[object Object]

← 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

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 →