clienttarget-python / src /discovery /lib /linkedin-person-finder.ts
iDevBuddy
feat: Phase 1 β€” AI Client Acquisition System
bd28470
/**
* 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;
}