mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-02 21:22:51 +00:00
month" and "2000-5000 requests per second" still failed /apply Step 5d: each has a four-digit number next to the dash that happens to fall in 1900-2099, and the guide tells users to keep "--" in exactly those prose ranges. When both sides of the dash are bare numbers - the whole whitespace-delimited token, outer punctuation stripped, matching \d+(?:[.,]\d+)*[kKmM%]? with no year group among its parts - count the match only if both are years. The salary band and the throughput range pass; 2016-2024, 1990-2010 and "2016 - Present" still fail as before, and a numerically formatted range (03/2016-07/2024, 01.2016 - 12.2024, 2016-03-2024-07, 2016/17-2018/19) is not a bare number on either side and is still caught. On the stock CV with all three prose ranges on one line, master reports three hits and this branch reports the one real date. Three new cases; the quantity one fails on master.
341 lines
13 KiB
Python
341 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Verify that a generated PDF has the expected pages and extractable text.
|
|
|
|
Text-layer extraction tries pypdf (BSD, optional `pip install pypdf`) first,
|
|
then Poppler `pdftotext` if pypdf is missing, raises, or returns zero
|
|
extractable characters. Poppler remains the fallback.
|
|
|
|
`--contains` compares after `normalize_text()` has folded both sides: whitespace,
|
|
Unicode normalization form (NFC), and the typographic substitutions LaTeX makes to
|
|
the source text. The fold is comparison-time only - the `--dump-text` output stays
|
|
the raw text layer an ATS parser actually sees.
|
|
|
|
`--ascii-dates` is the one check that deliberately does NOT fold: it scans the raw
|
|
layer for a year joined to a Unicode dash - the en-dash LaTeX makes from `--`, which
|
|
a Workday import dropped along with the date (`05-cv-templates.md`, "Date fields
|
|
must be ASCII ranges") - and fails naming each hit with its code point.
|
|
"""
|
|
|
|
import argparse
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import unicodedata
|
|
from pathlib import Path
|
|
|
|
|
|
class VerificationError(Exception):
|
|
"""Raised when a generated PDF does not satisfy its checks."""
|
|
|
|
|
|
def run_tool(command):
|
|
try:
|
|
return subprocess.run(
|
|
command,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
).stdout
|
|
except FileNotFoundError as exc:
|
|
raise VerificationError(
|
|
f"required command '{command[0]}' was not found. "
|
|
"Install pypdf (`pip install pypdf`) or poppler-utils "
|
|
"(macOS: brew install poppler, Debian/Ubuntu: apt install poppler-utils, "
|
|
"Windows: choco install poppler)"
|
|
) from exc
|
|
except subprocess.CalledProcessError as exc:
|
|
detail = (exc.stderr or "").strip() or (exc.stdout or "").strip()
|
|
detail = detail or "command failed"
|
|
raise VerificationError(f"{command[0]} could not read the PDF: {detail}") from exc
|
|
|
|
|
|
def parse_page_count(pdfinfo_output):
|
|
match = re.search(r"^Pages:\s+(\d+)\s*$", pdfinfo_output, re.MULTILINE)
|
|
if not match:
|
|
raise VerificationError("pdfinfo output did not contain a page count")
|
|
return int(match.group(1))
|
|
|
|
|
|
# Typographic substitutions the moderncv/cover.cls templates produce from plain
|
|
# source text, mapped back to what a user types into --contains. LaTeX ligatures
|
|
# ' into U+2019 and -- into U+2013, so "Master's degree" and "2016-2024" are
|
|
# absent from the text layer of a CV that plainly contains them (#385). Applied
|
|
# to both sides of the comparison; the extracted dump is never rewritten.
|
|
TYPOGRAPHIC_FOLDS = str.maketrans(
|
|
{
|
|
"\u2018": "'", # ` -> quoteleft
|
|
"\u2019": "'", # ' -> quoteright (the possessive apostrophe)
|
|
"\u201c": '"', # `` -> quotedblleft
|
|
"\u201d": '"', # '' -> quotedblright
|
|
"\u2013": "-", # -- -> endash (the \cventry date-range case)
|
|
"\u2014": "-", # --- -> emdash
|
|
"\u00a0": " ", # ~ -> no-break space
|
|
}
|
|
)
|
|
|
|
|
|
def normalize_text(text):
|
|
"""Fold a string for comparison: NFC, typographic punctuation, whitespace.
|
|
|
|
NFC covers the pdflatex text layer, which without T1 font encoding stores
|
|
accented letters decomposed (`e` + U+0300) while a user types them
|
|
precomposed (U+00E8); both forms fold to the same string (#384). The fold
|
|
applies to what is compared, never to what is dumped: the date-range rule in
|
|
`05-cv-templates.md` still needs the raw en-dash visible in `--dump-text`.
|
|
"""
|
|
text = unicodedata.normalize("NFC", text).translate(TYPOGRAPHIC_FOLDS)
|
|
return " ".join(text.split())
|
|
|
|
|
|
# Dash-like code points a year must never be joined to in a date range. U+2013 is
|
|
# what LaTeX makes from `--` and what the documented Workday import dropped; the
|
|
# rest are the other Unicode dashes and the minus sign, which a parser that splits
|
|
# a range only on U+002D treats the same way.
|
|
NON_ASCII_DASHES = "\u2010\u2011\u2012\u2013\u2014\u2015\u2212"
|
|
# A year is four digits standing alone: without the digit boundaries, `2000`
|
|
# inside `120000` or `12000` made a budget or throughput range read as a date.
|
|
_YEAR = r"(?<!\d)(?:19|20)\d{2}(?!\d)"
|
|
# Horizontal whitespace only between the year and the dash: `\s*` also matched
|
|
# a newline, so a year ending one line joined a dash opening the next (a bullet,
|
|
# a wrapped prose line) and an ASCII date was reported as U+2013.
|
|
NON_ASCII_DATE_RANGE = re.compile(
|
|
rf"{_YEAR}[^\S\n]*[{NON_ASCII_DASHES}]|[{NON_ASCII_DASHES}][^\S\n]*{_YEAR}"
|
|
)
|
|
# The whitespace-delimited tokens touching the dash on either side. A range whose
|
|
# two ends are both BARE numbers is a date only when both are years: `EUR 1500-2000`
|
|
# and `2000-5000 requests/s` are quantities that happen to contain a year-shaped
|
|
# number, and the guide tells users to keep `--` in exactly those. A composite
|
|
# token (`03/2016`, `01.2016`, `2016-03`, `2016/17`) is not a bare number, so a
|
|
# numerically formatted date range still falls through to the year rule.
|
|
_TOKEN_BEFORE = re.compile(r"(\S+)[^\S\n]*$")
|
|
_TOKEN_AFTER = re.compile(r"^[^\S\n]*(\S+)")
|
|
_BARE_NUMBER = re.compile(r"\d+(?:[.,]\d+)*[kKmM%]?")
|
|
_BARE_YEAR = re.compile(r"(?:19|20)\d{2}")
|
|
_OUTER_PUNCTUATION = ".,;:!?()[]{}\"'"
|
|
|
|
|
|
def _bare_number(token):
|
|
"""The token with outer punctuation stripped, if it is a bare number, else None.
|
|
|
|
A dotted or comma-separated token whose groups include a year (`01.2016`,
|
|
`31.07.2024`) is a date written numerically, not a quantity, and is left to
|
|
the year rule; `1,234.56` and `2.0M` have no year group and stay numbers.
|
|
"""
|
|
core = token.strip(_OUTER_PUNCTUATION)
|
|
if not _BARE_NUMBER.fullmatch(core):
|
|
return None
|
|
groups = re.split(r"[.,]", core.rstrip("kKmM%"))
|
|
if len(groups) > 1 and any(_BARE_YEAR.fullmatch(g) for g in groups):
|
|
return None
|
|
return core
|
|
|
|
|
|
def _quantity_range(line, dash_index):
|
|
"""True when both sides of the dash are bare numbers and not both years."""
|
|
before = _TOKEN_BEFORE.search(line[:dash_index])
|
|
after = _TOKEN_AFTER.search(line[dash_index + 1 :])
|
|
if not before or not after:
|
|
return False
|
|
left, right = _bare_number(before.group(1)), _bare_number(after.group(1))
|
|
if left is None or right is None:
|
|
return False
|
|
return not (_BARE_YEAR.fullmatch(left) and _BARE_YEAR.fullmatch(right))
|
|
|
|
|
|
def find_non_ascii_date_ranges(text):
|
|
"""Return (line, dash) for every year joined to a non-ASCII dash in raw text.
|
|
|
|
Works on the raw text layer, never on `normalize_text()` output: the fold
|
|
maps U+2013 back to `-` so `--contains "2016-2024"` can match what the
|
|
template renders, which is exactly why `--contains` cannot see this defect.
|
|
A year on either side of the dash is enough ("Mar 2016 - Jul 2016",
|
|
"2016 - Present"), so the check is locale-agnostic. A range whose two ends
|
|
are both bare numbers is a date only when both are years: "EUR 600k-1M",
|
|
"EUR 1500-2000" and "2000-5000 requests/s" are quantities and are left
|
|
alone, while a numerically formatted date ("03/2016-07/2024", "2016-03-2024-07")
|
|
is not a bare number on either side and is still caught.
|
|
"""
|
|
hits = []
|
|
for match in NON_ASCII_DATE_RANGE.finditer(text):
|
|
dash = next(c for c in match.group(0) if c in NON_ASCII_DASHES)
|
|
line_start = text.rfind("\n", 0, match.start()) + 1
|
|
line_end = text.find("\n", match.end())
|
|
line_end = line_end if line_end != -1 else len(text)
|
|
line = text[line_start:line_end]
|
|
if _quantity_range(line, match.start() + match.group(0).index(dash) - line_start):
|
|
continue
|
|
hits.append((" ".join(line.split()), dash))
|
|
return hits
|
|
|
|
|
|
def _extract_pypdf(pdf_path):
|
|
"""Return (text, pages) or None if pypdf is unavailable, raises, or yields no text."""
|
|
try:
|
|
from pypdf import PdfReader
|
|
except ImportError:
|
|
return None
|
|
try:
|
|
reader = PdfReader(str(pdf_path))
|
|
pages = len(reader.pages)
|
|
text = "\n".join((page.extract_text() or "") for page in reader.pages)
|
|
except Exception:
|
|
return None
|
|
# Harden: treat empty/degraded extraction as failure so we fall back
|
|
if len(normalize_text(text)) == 0:
|
|
return None
|
|
return text, pages
|
|
|
|
|
|
def _extract_pdftotext(pdf_path):
|
|
text = run_tool(["pdftotext", "-layout", "-enc", "UTF-8", str(pdf_path), "-"])
|
|
# Always call pdfinfo here so the fallback path returns a page count
|
|
# even when the caller did not request --pages (same Poppler package).
|
|
pages = parse_page_count(run_tool(["pdfinfo", str(pdf_path)]))
|
|
return text, pages
|
|
|
|
|
|
def extract_text_layer(pdf_path):
|
|
"""Extract ATS-readable text. Returns (text, pages, extractor_name)."""
|
|
pypdf_result = _extract_pypdf(pdf_path)
|
|
if pypdf_result is not None:
|
|
text, pages = pypdf_result
|
|
return text, pages, "pypdf"
|
|
text, pages = _extract_pdftotext(pdf_path)
|
|
return text, pages, "pdftotext"
|
|
|
|
|
|
def verify_pdf(
|
|
pdf_path,
|
|
expected_pages=None,
|
|
min_chars=1,
|
|
required_text=(),
|
|
dump_text=None,
|
|
ascii_dates=False,
|
|
):
|
|
pdf_path = Path(pdf_path)
|
|
if not pdf_path.is_file():
|
|
raise VerificationError(f"PDF does not exist: {pdf_path}")
|
|
|
|
extracted_text, actual_pages, extractor = extract_text_layer(pdf_path)
|
|
|
|
# Write dump *before* the checks so a failed verification still leaves a .txt
|
|
if dump_text is not None:
|
|
dump_path = Path(dump_text)
|
|
try:
|
|
dump_path.parent.mkdir(parents=True, exist_ok=True)
|
|
dump_path.write_text(
|
|
extracted_text if extracted_text.endswith("\n") else extracted_text + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
except OSError as exc:
|
|
raise VerificationError(
|
|
f"could not write --dump-text to {dump_path}: {exc}"
|
|
) from exc
|
|
|
|
if expected_pages is not None and actual_pages != expected_pages:
|
|
raise VerificationError(
|
|
f"expected {expected_pages} page(s), found {actual_pages} (extractor: {extractor})"
|
|
)
|
|
|
|
normalized = normalize_text(extracted_text)
|
|
if len(normalized) < min_chars:
|
|
raise VerificationError(
|
|
f"text layer has {len(normalized)} character(s); expected at least {min_chars} "
|
|
f"(extractor: {extractor})"
|
|
)
|
|
|
|
for required in required_text:
|
|
if normalize_text(required) not in normalized:
|
|
raise VerificationError(
|
|
f"text layer is missing required text: {required!r} (extractor: {extractor})"
|
|
)
|
|
|
|
if ascii_dates:
|
|
hits = find_non_ascii_date_ranges(extracted_text)
|
|
if hits:
|
|
listed = "; ".join(f"U+{ord(dash):04X} in {line[:80]!r}" for line, dash in hits)
|
|
raise VerificationError(
|
|
f"{len(hits)} date range(s) joined by a non-ASCII dash - an ATS that splits "
|
|
f"ranges on U+002D drops the date; write the date argument with a single "
|
|
f"ASCII hyphen (05-cv-templates.md, 'Date fields must be ASCII ranges'): "
|
|
f"{listed} (extractor: {extractor})"
|
|
)
|
|
return extractor, extracted_text, actual_pages
|
|
|
|
|
|
def build_parser():
|
|
parser = argparse.ArgumentParser(
|
|
description="Verify a PDF's page count and ATS-readable text layer."
|
|
)
|
|
parser.add_argument("pdf", type=Path, help="PDF file to verify")
|
|
parser.add_argument("--pages", type=int, help="required exact page count")
|
|
parser.add_argument(
|
|
"--min-chars",
|
|
type=int,
|
|
default=1,
|
|
help="minimum non-whitespace text-layer characters (default: 1)",
|
|
)
|
|
parser.add_argument(
|
|
"--contains",
|
|
action="append",
|
|
default=[],
|
|
help=(
|
|
"text that must appear in the text layer; both sides are folded for "
|
|
"whitespace, NFC, and LaTeX's typographic substitutions (curly "
|
|
"apostrophes/quotes, en/em dashes, no-break spaces); repeatable"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--dump-text",
|
|
type=Path,
|
|
help="write the extracted text layer to this path (UTF-8)",
|
|
)
|
|
parser.add_argument(
|
|
"--ascii-dates",
|
|
action="store_true",
|
|
help=(
|
|
"fail if the raw text layer has a year joined to a Unicode dash (the "
|
|
"en-dash LaTeX makes from --), which ATS date parsers drop; this check "
|
|
"never folds, unlike --contains"
|
|
),
|
|
)
|
|
return parser
|
|
|
|
|
|
def _force_utf8_output() -> None:
|
|
"""Write UTF-8 whatever the host's default encoding is.
|
|
|
|
A piped stdout on Windows defaults to the ANSI code page (cp1252 on most
|
|
Western installs), so printing a company, title or file name outside it
|
|
raised UnicodeEncodeError before the workflow saw any output.
|
|
"""
|
|
for stream in (sys.stdout, sys.stderr):
|
|
reconfigure = getattr(stream, "reconfigure", None) # absent on a StringIO under test
|
|
if reconfigure:
|
|
reconfigure(encoding="utf-8")
|
|
|
|
|
|
def main(argv=None):
|
|
_force_utf8_output()
|
|
args = build_parser().parse_args(argv)
|
|
try:
|
|
extractor, text, pages = verify_pdf(
|
|
args.pdf,
|
|
args.pages,
|
|
args.min_chars,
|
|
args.contains,
|
|
dump_text=args.dump_text,
|
|
ascii_dates=args.ascii_dates,
|
|
)
|
|
except VerificationError as exc:
|
|
print(f"Error: {args.pdf}: {exc}", file=sys.stderr)
|
|
return 1
|
|
print(f"Verified {args.pdf} (extractor: {extractor}, pages: {pages})")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|