ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从脚本到扩展:用OpenClaw打造Chrome巡检自动化工具

从脚本到扩展:用OpenClaw打造Chrome巡检自动化工具

养虾这事听起来跟Chrome扩展八竿子打不着,但如果你每天都在"人人养虾"这个管理平台上来回点页面,你会明白我为什么最后会走上自己写扩展这条路。从一个简单想法——"能不能把每天巡检虾池的重复操作变成一键完成",到最终把OpenClaw这个轻量浏览器自动化框架嵌进扩展里落地,整个过程踩了不少坑,也攒了一些很实用的经验。这篇博文就把这个扩展从设计思路到代码实现、从踩坑修复到优化重构的过程完整记录下来,给同样被重复网页操作折磨的人做个参考。

1. 为什么是"扩展"而不是"脚本":从日常巡检的重复劳动说起

1.1 每天都要重复的页面操作到底有多烦

我这边打理着几个模拟养殖池,每个池子每天要记录水温、pH、氨氮含量、溶氧量这几项指标。平台倒是有数据录入界面,但它设计得很偏"表单流",也就是说一次完整记录需要这样操作:点进虾池列表、打开某个池子详情、切到水质登记标签、填温度、填pH、填氨氮、填溶氧、选状态、点保存。九个动作,一天几个池子就是几十次点击,其中大部分点击路径完全相同,只是填的值不一样。

一开始我的做法是开着浏览器控制台,手写一段JavaScript去模拟点击和填表。临时用确实爽,但问题是页面一刷新脚本就没了,换个池子又要重新黏贴,而且控制台里输入的代码多了之后特别容易出错。后来又试过油猴脚本,它虽然能常驻页面,但对运行时机、跨域请求、数据缓存这些事处理起来挺难受,页面改版一次我就要跟着改一遍脚本逻辑,改了之后还不一定立刻生效。

真正让我决定换方案的事情发生在一个周末。那天我写了一段用户脚本,想把整个虾池列表的状态汇总展示在页面顶部,结果脚本加载后跟页面里原有的一个弹窗组件冲突了,两个都在抢同一个全局变量,直接导致页面上的筛选按钮失效。我花了一个小时排查,最后发现是脚本里某个变量名把页面原生的方法覆盖了。那会儿我意识到,在页面自己的JavaScript环境里做事情,终究是在别人的地盘上跳舞,两边共享一套全局作用域,出问题只是迟早的事。

1.2 扩展能带来什么不一样的体验

Chrome扩展跟脚本的核心区别在于,它有一套自己独立的运行环境,并且浏览器会为它提供"配置存储""后台调度""界面入口""跨页面消息"这些能力。也就是说我不需要再担心我的代码污染页面环境,也不需要在每次刷新之后重新加载脚本。我可以把扩展做成一个常驻的小工具,打开浏览器它就在,点一下图标就能跑任务,处理完结果自己存起来,下次打开还在。

这对"人人养虾"这个场景非常合适。因为巡检动作不是一次性的,而是每天固定要做的重复流程,我需要的是一个稳定、可重复、可配置的自动化工具,而不是一段随时会失效的临时脚本。更重要的是,扩展可以同时处理多个页面状态:我可以从列表页读取所有池子的名称和概要信息,再逐个打开详情页补充读取水质数据,最后把全部门数据汇总成一张表——这种跨页面的任务用脚本很难优雅实现,但用扩展的service worker加content script配合起来就好做得多。

所以我的结论是:凡是需要在浏览器里长期运行、跨页面操作、需要保存任务状态和数据的历史记录的自动化需求,都应该优先考虑写扩展,而不是图省事继续折腾脚本。这个判断在后面OpenClaw的集成过程中被验证得更加明显。

2. OpenClaw只做编排:扩展里各模块的真实分工

2.1 OpenClaw到底帮我解决了什么问题

严格来说,OpenClaw并不是一个"点击器",而是一个面向浏览器自动化的任务编排框架。它做的事情可以理解成:把"打开页面A、读取若干个字段、判断哪些值超标、按条件修改页面状态"这样一系列动作,从写死的命令式代码,变成一份可配置的、可扩展的任务清单。任务中的每一步都描述为"找什么元素、做什么操作、返回什么结果",框架拿到这份清单后负责按顺序执行、超时处理、错误聚合。

