Peter
Peter
发布于 2026-05-29 / 3 阅读
0
0

尝试写了个Youtube字幕查词脚本

最近为了看英文片儿练听力,遇到一个很具体的问题:

我并不需要完整的双语字幕,也不想让插件把整屏翻译成中文。我只是希望在 YouTube 上看英文字幕时,遇到不认识的词,能暂停、划词、点击按钮,然后自动跳转搜索这个词的中文意思。

需求看起来很小:

  1. 保留 YouTube 原来的英文字幕;

  2. 暂停时能用鼠标划词;

  3. 划词后在旁边出现一个“查词”按钮;

  4. 点击后打开新窗口搜索这个词;

  5. 不影响视频播放和原字幕显示。

但实际写下来才发现,这个小脚本涉及的不只是 DOM 操作,还牵扯到浏览器文本选择、HTML 原生拖拽、YouTube 播放器事件、字幕窗口拖动、播放暂停切换等多个机制。


一、起因:我只是想边看英文动画边查词

这次需求来自一个很普通的使用场景。

我在 YouTube Primetime Channels 里看 Paramount+ 的英文动画,比如 The Fairly OddParents 和 SpongeBob。因为主要目的是练听力,所以我希望保留英文字幕,而不是直接看中文字幕。

我的理想工作流是:

看英文字幕
↓
遇到不认识的词
↓
暂停
↓
鼠标划词
↓
点击“查词”
↓
跳转搜索中文意思

这和传统“双语字幕插件”的需求不一样。

我不希望屏幕上一直出现中文翻译,因为那样会让注意力自动转向中文。对听力训练来说,我更需要的是:

英文音频 → 英文字幕 → 场景理解

而不是:

英文音频 → 中文字幕 → 中文理解

所以我只需要一个低干扰的查词入口。

1.1 现有插件的问题

一开始当然不是直接写脚本,而是先试现成工具。

试过几个方向:

  • 沙拉查词;

  • Language Reactor;

  • Google Dictionary;

  • 其他双语字幕插件。

结果不太理想。

沙拉查词在普通网页上很好用,但对 YouTube 当前播放器里的字幕没有反应。Language Reactor 对普通 YouTube 视频支持不错,但在 YouTube Primetime Channels 里的 Paramount+ 内容上加载不出字幕。Google Dictionary 虽然能查词,但需要复制、粘贴、搜索,操作链路太长。

这说明问题不是“没有词典”,而是字幕层本身没有被这些插件正确识别。

于是需求变成:

能不能针对 YouTube 字幕 DOM 写一个轻量脚本,只做一件事:暂停后划词查词?

这个目标很明确,也适合 Tampermonkey。


二、第一步:确认字幕到底是什么

写脚本之前,第一件事不是动手,而是判断字幕的实现方式。

在线视频字幕大致有几类:

  1. 烧录字幕:字幕已经嵌在视频画面里,网页拿不到文字;

  2. canvas 字幕:字幕由播放器绘制,DOM 里没有可选文本;

  3. DOM 字幕:字幕以 HTML 元素形式存在,脚本可以读取;

  4. Shadow DOM 或封闭组件字幕:能看到,但不一定容易操作。

如果是前两种,就很难靠普通脚本实现划词。 如果是第三种,问题就有解。

我打开浏览器开发者工具,检查 YouTube 字幕区域,发现某个字幕片段是这样的:

<span class="ytp-caption-segment" style="display: inline-block; white-space: pre-wrap;">
  YEP. THIS YEAR,
</span>

关键点是:

<span class="ytp-caption-segment">

这说明 YouTube 字幕不是烧录进视频,也不是 canvas 绘制,而是真实存在于 DOM 树里的文本节点。

接着在控制台验证:

Array.from(document.querySelectorAll('.ytp-caption-segment'))
  .map(e => e.innerText)
  .join(' ')

如果控制台能输出当前字幕内容,就说明脚本可以直接读取字幕文本。

这一步非常关键。

因为后续所有方案都建立在一个前提上:

字幕是 DOM 文本,而不是视频画面的一部分。

如果这个前提不成立,后面所有 CSS 和事件处理都没有意义。

2.1 三条可行路线

确认字幕是 DOM 文本后,理论上有三种路线:

第一种,直接改 YouTube 原字幕层,让它可以被选中。

优点是体验最自然,看到什么就选什么。 缺点是可能和 YouTube 自带的字幕交互逻辑冲突。

第二种,做一个透明 overlay,把当前字幕复制到一层透明文本上。原字幕负责显示,透明层负责选择。

