← back to Stayclaim
scripts/prewarm-la-parcel.mjs
133 lines
#!/usr/bin/env node
/**
* Bulk pre-warm la_parcel cache for every public listing in the DB.
*
* For each listing: build query string from address_line1 + city, hit ArcGIS,
* upsert la_parcel. Throttled (300ms gap) to be polite to the public layer.
*
* Run from pastdoor cwd (so .env.production loads):
* cd /root/public-projects/pastdoor && node scripts/prewarm-la-parcel.mjs
*
* Idempotent — skips listings that already have a fresh la_parcel row (TTL 30d
* via the same logic in lib/la-arcgis.ts).
*/
import pg from 'pg';
const ARCGIS_BASE = 'https://public.gis.lacounty.gov/public/rest/services/LACounty_Cache/LACounty_Parcel/MapServer/0/query';
const FIELDS = [
'APN', 'SitusFullAddress', 'SitusHouseNo', 'SitusDirection', 'SitusStreet',
'SitusUnit', 'SitusCity', 'SitusZIP',
'UseCode', 'UseType', 'UseDescription',
'YearBuilt1', 'EffectiveYear1',
'Units1', 'Bedrooms1', 'Bathrooms1', 'SQFTmain1',
'Roll_Year', 'Roll_LandValue', 'Roll_ImpValue', 'Roll_HomeOwnersExemp',
'CENTER_LAT', 'CENTER_LON',
].join(',');
const UA = 'pastdoor/1.0-prewarm (https://wholivedthere.com)';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 3 });
function normalize(addr) {
return String(addr).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: 'ST', AVENUE: 'AVE', BOULEVARD: 'BLVD', DRIVE: 'DR', ROAD: 'RD', PLACE: 'PL', TERRACE: 'TER', COURT: 'CT', LANE: 'LN', WAY: 'WAY' }[m]))
.replace(/\s+/g, ' ').trim();
}
function tokens(norm) {
const parts = norm.split(' ');
const numIdx = parts.findIndex(p => /^\d+$/.test(p));
const houseNo = numIdx >= 0 ? parts[numIdx] : null;
const cityHints = ['BEVERLY','HOLLYWOOD','LOS','WEST','SANTA','PASADENA','BURBANK','GLENDALE','CULVER','MALIBU','LONG'];
const after = parts.slice(numIdx + 1);
const cityIdx = after.findIndex(p => cityHints.includes(p));
const street = (cityIdx >= 0 ? after.slice(0, cityIdx) : after).join(' ');
return { houseNo, street };
}
async function fetchArcGis(where) {
const url = `${ARCGIS_BASE}?where=${encodeURIComponent(where)}&outFields=${FIELDS}&f=json&resultRecordCount=1`;
const r = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
if (!r.ok) return [];
const d = await r.json().catch(() => null);
return d?.features ?? [];
}
async function lookup(addrInput) {
const norm = normalize(addrInput);
const { houseNo, street } = tokens(norm);
if (!houseNo || !street) return null;
const tries = [
`SitusFullAddress LIKE '${houseNo} %${street}%'`,
`SitusFullAddress LIKE '${houseNo} N ${street}%'`,
`SitusFullAddress LIKE '${houseNo} S ${street}%'`,
`SitusFullAddress LIKE '${houseNo} E ${street}%'`,
`SitusFullAddress LIKE '${houseNo} W ${street}%'`,
];
for (const where of tries) {
const feats = await fetchArcGis(where);
if (feats.length) return feats[0].attributes;
}
return null;
}
async function upsert(listingId, a) {
const num = v => { const n = Number(v); return Number.isFinite(n) && n !== 0 ? n : null; };
const land = num(a.Roll_LandValue);
const imp = num(a.Roll_ImpValue);
await pool.query(
`INSERT INTO la_parcel
(apn, listing_id, situs_address, city, zip, use_code, use_type, use_description,
year_built, effective_year, sqft_main, bedrooms, bathrooms, units,
roll_year, land_value, improvement_value, total_value, homeowner_exempt,
lat, lng, raw, fetched_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22, now())
ON CONFLICT (apn) DO UPDATE SET
listing_id = COALESCE(EXCLUDED.listing_id, la_parcel.listing_id),
situs_address = EXCLUDED.situs_address,
year_built = EXCLUDED.year_built,
sqft_main = EXCLUDED.sqft_main,
roll_year = EXCLUDED.roll_year,
land_value = EXCLUDED.land_value,
improvement_value = EXCLUDED.improvement_value,
total_value = EXCLUDED.total_value,
fetched_at = now()`,
[
String(a.APN), listingId, a.SitusFullAddress, a.SitusCity?.trim() ?? null, a.SitusZIP?.trim() ?? null,
a.UseCode ?? null, a.UseType ?? null, a.UseDescription ?? null,
num(a.YearBuilt1), num(a.EffectiveYear1), num(a.SQFTmain1),
num(a.Bedrooms1), num(a.Bathrooms1), num(a.Units1),
num(a.Roll_Year), land, imp, (land ?? 0) + (imp ?? 0) || null, !!a.Roll_HomeOwnersExemp,
num(a.CENTER_LAT), num(a.CENTER_LON), JSON.stringify(a),
]
);
}
(async () => {
const { rows } = await pool.query(
`SELECT l.id, l.slug, l.address_line1, l.city, l.title
FROM listing l
LEFT JOIN la_parcel p ON p.listing_id = l.id AND p.fetched_at > now() - interval '30 days'
WHERE l.is_public = true AND p.apn IS NULL
ORDER BY l.updated_at DESC`
);
console.log(`pre-warming ${rows.length} listings`);
let hits = 0, misses = 0, errors = 0;
for (const l of rows) {
const q = `${l.address_line1 ?? l.title} ${l.city ?? ''}`.trim();
try {
const a = await lookup(q);
if (a) { await upsert(l.id, a); hits++; if (hits % 25 === 0) console.log(` ${hits} hits / ${misses} misses ...`); }
else { misses++; }
} catch (e) {
errors++;
console.warn(` ${l.slug}: ${e?.message ?? e}`);
}
await new Promise(r => setTimeout(r, 300)); // be polite
}
console.log(`done: ${hits} hits, ${misses} misses, ${errors} errors`);
await pool.end();
})();