Add ats-tool

This commit is contained in:
badblocks 2026-08-07 17:18:34 -07:00
parent 89cfe7bdec
commit 523f4f6214
Signed by: badblocks
SSH key fingerprint: SHA256:hEcM6BP4hKm9F7WsomNuXSBpPn2BSDnFzPSl3y1NerQ
20 changed files with 3704 additions and 12 deletions

281
src/lib/ats/index.ts Normal file
View file

@ -0,0 +1,281 @@
/**
* Entry point for the ATS resume inspector.
*
* Everything runs from an in-memory buffer and nothing is written to disk or
* cached. The container this runs in is read-only with only /tmp as tmpfs, so
* "your resume is never stored" is a property of the deployment rather than a
* promise we are asking anyone to take on trust.
*/
import { extractDocx, CorruptDocxError } from "./extractDocx.ts";
import { extractPdf, EncryptedPdfError, CorruptPdfError, MAX_PAGES } from "./extractPdf.ts";
import { buildStrategies } from "./readingOrder.ts";
import { detectSections } from "./sections.ts";
import { extractFields } from "./fields.ts";
import { runDiagnostics, sortFindings } from "./diagnostics.ts";
import type { AtsReport, DocFacts, SourceKind, TextItem } from "./types.ts";
/** Matches the file size limit Greenhouse documents for resume uploads. */
export const MAX_BYTES = 2.5 * 1024 * 1024;
/** Mirrors Textkernel code 411, "parsing had to be stopped". */
export const PARSE_TIMEOUT_MS = 10_000;
export { MAX_PAGES };
export class UnsupportedFormatError extends Error {}
export class TooLargeError extends Error {}
export class ParseTimeoutError extends Error {}
/** Plain text has no layout, so synthesise one line per line of input. */
function itemsFromText(text: string): TextItem[] {
return text
.split(/\r?\n/)
.map((line, index) => ({
page: 1,
x: 0,
y: 1000 - index * 14,
width: line.length * 5,
height: 11,
fontName: "text",
bold: false,
hasEOL: true,
text: line,
}))
.filter((item) => item.text.trim().length > 0);
}
function detectKind(fileName: string, data: Uint8Array): SourceKind {
const lower = fileName.toLowerCase();
// Sniff magic bytes rather than trusting the extension.
const isPdf =
data[0] === 0x25 && data[1] === 0x50 && data[2] === 0x44 && data[3] === 0x46;
const isZip = data[0] === 0x50 && data[1] === 0x4b;
if (isPdf) return "pdf";
if (isZip && lower.endsWith(".docx")) return "docx";
if (lower.endsWith(".txt") || lower.endsWith(".md")) return "text";
if (lower.endsWith(".doc")) {
throw new UnsupportedFormatError(
"Legacy .doc files are not supported. Open it in Word or LibreOffice and save as .docx or PDF, then try again.",
);
}
if (isZip) return "docx";
throw new UnsupportedFormatError(
"Unrecognised file type. Upload a PDF, a DOCX, or paste the text directly.",
);
}
async function withTimeout<T>(work: Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new ParseTimeoutError("Parsing took too long")),
PARSE_TIMEOUT_MS,
);
});
try {
return await Promise.race([work, timeout]);
} finally {
clearTimeout(timer!);
}
}
export interface AnalyseInput {
data: Uint8Array;
fileName: string;
/** Set when the user pasted text rather than uploading a file. */
pastedText?: string;
}
export async function analyseResume(input: AnalyseInput): Promise<AtsReport> {
const started = Date.now();
if (input.data.byteLength > MAX_BYTES) {
throw new TooLargeError(
`File is larger than ${(MAX_BYTES / 1024 / 1024).toFixed(1)} MB.`,
);
}
return withTimeout(runAnalysis(input, started));
}
async function runAnalysis(
input: AnalyseInput,
started: number,
): Promise<AtsReport> {
const { data, fileName } = input;
const kind: SourceKind = input.pastedText
? "text"
: detectKind(fileName, data);
let items: TextItem[];
let facts: DocFacts;
if (kind === "pdf") {
let extraction;
try {
extraction = await extractPdf(data);
} catch (err) {
if (err instanceof EncryptedPdfError) {
return emptyReport(kind, fileName, data.byteLength, started, true);
}
if (err instanceof CorruptPdfError) {
throw new UnsupportedFormatError(
"This PDF could not be opened. It may be damaged or incomplete.",
);
}
throw err;
}
items = extraction.items;
facts = {
kind,
fileName,
byteSize: data.byteLength,
...extraction.facts,
};
} else if (kind === "docx") {
let extraction;
try {
extraction = await extractDocx(data);
} catch (err) {
if (err instanceof CorruptDocxError) {
throw new UnsupportedFormatError(
"This DOCX could not be opened. It may be damaged, or saved in an older format.",
);
}
throw err;
}
items = extraction.items;
facts = {
kind,
fileName,
byteSize: data.byteLength,
pageCount: 1,
pagesParsed: 1,
truncated: false,
encrypted: false,
textItemCount: items.length,
fonts: [],
nonEmbeddedFonts: [],
imageCount: 0,
vectorOpCount: 0,
hadLigatures: false,
pageSizes: [],
docx: extraction.docx,
};
} else {
const text = input.pastedText ?? new TextDecoder().decode(data);
items = itemsFromText(text);
facts = {
kind,
fileName: input.pastedText ? "pasted text" : fileName,
byteSize: data.byteLength,
pageCount: 1,
pagesParsed: 1,
truncated: false,
encrypted: false,
textItemCount: items.length,
fonts: [],
nonEmbeddedFonts: [],
imageCount: 0,
vectorOpCount: 0,
hadLigatures: false,
pageSizes: [],
};
}
const strategies = buildStrategies(items);
const visual = strategies.find((s) => s.id === "visual")!;
const column = strategies.find((s) => s.id === "column")!;
// Column-aware reading only matters if it actually changes the text.
const columnsChangeReading =
kind === "pdf" && visual.text !== column.text;
// Analyse the best available reading, which is what a good parser would use.
const primaryStrategy = columnsChangeReading ? column : visual;
const { sections, unrecognised } = detectSections(primaryStrategy.lines);
const { resume, scores } = extractFields(primaryStrategy.lines, sections);
const findings = sortFindings(
runDiagnostics({
facts,
lines: primaryStrategy.lines,
text: primaryStrategy.text,
sections,
unrecognised,
resume,
columnsChangeReading,
}),
);
return {
facts,
strategies,
primary: primaryStrategy.id,
sections,
resume,
findings,
scores,
columnsChangeReading,
parseMs: Date.now() - started,
};
}
/** Used when the document cannot be opened at all but we still owe a report. */
function emptyReport(
kind: SourceKind,
fileName: string,
byteSize: number,
started: number,
encrypted: boolean,
): AtsReport {
const facts: DocFacts = {
kind,
fileName,
byteSize,
pageCount: 0,
pagesParsed: 0,
truncated: false,
encrypted,
textItemCount: 0,
fonts: [],
nonEmbeddedFonts: [],
imageCount: 0,
vectorOpCount: 0,
hadLigatures: false,
pageSizes: [],
};
return {
facts,
strategies: [],
primary: "visual",
sections: [],
resume: {
basics: { name: null, email: null, phone: null, location: null, urls: [] },
work: [],
education: [],
skills: [],
},
findings: sortFindings(
runDiagnostics({
facts,
lines: [],
text: "",
sections: [],
unrecognised: [],
resume: {
basics: { name: null, email: null, phone: null, location: null, urls: [] },
work: [],
education: [],
skills: [],
},
columnsChangeReading: false,
}),
),
scores: {},
columnsChangeReading: false,
parseMs: Date.now() - started,
};
}