← back to Nationalrealestate

src/enrich/firm_website_resolve.ts

215 lines

/**
 * Firm→website resolver via the Google Places API — the reliable, STRUCTURED
 * alternative to the anti-bot-throttled Brave/DDG `discover` scraper.
 *
 * License-board firms (state DRE/TREC/DOS/DBPR/…) arrive with a name + license but
 * NO website. Places `searchText("<name> <city> <ST>")` returns `websiteUri`
 * directly, so this fills firm.website + firm_site without scraping any SERP HTML.
 *
 * RESUMABLE + ALL: each run takes the next BATCH firms still missing a site,
 * highest agent_count first, so successive runs progressively cover ALL ~213K
 * license-board firms. Quota-capped against the SAME places_quota monthly cap as
 * places-seed (one Google budget) so steady-state spend stays $0 (free tier).
 *
 * DRY by default; PLACES_SEED_LIVE=1 (or --live) makes real calls. Fail-soft:
 * always closes its ingest_runs row (no zombie 'running').
 *
 *   npm run resolve:firms                     # dry-run: plan + est cost
 *   npm run resolve:firms -- --live           # real calls, quota-capped
 *   USRE_RESOLVE_BATCH=250 npm run resolve:firms -- --live
 */
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 '../ingest/run.ts';

const SOURCE = 'firm_resolve';
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 BATCH = Number(process.env.USRE_RESOLVE_BATCH || 100);
const RATE_PER_CALL = 0.032; // Text-Search Pro list price, ledger-only (free-tier = $0)
// TK-10669: phone added to the mask — one call now yields website + phone, closing
// the firm.phone gap (0/215K populated) for the DRE-style contact card at $0 extra.
const FIELD_MASK = 'places.id,places.displayName,places.websiteUri,places.nationalPhoneNumber,places.formattedAddress';
// Optional asset-class scope (--asset=commercial|residential or USRE_RESOLVE_ASSET).
// RENTV is CRE-only, so the first sweep targets the ~4.1K commercial firms.
const ASSET_RAW = (process.argv.find(a => a.startsWith('--asset='))?.split('=')[1] || process.env.USRE_RESOLVE_ASSET || '').toLowerCase();
const ASSET = ['commercial', 'residential'].includes(ASSET_RAW) ? ASSET_RAW : '';
const ASSET_COND = ASSET ? `AND f.asset_class = '${ASSET}'` : '';

// Portal/aggregator hosts that are never a firm's OWN site.
const BLOCK = new Set([
  'zillow.com', 'realtor.com', 'redfin.com', 'trulia.com', 'homes.com', 'loopnet.com',
  'yelp.com', 'facebook.com', 'instagram.com', 'linkedin.com', 'indeed.com', 'google.com',
  'apartments.com', 'mapquest.com', 'bbb.org', 'yellowpages.com', 'crexi.com',
]);

function ym(): string {
  const d = new Date();
  return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
}
function hostOf(u: string): string | null {
  try { return new URL(u).host.replace(/^www\./, '').toLowerCase(); } catch { return null; }
}
function nameEchoesHost(name: string, host: string): boolean {
  const tokens = name.toLowerCase().replace(/[^a-z0-9]+/g, ' ').split(' ').filter(t => t.length >= 4);
  const stem = host.split('.').slice(-2, -1)[0] || host;
  return tokens.some(t => stem.includes(t) || t.includes(stem));
}

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-firm-resolve', 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 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]);
}

async function searchText(q: string): Promise<Array<{ name: string; website?: string; phone?: string; address?: 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: 5 }),
  });
  if (!res.ok) throw new Error(`places searchText ${res.status}: ${(await res.text()).slice(0, 160)}`);
  const j: any = await res.json();
  return (j.places || []).map((p: any) => ({ name: p.displayName?.text || '', website: p.websiteUri, phone: p.nationalPhoneNumber, address: p.formattedAddress }));
}

/**
 * Best own-site pick: skip aggregators; prefer a host that echoes the firm name.
 * Returns `confident=true` only when the winning host echoes the name — Places
 * sometimes returns an agent's personal page or a franchise-brand portal for a
 * firm, so a non-echoing host is recorded as a LEAD (firm_site low_confidence)
 * WITHOUT overwriting firm.website. ~30% of hits are loose (audited 2026-07-30).
 */
function pickWebsite(hits: Array<{ name: string; website?: string; phone?: string; address?: string }>, firmName: string): { url: string; confident: boolean; phone?: string; address?: string } | null {
  const cands = hits
    .filter(h => !!h.website)
    .filter(h => { const host = hostOf(h.website!); return host && !BLOCK.has(host); });
  if (!cands.length) return null;
  const named = cands.find(h => { const host = hostOf(h.website!); return host && nameEchoesHost(firmName, host); });
  const winner = named || cands[0];
  const h = hostOf(winner.website!);
  if (!h) return null;
  // Phone + address only ride a name-echoing (confident) hit — never staple a loose
  // match's contact details onto the firm, same rule as firm.website.
  return { url: 'https://' + h, confident: !!named, phone: named ? winner.phone : undefined, address: named ? winner.address : undefined };
}

interface FirmRow { id: number; name: string; hq_city: string | null; license_state: string | null }

