[object Object]

← 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

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 →