← back to La Socrata Ingester
Named build crew (scripts/permit-crew.js) — the linkage layer: subs on any build
1cd94696debd364cc9c06e0dde54a3da3c5fe004 · 2026-08-12 08:45:41 -0700 · steve
Breakthrough (la-research-agent): LADBS PcisPermitDetail is a PUBLIC single GET with
id1/id2/id3 (permit# split on -), not permitNumber. Chain: property -> all permits on
parcel -> GET each detail -> Contractor/Architect/Engineer + CSLB license -> match to
enriched cslb_raw (website/phone). GC on building permit + SUBS on electrical/plumbing/
mech permits = full named crew. Proven: 7143 Tampa named 7-firm crew w/ phones. $0.
Cached in permit_crew. DTD-decided, la-research-discovered, Cody-gated next.
Files touched
Diff
commit 1cd94696debd364cc9c06e0dde54a3da3c5fe004
Author: steve <steve@designerwallcoverings.com>
Date: Wed Aug 12 08:45:41 2026 -0700
Named build crew (scripts/permit-crew.js) — the linkage layer: subs on any build
Breakthrough (la-research-agent): LADBS PcisPermitDetail is a PUBLIC single GET with
id1/id2/id3 (permit# split on -), not permitNumber. Chain: property -> all permits on
parcel -> GET each detail -> Contractor/Architect/Engineer + CSLB license -> match to
enriched cslb_raw (website/phone). GC on building permit + SUBS on electrical/plumbing/
mech permits = full named crew. Proven: 7143 Tampa named 7-firm crew w/ phones. $0.
Cached in permit_crew. DTD-decided, la-research-discovered, Cody-gated next.
---
scripts/permit-crew.js | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 89 insertions(+)
diff --git a/scripts/permit-crew.js b/scripts/permit-crew.js
new file mode 100644
index 0000000..9820375
--- /dev/null
+++ b/scripts/permit-crew.js
@@ -0,0 +1,89 @@
+// Named build crew (READ-ONLY except a scrape-cache, $0). For a property/build: pull every
+// permit on the parcel, GET each LADBS permit-detail (public, single GET, id1/id2/id3 =
+// permit# split on '-'), extract Contractor/Architect/Engineer + CSLB license, and match the
+// contractor license -> our enriched cslb_raw (website/phone). The GC is on the building
+// permit; the SUBS are the contractors on the electrical/plumbing/mech permits — so this
+// names the full crew. Results cached in permit_crew.
+//
+// Usage: node scripts/permit-crew.js "<address>" | --apn=<APN> | --top=N (top-N current deals)
+import { q, pool } from '../src/db.js';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const kv = Object.fromEntries(process.argv.slice(2).filter(a => a.startsWith('--') && a.includes('=')).map(a => a.slice(2).split('=')));
+const flags = process.argv.slice(2).filter(a => a.startsWith('--') && !a.includes('='));
+const positional = process.argv.slice(2).filter(a => !a.startsWith('--')).join(' ').trim();
+const DELAY = Number(process.env.CREW_DELAY_MS || 1500); // polite to city infra
+
+const TRADE = { 'pi9x-tg5x': 'GC / Building', 'dyxf-7hc4': 'GC / Building', 'e67z-kt2n': 'GC / Building', 'ysqd-apz7': 'Electrical sub', '67is-svtd': 'Plumbing/Mech sub' };
+const nameOf = s => s ? s.split(';')[0].replace(/,\s*,/g, ',').trim() : null;
+const licNum = s => { const m = s && s.match(/Lic\.?\s*No\.?:?\s*([A-Za-z0-9]+)/i); return m ? m[1].replace(/[^0-9]/g, '') : null; };
+function role(html, label) {
+ const m = html.match(new RegExp('>\\s*' + label + '\\s*</td>\\s*<td[^>]*>(.*?)</td>', 'si'));
+ if (!m) return null;
+ const v = m[1].replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/\s+/g, ' ').trim();
+ return v || null;
+}
+
+async function scrapePermit(nbr) {
+ const cached = (await q(`SELECT * FROM permit_crew WHERE permit_nbr=$1`, [nbr])).rows[0];
+ if (cached) return { ...cached, cached: true };
+ const [a, b, c] = String(nbr).split('-');
+ if (!a || !b || !c) return null;
+ let html = '';
+ try {
+ const res = await fetch(`https://www.ladbsservices2.lacity.org/OnlineServices/PermitReport/PcisPermitDetail?id1=${a}&id2=${b}&id3=${c}`,
+ { headers: { 'User-Agent': 'Mozilla/5.0' }, signal: AbortSignal.timeout(15000) });
+ html = await res.text();
+ } catch { return null; }
+ if (/No Permits Match/i.test(html)) { await q(`INSERT INTO permit_crew(permit_nbr,contractor) VALUES($1,'(no match)') ON CONFLICT(permit_nbr) DO NOTHING`, [nbr]); return { permit_nbr: nbr, contractor: '(no match)' }; }
+ const contractor = role(html, 'Contractor'), architect = role(html, 'Architect'), engineer = role(html, 'Engineer'), owner = role(html, 'Owner');
+ const row = { permit_nbr: nbr, contractor, contractor_lic: licNum(contractor), architect, engineer, owner };
+ await q(`INSERT INTO permit_crew(permit_nbr,contractor,contractor_lic,architect,engineer,owner) VALUES($1,$2,$3,$4,$5,$6)
+ ON CONFLICT(permit_nbr) DO UPDATE SET contractor=EXCLUDED.contractor,contractor_lic=EXCLUDED.contractor_lic,architect=EXCLUDED.architect,engineer=EXCLUDED.engineer,owner=EXCLUDED.owner,scraped_at=now()`,
+ [nbr, contractor, row.contractor_lic, architect, engineer, owner]);
+ return row;
+}
+async function cslbMatch(lic) {
+ if (!lic) return null;
+ 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) {
+ 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 > '2018-01-01' ORDER BY issue_date DESC LIMIT 40`, [apn])).rows;
+ console.log(`\n=== BUILD CREW — ${label} (APN ${apn}) ===`);
+ console.log(`${permits.length} permits on parcel (2018+); scraping LADBS detail…`);
+ const seen = new Set();
+ for (const p of permits) {
+ 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 key = (d.contractor_lic || nm) + '|' + TRADE[p.dataset_id];
+ if (seen.has(key)) continue; seen.add(key);
+ const m = await cslbMatch(d.contractor_lic);
+ const web = m?.website ? ` 🌐 ${m.website}` : '';
+ const ph = m?.phone ? ` ☎ ${m.phone}` : '';
+ console.log(` ${(TRADE[p.dataset_id] || p.dataset_id).padEnd(18)} ${(nm || '?').slice(0, 34).padEnd(34)} lic ${d.contractor_lic || '—'}${web}${ph}`);
+ if (d.architect) console.log(` ${''.padEnd(18)} architect: ${nameOf(d.architect)}`);
+ if (d.engineer) console.log(` ${''.padEnd(18)} engineer: ${nameOf(d.engineer)}`);
+ }
+}
+
+async function main() {
+ if (kv.top) {
+ const deals = (await q(`SELECT DISTINCT ON (apn) apn, primary_address FROM la_building_permits_raw
+ WHERE dataset_id='pi9x-tg5x' AND status_desc='Issued' AND permit_type='Bldg-New'
+ AND issue_date > now()-interval '120 days' AND apn IS NOT NULL AND valuation>=1000000
+ ORDER BY apn, valuation DESC`)).rows.slice(0, Number(kv.top));
+ console.log(`Naming crews for top ${deals.length} current new-build deals…`);
+ for (const d of deals) await crewForApn(d.apn, d.primary_address);
+ } else {
+ const apn = kv.apn ? kv.apn.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`, ['%' + positional + '%'])).rows[0]?.apn;
+ if (!apn) { console.error('no property found'); process.exit(1); }
+ const label = kv.apn ? kv.apn : positional;
+ await crewForApn(apn, label);
+ }
+ 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());
← fa030eb Property drill-down (scripts/property.js): full permit/trade
·
back to La Socrata Ingester
·
permit-crew c-fix (Cody gate): era-scope the crew + dates + b291216 →