[object Object]

← back to Rentv

PR intel: LinkedIn enrichment via Brave search (local, key stays on Mac2)

1edf0c45492186033bb4abb67125177f620e6648 · 2026-08-05 14:20:59 -0700 · Steve

- src/pr/tools/linkedin-enrich.js: locates public LinkedIn URLs for CRM
  contacts lacking one, via the authorized search adapter (locatePerson);
  runs locally reading the Brave key from rentv/.env, writes results to prod
  through PUT /api/pr/people/:id as found_uncorroborated. Slug<->name guard
  rejects wrong-person namesake matches. Media/PR contacts prioritized.
- search.js: accept fleet-standard BRAVE_SEARCH_API_KEY / EXA_API_KEY names
  as fallbacks so secrets-manager routing works without a PR_-prefixed alias.
- $0 on Brave free tier (2k queries/mo); TOS-safe (search-indexed URLs only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 1edf0c45492186033bb4abb67125177f620e6648
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Aug 5 14:20:59 2026 -0700

    PR intel: LinkedIn enrichment via Brave search (local, key stays on Mac2)
    
    - src/pr/tools/linkedin-enrich.js: locates public LinkedIn URLs for CRM
      contacts lacking one, via the authorized search adapter (locatePerson);
      runs locally reading the Brave key from rentv/.env, writes results to prod
      through PUT /api/pr/people/:id as found_uncorroborated. Slug<->name guard
      rejects wrong-person namesake matches. Media/PR contacts prioritized.
    - search.js: accept fleet-standard BRAVE_SEARCH_API_KEY / EXA_API_KEY names
      as fallbacks so secrets-manager routing works without a PR_-prefixed alias.
    - $0 on Brave free tier (2k queries/mo); TOS-safe (search-indexed URLs only).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 src/pr/adapters/search.js       | 12 ++++--
 src/pr/tools/linkedin-enrich.js | 90 +++++++++++++++++++++++++++++++++++++++++
 2 files changed, 98 insertions(+), 4 deletions(-)

diff --git a/src/pr/adapters/search.js b/src/pr/adapters/search.js
index ccbe29bc..63359f35 100644
--- a/src/pr/adapters/search.js
+++ b/src/pr/adapters/search.js
@@ -7,16 +7,20 @@
 // call returns an empty report with the missing-env note (jobs record this and move on).
 const { throttled, report, register } = require('./index');
 
+// Accept the fleet-standard key names as fallbacks (BRAVE_SEARCH_API_KEY / EXA_API_KEY)
+// so the secrets-manager routing works without a PR_-prefixed alias.
+const EXA_KEY = process.env.PR_EXA_API_KEY || process.env.EXA_API_KEY;
+const BRAVE_KEY = process.env.PR_BRAVE_API_KEY || process.env.BRAVE_SEARCH_API_KEY;
 function backend() {
-  if (process.env.PR_EXA_API_KEY) return 'exa';
-  if (process.env.PR_BRAVE_API_KEY) return 'brave';
+  if (EXA_KEY) return 'exa';
+  if (BRAVE_KEY) return 'brave';
   return null;
 }
 
 async function exaSearch(query, { numResults = 10, cursor } = {}) {
   const r = await fetch('https://api.exa.ai/search', {
     method: 'POST',
-    headers: { 'x-api-key': process.env.PR_EXA_API_KEY, 'Content-Type': 'application/json' },
+    headers: { 'x-api-key': EXA_KEY, 'Content-Type': 'application/json' },
     body: JSON.stringify({ query, numResults, type: 'keyword', ...(cursor || {}) }),
     signal: AbortSignal.timeout(20000),
   });
@@ -28,7 +32,7 @@ async function exaSearch(query, { numResults = 10, cursor } = {}) {
 async function braveSearch(query, { numResults = 10, cursor } = {}) {
   const offset = cursor && cursor.offset ? cursor.offset : 0;
   const r = await fetch(`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${numResults}&offset=${offset}`, {
-    headers: { 'X-Subscription-Token': process.env.PR_BRAVE_API_KEY, Accept: 'application/json' },
+    headers: { 'X-Subscription-Token': BRAVE_KEY, Accept: 'application/json' },
     signal: AbortSignal.timeout(20000),
   });
   if (!r.ok) throw new Error('brave ' + r.status + ': ' + (await r.text()).slice(0, 200));
diff --git a/src/pr/tools/linkedin-enrich.js b/src/pr/tools/linkedin-enrich.js
new file mode 100644
index 00000000..8ad12f13
--- /dev/null
+++ b/src/pr/tools/linkedin-enrich.js
@@ -0,0 +1,90 @@
+'use strict';
+// LinkedIn enrichment — locate the PUBLIC LinkedIn URL for CRM contacts that don't
+// have one, via the authorized search API (Brave). Runs LOCALLY on Mac2 (the Brave
+// key lives in ~/Projects/rentv/.env and never leaves the machine); reads the target
+// people from the PROD API and writes the located URL back through the audited
+// PUT /api/pr/people/:id (stored as found_uncorroborated — never a "verified" title).
+//
+// TOS-safe: the linkedin adapter only locates a publicly indexed URL through the
+// search API. No logged-in scraping, no browser automation, no cookies.
+//
+// Usage: node src/pr/tools/linkedin-enrich.js [maxPeople]
+// Env: PR_API (default prod), PR_AUTH (user:pass), LI_THROTTLE_MS (default 1200),
+//      LI_MEDIA_ONLY=1 (default: media/PR org contacts first)
+const fs = require('fs');
+const path = require('path');
+
+// Load the Brave key from rentv/.env BEFORE requiring the adapters (search.js reads it at load).
+try {
+  const envTxt = fs.readFileSync(path.join(__dirname, '../../../.env'), 'utf8');
+  for (const line of envTxt.split('\n')) {
+    const m = line.match(/^\s*(BRAVE_SEARCH_API_KEY|PR_BRAVE_API_KEY|PR_EXA_API_KEY|EXA_API_KEY)\s*=\s*(.+)\s*$/);
+    if (m && !process.env[m[1]]) process.env[m[1]] = m[2].trim();
+  }
+} catch { /* env optional */ }
+
+const linkedin = require('../adapters/linkedin');
+const search = require('../adapters/search');
+
+const API = process.env.PR_API || 'https://rentv.agentabrams.com';
+const AUTH = 'Basic ' + Buffer.from(process.env.PR_AUTH || 'admin:DW2024!').toString('base64');
+const THROTTLE = Number(process.env.LI_THROTTLE_MS || 1200); // Brave free tier ≈ 1 req/s
+const MAX = Number(process.argv[2] || process.env.LI_MAX || 50);
+const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
+
+async function api(p, opts = {}) {
+  const r = await fetch(API + '/api/pr' + p, {
+    ...opts,
+    headers: { Authorization: AUTH, 'Content-Type': 'application/json', ...(opts.headers || {}) },
+    body: opts.body ? JSON.stringify(opts.body) : undefined,
+  });
+  if (!r.ok) throw new Error(p + ' → ' + r.status + ': ' + (await r.text()).slice(0, 120));
+  return r.json();
+}
+
+async function main() {
+  if (!search.configured()) { console.error('no search key (BRAVE_SEARCH_API_KEY) in rentv/.env — run: node ~/Projects/secrets-manager/cli.js sync'); process.exit(1); }
+  // no-LinkedIn people; media/PR contacts first (they're the outreach targets)
+  const orgTypes = "trade_publication,association,pr_agency,marketing_agency,public_affairs";
+  let targets = [];
+  try {
+    const media = await api(`/people?linkedin_status=none&organization_type=${encodeURIComponent(orgTypes)}&limit=${MAX}`);
+    targets = media.rows || media.people || [];
+  } catch { /* fall through */ }
+  if (!process.env.LI_MEDIA_ONLY && targets.length < MAX) {
+    const more = await api(`/people?linkedin_status=none&limit=${MAX - targets.length}`);
+    const seen = new Set(targets.map((t) => t.id));
+    for (const p of (more.rows || more.people || [])) if (!seen.has(p.id)) targets.push(p);
+  }
+  console.log(`[linkedin-enrich] backend=${search.configured()} targets=${targets.length} (Brave free tier ≈ $0)`);
+  let found = 0, queries = 0, miss = 0;
+  for (const p of targets.slice(0, MAX)) {
+    const orgName = p.organization_name || p.organization || p.org_display_name || '';
+    if (!p.full_name) continue;
+    queries++;
+    try {
+      const rep = await linkedin.locatePerson({ full_name: p.full_name, organization_name: orgName });
+      const hit = (rep.items || [])[0];
+      // Guard against wrong-person matches: the /in/ slug must share a name token with
+      // the person (Brave's top result is often a namesake). Reject obvious mismatches
+      // rather than store a wrong profile.
+      const slug = ((hit && hit.linkedin_url) || '').match(/\/in\/([^/?#]+)/);
+      const toks = String(p.full_name).toLowerCase().split(/\s+/).map((t) => t.replace(/[^a-z]/g, '')).filter((t) => t.length > 2);
+      const slugOk = slug && toks.some((t) => slug[1].toLowerCase().includes(t));
+      if (hit && hit.linkedin_url && slugOk) {
+        await api('/people/' + p.id, { method: 'PUT', body: {
+          linkedin_url: hit.linkedin_url, linkedin_status: 'found_uncorroborated',
+          linkedin_indexed_title: hit.indexed_title || null, linkedin_indexed_snippet: hit.indexed_snippet || null,
+        } });
+        found++;
+        console.log(`  ✓ ${p.full_name} (${orgName}) → ${hit.linkedin_url}`);
+      } else { miss++; }
+    } catch (e) { miss++; console.log(`  · ${p.full_name}: ${e.message.slice(0, 60)}`); }
+    sleep(THROTTLE);
+  }
+  const cost = queries * 0; // Brave free plan (2k/mo). Paid plan would be ~$0.003-0.005/query.
+  console.log(`[linkedin-enrich] DONE — located ${found}/${queries} · misses ${miss} · cost ~$${cost.toFixed(2)} (Brave free tier)`);
+  process.exit(0);
+}
+
+main().catch((e) => { console.error('fatal:', e.message); process.exit(1); });

← 45863c90 RENTV: incremental corpus-crawl wrapper (LOCK-aware, cursor-  ·  back to Rentv  ·  People + Organizations lists: add sortable 'Added' column (c 74c3fba9 →