← back to Commercialrealestate

scripts/sources/firms.js

280 lines

// 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';

const { LA_COUNTY, inLACounty } = require('./la-county');

// ── Shared market filter ─────────────────────────────────────────────────────────────────────────
// Scope is now ALL of Los Angeles County (per Steve, 2026-06-27 "all LA county frankly"). The geo
// gate is the canonical LA_COUNTY place set in la-county.js (88 incorporated cities + major LA
// neighborhoods + unincorporated communities). `SFV` is kept as an alias so any older import that
// references it still works — it now points at the full county set.
const SFV = LA_COUNTY;

// Capture band — WIDE by default (Steve: "add all properties", 2026-06-27). We capture the whole LA
// County commercial market and let the VIEWER filter by budget ($400k-down affordability) on top of
// the full set. MINP just rejects obvious non-prices/typos; MAXP is effectively uncapped. Override
// via CC_MINP / CC_MAXP to re-narrow a sweep.
const MINP = +(process.env.CC_MINP || 50000);
const MAXP = +(process.env.CC_MAXP || 1000000000);

// 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();

// All commercial asset classes we navigate, with each firm's URL slug. Expanded from multifamily+
// retail to the full set (Steve: "all properties") so office/industrial/land/hospitality are captured.
const TYPES = [
  ['multifamily', 'Multifamily'], ['retail', 'Retail'], ['office', 'Office'],
  ['industrial', 'Industrial'], ['mixed-use', 'Mixed-use'], ['land', 'Land'],
  ['hospitality', 'Hospitality']
];

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 ARRAY_KEYS = ['data', 'results', 'assets', 'items', 'listings', 'properties', 'hits', 'records', 'docs'];
const pickArray = (j) => {
  if (Array.isArray(j)) return j;
  if (!j || typeof j !== 'object') return [];
  // Case-insensitive match against known container keys (M&M's .NET API uses "Results", etc.).
  const lower = {}; for (const k of Object.keys(j)) lower[k.toLowerCase()] = k;
  for (const want of ARRAY_KEYS) {
    const k = lower[want]; if (k == null) continue;
    const v = j[k];
    if (Array.isArray(v)) return v;
    if (v && typeof v === 'object') {                         // {Results:{Items:[...]}} / {data:{items:[...]}}
      const inner = pickArray(v); if (inner.length) return inner;
    }
  }
  // Last resort: first non-empty 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; };

// Case-insensitive field access — many firm APIs (M&M's .NET, etc.) capitalize keys (ListPrice, City).
const ciIndex = (o) => { const m = {}; if (o && typeof o === 'object') for (const k of Object.keys(o)) m[k.toLowerCase()] = o[k]; return m; };
const ciStr = (o, names) => { const m = ciIndex(o); for (const n of names) { const v = m[n.toLowerCase()]; if (typeof v === 'string' && v.trim()) return v.trim(); } return ''; };
const ciVal = (o, names) => { const m = ciIndex(o); for (const n of names) { const v = m[n.toLowerCase()]; if (v != null) return v; } return null; };

// Pull a {city,address} out of an asset that may nest them under locations[0] / address{} / fields.
const locOf = (a) => {
  const m = ciIndex(a);
  const loc = (Array.isArray(m.locations) && m.locations[0]) || m.location || (m.address && typeof m.address === 'object' ? m.address : null) || m.geo || a;
  const city = ciStr(loc, ['city', 'cityName', 'town']) || ciStr(a, ['city', 'cityName', 'town']);
  const address = ciStr(loc, ['address', 'addressLine1', 'street', 'line1', 'fullAddress', 'streetAddress', 'name'])
    || ciStr(a, ['address', 'streetAddress', 'addressLine1', '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(ciVal(a, ['askingPrice', 'price', 'listPrice', 'salePrice', 'priceValue', 'amount']));
    const { city, address } = locOf(a);
    if (!price || !city || !address) continue;
    const id = String(ciVal(a, ['id', 'assetId', 'listingId', 'propertyId', 'slug']) ?? address).slice(0, 64);
    const capRaw = ciVal(a, ['capRate', 'cap', 'capRateValue']);
    out.push({
      id, price, city, address,
      cap: typeof capRaw === 'number' ? capRaw : num(capRaw),
      urlSlug: ciStr(a, ['urlSlug', 'slug', 'url', 'detailUrl', 'permalink']),
      desc: ciStr(a, ['description', 'summary', 'remarks', 'subtitle']),
      types: ciVal(a, ['types', 'propertyTypes']) || (ciVal(a, ['propertyType']) ? [ciVal(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. DISCOVERY (2026-06-27): it
    // HAS a real JSON API — POST /api/contentsearch/properties (and /mapproperties) returning
    // {Results:[...]} with capitalized .NET field names (now handled by the case-insensitive sniffer).
    // Remaining gap: the search returns empty Results unless the POST body carries the location filter
    // (lat/lng/polygon). A search URL nav alone didn't populate it. TODO: capture & replay the POST
    // payload (a discover2-style request-body log) to get populated results.
    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
  },
  // ── Additional commercial brokerages active in the San Fernando Valley (2026-06-27) ──
  // Major multifamily/commercial shops that list SFV deals. All start needs-discovery: run
  // `node scripts/discover-firm.js <key>` (or just include them in a refresh-all sweep) to confirm
  // each one's public property-search emits listing JSON the generic sniffer can read.
  {
    key: 'matthews', name: 'Matthews REIS', idPrefix: 'mat', status: 'needs-discovery',
    apiHosts: ['matthews.com'],
    navUrls: (city) => [`https://www.matthews.com/listings/?location=${encodeURIComponent(city + ', CA')}`],
    extract: genericExtract
  },
  {
    key: 'kidder', name: 'Kidder Mathews', idPrefix: 'kid', status: 'needs-discovery',
    apiHosts: ['kidder.com'],
    navUrls: (city) => [`https://kidder.com/properties/?search=${encodeURIComponent(city + ', CA')}`],
    extract: genericExtract
  },
  {
    key: 'naicapital', name: 'NAI Capital', idPrefix: 'nai', status: 'needs-discovery',
    apiHosts: ['naicapital.com'],
    navUrls: (city) => [`https://www.naicapital.com/properties/?keyword=${encodeURIComponent(city + ' CA')}`],
    extract: genericExtract
  },
  {
    key: 'cushwake', name: 'Cushman & Wakefield', idPrefix: 'cw', status: 'needs-discovery',
    apiHosts: ['cushmanwakefield.com'],
    navUrls: (city) => [`https://www.cushmanwakefield.com/en/united-states/properties?q=${encodeURIComponent(city + ', CA')}`],
    extract: genericExtract
  },
  {
    key: 'newmark', name: 'Newmark', idPrefix: 'nmrk', status: 'needs-discovery',
    apiHosts: ['nmrk.com'],
    navUrls: (city) => [`https://www.nmrk.com/properties?location=${encodeURIComponent(city + ', CA')}`],
    extract: genericExtract
  },
  {
    key: 'berkadia', name: 'Berkadia', idPrefix: 'berk', status: 'needs-discovery',
    apiHosts: ['berkadia.com'],
    navUrls: (city) => [`https://www.berkadia.com/listings/?location=${encodeURIComponent(city + ', CA')}`],
    extract: genericExtract
  },
  {
    key: 'stepp', name: 'Stepp Commercial', idPrefix: 'stp', status: 'needs-discovery',
    apiHosts: ['steppcommercial.com'],
    navUrls: (city) => [`https://www.steppcommercial.com/listings/?q=${encodeURIComponent(city)}`],
    extract: genericExtract
  },
  {
    // DISCOVERY (2026-06-27): LoopNet (CoStar-owned) emitted 0 JSON on its apiHosts — it server-renders
    // and is heavily anti-bot; Browserbase sees no listing API. Treat as a dead end without a much
    // deeper anti-bot approach. Crexi already covers most of the same inventory.
    key: 'loopnet', name: 'LoopNet', idPrefix: 'ln', status: 'needs-discovery',
    apiHosts: ['loopnet.com', 'api.loopnet.com'],
    navUrls: (city, slug) => [`https://www.loopnet.com/search/${slug === 'retail' ? 'retail' : 'multifamily'}/${city.replace(/\s+/g, '-').toLowerCase()}-ca/for-sale/`],
    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 classifyType(types) {
  const s = (Array.isArray(types) ? types.join(' ') : String(types || '')).toLowerCase();
  if (/multi.?family|apartment|duplex|triplex|fourplex/.test(s)) return 'Multifamily';
  if (/mixed.?use/.test(s)) return 'Mixed-use';
  if (/retail|shopping|storefront|net.?lease/.test(s)) return 'Retail';
  if (/office|medical office/.test(s)) return 'Office';
  if (/industrial|warehouse|flex|manufactur|distribution/.test(s)) return 'Industrial';
  if (/hospitality|hotel|motel|lodging/.test(s)) return 'Hospitality';
  if (/\bland\b|development|lot/.test(s)) return 'Land';
  if (/self.?storage/.test(s)) return 'Self-storage';
  if (/mobile.?home|manufactured housing/.test(s)) return 'Mobile-home park';
  return 'Multifamily';
}
function toListing(a, firm) {
  const type = classifyType(a.types);
  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 };