在看网课、技术视频、会议录像的时候,经常想把画面里的代码、公式、PPT 文字直接抠出来。截图再丢给在线 OCR 既麻烦,又有隐私顾虑。本文用 Manifest V3 写一个 Chrome 插件:框选屏幕区域 → 保存截图 → 发给局域网里的 Qwen2.5-VL 7B → 识别结果自动进剪贴板 ,全程数据不出局域网。
启动qwen 2.5vl 7b大语言模型:
一、功能与整体思路
插件实现以下功能:
- 点击 OCR 按钮(或快捷键
Alt+Shift+O),在网页(包括全屏视频)上拖拽框选一块区域。 - 把选区裁剪成 PNG,保存到本地「下载/ocr」目录。
- 把图片转成 base64,发送给局域网中 Ollama 部署的
qwen2.5vl:7b,要求只识别文字。 - 识别结果自动写入剪贴板 ,同时显示在弹窗文本框和页面右下角浮动面板里,旁边都有 copy 按钮。
架构
Chrome 扩展的几个组件职责分工明确:
| 文件 | 运行环境 | 职责 |
|---|---|---|
popup.html / popup.js |
弹窗页面 | OCR 按钮、结果文本框、copy 按钮、设置 |
content.js |
注入到网页 | 画遮罩、处理鼠标框选、显示结果面板 |
background.js |
Service Worker | 截图裁剪、保存图片、调用大模型 API |
offscreen.html / offscreen.js |
离屏文档 | 写剪贴板(Service Worker 没有 DOM) |
manifest.json |
--- | 声明权限、入口、快捷键 |
数据流如下:
popup 点击 OCR ──► background ──注入──► content.js 框选
│ 发送选区坐标
▼
background:captureVisibleTab → 裁剪 → 下载 PNG
│ base64
▼
Ollama /api/chat(qwen2.5vl:7b)
│ 文字
▼
offscreen 写剪贴板 + storage 存结果 + 通知页面和弹窗
目录结构:
ocr-extension/
├── manifest.json
├── popup.html
├── popup.js
├── background.js
├── content.js
├── offscreen.html
├── offscreen.js
└── icons/
├── icon16.png
├── icon28.png
└── icon128.png
C:\myApp\ChromePlugIn\ocr-extension\ocr-extension