优点是稳定,不会触碰 YouTube 原字幕。 缺点是容易出现选中重影、位置偏差、同步问题。

第三种,不允许用户划词,而是双击字幕后根据鼠标位置取单词。

优点是简单稳定。 缺点是不能查短语,体验不符合“划词查词”。

实际测试后,第三种很快被排除。它只能查单词,不适合短语。第二种也能用,但选中时会有视觉重影,不够自然。

最终选择第一种:

直接让原字幕支持划词。


三、第二步:让 YouTube 原字幕可以被选中

YouTube 原字幕默认是显示层,不是文本输入层。它的设计目标是“看字幕”,不是“操作字幕”。

所以正常情况下,鼠标在字幕上拖动,并不会像网页段落那样选中文字。

要改变这一点,需要修改字幕元素的 CSS。

核心 CSS 是:

.ytp-caption-segment {
  user-select: text !important;
  -webkit-user-select: text !important;
  pointer-events: auto !important;
  cursor: text !important;
}
​
.caption-window,
.ytp-caption-window-container {
  user-select: text !important;
  -webkit-user-select: text !important;
  pointer-events: auto !important;
}

这里有两个关键属性。

第一个是:

user-select: text;

它告诉浏览器:这个元素里的文本允许被选中。

第二个是:

pointer-events: auto;

它告诉浏览器:这个元素可以接收鼠标事件。

YouTube 字幕原本更像一个纯显示层,鼠标事件可能不会落到字幕文本上。把 pointer-events 改成 auto 后,鼠标才能真正作用到字幕区域。

做到这里,字幕确实可以被选中了。

但新问题马上出现。

3.1 第一个副作用:字幕窗口被拖动

YouTube 的字幕窗口本身支持拖动位置。

这件事平时不明显,因为字幕默认不是给你划词用的。但一旦我们把字幕变成可交互区域,YouTube 就会收到鼠标事件。

划词动作本质上是:

mousedown → mousemove → mouseup

而拖动字幕窗口也是:

mousedown → mousemove → mouseup

浏览器认为你在选中文字,YouTube 认为你在移动字幕窗口。

所以当我尝试上下拖动鼠标跨行选词时,字幕窗口会跟着上下移动。

这说明问题已经不是简单的 CSS 问题,而是事件系统冲突。


四、第三步:浏览器划词与 YouTube 拖字幕的冲突

这次调试中最关键的认识是:

同一组鼠标事件,会被浏览器和网页应用同时解释。

浏览器有自己的默认行为。 YouTube 播放器也有自己的事件监听器。

当鼠标按在字幕上时,浏览器可能想做文本选择,YouTube 可能想做字幕拖动。两者都依赖同一组事件:

pointerdown / mousedown
pointermove / mousemove
pointerup / mouseup

所以解决方式不能简单粗暴。

4.1 为什么不能直接 preventDefault

一种直觉做法是:既然 YouTube 会拖字幕,那就阻止默认行为。

例如:

e.preventDefault();

但如果在 mousedownmousemove 上乱用 preventDefault(),浏览器的原生文本选择也会被破坏。

换句话说,这样可能确实阻止了字幕移动,但你也不能划词了。

所以这里要区分两个方法:

e.stopPropagation();

和:

e.preventDefault();

它们不是一回事。

stopPropagation() 的意思是:阻止事件继续传播给后续监听器。 preventDefault() 的意思是:取消浏览器默认行为。

在这个阶段,我们需要保留浏览器默认划词能力,所以不能随便 preventDefault()。更合适的做法是:

保留事件默认行为,但阻止事件继续传播给 YouTube 的拖动逻辑。

于是脚本里使用了一个状态变量:

let selectingCaption = false;

当鼠标左键按在字幕区域时,标记正在选择字幕:

document.addEventListener('mousedown', e => {
  if (e.button === 0 && isCaptionTarget(e.target)) {
    selectingCaption = true;
    e.stopPropagation();
  }
}, true);

在移动过程中,如果正在选择字幕,就阻止事件继续传播:

document.addEventListener('mousemove', e => {
  if (selectingCaption && e.buttons === 1) {
    e.stopPropagation();
  }
}, true);

注意这里没有使用 preventDefault()

这样浏览器仍然可以完成文本选择,但 YouTube 尽量收不到“用户在拖字幕”的鼠标移动事件。

4.2 为什么用捕获阶段

事件监听最后一个参数写了 true

document.addEventListener('mousemove', handler, true);

这表示监听器运行在捕获阶段。

浏览器事件传播大致分为:

