stress: 单号日容量测试器(分层速率 sticky+狱后探测恢复测量)

This commit is contained in:
bigcode
2026-10-30 00:16:21 +08:00
parent a3a92aff3f
commit 2eaa299a43
+207
View File
@@ -0,0 +1,207 @@
/** # 脚本说明
* SERP 单号日容量测试器(用户 2026-10-29 目标:测一个 Profile 一天能执行多少请求):
* SERP_TOKEN=xxx bun serp-profile-capacity.ts --hours 24 --label cap \
* --spec "32706610:5,85459817:60,31923651:160,20374881:320"
* 每个 Profile 独立调度,按指定速率(req/h,±30% 人性化抖动)发 sticky 请求:
* GET /api/serp/html/search?q=...&runtime=fetch&profile=<指纹>
* (sticky+fetch 绕过占用锁/令牌桶/冷却,速率完全由本脚本控制,测量纯净)。
* 每条记录写 data/<label>.jsonl;每 30 分钟写 data/<label>-live.json 汇总与控制台摘要。
* 判定:200=ok;x-serp-gate:1=门壳;502 CAPTCHA_DETECTED=验证码;
* 验证码后该号转「探测模式」(1 次/10 分钟)直至首个 200 恢复(记录恢复分钟数),
* 之后恢复原速率,观察是否在相近累计量再次触发(区分「粘性惩罚」与「自然衰减」)。
* 指纹失效(重建新指纹,应答「Profile 不存在」)则该号终止并记录最终容量。
*/
import fs from "node:fs";
/** # 成员变量 服务基址(同机直连) **/
const BASE = "http://127.0.0.1:53100";
const args = process.argv.slice(2);
function argOf(k: string, d: string): string { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; }
/** # 成员变量 测试时长(小时) **/
const HOURS = Number(argOf("--hours", "24"));
const LABEL = argOf("--label", "cap");
/** # 成员变量 号表:指纹:速率(req/h) 逗号分隔;第三段可选=分组(前缀名) **/
const SPEC = argOf("--spec", "");
const TOKEN = process.env.SERP_TOKEN ?? "";
if (!TOKEN || !SPEC) { console.error("缺少 SERP_TOKEN 或 --spec"); process.exit(1); }
/** # 成员变量 输出目录(data/,相对脚本) **/
const DATA = new URL("./data/", import.meta.url).pathname;
fs.mkdirSync(DATA, { recursive: true });
const JSONL = `${DATA}${LABEL}.jsonl`;
const LIVE = `${DATA}${LABEL}-live.json`;
/** # 成员变量 长尾查询词表(A×B×C 组合,顺序消费避免重复) **/
const A = ["best", "top rated", "cheap", "review of", "how to choose a", "buy", "compare", "2026 guide for", "price of", "warranty for"];
const B = ["coffee maker", "running shoes", "laptop stand", "rice cooker", "air fryer", "standing desk", "office chair", "mechanical keyboard", "monitor arm", "webcam", "espresso machine", "vacuum cleaner", "electric kettle", "gaming mouse", "noise cancelling headphones", "desk lamp", "water bottle", "yoga mat", "blender", "space heater"];
const C = ["for home use", "for small office", "under 100 dollars", "in 2026", "with long warranty", "for beginners", "pro model", "on sale", "quiet operation", "energy efficient"];
const combos: string[] = [];
for (const a of A) for (const b of B) for (const c of C) combos.push(`${a} ${b} ${c}`);
let qi = Math.floor(Math.random() * combos.length);
function nextQuery(): string { const q = combos[qi % combos.length]; qi++; return q; }
/** # 成员变量 单号运行时状态 **/
type Slot = {
fp: string; ratePerH: number; group: string;
sent: number; ok: number; gate: number; captcha: number; httpErr: number; netErr: number;
firstCaptchaAt: string | null; captchaCount: number;
probeMode: boolean; probeSince: number; lastCaptchaAt: number;
/** # 成员变量 连续门壳/上游失败计数(≥5 判入「门壳监狱」转探测模式) **/
consecGate: number;
/** # 成员变量 入狱次数/入狱时刻/入狱类型(gate=门壳墙,captcha=验证码) **/
jails: number; jailStart: number; jailKind: "gate" | "captcha" | null;
recoveries: { kind: string; flaggedAt: string; recoveredAt: string; minutes: number }[];
gone: boolean;
};
/** # 成员变量 解析号表 → 槽位 **/
const slots: Slot[] = SPEC.split(",").map((s) => {
const [fp, rate, group] = s.trim().split(":");
return {
fp, ratePerH: Number(rate) || 1, group: group ?? `g${fp}`,
sent: 0, ok: 0, gate: 0, captcha: 0, httpErr: 0, netErr: 0,
firstCaptchaAt: null, captchaCount: 0,
probeMode: false, probeSince: 0, lastCaptchaAt: 0,
consecGate: 0, jails: 0, jailStart: 0, jailKind: null, recoveries: [], gone: false,
};
});
const slotsByFp = new Map(slots.map((s) => [s.fp, s]));
/** # 函数
* 写单条 JSONL 记录。
*/
function rec(o: Record<string, unknown>): void {
try { fs.appendFileSync(JSONL, JSON.stringify(o) + "\n"); } catch {}
}
/** # 函数
* 入狱:连续门壳/上游失败 ≥5 或命中验证码时转入探测模式(1 次/5 分钟测恢复)。
*/
function enterJail(s: Slot, kind: "gate" | "captcha", at: number): void {
if (!s.probeMode) {
s.probeMode = true; s.probeSince = at; s.jails++; s.jailStart = at; s.jailKind = kind;
console.log(`[${new Date().toISOString()}] ${s.fp}(${s.group}) 入狱(${kind}),第 ${s.jails} 次,转探测 1/5min`);
}
}
/** # 函数
* 发射单次 sticky 请求并记账。验证码/连续门壳 → 入狱探测;恢复 → 记录分钟数并回原速率。
*/
async function fire(s: Slot): Promise<void> {
const q = nextQuery();
const url = `${BASE}/api/serp/html/search?q=${encodeURIComponent(q)}&num=10&runtime=fetch&profile=${s.fp}`;
s.sent++;
const t = Date.now();
try {
const res = await fetch(url, { headers: { authorization: `Bearer ${TOKEN}` }, signal: AbortSignal.timeout(120000) });
const gate = res.headers.get("x-serp-gate") === "1";
if (!res.ok) {
const j = await res.json().catch(() => null) as any;
const code = String(j?.code ?? "");
const captcha = code === "CAPTCHA_DETECTED";
const upstreamFail = code === "UPSTREAM_FAILED" || code === "SERP_UPSTREAM_FAILED";
if (captcha) {
s.captcha++; s.captchaCount++;
if (!s.firstCaptchaAt) s.firstCaptchaAt = new Date().toISOString();
s.lastCaptchaAt = Date.now();
enterJail(s, "captcha", Date.now());
rec({ ts: new Date().toISOString(), fp: s.fp, group: s.group, rate: s.ratePerH, q, status: res.status, kind: "captcha", code, sent: s.sent, ms: Date.now() - t });
} else if (code === "SERP_POOL_EMPTY" && String(j?.message ?? "").includes("不存在")) {
// 指纹可能已重建:标记终止,不再打该号
s.gone = true;
rec({ ts: new Date().toISOString(), fp: s.fp, group: s.group, rate: s.ratePerH, q, status: res.status, kind: "gone", code, sent: s.sent, ms: Date.now() - t });
} else {
s.httpErr++;
if (upstreamFail) s.consecGate++; // 上游硬失败也按狱情累计(连接被掐/硬封信号)
rec({ ts: new Date().toISOString(), fp: s.fp, group: s.group, rate: s.ratePerH, q, status: res.status, kind: "httpErr", code, sent: s.sent, ms: Date.now() - t });
if (s.consecGate >= 5) enterJail(s, "gate", Date.now());
}
return;
}
if (gate) {
s.gate++;
s.consecGate++;
if (s.consecGate >= 5) enterJail(s, "gate", Date.now());
rec({ ts: new Date().toISOString(), fp: s.fp, group: s.group, rate: s.ratePerH, q, status: res.status, kind: "gate", sent: s.sent, ms: Date.now() - t });
return;
}
// 200 干净页:出狱/计数复位
s.consecGate = 0;
s.ok++;
if (s.probeMode) {
s.probeMode = false;
s.recoveries.push({
kind: s.jailKind ?? "unknown",
flaggedAt: new Date(Math.max(s.lastCaptchaAt, s.jailStart)).toISOString(),
recoveredAt: new Date().toISOString(),
minutes: +((Date.now() - Math.max(s.lastCaptchaAt, s.jailStart)) / 60000).toFixed(1),
});
console.log(`[${new Date().toISOString()}] ${s.fp}(${s.group}) 出狱(${s.jailKind}):${s.recoveries.at(-1)!.minutes} 分钟`);
}
rec({ ts: new Date().toISOString(), fp: s.fp, group: s.group, rate: s.ratePerH, q, status: res.status, kind: "ok200", sent: s.sent, ms: Date.now() - t });
} catch (e) {
s.netErr++;
rec({ ts: new Date().toISOString(), fp: s.fp, group: s.group, rate: s.ratePerH, q, status: 0, kind: "netErr", error: String(e).slice(0, 160), sent: s.sent, ms: Date.now() - t });
}
}
/** # 函数
* 单号主循环:正常速率 = 3600s/rate × 抖动(0.7~1.3);探测模式 = 1 次/10 分钟。
*/
async function runSlot(s: Slot): Promise<void> {
const endAt = Date.now() + HOURS * 3600_000;
while (Date.now() < endAt && !s.gone) {
await fire(s);
if (s.gone) break;
let waitMs: number;
if (s.probeMode) waitMs = 300_000 * (0.9 + Math.random() * 0.2);
else waitMs = (3600_000 / s.ratePerH) * (0.7 + Math.random() * 0.6);
const remain = endAt - Date.now();
if (remain <= 0) break;
await Bun.sleep(Math.min(waitMs, remain));
}
console.log(`[${new Date().toISOString()}] ${s.fp}(${s.group}) 循环结束 sent=${s.sent} ok=${s.ok} gate=${s.gate} captcha=${s.captcha} gone=${s.gone}`);
}
/** # 函数
* 每 30 分钟写活跃汇总(容量测量主读数)。
*/
function snapshot(): Record<string, unknown> {
const now = new Date().toISOString();
const byGroup: Record<string, unknown> = {};
for (const s of slots) {
const g = byGroup[s.group] as any ?? (byGroup[s.group] = {});
g[s.fp] = {
ratePerH: s.ratePerH, sent: s.sent, ok: s.ok, gate: s.gate, captcha: s.captcha,
httpErr: s.httpErr, netErr: s.netErr, firstCaptchaAt: s.firstCaptchaAt,
probeMode: s.probeMode, jails: s.jails, jailKind: s.jailKind,
recoveries: s.recoveries, gone: s.gone,
cleanCapacity: s.ok, // 干净 200 数 = 该号当前累计可用容量
};
}
const snap = { at: now, hoursElapsed: +((Date.now() - startedAt) / 3600_000).toFixed(2), groups: byGroup };
fs.writeFileSync(LIVE, JSON.stringify(snap, null, 2));
return snap;
}
/** # 主流程:并行起所有号;每 30 分钟摘要;到时汇总收尾 **/
const startedAt = Date.now();
fs.writeFileSync(JSONL, "");
console.log(`开始:label=${LABEL} hours=${HOURS} 号数=${slots.length} 规格=${SPEC}`);
for (const s of slots) void runSlot(s);
const snapTimer = setInterval(() => {
const snap = snapshot();
const lines = Object.entries(snap.groups as any).map(([g, fps]: any) => {
const parts = Object.entries(fps).map(([fp, v]: any) => `${fp}@${v.ratePerH}/h: ok=${v.cleanCapacity} gate=${v.gate} cap=${v.captcha} jails=${v.jails}${v.gone ? " GONE" : ""}${v.probeMode ? " PROBING" : ""}${v.recoveries.length ? ` rec=[${v.recoveries.map((r: any) => `${r.kind}:${r.minutes}m`).join(",")}]` : ""}`);
return `[${g}] ${parts.join(" | ")}`;
});
console.log(`[${snap.at}] ${lines.join("\n")}`);
}, 30 * 60_000);
const endTimer = setTimeout(() => {
clearInterval(snapTimer);
const snap = snapshot();
fs.writeFileSync(`${DATA}${LABEL}-final.json`, JSON.stringify(snap, null, 2));
console.log("测试结束,final 已写:" + `${DATA}${LABEL}-final.json`);
process.exit(0);
}, HOURS * 3600_000);
endTimer.unref?.();