← back to Nationalrealestate
src/ingest/crosswalk.ts
201 lines
/**
* Region-crosswalk helpers shared by ingest jobs.
* Loads the full region table once, builds lookup maps, consults
* data/crosswalk-overrides.json before any fuzzy match, and collects
* unmatched source regions into data/crosswalk-unmatched.json (skipped, never guessed).
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { query } from '../../db/pool.ts';
const __dirname = dirname(fileURLToPath(import.meta.url));
export const DATA_DIR = join(__dirname, '..', '..', 'data');
const OVERRIDES_PATH = join(DATA_DIR, 'crosswalk-overrides.json');
const UNMATCHED_PATH = join(DATA_DIR, 'crosswalk-unmatched.json');
export interface RegionRow {
id: number;
region_type: string;
canonical_key: string;
name: string;
state_code: string | null;
fips: string | null;
cbsa_code: string | null;
}
export interface RegionMaps {
byFips: Map<string, RegionRow>;
byCbsa: Map<string, RegionRow>;
byStateCode: Map<string, RegionRow>;
byCountyNameState: Map<string, RegionRow>; // "los angeles county|CA"
byCanonicalKey: Map<string, RegionRow>;
metros: RegionRow[];
}
export async function loadRegionMaps(): Promise<RegionMaps> {
const r = await query<RegionRow>(
`SELECT id, region_type, canonical_key, name, state_code, fips, cbsa_code FROM region`,
);
const maps: RegionMaps = {
byFips: new Map(),
byCbsa: new Map(),
byStateCode: new Map(),
byCountyNameState: new Map(),
byCanonicalKey: new Map(),
metros: [],
};
for (const row of r.rows) {
maps.byCanonicalKey.set(row.canonical_key, row);
if (row.region_type === 'county' && row.fips) {
maps.byFips.set(row.fips, row);
if (row.state_code) maps.byCountyNameState.set(normName(row.name) + '|' + row.state_code, row);
}
if (row.region_type === 'metro') {
maps.metros.push(row);
if (row.cbsa_code) maps.byCbsa.set(row.cbsa_code, row);
}
if (row.region_type === 'state' && row.state_code) maps.byStateCode.set(row.state_code, row);
}
return maps;
}
export function normName(s: string): string {
// strip diacritics via NFD + combining-mark range (Dona Ana vs Doña Ana)
return s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/\s+/g, ' ').trim();
}
export function loadOverrides(source: string): Record<string, string> {
if (!existsSync(OVERRIDES_PATH)) return {};
const all = JSON.parse(readFileSync(OVERRIDES_PATH, 'utf8'));
return all[source] || {};
}
/** Metro matcher for Gazetteer CBSA names like "Los Angeles-Long Beach-Anaheim, CA Metro Area". */
export function buildMetroMatcher(maps: RegionMaps) {
const exact = new Map<string, RegionRow>(); // "los angeles-long beach-anaheim, ca"
const cityState = new Map<string, RegionRow>(); // "los angeles|CA" (each -segment × each state)
for (const m of maps.metros) {
const stripped = m.name.replace(/\s+(Metro|Micro)\s+Area$/i, '');
exact.set(normName(stripped), m);
const comma = stripped.lastIndexOf(',');
if (comma < 0) continue;
const cityPart = stripped.slice(0, comma);
const states = stripped.slice(comma + 1).trim().split('-');
const firstCity = cityPart.split(/[-/]/)[0]; // "Louisville/Jefferson County" -> "Louisville"
for (const st of states) {
const key = normName(firstCity) + '|' + st.trim();
if (!cityState.has(key)) cityState.set(key, m); // first (largest CBSA seeded order) wins; ties skipped by has-check
}
}
/** name = e.g. "Los Angeles, CA" (Zillow style). Returns region or null. */
return function matchMetro(name: string): { region: RegionRow; method: string } | null {
const comma = name.lastIndexOf(',');
const ex = exact.get(normName(name));
if (ex) return { region: ex, method: 'cbsa_name_exact' };
if (comma < 0) return null;
const city = name.slice(0, comma);
const st = name.slice(comma + 1).trim();
const cs = cityState.get(normName(city) + '|' + st);
if (cs) return { region: cs, method: 'cbsa_city_prefix' };
return null;
};
}
export class UnmatchedCollector {
private items: Record<string, unknown[]> = {};
private seen = new Set<string>();
add(source: string, item: unknown) {
const key = source + '|' + JSON.stringify(item);
if (this.seen.has(key)) return; // same region unmatched by multiple files in one run (zhvi+zori)
this.seen.add(key);
(this.items[source] ||= []).push(item);
}
count(source: string): number {
return (this.items[source] || []).length;
}
/** Merge this run's unmatched sets into data/crosswalk-unmatched.json (replaces per source key). */
flush() {
mkdirSync(DATA_DIR, { recursive: true });
let existing: Record<string, unknown[]> = {};
if (existsSync(UNMATCHED_PATH)) {
try { existing = JSON.parse(readFileSync(UNMATCHED_PATH, 'utf8')); } catch { existing = {}; }
}
for (const [k, v] of Object.entries(this.items)) existing[k] = v;
writeFileSync(UNMATCHED_PATH, JSON.stringify(existing, null, 2));
}
}
export interface MetricRow {
regionId: number;
source: string;
metric: string;
period: string; // YYYY-MM-DD
value: number;
}
/** Batched multi-VALUES upsert into metric_series. Dedupes within the batch (last wins). */
export async function upsertMetricRows(rows: MetricRow[], ingestRunId: number, batchSize = 1000): Promise<number> {
const deduped = new Map<string, MetricRow>();
for (const r of rows) deduped.set(`${r.regionId}|${r.source}|${r.metric}|${r.period}`, r);
const all = [...deduped.values()];
let n = 0;
for (let i = 0; i < all.length; i += batchSize) {
const batch = all.slice(i, i + batchSize);
const params: unknown[] = [];
const values = batch.map((r, j) => {
params.push(r.regionId, r.source, r.metric, r.period, r.value, ingestRunId);
const b = j * 6;
return `($${b + 1},$${b + 2},$${b + 3},$${b + 4},$${b + 5},$${b + 6})`;
});
await query(
`INSERT INTO metric_series (region_id, source, metric, period, value, ingest_run_id)
VALUES ${values.join(',')}
ON CONFLICT (region_id, source, metric, period)
DO UPDATE SET value = EXCLUDED.value, ingest_run_id = EXCLUDED.ingest_run_id`,
params,
);
n += batch.length;
}
return n;
}
/** Batched upsert into source_region_map. */
export async function upsertSourceRegionMap(
entries: { source: string; sourceRegionId: string; regionId: number; matchMethod: string }[],
batchSize = 1000,
): Promise<void> {
const deduped = new Map<string, (typeof entries)[number]>();
for (const e of entries) deduped.set(`${e.source}|${e.sourceRegionId}`, e);
const all = [...deduped.values()];
for (let i = 0; i < all.length; i += batchSize) {
const batch = all.slice(i, i + batchSize);
const params: unknown[] = [];
const values = batch.map((e, j) => {
params.push(e.source, e.sourceRegionId, e.regionId, e.matchMethod);
const b = j * 4;
return `($${b + 1},$${b + 2},$${b + 3},$${b + 4})`;
});
await query(
`INSERT INTO source_region_map (source, source_region_id, region_id, match_method)
VALUES ${values.join(',')}
ON CONFLICT (source, source_region_id)
DO UPDATE SET region_id = EXCLUDED.region_id, match_method = EXCLUDED.match_method`,
params,
);
}
}
/** First-of-month normalization: '2026-06-30' -> '2026-06-01'. */
export function firstOfMonth(dateStr: string): string {
return dateStr.slice(0, 7) + '-01';
}
/** First-of-month cutoff N months back from now. */
export function monthsAgoCutoff(months: number): string {
const d = new Date();
d.setUTCDate(1);
d.setUTCMonth(d.getUTCMonth() - (months - 1));
return d.toISOString().slice(0, 7) + '-01';
}