1. 整体设计思路:先想清楚工具边界
1.1 自动化购票辅助工具到底解决什么问题
每年一到春运和节假日,朋友圈几乎都会变成“车票九宫格”大赛,有人在晒抢到的票,有人在吐槽验证码、排队提示和“无余票”的灰色按钮。作为一个平时就泡在自动化测试项目里的人,我第一反应是想写个脚本,把“盯余票”这种纯体力活交出去。折腾了大半年之后,我得到的结论是:这类工具并不能让你从“必然抢不到”变成“一定抢得到”,它真正的价值是帮你从高频手动刷新中解放出来,把有限的注意力留给真正需要判断的时刻。
举例来说,如果你在等一张周三晚上从北京回济南的高铁票,通常不会一整天都盯着页面。更合理的做法是让脚本每隔十秒自动查一次,一旦某个车次的二等座余票从“无”变成“有”,就用钉钉或邮件通知你,你再打开手机或者电脑决定是否下单。这中间省下来的是大量重复的点击、等待和焦虑,而不是购票规则本身。当然,我也不想把这个工具写成“暗黑抢票脚本”,它不应该绕过验证码,不应该对网站做高频攻击,更不应该去破解任何接口。它只是一个帮你“盯着页面”的自动化助手,最终下单和决策还是得由人来完成。
如果你本身就是自动化测试工程师,那这个项目更是一个难得的真实实训场景。它包含了登录态维护、动态元素等待、表格数据解析、验证码出现时的异常处理、失败重试等几乎所有UI自动化测试会遇到的问题。把12306当成测试对象,远比拿一个永远不更新的Demo网站练习要有意思得多。
1.2 为什么选 Playwright 而不是 Selenium
这个项目最核心的自动化框架,我最终选择了 Playwright,而不是更老牌的 Selenium。说实话,Selenium 在很长一段时间里都是Web自动化的默认选项,资料多,用的人也多,但它有几个痛点:第一,浏览器驱动需要单独下载,版本还容易和本机浏览器不匹配;第二,元素的自动等待需要自己处理,写不好就会频繁报“找不到元素”;第三,登录态的保存和恢复,虽然可以操作 Cookie,但代码写起来比较啰嗦。
Playwright 在这几个方面都舒服得多。安装的时候一条pip install playwright就能搞定,再执行playwright install chromium会自动下载匹配的浏览器内核。它的 locator 自带智能等待,页面元素没出现时它会自动等,而不是一上来就报错。最让我喜欢的是storage_state机制,登录完成后把整个浏览器的 Cookie 和本地存储保存到一个 JSON 文件,下次直接从这个状态恢复,代码只要三行。这对12306这种需要扫码登录的网站来说实在太友好了。
下面是一个简单的框架对比,可以直观看出区别:
| 对比维度 | Selenium | Playwright |
|---|---|---|
| 驱动安装 | 需要手动下载兼容驱动 | 一行命令自动下载 |
| 自动等待 | 需自己写 WebDriverWait | Locator 内置智能等待 |
| 登录态保存 | 自己处理 Cookie,容易漏 | storage_state 一键保存 |
| 代码生成工具 | Selenium IDE 较老 | Playwright Codegen 很好用 |
| 多浏览器支持 | 支持但配置繁琐 | Chromium / Firefox / WebKit 开箱即用 |
| 调试体验 | 一般 | 自带 Trace Viewer,方便截图和录像 |
如果你已经熟悉 Selenium,这套思路完全可以平移到 Playwright,核心逻辑不变,只是 API 换了层皮。但如果你是新手,直接上手 Playwright 更划算,市面上的教程和社区资料也已经非常多,遇到问题基本都能搜到解决方案。
1.3 和学习自动化测试的关系
很多人看到“12306自动化”会以为这只是一个购票偏门工具,但实际上它背后是一整套自动化测试的通用能力。你可以把它理解成一个“真实世界的自动化测试项目”:需要登录、需要处理反爬、需要解析动态渲染的页面、需要处理网络异常和弹窗,还需要把结果记录下来。这些场景在普通测试 Demo 里很难同时遇到,但在12306上全都撞齐了。
我在开发这个工具时,顺手用 pytest 搭了一套测试用例。比如一个用例专门验证登录态是否有效,一个用例验证余票查询结果是否正常返回,另一个用例验证收到通知消息后程序状态是否正确。用 pytest 的好处是可以跑出结构化结果,还能配合 Allure 生成漂亮的报告。这样一来,工具本身能买票,代码又可以被包装成自动化测试项目,简历上又多了一个拿得出手的实战案例。所以我不太建议一上来就写一个几百行全塞在一起的脚本,而是像我下面这样分层组织,后面维护起来会轻松很多。
2. 核心细节解析:登录、查票、下单的关键环节
2.1 登录态维护:扫码登录与Cookie持久化
12306 目前的主流登录方式是扫码登录,用户名密码登录也支持,但扫码相对安全,而且脚本里不需要保存任何敏感信息。这里我们先解决的问题是:如何只登录一次,后续每次运行脚本都在已登录状态。
Playwright 提供了storage_state,可以理解成“把浏览器当前状态拍一张快照”保存起来。首次运行时,我会打开一个非 headless 的浏览器窗口,让它停在12306首页,人用手机扫码登录。等你登录完成后,脚本把状态保存到state.json。后续再启动浏览器时,直接加载这个状态文件,就不再需要扫码了。
# login.py from playwright.sync_api import sync_playwright def save_login_state(): with sync_playwright() as p: browser = p.chromium.launch(headless=False) context = browser.new_context( user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", locale="zh-CN" ) page = context.new_page() page.goto("https://www.12306.cn/index/") print("请在弹出的浏览器中完成扫码登录") input("登录完成后回到这里按回车键继续...") context.storage_state(path="state.json") print("登录状态已保存到 state.json") browser.close() if __name__ == "__main__": save_login_state()这里有一点要注意,input()会阻塞脚本直到你回车,但浏览器窗口是可以操作的,所以流程很顺畅。真正执行的时候,永远不要使用headless=True来保存登录态,因为扫码需要看到页面。如果你发现保存后的state.json文件非常小,比如只有一两百字节,那大概率是登录还没完成就回车了,正常登录后的文件至少有几 KB。
后续脚本里恢复登录态的代码更是简单:
context = browser.new_context(storage_state="state.json") page = context.new_page()我建议在脚本启动后做一个“是否真的已登录”的检查,因为 Cookie 可能因为异地登录或过期而失效。比如打开12306首页后,如果页面右上角显示“登录”而不是用户名,就说明状态过期,需要提示用户重新执行登录模块。这个检查可以在后面用 pytest 封装成一个用例。
2.2 余票查询接口与数据解析
登录态搞定之后,接下来要面对的就是查票流程。12306 网页版查询余票有两种方式:直接调用它内部的 JSON 接口,或者用 Playwright 模拟用户手动点击查询,再从渲染出来的表格里读取结果。我强烈推荐后者,原因很简单:接口的地址、参数和签名都有可能变化,而且会被风控盯上;而模拟真实页面操作虽然慢一点,但行为和真人几乎一样,稳定性高很多。
页面操作的核心步骤包括:输入出发地、输入目的地、选择日期,然后点击“查询”。这里的难点是站点输入框有自动补全,你不能简单地fill("北京")就完事,还要等补全列表出现后选中真正的选项。Playwright 的 Locator 自带等待能力,写起来还算顺手:
def select_station(page, input_selector, station_name): locator = page.locator(input_selector) locator.click() locator.fill(station_name) page.wait_for_timeout(1000) # 自动补全列表里通常第一项就是精确匹配 page.locator(f"text={station_name}").first.click() page.wait_for_timeout(300)日期我推荐直接操作页面里的日期输入框,用fill写入2025-01-28这种格式,这是个老办法,但一直能用。写完后点击一次页面空白处,让日期控件确认输入。
查询结果是一个表格,结构大概是车次、出发站、到达站、出发时间、到达时间、各席别余票。用 Playwright 拿到整个表格的 tr 节点,然后逐行解析。这里特别容易踩坑的是表头行和无数据的空行,所以代码里要做两次过滤:一是td数量不够的跳过,二是车次列内容为空的跳过。余票列的值可能是“有”“无”或者具体数字,我们只需要关注自己关心的席位。
def parse_ticket_rows(page): rows = page.locator("#ticket_list tr") result = [] for i in range(rows.count()): row = rows.nth(i) if row.locator("td").count() < 10: continue train_no = row.locator("td:nth-child(1)").inner_text().strip() if not train_no: continue seat_value = row.locator("td:nth-last-child(1)").inner_text().strip() result.append({ "train_no": train_no, "seat": seat_value, }) return result需要提醒的是,12306 的页面结构随时可能微调,所以不要迷信这些选择器。更稳妥的办法是先手动打开浏览器的开发者工具,确认当前的表格列顺序和元素 class,再改脚本。Playwright 自带codegen工具,可以录制一段操作后自动生成骨架代码,这是前期写脚本的捷径。
2.3 提交订单的坑:车次、乘车人、席别选择
查询到余票只是第一步,真正容易出问题的是提交订单环节。在余票列表里找到目标车次后,点击该行“预订”按钮,页面会跳转到订单确认页。这时候我们需要选择乘车人和席别。
先说乘车人。12306 一个账号下会保存多个乘车人,每个乘车人右侧通常有一个复选框。自动化脚本可以通过 label 文本定位到对应的复选框,例如:
page.locator("label:has-text('张三')").click()这里要注意,乘车人必须已经在12306完成实名核验,否则提交订单时会报“乘车人未通过资质核验”。这种错误不是脚本能解决的,得提前去车站窗口或自助售票机办理。所以在工具运行前,先手动登录12306把乘车人核验状态确认好。
再说席别。页面通常是一个下拉框,Playwright 用select_option处理:
page.locator("#seatType_1").select_option(label="二等座")提交订单之前,我建议脚本停在这里,通过钉钉或微信通知用户来人工完成最后一步。因为一旦完全自动化,很可能会出现选错了车次、选错了席别,甚至误提交才后悔。一个真正负责任的辅助工具,应该把人留在决策链上,而不是把一切交给代码。
2.4 验证码与风控应对:人工介入,不自动识别
12306 的反自动化机制并不算特别激进,但验证码是一定会遇到的。尤其是同一 IP 下面的请求频率高到一定阈值,系统会强制弹出图片验证码。我从不建议写代码去自动识别验证码,原因有三点:第一,自动识别准确率不高,一旦识别错,账号可能会被系统标记;第二,这会破坏购票公平性;第三,网上那些教你破解验证码的教程,大概率会把你带进坑里。
更合理的处理方式是在脚本里检测验证码是否出现,一旦出现,就通过通知工具告诉用户“需要人工操作”。人工处理完,脚本继续往下走。
if page.locator("#code_more_img").count() > 0 or page.locator(".code-msg").count() > 0: send_alert("检测到验证码,请去浏览器完成验证") page.pause() # 人工完成后手动继续除了验证码,还要控制查询频率。我的经验是:每轮查询完成之后,至少等 8 到 15 秒再进行下一轮,并且加入随机延迟,不要让请求节奏看起来像机器。下面是常用的随机等待写法:
import random, time time.sleep(random.uniform(8, 15))把这个节奏放在循环里,既不会太频繁,又比纯手动快得多。真正的“秒杀”场景里,脚本不一定比人工手速有优势,但持续监控的场景里,它明显更可靠。
3. 实操过程:从环境搭建到首版脚本跑通
3.1 环境准备
在开始写代码之前,先把运行环境准备好。我用的是 Python 3.10,操作系统 Windows 和 Ubuntu 都跑过,过程基本一致。主要是这几步:
python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install playwright playwright install chromium如果你所在网络下载 Playwright 浏览器内核比较慢,可以用环境变量或者镜像配置加速,但注意这里指的是正常的开发依赖下载,不是什么旁门左道。装好后我们验证一下:
python -c "from playwright.sync_api import sync_playwright; print('ok')"能输出ok就说明环境没问题了。目录结构建议这样组织:
ticket_assistant/ ├── config.json ├── login.py ├── monitor.py ├── state.json └── requirements.txtrequirements.txt里至少包含playwright和requests,前者是自动化框架,后者用于发送通知。
3.2 脚本编写:登录模块、监控模块、提醒模块
登录模块在上面的代码块里已经写了,这里重点说监控模块。监控模块的逻辑可以拆成三步:打开浏览器并恢复登录态、循环查询余票、发现目标车次后发送通知。
下面是一个简化的监控循环示例,目标是“发现某个车次二等座有票后,通知用户”:
# monitor.py import json import random import time import requests from playwright.sync_api import sync_playwright config = json.load(open("config.json", encoding="utf-8")) def send_alert(message): webhook = config.get("dingtalk_webhook") if webhook: requests.post(webhook, json={ "msgtype": "text", "text": {"content": message} }) print(message) def monitor(): with sync_playwright() as p: browser = p.chromium.launch(headless=True) context = browser.new_context(storage_state="state.json") page = context.new_page() page.goto("https://www.12306.cn/index/") page.wait_for_timeout(3000) while True: try: select_station(page, "#fromStationText", config["from"]) # 目的地、日期、查询按钮的处理同类型 page.click("#query_ticket") page.wait_for_selector("#ticket_list") page.wait_for_timeout(2000) rows = page.locator("#ticket_list tr") for i in range(rows.count()): row = rows.nth(i) if row.locator("td").count() < 10: continue train_no = row.locator("td:nth-child(1)").inner_text().strip() seat = row.locator("td:nth-last-child(1)").inner_text().strip() if train_no == config["target_train"] and seat in ("有", "1", "2", "3"): send_alert(f"目标车次 {train_no} 出现余票,座位状态:{seat}") break time.sleep(random.uniform(8, 15)) except Exception as e: send_alert(f"查询出错:{e}") time.sleep(30) if __name__ == "__main__": monitor()这里select_station函数可以单独提取,避免两个输入框的处理逻辑重复。另外,因为monitor()是无限循环,在测试阶段建议先限定只跑5轮,减少被风控盯上的可能。只有在确认稳定之后,再放开跑长期任务。
3.3 参数与配置:如何设置出发地、目的地、日期
硬编码“北京到上海,1月28日”在脚本里并不方便,最好把配置抽到一个 JSON 文件。下面是我现在用的config.json:
{ "from": "北京", "to": "上海", "date": "2025-01-28", "target_train": "G5", "prefer_seat": "二等座", "dingtalk_webhook": "https://oapi.dingtalk.com/robot/send?access_token=xxx" }from和to是站点名称,脚本里通过自动补全选择,不需要手动维护站码。target_train是用来匹配特定车次的,如果你不关心具体车次,只想看“有没有票”,可以把这段判断去掉,换成解析所有车次中任意一个有票就提示。prefer_seat是席别偏好,但目前我示例里只是简单的取最后一列,实际页面中二等座、一等座、商务座是多个不同列,你可以根据页面结构选取第几列。
配置dingtalk_webhook是可选功能,如果你不想用钉钉,也可以换成邮件通知,或者干脆只打印日志。这个根据自己便利来。
3.4 运行与日志:使用pytest组织测试用例,结果输出
脚本跑通之后,我建议把它工程化,用 pytest 管理登录态验证和查询功能测试。这不只是为了显得专业,更是为了让脚本后续修改时不容易改坏。一个非常实用的 pytest 用例是验证登录态是否有效:
# test_login.py import pytest from playwright.sync_api import sync_playwright @pytest.fixture(scope="module") def context(): with sync_playwright() as p: browser = p.chromium.launch(headless=True) ctx = browser.new_context(storage_state="state.json") yield ctx browser.close() def test_login_state(context): page = context.new_page() page.goto("https://www.12306.cn/index/") page.wait_for_timeout(2000) # 如果右上角还显示“登录”文本,说明登录态失败 assert "登录" not in page.locator("#login_user").inner_text()还可以写一个查询用例,用测试日期查询一次,断言页面上出现“车次”或“余票”等关键字。跑测试的时候执行:
pytest -v配合日志模块,可以记录每次查询的日期、时间、车次和余票状态,输出到logs/ticket.log。日志文件保留一定时间,方便回头分析某个时间点是否出现过票。这在节前“蹲票”的场景下很有用,能帮你了解放票规律。
4. 常见问题与排查技巧实录
4.1 登录失效怎么办
我遇到最多的一个问题,是隔几天再跑脚本时,突然查询结果为空,或者页面跳回了登录页。原因通常是 Cookie 过期、异地登录或者被系统判定为风险状态。排查时先检查state.json是否存在、大小是否正常;然后跑一次登录检测用例,如果失败就重新执行登录模块,删除旧的state.json再生成新的。
还有一个经验:12306 会对长期不活跃的会话做强制过期处理,所以如果脚本是定时任务,最好在每天第一次查询前先访问一次首页,再判断登录状态。不要等到需要提交订单时才发现登录失效,那就太晚了。
4.2 查询太频繁被限流
如果你发现脚本跑了十几分钟之后,页面上出现的验证码频率越来越高,或者直接提示“访问过于频繁”,说明你的访问节奏被风控注意到了。这不一定是因为你查询了100次,可能只是某个瞬间频率波动太剧烈。
解决方法是把查询间隔加大,并且使用随机化。比如我常年在 10 到 20 秒之间随机休息,高峰期手动跑时可以降到 5 秒,但不要长期低于这个值。另一个技巧是设置单次运行时长上限,比如在两小时内自动停止,避免无人看管时脚本一直在跑。停止之后,等一会儿再重启,通常风控就会解除。
4.3 下单失败原因分析与处理
下单环节的问题更多是业务层面的,脚本本身反而没那么容易出错。我把常见失败原因整理成一张表:
| 失败现象 | 常见原因 | 处理建议 |
|---|---|---|
| 提示“该车次已无票” | 余票信息更新延迟或真无票 | 继续监控,不强行提交 |
| 提示“乘车人未核验” | 乘车人没完成资质核验 | 提前在12306或车站办理 |
| 提示“排队人数过多” | 热门时段热门车次 | 稍等几秒重试,或启用官方候补 |
| 提示“订单提交失败” | 网络波动或服务异常 | 刷新页面重新选择车次下单 |
| 选位后没反应 | 席别下拉框没有正确选中 | 检查元素选择器,确认选中的文本 |
我在实际使用中,最稳妥的策略是:脚本只负责把用户带到“提交订单”按钮附近,然后停下来通知用户。人工确认车次、日期、乘车人、席别都正确之后,再手动点击提交。这样虽然少了“全自动”的爽快感,但能避免很多让自己后悔的误操作。
4.4 避坑经验:不要在高峰期大量请求,合规使用官方候补
这里想认真说一句,市面上那些“抢票加速包”,本质上很多都是通过高频请求帮你去刷票,成功率并没有宣传那么夸张。与其依赖这些不确定性极强的操作,不如直接在12306官方App里把候补订单下好。官方候补的优先级是实际存在的,有票先保证候补队列,脚本的作用更多是辅助监控和提醒。
另外两个细节也要注意:第一,不要在脚本里明文保存账号密码,我们全程使用扫码登录,安全得多;第二,不要同时开几十个浏览器实例去“并发抢票”,这不仅没用,还会让你的账号被系统重点关注。稳妥、低频、持续,这才是自动化辅助工具的正确姿势。
5. 扩展:把脚本升级成工程化自动化测试项目
5.1 用pytest+Allure生成报告
既然这个项目已经用 pytest 组织了,那就顺理成章接入 Allure,让测试结果可视化。先在虚拟环境里安装:
pip install allure-pytest然后在项目根目录执行:
pytest --alluredir ./allure-results allure serve ./allure-resultsAllure 会把登录态检查、余票查询、通知发送这些用例的结果展示在一份网页报告里,图表化展示通过率、失败日志、耗时等。这在小项目里可能显得重,但如果你想拿这个项目去面试或者写进技术总结,这就是一个很完整的展示案例。
5.2 定时任务与监控通知
如果不想每天手动启动脚本,可以用系统定时任务。Windows 上是“任务计划程序”,Linux 上可以用 crontab,或者用 Python 的schedule库放到脚本内部。比如每天早上 8 点到晚上 10 点之间,每 10 分钟查询一次,一旦发现目标车次有票就立刻推送提醒。
不过请注意,定时任务长期挂在后台跑,一定要控制每天的总次数。很多账号被限制,不是因为技术问题,而是因为脚本违反了平台规则。我的建议是只在你真正需要等票的时间段运行,而不是 24 小时全年无休地刷。
5.3 借鉴思路:这套框架还能用在哪些场景
把这套框架抽象一下,你会发现它其实是一个通用的“页面监控 + 提醒”模板。我后来用它做过医院放号监控、博物馆预约提醒,都只改了表单填写和结果解析部分,核心逻辑没变。如果你再花点时间封装,甚至能把登录态、查询循环、通知三个模块拆成可复用的类,作为自己的自动化测试工具库。
最后再分享一个小技巧吧。我在实际跑这个项目的过程中,发现最影响体验的不是代码,而是“心态”。当你把脚本调成每 15 秒自动查一次,并且在手机上收到一次一次“暂无余票”的提醒时,焦虑反而会减轻,因为你终于不用再盯着那个灰色按钮。真正出票的那一刻,脚本会准时告诉你。对我来说,自动化工具的终极意义不是“保证买到”,而是“把等待变得可控”。希望这篇教程也能让你的抢票季稍微轻松一点。