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

358 lines
15 KiB
JavaScript

import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { createHash } from 'node:crypto';
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 normalizeName(value) {
return normalizeWhitespace(value)
.replace(/\s*[•·]\s*(?:1st|2nd|3rd\+?|degree connection).*$/i, '')
.replace(/\s+LinkedIn.*$/i, '')
.toLowerCase();
}
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 hashText(value) {
return createHash('sha256').update(normalizeWhitespace(value)).digest('hex');
}
function textContainsNormalized(haystack, needle) {
const h = normalizeWhitespace(haystack).toLowerCase();
const n = normalizeWhitespace(needle).toLowerCase();
return !n || h.includes(n);
}
function selectBestHeaderName(headerNames, expectedName) {
const expected = normalizeName(expectedName);
const names = (Array.isArray(headerNames) ? headerNames : [])
.map(normalizeWhitespace)
.filter(Boolean);
return names.find((name) => normalizeName(name) === expected) || names[0] || '';
}
function assessThreadSafety(probe, expected) {
const expectedName = normalizeWhitespace(expected.expectedName);
const actualName = selectBestHeaderName(probe?.headerNames, expectedName);
const expectedThreadUrl = canonicalizeLinkedInThreadUrl(expected.threadUrl);
const actualThreadUrl = canonicalizeLinkedInThreadUrl(probe?.url || '');
const bodyText = String(probe?.bodyText || '');
if (probe?.authRequired) {
return { ok: false, blockReason: 'auth_required', expectedValue: expectedName, actualValue: actualName, observedUrl: actualThreadUrl };
}
if (probe?.searchFailure || /we didn't find anything|no results found|no results for/i.test(bodyText)) {
return { ok: false, blockReason: 'search_failure_visible', expectedValue: expectedName, actualValue: actualName, observedUrl: actualThreadUrl };
}
if (expectedThreadUrl && actualThreadUrl && expectedThreadUrl !== actualThreadUrl) {
return { ok: false, blockReason: 'thread_url_mismatch', expectedValue: expectedThreadUrl, actualValue: actualThreadUrl, observedUrl: actualThreadUrl };
}
if (!actualName || normalizeName(actualName) !== normalizeName(expectedName)) {
return { ok: false, blockReason: 'recipient_header_mismatch', expectedValue: expectedName, actualValue: actualName, observedUrl: actualThreadUrl };
}
if (!probe?.composerFound) {
return { ok: false, blockReason: 'composer_not_found', expectedValue: expectedName, actualValue: actualName, observedUrl: actualThreadUrl };
}
const expectedLastHash = normalizeWhitespace(expected.expectedLastHash);
if (expectedLastHash && expectedLastHash !== probe?.latestMessageHash) {
return { ok: false, blockReason: 'latest_message_mismatch', expectedValue: expectedLastHash, actualValue: probe?.latestMessageHash || '', observedUrl: actualThreadUrl };
}
const expectedLastText = normalizeWhitespace(expected.expectedLastText);
if (expectedLastText && !textContainsNormalized(bodyText, expectedLastText)) {
return { ok: false, blockReason: 'latest_message_mismatch', expectedValue: expectedLastText, actualValue: '', observedUrl: actualThreadUrl };
}
return { ok: true, blockReason: 'verified', expectedValue: expectedName, actualValue: actualName, observedUrl: actualThreadUrl };
}
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 buildThreadProbeScript() {
return String.raw`(() => {
const marker = '__OPENCLI_LINKEDIN_PROBE__';
void marker;
const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
const text = document.body ? (document.body.innerText || '') : '';
const lower = text.toLowerCase();
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 searchFailure = /we didn't find anything|no results found|no results for/i.test(text);
const headerCandidates = [];
const selectors = [
'.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 selectors) {
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 composer = Array.from(document.querySelectorAll('[contenteditable="true"][role="textbox"], div.msg-form__contenteditable[contenteditable="true"], [aria-label*="Write a message" i]'))
.find((el) => !el.closest('[aria-hidden="true"]') && el.offsetParent !== null);
const messageText = Array.from(document.querySelectorAll('.msg-s-message-list__event, .msg-s-event-listitem, [data-event-urn], .msg-s-message-group__meta, .msg-s-message-list-content'))
.map((el) => clean(el.innerText || el.textContent))
.filter(Boolean)
.join('\n');
const sourceText = messageText || text;
const sourceLines = sourceText.split(/\n+/).map(clean).filter(Boolean);
const lastMeaningfulLine = [...sourceLines].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: text,
composerFound: Boolean(composer),
composerText: composer ? clean(composer.innerText || composer.textContent) : '',
authRequired,
searchFailure,
latestMessageText: lastMeaningfulLine,
latestMessageHash: '',
};
})()`;
}
function buildFocusComposerScript() {
return String.raw`(() => {
const marker = '__OPENCLI_LINKEDIN_FOCUS_COMPOSER__';
void marker;
const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
const composer = Array.from(document.querySelectorAll('[contenteditable="true"][role="textbox"], div.msg-form__contenteditable[contenteditable="true"], [aria-label*="Write a message" i]'))
.find((el) => !el.closest('[aria-hidden="true"]') && el.offsetParent !== null);
if (!composer) return { ok: false, error: 'composer_not_found', composerText: '' };
composer.focus();
composer.innerHTML = '';
composer.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null }));
return { ok: true, composerText: clean(composer.innerText || composer.textContent) };
})()`;
}
function buildReadComposerScript() {
return String.raw`(() => {
const marker = '__OPENCLI_LINKEDIN_READ_COMPOSER__';
void marker;
const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
const composer = Array.from(document.querySelectorAll('[contenteditable="true"][role="textbox"], div.msg-form__contenteditable[contenteditable="true"], [aria-label*="Write a message" i]'))
.find((el) => !el.closest('[aria-hidden="true"]') && el.offsetParent !== null);
return { ok: Boolean(composer), composerText: composer ? clean(composer.innerText || composer.textContent) : '' };
})()`;
}
function buildClickSendScript() {
return String.raw`(() => {
const marker = '__OPENCLI_LINKEDIN_CLICK_SEND__';
void marker;
const buttons = Array.from(document.querySelectorAll('button'));
const send = buttons.find((button) => {
const text = (button.innerText || button.textContent || button.getAttribute('aria-label') || '').trim().toLowerCase();
return text === 'send' || text === 'send message';
});
if (!send) return { ok: false, error: 'send_button_not_found', sent: false };
if (send.disabled || send.getAttribute('aria-disabled') === 'true') return { ok: false, error: 'send_button_disabled', sent: false };
send.click();
return { ok: true, sent: true };
})()`;
}
async function probeThread(page) {
const result = unwrapEvaluateResult(await page.evaluate(buildThreadProbeScript()));
const latestText = normalizeWhitespace(result?.latestMessageText || '');
return {
...(result || {}),
latestMessageText: latestText,
latestMessageHash: latestText ? hashText(latestText) : '',
};
}
cli({
site: 'linkedin',
name: 'safe-send',
access: 'write',
description: 'Fail-closed LinkedIn message sender that verifies exact thread, recipient, and latest message before filling/sending',
domain: LINKEDIN_DOMAIN,
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'thread-url', required: true, help: 'Exact LinkedIn messaging thread URL to open and verify' },
{ name: 'expected-name', required: true, help: 'Expected visible recipient name in the active thread header' },
{ name: 'message', required: true, help: 'Message body to send or dry-run' },
{ name: 'expected-last-text', help: 'Substring expected in the currently visible latest conversation context' },
{ name: 'expected-last-hash', help: 'SHA-256 hash of expected latest visible message text' },
{ name: 'send', type: 'bool', default: false, help: 'Actually click Send. Default is dry-run verification only.' },
{ name: 'screenshot', type: 'bool', default: false, help: 'Capture a screenshot during verification' },
],
columns: ['status', 'recipient', 'reason', 'thread_url', 'message_chars', 'screenshot'],
func: async (page, args) => {
if (!page) throw new CommandExecutionError('Browser session required for linkedin safe-send');
const threadUrl = requireLinkedInThreadUrl(requireStringArg(args, 'thread-url', '--thread-url'), '--thread-url');
const expectedName = requireStringArg(args, 'expected-name', '--expected-name');
const message = requireStringArg(args, 'message', '--message');
await page.goto('https://www.linkedin.com/messaging/');
await page.wait(4);
await page.goto(threadUrl);
// LinkedIn messaging often renders the shell first and hydrates the active
// thread header/messages a few seconds later. Wait long enough for the
// recipient header to appear so we fail closed on a real mismatch, not on
// a premature blank DOM snapshot.
await page.wait(12);
let beforeProbe = await probeThread(page);
const expectedLastText = normalizeWhitespace(args['expected-last-text']);
for (let attempt = 0; expectedLastText && attempt < 6 && !textContainsNormalized(beforeProbe.bodyText, expectedLastText); attempt += 1) {
await page.wait(2);
beforeProbe = await probeThread(page);
}
const safety = assessThreadSafety(beforeProbe, {
expectedName,
threadUrl,
expectedLastText: args['expected-last-text'],
expectedLastHash: args['expected-last-hash'],
});
if (safety.blockReason === 'auth_required') {
throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn safe-send requires an active signed-in LinkedIn browser session.');
}
if (!safety.ok) {
const observed = [
`Expected ${safety.expectedValue}; actual ${safety.actualValue || 'not_visible'} at ${safety.observedUrl || 'url_not_available'}`,
`Observed headers: ${(beforeProbe.headerNames || []).join(' | ') || 'no_visible_headers'}`,
`Title: ${beforeProbe.title || 'title_not_available'}`,
`Body: ${normalizeWhitespace(beforeProbe.bodyText || '').slice(0, 500)}`,
].join('\n');
throw new CommandExecutionError(
`LinkedIn safe-send blocked: ${safety.blockReason}`,
observed,
);
}
let screenshot = '';
if (args.screenshot && typeof page.screenshot === 'function') {
screenshot = await page.screenshot({ fullPage: false });
}
if (!args.send) {
return [{
status: 'verified_dry_run',
recipient: safety.actualValue,
reason: safety.blockReason,
thread_url: safety.observedUrl,
message_chars: message.length,
screenshot: screenshot ? 'captured' : '',
}];
}
const focus = unwrapEvaluateResult(await page.evaluate(buildFocusComposerScript()));
if (!focus?.ok) throw new CommandExecutionError(`LinkedIn safe-send blocked: ${focus?.error || 'composer_focus_failed'}`);
await page.insertText(message);
await page.wait(0.6 + Math.random() * 0.8);
const composer = unwrapEvaluateResult(await page.evaluate(buildReadComposerScript()));
if (!composer?.ok || normalizeWhitespace(composer.composerText) !== normalizeWhitespace(message)) {
throw new CommandExecutionError(
'LinkedIn safe-send blocked: composer_text_mismatch',
`Composer text did not exactly match intended message for ${expectedName}.`,
);
}
const afterFillProbe = await probeThread(page);
const afterFillSafety = assessThreadSafety(afterFillProbe, {
expectedName,
threadUrl,
expectedLastText: args['expected-last-text'],
expectedLastHash: args['expected-last-hash'],
});
if (!afterFillSafety.ok) {
throw new CommandExecutionError(`LinkedIn safe-send blocked after fill: ${afterFillSafety.blockReason}`);
}
const sent = unwrapEvaluateResult(await page.evaluate(buildClickSendScript()));
if (!sent?.ok || !sent.sent) {
throw new CommandExecutionError(`LinkedIn safe-send blocked: ${sent?.error || 'send_click_failed'}`);
}
await page.wait(0.8 + Math.random() * 1.2);
return [{
status: 'sent',
recipient: safety.actualValue,
reason: safety.blockReason,
thread_url: safety.observedUrl,
message_chars: message.length,
screenshot: screenshot ? 'captured' : '',
}];
},
});
export const __test__ = {
normalizeWhitespace,
unwrapEvaluateResult,
normalizeName,
canonicalizeLinkedInThreadUrl,
hashText,
assessThreadSafety,
};