You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
chajia/scripts/capture-flock-network-har.ts

368 lines
12 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* Flock Network 抓包 POC真实打开 get-a-quote录 HAR + 旁路 XHR 日志,
* 分析是否存在可直连的 quote/rate JSON 接口(对齐 MotherShip axel/quote 思路)。
*
* 用法:
* npm run capture:flock-har
* RPA_HEADLESS=false npm run capture:flock-har
*/
import fs from "node:fs";
import path from "node:path";
import type { Page, Request, Response } from "playwright";
import {
analyzeFlockHarEntries,
analyzeFlockNetworkLog,
mergeAndDedupeCandidates,
summarizeFlockHosts,
verdictFromCandidates,
type FlockHarFile,
type FlockNetworkLogEntry,
type FlockQuoteApiCandidate,
} from "@/lib/flock/har-analyze";
import { resolveFlockQuoteAccount } from "@/lib/flock/resolve-account";
import { getFlockQuoteUrl, getFlockQuoteWaitMs } from "@/lib/flock/env";
import { createRpaBrowserContext } from "@/lib/rpa/browser-context";
import { launchRpaBrowser } from "@/lib/rpa/browser-launch";
import {
formatExternalSiteNavigationError,
gotoWithResilience,
isTransientNavigationError,
} from "@/lib/rpa/page-goto";
import { DEFAULT_FLOCK_DEMO } from "@/workers/rpa/flock/demo-input";
import {
clickFlockQuoteSubmit,
fillFlockQuoteForm,
waitForFlockQuoteForm,
} from "@/workers/rpa/flock/form-interactions";
import {
completeFlockRegistrationIfNeeded,
isFlockQuoteResultsPage,
isFlockRegistrationPage,
} from "@/workers/rpa/flock/register-account";
import { extractFlockQuoteCards } from "@/workers/rpa/flock/quote-extract";
import {
flockQuoteLimitMessage,
flockRegistrationErrorMessage,
} from "@/workers/rpa/flock/page-state";
import type { FlockQuoteInput } from "@/workers/rpa/flock/types";
const ROOT = process.cwd();
const OUT_DIR = path.join(ROOT, ".rpa", "flock-network-poc");
function stamp(): string {
const d = new Date();
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
}
function demoToInput(): FlockQuoteInput {
return {
pickupDate: DEFAULT_FLOCK_DEMO.pickupDateDisplay,
pickupZip: DEFAULT_FLOCK_DEMO.pickupZip,
deliveryZip: DEFAULT_FLOCK_DEMO.deliveryZip,
palletCount: DEFAULT_FLOCK_DEMO.palletCount,
totalWeightLb: DEFAULT_FLOCK_DEMO.totalWeightLb,
lengthIn: DEFAULT_FLOCK_DEMO.lengthIn,
widthIn: DEFAULT_FLOCK_DEMO.widthIn,
heightIn: DEFAULT_FLOCK_DEMO.heightIn,
};
}
function shouldCaptureRequest(req: Request): boolean {
const type = req.resourceType();
if (type === "document" || type === "stylesheet" || type === "image" || type === "font" || type === "media") {
return false;
}
const url = req.url();
if (/\.(js|css|png|jpe?g|gif|svg|woff2?|map)(\?|$)/i.test(url)) {
return false;
}
return type === "xhr" || type === "fetch" || type === "other" || type === "websocket";
}
async function attachNetworkLogger(
page: Page,
sink: FlockNetworkLogEntry[],
): Promise<void> {
page.on("response", async (res: Response) => {
try {
const req = res.request();
if (!shouldCaptureRequest(req)) return;
const headers = res.headers();
const mime = headers["content-type"] ?? "";
let responseBodyPreview: string | undefined;
let requestBodyPreview: string | undefined;
try {
const post = req.postData();
if (post) requestBodyPreview = post.slice(0, 4_000);
} catch {
/* ignore */
}
if (/json|text|javascript|graphql/i.test(mime) || mime === "") {
try {
const body = await res.text();
responseBodyPreview = body.slice(0, 8_000);
} catch {
/* body may be unavailable */
}
}
sink.push({
method: req.method(),
url: req.url(),
status: res.status(),
mimeType: mime,
requestBodyPreview,
responseBodyPreview,
resourceType: req.resourceType(),
});
} catch {
/* ignore listener errors */
}
});
}
function writeSummaryMarkdown(opts: {
outPath: string;
harPath: string;
logPath: string;
verdict: string;
quoteOk: boolean;
quoteError?: string;
prices?: string;
hosts: Array<{ host: string; count: number; methods: string[] }>;
candidates: FlockQuoteApiCandidate[];
}): void {
const lines = [
"# Flock Network 抓包 POC 结论",
"",
`- 时间: ${new Date().toISOString()}`,
`- HAR: \`${opts.harPath}\``,
`- 旁路日志: \`${opts.logPath}\``,
`- DOM 抓价成功: ${opts.quoteOk ? "是" : "否"}${opts.prices ? `${opts.prices}` : ""}`,
opts.quoteError ? `- DOM 失败原因: ${opts.quoteError}` : "",
`- **裁决: \`${opts.verdict}\`**`,
"",
"## 非静态主机 Top",
"",
...opts.hosts.slice(0, 20).map(
(h) => `- \`${h.host}\` ×${h.count} methods=${h.methods.join(",")}`,
),
"",
"## quote/rate 候选接口",
"",
];
if (opts.candidates.length === 0) {
lines.push(
"_未发现 score≥4 的 JSON quote/rate 候选。当前路径更可能依赖 DOM SSR/内嵌数据,与 MotherShip `axel/quote` 不同。_",
"",
);
} else {
for (const [i, c] of opts.candidates.slice(0, 15).entries()) {
lines.push(
`### ${i + 1}. score=${c.score} ${c.method} ${c.status}`,
"",
`- URL: \`${c.urlWithoutQuery}\``,
`- source: ${c.source}`,
c.mimeType ? `- mime: ${c.mimeType}` : "",
c.requestPreview
? `- request preview:\n\`\`\`\n${c.requestPreview}\n\`\`\``
: "",
c.responsePreview
? `- response preview:\n\`\`\`\n${c.responsePreview}\n\`\`\``
: "",
"",
);
}
}
lines.push(
"## 含义",
"",
"- `stable_json_quote_api_found`: 存在高分 JSON 报价接口,可继续做 Direct 风格 POC",
"- `json_api_candidates_unclear`: 有 API 候选但契约不明,需人工核对 HAR",
"- `no_quote_json_api_dom_only`: 未见稳定 quote JSON继续 DOM RPA 或更深抓包",
"",
);
fs.writeFileSync(
opts.outPath,
lines.filter((l) => l !== "").join("\n") + "\n",
"utf8",
);
}
async function main(): Promise<void> {
if (process.env.RPA_MOCK_MODE === "true") {
console.error("capture:flock-har 需要真实站点;请勿设 RPA_MOCK_MODE=true");
process.exit(2);
}
fs.mkdirSync(OUT_DIR, { recursive: true });
const id = stamp();
const harPath = path.join(OUT_DIR, `flock-${id}.har`);
const logPath = path.join(OUT_DIR, `flock-${id}-network.json`);
const summaryJson = path.join(OUT_DIR, `flock-${id}-summary.json`);
const summaryMd = path.join(OUT_DIR, `flock-${id}-summary.md`);
const input = demoToInput();
const account = resolveFlockQuoteAccount(input);
const url = getFlockQuoteUrl();
const headless = process.env.RPA_HEADLESS !== "false";
const quoteWaitMs = getFlockQuoteWaitMs();
const networkLog: FlockNetworkLogEntry[] = [];
console.log(`[capture-flock-har] url=${url}`);
console.log(`[capture-flock-har] sample ${input.pickupZip}${input.deliveryZip} ${input.palletCount}pl ${input.totalWeightLb}lb`);
console.log(`[capture-flock-har] email=${account.email} headless=${headless}`);
console.log(`[capture-flock-har] har → ${harPath}`);
const browser = await launchRpaBrowser({ headless, incognito: true });
let quoteOk = false;
let quoteError: string | undefined;
let prices: string | undefined;
try {
const context = await createRpaBrowserContext(browser, {
recordHar: {
path: harPath,
mode: "full",
content: "embed",
},
});
const page = await context.newPage();
await attachNetworkLogger(page, networkLog);
try {
await gotoWithResilience(page, url);
} catch (error) {
const raw = error instanceof Error ? error.message : String(error);
quoteError = isTransientNavigationError({ message: raw })
? formatExternalSiteNavigationError("Flock Freight 官网", {
message: raw,
})
: raw;
await context.close();
throw new Error(quoteError);
}
if (!(await waitForFlockQuoteForm(page))) {
quoteError =
(await flockQuoteLimitMessage(page)) ??
"QUOTE_ENTRY_UNAVAILABLE表单未加载";
await context.close();
throw new Error(quoteError);
}
const filled = await fillFlockQuoteForm(page, input);
if (!filled.ok) {
quoteError = filled.error;
await context.close();
throw new Error(quoteError ?? "填表失败");
}
if (!(await clickFlockQuoteSubmit(page))) {
quoteError = "STRUCT_CHANGE未找到提交按钮";
await context.close();
throw new Error(quoteError);
}
const deadline = Date.now() + quoteWaitMs;
while (Date.now() < deadline) {
if (await flockQuoteLimitMessage(page)) break;
if (await isFlockRegistrationPage(page)) {
const ok = await completeFlockRegistrationIfNeeded(page, account);
if (!ok) {
quoteError =
(await flockRegistrationErrorMessage(page)) ??
"进入注册页但无法完成注册";
break;
}
}
if (await isFlockQuoteResultsPage(page)) break;
await page.waitForTimeout(800);
}
if (!quoteError) {
const limitMsg = await flockQuoteLimitMessage(page);
if (limitMsg) {
quoteError = limitMsg;
} else if (await isFlockRegistrationPage(page)) {
quoteError =
(await flockRegistrationErrorMessage(page)) ??
"停在注册页,未到结果卡";
} else {
const { quotes, reference } = await extractFlockQuoteCards(page);
if (quotes.length >= 2) {
quoteOk = true;
prices = quotes.map((q) => `${q.label}:$${q.totalUsd}`).join(", ");
console.log(`[capture-flock-har] DOM OK ref=${reference ?? "-"} ${prices}`);
} else {
quoteError = `仅抓到 ${quotes.length} 档 DOM 报价(仍可分析 HAR`;
}
}
}
// 必须 close context 才能落盘 HAR
await context.close();
} finally {
await browser.close();
}
fs.writeFileSync(logPath, JSON.stringify(networkLog, null, 2), "utf8");
let harEntries: NonNullable<NonNullable<FlockHarFile["log"]>["entries"]> = [];
if (fs.existsSync(harPath)) {
const har = JSON.parse(fs.readFileSync(harPath, "utf8")) as FlockHarFile;
harEntries = har.log?.entries ?? [];
}
const fromHar = analyzeFlockHarEntries(harEntries);
const fromLog = analyzeFlockNetworkLog(networkLog);
const candidates = mergeAndDedupeCandidates([fromHar, fromLog]);
const hosts = summarizeFlockHosts(harEntries);
const verdict = verdictFromCandidates(candidates);
const payload = {
capturedAt: new Date().toISOString(),
harPath,
logPath,
quoteOk,
quoteError: quoteError ?? null,
prices: prices ?? null,
harEntryCount: harEntries.length,
networkLogCount: networkLog.length,
verdict,
hosts: hosts.slice(0, 30),
candidates: candidates.slice(0, 20),
};
fs.writeFileSync(summaryJson, JSON.stringify(payload, null, 2), "utf8");
writeSummaryMarkdown({
outPath: summaryMd,
harPath,
logPath,
verdict,
quoteOk,
quoteError,
prices,
hosts,
candidates,
});
console.log(`[capture-flock-har] HAR entries=${payload.harEntryCount} networkLog=${payload.networkLogCount}`);
console.log(`[capture-flock-har] candidates=${candidates.length} verdict=${verdict}`);
console.log(`[capture-flock-har] summary → ${summaryMd}`);
for (const c of candidates.slice(0, 8)) {
console.log(` [${c.score}] ${c.method} ${c.status} ${c.urlWithoutQuery}`);
}
// 抓包本身成功即 exit 0无候选时 exit 3 便于脚本判定
if (!fs.existsSync(harPath) || payload.harEntryCount < 5) {
process.exit(1);
}
if (verdict === "no_quote_json_api_dom_only") {
process.exit(3);
}
}
main().catch((err) => {
console.error("[capture-flock-har] FAIL:", err instanceof Error ? err.message : err);
process.exit(1);
});