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.
175 lines
4.8 KiB
175 lines
4.8 KiB
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<string>();
|
|
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);
|
|
}
|