我为什么没有把拼多多批量申请发票做成“同时打开几十个页面”,我开发多多开票助手经验分享

1 阅读7分钟

做浏览器自动化时,最容易想到的方案就是并发。

有50笔订单,那就开50个页面;每个页面找到按钮、自动点击,理论上很快就能结束。

我刚开始设计拼多多批量申请发票功能时,也考虑过这种实现。真正把自己当成使用者走一遍流程后,我发现这个思路并不好。

用户需要的不是浏览器突然弹出几十个窗口,而是一份能核对的处理结果。

这个功能看起来只是循环点击

把需求压缩到代码层面,大概就是:

for (const order of orders) {
  打开发票页面(order);
  点击申请按钮();
  点击确认按钮();
}

但实际页面并没有这么规整。

有些订单已经申请过,有些正在退款;有些页面加载很慢,有些会提示不支持开票;还有一种情况是拼多多登录状态在任务执行到一半时失效。

如果只写一个循环,程序确实能跑,但用户不知道发生了什么。

因此,我后来把问题拆成了四部分:

  1. 哪些订单应该进入队列;
  2. 当前订单是否真的存在申请入口;
  3. 怎样确认这一笔已经结束;
  4. 中途出错后怎样继续。

真正花时间的不是“点击按钮”,而是把这几个状态理清楚。

先过滤,再进入页面

我自己使用时,希望可以直接全选订单,而不是先人工检查几十遍。

所以批量任务的第一步不是打开页面,而是过滤。

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侧的逻辑不同,主要是按商家整理订单,通过站内消息发送开票资料。

无论在哪个平台,它都只负责发起申请和记录结果,不能代替商家开具发票。

我没有把它设计成“几十个页面一起冲”的工具。对我来说,能暂停、能知道失败在哪里、下次不用全部重来,比表面上的速度更有价值。