← back to Rentv 2026
pr-intelligence: discover-people-linkedin job + adapter (permitted site:linkedin.com/in public-index query) — closes the contact-coverage gap; candidates stored search_indexed/found_uncorroborated
fbe5b696a8be21b4f5c98f954c6226a45f7dc115 · 2026-07-30 15:14:14 -0700 · Steve Abrams
Files touched
M server.jsM src/pr/adapters/linkedin.jsM src/pr/jobs/index.js
Diff
commit fbe5b696a8be21b4f5c98f954c6226a45f7dc115
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 30 15:14:14 2026 -0700
pr-intelligence: discover-people-linkedin job + adapter (permitted site:linkedin.com/in public-index query) — closes the contact-coverage gap; candidates stored search_indexed/found_uncorroborated
---
server.js | 44 +++++++++++++++++++++++++++++++++
src/pr/adapters/linkedin.js | 36 +++++++++++++++++++++++++++
src/pr/jobs/index.js | 59 +++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 139 insertions(+)
diff --git a/server.js b/server.js
index 22d33307..a67a7e42 100644
--- a/server.js
+++ b/server.js
@@ -346,6 +346,50 @@ app.get('/api/brokers', adminOnly, (req, res) => usreProxy('/api/brokers', req,
app.get('/api/firms', adminOnly, (req, res) => usreProxy('/api/firms', req, res));
app.get('/api/sublease', adminOnly, (req, res) => usreProxy('/api/sublease', req, res));
+// ── Summit Leads (growth idea #1 + #3): turn the CA registry (382k brokers / 34k firms)
+// into ranked, exportable summit INVITE lists + SPONSOR-TARGET lists. metro fans out to
+// its member cities; commercial=1 injects q=commercial as a proxy for CRE specialty (the
+// raw DRE roster has no specialty field); firms rank by agent_count (a marketing-budget /
+// sponsor-priority proxy). Admin-only. HONEST LIMIT: the roster carries NO email/phone/
+// domain, so this is a TARGETING / prospect-list tool (export → LinkedIn / mail-match /
+// CRM), not a send list, and an ad-signal scan still needs each firm's domain resolved. ──
+const SUMMIT_METROS = {
+ 'Los Angeles': ['LOS ANGELES', 'BEVERLY HILLS', 'SANTA MONICA', 'CENTURY CITY', 'PASADENA', 'GLENDALE', 'EL SEGUNDO'],
+ 'Orange County': ['IRVINE', 'NEWPORT BEACH', 'COSTA MESA', 'ANAHEIM', 'SANTA ANA'],
+ 'San Diego': ['SAN DIEGO', 'CARLSBAD', 'LA JOLLA', 'DEL MAR'],
+ 'Inland Empire': ['RIVERSIDE', 'ONTARIO', 'SAN BERNARDINO', 'RANCHO CUCAMONGA'],
+ 'Bay Area': ['SAN FRANCISCO', 'OAKLAND', 'SAN JOSE', 'PALO ALTO', 'WALNUT CREEK', 'SAN RAMON'],
+};
+async function usreFetch(upstream, params) {
+ const qs = new URLSearchParams({ state: 'CA', limit: '500', ...params });
+ const r = await fetch(`${USRE}${upstream}?${qs}`, { headers: { Authorization: USRE_AUTH }, signal: AbortSignal.timeout(12000) });
+ if (!r.ok) throw new Error('upstream ' + r.status);
+ const j = await r.json();
+ return Array.isArray(j) ? j : asArr(j.rows || j.firms || j.brokers);
+}
+app.get('/api/summit/metros', adminOnly, (_q, res) => res.json({ metros: Object.keys(SUMMIT_METROS) }));
+app.get('/api/summit/leads', adminOnly, async (req, res) => {
+ try {
+ const type = req.query.type === 'brokers' ? 'brokers' : 'firms';
+ const upstream = type === 'brokers' ? '/api/brokers' : '/api/firms';
+ const commercial = String(req.query.commercial || '') === '1';
+ const q = String(req.query.q || '').trim().slice(0, 60);
+ const minAgents = Math.max(0, parseInt(req.query.minAgents, 10) || 0);
+ const limit = Math.min(500, Math.max(10, parseInt(req.query.limit, 10) || 200));
+ const metro = SUMMIT_METROS[req.query.metro] ? req.query.metro : '';
+ const cities = metro ? SUMMIT_METROS[metro] : ['']; // '' = whole state
+ const base = {};
+ if (q) base.q = q; else if (commercial) base.q = 'commercial';
+ const batches = await Promise.all(cities.map(c => usreFetch(upstream, c ? { ...base, city: c } : base).catch(() => [])));
+ const seen = new Set(); let rows = [];
+ for (const b of batches) for (const row of b) { if (row && !seen.has(row.id)) { seen.add(row.id); rows.push(row); } }
+ if (type === 'firms' && minAgents) rows = rows.filter(r => (r.agent_count || 0) >= minAgents);
+ if (type === 'firms') rows.sort((a, b) => (b.agent_count || 0) - (a.agent_count || 0));
+ res.set('Cache-Control', 'private, max-age=60');
+ res.json({ type, metro: metro || 'All California', commercial: commercial && !q, total: rows.length, rows: rows.slice(0, limit) });
+ } catch (e) { res.status(502).json({ rows: [], total: 0, error: 'registry unavailable' }); }
+});
+
// Admin write-forwarder → usre backend (JSON body). Same-origin so /desk admin can add/withdraw
// sublease listings; the upstream is basic-auth-gated and loopback-only, so we inject the auth.
async function usreWrite(method, upstream, req, res) {
diff --git a/src/pr/adapters/linkedin.js b/src/pr/adapters/linkedin.js
index 90106d2a..24984fff 100644
--- a/src/pr/adapters/linkedin.js
+++ b/src/pr/adapters/linkedin.js
@@ -37,6 +37,42 @@ const adapter = register({
});
},
+ /**
+ * Discover comms/marketing PEOPLE at an organization via the publicly indexed
+ * site:linkedin.com/in query (the spec's permitted matrix query). Returns candidate
+ * people parsed from result titles ("Name - Title - Company | LinkedIn"), each
+ * labeled search-indexed / found_uncorroborated — never verified.
+ */
+ async discoverCommsPeople({ organization_name }) {
+ const q = `site:linkedin.com/in ("public relations" OR communications OR marketing) "${organization_name}"`;
+ const rep = await search.search(q, { numResults: 8 });
+ const orgLower = organization_name.toLowerCase();
+ const items = [];
+ for (const x of rep.items) {
+ const url = normalizeLinkedInUrl(x.url);
+ if (!url || !url.includes('/in/')) continue;
+ // Guard against namesakes at other firms: org name must appear in title or snippet.
+ const blob = ((x.title || '') + ' ' + (x.snippet || '')).toLowerCase();
+ if (!blob.includes(orgLower)) continue;
+ // "Jane Smith - Director of Communications - CBRE | LinkedIn" (en/em dashes too)
+ const clean = (x.title || '').replace(/\s*[|·]\s*LinkedIn.*$/i, '').trim();
+ const parts = clean.split(/\s+[-–—]\s+/);
+ const full_name = (parts[0] || '').trim();
+ if (!/^[A-Z][a-zA-Z'’.-]+(\s+[A-Z][a-zA-Z'’.-]+){1,3}$/.test(full_name)) continue;
+ const indexed_title = parts.length > 1 ? parts.slice(1).join(' — ').trim() : null;
+ items.push({
+ full_name, linkedin_url: url,
+ indexed_title, indexed_snippet: (x.snippet || '').slice(0, 400),
+ source_url: x.url, status_recommendation: 'found_uncorroborated',
+ });
+ }
+ return report({
+ provider: 'linkedin(search)', query: q, errors: rep.errors,
+ confidence_recommendation: 35, items,
+ terms_note: adapter.sourceMeta().license_or_usage_note,
+ });
+ },
+
/** Locate a company page URL. */
async locateCompany({ organization_name }) {
const rep = await search.findLinkedIn({ companyName: organization_name, kind: 'company' });
diff --git a/src/pr/jobs/index.js b/src/pr/jobs/index.js
index 610dddb4..da17598d 100644
--- a/src/pr/jobs/index.js
+++ b/src/pr/jobs/index.js
@@ -347,6 +347,65 @@ const handlers = {
return { offset: nextOffset, linked };
},
+ /**
+ * discover-people-linkedin: for orgs lacking a communications/marketing/agency
+ * contact (priority DESC), run the permitted site:linkedin.com/in matrix query and
+ * store candidate people — search-indexed, found_uncorroborated, needs_verification.
+ * Payload: { state='CA', limit=40 }. Checkpoint: { offset } — resumable + re-runnable.
+ */
+ async 'discover-people-linkedin'(job) {
+ const li = adapters.getAdapter('linkedin');
+ if (!li.configured()) return { skipped: 'search API unconfigured (PR_EXA_API_KEY or PR_BRAVE_API_KEY)' };
+ const state = job.payload.state || 'CA';
+ const limit = Math.min(Number(job.payload.limit) || 40, 100);
+ const offset = (job.checkpoint || {}).offset || 0;
+ const orgs = await db.rows(
+ `SELECT id, display_name, state_presence, metros FROM pr_organizations o
+ WHERE $1 = ANY(state_presence) AND lifecycle_status NOT IN ('duplicate','archived')
+ AND NOT EXISTS (SELECT 1 FROM pr_people p WHERE p.organization_id = o.id
+ AND p.department IN ('communications','marketing','agency','bd')
+ AND p.lifecycle_status NOT IN ('duplicate','suppressed','wrong_person','left_company'))
+ ORDER BY priority_score DESC, id LIMIT $2 OFFSET $3`, [state, limit, offset]);
+ let created = 0, queried = 0;
+ const ck = { offset };
+ for (const org of orgs) {
+ const rep = await li.discoverCommsPeople({ organization_name: org.display_name });
+ queried++;
+ if (rep.errors.some((e) => e.code === 'UNCONFIGURED')) return { ...ck, error: 'search unconfigured mid-run' };
+ for (const cand of rep.items.slice(0, 3)) {
+ const r = await people.create({
+ full_name: cand.full_name,
+ exact_title: cand.indexed_title, // classified for role; verification stays search-level
+ organization_id: org.id,
+ state, metro: (org.metros || [])[0] || null,
+ linkedin_url: cand.linkedin_url,
+ linkedin_status: 'found_uncorroborated',
+ linkedin_indexed_title: cand.indexed_title,
+ linkedin_indexed_snippet: cand.indexed_snippet,
+ verification_status: 'search_indexed',
+ lifecycle_status: 'needs_verification', // human review before anything further
+ }, {
+ evidence: {
+ source: {
+ source_type: 'search_api', source_name: 'LinkedIn public-index discovery: ' + org.display_name,
+ url: cand.source_url, short_excerpt: cand.indexed_snippet,
+ is_primary_source: false, license_or_usage_note: li.sourceMeta().license_or_usage_note,
+ adapter: 'linkedin',
+ },
+ fields: { organization: 35, exact_title: cand.indexed_title ? 35 : 0, linkedin_url: 40 },
+ },
+ actor: 'system:discover-people-linkedin',
+ });
+ if (r.created) created++;
+ }
+ ck.offset = offset + queried;
+ await db.query('UPDATE pr_jobs SET checkpoint=$2 WHERE id=$1', [job.id, JSON.stringify(ck)]);
+ }
+ // more orgs remain → queue the next page
+ if (orgs.length === limit) await enqueue('discover-people-linkedin', { state, limit, page: ck.offset }, { priority: 6 });
+ return { ...ck, queried, created };
+ },
+
/** refresh-stale-records: 90d high-priority / 180d others → mark stale + queue re-verify. */
async 'refresh-stale-records'() {
const hi = Number(await settings.get('refresh_days_high', 90));
← 697cc25b growth: add 7 concrete conference lead-gen tactics to the RE
·
back to Rentv 2026
·
pr-intelligence: alias-qualify all whole-row reads (fleet pr 1bcdcd8d →