[object Object]

← back to Commercialrealestate

chore: add broker-name normalizer + version bump (session close)

f30d3c725ff62255f76532d82d185216d5a47ca2 · 2026-08-20 13:43:04 -0700 · Steve

- scripts/normalize-broker-names.js: deterministic Crexi/CoStar broker-name cleanup
  (strip CCIM/SIOR credentials, ALL-CAPS→Title, lowercase→proper, stray punctuation).
- lint (node --check) clean on all session JS. Refactor pass SKIPPED intentionally:
  shared repo co-owned by crcp-ui + all session code already deployed & verified live +
  no test harness — behavior-preserving refactor risk outweighs benefit at close.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit f30d3c725ff62255f76532d82d185216d5a47ca2
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 20 13:43:04 2026 -0700

    chore: add broker-name normalizer + version bump (session close)
    
    - scripts/normalize-broker-names.js: deterministic Crexi/CoStar broker-name cleanup
      (strip CCIM/SIOR credentials, ALL-CAPS→Title, lowercase→proper, stray punctuation).
    - lint (node --check) clean on all session JS. Refactor pass SKIPPED intentionally:
      shared repo co-owned by crcp-ui + all session code already deployed & verified live +
      no test harness — behavior-preserving refactor risk outweighs benefit at close.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 package.json                      |  2 +-
 scripts/normalize-broker-names.js | 97 +++++++++++++++++++++++++++++++++++++++
 2 files changed, 98 insertions(+), 1 deletion(-)

