← back to Nationalrealestate
src/ingest/places/seed.ts
213 lines
/**
* M-P1 — Google Places DISCOVERY seed (feeds the free firm pipeline).
*
* Places is used as a SEED/pointer, not a stored dataset:
* pick stalest metro → official Places API (v1 searchText) → for each hit,
* persist ONLY place_id (the one cacheable field) + create/link a `firm` row
* (source='google_places', source_id=place_id) carrying its website → the
* existing discover:firms / crawl:firms jobs then enrich firm_site from the
* firm's OWN public site (first-party, storable). Idempotent: place_id dedupe.
*
* COST GUARD — this is the only metered source in the app. A monthly hard cap
* (places_quota) STOPS all calls before we cross the Google free-tier line, so
* steady-state spend is $0. Every call is logged to the cost-tracker ledger.
* Runs DRY by default; set PLACES_SEED_LIVE=1 to make real calls.
*
* npm run ingest:places # stalest metro, dry-run unless PLACES_SEED_LIVE=1
* npm run ingest:places -- --metro=31080 --live
*
* Env: GOOGLE_PLACES_API_KEY (required for live), PLACES_MONTHLY_CAP=1500,
* PLACES_SEED_LIVE=0, PLACES_QUERIES=<comma templates>.
*/
import 'dotenv/config';
import { appendFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { pool, query } from '../../../db/pool.ts';
import { openRun, closeRun } from '../run.ts';
const SOURCE = 'google_places';
const API = 'https://places.googleapis.com/v1/places:searchText';
const KEY = process.env.GOOGLE_PLACES_API_KEY || '';
const LIVE = process.env.PLACES_SEED_LIVE === '1' || process.argv.includes('--live');
const CAP = Number(process.env.PLACES_MONTHLY_CAP || 1500);
const SEED_TARGET = Number(process.env.PLACES_SEED_TARGET || 60); // seeds/metro before it's "covered" and we move on
// Text-Search (Pro SKU) list price if we ever exceeded the free tier — for the ledger only.
const RATE_PER_CALL = 0.032;
const QUERY_TEMPLATES = (process.env.PLACES_QUERIES ||
'real estate agency in {m};commercial real estate brokerage in {m};apartment leasing office in {m}')
.split(';').map(s => s.trim()).filter(Boolean);
const FIELD_MASK = 'places.id,places.displayName,places.websiteUri,places.formattedAddress,places.location';
function ym(): string {
// Date.now/new Date() are fine at runtime here (not a workflow script).
const d = new Date();
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
}
function logCost(calls: number, note: string): void {
try {
const dir = join(homedir(), '.claude');
mkdirSync(dir, { recursive: true });
const entry = {
ts: new Date().toISOString(), skill: 'usre-places-seed', provider: 'google_places',
units: calls, unit: 'searchText_call', rate: RATE_PER_CALL,
cost: 0, list_cost_if_billed: +(calls * RATE_PER_CALL).toFixed(4),
note: `${note} (free-tier, capped ${CAP}/mo)`,
};
appendFileSync(join(dir, 'cost-ledger.jsonl'), JSON.stringify(entry) + '\n');
} catch { /* ledger is best-effort */ }
}
async function callsUsed(): Promise<number> {
const r = await query<{ calls_used: number }>(
`SELECT calls_used FROM places_quota WHERE year_month = $1`, [ym()]);
return r.rows[0]?.calls_used ?? 0;
}
async function bumpQuota(n: number): Promise<void> {
await query(
`INSERT INTO places_quota (year_month, calls_used) VALUES ($1, $2)
ON CONFLICT (year_month) DO UPDATE
SET calls_used = places_quota.calls_used + $2, updated_at = NOW()`,
[ym(), n]);
}
/** Stalest metro: fewest seeds first, then least-recently seeded. */
async function pickMetro(): Promise<{ id: number; name: string; key: string } | null> {
const argIdx = process.argv.findIndex(a => a.startsWith('--metro='));
if (argIdx > -1) {
const cbsa = process.argv[argIdx].split('=')[1];
const r = await query<{ id: number; name: string; canonical_key: string }>(
`SELECT id, name, canonical_key FROM region WHERE region_type='metro' AND cbsa_code=$1 LIMIT 1`, [cbsa]);
if (r.rows.length) return { id: r.rows[0].id, name: r.rows[0].name, key: r.rows[0].canonical_key };
}
// Geographic sweep: state is embedded in the metro name ("…, CA Metro Area",
// "…, OR-WA Metro Area") since region.state_code is null. Tier west→east and
// seed UNDER-COVERED metros (seeds < SEED_TARGET) lowest-tier-first; once every
// West Coast metro hits the target the sweep advances east on its own. When all
// are covered it falls back to re-seeding the stalest (keeps data fresh).
const r = await query<{ id: number; name: string; canonical_key: string }>(
`WITH m AS (
SELECT r.id, r.name, r.canonical_key,
COALESCE((SELECT COUNT(*) FROM places_seed s WHERE s.region_id = r.id),0) AS seeds,
(SELECT MAX(last_seen) FROM places_seed s WHERE s.region_id = r.id) AS ls,
substring(r.name from ',\\s+([A-Z]{2}(-[A-Z]{2})*)\\s') AS st
FROM region r WHERE r.region_type = 'metro')
SELECT id, name, canonical_key FROM m
ORDER BY
CASE WHEN seeds < $1 THEN
CASE WHEN st ~ '(^|-)(CA|OR|WA)($|-)' THEN 0 -- West Coast
WHEN st ~ '(^|-)(NV|AZ|ID|UT)($|-)' THEN 1 -- Interior West
WHEN st ~ '(^|-)(MT|WY|CO|NM)($|-)' THEN 2 -- Mountain
ELSE 5 END -- rest of US
ELSE 999 END ASC,
CASE WHEN r.name ILIKE '%Micro Area%' THEN 1 ELSE 0 END ASC, -- big Metro Areas before small Micro Areas
seeds ASC, ls ASC NULLS FIRST, name ASC
LIMIT 1`, [SEED_TARGET]);
return r.rows.length ? { id: r.rows[0].id, name: r.rows[0].name, key: r.rows[0].canonical_key } : null;
}
/** "Asheville, NC Metro Area" -> "Asheville, NC" for natural Places queries. */
function placeLabel(name: string): string {
return name.replace(/\s+(Metro(politan)?( Statistical)?( Area)?|Micro(politan)?( Area)?)\s*$/i, '').trim();
}
function hostOf(url: string): string | null {
try { return new URL(url).host.replace(/^www\./, '').toLowerCase(); } catch { return null; }
}
async function searchText(q: string): Promise<Array<{ id: string; name: string; website?: string; addr?: string }>> {
const res = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Goog-Api-Key': KEY, 'X-Goog-FieldMask': FIELD_MASK },
body: JSON.stringify({ textQuery: q, maxResultCount: 20 }),
});
if (!res.ok) throw new Error(`places searchText ${res.status}: ${(await res.text()).slice(0, 200)}`);
const j: any = await res.json();
return (j.places || []).map((p: any) => ({
id: p.id, name: p.displayName?.text || p.id, website: p.websiteUri, addr: p.formattedAddress,
}));
}
/** Persist place_id + create/link a firm (source-of-record data comes later from the crawl). */
async function linkSeed(placeId: string, q: string, regionId: number, name: string, website?: string): Promise<'new' | 'seen'> {
const existed = await query(`SELECT 1 FROM places_seed WHERE place_id=$1`, [placeId]);
const host = website ? hostOf(website) : null;
if (existed.rows.length) {
await query(`UPDATE places_seed SET last_seen=NOW() WHERE place_id=$1`, [placeId]);
return 'seen';
}
let firmId: number | null = null;
if (website) {
const f = await query<{ id: number }>(
`INSERT INTO firm (name, normalized_name, website, source, source_id, region_id)
VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (source, source_id) DO UPDATE SET website = COALESCE(EXCLUDED.website, firm.website)
RETURNING id`,
[name, name.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(), website, SOURCE, placeId, regionId]);
firmId = f.rows[0].id;
// Seed firm_site directly so crawl:firms enriches from the firm's OWN site
// (first-party, storable) — skips the redundant discover:firms web search.
// crawl_status left NULL: that IS the crawler's work-queue signal.
await query(
`INSERT INTO firm_site (firm_id, url, discovery_method)
VALUES ($1,$2,'google_places')
ON CONFLICT (firm_id) DO NOTHING`,
[firmId, website]);
}
await query(
`INSERT INTO places_seed (place_id, query, region_id, website_host, firm_id, status)
VALUES ($1,$2,$3,$4,$5,$6)`,
[placeId, q, regionId, host, firmId, website ? 'linked' : 'no_website']);
return 'new';
}
async function main() {
const metro = await pickMetro();
if (!metro) { console.log('[places] no metro regions seeded yet — run seed:regions first'); await pool.end(); return; }
const used = await callsUsed();
const budget = Math.max(0, CAP - used);
console.log(`[places] metro=${metro.name} (${metro.key}) quota ${used}/${CAP} used, ${budget} left, live=${LIVE}`);
if (!LIVE) {
console.log('[places] DRY-RUN (set PLACES_SEED_LIVE=1 to make real calls). Would query:');
for (const t of QUERY_TEMPLATES) console.log(` • "${t.replace('{m}', placeLabel(metro.name))}"`);
console.log(`[places] est cost: $0 (free-tier; ${QUERY_TEMPLATES.length} calls, would be $${(QUERY_TEMPLATES.length * RATE_PER_CALL).toFixed(3)} if billed)`);
await pool.end();
return;
}
if (!KEY) { console.error('[places] PLACES_SEED_LIVE=1 but GOOGLE_PLACES_API_KEY unset'); process.exit(2); }
if (budget <= 0) { console.log(`[places] monthly cap ${CAP} reached — HARD STOP, $0 spent`); await pool.end(); return; }
const runId = await openRun(SOURCE, `${metro.key}`);
let calls = 0, fresh = 0, seen = 0, linked = 0;
try {
for (const t of QUERY_TEMPLATES) {
if (calls >= budget) { console.log('[places] cap reached mid-run — stopping'); break; }
const q = t.replace('{m}', placeLabel(metro.name));
const hits = await searchText(q);
calls++; await bumpQuota(1); logCost(1, `searchText "${q}"`);
for (const h of hits) {
const r = await linkSeed(h.id, q, metro.id, h.name, h.website);
if (r === 'new') { fresh++; if (h.website) linked++; } else seen++;
}
console.log(`[places] "${q}" -> ${hits.length} hits`);
await new Promise(r => setTimeout(r, 400)); // polite gap
}
await closeRun(runId, 'ok', { upserted: fresh, skipped: seen,
notes: `${calls} calls, ${fresh} new (${linked} w/ website), ${seen} seen; metro=${metro.name}` });
console.log(`[places] done: ${calls} calls, ${fresh} new firms (${linked} w/ site), ${seen} already seen. $0 (free-tier).`);
} catch (e: any) {
await closeRun(runId, 'failed', { notes: e.message });
console.error('[places] FAILED:', e.message);
await pool.end();
process.exit(1);
}
await pool.end();
}
main().catch(e => { console.error('[places] FAILED:', e); process.exit(1); });