Files
SerpMax/stress/serp-rate-test.ts
T

120 lines
6.0 KiB
TypeScript

/** # 脚本说明
* SERP go-fetch 速率-门壳实验发射器(在 admin 服务器本机运行,消除跨网噪声):
* SERP_TOKEN=xxx bun serp-rate-test.ts --rate 2 --minutes 5 --label r2
* 以指定速率发射 GET /api/serp/html/search?...&runtime=fetch(强制 go-fetch,绕过令牌桶,
* 用于测「该速率下 Google 是否发门壳」),逐条记录 HTTP 状态 / x-serp-gate / attempts / 耗时,
* 写 data/<label>.jsonl 与 data/<label>-summary.json。
* 门壳判定:x-serp-gate:1(admin 全候选门壳兜底);HTTP!=200 记错误码;网络异常单独计。
*/
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 RATE = Number(argOf("--rate", "1"));
const MINUTES = Number(argOf("--minutes", "5"));
const LABEL = argOf("--label", `r${RATE}`);
/** # 成员变量 runtime 覆写:fetch=强制 go-fetch(绕令牌桶,测速率阈值);auto=双模式生产路由 **/
const RUNTIME = argOf("--runtime", "fetch");
const TOKEN = process.env.SERP_TOKEN ?? "";
if (!TOKEN) { console.error("缺少 SERP_TOKEN 环境变量"); process.exit(1); }
/** # 成员变量 输出目录(data/,相对脚本) **/
const DATA = new URL("./data/", import.meta.url).pathname;
fs.mkdirSync(DATA, { recursive: true });
const JSONL = `${DATA}${LABEL}.jsonl`;
const SUMMARY = `${DATA}${LABEL}-summary.json`;
fs.writeFileSync(JSONL, "");
/** # 成员变量 长尾查询词表(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 = 0;
for (let i = combos.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [combos[i], combos[j]] = [combos[j], combos[i]]; }
function nextQuery(): string { const q = combos[qi % combos.length]; qi++; return q; }
/** # 成员变量 统计桶 **/
const stat = { sent: 0, ok200: 0, gate: 0, httpErr: 0, netErr: 0, inflight: 0, done: 0 };
const totalMsArr: number[] = [];
const clientMsArr: number[] = [];
/** # 函数
* 单次记录:追加 JSONL 与统计。
*/
function rec(o: Record<string, unknown>): void {
fs.appendFileSync(JSONL, JSON.stringify(o) + "\n");
stat.done++;
if (typeof o.totalMs === "number" && o.totalMs > 0) totalMsArr.push(o.totalMs);
if (typeof o.ms === "number") clientMsArr.push(o.ms);
}
/** # 函数
* 发射单次搜索(runtime=fetch 强制 go-fetch)。60s 客户端超时;失败分类:HTTP!=200=错误,
* x-serp-gate:1=门壳兜底,catch=网络异常。
*/
async function fire(): Promise<void> {
const q = nextQuery();
const url = `${BASE}/api/serp/html/search?q=${encodeURIComponent(q)}&num=10${RUNTIME ? `&runtime=${RUNTIME}` : ""}`;
const t = Date.now();
stat.sent++; stat.inflight++;
try {
const res = await fetch(url, { headers: { authorization: `Bearer ${TOKEN}` }, signal: AbortSignal.timeout(90000) });
const gate = res.headers.get("x-serp-gate");
const attempts = res.headers.get("x-serp-attempts");
const totalMs = Number(res.headers.get("x-serp-total-ms") ?? 0);
const nodeMs = Number(res.headers.get("x-serp-node-ms") ?? 0);
const profile = res.headers.get("x-serp-profile") ?? "";
const node = res.headers.get("x-serp-node") ?? "";
let code = ""; let msg = "";
if (!res.ok) {
const j = await res.json().catch(() => null) as any;
code = String(j?.code ?? ""); msg = String(j?.message ?? "").slice(0, 120);
}
const kind = !res.ok ? "httpErr" : gate === "1" ? "gate" : "ok200";
stat[kind]++;
rec({ q, status: res.status, kind, gate: gate === "1", attempts, totalMs, nodeMs, profile, node, code, msg, ms: Date.now() - t });
} catch (e) {
stat.netErr++;
rec({ q, status: 0, kind: "netErr", error: String(e).slice(0, 160), ms: Date.now() - t });
} finally {
stat.inflight--;
}
}
function pct(arr: number[], p: number): number {
if (arr.length === 0) return 0;
const s = [...arr].sort((a, b) => a - b);
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
}
/** # 主流程:恒定速率发射(每 tick 发 1 发),结束后等在途清空并写摘要 **/
console.log(`开始:rate=${RATE}/s minutes=${MINUTES} label=${LABEL}`);
const interval = 1000 / RATE;
const endAt = Date.now() + MINUTES * 60_000;
const MAX_INFLIGHT = Math.max(30, Math.ceil(RATE) * 20);
while (Date.now() < endAt) {
const t0 = Date.now();
if (stat.inflight < MAX_INFLIGHT) void fire();
const wait = interval - (Date.now() - t0);
if (wait > 0) await Bun.sleep(wait);
}
const drainDeadline = Date.now() + 70000;
while (stat.inflight > 0 && Date.now() < drainDeadline) await Bun.sleep(500);
const summary = {
label: LABEL, rate: RATE, minutes: MINUTES,
sent: stat.sent, ok200: stat.ok200, gate: stat.gate, httpErr: stat.httpErr, netErr: stat.netErr,
gateRate: stat.done ? +(stat.gate / stat.done * 100).toFixed(2) : 0,
errRate: stat.done ? +((stat.httpErr + stat.netErr) / stat.done * 100).toFixed(2) : 0,
totalMs_p50: pct(totalMsArr, 50), totalMs_p95: pct(totalMsArr, 95),
clientMs_p50: pct(clientMsArr, 50), clientMs_p95: pct(clientMsArr, 95),
at: new Date().toISOString(),
};
fs.writeFileSync(SUMMARY, JSON.stringify(summary, null, 2));
console.log(JSON.stringify(summary, null, 2));