亚马逊数据API生产避坑:Node.js指纹失效、HTTP2并发错乱、幂等缺失完整解决方案

14 阅读9分钟

amazon-data-api-nodejs-cover-zh.png

周四凌晨,CI 一片绿。TypeScript 编译零报错,接口返回值被你标成了 ProductSnapshot,本地 50 条样本跑通。发布上线三天后,BI 报表里 price 列大片 nulltitle 偶发空串。问题不在你的类型,在于类型系统在运行时关了门——它只管编译期,网络返回的数据在它视线之外。

第二个场景更隐蔽:你把并发数从 8 提到 16,以为吞吐翻倍,监控曲线纹丝不动。HTTP/2 一开,并发上限的含义整段改写了。

先说 null 那桩事。团队第一反应是解析层写错了,于是在本地重放同一条请求,字段好好的。问题出在契约分层:编译期你声明 price: number,运行期这条数据来自对端 HTTP 响应,二者之间没有任何强制桥接。对端某次把 price 置空、或者把整段 JSON 换成「稍后重试」的占位结构,你的类型一行没动,数据已经变质。这正是类型系统保不住运行时数据的典型切面——它证明的是「代码内部自洽」,不是「外部输入可信」。

把这个切面推到生产:五十万条商品每天刷新一次,只要对端在万分之一的概率下返回空结构,一天就是五百行脏数据静默入库。CI 永远绿,因为类型没错;报表慢慢失真,因为数据已坏。定位要靠运行时的契约校验,而不是更强的类型。

本文用生产负载视角,拆三个 TypeScript 保不住的失败:网络指纹碎片化、HTTP/2 并发模型改写、幂等重跑。每一条都配可运行 TypeScript 代码。

类型系统保不住运行时:先上 Zod 与 P0 断言

tsc 检查的是形状,不是数据。ProductSnapshotprice: number,但上游某天返回 null,或者字段名拼错成 prices。编译期对此一无所知。

把边界从类型层下移到运行时,用 Zod 的 safeParse 而不是 parse——parse 抛错会丢失错误上下文,safeParse 返回 {success, data, error},让你可以分类处理。

import { z } from "zod";

const ProductSnapshot = z.object({
  asin: z.string().regex(/^[A-Z0-9]{10}$/),
  marketplace: z.enum(["US", "DE", "JP", "UK"]),
  capturedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  contractVersion: z.string().default("2026-09-01"),
  title: z.string().min(1),
  price: z.number().positive().nullish(),
  currency: z.string().length(3).nullish(),
});

const P0_FIELDS = ["title", "price", "currency"] as const;

export function assertSnapshot(row: unknown) {
  const parsed = ProductSnapshot.safeParse(row);
  if (!parsed.success) {
    throw new Error(`snapshot contract violated: ${parsed.error.message}`);
  }
  const data = parsed.data;
  // P0 字段缺失即阻断,不写脏数据进库
  for (const field of P0_FIELDS) {
    const value = data[field];
    if (value === null || value === undefined || value === "") {
      throw new Error(`P0 field missing: ${field}`);
    }
  }
  return data;
}

P0 断言解决的是「字段为空还落库」的脏写。但还有一层要分开看:覆盖率填充率不是一回事。覆盖率 = 成功落库的行数 / 请求数,衡量你「抓没抓到」;填充率 = 非空字段数 / 应填字段数,衡量「抓到了但空不空」。混在一起你会误判——覆盖率 99% 看着健康,填充率掉到 60% 才知道价格字段在悄悄蒸发。两个指标分开埋点,定位问题才快。

主线一:Node 指纹生态碎片化

Python 有默认答案,Node 没有

在 Python 侧,遇到 TLS 指纹拦截,社区答案整齐:curl_cffi。它底层走 curl + BoringSSL,ClientHello 对齐到指定浏览器,JA3/JA4 指纹稳定。一个依赖解决问题。

Node 没有这样的默认答案。你打开 npm,会撞上一堆各说各话的库,每个都在不同层级上做妥协。

got-scraping 已停更,且只改头不改握手

got-scraping 曾是 Node 侧的热门选择,但它已经停止维护。更关键的是它的设计边界:它只改写请求头(User-Agent、Accept、sec-ch-ua 等),从不碰 TLS 握手本身。卡在 JA3/JA4 的拦截,它本来就解决不了——握手阶段的特征在它能力圈外。

2026-08 指纹库基准

