整体链路

B 站这条链路分两段:

  1. https://api.bilibili.com/x/web-interface/nav 获取 wbi_img.img_urlwbi_img.sub_url
  2. 通过 WBI 算法生成 w_rid
  3. https://api.bilibili.com/x/web-interface/view?bvid=<BV> 获取 cid
  4. https://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 代表当前请求真正返回了哪些流。写工具时不要用前者误导用户。