#!/usr/bin/env node /** * archive-posting.mjs — Save a live job posting as PDF before it disappears. * * Job postings vanish after they're filled, reposted, or companies reorganise. * This captures the fully-rendered page via Playwright so you always have the * original requirements for interview prep and salary negotiation evidence. * * Usage: * node archive-posting.mjs * node archive-posting.mjs --company=Anthropic --role=senior-ai-engineer * node archive-posting.mjs --pipeline Archive pending URLs in data/pipeline.md * node archive-posting.mjs --dry-run Preview filename without saving * * Output: jds/YYYY-MM-DD_company-slug_role-slug.pdf * Reference: local:jds/YYYY-MM-DD_company-slug_role-slug.pdf (paste into pipeline.md) */ import { chromium } from 'playwright'; import { writeFile, readFile } from 'fs/promises'; import { existsSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; const ROOT = dirname(fileURLToPath(import.meta.url)); const JDS_DIR = join(ROOT, 'jds'); const PIPELINE_PATH = join(ROOT, 'data', 'pipeline.md'); // ── CLI parsing ────────────────────────────────────────────────────────────── const args = process.argv.slice(2); if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { console.log(` ╔══════════════════════════════════════════════════════════════════╗ ║ career-ops — Job Posting Archiver ║ ╚══════════════════════════════════════════════════════════════════╝ Save a live job posting as PDF before it disappears. USAGE node archive-posting.mjs node archive-posting.mjs --company=Anthropic --role=senior-ai-engineer node archive-posting.mjs --pipeline Archive all pending URLs in data/pipeline.md node archive-posting.mjs --dry-run OPTIONS --company Override auto-detected company name --role Override auto-detected role title --pipeline Archive all pending (- [ ]) entries in data/pipeline.md --dry-run Preview filename without saving --help Show this help OUTPUT jds/YYYY-MM-DD_company-slug_role-slug.pdf PIPELINE REFERENCE (paste into pipeline.md or reports/) local:jds/YYYY-MM-DD_company-slug_role-slug.pdf EXAMPLES node archive-posting.mjs "https://jobs.ashbyhq.com/anthropic/abc123" node archive-posting.mjs "https://boards.greenhouse.io/openai/jobs/456" --company=OpenAI node archive-posting.mjs --pipeline npm run archive -- "https://jobs.lever.co/elevenlabs/abc" `); process.exit(0); } let targetUrl = null; let overrideCompany = null; let overrideRole = null; let pipelineMode = false; let dryRun = false; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--pipeline') { pipelineMode = true; } else if (arg === '--dry-run') { dryRun = true; } else if (arg.startsWith('--company=')) { overrideCompany = arg.slice('--company='.length).trim(); } else if (arg === '--company' && args[i + 1]) { overrideCompany = args[++i].trim(); } else if (arg.startsWith('--role=')) { overrideRole = arg.slice('--role='.length).trim(); } else if (arg === '--role' && args[i + 1]) { overrideRole = args[++i].trim(); } else if (!arg.startsWith('--') && !targetUrl) { targetUrl = arg; } } if (!pipelineMode && !targetUrl) { console.error('No URL provided. Run with --help for usage.'); process.exit(1); } // ── Utilities ──────────────────────────────────────────────────────────────── function slugify(text) { return text .toLowerCase() .replace(/[^\w\s-]/g, '') .replace(/[\s_]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 60); } function today() { return new Date().toISOString().split('T')[0]; } /** * Try to extract company/role from the rendered page title. * Handles common ATS patterns: * "Senior AI Engineer at Anthropic" → role + company * "Anthropic | Senior AI Engineer" → company + role * "Senior AI Engineer - Anthropic" → role + company */ function parsePageTitle(title) { if (!title) return { company: null, role: null }; // Strip common ATS platform suffixes const cleaned = title .replace(/\s*[|–-]\s*(greenhouse|lever|ashby|workday|linkedin|indeed|wellfound|angellist)\s*$/i, '') .trim(); // "Role at Company" const atMatch = cleaned.match(/^(.+?)\s+at\s+(.+)$/i); if (atMatch) return { role: atMatch[1].trim(), company: atMatch[2].trim() }; // "Company | Role" or "Company – Role" const pipeMatch = cleaned.match(/^([^|–]+?)\s*[|–]\s*(.+)$/); if (pipeMatch) { const left = pipeMatch[1].trim(); const right = pipeMatch[2].trim(); const roleKeywords = /engineer|manager|director|analyst|scientist|designer|developer|lead|head|vp|president|officer|specialist|architect/i; if (roleKeywords.test(right)) return { company: left, role: right }; if (roleKeywords.test(left)) return { role: left, company: right }; return { company: left, role: right }; } // "Role - Company" const dashMatch = cleaned.match(/^(.+?)\s+-\s+(.+)$/); if (dashMatch) return { role: dashMatch[1].trim(), company: dashMatch[2].trim() }; return { company: null, role: cleaned }; } /** * Extract company from known ATS URL patterns as a fallback when the page * title doesn't yield a clear company name. */ function extractCompanyFromUrl(url) { try { const { hostname, pathname } = new URL(url); const parts = pathname.split('/').filter(Boolean); if (hostname === 'boards.greenhouse.io') return parts[0] || null; if (/^jobs\.(eu\.)?lever\.co$/.test(hostname)) return parts[0] || null; if (hostname === 'jobs.ashbyhq.com') return parts[0] || null; if (hostname === 'app.dover.io') return parts[0] || null; return null; } catch { return null; } } // ── Pipeline URL extraction ────────────────────────────────────────────────── /** * Parse data/pipeline.md and return pending entries. * Handles both plain and annotated forms: * - [ ] https://example.com/job/123 * - [ ] https://example.com/job/456 | Acme Corp | Senior PM */ async function extractPipelineEntries() { if (!existsSync(PIPELINE_PATH)) { console.error('data/pipeline.md not found. Add URLs there first.'); process.exit(1); } const content = await readFile(PIPELINE_PATH, 'utf-8'); const entries = []; for (const line of content.split('\n')) { if (!line.startsWith('- [ ]')) continue; const urlMatch = line.match(/https?:\/\/[^\s|)]+/); if (!urlMatch) continue; const url = urlMatch[0]; const parts = line.split('|').map(s => s.trim()); const company = parts[1] || null; const role = parts[2] || null; entries.push({ url, company, role }); } return entries; } // ── Core archive function ──────────────────────────────────────────────────── async function archiveUrl(browser, url, { company: companyHint, role: roleHint } = {}) { console.log(`\n🔗 ${url}`); const page = await browser.newPage(); try { const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); const httpStatus = response?.status() ?? 0; // Give SPAs (Ashby, Lever, Workday) time to hydrate await page.waitForTimeout(2000); const pageTitle = await page.title(); const h1Text = await page.$eval('h1', el => el.innerText.trim()).catch(() => ''); const urlCompany = extractCompanyFromUrl(url); // Parse page title first — it usually has "Role | Company" or "Company | Role". // Fall back to h1 for the role when the page title doesn't yield one cleanly. const detected = parsePageTitle(pageTitle); const resolvedCompany = overrideCompany || companyHint || detected.company || urlCompany || 'unknown'; const resolvedRole = overrideRole || roleHint || detected.role || h1Text || 'job'; // Strip noisy prefixes common on Greenhouse/Lever ("Job Application for …") const company = resolvedCompany.replace(/^job\s+application\s+for\s+/i, '').trim(); const role = resolvedRole.replace(/^job\s+application\s+for\s+/i, '').trim(); console.log(` Company: ${company}`); console.log(` Role: ${role}`); if (httpStatus && httpStatus >= 400) { console.log(`HTTP ${httpStatus} — page may be closed, archiving anyway`); } const filename = `${today()}_${slugify(company)}_${slugify(role)}.pdf`; const outputPath = join(JDS_DIR, filename); const reference = `local:jds/${filename}`; console.log(` Output: jds/${filename}`); mkdirSync(JDS_DIR, { recursive: true }); const pdfBuffer = await page.pdf({ format: 'a4', printBackground: true, margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' }, preferCSSPageSize: false, }); await writeFile(outputPath, pdfBuffer); const sizeKb = (pdfBuffer.length / 1024).toFixed(1); console.log(`Saved (${sizeKb} KB)`); console.log(`Reference: ${reference}`); return { filename, reference, url, size: pdfBuffer.length }; } finally { await page.close(); } } // ── Main ───────────────────────────────────────────────────────────────────── async function main() { // Build the list of targets upfront let targets; if (pipelineMode) { const entries = await extractPipelineEntries(); if (entries.length === 0) { console.log('No pending (- [ ]) URLs found in data/pipeline.md.'); return; } targets = entries; } else { targets = [{ url: targetUrl, company: null, role: null }]; } if (dryRun) console.log('🔍 Dry-run mode — no files will be saved.\n'); console.log(`Archiving ${targets.length} posting(s) to jds/`); const results = []; let failed = 0; if (dryRun) { // Dry-run: no browser needed — use URL-based detection only for (const { url, company, role } of targets) { const urlCompany = extractCompanyFromUrl(url); const resolvedCompany = overrideCompany || company || urlCompany || 'unknown'; const resolvedRole = overrideRole || role || 'job'; const filename = `${today()}_${slugify(resolvedCompany)}_${slugify(resolvedRole)}.pdf`; const reference = `local:jds/${filename}`; console.log(`\n🔗 ${url}`); console.log(` Company: ${resolvedCompany}`); console.log(` Role: ${resolvedRole}`); console.log(` Output: jds/${filename}`); console.log(' (dry-run — not saved)'); results.push({ url, filename, reference, skipped: true }); } } else { // Sequential — project convention: never Playwright in parallel const browser = await chromium.launch({ headless: true }); try { for (const { url, company, role } of targets) { try { const result = await archiveUrl(browser, url, { company, role }); results.push(result); } catch (err) { console.error(` ❌ Failed: ${err.message.split('\n')[0]}`); results.push({ url, error: err.message }); failed++; } } } finally { await browser.close(); } } // Summary const saved = results.filter(r => !r.error && !r.skipped).length; const skipped = results.filter(r => r.skipped).length; console.log('\n' + '─'.repeat(62)); if (dryRun) { console.log(` Dry-run: ${skipped} file(s) would be saved to jds/`); } else { console.log(` Archived: ${saved} saved ${failed} failed`); } const references = results.filter(r => r.reference); if (references.length > 0) { console.log('\n References (paste into pipeline.md or a report header):'); for (const r of references) { console.log(` ${r.reference}`); } } console.log('─'.repeat(62) + '\n'); if (failed > 0) process.exit(1); } main().catch(err => { console.error('❌ Fatal:', err.message); process.exit(1); });