简介:jd-happy 是一份基于 Node.js 的京东商品库存监控与自动下单示例项目,面向具备一定 JavaScript 基础、希望了解电商页面抓取、扫码登录与下单流程的开发者。项目围绕地区库存查询、库存大于零自动下单、抢购商品支持和本地缓存登录状态展开,并通过日志呈现浏览器初始化、页面抓取、参数分析与关闭浏览器的过程。需要说明的是,作者已标注其为 DEPRECATED,京东接口更新后部分功能可能失效,更适合作为爬虫与自动化流程的学习参考,而非直接投入生产。压缩包共 11 个文件,约 1.7MB,以 5 个 JavaScript 脚本为主,涵盖入口、参数解析、日志与工具模块;另有 package.json 与 lock 文件管理依赖,README.md 提供说明,gif 展示运行效果,并附 license 与 gitignore。已有 820 人学习下载。读者可从中了解扫码登录、库存轮询、自动下单触发、登录态缓存及模块拆分方式,也能借鉴页面抓取到参数分析的调度思路与常见排错方向。
1. 从一份标了 DEPRECATED 的 Node 脚本说起
凌晨一点半,盯着某个商品页面刷新第十二次,库存还是灰的——干过这种事的人都明白,「到货监控」这四个字背后是多少个睡不好的夜晚。这份 Node 爬虫脚本包做的就是把这件事自动化:定时拉取商品接口,比对库存状态从无到有,命中后走一遍下单链路。它不是框架,也不是什么平台服务,就是一组 JavaScript 文件加一份配置,跑在任意一台装了 Node 的机器上。作者在标题里明确标了 DEPRECATED,这个标签得当真:把它当成一套可读、可改、结构完整的教学骨架,而不是开箱即用的生产工具。适合谁?写过一点 JavaScript、想搞清楚「抓取—状态比对—下单」三段链路怎么串起来的开发者;也适合手上已经有监控诉求、不想从零造轮子的从业者。下面按模块拆开看。
2. 模块拆解:三个职责边界与选型理由
这套脚本的价值不在代码量,而在它把「抓取」「解析」「决策」拆得很干净。很多人自己写监控,第一版往往是一个 200 行的 index.js,请求、正则、if-else 全糊在一起,平台字段一改就整个报废。这份资源的目录结构恰好是反面教材的解药,值得先看清楚再动手。
2.1 目录结构与职责边界
| 文件 / 目录 | 职责 | 你大概率会改的地方 |
|---|---|---|
config/default.json | 商品 ID、轮询间隔、通知地址 | 换监控目标只动这里 |
src/fetcher.js | 发请求、连接池、超时、重试 | UA、超时阈值、限速参数 |
src/parser.js | 把响应 JSON 转成内部状态 | 平台字段变动时只改这里 |
src/state.js | 状态持久化与前后比对 | 存储介质升级 |
src/notifier.js | 通知出口 | 换通道、加去重 |
src/order.js | 下单链路 | 确认要自动下单时才启用 |
index.js | 主循环与调度 | 调度策略、并发控制 |
最关键的一条分界线是fetcher和parser必须分开。抓取层只关心「我拿到了一段什么内容」,解析层只关心「这段内容里的库存是什么意思」。这么拆的直接好处是可回归:把一次真实响应存成 JSON 文件,解析层就能离线跑单测,不用每次都真去请求。我见过太多监控脚本死在「字段改名了但没人发现」上,解析失败静默返回 undefined,程序照跑,只是永远不报警——这才是最难查的那类故障。
state.js单独拎出来同样重要。状态不是「上一次请求的返回值」,而是「经过确认的业务判断」。这两者混在一起,后面加冷却窗口、加连续确认次数时就会处处打架。
2.2 为什么是 Node 而不是 Python 定时脚本
监控这件事的本质是 IO 等待:一个 SKU 一轮请求可能 200ms,其中 190ms 在等 socket。Node 的单线程事件循环天生适合这种负载,一个进程盯几十上百个 SKU 不需要开线程池,内存也就是几十兆。再加上通知通道(webhook、邮件 API、企业微信机器人)基本都是 HTTP 调用,同语言少一层胶水,配置文件读进来就能用。
但也有边界要说清楚。如果你的目标页面是纯前端渲染、库存数字靠 JS 算出来的,那不管 Node 还是别的语言,都得上无头浏览器,内存直接飙到几百兆,这时候 Node 的优势就不明显了,选你手熟的即可。另外别拿 Node 去做 CPU 密集的活,比如图片验证码识别、大文件哈希,事件循环会被阻塞,整个监控一起卡住。判断标准很简单:这活是「等」还是「算」,等用 Node 舒服,算就扔给子进程或者外部服务。
这份资源还有一个隐性优点:它没有引入 TypeScript、没有上 ORM、没有搞微服务拆分。对一个可能只跑几周的工具来说,这是对的取舍。你要是打算长期维护,再自己加类型和测试也不迟。
2.3 请求层选型:原生 http、axios 与 undici
三者的差别不在语法糖,而在连接复用和超时控制。
原生http/https什么都要自己来:重定向、gzip 解压、连接池、超时,写完一百行还没发出去一个正经请求。axios接口友好,但默认的 agent 连接复用窗口很短,高频轮询时会不断新建 TCP 连接,握手开销白白浪费,得手动配http.Agent({ keepAlive: true })。undici是 Node 18 之后内置fetch的底层实现,连接池、keep-alive、headers 超时、body 超时全在构造函数里配,这轮监控用它最省心。
# 依赖就这两个,别再多装 npm i undici better-sqlite3// src/fetcher.js —— 连接池只建一次,全进程复用 import { Pool } from 'undici'; export const pool = new Pool('https://example-mall.test', { connections: 4, // 同时打开的 socket 上限,别超过 8 keepAliveTimeout: 10_000, // 连接空闲多久后关闭,太短等于没复用 headersTimeout: 8_000, // 等响应头的上限,防挂死 bodyTimeout: 8_000, // 读响应体的上限 });connections是最该克制的一个参数。很多人以为开大点更快,实际是把行为特征搞得极其明显,还容易触发对端限流。一个账号盯十几个 SKU,4 个连接足够;真要扩到上百个 SKU,正确的做法是分账号、分时段,而不是把连接数堆到 64。headersTimeout和bodyTimeout建议设成同一个量级,否则会出现「头回来了、体卡住」的半死状态,日志里看着像成功,实际上拿不到数据。
2.4 状态存储:从 JSON 文件到 SQLite 的升级路径
原脚本用 JSON 文件存状态,好处是零依赖、肉眼可读、出问题直接cat一眼。坏处是写入不是原子操作:进程在writeFile中途被 kill,文件就只剩半截,下次启动JSON.parse直接抛异常,整个监控起不来。
短期方案是原子写:先写临时文件,再rename覆盖,rename在同一文件系统内是原子的。长期方案是换 SQLite,better-sqlite3是同步 API,在 Node 里反而好用——不用 await,不会出现两个协程同时写一行的情况。
-- src/schema.sql CREATE TABLE IF NOT EXISTS stock_state ( sku_id TEXT PRIMARY KEY, phase TEXT NOT NULL, -- OUT / IN / UNKNOWN streak INTEGER DEFAULT 0, -- 连续命中次数 last_notify INTEGER DEFAULT 0, -- 上次通知时间戳,用于冷却 updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS order_ledger ( idem_key TEXT PRIMARY KEY, -- 幂等键,防重复下单 sku_id TEXT NOT NULL, status TEXT NOT NULL, -- submitted / confirmed / failed created_at INTEGER NOT NULL );两张表,一张管库存状态,一张管下单台账。别小看order_ledger,它是后面防止「一次超时变两笔订单」的唯一可靠依据,比任何内存标记都管用。开库时记得PRAGMA journal_mode = WAL,读写不互相阻塞,进程被 kill 也能从日志恢复。
3. 到货监控链路:从接口定位到限速调度
抓取这一段的目标很明确:用最少的请求,拿到一个能判断「有没有货」的字段。听起来简单,实际上一半的坑都在「你以为你拿到了」。
3.1 先找接口,别急着解析页面
步骤很固定,照着走:
- 打开商品详情页,F12 切到 Network,筛 Fetch/XHR。
- 刷新页面,在过滤框里输入商品 ID,剩下的条目通常只有几条。
- 逐条看 Response,找带库存、可售状态、限购标记的那一个,一般是 JSON。
- 右键复制为 cURL,把
cookie、referer之外的头全删掉,看还能不能返回 200。 - 用 Node 复现一次,确认字段路径稳定。
第 4 步是分水岭。删头之后如果返回的是验证页或者 403,说明有额外的风控参数,这时候别硬刚,回到上一步找找有没有更「干净」的接口——比如列表页的批量查询接口,往往比单品接口限制宽松。
// 先探一次,把原始响应落盘,后面所有解析都基于这份样本 import { writeFile } from 'node:fs/promises'; import { pool } from './fetcher.js'; const res = await pool.request({ method: 'GET', path: '/api/product/100000000001/stock', headers: { 'accept': 'application/json', 'referer': 'https://example-mall.test/item/100000000001.html', 'user-agent': 'Mozilla/5.0 (compatible; StockWatcher/1.0)', }, }); const raw = await res.body.text(); await writeFile(`fixtures/stock-${Date.now()}.json`, raw); console.log(res.statusCode, res.headers['content-type'], raw.length);这段代码的重点不是请求本身,而是把原始响应存下来。content-type一定要打出来——很多「解析失败」其实是返回了 HTML 验证页,JSON.parse报的那个错会把你引向完全错误的方向。我习惯在探接口阶段至少存三份样本:正常有货、正常无货、异常情况,后面写解析和写测试都靠它们。
3.2 一个能跑起来的最小轮询
拿到接口之后,先把单次抓取写扎实,别急着上循环。
// src/fetcher.js export async function fetchStock(skuId) { const res = await pool.request({ method: 'GET', path: `/api/product/${skuId}/stock`, headers: { 'accept': 'application/json', 'referer': `https://example-mall.test/item/${skuId}.html` }, }); if (res.statusCode !== 200) { // 把状态码带出去,让上层决定是重试、降频还是告警 throw Object.assign(new Error(`http_${res.statusCode}`), { statusCode: res.statusCode }); } const type = res.headers['content-type'] || ''; if (!type.includes('json')) { throw new Error('not_json_response'); // 大概率是验证页,别往下走 } return res.body.json(); }两个细节值得说。第一,错误对象带上statusCode,上层才能区分「429 该退避」和「404 该停监控」。第二,content-type检查必须放在json()之前,undici的json()在拿到 HTML 时抛的错信息很含糊,你会在日志里看到一堆Unexpected token '<',排查半天才发现是被拦了。
调用侧的错误处理也要分级:网络类错误(ECONNRESET、ETIMEDOUT)重试两次;429直接指数退避并把全局频率降一档;403立刻停止该账号的所有请求并发通知,因为继续打只会让情况更糟。
3.3 库存状态机:把布尔值变成可比对的状态
最朴素的写法是if (stock > 0) notify(),然后你就会在一晚上收到四十条通知,因为接口在两个仓之间来回跳,库存数字在 0 和 1 之间抖动。
正确做法是引入状态机:内部状态只有OUT、IN、UNKNOWN三种,IN需要连续 N 次观测确认,且进入IN后有冷却窗口。
// src/state.js const CONFIRM_TIMES = 2; // 连续命中几次才算真到货 const COOLDOWN_MS = 30 * 60 * 1000; // 通知后 30 分钟内不再重复通知 export function reduce(prev, observed, now = Date.now()) { const s = prev ?? { phase: 'OUT', streak: 0, lastNotify: 0 }; s.shouldNotify = false; if (observed === 'UNKNOWN') return s; // 解析不出来就保持原状态,不误判 if (observed === 'OUT') { s.streak = 0; s.phase = 'OUT'; return s; } s.streak += 1; if (s.streak >= CONFIRM_TIMES && s.phase !== 'IN' && now - s.lastNotify > COOLDOWN_MS) { s.phase = 'IN'; s.lastNotify = now; s.shouldNotify = true; } return s; }streak是连续命中计数,一旦观测到无货立刻清零,防止「有货—无货—有货」这种交叉抖动累积到阈值。lastNotify做冷却,避免同一商品在半小时内反复触发。UNKNOWN分支是很多人漏掉的:解析失败时最危险的处理是当成「无货」,因为它会让状态静默回退,你还以为一切正常。保持原状态、单独计数告警,才是对的。
字段语义也要盯紧。「可加入购物车」不等于「有货」,「可预约」不等于「现货」,「限购 1 件」更不等于缺货。这几种状态在接口里经常是不同字段的组合,建议在parser.js里写一张映射表,把平台字段先归一成业务状态,再做状态机,别让状态机去理解平台细节。
3.4 定时与限速:setInterval 是个陷阱
setInterval(fn, 5000)不会管fn跑完没有。任务耗时一旦超过间隔,回调就会排队堆积,日志里会出现同一秒十几条请求,内存缓慢上涨,最后整个进程卡死。这是新手最常踩的一个坑,也是最容易验证的一个:把间隔设成 1 秒、请求故意 sleep 3 秒,跑一分钟就能复现。
正确写法是递归setTimeout,配合令牌桶做全局限速。
// src/scheduler.js import { setTimeout as sleep } from 'node:timers/promises'; class TokenBucket { constructor(capacity, refillPerSec) { this.capacity = capacity; this.tokens = capacity; this.refillPerSec = refillPerSec; this.last = Date.now(); } async take() { while (true) { const now = Date.now(); this.tokens = Math.min(this.capacity, this.tokens + (now - this.last) / 1000 * this.refillPerSec); this.last = now; if (this.tokens >= 1) { this.tokens -= 1; return; } await sleep(200); } } } const bucket = new TokenBucket(5, 0.5); // 桶容量 5,每秒补 0.5 个 → 平均 2 秒一次 let running = false; export async function loop(task, intervalMs = 5000, jitterMs = 2000) { if (running) return; // 防止重复启动 running = true; while (running) { const started = Date.now(); await bucket.take(); try { await task(); } catch (e) { console.error(JSON.stringify({ evt: 'task_fail', msg: e.message })); } const cost = Date.now() - started; const wait = Math.max(0, intervalMs - cost) + Math.random() * jitterMs; await sleep(wait); } }capacity和refillPerSec决定了平均 QPS,这里配成平均 2 秒一次,配合后面的抖动,对端看到的请求间隔是不规则的。jitterMs那个随机量非常关键——固定间隔是最容易被识别的机器特征,加个 0~2 秒的随机偏移,成本几乎为零。running标记防的是误调用两次loop()导致双倍请求,这种 bug 在重构时特别容易出现。
4. 下单链路的骨架:会话、参数与幂等
监控只解决「知道」,下单才解决「买到」。这一段是把双刃剑:写好了省事,写歪了可能重复下单、误买规格,或者把账号搞进风控名单。所以这一章的顺序是「先讲安全边界,再讲代码」。
4.1 会话与 Cookie 的持久化
下单必须带登录态。常见做法是从浏览器导出 Cookie,存成本地文件,权限设成 600,进程启动时读进来,请求时拼到 header 上。
// src/session.js import { readFile, writeFile, chmod } from 'node:fs/promises'; const FILE = './.session.json'; export async function loadCookie() { const txt = await readFile(FILE, 'utf8'); const jar = JSON.parse(txt); if (Date.now() > jar.expireAt) throw new Error('session_expired'); return jar.cookie; } export async function saveCookie(cookie, ttlMs = 2 * 60 * 60 * 1000) { await writeFile(FILE, JSON.stringify({ cookie, expireAt: Date.now() + ttlMs }), { mode: 0o600 }); await chmod(FILE, 0o600); // 双保险,别让同机器其他用户读到 }expireAt是给自己看的,不是给服务端看的。真实过期时间对端说了算,所以还要加一层主动探测:每次下单前先请求一个「我的订单」列表接口,如果返回 302 或者跳登录页,就判定会话失效,直接停掉下单流程并告警。别指望下单接口返回的错误信息能告诉你原因,它可能只给你一个「系统繁忙」。
提示:会话文件不要提交到任何版本库,
.gitignore第一行就该是它。也别在多台机器上同时用同一个会话跑下单,行为特征会很怪。
4.2 下单请求的字段拆解
不同平台字段名千差万别,但结构大同小异。下面这张表是这类接口的通用骨架,字段名请以你实际抓到的为准,别照抄。
| 字段 | 含义 | 容易踩的点 |
|---|---|---|
skuId | 规格 ID | 和商品 ID 不是一个东西,混了就是买错颜色 |
quantity | 数量 | 多数平台限购 1,传多了直接报错 |
addressId | 收货地址 ID | 提前在账号里配好,别在下单时算 |
payType | 支付方式 | 自动化的终点通常是「提交订单」,付款仍需人工 |
token/risk | 风控参数 | 变化频繁,硬编码必翻车 |
// src/order.js export function buildPayload({ skuId, addressId, riskToken }) { return { skuId, quantity: 1, addressId, payType: 3, token: riskToken, // 从提交流程里实时取,别写死 source: 'pc', }; } export async function submitOrder(payload, cookie) { const res = await pool.request({ method: 'POST', path: '/api/order/submit', headers: { 'content-type': 'application/json', 'cookie': cookie, 'referer': 'https://example-mall.test/cart', }, body: JSON.stringify(payload), }); return { status: res.statusCode, data: await res.body.json() }; }token这类风控参数是整条链路里最脆弱的一环。它通常有有效期、和会话绑定、甚至和具体商品绑定,写死在配置里的结果就是「昨天能跑,今天全失败」。我的建议是:这一版资源就把它当成占位符,跑通「提交订单」这个动作即可,别指望它长期可用。真想长期跑,得把取 token 的流程也自动化,那是另一个工程量级。
注意:自动化下单在很多平台的用户协议里是明确限制的行为,账号风险由使用者承担。监控自己需要的商品、控制频率、不批量扫货,是基本的分寸。
4.3 幂等与重试:一次超时不该变成两笔订单
这是整篇最值钱的一节。下单请求超时,不代表服务端没收到。你在客户端看到ETIMEDOUT,服务端可能已经把订单建好了,只是响应没回来。这时候如果按「失败就重试」的通用逻辑再来一次,结果就是两笔订单。
正确做法分三层。
第一层是幂等键:skuId + quantity + 账号 + 日期拼一个字符串,写进order_ledger表,主键冲突就代表这单已经提交过。
第二层是错误分级:只有明确的「请求没发出去」或「服务端明确返回可重试」才重试,超时一律不重试,改为查询订单列表确认。
// src/order.js const RETRYABLE_STATUS = new Set([502, 503, 504]); const RETRYABLE_CODE = new Set(['ECONNRESET', 'ECONNREFUSED']); export async function submitSafely(db, payload, cookie, idemKey) { const exists = db.prepare('SELECT 1 FROM order_ledger WHERE idem_key = ?').get(idemKey); if (exists) return { skipped: true, reason: 'already_submitted' }; let attempt = 0; while (attempt < 3) { attempt += 1; try { const { status, data } = await submitOrder(payload, cookie); if (status === 200 && data.success) { db.prepare('INSERT INTO order_ledger VALUES (?,?,?,?)') .run(idemKey, payload.skuId, 'submitted', Date.now()); return { ok: true, orderId: data.orderId }; } if (!RETRYABLE_STATUS.has(status)) { db.prepare('INSERT INTO order_ledger VALUES (?,?,?,?)') .run(idemKey, payload.skuId, 'failed', Date.now()); return { ok: false, status }; } } catch (e) { // 超时属于「不知道结果」,绝不能盲目重试 if (e.code === 'ETIMEDOUT' || e.name === 'AbortError') { return { ok: false, unknown: true, hint: '请查订单列表确认' }; } if (!RETRYABLE_CODE.has(e.code)) return { ok: false, error: e.message }; } await new Promise(r => setTimeout(r, 500 * 2 ** attempt + Math.random() * 300)); } return { ok: false, reason: 'retry_exhausted' }; }第三层是退避:500 * 2 ** attempt加上随机量,避免三次重试挤在一秒内打完。这段代码里最值得抄的是那个unknown: true分支——它把「不确定」当成一种独立结果来处理,而不是硬塞进成功或失败二选一。工程上大部分事故都出在把不确定当成确定。
4.4 通知通道:别让通知拖垮主流程
通知是整条链路的最后一环,也是最不该影响主流程的一环。webhook 挂了、邮件 API 限流了,都不该让监控循环停摆。
// src/notifier.js export async function notify(text, retries = 2) { for (let i = 0; i <= retries; i++) { try { const res = await fetch(process.env.WEBHOOK_URL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ msgtype: 'text', text: { content: text } }), signal: AbortSignal.timeout(5000), // 必须设超时,否则会挂住循环 }); if (res.ok) return true; } catch { /* 静默,通知失败不该中断监控 */ } await new Promise(r => setTimeout(r, 1000 * (i + 1))); } return false; }AbortSignal.timeout这行是重点。没有它,一次网络抖动就能让整个循环卡在这里几十秒,后面的调度全乱。另外通知内容里带上时间戳、SKU、当前状态、观测次数,事后翻记录时能省很多事。
5. 避坑与排查:五条踩出来的记录
下面这五条,基本覆盖了这套脚本从「能跑」到「敢放着不管」之间的全部距离。每条都按现象、原因、解决写,对上号了直接改。
5.1 跑二十分钟后全部返回验证页
现象:日志显示状态码 200,但解析一直失败,content-type是text/html。
原因:固定间隔无抖动,加上多个 SKU 共用连接池把并发打满,行为特征太整齐。对端不一定封你,但会把你降级到验证页。
解决:把令牌桶的全局 QPS 压到个位数,间隔加随机抖动,同一账号的请求串行化,并在解析前强制校验content-type。
if (!(res.headers['content-type'] || '').includes('json')) { throw new Error('blocked_or_verify_page'); // 单独一个错误类型,方便统计触发率 }统计这个错误的出现率比统计 403 更有用,它通常比硬封早出现十几分钟,是个很好的预警信号。
5.2 重启后状态文件解析失败
现象:Unexpected end of JSON input,进程直接起不来。
原因:writeFile覆盖写不是原子操作,进程在写入中途被 kill,文件只剩半截。
解决:临时文件加rename原子替换,或者直接换 SQLite。
import { writeFile, rename } from 'node:fs/promises'; export async function saveState(obj) { const tmp = `./state.json.${process.pid}.tmp`; await writeFile(tmp, JSON.stringify(obj)); await rename(tmp, './state.json'); // 同文件系统内原子替换 }5.3 同一秒出现十几条请求
现象:日志时间戳密集,内存缓慢上涨,一段时间后进程无响应。
原因:setInterval不感知任务耗时,任务变慢时回调排队堆积。
解决:改递归setTimeout,加running标记,请求层加AbortSignal.timeout。验证方法很简单:把间隔设成 1 秒、请求里故意 sleep 3 秒,跑一分钟看日志密度。
5.4 一晚上收到几十条到货通知
现象:通知刷屏,但实际库存只变动了一两次。
原因:一是没做连续确认,二是没做冷却窗口,三是字段语义判断错(把「限购」当成「缺货」再当成「有货」反复切换)。
解决:CONFIRM_TIMES设成 2 到 3,COOLDOWN_MS设成 30 分钟以上,通知本身落库去重(同一 SKU 同一状态一天只发一次)。另外把每次观测的原始字段值也记进日志,抖动时才有据可查。
5.5 一次下单变成两笔订单
现象:日志里第一次是ETIMEDOUT,重试后返回成功,但订单列表里有两笔。
原因:请求已到达服务端并创建订单,响应在回程丢了,客户端把超时当成失败去重试。
解决:超时不重试,改为查询订单列表确认;下单前查order_ledger幂等键;成功和失败都要落盘,不能只在内存里标记。
注意:这一条是所有坑里代价最高的,写下单逻辑时先把幂等表建好,再去写请求。
6. 进阶:把一次性脚本改成可回放的巡检服务
脚本能跑通只是及格线。真让它替我值夜班,还差两件事:出问题时能查,改代码时能验。
6.1 结构化日志:把黑匣子变成流水
不要用console.log('有货了')。每行日志打成一个 JSON,字段固定:ts、sku、status、latency、phase、err。这样grep、jq、导入表格都方便。
const log = (evt, extra = {}) => console.log(JSON.stringify({ ts: new Date().toISOString(), evt, ...extra })); log('fetch_ok', { sku, status: 200, latency: Date.now() - started, phase: next.phase });跑一周之后,我会看三个数:抓取成功率、blocked_or_verify_page的占比、P95 延迟。前两个掉下去就说明频率该降了,第三个涨上来通常是连接池或者对端变慢。
6.2 用响应回放给解析层上保险
平台字段改名是这个项目最大的外部风险。防它的办法不是天天盯着看,而是把真实响应存成 fixture,离线跑一遍解析。
// test/parser.test.js —— node --test 直接跑,不装任何测试框架 import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, readdirSync } from 'node:fs'; import { parseStock } from '../src/parser.js'; for (const f of readdirSync('fixtures')) { test(`replay ${f}`, () => { const raw = readFileSync(`fixtures/${f}`, 'utf8'); const got = parseStock(JSON.parse(raw)); assert.ok(['IN', 'OUT', 'UNKNOWN'].includes(got), `非法状态: ${got}`); }); }这个测试不检查「解析结果对不对」,只检查「解析结果是不是一个合法状态」。听起来很弱,但它能抓住 90% 的字段改名事故——字段没了,parseStock返回UNKNOWN,断言立刻红,你在改代码前就知道出事了。真正的语义正确性,还得靠人工对比那三份样本。
我的习惯是每次线上出现一次解析失败,就把那份原始响应补进fixtures,测试集自然长大。从那以后我每次改parser.js之前,都强制先跑一遍node --test,哪怕只改了一个字段名——被字段静默变更坑过一次的人,都会养成这个动作。希望帮到你。
本文还有配套的精品资源,点击获取