contenteditable 自动填值:受控组件、Shadow DOM 穿透和发送确认的判定,多多开票助手开发经验分享
发布时间:2026/9/29 21:34:16来源:尧图网络
这段填值代码跑在哪儿我做的这个扩展是给电商采购用的主要干一件事把一批订单的开票资料整理好替人到 1688 的聊天页里发给对应的商家。名字叫多多开票助手装在浏览器上我自己每天在用。要发消息就得在一个不是我写的页面上把一段几十行的文本填进聊天输入框再点发送。这段代码是整个扩展里最容易坏的部分因为它碰的每一处都是别人的实现。从外面看这件事只有三步找到输入框、写进去、点按钮。实际写下来每一步都有坑而且会多出第四步确认它到底发出去没有。这段逻辑没有用到任何未公开的接口也没碰账号密码。它跑在一个已经登录的页面上我就是那个登录的人。给输入框赋值没反应问题出在受控组件我第一版写的是这样const input document.querySelector(.chat-input); input.value message; document.querySelector(.send-btn).click();跑起来输入框里干干净净点发送发出去一条空消息。原因不复杂。这类聊天页面都用框架React 或者 Vue 这一类它们不看你 DOM 上现在是什么值只看自己状态里存的是什么。你绕过框架直接改 DOM框架下一次渲染就把你的值冲掉。你的赋值对它来说不存在。要让框架认这个值得走它自己的入口。受控输入框的属性描述符被框架换过一次你在元素实例上赋值会走到那层拦截上但把原型上的原生 setter 拿出来调用就能穿过去const proto Object.getPrototypeOf(input); const setter Object.getOwnPropertyDescriptor(proto, value)?.set; if (setter) setter.call(input, message); else input.value message;光赋值还不够。框架还靠事件判断「用户动过了」所以要自己补一个 input 事件再补 change。有一些实现紧接着还要键盘事件不然发送按钮一直是灰的。我照着补了 keydown、keypress、keyup 三个。这段是试出来的。我一开始只补 input按钮不亮加上 change 还是不行把 keydown 补上才活。我到现在也不确定它内部到底在等哪一个所以三个都留着。contenteditable 没有 value 可以赋上面那套只对input和textarea有效。1688 的聊天框是一个开了contenteditable的 div它没有 value里面存的是 HTML。改它的内容只有一个办法把光标放进去一个字一个字写。input.focus(); input.innerHTML ; for (const ch of message) { if (ch \n) { await sleep(20); document.execCommand(insertHTML, false, brbr); await sleep(20); } else { document.execCommand(insertText, false, ch); } }几个细节都是踩出来的。换行得写两个br。写一个在渲染上跟没换一样我第一版就是写了一个发出去的消息全挤成一段。insertText 也不能把整段一次塞进去。试过一次性塞几百字符前面一部分后面的丢了。逐字符写慢但它稳。一条消息一百来行写下来一秒多用户看不出来。写完之后照样要补事件一个 InputEvent、一个 change加三个键盘事件。再往后我加了一道兜底如果写完发现输入框还是空的就直接按 HTML 塞进去。if (!input.innerText.trim()) input.innerHTML message.replace(/\n/g, br);这条兜底不优雅。但一个脚本跑在别人的页面上页面一改版优雅的写法通常是第一个死的。我现在倾向留一条能出结果的笨路。选择器找不到输入框因为它住在影子根里document.querySelector(.editBox pre.edit)返回 null可页面上那个输入框明明在那儿。打开控制台一看它在一个 shadow root 里面。这套聊天组件是 Web Component 封的内部结构对外的 DOM 树不可见普通查询穿不过去。要往下钻function queryAny(selectors, root document) { for (const sel of selectors) { const hit root.querySelector?.(sel); if (hit) return hit; for (const el of root.querySelectorAll?.(*) || []) { if (el.shadowRoot) { const nested queryAny(selectors, el.shadowRoot); if (nested) return nested; } } } return null; }两个地方要注意。影子根是可以嵌套的所以这里是递归不是一层循环就够。另外querySelectorAll(*)遍历整棵子树在长列表页上很贵我把它放在所有直接查询都失败之后才走。还有一点这个函数收的是一个选择器数组。主选择器取不到就试第二个。我从控制台里把这套组件可能用到的名字抄了一遍留了四个。元素不是一加载就有的外面还得套一层等待DOM 一变就重新找一次找不到就挂超时。别用死循环轮询去问「出现了吗」浪费的是自己的机器。点完发送之后凭什么判断发出去了这是整件事里我最花心思的地方。点一下发送按钮它返回 undefined。没有 Promise没有回调也没有我能听的接口响应。网络请求是页面自己发的结果我看不到。早期的写法是点一下、等两秒、当成功。这个做法在对账的时候会露馅有一批单子其实没发出去我的记录里却全是成功。后来改成看证据同时看三样function evaluateSendEvidence({ beforeOwnCount, afterOwnCount, editorText, expectedMessage, latestOwnMessageText, disconnected }) { if (disconnected) return failed; // 证据一我发出的消息气泡数量变多了 if (afterOwnCount beforeOwnCount) return sent; // 证据二最后一条自己的消息里出现了期望文本的开头 const head expectedMessage.replace(/\s/g, ).trim().slice(0, 24); if (head latestOwnMessageText.replace(/\s/g, ).includes(head)) return sent; // 证据三输入框被清空了 if (!editorText.trim()) return sent; return pending; }三档证据的强度是递减的。第一档最硬。聊天列表里「我发出的消息」这类节点的数量增加了说明确实多出来一条。第二档是文本证据。最新一条自己的消息里包含我准备发的那段话的前 24 个字符。这是个折中完整比对经常失败因为页面会往文本里插零宽字符或者把空格换掉。所以两边都先做一次空白归一化再比一个足够长的开头。第三档最弱输入框空了。它成立有两种可能一种是发出去了另一种是被页面清掉了。我把它留作没有别的证据时的默认值。三档都不成立就先记 pending接下来 5 秒里每 200 毫秒重判一次。5 秒过去还是 pending结果记 unknown不记 failed。这就是那第四种状态存在的理由。unknown 的意思是我点过发送了但拿不到证据证明它成没成。这两种情况的处置完全相反。记成 failed下一轮就会重发同一条消息发给同一个商家两遍。记成 sent那批资料可能压根没出去而且没有人会发现。unknown 单独记一栏跑完在界面上列出来让人自己去聊天记录里看一眼。多这一步但它比猜一个强。同一份任务被多个标签页抢着跑这套东西开窗口的方式是一个商家开一个聊天页每个窗口都会注入一遍我的脚本。于是有个问题当前这条待发任务存在共享的存储里两个窗口同时醒过来都会读到它都会去发。同一条消息发了两遍。处理办法是认领。每个窗口生成一个自己的认领号写进任务之后再读一次读回来的认领号是自己的才往下走async function claimCurrentChatTask(expectedTaskId, claimId, storage) { const current await storage.getTask(); if (!current || current.taskId ! expectedTaskId || current.claimedBy) return null; await storage.saveTask({ ...current, claimedBy: claimId }); const claimed await storage.getTask(); return claimed?.claimedBy claimId ? claimed : null; }这不是原子操作我知道。两个窗口如果卡在「读」和「写」之间的同一个瞬间理论上可能都通过。线上我没见过因为窗口醒来本来就有几百毫秒的错峰而且任务上挂了 60 秒的过期时间最坏情况下重复的那一次会撞上过期而放弃。要卡死这个问题得上一把真的锁。我暂时没上为了一个没复现过的问题引入一套锁代价比收益大。发之前先核对收件人这段代码发出去的东西里有税号、抬头、订单号。发错人的后果比发不出去严重得多那是把公司信息交给了一个不相关的商家。所以任务里带着两个标识执行前都要对一遍。地址栏上的订单号和聊天对象跟任务里存的得一致function chatTaskMatchesLocation(task, href location.href) { const url new URL(href); return url.searchParams.get(orderId) String(task.representativeOrderId || ) url.searchParams.get(touid) cnalichn${task.merchantNick || }; }对不上就抛错不发送。写进消息之前还有一道断言检查手上这条任务的商家名和正在处理的商家是不是同一个不一致直接中止整轮。这两道都对着同一个方向窗口复用会带来错位。我复用一个聊天窗口去打开下一个商家的时候如果上一个页面还没切干净脚本有可能读到旧的内容。拍脑袋定下的毫秒和几处没解决的地方这段代码里有一堆等待时间。说实话多数是拍的。页面就绪 800 毫秒、输入后补事件 500 毫秒、发送确认窗口 5 秒、重连等待 8 秒、重连时的轮询间隔 250 毫秒。这些数我调过几轮觉得够用就停在那儿了。没做过压测也不知道换一台慢机器还够不够。发送确认那 5 秒是按我见过最慢的一次往上加的余量。它要是不够结果就是 unknown 变多不会变成重复发送这个方向上还算安全。有一处我确实没做好。消息里的订单总金额是拿浮点数直接加的const total amounts.reduce((a, b) a b, 0).toFixed(2);金额这种应该按分做整数运算的地方我用了小数加法再取两位。绝大多数组合不会出问题但它不严谨我记在这儿。还有一条是遇到安全验证就停。脚本跑之前会检查页面上有没有验证码、登录页、访问频繁这类提示检测到就中止抛一个明确的错误码不重试也不绕。这不是技术难题是个原则问题一个替人点按钮的东西没有资格替人过验证。失败原因收敛成六个码任务过期、聊天窗口订单不匹配、触发安全验证、聊天未登录、聊天未连接、窗口未加载完成。每个对应一句人话写在界面上。写错误码的成本很低回报是排查的时候不用猜。小结回头看这段代码难的地方不在写在确认。赋值和点击都是十几行的事文档也查得到。占掉我最多时间的是那个第四种状态。我一开始只有成功和失败两种是几批单子对不上账之后才补上的 unknown。一个页面上的操作如果没有回执那就只能自己找证据。证据不够的时候记一个「不知道」比记一个猜出来的结论有用。这个判断我是拿对账的时间换来的。关键词contenteditable,Shadow DOM,浏览器扩展,自动化测试,受控组件,execCommand,MutationObserver,前端自动化
网站建设高端定制企业官网