ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Node实现商品到货监控与自动下单闭环

Node实现商品到货监控与自动下单闭环

简介:jd-happy 是一份围绕京东商品到货监控与自动下单场景的 Node.js 爬虫示例项目,面向具备一定 JavaScript 基础、希望了解电商页面抓取与自动化下单流程的开发者。项目基于 Node.js 实现扫码登录、按地区查询商品库存、库存大于零时自动下单,并支持抢购商品与本地缓存登录状态,适合用作爬虫登录态管理、页面分析与定时监控的学习参考。需要说明的是,该项目已标记为 DEPRECATED,部分京东接口因平台更新而失效,更适合研究思路而非直接生产使用。压缩包共 11 个文件,约 1.7MB,以 5 个 js 源码文件为核心,辅以说明文档、依赖配置、许可文件与演示动图等辅助内容。已有 820 人学习或下载,读者可从中梳理从初始化浏览器、抓取与分析页面参数,到扫码登录、库存判断和下单触发的完整链路,理解各模块职责与常见排错点。

1. 需求先立住:到货监控与自动下单要解决的三个真问题

做 Node 爬虫监控商品到货、再顺手把下单动作也接上,最常见的起点其实很朴素:某个商品长期缺货,补货时间没有任何规律,人工刷新几十次也未必撞得上那一两分钟。真正难的从来不是"发出一个请求",而是三件事:怎么在长时间反复轮询中不被限流、怎么把页面或接口返回准确判断成"有货还是没货"、怎么在有货的几十秒窗口里把下单链路走完且不重复提交。

这套方案适合两类人:一类是要交毕设或课程设计,需要一个完整的"采集—判定—告警—执行"闭环来体现工程能力;另一类是从业者要搭一个内部用的库存监控服务,先跑通最小链路再谈扩展。后面按模块拆解,代码可以直接改成自己的目标站点。

2. 技术选型与总体架构:Node 事件循环下的四层拆分与三张表

2.1 为什么这类任务放在 Node 的单线程里反而合适

这类系统的负载特征非常明确:绝大部分时间在等网络 IO,CPU 几乎全程空闲,单机要同时挂几十个商品的轮询任务。Node 的事件循环天然适合这种"高并发、低计算"的场景,一个进程挂几百个定时任务,内存占用还在几十兆的量级,比给每个商品开一个线程划算得多。

真正需要注意的是两件事。第一,任何一段同步的重活都会卡住整个事件循环,比如用正则去啃几百 KB 的 HTML、或者把整个页面 JSON 做深拷贝,这类操作要么限制输入规模,要么扔到worker_threads里。第二,Node 的定时器不是精密时钟,setInterval在事件循环繁忙时会堆积回调,所以轮询调度我一般不用setInterval,而是用"上一轮结束之后再排下一轮"的递归setTimeout。

选型上,请求用axios(自带超时和拦截器,够用)或原生的undici(更轻、连接复用更好),解析用cheerio处理 HTML 片段,纯接口返回的 JSON 直接取字段。存储按数据量走:单机 Demo 用sqlite3一个文件就够,要多任务、多实例再换 MySQL。调度不引入消息队列,一个进程内的任务表加内存队列已经能覆盖到几百个 SKU 的量级,过早引入中间件只会让毕设答辩时多一个需要解释的组件。

2.2 四层结构:调度层、采集层、判定层、执行层

整个服务按职责切成四层,层与层之间只通过函数调用和数据表交互,任何一层出问题都能单独关掉降级。

调度层负责"什么时候去查哪个商品",维护一个任务表,按每个商品的间隔配置和随机抖动排下一次执行时间,同时控制全局并发。采集层只做一件事:拿一个 SKU 编号,发请求,把原始响应(HTML 或 JSON)原样返回,不做任何判断。判定层把原始响应压成一个结构化结果{ inStock, qty, price, digest },其中digest是归一化内容的摘要,用来区分"页面真的变了"和"只是时间戳变了"。执行层接收判定层的"由无到有"事件,负责走下单链路,并把每一步的结果写回order_task表。

