← 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
M public/lending-grid.cssM public/lending-grid.jsM public/residential-brokers.htmlA scripts/scrape-stepp.jsM scripts/sync-broker-feeds.sh
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(/&/g, '&').replace(/ /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 →