← 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(/ /g, ' ').replace(/&/g, '&').replace(/�?39;|’|'/g, "'")
+ .replace(/"/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(/ /g, ' ').replace(/&/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 →