我不需要把每个任务写成一长串的if else判断,也不用在页面改版的时候把所有代码翻出来逐行改。我只需要在任务配置里写明选择器和操作意图,OpenClaw会统一处理"找没找到元素""元素是否可见""操作是否成功"这些细节。它比我一开始设想的"脚本+选择器"方案多了一层抽象,而这层抽象恰恰是后期维护时最值钱的部分。

打个比方:以前的脚本写法相当于你每次出门都重新规划一条从家到公司的路线,路况变了就得全部重想;OpenClaw这种框架的写法相当于你把"从家到公司"定义成任务,路线细节都放在配置里,某段路因为施工不通了,我只改配置里这一段就好,不必重写整个出门流程。

2.2 扩展内部拆成四块,各干各的

整个扩展我按照"界面、调度、执行、配置"四个维度拆成了四个部分,OpenClaw的核心引擎放在调度和执行之间充当连接层。下面这张表是我最后定下来的模块划分:

模块职责对应文件OpenClaw参与程度
Popup界面用户点击工具栏图标后看到的弹窗,列出可执行任务、展示执行结果popup.html + popup.js低,只负责发起和展示
Service Worker后台接收任务请求,创建任务引擎实例,调度整个流程background.js高,核心编排逻辑在这层
Content Script内容脚本真正操作页面DOM,读取数据、模拟点击、修改状态content.js中,负责执行单步操作
配置与存储保存选择器、阈值、任务列表、执行日志options.html + chrome.storage低,但贯穿所有模块

一开始我犯过一个设计错误:把大部分逻辑都塞进了content script里面。当时想着反正页面操作都在content script里做,那为什么不把整个任务流程也直接在content script里跑完?结果很快就发现这条路不通。content script伴随着页面加载而存在,页面一刷新或者跳转,脚本的执行上下文就全部销毁,一个需要跨页面执行的长任务会被拦腰截断。更麻烦的是,如果用户直接把页面关掉了,content script里保存的任务状态也跟着消失,下次恢复无从谈起。

正确的做法是把content script当成"执行器"而不是"大脑":它只负责从消息里接收一条具体指令,比如"找到class为xxx的元素并点击",执行完把结果返回给后台。至于下一步做什么、整个任务做到第几步了、失败了要重试还是要跳过,这些判断全部放在service worker里。OpenClaw的任务引擎就住在service worker里,它看着任务清单,一步一步地把"该执行什么了"发给content script,然后接收结果继续推进。

2.3 为什么不直接把任务跑在service worker里

有朋友问我,既然OpenClaw在service worker里做编排,那它能不能直接操作DOM?不能。service worker没有页面上下文,接触不到任何DOM元素,它只能通过消息机制跟content script沟通。所以整个架构里必然有一条消息链路:popup发起任务、service worker编排步骤、content script在页面里真实执行动作、结果再原路返回。OpenClaw在service worker这层做得好的地方在于,它把消息往返、超时、重试这些底层机制封装好了,我只需要关心任务本身每一步做什么。

这带来的另一个好处是,将来如果哪天不想用content script了,换成官网提供的API方式做数据读取,扩展整体架构不用动,只要把content script这一层替换成API调用适配器就行,编排逻辑不受影响。这种"执行层与编排层解耦"的设计,是我在这个项目中最终确定的架构,也是我在实际使用中觉得最有价值的地方。

3. 从manifest到第一版可用任务:一步步写出来

3.1 manifest是个门面,也是权限边界

写Chrome扩展的第一步就是写manifest.json。在Manifest V3时代,很多东西比V2严格得多,比如后台页面改成了service worker,比如远程代码一律不允许加载。开发之前建议先确认浏览器版本,直接基于Manifest V3来写,不要为一时方便回退到V2,否则后面审核和签发的坑会更多。

我项目里的manifest.json关键部分长这样:

