系列文章目录
B站视频内容智能分析系统(二):Docker Compose 一键部署
B站视频内容智能分析系统(三):B站视频自动采集
文章目录
- 系列文章目录
- 前言
- 一、采集流程总览
- [二、UP主配置:YAML 文件](#二、UP主配置:YAML 文件)
- [1. 配置文件结构](#1. 配置文件结构)
- [2. 扫描与加载](#2. 扫描与加载)
- [三、B站 API 视频列表拉取](#三、B站 API 视频列表拉取)
- [1. WBI 签名机制](#1. WBI 签名机制)
- [2. Cookie 认证](#2. Cookie 认证)
- [3. 分页拉取](#3. 分页拉取)
- [四、增量采集 vs 全量扫描](#四、增量采集 vs 全量扫描)
- [1. 问题背景](#1. 问题背景)
- [2. 增量逻辑](#2. 增量逻辑)
- [3. 全量扫描按钮](#3. 全量扫描按钮)
- [4. 完整判断逻辑](#4. 完整判断逻辑)
- [五、双层 Checkpoint 机制](#五、双层 Checkpoint 机制)
- [1. 两个文件,两种状态](#1. 两个文件,两种状态)
- [2. 状态流转](#2. 状态流转)
- [3. 代码实现](#3. 代码实现)
- [六、yt-dlp 下载音频](#六、yt-dlp 下载音频)
- [1. 为什么只下载音频](#1. 为什么只下载音频)
- [2. yt-dlp 配置](#2. yt-dlp 配置)
- [3. 文件命名规则](#3. 文件命名规则)
- 七、分批处理避免磁盘爆满
- [1. 为什么不一次性全下载](#1. 为什么不一次性全下载)
- [2. 批次流程](#2. 批次流程)
- [3. 遗留文件清理](#3. 遗留文件清理)
- 八、多UP主聚合调度
- [1. monitor_all.py 入口](#1. monitor_all.py 入口)
- [2. 并发控制](#2. 并发控制)
- [3. 汇总报告](#3. 汇总报告)
- 九、触发方式
- [1. 手动触发](#1. 手动触发)
- [2. 前端触发](#2. 前端触发)
- [3. 定时触发(NAS)](#3. 定时触发(NAS))
- 总结
前言
前两篇讲了整体架构和 Docker 部署,这篇开始进入核心功能------B站视频自动采集。
bilibili-monitor 是整个系统的数据入口,它做的事情就是:拉视频列表 → 下载音频 → 转写 → 精炼 → 入库。看起来简单,但实际做下来有不少细节,比如 B站的 WBI 签名机制、增量采集遗漏旧视频的坑、双层 Checkpoint 容错等等。
这篇把采集链路拆开讲,转写和精炼的部分留到后面两篇单独讲。
截图:前端管理面板的采集触发页面,显示 UP主选择、采集按钮和实时日志
一、采集流程总览
先看一下完整的采集流程:
monitor_all.py(入口)
↓
扫描 config/*.yaml(UP主配置)
↓
逐个 UP 主运行 monitor.py
↓
monitor.py(单个UP主处理)
├── 1. 加载 Cookie + 校验有效性
├── 2. 读取 Checkpoint(已处理视频列表)
├── 3. WBI API 拉取视频列表(增量/全量)
├── 4. 过滤出未处理的新视频
├── 5. 分批处理(每批 30 个):
│ ├── 5a. yt-dlp 下载音频(m4a)
│ ├── 5b. 转写(GPU > 云ASR > CPU) ← 后面专门讲
│ ├── 5c. DeepSeek 精炼 + 入库 ← 后面专门讲
│ └── 5d. 清理已转写的音频文件
└── 6. QQ Bot 通知采集完成
整个流程的关键设计原则是:每一步都有 Checkpoint,中途挂了可以从断点继续,不会重复处理。
二、UP主配置:YAML 文件
1. 配置文件结构
每个 UP主 对应一个 YAML 配置文件,放在 bilibili-monitor/config/ 目录下:
yaml
# config/恋爱教头桃姐.yaml
name: "恋爱教头桃姐"
uid: "3546912280021515"
domain: "emotional"
cookie_file: "~/.bilibili/cookie.txt"
download_root: "~/B站监控"
# QQ 通知(填 openid)
notify_target: "qq:65091C38C651B44BA071725FDF78A800"
# Whisper 转写设置
whisper_model: "medium"
whisper_device: "cuda"
# 转写输出目录
transcribe_output_dir: "/mnt/e/情感素材库"
各字段的含义:
| 字段 | 说明 | 必填 |
|---|---|---|
name |
UP主名称(显示用) | 是 |
uid |
B站用户 UID | 是 |
domain |
内容领域(emotional / career) |
是 |
cookie_file |
Cookie 文件路径 | 是 |
download_root |
音频下载目录 | 否(默认 ~/B站监控) |
notify_target |
QQ 通知目标 | 否 |
whisper_model |
Whisper 模型(tiny/base/small/medium) | 否(默认 small) |
whisper_device |
设备(cpu/cuda) | 否(默认 cpu) |
transcribe_output_dir |
转写文本输出目录 | 否 |
domain 字段决定了精炼时走哪个 Prompt 模板。emotional 是情感域(恋爱、婚姻相关),career 是求职域(面试、职业规划相关)。两个域的 31 个分类完全不同。
2. 扫描与加载
monitor_all.py 启动时会自动扫描配置目录:
python
def find_all_configs():
"""扫描 config/ 下所有 .yaml 文件"""
config_dirs = []
# 优先使用环境变量指定的配置目录
env_dir = os.environ.get("BILIBILI_CONFIG_DIR", "")
if env_dir:
config_dirs.append(Path(env_dir))
# 默认配置目录
config_dirs.append(SCRIPT_DIR.parent / "config")
configs = []
seen = set()
for d in config_dirs:
if d.exists():
for f in d.glob("*.yaml"):
if f.stem not in seen:
configs.append(f)
seen.add(f.stem)
return sorted(configs, key=lambda p: p.stem)
支持通过 --up 参数指定只跑某些 UP主(模糊匹配):
bash
# 只采集桃姐和安佳
python monitor_all.py --up 桃姐 安佳
三、B站 API 视频列表拉取
1. WBI 签名机制
B站的视频列表接口 /x/space/wbi/arc/search 需要 WBI 签名。简单来说,就是把请求参数和一个动态密钥混合后做 MD5,生成一个 w_rid 参数。
核心代码:
python
# 固定的混淆表(B站硬编码在前端 JS 里)
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
]
def getMixinKey(orig: str) -> str:
"""按混淆表重排字符,取前 32 位"""
return reduce(lambda s, i: s + orig[i], MIXIN_KEY_ENC_TAB, '')[:32]
def encWbi(params: dict, img_key: str, sub_key: str) -> dict:
"""WBI 签名"""
mixin_key = getMixinKey(img_key + sub_key)
params['wts'] = round(time.time()) # 时间戳
params = dict(sorted(params.items())) # 按 key 排序
# 过滤特殊字符
params = {
k: ''.join(filter(lambda c: c not in "!'()*", str(v)))
for k, v in params.items()
}
query = urllib.parse.urlencode(params)
wbi_sign = hashlib.md5((query + mixin_key).encode()).hexdigest()
params['w_rid'] = wbi_sign
return params
img_key 和 sub_key 是从 B站的 /x/web-interface/nav 接口动态获取的,每次会话可能不同。签名的流程:
1. 调用 nav 接口获取 img_url 和 sub_url
2. 从 URL 中提取 img_key 和 sub_key
3. 拼接后按混淆表重排,取前 32 位得到 mixin_key
4. 请求参数 + 时间戳 → 排序 → 拼接 → MD5(拼接 + mixin_key) → w_rid
这个签名机制 B站随时可能改,不过目前(2026年6月)还是稳定可用的。
2. Cookie 认证
B站的大部分 API 都需要登录态 Cookie。Cookie 以 Netscape 格式存在文件里:
# Netscape HTTP Cookie File
.bilibili.com TRUE / FALSE 0 SESSDATA xxxxxxx
.bilibili.com TRUE / FALSE 0 bili_jct xxxxxxx
Cookie 有效性校验用的是 /x/web-interface/nav 接口:
python
def test_cookie(cookies: dict) -> tuple:
"""检验 Cookie 是否有效,返回 (ok, username_or_error)"""
resp = requests.get(
'https://api.bilibili.com/x/web-interface/nav',
headers=headers, cookies=cookies, timeout=10
)
code = resp.json().get('code', 0)
if code == 0:
return True, resp.json()['data']['uname']
elif code == -352:
return False, '风控校验失败(Cookie 已过期或被风控)'
else:
return False, resp.json().get('message', f'code={code}')
code=-352 是最常见的错误,表示 Cookie 过期或被风控了。这时候系统会自动发 QQ 通知提醒你去更新 Cookie。
3. 分页拉取
B站视频列表是分页的,每页最多 30 个。get_video_list 会自动翻页:
python
def get_video_list(uid, cookies, max_count=30):
"""获取UP主视频列表,自动翻页"""
all_videos = []
page = 1
while len(all_videos) < max_count:
videos = get_up_videos(uid, cookies, pn=page, ps=30)
if not videos:
break
all_videos.extend(videos)
page += 1
time.sleep(1) # 避免请求太快被风控
return all_videos[:max_count]
max_count 决定了拉取多少视频,这个值由增量/全量策略决定,下一节详细讲。
四、增量采集 vs 全量扫描
1. 问题背景
这是开发过程中遇到的一个比较坑的 BUG。
最开始的逻辑很简单:每次运行都拉取最新的 30 个视频,和已处理的做对比,找出新视频。这个逻辑在 UP主 视频数少于 100 的时候没问题,但一旦视频数超过 100,就出事了。
举个例子:老张有 900 多个视频,已经处理了 500 多个。按照增量逻辑,每次只拉最新 30 个,但这 30 个都已经在 done_bvid 里了。剩下的 400 多个老视频排在 API 返回的后面几页,永远不会被拉到。
结果就是:老张的视频数量永远停在 500 多,再也不会增加。
2. 增量逻辑
修复后的增量逻辑是这样的:
python
# 已处理的视频数
done_count = len(done_bvid_set)
# 新UP主(从未采集过)或 done < 100:全量拉取
# 否则(已完成初始采集):增量拉取最新 30 个
if is_new_up or done_count < 100:
max_count = 9999 # 全量
else:
max_count = 30 # 增量
设计意图是:当已处理视频数小于 100 时,认为还没完成初始采集,继续全量拉取;超过 100 后切换到增量模式,只关注最新视频。
但这就出现了前面说的问题------如果 UP主 有 900 个视频,初始采集只做了 500 个就停了(因为某次运行出了问题或手动中断),那 done_count 是 500 > 100,就会走增量模式,永远不会再拉剩下的 400 个了。
3. 全量扫描按钮
为了解决这个问题,我加了一个 --full-scan 参数:
python
parser.add_argument('--full-scan', action='store_true',
help='全量扫描所有历史视频(忽略增量阈值)')
在前端的管理面板里,采集按钮旁边有一个复选框:
☐ 全量扫描(拉取所有历史视频)
勾选后,full_scan=true 会通过 API 一直传到 monitor.py:
前端勾选 → api.ts → POST /api/trigger_monitor {full_scan: true}
→ monitor_trigger.py 构建 --full-scan
→ monitor_all.py 透传
→ monitor.py 强制 max_count=9999
4. 完整判断逻辑
最终的判断条件:
python
max_count = 9999 if (args.full_scan or is_new_up or len(done_bvid_set) < 100) else 30
三种情况走全量:
- 手动勾选全量扫描 (
args.full_scan) - 新 UP主 (
is_new_up,从未采集过) - 已完成数不足 100(还在初始采集阶段)
其他情况走增量(只拉最新 30 个)。
对于老张这种情况,手动勾选一次全量扫描就能把剩下的 400 多个视频全部拉回来。
五、双层 Checkpoint 机制
1. 两个文件,两种状态
每个 UP主 有两个 Checkpoint 文件,存在 data/ 目录下:
data/
├── 3546912280021515_downloaded.txt ← 已下载(但可能还没转写)
└── 3546912280021515_done_bvid.txt ← 已转写+精炼+入库(全流程完成)
文件内容很简单,每行一个 BV 号:
BV1Nh5r6PEZb
BV1Xk4y1M7az
BV1Qw411k7Jp
...
2. 状态流转
一个视频从发现到完成,状态是这样的:
新发现 → 下载成功 → 转写成功 → 精炼+入库 → 完成
↓ ↓ ↓
写入 downloaded 写入 done_bvid 从 downloaded 移除
具体来说:
- 下载成功后 :BVID 写入
_downloaded.txt - 转写+精炼+入库全部成功后 :BVID 写入
_done_bvid.txt,同时从_downloaded.txt中移除 - 下次运行时:同时存在于两个文件中的 BVID 会被视为"已完成",跳过
这个设计的好处是断点续传:
- 如果下载完还没转写就挂了,下次运行会跳过下载,直接从转写开始
- 如果转写了一半挂了,
downloaded.txt里有记录但done_bvid.txt里没有,下次会重新转写
3. 代码实现
Checkpoint 的读写封装成了三个函数:
python
def load_checkpoint(uid: str, suffix: str) -> set:
"""加载 BVID 集合"""
ck_file = _checkpoint_path(uid, suffix)
if not os.path.exists(ck_file):
return set()
with open(ck_file, 'r', encoding='utf-8') as f:
return {line.strip() for line in f if line.strip()}
def append_checkpoint(uid: str, suffix: str, bvids: list):
"""追加 BVID"""
ck_file = _checkpoint_path(uid, suffix)
os.makedirs(os.path.dirname(ck_file), exist_ok=True)
with open(ck_file, 'a', encoding='utf-8') as f:
for b in bvids:
f.write(b + '\n')
过滤新视频时,两个集合都做排除:
python
new_videos = [v for v in videos
if v['bvid'] not in done_bvid_set
and v['bvid'] not in downloaded_set]
六、yt-dlp 下载音频
1. 为什么只下载音频
我们的目的是获取视频的语音内容来做转写,不需要画面。yt-dlp 支持只提取音频:
python
'format': 'bestaudio/best',
'extractaudio': True,
'audioformat': 'm4a',
一个 20 分钟的视频,下载完整视频可能要 200MB,但只下载音频只需要 10-20MB。对于批量采集来说,这个差距很大。
2. yt-dlp 配置
python
def download_video(url: str, output_dir: str, cookie_file: str) -> Optional[str]:
"""下载单个视频的音频"""
ydl_opts = {
'format': 'bestaudio/best', # 只下载最佳音频
'outtmpl': f'{output_dir}/%(title)s [%(id)s].%(ext)s',
'extractaudio': True, # 提取音频
'audioformat': 'm4a', # 输出 m4a 格式
'audioquality': '0', # 最高音质
'cookiefile': cookie_file, # Cookie 文件
'noplaylist': True, # 不下载播放列表
'socket_timeout': 600, # 10分钟超时
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
# 提取音频后文件后缀变为 .m4a
base = os.path.splitext(ydl.prepare_filename(info))[0]
audio_file = base + '.m4a'
return audio_file if os.path.exists(audio_file) else None
3. 文件命名规则
下载后的文件命名格式:
{标题} [{BV号}].m4a
例如:女生不回消息怎么办 [BV1Nh5r6PEZb].m4a
BV号用方括号包裹,这个格式很重要------后面转写完成后生成的 .txt 文件也保持同样的命名(只是后缀不同),这样就能从文件名里提取 BV号来更新 Checkpoint。
七、分批处理避免磁盘爆满
1. 为什么不一次性全下载
一个 UP主 可能有几百个视频,如果一次性全部下载,磁盘可能扛不住。所以我做了分批处理:每 30 个视频为一批,下载 → 转写 → 精炼入库 → 清理音频,一批处理完再处理下一批。
这样任何时刻磁盘上最多只有 30 个音频文件,不会爆满。
2. 批次流程
python
batch_size = args.batch_size or 30
total_new = len(new_videos)
for batch_start in range(0, total_new, batch_size):
batch = new_videos[batch_start:batch_start + batch_size]
print(f"📦 批次 {batch_num}/{total_batches}")
# 1. 下载
for v in batch:
download_video(url, output_dir, cookie_file)
append_checkpoint(uid, '_downloaded', [bvid])
# 2. 转写
trigger_transcribe(output_dir, config, uid, up_name)
# 3. 精炼 + 入库(下一篇讲)
# ...
# 4. 清理已转写的音频文件
for bvid in batch_bvids:
for f in Path(output_dir).glob(f'*[{bvid}]*.m4a'):
f.unlink()
每批结束后,音频文件会被删除(因为已经转写成文本了),只保留 .txt 文件。
3. 遗留文件清理
每次启动时,会先检查有没有"下载了但没转写"的遗留文件:
python
# 检查遗留的已下载未转写文件
pending_set = load_checkpoint(uid, '_downloaded') - load_checkpoint(uid, '_done_bvid')
if pending_set:
print(f"🧹 清理 {len(pending_set)} 个遗留的下载文件...")
for old_bvid in pending_set:
for f in Path(output_dir).glob(f'*[{old_bvid}]*'):
f.unlink()
这种情况通常发生在上一批下载到一半被中断了。遗留的音频文件会占用磁盘空间,而且下次运行时会重新下载,所以直接清理掉。
八、多UP主聚合调度
1. monitor_all.py 入口
monitor_all.py 是整个采集的入口脚本。它扫描 config/ 下所有 YAML 配置,逐个运行 monitor.py:
bash
# 采集所有 UP主
python monitor_all.py
# 只采集指定 UP主
python monitor_all.py --up 桃姐 安佳
# 预览模式(不下载)
python monitor_all.py --dry-run
# 全量扫描
python monitor_all.py --full-scan
# 指定每个UP最多处理几个视频
python monitor_all.py --max-videos 10
2. 并发控制
多UP主采集时,转写阶段不能并发太多(NAS 的 CPU 吃不消):
python
MAX_CONCURRENT_TRANSCRIBE = 2 # 最多同时转写 2 个 UP
# 按批次分组
batched = []
for i in range(0, len(configs), MAX_CONCURRENT_TRANSCRIBE):
batched.append(configs[i:i + MAX_CONCURRENT_TRANSCRIBE])
每批之间留 5 秒的间隔,让 GPU 显存和 CPU 缓存回收:
python
if batch_idx < len(batched):
print("⏳ 等待 GPU 显存回收...")
time.sleep(5)
3. 汇总报告
全部跑完后,输出一个汇总报告:
============================================================
📊 汇总报告
============================================================
✅ 恋爱教头桃姐 --- 下载完成,新增 5 个
✅ 安佳 --- 无新视频
✅ 职场老张 --- 下载完成,新增 12 个
❌ 夹性学姐在这 --- 退出码 1(Cookie 已失效)
============================================================
总计: 3/4 个UP主成功,新增 17 个视频
九、触发方式
1. 手动触发
通过命令行直接运行:
bash
# Docker 环境
docker compose run --rm bilibili-monitor
# 带参数
docker compose run --rm bilibili-monitor \
python src/monitor_all.py --up 桃姐 --full-scan
--rm 参数会在运行结束后自动清理容器。
2. 前端触发
前端管理面板里可以图形化操作:
管理面板 → 采集触发 → 选择 UP主 → 勾选参数 → 点击"开始采集"
前端的请求链路:
前端 POST /api/trigger_monitor
→ Router Agent 通过 Docker SDK 启动 bilibili-monitor 容器
→ 容器运行 monitor_all.py
→ 前端每 5 秒轮询 /api/trigger_status 获取日志
→ 运行完成后显示最终结果
3. 定时触发(NAS)
NAS 环境下,bilibili-cron 容器每 6 小时自动触发一次:
yaml
bilibili-cron:
command: >
-c "
echo '17 */6 * * * /usr/local/bin/cron_entry.sh' > /etc/crontabs/root;
crond -f -l 2
"
cron_entry.sh 里面就是一行 docker compose run --rm bilibili-monitor。
这样 NAS 就能 7×24 自动采集了,不需要人工干预。
总结
bilibili-monitor 的采集链路看起来是"拉列表→下载→转写→入库"四步,但每一步都有不少工程细节:WBI 签名要和 B站的风斗,增量/全量策略决定了能不能覆盖所有视频,双层 Checkpoint 保证了断点续传,分批处理避免了磁盘爆炸。转写和精炼是这个链路的下游,后面两篇单独拆开来讲。