[object Object]

← back to Commercialrealestate

broker-email: contrarian-driven hardening — exact last-name-token name-match, cross-domain block, vendor blocklist, shared-inbox dedup, colleague-sole-email guard

95003172246c40d60839af3e3d226b300496ea89 · 2026-07-12 11:51:01 -0700 · Steve Abrams

Files touched

Diff

commit 95003172246c40d60839af3e3d226b300496ea89
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 12 11:51:01 2026 -0700

    broker-email: contrarian-driven hardening — exact last-name-token name-match, cross-domain block, vendor blocklist, shared-inbox dedup, colleague-sole-email guard
---
 scripts/broker-email-commit-from-logs.js | 107 ++++++++++++++++++++-----------
 1 file changed, 68 insertions(+), 39 deletions(-)

diff --git a/scripts/broker-email-commit-from-logs.js b/scripts/broker-email-commit-from-logs.js
index 2a774b0..8fb1ef9 100644
--- a/scripts/broker-email-commit-from-logs.js
+++ b/scripts/broker-email-commit-from-logs.js
@@ -1,69 +1,98 @@
 #!/usr/bin/env node
 /*
- * broker-email-commit-from-logs.js — write the finder's CONFIDENT hits to broker.email by parsing
- * the dry-run logs (NO re-scrape). Reversible: only fills rows where email IS NULL, and writes a
- * rollback file (id,prev-null) so it can be undone. Confident bases only (default): name-match,
- * role-inbox, sole-email (± "+browser"). ambiguous / timeout / anything else is NEVER written.
- *   node broker-email-commit-from-logs.js <log...> --dry              # preview
- *   node broker-email-commit-from-logs.js <log...> --commit           # write
- *   node broker-email-commit-from-logs.js <log...> --commit --bases name-match,role-inbox
+ * broker-email-commit-from-logs.js — write ONLY genuinely-clean broker emails from the finder's
+ * dry-run logs to broker.email (NO re-scrape). Reversible (rollback file). Hardened after a
+ * contrarian pass found name-match substring collisions + shared firm inboxes:
+ *   - name-match: the broker's LAST name must appear as an EXACT delimited token in the local-part
+ *     (kills "ken"⊂"kendra", "chris"⊂"christina", "tony.solomon" for Tony Azzi).
+ *   - cross-domain: email domain must match the site domain (kills utilityconnect@ on homebasedrealty).
+ *   - vendor/platform blocklist (moatable/wix/squarespace/agentfire/sentry/…).
+ *   - corp megasite/office-page guard on sole-email (unchanged).
+ *   - DEDUP: an email written to >1 broker (in-batch OR already elsewhere in the DB) is DROPPED —
+ *     a shared firm switchboard (info@kidder.com ×11) is not a personal contact.
+ *   node broker-email-commit-from-logs.js <log...> [--commit]
  */
 'use strict';
 const fs = require('fs'); const { execFileSync } = require('child_process');
 const args = process.argv.slice(2);
 const COMMIT = args.includes('--commit');
