← back to Realestate Flyers

scripts/index-flyers.mjs

213 lines

#!/usr/bin/env node
/**
 * index-flyers.mjs — RE flyer aggregation, increment 1 (TK-10708, re-flyers)
 *
 * Scans the real-estate builds (usre / CRCP / RENTV / HomesOnSpec) and produces a
 * single unified data/flyer-index.json that records:
 *   (a) EXISTING flyer artifacts already produced (rentv-bloom-flyers HTML+PDF), and
 *   (b) FLYER SOURCES — the per-deal / per-listing datasets that CAN be turned into
 *       flyers but have none yet (usre recent_commercial_deals, CRCP listings) — so the
 *       aggregator knows its full addressable surface, not just what's rendered today.
 *
 * $0, read-only, local. Never scrapes, never re-hosts third-party assets (compliance rail:
 * link OUT to broker/listing, never rip content). Idempotent — safe to re-run.
 *
 * Undo: git revert of the commit that adds data/flyer-index.json (nothing else written).
 */
import { readFileSync, existsSync, readdirSync, writeFileSync, statSync, mkdirSync } from 'node:fs';
import { join, dirname, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const PROJECTS = join(process.env.HOME, 'Projects');
const OUT = join(ROOT, 'data', 'flyer-index.json');

// --- Build registry: where each RE build lives -----------------------------
const BUILDS = {
  rentv:        { dir: join(PROJECTS, 'rentv-bloom-flyers'), label: 'RENTV / Steve Bloom media kit' },
  usre:         { dir: join(PROJECTS, 'nationalrealestate'), label: 'USRealEstate national explorer' },
  crcp:         { dir: join(PROJECTS, 'commercialrealestate'), label: 'CRCP LA commercial-RE (Frank)' },
  homesonspec:  { dir: join(PROJECTS, 'homesonspec'), label: 'HomesOnSpec spec homes' },
};

const flyers = [];   // rendered flyer artifacts that exist today
const sources = [];  // datasets that are flyer-able but have no flyer yet

// --- (a0) OUR OWN rendered per-deal flyers (public/flyers/*.html) -----------
function indexOwnFlyers() {
  const dir = join(ROOT, 'public', 'flyers');
  if (!existsSync(dir)) return;
  for (const f of readdirSync(dir).filter((n) => n.endsWith('.html')).sort()) {
    const slug = f.replace(/\.html$/, '');
    let title = slug;
    try {
      const m = readFileSync(join(dir, f), 'utf8').match(/<title>([^<]*)<\/title>/i);
      if (m) title = m[1].trim();
    } catch {}
    flyers.push({
      id: `local:${slug}`,
      build: slug.startsWith('usre') ? 'usre' : slug.startsWith('crcp') ? 'crcp' : 'realestate-flyers',
      kind: 'property-spotlight',
      title,
      format: 'US-Letter-816x1056',
      html_path: join(dir, f).replace(process.env.HOME, '~'),
      pdf_path: null,
      pdf_bytes: null,
      public_url: null, // deploy is gated
      status: 'rendered',
    });
  }
}

// --- (a) EXISTING rendered flyers: rentv-bloom-flyers concepts --------------
function indexBloomFlyers() {
  const b = BUILDS.rentv;
  const conceptsDir = join(b.dir, 'public', 'concepts');
  const pdfDir = join(b.dir, 'public', 'pdf');
  if (!existsSync(conceptsDir)) return;
  for (const f of readdirSync(conceptsDir).filter((n) => n.endsWith('.html')).sort()) {
    const slug = f.replace(/\.html$/, '');
    const htmlPath = join(conceptsDir, f);
    const pdfPath = join(pdfDir, `${slug}.pdf`);
    // title from <title> tag (cheap, no DOM parser)
    let title = slug;
    try {
      const m = readFileSync(htmlPath, 'utf8').match(/<title>([^<]*)<\/title>/i);
      if (m) title = m[1].trim();
    } catch {}
    flyers.push({
      id: `rentv:${slug}`,
      build: 'rentv',
      kind: 'media-kit',
      title,
      format: 'US-Letter-816x1056',
      html_path: htmlPath.replace(process.env.HOME, '~'),
      pdf_path: existsSync(pdfPath) ? pdfPath.replace(process.env.HOME, '~') : null,
      pdf_bytes: existsSync(pdfPath) ? statSync(pdfPath).size : null,
      public_url: `https://rentv.agentabrams.com/826/concepts/${f}`,
      status: 'rendered',
    });
  }
}

// --- (b) FLYER SOURCES: per-deal / per-listing datasets --------------------
function safeJson(p) { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } }

function indexUsreDeals() {
  // usre recent_commercial_deals is a Postgres view; count via psql if reachable.
  let count = null, ok = false;
  try {
    count = parseInt(execSync('psql -h /tmp usre -tAc "SELECT count(*) FROM recent_commercial_deals;"',
      { encoding: 'utf8', timeout: 8000 }).trim(), 10);
    ok = Number.isFinite(count);
  } catch { /* db not reachable — still register the source, count unknown */ }
  sources.push({
    id: 'usre:recent_commercial_deals',
    build: 'usre',
    kind: 'closed-deal',
    label: 'usre recent_commercial_deals (classified commercial parcels × sale events)',
    source_ref: 'psql -h /tmp usre :: view recent_commercial_deals',
    property_index: join(BUILDS.usre.dir, 'public/properties/property-index.json').replace(process.env.HOME, '~'),
    flyer_candidate_count: ok ? count : null,
    flyers_rendered: 0,
    status: ok ? 'flyer-able' : 'flyer-able-db-unreachable',
    note: 'Each closed deal (sale_date/price/type/address/city/county/sqft/year) is a per-deal flyer candidate. Loan-officer angle for Frank: est. loan size + refi window.',
  });
}

