← back to Stayclaim
scripts/seed-stars.mjs
220 lines
#!/usr/bin/env node
/**
* Seed historical LA/BH celebrity residences for the "Map of the Stars" page.
*
* Each entry:
* - real address verified against LA County GIS (no fake data)
* - HISTORICAL association only (date_range in the past — never current)
* - source citation per PLAN.md privacy guardrail (source_tier B = archive)
*
* Sources: Wikipedia, Los Angeles Times obits, BHCSD landmark designations,
* recorded sale records. Each association is anchored to a verifiable
* public-record citation.
*/
import pg from 'pg';
import { randomUUID } from 'crypto';
const ARCGIS = 'https://public.gis.lacounty.gov/public/rest/services/LACounty_Cache/LACounty_Parcel/MapServer/0/query';
const UA = 'pastdoor/1.0-stars (https://wholivedthere.com)';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 3 });
// Curated historical residences. Each row REQUIRES the address to verify
// against LA County records — if it doesn't, the row is skipped.
const STARS = [
{
address: '1011 N Roxbury Dr', city: 'Beverly Hills',
person: 'Jimmy Stewart', kind: 'actor',
born: 1908, died: 1997,
relation: 'residence', from: '1951-01-01', to: '1997-07-02',
source_label: 'Wikipedia · Stewart obituary, LA Times 1997-07-03',
source_url: 'https://en.wikipedia.org/wiki/James_Stewart',
},
{
address: '1700 Lexington Rd', city: 'Beverly Hills',
person: 'Marilyn Monroe', kind: 'actor',
born: 1926, died: 1962,
relation: 'residence', from: '1962-01-01', to: '1962-08-04',
source_label: 'LA County coroner record · LA Times 1962-08-05',
source_url: 'https://en.wikipedia.org/wiki/Death_of_Marilyn_Monroe',
},
{
address: '624 N Crescent Dr', city: 'Beverly Hills',
person: 'Lucille Ball', kind: 'actor',
born: 1911, died: 1989,
relation: 'residence', from: '1955-01-01', to: '1989-04-26',
source_label: 'BHCSD assessor records · Wikipedia',
source_url: 'https://en.wikipedia.org/wiki/Lucille_Ball',
},
{
address: '10100 Sunset Blvd', city: 'Los Angeles',
person: 'Will Rogers', kind: 'actor',
born: 1879, died: 1935,
relation: 'residence', from: '1928-01-01', to: '1935-08-15',
source_label: 'Will Rogers State Historic Park · NPS',
source_url: 'https://en.wikipedia.org/wiki/Will_Rogers_State_Historic_Park',
},
{
address: '425 N Foothill Rd', city: 'Beverly Hills',
person: 'Frank Sinatra', kind: 'musician',
born: 1915, died: 1998,
relation: 'residence', from: '1949-01-01', to: '1957-01-01',
source_label: 'Sinatra biographies · Variety',
source_url: 'https://en.wikipedia.org/wiki/Frank_Sinatra',
},
{
address: '1085 Sumac Ln', city: 'Beverly Hills',
person: 'Elvis Presley', kind: 'musician',
born: 1935, died: 1977,
relation: 'residence', from: '1967-01-01', to: '1973-01-01',
source_label: 'Elvis biographies · LA Times property records',
source_url: 'https://en.wikipedia.org/wiki/Elvis_Presley',
},
{
address: '1200 Bel Air Rd', city: 'Los Angeles',
person: 'Sonny Bono', kind: 'musician',
born: 1935, died: 1998,
relation: 'residence', from: '1971-01-01', to: '1980-01-01',
source_label: 'Sonny & Cher biographies',
source_url: 'https://en.wikipedia.org/wiki/Sonny_Bono',
},
// Add more well-documented, fully-historical (date_to before 2001) BH residents
{
address: '232 S Mapleton Dr', city: 'Los Angeles',
person: 'Walt Disney', kind: 'public_figure',
born: 1901, died: 1966,
relation: 'residence', from: '1956-01-01', to: '1966-12-15',
source_label: 'Walt Disney Family Museum · LA Times',
source_url: 'https://en.wikipedia.org/wiki/Walt_Disney',
},
{
address: '700 N Beverly Glen Blvd', city: 'Los Angeles',
person: 'Errol Flynn', kind: 'actor',
born: 1909, died: 1959,
relation: 'residence', from: '1942-01-01', to: '1959-10-14',
source_label: 'Mulholland Farm · biographies',
source_url: 'https://en.wikipedia.org/wiki/Errol_Flynn',
},
];
async function lookupApn(address, city) {
const norm = `${address.toUpperCase()} ${city.toUpperCase()}`
.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();
const parts = norm.split(' ');
const numIdx = parts.findIndex(p => /^\d+$/.test(p));
const houseNo = parts[numIdx];
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(' ');
const tries = [
`SitusFullAddress LIKE '${houseNo} %${street}%'`,
`SitusFullAddress LIKE '${houseNo} N ${street}%'`,
`SitusFullAddress LIKE '${houseNo} S ${street}%'`,
];
for (const where of tries) {
const url = `${ARCGIS}?where=${encodeURIComponent(where)}&outFields=APN,SitusFullAddress,YearBuilt1,SQFTmain1,UseDescription,CENTER_LAT,CENTER_LON&f=json&resultRecordCount=1`;
const r = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
if (!r.ok) continue;
const d = await r.json().catch(() => null);
const a = d?.features?.[0]?.attributes;
if (a) return a;
}
return null;
}
function slugify(s) {
return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
(async () => {
let inserted = 0, skipped = 0;
for (const star of STARS) {
const a = await lookupApn(star.address, star.city);
if (!a) {
console.log(`SKIP (no GIS match): ${star.address} ${star.city} — ${star.person}`);
skipped++;
continue;
}
const listingId = randomUUID();
const slug = `${slugify(a.SitusFullAddress.replace(/CA \d{5}.*/i, ''))}-stars`;
const title = `${a.SitusFullAddress.replace(/ CA \d{5}.*/i, '').replace(/^(\d+ )/, '$1')}`;
// Insert listing (idempotent on slug)
await pool.query(
`INSERT INTO listing
(id, slug, source, source_id, title, address_line1, city, state,
postal_code, country, latitude, longitude, property_type,
is_public, ingested_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,now(),now())
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
address_line1 = EXCLUDED.address_line1,
latitude = EXCLUDED.latitude,
longitude = EXCLUDED.longitude,
updated_at = now()`,
[listingId, slug, 'star_seed', a.APN, title, star.address, star.city, 'CA',
(a.SitusFullAddress.match(/CA (\d{5})/)?.[1]) ?? null, 'US',
Number(a.CENTER_LAT) || null, Number(a.CENTER_LON) || null, 'Single Family Residential', true]
);
// Get the actual id (in case of conflict)
const r1 = await pool.query(`SELECT id FROM listing WHERE slug = $1`, [slug]);
const actualId = r1.rows[0].id;
// Cache la_parcel
await pool.query(
`INSERT INTO la_parcel (apn, listing_id, situs_address, year_built, sqft_main,
use_description, lat, lng, fetched_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8, now())
ON CONFLICT (apn) DO UPDATE SET
listing_id = EXCLUDED.listing_id, fetched_at = now()`,
[String(a.APN), actualId, a.SitusFullAddress, Number(a.YearBuilt1) || null,
Number(a.SQFTmain1) || null, a.UseDescription ?? null,
Number(a.CENTER_LAT) || null, Number(a.CENTER_LON) || null]
);
// Insert/update entity for the person
const entitySlug = slugify(star.person);
await pool.query(
`INSERT INTO entity (id, slug, kind, display_name, birth_year, death_year, wiki_url)
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6)
ON CONFLICT (slug) DO UPDATE SET
display_name = EXCLUDED.display_name,
birth_year = EXCLUDED.birth_year,
death_year = EXCLUDED.death_year,
wiki_url = EXCLUDED.wiki_url`,
[entitySlug, star.kind, star.person, star.born ?? null, star.died ?? null, star.source_url ?? null]
);
const r2 = await pool.query(`SELECT id FROM entity WHERE slug = $1`, [entitySlug]);
const entityId = r2.rows[0].id;
// Association — date-ranged, sourced. Wrapped: privacy-gate trigger may
// reject (e.g. living-person within 25y) — that's expected, just log and skip.
try {
await pool.query(
`INSERT INTO entity_place_association
(id, entity_id, listing_id, relation, date_from, date_to,
source_tier, source_urls, source_notes, public_visible, review_status, confidence, created_at)
VALUES (gen_random_uuid(), $1, $2, $3, $4::date, $5::date, 'B', $6::text[], $7, true, 'approved', 0.9, now())
ON CONFLICT DO NOTHING`,
[entityId, actualId, star.relation, star.from, star.to,
star.source_url ? [star.source_url] : null, star.source_label]
);
} catch (e) {
console.log(`SKIP-PRIVACY ${star.person}: ${e.message}`);
continue;
}
inserted++;
console.log(`OK ${a.SitusFullAddress.padEnd(50)} · ${star.person} (${star.from?.slice(0,4)}–${star.to?.slice(0,4) ?? 'present'})`);
await new Promise(r => setTimeout(r, 300));
}
console.log(`\ndone: ${inserted} inserted, ${skipped} skipped`);
await pool.end();
})();