Chrome 扩展实战:Canvas 图表识别、OCR 与实时数据质量监控
本文介绍一个基于 Chrome Manifest V3 的通用网页图表监测扩展。内容聚焦浏览器扩展、Canvas 图像处理、OCR、跨分辨率定位、状态同步和数据质量校验,不涉及任何具体业务平台。
前言
网页自动化开发中有一类比较棘手的需求:页面上的信息肉眼清晰可见,但程序无法通过 DOM 直接读取。
常见原因包括:
- 数据绘制在 Canvas 或 WebGL 中;
- 内容位于跨域 iframe;
- 页面只显示图形标记,没有可访问文本;
- 图表会持续更新和滚动;
- 同一个页面在不同分辨率下按比例缩放;
- 浏览器截图尺寸和 CSS 视口尺寸不一致。
最近我用 TypeScript 实现了一套 Chrome 实时图表监测扩展,完整处理了截图、动态定位、颜色分类、网格重建、OCR、多帧确认、会话隔离、数据校验、Side Panel 展示和调试日志。
这篇文章不依赖某个具体网站,重点分享其中可以复用的工程方法。
---
一、项目目标
扩展需要完成以下工作:
- 用户点击启动后,读取页面已经存在的历史图表;
- 自动定位主要渲染区域和目标图表;
- 将不同颜色的圆形标记恢复成有序数据;
- 识别页面上的会话编号、数据编号和汇总数字;
- 对图形识别结果和页面汇总结果进行交叉校验;
- 持续监测新数据并避免重复处理;
- 页面切换到新会话后,自动隔离旧数据;
- 在 Chrome Side Panel 中展示当前状态;
- 保留有限数量的诊断日志,方便远程排错;
- 支持 macOS、Windows 和 Linux 桌面版 Chrome。
最终面板会显示类似信息:
text
状态:采集中
当前会话:A53
图表识别:32项
页面汇总:总32,类型A 15,类型B 14,类型C 3
一致性检查:通过
最后更新:13:26:44
这里最重要的设计原则是可观察性。系统不仅要给出解析后的数据,还必须告诉用户数据来自哪里、识别了多少项、是否通过交叉校验。
---

