女朋友做廣告策劃,每天要從海量網站和素材中摘抄文案。
微信或飛書截圖都有 OCR,但她總要“切微信/飛書 → 識別 → 複製 → 切回瀏覽器”,來回折騰好麻煩,經常被打斷思路。
兩個最常見的煩惱:
一張圖,好幾個步驟,來回切換三次。
有天晚上她在趕方案,一邊操作一邊念叨:“太麻煩了,思路都斷了……”
我說:“要不我給你寫個插件?”
於是週末兩天,做了這個 「圖文解鎖器」:
週一她用上之後的評價:“終於順手了!那些惡心的禁止複製網站現在隨便複製,圖片識別也不用跳來跳去了。”
下面講講開發過程和技術實現 👇
| 方式 | 說明 | 使用場景 |
|---|---|---|
| 頁面截圖 | 快速截取當前可見區域 | 一鍵識別網頁全部內容 |
| 自選區域 | 拖拽選擇任意區域 | 只識別你選中的特定區域 |
| 點擊上傳 | 選擇本地圖片文件 | 識別本地圖片中的文字 |
| 拖拽上傳 | 拖入圖片即可識別 | 方便上傳圖片並識別文字 |
小貼士:Side Panel 依賴較新版本 Chrome,建議 114+。
注:如免費額度用完,請根據下方指導申請屬於你自己的帳號哦。👇
插件支持騰訊雲 OCR,每月有約 1000 次免費額度,足夠日常使用。首次識別前,請按以下步驟開通並配置:
activeTab:訪問當前標籤頁scripting:注入腳本storage:保存配置sidePanel:側邊欄功能copy-everything
├── manifest.json # 插件配置
├── icons # 插件圖標
├── background.js # 後台服務
├── content.js # 內容腳本(核心功能)
├── inject.js # 注入頁面腳本(核心功能)
├── sidepanel.html # 側邊欄界面
├── sidepanel.js # 側邊欄界面邏輯
├── sidepanel.css # 側邊欄樣式
└── tencentOCR.js # OCR SDK
這是插件的核心功能之一,通過多層防護確保解除限制:

實現原理
通過 五層防護 確保解除限制
第一層:CSS 強制覆蓋
const style = document.createElement('style');
style.textContent = `
html, body, * {
-webkit-user-select: text !important;
-moz-user-select: text !important;
user-select: text !important;
}
`;
document.head.appendChild(style);
作用:強制允許文本選中,覆蓋網站的 CSS 限制。
第二層:事件攔截(捕獲階段)
// 在捕獲階段攔截所有限制事件
const restrictedEvents = [
'copy', 'cut', 'paste', 'contextmenu',
'selectstart', 'dragstart', 'keydown', 'keyup', 'keypress'
];
const stopEvent = (e) => {
e.stopPropagation();
e.stopImmediatePropagation?.(); // 阻止其他監聽器執行
};
// 在 window、document、documentElement、body 四個層級同時監聽
[window, document, document.documentElement, document.body].forEach(target => {
if (target) {
restrictedEvents.forEach(eventType => {
target.addEventListener(eventType, stopEvent, true); // ← 捕獲階段
});
}
});
關鍵點:
stopImmediatePropagation() 阻止其他監聽器為什麼要用捕獲階段?
事件流:捕獲階段 → 目標階段 → 冒泡階段
↓
我們在這裡攔截(優先級最高)
第三層:移除內聯事件屬性
// 移除所有事件屬性(如 oncopy="return false")
const eventAttrs = [
'oncopy', 'oncut', 'onpaste', 'oncontextmenu',
'onselectstart', 'onkeydown', 'ondragstart'
];
document.querySelectorAll(eventAttrs.map(a => `[${a}]`).join(','))
.forEach(el => {
eventAttrs.forEach(attr => el.removeAttribute(attr));
});
作用:移除 HTML 標籤上的內聯事件(如 oncopy="return false")
第四層:API 劫持(重寫 preventDefault)
// 注入到頁面上下文,重寫原生方法
const script = document.createElement('script');
script.textContent = `
(function() {
if (window.__preventDefaultDisabled) return;
window.__preventDefaultDisabled = true;
const blockedEvents = new Set(${JSON.stringify(restrictedEvents)});
const originalPreventDefault = Event.prototype.preventDefault;
Event.prototype.preventDefault = function() {
if (blockedEvents.has(this.type)) return; // ← 禁用 preventDefault
return originalPreventDefault.call(this);
};
})();
`;
document.documentElement.appendChild(script);
script.remove();
為什麼要注入 <script> 標籤?
Event.prototype<script> 標籤注入到 頁面上下文(Main World)💡 深入理解腳本通信機制
如果你想深入了解插件腳本通信機制,強烈推薦閱讀: 大部分人都錯了!這才是 Chrome 插件多腳本通信的正確姿勢 這篇文章用原理+示例拆解了常見誤區與正確做法,能幫你更透徹地理解本插件的通信機制。
第五層:動態內容監聽
// 監聽 DOM 變化,自動處理動態加載的元素
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes?.forEach((node) => {
if (node instanceof Element) {
// 移除事件屬性
eventAttrs.forEach(attr => node.removeAttribute(attr));
// 強制允許選中
node.style?.setProperty('user-select', 'text', 'important');
}
});
});
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
作用:監聽 DOM 變化,自動處理動態加載的元素(如 React/Vue 渲染的內容)。
這背後發生了什麼?
步驟 1:創建選框工具
// 創建遮罩層(就像給屏幕蓋了一層磨砂玻璃)
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
left: 0; top: 0;
width: 100vw; height: 100vh;
background: rgba(0,0,0,0.5); /* 半透明黑色 */
cursor: crosshair; /* 十字準星 */
z-index: 999999;
`;
document.body.appendChild(overlay);
當你點擊"自選區域"後,插件會:
2. 跟隨你的鼠標畫框(實時反饋)
// 鼠標按下 → 記錄起點
function handleMouseDown(e) {
startX = e.clientX; // 記住你點擊的 X 坐標
startY = e.clientY; // 記住你點擊的 Y 坐標
}
// 鼠標移動 → 實時更新框的大小
function handleMouseMove(e) {
currentX = e.clientX;
currentY = e.clientY;
// 計算框的位置和大小(支持反向拖拽)
const left = Math.min(startX, currentX); // 取最小值作為左邊界
const top = Math.min(startY, currentY); // 取最小值作為上邊界
const width = Math.abs(currentX - startX); // 寬度 = 绝对值
const height = Math.abs(currentY - startY); // 高度 = 绝对值
// 更新綠色邊框的位置
selectionBox.style.left = left + 'px';
selectionBox.style.top = top + 'px';
selectionBox.style.width = width + 'px';
selectionBox.style.height = height + 'px';
}
當你按住鼠標拖動時:
支持反向拖拽:不管你從左上往右下拖,還是從右下往左上拖,都能正確識別!
舉個例子:
你從 (100, 100) 拖到 (300, 300)
→ 框的位置:left=100, top=100, width=200, height=200 ✅
你從 (300, 300) 拖到 (100, 100)(反向)
→ 框的位置:left=100, top=100, width=200, height=200 ✅
3. 精準裁剪(像剪刀一樣裁圖)
當你鬆開鼠標後,插件會:
關鍵技術:Canvas 畫布裁剪
// 1. 創建一個畫布(就像準備一張白紙)
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 2. 設置畫布大小 = 你選中區域的大小
canvas.width = width;
canvas.height = height;
// 3. 把全屏截圖的"選中部分"畫到畫布上
ctx.drawImage(
fullScreenImage, // 全屏截圖
left, top, // 從哪裡開始裁剪(你選中區域的左上角)
width, height, // 裁剪多大(你選中區域的寬高)
0, 0, // 畫到畫布的哪裡(從畫布左上角開始)
width, height // 畫多大(填滿整個畫布)
);
// 4. 導出成圖片
const croppedImage = canvas.toDataURL('image/png');
用生活場景類比:
步驟 4:四遮罩高亮效果
問題:如果只用一個全屏遮罩,選中的區域也會被遮住,用戶看不清選了什麼。
解決方案:用 4 個遮罩塊圍繞選區。
┌─────────────────────────────────┐
│ 上遮罩(半透明黑色) │
├──────┬──────────────┬───────────┤
│ 左遮罩│ 選區(高亮) │ 右遮罩 │
├──────┴──────────────┴───────────┤
│ 下遮罩(半透明黑色) │
└─────────────────────────────────┘
代碼實現:
// 創建 4 個遮罩塊
const masks = ['top', 'right', 'bottom', 'left'].map(position => {
const mask = document.createElement('div');
mask.style.cssText = `
position: fixed;
background: rgba(0,0,0,0.5);
pointer-events: none; /* 不阻擋鼠標事件 */
`;
return mask;
});
// 根據選區位置更新遮罩塊大小
function updateMasks(left, top, width, height) {
masks[0].style.cssText += `left:0; top:0; width:100vw; height:${top}px;`; // 上
masks[1].style.cssText += `left:${left + width}px; top:${top}px; width:${window.innerWidth - left - width}px; height:${height}px;`; // 右
masks[2].style.cssText += `left:0; top:${top + height}px; width:100vw; height:${window.innerHeight - top - height}px;`; // 下
masks[3].style.cssText += `left:0; top:${top}px; width:${left}px; height:${height}px;`; // 左
}
高分屏適配(讓圖片更清晰)
問題:Retina 屏幕(如 MacBook)的像素密度是普通屏幕的 2 倍,如果不處理會導致截圖模糊。
解決方案:乘以設備像素比
const scale = window.devicePixelRatio || 1; // Retina 屏幕 = 2,普通屏幕 = 1
// 設置畫布大小時要乘以 scale
canvas.width = width * scale;
canvas.height = height * scale;
// 裁剪時也要乘以 scale
ctx.drawImage(
fullScreenImage,
left * scale, top * scale, // ← 乘以 scale
width * scale, height * scale, // ← 乘以 scale
0, 0,
width * scale, height * scale
);

HTML:
<input type="file" id="uploadInput" accept="image/*" style="display:none">
<button onclick="document.getElementById('uploadInput').click()">
📁 上傳圖片
</button>
JavaScript:
document.getElementById('uploadInput').addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
const base64 = event.target.result.split(',')[1];
await recognizeText(base64);
};
reader.readAsDataURL(file);
});
const dropZone = document.getElementById('dropZone');
// 1. 阻止默認行為
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
});
});
// 2. 視覺反饋
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropZone.classList.add('drag-over');
});
});
// 3. 處理文件
dropZone.addEventListener('drop', (e) => {
const file = e.dataTransfer.files[0];
if (!file.type.startsWith('image/')) {
alert('請上傳圖片文件!');
return;
}
const reader = new FileReader();
reader.onload = async (event) => {
const base64 = event.target.result.split(',')[1];
await recognizeText(base64);
};
reader.readAsDataURL(file);
});
CSS:
#dropZone {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 20px;
text-align: center;
transition: all 0.3s;
}
#dropZone.drag-over {
border-color: #4CAF50;
background: rgba(76, 175, 80, 0.1);
}
const capturePageBtn = document.getElementById('capturePageBtn');
capturePageBtn.addEventListener('click', async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
format: 'png'
});
const base64 = dataUrl.split(',')[1];
await recognizeText(base64);
});
直接使用chrome插件的截圖功能
權限配置(manifest.json):
{
"permissions": [
"activeTab",
"tabs"
]
}
使用騰訊雲 OCR API,通過 TC3-HMAC-SHA256 簽名調用 GeneralBasicOCR 接口,每月 1000 次免費額度。
// 核心調用代碼
const ocr = new TencentOCR(secretId, secretKey);
const text = await ocr.recognizeText(imageBase64);
這個插件解決了日常瀏覽網頁時的兩大痛點:
| 技術點 | 難點 | 解決方案 |
|---|---|---|
| 解除限制 | 網站多層防護 | 五層攔截 + API 劫持 |
| 自選截圖 | 高分屏模糊 | devicePixelRatio 適配 |
| 拖拽上傳 | 視覺反饋 | CSS 過渡動畫 |
| OCR 識別 | API 簽名 | TC3-HMAC-SHA256 |
希望這個插件能幫到大家!如果有問題或建議,歡迎在評論區交流~
本插件僅供學習交流使用,請遵守以下原則:
- 合法使用:請勿用於商業用途或侵犯他人知識產權
- 尊重版權:遵守網站服務條款
如果覺得對您有幫助,歡迎點贊 👍 收藏 ⭐ 關注 🔔 支持一下!
往期實戰推薦:
- Vue3 後台分頁寫膩了?我用 1 個 Hook 刪掉 90% 重複代碼(附源碼)
- ⚡ 一個Vue自定義指令搞定絲滑拖拽列表,告別複雜組件封裝
- 🔥 這才是 Vue 驅動的 Chrome 插件工程化正確打開方式
- 老闆問我:AI真能一鍵畫廣州旅遊路線圖?我用 MCP 現場開圖
- 【前端效率工具】:告別右鍵另存,不到 50 行代碼一鍵批量下載網頁圖片
- 女朋友炸了:剛打開的網頁怎麼又沒了?我反手甩出一鍵恢復按鈕!
- 你家孩子又偷玩網頁遊戲? 試試這個防沉迷工具
- 她說想要浪漫,我把瀏覽器鼠標換成了柴犬,點一下就有煙花(附源碼)
- 女朋友被鏈接折磨瘋了,我寫了個工具一鍵解救
- 上班摸魚看掘金,老闆突然出現在身後...
- 【前端效率工具】再也不用 APIfox 聯調!零侵入 Mock,全程不改代碼、不开代理
- 大部分人都錯了!這才是chrome插件多腳本通信的正確姿勢