
简介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是最该克制的一个参数。很多人以为开大点更快实际是把行为特征搞得极其明显还容易触发对端限流。一个账号盯十几个 SKU4 个连接足够真要扩到上百个 SKU正确的做法是分账号、分时段而不是把连接数堆到 64。headersTimeout和bodyTimeout建议设成同一个量级否则会出现「头回来了、体卡住」的半死状态日志里看着像成功实际上拿不到数据。2.4 状态存储从 JSON 文件到 SQLite 的升级路径原脚本用 JSON 文件存状态好处是零依赖、肉眼可读、出问题直接cat一眼。坏处是写入不是原子操作进程在writeFile中途被 kill文件就只剩半截下次启动JSON.parse直接抛异常整个监控起不来。短期方案是原子写先写临时文件再rename覆盖rename在同一文件系统内是原子的。长期方案是换 SQLitebetter-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 到 3COOLDOWN_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哪怕只改了一个字段名——被字段静默变更坑过一次的人都会养成这个动作。希望帮到你。本文还有配套的精品资源点击获取