← 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(/&nbsp;/g, ' ').replace(/&amp;/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.');
}