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 normalizeName(value) { return normalizeWhitespace(value) .replace(/\s*[•·]\s*(?:1st|2nd|3rd\+?|degree connection).*$/i, '') .replace(/\s+LinkedIn.*$/i, '') .replace(/\b(p\.?eng\.?|cpa|mba|ph\.?d\.?)\b/ig, '') .replace(/[^\p{L}\p{N}\s.'-]+/gu, ' ') .replace(/\s+/g, ' ') .trim() .toLowerCase(); } function nameTokens(value) { return normalizeName(value) .replace(/[.'-]+/g, ' ') .split(/\s+/) .map((token) => token.trim()) .filter((token) => token.length >= 2); } function matchInvitationName(candidate, expected) { const candidateName = normalizeName(candidate); const expectedName = normalizeName(expected); if (!candidateName || !expectedName) return false; if (candidateName === expectedName) return true; if (candidateName.includes(expectedName) || expectedName.includes(candidateName)) return true; const candidateTokens = new Set(nameTokens(candidateName)); const expectedTokens = nameTokens(expectedName); if (expectedTokens.length < 2 || candidateTokens.size < 2) return false; const matched = expectedTokens.filter((token) => candidateTokens.has(token)).length; return matched >= 2 && matched / expectedTokens.length >= 0.8; } function isLinkedInHost(hostname) { const host = String(hostname || '').toLowerCase(); return host === 'linkedin.com' || host.endsWith('.linkedin.com'); } function canonicalizeLinkedInProfileUrl(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(/^\/in\/([^/]+)\/?$/i); if (!match || !match[1]) return ''; // LinkedIn redirects country subdomains (ca./uk./...) to www.; normalize the // host so an expected `ca.linkedin.com/in/x` matches the landed `www.linkedin.com/in/x`. 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 requireLinkedInProfileUrl(value, label) { const url = canonicalizeLinkedInProfileUrl(value); if (!url) throw new ArgumentError(`${label} must be an exact https://www.linkedin.com/in// URL`); return url; } function unwrapEvaluateResult(payload) { if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data; return payload; } function clampNote(note) { const value = normalizeWhitespace(note); if (value.length > 300) throw new ArgumentError('--note must be 300 characters or fewer for LinkedIn connection requests'); return value; } function canonicalizeLinkedInInviteUrl(value) { try { const url = new URL(normalizeWhitespace(value), 'https://www.linkedin.com'); if (url.protocol !== 'https:' || url.username || url.password || url.port || !isLinkedInHost(url.hostname)) return ''; if (!/^\/preload\/custom-invite\/?$/i.test(url.pathname)) return ''; url.hostname = 'www.linkedin.com'; url.hash = ''; if (!url.pathname.endsWith('/')) url.pathname += '/'; return url.toString(); } catch { return ''; } } function assessProfileSafety(probe, expectedName, expectedProfileUrl) { const expected = normalizeWhitespace(expectedName); const actual = normalizeWhitespace(probe?.name || ''); const expectedUrl = canonicalizeLinkedInProfileUrl(expectedProfileUrl); const actualUrl = canonicalizeLinkedInProfileUrl(probe?.url || ''); if (probe?.authRequired) return { ok: false, safety: 'unsafe_block', connectable: null, blockReason: 'auth_required', expectedValue: expected, actualValue: actual, observedUrl: actualUrl }; if (!actual) return { ok: false, safety: 'unsafe_block', connectable: null, blockReason: 'profile_name_not_found', expectedValue: expected, actualValue: actual, observedUrl: actualUrl }; if (expected && normalizeName(actual) !== normalizeName(expected)) { return { ok: false, safety: 'unsafe_block', connectable: null, blockReason: 'profile_name_mismatch', expectedValue: expected, actualValue: actual, observedUrl: actualUrl }; } if (expectedUrl && actualUrl && expectedUrl !== actualUrl) { return { ok: false, safety: 'unsafe_block', connectable: null, blockReason: 'profile_url_mismatch', expectedValue: expectedUrl, actualValue: actualUrl, observedUrl: actualUrl }; } if (probe?.alreadyConnected) return { ok: false, safety: 'routine_non_connectable', connectable: false, blockReason: 'already_connected', expectedValue: expected, actualValue: actual, observedUrl: actualUrl }; if (probe?.pending) return { ok: false, safety: 'routine_non_connectable', connectable: false, blockReason: 'connection_pending', expectedValue: expected, actualValue: actual, observedUrl: actualUrl }; if (probe?.connectAvailable) return { ok: true, safety: 'connectable', connectable: true, blockReason: 'verified', expectedValue: expected, actualValue: actual, observedUrl: actualUrl }; // No top-level Connect, but a "More" actions menu is present and the profile is neither // already-connected nor pending: Connect is almost certainly inside that menu. Treat as // connectable; the send step opens "More" and fails cleanly if Connect truly isn't there. if (probe?.moreAvailable) return { ok: true, safety: 'connectable', connectable: true, blockReason: 'verified_via_more', expectedValue: expected, actualValue: actual, observedUrl: actualUrl }; return { ok: false, safety: 'routine_non_connectable', connectable: false, blockReason: 'connect_button_not_found', expectedValue: expected, actualValue: actual, observedUrl: actualUrl }; } function buildProfileProbeScript(expectedName = '') { return String.raw`(() => { const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim(); const expectedName = ${JSON.stringify(expectedName)}; const normalizeName = (s) => clean(s) .replace(/\s*[•·]\s*(?:1st|2nd|3rd\+?|degree connection).*$/i, '') .replace(/\s+LinkedIn.*$/i, '') .replace(/\b(p\.?eng\.?|cpa|mba|ph\.?d\.?)\b/ig, '') .replace(/[^\p{L}\p{N}\s.'-]+/gu, ' ') .replace(/\s+/g, ' ') .trim() .toLowerCase(); const tokens = (s) => normalizeName(s).replace(/[.'-]+/g, ' ').split(/\s+/).map((t) => t.trim()).filter((t) => t.length >= 2); 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 main = document.querySelector('main') || document.body; // LinkedIn profile pages no longer expose the name in an

; the heading // markup churns, but document.title is a stable "Name | LinkedIn" pattern. const heading = main?.querySelector('h1, .text-heading-xlarge, [class*="heading-xlarge"]'); const titleName = clean((document.title || '') .replace(/^\(\d+\+?\)\s*/, '') .replace(/\s*[||]\s*LinkedIn\s*$/i, '')); const name = clean(heading?.innerText || heading?.textContent || '') || titleName; const buttons = Array.from(document.querySelectorAll('button, [role="button"], a')).filter((el) => el.offsetParent !== null); const buttonLabels = buttons.map((button) => clean(button.innerText || button.textContent || button.getAttribute('aria-label'))).filter(Boolean); const expectedTokens = tokens(expectedName || name); const ariaLabel = (el) => clean(el?.getAttribute('aria-label') || ''); const textLabel = (el) => clean(el?.innerText || el?.textContent || ''); const labelOf = (el) => clean(textLabel(el) || ariaLabel(el)); const namesOwner = (el) => { const label = ariaLabel(el).toLowerCase(); return expectedTokens.length >= 1 && expectedTokens.every((token) => label.includes(token)); }; const topCard = main?.querySelector('.pv-top-card, [class*="top-card"]') || heading?.closest?.('section, .ph5, [class*="top-card"]') || main; const ownerAction = buttons.find((el) => namesOwner(el) && /^(invite|connect|follow|message|more|pending)\b/i.test(ariaLabel(el))); const ownerScope = ownerAction ? (ownerAction.closest('[class*="profile-actions"], [class*="pvs-profile-actions"], .pv-top-card, .ph5, section') || ownerAction.parentElement || topCard) : topCard; const scopedButtons = Array.from(ownerScope?.querySelectorAll?.('button, [role="button"], a') || []).filter((el) => el.offsetParent !== null); const ownerButtons = Array.from(new Set([...buttons.filter(namesOwner), ...scopedButtons])); const lowerOwnerLabels = ownerButtons.map((button) => labelOf(button).toLowerCase()).filter(Boolean); const pending = lowerOwnerLabels.some((label) => label === 'pending' || label.includes('pending')); const connectAvailable = ownerButtons.some((el) => { const label = labelOf(el).toLowerCase(); const aria = ariaLabel(el).toLowerCase(); return label === 'connect' || label.startsWith('connect ') || (/invite .* to connect/i.test(aria) && namesOwner(el)); }); // When "Follow" is the primary action, Connect is tucked inside the "More" actions // menu rather than shown as a top-level button. Note the menu's presence so the safety // check can treat the profile as connectable; the send step opens the menu and confirms. const moreAvailable = lowerOwnerLabels.some((label) => label === 'more' || label.startsWith('more ')); // A visible "Message" button is NOT proof of an existing connection: LinkedIn shows // Message on many 2nd/3rd-degree profiles (open profiles, Premium, recruiter) right // next to a real "Connect" button. The reliable signal for a 1st-degree connection is // the degree badge ("1st degree connection") in the top card. Scope to the top card so // the "People also viewed" sidebar (which lists other members' degrees) cannot bleed in, // and never call it connected when a Connect affordance is present. const topCardRaw = clean(topCard?.textContent || ''); const firstDegreeBadge = /\b1st degree connection\b/i.test(topCardRaw); const alreadyConnected = !connectAvailable && firstDegreeBadge; // The Connect control is an linking to LinkedIn's invitation route // (/preload/custom-invite/?vanityName=...). Capture it so the sender can // navigate straight to the invite dialog. const connectAnchor = ownerButtons.find((el) => el.tagName === 'A' && (/^connect$/i.test(labelOf(el)) || (/invite .* to connect/i.test(ariaLabel(el)) && namesOwner(el)))); const connectHref = connectAnchor ? (connectAnchor.getAttribute('href') || '') : ''; return { url: location.href, title: document.title || '', name, authRequired, alreadyConnected, pending, connectAvailable, moreAvailable, connectHref, buttonLabels: buttonLabels.slice(0, 30), bodyText: text, }; })()`; } // Runs in-page on LinkedIn's invitation route (/preload/custom-invite/...), // where the "Add a note to your invitation?" dialog is already open. function buildSentInvitationsProbeScript(expectedName, expectedProfileUrl) { return String.raw`(() => { const expectedName = ${JSON.stringify(expectedName)}; const expectedUrl = ${JSON.stringify(canonicalizeLinkedInProfileUrl(expectedProfileUrl))}; const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim(); const normName = (s) => clean(s) .replace(/\s*[•·]\s*(?:1st|2nd|3rd\+?|degree connection).*$/i, '') .replace(/\s+LinkedIn.*$/i, '') .replace(/\b(p\.?eng\.?|cpa|mba|ph\.?d\.?)\b/ig, '') .replace(/[^\p{L}\p{N}\s.'-]+/gu, ' ') .replace(/\s+/g, ' ') .trim() .toLowerCase(); const tokens = (s) => normName(s).replace(/[.'-]+/g, ' ').split(/\s+/).map((t) => t.trim()).filter((t) => t.length >= 2); const nameMatchesReasonably = (candidate, expected) => { const c = normName(candidate); const e = normName(expected); if (!c || !e) return false; if (c === e || c.includes(e) || e.includes(c)) return true; const candidateTokens = new Set(tokens(c)); const expectedTokens = tokens(e); if (expectedTokens.length < 2 || candidateTokens.size < 2) return false; const matched = expectedTokens.filter((token) => candidateTokens.has(token)).length; return matched >= 2 && matched / expectedTokens.length >= 0.8; }; const canon = (value) => { try { const url = new URL(value, 'https://www.linkedin.com'); if (!/^\/in\/[^/]+\/?$/i.test(url.pathname)) return ''; url.protocol = 'https:'; url.hostname = 'www.linkedin.com'; url.hash = ''; url.search = ''; if (!url.pathname.endsWith('/')) url.pathname += '/'; return url.toString(); } catch { return ''; } }; 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); if (authRequired) return { authRequired: true, found: false, matchedName: '', matchedUrl: '', visibleNames: [] }; const structuralRows = Array.from(document.querySelectorAll('li, article, [data-view-name], .mn-invitation-card')); const linkRows = Array.from(document.querySelectorAll('a[href*="/in/"]')) .map((a) => a.closest('li') || a.closest('[data-view-name]') || a.closest('[class*="invitation"]') || a.closest('div')) .filter(Boolean); const rows = Array.from(new Set([...structuralRows, ...linkRows])); const visibleNames = []; for (const row of rows.slice(0, 25)) { const rowText = clean(row.innerText || row.textContent || ''); if (!rowText) continue; const link = Array.from(row.querySelectorAll('a[href*="/in/"]')) .map((a) => ({ href: canon(a.href || a.getAttribute('href') || ''), text: clean(a.innerText || a.textContent || '') })) .find((a) => a.href || a.text); const candidateName = clean(link?.text || row.querySelector('span[aria-hidden="true"], h3, h2')?.textContent || rowText.split('\n')[0]); if (candidateName) visibleNames.push(candidateName); const candidateUrl = link?.href || ''; const nameMatches = expectedName && candidateName && nameMatchesReasonably(candidateName, expectedName); const urlMatches = expectedUrl && candidateUrl && candidateUrl === expectedUrl; if (urlMatches || nameMatches) return { authRequired: false, found: true, matchedName: candidateName, matchedUrl: candidateUrl, visibleNames: visibleNames.slice(0, 20) }; } return { authRequired: false, found: false, matchedName: '', matchedUrl: '', visibleNames: visibleNames.slice(0, 20) }; })()`; } function buildInviteScript(note) { return String.raw`(async () => { const note = ${JSON.stringify(note)}; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const jitter = async (min = 450, max = 1150) => sleep(min + Math.floor(Math.random() * (max - min + 1))); const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim(); const visible = (el) => el && el.offsetParent !== null; const label = (el) => clean(el?.innerText || el?.textContent || el?.getAttribute('aria-label')); const dialog = () => document.querySelector('[role="dialog"]'); const dialogButton = (pattern) => { const dlg = dialog(); if (!dlg) return null; return Array.from(dlg.querySelectorAll('button, [role="button"]')).filter(visible) .find((button) => pattern.test(label(button))); }; if (!dialog()) return { ok: false, status: 'blocked', reason: 'invite_dialog_not_found' }; if (!note) { const sendDirect = dialogButton(/^send without a note$/i) || dialogButton(/^send$/i); if (!sendDirect) return { ok: false, status: 'blocked', reason: 'send_button_not_found' }; await jitter(); sendDirect.click(); await jitter(1400, 2400); return { ok: true, status: 'sent', reason: 'invitation_sent_without_note' }; } const addNote = dialogButton(/^add a note$/i); if (!addNote) return { ok: false, status: 'blocked', reason: 'add_note_button_not_found' }; await jitter(); addNote.click(); await jitter(800, 1400); const textarea = document.querySelector('#custom-message') || Array.from(document.querySelectorAll('textarea')).find(visible); if (!textarea) return { ok: false, status: 'blocked', reason: 'note_textarea_not_found' }; textarea.focus(); // React tracks textarea values through the native setter; assigning .value // directly would leave component state (and the Send button) unchanged. const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; nativeSetter.call(textarea, note); textarea.dispatchEvent(new Event('input', { bubbles: true })); textarea.dispatchEvent(new Event('change', { bubbles: true })); await jitter(700, 1300); const send = dialogButton(/^send$/i); if (!send) return { ok: false, status: 'blocked', reason: 'send_button_not_found' }; if (send.disabled || send.getAttribute('aria-disabled') === 'true') { return { ok: false, status: 'blocked', reason: 'send_button_disabled' }; } send.click(); await jitter(1400, 2400); return { ok: true, status: 'sent', reason: 'invitation_sent_with_note' }; })()`; } // Opens the connection-invite dialog in-page for profiles where "Connect" is a //