← back to Rentv 2026
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
M src/pr/adapters/search.jsA src/pr/tools/linkedin-enrich.js
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 2026
·
People + Organizations lists: add sortable 'Added' column (c 74c3fba9 →