HKJC擷取足球數據的完整教學指南
- 9月19日
- 讀畢需時 14 分鐘
香港賽馬會(HKJC)的足球網站提供豐富的即時賠率和賽事數據,但需要透過特定的方式才能擷取這些資料。本教學將從基礎開始,介紹如何利用 HKJC 網站背後的 GraphQL API 來抓取足球比賽資訊和賠率,並說明如何用 fetch 或 Python 的 requests 模組模擬網站行為(包括必要的 HTTP 標頭設定)。接著,我們會利用提供的 Python 腳本 hkjc_graphql.py,一步步示範如何提取比賽基本資料與詳細賠率數據,轉換成 pandas 資料表格式。還會演示如何使用腳本中的 simulate_browser_behavior() 函數來模擬瀏覽器請求順序,並深入解釋 extract_match_info() 及 display_odds_table() 兩個函數的功能與輸出樣貌。最後,我們將討論一些延伸應用,例如即時賠率變化監控、簡單統計分析,以及建構即時儀表板。
HKJC 網站數據來源結構:GraphQL API 簡介
傳統上,抓取 HKJC 足球賽事資料可能需要解析網站 HTML。但自從 2024 年起,馬會網站改用 GraphQL API 傳送資料,使我們可以直接取得 JSON 格式的數據。GraphQL 提供單一接口供客戶端按需查詢資料,避免多次請求不同資源。
GraphQL 端點 (Endpoint): 經過觀察 HKJC 足球網站的網路流量,可以發現資料是透過 https://info.cld.hkjc.com/graphql/base/ 傳遞。這是 GraphQL 請求的目標 URL。所有賽事和賠率資料查詢都是向這個網址發送 POST 請求來完成的。
查詢結構: HKJC 定義了一些 GraphQL 查詢 (query) 模板。例如,一個名為 allMatchList 的查詢可以取得所有即將進行或進行中的比賽列表,包括每場比賽的基本資訊;另一個查詢則可在提供參數後取得比賽的詳細資訊及各種投注玩法的賠率(包含固定賠率足球投注的多種玩法)。GraphQL 容許在請求中傳入參數(variables)來過濾或選擇需要的資料。例如,我們可以指定某段日期範圍或特定聯賽的比賽,或是要求返回哪些類型的賠率。
資料範例: 透過 GraphQL,我們可以取得每場比賽的 比賽ID、前端顯示ID、比賽日期時間、狀態(如未開賽、進行中、已完場)、主客隊名稱、聯賽名稱、比數與角球數等,以及該場比賽有哪些投注彩池(如一般投注池、走地投注池)。若請求包含賠率,則還會返回各種投注類型(如主客和、讓球等)的賠率資訊。
GraphQL 的優點在於可以一併取得結構化的資料,不需要針對每場比賽分開抓取多次。但同時也需要我們模擬出正確的請求格式與環境,才能順利拿到資料。下一節將介紹如何模擬網站發送請求,包括使用瀏覽器 fetch 與 Python requests 的方法。
模擬網站行為的 HTTP 請求方法
要成功獲取 HKJC 足球的 GraphQL 資料,我們需要讓自己的請求看起來像是由正常的使用者瀏覽器發出。這涉及兩個重點:請求順序和HTTP 標頭。
請求順序與預檢 (Preflight): 由於 info.cld.hkjc.com 與主網站域名不同,瀏覽器在發送實際 GraphQL 查詢前,會自動發送一個 OPTIONS 預檢請求 以通過 CORS 驗證。我們在自行模擬時也需考慮這點。實際流程通常是:先對 GraphQL 接口發送一個 OPTIONS 請求確認服務允許,再發送正式的 POST 查詢,瀏覽器每次切換查詢類型或域時可能重複此步驟。透過瀏覽器開發者工具記錄,可以看到馬會網站在載入足球頁面時,送出了多個請求,包括比賽列表查詢、OPTIONS 預檢以及詳細賠率查詢等。稍後我們會示範如何用 simulate_browser_behavior() 函數重現這些步驟。
必要的 Header 設定: 除了順序,服務端也會檢查一些請求標頭來判斷請求是否合法。例如,HKJC 的 GraphQL API 要求有正確的 User-Agent(瀏覽器標識)、Referer(來源頁必須是 bet.hkjc.com),以及 CORS 相關的 sec-fetch-site、sec-fetch-mode 等標頭。我們需要模仿這些才可能得到回應。以下是關鍵的 header 範例:
User-Agent:偽裝成主流瀏覽器,例如 Chrome。腳本中設置的值如 "Mozilla/5.0 (Windows NT 10.0; Win64; x64)...Chrome/140.0.0.0 Safari/537.36"。
Referer:必須指向馬會足球投注主頁,例如 "https://bet.hkjc.com/" 。
Content-Type:使用 "application/json" 表明我們以 JSON 格式傳送 GraphQL 查詢。
其他模擬瀏覽器環境的標頭,如 accept、accept-language、sec-fetch-site: same-site、sec-fetch-mode: cors 等,也一併加上。這些值可以直接從實際瀏覽器請求中複製,用以降低被拒的可能性。
使用 JavaScript 的 fetch,你可以如下撰寫請求(以 GraphQL 查詢所有比賽列表為例):
fetch("https://info.cld.hkjc.com/graphql/base/", {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)...",
"Referer": "https://bet.hkjc.com/",
// ...其他必要 header
},
body: JSON.stringify({
query: `query allMatchList { ... }`, // GraphQL 查詢字串
variables: {} // 查詢參數
})
})
.then(res => res.json())
.then(data => console.log(data));
在 Python 中,使用 requests 模組會更方便。我們可以建立一個 Session 物件並透過 session.post 發送請求,同樣需要包含上述標頭與 JSON 主體。例如:
import requests, json
session = requests.Session()
session.headers.update({
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)...",
"Referer": "https://bet.hkjc.com/",
# ...其他 header
})
url = "https://info.cld.hkjc.com/graphql/base/"
payload = {
"query": "query allMatchList { ... }",
"variables": {}
}
response = session.post(url, json=payload)
data = response.json()
上述 payload 中 "query" 欄位是 GraphQL 查詢字串,我們可以從開發者工具中複製或參考提供的腳本內建的查詢模板;"variables" 則是提供給查詢的參數(此例為空,表示使用預設條件取得所有賽事)。
當我們正確模擬了 header 並按順序發送請求後,服務端就會返回 JSON 格式的結果。我們下一步將介紹如何利用提供的 Python 腳本來簡化這個過程,快速抓取並整理出我們需要的比賽資訊和賠率數據。
Python 腳本教學:提取比賽基本資料與賠率數據
為方便起見,我們使用預先編寫好的 hkjc_graphql.py 腳本。這個腳本封裝了一個 HKJCGraphQLClient 類別,裡面定義了取得資料的各種方法。我們可以直接使用這些方法,而不必從頭手動構造請求。以下是此腳本的主要功能分解:
建立客戶端並初始化: HKJCGraphQLClient 建構時會初始化 requests.Session 並設定好前述的必要 header,指向 GraphQL 基地URL。這意味著你只需實例化 client,就已經準備好發送請求了:
client = HKJCGraphQLClient()
(在此之前請先用 from hkjc_graphql import HKJCGraphQLClient 匯入類別。)
基本比賽列表查詢: client.send_basic_match_list_request() 方法會向 GraphQL 發送基本賽事列表查詢,取得目前所有足球比賽的基本資訊(不含賠率)。若請求成功,會返回一個 Python 字典(對應 JSON 結構)。其結構形如:
{ "data": { "matches": [ {...比賽1資料...}, {...比賽2資料...}, ... ] } }
每個比賽物件包含如 比賽ID、主隊/客隊名稱、比賽日期時間、聯賽名稱、即時比數等欄位。您可以檢視 data['matches'] 清單的長度來確認抓取了幾場比賽。
詳細比賽列表查詢(含賠率): client.send_detailed_match_list_request() 方法則發送詳細賽事列表查詢,預設會要求返回賠率資訊。內部實作上,它使用一組預設的查詢參數,例如:
fbOddsTypes / fbOddsTypesM:設定需要的投注賠率種類,預設值為 ["HAD", "EHA"]。HAD 代表主客和(即 1X2 彩池,主勝/和局/客勝);EHA 代表早場主客和(提前開售的主客和賠率)。你可以視需要更改這個列表以獲取不同玩法的賠率(例如讓球盤 HHA、波膽 CRS 等等,馬會支援的足球玩法代碼非常多)。
startIndex 與 endIndex:預設為 1 和 60,即抓取前 60 場比賽資料。如果預期比賽很多,可以調整這範圍或分批查詢。
其他參數如 inplayOnly(是否只要走地賽事)、featuredMatchesOnly(是否只要精選賽事)等,預設為 False,表示不過濾,取所有比賽。
調用此方法會返回包含賠率的完整比賽資料結構。除了前述基本資訊外,JSON 中每場比賽還會多一個 foPools 清單,其中每個元素對應一種投注類型的賠率池。例如,若預設請求了 HAD 和 EHA,則每場比賽的 foPools 大概會有兩個元素(一個 HAD 賠率池、一個 EHA 賠率池)。每個賠率池中又包含該玩法的賠率細節,例如各個選項(主勝/和局/客勝)的賠率值。
資料轉換與顯示: 腳本提供了便利函數將上述 JSON 數據轉成易讀的表格形式:
extract_match_info(data): 接收前述 API 返回的 data(包含 matches 列表),會先打印總比賽場數,然後調用內部的 display_matches_table() 和 display_odds_table(),分別將比賽基本資訊和賠率資訊整理成表格輸出。
display_matches_table(matches): 將比賽清單轉換為 pandas DataFrame 並輸出文字表格,包括欄位:比賽ID、前端ID、主隊、客隊、聯賽、比賽日期、開球時間、狀態、場地、主客隊比分和角球數、是否有正常/走地投注池、賠率池數量、最後更新時間等。
display_odds_table(matches): 將每場比賽的 foPools 賠率資訊展開為記錄列表,再轉為 DataFrame 輸出。其欄位包括:比賽(對戰隊伍)、投注類型(玩法名稱,如「主客和」)、賠率類型(玩法代碼,如 HAD)、是否走地、線路條件(如讓球盤的讓球數,在主客和中通常為 N/A)、組合字串(內部標識賠率選項組合的代碼)、選項名稱(投注選項,如「主勝」、「和局」、「客勝」等)、當前賠率、組合狀態(選項是否開售/封盤等狀態)、提早結算(是否提供提前結算功能)、投注池狀態(整個彩池的狀態,如開售中或已關閉)、更新時間等。
以下是這兩個表格輸出的範例(以假想資料示意):
==========================================================================
🏆 HKJC 比賽列表
==========================================================================
比賽ID 前端ID 主隊 客隊 聯賽 比賽日期 開球時間 狀態 場地 主隊比分 客隊比分 主隊角球 客隊角球 正常投注池 走地投注池 賠率池數量 更新時間
0 12345 98765 利物浦 曼城 英格蘭超級聯賽 2025-09-19 03:00 Scheduled 安菲爾德 0 0 0 0 是 否 2 2025-09-18T12:00:00Z
1 12346 98766 球隊A 球隊B 國際友誼賽 2025-09-20 20:00 Scheduled 香港大球場 0 0 0 0 是 否 1 2025-09-18T12:00:00Z
==========================================================================
上表展示了兩場比賽的基本資料:例如比賽ID 12345 是英超聯賽中的「利物浦 vs 曼城」,狀態Scheduled(即未開賽),開球時間為 2025-09-19 凌晨3點(時區可能為香港時間UTC+8),這場比賽有正常投注池(是)但沒有走地投注池(否),並且有2種賠率池(因為我們請求了HAD和EHA)。第二場則是友誼賽「球隊A vs 球隊B」,只有1種賠率池(假設只適用主客和)。
接著是賠率資訊表格範例:
==========================================================================
💰 HKJC 賠率信息
==========================================================================
比賽 投注類型 賠率類型 是否走地 線路條件 組合字串 選項名稱 當前賠率 組合狀態 提早結算 投注池狀態 更新時間
0 利物浦 vs 曼城 主客和 HAD 否 N/A HADH 主勝 1.90 開售中 否 開售中 2025-09-18T12:00:00Z
1 利物浦 vs 曼城 主客和 HAD 否 N/A HADX 和局 3.50 開售中 否 開售中 2025-09-18T12:00:00Z
2 利物浦 vs 曼城 主客和 HAD 否 N/A HADA 客勝 4.00 開售中 否 開售中 2025-09-18T12:00:00Z
3 利物浦 vs 曼城 早場主客和 EHA 否 N/A EHAH 主勝 2.00 開售中 否 開售中 2025-09-18T08:00:00Z
4 利物浦 vs 曼城 早場主客和 EHA 否 N/A EHAX 和局 3.20 開售中 否 開售中 2025-09-18T08:00:00Z
5 利物浦 vs 曼城 早場主客和 EHA 否 N/A EHAA 客勝 3.60 開售中 否 開售中 2025-09-18T08:00:00Z
6 球隊A vs 球隊B 主客和 HAD 否 N/A HADH 主勝 2.30 開售中 否 開售中 2025-09-18T12:00:00Z
7 球隊A vs 球隊B 主客和 HAD 否 N/A HADX 和局 3.10 開售中 否 開售中 2025-09-18T12:00:00Z
8 球隊A vs 球隊B 主客和 HAD 否 N/A HADA 客勝 2.80 開售中 否 開售中 2025-09-18T12:00:00Z
==========================================================================
上表顯示「利物浦 vs 曼城」這場比賽的賠率細節:包括主客和 (HAD) 及早場主客和 (EHA) 兩種投注,每種投注各有三個選項(主勝、和局、客勝)及其對應賠率。例如,主客和的當前賠率顯示主勝1.90倍、和局3.50倍、客勝4.00倍。因為這場比賽還未開始且正在接受投注,所以組合狀態和投注池狀態都顯示「開售中」,沒有提早結算。早場主客和(提前開售的固定賠率)也提供了賠率,可能是在比賽日稍早時刻更新的(其更新時間不同)。第二場「球隊A vs 球隊B」只有主客和一種投注,賠率如表所示。
注意: 實際輸出時,表格會依照當前抓取到的資料自適應寬度,上述內容為示範性的靜態範例。透過這些表格,我們可以方便地觀察所有比賽的情況和賠率。在代碼中,這些表格是透過 pandas.DataFrame 轉成字串列印出來的,若想進一步程式化處理,也可以直接使用 DataFrame 物件進行分析或導出。
模擬瀏覽器請求順序:使用 simulate_browser_behavior()
前面提到正確的請求順序對於獲取資料很重要。HKJCGraphQLClient 提供了一個 simulate_browser_behavior() 函數,可以自動按照網站原本的請求順序執行一系列請求。我們建議初學者先跑這個方法來觀察過程。使用方法很簡單:
client = HKJCGraphQLClient()
client.simulate_browser_behavior()
執行後,您將在控制台看到類似以下的輸出日誌:
=== 開始模擬 HKJC GraphQL 請求 ===
第 1 個請求: 基本比賽列表
基本比賽列表請求狀態: 200
- 獲取到 45 場比賽
第 2 個請求: OPTIONS 預檢
OPTIONS 請求狀態: 204
第 3 個請求: 詳細比賽列表
詳細比賽列表請求狀態: 200
- 獲取到 45 場比賽(含賠率)
- 首場比賽: 利物浦 vs 曼城
第 4 個請求: OPTIONS 預檢
OPTIONS 請求狀態: 204
第 5 個請求: 基本比賽列表
基本比賽列表請求狀態: 200
- 獲取到 45 場比賽
=== 請求模擬完成 ===
上述日誌說明了發生了什麼:首先取得基本比賽列表,然後發送預檢,接著取得含賠率的詳細比賽列表,再次預檢,最後又抓取了一次基本列表。這和我們從瀏覽器觀察到的請求順序一致。整個過程執行後,simulate_browser_behavior() 會將所有請求結果(JSON 資料或狀態碼)收集在一個列表中返回。如果我們想取得最終的詳細數據,可以從返回結果中提取,例如:
results = client.simulate_browser_behavior()
detailed_data = None
for res in results:
if res.get("type") == "detailed":
detailed_data = res.get("data")
break
這段代碼在模擬過程完成後,尋找類型為 "detailed" 的結果,即我們想要的含賠率賽事資料(與直接使用 send_detailed_match_list_request() 的結果相同)。有了 detailed_data,接下來就可以調用前述的 extract_match_info(detailed_data) 來打印表格,或自行處理 detailed_data 中的 JSON。
透過 simulate_browser_behavior(),我們不僅拿到了資料,也更清晰了解瀏覽器在背後都做了哪些請求。如果你對 HTTP 請求流程不熟悉,建議先從這裡入手,確認每步都得到預期回應,然後再嘗試進一步的資料處理。
函數解析:extract_match_info() 與 display_odds_table() 的功能
現在我們深入講解兩個在腳本中經常使用的函數,了解它們具體做了什麼以及如何利用它們的輸出。
extract_match_info(data)
功能: 這個函數的目的在於從完整的資料集中提取有用資訊並友善地顯示給使用者。它接收一個 GraphQL 回傳的資料(即包含 data -> matches 的字典),內部步驟如下:
驗證輸入:檢查傳入的物件是否包含 'data' 和 'matches' 欄位。如果沒有,打印「無效的響應數據」並返回。
比賽場數提示:計算 matches 列表長度,打印「找到 X 場比賽」。如果比賽數為0則提示「沒有比賽數據」並結束。
顯示基本資訊表格:調用 display_matches_table(matches) 將比賽基本資料輸出成表格。這個步驟會列出每場比賽的關鍵資訊(欄位前面已詳述),方便快速瀏覽有哪些比賽及其狀態。
顯示賠率資訊表格:緊接著調用 display_odds_table(matches)。該函數會掃描每場比賽的 foPools,如果有賠率資料就整理輸出。如沒有找到任何賠率(例如查詢的資料不含賠率或比賽尚未開盤等),函數內會打印警告「⚠️ 沒有找到賠率信息」。
簡而言之,extract_match_info 是將 JSON 原始數據 -> 結構化表格輸出 這件事一鍵完成。它本身不返回值,而是直接打印出結果,適合作為教學演示或快速檢視資料之用。
display_odds_table(matches)
功能: 逐場比賽提取投注賠率相關資訊並以表格呈現。它假設傳入的 matches 列表中,每個元素可能含有 foPools 資料。主要流程:
迭代比賽:對於每一場比賽,先跳過不存在或無效的項目。取出主隊與客隊名稱作為日後欄位的一部分。
檢查賠率池:讀取比賽的 foPools 列表。如果該比賽沒有任何賠率池(例如某場比賽暫未開售固定賠率投注),則跳過。
迭代賠率池與組合:每個賠率池可能對應一種投注玩法(如 HAD)。在池裡會有一組或多組 lines(例如某些玩法可能有多條讓球線,主客和則通常只有一條預設線)。對每條 line 中的 combinations 進行遍歷。每個 combination 代表一組投注選項組合,裡面包含一個 selections 列表。對於簡單玩法,一個 combination 通常只有一個 selection,但對於例如「波膽」這種可能會把多個選項組合在一起計算賠率的玩法,結構稍複雜。不過我們無需深究,因為腳本已經一律將 每個 selection 都獨立輸出一行。
組裝賠率資訊:對於每個 selection,我們提取並組合以下欄位:
比賽:主隊 vs 客隊(已取得的名稱拼接)。
投注類型:來自賠率池的 name_ch(中文投注類型名稱,如「主客和」、「讓球」等)。
賠率類型:賠率池的 oddsType 代碼(如 HAD、HHA 等)。
是否走地:inplay 布林值轉成「是/否」,表示此彩池是否屬於走地盤。
線路條件:來自 line 的 condition(如讓球數、「大細」的界線球數等。如果沒有特殊條件則通常為 N/A)。
組合字串:combination 的 str 字段,代表該投注選項組合的編碼。例如對於主客和玩法,可能區分主勝、和局、客勝的組合代碼。
選項名稱:selection 的 name_ch(中文選項名)。對於主客和,這通常就是「主勝」、「和局」、「客勝」;讓球盤則可能是「讓球主勝」等;其他玩法會有不同選項名稱。
當前賠率:combination 的 currentOdds,即此選項目前的賠率值。
組合狀態:combination 的 status,表示這個選項的狀態。常見可能是 "Selling"(開售中)、"Suspended"(暫停受注)或類似狀態代碼。
提早結算:combination 的 offerEarlySettlement 布林值轉成「是/否」。若為「是」,表示投注此選項的單可在比賽未完場前提前結算(馬會提供的功能之一)。
投注池狀態:賠率池的 status。通常與組合狀態類似,反映整個彩池是否開放受注。
更新時間:賠率池的 updateAt。這是該彩池資料最後更新的時間戳記。
每個 selection 會生成一筆 odds_info 字典,收集在 odds_data 清單中。
輸出表格:當迭代完所有比賽後,如果 odds_data 非空,函數將其轉為 DataFrame 並格式化列印。列印時欄位順序即上述我們整理的順序,且會在表格上方打印「💰 HKJC 賠率信息」作為標題。若 odds_data 為空,則如前述會提示沒有賠率信息。
總的來說,display_odds_table 的輸出讓我們一目了然地查看每場比賽各投注類型的賠率。對於需要進一步分析的人員,這些資料也便於匯出或轉存,例如函數 save_data_to_csv(data) 就能把所有比賽的基本資訊和賠率資訊各自存成 CSV 檔案。
延伸應用與可能性
通過以上步驟,我們已經可以從香港賽馬會網站自動擷取足球比賽資料和賠率,並轉換成易於理解和分析的格式。這為各種進階應用鋪平了道路:
即時賠率變化監控: 因為 GraphQL API 返回的賠率數據包含更新時間,我們可以定期(例如每隔幾分鐘)抓取資料,將新賠率與上一次比較,偵測哪些比賽的哪些選項賠率發生了變動。一旦偵測到變化,可進行通知或記錄,用於分析賠率走勢。這對投注者而言可用來追蹤莊家信心的變化,或對研究人員分析比賽進程和市場反應也很有價值。
簡單統計分析: 有了大量比賽和賠率資料,可以做許多統計。例如統計不同聯賽中有多少場比賽在進行走地投注、各聯賽平均每場比賽開出多少種投注玩法,甚至可以計算某段時間內主勝賠率低於特定值的比賽比例等等。這些統計可以直接利用 pandas 來完成,因為我們已經有結構化的 DataFrame 資料。腳本中也示範了透過 display_tournament_summary(data) 來彙總每個聯賽的比賽數、開盤情況,這就是一個簡單的延伸應用例子。
即時儀表板建構: 若需要更直觀的展示,可以將擷取的資料接入一個儀表板應用。例如使用 Plotly Dash、Streamlit 等 Python 工具,或將資料傳送到前端框架進行視覺化。我們可以製作一個實時賠率看板,顯示當天所有比賽及其賠率,並在賠率變化時以高亮或箭頭標示。這對需要即時決策的用戶(如專業投注人士)非常有幫助。
歷史資料蒐集與分析: 除了即時用途,也可以將每天的賽事和賠率資料存庫,長期累積形成歷史數據。進一步可以分析莊家賠率與比賽結果的關聯、賠率概率是否符合實際勝率、尋找套利空間等。因為 HKJC 是香港本地極具代表性的莊家,其賠率數據對於學術研究體育賽事分析、投資模擬等都有參考價值。
最後,提醒讀者:在自動抓取網站資料時務必留意網站服務條款和機器人規則 (robots.txt)。雖然我們這裡探討的是公開資訊的擷取,但仍應避免過於頻繁的請求給伺服器造成壓力,建議在程式中適當加入延遲,或限制抓取頻率。在合理合法的範圍內,善用這些數據將能為我們帶來更深入的見解和應用。




