← back to Stayclaim
scripts/ingest-loc-sanborn.ts
156 lines
#!/usr/bin/env -S npx tsx
/**
* Library of Congress Sanborn fire-insurance maps ingest.
*
* Queries the LoC Sanborn collection for sheets covering Beverly Hills,
* stores them as media_asset rows linked to all BH listings (the same
* sheet covers many parcels — ingest deliberately fans out so each
* address page can show its own block in context).
*
* Public-domain (US gov + expired copyright). Tier B.
*
* Usage:
* npx tsx scripts/ingest-loc-sanborn.ts # all BH listings
* npx tsx scripts/ingest-loc-sanborn.ts --city Malibu # other city
* npx tsx scripts/ingest-loc-sanborn.ts --dry
*/
import { Pool } from 'pg';
type LocItem = {
id: string;
title?: string;
url?: string;
date?: string;
image_url?: string[];
resources?: Array<{ image?: string; url?: string }>;
};
async function searchSanborn(city: string, state: string, page: number, perPage = 25): Promise<LocItem[]> {
// Sanborn collection search; faceted by location.
const params = new URLSearchParams({
q: `${city} ${state}`,
fa: `location:${state.toLowerCase()}`,
fo: 'json',
c: String(perPage),
sp: String(page),
});
const url = `https://www.loc.gov/collections/sanborn-maps/?${params}`;
for (let attempt = 0; attempt < 4; attempt++) {
const res = await fetch(url, {
headers: {
'User-Agent': 'pastdoor/0.1 (steve@pastdoor.com) historical research',
Accept: 'application/json',
},
});
if (res.status === 429) {
// Back off exponentially: 3s, 7s, 15s.
const wait = (attempt + 1) * (attempt + 1) * 1000 + 2000;
console.warn(` [LoC 429] backing off ${wait}ms (attempt ${attempt + 1})`);
await new Promise(r => setTimeout(r, wait));
continue;
}
if (!res.ok) throw new Error(`LoC ${res.status}`);
const j = (await res.json()) as { results?: LocItem[] };
return j.results ?? [];
}
throw new Error('LoC 429 — exhausted retries');
}
function yearFromTitle(title: string): number | null {
const m = title.match(/\b(18|19|20)\d{2}\b/);
return m ? Number(m[0]) : null;
}
function pickThumb(item: LocItem): string | null {
if (item.image_url && item.image_url.length > 0) return item.image_url[0]!;
if (item.resources && item.resources.length > 0) {
const r = item.resources[0]!;
return r.image ?? r.url ?? null;
}
return null;
}
const pool = new Pool({
host: process.env.PGHOST ?? '/tmp',
database: process.env.PGDATABASE ?? 'stayclaim',
user: process.env.PGUSER ?? process.env.USER,
max: 4,
});
async function main() {
const args = process.argv.slice(2);
const cityArg = args.find((a, i) => args[i - 1] === '--city') ?? 'Beverly Hills';
const stateArg = args.find((a, i) => args[i - 1] === '--state') ?? 'California';
const dry = args.includes('--dry');
const items: LocItem[] = [];
// pull up to 2 pages = 50 results max
for (let p = 1; p <= 2; p++) {
try {
const r = await searchSanborn(cityArg, stateArg, p);
if (r.length === 0) break;
items.push(...r);
} catch (e) {
console.warn(`page ${p} error:`, (e as Error).message);
break;
}
await new Promise(r => setTimeout(r, 800));
}
console.log(`LoC returned ${items.length} candidate items for ${cityArg}, ${stateArg}`);
// Filter: must look like a Sanborn sheet (title or url contains the city).
const cityRe = new RegExp(cityArg, 'i');
const filtered = items.filter(it => cityRe.test(it.title ?? '') || cityRe.test(it.id ?? ''));
console.log(`Filtered to ${filtered.length} likely-${cityArg} sheets`);
// Pull listings to attach sheets to.
const { rows: listings } = await pool.query<{ id: string; slug: string }>(
'SELECT id, slug FROM listing WHERE city = $1 AND is_public = true',
[cityArg]
);
if (listings.length === 0) {
console.log(`No public listings for ${cityArg}; nothing to attach.`);
await pool.end();
return;
}
console.log(`Attaching to ${listings.length} listings.`);
let inserted = 0;
let skipped = 0;
for (const item of filtered) {
const year = yearFromTitle(item.title ?? '');
const url = pickThumb(item);
if (!url) {
skipped++;
continue;
}
const caption = item.title ? item.title.slice(0, 200) : 'Sanborn sheet';
for (const l of listings) {
// Idempotency: skip if we already have this LoC item for this listing.
const existing = await pool.query(
`SELECT 1 FROM media_asset WHERE listing_id = $1 AND source_url = $2 LIMIT 1`,
[l.id, item.url ?? item.id]
);
if (existing.rowCount! > 0) {
skipped++;
continue;
}
if (dry) {
console.log(`[${l.slug}] DRY would insert ${year ?? '?'} :: ${caption.slice(0, 60)}`);
continue;
}
await pool.query(
`INSERT INTO media_asset
(listing_id, kind, year, caption, storage_url, source_tier, source_label, source_url, rights, public_visible)
VALUES ($1, 'historic_map', $2, $3, $4, 'B', $5, $6, 'Public domain (LoC)', true)`,
[l.id, year, caption, url, 'Library of Congress · Sanborn Maps Collection', item.url ?? item.id]
);
inserted++;
}
}
console.log(JSON.stringify({ found: items.length, sheets: filtered.length, listings: listings.length, inserted, skipped, dry }, null, 2));
await pool.end();
}
main().catch(e => { console.error(e); process.exit(1); });