
- 10 installs
- Updated January 31, 2026
- skillhq/linkedin
Helps with ai & agent building tasks.
About
linkedin is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- AI & Agent Building
- AI-coding skill
Linkedin by the numbers
- 10 all-time installs (skills.sh)
- Ranked #11,937 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 27, 2026 (Skillselion catalog sync)
npx skills add https://github.com/skillhq/linkedin --skill linkedinAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 10 |
|---|---|
| Last updated | January 31, 2026 |
| Repository | skillhq/linkedin ↗ |
What it does
Helps with ai & agent building tasks.
Files
LinkedIn Skill
Access LinkedIn data through browser automation. This approach is reliable because it uses your actual browser session, avoiding LinkedIn's aggressive bot detection that blocks API-only traffic.
Setup Requirements
Chrome Profile Setup (One-Time)
1. Create a dedicated Chrome profile for automation:
- Open Chrome → click your profile icon (top right) → "Add"
- Name it something memorable (e.g., "Claude" or "Automation")
- This keeps automation separate from personal browsing
2. In your automation profile:
- Log into LinkedIn (www.linkedin.com)
- Install the Claude browser extension from https://claude.ai/chrome
- Keep this Chrome window open when using LinkedIn commands
3. Before using LinkedIn commands:
- Ensure Chrome is open with your automation profile
- The Claude browser extension must be connected
Browser Automation Commands
When the user asks for LinkedIn data, use the claude-in-chrome tools to navigate and extract information.
Connecting to Chrome
Always start by checking the browser connection:
1. Call mcp__claude-in-chrome__tabs_context_mcp with createIfEmpty: true
2. If not connected, ask user to open Chrome with their automation profile
3. Create a new tab or use existing oneReading Feed
When user asks to see their LinkedIn feed:
1. Navigate to https://www.linkedin.com/feed/
2. Use read_page to extract feed content
3. Parse and format the feed itemsExample flow: 1. mcp__claude-in-chrome__navigate to https://www.linkedin.com/feed/ 2. mcp__claude-in-chrome__read_page with filter: "all" to get post content 3. Extract post authors, content, engagement metrics from the accessibility tree
Viewing Profiles
When user asks about a LinkedIn profile:
1. Navigate to https://www.linkedin.com/in/{username}/
2. Use read_page to extract profile information
3. Parse name, headline, about, experience, etc.Searching
When user wants to search LinkedIn:
People: https://www.linkedin.com/search/results/people/?keywords={query}
Jobs: https://www.linkedin.com/search/results/jobs/?keywords={query}
Companies: https://www.linkedin.com/search/results/companies/?keywords={query}
Posts: https://www.linkedin.com/search/results/content/?keywords={query}Reading Messages
When user wants to check messages:
1. Navigate to https://www.linkedin.com/messaging/
2. Use read_page to extract conversation list
3. Click on a conversation to read messagesData Extraction Patterns
Feed Posts
Look for these patterns in the accessibility tree:
- Post author: Links with profile URLs
- Post content: Text nodes within post containers
- Engagement: Like/comment/share counts
- Timestamps: Relative time indicators
Profile Data
Navigate to profile and extract:
- Name and headline from main header
- About section text
- Experience entries (company, title, duration)
- Education entries
- Skills section
Search Results
Parse result items containing:
- Name/title link
- Subtitle (headline, location)
- Connection degree
- Profile picture
Example Interactions
"Show my LinkedIn feed"
1. Connect to Chrome: tabs_context_mcp
2. Navigate: navigate to linkedin.com/feed/
3. Wait for load: computer action=wait duration=2
4. Read content: read_page with depth=10
5. Parse and summarize top posts"Look up John Smith on LinkedIn"
1. Connect to Chrome: tabs_context_mcp
2. Navigate: navigate to linkedin.com/search/results/people/?keywords=John%20Smith
3. Read results: read_page filter=interactive
4. Present matching profiles"What's my profile looking like?"
1. Connect to Chrome: tabs_context_mcp
2. Navigate: navigate to linkedin.com/in/me/
3. Read profile: read_page
4. Summarize profile stats, recent activityCLI Fallback (Less Reliable)
The CLI tool is available but LinkedIn often invalidates API sessions:
# Install
cd /Users/derekrein/Code/cyberdrk305/linkedin && npm install && npm run build && npm link
# Commands (may require fresh cookies frequently)
linkedin setup # Setup instructions
linkedin check # Verify credentials
linkedin whoami # Show logged-in user
linkedin feed -n 10 # Get feed posts
linkedin profile <username> # View profileManual Cookie Setup
If using the CLI, you'll need to manually extract cookies:
1. Open Chrome DevTools (F12) on linkedin.com 2. Go to: Application → Cookies → linkedin.com 3. Copy li_at and JSESSIONID values 4. Run: linkedin setup --li-at "VALUE" --jsessionid "VALUE"
Note: LinkedIn aggressively invalidates these sessions after a few API calls.
Troubleshooting
Browser extension not connected
- Ensure Chrome is open with your automation profile
- Click the Claude extension icon to activate it
- Try restarting Chrome
LinkedIn checkpoint/verification page
- LinkedIn may require verification for new browser sessions
- Complete the verification manually, then retry
Session expired
- Log back into LinkedIn in your automation Chrome profile
- Refresh the page and try again
# Only publish SKILL.md and README.md for the skill
# Exclude everything else
# Source code
src/
dist/
scripts/
docs/
# Config files
*.json
*.json5
*.yaml
*.yml
*.toml
tsconfig*.json
package-lock.json
# Docs (except README and SKILL)
CLAUDE.md
CONTRIBUTING.md
LICENSE
# GitHub
.github/
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
name: Publish to npm
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm run build
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
node_modules/
dist/
*.log
.DS_Store
# Credentials and config files (contain cookies and session tokens)
.lirc.json5
config.json5
.env
.env.*
LinkedIn Browser Parsing Reference
Patterns for extracting structured data from LinkedIn pages via browser automation.
Feed Page (/feed/)
Profile Sidebar
Look for elements in complementary region "Side Bar":
- Name:
headingtext (e.g., "✌️ Derek Rein") - Headline:
genericafter name (e.g., "CTO @ WalletConnect") - Location: next
generic(e.g., "United Arab Emirates") - Profile viewers: in
listitemwith "Profile viewers" text, sibling has count - Post impressions: in
listitemwith "Post impressions" text, sibling has count
Feed Posts
Posts are in article elements with heading "Feed post number N":
Post Author:
- Company posts: Look for
linkwith/company/href - Person posts: Look for
linkwith/in/href - Author name:
generictext within the author link - Follower count:
genericwith "followers" text - Connection degree:
genericwith "• 1st" or "• 2nd" text
Post Metadata:
- Time:
genericwith pattern like "1w •" or "4d •" - Visibility: Next
genericwith "Visible to anyone..." text
Post Content:
- Main text:
genericelement containing the post body - "...more" button indicates truncated content
- Mentions:
linkelements within content pointing to profiles/companies
Engagement:
- Reactions:
buttonwith "X reactions" text, or "Name and N others" - Comments:
buttonwith "N comments on..." text - Reposts:
buttonwith "N reposts..." text
Post Type Detection:
- Image post: Contains
imageelement with "No alternative text description" - Article: Contains
presentationwith external link - Video: Contains video player elements
- Promoted: Has "Promoted"
genericinstead of timestamp
Profile Page (/in/{username}/)
Header Section
- Name: Main
headingelement - Headline:
genericbelow name - Location:
genericwith location text - Connection count: Look for "X connections" text
- Follower count: Look for "X followers" text
About Section
- Contains
sectionwith heading "About" - Text content in
genericelements below
Experience Section
- Contains
sectionwith heading "Experience" - Each role in
listitem: - Company logo:
imageelement - Title: First
generictext - Company:
linkto company page - Duration:
genericwith date range - Description: Additional
generictext
Search Results (/search/results/{type}/)
People Results
- Each result in
listitem - Profile picture:
imageelement - Name:
linkto profile - Headline:
genericbelow name - Location: Additional
generic - Connection degree: "1st", "2nd", "3rd+"
Job Results
- Job title:
linktext - Company:
linkto company - Location:
generic - Posted time:
genericwith time pattern
Company Results
- Company name:
linktext - Industry:
generic - Follower count:
genericwith "followers"
Messaging (/messaging/)
Conversation List
- Conversations in
listitemelements - Participant name:
genericorlinktext - Last message preview:
generictext - Timestamp:
genericwith time - Unread indicator: badge element
Message Thread
- Messages in ordered list
- Sender:
imagealt text or name element - Message body:
generictext - Timestamp:
genericwith time
Notifications (/notifications/)
- Each notification in
listitem - Actor:
linkto profile - Action text:
genericdescribing action - Target:
linkto content - Time:
genericwith relative time
Common Patterns
Relative Time
- "Xm" = X minutes ago
- "Xh" = X hours ago
- "Xd" = X days ago
- "Xw" = X weeks ago
- "Xmo" = X months ago
Connection Degrees
- "• 1st" = 1st degree connection
- "• 2nd" = 2nd degree connection
- "• 3rd+" = 3rd+ degree connection
- No indicator = not connected
Verification
- Premium badge:
imgwith "Premium" or gold checkmark - Verified badge: Blue checkmark near name
{
"name": "linkedin-cli",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "linkedin-cli",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@steipete/sweet-cookie": "^0.1.0",
"commander": "^12.1.0",
"json5": "^2.2.3"
},
"bin": {
"linkedin": "dist/index.js"
},
"devDependencies": {
"@types/node": "^22.10.0",
"typescript": "^5.7.0"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@steipete/sweet-cookie": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@steipete/sweet-cookie/-/sweet-cookie-0.1.0.tgz",
"integrity": "sha512-cF8hS6rTohAFI1NAm2/HM9omBlw6V1DpwF6q5e9FB+l274HjtyirCgCc6QK2Rtuh7uDoqWHshFQZPsceyJuVgw==",
"license": "MIT",
"engines": {
"node": ">=22"
}
},
"node_modules/@types/node": {
"version": "22.19.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
{
"name": "@skillhq/linkedin",
"version": "0.1.0",
"description": "LinkedIn CLI for reading feed, profiles, connections, search results, and messages",
"type": "module",
"main": "dist/index.js",
"bin": {
"linkedin": "dist/index.js"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"start": "node dist/index.js",
"lint": "eslint src --ext .ts",
"clean": "rm -rf dist",
"prepublishOnly": "npm run build"
},
"keywords": [
"linkedin",
"cli",
"social",
"networking",
"api"
],
"author": "Derek Rein",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/skillhq/linkedin.git"
},
"homepage": "https://github.com/skillhq/linkedin#readme",
"bugs": {
"url": "https://github.com/skillhq/linkedin/issues"
},
"dependencies": {
"@steipete/sweet-cookie": "^0.1.0",
"commander": "^12.1.0",
"json5": "^2.2.3"
},
"devDependencies": {
"@types/node": "^22.10.0",
"typescript": "^5.7.0"
},
"engines": {
"node": ">=22.0.0"
},
"files": [
"dist",
"SKILL.md"
]
}
LinkedIn CLI
A CLI tool for reading LinkedIn feed, profiles, connections, search results, and messages.
Installation
npm install -g @skillhq/linkedinOr clone and build from source:
git clone https://github.com/skillhq/linkedin.git
cd linkedin
npm install
npm run build
npm linkAuthentication
The CLI uses cookie-based authentication with LinkedIn's Voyager API. You'll need your li_at and JSESSIONID cookies from an active LinkedIn session.
Automatic Cookie Extraction
The CLI can automatically extract cookies from your browser using @steipete/sweet-cookie:
linkedin check # Auto-extract from default browser
linkedin check --cookie-source chrome
linkedin check --chrome-profile "Profile 1"Manual Cookie Setup
1. Open Chrome DevTools (F12) on linkedin.com 2. Go to: Application → Cookies → linkedin.com 3. Copy li_at and JSESSIONID values 4. Run: linkedin setup --li-at "VALUE" --jsessionid "VALUE"
Commands
Authentication
linkedin check # Verify credentials are valid
linkedin whoami # Show logged-in user info
linkedin setup # Show setup instructionsReading Data
linkedin feed [-n 20] # Get home feed posts
linkedin profile <username> # View a profile by username or URL
linkedin connections [-n 50] # List your connections
linkedin search "query" [-t type] # Search people, jobs, companies, posts
linkedin inbox [-n 20] # List message conversations
linkedin read <conversation-id> # Read messages from a conversationOptions
--json # Output in JSON format
--li-at <token> # Provide li_at cookie directly
--jsessionid <token> # Provide JSESSIONID cookie directly
--cookie-source <browser> # Browser to extract cookies from
--chrome-profile <name> # Chrome profile name
--timeout <ms> # Request timeout in millisecondsBrowser Automation Alternative
LinkedIn aggressively invalidates API sessions. For more reliable access, use browser automation with the Claude chrome extension. See SKILL.md for details.
Development
npm run build # Build TypeScript
npm run dev # Watch mode
npm run clean # Clean dist folderLicense
MIT
#!/usr/bin/env bash
set -euo pipefail
# Publish linkedin skill to ClawdHub
# Usage:
# ./scripts/publish-skill.sh # Use version from package.json
# ./scripts/publish-skill.sh 0.2.0 # Specify version
# ./scripts/publish-skill.sh patch # Bump patch (0.1.0 -> 0.1.1)
# ./scripts/publish-skill.sh minor # Bump minor (0.1.0 -> 0.2.0)
# ./scripts/publish-skill.sh major # Bump major (0.1.0 -> 1.0.0)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_DIR"
# Read current version from package.json
CURRENT_VERSION=$(node -p "require('./package.json').version")
# Determine target version
if [[ $# -eq 0 ]]; then
VERSION="$CURRENT_VERSION"
elif [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
VERSION="$1"
elif [[ "$1" == "patch" || "$1" == "minor" || "$1" == "major" ]]; then
IFS='.' read -r major minor patch <<< "$CURRENT_VERSION"
case "$1" in
patch) VERSION="$major.$minor.$((patch + 1))" ;;
minor) VERSION="$major.$((minor + 1)).0" ;;
major) VERSION="$((major + 1)).0.0" ;;
esac
else
echo "Usage: $0 [version|patch|minor|major]"
echo " version: semver like 0.2.0"
echo " patch: bump patch version"
echo " minor: bump minor version"
echo " major: bump major version"
exit 1
fi
# Get changelog from git (commits since last tag, or last 5 commits)
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [[ -n "$LAST_TAG" ]]; then
CHANGELOG=$(git log "$LAST_TAG"..HEAD --oneline --no-decorate | head -10 | sed 's/^/- /')
else
CHANGELOG=$(git log -5 --oneline --no-decorate | sed 's/^/- /')
fi
if [[ -z "$CHANGELOG" ]]; then
CHANGELOG="Version $VERSION"
fi
echo "Publishing linkedin skill v$VERSION"
echo "Changelog:"
echo "$CHANGELOG"
echo ""
# Update package.json version if different
if [[ "$VERSION" != "$CURRENT_VERSION" ]]; then
echo "Updating package.json version: $CURRENT_VERSION -> $VERSION"
npm pkg set version="$VERSION"
fi
# Publish to ClawdHub
# Note: --workdir is required to override clawdbot default workspace
TAGS="latest,linkedin,social,networking,cli"
clawdhub --workdir "$PROJECT_DIR" publish . \
--slug linkedin \
--name "LinkedIn CLI" \
--version "$VERSION" \
--tags "$TAGS" \
--changelog "$CHANGELOG"
echo ""
echo "Published linkedin@$VERSION to ClawdHub"
import type { LinkedInCredentials, ApiResponse } from '../types.js';
import { getLinkedInHeaders } from '../util/headers.js';
const BASE_URL = 'https://www.linkedin.com/voyager/api';
const MIN_REQUEST_INTERVAL = 500; // 500ms between requests
export class LinkedInClient {
private credentials: LinkedInCredentials;
private timeout: number;
private lastRequestTime = 0; // Instance-level rate limiting
constructor(credentials: LinkedInCredentials, timeout = 30000) {
this.credentials = credentials;
this.timeout = timeout;
}
private async rateLimit(): Promise<void> {
const now = Date.now();
const timeSinceLastRequest = now - this.lastRequestTime;
if (timeSinceLastRequest < MIN_REQUEST_INTERVAL) {
await new Promise((resolve) => setTimeout(resolve, MIN_REQUEST_INTERVAL - timeSinceLastRequest));
}
this.lastRequestTime = Date.now();
}
async get<T>(endpoint: string): Promise<ApiResponse<T>> {
await this.rateLimit();
const url = endpoint.startsWith('http') ? endpoint : `${BASE_URL}${endpoint}`;
const headers = getLinkedInHeaders(this.credentials);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(url, {
method: 'GET',
headers,
signal: controller.signal,
});
// Check for auth failures
if (response.status === 401 || response.status === 403) {
throw new ApiError(
'Authentication failed. Your session may have expired.',
response.status,
''
);
}
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new ApiError(
`LinkedIn API error: ${response.status} ${response.statusText}`,
response.status,
errorText.slice(0, 1000) // Truncate error text to avoid exposing too much
);
}
const text = await response.text();
try {
return JSON.parse(text) as ApiResponse<T>;
} catch {
throw new ApiError('Failed to parse response as JSON', 0, text.slice(0, 500));
}
} catch (error) {
if (error instanceof ApiError) throw error;
if (error instanceof Error && error.name === 'AbortError') {
throw new ApiError(`Request timed out after ${this.timeout}ms`, 0, '');
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
async post<T>(endpoint: string, body: unknown): Promise<ApiResponse<T>> {
await this.rateLimit();
const url = endpoint.startsWith('http') ? endpoint : `${BASE_URL}${endpoint}`;
const headers = {
...getLinkedInHeaders(this.credentials),
'Content-Type': 'application/json',
};
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: controller.signal,
});
if (response.status === 401 || response.status === 403) {
throw new ApiError(
'Authentication failed. Your session may have expired.',
response.status,
''
);
}
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new ApiError(
`LinkedIn API error: ${response.status} ${response.statusText}`,
response.status,
errorText.slice(0, 1000)
);
}
const text = await response.text();
try {
return JSON.parse(text) as ApiResponse<T>;
} catch {
throw new ApiError('Failed to parse response as JSON', 0, text.slice(0, 500));
}
} catch (error) {
if (error instanceof ApiError) throw error;
if (error instanceof Error && error.name === 'AbortError') {
throw new ApiError(`Request timed out after ${this.timeout}ms`, 0, '');
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
/** Check if credentials are present (does not expose actual values) */
hasCredentials(): boolean {
return Boolean(this.credentials.liAt && this.credentials.jsessionId);
}
/** Get a redacted version of credentials for logging/display */
getCredentialsSummary(): { liAtPrefix: string; hasJsessionId: boolean } {
return {
liAtPrefix: this.credentials.liAt ? this.credentials.liAt.slice(0, 8) + '...' : '',
hasJsessionId: Boolean(this.credentials.jsessionId),
};
}
/** Internal: get raw credentials (use sparingly) */
_getCredentials(): LinkedInCredentials {
return this.credentials;
}
}
export class ApiError extends Error {
status: number;
body: string;
constructor(message: string, status: number, body: string) {
super(message);
this.name = 'ApiError';
this.status = status;
this.body = body;
}
}
import type { LinkedInClient } from './base.js';
import type { Connection, MiniProfile } from '../types.js';
import { buildEntityMap, getIncludedArray, parseMiniProfile } from '../util/parsers.js';
interface ConnectionsResponse {
elements?: unknown[];
paging?: {
count: number;
start: number;
total?: number;
};
}
export interface ConnectionsOptions {
count?: number;
start?: number;
}
export async function getConnections(
client: LinkedInClient,
options: ConnectionsOptions = {}
): Promise<{ connections: Connection[]; paging?: ConnectionsResponse['paging'] }> {
const { count = 50, start = 0 } = options;
const response = await client.get<ConnectionsResponse>(
`/relationships/dash/connections?decorationId=com.linkedin.voyager.dash.deco.web.mynetwork.ConnectionListWithProfile-15&count=${count}&q=search&sortType=RECENTLY_ADDED&start=${start}`
);
const data = response.data || response;
const dataAny = data as unknown as ConnectionsResponse;
const included = getIncludedArray(response);
const entityMap = buildEntityMap(included);
const connections: Connection[] = [];
const elements = dataAny.elements || [];
for (const element of elements) {
const el = element as Record<string, unknown>;
const entityUrn = el.entityUrn as string;
if (!entityUrn) continue;
// Get connected member profile
const memberUrn = el['*connectedMemberResolutionResult'] as string;
let connectedMember: MiniProfile | null = null;
if (memberUrn && entityMap.has(memberUrn)) {
connectedMember = parseMiniProfile(entityMap.get(memberUrn));
}
if (!connectedMember) {
continue;
}
connections.push({
entityUrn,
connectedMember,
connectedAt: el.createdAt as number || 0,
});
}
return {
connections,
paging: dataAny.paging,
};
}
import type { LinkedInClient } from './base.js';
import type { FeedUpdate, MiniProfile, FeedContent, SocialDetail } from '../types.js';
import { buildEntityMap, getIncludedArray, parseMiniProfile, createUnknownProfile } from '../util/parsers.js';
interface FeedResponse {
elements?: unknown[];
paging?: {
count: number;
start: number;
total?: number;
};
}
export interface FeedOptions {
count?: number;
start?: number;
}
export async function getFeed(
client: LinkedInClient,
options: FeedOptions = {}
): Promise<{ updates: FeedUpdate[]; paging?: FeedResponse['paging'] }> {
const { count = 20, start = 0 } = options;
const response = await client.get<FeedResponse>(
`/feed/updatesV2?q=relevance&count=${count}&start=${start}&moduleKey=home-feed&sortOrder=RELEVANCE`
);
const data = response.data || response;
const dataAny = data as unknown as FeedResponse;
const included = getIncludedArray(response);
// Build a map of included entities for dereferencing
const entityMap = buildEntityMap(included);
const updates: FeedUpdate[] = [];
const elements = dataAny.elements || [];
for (const element of elements) {
const update = parseUpdate(element, entityMap);
if (update) {
updates.push(update);
}
}
return {
updates,
paging: dataAny.paging,
};
}
function parseUpdate(element: unknown, entityMap: Map<string, unknown>): FeedUpdate | null {
const el = element as Record<string, unknown>;
const urn = (el.entityUrn as string) || (el.urn as string) || '';
if (!urn) return null;
// Parse actor (who posted)
let actor: MiniProfile | null = null;
const actorRef = el.actor as Record<string, unknown> | undefined;
if (actorRef) {
const miniProfileUrn = actorRef['*miniProfile'] as string | undefined;
if (miniProfileUrn && entityMap.has(miniProfileUrn)) {
actor = parseMiniProfile(entityMap.get(miniProfileUrn));
} else if (actorRef.name) {
// Inline actor data
actor = {
publicIdentifier: '',
firstName: (actorRef.name as Record<string, unknown>)?.text as string || '',
lastName: '',
headline: (actorRef.description as Record<string, unknown>)?.text as string,
};
}
}
if (!actor) {
actor = createUnknownProfile();
}
// Parse commentary (post text)
let commentary: string | undefined;
const commentaryEl = el.commentary as Record<string, unknown> | undefined;
if (commentaryEl?.text) {
const textEl = commentaryEl.text as Record<string, unknown>;
commentary = textEl.text as string;
}
// Parse content
const content = parseContent(el.content as Record<string, unknown> | undefined);
// Parse social details (likes, comments)
const socialDetail = parseSocialDetail(el.socialDetail as Record<string, unknown> | undefined);
// Parse timestamp
const postedAt = el.publishedAt as number | undefined;
return {
urn,
actor,
commentary,
content,
socialDetail,
postedAt,
};
}
function parseContent(content: Record<string, unknown> | undefined): FeedContent | undefined {
if (!content) return undefined;
// Article content
if (content['com.linkedin.voyager.feed.render.ArticleComponent']) {
const article = content['com.linkedin.voyager.feed.render.ArticleComponent'] as Record<string, unknown>;
return {
type: 'article',
title: (article.title as Record<string, unknown>)?.text as string,
description: (article.subtitle as Record<string, unknown>)?.text as string,
url: article.navigationUrl as string,
};
}
// Image content
if (content['com.linkedin.voyager.feed.render.ImageComponent']) {
const images = content['com.linkedin.voyager.feed.render.ImageComponent'] as Record<string, unknown>;
const imageUrls: string[] = [];
const imageList = images.images as Array<Record<string, unknown>> | undefined;
if (imageList) {
for (const img of imageList) {
const attributes = img.attributes as Array<Record<string, unknown>> | undefined;
if (attributes?.length) {
const vecImg = attributes[0].vectorImage as Record<string, unknown> | undefined;
if (vecImg?.rootUrl) {
imageUrls.push(vecImg.rootUrl as string);
}
}
}
}
return {
type: 'image',
images: imageUrls,
};
}
// Video content
if (content['com.linkedin.voyager.feed.render.LinkedInVideoComponent']) {
return { type: 'video' };
}
// Document/carousel content
if (content['com.linkedin.voyager.feed.render.DocumentComponent']) {
const doc = content['com.linkedin.voyager.feed.render.DocumentComponent'] as Record<string, unknown>;
return {
type: 'document',
title: (doc.title as Record<string, unknown>)?.text as string,
};
}
// Poll content
if (content['com.linkedin.voyager.feed.render.PollComponent']) {
return { type: 'poll' };
}
return { type: 'unknown' };
}
function parseSocialDetail(detail: Record<string, unknown> | undefined): SocialDetail | undefined {
if (!detail) return undefined;
const counts = detail.totalSocialActivityCounts as Record<string, unknown> | undefined;
return {
totalSocialActivityCounts: counts
? {
numLikes: counts.numLikes as number,
numComments: counts.numComments as number,
numShares: counts.numShares as number,
}
: undefined,
liked: detail.liked as boolean,
threadId: detail.threadId as string,
};
}
import type { LinkedInClient } from './base.js';
import type { Conversation, Message, MiniProfile } from '../types.js';
import { buildEntityMap, getIncludedArray, parseMiniProfile, createUnknownProfile } from '../util/parsers.js';
interface ConversationsResponse {
elements?: unknown[];
paging?: {
count: number;
start: number;
total?: number;
};
}
export interface InboxOptions {
count?: number;
start?: number;
}
export async function getInbox(
client: LinkedInClient,
options: InboxOptions = {}
): Promise<{ conversations: Conversation[]; paging?: ConversationsResponse['paging'] }> {
const { count = 20, start = 0 } = options;
const response = await client.get<ConversationsResponse>(
`/messaging/conversations?keyVersion=LEGACY_INBOX&start=${start}&count=${count}`
);
const data = response.data || response;
const dataAny = data as unknown as ConversationsResponse;
const included = getIncludedArray(response);
const entityMap = buildEntityMap(included);
const conversations: Conversation[] = [];
const elements = dataAny.elements || [];
for (const element of elements) {
const conversation = parseConversation(element, entityMap);
if (conversation) {
conversations.push(conversation);
}
}
return {
conversations,
paging: dataAny.paging,
};
}
export async function getConversation(
client: LinkedInClient,
conversationId: string,
options: { count?: number } = {}
): Promise<{ messages: Message[]; conversation?: Conversation }> {
const { count = 20 } = options;
// Normalize conversationId - extract just the ID if a full URN is provided
const id = conversationId.includes(':')
? conversationId.split(':').pop() || conversationId
: conversationId;
const response = await client.get<unknown>(
`/messaging/conversations/${id}/events?keyVersion=LEGACY_INBOX&count=${count}`
);
const included = getIncludedArray(response);
const data = response.data || response;
const dataAny = data as Record<string, unknown>;
const entityMap = buildEntityMap(included);
const messages: Message[] = [];
const elements = (dataAny.elements as unknown[]) || [];
for (const element of elements) {
const message = parseMessage(element, entityMap);
if (message) {
messages.push(message);
}
}
// Sort messages by createdAt (oldest first for reading)
messages.sort((a, b) => a.createdAt - b.createdAt);
return { messages };
}
function parseConversation(
element: unknown,
entityMap: Map<string, unknown>
): Conversation | null {
const el = element as Record<string, unknown>;
const entityUrn = el.entityUrn as string;
if (!entityUrn) return null;
const lastActivityAt = el.lastActivityAt as number || 0;
// Parse participants
const participants: MiniProfile[] = [];
const participantRefs = el['*participants'] as string[] | undefined;
if (participantRefs) {
for (const ref of participantRefs) {
if (entityMap.has(ref)) {
const profile = parseMiniProfile(entityMap.get(ref));
if (profile) {
participants.push(profile);
}
}
}
}
// Parse last message
let lastMessage: Message | undefined;
const lastMsgRef = el['*latestMessage'] as string | undefined;
if (lastMsgRef && entityMap.has(lastMsgRef)) {
lastMessage = parseMessage(entityMap.get(lastMsgRef), entityMap) || undefined;
}
return {
entityUrn,
lastActivityAt,
participants,
lastMessage,
unreadCount: el.unreadCount as number,
muted: el.muted as boolean,
};
}
function parseMessage(element: unknown, entityMap: Map<string, unknown>): Message | null {
const el = element as Record<string, unknown>;
const entityUrn = el.entityUrn as string;
if (!entityUrn) return null;
const createdAt = el.createdAt as number || 0;
// Parse sender
let sender: MiniProfile | null = null;
const fromRef = el['*from'] as string | undefined;
if (fromRef && entityMap.has(fromRef)) {
const fromEntity = entityMap.get(fromRef) as Record<string, unknown>;
const miniProfileRef = fromEntity['*miniProfile'] as string | undefined;
if (miniProfileRef && entityMap.has(miniProfileRef)) {
sender = parseMiniProfile(entityMap.get(miniProfileRef));
}
}
if (!sender) {
sender = createUnknownProfile();
}
// Parse message body
let body = '';
const eventContent = el.eventContent as Record<string, unknown> | undefined;
if (eventContent) {
const msgEvent =
eventContent['com.linkedin.voyager.messaging.event.MessageEvent'] as Record<string, unknown> | undefined;
if (msgEvent?.attributedBody) {
const attrBody = msgEvent.attributedBody as Record<string, unknown>;
body = attrBody.text as string || '';
}
}
return {
entityUrn,
createdAt,
sender,
body,
};
}
// Posts API - placeholder for future write operations
// Currently read-focused for account safety
import type { LinkedInClient } from './base.js';
import type { FeedUpdate } from '../types.js';
export interface PostOptions {
count?: number;
start?: number;
}
// Get posts by a specific user
export async function getUserPosts(
client: LinkedInClient,
publicIdentifier: string,
options: PostOptions = {}
): Promise<{ posts: FeedUpdate[] }> {
const { count = 10, start = 0 } = options;
// LinkedIn's profile posts endpoint
const response = await client.get<unknown>(
`/identity/profileUpdatesV2?q=memberIdentity&memberIdentity=${publicIdentifier}&count=${count}&start=${start}&moduleKey=member-shares`
);
const included = (response as unknown as { included?: unknown[] }).included || [];
const data = response.data || response;
const dataAny = data as Record<string, unknown>;
// Parse updates from included array
const posts: FeedUpdate[] = [];
const elements = (dataAny.elements as unknown[]) || [];
for (const element of elements) {
const el = element as Record<string, unknown>;
const urn = (el.entityUrn as string) || '';
if (!urn) continue;
// Basic post structure - full parsing would need more work
const post: FeedUpdate = {
urn,
actor: {
publicIdentifier,
firstName: '',
lastName: '',
},
commentary: extractCommentary(el),
postedAt: el.publishedAt as number,
};
posts.push(post);
}
return { posts };
}
function extractCommentary(element: Record<string, unknown>): string | undefined {
const commentary = element.commentary as Record<string, unknown> | undefined;
if (commentary?.text) {
const textEl = commentary.text as Record<string, unknown>;
return textEl.text as string;
}
return undefined;
}
// Future: createPost, react, comment
// Not implemented yet to minimize risk to account
import type { LinkedInClient } from './base.js';
import type { Profile, MiniProfile } from '../types.js';
import { getIncludedArray, extractProfilePictureUrl } from '../util/parsers.js';
interface ProfileApiResponse {
profile?: {
firstName?: string;
lastName?: string;
headline?: string;
summary?: string;
industryName?: string;
locationName?: string;
publicIdentifier?: string;
entityUrn?: string;
profilePicture?: {
displayImageReference?: {
vectorImage?: {
rootUrl?: string;
artifacts?: Array<{
fileIdentifyingUrlPathSegment?: string;
width?: number;
}>;
};
};
};
backgroundPicture?: {
displayImageReference?: {
vectorImage?: {
rootUrl?: string;
artifacts?: Array<{
fileIdentifyingUrlPathSegment?: string;
width?: number;
}>;
};
};
};
};
}
interface MeResponse {
miniProfile?: {
firstName?: string;
lastName?: string;
publicIdentifier?: string;
entityUrn?: string;
headline?: string;
picture?: {
'com.linkedin.common.VectorImage'?: {
rootUrl?: string;
artifacts?: Array<{
fileIdentifyingUrlPathSegment?: string;
}>;
};
};
};
plainId?: string;
}
export async function getProfile(
client: LinkedInClient,
publicIdentifier: string
): Promise<Profile> {
// URL-encode the identifier to handle special characters safely
const encodedId = encodeURIComponent(publicIdentifier);
const response = await client.get<ProfileApiResponse>(
`/identity/dash/profiles?q=memberIdentity&memberIdentity=${encodedId}&decorationId=com.linkedin.voyager.dash.deco.identity.profile.WebTopCardCore-16`
);
// LinkedIn returns data in 'included' array or nested structure
const data = response.data || response;
const included = getIncludedArray(response);
// Try to find profile in included array
let profile: Profile | null = null;
for (const item of included) {
const itemAny = item as Record<string, unknown>;
if (itemAny.$type === 'com.linkedin.voyager.dash.identity.profile.Profile') {
profile = parseProfile(itemAny, publicIdentifier);
break;
}
}
if (!profile && data) {
profile = parseProfile(data as Record<string, unknown>, publicIdentifier);
}
if (!profile) {
throw new Error(
`Profile not found: ${publicIdentifier}. ` +
`The profile may be private, the username may be incorrect, or your session may have expired.`
);
}
return profile;
}
function parseProfile(data: Record<string, unknown>, publicIdentifier: string): Profile {
const firstName = (data.firstName as string) || '';
const lastName = (data.lastName as string) || '';
// Use shared utility for consistent profile picture extraction
const profilePictureUrl = extractProfilePictureUrl(data);
return {
publicIdentifier: (data.publicIdentifier as string) || publicIdentifier,
firstName,
lastName,
headline: data.headline as string | undefined,
summary: data.summary as string | undefined,
industryName: data.industryName as string | undefined,
locationName: data.locationName as string | undefined,
profilePictureUrl,
entityUrn: data.entityUrn as string | undefined,
vanityName: data.vanityName as string | undefined,
};
}
export async function getMe(client: LinkedInClient): Promise<MiniProfile> {
const response = await client.get<MeResponse>('/me');
// The miniProfile data is in the 'included' array, referenced by '*miniProfile' in data
const included = getIncludedArray(response);
// Find the miniProfile in included array
let miniProfile: Record<string, unknown> | null = null;
for (const item of included) {
const itemAny = item as Record<string, unknown>;
if (
itemAny.$type === 'com.linkedin.voyager.identity.shared.MiniProfile' ||
itemAny.firstName !== undefined
) {
miniProfile = itemAny;
break;
}
}
if (!miniProfile) {
throw new Error('Could not fetch current user profile');
}
const profilePictureUrl = extractProfilePictureUrl(miniProfile);
return {
publicIdentifier: (miniProfile.publicIdentifier as string) || '',
firstName: (miniProfile.firstName as string) || '',
lastName: (miniProfile.lastName as string) || '',
headline: miniProfile.occupation as string | undefined,
profilePictureUrl,
entityUrn: miniProfile.entityUrn as string | undefined,
};
}
import type { LinkedInClient } from './base.js';
import type { SearchResult, SearchResultItem } from '../types.js';
import { getIncludedArray, buildEntityMap, extractProfilePictureUrl } from '../util/parsers.js';
export type SearchType = 'people' | 'jobs' | 'companies' | 'posts';
export interface SearchOptions {
type?: SearchType;
count?: number;
start?: number;
}
const SEARCH_TYPE_FILTERS: Record<SearchType, string> = {
people: 'List(PEOPLE)',
jobs: 'List(JOBS)',
companies: 'List(COMPANIES)',
posts: 'List(CONTENT)',
};
export async function search(
client: LinkedInClient,
query: string,
options: SearchOptions = {}
): Promise<SearchResult> {
const { type = 'people', count = 10, start = 0 } = options;
const encodedQuery = encodeURIComponent(query);
const typeFilter = SEARCH_TYPE_FILTERS[type];
const endpoint = `/graphql?variables=(start:${start},origin:FACETED_SEARCH,query:(keywords:${encodedQuery},flagshipSearchIntent:SEARCH_SRP,queryParameters:${typeFilter},includeFiltersInResponse:false))&queryId=voyagerSearchDashClusters.5c2261c89a94e9f6aa1ae830c84ebca3`;
const response = await client.get<unknown>(endpoint);
const included = getIncludedArray(response);
const entityMap = buildEntityMap(included);
const items: SearchResultItem[] = [];
for (const item of included) {
const itemAny = item as Record<string, unknown>;
const $type = itemAny.$type as string;
if ($type === 'com.linkedin.voyager.dash.search.EntityResultViewModel') {
const parsed = parseSearchItem(itemAny, type, entityMap);
if (parsed) {
items.push(parsed);
}
}
}
return {
type,
items: items.slice(0, count),
paging: {
count,
start,
},
};
}
function parseSearchItem(
data: Record<string, unknown>,
type: SearchType,
entityMap: Map<string, unknown>
): SearchResultItem | null {
const entityUrn = data.entityUrn as string;
if (!entityUrn) return null;
const title = extractText(data.title);
if (!title) return null;
const subtitle = extractText(data.primarySubtitle);
const secondarySubtitle = extractText(data.secondarySubtitle);
let imageUrl: string | undefined;
const image = data.image as Record<string, unknown> | undefined;
if (image?.attributes) {
const attrs = image.attributes as Array<Record<string, unknown>>;
if (attrs.length > 0) {
const detailData = attrs[0].detailData as Record<string, unknown> | undefined;
if (detailData) {
// Try to resolve profile picture from entity map
const profilePictureRef = detailData['*profilePicture'] as string | undefined;
if (profilePictureRef && entityMap.has(profilePictureRef)) {
const profilePicData = entityMap.get(profilePictureRef) as Record<string, unknown>;
imageUrl = extractProfilePictureUrl(profilePicData);
}
// Try to resolve company logo from entity map
const companyLogoRef = detailData['*companyLogo'] as string | undefined;
if (!imageUrl && companyLogoRef && entityMap.has(companyLogoRef)) {
const logoData = entityMap.get(companyLogoRef) as Record<string, unknown>;
imageUrl = extractProfilePictureUrl(logoData);
}
// Fall back to inline vector image
if (!imageUrl) {
const vectorImage = detailData.vectorImage as Record<string, unknown> | undefined;
if (vectorImage?.rootUrl) {
const artifacts = vectorImage.artifacts as Array<Record<string, unknown>> | undefined;
if (artifacts && artifacts.length > 0 && artifacts[0].fileIdentifyingUrlPathSegment) {
imageUrl = `${vectorImage.rootUrl}${artifacts[0].fileIdentifyingUrlPathSegment}`;
}
}
}
}
}
}
const navigationUrl = data.navigationUrl as string | undefined;
return {
entityUrn,
title,
subtitle,
secondarySubtitle,
imageUrl,
navigationUrl,
trackingId: data.trackingId as string | undefined,
};
}
function extractText(field: unknown): string | undefined {
if (!field) return undefined;
const fieldObj = field as Record<string, unknown>;
// Direct text
if (typeof fieldObj.text === 'string') {
return fieldObj.text;
}
// Nested text object
if (fieldObj.text && typeof (fieldObj.text as Record<string, unknown>).text === 'string') {
return (fieldObj.text as Record<string, unknown>).text as string;
}
return undefined;
}
import { extractLinkedInCookies, type CookieOptions } from './util/cookies.js';
import { LinkedInClient } from './api/base.js';
import { getMe } from './api/profile.js';
import { loadConfig } from './config.js';
import type { LinkedInCredentials, MiniProfile } from './types.js';
export interface AuthOptions {
// Manual cookie values (highest priority)
liAt?: string;
jsessionId?: string;
// Browser extraction options
cookieSource?: string[];
chromeProfile?: string;
firefoxProfile?: string;
timeout?: number;
}
export async function authenticate(options: AuthOptions = {}): Promise<LinkedInClient> {
const config = loadConfig();
const timeout = options.timeout || config.timeoutMs;
// Priority 1: CLI arguments
if (options.liAt && options.jsessionId) {
const credentials: LinkedInCredentials = {
liAt: options.liAt,
jsessionId: options.jsessionId.replace(/^"|"$/g, ''),
};
return new LinkedInClient(credentials, timeout);
}
// Priority 2: Config file
if (config.liAt && config.jsessionId) {
const credentials: LinkedInCredentials = {
liAt: config.liAt,
jsessionId: config.jsessionId.replace(/^"|"$/g, ''),
};
return new LinkedInClient(credentials, timeout);
}
// Priority 3: Browser cookie extraction
const cookieOptions: CookieOptions = {
sources: options.cookieSource || config.cookieSource,
chromeProfile: options.chromeProfile || config.chromeProfile,
firefoxProfile: options.firefoxProfile || config.firefoxProfile,
timeout,
};
const credentials = await extractLinkedInCookies(cookieOptions);
return new LinkedInClient(credentials, timeout);
}
export async function verifyAuth(client: LinkedInClient): Promise<MiniProfile> {
return getMe(client);
}
export interface CredentialsSummary {
liAtPrefix: string;
hasJsessionId: boolean;
}
export async function checkCredentials(options: AuthOptions = {}): Promise<{
valid: boolean;
summary?: CredentialsSummary;
error?: string;
}> {
try {
const client = await authenticate(options);
const summary = client.getCredentialsSummary();
// Verify by making a simple API call
await getMe(client);
return {
valid: true,
summary,
};
} catch (error) {
return {
valid: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
import { Command } from 'commander';
import { authenticate, checkCredentials, verifyAuth } from '../auth.js';
import { saveConfig, getConfigPath } from '../config.js';
import { formatMiniProfile } from '../formatters/plain.js';
import { formatJson } from '../formatters/json.js';
import type { CommandOptions } from '../types.js';
import { parseTimeout, buildAuthOptions, handleCommandError } from './utils.js';
export function registerAuthCommands(program: Command): void {
program
.command('setup')
.description('Configure LinkedIn credentials manually')
.action(async () => {
// Get options from parent program (--li-at and --jsessionid are global options)
const opts = program.opts() as CommandOptions;
// If no tokens provided, show instructions
if (!opts.liAt || !opts.jsessionid) {
console.log(`LinkedIn Setup
RECOMMENDED: Browser Automation Mode (most reliable)
====================================================
LinkedIn aggressively invalidates API-only sessions. Using browser
automation with the Claude browser extension is much more reliable.
1. Create a dedicated Chrome profile for automation:
- Open Chrome → click your profile icon (top right) → "Add"
- Name it something like "Claude" or "Automation"
- This keeps your automation separate from personal browsing
2. In your automation profile:
- Log into LinkedIn (www.linkedin.com)
- Install the Claude browser extension from https://claude.ai/chrome
- Keep this Chrome window open when using the CLI
3. Update your LinkedIn CLI config to use this profile:
Edit ~/.config/linkedin/config.json5:
{
"chromeProfile": "Claude", // Your automation profile name
"browserMode": true
}
ALTERNATIVE: Manual Cookie Setup (sessions may expire quickly)
==============================================================
Extract cookies manually - note that LinkedIn may invalidate these
after just a few API calls.
1. Open Chrome and go to https://www.linkedin.com
2. Log in to your LinkedIn account
3. Open DevTools: Press F12 (or Cmd+Option+I on Mac)
4. Go to: Application tab → Cookies → https://www.linkedin.com
5. Find and copy these cookie values:
- li_at (long string, your session token)
- JSESSIONID (looks like "ajax:1234567890")
6. Run this command with your values:
linkedin setup --li-at "YOUR_LI_AT_VALUE" --jsessionid "YOUR_JSESSIONID_VALUE"
Note: The quotes are important if the values contain special characters.
`);
return;
}
// Clean up the jsessionid (remove quotes if present)
const cleanJsessionId = opts.jsessionid!.replace(/^"|"$/g, '');
// Save to config
saveConfig({
liAt: opts.liAt,
jsessionId: cleanJsessionId,
});
// Verify the credentials work
try {
const result = await checkCredentials({
liAt: opts.liAt,
jsessionId: cleanJsessionId,
});
if (result.valid) {
if (opts.json) {
console.log(formatJson({ status: 'saved', configPath: getConfigPath() }));
} else {
console.log(`Credentials saved to ${getConfigPath()}`);
console.log('Credentials verified successfully!');
}
} else {
if (opts.json) {
console.log(formatJson({ status: 'saved_but_invalid', error: result.error }));
} else {
console.log(`Credentials saved to ${getConfigPath()}`);
console.warn(`Warning: Credentials may be invalid: ${result.error}`);
}
}
} catch (error) {
if (opts.json) {
console.log(formatJson({ status: 'saved', warning: String(error) }));
} else {
console.log(`Credentials saved to ${getConfigPath()}`);
console.warn(`Warning: Could not verify credentials: ${error}`);
}
}
});
program
.command('check')
.description('Verify LinkedIn credentials are valid')
.action(async () => {
const opts = program.opts() as CommandOptions;
try {
const result = await checkCredentials(buildAuthOptions(opts));
if (result.valid) {
if (opts.json) {
console.log(formatJson({ status: 'valid', hasCredentials: true }));
} else {
console.log('LinkedIn credentials are valid.');
console.log(`li_at: [present]`);
}
} else {
if (opts.json) {
console.log(formatJson({ status: 'invalid', error: result.error }));
} else {
console.error(`Invalid credentials: ${result.error}`);
}
process.exit(1);
}
} catch (error) {
if (opts.json) {
console.log(formatJson({ status: 'error', error: String(error) }));
} else {
console.error(`Error: ${error}`);
}
process.exit(1);
}
});
program
.command('whoami')
.description('Show the currently authenticated LinkedIn user')
.action(async () => {
const opts = program.opts() as CommandOptions;
try {
const client = await authenticate(buildAuthOptions(opts));
const me = await verifyAuth(client);
if (opts.json) {
console.log(formatJson(me));
} else {
console.log(formatMiniProfile(me));
}
} catch (error) {
handleCommandError(error, opts);
}
});
program
.command('logout')
.description('Remove stored LinkedIn credentials')
.action(async () => {
const opts = program.opts() as CommandOptions;
// Save empty credentials to config
saveConfig({
liAt: undefined,
jsessionId: undefined,
});
if (opts.json) {
console.log(formatJson({ status: 'logged_out' }));
} else {
console.log('Credentials removed from config.');
}
});
}
import { Command } from 'commander';
import { authenticate } from '../auth.js';
import { getConnections } from '../api/connections.js';
import { formatConnections } from '../formatters/plain.js';
import { formatJson } from '../formatters/json.js';
import type { CommandOptions } from '../types.js';
import { buildAuthOptions, handleCommandError } from './utils.js';
export function registerConnectionsCommands(program: Command): void {
program
.command('connections')
.description('List your LinkedIn connections')
.option('-n, --count <number>', 'Number of connections to fetch', '50')
.option('--start <number>', 'Pagination start offset', '0')
.action(async (cmdOpts: { count: string; start: string }) => {
const opts = program.opts() as CommandOptions;
try {
const client = await authenticate(buildAuthOptions(opts));
const count = parseInt(cmdOpts.count, 10) || 50;
const start = parseInt(cmdOpts.start, 10) || 0;
const { connections, paging } = await getConnections(client, { count, start });
if (opts.json) {
console.log(formatJson({ connections, paging }));
} else {
console.log(formatConnections(connections));
if (paging && connections.length > 0) {
const total = paging.total ? ` of ${paging.total}` : '';
console.log(`\nShowing ${connections.length}${total} connections (offset: ${start})`);
}
}
} catch (error) {
handleCommandError(error, opts);
}
});
}
import { Command } from 'commander';
import { authenticate } from '../auth.js';
import { getFeed } from '../api/feed.js';
import { formatFeed } from '../formatters/plain.js';
import { formatJson } from '../formatters/json.js';
import type { CommandOptions } from '../types.js';
import { buildAuthOptions, handleCommandError } from './utils.js';
export function registerFeedCommands(program: Command): void {
program
.command('feed')
.description('Get your LinkedIn home feed')
.option('-n, --count <number>', 'Number of posts to fetch', '20')
.option('--start <number>', 'Pagination start offset', '0')
.action(async (cmdOpts: { count: string; start: string }) => {
const opts = program.opts() as CommandOptions;
try {
const client = await authenticate(buildAuthOptions(opts));
const count = parseInt(cmdOpts.count, 10) || 20;
const start = parseInt(cmdOpts.start, 10) || 0;
const { updates, paging } = await getFeed(client, { count, start });
if (opts.json) {
console.log(formatJson({ updates, paging }));
} else {
console.log(formatFeed(updates));
if (paging && updates.length > 0) {
console.log(`\nShowing ${updates.length} posts (offset: ${start})`);
}
}
} catch (error) {
handleCommandError(error, opts);
}
});
}
import { Command } from 'commander';
import { authenticate } from '../auth.js';
import { getInbox, getConversation } from '../api/messaging.js';
import { formatInbox, formatMessages } from '../formatters/plain.js';
import { formatJson } from '../formatters/json.js';
import type { CommandOptions } from '../types.js';
import { buildAuthOptions, handleCommandError } from './utils.js';
export function registerMessagesCommands(program: Command): void {
program
.command('inbox')
.description('List your LinkedIn message conversations')
.option('-n, --count <number>', 'Number of conversations to fetch', '20')
.option('--start <number>', 'Pagination start offset', '0')
.action(async (cmdOpts: { count: string; start: string }) => {
const opts = program.opts() as CommandOptions;
try {
const client = await authenticate(buildAuthOptions(opts));
const count = parseInt(cmdOpts.count, 10) || 20;
const start = parseInt(cmdOpts.start, 10) || 0;
const { conversations, paging } = await getInbox(client, { count, start });
if (opts.json) {
console.log(formatJson({ conversations, paging }));
} else {
console.log(formatInbox(conversations));
if (paging && conversations.length > 0) {
console.log(`\nShowing ${conversations.length} conversations (offset: ${start})`);
}
}
} catch (error) {
handleCommandError(error, opts);
}
});
program
.command('read <conversation-id>')
.description('Read messages from a conversation')
.option('-n, --count <number>', 'Number of messages to fetch', '20')
.action(async (conversationId: string, cmdOpts: { count: string }) => {
const opts = program.opts() as CommandOptions;
try {
const client = await authenticate(buildAuthOptions(opts));
const count = parseInt(cmdOpts.count, 10) || 20;
const { messages } = await getConversation(client, conversationId, { count });
if (opts.json) {
console.log(formatJson({ messages }));
} else {
console.log(formatMessages(messages));
}
} catch (error) {
handleCommandError(error, opts);
}
});
}
import { Command } from 'commander';
import { authenticate } from '../auth.js';
import { getProfile } from '../api/profile.js';
import { formatProfile } from '../formatters/plain.js';
import { formatJson } from '../formatters/json.js';
import type { CommandOptions } from '../types.js';
import { buildAuthOptions, handleCommandError } from './utils.js';
export function registerProfileCommands(program: Command): void {
program
.command('profile <username>')
.description('View a LinkedIn profile by username/vanity URL')
.action(async (username: string) => {
const opts = program.opts() as CommandOptions;
try {
const client = await authenticate(buildAuthOptions(opts));
// Handle full URLs or just usernames
let publicIdentifier = username;
if (username.includes('linkedin.com/in/')) {
const match = username.match(/linkedin\.com\/in\/([^/?]+)/);
if (match) {
publicIdentifier = match[1];
}
}
const profile = await getProfile(client, publicIdentifier);
if (opts.json) {
console.log(formatJson(profile));
} else {
console.log(formatProfile(profile));
}
} catch (error) {
handleCommandError(error, opts);
}
});
}
import { Command } from 'commander';
import { authenticate } from '../auth.js';
import { search, type SearchType } from '../api/search.js';
import { formatSearchResults } from '../formatters/plain.js';
import { formatJson } from '../formatters/json.js';
import type { CommandOptions } from '../types.js';
import { buildAuthOptions, handleCommandError } from './utils.js';
const VALID_TYPES: SearchType[] = ['people', 'jobs', 'companies', 'posts'];
export function registerSearchCommands(program: Command): void {
program
.command('search <query>')
.description('Search LinkedIn for people, jobs, companies, or posts')
.option('-t, --type <type>', `Search type: ${VALID_TYPES.join(', ')}`, 'people')
.option('-n, --count <number>', 'Number of results', '10')
.option('--start <number>', 'Pagination start offset', '0')
.action(async (query: string, cmdOpts: { type: string; count: string; start: string }) => {
const opts = program.opts() as CommandOptions;
try {
const type = cmdOpts.type as SearchType;
if (!VALID_TYPES.includes(type)) {
throw new Error(`Invalid search type: ${type}. Valid types: ${VALID_TYPES.join(', ')}`);
}
const client = await authenticate(buildAuthOptions(opts));
const count = parseInt(cmdOpts.count, 10) || 10;
const start = parseInt(cmdOpts.start, 10) || 0;
const result = await search(client, query, { type, count, start });
if (opts.json) {
console.log(formatJson(result));
} else {
console.log(formatSearchResults(result.items, type));
if (result.items.length > 0) {
console.log(`\nShowing ${result.items.length} ${type}`);
}
}
} catch (error) {
handleCommandError(error, opts);
}
});
}
import type { CommandOptions } from '../types.js';
import type { AuthOptions } from '../auth.js';
import { formatJson } from '../formatters/json.js';
/** Parse timeout option with NaN validation */
export function parseTimeout(value: string | number | undefined): number | undefined {
if (value === undefined) return undefined;
const parsed = parseInt(String(value), 10);
return isNaN(parsed) ? undefined : parsed;
}
/** Build auth options from command options */
export function buildAuthOptions(opts: CommandOptions): AuthOptions {
return {
liAt: opts.liAt,
jsessionId: opts.jsessionid,
cookieSource: opts.cookieSource,
chromeProfile: opts.chromeProfile,
firefoxProfile: opts.firefoxProfile,
timeout: parseTimeout(opts.timeout),
};
}
/** Standard error handler for commands */
export function handleCommandError(error: unknown, opts: CommandOptions): void {
if (opts.json) {
console.log(formatJson({ error: String(error) }));
} else {
console.error(`Error: ${error}`);
}
process.exit(1);
}
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import JSON5 from 'json5';
import type { Config } from './types.js';
const CONFIG_DIR = join(homedir(), '.config', 'linkedin');
const CONFIG_FILE = join(CONFIG_DIR, 'config.json5');
// File permissions: owner read/write only (0600)
const CONFIG_FILE_MODE = 0o600;
// Directory permissions: owner only (0700)
const CONFIG_DIR_MODE = 0o700;
const DEFAULT_CONFIG: Config = {
cookieSource: ['chrome'],
timeoutMs: 30000,
defaultFormat: 'plain',
};
export function getConfigDir(): string {
return CONFIG_DIR;
}
export function getConfigPath(): string {
return CONFIG_FILE;
}
export function loadConfig(): Config {
if (!existsSync(CONFIG_FILE)) {
return DEFAULT_CONFIG;
}
try {
const content = readFileSync(CONFIG_FILE, 'utf-8');
const parsed = JSON5.parse(content);
return { ...DEFAULT_CONFIG, ...parsed };
} catch (error) {
// Warn user that config parsing failed (but file exists)
console.warn(`Warning: Failed to parse config file at ${CONFIG_FILE}. Using defaults.`);
if (error instanceof Error) {
console.warn(` Reason: ${error.message}`);
}
return DEFAULT_CONFIG;
}
}
export function saveConfig(config: Partial<Config>): void {
if (!existsSync(CONFIG_DIR)) {
mkdirSync(CONFIG_DIR, { recursive: true, mode: CONFIG_DIR_MODE });
}
const existing = loadConfig();
const merged = { ...existing, ...config };
const content = JSON5.stringify(merged, null, 2);
// Write with restrictive permissions (owner read/write only)
writeFileSync(CONFIG_FILE, content, { encoding: 'utf-8', mode: CONFIG_FILE_MODE });
// Ensure permissions are correct even if file existed
chmodSync(CONFIG_FILE, CONFIG_FILE_MODE);
}
export function ensureConfigDir(): void {
if (!existsSync(CONFIG_DIR)) {
mkdirSync(CONFIG_DIR, { recursive: true, mode: CONFIG_DIR_MODE });
}
}
export function formatJson(data: unknown): string {
return JSON.stringify(data, null, 2);
}
import type {
Profile,
MiniProfile,
FeedUpdate,
Connection,
Conversation,
Message,
SearchResultItem,
} from '../types.js';
export function formatProfile(profile: Profile): string {
const lines: string[] = [];
lines.push(`${profile.firstName} ${profile.lastName}`);
if (profile.headline) {
lines.push(profile.headline);
}
if (profile.locationName) {
lines.push(profile.locationName);
}
if (profile.industryName) {
lines.push(`Industry: ${profile.industryName}`);
}
if (profile.summary) {
lines.push('');
lines.push(profile.summary);
}
lines.push('');
lines.push(`https://linkedin.com/in/${profile.publicIdentifier}`);
return lines.join('\n');
}
export function formatMiniProfile(profile: MiniProfile): string {
const parts = [`${profile.firstName} ${profile.lastName}`];
if (profile.headline) {
parts.push(`- ${profile.headline}`);
}
if (profile.publicIdentifier) {
parts.push(`(@${profile.publicIdentifier})`);
}
return parts.join(' ');
}
export function formatFeedUpdate(update: FeedUpdate): string {
const lines: string[] = [];
// Actor info
const actorName = `${update.actor.firstName} ${update.actor.lastName}`.trim();
lines.push(`${actorName}`);
if (update.actor.headline) {
lines.push(update.actor.headline);
}
// Timestamp
if (update.postedAt) {
lines.push(formatTimestamp(update.postedAt));
}
lines.push('');
// Post content
if (update.commentary) {
lines.push(update.commentary);
lines.push('');
}
// Content type indicator
if (update.content) {
switch (update.content.type) {
case 'article':
if (update.content.title) {
lines.push(`[Article] ${update.content.title}`);
}
if (update.content.url) {
lines.push(update.content.url);
}
break;
case 'image':
lines.push('[Image]');
break;
case 'video':
lines.push('[Video]');
break;
case 'document':
if (update.content.title) {
lines.push(`[Document] ${update.content.title}`);
}
break;
case 'poll':
lines.push('[Poll]');
break;
}
lines.push('');
}
// Engagement
if (update.socialDetail?.totalSocialActivityCounts) {
const counts = update.socialDetail.totalSocialActivityCounts;
const engagement: string[] = [];
if (counts.numLikes) engagement.push(`${counts.numLikes} likes`);
if (counts.numComments) engagement.push(`${counts.numComments} comments`);
if (counts.numShares) engagement.push(`${counts.numShares} shares`);
if (engagement.length > 0) {
lines.push(engagement.join(' | '));
}
}
lines.push('---');
return lines.join('\n');
}
export function formatFeed(updates: FeedUpdate[]): string {
if (updates.length === 0) {
return 'No feed updates found.';
}
return updates.map(formatFeedUpdate).join('\n');
}
export function formatConnection(connection: Connection): string {
const { connectedMember } = connection;
const name = `${connectedMember.firstName} ${connectedMember.lastName}`.trim();
const parts = [name];
if (connectedMember.headline) {
parts.push(`- ${connectedMember.headline}`);
}
if (connectedMember.publicIdentifier) {
parts.push(`(@${connectedMember.publicIdentifier})`);
}
return parts.join(' ');
}
export function formatConnections(connections: Connection[]): string {
if (connections.length === 0) {
return 'No connections found.';
}
return connections.map(formatConnection).join('\n');
}
export function formatConversation(conversation: Conversation): string {
const participants = conversation.participants
.map((p) => `${p.firstName} ${p.lastName}`.trim())
.join(', ');
const lines = [participants];
if (conversation.lastMessage) {
const preview =
conversation.lastMessage.body.length > 60
? conversation.lastMessage.body.slice(0, 60) + '...'
: conversation.lastMessage.body;
lines.push(` "${preview}"`);
}
lines.push(` ${formatTimestamp(conversation.lastActivityAt)}`);
if (conversation.unreadCount && conversation.unreadCount > 0) {
lines.push(` [${conversation.unreadCount} unread]`);
}
return lines.join('\n');
}
export function formatInbox(conversations: Conversation[]): string {
if (conversations.length === 0) {
return 'No conversations found.';
}
return conversations.map(formatConversation).join('\n\n');
}
export function formatMessage(message: Message): string {
const senderName = `${message.sender.firstName} ${message.sender.lastName}`.trim();
const timestamp = formatTimestamp(message.createdAt);
return `[${timestamp}] ${senderName}: ${message.body}`;
}
export function formatMessages(messages: Message[]): string {
if (messages.length === 0) {
return 'No messages found.';
}
return messages.map(formatMessage).join('\n');
}
export function formatSearchResult(item: SearchResultItem): string {
const lines = [item.title];
if (item.subtitle) {
lines.push(` ${item.subtitle}`);
}
if (item.secondarySubtitle) {
lines.push(` ${item.secondarySubtitle}`);
}
if (item.navigationUrl) {
lines.push(` ${item.navigationUrl}`);
}
return lines.join('\n');
}
export function formatSearchResults(items: SearchResultItem[], type: string): string {
if (items.length === 0) {
return `No ${type} found.`;
}
return items.map(formatSearchResult).join('\n\n');
}
function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
#!/usr/bin/env node
import { Command } from 'commander';
import { registerAuthCommands } from './commands/auth.js';
import { registerFeedCommands } from './commands/feed.js';
import { registerProfileCommands } from './commands/profile.js';
import { registerSearchCommands } from './commands/search.js';
import { registerConnectionsCommands } from './commands/connections.js';
import { registerMessagesCommands } from './commands/messages.js';
const program = new Command();
program
.name('linkedin')
.description('LinkedIn CLI - read your feed, profiles, and messages')
.version('0.1.0')
.option('--json', 'Output in JSON format')
.option('--plain', 'Output in plain text format (default)')
.option('--li-at <token>', 'LinkedIn li_at cookie value (manual auth)')
.option('--jsessionid <token>', 'LinkedIn JSESSIONID cookie value (manual auth)')
.option('--chrome-profile <name>', 'Chrome profile name for cookie extraction', 'Default')
.option('--firefox-profile <name>', 'Firefox profile name for cookie extraction')
.option('--cookie-source <source...>', 'Cookie source(s) for extraction')
.option('--timeout <ms>', 'Request timeout in milliseconds', '30000');
// Register all command groups
registerAuthCommands(program);
registerFeedCommands(program);
registerProfileCommands(program);
registerSearchCommands(program);
registerConnectionsCommands(program);
registerMessagesCommands(program);
// Add help examples
program.addHelpText(
'after',
`
Examples:
$ linkedin check Verify credentials are valid
$ linkedin whoami Show logged-in user
$ linkedin feed -n 10 Get 10 posts from your feed
$ linkedin profile satyanadella View a profile
$ linkedin search "software engineer" -t people
$ linkedin connections -n 50 List your connections
$ linkedin inbox View your message inbox
$ linkedin read <conversation-id> Read a conversation
Manual setup (if automatic cookie extraction fails):
1. Open LinkedIn in Chrome and log in
2. Open DevTools (F12) > Application > Cookies > linkedin.com
3. Copy the values of 'li_at' and 'JSESSIONID'
4. Run: linkedin setup --li-at "YOUR_LI_AT" --jsessionid "YOUR_JSESSIONID"
Cookie extraction:
By default, cookies are extracted from Chrome.
Use --cookie-source to specify browser(s): chrome, firefox, safari, edge
Use --chrome-profile or --firefox-profile to specify a profile name.
`
);
program.parse(process.argv);
// LinkedIn API Types
export interface LinkedInCredentials {
liAt: string;
jsessionId: string;
}
export interface Config {
cookieSource: string[];
chromeProfile?: string;
firefoxProfile?: string;
timeoutMs: number;
defaultFormat: 'plain' | 'json';
// Manual auth cookies (stored by setup command)
liAt?: string;
jsessionId?: string;
// Browser automation mode (recommended - avoids API detection)
browserMode?: boolean;
}
export interface Profile {
publicIdentifier: string;
firstName: string;
lastName: string;
headline?: string;
summary?: string;
industryName?: string;
locationName?: string;
profilePictureUrl?: string;
connectionCount?: number;
followerCount?: number;
backgroundPictureUrl?: string;
vanityName?: string;
entityUrn?: string;
}
export interface MiniProfile {
publicIdentifier: string;
firstName: string;
lastName: string;
headline?: string;
profilePictureUrl?: string;
entityUrn?: string;
trackingId?: string;
}
export interface FeedUpdate {
urn: string;
actor: MiniProfile;
commentary?: string;
content?: FeedContent;
socialDetail?: SocialDetail;
postedAt?: number;
reshared?: boolean;
originalUpdate?: FeedUpdate;
}
export interface FeedContent {
type: 'article' | 'image' | 'video' | 'document' | 'poll' | 'celebration' | 'unknown';
title?: string;
description?: string;
url?: string;
images?: string[];
}
export interface SocialDetail {
totalSocialActivityCounts?: {
numLikes?: number;
numComments?: number;
numShares?: number;
};
liked?: boolean;
threadId?: string;
}
export interface Connection {
entityUrn: string;
connectedMember: MiniProfile;
connectedAt: number;
}
export interface Conversation {
entityUrn: string;
lastActivityAt: number;
participants: MiniProfile[];
lastMessage?: Message;
unreadCount?: number;
muted?: boolean;
}
export interface Message {
entityUrn: string;
createdAt: number;
sender: MiniProfile;
body: string;
attachments?: MessageAttachment[];
}
export interface MessageAttachment {
type: string;
name?: string;
url?: string;
}
export interface SearchResult {
type: 'people' | 'jobs' | 'companies' | 'posts';
items: SearchResultItem[];
paging?: {
count: number;
start: number;
total?: number;
};
}
export interface SearchResultItem {
entityUrn: string;
title: string;
subtitle?: string;
secondarySubtitle?: string;
imageUrl?: string;
navigationUrl?: string;
trackingId?: string;
}
export interface ApiResponse<T> {
data?: T;
included?: unknown[];
paging?: {
count: number;
start: number;
total?: number;
links?: unknown[];
};
}
export interface CommandOptions {
plain?: boolean;
json?: boolean;
count?: number;
liAt?: string;
jsessionid?: string;
chromeProfile?: string;
firefoxProfile?: string;
cookieSource?: string[];
timeout?: number;
browserMode?: boolean;
}
import { getCookies, type BrowserName } from '@steipete/sweet-cookie';
import type { LinkedInCredentials } from '../types.js';
const LINKEDIN_URL = 'https://www.linkedin.com';
export interface CookieOptions {
sources?: string[];
chromeProfile?: string;
firefoxProfile?: string;
timeout?: number;
}
export async function extractLinkedInCookies(
options: CookieOptions = {}
): Promise<LinkedInCredentials> {
const {
sources = ['chrome'],
chromeProfile = 'Default',
firefoxProfile,
timeout = 30000,
} = options;
const browsers = sources.map((s) => s.toLowerCase() as BrowserName);
const result = await getCookies({
url: LINKEDIN_URL,
names: ['li_at', 'JSESSIONID'],
browsers,
chromeProfile,
firefoxProfile,
timeoutMs: timeout,
mode: 'first',
});
const cookies = result.cookies;
const liAtCookie = cookies.find((c) => c.name === 'li_at');
const jsessionIdCookie = cookies.find((c) => c.name === 'JSESSIONID');
const liAt = liAtCookie?.value;
const jsessionId = jsessionIdCookie?.value;
if (!liAt) {
const warnings = result.warnings.length > 0 ? `\nWarnings: ${result.warnings.join('; ')}` : '';
const foundCookies = result.cookies.map(c => c.name).join(', ') || 'none';
throw new Error(
`Could not find li_at cookie (found: ${foundCookies}). ` +
`Make sure you are logged into LinkedIn in your browser.` +
`\n\nTry one of these:` +
`\n 1. Log into LinkedIn at https://www.linkedin.com in Chrome` +
`\n 2. Use a different browser: --cookie-source safari` +
`\n 3. Use a different Chrome profile: --chrome-profile "Profile 1"${warnings}`
);
}
if (!jsessionId) {
throw new Error(
'Could not find JSESSIONID cookie. Make sure you are logged into LinkedIn in your browser.'
);
}
// JSESSIONID comes with quotes, strip them
const cleanJsessionId = jsessionId.replace(/^"|"$/g, '');
return {
liAt,
jsessionId: cleanJsessionId,
};
}
export function validateCredentials(creds: LinkedInCredentials): boolean {
return Boolean(creds.liAt && creds.jsessionId);
}
import type { LinkedInCredentials } from '../types.js';
const USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
export function getLinkedInHeaders(creds: LinkedInCredentials): Record<string, string> {
// Keep headers minimal - extra headers actually cause 400 errors
return {
'User-Agent': USER_AGENT,
'Accept': 'application/vnd.linkedin.normalized+json+2.1',
'x-restli-protocol-version': '2.0.0',
'csrf-token': creds.jsessionId,
'Cookie': `li_at=${creds.liAt}; JSESSIONID="${creds.jsessionId}"`,
};
}
import type { MiniProfile } from '../types.js';
/**
* Build an entity map from LinkedIn's included array for reference resolution
*/
export function buildEntityMap(included: unknown[]): Map<string, unknown> {
const entityMap = new Map<string, unknown>();
for (const item of included) {
const itemAny = item as Record<string, unknown>;
if (itemAny.entityUrn) {
entityMap.set(itemAny.entityUrn as string, item);
}
// Also index by miniProfile reference if present
if (itemAny['*miniProfile']) {
entityMap.set(itemAny['*miniProfile'] as string, item);
}
}
return entityMap;
}
/**
* Extract the included array from a LinkedIn API response
*/
export function getIncludedArray(response: unknown): unknown[] {
return (response as { included?: unknown[] }).included || [];
}
/**
* Parse a mini profile from LinkedIn's response format
* Handles multiple picture location formats consistently
*/
export function parseMiniProfile(data: unknown): MiniProfile | null {
if (!data) return null;
const d = data as Record<string, unknown>;
// Skip if this doesn't look like a profile
if (!d.firstName && !d.publicIdentifier) return null;
const profilePictureUrl = extractProfilePictureUrl(d);
return {
publicIdentifier: (d.publicIdentifier as string) || '',
firstName: (d.firstName as string) || '',
lastName: (d.lastName as string) || '',
headline: (d.headline as string) || (d.occupation as string) || undefined,
profilePictureUrl,
entityUrn: d.entityUrn as string | undefined,
};
}
/**
* Extract profile picture URL from various LinkedIn response formats
* Consistently returns the largest available image
*/
export function extractProfilePictureUrl(data: Record<string, unknown>): string | undefined {
// Format 1: profilePicture.displayImageReference.vectorImage (profile responses)
const profilePicture = data.profilePicture as Record<string, unknown> | undefined;
if (profilePicture?.displayImageReference) {
const ref = profilePicture.displayImageReference as Record<string, unknown>;
const vectorImage = ref.vectorImage as Record<string, unknown> | undefined;
if (vectorImage?.rootUrl && vectorImage?.artifacts) {
const url = extractLargestArtifactUrl(vectorImage);
if (url) return url;
}
}
// Format 2: picture['com.linkedin.common.VectorImage'] (mini profile responses)
const picture = data.picture as Record<string, unknown> | undefined;
if (picture?.['com.linkedin.common.VectorImage']) {
const vectorImage = picture['com.linkedin.common.VectorImage'] as Record<string, unknown>;
if (vectorImage?.rootUrl && vectorImage?.artifacts) {
const url = extractLargestArtifactUrl(vectorImage);
if (url) return url;
}
}
return undefined;
}
/**
* Extract the URL for the largest artifact from a vector image
*/
function extractLargestArtifactUrl(vectorImage: Record<string, unknown>): string | undefined {
const artifacts = vectorImage.artifacts as Array<Record<string, unknown>> | undefined;
if (!artifacts || artifacts.length === 0) return undefined;
// Find the largest artifact by width
const largest = artifacts.reduce((prev, curr) => {
const prevWidth = (prev.width as number) || 0;
const currWidth = (curr.width as number) || 0;
return currWidth > prevWidth ? curr : prev;
}, artifacts[0]);
if (largest?.fileIdentifyingUrlPathSegment) {
return `${vectorImage.rootUrl}${largest.fileIdentifyingUrlPathSegment}`;
}
return undefined;
}
/**
* Create a fallback MiniProfile for unknown users
*/
export function createUnknownProfile(): MiniProfile {
return {
publicIdentifier: '',
firstName: 'Unknown',
lastName: '',
};
}
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}