网络安全

对B站已注销的视频博主信息本地化

从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站服务器造成负担。最终产物仅用于个人纪念用途,不涉及商业目的。


参考资料

评论 0

?
0/2000

还没有评论,来留下第一条吧 ✨