☰
Python自动化监控12306余票:从接口分析到通知推送的工程实践
2026/10/14 15:04:37 网站建设 项目流程

简介:这是一份面向Python初学者与自动化爱好者的12306自动抢票脚本源码,针对中秋、国庆、春节等出行高峰一票难求的场景,提供电脑端购票的自动化实现思路。资源以rar压缩包分发,共8个文件、约6KB,主体为1个Python脚本,另含4个xml配置、1个iml模块文件、1个name文件及1个gitignore,整体属于轻量级工程结构,便于直接阅读与二次修改。目前已有2168人学习下载,说明该脚本在抢票需求下具备一定参考价值。读者可从中了解购票流程的自动化组织方式、请求参数构造与脚本运行逻辑,并借助工程配置文件快速在本地IDE中导入调试,适合作为Python网络请求与自动化实战的练手案例,也可在此基础上按自身需求调整车次、日期等参数。

1. 抢票脚本的真实边界:它到底在解决什么问题

每年春运前后,总有人问我能不能写个 Python 脚本自动抢火车票。先说结论:12306 的余票查询接口是公开可访问的,但下单环节涉及登录态、验证码、风控和设备指纹,纯脚本能做的远比想象中少。我见过太多人兴冲冲写了个 requests 循环刷票,结果要么被限流封 IP,要么卡在滑块验证上一步都走不动。

这个方向真正有价值的场景不是"秒杀式抢票",而是自动化监控 + 有票提醒 + 半自动下单。具体来说:定时轮询指定车次的余票状态,一旦出现符合预期的票(比如二等座、指定时间段),立刻通过通知渠道推送到手机,人再手动去 12306 完成支付。这样既绕开了最难的验证码环节,又能把"盯票"这件枯燥的事交给机器。

适合谁看?有 Python 基础、懂 HTTP 请求、能自己看浏览器开发者工具抓包的人。如果你连 requests 库都没用过,建议先补一下 HTTP 协议和 Session 机制再来。下面我从接口分析讲到代码实现,再到风控规避,把这条路上能踩的坑都摆出来。

2. 拆解 12306 余票查询接口:从抓包到构造请求

2.1 为什么不能直接 requests.get 就完事

12306 的余票查询接口地址是https://kyfw.12306.cn/otn/leftTicket/query系列,但直接请求会返回错误或空数据。原因有三层:第一,接口需要携带Cookie中的JSESSIONID和RAIL_EXPIRATION等字段,这些是访问首页后由服务端下发的;第二,请求头里的User-Agent、Referer、Host必须匹配浏览器行为;第三,部分接口有X-Requested-With或自定义头校验。

我一般会先用浏览器打开 12306 首页,按 F12 进 Network 面板,手动查一次票,找到query请求,右键 Copy as cURL,然后转成 Python 代码。这一步能拿到最真实的请求头和 Cookie 结构。

2.2 用 Session 维持登录态的最小代码

import requests import time import re # 创建 Session 对象,自动维持 Cookie session = requests.Session() # 第一步:访问首页,获取初始 Cookie index_url = "https://kyfw.12306.cn/otn/leftTicket/init" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36", "Referer": "https://kyfw.12306.cn/otn/leftTicket/init", "Accept": "*/*", "Accept-Language": "zh-CN,zh;q=0.9", } session.headers.update(headers) resp = session.get(index_url, timeout=10) print("首页状态码:", resp.status_code) print("获取到的 Cookie:", session.cookies.get_dict())

这段代码的核心是requests.Session(),它会在后续请求中自动带上服务端 Set-Cookie 下发的字段。headers里的User-Agent建议和你实际浏览器保持一致,Referer必须指向 12306 域内地址,否则会被拦截。timeout=10是防止网络卡死,生产环境建议设 5 到 8 秒。

2.3 构造余票查询请求与参数说明

拿到 Cookie 后,就可以构造查询请求了。关键参数包括:leftTicketDTO.train_date(乘车日期,格式 2025-01-20)、leftTicketDTO.from_station(出发站电报码,如北京为 BJP)、leftTicketDTO.to_station(到达站电报码)、purpose_codes(固定为 ADULT)。

# 车站电报码需要提前查表,这里以北京到上海为例 from_station = "BJP" # 北京 to_station = "SHH" # 上海 train_date = "2025-01-20" query_url = "https://kyfw.12306.cn/otn/leftTicket/query" params = { "leftTicketDTO.train_date": train_date, "leftTicketDTO.from_station": from_station, "leftTicketDTO.to_station": to_station, "purpose_codes": "ADULT", } # 查询前先访问一次 init 页面刷新 Cookie session.get(index_url, timeout=10) result = session.get(query_url, params=params, timeout=10) if result.status_code == 200: data = result.json() # 余票数据在 data["data"]["result"] 列表中,每项是竖线分隔的字符串 for item in data.get("data", {}).get("result", [])[:3]: fields = item.split("|") print("车次:", fields[3], "| 出发:", fields[8], "| 到达:", fields[9], "| 二等座余票:", fields[30]) else: print("请求失败,状态码:", result.status_code)

