[object Object]

← back to Rentv 2026

RENTV: pull archive with LOCAL MODELS (qwen3:14b on Mac2+Mac1, $0) — TK-10250

b881b5e3c479764c30b91a279933d911584d750e · 2026-08-05 13:38:57 -0700 · Steve Abrams

Steve: 'use local models to pull data!!'. Replaces regex heuristics with local Ollama
structured extraction — txn/property type, city/state, $ amount, buyer/seller/broker parties,
size, clean summary. lib/llm-extract.mjs calls Ollama (format:json, think:false, temp:0) with a
regex fallback so an LLM timeout never drops an article. pull-archive.mjs runs a worker pool —
one worker per Ollama endpoint (Mac2 localhost + Mac1 192.168.1.133) — splitting the crawl across
both machines for ~2x throughput. Stores raw story text + extractor/model provenance; resumable,
lockfile-guarded. Verified: 2-machine split, rich extraction (parties + summary), 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit b881b5e3c479764c30b91a279933d911584d750e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 5 13:38:57 2026 -0700

    RENTV: pull archive with LOCAL MODELS (qwen3:14b on Mac2+Mac1, $0) — TK-10250
    
    Steve: 'use local models to pull data!!'. Replaces regex heuristics with local Ollama
    structured extraction — txn/property type, city/state, $ amount, buyer/seller/broker parties,
    size, clean summary. lib/llm-extract.mjs calls Ollama (format:json, think:false, temp:0) with a
    regex fallback so an LLM timeout never drops an article. pull-archive.mjs runs a worker pool —
    one worker per Ollama endpoint (Mac2 localhost + Mac1 192.168.1.133) — splitting the crawl across
    both machines for ~2x throughput. Stores raw story text + extractor/model provenance; resumable,
    lockfile-guarded. Verified: 2-machine split, rich extraction (parties + summary), 0 errors.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/lib/llm-extract.mjs |  71 +++++++++++++++++++++
 scripts/pull-archive.mjs    | 146 +++++++++++++++++++++++++++++---------------
 2 files changed, 169 insertions(+), 48 deletions(-)

