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.")