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

215 lines
8.2 KiB
JavaScript

import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
const LINKEDIN_DOMAIN = 'www.linkedin.com';
function normalizeWhitespace(value) {
return String(value ?? '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
}
function unwrapEvaluateResult(payload) {
if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
return payload;
}
function isLinkedInHost(hostname) {
const host = String(hostname || '').toLowerCase();
return host === 'linkedin.com' || host.endsWith('.linkedin.com');
}
function canonicalizeLinkedInThreadUrl(value) {
const raw = normalizeWhitespace(value);
if (!raw) return '';
try {
const url = new URL(raw);
if (url.protocol !== 'https:' || url.username || url.password || url.port || !isLinkedInHost(url.hostname)) return '';
const match = url.pathname.match(/^\/messaging\/thread\/([^/]+)\/?$/i);
if (!match || !match[1]) return '';
url.hostname = 'www.linkedin.com';
url.hash = '';
url.search = '';
if (!url.pathname.endsWith('/')) url.pathname += '/';
return url.toString();
} catch {
return '';
}
}
function requireStringArg(args, key, label = key) {
const value = normalizeWhitespace(args[key]);
if (!value) throw new ArgumentError(`${label} is required`);
return value;
}
function requireLinkedInThreadUrl(value, label) {
const url = canonicalizeLinkedInThreadUrl(value);
if (!url) throw new ArgumentError(`${label} must be an exact https://www.linkedin.com/messaging/thread/<id>/ URL`);
return url;
}
function parseMaxScrolls(value) {
if (value === undefined || value === null || value === '') return 30;
const scrolls = Number(value);
if (!Number.isInteger(scrolls) || scrolls < 0 || scrolls > 80) {
throw new ArgumentError('--max-scrolls must be an integer between 0 and 80');
}
return scrolls;
}
function buildThreadSnapshotScript(maxScrolls) {
const scrolls = maxScrolls;
return String.raw`(async () => {
const marker = '__OPENCLI_LINKEDIN_THREAD_SNAPSHOT__';
void marker;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
const text = document.body ? (document.body.innerText || '') : '';
const authRequired = /\b(sign in|log in|join linkedin)\b/i.test(text)
|| /linkedin\.com\/(login|checkpoint|authwall)/i.test(location.href)
|| /captcha|verification required/i.test(text);
const selectors = [
'.msg-s-message-list',
'.msg-s-message-list-scrollable',
'.msg-thread',
'main [role="main"]',
'main'
];
let scroller = null;
for (const selector of selectors) {
const el = document.querySelector(selector);
if (el && (el.scrollHeight > el.clientHeight || selector === 'main')) { scroller = el; break; }
}
scroller = scroller || document.scrollingElement || document.documentElement;
let previousHeight = -1;
let stable = 0;
for (let i = 0; i < ${scrolls}; i += 1) {
scroller.scrollTop = 0;
window.scrollTo(0, 0);
await sleep(750);
const height = scroller.scrollHeight || document.body.scrollHeight || 0;
if (height === previousHeight) stable += 1; else stable = 0;
previousHeight = height;
if (stable >= 3) break;
}
await sleep(1000);
const headerCandidates = [];
const headerSelectors = [
'.msg-thread__link-to-profile',
'.msg-thread__link-to-profile span[aria-hidden="true"]',
'.msg-entity-lockup__entity-title',
'.msg-conversation-card__participant-names',
'main h1',
'main h2',
'[data-anonymize="person-name"]',
'a[href*="/in/"] span[aria-hidden="true"]',
'a[href*="/in/"]'
];
for (const selector of headerSelectors) {
for (const el of Array.from(document.querySelectorAll(selector)).slice(0, 8)) {
const value = clean(el.innerText || el.textContent || el.getAttribute('aria-label'));
if (value && value.length <= 120 && !/^(message|messaging|send|profile|view profile)$/i.test(value)) {
headerCandidates.push(value);
}
}
}
const seen = new Set();
const messages = [];
const nodes = Array.from(document.querySelectorAll('.msg-s-message-list__event, .msg-s-event-listitem, [data-event-urn], .msg-s-message-list-content'));
for (const [nodeIndex, el] of nodes.entries()) {
const raw = clean(el.innerText || el.textContent);
if (!raw || seen.has(raw)) continue;
seen.add(raw);
const lines = raw.split(/\n+/).map(clean).filter(Boolean);
const speaker = lines.length > 1 && lines[0].length <= 120 ? lines[0] : '';
messages.push({ index: messages.length, nodeIndex, speaker, text: raw });
}
const refreshedText = document.body ? (document.body.innerText || '') : '';
const fallbackLines = refreshedText.split(/\n+/).map(clean).filter(Boolean);
const latestMessageText = messages.length
? messages[messages.length - 1].text
: ([...fallbackLines].reverse().find((line) => !/^(send|reply|write a message|press enter to send)$/i.test(line)) || '');
return {
url: location.href,
title: document.title || '',
headerNames: Array.from(new Set(headerCandidates)).slice(0, 10),
bodyText: refreshedText,
latestMessageText,
messages,
messageCount: messages.length,
authRequired,
extractedAt: new Date().toISOString(),
maxScrolls: ${scrolls}
};
})()`;
}
cli({
site: 'linkedin',
name: 'thread-snapshot',
access: 'read',
description: 'Load a LinkedIn messaging thread, scroll for available history, and return a full context snapshot',
domain: LINKEDIN_DOMAIN,
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'thread-url', required: true, help: 'Exact LinkedIn messaging thread URL to open and snapshot' },
{ name: 'max-scrolls', type: 'number', default: 30, help: 'Maximum upward scroll attempts to load older messages' },
{ name: 'json', type: 'bool', default: false, help: 'Return only JSON snapshot string in the snapshot_json field' },
],
columns: ['thread_url', 'recipient', 'message_count', 'latest_text', 'snapshot_json'],
func: async (page, args) => {
if (!page) throw new CommandExecutionError('Browser session required for linkedin thread-snapshot');
const threadUrl = requireLinkedInThreadUrl(requireStringArg(args, 'thread-url', '--thread-url'), '--thread-url');
const maxScrolls = parseMaxScrolls(args['max-scrolls']);
await page.goto('https://www.linkedin.com/messaging/');
await page.wait(4);
await page.goto(threadUrl);
await page.wait(10);
const snapshot = unwrapEvaluateResult(await page.evaluate(buildThreadSnapshotScript(maxScrolls)));
if (snapshot?.authRequired) {
throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn thread-snapshot requires an active signed-in LinkedIn browser session.');
}
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot) || !Array.isArray(snapshot.headerNames) || !Array.isArray(snapshot.messages)) {
throw new CommandExecutionError('LinkedIn thread-snapshot returned malformed snapshot payload');
}
const actualUrl = canonicalizeLinkedInThreadUrl(snapshot?.url || '');
if (threadUrl && actualUrl && threadUrl !== actualUrl) {
throw new CommandExecutionError('LinkedIn thread-snapshot blocked: thread_url_mismatch', `Expected ${threadUrl}; actual ${actualUrl}`);
}
const recipient = normalizeWhitespace(snapshot.headerNames[0] || '');
const messageCount = snapshot.messages.length;
const normalized = {
...snapshot,
url: actualUrl || threadUrl,
headerNames: snapshot.headerNames,
latestMessageText: normalizeWhitespace(snapshot?.latestMessageText || ''),
messages: snapshot.messages,
};
return [{
thread_url: normalized.url,
recipient,
message_count: messageCount,
latest_text: normalized.latestMessageText,
snapshot_json: JSON.stringify(normalized),
}];
},
});
export const __test__ = {
normalizeWhitespace,
canonicalizeLinkedInThreadUrl,
parseMaxScrolls,
unwrapEvaluateResult,
buildThreadSnapshotScript,
};