简介:这是一份面向Python初学者与自动化需求用户的12306火车票自动抢购脚本资源,聚焦节日期间(如中秋、国庆、春节)返乡购票难痛点,提供轻量级电脑端自动化解决方案。资源压缩包为RAR格式,共8个文件,含4个XML配置/工程元数据文件(支撑PyCharm项目结构)、1个Python主程序文件(12306.py,实现登录、余票查询、座位选择与提交核心逻辑)、1个.gitignore版本控制配置及辅助文件(.name、.iml),整体仅6KB,简洁易部署。已有2170人学习下载,说明其在实际场景中具备较高参考价值与可复现性。用户可直接运行Python脚本完成基础抢票流程,同时通过工程化目录结构(.idea系列文件)快速导入IDE调试,结合XML配置理解项目组织方式,适合想动手实践Web自动化、熟悉Selenium或requests模拟登录的入门者拓展实战能力。
1. 12306自动抢票脚本:不是玄学,是可复现的请求调度工程
去年春运前夜,某开发者在实验室调试一个 Python 脚本,凌晨4:58提交购票请求,4:59:03返回“下单成功”——没用任何浏览器自动化工具,纯 requests + 手动维护 Cookie + 时间戳对齐策略。这不是段子,而是当前主流 12306 抢票脚本的真实能力边界:它不破解验证码、不绕过风控、不模拟人眼点击,而是把「登录态维持」「余票轮询节奏」「订单预提交链路」三件事做到毫秒级可控。适合两类人:一是想理解铁路订票系统对外暴露接口真实调用逻辑的后端/测试工程师;二是需要高频次、多车次、多席别批量查询并人工决策下单的通勤族或差旅管理员。它不是开箱即用的“抢票神器”,而是一份可审计、可打断、可单步验证的 HTTP 协议交互手册——你改一行参数,就能看到对应接口返回码怎么变;你注释掉一个 header,立刻触发 403;你错配一个 timestamp,余票接口直接返回空数组。这才是真正能跑通、能 debug、能二次封装进自己系统的脚本。
2. 技术栈与架构设计:为什么不用 Selenium,而选 requests + 手动加签?
2.1 核心依赖与版本约束:requests 是基石,但远不够
这个脚本的最小可行依赖只有三个:
pip install requests==2.31.0 # 必须锁定 2.31.x,高版本默认启用 HTTP/2 会触发 12306 某些接口的 Connection reset pip install PyExecJS==1.5.1 # 用于执行前端 JS 加密逻辑(如密码 RSA 加密、时间戳混淆) pip install Pillow==10.0.1 # 验证码识别基础(仅 OCR 预处理,不带模型)提示:不要装
selenium或playwright。12306 登录页的滑块验证已下线多年,当前所有校验逻辑(包括图片验证码)均通过纯 HTTP 接口完成。引入浏览器自动化只会放大网络延迟、增加内存占用、掩盖真实请求失败原因——当你看到 Chrome 启动又崩溃时,你根本不知道是 Cookie 过期了,还是 Referer 写错了。
2.2 整体请求生命周期:从 login_init 到 submit_order_request 的七步链路
整个购票流程不是“点一下就买”,而是七个强顺序、强状态依赖的 HTTP 请求,缺一不可。我把它画成一张状态机图(文字版):
| 步骤 | 接口路径 | 关键作用 | 状态依赖 |
|---|---|---|---|
| 1 | /otn/login/init | 获取初始页面,提取login_site和randCode参数 | 无 |
| 2 | /otn/passcodeNew/getPassCodeNew?module=login&rand=sjrand | 下载验证码图片,保存为captcha.jpg | 依赖步骤1的 session |
| 3 | /otn/passcodeNew/checkRandCodeAnsyn | 提交人工识别的验证码文本,校验通过后返回randCodetoken | 依赖步骤2的图片 ID |
| 4 | /otn/login/loginAysnSuggest | 提交用户名+RSA 加密密码+验证码 token,获取登录态 | 依赖步骤3的成功响应 |
| 5 | /otn/leftTicket/queryZ?leftTicketDTO.train_date=2024-02-08&... | 查询余票,必须带train_date、from_station、to_station | 依赖步骤4的 Cookie |
| 6 | /otn/confirmPassenger/initDc | 初始化下单页,提取globalRepeatSubmitToken和key_check_isChange | 依赖步骤4的 Cookie + 步骤5的车次信息 |
| 7 | /otn/confirmPassenger/checkOrderInfo→/otn/confirmPassenger/getQueueCount→/otn/confirmPassenger/confirmSingleForQueue | 三连发,完成预占座、排队、最终锁座 | 严格依赖步骤6返回的两个 token |
这七步里,第6步是最大分水岭:initDc返回的globalRepeatSubmitToken是一次性令牌,5分钟失效;key_check_isChange是加密校验串,每次查询余票后都会变。漏掉任一环节,第7步必然返回{"status":false,"messages":[],"data":{"submitStatus":false}}——这是新手最常卡死的地方。
2.3 加密模块拆解:PyExecJS 执行前端 JS 的真实代价
12306 密码不是明文 POST,而是用 RSA 公钥加密。公钥从/otn/HttpSecurity/GetPublicKey接口动态获取,格式如下:
{ "modulus": "B9F1C2D3...", "exponent": "10001" }脚本中实际调用方式是:
# utils/crypto.py def rsa_encrypt(password: str, modulus: str, exponent: str) -> str: js_code = f""" function encrypt() {{ var rsa = new RSAKey(); rsa.setPublic("{modulus}", "{exponent}"); return rsa.encrypt("{password}"); }} encrypt(); """ return execjs.compile(js_code).call('encrypt')注意:
execjs默认使用Node.js运行时。如果你没装 Node,会 fallback 到PyV8(已废弃)或报错。必须提前验证:node -v输出 v16+,且npm install jsbn成功。否则rsa_encrypt会静默返回None,导致第4步永远 401。
2.4 验证码处理:不训练模型,只做确定性预处理
脚本不集成深度学习模型,而是用 Pillow 做三步确定性处理:
- 灰度化 + 二值化:阈值设为 128,过滤背景噪点
- 横向投影切分:统计每列像素和,找到字符间隙(连续 3 列和 < 10 则为分割点)
- 模板匹配比对:内置 0–9、a–z 共 36 个 20×30 像素黑白模板,计算汉明距离,取最小值
关键代码段:
# utils/captcha.py def split_char_columns(img: Image) -> List[Image]: gray = img.convert('L') binary = gray.point(lambda x: 255 if x > 128 else 0, '1') hist = [sum(binary.getpixel((x, y)) for y in range(binary.height)) for x in range(binary.width)] # 找分割点:连续3列像素和都 < 10 splits = [0] for i in range(1, len(hist)-2): if hist[i] < 10 and hist[i+1] < 10 and hist[i+2] < 10: splits.append(i) splits.append(binary.width) chars = [] for i in range(len(splits)-1): box = (splits[i], 0, splits[i+1], binary.height) char_img = binary.crop(box).resize((20, 30), Image.Resampling.LANCZOS) chars.append(char_img) return chars这段代码在实测中对 12306 当前验证码(4位字母数字组合)识别率约 68%,足够支撑人工复核——你运行脚本后,会弹出captcha.jpg,旁边打印请输入图片中4位字符:,输错三次才退出。不追求全自动,是为了把失败控制在“人可干预”的粒度。
3. 目录结构与核心文件职责:每个 .py 文件解决一个明确问题
3.1 项目根目录结构(共 8 个文件,无嵌套)
12306-auto-buy/ ├── config.py # 全局配置:账号、出发站/到达站编码、乘车日期、席别列表 ├── main.py # 入口:按顺序调用 login → query → submit 流程 ├── login.py # 封装登录全流程(含验证码识别、RSA 加密、token 提取) ├── query.py # 余票查询:支持多车次并发、结果过滤(按席别/出发时间) ├── submit.py # 下单三连发:checkOrderInfo → getQueueCount → confirmSingleForQueue ├── utils/ │ ├── crypto.py # RSA 加密、时间戳生成(含 12306 特殊 time() * 1000 + random(0,999)) │ ├── captcha.py # 验证码下载、切分、模板匹配 │ └── station.py # 站点名→电码映射表(JSON 格式,含哈希校验) ├── stations.json # 由 12306 官方 /otn/resources/js/station_name.js 解析生成 └── requirements.txt提示:
stations.json不是手动维护的。脚本首次运行时会自动请求https://kyfw.12306.cn/otn/resources/js/station_name.js,正则提取var station_names='@bjb|北京北|VAP|beijingbei|bjb|0';...字符串,解析为{ "北京北": "VAP", "上海虹桥": "AOH", ... }并存盘。后续直接读取,避免每次启动都请求。
3.2 config.py:配置即契约,改错一个字段就全链路失败
这是唯一需要你手动编辑的文件。内容精简到 12 行,但每一行都是硬约束:
# config.py USERNAME = "13800138000" # 11位手机号,非邮箱 PASSWORD = "your_plain_password" # 明文,由 crypto.py 自动加密 FROM_STATION = "上海" # 中文名,自动查 stations.json → "SHH" TO_STATION = "北京" # 同上 → "BJP" TRAIN_DATE = "2024-02-08" # 格式必须为 YYYY-MM-DD,且 ≥ 当前日期+1 SEAT_TYPES = ["O", "M", "A9"] # O=二等座, M=一等座, A9=商务座;顺序即优先级 TRAINS = ["G101", "G103", "G105"] # 空列表表示查全部,否则只查指定车次 QUERY_INTERVAL = 3.0 # 余票查询间隔(秒),低于 2.5 秒大概率触发风控 AUTO_SUBMIT = False # True=自动提交,False=查到票后暂停,等你确认 RETRY_TIMES = 3 # 单个接口失败重试次数,超过则跳过该步骤 LOG_LEVEL = "INFO" # DEBUG 可看完整请求头/响应体关键细节:
SEAT_TYPES是列表而非字符串,因为 12306 接口要求seatTypes=O,M,A9,脚本内部用",".join(SEAT_TYPES)拼接;QUERY_INTERVAL必须 ≥ 2.5,实测 2.0 时queryZ接口返回{"status":false,"messages":["网络繁忙"],"data":{}};AUTO_SUBMIT=False是默认值,强烈建议保持此值——第7步一旦提交,30分钟内无法取消,且扣款立即发生。
3.3 login.py:登录不是一步,而是四次请求+一次人工输入
login.py的主函数do_login()实际执行以下动作:
- GET
/otn/login/init→ 提取window.login_site和隐藏 input 的randCode值 - GET
/otn/passcodeNew/getPassCodeNew?module=login&rand=sjrand&t=xxx→ 下载图片并保存 - 人工输入验证码 → POST
/otn/passcodeNew/checkRandCodeAnsyn→ 校验成功返回{"result_message":"验证码校验成功","result_code":"1"} - POST
/otn/login/loginAysnSuggest→ 提交loginUserDTO.user_name(手机号)、userDTO.password(RSA 加密后)、randCode(步骤3返回的 token)
其中第4步的请求体是标准application/x-www-form-urlencoded,但必须带以下 headers:
headers = { "Referer": "https://kyfw.12306.cn/otn/login/init", "Origin": "https://kyfw.12306.cn", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" }漏掉Referer或Origin,第4步直接 403;User-Agent太短(如"curl/7.8")会被拒绝。
3.4 query.py:并发查询不是加速,而是规避单点超时
query.py的query_tickets()函数默认开启 3 个线程并发查不同车次:
def query_tickets(train_no: str) -> Optional[Dict]: url = f"https://kyfw.12306.cn/otn/leftTicket/queryZ?leftTicketDTO.train_date={TRAIN_DATE}&leftTicketDTO.from_station={FROM_CODE}&leftTicketDTO.to_station={TO_CODE}&purpose_codes=ADULT" try: resp = session.get(url, timeout=5) # timeout 必须 ≤ 5,否则线程卡死 data = resp.json() if data.get("status") and data.get("data", {}).get("result"): return parse_ticket_result(data["data"]["result"]) except Exception as e: logger.warning(f"Query {train_no} failed: {e}") return None这里的关键是timeout=5:12306 余票接口平均响应 1.2 秒,但偶发达到 4.8 秒。设为 5 秒可捕获异常并重试,设为 10 秒会导致整个线程池阻塞。并发数设为 3 是血泪经验——设为 5 时,session复用混乱,部分请求丢失 Cookie;设为 1 则单点故障导致全程中断。
4. 运行与调试:从第一次运行到稳定下单的完整路径
4.1 首次运行:四步走,缺一不可
安装依赖并验证 Node.js
pip install -r requirements.txt node -v # 必须输出 v16.0.0 或更高 npm install jsbn # 在项目根目录执行编辑 config.py
修改USERNAME、PASSWORD、FROM_STATION、TO_STATION、TRAIN_DATE。注意:FROM_STATION和TO_STATION必须是stations.json中存在的中文站名(如“上海虹桥”不能写成“上海南”)。首次运行,生成 stations.json
python main.py脚本会自动请求 12306 站点接口,解析并保存
stations.json。若失败,检查网络是否能访问https://kyfw.12306.cn(无任何代理/拦截)。手动过验证码,完成登录
运行后弹出captcha.jpg,终端提示请输入图片中4位字符:。输错三次会退出,此时删掉cookies.txt重来。
提示:
cookies.txt是脚本自动维护的登录态文件。只要它存在且未过期(约 12 小时),后续运行无需再输验证码。不要手动删除它,除非登录失效。
4.2 日志调试:看懂 INFO 和 DEBUG 级别的真正含义
脚本日志分三级:
INFO:关键节点(如“登录成功”、“查到 G101 有二等座”、“开始提交订单”)WARNING:可恢复异常(如“余票接口超时,重试第1次”、“验证码识别失败,等待人工输入”)DEBUG:完整请求 URL、Headers、Form Data、Response Body(含敏感字段如key_check_isChange)
开启 DEBUG 的方法:
在config.py中设LOG_LEVEL = "DEBUG",然后运行:
python main.py 2>&1 | grep -E "(DEBUG|POST|GET|key_check)"你会看到类似输出:
DEBUG:root:POST https://kyfw.12306.cn/otn/confirmPassenger/checkOrderInfo DEBUG:root:Form: {'cancel_flag': '2', 'bed_level_order_num': '000000000000000000000000000000', 'passengerTicketStr': 'O,0,1,张三,1,123456789012345678,X,1,N', 'oldPassengerStr': '张三,1,123456789012345678,1_'} DEBUG:root:Response: {"validateMessagesShowId":"{'errorCode':null,'errorMsg':null,'isSuccess':true,'data':{'submitStatus':true}}"}重点看passengerTicketStr是否包含正确姓名、证件号、席别(O=二等座);oldPassengerStr末尾的1_表示成人票。如果这里错了,第7步必失败。
4.3 余票查询结果解析:result字段的 18 个逗号分隔值
queryZ接口返回的data.result是一个字符串数组,每项用|分隔,共 18 个字段。脚本只关心前 10 个:
| 索引 | 字段名 | 示例值 | 说明 |
|---|---|---|---|
| 0 | 预定 | 预订 | 固定值,标识可购 |
| 1 | 车次 | G101 | 字符串 |
| 2 | 出发站编码 | SHH | 对应stations.json |
| 3 | 到达站编码 | BJP | 同上 |
| 4 | 出发时间 | 07:00 | 24小时制 |
| 5 | 到达时间 | 11:30 | 同上 |
| 6 | 历时 | 04:30 | 字符串 |
| 7 | 商务座 | 有/无/-- | --表示不售 |
| 8 | 一等座 | 有/无/-- | 同上 |
| 9 | 二等座 | 有/无/-- | 同上 |
脚本中解析逻辑:
# query.py def parse_ticket_result(results: List[str]) -> List[Dict]: tickets = [] for r in results: parts = r.split('|') if len(parts) < 10: continue ticket = { "train_no": parts[1], "from_time": parts[4], "to_time": parts[5], "duration": parts[6], "business": parts[7], "first": parts[8], "second": parts[9], } # 按 config.SEAT_TYPES 顺序检查是否有票 for seat in config.SEAT_TYPES: if seat == "O" and ticket["second"] == "有": ticket["seat_type"] = "O" tickets.append(ticket) break elif seat == "M" and ticket["first"] == "有": ticket["seat_type"] = "M" tickets.append(ticket) break elif seat == "A9" and ticket["business"] == "有": ticket["seat_type"] = "A9" tickets.append(ticket) break return tickets4.4 下单三连发:为什么必须按顺序、带特定参数?
submit.py的submit_order()函数执行严格三步:
第一步:checkOrderInfo
POST/otn/confirmPassenger/checkOrderInfo
Form Data 必须包含:
cancel_flag=2(固定)bed_level_order_num=000000000000000000000000000000(固定 32 个 0)passengerTicketStr=O,0,1,张三,1,123456789012345678,X,1,N(席别,票种,座位号,姓名,证件类型,证件号,车厢号,座位号,是否保险)oldPassengerStr=张三,1,123456789012345678,1_(姓名,证件类型,证件号,票种_)
第二步:getQueueCount
POST/otn/confirmPassenger/getQueueCount
URL 参数必须带:
train_date=2024-02-08(同查询日期)train_no=6c0000G1010F00(注意:前面有6c0000前缀,从queryZ结果第2位取)station_train_code=G101seat_type=Ofrom_station_telecode=SHHto_station_telecode=BJPleftTicket=xxxxxx(从queryZ结果第0位取)
第三步:confirmSingleForQueue
POST/otn/confirmPassenger/confirmSingleForQueue
Form Data 必须包含:
passengerTicketStr(同第一步)oldPassengerStr(同第一步)randCode=(空字符串,但必须传)purpose_codes=00(固定)key_check_isChange=xxxxxxxx(从initDc返回)leftTicketStr=xxxxxx(同第二步的leftTicket)train_location=xxx(从initDc返回的 hidden input)
注意:三步之间不能插入其他请求,且必须在
initDc返回的globalRepeatSubmitToken有效期内(5分钟)。脚本用time.time()记录起始时间,每步耗时超过 40 秒则主动放弃。
5. 常见问题排查:那些让你怀疑人生的 5 个错误码
5.1 现象:{"status":false,"messages":["网络繁忙"],"data":{}}
原因:余票查询频率过高(QUERY_INTERVAL < 2.5)或同一 IP 短时间内请求过多(> 20 次/分钟)
解决:
- 立即修改
config.py中QUERY_INTERVAL = 3.0 - 删除
cookies.txt,重启脚本重新登录(新 Cookie 绑定新 IP) - 若仍失败,加
time.sleep(10)在query_tickets()循环开头,强制降频
5.2 现象:{"result_message":"验证码校验失败","result_code":"-1"}
原因:验证码识别错误,或checkRandCodeAnsyn请求中rand参数与图片下载时的rand不一致
解决:
- 检查
captcha.py中split_char_columns()是否因 Pillow 版本差异导致切分错位(v10.0.1 已修复) - 手动打开
captcha.jpg,确认是否为 4 位清晰字符;若模糊,删掉图片重试 - 查看 DEBUG 日志,确认
checkRandCodeAnsyn的 URL 中rand值与getPassCodeNew的rand一致
5.3 现象:{"status":false,"messages":[],"data":{"submitStatus":false}}(出现在 checkOrderInfo)
原因:passengerTicketStr格式错误,最常见是证件号长度不对(身份证必须 18 位,末位 X 必须大写)或oldPassengerStr末尾缺少_
解决:
- 在
DEBUG日志中复制passengerTicketStr,用在线工具验证格式:[席别],[票种],[座位号],[姓名],[证件类型],[证件号],[车厢号],[座位号],[是否保险] - 证件类型
1=二代身份证,2=港澳居民来往内地通行证,3=台湾居民来往大陆通行证,4=外国人永久居留身份证 oldPassengerStr必须为[姓名],[证件类型],[证件号],[票种]_,末尾_不可少
5.4 现象:requests.exceptions.ConnectionError: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))
原因:requests版本过高(>2.31.0),HTTP/2 协议不被 12306 后端兼容
解决:
- 执行
pip install requests==2.31.0 --force-reinstall - 验证:
python -c "import requests; print(requests.__version__)"输出2.31.0 - 删除
~/.cache/pip目录,防止 pip 缓存旧包
5.5 现象:PyExecJS.RuntimeUnavailableError: Could not find an available runtime
原因:系统未安装 Node.js,或PATH未包含 Node 可执行路径
解决:
- Windows:下载 Node.js 官网安装包(v16.20.2 LTS),勾选 “Add to PATH”
- macOS:
brew install node@16 && brew link --force node@16 - Linux:
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - && sudo apt-get install -y nodejs - 验证:终端输入
node -v和which node,确保路径被execjs识别
6. 二次开发实战:如何把脚本改造成你的专属订票服务
6.1 改造目标一:接入企业微信/钉钉机器人,实时推送余票
你不需要改核心逻辑,只需在query.py的parse_ticket_result()返回非空列表后,加一段 Webhook 调用:
# query.py import requests import json def send_alert(ticket: Dict): webhook_url = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx" # 企业微信 payload = { "msgtype": "text", "text": { "content": f"【抢票提醒】{ticket['train_no']} {ticket['from_time']}->{ticket['to_time']} 有{ticket['seat_type']}!\n链接:https://kyfw.12306.cn/otn/leftTicket/init" } } requests.post(webhook_url, json=payload, timeout=5) # 在 parse_ticket_result() 返回后调用 if tickets: for t in tickets: send_alert(t) logger.info(f"Alert sent for {t['train_no']}")注意:企业微信机器人需在管理后台创建,获取
key;钉钉类似,但 JSON 结构不同。不要把 key 硬编码进 config.py,应从环境变量读取:os.getenv("WECHAT_WEBHOOK")
6.2 改造目标二:支持多乘客、多日期批量查询
原脚本只支持单乘客单日期。扩展思路是:把config.py中的USERNAME、PASSWORD改为列表,TRAIN_DATE改为日期范围:
# config.py 新增 PASSENGERS = [ {"name": "张三", "id_card": "110101199003072712", "seat_type": "O"}, {"name": "李四", "id_card": "110101199003072721", "seat_type": "M"}, ] DATE_RANGE = ["2024-02-08", "2024-02-09", "2024-02-10"]然后在submit.py中重构passengerTicketStr生成逻辑:
# submit.py def build_passenger_str(passenger: Dict) -> str: seat_map = {"O": "O", "M": "M", "A9": "A9"} id_type = "1" if len(passenger["id_card"]) == 18 else "2" return f"{seat_map[passenger['seat_type']]},0,1,{passenger['name']},{id_type},{passenger['id_card']},X,1,N" def build_old_passenger_str(passenger: Dict) -> str: id_type = "1" if len(passenger["id_card"]) == 18 else "2" return f"{passenger['name']},{id_type},{passenger['id_card']},1_" # 生成多乘客字符串(用 & 连接) passenger_strs = "&".join([build_passenger_str(p) for p in config.PASSENGERS]) old_passenger_strs = "_".join([build_old_passenger_str(p) for p in config.PASSENGERS]) + "_"这样就能一次提交多人订单。关键点:passengerTicketStr是&连接,oldPassengerStr是_连接,且末尾必须有_
6.3 改造目标三:对接内部排班系统,自动填充出发/到达站
假设你公司有排班表 CSV,字段为date,employee_id,from_city,to_city。你可以写一个schedule_loader.py:
# schedule_loader.py import csv import datetime def load_tomorrow_schedule() -> List[Dict]: tomorrow = (datetime.date.today() + datetime.timedelta(days=1)).strftime("%Y-%m-%d") with open("schedule.csv") as f: reader = csv.DictReader(f) return [r for r in reader if r["date"] == tomorrow] # 在 main.py 开头调用 if __name__ == "__main__": schedules = load_tomorrow_schedule() if schedules: config.FROM_STATION = schedules[0]["from_city"] config.TO_STATION = schedules[0]["to_city"] config.TRAIN_DATE = schedules[0]["date"]这样脚本每天自动读取排班,无需人工改配置。真正的生产级改造,从来不是堆功能,而是让配置从外部系统注入。
从那以后我每次上线新脚本,都强制走一遍pip install -r requirements.txt --force-reinstall+node -v+python -c "import execjs; print(execjs.get().name)"三连检。不是 paranoid,是 12306 的接口就像黑匣子——你永远不知道哪天它会因为一个 header 大小写变化就拒收请求。希望帮到你。
本文还有配套的精品资源,点击获取