浏览器插件开发实战:解锁网页禁复制与图片OCR提取

作者:很菜不狗2026.11.04 05:12浏览量:0

简介:网页禁止选中复制、图片文字提取流程繁琐,是内容工作者的两大高频痛点。本文完整复盘一个浏览器插件从需求到落地的全过程:剖析网站禁用复制的常见技术手段与对应解除方案,梳理截图、框选、上传、拖拽四种图像采集入口,并给出云端OCR接入的架构设计与关键代码,帮你把“看到即可复制、框选即可识别”的体验真正落地。

一、需求背景:两个真实的高频痛点

女朋友是做广告策划的,日常工作是从海量网站和素材库中摘抄文案、整理灵感。理想状态是“看到→摘录→归档”一气呵成,现实里却处处是坎,最典型的两个:

痛点一:看得见、选不了的网页。 设计灵感站、素材站、文库类网站,为了保护内容,普遍禁用了文本选中、右键菜单甚至快捷键复制。文字明明就在屏幕上,就是拿不下来,最后只能照着手敲一遍——效率低还容易出错。

痛点二:图片文字提取链路太长。 想从一张图里拿文字,常规做法是:截图→切到聊天或办公软件→用它自带的“提取文字”功能→等识别→复制→再切回浏览器粘贴。一张图,来回切换三次,思路就是在这种切换里被打断的。

某天晚上她在赶方案,一边操作一边念叨“太麻烦了,思路全断了”。我说:“要不我给你写个插件?”

于是用周末两天做了这个「图文解锁器」,目标三个:

  1. 一键解除网页限制——禁选中、禁右键的页面,点一下就能正常选中与复制;
  2. 浏览器内直接框选识别——不用切出浏览器,看到哪里框哪里,几秒出结果;
  3. 所有操作在侧边栏完成——不遮挡页面、不用来回切窗口。

用上之后的评价是:“终于顺手了,那些禁止复制的网站现在随便复制,图片识别也不用跳来跳去了。”下面完整拆解技术方案与实现细节。

二、目标网站是如何“禁止复制”的

要解除限制,先要理解限制是怎么做的。主流站点的封锁手段基本是以下几层的组合:

  1. 内联事件拦截:在元素上直接写oncopy="return false"、oncontextmenu="return false"、onselectstart="return false",古老但依然常见;
  2. 脚本批量绑定:通过addEventListener在document上监听copy、cut、contextmenu、selectstart、mousedown、keydown(专门拦截复制与全选快捷键),回调里统一preventDefault();
  3. CSS封锁:user-select: none一行就能让整页不可选中;
  4. 遮罩层:在文字上叠一层透明元素,鼠标点击永远落在遮罩上,选区无从谈起;
  5. 动态加锁:内容异步加载,或页面是单页应用,脚本会在路由切换、节点插入后给新内容重新上锁;
  6. 更底层的防护:少数文库类站点把文字画到canvas上渲染,或用自定义字体打乱字符映射——这种情况下DOM里根本没有可直接复制的文本。

前五类都可以从扩展侧对抗;第六类属于“降维打击”,唯一通用的解法反而是截图+OCR。这正是把「解锁」和「识别」两件事放在一个插件里的原因:一个负责恢复页面的原生能力,一个在原生能力不可恢复时兜底。

三、一键解除限制的实现

扩展的内容脚本(content script)运行在页面上下文里,可以操作完整DOM,这给了解锁的切入点。策略分四步:

第一步:用最高优先级的CSS覆盖选中封锁。

  1. *, *::before, *::after {
  2. -webkit-user-select: text !important;
  3. user-select: text !important;
  4. }

第二步:清空内联处理器。 遍历所有元素,把内联属性显式置空,同时递归穿透Shadow DOM:

  1. function clearInlineHandlers(root) {
  2. root.oncopy = root.oncut = root.oncontextmenu = root.onselectstart = null;
  3. for (const el of root.querySelectorAll('*')) {
  4. el.oncopy = el.oncut = el.oncontextmenu = el.onselectstart = null;
  5. if (el.shadowRoot) clearInlineHandlers(el.shadowRoot);
  6. }
  7. }

第三步:捕获阶段拦截,“截断”站点的监听器。
addEventListener绑定的监听器无法逐个摘除,但事件传播是有顺序的:捕获阶段从window一路向下。绝大多数站点把监听器绑在document或更深的节点上,因此在window上以capture: true注册同名事件并stopPropagation(),站点绑在传播路径后段的监听器就永远不会执行:

  1. const EVENTS = ['copy', 'cut', 'contextmenu', 'selectstart', 'dragstart'];
  2. for (const type of EVENTS) {
  3. window.addEventListener(type, e => e.stopPropagation(), true);
  4. }

