← 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
M scripts/permit-crew.jsM scripts/property.js
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 →