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
263 lines
11 KiB
JavaScript
263 lines
11 KiB
JavaScript
/**
|
|
* LinkedIn people-search via SSR DOM text-slice. Voyager people-search
|
|
* REST returns HTTP 500 from a web context; LinkedIn renders results
|
|
* server-side now. One navigation per call consumes one CUL query.
|
|
*/
|
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
|
|
const LINKEDIN_DOMAIN = 'www.linkedin.com';
|
|
const SEARCH_URL_BASE = 'https://www.linkedin.com/search/results/people/';
|
|
const MAX_LIMIT = 10;
|
|
|
|
function normalizeWhitespace(value) {
|
|
return String(value ?? '').replace(/[ ]/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 parseLimit(value) {
|
|
if (value === undefined || value === null || value === '') return 5;
|
|
const limit = Number(value);
|
|
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
|
|
throw new ArgumentError(`--limit must be an integer between 1 and ${MAX_LIMIT}`);
|
|
}
|
|
return limit;
|
|
}
|
|
|
|
function unwrapEvaluateResult(payload) {
|
|
if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
|
|
return payload;
|
|
}
|
|
|
|
function buildSearchUrl(keywords) {
|
|
return SEARCH_URL_BASE + '?keywords=' + encodeURIComponent(keywords);
|
|
}
|
|
|
|
function looksLinkedInAuthWall(value) {
|
|
const text = normalizeWhitespace(value).toLowerCase();
|
|
if (!text) return false;
|
|
return /linkedin\.com\/(?:login|checkpoint|authwall|uas)/i.test(text)
|
|
|| /\b(sign in|log in|join linkedin|captcha|verification required)\b/i.test(text)
|
|
|| /(请登录|登录领英|安全验证)/.test(text);
|
|
}
|
|
|
|
function normalizeProfileUrl(value) {
|
|
const raw = normalizeWhitespace(value);
|
|
if (!raw) return '';
|
|
try {
|
|
const parsed = new URL(raw);
|
|
const host = parsed.hostname.toLowerCase();
|
|
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port) return '';
|
|
if (host !== 'linkedin.com' && host !== 'www.linkedin.com') return '';
|
|
const match = parsed.pathname.match(/^\/in\/([^/?#]+)\/?$/);
|
|
if (!match || !match[1]) return '';
|
|
return `https://www.linkedin.com/in/${match[1]}/`;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function normalizePeopleRows(rows) {
|
|
if (!Array.isArray(rows)) {
|
|
throw new CommandExecutionError('LinkedIn people search returned malformed extraction payload: missing rows array');
|
|
}
|
|
return rows.map((row, index) => {
|
|
if (!row || typeof row !== 'object') {
|
|
throw new CommandExecutionError(`LinkedIn people search returned malformed row at index ${index}`);
|
|
}
|
|
const name = normalizeWhitespace(row.name);
|
|
const profileUrl = normalizeProfileUrl(row.profile_url);
|
|
if (!name || !profileUrl) {
|
|
throw new CommandExecutionError(`LinkedIn people search returned row without stable profile identity at index ${index}`);
|
|
}
|
|
return {
|
|
name,
|
|
headline: normalizeWhitespace(row.headline),
|
|
location: normalizeWhitespace(row.location),
|
|
profile_url: profileUrl,
|
|
};
|
|
});
|
|
}
|
|
|
|
function parseNonNegativeCount(value, label) {
|
|
const count = Number(value);
|
|
if (!Number.isInteger(count) || count < 0) {
|
|
throw new CommandExecutionError(`LinkedIn people search returned malformed extraction payload: invalid ${label}`);
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function extractionScript() {
|
|
// Class-based selectors are dead (LinkedIn rotates hashed class
|
|
// names on every deploy) and display:contents flattens the DOM
|
|
// tree so per-card containers don't exist. Read main.innerText
|
|
// and slice between consecutive person-name lines instead.
|
|
return String.raw`(() => {
|
|
if (!/search\/results\/people/.test(window.location.href)) {
|
|
return { error: 'not on people search page', url: window.location.href };
|
|
}
|
|
const main = document.querySelector('main') || document.body;
|
|
const normalize = (s) => String(s || '').replace(/[\s\u00a0\u202f]+/g, ' ').trim();
|
|
const skip = (l) => !l
|
|
|| /^Status is/.test(l)
|
|
|| /^(Message|Connect|Follow|View profile|Pending|Remove)$/i.test(l)
|
|
|| /^[•·]\s*(?:1st|2nd|3rd\+?|degree)/i.test(l)
|
|
|| /^[•·]/.test(l)
|
|
|| l.includes('mutual connection')
|
|
|| l.includes('shared connection')
|
|
|| /^Summary:/i.test(l)
|
|
|| /^About this profile/i.test(l);
|
|
|
|
const anchors = Array.from(main.querySelectorAll('a[href*="/in/"]'));
|
|
const personEntries = [];
|
|
const seenHandles = new Set();
|
|
for (const a of anchors) {
|
|
const m = (a.getAttribute('href') || '').match(/\/in\/([^/?#]+)/);
|
|
if (!m || !m[1]) continue;
|
|
const profileHandle = m[1];
|
|
if (seenHandles.has(profileHandle)) continue;
|
|
const aria = a.querySelector('span[aria-hidden="true"]');
|
|
let name = normalize(aria ? aria.textContent : a.textContent);
|
|
name = name.replace(/^Status is (online|offline)\.?\s*/i, '')
|
|
.replace(/'?s profile$/i, '')
|
|
.replace(/\s*[•·].*$/, '').trim();
|
|
if (!name) continue;
|
|
seenHandles.add(profileHandle);
|
|
personEntries.push({ profileHandle, displayName: name });
|
|
}
|
|
|
|
const lines = (main.innerText || '').split(/\n+/).map(normalize).filter(Boolean);
|
|
|
|
// skip() rejects mutual-connection lines, so candidates that only
|
|
// appear as mutual-connection links inside another card's row
|
|
// never resolve a name index and get filtered out below.
|
|
const nameToIndex = new Map();
|
|
for (const { displayName } of personEntries) {
|
|
if (nameToIndex.has(displayName)) continue;
|
|
const match = lines.findIndex((l) =>
|
|
!skip(l) && (
|
|
l === displayName
|
|
|| l.startsWith(displayName + ' ')
|
|
|| l.startsWith(displayName + ',')
|
|
|| l.startsWith(displayName + "'")
|
|
)
|
|
);
|
|
if (match >= 0) nameToIndex.set(displayName, match);
|
|
}
|
|
|
|
const resolved = personEntries.filter((p) => nameToIndex.has(p.displayName));
|
|
const rows = [];
|
|
for (let i = 0; i < resolved.length; i++) {
|
|
const { profileHandle, displayName } = resolved[i];
|
|
const startIdx = nameToIndex.get(displayName);
|
|
let stopIdx = lines.length;
|
|
for (let j = i + 1; j < resolved.length; j++) {
|
|
const otherStart = nameToIndex.get(resolved[j].displayName);
|
|
if (otherStart != null && otherStart > startIdx) {
|
|
stopIdx = otherStart;
|
|
break;
|
|
}
|
|
}
|
|
const slice = lines.slice(startIdx + 1, stopIdx).filter((l) => l !== displayName && !skip(l));
|
|
rows.push({
|
|
name: displayName,
|
|
headline: slice[0] || '',
|
|
location: slice[1] || '',
|
|
profile_url: 'https://www.linkedin.com/in/' + profileHandle + '/',
|
|
});
|
|
}
|
|
return {
|
|
rows,
|
|
candidate_count: personEntries.length,
|
|
person_entries_count: personEntries.length,
|
|
resolved_count: resolved.length,
|
|
};
|
|
})()`;
|
|
}
|
|
|
|
cli({
|
|
site: 'linkedin',
|
|
name: 'people-search',
|
|
access: 'read',
|
|
description: 'Search standard LinkedIn (not Sales Navigator) for people by keyword. Each invocation consumes against LinkedIn\'s monthly Commercial Use Limit on people search; throttle accordingly.',
|
|
domain: LINKEDIN_DOMAIN,
|
|
strategy: Strategy.COOKIE,
|
|
browser: true,
|
|
args: [
|
|
{ name: 'keywords', type: 'string', required: true, positional: true, help: 'People search keywords, e.g. "site reliability engineer berlin"' },
|
|
{ name: 'limit', type: 'int', default: 5, help: `Maximum people to return (1-${MAX_LIMIT}); each query counts toward LinkedIn's monthly CUL` },
|
|
],
|
|
columns: ['rank', 'name', 'headline', 'location', 'profile_url'],
|
|
func: async (page, args) => {
|
|
if (!page) throw new CommandExecutionError('Browser session required for linkedin people-search');
|
|
const keywords = requireStringArg(args, 'keywords', '--keywords');
|
|
const limit = parseLimit(args.limit);
|
|
|
|
try {
|
|
await page.goto(buildSearchUrl(keywords));
|
|
await page.wait(6);
|
|
} catch (error) {
|
|
throw new CommandExecutionError(`LinkedIn people search navigation failed: ${error?.message || error}`);
|
|
}
|
|
|
|
let cookies;
|
|
try {
|
|
cookies = await page.getCookies({ url: 'https://www.linkedin.com' });
|
|
} catch (error) {
|
|
throw new CommandExecutionError(`LinkedIn cookie lookup failed: ${error?.message || error}`);
|
|
}
|
|
if (!Array.isArray(cookies)) {
|
|
throw new CommandExecutionError('LinkedIn cookie lookup returned malformed payload');
|
|
}
|
|
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 in the browser.');
|
|
}
|
|
|
|
let result;
|
|
try {
|
|
result = unwrapEvaluateResult(await page.evaluate(extractionScript()));
|
|
} catch (error) {
|
|
throw new CommandExecutionError(`LinkedIn people search extraction failed: ${error?.message || error}`);
|
|
}
|
|
if (result?.error) {
|
|
if (looksLinkedInAuthWall(`${result.url || ''} ${result.error || ''}`)) {
|
|
throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn people search requires an active signed-in browser session.');
|
|
}
|
|
// If LinkedIn redirected away from the search page that
|
|
// usually means CUL was reached or the account is gated.
|
|
throw new CommandExecutionError(`LinkedIn redirected away from the search page (${result.error}). Likely Commercial Use Limit reached - the limit resets on the 1st of next month.`);
|
|
}
|
|
if (!result || typeof result !== 'object') {
|
|
throw new CommandExecutionError('LinkedIn people search returned malformed extraction payload');
|
|
}
|
|
const candidateCount = parseNonNegativeCount(result.candidate_count, 'candidate_count');
|
|
parseNonNegativeCount(result.person_entries_count, 'person_entries_count');
|
|
const resolvedCount = parseNonNegativeCount(result.resolved_count, 'resolved_count');
|
|
const rows = normalizePeopleRows(result.rows);
|
|
if (rows.length === 0 && (candidateCount > 0 || resolvedCount > 0)) {
|
|
throw new CommandExecutionError('LinkedIn people search found profile candidates but could not parse stable result rows');
|
|
}
|
|
if (rows.length === 0) {
|
|
throw new EmptyResultError(`No people found on the rendered page for "${keywords}". The search may have returned zero results, or the DOM markup may have changed.`);
|
|
}
|
|
return rows.slice(0, limit).map((p, i) => ({ rank: i + 1, ...p }));
|
|
},
|
|
});
|
|
|
|
export const __test__ = {
|
|
normalizeWhitespace,
|
|
parseLimit,
|
|
buildSearchUrl,
|
|
looksLinkedInAuthWall,
|
|
normalizeProfileUrl,
|
|
normalizePeopleRows,
|
|
parseNonNegativeCount,
|
|
extractionScript,
|
|
};
|