[object Object]

← back to La Socrata Ingester

permit-crew c-fix (Cody gate): era-scope the crew + dates + entity decode + no-match flag

b291216655c6d77b3ba570d8efa3a173141b06d3 · 2026-08-12 08:48:57 -0700 · steve

Cody: merging 2018+ permits presented stale-era tradespeople as current crew. Now
anchors to the latest building permit, includes only ~18mo era (Woodlake correctly
drops 2 older permits), shows each permit date, flags GC-appears-as-sub cross-role,
distinguishes 'no CSLB match' from not-tried, decodes HTML entities. DTD final: SHIP.

Files touched

Diff

commit b291216655c6d77b3ba570d8efa3a173141b06d3
Author: steve <steve@designerwallcoverings.com>
Date:   Wed Aug 12 08:48:57 2026 -0700

    permit-crew c-fix (Cody gate): era-scope the crew + dates + entity decode + no-match flag
    
    Cody: merging 2018+ permits presented stale-era tradespeople as current crew. Now
    anchors to the latest building permit, includes only ~18mo era (Woodlake correctly
    drops 2 older permits), shows each permit date, flags GC-appears-as-sub cross-role,
    distinguishes 'no CSLB match' from not-tried, decodes HTML entities. DTD final: SHIP.
---
 scripts/permit-crew.js | 34 ++++++++++++++++++++++++----------
 1 file changed, 24 insertions(+), 10 deletions(-)

diff --git a/scripts/permit-crew.js b/scripts/permit-crew.js
index 9820375..ea8fca8 100644
--- a/scripts/permit-crew.js
+++ b/scripts/permit-crew.js
@@ -14,12 +14,15 @@ const positional = process.argv.slice(2).filter(a => !a.startsWith('--')).join('
 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 decode = s => String(s)
+  .replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&#0?39;|&rsquo;|&apos;/g, "'")
+  .replace(/&quot;/g, '"').replace(/&#(\d+);/g, (_, n) => String.fromCharCode(+n)).replace(/&[a-z]+;/gi, ' ');
 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();
+  const v = decode(m[1].replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim();
   return v || null;
 }
 
@@ -49,24 +52,35 @@ async function cslbMatch(lic) {
 
 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;
+    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(`${permits.length} permits on parcel (2018+); scraping LADBS detail…`);
-  const seen = new Set();
-  for (const p of permits) {
+  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 = {};
+  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 key = (d.contractor_lic || nm) + '|' + TRADE[p.dataset_id];
+    const 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}` : '';
-    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)}`);
+    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)}`);
   }
+  // 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(', ')}`);
 }
 
 async function main() {

← 1cd9469 Named build crew (scripts/permit-crew.js) — the linkage laye  ·  back to La Socrata Ingester  ·  Unified property drill-down: address -> property + trade str c9ccae5 →