iT邦幫忙

2023 iThome 鐵人賽

DAY 22
0

在介紹完 pybaseball 用來獲得 Baseball Reference 的 functions 後,今天一樣接著來介紹他們的程式碼。

Beautiful Soup

上次在介紹 FanGraphs 的程式碼的時候有提到他們使用 lxml 這個 package 來爬網站上的資料,這次 BR 也需要爬蟲但他們是使用 BeautifulSoup 這個套件來爬,但他們使用的 html parser 一樣是使用 lxml 的 parser。

FanGraphs 的程式碼介紹可以回顧我之前的文章:

Day 09 - FanGraphs 原始碼 - iT 邦幫忙::一起幫忙解決難題,拯救 IT 人的一天 (ithome.com.tw)

另外想補充一個在 FanGraphs 介紹文裡面沒提到的,就是在獲得網頁 html 他們會使用 requests 這個套件,這次 BR 也需要先使用這個套件來獲得 html 再轉成我們要的資料型態。

class BRefSession

由於 BR 在呼叫 API 有次數限制,每分鐘假設超過十次 request 就會封鎖一小時,所以在獲得資料前會需要做一些設定來防止被封鎖的事情發生。pybaseball 的解決方法是會計算存在 class 裡的上次發送 request 的時間,看是否該次 request 還在不在限制內,如果超過限制就會使用 time 這個 python 套件裡的 sleep function 強制使用者等待到限制結束。

class BRefSession(singleton.Singleton):

		# 設定 class 的基本資料,預設是一分鐘最多打 10 次
    def __init__(self, max_requests_per_minute: int = 10) -> None:
        self.max_requests_per_minute = max_requests_per_minute
        self.last_request: Optional[datetime.datetime]  = None
        self.session = requests.Session()
    
    def get(self, url: str, **kwargs: Any) -> requests.Response:
        if self.last_request:
						# 計算距離上次 request 有多久時間
            delta = datetime.datetime.now() - self.last_request
						# 如果是一分鐘最多打 10 次,pybaseball 會設定讓你每十秒最多打一次來防止超過限制
            sleep_length = (60 / self.max_requests_per_minute) - delta.total_seconds()

						# 如果超過秒數強制 sleep
            if sleep_length > 0:
                sleep(sleep_length)

        self.last_request = datetime.datetime.now()

        return self.session.get(url, **kwargs)

打擊與投球資料

知道爬取 BR 的限制跟方法後,接下來來看各 functions 的設定,一樣可以先從 init.py 這個檔案知道 functions 的相關檔案是從哪裡來:

# 獲得投打資料的 functions
from .league_batting_stats import batting_stats_bref
from .league_batting_stats import batting_stats_range
from .league_batting_stats import bwar_bat
from .league_pitching_stats import pitching_stats_bref
from .league_pitching_stats import pitching_stats_range
from .league_pitching_stats import bwar_pitch

# 隊伍數據 FanGraphs 跟 BR 會共用一個檔案
from .team_batting import team_batting
from .team_batting import team_batting_bref
from .team_fielding import team_fielding
from .team_fielding import team_fielding_bref
from .team_pitching import team_pitching
from .team_pitching import team_pitching_bref

# 獲得分項資料的 function
from .split_stats import get_splits

打擊跟投球的程式碼分別在 league_batting_statsleague_pitching_stats 檔案裡,由於他們兩個的架構十分類似所以我就一起介紹。

進到檔案裡面可以發現 batting_stats_bref 會使用 batting_stats_range 獲得資料,只不過會先透過使用者傳入的球季資訊後,整理成 range 需要的 start_dtend_dt

def batting_stats_bref(season: Optional[int]=None) -> pd.DataFrame:
		# 如果沒傳入球季資訊會取得最近一季的年度
    if season is None:
        season = most_recent_season()
    str_season = str(season)

		# pybaseball 自己設定的球季開始與結束的日期,大多數都會在這範圍內
		# 因為這次使用的連結只會獲得例行賽資料所以不用擔心多獲得其他像是熱身賽的資料
    start_dt = str_season + '-03-01'
    end_dt = str_season + '-11-30'
    return(pitching_stats_range(start_dt, end_dt))

