B站会员购抢票脚本拆解:接口调用、验证码预演与GUI工具开发
简介:面向哔哩哔哩会员购漫展抢票场景的自动化脚本项目,采用图形化界面与纯接口设计,集成验证码预演练习功能,适合具备编程基础并希望掌握抢票流程或验证码识别技术的开发者学习使用。压缩包共六十六个文件,大小十九点五四兆字节,以源码脚本为主,辅以样例、配置、说明文档,并包含极验等验证码处理模块、容器化配置、仓库元数据等,目录划分清晰。项目内封装有请求模块、订单二维码生成、消息推送工具及多个验证码识别示例,同时附带测试文件用于功能验证。已有九十一人学习下载。资源不仅提供完整可运行的项目骨架,还通过多样化的验证器与调试样例,帮助读者理解接口交互、会话管理和验证码识别等关键环节,便于二次开发或作为接口自动化学习的练习项目。
1. 抢票脚本抢的不是票,是接口时序:B站会员购纯接口路线拆解
做过抢票脚本的人都知道,真正难的不是“手速”,而是把登录态、场次信息、验证码全部前置准备好,然后在开售瞬间只提交一个订单请求。B站会员购的漫展票务走的是典型的 Web 接口下单链路,纯接口脚本不做浏览器模拟,直接用 HTTP 请求跟服务端打交道,比 Selenium 那套轻一个量级,响应快、可控性强,也更容易做成图形化工具。这个标题里的“验证码预演练习项目”,本质就是把最容易翻车的一环——验证码识别与模拟提交——从真实抢票流程里剥离出来,先跑通链路再谈其他。这篇文章适合想学接口调用、签名处理和 GUI 工程化的从业者,也适合拿这个练习项目当接口编程练手的人。前提是:把它当学习项目,不要拿去干扰正常购票秩序。
2. 会员购下单链路与关键参数:摸清接口才能写脚本
2.1 会员购票务下单的四个阶段
B站会员购里的漫展票务,下单流程可以拆成四个阶段:
- 拉取展会信息与场次列表;
- 选中具体场次和票档;
- 携带实名信息、收货方式提交订单;
- 验证码校验与支付跳转。
纯接口脚本只关心第 2 到第 4 步。第 1 步通常在图形化界面里提前完成,把场次号和票档号塞进配置文件,开售瞬间不需要再拉一次列表,减少一次往返就少一分延迟。
常见做法是先用浏览器登录 B 站,打开开发者工具(F12)的 Network 面板,手动走一遍“进入会员购 → 选场次 → 选票档 → 提交订单”的操作,把每一步的请求路径、请求头、表单参数记录下来。这个动作是整个练习项目的地基,后面所有代码里的 URL 和参数名都来自这次抓包,标题里说的“纯接口”指的就是这一套东西。
我一般会把抓到的请求按时间顺序整理成一张表:
| 阶段 | 请求作用 | 关键参数 |
|---|---|---|
| 场次列表 | 获取可选场次 | 展会ID、页码 |
| 票档列表 | 获取票价与余量 | 场次ID |
| 提交订单 | 创建订单 | 票档ID、数量、实名信息 |
| 验证码校验 | 校验滑块/点选 | 验证码token、轨迹数据 |
抓包时注意勾选“Preserve log”,避免页面跳转后请求记录被清空。这一步不需要写代码,但决定了后面所有代码能不能跑通。
2.2 纯接口方案的请求链与Session管理
纯接口方案的核心是一个requests.Session。Session 会自动维持 cookie,保证登录态在多次请求之间不丢。
用代码描述最小请求链的话,骨架是这样:
import requests # 全局会话:自动携带 cookie,保持登录态 session = requests.Session() def build_headers(referer: str) -> dict: return { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Referer": referer, # 很多接口会校验 Referer,务必从抓包里抄 "Origin": "https://member.bilibili.com", } def fetch_sessions(event_id: str, cookie_str: str): # 把浏览器里复制出来的完整 cookie 注入会话 session.headers.update({"Cookie": cookie_str}) # 场次列表接口:路径和参数以抓包结果为准 resp = session.get( "https://api.example.com/x/ticket/session/list", params={"event_id": event_id, "page": 1}, headers=build_headers("https://member.bilibili.com/xxx"), timeout=5, ) # 先看状态码,再看业务 code data = resp.json() if data.get("code") != 0: print("接口返回异常:", data.get("message")) return [] return data["data"]["list"]逻辑说明:先创建全局session,把浏览器里的 cookie 复制进去,这样后续请求都带着登录态。fetch_sessions负责拉场次列表,URL 和参数必须替换成你自己抓到的真实值。示例里用api.example.com占位,不是让你照抄——会员购的接口域名和路径会随版本调整,练习项目最忌讳写死一组过期的 URL。
参数说明:headers里的Referer容易被忽略,但很多接口靠它判断请求来源,少了直接返回 403。timeout一定要显式设置,默认情况下requests会无限等待,开售瞬间服务端响应慢,脚本会挂在那里不报错。
2.3 鉴权参数与签名:cookie、timestamp和sign
会员购的部分接口不止要 cookie,还要带签名参数。签名的作用是让服务端确认请求参数没有被篡改,常见做法是把所有业务参数排序后拼上密钥,做一次 MD5。
一个通用签名函数长这样:
import hashlib import time def make_sign(params: dict, secret: str) -> str: # 1. 过滤空值 items = {k: v for k, v in params.items() if v is not None and v != ""} # 2. 按 key 字典序拼接 raw = "&".join(f"{k}={items[k]}" for k in sorted(items)) + secret # 3. MD5 后转大写 return hashlib.md5(raw.encode("utf-8")).hexdigest().upper() def build_submit_params(ticket_id: str, count: int, ts: int) -> dict: params = { "ticket_id": ticket_id, "count": count, "ts": str(ts), "appkey": "从抓包里获取", } params["sign"] = make_sign(params, "从抓包里获取的签名密钥") return params逻辑说明:make_sign的处理顺序是固定的——过滤空值、按 key 排序、拼接密钥、MD5。任何一步顺序不对,服务端算出的签名就和本地不一致,返回签名错误。
参数说明:appkey和密钥不要写在代码里到处贴,练习项目里放到config.py或者本地环境变量。密钥来自抓包时看到的请求参数,不同版本的客户端可能用不同的 appkey,换一台设备抓到的可能都不一样。签名的意义是练习“参数防篡改”的思路,不是为了绕过什么限制。
注意:接口路径、参数名、密钥都会变。跑不通时先重新抓包,不要怀疑签名算法写错了——先核对参数名是否和抓包完全一致。
3. 图形化界面从零搭:用PyQt5把抢票脚本变成工具
3.1 为什么选PyQt5而不是Tkinter
纯接口脚本本身是命令行跑的,但标题里明确要求“图形化”,那就要选 GUI 框架。常见选择有 Tkinter、PyQt5、PySide6。
Tkinter 是 Python 自带的,胜在零依赖,但做抢票工具这种需要“配置区 + 实时日志 + 状态提示”的界面,它的控件样式偏老旧,表格组件也简陋。PyQt5 的好处是控件齐全:QTableWidget展示场次列表、QPlainTextEdit做日志输出、QFormLayout铺配置表单,一套下来界面像样,而且信号槽机制天然适合处理后台线程的回调。
PySide6 是 Qt 官方绑定,但网上资料和案例数量目前不如 PyQt5 多。练习项目优先选资料多的,遇到问题搜得到答案比什么都重要。
安装就一条命令:
pip install pyqt5 requests opencv-pythonopencv-python是给第 4 章的验证码预演用的,如果只做 GUI 可以暂时不装。版本上 PyQt5 选 5.15 系列即可,不需要追新。
3.2 界面结构:配置区、场次区、控制区、日志区
图形化界面的布局我习惯分成四块,从上到下依次排开:
- 配置区:cookie、展会ID、票档ID、抢票数量;
- 场次区:表格展示场次列表,支持手动选择;
- 控制区:开始抢票、停止、清空日志三个按钮;
- 日志区:实时滚动输出请求日志。
主窗口骨架:
from PyQt5.QtWidgets import ( QApplication, QWidget, QLabel, QLineEdit, QPushButton, QFormLayout, QVBoxLayout, QTableWidget, QPlainTextEdit, QHBoxLayout ) class MainWindow(QWidget): def __init__(self): super().__init__() self.setWindowTitle("会员购抢票练习工具") self.resize(720, 620) # 配置区 form = QFormLayout() self.cookie_edit = QLineEdit() self.event_edit = QLineEdit() self.count_edit = QLineEdit("1") form.addRow("Cookie", self.cookie_edit) form.addRow("展会ID", self.event_edit) form.addRow("数量", self.count_edit) # 场次区 self.table = QTableWidget(0, 3) self.table.setHorizontalHeaderLabels(["场次ID", "名称", "余量"]) # 控制区 self.start_btn = QPushButton("开始抢票") self.stop_btn = QPushButton("停止") self.clear_btn = QPushButton("清空日志") btn_row = QHBoxLayout() btn_row.addWidget(self.start_btn) btn_row.addWidget(self.stop_btn) btn_row.addWidget(self.clear_btn) # 日志区 self.log_view = QPlainTextEdit() self.log_view.setReadOnly(True) # 总体布局 layout = QVBoxLayout(self) layout.addLayout(form) layout.addWidget(self.table) layout.addLayout(btn_row) layout.addWidget(self.log_view) def append_log(self, msg: str): # 带时间戳写日志,方便事后排查时序问题 from datetime import datetime line = f"[{datetime.now().strftime('%H:%M:%S.%f')[:-3]}] {msg}" self.log_view.appendPlainText(line)逻辑说明:界面本身不执行抢票逻辑,只负责收集配置、展示状态、触发开始和停止。append_log是统一的日志入口,所有业务代码通过它输出信息,时间戳精确到毫秒,后面排查“是不是提前了 200ms 发起请求”这类问题全靠它。
参数说明:Cookie 这个输入框要支持粘贴浏览器里的完整 cookie 字符串,不要做成“用户名 + 密码”登录表单——纯接口路线不负责模拟登录,登录态从浏览器复制过来是最简单可靠的方案。这也是为什么标题里写“纯接口”而不是“全自动登录”。
3.3 抢票线程与界面分离:QThread的正确用法
新手最容易踩的坑是:点击“开始抢票”后,在按钮的 clicked 信号里直接跑 while 循环,结果窗口立刻卡死,鼠标转圈,最终只能强杀进程。原因很简单——抢票循环是阻塞操作,把它放在 GUI 主线程里,Qt 的事件循环就被挡住了。
正确做法是把抢票逻辑丢进QThread的子类,通过信号把日志和结果传回主线程:
from PyQt5.QtCore import QThread, pyqtSignal class GrabWorker(QThread): log = pyqtSignal(str) # 日志信号,回传主线程 finished_order = pyqtSignal(dict) # 订单结果信号 def __init__(self, config: dict): super().__init__() self.config = config self._stop = False def run(self): # 抢票循环在子线程里跑 while not self._stop: self.log.emit(f"尝试提交订单,票档 {self.config['ticket_id']}") # 这里调用第 2 章的请求函数 # result = submit_order(self.config) # 成功后就发信号并退出 if True: # 示意条件 self.finished_order.emit({"order_id": "mock_order_001"}) break def stop(self): self._stop = True在主窗口里这样启动:
def start_grab(self): config = { "cookie": self.cookie_edit.text().strip(), "event_id": self.event_edit.text().strip(), "count": int(self.count_edit.text().strip()), } self.worker = GrabWorker(config) self.worker.log.connect(self.append_log) self.worker.finished_order.connect(self.on_order_done) self.worker.start() def on_order_done(self, order: dict): self.append_log(f"订单创建成功: {order['order_id']}")逻辑说明:GrabWorker继承QThread,run()里是真正的抢票循环。pyqtSignal负责把子线程里的字符串和字典“搬运”到主线程,connect指向界面上的日志函数和完成回调。因为信号槽默认是队列连接,跨线程调用是安全的。
参数说明:stop()用一个布尔标志位实现协作式停止,不要在窗口关闭时直接调terminate(),那会中断正在进行的 HTTP 请求,可能导致订单重复提交或者数据写坏。窗口关闭事件里应该先stop(),再wait()等待线程退出。
4. 验证码预演练习:滑块识别、打码接入与测试边界
4.1 会员购场景里会遇到的三类验证码
B站会员购在提交订单阶段可能出现验证码,常见的有三类:滑块验证码、点选文字验证码、短信验证码。纯接口脚本里短信验证码无法自动处理,因为要接收手机短信,这块基本留给人工。点选文字验证码需要 OCR 或者深度学习模型,练习成本偏高。滑块验证码是最适合做“预演练习”的——它图形特征明确,本地就能做识别实验。
标题里的“验证码预演练习项目”我理解就是这个意思:不追求真实环境里的高通过率,而是把“图片获取 → 缺口定位 → 滑动轨迹生成 → 校验提交”整条链路在本地跑通,验证自己的代码逻辑有没有问题。
4.2 滑块缺口定位:模板匹配与边缘检测的预演
滑块验证码的缺口定位,最常用的本地方案是 OpenCV 的模板匹配:拿一张“无缺口的背景图”当模板,在“有缺口的背景图”里找差异位置。如果拿不到无缺口图,就用边缘检测找出缺口轮廓。
一个可以跑的预演示例:
import cv2 import numpy as np def find_gap_x(bg_path: str, gap_path: str) -> int: # 背景图与滑块小图的缺口位置匹配 bg = cv2.imread(bg_path, cv2.IMREAD_GRAYSCALE) gap = cv2.imread(gap_path, cv2.IMREAD_GRAYSCALE) # 模板匹配,计算左上角最佳匹配位置 result = cv2.matchTemplate(bg, gap, cv2.TM_CCOEFF_NORMED) _, _, _, max_loc = cv2.minMaxLoc(result) # 返回缺口中心 x 坐标,y 方向一般不用管 h, w = gap.shape[:2] return max_loc[0] + w // 2 def generate_track(distance: int) -> list: # 模拟人类滑动轨迹:先快后慢,带随机抖动 track = [] current = 0 mid = distance * 0.7 while current < distance: if current < mid: step = int(np.random.normal(12, 4)) else: step = int(np.random.normal(4, 2)) step = max(1, min(step, distance - current)) current += step track.append(current) return track逻辑说明:find_gap_x用模板匹配找到缺口在背景图中的位置,返回的是位移距离。generate_track生成滑动轨迹,轨迹的特点是先快后慢,每一步长度带随机噪声,这样比一次拖到位更像人手操作。
参数说明:TM_CCOEFF_NORMED是归一化相关系数匹配,对光照变化不敏感,滑块验证码里灰度图直接跑这个就行。track里的步长参数(12、4)不是定死的,不同验证码的缺口距离和画幅比例不一样,练习时先打印出distance的值,再观察轨迹曲线,确认没有一步跨过终点的情况。
注意:真实环境里验证码还会带时间戳和轨迹加密参数,本地预演通过不代表线上能过。练习项目的价值在于把链路跑通,而不是保证通过率。
4.3 验证码预演模块的代码组织
验证码这块代码别和 GUI 逻辑混在一起,单独拆一个模块,方便换算法、换打码平台。目录结构我习惯这样组织:
# 示意目录结构,不包含具体文件内容 main.py # GUI 入口 grab_worker.py # 抢票线程 captcha/ __init__.py slider.py # 滑块定位与轨迹生成 platform.py # 打码平台接入预留 resources/ sample_bg.png # 预演用样例图片 sample_gap.pngplatform.py里可以预留一个打码平台接口的抽象层:
class CaptchaSolver: """统一验证码识别接口,预演项目里用本地算法实现""" def __init__(self, mode: str = "local"): self.mode = mode # local 或 platform def solve(self, bg_path: str, gap_path: str) -> dict: if self.mode == "local": from captcha.slider import find_gap_x, generate_track distance = find_gap_x(bg_path, gap_path) return {"distance": distance, "track": generate_track(distance)} else: # 打码平台的 HTTP 调用在这里实现,仅用于测试 raise NotImplementedError("平台接入需要自行申请并配置密钥")逻辑说明:CaptchaSolver做了本地算法和平台接入的隔离。练习项目默认走local,用 OpenCV 跑通全流程;platform分支留了口子,以后真要接打码服务,只需要在solve里加一个 HTTP 请求,不影响上层调用代码。
参数说明:mode的取值放到配置文件里,不要在代码里写死。预演项目的样例图片放在resources目录,用相对路径加载,避免换机器后路径失效。
5. 图形化抢票避坑清单:时间偏移、风控与订单丢失
5.1 现象:按钮点了脚本没反应,控制台也没有日志
原因多半是异常被吞了。GUI 程序里按钮的回调函数如果不做异常捕获,任何报错都会直接打到 stderr,而用pythonw.exe启动或打包成 exe 后,stderr 是不可见的,看起来就是“没反应”。
解决:在start_grab开头和GrabWorker.run里包一层 try/except,把traceback.format_exc()直接写到日志区:
import traceback def start_grab(self): try: # ... 原有逻辑 pass except Exception: self.append_log(traceback.format_exc())血泪经验是:任何回调函数都值得加这句兜底,不要相信“我这个函数不会有异常”的想法。
5.2 现象:日志显示“提交成功”但订单列表里查不到
先说结论:你看到的“成功”多半是假成功。很多接口的 HTTP 状态码是 200,但业务 code 不是 0,而是“库存不足”“参数错误”“未实名”。脚本只看状态码就以为成功了,订单自然不存在。
解决:提交订单后必须校验两层——HTTP 状态码是 200,且 JSON 里的code == 0,并且能从data里取到订单号。取不到订单号一律视为失败,把完整的响应体打出来:
resp = session.post(url, data=params, headers=headers) body = resp.json() if body.get("code") != 0: self.log.emit(f"下单被拒: {body.get('message')}") return None order_id = body["data"]["order_id"] # 这里取不到就抛异常,触发日志5.3 现象:滑块识别结果正确,提交后仍被拦截
本地预演时缺口定位很准,但真实提交总被拒,原因通常在三个地方:轨迹太假、验证码 token 过期、请求少了加密参数。第一个最容易被忽略——只传了滑到终点的x坐标,没传轨迹数组,或者轨迹数组是直线匀速的,一眼就是机器。
解决:把generate_track生成的轨迹数组完整传给校验接口,并且在校验前确认验证码图片和 token 是同一批次拿到的。预演练习里不要追求过真实风控,把“识别 → 生成轨迹 → 提交 → 看反馈”这几步的日志打通,知道每一站在哪失败就够了。
5.4 现象:开售前1秒开始抢,反而被限流
很多人把脚本改成在开售前 1 秒连续发 10 个请求,结果被限流。原因有两个:一是本地时钟和服务端时钟有偏差,你以为的“开售前 1 秒”实际是“开售后 1 秒”;二是瞬时高频请求直接触发风控,真正的抢票不是拼请求数量,而是拼单次请求的完整度。
解决:先做时间校准,用一个时间接口取服务端时间,算出本地偏移量:
import time, requests def server_offset_ms(session: requests.Session) -> float: # 请求带 ts 参数,服务端返回自己的时间戳 resp = session.get("https://api.example.com/time", params={"ts": int(time.time() * 1000)}) server_ms = resp.json()["data"]["server_ms"] return server_ms - time.time() * 1000 # 正数代表本地慢了拿到偏移量后,所有“是否到点”的判断都用本地时间 + offset。请求频率控制在开售瞬间发 1 到 2 个请求,后面用退避重试,别在同一毫秒打出一串请求。
5.5 现象:抢票过程中GUI卡死,只能强杀进程
界面卡死的根因在 3.3 节已经说过:耗时操作放到了主线程。但还有一种隐蔽情况——子线程里直接用print打印日志,或者子线程里调用了修改界面控件的代码,Qt 跨线程操作控件会引发未定义行为,表现就是偶发卡死。
解决:所有日志一律走QThread的信号回传,子线程里绝不直接碰控件。字符串拼接和控件更新都在主线程的槽函数里做。这一点没有商量余地,不是“碰巧没问题”就算过的。
6. 让练习项目可自检:报文回放、签名校验与日志分级
6.1 用真实报文回放验证解析逻辑
验证接口解析代码对不对,最快的方式是把抓包得到的真实请求保存成离线用例,然后写一个回放脚本:
# replay_check.py 示意 import json, requests with open("cases/submit_order_case.json", "r", encoding="utf-8") as f: case = json.load(f) session = requests.Session() session.headers.update(case["headers"]) resp = session.post( case["url"], params=case.get("params"), data=case.get("data"), timeout=5, ) assert resp.status_code == 200, f"状态码异常: {resp.status_code}" body = resp.json() assert body.get("code") == 0, f"业务异常: {body}" print("回放通过,订单号:", body["data"]["order_id"])回放的意义是:不依赖真实开售时间,不依赖真实库存,随时验证代码解析逻辑有没有被改坏。我把每次抓包保存成一个 JSON 文件,放进cases/目录,跑回归测试时就逐个回放。
6.2 签名校验与时间自检
签名出错是最难排查的一类问题,因为报错信息往往只是“sign error”。我的自检习惯是写一个独立脚本,对同一组参数用代码算一遍签名,再和抓包里的真实 sign 比对。如果算法和密钥都对,结果应该完全一致。不一致就按“参数没过滤空值、排序不是字典序、密钥复制多了一个空格”这三个方向排查。
6.3 日志分级与收尾
练习项目的日志不要只分“成功/失败”两档。我一般分三级:INFO记录每次请求的 URL、耗时、HTTP 状态码;WARN记录重试、限流、验证码被拒;ERROR记录抛异常和订单创建失败。GUI 日志区用颜色区分三档,排查问题时一眼就能定位是哪一段链路出了问题。
这个项目做完,我最大的感受是:抢票脚本的工程难点从来不在“发请求”,而在“时序、状态管理和可观测性”。每次开售前我都会跑一遍报文回放,确认签名、cookie、参数解析都没被改坏,才敢把界面打开。图形化界面是给别人看的,回放脚本和日志分级才是给自己留的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取