资讯详情

资讯详情

Node爬虫库存监控:轮询、状态机与通知下单链路

简介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 的写法操作 HTMLwinston负责日志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)用好处是按下 CtrlC 时能把当前这轮跑完再退出而不是中途砍断留下半个状态文件。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把所有商品一次性打出去商品一多就是瞬间十几个请求同一秒发出特征非常明显。稳妥的做法是串行——一个商品一个商品地查中间加 25 秒间隔整体一轮的时间拉长到几分钟也完全可以接受库存窗口通常没那么窄。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 名换了二是返回的压根不是商品页而是一个验证页或跳转页内容长度往往只有几 KBcheerio解析出来自然什么选择器都匹配不上。第二种在轮询频率过高时非常常见。解决在解析前先打印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_MODEreplay跑几秒钟出结果不消耗任何请求配额也不会有被限速的风险。改造点涉及文件风险多任务配置化config/tasks.json、core/scheduler.js串行队列写错容易变成并发录制回放fetcher/index.js、data/fixtures/fixture 过期会掩盖真实改版通知多通道action/notify.js通道异常必须就地吞掉登录态心跳fetcher/http.js心跳本身也算一次请求计入限速改完之后的验证方式很朴素FETCH_MODEreplay npm run once看状态文件的边沿判断对不对再npm run dry打一次真实请求确认 cookie 和解析器都是通的最后才开--once真跑一轮。三步走完才允许npm start挂后台。从那以后我每次写这类轮询脚本都强制先把--once和--dry-run两个开关实现出来再动解析逻辑——否则你会用「等 45 秒看一次日志」的方式调试一晚上能试的次数两只手数得过来。这个习惯省下来的时间比优化任何一行代码都多。源码就在包里先跑一次npm run once看真实输出再回头改配置比通读一遍快得多。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →