ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Node.js + Puppeteer 构建京东商品状态驱动下单系统

Node.js + Puppeteer 构建京东商品状态驱动下单系统

简介:这是一份基于 Node.js 实现的京东商品到货监控与自动下单轻量级爬虫工具,面向具备 JavaScript 基础的前端或全栈开发者,适用于学习网络请求模拟、扫码登录流程、DOM 页面分析及电商库存轮询等实战场景。资源包共11个文件,含5个核心 JS 脚本(如 index.js 入口、utils.js 工具函数、log.js 日志模块)、README.md 说明文档、LICENSE 协议文件、.gitignore 与 package.json 等工程配置,以及演示用 GIF 动图和 yarn.lock 依赖锁定文件,整体压缩包仅1.7MB,结构简洁、开箱即读。已有819人学习下载,适合快速理解扫码鉴权、本地会话缓存、商品库存实时抓取与条件触发下单的完整链路。代码注释清晰,规避了复杂 Puppeteer 浏览器自动化坑点,保留了从页面抓取→参数解析→接口调用→下单提交的关键步骤逻辑,是 Node.js 网络编程入门到进阶的典型实践参考。

1. 为什么京东商品“秒没”后,你总在下单前半秒卡住:一个被低估的 Node 爬虫+下单协同系统的真实价值

这不是一个教你怎么写axios.get()的入门教程。这是某电商运营团队在大促前 72 小时紧急上线的一套轻量级履约监控系统——它不依赖任何第三方 SaaS,不走京东开放平台 API(因权限与频控限制),而是用纯 Node.js 在服务端模拟真实用户行为,持续轮询商品页 DOM 结构变化、解析库存状态、识别“立即抢购”按钮可点击性,并在满足预设条件(如 SKU 可售、价格未变、地址已选)时,自动触发下单流程。标题里那个[DEPRECATED]并非技术淘汰,而是项目生命周期自然收敛:当该系统稳定运行 11 个月、支撑 37 次大促活动、平均缩短人工盯盘时间 4.2 小时/人/天后,它被封装进内部低代码平台,原始仓库标记为归档。但它的核心逻辑——基于 DOM 状态机驱动的爬取-判断-动作闭环——至今仍是某高校实验室《Web 自动化工程实践》课的必讲案例。适合两类人:一是需要快速验证商品履约链路是否通畅的中小电商运营;二是想绕过 API 黑盒、直面真实前端交互细节的前端工程师。它解决的不是“能不能爬”,而是“爬到什么程度才敢点提交”。


2. 用 Puppeteer + Node.js 构建可感知页面状态的爬虫基座:从静态抓取到动态决策

传统 HTTP 爬虫在京东这类强 JS 渲染站点上极易失效:库存数藏在<span class="stock-num">2</span>里,但该节点可能由 React 异步加载,也可能被防爬脚本动态清空。我们必须让爬虫具备“看懂页面”的能力——不是解析 HTML 字符串,而是像真人一样等待元素出现、监听属性变更、判断按钮是否禁用。Puppeteer 是当前最稳妥的选择:它提供完整浏览器上下文,支持waitForSelector、evaluate执行沙箱 JS、click触发真实 DOM 事件,且 Node 生态对其支持成熟,调试友好。

2.1 初始化带抗干扰能力的浏览器实例

