[object Object]

← back to Commercialrealestate

Fix Crexi extractor for universal-search/v2 API (json.items + new schema + CA-state gate); unblocks stale feed

b2acc84503940cdf51d7a5ef7a6aff6936d42f11 · 2026-09-22 15:10:57 -0700 · Steve Abrams

Files touched

Diff

commit b2acc84503940cdf51d7a5ef7a6aff6936d42f11
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 22 15:10:57 2026 -0700

    Fix Crexi extractor for universal-search/v2 API (json.items + new schema + CA-state gate); unblocks stale feed
---
 scripts/refresh-listings-multi.js | 24 +++++++++++++++++++-----
 scripts/sources/firms.js          | 28 ++++++++++++++++++++++------
 2 files changed, 41 insertions(+), 11 deletions(-)

diff --git a/scripts/refresh-listings-multi.js b/scripts/refresh-listings-multi.js
index 82ea98a..79d6a4e 100644
--- a/scripts/refresh-listings-multi.js
+++ b/scripts/refresh-listings-multi.js
@@ -51,13 +51,27 @@ async function makeBrowser() {
       if (!resp.url().includes('api.crexi.com')) return;
       try {
         const j = await resp.json();
-        const arr = Array.isArray(j) ? j : (j.data || j.assets || j.results || []);
+        // Crexi migrated (2026-09) to universal-search/v2: rows in j.items, address is an ARRAY carrying
+        // stateCode, price under propertyPrice.total, type/subType under propertyAttributes. Old-shape
+        // fallbacks kept. Drop rows whose state is present and not CA (cross-state contamination guard).
+        const arr = Array.isArray(j) ? j : (j.items || j.data || j.assets || j.results || []);
         if (Array.isArray(arr)) for (const a of arr) {
-          const id = a.id || a.assetId, price = a.askingPrice || a.price, loc = (a.locations && a.locations[0]) || {};
-          if (id && price && loc.city && !assets.has(id)) assets.set(id, {
-            id, price: +price, city: loc.city, address: loc.address || (a.name || ''),
+          if (!a || typeof a !== 'object') continue;
+          const loc = Array.isArray(a.address) ? (a.address[0] || {})
+            : (a.address && typeof a.address === 'object' ? a.address : ((a.locations && a.locations[0]) || {}));
+          const st = String(loc.stateCode || loc.state || loc.stateName || '').toUpperCase();
+          if (st && st !== 'CA' && st !== 'CALIFORNIA') continue;
+          const id = a.id || a.assetId;
+          const price = (a.propertyPrice && a.propertyPrice.total) || a.askingPrice || a.price;
+          const city = loc.city || loc.cityName;
+          const address = loc.streetAddress || loc.address || loc.fullAddress || a.propertyName || a.name || '';
+          const attrs = a.propertyAttributes || {};
+          if (id && price && city && !assets.has(id)) assets.set(id, {
+            id, price: +price, city, address,
             cap: typeof a.capRate === 'number' ? a.capRate : null, urlSlug: a.urlSlug || '',
-            desc: String(a.description || ''), types: a.types || [], metro: null,
+            desc: String(a.description || ''),
+            types: (Array.isArray(a.types) && a.types.length) ? a.types : [attrs.type, attrs.subType].filter(Boolean),
+            metro: null,
           });
         }
       } catch (_) {}
diff --git a/scripts/sources/firms.js b/scripts/sources/firms.js
index 75cd98b..23f2973 100644
--- a/scripts/sources/firms.js
+++ b/scripts/sources/firms.js
@@ -113,16 +113,32 @@ function genericExtract(json) {
 // 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 || []);
+  // Crexi migrated (2026-09) to api.crexi.com/universal-search/v2/search: rows now live in json.items
+  // and use a new schema — `address` is an ARRAY of {stateCode,city,streetAddress,zip,...}, price is
+  // `propertyPrice.total`, and type/subType live under `propertyAttributes`. Old-shape fallbacks
+  // (data/assets/results + locations[]/askingPrice/capRate) are kept so this survives either schema.
+  // The search API also returns NATIONAL rows on the initial (unscoped) call, so we drop any row whose
+  // state is present and not California — the exact cross-state contamination class the
+  // crcp-contamination-canary guards (e.g. Pasadena/Georgetown TX bleeding onto CA city subdomains).
+  const arr = Array.isArray(json) ? json : (json.items || 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;
+    if (!a || typeof a !== 'object') return null;
+    const loc = Array.isArray(a.address) ? (a.address[0] || {})
+      : (a.address && typeof a.address === 'object' ? a.address : ((a.locations && a.locations[0]) || {}));
+    const st = String(loc.stateCode || loc.state || loc.stateName || '').toUpperCase();
+    if (st && st !== 'CA' && st !== 'CALIFORNIA') return null;   // California-only gate (contamination guard)
+    const id = a.id || a.assetId;
+    const price = (a.propertyPrice && a.propertyPrice.total) || a.askingPrice || a.price;
+    const city = loc.city || loc.cityName;
+    const address = loc.streetAddress || loc.address || loc.fullAddress || a.propertyName || a.name || '';
+    if (!id || !price || !city) return null;
+    const attrs = a.propertyAttributes || {};
     return {
-      id: a.id || a.assetId, price: +price, city: loc.city, address: loc.address || (a.name || ''),
+      id, price: +price, city, address,
       cap: typeof a.capRate === 'number' ? a.capRate : null, urlSlug: a.urlSlug || '',
-      desc: String(a.description || ''), types: a.types || []
+      desc: String(a.description || ''),
+      types: (Array.isArray(a.types) && a.types.length) ? a.types : [attrs.type, attrs.subType].filter(Boolean)
     };
   }).filter(Boolean);
 }

← 9a4acb8 afternoon CRE update 2026-09-22  ·  back to Commercialrealestate  ·  auto-data-snapshot: 2026-09-22T15:36:13 (3 data files) — dat 52b68ab →