prepare-application.mjs
Rama main · réplica de lectura del repositorio original.
#!/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.
* Identity fields come from the candidate block (profile.example.yml).
* A root scalar of the same name is only a fallback for older flat files.
* The root location mapping (country, city, timezone) is not a form value.
* 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';
import { loadYaml } from './lib/yaml-document.mjs';
import { getCareerOpsRoot, getProfilePath } from './path-resolver.mjs';
import { flagValue } from './lib/cli-flags.mjs';
const ROOT = dirname(fileURLToPath(import.meta.url));
const DATA_ROOT = getCareerOpsRoot();
const ALLOWED_HOSTS = new Set([
'boards.greenhouse.io',
'job-boards.greenhouse.io',
'job-boards.eu.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) => flagValue(args, flag);
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(DATA_ROOT, 'output');
const absPdf = resolve(DATA_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', 'job-boards.greenhouse.io', 'job-boards.eu.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 = getProfilePath();
if (!existsSync(profilePath)) return {};
let profile;
try {
profile = loadYaml(readFileSync(profilePath, 'utf-8')) ?? {};
if (typeof profile !== 'object' || Array.isArray(profile)) throw new Error('expected a mapping');
} catch (err) {
console.error(`Error: invalid profile YAML at ${profilePath}: ${err.message}`);
process.exit(1);
}
const candidate = profile.candidate;
const hasCandidate = candidate != null && typeof candidate === 'object' && !Array.isArray(candidate);
const pick = key => {
const sources = hasCandidate ? [candidate, profile] : [profile];
for (const source of sources) {
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
const value = source[key];
if (value == null) return '';
if (typeof value !== 'object') return String(value).trim();
// Root location is a mapping in the example profile. Skip it when the
// candidate block is the place identity lives. A collection with no
// candidate block, or a collection inside candidate, is still a mistake.
if (source === profile && hasCandidate) continue;
console.error(`Error: profile field "${key}" in ${profilePath} must be a scalar value`);
process.exit(1);
}
return '';
};
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(DATA_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();