作为一个重度网页内容消费者,我经常遇到一个让人抓狂的场景:打开一篇好文章,想摘录几句话,结果发现文字根本选不中,或者一按 Ctrl+C 就弹出"复制功能已被禁用"的提示。

于是我花了一些时间,从零实现了一个 Chrome 扩展 —— Free Copy,一键解除这类限制。

网站是怎么限制复制的?

在动手之前,先搞清楚对手的手段。经过调研,常见的复制限制方案主要有以下几种:

1. CSS user-select: none

最简单粗暴的方式,直接在 CSS 里把文本设为不可选中:

.content {
  user-select: none;
  -webkit-user-select: none;
}

破解方式:注入一段高优先级的 !important 样式覆盖它即可。

2. JavaScript 事件拦截

监听 copyselectstartcontextmenu 等事件,在回调里调用 preventDefault() 阻止默认行为:

document.addEventListener('copy', e => e.preventDefault());
document.addEventListener('selectstart', e => e.preventDefault());
document.oncopy = e => e.preventDefault();

3. 权限型编辑器检测

像飞书文档、快手 Docs 这类平台更复杂——它们在 copy 事件的回调里做权限校验,无权限时弹出提示框。如果我们粗暴地 stopImmediatePropagation,可能触发全局错误被检测到。

4. focus/blur 焦点劫持

将焦点强制锁在某个隐藏元素上,导致页面内容永远无法获得焦点,自然也就无法选中文字。

Free Copy 的破解策略

针对以上四种手段,Free Copy 采用了分层对应的破解方案:

切面 A:CSS 覆盖

在 content script 里注入样式,强制所有元素文字可选:

*,*::before,*::after {
  -webkit-user-select: text !important;
  user-select: text !important;
}

切面 B:让 preventDefault 对特定事件静默失效

在页面所有脚本加载之前,覆盖 Event.prototype.preventDefault

const _origPD = Event.prototype.preventDefault;
Event.prototype.preventDefault = function () {
  if (enabled && ['selectstart', 'contextmenu', 'dragstart'].has(this.type)) {
    return; // 静默丢弃
  }
  return _origPD.call(this);
};

这样网站的 handler 正常执行,业务逻辑不受影响,但 preventDefault() 对我们屏蔽的事件类型不产生效果。

切面 C:copy 事件 capture 阶段拦截

copycut 事件,在 capture 阶段最先执行,调用 stopImmediatePropagation() 阻断后续所有 handler(包括权限检测),但不调用 preventDefault——让浏览器默认把选中内容放入剪贴板。

document.addEventListener('copy', function(e) {
  if (!enabled) return;
  e.stopImmediatePropagation(); // 权限检测 handler 不执行
  // 不 preventDefault → 浏览器正常复制选中内容
}, true /* capture */);

切面 D:WeakMap 安全 + focus 焦点防护

在劫持 EventTarget.prototype.addEventListener 时,必须检查 this 是否可以作为 WeakMap 的 key(必须是对象类型),否则会抛出错误被页面的全局错误监听捕获,暴露插件存在。

同时 patch 了 HTMLElement.prototype.focus,在 blur 事件发生后的 50ms 内屏蔽 focus 调用,防止焦点劫持。

插件架构

整个插件基于 Manifest V3 构建,分为以下几个模块:

  • background.js(Service Worker):管理各 Tab 的开关状态,响应图标点击事件,更新工具栏图标颜色
  • content.js(Isolated World):注入 CSS 样式、将 injected.js 插入到 MAIN World、通过随机 CustomEvent 与主世界通信
  • injected.js(MAIN World):覆盖原生 API,实现上述所有切面拦截

其中一个关键的反检测设计:content.js 每次页面加载都生成一个随机字符串作为通信事件名(如 e7f3k2a1),注入脚本后立即从 DOM 移除 script 标签。这样网站无法通过固定的事件名或 DOM 结构检测到插件的存在。

使用方式

  1. 打开 Chrome,地址栏输入 chrome://extensions/
  2. 右上角开启开发者模式
  3. 点击加载已解压的扩展程序,选择项目目录
  4. 工具栏出现图标后,进入任意限制复制的网站,点击图标(变绿)即可激活

激活后,页面右上角会出现短暂提示,之后可以自由选中并复制任意文本内容。

一些边界情况

  • Canvas / SVG 渲染的文本:无法复制,这类内容根本不是 DOM 文本节点
  • 图片形式的文字:同样无法直接复制,需要 OCR
  • iframe 嵌套内容:content script 配置了 all_frames: true,理论上覆盖 iframe
  • 部分带严格 CSP 的网站:可能阻止 injected.js 加载,这种情况下只有 CSS 覆盖生效

代码已开源(私有仓库):github.com/BAIXIONGSODA/free-copy