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.

129 lines
3.5 KiB

import type { MothershipAddressCandidate } from "@/modules/address/types";
export type StreetDirection = "N" | "S" | "E" | "W";
const DIRECTION_FULL: Record<StreetDirection, string> = {
N: "North",
S: "South",
E: "East",
W: "West",
};
/** 解析门牌后方位(单字母或 North/East 全称) */
export function parseStreetDirection(street: string): StreetDirection | null {
const trimmed = street.trim();
const letter = trimmed.match(/^\d+\s+([NSEW])\b/i);
if (letter) {
return letter[1]!.toUpperCase() as StreetDirection;
}
for (const dir of Object.keys(DIRECTION_FULL) as StreetDirection[]) {
if (new RegExp(`\\b${DIRECTION_FULL[dir]}\\b`, "i").test(trimmed)) {
return dir;
}
}
return null;
}
/**
* 将「600 W 7th St」展开为 MotherShip 更常识别的「600 West 7th Street」。
* 仅处理门牌后单字母方位;无法识别时返回 null。
*/
export function expandDirectionalStreet(street: string): string | null {
const trimmed = street.trim();
const match = trimmed.match(/^(\d+)\s+([NSEW])\s+(.+)$/i);
if (!match) {
return null;
}
const dir = match[2]!.toUpperCase() as StreetDirection;
const fullDir = DIRECTION_FULL[dir];
if (!fullDir) {
return null;
}
let rest = match[3]!.trim();
rest = rest.replace(/\bSt\.?$/i, "Street");
return `${match[1]} ${fullDir} ${rest}`.replace(/\s+/g, " ").trim();
}
function labelHasDirection(
label: string,
dir: StreetDirection,
): boolean {
const lower = label.toLowerCase();
const full = DIRECTION_FULL[dir].toLowerCase();
if (lower.includes(full)) {
return true;
}
return new RegExp(`\\b\\d+\\s+${dir}\\b`, "i").test(label);
}
/** 按用户草稿对 MotherShip 候选打分(方位一致优先,相反方位重罚) */
export function scoreMothershipCandidateForDraft(
candidate: MothershipAddressCandidate,
draft: { street: string; city: string; state: string },
): number {
const label = `${candidate.display_label} ${candidate.street}`.toLowerCase();
let score = 0;
const streetNum = draft.street.match(/^\d+/)?.[0];
if (streetNum && label.includes(streetNum)) {
score += 12;
}
const city = draft.city.trim().toLowerCase();
if (city && label.includes(city)) {
score += 6;
}
const state = draft.state.trim().toLowerCase();
if (state && label.includes(state)) {
score += 3;
}
const draftDir = parseStreetDirection(draft.street);
if (draftDir) {
const opposite: Record<StreetDirection, StreetDirection> = {
N: "S",
S: "N",
E: "W",
W: "E",
};
if (labelHasDirection(label, draftDir)) {
score += 18;
}
if (labelHasDirection(label, opposite[draftDir])) {
score -= 28;
}
const expanded = expandDirectionalStreet(draft.street);
if (expanded && label.includes(expanded.toLowerCase())) {
score += 14;
}
if (
expanded &&
/\b\d+\s+[nsew]\s+/i.test(draft.street) &&
label.includes(expanded.toLowerCase()) === false &&
labelHasDirection(label, draftDir)
) {
score -= 8;
}
}
if (/\bstreet\b/i.test(label)) {
score += 4;
}
if (/\bbridge\b/.test(label) && !/\bbridge\b/i.test(draft.street)) {
score -= 20;
}
return score;
}
export function sortMothershipCandidatesForDraft(
candidates: MothershipAddressCandidate[],
draft: { street: string; city: string; state: string },
): MothershipAddressCandidate[] {
return [...candidates].sort(
(a, b) =>
scoreMothershipCandidateForDraft(b, draft) -
scoreMothershipCandidateForDraft(a, draft),
);
}