Files
claw-apply/lib/linkedin.mjs
Matthew Jackson 8212f97aba refactor: normalize apply statuses, remove dead code, fix signal handler
- lib/apply/index.mjs: add STATUS_MAP to normalize platform-specific statuses
  to generic ones (no_button/no_submit/no_modal → skipped_no_apply).
  Documented all generic statuses for AI/developer reference.
- job_applier.mjs: handleResult now handles skipped_no_apply, default case
  logs + saves instead of silently dropping
- lib/linkedin.mjs: remove dead applyLinkedIn() and detectAts(), clean imports
  (~110 lines removed). Search-only module now.
- lib/wellfound.mjs: remove dead applyWellfound(), clean imports.
  Search-only module now.
- lib/lock.mjs: fix async signal handler — shutdown handlers now actually
  complete before process.exit()
- test_linkedin_login.mjs: add try/catch/finally with proper browser cleanup
- README: update status table with all current statuses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:39:48 -08:00

137 lines
5.4 KiB
JavaScript

/**
* linkedin.mjs — LinkedIn search + job classification
* Apply logic lives in lib/apply/easy_apply.mjs
*/
import {
LINKEDIN_BASE, NAVIGATION_TIMEOUT, FEED_NAVIGATION_TIMEOUT,
PAGE_LOAD_WAIT, SCROLL_WAIT, CLICK_WAIT,
EXTERNAL_ATS_PATTERNS
} from './constants.mjs';
const MAX_SEARCH_PAGES = 40;
export async function verifyLogin(page) {
await page.goto(`${LINKEDIN_BASE}/feed/`, { waitUntil: 'domcontentloaded', timeout: FEED_NAVIGATION_TIMEOUT });
await page.waitForTimeout(CLICK_WAIT);
return page.url().includes('/feed');
}
export async function searchLinkedIn(page, search, { onPage } = {}) {
const jobs = [];
const seenIds = new Set();
for (const keyword of search.keywords) {
const params = new URLSearchParams({ keywords: keyword, sortBy: 'DD' });
if (search.filters?.remote) params.set('f_WT', '2');
if (search.filters?.easy_apply_only) params.set('f_LF', 'f_AL');
if (search.filters?.posted_within_days) {
params.set('f_TPR', `r${search.filters.posted_within_days * 86400}`);
}
const url = `${LINKEDIN_BASE}/jobs/search/?${params.toString()}`;
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: NAVIGATION_TIMEOUT });
} catch (e) {
console.error(` ⚠️ Navigation failed for "${keyword}": ${e.message}`);
continue;
}
await page.waitForTimeout(PAGE_LOAD_WAIT);
let pageNum = 0;
while (pageNum < MAX_SEARCH_PAGES) {
// Scroll to load all cards
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(SCROLL_WAIT);
// Get all job IDs on this page
const pageIds = await page.evaluate(() =>
[...new Set(
Array.from(document.querySelectorAll('a[href*="/jobs/view/"]'))
.map(a => a.href.match(/\/jobs\/view\/(\d+)/)?.[1])
.filter(Boolean)
)]
);
const pageJobs = [];
for (const jobId of pageIds) {
if (seenIds.has(jobId)) continue;
// Click the job card to load right panel
try {
await page.evaluate((id) => {
const link = document.querySelector(`a[href*="/jobs/view/${id}"]`);
link?.closest('li')?.click() || link?.click();
}, jobId);
await page.waitForTimeout(CLICK_WAIT);
} catch (e) {
console.warn(` ⚠️ Could not click job card ${jobId}: ${e.message}`);
}
// Read title, company, location from detail panel (more accurate)
const meta = await page.evaluate(({ id, track, excludes }) => {
const panel = document.querySelector('.jobs-unified-top-card, .job-details-jobs-unified-top-card__job-title');
const title = document.querySelector('.job-details-jobs-unified-top-card__job-title, h1[class*="title"]')?.textContent?.trim()
|| document.querySelector('.jobs-unified-top-card__job-title')?.textContent?.trim() || '';
const company = document.querySelector('.job-details-jobs-unified-top-card__company-name a, .jobs-unified-top-card__company-name a')?.textContent?.trim() || '';
const location = document.querySelector('.job-details-jobs-unified-top-card__bullet, .jobs-unified-top-card__bullet')?.textContent?.trim() || '';
const tl = title.toLowerCase(), cl = company.toLowerCase();
for (const ex of excludes) {
if (tl.includes(ex.toLowerCase()) || cl.includes(ex.toLowerCase())) return null;
}
return { title, company, location };
}, { id: jobId, track: search.track, excludes: search.exclude_keywords || [] });
if (!meta) { seenIds.add(jobId); continue; } // excluded
// Detect apply type from right panel
const applyInfo = await page.evaluate(({ atsPatterns }) => {
const eaBtn = document.querySelector('button.jobs-apply-button[aria-label*="Easy Apply"]');
if (eaBtn) return { apply_type: 'easy_apply', apply_url: null };
const interestedBtn = document.querySelector('button[aria-label*="interested"]');
if (interestedBtn) return { apply_type: 'recruiter_only', apply_url: null };
// Look for external ATS link
const allLinks = Array.from(document.querySelectorAll('a[href]')).map(a => a.href);
for (const href of allLinks) {
for (const { name, pattern } of atsPatterns) {
if (new RegExp(pattern).test(href)) return { apply_type: name, apply_url: href };
}
}
const externalBtn = document.querySelector('button.jobs-apply-button:not([aria-label*="Easy Apply"])');
if (externalBtn) return { apply_type: 'unknown_external', apply_url: null };
return { apply_type: 'unknown', apply_url: null };
}, { atsPatterns: EXTERNAL_ATS_PATTERNS.map(({ name, pattern }) => ({ name, pattern: pattern.source })) });
seenIds.add(jobId);
const job = {
id: `li_${jobId}`,
platform: 'linkedin',
track: search.track,
jobId,
url: `https://www.linkedin.com/jobs/view/${jobId}/`,
classified_at: Date.now(),
...meta,
...applyInfo,
};
pageJobs.push(job);
jobs.push(job);
}
if (pageJobs.length > 0 && onPage) onPage(pageJobs);
const nextBtn = await page.$('button[aria-label="View next page"]');
if (!nextBtn) break;
await nextBtn.click();
await page.waitForTimeout(PAGE_LOAD_WAIT);
pageNum++;
}
}
return jobs;
}