下面这张表取自 wreq-js 仓库公开 bench,测量日期 2026-08-06,M 系列 Mac,300 次串行请求对本地服务。数值随版本变化,仅作当下参考。

引擎最新 ChromeHTTP/2 指纹req/s冷启动
wreq-jsRust wreq + BoringSSL149正确128427 ms
imperscurl-impersonate146正确843916 ms
node-wreqRust149正确650010 ms
impitRust reqwest + rustls124不正确671037 ms
CycleTLSGo 子进程 IPC未参与未参与未参与IPC 开销

(2026-08 实测,数值随版本变化。)

impit 的 SETTINGS 错位:声称 Chrome,说着 Rust

impit 跑在 Rust 的 reqwest + rustls 上,HTTP/2 指纹这项它没对齐。它的 HTTP/2 SETTINGS 用的是底层 Rust 库默认值——缺 HEADER_TABLE_SIZE,还发送了 Chrome 从不发的 MAX_FRAME_SIZE。结果就是:它名义上模拟 Chrome 149,握手特征却暴露了 Rust 的运行逻辑。一句话概括这个矛盾——声称 Chrome,却在用 Rust 的语法说话

这对生产意味着什么:风控不只看 TLS,还看 HTTP/2 层的 SETTINGS 帧顺序与字段集合。一处错位,浏览器一致性就破功。

回到基准表说几个容易被忽略的点。第一,req/s 这一列是在本地服务上测的串行值,放到真实对端会受出口带宽和对端限速影响,但库与库之间的相对排位有参考意义:wreq-js 的 12842 与 impit 的 6710 差着一倍,量级差异来自底层引擎是否对齐了指纹。第二,冷启动这一列对 serverless 场景很关键——impit 的 37 ms 比 wreq-js 的 7 ms 慢五倍,函数实例频繁重建时,这部分开销会直接吃进首请求延迟。第三,CycleTLS 走 Go 子进程 IPC,bench 里标「未参与」不是因为它弱,而是它的架构没法在同一进程内公平比对,生产里它的 IPC 开销会在高并发下放大。

选库时还要看维护状态。got-scraping 停更意味着它不会跟进新的 Chrome 指纹与 undici 版本变更,新项目不该把它当主力。curl-impersonate 系的 impers 对齐到 146,落后于 wreq-js 的 149,差三个大版本,某些新站点的指纹校验会因此漏判。

JA3 与 JA4 的背景

JA3 把 ClientHello 的五字段拼接后取 MD5,作为指纹。Chrome 110 之后扩展顺序随机置换,导致 JA3 不稳定。主流风控因此转向 JA4——对字段排序后再哈希,抗随机性更强。你选库时,得确认它对齐的是 JA4 这一代,而不是停留在 JA3 时代。

选哪家做接入层,取决于你的技术栈与维护意愿。把指纹这件事交给托管方案也是一种路径,下面收口时展开。

主线二:并发模型改变

undici-concurrency-zh.png

undici 的 allowH2 两个入口默认值不同

Node 内置 fetch 基于 undici,默认不协商 HTTP/2(官方称实验性、未默认开启)。要开,得手动:

import { Agent, setGlobalDispatcher, fetch } from "undici";

const dispatcher = new Agent({
  allowH2: true,
  connections: 8,
  pipelining: 0,
  maxConcurrentStreams: 100,
  bodyTimeout: 30_000,
  headersTimeout: 15_000,
  connect: { timeout: 5_000 },
});

setGlobalDispatcher(dispatcher);

async function fetchSnapshot(url: string) {
  // 分阶段超时:整体上限单独设一道闸
  const signal = AbortSignal.any([AbortSignal.timeout(45_000)]);
  const res = await fetch(url, { signal });
  if (!res.ok) throw new Error(`upstream ${res.status}`);
  return res.json();
}

这里有个坑:undiciAgentallowH2 默认 false,但 undiciClientallowH2 默认 true。两个入口默认值不同,你按 Client 的直觉去配 Agent,会以为 HTTP/2 自动开了,实际没开。

maxConcurrentStreams 取代 pipelining

协商成 HTTP/2 之后,单连接并发天花板由 maxConcurrentStreams(默认 100)决定,取代了 HTTP/1.1 时代的 pipelining。流控窗口 initialWindowSize 默认 262144。这就是为什么「并发翻倍吞吐没涨」——你可能只调了 p-limit 的并发数,没碰 maxConcurrentStreams,单连接流上限没放开,堆叠再多任务也挤在同一道闸后面。