第四步:MutationObserver兜底动态内容。 对异步插入的节点,用MutationObserver监听childList + subtree,变化后重新执行内联处理器清理:

  1. new MutationObserver(() => clearInlineHandlers(document))
  2. .observe(document.documentElement, { childList: true, subtree: true });

三个工程取舍值得说明:

  • 不碰遮罩层与pointer-events。理论上给全页加pointer-events: auto !important也能“解决”遮罩,但会顺手破坏大量弹窗、下拉等正常交互。方案只精确放开「选中」和「复制」两个能力,其他一概不动。
  • keydown是否纳入拦截要做取舍:部分站点在keydown里封锁快捷键,纳入拦截能解除封锁,但也会屏蔽站点自身的快捷键逻辑,因此建议仅在确认站点存在复制封锁时启用。
  • 极少数站点在window上更早注册了监听器,此时stopPropagation拦不住(同一目标上的监听器按注册顺序执行)。暴力解法是克隆并替换根节点、甩掉所有监听器,但这会摧毁页面脚本持有的引用(使用虚拟DOM的页面会直接白屏),因此默认不启用,遇到时用“截图识别”兜底。

最后补一句合规提醒:解除复制限制的正当用途是合法的资料摘录与个人学习,请尊重内容版权与站点协议,不要用于批量抓取或侵权场景。

四、图像采集:四种入口的设计

OCR的第一步是把图拿进来。按使用场景设计了四个入口:

入口 采集方式 适用场景
页面截图 一键截取当前可见区域 整屏内容快速识别
自选区域 按住鼠标拖拽框选 只识别局部特定内容
点击上传 通过文件选择器选图 识别本地图片素材
拖拽上传 把图片文件拖入侧边栏 零点击成本的上传路径

采集之后统一进入「预览→识别→复制/下载」的流程。内置预览不只是体验问题:一是识别前让用户确认内容、避免误框;二是识别属于外部调用,先确认一次就能有效减少无效请求。

技术上各有要点:

1)页面截图。 浏览器为扩展提供了截取可见区域的接口(tabs.captureVisibleTab),返回整屏PNG数据。注意它必须在后台脚本(service worker)中调用,侧边栏通过消息把请求转发给后台,拿到结果再回传展示。

2)自选区域:遮罩+坐标裁剪。 内容脚本注入全屏透明遮罩层,监听mousedown / mousemove / mouseup画出选框,抬起时记录CSS像素坐标;随后取整屏截图,在canvas里按物理像素裁剪:

  1. function cropSelection(fullImage, rect, dpr) {
  2. return new Promise(resolve => {
  3. const img = new Image();
  4. img.onload = () => {
  5. const canvas = document.createElement('canvas');
  6. canvas.width = Math.round(rect.width * dpr);
  7. canvas.height = Math.round(rect.height * dpr);
  8. canvas.getContext('2d').drawImage(
  9. img,
  10. rect.x * dpr, rect.y * dpr, rect.width * dpr, rect.height * dpr,
  11. 0, 0, canvas.width, canvas.height
  12. );
  13. resolve(canvas.toDataURL('image/png'));
  14. };
  15. img.src = fullImage;
  16. });
  17. }

这里的dpr是devicePixelRatio。整屏截图返回的是物理像素,而选框坐标是CSS像素,两者不换算就会裁错区域或得到一张模糊的图——这是本插件开发中最高频的坑。

3)上传与拖拽。 走input[type=file]与dragover / drop事件,用FileReader读成DataURL即可。两个小细节:拖拽进入时给侧边栏高亮反馈;必须显式阻止dragover的默认行为,否则浏览器会直接打开图片文件而不是触发放置。

五、OCR识别:两条技术路线的取舍

图像到手后,识别有两条路线:

路线一:端内识别。 用基于WebAssembly的开源OCR引擎在浏览器里直接运行,全程离线、隐私无忧;代价是首次需加载较大的语言模型,且复杂版式、低分辨率场景下准确率与云端仍有差距,适合强隐私、离线场景。

路线二:云端通用文字识别。 调用云厂商的文字识别服务(例如百度智能云的文字识别,或任一云厂商的同类服务)。优点是准确率高、开箱即用、覆盖印刷体到手写、表格等多类版式,通常提供一定的免费额度(具体以官方说明为准)。本工具“高频、少量、要准”的场景下,默认走云端路线,保留端内路线作为可选项。

云端接入有三个必须做对的设计点:

1)密钥不进页面。 密钥由使用者在设置页填入,保存在扩展本地存储(storage.local)里;所有识别请求统一从后台脚本发出——content script与页面共享执行环境,任何写死在前端的密钥都等于公开。

