[object Object]

← 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(/&nbsp;/g, ' ').replace(/&amp;/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 →