做浏览器自动化时,最容易想到的方案就是并发。
有50笔订单,那就开50个页面;每个页面找到按钮、自动点击,理论上很快就能结束。
我刚开始设计拼多多批量申请发票功能时,也考虑过这种实现。真正把自己当成使用者走一遍流程后,我发现这个思路并不好。
用户需要的不是浏览器突然弹出几十个窗口,而是一份能核对的处理结果。
这个功能看起来只是循环点击
把需求压缩到代码层面,大概就是:
for (const order of orders) {
打开发票页面(order);
点击申请按钮();
点击确认按钮();
}
但实际页面并没有这么规整。
有些订单已经申请过,有些正在退款;有些页面加载很慢,有些会提示不支持开票;还有一种情况是拼多多登录状态在任务执行到一半时失效。
如果只写一个循环,程序确实能跑,但用户不知道发生了什么。
因此,我后来把问题拆成了四部分:
- 哪些订单应该进入队列;
- 当前订单是否真的存在申请入口;
- 怎样确认这一笔已经结束;
- 中途出错后怎样继续。
真正花时间的不是“点击按钮”,而是把这几个状态理清楚。
先过滤,再进入页面
我自己使用时,希望可以直接全选订单,而不是先人工检查几十遍。
所以批量任务的第一步不是打开页面,而是过滤。
function shouldSkipInvoice(order) {
if (order.isUnpaid) return '待支付';
if (order.isRefunding) return '退款或售后';
if (order.hasInvoiceDetail) return '已有发票';
if (order.invoiceApplied) return '已经申请';
return null;
}
实际代码会考虑更多字段,但思路就是这样。
用户全选50笔订单后,系统会先告诉他:
- 一共选择多少笔;
- 自动跳过多少笔;
- 实际进入任务多少笔。
我认为这个确认比“一键开始”更重要。如果选了50笔却只有2笔进入任务,用户至少知道系统做了过滤,而不是误以为另外48笔消失了。
为什么我选择复用一个窗口
批量任务启动后,多多开票助手会创建一个发票处理窗口。当前订单完成后,不关闭整个任务,而是把这个窗口导航到下一笔订单的申请页面。
这比同时创建大量窗口慢一点,但有几个实际好处:
- 浏览器资源占用更可控;
- 当前处理到哪一笔很明确;
- 页面返回结果容易和订单对应;
- 暂停和停止比较好实现;
- 用户不会被几十个窗口挡住屏幕。
任务状态大致是:
const batchState = {
running: true,
paused: false,
stopped: false,
currentIndex: 0,
popupWindowId: null,
orders: []
};
每完成一笔,更新订单状态和当前位置,然后进入下一笔。
这类任务的重点不是循环本身,而是循环能够被打断、恢复,并且不会因为某一笔失败而丢失全部进度。
页面脚本不能只负责点击
进入发票申请页后,页面脚本要先判断当前状态。
如果页面已经显示“处理中”或“已开票”,这笔订单不需要再次提交。如果出现“不支持开票”“订单异常”之类的提示,也不应该继续乱点。
只有找到真实可见的申请按钮,才进入后续操作。
我给页面处理设置了超时。页面长时间没有返回结果时,这一笔会被记录为超时,然后继续下一笔。
这里不能把超时写成成功,也不能因为按钮被点击过,就认定申请一定提交了。
浏览器自动化里,“我执行了点击”和“页面确认成功”是两件事。
跨窗口返回结果,比想象中麻烦
发票申请发生在单独窗口,任务列表在插件面板。处理窗口完成后,需要把结果传回面板。
最初很容易想到window.opener.postMessage()。但通过浏览器扩展API创建的窗口,window.opener不一定存在,不能把它作为唯一通信方式。
所以我把扩展内部消息作为主通道:
发票页面内容脚本
→ chrome.runtime.sendMessage
→ 扩展后台
→ 任务面板
→ 更新订单状态
窗口消息可以保留为补充,但不能依赖它维持整个任务。
这也是开发过程中一个很现实的问题:在普通网页里能工作的通信方式,放进浏览器扩展以后不一定可靠。
为什么必须支持暂停和停止
从开发角度看,暂停和停止会增加状态管理的工作量。只做“开始”按钮最省事。
从使用角度看,没有暂停的批量任务很难放心运行。
可能出现的情况包括:
- 发现抬头选错;
- 当前查询范围不对;
- 拼多多突然要求重新登录;
- 某一类订单不想继续处理;
- 临时需要关闭浏览器。
因此,每次进入下一笔订单前,都要检查任务状态。
while (currentIndex < orders.length) {
if (stopped) break;
if (paused) {
await waitUntilResumed();
}
await processOrder(orders[currentIndex]);
currentIndex++;
}
暂停不是让当前页面停在任意一行代码上,而是在安全的任务边界等待。通常是当前订单完成后,不再进入下一笔。
这种实现更容易维护,恢复时也不容易重复提交当前订单。
登录失效后不能继续假装执行
批量任务运行期间,拼多多登录可能失效。
如果继续打开后续订单,页面只会重复出现登录提示或空白状态。程序看起来还在工作,实际上已经没有有效结果。
因此,一旦识别到登录失效,我会停止后续任务,并提示用户重新绑定账号。
我不喜欢那种“无论发生什么,进度条一定跑到100%”的自动化。完成率看起来很好,但结果不能用。
真实的批量任务应该允许失败,也应该把失败原因告诉用户。
我作为使用者,最后想看到什么
任务完成后,我不需要一段“恭喜操作成功”的提示。我需要知道:
- 实际提交了多少笔;
- 哪些订单已经申请过;
- 哪些被退款或售后状态过滤;
- 哪些页面不支持申请;
- 哪些因为登录或页面超时失败;
- 下一次只需要补哪几笔。
这也是多多开票助手最终采用进度列表的原因。自动化解决重复操作,状态列表负责让结果可以核对。
申请完成后,插件还可以继续查询发票状态、批量下载已经开具的文件,并导出订单CSV。但这些是后续流程,不能和“申请已提交”混成一个状态。
这套设计不只适用于发票申请
顺序队列、状态记录、暂停恢复、失败隔离,其实适用于很多浏览器批量任务,例如:
- 批量检查页面状态;
- 分页采集公开数据;
- 批量下载文件;
- 批量填写重复表单;
- 批量处理后台订单。
只要操作涉及外部页面,就应该假设页面会变慢、登录会失效、按钮可能不存在。自动化代码不能只写理想路径。
一个可靠的任务至少需要:
输入过滤
→ 顺序执行
→ 页面验证
→ 结果回传
→ 超时处理
→ 暂停停止
→ 失败记录
少了任何一项,演示时可能没问题,真正连续处理几十笔数据时就容易出错。
关于多多开票助手
多多开票助手是我开发并在实际使用的第三方Chrome/Edge扩展,主要处理拼多多和1688采购场景中的重复开票申请。
拼多多侧,它会读取买家订单、过滤不适合处理的订单,再逐笔进入发票页面提交申请。1688侧的逻辑不同,主要是按商家整理订单,通过站内消息发送开票资料。
无论在哪个平台,它都只负责发起申请和记录结果,不能代替商家开具发票。
我没有把它设计成“几十个页面一起冲”的工具。对我来说,能暂停、能知道失败在哪里、下次不用全部重来,比表面上的速度更有价值。