{ "manifest_version": 3, "name": "OpenClaw - 人人养虾助手", "version": "0.3.2", "description": "为养殖管理页面提供一键巡检与批量登记能力", "permissions": ["storage", "activeTab", "notifications"], "host_permissions": [ "https://platform.example.com/*" ], "action": { "default_popup": "popup.html" }, "background": { "service_worker": "background.js", "type": "module" }, "content_scripts": [ { "matches": ["https://platform.example.com/*"], "js": ["vendor/openclaw-engine.js", "content.js"], "run_at": "document_idle" } ] }

值得展开说两句的是permissions这一块。我最终只保留了storage、activeTab和notifications三个权限。storage用来保存任务配置和执行记录,activeTab用来在用户主动点击扩展时获得当前页面操作权限,notifications用来做巡检结果提醒。host_permissions没有写*:///,而是收窄到了平台域名下。这么做主要是两个原因:一是权限越少越好,避免扩展被浏览器判定为"过度敏感",上架审核或者公司内部发布的时候少一些审查麻烦;二是从安全性角度考虑,明确了扩展只能读哪些站点,即使代码被注入恶意逻辑,它能影响的范围也是有限的。

3.2 Content Script只管"看"和"点"

content.js我写得很克制,几乎不承载任何业务逻辑,只做三件事:接收任务步骤、执行DOM操作、返回结果。它通过chrome.runtime.onMessage监听来自service worker的消息,消息体里带着步骤类型和选择器。我的实现大概长这样:

// content.js chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (!message || message.channel !== 'openclaw-step') { return; } const { stepId, action, selector, value } = message.payload; const handleResult = (result) => { sendResponse({ ok: true, stepId, result }); }; const handleError = (error) => { sendResponse({ ok: false, stepId, error: error.message }); }; try { switch (action) { case 'readText': handleResult(readTextBySelector(selector)); break; case 'collectTable': handleResult(collectTableData(selector)); break; case 'setValue': handleResult(setFieldValue(selector, value)); break; case 'click': { const element = queryElement(selector); element.click(); handleResult({ clicked: true }); break; } default: handleError(new Error('未知动作类型: ' + action)); } } catch (error) { handleError(error); } }); function queryElement(selector) { const element = document.querySelector(selector); if (!element) { throw new Error('未找到元素: ' + selector); } return element; } function readTextBySelector(selector) { const element = queryElement(selector); return { text: element.innerText || element.textContent || '' }; } function collectTableData(selector) { const rows = document.querySelectorAll(selector + ' tr'); const data = []; rows.forEach((row) => { const cells = Array.from(row.querySelectorAll('td, th')); data.push(cells.map((cell) => cell.innerText.trim())); }); return { rows: data }; } function setFieldValue(selector, value) { const element = queryElement(selector); const prototype = Object.getPrototypeOf(element); const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value'); descriptor.set.call(element, value); element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); return { value: element.value }; }

这个文件的风格就是"你能想到的页面操作原语,我就提供对应函数"。至于哪个该读、哪个该点、读完怎么判断,那是service worker里OpenClaw引擎的事,跟它没有关系。

有一点值得单独提醒:设置输入框值的时候,直接用element.value = value在某些框架页面里不会生效,因为页面框架自定义的响应逻辑监听的是input事件,而直接赋值并不会触发事件。所以我用了原型链上的value描述符来赋值,然后手动派发input和change事件。这一步是很多自动化脚本在填表时失灵的根源。

3.3 Service Worker里跑着真正的编排引擎

background.js是扩展真正的大脑。它初始化一个OpenClaw任务引擎实例,接收popup发来的执行请求,然后按任务配置逐步执行。因为service worker是模块化的,我可以直接import OpenClaw的引擎代码。

// background.js import { TaskEngine } from './vendor/openclaw-engine.js'; const engine = new TaskEngine({ executeStep: async (step) => { const tab = await getActiveTab(); const response = await chrome.tabs.sendMessage(tab.id, { channel: 'openclaw-step', payload: step }); return response; } }); chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.channel === 'openclaw-run-task') { engine .run(message.taskId, message.params) .then((result) => sendResponse({ ok: true, result })) .catch((error) => sendResponse({ ok: false, error: error.message })); return true; } });

