Spaces:
Running
Running
| /** | |
| * 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<PersonalLinkedIn | null> { | |
| // 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<PersonalLinkedIn | null> { | |
| 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<PersonalLinkedIn | null> { | |
| 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<Map<string, PersonalLinkedIn>> { | |
| const results = new Map<string, PersonalLinkedIn>(); | |
| 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; | |
| } | |