diff --git a/package.json b/package.json
index 33b8312..df64a83 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "commercialrealestate",
-  "version": "0.22.0",
+  "version": "0.22.1",
   "private": true,
   "description": "LA County CRE investment explorer — multi-firm sourced, Census + assessor enriched, Qwen-analyzed",
   "scripts": {
diff --git a/scripts/normalize-broker-names.js b/scripts/normalize-broker-names.js
new file mode 100644
index 0000000..4795a21
--- /dev/null
+++ b/scripts/normalize-broker-names.js
@@ -0,0 +1,97 @@
+#!/usr/bin/env node
+/* normalize-broker-names.js (Steve 2026-08-20)
+ * Crexi/CoStar broker/agent names carry formatting garbage: professional-credential
+ * suffixes (", CCIM"/", SIOR"), ALL-CAPS, lowercase-start, stray "?"/commas, and
+ * pure-title tokens stored as a name ("Senior Vice President / Principal"). ~94% of names
+ * are already clean; this deterministically fixes the ~6% that aren't, WITHOUT touching the
+ * clean ones and WITHOUT any web scraping.
+ *
+ * DEFAULT = STAGE ONLY: writes a before->after report to data/broker-name-fixes.staging.json
+ * and NEVER modifies ranked.json. Pass --apply to rewrite data/ranked.json in place (that is
+ * the customer-facing/canonical write — gated; a .bak is written first).
+ *
+ * Genuinely-garbled names with no clean alternate (e.g. "Ix Garcia") are LEFT as-is and listed
+ * under `needsSite` for the separate site-enrichment pass.
+ */
+const fs = require('fs');
+const path = require('path');
+const ROOT = path.join(__dirname, '..');
+const FILE = path.join(ROOT, 'data', 'ranked.json');
+const OUT = path.join(ROOT, 'data', 'broker-name-fixes.staging.json');
+const APPLY = process.argv.includes('--apply');
+
+// Professional designations to strip (NOT Jr/Sr/II/III — those are real name suffixes we keep).
+const CRED = /\s*[,\/]?\s*\b(CCIM|SIOR|CPM|MBA|MAI|CRE|GRI|ABR|CRS|SRES|CIPS|RPA|LEED\s*AP|R\.?E\.?P\.?A)\b\.?/gi;
+// Pure job-title phrases (a token that is ONLY a title, not a person's name).
+const PURE_TITLE = /^(senior\s+)?(managing\s+)?(vice\s+president|president|principal|senior\s+vice\s+president|broker(\s+associate)?|associate|director|partner|founder|ceo|coo|realtor)(\s*[\/,&-]\s*(vice\s+president|president|principal|broker|associate|director|partner|founder))*$/i;
+
+const isTarget = r => /crexi|costar/i.test(r.source_host || r.source || '');
+const titleCase = s => s.replace(/\w[^\s'-]*/g, w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
+  .replace(/\b(Mc)([a-z])/g, (m,a,b)=>a+b.toUpperCase())          // McCann
+  .replace(/\b(O')([a-z])/gi, (m,a,b)=>"O'"+b.toUpperCase());     // O'Reilly
+
+function fixToken(raw) {
+  if (raw == null) return { out: null, changed: false, drop: false };
+  let t = String(raw).trim();
+  const before = t;
+  // drop pure-title tokens entirely
+  if (PURE_TITLE.test(t.replace(/[.,]$/,''))) return { out: null, changed: true, drop: true, reason: 'pure-title' };
+  t = t.replace(CRED, '').trim();                  // strip credentials
+  t = t.replace(/[?]/g, '').trim();                // stray ?
+  t = t.replace(/[\s,]+$/,'').replace(/^[\s,]+/,'').trim(); // trim stray commas/space
+  t = t.replace(/\s{2,}/g,' ');
+  // ALL-CAPS or all-lowercase -> Title Case (only when there's no internal camelCase signal)
+  if ((t === t.toUpperCase() || t === t.toLowerCase()) && /[a-z]/i.test(t)) t = titleCase(t);
+  const changed = t !== before;
+  return { out: t || null, changed, drop: false, reason: changed ? 'format' : null };
+}
+
+function fixNames(arr) {
+  const out = [], notes = [];
+  let changed = false;
+  (arr || []).forEach(n => {
+    const f = fixToken(n);
+    if (f.drop) { changed = true; notes.push('dropped title "' + n + '"'); return; }
+    if (f.changed) { changed = true; notes.push('"' + n + '" -> "' + f.out + '"'); }
+    if (f.out) out.push(f.out);
+  });
+  return { names: out, changed, notes };
+}
+
+// A name still looks garbled (single odd token, no clean alternate) -> needs the broker site.
+const stillGarbled = arr => arr.length === 0 ||
+  (arr.length === 1 && (!/\s/.test(arr[0]) || arr[0].length < 4));
+
+const d = JSON.parse(fs.readFileSync(FILE, 'utf8'));
+const rows = d.ranked || [];
+const fixes = [], needsSite = [];
+let touched = 0;
+for (const r of rows) {
+  if (!isTarget(r)) continue;
+  const res = fixNames(r.broker_agents);
+  if (res.changed) {
+    touched++;
+    fixes.push({ address: r.address, before: r.broker_agents, after: res.names, notes: res.notes,
+      site: (r.broker_url || r.website || r.broker_website || '') || null });
+    if (APPLY) r.broker_agents = res.names;
+  }
+  const finalNames = res.changed ? res.names : (r.broker_agents || []);
+  if (stillGarbled(finalNames) && (r.broker_url || r.website || r.broker_website)) {
+    needsSite.push({ address: r.address, names: finalNames, phone: r.broker_phone || null,
+      site: r.broker_url || r.website || r.broker_website });
+  }
+}
+
+const report = { generated: new Date().toISOString().replace(/\.\d+Z$/,'Z'),
+  applied: APPLY, targetRows: rows.filter(isTarget).length,
+  namesFixed: touched, needsSiteEnrichment: needsSite.length,
+  fixes, needsSite };
+fs.writeFileSync(OUT, JSON.stringify(report, null, 2));
+
+if (APPLY) {
+  fs.copyFileSync(FILE, FILE + '.bak');            // reversible: restore from .bak
+  fs.writeFileSync(FILE, JSON.stringify(d));
+}
+console.log((APPLY ? 'APPLIED' : 'STAGED') + ': ' + touched + ' rows name-fixed, ' +
+  needsSite.length + ' still need site enrichment. Report -> ' + path.relative(ROOT, OUT) +
+  (APPLY ? ' (ranked.json rewritten; .bak saved)' : ' (ranked.json untouched)'));

← 9cfd192 CRCP: firecities.crcp hub — 2025 burn-zone cities (Eaton+Pal  ·  back to Commercialrealestate  ·  loan-officers: refresher auto-deploys to prod (no approval t 4654443 →