Spaces:
Runtime error
Runtime error
File size: 11,068 Bytes
1207440 | 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 | 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.")
|