☰
Python抢票脚本实战:从登录到提交订单的完整链路与避坑指南
2026/10/14 17:58:46 网站建设 项目流程

简介:这是一份面向Python初学者与自动化爱好者的12306火车票购票自动化脚本,针对中秋、国庆、春节等出行高峰抢票难的问题,提供可在电脑端运行的抢票辅助方案。资源以Python源码为核心,配合IDE工程配置与项目元数据文件,方便直接导入开发环境阅读与调试。压缩包为rar格式,共8个文件,整体约6KB,包含1个py主脚本、4个xml工程配置、1个iml模块描述、1个name标识及1个gitignore忽略规则,结构精简,适合快速理解脚本逻辑与二次修改。目前已有2168人学习下载,说明该方案在抢票场景中具有一定参考价值。读者可从中获取完整的抢票脚本源码、工程目录组织方式以及自动化购票的基本实现思路,便于结合自身需求调整参数、排查运行问题,并在此基础上扩展验证码识别、多车次监控等功能。

1. 抢票脚本的真实边界:为什么你写的 Python 脚本总在提交订单那一步翻车

每年春运前后,后台总有人问我同一个问题:用 Python 写个自动抢火车票脚本到底行不行?我的回答从来不是“行”或“不行”,而是先反问一句——你卡在哪一步?绝大多数人的答案惊人地一致:查询车次没问题,提交订单就报错,或者干脆被踢回登录页。这不是代码写得烂,而是没搞懂这套系统的真实交互模型。

所谓“12306 自动抢火车票脚本”,本质是用程序模拟浏览器完成「登录 → 查票 → 提交订单 → 排队 → 支付」这条链路。它解决的是人工刷新慢、手速不够、余票一闪而过的问题,适合懂一点 Python、愿意折腾请求库和会话保持的开发者。但它的天花板也很明显:你面对的不是一个静态网页,而是一套带风控、带时间戳校验、带动态令牌的分布式系统。脚本能帮你把查询频率拉到秒级,但能不能抢到,取决于你对会话、令牌和提交时机的理解深度。下面我把这套东西拆开讲,能复现的部分给代码,不能碰的边界说清楚。

2. 从登录到查票:会话保持与请求构造的最小闭环

2.1 为什么直接用 requests 裸请求会被识别

很多人第一步就翻车:用requests.get直接怼查询接口,返回一堆乱码或者直接 403。原因在于这套系统不是靠单一 Cookie 认人,而是靠一整套会话上下文——包括但不限于初始页面下发的会话标识、动态生成的令牌、以及请求头里的浏览器指纹字段。你少带一个,风控就把你当机器人。

常见做法是先用一个Session对象访问初始页面,把服务端下发的 Cookie 全部收进会话罐子里,后续所有请求都复用这个会话。这一步不做,后面全是白搭。

import requests # 创建会话对象,后续所有请求共用这套 Cookie session = requests.Session() # 模拟浏览器的基础请求头,缺一不可 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", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", "Connection": "keep-alive", } # 第一步:访问初始页面,让服务端把会话 Cookie 种进来 init_resp = session.get( "https://example-railway-init-page", headers=headers, timeout=10 ) print("初始页面状态码:", init_resp.status_code) print("当前会话 Cookie:", session.cookies.get_dict())

这段代码的逻辑很简单:先建立一个有状态的会话,再用接近真实浏览器的请求头去访问入口页面。参数上,User-Agent必须和后续请求保持一致,不能这次用 Chrome 下次用 Firefox,否则会话上下文对不上。timeout建议设 10 秒以内,抢票场景下等太久没意义。

2.2 查询接口的参数怎么拼才不会被拒

登录之后的核心动作是查票。查票接口通常需要几个关键参数:出发站、到达站、出发日期、以及一个从页面里提取出来的动态令牌。这个令牌有时藏在 HTML 的 script 标签里,有时是单独的接口下发,不同时期形态不一样,但思路固定——先拿令牌,再带令牌查票。

