简介:网页资料整理时,禁复制页面只能手敲、图片文字需跨应用反复切换,效率极低。本文完整拆解一款浏览器扩展的开发实践:用样式注入、事件拦截与属性清理一键解除禁复制、禁右键限制,并兼容动态加载内容;在浏览器内完成框选截图、裁剪压缩与OCR识别,结果一键复制;同时覆盖侧边栏交互、密钥安全与权限最小化等工程细节,附示例代码,可直接迁移到同类效率工具开发中。
做文案、策划、竞品分析的人,几乎每天都在和网页“较劲”。最常见的障碍无非两类:
一是禁止复制的页面。设计灵感站、素材站、文库类网站,文字明明看得见,却选不中、右键也被屏蔽,最后只能对着屏幕逐字手敲,既慢又容易抄错。
二是图片里的文字无法就地提取。想取一张图上的文案,惯用流程是:截图 → 切到自带 OCR 的聊天或办公软件 → 等识别 → 复制 → 再切回浏览器粘贴。一段文字要跨三次窗口,思路被打断得七零八落。
家里那位做广告策划的,就常年被这两件事折磨。某天晚上赶方案时她随口念叨“太麻烦了,思路都断了”,我便干脆利用周末写了一个「图文解锁器」浏览器扩展:
她用了一周之后的反馈是“终于顺手了”。下面把整个方案的设计思路与关键实现完整拆开讲。
动手前对比过三种做法:
最终选型基于标准扩展清单格式(Manifest V3),整体分三层:内容脚本负责解除限制与页面内拖框;后台脚本负责截图与 OCR 接口代理;侧边栏页负责预览、结果展示与交互。三层职责单一,靠消息端口串联。
分析下来,绝大多数站点的限制只有四类:
user-select: none 禁止选中文本;copy / cut / selectstart 并调用 preventDefault();contextmenu / dragstart 并屏蔽默认行为;oncopy="return false" 之类的属性。对应的解法是“样式覆盖 + 捕获阶段拦截 + 清理内联属性”三板斧,一次注入全部解决。
// unlock.js(内容脚本):一键解除页面交互限制function unlockPage() {// 1) 样式覆盖:恢复文本可选中const style = document.createElement('style');style.textContent = `* {user-select: text !important;-webkit-user-select: text !important;}`;document.documentElement.appendChild(style);// 2) 捕获阶段拦截:让页面自身的"禁用逻辑"收不到事件// 注意只 stopPropagation、不 preventDefault,// 这样浏览器默认行为(复制、右键菜单)照常工作['copy', 'cut', 'selectstart', 'contextmenu', 'dragstart'].forEach(type => {document.addEventListener(type, e => e.stopPropagation(), true);});// 3) 清理内联 on* 属性cleanInlineHandlers(document.body);}function cleanInlineHandlers(root) {const attrs = ['oncopy', 'oncut', 'onpaste', 'onselectstart', 'oncontextmenu'];root.querySelectorAll('*').forEach(el => {attrs.forEach(a => el.hasAttribute(a) && el.removeAttribute(a));});}
两个容易被忽略的细节:其一,样式节点挂在 document.documentElement 而非 body 上,可降低被站点脚本误删的概率;其二,捕获阶段监听务必“只断传播、不取消默认行为”,否则会把正常复制也一起废掉。
素材站大多是异步渲染:初始清理完成后,新插入的节点可能又带着内联限制属性。用 MutationObserver 持续兜底:
const observer = new MutationObserver(() => cleanInlineHandlers(document.body));observer.observe(document.body, { childList: true, subtree: true });
再在侧边栏保留一个“重新解锁”按钮,应对个别会主动恢复限制的极端站点,双保险。
工程上要留三分克制:默认不全局开启,用户点按钮才注入,避免影响依赖 pointer-events 的正常浮层;不要粗暴拦截所有 keydown,只处理阻断复制类按键的场景,否则会干扰页面自身快捷键。同时必须想清楚工具的伦理边界——它只做“恢复浏览器默认能力”,不做批量爬取与内容搬运,使用时应尊重站点条款与内容版权。
| 方式 | 说明 | 适用场景 |
|---|---|---|
| 页面截图 | 抓取当前可见区域 | 一键识别整屏内容 |
| 自选区域 | 拖框选择任意区域 | 只识别关注的部分 |
| 点击上传 | 选择本地图片文件 | 识别本地素材图 |
| 拖拽上传 | 拖入图片即识别 | 快速投喂素材 |
四条入口统一收敛到同一条管线:取图 → 预览 → 识别 → 复制/下载。预览环节很重要,识别前肉眼确认一遍,能避免“识别完才发现框歪了”的返工。
扩展平台提供了抓取当前标签页可见区域的标准 API(chrome.tabs.captureVisibleTab),需在后台脚本调用并声明 activeTab 权限:
// background.js:抓取当前可见区域async function captureVisibleTab() {const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });return chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });}
返回的是整屏 PNG,坐标基于视口,后续按需裁剪。
内容脚本里铺一层全屏遮罩,监听 mousedown / mousemove / mouseup 画框,松手后把 CSS 像素坐标回传后台:
// 内容脚本:全屏遮罩 + 拖框function startRegionSelect(onDone) {const mask = document.createElement('div');Object.assign(mask.style, {position: 'fixed', inset: '0', zIndex: 2147483647,cursor: 'crosshair', background: 'rgba(0,0,0,.08)'});document.documentElement.appendChild(mask);const box = document.createElement('div');Object.assign(box.style, {position: 'fixed', border: '2px dashed #4f8cff',background: 'rgba(79,140,255,.15)', display: 'none'});document.body.appendChild(box);let sx = 0, sy = 0;mask.addEventListener('mousedown', e => {sx = e.clientX; sy = e.clientY; box.style.display = 'block';});mask.addEventListener('mousemove', e => {Object.assign(box.style, {left: Math.min(sx, e.clientX) + 'px',top: Math.min(sy, e.clientY) + 'px',width: Math.abs(e.clientX - sx) + 'px',height: Math.abs(e.clientY - sy) + 'px'});});mask.addEventListener('mouseup', e => {const rect = {x: Math.min(sx, e.clientX), y: Math.min(sy, e.clientY),w: Math.abs(e.clientX - sx), h: Math.abs(e.clientY - sy)};mask.remove(); box.remove();onDone(rect); // CSS 像素坐标,回传后台});}
裁剪时必须处理 devicePixelRatio:截图是物理像素,而选择框坐标是 CSS 像素,高分屏上不做换算,就会出现“识别区域整体偏移”的经典 bug。dpr 应由内容脚本从被截图页面回传,而非取侧边栏窗口的值:
// 裁剪 + 压缩(dpr 由内容脚本回传)function cropAndCompress(dataUrl, rect, dpr) {return new Promise(resolve => {const img = new Image();img.onload = () => {const canvas = document.createElement('canvas');canvas.width = Math.round(rect.w * dpr);canvas.height = Math.round(rect.h * dpr);canvas.getContext('2d').drawImage(img,rect.x * dpr, rect.y * dpr, rect.w * dpr, rect.h * dpr,0, 0, canvas.width, canvas.height);resolve(canvas.toDataURL('image/jpeg', 0.9)); // 压缩体积};img.src = dataUrl;});}
顺带用 toDataURL('image/jpeg', 0.9) 压缩:高分屏长图动辄几 MB,压一下能显著降低上传耗时与流量。
点击上传走 input[type=file] + FileReader 读取为 dataURL;拖拽上传则监听侧边栏容器的 dragover / drop,从 e.dataTransfer.files[0] 取文件。注意 drop 时必须 preventDefault(),否则浏览器会直接打开图片页。两条入口复用同一条“预览 → 识别”管线,代码零重复。
OCR 引擎刻意抽象成适配层,不与任何一家绑定。通用文字识别是各家云厂商的成熟类目,以百度智能云为代表的云服务都提供标准 HTTP 接口,开通后按文档鉴权调用即可;个人轻度使用的频率下,多数平台的免费额度通常够用(以各平台说明为准)。同时保留切换到本地推理或自建服务的口子,避免被单一引擎锁死:
// ocr.js:引擎适配层(示意,请求/响应字段以所用服务文档为准)async function recognize(base64) {const image = base64.replace(/^data:image\/\w+;base64,/, '');const resp = await fetch(OCR_CONFIG.endpoint, {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ image })});if (!resp.ok) throw new Error('OCR 服务返回 ' + resp.status);const data = await resp.json();// 通用做法:按行取词拼接,具体字段名依接口文档调整return (data.lines || []).map(l => l.text).join('\n');}
工程上再叠三层保护:
识别完成后提供一键复制,并支持保存图片副本。这些细节虽小,却决定日常是否顺手:复制按钮要有成功反馈;识别过程中禁用按钮防重复提交;错误信息要说人话,而不是直接抛原始报错。
整体交互放在扩展侧边栏而非弹窗,理由很实际:弹窗点一下页面外部就会关闭,长流程(上传大图、等待识别)体验极差;侧边栏常驻且与页面并排,拖框时遮罩显示在页面上、结果落在侧栏里,视线不必来回跳。
状态机设计为 idle → selecting → previewing → recognizing → result / error,每个状态下按钮的可用性由状态驱动,避免出现“能点但点了没用”的幽灵按钮。消息链路统一走 chrome.runtime 的消息端口:侧边栏 → 后台(截图、请求 OCR)→ 内容脚本(解锁、拖框),排查问题沿链路定位即可。
Manifest V3 的权限坚持最小化:
{"manifest_version": 3,"name": "图文解锁器","version": "1.0.0","description": "解除网页复制限制,浏览器内框选识别图片文字","permissions": ["activeTab", "scripting", "sidePanel", "storage"],"background": { "service_worker": "background.js" },"action": { "default_title": "打开侧边栏" },"side_panel": { "default_path": "sidepanel.html" }}
三点补充说明:
activeTab + scripting 意味着用户主动点击才注入脚本,避免申请全站宽权限吓到用户;若确需常驻解锁,再声明内容脚本并在商店里写明用途;host_permissions 只申请对应服务域名,不要放宽;这个周末项目的收获,不只是一个“终于顺手了”的小工具,而是一套可复用的方法论:
同一副骨架稍加改造,就能长出划词翻译、表格抓取、网页批注等一整族效率工具。愿你也把身边的“来回折腾”,变成某个周末的一次动手。