← back to Re Flyer Aggregator
add PER-PROPERTY index (Steve: data on every property) — build-property-index.mjs extracts address+city+type+price+parties from every deal/article w/ an address, aggregates all mentions into one property record w/ full transaction history; /properties viewer (search, expand history, sort by most-tracked/newest/biggest); 2340 unique properties; wired into refine loop (TK-10708)
9ea2e7d65b2ef0f7c535c001462708da55e700f3 · 2026-08-20 08:56:29 -0700 · Steve Abrams
Files touched
A public/properties/index.htmlA scripts/build-property-index.mjsM scripts/refine-loop.shM serve-viewers.mjs
Diff
commit 9ea2e7d65b2ef0f7c535c001462708da55e700f3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Aug 20 08:56:29 2026 -0700
add PER-PROPERTY index (Steve: data on every property) — build-property-index.mjs extracts address+city+type+price+parties from every deal/article w/ an address, aggregates all mentions into one property record w/ full transaction history; /properties viewer (search, expand history, sort by most-tracked/newest/biggest); 2340 unique properties; wired into refine loop (TK-10708)
---
public/properties/index.html | 56 ++++++++++++++++++++++++++++++++++++++
scripts/build-property-index.mjs | 58 ++++++++++++++++++++++++++++++++++++++++
scripts/refine-loop.sh | 1 +
serve-viewers.mjs | 2 +-
4 files changed, 116 insertions(+), 1 deletion(-)
diff --git a/public/properties/index.html b/public/properties/index.html
new file mode 100644
index 0000000..03bf758
--- /dev/null
+++ b/public/properties/index.html
@@ -0,0 +1,56 @@
+<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Properties — per-property CRE data · Abrams</title>
+<style>
+ :root{--navy:#0E2350;--blue:#16357A;--gold:#E8A81C;--gray:#6B7280} *{box-sizing:border-box}
+ body{margin:0;font-family:Inter,-apple-system,Helvetica,Arial,sans-serif;background:#f4f6fb;color:#141414}
+ header{background:var(--navy);color:#fff;padding:16px 26px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px}
+ header h1{margin:0;font-size:22px} header .s{color:var(--gold);font-size:12px;margin-top:3px} header a{color:#7fd7ff;font-size:13px;text-decoration:none;margin-left:14px}
+ .controls{position:sticky;top:0;z-index:5;background:#fff;border-bottom:1px solid #e6e9f2;padding:11px 26px;display:flex;gap:14px;align-items:center;flex-wrap:wrap;font-size:13px}
+ .controls input,.controls select{padding:7px 11px;border:1px solid #d6dced;border-radius:8px;font-size:14px} .controls #q{min-width:320px} .controls label{color:var(--gray);margin-right:5px}
+ .stat{color:var(--gray);margin-left:auto;font-variant-numeric:tabular-nums}
+ .rows{padding:8px 26px 60px}
+ .p{border-bottom:1px solid #eef0f6;padding:11px 12px;cursor:pointer}
+ .p:hover{background:#fbfcff}
+ .p .top{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap}
+ .p .addr{font-size:15px;font-weight:700;color:var(--navy)} .p .loc{color:#445;font-size:12px}
+ .p .chip{font-size:10px;font-weight:800;padding:2px 7px;border-radius:5px;color:#fff;background:#6d28d9} .p .pr{font-weight:800;color:var(--blue)}
+ .p .mn{background:#eef0f6;color:#334;border-radius:10px;padding:1px 9px;font-size:11px;font-weight:700}
+ .p .sub{color:var(--gray);font-size:12px;margin-top:3px}
+ .hist{margin:8px 0 2px 8px;padding-left:12px;border-left:2px solid #e6e9f2}
+ .hist .e{padding:5px 0;font-size:12px;border-top:1px solid #f4f6fb} .hist .e:first-child{border-top:none}
+ .hist .e .d{color:#8A5A00} .hist .e b{color:#0E7C4A} .hist .e a{color:#16357A;text-decoration:none} .hist .e .src{color:#9aa}
+ mark{background:#fff2ab}
+</style></head><body>
+<header><div><h1>Properties</h1><div class="s">ABRAMS · per-property CRE data — every property we've seen, with its full history</div></div>
+ <div><a href="/search">🔍 Search</a><a href="/deals">Deals</a><a href="/">📊 Sources</a></div></header>
+<div class="controls">
+ <input id="q" placeholder="Search by address, city, buyer, seller…" autofocus autocomplete="off">
+ <div><label>Type</label><select id="pt"><option value="">All</option><option>Office</option><option>Industrial</option><option>Retail</option><option>Multifamily</option><option>Hotel</option><option>Land</option></select></div>
+ <div><label>Sort</label><select id="sort"><option value="mentions">Most-tracked</option><option value="date">Newest</option><option value="price">Biggest $</option></select></div>
+ <div class="stat" id="stat">loading…</div>
+</div>
+<div class="rows" id="rows"></div>
+<script>
+const esc=s=>(s||'').replace(/[<>&]/g,c=>({'<':'<','>':'>','&':'&'}[c]));
+const dt=s=>{const d=new Date(s);return isNaN(d)?'':d.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'2-digit'});};
+const pn=l=>{if(!l)return 0;const m=(''+l).match(/[\d.]+/);if(!m)return 0;return parseFloat(m[0])*(/b/i.test(l)?1e9:/k/i.test(l)?1e3:1e6);};
+let DATA=[],Q='',EXP=new Set();
+function hl(t){if(!Q)return esc(t);const i=t.toLowerCase().indexOf(Q);return i<0?esc(t):esc(t.slice(0,i))+'<mark>'+esc(t.slice(i,i+Q.length))+'</mark>'+esc(t.slice(i+Q.length));}
+function match(p){ if(document.getElementById('pt').value&&p.property_type!==document.getElementById('pt').value)return false;
+ if(!Q)return true; return (p.address+' '+(p.city||'')+' '+(p.state||'')+' '+(p.property_type||'')+' '+p.entries.map(e=>e.title+' '+(e.buyer||'')+' '+(e.seller||'')).join(' ')).toLowerCase().includes(Q); }
+function hist(p){return `<div class="hist">${p.entries.map(e=>`<div class="e"><span class="d">${dt(e.date)}</span> ${e.price_label?'<b>'+esc(e.price_label)+'</b> ':''}${esc(e.txn||'')} · ${e.link?`<a href="${e.link}" target="_blank" rel="noopener">${hl(e.title||'')}</a>`:hl(e.title||'')} <span class="src">[${esc(e.source)}]</span>${e.buyer?' · buyer '+esc(e.buyer):''}${e.seller?' · seller '+esc(e.seller):''}</div>`).join('')}</div>`;}
+function render(){
+ const sort=document.getElementById('sort').value;
+ let a=DATA.filter(match);
+ a.sort(sort==='date'?(x,y)=>(Date.parse(y.latest)||0)-(Date.parse(x.latest)||0):sort==='price'?(x,y)=>pn(y.top_price)-pn(x.top_price):(x,y)=>y.mentions-x.mentions||(Date.parse(y.latest)||0)-(Date.parse(x.latest)||0));
+ document.getElementById('stat').textContent=`${a.length.toLocaleString()} properties${Q?` · “${Q}”`:''}`;
+ document.getElementById('rows').innerHTML=a.slice(0,600).map((p,i)=>{const k=p.address+'|'+(p.city||'');const open=EXP.has(k);
+ return `<div class="p" data-k="${esc(k)}"><div class="top"><span class="addr">${open?'▾ ':'▸ '}${hl(p.address)}</span><span class="chip">${(p.property_type||'CRE').toUpperCase()}</span><span class="pr">${p.top_price||p.latest_price||''}</span><span class="loc">📍 ${esc([p.city,p.state].filter(Boolean).join(', '))}</span><span class="mn">${p.mentions}× · ${p.source_count} src</span></div>
+ <div class="sub">latest: ${esc(p.latest_txn||'')} ${dt(p.latest)}</div>${open?hist(p):''}</div>`;}).join('')||'<div style="padding:24px;color:#9aa">No properties.</div>';
+}
+document.getElementById('rows').addEventListener('click',e=>{ if(e.target.closest('a'))return; const row=e.target.closest('.p'); if(!row)return; const k=row.dataset.k; EXP.has(k)?EXP.delete(k):EXP.add(k); render(); });
+let deb;document.getElementById('q').oninput=e=>{clearTimeout(deb);deb=setTimeout(()=>{Q=e.target.value.trim().toLowerCase();render();},120);};
+['pt','sort'].forEach(id=>document.getElementById(id).onchange=render);
+fetch('/properties/property-index.json?t='+Date.now()).then(r=>r.json()).then(j=>{DATA=j;render();}).catch(()=>document.getElementById('stat').textContent='load error');
+</script></body></html>
diff --git a/scripts/build-property-index.mjs b/scripts/build-property-index.mjs
new file mode 100644
index 0000000..e17e0f1
--- /dev/null
+++ b/scripts/build-property-index.mjs
@@ -0,0 +1,58 @@
+#!/usr/bin/env node
+// TK-10708 PER-PROPERTY INDEX (Steve: "store info so we can use for data on every property"). Extracts a
+// street address + city/state/type/price/parties from every deal-store + archive record that has one, and
+// AGGREGATES all mentions of the same property into one record (its full transaction history across outlets).
+// This is the "data on every property" layer. $0 local. Grows as the archive grows.
+//
+// Usage: node scripts/build-property-index.mjs
+
+import { readFileSync, writeFileSync, existsSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const load = f => existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : [];
+const store = load(join(ROOT, 'data', 'deal-store.json'));
+const arch = load(join(ROOT, 'data', 'article-archive.json'));
+
+const ADDR = /\b(\d{2,5}(?:-\d{2,5})?\s+(?:[NSEW]\.?\s+)?[A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+){0,3}\s+(?:Ave|Avenue|St|Street|Blvd|Boulevard|Dr|Drive|Rd|Road|Way|Ln|Lane|Ct|Court|Pl|Place|Pkwy|Parkway|Hwy|Highway|Cir|Circle|Ter|Terrace|Sq|Square))\b/;
+const PTYPE = /\b(office|industrial|warehouse|logistics|retail|multifamily|apartment|residential|hotel|resort|hospitality|medical|life science|lab|self.?storage|storage|mixed.?use|data center|land|shopping center|mall|flex)\b/i;
+const canon = a => a.toLowerCase().replace(/\b(ave|avenue|st|street|blvd|boulevard|dr|drive|rd|road|way|ln|lane|ct|court|pl|place|pkwy|parkway|hwy|highway|cir|circle|ter|terrace|sq|square)\b/g, m => m[0]).replace(/[^a-z0-9]+/g, ' ').trim();
+const num = x => typeof x.price === 'number' ? x.price : null;
+const cityState = m => { const p = (m || '').split(',').map(s => s.trim()); return { city: p[0] || null, state: p[1] || null }; };
+
+const props = new Map();
+let scanned = 0, matched = 0;
+for (const r of [...store, ...arch]) {
+ scanned++;
+ const addr = r.address || (ADDR.exec(r.title || '') || [])[1];
+ if (!addr) continue;
+ matched++;
+ const { city, state } = cityState(r.market);
+ const key = canon(addr) + '|' + (city || '').toLowerCase();
+ if (!props.has(key)) props.set(key, { address: addr.trim(), city, state, property_type: null, entries: [], sources: new Set() });
+ const p = props.get(key);
+ if (!p.property_type) p.property_type = r.property_type || (PTYPE.exec(r.title || '') || [])[1] || null;
+ if (city && !p.city) p.city = city; if (state && !p.state) p.state = state;
+ p.sources.add(r.source);
+ p.entries.push({ date: r.date, txn: r.type, price: num(r), price_label: r.price_label || r.label || (typeof r.price === 'string' ? r.price : null), source: r.source, link: r.link, title: r.title, buyer: r.buyer || null, seller: r.seller || null });
+}
+
+const out = [...props.values()].map(p => {
+ const e = p.entries.slice().sort((a, b) => (Date.parse(b.date) || 0) - (Date.parse(a.date) || 0));
+ // dedup entries by title
+ const seen = new Set(), ent = [];
+ for (const x of e) { const k = (x.title || '').toLowerCase().slice(0, 50); if (!seen.has(k)) { seen.add(k); ent.push(x); } }
+ const priced = ent.filter(x => x.price);
+ return {
+ address: p.address, city: p.city, state: p.state, property_type: p.property_type,
+ latest: ent[0]?.date, latest_txn: ent[0]?.txn, latest_price: ent[0]?.price_label,
+ top_price: priced.sort((a, b) => (b.price || 0) - (a.price || 0))[0]?.price_label || null,
+ mentions: ent.length, source_count: p.sources.size, sources: [...p.sources], entries: ent
+ };
+}).sort((a, b) => b.mentions - a.mentions || (Date.parse(b.latest) || 0) - (Date.parse(a.latest) || 0));
+
+writeFileSync(join(ROOT, 'public', 'properties', 'property-index.json'), JSON.stringify(out, null, 0));
+const multi = out.filter(p => p.mentions > 1).length;
+console.log(`Property index: scanned ${scanned} records, ${matched} had an address -> ${out.length} unique properties (${multi} with a history of 2+ mentions). -> public/properties/property-index.json`);
+for (const p of out.slice(0, 8)) console.log(` ${(p.top_price || p.latest_price || '—').padStart(7)} ${(p.property_type || '?').padEnd(12)} ${(p.city || '').padEnd(16)} ${p.address} [${p.mentions}× ${p.source_count} src]`);
diff --git a/scripts/refine-loop.sh b/scripts/refine-loop.sh
index b94faea..7535676 100755
--- a/scripts/refine-loop.sh
+++ b/scripts/refine-loop.sh
@@ -14,6 +14,7 @@ while true; do
node scripts/ingest-rentv.mjs >> "$LOG" 2>&1 # RENTV direct (Steve rentv.agentabrams.com API)
node scripts/ingest-public-deals.mjs >> "$LOG" 2>&1 # CA deeds/DTT
node scripts/build-flyers-site.mjs >> "$LOG" 2>&1 # rebuild flyers.json + index
+ node scripts/build-property-index.mjs >> "$LOG" 2>&1 # per-property index
git add data/ public/ scripts/ >/dev/null 2>&1
git commit -q -m "refine-loop cycle $n: +articles/deals $(date '+%m-%d %H:%M')" >/dev/null 2>&1
arch=$(node -e 'try{console.log(require("./data/article-archive.json").length)}catch(e){console.log(0)}' 2>/dev/null)
diff --git a/serve-viewers.mjs b/serve-viewers.mjs
index c744660..5593e80 100644
--- a/serve-viewers.mjs
+++ b/serve-viewers.mjs
@@ -4,7 +4,7 @@ const MIME = {'.html':'text/html','.json':'application/json','.csv':'text/csv','
const ROOT = dirname(fileURLToPath(import.meta.url));
const ALIAS = { '/':'/sources/index.html', '/sources':'/sources/index.html', '/live.html':'/flyers/live.html',
'/flyers.json':'/flyers/flyers.json', '/advertising':'/advertising/index.html', '/property-flyers':'/flyers-found/index.html', '/flyers-found':'/flyers-found/index.html', '/flyers':'/flyers/index.html',
- '/search':'/search/index.html', '/deals':'/deals/index.html', '/listings':'/search/index.html' };
+ '/search':'/search/index.html', '/deals':'/deals/index.html', '/properties':'/properties/index.html', '/listings':'/search/index.html' };
http.createServer((q,s)=>{
let u = decodeURIComponent(q.url.split('?')[0]); u = ALIAS[u] || u;
// the growing article archive lives in data/ (outside public) — serve it for the global search
← 2c83a5f auto-data-snapshot: 2026-08-20T08:33:21 (1 data files) — pub
·
back to Re Flyer Aggregator
·
serve-viewers: honor PORT env (default 9862) for Kamatera de ca7ef7d →