import re # 假设登录已完成,现在从某个页面提取动态令牌 page_resp = session.get( "https://example-railway-ticket-page", headers=headers, timeout=10 ) # 用正则从页面源码里抠出令牌,具体字段名以实际页面为准 token_match = re.search(r'globalRepeatSubmitToken\s*=\s*"([^"]+)"', page_resp.text) if not token_match: raise RuntimeError("没找到动态令牌,页面结构可能变了") repeat_token = token_match.group(1) print("提取到的令牌:", repeat_token[:16], "...") # 构造查票请求参数 query_params = { "leftTicketDTO.train_date": "2025-01-20", # 出发日期 "leftTicketDTO.from_station": "BJP", # 出发站电报码 "leftTicketDTO.to_station": "SHH", # 到达站电报码 "purpose_codes": "ADULT", # 成人票 } # 带上令牌和会话去查票 query_resp = session.get( "https://example-railway-query-api", headers=headers, params=query_params, cookies={"RAIL_EXPIRATION": repeat_token}, # 令牌有时放 Cookie 有时放参数 timeout=10 ) print("查票响应长度:", len(query_resp.text))

这里的关键参数是车站电报码,不是中文站名。很多人直接传“北京”“上海”,接口不认。电报码需要提前从车站列表接口拉一份映射表存本地,查的时候做一次转换。令牌的提取正则只是示例,实际字段名会变,思路是「先抓页面、再抠字段、后带请求」。如果查票返回空或者报错,优先检查令牌是否过期、Cookie 是否完整、请求头是否和登录时一致。

2.3 余票数据的解析与车次筛选

查票接口返回的通常是一段用特殊符号分隔的字符串,不是标准 JSON。你需要按分隔符切开,再按固定下标取字段。这一步没有捷径,只能对着返回样本数位置。

# 假设 query_resp.text 里有一个 result 数组,每个元素是竖线分隔的字符串 raw_result = query_resp.json().get("data", {}).get("result", []) for item in raw_result: fields = item.split("|") # 下标位置以实际返回为准,这里只是示意 train_no = fields[3] # 车次编号 from_code = fields[6] # 出发站电报码 to_code = fields[7] # 到达站电报码 depart_time = fields[8] # 出发时间 arrive_time = fields[9] # 到达时间 secret_str = fields[0] # 提交订单时要用的密钥串 # 筛选条件:出发时间在早上六点到晚上十点之间 if "06:00" <= depart_time <= "22:00": print(f"车次 {train_no} {depart_time}->{arrive_time} 密钥 {secret_str[:12]}...")

解析逻辑说明:每个字段的下标是固定的,但不同时期接口可能增删字段,所以拿到返回后先用一条样本打印全部下标,确认位置再写死。secret_str是提交订单时的关键凭证,查到了先存下来,后面提交要用。筛选条件按自己需求改,比如只看高铁、只看卧铺,都是在解析后加判断。

提示:查票频率不要拉到毫秒级,常见做法是 3 到 5 秒一次,带随机抖动。频率过高会触发风控,反而什么都查不到。

3. 提交订单与排队:令牌、时间戳与提交时机的配合

3.1 提交订单需要凑齐哪几个凭证

查到票只是第一步,真正难的是提交订单。这一步需要凑齐几个东西:查票时拿到的密钥串、登录后下发的令牌、以及一个和当前时间绑定的时间戳。少一个都会被拒。

import time # 假设前面已经拿到了 secret_str 和 repeat_token # 提交订单的请求体 order_payload = { "secretStr": secret_str, # 查票时拿到的密钥串 "train_date": "2025-01-20", # 出发日期 "backTrainDate": "", # 返程日期,单程留空 "tour_flag": "dc", # 单程标识 "purpose_codes": "ADULT", # 成人票 "queryTrainInfo": "", # 查询信息,通常留空 "REPEAT_SUBMIT_TOKEN": repeat_token, # 登录后下发的令牌 } # 提交订单 order_resp = session.post( "https://example-railway-submit-api", headers={**headers, "Content-Type": "application/x-www-form-urlencoded"}, data=order_payload, timeout=10 ) print("提交订单响应:", order_resp.text[:200])

