整体链路
B 站这条链路分两段:
https://api.bilibili.com/x/web-interface/nav获取wbi_img.img_url和wbi_img.sub_url- 通过 WBI 算法生成
w_rid https://api.bilibili.com/x/web-interface/view?bvid=<BV>获取cidhttps://api.bilibili.com/x/player/wbi/playurl请求 DASH / durl 播放信息
游客也能拿到一部分清晰度;如果要更高清晰度,需要自己的 SESSDATA。我不会把自己的登录态写进文章,代码里用环境变量 BILI_SESSDATA 读取。
WBI 签名
WBI 的核心是把 imgKey + subKey 按固定表重排得到 32 位 mixinKey,参数排序后拼 query,再对 query + mixinKey 做 MD5。
import hashlib
import time
from urllib.parse import quote
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,
]
FILTER_CHARS = "!'()*"
def get_mixin_key(img_key: str, sub_key: str) -> str:
raw = img_key + sub_key
return "".join(raw[index] for index in MIXIN_KEY_ENC_TAB)[:32]
def filter_value(value: str) -> str:
return "".join(char for char in str(value) if char not in FILTER_CHARS)
def enc_wbi(params: dict, img_key: str, sub_key: str) -> str:
signed = {**params, "wts": int(time.time())}
query = "&".join(
f"{quote(str(key), safe='')}={quote(filter_value(value), safe='')}"
for key, value in sorted(signed.items())
)
w_rid = hashlib.md5((query + get_mixin_key(img_key, sub_key)).encode()).hexdigest()
return f"{query}&w_rid={w_rid}"
完整 Python 脚本
下面这份脚本能从 BV 号解析 cid,再走 WBI playurl 拿 DASH 流。需要登录态时设置 BILI_SESSDATA。
import hashlib
import json
import os
import time
from urllib.parse import quote
import requests
NAV_URL = "https://api.bilibili.com/x/web-interface/nav"
VIEW_URL = "https://api.bilibili.com/x/web-interface/view"
PLAYURL_URL = "https://api.bilibili.com/x/player/wbi/playurl"
UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
)
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,
]
FILTER_CHARS = "!'()*"
def headers() -> dict:
result = {
"User-Agent": UA,
"Referer": "https://www.bilibili.com/",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
}
sessdata = os.getenv("BILI_SESSDATA", "").strip()
if sessdata:
result["Cookie"] = sessdata if "SESSDATA=" in sessdata else f"SESSDATA={sessdata}"
return result
def key_from_url(url: str) -> str:
file_name = url.rsplit("/", 1)[-1]
return file_name.split(".", 1)[0]
def fetch_wbi_keys() -> tuple[str, str]:
response = requests.get(NAV_URL, headers=headers(), timeout=10)
response.raise_for_status()
data = response.json()["data"]["wbi_img"]
return key_from_url(data["img_url"]), key_from_url(data["sub_url"])
def get_mixin_key(img_key: str, sub_key: str) -> str:
raw = img_key + sub_key
return "".join(raw[index] for index in MIXIN_KEY_ENC_TAB)[:32]
def filter_value(value: str) -> str:
return "".join(char for char in str(value) if char not in FILTER_CHARS)
def enc_wbi(params: dict, img_key: str, sub_key: str) -> str:
signed = {**params, "wts": int(time.time())}
query = "&".join(
f"{quote(str(key), safe='')}={quote(filter_value(value), safe='')}"
for key, value in sorted(signed.items())
)
w_rid = hashlib.md5((query + get_mixin_key(img_key, sub_key)).encode()).hexdigest()
return f"{query}&w_rid={w_rid}"
def fetch_view(bvid: str) -> dict:
response = requests.get(VIEW_URL, params={"bvid": bvid}, headers=headers(), timeout=10)
response.raise_for_status()
root = response.json()
if root.get("code") != 0:
raise RuntimeError(f"view 接口错误:{root.get('code')} {root.get('message')}")
return root["data"]
def fetch_playurl(bvid: str, cid: int, qn: int = 127) -> dict:
img_key, sub_key = fetch_wbi_keys()
params = {
"bvid": bvid,
"cid": cid,
"qn": qn,
"fnval": 4048,
"fnver": 0,
"fourk": 1,
}
signed_query = enc_wbi(params, img_key, sub_key)
response = requests.get(f"{PLAYURL_URL}?{signed_query}", headers=headers(), timeout=12)
response.raise_for_status()
root = response.json()
if root.get("code") != 0:
raise RuntimeError(f"playurl 错误:{root.get('code')} {root.get('message')}")
return root.get("data") or root.get("result")
def normalize_dash(data: dict) -> dict:
quality_map = {
item.get("quality"): item.get("new_description") or item.get("display_desc")
for item in data.get("support_formats", [])
}
dash = data.get("dash") or {}
videos = []
for item in dash.get("video", []):
videos.append({
"id": item.get("id"),
"quality": quality_map.get(item.get("id")),
"codecs": item.get("codecs"),
"width": item.get("width"),
"height": item.get("height"),
"bandwidth": item.get("bandwidth"),
"base_url": item.get("baseUrl") or item.get("base_url"),
"backup_url": item.get("backupUrl") or item.get("backup_url") or [],
})
audios = [
{
"id": item.get("id"),
"codecs": item.get("codecs"),
"bandwidth": item.get("bandwidth"),
"base_url": item.get("baseUrl") or item.get("base_url"),
}
for item in dash.get("audio", [])
]
return {"video": videos, "audio": audios, "support_formats": data.get("support_formats", [])}
def parse_bilibili(bvid: str) -> dict:
view = fetch_view(bvid)
first_page = view["pages"][0]
play_data = fetch_playurl(bvid, first_page["cid"])
return {
"bvid": bvid,
"title": view.get("title"),
"owner": view.get("owner", {}).get("name"),
"cid": first_page["cid"],
"duration": view.get("duration"),
"streams": normalize_dash(play_data),
}
if __name__ == "__main__":
result = parse_bilibili("BV1xx411c7mD")
print(json.dumps(result, ensure_ascii=False, indent=2))
注意事项
playurl 的 HTTP 状态经常是 200,但业务 code 可能是负数,所以错误判断要看 JSON 里的 code。我在 Java 版本里对 -352 做了一次刷新 WBI key 后重试,对 -403 单独提示“需要登录或大会员”。
游客能拿到的清晰度有限,support_formats 代表视频支持哪些清晰度,dash.video 代表当前请求真正返回了哪些流。写工具时不要用前者误导用户。