这篇讲的是导出之前那一段
我在做一个浏览器扩展,处理拼多多电商买家侧的订单和发票,订单导出 CSV 是其中一块功能,名字叫多多开票助手(duoduoke.net)。这一篇不聊 CSV 格式本身的毛病,编码、科学计数法、逗号转义那些是另一个话题。这里只讲前面那一段,也是很多人以为最简单、实际最容易翻车的地方:这些数据从哪来,怎么变成表格里的一行。
写完之后我的感受是,收尾那一步(拼字符串、触发保存)大概二十行就写完了,前面取数和清洗的部分写了十几倍。
数据别从页面上抠,从接口拿
第一版我试过从 DOM 里读。订单列表就在页面上,一张卡片对应一单,取它的文本和链接就行。
跑起来才发现拿不全。这种列表是懒加载加分页的,往下滚才把后面的渲染出来,为了省内存,滚过去的部分又会被回收。你从 DOM 里能读到的,永远只有当前视口附近那几条。想靠"一直滚到底"来收集,中间还会碰上重新渲染,前面已经取到的节点变成悬空引用,textContent 拿回来一个空字符串。
我的做法是绕开 DOM,直接读页面请求订单列表用的那个接口返回的 JSON。判断依据很简单:DOM 结构会随改版变,接口的字段名相对稳定,而且一次能拿到一整页。
const resp = await chrome.runtime.sendMessage({
action: 'FETCH_ORDERS',
data: { order_type: 'all', offset: '' }
});
const orders = resp?.data?.orders || [];
const hasMore = resp?.data?.hasMore;
这里要做一次中转,不能让内容脚本直接发这个请求。一是跨域和站点上下文的问题,二是所有请求收在同一个入口,后面加统一的重试和错误处理方便。代价是多定义一个消息类型,多一层转发。
翻页的逻辑不难,难的是两个数不是一个数
接口是游标分页,一页五十条,返回里带一个 offset 指向下一页。看起来就是个 while 循环。
第一个坑在这里:你要的是一段时间范围内的单,接口给的是"最近 N 条",所以每一页拿回来之后还要按时间筛一遍,筛掉的直接丢。于是会出现一种很别扭的情况:翻了三页,一共一百五十条原始数据,真正进到结果里的只有十二单。
这两个数必须分开记。我一开始用一个计数器,结果进度显示是错的(明明还在往下翻,界面已经报了一百多条),而且"翻完了"这个判断也提前触发了。
state.rawScannedCount += page.length; // 一共翻过多少条原始数据
state.loadedCount += usable.length; // 其中多少条进了结果
state.offset = resp.data.offset; // 下一页游标
state.hasMore = resp.data.hasMore;
第二个坑是中断。几百单翻到一半,页面刷新了,或者用户自己点了别的按钮,这一轮就断在这里。重新点一次查询,又从第一页开始,前面翻的全白翻。
翻页要串行,别并发
游标分页和 page 参数分页不一样,offset 是有状态的:返回的下一页游标,只有在你确实拿到这一页之后才是有效的。所以我不并发,一页一页串着请求。
试过一次并发三页,结果两页的数据有重叠,还有几条直接跳过去了。原因在于服务端那边会把当前进度往前推,哪个请求先回来并不确定,后回来的那一页,已经不是你发请求时的位置了。
串行的代价是慢一点。一页五十条,几百单就是十几次请求,多花的那几秒,比事后去重和排错省事。
中断之后接着跑,靠的是首页指纹
断点续跑本身不复杂:把当前游标和时间范围存下来,下次先读它,对得上就从那儿接着走。
麻烦在"对得上"这三个字。用户中间又下了两单,订单列表顺序就变了,上次存的那个游标指向的位置,已经不是你离开的地方。硬接着跑,要么漏,要么重。
我的做法是给第一页算一个指纹,把订单号拼起来取个值。续跑之前先请求一次第一页,指纹一致才按断点走,不一致就把整份断点丢掉,从头开始。
const fingerprint = orders.slice(0, 3).map(o => o.order_sn).join('|');
if (saved.fingerprint !== fingerprint) return null; // 数据变了,断点作废
只取前三单算指纹是有意的。全量算要遍历一遍,而订单列表的排序基本由最新的几单决定,前三单足够反映变化。这是个取舍,没有标准答案。
嵌套结构平铺成一行:数组、缺值、算出来的字段
接口返回的是嵌套 JSON,CSV 要的是一行一条记录,中间得平铺。三个具体的点。
物流轨迹是个数组,每项是 { time, info }。我要把它塞进一个单元格,做法是拼成多行文本:
const trace = (o.express_traces || [])
.map(t => [t.time, t.info].filter(Boolean).join(' '))
.join('\n');
这里要留神:拼完之后这个字段里是有换行的。CSV 一行一条记录,字段内部的换行必须用双引号包住,不包就会被当成三条记录读。所以平铺和转义的调用顺序不能反,反了这份文件直接是坏的。
第二个是缺值。接口没给的字段是 null 或 undefined,直接塞进去,表里就会出现字符串 "null"。我统一兜了一层,但要分两种:给人看的列填「未知」,给脚本读的列留空字符串。区别在于,脚本拿到「未知」还得再判断一次,留空可以直接按没值处理。
第三个是算出来的字段。表里有一列叫「单价」,这个值接口不给,是拿实付金额除以数量算的:
const qty = o.quantity || 1;
const unitPrice = qty > 0 ? (o.amount || 0) / qty : 0;
数量为 0 的情况一定要挡。不挡会得到 Infinity,写进表里就是一个谁也不知道怎么来的字符串。
用字段名取,别用下标
平铺的时候容易图省事,写成一组位置映射,或者更省事的 Object.values(o) 直接摊开。
这两种写法在接口加字段、调字段顺序的时候会直接错位:值都还在,但串行了,而且不报错。我的写法是每个值都明确写出取的是哪个字段,哪怕啰嗦:
const row = [
orderNo(o), // 订单号
formatTime(o.order_time), // 下单时间
money(o.amount), // 实付金额
];
多写这三行,等哪天接口多返回一个字段,这份表还是对的。
同一个语义,接口给了两个字段
订单的最新物流状态,接口里有两个地方都有:一个是直接给的 logistics_status,另一个藏在 extra_info.order_hint.message 里。哪个有值用哪个。
这种同义字段并存的情况在电商类接口里很常见,通常是不同业务线各自加的。写死只取一个,等接口一改就取不到值,整列空白。所以我写成回退链:
const lastTrace = o.logistics_status || o.last_trace || '';
代价要说清楚。这么写之后,"为什么这行有物流状态、那行没有"就不好解释了,值的来源不固定。这个代价我接受,对"有没有最新状态"这件事来说,有值比来源统一更重要。
导出之前还剩两件事
到这一步数据已经平了,剩下的就是拼字符串和触发保存。有两件小事还是提一下。
一是金额的单位。我这边接口给的直接是元,所以要处理的只是保留两位小数。这不是通行规则,接别的平台之前先确认它给的金额是元还是分。这一处错了,整张表的金额全是错的,而且数值看起来很正常,不容易第一时间发现。
二是时间的格式。同一个接口里,下单时间和支付时间,有可能是时间戳,也有可能已经是格式化好的字符串。统一在平铺这一层转好,别让两种格式混进同一列,不然下游一做排序就乱。
小结
拼多多订单导出 CSV表格,收尾那步拼字符串、建 Blob、触发保存,确实只要二十来行。真正的工作量在前面:从哪个数据源取、怎么翻页、断点怎么接、嵌套怎么平、缺值怎么兜。
这几件事没有一件是难的,但它们有个共同点:出问题的时候不报错,只是表里的数据悄悄不对。等你发现,往往已经拿着这份表做完对账了。
关键词:订单数据导出, CSV 导出, 接口分页, 浏览器扩展, 数据处理