[object Object]

← back to La Socrata Ingester

add targeted openclaw LinkedIn tool (real-browser Google; site-less deal crews; precision-guarded)

258fd0a8578a3d0c839af03c189b8b96610f5294 · 2026-08-12 10:16:14 -0700 · Steve Abrams

Files touched

Diff

commit 258fd0a8578a3d0c839af03c189b8b96610f5294
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 12 10:16:14 2026 -0700

    add targeted openclaw LinkedIn tool (real-browser Google; site-less deal crews; precision-guarded)
---
 scripts/linkedin-openclaw.js | 110 +++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 110 insertions(+)

diff --git a/scripts/linkedin-openclaw.js b/scripts/linkedin-openclaw.js
new file mode 100644
index 0000000..ac995f8
--- /dev/null
+++ b/scripts/linkedin-openclaw.js
@@ -0,0 +1,110 @@
+// Targeted LinkedIn via openclaw ($0 — real logged-in Chrome, NOT bot-blocked like curl).
+// For the site-less entities that actually MATTER — the named crew of a real deal — drive a
+// real-browser Google search "<name> <city> CA linkedin", read the direct linkedin.com/
+// (company|in) hrefs off the rendered page, accept one only if a name token (>=4) matches the
+// slug, and write it (linkedin_source='openclaw'). This is on-demand + per-entity, not a bulk
+// grind — that's the point: you need LinkedIn for the crew on the deal in front of you, not
+// all 145k licensees. We never request linkedin.com directly; Google renders the result links.
+//
+// Usage:
+//   node scripts/linkedin-openclaw.js --apn=<APN>              # every crew member of a deal
+//   node scripts/linkedin-openclaw.js --license=<CSLB#>        # one licensed entity
+//   node scripts/linkedin-openclaw.js --name="ACME" --city="Los Angeles"   # ad-hoc (no write)
+//   node scripts/linkedin-openclaw.js --top=<N>                # crews of the top-N current deals
+import { execSync } from 'child_process';
+import { q, pool } from '../src/db.js';
+import { getCrew } from './permit-crew.js';
+
+const kv = Object.fromEntries(process.argv.slice(2).filter(a => a.includes('=')).map(a => a.slice(2).split('=')));
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+function oc(args) { return execSync(`openclaw browser ${args}`, { encoding: 'utf8', timeout: 45000, stdio: ['ignore', 'pipe', 'ignore'] }); }
+
+let tab = process.env.OPENCLAW_TAB || null;
+function ensureTab() { if (tab) return; const out = oc('open "about:blank" --timeout 20000'); tab = (out.match(/id:\s*([A-F0-9]+)/i) || [])[1]; }
+function unwrap(s) {                                        // openclaw evaluate returns a JSON-encoded string
+  s = (s || '').trim().split('\n').filter(Boolean).pop() || '[]';
+  try { let v = JSON.parse(s); if (typeof v === 'string') v = JSON.parse(v); return Array.isArray(v) ? v : []; } catch { return []; }
+}
+const norm = u => u.split('#')[0].split('?')[0]                    // drop fragment (#:~:text=…) AND query
+  .replace(/^https?:\/\/[a-z]{2,3}\.linkedin/i, 'https://www.linkedin').replace(/\/$/, '');
+// generic words that must NEVER be the sole basis for a match (too common → false positives)
+const GENERIC = new Set(('inc llc corp co ltd company the and of dba construction builders building build ' +
+  'remodeling remodel design designs development dev group services service enterprises realty real estate ' +
+  'properties property associates custom quality pacific coast best pro elite premier american america california ' +
+  'socal west east north south general home homes new star royal first prime advanced modern classic superior ' +
+  'professional solutions contracting contractor engineering engineers architects landscape landscaping electric ' +
+  'plumbing roofing painting concrete masonry').split(' '));
+function nameTokens(name) {
+  const clean = String(name || '').toLowerCase().replace(/[^a-z0-9 ]/g, ' ').split(/\s+/).filter(Boolean);
+  const big = clean.filter(t => t.length >= 3 && !GENERIC.has(t));  // distinctive tokens
+  const acr = clean.filter(t => t.length === 1).join('');           // spaced initials "M D Q" -> "mdq"
+  return { big, acr: acr.length >= 2 ? acr : null };
+}
+function slugMatches(name, url) {
+  const path = url.replace(/^https?:\/\/[^/]+/i, '');             // slug from PATH only, never domain/fragment
+  const slug = path.toLowerCase().replace(/[^a-z0-9]/g, '');
+  const { big, acr } = nameTokens(name);
+  if (big.some(t => slug.includes(t))) return true;                 // a distinctive word matches
+  if (acr && slug.includes(acr)) return true;                       // the initials-acronym matches
+  return false;
+}
+const EXTRACT_FN = `() => JSON.stringify([...new Set([...document.querySelectorAll('a')].map(a=>a.href).filter(h=>/linkedin\\.com\\/(company|in|pub|school)\\//i.test(h)))])`;
+async function searchLinkedin(name, city) {
+  const url = 'https://www.google.com/search?q=' + encodeURIComponent(`${name} ${city || ''} CA linkedin`);
+  let t;
+  try { t = (oc(`open ${JSON.stringify(url)} --timeout 30000`).match(/id:\s*([A-F0-9]+)/i) || [])[1]; } catch { return null; }
+  if (!t) return null;
+  let arr = [];
+  try {
+    for (let i = 0; i < 4 && !arr.length; i++) {                     // retry until result anchors render
+      await sleep(1500);
+      try { arr = unwrap(oc(`evaluate --target-id ${t} --fn ${JSON.stringify(EXTRACT_FN)}`)); } catch { arr = []; }
+    }
+  } finally { try { oc(`close --target-id ${t}`); } catch {} }       // don't leak tabs
+    const cands = [...new Set(arr.map(norm))]
+    .filter(u => !/\/pub\/dir\//i.test(u) && slugMatches(name, u));   // reject people-directory pages
+  return cands.find(u => /\/company\//i.test(u)) || cands[0] || null;   // prefer the company page
+}
+async function writeLic(lic, link) {
+  await q(`UPDATE cslb_raw SET linkedin=$2, linkedin_source='openclaw', contacts_enriched_at=COALESCE(contacts_enriched_at,now()) WHERE "LicenseNo"=$1`, [lic, link]);
+}
+async function doEntity(row) {
+  if (row.linkedin) { console.log(`  ✓ ${row.BusinessName.slice(0, 30).padEnd(30)} already has ${row.linkedin} (${row.linkedin_source || '?'})`); return 0; }
+  const link = await searchLinkedin(row.BusinessName, row.City);
+  if (link) { await writeLic(row.LicenseNo, link); console.log(`  → ${row.BusinessName.slice(0, 30).padEnd(30)} ${link}`); return 1; }
+  console.log(`  · ${row.BusinessName.slice(0, 30).padEnd(30)} (no confident match)`); return 0;
+}
+async function rowByLic(lic) {
+  return (await q(`SELECT "LicenseNo","BusinessName","City",linkedin,linkedin_source FROM cslb_raw WHERE "LicenseNo"=$1`, [lic])).rows[0];
+}
+
+async function main() {
+  let found = 0;
+  if (kv.name) {
+    const link = await searchLinkedin(kv.name, kv.city || "Los Angeles"); if(link) found++;
+    console.log(link ? `→ ${kv.name}: ${link}` : `· ${kv.name}: no confident match`);
+  } else if (kv.license) {
+    const r = await rowByLic(kv.license.replace(/[^0-9]/g, ''));
+    if (!r) { console.error('no CSLB row for license ' + kv.license); process.exit(1); }
+    found += await doEntity(r);
+  } else if (kv.apn || kv.top) {
+    const apns = kv.apn ? [kv.apn.replace(/[^0-9]/g, '')]
+      : (await q(`SELECT DISTINCT ON (apn) apn 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)).map(r => r.apn);
+    for (const apn of apns) {
+      console.log(`\n=== DEAL APN ${apn} — naming crew LinkedIn ===`);
+      const crew = await getCrew(apn);                 // uses cached LADBS detail where available
+      const seen = new Set();
+      for (const c of crew.crew) {
+        if (!c.lic || seen.has(c.lic)) continue; seen.add(c.lic);
+        const r = await rowByLic(c.lic);
+        if (r) found += await doEntity(r); else console.log(`  · ${c.contractor} (lic ${c.lic}) not in CSLB`);
+        await sleep(1500);
+      }
+    }
+  } 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());

← da8cbba yoloforever c1: disk-space preflight in run-refresh.sh (skip  ·  back to La Socrata Ingester  ·  property.js: one command → property + crew + each member's L 952a377 →