从B站「抢救」5000余条视频数据的全过程
起因
一位UP主注销了账号,其空间页返回 404。然而该UP主曾发布 5000 余条视频,这些内容并未真正消失——通过 BV 号直接访问 bilibili.com/video/BVxxxxx/ 仍可正常播放,搜索引擎也依然能检索到。所谓「注销」,实质上只是隐藏了空间页面,底层数据完好无损。
问题在于:没有任何公开入口能浏览一个已注销用户的完整投稿列表。更棘手的是,由于 B站的分页限制,超过 3000 条的部分甚至无法通过 API 获取。
为了完整归档这些视频的元数据,我逐一突破了 B站反爬体系的多层防护。
B站的四层防线
B站的反爬体系分为四层,每层采用不同的技术手段,层层递进:
┌─────────────────────────────────────┐
│ 第四层:分页硬限制 (pn>100) │ ← 服务端强制截断
├─────────────────────────────────────┤
│ 第三层:频率限制 (-799) │ ← 请求频率控制
├─────────────────────────────────────┤
│ 第二层:WBI 签名 (-352) │ ← API 鉴权机制
├─────────────────────────────────────┤
│ 第一层:TLS 指纹检测 (412) │ ← 传输层身份识别
└─────────────────────────────────────┘
第一层:TLS 指纹检测
B站在接入层部署了 TLS 指纹识别系统。使用 Python requests 库请求 API 时,HTTP 请求尚未发出,服务端便已在 TLS 握手阶段将其识别为自动化客户端,直接返回 412:
import requests
resp = requests.get("https://api.bilibili.com/x/space/wbi/arc/search?mid=223236400")
# → HTTP 412
原因在于 HTTPS 连接建立时,Client Hello 报文中的密码套件列表、TLS 扩展、椭圆曲线参数等信息可以组合为所谓的 JA3/JA4 指纹。Python requests 底层使用的 urllib3 与 Chrome 浏览器的 BoringSSL 在握手特征上存在显著差异,在传输层即暴露了爬虫身份。
解决方案: 使用 curl_cffi 模拟 Chrome 的 TLS 指纹:
from curl_cffi import requests as cffi_requests
session = cffi_requests.Session(impersonate="chrome131")
resp = session.get("https://api.bilibili.com/x/space/wbi/arc/search?mid=223236400")
# → HTTP 200
该库基于 curl-impersonate 项目,能够生成与 Chrome 131 完全一致的 Client Hello 报文。
值得注意的是一个反直觉的发现:不应预先访问 bilibili.com 获取 Cookie。B站在 Cookie 中植入的 buvid3 等标识符会与当前 TLS 会话绑定,若后续请求的 TLS 指纹发生变化,反而会触发风控。以无 Cookie 状态直接请求 API 更为稳妥。
第二层:WBI 签名验证
自 2023 年起,B站对空间视频列表等 API 引入了 WBI 签名机制。每个请求必须携带有效的 w_rid(签名)和 wts(时间戳),否则返回 -352(风控校验失败)。
通过分析 B站前端 JavaScript 代码,逆向出了完整的签名流程:
第一步:获取动态密钥
请求 /x/web-interface/nav
→ 从 img_url 和 sub_url 中提取密钥片段
第二步:密钥混淆
将两段密钥拼接(64 字符),通过固定置换表重排后截取前 32 位
第三步:参数签名
将请求参数按 key 排序后编码为查询字符串,
拼接混淆密钥后计算 MD5,即为 w_rid
其中置换表硬编码在前端代码中:
MIXIN_KEY_ENC_TAB = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13,
37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4,
22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52
]
代码实现:
def get_mixin_key(orig: str) -> str:
"""通过置换表重排密钥,截取前 32 位"""
return "".join([orig[i] for i in MIXIN_KEY_ENC_TAB])[:32]
def sign_params(params: dict, mixin_key: str) -> dict:
"""对请求参数进行 WBI 签名"""
params["wts"] = str(int(time.time()))
sorted_params = dict(sorted(params.items()))
query = urllib.parse.urlencode(sorted_params)
w_rid = hashlib.md5((query + mixin_key).encode()).hexdigest()
sorted_params["w_rid"] = w_rid
return sorted_params
需要注意的是,密钥存在有效期。长时间使用同一 mixin_key 会导致签名失效,需每 15~20 页重新获取密钥:
if page_counter >= 15:
session, mixin_key = new_session_and_keys()
page_counter = 0
第三层:频率限制
B站对 API 调用频率有所限制,短时间内大量请求会收到 -799(请求过于频繁)。应对方式是在每次请求之间添加随机延迟:
REQUEST_DELAY = (2.0, 3.5) # 秒
time.sleep(random.uniform(*REQUEST_DELAY))
随机间隔比固定间隔更接近真实用户行为。相应的代价是,5000 余条视频的爬取需要约 2~3 小时。
第四层:分页硬限制
这是整个过程中最棘手的一层。B站在服务端硬编码了分页上限:当页码超过 100 时,无论 TLS 指纹和 WBI 签名多么正确,一律返回 HTTP 412。
100 页 × 30 条/页 = 3000 条。对于投稿数超过 3000 的UP主,单一排序方式无法获取全部数据。
3000 这个数字的由来
一个值得关注的现象:B站视频合集功能的单个合集容量上限恰好也是 3000。这与分页限制完全吻合,大概率并非巧合,而是B站架构中一个统一的常量。
其根本原因与深度分页的性能问题密切相关:
Elasticsearch 层面: B站的搜索服务大概率基于 ES。ES 的 from + size 分页机制在深度分页时开销极大——假设集群有 5 个分片,查询第 100 页时每个分片需取出 3000 条记录,协调节点需对 15,000 条记录进行排序,最终仅返回 30 条:
查第 1 页: 每个分片取 30 条 → 排序 150 条 → 返回 30 条
查第 100 页: 每个分片取 3000 条 → 排序 15,000 条 → 返回 30 条
查第 1000 页: 每个分片取 30000 条 → 排序 150,000 条 → 返回 30 条 ← 内存溢出风险
数据库层面: 即使使用 MySQL/TiDB,OFFSET 29970 意味着先扫描并丢弃近 3 万行再返回 30 行,性能同样不可接受。
此外,从返回的是 HTTP 412 而非 JSON 业务错误码(如 -352、-799)来看,该检查大概率发生在 API 网关层(Nginx/OpenResty),而非业务服务内部:
-- 推测的网关层 Lua 代码
local pn = tonumber(ngx.var.arg_pn) or 1
if pn > 100 then
return ngx.exit(412)
end
综合来看,100 页限制并非单纯的反爬策略,而是一项系统级架构约束——通过牺牲极少数高产UP主 3000 条之后的可访问性,换取整个系统的稳定性。
应对方案一:多排序合并
B站 API 支持多种排序方式,不同排序下视频的分页位置各不相同。因此可以分别按不同排序爬取 100 页,再去重合并以扩大覆盖范围:
| 排序方式 | 含义 | 覆盖范围 |
|---|---|---|
pubdate |
发布时间 | 最新 3000 条 |
click |
播放量 | 播放量最高的 3000 条 |
stow |
收藏量 | 收藏量最高的 3000 条 |
三种排序的并集去重后,共获取约 4,200 条不重复记录。
sort_orders = ["pubdate", "click", "stow"]
all_videos_map = {} # bvid → video,以 BV 号去重
for order in sort_orders:
new_videos = fetch_all_with_order(order, existing_bvids, progress)
for v in new_videos:
all_videos_map[v["bvid"]] = v
应对方案二:浏览器控制台注入
多排序合并后仍有约一千余条视频未能覆盖(第 101~179 页的时间线数据)。对此需要借助真实浏览器环境——在已登录的浏览器中,通过控制台执行自定义脚本:
(async function() {
const MID = "223236400";
const START_PAGE = 101;
const END_PAGE = 179; // ceil(5348 / 30)
// ... WBI 签名逻辑(含纯 JS 实现的 MD5 算法)...
for (let pn = START_PAGE; pn <= END_PAGE; pn++) {
let params = { mid: MID, ps: "30", pn: pn.toString(), order: "pubdate" };
let queryStr = signParams(params);
let resp = await fetch(url, { credentials: "include" });
// ... 收集数据 ...
await new Promise(r => setTimeout(r, 1200));
}
// 自动下载为 JSON 文件
let blob = new Blob([JSON.stringify(allVideos)], {type: "application/json"});
// ... 触发下载 ...
})();
该脚本的几个关键设计:
- 浏览器环境中无法使用 Python hashlib,因此在脚本内内联了完整的 MD5 实现
- 通过 credentials: "include" 携带浏览器 Cookie,利用已登录状态
- 爬取完成后自动生成 JSON 文件并触发下载,无需手动复制数据
数据处理流水线
API 接口 处理 产出
───────── ────── ──────
/x/space/wbi/arc/search ──→ Python 爬虫 ──→ CSV(元数据)
↓ ↓
/x/web-interface/nav ──→ WBI 密钥刷新 封面缓存(base64)
↓ ↓
浏览器控制台 ──→ JSON(101页+) 头像/横幅(base64)
↓
HTML 生成器 ──→ 单文件 HTML(约 18MB)
图片压缩
为生成单文件自包含 HTML,所有封面图需以 base64 编码内嵌。5000 余张封面若不压缩,HTML 体积将超过 500MB。采用的压缩策略如下:
def compress_image(img_data: bytes, max_kb: float = 2.5) -> str:
img = Image.open(io.BytesIO(img_data)).convert("RGB")
img = img.resize((160, int(160 * img.height / img.width)), Image.LANCZOS)
for quality in [60, 45, 30, 20, 15, 10, 5]:
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=quality, optimize=True)
if buf.tell() <= max_kb * 1024:
return base64.b64encode(buf.getvalue()).decode("ascii")
每张封面缩放至 160px 宽,逐步降低 JPEG 质量直至文件小于 2.5KB。最终 5000 余张封面的 base64 数据总计约 10MB。
去重
多种排序与手动爬取的数据不可避免地存在重复。以 BV 号作为唯一键进行去重:
all_videos_map = {}
for v in videos_from_all_sources:
all_videos_map[v["bvid"]] = v
断点续传
5000 余条视频的爬取耗时数小时,期间随时可能遭遇网络中断或风控触发。因此实现了断点续传机制:
# 每完成一页即保存进度
progress[f"last_page_{order}"] = page
save_progress(progress)
# 重启时从上次成功位置继续
start_page = progress.get(f"last_page_{order}", 0) + 1
前端:复刻B站空间页
最终生成的 HTML 并非简单的数据表格,而是一个高保真的 B站空间页复刻,具备以下功能:
- 响应式布局:桌面端 5 列网格,移动端 2 列
- 搜索与排序:实时搜索(300ms 防抖)、支持按时间/播放量/收藏量排序
- 分页系统:每页 30 条,带智能页码按钮
- 设备自适应交互:桌面端点击跳转 B站播放;移动端弹出 BV 号供复制(因本地
file://协议无法跳转 B站 App) - 视觉还原:毛玻璃效果、渐变横幅、圆角头像
移动端剪贴板问题
在移动端通过 file:// 协议打开 HTML 时,navigator.clipboard.writeText() 因安全策略限制而无法使用(该 API 要求 Secure Context,即 HTTPS 或 localhost)。进一步测试发现,document.execCommand("copy") 在部分移动端浏览器上虽返回 true,实际并未写入剪贴板,属于 false positive。
最终采用的分级方案:
function doCopy(bv) {
if (navigator.clipboard && navigator.clipboard.writeText) {
// 现代 API(HTTPS 环境下可用)
navigator.clipboard.writeText(bv)
.then(() => showModal(bv))
.catch(() => showModal(bv, "select"));
} else if (isMobile) {
// 移动端 file:// 环境 → 显示可选中的文本框
showModal(bv, "select");
} else {
// 桌面端降级方案
var ta = document.createElement("textarea");
ta.value = bv;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
showModal(bv);
}
}
其中 "select" 模式会弹出包含 BV 号的 <input> 元素,文本自动选中,用户长按即可调出系统原生的「拷贝」菜单。这是 file:// 协议下最可靠的方案。
最终成果
| 指标 | 数据 |
|---|---|
| 目标UP主 UID | 223236400 |
| 总视频数 | 5,348 |
| API 爬取(前100页 × 3种排序) | 约 4,200(去重后) |
| 浏览器手动爬取(101-179页) | 约 2,370 |
| 合并去重后 | 5,348(完整覆盖) |
| 封面图片 | 5,300+ 张(每张压缩至 2.5KB 以内) |
| 最终 HTML 大小 | 约 18 MB |
| 总耗时 | 约 3 小时(API)+ 约 4 分钟(浏览器) |
经验与反思
TLS 指纹检测是反爬的新前沿
传统反爬措施(User-Agent 检测、Cookie 验证、IP 限制)均工作在应用层。TLS 指纹检测将对抗推进到了传输层,使得大多数 HTTP 库在连接建立阶段即暴露身份。curl_cffi 是当前最有效的应对方案,但本质上这是一场持续的军备竞赛。
服务端分页限制极为有效
100 页硬限制是本项目中最难突破的防线。它不依赖客户端行为检测,而是在服务端直接截断——无论请求多么「合法」,pn > 100 即返回 412。这种策略简洁而有效,最终迫使我不得不切换到完全不同的技术路径。
纵深防御的实际效果
B站的四层防护相互独立、逐层递进。突破 TLS 指纹后还需面对 WBI 签名,通过签名验证后还有频率限制,绕过频率限制后又遭遇分页硬限制。每一层都抬高了攻击成本,四层叠加使得全自动化爬取无法实现——最终不得不引入人工操作环节。这是纵深防御理念的典型体现。
「注销」与「删除」的差距
从用户视角看,注销后视频已经「消失」。但从技术角度看,数据仍完整存在于服务器上,只是失去了访问入口。这一设计可能出于存储成本考虑(删除视频文件代价较高),但也意味着「注销账号」并不等同于「数据删除」。在 GDPR「被遗忘权」的语境下,这一现象值得深思。
技术栈
| 组件 | 技术 | 用途 |
|---|---|---|
| HTTP 客户端 | curl_cffi (Python) |
TLS 指纹伪装 |
| 图片处理 | Pillow |
封面压缩转 base64 |
| 签名 | hashlib (Python) / 纯 JS MD5 |
WBI 签名生成 |
| 浏览器自动化 | 原生 JavaScript(控制台) | 绕过分页限制 |
| 前端 | 单文件 HTML/CSS/JS | 离线归档浏览器 |
| 数据格式 | CSV + JSON | 中间数据交换 |
声明
本项目旨在为已注销UP主的公开视频建立个人存档。所有爬取的数据(视频标题、播放量、封面等)均为公开信息,可通过浏览器正常访问。爬取过程中严格控制了请求频率(2~3.5 秒间隔),以避免对 B站服务器造成负担。最终产物仅用于个人纪念用途,不涉及商业目的。
还没有评论,来留下第一条吧 ✨