周四凌晨,CI 一片绿。TypeScript 编译零报错,接口返回值被你标成了 ProductSnapshot,本地 50 条样本跑通。发布上线三天后,BI 报表里 price 列大片 null,title 偶发空串。问题不在你的类型,在于类型系统在运行时关了门——它只管编译期,网络返回的数据在它视线之外。
第二个场景更隐蔽:你把并发数从 8 提到 16,以为吞吐翻倍,监控曲线纹丝不动。HTTP/2 一开,并发上限的含义整段改写了。
先说 null 那桩事。团队第一反应是解析层写错了,于是在本地重放同一条请求,字段好好的。问题出在契约分层:编译期你声明 price: number,运行期这条数据来自对端 HTTP 响应,二者之间没有任何强制桥接。对端某次把 price 置空、或者把整段 JSON 换成「稍后重试」的占位结构,你的类型一行没动,数据已经变质。这正是类型系统保不住运行时数据的典型切面——它证明的是「代码内部自洽」,不是「外部输入可信」。
把这个切面推到生产:五十万条商品每天刷新一次,只要对端在万分之一的概率下返回空结构,一天就是五百行脏数据静默入库。CI 永远绿,因为类型没错;报表慢慢失真,因为数据已坏。定位要靠运行时的契约校验,而不是更强的类型。
本文用生产负载视角,拆三个 TypeScript 保不住的失败:网络指纹碎片化、HTTP/2 并发模型改写、幂等重跑。每一条都配可运行 TypeScript 代码。
类型系统保不住运行时:先上 Zod 与 P0 断言
tsc 检查的是形状,不是数据。ProductSnapshot 说 price: 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 次串行请求对本地服务。数值随版本变化,仅作当下参考。
| 库 | 引擎 | 最新 Chrome | HTTP/2 指纹 | req/s | 冷启动 |
|---|---|---|---|---|---|
| wreq-js | Rust wreq + BoringSSL | 149 | 正确 | 12842 | 7 ms |
| impers | curl-impersonate | 146 | 正确 | 8439 | 16 ms |
| node-wreq | Rust | 149 | 正确 | 6500 | 10 ms |
| impit | Rust reqwest + rustls | 124 | 不正确 | 6710 | 37 ms |
| CycleTLS | Go 子进程 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 的 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();
}
这里有个坑:undici 的 Agent 上 allowH2 默认 false,但 undici 的 Client 上 allowH2 默认 true。两个入口默认值不同,你按 Client 的直觉去配 Agent,会以为 HTTP/2 自动开了,实际没开。
maxConcurrentStreams 取代 pipelining
协商成 HTTP/2 之后,单连接并发天花板由 maxConcurrentStreams(默认 100)决定,取代了 HTTP/1.1 时代的 pipelining。流控窗口 initialWindowSize 默认 262144。这就是为什么「并发翻倍吞吐没涨」——你可能只调了 p-limit 的并发数,没碰 maxConcurrentStreams,单连接流上限没放开,堆叠再多任务也挤在同一道闸后面。
再算一笔账。你设 connections: 8 且 maxConcurrentStreams: 100,单连接最多 100 个并发流,八条连接理论天花板是 800 个在途请求。但 p-limit(8) 只让你同时发 8 个,这 8 个挤在一条连接的前 8 个流里,其余 792 个流配额空转——这就是「并发翻倍吞吐没涨」的算术根。要让并发吃满 HTTP/2,得让并发管制与 maxConcurrentStreams 同向放大,再用 intervalCap 把速率摁在配额线下。
initialWindowSize 默认 262144(256 KB),它决定单条流在未收到对方窗口更新前能推多少字节。抓商品详情这种几十 KB 的响应,一个窗口够用;若你顺带抓评论长文或图片清单,单响应接近或超过窗口,流会被流控卡住,此时要和对端协商更大的窗口,或拆小请求粒度。
分阶段超时
把超时拆成三段,分别对待:连接阶段、响应头阶段、响应体阶段。undici 的 connect.timeout、headersTimeout、bodyTimeout 各管一段。任何一个阶段卡住,都该用不同的重试策略——连接超时往往要换出口 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-queue 的 intervalCap 补上速率这一层。并发限的是「同时几个」,速率限的是「每秒几个」,两件事要分开配。
会话复用省握手成本
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 池运营、拦截页与验证码的人工兜底。