Files
resume-agent/backend/app/document_extractors.py
T

131 lines
4.8 KiB
Python

"""Safe text extraction for the supported import formats."""
from __future__ import annotations
from io import BytesIO
from multiprocessing import get_context
from queue import Empty
from pathlib import Path
from zipfile import ZipFile
from docx import Document
from pypdf import PdfReader
class ImportExtractionError(ValueError):
pass
# A docx is a zip: a tiny compressed upload can expand into huge XML and burn
# minutes of parser CPU (measured: 90 KB -> ~30 MB -> 86 s, ~3 s per MB). A real
# resume decompresses to well under 1 MB, so 10 MB is generous and still bounds
# worst-case parse time to seconds.
_MAX_DECOMPRESSED_BYTES = 10 * 1024 * 1024
_MAX_PDF_PAGES = 20
_MAX_PDF_TEXT_CHARACTERS = 100_000
_PDF_EXTRACTION_TIMEOUT_SECONDS = 10.0
def _extract_pdf_text_worker(content: bytes, result_queue: object) -> None:
"""Run pypdf in an isolated process so the parent can enforce a CPU deadline."""
try:
reader = PdfReader(BytesIO(content))
if len(reader.pages) > _MAX_PDF_PAGES:
raise ImportExtractionError("import_file_too_complex")
parts: list[str] = []
characters = 0
for page in reader.pages:
page_text = page.extract_text() or ""
characters += len(page_text)
if characters > _MAX_PDF_TEXT_CHARACTERS:
raise ImportExtractionError("import_file_too_complex")
if page_text:
parts.append(page_text)
result_queue.put(("ok", "\n".join(parts).strip()))
except ImportExtractionError as exc:
result_queue.put(("error", str(exc)))
except Exception:
result_queue.put(("error", "ocr_required"))
def _extract_pdf_text(content: bytes) -> str:
context = get_context("spawn")
result_queue = context.Queue(maxsize=1)
process = context.Process(target=_extract_pdf_text_worker, args=(content, result_queue))
process.start()
process.join(_PDF_EXTRACTION_TIMEOUT_SECONDS)
if process.is_alive():
process.terminate()
process.join()
raise ImportExtractionError("import_file_too_complex")
try:
status, value = result_queue.get(timeout=1.0)
except Empty as exc:
raise ImportExtractionError("ocr_required") from exc
finally:
result_queue.close()
result_queue.join_thread()
if status != "ok":
raise ImportExtractionError(value)
if not value:
raise ImportExtractionError("ocr_required")
return value
def _reject_decompression_bomb(content: bytes) -> None:
try:
with ZipFile(BytesIO(content)) as archive:
total = sum(info.file_size for info in archive.infolist())
except Exception as exc:
raise ImportExtractionError("invalid_import_file") from exc
if total > _MAX_DECOMPRESSED_BYTES:
raise ImportExtractionError("import_file_too_large")
def normalize_upload_name(file_name: str) -> tuple[str, str]:
safe_name = Path(file_name or "upload").name
if not safe_name or safe_name in {".", ".."}:
raise ImportExtractionError("invalid_file_name")
extension = Path(safe_name).suffix.lower()
if extension == ".doc":
raise ImportExtractionError("legacy_doc_unsupported")
if extension not in {".pdf", ".docx"}:
raise ImportExtractionError("unsupported_import_format")
return safe_name, extension
def validate_upload(*, extension: str, declared_mime: str | None, content: bytes) -> str:
if not content:
raise ImportExtractionError("empty_import_file")
if extension == ".pdf":
if not content.startswith(b"%PDF-"):
raise ImportExtractionError("invalid_import_file")
return "application/pdf"
if not content.startswith(b"PK"):
raise ImportExtractionError("invalid_import_file")
if declared_mime and declared_mime not in {
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/octet-stream",
}:
raise ImportExtractionError("invalid_import_mime")
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
def extract_text(*, extension: str, content: bytes) -> str:
if extension == ".pdf":
return _extract_pdf_text(content)
_reject_decompression_bomb(content)
try:
document = Document(BytesIO(content))
except Exception as exc:
raise ImportExtractionError("invalid_import_file") from exc
parts = [paragraph.text.strip() for paragraph in document.paragraphs if paragraph.text.strip()]
for table in document.tables:
for row in table.rows:
values = [cell.text.strip() for cell in row.cells if cell.text.strip()]
if values:
parts.append(" | ".join(values))
text = "\n".join(parts).strip()
if not text:
raise ImportExtractionError("empty_import_text")
return text