Files
wehub-resource-sync 9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

127 lines
5.2 KiB
JavaScript

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 };