diff --git a/scripts/lib/llm-extract.mjs b/scripts/lib/llm-extract.mjs
new file mode 100644
index 00000000..af97d2cb
--- /dev/null
+++ b/scripts/lib/llm-extract.mjs
@@ -0,0 +1,71 @@
+// llm-extract.mjs — LOCAL-MODEL structured extraction for rentv.com articles (TK-10250, Steve
+// 2026-08-05: "use local models to pull data!!"). Sends the article headline + body to a local
+// Ollama model (qwen3:14b on Mac2 + Mac1) and gets back clean structured CRE fields — better than
+// the regex heuristics (real summary, buyer/seller/broker parties, robust type/amount/location).
+// $0 (local), split across both machines by the caller for throughput.
+//
+// Endpoints default to Mac2 localhost + Mac1 tailnet/LAN; model default qwen3:14b. Forces
+// format:json + think:false + temperature:0 for deterministic, thinking-free JSON.
+export const OLLAMA_ENDPOINTS = ['http://localhost:11434', 'http://192.168.1.133:11434'];
+export const DEFAULT_MODEL = 'qwen3:14b';
+
+const PROP_TYPES = 'Office, Industrial, Retail, Multifamily, Hospitality, Medical/Life Science, Mixed-Use, Land, Self-Storage';
+const TXN_TYPES = 'Sale, Lease, Financing, Development';
+
+function buildPrompt(title, story) {
+  return `You extract structured data from a commercial real estate (CRE) news article. Return ONLY a strict JSON object, no prose.
+
+Fields:
+- txn_type: one of [${TXN_TYPES}] — the deal action.
+- property_type: one of [${PROP_TYPES}] — the asset class.
+- city: the ASSET's city (not a firm HQ), or null.
+- state: 2-letter US state of the asset, or null.
+- amount: deal dollar value as a plain integer (e.g. 51800000), or null if none stated.
+- buyer: the party acquiring/borrowing/leasing-in (firm or person), or null.
+- seller: the party selling/lending/leasing-out, or null.
+- broker: the brokerage firm(s) that arranged the deal, or null.
+- size: the asset size as text (e.g. "236 units", "50,000 SF", "12 acres"), or null.
+- summary: one clean factual sentence summarizing the deal.
+
+Article headline: "${title}"
+Article body: ${story}
+
+JSON:`;
+}
+
+// Extract from one article via one Ollama endpoint. Returns the parsed object or null on any failure
+// (timeout / bad JSON / model error) so the caller can fall back to the regex parser.
+export async function llmExtract(title, story, { endpoint = OLLAMA_ENDPOINTS[0], model = DEFAULT_MODEL, timeoutMs = 30000 } = {}) {
+  try {
+    const r = await fetch(endpoint + '/api/generate', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ model, prompt: buildPrompt(title, String(story).slice(0, 1800)), format: 'json', stream: false, think: false, options: { temperature: 0, num_ctx: 4096 } }),
+      signal: AbortSignal.timeout(timeoutMs),
+    });
+    if (!r.ok) return null;
+    const j = await r.json();
+    let obj;
+    try { obj = JSON.parse(j.response); } catch { return null; }
+    if (!obj || typeof obj !== 'object') return null;
+    // normalize
+    const num = (v) => { if (v == null) return null; const n = +String(v).replace(/[^0-9.]/g, ''); return isFinite(n) && n > 0 ? Math.round(n) : null; };
+    const amount = num(obj.amount);
+    const clean = (v) => { const s = v == null ? '' : String(v).trim(); return s && !/^(null|n\/a|none|unknown)$/i.test(s) ? s : null; };
+    return {
+      txn_type: clean(obj.txn_type), property_type: clean(obj.property_type),
+      city: clean(obj.city), state: clean(obj.state) ? String(obj.state).toUpperCase().slice(0, 2) : null,
+      amount, amount_label: amount ? (amount >= 1e9 ? `$${(amount / 1e9).toFixed(2)}B` : amount >= 1e6 ? `$${(amount / 1e6).toFixed(1)}M` : `$${amount.toLocaleString()}`) : null,
+      buyer: clean(obj.buyer), seller: clean(obj.seller), broker: clean(obj.broker),
+      size_label: clean(obj.size), summary: clean(obj.summary),
+    };
+  } catch { return null; }
+}
+
+// Verify an endpoint has the model loaded + responding (used at crawl startup).
+export async function endpointUp(endpoint, model = DEFAULT_MODEL) {
+  try {
+    const r = await fetch(endpoint + '/api/generate', { method: 'POST', body: JSON.stringify({ model, prompt: 'ok', stream: false, think: false, options: { num_predict: 2 } }), signal: AbortSignal.timeout(8000) });
+    return r.ok;
+  } catch { return false; }
+}
diff --git a/scripts/pull-archive.mjs b/scripts/pull-archive.mjs
index af64ce16..e283ed31 100644
--- a/scripts/pull-archive.mjs
+++ b/scripts/pull-archive.mjs
@@ -1,46 +1,51 @@
 #!/usr/bin/env node
-// pull-archive.mjs — backfill the FULL rentv.com article archive into the corpus (TK-10247,
-// Steve 2026-08-05: "load all rentv.com articles… to analyze how to properly post news events").
+// pull-archive.mjs — backfill the FULL rentv.com article archive into the corpus, extracting each
+// article with a LOCAL MODEL (TK-10250; Steve 2026-08-05: "load all rentv.com articles… use local
+// models to pull data!!"). qwen3:14b on Mac2 + Mac1 (Ollama) does the structured CRE extraction —
+// txn/property type, city/state, $ amount, buyer/seller/broker parties, size, a clean summary —
+// far better than regex heuristics. $0 (local). Work is SPLIT across both machines via a worker
+// pool (one worker per Ollama endpoint) for ~2x throughput. Regex parse is the FALLBACK so an LLM
+// timeout never drops an article.
 //
