提示

返回博客列表

B 站视频下载深度解析——从 AV/BV 号互转到 DASH 音视频分离的完整链路

B 站视频下载深度解析——从 AV/BV 号互转到 DASH 音视频分离的完整链路

B 站是国内视频下载需求最大的平台,没有之一。但你有没有想过:浏览器里一个 bilibili.com/video/BV1xx411c7mD 的链接,背后经历了多少次转换才变成你硬盘上的 .mp4 文件?AV 号和 BV 号到底什么关系?WBI 签名是什么、怎么算?为什么有些视频下载下来只有画面没声音?弹幕能不能一起下载?这篇文章把 B 站下载的完整链路从头拆到尾,包括签名算法的还原过程、DASH 音视频分离的原理、以及互动视频和番剧的特殊处理方式。

TL;DR:B 站下载链路:BV 号 → AV 号(异或查表)→ cid(视频分P标识)→ 请求 playurl API(需 WBI 签名 + Referer + Cookie)→ 获取 DASH 音视频流地址 → 分别下载 → FFmpeg 合并。WBI 签名的核心是 mix_key + w_rid 参数,需要从页面提取 img_keysub_key,拼接后用 MD5 计算。弹幕通过独立的 protobuf 接口获取,是 XML 格式。

目录

一、B 站视频的 ID 体系:AV、BV、cid、epid 都是什么

B 站的视频标识体系比较复杂,因为它是逐步演进过来的:

BV1xx411c7mD          ← 视频的"身份证号"(2020 年启用,替代 AV 号)
   ↓ 转换
AV170001              ← 老式纯数字 ID(2020 年以前使用)
   ↓ 查询 API
cid: 123456           ← 分P 标识(一个视频可能有多个 P)
   ↓ 请求 playurl
DASH 视频流 + 音频流   ← 实际的媒体文件地址

此外还有: - epid:番剧/影视的剧集 ID,和普通视频的 cid 是两套体系 - bvid:就是 BV 号的官方名称 - season_id:番剧的"季"标识

二、AV 号和 BV 号的互转算法

2.1 为什么需要互转

BV 号是面向用户的(出现在 URL 中),AV 号是面向 API 的(很多老接口只用 AV 号)。所以下载器的第一步就是把 BV 号转成 AV 号。

2.2 转换原理

这个算法是 B 站社区逆向出来的,核心是一张 58 字符的映射表和异或运算:

# BV 号 ↔ AV 号的互转

# B 站使用的 58 进制字符表(去掉了易混淆的 0/O/I/l 等)
TABLE = 'fZodR9XQDSUm21yCkr6zBqiveYah8bt4xsWpHnJE7jL5VG3guMTKNPAwcF'

# 固定异或值
XOR = 177451812
# BV 号固定前缀对应的偏移
ADD = 8728348608

# 字符在 TABLE 中的位置 → AV 号中该位置对应的权值
TR = {TABLE[i]: i for i in range(58)}
# BV 号中哪些位置参与编码(从 0 开始)
S = [11, 10, 3, 8, 4, 6]


def bv_to_av(bvid):
    """BV 号 → AV 号"""
    r = 0
    for i in range(6):
        r += TR[bvid[S[i]]] * (58 ** i)
    av = (r - ADD) ^ XOR
    return f'av{av}'


