import { existsSync, readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; import type { AddressLookupInput } from "@/modules/address/types"; import type { ManualInteractionRecord } from "@/workers/rpa/selection-trace"; export type GoldStandardClickTarget = { textPreview: string; className: string; tagName: string; dataOptionId: string | null; side: "pickup" | "delivery"; sourceDir: string; }; type GoldFile = { pickup?: { selectionClicks: ManualInteractionRecord[] }; delivery?: { selectionClicks: ManualInteractionRecord[] }; }; const GOLD_ROOT = join(process.cwd(), ".rpa", "address-event-compare"); function normalizeText(text: string): string { return text.replace(/\s+/g, " ").trim().toLowerCase(); } function stripCountrySuffix(label: string): string { return label.replace(/,\s*USA\s*$/i, "").trim(); } /** 金样本 textPreview 与目标 description 的匹配分(越高越优先) */ export function scoreGoldTextMatch( description: string, query: AddressLookupInput, textPreview: string, ): number { const desc = normalizeText(description); const preview = normalizeText(textPreview); if (!desc || !preview) { return 0; } if (desc === preview) { return 100; } if (preview.includes(desc) || desc.includes(preview)) { return 90; } const descNoCountry = normalizeText(stripCountrySuffix(description)); const previewNoCountry = normalizeText(stripCountrySuffix(textPreview)); if (descNoCountry === previewNoCountry) { return 85; } if ( previewNoCountry.includes(descNoCountry) || descNoCountry.includes(previewNoCountry) ) { return 75; } const street = normalizeText(query.street); const city = normalizeText(query.city); let score = 0; if (street && preview.includes(street)) { score += 40; } if (city && preview.includes(city)) { score += 30; } const state = normalizeText(query.state); if (state) { if (state.length === 2) { const stateRe = new RegExp( `(,|\\b)${state.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\b|,|$)`, ); if (stateRe.test(preview)) { score += 20; } } else if (preview.includes(state)) { score += 20; } } const streetHead = street.split(",")[0]?.trim() ?? ""; if (score < 75 && streetHead.length >= 8 && !preview.includes(streetHead)) { return 0; } const streetNum = query.street.match(/^\d+/)?.[0]; if (score < 75 && streetNum && !preview.includes(streetNum.toLowerCase())) { return 0; } return score; } function toTarget( click: ManualInteractionRecord, side: "pickup" | "delivery", sourceDir: string, ): GoldStandardClickTarget { return { textPreview: click.target.textPreview, className: click.target.className, tagName: click.target.tagName, dataOptionId: click.target.dataOptionId, side, sourceDir, }; } function loadGoldTargetsFromDir(dir: string): GoldStandardClickTarget[] { const path = join(dir, "manual-interactions-gold.json"); if (!existsSync(path)) { return []; } let data: GoldFile; try { data = JSON.parse(readFileSync(path, "utf8")) as GoldFile; } catch { return []; } const out: GoldStandardClickTarget[] = []; for (const click of data.pickup?.selectionClicks ?? []) { out.push(toTarget(click, "pickup", dir)); } for (const click of data.delivery?.selectionClicks ?? []) { out.push(toTarget(click, "delivery", dir)); } return out; } /** 扫描 .rpa/address-event-compare 下全部金样本点击目标 */ export function loadAllGoldStandardTargets(): GoldStandardClickTarget[] { if (!existsSync(GOLD_ROOT)) { return []; } const targets: GoldStandardClickTarget[] = []; for (const name of readdirSync(GOLD_ROOT, { withFileTypes: true })) { if (!name.isDirectory()) { continue; } targets.push(...loadGoldTargetsFromDir(join(GOLD_ROOT, name.name))); } return targets; } /** * 按 description/query 匹配最相关的金样本点击目标(同侧优先)。 * 用于运行时复现人工点选 DOM 特征。 */ export function findGoldStandardTargets( description: string, query: AddressLookupInput, side: "pickup" | "delivery", minScore = 50, ): GoldStandardClickTarget[] { const all = loadAllGoldStandardTargets(); const scored = all .map((target) => ({ target, score: scoreGoldTextMatch(description, query, target.textPreview) + (target.side === side ? 5 : 0), })) .filter((item) => item.score >= minScore) .sort((a, b) => b.score - a.score); const seen = new Set(); const unique: GoldStandardClickTarget[] = []; for (const { target } of scored) { const key = `${target.textPreview}|${target.className}`; if (seen.has(key)) { continue; } seen.add(key); unique.push(target); } return unique.slice(0, 5); }