2)后台统一收发。 扩展后台具备跨域请求能力(在manifest声明目标域名权限即可),既绕开页面CORS限制,又把网络请求与UI解耦。

3)结构化的调用链路。 以后台脚本为例:读密钥→换取访问令牌→携带Base64图像调用接口→校验错误码→拼装文本:

  1. // background:识别调度(示意代码,接口地址与参数以所选服务的官方文档为准)
  2. async function recognize(base64Image) {
  3. const { apiKey, secretKey } = await browser.storage.local.get(['apiKey', 'secretKey']);
  4. if (!apiKey || !secretKey) throw new Error('请先在设置页配置密钥');
  5. const token = await getAccessToken(apiKey, secretKey);
  6. const resp = await fetch(buildEndpoint(token), {
  7. method: 'POST',
  8. headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  9. body: new URLSearchParams({ image: base64Image })
  10. });
  11. const data = await resp.json();
  12. if (data.error_code) throw new Error(`识别失败:${data.error_code} ${data.error_msg}`);
  13. return data.words_result.map(item => item.words).join('\n');
  14. }

错误处理不能偷懒:密钥未配置、令牌过期、额度耗尽、图片超限,每种都要给出可读提示,否则用户面对的只是一个沉默的失败。结果侧再做两件事:一键复制(优先navigator.clipboard.writeText,失败回退document.execCommand('copy'))与下载为txt,让识别结果直接进入下游工作流。

六、交互载体:为什么是侧边栏而不是弹窗

常见扩展交互是点击图标弹出popup,但popup有两个致命缺陷:点击页面任意位置立即关闭;面积狭小,放不下识别结果。所以最终选择侧边栏(Manifest V3的sidePanel能力)作为唯一交互面板,收益立竿见影:

  • 常驻显示,识别过程中可以继续滚动页面找下一处素材;
  • 与页面并排而非覆盖,不遮挡原文;
  • 面积足够放下预览图、结果文本与历史记录。

整体消息流也清晰:

  1. 侧边栏(交互 / 预览 / 结果展示)
  2. ⇅ runtime.sendMessage
  3. 后台脚本(截图 / OCR调用 / 密钥存储)
  4. ⇅ tabs.sendMessage
  5. 内容脚本(解锁 / 框选采集)

每层只做自己的事:内容脚本负责“采集与解锁”,后台负责“需要特权的能力”,侧边栏负责“呈现与交互”。

七、踩坑与细节记录

开发过程中值得沉淀的几条经验:

  1. 高分屏裁剪必须乘devicePixelRatio,否则选区不准、图片发糊,前文已述,最高频问题;
  2. 想直接识别“网页里的某张图”时,别在页面canvas上做。跨域图片画上canvas后画布会被“污染”,toDataURL直接抛异常;正确姿势是后台脚本按图片URL取回二进制转Base64再送识别;
  3. 单页应用的路由切换会让限制“复活”。解锁脚本只在页面加载时执行一次是不够的,除了MutationObserver,还要在URL变化时重新执行整个解锁流程(可利用扩展的webNavigation事件,或轻量轮询地址变化);
  4. 遇到canvas渲染文字的站点,坦然用截图识别。这不是解锁失败,而是第二节中第六类防护的预期行为——两个功能本来就是互补设计;
  5. 权限最小化。manifest只申请确实需要的权限,既是商店审核要求,也是对用户信任的基本尊重;
  6. 给云端调用加节流与缓存。同一张图重复识别没有意义,以图像哈希为key缓存结果,省额度也更快。

八、效果与总结

最终交付的功能:一键解除复制限制、恢复右键与选中、覆盖动态加载内容;截图/框选/上传/拖拽四种采集入口;预览确认后识别,结果一键复制或下载。使用者的原话是:“终于顺手了,思路不再被打断。”

复盘下来,这个小插件的完整链路——理解封锁手段→事件体系对抗→图像采集与裁剪→云服务接入→交互架构设计——恰好是浏览器扩展开发的一次综合练习,每一环都有可迁移的通用知识:

  • 事件的捕获与冒泡模型,在“拦截”与“反拦截”中体现得淋漓尽致;
  • CSS像素与物理像素的换算,是所有截图类工具绕不开的细节;
  • 密钥管理与后台收发,是任何“前端+云服务”架构的安全底线。

后续还计划补上:批量框选识别、表格与手写版式识别、端内引擎开关(完全离线模式)与快捷键支持。如果你身边也有被“选不了的网页”和“切不完的窗口”折磨的人,动手试试——一个周末的开发量,换来的是每天少切换几十次的顺畅体验。