简介:网页禁止选中复制、图片文字提取流程繁琐,是内容工作者的两大高频痛点。本文完整复盘一个浏览器插件从需求到落地的全过程:剖析网站禁用复制的常见技术手段与对应解除方案,梳理截图、框选、上传、拖拽四种图像采集入口,并给出云端OCR接入的架构设计与关键代码,帮你把“看到即可复制、框选即可识别”的体验真正落地。
女朋友是做广告策划的,日常工作是从海量网站和素材库中摘抄文案、整理灵感。理想状态是“看到→摘录→归档”一气呵成,现实里却处处是坎,最典型的两个:
痛点一:看得见、选不了的网页。 设计灵感站、素材站、文库类网站,为了保护内容,普遍禁用了文本选中、右键菜单甚至快捷键复制。文字明明就在屏幕上,就是拿不下来,最后只能照着手敲一遍——效率低还容易出错。
痛点二:图片文字提取链路太长。 想从一张图里拿文字,常规做法是:截图→切到聊天或办公软件→用它自带的“提取文字”功能→等识别→复制→再切回浏览器粘贴。一张图,来回切换三次,思路就是在这种切换里被打断的。
某天晚上她在赶方案,一边操作一边念叨“太麻烦了,思路全断了”。我说:“要不我给你写个插件?”
于是用周末两天做了这个「图文解锁器」,目标三个:
用上之后的评价是:“终于顺手了,那些禁止复制的网站现在随便复制,图片识别也不用跳来跳去了。”下面完整拆解技术方案与实现细节。
要解除限制,先要理解限制是怎么做的。主流站点的封锁手段基本是以下几层的组合:
oncopy="return false"、oncontextmenu="return false"、onselectstart="return false",古老但依然常见;addEventListener在document上监听copy、cut、contextmenu、selectstart、mousedown、keydown(专门拦截复制与全选快捷键),回调里统一preventDefault();user-select: none一行就能让整页不可选中;前五类都可以从扩展侧对抗;第六类属于“降维打击”,唯一通用的解法反而是截图+OCR。这正是把「解锁」和「识别」两件事放在一个插件里的原因:一个负责恢复页面的原生能力,一个在原生能力不可恢复时兜底。
扩展的内容脚本(content script)运行在页面上下文里,可以操作完整DOM,这给了解锁的切入点。策略分四步:
第一步:用最高优先级的CSS覆盖选中封锁。
*, *::before, *::after {-webkit-user-select: text !important;user-select: text !important;}
第二步:清空内联处理器。 遍历所有元素,把内联属性显式置空,同时递归穿透Shadow DOM:
function clearInlineHandlers(root) {root.oncopy = root.oncut = root.oncontextmenu = root.onselectstart = null;for (const el of root.querySelectorAll('*')) {el.oncopy = el.oncut = el.oncontextmenu = el.onselectstart = null;if (el.shadowRoot) clearInlineHandlers(el.shadowRoot);}}
第三步:捕获阶段拦截,“截断”站点的监听器。addEventListener绑定的监听器无法逐个摘除,但事件传播是有顺序的:捕获阶段从window一路向下。绝大多数站点把监听器绑在document或更深的节点上,因此在window上以capture: true注册同名事件并stopPropagation(),站点绑在传播路径后段的监听器就永远不会执行:
const EVENTS = ['copy', 'cut', 'contextmenu', 'selectstart', 'dragstart'];for (const type of EVENTS) {window.addEventListener(type, e => e.stopPropagation(), true);}
第四步:MutationObserver兜底动态内容。 对异步插入的节点,用MutationObserver监听childList + subtree,变化后重新执行内联处理器清理:
new MutationObserver(() => clearInlineHandlers(document)).observe(document.documentElement, { childList: true, subtree: true });
三个工程取舍值得说明:
pointer-events: auto !important也能“解决”遮罩,但会顺手破坏大量弹窗、下拉等正常交互。方案只精确放开「选中」和「复制」两个能力,其他一概不动。stopPropagation拦不住(同一目标上的监听器按注册顺序执行)。暴力解法是克隆并替换根节点、甩掉所有监听器,但这会摧毁页面脚本持有的引用(使用虚拟DOM的页面会直接白屏),因此默认不启用,遇到时用“截图识别”兜底。最后补一句合规提醒:解除复制限制的正当用途是合法的资料摘录与个人学习,请尊重内容版权与站点协议,不要用于批量抓取或侵权场景。
OCR的第一步是把图拿进来。按使用场景设计了四个入口:
| 入口 | 采集方式 | 适用场景 |
|---|---|---|
| 页面截图 | 一键截取当前可见区域 | 整屏内容快速识别 |
| 自选区域 | 按住鼠标拖拽框选 | 只识别局部特定内容 |
| 点击上传 | 通过文件选择器选图 | 识别本地图片素材 |
| 拖拽上传 | 把图片文件拖入侧边栏 | 零点击成本的上传路径 |
采集之后统一进入「预览→识别→复制/下载」的流程。内置预览不只是体验问题:一是识别前让用户确认内容、避免误框;二是识别属于外部调用,先确认一次就能有效减少无效请求。
技术上各有要点:
1)页面截图。 浏览器为扩展提供了截取可见区域的接口(tabs.captureVisibleTab),返回整屏PNG数据。注意它必须在后台脚本(service worker)中调用,侧边栏通过消息把请求转发给后台,拿到结果再回传展示。
2)自选区域:遮罩+坐标裁剪。 内容脚本注入全屏透明遮罩层,监听mousedown / mousemove / mouseup画出选框,抬起时记录CSS像素坐标;随后取整屏截图,在canvas里按物理像素裁剪:
function cropSelection(fullImage, rect, dpr) {return new Promise(resolve => {const img = new Image();img.onload = () => {const canvas = document.createElement('canvas');canvas.width = Math.round(rect.width * dpr);canvas.height = Math.round(rect.height * dpr);canvas.getContext('2d').drawImage(img,rect.x * dpr, rect.y * dpr, rect.width * dpr, rect.height * dpr,0, 0, canvas.width, canvas.height);resolve(canvas.toDataURL('image/png'));};img.src = fullImage;});}
这里的dpr是devicePixelRatio。整屏截图返回的是物理像素,而选框坐标是CSS像素,两者不换算就会裁错区域或得到一张模糊的图——这是本插件开发中最高频的坑。
3)上传与拖拽。 走input[type=file]与dragover / drop事件,用FileReader读成DataURL即可。两个小细节:拖拽进入时给侧边栏高亮反馈;必须显式阻止dragover的默认行为,否则浏览器会直接打开图片文件而不是触发放置。
图像到手后,识别有两条路线:
路线一:端内识别。 用基于WebAssembly的开源OCR引擎在浏览器里直接运行,全程离线、隐私无忧;代价是首次需加载较大的语言模型,且复杂版式、低分辨率场景下准确率与云端仍有差距,适合强隐私、离线场景。
路线二:云端通用文字识别。 调用云厂商的文字识别服务(例如百度智能云的文字识别,或任一云厂商的同类服务)。优点是准确率高、开箱即用、覆盖印刷体到手写、表格等多类版式,通常提供一定的免费额度(具体以官方说明为准)。本工具“高频、少量、要准”的场景下,默认走云端路线,保留端内路线作为可选项。
云端接入有三个必须做对的设计点:
1)密钥不进页面。 密钥由使用者在设置页填入,保存在扩展本地存储(storage.local)里;所有识别请求统一从后台脚本发出——content script与页面共享执行环境,任何写死在前端的密钥都等于公开。
2)后台统一收发。 扩展后台具备跨域请求能力(在manifest声明目标域名权限即可),既绕开页面CORS限制,又把网络请求与UI解耦。
3)结构化的调用链路。 以后台脚本为例:读密钥→换取访问令牌→携带Base64图像调用接口→校验错误码→拼装文本:
// background:识别调度(示意代码,接口地址与参数以所选服务的官方文档为准)async function recognize(base64Image) {const { apiKey, secretKey } = await browser.storage.local.get(['apiKey', 'secretKey']);if (!apiKey || !secretKey) throw new Error('请先在设置页配置密钥');const token = await getAccessToken(apiKey, secretKey);const resp = await fetch(buildEndpoint(token), {method: 'POST',headers: { 'Content-Type': 'application/x-www-form-urlencoded' },body: new URLSearchParams({ image: base64Image })});const data = await resp.json();if (data.error_code) throw new Error(`识别失败:${data.error_code} ${data.error_msg}`);return data.words_result.map(item => item.words).join('\n');}
错误处理不能偷懒:密钥未配置、令牌过期、额度耗尽、图片超限,每种都要给出可读提示,否则用户面对的只是一个沉默的失败。结果侧再做两件事:一键复制(优先navigator.clipboard.writeText,失败回退document.execCommand('copy'))与下载为txt,让识别结果直接进入下游工作流。
常见扩展交互是点击图标弹出popup,但popup有两个致命缺陷:点击页面任意位置立即关闭;面积狭小,放不下识别结果。所以最终选择侧边栏(Manifest V3的sidePanel能力)作为唯一交互面板,收益立竿见影:
整体消息流也清晰:
侧边栏(交互 / 预览 / 结果展示)⇅ runtime.sendMessage后台脚本(截图 / OCR调用 / 密钥存储)⇅ tabs.sendMessage内容脚本(解锁 / 框选采集)
每层只做自己的事:内容脚本负责“采集与解锁”,后台负责“需要特权的能力”,侧边栏负责“呈现与交互”。
开发过程中值得沉淀的几条经验:
toDataURL直接抛异常;正确姿势是后台脚本按图片URL取回二进制转Base64再送识别;最终交付的功能:一键解除复制限制、恢复右键与选中、覆盖动态加载内容;截图/框选/上传/拖拽四种采集入口;预览确认后识别,结果一键复制或下载。使用者的原话是:“终于顺手了,思路不再被打断。”
复盘下来,这个小插件的完整链路——理解封锁手段→事件体系对抗→图像采集与裁剪→云服务接入→交互架构设计——恰好是浏览器扩展开发的一次综合练习,每一环都有可迁移的通用知识:
后续还计划补上:批量框选识别、表格与手写版式识别、端内引擎开关(完全离线模式)与快捷键支持。如果你身边也有被“选不了的网页”和“切不完的窗口”折磨的人,动手试试——一个周末的开发量,换来的是每天少切换几十次的顺畅体验。