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

463 lines
20 KiB
JavaScript

import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
const LINKEDIN_DOMAIN = 'linkedin.com';
const MIN_LIMIT = 1;
const MAX_LIMIT = 100;
const MIN_START = 0;
// ── Filter value mappings ──────────────────────────────────────────────
const EXPERIENCE_LEVELS = {
internship: '1',
entry: '2',
'entry-level': '2',
associate: '3',
mid: '4',
senior: '4',
'mid-senior': '4',
'mid-senior-level': '4',
director: '5',
executive: '6',
};
const JOB_TYPES = {
'full-time': 'F',
fulltime: 'F',
full: 'F',
'part-time': 'P',
parttime: 'P',
part: 'P',
contract: 'C',
temporary: 'T',
temp: 'T',
volunteer: 'V',
internship: 'I',
other: 'O',
};
const DATE_POSTED = {
any: 'on',
month: 'r2592000',
'past-month': 'r2592000',
week: 'r604800',
'past-week': 'r604800',
day: 'r86400',
'24h': 'r86400',
'past-24h': 'r86400',
};
const REMOTE_TYPES = {
onsite: '1',
'on-site': '1',
hybrid: '3',
remote: '2',
};
// ── Helpers ────────────────────────────────────────────────────────────
function parseCsvArg(value) {
if (value === undefined || value === null || value === '')
return [];
return String(value)
.split(',')
.map(item => item.trim())
.filter(Boolean);
}
function mapFilterValues(input, mapping, label) {
const values = parseCsvArg(input);
const resolved = values.map(value => {
const key = value.toLowerCase();
const mapped = mapping[key];
if (!mapped)
throw new ArgumentError(`Unsupported ${label}: ${value}`);
return mapped;
});
return [...new Set(resolved)];
}
function normalizeWhitespace(value) {
return String(value ?? '').replace(/\s+/g, ' ').trim();
}
function parseIntegerArg(value, label, fallback, min, max = Infinity) {
if (value === undefined || value === null || value === '')
return fallback;
const parsed = Number(value);
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
throw new ArgumentError(`${label} must be an integer, got ${JSON.stringify(value)}`);
}
if (parsed < min || parsed > max) {
const range = Number.isFinite(max) ? `between ${min} and ${max}` : `at least ${min}`;
throw new ArgumentError(`${label} must be ${range}, got ${parsed}`);
}
return parsed;
}
function decodeLinkedinRedirect(url) {
if (!url)
return '';
try {
const parsed = new URL(url);
if (parsed.pathname === '/redir/redirect/') {
return parsed.searchParams.get('url') || url;
}
}
catch { }
return url;
}
function buildVoyagerSearchQuery(input) {
const hasFilters = input.companyIds.length ||
input.experienceLevels.length ||
input.jobTypes.length ||
input.datePostedValues.length ||
input.remoteTypes.length;
const parts = [
'origin:' + (hasFilters ? 'JOB_SEARCH_PAGE_JOB_FILTER' : 'JOB_SEARCH_PAGE_OTHER_ENTRY'),
'keywords:' + input.keywords,
];
if (input.location) {
parts.push('locationUnion:(seoLocation:(location:' + input.location + '))');
}
const filters = [];
if (input.companyIds.length)
filters.push('company:List(' + input.companyIds.join(',') + ')');
if (input.experienceLevels.length)
filters.push('experience:List(' + input.experienceLevels.join(',') + ')');
if (input.jobTypes.length)
filters.push('jobType:List(' + input.jobTypes.join(',') + ')');
if (input.datePostedValues.length)
filters.push('timePostedRange:List(' + input.datePostedValues.join(',') + ')');
if (input.remoteTypes.length)
filters.push('workplaceType:List(' + input.remoteTypes.join(',') + ')');
if (filters.length)
parts.push('selectedFilters:(' + filters.join(',') + ')');
parts.push('spellCorrectionEnabled:true');
return '(' + parts.join(',') + ')';
}
function buildVoyagerUrl(input, offset, count) {
const params = new URLSearchParams({
decorationId: 'com.linkedin.voyager.dash.deco.jobs.search.JobSearchCardsCollection-220',
count: String(count),
q: 'jobSearch',
});
const query = encodeURIComponent(buildVoyagerSearchQuery(input))
.replace(/%3A/gi, ':')
.replace(/%2C/gi, ',')
.replace(/%28/gi, '(')
.replace(/%29/gi, ')');
return '/voyager/api/voyagerJobsDashJobCards?' + params.toString() + '&query=' + query + '&start=' + offset;
}
function looksLinkedInAuthWallText(value) {
const text = String(value ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
if (!text)
return false;
return /\b(sign in|log in|join linkedin)\b/.test(text) ||
/linkedin\.com\/(login|checkpoint|authwall)/i.test(text) ||
/\b(captcha|verification required)\b/.test(text) ||
/(请登录|登录领英|安全验证)/.test(text);
}
function buildLinkedInAuthProbeScript() {
return `(() => {
const text = [
window.location.href || '',
document.title || '',
document.body ? (document.body.innerText || '').slice(0, 4000) : '',
].join('\\n');
return ${looksLinkedInAuthWallText.toString()}(text);
})()`;
}
async function assertLinkedInAuthenticated(page, context) {
const authRequired = await page.evaluate(buildLinkedInAuthProbeScript());
if (authRequired) {
throw new AuthRequiredError(LINKEDIN_DOMAIN, `${context} requires an active signed-in LinkedIn browser session`);
}
}
// ── Company ID resolution (requires DOM interaction) ──────────────────
async function resolveCompanyIds(page, input) {
const rawValues = parseCsvArg(input);
const ids = new Set();
const names = [];
for (const value of rawValues) {
if (/^\d+$/.test(value))
ids.add(value);
else
names.push(value);
}
if (!names.length)
return [...ids];
const resolved = await page.evaluate(`(async () => {
const targets = ${JSON.stringify(names)};
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const normalize = (v) => (v || '').toLowerCase().replace(/\\s+/g, ' ').trim();
// Open "All filters" panel to expose company filter inputs
const allBtn = [...document.querySelectorAll('button')]
.find(b => ((b.innerText || '').trim().replace(/\\s+/g, ' ')) === 'All filters');
if (allBtn) { allBtn.click(); await sleep(300); }
const getCompanyMap = () => {
const map = {};
for (const el of document.querySelectorAll('input[name="company-filter-value"]')) {
const text = (el.parentElement?.innerText || el.closest('label')?.innerText || '')
.replace(/\\s+/g, ' ').trim().replace(/\\s*Filter by.*$/i, '').trim();
if (text) map[normalize(text)] = el.value;
}
return map;
};
const match = (map, name) => {
const n = normalize(name);
if (map[n]) return map[n];
const k = Object.keys(map).find(e => e === n || e.includes(n) || n.includes(e));
return k ? map[k] : null;
};
const results = {};
let map = getCompanyMap();
for (const name of targets) {
let found = match(map, name);
if (!found) {
const inp = [...document.querySelectorAll('input')]
.find(el => el.getAttribute('aria-label') === 'Add a company');
if (inp) {
inp.focus();
inp.value = name;
inp.dispatchEvent(new Event('input', { bubbles: true }));
inp.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true }));
await sleep(1200);
map = getCompanyMap();
found = match(map, name);
inp.value = '';
inp.dispatchEvent(new Event('input', { bubbles: true }));
await sleep(100);
}
}
results[name] = found || null;
}
return results;
})()`);
const unresolved = [];
for (const name of names) {
const id = resolved?.[name];
if (id)
ids.add(id);
else
unresolved.push(name);
}
if (unresolved.length) {
throw new ArgumentError(`Could not resolve LinkedIn company filter: ${unresolved.join(', ')}`);
}
return [...ids];
}
// ── Voyager API fetch (runs inside page context for cookie access) ────
async function fetchJobCards(page, input) {
const MAX_BATCH = 25;
const allJobs = [];
let offset = input.start;
// Read JSESSIONID directly from the cookie store via CDP — zero page.evaluate round-trip
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 in the browser.');
}
const csrf = jsession.replace(/^"|"$/g, '');
while (allJobs.length < input.limit) {
const count = Math.min(MAX_BATCH, input.limit - allJobs.length);
const apiPath = buildVoyagerUrl(input, offset, count);
const batch = await page.evaluate(`(async () => {
const res = await fetch(${JSON.stringify(apiPath)}, {
credentials: 'include',
headers: { 'csrf-token': ${JSON.stringify(csrf)}, 'x-restli-protocol-version': '2.0.0' },
});
if (res.status === 401 || res.status === 403) {
const text = await res.text();
return {
authRequired: true,
error: 'LinkedIn API authentication failed: HTTP ' + res.status + ' ' + text.slice(0, 200)
};
}
if (!res.ok) {
const text = await res.text();
return { error: 'LinkedIn API error: HTTP ' + res.status + ' ' + text.slice(0, 200) };
}
return res.json();
})()`);
if (!batch || batch.error) {
if (batch?.authRequired) {
throw new AuthRequiredError(LINKEDIN_DOMAIN, batch.error);
}
throw new CommandExecutionError(batch?.error || 'LinkedIn search returned an unexpected response');
}
const elements = Array.isArray(batch?.elements) ? batch.elements : [];
if (elements.length === 0)
break;
for (const element of elements) {
const card = element?.jobCardUnion?.jobPostingCard;
if (!card)
continue;
// Extract job ID from URN fields
const jobId = [card.jobPostingUrn, card.jobPosting?.entityUrn, card.entityUrn]
.filter(Boolean)
.map(s => String(s).match(/(\d+)/)?.[1])
.find(Boolean) ?? '';
// Extract listed date
const listedItem = (card.footerItems || []).find((i) => i?.type === 'LISTED_DATE' && i?.timeAt);
const listed = listedItem?.timeAt ? new Date(listedItem.timeAt).toISOString().slice(0, 10) : '';
allJobs.push({
title: card.jobPostingTitle || card.title?.text || '',
company: card.primaryDescription?.text || '',
location: card.secondaryDescription?.text || '',
listed,
salary: card.tertiaryDescription?.text || '',
url: jobId ? 'https://www.linkedin.com/jobs/view/' + jobId : '',
});
}
if (elements.length < count)
break;
offset += elements.length;
}
return allJobs.slice(0, input.limit).map((item, index) => ({
rank: input.start + index + 1,
...item,
}));
}
// ── Job detail enrichment (--details flag) ────────────────────────────
//
// Per-row failures should NOT abort the whole list (--details enriches N rows;
// partial failure is expected). But silent empty-string fields hide the failure
// from callers — previously `catch {}` and the `if (!job.url)` early-return
// both produced indistinguishable `description: '', apply_url: ''` payloads,
// so users could not tell "fetch failed" from "upstream had no description".
//
// The fix: surface `null` instead of `''` for missing/failed rows, set
// `detail_error` to a short reason ("no url" / "fetch failed: <message>" /
// "missing description"), and log every failure to stderr with the offending
// URL so debugging is possible. Successful rows have `detail_error: null`.
async function enrichJobDetails(page, jobs) {
const enriched = [];
for (let i = 0; i < jobs.length; i++) {
const job = jobs[i];
console.error(`[opencli:linkedin] Fetching details ${i + 1}/${jobs.length}: ${job.title}`);
if (!job.url) {
const reason = 'no url';
console.error(`[opencli:linkedin] Skipping detail for "${job.title}": ${reason}`);
enriched.push({ ...job, description: null, apply_url: null, detail_error: reason });
continue;
}
try {
await page.goto(job.url);
await assertLinkedInAuthenticated(page, 'LinkedIn job detail');
await page.wait({ text: 'About the job', timeout: 8 });
// Expand "Show more" button if present
await page.evaluate(`(() => {
const norm = (v) => (v || '').replace(/\\s+/g, ' ').trim().toLowerCase();
const section = [...document.querySelectorAll('div, section, article')]
.find(el => norm(el.querySelector('h1,h2,h3,h4')?.textContent || '') === 'about the job');
const btn = [...(section?.querySelectorAll('button, a[role="button"]') || [])]
.find(el => /more/.test(norm(el.textContent || '')) || /more/.test(norm(el.getAttribute('aria-label') || '')));
if (btn) btn.click();
})()`);
await page.wait(1);
// Extract description and apply URL
const detail = await page.evaluate(`(() => {
const norm = (v) => (v || '').replace(/\\s+/g, ' ').trim();
// Find the most specific (shortest) container with "About the job" heading
// Shortest = most specific DOM node, avoiding outer wrappers that include unrelated text
const candidates = [...document.querySelectorAll('div, section, article')]
.map(el => ({
heading: norm(el.querySelector('h1,h2,h3,h4')?.textContent || ''),
text: norm(el.innerText || ''),
}))
.filter(c => c.text && c.heading.toLowerCase() === 'about the job' && c.text.length > 'About the job'.length)
.sort((a, b) => a.text.length - b.text.length);
const description = candidates[0]?.text.replace(/^About the job\\s*/i, '') || '';
const applyLink = [...document.querySelectorAll('a[href]')]
.map(a => ({ href: a.href || '', text: norm(a.textContent || ''), aria: norm(a.getAttribute('aria-label') || '') }))
.find(a => /apply/i.test(a.text) || /apply/i.test(a.aria));
return { description, applyUrl: applyLink?.href || '' };
})()`);
const description = normalizeWhitespace(detail?.description);
const apply_url = decodeLinkedinRedirect(String(detail?.applyUrl ?? ''));
// Empty description after a successful fetch is itself a
// recognizable signal — surface it via detail_error instead of
// silently emitting an empty string.
const detail_error = description ? null : 'missing description';
enriched.push({
...job,
description: description || null,
apply_url: apply_url || null,
detail_error,
});
}
catch (err) {
if (err instanceof AuthRequiredError)
throw err;
const reason = `fetch failed: ${err?.message || err}`;
console.error(`[opencli:linkedin] Detail fetch failed for ${job.url}: ${reason}`);
enriched.push({ ...job, description: null, apply_url: null, detail_error: reason });
}
}
return enriched;
}
// ── CLI registration ──────────────────────────────────────────────────
cli({
site: 'linkedin',
name: 'search',
access: 'read',
description: 'Search LinkedIn jobs',
domain: 'www.linkedin.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'query', type: 'string', required: true, positional: true, help: 'Job search keywords' },
{ name: 'location', type: 'string', required: false, help: 'Location text such as San Francisco Bay Area' },
{ name: 'limit', type: 'int', default: 10, help: 'Number of jobs to return (max 100)' },
{ name: 'start', type: 'int', default: 0, help: 'Result offset for pagination' },
{ name: 'details', type: 'bool', default: false, help: 'Include full job description and apply URL (slower)' },
{ name: 'company', type: 'string', required: false, help: 'Comma-separated company names or LinkedIn company IDs' },
{ name: 'experience-level', type: 'string', required: false, help: 'Comma-separated: internship, entry, associate, mid-senior, director, executive' },
{ name: 'job-type', type: 'string', required: false, help: 'Comma-separated: full-time, part-time, contract, temporary, volunteer, internship, other' },
{ name: 'date-posted', type: 'string', required: false, help: 'One of: any, month, week, 24h' },
{ name: 'remote', type: 'string', required: false, help: 'Comma-separated: on-site, hybrid, remote' },
],
columns: ['rank', 'title', 'company', 'location', 'listed', 'salary', 'url'],
func: async (page, kwargs) => {
const limit = parseIntegerArg(kwargs.limit, '--limit', 10, MIN_LIMIT, MAX_LIMIT);
const start = parseIntegerArg(kwargs.start, '--start', 0, MIN_START);
const includeDetails = Boolean(kwargs.details);
const location = (kwargs.location ?? '').trim();
const keywords = String(kwargs.query ?? '').trim();
if (!keywords)
throw new ArgumentError('query is required');
const searchParams = new URLSearchParams({ keywords });
if (location)
searchParams.set('location', location);
await page.goto(`https://www.linkedin.com/jobs/search/?${searchParams.toString()}`);
await assertLinkedInAuthenticated(page, 'LinkedIn search');
await page.wait({ text: 'Jobs', timeout: 10 });
const companyIds = await resolveCompanyIds(page, kwargs.company);
const input = {
keywords,
location,
limit,
start,
companyIds,
experienceLevels: mapFilterValues(kwargs['experience-level'], EXPERIENCE_LEVELS, 'experience_level'),
jobTypes: mapFilterValues(kwargs['job-type'], JOB_TYPES, 'job_type'),
datePostedValues: mapFilterValues(kwargs['date-posted'], DATE_POSTED, 'date_posted'),
remoteTypes: mapFilterValues(kwargs.remote, REMOTE_TYPES, 'remote'),
};
const data = await fetchJobCards(page, input);
if (!includeDetails)
return data;
return enrichJobDetails(page, data);
},
});
export const __test__ = {
parseCsvArg,
parseIntegerArg,
mapFilterValues,
decodeLinkedinRedirect,
looksLinkedInAuthWallText,
assertLinkedInAuthenticated,
enrichJobDetails,
EXPERIENCE_LEVELS,
JOB_TYPES,
DATE_POSTED,
REMOTE_TYPES,
};