/** * Personal LinkedIn Finder * * Finds linkedin.com/in/person-name (personal profile) * NOT linkedin.com/company/ (company page — already have that) * * Methods in priority order: * 1. Google search: "name" "company" site:linkedin.com/in * 2. Company's LinkedIn people page scrape * 3. Hunter.io linkedin_url field (sometimes returned) * * MANDATORY — every qualified lead must have a LinkedIn attempt. */ import { searchCompanies, SerperResult } from "../providers/serper"; import { serperLimiter } from "../../shared/utils/rate-limiter"; import { logger } from "../../shared/utils/logger"; import axios from "axios"; import { getEnv } from "../../shared/config/env"; export interface PersonalLinkedIn { url: string; // linkedin.com/in/john-smith-abc123 confidence: number; // how sure we are this is the right person source: "google_search" | "company_people_page" | "hunter_field"; verified: boolean; // URL format is valid and accessible } /** * Find personal LinkedIn profile for a decision maker. * Tries multiple methods. Returns null if all fail (not an error — just LinkedIn-not-found). */ export async function findPersonalLinkedIn( fullName: string, companyName: string, companyDomain: string, companyLinkedInUrl: string | null ): Promise { // Method 1: Google search (highest accuracy) const googleResult = await searchViaGoogle(fullName, companyName); if (googleResult) return googleResult; // Method 2: From company LinkedIn people page (already scraped) if (companyLinkedInUrl) { const peopleResult = await searchViaPeoplePage(fullName, companyLinkedInUrl); if (peopleResult) return peopleResult; } logger.info({ fullName, companyName }, "LinkedIn personal not found — all methods tried"); return null; } // ─── Method 1: Google Search ───────────────────────────────── async function searchViaGoogle( fullName: string, companyName: string ): Promise { try { await serperLimiter.consume("serper"); const env = getEnv(); const query = `"${fullName}" "${companyName}" site:linkedin.com/in`; const response = await axios.post( "https://google.serper.dev/search", { q: query, num: 5 }, { headers: { "X-API-KEY": env.SERPER_API_KEY, "Content-Type": "application/json", }, timeout: 8_000, } ); const organic = response.data?.organic ?? []; for (const result of organic) { const url = result.link; if (!isLinkedInPersonalUrl(url)) continue; // Verify the result mentions both name and company const snippet = (result.snippet ?? "").toLowerCase(); const title = (result.title ?? "").toLowerCase(); const combined = `${snippet} ${title}`; const nameParts = fullName.toLowerCase().split(/\s+/); const hasName = nameParts.some(part => part.length > 2 && combined.includes(part)); const hasCompany = companyName.toLowerCase().split(/\s+/).some( part => part.length > 3 && combined.includes(part) ); if (hasName) { return { url: cleanLinkedInUrl(url), confidence: hasCompany ? 0.92 : 0.70, source: "google_search", verified: true, }; } } return null; } catch (err) { logger.warn({ fullName, err }, "Google LinkedIn search failed"); return null; } } // ─── Method 2: Company People Page ────────────────────────── async function searchViaPeoplePage( fullName: string, companyLinkedInUrl: string ): Promise { try { await serperLimiter.consume("serper"); const env = getEnv(); // Search Google for the person's name on the company's LinkedIn const companySlug = companyLinkedInUrl.match(/company\/([^/?]+)/)?.[1]; if (!companySlug) return null; const query = `"${fullName}" site:linkedin.com/in ${companySlug}`; const response = await axios.post( "https://google.serper.dev/search", { q: query, num: 3 }, { headers: { "X-API-KEY": env.SERPER_API_KEY, "Content-Type": "application/json", }, timeout: 8_000, } ); const organic = response.data?.organic ?? []; for (const result of organic) { if (isLinkedInPersonalUrl(result.link)) { return { url: cleanLinkedInUrl(result.link), confidence: 0.75, source: "company_people_page", verified: true, }; } } return null; } catch { return null; } } // ─── Helpers ───────────────────────────────────────────────── function isLinkedInPersonalUrl(url: string): boolean { // Must be linkedin.com/in/ (personal) not /company/ or /jobs/ return /linkedin\.com\/in\/[a-zA-Z0-9\-]+/.test(url); } function cleanLinkedInUrl(url: string): string { // Remove query params and fragments, normalize const match = url.match(/(https?:\/\/(?:www\.)?linkedin\.com\/in\/[a-zA-Z0-9\-]+)/); return match ? match[1] : url; } /** * Batch find LinkedIn profiles for multiple decision makers. * Stops after 5 to conserve API calls. */ export async function batchFindLinkedIn( people: { fullName: string; title: string }[], companyName: string, companyDomain: string, companyLinkedInUrl: string | null ): Promise> { const results = new Map(); const maxLookups = Math.min(people.length, 5); for (let i = 0; i < maxLookups; i++) { const person = people[i]; const result = await findPersonalLinkedIn( person.fullName, companyName, companyDomain, companyLinkedInUrl ); if (result) { results.set(person.fullName, result); } // Small delay between searches to be polite await new Promise(r => setTimeout(r, 1500)); } logger.info({ company: companyName, found: results.size, attempted: maxLookups }, "LinkedIn personal batch complete" ); return results; }