← back to Stayclaim
scripts/ingest-bh-permits.ts
159 lines
#!/usr/bin/env -S npx tsx
/**
* Beverly Hills nightly permit feed ingest.
*
* Drop-in slot for the real BH issued-permits dataset. Behaviour:
* 1. Reads from a CSV path (--file) or HTTP URL (--url) or the bundled sample.
* 2. Normalizes the address string and matches it to an existing listing.
* If no match, creates a stub listing in DRAFT state (is_public=false)
* so the permit isn't lost — editorial can promote later.
* 3. Appends a row to `place_event` per permit, idempotent on permit_number
* (extracted into source_id so repeat runs don't double-insert).
*
* Usage:
* npx tsx scripts/ingest-bh-permits.ts # bundled sample
* npx tsx scripts/ingest-bh-permits.ts --file path/to.csv
* npx tsx scripts/ingest-bh-permits.ts --url https://data.beverlyhills.org/.../permits.csv
*
* Wire-up for the live feed (when the URL lands):
* - Add ~/Library/LaunchAgents/com.pastdoor.bh-permits.plist (cron 03:00 daily)
* - Or pm2: `pm2 start "npx tsx scripts/ingest-bh-permits.ts --url $URL" --cron "0 3 * * *"`
*/
import fs from 'node:fs';
import path from 'node:path';
import { Pool } from 'pg';
import slugify from 'slugify';
type Row = {
permit_number: string;
issued_date: string;
address: string;
scope: string;
permit_type: string;
valuation: string;
applicant: string;
};
function parseCsv(text: string): Row[] {
// Lightweight CSV parser — sample file uses clean quoting.
const lines = text.split(/\r?\n/).filter(Boolean);
const headers = lines[0]!.split(',');
return lines.slice(1).map(line => {
// naive comma-split — sample data has no embedded commas inside fields
const cols = line.split(',');
const row: Record<string, string> = {};
headers.forEach((h, i) => (row[h.trim()] = (cols[i] ?? '').trim()));
return row as unknown as Row;
});
}
function normalizeAddress(raw: string): string {
return raw
.toLowerCase()
.replace(/[.,]/g, '')
.replace(/\bnorth\b/g, 'n')
.replace(/\bsouth\b/g, 's')
.replace(/\beast\b/g, 'e')
.replace(/\bwest\b/g, 'w')
.replace(/\bdrive\b/g, 'dr')
.replace(/\bavenue\b/g, 'ave')
.replace(/\bboulevard\b/g, 'blvd')
.replace(/\bstreet\b/g, 'st')
.replace(/\bway\b/g, 'wy')
.replace(/\broad\b/g, 'rd')
.replace(/\splace\b/g, ' pl')
.replace(/\bcanyon\b/g, 'canyon')
.replace(/\s+/g, ' ')
.trim();
}
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 findOrCreateListingByAddress(address: string): Promise<string> {
const norm = normalizeAddress(address);
const { rows: hit } = await pool.query<{ id: string }>(
`SELECT id FROM listing
WHERE city = 'Beverly Hills'
AND lower(regexp_replace(address_line1, '[.,]', '', 'g')) ILIKE $1
LIMIT 1`,
[`%${norm}%`]
);
if (hit[0]) return hit[0].id;
// Create a stub. is_public=false, so the page won't index until enriched.
const slug = slugify(`${address}-bh`, { lower: true, strict: true });
const { rows } = await pool.query<{ id: string }>(
`INSERT INTO listing (slug, source, source_id, title, address_line1, city, state, country, is_public, privacy_class)
VALUES ($1, 'bh-permits', $2, $3, $4, 'Beverly Hills', 'CA', 'US', false, 'public_history_only')
ON CONFLICT (slug) DO UPDATE SET updated_at = now()
RETURNING id`,
[slug, slug, address, address]
);
return rows[0]!.id;
}
async function appendPermitEvent(listingId: string, row: Row) {
const summary = row.scope ? `${row.scope}` : `Permit issued — ${row.permit_type}`;
const sourceLabel = `BH Planning · permit ${row.permit_number}`;
const { rowCount } = await pool.query(
`INSERT INTO place_event (listing_id, kind, summary, valid_from, source_tier, source_label, source_id, confidence)
SELECT $1, 'permit', $2, $3::date, 'A', $4, $5, 0.94
WHERE NOT EXISTS (SELECT 1 FROM place_event WHERE source_id = $5 AND kind = 'permit')`,
[listingId, summary, row.issued_date, sourceLabel, row.permit_number]
);
return rowCount === 1;
}
async function main() {
const args = process.argv.slice(2);
const fileArg = args.find((a, i) => args[i - 1] === '--file');
const urlArg = args.find((a, i) => args[i - 1] === '--url');
let csv: string;
if (urlArg) {
const r = await fetch(urlArg);
if (!r.ok) throw new Error(`Fetch ${urlArg} ${r.status}`);
csv = await r.text();
} else {
const filePath = fileArg ?? path.resolve(__dirname, '..', 'data', 'bh-permits-sample.csv');
csv = fs.readFileSync(filePath, 'utf8');
}
const rows = parseCsv(csv);
let inserted = 0;
let matched = 0;
let stubbed = 0;
for (const row of rows) {
const before = await pool.query<{ id: string }>(
`SELECT id FROM listing WHERE source = 'bh-permits' AND source_id = $1`,
[slugify(`${row.address}-bh`, { lower: true, strict: true })]
);
const wasStub = before.rowCount! > 0;
const listingId = await findOrCreateListingByAddress(row.address);
if (!wasStub) {
const after = await pool.query<{ id: string }>(
`SELECT id FROM listing WHERE source = 'bh-permits' AND source_id = $1`,
[slugify(`${row.address}-bh`, { lower: true, strict: true })]
);
if (after.rowCount! > 0) stubbed++;
else matched++;
}
const did = await appendPermitEvent(listingId, row);
if (did) inserted++;
}
console.log(JSON.stringify(
{ processed: rows.length, events_inserted: inserted, matched_existing: matched, stubs_created: stubbed },
null, 2
));
await pool.end();
}
main().catch(e => {
console.error(e);
process.exit(1);
});