:抓取山東新高考選考科目要求數(shù)據(jù))
2020年山東新高考第一年落地后高校專業(yè)的選考科目要求成了考生和家長最關(guān)心的數(shù)據(jù)之一。當(dāng)時招考信息網(wǎng)站提供了一個在線查詢系統(tǒng)按學(xué)校逐頁展示專業(yè)類的選考要求但頁面沒有導(dǎo)出功能翻上幾百頁復(fù)制粘貼顯然是體力活。我接到一個整理全量數(shù)據(jù)的小任務(wù)把擬在山東招生的普通高校專業(yè)類選考科目要求全部抓下來做成一張能篩選的本地表格。文章里沒有復(fù)雜的框架就是Python加requests加BeautifulSoup的一整套實戰(zhàn)記錄適合剛接觸爬蟲、想拿真實站點練手的朋友參考。1. 需求拆解選考科目要求數(shù)據(jù)到底長什么樣1.1 先理解“選考科目要求”的業(yè)務(wù)含義山東新高考實行“33”模式除了語數(shù)外三門必考考生要從思想政治、歷史、地理、物理、化學(xué)、生物六門里選三門參加等級考。而高校在招生時會針對每個專業(yè)類提出選考科目要求。常見的有幾種形式不限選科、必須選考物理、化學(xué)和生物均須選考、物理或化學(xué)任選一門等。這直接決定考生能不能報某個專業(yè)所以數(shù)據(jù)本身的準(zhǔn)確性特別關(guān)鍵。爬蟲爬下來之后如果字段錯位、漏行、專業(yè)名稱被截斷那整理出來的表就沒法用。做這個項目第一步不是寫代碼而是把手上的資料先理清楚需要保存哪些字段、頁面上的展示規(guī)則是什么、選考科目要求有哪些固定寫法。我當(dāng)時列了六個核心字段分別是年份、院校代碼、院校名稱、專業(yè)類名稱、選考科目要求、備注。年份單獨拎出來是因為查詢系統(tǒng)里可能默認(rèn)展示當(dāng)年數(shù)據(jù)保存下來方便以后做多年度對比。備注字段不是每個頁面都有但有些專業(yè)會寫明“按大類招生”或“其中某方向有特殊要求”這種補充信息不能丟。1.2 觀察頁面結(jié)構(gòu)先花十分鐘省掉兩小時拿到查詢地址后我沒急著寫爬蟲而是先用瀏覽器打開頁面按F12打開開發(fā)者工具看兩個東西頁面上的表格結(jié)構(gòu)以及翻頁時發(fā)送的請求。這類查詢系統(tǒng)分為幾種典型形態(tài)。第一種是表格數(shù)據(jù)直接渲染在HTML里翻頁鏈接就是帶page參數(shù)的普通網(wǎng)頁地址這種最簡單。第二種是頁面先給一個框架數(shù)據(jù)通過AJAX接口返回JSON再動態(tài)填充到表格這種就比解析HTML麻煩一些但接口返回的數(shù)據(jù)往往更規(guī)整。第三種是點擊查詢后才出數(shù)據(jù)而且用的是POST請求參數(shù)藏在請求體里需要從網(wǎng)絡(luò)面板里找Form Data。2020年這個查詢系統(tǒng)屬于第一種和第二種混合的形態(tài)首屏能看到表格但翻頁是通過一個固定地址帶page和pageSize參數(shù)完成的。我在Network面板里選中XHR和Doc兩個過濾條件刷新頁面后找到真實請求確認(rèn)返回的是HTML片段而不是JSON。既然返回的是HTML就直接用BeautifulSoup解析比處理嵌套JSON還直觀。2. 動手前的準(zhǔn)備技術(shù)選型與字段規(guī)劃2.1 為什么是requests加BeautifulSoup而不是Scrapy做這個爬蟲之前我順手評估過工具。Scrapy功能確實強自帶并發(fā)調(diào)度、去重、中間件適合做大規(guī)模分布式采集。但這個任務(wù)的數(shù)據(jù)量頂多幾千條記錄、幾十個頁面用Scrapy有點小題大做而且調(diào)試起來不如腳本直觀。requests負(fù)責(zé)發(fā)HTTP請求BeautifulSoup負(fù)責(zé)解析HTML兩者配合非常輕。對于新手來說這套組合的學(xué)習(xí)曲線也最平緩代碼量小哪里出錯看哪里。最關(guān)鍵的是這類教育類查詢網(wǎng)站結(jié)構(gòu)相對穩(wěn)定不會頻繁調(diào)整頁面不需要Scrapy那種強大的健壯性設(shè)計。另外我特意沒有用Selenium。當(dāng)時有同事建議用瀏覽器自動化模擬點擊因為覺得頁面是動態(tài)的。但實際看Network面板之后發(fā)現(xiàn)數(shù)據(jù)內(nèi)容就在HTML里沒必要啟動一個瀏覽器進程去渲染那樣反而慢還要處理等待時間、瀏覽器版本兼容的問題。能不用瀏覽器就不用瀏覽器這是小項目提效的第一原則。2.2 環(huán)境準(zhǔn)備與數(shù)據(jù)字段設(shè)計本機環(huán)境是Python 3.8Windows系統(tǒng)用的庫只有requests和beautifulsoup4。安裝命令很簡單pip install requests beautifulsoup4如果你想在原有環(huán)境里不污染全局可以先建一個虛擬環(huán)境python -m venv venv venv\Scripts\activate pip install requests beautifulsoup4字段設(shè)計這塊我一開始就在代碼里寫了一個字段列表后面解析和寫入都用它做標(biāo)準(zhǔn)。這樣即使網(wǎng)站顯示順序變了只要調(diào)整解析邏輯最終輸出的一致性不會亂。字段名類型說明yearstr招生年份固定為2020school_codestr院校代碼原始文本保留school_namestr院校名稱major_namestr專業(yè)類名稱subject_requirementstr選考科目要求remarkstr備注可能為空字段類型統(tǒng)一用字符串是刻意的。后續(xù)如果要做篩選和統(tǒng)計院校代碼、院校名稱這些本質(zhì)上就是字符串雖然年份看著像數(shù)字但保存成文本可以避免導(dǎo)入Excel時開頭0被吞掉的問題。備注字段可能存在缺失寫入CSV時留空即可。3. 爬蟲核心實現(xiàn)請求、解析、落庫全流程3.1 請求構(gòu)造與分頁規(guī)則確認(rèn)第一步是確認(rèn)查詢地址和參數(shù)格式。假設(shè)查詢頁面的地址是https://example.com/xkkm/query頁面默認(rèn)每頁顯示20條記錄翻頁時URL變成了https://example.com/xkkm/query?page2pageSize20這說明是一個標(biāo)準(zhǔn)的GET分頁查詢。我直接用requests的params參數(shù)來組織請求參數(shù)會自動拼接到URL后面比手寫字符串方便也避免中文編碼出錯。import requests from bs4 import BeautifulSoup import csv import time import random BASE_URL https://example.com/xkkm/query HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Referer: https://example.com/ } def fetch_page(page, page_size20): params { page: page, pageSize: page_size } resp requests.get(BASE_URL, paramsparams, headersHEADERS, timeout10) resp.encoding utf-8 resp.raise_for_status() return resp.text這里有幾個細(xì)節(jié)值得注意。第一是resp.encoding查詢網(wǎng)站返回的是UTF-8編碼但如果不顯式設(shè)置requests有時會根據(jù)響應(yīng)頭里的charset來推斷偶爾會推斷錯誤就會導(dǎo)致中文全部亂碼。第二是timeout時間的設(shè)置必須寫否則網(wǎng)絡(luò)異常時請求會一直卡住拖垮整個采集流程。第三是User-Agent默認(rèn)的python-requests標(biāo)識容易被服務(wù)端攔截偽裝成瀏覽器是基本禮儀。分頁總頁數(shù)怎么確定我在實際解析中發(fā)現(xiàn)頁面底部有一個分頁欄里面顯示“共XX頁”。先請求第一頁然后從HTML里提取總頁數(shù)再做循環(huán)。提取方式可以靈活處理一般是找一個class為pagination或者page-info的節(jié)點然后用正則或者字符串截取數(shù)字。def get_total_pages(html): soup BeautifulSoup(html, html.parser) page_info soup.select_one(.page-info) if not page_info: return 1 # 比如文本是“共 123 頁”提取數(shù)字 import re match re.search(r共\s*(\d)\s*頁, page_info.get_text()) return int(match.group(1)) if match else 13.2 表格解析與字段清洗拿到HTML文本后先用BeautifulSoup解析然后定位到數(shù)據(jù)表格。這個網(wǎng)站的表格結(jié)構(gòu)很標(biāo)準(zhǔn)外層是table標(biāo)簽表頭是thead數(shù)據(jù)行都在tbody里。每一行有六個單元格分別對應(yīng)院校代碼、院校名稱、專業(yè)類名稱、選考科目要求、備注和其他信息。解析代碼寫起來不算難真正的坑在于單元格內(nèi)部的隱藏結(jié)構(gòu)。有的單元格里不只是純文本可能嵌套了span、a標(biāo)簽甚至還有換行。直接用get_text()拿到的是所有子標(biāo)簽文本的拼接必須用stripTrue去除首尾空白同時把內(nèi)部的換行和多余空格替換掉。def parse_table(html): soup BeautifulSoup(html, html.parser) rows soup.select(table tbody tr) data [] for row in rows: cells row.find_all(td) if len(cells) 6: continue school_code cells[0].get_text(stripTrue) school_name cells[1].get_text(stripTrue) major_name cells[2].get_text(stripTrue) subject_req cells[3].get_text(stripTrue) remark cells[4].get_text(stripTrue) # 如果單元格內(nèi)有多個換行統(tǒng)一替換成空格 import re subject_req re.sub(r\s, , subject_req) major_name re.sub(r\s, , major_name) data.append({ year: 2020, school_code: school_code, school_name: school_name, major_name: major_name, subject_requirement: subject_req, remark: remark }) return data為什么用CSS選擇器table tbody tr而不是find_all(tr)因為如果表格里還有嵌套表格find_all(tr)會把內(nèi)層行也找出來造成解析錯亂。用tbody限定最外層數(shù)據(jù)體干凈利落。另外解析時一定不要把表頭行混進去。表頭通常是thead中的tr用table tbody tr選擇器就已經(jīng)天然過濾了表頭。但如果網(wǎng)站結(jié)構(gòu)不規(guī)范需要額外判斷第一個單元格的內(nèi)容是否為“院校代碼”之類的文本做好二次過濾。3.3 數(shù)據(jù)落地CSV寫入與去重數(shù)據(jù)最終要交到別人手里Excel是最通用的格式。我選擇用CSV存儲然后用一個特殊編碼處理中文問題。Python自帶的csv模塊寫入中文時如果用默認(rèn)編碼Excel打開會亂碼。解決方案是寫入時使用utf-8-sig編碼它會自動添加BOM頭Excel識別就沒問題。def save_to_csv(all_data, output_filexuangao_2020.csv): with open(output_file, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[ year, school_code, school_name, major_name, subject_requirement, remark ]) writer.writeheader() writer.writerows(all_data)去重邏輯是在循環(huán)里維護一個已見集合用院校代碼加專業(yè)類名稱加選考科目要求拼接作為鍵。理論上同一個院校的同一個專業(yè)只出現(xiàn)一次但查詢系統(tǒng)偶爾會有重復(fù)展示。如果不去重后續(xù)做統(tǒng)計分析時計數(shù)就會偏高。def collect_all_pages(total_pages): all_data [] seen set() for page in range(1, total_pages 1): html fetch_page(page) page_data parse_table(html) for item in page_data: key (item[school_code], item[major_name], item[subject_requirement]) if key not in seen: seen.add(key) all_data.append(item) print(f第 {page} 頁完成累計 {len(all_data)} 條) time.sleep(random.uniform(0.5, 1.5)) return all_data這里每抓完一頁強制休息0.5到1.5秒。別小看這個sleep它一方面控制請求頻率避免給服務(wù)器造成壓力另一方面也能降低被臨時限流的概率。later如果你要抓更頻繁可以把間隔壓縮到0.3秒但我不建議。3.4 完整的采集循環(huán)把上面幾個函數(shù)串起來主流程就是先獲取總頁數(shù)然后循環(huán)抓取、解析、去重、保存。直接運行這個腳本就能在當(dāng)前目錄生成xuangao_2020.csv。def main(): first_html fetch_page(1) total_pages get_total_pages(first_html) print(f檢測到總頁數(shù){total_pages}) all_data collect_all_pages(total_pages) save_to_csv(all_data) print(f采集完成共 {len(all_data)} 條記錄) if __name__ __main__: main()在實際跑的時候我記得總共跑了不到兩百個頁面最后落地的有效記錄大概幾千條全程耗時大概十幾分鐘。這個速度完全夠用而且不會對目標(biāo)網(wǎng)站造成明顯影響。如果數(shù)據(jù)量比這個大一個量級再考慮并發(fā)優(yōu)化也不遲。4. 踩坑實錄翻頁丟失、亂碼與數(shù)據(jù)重復(fù)怎么處理4.1 翻頁后數(shù)據(jù)沒有變化是怎么回事這是我第一次跑腳本時遇到的最詭異的問題。第一頁正常第二頁返回的內(nèi)容和第一頁一模一樣。當(dāng)時我沒有看URL而是直接用了response的URL做檢查發(fā)現(xiàn)requests的params參數(shù)確實變了但返回內(nèi)容還是第一頁。后來才意識到這種查詢系統(tǒng)對分頁參數(shù)的處理依賴某個隱藏字段比如頁面里的requestId或者sessionId首次請求時服務(wù)器分配了一個會話標(biāo)識翻頁時必須帶上這個標(biāo)識才能繼續(xù)取數(shù)。解決辦法很簡單先用requests.Session()創(chuàng)建一個會話對象讓cookie自動管理。session requests.Session() def fetch_page_with_session(page, page_size20): params {page: page, pageSize: page_size} resp session.get(BASE_URL, paramsparams, headersHEADERS, timeout10) resp.encoding utf-8 resp.raise_for_status() return resp.text改成Session之后翻頁正常了。這種坑在表單類網(wǎng)站里非常常見尤其是登錄后查詢、分頁顯示的系統(tǒng)。以后遇到翻頁異常先檢查是不是cookie或者會話狀態(tài)沒維持住。4.2 中文亂碼與字段內(nèi)容碎片化亂碼問題多半是編碼判斷錯誤。第一版代碼沒寫resp.encoding結(jié)果抓回來的院校名稱變成一堆“錕斤拷”。requests的推測機制并不總是準(zhǔn)確尤其在響應(yīng)頭缺失charset時。顯式指定編碼是最保險的。字段碎片化則是單元格內(nèi)存在不規(guī)整結(jié)構(gòu)造成的。比如有的院校名稱很長頁面里用了換行展示get_text(stripTrue)之后變成了名字中間帶空格。還有的選考科目要求是“物理、化學(xué)”并分成兩行直接拼接就變成“物理化學(xué)”中間沒有分隔符。我用re.sub(r\s, , text)把所有連續(xù)空白字符統(tǒng)一替換成單個空格再手動把逗號、頓號規(guī)范一下。這類清洗邏輯不復(fù)雜但一定要在爬取階段做掉否則數(shù)據(jù)進了CSV再處理會非常被動。4.3 請求失敗與中斷恢復(fù)跑長任務(wù)最怕中途掛掉。網(wǎng)絡(luò)抖動、服務(wù)器超時、偶發(fā)的狀態(tài)碼異常都可能導(dǎo)致腳本中斷。我在代碼里加了簡單的重試機制遇到請求異常就等待一段時間后再試最多重試三次。def fetch_with_retry(page, retries3): for attempt in range(retries): try: return fetch_page_with_session(page) except requests.RequestException as e: print(f第 {page} 頁請求失敗第 {attempt 1} 次重試{e}) time.sleep(2 ** attempt) return None但重試只能解決臨時故障如果程序中斷后重新再跑還是會從頭開始。更穩(wěn)妥的做法是記錄已經(jīng)完成的分頁。我用一個簡單的斷點文件每頁成功解析后就寫入當(dāng)前頁數(shù)下次啟動時從斷點頁繼續(xù)。這樣即使跑了半小時斷掉也不需要重頭再來。def load_last_page(): try: with open(checkpoint.txt, r) as f: return int(f.read().strip()) except (FileNotFoundError, ValueError): return 1 def save_checkpoint(page): with open(checkpoint.txt, w) as f: f.write(str(page))這種斷點方式比數(shù)據(jù)庫快照輕量得多對于小規(guī)模采集已經(jīng)足夠。如果是更正式的工程建議把采集進度寫到SQLite或單獨的進度表里。4.4 常見問題速查表我把這次實戰(zhàn)里最典型的幾個問題整理成一張表后面遇到類似情況可以直接對照?,F(xiàn)象可能原因解決辦法中文亂碼響應(yīng)編碼推斷錯誤顯式設(shè)置resp.encoding utf-8第二頁內(nèi)容和第一頁一樣分頁依賴會話Cookie改用requests.Session()部分?jǐn)?shù)據(jù)缺失表格結(jié)構(gòu)不規(guī)整tr嵌套用table tbody tr限定范圍字段間有多余空格或換行HTML源碼內(nèi)部有標(biāo)簽嵌套get_text(stripTrue)后再用正則清洗跑到一半被限流請求過于頻繁增加隨機延時sleep數(shù)據(jù)重復(fù)寫入未做唯一性校驗維護已見集合按唯一鍵去重程序中斷后從頭再跑缺少斷點記錄寫頁面斷點文件5. 數(shù)據(jù)到手之后統(tǒng)計與擴展玩法5.1 用CSV數(shù)據(jù)做選考要求分布統(tǒng)計爬蟲跑完手里攢下幾千條高校專業(yè)記錄單純交給需求方還不夠。我用pandas做了個快速統(tǒng)計看看選考科目要求大致是什么分布哪些專業(yè)幾乎都要求物理哪些專業(yè)完全不限。import pandas as pd df pd.read_csv(xuangao_2020.csv, encodingutf-8-sig) # 統(tǒng)計選考科目要求的分布 req_counts df[subject_requirement].value_counts() print(req_counts.head(20))統(tǒng)計結(jié)果里占比最高的通常是“物理必選”“不限”和“化學(xué)或生物任選一門”這幾類。物理必選的專業(yè)大多是工科、計算機、電子信息相關(guān)不限選科的多是文科、藝術(shù)類、部分管理類專業(yè)。這個分布對考生選科策略很有參考意義也方便招生部門做數(shù)據(jù)復(fù)盤。如果想把數(shù)據(jù)變成一張方便篩選的表可以直接用Excel打開CSV選中年份列和選考科目要求列加篩選按鈕。之后按“不限選科”“物理必選”等關(guān)鍵詞逐項篩選效率比在網(wǎng)頁上翻頁高得多。5.2 這個爬蟲還能遷移到哪些場景這次任務(wù)的思路不局限于高考選考數(shù)據(jù)。凡是“列表頁分頁表格HTML”結(jié)構(gòu)的網(wǎng)站都可以用同一套代碼邏輯快速改造。比如高校招生計劃查詢、職業(yè)資格目錄查詢、企業(yè)資質(zhì)公示列表結(jié)構(gòu)上都大同小異。遷移時主要改三個地方查詢地址BASE_URL、表格行的CSS選擇器、字段清單fieldnames。其他像請求頭、會話保持、翻頁循環(huán)、去重寫入這些邏輯基本可以原封不動復(fù)用。甚至可以把腳本封裝成一個通用函數(shù)傳入不同的URL、選擇器和字段名就變成了一個小型通用列表采集器。如果目標(biāo)網(wǎng)站的數(shù)據(jù)不是HTML表格而是JSON接口就把parse_table換成json解析整體架構(gòu)不需要變。requests拿到的還是同樣的響應(yīng)只是解析方式不同。掌握這一個套路遇到類似場景基本能直接套模板。最后再分享一個小技巧這類數(shù)據(jù)爬完以后最好在文件里額外增加一列采集時間記錄數(shù)據(jù)快照的抓取時間。以后數(shù)據(jù)源更新了你能清楚分辨哪些是舊數(shù)據(jù)、哪些是新數(shù)據(jù)也方便做對比。我當(dāng)時給CSV加了采集日期列后來再做年度對比時直接按年份和采集日期兩個維度篩選非常有用。