[object Object]

← back to Sublease Agentabrams

auto-save: 2026-07-21T13:37:45 (7 files) — public/index.html scripts/export-snapshot.js server.js crawl/crexi-national-brokers.js crawl/crexi-recon.js

ded7ae628a94b556ccf50267e8ad8d97ade9cb31 · 2026-07-21 13:37:46 -0700 · Steve Abrams

Files touched

Diff

commit ded7ae628a94b556ccf50267e8ad8d97ade9cb31
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 21 13:37:46 2026 -0700

    auto-save: 2026-07-21T13:37:45 (7 files) — public/index.html scripts/export-snapshot.js server.js crawl/crexi-national-brokers.js crawl/crexi-recon.js
---
 crawl/crexi-national-brokers.js |  97 ++++++++++++++++++++++
 crawl/crexi-recon.js            |  39 +++++++++
 data/crexi-national-run.json    | 176 ++++++++++++++++++++++++++++++++++++++++
 data/crexi-recon.json           |  82 +++++++++++++++++++
 public/index.html               |   6 +-
 scripts/export-snapshot.js      |   2 +-
 server.js                       |  15 ++--
 7 files changed, 409 insertions(+), 8 deletions(-)

diff --git a/crawl/crexi-national-brokers.js b/crawl/crexi-national-brokers.js
new file mode 100644
index 0000000..a316c40
--- /dev/null
+++ b/crawl/crexi-national-brokers.js
@@ -0,0 +1,97 @@
+'use strict';
+// National broker fetch: for each US state, load Crexi's property list, pull asset IDs from
+// the DOM, then fetch /assets/<id>/brokers (in-page, cookie-authed). Upsert real brokers with
+// their state. Metered Browserbase — HARD budget cap. $0.15/session-min.
+const fs = require('fs');
+const path = require('path');
+const { chromium } = require('playwright-core');
+const Browserbase = require('@browserbasehq/sdk').default || require('@browserbasehq/sdk');
+const { Pool } = require('pg');
+const pool = new Pool({ host: '/tmp', database: 'crunified' });
+const envTxt = fs.readFileSync(path.join(process.env.HOME, '.claude/skills/browserbase/.env'), 'utf8');
+const API_KEY = (envTxt.match(/BROWSERBASE_API_KEY=(.*)/) || [])[1]?.trim();
+const PROJECT = (envTxt.match(/BROWSERBASE_PROJECT_ID=(.*)/) || [])[1]?.trim();
+
+const STATES = ['AL','AK','AZ','AR','CA','CO','CT','DE','FL','GA','HI','ID','IL','IN','IA','KS','KY','LA','ME','MD','MA','MI','MN','MS','MO','MT','NE','NV','NH','NJ','NM','NY','NC','ND','OH','OK','OR','PA','RI','SC','SD','TN','TX','UT','VT','VA','WA','WV','WI','WY'];
+const CAP_MIN = Number(process.env.CREXI_CAP_MIN || 90);   // hard budget cap in session-minutes ($0.15/min)
+const PER_STATE = Number(process.env.CREXI_PER_STATE || 40); // assets sampled per state
+
+async function upsertBroker(b, state) {
+  if (!b.name) return false;
+  const { rows } = await pool.query(
+    `INSERT INTO brokers (name, firm, total_assets, crexi_id, source, state)
+     VALUES ($1,$2,$3,$4,'crexi',$5)
+     ON CONFLICT (crexi_id) WHERE crexi_id IS NOT NULL
+     DO UPDATE SET state=COALESCE(brokers.state, EXCLUDED.state), firm=COALESCE(EXCLUDED.firm, brokers.firm),
+       total_assets=GREATEST(COALESCE(brokers.total_assets,0), COALESCE(EXCLUDED.total_assets,0))
+     RETURNING (xmax=0) AS inserted`,
+    [b.name, b.firm || null, b.total_assets || null, b.crexi_id || null, state]);
+  return rows[0]?.inserted === true;
+}
+
+async function doState(page, st) {
+  await page.goto(`https://www.crexi.com/properties/${st}`, { waitUntil: 'domcontentloaded', timeout: 40000 });
+  await page.waitForTimeout(4500);
+  for (let s = 0; s < 3; s++) { await page.mouse.wheel(0, 4000).catch(() => {}); await page.waitForTimeout(1500); }
+  const html = await page.content();
+  const ids = [...new Set([...html.matchAll(/\/properties\/(\d{5,})/g)].map(m => m[1]))].slice(0, PER_STATE);
+  let seen = 0, added = 0;
+  for (let i = 0; i < ids.length; i += 25) {
+    const batch = ids.slice(i, i + 25);
+    const out = await page.evaluate(async (batch) => {
+      const r = [];
+      for (const id of batch) {
+        try { const resp = await fetch(`https://api.crexi.com/assets/${id}/brokers`, { headers: {} });
+          if (resp.ok) r.push({ id, brokers: await resp.json() }); } catch {}
+      }
+      return r;
+    }, batch);
+    for (const row of out) {
+      if (!Array.isArray(row.brokers)) continue;
+      for (const b of row.brokers) {
+        const name = [b.firstName, b.lastName].filter(Boolean).join(' ').trim(); if (!name) continue;
+        seen++;
+        if (await upsertBroker({ name, firm: b.brokerage && b.brokerage.name, total_assets: b.numberOfAssets || null, crexi_id: String(b.id || b.globalId || '') || null }, st)) added++;
+      }
+    }
+  }
+  return { assets: ids.length, brokers: seen, new: added };
+}
+
+(async () => {
+  const t0 = Date.now();
+  const bb = new Browserbase({ apiKey: API_KEY });
+  // Resume: skip states already covered (unless CREXI_FORCE=1)
+  const done = process.env.CREXI_FORCE ? new Set()
+    : new Set((await pool.query(`SELECT DISTINCT state FROM brokers WHERE source='crexi' AND state IS NOT NULL`)).rows.map(r => r.state));
+  const todo = STATES.filter(st => !done.has(st));
+  console.log(`${done.size} states already covered; ${todo.length} to do: ${todo.join(',')}`);
+  const perState = {}; let totalNew = 0, totalSeen = 0;
+  const BATCH_STATES = 8;  // fresh browser session per 8 states — avoids long-session death
+
+  for (let bi = 0; bi < todo.length; bi += BATCH_STATES) {
+    if ((Date.now() - t0) / 60000 > CAP_MIN) { console.log(`\n⏹ budget cap ${CAP_MIN}min reached`); break; }
+    const batch = todo.slice(bi, bi + BATCH_STATES);
+    let browser;
+    try {
+      const session = await bb.sessions.create({ projectId: PROJECT, browserSettings: { solveCaptchas: true, viewport: { width: 1440, height: 1000 } } });
+      browser = await chromium.connectOverCDP(session.connectUrl);
+      const ctx = browser.contexts()[0]; const page = ctx.pages()[0] || await ctx.newPage();
+      page.setDefaultTimeout(45000);
+      for (const st of batch) {
+        try { const r = await doState(page, st); perState[st] = r; totalNew += r.new; totalSeen += r.brokers; process.stdout.write(`${st}:${r.new}new  `); }
+        catch (e) { perState[st] = { err: String(e).slice(0, 40) }; process.stdout.write(`${st}:ERR  `); }
+      }
+    } catch (e) { console.log(`\nsession batch [${batch.join(',')}] failed: ${String(e).slice(0, 60)}`); }
+    finally { if (browser) await browser.close().catch(() => {}); }
+  }
+
+  const mins = (Date.now() - t0) / 60000, cost = mins * 0.15;
+  const byState = (await pool.query(`SELECT state, count(*) n FROM brokers WHERE source='crexi' AND state IS NOT NULL GROUP BY state ORDER BY n DESC`)).rows;
+  console.log(`\n\n=== DONE · ${mins.toFixed(1)} min · $${cost.toFixed(2)} ===`);
+  console.log(`new brokers this run: ${totalNew} (of ${totalSeen} seen)`);
+  console.log('states with crexi brokers now:', byState.length);
+  console.log('top:', byState.slice(0, 15).map(r => `${r.state}:${r.n}`).join(' '));
+  fs.writeFileSync(path.join(__dirname, '..', 'data', 'crexi-national-run.json'), JSON.stringify({ mins, cost, perState }, null, 1));
+  await pool.end();
+})();
diff --git a/crawl/crexi-recon.js b/crawl/crexi-recon.js
new file mode 100644
index 0000000..66e1e3a
--- /dev/null
+++ b/crawl/crexi-recon.js
@@ -0,0 +1,39 @@
+'use strict';
+// Recon (~$0.30): capture Crexi's bearer token + discover the property-search and/or
+// broker-directory API endpoints so the national broker fetch runs with a known plan.
+const fs = require('fs');
+const path = require('path');
+const { chromium } = require('playwright-core');
+const Browserbase = require('@browserbasehq/sdk').default || require('@browserbasehq/sdk');
+const envTxt = fs.readFileSync(path.join(process.env.HOME, '.claude/skills/browserbase/.env'), 'utf8');
+const API_KEY = (envTxt.match(/BROWSERBASE_API_KEY=(.*)/) || [])[1]?.trim();
+const PROJECT = (envTxt.match(/BROWSERBASE_PROJECT_ID=(.*)/) || [])[1]?.trim();
+
+(async () => {
+  const t0 = Date.now();
+  const bb = new Browserbase({ apiKey: API_KEY });
+  const session = await bb.sessions.create({ projectId: PROJECT, browserSettings: { solveCaptchas: true, viewport: { width: 1440, height: 1000 } } });
+  const browser = await chromium.connectOverCDP(session.connectUrl);
+  const ctx = browser.contexts()[0]; const page = ctx.pages()[0] || await ctx.newPage();
+  page.setDefaultTimeout(45000);
+  let auth = null; const apis = new Map();
+  page.on('request', req => { const h = req.headers(); if (!auth && h.authorization && /bearer/i.test(h.authorization) && req.url().includes('api.crexi.com')) auth = h.authorization; });
+  page.on('response', async (res) => {
+    const u = res.url(); if (!u.includes('api.crexi.com')) return;
+    const key = u.split('?')[0];
+    if (!apis.has(key)) { let sample = ''; try { const t = await res.text(); sample = t.slice(0, 240); } catch {} apis.set(key, { status: res.status, sample, full: u }); }
+  });
+  const visited = [];
+  for (const url of ['https://www.crexi.com/properties/TX/Houston', 'https://www.crexi.com/brokers']) {
+    try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 40000 }); await page.waitForTimeout(6000); await page.mouse.wheel(0, 3000).catch(()=>{}); await page.waitForTimeout(3000); visited.push(url + ' OK'); }
+    catch (e) { visited.push(url + ' ERR ' + String(e).slice(0,50)); }
+  }
+  await browser.close().catch(()=>{});
+  const mins = (Date.now()-t0)/60000;
+  console.log('visited:', visited.join(' | '));
+  console.log('bearer captured:', auth ? 'YES' : 'no');
+  console.log('cost: $' + (mins*0.15).toFixed(2), `(${mins.toFixed(1)} min)`);
+  console.log('=== api.crexi.com endpoints seen ===');
+  for (const [k, v] of apis) console.log(`[${v.status}] ${k}\n   full: ${v.full.slice(0,160)}\n   sample: ${v.sample.replace(/\s+/g,' ').slice(0,180)}`);
+  fs.writeFileSync(path.join(__dirname, '..', 'data', 'crexi-recon.json'), JSON.stringify({ auth: auth?auth.slice(0,20)+'…':null, apis: [...apis] }, null, 1));
+})();
diff --git a/data/crexi-national-run.json b/data/crexi-national-run.json
new file mode 100644
index 0000000..f340fd9
--- /dev/null
+++ b/data/crexi-national-run.json
@@ -0,0 +1,176 @@
+{
+ "mins": 9.296183333333333,
+ "cost": 1.3944275,
+ "perState": {
+  "AL": {
+   "assets": 0,
+   "brokers": 0,
+   "new": 0
+  },
+  "ID": {
+   "assets": 3,
+   "brokers": 8,
+   "new": 5
+  },
+  "ME": {
+   "assets": 8,
+   "brokers": 19,
+   "new": 13
+  },
+  "MD": {
+   "assets": 4,
+   "brokers": 10,
+   "new": 10
+  },
+  "MA": {
+   "assets": 7,
+   "brokers": 19,
+   "new": 16
+  },
+  "MI": {
+   "assets": 9,
+   "brokers": 23,
+   "new": 16
+  },
+  "MN": {
+   "assets": 12,
+   "brokers": 33,
+   "new": 26
+  },
+  "MS": {
+   "assets": 12,
+   "brokers": 27,
+   "new": 17
+  },
+  "MO": {
+   "assets": 0,
+   "brokers": 0,
+   "new": 0
+  },
+  "MT": {
+   "assets": 1,
+   "brokers": 2,
+   "new": 2
+  },
+  "NE": {
+   "assets": 8,
+   "brokers": 16,
+   "new": 15
+  },
+  "NV": {
+   "assets": 12,
+   "brokers": 29,
+   "new": 14
+  },
+  "NH": {
+   "assets": 0,
+   "brokers": 0,
+   "new": 0
+  },
+  "NJ": {
+   "assets": 5,
+   "brokers": 13,
+   "new": 10
+  },
+  "NM": {
+   "assets": 2,
+   "brokers": 6,
+   "new": 4
+  },
+  "NY": {
+   "assets": 8,
+   "brokers": 27,
+   "new": 22
+  },
+  "NC": {
+   "assets": 0,
+   "brokers": 0,
+   "new": 0
+  },
+  "ND": {
+   "assets": 4,
+   "brokers": 8,
+   "new": 3
+  },
+  "OH": {
+   "assets": 0,
+   "brokers": 0,
+   "new": 0
+  },
+  "OK": {
+   "assets": 2,
+   "brokers": 4,
+   "new": 2
+  },
+  "OR": {
+   "assets": 10,
+   "brokers": 28,
+   "new": 21
+  },
+  "PA": {
+   "assets": 12,
+   "brokers": 37,
+   "new": 28
+  },
+  "RI": {
+   "assets": 8,
+   "brokers": 18,
+   "new": 8
+  },
+  "SC": {
+   "assets": 11,
+   "brokers": 33,
+   "new": 22
+  },
+  "SD": {
+   "assets": 0,
+   "brokers": 0,
+   "new": 0
+  },
+  "TN": {
+   "assets": 0,
+   "brokers": 0,
+   "new": 0
+  },
+  "TX": {
+   "assets": 0,
+   "brokers": 0,
+   "new": 0
+  },
+  "UT": {
+   "assets": 6,
+   "brokers": 14,
+   "new": 11
+  },
+  "VT": {
+   "assets": 0,
+   "brokers": 0,
+   "new": 0
+  },
+  "VA": {
+   "assets": 6,
+   "brokers": 21,
+   "new": 9
+  },
+  "WA": {
+   "assets": 0,
+   "brokers": 0,
+   "new": 0
+  },
+  "WV": {
+   "assets": 4,
+   "brokers": 15,
+   "new": 13
+  },
+  "WI": {
+   "assets": 0,
+   "brokers": 0,
+   "new": 0
+  },
+  "WY": {
+   "assets": 0,
+   "brokers": 0,
+   "new": 0
+  }
+ }
+}
\ No newline at end of file
diff --git a/data/crexi-recon.json b/data/crexi-recon.json
new file mode 100644
index 0000000..4b0cd22
--- /dev/null
+++ b/data/crexi-recon.json
@@ -0,0 +1,82 @@
+{
+ "auth": "Bearer null…",
+ "apis": [
+  [
+   "https://api.crexi.com/lookups/used-asset-types",
+   {
+    "sample": "[\"Retail\",\"Multifamily\",\"Office\",\"Industrial\",\"Hospitality\",\"Mixed Use\",\"Land\",\"Self Storage\",\"Mobile Home Park\",\"Senior Living\",\"Special Purpose\",\"Note/Loan\",\"Business for Sale\"]",
+    "full": "https://api.crexi.com/lookups/used-asset-types"
+   }
+  ],
+  [
+   "https://api.crexi.com/lookups/subtypes",
+   {
+    "sample": "[{\"type\":\"Multifamily\",\"subtypes\":[\"Student Housing\",\"Single Family Rental Portfolio\",\"RV Park\",\"Apartment Building\"]},{\"type\":\"Retail\",\"subtypes\":[\"Bank\",\"Convenience Store\",\"Day Care/Nursery\",\"QSR/Fast Food\",\"Gas Station\",\"Grocery Store\",",
+    "full": "https://api.crexi.com/lookups/subtypes"
+   }
+  ],
+  [
+   "https://api.crexi.com/settings",
+   {
+    "sample": "{\"linkedInClientId\":\"75845l8c55xrxq\",\"facebookClientId\":\"748507905276029\",\"googleClientId\":\"540861202907-t4r7fgpdcdddle5ur2la75a2c2oqgdjm.apps.googleusercontent.com\",\"appleClientId\":\"com.crexi.app.service\",\"ccimSigninUrl\":\"https://ccim.my.s",
+    "full": "https://api.crexi.com/settings"
+   }
+  ],
+  [
+   "https://api.crexi.com/universal-search/search/table-settings",
+   {
+    "sample": "[{\"label\":\"Record type\",\"isRestricted\":false,\"isSortable\":false,\"addToSortDrawer\":false,\"id\":\"recordType\",\"order\":0,\"isHidden\":false,\"isPinned\":false},{\"label\":\"Property name\",\"isRestricted\":false,\"isSortable\":true,\"addToSortDrawer\":false,\"",
+    "full": "https://api.crexi.com/universal-search/search/table-settings"
+   }
+  ],
+  [
+   "https://api.crexi.com/universal-search/stats",
+   {
+    "sample": "{\"propertyAttributes.type\":[{\"title\":\"Land\",\"count\":130945,\"url\":{\"titleTag\":\"Land Types\",\"filters\":{\"propertyAttributes.type\":{\"type\":\"Plain\",\"mode\":\"Include\",\"values\":[\"Land\"],\"structuredValues\":[\"Land\"]}},\"searchTypes\":[\"Sales\"]}},{\"titl",
+    "full": "https://api.crexi.com/universal-search/stats"
+   }
+  ],
+  [
+   "https://api.crexi.com/universal-search/search/structured-filters/ui-config",
+   {
+    "sample": "[{\"filterConfigKey\":\"Location\",\"metadata\":[{\"structuredFilterId\":\"address\",\"dataType\":\"StringArray\",\"restrictions\":[{\"type\":\"Sales\",\"restrictionType\":\"Free\",\"isAccessible\":true},{\"type\":\"Lease\",\"restrictionType\":\"Free\",\"isAccessible\":true},",
+    "full": "https://api.crexi.com/universal-search/search/structured-filters/ui-config"
+   }
+  ],
+  [
+   "https://api.crexi.com/universal-search/tiles/clusters/mvt/3/1/2",
+   {
+    "sample": "\u001a�\u0005\n\bclusters\u0012\u0017\u0012\f\u0000\u0000\u0001\u0001\u0002\u0002\u0003\u0003\u0004\u0004\u0005\u0005\u0018\u0001\"\u0005\t�:�=\u0012\u0017\u0012\f\u0000\u0006\u0001\u0007\u0002\b\u0003\u0002\u0004\t\u0005\n\u0018\u0001\"\u0005\t�\u0017�3\u0012\u0017\u0012\f\u0000\u000b\u0001\f\u0002\u0002\u0003\u0003\u0004\t\u0005\n\u0018\u0001\"\u0005\t�\u0016�;\u0012\u0017\u0012\f\u0000\r\u0001\u000e\u0002\b\u0003\u0002\u0004\u0004\u0005\u0005\u0018\u0001\"\u0005\t�8�5\u0012\u0017\u0012\f\u0000\u000f\u0001\u0010\u0002\u0002\u0003\u0003\u0004\u0005\u0005\t\u0018\u0001\"\u0005\t�&�<\u0012\u0017\u0012\f\u0000\u0011\u0001\u0012\u0002\b\u0003\u0002\u0004\u0005\u0005\t\u0018\u0001\"\u0005\t�$�4\u0012\u0017\u0012\f\u0000\u0013\u0001\u0014\u0002\u0002\u0003\u0003\u0004\n\u0005\u0015\u0018\u0001\"\u0005\t�\u000f�9\u0012\u0017\u0012\f\u0000\u0016\u0001\u0017\u0002\b\u0003\u0002\u0004\n\u0005\u0015\u0018\u0001\"\u0005\t�\u000f�6\u0012\u0016\u0012\f\u0000\u0018\u0001\u0019\u0002\u001a\u0003\u001b\u0004\n\u0005\u0015\u0018\u0001\"\u0004\tZ�\u0019\u0012\u0017\u0012",
+    "full": "https://api.crexi.com/universal-search/tiles/clusters/mvt/3/1/2"
+   }
+  ],
+  [
+   "https://api.crexi.com/universal-search/tiles/clusters/mvt/3/1/3",
+   {
+    "sample": "\u001a�\u0006\n\bclusters\u0012\u0017\u0012\f\u0000\u0000\u0001\u0001\u0002\u0002\u0003\u0003\u0004\u0004\u0005\u0005\u0018\u0001\"\u0005\t�8�\u0011\u0012\u0017\u0012\f\u0000\u0006\u0001\u0007\u0002\b\u0003\u0002\u0004\t\u0005\n\u0018\u0001\"\u0005\t�\u0017�\t\u0012\u0017\u0012\f\u0000\u000b\u0001\f\u0002\b\u0003\u0002\u0004\u0004\u0005\u0005\u0018\u0001\"\u0005\t�9�\u0007\u0012\u0017\u0012\f\u0000\r\u0001\u000e\u0002\u0002\u0003\u0003\u0004\u0005\u0005\t\u0018\u0001\"\u0005\t�'�\u000f\u0012\u0017\u0012\f\u0000\u000f\u0001\u0010\u0002\b\u0003\u0002\u0004\u0005\u0005\t\u0018\u0001\"\u0005\t�(�\b\u0012\u0017\u0012\f\u0000\u0011\u0001\u0012\u0002\u0003\u0003\u0013\u0004\u0004\u0005\u0005\u0018\u0001\"\u0005\t�4�\u0018\u0012\u0017\u0012\f\u0000\u0014\u0001\u0015\u0002\u0002\u0003\u0003\u0004\t\u0005\n\u0018\u0001\"\u0005\t�\u0019�\r\u0012\u0017\u0012\f\u0000\u0016\u0001\u0017\u0002\u0018\u0003\b\u0004\u0005\u0005\t\u0018\u0001\"\u0005\t�(�\u0001\u0012\u0017\u0012\f\u0000\u0019\u0001\u001a\u0002\u0018\u0003\b\u0004\u0004\u0005\u0005\u0018\u0001\"\u0005\t�:�\u0001\u0012\u0017",
+    "full": "https://api.crexi.com/universal-search/tiles/clusters/mvt/3/1/3"
+   }
+  ],
+  [
+   "https://api.crexi.com/universal-search/tiles/clusters/mvt/3/2/2",
+   {
+    "sample": "\u001a�\u0003\n\bclusters\u0012\u0017\u0012\f\u0000\u0000\u0001\u0001\u0002\u0002\u0003\u0003\u0004\u0004\u0005\u0005\u0018\u0001\"\u0005\t�\u0006�=\u0012\u0017\u0012\f\u0000\u0006\u0001\u0007\u0002\u0002\u0003\u0003\u0004\b\u0005\u0004\u0018\u0001\"\u0005\t�\u0017�=\u0012\u0017\u0012\f\u0000\t\u0001\n\u0002\u000b\u0003\u0002\u0004\u0004\u0005\u0005\u0018\u0001\"\u0005\t�\u0004�6\u0012\u0017\u0012\f\u0000\f\u0001\r\u0002\u000b\u0003\u0002\u0004\b\u0005\u0004\u0018\u0001\"\u0005\t�\u001e�6\u0012\u0017\u0012\f\u0000\u000e\u0001\u000f\u0002\u000b\u0003\u0002\u0004\u0010\u0005\b\u0018\u0001\"\u0005\t�!�6\u0012\u0017\u0012\f\u0000\u0011\u0001\u0012\u0002\u0002\u0003\u0003\u0004\u0010\u0005\b\u0018\u0001\"\u0005\t� �8\u0012\u0017\u0012\f\u0000\u0013\u0001\u0014\u0002\u000b\u0003\u0002\u0004\u0015\u0005\u0010\u0018\u0001\"\u0005\t�4�2\u0012\u0017\u0012\f\u0000\u0013\u0001\u0014\u0002\u0016\u0003\u000b\u0004\b\u0005\u0004\u0018\u0001\"\u0005\t�\u0011�!\u001a\u000bclusterSize\u001a\u0017clusterSizeA",
+    "full": "https://api.crexi.com/universal-search/tiles/clusters/mvt/3/2/2"
+   }
+  ],
+  [
+   "https://api.crexi.com/universal-search/tiles/clusters/mvt/3/2/3",
+   {
+    "sample": "\u001a�\u0006\n\bclusters\u0012\u0017\u0012\f\u0000\u0000\u0001\u0001\u0002\u0002\u0003\u0003\u0004\u0004\u0005\u0005\u0018\u0001\"\u0005\t�\b�\t\u0012\u0017\u0012\f\u0000\u0006\u0001\u0007\u0002\u0003\u0003\b\u0004\u0004\u0005\u0005\u0018\u0001\"\u0005\t�\b�\u0011\u0012\u0016\u0012\f\u0000\t\u0001\n\u0002\u000b\u0003\u0002\u0004\f\u0005\u0004\u0018\u0001\"\u0004\t�\u0015z\u0012\u0017\u0012\f\u0000\r\u0001\u000e\u0002\b\u0003\u000f\u0004\u0004\u0005\u0005\u0018\u0001\"\u0005\t�\f�\u0018\u0012\u0017\u0012\f\u0000\u0010\u0001\u0011\u0002\u000b\u0003\u0002\u0004\u0004\u0005\u0005\u0018\u0001\"\u0005\t�\b�\u0001\u0012\u0017\u0012\f\u0000\u0012\u0001\u0013\u0002\u0002\u0003\u0003\u0004\f\u0005\u0004\u0018\u0001\"\u0005\t�\u0012�\u0006\u0012\u0017\u0012\f\u0000\u0014\u0001\u0015\u0002\u000f\u0003\u0016\u0004\u0017\u0005\f\u0018\u0001\"\u0005\t�!�%\u0012\u0017\u0012\f\u0000\u0018\u0001\u0019\u0002\u001a\u0003\u001b\u0004\u0004\u0005\u0005\u0018\u0001\"\u0005\t�\u0007�1\u0012\u0017\u0012\f\u0000\u001c\u0001\u001d\u0002\u000f\u0003\u0016\u0004\f\u0005\u0004\u0018\u0001\"\u0005\t�\u001d�$\u0012\u0017\u0012",
+    "full": "https://api.crexi.com/universal-search/tiles/clusters/mvt/3/2/3"
+   }
+  ],
+  [
+   "https://api.crexi.com/seo-info",
+   {
+    "sample": "",
+    "full": "https://api.crexi.com/seo-info?url=/"
+   }
+  ]
+ ]
+}
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index 73a1e3d..0bc1fc9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -99,7 +99,8 @@
 
 <section id="brokers"><div class="wrap">
   <h2>Broker directory <span class="sub" id="bkNote"></span></h2>