这样切的好处很实际:调试解析规则时只跑判定层,喂历史响应快照就能验证;调下单链路时把采集层的结果用手工构造的事件灌进去,不用真等商品补货。

2.3 数据落库:三张表把"监控—告警—下单"串起来

表结构不用多,三张表能覆盖整条链路。monitor_item存监控对象和它的节奏参数,stock_event存每次判定的结果快照,order_task存下单任务的状态机。

-- 监控对象:一个 SKU 一行,节奏参数下放到行级,便于单商品调优 CREATE TABLE monitor_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_id VARCHAR(32) NOT NULL, title VARCHAR(128) NOT NULL DEFAULT '', interval_ms INT NOT NULL DEFAULT 15000, -- 该商品的轮询间隔 enabled TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_sku (sku_id) ); -- 判定快照:只追加不更新,用于回溯"当时到底看到了什么" CREATE TABLE stock_event ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_id VARCHAR(32) NOT NULL, in_stock TINYINT NOT NULL, qty INT NOT NULL DEFAULT 0, digest CHAR(32) NOT NULL, -- 归一化内容摘要,判断是否真变化 http_status SMALLINT NOT NULL, checked_at DATETIME NOT NULL, KEY idx_sku_time (sku_id, checked_at) ); -- 下单任务:idem_key 唯一索引是防重复提交的最后一道闸 CREATE TABLE order_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_id VARCHAR(32) NOT NULL, idem_key VARCHAR(64) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending/running/success/failed/cancel attempts INT NOT NULL DEFAULT 0, last_error VARCHAR(255) NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_idem (idem_key) );

stock_event只追加不更新这一点很关键。排查"为什么没触发下单"时,最有力的证据就是这张表:某天 14:03 判定层看到的是in_stock=0,还是根本没查到数据,一眼就能分辨是解析规则错了还是请求失败了。order_task的idem_key一般用skuId + 日期 + 序号拼,靠唯一索引把重复提交挡在数据库这一层,比在代码里判断可靠得多。

3. 采集层实现:请求闸门、库存解析与状态去重

3.1 请求客户端:超时、重试与并发闸门

采集层最容易被忽略的是"失控"。一个商品失败后立刻重试,几十个商品一起失败,瞬间就是几百个请求打出去,接下来的结果就是整段时间拿不到任何有效响应。我一般会在客户端外面套三个东西:全局并发闸门、按错误类型分级的重试策略、以及随机抖动。

// lib/gate.js —— 极简并发闸门,控制同时在飞的请求数 class Gate { constructor(limit = 2) { this.limit = limit; this.running = 0; this.queue = []; } // 任务入队,超过上限就排队,避免瞬时打满 run(task) { return new Promise((resolve, reject) => { const exec = () => { this.running++; task() .then(resolve, reject) .finally(() => { this.running--; const next = this.queue.shift(); if (next) next(); }); }; this.running < this.limit ? exec() : this.queue.push(exec); }); } } // lib/fetcher.js —— 带分类重试的请求封装 const axios = require('axios'); const gate = new Gate(2); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function fetchPage(url, { timeoutMs = 5000, retry = 2 } = {}) { return gate.run(async () => { for (let attempt = 0; attempt <= retry; attempt++) { try { const res = await axios.get(url, { timeout: timeoutMs, headers: { 'User-Agent': 'Mozilla/5.0 (compatible; StockWatcher/1.0)' }, validateStatus: () => true, // 自己判状态码,便于分类处理 }); // 429/403 属于被限流,重试只会加重,直接抛出让上层退避 if (res.status === 429 || res.status === 403) { const err = new Error(`rate_limited_${res.status}`); err.kind = 'throttle'; throw err; } if (res.status >= 500 && attempt < retry) { await sleep(800 * Math.pow(2, attempt) + Math.random() * 300); continue; } return { status: res.status, body: res.data }; } catch (e) { if (e.kind === 'throttle' || attempt === retry) throw e; await sleep(800 * Math.pow(2, attempt) + Math.random() * 300); } } }); } module.exports = { fetchPage, Gate };