捕获阶段 → 目标阶段 → 冒泡阶段

YouTube 自己也会在某些阶段监听播放器事件。我们希望尽早拦截字幕区域的鼠标事件,所以把监听器放到捕获阶段。

这一步之后,字幕上下移动的问题有所缓解,但并没有完全结束。


五、第四步:真正卡住的问题——禁止拖动图标

在继续测试时,又出现一个更奇怪的现象:

单击按住字幕并拖动时,浏览器出现了“禁止拖动”的图标。

这个现象一开始容易误判,以为还是 YouTube 字幕拖动。但它其实是另一个机制:

浏览器进入了 HTML 原生 drag-and-drop 拖拽态。

也就是说,浏览器不再只是认为你在选中文字,而是开始认为你在拖动某个文本或元素。

这和前面的字幕上下移动不是同一个问题。

前面的问题是 YouTube 收到了鼠标移动事件。 这里的问题是浏览器启动了原生拖拽流程。

原生拖拽流程大致是:

mousedown
mousemove
dragstart
drag
dragover
drop

之前的脚本只做了:

e.stopPropagation();

但这只能阻止事件继续传播,不能取消浏览器自己的拖拽默认行为。

所以关键修复点是:

document.addEventListener('dragstart', e => {
  if (selectingCaption || isCaptionTarget(e.target)) {
    e.preventDefault();
    e.stopPropagation();
  }
}, true);

这里终于可以使用 preventDefault()

原因是它作用在 dragstart,而不是 mousedownmousemove

也就是说:

  • mousedown / mousemove 保留,用来完成文本选择;

  • dragstart 取消,用来阻止浏览器进入拖拽态;

  • stopPropagation() 保留,用来减少 YouTube 拿到相关事件。

同时加一层 CSS:

-webkit-user-drag: none !important;

完整写法类似:

.ytp-caption-segment {
  -webkit-user-drag: none !important;
}
​
.caption-window,
.ytp-caption-window-container {
  -webkit-user-drag: none !important;
}

这相当于从 CSS 层面对 Chromium 说:

这个字幕元素不应该被当成可拖拽对象。

最终,真正解决“禁止拖动图标”的关键不是阻止鼠标移动,而是取消 dragstart 的默认行为。

这一点也是这次调试里最有价值的地方。

很多时候,看起来相似的拖动问题,背后可能是两个完全不同的机制:

字幕上下移动 = YouTube 播放器拖动字幕窗口
禁止拖动图标 = 浏览器原生 drag-and-drop 状态

对应的解决方式也不同。

前者主要靠:

stopPropagation()

后者必须靠:

preventDefault()

preventDefault() 必须放在正确阶段,否则就会把划词能力一起破坏。


六、第五步:解决松开鼠标后视频自动播放

解决了“字幕可选中”“字幕窗口拖动”“禁止拖动图标”之后,脚本已经接近可用了。

但继续测试时,又出现一个新问题:

视频本来是暂停的,我在字幕上划词,松开鼠标后,视频自动恢复播放。

这不是查词逻辑的问题,而是 YouTube 播放器的点击逻辑在接管。

6.1 问题根源:播放器把字幕区域当成点击播放区域

YouTube 播放器里有一个常见交互:

点击视频画面 → 切换播放 / 暂停

字幕层虽然显示在视频上方,但它仍然处在播放器区域内。

所以当我执行:

mousedown → mousemove → mouseup

完成划词后,浏览器可能还会生成一次 click 事件。这个 click 如果继续传给 YouTube 播放器,就会被解释成:

用户点击了视频,应该切换播放状态。

于是视频从暂停变成播放。

这个现象说明:即使划词和拖拽问题解决了,播放器自身的点击行为仍然需要处理。

6.2 不能简单阻止所有点击

一种简单想法是:直接禁掉字幕区域所有点击。

但这也有潜在副作用。比如未来如果字幕菜单、播放器控制层、快捷操作等也依赖点击,粗暴拦截可能会带来其他问题。

所以更稳的做法是:

  1. 只有当点击发生在字幕区域时才处理;

  2. 如果选词前视频本来是暂停的,就保持暂停;

  3. 如果选词前视频本来就在播放,不强行暂停。

这就需要记录一次状态。

脚本里新增一个变量:

let videoWasPausedBeforeCaptionSelect = false;

然后写一个获取当前视频元素的函数:

function getVideo() {
  return document.querySelector('video');
}

在用户按下字幕区域时记录视频状态:

const video = getVideo();
videoWasPausedBeforeCaptionSelect = !!video?.paused;

