Spaces:
Sleeping
Sleeping
Download engine/parser.py from ResumeSelectors/resume-analyzer: direct link, hf CLI and curl.
- Browser
- Download file 12.8 kB
-
https://huggingface.co/spaces/ResumeSelectors/resume-analyzer/resolve/main/engine/parser.py
- Command line
-
hf download hf://spaces/ResumeSelectors/resume-analyzer/engine/parser.py
-
curl -L -o parser.py https://huggingface.co/spaces/ResumeSelectors/resume-analyzer/resolve/main/engine/parser.py
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") | |