[object Object]

← back to Commercialrealestate

CRCP crexi DB scraper: parse openclaw CLI output past its stdout preamble, 'Listed by' as primary firm source, measured 0-card diagnostics, 6s SPA settle (TK-12033)

5d3636732255f51e1329e0748ad1cf2ecae07ea4 · 2026-09-23 12:08:23 -0700 · Steve Abrams

The openclaw CLI printed a Config-warnings box on STDOUT before the JSON result, so every
evaluate() parsed as a string -> 0 cards -> a false THROTTLED verdict (the same 'inferred from an
empty parse' class as the extractor bug). The 2026-09 detail page adds masked/PRO lines that the
reverse line-scan misread as the firm; the structured 'Listed by <firm>' line is now primary.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7HcZ26MePoJ5QpcvDhKmH

Files touched

Diff

commit 5d3636732255f51e1329e0748ad1cf2ecae07ea4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 23 12:08:23 2026 -0700

    CRCP crexi DB scraper: parse openclaw CLI output past its stdout preamble, 'Listed by' as primary firm source, measured 0-card diagnostics, 6s SPA settle (TK-12033)
    
    The openclaw CLI printed a Config-warnings box on STDOUT before the JSON result, so every
    evaluate() parsed as a string -> 0 cards -> a false THROTTLED verdict (the same 'inferred from an
    empty parse' class as the extractor bug). The 2026-09 detail page adds masked/PRO lines that the
    reverse line-scan misread as the firm; the structured 'Listed by <firm>' line is now primary.
    
    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01X7HcZ26MePoJ5QpcvDhKmH
---
 scripts/scrape-crexi-loopnet.js | 38 +++++++++++++++++++++++++++++++-------
 1 file changed, 31 insertions(+), 7 deletions(-)

diff --git a/scripts/scrape-crexi-loopnet.js b/scripts/scrape-crexi-loopnet.js
index bcc74b6..e4f46e7 100644
--- a/scripts/scrape-crexi-loopnet.js
+++ b/scripts/scrape-crexi-loopnet.js
@@ -57,7 +57,19 @@ function saveProg(p) { try { fs.writeFileSync(PROG, JSON.stringify(p, null, 2));
 
 const sleep = ms => new Promise(r => setTimeout(r, ms));
 function oc(args, timeout = 45000) { try { return execFileSync(OC, args, { encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'ignore'] }); } catch { return null; } }
-function ocEval(fn) { const out = oc(['browser', 'evaluate', '--fn', fn]); if (!out) return null; try { return JSON.parse(out); } catch { return out; } }
+// The openclaw CLI (2026.3.x) prints a "Config warnings" preamble to STDOUT before the JSON result, which made a
+// bare JSON.parse fail -> every card sweep read as [] -> a false "THROTTLED" (TK-12033). Parse from the first JSON line.
+function parseOcJson(out) {
+  if (!out) return null;
+  try { return JSON.parse(out); } catch {}
+  const lines = out.split("\n");
+  for (let i = 0; i < lines.length; i++) {
+    const s = lines.slice(i).join("\n").trim();
+    if (/^[\[{"]/.test(s) || /^(true|false|null|-?\d)/.test(s)) { try { return JSON.parse(s); } catch {} }
+  }
+  return out;
+}
+function ocEval(fn) { const out = oc(['browser', 'evaluate', '--fn', fn]); if (!out) return null; return parseOcJson(out); }
 const nAddr = s => String(s || '').toLowerCase().replace(/\b(street|st|avenue|ave|boulevard|blvd|road|rd|drive|dr|lane|ln|place|pl|court|ct|suite|ste|unit|#|north|south|east|west|n|s|e|w)\b/g, '').replace(/[^a-z0-9]+/g, '');
 const nFirm = s => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '').replace(/(inc|llc|corp|corporation|company|co|realty|realestate|group|properties|partners|associates)$/, '');
 
@@ -114,12 +126,18 @@ async function collectAllCards(type) {
     return { cards: cached.byType[type], throttled: false };
   }
   const all = new Map();
-  if (!oc(['browser', 'navigate', searchUrl(type)])) return { cards: [], throttled: true };
-  await sleep(2500);
+  if (!oc(['browser', 'navigate', searchUrl(type)])) { console.log(`  [${type}] navigate FAILED (openclaw CLI returned nothing) — ${searchUrl(type)}`); return { cards: [], throttled: true }; }
+  await sleep(6000);                                     // SPA settle: the v2 grid needs ~5-6s before cards render (measured 2026-09-23)
   for (let p = 1; p <= PAGES; p++) {
     const cs = await extractCards();
     // THROTTLE signature: page 1 yields nothing rendered at all.
-    if (p === 1 && cs.length === 0) return { cards: [], throttled: true };
+    if (p === 1 && cs.length === 0) {
+      // MEASURE, don't infer (TK-11431 class): a nav failure, a slow SPA, a bot wall and a real throttle all
+      // look like "0 cards". Print what the tab actually holds so the operator can tell them apart.
+      const diag = ocEval(`() => ({ url: location.href, title: document.title.slice(0,80), anchors: document.querySelectorAll('a[href]').length, propLinks: document.querySelectorAll('a[href*="/properties/"]').length, hint: (document.body.innerText||'').replace(/\\s+/g,' ').slice(0,160) })`);
+      console.log(`  [${type}] 0 cards — page state: ${typeof diag === 'object' ? JSON.stringify(diag) : String(diag).slice(0,200)}`);
+      return { cards: [], throttled: true };
+    }
     let fresh = 0; for (const c of cs) if (!all.has(c.id)) { all.set(c.id, c); fresh++; }
     process.stdout.write(`  [${type}] page ${p}: +${fresh} (total ${all.size})\n`);
     if (fresh === 0 && p > 1) break;
@@ -162,11 +180,17 @@ function detailAgent(url) {
   let agent = null, firm = null, license = null;
   const chunk = ((d.lc || head).split(/View Profile/)[0]) || '';
   const lines = chunk.split('\n').map(s => s.trim()).filter(Boolean)
-    .filter(l => !/^(Listing Contacts|Submit LOI|PRO|LIC:?|View Profile|Notes|Print|Share|Save|Report|\(\+\d+\))$/i.test(l));
+    .filter(l => !/^(Listing Contacts|Submit LOI|PRO|LIC:?|View Profile|View phone number|View email|Notes|Print|Share|Save|Report|\(\+\d+\))$/i.test(l))
+    .filter(l => !/^[*•]{3,}$/.test(l));                        // masked contact lines ("*****") on the 2026-09 detail page
   agent = (lines.find(l => PERSON_RE.test(l)) || '').trim() || null;
   license = (chunk.match(/CA\s+(\d{6,9})/) || [])[1] || null;
-  // firm = the last content line of the block that isn't the name / phone / email / license marker
-  for (let i = lines.length - 1; i >= 0; i--) {
+  // PRIMARY firm source (2026-09 page): the structured "Listed by <firm>[, <firm dba>]" line — it is present on the
+  // detail page and in the Listing Contacts block; the reverse line-scan below misread section headers
+  // ("Property Tax") and masked lines as the firm (TK-12033). Fall back to the scan only when it is absent.
+  const lbLine = ((d.lc || '') + '\n' + head).match(/Listed by\s+([^\n$]{2,120}?)(?=\s*(?:\n|\$|$))/i);
+  if (lbLine) firm = lbLine[1].split(',')[0].trim() || null;
+  // fallback: the last content line of the block that isn't the name / phone / email / license marker
+  for (let i = lines.length - 1; i >= 0 && !firm; i--) {
     const l = lines[i];
     if (l === agent) continue;
     if (/[@•]/.test(l)) continue;                     // email / masked-phone line (NOT /LIC/ — it matched firms like "Mil·lic·hap")

← 6c44ca0 CRCP data: repair 47 crexi rows minted with v2 'sales-' id p  ·  back to Commercialrealestate  ·  auto-data-snapshot: 2026-09-23T12:09:58 (7 data files) — dat de85550 →