
养虾这事听起来跟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-disabledtrue但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只负责操作DOMservice worker负责编排这个架构。前几版把业务判断和DOM操作混在一起改一个判断逻辑要连带改DOM操作代码。后来强行重构了一次把整个任务执行链路改成执行层与编排层分离代码清爽程度和可维护性都提升了一大截。这个划分也让我给扩展增加新任务变得非常简单先看看能不能用已有的动作原语组合出来如果组合不了再去content script里加一个新的动作原语后台的任务配置不用动。这种扩展机制让我后面加任务时省了非常多时间。6.3 日常经验选择器稳定性的偏好顺序根据这几个月的使用经验我给选择器的稳定性排了个偏好顺序优先用数据属性比如data-pool-id其次是稳定的class名再次才是标签加索引的组合。数据属性是最理想的因为它是业务层面的标识基本不会因为样式调整而改变。class名和样式强相关UI改版经常变。标签加索引的方式最脆弱平台只要多加一列数据索引就全部错位。我给所有关键操作元素都加上了data属性编写建议虽然现在平台端还没加但下次遇到改版我会优先推动这一项从根源上降低选择器维护成本。6.4 最后的一点真心话做这个扩展最大的收获其实不是省下了每天几分钟的点击时间。而是让我重新理解了工具这回事真正的工具不是写一次跑完就扔的脚本而是能随着环境和需求的变化不断调整自己的小系统。页面改版不可怕权限审查不可怕service worker回收也不可怕这些坑都有对应的解决办法。可怕的是你明明知道这些流程可以自动化却因为觉得写扩展太麻烦而继续每天手动点几百下。如果你也有一个每天都要打开、每天都要重复操作的网页我建议你用同样的方式试试把最小环节自动化跑通一个小功能然后在这个基础上不断完善。你不需要懂特别高深的技术只需要翻翻官方文档照着Manifest V3的规范搭一个最小扩展出来写一个能在页面上读取数据的content script再一步步加任务。等它跑通第一个自动读取的时候你会觉得这比省下来的那几分钟有价值得多。