这一步的作用是:

判断这次字幕选择开始前,视频到底是不是暂停的。

6.3 拦截字幕区域 click

接下来,在捕获阶段拦截字幕区域的 click

document.addEventListener('click', e => {
  if (isCaptionTarget(e.target)) {
    e.preventDefault();
    e.stopImmediatePropagation();
    keepVideoPausedIfNeeded();
  }
}, true);

这里用了两个动作。

第一个:

e.preventDefault();

取消浏览器默认点击行为。

第二个:

e.stopImmediatePropagation();

阻止事件继续传递给后续监听器。相比 stopPropagation(),它更强,不仅阻止事件向后传播,也会阻止同一阶段后续监听器执行。

这里使用更强的拦截是合理的,因为:

字幕查词时,这个 click 不应该再被播放器解释成播放/暂停操作。

6.4 兜底:强制保持暂停

只拦截事件还不够稳。

因为 YouTube 内部监听器可能已经在某些阶段先一步触发了播放。为了兜底,脚本还加了一个函数:

function keepVideoPausedIfNeeded() {
  const video = getVideo();
  if (videoWasPausedBeforeCaptionSelect && video && !video.paused) {
    video.pause();
  }
}

意思是:

如果选词前视频是暂停的
并且现在视频变成播放了
那就立刻 pause()

这个设计很重要。

它不是无条件暂停视频,而是只恢复用户原本的状态。

如果用户本来是在播放状态下误操作字幕,脚本不会强行暂停。 如果用户本来暂停了视频准备查词,脚本会确保 YouTube 不会把它自动恢复播放。

6.5 为什么要在多个时间点调用

最终脚本中,keepVideoPausedIfNeeded() 不只调用一次,而是在几个关键阶段都调用:

click
mouseup 后的 setTimeout
pointerup 后的 setTimeout
查词按钮点击后

这看起来有些重复,但实际是为了应对 YouTube 播放器内部时序。

播放器可能在:

  • mouseup 后触发状态切换;

  • click 阶段触发状态切换;

  • 某些异步逻辑中延迟触发播放。

所以脚本做了几次轻量兜底:

setTimeout(() => {
  keepVideoPausedIfNeeded();
}, 30);

以及:

setTimeout(() => {
  keepVideoPausedIfNeeded();
}, 120);

这不是优雅的理论方案,但对浏览器脚本来说很实用。

网页播放器经常有复杂的内部事件顺序,外部脚本无法完全控制,只能在关键节点做状态校正。

最终这个问题的解决逻辑可以概括为:

记录原状态
↓
拦截字幕 click
↓
如果播放器误恢复播放,就 pause 拉回去

七、最终脚本结构拆解

最终脚本看起来不长,但它已经不是一个简单的“查词按钮脚本”了。

它实际上由五个模块组成:

  1. 搜索地址构造;

  2. 字幕区域识别;

  3. 字幕可选中 CSS;

  4. 鼠标与拖拽事件处理;

  5. 查词按钮与播放状态保护。

下面逐层拆开。

7.1 搜索地址构造

最简单的查词方式不是接词典 API,而是直接打开搜索页面。

这次使用的是 Google 搜索:

function buildSearchUrl(text) {
  return 'https://www.google.com/search?q='
    + encodeURIComponent(text + ' 中文意思 英语');
}

例如选中:

possibly

最终搜索:

possibly 中文意思 英语

这个方式的优点是:

  • 不需要 API;

  • 不需要登录;

  • 不需要处理跨域;

  • 单词和短语都能查;

  • 结果页容错强。

缺点是:

  • 不是内嵌释义;

  • 会打开新标签页;

  • 依赖搜索引擎结果质量。

如果后续想改成有道、欧路、Cambridge,也只需要替换这个函数。

例如有道可以写成:

function buildSearchUrl(text) {
  return 'https://www.youdao.com/result?word='
    + encodeURIComponent(text)
    + '&lang=en';
}

这就是把“查词服务”从脚本主体中抽离出来的好处。

7.2 字幕区域识别

脚本需要判断鼠标事件是不是发生在字幕区域。

核心函数是:

function isCaptionTarget(target) {
  return !!target?.closest?.(
    '.ytp-caption-segment, .caption-window, .ytp-caption-window-container'
  );
}

这三个 selector 分别覆盖:

.ytp-caption-segment          单个字幕文本片段
.caption-window               字幕窗口
.ytp-caption-window-container 字幕窗口容器

为什么不只判断 .ytp-caption-segment

