resume-analyzer / engine /parser.py
mohit kumar
Initial Resume Analyzer deploy
a4db4ad
Raw History Blame Contribute Delete
12.8 kB
import os
import re
import sys
import traceback
import pdfplumber
from docx import Document
# =============================================================================
# INTERNAL HELPER β€” Text Cleaning
# =============================================================================
def clean_text(text: str) -> str:
"""
Clean raw extracted text for downstream processing.
What this does:
- Removes non-printable / control characters (common in PDFs)
- Collapses multiple blank lines into a single blank line
- Strips leading/trailing whitespace from each line
- Preserves paragraph structure (single blank line between sections)
- Does NOT remove punctuation or change any words β€” extractor.py needs raw content
Args:
text: Raw string from pdfplumber or python-docx
Returns:
Cleaned string, ready for extractor.py
"""
if not text:
return ""
# Step 1: Remove non-printable characters (null bytes, form feeds etc)
# Keep: normal chars, newlines, tabs
text = re.sub(r'[^\x09\x0A\x0D\x20-\x7E\u00A0-\uFFFF]', ' ', text)
# Step 2: Normalise line endings (Windows \r\n β†’ \n)
text = text.replace('\r\n', '\n').replace('\r', '\n')
# Step 3: Strip trailing whitespace from each line
lines = [line.rstrip() for line in text.split('\n')]
# Step 4: Collapse 3+ consecutive blank lines into 2
# This preserves section gaps while removing excessive whitespace
cleaned_lines = []
blank_count = 0
for line in lines:
if line.strip() == '':
blank_count += 1
if blank_count <= 2:
cleaned_lines.append('')
else:
blank_count = 0
cleaned_lines.append(line)
# Step 5: Remove leading/trailing blank lines from the whole document
result = '\n'.join(cleaned_lines).strip()
return result
# =============================================================================
# PDF EXTRACTION
# =============================================================================
def extract_text_from_pdf(file_path: str) -> str:
"""
Extract all text from a PDF resume using pdfplumber.
Handles:
- Multi-page PDFs (reads all pages)
- Single and multi-column layouts
- Tables inside PDFs (extracts cell text)
- PDFs with mixed text + table sections
Does NOT handle:
- Scanned/image PDFs (these have no embedded text β€” returns empty string)
- Password-protected PDFs (raises an error)
Args:
file_path: Absolute or relative path to the PDF file
Returns:
Cleaned text string. Empty string if no text found (likely scanned PDF).
Raises:
FileNotFoundError: If file does not exist
Exception: If pdfplumber cannot open the file
"""
if not os.path.exists(file_path):
raise FileNotFoundError(f"PDF not found: {file_path}")
all_text = []
with pdfplumber.open(file_path) as pdf:
for page_num, page in enumerate(pdf.pages, start=1):
page_text_parts = []
# --- Extract regular text ---
text = page.extract_text()
if text:
page_text_parts.append(text)
# --- Extract text from tables ---
# Some resumes put skills or experience in HTML-style tables
# pdfplumber reads these separately from regular text
tables = page.extract_tables()
for table in tables:
for row in table:
# Each row is a list of cell values (some may be None)
row_text = ' '.join(
cell.strip() for cell in row
if cell and cell.strip()
)
if row_text:
page_text_parts.append(row_text)
# Combine text from this page
if page_text_parts:
page_combined = '\n'.join(page_text_parts)
all_text.append(f"[Page {page_num}]\n{page_combined}")
raw_text = '\n\n'.join(all_text)
return clean_text(raw_text)
# =============================================================================
# DOCX EXTRACTION
# =============================================================================
def extract_text_from_docx(file_path: str) -> str:
"""
Extract all text from a DOCX resume using python-docx.
Handles:
- All paragraph text (normal, headings, bullets)
- Tables inside DOCX (common in formatted resumes)
- Bold/italic text (text content preserved, formatting ignored)
- Multi-section documents
Args:
file_path: Absolute or relative path to the DOCX file
Returns:
Cleaned text string.
Raises:
FileNotFoundError: If file does not exist
"""
if not os.path.exists(file_path):
raise FileNotFoundError(f"DOCX not found: {file_path}")
doc = Document(file_path)
all_text = []
# --- Extract paragraph text ---
for para in doc.paragraphs:
text = para.text.strip()
if text:
all_text.append(text)
# --- Extract table text ---
# Many DOCX resumes use tables for layout (two-column skill sections etc)
for table in doc.tables:
for row in table.rows:
row_parts = []
for cell in row.cells:
cell_text = cell.text.strip()
if cell_text:
row_parts.append(cell_text)
if row_parts:
all_text.append(' | '.join(row_parts))
raw_text = '\n'.join(all_text)
return clean_text(raw_text)
# =============================================================================
# MAIN ROUTER β€” Use This Function in All Other Files
# =============================================================================
def extract_text(file_path: str) -> str:
"""
Auto-detect file type and extract text from a resume.
This is the only function you need to call from outside this file.
It detects .pdf vs .docx and routes to the correct extractor.
Args:
file_path: Path to a .pdf or .docx resume file
Returns:
Cleaned text string ready for extractor.py
Raises:
ValueError: If file extension is not .pdf or .docx
FileNotFoundError: If file does not exist
"""
# Check extension first β€” unsupported format is a config error, not a missing file
ext = os.path.splitext(file_path)[1].lower()
if ext not in ('.pdf', '.docx'):
raise ValueError(
f"Unsupported file format: '{ext}'\n"
f"Supported formats: .pdf, .docx\n"
f"File: {file_path}"
)
# Then check existence
if not os.path.exists(file_path):
raise FileNotFoundError(
f"File not found: {file_path}\n"
f"Make sure the file is saved in data/resumes/"
)
if ext == '.pdf':
return extract_text_from_pdf(file_path)
else:
return extract_text_from_docx(file_path)
# =============================================================================
# BATCH LOADER β€” Processes an Entire Folder
# =============================================================================
def load_resumes_from_folder(folder_path: str,
verbose: bool = True) -> list:
"""
Load and parse all PDF and DOCX resumes from a folder.
Processes every supported file in the folder.
Skips unsupported files silently.
Logs a warning for files that fail to parse (does not crash).
Args:
folder_path: Path to folder containing resume files
verbose: If True, prints progress for each file
Returns:
List of dicts, one per successfully parsed resume:
[
{
"file_name" : "resume_1.pdf",
"file_path" : "/full/path/to/resume_1.pdf",
"raw_text" : "Priya Sharma\nData Analyst...",
"char_count": 3241,
"word_count": 512,
"status" : "ok"
},
...
]
Failed files are included with status "failed" and empty raw_text,
so you can see what went wrong without crashing the pipeline.
"""
if not os.path.exists(folder_path):
raise FileNotFoundError(
f"Resume folder not found: {folder_path}\n"
f"Create it and add PDF/DOCX files, then run again."
)
# Find all supported files in the folder
all_files = os.listdir(folder_path)
supported = [
f for f in sorted(all_files)
if os.path.splitext(f)[1].lower() in ('.pdf', '.docx')
]
skipped = [
f for f in all_files
if f not in supported and not f.startswith('.')
]
if verbose:
print(f"\n[Parser] Found {len(supported)} supported file(s) "
f"in '{os.path.basename(folder_path)}'")
if skipped:
print(f"[Parser] Skipping {len(skipped)} unsupported file(s): "
f"{', '.join(skipped[:5])}")
results = []
for i, fname in enumerate(supported, start=1):
fpath = os.path.join(folder_path, fname)
if verbose:
print(f"[Parser] ({i}/{len(supported)}) Parsing: {fname} ...", end=' ')
try:
text = extract_text(fpath)
# Quality check β€” warn if text is suspiciously short
word_count = len(text.split())
char_count = len(text)
if word_count < 30:
status = "warning_short"
if verbose:
print(f"⚠ Only {word_count} words extracted β€” "
f"may be a scanned or image-based PDF")
else:
status = "ok"
if verbose:
print(f"βœ“ {char_count:,} chars | {word_count:,} words")
results.append({
"file_name" : fname,
"file_path" : os.path.abspath(fpath),
"raw_text" : text,
"char_count" : char_count,
"word_count" : word_count,
"status" : status,
})
except Exception as e:
if verbose:
print(f"βœ— FAILED β€” {type(e).__name__}: {e}")
results.append({
"file_name" : fname,
"file_path" : os.path.abspath(fpath),
"raw_text" : "",
"char_count" : 0,
"word_count" : 0,
"status" : f"failed: {type(e).__name__}: {e}",
})
# Summary
ok_count = sum(1 for r in results if r["status"] == "ok")
fail_count = sum(1 for r in results if r["status"].startswith("failed"))
warn_count = sum(1 for r in results if r["status"] == "warning_short")
if verbose:
print(f"\n[Parser] Done β€” "
f"{ok_count} parsed OK | "
f"{warn_count} warnings | "
f"{fail_count} failed\n")
return results
# =============================================================================
# QUICK SELF-TEST
# Run this file directly to test parser on your resumes:
# python engine/parser.py
# =============================================================================
if __name__ == "__main__":
# Add project root to path so config.py can be imported
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
import config
resume_folder = config.RESUME_DIR
except ImportError:
# Fallback if run outside project structure
resume_folder = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"data", "../data/resumes"
)
print("\n" + "="*60)
print(" PARSER SELF-TEST")
print("="*60)
print(f" Resume folder: {resume_folder}")
if not os.path.exists(resume_folder):
print(f"\n Folder does not exist yet.")
print(f" Create it and add PDF/DOCX files:\n {resume_folder}\n")
sys.exit(0)
resumes = load_resumes_from_folder(resume_folder, verbose=True)
if not resumes:
print("\n No resumes found. Add PDF/DOCX files to the folder above.\n")
sys.exit(0)
# Show a preview of the first successfully parsed resume
ok_resumes = [r for r in resumes if r["status"] == "ok"]
if ok_resumes:
first = ok_resumes[0]
print("="*60)
print(f" PREVIEW β€” {first['file_name']}")
print("="*60)
preview_lines = first["raw_text"].split('\n')[:20]
for line in preview_lines:
if line.strip():
print(f" {line}")
print(f"\n ... ({first['word_count']} total words)\n")