Spaces:
Sleeping
Sleeping
File size: 12,814 Bytes
a4db4ad | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 |
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")
|