← 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
A crawl/crexi-national-brokers.jsA crawl/crexi-recon.jsA data/crexi-national-run.jsonA data/crexi-recon.jsonM public/index.htmlM scripts/export-snapshot.jsM server.js
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 →