再算一笔账。你设 connections: 8maxConcurrentStreams: 100,单连接最多 100 个并发流,八条连接理论天花板是 800 个在途请求。但 p-limit(8) 只让你同时发 8 个,这 8 个挤在一条连接的前 8 个流里,其余 792 个流配额空转——这就是「并发翻倍吞吐没涨」的算术根。要让并发吃满 HTTP/2,得让并发管制与 maxConcurrentStreams 同向放大,再用 intervalCap 把速率摁在配额线下。

initialWindowSize 默认 262144(256 KB),它决定单条流在未收到对方窗口更新前能推多少字节。抓商品详情这种几十 KB 的响应,一个窗口够用;若你顺带抓评论长文或图片清单,单响应接近或超过窗口,流会被流控卡住,此时要和对端协商更大的窗口,或拆小请求粒度。

分阶段超时

把超时拆成三段,分别对待:连接阶段、响应头阶段、响应体阶段。undici 的 connect.timeoutheadersTimeoutbodyTimeout 各管一段。任何一个阶段卡住,都该用不同的重试策略——连接超时往往要换出口 IP,响应体超时可能要断点续读。

Node 错误名分类

undici 抛出的错误有固定前缀,分类处理才能精准重试:

  • UND_ERR_CONNECT_TIMEOUT:连接阶段超时,优先换 IP 重试。
  • UND_ERR_HEADERS_TIMEOUT:收到响应头前超时,可重试。
  • UND_ERR_BODY_TIMEOUT:响应体流中断,考虑断点续读。
  • TimeoutError(DOMException):由 AbortSignal.timeout 触发,外层超时。
  • ENOTFOUND:DNS 解析失败,目标域名问题。
  • EAI_AGAIN:DNS 临时失败,可重试。
function classify(err: unknown): "retry" | "ip" | "fatal" {
  if (err instanceof Error) {
    switch (err.name) {
      case "UND_ERR_CONNECT_TIMEOUT":
      case "UND_ERR_HEADERS_TIMEOUT":
      case "EAI_AGAIN":
        return "retry";
      case "UND_ERR_BODY_TIMEOUT":
        return "ip"; // 换出口后断点续读
      case "ENOTFOUND":
        return "fatal";
    }
  }
  return "retry";
}

AbortSignal.timeout 在 Node 17.3+ 可用,AbortSignal.any 在 Node 22+ 可用——后者让你可以把「整体超时」与「单阶段超时」合并成一个信号,代码更干净。

p-limit 只限并发不限速率

这是第二个隐蔽陷阱。p-limit(8) 限制同时进行的任务数,但不限制速率。响应 10 ms 时它约 800 req/s,响应 5 s 时掉到约 1.6 req/s。并发数固定,速率随响应时间剧烈波动,你没法用它兜底限流。

import PQueue from "p-queue";

// 并发 8,每 1000ms 最多放行 4 个 → 真限速
const queue = new PQueue({ concurrency: 8, interval: 1_000, intervalCap: 4 });

async function crawl(asins: string[]) {
  return Promise.all(
    asins.map((asin) => queue.add(() => fetchSnapshot(buildUrl(asin))))
  );
}

p-queueintervalCap 补上速率这一层。并发限的是「同时几个」,速率限的是「每秒几个」,两件事要分开配。

会话复用省握手成本

TLS 握手在会话复用场景约 15 ms,单次调用约 53 ms。把会话保持住,复用同一条连接,握手成本摊薄到接近零。

// 会话对象由接入层提供,复用避免重复握手
const session = await createSession({ browser: "chrome_149" });
try {
  for (const asin of asins) {
    await session.fetch(buildUrl(asin));
  }
} finally {
  await session.close();
}

如果你不想自己维护指纹与出口,Pangolinfo 的中文采集接口把这部分封装成一次调用。

主线三:幂等重跑

抓取任务跑一半崩溃,重启后是重跑一遍,还是产生重复行?前者浪费配额,后者污染数仓。答案是幂等写入。

主键四要素 + ON CONFLICT

(asin, marketplace, captured_at, contract_version) 设为唯一约束, upsert 时冲突就更新,不插入重复行。contract_version 这一列很关键——上游字段结构升级后,新旧快照应该并存比对,而不是互相覆盖。

import { Pool } from "pg";

const pool = new Pool({ max: 4 });