这里几个参数值得说清楚。Gate(2)的 2 不是拍脑袋,它取决于目标站点对单 IP 的容忍度,先按 2 跑一天,看stock_event里http_status=429的比例,低于 1% 再往上加。timeoutMs给 5 秒是因为轮询间隔本身只有十几秒,超时设太长会把整轮任务拖住。重试只对超时和 5xx 生效,遇到 429 和 403 直接抛出,交给调度层把该商品的下次执行时间推后——这是血泪经验,早期版本对 429 也做重试,结果越重试越拿不到数据,整晚都是空的。

退避里的随机项(Math.random() * 300)看似无意义,实际是把多个商品的重试时间打散,避免它们在同一毫秒一起回来。

3.2 库存解析:把页面或接口返回压成一个布尔值

判定层的输入是原始响应,输出必须是结构化的、可断言的四个字段。这里最容易翻车的地方是"页面正常返回但内容是无货模板",所以解析函数不能只做includes('有货')这种判断,要锚定具体字段,并在解析失败时明确抛出而不是默认返回"有货"。

// lib/parser.js const crypto = require('crypto'); // 把响应归一化成一串稳定的文本,用于判断内容是否真变化 function digestOf(text) { return crypto.createHash('md5').update(text.replace(/\s+/g, '')).digest('hex'); } /** * @param {object} resp { status, body } 来自 fetchPage * @returns {{inStock:boolean, qty:number, price:number|null, digest:string}} * 解析失败一律抛错,绝不能"猜"成有货 */ function parseStock(resp) { if (resp.status !== 200) { const err = new Error(`http_${resp.status}`); err.kind = 'http'; throw err; } const body = typeof resp.body === 'string' ? resp.body : JSON.stringify(resp.body); // 优先走接口字段;页面渲染的场景再退回 DOM 选择器 let inStock = null; let qty = 0; let price = null; const jsonMatch = body.match(/\{"stock":\s*(\{.*?\})\s*\}/s); if (jsonMatch) { try { const s = JSON.parse(`{"stock":${jsonMatch[1]}}`).stock; inStock = Number(s.stockLevel) > 0; qty = Number(s.stockLevel) || 0; price = s.price != null ? Number(s.price) : null; } catch (_) { /* 落到下面的兜底 */ } } if (inStock === null) { // 兜底:从按钮文案判断,必须命中明确的两种状态之一 if (/立即购买|加入购物车/.test(body)) inStock = true; else if (/到货通知|暂时缺货|无货/.test(body)) inStock = false; else { const err = new Error('parse_unmatched'); // 页面结构变了 err.kind = 'parse'; throw err; } } return { inStock, qty, price, digest: digestOf(body) }; } module.exports = { parseStock };

parse_unmatched这个错误类型很重要。它和第 3.1 节的throttle、http分开统计,在日志里按kind分组看比例,如果某天parse突然从 0 涨到 30%,基本可以断定是页面结构改版了,这时候要做的不是加重试,而是去更新选择器。

3.3 去重与变更通知:只在"由无到有"时触发下游

有了每次的判定结果,还需要一个状态机来决定"这次要不要通知、要不要下单"。规则很直白:只在上一次 in_stock=0且本次 in_stock=1时触发;连续多次有货不重复触发;digest相同视为无变化,可以直接跳过后续处理以省掉无谓的计算。

