Add ats-tool
This commit is contained in:
parent
89cfe7bdec
commit
523f4f6214
20 changed files with 3704 additions and 12 deletions
281
src/lib/ats/index.ts
Normal file
281
src/lib/ats/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue