← back to La Socrata Ingester
scripts/property.js
82 lines
// Property drill-down (READ-ONLY, $0 local). "Everything we hold for a build": the
// parcel + its full permit history grouped by TRADE (GC building permit + the electrical/
// plumbing/HVAC SUB-permits on the same parcel = the crew structure) + a summary.
// Contractor NAMES per permit come from a separate LADBS permit-detail scrape layer
// (the bulk feeds are anonymized) — this shows the trades involved and the timeline.
//
// Usage: node scripts/property.js "<address substring>" | node scripts/property.js <APN>
import { q, pool } from '../src/db.js';
import { getCrew } from './permit-crew.js';
import { searchLinkedin, writeLic } from './linkedin-openclaw.js';
const money = v => v == null || Number(v) === 0 ? '—' : '$' + Math.round(Number(v)).toLocaleString();
const NO_CREW = process.argv.includes('--no-crew'); // skip the LADBS scrape (structure only)
const LINKEDIN = process.argv.includes('--linkedin'); // opt-in: openclaw-fill missing LinkedIn (slower, $0)
const TRADE = { 'pi9x-tg5x': 'BUILDING (GC)', 'dyxf-7hc4': 'BUILDING (GC, 2010-19)', 'e67z-kt2n': 'BUILDING (GC, pre-2010)', 'ysqd-apz7': 'ELECTRICAL', '67is-svtd': 'MECH/PLUMB' };
async function main() {
const arg = process.argv.slice(2).filter(a => !a.startsWith('--')).join(' ').trim();
if (!arg) { console.error('usage: node scripts/property.js "<address>" | <APN>'); process.exit(1); }
const isApn = /^\d{7,10}$/.test(arg.replace(/[^0-9]/g, '')) && !/\s/.test(arg);
// resolve to an APN
const apnRow = isApn
? { apn: arg.replace(/[^0-9]/g, '') }
: (await q(`SELECT apn, primary_address FROM la_building_permits_raw WHERE primary_address ILIKE $1 AND apn IS NOT NULL ORDER BY issue_date DESC LIMIT 1`, ['%' + arg + '%'])).rows[0];
if (!apnRow?.apn) { console.error('no property found for: ' + arg); process.exit(1); }
const apn = apnRow.apn;
// parcel (assessor 2025)
const parcel = (await q(`SELECT property_location, situs_zip5, use_type, year_built, sqft_main, total_value, land_value, imp_value FROM la_assessor_parcels_raw WHERE ain=$1 AND roll_year='2025' LIMIT 1`, [apn])).rows[0];
const addr = parcel?.property_location || apnRow.primary_address || '(address unknown)';
// all permits on this parcel
const permits = (await q(`
SELECT dataset_id, permit_nbr, to_char(issue_date,'YYYY-MM-DD') issued, permit_type, permit_sub_type,
valuation, status_desc, use_desc, work_desc
FROM la_building_permits_raw WHERE apn=$1 ORDER BY issue_date NULLS FIRST`, [apn])).rows;
console.log(`\n=== PROPERTY DRILL-DOWN — ${addr} ===`);
console.log(`APN ${apn}${parcel ? ` · ${parcel.situs_zip5 || ''} · ${parcel.use_type || ''}` : ''}`);
if (parcel) console.log(`Parcel: assessed ${money(parcel.total_value)} (land ${money(parcel.land_value)} / improvements ${money(parcel.imp_value)}) · built ${parcel.year_built || '—'} · ${parcel.sqft_main ? Number(parcel.sqft_main).toLocaleString() + ' sqft' : '—'}`);
// crew structure — permit count by trade
const byTrade = {};
for (const p of permits) { const t = TRADE[p.dataset_id] || p.dataset_id; (byTrade[t] ||= 0); byTrade[t]++; }
console.log(`\nCREW / TRADES on this parcel (${permits.length} permits total):`);
for (const [t, n] of Object.entries(byTrade).sort((a, b) => b[1] - a[1])) console.log(` ${t.padEnd(26)} ${n} permit${n > 1 ? 's' : ''}`);
console.log(`\nPERMIT HISTORY (timeline):`);
for (const p of permits) {
const t = (TRADE[p.dataset_id] || p.dataset_id).replace(/ \(.*/, '');
console.log(` ${p.issued || '(n/a) '} ${t.padEnd(14)} ${(p.permit_type || '').padEnd(18)} ${money(p.valuation).padStart(12)} ${p.status_desc || ''}`);
}
const bldgVal = permits.filter(p => /BUILDING/.test(TRADE[p.dataset_id] || '')).reduce((s, p) => s + Number(p.valuation || 0), 0);
console.log(`\nSummary: ${permits.length} permits, ${Object.keys(byTrade).length} trade categories, ${money(bldgVal)} total building valuation.`);
// NAMED CREW — GC + subs + architect/engineer with contacts (LADBS detail + CSLB match)
if (NO_CREW) { console.log(`\n(named crew skipped: --no-crew)\n`); return; }
console.log(`\nNAMED CREW (this build's era) — scraping LADBS permit details…`);
const r = await getCrew(apn);
if (!r.crew.length) { console.log(` (no named contractors found for the current build era since ${r.eraStart})\n`); return; }
console.log(` era since ${r.eraStart} · ${r.inEra} permits · ${r.olderExcluded} older excluded${LINKEDIN ? ' · --linkedin: filling missing profiles via openclaw…' : ''}`);
for (const c of r.crew) {
// opt-in: find a missing LinkedIn live via the real browser, and persist it
if (LINKEDIN && c.lic && !c.linkedin) {
try { const li = await searchLinkedin(c.contractor, c.city); if (li) { await writeLic(c.lic, li); c.linkedin = li; c.linkedin_source = 'openclaw'; } } catch {}
}
console.log(` ${c.issued} ${c.trade.padEnd(18)} ${(c.contractor || '?').slice(0, 40)}`);
const bits = [`lic ${c.lic || '—'}`];
if (c.phone) bits.push(`☎ ${c.phone}`);
if (c.email) bits.push(`✉ ${c.email}`);
if (c.website) bits.push(`🌐 ${c.website}`);
if (c.linkedin) bits.push(`in ${c.linkedin}${c.linkedin_source === 'openclaw' ? '' : ` (${c.linkedin_source || 'site'})`}`);
else if (!c.cslb) bits.push('(no CSLB match)');
console.log(` ${bits.join(' · ')}`);
if (c.architect) console.log(` architect: ${c.architect}`);
if (c.engineer) console.log(` engineer: ${c.engineer}`);
}
if (r.multiRole.length) console.log(` ⚠ same firm across roles (likely GC pulled the sub-permit): ${r.multiRole.join(', ')}`);
console.log('');
}
main().catch(e => { console.error('property error:', e.message); process.exitCode = 1; }).finally(() => pool.end());