-// rentv.com articles are /content/homepage/mainnews/news/{id} with sequential integer ids. IDs
-// below ~20000 302→cart.php (gone). From ~20000→newest they're live: real articles interleaved
-// with a byte-identical empty boilerplate stub. parse-article.mjs returns null for the stub, so
-// we keep only real articles. $0 plain-fetch, polite jittered delay, iso-8859-1 decode.
+// rentv.com articles are /content/homepage/mainnews/news/{id}, sequential ids. <~20000 302→cart.php
+// (gone). ~20000→newest are live: real articles interleaved with a byte-identical empty stub
+// (parseHeadline→'' → skipped). iso-8859-1 decode. RESUMABLE: appends data/articles-corpus.jsonl,
+// tracks a descending cursor in data/archive-crawl-state.json; already-ingested ids are skipped.
 //
-// RESUMABLE: appends to data/articles-corpus.jsonl (one article per line) and tracks a descending
-// cursor in data/archive-crawl-state.json. Re-run with --resume (default) to continue; already-
-// ingested ids are skipped. Runs for a while (14k+ ids) — launch in the background.
-//
-// Usage: node scripts/pull-archive.mjs [--max 34700] [--min 20000] [--delay 450] [--limit N] [--fresh]
-import { createReadStream, existsSync, appendFileSync, writeFileSync, readFileSync, renameSync } from 'node:fs';
+// Usage: node scripts/pull-archive.mjs [--max 34290] [--min 20000] [--delay 120] [--limit N]
+//        [--fresh] [--model qwen3:14b] [--endpoints a,b] [--no-llm]
+import { createReadStream, existsSync, appendFileSync, writeFileSync, readFileSync, renameSync, unlinkSync } from 'node:fs';
 import { dirname, join } from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { createInterface } from 'node:readline';
-import { parseArticle } from './lib/parse-article.mjs';
+import { parseArticle, parseHeadline, bodyText } from './lib/parse-article.mjs';
+import { llmExtract, endpointUp, OLLAMA_ENDPOINTS, DEFAULT_MODEL } from './lib/llm-extract.mjs';
 
 const HERE = dirname(fileURLToPath(import.meta.url));
 const DATA = join(HERE, '..', 'data');
 const CORPUS = join(DATA, 'articles-corpus.jsonl');
 const STATE = join(DATA, 'archive-crawl-state.json');
+const LOCK = join(DATA, '.archive-crawl.lock');
 const arg = (k, d) => { const i = process.argv.indexOf(k); return i > -1 ? (process.argv[i + 1] ?? true) : d; };
-const MAX = +arg('--max', 34700);
+const MAX = +arg('--max', 34290);
 const MIN = +arg('--min', 20000);
-const DELAY = +arg('--delay', 450);
+const DELAY = +arg('--delay', 120);       // polite gap between an endpoint's fetches (LLM call dominates anyway)
 const LIMIT = +arg('--limit', 0) || Infinity;
 const FRESH = process.argv.includes('--fresh');
+const USE_LLM = !process.argv.includes('--no-llm');
+const MODEL = arg('--model', DEFAULT_MODEL);
+const ENDPOINTS = (arg('--endpoints', '') ? String(arg('--endpoints', '')).split(',') : OLLAMA_ENDPOINTS).filter(Boolean);
 const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
-const jitter = () => DELAY + Math.floor(Math.random() * 350);
 
 async function fetchArticle(id) {
   const url = `https://www.rentv.com/content/homepage/mainnews/news/${id}`;
   const r = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 RENTV-archive' }, redirect: 'manual', signal: AbortSignal.timeout(20000) });
-  if (r.status !== 200) return { status: r.status, html: null };   // 302 = gone
+  if (r.status !== 200) return { status: r.status, html: null };
   const b = Buffer.from(await r.arrayBuffer());
   let s = b.toString('utf8');
   if ((s.match(/�/g) || []).length > 5) s = b.toString('latin1');
   return { status: 200, html: s };
 }
