拼多多订单数据导出 CSV 实战:接口分页、字段平铺与 5 个数据坑,多多开票助手

这篇讲的是导出之前那一段

我在做一个浏览器扩展,处理拼多多电商买家侧的订单和发票,订单导出 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 导出, 接口分页, 浏览器扩展, 数据处理

相关推荐
Joe_Wang51 小时前
【从0到1学习JVM · 25】同样都要停顿所有线程,Parallel比Serial到底强在哪
java·jvm·学习·垃圾回收
wuminyu1 小时前
JVM虚拟线程的底层实现原理分析
java·linux·c语言·jvm·c++
搬砖的小码农_Sky1 小时前
AI Agent:Node.js和浏览器运行环境的本质区别
javascript·node.js·人机交互
ss2731 小时前
AI全栈实战 | 2.2-02 React 思维模型:数据不可变 vs Vue 数据可变,两种哲学把复杂度推给谁
前端·vue.js·react.js
想吃火锅10051 小时前
【leetcode】189.轮转数组js
开发语言·javascript·leetcode
天衍四九-1 小时前
Docker Compose企业实战系列(三):Vue/React前端项目容器化完整部署
前端·vue.js·docker
弹简特2 小时前
【Java项目-企悦抽】13-奖品管理模块-奖品列表和创建奖品的实现
java·开发语言·网络·springboot
Rosanci2 小时前
从零到合入主线:我的 DeepSeek Harness 开源贡献实战手记
开发语言·前端·开源
小蒜学长2 小时前
基于Spring Boot+Vue的“禾源”农产品销售系统设计与实现(代码+数据库+LW)
java·spring boot·后端·农产品销售系统·产品溯源