目标不是下载器
我在 ShayuAgent 里做 B 站相关工具时,更关心的是把公开视频的元信息和可用流信息整理给 Agent,而不是把它做成一个无边界下载器。这里的原则很简单:尊重平台规则,只处理自己有权访问的内容,不绕过登录、付费或版权限制。
这篇笔记只记录工程上的链路设计,不提供规避风控的做法。
一条典型链路
视频工具通常先解析用户输入。输入可能是 BV 号、AV 号、普通链接或分享链接。第一步要把它们归一化成稳定参数,比如视频 ID、分 P、清晰度偏好和是否需要 DASH 信息。
接着请求播放信息接口。B 站部分接口需要 WBI 签名,工程上可以把签名逻辑独立成一个 Signer,把密钥获取独立成一个 KeyProvider,请求客户端只关心「参数进来,签名后的 URL 出去」。
为什么要拆 KeyProvider
签名密钥并不是业务逻辑的一部分。把它单独拆出去,有几个好处:可以缓存、可以过期刷新、可以在错误时触发一次重试,也更容易测试。
当接口返回业务错误码时,工具层不应该直接把原始响应丢给前端。更好的方式是转成稳定错误:比如 invalid_input、wbi_key_unavailable、playurl_denied、platform_changed。Agent 更容易理解这些错误,也能决定是否换一种策略。
Shark-Tools 里其他解析器也有类似模式:先把业务参数规范化,再加时间戳、nonce 和签名。下面是脱敏后的通用写法,不包含真实密钥和目标接口:
import hashlib
import hmac
import json
import secrets
import time
def build_signed_payload(params: dict, secret: str) -> dict:
payload = {
**params,
"timestamp": int(time.time() * 1000),
"nonce": secrets.token_hex(16),
}
canonical = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
signature = hmac.new(
secret.encode("utf-8"),
canonical.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return {"payload": payload, "signature": signature}
WBI、HMAC、MD5 参数签名这些细节各不相同,但工程上都应该抽成一个小而清晰的 Signer。上层只需要知道“给我一组已签名参数”,不要到处散落字符串拼接。
输出要面向上层
视频解析工具最后返回的内容,最好不是某个平台接口的原始 JSON,而是上层能直接使用的结构:标题、作者、封面、时长、分辨率、音视频轨道、字幕或错误原因。
这样前端可以直接渲染,Agent 也可以继续总结、转写或生成选题。工具越靠近底层,输出越应该稳定;否则平台一改字段,上层所有逻辑都会跟着抖。
我喜欢在边界层就做一次规范化:
def normalize_streams(raw: dict) -> dict:
streams = []
for item in raw.get("dash", {}).get("video", []):
streams.append({
"quality": item.get("id"),
"codec": item.get("codecs"),
"width": item.get("width"),
"height": item.get("height"),
"url": item.get("baseUrl") or item.get("base_url"),
})
return {
"title": raw.get("title", ""),
"cover": raw.get("pic", ""),
"duration": raw.get("duration", 0),
"streams": [item for item in streams if item["url"]],
}
规范化不是为了让代码显得“干净”,而是为了隔离变化。平台返回字段属于外部世界,自己的 streams 结构才是内部契约。
维护策略
这类解析链路必须接受一个事实:平台会变化。我的做法是让代码里保留清楚的错误边界和少量注释,把签名、请求、解析、规范化输出拆开。坏的时候,先看是哪一层坏了,而不是一上来整段重写。
逆向不是一次性灵感,更多是长期维护。写得克制一点,后面自己会感谢自己。