Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -21765,6 +21765,36 @@
"sourceFile": "linkedin/connect.js",
"navigateBefore": true
},
{
"site": "linkedin",
"name": "connections",
"description": "List your LinkedIn first-degree connections (name, headline, profile URL)",
"access": "read",
"domain": "www.linkedin.com",
"strategy": "cookie",
"browser": true,
"args": [
{
"name": "limit",
"type": "int",
"default": 20,
"required": false,
"help": "Number of connections to return (max 500)"
}
],
"columns": [
"rank",
"name",
"occupation",
"public_id",
"connected_at",
"url"
],
"type": "js",
"modulePath": "linkedin/connections.js",
"sourceFile": "linkedin/connections.js",
"navigateBefore": "https://www.linkedin.com"
},
{
"site": "linkedin",
"name": "inbox",
Expand Down
126 changes: 126 additions & 0 deletions clis/linkedin/connections.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import {
LINKEDIN_DOMAIN,
unwrapEvaluateResult,
requireLinkedInCookie,
normalizeWhitespace,
parseLimit,
assertLinkedInAuthenticated,
} from './shared.js';

const CONNECTIONS_PATH = '/voyager/api/relationships/connections';
const PAGE_SIZE = 40;

// Runs in-page: fetch the legacy voyager connections REST endpoint with the
// session csrf token. Returns { authRequired } / { error } / { json }.
async function fetchConnections(url, csrf) {
try {
const res = await fetch(url, {
credentials: 'include',
headers: {
'csrf-token': csrf,
accept: 'application/json',
'x-restli-protocol-version': '2.0.0',
},
});
if (res.status === 401 || res.status === 403) return { authRequired: true, error: 'HTTP ' + res.status };
if (!res.ok) return { error: 'HTTP ' + res.status };
const contentType = res.headers?.get?.('content-type') || '';
if (/\btext\/html\b/i.test(contentType)) {
return { authRequired: true, error: 'HTML auth/checkpoint response' };
}
try {
return { json: await res.json() };
} catch {
return { error: 'response was not valid JSON' };
}
} catch (e) {
return { error: 'fetch failed: ' + ((e && e.message) || String(e)) };
}
}

function optionalText(value, field) {
if (value == null) return '';
if (typeof value !== 'string') {
throw new CommandExecutionError(`LinkedIn connection miniProfile field ${field} was malformed`);
}
return normalizeWhitespace(value);
}

function mapConnection(element, index) {
const mini = element && element.miniProfile;
if (!mini || typeof mini !== 'object') {
throw new CommandExecutionError('LinkedIn connections returned an element without a miniProfile');
}
const publicId = optionalText(mini.publicIdentifier, 'publicIdentifier');
if (!publicId || /[\s/?#]/.test(publicId)) {
throw new CommandExecutionError('LinkedIn connection element missing a stable public identifier');
}
const name = normalizeWhitespace([
optionalText(mini.firstName, 'firstName'),
optionalText(mini.lastName, 'lastName'),
].filter(Boolean).join(' ')) || publicId;
return {
rank: index + 1,
name,
occupation: optionalText(mini.occupation, 'occupation'),
public_id: publicId,
connected_at: Number.isFinite(element.createdAt) ? element.createdAt : 0,
url: publicId ? `https://www.linkedin.com/in/${encodeURIComponent(publicId)}` : '',
};
}

cli({
site: 'linkedin',
name: 'connections',
access: 'read',
description: 'List your LinkedIn first-degree connections (name, headline, profile URL)',
domain: LINKEDIN_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'limit', type: 'int', default: 20, help: 'Number of connections to return (max 500)' },
],
columns: ['rank', 'name', 'occupation', 'public_id', 'connected_at', 'url'],
func: async (page, kwargs) => {
const limit = parseLimit(kwargs.limit, 20, 500);
await page.goto('https://www.linkedin.com/mynetwork/invite-connect/connections/');
await page.wait(2);
await assertLinkedInAuthenticated(page, 'linkedin connections');
const csrf = await requireLinkedInCookie(page, 'linkedin connections');
const rows = [];
let start = 0;
while (rows.length < limit) {
const remaining = limit - rows.length;
const count = remaining < PAGE_SIZE ? remaining : PAGE_SIZE;
const url = `${CONNECTIONS_PATH}?start=${start}&count=${count}`;
const fetched = unwrapEvaluateResult(
await page.evaluate(`(${fetchConnections.toString()})(${JSON.stringify(url)}, ${JSON.stringify(csrf)})`),
);
if (fetched && fetched.authRequired) {
throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn connections API authentication failed: ' + fetched.error);
}
if (!fetched || fetched.error || !fetched.json) {
throw new CommandExecutionError('LinkedIn connections API returned an unexpected response: ' + ((fetched && fetched.error) || 'no data'));
}
const elements = fetched.json.elements;
if (!Array.isArray(elements)) {
throw new CommandExecutionError('LinkedIn connections API returned a malformed payload: missing elements array');
}
if (elements.length === 0) break;
for (const element of elements) {
rows.push(mapConnection(element, rows.length));
if (rows.length >= limit) break;
}
start += elements.length;
if (elements.length < count) break;
}
if (rows.length === 0) {
throw new EmptyResultError('linkedin connections', 'No LinkedIn connections were found.');
}
return rows;
},
});

