1. 从一个抢购场景说起:为什么要自己写库存监控
iPhone 新机首发那几天,官网的购买按钮基本就是玄学。你盯着页面刷新,它显示“暂无供应”;你切个外卖回来,黄牛已经加价八百在朋友圈晒单了。我去年蹲 iPhone 17 首发的时候,连续三天手动刷新,结果连购物袋都没进去过。今年 iPhone 18 传闻备货更紧张,我决定不再拼手速,而是让程序替我盯着。
这个项目的核心目标很朴素:定时轮询苹果官网的库存接口,一旦目标机型从“暂无供应”变成“有货”,立刻通过本地通知或声音提醒我,让我第一时间手动下单。注意,这里说的是“提醒”,不是“自动下单”。自动下单涉及账号风控、验证码、支付授权等一堆麻烦事,而且容易触发平台的风控机制,得不偿失。我们要做的是一个库存状态探测器,把“什么时候有货”这个信息差抹平。
技术选型上,我用Codex作为主要的代码生成与辅助工具,配合Node.js写核心轮询逻辑,Chrome负责调试和观察网络请求,JavaScript作为唯一的开发语言。这套组合的好处是:Codex 能帮我快速生成重复性的 HTTP 请求代码和数据处理逻辑,Node.js 天生适合做定时任务和网络 IO,Chrome 的开发者工具则是我分析苹果官网接口的“显微镜”。整个项目不需要复杂的框架,一个index.js加上几个辅助模块就能跑起来。
适合谁来参考这篇内容?如果你有JavaScript 基础,知道fetch或axios怎么用,了解setInterval和Promise的基本概念,那就可以直接抄作业。如果你完全没写过代码,也不用慌,我会把每一步的操作意图和参数选择都讲清楚,你照着做也能跑通。这个项目的门槛不在于代码有多难,而在于你能不能看懂浏览器里那些网络请求,以及愿不愿意花半小时调试轮询频率。
提示:本文所有操作均基于公开的网页接口和本地开发环境,不涉及任何账号登录、支付流程或自动化下单行为。库存监控的本质是信息聚合,请合理使用,避免对目标服务器造成不必要的压力。
2. 整体设计思路与工具链拆解
2.1 为什么选 Node.js 而不是 Python 或浏览器插件
很多人第一反应是用 Python 写爬虫,毕竟requests库很顺手。但我选 Node.js 有几个很实际的理由。第一,JavaScript 的异步模型天然适合高频轮询。Node.js 的事件循环在处理大量并发 HTTP 请求时,内存占用比 Python 的多线程方案低得多。你开十个轮询任务,Node.js 可能只占几十兆内存,Python 开十个线程就要小心 GIL 和上下文切换的开销。
第二,Chrome 开发者工具和 Node.js 共享同一套 JavaScript 生态。我在 Chrome 里分析苹果接口的时候,可以直接把fetch请求复制成 Node.js 代码,粘贴到项目里改改就能用。这种无缝切换的体验,是 Python 做不到的。第三,Codex 对 JavaScript 的支持非常成熟,我让它生成一个带重试机制的轮询函数,它能把AbortController、超时处理、指数退避这些细节都写出来,省了我大量查文档的时间。
至于浏览器插件方案,我试过用 Chrome 扩展的chrome.alarmsAPI 做定时请求。问题是扩展的权限模型越来越严格,跨域请求需要配置host_permissions,而且后台脚本容易被浏览器休眠。Node.js 跑在本地终端里,想怎么轮询就怎么轮询,不受浏览器标签页生命周期的影响。
2.2 Codex 在这个项目里到底帮了什么忙
Codex 不是万能的,但在几个特定环节它确实像有个结对编程的搭档。第一个环节是接口响应数据的解析。苹果官网返回的 JSON 结构嵌套很深,手动写data.body.content.productSelection[0].items[1].availability这种路径很容易出错。我把一段脱敏后的响应样本丢给 Codex,让它生成一个提取库存状态的函数,它几秒钟就给出了带可选链和默认值的版本,比我手写快得多。
第二个环节是错误处理和重试逻辑。轮询最怕的就是网络抖动导致程序崩溃。我让 Codex 写一个带指数退避的重试包装器,它给出了一个retryFetch函数,支持最大重试次数、初始延迟、退避倍数等参数。我只需要调整参数值,不用自己从头实现。
第三个环节是通知模块的快速原型。我想在终端里用声音和文字同时提醒,Codex 直接生成了调用系统命令播放提示音的代码,还贴心地加了跨平台判断。当然,Codex 生成的代码不能直接无脑用,尤其是涉及具体接口地址和参数的地方,它可能会“幻觉”出一些不存在的字段。我的做法是:让 Codex 写骨架和通用逻辑,我自己填业务细节和验证数据。
2.3 轮询频率的取舍:别把服务器惹毛了
轮询频率是这个项目最关键的参数。设得太慢,比如五分钟一次,那基本等于没监控,有货也被别人抢光了。设得太快,比如一秒一次,你的 IP 很快就会被限流甚至封禁,而且对目标服务器也不公平。
我的经验值是15 到 30 秒一次。这个频率在首发抢购场景下足够灵敏,因为库存释放通常不是毫秒级的,从“有货”到“被抢完”一般有几十秒的窗口期。15 秒的轮询间隔意味着你最多错过 15 秒,但换来的是长时间稳定运行不被封。如果你监控的是非热门机型,可以放宽到 60 秒一次。
另外,一定要加随机抖动。不要固定每 15 秒请求一次,而是在 12 到 18 秒之间随机。这样请求的时间点不会形成规律,降低被识别为机器行为的概率。实现上很简单,把setInterval换成递归的setTimeout,每次计算下一次延迟时加上Math.random() * 6000 - 3000的偏移量。
注意:轮询频率没有绝对标准,你需要根据目标接口的响应速度和自身网络环境做调整。如果发现请求开始返回 429 或 403,立刻降低频率或暂停一段时间。
3. 核心细节解析与实操要点
3.1 如何在 Chrome 里找到真正的库存接口
这是整个项目最需要耐心的一步。打开苹果官网的购买页面,按 F12 打开开发者工具,切换到 Network 面板,勾选Fetch/XHR过滤器。然后刷新页面,你会看到一堆请求。这时候不要急着一个个点开看,先用搜索框过滤关键词,比如availability、stock、product或者你目标机型的型号代码。
我实测下来,苹果的库存状态通常藏在某个返回 JSON 的接口里,响应体里会有类似"availability": "IN_STOCK"或"availability": "OUT_OF_STOCK"的字段。有时候它不会直接写IN_STOCK,而是用"isAvailable": true或者"stockLevel": "HIGH"这种变体。你需要仔细看响应结构,找到那个真正表示“能不能买”的字段。
找到接口后,右键点击该请求,选择Copy->Copy as Node.js fetch。Chrome 会帮你生成一段包含完整请求头和请求体的代码。这段代码就是你轮询逻辑的起点。把它粘贴到一个临时文件里,用node跑一下,看看能不能拿到正确的响应。如果返回 403 或 401,说明缺少某些请求头,你需要把 Chrome 里看到的User-Agent、Accept、Referer等头信息补全。
提示:苹果的接口可能会校验
Referer和Origin,确保这两个头信息与官网域名一致。另外,有些接口需要携带特定的Cookie,但我不建议在监控工具里使用登录态 Cookie,因为那会涉及账号安全,而且 Cookie 过期后需要重新获取。
3.2 用 JavaScript 写一个健壮的轮询函数
轮询函数的核心逻辑不复杂,但要写得健壮需要处理几个边界情况。第一是超时控制。如果某个请求卡住不返回,整个轮询循环就会被阻塞。用AbortController配合setTimeout可以实现请求级别的超时。第二是错误隔离。单次请求失败不应该导致整个程序退出,而是记录错误后继续下一次轮询。第三是状态变化检测。只有当库存状态从“无”变成“有”时才触发提醒,避免重复通知。
下面是我实际使用的轮询函数骨架,基于 Node.js 的fetchAPI(Node 18 以上原生支持):
const AbortController = globalThis.AbortController; async function checkStock(url, options, timeoutMs = 10000) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { ...options, signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); return parseAvailability(data); } catch (error) { clearTimeout(timeoutId); if (error.name === 'AbortError') { console.warn('请求超时,跳过本次轮询'); } else { console.warn('请求失败:', error.message); } return null; } }parseAvailability函数需要你根据实际响应结构来写。我的做法是先用console.log(JSON.stringify(data, null, 2))把完整响应打印出来,找到表示库存的字段路径,然后用可选链安全地提取。比如:
function parseAvailability(data) { const availability = data?.body?.content?.productSelection?.[0] ?.items?.[0]?.availability; return availability === 'IN_STOCK' ? 'in_stock' : 'out_of_stock'; }这里用?.可选链是为了防止某一层字段缺失导致程序报错。如果接口返回结构变了,parseAvailability会返回out_of_stock而不是崩溃,这样轮询可以继续运行,你只需要后续调整字段路径即可。
3.3 状态管理与提醒触发逻辑
轮询本身只是手段,真正的价值在于状态变化时及时提醒。我维护一个简单的状态变量lastStatus,初始为null。每次轮询拿到新状态后,和lastStatus比较:
- 如果
lastStatus是out_of_stock,新状态是in_stock,触发“有货”提醒。 - 如果
lastStatus是in_stock,新状态是out_of_stock,触发“售罄”提醒(可选)。 - 如果状态没变,什么都不做,避免重复通知。
提醒方式我用了两种:终端文字高亮和系统提示音。文字部分用console.log配合 ANSI 颜色码,让“有货”两个字在终端里闪烁。声音部分在 macOS 上调用afplay播放系统音效,在 Windows 上用powershell播放SystemSounds。Codex 帮我生成了跨平台的判断逻辑,我只需要把音效文件路径换成自己喜欢的就行。
注意:如果你在服务器上跑这个脚本,声音提醒就没意义了。这时候可以换成发送邮件或写入日志文件。但邮件发送需要配置 SMTP,相对麻烦,我建议先用本地终端跑,确认逻辑没问题后再考虑迁移。
4. 完整实操流程:从零跑通库存监控
4.1 环境准备与依赖安装
首先确认你的机器上装了 Node.js。打开终端输入node -v,如果显示版本号大于等于 18,就可以直接用原生的fetch。如果版本低于 18,要么升级 Node.js,要么安装node-fetch包。我推荐直接升级到最新的 LTS 版本,Node.js 官网下载安装包一路下一步就行。
然后创建一个项目目录,比如stock-monitor,进入目录后执行npm init -y生成package.json。这个项目不需要额外的 npm 包,因为fetch和AbortController都是 Node.js 内置的。如果你想要更友好的终端输出,可以装一个chalk,但我觉得没必要,用 ANSI 转义码就够了。
目录结构建议这样组织:
stock-monitor/ ├── index.js # 主入口,轮询循环 ├── config.js # 配置文件,放接口地址和轮询参数 ├── parser.js # 响应解析函数 └── notifier.js # 提醒模块把配置单独抽出来是为了方便调整。比如你想换一个机型监控,只需要改config.js里的接口地址和字段路径,不用动主逻辑。
4.2 配置文件与参数计算
config.js里我放了几个关键参数:
module.exports = { apiUrl: 'https://www.apple.com.cn/shop/fulfillment-messages?...', headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)...', 'Accept': 'application/json', 'Referer': 'https://www.apple.com.cn/shop/buy-iphone/...', }, pollIntervalMs: 15000, jitterMs: 3000, requestTimeoutMs: 10000, maxRetries: 3, };pollIntervalMs是基础轮询间隔,我设了 15 秒。jitterMs是随机抖动范围,实际延迟会在15000 - 3000到15000 + 3000之间,也就是 12 到 18 秒。requestTimeoutMs是单次请求超时,10 秒足够。maxRetries是单次轮询内的重试次数,我设了 3 次,配合指数退避,第一次失败等 1 秒,第二次等 2 秒,第三次等 4 秒。
这些参数不是拍脑袋定的。15 秒的基础间隔是基于“库存释放窗口通常大于 30 秒”这个观察。抖动是为了避免请求时间点过于规律。超时 10 秒是因为苹果接口正常响应在 1 到 3 秒,超过 10 秒基本就是网络异常了。重试 3 次是在“及时性”和“不惹毛服务器”之间取的平衡。
4.3 主循环的递归实现与优雅退出
主循环我用递归setTimeout而不是setInterval,原因是setInterval不会等待上一次请求完成,如果请求耗时超过间隔,会导致请求堆积。递归setTimeout保证上一次轮询完全结束后,才计算下一次的延迟。
const config = require('./config'); const { checkStock } = require('./parser'); const { notify } = require('./notifier'); let lastStatus = null; let running = true; async function poll() { if (!running) return; const status = await checkStock(config.apiUrl, { headers: config.headers, }, config.requestTimeoutMs); if (status && status !== lastStatus) { if (status === 'in_stock') { notify('有货了!赶紧下单!'); } else if (lastStatus === 'in_stock') { notify('又售罄了,继续蹲。'); } lastStatus = status; } const delay = config.pollIntervalMs + (Math.random() * 2 - 1) * config.jitterMs; setTimeout(poll, delay); } process.on('SIGINT', () => { running = false; console.log('\n监控已停止。'); process.exit(0); }); poll();按Ctrl+C时触发SIGINT,把running设为false,当前轮询结束后就不再调度下一次。这样退出很干净,不会留下悬挂的定时器。
4.4 实测记录:第一次跑通的全过程
我第一次跑的时候,接口返回的是 403。检查后发现是User-Agent用了 Node.js 默认的,被苹果识别为爬虫。把 Chrome 里的User-Agent复制过来后,请求成功返回 200。但解析出来的状态一直是out_of_stock,我一度以为接口找错了。后来把完整响应打印出来,发现库存字段在body.content.productSelection[0].items[0].availability下面,而我之前写的是body.content.productSelection[0].availability,少了一层items。修正路径后,状态正确显示为out_of_stock。
为了测试“有货”提醒,我临时把parseAvailability改成随机返回in_stock或out_of_stock。跑了几轮后,终端成功弹出“有货了!赶紧下单!”的提示,声音也响了。确认逻辑没问题后,再把解析函数改回真实逻辑。
整个调试过程大概花了四十分钟,其中大部分时间用在找接口和确认字段路径上。代码本身很简单,难点在于理解目标网站的数据结构。
5. 常见问题与排查技巧实录
5.1 请求返回 403 或 429 怎么办
403 通常是请求头不完整。重点检查User-Agent、Referer、Origin、Accept这四个头。苹果的接口对Referer比较敏感,确保它和官网域名一致。如果还是 403,尝试把 Chrome 里该请求的所有请求头都复制过来,包括Accept-Language、Sec-Fetch-*这些看起来不重要的头。
429 是请求过于频繁。这时候不要硬刚,立刻把轮询间隔调大,比如从 15 秒改成 60 秒,然后等半小时再试。如果 429 持续出现,可能需要更换网络环境或等待更长时间。我的经验是,15 秒间隔配合随机抖动,连续跑几个小时不会触发 429。但如果你同时监控多个机型,每个机型都开一个轮询任务,总请求频率就会翻倍,这时候要相应降低每个任务的频率。
注意:不要用代理池或频繁更换 IP 来绕过限流,这既不道德也可能违反服务条款。合理的做法是尊重服务器的承受能力,用较低的频率换取长期稳定。
5.2 接口返回的数据结构变了
苹果官网的接口不是一成不变的。大促期间或者版本更新后,字段路径可能会调整。应对策略是把解析逻辑和轮询逻辑解耦。parser.js里只负责从响应中提取库存状态,如果提取失败就返回null,主循环收到null时跳过状态比较,继续下一次轮询。这样即使接口变了,程序也不会崩溃,只是不再触发提醒。你只需要重新抓包,更新parser.js里的字段路径即可。
我还会在parser.js里加一个“调试模式”,通过环境变量控制是否打印完整响应。当发现状态一直不变时,开启调试模式看一眼原始响应,很快就能定位问题。
5.3 程序跑了一晚上,早上发现停了
最常见的原因是未捕获的异常导致进程退出。虽然我在checkStock里做了 try-catch,但notify函数如果抛错,也会让主循环中断。解决办法是在poll函数的最外层再包一层 try-catch,确保任何未预料的错误都不会终止循环。
另一个原因是系统休眠。笔记本合盖后,Node.js 进程会被挂起,setTimeout不再触发。如果你用笔记本跑监控,记得在电源设置里把“合盖不休眠”打开,或者干脆用一台常开的设备。我后来换了一台闲置的小主机跑这个脚本,稳定性好了很多。
还有一个隐蔽的问题是内存泄漏。如果每次轮询都创建新的AbortController但没有正确释放,长时间运行后内存会缓慢增长。Node.js 的垃圾回收通常能处理这种情况,但如果你发现内存持续上涨,可以用process.memoryUsage()打印一下,确认没有持有不必要的引用。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 请求返回 403 | 请求头缺失或不匹配 | 对比 Chrome 里的请求头 | 补全 User-Agent、Referer 等 |
| 请求返回 429 | 轮询频率过高 | 查看响应中的 Retry-After | 降低频率,增加抖动 |
| 状态一直不变 | 字段路径错误 | 打印完整响应 | 重新抓包,更新解析路径 |
| 程序意外退出 | 未捕获异常 | 查看终端错误输出 | 外层加 try-catch |
| 内存持续增长 | 资源未释放 | 打印 memoryUsage | 检查 AbortController 和定时器 |
| 提醒不触发 | 状态比较逻辑错误 | 打印 lastStatus 和新状态 | 修正比较条件 |
5.5 几个我踩过的坑和对应技巧
第一个坑是时区问题。苹果的库存释放有时和特定时间点有关,比如每天上午 10 点。如果你用Date对象做时间判断,注意 Node.js 默认使用系统时区。我建议统一用 UTC 时间做逻辑判断,显示的时候再转成本地时间。
第二个坑是JSON 解析失败。有时候接口返回的不是 JSON,而是一段 HTML 错误页。直接response.json()会抛异常。稳妥的做法是先读response.text(),然后尝试JSON.parse,失败时打印原始文本。这样你能看到服务器到底返回了什么。
第三个坑是终端颜色码在 Windows 上不生效。ANSI 转义码在 Windows 10 之前的 cmd 里不支持。如果你用 Windows,要么升级到 Windows Terminal,要么用chalk这类库做兼容处理。我后来在 Windows 上测试时,直接去掉了颜色,用[有货]这样的文字标记代替。
第四个坑是声音提醒太吵。第一次触发时我设了循环播放,结果有货的那几秒里声音响个不停。后来改成只播放一次,并且加了一个“静默期”,触发提醒后 60 秒内不再重复提醒,避免状态在“有货”和“无货”之间快速切换时反复响铃。
6. 后续可以扩展的方向与个人体会
这个工具跑通之后,我陆续加了一些小功能。比如多机型监控,把配置改成数组,每个机型一个轮询任务,共用一个提醒模块。再比如日志记录,每次状态变化写入一个log.txt,方便事后分析库存释放的规律。我还试过把提醒推送到手机,但涉及第三方推送服务,配置起来比较麻烦,后来觉得本地声音提醒已经够用了。
Codex 在这个过程中扮演的角色更像一个“快速原型生成器”。它帮我省去了查 API 文档和写重复代码的时间,但核心的业务逻辑——比如接口地址、字段路径、轮询策略——还是需要我自己判断和验证。我的体会是:把 Codex 当成一个执行力很强但需要明确指令的助手,而不是一个能替你思考的专家。你给它的上下文越具体,它生成的代码越可用。
最后分享一个小技巧:如果你不确定某个接口是否值得监控,可以先用 Chrome 的Copy as fetch手动请求几次,观察响应时间和数据稳定性。如果接口本身响应很慢或者经常超时,那就不适合做高频轮询,换一个接口或者降低频率才是正道。库存监控的本质是信息获取,不是技术炫技,能稳定拿到数据比什么都重要。