function indexCrcpListings() {
  const b = BUILDS.crcp;
  const buildout = safeJson(join(b.dir, 'data', 'buildout-listings.json'));
  const closed = safeJson(join(b.dir, 'data', 'closed-sales.json'));
  if (buildout) {
    sources.push({
      id: 'crcp:buildout-listings',
      build: 'crcp',
      kind: 'active-listing',
      label: 'CRCP buildout active commercial listings',
      source_ref: join(b.dir, 'data/buildout-listings.json').replace(process.env.HOME, '~'),
      flyer_candidate_count: buildout.count ?? (buildout.listings?.length ?? null),
      flyers_rendered: 0,
      status: 'flyer-able',
      note: 'Active CRE listings (address/type/price/broker). Property-spotlight flyer candidates; link OUT to broker + listing page, never re-host their assets.',
    });
  }
  if (closed) {
    sources.push({
      id: 'crcp:closed-sales',
      build: 'crcp',
      kind: 'closed-deal',
      label: 'CRCP closed residential+commercial sales',
      source_ref: join(b.dir, 'data/closed-sales.json').replace(process.env.HOME, '~'),
      flyer_candidate_count: closed.meta?.rows ?? (closed.rows?.length ?? null),
      flyers_rendered: 0,
      status: 'flyer-able',
      note: 'Closed comps — deal-recap flyer candidates. Overlaps usre; dedupe by canon(address)+city at flyer time.',
    });
  }
}

function indexHomesOnSpec() {
  // No structured public flyer-source JSON present locally today; register as a known
  // future surface so the aggregator is honest about coverage.
  sources.push({
    id: 'homesonspec:listings',
    build: 'homesonspec',
    kind: 'spec-home-listing',
    label: 'HomesOnSpec spec-home inventory',
    source_ref: BUILDS.homesonspec.dir.replace(process.env.HOME, '~'),
    flyer_candidate_count: null,
    flyers_rendered: 0,
    status: 'source-not-yet-wired',
    note: 'Residential new-construction. Flyer source lives in the Kamatera prod DB / admin app, not a local JSON — wire in a later increment.',
  });
}

// --- run -------------------------------------------------------------------
indexOwnFlyers();
indexBloomFlyers();
indexUsreDeals();
indexCrcpListings();
indexHomesOnSpec();

// Cody hole #2 fix: report the count HONESTLY. usre closed-deals and CRCP closed-sales
// overlap heavily (both LA-adjacent closed deals), so summing pre-dedupe inflates the
// number. We report gross AND a de-overlapped estimate keyed by kind, and label it.
const grossCandidates = sources.reduce((a, s) => a + (s.flyer_candidate_count || 0), 0);
// crude de-overlap: closed-deal sources overlap → count the LARGER closed-deal source once,
// then add non-closed-deal sources in full. (True dedupe = canon(addr)+city at flyer time.)
const closedDeal = sources.filter((s) => s.kind === 'closed-deal' && s.flyer_candidate_count);
const nonClosed = sources.filter((s) => s.kind !== 'closed-deal');
const dedupEstimate =
  (closedDeal.length ? Math.max(...closedDeal.map((s) => s.flyer_candidate_count)) : 0) +
  nonClosed.reduce((a, s) => a + (s.flyer_candidate_count || 0), 0);

const out = {
  generated_at: new Date().toISOString(),
  ticket: 'TK-10708',
  agent: 're-flyers',
  builds: Object.fromEntries(Object.entries(BUILDS).map(([k, v]) => [k, v.label])),
  summary: {
    rendered_flyers: flyers.length,
    flyer_sources: sources.length,
    flyer_candidates_gross_pre_dedupe: grossCandidates,
    flyer_candidates_dedup_estimate: dedupEstimate,
    candidate_count_note:
      'GROSS sums every source; closed-deal sources (usre + CRCP) overlap heavily, so DEDUP_ESTIMATE counts the largest closed-deal source once + non-closed sources in full. True dedupe = canon(address)+city at flyer time.',
    rendered_coverage_pct_vs_dedup: dedupEstimate
      ? +((flyers.length / (flyers.length + dedupEstimate)) * 100).toFixed(4)
      : null,
  },
  flyers,
  sources,
};

mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(OUT, JSON.stringify(out, null, 2) + '\n');
console.log(`flyer-index.json written -> ${OUT.replace(process.env.HOME, '~')}`);
console.log(`  rendered flyers: ${flyers.length}`);
console.log(`  flyer sources:   ${sources.length}`);
console.log(`  candidates:      ${grossCandidates.toLocaleString()} gross / ${dedupEstimate.toLocaleString()} dedup-est`);