Files
santifer--career-ops/archive-posting.mjs
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

349 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <url>
* node archive-posting.mjs <url> --company=Anthropic --role=senior-ai-engineer
* node archive-posting.mjs --pipeline Archive pending URLs in data/pipeline.md
* node archive-posting.mjs --dry-run <url> 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 <url>
node archive-posting.mjs <url> --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 <url>
OPTIONS
--company <name> Override auto-detected company name
--role <title> 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);
});