Add ats-tool
This commit is contained in:
parent
89cfe7bdec
commit
523f4f6214
20 changed files with 3704 additions and 12 deletions
158
src/lib/ats/dates.ts
Normal file
158
src/lib/ats/dates.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
/**
|
||||
* Date range parsing.
|
||||
*
|
||||
* Resume dates are where a surprising amount of ATS damage happens, because
|
||||
* the formats that look tidiest to a human ("2020–22", "Jan '20") are the ones
|
||||
* no parser handles. Where a format fails we say so explicitly rather than
|
||||
* silently dropping the range.
|
||||
*/
|
||||
|
||||
import type { DateRange } from "./types.ts";
|
||||
|
||||
const MONTHS: Record<string, number> = {
|
||||
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
|
||||
jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
|
||||
};
|
||||
|
||||
const SEASONS: Record<string, number> = {
|
||||
spring: 3, summer: 6, fall: 9, autumn: 9, winter: 12,
|
||||
};
|
||||
|
||||
const MONTH = String.raw`(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)`;
|
||||
const SEASON = String.raw`(?:spring|summer|fall|autumn|winter)`;
|
||||
/** Hyphen, en dash, em dash, tilde or the word "to". */
|
||||
const SEP = String.raw`\s*(?:[-–—~]|\bto\b)+\s*`;
|
||||
const CURRENT = String.raw`(?:present|current|now|ongoing|today)`;
|
||||
|
||||
export const CURRENT_INDICATORS = /\b(present|current|now|ongoing|today)\b/i;
|
||||
|
||||
const PATTERNS: { re: RegExp; kind: string }[] = [
|
||||
{ re: new RegExp(`${MONTH}\\s*\\.?\\s*\\d{4}${SEP}(?:${MONTH}\\s*\\.?\\s*\\d{4}|${CURRENT})`, "i"), kind: "monthYear" },
|
||||
{ re: new RegExp(`\\d{1,2}\\/\\d{4}${SEP}(?:\\d{1,2}\\/\\d{4}|${CURRENT})`, "i"), kind: "numericMonthYear" },
|
||||
{ re: new RegExp(`${SEASON}\\s*\\d{4}${SEP}(?:${SEASON}\\s*\\d{4}|${CURRENT})`, "i"), kind: "season" },
|
||||
{ re: new RegExp(`\\b(?:19|20)\\d{2}${SEP}(?:(?:19|20)\\d{2}|${CURRENT})\\b`, "i"), kind: "yearOnly" },
|
||||
];
|
||||
|
||||
/** Formats that look reasonable but that no parser examined handles. */
|
||||
const KNOWN_BAD: { re: RegExp; problem: string }[] = [
|
||||
{
|
||||
re: /\b(?:19|20)\d{2}\s*[-–—]\s*\d{2}\b(?!\d)/,
|
||||
problem:
|
||||
"A two-digit end year (“2020–22”) matches no date pattern, so the range is dropped entirely.",
|
||||
},
|
||||
{
|
||||
re: /['’]\d{2}\b/,
|
||||
problem:
|
||||
"Apostrophe years (“Jan ’20”) are not supported by any parser examined; write the year in full.",
|
||||
},
|
||||
];
|
||||
|
||||
function pad(month: number): string {
|
||||
return String(month).padStart(2, "0");
|
||||
}
|
||||
|
||||
/** Normalise one endpoint to YYYY-MM or YYYY. */
|
||||
function normaliseEndpoint(raw: string): string | null {
|
||||
const text = raw.trim().toLowerCase();
|
||||
if (CURRENT_INDICATORS.test(text)) return null;
|
||||
|
||||
const monthYear = text.match(new RegExp(`(${MONTH})\\s*\\.?\\s*((?:19|20)\\d{2})`, "i"));
|
||||
if (monthYear) {
|
||||
const key = monthYear[1].slice(0, 3).toLowerCase();
|
||||
const month = MONTHS[key];
|
||||
return month ? `${monthYear[2]}-${pad(month)}` : monthYear[2];
|
||||
}
|
||||
|
||||
const seasonYear = text.match(new RegExp(`(${SEASON})\\s*((?:19|20)\\d{2})`, "i"));
|
||||
if (seasonYear) {
|
||||
const month = SEASONS[seasonYear[1].toLowerCase()];
|
||||
return `${seasonYear[2]}-${pad(month)}`;
|
||||
}
|
||||
|
||||
const numeric = text.match(/(\d{1,2})\/((?:19|20)\d{2})/);
|
||||
if (numeric) {
|
||||
const month = Number(numeric[1]);
|
||||
return month >= 1 && month <= 12
|
||||
? `${numeric[2]}-${pad(month)}`
|
||||
: numeric[2];
|
||||
}
|
||||
|
||||
const yearOnly = text.match(/\b((?:19|20)\d{2})\b/);
|
||||
if (yearOnly) return yearOnly[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Find and parse the first date range in a line of text. */
|
||||
export function parseDateRange(text: string): DateRange | null {
|
||||
for (const bad of KNOWN_BAD) {
|
||||
if (bad.re.test(text)) {
|
||||
const raw = text.match(bad.re)?.[0] ?? text.trim();
|
||||
return {
|
||||
raw,
|
||||
start: null,
|
||||
end: null,
|
||||
isCurrent: false,
|
||||
parsed: false,
|
||||
problem: bad.problem,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const { re } of PATTERNS) {
|
||||
const match = text.match(re);
|
||||
if (!match) continue;
|
||||
|
||||
const raw = match[0];
|
||||
const halves = raw.split(new RegExp(SEP, "i"));
|
||||
const startRaw = halves[0] ?? "";
|
||||
const endRaw = halves.slice(1).join(" ");
|
||||
const isCurrent = CURRENT_INDICATORS.test(endRaw);
|
||||
|
||||
const range: DateRange = {
|
||||
raw: raw.trim(),
|
||||
start: normaliseEndpoint(startRaw),
|
||||
end: isCurrent ? null : normaliseEndpoint(endRaw),
|
||||
isCurrent,
|
||||
parsed: true,
|
||||
};
|
||||
|
||||
if (!range.start) {
|
||||
range.parsed = false;
|
||||
range.problem = "The start of this range could not be interpreted.";
|
||||
} else if (/^\d{4}$/.test(range.start)) {
|
||||
range.problem =
|
||||
"Only a year was given, so month precision is lost and tenure is rounded.";
|
||||
}
|
||||
return range;
|
||||
}
|
||||
|
||||
// A lone date with no range, e.g. a graduation year.
|
||||
const single = normaliseEndpoint(text);
|
||||
if (single) {
|
||||
return {
|
||||
raw: text.match(/\b(?:19|20)\d{2}\b/)?.[0] ?? text.trim(),
|
||||
start: single,
|
||||
end: single,
|
||||
isCurrent: false,
|
||||
parsed: true,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasAnyDate(text: string): boolean {
|
||||
return /\b(?:19|20)\d{2}\b/.test(text) || CURRENT_INDICATORS.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a date range broken across two lines, which Textkernel flags as 418
|
||||
* and RChilli as 4109. Both treat it as fatal.
|
||||
*/
|
||||
export function isVerticalDateRange(line: string, next: string | undefined): boolean {
|
||||
if (!next) return false;
|
||||
const endsOpen = /\b(?:19|20)\d{2}\s*[-–—~]\s*$/.test(line.trim());
|
||||
const nextIsDate = /^\s*(?:(?:19|20)\d{2}|present|current)\b/i.test(next.trim());
|
||||
return endsOpen && nextIsDate;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue