← back to Commercialrealestate
CRE: multi-firm listing-source framework + firm badges in viewer
9c8ad7b74bf9f65abc3fa7733ddc08c3344382d4 · 2026-06-26 23:30:53 -0700 · Steve
- sources/firms.js: shared normalizer + dedup + declarative firm registry
(Crexi verified; Marcus & Millichap, Lee & Associates, Colliers, CBRE, JLL
wired with generic listing-shape sniffer, flagged needs-discovery)
- refresh-all.js: one Browserbase session drives every firm through the same
capture->normalize->dedup->append path into listings.json
- discover-firm.js: one-command schema cracker to lock each new firm
- viewer: 🏢 firm/source badge on every deal card (verified 104 cards render)
- npm run refresh / npm run discover
Files touched
M package.jsonM public/index.htmlA scripts/discover-firm.jsA scripts/refresh-all.jsA scripts/sources/firms.js
Diff
commit 9c8ad7b74bf9f65abc3fa7733ddc08c3344382d4
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jun 26 23:30:53 2026 -0700
CRE: multi-firm listing-source framework + firm badges in viewer
- sources/firms.js: shared normalizer + dedup + declarative firm registry
(Crexi verified; Marcus & Millichap, Lee & Associates, Colliers, CBRE, JLL
wired with generic listing-shape sniffer, flagged needs-discovery)
- refresh-all.js: one Browserbase session drives every firm through the same
capture->normalize->dedup->append path into listings.json
- discover-firm.js: one-command schema cracker to lock each new firm
- viewer: 🏢 firm/source badge on every deal card (verified 104 cards render)
- npm run refresh / npm run discover
---
package.json | 2 +
public/index.html | 14 +++-
scripts/discover-firm.js | 94 ++++++++++++++++++++++++
scripts/refresh-all.js | 115 +++++++++++++++++++++++++++++
scripts/sources/firms.js | 183 +++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 407 insertions(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 6334a74..cbdab9c 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,8 @@
"description": "San Fernando Valley CRE investment ranker ($400k down, leveraged) — Crexi-sourced, Qwen-analyzed",
"scripts": {
"scrape": "node scripts/scrape-crexi.js",
+ "refresh": "NODE_PATH=$HOME/.claude/skills/browserbase/node_modules node scripts/refresh-all.js",
+ "discover": "NODE_PATH=$HOME/.claude/skills/browserbase/node_modules node scripts/discover-firm.js",
"analyze": "node scripts/analyze.js",
"serve": "node scripts/serve.js"
},
diff --git a/public/index.html b/public/index.html
index b77cc58..e2efec8 100644
--- a/public/index.html
+++ b/public/index.html
@@ -150,8 +150,20 @@ function recompute(){
DATA.ranked.forEach((p,i)=>p.rank=i+1);
}
+// Firm/source label: prefer the explicit firm tag (set by refresh-all.js), else derive from the
+// source domain so the 104 legacy listings (pre-firm-field) still show where they came from.
+function firmLabel(p){
+ if(p.firm) return p.firm;
+ const d=(p.source||'').replace(/^https?:\/\/(www\.)?/,'').split('/')[0]||'';
+ const map={'crexi.com':'Crexi','loopnet.com':'LoopNet','compass.com':'Compass','remax.com':'RE/MAX',
+ 'coldwellbankerhomes.com':'Coldwell Banker','marcusmillichap.com':'Marcus & Millichap',
+ 'lee-associates.com':'Lee & Associates','colliers.com':'Colliers','cbre.com':'CBRE','jll.com':'JLL'};
+ if(map[d]) return map[d];
+ return d ? d.split('.')[0].replace(/^./,c=>c.toUpperCase()) : 'Unknown';
+}
function card(p){
const f=p.finance, uc=/Under Contract|Pending/i.test(p.status);
+ const firmB = `<span class="b" title="Listing source / brokerage firm">🏢 ${firmLabel(p)}</span>`;
const aff = f.affordable ? `<span class="b ok">✓ $${(f.cashNeeded/1000).toFixed(0)}k in budget</span>`
: `<span class="b bad">✗ needs $${(f.cashNeeded/1000).toFixed(0)}k</span>`;
const rec = p.qwen && p.qwen.recommendation ? `<span class="b rec-${p.qwen.recommendation}">${p.qwen.recommendation}</span>` : '';
@@ -167,7 +179,7 @@ function card(p){
<div><div class="rank">#${p.rank} · score</div><div class="addr">${p.address}</div><div class="city">${p.city}, CA ${p.zip||''}</div></div>
<div class="score">${p.composite}<small>fin ${p.financeScore}${p.qwenScore!=null?' · ai '+p.qwenScore:''}</small></div>
</div>
- <div class="badges"><span class="b type">${p.type}</span><span class="b">${p.units} unit${p.units>1?'s':''}</span><span class="b">${fmt(p.price)}</span>${capB}${verB}${f.grossYield?`<span class="b ok" title="Annual gross rent ÷ price — higher = rent closer to price">${f.grossYield}% rent yld</span>`:''}${statusB} ${aff} ${rec}</div>
+ <div class="badges">${firmB}<span class="b type">${p.type}</span><span class="b">${p.units} unit${p.units>1?'s':''}</span><span class="b">${fmt(p.price)}</span>${capB}${verB}${f.grossYield?`<span class="b ok" title="Annual gross rent ÷ price — higher = rent closer to price">${f.grossYield}% rent yld</span>`:''}${statusB} ${aff} ${rec}</div>
<div class="metrics">
<div><span class="k">Cash-on-cash</span><span class="v ${cls(f.coc,6,3)}">${pct(f.coc)}</span></div>
<div><span class="k">DSCR</span><span class="v ${cls(f.dscr,1.25,1.0)}">${f.dscr??'—'}</span></div>
diff --git a/scripts/discover-firm.js b/scripts/discover-firm.js
new file mode 100644
index 0000000..29ca0c1
--- /dev/null
+++ b/scripts/discover-firm.js
@@ -0,0 +1,94 @@
+// discover-firm.js — generalized schema cracker for any firm in sources/firms.js. Warms the firm's
+// search page in one Browserbase session and logs EVERY JSON response on its apiHosts: method, path,
+// item-count, top-level keys, and a sample asset. Use this to confirm a 'needs-discovery' firm's API
+// shape, then either trust genericExtract or write a precise extract() in firms.js and flip it to
+// 'verified'. This is the same technique that originally cracked Crexi (discover.js / discover2.js).
+//
+// node scripts/discover-firm.js marcusmillichap # default first SFV city
+// CC_NAV="Van Nuys" node scripts/discover-firm.js colliers
+//
+// COST: ~$0.03–0.05 (one Browserbase session). Printed at the end. READ-ONLY — writes nothing to
+// listings.json; output goes to data/raw/discover-<firm>.json for inspection.
+
+'use strict';
+const fs = require('fs');
+const path = require('path');
+const { chromium } = require('playwright-core');
+const Browserbase = require('@browserbasehq/sdk').default;
+const { TYPES, byKey, genericExtract } = require('./sources/firms');
+
+const ROOT = path.join(__dirname, '..');
+const bbEnv = fs.readFileSync(process.env.HOME + '/.claude/skills/browserbase/.env', 'utf8');
+const get = (t, k) => (t.match(new RegExp('^' + k + '=(.*)$', 'm')) || [])[1]?.replace(/['"]/g, '').trim();
+
+const KEY = process.argv[2];
+const firm = byKey(KEY);
+if (!firm) { console.error('usage: node scripts/discover-firm.js <firmKey> e.g. marcusmillichap | lee-associates | colliers | cbre | jll'); process.exit(2); }
+const CITY = (process.env.CC_NAV || 'Van Nuys').split(',')[0].trim();
+
+(async () => {
+ const hits = [];
+ let browser;
+ try {
+ const bb = new Browserbase({ apiKey: get(bbEnv, 'BROWSERBASE_API_KEY') });
+ const session = await bb.sessions.create({
+ projectId: get(bbEnv, 'BROWSERBASE_PROJECT_ID'),
+ browserSettings: { solveCaptchas: true, viewport: { width: 1440, height: 1000 } }
+ });
+ console.log('bb session', session.id, '| firm', firm.key, '| city', CITY);
+ browser = await chromium.connectOverCDP(session.connectUrl);
+ const ctx = browser.contexts()[0];
+ const page = ctx.pages()[0] || await ctx.newPage();
+ page.setDefaultTimeout(60000);
+
+ page.on('response', async (resp) => {
+ const url = resp.url();
+ if (!firm.apiHosts.some(h => url.includes(h))) return;
+ const ct = resp.headers()['content-type'] || '';
+ if (!/json/i.test(ct)) return;
+ let j; try { j = await resp.json(); } catch { return; }
+ let sample = null, count = 0, generic = 0;
+ try {
+ const g = genericExtract(j); generic = g.length; sample = g[0] || null;
+ const arr = Array.isArray(j) ? j : (j.data || j.results || j.assets || j.items || []);
+ count = Array.isArray(arr) ? arr.length : 0;
+ } catch {}
+ let u; try { u = new URL(url); } catch { u = { pathname: url, hostname: '' }; }
+ hits.push({
+ method: resp.request().method(), host: u.hostname, path: u.pathname,
+ topKeys: Array.isArray(j) ? '[array]' : Object.keys(j || {}).slice(0, 14),
+ rawCount: count, genericExtracted: generic, sample
+ });
+ });
+
+ for (const [slug] of TYPES) {
+ for (const url of firm.navUrls(CITY, slug)) {
+ console.log('→', url);
+ try {
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(8000);
+ await page.mouse.wheel(0, 3000).catch(() => {});
+ await page.waitForTimeout(4000);
+ } catch (e) { console.log('nav err', String(e.message).split('\n')[0]); }
+ }
+ }
+ } catch (e) {
+ console.error('FATAL', e.message);
+ } finally {
+ if (browser) await browser.close().catch(() => {});
+ }
+
+ hits.sort((a, b) => b.genericExtracted - a.genericExtracted);
+ const outFile = path.join(ROOT, 'data', 'raw', `discover-${firm.key}.json`);
+ fs.writeFileSync(outFile, JSON.stringify({ firm: firm.key, city: CITY, capturedResponses: hits.length, hits }, null, 2));
+
+ console.log(`\n${firm.name}: ${hits.length} JSON responses on ${firm.apiHosts.join('/')}`);
+ for (const h of hits.slice(0, 12)) {
+ console.log(` [${h.method}] ${h.path} raw=${h.rawCount} generic=${h.genericExtracted} keys=${Array.isArray(h.topKeys) ? h.topKeys.join(',') : h.topKeys}`);
+ }
+ const best = hits.find(h => h.genericExtracted > 0);
+ console.log(best
+ ? `\n✓ genericExtract already pulls ${best.genericExtracted} assets from ${best.path} — sample: ${JSON.stringify(best.sample)}\n → safe to run: CC_FIRMS=${firm.key} node scripts/refresh-all.js`
+ : `\n✗ genericExtract found 0 assets — inspect ${outFile} and write a precise extract() in sources/firms.js for "${firm.key}".`);
+ console.log('Cost: ~$0.03–0.05 (1 Browserbase session).');
+})();
diff --git a/scripts/refresh-all.js b/scripts/refresh-all.js
new file mode 100644
index 0000000..d270f8a
--- /dev/null
+++ b/scripts/refresh-all.js
@@ -0,0 +1,115 @@
+// refresh-all.js — multi-firm sweep. Supersedes refresh-listings.js (which was Crexi-only) by
+// driving EVERY firm in sources/firms.js through one shared capture → normalize → dedup → append
+// path into data/listings.json. Crexi remains the proven source; the brokerage firms (Marcus &
+// Millichap, Lee & Associates, Colliers, CBRE, JLL) are included once their schema is confirmed.
+//
+// node scripts/refresh-all.js # all VERIFIED firms (default: crexi only)
+// CC_FIRMS=crexi,marcusmillichap node ... # explicit firm set (incl. needs-discovery ones)
+// CC_NAV="Van Nuys,Reseda" CC_FIRMS=... node ... # override the city list
+//
+// Idempotent: only appends listings whose normalized address isn't already present (cross-firm).
+// COST: each run = ONE Browserbase session (~$0.03–0.05 incl. captcha solves), independent of how
+// many firms/cities are swept in that session. Printed at the end.
+
+'use strict';
+const fs = require('fs');
+const path = require('path');
+const { chromium } = require('playwright-core');
+const Browserbase = require('@browserbasehq/sdk').default;
+const { TYPES, norm, today, FIRMS, byKey, toListing, inBand } = require('./sources/firms');
+
+const ROOT = path.join(__dirname, '..');
+const bbEnv = fs.readFileSync(process.env.HOME + '/.claude/skills/browserbase/.env', 'utf8');
+const get = (t, k) => (t.match(new RegExp('^' + k + '=(.*)$', 'm')) || [])[1]?.replace(/['"]/g, '').trim();
+
+const NAV_CITIES = process.env.CC_NAV
+ ? process.env.CC_NAV.split(',').map(s => s.trim())
+ : ['Van Nuys', 'Reseda', 'North Hollywood', 'Northridge', 'Panorama City', 'Canoga Park', 'Sherman Oaks', 'Sun Valley', 'Encino', 'Granada Hills'];
+
+// Which firms to run. Default = verified only, so an unattended cron never silently scrapes an
+// unconfirmed firm. Pass CC_FIRMS to opt specific firms in.
+const SELECTED = process.env.CC_FIRMS
+ ? process.env.CC_FIRMS.split(',').map(s => s.trim()).map(byKey).filter(Boolean)
+ : FIRMS.filter(f => f.status === 'verified');
+
+(async () => {
+ if (!SELECTED.length) { console.error('no firms selected'); process.exit(2); }
+ console.log('firms:', SELECTED.map(f => `${f.key}(${f.status})`).join(', '));
+
+ // Per-firm bucket of captured intermediate assets, keyed by firm.key.
+ const captured = new Map(SELECTED.map(f => [f.key, new Map()]));
+ let browser;
+ try {
+ const bb = new Browserbase({ apiKey: get(bbEnv, 'BROWSERBASE_API_KEY') });
+ const session = await bb.sessions.create({
+ projectId: get(bbEnv, 'BROWSERBASE_PROJECT_ID'),
+ browserSettings: { solveCaptchas: true, viewport: { width: 1440, height: 1000 } }
+ });
+ console.log('bb session', session.id);
+ browser = await chromium.connectOverCDP(session.connectUrl);
+ const ctx = browser.contexts()[0];
+ const page = ctx.pages()[0] || await ctx.newPage();
+ page.setDefaultTimeout(45000);
+
+ // ONE response listener routes each JSON body to whichever firm owns its host, then runs that
+ // firm's extractor. This is the generalization of refresh-listings.js's single crexi hook.
+ page.on('response', async (resp) => {
+ const url = resp.url();
+ const firm = SELECTED.find(f => f.apiHosts.some(h => url.includes(h)));
+ if (!firm) return;
+ let j; try { j = await resp.json(); } catch { return; }
+ let assets; try { assets = firm.extract(j) || []; } catch { return; }
+ const bucket = captured.get(firm.key);
+ for (const a of assets) if (a && a.id && !bucket.has(a.id)) bucket.set(a.id, a);
+ });
+
+ // Navigate each firm's search pages (city × type) to make its XHRs fire.
+ for (const firm of SELECTED) {
+ for (const [slug] of TYPES) {
+ for (const city of NAV_CITIES) {
+ for (const url of firm.navUrls(city, slug)) {
+ try {
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(4500);
+ await page.mouse.wheel(0, 3000).catch(() => {});
+ await page.waitForTimeout(1500);
+ } catch (e) { console.log('nav err', firm.key, city, slug, String(e.message).split('\n')[0]); }
+ }
+ }
+ }
+ console.log(` ${firm.key}: captured ${captured.get(firm.key).size} raw assets`);
+ }
+ } catch (e) {
+ console.error('FATAL', e.message);
+ } finally {
+ if (browser) await browser.close().catch(() => {});
+ }
+
+ // ── Normalize + dedup + append (shared path) ─────────────────────────────────────────────────
+ const data = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'listings.json'), 'utf8'));
+ const have = new Set(data.listings.map(l => norm(l.address)));
+ const perFirm = {};
+ let added = 0, totalCaptured = 0;
+
+ for (const firm of SELECTED) {
+ const cand = [...captured.get(firm.key).values()].filter(inBand);
+ totalCaptured += captured.get(firm.key).size;
+ let firmAdded = 0;
+ for (const a of cand) {
+ const key = norm(a.address);
+ if (!key || have.has(key)) continue; // cross-firm dedup: first firm to list an address wins
+ have.add(key);
+ data.listings.push(toListing(a, firm));
+ firmAdded++; added++;
+ }
+ perFirm[firm.key] = { captured: captured.get(firm.key).size, inBand: cand.length, added: firmAdded };
+ }
+
+ fs.writeFileSync(path.join(ROOT, 'data', 'listings.json'), JSON.stringify(data, null, 2));
+ fs.writeFileSync(path.join(ROOT, 'data', 'last-refresh.json'),
+ JSON.stringify({ date: today(), firms: perFirm, totalCaptured, added, cost: '~$0.03–0.05 (1 Browserbase session)' }, null, 2));
+
+ console.log('per-firm:', JSON.stringify(perFirm));
+ console.log(`ADDED ${added} new listings (total ${data.listings.length}). Cost: ~$0.03–0.05 (1 Browserbase session).`);
+ console.log('Next: npm run analyze (re-rank listings.json into ranked.json for the viewer).');
+})();
diff --git a/scripts/sources/firms.js b/scripts/sources/firms.js
new file mode 100644
index 0000000..442e9ab
--- /dev/null
+++ b/scripts/sources/firms.js
@@ -0,0 +1,183 @@
+// sources/firms.js — multi-firm source registry + shared normalizer for the SFV CRE tracker.
+//
+// WHY THIS EXISTS: refresh-listings.js was hard-coupled to Crexi (it sniffed api.crexi.com and
+// hand-mapped that one schema). Adding more brokerage/marketplace "firms" by cloning that whole
+// file per firm doesn't scale. This module inverts the control: ONE shared normalizer + dedup,
+// plus a declarative list of FIRMS. The orchestrator (refresh-all.js) drives every firm through
+// the same capture → normalize → dedup → append path, so a new firm is a ~10-line config, not a
+// new scraper.
+//
+// VERIFIED vs NEEDS-DISCOVERY: crexi's extractor is the proven one (ported verbatim from the
+// working refresh-listings.js). The other firms ship with nav configs + the GENERIC listing-shape
+// sniffer (genericExtract), which auto-detects address/price/city-bearing JSON from any XHR on the
+// firm's domain. That gets real coverage for many sites, but each firm should be locked with one
+// `node scripts/discover-firm.js <key>` run (cheap Browserbase session) before you trust its yield.
+// Do not represent a NEEDS-DISCOVERY firm as "verified working" until its discovery pass confirms.
+
+'use strict';
+
+// ── Shared market filter (single source of truth; was duplicated inline in refresh-listings.js) ──
+const SFV = new Set(['van nuys', 'reseda', 'north hollywood', 'northridge', 'panorama city',
+ 'canoga park', 'sherman oaks', 'sun valley', 'encino', 'granada hills', 'tarzana', 'woodland hills',
+ 'winnetka', 'west hills', 'lake balboa', 'valley village', 'valley glen', 'studio city', 'toluca lake',
+ 'arleta', 'pacoima', 'mission hills', 'sylmar', 'tujunga', 'sunland', 'porter ranch', 'chatsworth',
+ 'north hills']);
+
+const MINP = 800000, MAXP = 1750000;
+
+// Address-normalizer used as the cross-firm dedup key (identical rules to the original refresh).
+const norm = s => String(s || '').toLowerCase()
+ .replace(/[^a-z0-9 ]/g, '')
+ .replace(/\b(ave|avenue|st|street|blvd|boulevard|dr|drive|rd|road|ln|lane|pl|place|ct|court)\b/g, '')
+ .replace(/\s+/g, ' ').trim();
+
+// The two asset classes we navigate, with each firm's URL slug for them.
+const TYPES = [['multifamily', 'Multifamily'], ['retail', 'Retail']];
+
+const today = () => new Date().toISOString().slice(0, 10);
+
+// ── Generic listing-shape sniffer ───────────────────────────────────────────────────────────────
+// Given any parsed JSON body from a firm's API, dig out an array of "asset-like" objects and map
+// each to the common intermediate shape { id, price, city, address, cap, types, desc, urlSlug }.
+// Heuristic but conservative: it only keeps objects that have BOTH a plausible price and a city.
+const pickArray = (j) => {
+ if (Array.isArray(j)) return j;
+ for (const k of ['data', 'results', 'assets', 'items', 'listings', 'properties', 'hits', 'records', 'docs']) {
+ if (Array.isArray(j?.[k])) return j[k];
+ if (Array.isArray(j?.[k]?.items)) return j[k].items; // {data:{items:[...]}}
+ }
+ // Last resort: first array-of-objects value on the top level.
+ for (const v of Object.values(j || {})) {
+ if (Array.isArray(v) && v.length && typeof v[0] === 'object') return v;
+ }
+ return [];
+};
+
+const num = (v) => { const n = +String(v == null ? '' : v).replace(/[^0-9.]/g, ''); return Number.isFinite(n) && n > 0 ? n : null; };
+
+const firstStr = (obj, keys) => { for (const k of keys) { const v = obj?.[k]; if (typeof v === 'string' && v.trim()) return v.trim(); } return ''; };
+
+// Pull a {city,address} out of an asset that may nest them under locations[0] / address{} / fields.
+const locOf = (a) => {
+ const loc = (Array.isArray(a.locations) && a.locations[0]) || a.location || a.address || a.geo || a;
+ const city = firstStr(loc, ['city', 'cityName', 'town']) || firstStr(a, ['city', 'cityName']);
+ const address = firstStr(loc, ['address', 'addressLine1', 'street', 'line1', 'fullAddress', 'name'])
+ || firstStr(a, ['address', 'name', 'title', 'displayAddress']);
+ return { city, address };
+};
+
+function genericExtract(json) {
+ const out = [];
+ for (const a of pickArray(json)) {
+ if (!a || typeof a !== 'object') continue;
+ const price = num(a.askingPrice ?? a.price ?? a.listPrice ?? a.salePrice ?? a.priceValue ?? a.amount);
+ const { city, address } = locOf(a);
+ if (!price || !city || !address) continue;
+ const id = String(a.id ?? a.assetId ?? a.listingId ?? a.propertyId ?? a.slug ?? address).slice(0, 64);
+ const capRaw = a.capRate ?? a.cap ?? a.capRateValue ?? null;
+ out.push({
+ id, price, city, address,
+ cap: typeof capRaw === 'number' ? capRaw : num(capRaw),
+ urlSlug: firstStr(a, ['urlSlug', 'slug', 'url', 'detailUrl', 'permalink']),
+ desc: firstStr(a, ['description', 'summary', 'remarks', 'subtitle']),
+ types: a.types || a.propertyTypes || (a.propertyType ? [a.propertyType] : [])
+ });
+ }
+ return out;
+}
+
+// Crexi's verified extractor — its API matches genericExtract anyway, but we keep it explicit so the
+// reference source is never silently changed by a heuristic tweak.
+function crexiExtract(json) {
+ const arr = Array.isArray(json) ? json : (json.data || json.assets || json.results || []);
+ if (!Array.isArray(arr)) return [];
+ return arr.map(a => {
+ const loc = (a.locations && a.locations[0]) || {};
+ const price = a.askingPrice || a.price;
+ if (!(a.id || a.assetId) || !price || !loc.city) return null;
+ return {
+ id: a.id || a.assetId, price: +price, city: loc.city, address: loc.address || (a.name || ''),
+ cap: typeof a.capRate === 'number' ? a.capRate : null, urlSlug: a.urlSlug || '',
+ desc: String(a.description || ''), types: a.types || []
+ };
+ }).filter(Boolean);
+}
+
+// ── Firm registry ────────────────────────────────────────────────────────────────────────────────
+// key stable id used in CC_FIRMS, the listing id prefix, and discover-firm.
+// name human label.
+// idPrefix prepended to each listing id so cross-firm ids never collide.
+// apiHosts substrings; the orchestrator captures any JSON response whose URL contains one.
+// navUrls (city, typeSlug) -> array of pages to visit to make the firm's search XHRs fire.
+// extract (json) -> intermediate assets. Defaults to genericExtract.
+// status 'verified' (proven) or 'needs-discovery' (wired, run discover-firm to confirm yield).
+const FIRMS = [
+ {
+ key: 'crexi', name: 'Crexi', idPrefix: 'crx', status: 'verified',
+ apiHosts: ['api.crexi.com'],
+ navUrls: (city, slug) => [`https://www.crexi.com/properties/CA/${city.replace(/\s+/g, '-')}/${slug}`],
+ extract: crexiExtract,
+ sourceUrl: (a) => 'https://www.crexi.com/properties/' + a.id + (a.urlSlug ? '/' + a.urlSlug : '')
+ },
+ {
+ key: 'marcusmillichap', name: 'Marcus & Millichap', idPrefix: 'mm', status: 'needs-discovery',
+ // M&M is the dominant SFV multifamily brokerage — highest-value add. Property search:
+ apiHosts: ['marcusmillichap.com', 'api.marcusmillichap'],
+ navUrls: (city) => [`https://www.marcusmillichap.com/properties?searchText=${encodeURIComponent(city + ', CA')}`],
+ extract: genericExtract
+ },
+ {
+ key: 'lee-associates', name: 'Lee & Associates', idPrefix: 'lee', status: 'needs-discovery',
+ apiHosts: ['lee-associates.com'],
+ navUrls: (city) => [`https://www.lee-associates.com/properties/?keyword=${encodeURIComponent(city + ' CA')}`],
+ extract: genericExtract
+ },
+ {
+ key: 'colliers', name: 'Colliers', idPrefix: 'col', status: 'needs-discovery',
+ apiHosts: ['colliers.com'],
+ navUrls: (city) => [`https://www.colliers.com/en-us/properties?searchTerm=${encodeURIComponent(city + ', CA')}`],
+ extract: genericExtract
+ },
+ {
+ key: 'cbre', name: 'CBRE', idPrefix: 'cbre', status: 'needs-discovery',
+ apiHosts: ['cbre.com'],
+ navUrls: (city) => [`https://www.cbre.com/properties/properties-for-sale?location=${encodeURIComponent(city + ', CA')}`],
+ extract: genericExtract
+ },
+ {
+ key: 'jll', name: 'JLL', idPrefix: 'jll', status: 'needs-discovery',
+ apiHosts: ['jll.com', 'property.jll.com'],
+ navUrls: (city) => [`https://property.jll.com/search?q=${encodeURIComponent(city + ', CA')}`],
+ extract: genericExtract
+ }
+];
+
+const byKey = (key) => FIRMS.find(f => f.key === key);
+
+// ── Shared normalizer: intermediate asset (+ its firm) -> the canonical listings.json record. ─────
+// This is the EXACT field shape the original refresh produced, so analyze.js / ranked.json need no
+// changes. We add `firm` and `firm_key` so the viewer can facet "who's listing what".
+function toListing(a, firm) {
+ const type = (a.types || []).some(t => /retail/i.test(t)) ? 'Retail'
+ : (a.types || []).some(t => /mixed/i.test(t)) ? 'Mixed-use' : 'Multifamily';
+ const units = (() => {
+ const m = String(a.desc || '').match(/(?:\|\s*)?\b(\d{1,3})\s*units?\b/i);
+ if (m) { const n = +m[1]; if (n >= 1 && n <= 500 && a.price / n >= 50000) return n; }
+ return null;
+ })();
+ const src = firm.sourceUrl ? firm.sourceUrl(a)
+ : (a.urlSlug && /^https?:/.test(a.urlSlug) ? a.urlSlug : '');
+ return {
+ id: firm.idPrefix + a.id, address: a.address, city: a.city, zip: '', type,
+ price: a.price, units: units || 1, sqft: null, cap_rate: a.cap ?? null, verified: false,
+ year_built: null, rent_control: 'verify', status: 'Active',
+ firm: firm.name, firm_key: firm.key,
+ upside_note: `Auto-added by ${firm.name} refresh ${today()}. ${a.cap ? 'Broker cap ' + a.cap + '% (unverified).' : 'Cap not disclosed.'} Verify status, rent roll, and financials before acting.`,
+ source: src
+ };
+}
+
+// Apply the SFV + price-band + plausibility gate to a batch of intermediate assets.
+const inBand = (a) => a && a.city && SFV.has(a.city.toLowerCase()) && a.price >= MINP && a.price <= MAXP && a.address;
+
+module.exports = { SFV, MINP, MAXP, TYPES, norm, today, FIRMS, byKey, genericExtract, toListing, inBand };
← aa88a98 CRE reports: add score bar-chart, rich table-of-info ($/unit
·
back to Commercialrealestate
·
auto-save: 2026-06-26T23:36:14 (2 files) — scripts/refresh-a 9c2ab88 →