再來看到 batting_stats_range 裡的程式,會先確定輸入的年份有沒有問題,如果輸入的年份小於 2008 年會回傳錯誤。確認完畢後會使用 get_soupget_table 兩個 function 抓取網頁資料。get_soup 會獲得網頁 html,get_table 會利用得到的 html 整理成 DataFrame 的格式。

def get_soup(start_dt: Optional[Union[date, str]], end_dt: Optional[Union[date, str]]) -> BeautifulSoup:
    # 沒給日期會回傳錯誤
    if((start_dt is None) or (end_dt is None)):
        print('Error: a date range needs to be specified')
        return None
    url = "http://www.baseball-reference.com/leagues/daily.cgi?user_team=&bust_cache=&type=p&lastndays=7&dates=fromandto&fromandto={}.{}&level=mlb&franch=&stat=&stat_value=0".format(start_dt, end_dt)
    # 這邊的 session 是 class BRefSession
		s = session.get(url).content
    s = str(s).encode()
    return BeautifulSoup(s, features="lxml")

# 從 get_soup 獲得 Parse 完的 html 後做資料處理
def get_table(soup: BeautifulSoup) -> pd.DataFrame:
    table = soup.find_all('table')[0]
    raw_data = []
    headings = [th.get_text() for th in table.find("tr").find_all("th")][1:]
    headings.append("mlbID")
    raw_data.append(headings)
    table_body = table.find('tbody')
    rows = table_body.find_all('tr')
    for row in rows:
        cols = row.find_all('td')
        row_anchor = row.find("a")
        mlbid = row_anchor["href"].split("mlb_ID=")[-1] if row_anchor else pd.NA  # ID str or nan
        cols = [ele.text.strip() for ele in cols]
        cols.append(mlbid)
        raw_data.append([ele for ele in cols])
    data = pd.DataFrame(raw_data)
    data = data.rename(columns=data.iloc[0])
    data = data.reindex(data.index.drop(0))
    return data

bwar_bat 又更簡單,直接透過 URL get 資料後轉成 utf-8 格式的字串後直接使用 Pandas 的 pd.read_csv 轉成 DataFrame

另外要注意一點,如果搜尋的範圍裡找不到資料,或造成 table = soup.find_all('table')[0] 這一行發生錯誤因為找不到 table,所以看到錯誤訊息有 **IndexError: list index out of range** 通常都是沒搜尋到資料的關係。這邊有個 Issue 有提到這樣的情況:

Issue with batting_stats_range() function - IndexError: list index out of range · Issue #364 · jldbc/pybaseball (github.com)

因為隊伍數據跟球員數據的差別只在使用的爬蟲頁面不同所以不多做介紹,get_splits 其實也差不多,但因為他會需要整理不同分項進 index,處理上會看起來很複雜,但其實他就是看提供的 table 有甚麼並多塞項目進 index 裡整理。關於 Pandas 裡的 index 介紹可以參考這篇:

[Day09]Pandas索引的運用! - iT 邦幫忙::一起幫忙解決難題,拯救 IT 人的一天 (ithome.com.tw)

本日小結

今天介紹 pybaseball 裡 BR 相關的程式碼,感謝大家耐心地看完。可以發現他們都是使用網頁取得資料,所以如果之後自己發現其他可以使用的連結或 API,就可以參考他們的寫法去整理成 DataFrame,再製作自己想要的客製化數據。

終於介紹完三大棒球數據網站在 pybaseball 裡使用的 functions,接下來會花幾天再多介紹 pybaseball 裡面其他可以使用的方法,如果有其他篇幅會介紹如何貢獻或是一些綜合運用。


上一篇
Day 21 - Baseball Reference 進階數據解釋
下一篇
Day 23 - Top Prospects
系列文
Python 棒球數據分析套件 pybaseball 介紹30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言