# Chrome纸牌游戏预测工具开发实战:Canvas图表识别、OCR与实时状态监控

Chrome 扩展实战:Canvas 图表识别、OCR 与实时数据质量监控

本文介绍一个基于 Chrome Manifest V3 的通用网页图表监测扩展。内容聚焦浏览器扩展、Canvas 图像处理、OCR、跨分辨率定位、状态同步和数据质量校验,不涉及任何具体业务平台。

前言

网页自动化开发中有一类比较棘手的需求:页面上的信息肉眼清晰可见,但程序无法通过 DOM 直接读取。

常见原因包括:

  • 数据绘制在 Canvas 或 WebGL 中;
  • 内容位于跨域 iframe;
  • 页面只显示图形标记,没有可访问文本;
  • 图表会持续更新和滚动;
  • 同一个页面在不同分辨率下按比例缩放;
  • 浏览器截图尺寸和 CSS 视口尺寸不一致。

最近我用 TypeScript 实现了一套 Chrome 实时图表监测扩展,完整处理了截图、动态定位、颜色分类、网格重建、OCR、多帧确认、会话隔离、数据校验、Side Panel 展示和调试日志。

这篇文章不依赖某个具体网站,重点分享其中可以复用的工程方法。

---

一、项目目标

扩展需要完成以下工作:

  1. 用户点击启动后,读取页面已经存在的历史图表;
  2. 自动定位主要渲染区域和目标图表;
  3. 将不同颜色的圆形标记恢复成有序数据;
  4. 识别页面上的会话编号、数据编号和汇总数字;
  5. 对图形识别结果和页面汇总结果进行交叉校验;
  6. 持续监测新数据并避免重复处理;
  7. 页面切换到新会话后,自动隔离旧数据;
  8. 在 Chrome Side Panel 中展示当前状态;
  9. 保留有限数量的诊断日志,方便远程排错;
  10. 支持 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 };

但换一台电脑后,系统缩放、浏览器缩放、窗口尺寸和设备像素比都会改变真实截图坐标。

更可靠的方法是:

  1. 先测量主 iframe 或 Canvas 的 CSS 边界;
  2. 把目标区域定义在标准舞台坐标系中;
  3. 根据实际舞台比例映射区域;
  4. 根据截图实际尺寸把 CSS 坐标换成像素坐标;
  5. 使用图像内容对候选区域再次评分。

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";
}

实际处理中,不要根据单个像素下结论。更可靠的方法是:

  1. 在候选区域中统计每一类像素数量;
  2. 过滤低饱和度背景;
  3. 计算中心区域和边缘区域密度;
  4. 使用多数票确定类别;
  5. 保存类别置信度。

浏览器抗锯齿会在圆形边缘产生大量过渡色,因此阈值需要通过多张脱敏截图回归测试确定。


九、连通区域检测

颜色二值化后,可以用 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 复制代码
面积过小:很可能是抗锯齿噪声
面积过大:很可能是背景色块
宽高比偏差过大:不是圆形标记
中心密度过低:可能只是文字边缘
与其他标记间距异常:可能来自其他组件

十、六行网格重建

识别出圆点中心后,还要恢复图表的真实排列顺序。

如果图表按固定六行、从上到下填充,再移动到下一列,就不能简单地按照横纵坐标总和排序。

正确流程是:

  1. 按横坐标初步排序;
  2. 根据横向距离进行列聚类;
  3. 每列内部按纵坐标排序;
  4. 将中心点吸附到最近的标准网格;
  5. 过滤同一网格中的重复候选;
  6. 按列优先输出最终序列。
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/O1/I5/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 布局

面板信息建议按照用户排错顺序排列:

  1. 状态与启动、暂停、重新识别按钮;
  2. 当前会话和数据编号;
  3. 图表识别数量;
  4. 页面汇总数字;
  5. 一致性校验状态;
  6. 最近确认的数据;
  7. 可折叠诊断信息;
  8. 日志导出和清空按钮。

不要在 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 不支持常规桌面扩展,不能笼统宣传为所有终端均可安装。

开发版安装步骤:

  1. 打开 chrome://extensions/
  2. 开启"开发者模式";
  3. 点击"加载已解压的扩展程序";
  4. 选择包含 manifest.json 的构建目录;
  5. 更新代码后点击扩展卡片中的"重新加载"。

十九、测试策略

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 截图成功但识别数量为零

检查顺序:

  1. 记录视口和截图尺寸;
  2. 输出映射后的裁剪区域;
  3. 检查裁剪是否超出截图边界;
  4. 保存脱敏裁剪图进行调试;
  5. 检查 HSV 阈值和最小面积;
  6. 检查页面是否正在动画或遮挡状态。

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展示、调试日志和跨系统兼容。
相关推荐
SamChan902 小时前
在Web应用中集成PDF多语言翻译功能:PDFTranslator API实战指南
前端·python·ai·pdf·yapi·机器翻译
天天进步20153 小时前
Python全栈项目--智能办公自动化系统
开发语言·python
颜酱3 小时前
09 | 重构项目结构
人工智能·python·langchain
EanoJiang4 小时前
【UE】混合程序动画
游戏
Sisphusssss5 小时前
香橙派5plus GPIO
linux·python·ubuntu
颜酱5 小时前
08 | 把维度值同步到 Elasticsearch(生成阶段)
人工智能·python·langchain
久久学姐6 小时前
基础转码学 AI:Java+Python 双语言入门,3 个月可落地实战项目
java·python·ai·转码·实战项目
ZHOU_WUYI6 小时前
4. light wam 模型loss计算过程
开发语言·人工智能·python
nwsuaf_huasir6 小时前
【无标题】
python
Python私教7 小时前
Codex 写出的代码能跑却算错钱:我用 3 个测试拆穿一次 AI 编程幻觉
python·单元测试·ai编程