← 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`);