// lib/detector.js async function detect(db, skuId, result, now = new Date()) { // 取该 SKU 最近一条快照 const last = await db.get( 'SELECT in_stock, digest FROM stock_event WHERE sku_id = ? ORDER BY id DESC LIMIT 1', [skuId] ); await db.run( 'INSERT INTO stock_event (sku_id, in_stock, qty, digest, http_status, checked_at) VALUES (?,?,?,?,?,?)', [skuId, result.inStock ? 1 : 0, result.qty, result.digest, 200, now] ); const prevInStock = last ? !!last.in_stock : false; const changed = !last || last.digest !== result.digest; // 唯一的触发条件:由无到有 if (result.inStock && !prevInStock) { return { trigger: true, reason: 'restock', changed }; } return { trigger: false, reason: result.inStock ? 'still_in_stock' : 'out_of_stock', changed }; } module.exports = { detect };

这里的边界是服务重启。进程重启后内存里的"上一次状态"没了,但数据库里还有,所以判断必须以库里的最后一条快照为准,不能放在内存变量里。我见过把prevInStock存成模块级变量的写法,重启一次就会把"本来有货"误判成"刚补货",直接触发一轮下单。

另外,通知和下单要解耦。触发事件先写order_task,再由执行层去消费,这样即使下单模块当时挂了,重启后任务还在,不会丢。

4. 下单执行层:登录态保持、四步链路与幂等兜底

4.1 登录态管理:会话池与失效心跳

下单链路和采集链路最大的区别是它带着身份。会话失效的表现往往不是 401,而是"提交订单后跳回登录页,返回 200",如果不做校验,程序会以为成功了。所以会话对象必须有明确的健康检查。

// session/store.js const fs = require('fs'); class SessionStore { constructor(file = './runtime/session.json') { this.file = file; this.current = this.load(); } load() { // 会话从外部注入的文件读取,不落库、不写日志 return JSON.parse(fs.readFileSync(this.file, 'utf8')); } // 心跳:请求一个只返回当前登录用户标识的轻量接口 async heartbeat(client) { const res = await client.get('/profile/basic', { timeout: 4000 }); const ok = res.status === 200 && /userId/.test(JSON.stringify(res.data)); if (!ok) { this.current.healthy = false; // 失效即暂停所有下单任务,并抛出信号让调度层停手 throw new Error('session_invalid'); } this.current.healthy = true; return true; } } module.exports = new SessionStore();

心跳要放在两处:服务启动时做一次,每次下单前再做一次。启动时做是为了避免带着废会话跑一整夜;下单前做是为了避免"查到有货—准备提交—会话到期"这三秒窗口里白跑一趟。心跳接口要挑最轻的那个,别用首页,首页通常几百 KB。

4.2 下单链路:预校验、加购、结算、提交四步

把整条链路拆成四步,每步的返回值都要落日志,出问题时能一眼看出卡在哪一步。四步之间不做重试,只做整体重试,因为中间步骤重试很容易造成购物车里多件商品,进而多付一笔。

// order/place.js async function placeOrder(ctx, skuId, idemKey, { qty = 1 } = {}) { const { client, db, store } = ctx; // 0) 幂等闸:靠唯一索引挡住重复任务,插入失败说明已经有人在做 try { await db.run('INSERT INTO order_task (sku_id, idem_key, status) VALUES (?,?,?)', [skuId, idemKey, 'running']); } catch (e) { return { ok: false, stage: 'idem', reason: 'duplicate_task' }; } try { // 1) 预校验:商品仍在售、可配送、限购数量足够 const pre = await client.get(`/item/check?sku=${skuId}`); if (!pre.data?.purchasable) throw new Error('not_purchasable'); // 2) 加购:先清空该 SKU 的旧条目,避免叠加 await client.post('/cart/remove', { skuId }); const add = await client.post('/cart/add', { skuId, qty }); if (add.data?.code !== 0) throw new Error(`cart_add_${add.data?.code}`); // 3) 结算:拿结算页的流水号,后续提交必须带上它 const settle = await client.post('/checkout/preview', { skuId, qty }); const token = settle.data?.token; if (!token) throw new Error('checkout_token_missing'); // 4) 提交:带上结算流水号,服务端据此判重 const submit = await client.post('/checkout/submit', { token }); if (submit.data?.orderId) { await db.run('UPDATE order_task SET status=?, updated_at=NOW() WHERE idem_key=?', ['success', idemKey]); return { ok: true, orderId: submit.data.orderId }; } throw new Error(`submit_rejected_${submit.data?.code}`); } catch (e) { const attemptsInc = 1; await db.run( 'UPDATE order_task SET status=?, attempts=attempts+?, last_error=?, updated_at=NOW() WHERE idem_key=?', ['failed', attemptsInc, String(e.message).slice(0, 250), idemKey] ); // 会话类错误要冒泡到调度层,触发暂停而不是重试 if (/session_invalid|not_login/.test(e.message)) throw e; return { ok: false, stage: 'pipeline', reason: e.message }; } } module.exports = { placeOrder };

