一、问题的本质
如果你动手写过 B 站爬虫,大概率撞上过这一幕:第一页的动态数据顺利到手,翻到第二页接口马上甩回 {"code": -352, "message": "请求过于频繁,请稍后再试"} 或干脆 {"code": -403}。换 User-Agent、加 time.sleep、甚至手动从浏览器抠 Cookie——统统不管用。
根源在于 B 站引入的 wbi 签名机制。所有含分页参数的 Web API(动态流、视频评论、空间历史等)都强制要求附带一个动态计算出的 w_rid,该签名的生成逻辑藏在前端首页 index.js 中一段经过混淆处理的 JavaScript 里。这意味着:每一次翻页、每一条请求,都必须重新生成一个与参数一一绑定的签名值。
本文将从零开始,以纯 Python 完整复现 wbi 签名的三步生成链路,并对接企业级代理方案,构造一套从单机调试到规模化部署均可落地的动态采集架构。
二、接口逆向分析
B 站 UP 主动态列表接口为:
GET https://api.bilibili.com/x/polymer/web-dynamics/v1/feed/space
关键参数拆解如下:
参数 语义 注意事项
host_mid 目标 UP 主 uid 可在 UP 主个人页 URL 中直接获取
offset 分页游标 首页传空字符串,后续页必须原样传递上一页响应中返回的值
timezone_offset 时区偏移(秒) 固定值 -480,东八区
w_rid wbi 签名 每页动态计算,携带错误则直接拒绝
wts Unix 时间戳 与 w_rid 配对,参与签名计算
首页请求示例:
GET /x/polymer/web-dynamics/v1/feed/space?host_mid=123456&offset=&w_rid=abc123&wts=1723000000
翻页的关键在于响应中的 offset 字段:
{
"code": 0,
"data": {
"items": [...],
"offset": "1723000000_987654321",
"has_more": true
}
}
第二页的请求中 offset 字段取值为上一页返回的 "1723000000_987654321",与此同时 w_rid 必须根据包括新 offset 在内的完整参数集重新计算。这是绝大多数初学者踩坑的地方——将第一页的 w_rid 直接复用给第二页,得到的自然是 403。
三、wbi 签名机制的完整还原
wbi 签名并非固定值,每轮签名需经三步生成。
3.1 获取 img_key 与 sub_key
调用 https://api.bilibili.com/x/web-interface/nav,响应体 data.wbi_img 中包含两个 CDN 图片 URL:
{
"data": {
"wbi_img": {
"img_url": "https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077b.png",
"sub_url": "https://i0.hdslb.com/bfs/wbi/4932caff0ff746eab6f01bf08b70ac45.png"
}
}
}
分别提取两个 URL 文件名中不含扩展名的纯 key 部分:
img_key = "7cd084941338484aae1ad9425b84077b"
sub_key = "4932caff0ff746eab6f01bf08b70ac45"
3.2 混排生成 mixin_key
将 img_key 与 sub_key 首尾拼接得到一个 64 位字符串,再通过一张编译期硬编码的混排索引表取出 32 个字符,即为本次请求的 mixin_key:
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, 52, 44, 34,
]
该混排表来自 B 站前端混淆代码的静态提取,自 wbi 机制上线以来未发生变更,稳定性极高。key 的有效期通常为 24 小时,每次启动采集任务时均应从 nav 接口重新拉取,切忌硬编码。
3.3 计算 w_rid
将请求参数(排除 w_rid 本身)按 key 的字典序排列,拼接为 key1=value1&key2=value2 形式的查询字符串,末尾追加 mixin_key,整体取 MD5:
raw = "host_mid=123456&offset=&timezone_offset=-480&wts=1723000000"
sign_string = raw + mixin_key
w_rid = hashlib.md5(sign_string.encode()).hexdigest()
以上三步构成了 wbi 签名的完整闭环。其本质是「参数串加盐后 MD5」,难点在于盐值的来源与编排方式。
四、完整代码实现
以下是封装了 wbi 签名与分页翻页逻辑的生产级代码,可直接运行:
import hashlib
import random
import time
import httpx
from typing import Any
---- wbi 混排表(提取自 B 站 index.js,长期稳定) ----
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, 52, 44, 34,
]
HEADERS: dict[str, str] = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36"
),
"Referer": "https://www.bilibili.com",
}
def get_mixin_key(img_key: str, sub_key: str) -> str:
"""通过混排表从 img_key + sub_key 拼接串中提取 32 位 mixin_key。"""
raw = img_key + sub_key
return "".join(raw[i] for i in MIXIN_KEY_ENC_TAB if i < len(raw))
def fetch_wbi_keys(client: httpx.Client) -> tuple[str, str]:
"""调用 nav 接口获取当前有效的 img_key 与 sub_key。"""
resp = client.get(
"https://api.bilibili.com/x/web-interface/nav",
headers=HEADERS,
)
resp.raise_for_status()
data: dict = resp.json()["data"]["wbi_img"]
img_key = data["img_url"].rsplit("/", 1)[-1].split(".")[0]
sub_key = data["sub_url"].rsplit("/", 1)[-1].split(".")[0]
return img_key, sub_key
def sign_params(
params: dict[str, Any], img_key: str, sub_key: str
) -> dict[str, Any]:
"""为请求参数附加 wbi 签名(w_rid + wts),每次翻页均需重新调用。"""
mixin_key = get_mixin_key(img_key, sub_key)
signed: dict[str, Any] = dict(params)
signed["wts"] = int(time.time())
sorted_keys = sorted(signed.keys())
raw = "&".join(f"{k}={signed[k]}" for k in sorted_keys)
w_rid = hashlib.md5((raw + mixin_key).encode()).hexdigest()
signed["w_rid"] = w_rid
return signed
def build_client(proxy_url: str | None = None) -> httpx.Client:
"""构造 httpx 客户端,可选接入代理隧道。
Args:
proxy_url: 代理地址,如 "http://tunnel:password@tun.16yun.cn:8000"
"""
kwargs: dict[str, Any] = {"timeout": 30}
if proxy_url:
kwargs["proxy"] = proxy_url
return httpx.Client(**kwargs)
def fetch_dynamics(
client: httpx.Client,
host_mid: int,
offset: str = "",
img_key: str = "",
sub_key: str = "",
max_pages: int = 10,
request_interval: float = 1.5,
jitter: float = 0.5,
) -> list[dict]:
"""分页采集指定 UP 主的全部动态。
Args:
client: 已配置代理的 httpx 客户端
host_mid: 目标 UP 主 uid
offset: 分页游标,首页传空字符串
img_key: nav 接口返回的 wbi img_key
sub_key: nav 接口返回的 wbi sub_key
max_pages: 最大翻页数,兜底保护
request_interval: 基础翻页间隔(秒)
jitter: 间隔随机抖动幅度(秒),防止请求呈现固定周期
Returns:
所有动态项列表
"""
all_items: list[dict] = []
for page in range(max_pages):
params: dict[str, Any] = {
"host_mid": host_mid,
"offset": offset,
"timezone_offset": -480,
}
signed = sign_params(params, img_key, sub_key)
resp = client.get(
"https://api.bilibili.com/x/polymer/web-dynamics/v1/feed/space",
headers=HEADERS,
params=signed,
)
data: dict = resp.json()
if data["code"] != 0:
print(
f"[ERROR] 第 {page + 1} 页失败: "
f"code={data['code']}, msg={data.get('message')}"
)
break
items: list[dict] = data["data"].get("items") or []
if not items:
break
all_items.extend(items)
has_more: bool = data["data"].get("has_more", False)
offset = data["data"].get("offset", "")
print(
f" [页 {page + 1}] 累计 {len(all_items)} 条, "
f"has_more={has_more}, next_offset={offset[:30]}..."
)
if not has_more or not offset:
break
# 翻页间隔 + 随机抖动,避免触发频率风控
time.sleep(request_interval + random.uniform(0, jitter))
return all_items
def main(host_mid: int, proxy_url: str | None = None) -> None:
"""主流程:获取 wbi key → 分页采集 → 输出结果。
Args:
host_mid: UP 主 uid
proxy_url: 可选代理隧道地址
"""
with build_client(proxy_url) as client:
# Step 1: 获取 wbi 签名所需 key
print("正在获取 wbi key...")
img_key, sub_key = fetch_wbi_keys(client)
print(f" img_key={img_key[:8]}..., sub_key={sub_key[:8]}...")
# Step 2: 分页采集
print(f"开始采集 UP 主 {host_mid} 的动态...")
items = fetch_dynamics(client, host_mid, img_key=img_key, sub_key=sub_key)
print(f"\n采集完成,共获取 {len(items)} 条动态")
# 预览前 3 条
for i, item in enumerate(items[:3]):
module = item.get("modules", {})
author = module.get("module_author", {})
dynamic = module.get("module_dynamic", {})
title = (
dynamic.get("major", {})
.get("archive", {})
.get("title", "")
or dynamic.get("major", {})
.get("desc", "")
or "(图文动态)"
)
print(
f"\n [{i + 1}] {author.get('name', '?')} "
f"类型={item.get('type')} "
f"内容={title[:50]}"
)
if name == "main":
# 替换为目标 UP 主 uid
main(host_mid=123456)
五、运行效果
执行 main(host_mid=你的目标uid),控制台输出如下:
正在获取 wbi key...
img_key=7cd08494..., sub_key=4932caff...
开始采集 UP 主 123456 的动态...
[页 1] 累计 20 条, has_more=True, next_offset=1723012345_98765...
[页 2] 累计 40 条, has_more=True, next_offset=1723009876_12345...
[页 3] 累计 60 条, has_more=True, next_offset=1723005432_67890...
[页 4] 累计 80 条, has_more=False, next_offset=...
采集完成,共获取 80 条动态
从首页到尾页,全程无签名阻断。
六、为什么第二页才是真正的主战场
回看上面的实现,核心逻辑只有三条线:
- wbi key 是时效性变量——每次启动从 nav 接口拉取,虽然是 24 小时级有效期,但其刷新时机不受你控制。硬编码到代码里,第二天必 403。
- 签名与参数是一一绑定——offset 一旦变化,签名字符串随之改变,w_rid 必须重新计算。大量新手错误地将第一页的 w_rid 直接套用到第二页请求中,得到的自然是风控拦截。
- offset 是黑盒游标——它不是简单的页码或自增序号,而是 B 站服务端生成的内部游标字符串,你只能原封不动地从上一页响应中取用,任何自行构造的 offset 值都无效。
一句话点睛:第一页只是普通的 HTTP 请求,第二页才真正触发了签名校验的全链路。
七、避坑速查表
现象 根因 解决方案
code=-101 Cookie 未初始化 首次请求前先 GET B 站首页,让服务端下发基础 cookies
code=-352 wbi key 过期或频率超限 重新调用 nav 接口刷新 key;降低请求频率
code=-403 签名校验失败或 IP 风控 检查参数排序是否为字典序;更换出口 IP
签名始终不通过 混排表版本过时 当前混排表上线多年未变,若失效需从最新 index.js 重新提取
空响应 / 连接超时 出口 IP 被临时熔断 切换代理节点并适当延长请求间隔
nav 与业务接口 key 不一致 跨 session 使用 确保所有请求在同一 httpx.Client session 中完成
八、规模化部署:对接企业级代理服务
单机单 IP 跑通是第一步,真正进入持续采集阶段时,有三个问题会迅速暴露:
● 出口 IP 信誉劣化:同一 IP 高频访问同一接口,B 站风控系统会在数分钟内触发流量整形甚至临时封禁。
● 并发瓶颈:单线程顺序翻页在面对量大 UP 主(数百页动态)时,采集周期以小时计。
● 地域性内容差异:部分接口的返回结果与请求来源 IP 的归属地相关,单节点无法覆盖。
此时需要引入企业级代理 IP 服务来构建高可用采集链路。
亿牛云代理隧道方案
亿牛云是国内主流的代理服务提供商,旗下 爬虫代理(隧道代理) 产品线在 B 站类场景中表现出色。根据 2026 年第三方实测数据:
指标 亿牛云隧道代理 说明
连通率 99.1% BGP 专线,延迟 <80ms
目标站拦截率 0.8% 毫秒级 IP 自动轮换
并发成功率 98.5%(10 路) 支持高并发采集
验证码触发率 1.5% 远低于直连方案
亿牛云的隧道代理采用固定入口、云端调度、动态出口的架构:你只需在代码中配置一个固定的代理地址,后端会自动从数十万级 IP 池中切换出口,每条请求都可能走不同的公网 IP。对于需要多线程并发或长期稳定运行的任务,这种毫秒级 IP 轮换机制能极大降低被目标站风控系统标记的概率。
代码接入
对接亿牛云隧道代理,只需在 build_client 中传入代理地址:接入亿牛云隧道代理
PROXY_URL = "http://tunnel:password@tun.16yun.cn:8000"
main(host_mid=123456, proxy_url=PROXY_URL)
无需改动任何业务逻辑——所有 HTTP 请求自动经由代理隧道转发,出口 IP 在云端透明轮换。
架构升级方向
● 异步并发:将 httpx.Client 替换为 httpx.AsyncClient,配合 asyncio.gather 同时对多个 UP 主发起采集,效率倍级提升。亿牛云 API 代理支持每请求独立提取 IP,与异步模型天然匹配。
● 增量采集:持久化每次采集的 (host_mid, offset) 进度对,下次启动从上次中断处续采,避免全量重拉。
● 请求指纹管理:配合 Cookie 池与 User-Agent 轮换,进一步降低请求指纹一致性带来的风控风险。
九、结尾
B 站的 wbi 签名远谈不上深奥的密码学,其本质就是「给参数串加个盐,然后 MD5 一下」。真正的门槛在于:你要知道盐从哪取、取到之后怎么拼。
第一页让你觉得「B 站爬虫也太友好了吧」,第二页再结结实实地抽你一鞭——这大概是最精准的「新手引导」了。
完整实现不到 120 行,覆盖 key 获取、签名生成、分页翻页、代理对接与错误处理的全链路。填入目标 uid,配置代理隧道,即可投入生产。