Spaces:
Runtime error
Runtime error
| import os | |
| import re | |
| import pdfplumber | |
| import docx2txt | |
| import spacy | |
| from typing import List, Dict, Optional | |
| import pprint | |
| # Load NLP model | |
| try: | |
| nlp = spacy.load("en_core_web_sm") | |
| except OSError: | |
| print("SpaCy model 'en_core_web_sm' not found.") | |
| print("Please run: python -m spacy download en_core_web_sm") | |
| exit() | |
| # Text Extraction | |
| def extract_text_from_pdf(file_path: str) -> str: | |
| """Extract text from PDF with proper page separation""" | |
| text = "" | |
| try: | |
| with pdfplumber.open(file_path) as pdf: | |
| for page in pdf.pages: | |
| page_text = page.extract_text() | |
| if page_text: | |
| text += page_text + "\n" | |
| except Exception as e: | |
| print(f"Error reading PDF file {file_path}: {e}") | |
| return text | |
| def extract_text_from_docx(file_path: str) -> str: | |
| """Extract text from DOCX while preserving structure""" | |
| try: | |
| return docx2txt.process(file_path) | |
| except Exception as e: | |
| print(f"Error reading DOCX file {file_path}: {e}") | |
| return "" | |
| def clean_text(text: str) -> str: | |
| """Normalize whitespace and clean artifacts""" | |
| text = re.sub(r'\n+', '\n', text) # Collapse multiple newlines | |
| text = re.sub(r'[^\S\n]+', ' ', text) # Collapse multiple spaces/tabs | |
| return text.strip() | |
| def validate_email(email: str) -> bool: | |
| """Validate email format strictly""" | |
| if not email: | |
| return False | |
| # Check for invalid characters | |
| if any(char in email for char in ['|', '\n', '\t', ' ', ',', ';']): | |
| return False | |
| # Standard email regex | |
| pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' | |
| return bool(re.match(pattern, email)) | |
| def validate_phone(phone: str) -> bool: | |
| """Validate phone number format""" | |
| if not phone: | |
| return False | |
| # Remove common formatting characters | |
| cleaned = re.sub(r'[\s\-\(\)]+', '', phone) | |
| # Check if it's a reasonable phone number (7-15 digits) | |
| return bool(re.match(r'^\+?\d{7,15}$', cleaned)) | |
| # Entity Extraction | |
| def extract_entities(text: str) -> Dict[str, Optional[str]]: | |
| """ | |
| Extract name, email, and phone with robust validation | |
| Returns: {'name': str, 'email': str, 'phone': str} | |
| """ | |
| # Extract name from first 3 lines (where it usually appears) | |
| first_lines = '\n'.join(text.split('\n')[:3]) | |
| doc = nlp(first_lines) | |
| name = "" | |
| # Try to find PERSON entity | |
| for ent in doc.ents: | |
| if ent.label_ == "PERSON": | |
| # Clean the name - take only the first line and remove extra whitespace | |
| potential_name = ent.text.split('\n')[0].strip() | |
| potential_name = re.sub(r'\s+', ' ', potential_name) | |
| # Validate name (should not contain numbers or special chars except spaces, hyphens, apostrophes) | |
| if re.match(r"^[A-Za-z\s\-'\.]+$", potential_name) and len(potential_name) > 2: | |
| name = potential_name | |
| break | |
| # Fallback: Use first line if no PERSON entity found | |
| if not name: | |
| first_line = text.split('\n')[0].strip() | |
| # Clean and validate | |
| first_line = re.sub(r'\s+', ' ', first_line) | |
| if re.match(r"^[A-Za-z\s\-'\.]+$", first_line) and len(first_line) > 2: | |
| name = first_line | |
| # Extract email with strict validation | |
| email = None | |
| # Find all potential emails | |
| email_pattern = r'\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b' | |
| email_matches = re.findall(email_pattern, text) | |
| # Validate and pick the first valid one | |
| for match in email_matches: | |
| # Clean the match (remove any trailing/leading dots or special chars) | |
| cleaned_email = match.strip().lower() | |
| if validate_email(cleaned_email): | |
| email = cleaned_email | |
| break | |
| # Extract phone with validation | |
| phone = None | |
| # Multiple phone patterns | |
| phone_patterns = [ | |
| r'\+?\d{1,3}[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{4}', # US/International | |
| r'\+?\d{10,15}', # Simple international | |
| r'\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{4}', # US format | |
| ] | |
| for pattern in phone_patterns: | |
| phone_match = re.search(pattern, text) | |
| if phone_match: | |
| potential_phone = phone_match.group(0).strip() | |
| if validate_phone(potential_phone): | |
| phone = potential_phone | |
| break | |
| return { | |
| "name": name if name else None, | |
| "email": email, | |
| "phone": phone | |
| } | |
| # Skills Extraction | |
| def extract_skills(text: str, skill_list: List[str]) -> List[str]: | |
| """Find skills using case-insensitive whole-word matching""" | |
| found_skills = set() | |
| text_lower = text.lower() | |
| for skill in skill_list: | |
| # Match whole words to avoid partial matches | |
| pattern = rf'\b{re.escape(skill.lower())}\b' | |
| if re.search(pattern, text_lower): | |
| found_skills.add(skill) | |
| return sorted(found_skills) | |
| # Education Extraction | |
| def extract_education(text: str) -> List[str]: | |
| """Extract education degrees with flexible matching""" | |
| patterns = [ | |
| r"(?:B\.?Tech|Bachelor[\s']*(?:of|in)?[\s']*Technology)\b.*?(?:Computer Science|Artificial Intelligence|IT|\bAI\b|\bCS\b|Information Technology)", | |
| r"(?:M\.?Tech|Master[\s']*(?:of|in)?[\s']*Technology)\b.*?(?:Data Science|Machine Learning|Computer)", | |
| r"B\.?[Ee]\.?\b.*?(?:Computer|Electrical|Electronics)", | |
| r"B\.?[Ss]c\.?\b.*?(?:Computer|Physics|Mathematics|IT)", | |
| r"\bPhD\b.*?(?:Computer Science|Engineering|Technology)", | |
| r"(?:Bachelor|Master).*?(?:Computer Science|Information Technology|Software Engineering)", | |
| r"(?:B\.?A\.|Bachelor of Arts).*?(?:Computer|Technology)", | |
| r"(?:M\.?S\.|Master of Science).*?(?:Computer|Data|Technology)" | |
| ] | |
| found = set() | |
| for pattern in patterns: | |
| matches = re.findall(pattern, text, re.IGNORECASE) | |
| for match in matches: | |
| # Clean the match | |
| cleaned = re.sub(r'\s+', ' ', match.strip()) | |
| if len(cleaned) > 5: # Reasonable length | |
| found.add(cleaned) | |
| return sorted(found) if found else [] | |
| # Experience Extraction | |
| def calculate_experience_years(experience_dates: List[str]) -> float: | |
| """Calculate total years of experience from date ranges""" | |
| total_years = 0.0 | |
| for date_range in experience_dates: | |
| # Find all 4-digit years in the string | |
| years = list(map(int, re.findall(r'\b(19|20)\d{2}\b', date_range))) | |
| # Handle "YYYY-YYYY" or "Month YYYY - Month YYYY" | |
| if len(years) >= 2: | |
| start_year = min(years) | |
| end_year = max(years) | |
| total_years += (end_year - start_year) | |
| return round(total_years, 1) | |
| def extract_experience_dates(text: str) -> List[str]: | |
| """Extract all experience date ranges""" | |
| patterns = [ | |
| r'\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+(19|20)\d{2}\s*[\-–to]+\s*(?:(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+)?(19|20)\d{2}|Present|Current', | |
| r'\b(19|20)\d{2}\s*[\-–to]+\s*(19|20)\d{2}\b', | |
| r'\b(19|20)\d{2}\s*[\-–to]+\s*(?:Present|Current)\b' | |
| ] | |
| matches = [] | |
| for pattern in patterns: | |
| matches.extend(re.findall(pattern, text, re.IGNORECASE)) | |
| # Convert tuples to strings | |
| return [' '.join(m) if isinstance(m, tuple) else m for m in matches] | |
| # Main Parser | |
| def parse_resume(file_path: str) -> Optional[Dict]: | |
| """Parse resume and return structured data with robust validation""" | |
| if not os.path.exists(file_path): | |
| print(f"Error: File not found at {file_path}") | |
| return None | |
| # Extract text based on file type | |
| if file_path.endswith(".pdf"): | |
| text = extract_text_from_pdf(file_path) | |
| elif file_path.endswith(".docx"): | |
| text = extract_text_from_docx(file_path) | |
| else: | |
| print("Error: Only PDF and DOCX files are supported") | |
| return None | |
| if not text or len(text.strip()) < 50: | |
| print("Could not extract sufficient text from the file.") | |
| return None | |
| text = clean_text(text) | |
| # Define comprehensive skills list | |
| skills_list = [ | |
| # Programming Languages | |
| "Python", "Java", "JavaScript", "TypeScript", "C++", "C#", "Ruby", "Go", "Rust", | |
| "PHP", "Swift", "Kotlin", "Scala", "R", "MATLAB", "Perl", | |
| # Web Technologies | |
| "HTML", "CSS", "React", "Angular", "Vue.js", "Node.js", "Express", "Django", | |
| "Flask", "FastAPI", "Spring Boot", "ASP.NET", "jQuery", "Bootstrap", "Tailwind", | |
| # Databases | |
| "SQL", "MySQL", "PostgreSQL", "MongoDB", "Redis", "Cassandra", "Oracle", | |
| "SQL Server", "SQLite", "DynamoDB", "Elasticsearch", | |
| # Cloud & DevOps | |
| "AWS", "Azure", "Google Cloud", "GCP", "Docker", "Kubernetes", "Jenkins", | |
| "Git", "GitHub", "GitLab", "CI/CD", "Terraform", "Ansible", | |
| # Data Science & ML | |
| "Machine Learning", "Deep Learning", "TensorFlow", "PyTorch", "Keras", | |
| "Scikit-learn", "Pandas", "NumPy", "Matplotlib", "Seaborn", "NLP", | |
| "Natural Language Processing", "Computer Vision", "Data Analysis", | |
| "Big Data", "Spark", "Hadoop", "Tableau", "Power BI", | |
| # Other Tools | |
| "Linux", "Unix", "Bash", "PowerShell", "Agile", "Scrum", "JIRA", | |
| "Confluence", "Postman", "REST API", "GraphQL", "Microservices" | |
| ] | |
| # Extract all information | |
| entities = extract_entities(text) | |
| experience_dates = extract_experience_dates(text) | |
| result = { | |
| "name": entities.get("name"), | |
| "email": entities.get("email"), | |
| "phone": entities.get("phone"), | |
| "education": extract_education(text), | |
| "total_experience_years": calculate_experience_years(experience_dates), | |
| "skills": extract_skills(text, skills_list), | |
| "full_text": text[:5000] # Limit to first 5000 chars | |
| } | |
| # Validate critical fields | |
| if not result["name"]: | |
| result["name"] = "Unknown Candidate" | |
| if not result["email"]: | |
| print("Warning: No valid email found in resume") | |
| return result | |
| # --- Main Execution --- | |
| if __name__ == "__main__": | |
| # Test with a file | |
| file_path = "test_resume.pdf" # Change this to your test file | |
| if os.path.exists(file_path): | |
| parsed_data = parse_resume(file_path) | |
| if parsed_data: | |
| print("--- Resume Parsing Complete ---") | |
| pprint.pprint(parsed_data) | |
| print("-----------------------------") | |
| else: | |
| print(f"Test file not found: {file_path}") | |
| print("Please provide a test resume file to parse.") | |