/**
* LinkedIn API Client
* Clean, typed interface for all LinkedIn operations
*/
import { getCredentials, hasValidSession, validateSession } from "../session/manager.js";
// ===== TYPES =====
export interface Profile {
[key: string]: unknown;
name: string;
headline: string;
summary: string;
profileUrl: string;
publicId: string;
experience: Array<{
title: string;
company: string;
location: string;
description: string;
}>;
education: Array<{
school: string;
degree: string;
field: string;
}>;
skills: string[];
certifications: Array<{
name: string;
issuer: string;
}>;
}
export interface Company {
[key: string]: unknown;
name: string;
tagline: string;
description: string;
industry: string;
staffCount: number;
headquarters: string;
website: string;
companyUrl: string;
followerCount: number;
}
export interface Job {
[key: string]: unknown;
title: string;
company: string;
location: string;
employmentType: string;
experienceLevel: string;
remote: boolean;
salary: string;
description: string;
listedAt: string;
jobUrl: string;
}
export interface Group {
[key: string]: unknown;
name: string;
description: string;
memberCount: number;
type: string;
groupUrl: string;
}
export interface SearchResult {
[key: string]: unknown;
title: string;
subtitle?: string;
metadata?: string;
url: string;
}
export type SearchType = "PEOPLE" | "JOBS" | "COMPANIES" | "GROUPS";
export interface SearchFilters {
// PEOPLE
location?: string;
currentCompany?: string;
network?: ("F" | "S" | "O")[];
industry?: string;
school?: string;
// JOBS
workplaceType?: ("1" | "2" | "3")[];
experienceLevel?: ("1" | "2" | "3" | "4" | "5" | "6")[];
jobType?: ("F" | "P" | "C" | "T" | "I")[];
datePosted?: "r86400" | "r604800" | "r2592000";
salary?: "1" | "2" | "3" | "4" | "5";
easyApply?: boolean;
company?: string;
// COMPANIES
companySize?: ("A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I")[];
companyHqGeo?: string;
// GROUPS
groupMemberCount?: "1" | "101" | "1001" | "10001";
}
// ===== CLIENT =====
interface ApiResponse {
data?: Record<string, unknown>;
included?: Array<Record<string, unknown>>;
elements?: Array<Record<string, unknown>>;
}
class LinkedInClient {
private liAt: string;
private csrfToken: string;
constructor(liAt: string, csrfToken: string) {
this.liAt = liAt;
this.csrfToken = csrfToken;
}
private get headers(): Record<string, string> {
return {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Cookie": `li_at=${this.liAt}; JSESSIONID="${this.csrfToken}"`,
"Accept": "application/vnd.linkedin.normalized+json+2.1",
"csrf-token": this.csrfToken,
"x-restli-protocol-version": "2.0.0",
"x-li-lang": "en_US",
};
}
private async fetch(url: string): Promise<ApiResponse> {
const response = await fetch(url, { headers: this.headers });
if (!response.ok) {
throw new Error(`LinkedIn API error: ${response.status}`);
}
return response.json() as Promise<ApiResponse>;
}
// ===== PROFILE =====
async getMyProfile(): Promise<Profile> {
const meData = await this.fetch("https://www.linkedin.com/voyager/api/me");
const basicProfile = meData.included?.find((item) => item.publicIdentifier);
if (!basicProfile?.publicIdentifier) throw new Error("Could not get profile ID");
const publicId = basicProfile.publicIdentifier as string;
return this.getProfile(publicId);
}
async getProfile(username: string): Promise<Profile> {
const url = `https://www.linkedin.com/voyager/api/identity/dash/profiles?q=memberIdentity&memberIdentity=${username}&decorationId=com.linkedin.voyager.dash.deco.identity.profile.FullProfileWithEntities-93`;
const data = await this.fetch(url);
return this.parseProfile(data.included || [], username);
}
private parseProfile(included: Array<Record<string, unknown>>, publicId: string): Profile {
const mainProfile = included.find((i) =>
i["$type"]?.toString().includes("Profile") && i.firstName
);
const positions = included.filter((i) =>
i["$type"]?.toString().includes("Position") && !i["$type"]?.toString().includes("Group")
);
const education = included.filter((i) =>
i["$type"]?.toString().includes("Education")
);
const skills = included.filter((i) =>
i["$type"]?.toString().includes("Skill")
);
const certifications = included.filter((i) =>
i["$type"]?.toString().includes("Certification")
);
return {
name: `${mainProfile?.firstName || ""} ${mainProfile?.lastName || ""}`.trim(),
headline: (mainProfile?.headline as string) || "",
summary: (mainProfile?.summary as string) || "",
profileUrl: `https://www.linkedin.com/in/${publicId}/`,
publicId,
experience: positions.map((p) => ({
title: (p.title as string) || "",
company: (p.companyName as string) || "",
location: (p.locationName as string) || "",
description: (p.description as string) || "",
})),
education: education.map((e) => ({
school: (e.schoolName as string) || "",
degree: (e.degreeName as string) || "",
field: (e.fieldOfStudy as string) || "",
})),
skills: skills.map((s) => s.name as string).filter(Boolean),
certifications: certifications.map((c) => ({
name: (c.name as string) || "",
issuer: (c.authority as string) || "",
})),
};
}
// ===== COMPANY =====
async getCompany(slug: string): Promise<Company> {
const url = `https://www.linkedin.com/voyager/api/organization/companies?decorationId=com.linkedin.voyager.deco.organization.web.WebFullCompanyMain-35&q=universalName&universalName=${slug}`;
const data = await this.fetch(url);
const targetUrl = `https://www.linkedin.com/company/${slug}`;
const company = (data.included || []).find((i) =>
i["$type"]?.toString().includes("Company") &&
(i.url === targetUrl || i.name?.toString().toLowerCase() === slug.toLowerCase())
) || (data.included || []).find((i) => i["$type"]?.toString().includes("Company") && i.name);
if (!company) throw new Error("Company not found");
return {
name: (company.name as string) || "",
tagline: (company.tagline as string) || "",
description: (company.description as string) || "",
industry: (company.companyIndustries as Array<{localizedName: string}>)?.[0]?.localizedName || "",
staffCount: (company.staffCount as number) || 0,
headquarters: company.headquarter ? `${(company.headquarter as {city?: string}).city || ""}` : "",
website: (company.website as string) || "",
companyUrl: (company.url as string) || `https://www.linkedin.com/company/${slug}`,
followerCount: (company.followingInfo as {followerCount?: number})?.followerCount || 0,
};
}
// ===== JOB =====
async getJob(jobId: string): Promise<Job> {
const url = `https://www.linkedin.com/voyager/api/jobs/jobPostings/${jobId}?decorationId=com.linkedin.voyager.deco.jobs.web.shared.WebLightJobPosting-23`;
const data = await this.fetch(url);
const job = data.data as Record<string, unknown>;
if (!job) throw new Error("Job not found");
const company = (data.included || []).find((i) => i.name);
return {
title: (job.title as string) || "",
company: (company?.name as string) || "",
location: (job.formattedLocation as string) || "",
employmentType: (job.formattedEmploymentStatus as string) || "",
experienceLevel: (job.formattedExperienceLevel as string) || "",
remote: (job.workRemoteAllowed as boolean) || false,
salary: (job.formattedSalaryDescription as string) || "",
description: (job.description as { text?: string })?.text || "",
listedAt: job.listedAt ? new Date(job.listedAt as number).toISOString() : "",
jobUrl: `https://www.linkedin.com/jobs/view/${jobId}/`,
};
}
// ===== GROUP =====
async getGroup(groupId: string): Promise<Group> {
const url = `https://www.linkedin.com/voyager/api/voyagerGroupsDashGroups/urn:li:fsd_group:${groupId}`;
const data = await this.fetch(url);
const group = data.data as Record<string, unknown>;
if (!group || !group.name) throw new Error("Group not found");
return {
name: (group.name as string) || "",
description: (group.description as { text?: string })?.text || "",
memberCount: (group.memberCount as number) || 0,
type: (group.type as string) || "",
groupUrl: `https://www.linkedin.com/groups/${groupId}/`,
};
}
// ===== SEARCH =====
async search(type: SearchType, query: string, limit = 10, filters?: SearchFilters): Promise<SearchResult[]> {
if (type === "JOBS") {
return this.searchJobs(query, limit, filters);
}
return this.searchGeneric(type, query, limit, filters);
}
private async searchGeneric(type: SearchType, query: string, limit: number, filters?: SearchFilters): Promise<SearchResult[]> {
const encodedQuery = encodeURIComponent(query);
const filterParams: string[] = [`(key:resultType,value:List(${type}))`];
if (filters) {
if (filters.location) filterParams.push(`(key:geoUrn,value:List(${filters.location}))`);
if (filters.currentCompany) filterParams.push(`(key:currentCompany,value:List(${filters.currentCompany}))`);
if (filters.network?.length) filterParams.push(`(key:network,value:List(${filters.network.join(",")}))`);
if (filters.industry) filterParams.push(`(key:industry,value:List(${filters.industry}))`);
if (filters.school) filterParams.push(`(key:school,value:List(${filters.school}))`);
if (filters.companySize?.length) filterParams.push(`(key:companySize,value:List(${filters.companySize.join(",")}))`);
if (filters.companyHqGeo) filterParams.push(`(key:companyHqGeo,value:List(${filters.companyHqGeo}))`);
if (filters.groupMemberCount) filterParams.push(`(key:groupMemberCount,value:List(${filters.groupMemberCount}))`);
}
const url = `https://www.linkedin.com/voyager/api/graphql?includeWebMetadata=true&variables=(start:0,origin:GLOBAL_SEARCH_HEADER,query:(keywords:${encodedQuery},flagshipSearchIntent:SEARCH_SRP,queryParameters:List(${filterParams.join(",")})))&queryId=voyagerSearchDashClusters.b0928897b71bd00a5a7291755dcd64f0`;
const data = await this.fetch(url);
type ResultItem = {
title?: { text?: string };
primarySubtitle?: { text?: string };
secondarySubtitle?: { text?: string };
navigationUrl?: string;
};
return (data.included || [])
.filter((i) => (i as ResultItem).title?.text && (i as ResultItem).navigationUrl)
.slice(0, limit)
.map((r) => {
const item = r as ResultItem;
return {
title: item.title?.text || "",
subtitle: item.primarySubtitle?.text || "",
metadata: item.secondarySubtitle?.text || "",
url: item.navigationUrl?.split("?")?.[0] || "",
};
});
}
private async searchJobs(query: string, limit: number, filters?: SearchFilters): Promise<SearchResult[]> {
const encodedQuery = encodeURIComponent(query);
const selectedFilters: string[] = ["sortBy:List(R)"];
if (filters) {
if (filters.workplaceType?.length) selectedFilters.push(`workplaceType:List(${filters.workplaceType.join(",")})`);
if (filters.experienceLevel?.length) selectedFilters.push(`experience:List(${filters.experienceLevel.join(",")})`);
if (filters.jobType?.length) selectedFilters.push(`jobType:List(${filters.jobType.join(",")})`);
if (filters.datePosted) selectedFilters.push(`timePostedRange:List(${filters.datePosted})`);
if (filters.salary) selectedFilters.push(`salaryBucketV2:List(${filters.salary})`);
if (filters.easyApply) selectedFilters.push(`applyWithLinkedin:List(true)`);
if (filters.company) selectedFilters.push(`company:List(${filters.company})`);
}
const locationPart = filters?.location ? `,locationUnion:(geoId:${filters.location})` : "";
const url = `https://www.linkedin.com/voyager/api/voyagerJobsDashJobCards?decorationId=com.linkedin.voyager.dash.deco.jobs.search.JobSearchCardsCollection-191&count=${limit}&q=jobSearch&query=(origin:JOB_SEARCH_PAGE_QUERY_EXPANSION,keywords:${encodedQuery}${locationPart},selectedFilters:(${selectedFilters.join(",")}))&start=0`;
const data = await this.fetch(url);
return (data.included || [])
.filter((i) => i["$type"]?.toString().includes("JobPostingCard") && i.jobPostingTitle)
.slice(0, limit)
.map((card) => ({
title: (card.jobPostingTitle as string) || "",
subtitle: (card.primaryDescription as { text?: string })?.text || "",
metadata: (card.secondaryDescription as { text?: string })?.text || "",
url: card.jobPostingUrn ? `https://www.linkedin.com/jobs/view/${(card.jobPostingUrn as string).split(":").pop()}/` : "",
}));
}
}
// ===== FACTORY =====
let cachedClient: LinkedInClient | null = null;
/**
* Get a LinkedIn client instance after validating session
* @param sessionId - The session ID from linkedin_auth
* @throws Error if session is invalid
*/
export function getClient(sessionId: string): LinkedInClient {
// Validate session ID
const validation = validateSession(sessionId);
if (!validation.valid) {
throw new Error(validation.error || "Invalid session");
}
const creds = getCredentials();
if (!creds) {
throw new Error("No credentials available. Call linkedin_auth first.");
}
// Return cached client if available
if (cachedClient) {
return cachedClient;
}
cachedClient = new LinkedInClient(creds.liAt, creds.csrfToken);
return cachedClient;
}
/**
* Clear cached client (call after logout or re-auth)
*/
export function clearClient(): void {
cachedClient = null;
}
// Re-export types and validation
export { LinkedInClient, validateSession };