async function main() {
  const used = await callsUsed();
  const budget = Math.max(0, CAP - used);
  const take = Math.min(BATCH, budget || BATCH);

  const r = await query<FirmRow>(`
    SELECT f.id, f.name, f.hq_city, f.license_state
      FROM firm f
     WHERE f.source <> 'google_places'
       AND f.agent_count IS NOT NULL
       AND (f.website IS NULL OR f.website = '')
       AND NOT EXISTS (SELECT 1 FROM firm_site s WHERE s.firm_id = f.id)
       ${ASSET_COND}
     ORDER BY f.agent_count DESC NULLS LAST, f.id
     LIMIT $1`, [take]);
  const queue = r.rows;

  const remaining = await query<{ n: number }>(`
    SELECT COUNT(*)::int AS n FROM firm f
     WHERE f.source <> 'google_places' AND f.agent_count IS NOT NULL
       AND (f.website IS NULL OR f.website = '')
       AND NOT EXISTS (SELECT 1 FROM firm_site s WHERE s.firm_id = f.id)
       ${ASSET_COND}`);
  console.log(`[resolve] quota ${used}/${CAP} (${budget} left) · batch ${queue.length} · ${remaining.rows[0].n} firms still unresolved${ASSET ? ` [asset=${ASSET}]` : ''} · live=${LIVE}`);

  if (!LIVE) {
    for (const f of queue.slice(0, 5)) {
      console.log(`   • "${`${f.name} ${f.hq_city || ''} ${f.license_state || ''}`.replace(/\s+/g, ' ').trim()}"`);
    }
    if (queue.length > 5) console.log(`   … +${queue.length - 5} more`);
    console.log(`[resolve] DRY-RUN — set PLACES_SEED_LIVE=1 (or --live). ${queue.length} calls, est $0 (free-tier; $${(queue.length * RATE_PER_CALL).toFixed(2)} if billed).`);
    await pool.end();
    return;
  }
  if (!KEY) { console.error('[resolve] live but GOOGLE_PLACES_API_KEY unset'); await pool.end(); process.exit(2); }
  if (budget <= 0) { console.log(`[resolve] monthly cap ${CAP} reached — HARD STOP, $0 spent`); await pool.end(); return; }

  const runId = await openRun(SOURCE, 'places-firm-resolve');
  let calls = 0, resolved = 0, phones = 0, lowConf = 0, noSite = 0, fatal: any = null;
  try {
    for (const f of queue) {
      if (calls >= budget) { console.log('[resolve] cap reached mid-run — stopping'); break; }
      const q = `${f.name} ${f.hq_city || ''} ${f.license_state || ''}`.replace(/\s+/g, ' ').trim();
      const hits = await searchText(q);
      calls++; await bumpQuota(1); logCost(1, `resolve "${f.name.slice(0, 40)}"`);
      const pick = pickWebsite(hits, f.name);
      if (pick && pick.confident) {
        resolved++;
        // COALESCE(NULLIF(...)) so we fill only empty fields — never clobber an
        // already-known website/phone with a fresh Places guess.
        await query(
          `UPDATE firm SET website        = COALESCE(NULLIF(website,''), $2),
                           phone          = COALESCE(NULLIF(phone,''),   $3),
                           street_address = COALESCE(NULLIF(street_address,''), $4),
                           address_source = CASE WHEN NULLIF(street_address,'') IS NULL AND $4 IS NOT NULL
                                                 THEN 'google_places_resolve' ELSE address_source END
             WHERE id = $1`, [f.id, pick.url, pick.phone || null, pick.address || null]);
        if (pick.phone) phones++;
        await query(
          `INSERT INTO firm_site (firm_id, url, discovery_method) VALUES ($1,$2,'google_places_resolve')
           ON CONFLICT (firm_id) DO NOTHING`, [f.id, pick.url]);
      } else if (pick) {
        // Loose match (host doesn't echo the firm name) — record the LEAD for
        // review/crawl but do NOT overwrite firm.website with a maybe-wrong site.
        lowConf++;
        await query(
          `INSERT INTO firm_site (firm_id, url, discovery_method, crawl_status)
           VALUES ($1,$2,'google_places_resolve','low_confidence') ON CONFLICT (firm_id) DO NOTHING`, [f.id, pick.url]);
      } else {
        noSite++;
        await query(
          `INSERT INTO firm_site (firm_id, url, discovery_method, crawl_status)
           VALUES ($1, NULL, 'google_places_resolve', 'no_url') ON CONFLICT (firm_id) DO NOTHING`, [f.id]);
      }
      await new Promise(res => setTimeout(res, 250)); // polite gap
    }
  } catch (e: any) {
    fatal = e;
    console.error(`[resolve] aborted, recording partial: ${String(e?.message || e).slice(0, 120)}`);
  }
  // Fail-soft: always close the run (never leave a zombie 'running').
  const status = calls > 0 ? 'ok' : 'failed';
  await closeRun(runId, status, {
    upserted: resolved, skipped: lowConf + noSite,
    notes: `${calls} Places calls, ${resolved} confident sites (${phones} w/phone), ${lowConf} low-confidence leads, ${noSite} no-site${ASSET ? ` [asset=${ASSET}]` : ''}${fatal ? ` · partial(${String(fatal?.message || fatal).slice(0, 40)})` : ''}`,
  });
  console.log(`[resolve] done: ${calls} calls · ${resolved} confident · ${lowConf} low-confidence · ${noSite} no-site · $0 (free-tier)${fatal ? ' (partial)' : ''}`);
  await pool.end();
}

main().catch(async e => { console.error('[resolve] FATAL:', e); try { await pool.end(); } catch {} process.exit(1); });