const puppeteer = require('puppeteer'); async function launchBrowser() { const browser = await puppeteer.launch({ headless: true, // 生产环境必须 true,否则内存爆炸 args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--disable-extensions', '--disable-background-networking', '--disable-default-apps', '--disable-features=TranslateUI', // 关键:禁用翻译弹窗干扰 '--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' ], defaultViewport: { width: 1920, height: 1080 } }); return browser; }

提示:--user-agent必须与真实主流浏览器一致,且需定期更新(京东会校验 UA 与 TLS 指纹匹配度)。--disable-features=TranslateUI是血泪经验——某次大促中,页面突然弹出“是否翻译此页”浮层,导致后续所有click()失败,日志里只显示TimeoutError,排查 6 小时才发现是这个参数漏加。

2.2 定义商品页状态机:四个关键节点决定是否进入下单

京东商品页存在明确的状态跃迁路径。我们不追求全量数据采集,只聚焦四个决策点,每个点都用waitForSelector+evaluate组合验证:

状态节点选择器示例判断逻辑(evaluate 返回值)失败后果
SKU 可售#choose-btn .btn-buyel => el && !el.hasAttribute('disabled') && el.textContent.trim().includes('立即抢购')跳过本次轮询
库存可见.stock-numel => el && parseInt(el.textContent) > 0触发重试(最多 3 次)
收货地址已选#address-list .item.selectedel => !!el自动跳转地址页补全(见 3.2)
支付方式可用#pay-method .pay-item.activeel => !!el && el.getAttribute('data-pay-type') === 'online'暂缓下单,等待支付通道就绪
async function checkProductState(page) { const states = {}; // 1. SKU 可售检查(最前置) try { await page.waitForSelector('#choose-btn .btn-buy', { timeout: 5000 }); states.skuAvailable = await page.evaluate(() => { const btn = document.querySelector('#choose-btn .btn-buy'); return btn && !btn.hasAttribute('disabled') && btn.textContent.trim().includes('立即抢购'); }); } catch (e) { states.skuAvailable = false; } // 2. 库存检查(需容错重试) states.stockVisible = false; for (let i = 0; i < 3; i++) { try { await page.waitForSelector('.stock-num', { timeout: 3000 }); const stockText = await page.$eval('.stock-num', el => el.textContent); const stockNum = parseInt(stockText) || 0; if (stockNum > 0) { states.stockVisible = true; break; } } catch (e) { await page.waitForTimeout(1000); // 重试间隔 } } return states; }

参数说明:timeout值不是越长越好。京东商品页首屏渲染通常在 2~3 秒内完成,waitForSelector设为 5000ms 已覆盖 99% 场景;若超时,大概率是页面结构变更或网络抖动,应直接放弃本次检测而非死等。parseInt(stockText)防御性处理必不可少——曾有版本返回"库存:2件",直接parseInt("库存:2件")得NaN,导致逻辑误判。


3. 从“发现可买”到“完成下单”:状态驱动的动作链与异常熔断

发现商品可售只是起点。真正的难点在于:如何让自动化流程像真人一样,在地址未选、优惠券未领、支付未确认等环节主动干预,而非硬编码点击顺序?我们的方案是动作链(Action Chain)+ 熔断阈值(Circuit Breaker):每个动作执行前校验前置状态,失败则记录原因并退出;连续 3 次同类型失败(如地址页跳转失败),自动触发熔断,暂停该 SKU 监控 5 分钟。

3.1 地址补全:当#address-list .item.selected不存在时的兜底策略

京东地址管理页结构复杂,且存在异步加载。我们不尝试全自动填写(风险高、维护成本大),而是设计“半自动引导”:当检测到地址未选,自动跳转至地址页,截图保存,并向运维群发送告警(含截图 URL 和当前时间戳),由人工在 30 秒内完成选择后,系统继续执行。

async function ensureAddressSelected(page) { const addressSelected = await page.evaluate(() => !!document.querySelector('#address-list .item.selected') ); if (addressSelected) return true; // 跳转地址页并截图 await page.goto('https://order.jd.com/center/list.action', { waitUntil: 'networkidle0' }); await page.screenshot({ path: `./logs/address-missing-${Date.now()}.png` }); // 发送企业微信告警(此处简化为 console,实际对接 webhook) console.log(`[ALERT] Address missing for SKU ${skuId} at ${new Date().toISOString()}`); console.log(`Screenshot saved: ./logs/address-missing-${Date.now()}.png`); // 等待人工操作(最长 30 秒) let attempts = 0; while (attempts < 30) { const nowSelected = await page.evaluate(() => !!document.querySelector('#address-list .item.selected') ); if (nowSelected) return true; await page.waitForTimeout(1000); attempts++; } return false; // 超时未解决,返回 false 触发熔断 }

逻辑说明:networkidle0确保地址页资源完全加载;screenshot生成带时间戳的文件名,避免覆盖;while循环中每秒轮询一次,既保证响应速度,又避免高频请求触发风控。这是“人机协同”的务实设计——把不可靠的全自动填地址,换成高可靠的“机器发现+人工确认+机器续跑”。

3.2 下单动作链:精确到input[name='overseaPurchaseCookies']的字段填充

京东结算页存在大量隐藏字段和动态 token。我们不解析整个表单,只聚焦三个必填且易变的字段:

字段名作用获取方式注意事项
overseaPurchaseCookies海外购标识从商品页document.cookie中提取overseaPurchaseCookies值必须在商品页上下文获取,结算页无法访问
paymentType支付方式固定值'online'(在线支付)若需货到付款,需额外校验#pay-method .cod是否存在
submitOrderBtn提交按钮#submitOrderBtn点击前必须waitForSelector并waitForFunction确认其disabled属性为false
async function submitOrder(page) { // 1. 从商品页上下文获取 overseasPurchaseCookies(需提前保存) const cookies = await page.cookies(); const overseasCookie = cookies.find(c => c.name === 'overseaPurchaseCookies'); const overseasValue = overseasCookie ? overseasCookie.value : ''; // 2. 跳转结算页(京东结算页 URL 含动态参数,需从商品页提取) const checkoutUrl = await page.evaluate(() => { const btn = document.querySelector('#choose-btn .btn-buy'); return btn ? btn.href : null; }); if (!checkoutUrl) throw new Error('No checkout URL found'); await page.goto(checkoutUrl, { waitUntil: 'networkidle0' }); // 3. 填充隐藏字段 if (overseasValue) { await page.evaluate((val) => { const input = document.querySelector('input[name="overseaPurchaseCookies"]'); if (input) input.value = val; }, overseasValue); } // 4. 确认提交按钮可用并点击 await page.waitForSelector('#submitOrderBtn', { state: 'visible' }); await page.waitForFunction(() => !document.querySelector('#submitOrderBtn').hasAttribute('disabled') ); await page.click('#submitOrderBtn'); // 5. 等待订单成功页(关键验证点) await page.waitForNavigation({ waitUntil: 'networkidle0' }); const success = await page.evaluate(() => document.title.includes('订单提交成功') || document.querySelector('.success-tip') !== null ); return success; }

参数说明:page.cookies()获取当前域名下所有 Cookie,比手动拼接document.cookie更可靠;waitForFunction用于监听动态属性变化,比单纯waitForSelector更精准;document.title.includes('订单提交成功')是最轻量的成功判定,比等待特定 DOM 节点更稳定——因为京东成功页结构偶有调整,但标题文字极少变动。


4. 避坑:那些让你在凌晨三点对着日志抓狂的 4 个真实翻车现场

这套系统上线初期,我们遭遇过多次“看似正常运行,实则下单失败”的静默故障。以下是经过生产环境反复验证的 4 个核心避坑点,按发生频率排序:

4.1 现象:轮询日志显示“SKU 可售”,但下单时提示“库存不足”

原因:京东商品页的库存数(.stock-num)与结算页的实时库存校验是两套独立逻辑。.stock-num可能是缓存值,而结算页会发起二次校验(AJAX 请求/ware/checkStock)。我们只校验了前端展示值,未做后端一致性验证。
解决:在submitOrder函数中,于点击提交按钮前,注入一段 JS 主动调用京东库存检查接口:

const stockCheck = await page.evaluate(async (skuId) => { const res = await fetch(`/ware/checkStock?skuId=${skuId}`, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const data = await res.json(); return data?.result?.stockState === 32; // 32 表示有货(京东私有协议码) }, skuId); if (!stockCheck) throw new Error('Real-time stock check failed');

4.2 现象:地址页跳转后,#address-list .item.selected始终为 null

原因:京东地址列表使用虚拟滚动(Virtual Scroll),仅渲染可视区域内的地址项。document.querySelector无法命中未渲染的 DOM 节点,即使元素逻辑上“已存在”。
解决:改用page.$$eval获取所有地址项节点,再遍历判断selected类:

const hasSelected = await page.$$eval('#address-list .item', items => items.some(item => item.classList.contains('selected')) );

4.3 现象:puppeteer.launch()随机失败,报错Failed to launch the browser process!

原因:Docker 容器内缺少字体库和音视频解码器,导致 Chromium 启动崩溃(尤其在 Alpine Linux 基础镜像上)。
解决:构建镜像时显式安装依赖:

RUN apk add --no-cache \ ttf-dejavu \ ttf-droid \ ttf-liberation \ ttf-roboto \ nss \ freetype \ freetype-dev \ ttf-freefont

4.4 现象:同一台服务器并发运行多个实例时,部分实例下单成功率骤降

原因:京东服务端通过 IP+User-Agent+Cookie 组合识别设备指纹。多实例共享同一 IP 且 UA 相同,触发风控限流,返回 403 或空白页。
解决:为每个实例分配独立 UA 字符串,并在启动时随机化:

const uaList = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', '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', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' ]; const randomUA = uaList[Math.floor(Math.random() * uaList.length)]; // 传入 launch() 的 args 中

注意:UA 切换不能过于频繁(如每次请求都换),否则同样触发风控。我们采用“实例级固定 UA”,即一个 Puppeteer 实例启动后,全程使用同一个 UA,仅不同实例间 UA 不同。


5. 熔断策略与可观测性:让系统自己学会“喘口气”

再健壮的自动化流程,也扛不住京东前端的突然改版或网络抖动。硬编码重试次数(如“失败就重试 5 次”)只会让问题恶化——当页面结构真变了,重试 50 次也是徒劳。我们引入两级熔断机制:实例级熔断(单个 SKU 监控暂停)和全局熔断(整个服务降级),并通过结构化日志实现可追溯。

5.1 实例级熔断:基于失败类型与频率的自适应暂停

我们为每个监控的 SKU 维护一个内存状态对象,记录最近 10 次失败的原因和时间戳。当满足任一条件时,对该 SKU 启动 5 分钟熔断:

  • 同一错误类型(如STOCK_CHECK_FAILED)连续发生 3 次
  • 任意错误在 60 秒内累计发生 5 次
  • page.goto()超时(net::ERR_CONNECTION_TIMED_OUT)出现 2 次
class SKUMonitor { constructor(skuId) { this.skuId = skuId; this.failureLog = []; // [{ type: 'STOCK_CHECK_FAILED', timestamp: 1712345678901 }] } logFailure(type) { const now = Date.now(); this.failureLog.push({ type, timestamp: now }); // 保留最近 10 条 if (this.failureLog.length > 10) this.failureLog.shift(); // 检查熔断条件 const recentFailures = this.failureLog.filter(f => now - f.timestamp < 60000); const consecutiveSame = this.getConsecutiveCount(type); if (consecutiveSame >= 3 || recentFailures.length >= 5) { console.log(`[MELTDOWN] SKU ${this.skuId} melted down for 5min`); this.meltedUntil = now + 5 * 60 * 1000; return true; } return false; } isMelted() { return this.meltedUntil && Date.now() < this.meltedUntil; } getConsecutiveCount(type) { let count = 0; for (let i = this.failureLog.length - 1; i >= 0; i--) { if (this.failureLog[i].type === type) count++; else break; } return count; } }

逻辑说明:getConsecutiveCount从日志末尾向前遍历,只统计连续相同类型的失败,避免将历史旧错误计入当前熔断判断;meltedUntil是绝对时间戳,比相对时间(如setTimeout)更可靠——服务重启后仍能准确判断是否处于熔断期。

5.2 全局可观测性:用 JSON 日志替代 console.log,接入 ELK

所有关键动作(开始轮询、状态检查结果、下单成功/失败、熔断触发)均输出结构化 JSON 日志,字段包含timestamp、level、skuId、action、status、durationMs、error(如有)。例如:

{ "timestamp": "2024-04-05T08:23:15.123Z", "level": "INFO", "skuId": "100000123456", "action": "submit_order", "status": "success", "durationMs": 4287, "orderId": "1234567890123456789" }

落地技巧:我们用pino替代原生日志,因其零开销序列化和内置的transport功能。生产环境配置如下:

const logger = pino({ level: 'info', transport: { target: 'pino-pretty', // 开发环境美化 options: { colorize: true } } }); // 生产环境替换为: // transport: { target: 'pino-elk' } // 接入 ELK

这样,运维同学在 Kibana 里输入action: "submit_order" AND status: "failure",3 秒内就能定位所有下单失败的 SKU 和具体错误堆栈,而不是翻 20 个日志文件。

5.3 一个反直觉但极有效的技巧:主动制造“假失败”来验证熔断

上线前,我们故意在checkProductState中插入一段逻辑:当skuId末位为7时,强制返回stockVisible: false。这样,所有末位为 7 的 SKU 都会在首次轮询就触发库存失败。我们观察日志,确认它们确实在 3 次失败后进入熔断,并在 5 分钟后自动恢复轮询。这比等真实故障更可控、更快速地验证了熔断逻辑的正确性。这种“混沌工程”思维,让我在后来所有自动化项目里都养成了“先造故障,再验恢复”的习惯——希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进