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

361 lines
16 KiB
JavaScript

import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
const LINKEDIN_DOMAIN = 'www.linkedin.com';
const SALES_HOME = 'https://www.linkedin.com/sales/';
const PROFILE_DECO = '(entityUrn,objectUrn,firstName,lastName,fullName,headline,degree,inmailRestriction,memberBadges,defaultPosition)';
const CREDITS_URL = 'https://www.linkedin.com/sales-api/salesApiCredits?q=findCreditGrant&creditGrantType=LSS_INMAIL';
const MESSAGE_ACTION_URL = 'https://www.linkedin.com/sales-api/salesApiMessageActions?action=createMessage';
function normalizeWhitespace(value) {
return String(value ?? '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
}
function requireStringArg(args, key, label = key) {
const value = normalizeWhitespace(args[key]);
if (!value) throw new ArgumentError(`${label} is required`);
return value;
}
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 parseSalesProfileUrn(value) {
const raw = normalizeWhitespace(value);
const match = raw.match(/^urn:li:fs_salesProfile:\(([^,()]+),([^,()]+),([^,()]+)\)$/);
if (!match) return null;
if (!isResolvedSalesProfileParts(match[1], match[2], match[3])) return null;
return { profileId: match[1], authType: match[2], authToken: match[3], entityUrn: raw };
}
function isResolvedSalesProfileParts(profileId, authType, authToken) {
return [profileId, authType, authToken].every((part) => {
const clean = normalizeWhitespace(part).toLowerCase();
return clean && clean !== 'undefined' && clean !== 'null' && clean !== 'not_available';
});
}
function salesLeadUrlFromParts({ profileId, authType, authToken }) {
return `https://www.linkedin.com/sales/lead/${encodeURIComponent(profileId)},${encodeURIComponent(authType)},${encodeURIComponent(authToken)}`;
}
function parseRecipient(value) {
const raw = normalizeWhitespace(value);
const urn = parseSalesProfileUrn(raw);
if (urn) return urn;
try {
const url = new URL(raw);
if (url.protocol !== 'https:' || url.username || url.password || url.port || !isLinkedInHost(url.hostname)) return null;
const salesMatch = url.pathname.match(/^\/sales\/lead\/([^,/]+),([^,/]+),([^/]+)\/?$/i);
if (salesMatch) {
const profileId = decodeURIComponent(salesMatch[1]);
const authType = decodeURIComponent(salesMatch[2]);
const authToken = decodeURIComponent(salesMatch[3]);
if (!isResolvedSalesProfileParts(profileId, authType, authToken)) return null;
return { profileId, authType, authToken, entityUrn: `urn:li:fs_salesProfile:(${profileId},${authType},${authToken})` };
}
const profileMatch = url.pathname.match(/^\/in\/([^/]+)\/?$/i);
if (profileMatch) {
return { profileId: decodeURIComponent(profileMatch[1]), authType: '', authToken: '', entityUrn: '' };
}
} catch {
return null;
}
return null;
}
function encodeRestliDecoration(value) {
return encodeURIComponent(value).replace(/\(/g, '%28').replace(/\)/g, '%29');
}
function profileApiUrl(recipient) {
if (!recipient?.profileId || !recipient?.authType || !recipient?.authToken) return '';
const key = `(profileId:${recipient.profileId},authType:${recipient.authType},authToken:${recipient.authToken})`;
return `https://www.linkedin.com/sales-api/salesApiProfiles/${key}?decoration=${encodeRestliDecoration(PROFILE_DECO)}`;
}
function randomTrackingId() {
const bytes = new Uint8Array(8);
if (globalThis.crypto?.getRandomValues) globalThis.crypto.getRandomValues(bytes);
else for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
function buildCreateMessagePayload({ recipientUrn, subject, body, trackingId = randomTrackingId(), copyToCrm = false }) {
const cleanRecipient = normalizeWhitespace(recipientUrn);
if (!parseSalesProfileUrn(cleanRecipient)) throw new ArgumentError('--recipient must resolve to a Sales Navigator lead urn');
const cleanSubject = normalizeWhitespace(subject);
const cleanBody = String(body ?? '').trim();
if (!cleanSubject) throw new ArgumentError('--subject is required');
if (!cleanBody) throw new ArgumentError('--body is required');
if (cleanSubject.length > 200) throw new ArgumentError('--subject must be 200 characters or fewer');
if (cleanBody.length > 1900) throw new ArgumentError('--body must be 1900 characters or fewer');
return {
createMessageRequest: {
recipients: [cleanRecipient],
subject: cleanSubject,
body: cleanBody,
copyToCrm: Boolean(copyToCrm),
trackingId,
},
};
}
function extractRemainingCredits(json) {
const elements = Array.isArray(json?.elements) ? json.elements : [];
const inmailGrant = elements.find((el) => el?.type === 'LSS_INMAIL' && Number.isInteger(el.value));
if (inmailGrant) return inmailGrant.value;
const candidates = [];
const visit = (value) => {
if (value === null || value === undefined) return;
if (typeof value === 'number' && Number.isFinite(value)) candidates.push(value);
if (Array.isArray(value)) value.forEach(visit);
else if (typeof value === 'object') {
for (const [key, child] of Object.entries(value)) {
if (/remaining|available|balance|value/i.test(key) && typeof child === 'number') candidates.unshift(child);
else if (!/^count$|^start$|^id$/i.test(key)) visit(child);
}
}
};
visit(json);
return candidates.find((n) => Number.isInteger(n) && n >= 0) ?? null;
}
function fetchJsonScript(url, csrf, options = {}) {
return String.raw`(async () => {
const headers = {
'csrf-token': ${JSON.stringify(csrf)},
'x-restli-protocol-version': '2.0.0',
accept: ${JSON.stringify(options.accept || 'application/json')},
...((${JSON.stringify(Boolean(options.body))}) ? { 'content-type': 'application/json' } : {}),
};
try {
const res = await fetch(${JSON.stringify(url)}, {
credentials: 'include',
method: ${JSON.stringify(options.method || 'GET')},
headers,
body: ${options.body ? JSON.stringify(JSON.stringify(options.body)) : 'undefined'},
});
const text = await res.text();
let json = null;
try { json = text ? JSON.parse(text) : null; } catch (_) { json = null; }
if (res.status === 401 || res.status === 403) return ['auth', res.status, json, text];
if (!res.ok) return ['error', res.status, json, text, 'HTTP ' + res.status];
return ['ok', res.status, json, text];
} catch (e) {
return ['error', 0, null, '', 'fetch failed: ' + ((e && e.message) || String(e))];
}
})()`;
}
function requireFetchResult(result, label, { requireJson = true } = {}) {
if (Array.isArray(result)) {
const [kind, status, json, text, error] = result;
result = {
authRequired: kind === 'auth',
error: kind === 'error' ? error || `HTTP ${status}` : '',
status,
json,
text,
};
}
if (result?.authRequired) throw new AuthRequiredError(LINKEDIN_DOMAIN, `${label} auth failed.`);
if (result?.error) throw new CommandExecutionError(`${label} failed`, result.error);
if (!result || typeof result !== 'object' || Array.isArray(result)) {
throw new CommandExecutionError(`${label} returned malformed response`);
}
if (requireJson && (!result.json || typeof result.json !== 'object' || Array.isArray(result.json))) {
throw new CommandExecutionError(`${label} returned malformed response`, 'missing_json');
}
return result;
}
function salesPageShowsSentMessage(text, recipientName) {
const normalizedText = normalizeWhitespace(text);
const firstName = normalizeWhitespace(recipientName).split(' ')[0];
return normalizedText.includes('You sent a Sales Navigator message')
&& (!firstName || normalizedText.includes(firstName));
}
async function getCsrf(page) {
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.');
return jsession.replace(/^\"|\"$/g, '');
}
async function resolveRecipient(page, parsed, csrf) {
if (!parsed) throw new ArgumentError('--recipient must be a Sales Navigator lead URL, Sales Navigator profile URL, LinkedIn /in/ URL, or urn:li:fs_salesProfile:(...)');
if (parsed.entityUrn && parsed.authType && parsed.authToken) return parsed;
await page.goto(`https://www.linkedin.com/sales/lead/${encodeURIComponent(parsed.profileId)}`);
await page.wait(6);
const probe = unwrapEvaluateResult(await page.evaluate(String.raw`(() => {
const href = location.href;
const text = document.body ? document.body.innerText : '';
const resourceUrns = Array.from(performance.getEntriesByType('resource'))
.map((entry) => entry.name)
.filter((name) => name.includes('/sales-api/salesApiProfiles/'))
.slice(-20);
return { href, text: text.slice(0, 1000), resourceUrns };
})()`));
const urlMatch = String(probe?.href || '').match(/\/sales\/lead\/([^,/]+),([^,/]+),([^/?#]+)/i);
if (urlMatch && isResolvedSalesProfileParts(urlMatch[1], urlMatch[2], urlMatch[3])) {
return {
profileId: decodeURIComponent(urlMatch[1]),
authType: decodeURIComponent(urlMatch[2]),
authToken: decodeURIComponent(urlMatch[3]),
entityUrn: `urn:li:fs_salesProfile:(${decodeURIComponent(urlMatch[1])},${decodeURIComponent(urlMatch[2])},${decodeURIComponent(urlMatch[3])})`,
};
}
for (const resource of probe?.resourceUrns || []) {
const resourceMatch = String(resource).match(/profileId:([^,)]+),authType:([^,)]+),authToken:([^,)]+)\)/);
if (resourceMatch && resourceMatch[1] === parsed.profileId) {
return {
profileId: resourceMatch[1],
authType: resourceMatch[2],
authToken: resourceMatch[3],
entityUrn: `urn:li:fs_salesProfile:(${resourceMatch[1]},${resourceMatch[2]},${resourceMatch[3]})`,
};
}
}
void csrf;
throw new CommandExecutionError('Could not resolve Sales Navigator auth token for recipient', `Observed URL: ${probe?.href || 'url_not_available'}\nBody: ${normalizeWhitespace(probe?.text || '').slice(0, 500)}`);
}
function profileSummary(json) {
const data = json?.data || json || {};
const pos = data.defaultPosition || (Array.isArray(data.positions) ? data.positions.find((p) => p.current) || data.positions[0] : {}) || {};
return {
recipient: normalizeWhitespace(data.fullName || [data.firstName, data.lastName].filter(Boolean).join(' ')),
title: normalizeWhitespace(pos.title || data.headline || ''),
company: normalizeWhitespace(pos.companyName || pos.company?.name || ''),
degree: normalizeWhitespace(data.degree || ''),
inmail_restriction: normalizeWhitespace(data.inmailRestriction || ''),
open_link: Boolean(data.memberBadges?.openLink),
};
}
function requireProfileSummary(json) {
const summary = profileSummary(json);
if (!summary.recipient) {
throw new CommandExecutionError('Sales Navigator profile lookup returned malformed profile data', 'missing_recipient_name');
}
return summary;
}
cli({
site: 'linkedin',
name: 'salesnav-message',
access: 'write',
description: 'Send or dry-run a LinkedIn Sales Navigator InMail to a lead using the Sales Navigator messaging API',
domain: LINKEDIN_DOMAIN,
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'recipient', type: 'string', required: true, positional: true, help: 'Sales Navigator lead URL, LinkedIn /in/ URL from salesnav-search, or urn:li:fs_salesProfile:(...)' },
{ name: 'subject', type: 'string', required: true, help: 'InMail subject' },
{ name: 'body', type: 'string', required: true, help: 'InMail body' },
{ name: 'send', type: 'bool', default: false, help: 'Actually send the InMail. Default is dry-run validation only.' },
{ name: 'copy-to-crm', type: 'bool', default: false, help: 'Set Sales Navigator copyToCrm on the message request' },
],
columns: ['status', 'recipient', 'title', 'company', 'credits_remaining', 'credits_before', 'credits_after', 'sent_in_salesnav', 'message_chars', 'subject_chars', 'recipient_urn', 'degree', 'inmail_restriction', 'open_link'],
func: async (page, args) => {
if (!page) throw new CommandExecutionError('Browser session required for linkedin salesnav-message');
const recipientArg = requireStringArg(args, 'recipient', '--recipient');
const subject = requireStringArg(args, 'subject', '--subject');
const body = String(args.body ?? '').trim();
if (!body) throw new ArgumentError('--body is required');
await page.goto(SALES_HOME);
await page.wait(4);
const csrf = await getCsrf(page);
const recipient = await resolveRecipient(page, parseRecipient(recipientArg), csrf);
let summary = { recipient: '', title: '', company: '', degree: '', inmail_restriction: '', open_link: false };
const profileUrl = profileApiUrl(recipient);
if (profileUrl) {
const profileResult = requireFetchResult(unwrapEvaluateResult(await page.evaluate(fetchJsonScript(profileUrl, csrf))), 'LinkedIn Sales Navigator profile API');
summary = requireProfileSummary(profileResult.json);
}
if (summary.inmail_restriction && summary.inmail_restriction !== 'NO_RESTRICTION') {
throw new CommandExecutionError('Sales Navigator InMail blocked by recipient restriction', summary.inmail_restriction);
}
const creditsResult = requireFetchResult(unwrapEvaluateResult(await page.evaluate(fetchJsonScript(CREDITS_URL, csrf))), 'LinkedIn Sales Navigator credits API');
const creditsRemaining = extractRemainingCredits(creditsResult?.json);
const payload = buildCreateMessagePayload({ recipientUrn: recipient.entityUrn, subject, body, copyToCrm: args['copy-to-crm'] });
if (!args.send) {
return [{
status: 'validated_dry_run',
recipient: summary.recipient,
title: summary.title,
company: summary.company,
credits_remaining: creditsRemaining,
credits_before: creditsRemaining,
credits_after: '',
sent_in_salesnav: false,
message_chars: body.length,
subject_chars: subject.length,
recipient_urn: recipient.entityUrn,
degree: summary.degree,
inmail_restriction: summary.inmail_restriction,
open_link: summary.open_link,
}];
}
const sendResult = requireFetchResult(unwrapEvaluateResult(await page.evaluate(fetchJsonScript(MESSAGE_ACTION_URL, csrf, {
method: 'POST',
accept: 'application/vnd.linkedin.normalized+json+2.1',
body: payload,
}))), 'LinkedIn Sales Navigator message API', { requireJson: false });
void sendResult;
await page.wait(3);
const creditsAfterResult = requireFetchResult(unwrapEvaluateResult(await page.evaluate(fetchJsonScript(CREDITS_URL, csrf))), 'LinkedIn Sales Navigator credits API after send');
const creditsAfter = extractRemainingCredits(creditsAfterResult?.json);
await page.goto(salesLeadUrlFromParts(recipient));
await page.wait(6);
const salesPageText = unwrapEvaluateResult(await page.evaluate('document.body ? document.body.innerText : ""'));
const sentInSalesNav = salesPageShowsSentMessage(salesPageText, summary.recipient);
if (!sentInSalesNav) throw new CommandExecutionError('Sales Navigator post-send verification failed', 'Sent activity was not found on the Sales Navigator lead page.');
return [{
status: 'sent',
recipient: summary.recipient,
title: summary.title,
company: summary.company,
credits_remaining: creditsAfter,
credits_before: creditsRemaining,
credits_after: creditsAfter,
sent_in_salesnav: sentInSalesNav,
message_chars: body.length,
subject_chars: subject.length,
recipient_urn: recipient.entityUrn,
degree: summary.degree,
inmail_restriction: summary.inmail_restriction,
open_link: summary.open_link,
}];
},
});
export const __test__ = {
normalizeWhitespace,
parseSalesProfileUrn,
isResolvedSalesProfileParts,
parseRecipient,
salesLeadUrlFromParts,
profileApiUrl,
buildCreateMessagePayload,
extractRemainingCredits,
profileSummary,
requireProfileSummary,
salesPageShowsSentMessage,
};