[object Object]

← back to Commercialrealestate

backfill option-3: openclaw real-Chrome (beats 403/JS) + qwen3:14b extraction of firm-site listings, attributed at FIRM level (role=firm-listing, reversible source=broker-site-oc) — Steve: build option 3, fan to top 200

86b8219d936394dadc7a027092dcbe0255b23dbe · 2026-08-19 09:29:32 -0700 · Steve Abrams

Files touched

Diff

commit 86b8219d936394dadc7a027092dcbe0255b23dbe
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 19 09:29:32 2026 -0700

    backfill option-3: openclaw real-Chrome (beats 403/JS) + qwen3:14b extraction of firm-site listings, attributed at FIRM level (role=firm-listing, reversible source=broker-site-oc) — Steve: build option 3, fan to top 200
---
 scripts/backfill-openclaw-llm.js | 97 ++++++++++++++++++++++++++++++++++++++++
 1 file changed, 97 insertions(+)

diff --git a/scripts/backfill-openclaw-llm.js b/scripts/backfill-openclaw-llm.js
new file mode 100644
index 0000000..210f0ae
--- /dev/null
+++ b/scripts/backfill-openclaw-llm.js
@@ -0,0 +1,97 @@
+#!/usr/bin/env node
+// backfill-openclaw-llm.js — OPTION 3 (Steve 2026-08-19): real listings direct from broker FIRM
+// sites via openclaw real-Chrome (defeats 403/JS) + local qwen3:14b extraction ($0). Firm-site
+// listing GRIDS carry address+price but NOT the per-listing agent, so these are attributed at the
+// FIRM level (role='firm-listing', linked to every broker at that firm) and shown on agent profiles
+// as a clearly-labeled "Firm listings" section — never silently claimed as that one agent's book.
+// Reversible: everything tagged source='broker-site-oc'. Dry-run default; --apply to write.
+//
+// Usage: node scripts/backfill-openclaw-llm.js [--limit N] [--apply]
+const { execFileSync } = require('child_process');
+const db = require('./db/brokers-db');
+const OC = process.env.HOME + '/.npm-global/bin/openclaw';
+const OLLAMA = 'http://127.0.0.1:11434/api/generate';
+const LIMIT = +(process.argv.find(a => a.startsWith('--limit='))?.split('=')[1]) || 5;
+const APPLY = process.argv.includes('--apply');
+const BIG = /cbre|kw\.com|yourkwoffice|kellerwilliams|marcusmillichap|kidder|coldwell|compass|remax|century21|colliers|jll|cushman|berkshire/i;
+const domainOf = u => { try { return new URL(/^https?:/i.test(u) ? u : 'https://' + u).hostname.replace(/^www\./, ''); } catch { return u; } };
+
+function oc(args, timeout = 45000) { try { return execFileSync(OC, args, { encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'ignore'] }); } catch (e) { return null; } }
+function ocEval(fn) { const out = oc(['browser', 'evaluate', '--fn', fn]); if (!out) return null; try { return JSON.parse(out); } catch { return out; } }
+
+async function llmExtract(text) {
+  // feed the price-dense window (skip nav/cookie header) in <=7k chunks
+  const firstPrice = text.search(/\$[0-9]{3,}(,[0-9]{3})+/);
+  const body = firstPrice > 0 ? text.slice(Math.max(0, firstPrice - 200)) : text;
+  const out = [];
+  for (let i = 0; i < body.length && i < 21000; i += 7000) {
+    const chunk = body.slice(i, i + 7000);
+    if (!/\$[0-9]{3,}/.test(chunk)) continue;
+    const prompt = `From this real-estate broker listings text, extract each property listing. Return ONLY a JSON array (no prose/markdown), items {"address":string,"price":number,"type":string_or_null}. Skip anything without a real street address AND a price.\n\nTEXT:\n${chunk}`;
+    try {
+      const r = await fetch(OLLAMA, { method: 'POST', body: JSON.stringify({ model: 'qwen3:14b', prompt, stream: false, options: { temperature: 0 } }) }).then(x => x.json());
+      const raw = (r.response || '').replace(/<think>[\s\S]*?<\/think>/g, '').replace(/```json|```/g, '').trim();
+      const m = raw.match(/\[[\s\S]*\]/); if (!m) continue;
+      const arr = JSON.parse(m[0]);
+      for (const it of arr) {
+        const addr = String(it.address || '').trim();
+        const price = +String(it.price).replace(/[^0-9.]/g, '');
+        const houseNo = (addr.match(/^\d+/) || ['0'])[0];
+        if (addr.length >= 8 && price >= 100000 && price <= 500000000 && !/^0+$/.test(houseNo) && !/\b000\b/.test(addr))
+          out.push({ address: addr, price, type: it.type || 'Commercial' });
+      }
+    } catch (e) { /* skip chunk */ }
+  }
+  // dedupe by address
+  const seen = new Set(); return out.filter(l => { const k = l.address.toLowerCase(); return !seen.has(k) && seen.add(k); });
+}
+
+async function scrapeDomain(url) {
+  if (!oc(['browser', 'navigate', /^https?:/i.test(url) ? url : 'https://' + url])) return { err: 'nav-fail' };
+  // find the best listings page link
+  const links = ocEval(`() => [...document.querySelectorAll('a')].map(a=>({t:(a.textContent||'').trim().slice(0,40),h:a.href})).filter(l=>/listing|propert|for.?sale|inventory|available|our.?deals/i.test(l.t+' '+l.h)).slice(0,10)`);
+  const cand = (Array.isArray(links) ? links : []).map(l => l.h).filter(Boolean);
+  // prefer an /listings or /properties page; else stay on the homepage
+  const listUrl = cand.find(h => /listing|propert|for-?sale|inventory/i.test(h)) || null;
+  if (listUrl) oc(['browser', 'navigate', listUrl]);
+  const txt = ocEval(`() => document.body.innerText`);
+  const text = typeof txt === 'string' ? txt : (txt && (txt.result || txt.value)) || '';
+  if (!text || text.length < 400) return { err: 'no-text' };
+  const listings = await llmExtract(text);
+  return { listUrl: listUrl || url, listings };
+}
+
+(async () => {
+  // top SHARED firm domains (most agents per domain = highest yield/effort)
+  const rows = (await db.pool.query(
+    `SELECT b.firm_id, f.name AS firm, count(*) AS agents,
+            (array_agg(b.website ORDER BY length(b.website)))[1] AS website,
+            array_agg(b.id) AS broker_ids
+       FROM broker b JOIN firm f ON f.id=b.firm_id
+      WHERE b.website IS NOT NULL AND b.website !~* 'crexi' AND b.website !~* $1
+      GROUP BY b.firm_id, f.name HAVING count(*) >= 2
+      ORDER BY count(*) DESC LIMIT $2`, [BIG.source, LIMIT])).rows;
+  console.log(`\n== Option-3 openclaw+LLM backfill · ${rows.length} firm domains · ${APPLY ? 'APPLY' : 'DRY-RUN'} ==\n`);
+  let totFound = 0, wrote = 0;
+  for (const r of rows) {
+    process.stdout.write(`  ${r.firm} (${r.agents} agents) — ${domainOf(r.website)} … `);
+    const res = await scrapeDomain(r.website);
+    if (res.err) { console.log(`[${res.err}]`); continue; }
+    totFound += res.listings.length;
+    console.log(`${res.listings.length} firm listing(s)`);
+    res.listings.slice(0, 3).forEach(l => console.log(`       $${l.price.toLocaleString()}  ${l.address}`));
+    if (APPLY && res.listings.length) {
+      for (const l of res.listings) {
+        const ins = await db.pool.query(
+          `INSERT INTO listing (id,address,price,type,source,created_at) VALUES (gen_random_uuid()::text,$1,$2,$3,'broker-site-oc',now()) RETURNING id`,
+          [l.address, l.price, l.type]).catch(() => ({ rows: [] }));
+        const lid = ins.rows[0]?.id; if (!lid) continue;
+        for (const bid of r.broker_ids) await db.pool.query(`INSERT INTO broker_listing (broker_id,listing_id,role) VALUES ($1,$2,'firm-listing') ON CONFLICT DO NOTHING`, [bid, lid]).catch(() => {});
+        wrote++;
+      }
+    }
+  }
+  console.log(`\n== ${rows.length} firms · ${totFound} listings extracted · ${APPLY ? wrote + ' written (source=broker-site-oc, role=firm-listing, reversible)' : 'DRY-RUN'} ==`);
+  console.log(APPLY ? "Undo: DELETE FROM broker_listing WHERE listing_id IN (SELECT id FROM listing WHERE source='broker-site-oc'); DELETE FROM listing WHERE source='broker-site-oc';" : 'Re-run with --apply to write.');
+  process.exit(0);
+})();

← 200087f CRCP: extend 'agent opens its own page' — condos listing age  ·  back to Commercialrealestate  ·  CRCP: Showcase daily refresh now openclaw-first ($0), browse e77b85d →