[object Object]

← back to Re Flyer Aggregator

add /deals — price-ranked 'unique deals' view (sort by biggest $ / newest / most-reported); cross-source clustering by price-bucket+entity merges exact dupes (Rexford/Blackstone/Goldman show '2 sources'); conservative by design — full cross-outlet dedup needs entity resolution. Additive, no collision (TK-10708)

a851742b271c712e35f61a8f4ef27aa4a2194062 · 2026-08-20 08:17:38 -0700 · Steve Abrams

Files touched

Diff

commit a851742b271c712e35f61a8f4ef27aa4a2194062
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 20 08:17:38 2026 -0700

    add /deals — price-ranked 'unique deals' view (sort by biggest $ / newest / most-reported); cross-source clustering by price-bucket+entity merges exact dupes (Rexford/Blackstone/Goldman show '2 sources'); conservative by design — full cross-outlet dedup needs entity resolution. Additive, no collision (TK-10708)
---
 public/deals/index.html | 73 +++++++++++++++++++++++++++++++++++++++++++++++++
 serve-viewers.mjs       |  2 +-
 2 files changed, 74 insertions(+), 1 deletion(-)

diff --git a/public/deals/index.html b/public/deals/index.html
new file mode 100644
index 0000000..8b1a8fc
--- /dev/null
+++ b/public/deals/index.html
@@ -0,0 +1,73 @@
+<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Unique Deals — one-stop CRE · 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:6px 9px;border:1px solid #d6dced;border-radius:6px;font-size:13px} .controls label{color:var(--gray);margin-right:5px}
+ .stat{color:var(--gray);margin-left:auto;font-variant-numeric:tabular-nums}
+ .rows{padding:10px 26px 60px}
+ .r{padding:11px 12px;border-bottom:1px solid #eef0f6}
+ .r .top{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap}
+ .r .p{font-weight:800;color:var(--blue);font-size:16px;min-width:74px} .r .chip{font-size:10px;font-weight:800;padding:2px 7px;border-radius:5px;color:#fff}
+ .r .d{color:#8A5A00;font-size:12px} .r .mk{color:#445;font-size:12px}
+ .r .ttl{font-size:14px;margin:3px 0 4px} .r .ttl a{color:#141414;text-decoration:none} .r .ttl a:hover{color:var(--blue)}
+ .r .srcs{font-size:12px;color:var(--gray)} .r .srcs .n{background:#eef0f6;color:#334;border-radius:10px;padding:1px 8px;font-weight:700;font-size:11px}
+ .r .srcs a{color:#8A5A00;text-decoration:none;margin-left:8px} .r .srcs a:hover{text-decoration:underline}
+ mark{background:#fff2ab}
+</style></head><body>
+<header><div><h1>Unique Deals</h1><div class="s">ABRAMS · one-stop CRE — same deal across outlets, merged</div></div>
+ <div><a href="/search">🔍 Search all</a><a href="/">📊 Sources</a><a href="/live.html">◂ Live</a></div></header>
+<div class="controls">
+ <input id="q" placeholder="search deals…" size="22">
+ <div><label>Type</label><select id="ft"><option value="">All</option><option>Sale</option><option>Listing</option><option>Lease</option><option>Loan</option><option>Development</option></select></div>
+ <div><label>Sort</label><select id="sort"><option value="price">Biggest $</option><option value="date">Newest</option><option value="src">Most-reported</option></select></div>
+ <div class="stat" id="stat">loading…</div>
+</div>
+<div class="rows" id="rows"></div>
+<script>
+const TC={Sale:'#0E7C4A',Listing:'#0ea5e9',Lease:'#16357A',Loan:'#8A5A00',Development:'#6d28d9',News:'#64748b',Post:'#E8A81C',Filing:'#6B7280',CRE:'#6B7280'};
+const money=n=>!n?'':n>=1e9?'$'+(n/1e9).toFixed(1)+'B':n>=1e6?'$'+Math.round(n/1e6)+'M':'$'+Math.round(n/1e3)+'K';
+const esc=s=>(s||'').replace(/[<>&]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]));
+const dt=s=>{const d=new Date(s);return isNaN(d)?'':d.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'2-digit'});};
+// cross-source cluster key: price + the first Proper-Noun phrase in the title (the entity)
+const entity=t=>{const m=(t||'').replace(/^(exclusive|news|report|podcast)\s*[:|-]\s*/i,'').match(/[A-Z][\w.&'’-]+(?:\s+[A-Z][\w.&'’-]+){0,2}/);return m?m[0].toLowerCase():'';};
+const priceNum=x=>x.price&&typeof x.price==='number'?x.price:(x.price_label||x.price||'').toString().match(/[\d.]+/)?parseFloat((x.price_label||x.price).toString().replace(/[^\d.]/g,''))*((x.price_label||x.price).toString().match(/b/i)?1e9:(x.price_label||x.price).toString().match(/k/i)?1e3:1e6):0;
+let CLUS=[],Q='';
+const pbucket=n=>{n=+n||0;if(!n)return '0';const s=Math.round(n/Math.pow(10,Math.floor(Math.log10(n))-1))*Math.pow(10,Math.floor(Math.log10(n))-1);return ''+s;};   // 2-sig-fig so $1.2B label variants collide
+function cluster(items){
+ const map=new Map();
+ for(const x of items){ const pl=x.price_label||(typeof x.price==='string'?x.price:money(x.price)); const ent=entity(x.title); if(!ent)continue; const key=pbucket(priceNum(x))+'|'+ent+'|'+(x.type||'');
+  if(!map.has(key)){map.set(key,{...x,price_label:pl,sources:[],links:[]});}
+  const g=map.get(key); if(!g.sources.includes(x.source)){g.sources.push(x.source);if(x.link)g.links.push({s:x.source,l:x.link});}
+  if((Date.parse(x.date)||0)>(Date.parse(g.date)||0)){g.date=x.date;g.title=x.title;g.link=x.link||g.link;} }
+ return [...map.values()];
+}
+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 render(){
+ const ft=document.getElementById('ft').value,sort=document.getElementById('sort').value;
+ let a=CLUS.filter(x=>(!ft||x.type===ft)&&(!Q||(x.title+' '+x.sources.join(' ')+' '+(x.market||'')).toLowerCase().includes(Q)));
+ a.sort(sort==='date'?(x,y)=>(Date.parse(y.date)||0)-(Date.parse(x.date)||0):sort==='src'?(x,y)=>y.sources.length-x.sources.length||priceNum(y)-priceNum(x):(x,y)=>priceNum(y)-priceNum(x));
+ document.getElementById('stat').textContent=`${a.length.toLocaleString()} unique deals${ft?' · '+ft:''}${Q?` · “${Q}”`:''}`;
+ document.getElementById('rows').innerHTML=a.slice(0,700).map(x=>{const c=TC[x.type]||'#6B7280';
+  const srcs=x.sources.length>1?`<span class="n">${x.sources.length} sources</span> ${x.links.slice(0,6).map(o=>`<a href="${o.l}" target="_blank" rel="noopener">${esc(o.s)}↗</a>`).join('')}`:`<a href="${x.link||'#'}" target="_blank" rel="noopener">${esc(x.sources[0]||'')}↗</a>`;
+  return `<div class="r"><div class="top"><span class="p">${x.price_label||money(x.price)||'—'}</span><span class="chip" style="background:${c}">${(x.type||'CRE').toUpperCase()}</span><span class="d">${dt(x.date)}</span><span class="mk">📍 ${esc(x.market||'')}</span></div>
+   <div class="ttl">${x.link?`<a href="${x.link}" target="_blank" rel="noopener">${hl(x.title||'')}</a>`:hl(x.title||'')}</div>
+   <div class="srcs">${srcs}</div></div>`;}).join('')||'<div style="padding:24px;color:#9aa">No deals.</div>';
+}
+let deb;document.getElementById('q').oninput=e=>{clearTimeout(deb);deb=setTimeout(()=>{Q=e.target.value.trim().toLowerCase();render();},120);};
+['ft','sort'].forEach(id=>document.getElementById(id).onchange=render);
+async function load(){
+ const [deals,arch]=await Promise.all([
+  fetch('/flyers.json?t='+Date.now()).then(r=>r.json()).catch(()=>[]),
+  fetch('/article-archive.json?t='+Date.now()).then(r=>r.ok?r.json():[]).catch(()=>[])]);
+ // only real transactions (has a $ or a deal type) — cluster those
+ const items=[...deals,...arch].filter(x=>(x.price||x.price_label)&&['Sale','Listing','Lease','Loan','Development'].includes(x.type));
+ CLUS=cluster(items); render();
+}
+load();
+</script></body></html>
diff --git a/serve-viewers.mjs b/serve-viewers.mjs
index f22c3b5..c744660 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', '/listings':'/search/index.html' };
+  '/search':'/search/index.html', '/deals':'/deals/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

← e531cc6 repo hygiene: gitignore regenerable build artifacts (flyers.  ·  back to Re Flyer Aggregator  ·  archive: record only CRE-RELEVANT articles (RE_SIGNAL + not d912870 →