让 YouTube 自动字幕按整句切分:我把 Language Reactor 的断句问题从根上修掉了

我每天用 Edge + Language Reactor 看 CNN10 做精听,习惯一句一句反复听。

但这个流程一直有个很难受的地方:字幕不是在句子边界断开的。

经常是这样:

...and most policy makers          <- 上一行
expect at least one more hike this year.   <- 下一行

按句子复读的时候,你得手动把光标拖回去,或者干脆把两个碎片连在一起听。次数一多,学习节奏全碎了。

搜了一圈,没人给出解法,于是我自己动手把根因挖出来了,顺便做了个扩展。过程挺有意思,记一下。

一、先量一下问题有多大

不量化就动手,很容易改错地方。我先把那份字幕原始数据抓下来看。

Language Reactor 的字幕来自 YouTube 的 timedtext 接口,返回 json3 格式。抓法:用 Chrome DevTools Protocol 连上浏览器,开 Network 域,然后从响应体里把 json3 拿出来解析。

拿 9 月 18 日那期 CNN10 统计的结果:

总段数: 456
总词数: 1444
平均每段: 6.3 词 / 4.6 秒
结尾没有终止标点的段: 186 段(约 82%)

也就是说,YouTube 是按时间切片,不是按句子切片——大约每 4.5 秒切一刀,切到哪儿算哪儿。

二、Language Reactor 到底做了什么

接下来把 Language Reactor 5.1.8 的 pageScript_lly.min.js 扒开看。

它拉字幕的方式很朴素:jQuery.ajax(底层就是 XMLHttpRequest)请求

https://www.youtube.com/api/timedtext?v=...&fmt=json3&c=WEB&pot=...

(pot 这个令牌它是从 XHR 的 open 调用里顺手截下来的——因为 YouTube 现在对这个接口有校验。)

真正关键的是它对字幕的解析函数。在 ASR(自动字幕)分支里,它做的是两两配对:

相邻两个 cue,如果时间间隔 ≤ 10ms,就合并成一行;否则单独一行。

也就是说,Language Reactor 根本没打算理解句子——它只是把 YouTube 切碎的块两两粘起来。

这就解释了为什么断句看起来“有点道理但就是不整”:它只是把碎片两两黏合,恰好半数时候黏对了。

三、解法:在字幕进入 Language Reactor 之前重排

既然断句是数据问题,那就别在工具里硬扛,在数据进入工具之前解决。

关键发现是:json3 里的每个 event 都带逐词时间戳(segs[].tOffsetMs)。这意味着我能精确知道“句号落在第几个词上”。

于是改写流程变成三步:

  1. 把所有 event 摊平成“词 + 绝对时间”的列表。
  2. 按标点重新组句:.?! 结尾断开;带缩写保护(U.S.、Dr.、p.m. 不算句尾);>> 表示换人说话时断开;停顿超过 0.9 秒且下一个词是大写,也视为换句;单句最长 13 秒 / 200 字符兜底。
  3. 重建 json3:一个 cue = 一整句,逐词时间戳原样保留。

还有一个必须处理的细节:Language Reactor 那条“间隔 ≤10ms 就两两配对”的规则。

破解方式很土但很有效——每句结尾故意留 60ms 空隙。间隔变成 60ms,远超 10ms,它就再也不会把两句粘起来了。

// 保留 60ms 空隙,等于锁死"一行一句"
var dur = s.end - s.start;
if (i + 1 < sents.length) dur = Math.min(dur, sents[i + 1].start - s.start - 60);

顺手还修了一个隐藏 bug:YouTube 每个 event 的第一个词没有前导空格,直接拼接会得到 Wemade it.。所以重排时还要按词补空格。

四、短句不能单独成行

第一版改完:456 段 → 137 行,确实一句一行了。

但用起来立刻发现问题:I'm Koi Wire. 这种 2-3 个词的短句,单独占一行非常别扭,眼睛还没落下去就翻页了。

于是加了一条最小长度规则:不足 4 个词、并且时长不足 1.5 秒的句子,并入下一句。

改完 456 段 → 124 行,只剩两行是极短的,而且都合理(一行后面紧跟着 >> 换人,一行是视频结尾)。

这里也踩了个坑:条件一开始写得糙,导致 >> 换人处前面只剩一个词时也被断开,出现了单行单词 It's。改成“只有当前句子已经成立(有标点或词数够)才在换人处断开”就正常了。

五、中英对不上:翻译轨也要一起切