参数说明:secretStr是查票结果里带的,每次查询都会变,不能复用。REPEAT_SUBMIT_TOKEN是登录后拿到的,整个会话周期内有效。tour_flag单程是dc,往返是wc,别填错。提交后如果返回里带订单号,说明进入排队了;如果返回错误码,看错误信息是令牌过期还是余票不足。

3.2 排队与确认:为什么提交成功不等于抢到

提交订单成功只是进入排队队列,不代表票已经锁定。系统会返回一个排队状态,你需要轮询确认接口,直到状态变成「订单已确认」或者「排队失败」。

import time # 假设提交后拿到了一个 queue_id queue_id = "example-queue-id" for attempt in range(20): confirm_resp = session.get( "https://example-railway-confirm-api", headers=headers, params={"queueId": queue_id}, timeout=10 ) status = confirm_resp.json().get("status") print(f"第 {attempt+1} 次轮询,状态: {status}") if status == "CONFIRMED": print("订单已确认,去支付") break elif status == "FAILED": print("排队失败,余票被别人抢了") break time.sleep(1.5) # 轮询间隔别太短,1 到 2 秒比较稳

轮询逻辑说明:间隔太短会给服务端压力,也容易触发风控;太长又可能错过确认窗口。1 到 2 秒是常见做法。状态字段名以实际返回为准,有的叫status,有的叫orderStatus。如果轮询多次仍是「排队中」,说明队列拥堵,继续等或者重新查票。

3.3 多车次并发查询的线程池写法

单线程查一个车次太慢,常见做法是用线程池并发查多个车次,谁先有票就提交谁。但要注意,提交订单本身最好串行,避免多个线程同时提交导致令牌冲突。

from concurrent.futures import ThreadPoolExecutor, as_completed def query_train(train_code): """查询单个车次的余票,返回有票的车次信息""" params = { "leftTicketDTO.train_date": "2025-01-20", "leftTicketDTO.from_station": "BJP", "leftTicketDTO.to_station": "SHH", "purpose_codes": "ADULT", } resp = session.get( "https://example-railway-query-api", headers=headers, params=params, timeout=10 ) # 解析逻辑省略,返回有票的车次 return parse_ticket(resp.text, train_code) # 用线程池并发查询,最多 5 个线程 train_list = ["G101", "G102", "G103", "G104", "G105"] with ThreadPoolExecutor(max_workers=5) as executor: futures = {executor.submit(query_train, t): t for t in train_list} for future in as_completed(futures): result = future.result() if result: print("发现余票:", result) # 这里触发提交订单,注意加锁或串行处理

线程池参数说明:max_workers建议不超过 5,再多容易触发风控。每个线程共用同一个session对象,Cookie 是共享的,但要注意requests.Session本身不是完全线程安全的,高并发下可能出现 Cookie 错乱。稳妥做法是每个线程独立会话,或者用锁保护提交动作。

注意:并发查询可以,并发提交订单是大忌。多个线程同时提交同一个令牌,服务端会判定为异常请求,直接拒绝。

4. 避坑与排查:抢票脚本最常见的 5 个翻车现场

4.1 登录后查票一直返回未登录

现象:登录接口返回成功,但紧接着查票提示「请先登录」。 原因:登录成功后服务端会重定向或者下发新的 Cookie,如果Session没有自动跟随重定向,或者手动覆盖了 Cookie,会话就断了。 解决:登录请求加allow_redirects=True,登录后打印session.cookies.get_dict()确认关键 Cookie 存在。不要手动设置Cookie请求头,交给Session管理。

4.2 令牌提取正则突然匹配不到

现象:昨天还能跑的脚本,今天报「没找到动态令牌」。 原因:页面结构变了,令牌字段名或者所在位置调整了。 解决:不要写死正则,先请求页面把 HTML 存下来,用浏览器开发者工具搜关键字,确认新字段名后再改正则。更稳的做法是找令牌下发的独立接口,直接拿 JSON,比抠 HTML 可靠。