这个例子里的executeStep回调很关键。它告诉引擎:你要执行一步动作的时候,就去调用我提供的这个函数,这个函数会找到当前激活的标签页,把步骤通过chrome.tabs.sendMessage发给content script。OpenClaw自己不需要知道怎么和页面通信,只要知道怎么组织步骤和聚合结果。这样抽象确实带来了方便,但service worker的异步限制也坑了我一轮,这到后面踩坑部分细说。

3.4 拿"水质巡检"当第一个实际任务

第一步写出来的巡检任务是这样定义的:打开虾池列表页,等表格加载完成,读取每一行里温度、pH、氨氮和溶氧四列,如果某个数值超出设定阈值,就把这行标记为异常,同时把汇总结果返回给popup展示。

按照OpenClaw的写法,任务不用写成命令式的代码,而是结构化的步骤列表。我大致定义成:

const inspectTask = { id: 'inspect-water-quality', name: '水质巡检', steps: [ { action: 'waitForSelector', selector: '.pool-table tbody tr', timeout: 10000 }, { action: 'collectTable', selector: '.pool-table', columns: ['pool', 'temp', 'ph', 'ammonia', 'oxygen'] }, { action: 'filterRows', condition: { anyFieldAbove: { ammonia: 0.02, ph: 8.5 } } }, { action: 'markRows', selector: '.pool-table tbody tr', style: 'background: #fff3e0' }, { action: 'returnSummary', fields: ['total', 'abnormalCount', 'abnormalList'] } ] };

引擎跑完collectTable之后拿到的是一个二维数组,然后filterRows步骤按阈值条件逐行判断,markRows步骤把不达标的行的背景色标出来,最后returnSummary把汇总信息返回过去。这个任务结构的好处是每个步骤都简单、可测试、可复用。collectTable可以复用到其他列表,filterRows换个条件就能用在别的告警场景。

整个流程走通之后,"从打开浏览器到看到一滴巡检结果"的时间大约在5秒左右,而且可以批量处理多个池子。第一个版本虽然简陋,但已经足够让我确信这个方向是对的。

4. 被真实页面教育的过程:四个典型故障与排查思路

4.1 异步渲染的表格为什么总是读到空数据

先讲我遇到的第一个坑。任务在部分机器上跑出来结果一直是空的,collectTable返回了空数组。我打开开发者工具手动在页面执行document.querySelectorAll('.pool-table tbody tr'),明明能查到好几行。为什么脚本里就是空的?

我排查的过程是这样的:先在我自己电脑上重新加载扩展,结果正常;再去出问题的环境看,发现页面加载速度比我的环境慢不少。我怀疑是表格还没渲染完成content script就把读取操作执行了。虽然manifest里写了run_at: document_idle,这个时机只表示DOM解析完成,并不意味着异步数据已经渲染。页面里的数据其实是接口返回之后由前端框架再渲染上去的,document_idle时接口大概率还没回来。

解决方式是在content script端不依赖固定的执行时机,而是用MutationObserver观察目标表格容器的变化,等容器里出现预期数量的数据行后再通知后台继续。改完之后任务在各种网络环境下都比较稳了。这个问题的本质不是选择器写错了,而是没有理解页面的渲染生命周期。做页面自动化,第一步永远不是写选择器,而是搞明白数据是什么时候以什么方式出现在页面上的。

4.2 点击无效的秘密:按钮被框架的"半禁用"状态拦住了

第二个坑更加隐蔽。有一个任务要做的是把某个池子的状态从"待确认"改成"已确认"。逻辑上click一下确认按钮就行,但实际运行时按钮确实被点击了,页面状态却纹丝不动。控制台里手动执行element.click()也一样没有反应。

排查思路从"会不会没点中"开始,我检查了元素坐标、可见性、遮挡关系,这些都没问题。后来我仔细看了一下按钮的DOM属性,发现它身上有个aria-disabled="true",但class样式看上去不像禁用状态。也就是说页面框架用了一种"看起来像启用、其实逻辑上禁用"的半禁用状态。这种情况下点击动作被框架的事件监听拦截了,只有先把某个前置条件满足——具体到我这个页面是需要先勾选一个"我已复核"的复选框——按钮才真正生效。

这个坑让我学到的经验是:在自动化过程中遇到点击无效,先别急着怀疑选择器,先看元素身上有没有aria-disabled、data-disabled之类的状态标记,同时留意页面上有没有隐藏的前置交互条件。这个排查方向,比反复调整点击坐标有用得多。

