SmartHire-AI / src /parser.py
Vishu2006's picture
Initial commit: SmartHire-AI FastAPI + Streamlit
91e794e
Raw
History Blame Contribute Delete
8.78 kB
"""
parser.py
---------
Production-grade resume and JD file parser.
Improvements:
- pdfplumber as primary PDF engine (handles tables, multi-column layouts)
- PyPDF2 as automatic fallback
- DOCX: extracts headers, tables, and text boxes (None-safe style check)
- Encoding detection for TXT files (UTF-8, UTF-16, Latin-1)
- File size validation (max 10 MB)
Author: SmartHire AI
"""
import io
import logging
from pathlib import Path
from typing import Optional, Union
logger = logging.getLogger(__name__)
MAX_FILE_SIZE_MB = 10
MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
# -- PDF Extraction ---------------------------------------------------
def extract_text_from_pdf(file: Union[bytes, io.BytesIO]) -> str:
"""
Extract text from PDF using pdfplumber (primary) with PyPDF2 fallback.
"""
if isinstance(file, bytes):
file = io.BytesIO(file)
# Try pdfplumber first
try:
import pdfplumber
file.seek(0)
with pdfplumber.open(file) as pdf:
pages_text = []
for page_num, page in enumerate(pdf.pages):
page_text = page.extract_text(x_tolerance=3, y_tolerance=3)
tables = page.extract_tables()
table_text = ""
for table in tables:
for row in table:
row_cells = [cell.strip() if cell else "" for cell in row]
table_text += " ".join(row_cells) + "\n"
combined = ""
if page_text:
combined += page_text
if table_text:
combined += "\n" + table_text
if combined.strip():
pages_text.append(combined)
else:
logger.warning(f"Page {page_num + 1}: no text extracted")
full_text = "\n\n".join(pages_text)
if full_text.strip():
logger.info(f"pdfplumber: extracted {len(full_text)} chars from {len(pdf.pages)} pages")
return full_text
except ImportError:
logger.warning("pdfplumber not installed -- trying PyPDF2")
except Exception as e:
logger.warning(f"pdfplumber failed ({e}) -- trying PyPDF2")
# Fallback: PyPDF2
try:
import PyPDF2
if isinstance(file, io.BytesIO):
file.seek(0)
else:
file = io.BytesIO(file)
reader = PyPDF2.PdfReader(file)
text_parts = []
for page_num, page in enumerate(reader.pages):
page_text = page.extract_text()
if page_text:
text_parts.append(page_text)
else:
logger.warning(f"PyPDF2: no text on page {page_num + 1}")
full_text = "\n".join(text_parts)
if full_text.strip():
logger.info(f"PyPDF2: extracted {len(full_text)} chars")
return full_text
raise ValueError("No text extracted from PDF. File may be image-based (scanned).")
except ImportError:
raise ImportError("No PDF parser installed. Run: pip install pdfplumber PyPDF2")
except Exception as e:
raise ValueError(f"PDF parsing failed: {e}")
# -- DOCX Extraction --------------------------------------------------
def extract_text_from_docx(file: Union[bytes, io.BytesIO]) -> str:
"""
Extract text from DOCX including paragraphs, tables, and headers.
Handles None styles safely (some DOCX files have unstyled paragraphs).
"""
try:
from docx import Document
except ImportError:
raise ImportError("python-docx is required. Run: pip install python-docx")
if isinstance(file, bytes):
file = io.BytesIO(file)
try:
doc = Document(file)
parts = []
for para in doc.paragraphs:
text = para.text.strip()
if not text:
continue
# FIX: para.style or para.style.name can be None in some DOCX files
try:
style_name = para.style.name if para.style and para.style.name else ""
except Exception:
style_name = ""
if style_name.startswith("Heading"):
parts.append(f"\n{text.upper()}\n")
else:
parts.append(text)
# Extract table contents
for table in doc.tables:
for row in table.rows:
row_cells = []
for cell in row.cells:
cell_text = cell.text.strip() if cell.text else ""
if cell_text:
row_cells.append(cell_text)
if row_cells:
parts.append(" | ".join(row_cells))
full_text = "\n".join(parts)
# Fallback: try reading body XML if no text found
if not full_text.strip():
try:
import re
xml_content = doc.element.body.xml
clean = re.sub(r'<[^>]+>', ' ', xml_content)
clean = re.sub(r'\s+', ' ', clean).strip()
if clean:
logger.warning("DOCX: used XML fallback extraction")
return clean
except Exception:
pass
raise ValueError("No text extracted from DOCX — file may be empty or image-based.")
logger.info(f"DOCX: extracted {len(full_text)} chars")
return full_text
except ValueError:
raise
except Exception as e:
raise ValueError(f"DOCX parsing failed: {e}")
# -- TXT Extraction ---------------------------------------------------
def extract_text_from_txt(file: Union[bytes, io.BytesIO, str]) -> str:
"""
Extract text from TXT file with encoding detection.
Tries UTF-8, UTF-16, Latin-1 in order.
"""
encodings = ["utf-8", "utf-16", "latin-1", "cp1252"]
try:
if isinstance(file, str):
for enc in encodings:
try:
with open(file, "r", encoding=enc, errors="strict") as f:
text = f.read()
logger.info(f"TXT: read {len(text)} chars (encoding: {enc})")
return text
except (UnicodeDecodeError, UnicodeError):
continue
with open(file, "r", encoding="utf-8", errors="replace") as f:
return f.read()
elif isinstance(file, bytes):
raw = file
elif isinstance(file, io.BytesIO):
raw = file.read()
else:
raise ValueError(f"Unsupported type: {type(file)}")
for enc in encodings:
try:
text = raw.decode(enc)
logger.info(f"TXT: decoded {len(text)} chars (encoding: {enc})")
return text
except (UnicodeDecodeError, UnicodeError):
continue
text = raw.decode("utf-8", errors="replace")
logger.warning("TXT decoded with replacement characters")
return text
except Exception as e:
raise ValueError(f"TXT parsing failed: {e}")
# -- Public API -------------------------------------------------------
def validate_file_size(file_bytes: bytes, filename: str) -> None:
"""Raise ValueError if file exceeds MAX_FILE_SIZE_MB."""
size_mb = len(file_bytes) / (1024 * 1024)
if size_mb > MAX_FILE_SIZE_MB:
raise ValueError(
f"File '{filename}' is {size_mb:.1f} MB — maximum is {MAX_FILE_SIZE_MB} MB."
)
def parse_resume(file: Union[bytes, io.BytesIO], filename: str) -> str:
"""
Parse a resume file and return extracted text.
Supports PDF, DOCX, TXT.
"""
if isinstance(file, bytes):
validate_file_size(file, filename)
suffix = Path(filename).suffix.lower()
logger.info(f"Parsing resume: {filename} ({suffix})")
if suffix == ".pdf":
return extract_text_from_pdf(file)
elif suffix == ".docx":
return extract_text_from_docx(file)
elif suffix == ".txt":
return extract_text_from_txt(file)
else:
raise ValueError(f"Unsupported format: '{suffix}'. Supported: PDF, DOCX, TXT.")
def parse_job_description(
text_or_file: Union[str, bytes, io.BytesIO],
filename: Optional[str] = None,
) -> str:
"""
Parse a job description from pasted text or uploaded file.
"""
if isinstance(text_or_file, str):
if not text_or_file.strip():
raise ValueError("Job description text is empty.")
logger.info(f"JD received as text ({len(text_or_file)} chars)")
return text_or_file
if filename is None:
raise ValueError("filename is required when passing a file.")
return parse_resume(text_or_file, filename)