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_key和sub_key,拼接后用 MD5 计算。弹幕通过独立的 protobuf 接口获取,是 XML 格式。
目录
- 一、B 站视频的 ID 体系:AV、BV、cid、epid 都是什么
- 二、AV 号和 BV 号的互转算法
- 三、WBI 签名:B 站反爬的核心防线
- 四、获取视频流地址:playurl API 详解
- 五、DASH 音视频分离与合并
- 六、弹幕的下载与解析
- 七、番剧与互动视频的特殊处理
- 八、完整代码:一个 B 站下载器
- 九、合规与温馨提示
一、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_rid 和 wts 参数。这就是 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_key 和 sub_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-', # 支持断点续传
}
少了 Referer 或 Origin,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 了解更多。