
简介这是一份基于 Node.js 实现的京东商品到货监控与自动下单脚本面向具备一定 JavaScript 基础、希望了解浏览器自动化与抢购流程的开发者。项目通过扫码登录、按地区查询商品库存并在库存大于 0 时触发下单同时支持抢购场景与本地缓存登录状态入口、参数、日志和工具函数做了模块拆分便于按流程阅读调试。需要说明的是京东接口更新后部分功能已过期仓库标注为 DEPRECATED更适合作为自动化流程与页面分析思路的参考而非直接投入生产。压缩包共 11 个文件约 1.7MB以 5 个 js 源码为核心另含 package.json 与 yarn.lock 依赖配置、README 说明文档、LICENSE、.gitignore 及演示 gif覆盖运行配置、日志输出和操作展示。已有 820 人学习适合用来拆解扫码登录、库存轮询与下单触发之间的协作关系并积累接口失效后的排查与改造经验。1. 用 Node 写一个到货监控先把需求钉在「低频、可查、可控」上某电商平台上有一件商品长期缺货页面上的「到货通知」按钮是唯一的线索手动刷新几十次也未必能撞上补货的那几分钟。这个需求催生了这套 Node 爬虫方案定时采集商品页判定库存状态变化触发通知再进入一个带人工确认的下单草稿流程。它适合两类人一类是要交毕设或课设的学生需要一条从采集到动作的完整链路来证明工程能力另一类是从业者想把这套监控骨架改成价格监控、库存预警、竞品快照。方案的前提必须说清楚单机、低频、个人自用、尊重平台规则遇到验证环节就停下转人工不追求毫秒级抢购也不做批量囤货。先把边界钉死后面的架构才有讨论价值。2. 总体架构四层结构与三张核心表的职责划分这个项目最容易被写成一堆 setInterval 加几个 axios 请求的堆砌脚本跑两天就不知道哪条数据是真的。要让它成为一份能答辩、能交付的方案第一步是把职责切开。我在做类似项目时习惯先画数据流再决定代码目录因为数据流的断点往往就是后面 bug 的藏身处。2.1 四层划分调度层、采集层、判定层、动作层调度层只干一件事决定「什么时候去抓哪个 SKU」。它不关心页面长什么样也不关心抓回来是涨是跌手里维护的是一张任务表每个任务有下次触发时间、最小间隔、当前是否在执行。把所有时间逻辑收在一处后面要改成固定间隔、随机抖动、错峰批量只动这一个文件。采集层负责「把页面变成结构化数据」。它需要处理超时、重试、条件请求、编码异常这些脏活输出一份干净的快照对象标题、价格、库存文案、抓取时间、数据来源标记。这一层的价值在于它让上层永远不接触原始 HTML页面改版的影响面被压缩到一个解析函数里。判定层是整套系统的大脑维护每个 SKU 的状态机。它拿新快照和上一条快照比对决定是「无变化」「状态迁移」还是「数据可疑」。可疑这个词很重要解析失败、价格为空、库存文案消失都不应该被当成「无货」直接吞掉而要单独标记出来否则你会得到一条看起来很安静、实际上早就瞎掉的监控。动作层负责对外产生副作用发通知、写事件表、生成下单草稿。副作用必须可重放、可去重、可关闭。我一般会给动作层加一个全局开关调试时把通知关掉只写日志避免在开发阶段把消息通道刷爆。四层之间的通信建议用普通的函数调用加一个薄薄的事件总线不要一上来就上消息队列。单机监控的并发量通常在个位数引入 MQ 只会让你在答辩时多解释十分钟还解释不清。2.2 数据模型商品表、快照表、事件表怎么设存储选 SQLite 就够单文件、零部署、方便随项目一起打包提交。三张表的字段我按实际用过的最小集合给出来表名关键字段说明productssku、title、url、max_price、interval_ms、enabled监控目标配置max_price 是价格上限超过就不生成订单草稿snapshotsid、sku、price、stock_text、source、etag、fetched_at每次采集落一条保留原始判定依据eventsid、sku、event_key、from_state、to_state、payload、created_at状态迁移事件event_key 唯一索引用于幂等ordersid、sku、price、qty、status、created_at下单草稿status 只有 draft / confirmed / cancelled 三种快照表会快速膨胀一天 288 条乘以监控数量跑一个月就是几万行。通常的做法是给 fetched_at 建索引再加一个清理任务只保留最近 7 天的明细历史状态压缩成每日一条。这个细节写进方案里答辩时被问到「数据量大了怎么办」就有话可说。2.3 技术选型为什么是 Node 而不是一堆定时脚本选 Node 的理由不是「流行」而是这个场景恰好是 IO 密集型大量等待网络返回CPU 几乎空转单线程事件循环能把并发请求安排得明明白白。再配上 JavaScript 天然的 JSON 处理能力解析页面内嵌数据几乎零成本。依赖清单控制在六个以内多了说明设计有问题依赖用途备注undici 或内置 fetchHTTP 请求Node 18 之后内置 fetch 已可用不额外装也行cheerioDOM 解析兜底只在页面没有内嵌 JSON 时启用better-sqlite3本地存储同步 API代码短适合单机pino结构化日志输出 JSON方便 grep 和后续接采集node-cron低频兜底任务清理快照、日报汇总用dotenv配置注入把 webhook 地址、cookie 放环境变量不要引入 puppeteer 这类浏览器自动化方案。它启动慢、吃内存而且在这个场景里属于明显的过度设计个人自用的低频监控用不上渲染反而会把机器拖慢。真遇到必须渲染的页面正确做法是放弃这个目标换一个数据源而不是把复杂度翻三倍。提示所有凭证、cookie、webhook 地址一律走环境变量并在 .gitignore 里排掉 .env。我见过太多把登录态硬编码进源码然后提交到公开仓库的例子那是最不该犯的错。3. 采集层实现调度、节流与解析的三角平衡采集层是整个项目里最容易翻车的地方因为它直接面对外部系统的不确定性。写得糙一点跑一天就能收到一堆 429 和空数据写得好一点跑一个月不用人管。这一章把调度器、请求封装、解析兜底三块拆开讲每块都给可运行的代码。3.1 调度器单进程时间轮与并发闸门调度器的核心设计是「一次扫描 并发上限」而不是给每个 SKU 起一个 setInterval。后者的血泪经验是任务一多定时器互相打架某个任务卡住不影响别人但也没人发现最后内存里躺着一堆没人清理的句柄。// scheduler.js —— 单进程时间轮按 tick 扫描到期任务 class Scheduler { constructor({ tickMs 1000, concurrency 3 } {}) { this.tickMs tickMs; // 扫描粒度1s 足够别设成 10ms 空转 this.concurrency concurrency; // 同时在跑的请求上限个人自用 2~3 即可 this.tasks new Map(); // sku - { nextAt, intervalMs, running } this.active 0; // 当前在跑的任务数 this.timer null; } add(sku, intervalMs, jitterRatio 0.2) { this.tasks.set(sku, { intervalMs, jitterRatio, // 首次触发加随机偏移避免所有任务在同一秒集中打出去 nextAt: Date.now() this.rand(intervalMs * jitterRatio), running: false, }); } rand(ms) { return Math.floor(Math.random() * ms); } start(handler) { this.timer setInterval(() this.tick(handler), this.tickMs); this.timer.unref?.(); // 不阻塞进程退出写测试脚本时非常关键 } async tick(handler) { if (this.active this.concurrency) return; const now Date.now(); for (const [sku, t] of this.tasks) { if (this.active this.concurrency) break; if (t.running || t.nextAt now) continue; t.running true; this.active 1; handler(sku) .catch((err) console.error([task:${sku}], err.message)) .finally(() { t.running false; this.active - 1; // 下一次触发在基准间隔上叠加抖动避免形成固定节奏 const base t.intervalMs; t.nextAt Date.now() base this.rand(base * t.jitterRatio); }); } } stop() { clearInterval(this.timer); } }tickMs 决定扫描精度设成 1000 意味着最坏情况下任务延后一秒执行对分钟级的监控间隔完全无感而设成 10 只会让 CPU 白白空转。concurrency 是并发闸门它的作用是把自己伪装成一个「有礼貌的访客」不要让十来个请求同时涌出去。jitterRatio 控制抖动的比例0.2 表示在间隔上下浮动 20%配上随机偏移整体请求节奏看起来就自然多了。running 标记是防止同一个 SKU 的任务重入。如果上一次请求卡在网络超时里下一次 tick 到点时它还在跑直接跳过避免堆积。这一行代码省掉的问题比它看起来多得多。3.2 请求封装超时、退避与条件请求请求封装要覆盖四类返回正常 200、未修改 304、限流 429、其他错误。四类的处理策略完全不同混在一起写就会得到「一出错就疯狂重试」的经典反模式。// fetchSku.js —— 带超时、退避与条件请求的抓取 const DEFAULT_HEADERS { accept-language: zh-CN,zh;q0.9, cache-control: no-cache, }; async function fetchWithPolicy(url, { timeoutMs 8000, retries 2, etag } {}) { let lastErr; for (let attempt 0; attempt retries; attempt 1) { const ac new AbortController(); const timer setTimeout(() ac.abort(), timeoutMs); try { const res await fetch(url, { signal: ac.signal, headers: { ...DEFAULT_HEADERS, ...(etag ? { if-none-match: etag } : {}) }, }); // 304 说明页面没变直接复用上次解析结果省掉一次解析开销 if (res.status 304) return { notModified: true, etag }; // 429 和 5xx 是让你慢一点的信号退避后重试 if (res.status 429 || res.status 500) { const wait 1000 * 2 ** attempt Math.random() * 500; await new Promise((r) setTimeout(r, wait)); lastErr new Error(HTTP ${res.status}); continue; } // 其他 4xx 属于确定性失败重试没有意义直接抛 if (!res.ok) throw new Error(HTTP ${res.status}); return { notModified: false, body: await res.text(), etag: res.headers.get(etag), }; } catch (err) { lastErr err; } finally { clearTimeout(timer); } } throw lastErr; }timeoutMs 设 8 秒是因为页面本身不大超过这个时间基本可以判定链路异常继续等只会拖住并发槽位。retries 给 2 次配合 1s、2s 的指数退避总耗时上限约 11 秒仍然控制在单次任务的可接受范围内。etag 是条件请求的关键服务端返回 304 时省下的不只是流量还有一次完整的解析与状态比对在监控大量 SKU 时这个优化相当可观。有一点容易忽略AbortController 的 clearTimeout 必须放在 finally 里。如果只在成功分支清理超时抛错的那条路径会留下一个悬空定时器跑久了就是内存缓慢上涨非常隐蔽。3.3 解析与归一化选择器失效的兜底策略解析策略我一般按优先级排三级页面内嵌 JSON、语义化 DOM 选择器、纯文本正则。第一级最可靠因为前端渲染的数据接口通常会带一个结构化的初始状态第二级依赖 class 名改版就挂第三级最糙但对文案型字段有货 / 无货 / 预订意外地耐用。// parse.js —— 三级解析输出统一的快照结构 function normalizeText(s ) { return s.replace(/\s/g, ).trim(); } function parseSkuPage(html) { // 一级页面内嵌的初始状态 JSON改版风险最低 const jsonMatch html.match(/window\.__SKU_DATA__\s*\s*(\{[\s\S]*?\});/); if (jsonMatch) { try { const data JSON.parse(jsonMatch[1]); return { source: json, title: normalizeText(data.name), price: Number(data.price), stockText: data.stockText, }; } catch { /* 解析失败落到下一级不在这里中断 */ } } // 二级 三级DOM 与文本兜底 const price html.match(/span[^]*class?price?[^]*([\d.,])/)?.[1]; const stockText html.match(/(有货|无货|到货通知|预订)/)?.[1]; return { source: dom, title: null, price: price ? Number(price.replace(/,/g, )) : null, stockText, }; }source 字段是整个解析层最重要的输出。它的含义是「这条数据是从哪一级拿到的」后续判定层看到 source 从 json 变成 dom即使价格和库存都没变也应该在日志里升一级告警因为这说明页面结构动了下一次可能就彻底解析不出来。这是给未来的自己留的后悔药。price 为空时不要填 00 会在后面的价格守卫里被误判成「极低价」逻辑上等价于放行。空值就是空值让判定层去处理未知态这才是正确的边界处理。3.4 冷启动把全量抓取切成小批监控 50 个 SKU程序一启动就并发全抓十有八九会被限流。通常的做法是冷启动分片把任务列表按 5 个一批批间间隔 30 秒边跑边写数据库第一批结果出来时后面的批次还没开始用户已经能看到部分数据体验上也不至于「启动后干等三分钟」。实现上不需要额外组件在 Scheduler.add 之前做一次分批循环配合await sleep(batchGapMs)即可。参数上批大小取决于并发闸门批大小应该小于等于 concurrency 的两倍否则排队等待的任务太多抖动计算会失真。4. 状态判定与通知库存状态机与消息幂等采集层给的是「这一刻的事实」判定层要回答的是「和上一刻相比发生了什么值得通知的事」。这两件事混在一起写就会出现「每次抓到有货都发一条消息」的结果一晚上能给你发两百条。4.1 状态机定义五种状态与合法迁移状态用五个就够UNKNOWN首次采集或数据可疑、OUT_OF_STOCK无货、IN_STOCK有货未通知、NOTIFIED已通知待处理、ORDER_DRAFT已生成订单草稿。迁移表如下当前状态允许迁移到触发条件UNKNOWNOUT_OF_STOCK、IN_STOCK首次拿到有效库存文案OUT_OF_STOCKIN_STOCK库存文案变为有货IN_STOCKNOTIFIED、ORDER_DRAFT、OUT_OF_STOCK通知发出 / 生成草稿 / 又卖光NOTIFIEDORDER_DRAFT、OUT_OF_STOCK人工确认后生成草稿 / 再次缺货ORDER_DRAFTOUT_OF_STOCK草稿未及时处理商品售罄这张表的价值在于任何不在表内的迁移都会被拒绝并记一条日志。比如从 UNKNOWN 直接跳到 ORDER_DRAFT说明中间漏了判定属于逻辑错误而不是业务变化必须拦下来。// stateMachine.js —— 迁移校验与价格守卫 const TRANSITIONS { UNKNOWN: [OUT_OF_STOCK, IN_STOCK], OUT_OF_STOCK: [IN_STOCK], IN_STOCK: [NOTIFIED, ORDER_DRAFT, OUT_OF_STOCK], NOTIFIED: [ORDER_DRAFT, OUT_OF_STOCK], ORDER_DRAFT: [OUT_OF_STOCK], }; function transition(current, next, ctx) { const allowed TRANSITIONS[current] || []; if (!allowed.includes(next)) { return { ok: false, reason: illegal transition ${current} - ${next} }; } // 价格守卫到货不等于该买超过心理价位就不生成草稿 if (next ORDER_DRAFT (!ctx.price || ctx.price ctx.maxPrice)) { return { ok: false, reason: price ${ctx.price} maxPrice ${ctx.maxPrice} }; } return { ok: true, state: next }; }ctx 里带的是当次快照的价格和配置里的 maxPrice。价格守卫放在状态机里而不是下单函数里是为了让「不买」这件事也留下一条可查的记录事后复盘时能分清是没抢到还是主动放弃。4.2 判定逻辑四个维度的组合光看「有货 / 无货」两个词不够实际判定要组合四个维度库存文案、价格、是否预售、配送地区。库存文案为主价格用于过滤异常比如标价 1 元的占位商品预售标记用于降级预售到货通知意义不大可以只记日志不推送配送地区影响的是「有货但送不到」这一项如果没有数据源就先不做别硬编。判定函数的输出应该是三种之一NO_CHANGE、TRANSITION带目标状态、SUSPECT数据可疑。SUSPECT 的处理方式是保持原状态不变同时把这次快照标记出来连续三次 SUSPECT 就暂停这个 SKU 的调度并告警。这个「连续三次」的阈值是可配的我一般设 3因为单次解析失败太常见不值得打扰人。4.3 通知去重事件键与静默窗口去重靠两样东西事件键和静默窗口。事件键用 sku 状态 日期拼接配合数据库唯一索引同一天同一个 SKU 的同一种状态迁移只能产生一条事件。静默窗口是第二道闸即使状态在一天内反复横跳有货、无货、又有货也只发第一条之后半小时内的同类事件只入库不推送。// event.js —— 事件键与静默窗口 function eventKey(sku, state, date new Date()) { const day date.toISOString().slice(0, 10); return ${sku}:${state}:${day}; } function shouldNotify(lastEvent, key, silentMs 30 * 60 * 1000) { if (!lastEvent) return true; if (lastEvent.key ! key) return true; return Date.now() - lastEvent.at silentMs; }用 UTC 日期做键会有一个小坑跨零点时事件键切换同一晚的两次通知可能都被放行。要更严谨就把时区偏移算进去取本地日期的字符串。这类细节不影响主流程但在答辩被追问「幂等怎么保证」时能答上来就是加分项。4.4 通知渠道降级链而不是单点通知渠道建议配两条主通道走 Webhook 机器人备通道走 SMTP 邮件。发送逻辑做成降级链主通道失败重试两次仍失败就落到备通道两条都失败才写进失败队列等下一轮补发。这样做的原因很实际——单一通道的可用性不可能百分百而库存通知的时效窗口往往只有几分钟错过就是错过。消息体保持极简商品标题、当前价格、状态迁移方向、时间、直达链接。不要塞长文通知是给人扫一眼的字越多越容易被忽略。链接必须是能直接打开商品页的别让人再去搜一遍。5. 下单链路编排与常见问题排查到了下单这一步态度必须转变。前面几层追求自动化这一层恰恰要主动加人为关卡。原因不复杂下单是不可逆的副作用涉及真实支付任何自动化错误都会被放大。我采用的方案是半自动——系统只负责把订单草稿准备好提交动作由人点。5.1 半自动下单从事件到草稿的三步第一步事件触发后生成草稿记录状态 draft写入 sku、价格、数量固定 1。第二步把草稿推送到通知渠道附带一个本地控制台的确认入口。第三步人工确认后状态置为 confirmed此时才走提交流程。整个链路里qty 被硬限制为 1这是刻意的防止参数被误改成批量。// orderGuard.js —— 提交前把所有能挡的都挡掉 async function buildOrderDraft(sku, ctx) { // 库存文案必须是有货不能用可能有货这类模糊值放行 if (ctx.stockText ! 有货) throw new Error(库存文案非有货放弃); // 价格必须存在且不超过上限null 和 0 都不放行 if (!ctx.price || ctx.price ctx.maxPrice) throw new Error(价格超出上限); // 登录态失效是最高频的失败原因提前挡住 if (ctx.loginStatus ! valid) throw new Error(登录态失效需人工重新登录); // 没有人工确认标记一律不进入提交 if (!ctx.confirmedByHuman) throw new Error(未人工确认不进入提交); return { sku, price: ctx.price, qty: Math.min(ctx.qty ?? 1, 1), // 数量硬上限 1 createdAt: new Date().toISOString(), }; }四个守卫的顺序有讲究先判断库存再判断价格然后是登录态最后是人工确认。前两个是业务条件后两个是执行条件业务不满足就没必要浪费一次登录态校验。qty 用 Math.min 而不是直接赋值是为了在参数被外部污染时仍然能兜住。5.2 登录态管理加密存储与失效检测登录凭证只存本地用一个环境变量里的密钥做对称加密解密后只在内存中保留不落日志。失效检测的做法很简单每轮采集时顺带请求一个需要登录的轻量接口返回未登录就立即把 loginStatus 置为 invalid并推送一条「需要重新登录」的通知。这条通知要独立于商品通知不然用户会以为又是到货消息而忽略。不要试图用代码去自动处理验证码或人机校验。这是明确的边界遇到就停下转人工既是合规要求也是工程上最省事的做法。5.3 五条最容易翻车的排查清单下面这五条每一条我都在不同项目里真实遇到过按出现频率排序。一、定时器泄漏进程内存缓慢上涨。现象是跑三天后 RSS 从 80MB 涨到 600MB重启就恢复。原因多半是每次 tick 都新建 setInterval 或 setTimeout却没有清理或者 AbortController 的超时定时器没在 finally 里清。解决办法是把所有定时器句柄集中登记进程退出时统一 clear同时在封装层强制 finally 清理。加一个每天打印一次process.memoryUsage()的定时任务能提前发现趋势。二、页面改版后静默失败监控变成睁眼瞎。现象是连续几天没有任何事件你以为一直缺货其实解析早就返回 null 了。原因是解析函数对空结果过于宽容返回{price: null, stockText: undefined}而不抛错。解决办法是加 SUSPECT 状态与连续失败计数连续三次解析异常就暂停该 SKU 并推送告警。同时在日志里打出每次解析命中的是 json 还是 dom用于判断改版时机。三、请求频率过高触发限流429 之后重试把情况变得更糟。现象是某个时间点后所有请求全部 429越重试越糟。原因是没有区分「确定性失败」和「临时失败」把 429 当成普通错误立刻重试。解决办法是 429 与 5xx 走指数退避退避期间该 SKU 的下次触发时间同步推迟避免闸门放开后又立刻打满。低频是这套方案最重要的前提之一。四、SQLite 并发写入冲突。现象是偶发SQLITE_BUSY。原因是 better-sqlite3 虽然是同步 API但多个异步任务在同一事件循环里交替写入事务粒度没控制好。解决办法是把写入集中到一个串行队列或者开启 WAL 模式并设置 busy_timeout。批量写入时用事务包住别一条一条 commit。五、时间漂移导致错过窗口。现象是通知时间戳和实际到货时间差了好几分钟排查发现是机器时间同步没开。原因在于本地时钟漂移计时逻辑全部基于 Date.now()漂移会累积。解决办法是容器或主机开启 NTP 同步同时在启动时打印一次服务器时间与上游响应 Date 头的差值差超过阈值就告警。这个点有点玄学但真遇上会非常难查。5.4 日志与可观测性把黑匣子打开日志用结构化输出每条至少带四个字段sku、阶段fetch、parse、judge、act、结果、耗时。这样出问题时一条 grep 就能拉出某个 SKU 的完整链路。另外单独记一份「状态迁移流水」只写事件不管细节用于事后复盘「到底哪一刻到的货、通知什么时候发的、为什么没下单」。我习惯在项目根目录留一个logs/按天切割保留 14 天。日志里绝不能出现 cookie、token、手机号这类东西写之前统一过一遍脱敏函数。这个习惯养成之后把日志发给别人帮忙看也不会出问题。6. 验收与答辩用三个脚本证明监控可信到这一步代码跑起来了但「跑起来了」不等于「可信」。验收要解决的核心问题是你怎么证明它在页面改版、网络抖动、状态反复的情况下依然给出正确结论。第一个脚本做回放测试。把提前抓好的十份 HTML 样本有货、无货、预售、结构变异、半截 HTML放进 fixtures 目录逐个喂给解析函数断言输出符合预期。这个脚本跑通说明解析层至少不会对异常输入产生幻觉。第二个脚本做状态机穷举。遍历所有状态对检查非法迁移是否被拒绝、价格守卫是否生效、事件键是否唯一。用表驱动的方式写二十来行覆盖全部分支比手写十几个用例可靠得多。第三个脚本做端到端演练。把调度间隔临时改成 3 秒用一个本地 mock 服务模拟库存从无到有观察事件是否只产生一条、通知是否只发一次、草稿是否生成且 qty 为 1。这一步跑完答辩时的现场演示基本不会出意外。验收项通过标准对应脚本解析健壮性10 份样本全部正确或正确标记为可疑回放测试状态机完备无非法迁移被放行无合法迁移被误拒穷举测试通知幂等同一事件一天内只发一次端到端演练下单守卫缺任一条守卫条件即拒绝生成草稿端到端演练资源占用24 小时内存增幅小于 20%长跑观察答辩时被问得最多的三个问题我提前给出答法。问「为什么不用浏览器自动化」答复杂度与收益不匹配低频场景不需要渲染且浏览器方案在长时间运行下资源占用不可控。问「怎么防止被封」答控制频率、加抖动、尊重条件请求、遇到校验就停不追求极限速度。问「下单环节为什么不做全自动」答不可逆操作必须留人工确认工程上这叫安全边界不叫功能缺失。从那以后我每次做带定时任务的采集项目都会在第一天就把「回放测试脚本」写出来哪怕只有五个样本。它不花什么时间但能在后面每一次改解析逻辑时给你兜底。这套东西不大结构却完整从采集到判定再到动作每一层都能单独拿出来讲。希望帮到你。本文还有配套的精品资源点击获取