由於之前接了滿多小案子是在做爬蟲+LINE Bot 應用,我一直認為爬蟲這個技術或是流程應該是廣為人知,加上現在各種 AI 工具像是ChatGPT, Bard, etc... 的崛起,現在已經沒有遇過有人在問爬蟲的案子了,今天遇到了朋友問我有關於爬蟲爬 MLB 網站資料的問題,當時我很簡單的用瀏覽器的開發者管理工具裡的 Network 看了一下傳給我的 MLB 網站,跟他說了那個 API 可以取到他要的資料,但對方一臉
的表情後才發覺原來很多人不清楚怎麼開始製作爬蟲或是誤會了只要學 python 就會爬蟲的這種迷思,也因為這樣才會有這一篇文章的誕生。
迷思誤區#
- 只要學 Python 我就可以 Do Re Mi So 的把爬蟲寫出來
- 只要會寫 Code 我就可以登登登的爬到我想要的資料
- 只要學一套爬蟲技術就可以爬到任意我想要的東西
闢謠專區#
- 爬蟲是程式而程式是人寫的,並不會因為你不懂流程還能執行出正確結果
- 爬蟲只是一個幫助你自動化抓取資料的流程,前提是你要懂這個流程
- 要寫爬蟲前必須要可以手動去取得資料並且知道整個 Flow
示範寫爬蟲程式流程 - 以MLB 網站為例#
目標資料:10 年的球賽數據
目標來源:https://www.mlb.com/gameday/braves-vs-phillies/2023/09/11/717664/final/box
抓取資料內容:
WP:Alvarado.
HBP:Harris II, M (by Walker, T); Riley, A (by Walker, T).
Pitches-strikes:Morton, C 104-64; Lee, D 8-4; Jiménez, J 11-8; Minter 13-9; Iglesias, R 15-10; Yates 14-9; Walker, T 103-53; Bellatti 21-13; Covey 18-14; Alvarado 16-13.
Groundouts-flyouts:Morton, C 2-4; Lee, D 1-0; Jiménez, J 0-0; Minter 1-0; Iglesias, R 0-2; Yates 1-0; Walker, T 3-2; Bellatti 0-2; Covey 5-0; Alvarado 2-0.
Batters faced:Morton, C 27; Lee, D 3; Jiménez, J 2; Minter 3; Iglesias, R 5; Yates 3; Walker, T 26; Bellatti 8; Covey 7; Alvarado 5.
Inherited runners-scored:Bellatti 1-1.
Umpires:HP: Larry Vanover. 1B: Jacob Metz. 2B: Edwin Moscoso. 3B: D.J. Reyburn.
Weather:78 degrees, Sunny.
Wind:4 mph, Out To CF.
First pitch:1:08 PM.
T:3:08.
Att:30,572.
Venue:Citizens Bank Park.
September 11, 2023
第一步:找出資料來源#
由於是要抓取頁面上的數據,基本上網頁的數據源自於兩個地方
- SSR - 由後端回傳整個 HTML 網頁
- CSR - 由前端呼叫後端 API 取得數據在 Render 到網頁上
這邊我會使用瀏覽器的開發人員工具 > 網路 並且將 filter 切至 Fetch/XHR 然後重新整理網頁,在逐一檢查每一筆 request 的 response,經過每一筆的檢查後我們發現這個 request 應該是我們要找的 API,因為他的 response 有著網頁上顯示的資料
點擊標頭我們可以看到他的 API URL
https://ws.statsapi.mlb.com/api/v1.1/game/717664/feed/live?language=en
我們合理懷疑 717664 是 Game 的編號,要怎麼確認可以看一下網頁的網址
https://www.mlb.com/gameday/braves-vs-phillies/2023/09/11/717664/final/box
看起來 717664 是 Game 的編號,看其他場比賽也是這樣
https://www.mlb.com/gameday/braves-vs-phillies/2023/09/11/716590/final/box
https://ws.statsapi.mlb.com/api/v1.1/game/716590/feed/live?language=en
第二步:確認資料來源是否含有自己所需要的數據#
我們可以瀏覽 > 右鍵 > 複製 Object或是回應 > 全選 > 複製
簡單的話可以貼到線上的 Json online parser (jsoneditoronline, json.parser) 檢查,可以使用 Ctrl + F 搜尋關鍵字,我們可以看到在 API response json 中的 info 有著上面我們要抓取的數據,Bingo!
第三步:使用外部工具驗證 API 可行性#
這邊我們需要確認 API 是否需要特別的認證或是其他東西使他只有該網頁可以存取,我們可以使用Postman來測試,如果不會使用這個工具的可以搜尋一下教學文章。
我們確認了只要可以透過呼叫 API 就能取得資訊,這樣就可以進行下一步了。
第四步:如何連續獲取不同場次資訊#
程式是人設計的,所以要寫出爬蟲自己一定要知道整個流程才有辦法寫出來,並不是看個書看個教學就能夠寫出來,以這個例子為例,爬蟲的整體邏輯應該會是
- 抓取 10 年所有比賽編號
- 將 10 年的比賽編號透過上面的 API 去得到所有比賽的資訊
- 將比賽的資訊儲存在變數中,再將這些資訊寫入到 CSV
第一步是該如何抓取所有場次比賽編號呢,我們可以透過這個網址去找上一層的網址
https://www.mlb.com/gameday/braves-vs-phillies/2023/09/11/717664/final/box
使用
https://www.mlb.com/gameday/
結果導頁至
https://www.mlb.com/scores
就是這邊了,再來使用剛剛方式去找到是那個 reques 取得這些資料的,
找到 API 了
https://bdfed.stitch.mlbinfra.com/bdfed/transform-mlb-scoreboard?stitch_env=prod&sortTemplate=4&sportId=1&&sportId=51&startDate=2023-09-11&endDate=2023-09-11&gameType=E&&gameType=S&&gameType=R&&gameType=F&&gameType=D&&gameType=L&&gameType=W&&gameType=A&&gameType=C&language=en&leagueId=104&&leagueId=103&&leagueId=160&contextTeamId=
將這串 API URL 丟到 postman 中可以看到左邊有著呼叫該 URL 所夾帶的參數,有些參數可能不清楚功能但保險起見不要隨意更改
但我們可以看到當中有startDate & endDate,我們可以更改一下測試看看是不是可以抓取多天資料,如果可以的話可以加速我們抓取資料的速度。
Bingo! 我們可以一次取回 2023-09-01 ~ 2023-09-11 的所有比賽資料,但呼叫的時間長達 8 秒以及 response 資料量很大,這邊可能會以最多一次一個月的方式來抓取資料,太多了可能會 timeout。
第五步:將這些流程轉化為程式邏輯#
這邊還先不要急著寫 Code,我們要先將上面的流程轉化成程式的流程與邏輯在來寫,這邊簡單的示範程式碼該怎麼轉化成爬蟲流程
import 所需的lib ex: requests
import calendar
# 宣告全域變數
# 宣告scores_api_url
scores_api_url = "https://bdfed.stitch.mlbinfra.com/bdfed/transform-mlb-scoreboard"
# 宣告game_api_url 將會根據game編號改變的地方改成一個特定取代值game_id
game_api_url = "https://ws.statsapi.mlb.com/api/v1.1/game/game_id/feed/live?language=en"
# 宣告開始抓取年
start_year = "2012"
# 宣告結束抓取年
end_year = "2022"
# 存放所有GameId資料
game_data = []
# 主要程式區塊
def main:
day_list = get_month()
# loop 所有月份
for seDay in day_list:
# 透過get_scores_data抓取該月第一天至最後一天的所有比賽編號
gameId_list = get_scores_data(seDay[0], seDay[1])
# 透過get_game_date抓取所有gameId資料並且增加進去game_data
game_data = game_data + get_game_date(gameId_list)
# 將game_data 儲存至csv中
...
# 取得開始年到結束年的每月第一天和最後一天
def get_month() -> list:
result = []
for year in range(start_year, end_year + 1):
for month in range(1, 13):
# 取得該月的第一天和總天數
_, days_in_month = calendar.monthrange(year, month)
# 第一天
first_day = f"{year}-{month:02}-01"
# 最後一天
last_day = f"{year}-{month:02}-{days_in_month:02}"
result.append((first_day, last_day))
return result
# 抓取scores日期函數
def get_scores_data(sDay: str, eDay: str) -> list:
gameId_list = []
# 替換正確的URL
url = scores_api_url
# 設定payload
payload = {
"stitch_env": "prod",
"sortTemplate": "4",
"sportId": "1",
"sportId": "51",
"startDate": sDay,
"endDate": eDay,
"gameType": "E",
...
}
res = get_api(url, payload)
if res != {}:
# loop 該天賽事list
for game_list in res.get("dates"):
# loop 該天賽事
for game in game_list:
gameId_list.append(game.get("gamePk"))
return gameId_list
# 抓取game資料函數
def get_game_date(gameId_list: list) -> list:
result = []
# loop gameId_list 取得所有gameId 資料
for gameId in gameId_list:
# 替換正確的gameId
url = game_api_url.replace("game_id", str(gameId))
res = get_api(url, {})
# 呼叫API有取回值
if res != {}:
# 以下實作從res dict中取得所需資訊
...
result.append(gameData)
return result
# 呼叫API函數
def get_api(url: str, payload: dict) -> dict:
res = request.get(url, params=payload)
if res.status_code == 200:
return res.json()
else:
return {}
# 程式進入點
if __name__ == '__main__':
main()
整體抓取 10 年份比賽資料大致上程式長這樣,有些功能沒有仔細實作,留給大家試試看,在完成之後可能會有許多地方可以優化或是會遇到問題,以下是可能遇到的問題和優化方向。
- 可能會遇到資料太大 request timeout
- 可能會遇到瞬間大量 API 存取使對方 server 拒絕存取
- 暫存資料過大導致程式 crash
- 未實作將資料儲存成 csv
- 可以將執行過的資料儲存起來,下次啟動不要從頭重複的執行
- 可以使用 multiprocessing 加速處理