B站视频内容智能分析系统(三):B站视频自动采集

系列文章目录

B站视频内容智能分析系统(一):项目介绍与架构设计

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_keysub_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月)还是稳定可用的。

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

三种情况走全量:

  1. 手动勾选全量扫描args.full_scan
  2. 新 UP主is_new_up,从未采集过)
  3. 已完成数不足 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 保证了断点续传,分批处理避免了磁盘爆炸。转写和精炼是这个链路的下游,后面两篇单独拆开来讲。

相关推荐
小村儿1 小时前
连载13- 内部Tools,Claude Code 怎么真正"动"你的代码
前端·后端·ai编程
夏语灬1 小时前
cryptography:Python 密码学标准库的终极选择
开发语言·python·密码学
郑洁文1 小时前
基于SpringBoot的商品仓库管理系统的设计与实现
java·spring boot·后端·仓库管理系统·商品仓库管理系统
布朗克1681 小时前
22 异常处理——从入门到精通的完整指南
java·异常处理
小旭95271 小时前
Spring AI Alibaba 从入门到实战:一站式掌握企业级 AI 应用开发
java·人工智能·spring
Jun6262 小时前
QT(19)-VISA控制仪器
开发语言·qt
ANnianStriver2 小时前
PetLumina 07 — 宠物管理升级与 JavaScript 大数精度修复
开发语言·javascript·ai编程·宠物
Arrom2 小时前
DLNA 渲染端排障实战:从 20s 卡顿到 stale subscriber 的两周追凶之旅
android·java
阿里云云原生2 小时前
Code designs Harness 还是 Model drives Harnesses?
ai编程