-  <div class="controls"><input type="search" id="bq" placeholder="Search brokers by name, firm, area…" style="min-width:320px"></div>
+  <div class="controls"><input type="search" id="bq" placeholder="Search brokers by name, firm, area…" style="min-width:280px">
+    <select id="bstate"><option value="">All states</option></select></div>
   <div class="bgrid" id="bgrid"></div>
 </div></section>
 
@@ -148,7 +149,8 @@ function fillFilters(){
   $('#dens').addEventListener('input',e=>document.documentElement.style.setProperty('--cols',e.target.value));
   const sd=localStorage.getItem('sub_dens'); if(sd){$('#dens').value=sd;document.documentElement.style.setProperty('--cols',sd);}
   $('#dens').addEventListener('change',e=>localStorage.setItem('sub_dens',e.target.value));
-  $('#bq').addEventListener('input',e=>loadBrokers(e.target.value));
+  $('#bq').addEventListener('input',()=>loadBrokers());
+  $('#bstate').addEventListener('change',()=>loadBrokers());
 }
 function renderGrid(){
   const qv=$('#q').value.toLowerCase(),ty=$('#fType').value,sp=$('#fSponsor').value,so=$('#sort').value;
diff --git a/scripts/export-snapshot.js b/scripts/export-snapshot.js
index 2caa29d..3531ba5 100644
--- a/scripts/export-snapshot.js
+++ b/scripts/export-snapshot.js
@@ -19,7 +19,7 @@ const OUT = path.join(__dirname, '..', 'data', 'snapshot.json');
            s.slug AS sponsor_slug, s.name AS sponsor_name, s.logo_path
     FROM listings l LEFT JOIN sponsors s ON s.id=l.sponsor_id WHERE l.status IN ('active','Active') OR l.status IS NULL
     ORDER BY l.is_sublease DESC, l.id`);
-  const brokers = await q(`SELECT id, name, firm, agent_type, phone, email, website, linkedin, office_addr, total_assets, specialties, created_at FROM brokers ORDER BY name`);
+  const brokers = await q(`SELECT id, name, firm, agent_type, phone, email, website, linkedin, office_addr, total_assets, specialties, state, created_at FROM brokers ORDER BY total_assets DESC NULLS LAST, name`);
   const runs = await q(`SELECT s.slug, cr.status, cr.finished_at, cr.listings_found FROM crawl_runs cr JOIN sponsors s ON s.id=cr.sponsor_id ORDER BY cr.started_at DESC LIMIT 20`);
 
   const cnt = (arr, key) => arr.reduce((m, x) => { const k = x[key] || '—'; m[k] = (m[k] || 0) + 1; return m; }, {});
diff --git a/server.js b/server.js
index f0e0ac4..87616c2 100644
--- a/server.js
+++ b/server.js
@@ -145,18 +145,23 @@ app.get('/api/map-points', async (_q, res) => {
 
 app.get('/api/brokers', async (req, res) => {
   try {
-    const { q: search } = req.query; const limit = Math.min(+req.query.limit || 60, 500), offset = +req.query.offset || 0;
+    const { q: search, state } = req.query; const limit = Math.min(+req.query.limit || 60, 500), offset = +req.query.offset || 0;
     if (USE_SNAPSHOT) {
       let rows = snap.brokers || [];
+      if (state) rows = rows.filter(b => b.state === state);
       if (search) { const s = String(search).toLowerCase(); rows = rows.filter(b => `${b.name} ${b.firm} ${b.office_addr}`.toLowerCase().includes(s)); }
-      return res.json({ brokers: rows.slice(offset, offset + limit), count: rows.length });
+      const states = [...new Set((snap.brokers || []).map(b => b.state).filter(Boolean))].sort();
+      return res.json({ brokers: rows.slice(offset, offset + limit), count: rows.length, states });
     }
-    const p = []; let w = '';
-    if (search) { p.push(`%${search}%`); w = `WHERE (name ILIKE $1 OR firm ILIKE $1 OR office_addr ILIKE $1)`; }
+    const p = []; const where = [];
+    if (search) { p.push(`%${search}%`); where.push(`(name ILIKE $${p.length} OR firm ILIKE $${p.length} OR office_addr ILIKE $${p.length})`); }
+    if (state)  { p.push(state); where.push(`state = $${p.length}`); }
+    const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
     const [{ n }] = await q(`SELECT COUNT(*)::int n FROM brokers ${w}`, p);
     p.push(limit, offset);
     const rows = await q(`SELECT * FROM brokers ${w} ORDER BY total_assets DESC NULLS LAST, name LIMIT $${p.length - 1} OFFSET $${p.length}`, p);
-    res.json({ brokers: rows, count: n });
+    const states = (await q(`SELECT DISTINCT state FROM brokers WHERE state IS NOT NULL ORDER BY state`)).map(r => r.state);
+    res.json({ brokers: rows, count: n, states });
   } catch (e) { res.status(500).json({ error: String(e) }); }
 });
 

← 35f6650 chore: lint, refactor, v0.2.0 (session close) — CENSUS_URL d  ·  back to Sublease Agentabrams  ·  National brokers: 573 crexi brokers across 38 states + broke 0f3b28e →