281 lines
7.5 KiB
TypeScript
281 lines
7.5 KiB
TypeScript
/**
|
|
* 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,
|
|
};
|
|
}
|