返回的result是一个字符串列表,每个元素用|分隔,字段位置是固定的。fields[3]是车次号,fields[30]左右是二等座余票数量(不同时期字段位置可能微调,建议打印完整字段对照)。如果返回{"data": {"result": []}}或直接报错,大概率是 Cookie 过期或 IP 被限流,需要重新访问首页刷新。

提示:车站电报码不是拼音缩写,北京是 BJP、上海是 SHH、广州是 GZQ,完整对照表可以在 12306 页面源码的station_name.js里找到。

3. 轮询策略与通知推送:让脚本替你盯票

3.1 轮询间隔怎么设才不会被封

这是血泪经验:查询接口的容忍度大约在 5 到 10 秒一次,低于 3 秒连续请求,快则几分钟慢则半小时就会被限流。被限流的典型表现是返回 403 或直接跳转到验证页面。我一般会把间隔设在 8 到 15 秒之间,并且加随机抖动,避免固定频率被识别为机器行为。

import random def poll_tickets(session, params, interval_range=(8, 15), max_rounds=100): """轮询查询余票,带随机间隔""" for i in range(max_rounds): try: resp = session.get( "https://kyfw.12306.cn/otn/leftTicket/query", params=params, timeout=10 ) if resp.status_code != 200: print(f"第 {i+1} 轮请求异常,状态码 {resp.status_code}") time.sleep(30) # 异常时拉长等待 continue data = resp.json() results = data.get("data", {}).get("result", []) for item in results: fields = item.split("|") train_no = fields[3] seat_2nd = fields[30] # 余票为数字且大于 0 时触发通知 if seat_2nd.isdigit() and int(seat_2nd) > 0: print(f"发现余票!车次 {train_no},二等座 {seat_2nd} 张") return fields # 找到即返回,交给通知模块 wait = random.uniform(*interval_range) print(f"第 {i+1} 轮无票,等待 {wait:.1f} 秒") time.sleep(wait) except Exception as e: print("轮询异常:", e) time.sleep(20) return None

interval_range控制随机等待区间,max_rounds防止无限循环。异常时time.sleep(30)是给服务端喘息时间,也降低被封概率。找到票后直接return,由上层决定是发通知还是继续监控其他车次。

3.2 用 Server 酱或邮件做通知落地

找到票之后,最关键的是让你第一时间知道。常见做法有两种:邮件 SMTP 推送和第三方推送服务。邮件最通用,不依赖额外账号。

import smtplib from email.mime.text import MIMEText def send_email_notify(train_info, sender, password, receiver): """发送余票提醒邮件""" subject = f"余票提醒:{train_info[3]} 有票" body = f"车次 {train_info[3]},出发 {train_info[8]},到达 {train_info[9]},二等座 {train_info[30]} 张。请尽快登录 12306 下单。" msg = MIMEText(body, "plain", "utf-8") msg["Subject"] = subject msg["From"] = sender msg["To"] = receiver # 使用 SMTP_SSL 连接邮件服务商,端口一般为 465 with smtplib.SMTP_SSL("smtp.example.com", 465, timeout=10) as server: server.login(sender, password) server.sendmail(sender, [receiver], msg.as_string()) print("邮件已发送")

sender是你的发件邮箱,password是邮箱的授权码(不是登录密码),receiver可以是同一个邮箱或手机邮箱。smtp.example.com需要替换成你实际使用的邮件服务商地址。这段代码只负责通知,不涉及下单,所以完全合规。

注意:授权码泄露等于邮箱被控制,建议单独注册一个只用于通知的邮箱,不要用主邮箱。

3.3 多车次并行监控的取舍

如果你要监控多个车次或多个日期,串行轮询会让每轮间隔变得很长。我一般用concurrent.futures.ThreadPoolExecutor开 2 到 3 个线程,每个线程负责一组查询条件,但总请求频率仍然控制在安全线内。线程不是越多越好,超过 5 个线程同时打接口,基本等于自爆。

from concurrent.futures import ThreadPoolExecutor def monitor_task(params): """单个监控任务""" return poll_tickets(session, params, max_rounds=50) # 两组查询条件,最多 2 个线程并行 tasks = [params_set_1, params_set_2] with ThreadPoolExecutor(max_workers=2) as executor: futures = [executor.submit(monitor_task, p) for p in tasks] for f in futures: result = f.result() if result: send_email_notify(result, sender, password, receiver)

