← back to Nationalrealestate
src/ingest/listings/engine.ts
122 lines
/**
* M-B3 firm↔listing pilot ingest engine.
*
* Per site: openRun → robots gate → discover ≤CAP listing URLs from the public sitemap →
* for each: robots-recheck → politeFetch (UA + 2–4s jitter) → parse FACTS ONLY from
* JSON-LD → resolve firm_id by source host → resolve county region_id by centroid →
* upsert into `listing` (UNIQUE(source, source_id)) → closeRun.
*
* Fail-loud: 0 parsed listings = failed run + non-zero exit. A robots-disallowed listings
* path = the whole site is SKIPPED and recorded as a failed run with an explicit note.
*
* npm run ingest:listings -- coldwellbanker
* npm run ingest:listings -- realtytexas
* npm run ingest:listings -- all
*/
import { pool, query } from '../../../db/pool.ts';
import { openRun, closeRun } from '../run.ts';
import type { ListingAdapter } from './types.ts';
import { coldwellbanker } from './coldwellbanker.ts';
import { realtytexas } from './realtytexas.ts';
import { politeFetch, robotsGate, resolveFirmByHost, resolveRegion } from './shared.ts';
const ADAPTERS: ListingAdapter[] = [coldwellbanker, realtytexas];
const CAP = 50; // pilot cap per site
async function upsertListing(
source: string,
f: import('./types.ts').ListingFacts,
firmId: number | null,
regionId: number | null,
): Promise<void> {
await query(
`INSERT INTO listing
(source, source_id, firm_id, region_id, address, price, beds, baths, sqft, lat, lng, url, status, last_seen)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13, NOW())
ON CONFLICT (source, source_id) DO UPDATE SET
firm_id = COALESCE(EXCLUDED.firm_id, listing.firm_id),
region_id= COALESCE(EXCLUDED.region_id, listing.region_id),
address = EXCLUDED.address,
price = EXCLUDED.price,
beds = COALESCE(EXCLUDED.beds, listing.beds),
baths = COALESCE(EXCLUDED.baths, listing.baths),
sqft = COALESCE(EXCLUDED.sqft, listing.sqft),
lat = COALESCE(EXCLUDED.lat, listing.lat),
lng = COALESCE(EXCLUDED.lng, listing.lng),
url = EXCLUDED.url,
status = EXCLUDED.status,
last_seen= NOW()`,
[source, f.sourceId, firmId, regionId, f.address, f.price, f.beds, f.baths, f.sqft, f.lat, f.lng, f.url, f.status],
);
}
async function runAdapter(a: ListingAdapter): Promise<{ upserted: number; skipped: number }> {
const runId = await openRun(a.source, `https://${a.host}`);
console.log(`[${a.source}] robots gate…`);
try {
const allowed = await robotsGate(a.host);
if (!allowed(a.listingsPathHint)) {
const note = `robots.txt disallows listings path ${a.listingsPathHint} — SKIPPED`;
console.log(`[${a.source}] ${note}`);
await closeRun(runId, 'failed', { upserted: 0, skipped: 0, notes: note });
return { upserted: 0, skipped: 0 };
}
const firmId = await resolveFirmByHost(a.host);
console.log(`[${a.source}] firm_id resolved: ${firmId ?? 'NONE'}`);
const urls = await a.discover(CAP);
console.log(`[${a.source}] discovered ${urls.length} listing URLs (cap ${CAP})`);
let upserted = 0;
let skipped = 0;
for (const url of urls) {
const path = new URL(url).pathname;
if (!allowed(path)) { skipped++; continue; }
const { ok, status, body } = await politeFetch(url);
if (!ok) { console.log(`[${a.source}] fetch ${status} ${url}`); skipped++; continue; }
const facts = a.parse(url, body);
if (!facts) { skipped++; continue; }
const regionId = await resolveRegion(facts.state, facts.lat, facts.lng);
await upsertListing(a.source, facts, firmId, regionId);
upserted++;
if (upserted % 5 === 0) console.log(`[${a.source}] upserted ${upserted}…`);
}
if (upserted === 0) {
const note = `0 listings parsed from ${urls.length} URLs — structure drift or bot-block`;
console.error(`[${a.source}] FAIL: ${note}`);
await closeRun(runId, 'failed', { upserted: 0, skipped, notes: note });
return { upserted: 0, skipped };
}
await closeRun(runId, 'ok', { upserted, skipped, notes: `firm_id=${firmId}` });
console.log(`[${a.source}] DONE upserted=${upserted} skipped=${skipped} firm_id=${firmId}`);
return { upserted, skipped };
} catch (e: any) {
await closeRun(runId, 'failed', { notes: String(e.message || e) });
throw e;
}
}
async function main() {
const arg = (process.argv[2] || '').toLowerCase();
const targets = arg === 'all' || !arg ? ADAPTERS : ADAPTERS.filter((a) => a.source === arg);
if (!targets.length) {
console.error(`unknown site '${arg}'. known: ${ADAPTERS.map((a) => a.source).join(', ')}, all`);
process.exit(2);
}
let total = 0;
for (const a of targets) {
const r = await runAdapter(a);
total += r.upserted;
}
console.log(`\n[listings] total upserted across ${targets.length} site(s): ${total}`);
await pool.end();
if (total === 0) process.exit(1);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});