249 lines
11 KiB
Python
249 lines
11 KiB
Python
"""简历解析:.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))
|