ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python抢票脚本实战:绕过页面渲染,直接构造HTTP请求

Python抢票脚本实战:绕过页面渲染,直接构造HTTP请求

简介:基于Python实现的大麦抢票脚本,面向有自动化抢票需求的Python学习者与购票用户,解决大麦网手动刷新、下单耗时与易错过的问题。脚本通过Selenium驱动Chrome浏览器,配合config.json完成场次、票价、实名者、购买数量等参数设置,支持按优先级自动选座与提交订单。资源包共29个文件,主要包含Python主程序、配置文件、Git版本记录及说明文档,压缩包仅51KB,结构精简便于快速上手。已有5078人学习下载。除抢票主逻辑外,还提供README帮助文档、配置样例与版本管理元数据,读者可借助示例快速理解参数含义,并基于现有框架拓展候补抢票、多账号并发或验证码处理等能力,适合具备Python基础并希望掌握Selenium自动化实战的开发者。

1. 先说结论:抢票慢的半秒不在手速,在页面渲染

每次热门场次开售,大量用户在同一秒刷新页面,结果就是“已售罄”。真正让手动操作落后的,不是手指点击速度,而是页面从渲染到提交订单中间多走的几百毫秒。基于 Python 实现的抢票脚本,核心思路是把“点击下单”从页面里抽离出来,构造一条与真实请求完全一致的 HTTP 请求,在开售临界点直接打到订单接口,把页面渲染开销压缩到一次网络往返。这套脚本针对某头部票务 App 的抢票场景,覆盖登录态保持、订单请求构造、并发与重试三个环节,并把验证码、参数签名、账号风控这些常见拦截点做了专门说明。适合已经掌握 Python 基础语法、想自己掌控抢票流程而不是依赖成品工具的读者。看完这篇,你能得到一个最小可运行脚本,也知道上线前该避开哪些坑。

2. 拆解抢票链路:从页面点击到订单接口的全过程

2.1 请求调度与页面自动化的真实差距

常见的抢票自动化有两条技术路线:一条是用浏览器自动化框架模拟鼠标点击,另一条是绕过页面,直接构造订单接口请求。基于 Python 的抢票脚本通常选用后者,核心原因是页面自动化太重了。一个浏览器从启动到页面可用经常需要 2 到 5 秒,期间还要加载静态资源、执行脚本、完成首屏渲染;而直接发请求的耗时一般在 200 毫秒左右,差距接近一个数量级。

抢票本质上是一场请求调度:服务器只认接口调用,不关心用户界面是否完成渲染。页面上显示的场次、票档、数量最终都会映射为接口字段,只要复刻请求结构,就等价于完成了一次下单。许多新手在浏览器开发者工具里看到几十个请求,误以为每个都要模拟,其实大部分是埋点上报、资源加载和容灾请求,真正决定成败的只有一两个订单接口。把这几个接口定位出来,脚本的骨架也就出来了。

页面自动化并不是没用,但它更适合做调试工具而不是主力抢票通道。比如想确认某个按钮到底触发了什么参数,用自动化脚本点一次,观察接口变化,比单纯看代码更直观。真正到开售那一刻,还是要回到请求级别的实现上。

2.2 登录态与 Session:不要让服务器把你当新访客

票务平台要求登录后才能购票,登录态通常通过 Cookie 维持。requests.Session()在这里不是可有可无的封装,它在内部会自动接收服务端返回的Set-Cookie,并在后续请求中携带。很多人把浏览器里复制的 Cookie 塞进单次requests.get(),过一段时间就失效,原因就是服务端续期后的新 Cookie 没有被保留下来。

用 Session 维护一个完整会话,是抢票脚本的底线:

