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