← back to Nationalrealestate
src/ingest/zillow_research.ts
161 lines
/**
* Zillow Research ingest — ZHVI + ZORI, county + metro, last 36 monthly columns.
* Wide CSV: one row per region, one column per month (month-end headers,
* normalized to first-of-month on store). County crosswalk = StateCodeFIPS+MunicipalCodeFIPS
* -> region.fips; metro crosswalk = overrides -> exact CBSA-name -> primary-city prefix.
* Unmatched metros are recorded + skipped, never guessed.
* Run with --cached to reuse data/downloads copies during dev.
*/
import { openRun, closeRun, download } from './run.ts';
import {
loadRegionMaps, loadOverrides, buildMetroMatcher, UnmatchedCollector,
upsertMetricRows, upsertSourceRegionMap, firstOfMonth,
type MetricRow, type RegionMaps,
} from './crosswalk.ts';
import { readFileSync } from 'node:fs';
import { pool } from '../../db/pool.ts';
const BASE = 'https://files.zillowstatic.com/research/public_csvs';
const FILES = [
{ url: `${BASE}/zhvi/County_zhvi_uc_sfrcondo_tier_0.33_0.67_sm_sa_month.csv`, file: 'zillow_county_zhvi.csv', metric: 'zhvi', level: 'county' as const },
{ url: `${BASE}/zhvi/Metro_zhvi_uc_sfrcondo_tier_0.33_0.67_sm_sa_month.csv`, file: 'zillow_metro_zhvi.csv', metric: 'zhvi', level: 'metro' as const },
{ url: `${BASE}/zori/County_zori_uc_sfrcondomfr_sm_sa_month.csv`, file: 'zillow_county_zori.csv', metric: 'zori', level: 'county' as const },
{ url: `${BASE}/zori/Metro_zori_uc_sfrcondomfr_sm_sa_month.csv`, file: 'zillow_metro_zori.csv', metric: 'zori', level: 'metro' as const },
];
const MONTHS = 36;
function parseCsvLine(line: string): string[] {
const out: string[] = [];
let field = '';
let inQ = false;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (inQ) {
if (c === '"') {
if (line[i + 1] === '"') { field += '"'; i++; }
else inQ = false;
} else field += c;
} else if (c === '"') inQ = true;
else if (c === ',') { out.push(field); field = ''; }
else field += c;
}
out.push(field);
return out;
}
interface FileResult { rows: MetricRow[]; maps: { source: string; sourceRegionId: string; regionId: number; matchMethod: string }[]; parsed: number; skipped: number; }
function processFile(
path: string, metric: string, level: 'county' | 'metro',
maps: RegionMaps, matchMetro: ReturnType<typeof buildMetroMatcher>,
overrides: Record<string, string>, unmatched: UnmatchedCollector,
): FileResult {
const text = readFileSync(path, 'utf8');
const lines = text.split('\n');
const header = parseCsvLine(lines[0].replace(/\r$/, ''));
const col = (name: string) => header.indexOf(name);
const dateCols: { idx: number; period: string }[] = [];
header.forEach((h, i) => { if (/^\d{4}-\d{2}-\d{2}$/.test(h)) dateCols.push({ idx: i, period: firstOfMonth(h) }); });
const useCols = dateCols.slice(-MONTHS);
if (useCols.length === 0) throw new Error(`no date columns found in ${path}`);
const iRegionId = col('RegionID');
const iRegionName = col('RegionName');
const iStateFips = col('StateCodeFIPS');
const iMuniFips = col('MunicipalCodeFIPS');
const res: FileResult = { rows: [], maps: [], parsed: 0, skipped: 0 };
for (let li = 1; li < lines.length; li++) {
const line = lines[li].replace(/\r$/, '');
if (!line) continue;
const f = parseCsvLine(line);
const regionName = f[iRegionName];
let regionId: number | null = null;
let method = '';
if (level === 'county') {
const fips = f[iStateFips].padStart(2, '0') + f[iMuniFips].padStart(3, '0');
const region = maps.byFips.get(fips);
if (region) { regionId = region.id; method = 'fips'; }
} else {
const ovr = overrides[regionName];
if (ovr) {
const region = maps.byCanonicalKey.get(ovr);
if (region) { regionId = region.id; method = 'override'; }
}
if (regionId === null && regionName !== 'United States') {
const m = matchMetro(regionName);
if (m) { regionId = m.region.id; method = m.method; }
}
}
if (regionId === null) {
res.skipped++;
if (level === 'metro' && regionName !== 'United States') {
unmatched.add('zillow_metro', { regionId: f[iRegionId], regionName });
} else if (level === 'county') {
unmatched.add('zillow_county', { regionId: f[iRegionId], regionName, fips: f[iStateFips] + '/' + f[iMuniFips] });
}
continue;
}
res.maps.push({ source: 'zillow', sourceRegionId: f[iRegionId], regionId, matchMethod: method });
for (const { idx, period } of useCols) {
const v = f[idx];
if (v === '' || v === undefined) continue;
const num = Number(v);
if (!Number.isFinite(num)) continue;
res.rows.push({ regionId, source: 'zillow', metric, period, value: num });
}
res.parsed++;
}
return res;
}
async function main() {
const cached = process.argv.includes('--cached');
const runId = await openRun('zillow', BASE);
try {
const maps = await loadRegionMaps();
const matchMetro = buildMetroMatcher(maps);
const overrides = loadOverrides('zillow');
const unmatched = new UnmatchedCollector();
let totalUpserted = 0;
let totalSkipped = 0;
let totalParsed = 0;
const hashes: string[] = [];
for (const spec of FILES) {
console.log(`[zillow] downloading ${spec.file}${cached ? ' (cached ok)' : ''}`);
const { path, sha256 } = await download(spec.url, spec.file, { reuseCached: cached });
hashes.push(`${spec.file}:${sha256.slice(0, 12)}`);
const r = processFile(path, spec.metric, spec.level, maps, matchMetro, overrides, unmatched);
console.log(`[zillow] ${spec.file}: ${r.parsed} regions matched, ${r.skipped} skipped, ${r.rows.length} metric rows`);
await upsertSourceRegionMap(r.maps);
totalUpserted += await upsertMetricRows(r.rows, runId);
totalSkipped += r.skipped;
totalParsed += r.parsed;
}
unmatched.flush();
const unmatchedMetro = unmatched.count('zillow_metro');
const unmatchedCounty = unmatched.count('zillow_county');
console.log(`[zillow] TOTAL upserted=${totalUpserted} regions=${totalParsed} unmatched: county=${unmatchedCounty} metro=${unmatchedMetro}`);
if (totalUpserted === 0) {
await closeRun(runId, 'failed', { notes: '0 rows parsed — format drift?' });
process.exitCode = 1;
} else {
await closeRun(runId, 'ok', {
upserted: totalUpserted, skipped: totalSkipped,
notes: `regions=${totalParsed} unmatched_county=${unmatchedCounty} unmatched_metro=${unmatchedMetro}`,
fileHash: hashes.join(' '),
});
}
} catch (e) {
console.error('[zillow] FAILED', e);
await closeRun(runId, 'failed', { notes: String((e as Error).message || e) });
process.exitCode = 1;
} finally {
await pool.end();
}
}
main();