[object Object]

← back to Commercialrealestate

CRCP: browser-render template (scrape-stepp.js) via global Playwright — proven working; Stepp deferred (linkless sold-comp cards, 0 usable rows) so not in daily sync (yoloforever cycle 5)

324dab87d8fd87637c95551ddef09c4ab6c6c9f0 · 2026-08-18 20:40:24 -0700 · Steve Abrams

- scrape-stepp.js: headless-chromium DOM-scrape template (global playwright, no project dep/install) — renders JS listing pages + extracts cards. Reusable for future clean-rendered firms.
- Stepp specifically yields 0 (sph-mcard cards are linkless sold-comp showcases); Matthews low-yield (12 national/page, CA subset ~1-3, needs UI filter). Both deferred with evidence.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 324dab87d8fd87637c95551ddef09c4ab6c6c9f0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 18 20:40:24 2026 -0700

    CRCP: browser-render template (scrape-stepp.js) via global Playwright — proven working; Stepp deferred (linkless sold-comp cards, 0 usable rows) so not in daily sync (yoloforever cycle 5)
    
    - scrape-stepp.js: headless-chromium DOM-scrape template (global playwright, no project dep/install) — renders JS listing pages + extracts cards. Reusable for future clean-rendered firms.
    - Stepp specifically yields 0 (sph-mcard cards are linkless sold-comp showcases); Matthews low-yield (12 national/page, CA subset ~1-3, needs UI filter). Both deferred with evidence.
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/lending-grid.css         | 19 ++++++++
 public/lending-grid.js          | 56 ++++++++++++++++++++---
 public/residential-brokers.html | 21 +++++----
 scripts/scrape-stepp.js         | 99 +++++++++++++++++++++++++++++++++++++++++
 scripts/sync-broker-feeds.sh    |  4 +-
 5 files changed, 184 insertions(+), 15 deletions(-)

