
这篇讲的是导出之前那一段我在做一个浏览器扩展处理拼多多电商买家侧的订单和发票订单导出 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; // 数据变了断点作废只取前三单算指纹是有意的。全量算要遍历一遍而订单列表的排序基本由最新的几单决定前三单足够反映变化。这是个取舍没有标准答案。嵌套结构平铺成一行数组、缺值、算出来的字段接口返回的是嵌套 JSONCSV 要的是一行一条记录中间得平铺。三个具体的点。物流轨迹是个数组每项是{ 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 导出, 接口分页, 浏览器扩展, 数据处理