← back to Homesonspec
packages/shared/src/canonical.ts
75 lines
import { createHash } from "node:crypto";
/**
* Canonical inventory identity. The same physical home may surface on a
* builder's national site, a division site, a community page, or a feed —
* this key collapses them to one record. Components are normalized before
* hashing so cosmetic differences ("123 Oak St." vs "123 oak street")
* don't fork identities.
*/
export interface CanonicalHints {
builderSlug: string;
communityName?: string | null;
address?: string | null;
lotNumber?: string | null;
builderInventoryId?: string | null;
lat?: number | null;
lon?: number | null;
planName?: string | null;
}
const STREET_ABBREVIATIONS: Record<string, string> = {
street: "st", "st.": "st",
avenue: "ave", "ave.": "ave", av: "ave",
boulevard: "blvd", "blvd.": "blvd",
drive: "dr", "dr.": "dr",
lane: "ln", "ln.": "ln",
road: "rd", "rd.": "rd",
court: "ct", "ct.": "ct",
circle: "cir", "cir.": "cir",
place: "pl", "pl.": "pl",
trail: "trl", "trl.": "trl",
parkway: "pkwy", "pkwy.": "pkwy",
terrace: "ter", "ter.": "ter",
way: "way",
north: "n", south: "s", east: "e", west: "w",
"n.": "n", "s.": "s", "e.": "e", "w.": "w",
};
/** Lowercase, strip punctuation, collapse whitespace, abbreviate street words. */
export function normalizeAddress(address: string | null | undefined): string {
if (!address) return "";
return address
.toLowerCase()
.replace(/[#,.]/g, " ")
.split(/\s+/)
.filter(Boolean)
.map((word) => STREET_ABBREVIATIONS[word] ?? word)
.join(" ")
.trim();
}
function norm(value: string | null | undefined): string {
return (value ?? "").toLowerCase().replace(/\s+/g, " ").trim();
}
/** Round to 5 decimals (~1.1m) so GPS jitter doesn't fork identities. */
function latlon5(value: number | null | undefined): string {
if (value === null || value === undefined || Number.isNaN(value)) return "";
return value.toFixed(5);
}
export function canonicalKey(hints: CanonicalHints): string {
const parts = [
norm(hints.builderSlug),
norm(hints.communityName),
normalizeAddress(hints.address),
norm(hints.lotNumber),
norm(hints.builderInventoryId),
latlon5(hints.lat),
latlon5(hints.lon),
norm(hints.planName),
];
return createHash("sha256").update(parts.join("|")).digest("hex");
}