import requests session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0 Safari/537.36", "Accept": "application/json, text/plain, */*", "Origin": "https://m.example.com", "Referer": "https://m.example.com/ticket/1001" })

User-Agent 用来标识设备类型,票务系统会用它判断你来自 H5、桌面还是客户端。Referer 和 Origin 则是用来校验请求来源的,很多接口会拒绝来源为空或来源异常的请求。这三个头是请求伪装中最基础的一部分,缺一个都可能触发风控校验。

2.3 从浏览器开发者工具里定位真正的下单接口

定位接口是写抢票脚本的第一步,也是最需要细心的一步。我的常规做法是这样:

  1. 打开浏览器开发者工具,切到“网络”面板,勾选“保留日志”选项。如果不勾,页面跳转后请求记录会被清空,很多关键请求就丢了。
  2. 进入目标场次的购票页,选择一个票档和数量,点击“提交订单”但不要支付。
  3. 在“网络”面板过滤出 XHR 或 Fetch 请求,逐个看响应内容,找到返回订单号或提示“下单成功”的那个请求。
  4. 右键该请求,选择“复制为 cURL 命令”,先存到本地文本文件。

找到的请求通常包含以下字段映射:

页面上的信息接口字段名说明
场次performId一个场次对应一个 ID
票档/价格skuId不同价格档位对应不同 skuId
项目itemId演出项目的唯一标识
购买数量quantity每单限购数量
配送方式deliveryType1 为邮寄,2 为电子票

这些字段名在不同平台会有差异,但语义基本一致。复制 cURL 后,可以直接在本地把请求转换成 Python 代码,再逐步精简成最小 payload。

2.4 签名、token 与 deviceId:动态参数的常规处理思路

票务接口不是光靠固定参数就能打通的,很多平台会给请求体加签名参数,常见的有sign、token、deviceId。这些参数有的是随页面加载生成的,有的是点击瞬间计算出来的。如果直接把浏览器里抓到的签名写死进脚本,往往开售前就过期了。

处理动态参数有一套优先级比较明确的思路:优先选 H5 端而不是 App 客户端,因为 H5 端的加密强度通常低于 App;在开售前几分钟手动打开页面,让页面完成一次预签名,再抓取当前签名注入脚本;如果签名时效极短,就需要对提交订单前的 JS 做分析,用无头浏览器执行签名计算,把结果回传给 requests。签名问题不会影响脚本主流程的搭建,但它决定了脚本能不能从本地测试走向真实抢票,这个问题在第 5 章会专门展开。

3. 用 Python 实现第一版抢票脚本:最小骨架与参数设置

3.1 环境准备与项目文件结构

建议用 Python 3.8 以上版本,依赖只有一个requests库。为了不污染全局环境,我一般会新建虚拟环境:

python3 -m venv venv source venv/bin/activate pip install requests

项目文件按下面这种方式组织,简单直接:

grab_ticket/ ├── config.json # 用户配置:场次、票档、数量 ├── cookies.txt # 从浏览器复制的 Cookie 文本 └── main.py # 抢票脚本主程序

把配置和代码分离,是因为抢不同场次时只需要改 JSON,不用动代码。cookies.txt 单独放也可以避免你每次改代码时误删登录信息。

3.2 从浏览器复制 Cookie 并加载进 Session

Cookie 的获取方法在前面说过:登录平台,打开开发者工具,随便看一个请求的请求头,复制完整的 Cookie 字段。一般都是k1=v1; k2=v2这种分号分隔的文本。

import requests def load_cookie(path: str = "cookies.txt") -> dict: """解析浏览器里复制的 Cookie 文本,返回字典格式""" with open(path, "r", encoding="utf-8") as f: text = f.read().strip() cookie_dict = {} for item in text.split(";"): item = item.strip() if "=" in item: key, value = item.split("=", 1) cookie_dict[key] = value return cookie_dict def create_session(cookie_dict: dict) -> requests.Session: s = requests.Session() s.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...", "Referer": "https://m.example.com/ticket/1001", "Origin": "https://m.example.com" }) s.cookies.update(cookie_dict) return s

逻辑说明:load_cookie用split("=", 1)只切分第一个等号,避免值里包含等号时被拆散。create_session把 Cookie 注入 Session,后续所有请求都会自动携带。注意 Cookie 里的关键字段不要随意删除,尤其是会话标识和设备指纹相关的字段,删掉任意一个都可能让服务端判定登录态不完整。

3.3 构造下单 Payload:字段映射与参数默认值

在真实接口抓包基础上,把有效字段精简成最小集合。字段过多不一定是好事,有些字段是页面自动计算的结果,多传反而可能触发参数校验。

def build_payload(cfg: dict) -> dict: """根据用户配置构造成单请求体""" return { "itemId": cfg["item_id"], # 项目 ID,详情页 URL 里可见 "performId": cfg["perform_id"], # 场次 ID "skuId": cfg["sku_id"], # 票档 ID "quantity": cfg["buy_count"], # 购票数量,不能超过限购数 "deliveryType": cfg.get("delivery_type", 2), # 默认电子票 "channel": "h5", # 渠道标识,用 H5 端抓包值 "seatType": "auto" # 自动选座策略 }

参数说明:itemId和performId在购票页地址栏或者页面源代码里能直接搜到;skuId需要点击具体价格档位后从网络请求里抓;quantity不建议写成固定值,因为每个账号有限购约束,提交前最好从配置读取。deliveryType默认 2 表示电子票,如果项目只支持邮寄,需要改成 1。channel必须与登录端的渠道保持一致,H5 端登录就写 h5。

3.4 下单请求与结果判定:怎么判断真的抢到了

下单接口返回的一般是 JSON,结构通常是{"success": true, "data": {"orderId": "xxx"}}或者{"success": false, "msg": "库存不足"}。脚本不能只看 HTTP 状态码,业务返回码才是真正需要关注的。

def place_order(session: requests.Session, cfg: dict): order_url = cfg["order_url"] # 从开发者工具复制的下单接口 payload = build_payload(cfg) try: resp = session.post(order_url, json=payload, timeout=3) data = resp.json() except requests.exceptions.Timeout: return False, "网络超时,需要重试" except requests.exceptions.ConnectionError: return False, "连接异常,需要检查网络" except ValueError: return False, f"响应不是 JSON,原始内容: {resp.text[:200]}" if data.get("success"): order_id = data.get("data", {}).get("orderId", "") return True, order_id return False, data.get("msg", "下单失败") if __name__ == "__main__": cfg = json.load(open("config.json", encoding="utf-8")) session = create_session(load_cookie()) ok, msg = place_order(session, cfg) print(f"结果: {ok}, 返回: {msg}")

逻辑说明:timeout=3控制单次请求的最长等待时间,避免接口挂死时脚本原地等待。先捕获超时和连接异常,再解析 JSON,防止服务端返回 HTML 错误页时程序直接抛异常。返回结果为success=true且拿到orderId才算成功,其余情况统一按失败处理并输出原因。第一版脚本到这里能跑通一次完整下单,但距离真正抢到票还差校时、并发和重试三层优化。

4. 把脚本从能跑优化到能打:校时、并发与重试

4.1 本地时间不可信:从服务器时间校准开售时刻

抢票脚本最容易翻车的地方是时间基准不对。本地电脑时间跟服务器时间通常有几百毫秒甚至几秒的偏差,如果你按照本地时间触发请求,可能早发导致请求被拒绝,也可能晚发导致票已经被抢空。常见做法是先从一个轻量接口获取服务器时间,计算出本地与服务器的偏移量。

import time def fetch_server_time(session: requests.Session) -> float: """从服务端取时间,返回毫秒时间戳。接口地址以实际抓包为准。""" resp = session.get("https://m.example.com/server_time", timeout=2) return float(resp.headers.get("X-Server-Time", "0")) / 1000

拿到服务器时间后,用offset = server_time - time.time()保存偏移量。开售循环里的判断基准就统一用服务器时间:

def wait_until(sale_server_ts: float, offset: float): """等待到指定服务器时间戳,offset 是本地与服务器的差值""" while True: now_server = time.time() + offset delta = sale_server_ts - now_server if delta <= 0: break if delta > 1: time.sleep(delta - 0.5) else: time.sleep(0.05)

参数说明:sale_server_ts是开售时刻的服务器时间戳,需要换算成秒级。offset是校准一次后缓存下来的偏移量。等待循环里做了两段式休眠,剩余时间大于 1 秒时粗粒度等待,小于 1 秒时按 50 毫秒轮询,避免线程被系统休眠精度拖累。

4.2 并发下单的正确做法:账号级并发,不是请求级并发

很多人想到“多线程同时发请求”来提高成功率,这是一个容易踩雷的方向。同一账号在极短时间内提交多个相同订单,很可能被风控判定为异常操作,导致订单全部取消甚至账号被临时锁定。正确的并发粒度是账号,不是一个请求。

from concurrent.futures import ThreadPoolExecutor def work(account_cfg: dict): s = create_session(load_cookie(account_cfg["cookie_path"])) ok, msg = place_order(s, account_cfg) return account_cfg["name"], ok, msg def run_with_accounts(accounts: list): with ThreadPoolExecutor(max_workers=len(accounts)) as pool: results = list(pool.map(work, accounts)) return results

max_workers设置为账号数量,每个线程专职处理一个账号的会话,互不干扰。注意不要让多个线程共享同一个 Session,因为 Session 内部的 Cookie 更新和连接复用不是线程安全的,共享状态下容易出现请求参数错乱。

4.3 重试与退避:哪些失败值得重试,哪些必须停下

重试不是无限点击。盲目重试不但帮不上忙,还会拉高请求频率,把账号和 IP 都拖进风控名单。我一般把失败类型分成两类:值得重试的包括网络超时、连接异常、服务端返回 5xx;不值得重试的包括参数错误、验证码拦截、风控提示、已经生成订单但未返回 orderId 的情况。

import time def try_with_retry(session: requests.Session, cfg: dict, max_retries: int = 5): backoff = [0.0, 0.3, 0.6, 1.0, 1.5] for attempt in range(max_retries): ok, msg = place_order(session, cfg) if ok: return True, msg if ("参数错误" in msg or "验证码" in msg or "风控" in msg): return False, msg # 这类失败重试没有意义,必须停下来 time.sleep(backoff[attempt]) return False, "重试次数耗尽"

参数说明:backoff是逐次增加的等待时间,目的是把请求间隔拉开,给服务端留出处理空间。第一次重试立即执行,第二次等待 0.3 秒,第三次 0.6 秒,以此类推。max_retries=5是上限,超过就不再尝试,交由人工处理。这样设计的好处是:遇到瞬时网络问题有机会 recover,遇到业务层面的拦截则立刻止损。

5. 抢票脚本避坑:5 个真实卡点与排查对策

5.1 现象:接口报“参数错误”,但浏览器里同样操作正常

脚本里明明用了从浏览器复制下来的字段,为什么提交后提示参数错误?最常见原因是请求体里少了动态签名参数,例如sign或token。浏览器页面每次操作前都会重新计算签名,而你复制的是几分钟前的旧值,到提交那一刻已经失效。

解决思路分两步:先打开开发者工具,对比浏览器真实请求和脚本请求的请求体,逐个字段排查差异。找到动态参数后,优先在开售前几分钟手动触发一次预签名,把新鲜值写入配置。如果签名时效极短,就需要分析提交订单前的 JS 逻辑,用无头浏览器执行签名计算,把计算结果回传给脚本主程序。这属于脚本里最麻烦的部分,但排查方法始终只有一个:对比真实请求与脚本请求的完整差异。

5.2 现象:验证码越出越多,滑块滑不完

脚本运行一段时间后,接口开始返回验证码要求,且频率越来越高。这不是脚本请求头写得不够像,而是请求节奏太均匀了。风控模型会关注请求密度的统计学特征,机器人请求往往时间间隔非常规律,而人类操作天然带有随机抖动。

解决方法是把预热和重试的间隔都加上随机波动。比如预热请求间隔从固定的 30 秒改成 25 到 35 秒随机;重试的退避时间加上 ±0.1 秒抖动。另一个原则是不要硬碰验证码:遇到滑块验证码就停下来,转为手动处理,脚本最多做到“自动触发 + 人工滑动”,不要试图在识别验证码上投入过多精力,性价比极低。

5.3 现象:一个账号多线程并发,结果全部失败

我见过不少案例:用三个线程同时对一个账号发起下单请求,服务端响应都很快,但最终一个订单都没生成,账号反而被要求重新登录。原因是同一账号在同一场次短时间内提交多个订单请求,风控端会判定为异常排队,直接让所有请求失效。

正确的做法是并发以账号为最小单位。如果你想同时抢多张票,优先准备多个账号,每个账号分配一个独立线程。同一个账号哪怕有多个票档备选,也不要并发请求,改为串行提交,第一个票档失败再尝试第二个。

5.4 现象:本地测试正常,开售瞬间仍显示“已售罄”

脚本在开放购买前测试可以正常下单,但真正开售那一刻总是抢不到。排查时发现本地电脑时间和服务器时间差了 3 秒,脚本在服务器还没开售时就发出了请求,被服务端拒绝;等到重试时,票已经进入正常售卖队列并被其他用户抢走。

这个问题在 4.1 节已经给出校准方案。这里补充一个细节:校时接口要选一个轻量接口,最好不要用下单接口本身去取时间,避免额外产生订单副作用。同时建议在开售前 10 分钟做一次校时,开售前 30 秒再做一次,两个时间点取平均值,减少网络波动带来的误差。

5.5 现象:重试到后半段全是 503 / 超时,被网关限流

脚本开始两三分钟一切正常,再往后接口开始报 503 或直接超时。这通常是出口 IP 被网关限流了。票务系统的网关会统计单个来源 IP 的短时请求量,超过阈值后对后续请求做限制。真实用户不会在几秒内请求上百次,脚本如果重试太激进,很快会撞上这层限制。

解决方法是给脚本设置总运行时长和总请求次数上限。比如每次抢票流程最多运行 60 秒,最多请求 20 次,超限就退出。重试间隔加大,并且每次加随机抖动。如果 Cookie 已经失效,立刻停止重发,否则每一次无效请求都在拉长限流窗口。这也是为什么 4.3 节要把“参数错误”和“风控提示”归类为不值得重试的失败。

6. 验证抢票脚本有效性的三板斧:日志、断言与演练

6.1 全链路日志:把每一次请求的耗时和结果落盘

没有日志的抢票脚本就像没有仪表盘的驾驶舱,成功和失败全靠猜。我会为每次运行生成独立日志文件,时长精确到毫秒:

import logging import time logging.basicConfig( filename=f"grab_{time.strftime('%Y%m%d_%H%M%S')}.log", level=logging.INFO, format="%(asctime)s.%(msecs)03d %(levelname)s %(message)s", datefmt="%H:%M:%S" ) logging.info("开始抢票,目标时间戳: %s", sale_timestamp) ok, order_id = place_order(session, cfg) logging.info("下单结果: %s, 订单号: %s", ok, order_id)

日志里记录的不仅仅是最终结果,还应该记录每次重试的时间点、请求耗时和失败原因。复盘时对照时间线,能很快看出是校时偏差、重试策略错误还是需要等验证码。

6.2 用 HTTP 状态码与业务返回码做双重断言

下单成功的判断不能只看响应里有没有success字段。高可靠的断言是:HTTP 状态码为 200,业务返回success=true,data.orderId非空且长度大于 16 位。三个条件同时满足才算成功。

def assert_order_success(resp, data) -> bool: if resp.status_code != 200: return False if not data.get("success"): return False order_id = data.get("data", {}).get("orderId", "") return bool(order_id)

这样写的原因是有时候业务系统会返回success=true,但 data 里没有 orderId,这多半是下单流程没走完,不能算抢到。把断言做成独立函数后,可以在日志和告警两个地方同时复用。

6.3 用模拟场次做演练,拒绝赛后后悔

我的习惯是在正式开售前,找一场冷门的免费预约或开放报名的活动,用同一套脚本流程先跑一遍。模拟场次不需要真的付款,只要走通“提交订单到接口返回成功”的链路即可。演练时重点观察三个指标:下单请求从发出到返回的时间是否稳定在 200 毫秒以内;重试机制是否正确区分了可重试与不可重试的错误;日志里有没有出现非预期的异常堆栈。

A同学第一次用脚本抢票时,把重试间隔写成 0,结果接口连续 503,账号被临时限制登录。后来他先在模拟场次里跑了几轮,发现问题是重试策略太激进,改正后再上正式场次就顺利很多。这套逻辑在冷门场次验证过,比在热门场次里反复试错成本低得多。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进