字幕提取是整条链路里最先决定总结质量上限的一环。一开始我以为把视频下载下来就完事,结果发现大多数视频根本没有现成字幕:YouTube 有官方和自动字幕,B 站部分视频才有 CC 字幕,抖音、小红书这类 UGC 平台几乎什么都不提供,挨个平台去适配完全忙不过来。我反复调了几版方案,最后定下”官方字幕优先、自动字幕其次、Whisper 转录兜底”的三级管线,覆盖不同平台、不同字幕现状的视频。下面把每个平台的接入方式、解析细节、性能数据和踩过的坑完整展开。
一、字幕来源分类
字幕来源直接决定提取准确度和成本,我先把常见平台分成了三类:
| 类型 | 例子 | 准确度 |
|---|---|---|
| 平台官方字幕 | B 站番剧、YouTube 创作者上传 | 99% |
| 平台自动字幕 | YouTube Auto-generated、抖音 | 85-95% |
| 无字幕 | 大量 UGC 视频 | N/A |
分类依据来自我处理过的几千条真实链接:官方字幕准确度较高但覆盖率低;自动字幕覆盖率上来,准确度打折扣;UGC 平台干脆没有。基于这张表,我定了三条优先级规则:
- 优先用官方字幕(最准);
- 次选自动字幕(可用);
- 都没就用 Whisper 转录音频(兜底)。
这套优先级写进调度器之后,绝大多数视频自动走”官方或自动字幕”的快通道,只有真正没有字幕的才落到 Whisper。
二、yt-dlp 提取官方字幕
下载字幕我直接用了 yt-dlp,它对 YouTube、B 站等平台支持成熟,一次调用能同时拿到视频元信息和字幕文件:
ydl_opts = {
'writesubtitles': True, # 人工字幕
'writeautomaticsub': True, # 自动字幕
'subtitleslangs': ['zh-Hans', 'en', 'ja'], # 优先语言
'subtitlesformat': 'vtt', # VTT 格式
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
# 字幕下载到 info['requested_subtitles']
返回的字幕是 VTT 格式(WebVTT)。格式统一成 VTT 是我刻意做的决定,解析就只有一个分支要维护,后面接其他平台时能省不少事。
三、VTT 字幕解析
VTT 是 WebVTT 的标准字幕格式,时间轴用箭头分隔,正文里还可能混着标签。解析函数要把每一条 cue 的起止时间和文本干净地抠出来:
def parse_vtt(vtt_text: str) -> list[dict]:
"""解析 VTT 字幕为 [{start, end, text}] 列表"""
cues = []
blocks = vtt_text.strip().split('\n\n')
for block in blocks:
lines = block.strip().split('\n')
if '-->' not in lines[0] if lines else True:
continue
time_line = lines[0]
match = re.match(
r'(\d+):(\d+):(\d+)\.\d+ --> (\d+):(\d+):(\d+)\.\d+',
time_line
)
if not match:
continue
h1, m1, s1, h2, m2, s2 = match.groups()
start = int(h1) * 3600 + int(m1) * 60 + int(s1)
end = int(h2) * 3600 + int(m2) * 60 + int(s2)
text = ' '.join(lines[1:])
# 去除 HTML 标签
text = re.sub(r'<[^>]+>', '', text)
cues.append({'start': start, 'end': end, 'text': text})
return cues
这个解析器我补了两个细节:空块直接跳过,避免把分隔行误当成 cue;正文里的内嵌标签用正则剥掉,否则喂给 AI 的文本里全是尖括号噪音。遇到时间格式不规范的行就跳过,宁可丢一条也不报错中断。
四、B 站字幕特殊处理
B 站的字幕情况和 YouTube 不一样,官方字幕以 JSON 形式单独下发,URL 还带有效期,拿到后必须马上拉取。字幕分两类:
1. 官方字幕(番剧/影视)
def _bilibili_subtitle(self, info: dict) -> Optional[list]:
"""B 站官方字幕"""
subtitles = info.get('subtitles', [])
if not subtitles:
return None
# 选中文
for sub in subtitles:
if sub.get('lan') in ('zh-CN', 'zh-Hans'):
url = sub.get('subtitle_url')
if url and not url.startswith('http'):
url = 'https:' + url
return self._fetch_json_subtitle(url)
return None
def _fetch_json_subtitle(self, url: str) -> list:
"""B 站字幕是 JSON 格式"""
response = httpx.get(url, headers={
'User-Agent': 'Mozilla/5.0 ...',
'Referer': 'https://www.bilibili.com'
})
data = response.json()
return [
{'start': c['from'], 'end': c['to'], 'text': c['content']}
for c in data['body']
]
B 站字幕接口要求带 Referer 和 User-Agent,缺了会被风控挡下来,这两个请求头我在代码里写死。subtitle_url 缺协议头时要手动补上 https:,这个细节是实际抓包才发现的。
2. AI 字幕
B 站部分视频带 CC 标志,那是平台的 AI 字幕,走单独的 endpoint。我把它作为官方字幕缺失时的补充源:
# AI 字幕通常在另一个 endpoint
ai_subtitle_url = info.get('ai_subtitle_url')
AI 字幕准确度在九成上下,官方字幕缺失时能顶上,但专业术语容易出错,需要后面的清洗环节兜着。
五、YouTube 字幕
YouTube 的字幕体系比较复杂,人工字幕和自动字幕分开存放,语言优先级也得自己定:
def _youtube_subtitle(self, info: dict) -> Optional[list]:
subs = info.get('subtitles', {}) # {lang: [formats]}
auto_subs = info.get('automatic_captions', {})
# 优先级:人工 zh-Hans > 自动 zh-Hans > 人工 en > 自动 en
for lang in ['zh-Hans', 'zh-CN', 'zh-TW', 'en']:
if lang in subs:
return self._download_sub(subs[lang])
if lang in auto_subs:
return self._download_sub(auto_subs[lang])
return None
def _download_sub(self, formats: list) -> list:
"""下载 YouTube 字幕(通常是 XML/TTML 格式)"""
for fmt in formats:
if fmt.get('ext') in ('vtt', 'ttml', 'srv1'):
url = fmt.get('url')
text = httpx.get(url).text
if fmt['ext'] == 'vtt':
return parse_vtt(text)
elif fmt['ext'] == 'ttml':
return parse_ttml(text)
return []
语言优先级我按”中文人工 > 中文自动 > 英文人工 > 英文自动”排,贴合中文用户为主的定位。字幕格式上 VTT、TTML、srv1 都有,代码做了分支处理,拿不到目标格式就往下找。
TTML 是 XML 格式,解析稍复杂:
from xml.etree import ElementTree as ET
def parse_ttml(ttml_text: str) -> list:
"""解析 TTML 字幕"""
root = ET.fromstring(ttml_text)
ns = {'tt': 'http://www.w3.org/ns/ttml'}
cues = []
for p in root.findall('.//tt:p', ns):
begin = p.get('begin', '')
end = p.get('end', '')
text = ''.join(p.itertext())
cues.append({
'start': parse_time(begin),
'end': parse_time(end),
'text': text.strip()
})
return cues
TTML 的坑在命名空间:findall 路径必须带上 ns 字典,否则元素匹配不到,返回空列表,字幕整个丢光。时间字段形如 00:00:10.000,parse_time 负责转成秒数。
六、抖音/小红书无字幕
抖音、小红书、Instagram 这类 UGC 平台几乎都不提供字幕,这一路只能用最稳的办法:把音频抽出来,交给 Whisper 转。这也是三级管线的兜底层:
def _extract_audio_and_transcribe(self, video_path: str) -> list:
"""用 ffmpeg 提取音频,再用 Whisper 转录"""
# 1) 提取音频
audio_path = video_path.rsplit('.', 1)[0] + '.mp3'
subprocess.run([
'ffmpeg', '-i', video_path,
'-vn', '-acodec', 'libmp3lame',
'-ar', '16000', '-ac', '1',
audio_path
], check=True)
# 2) Whisper 转录
import whisper
model = whisper.load_model('base') # base/small/medium/large
result = model.transcribe(audio_path, language='zh')
return [
{'start': int(seg['start']),
'end': int(seg['end']),
'text': seg['text']}
for seg in result['segments']
]
ffmpeg 抽音频时把采样率固定到 16kHz 单声道,这是 Whisper 的标准输入格式,能少很多转录噪声。base 模型实测 5 分钟视频大概 30 秒出结果,等待在可接受范围。
七、Whisper 模型选择
Whisper 从 tiny 到 large 一字排开,速度和准确度差别明显,我实测过一轮:
| 模型 | 大小 | 速度 | 准确度 |
|---|---|---|---|
| tiny | 39M | 极快 | 低 |
| base | 74M | 快 | 中 |
| small | 244M | 中 | 中高 |
| medium | 769M | 慢 | 高 |
| large | 1550M | 最慢 | 最高 |
线上服务器没有 GPU,large 的显存要求直接劝退。平台默认 base,用户在设置里可选 small/medium 提升准确度。这是成本和质量之间的折中,也是我反复压测后定下的默认值。
八、长视频分段处理
长视频是 Whisper 的另一道坎。1 小时视频的音频直接转录要 5-15 分钟,用户等不起,超长音频还容易 OOM。我的解法是切段并行:
def transcribe_long_audio(self, audio_path: str, chunk_sec: int = 600):
"""分段转录长音频"""
# 1) 用 ffmpeg 切分成 10 分钟一段
duration = get_audio_duration(audio_path)
chunks = []
for i in range(0, int(duration), chunk_sec):
chunk_path = f'{audio_path}.part{i}.mp3'
subprocess.run([
'ffmpeg', '-i', audio_path,
'-ss', str(i), '-t', str(chunk_sec),
chunk_path
])
chunks.append(chunk_path)
# 2) 并行转录
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as ex:
futures = [ex.submit(self._whisper_transcribe, c) for c in chunks]
results = [f.result() for f in futures]
# 3) 合并结果(按时间排序)
all_cues = []
offset = 0
for i, cues in enumerate(results):
for cue in cues:
cue['start'] += offset
cue['end'] += offset
all_cues.append(cue)
offset += chunk_sec
return all_cues
分段转录有个隐蔽问题:每段的 cue 时间都从 0 开始,合并时必须加上段偏移,代码里用 offset 累加解决。实测 1 小时视频从 15 分钟压到 4-5 分钟,主要靠 4 个线程并行,再往上加线程收益递减。
九、字幕清洗
不管字幕来自哪条链路,直接喂给 AI 都会出问题:官方字幕带广告和音乐标记,自动字幕有大量重复。清洗这步不能省:
def clean_subtitle(cues: list) -> list:
cleaned = []
for cue in cues:
text = cue['text']
# 1) 去除 [Music] [Applause] 等
text = re.sub(r'\[(Music|Applause|Laughter)\]', '', text)
# 2) 去除纯标点
if not re.search(r'[\u4e00-\u9fffA-Za-z]', text):
continue
# 3) 去除过长 cue(一句话超过 100 字可能不对)
if len(text) > 100:
text = text[:100] + '...'
cleaned.append({**cue, 'text': text.strip()})
return cleaned
清洗规则我控制得克制,只去噪音和超长句,不碰语义。太激进的清洗会把有效信息一起删掉,这个分寸我调过好几版。
十、字幕合并为文本
清洗完的 cue 列表要转成文本喂给 AI。这里我纠结过要不要保留时间戳:不带时间戳更紧凑,但 AI 总结时无法引用”几分几秒”:
def cues_to_text(cues: list) -> str:
"""合并字幕为连续文本"""
return '\n'.join(cue['text'] for cue in cues)
def cues_to_timestamped_text(cues: list) -> str:
"""带时间戳的文本(保留时间信息)"""
return '\n'.join(
f'[{cue["start"]}s] {cue["text"]}'
for cue in cues
)
平台默认用后者(带时间戳),AI 总结时可以引用”3:25 提到的观点”。实测总结结果里能正确出现”3 分 25 秒提到”这样的引用,用户反馈明显更信任。
十一、缓存策略
同一视频反复提取纯属浪费,尤其 B 站字幕 URL 只有 1 小时有效期,缓存既能提速也能避开过期链接的报错:
import hashlib
def cache_key(url: str) -> str:
return hashlib.md5(url.encode()).hexdigest()
def get_or_extract_subtitle(url: str) -> list:
key = cache_key(url)
cached = redis.get(f'sub:{key}')
if cached:
return json.loads(cached)
cues = extract_subtitle(url)
redis.setex(f'sub:{key}', 86400, json.dumps(cues)) # 24h 缓存
return cues
缓存键直接取 URL 的 MD5,24 小时过期。被频繁引用的视频命中率很高,Redis 的负担可以忽略。
十二、字幕质量评估
转录结果偶尔会离谱:字幕稀疏得像没内容,或者密集到像复制粘贴。与其等 AI 总结出来再发现,不如在入口先评估:
def assess_quality(cues: list) -> dict:
"""评估字幕质量"""
if not cues:
return {'quality': 'empty'}
text = ' '.join(c['text'] for c in cues)
total_chars = len(text)
duration = cues[-1]['end'] - cues[0]['start']
chars_per_sec = total_chars / max(duration, 1)
# 正常中文语速 3-5 字/秒
if chars_per_sec < 1:
return {'quality': 'too_sparse', 'cps': chars_per_sec}
if chars_per_sec > 8:
return {'quality': 'too_dense', 'cps': chars_per_sec}
return {'quality': 'good', 'cps': chars_per_sec}
质量差的字幕(过稀/过密)警告用户。判断标准是字/秒,正常中文语速 3-5 字/秒。评估结果挂在任务详情里,质量差的会提示”总结可能不完整”,提前管理用户预期。
十三、踩过的坑
汇总一下这个项目里实际踩过的坑,都来自线上:
- YouTube 自动字幕是 ASR 结果:准确度不如人工,但可用。
- B 站字幕链接 1 小时过期:必须立即下载,缓存 1 天以上会失效。
- VTT 格式有 BOM:解析前要去除 UTF-8 BOM。
- 多语言字幕优先级:用户没指定时按”中文 > 英文 > 日文”顺序。
- Whisper 内存占用:large 模型 5GB 显存,普通服务器用 base/small。
- 音频采样率:Whisper 要求 16kHz 单声道。
- 长音频 Whisper 截断:> 几小时会 OOM。分段处理。
- 字幕与音频不对齐:自动字幕可能差 1-2 秒。AI 总结时标”约 X 分”。
这些坑里影响面比较大的是 B 站字幕过期和 Whisper 内存占用,建议代码评审时把这两条列为必查项。
十四、性能数据
各平台的字幕获取耗时我测了一轮,结果如下:
| 平台 | 字幕源 | 提取时间 |
|---|---|---|
| YouTube | 官方/自动 | < 2s |
| B 站 | 官方 | < 1s |
| 抖音 | Whisper | 视频时长 0.2-0.5x |
| 小红书 | Whisper | 同上 |
| 无字幕 → Whisper | 同上 |
从数据看,官方字幕基本秒回,Whisper 兜底和视频时长成正比。这也是调度器优先走官方链路的原因——成本差一个数量级。
十五、未来优化
这套管线还有优化空间,我列了三个方向排在 roadmap 上:
- 自训练字幕模型:针对特定领域(科技/教育)准确度更高;
- Whisper 量化:INT8 量化后模型大小 4 倍,速度 2 倍;
- 云端 ASR 接入:阿里云/腾讯云 ASR 作为兜底,速度快 5-10 倍。
三个方向里,自训练模型收益大但投入也大,云端 ASR 接入立竿见影,Whisper 量化则是对现有服务器的免费升级,我会先做后两个。
常见问题(FAQ)
Q1:Whisper 转录要多久?
base 模型 1 分钟视频约 5-10 秒转录。
Q2:B 站 AI 字幕能用吗?
能用,准确度 90%+,但需要会员视频权限。
Q3:YouTube 视频没字幕怎么办?
平台自动 Whisper 转录。