← back to Nationalrealestate
src/ingest/redfin_tracker.ts
204 lines
/**
* Redfin market-tracker ingest — county + metro + state, gzipped full-history TSVs.
* NEVER buffers a whole file: createReadStream -> gunzip -> readline, flat memory.
* Filters: PROPERTY_TYPE='All Residential', IS_SEASONALLY_ADJUSTED=false (bare boolean),
* PERIOD_DURATION=30, PERIOD_BEGIN within the last 36 months.
* County match = REGION name ("Los Angeles County, CA") -> Gazetteer name+state;
* metro match = TABLE_ID (verified to be the CBSA code) -> region.cbsa_code;
* state match = STATE_CODE. Overrides consulted first; unmatched recorded + skipped.
* Run with --cached to reuse data/downloads copies during dev.
*/
import { createReadStream } from 'node:fs';
import { createGunzip } from 'node:zlib';
import { createInterface } from 'node:readline';
import { openRun, closeRun, download } from './run.ts';
import {
loadRegionMaps, loadOverrides, buildMetroMatcher, UnmatchedCollector,
upsertMetricRows, upsertSourceRegionMap, monthsAgoCutoff,
type MetricRow, type RegionMaps, normName,
} from './crosswalk.ts';
import { pool } from '../../db/pool.ts';
const BASE = 'https://redfin-public-data.s3.us-west-2.amazonaws.com/redfin_market_tracker';
const FILES = [
{ url: `${BASE}/county_market_tracker.tsv000.gz`, file: 'redfin_county.tsv.gz', level: 'county' as const },
{ url: `${BASE}/redfin_metro_market_tracker.tsv000.gz`, file: 'redfin_metro.tsv.gz', level: 'metro' as const },
{ url: `${BASE}/state_market_tracker.tsv000.gz`, file: 'redfin_state.tsv.gz', level: 'state' as const },
];
const METRIC_COLS: [string, string][] = [
['median_sale_price', 'median_sale_price'],
['median_sale_price_yoy', 'median_sale_price_yoy'],
['homes_sold', 'homes_sold'],
['inventory', 'inventory'],
['new_listings', 'new_listings'],
['median_dom', 'dom'],
['avg_sale_to_list', 'sale_to_list'],
['months_of_supply', 'months_of_supply'],
];
const unq = (s: string) => (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"' ? s.slice(1, -1) : s);
interface Ctx {
maps: RegionMaps;
matchMetro: ReturnType<typeof buildMetroMatcher>;
overrides: Record<string, string>;
unmatched: UnmatchedCollector;
cutoff: string;
runId: number;
}
async function processFile(path: string, level: 'county' | 'metro' | 'state', ctx: Ctx): Promise<{ upserted: number; skipped: number }> {
const rl = createInterface({ input: createReadStream(path).pipe(createGunzip()), crlfDelay: Infinity });
let header: string[] | null = null;
let idx: Record<string, number> = {};
let lineNo = 0;
let buffer: MetricRow[] = [];
let mapBuffer: { source: string; sourceRegionId: string; regionId: number; matchMethod: string }[] = [];
let upserted = 0;
let skipped = 0;
const seenUnmatched = new Set<string>();
const regionCache = new Map<string, { regionId: number; method: string } | null>();
const flush = async () => {
if (mapBuffer.length) { await upsertSourceRegionMap(mapBuffer); mapBuffer = []; }
if (buffer.length) { upserted += await upsertMetricRows(buffer, ctx.runId); buffer = []; }
};
for await (const raw of rl) {
lineNo++;
if (lineNo % 100_000 === 0) console.log(`[redfin:${level}] ${lineNo.toLocaleString()} lines, ${upserted.toLocaleString()} rows upserted`);
if (!raw) continue;
const f = raw.split('\t');
if (!header) {
header = f.map((h) => unq(h).toLowerCase());
header.forEach((h, i) => { idx[h] = i; });
for (const req of ['period_begin', 'property_type', 'region', 'table_id']) {
if (!(req in idx)) throw new Error(`redfin ${level}: missing column ${req}`);
}
continue;
}
const get = (c: string) => (idx[c] !== undefined ? unq(f[idx[c]] ?? '') : '');
if (get('property_type') !== 'All Residential') continue;
const sa = get('is_seasonally_adjusted');
if (sa && sa !== 'false' && sa !== 'f') continue;
const dur = get('period_duration');
if (dur && dur !== '30') continue;
const periodBegin = get('period_begin');
if (!periodBegin || periodBegin < ctx.cutoff) continue;
const region = get('region');
const tableId = get('table_id');
const stateCode = get('state_code');
const cacheKey = tableId || region;
let match = regionCache.get(cacheKey);
if (match === undefined) {
match = resolveRegion(level, region, tableId, stateCode, ctx);
regionCache.set(cacheKey, match);
if (match) mapBuffer.push({ source: 'redfin', sourceRegionId: tableId || region, regionId: match.regionId, matchMethod: match.method });
}
if (!match) {
skipped++;
if (!seenUnmatched.has(cacheKey)) {
seenUnmatched.add(cacheKey);
ctx.unmatched.add(`redfin_${level}`, { tableId, region, stateCode });
}
continue;
}
const period = periodBegin.slice(0, 7) + '-01';
for (const [colName, metric] of METRIC_COLS) {
if (idx[colName] === undefined) continue;
const v = unq(f[idx[colName]] ?? '');
if (v === '') continue;
const num = Number(v);
if (!Number.isFinite(num)) continue;
buffer.push({ regionId: match.regionId, source: 'redfin', metric, period, value: num });
}
if (buffer.length >= 5000) await flush();
}
await flush();
console.log(`[redfin:${level}] done: ${lineNo.toLocaleString()} lines, ${upserted.toLocaleString()} rows upserted, ${skipped} row-skips (unmatched regions: ${seenUnmatched.size})`);
return { upserted, skipped };
}
function resolveRegion(level: string, region: string, tableId: string, stateCode: string, ctx: Ctx): { regionId: number; method: string } | null {
const ovr = ctx.overrides[tableId] || ctx.overrides[region];
if (ovr) {
const r = ctx.maps.byCanonicalKey.get(ovr);
if (r) return { regionId: r.id, method: 'override' };
}
if (level === 'state') {
const r = ctx.maps.byStateCode.get(stateCode);
return r ? { regionId: r.id, method: 'state' } : null;
}
if (level === 'metro') {
const byCbsa = ctx.maps.byCbsa.get(tableId);
if (byCbsa) return { regionId: byCbsa.id, method: 'cbsa' };
const name = region.replace(/\s+metro area$/i, '');
const m = ctx.matchMetro(name);
return m ? { regionId: m.region.id, method: m.method } : null;
}
// county: "Los Angeles County, CA" -> name + state
const comma = region.lastIndexOf(',');
if (comma < 0) return null;
const name = region.slice(0, comma);
const st = region.slice(comma + 1).trim();
const r = ctx.maps.byCountyNameState.get(normName(name) + '|' + st);
return r ? { regionId: r.id, method: 'name' } : null;
}
async function main() {
const cached = process.argv.includes('--cached');
const runId = await openRun('redfin', BASE);
try {
const maps = await loadRegionMaps();
const ctx: Ctx = {
maps,
matchMetro: buildMetroMatcher(maps),
overrides: loadOverrides('redfin'),
unmatched: new UnmatchedCollector(),
cutoff: monthsAgoCutoff(36),
runId,
};
console.log(`[redfin] cutoff period_begin >= ${ctx.cutoff}`);
let totalUpserted = 0;
let totalSkipped = 0;
const hashes: string[] = [];
for (const spec of FILES) {
console.log(`[redfin] 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 = await processFile(path, spec.level, ctx);
totalUpserted += r.upserted;
totalSkipped += r.skipped;
}
ctx.unmatched.flush();
const uc = ctx.unmatched.count('redfin_county');
const um = ctx.unmatched.count('redfin_metro');
const us = ctx.unmatched.count('redfin_state');
console.log(`[redfin] TOTAL upserted=${totalUpserted} unmatched: county=${uc} metro=${um} state=${us}`);
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: `unmatched_county=${uc} unmatched_metro=${um} unmatched_state=${us}`,
fileHash: hashes.join(' '),
});
}
} catch (e) {
console.error('[redfin] FAILED', e);
await closeRun(runId, 'failed', { notes: String((e as Error).message || e) });
process.exitCode = 1;
} finally {
await pool.end();
}
}
main();