max_workers=2是保守值,实际可以根据被封情况调整。每个线程内部仍然有随机等待,所以总 QPS 不会叠加得太夸张。

4. 避坑与排查:那些让我翻车的细节

4.1 坑一:Cookie 过期导致查询全部返回空

现象:脚本跑了几十分钟后,突然所有查询都返回{"data": {"result": []}},但手动打开 12306 明明有票。

原因:12306 的JSESSIONID和RAIL_EXPIRATION有有效期,通常几十分钟到两小时不等。Session 不会自动刷新这些字段。

解决:在轮询循环里加一个计数器,每 20 到 30 轮重新访问一次init页面刷新 Cookie。或者检测到连续 3 次返回空结果时,主动刷新。

refresh_counter = 0 # 在循环内 refresh_counter += 1 if refresh_counter % 25 == 0: session.get(index_url, timeout=10) print("已刷新 Cookie")

4.2 坑二:车站电报码写错导致查不到车

现象:请求返回 200,但result列表为空,换日期也没用。

原因:from_station和to_station必须用 12306 内部的电报码,不是城市拼音也不是车站全称。比如"北京"是 BJP,"北京南"是 VNP,两者不通用。

解决:从 12306 页面源码里下载station_name.js,用正则提取所有@电报码|中文名对,存成字典备用。不要凭记忆写。

4.3 坑三:请求头缺失 Referer 被直接 302

现象:session.get(query_url)返回 302 重定向到登录页或错误页。

原因:12306 校验Referer是否在域内,缺失或写成外部地址都会被拒。

解决:确保session.headers里Referer设为https://kyfw.12306.cn/otn/leftTicket/init,并且每次请求前不要覆盖掉。

4.4 坑四:高频请求触发滑块验证

现象:查询接口开始返回带验证码的 HTML 页面,而不是 JSON。

原因:请求频率过高或 IP 被标记。

解决:立即停止脚本,等待 30 到 60 分钟再试。把轮询间隔拉长到 15 秒以上,并加入随机抖动。如果持续被封,考虑更换网络出口(比如切换手机热点),但不要用任何自动化打码方案,那是另一个深坑。

4.5 坑五:把查询脚本写成下单脚本

现象:想进一步自动提交订单,结果卡在验证码或提交环节,甚至账号被临时限制。

原因:下单接口涉及token、validate等动态参数,还有滑块和设备指纹校验,纯脚本几乎不可能稳定通过。

解决:认清边界,脚本只做监控和通知,下单手动完成。这不是技术不行,是风控设计如此,硬闯只会浪费时间。

5. 进阶技巧:把脚本做成可长期运行的服务

5.1 用配置文件管理查询条件

硬编码在代码里的查询条件改起来很麻烦。我习惯用一个config.json管理所有参数,脚本启动时读取,这样换日期、换车次不用动代码。

{ "train_date": "2025-01-20", "from_station": "BJP", "to_station": "SHH", "interval_min": 8, "interval_max": 15, "max_rounds": 200, "notify_email": "receiver@example.com" }
import json with open("config.json", "r", encoding="utf-8") as f: cfg = json.load(f) params = { "leftTicketDTO.train_date": cfg["train_date"], "leftTicketDTO.from_station": cfg["from_station"], "leftTicketDTO.to_station": cfg["to_station"], "purpose_codes": "ADULT", } interval_range = (cfg["interval_min"], cfg["interval_max"])

这样调整策略只需要改 JSON 文件,不用重新读代码逻辑。

5.2 日志记录与异常恢复

长期运行的脚本必须有日志,否则出了问题只能靠猜。我用 Python 内置的logging模块,把每轮查询结果、异常、通知发送情况都写到文件里。

import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[ logging.FileHandler("ticket_monitor.log", encoding="utf-8"), logging.StreamHandler() ] ) logging.info("监控启动,日期 %s,%s -> %s", cfg["train_date"], cfg["from_station"], cfg["to_station"])

日志文件按天切割可以用TimedRotatingFileHandler,避免单个文件无限增长。异常恢复方面,我在主循环外层包一个while True,捕获所有异常后等待 60 秒重新进入,这样即使网络抖动或临时封禁,脚本也不会直接退出。

5.3 验证脚本是否真的在工作

写完脚本别急着挂后台,先做三个验证:第一,手动查一次票,确认当前确实有票或无票,再跑脚本看结果是否一致;第二,把轮询间隔临时调到 30 秒,观察日志输出是否正常;第三,故意填错一个车站码,看异常处理是否按预期记录日志而不是崩溃。

我自己的习惯是,任何监控脚本上线前先空跑两小时,确认没有内存泄漏、Cookie 刷新正常、通知渠道畅通。抢票这件事,脚本稳定比脚本快更重要。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询