← back to La Socrata Ingester
property.js: one command → property + crew + each member's LinkedIn/email/phone (--linkedin opt-in openclaw fill)
952a377aab6d3b7907f5ab52f5ea2a5deeb2a247 · 2026-08-12 10:20:24 -0700 · Steve Abrams
Files touched
M scripts/linkedin-openclaw.jsM scripts/permit-crew.jsM scripts/property.js
Diff
commit 952a377aab6d3b7907f5ab52f5ea2a5deeb2a247
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 12 10:20:24 2026 -0700
property.js: one command → property + crew + each member's LinkedIn/email/phone (--linkedin opt-in openclaw fill)
---
scripts/linkedin-openclaw.js | 6 +++++-
scripts/permit-crew.js | 8 ++++++--
scripts/property.js | 24 ++++++++++++++++++------
3 files changed, 29 insertions(+), 9 deletions(-)
diff --git a/scripts/linkedin-openclaw.js b/scripts/linkedin-openclaw.js
index ac995f8..1d713f1 100644
--- a/scripts/linkedin-openclaw.js
+++ b/scripts/linkedin-openclaw.js
@@ -107,4 +107,8 @@ async function main() {
} else { console.error('usage: --apn=<APN> | --license=<#> | --name="X" --city="Y" | --top=<N>'); process.exit(1); }
console.log(`\nDone — ${found} LinkedIn profile(s) found. $0 (openclaw real browser + local).`);
}
-main().catch(e => { console.error('linkedin-openclaw error:', e.message); process.exitCode = 1; }).finally(() => pool.end());
+export { searchLinkedin, writeLic };
+// run the CLI only when invoked directly (not when imported by property.js)
+if (process.argv[1] && /linkedin-openclaw\.js$/.test(process.argv[1])) {
+ main().catch(e => { console.error('linkedin-openclaw error:', e.message); process.exitCode = 1; }).finally(() => pool.end());
+}
diff --git a/scripts/permit-crew.js b/scripts/permit-crew.js
index f5144ee..693f63d 100644
--- a/scripts/permit-crew.js
+++ b/scripts/permit-crew.js
@@ -47,7 +47,8 @@ async function scrapePermit(nbr) {
}
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;
+ return (await q(`SELECT "BusinessName" name, "City" city, website, email, linkedin, linkedin_source,
+ COALESCE(NULLIF("BusinessPhone",''), phone_web) phone FROM cslb_raw WHERE "LicenseNo"=$1 LIMIT 1`, [lic])).rows[0] || null;
}
// Returns era-scoped named crew for a parcel as structured data (importable).
@@ -68,7 +69,10 @@ export async function getCrew(apn) {
if (seen.has(key)) continue; seen.add(key);
(roleByName[nm] ||= new Set()).add(trade.split(' ')[0]);
const m = await cslbMatch(d.contractor_lic);
- 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) });
+ crew.push({ issued: p.issued, trade, contractor: nm, lic: d.contractor_lic, city: m?.city || null,
+ phone: m?.phone || null, website: m?.website || null, email: m?.email || null,
+ linkedin: m?.linkedin || null, linkedin_source: m?.linkedin_source || 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 };
diff --git a/scripts/property.js b/scripts/property.js
index 2614754..ae6406f 100644
--- a/scripts/property.js
+++ b/scripts/property.js
@@ -7,12 +7,14 @@
// 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';
+import { searchLinkedin, writeLic } from './linkedin-openclaw.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 LINKEDIN = process.argv.includes('--linkedin'); // opt-in: openclaw-fill missing LinkedIn (slower, $0)
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() {
- const arg = process.argv.slice(2).join(' ').trim();
+ const arg = process.argv.slice(2).filter(a => !a.startsWith('--')).join(' ').trim();
if (!arg) { console.error('usage: node scripts/property.js "<address>" | <APN>'); process.exit(1); }
const isApn = /^\d{7,10}$/.test(arg.replace(/[^0-9]/g, '')) && !/\s/.test(arg);
@@ -56,12 +58,22 @@ async function main() {
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`);
+ console.log(` era since ${r.eraStart} · ${r.inEra} permits · ${r.olderExcluded} older excluded${LINKEDIN ? ' · --linkedin: filling missing profiles via openclaw…' : ''}`);
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}`);
+ // opt-in: find a missing LinkedIn live via the real browser, and persist it
+ if (LINKEDIN && c.lic && !c.linkedin) {
+ try { const li = await searchLinkedin(c.contractor, c.city); if (li) { await writeLic(c.lic, li); c.linkedin = li; c.linkedin_source = 'openclaw'; } } catch {}
+ }
+ console.log(` ${c.issued} ${c.trade.padEnd(18)} ${(c.contractor || '?').slice(0, 40)}`);
+ const bits = [`lic ${c.lic || '—'}`];
+ if (c.phone) bits.push(`☎ ${c.phone}`);
+ if (c.email) bits.push(`✉ ${c.email}`);
+ if (c.website) bits.push(`🌐 ${c.website}`);
+ if (c.linkedin) bits.push(`in ${c.linkedin}${c.linkedin_source === 'openclaw' ? '' : ` (${c.linkedin_source || 'site'})`}`);
+ else if (!c.cslb) bits.push('(no CSLB match)');
+ console.log(` ${bits.join(' · ')}`);
+ if (c.architect) console.log(` architect: ${c.architect}`);
+ if (c.engineer) console.log(` engineer: ${c.engineer}`);
}
if (r.multiRole.length) console.log(` ⚠ same firm across roles (likely GC pulled the sub-permit): ${r.multiRole.join(', ')}`);
console.log('');
← 258fd0a add targeted openclaw LinkedIn tool (real-browser Google; si
·
back to La Socrata Ingester
·
chore: refactor (ERA_DAYS const, indent), minor version bump f06221a →