英文修好之后,我打开中文对照,发现中英错位了。

原因不复杂:Language Reactor 显示的中文不是它自己翻的,而是从 YouTube 的翻译轨(URL 里带 tlang=)拉下来的,然后按时间轴和英文对齐。

英文被我切成 124 句,中文还是原来的 6 词一段——条数都不等,当然对不上。

修法:记住英文算出来的句子边界,把翻译轨按同一套边界重切一遍。为了兼容 YouTube 返回的两种结构(逐词 or 整行),用的是“按时间比例切分”:

  • 逐词轨迹:每个词按自己的时间落进对应句子;
  • 整行轨迹:整行按时间长度的重叠比例切给不同的句子。

结果是条数严格相等:124 ↔ 124,逐条对齐。

六、干脆用自己的翻译

到这一步已经很好用了,但还有个隐患:翻译轨的断句权在 YouTube 手里,质量也一般。

那不如自己的句子自己翻。

架构是三层:

页面脚本(主世界)  →  桥(content script)  →  后台 service worker  →  翻译接口
   拦到 tlang 请求        页面拿不到 chrome.runtime,得转发      调 API + 本地缓存

这里有个我觉得挺有意思的操作:拦截到翻译请求后,不发真请求。

等后台把译文拿回来,我自己给这个 XHR 打上状态:

// 让 jQuery 以为请求成功结束了,拿到的却是我们的译文
shape('readyState', 4);
shape('status', 200);
shape('responseText', text);
xhr.dispatchEvent(new Event('readystatechange'));
xhr.dispatchEvent(new ProgressEvent('load'));

因为译文就是我们按英文句子生成的,一行对一行,构造上不可能错位。

翻译源做成可切换的:

  1. MyMemory:免费、不用 key,实测 3 句 1.4 秒,质量中等(默认)。
  2. OpenAI 兼容接口:DeepSeek / 中转站 / 本地 Ollama 都行,一个视频一次请求,质量最好。
  3. Google 免费端点:我这边的出口 IP 被 Google 风控拦了(返回 Sorry... automated queries),基本不可用,留作备选。

译文按「视频 + 语言 + 翻译源 + 句子哈希」缓存在本机,重看不重翻;翻译失败或超时会自动回退到 YouTube 翻译轨,并在右下角弹个徽章说明原因——不静默失败是我做工具的基本原则。

七、踩坑清单

几个值得单独记下来的坑:

  1. 改了扩展文件必须点“重新加载”。 edge://extensions 里那张卡片上不点一下,改的文件根本不生效,只刷页面没用。我第一次就是被这个骗了,以为代码没起作用。
  2. 工具自己有内存缓存。 Language Reactor 会把解析过的字幕缓存在内存里,换版本之后必须刷新视频页,否则看到的还是旧结果。
  3. 油猴版做不了自有翻译。 页面里跨域调翻译接口会被 CORS 拦,没有后台服务就绕不过去,所以自有翻译只能做成扩展。
  4. 别把工具的怪癖当成自己的 bug。 Language Reactor 里偶尔出现的 … 是它自己的行为:词与词间隔超过 1.5 秒它就插省略号,跟我的改写无关。
  5. Node 的 vm 里 window 是 proxy。 写测试时发现 ev.source !== window 恒为真,把消息全挡了,得写成宽松比较。这类环境的细微差别,不写测试根本发现不了。

八、效果

改前 改后
字幕行数 456 段碎片 124 句
断句位置 82% 在句子中间 句子边界
中英对照 按 YouTube 碎块对齐 124 对 124
词级高亮 / 暂停 / 复读 可用 可用(时间戳全部保留)

现在看 CNN10,一句一行,暂停点落在句末,中英对齐,还能挂自己的翻译。

写在最后

这次折腾最大的体会是:遇到工具不好用,先分清是“工具的问题”还是“数据的问题”。

这里其实是数据的问题——YouTube 的字幕本身就是按时间切的碎块,Language Reactor 只能被动地两两拼。所以最干净的解法不是去改它的渲染逻辑,而是在数据流进它之前,把数据重排成它想要的样子。

而数据能重排的前提,是 json3 里藏着逐词时间戳。很多“看起来没救”的问题,答案其实就埋在原始数据里——先去看原始数据长什么样,永远值得。

如果你也在用 Language Reactor 精听,或者有更聪明的断句策略,欢迎留言交流。

评论区已延迟加载滑到评论区附近时会自动加载,也可以现在手动打开。