← back to Re Flyer Aggregator
scripts/edgar-cre-monitor.mjs
126 lines
#!/usr/bin/env node
// TK-10708 FREE "be first to a closed deal" monitor + enrichment.
// SEC EDGAR full-text search for recent 8-K real-estate ACQUISITION filings across the
// RENTV West footprint, then fetches each NEW filing and extracts property + price so the
// alert reads "Kilroy acquired [property/city] for $[X]". $0, no key, no owner names.
//
// Covers public-company / REIT deals (the big newsworthy ones). Dedupes by accession.
// Usage: node scripts/edgar-cre-monitor.mjs [--days 14] [--max 30] [--dry]
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 args = process.argv.slice(2);
const getArg = (f, d) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : d; };
const DAYS = parseInt(getArg('--days', '14'), 10);
const MAX_ENRICH = parseInt(getArg('--max', '30'), 10);
const UA = 'DW-research steve@designerwallcoverings.com'; // SEC requires a UA
const SEEN_FILE = join(ROOT, 'data', 'edgar-seen.json');
// RENTV West footprint (state names match "City, State" in filing text).
const REGIONS = ['California', 'Arizona', 'Nevada', 'Washington', 'Oregon', 'Colorado', 'Texas'];
const sleep = ms => new Promise(r => setTimeout(r, ms));
const today = new Date();
const start = new Date(today.getTime() - DAYS * 864e5).toISOString().slice(0, 10);
const end = today.toISOString().slice(0, 10);
async function ftsFor(region) {
const q = `"square feet" "${region}" "acquired"`;
const url = `https://efts.sec.gov/LATEST/search-index?q=${encodeURIComponent(q)}&forms=8-K&startdt=${start}&enddt=${end}`;
const r = await fetch(url, { headers: { 'User-Agent': UA } });
if (!r.ok) return [];
const d = await r.json();
return (d.hits?.hits || []).map(h => {
const s = h._source || {};
const [accn] = (h._id || '').split(':');
const cik = (s.ciks && s.ciks[0]) ? String(s.ciks[0]).replace(/^0+/, '') : null;
return { filer: (s.display_names || ['?'])[0], file_date: s.file_date, accession: accn, cik, region };
});
}
// --- deal-detail extraction from a filing's text ---
const stripTags = html => html.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/&#\d+;/g, ' ').replace(/\s+/g, ' ').trim();
function extractDeal(text) {
const out = { price: null, price_label: null, price_confidence: null, sqft: null, property_type: null, location: null };
const sf = text.match(/([\d,]{4,})\s*(?:rentable\s*)?(?:square feet|sq\.?\s*ft)/i);
if (sf) out.sqft = parseInt(sf[1].replace(/,/g, ''), 10);
// price: ONLY amounts anchored to deal-price context (avoid balance-sheet/portfolio numbers).
const priceRe = /\$\s?([\d,]+(?:\.\d+)?)\s*(billion|million)\b/gi;
const DEAL = /(purchase price|acquisition price|aggregate (?:consideration|purchase)|consideration of|acquired .{0,30}for|for approximately|sold .{0,20}for|sale price|total consideration|for a (?:purchase|total))/i;
const cands = []; let m;
while ((m = priceRe.exec(text))) {
const val = parseFloat(m[1].replace(/,/g, '')) * (/billion/i.test(m[2]) ? 1e9 : 1e6);
const ctx = text.slice(Math.max(0, m.index - 70), m.index + 15);
if (DEAL.test(ctx)) cands.push({ val, label: `$${m[1]} ${m[2].toLowerCase()}` });
}
// $/SF sanity gate (plausible CRE range) when sqft is known
const ok = c => !out.sqft || (c.val / out.sqft >= 50 && c.val / out.sqft <= 5000);
const plausible = cands.filter(ok);
const chosen = plausible[0] || null; // first deal-anchored, sanity-passing figure
if (chosen) { out.price = Math.round(chosen.val); out.price_label = chosen.label; out.price_confidence = out.sqft ? 'anchored+ppsf-ok' : 'anchored'; }
else if (cands.length) { out.price_confidence = 'anchored-but-implausible-ppsf'; } // leave price null, flag
const pt = text.match(/\b(office|industrial|logistics|warehouse|retail|multifamily|apartment|life science|data center|self[- ]storage|hotel|mixed[- ]use|medical office)\b/i);
if (pt) out.property_type = pt[1].toLowerCase();
const loc = text.match(/located (?:in|at|within)\s+([^.,;]{3,60}?,\s*(?:California|Arizona|Nevada|Washington|Oregon|Colorado|Texas|CA|AZ|NV|WA|OR|CO|TX))\b/i)
|| text.match(/\bin\s+([A-Z][a-zA-Z ]{2,30},\s*(?:California|Arizona|Nevada|Washington|Oregon|Colorado|Texas))\b/);
if (loc) out.location = loc[1].replace(/\s+/g, ' ').trim();
return out;
}
async function enrich(d) {
try {
const accnClean = d.accession.replace(/-/g, '');
const idxUrl = `https://www.sec.gov/Archives/edgar/data/${d.cik}/${accnClean}/index.json`;
const idx = await (await fetch(idxUrl, { headers: { 'User-Agent': UA } })).json();
const items = idx.directory?.item || [];
// prefer a press-release exhibit (EX-99), else the primary 8-K htm
const pick = items.find(i => /ex-?99.*\.htm/i.test(i.name)) || items.find(i => /\.htm$/i.test(i.name) && !/index/i.test(i.name));
if (!pick) return { ...d, filing_url: `https://www.sec.gov/Archives/edgar/data/${d.cik}/${accnClean}/${d.accession}-index.htm` };
const docUrl = `https://www.sec.gov/Archives/edgar/data/${d.cik}/${accnClean}/${pick.name}`;
const text = stripTags(await (await fetch(docUrl, { headers: { 'User-Agent': UA } })).text()).slice(0, 40000);
// Classify: quarterly EARNINGS releases mention "acquired"/"square feet" in passing but
// are NOT single-deal announcements. Drop them so the feed is real closed deals only.
const earningsHits = (text.match(/funds from operations|\bFFO\b|per diluted share|quarter ended|nine months ended|net income available/gi) || []).length;
// Item 2.01 = "Completion of Acquisition or Disposition of Assets" = a CLOSED deal (vs 2.02 earnings).
const item201 = /Item\s*2\.01|Completion of (?:the )?Acquisition or Disposition/i.test(text);
const kind = item201 ? 'closed_deal' : (earningsHits >= 2 ? 'earnings' : 'deal');
return { ...d, filing_url: docUrl, kind, item201, ...extractDeal(text) };
} catch { return { ...d, filing_url: null }; }
}
// 1) sweep regions, merge+dedupe by accession
const all = [];
for (const region of REGIONS) { all.push(...await ftsFor(region)); await sleep(150); }
const byAccn = new Map();
for (const d of all) if (!byAccn.has(d.accession)) byAccn.set(d.accession, d);
const deals = [...byAccn.values()];
const seen = existsSync(SEEN_FILE) ? new Set(JSON.parse(readFileSync(SEEN_FILE, 'utf8'))) : new Set();
const fresh = deals.filter(d => !seen.has(d.accession));
console.log(`EDGAR West-footprint RE 8-Ks (last ${DAYS}d): ${deals.length} total, ${fresh.length} NEW. Enriching up to ${MAX_ENRICH}...`);
// 2) enrich new filings (rate-limited, SEC-polite)
const enriched = [];
for (const d of fresh.slice(0, MAX_ENRICH)) { enriched.push(await enrich(d)); await sleep(150); }
const realDeals = enriched.filter(d => d.kind === 'closed_deal' || d.kind === 'deal')
.sort((a, b) => (b.kind === 'closed_deal') - (a.kind === 'closed_deal'));
const closed = realDeals.filter(d => d.kind === 'closed_deal').length;
const earnings = enriched.filter(d => d.kind === 'earnings');
console.log(`-> ${closed} CLOSED-deal (Item 2.01), ${realDeals.length - closed} other acquisition 8-Ks, ${earnings.length} earnings dropped.`);
for (const d of realDeals) {
const price = d.price_label || '$? (see filing)';
const where = d.location || d.region;
const pt = d.property_type ? ` ${d.property_type}` : '';
console.log(` ${d.file_date} ${d.filer.split('(')[0].trim().slice(0,32)} acquired${pt} in ${where} for ${price}${d.sqft ? ` (${d.sqft.toLocaleString()} SF)` : ''}`);
}
if (!args.includes('--dry')) {
deals.forEach(d => seen.add(d.accession));
writeFileSync(SEEN_FILE, JSON.stringify([...seen], null, 0));
if (realDeals.length) writeFileSync(join(ROOT, 'out', `edgar-new-deals-${end}.json`), JSON.stringify(realDeals, null, 2));
console.log(realDeals.length ? `-> out/edgar-new-deals-${end}.json (${realDeals.length} deals) for the alert feed.` : 'no new single-deal announcements.');
}