-const bi = args.indexOf('--bases');
-const BASES = bi > -1 ? args[bi + 1].split(',') : ['name-match', 'role-inbox', 'sole-email'];
 const logs = args.filter(a => a.endsWith('.log') && fs.existsSync(a));
 const psql = s => execFileSync('psql', ['-At', '-d', 'cre', '-c', s], { encoding: 'utf8' }).trim();
 const q = s => "'" + String(s).replace(/'/g, "''") + "'";
 
-// Parse: "✓ Name  (#123)  [basis]" then a following "    EMAIL: addr ..." line.
-// Corporate megasites / office pages: a broker's "website" is often a shared corp office page that
-// renders ONE OTHER agent's email → `sole-email` there mis-assigns a stranger. Drop those.
-const CORP = /marcusmillichap|cbre|kw\.com|kwcommercial|remax|coldwell|century21|compass|colliers|cushwake|jll|berkadia|newmark|kidder\.com/i;
+const CORP = /marcusmillichap|cbre|kw\.com|kwcommercial|remax|coldwell|century21|compass|colliers|cushwake|jll|berkadia|newmark|kidder|bhhs|naiglobal|svn\.com|matthews\.com|cbcworldwide|lyonstahl/i;
 const OFFICE = /\/(offices?|about-us|team|our-team|agents)\//i;
+const VENDOR = /moatable|wix|squarespace|agentfire|sentry|godaddy|weebly|wordpress|shopify|hubspot|mailchimp|constantcontact|bugreport|noreply|no-reply|donotreply|webmaster|postmaster|abuse@|privacy@|support@agent/i;
+const dom = e => (e.split('@')[1] || '').replace(/^www\./, '');
+const siteDom = url => { const m = url.match(/https?:\/\/([^\/\s]+)/i); return m ? m[1].replace(/^www\./, '') : ''; };
+const sameDom = (email, site) => { const a = dom(email), b = siteDom(site); return a && b && (a === b || a.endsWith('.' + b) || b.endsWith('.' + a)); };
+const tokens = s => s.toLowerCase().split(/[._\-+]+/).filter(Boolean);
+
+function nameMatchStrict(name, email) {
+  const parts = String(name || '').toLowerCase().replace(/["']/g, '').split(/\s+/).filter(w => w.length > 1);
+  if (!parts.length) return false;
+  const last = parts[parts.length - 1], first = parts[0];
+  const lt = tokens(email.split('@')[0]);
+  const localFlat = email.split('@')[0].toLowerCase().replace(/[._\-+]/g, '');
+  // last name as an EXACT token, OR the concatenation firstlast / flast is the whole local-part
+  if (last.length >= 3 && lt.includes(last)) return true;
+  if (localFlat === first + last || localFlat === (first[0] + last) || localFlat === (last + first)) return true;
+  return false;
+}
+
 function parse(text) {
-  const out = [];
-  const lines = text.split('\n');
+  const out = []; const lines = text.split('\n');
   for (let i = 0; i < lines.length; i++) {
-    const m = lines[i].match(/^✓\s+.*?\(#(\d+)\)\s+\[([a-z+-]+)\]/);
+    const m = lines[i].match(/^✓\s+(.*?)\s+\(#(\d+)\)\s+\[([a-z+-]+)\]/);
     if (!m) continue;
-    const id = +m[1], basis = m[2].replace('+browser', '');
-    let site = '', em = null;
+    const name = m[1], id = +m[2], basis = m[3].replace('+browser', '');
+    let site = '', email = null;
     for (let j = 1; j <= 3 && i + j < lines.length; j++) {
-      if (/^\s+site:/.test(lines[i + j])) site = lines[i + j];
-      const e = lines[i + j].match(/EMAIL:\s+(\S+)/); if (e) { em = e; break; }
+      if (/^\s+site:/.test(lines[i + j])) site = lines[i + j].replace(/^\s+site:\s*/, '').trim();
+      const e = lines[i + j].match(/EMAIL:\s+(\S+)/); if (e) { email = e[1].toLowerCase(); break; }
     }
-    if (!em) continue;
-    const email = em[1].toLowerCase();
-    // POISON GUARD: sole-email only trusted on the broker's OWN small domain — never a corp megasite/office page.
-    if (basis === 'sole-email' && (CORP.test(site) || CORP.test(email) || OFFICE.test(site))) continue;
-    out.push({ id, basis, email });
+    if (email) out.push({ id, name, basis, email, site });
   }
   return out;
 }
 
-const seen = new Map();   // id -> {email, basis}  (first confident wins; logs are ordered plain→browser)
+// collect first-seen per broker, apply per-hit guards
+const seen = new Map();
 for (const lg of logs) for (const r of parse(fs.readFileSync(lg, 'utf8'))) {
-  if (!BASES.includes(r.basis)) continue;
-  if (!seen.has(r.id)) seen.set(r.id, r);
+  if (seen.has(r.id)) continue;
+  if (VENDOR.test(r.email)) continue;                                          // platform / role junk
+  if (!sameDom(r.email, r.site)) continue;                                     // cross-domain → drop
+  if (r.basis === 'name-match' && !nameMatchStrict(r.name, r.email)) continue; // substring collision → drop
+  if (r.basis === 'sole-email' && (CORP.test(r.site) || CORP.test(r.email) || OFFICE.test(r.site))) continue;
+  // sole-email whose local-part looks like a PERSON (not a role inbox) must be THIS broker — else it's
+  // a colleague at the same small firm (e.g. larry@ for broker Brett Howard). Role local-parts pass.
+  const roleLocal = /^(info|contact|hello|sales|admin|office|team|reception|frontdesk|leasing|main)$/i.test(r.email.split('@')[0]);
+  if (r.basis === 'sole-email' && !roleLocal && !nameMatchStrict(r.name, r.email)) continue;
+  if (r.basis === 'role-inbox' && (CORP.test(r.email) || CORP.test(r.site))) continue; // corp firm inbox → drop
+  seen.set(r.id, r);
 }
-const rows = [...seen.values()];
-console.log(`confident hits parsed from ${logs.length} logs (bases: ${BASES.join(',')}): ${rows.length}`);
-const byBasis = {}; rows.forEach(r => byBasis[r.basis] = (byBasis[r.basis] || 0) + 1);
-console.log('  by basis:', JSON.stringify(byBasis));
+let rows = [...seen.values()];
+
+// DEDUP: an email assigned to >1 broker in this batch is a shared inbox → drop all of them
+const emailCount = {}; rows.forEach(r => emailCount[r.email] = (emailCount[r.email] || 0) + 1);
+rows = rows.filter(r => emailCount[r.email] === 1);
+// also drop any email already present on a DIFFERENT broker in the DB
+rows = rows.filter(r => {
+  const n = +psql(`SELECT count(*) FROM broker WHERE lower(email)=${q(r.email)} AND id<>${r.id};`);
+  return n === 0;
+});
 
-if (!COMMIT) { console.log('DRY — nothing written. Sample:', rows.slice(0, 5).map(r => `#${r.id} ${r.email} [${r.basis}]`).join(' | ')); process.exit(0); }
+const byBasis = {}; rows.forEach(r => byBasis[r.basis] = (byBasis[r.basis] || 0) + 1);
+console.log(`CLEAN committable hits: ${rows.length}  ${JSON.stringify(byBasis)}`);
+console.log('sample:', rows.slice(0, 8).map(r => `#${r.id} ${r.name.split(' ')[0]}→${r.email} [${r.basis}]`).join('\n        '));
 
-let wrote = 0, skipped = 0; const rollback = [];
+if (!COMMIT) { console.log('\nDRY — nothing written.'); process.exit(0); }
+const rollback = [];
 for (const r of rows) {
-  // only fill still-empty rows; never overwrite an existing email
   const cur = psql(`SELECT COALESCE(email,'') FROM broker WHERE id=${r.id};`);
-  if (cur) { skipped++; continue; }
+  if (cur) continue;
   psql(`UPDATE broker SET email=${q(r.email)} WHERE id=${r.id} AND (email IS NULL OR email='');`);
-  rollback.push(r.id); wrote++;
+  rollback.push(r.id);
 }
 fs.writeFileSync('/tmp/broker-email-commit-rollback.txt', rollback.join(','));
-console.log(`COMMITTED: ${wrote} written to broker.email, ${skipped} skipped (already had email). Rollback ids -> /tmp/broker-email-commit-rollback.txt`);
-console.log(`Undo: psql -d cre -c "UPDATE broker SET email=NULL WHERE id IN (${rollback.slice(0,3).join(',')}...);"`);
+console.log(`\nCOMMITTED ${rollback.length} clean emails. Rollback ids -> /tmp/broker-email-commit-rollback.txt`);

← 310925d auto-save: 2026-07-12T11:48:02 (2 files) — data/listings.jso  ·  back to Commercialrealestate  ·  CRCP email-alerts: opt-in gov-agent segment (DTD verdict C) 0a6e0fc →