二、技术难点
2.1 Canvas 中没有可以查询的节点
普通 HTML 表格可以使用选择器读取:
ts
const rows = document.querySelectorAll(".data-row");
Canvas 则不同。它把文字、圆点和网格绘制成像素,浏览器不会为其中的图形创建 DOM 节点。开发者只能:
- 找到页面已有的数据接口;
- 拦截页面内部消息;
- 或对页面可见内容进行图像分析。
当目标页面没有稳定接口,或者内容位于受限制的跨域 iframe 中时,视觉识别通常是兼容性更高的备用方案。
2.2 固定截图坐标无法跨设备运行
下面这种代码在开发者电脑上可能有效:
ts
const crop = { x: 20, y: 650, width: 430, height: 214 };
但换一台电脑后,系统缩放、浏览器缩放、窗口尺寸和设备像素比都会改变真实截图坐标。
更可靠的方法是:
- 先测量主 iframe 或 Canvas 的 CSS 边界;
- 把目标区域定义在标准舞台坐标系中;
- 根据实际舞台比例映射区域;
- 根据截图实际尺寸把 CSS 坐标换成像素坐标;
- 使用图像内容对候选区域再次评分。
2.3 实时页面存在中间状态
实时图表更新通常不是原子操作。页面可能先更新编号,再更新汇总数字,最后才绘制图形。如果每次看到一点变化就立即保存,很容易产生半成品数据。
因此需要引入:
- 采集节流;
- 单飞控制;
- 多帧一致确认;
- 数据签名去重;
- 状态机;
- 超时恢复。
三、扩展架构
整体可以拆成以下模块:
#mermaid-svg-t97hW0YiuMUMui5H{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-t97hW0YiuMUMui5H .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-t97hW0YiuMUMui5H .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-t97hW0YiuMUMui5H .error-icon{fill:#552222;}#mermaid-svg-t97hW0YiuMUMui5H .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-t97hW0YiuMUMui5H .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-t97hW0YiuMUMui5H .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-t97hW0YiuMUMui5H .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-t97hW0YiuMUMui5H .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-t97hW0YiuMUMui5H .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-t97hW0YiuMUMui5H .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-t97hW0YiuMUMui5H .marker{fill:#333333;stroke:#333333;}#mermaid-svg-t97hW0YiuMUMui5H .marker.cross{stroke:#333333;}#mermaid-svg-t97hW0YiuMUMui5H svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-t97hW0YiuMUMui5H p{margin:0;}#mermaid-svg-t97hW0YiuMUMui5H .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-t97hW0YiuMUMui5H .cluster-label text{fill:#333;}#mermaid-svg-t97hW0YiuMUMui5H .cluster-label span{color:#333;}#mermaid-svg-t97hW0YiuMUMui5H .cluster-label span p{background-color:transparent;}#mermaid-svg-t97hW0YiuMUMui5H .label text,#mermaid-svg-t97hW0YiuMUMui5H span{fill:#333;color:#333;}#mermaid-svg-t97hW0YiuMUMui5H .node rect,#mermaid-svg-t97hW0YiuMUMui5H .node circle,#mermaid-svg-t97hW0YiuMUMui5H .node ellipse,#mermaid-svg-t97hW0YiuMUMui5H .node polygon,#mermaid-svg-t97hW0YiuMUMui5H .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-t97hW0YiuMUMui5H .rough-node .label text,#mermaid-svg-t97hW0YiuMUMui5H .node .label text,#mermaid-svg-t97hW0YiuMUMui5H .image-shape .label,#mermaid-svg-t97hW0YiuMUMui5H .icon-shape .label{text-anchor:middle;}#mermaid-svg-t97hW0YiuMUMui5H .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-t97hW0YiuMUMui5H .rough-node .label,#mermaid-svg-t97hW0YiuMUMui5H .node .label,#mermaid-svg-t97hW0YiuMUMui5H .image-shape .label,#mermaid-svg-t97hW0YiuMUMui5H .icon-shape .label{text-align:center;}#mermaid-svg-t97hW0YiuMUMui5H .node.clickable{cursor:pointer;}#mermaid-svg-t97hW0YiuMUMui5H .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-t97hW0YiuMUMui5H .arrowheadPath{fill:#333333;}#mermaid-svg-t97hW0YiuMUMui5H .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-t97hW0YiuMUMui5H .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-t97hW0YiuMUMui5H .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-t97hW0YiuMUMui5H .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-t97hW0YiuMUMui5H .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-t97hW0YiuMUMui5H .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-t97hW0YiuMUMui5H .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-t97hW0YiuMUMui5H .cluster text{fill:#333;}#mermaid-svg-t97hW0YiuMUMui5H .cluster span{color:#333;}#mermaid-svg-t97hW0YiuMUMui5H div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-t97hW0YiuMUMui5H .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-t97hW0YiuMUMui5H rect.text{fill:none;stroke-width:0;}#mermaid-svg-t97hW0YiuMUMui5H .icon-shape,#mermaid-svg-t97hW0YiuMUMui5H .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-t97hW0YiuMUMui5H .icon-shape p,#mermaid-svg-t97hW0YiuMUMui5H .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-t97hW0YiuMUMui5H .icon-shape .label rect,#mermaid-svg-t97hW0YiuMUMui5H .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-t97hW0YiuMUMui5H .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-t97hW0YiuMUMui5H .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-t97hW0YiuMUMui5H :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 网页与 iframe
Content Script
Service Worker
标签页截图
图像适配器
网格与文字识别
状态跟踪器
数据质量校验
会话存储
Side Panel
| 模块 | 职责 |
|---|---|
| Content Script | 测量页面布局、观察变化、接收状态 |
| Service Worker | 标签页截图、消息路由、打开侧边栏 |
| Image Adapter | 裁剪、颜色转换、候选目标提取 |
| OCR Adapter | 文字预处理、识别与格式解析 |
| Grid Rebuilder | 聚类圆点并恢复行列顺序 |
| State Tracker | 去重、多帧确认、会话切换 |
| Validator | 比较图表数据与页面汇总数据 |
| Session Store | 保存会话状态与诊断日志 |
| Side Panel | 展示识别结果、校验状态和控制按钮 |
分层后,每个模块都能独立测试。更换 OCR 引擎不需要修改状态跟踪器,调整界面也不会影响颜色识别逻辑。
四、Manifest V3 配置
一个精简示例如下:
json
{
"manifest_version": 3,
"name": "网页图表监测助手",
"version": "1.0.0",
"description": "识别网页可视化图表并进行本地数据质量检查。",
"permissions": [
"storage",
"activeTab",
"scripting",
"tabs",
"sidePanel"
],
"host_permissions": [
"https://example.com/*"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"action": {
"default_title": "打开图表监测面板"
},
"side_panel": {
"default_path": "sidepanel.html"
},
"content_scripts": [
{
"matches": ["https://example.com/*"],
"js": ["content.js"],
"run_at": "document_idle",
"all_frames": true
}
]
}
正式发布时应遵循最小权限原则,只申请实际需要访问的域名。activeTab 适合用户主动触发的临时访问,固定站点则应使用明确的 host_permissions。
五、为什么使用 Side Panel
早期实现常把工具面板插入网页右侧,但这种方式有几个问题:
- 面板可能遮挡目标图表;
- 标签页截图可能把面板一起截进去;
- 页面自身的 CSS 会影响扩展样式;
- 页面全屏或缩放后容易错位;
- 多层滚动容器影响操作。
Chrome Side Panel 位于浏览器界面层,不属于网页截图图层,因此不会污染待分析画面。它也适合持续显示采集状态和调试信息。
打开侧边栏的 Service Worker 示例:
ts
chrome.action.onClicked.addListener(async (tab) => {
if (typeof tab.id !== "number") return;
await chrome.sidePanel.open({ tabId: tab.id });
});
六、标准舞台与坐标映射
假设页面设计稿的标准舞台尺寸为 1920 × 1080,目标图表在标准舞台中的区域为:
ts
const canonicalRegion = {
x: 20,
y: 650,
width: 430,
height: 214,
};
根据实际舞台尺寸映射:
ts
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
function scaleRect(
rect: Rect,
canonicalWidth: number,
canonicalHeight: number,
actualWidth: number,
actualHeight: number,
): Rect {
const scaleX = actualWidth / canonicalWidth;
const scaleY = actualHeight / canonicalHeight;
return {
x: rect.x * scaleX,
y: rect.y * scaleY,
width: rect.width * scaleX,
height: rect.height * scaleY,
};
}
随后将 CSS 坐标转换为截图像素坐标:
ts
function cssToScreenshot(
rect: Rect,
viewport: { width: number; height: number },
screenshot: { width: number; height: number },
): Rect {
const ratioX = screenshot.width / viewport.width;
const ratioY = screenshot.height / viewport.height;
return {
x: Math.round(rect.x * ratioX),
y: Math.round(rect.y * ratioY),
width: Math.round(rect.width * ratioX),
height: Math.round(rect.height * ratioY),
};
}
不要直接假设截图比例等于 devicePixelRatio。浏览器页面缩放与操作系统缩放可能共同影响结果,使用实际截图尺寸计算更稳妥。
七、候选区域评分
只依赖相对位置仍然可能截到错误区域。可以在目标位置附近生成多个候选框,再按照内容特征评分。
text
候选得分 = 目标颜色密度 × 0.30
+ 圆形区域数量 × 0.25
+ 网格间距稳定性 × 0.20
+ 横纵边界清晰度 × 0.10
+ 位置先验 × 0.10
+ 跨帧稳定性 × 0.05
其中:
- 颜色密度用于排除纯文字或视频区域;
- 圆形区域数量用于确认图表结构;
- 网格间距用于排除装饰图案;
- 跨帧稳定性用于排除动画元素。
只有得分超过最低阈值的候选区域才进入完整识别流程。
八、HSV 颜色分类
RGB 对亮度变化比较敏感,HSV 将色相、饱和度和明度分离,更适合识别颜色类别。
ts
type VisualLabel = "type-a" | "type-b" | "type-c" | "unknown";
function classifyHsv(h: number, s: number, v: number): VisualLabel {
if (s < 0.35 || v < 0.25) return "unknown";
if (h <= 15 || h >= 345) return "type-a";
if (h >= 195 && h <= 235) return "type-b";
if (h >= 90 && h <= 155) return "type-c";
return "unknown";
}
实际处理中,不要根据单个像素下结论。更可靠的方法是:
- 在候选区域中统计每一类像素数量;
- 过滤低饱和度背景;
- 计算中心区域和边缘区域密度;
- 使用多数票确定类别;
- 保存类别置信度。
浏览器抗锯齿会在圆形边缘产生大量过渡色,因此阈值需要通过多张脱敏截图回归测试确定。
九、连通区域检测
颜色二值化后,可以用 BFS 提取连通区域:
ts
interface Point {
x: number;
y: number;
}
function neighbors(x: number, y: number): Point[] {
return [
{ x: x - 1, y },
{ x: x + 1, y },
{ x, y: y - 1 },
{ x, y: y + 1 },
];
}
每个连通区域需要计算:
- 面积;
- 包围盒;
- 中心点;
- 宽高比;
- 填充率;
- 颜色类别;
- 类别置信度。
过滤规则可以包括:
text
面积过小:很可能是抗锯齿噪声
面积过大:很可能是背景色块
宽高比偏差过大:不是圆形标记
中心密度过低:可能只是文字边缘
与其他标记间距异常:可能来自其他组件
十、六行网格重建
识别出圆点中心后,还要恢复图表的真实排列顺序。
如果图表按固定六行、从上到下填充,再移动到下一列,就不能简单地按照横纵坐标总和排序。
正确流程是:
- 按横坐标初步排序;
- 根据横向距离进行列聚类;
- 每列内部按纵坐标排序;
- 将中心点吸附到最近的标准网格;
- 过滤同一网格中的重复候选;
- 按列优先输出最终序列。
ts
interface Marker {
x: number;
y: number;
label: VisualLabel;
confidence: number;
}
function rebuildColumns(markers: Marker[], tolerance: number): Marker[][] {
const columns: Marker[][] = [];
for (const marker of [...markers].sort((a, b) => a.x - b.x)) {
const column = columns.find((items) =>
Math.abs(items[0].x - marker.x) <= tolerance,
);
if (column) column.push(marker);
else columns.push([marker]);
}
for (const column of columns) {
column.sort((a, b) => a.y - b.y);
}
return columns;
}
当图表填满后发生横向滚动,旧列会消失。此时可以比较新旧序列的最长公共区段,判断本次变化是追加数据还是整体重置。
十一、OCR 预处理
编号和汇总数字适合交给 OCR,但原始截图通常需要预处理。
常见步骤:
text
裁剪目标文字区域
放大2到4倍
灰度化
对比度拉伸
自适应二值化
去除小面积噪声
设置字符白名单
根据文本布局选择识别模式
编号区域可以限制为大写字母和数字:
ts
const whitelist = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
汇总区域则只允许数字和少量分隔符。分别配置识别参数通常比对整张图使用统一配置准确。
OCR 输出还必须经过格式解析和基本约束检查:
ts
function parseSessionId(text: string): string | null {
const normalized = text.toUpperCase().replace(/\s+/g, "");
const match = normalized.match(/[A-Z]\d{1,3}/);
return match?.[0] ?? null;
}
不要把 OCR 原始文本直接作为业务状态。
十二、多帧稳定确认
OCR 偶尔会混淆 0/O、1/I、5/S。如果一次识别结果就触发会话重置,系统会非常不稳定。
可以建立一个稳定值跟踪器:
ts
class StableValue<T> {
private candidate: T | null = null;
private repeated = 0;
push(value: T, required = 2): T | null {
if (Object.is(value, this.candidate)) {
this.repeated += 1;
} else {
this.candidate = value;
this.repeated = 1;
}
return this.repeated >= required ? value : null;
}
clear(): void {
this.candidate = null;
this.repeated = 0;
}
}
对于会话切换,可以要求多种证据同时成立:
- 新会话编号连续多帧一致;
- 数据编号格式与会话编号匹配;
- 图表数量和汇总数量一致;
- 页面主要渲染区域仍然存在。
这样可以防止一帧噪声破坏当前状态。
十三、实时状态机
一个通用状态机如下:
#mermaid-svg-3K2pADBf2g7xmiYS{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3K2pADBf2g7xmiYS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3K2pADBf2g7xmiYS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3K2pADBf2g7xmiYS .error-icon{fill:#552222;}#mermaid-svg-3K2pADBf2g7xmiYS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3K2pADBf2g7xmiYS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3K2pADBf2g7xmiYS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3K2pADBf2g7xmiYS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3K2pADBf2g7xmiYS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3K2pADBf2g7xmiYS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3K2pADBf2g7xmiYS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3K2pADBf2g7xmiYS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3K2pADBf2g7xmiYS .marker.cross{stroke:#333333;}#mermaid-svg-3K2pADBf2g7xmiYS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3K2pADBf2g7xmiYS p{margin:0;}#mermaid-svg-3K2pADBf2g7xmiYS defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-3K2pADBf2g7xmiYS g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-3K2pADBf2g7xmiYS g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-3K2pADBf2g7xmiYS g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-3K2pADBf2g7xmiYS g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-3K2pADBf2g7xmiYS g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-3K2pADBf2g7xmiYS .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-3K2pADBf2g7xmiYS .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-3K2pADBf2g7xmiYS .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-3K2pADBf2g7xmiYS .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-3K2pADBf2g7xmiYS .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-3K2pADBf2g7xmiYS .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-3K2pADBf2g7xmiYS .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-3K2pADBf2g7xmiYS .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3K2pADBf2g7xmiYS .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3K2pADBf2g7xmiYS .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3K2pADBf2g7xmiYS .edgeLabel .label text{fill:#333;}#mermaid-svg-3K2pADBf2g7xmiYS .label div .edgeLabel{color:#333;}#mermaid-svg-3K2pADBf2g7xmiYS .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-3K2pADBf2g7xmiYS .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-3K2pADBf2g7xmiYS .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-3K2pADBf2g7xmiYS .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-3K2pADBf2g7xmiYS .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-3K2pADBf2g7xmiYS .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3K2pADBf2g7xmiYS .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3K2pADBf2g7xmiYS #statediagram-barbEnd{fill:#333333;}#mermaid-svg-3K2pADBf2g7xmiYS .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3K2pADBf2g7xmiYS .cluster-label,#mermaid-svg-3K2pADBf2g7xmiYS .nodeLabel{color:#131300;}#mermaid-svg-3K2pADBf2g7xmiYS .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-3K2pADBf2g7xmiYS .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-3K2pADBf2g7xmiYS .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-3K2pADBf2g7xmiYS .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-3K2pADBf2g7xmiYS .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-3K2pADBf2g7xmiYS .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-3K2pADBf2g7xmiYS .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-3K2pADBf2g7xmiYS .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-3K2pADBf2g7xmiYS .note-edge{stroke-dasharray:5;}#mermaid-svg-3K2pADBf2g7xmiYS .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-3K2pADBf2g7xmiYS .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-3K2pADBf2g7xmiYS .statediagram-note text{fill:black;}#mermaid-svg-3K2pADBf2g7xmiYS .statediagram-note .nodeLabel{color:black;}#mermaid-svg-3K2pADBf2g7xmiYS .statediagram .edgeLabel{color:red;}#mermaid-svg-3K2pADBf2g7xmiYS #dependencyStart,#mermaid-svg-3K2pADBf2g7xmiYS #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-3K2pADBf2g7xmiYS .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-3K2pADBf2g7xmiYS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 启动
检测到变化
证据不足
多帧确认成功
保存并更新面板
确认新会话
清空旧数据并导入新数据
暂停
继续
退出
Idle
Collecting
Candidate
Confirmed
Switching
Paused
会话切换时需要清理:
- 当前历史数据;
- 当前候选状态;
- OCR 稳定缓存;
- 图表对齐缓存;
- 页面截图签名;
- 当前会话显示记录。
切换完成后,系统重新识别新页面已经存在的完整图表,而不是只等待下一条数据。
十四、交叉校验与数据质量
假设视觉模块识别出三种类型,可以计算汇总:
ts
interface Summary {
total: number;
typeA: number;
typeB: number;
typeC: number;
}
function summarize(labels: VisualLabel[]): Summary {
const valid = labels.filter((item) => item !== "unknown");
return {
total: valid.length,
typeA: valid.filter((item) => item === "type-a").length,
typeB: valid.filter((item) => item === "type-b").length,
typeC: valid.filter((item) => item === "type-c").length,
};
}
页面汇总数据必须满足:
text
total = typeA + typeB + typeC
所有字段都是非负整数
数据更新后总数变化符合页面规则
图表统计与页面汇总一致
校验状态建议分为:
| 状态 | 含义 |
|---|---|
| 一致 | 图表和页面汇总完全一致 |
| 待确认 | 页面正在更新,连续帧尚未稳定 |
| 不一致 | 数量或分类汇总存在差异 |
| 不可用 | 当前帧无法获得可靠数据 |
出现"不一致"时,不要覆盖最后一份已确认数据。应保留旧状态并继续采集,直到获得稳定的新结果。
十五、采集节流与单飞控制
标签页截图和 OCR 都比较消耗资源。合理的策略是按需触发,而不是无限高频运行完整流程。
text
MutationObserver:监听轻量页面变化
布局测量:页面尺寸变化时运行
截图:怀疑可视数据变化时运行
颜色识别:目标区域签名变化时运行
OCR:编号或汇总需要确认时运行
完整备用识别:主视觉流程失败时运行
为了避免并发识别导致状态乱序,可以使用单飞控制:
ts
let collecting = false;
async function collectOnce(): Promise<void> {
if (collecting) return;
collecting = true;
try {
await captureAndAnalyze();
} finally {
collecting = false;
}
}
还可以对目标区域生成采样哈希:
ts
function sampleHash(data: Uint8ClampedArray, step = 64): number {
let hash = 2166136261;
for (let index = 0; index < data.length; index += step) {
hash ^= data[index];
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
签名未变化时可以跳过完整识别,显著降低 CPU 使用率。
十六、Side Panel 布局
面板信息建议按照用户排错顺序排列:
- 状态与启动、暂停、重新识别按钮;
- 当前会话和数据编号;
- 图表识别数量;
- 页面汇总数字;
- 一致性校验状态;
- 最近确认的数据;
- 可折叠诊断信息;
- 日志导出和清空按钮。
不要在 Side Panel 内部创建多层独立滚动区域。浏览器侧边栏本身已经可以滚动,嵌套滚动会让触控板和鼠标滚轮操作变得困难。
需要折叠的内容可以使用原生 <details>:
html
<details>
<summary>识别诊断</summary>
<dl>
<dt>截图尺寸</dt>
<dd>2560 × 1440</dd>
<dt>目标区域</dt>
<dd>620 × 308</dd>
<dt>候选标记</dt>
<dd>32</dd>
</dl>
</details>
更新界面时要保留 details.open 状态,否则实时刷新会导致用户刚展开的内容自动关闭。
十七、临时调试日志
视觉问题高度依赖设备和页面状态。开发者电脑正常,不代表用户的缩放与分辨率组合也正常。
建议记录:
text
时间与日志级别
模块名称
视口尺寸
截图尺寸
设备像素比
主iframe边界
映射后的裁剪区域
候选区域评分
颜色候选数量
OCR原始文本
OCR置信度
解析后的编号
图表汇总
页面汇总
校验状态
异常堆栈
日志应设置容量上限,例如只保留最近 300 条:
ts
const MAX_LOGS = 300;
function appendLog(logs: DebugLog[], next: DebugLog): DebugLog[] {
return [...logs, next].slice(-MAX_LOGS);
}
同时注意隐私:
- 不记录账号和密码;
- 不记录 Cookie、令牌和请求头;
- 不持久化完整页面截图;
- 导出日志前提示用户检查内容;
- 提供一键清空功能。
十八、跨系统兼容
Chrome 扩展使用标准 Web API,只要不依赖操作系统专属接口,通常可以同时运行于:
- macOS 桌面版 Chrome;
- Windows 桌面版 Chrome;
- Linux 桌面版 Chrome;
- 其他兼容 Chromium Extension API 的桌面浏览器。
需要重点测试:
text
100%、125%、150%、200%系统缩放
不同devicePixelRatio
浏览器页面缩放
窗口化与全屏
不同Side Panel宽度
Retina与普通显示器
多显示器切换
页面布局动态变化
这里的跨系统支持是指桌面环境。iOS 和 Android 版 Chrome 不支持常规桌面扩展,不能笼统宣传为所有终端均可安装。
开发版安装步骤:
- 打开
chrome://extensions/; - 开启"开发者模式";
- 点击"加载已解压的扩展程序";
- 选择包含
manifest.json的构建目录; - 更新代码后点击扩展卡片中的"重新加载"。
十九、测试策略
19.1 纯函数测试
适合单元测试的模块包括:
- 标准舞台坐标映射;
- CSS 坐标到截图像素转换;
- HSV 颜色分类;
- 连通区域过滤;
- 列聚类与网格排序;
- OCR 文本格式解析;
- 汇总统计和一致性校验;
- 数据签名和去重。
19.2 状态机测试
重点测试:
text
暂停后不继续采集
相同数据不会重复保存
中间帧不会覆盖已确认数据
多帧一致后才确认更新
短暂OCR错误不触发会话切换
确认新会话后旧数据被清空
新会话已有图表可以被完整导入
扩展上下文失效后停止定时任务
19.3 固定截图回归
准备经过脱敏的测试截图,覆盖:
- 不同分辨率;
- 不同系统缩放;
- 图表项目较少和较多;
- 图表滚动前后;
- 动画遮挡;
- 深色和浅色背景;
- OCR 清晰与模糊场景。
每张截图断言目标区域、标记数量、排列顺序和汇总数字。
19.4 构建验证
发布前运行:
bash
npm test
npm run typecheck
npm run build
还要在 chrome://extensions/ 检查:
- Service Worker 是否正常启动;
- Content Script 是否成功注入;
- Side Panel 是否可以打开;
- OCR Worker 和语言资源是否存在;
- 是否出现权限或扩展上下文错误。
二十、常见故障与处理
20.1 Extension context invalidated
开发过程中重新加载扩展后,旧页面里的 Content Script 仍属于旧扩展上下文。继续调用 chrome.runtime.sendMessage 会抛出此错误。
处理方式:
- 捕获消息发送异常;
- 停止旧定时器;
- 提示刷新目标页面;
- 重新注入最新版 Content Script。
20.2 截图成功但识别数量为零
检查顺序:
- 记录视口和截图尺寸;
- 输出映射后的裁剪区域;
- 检查裁剪是否超出截图边界;
- 保存脱敏裁剪图进行调试;
- 检查 HSV 阈值和最小面积;
- 检查页面是否正在动画或遮挡状态。
20.3 项目较多时顺序错误
通常是因为:
- 使用了简单横向排序;
- 列聚类容差过小;
- 相邻列被错误合并;
- 没有处理图表滚动;
- 把其他区域的小圆点纳入候选。
应结合网格间距估计、列内纵向排序和公共区段对齐修复。
20.4 面板展开后自动关闭
如果每次状态更新都重建整个面板 DOM,<details> 的展开状态会丢失。更新前保存 open,渲染后恢复,或者只更新变化的子节点。
二十一、工程经验总结
经验一:输入质量优先于后续分析
任何统计和展示都建立在正确输入上。区域定位、颜色分类、网格排序和交叉校验应当先于复杂功能开发。
经验二:破坏性状态切换需要强证据
会话切换会清空当前数据,不能由一帧 OCR 结果触发。多帧确认和多数据源一致性非常重要。
经验三:页面可观察性应该做进产品
让用户直接看见会话编号、识别数量、页面汇总和校验状态,比在后台悄悄失败更容易维护。
经验四:标准坐标与视觉验证需要结合
完全固定像素无法跨设备使用,完全放弃位置先验又会扩大误识别范围。标准舞台映射负责缩小搜索空间,视觉评分负责确认内容。
经验五:不要无限提高采集频率
提高频率不能解决中间状态问题,反而会增加 OCR 并发和状态乱序。状态机、多帧确认和按需触发比简单轮询更可靠。
经验六:远程问题必须依靠结构化日志
截图尺寸、裁剪区域、候选数量和 OCR 原文等数据,可以快速判断错误发生在哪一层。
二十二、后续优化方向
可以继续研究:
- 使用 OffscreenCanvas 与 Worker 分离图像计算;
- 使用 WebAssembly 加速颜色分割和连通区域检测;
- 使用感知哈希识别页面布局版本;
- 使用轻量目标检测模型定位图表;
- 建立脱敏截图数据集做跨设备回归;
- 根据页面可见性自动降低后台采集频率;
- 对 OCR 候选执行字符级约束纠错;
- 增加可视化裁剪调试模式;
- 对不同布局建立可配置适配器。
总结
一个可靠的 Chrome 网页图表监测扩展,需要同时解决:
text
Manifest V3扩展架构
+ iframe与页面布局测量
+ 标签页截图
+ 跨分辨率坐标映射
+ HSV颜色分类
+ 连通区域检测
+ 网格聚类与顺序重建
+ OCR预处理与多帧确认
+ 实时状态机
+ 会话数据隔离
+ 数据质量交叉校验
+ Side Panel可视化
+ 结构化调试日志
在这类项目中,真正困难的通常不是某一个算法,而是如何让多个模块在页面持续变化、设备尺寸不同和识别结果偶尔不稳定的情况下协同工作。
本文中的架构和代码可以用于监测大屏看板、设备状态面板、赛事数据图、质量检测图表以及其他经过授权的网页可视化系统。
如果你正在开发 Chrome Extension、Canvas 图像识别、OCR 或实时数据监控工具,欢迎在评论区交流技术实现。
使用边界
本文仅讨论通用浏览器扩展和计算机视觉工程。实施网页采集前,请确认拥有相应权限,并遵守目标网站服务条款、数据安全要求、Chrome 扩展政策和所在地法律法规。不要采集账号、身份信息、访问令牌或其他敏感数据。
文章标签
text
Chrome Extension
Manifest V3
TypeScript
Canvas
OCR
计算机视觉
图像处理
网页自动化
实时数据监控
浏览器插件
前端工程
SEO 摘要
text
本文介绍如何使用TypeScript和Chrome Manifest V3开发跨平台网页图表监测扩展,涵盖iframe布局测量、标签页截图、自适应区域定位、HSV颜色分类、连通区域检测、网格聚类、OCR多帧确认、会话隔离、数据质量校验、Side Panel展示、调试日志和跨系统兼容。