|
|
/**
|
|
|
* Flock Network POC:从 HAR / 旁路日志中识别 quote/rate 类 JSON 接口
|
|
|
*/
|
|
|
export type FlockHarRequest = {
|
|
|
url: string;
|
|
|
method: string;
|
|
|
};
|
|
|
|
|
|
export type FlockHarResponse = {
|
|
|
status: number;
|
|
|
content?: { mimeType?: string; text?: string; size?: number };
|
|
|
};
|
|
|
|
|
|
export type FlockHarEntry = {
|
|
|
request: FlockHarRequest;
|
|
|
response: FlockHarResponse;
|
|
|
time?: number;
|
|
|
};
|
|
|
|
|
|
export type FlockHarFile = {
|
|
|
log?: { entries?: FlockHarEntry[] };
|
|
|
};
|
|
|
|
|
|
/** 旁路网络日志(捕获脚本额外落盘) */
|
|
|
export type FlockNetworkLogEntry = {
|
|
|
method: string;
|
|
|
url: string;
|
|
|
status: number;
|
|
|
mimeType?: string;
|
|
|
requestBodyPreview?: string;
|
|
|
responseBodyPreview?: string;
|
|
|
resourceType?: string;
|
|
|
};
|
|
|
|
|
|
const STATIC_OR_TELEMETRY =
|
|
|
/\.(js|css|png|jpe?g|gif|svg|woff2?|ttf|map|ico)(\?|$)|google-analytics|googletagmanager|segment\.|mixpanel|sentry|hotjar|fullstory|datadog|newrelic|intercom|facebook\.com|doubleclick|amplitude|heap\.io|clarity\.ms|cdn\.|static\./i;
|
|
|
|
|
|
const API_HOST_HINT =
|
|
|
/flockfreight|flock\.|api\.|graphql|gql|supabase|firebase|azure|cloudfront|amazonaws/i;
|
|
|
|
|
|
const QUOTE_URL_HINT =
|
|
|
/quote|rate|pricing|estimate|shipment|freight|pallet|zip|offer|price|cart|booking|land|inquiry/i;
|
|
|
|
|
|
const QUOTE_BODY_HINTS = [
|
|
|
"flockdirect",
|
|
|
"flock_direct",
|
|
|
"prices from",
|
|
|
"transit",
|
|
|
"reference",
|
|
|
"quotes generated",
|
|
|
"rate",
|
|
|
"price",
|
|
|
"total",
|
|
|
"quote",
|
|
|
"pallet",
|
|
|
"zip",
|
|
|
];
|
|
|
|
|
|
export function stripUrlQuery(url: string): string {
|
|
|
try {
|
|
|
const u = new URL(url);
|
|
|
return `${u.origin}${u.pathname}`;
|
|
|
} catch {
|
|
|
return url.split("?")[0] ?? url;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export function isLikelyStaticOrTelemetry(url: string): boolean {
|
|
|
return STATIC_OR_TELEMETRY.test(url);
|
|
|
}
|
|
|
|
|
|
export function scoreFlockQuoteCandidate(input: {
|
|
|
url: string;
|
|
|
method: string;
|
|
|
status: number;
|
|
|
mimeType?: string;
|
|
|
responseText?: string;
|
|
|
requestText?: string;
|
|
|
}): number {
|
|
|
const { url, method, status, mimeType, responseText, requestText } = input;
|
|
|
if (status < 200 || status >= 400) return 0;
|
|
|
if (isLikelyStaticOrTelemetry(url)) return 0;
|
|
|
|
|
|
let score = 0;
|
|
|
const urlLower = url.toLowerCase();
|
|
|
const methodUpper = method.toUpperCase();
|
|
|
|
|
|
if (API_HOST_HINT.test(urlLower)) score += 2;
|
|
|
if (QUOTE_URL_HINT.test(urlLower)) score += 3;
|
|
|
if (methodUpper === "POST" || methodUpper === "PUT" || methodUpper === "PATCH") {
|
|
|
score += 2;
|
|
|
}
|
|
|
if (/json|graphql/i.test(mimeType ?? "")) score += 2;
|
|
|
if (responseText?.trim().startsWith("{") || responseText?.trim().startsWith("[")) {
|
|
|
score += 1;
|
|
|
}
|
|
|
|
|
|
const blob = `${responseText ?? ""}\n${requestText ?? ""}`.toLowerCase();
|
|
|
for (const hint of QUOTE_BODY_HINTS) {
|
|
|
if (blob.includes(hint)) score += 2;
|
|
|
}
|
|
|
if (/\$\s*\d/.test(blob) || /"amount"|"totalUsd"|"finalPrice"/.test(blob)) {
|
|
|
score += 3;
|
|
|
}
|
|
|
if (/flockdirect|standard line|prices from/i.test(blob)) {
|
|
|
score += 4;
|
|
|
}
|
|
|
|
|
|
return score;
|
|
|
}
|
|
|
|
|
|
export type FlockQuoteApiCandidate = {
|
|
|
method: string;
|
|
|
url: string;
|
|
|
urlWithoutQuery: string;
|
|
|
status: number;
|
|
|
score: number;
|
|
|
mimeType?: string;
|
|
|
requestPreview?: string;
|
|
|
responsePreview?: string;
|
|
|
source: "har" | "network-log";
|
|
|
};
|
|
|
|
|
|
function preview(text: string | undefined, max = 600): string | undefined {
|
|
|
if (!text?.trim()) return undefined;
|
|
|
const t = text.trim();
|
|
|
return t.length <= max ? t : `${t.slice(0, max)}…`;
|
|
|
}
|
|
|
|
|
|
export function analyzeFlockHarEntries(
|
|
|
entries: FlockHarEntry[],
|
|
|
): FlockQuoteApiCandidate[] {
|
|
|
const out: FlockQuoteApiCandidate[] = [];
|
|
|
for (const entry of entries) {
|
|
|
const url = entry.request.url;
|
|
|
const text = entry.response.content?.text;
|
|
|
const score = scoreFlockQuoteCandidate({
|
|
|
url,
|
|
|
method: entry.request.method,
|
|
|
status: entry.response.status,
|
|
|
mimeType: entry.response.content?.mimeType,
|
|
|
responseText: text,
|
|
|
});
|
|
|
if (score < 4) continue;
|
|
|
out.push({
|
|
|
method: entry.request.method,
|
|
|
url,
|
|
|
urlWithoutQuery: stripUrlQuery(url),
|
|
|
status: entry.response.status,
|
|
|
score,
|
|
|
mimeType: entry.response.content?.mimeType,
|
|
|
responsePreview: preview(text),
|
|
|
source: "har",
|
|
|
});
|
|
|
}
|
|
|
return out.sort((a, b) => b.score - a.score);
|
|
|
}
|
|
|
|
|
|
export function analyzeFlockNetworkLog(
|
|
|
logs: FlockNetworkLogEntry[],
|
|
|
): FlockQuoteApiCandidate[] {
|
|
|
const out: FlockQuoteApiCandidate[] = [];
|
|
|
for (const entry of logs) {
|
|
|
const score = scoreFlockQuoteCandidate({
|
|
|
url: entry.url,
|
|
|
method: entry.method,
|
|
|
status: entry.status,
|
|
|
mimeType: entry.mimeType,
|
|
|
responseText: entry.responseBodyPreview,
|
|
|
requestText: entry.requestBodyPreview,
|
|
|
});
|
|
|
if (score < 4) continue;
|
|
|
out.push({
|
|
|
method: entry.method,
|
|
|
url: entry.url,
|
|
|
urlWithoutQuery: stripUrlQuery(entry.url),
|
|
|
status: entry.status,
|
|
|
score,
|
|
|
mimeType: entry.mimeType,
|
|
|
requestPreview: preview(entry.requestBodyPreview),
|
|
|
responsePreview: preview(entry.responseBodyPreview),
|
|
|
source: "network-log",
|
|
|
});
|
|
|
}
|
|
|
return out.sort((a, b) => b.score - a.score);
|
|
|
}
|
|
|
|
|
|
export function mergeAndDedupeCandidates(
|
|
|
lists: FlockQuoteApiCandidate[][],
|
|
|
): FlockQuoteApiCandidate[] {
|
|
|
const map = new Map<string, FlockQuoteApiCandidate>();
|
|
|
for (const list of lists) {
|
|
|
for (const c of list) {
|
|
|
const key = `${c.method}|${c.urlWithoutQuery}`;
|
|
|
const prev = map.get(key);
|
|
|
if (!prev || c.score > prev.score) {
|
|
|
map.set(key, c);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
return [...map.values()].sort((a, b) => b.score - a.score);
|
|
|
}
|
|
|
|
|
|
export function summarizeFlockHosts(entries: FlockHarEntry[]): Array<{
|
|
|
host: string;
|
|
|
count: number;
|
|
|
methods: string[];
|
|
|
}> {
|
|
|
const hosts = new Map<string, { count: number; methods: Set<string> }>();
|
|
|
for (const e of entries) {
|
|
|
if (isLikelyStaticOrTelemetry(e.request.url)) continue;
|
|
|
let host = "invalid";
|
|
|
try {
|
|
|
host = new URL(e.request.url).host;
|
|
|
} catch {
|
|
|
/* keep invalid */
|
|
|
}
|
|
|
const cur = hosts.get(host) ?? { count: 0, methods: new Set<string>() };
|
|
|
cur.count += 1;
|
|
|
cur.methods.add(e.request.method.toUpperCase());
|
|
|
hosts.set(host, cur);
|
|
|
}
|
|
|
return [...hosts.entries()]
|
|
|
.map(([host, v]) => ({
|
|
|
host,
|
|
|
count: v.count,
|
|
|
methods: [...v.methods].sort(),
|
|
|
}))
|
|
|
.sort((a, b) => b.count - a.count);
|
|
|
}
|
|
|
|
|
|
export type FlockNetworkPocVerdict =
|
|
|
| "stable_json_quote_api_found"
|
|
|
| "json_api_candidates_unclear"
|
|
|
| "no_quote_json_api_dom_only";
|
|
|
|
|
|
export function verdictFromCandidates(
|
|
|
candidates: FlockQuoteApiCandidate[],
|
|
|
): FlockNetworkPocVerdict {
|
|
|
const strong = candidates.filter((c) => c.score >= 10);
|
|
|
if (strong.length > 0) return "stable_json_quote_api_found";
|
|
|
if (candidates.length > 0) return "json_api_candidates_unclear";
|
|
|
return "no_quote_json_api_dom_only";
|
|
|
}
|