def av_to_bv(aid):
    """AV 号 → BV 号"""
    if isinstance(aid, str):
        aid = int(aid.replace('av', ''))
    r = (aid ^ XOR) + ADD

    bv = ['B', 'V', '1', '', '', '4', '', '1', '', '7', '', '']
    for i in range(6):
        bv[S[i]] = TABLE[r // (58 ** i) % 58]

    return ''.join(bv)


# 测试
print(bv_to_av('BV1xx411c7mD'))  # → av170001
print(av_to_bv(170001))           # → BV1xx411c7mD

2.3 这个算法的来龙去脉

这个转换不是"破解"——它只是一个编码变换,把数字 ID 映射成字符串 ID。本质上和把十进制转十六进制一样,只是 B 站用了自定义的 58 进制表。

B 站为什么搞 BV 号? 因为 AV 号是自增数字,可以通过遍历发现所有视频。BV 号看似随机,外部分析不出上传顺序和总量。

三、WBI 签名:B 站反爬的核心防线

3.1 WBI 签名是什么

从 2023 年开始,B 站的视频播放接口(playurl)要求请求携带 w_ridwts 参数。这就是 WBI(Web Interface)签名——一种防爬机制,签名错误直接返回 -352 错误码。

正常请求的 URL:
https://api.bilibili.com/x/player/wbi/v2?
  bvid=BV1xx411c7mD&
  cid=123456&
  w_rid=abc123def456&    ← WBI 签名
  wts=1735689600          ← 时间戳

3.2 签名计算过程

import hashlib
import time
from urllib.parse import urlencode


def get_wbi_keys(session):
    """从 B 站首页获取 WBI 签名所需的 img_key 和 sub_key"""
    # 访问首页,从 HTML 中提取
    resp = session.get('https://www.bilibili.com/',
                       headers={'User-Agent': 'Mozilla/5.0 ...'})

    # 从页面 JS 或内嵌数据中提取
    # 方式 1:从 window.__INITIAL_STATE__ 中找
    import re
    import json

    # B 站的 img_key 和 sub_key 存在 nav 接口的返回中
    nav_resp = session.get(
        'https://api.bilibili.com/x/web-interface/nav'
    )
    nav_data = nav_resp.json()['data']
    img_url = nav_data['wbi_img']['img_url']
    sub_url = nav_data['wbi_img']['sub_url']

    # 从 URL 中提取文件名(去掉路径和扩展名)
    img_key = img_url.split('/')[-1].split('.')[0]
    sub_key = sub_url.split('/')[-1].split('.')[0]

    return img_key, sub_key


def calculate_mix_key(img_key, sub_key):
    """计算 mix_key——img_key 和 sub_key 的乱序拼接"""
    # 核心规则:按固定顺序交错拼接两个字符串
    # 取 img_key 的前半部分和 sub_key 的前半部分,按字符交替组合
    mix = ''
    # 实际算法:取两个 key 的特定位置字符拼接
    # 这里是简化版,真实算法需要参照 B 站前端 JS 的具体实现
    combined = (img_key + sub_key)
    # 取第 2,4,6... 位置的字符组成 mix_key
    # 具体位置序列是固定的
    order = [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30,
             1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27, 29, 31]
    mix = ''.join(combined[i] for i in order if i < len(combined))
    return mix[:32]


def sign_params(params, mix_key):
    """对请求参数进行 WBI 签名"""
    # 1. 添加时间戳
    params['wts'] = int(time.time())

    # 2. 按 key 排序
    sorted_params = sorted(params.items())

    # 3. 拼接为 query string
    query = urlencode(sorted_params)

    # 4. 拼接 mix_key
    sign_str = query + mix_key

    # 5. MD5 哈希
    w_rid = hashlib.md5(sign_str.encode()).hexdigest()

    params['w_rid'] = w_rid
    return params


# 完整调用
session = requests.Session()
session.headers.update({
    'User-Agent': 'Mozilla/5.0 ...',
    'Referer': 'https://www.bilibili.com/',
})

img_key, sub_key = get_wbi_keys(session)
mix_key = calculate_mix_key(img_key, sub_key)

params = {
    'bvid': 'BV1xx411c7mD',
    'cid': '123456',
    'fnval': '4048',  # 请求 DASH 格式
    'qn': '112',       # 请求最高画质
}
signed = sign_params(params, mix_key)

resp = session.get('https://api.bilibili.com/x/player/wbi/v2',
                   params=signed)

3.3 签名失败的表现

{
  "code": -352,
  "message": "请求错误"
}

-352 是 WBI 签名失败的标志。如果遇到,检查: - img_keysub_key 是否过期(通常 10-30 分钟有效,每次请求 nav 接口获取最新的) - mix_key 的计算逻辑是否和 B 站前端一致(B 站偶尔调整拼接顺序) - 时间戳是否偏差太大

四、获取视频流地址:playurl API 详解

4.1 关键参数

params = {
    'bvid': 'BV1xx411c7mD',    # BV 号
    'cid': '123456',            # 分P 的 cid
    'fnval': '4048',            # 请求格式:1=FLV, 16=DASH, 4048=DASH+HDR
    'fnver': '0',
    'fourk': '1',               # 允许 4K
    'qn': '112',                # 画质:112=1080P高码率, 80=1080P, 64=720P
}

fnval 的值是一个位掩码:

fnval 返回格式
1 FLV(老格式,视频音频在一起,低画质)
16 DASH(视频音频分离,高清)
64 DASH + 杜比视界
128 DASH + 8K
256 DASH + AV1 编码
4048 DASH + HDR + 杜比 + 8K(推荐,全部包含)

4.2 返回结构

{
  "code": 0,
  "data": {
    "dash": {
      "video": [
        {
          "id": 112,
          "base_url": "https://upos-sz-mirrorcos.bilivideo.com/...",
          "backup_url": ["https://upos-sz-mirrorali.bilivideo.com/..."],
          "bandwidth": 5800000,
          "mime_type": "video/mp4; codecs=\"avc1.640028\"",
          "codecs": "avc1.640028",
          "width": 1920,
          "height": 1080,
          "frame_rate": "30.000",
          "codecid": 7
        }
      ],
      "audio": [
        {
          "id": 30280,
          "base_url": "https://upos-sz-mirrorcos.bilivideo.com/...",
          "bandwidth": 320000,
          "mime_type": "audio/mp4; codecs=\"mp4a.40.2\"",
          "codecs": "mp4a.40.2",
          "codecid": 0
        }
      ]
    },
    "timelength": 120000,
    "accept_quality": [112, 80, 64, 32, 16],
    "quality": 112
  }
}

4.3 画质等级(qn 值)

qn 含义
127 8K 超高清
125 HDR 真彩
120 4K 超清
116 1080P60 帧
112 1080P 高码率
80 1080P 普通
74 720P60 帧
64 720P
32 480P
16 360P

注意:不是所有视频都有全部画质等级。B 站根据视频的上传参数和观看量决定提供哪些档位。高码率(112)和 4K(120)通常只有热门视频才有。

五、DASH 音视频分离与合并

5.1 为什么 B 站要把音视频分开

好处 1:自适应码率——网速差时只降视频画质,音频保持清晰
好处 2:存储优化——同一个音频可以被多个视频画质复用
好处 3:编码灵活——视频用 H.264/HEVC/AV1,音频用 AAC,各取最优

5.2 下载和合并

import subprocess
import os
import requests

def download_bilibili_video(bvid, session, output_path):
    """完整的 B 站视频下载:获取地址 → 下载视频和音频 → 合并"""
    # 1. 获取 cid
    cid = get_cid(bvid, session)

    # 2. 获取 playurl
    video_url, audio_url = get_playurl(bvid, cid, session)

    # 3. 下载视频流和音频流
    video_file = output_path + '.video.mp4'
    audio_file = output_path + '.audio.mp4'

    download_file(session, video_url, video_file)
    download_file(session, audio_url, audio_file)

    # 4. FFmpeg 合并
    subprocess.run([
        'ffmpeg',
        '-i', video_file,
        '-i', audio_file,
        '-c', 'copy',  # 不重新编码,直接复制流
        output_path,
        '-y'
    ], check=True)

    # 5. 清理临时文件
    os.remove(video_file)
    os.remove(audio_file)

    return output_path


def download_file(session, url, output_path, headers=None):
    """下载单个文件"""
    resp = session.get(url, headers=headers or {}, stream=True)
    resp.raise_for_status()

    total = int(resp.headers.get('content-length', 0))
    downloaded = 0

    with open(output_path, 'wb') as f:
        for chunk in resp.iter_content(chunk_size=8192):
            f.write(chunk)
            downloaded += len(chunk)

    return output_path

5.3 下载时需要的关键请求头

B 站的 CDN 对请求头检查比较严格:

headers = {
    'Referer': 'https://www.bilibili.com/',
    'Origin': 'https://www.bilibili.com',
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
                  'AppleWebKit/537.36 (KHTML, like Gecko) '
                  'Chrome/120.0.0.0 Safari/537.36',
    'Accept': '*/*',
    'Accept-Language': 'zh-CN,zh;q=0.9',
    'Range': 'bytes=0-',  # 支持断点续传
}

少了 RefererOrigin,B 站 CDN 直接返回 403。

六、弹幕的下载与解析

6.1 弹幕接口

B 站的弹幕通过独立的 protobuf 接口获取,不是普通的 JSON API:

import requests
import re
import zlib


def download_danmaku(cid, output_path=None):
    """下载 B 站弹幕(protobuf 格式)"""
    # B 站弹幕接口
    url = f'https://api.bilibili.com/x/v1/dm/list.so?oid={cid}'

    resp = requests.get(url, headers={
        'User-Agent': 'Mozilla/5.0 ...',
        'Referer': 'https://www.bilibili.com/',
    })

    # 弹幕是 XML 格式(不是 JSON)
    xml_content = resp.text

    # 解析弹幕
    danmaku_list = []
    for match in re.finditer(
        r'<d p="([^"]*)">(.*?)</d>',
        xml_content
    ):
        attrs = match.group(1).split(',')
        text = match.group(2)

        danmaku_list.append({
            'time': float(attrs[0]),          # 弹幕出现时间(秒)
            'type': int(attrs[1]),             # 弹幕类型(1-3=滚动,4=底端,5=顶端)
            'font_size': int(attrs[2]),        # 字号
            'color': int(attrs[3]),            # 颜色(十进制 RGB)
            'send_time': int(attrs[4]),        # 发送时间戳
            'text': text,
        })

    if output_path:
        import json
        with open(output_path, 'w', encoding='utf-8') as f:
            json.dump(danmaku_list, f, ensure_ascii=False, indent=2)

    return danmaku_list

6.2 弹幕格式说明

每条弹幕的属性用逗号分隔:

<d p="12.345,1,25,16777215,1735689600,0,abc123,123456789">哈哈哈</d>
      │      │ │  │        │          │ │      │
      │      │ │  │        │          │ │      └─ 弹幕 ID
      │      │ │  │        │          │ └─ 用户 ID 的哈希
      │      │ │  │        │          └─ 弹幕池(0=普通,1=字幕,2=特殊)
      │      │ │  │        └─ 发送时间戳
      │      │ │  └─ 颜色(16777215=白色)
      │      │ └─ 字号(25=标准)
      │      └─ 类型(1=滚动)
      └─ 出现时间(12.345 秒)

七、番剧与互动视频的特殊处理

7.1 番剧(ep 模式)

番剧不使用 bvid+cid,而是使用 ep_id

def get_bangumi_info(ep_id, session):
    """获取番剧信息"""
    # 番剧接口
    resp = session.get(
        'https://api.bilibili.com/pgc/view/web/season',
        params={'ep_id': ep_id}
    )
    data = resp.json()

    # 获取当前集的视频流
    current_ep = data['result']['episodes'][0]  # 简化处理
    bvid = current_ep['bvid']
    cid = current_ep['cid']

    # 后续流程和普通视频一样
    return get_playurl(bvid, cid, session)

7.2 互动视频

互动视频有多个分支剧情,每个分支对应不同的 cid。需要从 interaction 接口获取完整的剧情树:

def get_interactive_graph(bvid, session):
    """获取互动视频的剧情分支图"""
    resp = session.get(
        'https://api.bilibili.com/x/player/v2',
        params={'bvid': bvid}
    )
    data = resp.json()

    # 互动视频的 interaction 字段
    interaction = data.get('data', {}).get('interaction', {})
    if interaction:
        graph = interaction.get('graph', {})
        # graph 是一个 JSON 字符串,需要二次解析
        import json
        return json.loads(graph)

    return None

八、完整代码:一个 B 站下载器

#!/usr/bin/env python3
"""完整的 B 站视频下载器"""

import requests
import hashlib
import time
import re
import json
import subprocess
import os
from urllib.parse import urlparse, urlencode


class BilibiliDownloader:
    """B 站视频下载器"""

    TABLE = 'fZodR9XQDSUm21yCkr6zBqiveYah8bt4xsWpHnJE7jL5VG3guMTKNPAwcF'
    TR = {TABLE[i]: i for i in range(58)}
    S = [11, 10, 3, 8, 4, 6]
    XOR = 177451812
    ADD = 8728348608

    def __init__(self, cookies_file=None):
        self.session = requests.Session()
        self.session.headers.update({
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
                          'AppleWebKit/537.36 (KHTML, like Gecko) '
                          'Chrome/120.0.0.0 Safari/537.36',
            'Referer': 'https://www.bilibili.com/',
            'Accept-Language': 'zh-CN,zh;q=0.9',
        })

        if cookies_file:
            from http.cookiejar import MozillaCookieJar
            jar = MozillaCookieJar(cookies_file)
            jar.load(ignore_discard=True, ignore_expires=True)
            self.session.cookies = jar

        self._mix_key = None
        self._mix_key_time = 0

    def bv_to_av(self, bvid):
        """BV 号 → AV 号"""
        r = sum(self.TR[bvid[s]] * (58 ** i) for i, s in enumerate(self.S))
        return f'av{(r - self.ADD) ^ self.XOR}'

    def _get_mix_key(self):
        """获取 WBI 签名密钥(缓存 20 分钟)"""
        if self._mix_key and time.time() - self._mix_key_time < 1200:
            return self._mix_key

        resp = self.session.get(
            'https://api.bilibili.com/x/web-interface/nav'
        )
        data = resp.json()['data']['wbi_img']
        img_key = data['img_url'].split('/')[-1].split('.')[0]
        sub_key = data['sub_url'].split('/')[-1].split('.')[0]

        # 拼接 mix_key
        combined = img_key + sub_key
        order = [0,2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,
                 1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31]
        self._mix_key = ''.join(combined[i] for i in order if i < len(combined))[:32]
        self._mix_key_time = time.time()
        return self._mix_key

    def _sign(self, params):
        """WBI 签名"""
        params['wts'] = int(time.time())
        sorted_params = sorted(params.items())
        query = urlencode(sorted_params)
        sign_str = query + self._get_mix_key()
        params['w_rid'] = hashlib.md5(sign_str.encode()).hexdigest()
        return params

    def get_video_info(self, bvid):
        """获取视频基本信息"""
        params = self._sign({'bvid': bvid})
        resp = self.session.get(
            'https://api.bilibili.com/x/web-interface/view',
            params=params
        )
        data = resp.json()['data']
        return {
            'title': data['title'],
            'duration': data['duration'],
            'pages': [{'cid': p['cid'], 'title': p['part'],
                       'page': p['page']} for p in data['pages']],
        }

    def get_playurl(self, bvid, cid, quality=112):
        """获取视频流地址"""
        params = self._sign({
            'bvid': bvid,
            'cid': cid,
            'fnval': 4048,
            'fnver': 0,
            'fourk': 1,
            'qn': quality,
        })
        resp = self.session.get(
            'https://api.bilibili.com/x/player/wbi/v2',
            params=params
        )
        data = resp.json()['data']

        dash = data.get('dash', {})
        videos = dash.get('video', [])
        audios = dash.get('audio', [])

        if not videos:
            raise Exception("未获取到视频流,可能需要登录")

        # 选最高画质视频流
        best_video = max(videos, key=lambda v: v.get('bandwidth', 0))
        best_audio = max(audios, key=lambda a: a.get('bandwidth', 0))

        return best_video['base_url'], best_audio['base_url']

    def download(self, url, output_path='./output.mp4'):
        """主下载入口"""
        # 解析 BV 号
        bvid = self._extract_bvid(url)

        # 获取视频信息
        info = self.get_video_info(bvid)
        print(f"视频: {info['title']}")
        print(f"共 {len(info['pages'])} P")

        # 下载每一P
        for page in info['pages']:
            print(f"\n下载 P{page['page']}: {page['title']}")

            # 获取流地址
            video_url, audio_url = self.get_playurl(bvid, page['cid'])

            # 下载
            base = output_path.rsplit('.', 1)[0]
            v_file = f'{base}_p{page["page"]}_v.mp4'
            a_file = f'{base}_p{page["page"]}_a.mp4'
            out_file = f'{base}_p{page["page"]}.mp4'

            self._download_file(video_url, v_file)
            self._download_file(audio_url, a_file)

            # 合并
            self._merge(v_file, a_file, out_file)
            os.remove(v_file)
            os.remove(a_file)
            print(f"完成: {out_file}")

        print("\n全部下载完成")

    def _download_file(self, url, output, headers=None):
        headers = headers or {}
        headers.setdefault('Referer', 'https://www.bilibili.com/')
        headers.setdefault('Origin', 'https://www.bilibili.com')

        resp = self.session.get(url, headers=headers, stream=True)
        resp.raise_for_status()

        total = int(resp.headers.get('content-length', 0))
        downloaded = 0

        with open(output, 'wb') as f:
            for chunk in resp.iter_content(8192):
                f.write(chunk)
                downloaded += len(chunk)

    def _merge(self, video, audio, output):
        subprocess.run([
            'ffmpeg', '-i', video, '-i', audio,
            '-c', 'copy', output, '-y'
        ], capture_output=True, check=True)

    def _extract_bvid(self, url):
        """从各种形式的 URL 中提取 BV 号"""
        patterns = [
            r'BV[a-zA-Z0-9]{10}',
            r'bvid=([a-zA-Z0-9]+)',
        ]
        for p in patterns:
            match = re.search(p, url)
            if match:
                return match.group(1) if '(' in p else match.group(0)
        raise ValueError(f"无法从 URL 中提取 BV 号: {url}")


if __name__ == '__main__':
    dl = BilibiliDownloader()
    dl.download('https://www.bilibili.com/video/BV1xx411c7mD')

九、合规与温馨提示

  • B 站视频受版权保护,本文技术仅用于个人学习研究和下载自己上传的已获授权的内容
  • WBI 签名算法属于 B 站的技术保护措施,逆向和复现该算法可能涉及法律风险
  • B 站的服务条款禁止未经授权的自动化访问和下载
  • 更多讨论见 下载视频算侵权吗?聊聊个人备份与版权的那条线

B 站的下载链路,说到底就是三件事:把 BV 号转成 AV 号 → 算对 WBI 签名 → 把 DASH 音视频流合起来。 其中签名是最容易出问题的一环——B 站每次更新都可能微调 mix_key 的拼接逻辑。所以一个健壮的 B 站下载器,一定要有签名失败的降级策略(比如退回到模拟浏览器的方式获取播放地址)。

本文由 VidDown 技术博客原创发布。VidDown 内置了 B 站视频解析引擎,自动处理 WBI 签名和 DASH 合并,支持 1080P 高码率、4K、HDR 和弹幕下载。访问 VidDown 了解更多。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部