export const __test__ = { fetchConnections, mapConnection };
109 changes: 109 additions & 0 deletions clis/linkedin/connections.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { AuthRequiredError, CliError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import './connections.js';

const { mapConnection } = await import('./connections.js').then((m) => m.__test__);

function makePage({ evaluateResults = [false], cookies = [{ name: 'JSESSIONID', value: '"ajax:12345"' }] } = {}) {
const evaluate = vi.fn();
for (const result of evaluateResults) evaluate.mockResolvedValueOnce(result);
return {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
getCookies: vi.fn().mockResolvedValue(cookies),
evaluate,
};
}

const conn = (id) => ({
createdAt: 1700000000000 + id,
miniProfile: { firstName: 'First' + id, lastName: 'Last' + id, occupation: 'Job ' + id, publicIdentifier: 'user' + id },
});

describe('linkedin connections', () => {
it('maps a connection element to a row', () => {
expect(mapConnection(conn(1), 0)).toEqual({
rank: 1,
name: 'First1 Last1',
occupation: 'Job 1',
public_id: 'user1',
connected_at: 1700000000001,
url: 'https://www.linkedin.com/in/user1',
});
});

it('throws when an element has no miniProfile', () => {
expect(() => mapConnection({ createdAt: 1 }, 0)).toThrow(CommandExecutionError);
});

it('requires a stable public profile identity for every row', () => {
expect(() => mapConnection({
createdAt: 1,
miniProfile: { firstName: 'Only', lastName: 'Name', occupation: 'No id' },
}, 0)).toThrow(CommandExecutionError);
expect(() => mapConnection({
createdAt: 1,
miniProfile: { firstName: 'Bad', lastName: 'Id', publicIdentifier: 'bad/id' },
}, 0)).toThrow(CommandExecutionError);
});

it('fails closed for malformed miniProfile scalar fields', () => {
expect(() => mapConnection({
createdAt: 1,
miniProfile: { firstName: { text: 'Alice' }, lastName: 'Example', publicIdentifier: 'alice' },
}, 0)).toThrow(CommandExecutionError);
});

it('returns rows from the voyager connections API', async () => {
const cmd = getRegistry().get('linkedin/connections');
expect(cmd?.func).toBeTypeOf('function');
const page = makePage({ evaluateResults: [false, { json: { elements: [conn(1), conn(2)] } }] });
const rows = await cmd.func(page, { limit: 2 });
expect(rows.map((r) => r.public_id)).toEqual(['user1', 'user2']);
expect(page.evaluate.mock.calls[1][0]).toContain('/voyager/api/relationships/connections?start=0&count=2');
expect(page.evaluate.mock.calls[1][0]).toContain('ajax:12345');
});

it('throws AuthRequiredError when the connections page is an auth wall', async () => {
const cmd = getRegistry().get('linkedin/connections');
const page = makePage({ evaluateResults: [true] });
await expect(cmd.func(page, { limit: 2 })).rejects.toBeInstanceOf(AuthRequiredError);
});

it('throws AuthRequiredError when JSESSIONID is missing', async () => {
const cmd = getRegistry().get('linkedin/connections');
const page = makePage({ evaluateResults: [false], cookies: [] });
await expect(cmd.func(page, { limit: 2 })).rejects.toBeInstanceOf(AuthRequiredError);
});

it('maps an authRequired fetch result to AuthRequiredError', async () => {
const cmd = getRegistry().get('linkedin/connections');
const page = makePage({ evaluateResults: [false, { authRequired: true, error: 'HTTP 403' }] });
await expect(cmd.func(page, { limit: 2 })).rejects.toBeInstanceOf(AuthRequiredError);
});

it('maps HTML checkpoint responses to AuthRequiredError', async () => {
const cmd = getRegistry().get('linkedin/connections');
const page = makePage({ evaluateResults: [false, { authRequired: true, error: 'HTML auth/checkpoint response' }] });
await expect(cmd.func(page, { limit: 2 })).rejects.toBeInstanceOf(AuthRequiredError);
});

it('fails closed for non-JSON API drift', async () => {
const cmd = getRegistry().get('linkedin/connections');
const page = makePage({ evaluateResults: [false, { error: 'response was not valid JSON' }] });
await expect(cmd.func(page, { limit: 2 })).rejects.toBeInstanceOf(CommandExecutionError);
});

it('throws EmptyResultError when there are no connections', async () => {
const cmd = getRegistry().get('linkedin/connections');
const page = makePage({ evaluateResults: [false, { json: { elements: [] } }] });
await expect(cmd.func(page, { limit: 2 })).rejects.toBeInstanceOf(EmptyResultError);
});

it('rejects invalid limits', async () => {
const cmd = getRegistry().get('linkedin/connections');
const page = makePage();
await expect(cmd.func(page, { limit: 0 })).rejects.toBeInstanceOf(CliError);
});
});
Loading