戰(zhàn):從12306官網(wǎng)獲取火車站三字碼對照表)
1. 項(xiàng)目緣起為什么需要火車站三字碼如果你曾經(jīng)嘗試過通過12306的接口或者一些第三方票務(wù)平臺來查詢火車票你大概率會遇到一個東西火車站三字碼。比如北京是BJP上海是SHH廣州是GZQ。這些看起來有點(diǎn)奇怪的字母組合就是鐵路系統(tǒng)內(nèi)部用來唯一標(biāo)識一個車站的代碼。對于寫程序自動處理火車票相關(guān)業(yè)務(wù)來說這個三字碼是繞不開的關(guān)鍵參數(shù)。你不可能在請求接口時直接發(fā)送“北京南站”這樣的中文站名服務(wù)器認(rèn)的是BJP。那么問題來了我們怎么系統(tǒng)地獲取這份完整的映射關(guān)系呢12306官網(wǎng)并沒有提供一個官方的、格式友好的車站代碼列表下載。你可能會在一些技術(shù)博客或者GitHub項(xiàng)目里找到別人爬下來整理好的station_name.js文件但數(shù)據(jù)可能過時或者格式不符合你的需求。自己動手豐衣足食。通過Python爬蟲直接從12306官網(wǎng)獲取這份數(shù)據(jù)不僅是最直接、最可靠的方式也是一個非常經(jīng)典的、適合練手的爬蟲實(shí)戰(zhàn)項(xiàng)目。它涵蓋了靜態(tài)頁面分析、數(shù)據(jù)提取、清洗和持久化存儲的全流程沒有復(fù)雜的反爬機(jī)制卻非常實(shí)用。2. 目標(biāo)分析與技術(shù)選型用最簡單的工具做最有效的事我們的目標(biāo)很明確從12306官方網(wǎng)站上抓取所有火車站的中文名與其對應(yīng)的三字碼并保存成一份結(jié)構(gòu)化的數(shù)據(jù)比如JSON或CSV方便后續(xù)程序調(diào)用。首先我們得找到數(shù)據(jù)在哪。打開12306官網(wǎng)www.12306.cn在查詢車票的頁面你會發(fā)現(xiàn)出發(fā)站和到達(dá)站的輸入框。當(dāng)你點(diǎn)擊輸入框時會下拉出一個包含所有車站的列表。這個列表的數(shù)據(jù)并不是在HTML頁面里寫死的而是通過一個JavaScript文件動態(tài)加載的。通過瀏覽器的開發(fā)者工具F12在“網(wǎng)絡(luò)”Network選項(xiàng)卡中篩選JS文件很容易就能找到一個名為station_name.js或者類似名稱的文件。這個文件就是我們數(shù)據(jù)的源頭。技術(shù)選型上我們追求簡潔高效請求庫requests。這是Python生態(tài)中公認(rèn)的、用于發(fā)送HTTP請求的瑞士軍刀。它簡單、直觀、功能強(qiáng)大足以應(yīng)對我們這個靜態(tài)資源獲取的任務(wù)。相比于urllib它的API更加友好。數(shù)據(jù)解析re正則表達(dá)式。由于目標(biāo)數(shù)據(jù)station_name.js本身并不是標(biāo)準(zhǔn)的JSON或HTML而是一段特定格式的JavaScript字符串例如bjb|北京北|VAP|beijingbei|bjb|0使用正則表達(dá)式來匹配和提取我們需要的字段站名和三字碼是最直接、最靈活的方法。雖然對于復(fù)雜的HTML解析推薦BeautifulSoup或lxml但在這里正則表達(dá)式是更合適的工具。數(shù)據(jù)存儲內(nèi)置json模塊或csv模塊。我們將提取出的數(shù)據(jù)轉(zhuǎn)化為Python字典列表然后根據(jù)喜好可以輕松地保存為JSON文件便于程序讀取或CSV文件便于用Excel查看。為什么不選用更“重型”的框架如Scrapy因?yàn)檫@是一個目標(biāo)單一、頁面結(jié)構(gòu)簡單的任務(wù)用requestsre的組合足以優(yōu)雅地解決無需引入框架的復(fù)雜度。記住一個原則用最合適的工具而不是最強(qiáng)大的工具。3. 實(shí)戰(zhàn)步驟詳解從找到數(shù)據(jù)到保存數(shù)據(jù)接下來我們一步步拆解整個爬取過程。我會把每個步驟的意圖和可能遇到的細(xì)節(jié)都講清楚。3.1 定位并分析數(shù)據(jù)源打開12306官網(wǎng)訪問https://www.12306.cn。打開開發(fā)者工具按F12切換到“網(wǎng)絡(luò)” (Network)選項(xiàng)卡。記得勾選“保留日志” (Preserve log)以防頁面跳轉(zhuǎn)時請求記錄被清除。觸發(fā)車站列表加載點(diǎn)擊出發(fā)地或目的地的輸入框。此時開發(fā)者工具的網(wǎng)絡(luò)面板中會刷出一系列新的請求。尋找目標(biāo)文件在這些請求中仔細(xì)查找一個以station為關(guān)鍵詞的JS文件。它的完整URL可能類似于https://kyfw.12306.cn/otn/resources/js/framework/station_name.js?station_version1.12345。這個station_version參數(shù)是版本號會變化但文件基本路徑是穩(wěn)定的。查看響應(yīng)內(nèi)容點(diǎn)擊這個JS請求在“響應(yīng)” (Response) 或“預(yù)覽” (Preview) 標(biāo)簽頁里你就能看到數(shù)據(jù)的真面目。它通常是一大段文本格式如下var station_names ‘bjb|北京北|VAP|beijingbei|bjb|0bjd|北京東|BOP|beijingdong|bjd|1bji|北京|BJP|beijing|bj|2bjn|北京南|VNP|beijingnan|bjn|3bjx|北京西|BXP|beijingxi|bjx|4bjy|北京北|VAP|beijingbei|bjy|5...’;可以看到每個車站的信息以“”符號開頭不同字段之間用“|”管道符分隔。注意12306的靜態(tài)資源域名可能是kyfw.12306.cn主站是www.12306.cn。爬取時直接請求kyfw.12306.cn下的資源地址即可這是完全正常的資源訪問不涉及任何敏感操作。3.2 編寫爬蟲代碼現(xiàn)在我們開始寫代碼。我會逐塊解釋。import requests import re import json import csv def fetch_station_data(): 從12306獲取火車站數(shù)據(jù)JS文件并解析出車站名和三字碼。 # 目標(biāo)URL。這個URL可能會隨版本更新但基本模式不變。 # 如果上述URL失效請按3.1步驟重新從官網(wǎng)獲取最新URL。 url ‘https://kyfw.12306.cn/otn/resources/js/framework/station_name.js?station_version1.12345‘ # 設(shè)置請求頭模擬瀏覽器訪問這是一個良好的爬蟲習(xí)慣。 headers { ‘User-Agent‘: ‘Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36‘ } print(‘正在請求數(shù)據(jù)...‘) try: response requests.get(url, headersheaders, timeout10) # 檢查請求是否成功 response.raise_for_status() # 12306的這個接口返回的編碼是UTF-8 response.encoding ‘utf-8‘ js_content response.text print(‘?dāng)?shù)據(jù)請求成功‘) except requests.exceptions.RequestException as e: print(f‘請求失敗: {e}‘) return None # 使用正則表達(dá)式匹配數(shù)據(jù)部分。 # 模式解釋匹配 “” 開頭然后捕獲“非|字符”三字碼 # 再匹配“|”再捕獲“非|字符”中文站名直到下一個“”或字符串結(jié)束。 # 這里我們只關(guān)心第一個三字碼和第二個中文名捕獲組。 pattern r‘([^|])\|([^|])\|‘ stations re.findall(pattern, js_content) # re.findall 返回的是元組列表例如 [(‘bjb‘, ‘北京北‘), (‘bjd‘, ‘北京東‘), ...] # 但注意根據(jù)我們看到的格式第一個字段似乎是電報(bào)碼小寫拼音第二個才是中文名。 # 然而我們仔細(xì)看原始數(shù)據(jù)‘bjb|北京北|VAP|beijingbei|bjb|0‘ # 按‘|‘分割后索引0是‘bjb‘可能是簡碼索引1是‘北京北‘索引2是‘VAP‘這才是三字碼。 # 所以我們需要調(diào)整正則表達(dá)式捕獲索引1中文名和索引2三字碼。 print(‘正在解析數(shù)據(jù)...‘) # 修正后的正則表達(dá)式匹配三個我們需要的字段簡碼、中文名、三字碼 # 模式([^|])\|([^|])\|([^|]) 匹配 xxx|yyy|zzz 的結(jié)構(gòu)并捕獲xxx, yyy, zzz pattern_corrected r‘([^|])\|([^|])\|([^|])\|‘ stations_corrected re.findall(pattern_corrected, js_content) # stations_corrected 現(xiàn)在是 [(‘bjb‘, ‘北京北‘, ‘VAP‘), (‘bjd‘, ‘北京東‘, ‘BOP‘), ...] # 但我們只需要 中文名 和 三字碼。我們將其轉(zhuǎn)換為字典列表鍵名更清晰。 station_list [] for item in stations_corrected: # item[0] 是簡碼如bjb item[1]是中文名 item[2]是三字碼 station_list.append({ ‘name‘: item[1], # 中文站名 ‘code‘: item[2], # 三字碼 ‘telecode‘: item[0] # 電報(bào)碼/簡碼也保留下來可能有用 }) print(f‘共解析出 {len(station_list)} 個車站信息?!? return station_list代碼要點(diǎn)解析請求頭User-Agent這是為了讓自己看起來更像一個普通的瀏覽器避免被一些簡單的反爬策略攔截。雖然12306這個接口可能不檢查但加上是好習(xí)慣。異常處理網(wǎng)絡(luò)請求總是可能失敗的。用try...except包裹起來并使用raise_for_status()在HTTP狀態(tài)碼非200時拋出異常能讓程序更健壯。正則表達(dá)式修正這是本項(xiàng)目第一個關(guān)鍵點(diǎn)。一定要仔細(xì)核對原始數(shù)據(jù)的格式。我第一版正則就抓錯了字段把電報(bào)碼當(dāng)成了三字碼。通過觀察bjb|北京北|VAP|...我們明確需要的三字碼在第三個位置VAP。所以正則模式要能精準(zhǔn)捕獲前三個字段。數(shù)據(jù)轉(zhuǎn)換將元組列表轉(zhuǎn)換成字典列表這樣每個車站的信息都有了明確的鍵name,code后續(xù)使用和保存都更方便可讀性也更強(qiáng)。3.3 數(shù)據(jù)清洗與保存獲取到的數(shù)據(jù)可能包含一些我們不需要的車站比如某些乘降所或者存在重復(fù)。但通常12306提供的這個列表已經(jīng)是比較干凈的運(yùn)營車站列表。我們直接保存即可。這里提供兩種保存方式。def save_to_json(station_list, filename‘stations.json‘): 將車站列表保存為JSON文件。 with open(filename, ‘w‘, encoding‘utf-8‘) as f: # ensure_asciiFalse 確保中文正常顯示indent2 讓文件有縮進(jìn)更易讀 json.dump(station_list, f, ensure_asciiFalse, indent2) print(f‘?dāng)?shù)據(jù)已保存至 {filename}‘) def save_to_csv(station_list, filename‘stations.csv‘): 將車站列表保存為CSV文件。 # 定義CSV文件的列頭 fieldnames [‘name‘, ‘code‘, ‘telecode‘] with open(filename, ‘w‘, newline‘‘, encoding‘utf-8-sig‘) as f: # utf-8-sig 解決Excel打開中文亂碼 writer csv.DictWriter(f, fieldnamesfieldnames) writer.writeheader() writer.writerows(station_list) print(f‘?dāng)?shù)據(jù)已保存至 {filename}‘) def main(): 主函數(shù) stations fetch_station_data() if stations: # 保存為JSON便于程序讀取 save_to_json(stations) # 保存為CSV便于人工查閱 save_to_csv(stations) # 打印前10條數(shù)據(jù)看看效果 print(‘\n前10個車站信息‘) for s in stations[:10]: print(f{s[‘name‘]} - {s[‘code‘]} (電報(bào)碼{s[‘telecode‘]})) else: print(‘未能獲取數(shù)據(jù)程序退出?!? if __name__ ‘__main__‘: main()運(yùn)行這段代碼你會在當(dāng)前目錄下得到stations.json和stations.csv兩個文件。用文本編輯器或Excel打開看看一份完整的中國火車站三字碼對照表就到手了。4. 關(guān)鍵細(xì)節(jié)與常見踩坑點(diǎn)在實(shí)際操作中有幾個細(xì)節(jié)如果不注意很容易導(dǎo)致爬蟲失敗或者數(shù)據(jù)錯誤。4.1 URL的動態(tài)版本號最大的一個坑就是數(shù)據(jù)源URL的版本號會變。我們代碼里寫的station_version1.12345只是一個例子。12306更新車站數(shù)據(jù)如新站開通、舊站改名時這個版本號就會遞增。如果你的某天發(fā)現(xiàn)爬蟲突然獲取不到數(shù)據(jù)或者數(shù)據(jù)是舊的第一件事就是去12306官網(wǎng)按照3.1的步驟重新抓取一次那個station_name.js文件的真實(shí)URL。一個更健壯的做法是我們可以嘗試從12306官網(wǎng)的HTML里動態(tài)解析出這個JS文件的鏈接。通常這個鏈接會在某個script標(biāo)簽的src屬性里。但這需要額外的HTML解析步驟對于這個簡單項(xiàng)目手動更新一次URL也完全可以接受。你可以把URL作為一個配置項(xiàng)放在代碼開頭方便修改。4.2 正則表達(dá)式的精確性正如我們在代碼中經(jīng)歷的正則表達(dá)式寫錯一個分組抓到的就是完全不同的數(shù)據(jù)。務(wù)必、務(wù)必、務(wù)必在寫好正則后先用一小段樣本數(shù)據(jù)測試一下。比如sample ‘bjb|北京北|VAP|beijingbei|bjb|0bjd|北京東|BOP|beijingdong|bjd|1‘ test_pattern r‘([^|])\|([^|])\|([^|])\|‘ result re.findall(test_pattern, sample) print(result) # 應(yīng)該輸出 [(‘bjb‘, ‘北京北‘, ‘VAP‘), (‘bjd‘, ‘北京東‘, ‘BOP‘)]確保輸出的結(jié)果和你預(yù)期的一致再進(jìn)行全量數(shù)據(jù)的抓取。4.3 編碼問題雖然12306的這個接口通常返回UTF-8編碼但明確指定response.encoding ‘utf-8‘是一個好習(xí)慣可以避免在一些環(huán)境下出現(xiàn)亂碼。保存文件時JSON使用utf-8CSV為了在Windows Excel中正常打開使用utf-8-sig帶BOM的UTF-8是更穩(wěn)妥的選擇。4.4 網(wǎng)絡(luò)請求的穩(wěn)健性我們設(shè)置了timeout10意味著如果10秒內(nèi)沒有收到響應(yīng)就拋出超時異常。在網(wǎng)絡(luò)狀況不佳時這能防止程序無限期掛起。此外可以考慮增加重試機(jī)制使用requests.adapters.HTTPAdapter和urllib3.util.Retry來配置遇到網(wǎng)絡(luò)錯誤或5xx狀態(tài)碼時的重試策略這對于生產(chǎn)環(huán)境下的爬蟲很重要。5. 數(shù)據(jù)擴(kuò)展與應(yīng)用場景拿到這份干凈的車站三字碼數(shù)據(jù)后它的用處可不僅僅是填充一個下拉框。這里分享幾個我實(shí)際用到的或者看到過的擴(kuò)展場景1. 構(gòu)建車站查詢工具或API將JSON數(shù)據(jù)加載到內(nèi)存中可以快速實(shí)現(xiàn)站名到三字碼、三字碼到站名的雙向查詢。比如做一個簡單的命令行工具def find_station_by_name(name_keyword, station_list): return [s for s in station_list if name_keyword in s[‘name‘]] def find_station_by_code(code, station_list): return next((s for s in station_list if s[‘code‘] code.upper()), None)2. 數(shù)據(jù)清洗與關(guān)聯(lián)如果你手頭有別的數(shù)據(jù)源例如歷史票務(wù)數(shù)據(jù)、列車時刻表但里面的車站信息是雜亂的中文名甚至可能有錯別字或簡稱如“北京”、“北京站”、“Beijing”。你可以用這份權(quán)威的三字碼列表作為“清洗字典”通過模糊匹配如使用fuzzywuzzy庫將雜亂的車站名標(biāo)準(zhǔn)化為統(tǒng)一的三字碼為后續(xù)的數(shù)據(jù)分析打下基礎(chǔ)。3. 結(jié)合地理信息單純的三字碼和站名信息還不夠“立體”。你可以進(jìn)一步爬取或購買車站的地理坐標(biāo)經(jīng)緯度數(shù)據(jù)然后與這份列表關(guān)聯(lián)。這樣每個車站就擁有了“位置”屬性?;诖四憧梢詫?shí)現(xiàn)車站間距離計(jì)算用于估算票價或旅行時間雖然不精確但有參考價值??梢暬貓D將所有車站在地圖上打點(diǎn)直觀展示鐵路網(wǎng)絡(luò)密度。智能推薦用戶輸入一個城市推薦周邊可用的火車站例如輸入“蘇州”可推薦“蘇州站”、“蘇州北站”、“蘇州園區(qū)站”等。4. 監(jiān)測數(shù)據(jù)更新由于車站數(shù)據(jù)并非一成不變你可以將本次爬取的數(shù)據(jù)與本地存儲的上一版本進(jìn)行對比比較code或name集合的差異自動檢測出新開通的車站、更名的車站甚至是被關(guān)閉的車站。這可以作為一個定時任務(wù)讓你的數(shù)據(jù)始終保持最新。6. 進(jìn)階思考從爬蟲到可持續(xù)的數(shù)據(jù)管道我們寫的這個腳本是一次性的。但實(shí)際應(yīng)用中數(shù)據(jù)需要更新。如何將它變成一個可持續(xù)的、自動化的數(shù)據(jù)管道思路一腳本 定時任務(wù)Cron / Task Scheduler這是最簡單的方法。將完整的Python腳本部署到服務(wù)器上然后使用Linux的Cron Job或者Windows的任務(wù)計(jì)劃程序設(shè)定每周或每月執(zhí)行一次。腳本執(zhí)行后可以將新的數(shù)據(jù)文件覆蓋舊的或者按日期存檔。你可以在腳本中加入簡單的對比邏輯如果發(fā)現(xiàn)數(shù)據(jù)有變化就發(fā)送一封郵件或一個釘釘/企業(yè)微信消息通知自己。思路二封裝為微服務(wù)API如果你需要一個隨時可查詢的在線服務(wù)可以用Flask或FastAPI等框架將數(shù)據(jù)加載到內(nèi)存并提供RESTful API接口。例如GET /stations獲取所有車站列表。GET /station/code/code根據(jù)三字碼查詢車站詳情。GET /station/name/keyword根據(jù)站名關(guān)鍵詞模糊查詢。 同時在服務(wù)內(nèi)部可以再啟動一個后臺線程或定時任務(wù)定期從12306拉取最新數(shù)據(jù)并更新內(nèi)存中的數(shù)據(jù)源。這樣你的其他應(yīng)用就可以通過調(diào)用這個API來獲取準(zhǔn)確的車站信息而無需各自維護(hù)一份可能過時的數(shù)據(jù)。思路三集成到數(shù)據(jù)倉庫在更復(fù)雜的數(shù)據(jù)平臺中這份車站數(shù)據(jù)可能只是維度表的一部分。你可以使用Apache Airflow這樣的工作流調(diào)度工具將爬蟲腳本定義為一個PythonOperator任務(wù)。這個任務(wù)定期運(yùn)行爬取數(shù)據(jù)后進(jìn)行清洗、去重、格式化然后寫入到數(shù)據(jù)庫如MySQL、PostgreSQL或數(shù)據(jù)湖如HDFS、S3中指定的表里。下游的BI報(bào)表、數(shù)據(jù)分析任務(wù)都從這張統(tǒng)一的表中讀取數(shù)據(jù)保證了數(shù)據(jù)的一致性。無論采用哪種方式核心都是將“一次性”的爬蟲動作轉(zhuǎn)變?yōu)椤翱烧{(diào)度、可監(jiān)控、可復(fù)用”的數(shù)據(jù)生產(chǎn)環(huán)節(jié)。這才是爬蟲技能在真實(shí)業(yè)務(wù)場景中的價值體現(xiàn)。最后再強(qiáng)調(diào)一個非常重要的點(diǎn)尊重?cái)?shù)據(jù)源合規(guī)使用。我們爬取的是12306公開用于頁面功能的數(shù)據(jù)頻率很低且用于個人學(xué)習(xí)或內(nèi)部數(shù)據(jù)建設(shè)這通常是合理的。但切記不要高頻、并發(fā)地請求以免對對方服務(wù)器造成不必要的壓力這也是一個合格開發(fā)者的基本素養(yǎng)。我們的代碼里已經(jīng)通過單次請求、添加請求頭、設(shè)置超時等方式盡可能地做到了友好訪問。