← back to Commercialrealestate
scripts/afternoon-report.js
134 lines
// afternoon-report.js — the 1pm "what's new since this morning" report.
// Reads ranked.json (re-ranked after the 1pm Crexi re-scrape) plus the pre-scrape id snapshot
// (data/.pre-afternoon-ids.json, written by run-afternoon-report.sh BEFORE refresh-listings.js)
// and surfaces TWO things Steve asked for:
// 1. ADDITIONAL LISTINGS — listings whose id wasn't present before the 1pm scrape (genuinely new to the ranker)
// 2. RED-HOT OPPORTUNITIES — active + in-budget deals with standout economics (high composite / strong CoC / AI "Buy")
// Builds HTML -> data/afternoon-report.html and emails it to steve-office + steve-personal via George's
// local /api/send (both recipients are George-internal, so the external-send guard passes; no claude -p,
// no paid API — the only cost in the whole 1pm job is the Browserbase scrape upstream).
const fs = require('fs');
const path = require('path');
const http = require('http');
const ROOT = path.join(__dirname, '..');
const CREFin = require('../public/finance.js');
const EX = require('./report-extras.js'); // chart + table-of-info + suggested data points (2026-06-26)
const { meta, assumptions, ranked } = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'ranked.json'), 'utf8'));
const A = assumptions || { budget: 400000, downPct: 25, ratePct: 6.75, amortYears: 30, closingPct: 2.5 };
// pre-scrape snapshot of listing ids (written by the shell before refresh). Missing/empty => treat as "no baseline"
let preIds = new Set();
let hadBaseline = false;
try {
const snap = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', '.pre-afternoon-ids.json'), 'utf8'));
if (Array.isArray(snap.ids)) { preIds = new Set(snap.ids); hadBaseline = snap.ids.length > 0; }
} catch (_) {}
// recompute finance at the canonical 25%-down scenario (same as morning-report.js)
const rows = ranked.map(p => {
const f = CREFin.finance(p, A);
const fs2 = CREFin.financeScore(p, f);
return { ...p, finance: f, composite: CREFin.composite(p, f, fs2.score, p.qwenScore) };
});
const isActive = p => !/Under Contract|Pending|OUT OF BUDGET/i.test(p.status || '');
// 1) ADDITIONAL LISTINGS: in the current ranker but not in the pre-scrape snapshot
const newListings = hadBaseline ? rows.filter(p => !preIds.has(p.id)) : [];
newListings.sort((a, b) => b.composite - a.composite);
// 2) RED-HOT: active + affordable + standout economics. Exclude nothing by recency — a hot deal is hot
// whether it's new or has been sitting. Bar = high blended score OR strong cash-on-cash OR AI says Buy.
const isHot = p => isActive(p) && p.finance.affordable &&
(p.composite >= 68 || (p.finance.coc != null && p.finance.coc >= 4) || (p.qwen && p.qwen.recommendation === 'Buy'));
let redHot = rows.filter(isHot).sort((a, b) => b.composite - a.composite).slice(0, 6);
// Fallback: if nothing clears the bar, show the 3 best active in-budget deals so the section is never empty.
let hotFallback = false;
if (!redHot.length) {
hotFallback = true;
redHot = rows.filter(p => isActive(p) && p.finance.affordable).sort((a, b) => b.composite - a.composite).slice(0, 3);
}
const today = process.env.REPORT_DATE || new Date().toLocaleDateString('en-CA', { timeZone: 'America/Los_Angeles' });
const fmt = n => n == null ? '—' : '$' + Math.round(n).toLocaleString();
const card = (p, badge) => {
const f = p.finance;
const ver = p.verified === true ? '✓ verified in-place' : (p.cap_rate != null ? 'broker cap' : 'no income data');
const cap = p.cap_rate != null ? p.cap_rate + '%' : '—';
const posLev = f.coc != null && f.coc > 0;
return `<div style="border-top:1px solid #e2e2e2;padding:10px 0">
<div style="font-size:14px">${badge || ''}<b><a href="${p.source}">${p.address}</a>, ${p.city}</b> <span style="color:#888;font-weight:400">score ${p.composite}</span></div>
<div style="color:#666;font-size:12px">${p.type} · ${p.units} unit${p.units > 1 ? 's' : ''}${p.year_built ? ` · built ${p.year_built}` : ''} · ${fmt(p.price)} · cap ${cap} (${ver})</div>
<div style="font-size:12px;color:#333;margin:3px 0">Cash to close <b>${fmt(f.cashNeeded)}</b> (${f.affordable ? 'in budget' : 'OVER budget'}) · CoC <span style="color:${posLev ? '#178a3a' : '#b00'}">${f.coc != null ? f.coc + '%' : 'n/a'}</span> · DSCR ${f.dscr ?? 'n/a'} · est NOI ${f.noi ? fmt(f.noi) : 'n/d'}</div>
${p.qwen && p.qwen.thesis ? `<div style="font-size:13px;color:#222;margin:4px 0">${p.qwen.thesis}</div>` : ''}
${p.qwen && p.qwen.risks && p.qwen.risks.length ? `<div style="font-size:12px;color:#a15c00;margin:4px 0"><b>Risks:</b> ${p.qwen.risks.slice(0, 3).join('; ')}</div>` : ''}
<div style="font-size:12px;color:#555">${p.upside_note || ''}</div>
</div>`;
};
const NEW_BADGE = '<span style="background:#0a7d2c;color:#fff;font-size:10px;font-weight:700;padding:2px 6px;border-radius:3px;margin-right:6px;vertical-align:middle">NEW</span>';
const HOT_BADGE = '<span style="background:#c81e1e;color:#fff;font-size:10px;font-weight:700;padding:2px 6px;border-radius:3px;margin-right:6px;vertical-align:middle">🔥 HOT</span>';
const newSection = !hadBaseline
? `<p style="font-size:13px;color:#a15c00;margin:8px 0">No pre-scrape baseline was available this run, so "new since morning" couldn't be computed. (Resolves automatically on the next 1pm run.)</p>`
: newListings.length
? newListings.map(p => card(p, NEW_BADGE)).join('\n')
: `<p style="font-size:13px;color:#444;margin:8px 0">No new SFV listings hit Crexi since this morning's run. The ranker still tracks ${rows.length} listings; red-hot standouts below.</p>`;
const hotSection = redHot.length
? (hotFallback ? `<p style="font-size:12px;color:#a15c00;margin:6px 0">Nothing cleared the red-hot bar today — showing the best active, in-budget deals instead.</p>` : '')
+ redHot.map(p => card(p, HOT_BADGE)).join('\n')
: `<p style="font-size:13px;color:#444;margin:8px 0">No active in-budget deals to flag right now.</p>`;
const html = `<div style="font-family:-apple-system,Segoe UI,Arial,sans-serif;max-width:780px">
<h2 style="margin:0 0 2px">SFV CRE — 1pm Update: New Listings & Red-Hot Deals</h2>
<div style="color:#666;font-size:13px">${today} · ${meta.market} · $${A.budget.toLocaleString()} down (${A.downPct}% / ${A.ratePct}% / ${A.amortYears}yr) · ${rows.length} listings tracked</div>
<p style="font-size:13px;color:#444;margin:12px 0">Afternoon re-scrape of Crexi vs this morning. Cap rates are broker/MLS-stated — verify NOI in DD.
Most SFV multifamily runs negative/breakeven leverage at today's rates; positive-leverage (green CoC) or seller-financed deals are the standouts.</p>
<h3 style="margin:16px 0 4px;font-size:15px">🆕 Additional listings since this morning${hadBaseline ? ` (${newListings.length})` : ''}</h3>
${newSection}
<h3 style="margin:20px 0 4px;font-size:15px">🔥 Red-hot opportunities (${redHot.length})</h3>
${hotSection}
${EX.barChart(rows, { n: 8, title: 'Top SFV deals by blended score' })}
${EX.dataTable(rows, { title: 'Table of info — all tracked SFV listings' })}
${EX.moreDataPoints()}
<p style="color:#888;font-size:12px;margin-top:16px">Live viewer: http://127.0.0.1:9911 · 6am report covers the full Top-10 · generated by commercialrealestate/scripts/afternoon-report.js</p>
</div>`;
fs.writeFileSync(path.join(ROOT, 'data', 'afternoon-report.html'), html);
if (process.env.DRY_RUN) {
console.log(`DRY_RUN: built afternoon-report.html — baseline=${hadBaseline} new=${hadBaseline ? newListings.length : 'n/a'} redHot=${redHot.length}${hotFallback ? ' (fallback)' : ''}; not sending.`);
process.exit(0);
}
// ---- send via George local API (both recipients are internal; guard passes) ----
const payload = JSON.stringify({
account: 'steve-office',
to: 'steve@designerwallcoverings.com, steveabramsdesigns@gmail.com',
subject: `SFV CRE 1pm — ${hadBaseline ? newListings.length : '?'} new · ${redHot.length} red-hot (${today})`,
body: html,
});
const req = http.request({
host: '127.0.0.1', port: 9850, path: '/api/send', method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
'Authorization': 'Basic ' + Buffer.from('admin:').toString('base64'),
},
}, res => {
let d = ''; res.on('data', c => d += c);
res.on('end', () => {
console.log('George /api/send ->', res.statusCode, d);
process.exit(res.statusCode === 200 ? 0 : 1);
});
});
req.on('error', e => { console.error('send error:', e.message); process.exit(1); });
req.write(payload); req.end();