Files

249 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""简历解析:.txt 直接读,.docx 用 python-docx,.pdf 用 pypdf。
解析后做四件事:脱敏(姓名/手机/邮箱/住址打码)、技能抽取并归一化到词表、
工作年限与场景识别、结构化信息抽取(性别/学历/住址/分节/证件照),
供规范简历模板展示与导出。
"""
from __future__ import annotations
import base64
import logging
import re
from pathlib import Path
from app.extract.base import SkillMatcher, load_lexicon
from app.models import ResumeProfile
logger = logging.getLogger(__name__)
_PHONE_RE = re.compile(r"1[3-9]\d{9}")
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
# 标签字间可含空格/全角空格(PDF/Word 排版常见:"姓 名 :孙千越")
_NAME_RE = re.compile(r"姓\s*名\s*[::]\s*([一-龥]{2,4})")
# 简历常见写法:首行裸姓名(无"姓名:"前缀),如" 王伟航 "
_BARE_NAME_RE = re.compile(r"^\s*([一-龥]{2,4})\s*$", re.MULTILINE)
_YEARS_RE = re.compile(r"(\d+(?:\.\d+)?)\s*年(?:以上)?[^\n,。;]{0,6}?经验")
# 时间段兜底:"2023.07-2023.08" / "2021.09-2025.6" / "2025.12-至今"
_PERIOD_RE = re.compile(
r"(20\d{2})\s*[./年]\s*(\d{1,2})\s*月?\s*[-–—~]+\s*(?:(20\d{2})\s*[./年]\s*(\d{1,2})\s*月?|至今)")
_GENDER_RE = re.compile(r"性\s*别\s*[::]?\s*(男|女)")
_EDU_RE = re.compile(r"(博士后|博士|专硕|学硕|硕士|研究生|MBA|本科|大专|专科)")
_EDU_MAP = {"专硕": "硕士", "学硕": "硕士", "研究生": "硕士"}
# 在读学历:「硕士在读」或「大二/大三」这类年级写法 → 本科(在读)
_EDU_READING_RE = re.compile(r"(本科|硕士|博士|研究生|MBA)\s*在读")
# 学位词同行出现"至今"(教育经历未结束的常见写法):金融科技(专硕)… 2025.09-至今 → 在读
_EDU_ONGOING_RE = re.compile(
r"(博士后|博士|专硕|学硕|硕士|研究生|MBA|本科|大专|专科)[^\n]{0,30}?至今")
_GRADE_RE = re.compile(r"大[一二三四]")
_ADDR_RE = re.compile(r"(?:家庭住址|现居(?:住)?(?:地|址)?|联系地址|通讯地址|籍贯)[::]\s*([^\n,。;]{2,30})")
# 常见简历分节标题(整行匹配,可带冒号)
_SECTION_TITLES = {
"个人信息", "基本信息", "求职意向", "教育经历", "教育背景",
"工作经历", "工作经验", "实习经历", "项目经历", "项目经验",
"技能清单", "专业技能", "核心技能", "技能特长", "技能",
"自我评价", "个人评价", "荣誉证书", "荣誉奖项", "证书",
}
def _extract_name(text: str) -> str:
"""姓名识别:优先"姓名:X"格式,否则取首个独立的 2-4 字中文裸行(简历常见排版)。"""
m = _NAME_RE.search(text)
if m:
return m.group(1)
for line in text.splitlines()[:5]: # 只在开头几行找,避免误伤正文
b = _BARE_NAME_RE.match(line)
if b:
return b.group(1)
return ""
def _extract_education(text: str) -> str:
"""学历:优先识别在读("硕士在读" / 学位词同行"至今" / "大二"年级写法),再匹配学位词。"""
rm = _EDU_READING_RE.search(text)
if rm:
return f"{_EDU_MAP.get(rm.group(1), rm.group(1))}(在读)"
om = _EDU_ONGOING_RE.search(text)
if om:
return f"{_EDU_MAP.get(om.group(1), om.group(1))}(在读)"
if _GRADE_RE.search(text):
return "本科(在读)"
m = _EDU_RE.search(text)
return _EDU_MAP.get(m.group(1), m.group(1)) if m else ""
def mask_text(text: str) -> str:
"""脱敏:手机、邮箱、姓名(含首行裸姓名)、住址打码。"""
text = _PHONE_RE.sub(lambda m: m.group(0)[:3] + "****" + m.group(0)[-2:], text)
text = _EMAIL_RE.sub(lambda m: m.group(0)[0] + "***@" + m.group(0).split("@")[1], text)
text = _NAME_RE.sub(lambda m: "姓名:" + m.group(1)[0] + "*" * (len(m.group(1)) - 1), text)
name = _extract_name(text)
if name: # 裸姓名行整体打码
text = _BARE_NAME_RE.sub(
lambda m: m.group(0).replace(name, name[0] + "*" * (len(name) - 1))
if m.group(1) == name else m.group(0), text, count=1)
text = _ADDR_RE.sub(lambda m: m.group(0)[:2] + m.group(1)[:6] + "***"
if len(m.group(1)) > 6 else m.group(0), text)
return text
def split_sections(text: str) -> list[dict]:
"""按常见标题把简历正文分节,返回 [{title, text}];无标题时整体归入"简历全文"。"""
sections: list[dict] = []
cur_title, cur_lines = "基本信息", []
for ln in text.splitlines():
key = ln.strip().rstrip("::").strip()
if key in _SECTION_TITLES:
if "\n".join(cur_lines).strip():
sections.append({"title": cur_title, "text": "\n".join(cur_lines).strip()})
cur_title, cur_lines = key, []
else:
cur_lines.append(ln)
if "\n".join(cur_lines).strip():
sections.append({"title": cur_title, "text": "\n".join(cur_lines).strip()})
if not sections and text.strip():
sections = [{"title": "简历全文", "text": text.strip()}]
return sections
def extract_photo_b64(path: str | Path) -> str:
"""从 docx/pdf 中提取第一张图片作为证件照,返回 data URI;无图片返回空串。"""
path = Path(path)
suffix = path.suffix.lower()
try:
if suffix == ".docx":
import zipfile
with zipfile.ZipFile(str(path)) as z:
names = sorted(n for n in z.namelist()
if n.startswith("word/media/")
and n.lower().endswith((".png", ".jpg", ".jpeg")))
if not names:
return ""
data = z.read(names[0])
mime = "image/png" if names[0].lower().endswith(".png") else "image/jpeg"
return f"data:{mime};base64,{base64.b64encode(data).decode()}"
if suffix == ".pdf":
from pypdf import PdfReader
reader = PdfReader(str(path))
for page in reader.pages[:2]:
for img in getattr(page, "images", []):
mime = "image/png" if img.name.lower().endswith(".png") else "image/jpeg"
return f"data:{mime};base64,{base64.b64encode(img.data).decode()}"
except Exception as exc: # 照片是增强信息,提取失败不阻断解析
logger.warning("简历照片提取失败(已跳过): %s", exc)
return ""
def _docx_text(path: Path) -> str:
"""按文档顺序提取 docx 文本:段落 + 表格(含嵌套表格单元格)。
简历模板普遍用无边框表格排版个人信息/技能区,只读 paragraphs 会整块丢失。
"""
from docx import Document # python-docx,requirements 已声明
from docx.table import Table
from docx.text.paragraph import Paragraph
doc = Document(str(path))
parts: list[str] = []
def walk_table(tbl) -> None:
for row in tbl.rows:
for cell in row.cells:
if cell.text.strip():
parts.append(cell.text)
for nested in cell.tables:
walk_table(nested)
for child in doc.element.body.iterchildren():
if child.tag.endswith("}p"):
parts.append(Paragraph(child, doc).text)
elif child.tag.endswith("}tbl"):
walk_table(Table(child, doc))
return "\n".join(parts)
def read_resume_file(path: str | Path) -> str:
"""按扩展名读取简历文件文本。"""
path = Path(path)
suffix = path.suffix.lower()
if suffix == ".txt":
return path.read_text(encoding="utf-8")
if suffix == ".docx":
return _docx_text(path)
if suffix == ".pdf":
try:
from pypdf import PdfReader
except ImportError as exc:
raise RuntimeError(
"解析 .pdf 需要可选依赖 pypdf,请先执行:pip install pypdf"
) from exc
reader = PdfReader(str(path))
return "\n".join(page.extract_text() or "" for page in reader.pages)
raise ValueError(f"不支持的简历格式:{suffix}(支持 .txt/.docx/.pdf)")
def _mask_addr(addr: str) -> str:
"""住址脱敏:保留前 6 字(大致到区/县),其余打码。"""
return addr[:6] + "***" if len(addr) > 6 else addr
def _estimate_years(text: str) -> float:
"""工作年限兜底估计:无"X年经验"写法时,累加工作/实习/项目分节中的时间段。
教育经历不计入工龄;无分节时剔除教育相关行后全文兜底。
单段封顶 36 个月、总计封顶 10 年,防误识别。
"""
import datetime as _dt
work_titles = {"工作经历", "工作经验", "实习经历", "项目经历", "项目经验"}
secs = split_sections(text)
body = "\n".join(s["text"] for s in secs if s["title"] in work_titles)
if not body.strip():
body = "\n".join(ln for ln in text.splitlines()
if not re.search(r"大学|学院|学校|教育|学历|学位|GPA", ln, re.IGNORECASE))
now = _dt.date.today()
months = 0
for m in _PERIOD_RE.finditer(body):
y1, m1 = int(m.group(1)), int(m.group(2))
if m.group(3):
y2, m2 = int(m.group(3)), int(m.group(4))
else:
y2, m2 = now.year, now.month
dm = (y2 - y1) * 12 + (m2 - m1) + 1
if 0 < dm <= 600: # 超 50 年的区间视为误识别
months += min(dm, 36)
return round(min(months, 120) / 12, 1)
def parse_resume(text: str, matcher: SkillMatcher | None = None,
photo_b64: str = "") -> ResumeProfile:
"""从简历文本解析脱敏画像(含结构化基本信息与分节)。"""
matcher = matcher or SkillMatcher(load_lexicon())
name_m = _NAME_RE.search(text)
name = name_m.group(1) if name_m else _extract_name(text)
phone_m = _PHONE_RE.search(text)
email_m = _EMAIL_RE.search(text)
years_m = _YEARS_RE.search(text)
gender_m = _GENDER_RE.search(text)
addr_m = _ADDR_RE.search(text)
masked = mask_text(text)
return ResumeProfile(
name=(name[0] + "*" * (len(name) - 1)) if name else "",
phone=(phone_m.group(0)[:3] + "****" + phone_m.group(0)[-2:]) if phone_m else "",
email=(email_m.group(0)[0] + "***@" + email_m.group(0).split("@")[1]) if email_m else "",
years=float(years_m.group(1)) if years_m else _estimate_years(text),
skills=matcher.find_names(text),
scenarios=matcher.find_scenarios(text),
raw_masked_text=masked,
gender=gender_m.group(1) if gender_m else "",
education=_extract_education(text),
address=_mask_addr(addr_m.group(1).strip()) if addr_m else "",
photo_b64=photo_b64,
sections=split_sections(masked),
)
def parse_resume_file(path: str | Path, matcher: SkillMatcher | None = None) -> ResumeProfile:
"""读取文件并解析(含证件照提取)。"""
return parse_resume(read_resume_file(path), matcher,
photo_b64=extract_photo_b64(path))