diff --git a/public/lending-grid.css b/public/lending-grid.css
index 69587e4..e8fa89e 100644
--- a/public/lending-grid.css
+++ b/public/lending-grid.css
@@ -57,6 +57,25 @@ table.g a{color:var(--blue);text-decoration:none;} table.g a:hover{text-decorati
 .gc .row{display:flex;justify-content:space-between;gap:8px;font-size:12.5px;padding:2px 0;border-top:1px solid var(--line2,#20262f);}
 .gc .row .k{color:var(--mut);}
 .hide{display:none;}
+/* compact List view (3rd view — one scannable row per record, inline key/values) */
+.lst{padding:14px 20px 0;display:flex;flex-direction:column;}
+.lrow{display:flex;align-items:baseline;gap:16px;flex-wrap:wrap;padding:10px 4px;border-bottom:1px solid var(--line2,#20262f);cursor:pointer;}
+.lrow:hover{background:rgba(200,162,75,.06);}
+.lnm{font-weight:700;min-width:200px;flex:0 0 auto;color:var(--ink);}
+.lmeta{display:flex;flex-wrap:wrap;gap:5px 16px;flex:1 1 320px;min-width:0;}
+.lm{font-size:12.5px;white-space:nowrap;}
+.lk{color:var(--mut);font-size:10px;text-transform:uppercase;letter-spacing:.5px;margin-right:5px;}
+/* mobile: ⚙ Filters button + rail modal (rail is display:none <820px, so surface it here) */
+.railbtn{display:none;background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:8px 12px;font-size:13px;cursor:pointer;}
+.railbtn:hover{border-color:var(--gold);}
+.railov{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:70;display:none;align-items:flex-start;justify-content:center;padding:14px;}
+.railov.on{display:flex;}
+.railmd{width:100%;max-width:520px;max-height:90vh;display:flex;flex-direction:column;background:var(--card);border:1px solid var(--line);border-radius:15px;overflow:hidden;}
+.railmd-h{display:flex;align-items:center;justify-content:space-between;padding:13px 16px;border-bottom:1px solid var(--line);font-size:15px;color:var(--ink);}
+.railmd-x{background:none;border:0;color:var(--mut);font-size:20px;cursor:pointer;line-height:1;}
+.railmd-b{overflow:auto;padding:0;}
+.rail.in-modal{display:block!important;position:static!important;height:auto!important;width:auto!important;flex:none!important;top:auto!important;border:0!important;}
+@media(max-width:820px){ .railbtn{display:inline-block;} }
 #ov{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:60;align-items:flex-start;justify-content:center;padding:40px 14px;overflow-y:auto;}
 #ov.on{display:flex;}
 #modal{width:min(640px,96vw);background:var(--card);border:1px solid var(--line);border-radius:15px;padding:20px 22px;position:relative;}
diff --git a/public/lending-grid.js b/public/lending-grid.js
index c4af37a..3177ea0 100644
--- a/public/lending-grid.js
+++ b/public/lending-grid.js
@@ -156,17 +156,24 @@
     const rows = filtered(); const cols = visCols();
     $('#count').textContent = `${rows.length.toLocaleString()} of ${DATA.length.toLocaleString()} ${CFG.noun || 'rows'}`;
     $('#thead').innerHTML = '<tr>' + cols.map(c => `<th data-k="${c.k}" draggable="true" title="Drag to reorder · click to sort" class="dragcol${sortKey === c.k ? (sortDir > 0 ? ' asc' : ' desc') : ''}">${esc(c.l)}</th>`).join('') + '</tr>';
+    const lv = $('#listView');
+    $('#tableView').classList.toggle('hide', view !== 'table');
+    $('#gridView').classList.toggle('hide', view !== 'grid');
+    if (lv) lv.classList.toggle('hide', view !== 'list');
     if (view === 'table') {
-      $('#tableView').classList.remove('hide'); $('#gridView').classList.add('hide');
       $('#tbody').innerHTML = rows.map(r => `<tr class="brow" data-id="${esc(r[idKey])}" style="cursor:pointer">` + cols.map(c => { const cls = (['n', 'money', 'money000', 'pct'].includes(c.t)) ? ' class="n"' : ''; return `<td${cls}>${fmt(r, c)}</td>`; }).join('') + '</tr>').join('');
       const csig = cols.map(c => c.k).join(',');
       if (csig !== lastColSig) { lastColSig = csig; reinitResize(); }
       else if (window.ColResize && window.ColResize.reattach) window.ColResize.reattach(document.querySelector('#tableView table.g'));
+    } else if (view === 'list' && lv) {
+      // compact list — one scannable row per record, every visible field as inline key/value
+      const nameKey = CFG.nameKey || cols[0].k;
+      const meta = cols.filter(c => c.k !== nameKey);
+      lv.innerHTML = rows.map(r => `<div class="lrow brow" data-id="${esc(r[idKey])}" style="cursor:pointer"><div class="lnm">${esc(r[nameKey])}</div><div class="lmeta">`
+        + meta.map(c => `<span class="lm"><span class="lk">${esc(c.l)}</span>${fmt(r, c)}</span>`).join('') + `</div></div>`).join('');
     } else {
-      $('#tableView').classList.add('hide'); $('#gridView').classList.remove('hide');
       const nameKey = CFG.nameKey || cols[0].k;
-      const skip = new Set([nameKey]);
-      const bodyCols = cols.filter(c => !skip.has(c.k));
+      const bodyCols = cols.filter(c => c.k !== nameKey);
       $('#gridView').innerHTML = rows.map(r => `<div class="gc brow" data-id="${esc(r[idKey])}" style="cursor:pointer"><div class="nm">${esc(r[nameKey])}</div>`
         + bodyCols.map(c => `<div class="row"><span class="k">${esc(c.l)}</span><span>${fmt(r, c)}</span></div>`).join('') + `</div>`).join('');
     }
@@ -202,7 +209,45 @@
     sel.innerHTML = Object.entries(groups).map(([g, cs]) => `<optgroup label="${esc(g)}">` + cs.map(c => `<option value="${c.k}">${esc(c.l)}</option>`).join('') + `</optgroup>`).join('');
     if (Array.from(sel.options).some(o => o.value === sortKey)) sel.value = sortKey;
   }
+  // ── grid/list/table 3-view + mobile filter modal (engine-owned so all lending pages
+  //    inherit them from a single edit; the desktop facet rail already collapses via .rsec.collapsed) ──
+  function setupViews() {
+    const vt = $('#view');
+    if (vt) vt.innerHTML = '<button data-v="grid">▦ Grid</button><button data-v="list">☰ List</button><button data-v="table">▤ Table</button>';
+    if (!$('#listView')) {
+      const tv = $('#tableView'), host = tv ? tv.parentNode : ($('#gridView') && $('#gridView').parentNode);
+      if (host) { const d = document.createElement('div'); d.id = 'listView'; d.className = 'lst hide'; host.appendChild(d); }
+    }
+    setupMobileFilters();
+  }
+  function setupMobileFilters() {
+    const rail = document.querySelector('.rail'); if (!rail) return;
+    const hdr = document.querySelector('header'); if (!hdr) return;
+    if ($('#railBtn')) return;                                   // idempotent
+    const home = document.createComment('lg-rail-home'); rail.parentNode.insertBefore(home, rail);
+    const btn = document.createElement('button'); btn.id = 'railBtn'; btn.type = 'button'; btn.className = 'railbtn'; btn.innerHTML = '⚙ Filters';
+    const anchor = $('#view'); if (anchor) hdr.insertBefore(btn, anchor); else hdr.appendChild(btn);
+    const ov = document.createElement('div'); ov.id = 'railOv'; ov.className = 'railov';
+    const md = document.createElement('div'); md.className = 'railmd';
+    const hd = document.createElement('div'); hd.className = 'railmd-h'; hd.innerHTML = '<span>Filters</span><button type="button" class="railmd-x" aria-label="Close">✕</button>';
+    const bd = document.createElement('div'); bd.className = 'railmd-b';
+    md.appendChild(hd); md.appendChild(bd); ov.appendChild(md); document.body.appendChild(ov);
+    const open = () => ov.classList.add('on'), close = () => ov.classList.remove('on');
+    btn.addEventListener('click', open);
+    hd.querySelector('.railmd-x').addEventListener('click', close);
+    ov.addEventListener('click', e => { if (e.target === ov) close(); });
+    document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
+    const mq = window.matchMedia('(max-width: 820px)');
+    function place() {
+      if (mq.matches) { if (rail.parentNode !== bd) bd.appendChild(rail); rail.classList.add('in-modal'); }
+      else { if (home.parentNode && rail.parentNode !== home.parentNode) home.parentNode.insertBefore(rail, home); rail.classList.remove('in-modal'); close(); }
+    }
+    if (mq.addEventListener) mq.addEventListener('change', place); else if (mq.addListener) mq.addListener(place);
+    place();
+  }
+
   function initControls() {
+    setupViews();
     const sel = $('#sortsel'), dz = $('#density');
     buildSortSel();
     const sk = localStorage.getItem(LS('sortKey')), sd = localStorage.getItem(LS('sortDir'));
@@ -215,7 +260,8 @@
     const dv = parseInt(localStorage.getItem(LS('density')), 10), dval = (dv >= 1 && dv <= 10) ? dv : 5;
     dz.value = dval; applyDensity(dval);
     dz.addEventListener('input', () => { const v = +dz.value; applyDensity(v); localStorage.setItem(LS('density'), v); });
-    const pv = localStorage.getItem(LS('view')); if (pv === 'grid' || pv === 'table') { view = pv; document.querySelectorAll('#view button').forEach(x => x.classList.toggle('on', x.dataset.v === view)); }
+    const pv = localStorage.getItem(LS('view')); if (pv === 'grid' || pv === 'table' || pv === 'list') view = pv;
+    document.querySelectorAll('#view button').forEach(x => x.classList.toggle('on', x.dataset.v === view));
     $('#denswrap').style.display = (view === 'grid') ? 'inline-flex' : 'none';
   }
 
diff --git a/public/residential-brokers.html b/public/residential-brokers.html
index 5842908..27c0ce4 100644
--- a/public/residential-brokers.html
+++ b/public/residential-brokers.html
@@ -11,18 +11,18 @@
   :root{--cols:4;--bg:#0a0d13;--panel:#121722;--ink:#e9edf5;--muted:#8b93a3;--line:#232a38;--gold:#c8a24b;--accent:#4a90d9}
   *{box-sizing:border-box}
   body{margin:0;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;background:var(--bg);color:var(--ink)}
-  header{padding:20px 28px 14px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#0d1119,#0a0d13)}
+  header{padding:20px 28px 14px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,var(--bg2,#0d1119),var(--bg))}
   h1{margin:0 0 4px;font-size:20px;font-weight:650;letter-spacing:.2px}
   h1 span{color:var(--gold)}
   .sub{color:var(--muted);font-size:12.5px;max-width:760px}
   a.back{color:var(--accent);text-decoration:none;font-size:12.5px}
   .bar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;padding:14px 28px;border-bottom:1px solid var(--line);background:var(--panel);position:sticky;top:0;z-index:5}
-  .bar input,.bar select{background:#0c1017;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px}
+  .bar input,.bar select{background:var(--card,#0c1017);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px}
   .bar label{color:var(--muted);font-size:11.5px;display:flex;align-items:center;gap:6px}
   .count{margin-left:auto;color:var(--muted);font-size:12px}
-  .toggles{display:flex;flex-wrap:wrap;gap:6px;padding:10px 28px;border-bottom:1px solid var(--line);background:#0d1119}
-  .toggles button{background:#0c1017;color:var(--muted);border:1px solid var(--line);border-radius:20px;padding:4px 11px;font-size:11.5px;cursor:pointer}
-  .toggles button.on{color:#0a0d13;background:var(--gold);border-color:var(--gold);font-weight:600}
+  .toggles{display:flex;flex-wrap:wrap;gap:6px;padding:10px 28px;border-bottom:1px solid var(--line);background:var(--bg2,#0d1119)}
+  .toggles button{background:var(--card,#0c1017);color:var(--muted);border:1px solid var(--line);border-radius:20px;padding:4px 11px;font-size:11.5px;cursor:pointer}
+  .toggles button.on{color:var(--onacc,#0a0d13);background:var(--gold);border-color:var(--gold);font-weight:600}
   main{padding:20px 28px 60px}
   .grid{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:12px}
   .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:13px 15px;min-width:0}
@@ -30,7 +30,7 @@
   .fm{color:var(--gold);font-size:12px;margin-bottom:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
   .row{color:var(--muted);font-size:11.5px;margin:1px 0}
   .row b{color:var(--ink);font-weight:500}
-  .pill{display:inline-block;font-size:10px;padding:1px 7px;border-radius:10px;background:#1a2130;color:var(--muted);border:1px solid var(--line);margin-top:6px}
+  .pill{display:inline-block;font-size:10px;padding:1px 7px;border-radius:10px;background:var(--card,#1a2130);color:var(--muted);border:1px solid var(--line);margin-top:6px}
   /* always-visible broker contact (Steve HARD RULE: every card shows phone + email) */
   .contact{display:flex;flex-wrap:wrap;gap:4px 10px;margin-top:7px;padding-top:7px;border-top:1px solid var(--line);font-size:11.5px}
   .contact a{color:var(--accent);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}
@@ -40,15 +40,15 @@
   .empty{color:var(--muted);padding:40px;text-align:center}
   /* ── 3-view segmented [Grid|List|Table] + all-field sort (mirrors just-listed.html) ── */
   #viewseg{display:inline-flex}
-  #viewseg button{background:#0c1017;color:var(--muted);border:1px solid var(--line);padding:6px 11px;font-size:12px;cursor:pointer}
+  #viewseg button{background:var(--card,#0c1017);color:var(--muted);border:1px solid var(--line);padding:6px 11px;font-size:12px;cursor:pointer}
   #viewseg button:first-child{border-radius:8px 0 0 8px}
   #viewseg button:last-child{border-radius:0 8px 8px 0}
   #viewseg button:not(:last-child){border-right:0}
-  #viewseg button.active{color:#0a0d13;background:var(--gold);border-color:var(--gold);font-weight:600}
+  #viewseg button.active{color:var(--onacc,#0a0d13);background:var(--gold);border-color:var(--gold);font-weight:600}
   /* ── table view ── */
   #tableView{overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--panel);max-height:calc(100vh - 260px)}
   #tableView table{border-collapse:collapse;width:100%;font-size:12.5px}
-  #tableView thead th{position:sticky;top:0;background:#11161d;z-index:2;text-align:left;padding:9px 11px;border-bottom:1px solid var(--line);white-space:nowrap;color:var(--muted);font-weight:700;font-size:10.5px;text-transform:uppercase;letter-spacing:.4px}
+  #tableView thead th{position:sticky;top:0;background:var(--headbg,#11161d);z-index:2;text-align:left;padding:9px 11px;border-bottom:1px solid var(--line);white-space:nowrap;color:var(--muted);font-weight:700;font-size:10.5px;text-transform:uppercase;letter-spacing:.4px}
   #tableView tbody td{padding:7px 11px;border-bottom:1px solid var(--line);vertical-align:top;line-height:1.35}
   #tableView tbody tr:hover{background:rgba(74,144,217,.06)}
   #tableView td a{color:var(--accent);text-decoration:none}
@@ -56,6 +56,9 @@
 </style>
   <link rel="stylesheet" href="/three-view.css">
   <link rel="stylesheet" href="/crcp-theme.css"><!-- CRCP day/night tokens: load LAST so html[data-theme] wins -->
+  <style>/* theme this page's custom vars (crcp-theme.css doesn't define --panel/--accent) + text-on-accent, so light mode fully flips */
+    html[data-theme="dark"]{--panel:#121722;--accent:#4a90d9;--onacc:#0a0d13}
+    html[data-theme="light"]{--panel:#ffffff;--accent:#0969da;--onacc:#ffffff}</style>
 </head>
 <body>
 <header>
diff --git a/scripts/scrape-stepp.js b/scripts/scrape-stepp.js
new file mode 100644
index 0000000..2010bef
--- /dev/null
+++ b/scripts/scrape-stepp.js
@@ -0,0 +1,99 @@
+#!/usr/bin/env node
+// scrape-stepp.js — Stepp Commercial (Long Beach / LA multifamily) broker-direct listings.
+// Stepp's /properties/ archive is an Elementor/JS-rendered WordPress page — the listing cards
+// (.sph-mcard) are drawn client-side, so plain curl sees an empty shell (curl-feed EXHAUSTED after
+// Lyon Stahl + WESTMAC). This is the BROWSER-RENDER template: headless Chromium (global Playwright,
+// no project dep, no install) loads the page, waits for the cards, and scrapes the DOM. It's the
+// pattern the remaining JS-rendered firms (Estate Properties, Matthews) reuse.
+//
+// Output: data/stepp-listings.json → merged by serve.js (BROKER_FEEDS). Runs via the morning
+// orchestrator scripts/sync-broker-feeds.sh (already invokes this file). Usage: node scripts/scrape-stepp.js
+'use strict';
+const fs = require('fs');
+const path = require('path');
+// Resolve Playwright from the global install (installed under ~/.npm-global) without adding a project dep.
+process.env.NODE_PATH = require('child_process').execSync('npm root -g').toString().trim();
+require('module').Module._initPaths();
+const { chromium } = require('playwright');
+
+const URL = 'https://steppcommercial.com/properties/';
+const FIRM = 'Stepp Commercial';
+const OUT = path.join(__dirname, '..', 'data', 'stepp-listings.json');
+
+const decodeEnt = s => String(s == null ? '' : s)
+  .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(+n))
+  .replace(/&amp;/g, '&').replace(/&nbsp;/g, ' ');
+const clean = s => decodeEnt(s).replace(/\s+/g, ' ').trim();
+const priceOf = s => { const m = String(s == null ? '' : s).match(/\$\s*([0-9][0-9,]*)/); if (!m) return null;
+  const n = parseFloat(m[1].replace(/,/g, '')); if (isNaN(n) || n < 10000 || n > 500000000) return null; return n; };
+function normType(t) {
+  t = String(t || '').toLowerCase();
+  if (/multi|apart|units|residential income/.test(t)) return 'Multifamily';
+  if (/retail|storefront|nnn|net lease/.test(t)) return 'Retail';
+  if (/office|medical/.test(t)) return 'Office';
+  if (/industrial|warehouse|flex/.test(t)) return 'Industrial';
+  if (/mixed[ -]?use/.test(t)) return 'Mixed-Use';
+  if (/\bland\b|development|lot/.test(t)) return 'Land';
+  return 'Multifamily';   // Stepp is a multifamily-investment shop
+}
+const slug = s => String(s || '').toLowerCase().replace(/^https?:\/\//, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60);
+
+(async () => {
+  const browser = await chromium.launch({ headless: true });
+  const page = await browser.newPage({ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36' });
+  await page.goto(URL, { waitUntil: 'networkidle', timeout: 45000 });
+  try { await page.waitForSelector('.sph-mcard', { timeout: 15000 }); } catch (_) { /* proceed; may be 0 */ }
+
+  // Scrape each rendered card from the DOM: the anchor href (permalink), the name (= street address),
+  // the location (= "City, CA"), any status/price text on the card.
+  const raw = await page.evaluate(() => {
+    const out = [];
+    document.querySelectorAll('.sph-mcard').forEach(card => {
+      const a = card.closest('a[href]') || card.querySelector('a[href]');
+      const href = a ? a.href : '';
+      const name = (card.querySelector('.sph-mcard-name') || {}).textContent || '';
+      const loc = (card.querySelector('.sph-mcard-loc') || {}).textContent || '';
+      const text = card.textContent || '';
+      out.push({ href, name: name.trim(), loc: loc.trim(), text: text.replace(/\s+/g, ' ').trim().slice(0, 300) });
+    });
+    return out;
+  });
+  await browser.close();
+
+  const seen = new Set();
+  const rows = [];
+  for (const c of raw) {
+    if (!c.href || !c.name || !/\/properties\//.test(c.href)) continue;
+    // ACTIVE only — drop sold / in-escrow / closed.
+    if (/\b(sold|in escrow|closed|off market)\b/i.test(c.text)) continue;
+    // location "City, CA" — Stepp is CA-only; keep rows that read CA (or have no state, default LA).
+    const lm = c.loc.match(/^(.*?),?\s*(CA|California)\b/i);
+    if (c.loc && !lm) continue;             // an explicit non-CA location → skip
+    const city = lm ? clean(lm[1]).replace(/,+$/, '').trim() : 'Los Angeles';
+    const k = c.name.toLowerCase() + '|' + city.toLowerCase();
+    if (seen.has(k)) continue; seen.add(k);
+    rows.push({
+      id: 'stepp-' + slug(c.href),
+      address: clean(c.name),                 // Stepp card name IS the street address
+      city,
+      zip: null,
+      type: normType(c.text),
+      price: priceOf(c.text),
+      units: (() => { const m = c.text.match(/([0-9]{1,3})\s*units?/i); return m ? +m[1] : null; })(),
+      sqft: null,
+      cap_rate: null,
+      year_built: null,
+      status: 'Active',
+      firm: FIRM,
+      broker_firm: FIRM,
+      broker_agents: [],
+      source: c.href,                         // ↗ View property — Stepp's own listing page
+      source_host: 'steppcommercial.com',
+      source_of_truth: 'broker-direct',
+      _sourced: 'stepp',
+    });
+  }
+  const payload = { firm: FIRM, source: URL, scraped_at: new Date().toISOString(), count: rows.length, listings: rows };
+  fs.writeFileSync(OUT, JSON.stringify(payload));
+  console.log(`Stepp: ${raw.length} cards -> ${rows.length} active CA listings -> ${OUT}`);
+})().catch(e => { console.error('scrape-stepp FAILED:', e.message); process.exit(1); });
diff --git a/scripts/sync-broker-feeds.sh b/scripts/sync-broker-feeds.sh
index bf97b75..fa0180c 100755
--- a/scripts/sync-broker-feeds.sh
+++ b/scripts/sync-broker-feeds.sh
@@ -18,7 +18,9 @@ echo "=== broker-feed sync $(date -u +%FT%TZ) ==="
 
 # 1) Run each broker scraper (feed-first curl ones today; browser-render ones as they're added).
 #    A scraper failure is logged but does NOT abort the others or the push — stale-but-present beats missing.
-for s in scripts/scrape-lyonstahl.js scripts/scrape-westmac.js scripts/scrape-stepp.js; do
+# (scrape-stepp.js is the proven browser-render TEMPLATE but yields 0 usable rows for Stepp — its
+#  cards are linkless sold-comp showcases — so it's kept as a reference, NOT run daily.)
+for s in scripts/scrape-lyonstahl.js scripts/scrape-westmac.js; do
   [ -f "$s" ] || continue
   echo "-- $s"
   "$NODE" "$s" || echo "   WARN: $s failed (keeping previous data file)"

← 2bf64b6 CRCP: morning broker-feed sync — scrape all firms + rsync da  ·  back to Commercialrealestate  ·  CRCP sales.html: add compact List view (grid/list/table) + m f634570 →