-
 async function loadIngestedIds() {
   const seen = new Set();
   if (!existsSync(CORPUS)) return seen;
@@ -54,44 +59,89 @@ async function loadIngestedIds() {
 const loadState = () => { try { return JSON.parse(readFileSync(STATE, 'utf8')); } catch { return null; } };
 const saveState = (s) => { const tmp = STATE + '.tmp'; writeFileSync(tmp, JSON.stringify(s, null, 1)); renameSync(tmp, STATE); };
 
+// Build one corpus record for an id: regex parse (structure + fallback) MERGED with the local-model
+// extraction (authoritative for the fields it fills). Returns null if the page isn't a real article.
+async function buildRecord(id, html, endpoint) {
+  const base = parseArticle(id, html);              // regex — real-article gate + fallback fields
+  if (!base) return null;
+  const story = (() => { const b = bodyText(html); const i = b.indexOf(base.title); return (i >= 0 ? b.slice(i + base.title.length) : b).replace(/^\s*\d{1,2}\/\d{1,2}\/\d{2,4}\s*/, '').trim().slice(0, 1800); })();
+  let rec = { ...base, story, extractor: 'regex' };
+  if (USE_LLM) {
+    const llm = await llmExtract(base.title, story, { endpoint, model: MODEL });
+    if (llm) {
+      rec = {
+        ...base,
+        txn_type: llm.txn_type || base.txn_type, property_type: llm.property_type || base.property_type,
+        city: llm.city || base.city, state: llm.state || base.state,
+        amount: llm.amount ?? base.amount, amount_label: llm.amount_label || base.amount_label,
+        size_label: llm.size_label || base.size_label,
+        buyer: llm.buyer || null, seller: llm.seller || null, broker: llm.broker || null,
+        summary: llm.summary || base.summary,
+        story, extractor: 'llm', model: MODEL,
+      };
+    }
+  }
+  return rec;
+}
+
 async function main() {
+  if (existsSync(LOCK)) { try { const age = Date.now() - JSON.parse(readFileSync(LOCK, 'utf8')).at; if (age < 6 * 3600e3) { console.log('another archive crawl holds the lock — skipping.'); return; } } catch { /* reclaim */ } }
+  writeFileSync(LOCK, JSON.stringify({ pid: process.pid, at: Date.now() }));
+  process.on('exit', () => { try { unlinkSync(LOCK); } catch { /* gone */ } });
+
   if (FRESH && existsSync(CORPUS)) writeFileSync(CORPUS, '');
   const seen = FRESH ? new Set() : await loadIngestedIds();
   const prev = FRESH ? null : loadState();
-  let cursor = prev && prev.cursor != null ? Math.min(prev.cursor, MAX) : MAX;   // descending
-  const stat = prev && !FRESH ? prev
-    : { max: MAX, min: MIN, cursor, real: 0, empty: 0, gone: 0, errors: 0, started_at: new Date().toISOString() };
-  stat.max = Math.max(stat.max || MAX, MAX);
+  let nextId = prev && prev.cursor != null ? Math.min(prev.cursor, MAX) : MAX;   // shared descending cursor
+  const stat = prev && !FRESH ? prev : { max: MAX, min: MIN, cursor: nextId, real: 0, empty: 0, gone: 0, errors: 0, llm: 0, regex_fallback: 0, started_at: new Date().toISOString() };
+  // ensure counters exist when resuming an older state file (pre-LLM runs lack these keys)
+  for (const k of ['real', 'empty', 'gone', 'errors', 'llm', 'regex_fallback']) if (typeof stat[k] !== 'number') stat[k] = 0;
+
+  // Verify endpoints (drop any that are down so we don't stall a worker on a dead machine).
+  let live = ENDPOINTS;
+  if (USE_LLM) {
+    const checks = await Promise.all(ENDPOINTS.map(async (e) => ({ e, up: await endpointUp(e, MODEL) })));
+    live = checks.filter((c) => c.up).map((c) => c.e);
+    console.log(`local-model pull: ${MODEL} on ${live.length}/${ENDPOINTS.length} endpoints [${live.join(', ')}]`);
+    if (!live.length) { console.error('no Ollama endpoint is up — aborting (or use --no-llm for regex).'); return; }
+  } else { live = ['(regex-only)']; console.log('regex-only mode (--no-llm)'); }
 
-  console.log(`archive backfill: ids ${cursor}→${MIN} · ${seen.size} already in corpus · delay ~${DELAY}ms · $0 local`);
-  let processed = 0, consecErr = 0;
+  console.log(`archive backfill: ids ${nextId}→${MIN} · ${seen.size} already in corpus · ${live.length} worker(s) · $0 local`);
+  let processed = 0, stop = false;
   const t0 = Date.now();
-  for (let id = cursor; id >= MIN; id--) {
-    stat.cursor = id;
-    if (seen.has(String(id))) continue;
-    if (processed >= LIMIT) break;
-    processed++;
-    let res;
-    try { res = await fetchArticle(id); consecErr = 0; }
-    catch (e) { stat.errors++; consecErr++; if (consecErr >= 12) { console.error(`\n12 consecutive fetch errors — network down? stopping (resumable at ${id}).`); break; } await sleep(1500); continue; }
-    if (res.status === 302 || res.status === 301) { stat.gone++; }
-    else if (res.status === 200 && res.html) {
-      const a = res.html ? parseArticle(id, res.html) : null;
-      if (a) { appendFileSync(CORPUS, JSON.stringify(a) + '\n'); seen.add(String(id)); stat.real++; }
-      else stat.empty++;
-    } else stat.empty++;
-    if (processed % 25 === 0) {
-      stat.updated_at = new Date().toISOString();
-      saveState(stat);
-      const rate = processed / ((Date.now() - t0) / 1000);
-      process.stdout.write(`\r  at id ${id}  real:${stat.real}  empty:${stat.empty}  gone:${stat.gone}  (${rate.toFixed(1)}/s, ${processed} this run)   `);
+  const persist = () => { stat.cursor = nextId; stat.updated_at = new Date().toISOString(); saveState(stat); };
+
+  // One worker per endpoint; all share nextId + seen (single-threaded event loop → no races).
+  async function worker(endpoint) {
+    while (!stop) {
+      const id = nextId--;
+      if (id < MIN) break;
+      stat.cursor = Math.min(stat.cursor, id);
+      if (seen.has(String(id))) continue;
+      if (processed >= LIMIT) { stop = true; break; }
+      processed++;
+      let res;
+      try { res = await fetchArticle(id); }
+      catch { stat.errors++; await sleep(1000); continue; }
+      if (res.status === 302 || res.status === 301) { stat.gone++; }
+      else if (res.status === 200 && res.html) {
+        if (!parseHeadline(res.html)) { stat.empty++; }
+        else {
+          const rec = await buildRecord(id, res.html, endpoint);
+          if (rec) { appendFileSync(CORPUS, JSON.stringify(rec) + '\n'); seen.add(String(id)); stat.real++; if (rec.extractor === 'llm') stat.llm++; else stat.regex_fallback++; }
+          else stat.empty++;
+        }
+      } else stat.empty++;
+      if (stat.real % 20 === 0) { persist(); const rate = processed / ((Date.now() - t0) / 1000); process.stdout.write(`\r  at id ${nextId}  real:${stat.real} (llm:${stat.llm} fallback:${stat.regex_fallback})  empty:${stat.empty}  gone:${stat.gone}  ${rate.toFixed(2)}/s   `); }
+      await sleep(DELAY);
     }
-    await sleep(jitter());
   }
-  stat.updated_at = new Date().toISOString();
-  stat.done = stat.cursor <= MIN;
-  saveState(stat);
-  console.log(`\n${stat.done ? 'DONE' : 'PAUSED'}: ${stat.real} real articles in corpus · empty:${stat.empty} · gone:${stat.gone} · errors:${stat.errors}`);
+  await Promise.all(live.map((e) => USE_LLM ? worker(e) : worker(null)));
+
+  stat.cursor = Math.max(MIN, nextId + 1);
+  stat.done = nextId < MIN;
+  persist();
+  console.log(`\n${stat.done ? 'DONE' : 'PAUSED'}: ${stat.real} articles (llm:${stat.llm} regex-fallback:${stat.regex_fallback}) · empty:${stat.empty} · gone:${stat.gone} · errors:${stat.errors}`);
   if (!stat.done) console.log(`  resume: node scripts/pull-archive.mjs  (continues at id ${stat.cursor})`);
 }
 main().catch((e) => { console.error('archive crawl failed:', e.message); process.exit(1); });

← 0432aa44 RENTV: move Property Closings to backend-only + editable ove  ·  back to Rentv 2026  ·  auto-save: 2026-08-05T13:42:25 (8 files) — data/deals-regist b39b469c →