[object Object]

← back to Nationalrealestate

firm directory: capture phone via Places resolve (commercial-scoped) + surface registry phone/website on firm card (TK-10669)

d1169175ff15bbd3392232782a16dbe7e48ebeab · 2026-08-18 08:57:07 -0700 · Steve Abrams

Files touched

Diff

commit d1169175ff15bbd3392232782a16dbe7e48ebeab
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 18 08:57:07 2026 -0700

    firm directory: capture phone via Places resolve (commercial-scoped) + surface registry phone/website on firm card (TK-10669)
---
 public/brokers.html                | 11 ++++++++++
 src/enrich/firm_website_resolve.ts | 45 ++++++++++++++++++++++++++------------
 2 files changed, 42 insertions(+), 14 deletions(-)

diff --git a/public/brokers.html b/public/brokers.html
index c21d63a..30aaae2 100644
--- a/public/brokers.html
+++ b/public/brokers.html
@@ -43,6 +43,7 @@
   td > .usre-fd{margin:2px 0;}
   .usre-fd h3{margin:0 0 6px;font-size:14px;color:var(--gold);}
   .usre-fd .fd-muted{color:var(--muted);}
+  .usre-fd .fd-primary{margin:0 0 6px;font-size:13px;display:flex;flex-wrap:wrap;gap:4px 12px;align-items:center;}
   .usre-fd .fd-sec{margin-top:8px;}
   .usre-fd .fd-sec b{color:var(--fg);font-size:12px;text-transform:uppercase;letter-spacing:.04em;}
   .usre-fd a{color:var(--gold);text-decoration:none;} .usre-fd a:hover{text-decoration:underline;}
@@ -230,9 +231,19 @@ function firmDetailHtml(d) {
   ).join(' · ') || '<span class="fd-muted">none extracted</span>';
   const brokers = (d.brokers || []).map(b =>
     `<span>${esc(b.name)}${b.city ? ' · ' + esc(b.city) : ''}</span>`).join('') || '<span class="fd-muted">none</span>';
+  // TK-10669 primary-contact line: the firm's own registry-grade phone + website
+  // (Google Places resolve). This is the DRE-style contact the row was missing —
+  // distinct from the crawl-derived firm_contacts shown below.
+  const loc = [f.hq_city, f.hq_state].filter(Boolean).join(', ');
+  const primaryBits = [];
+  if (f.phone) primaryBits.push(`<a href="tel:${esc(f.phone)}">📞 ${esc(f.phone)}</a>`);
+  if (f.website) primaryBits.push(`<a href="${esc(f.website)}" target="_blank" rel="noopener noreferrer">🏢 ${esc(String(f.website).replace(/^https?:\/\//, ''))} ↗</a>`);
+  if (loc) primaryBits.push(`<span class="fd-muted">📍 ${esc(loc)}</span>`);
+  const primaryLine = primaryBits.length ? `<div class="fd-primary">${primaryBits.join(' · ')}</div>` : '';
   return `<div class="usre-fd">
     <h3>${esc(f.name)} <span class="fd-pill">${esc(f.license_state)}</span>
       ${f.agent_count != null ? `<span class="fd-pill">${Number(f.agent_count).toLocaleString()} agents</span>` : ''}</h3>
+    ${primaryLine}
     <div>${siteLine}</div>
     <div class="fd-sec"><b>Contacts</b><div>${contacts}</div></div>
     <div class="fd-sec"><b>Brokers</b> <span class="fd-muted">(first ${(d.brokers || []).length})</span>
diff --git a/src/enrich/firm_website_resolve.ts b/src/enrich/firm_website_resolve.ts
index 567a778..786c42e 100644
--- a/src/enrich/firm_website_resolve.ts
+++ b/src/enrich/firm_website_resolve.ts
@@ -32,7 +32,14 @@ const LIVE = process.env.PLACES_SEED_LIVE === '1' || process.argv.includes('--li
 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)
-const FIELD_MASK = 'places.id,places.displayName,places.websiteUri';
+// 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';
+// 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([
@@ -80,7 +87,7 @@ async function bumpQuota(n: number): Promise<void> {
     [ym(), n]);
 }
 
-async function searchText(q: string): Promise<Array<{ name: string; website?: string }>> {
+async function searchText(q: string): Promise<Array<{ name: string; website?: string; phone?: string }>> {
   const res = await fetch(API, {
     method: 'POST',
     headers: { 'Content-Type': 'application/json', 'X-Goog-Api-Key': KEY, 'X-Goog-FieldMask': FIELD_MASK },
@@ -88,7 +95,7 @@ async function searchText(q: string): Promise<Array<{ name: string; website?: st
   });
   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 }));
+  return (j.places || []).map((p: any) => ({ name: p.displayName?.text || '', website: p.websiteUri, phone: p.nationalPhoneNumber }));
 }
 
 /**
@@ -98,16 +105,18 @@ async function searchText(q: string): Promise<Array<{ name: string; website?: st
  * 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 }>, firmName: string): { url: string; confident: boolean } | null {
+function pickWebsite(hits: Array<{ name: string; website?: string; phone?: string }>, firmName: string): { url: string; confident: boolean; phone?: string } | null {
   const cands = hits
-    .map(h => h.website).filter((u): u is string => !!u)
-    .filter(u => { const h = hostOf(u); return h && !BLOCK.has(h); });
+    .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(u => { const h = hostOf(u); return h && nameEchoesHost(firmName, h); });
+  const named = cands.find(h => { const host = hostOf(h.website!); return host && nameEchoesHost(firmName, host); });
   const winner = named || cands[0];
-  const h = hostOf(winner);
+  const h = hostOf(winner.website!);
   if (!h) return null;
-  return { url: 'https://' + h, confident: !!named };
+  // Phone only rides a name-echoing (confident) hit — never staple a loose match's
+  // phone onto the firm, same rule as firm.website.
+  return { url: 'https://' + h, confident: !!named, phone: named ? winner.phone : undefined };
 }
 
 interface FirmRow { id: number; name: string; hq_city: string | null; license_state: string | null }
@@ -124,6 +133,7 @@ async function main() {
        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;
@@ -132,8 +142,9 @@ async function main() {
     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)`);
-  console.log(`[resolve] quota ${used}/${CAP} (${budget} left) · batch ${queue.length} · ${remaining.rows[0].n} firms still unresolved · live=${LIVE}`);
+       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)) {
@@ -148,7 +159,7 @@ async function main() {
   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, lowConf = 0, noSite = 0, fatal: any = null;
+  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; }
@@ -158,7 +169,13 @@ async function main() {
       const pick = pickWebsite(hits, f.name);
       if (pick && pick.confident) {
         resolved++;
-        await query(`UPDATE firm SET website = COALESCE(website, $2) WHERE id = $1`, [f.id, pick.url]);
+        // 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)
+             WHERE id = $1`, [f.id, pick.url, pick.phone || 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]);
@@ -185,7 +202,7 @@ async function main() {
   const status = calls > 0 ? 'ok' : 'failed';
   await closeRun(runId, status, {
     upserted: resolved, skipped: lowConf + noSite,
-    notes: `${calls} Places calls, ${resolved} confident sites, ${lowConf} low-confidence leads, ${noSite} no-site${fatal ? ` · partial(${String(fatal?.message || fatal).slice(0, 40)})` : ''}`,
+    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();

← bdc39ea snapshot before restart: preserve in-flight work (auto-saved  ·  back to Nationalrealestate  ·  firm directory: capture + surface street address via Places 771e7f8 →