← 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());