参数上,qty不要从页面上"猜",用配置里的固定值,限购一件就写 1。第 2 步先删后加这个动作看着多余,实际很关键:如果上一次执行在加购之后崩了,购物车里已经有一条记录,直接再加会变成两件。第 3 步拿到的token是整条链路的钥匙,很多站点的提交接口靠它对同一笔结算做判重,所以它比任何本地幂等键都更可靠。

4.3 幂等与熔断:宁可漏一单,不可重三单

自动下单服务最不能接受的错误不是"没买到",而是"买了三份"。所以整套执行层的设计原则是单向的:任何不确定的情况一律按失败处理,不重试、不猜。

熔断分三层。第一层是任务级,同一个idem_key因为唯一索引只会成功插入一次。第二层是 SKU 级,内存里维护一个inFlight集合,同一 SKU 在下单过程中不再接受新的触发事件。第三层是账号级,会话失效、连续两次提交被拒、或者返回了"账户异常"这类提示,直接把全局开关置为暂停,需要人工确认后再打开。

// order/breaker.js class Breaker { constructor({ maxReject = 2 } = {}) { this.maxReject = maxReject; this.rejects = 0; this.paused = false; this.inFlight = new Set(); } // 返回 false 表示本次拒绝执行 acquire(skuId) { if (this.paused || this.inFlight.has(skuId)) return false; this.inFlight.add(skuId); return true; } release(skuId) { this.inFlight.delete(skuId); } onReject() { this.rejects++; // 连续被拒达到阈值就整体暂停,避免把账号打废 if (this.rejects >= this.maxReject) this.paused = true; } onSuccess() { this.rejects = 0; } } module.exports = new Breaker({ maxReject: 2 });

maxReject设成 2 是保守值。真实场景里,提交被拒要么是商品真没了,要么是账号状态有问题,两种情况继续重试都没有收益。

5. 避坑与排查:轮询任务最容易翻车的五个现场

5.1 现象:跑了两小时,请求全是空响应

原因:轮询间隔固定且偏短,多个商品在同一秒发出请求,触发了站点的频率限制,返回的是空壳页面或 200 的拦截页。

解决:间隔加抖动,实际执行时间用intervalMs + Math.random() * 3000;错误码按类型分开统计,429 直接退避到 60 秒后再试;stock_event里记录http_status,连续 5 次非 200 就自动把该商品的间隔翻倍,恢复正常后再降回来。

5.2 现象:明明显示无货,程序却触发了下单

原因:解析器用了宽松匹配,比如只判断页面里有没有"购买"字样,而推荐位、广告位里正好有这个词。或者页面结构改版后,原来的选择器匹配不到,兜底分支默认返回了有货。

解决:解析函数必须做到"匹配不到就抛错",绝不能有默认成功的分支。上线前拿二十份历史响应快照做回归,其中至少包括三份无货模板、一份验证页。每次改选择器都跑一遍这套快照,这一步看着笨,但能挡掉绝大部分误判。

5.3 现象:会话失效后程序还在反复尝试下单

