← 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
M public/brokers.htmlM src/enrich/firm_website_resolve.ts
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 →