← back to Re Flyer Aggregator
scripts/build-flyers-site.mjs
128 lines
#!/usr/bin/env node
// TK-10708 Build flyers.agentabrams.com — one place to view every aggregated CRE deal as
// a branded flyer with its info extracted. STATIC site (no server/DB exposed). We generate
// our OWN flyer per deal from the deal data we hold (no third-party PDF hosting = no
// copyright exposure). Each flyer prints to PDF. Public deploy is Steve-gated.
//
// Usage: node scripts/build-flyers-site.mjs -> public/flyers/index.html + data embedded
import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const OUT = join(ROOT, 'out');
const SITE = join(ROOT, 'public', 'flyers');
mkdirSync(SITE, { recursive: true });
// --- gather every deal: the ROLLING STORE (news RSS + public-record deeds/DTT) + EDGAR ---
const recs = [];
const STORE = join(ROOT, 'data', 'deal-store.json');
if (existsSync(STORE)) for (const d of JSON.parse(readFileSync(STORE, 'utf8')))
recs.push({ type: d.type || 'CRE', price: d.price, price_label: d.label, market: d.market || d.location, title: d.title, source: d.source, link: d.link, date: d.date, buyer: d.buyer, seller: d.seller });
for (const f of readdirSync(OUT)) {
if (/^edgar-new-deals-.*\.json$/.test(f)) {
for (const d of JSON.parse(readFileSync(join(OUT, f), 'utf8')))
recs.push({ type: d.kind === 'closed_deal' ? 'Sale' : 'Filing', price: d.price, price_label: d.price_label, market: d.location || d.region, title: `${(d.filer||'').split('(')[0].trim()} — ${d.property_type||'acquisition'}`, source: 'SEC EDGAR', link: d.filing_url, date: d.file_date });
}
}
// dedup by (price + normalized title)
const seen = new Set(), deals = [];
for (const r of recs) {
const k = `${r.price}|${(r.title||'').toLowerCase().replace(/[^a-z0-9]+/g,' ').trim().slice(0,40)}`;
if (seen.has(k) || !r.title) continue; seen.add(k); deals.push(r);
}
// extract parties (buyer / seller) from the headline verb pattern — "the info" per flyer.
const NAME = "[A-Z][\\w.&'’ -]{2,45}?";
for (const d of deals) {
const t = d.title;
// buyer: "X buys/acquires/to pay ..." OR a sale headline ending "... to X"
let b = t.match(new RegExp(`^(${NAME})\\s+(?:to\\s+)?(?:buys?|acquires?|purchases?|to pay|pays?|snaps up|picks up|nabs?|grabs?)\\b`, 'i'));
if (!b) b = t.match(new RegExp(`\\b(?:sells?|sold|offloads?|unloads?)\\b.*?\\bto\\s+(${NAME})(?:\\s+for\\b|,|\\.|$)`, 'i'));
d.buyer = b ? b[1].trim() : null;
// seller: "X sells/to sell/offloads ..." OR "... from X"
let s = t.match(new RegExp(`^(${NAME})\\s+(?:to\\s+)?(?:sells?|sold|offloads?|unloads?|dispose)`, 'i'));
if (!s) s = t.match(new RegExp(`\\bfrom\\s+(${NAME})(?:\\s+for\\b|,|\\.|$)`, 'i'));
d.seller = s ? s[1].trim() : null;
}
deals.sort((a, b) => (b.price || 0) - (a.price || 0));
writeFileSync(join(SITE, 'flyers.json'), JSON.stringify(deals, null, 0));
const TC = { Sale: '#0E7C4A', Loan: '#8A5A00', Lease: '#16357A', Listing: '#0ea5e9', Development: '#6d28d9', News: '#64748b', Filing: '#6B7280', Post: '#E8A81C' };
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Flyers — Abrams | CRE deal flyers</title>
<style>
:root{--cols:3;--navy:#0E2350;--blue:#16357A;--gold:#E8A81C;--ink:#141414;--gray:#6B7280}
*{box-sizing:border-box} body{margin:0;font-family:Inter,-apple-system,Helvetica,Arial,sans-serif;background:#f4f6fb;color:var(--ink)}
header{background:var(--navy);color:#fff;padding:22px 34px} header h1{margin:0;font-size:24px;letter-spacing:.5px}
header .s{color:var(--gold);font-size:13px;margin-top:4px}
.controls{position:sticky;top:0;z-index:5;background:#fff;border-bottom:1px solid #e6e9f2;padding:12px 34px;display:flex;gap:16px;align-items:center;flex-wrap:wrap;font-size:13px}
.controls label{color:var(--gray);margin-right:5px} select,input[type=range]{vertical-align:middle}
.stat{color:var(--gray);margin-left:auto}
.wrap{padding:22px 34px;display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:16px}
.flyer{background:#fff;border-radius:10px;box-shadow:0 2px 12px rgba(0,0,0,.07);padding:18px 20px;display:flex;flex-direction:column;min-height:190px}
.flyer .top{display:flex;justify-content:space-between;align-items:flex-start}
.chip{color:#fff;font-size:10px;font-weight:700;padding:3px 9px;border-radius:5px;letter-spacing:.5px}
.price{font-size:30px;font-weight:800;color:var(--blue);margin:8px 0 2px;letter-spacing:-.5px}
.ttl{font-family:'Playfair Display',Georgia,serif;font-size:16px;line-height:1.25;color:var(--navy);margin:2px 0 8px}
.meta{font-size:12px;color:var(--gray);line-height:1.7;margin-top:auto}
.meta b{color:var(--ink);font-weight:600}
.src{margin-top:10px;font-size:12px} .src a{color:#8A5A00;text-decoration:none;font-weight:600}
.mk{display:inline-block;background:#eef0f6;color:#445;border-radius:4px;padding:1px 7px;font-size:11px}
@media(max-width:700px){.wrap{grid-template-columns:1fr}}
</style></head><body>
<header><h1>Flyers · Abrams</h1><div class="s">Commercial real-estate deal flyers — one place · updated from 19 CRE news sources</div></header>
<div class="controls">
<div><label>Type</label><select id="ftype"><option value="">All</option><option>Sale</option><option>Listing</option><option>Lease</option><option>Loan</option><option>Development</option><option>News</option></select></div>
<div><label>Market</label><select id="fmk"><option value="">All</option></select></div>
<div><label>Show</label><select id="recency"><option value="0">All time</option><option value="7">Past 7 days</option><option value="30">Past 30 days</option><option value="90">Past 90 days</option></select></div>
<div><label>Sort</label><select id="sort"><option value="price">Price ↓</option><option value="date">Newest</option><option value="type">Type</option></select></div>
<div><label>Density</label><input type="range" id="dens" min="1" max="5" step="1" value="3"></div>
<div class="stat" id="stat"></div>
</div>
<div class="wrap" id="grid"></div>
<script>
const TC=${JSON.stringify(TC)};
const money=n=>n?'$'+(+n).toLocaleString('en-US'):'';
const tc=s=>(s||'').toLowerCase().replace(/\\b\\w/g,c=>c.toUpperCase());
let DEALS=[];
function card(d){const c=TC[d.type]||'#6B7280';
return \`<div class="flyer">
<div class="top"><span class="chip" style="background:\${c}">\${(d.type||'CRE').toUpperCase()}</span><span class="mk">\${d.market||'US'}</span></div>
<div class="price">\${d.price_label||money(d.price)||'—'}</div>
<div class="ttl">\${(d.title||'').replace(/</g,'<')}</div>
<div class="meta">
\${d.buyer?'<div><b>Buyer:</b> '+d.buyer+'</div>':''}
\${d.seller?'<div><b>Seller:</b> '+d.seller+'</div>':''}
\${d.date?'<div><b>Reported:</b> '+new Date(d.date).toLocaleDateString()+'</div>':''}
</div>
<div class="src"><a href="\${d.link}" target="_blank" rel="noopener noreferrer">\${d.source} ↗</a></div>
</div>\`;}
function markets(){return [...new Set(DEALS.map(d=>d.market).filter(Boolean))].sort();}
function render(){let a=[...DEALS];
const ft=ftype.value,fk=fmk.value;
if(ft)a=a.filter(d=>d.type===ft); if(fk)a=a.filter(d=>d.market===fk);
const rc=+recency.value; if(rc){const cut=Date.now()-rc*864e5;a=a.filter(d=>{const t=Date.parse(d.date||'');return !isNaN(t)&&t>=cut;});}
const s=sort.value;
a.sort(s==='date'?(x,y)=>(y.date||'').localeCompare(x.date||''):s==='type'?(x,y)=>(x.type||'').localeCompare(y.type||''):(x,y)=>(y.price||0)-(x.price||0));
grid.innerHTML=a.map(card).join(''); stat.textContent=a.length+' flyers'+(rc?' · past '+rc+'d':'');
localStorage.flyType=ft;localStorage.flyMk=fk;localStorage.flySort=s;localStorage.flyRecency=recency.value;}
function dens(){document.documentElement.style.setProperty('--cols',document.getElementById('dens').value);localStorage.flyDens=document.getElementById('dens').value;}
['ftype','fmk','sort','recency'].forEach(id=>document.getElementById(id).onchange=render);
document.getElementById('dens').oninput=dens;
fetch('flyers.json').then(r=>r.json()).then(j=>{DEALS=j;
fmk.innerHTML='<option value="">All</option>'+markets().map(m=>'<option>'+m+'</option>').join('');
if(localStorage.flyType)ftype.value=localStorage.flyType;
if(localStorage.flyMk)fmk.value=localStorage.flyMk;
if(localStorage.flyRecencyV!=='2'){delete localStorage.flyRecency;localStorage.flyRecencyV='2';} // one-time reset: old 90d default -> All time
sort.value=localStorage.flySort||'date'; // default: Newest first (freshest deals on top)
recency.value=localStorage.flyRecency||'0'; // default: All time (so full inventory shows, not just the ~47 fresh)
if(localStorage.flyDens)document.getElementById('dens').value=localStorage.flyDens;
dens();render();}).catch(e=>{
grid.innerHTML='<div style="grid-column:1/-1;padding:40px;text-align:center;color:#b91c1c;font-size:14px">Could not load flyers ('+(e&&e.message||e)+'). Please reload.</div>';
stat.textContent='load failed';});
</script></body></html>`;
writeFileSync(join(SITE, 'index.html'), html);
console.log(`Wrote public/flyers/index.html + flyers.json — ${deals.length} flyers (${new Set(deals.map(d=>d.market)).size} markets, types: ${[...new Set(deals.map(d=>d.type))].join('/')})`);