← back to Re Flyer Aggregator

scripts/build-property-index.mjs

74 lines

#!/usr/bin/env node
// TK-10708  PER-PROPERTY INDEX (Steve: "store info so we can use for data on every property"). Extracts a
// street address + city/state/type/price/parties from every deal-store + archive record that has one, and
// AGGREGATES all mentions of the same property into one record (its full transaction history across outlets).
// This is the "data on every property" layer. $0 local. Grows as the archive grows.
//
// Usage: node scripts/build-property-index.mjs

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 load = f => existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : [];
const store = load(join(ROOT, 'data', 'deal-store.json'));
const arch = load(join(ROOT, 'data', 'article-archive.json'));

const ADDR = /\b(\d{2,5}(?:-\d{2,5})?\s+(?:[NSEW]\.?\s+)?[A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+){0,3}\s+(?:Ave|Avenue|St|Street|Blvd|Boulevard|Dr|Drive|Rd|Road|Way|Ln|Lane|Ct|Court|Pl|Place|Pkwy|Parkway|Hwy|Highway|Cir|Circle|Ter|Terrace|Sq|Square))\b/;
const PTYPE = /\b(office|industrial|warehouse|logistics|retail|multifamily|apartment|residential|hotel|resort|hospitality|medical|life science|lab|self.?storage|storage|mixed.?use|data center|land|shopping center|mall|flex)\b/i;
const SQFT = /([\d,]{2,})\s*(?:sq\.?\s?ft|sf|square[- ]?f(?:ee|oo)t)\b/i;
const UNITS = /([\d,]{1,6})[- ]unit\b/i;
const sizeOf = r => {
  const t = r.title || '';
  const units = r.units || ((UNITS.exec(t) || [])[1] || '').replace(/,/g, '') || null;
  const sqft = r.sqft || ((SQFT.exec(t) || [])[1] || '').replace(/,/g, '') || null;
  return { units: units ? +units : null, sqft: sqft ? +sqft : null, size_label: r.size_label || null, occupancy_pct: r.occupancy_pct || null };
};
const canon = a => a.toLowerCase().replace(/\b(ave|avenue|st|street|blvd|boulevard|dr|drive|rd|road|way|ln|lane|ct|court|pl|place|pkwy|parkway|hwy|highway|cir|circle|ter|terrace|sq|square)\b/g, m => m[0]).replace(/[^a-z0-9]+/g, ' ').trim();
const num = x => typeof x.price === 'number' ? x.price : null;
const cityState = m => { const p = (m || '').split(',').map(s => s.trim()); return { city: p[0] || null, state: p[1] || null }; };

const props = new Map();
let scanned = 0, matched = 0;
for (const r of [...store, ...arch]) {
  scanned++;
  const addr = r.address || (ADDR.exec(r.title || '') || [])[1];
  if (!addr) continue;
  matched++;
  const { city, state } = cityState(r.market);
  const key = canon(addr) + '|' + (city || '').toLowerCase();
  if (!props.has(key)) props.set(key, { address: addr.trim(), city, state, property_type: null, entries: [], sources: new Set() });
  const p = props.get(key);
  if (!p.property_type) p.property_type = r.property_type || (PTYPE.exec(r.title || '') || [])[1] || null;
  if (city && !p.city) p.city = city;
  if (state && !p.state) p.state = state;
  const sz = sizeOf(r);
  if (!p.units && sz.units) p.units = sz.units;
  if (!p.sqft && sz.sqft) p.sqft = sz.sqft;
  if (!p.size_label && sz.size_label) p.size_label = sz.size_label;
  if (!p.occupancy_pct && sz.occupancy_pct) p.occupancy_pct = sz.occupancy_pct;
  p.sources.add(r.source);
  p.entries.push({ date: r.date, txn: r.type, price: num(r), price_label: r.price_label || r.label || (typeof r.price === 'string' ? r.price : null), source: r.source, link: r.link, title: r.title, buyer: r.buyer || null, seller: r.seller || null });
}

const out = [...props.values()].map(p => {
  const e = p.entries.slice().sort((a, b) => (Date.parse(b.date) || 0) - (Date.parse(a.date) || 0));
  // dedup entries by title
  const seen = new Set(), ent = [];
  for (const x of e) { const k = (x.title || '').toLowerCase().slice(0, 50); if (!seen.has(k)) { seen.add(k); ent.push(x); } }
  const priced = ent.filter(x => x.price);
  return {
    address: p.address, city: p.city, state: p.state, property_type: p.property_type,
    units: p.units || null, sqft: p.sqft || null, size_label: p.size_label || null, occupancy_pct: p.occupancy_pct || null,
    latest: ent[0]?.date, latest_txn: ent[0]?.txn, latest_price: ent[0]?.price_label,
    top_price: priced.sort((a, b) => (b.price || 0) - (a.price || 0))[0]?.price_label || null,
    mentions: ent.length, source_count: p.sources.size, sources: [...p.sources], entries: ent
  };
}).sort((a, b) => b.mentions - a.mentions || (Date.parse(b.latest) || 0) - (Date.parse(a.latest) || 0));

writeFileSync(join(ROOT, 'public', 'properties', 'property-index.json'), JSON.stringify(out, null, 0));
const multi = out.filter(p => p.mentions > 1).length;
console.log(`Property index: scanned ${scanned} records, ${matched} had an address -> ${out.length} unique properties (${multi} with a history of 2+ mentions). -> public/properties/property-index.json`);
for (const p of out.slice(0, 8)) console.log(`  ${(p.top_price || p.latest_price || '—').padStart(7)}  ${(p.property_type || '?').padEnd(12)} ${(p.city || '').padEnd(16)} ${p.address}  [${p.mentions}× ${p.source_count} src]`);