← back to Beverlyhillsbutler
server.js
137 lines
'use strict';
const express = require('express');
const path = require('path');
const fs = require('fs');
const app = express();
const PORT = process.env.PORT || 9906;
// ── Property data (loaded once at startup) ───────────────────────────────────
// data/bh-properties.json is built by scripts/fetch-bh-properties.js
let properties = []; // full array for search
let byAIN = new Map(); // AIN -> property record for O(1) lookup
function loadProperties() {
const f = path.join(__dirname, 'data', 'bh-properties.json');
if (!fs.existsSync(f)) {
console.log('[server] data/bh-properties.json not found — run: node scripts/fetch-bh-properties.js');
return;
}
try {
properties = JSON.parse(fs.readFileSync(f, 'utf8'));
byAIN = new Map(properties.map(p => [p.ain, p]));
console.log(`[server] Loaded ${properties.length.toLocaleString()} BH properties`);
} catch (e) {
console.error('[server] Failed to load properties:', e.message);
}
}
loadProperties();
// ── Middleware ────────────────────────────────────────────────────────────────
app.use(express.json());
// No-cache for HTML so Cloudflare doesn't stale it
app.use((req, res, next) => {
if (req.path.match(/\.(html|htm)$/) || req.path === '/') {
res.setHeader('Cache-Control', 'no-store, must-revalidate');
}
next();
});
// 404-guard: never serve snapshot/backup files
app.use((req, res, next) => {
if (/\.(bak)(\..*)?$|\.pre-/i.test(req.path)) {
return res.status(404).send('Not found');
}
next();
});
// ── API: Property search ──────────────────────────────────────────────────────
// GET /api/properties?q=<address>&zip=<zip>&use=<use_type>&limit=<N>
app.get('/api/properties', (req, res) => {
let results = properties;
const q = (req.query.q || '').trim().toUpperCase();
const zipFilter = (req.query.zip || '').trim();
const useFilter = (req.query.use || '').trim().toUpperCase();
const limit = Math.min(parseInt(req.query.limit || '50', 10), 500);
if (q) {
results = results.filter(p =>
(p.address && p.address.toUpperCase().includes(q)) ||
(p.property_location && p.property_location.toUpperCase().includes(q)) ||
p.ain.includes(q) ||
p.apn.includes(q)
);
}
if (zipFilter) results = results.filter(p => p.zip === zipFilter);
if (useFilter) results = results.filter(p => (p.use_desc || '').toUpperCase().includes(useFilter));
res.json({
total: results.length,
returned: Math.min(results.length, limit),
properties: results.slice(0, limit).map(p => ({
ain: p.ain, apn: p.apn, address: p.address, city: p.city, zip: p.zip,
use_desc: p.use_desc, year_built: p.year_built, sqft: p.sqft,
total_value: p.total_value, total_value_fmt: p.total_value_fmt
}))
});
});
// ── API: Single property dossier ──────────────────────────────────────────────
// GET /api/property/:apn (accepts both "4331010039" and "4331-010-039")
app.get('/api/property/:apn', (req, res) => {
const raw = req.params.apn.replace(/-/g, '');
const prop = byAIN.get(raw);
if (!prop) return res.status(404).json({ error: 'Property not found', apn: req.params.apn });
res.json(prop);
});
// ── API: Stats ────────────────────────────────────────────────────────────────
// GET /api/stats
app.get('/api/stats', (req, res) => {
const valued = properties.filter(p => p.total_value);
const avgVal = valued.length
? Math.round(valued.reduce((s, p) => s + p.total_value, 0) / valued.length)
: 0;
const maxVal = valued.length ? Math.max(...valued.map(p => p.total_value)) : 0;
const minVal = valued.length ? Math.min(...valued.map(p => p.total_value)) : 0;
const byZip = {};
const byUse = {};
for (const p of properties) {
byZip[p.zip] = (byZip[p.zip] || 0) + 1;
byUse[p.use_desc || 'Unknown'] = (byUse[p.use_desc || 'Unknown'] || 0) + 1;
}
res.json({
total_properties: properties.length,
data_source: 'LA County Assessor Roll 2025 (public open data)',
coverage: 'Beverly Hills 90210 / 90211 / 90212',
avg_assessed_value: avgVal,
max_assessed_value: maxVal,
min_assessed_value: minVal,
by_zip: byZip,
by_use_type: byUse,
cost: '$0 (free government data)',
last_loaded: new Date().toISOString()
});
});
// ── Health ────────────────────────────────────────────────────────────────────
app.get('/health', (req, res) => res.json({
status: 'ok',
site: 'beverlyhillsbutler.com',
properties_loaded: properties.length
}));
// ── Static + SPA fallback ─────────────────────────────────────────────────────
app.use(express.static(path.join(__dirname, 'public')));
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.listen(PORT, () => {
console.log(`beverlyhillsbutler.com running on :${PORT}`);
});