
1. 从抢不到说起一个库存监控工具的诞生背景iPhone 新机首发那几天官网的购买按钮基本就是摆设。我蹲了三个晚上每次都是预计送达时间从 2 周跳到 4 周再刷新直接变成暂无供应。手动刷页面这件事效率低到令人发指——你不可能 24 小时盯着浏览器但库存补货往往就发生在凌晨两三点。这个需求其实非常朴素定时轮询苹果官网的库存接口一旦目标机型有货就立刻通知我。听起来简单但真动手做会踩到一堆坑接口返回的数据结构怎么解析、请求频率怎么控制才不会被限流、通知渠道怎么选才能第一时间触达、程序怎么长期稳定运行而不崩。我用 Codex 辅助写了这套工具核心思路是Node.js 做后端轮询 Chrome 扩展做前端辅助监控 系统级通知兜底。整套东西不复杂但每个环节都有讲究。下面我把设计思路、核心实现、踩坑记录完整拆一遍适合有一定 JavaScript 基础、想自己动手做监控工具的朋友参考。哪怕你只是想监控某个商品的补货情况这套框架也能直接改改用。先说清楚这个工具能做什么它定时请求苹果官网的库存查询接口对比目标机型的供货状态状态从无货变为有货时通过多种渠道推送提醒。它解决的是人工盯梢效率低、补货瞬间抓不住的问题。适合的人群包括想抢首发但没时间盯屏的普通用户、想学习接口轮询与通知系统设计的开发者、以及需要做类似库存监控场景比如限量球鞋、演唱会门票的技术爱好者。2. 整体架构设计与技术选型考量2.1 为什么选 Node.js 而不是 Python做定时轮询和 HTTP 请求Python 其实更顺手requests 库几行就能搞定。但我最终选了 Node.js原因有几个。第一通知渠道的生态。我想同时支持系统通知、Telegram Bot、以及一个本地的 Chrome 扩展弹窗Node.js 在这些渠道的 SDK 和库都比较成熟尤其是和 Chrome 扩展的通信用 Node.js 起一个本地 WebSocket 服务扩展端直接连非常顺。第二异步轮询的天然优势。库存监控本质是大量并发的 HTTP 请求Node.js 的事件循环模型处理这种 IO 密集型任务很舒服不需要像 Python 那样纠结 asyncio 还是多线程。第三部署简单。一台便宜的云服务器装个 Node.js 20 LTSnpm install之后node index.js就能跑配合 pm2 做进程守护基本不用管。提示Node.js 版本建议用 20 LTS 或更高。低版本在 fetch API 和部分依赖上有兼容问题我一开始用 16 版本undici相关的报错折腾了半天换到 20 之后一切正常。2.2 三层架构的职责划分整套工具分成三层各司其职层级技术栈核心职责运行位置轮询层Node.js node-cron定时请求库存接口、解析数据、判断状态变化服务器/本地通知层Node.js 多通道 SDK状态变化时触发推送服务器/本地展示层Chrome 扩展本地可视化状态、手动触发检查浏览器轮询层是核心它决定了监控的准确性和及时性。通知层是触达手段决定了你能不能第一时间收到消息。展示层是锦上添花方便你在电脑前时快速查看当前状态不用去翻手机。2.3 请求频率的设计逻辑这是整个工具最需要拿捏的地方。频率太高容易被官网限流甚至封 IP频率太低补货瞬间你可能错过几分钟。我的方案是动态频率无货状态下每 60 秒请求一次检测到即将有货的中间状态比如页面出现即将开售字样时自动切换到每 10 秒一次确认有货后立即停止轮询并推送。为什么是 60 秒实测下来苹果官网的库存状态更新本身就有延迟你 10 秒刷一次和 60 秒刷一次实际能抢到的概率差别不大但请求量差了 6 倍。60 秒是一个在及时性和安全性之间比较平衡的值。当然如果你监控的是补货极其频繁的品类可以适当调低。注意不要用固定间隔的暴力轮询。加一点随机抖动比如 60 秒 ± 5 秒模拟人类行为能显著降低被风控的概率。3. 核心细节解析与实操要点3.1 库存接口的定位与请求构造苹果官网的库存查询前端页面是通过一个内部 API 获取数据的。你打开浏览器的开发者工具切到 Network 面板筛选 XHR/Fetch 请求刷新商品页面就能看到那个返回 JSON 的请求。这个请求通常长这样GET https://www.apple.com.cn/shop/fulfillment-messages? parts.0MTQ03CH/A searchNearbytrue littletrue storeR123关键参数说明parts.0产品型号编码比如MTQ03CH/A对应某个具体配置的 iPhone。这个编码在商品页面的 URL 或页面源码里能找到。store门店编号。如果你想监控特定门店的自提库存需要填对应编号不填则查全国线上库存。searchNearby是否搜索附近门店。请求头里必须带上User-Agent否则可能返回 403。我一般直接复制浏览器里的完整 UA 字符串。const headers { User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept: application/json, Accept-Language: zh-CN,zh;q0.9, Referer: https://www.apple.com.cn/shop/buy-iphone };3.2 返回数据的解析与状态判断接口返回的 JSON 结构大致是这样简化版{ body: { content: { pickupMessage: { regular: { stores: [ { storeName: Apple 三里屯, partsAvailability: { MTQ03CH/A: { pickupSearchQuote: 今日可到店取货, pickupDisplay: available } } } ] } } } } }判断逻辑很简单遍历stores数组检查目标型号的pickupDisplay字段。如果是available说明有货如果是unavailable说明无货。但这里有个坑不同地区的接口返回结构可能不一样。有的返回pickupDisplay有的返回buyability还有的嵌套层级更深。我的做法是写一个容错解析函数逐层尝试提取关键字段任何一层缺失都不报错只是返回未知状态。function parseStockStatus(json, partNumber) { try { const stores json?.body?.content?.pickupMessage?.regular?.stores || []; for (const store of stores) { const avail store?.partsAvailability?.[partNumber]; if (avail?.pickupDisplay available) { return { status: in_stock, store: store.storeName }; } } return { status: out_of_stock }; } catch (e) { return { status: unknown, error: e.message }; } }3.3 状态变化检测与去重监控工具最怕的是重复通知。如果每次轮询都发现有货就推一次你的手机能被轰炸到崩溃。所以必须做状态去重只在状态从无货变为有货的那一刻推送一次。实现方式是用一个内存变量记录上一次的状态let lastStatus out_of_stock; function checkAndNotify(newStatus) { if (newStatus in_stock lastStatus ! in_stock) { sendNotification(有货了); } lastStatus newStatus; }这个逻辑看起来简单但有个边界情况如果程序重启lastStatus会重置为初始值可能导致重复通知。解决办法是把状态持久化到本地文件或 Redis重启后先读取上次状态。实操心得我一开始没做持久化结果服务器半夜自动重启了一次早上起来发现收到了 7 条重复通知。后来加了个简单的 JSON 文件存储问题解决。3.4 通知渠道的选择与配置通知渠道我配了三个按可靠性排序Telegram Bot最可靠几乎无延迟支持富文本。配置方式是找 BotFather 创建一个 Bot拿到 token然后调用sendMessage接口。系统通知本地运行时用node-notifier弹窗适合在电脑前的时候。Chrome 扩展弹窗通过本地 WebSocket 推送到扩展扩展再调用chrome.notificationsAPI。Telegram 的推送代码async function sendTelegram(message) { const url https://api.telegram.org/bot${BOT_TOKEN}/sendMessage; await fetch(url, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ chat_id: CHAT_ID, text: message, parse_mode: HTML }) }); }注意Telegram 的 API 在国内网络环境下可能不稳定建议同时配置一个备用的邮件通知渠道。用 nodemailer 发邮件虽然延迟高一点但胜在稳定。4. 实操过程与核心环节实现4.1 环境准备与依赖安装先把基础环境搭起来。假设你用的是一台 Ubuntu 服务器或者本地 Mac。# 安装 Node.js 20 LTSUbuntu curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt-get install -y nodejs # 验证版本 node -v # 应该输出 v20.x.x npm -v然后初始化项目mkdir apple-stock-monitor cd apple-stock-monitor npm init -y npm install node-cron undici ws node-notifier nodemailer依赖说明node-cron定时任务调度比 setInterval 更灵活支持 cron 表达式。undici高性能 HTTP 客户端Node.js 18 内置了 fetch但 undici 在连接池管理上更优。wsWebSocket 服务端用于和 Chrome 扩展通信。node-notifier跨平台系统通知。nodemailer邮件通知。4.2 轮询主逻辑的编写核心轮询函数长这样const { request } require(undici); const cron require(node-cron); const PART_NUMBER MTQ03CH/A; const CHECK_INTERVAL 60; // 秒 async function checkStock() { const url https://www.apple.com.cn/shop/fulfillment-messages?parts.0${PART_NUMBER}searchNearbytruelittletrue; try { const { body } await request(url, { headers }); const json await body.json(); const result parseStockStatus(json, PART_NUMBER); console.log([${new Date().toISOString()}] 状态: ${result.status}); if (result.status in_stock lastStatus ! in_stock) { await notifyAll(有货了门店: ${result.store}); } lastStatus result.status; } catch (err) { console.error(请求失败:, err.message); // 失败不改变状态避免误判 } } // 启动定时任务加随机抖动 cron.schedule(*/${CHECK_INTERVAL} * * * * *, () { const jitter Math.random() * 5000; setTimeout(checkStock, jitter); });这里有几个细节值得说随机抖动的实现cron 表达式本身不支持随机所以我在回调里用setTimeout加了一个 0-5 秒的随机延迟。这样每次实际请求的时间点都不一样降低被识别的风险。错误处理不改变状态网络请求失败时我选择保持lastStatus不变。因为失败可能是临时的网络抖动如果这时候把状态改成未知下次成功时又会触发一次通知造成误报。日志格式带上 ISO 时间戳方便排查问题。我还会把日志写到文件里用pm2 logs或者直接tail -f查看。4.3 Chrome 扩展的配合实现Chrome 扩展这部分主要做两件事显示当前监控状态、提供手动触发按钮。扩展的manifest.json{ manifest_version: 3, name: Apple Stock Monitor, version: 1.0, permissions: [notifications, storage], background: { service_worker: background.js }, action: { default_popup: popup.html } }background.js里连本地 WebSocketlet ws; function connectWS() { ws new WebSocket(ws://localhost:8765); ws.onmessage (event) { const data JSON.parse(event.data); if (data.type stock_alert) { chrome.notifications.create({ type: basic, iconUrl: icon128.png, title: 库存提醒, message: data.message }); } }; ws.onclose () { setTimeout(connectWS, 5000); // 断线重连 }; } connectWS();Node.js 端的 WebSocket 服务const WebSocket require(ws); const wss new WebSocket.Server({ port: 8765 }); function broadcast(data) { wss.clients.forEach(client { if (client.readyState WebSocket.OPEN) { client.send(JSON.stringify(data)); } }); }提示Chrome 扩展加载时如果提示该扩展程序未列在 Chrome 应用商店中这是正常的因为你用的是开发者模式加载。点击加载已解压的扩展程序选择扩展目录即可。如果遇到扩展不生效去chrome://extensions/页面点一下刷新按钮reload。4.4 部署与进程守护本地跑没问题之后扔到服务器上长期运行。用 pm2 做进程守护npm install -g pm2 pm2 start index.js --name apple-monitor pm2 save pm2 startuppm2 startup会生成一条命令复制执行就能实现开机自启。这样即使服务器重启监控也会自动恢复。如果你不想用服务器也可以在自己的电脑上跑但要注意电脑不能休眠。Mac 上可以在节能设置里勾选防止电脑自动进入睡眠。5. 常见问题与排查技巧实录5.1 请求返回 403 或 541 错误这是最常见的问题。HTTP 541 不是标准状态码通常是苹果的风控系统返回的自定义错误。排查思路检查User-Agent是否完整是否和真实浏览器一致。检查请求频率是否过高尝试把间隔调到 120 秒以上。检查是否缺少Referer头。如果用了代理检查代理 IP 是否被标记。我的经验是加上完整的浏览器请求头 随机抖动 60 秒以上间隔基本能稳定运行。如果还是被封就换一个出口 IP或者降低频率到 5 分钟一次。5.2 接口返回结构变化导致解析失败苹果偶尔会调整接口的返回结构比如把pickupDisplay改成buyability或者调整嵌套层级。应对方法写容错解析 加日志。每次解析失败时把原始 JSON 的前 500 个字符打到日志里方便你快速定位结构变化。然后根据新结构更新解析函数。我还会在解析函数里加一个兜底逻辑如果所有已知字段都找不到就用正则去匹配 JSON 字符串里的关键词比如available。虽然粗暴但能应急。5.3 通知收不到或延迟严重分渠道排查渠道常见问题解决方法Telegram网络不通、token 错误检查网络、重新生成 token系统通知权限未授予系统设置里允许通知Chrome 扩展WebSocket 断连检查本地服务是否运行、端口是否被占邮件进了垃圾箱加白名单、换发件邮箱实操心得我建议至少配两个渠道。我有一次 Telegram 因为网络问题延迟了 20 分钟才收到幸好系统通知及时弹了出来。多一个渠道多一份保险。5.4 程序运行一段时间后崩溃长时间运行崩溃通常是内存泄漏或者未捕获的异常。排查步骤用pm2 monit看内存占用如果持续增长说明有泄漏。检查是否有未关闭的 HTTP 连接undici 的 request 用完要确保 body 被消费。加全局异常捕获process.on(uncaughtException, (err) { console.error(未捕获异常:, err); // 记录日志但不退出进程 }); process.on(unhandledRejection, (reason) { console.error(未处理的 Promise 拒绝:, reason); });我自己的程序跑了两个月没崩过关键就是做好了异常捕获和连接管理。5.5 常见问题速查表现象可能原因快速排查一直显示无货型号编码错误核对 parts.0 参数频繁误报有货状态未去重检查 lastStatus 逻辑请求全部失败IP 被封换 IP 或降低频率扩展不弹窗WebSocket 未连检查 8765 端口通知重复状态未持久化加文件存储6. 几个提升稳定性的进阶技巧6.1 多型号并行监控如果你同时想监控多个型号或多个门店不要串行请求用Promise.all并行const targets [ { part: MTQ03CH/A, store: R123 }, { part: MTQ05CH/A, store: R456 } ]; await Promise.all(targets.map(t checkStock(t)));但要注意并行请求会增加瞬时请求量建议把并行数控制在 3-5 个以内并且每个请求之间加一点延迟。6.2 用配置文件管理参数不要把型号、门店、通知 token 硬编码在代码里。用一个config.json管理{ targets: [ { partNumber: MTQ03CH/A, store: R123, alias: iPhone 18 Pro 256G } ], interval: 60, telegram: { token: your_token, chatId: your_chat_id } }这样改配置不用动代码也方便分享给别人用记得把 token 换成占位符。6.3 日志轮转长期运行日志文件会越来越大。用winston配合winston-daily-rotate-file做日志轮转每天一个文件保留最近 7 天。const winston require(winston); require(winston-daily-rotate-file); const logger winston.createLogger({ transports: [ new winston.transports.DailyRotateFile({ filename: logs/app-%DATE%.log, datePattern: YYYY-MM-DD, maxFiles: 7d }) ] });这个工具我从最初的手动刷页面到现在稳定运行了两个多月中间踩的坑基本都记录在上面了。最核心的体会是监控工具的价值不在于技术多复杂而在于稳定和及时。一个能 24 小时不崩、有货 10 秒内推送的工具比一个功能花哨但三天两头挂掉的工具强太多。如果你也想动手做建议先从最简单的单型号、单通知渠道开始跑通了再逐步加功能。别一上来就搞多型号、多渠道、Web 界面那样很容易在调试阶段就放弃。先把核心的轮询-判断-通知闭环跑通剩下的都是锦上添花。