← back to Bubbesblock
lib/home-history.js
398 lines
/**
* home-history.js — BubbesBlock home-history lookup module
*
* Exports: async function getHomeHistory(address) -> NormalizedHomeHistory
*
* Data sources (all free, no auth):
* Tier A — LA County GIS ArcGIS REST parcel layer (public.gis.lacounty.gov)
* Fields: year built, sqft, beds/baths, assessed value, APN
* Tier A — LADBS Socrata building-permit datasets (data.lacity.org)
* Datasets: pi9x-tg5x (2020+), dyxf-7hc4 (2010-19), e67z-kt2n (<2010)
*
* What is NOT available from free sources:
* - Owner names (intentionally redacted from public GIS layer per Gov Code §6254.21)
* - Sale price / last-sold date (Registrar-Recorder — no free online index)
* - Resident history (Census 72-yr embargo; city directories not digitized/API)
*
* Cache: data/home-history.json (object keyed by normalized address)
* Source tier tagging follows stayclaim schema conventions.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const https = require('https');
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
const CACHE_PATH = path.join(__dirname, '..', 'data', 'home-history.json');
// ---------------------------------------------------------------------------
// Free public endpoints (verified 2026-06-19)
// ---------------------------------------------------------------------------
const ARCGIS_BASE =
'https://public.gis.lacounty.gov/public/rest/services/LACounty_Cache/LACounty_Parcel/MapServer/0/query';
const ARCGIS_FIELDS = [
'APN', 'SitusFullAddress', 'SitusHouseNo', 'SitusStreet', 'SitusZIP',
'SitusCity',
'YearBuilt1', 'EffectiveYear1',
'SQFTmain1', 'Bedrooms1', 'Bathrooms1', 'Units1',
'UseDescription', 'DesignType1',
'Roll_Year', 'Roll_LandValue', 'Roll_ImpValue',
'Roll_LandBaseYear', 'Roll_ImpBaseYear',
'Roll_HomeOwnersExemp',
'LegalDescription',
'CENTER_LAT', 'CENTER_LON',
].join(',');
// LADBS Socrata datasets (era-bucketed)
const LADBS_DATASETS = [
{ id: 'pi9x-tg5x', label: '2020+' },
{ id: 'dyxf-7hc4', label: '2010-19' },
{ id: 'e67z-kt2n', label: 'pre-2010' },
];
// ---------------------------------------------------------------------------
// HTTP helper — simple https.get that returns a parsed JSON object
// No retries, 10-second timeout. Never throws to caller (returns null on fail).
// ---------------------------------------------------------------------------
function fetchJson(url) {
return new Promise(resolve => {
const req = https.get(url, { timeout: 10000 }, res => {
let body = '';
res.on('data', chunk => { body += chunk; });
res.on('end', () => {
try { resolve(JSON.parse(body)); }
catch { resolve(null); }
});
});
req.on('timeout', () => { req.destroy(); resolve(null); });
req.on('error', () => resolve(null));
});
}
// ---------------------------------------------------------------------------
// Address normalisation
// ---------------------------------------------------------------------------
const STREET_ABBR = {
STREET: 'ST', AVENUE: 'AVE', BOULEVARD: 'BLVD', DRIVE: 'DR',
ROAD: 'RD', PLACE: 'PL', TERRACE: 'TER', COURT: 'CT',
LANE: 'LN', WAY: 'WAY',
};
function normalizeAddress(input) {
return String(input)
.toUpperCase()
.replace(/[,]/g, ' ')
.replace(/\b(NORTH|SOUTH|EAST|WEST)\b/g, m => m[0])
.replace(/\b(STREET|AVENUE|BOULEVARD|DRIVE|ROAD|PLACE|TERRACE|COURT|LANE|WAY)\b/g,
m => STREET_ABBR[m] || m)
.replace(/\s+/g, ' ')
.trim();
}
/** Extract house number and street tokens from a normalized address string. */
function parseAddressTokens(norm) {
const parts = norm.split(' ');
const numIdx = parts.findIndex(p => /^\d+$/.test(p));
if (numIdx < 0) return { houseNo: null, street: norm };
const houseNo = parts[numIdx];
// Street name = tokens after house number, stopping before city/state/ZIP tokens
const stopTokens = new Set(['CA', 'LOS', 'ANGELES', 'BEVERLY', 'HILLS', 'SANTA', 'MONICA',
'WEST', 'HOLLYWOOD', 'CULVER', 'CITY', 'PASADENA', 'BURBANK', 'GLENDALE',
'TARZANA', 'RESEDA', 'WOODLAND', 'ENCINO', 'SHERMAN', 'OAKS', 'VAN', 'NUYS',
'91356', '91406', '91335', '90028', '90210', '90036']);
const after = parts.slice(numIdx + 1);
const stopIdx = after.findIndex(p => stopTokens.has(p) || /^\d{5}$/.test(p));
const streetParts = stopIdx >= 0 ? after.slice(0, stopIdx) : after;
return { houseNo, street: streetParts.join(' ') };
}
function escapeLike(s) {
return String(s ?? '').replace(/\\/g, '').replace(/'/g, "''").replace(/[%_]/g, '');
}
// ---------------------------------------------------------------------------
// Cache helpers
// ---------------------------------------------------------------------------
function readCache() {
try {
const raw = fs.readFileSync(CACHE_PATH, 'utf8');
return JSON.parse(raw);
} catch {
return {};
}
}
function writeCache(cache) {
try {
fs.mkdirSync(path.dirname(CACHE_PATH), { recursive: true });
fs.writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2), 'utf8');
} catch (e) {
console.error('[home-history] cache write failed:', e.message);
}
}
// ---------------------------------------------------------------------------
// ArcGIS parcel lookup
// ---------------------------------------------------------------------------
async function lookupParcel(input) {
const norm = normalizeAddress(input);
const { houseNo, street } = parseAddressTokens(norm);
if (!houseNo || !street) return null;
const safeNo = /^\d{1,8}$/.test(houseNo) ? houseNo : '';
const safeStreet = escapeLike(street);
if (!safeNo || !safeStreet) return null;
// Try multiple WHERE patterns progressively
const wheres = [
`SitusHouseNo='${safeNo}' AND SitusStreet='${safeStreet}'`,
`SitusFullAddress LIKE '${safeNo} %${safeStreet}%'`,
`SitusFullAddress LIKE '${safeNo} W ${safeStreet}%'`,
`SitusFullAddress LIKE '${safeNo} N ${safeStreet}%'`,
];
for (const where of wheres) {
const url = `${ARCGIS_BASE}?where=${encodeURIComponent(where)}`
+ `&outFields=${encodeURIComponent(ARCGIS_FIELDS)}&f=json&resultRecordCount=3`;
const data = await fetchJson(url);
if (!data) continue;
const feats = data.features ?? [];
if (feats.length > 0) {
return feats[0].attributes;
}
// Small throttle between ArcGIS requests
await new Promise(r => setTimeout(r, 300));
}
return null;
}
// ---------------------------------------------------------------------------
// LADBS permit lookup (by APN, no-dash format)
// ---------------------------------------------------------------------------
async function lookupPermits(apn) {
if (!apn) return [];
const apnClean = String(apn).replace(/-/g, '');
const all = [];
for (const ds of LADBS_DATASETS) {
const url = `https://data.lacity.org/resource/${ds.id}.json`
+ `?$where=${encodeURIComponent(`apn='${apnClean}'`)}&$limit=10&$order=issue_date+DESC`;
const data = await fetchJson(url);
if (Array.isArray(data)) {
all.push(...data.map(p => ({
permitNumber: p.permit_nbr,
type: p.permit_type,
subType: p.permit_sub_type,
issueDate: p.issue_date ? p.issue_date.slice(0, 10) : null,
status: p.status_desc,
valuation: p.valuation ? Number(p.valuation) : null,
description: p.work_desc ? p.work_desc.slice(0, 120) : null,
dataset: ds.label,
})));
}
await new Promise(r => setTimeout(r, 300));
}
// Sort most-recent first
all.sort((a, b) => (b.issueDate ?? '').localeCompare(a.issueDate ?? ''));
return all;
}
// ---------------------------------------------------------------------------
// Build normalised result from parcel attributes + permits
// ---------------------------------------------------------------------------
function buildResult(address, normalized, parcel, permits) {
if (!parcel) {
return {
address,
normalized,
builtYear: null,
lastSold: null,
beds: null,
baths: null,
sqft: null,
residents: [],
permits: [],
summary: 'No parcel record found in LA County public GIS layer for this address. '
+ 'The address may be outside LA County, use a different number format, '
+ 'or may not be indexed in the current roll.',
source: 'unavailable',
sourceUrl: 'https://public.gis.lacounty.gov/public/rest/services/LACounty_Cache/LACounty_Parcel/MapServer/0',
tier: 'A',
retrievedAt: new Date().toISOString(),
};
}
const apn = parcel.APN ?? null;
const builtYear = parcel.YearBuilt1 ? Number(parcel.YearBuilt1) : null;
const sqft = Number(parcel.SQFTmain1) || null;
const beds = Number(parcel.Bedrooms1) || null;
const baths = Number(parcel.Bathrooms1) || null;
const landValue = Number(parcel.Roll_LandValue) || null;
const impValue = Number(parcel.Roll_ImpValue) || null;
const totalAssessed = (landValue && impValue) ? landValue + impValue : null;
const landBaseYear = parcel.Roll_LandBaseYear ?? null;
const impBaseYear = parcel.Roll_ImpBaseYear ?? null;
const useDesc = parcel.UseDescription ?? null;
const situs = parcel.SitusFullAddress ?? normalized;
// Last-sold approximation:
// Prop 13 resets the "base year" when a property transfers. If both land and
// improvement base years are the same recent year, that's the likely last-sale year.
// This is an INFERENCE, not a deed transfer record (no free online deed index in LA County).
let lastSold = null;
if (landBaseYear && landBaseYear === impBaseYear) {
const yr = Number(landBaseYear);
if (yr > 1980) {
lastSold = {
date: `~${yr}`, // approximate — Prop 13 base year, not exact sale date
price: null, // sale price is NOT in the public assessor layer
note: 'Estimated from Prop 13 reassessment base year. '
+ 'Actual sale date/price requires LA County Registrar-Recorder deed research '
+ '(no free online index — in-person or CPRA request required).',
};
}
}
// Residents: NOT available from free sources.
// The public parcel layer intentionally omits owner names (Gov Code §6254.21).
// City directories are not available as a public API.
const residents = [];
// Permit summary for human reading
const recentPermit = permits[0] ?? null;
const permitSummary = permits.length > 0
? `${permits.length} building permit(s) on LADBS record; most recent: `
+ `${recentPermit.type} (${recentPermit.issueDate ?? 'date unknown'}).`
: 'No LADBS building permits found in public records.';
const assessedStr = totalAssessed
? `Total assessed value: $${totalAssessed.toLocaleString()} (${parcel.Roll_Year ?? 'roll year unknown'}).`
: '';
const summary = [
`${situs}: ${useDesc ?? 'use type unknown'}.`,
builtYear ? `Built ${builtYear}.` : '',
sqft ? `${sqft.toLocaleString()} sq ft.` : '',
beds || baths ? `${beds ?? '?'} bed / ${baths ?? '?'} bath.` : '',
lastSold ? `Last transfer approx. ${lastSold.date} (Prop 13 base year reassessment).` : '',
assessedStr,
permitSummary,
apn ? `APN: ${apn}.` : '',
'Owner name, sale price, and resident history are not available from free public sources.',
].filter(Boolean).join(' ');
return {
address,
normalized,
apn,
builtYear,
lastSold,
beds,
baths,
sqft,
assessedValue: totalAssessed,
useDescription: useDesc,
residents, // always empty — see comment above
permits,
summary,
source: 'LA County GIS ArcGIS REST (parcel) + LADBS Socrata (permits)',
sourceUrl: apn
? `https://portal.assessor.lacounty.gov/parceldetail/${apn.replace(/-/g, '')}`
: 'https://portal.assessor.lacounty.gov',
tier: 'A',
retrievedAt: new Date().toISOString(),
_raw: {
situsFullAddress: situs,
landValue,
improvementValue: impValue,
landBaseYear,
improvementBaseYear: impBaseYear,
rollYear: parcel.Roll_Year,
homeownerExemption: !!parcel.Roll_HomeOwnersExemp,
effectiveYear: parcel.EffectiveYear1,
lat: Number(parcel.CENTER_LAT) || null,
lng: Number(parcel.CENTER_LON) || null,
},
};
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* getHomeHistory(address)
*
* @param {string} address Free-form address string
* @returns {Promise<NormalizedHomeHistory>}
*
* NormalizedHomeHistory shape:
* {
* address: string, // original input
* normalized: string, // normalised form used for cache key
* apn: string|null, // LA County APN (10-digit with dashes)
* builtYear: number|null, // year constructed (from assessor roll)
* lastSold: {date, price, note}|null, // price is always null (not public)
* beds: number|null,
* baths: number|null,
* sqft: number|null,
* assessedValue: number|null, // land + improvement (Prop 13 value, NOT market)
* useDescription:string|null, // e.g. "Single", "Store Combination"
* residents: [], // always empty — owner names not public
* permits: Array<PermitRecord>,
* summary: string,
* source: string, // 'unavailable' if no data found
* sourceUrl: string,
* tier: 'A'|'B'|'C'|'D',
* retrievedAt: string, // ISO-8601
* _raw: object, // raw assessor fields (internal use)
* }
*
* Wire into Express:
* const { getHomeHistory } = require('./lib/home-history');
* app.get('/api/home-history', async (req, res) => {
* const result = await getHomeHistory(req.query.address ?? '');
* res.json(result);
* });
*/
async function getHomeHistory(address) {
if (!address || typeof address !== 'string' || !address.trim()) {
return buildResult('', '', null, []);
}
const normalized = normalizeAddress(address.trim());
// --- Cache check ---
const cache = readCache();
if (cache[normalized]) {
return cache[normalized];
}
// --- Live lookup ---
try {
const parcel = await lookupParcel(address);
const permits = parcel ? await lookupPermits(parcel.APN) : [];
const result = buildResult(address.trim(), normalized, parcel, permits);
// Write back to cache
cache[normalized] = result;
writeCache(cache);
return result;
} catch (err) {
console.error('[home-history] lookup error:', err.message);
// Return safe unavailable stub — never throw to caller
return buildResult(address.trim(), normalized, null, []);
}
}
module.exports = { getHomeHistory };