[object Object]

← back to La Socrata Ingester

Unified property drill-down: address -> property + trade structure + NAMED CREW + contacts

c9ccae5a7e0292fde4b30e60831b21ecf6ca95c2 · 2026-08-12 08:52:27 -0700 · steve

Folded getCrew() into property.js (permit-crew.js now exports it, CLI guarded). One
command: node scripts/property.js "<address>" -> parcel + permit/trade structure +
timeline + full named crew (GC/subs/architect/engineer w/ license, phone, website).
--no-crew skips the LADBS scrape. $0 (cached LADBS GET + local CSLB join).

Files touched

Diff

commit c9ccae5a7e0292fde4b30e60831b21ecf6ca95c2
Author: steve <steve@designerwallcoverings.com>
Date:   Wed Aug 12 08:52:27 2026 -0700

    Unified property drill-down: address -> property + trade structure + NAMED CREW + contacts
    
    Folded getCrew() into property.js (permit-crew.js now exports it, CLI guarded). One
    command: node scripts/property.js "<address>" -> parcel + permit/trade structure +
    timeline + full named crew (GC/subs/architect/engineer w/ license, phone, website).
    --no-crew skips the LADBS scrape. $0 (cached LADBS GET + local CSLB join).
---
 scripts/permit-crew.js | 41 ++++++++++++++++++++++++-----------------
 scripts/property.js    | 18 +++++++++++++++++-
 2 files changed, 41 insertions(+), 18 deletions(-)

diff --git a/scripts/permit-crew.js b/scripts/permit-crew.js
index ea8fca8..f5144ee 100644
--- a/scripts/permit-crew.js
+++ b/scripts/permit-crew.js
@@ -50,37 +50,41 @@ async function cslbMatch(lic) {
   return (await q(`SELECT "BusinessName" name, website, "BusinessPhone" phone FROM cslb_raw WHERE "LicenseNo"=$1 LIMIT 1`, [lic])).rows[0] || null;
 }
 