原因:提交接口在会话失效时返回 200 并跳转到登录页,代码只看状态码,认为成功了。

解决:不能只看状态码,要校验响应体里有没有订单号;下单前加一次轻量心跳;一旦识别出会话失效,立刻把全局开关置为暂停,而不是继续跑。日志里把会话失效单独打一条,方便人工补会话后重启。

5.4 现象:同一个商品下了两单

原因:触发事件被重复消费,或者进程重启后把"本来有货"误判成"刚补货"。也有可能是购物车里有上次残留的条目,加购后变成两件。

解决:idem_key加唯一索引兜底;把inFlight集合做成 SKU 级互斥;加购前先执行一次删除;重启后从数据库读最后一条快照再判断,不用内存变量。这几条里唯一索引是最后的保险,前面几条都可能因为代码改动失效,索引不会。

5.5 现象:跑一天内存涨到几百兆

原因:setInterval里发异步请求,上一轮没结束下一轮已经开始,Promise 和响应对象层层堆积;或者日志里把整页 HTML 都打了出来。

解决:用"上一轮结束再排下一轮"的递归setTimeout,配合inFlight标记实现单飞;日志只打长度和摘要,不打原文;用process.memoryUsage()每十分钟记一条,涨到阈值就重启进程。这类任务重启成本极低,把重启当成正常的运维手段比追求"永不重启"务实得多。

6. 验收与答辩:用一条可复现的证据链证明链路真的跑通

验收这件事,很多同学栽在"我只演示了成功路径"。评审和面试官最想看的恰恰是异常路径,因为成功路径谁都能写出来。我一般会准备一张验收表,逐项跑,每项都留下日志或数据库记录作为证据。

验收项操作方式通过标准证据
冷启动清空数据库后启动首次判定就写入stock_event,不误触发下单表内首条记录时间
状态翻转手工改解析结果为有货只触发一次order_task,重复调用不再新增order_task行数
会话失效替换成过期会话文件心跳失败,全局暂停,日志有明确标识日志关键字session_invalid
接口限流把并发调到 20 跑十分钟429 出现后自动退避,不出现请求雪崩http_status分布
进程重启跑到一半 kill 再启动不重复触发,任务状态从库里续上order_task.status
长跑观察连续跑 24 小时内存曲线平稳,无未捕获异常内存采样日志

演示时的顺序也有讲究。先展示数据库里的stock_event快照,说明"判定层看到了什么";再展示order_task的状态迁移,说明"触发了几次、为什么只有一次";最后打开日志,把一次会话失效的完整处理过程放出来。这条证据链走完,基本不用再多解释,对方自己就能判断这套东西是真跑过的。

有个技巧值得单独说:准备一个"回放模式",把历史响应快照存成文件,用一个命令就能把判定层单独跑一遍,不碰网络。

# 回放历史快照,验证解析规则和触发逻辑,不发真实请求 node scripts/replay.js --dir ./fixtures/snapshots --sku 100000000001 --verbose # 输出示例: # [1/6] 2024-05-11T09:12:03 in_stock=0 qty=0 -> no_trigger (out_of_stock) # [2/6] 2024-05-11T09:12:18 in_stock=1 qty=3 -> TRIGGER (restock) # [3/6] 2024-05-11T09:12:33 in_stock=1 qty=3 -> no_trigger (still_in_stock)

答辩现场网络不一定通,回放模式能让整套逻辑在任何环境下演示,而且能精准复现"由无到有"那一瞬间。这个脚本我一般会在开发第一周就写出来,后面每次改解析规则都跑一遍。

最后说一条教训。早些年做这类监控,我习惯先写采集和解析,等一切顺了再补会话和幂等,结果第一次真跑就下了两单,退款流程走了一整周。从那以后我每次做这类带资金动作的项目,都强制先把幂等键、唯一索引、全局暂停开关这三样写完,再去写解析逻辑——顺序反过来,成本高得离谱。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表