const UPSERT = `
INSERT INTO product_snapshot
  (asin, marketplace, captured_at, contract_version, title, price, currency)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (asin, marketplace, captured_at, contract_version)
DO UPDATE SET
  title = EXCLUDED.title,
  price = EXCLUDED.price,
  currency = EXCLUDED.currency;
`;

async function upsertBatch(rows: Snapshot[]) {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    for (const r of rows) {
      await client.query(UPSERT, [
        r.asin, r.marketplace, r.capturedAt,
        r.contractVersion, r.title, r.price ?? null, r.currency ?? null,
      ]);
    }
    await client.query("COMMIT");
  } catch (e) {
    await client.query("ROLLBACK");
    throw e;
  } finally {
    client.release();
  }
}

分批 checkpoint 续跑

每批落库后记录游标,崩溃后从游标续跑,不重头抓取。

import fs from "node:fs";

function readCheckpoint(path: string): number {
  try { return Number(fs.readFileSync(path, "utf8")) || 0; }
  catch { return 0; }
}
function writeCheckpoint(path: string, cursor: number) {
  fs.writeFileSync(path, String(cursor));
}

async function run(asins: string[], checkpointPath: string) {
  let cursor = readCheckpoint(checkpointPath);
  for (let i = cursor; i < asins.length; i += 50) {
    const batch = asins.slice(i, i + 50);
    const rows = await Promise.all(batch.map((a) => fetchAndAssert(a)));
    await upsertBatch(rows);
    writeCheckpoint(checkpointPath, i + 50); // 落库即 checkpoint
  }
}

checkpoint 存哪也有讲究。上面用文件存游标,简单但在多实例部署下会互相覆盖,更稳的做法是把游标写进同一张元信息表,主键就是任务 ID,这样横向扩实例也不会重复消费同一段 asin。无论存文件还是存库,原则一致:落库成功才推进游标,推进游标才认领下一批——二者顺序错了就会丢数据或重复消费。

批内部分失败也要处理。50 条一批,第 30 条对端超时,整批回滚会浪费前 29 条的成果;更细的做法是批内逐条 upsert(像上面 upsertBatch 的循环),单条失败只重试那一条,整批不回滚。代价是事务变大,但幂等约束让重复执行安全,重试不再是负担。

优雅停机

收到 SIGTERM 时,停止接收新任务,把已在途的请求刷写落库再退出。结合上面的 checkpoint,重启后从落点续跑。

let shuttingDown = false;
const inFlight = new Set<Promise<void>>();

process.on("SIGTERM", () => { shuttingDown = true; });
process.on("SIGINT", () => { shuttingDown = true; });

async function worker(asins: string[]) {
  for (const asin of asins) {
    if (shuttingDown) {
      await flush(); // 刷写已取到未入库的数据
      return;
    }
    const task = fetchAndAssert(asin).then((row) => upsertBatch([row]));
    inFlight.add(task);
    task.finally(() => inFlight.delete(task));
    await task;
  }
}

async function flush() {
  await Promise.allSettled([...inFlight]);
}

把三条主线串起来看:指纹决定请求能不能进门,并发模型决定进门后跑多快,幂等决定跑挂了重来会不会留烂摊子。任何一条短板都会让另外两条的努力打折——指纹不对,并发拉再高也是批量被拦;并发失控,幂等再稳也扛不住重复写入洪流;幂等缺失,单次成功也救不了每日重跑的脏库。

收口:把三道坎交给托管

指纹碎片化、HTTP/2 并发改写、幂等重跑——这三件事每一项都吃掉工程带宽。如果你只想把数据拿回来,Pangolinfo 的托管接口把这些封装掉了:

一次请求的费用包含一切:住宅与移动 IP 出口与轮换、TLS 与 HTTP/2 指纹、浏览器指纹一致性、需要时的 JS 渲染、验证码与拦截页的服务端处理与重试、地域与邮区对齐——都不作为加价项。你发一个请求,收一份结构化实时 JSON。无渲染倍率、无端点难度倍率、无住宅 IP 加价。

锚点数据:中位延迟约 3 s、成功率 99%、日调用 3000 万+、SP 广告位采集率跨 13 市场 91.4%。这些数字来自托管集群的公开口径,落到你的业务上,省掉的是三件事的工程维护:指纹库跟随、出口 IP 池运营、拦截页与验证码的人工兜底。

延伸阅读

  • 上一篇从「接口返回 200 但字段空」切入,拆每日任务里八处缺口:同平台文章
  • 另一篇算清数据采集管线在爬虫之后还花你多少钱:同平台文章