267 lines
9.6 KiB
Python
267 lines
9.6 KiB
Python
"""
|
||
Resume parsing service — enhanced extraction.
|
||
Supports PDF (via PyMuPDF) and DOCX (via python-docx).
|
||
Extracts: name, email, phone, socials, objective, skills,
|
||
projects, education, experience, languages, interests.
|
||
"""
|
||
|
||
import re
|
||
import io
|
||
from typing import Optional
|
||
|
||
|
||
# ── Section header patterns ───────────────────────────────────
|
||
SECTION_PATTERNS = {
|
||
"objective": r"objective|summary|profile|about me|career goal",
|
||
"skills": r"skills|technical skills|competencies|technologies",
|
||
"experience": r"experience|work history|employment|professional experience|internship",
|
||
"education": r"education|academic|qualification|academics",
|
||
"projects": r"projects|portfolio|personal projects|academic projects",
|
||
"languages": r"languages|spoken languages",
|
||
"interests": r"interests|hobbies|activities|extra.?curricular",
|
||
"certifications": r"certifications?|achievements?|awards?",
|
||
}
|
||
|
||
# ── Programming / spoken language keywords ────────────────────
|
||
SKILL_KEYWORDS = {
|
||
"python", "javascript", "typescript", "java", "c++", "c#", "c", "go", "golang",
|
||
"rust", "swift", "kotlin", "scala", "ruby", "php", "r", "matlab", "bash",
|
||
"html", "css", "react", "vue", "angular", "next.js", "nuxt", "svelte",
|
||
"node.js", "express", "fastapi", "django", "flask", "spring", "laravel",
|
||
"machine learning", "deep learning", "tensorflow", "pytorch", "keras",
|
||
"scikit-learn", "pandas", "numpy", "matplotlib", "nlp", "computer vision",
|
||
"aws", "gcp", "azure", "docker", "kubernetes", "terraform", "jenkins",
|
||
"github actions", "ci/cd", "linux",
|
||
"mongodb", "postgresql", "mysql", "redis", "elasticsearch", "sqlite",
|
||
"dynamodb", "cassandra", "neo4j",
|
||
"git", "graphql", "rest api", "microservices", "kafka", "rabbitmq",
|
||
"websocket", "grpc", "firebase", "supabase", "prisma", "tailwind",
|
||
}
|
||
|
||
SPOKEN_LANGUAGE_KEYWORDS = {
|
||
"english", "hindi", "spanish", "french", "german", "chinese", "mandarin",
|
||
"japanese", "korean", "arabic", "portuguese", "italian", "russian",
|
||
"telugu", "tamil", "kannada", "bengali", "marathi", "gujarati", "malayalam",
|
||
"urdu", "punjabi",
|
||
}
|
||
|
||
|
||
# ── Low-level extractors ───────────────────────────────────────
|
||
|
||
def extract_email(text: str) -> Optional[str]:
|
||
m = re.search(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}", text)
|
||
return m.group(0) if m else None
|
||
|
||
|
||
def extract_phone(text: str) -> Optional[str]:
|
||
m = re.search(r"(\+?[\d\s\-().]{10,15})", text)
|
||
return m.group(0).strip() if m else None
|
||
|
||
|
||
def extract_linkedin(text: str) -> Optional[str]:
|
||
m = re.search(r"linkedin\.com/in/[\w\-]+", text, re.IGNORECASE)
|
||
if m:
|
||
return "https://" + m.group(0)
|
||
return None
|
||
|
||
|
||
def extract_github(text: str) -> Optional[str]:
|
||
m = re.search(r"github\.com/[\w\-]+", text, re.IGNORECASE)
|
||
if m:
|
||
return "https://" + m.group(0)
|
||
return None
|
||
|
||
|
||
def extract_website(text: str) -> Optional[str]:
|
||
# Match personal URLs that are NOT linkedin/github
|
||
m = re.search(
|
||
r"https?://(?!.*(?:linkedin|github))[a-zA-Z0-9.\-/~]+",
|
||
text,
|
||
re.IGNORECASE,
|
||
)
|
||
return m.group(0) if m else None
|
||
|
||
|
||
def extract_name(text: str) -> Optional[str]:
|
||
lines = [l.strip() for l in text.split("\n") if l.strip()]
|
||
name_pattern = re.compile(r"^[A-Z][a-z]+(?:\s[A-Z][a-z]+){1,3}$")
|
||
for line in lines[:15]:
|
||
if name_pattern.match(line):
|
||
return line
|
||
return None
|
||
|
||
|
||
def extract_skills(text: str) -> list[str]:
|
||
text_lower = text.lower()
|
||
found = []
|
||
for skill in SKILL_KEYWORDS:
|
||
if re.search(r"\b" + re.escape(skill) + r"\b", text_lower):
|
||
found.append(skill.title() if len(skill) > 3 else skill.upper())
|
||
return sorted(set(found))
|
||
|
||
|
||
def extract_languages(section_text: str, full_text: str) -> list[str]:
|
||
"""Extract spoken/natural languages (not programming languages)."""
|
||
search_text = (section_text or full_text).lower()
|
||
found = []
|
||
for lang in SPOKEN_LANGUAGE_KEYWORDS:
|
||
if re.search(r"\b" + lang + r"\b", search_text):
|
||
found.append(lang.capitalize())
|
||
return sorted(set(found))
|
||
|
||
|
||
def extract_interests(section_text: str) -> list[str]:
|
||
if not section_text:
|
||
return []
|
||
items = re.split(r"[,\n•\-|/]", section_text)
|
||
interests = [i.strip().strip("•-– ") for i in items if i.strip() and len(i.strip()) > 2]
|
||
# Deduplicate & cap
|
||
seen = set()
|
||
result = []
|
||
for item in interests:
|
||
if item.lower() not in seen and len(item) < 60:
|
||
seen.add(item.lower())
|
||
result.append(item.title())
|
||
return result[:15]
|
||
|
||
|
||
def extract_projects(section_text: str) -> list[dict]:
|
||
"""Parse projects section into list of {name, description, tech}."""
|
||
if not section_text:
|
||
return []
|
||
|
||
projects = []
|
||
# Split by common project delimiters: blank line, bullet, or ALL-CAPS title line
|
||
blocks = re.split(r"\n{2,}|(?=\n[•\-★▸])", section_text)
|
||
|
||
for block in blocks:
|
||
block = block.strip()
|
||
if not block:
|
||
continue
|
||
|
||
lines = [l.strip() for l in block.split("\n") if l.strip()]
|
||
if not lines:
|
||
continue
|
||
|
||
name = lines[0].lstrip("•-★▸ ").strip()
|
||
description = " ".join(lines[1:]).strip() if len(lines) > 1 else ""
|
||
|
||
# Extract tech from description
|
||
tech_found = []
|
||
desc_lower = description.lower()
|
||
for skill in SKILL_KEYWORDS:
|
||
if re.search(r"\b" + re.escape(skill) + r"\b", desc_lower):
|
||
tech_found.append(skill.title() if len(skill) > 3 else skill.upper())
|
||
|
||
if name and len(name) < 120:
|
||
projects.append({
|
||
"name": name,
|
||
"description": description[:600] if description else "",
|
||
"tech": sorted(set(tech_found)),
|
||
})
|
||
|
||
return projects[:10]
|
||
|
||
|
||
def extract_education(section_text: str) -> list[dict]:
|
||
if not section_text:
|
||
return []
|
||
|
||
institutions = []
|
||
lines = [l.strip() for l in section_text.split("\n") if l.strip()]
|
||
|
||
i = 0
|
||
while i < len(lines):
|
||
line = lines[i]
|
||
# Detect degree lines
|
||
degree_pattern = re.search(
|
||
r"(b\.?tech|m\.?tech|b\.?e|m\.?e|bsc|msc|b\.?sc|m\.?sc|bachelor|master|phd|doctorate|diploma|high school|hsc|ssc|10th|12th)",
|
||
line, re.IGNORECASE
|
||
)
|
||
if degree_pattern:
|
||
entry = {
|
||
"degree": line,
|
||
"institution": lines[i + 1] if i + 1 < len(lines) else "",
|
||
"year": "",
|
||
}
|
||
# Look for year
|
||
for j in range(i, min(i + 4, len(lines))):
|
||
year_m = re.search(r"\b(19|20)\d{2}\b", lines[j])
|
||
if year_m:
|
||
entry["year"] = year_m.group(0)
|
||
break
|
||
institutions.append(entry)
|
||
i += 2
|
||
else:
|
||
i += 1
|
||
|
||
return institutions[:6]
|
||
|
||
|
||
# ── Section splitter ───────────────────────────────────────────
|
||
|
||
def split_sections(text: str) -> dict[str, str]:
|
||
sections: dict[str, list[str]] = {"header": []}
|
||
current = "header"
|
||
lines = text.split("\n")
|
||
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
matched_section = None
|
||
for sec_key, pattern in SECTION_PATTERNS.items():
|
||
if re.match(rf"^({pattern})s?[\s:]*$", stripped, re.IGNORECASE):
|
||
matched_section = sec_key
|
||
break
|
||
if matched_section:
|
||
current = matched_section
|
||
sections.setdefault(current, [])
|
||
else:
|
||
sections.setdefault(current, []).append(stripped)
|
||
|
||
return {k: "\n".join(v) for k, v in sections.items()}
|
||
|
||
|
||
# ── Main parse function ────────────────────────────────────────
|
||
|
||
def parse_text(raw_text: str) -> dict:
|
||
sections = split_sections(raw_text)
|
||
|
||
objective_text = sections.get("objective", "").strip()
|
||
|
||
return {
|
||
"name": extract_name(raw_text),
|
||
"email": extract_email(raw_text),
|
||
"phone": extract_phone(raw_text),
|
||
"linkedin": extract_linkedin(raw_text),
|
||
"github": extract_github(raw_text),
|
||
"website": extract_website(raw_text),
|
||
"objective": objective_text[:600] if objective_text else None,
|
||
"skills": extract_skills(raw_text),
|
||
"languages": extract_languages(sections.get("languages", ""), raw_text),
|
||
"interests": extract_interests(sections.get("interests", "")),
|
||
"projects": extract_projects(sections.get("projects", "")),
|
||
"education": extract_education(sections.get("education", "")),
|
||
"experience": [], # Structured experience parsing can be added as needed
|
||
"raw_text": raw_text[:8000], # increased cap for better AI context
|
||
}
|
||
|
||
|
||
def parse_pdf(file_bytes: bytes) -> dict:
|
||
try:
|
||
import fitz # PyMuPDF
|
||
doc = fitz.open(stream=file_bytes, filetype="pdf")
|
||
text = "\n".join(page.get_text() for page in doc)
|
||
doc.close()
|
||
return parse_text(text)
|
||
except Exception as e:
|
||
return {"error": str(e), "raw_text": "", "skills": [], "projects": []}
|
||
|
||
|
||
def parse_docx(file_bytes: bytes) -> dict:
|
||
try:
|
||
from docx import Document
|
||
doc = Document(io.BytesIO(file_bytes))
|
||
text = "\n".join(p.text for p in doc.paragraphs)
|
||
return parse_text(text)
|
||
except Exception as e:
|
||
return {"error": str(e), "raw_text": "", "skills": [], "projects": []}
|