Files
santifer--career-ops/prepare-application.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

242 lines
8.9 KiB
JavaScript

#!/usr/bin/env node
/**
* prepare-application.mjs — ATS auto-fill for Greenhouse, Ashby, and Lever.
*
* Detects the ATS from the apply URL, reads candidate data from
* config/profile.yml, and prints a prefill summary to stdout.
* Never POSTs anything — the user reviews the output, opens the apply URL,
* and submits themselves.
*
* Usage:
* node prepare-application.mjs --url <apply_url> --pdf output/<cv>.pdf
* node prepare-application.mjs --url <apply_url> --pdf output/<cv>.pdf --cover cover.txt
*
* Supported ATS:
* Greenhouse boards.greenhouse.io / greenhouse.io
* Ashby jobs.ashbyhq.com / ashbyhq.com
* Lever jobs.(eu.)?lever.co / lever.co
*/
import { readFileSync, existsSync, statSync } from 'fs';
import { basename, resolve, dirname, relative, isAbsolute } from 'path';
import { fileURLToPath } from 'url';
const ROOT = dirname(fileURLToPath(import.meta.url));
const ALLOWED_HOSTS = new Set([
'boards.greenhouse.io',
'greenhouse.io',
'jobs.ashbyhq.com',
'ashbyhq.com',
'jobs.lever.co',
'jobs.eu.lever.co',
'lever.co',
]);
// ── CLI args ─────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const get = (flag) => { const i = args.indexOf(flag); return i !== -1 ? args[i + 1] : null; };
const applyUrl = get('--url');
const pdfPath = get('--pdf');
const coverPath = get('--cover');
if (!applyUrl || !pdfPath) {
console.error('Usage: node prepare-application.mjs --url <apply_url> --pdf <pdf_path> [--cover <cover_txt>]');
process.exit(1);
}
// ── PDF validation ────────────────────────────────────────────────────
const outputDir = resolve(ROOT, 'output');
const absPdf = resolve(ROOT, pdfPath);
const relPdf = relative(outputDir, absPdf);
if (relPdf === '' || relPdf.startsWith('..') || isAbsolute(relPdf)) {
console.error(`Error: --pdf must point to a file inside output/ (got ${pdfPath})`);
process.exit(1);
}
if (!existsSync(absPdf)) {
console.error(`Error: PDF not found at ${pdfPath}`);
process.exit(1);
}
if (!statSync(absPdf).isFile()) {
console.error(`Error: ${pdfPath} is not a file`);
process.exit(1);
}
// ── URL validation ────────────────────────────────────────────────────
let parsedUrl;
try {
parsedUrl = new URL(applyUrl);
} catch {
console.error(`Error: invalid URL: ${applyUrl}`);
process.exit(1);
}
if (parsedUrl.protocol !== 'https:') {
console.error(`Error: URL must use https (got ${parsedUrl.protocol})`);
process.exit(1);
}
if (!ALLOWED_HOSTS.has(parsedUrl.hostname)) {
console.error(`Error: "${parsedUrl.hostname}" is not a supported ATS host.`);
console.error(`Supported: ${[...ALLOWED_HOSTS].join(', ')}`);
process.exit(1);
}
// ── ATS detection ─────────────────────────────────────────────────────
const SAFE_SLUG = /^[a-zA-Z0-9._-]+$/;
/**
* @param {URL} url
* @returns {{ ats: string, companySlug: string, jobId: string } | null}
*/
function detectAts(url) {
const path = url.pathname;
const GH = new Set(['boards.greenhouse.io', 'greenhouse.io']);
const ASH = new Set(['jobs.ashbyhq.com', 'ashbyhq.com']);
const LEV = new Set(['jobs.lever.co', 'jobs.eu.lever.co', 'lever.co']);
const gh = path.match(/^\/([^/]+)\/jobs\/(\d+)/);
if (gh && GH.has(url.hostname)) {
const [, company, jobId] = gh;
if (!SAFE_SLUG.test(company)) return null;
return { ats: 'greenhouse', companySlug: company, jobId };
}
const seg = path.match(/^\/([^/]+)\/([^/?#]+)/);
if (seg && ASH.has(url.hostname)) {
const [, company, jobId] = seg;
if (!SAFE_SLUG.test(company) || !SAFE_SLUG.test(jobId)) return null;
return { ats: 'ashby', companySlug: company, jobId };
}
if (seg && LEV.has(url.hostname)) {
const [, company, jobId] = seg;
if (!SAFE_SLUG.test(company) || !SAFE_SLUG.test(jobId)) return null;
return { ats: 'lever', companySlug: company, jobId };
}
return null;
}
// ── Profile reader ────────────────────────────────────────────────────
function readProfile() {
const profilePath = resolve(ROOT, 'config/profile.yml');
if (!existsSync(profilePath)) return {};
const raw = readFileSync(profilePath, 'utf-8');
const pick = (key) => {
const m = raw.match(new RegExp(`^\\s*${key}:\\s*["']?([^"'\\n]+?)["']?\\s*$`, 'm'));
return m ? m[1].trim() : '';
};
const fullName = pick('full_name');
const [firstName, ...rest] = fullName.split(' ');
return {
firstName: firstName || '',
lastName: rest.join(' ') || '',
email: pick('email'),
phone: pick('phone'),
location: pick('location'),
linkedin: pick('linkedin'),
portfolioUrl: pick('portfolio_url'),
};
}
// ── Cover letter reader (optional) ────────────────────────────────────
function readCover() {
if (!coverPath) return null;
const abs = resolve(ROOT, coverPath);
if (!existsSync(abs)) {
console.error(`Warning: cover letter not found at ${coverPath} — skipping`);
return null;
}
if (!statSync(abs).isFile()) {
console.error(`Warning: ${coverPath} is not a file — skipping`);
return null;
}
const text = readFileSync(abs, 'utf-8').trim();
return { text, wordCount: text.split(/\s+/).filter(Boolean).length };
}
// ── Field maps per ATS ────────────────────────────────────────────────
function buildGreenhouseFields(profile, cover, pdfFile) {
return [
['first_name', profile.firstName],
['last_name', profile.lastName],
['email', profile.email],
['phone', profile.phone],
['resume', `${pdfFile} ← attach this file`],
cover ? ['cover_letter', `${cover.wordCount} words — ${cover.text.slice(0, 80).replace(/\n/g, ' ')}…`] : null,
profile.linkedin ? ['linkedin_profile', profile.linkedin] : null,
profile.portfolioUrl ? ['website', profile.portfolioUrl] : null,
].filter(Boolean);
}
function buildAshbyFields(profile, cover, pdfFile) {
return [
['firstName', profile.firstName],
['lastName', profile.lastName],
['email', profile.email],
['phone', profile.phone],
['resume', `${pdfFile} ← attach this file`],
cover ? ['coverLetter', `(${cover.wordCount} words — paste from cover file)`] : null,
profile.linkedin ? ['linkedInUrl', profile.linkedin] : null,
].filter(Boolean);
}
function buildLeverFields(profile, cover, pdfFile) {
return [
['name', `${profile.firstName} ${profile.lastName}`.trim()],
['email', profile.email],
['phone', profile.phone],
['resume', `${pdfFile} ← attach this file`],
cover ? ['comments', `(${cover.wordCount} words — paste from cover file)`] : null,
profile.linkedin ? ['urls[LinkedIn]', profile.linkedin] : null,
profile.portfolioUrl ? ['urls[Portfolio]', profile.portfolioUrl] : null,
].filter(Boolean);
}
// ── Main ──────────────────────────────────────────────────────────────
const detected = detectAts(parsedUrl);
if (!detected) {
console.error(`Error: URL not recognized as Greenhouse, Ashby, or Lever.\n URL: ${applyUrl}`);
process.exit(1);
}
const { ats, companySlug, jobId } = detected;
const pdfFile = basename(absPdf);
const pdfSizeKb = (statSync(absPdf).size / 1024).toFixed(1);
const profile = readProfile();
const cover = readCover();
let fields;
if (ats === 'greenhouse') fields = buildGreenhouseFields(profile, cover, pdfFile);
if (ats === 'ashby') fields = buildAshbyFields(profile, cover, pdfFile);
if (ats === 'lever') fields = buildLeverFields(profile, cover, pdfFile);
const labelWidth = Math.max(...fields.map(([k]) => k.length)) + 2;
const atsLabel = ats.charAt(0).toUpperCase() + ats.slice(1);
console.log(`\n── ${atsLabel} · ${companySlug} · job ${jobId} ${'─'.repeat(20)}`);
console.log();
for (const [key, value] of fields) {
console.log(` ${key.padEnd(labelWidth)}${value || '(not set — check config/profile.yml)'}`);
}
console.log(`\n PDF ${pdfFile} (${pdfSizeKb} KB)`);
if (cover) console.log(` Cover ${coverPath} (${cover.wordCount} words)`);
console.log('\n── Next step ' + '─'.repeat(38));
console.log(` Open: ${applyUrl}`);
console.log(' Fill the form using the values above, attach the PDF, then submit.');
console.log();