-async function crewForApn(apn, label) {
+// Returns era-scoped named crew for a parcel as structured data (importable).
+export async function getCrew(apn) {
   const permits = (await q(`SELECT dataset_id, permit_nbr, permit_type, to_char(issue_date,'YYYY-MM-DD') issued
     FROM la_building_permits_raw WHERE apn=$1 AND issue_date IS NOT NULL ORDER BY issue_date DESC`, [apn])).rows;
-  // Build ERA (Cody fix): anchor to the most recent BUILDING permit; only permits within
-  // ~18 months of it are "this build's crew" — older permits are a different job, excluded.
   const bldg = permits.filter(p => /pi9x|dyxf|e67z/.test(p.dataset_id));
   const anchor = (bldg[0] || permits[0])?.issued;
   const eraStart = anchor ? new Date(new Date(anchor).getTime() - 550 * 86400 * 1000).toISOString().slice(0, 10) : '0000-01-01';
   const era = permits.filter(p => p.issued >= eraStart);
-  console.log(`\n=== BUILD CREW — ${label} (APN ${apn}) ===`);
-  console.log(`Build era: permits since ${eraStart} (anchored to latest building permit ${anchor || 'n/a'}). ${era.length} in-era, ${permits.length - era.length} older excluded.`);
-  const seen = new Set(); const roleByName = {};
+  const seen = new Set(), roleByName = {}, crew = [];
   for (const p of era) {
     const d = await scrapePermit(p.permit_nbr);
     if (!d?.cached) await sleep(DELAY);
     if (!d || !d.contractor || d.contractor === '(no match)') continue;
-    const nm = nameOf(d.contractor);
-    const trade = TRADE[p.dataset_id] || p.dataset_id;
+    const nm = nameOf(d.contractor), trade = TRADE[p.dataset_id] || p.dataset_id;
     const key = (d.contractor_lic || nm) + '|' + trade;
     if (seen.has(key)) continue; seen.add(key);
     (roleByName[nm] ||= new Set()).add(trade.split(' ')[0]);
     const m = await cslbMatch(d.contractor_lic);
-    const web = m?.website ? `  🌐 ${m.website}` : '';
-    const ph = m?.phone ? `  ☎ ${m.phone}` : (d.contractor_lic ? '  (no CSLB match)' : '');
-    console.log(`  ${p.issued}  ${trade.padEnd(18)} ${(nm || '?').slice(0, 32).padEnd(32)} lic ${d.contractor_lic || '—'}${web}${ph}`);
-    if (d.architect) console.log(`  ${' '.repeat(30)}architect: ${nameOf(d.architect)}`);
-    if (d.engineer) console.log(`  ${' '.repeat(30)}engineer:  ${nameOf(d.engineer)}`);
+    crew.push({ issued: p.issued, trade, contractor: nm, lic: d.contractor_lic, phone: m?.phone || null, website: m?.website || null, cslb: !!m, architect: nameOf(d.architect), engineer: nameOf(d.engineer) });
+  }
+  const multiRole = Object.entries(roleByName).filter(([, r]) => r.size > 1).map(([n]) => n);
+  return { apn, anchor, eraStart, inEra: era.length, olderExcluded: permits.length - era.length, crew, multiRole };
+}
+
+async function crewForApn(apn, label) {
+  const r = await getCrew(apn);
+  console.log(`\n=== BUILD CREW — ${label} (APN ${apn}) ===`);
+  console.log(`Build era: permits since ${r.eraStart} (anchored to latest building permit ${r.anchor || 'n/a'}). ${r.inEra} in-era, ${r.olderExcluded} older excluded.`);
+  for (const c of r.crew) {
+    const web = c.website ? `  🌐 ${c.website}` : '', ph = c.phone ? `  ☎ ${c.phone}` : (c.lic ? '  (no CSLB match)' : '');
+    console.log(`  ${c.issued}  ${c.trade.padEnd(18)} ${(c.contractor || '?').slice(0, 32).padEnd(32)} lic ${c.lic || '—'}${web}${ph}`);
+    if (c.architect) console.log(`  ${' '.repeat(30)}architect: ${c.architect}`);
+    if (c.engineer) console.log(`  ${' '.repeat(30)}engineer:  ${c.engineer}`);
   }
-  // Flag GC-pulled-own-subs: a firm appearing under both GC and a trade role (now visible)
-  const multi = Object.entries(roleByName).filter(([, r]) => r.size > 1);
-  if (multi.length) console.log(`  ⚠ same firm across roles (likely GC pulled the sub-permit): ${multi.map(([n]) => n).join(', ')}`);
+  if (r.multiRole.length) console.log(`  ⚠ same firm across roles (likely GC pulled the sub-permit): ${r.multiRole.join(', ')}`);
 }
 
 async function main() {
@@ -100,4 +104,7 @@ async function main() {
   }
   console.log('\n$0 (public LADBS detail GET + local CSLB join).');
 }
-main().catch(e => { console.error('permit-crew error:', e.message); process.exitCode = 1; }).finally(() => pool.end());
+// run the CLI only when invoked directly (not when imported by property.js)
+if (process.argv[1] && /permit-crew\.js$/.test(process.argv[1])) {
+  main().catch(e => { console.error('permit-crew error:', e.message); process.exitCode = 1; }).finally(() => pool.end());
+}
diff --git a/scripts/property.js b/scripts/property.js
index 5b74b81..2614754 100644
--- a/scripts/property.js
+++ b/scripts/property.js
@@ -6,7 +6,9 @@
 //
 // 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';
 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 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() {
@@ -48,6 +50,20 @@ async function main() {
   }
   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.`);
-  console.log(`Contractor names per permit: available via the LADBS permit-detail layer (permit_nbr -> detail). Not in the bulk feed.\n`);
+
+  // 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`);
+  for (const c of r.crew) {
+    const web = c.website ? `  🌐 ${c.website}` : '', ph = c.phone ? `  ☎ ${c.phone}` : (c.lic ? '  (no CSLB match)' : '');
+    console.log(`  ${c.issued}  ${c.trade.padEnd(18)} ${(c.contractor || '?').slice(0, 32).padEnd(32)} lic ${c.lic || '—'}${web}${ph}`);
+    if (c.architect) console.log(`  ${' '.repeat(30)}architect: ${c.architect}`);
+    if (c.engineer) console.log(`  ${' '.repeat(30)}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());

← b291216 permit-crew c-fix (Cody gate): era-scope the crew + dates +  ·  back to La Socrata Ingester  ·  rate-limit: finalize apply-ratelimit.sh — token-free DRY_RUN e9415b8 →