最近为了看英文片儿练听力,遇到一个很具体的问题:
我并不需要完整的双语字幕,也不想让插件把整屏翻译成中文。我只是希望在 YouTube 上看英文字幕时,遇到不认识的词,能暂停、划词、点击按钮,然后自动跳转搜索这个词的中文意思。
需求看起来很小:
保留 YouTube 原来的英文字幕;
暂停时能用鼠标划词;
划词后在旁边出现一个“查词”按钮;
点击后打开新窗口搜索这个词;
不影响视频播放和原字幕显示。
但实际写下来才发现,这个小脚本涉及的不只是 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。
二、第一步:确认字幕到底是什么
写脚本之前,第一件事不是动手,而是判断字幕的实现方式。
在线视频字幕大致有几类:
烧录字幕:字幕已经嵌在视频画面里,网页拿不到文字;
canvas 字幕:字幕由播放器绘制,DOM 里没有可选文本;
DOM 字幕:字幕以 HTML 元素形式存在,脚本可以读取;
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();但如果在 mousedown 或 mousemove 上乱用 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,而不是 mousedown 或 mousemove。
也就是说:
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 不能简单阻止所有点击
一种简单想法是:直接禁掉字幕区域所有点击。
但这也有潜在副作用。比如未来如果字幕菜单、播放器控制层、快捷操作等也依赖点击,粗暴拦截可能会带来其他问题。
所以更稳的做法是:
只有当点击发生在字幕区域时才处理;
如果选词前视频本来是暂停的,就保持暂停;
如果选词前视频本来就在播放,不强行暂停。
这就需要记录一次状态。
脚本里新增一个变量:
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 拉回去七、最终脚本结构拆解
最终脚本看起来不长,但它已经不是一个简单的“查词按钮脚本”了。
它实际上由五个模块组成:
搜索地址构造;
字幕区域识别;
字幕可选中 CSS;
鼠标与拖拽事件处理;
查词按钮与播放状态保护。
下面逐层拆开。
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 对象里,anchorNode 和 focusNode 很可能是文本节点,而文本节点没有 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() 很强,好像哪里有问题就可以加哪里。
但这次正好说明:
强不代表适合。
如果在 mousedown 或 mousemove 上阻止默认行为,浏览器的文字选择能力可能直接消失。
因为选中文字本身就是浏览器默认行为的一部分。
正确做法是:
mousedown/mousemove:保留默认行为,用来划词
dragstart:取消默认行为,用来阻止拖拽
click:取消默认行为,用来阻止播放切换这个阶段划分比代码本身更重要。
8.3 网页不是静态页面,而是一个复杂应用
普通网页文本选择很简单。
但 YouTube 播放器不是普通网页,它是一个复杂 Web App:
字幕层有自己的显示逻辑;
字幕窗口可能支持拖动;
视频区域点击会切换播放状态;
控制栏会响应鼠标事件;
播放器内部可能有捕获阶段和冒泡阶段的监听器;
字幕内容还会不断更新。
所以一个看似简单的需求:
让我选中字幕里的一个词
实际上会和播放器已有逻辑冲突。
这也是为什么很多通用划词插件在这种页面上失效。
不是词典插件不够强,而是字幕并不总是普通网页文本。
8.4 透明 overlay 方案为什么被放弃
中间试过透明 overlay 方案。
逻辑是:
原字幕负责显示
透明层复制字幕文本
用户实际选中透明层这个方案技术上可行,而且能绕开 YouTube 字幕拖动逻辑。
但它有两个体验问题:
选中时容易和原字幕形成重影;
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 增加页面内释义弹窗
现在脚本打开新窗口搜索,简单、稳定。
但更理想的体验是:
划词
↓
弹出小卡片
↓
显示中文释义
↓
不离开视频页面这需要词典数据来源。
可能路线有:
调用公开词典 API;
请求翻译服务;
把查询页面嵌入 iframe;
本地维护常见词典数据。
但这会引入复杂问题:
跨域;
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;
});
})();