简介:jd-happy 是一套基于 Node.js 编写的京东商品库存监控与自动下单脚本,面向已具备 JavaScript 基础、希望了解爬虫与自动化流程的开发者,用来应对热门商品到货提醒不及时、代下单服务受限等场景。程序通过扫码登录获取会话,按地区轮询商品库存,检测到有货即触发下单,并支持抢购与本地缓存登录状态,运行时会输出初始化浏览器、抓取页面、分析页面参数、请求扫码等阶段性日志。压缩包共 11 个文件、约 1.7MB,主体是 5 个 js 源码文件,分别承载入口逻辑、命令行参数解析、日志与工具函数;另有 package.json 与 yarn.lock 管理依赖,README 说明使用方式,gif 演示实际运行效果,并附 LICENSE 与 .gitignore。目前已有 820 人学习下载。需要留意的是,作者已标注项目废弃,京东接口更新后部分功能失效,因此它更适合作为研究登录鉴权、页面抓取、参数提取与定时轮询的参考实现,读者可从中了解浏览器自动化在电商场景中的模块拆分思路,以及接口变更后如何定位与排查问题。
1. 库存从 0 跳到 1 的那几十秒,是这类需求全部的战场
happy-watch 就是为这几十秒写的:一个 Node 爬虫,按固定节奏轮询某电商平台的商品详情页和库存接口,一旦「无货」翻成「有货」,立刻推一条通知,配置允许时再走一遍下单链路。商品到货监控加上自动下单,听起来是两个功能,落到代码里其实是四条流水线:抓取、解析、状态判断、动作执行。它适合两类人——想完整走一遍 Node 爬虫链路(HTTP 客户端、选择器、定时调度、状态持久化、告警)的后端新手,以及手上有一堆「等补货」场景、想改成自己版本的老手。项目本身早就被作者挂了废弃标记,平台接口换过几轮,直接 clone 下来跑必然出问题;但它的骨架出奇地干净,拿来当「定时任务 + 抓取 + 解析 + 通知」的练习模板,比读十篇爬虫教程都管用。
2. 项目骨架与技术选型:一个 Node 爬虫该有的五个模块
先说结论:这个项目没有用 Puppeteer,也没有上 Redis 队列,全部依赖都是「一个 HTTP 客户端 + 一个 HTML 解析器 + 一个日志库」。作者的选择很务实——监控是长跑型任务,常驻内存越小越好,浏览器内核那几百兆开销对这种 45 秒一次的轮询来说完全是浪费。想读懂它,先把目录结构在脑子里建立起来。
2.1 目录结构:五层分工,一层只干一件事
happy-watch/ ├── config/ │ ├── default.json # 轮询间隔、抖动、通知开关、下单开关 │ └── cookie.txt # 登录态,从浏览器手工导出 ├── src/ │ ├── index.js # 入口:装配依赖、读配置、启动调度 │ ├── fetcher/ │ │ ├── http.js # axios 实例:UA、Referer、Cookie、超时、重试 │ │ ├── page.js # 抓商品详情页 HTML │ │ └── stock.js # 抓库存/价格接口 │ ├── parser/ │ │ ├── html.js # cheerio 选择器,从 DOM 里抠字段 │ │ └── json.js # 接口字段归一化,抹平不同返回结构 │ ├── core/ │ │ ├── state.js # 库存状态机 + 原子落盘 │ │ └── scheduler.js # 轮询循环、耗时补偿、随机抖动 │ ├── action/ │ │ ├── notify.js # webhook / 邮件,失败不阻塞主流程 │ │ └── order.js # 下单请求组装,支持 dry-run │ └── utils/ │ ├── logger.js # winston,按大小滚动 │ └── sleep.js # Promise 版 setTimeout ├── data/ │ ├── state.json # 上一次的库存状态,重启不丢 │ └── app.log └── package.json这个分层最值得学的地方是fetcher和parser的切分。很多新手写的爬虫是一把梭:axios.get拿到响应,当场.data.list[0].stock取字段,然后在同一个函数里判断、发通知、写文件。这种写法在页面改版的那一天会同时崩掉四个环节,你连从哪儿查都不知道。happy-watch 把「拿数据」和「解释数据」分开以后,改版只需要动parser/,抓取层和调度层完全不用碰——这个边界感,是判断一个爬虫项目能不能长期维护的第一标准。
另外注意parser下有两个文件。html.js处理详情页,json.js处理库存接口。为什么不统一?因为详情页上能拿到标题、促销文案、区域提示这些「人看的」信息,接口里能拿到精确的库存数字和价格,两边各有各的用处,一个挂了另一个还能兜着。这是很典型的双通道冗余设计。
2.2 依赖与运行环境:依赖越少,长跑越稳
老项目的package.json一般长这样,我按原结构还原了一份,依赖数量控制在五个:
{ "name": "happy-watch", "version": "1.0.0", "private": true, "engines": { "node": ">=14.18" }, "scripts": { "start": "node src/index.js", "once": "node src/index.js --once", "dry": "node src/index.js --dry-run" }, "dependencies": { "axios": "^0.27.2", "cheerio": "^1.0.0-rc.12", "dayjs": "^1.11.5", "nodemailer": "^6.7.8", "winston": "^3.8.1" } }engines卡在 14.18 是有原因的:从这条线开始??和?.才稳定可用,而解析层写字段兜底时这两个语法能省掉一半的if。axios负责请求,cheerio负责在 Node 里用 jQuery 的写法操作 HTML,winston负责日志,nodemailer只在开邮件通道时才被 require——注意是懒加载,别在文件顶部无脑 import,启动时那点 SMTP 握手在容器里偶尔会卡住入口。
重点看scripts里的两个开关。npm run once表示「只跑一轮就退出」,npm run dry表示「走完整个链路但不真的提交订单」。这两个是长跑脚本的后悔药,后面排查章节会反复用到。我一般写这类项目,先写这两个开关,再写业务逻辑,顺序反过来会浪费很多时间。
2.3 配置与登录态:cookie 文件是整条链路的地基
配置全部集中在一个 JSON 里,改参数不用碰代码:
{ "interval": 45000, "jitter": 8000, "timeout": 8000, "retry": 2, "items": [ { "id": "10086", "name": "示例商品A", "area": "1_72_2799_0" } ], "notify": { "webhook": "", "mail": { "enable": false, "to": "" } }, "order": { "enable": false, "dryRun": true, "maxQty": 1, "addrId": "" } }逐项说一下这几个参数在真实场景里怎么调。interval是两轮之间的基准间隔,单位毫秒,45000 大约是 45 秒,太短会被风控盯上,太长会错过库存窗口,这个值是经验值不是标准答案。jitter是随机抖动上限,实际等待时间是interval + Math.random() * jitter,目的是让请求时间点不呈现严格的等间距特征。timeout必须设,axios默认是不超时的,一个卡住的 TCP 连接能让整个循环停在那儿。retry是单次请求的重试次数,只对网络错误和 5xx 生效。
items[].area是地区编码,这个字段很关键也很容易被忽略。同一个商品在不同地区的库存是独立的,你看到「有货」但下单时提示「该地区无货」,八成就是这里和addrId对不上。order.dryRun默认true,这是个刻意的安全设计——就算你误开了enable,也不会真的产生订单。手上没有登录态时,公开页面照样能抓,但下单那一步一定失败,所以cookie.txt建议单独放、单独管,不要塞进版本库。
3. 核心链路拆解:一次「查库存 → 通知 → 下单」的完整代码路径
模块划分清楚了,接下来顺着一次 tick 把代码走一遍。整条链路是scheduler触发 →fetcher取数 →parser归一化 →state判断是否翻转 →notify/order执行。每一段我都贴了能直接抄的写法。
3.1 抓取层:一个 axios 实例管住所有请求
抓取层的核心不是「怎么发请求」,而是「怎么让所有请求长得一样」。UA、Referer、Cookie、超时这四样必须统一,否则会出现「详情页能抓、接口 403」这种奇怪现象。
// src/fetcher/http.js const axios = require('axios'); const fs = require('fs'); const path = require('path'); const cfg = require('../../config/default.json'); const cookiePath = path.join(__dirname, '../../config/cookie.txt'); const readCookie = () => { try { return fs.readFileSync(cookiePath, 'utf8').trim(); } catch (e) { return ''; } // 没有 cookie 也能抓公开页,但下单必然失败 }; const client = axios.create({ timeout: cfg.timeout, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36', 'Referer': 'https://example-mall.com/', 'Accept': 'application/json, text/plain, */*', 'Cookie': readCookie() } }); // 只对网络错误和 5xx 重试,4xx 直接抛,避免对着风控页反复请求 client.interceptors.response.use( res => res, async err => { const conf = err.config || {}; conf.__retry = (conf.__retry || 0) + 1; const status = err.response && err.response.status; const retryable = !status || status >= 500; if (retryable && conf.__retry <= cfg.retry) { await new Promise(r => setTimeout(r, 500 * conf.__retry)); // 线性退避 return client(conf); } return Promise.reject(err); } ); module.exports = client;几个参数值得展开。timeout走配置而不是写死,因为不同网络环境下合适的值差别很大,家里宽带 8 秒够用,云主机上经常要放到 15 秒。conf.__retry挂在请求配置上,axios 会把config原样传回来,这是做单请求级别重试计数的标准位置。retryable的判断逻辑是「没有 status(说明是连接层错误)或者 status >= 500」,把 4xx 排除在外——这一点很多人写错,对着一个返回 403 的拦截页重试三次,除了让自己更快进黑名单没有任何意义。
重试等待用的是线性退避500 * n,不是指数退避。原因很简单:单次 tick 的容忍时间有限,指数退避到十几秒,这一轮早就该结束了,会直接把循环节奏拖乱。
真正取数就三行:
// src/fetcher/stock.js const client = require('./http'); async function fetchStock(skuId, area) { const { data } = await client.get('/stock/sku', { params: { skuId, area, _: Date.now() // 时间戳参数,打掉中间层缓存,拿到实时库存 } }); return data; // 原样返回,字段解释交给 parser } module.exports = { fetchStock };_这个时间戳参数是老爬虫的常规操作。有些接口前面挂着 CDN 或者网关缓存,不加会拿到几分钟前的旧数据,而你盯的恰恰是「刚刚补货」的那一瞬间。注意这里return data是原样返回,不做任何字段处理——这个克制很重要,接口哪天多包一层result,只需要改parser/json.js一行,fetcher完全不动。
3.2 解析层:双通道取数,字段全部归一化
parser/html.js负责从详情页抠信息,用的就是 cheerio 那套选择器:
// src/parser/html.js const cheerio = require('cheerio'); function parsePage(html) { const $ = cheerio.load(html); const title = $('.sku-name').text().trim(); // 库存提示文案五花八门:有货 / 无货 / 采购中 / 仅剩2件 const stockText = $('.store-prompt').text().trim(); const price = $('.p-price span').first().text().trim(); const hasStock = /有货|现货/.test(stockText) && !/无货/.test(stockText); return { title, stockText, price, hasStock }; } module.exports = { parsePage };选择器.sku-name.store-prompt这类 class 名是页面结构层面的,平台一改版就失效,这没什么好办法,只能把「文案判断」写得宽容一点。上面那句hasStock同时做了正向包含和负向排除,就是因为曾经出现过「无货」文案里带「有货」两个字的坑(比如「暂时无货,可关注到货通知」,关注两个字在某些版本里写的是「有货通知」)。这种正则兜底不是为了优雅,是为了少一次误报。
接口那条路走归一化:
// src/parser/json.js function normalize(raw) { // 不同接口返回结构差异很大,这里做一层「哪个字段有就用哪个」的兼容 const s = (raw && (raw.stock || raw.data || raw)) || {}; const qty = Number(s.stockLevel ?? s.StockState ?? s.remain ?? 0); const price = Number(s.price ?? s.p ?? 0); return { qty: Number.isFinite(qty) ? qty : 0, // NaN 一律当 0,宁可漏报不误报 price: Number.isFinite(price) ? price : 0, areaDesc: s.areaName || s.area || '', hasStock: Number.isFinite(qty) && qty > 0 }; } module.exports = { normalize };??在这里是刚需。如果用||,字段值是0的时候会被当成空值继续往下找,最后可能取到一个错误的备用字段——库存 0 被读成「有货」,这在监控脚本里是灾难级的 bug。Number.isFinite的兜底同样是血泪经验,接口字段偶尔返回字符串或者空对象,Number(undefined)是NaN,而NaN > 0是false,看起来没事,可一旦进入后续的价格计算或者日志拼接,就会污染整条记录。
两条通道的数据最后要合流成一个统一的结构:{ id, title, qty, price, area, hasStock, ts }。后面所有逻辑只认这个结构,谁来源的都不关心。
3.3 状态机与去重:只在翻转的那一次触发动作
这是整个项目最容易被写错的地方。绝大多数新手脚本会写成「只要有货就发通知」,结果就是每 45 秒推一条消息,两小时后你自己把它关掉。
正确的做法是把状态持久化,只在false → true的边沿触发:
// src/core/state.js const fs = require('fs'); const path = require('path'); const statePath = path.join(__dirname, '../../data/state.json'); const load = () => { try { return JSON.parse(fs.readFileSync(statePath, 'utf8')); } catch (e) { return {}; } // 文件不存在或内容残缺,一律当空状态 }; // 返回 true 只在「上次无货、这次有货」的那一次 function shouldFire(key, hasStock, state) { const prev = state[key] || { hasStock: false, updatedAt: 0 }; const fired = !prev.hasStock && hasStock; state[key] = { hasStock, updatedAt: Date.now() }; return fired; } function save(state) { const tmp = statePath + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(state, null, 2)); fs.renameSync(tmp, statePath); // 原子写,避免进程被杀时留下半个 JSON } module.exports = { load, shouldFire, save };shouldFire三行代码,但把「状态机」说清楚了:任何时刻一个商品只有两个状态,hasStock: false和hasStock: true,动作只发生在两者之间的转移上。如果只看结果不看转移,重复通知就不可避免。
save里用writeFileSync写临时文件再renameSync,这是原子写的标准套路。rename在同一个文件系统内是原子的,不会出现「文件被写了一半」的中间态。早期我图省事直接writeFileSync(statePath, ...),进程被kill -9的时候留下过半个 JSON,第二天启动JSON.parse直接抛异常,整个监控静默了一整天——这种无声失败比崩溃更可怕。
还有一个细节:状态文件按id + area复合键存。同一个商品在不同地区库存不一样,只用id做键会互相覆盖,表现就是「明明北京有货,上海这边不发通知」。
3.4 下单层:把浏览器里的请求搬进 Node 的三个前提
先把话说在前面:下单这步只适合自己账号、自己地址、低频率的场景,任何情况下都要遵守平台的服务条款,别拿它当抢购脚本批量跑。
浏览器里点一次「提交订单」,背后至少有四样东西在参与:登录态 Cookie、收货地址 ID、数量、以及一个一次性的提交票据。搬到 Node 里,缺一样就是失败。
// src/action/order.js const client = require('../fetcher/http'); const cfg = require('../../config/default.json'); async function placeOrder({ skuId, qty = 1, addrId, token }) { if (cfg.order.dryRun) { // dry-run 只走到参数校验这一步,绝不发真实提交请求 return { ok: true, dryRun: true, skuId, qty, addrId }; } const { data } = await client.post( '/order/submit', new URLSearchParams({ skuId, num: Math.min(qty, cfg.order.maxQty), // 数量封顶,防止把配置写错变成批量下单 addrId, token, // 下单页里的隐藏字段,一次性 remark: '' }).toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } ); return { ok: !!data && data.success === true, raw: data }; } module.exports = { placeOrder };参数说明几句。token是下单页 HTML 里那个隐藏 input 的值,一次性,用过就废,所以它必须在每次进入下单流程前重新抓一次页面拿——这也是为什么fetcher/page.js除了详情页还得负责下单页。Math.min(qty, cfg.order.maxQty)这个封顶是必须的,配置里maxQty默认 1,就是防止哪天手抖把qty写成 10,一觉醒来多了十单。Content-Type显式指定x-www-form-urlencoded,别指望服务端什么格式都收。
返回值的判断也留了余地:data.success === true严格比真,因为这类接口有时返回"true"字符串,有时返回{code: 0},不同版本不一样。判断条件写得越松,假成功越多——「下单成功」的日志刷屏,订单列表里空空如也,这是最消耗信任的一类 bug。
4. 调度与通知:把轮询、限速、告警拼成能长期跑的守夜人
一次 tick 跑通了只是开始,能不能连续跑两周不出事,全看这一层。说句实话,这类项目 90% 的翻车不在业务代码,在调度和日志。
4.1 调度:为什么不能用 setInterval
// src/core/scheduler.js const { sleep } = require('../utils/sleep'); const logger = require('../utils/logger'); async function runLoop(task, cfg, signal) { while (!signal.stopped) { const started = Date.now(); try { await task(); } catch (e) { // 单次失败不退出循环,这是长跑脚本和一次性脚本最大的区别 logger.warn('tick failed: %s', e.message); } const cost = Date.now() - started; // 扣掉本轮耗时再等待,保证「起点到起点」的间隔稳定 const wait = Math.max(cfg.interval - cost, 1000) + Math.random() * cfg.jitter; await sleep(wait); } } module.exports = { runLoop };setInterval的问题是它会吞掉执行时间。你设 45 秒,任务本身跑了 20 秒,实际请求间隔就变成了 45 秒固定节拍、但响应时刻漂移,更要命的是任务一旦超过间隔,下一轮会在上一轮还没结束时启动,请求开始堆积。while + await sleep的写法里,每一轮严格串行,永远不会出现并发叠罗汉。
Math.max(cfg.interval - cost, 1000)里的1000是下限保护。如果某一轮因为重试跑了 44 秒,interval - cost只剩 1 秒,仍然允许立刻发下一轮,但绝不允许负数——负等待等于连发。Math.random() * cfg.jitter加在最后,是让请求时刻不呈现规律的等间距。
signal.stopped是一个外部可写的标志位,配合process.on('SIGINT')用,好处是按下 Ctrl+C 时能把当前这轮跑完再退出,而不是中途砍断留下半个状态文件。
4.2 限速与退避:被拦的时候要「越拦越慢」
轮询型爬虫最怕的不是慢,是规律。除了抖动,还得准备一层失败退避:
// src/utils/backoff.js function nextDelay(base, attempt, cap = 10 * 60 * 1000) { const exp = Math.min(base * Math.pow(2, attempt), cap); // ±20% 抖动,避免多个任务在同一时刻同时恢复 return Math.round(exp * (0.8 + Math.random() * 0.4)); } module.exports = { nextDelay };思路是:正常轮询用配置里的interval,一旦连续失败(超时、403、429、返回的不是预期 JSON),就把间隔按指数拉长,最长不超过 10 分钟。cap上限是必须的,否则指数会一路涨到几天后,等于把自己关了。
多商品场景下还有个隐性问题:并发。很多人图快用Promise.all把所有商品一次性打出去,商品一多就是瞬间十几个请求同一秒发出,特征非常明显。稳妥的做法是串行——一个商品一个商品地查,中间加 2~5 秒间隔,整体一轮的时间拉长到几分钟也完全可以接受,库存窗口通常没那么窄。
4.3 通知通道:告警本身不能成为单点
通知代码最容易犯的错是「通知挂了把监控也带崩」。webhook 超时、SMTP 连不上,这些都是外部依赖,必须彻底隔离:
// src/action/notify.js const client = require('../fetcher/http'); const logger = require('../utils/logger'); const cfg = require('../../config/default.json'); async function notify(text) { const jobs = []; if (cfg.notify.webhook) { jobs.push( client.post(cfg.notify.webhook, { msgtype: 'text', text: { content: text } }) .then(() => logger.info('notify ok')) .catch(e => logger.error('webhook failed: %s', e.message)) // 吞掉异常,不往上抛 ); } // 邮件通道同理,transporter 在这里懒加载 // if (cfg.notify.mail.enable) { ... } if (!jobs.length) logger.warn('未配置任何通知通道,本次告警丢弃'); await Promise.all(jobs); } module.exports = { notify };关键在.catch的位置。如果让异常冒泡回主循环,runLoop里的 catch 会把它当成「本次 tick 失败」,于是这一轮抓到的库存状态不会被保存——下一轮又会重新触发一次通知,形成告警风暴。所以通知通道的异常必须在notify内部就地消化。
另外那个未配置任何通知通道的 warn 很有用。我就踩过这个坑:配置文件的notify.webhook手滑写成了空字符串,脚本安安静静跑了三天,state.json里的状态确实在变,就是一条消息没发出去。
4.4 进程守护与日志:出事后你得能查
// src/utils/logger.js const winston = require('winston'); module.exports = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', format: winston.format.combine( winston.format.timestamp(), winston.format.printf(({ timestamp, level, message }) => `${timestamp} ${level.toUpperCase()} ${message}`) ), transports: [ new winston.transports.File({ filename: 'data/app.log', maxsize: 5 * 1024 * 1024, // 单文件 5MB maxFiles: 3 // 只留 3 个,超了自动删最旧的 }) ] });maxsize和maxFiles这两个参数别省。轮询脚本一天能写几十万行日志,不设上限的话,两三个月后磁盘满了,进程写日志失败直接崩,而崩之前通常还不会有任何告警。
进程守护层面,容器里用编排平台的重启策略,裸机上用systemd或者 pm2 都行,核心只有两条:退出后自动拉起,以及把 stdout 也接进日志体系。别让脚本以「前台运行的窗口是我本地终端」的方式活在生产环境里。
5. 常见问题排查:这项目跑不起来多半栽在这五处
下面这五条基本覆盖了从「clone 下来跑不动」到「跑两天就不干活」的全部区间。每条我都按现象、原因、解决的顺序写,对着查就行。
5.1 第一次跑能收到通知,之后永远显示「无货」
现象:npm start起来,第一轮果然弹出「有货」的通知,心情大好。然后不管怎么等,再也没有第二条,日志里却每轮都在正常输出。
原因:data/state.json里那个商品的状态已经是hasStock: true了。shouldFire只在false → true的边沿触发,现在状态一直是true,条件永不满足。这不是 bug,是设计。但如果页面其实已经变成无货,而状态没跟着更新,那就是parser返回的hasStock一直为true——大概率是详情页的提示文案匹配错了。
解决:先cat data/state.json看真实状态,再npm run once跑一轮,把normalize的输出打到日志里对比。确认是解析问题就改正则;确认只是边沿逻辑,想看效果就手工把hasStock改回false再跑。
5.2 本地一切正常,服务器上全是「登录超时」
现象:同一个 cookie 文件,本机跑得好好的,扔到云主机上第一轮就返回未登录,或者跳一个验证页。
原因:登录态不是一把万能钥匙。它通常和 UA、出口 IP、签发时间绑定,三样里换任何一样都可能失效。本地浏览器的 UA 和你 Node 里硬编码的 UA 很可能压根不是同一个,cookie.txt又是从本地浏览器导出的,对不上就必然被识别成异常。
解决:把http.js里的User-Agent改成和你导出 cookie 的那个浏览器完全一致,包括版本号那一串。IP 变了就得重新导出一次 cookie。长期跑的话,可以在每轮 tick 里附带一个轻量的「登录态心跳」请求,一旦发现失效立刻告警,而不是等到要下单的那一刻才发现。
5.3 解析结果全是空字符串,但请求返回 200
现象:parsePage返回{ title: '', stockText: '', price: '' },状态码却是 200,日志里没有任何报错。
原因:两种可能。一是页面改版,class 名换了;二是返回的压根不是商品页,而是一个验证页或跳转页,内容长度往往只有几 KB,cheerio解析出来自然什么选择器都匹配不上。第二种在轮询频率过高时非常常见。
解决:在解析前先打印html.length和html.slice(0, 300),一眼就能看出被拦了还是改版了。被拦就往限速和退避那章反推,把interval调大、加长退避;改版就去改选择器,把新老 class 都写进选择器列表,用逗号分隔做兼容。
5.4 日志显示下单成功,但订单列表里没有
现象:placeOrder返回ok: true,日志打印了一行成功,去账号里翻订单,空的。
原因:三个高发点。token是一次性的,你在同一轮里重试了两次,第二次用的是已经作废的票据;addrId和area不匹配,服务端静默失败但返回体看着像成功;或者你的成功判断条件太松,data.success === true写成了data直接判真,{code: 500}这种对象也是真值。
解决:把ok的判断收紧到具体字段,把服务端返回的原始响应体整个写进日志,别只记一个布尔值。然后拿这个响应去和浏览器开发者工具里真实下单的响应逐字段对比,差异往往一眼就看出来。排查期间务必让order.dryRun保持true。
5.5 连续跑两三天后进程消失,或者内存缓慢上涨
现象:前几天一切正常,第三天发现进程没了,或者ps看到 RSS 一路从 80MB 涨到 600MB。
原因:常见的是日志文件没有上限、state.json里累积了几万个废弃键、或者某个Promise没有被 await 导致异常脱离主循环。还有一种更隐蔽的:axios没设 timeout,个别连接永远挂着,socket一直不释放。
解决:检查logger的maxsize/maxFiles是否生效;state.json定期清理超过 7 天没更新的键;所有请求必须带timeout;再给进程加个「每轮打印一次process.memoryUsage().rss」的探针,一旦看到单调上涨,基本就能定位到具体环节。另外服务器时区如果是 UTC,而你用本地时间算某些签名或校验字段,也会出现「看着成功了其实被拒」的情况,顺手date一下确认时区。
6. 二次开发:从写死一个商品,到可配置的多任务监控
这个项目最值得动手改的地方,是它把商品 ID 硬编码在了配置数组里,一轮只查一个,业务一变就得改代码重启。我一般按下面两步改造。
第一步,把任务配置外置成config/tasks.json,每个任务带上自己的间隔、通知通道和 dry-run 开关:
{ "concurrency": 1, "gap": 3000, "tasks": [ { "id": "10086", "area": "1_72_2799_0", "interval": 45000, "notify": "default" }, { "id": "10087", "area": "1_72_2799_0", "interval": 90000, "notify": "default", "order": { "dryRun": true } } ] }concurrency保持 1、gap给 3 秒,就是前面说的串行策略。每个任务独立维护自己的nextRunAt,调度器每 500 毫秒扫一遍,把到点的任务丢进队列执行。这样冷门商品可以放到 90 秒一轮,热点商品保持 45 秒,整体请求量反而下降。
第二步,把抓取层抽成可替换的接口,加上录制回放。这一步做不做,决定了你后面每次改解析器要花多久验证:
// src/fetcher/index.js const mode = process.env.FETCH_MODE || 'live'; // live | replay async function getStock(skuId, area) { if (mode === 'replay') { // 回放模式:从 data/fixtures/ 读历史响应,不发任何真实请求 return require(`../../data/fixtures/${skuId}.json`); } return require('./stock').fetchStock(skuId, area); } module.exports = { getStock };配合一个--record开关,第一轮把真实响应落盘到data/fixtures/,之后改parser就用FETCH_MODE=replay跑,几秒钟出结果,不消耗任何请求配额,也不会有被限速的风险。
| 改造点 | 涉及文件 | 风险 |
|---|---|---|
| 多任务配置化 | config/tasks.json、core/scheduler.js | 串行队列写错容易变成并发 |
| 录制回放 | fetcher/index.js、data/fixtures/ | fixture 过期会掩盖真实改版 |
| 通知多通道 | action/notify.js | 通道异常必须就地吞掉 |
| 登录态心跳 | fetcher/http.js | 心跳本身也算一次请求,计入限速 |
改完之后的验证方式很朴素:FETCH_MODE=replay npm run once,看状态文件的边沿判断对不对;再npm run dry打一次真实请求,确认 cookie 和解析器都是通的;最后才开--once真跑一轮。三步走完,才允许npm start挂后台。
从那以后我每次写这类轮询脚本,都强制先把--once和--dry-run两个开关实现出来,再动解析逻辑——否则你会用「等 45 秒看一次日志」的方式调试,一晚上能试的次数两只手数得过来。这个习惯省下来的时间,比优化任何一行代码都多。源码就在包里,先跑一次npm run once看真实输出,再回头改配置,比通读一遍快得多。希望帮到你。
本文还有配套的精品资源,点击获取