← 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
M package.jsonA scripts/normalize-broker-names.js
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 →