diff --git a/cli-manifest.json b/cli-manifest.json index 58a0bb0d0..83814ab03 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -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", diff --git a/clis/linkedin/connections.js b/clis/linkedin/connections.js new file mode 100644 index 000000000..7d82014ce --- /dev/null +++ b/clis/linkedin/connections.js @@ -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 }; diff --git a/clis/linkedin/connections.test.js b/clis/linkedin/connections.test.js new file mode 100644 index 000000000..7ae572f03 --- /dev/null +++ b/clis/linkedin/connections.test.js @@ -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); + }); +});