4.3 提交订单返回「操作过于频繁」

现象:查票正常,一提交就提示频繁。 原因:提交间隔太短,或者同一会话短时间内多次提交。 解决:提交失败后至少等 3 到 5 秒再重试,重试前重新查一次票拿新的密钥串。不要用同一个密钥串反复提交,它是一次性的。

4.4 余票显示有但提交时提示「余票不足」

现象:查票结果里明明有票,提交却说没了。 原因:查票和提交之间有时间差,票被抢走了。或者查到的票是「候补」状态,不是直接可购。 解决:查票后立刻提交,中间不要做多余操作。解析时区分「有票」和「候补」,只对真正有票的车次提交。候补是另一套接口,不要混用。

4.5 脚本跑一晚上什么都没抢到

现象:日志显示一直在查,但从来没提交成功。 原因:查询频率太低,或者筛选条件太窄,或者提交时机总是慢半拍。 解决:把查询间隔压到 3 秒左右带随机抖动,筛选条件放宽一档,提交动作放在查到票后的第一个循环里立刻执行。另外确认系统时间是否准确,时间戳偏差太大会导致请求被拒。

5. 把脚本跑稳的几个进阶习惯

5.1 用配置文件管理车站码和日期

硬编码车站码和日期是新手最容易犯的错。改一次日期要翻遍代码,容易漏改。常见做法是抽一个config.yaml或者config.json,把出发站、到达站、日期、车次列表、查询间隔全放进去。

import json with open("config.json", "r", encoding="utf-8") as f: config = json.load(f) train_date = config["train_date"] from_station = config["from_station"] to_station = config["to_station"] query_interval = config.get("query_interval", 3)

配置文件里还可以放车站码映射表,查票前做一次转换。这样换日期、换车站只改配置,不动代码。

5.2 日志要记到文件,别只 print

抢票脚本往往跑在后台,print 到终端看不到。用logging模块把关键节点记到文件:登录成功、令牌提取、查票结果、提交响应、排队状态。出问题时翻日志比猜快得多。

import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[ logging.FileHandler("ticket.log", encoding="utf-8"), logging.StreamHandler() ] ) logging.info("开始查询车次 %s", train_code) logging.warning("令牌提取失败,准备重试")

日志级别用INFO记正常流程,WARNING记异常但可恢复的情况,ERROR记致命错误。文件按天切分更好,避免单个日志文件过大。

5.3 失败重试要带退避,不要死循环

网络抖动、令牌过期、服务端限流都会导致请求失败。无脑while True重试只会让情况更糟。常见做法是带指数退避的重试:第一次等 1 秒,第二次等 2 秒,第三次等 4 秒,最多重试 3 到 5 次。

import time def retry_request(func, max_retries=3, base_delay=1): """带指数退避的请求重试""" for attempt in range(max_retries): try: return func() except Exception as e: if attempt == max_retries - 1: raise delay = base_delay * (2 ** attempt) print(f"请求失败: {e},{delay} 秒后重试") time.sleep(delay)

这个重试函数可以包住查票、提交、轮询各个动作。注意提交订单的重试要重新拿密钥串,不能直接重发原请求。

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

跑起来不等于在工作。验证方法很简单:手动在浏览器里查一次票,记下有余票的车次和时间,然后看脚本日志里有没有查到同样的结果。如果浏览器有票脚本查不到,说明请求参数或者令牌有问题;如果脚本查到了但提交失败,说明提交环节的凭证不对。逐段对比,比盲目改代码有效。

我自己的习惯是每次改完脚本,先用一个已知有票的车次跑一遍完整流程,确认从查询到提交都能走通,再换成目标车次。这个习惯帮我省了很多「跑了一晚上发现根本没提交」的后悔药。抢票脚本这东西,稳定比快更重要,能跑通一条完整链路,比并发一百个车次但每个都半路翻车强得多。希望帮到你。

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

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

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

立即咨询