← back to Stayclaim
scripts/ingest-la-historic-monuments.ts
141 lines
/**
* ingest-la-historic-monuments.ts
*
* LA City Historic-Cultural Monuments via NavigateLA layer 74.
* Verified 2026-04-30: 7,836 monuments + survey records.
*
* Each monument gets:
* - A listing (matched to existing if address matches LADBS data, else stub)
* - A place_event row (kind='historic_designation') tied to that listing
* - source_url linking back to NavigateLA's HCM detail report
*
* Tier: A (LA City Office of Historic Resources primary record).
*/
import { Pool } from 'pg';
const pool = new Pool({
host: process.env.PGHOST ?? '/tmp',
database: process.env.PGDATABASE ?? 'stayclaim',
user: process.env.PGUSER ?? process.env.USER,
password: process.env.PGPASSWORD,
port: parseInt(process.env.PGPORT ?? '5432', 10),
max: 4,
});
const URL = 'https://maps.lacity.org/arcgis/rest/services/Mapping/NavigateLA/MapServer/74/query';
const PAGE = 1000;
function canonicalize(addr: string): string {
return addr.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 90);
}
async function fetchPage(offset: number) {
const u = new URL_(URL);
u.searchParams.set('where', '1=1');
u.searchParams.set('outFields', '*');
u.searchParams.set('returnGeometry', 'true');
u.searchParams.set('outSR', '4326');
u.searchParams.set('resultOffset', String(offset));
u.searchParams.set('resultRecordCount', String(PAGE));
u.searchParams.set('f', 'json');
u.searchParams.set('orderByFields', 'OBJECTID');
const res = await fetch(u);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const j = await res.json() as { features?: { attributes: any; geometry?: { x: number; y: number } }[] };
return j.features ?? [];
}
// Workaround: the URL constant shadows the global URL constructor inside fetchPage above.
const URL_ = globalThis.URL;
async function ensureSchema() {
// Use existing place_event table for the historic designation event
// (kind='historic_designation', source_label='LA City Office of Historic Resources').
}
function parseLocation(loc: string): string | null {
// "200-240 Columbia Avenue; 200-252 Columbia Place" → take first segment, strip ranges
if (!loc) return null;
const first = loc.split(/[;,]/)[0]!.trim();
// collapse "200-240 Columbia Avenue" → "200 Columbia Avenue"
return first.replace(/^(\d+)-\d+\s+/, '$1 ');
}
async function upsertOne(f: any) {
const a = f.attributes ?? {};
const name = (a.NAME ?? '').trim();
const location = (a.LOCATION ?? '').trim();
const addr = parseLocation(location);
if (!addr) return { skipped: true };
const canon = canonicalize(addr);
if (!canon) return { skipped: true };
const lat = f.geometry?.y ?? null;
const lon = f.geometry?.x ?? null;
const dateActive = a.DATE_ACTIVE ? new Date(a.DATE_ACTIVE).toISOString().slice(0, 10) : null;
const objectId = a.OBJECTID;
// Try to match an existing listing first (LADBS / BH / etc). Otherwise create a stub.
const exists = await pool.query<{ id: string }>(
`SELECT id FROM listing
WHERE source IN ('ladbs_permit','bh_permit','manual')
AND lower(regexp_replace(coalesce(address_line1,''), '[^\\w\\s-]', '', 'g')) ILIKE $1
LIMIT 1`,
[`%${canon.replace(/-/g, '%')}%`]
);
let listingId: string;
if (exists.rows[0]) {
listingId = exists.rows[0].id;
} else {
const r = await pool.query<{ id: string }>(
`INSERT INTO listing (slug, source, source_id, title, address_line1, city, state, country, latitude, longitude, is_public, tier)
VALUES ($1,'la_hcm',$2,$3,$4,'Los Angeles','CA','US',$5,$6,true,'free')
ON CONFLICT (slug) DO UPDATE SET
latitude = COALESCE(listing.latitude, EXCLUDED.latitude),
longitude = COALESCE(listing.longitude, EXCLUDED.longitude)
RETURNING id`,
[`${canon}-hcm`, `hcm:${objectId}`, name || addr, addr, lat, lon]
);
listingId = r.rows[0]!.id;
}
await pool.query(
`INSERT INTO place_event
(listing_id, kind, summary, valid_from, source_tier, source_label, source_url, source_id, public_visible)
VALUES ($1,'historic_designation',$2,$3::date,'A','LA City Office of Historic Resources',$4,$5,true)
ON CONFLICT DO NOTHING`,
[
listingId,
`Designated Historic-Cultural Monument: ${name} (HCM #${a.MNT_NUM || objectId} · ${a.HIST_TYPE || 'HCM'})`,
dateActive,
`https://maps.lacity.org/${a.NLA_URL ?? `navigatela/reports/historic_cultural_monuments.cfm?PK=${objectId}`}`,
`hcm:${objectId}`,
]
);
return { ok: true };
}
async function main() {
await ensureSchema();
let offset = 0, total = 0, ok = 0, skipped = 0;
const t0 = Date.now();
while (true) {
const features = await fetchPage(offset);
if (!features.length) break;
for (const f of features) {
const r = await upsertOne(f);
if (r.ok) ok++;
else skipped++;
}
total += features.length;
const dt = (Date.now() - t0) / 1000;
console.log(` hcm: ${total} processed (${ok} ok, ${skipped} skipped) — ${(total/dt).toFixed(0)}/s`);
if (features.length < PAGE) break;
offset += features.length;
if (offset > 50000) break;
}
console.log(`✓ HCM: ${ok} monuments tied to listings`);
await pool.end();
}
main().catch(e => { console.error('FATAL', e); process.exit(1); });