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

> 发布于：2026-09-20 | 分类：技能分享 | 原文：https://www.imbailey.cn/post/youtube-asr-sentence-cues-language-reactor

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

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

经常是这样：

```text
...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 统计的结果：

```text
总段数: 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**）请求

```text
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，它就再也不会把两句粘起来了。

```js
// 保留 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 手里，质量也一般。

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

架构是三层：

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

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

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

```js
// 让 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 精听，或者有更聪明的断句策略，欢迎留言交流。
