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

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,
};