因为鼠标事件不一定总是落在最内部的文字 span 上。 有时可能落在字幕窗口或容器上。

所以判断范围要覆盖整个字幕区域。

7.3 判断当前选区是否来自字幕

划词结束后,脚本需要判断用户选中的文本是不是来自字幕,而不是页面其他地方。

相关函数是:

function nodeToElement(node) {
  if (!node) return null;
  return node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
}

以及:

function isSelectionFromCaption(selection) {
  if (!selection || selection.rangeCount === 0) return false;

  const anchorEl = nodeToElement(selection.anchorNode);
  const focusEl = nodeToElement(selection.focusNode);

  return isCaptionTarget(anchorEl) || isCaptionTarget(focusEl);
}

浏览器的 Selection 对象里,anchorNodefocusNode 很可能是文本节点,而文本节点没有 closest() 方法。

所以需要先把文本节点转换成它的父元素,再判断是否属于字幕区域。

7.4 清理选中文本

字幕文本可能包含:

  • 换行;

  • 多余空格;

  • 中文标点;

  • 英文引号;

  • 句末符号。

所以需要清理:

function cleanText(text) {
  return text
    .replace(/[“”"]/g, '')
    .replace(/[。!?;:,、]/g, '')
    .replace(/[\n\r]+/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
}

这个函数不做复杂 NLP,只做轻量清洗。

例如:

"What could possibly go wrong?"

会变成:

What could possibly go wrong?

保留英文问号是否删除,可以按个人习惯调整。

7.5 显示查词按钮

用户松开鼠标后,脚本读取当前选区:

const selection = window.getSelection();
const raw = selection ? selection.toString() : '';

如果选区有效,就在选区附近显示按钮:

const rect = selection.getRangeAt(0).getBoundingClientRect();

btn.style.left = `${Math.max(8, rect.left)}px`;
btn.style.top = `${Math.max(8, rect.top - 42)}px`;
btn.style.display = 'block';

这里使用 getBoundingClientRect() 获取选区位置。

按钮显示在选区上方一点:

rect.top - 42

同时使用:

Math.max(8, ...)

避免按钮跑到浏览器窗口外。

按钮文本做了截断:

selectedText.length > 24
  ? selectedText.slice(0, 24) + '…'
  : selectedText

否则如果选中一整句,按钮会变得很长。

7.6 查词按钮点击

点击按钮后:

window.open(
  buildSearchUrl(selectedText),
  '_blank',
  'noopener,noreferrer'
);

然后隐藏按钮,清除选区:

btn.style.display = 'none';
window.getSelection()?.removeAllRanges();

按钮自己的事件也必须拦截:

btn.addEventListener('click', e => {
  e.preventDefault();
  e.stopImmediatePropagation();
});

原因是按钮显示在播放器上方,如果点击事件穿透或继续传播,可能又被 YouTube 播放器接管。


八、这次调试真正学到的东西

这个脚本最终代码不算大,但调试过程很有价值。

因为它集中展示了浏览器前端里几个容易混淆的机制。

8.1 stopPropagation()preventDefault() 不是一回事

这次最重要的分界是:

stopPropagation()

和:

preventDefault()

它们经常被初学者混用,但这次案例里区别非常明显。

stopPropagation() 解决的是:

事件不要继续传给 YouTube。

它适合处理:

YouTube 把 mousemove 当成拖动字幕

所以用在:

pointermove
mousemove

preventDefault() 解决的是:

浏览器不要执行默认行为。

它适合处理:

浏览器进入原生 drag-and-drop 拖拽态

所以用在:

dragstart

如果把这两个用反,问题就会变复杂。

8.2 为什么不能到处 preventDefault

直觉上,preventDefault() 很强,好像哪里有问题就可以加哪里。

但这次正好说明:

强不代表适合。

如果在 mousedownmousemove 上阻止默认行为,浏览器的文字选择能力可能直接消失。

因为选中文字本身就是浏览器默认行为的一部分。

正确做法是:

mousedown/mousemove:保留默认行为,用来划词
dragstart:取消默认行为,用来阻止拖拽
click:取消默认行为,用来阻止播放切换

这个阶段划分比代码本身更重要。

8.3 网页不是静态页面,而是一个复杂应用

普通网页文本选择很简单。

但 YouTube 播放器不是普通网页,它是一个复杂 Web App:

  • 字幕层有自己的显示逻辑;

  • 字幕窗口可能支持拖动;

  • 视频区域点击会切换播放状态;

  • 控制栏会响应鼠标事件;

  • 播放器内部可能有捕获阶段和冒泡阶段的监听器;

  • 字幕内容还会不断更新。

所以一个看似简单的需求:

让我选中字幕里的一个词

实际上会和播放器已有逻辑冲突。

这也是为什么很多通用划词插件在这种页面上失效。

不是词典插件不够强,而是字幕并不总是普通网页文本。

8.4 透明 overlay 方案为什么被放弃

中间试过透明 overlay 方案。

逻辑是:

原字幕负责显示
透明层复制字幕文本
用户实际选中透明层

这个方案技术上可行,而且能绕开 YouTube 字幕拖动逻辑。

但它有两个体验问题:

  1. 选中时容易和原字幕形成重影;

  2. overlay 位置需要不断和字幕位置同步。

尤其字幕字号、行高、位置、窗口缩放都会影响 overlay 对齐。

所以最终还是回到原字幕直选方案。

原字幕直选更难调,但一旦解决事件冲突,体验更自然。

8.5 双击取词方案为什么也被放弃

也试过双击字幕取词。

脚本通过鼠标位置和 caretRangeFromPoint() 识别当前单词。

这个方案优点是稳定,不需要划选。

但缺点也明显:

只能查单词
不适合查短语

而看英文字幕时,真正难的经常不是单个词,而是短语:

give me a break
what could possibly go wrong
get away with
come up with

这些表达不能只靠双击一个单词解决。

所以双击取词方案不适合这次需求。


九、可以继续优化的方向

当前脚本已经满足核心需求:

暂停
↓
划词
↓
点击查词
↓
打开搜索
↓
视频保持暂停

但如果继续做,可以扩展几个方向。

9.1 增加搜索引擎切换

现在搜索方式写死为 Google:

text + ' 中文意思 英语'

可以做成配置项:

const SEARCH_PROVIDER = 'google';

然后支持:

  • Google;

  • 有道词典;

  • 欧路词典;

  • Cambridge Dictionary;

  • DeepL;

  • Google Translate。

例如:

const providers = {
  google: text => 'https://www.google.com/search?q='
    + encodeURIComponent(text + ' 中文意思 英语'),

  youdao: text => 'https://www.youdao.com/result?word='
    + encodeURIComponent(text)
    + '&lang=en',

  eudic: text => 'https://dict.eudic.net/dicts/en/'
    + encodeURIComponent(text)
};

这样以后只需要改一个变量。

9.2 增加快捷键

可以加入几个轻量快捷键:

Enter:直接查词
Esc:取消选区
Alt + 鼠标划词:只在按住 Alt 时启用查词

其中 Alt + 鼠标划词 可能很实用。

因为它能减少和 YouTube 默认交互的冲突。

逻辑是:

if (e.altKey && isCaptionTarget(e.target)) {
  selectingCaption = true;
}

这样平时字幕仍然保持普通状态,只有按住 Alt 时才进入查词模式。

9.3 增加页面内释义弹窗

现在脚本打开新窗口搜索,简单、稳定。

但更理想的体验是:

划词
↓
弹出小卡片
↓
显示中文释义
↓
不离开视频页面

这需要词典数据来源。

可能路线有:

  1. 调用公开词典 API;

  2. 请求翻译服务;

  3. 把查询页面嵌入 iframe;

  4. 本地维护常见词典数据。

但这会引入复杂问题:

  • 跨域;

  • API 限制;

  • 请求频率;

  • 翻译质量;

  • 页面样式;

  • 隐私与稳定性。

从实用角度看,现阶段打开新标签页反而更稳。

9.4 支持字幕历史

另一个值得做的方向是保留最近几句字幕。

例如在右侧显示:

上一句
当前句
下一句

这样用户可以回看刚刚错过的句子。

实现方式也不复杂:监听 .ytp-caption-segment 文本变化,把新字幕存入数组。

伪代码:

const captionHistory = [];

setInterval(() => {
  const text = getCurrentCaptionText();
  if (text && text !== captionHistory.at(-1)) {
    captionHistory.push(text);
  }
}, 300);

但这会让脚本从“查词工具”变成“字幕学习工具”,范围会扩大。

9.5 适配其他视频网站

这个脚本目前针对 YouTube。

其他平台不一定能直接用,因为字幕实现方式可能完全不同。

例如:

  • Netflix 可能用自己的字幕容器;

  • Disney+ 可能有更强的播放器封装;

  • Prime Video 可能有 DRM 与封闭字幕层;

  • Paramount+ 官网可能和 YouTube Primetime Channels 实现不同。

适配其他平台时,第一步仍然是:

检查字幕是不是 DOM 文本

如果字幕不是 DOM,就不能照搬这套方法。


十、结尾:一次小脚本背后的浏览器机制

这次经历表面上是在写一个 YouTube 字幕查词脚本。

但真正调试的是:

  • DOM 文本识别;

  • CSS 交互控制;

  • 浏览器原生文本选择;

  • HTML drag-and-drop;

  • 事件捕获与传播;

  • YouTube 播放器点击逻辑;

  • 视频播放状态保护。

最终脚本能跑通,不是因为代码很复杂,而是因为每个问题都被拆到了对应的机制层。

可以把整个过程概括成这样:

字幕不能选中
→ 处理 user-select / pointer-events
​
字幕会上下动
→ 处理 mousemove / pointermove 的事件传播
​
出现禁止拖动图标
→ 处理 dragstart 的默认拖拽行为
​
松开鼠标后自动播放
→ 处理 click 事件和 video.pause() 状态恢复

这次最重要的经验是:

不要看到事件问题就乱加 preventDefault,也不要看到播放器冲突就一味 stopPropagation。必须先判断当前冲突属于浏览器默认行为,还是网页应用自己的事件逻辑。

如果是浏览器默认行为,就考虑 preventDefault()。 如果是网页应用自己的监听器,就考虑 stopPropagation()stopImmediatePropagation()。 如果两者叠在一起,就必须分阶段处理。

小脚本的问题往往不在代码量,而在你是否真的理解网页本身正在做什么。

这也是我喜欢折腾这类东西的原因:一个具体到不能再具体的小需求,最后往往会暴露出一整套底层机制。


附相关脚本:

// ==UserScript==
// @name         YouTube Native Caption Select Search
// @namespace    yt-native-caption-select-search
// @version      1.5
// @description  Select original YouTube captions, prevent drag mode, and search selected text without resuming video
// @author       loopnull & ChatGPT
// @match        https://www.youtube.com/*
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  function buildSearchUrl(text) {
    return 'https://www.google.com/search?q='
      + encodeURIComponent(text + '的简中意思');
  }

  const style = document.createElement('style');
  style.textContent = `
    .ytp-caption-segment {
      user-select: text !important;
      -webkit-user-select: text !important;
      pointer-events: auto !important;
      cursor: text !important;
      -webkit-user-drag: none !important;
    }

    .caption-window,
    .ytp-caption-window-container {
      user-select: text !important;
      -webkit-user-select: text !important;
      pointer-events: auto !important;
      -webkit-user-drag: none !important;
    }

    #yt-caption-search-btn {
      position: fixed;
      z-index: 999999999;
      display: none;
      padding: 6px 10px;
      font-size: 14px;
      line-height: 1;
      color: white;
      background: rgba(0, 0, 0, 0.88);
      border: 1px solid rgba(255,255,255,0.35);
      border-radius: 6px;
      cursor: pointer;
      user-select: none;
      font-family: Arial, sans-serif;
      max-width: 360px;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }

    #yt-caption-search-btn:hover {
      background: rgba(30, 30, 30, 0.96);
    }
  `;
  document.head.appendChild(style);

  const btn = document.createElement('button');
  btn.id = 'yt-caption-search-btn';
  btn.textContent = '查词';
  document.body.appendChild(btn);

  let selectedText = '';
  let selectingCaption = false;
  let videoWasPausedBeforeCaptionSelect = false;

  function getVideo() {
    return document.querySelector('video');
  }

  function keepVideoPausedIfNeeded() {
    const video = getVideo();
    if (videoWasPausedBeforeCaptionSelect && video && !video.paused) {
      video.pause();
    }
  }

  function cleanText(text) {
    return text
      .replace(/[“”"]/g, '')
      .replace(/[。!?;:,、]/g, '')
      .replace(/[\n\r]+/g, ' ')
      .replace(/\s+/g, ' ')
      .trim();
  }

  // 检查是否点击在字幕区域
  function isCaptionTarget(target) {
    return !!target?.closest?.(
      '.ytp-caption-segment, .caption-window, .ytp-caption-window-container'
    );
  }

  function nodeToElement(node) {
    if (!node) return null;
    return node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
  }

  function isSelectionFromCaption(selection) {
    if (!selection || selection.rangeCount === 0) return false;

    const anchorEl = nodeToElement(selection.anchorNode);
    const focusEl = nodeToElement(selection.focusNode);

    return isCaptionTarget(anchorEl) || isCaptionTarget(focusEl);
  }

  function showSearchButtonFromSelection() {
    const selection = window.getSelection();
    const raw = selection ? selection.toString() : '';

    if (!raw || !selection.rangeCount || !isSelectionFromCaption(selection)) {
      btn.style.display = 'none';
      return;
    }

    selectedText = cleanText(raw);

    if (!selectedText) {
      btn.style.display = 'none';
      return;
    }

    const rect = selection.getRangeAt(0).getBoundingClientRect();

    btn.textContent = `查词:${
      selectedText.length > 24
        ? selectedText.slice(0, 24) + '…'
        : selectedText
    }`;

    btn.style.left = `${Math.max(8, rect.left)}px`;
    btn.style.top = `${Math.max(8, rect.top - 42)}px`;
    btn.style.display = 'block';
  }

  /*
   * 记录选词前视频是否暂停。
   * 同时阻止事件冒泡给 YouTube,减少字幕拖动和播放切换。
   */
  document.addEventListener('pointerdown', e => {
    if (e.button === 0 && isCaptionTarget(e.target)) {
      const video = getVideo();
      videoWasPausedBeforeCaptionSelect = !!video?.paused;

      selectingCaption = true;
      e.stopPropagation();
    }
  }, true);

  document.addEventListener('mousedown', e => {
    if (e.button === 0 && isCaptionTarget(e.target)) {
      const video = getVideo();
      videoWasPausedBeforeCaptionSelect = !!video?.paused;

      selectingCaption = true;
      e.stopPropagation();
    }
  }, true);

  /*
   * 只在左键按住字幕区域时阻止 move 事件继续传给 YouTube。
   * 不 preventDefault,否则可能破坏浏览器原生划词。
   */
  document.addEventListener('pointermove', e => {
    if (selectingCaption && e.buttons === 1) {
      e.stopPropagation();
    }
  }, true);

  document.addEventListener('mousemove', e => {
    if (selectingCaption && e.buttons === 1) {
      e.stopPropagation();
    }
  }, true);

  /*
   * 关键:取消原生 drag-and-drop 拖拽态。
   * 否则会出现“禁止拖动”图标。
   */
  document.addEventListener('dragstart', e => {
    if (selectingCaption || isCaptionTarget(e.target)) {
      e.preventDefault();
      e.stopPropagation();
    }
  }, true);

  /*
   * 关键:拦截字幕区域 click。
   * 否则 YouTube 可能把 mouseup/click 当成播放器点击,导致自动恢复播放。
   */
  document.addEventListener('click', e => {
    if (isCaptionTarget(e.target)) {
      e.preventDefault();
      e.stopImmediatePropagation();
      keepVideoPausedIfNeeded();
    }
  }, true);

  document.addEventListener('pointerup', () => {
    selectingCaption = false;

    setTimeout(() => {
      keepVideoPausedIfNeeded();
    }, 30);
  }, true);

  document.addEventListener('mouseup', () => {
    selectingCaption = false;

    setTimeout(() => {
      showSearchButtonFromSelection();
      keepVideoPausedIfNeeded();
    }, 30);

    setTimeout(() => {
      keepVideoPausedIfNeeded();
    }, 120);
  }, true);

  document.addEventListener('pointercancel', () => {
    selectingCaption = false;
  }, true);

  /*
   * 查词按钮自身也要完整拦截 pointer/mouse/click,
   * 防止点击按钮时事件穿透到播放器。
   */
  btn.addEventListener('pointerdown', e => {
    e.preventDefault();
    e.stopImmediatePropagation();
  });

  btn.addEventListener('mousedown', e => {
    e.preventDefault();
    e.stopImmediatePropagation();
  });

  btn.addEventListener('mouseup', e => {
    e.preventDefault();
    e.stopImmediatePropagation();
  });

  btn.addEventListener('click', e => {
    e.preventDefault();
    e.stopImmediatePropagation();

    if (!selectedText) return;

    window.open(
      buildSearchUrl(selectedText),
      '_blank',
      'noopener,noreferrer'
    );

    btn.style.display = 'none';
    window.getSelection()?.removeAllRanges();

    keepVideoPausedIfNeeded();
  });

  document.addEventListener('mousedown', e => {
    if (e.target !== btn && !isCaptionTarget(e.target)) {
      btn.style.display = 'none';
    }
  });

  document.addEventListener('keydown', e => {
    if (e.key === 'Escape') {
      btn.style.display = 'none';
      window.getSelection()?.removeAllRanges();
      selectingCaption = false;
    }
  });

  window.addEventListener('blur', () => {
    selectingCaption = false;
  });
})();


评论