二、环境准备:Ollama 服务端
在运行大模型的机器上(本文是 192.168.1.28)拉取模型:
bash
ollama pull qwen2.5vl:7b
ollama list # 确认模型名
要让局域网里的浏览器访问它,需要让 Ollama 监听所有网卡:
- Windows:新建用户环境变量
OLLAMA_HOST=0.0.0.0:11434,并彻底退出 Ollama 后重启。 - Linux 用
systemctl edit ollama添加Environment="OLLAMA_HOST=0.0.0.0:11434"。
另外要在防火墙里放行 TCP 11434(Windows 管理员 PowerShell):
powershell
New-NetFirewallRule -DisplayName "Ollama 11434" -Direction Inbound -Protocol TCP -LocalPort 11434 -Action Allow -Profile Private,Domain
在另一台电脑上用 curl 验证服务可用:
bash
curl http://192.168.1.28:11434/api/chat -d '{"model":"qwen2.5vl:7b","stream":false,"messages":[{"role":"user","content":"你好"}]}'
能返回正常 JSON,说明服务端就绪。
三、核心源码详解
1. manifest.json:权限是关键
json
{
"manifest_version": 3,
"name": "视频截屏 OCR (Qwen2.5-VL)",
"version": "1.0.2",
"permissions": ["activeTab", "scripting", "storage", "downloads",
"offscreen", "clipboardWrite", "declarativeNetRequest"],
"host_permissions": ["http://192.168.1.28:11434/*"],
"background": { "service_worker": "background.js" },
"action": {
"default_popup": "popup.html",
"default_icon": { "16": "icons/icon16.png", "28": "icons/icon28.png", "128": "icons/icon128.png" }
},
"commands": {
"start-ocr": {
"suggested_key": { "default": "Alt+Shift+O" },
"description": "开始框选 OCR"
}
}
}
每个权限都有用途:
activeTab:用户点击插件或按快捷键后,获得对当前标签页的临时访问权,captureVisibleTab靠它截图,不需要申请<all_urls>这种过大的权限。scripting:动态注入content.js。downloads:保存截图。offscreen+clipboardWrite:后面讲剪贴板时会解释。host_permissions:授权访问 Ollama 地址。有了它,Service Worker 里的fetch可以绕过浏览器的 CORS 检查。declarativeNetRequest:用于修改请求头,解决 Ollama 的 403 问题(见后文)。
2. content.js:框选遮罩
这是交互的核心,思路是在页面最上层盖一个全屏半透明 div,监听鼠标事件画选框。
js
const host = () => document.fullscreenElement || document.documentElement;
这一行很重要。视频进入全屏后,只有全屏元素及其子树才可见。如果还把遮罩挂在 document.body 上,全屏视频时就看不到遮罩。所以挂载点优先取 document.fullscreenElement,这样全屏播放时也能框选。
创建遮罩和选框:
js
const overlay = document.createElement('div');
overlay.style.cssText =
'position:fixed;inset:0;z-index:2147483647;cursor:crosshair;background:rgba(0,0,0,.25);';
const box = document.createElement('div');
box.style.cssText = 'position:fixed;border:2px solid #ff3b30;background:rgba(255,59,48,.12);display:none;';
overlay.appendChild(box);
host().appendChild(overlay);
z-index 用 32 位整数的最大值 2147483647,保证盖住页面上一切元素。
三个鼠标事件的逻辑:
js
overlay.addEventListener('mousedown', (e) => {
drag = true; sx = e.clientX; sy = e.clientY;
Object.assign(box.style, { display: 'block', left: sx + 'px', top: sy + 'px', width: '0', height: '0' });
e.preventDefault();
});
overlay.addEventListener('mousemove', (e) => {
if (!drag) return;
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'
});
});
这里用 Math.min 和 Math.abs 处理,是为了支持从任意方向拖拽(比如从右下往左上拖),否则宽高会变成负数。
松开鼠标后最关键的一段:
js
overlay.addEventListener('mouseup', async (e) => {
if (!drag) return;
drag = false;
const r = {
x: Math.min(sx, e.clientX), y: Math.min(sy, e.clientY),
w: Math.abs(e.clientX - sx), h: Math.abs(e.clientY - sy),
viewportW: window.innerWidth
};
cleanup(); // 先移除遮罩
if (r.w < 5 || r.h < 5) return; // 太小视为误触
// 等遮罩真正消失后再截图,避免红框被截进去
await new Promise((ok) => requestAnimationFrame(() =>
requestAnimationFrame(() => setTimeout(ok, 80))));
chrome.runtime.sendMessage({ type: 'REGION_SELECTED', ...r });
});
这里有个容易踩的坑:移除遮罩之后,必须等浏览器真正重绘完,才能让后台去截图 。否则截到的图片里会带着红色选框和半透明黑色。所以用两层 requestAnimationFrame 再加 80ms 延迟,确保画面已经刷新。
同时把 window.innerWidth(视口的 CSS 宽度)一并发给后台,下一步换算像素比例时要用。
重复点击 OCR 时,脚本会被再次注入,开头用一个全局标记避免重复挂监听:
js
if (window.__ocrStart) { window.__ocrStart(); return; }
3. background.js:截图与裁剪
chrome.tabs.captureVisibleTab 返回的是整个可见区域的截图(data URL),需要按选区裁剪:
js
async function captureAndCrop(tab, r) {
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
const bin = atob(dataUrl.split(',')[1]);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const bmp = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
const k = bmp.width / r.viewportW; // 实际像素 / CSS 像素
const sx = Math.round(r.x * k), sy = Math.round(r.y * k);
const sw = Math.max(1, Math.round(r.w * k)), sh = Math.max(1, Math.round(r.h * k));
const canvas = new OffscreenCanvas(sw, sh);
canvas.getContext('2d').drawImage(bmp, sx, sy, sw, sh, 0, 0, sw, sh);
return await canvas.convertToBlob({ type: 'image/png' });
}
要点解释:
- 缩放比例
k:在高分屏或开了系统缩放(比如 Windows 125%、150%)时,CSS 像素和截图的实际像素不是 1:1。用截图宽度 / 视口 CSS 宽度算出比例,比直接读devicePixelRatio更可靠,因为浏览器页面缩放也会被一并算进去。 - Service Worker 里没有
document,不能用普通<canvas>,要用OffscreenCanvas,createImageBitmap也是 Worker 里可用的。 - 手动解码 base64 :我最初用
fetch(dataUrl)把 data URL 转成 Blob,调试时发现这种写法一旦出错,提示也只是笼统的 "Failed to fetch",很难定位。改成atob手动解码后,既稳定又方便区分错误来源。
4. 调用 Qwen2.5-VL:提示词与请求体
js
const PROMPT =
'Extract all the text visible in this image exactly as it appears, keeping line breaks. ' +
'Output ONLY the recognized text, without any explanation, translation or markdown. ' +
'If there is no text, output nothing.';
OCR 场景的提示词要点:只输出文字、不要解释、不要翻译、不要加 Markdown,否则视觉语言模型很喜欢"这张图片里写着......"这类废话,影响后续直接粘贴。
请求体使用 Ollama 的 /api/chat 接口,图片放在 images 字段,值是不带 data:image/png;base64, 前缀的纯 base64:
js
body: JSON.stringify({
model: cfg.model,
stream: false, // 一次性返回完整结果,便于处理
options: { temperature: 0 }, // OCR 需要确定性,温度设为 0
messages: [{ role: 'user', content: PROMPT, images: [b64] }]
})
返回结果在 data.message.content。
5. 踩坑重点:Ollama 的 403 与 Origin
首次联调时,弹窗里报错 「API 返回 403」 。原因是:浏览器扩展发出的请求会带 Origin: chrome-extension://<扩展ID>,而 Ollama 默认只允许本机来源,对其他来源直接返回 403(且响应体为空)。
有两种解决方案:
方案 A(服务端) :设置环境变量 OLLAMA_ORIGINS=chrome-extension://*,重启 Ollama。
方案 B(客户端) :用 declarativeNetRequest 在发请求时把 Origin 头删掉:
js
async function applyOriginRule() {
const { apiUrl } = await getSettings();
const base = new URL(apiUrl).origin + '/';
await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: [1],
addRules: [{
id: 1,
priority: 1,
action: { type: 'modifyHeaders',
requestHeaders: [{ header: 'Origin', operation: 'remove' }] },
condition: { urlFilter: '|' + base, resourceTypes: ['xmlhttprequest', 'other'] }
}]
});
}
我最终把两者结合:默认不改请求头,遇到 403 或网络层失败时,才自动启用这条规则重试 ,对应 callOllama:
js
async function callOllama(cfg, b64) {
const url = cfg.apiUrl.replace(/\/$/, '') + '/api/chat';
const init = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: /* ... */ };
await clearOriginRule(); // 先不改任何请求头
let resp;
try {
resp = await fetch(url, init);
} catch (e) { // 网络层失败:换成"去掉 Origin"再试一次
await applyOriginRule();
resp = await fetch(url, init);
}
if (resp.status === 403) { // 来源检查被拒:去掉 Origin 后重试
await applyOriginRule();
resp = await fetch(url, init);
}
return resp;
}
这样无论服务端有没有配置 OLLAMA_ORIGINS,插件都能工作。
6. 写剪贴板:为什么需要 offscreen 文档
需求要求"识别结果自动进入剪贴板"。但是:
- Service Worker 没有 DOM,不能调用
navigator.clipboard; - 页面里的
navigator.clipboard.writeText要求页面处于聚焦状态,并且通常要有用户手势,自动写入很不稳定。
Manifest V3 给出的标准方案是 Offscreen Document :创建一个不可见的扩展页面,在里面借助 execCommand('copy') 写入剪贴板。
background.js 一侧:
js
async function copyToClipboard(text) {
try {
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['CLIPBOARD'],
justification: '把 OCR 结果写入剪贴板'
});
} catch (e) { /* 已存在则忽略 */ }
await chrome.runtime.sendMessage({ target: 'offscreen', type: 'COPY', text });
}
offscreen.js 一侧:
js
chrome.runtime.onMessage.addListener((msg) => {
if (msg.target !== 'offscreen' || msg.type !== 'COPY') return;
const t = document.getElementById('t'); // offscreen.html 里放了一个 textarea
t.value = msg.text;
t.select();
document.execCommand('copy');
});
注意消息里用了 target: 'offscreen' 做路由:runtime.sendMessage 会广播给扩展内所有页面,背景脚本自己的监听器遇到 target === 'offscreen' 的消息要直接忽略,反之亦然。
7. 结果展示:storage 作为"总线"
弹窗(popup)一旦失去焦点就会关闭,而用户框选时必然要点页面,所以弹窗不可能一直开着等结果 。我的做法是把状态和结果统一存到 chrome.storage.local:
js
async function notify(tabId, type, text) {
await chrome.storage.local.set(
type === 'OCR_RESULT' ? { lastResult: text, status: '完成' } : { status: text });
try { await chrome.tabs.sendMessage(tabId, { type, text }); } catch (e) {}
}
- 页面里的浮动面板通过
tabs.sendMessage实时更新; - 弹窗下次打开时,读取
lastResult显示出来,并且监听storage.onChanged动态刷新:
js
chrome.storage.onChanged.addListener((c) => {
if (c.lastResult) $('result').value = c.lastResult.newValue || '';
if (c.status) $('status').textContent = c.status.newValue || '';
});
识别完成后,还会尝试 chrome.action.openPopup() 自动弹出弹窗(新版 Chrome 支持,失败则忽略)。
8. popup.js:启动与 copy 按钮
js
$('ocr').onclick = async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
await chrome.storage.local.set({ status: '请在页面上拖拽框选区域(Esc 取消)' });
chrome.runtime.sendMessage({ type: 'START_OCR', tabId: tab.id });
window.close(); // 关闭弹窗,让用户在页面上框选
};
$('copy').onclick = async () => {
const v = $('result').value;
try { await navigator.clipboard.writeText(v); }
catch (e) { $('result').select(); document.execCommand('copy'); }
$('copy').textContent = 'copied ✓';
setTimeout(() => ($('copy').textContent = 'copy'), 1200);
};
copy 按钮是用户手动点击,属于有用户手势的场景,navigator.clipboard.writeText 可以直接用,失败时再退回到 execCommand。
四、安装方法
- 把源码放进一个文件夹,比如
ocr-extension,结构与上文一致(icons里放 16、28、128 三个尺寸的 PNG 图标)。 - 在 Chrome 地址栏输入
chrome://extensions。 - 右上角打开 开发者模式。
- 点击左上角 加载已解压的扩展程序 ,选择
ocr-extension文件夹。 - 在工具栏的拼图图标里,把插件 固定 到工具栏,方便点击。
- 修改了代码后,在扩展管理页点击插件卡片上的 刷新 按钮,并刷新正在测试的网页(旧页面里还残留着上一版注入的脚本)。
五、使用方法
- 打开视频网页,把视频暂停在想识别的那一帧(视频全屏也可以)。
- 点击工具栏里的插件图标,再点蓝色的 OCR:框选屏幕区域 按钮;也可以直接按快捷键
Alt+Shift+O。 - 页面变暗后,按住鼠标左键拖出一个框,把要识别的文字框住。按
Esc可取消。 - 松开鼠标,右下角出现「正在截图... / 识别中...」提示。
- 识别完成后,文字已经在剪贴板里 ,可以直接粘贴。结果同时显示在页面右下角面板和弹窗文本框里,点旁边的 copy 可以再次复制。
- 截图会同时保存到浏览器下载目录的
ocr子文件夹。如果不需要,在弹窗「设置」里取消勾选即可。
在弹窗的「设置」里还可以修改:
- API 地址 (默认
http://192.168.1.28:11434); - 模型名称 (默认
qwen2.5vl:7b)。
注意:如果把 API 地址改成其他主机,需要同步修改
manifest.json里的host_permissions,然后刷新插件。
六、常见问题
| 现象 | 原因与处理 |
|---|---|
弹窗提示 API 返回 403 |
Ollama 拒绝了扩展来源。服务端设置 OLLAMA_ORIGINS=chrome-extension://* 并重启;或使用本文的自动去 Origin 逻辑 |
Failed to fetch |
网络不通:检查 Ollama 是否在运行、OLLAMA_HOST 是否为 0.0.0.0、防火墙是否放行 11434 端口 |
model not found |
模型名不对,用 ollama list 查准确名称 |
| 识别很慢 | 首次调用需要把 7B 视觉模型加载进显存;之后会快很多 |
| 结果为空或乱码 | 框选区域太小或文字太小,放大视频或扩大选框 |
| 受 DRM 保护的视频截出黑屏 | 这是浏览器的版权保护限制,无法绕过 |
chrome:// 页面和应用商店页面不能用 |
Chrome 不允许在这些页面注入脚本 |
七、总结与扩展
这个小工具把几个 Manifest V3 里比较有代表性的知识点串在了一起:
content script负责页面交互,service worker负责耗时的后台任务,两者通过消息通信;captureVisibleTab+OffscreenCanvas完成截图和裁剪,并且处理好高分屏的缩放比例;- Service Worker 访问局域网 API 时用
host_permissions绕过 CORS,并用declarativeNetRequest修改请求头; - Service Worker 无法直接操作剪贴板,借助
offscreen文档解决; - 弹窗生命周期很短,用
chrome.storage做状态中转。
后续还可以继续扩展:
- 固定区域 + 定时识别:比如每隔 3 秒自动识别视频字幕区域,实现实时字幕提取;
- 识别后自动翻译:把提示词换成"识别并翻译成中文";
- 识别结果带格式:让模型输出 Markdown 表格或 LaTeX 公式,用来提取课件中的表格和公式;
- 历史记录 :把每次识别的图片和文字存进
storage,做成可检索的列表。
整个方案的数据只在局域网内流转,不依赖任何第三方云服务,对隐私敏感的场景(内部培训视频、会议录像)尤其合适。希望这篇文章对你有帮助,如果你有更好的想法,欢迎在评论区交流。
需要的话,我可以把这篇博客保存成一个 .md 文件,方便你直接上传到 CSDN。
