
这件事的难点不在发送在整理手头这个扩展是我自己写的装在浏览器上主要处理网店订单和发票这条线的事名字叫多多开票助手官网:duoduoke.net。拼多多那边申请发票是走进开票页点提交动作有明确回执。1688 这边不一样平台没有给买家一个统一的申请入口想要发票只能去站内消息里跟商家说。换句话说买家这一侧的「申请」落到代码里就是一段发出去的消息。发送本身不是难事难的是发出去之前那一段。要开的单可能散在几十页、几十家商家手里每一家的抬头、订单号、金额都得凑齐一条消息对应一家。这一段我前后重写过三版这篇把它拆开讲。有一个前提要先讲清楚1688 买家这一侧没有统一的发票申请入口所谓申请落到实处就是一段发给商家的站内消息。这个前提决定了整件事的形状也决定了下面所有设计都围着「消息」转。先把不能开票的单挑出去不是所有订单都能开。交易关闭的、退款完成的、还在售后的这些单发过去商家也没法处理白白多一条消息。所以在归并之前先过一遍状态。有一种情况要单独拎出来整单退款和部分退款不是一回事。export function evaluate1688OrderEligibility(orderStatusText, itemStatusTexts []) { const orderExclusion get1688OrderExclusion(orderStatusText); const itemCount itemStatusTexts.length; const refundedItemCount itemStatusTexts.filter(t Boolean(get1688OrderExclusion(t))).length; if (orderExclusion) return { exclusion: orderExclusion, partialRefund: false }; // 一个单子里所有商品都退了整单不可用 if (itemCount 0 refundedItemCount itemCount) { return { exclusion: { status: 全部商品已退款或售后 }, partialRefund: false }; } // 只有一部分退了保留但打个标记 return { exclusion: null, partialRefund: refundedItemCount 0, refundedItemCount, itemCount }; }这里有个我踩过的坑。订单状态的文案有好几种写法不是一个规整的枚举退款有「退款成功」「退款完成」「已退款」售后有「售后中」「售后完成」「售后关闭」。我第一版只覆盖了其中两个结果一批本该排除的单混进了队列发出去以后被商家退回来。改法是把这些文案收成一张表用includes做包含匹配而不是等值比对。等值匹配在文案变体面前就是筛子页面每换一次措辞就得补一条。按商家归并一个 Map 就够挑完之后是归并。同一个商家可能这个月下了十单要是每单发一条等于在人家聊天框里刷十条抬头税号重复十遍。正确的做法是先按商家聚起来一家一条。export function groupOrdersByMerchant(orders) { const groups new Map(); orders.forEach(order { if (!groups.has(order.merchantNick)) groups.set(order.merchantNick, []); groups.get(order.merchantNick).push({ ...order }); }); return groups; }merchantNick是商家在订单卡片上的旺旺标识同一家只要标识一致就能聚到一起。这里我特意做了浅拷贝否则两个商家若共享同一个对象引用后面改一单的状态另一条也跟着变排查起来很费劲。一条消息最多装 10 笔超了拆批归并之后有个上限问题一家商家如果有一百笔全塞进一条消息聊天框会把它折叠起来商家根本不会点开看。所以每条消息有笔数上限。export const MAX_ORDERS_PER_MESSAGE 10; export function createMerchantBatches(orders, size MAX_ORDERS_PER_MESSAGE) { const batches []; groupOrdersByMerchant(orders).forEach((merchantOrders, merchantNick) { const total Math.ceil(merchantOrders.length / size); for (let index 0; index total; index 1) { batches.push({ merchantNick, orders: merchantOrders.slice(index * size, (index 1) * size), batchIndex: index 1, batchTotal: total, batchLabel: ${index 1}/${total}, }); } }); return batches; }为什么是 10而不是 20 或 30这是个取舍。10 笔加上抬头信息差不多是一屏能看完的长度再长就要滑动商家在手机上更不会细看。而且一条消息对应一次发送尝试装得太满中间出一次错整批都得重来。10 是我拿实际聊天记录试出来的数不是算出来的。超过 10 笔的会拆成多条每条开头带上[分批申请 1/3]这样的序号商家一看就明白这是同一批里的一条。少了这个序号对方收到三条相似的消息大概率只会回你一句「到底几单」。消息怎么拼抬头、订单、合计、时间范围一条消息里要装四样东西顺序不能乱。发票信息票种、抬头、税号要专票再加注册地址、电话、开户行和账号订单汇总总金额、总笔数订单明细一行一笔订单号加金额时间范围这批单从哪天到哪天拼装我写成了一个纯函数输入抬头和订单数组输出一段字符串中间不碰 DOM。好处是能单独测改一处不至于影响全流程。const amounts orders.map(o toAmount(o.amount)).filter(a a ! null); const summary amounts.length ? 总订单金额${amounts.reduce((a, b) a b, 0).toFixed(2)}总订单数${orders.length} : 总订单数${orders.length};这里的toAmount是过滤器不是转换器。有些订单的金额字段是空的如果直接Number(undefined)就是 NaN加进合计那一行会变成「总订单金额NaN」。我最早没挡这个商家收到一条写着 NaN 的消息回头问我这个金额是不是有问题。后来改成求和之前先把非数值的项剔掉一笔有效金额都没有的时候干脆不写总金额这一行。判断这条发过了先归一化再算哈希到这一步消息拼好了但还剩一个问题怎么知道同一份内容发过没有。用户点两次开始或者同一批单今天发过、明天又发都不该重复。直接拿消息全文比对有两个麻烦。一条消息几百上千字符存下来占地方更麻烦的是「看起来一样」的文本未必真的相等。第二个麻烦是我实际撞上的。我在本地拼出来的字符串行尾是干净的可同一段文本从输入框里读回来可能带上了行尾空格换行符也从\n变成了\r\n。两段人眼一模一样的内容直接比是对不上的去重等于没做。所以先归一化再算一个短哈希。export function normalizeMessage(value) { return String(value || ) .replace(/\r\n?/g, \n) // 换行统一成 \n .split(\n).map(line line.trimEnd()) // 去掉每行行尾空白 .join(\n).trim(); } export function stableMessageHash(value) { const text normalizeMessage(value); let hash 0x811c9dc5; for (let i 0; i text.length; i 1) { hash ^ text.charCodeAt(i); hash Math.imul(hash, 0x01000193); // FNV-1a } return (hash 0).toString(16).padStart(8, 0); }归一化把格式上的差异抹掉只留下内容上的差异。这样行尾空白、换行符类型都不影响哈希只有抬头或订单真的变了哈希才跟着变。FNV-1a 很短八位十六进制够用也不用为了它引一个依赖。历史记录里存的不是全文是一条三元组订单号、抬头 id、消息哈希。三个都对上才判定这条发过了。export function hasSuccessfulDuplicate(entries, candidate) { return entries.some(entry entry.status sent entry.orderId candidate.orderId entry.invoiceTitleId candidate.invoiceTitleId entry.messageHash candidate.messageHash ); }抬头 id 要一起进比对是因为同一批订单换个抬头再发一次是合理需求不该被判成重复。只看订单号会把这种正常操作误伤掉。同一家出现在两页会生成两条消息再说一个容易被当成 bug 的行为。1688 的订单列表是分页的这个扩展按当前页读。如果同一家商家的单分别落在第 2 页和第 5 页翻到第 5 页时会重新为这家生成一条消息。结果是同一家收到两条两条对应的订单不一样。这不是重复发送。它按页组织每一页是独立的一批。要做到跨页去重就得先把所有页读完在内存里维护一张全局索引中途断了还要能恢复。代价是每次都要扫完几十页慢而且任何一页失败整批就不完整。我选了按页来。理由很直接买家自己清楚这批单是分几页翻的消息对得上一页一页的直觉而全局去重带来的那点复杂度换来的只是少几条消息不划算。export function buildSentOrderIndex(entries) { return new Set( entries.filter(e e.status sent e.orderId).map(e e.orderId) ); }发送记录默认保留 180 天超期的自动清掉。这个长度是照着一年的对账周期砍半定的再长意义不大存储也会涨。小结这套东西从头到尾没有多难的技术难在把「一家家发消息」这个模糊的动作拆成可描述、可判断的几步哪些单该发、归到哪一家、一条装几笔、这份内容是不是发过了。拆开之后有个好处每一步都能单独验证。归并错了看分组消息拼错了看那段纯函数重复发了看哈希和历史。反过来如果当初写成一大坨「遍历订单挨个发」出问题时只能从头猜。边界也得说清楚。它整理的是发出去之前那一段消息出去之后的事它管不了商家什么时候看、开不开、开成什么样都在对方手里。把能确定的部分做干净把不确定的部分如实交出来这是它全部的野心。还有一处维护成本一直存在。页面的订单卡片结构改一次选择器就要跟着改一次。这类跑在别人页面上的自动化没有一劳永逸。关键词1688批量申请发票, 订单归并, 浏览器扩展, 消息去重, FNV哈希, 分批发送, MV3, 电商自动化