wbi 的设计哲学:密钥动态化
B站 2023 年起大面积启用 wbi 签名,取代旧的 appkey+appsign 体系。它的设计思路和别家相反:算法完全公开(就是 md5),但密钥不在客户端写死,而是藏在接口返回的图片 URL 里,定期轮换。防护思路从"算法保密"转向"密钥时效",对逆向者反而友好了。
完整流程拆解
第一步:拿密钥原料。请求 https://api.bilibili.com/x/web-interface/nav(无需登录),返回:
{
"data": {
"wbi_img": {
"img_url": "https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png",
"sub_url": "https://i0.hdslb.com/bfs/wbi/4932caff0ff746eab6f01bf08b70ac45.png"
}
}
}取两个 URL 的文件名去扩展名:img_key = 7cd084941338484aae1ad9425b84077c,sub_key = 4932caff0ff746eab6f01bf08b70ac45。
第二步:混淆重排出真密钥。把 img_key + sub_key 拼接(64 字符),按一张固定的置换表重排,取前 32 位:
MIXIN_TABLE = [
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(img_key, sub_key):
raw = img_key + sub_key
return ''.join(raw[i] for i in MIXIN_TABLE)[:32]这张表写死在 B站前端 JS 里(搜 [46,47,18 能直接命中),自 wbi 上线以来没变过。
第三步:签名。
import hashlib, time, urllib.parse
def wbi_sign(params: dict, mixin_key: str) -> dict:
params = dict(params)
params['wts'] = int(time.time())
# 1. 按 key 字典序排序
params = dict(sorted(params.items()))
# 2. 参数值过滤 !'()* 五个字符(B站前端的特殊处理)
filtered = {k: ''.join(c for c in str(v) if c not in "!'()*") for k, v in params.items()}
# 3. urlencode 后拼密钥算 md5
query = urllib.parse.urlencode(filtered)
params['w_rid'] = hashlib.md5((query + mixin_key).encode()).hexdigest()
return params精髓:五个实测出来的坑
- 过滤字符这步不能省。B站前端在签名前会把参数值里的
!'()*删掉,你不删,遇到含这些字符的参数(比如搜索关键词带括号)签名就对不上。这个细节藏在前端代码里,文档没有。 - urlencode 的编码方式要对。空格必须编码成
%20而不是+(Python 的urlencode默认用quote_plus,要换quote_via=urllib.parse.quote),中文按 UTF-8 百分号编码。编码方式不一致,含中文参数时 md5 必错。 - 密钥有有效期,别每次都拉。img_key/sub_key 会轮换(实测周期以天计),但每次都请求 nav 接口会留下扎眼的访问模式。正确做法:缓存 mixin_key,签名失败(返回 -352 风控码)时刷新重试一次。
- wts 与服务器时钟偏差容忍约 ±1 天,但建议本机校时,偏差大了返回签名错误。
- 部分接口还要 dm 系列参数。搜索等敏感接口除了 wbi 还校验
dm_img_list、dm_img_str、dm_cover_img_str这几个——它们是 webgl/canvas 指纹的 base64 编码。可以传空值dm_img_list=[],但传了就要传对,乱填比不填死得更快。
完整可用的封装
class WbiSigner:
def __init__(self, session):
self.session = session
self.mixin_key = None
self.key_time = 0
def refresh_key(self):
nav = self.session.get('https://api.bilibili.com/x/web-interface/nav').json()
wbi = nav['data']['wbi_img']
img_key = wbi['img_url'].rsplit('/', 1)[1].split('.')[0]
sub_key = wbi['sub_url'].rsplit('/', 1)[1].split('.')[0]
self.mixin_key = get_mixin_key(img_key, sub_key)
self.key_time = time.time()
def sign(self, params):
if not self.mixin_key or time.time() - self.key_time > 3600 * 12:
self.refresh_key()
return wbi_sign(params, self.mixin_key)总结
- wbi = 动态密钥(图片文件名)+ 固定置换表 + md5,算法零障碍
- 三个易错点:过滤
!'()*、空格编码%20、key 排序 - 密钥缓存 12 小时,签名失败再刷新
- 敏感接口的 dm 指纹参数是第二道门,传空可过基础校验