4.3 Service Worker中途被回收:任务跑到一半"失忆"了

第三个坑是扩展开发里比较容易翻车的点。任务流程长一点的时候,比如巡检完整个平台的几十个池子,跑着跑着突然没反应了。排查了一个多小时,最后发现service worker已经被浏览器回收了。

Chrome对service worker有很严格的空闲回收机制:一段时间内没有事件触发或者没有消息往来,它就会休眠,休眠之后之前内存里的所有状态全部消失。我的任务引擎跑在service worker内存里,任务执行到一半,引擎里保存的"当前进度"也随着休眠被清掉了,因此后续步骤永远不会执行。控制台的报错也很少,因为休眠不是崩溃,它只是"安静地消失了"。

解决思路有几个方向。第一种是用chrome.runtime.connect建立长连接,连接存在期间service worker被视为活跃,但这种方式在长期无操作状态下依然可能被回收。第二种是用Chrome 109开始的Offscreen Document来维持一个后台页,让任务引擎跑在不被回收的offscreen document里,这个方案稳定但实现稍重。我最终采取的组合方案是:把长任务拆成小步骤,每完成一步就把执行进度通过chrome.storage.session持久化,任务重新开始时从保存的进度恢复。这样即使service worker真的休眠了,等下次用户打开popup或者Task重新触发时,还能接着上次的位置继续跑,不会前功尽弃。

4.4 权限过度声明带来的分发麻烦

第四个坑是在我准备把扩展打包分享给其他同样养虾的朋友时暴露出来的。原本我图省事,host_permissions直接写的*:///,想着什么站点都能适配,实际用起来确实方便。但Chrome对使用全域名权限的扩展审查严格很多,打包之后在浏览器里也出现过"此扩展已停用"的警告。这个警告对普通用户来说就已经是劝退级别的障碍了。

这里我把host_permissions从*://*/*收窄到明确的业务域名,为两个不同的页面各自声明了精确匹配。权限收窄后警告消失,扩展也能正常加载。这个教训也印证了我前面选择"权限边界"优先的思路——只有真正用到的权限才声明,才不容易在分发环节卡住。很多人开发扩展时习惯先把权限开大再说,实际这会给后期带来很多不必要的麻烦。

5. 把扩展变成可维护的小工具:配置外置、日志与性能收敛

5.1 改版不再可怕:选择器和阈值全部抽到配置里

页面自动化最扎心的瞬间莫过于平台改了一次版,老的选择器全部失效。扩展第一版的时候选择器都是硬编码在任务定义里的,改版之后我需要把每一个任务翻出来改。后来我把"选择器"和"任务"分开管理,任务里只需要引用一个选择器ID,真正的CSS选择器配置在options页面里维护。

// 配置存于 chrome.storage.sync const selectorConfig = { poolTable: { table: '.pool-table', rows: '.pool-table tbody tr', nameCell: '.pool-table tbody tr td:nth-child(1)', tempCell: '.pool-table tbody tr td:nth-child(2)', phCell: '.pool-table tbody tr td:nth-child(3)' } };

阈值配置也一并移到options页,温度和pH的告警线直接在界面上改,不用改代码。现在平台改版之后,我的应对流程是:打开平台页面,用开发者工具找到新元素的选择器,更新到配置里,刷新扩展配置,任务就恢复可用了。整个过程不下五分钟,而且不需要重新发布扩展。

这里还有一个小技巧:配置修改后我会在options页面里放一个"测试选择器"按钮,点击之后它会发消息给content script,尝试用配置里的选择器去页面里查找元素,然后返回找到的数量。这个功能虽然简单,但极大缩短了配置出错时排查的时间。

5.2 执行历史和错误日志,关键时刻能救命

自动化工具跑久了,执行结果对不上账的时候就需要日志来帮忙。我实现了一个环形日志:最近200条执行记录保存在chrome.storage.local里,每条记录包含任务ID、执行时间、成功与否、错误摘要、耗时。popup页面里用一个小列表展示最近几次执行情况,执行异常的任务会标红。

