建12306購(gòu)票監(jiān)控助手:自動(dòng)化查詢與提醒實(shí)戰(zhàn))
每年春運(yùn)搶票那幾天我基本是左手手機(jī)、右手電腦12306頁(yè)面刷到懷疑人生。后來(lái)把做自動(dòng)化測(cè)試的經(jīng)驗(yàn)帶進(jìn)來(lái)用Playwright搭了一套自己的購(gòu)票輔助工具查詢、監(jiān)控、提醒這些磨人的環(huán)節(jié)全交給程序我只負(fù)責(zé)在收到通知后再去操作。這篇文章就把我用自動(dòng)化方式處理12306購(gòu)票的全過程整理出來(lái)不推薦任何收費(fèi)加速包也不碰來(lái)路不明的瀏覽器插件適合有點(diǎn)Python基礎(chǔ)、愿意自己掌控完整流程的朋友參考。1. 為什么我把“搶票工具”做成了自動(dòng)化腳本而不是裝個(gè)插件1.1 每年開票的真實(shí)痛點(diǎn)先聊需求。12306的問題從來(lái)不是“網(wǎng)速不夠快”而是狀態(tài)變化完全不可預(yù)測(cè)。起售時(shí)間不是一個(gè)不同車站、不同車次各有各的時(shí)間點(diǎn)退票回流也不是均勻分布經(jīng)常半夜冒出來(lái)一張票幾分鐘后又沒了還有候補(bǔ)釋放、區(qū)間放票、臨時(shí)加開每一個(gè)變化都是一次“窗口期”。人在這種場(chǎng)景下有個(gè)天然劣勢(shì)注意力會(huì)衰減。盯二十分鐘屏幕還能保持敏感盯三個(gè)小時(shí)就開始麻木真出票了反而反應(yīng)不過來(lái)。我可以熬夜守在第一波開售但沒法做到連續(xù)十幾個(gè)小時(shí)保持高專注度更扛不住同時(shí)盯好幾個(gè)日期、好幾趟車。所以自動(dòng)化并不是為了“跟黃牛比手速”核心訴求很簡(jiǎn)單把高頻、重復(fù)、容易看漏的操作交給程序讓我只在需要做決定的時(shí)候介入。1.2 現(xiàn)成工具為什么讓我不放心市面上各類“搶票助手”和“加速包”本質(zhì)上只有兩種實(shí)現(xiàn)路徑。第一種是純前端插件注入腳本模擬點(diǎn)擊和刷新。這類工具看起來(lái)方便但它運(yùn)行在一個(gè)不受你控制的第三方腳本里頁(yè)面只要一改版就可能失效還隨時(shí)可能被網(wǎng)站風(fēng)控識(shí)別把賬號(hào)搞出異常。第二種是服務(wù)器端搶票號(hào)稱“云端并發(fā)請(qǐng)求”。你把12306賬號(hào)授權(quán)給第三方它用高并發(fā)代替你去刷票。問題在于你的賬號(hào)數(shù)據(jù)在別人服務(wù)器上出票邏輯也不透明你根本不知道它在背后發(fā)了多少請(qǐng)求、有沒有用違規(guī)手段。真出了問題你能追的只有一個(gè)客服工單。我見過太多人為了搶一張票把賬號(hào)授權(quán)給好幾個(gè)平臺(tái)最后票沒搶到乘車人信息、常用聯(lián)系人反而被串得亂七八糟。這些數(shù)據(jù)風(fēng)險(xiǎn)是實(shí)打?qū)嵉奈铱刹幌虢唤o一個(gè)“加速成功率”來(lái)背書。1.3 自動(dòng)化測(cè)試思路帶來(lái)的三個(gè)優(yōu)勢(shì)后來(lái)我換了個(gè)思路不把這件事當(dāng)成“搶票”而是當(dāng)成一個(gè)網(wǎng)頁(yè)自動(dòng)化測(cè)試任務(wù)。用做自動(dòng)化測(cè)試的工程化方法去處理能拿到三個(gè)明顯好處。第一過程可回溯。每一步操作都有日志、截圖、視頻回放出問題我能看到是選擇器失效還是網(wǎng)絡(luò)超時(shí)而不是黑盒一樣對(duì)著“搶票失敗”四個(gè)字發(fā)呆。第二頻率可控。腳本完全跑在本地刷新間隔、并發(fā)數(shù)、重試策略都由我決定不會(huì)出現(xiàn)一覺醒來(lái)發(fā)現(xiàn)賬號(hào)被風(fēng)控的窘?jīng)r。第三可持續(xù)維護(hù)。頁(yè)面改版了我能快速定位并修復(fù)今年寫的腳本明年還能繼續(xù)用不像第三方工具那樣說(shuō)停更就停更。說(shuō)到底搶票輔助的本質(zhì)就是一個(gè)“真實(shí)瀏覽器環(huán)境下的UI自動(dòng)化任務(wù)”恰好是我最熟悉的領(lǐng)域。2. 技術(shù)選型Playwright為什么比Selenium和純r(jià)equests更適合干這活2.1 三種方案的邊界選型階段我對(duì)比過三類方案requests接口直連、Selenium、Playwright。對(duì)比維度requests接口直連SeleniumPlaywright登錄態(tài)維護(hù)要自己處理Cookie和簽名復(fù)雜真實(shí)瀏覽器自然真實(shí)瀏覽器且支持storage_state持久化風(fēng)控識(shí)別風(fēng)險(xiǎn)最容易暴露有一定風(fēng)險(xiǎn)相對(duì)低但仍需控制頻率元素定位穩(wěn)定性不涉及依賴等待策略常需手動(dòng)sleep內(nèi)置自動(dòng)等待穩(wěn)得多調(diào)試體驗(yàn)弱基本靠抓包一般截圖麻煩trace回放、UI模式操作非常好用運(yùn)行速度最快偏重中等偏快但足夠用純r(jià)equests方案看起來(lái)很“輕”但它繞過了真實(shí)瀏覽器渲染12306頁(yè)面上的數(shù)據(jù)大多依賴JavaScript動(dòng)態(tài)加載你得先逆向接口參數(shù)、模擬加密邏輯工作量一下子就上來(lái)了。更麻煩的是這種非瀏覽器行為很容易被服務(wù)端識(shí)別為異常請(qǐng)求賬號(hào)風(fēng)險(xiǎn)很高。Selenium是老牌工具能跑真實(shí)瀏覽器但配置WebDriver容易踩環(huán)境坑對(duì)等待條件的處理也不夠優(yōu)雅。我不反對(duì)用它只是Playwright同樣能實(shí)現(xiàn)目標(biāo)還更順手。2.2 Playwright比Selenium順手在哪Playwright有幾個(gè)點(diǎn)非常契合這類場(chǎng)景。首先是自動(dòng)等待。它定位元素時(shí)會(huì)自動(dòng)等待元素出現(xiàn)、可見、可操作不需要像Selenium那樣寫一堆顯式等待或者到處塞time.sleep。這一點(diǎn)在12306這種頁(yè)面加載慢、列表異步渲染的站點(diǎn)上特別實(shí)用。其次是瀏覽器上下文隔離。每個(gè)context都是獨(dú)立的會(huì)話我可以用一個(gè)context跑普通查詢另一個(gè)context跑登錄態(tài)保持互不干擾。然后是storage_state機(jī)制。登錄之后把整個(gè)上下文狀態(tài)保存成JSON文件下次啟動(dòng)直接用省掉了每次掃碼登錄的麻煩。這個(gè)能力在處理12306這種“登錄一次能管很久”的站點(diǎn)時(shí)價(jià)值非常大。最后是trace回放。腳本跑掛了Playwright會(huì)記錄完整的調(diào)用過程、DOM快照、網(wǎng)絡(luò)請(qǐng)求和頁(yè)面截圖我用playwright show-trace命令打開報(bào)告一眼就能看出腳本到底死在哪一步。2.3 環(huán)境準(zhǔn)備與最小項(xiàng)目結(jié)構(gòu)安裝沒什么特別的Python 3.9以上然后執(zhí)行pip install playwright pytest playwright install chromium建議只裝Chromium內(nèi)核夠了不需要把所有瀏覽器都裝一遍。項(xiàng)目結(jié)構(gòu)我習(xí)慣這樣安排ticket_assistant/ ├── config.py # 車次、日期、通知地址等配置 ├── login.py # 掃碼登錄并保存state.json ├── monitor.py # 核心查詢與監(jiān)控邏輯 ├── notify.py # 消息通知模塊 ├── tests/test_monitor.py # pytest用例 └── logs/ # 日志和trace目錄這個(gè)結(jié)構(gòu)不復(fù)雜但能把“登錄態(tài)”“查詢邏輯”“通知通道”拆開后續(xù)哪一塊出問題都不需要翻整個(gè)項(xiàng)目。3. 最小可用閉環(huán)登錄態(tài)保持、余票查詢與消息通知3.1 掃碼登錄后保存storage_state讓腳本免掃碼腳本的第一步是拿到一個(gè)“登錄后的瀏覽器狀態(tài)”。手動(dòng)掃碼一次把狀態(tài)保存成本地文件后續(xù)所有自動(dòng)化運(yùn)行都復(fù)用這個(gè)狀態(tài)不用每跑一次就重新掃碼。from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessFalse) context browser.new_context() page context.new_page() page.goto(https://www.12306.cn/index/) input(掃碼登錄并完成人機(jī)驗(yàn)證后按回車...) context.storage_state(pathstate.json) browser.close()這里有一點(diǎn)必須提醒不要用headlessTrue做首次登錄。12306的掃碼頁(yè)面和人機(jī)校驗(yàn)對(duì)無(wú)頭瀏覽器的識(shí)別非常敏感一旦被判定為自動(dòng)化流量可能直接彈滑塊甚至鎖賬號(hào)。老老實(shí)實(shí)開一次有頭瀏覽器登錄完再關(guān)閉。后續(xù)腳本啟動(dòng)時(shí)直接加載這個(gè)狀態(tài)文件context browser.new_context(storage_statestate.json)3.2 用頁(yè)面定位器讀取車次和余票信息登錄態(tài)解決之后最關(guān)鍵的是從頁(yè)面里取出“我想知道的信息”。12306的余票頁(yè)面是動(dòng)態(tài)渲染的用requests去拿HTML基本拿不到完整數(shù)據(jù)但Playwright可以等頁(yè)面渲染完再讀取。page.goto(https://www.12306.cn/index/) page.get_by_text(購(gòu)票).click() page.locator(#fromStationText).fill(北京) page.locator(#toStationText).fill(上海) page.locator(#train_date).fill(2025-02-01) page.keyboard.press(Enter) page.wait_for_load_state(networkidle)這里的選擇器是12306當(dāng)前頁(yè)面的真實(shí)id但我不建議你完全照抄因?yàn)轫?yè)面改版太頻繁。更穩(wěn)妥的做法是打開瀏覽器F12確認(rèn)當(dāng)前版本用的到底是什么id或占位符再填進(jìn)代碼里。讀取車次余票信息時(shí)用locator把所有行抓下來(lái)然后過濾出包含目標(biāo)車次的那一行rows page.locator(tr.query_model) for row in rows.all(): text row.inner_text() if G1 in text: print(text)我會(huì)把解析邏輯封裝成一個(gè)函數(shù)返回結(jié)構(gòu)化結(jié)果方便后續(xù)做狀態(tài)對(duì)比和監(jiān)控判斷。3.3 寫一個(gè)不依賴短信網(wǎng)關(guān)的提醒通道查詢到余票后怎么通知自己最簡(jiǎn)單的是桌面通知但如果你人不在電腦前最好接一個(gè)能推到手機(jī)上的通道。我用的最順手的是釘釘群機(jī)器人。創(chuàng)建一個(gè)群添加自定義機(jī)器人拿到Webhook地址后直接往里面丟文本消息即可。import requests def send_dingtalk(text: str): webhook https://oapi.dingtalk.com/robot/send?access_tokenYOUR_TOKEN payload { msgtype: text, text: {content: text} } requests.post(webhook, jsonpayload)不依賴短信服務(wù)、不花錢、配置一次管一年非常適合個(gè)人自動(dòng)化場(chǎng)景。如果用不慣釘釘換Server醬、郵件都可以核心邏輯就是“腳本發(fā)現(xiàn)變化 - 發(fā)一條消息給我”。3.4 第一個(gè)可運(yùn)行腳本把上面三塊整合起來(lái)就是最小可用閉環(huán)with sync_playwright() as p: browser p.chromium.launch(headlessTrue) context browser.new_context(storage_statestate.json) page context.new_page() page.goto(https://www.12306.cn/index/) # ... 跳轉(zhuǎn)購(gòu)票頁(yè)、輸入出發(fā)地、目的地、日期 ... rows page.locator(tr.query_model).all() found [r.inner_text() for r in rows if G1 in r.inner_text()] if found: send_dingtalk(發(fā)現(xiàn)可購(gòu)車次 | .join(found)) browser.close()這個(gè)腳本跑通后你對(duì)整個(gè)流程的掌控感就完全不一樣了。4. 場(chǎng)景擴(kuò)展候補(bǔ)監(jiān)控、搶票節(jié)奏與多任務(wù)巡檢4.1 從“查一次”到“盯全程”候補(bǔ)監(jiān)控循環(huán)最小閉環(huán)只解決了“主動(dòng)查一次”實(shí)際場(chǎng)景更需要的是“持續(xù)盯”。我就設(shè)計(jì)了一個(gè)監(jiān)控循環(huán)每隔一段時(shí)間刷新余票頁(yè)面一旦發(fā)現(xiàn)目標(biāo)車次狀態(tài)發(fā)生變化就立刻通知。import time import random from datetime import datetime, timedelta deadline datetime.now() timedelta(hours2) while datetime.now() deadline: try: page.reload() page.wait_for_load_state(networkidle) rows page.locator(tr.query_model).all() current [r.inner_text() for r in rows if G1 in r.inner_text()] if current ! last_state: send_dingtalk(車次狀態(tài)變化 | .join(current)) last_state current time.sleep(random.uniform(4, 7)) except Exception as e: # 哪怕報(bào)錯(cuò)也不要立刻把進(jìn)程打死記下日志等下一輪 print(f[ERROR] {e}) time.sleep(10)循環(huán)里必須做兩件事隨機(jī)延時(shí)和異常兜底。隨機(jī)延時(shí)讓請(qǐng)求間隔不固定避免被風(fēng)控識(shí)別成機(jī)械行為異常兜底保證某次頁(yè)面加載失敗時(shí)不會(huì)讓整個(gè)程序退出。4.2 開售瞬間的搶票節(jié)奏控制候補(bǔ)監(jiān)控解決的是“撿漏”開售首波還得靠主動(dòng)刷新。我的做法是在開售時(shí)間前3分鐘啟動(dòng)腳本提前完成頁(yè)面跳轉(zhuǎn)、輸入出發(fā)地目的地、選中目標(biāo)車次。真正的“搶”只發(fā)生在開售那一刻——腳本以較高的頻率刷新一旦出現(xiàn)余票立即鎖定并通知。這里要注意“較高頻率”不是無(wú)限快。實(shí)測(cè)下來(lái)單賬號(hào)每3秒刷新一次已經(jīng)是比較激進(jìn)的狀態(tài)再快就容易觸發(fā)校驗(yàn)頁(yè)。與其把頻率拉滿不如讓腳本更聰明一次刷新能看到的數(shù)據(jù)量其實(shí)不少重點(diǎn)是判斷準(zhǔn)確、通知及時(shí)。4.3 用pytest參數(shù)化同時(shí)盯多個(gè)車次、多個(gè)日期一個(gè)人盯幾個(gè)車次時(shí)靠手寫循環(huán)也可以但一旦要同時(shí)監(jiān)控好幾條線路、好幾個(gè)日期就該上pytest了。用pytest做參數(shù)化每條數(shù)據(jù)都是一條獨(dú)立的測(cè)試用例失敗隔離、日志清晰還能用插件做失敗重試import pytest from ticket_assistant.monitor import monitor_trip pytest.mark.parametrize(trip, [ (北京南, 上海虹橋, 2025-02-01), (北京南, 南京南, 2025-02-01), (北京南, 杭州東, 2025-02-02), ]) def test_monitor_trip(trip): monitor_trip(*trip)pytest另一重價(jià)值是報(bào)告所有用例跑完哪些線路狀態(tài)正常、哪些線路發(fā)現(xiàn)過余票自動(dòng)生成匯總報(bào)告不用在控制臺(tái)里翻日志。4.4 自動(dòng)填單的邊界把“提交”按鈕留給人工邏輯上腳本完全能繼續(xù)往下走自動(dòng)選擇乘車人、自動(dòng)提交訂單。我試過一次后反而把這一層從自動(dòng)化鏈路里摘掉了。原因有三。第一訂單提交涉及真實(shí)資金和出行安排一旦選錯(cuò)人、選錯(cuò)日期糾錯(cuò)成本極高第二提交訂單后有支付環(huán)節(jié)如果自動(dòng)化程度太高人反而會(huì)失去對(duì)每一筆操作的感知第三于合規(guī)而言自動(dòng)化購(gòu)票工具更適合定位為“信息輔助”而不是“全自動(dòng)代購(gòu)”。所以我最后的做法是腳本自動(dòng)完成到“鎖定余票、喚起通知、自動(dòng)勾選乘車人”為止提交訂單那一幀永遠(yuǎn)留給人工確認(rèn)。這既是安全底線也是給自己留一個(gè)冷靜的決策點(diǎn)。5. 踩坑實(shí)錄風(fēng)控、滑塊驗(yàn)證與頁(yè)面結(jié)構(gòu)變更的排查全流程5.1 登錄風(fēng)控連續(xù)掃碼觸發(fā)滑塊第一次跑腳本時(shí)我為了測(cè)試不同登錄方式連續(xù)在幾個(gè)context里發(fā)起掃碼結(jié)果沒一會(huì)兒頁(yè)面直接彈出了滑塊驗(yàn)證之后連續(xù)幾次登錄都要求二次驗(yàn)證。排查下來(lái)問題出在“同一IP短時(shí)間內(nèi)頻繁請(qǐng)求登錄二維碼接口”。12306對(duì)登錄接口的風(fēng)控閾值并不高連續(xù)兩三次請(qǐng)求就可能觸發(fā)高頻警告。解決方案很粗暴把重試間隔拉長(zhǎng)至少間隔1分鐘以上并且盡量使用同一個(gè)穩(wěn)定的網(wǎng)絡(luò)出口。如果已經(jīng)觸發(fā)了滑塊別硬剛手動(dòng)過掉一次讓賬號(hào)恢復(fù)正常狀態(tài)然后再繼續(xù)。5.2 高頻請(qǐng)求觸發(fā)“訪問人數(shù)過多”與退避策略監(jiān)控循環(huán)跑久了偶爾會(huì)遇到頁(yè)面返回一個(gè)“當(dāng)前訪問人數(shù)過多”的提示車次列表完全刷不出來(lái)。第一次遇到時(shí)我以為是腳本寫崩了查了半天發(fā)現(xiàn)是請(qǐng)求頻率踩線。處理方式是加入指數(shù)退避邏輯retry_count 0 while True: page.reload() if 訪問人數(shù)過多 in page.inner_text(body): retry_count 1 wait_time min(60, 2 ** retry_count random.random()) time.sleep(wait_time) continue # 正常解析邏輯這個(gè)模式在很多限流場(chǎng)景下都通用遇到風(fēng)控提示不重試三次就放棄而是逐步拉長(zhǎng)間隔給服務(wù)端一個(gè)“我在退讓”的信號(hào)。5.3 頁(yè)面改版導(dǎo)致選擇器失效trace和截圖幫我定位12306的改版頻率不算低某次運(yùn)行腳本時(shí)車次列表突然解析不到數(shù)據(jù)。我沒有直接去猜選擇器而是用Playwright的trace功能記錄了完整運(yùn)行過程context.tracing.start(screenshotsTrue, snapshotsTrue) # 執(zhí)行查詢邏輯 context.tracing.stop(pathtrace.zip)然后通過命令回放playwright show-trace trace.zip在回放界面里能看到每一步操作對(duì)應(yīng)的DOM快照很快發(fā)現(xiàn)原來(lái)是車次行的class從query_model變成了query_model new列表結(jié)構(gòu)沒變但class變了。修正選擇器后問題解決。這種排查鏈路比“盲猜選擇器”高效得多也是我把這套流程稱為“自動(dòng)化測(cè)試項(xiàng)目”而不是“搶票腳本”的原因——它用工程手段處理不確定性。5.4 登錄態(tài)過期后的優(yōu)雅降級(jí)storage_state不是永久有效的。12306的登錄態(tài)可能幾個(gè)小時(shí)就失效也可能撐幾天完全取決于服務(wù)端策略。最初我沒做這個(gè)判斷腳本從早跑到晚突然某個(gè)時(shí)刻所有查詢都返回登錄頁(yè)面。后來(lái)在解析前加了一步if /login in page.url: send_dingtalk(登錄態(tài)已失效請(qǐng)重新掃碼更新state.json) break一旦檢測(cè)到跳轉(zhuǎn)登錄頁(yè)立即停止腳本并通知我而不是繼續(xù)徒勞刷新。重新登錄一次、覆蓋state.json、再啟動(dòng)整個(gè)過程兩分鐘。這個(gè)判斷雖然簡(jiǎn)單卻避免了很多無(wú)效運(yùn)行時(shí)間。6. 合規(guī)底線與正確使用姿勢(shì)6.1 自動(dòng)化能做什么不能做什么聊到這里必須把邊界說(shuō)清楚。能做的查詢余票、監(jiān)控狀態(tài)變化、發(fā)送提醒、在瀏覽器內(nèi)模擬普通用戶的正常操作流程。這些不涉及對(duì)服務(wù)端正常邏輯的破壞本質(zhì)上是“用自動(dòng)化工具幫人看得更勤快”。不能碰的繞過或破解人機(jī)驗(yàn)證、偽造加密參數(shù)直接調(diào)接口、用腳本惡意高頻請(qǐng)求干擾服務(wù)、批量注冊(cè)賬號(hào)、任何形式的商業(yè)代搶。這些行為不僅違反用戶協(xié)議也可能觸犯相關(guān)法律法規(guī)風(fēng)險(xiǎn)完全不可控。我始終把這段代碼定義成“購(gòu)票輔助工具”而不是“搶票外掛”就是在劃定這個(gè)邊界。6.2 頻率、禮貌與賬號(hào)安全自動(dòng)化操作的最起碼禮貌是別給服務(wù)端制造異常壓力。個(gè)人使用場(chǎng)景下我建議單賬號(hào)刷新間隔不低于3秒。同時(shí)監(jiān)控的并發(fā)任務(wù)控制在個(gè)位數(shù)。不要自作聰明地開幾十個(gè)瀏覽器上下文去并行搶票。避免把登錄態(tài)文件提交到公開代碼倉(cāng)庫(kù)文件里包含完整的Cookie信息。優(yōu)先用自己的主賬號(hào)做低頻率查詢高頻率測(cè)試用不重要的賬號(hào)去跑。這幾點(diǎn)同時(shí)也是保護(hù)賬號(hào)安全的手段。被風(fēng)控鎖賬號(hào)的損失遠(yuǎn)比“晚了幾秒刷票”大得多。6.3 保持“人做決策”的鐵律自動(dòng)化幫我把“盯屏幕”的重復(fù)勞動(dòng)扛了下來(lái)這是它最大的價(jià)值。但在支付確認(rèn)、退改簽、異常訂單處理這些環(huán)節(jié)我始終保留人工操作。原因很樸素工具能做的是發(fā)現(xiàn)機(jī)會(huì)最終決定權(quán)一定要留在有判斷能力的人手里。票價(jià)不對(duì)、日期看錯(cuò)、乘車人選錯(cuò)任何一個(gè)失誤都可能造成實(shí)際損失而這些失誤恰恰是高度自動(dòng)化流程里最難預(yù)設(shè)的。把自動(dòng)化當(dāng)成“另一雙眼睛”而不是“另一只手”是我折騰完整套方案后最想強(qiáng)調(diào)的一點(diǎn)。最后再分享一個(gè)實(shí)用技巧這套思路并不局限于12306。只要你面對(duì)的是一個(gè)網(wǎng)頁(yè)版業(yè)務(wù)系統(tǒng)凡是包含重復(fù)查詢、狀態(tài)監(jiān)控、手動(dòng)刷新這類環(huán)節(jié)的都可以用同樣的方式去改造。我后來(lái)把同一個(gè)Playwright模板用在了預(yù)約掛號(hào)、搶公開課名額、庫(kù)存監(jiān)控等場(chǎng)景只需要改改選擇器和通知邏輯整個(gè)框架完全可以復(fù)用。