← back to Re Flyer Aggregator

scripts/build-digest.mjs

47 lines

#!/usr/bin/env node
// TK-10708  Build the morning "closed deals" email body from today's alert feeds.
// Prints HTML to stdout (empty output = no new deals today -> caller skips the send).
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const date = process.argv[2] || new Date().toISOString().slice(0, 10);
const read = f => { const p = join(ROOT, 'out', f); return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : []; };

const news = read(`crenews-new-deals-${date}.json`);
const edgar = read(`edgar-new-deals-${date}.json`);
if (!news.length && !edgar.length) { process.stdout.write(''); process.exit(0); }

const esc = s => String(s || '').replace(/[<>&]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
const TYPE_COLOR = { Sale: '#0E7C4A', Loan: '#8A5A00', Lease: '#16357A', Development: '#6d28d9' };
const chip = t => `<span style="display:inline-block;background:${TYPE_COLOR[t] || '#6B7280'};color:#fff;font-size:10px;font-weight:700;padding:2px 7px;border-radius:4px;letter-spacing:.4px">${esc((t || 'CRE').toUpperCase())}</span>`;
const HEAD = `<tr style="text-align:left;color:#9aa;font-size:11px;text-transform:uppercase;letter-spacing:.4px">
  <th style="padding:4px 10px">Type</th><th style="padding:4px 10px">Price</th><th style="padding:4px 10px">Where</th>
  <th style="padding:4px 10px">Deal</th><th style="padding:4px 10px">Source</th></tr>`;
const row = (type, price, where, title, src, link) =>
  `<tr><td style="padding:6px 10px;white-space:nowrap;border-top:1px solid #eef0f6">${chip(type)}</td>
   <td style="padding:6px 10px;font-weight:700;color:#0E7C4A;white-space:nowrap;border-top:1px solid #eef0f6">${esc(price)}</td>
   <td style="padding:6px 10px;color:#6B7280;white-space:nowrap;border-top:1px solid #eef0f6">${esc(where)}</td>
   <td style="padding:6px 10px;border-top:1px solid #eef0f6"><a href="${esc(link)}" style="color:#16357A;text-decoration:none">${esc(title)}</a></td>
   <td style="padding:6px 10px;white-space:nowrap;border-top:1px solid #eef0f6"><a href="${esc(link)}" style="color:#8A5A00;font-weight:600;font-size:12px;text-decoration:none">${esc(src)} ↗</a></td></tr>`;

const NEWS_CAP = 40;
const newsRows = news.slice(0, NEWS_CAP).map(d => row(d.type, d.label || '$?', d.location || d.feed_region, d.title, d.feed, d.link)).join('')
  + (news.length > NEWS_CAP ? `<tr><td colspan="5" style="padding:8px 10px;color:#9aa;font-size:12px;border-top:1px solid #eef0f6">+ ${news.length - NEWS_CAP} more in out/crenews-new-deals-*.json</td></tr>` : '');
const edgarRows = edgar.map(d => row('Sale', d.price_label || '$? (see filing)', d.location || d.region,
  `${(d.filer || '').split('(')[0].trim()} — ${d.property_type || 'acquisition'}${d.kind === 'closed_deal' ? ' (Item 2.01 closed)' : ''}`,
  'SEC EDGAR 8-K', d.filing_url)).join('');

const html = `<div style="font-family:-apple-system,Helvetica,Arial,sans-serif;color:#222;max-width:720px">
<!--DEALS:${news.length + edgar.length}-->
<h2 style="margin:0 0 2px;color:#0E2350">🏙️ CRE deals — ${date} (sales · loans · leases)</h2>
<p style="color:#6B7280;font-size:13px;margin:0 0 14px">${news.length} from CRE trade press · ${edgar.length} from SEC filings · national · free · new since last run</p>
${news.length ? `<h3 style="color:#16357A;margin:14px 0 4px">Trade press — all deal types</h3>
<table style="border-collapse:collapse;width:100%;font-size:14px">${HEAD}${newsRows}</table>` : ''}
${edgar.length ? `<h3 style="color:#16357A;margin:18px 0 4px">SEC 8-K filings (public REITs)</h3>
<table style="border-collapse:collapse;width:100%;font-size:14px">${HEAD}${edgarRows}</table>` : ''}
<p style="color:#9aa;font-size:11px;margin-top:16px">re-flyer-aggregator · TK-10708 · deduped daily · $0 sources. Prices from headlines/filings — verify before publishing.</p>
</div>`;
process.stdout.write(html);