日志不仅用于事后排查,在执行失败时它还能告诉我到底是哪一步失败了。有一次某一个池子的数据读取持续返回空值,我打开日志发现失败步骤停在"点击详情页按钮",结合时间点判断出那段时间平台页面状态有bug,与扩展本身无关。没有日志的话我可能会去改选择器,白费一堆功夫。

5.3 性能收敛:别让扩展拖慢正常浏览体验

扩展如果写得不注意性能,会在不知不觉中拖慢所有页面的速度。我的做法是做了三处优化:

第一,MutationObserver回调做了节流。数据表格可能频繁更新DOM,我不需要每次都重新读取表格,只需要在停止变化后300毫秒读取一次。这样避免了频繁触发collectTable导致的高CPU占用。

第二,读取大量表格数据的时候尽量避免逐行逐单元格查询DOM。collectTableData里的实现是一口气查出所有tr,然后逐行拆td,这种做法比单独为每个单元格执行一次querySelector高效得多。

第三,任务不执行时绝不轮询。第一版我在content script里加了个定时器,每5秒检查一遍页面有没有新数据,后来发现这个定时器在后台页面里也一直跑着,白白消耗资源。改成仅在任务执行期间才启动必要的观察器,任务结束立刻断开,页面负载明显下降。

6. 如果你也要做类似的浏览器扩展:几条不写进文档的经验

6.1 先小后大,先跑通一个动作再谈"全自动"

我最初的目标并不是做一个全自动的巡检工具,只是想解决"每天要点好几十下"的问题。如果一开始就抱着改造所有操作流程的想法,大概率会陷入设计过度,还没跑起来就把自己劝退了。先做一个能用的最小闭环,哪怕只解决"一键打开所有池子详情页"这一件事,跑通了之后再一步步往上加任务类型,这才是比较现实的做法。

实际用下来我发现,扩展做得好的关键不在于功能数量多,而在于每个小动作都足够可靠。一个可靠到可以闭眼点的小按钮,它的价值超过十个时灵时不灵的复杂流程。

6.2 把"执行层"和"编排层"分开,后面会感谢自己

我没有在一开始就规划好"content script只负责操作DOM,service worker负责编排"这个架构。前几版把业务判断和DOM操作混在一起,改一个判断逻辑要连带改DOM操作代码。后来强行重构了一次,把整个任务执行链路改成执行层与编排层分离,代码清爽程度和可维护性都提升了一大截。

这个划分也让我给扩展增加新任务变得非常简单:先看看能不能用已有的动作原语组合出来,如果组合不了,再去content script里加一个新的动作原语,后台的任务配置不用动。这种扩展机制让我后面加任务时省了非常多时间。

6.3 日常经验:选择器稳定性的偏好顺序

根据这几个月的使用经验,我给选择器的稳定性排了个偏好顺序:优先用数据属性(比如data-pool-id),其次是稳定的class名,再次才是标签加索引的组合。数据属性是最理想的,因为它是业务层面的标识,基本不会因为样式调整而改变。class名和样式强相关,UI改版经常变。标签加索引的方式最脆弱,平台只要多加一列数据,索引就全部错位。

我给所有关键操作元素都加上了data属性编写建议,虽然现在平台端还没加,但下次遇到改版我会优先推动这一项,从根源上降低选择器维护成本。

6.4 最后的一点真心话

做这个扩展最大的收获,其实不是省下了每天几分钟的点击时间。而是让我重新理解了"工具"这回事:真正的工具不是写一次跑完就扔的脚本,而是能随着环境和需求的变化不断调整自己的小系统。页面改版不可怕,权限审查不可怕,service worker回收也不可怕,这些坑都有对应的解决办法。可怕的是你明明知道这些流程可以自动化,却因为觉得"写扩展太麻烦"而继续每天手动点几百下。

如果你也有一个每天都要打开、每天都要重复操作的网页,我建议你用同样的方式试试:把最小环节自动化,跑通一个小功能,然后在这个基础上不断完善。你不需要懂特别高深的技术,只需要翻翻官方文档,照着Manifest V3的规范搭一个最小扩展出来,写一个能在页面上读取数据的content script,再一步步加任务。等它跑通第一个自动读取的时候,你会觉得这比省下来的那几分钟有价值得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进