简介:这是一份面向12306购票场景的Python自动化脚本,适合需要在春运、国庆等出行高峰抢票的用户,也可供对网络请求与自动化操作感兴趣的开发者参考。脚本围绕12306电脑端购票流程,提供了自动抢票的Python实现思路,包含请求构造、余票查询与提交订单等关键环节,并已标注为亲测有效。资源包整体较小,共8个文件,以1个主脚本12306.py为核心,辅以xml项目配置、.gitignore、.iml等文件,总计约6KB,便于快速查看与移植学习。当前已有2169人浏览学习。对于希望提升购票成功率或研究票务自动化逻辑的读者,这份脚本可作为一个可运行的轻量示例,帮助理解从登录到抢票的完整流程,并在此基础上按需调整策略。
1. 抢票脚本不是玄学:12306 也能用 Python 优化买票效率
开票那一秒,页面转三圈,余票从“有”变成“无”,这种经历很多坐火车的人都经历过。与其拼手速,不如让 Python 在后台盯着接口:这套 12306 自动抢火车票脚本做的事,就是替你反复查余票、发现车次有票就提交订单,把人工刷新的几百毫秒省掉。它能解决“开售瞬间抢不到”“备选车次盯不过来”“白天上班没法一直刷”的问题。适合有点 Python 基础的普通用户,也适合想看看购票系统接口长什么样的开发者。需要提醒的是,脚本只是调用官方购票系统的接口,车票、票价、实名制规则完全跟着官方走,个人自用、不以转售牟利是前提。
2. 先拆流程再写代码:购票链路里真正决定手速的四个环节
抢票脚本看着像个黑匣子,拆开无非四个环节:保持登录会话、查余票、提交订单、排队确认。我先把这条链路讲清楚,是因为很多翻车不是因为代码少写一行,而是对系统响应时序理解错了。比如“查到有票”和“提交成功”中间隔着一个订单接口,这个接口背后还会做实名核验、席位锁定、排队判断;你把查询结果当成最终结果,就会在无票时反复提交,把自己逼进风控名单。下面逐个环节讲,每个环节都会标出参数边界。
2.1 登录与会话保持:下单接口的前置条件
先讲登录。12306 的查询余票接口并不强制登录,但提交订单、获取乘车人、确认排队都必须带着登录后的 cookie。所以脚本第一步不是查询,而是先把会话建好。很多开源脚本把登录做成二维码扫码:打开登录页拿二维码,用手机上的官方 App 扫一下,然后把 cookie 保存到本地文件,之后每次启动直接加载上次的会话。这个做法的好处是不用把密码写在脚本里,也少碰滑块验证码;我一般会建议第一次用扫码登录,给脚本 30 秒等待二维码确认,超时后重新拿。
import requests s = requests.Session() s.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36", "Referer": "https://kyfw.12306.cn/otn/leftTicket/init" }) # 第一次抢票前先手动完成一次扫码登录,把 cookie 持久化 def persist_cookie(session): session.get("https://kyfw.12306.cn/otn/login/init", timeout=5) # 这里用包内封装的扫码登录逻辑等待用户扫码 # 登录成功后,把 session.cookies 保存到 local_cookie.txt # 下次启动直接加载,避免重复扫码这里有几个容易忽略的点:一是请求头里的 User-Agent 不要用默认的 python-requests,否则容易被辨识;二是 Referer 要带一个购票相关的页面,模拟从车站页面发起的请求;三是 cookie 有有效期,通常隔夜就失效,第二天抢票前要重新扫码。我见过有人把 cookie 文件当成万能钥匙,发现失效后又开始怀疑脚本,其实是会话过期而已。
登录完成后,最好做一个会话探测。可以请求一个需要登录态的接口,比如查询当前登录用户昵称,如果返回 302 跳转或者提示“未登录”,就说明 cookie 已经失效。这一步在开票当天启动脚本时特别关键:查询接口允许未登录访问,所以即使 cookie 失效,脚本上半段看起来仍然正常,等真的要下单时才暴露问题。
2.2 车站编码与日期:两处反直觉的参数
12306 的大部分接口不接受中文站名,它们用一套三字符或四字符的电码,比如你常坐的那个大站,在接口参数里是一个大写字码,而不是中文。所以脚本里必须维护一份车站映射表,来源是官方站点名文件 station_name.js。这份文件里插着所有车站的名称、拼音和电码,脚本可以用正则把它解析成字典。
import re resp = s.get("https://kyfw.12306.cn/otn/resources/js/framework/station_name.js", timeout=5) # 内容形如 @xxx|中文站名|电码|拼音|拼音简写|0,分隔符是 | pairs = re.findall(r'@([a-z]+)\|([\u4e00-\u9fa5]+)\|([A-Z]+)', resp.text) station_map = {name: code for _, name, code in pairs} print(station_map.get("示例站")) # 这里只是演示,返回值是电码日期格式也比想象的严格:它要YYYY-MM-dd,也就是月份和日期都要两位数字。如果你用 Python 的strftime("%Y-%-m-%-d")得到2025-4-1,接口会直接当成错误日期处理,更隐蔽的是它不会报参数错误,而是返回空余票,让你误以为当天没有票。这算是我踩过的比较深的一个坑,后面排查章还会再提。
为什么要特别强调这两个参数?因为大多数脚本“看起来没反应”的问题,最后都定位到这里。请求正常返回了,cookie 也有效,日志里也有数据,但就是匹配不到车次;把请求 URL 复制到浏览器一看,发现日期少了个零,或者站点传成了中文。这类错误不需要懂什么高深原理,只要把输入参数规整好就能避免。
2.3 余票查询接口:真实抢票入口长这样
余票查询是抢票脚本的入口。老版本接口是/otn/leftTicket/query,后来大部分线路被换成了/otn/leftTicket/queryG。两种接口的响应结构基本一样:data.result是一个字符串数组,里面每一个元素用|分隔,存了车次、出发站、到达站、发车时间、到达时间以及各席别余票。
def query_left_ticket(date_str, from_code, to_code, purpose="ADULT"): url = "https://kyfw.12306.cn/otn/leftTicket/queryG" params = { "leftTicketDTO.train_date": date_str, "leftTicketDTO.from_station": from_code, "leftTicketDTO.to_station": to_code, "purpose_codes": purpose, } try: r = s.get(url, params=params, timeout=5) r.raise_for_status() payload = r.json() except Exception as e: print("查询请求异常:", e) return [] result = payload.get("data", {}).get("result") or [] return result这段代码有三个值得说明的参数:date_str是2025-04-01这样的字符串;from_code和to_code是上一节说的电码;purpose_codes=ADULT表示成人票,如果是学生票则要传0X0001一类,不过抢票场景基本只关心成人票。
拿到result之后,不能直接按数组下标写死解析,因为接口在几次改版中调整过字段顺序。我一般会在第一次运行时把某一条结果完整打印出来,用返回内容去对齐脚本里的解析映射表,确认第几列是车次、第几列是二等座余票。常见的字段布局大致如下:
| 字段位置 | 含义 | 说明 |
|---|---|---|
| 0 | secret_str | 下单要用到的唯一标识 |
| 1 | train_no | 系统内部车次编号 |
| 2 | 车次号 | 展示给用户看的车次名 |
| 3 | 发车时间 | 24 小时制 |
| 4 | 到达时间 | 可能跨天 |
| 7 | 二等座余票 | 可能是“有”“无”或数字 |
这张表只是常见布局示例,不是永久标准。脚本如果写死parts[7]表示二等座,隔了一个月可能就错位了。所以比写死更靠谱的做法是,在解析函数里加一个debug=True开关,把原始行输出到日志,每次升级脚本前先看一眼。
2.4 提交订单与排队:从“查到”到“买到”还有一次握手
查询到有票之后,下一步是提交订单。这一步必须带着登录 cookie,请求方式是 POST,表单里有train_no、train_date、from_station_telecode、to_station_telecode、seat_detail_type、tour_flag=dc等字段。其中train_no不是你在车站看到的那种字母加数字的“车次号”,而是系统内部的一串编号,通常从余票查询结果里取secret_str或train_no字段。这个字段拿错的话,下单接口会直接返回“车次不存在”。
def submit_order(train_no, date_str, from_code, to_code, seat_type_code): payload = { "train_no": train_no, "train_date": date_str, "from_station_telecode": from_code, "to_station_telecode": to_code, "seat_detail_type": seat_type_code, "tour_flag": "dc", "purpose_codes": "ADULT", "whatsSelect": "1", "_json_att": "", } r = s.post("https://kyfw.12306.cn/otn/leftTicket/submitOrderRequest", data=payload) resp = r.json() if resp.get("status") is True and resp.get("data") and resp["data"].get("count") is not None: return True return False提交成功后,系统会返回一个排队相关信息,并不是立刻出票。你要继续请求确认接口,把乘车人、席别、订单批次传给系统,系统才会真正进入排队队列。换句话说,“提交订单”只是占住一个处理名额,后面的确认流程同样需要脚本继续跑。不少第一次写抢票脚本的人在这里断掉,以为提交成功就完事了,结果票根本没锁住。
座位类型编码也不是中文,脚本里维护一张映射表,常见值如商务座、一等座、二等座分别对应不同编码。你不需要背下来,但自定义配置时要去查一下,不要用“二等座”三个字直接传给接口。
2.5 抓包定位接口:不依赖文档的排查方法
没有官方文档的时候,抓包是唯一可靠的方法。我一般会用浏览器打开购票页面,登录后按 F12,在 Network 面板里过滤leftTicket或confirm,然后手动刷一次余票,找到对应的请求,再右键复制为 cURL,最后转成 Python 代码。这个流程可以验证所有需要手动确认的参数。
具体步骤很简单:第一步,在浏览器里正常登录并查一次余票;第二步,找到queryG这个请求,查看 Query String Parameters;第三步,找到提交订单的请求,查看 Form Data;第四步,把请求复制成 curl,再用工具转成 requests 代码。这套方法不限于本项目,几乎所有接口型脚本都能这么排查。
如果你的脚本和我一样是拆解别人的资源包,抓包的重点不是把整个流程重写一遍,而是对比包内代码里的参数名和线上版本是否一致。网上很多开源抢票脚本因为接口改版已经没法直接用,但核心逻辑仍然值得参考,照着抓包结果改参数通常就能救回来。
3. 核心脚本怎么落地:定时查询、下单排队和多线程并行
原理章节讲完之后,开始落地。一个能用的抢票脚本,至少要有三个组件:带随机间隔的查询循环、解析余票并触发下单的分配器、最后是多线程并行抢多车次的能力。这一章我把最常写的片段贴出来,并说明为什么要这样组织,而不是把代码堆在一起。
3.1 查询循环:别把接口当 WebSocket 用
最直观的做法是while True里查余票,这个思路没错,但很多人在频率上栽跟头。我一般不推荐固定间隔循环。固定间隔容易被识别成机器行为,而且高峰期可能会把出口 IP 打出问题;随机间隔更接近人手点刷新时的行为。每次循环之间用random.uniform控制,比如 3 到 8 秒之间浮动。
开票前 20 分钟可以开始跑,但最好先校准系统时间。脚本所在机器的时间如果和官方服务器差几秒,提前或延后启动都会影响判断。最省事的办法是打开系统的自动时间同步,或者在脚本里用时间服务器校准一次。不要凭手机上的“北京时间”改电脑时间,因为你的手机可能也存在几十毫秒误差。
import time import random def run_loop(tasks, interval=(3, 8)): for task in tasks: now = time.strftime("%H:%M:%S") if now >= task["sale_time"]: result = query_left_ticket( task["date"], task["from_code"], task["to_code"] ) matched = parse_train_and_filter(result, task["wanted_seat"]) if matched: # 命中后先通知,再走下单流程 print("命中车次:", matched["display_no"]) submit_and_confirm(task, matched) break # 随机等待,避免变成定时请求 wait = random.uniform(*interval) time.sleep(wait)run_loop里的tasks是一个配置列表,每一项包含车次日期、出发到达站电码、期望席别、放票时间。命中后先打印日志,再提交订单。你可能想问为什么命中后 break,而不是继续查别的车次?因为多车次备选的逻辑我放在多线程里处理,单线程内一旦下单成功就应该停止该线程,避免重复提交同一张票。
3.2 余票解析:用配置化字段替代硬编码
解析余票行时,建议用一个字典来定义字段顺序,而不是在代码里到处写下标。这样做的好处是当接口字段顺序变化时,只需要改一处映射。我先演示一个面向“二等座有票”的判断逻辑:
FIELD_MAP = { "secret_str": 0, "train_no": 1, "display_no": 2, "start_time": 3, "arrive_time": 4, "business": 5, "first_class": 6, "second_class": 7, } def parse_train_and_filter(result_lines, seat_name="second_class"): tickets = [] for line in result_lines: parts = line.split("|") if len(parts) < 12: continue ticket = { "train_no": parts[FIELD_MAP["train_no"]], "display_no": parts[FIELD_MAP["display_no"]], "start_time": parts[FIELD_MAP["start_time"]], "arrive_time": parts[FIELD_MAP["arrive_time"]], "seat_state": parts[FIELD_MAP[seat_name]], } tickets.append(ticket) return tickets这里有一个非常重要的细节:余票行里的第 0 列其实是secret_str,也就是下单要用的唯一标识,不是无用数据。部分接口版本会把train_no放在第 1 列,车次号放在第 2 列。但我不建议把这张表当成永久标准,因为线上接口做过微调。最稳的做法:上线前用一条真实余票响应打日志,人工核对FIELD_MAP与实际字段是否一致,再开始抢票。
余票状态的取值也要小心。常见的有“有”“无”和数字三种,数字表示剩余张数。数字是 0 时表示无票,但“有”并不保证一定买得到,这涉及缓存问题,后面避坑章会专门讲。
3.3 下单与确认:把两步拆开,中间允许重试
下单确认环节,建议把submit_order和confirm_queue拆成两个函数,之间留重试和控制。因为submit_order返回成功后,理论上应该立刻进入确认乘车人的流程,但网络抖动可能导致确认请求失败,此时如果直接放弃就浪费了一次抢票机会。我会记录当前已经成功提交的订单号,然后对后续确认请求做最多三次重试,每次间隔 1 秒;如果三次都失败,再把这个车次切给备用线程。
def submit_and_confirm(task, matched): ok = submit_order( train_no=matched["train_no"], date_str=task["date"], from_code=task["from_code"], to_code=task["to_code"], seat_type_code=task["seat_code"], ) if not ok: print("下单提交失败,稍后重试") return for attempt in range(3): result = confirm_queue(order_info=matched["secret_str"]) if result["status"]: print("确认成功,订单已进入排队") return time.sleep(1) print("确认接口连续失败,请查看日志")confirm_queue在示例里简化成接收secret_str,但实际它还需要乘客编号、席别和订单批次。这些参数通常要再请求一次乘车人接口拿列表。下载的脚本包里会把这些封装好,我不建议在抢票高峰期现场改参数字段。如果你是从零写,第一次没有跑通很正常,把 Flask 或 FastAPI 那套调试经验拿过来,逐层打印响应内容就行了。
3.4 多线程与多账户:提升命中率,也放大风险
最后说多线程。当你要同时盯好几个车次时,可以用ThreadPoolExecutor,每个任务一个 Worker,但不同线程不能直接共用一个requests.Session,因为 Session 内部状态在并发时会乱掉,可能在错误线程用了另一个线程的 cookie。常规做法是每个线程自己requests.Session(),或者在线程内部复制一份 cookie。下面是一个最小示例:
from concurrent.futures import ThreadPoolExecutor, as_completed def worker(cfg): # 每个线程独立 session,避免 cookie 串线 local_session = build_session_with_saved_cookie() cfg["session"] = local_session return run_loop([cfg], interval=(2, 5)) configs = [ {"train": "T0001", "sale_time": "12:00:00", "date": "2025-04-01", "...": ""}, {"train": "T0002", "sale_time": "12:00:00", "date": "2025-04-01", "...": ""}, {"train": "T0003", "sale_time": "12:00:00", "date": "2025-04-01", "...": ""}, ] with ThreadPoolExecutor(max_workers=3) as pool: futures = [pool.submit(worker, cfg) for cfg in configs] for f in as_completed(futures): print("任务结束", f.result())这里有一个多线程的边界:不是线程越多越好。系统风控会关注同一个 cookie 的请求频率,如果你一个账户开 10 个线程高频请求同一个车次,很容易触发风控,订单提交直接变成“请求过于频繁”。更稳妥的方案是一个账户只盯一个主力车次,另一个账户盯备选车次,但注意一个人不能给自己买多个冲突车次的票,实名制信息要自己维护好。我一般会把max_workers控制在 3 以内,并且把线程之间的查询间隔互相错开,比如线程 A 间隔 3~5 秒,线程 B 间隔 5~7 秒,减少瞬时突发。
3.5 异常处理与重试:把偶发网络抖动兜住
网络请求永远有超时和断连,抢票脚本一旦在开票瞬间抛异常退出,前面所有的准备都白费。所以我习惯把查询和下单包一层安全函数,捕获异常并按退避策略重试。
def safe_query_left_ticket(retries=3, **kwargs): for i in range(retries): try: result = query_left_ticket(**kwargs) if result: return result except Exception: time.sleep(1 + i * 0.5) return []这段代码的逻辑很简单:最多重试三次,每次失败后间隔递增。第一次失败等 1 秒,第二次等 1.5 秒,第三次等 2 秒。不要无限重试,更不要在高频查询时对同一个请求连续重试十几次,那样只会加重风控。真正该无限重试的是“查询成功但返回为空”的情况,因为开票前接口本身就可能没有数据,这是正常状态,不是异常。
这套异常处理写完后,整个脚本的骨架就完整了:登录会话、查询循环、余票解析、提交订单、确认排队、多线程和重试。如果拿到的资源包没有做异常兜底,建议先自己补上这一层,再拿去跑真实抢票。
4. 避坑排查:验证码、IP 风控与日期格式里的血泪经验
这一章是我最想先讲的部分。抢票脚本能跑通的关键往往不是代码,而是对系统限制的理解。我整理了五条踩坑记录,都是真实场景里最容易翻车的,每一条都按“现象 → 原因 → 解决”来说。
4.1 验证码会卡住整个流程:优先扫码登录
现象:脚本用账号密码登录后,在提交订单或确认乘车人时弹出滑块验证,然后整个脚本停在原地报错,订单没有提交成功。
原因:账号密码登录本身容易触发验证码,而且验证码不仅仅出现在登录时,提交订单时也可能出现;纯图像识别做得再好也架不住验证码内容变化。
解决:抢票脚本强烈建议用官方 App 扫码登录,拿到 cookie 后全程复用,不在高峰期反复登录;如果脚本已经弹出验证码,立刻停掉,手动扫码重新登录再启动。我见过有人花一周时间训练滑块识别模型,最后上线还是经常卡,换成扫码登录之后 10 分钟解决。验证码识别不是不能做,但它在抢票场景里收益太低,不值得为它牺牲稳定性。
4.2 查询频率太高:接口直接请求失败
现象:开票前几分钟脚本正常运行,连续查询几十次后突然所有请求都超时,或者返回“系统繁忙”。
原因:同一出口 IP 在短时间内发出太多查询请求,触发了限流;这和账户无关,换一个账户仍然会被挡住。
解决:把查询间隔从固定 2 秒改成随机 5~10 秒,连续跑 10 波后主动休息 30 秒;如果 IP 被暂时限制,可以断开宽带重新拨号换取新的出口 IP,再继续跑。不要试图用多线程把频率堆上去,那样只会更快触发限制。我刚开始写抢票脚本时也以为速度越快越好,实际上这些查询接口在开票瞬间响应并不慢,真正的瓶颈是提交订单和排队确认的稳定性。
4.3 余票接口有缓存:看到有票不等于能买到
现象:查询接口显示某车次二等座剩余 10 张,脚本立刻去提交订单,返回却是“没有足够的票”,或者“订单提交失败,余票不足”。
原因:余票查询接口的数据带缓存,尤其在高峰期,不同线路缓存时间有差异;即便看到 10 张,可能也已经是十几秒前的快照,真正下单时系统按最新席位重算。
解决:不要把查询结果当作最终结论,下单结果才是真正的判断依据;一个车次提交失败后可以隔 2~3 秒重试一次,但不要疯狂重试同一个车次,否则会进入限流名单。有用的技巧是,在下单接口返回失败时,顺手记录当时的余票提示文本,比如“无票”还是“排队人数过多”。前者说明该切车次了,后者说明还有希望,值得再试一次。
4.4 日期和车站编码:一个字符错就静默失败
现象:脚本在开票后跑了一分钟,日志显示每次都有返回,但整理出来的车次数量为零,人眼确认当天明明有大量车次。
原因:常见原因之一是日期格式不对,把2025-04-01写成了2025-4-1,接口没有报错但返回空列表;另一个原因是车站编码用了中文,或者用了城市名而不是具体站名,导致查询范围不对。
解决:日期全部用strftime("%Y-%m-%d")生成,站点名先通过station_map转成电码再请求;在查询函数里加一个断言,如果from_code或to_code不在映射表里,直接抛异常,不要静默继续。下面这个判断看起来简单,但帮我挡住过好几次脚本上线时的配置错误:
def resolve_station(name): code = station_map.get(name) if not code: raise ValueError(f"站点映射缺失:{name} 请检查 station_name.js 是否更新") return code我还习惯把每次实际请求的日期、站点电码一起写进日志,因为“看起来正常但结果不对”的问题,很多都出在输入参数上。日志里看到2025-4-1这种值,一眼就能定位,不用猜。
4.5 登录状态过期后抢票静默失败
现象:前一天用脚本预约好了任务,第二天放票时间一到,日志里查询请求都正常,但提交订单时全部失败,提示需要重新登录。
原因:登录会话有效期不是永久的,跨天或长时间无操作后 cookie 会失效;查询接口允许未登录访问,所以上半段脚本看起来一切正常,下单时才发现会话已经过期。
解决:上线前先查看本地保存的 cookie 是否仍然有效,可以在启动脚本时请求一个需要登录的接口做探测,比如查询当前登录用户的昵称;如果探测失败,先重新扫码登录再开始抢票。这个探测动作只要十几行代码,但能避免你在开票前才发现会话已经掉了。
我做会话探测时用的是一种“最小请求”的思路:不查完整个人信息,只请求一个轻量接口,响应 200 就算有效;如果返回 302 到登录页,立刻停止并提示。这样既快又不会因为频繁请求用户信息接口引发额外风控。
5. 让脚本更像真人:限速、结果验证与日志复盘
抢票脚本跑通只是第一步,真正让它稳定的是“像真人”的运行方式。我会在每次上线前把三个验证动作固定下来,作为默认流程。
第一,查询间隔不要写成固定值。把间隔设计成 5~10 秒的随机数,模拟人切回页面看一下又切走的节奏。第二,请求头不要一直用同一个 User-Agent。准备一个常用的浏览器 UA 列表,每次请求随机取一个;不同线程之间也尽量错开。这些技巧不能保证不被识别,但能明显降低被风控的概率。
第三,所有关键节点都要有日志。脚本运行过程中,把查询到余票的响应、提交订单的返回码、确认排队的响应时间写进本地日志文件。这样第二天回看时,能定位是网络超时、接口字段变化还是登录过期。我习惯给日志加上时间戳和线程名:
import logging logging.basicConfig( filename="rob_ticket.log", level=logging.INFO, format="%(asctime)s %(levelname)s %(threadName)s %(message)s" ) logging.info("submit order status=%s", status_code)看到这条日志,你至少知道是哪个线程在哪个时刻提交了订单,而不是面对一团迷雾猜原因。
每次改完代码,我会强制跑一遍最小验证:先用明天的车次查一次余票,确认站点编码和日期正常;再故意传一个不存在的车次,确认下单接口返回失败;最后用真实日期跑一次抢票流程,抢到后立刻取消订单,验证整个链路能走通。从那以后我每次上线抢票脚本前,都会强制走一遍这三个动作,宁可在开票前多花十分钟确认,也不要在放票那一刻才发现配置错了。希望帮到你。
本文还有配套的精品资源,点击获取