Files
wehub-resource-sync 9b395f5cc3
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
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

235 lines
9.6 KiB
JavaScript

import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
const LINKEDIN_DOMAIN = 'linkedin.com';
const MESSAGING_URL = 'https://www.linkedin.com/messaging/';
const MIN_LIMIT = 1;
const MAX_LIMIT = 100;
const DEFAULT_LIMIT = 40;
// ── Why this command reads an API response instead of scraping the DOM ──
//
// LinkedIn's messaging UI is a realtime, virtualized SPA. Scraping the rendered
// conversation list is brittle: rows lazy-render, the list virtualizes, and the
// markup churns. Instead we let the page load /messaging/ exactly as a human
// would, which makes the page fire its own `messengerConversations` GraphQL
// call. We then re-issue that same request (URL lifted from the Performance API,
// so the rotating queryId is always current) and parse LinkedIn's normalized
// JSON. Same session, same origin, same request the page already makes.
function unwrapEvaluateResult(payload) {
if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
return payload;
}
function threadUrl(threadId) {
return threadId ? `https://www.linkedin.com/messaging/thread/${threadId}/` : '';
}
// Runs in-page: locate the messengerConversations request the page already fired.
// Prefers the category-scoped query (the primary inbox) over the sync-token query.
function findMessagingApiUrl() {
if (/\/(login|checkpoint|authwall|uas)/i.test(location.pathname)) return { loginRequired: true };
const urls = performance.getEntriesByType('resource').map((e) => e.name);
const matches = (re) => urls.find((u) => /messengerConversations\.[a-f0-9]+/i.test(u) && re.test(u));
const url =
matches(/PRIMARY_INBOX/i) ||
matches(/conversationCategoryPredicate/i) ||
urls.find((u) => /messengerConversations\.[a-f0-9]+/i.test(u) && /mailboxUrn/i.test(u));
if (!url) return { url: null };
const mb = url.match(/mailboxUrn:(urn[^,)&]+)/i);
return { url, mailboxUrn: mb ? decodeURIComponent(mb[1]) : '' };
}
// Runs in-page: re-issue the messaging request with the session's csrf token.
async function fetchMessagingApi(url, csrf) {
try {
const res = await fetch(url, {
credentials: 'include',
headers: {
'csrf-token': csrf,
accept: 'application/vnd.linkedin.normalized+json+2.1',
'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 };
return { json: await res.json() };
} catch (e) {
return { error: 'fetch failed: ' + ((e && e.message) || String(e)) };
}
}
// Parse LinkedIn's normalized messaging JSON into plain conversation rows.
// `included` is a flat entity array; conversations reference participants and
// messages by URN, which we resolve through a urn->entity index. Exported for
// unit testing against a captured fixture.
function parseConversations(normalized, mailboxUrn) {
if (!normalized || typeof normalized !== 'object' || Array.isArray(normalized) || !Array.isArray(normalized.included)) {
throw new CommandExecutionError('LinkedIn messaging API returned malformed normalized payload: missing included array');
}
const included = normalized.included;
const byUrn = new Map();
for (const o of included) {
if (o && o.entityUrn) byUrn.set(o.entityUrn, o);
}
const norm = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
const participantInfo = (p) => {
if (!p) return { name: '', kind: '' };
const pt = p.participantType || {};
if (pt.organization && pt.organization.name) return { name: norm(pt.organization.name.text), kind: 'organization' };
if (pt.member) {
const fn = pt.member.firstName && pt.member.firstName.text;
const ln = pt.member.lastName && pt.member.lastName.text;
return { name: norm([fn, ln].filter(Boolean).join(' ')), kind: 'member' };
}
if (pt.agent && pt.agent.name) return { name: norm(pt.agent.name.text), kind: 'agent' };
return { name: '', kind: '' };
};
const entries = [];
for (const conv of included) {
if (!conv || conv.$type !== 'com.linkedin.messenger.Conversation') continue;
const threadId = String(conv.backendUrn || '').replace(/^urn:li:messagingThread:/, '');
if (!threadId) {
throw new CommandExecutionError('LinkedIn messaging API returned a conversation without thread id');
}
const others = [];
let counterpartyKind = '';
for (const urn of conv['*conversationParticipants'] || []) {
const p = byUrn.get(urn);
if (!p) continue;
if (mailboxUrn && p.hostIdentityUrn === mailboxUrn) continue; // exclude the inbox owner
const info = participantInfo(p);
if (info.name) {
others.push(info.name);
if (!counterpartyKind) counterpartyKind = info.kind;
}
}
const msgUrns = (conv.messages && conv.messages['*elements']) || [];
const lastMsg = byUrn.get(msgUrns[0]);
let preview = lastMsg && lastMsg.body ? norm(lastMsg.body.text) : '';
if (!preview) preview = norm(conv.descriptionText || '');
const activityMs = Number(conv.lastActivityAt || 0);
entries.push({
activityMs,
row: {
thread_id: threadId,
person_name: conv.title ? norm(conv.title) : others.join(', '),
last_message_preview: preview.slice(0, 300),
unread: Number(conv.unreadCount || 0) > 0 || conv.read === false,
counterparty_type: counterpartyKind,
category: Array.isArray(conv.categories) ? conv.categories.join(',') : '',
timestamp: activityMs ? new Date(activityMs).toISOString() : '',
},
});
}
// Most-recent first; the sort key is kept off the returned row.
entries.sort((a, b) => b.activityMs - a.activityMs);
return entries.map((entry) => entry.row);
}
cli({
site: 'linkedin',
name: 'inbox',
access: 'read',
description: 'List LinkedIn messaging inbox conversations and unread messages',
domain: 'www.linkedin.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'limit', type: 'int', default: DEFAULT_LIMIT, help: 'Maximum conversations to return (1-100)' },
{ name: 'unread-only', type: 'bool', default: false, help: 'Return only conversations with unread messages' },
],
columns: [
'rank',
'thread_url',
'thread_id',
'person_name',
'last_message_preview',
'unread',
'counterparty_type',
'category',
'timestamp',
],
func: async (page, kwargs) => {
// Validate --limit explicitly rather than silently clamping an out-of-range value.
let limit = DEFAULT_LIMIT;
if (kwargs.limit !== undefined && kwargs.limit !== null && kwargs.limit !== '') {
limit = Number(kwargs.limit);
if (!Number.isInteger(limit) || limit < MIN_LIMIT || limit > MAX_LIMIT) {
throw new ArgumentError(`--limit must be an integer between ${MIN_LIMIT} and ${MAX_LIMIT}`);
}
}
const unreadOnly = Boolean(kwargs['unread-only']);
await page.goto(MESSAGING_URL);
await page.wait(10);
// Locate the messaging API request the page fired on load; retry once if the
// SPA was slow to issue it.
let located = unwrapEvaluateResult(await page.evaluate(`(${findMessagingApiUrl.toString()})()`));
if (located && located.loginRequired) {
throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn requires an active signed-in browser session.');
}
if (!located || !located.url) {
await page.wait(6);
located = unwrapEvaluateResult(await page.evaluate(`(${findMessagingApiUrl.toString()})()`));
}
if (!located || !located.url) {
throw new CommandExecutionError(
'LinkedIn did not issue a messaging API request; the inbox may have failed to load.',
);
}
const cookies = await page.getCookies({ url: 'https://www.linkedin.com' });
const jsession = cookies.find((c) => c.name === 'JSESSIONID')?.value;
if (!jsession) {
throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn JSESSIONID cookie not found. Please sign in to LinkedIn.');
}
const csrf = jsession.replace(/^"|"$/g, '');
// Widen the page size to the requested limit where the query supports it.
const targetUrl = located.url.replace(/count:\d+/, 'count:' + limit);
const fetched = unwrapEvaluateResult(
await page.evaluate(`(${fetchMessagingApi.toString()})(${JSON.stringify(targetUrl)}, ${JSON.stringify(csrf)})`),
);
if (fetched && fetched.authRequired) {
throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn messaging API authentication failed: ' + fetched.error);
}
if (!fetched || fetched.error || !fetched.json) {
throw new CommandExecutionError(
'LinkedIn messaging API returned an unexpected response: ' + ((fetched && fetched.error) || 'no data'),
);
}
let conversations = parseConversations(fetched.json, located.mailboxUrn || '');
if (unreadOnly) conversations = conversations.filter((c) => c.unread);
if (conversations.length === 0) {
if (unreadOnly) return [];
throw new EmptyResultError('linkedin inbox', 'No LinkedIn conversations were found in the inbox.');
}
return conversations.slice(0, limit).map((c, index) => ({
rank: index + 1,
thread_url: threadUrl(c.thread_id),
thread_id: c.thread_id,
person_name: c.person_name,
last_message_preview: c.last_message_preview,
unread: c.unread,
counterparty_type: c.counterparty_type,
category: c.category,
timestamp: c.timestamp,
}));
},
});
export const __test__ = {
parseConversations,
threadUrl,
};