- 全链路:多源JD接入/清洗交叉验证/结构化抽取(星火+离线双通道)/时序知识图谱/新岗位发现/演化分析/人岗匹配/学习路径 - WebUI 五视图:驾驶舱/图谱工作台/岗位演化/匹配(单人+批量)/学习路径 - 设置中心(config.json)/Token鉴权/SQLite状态库/语义向量通道(BGE-M3本地或联网API) - 71 个 pytest 用例;数据目录仅保留结构(.gitkeep),数据由 scripts/generate_dataset.py 复现
152 lines
6.7 KiB
Python
152 lines
6.7 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,}")
|
||
_NAME_RE = re.compile(r"姓名[::]\s*([一-龥]{2,4})")
|
||
_YEARS_RE = re.compile(r"(\d+(?:\.\d+)?)\s*年(?:以上)?[^\n,。;]{0,6}?经验")
|
||
_GENDER_RE = re.compile(r"性别[::]?\s*(男|女)")
|
||
_EDU_RE = re.compile(r"(博士后|博士|硕士|研究生|MBA|本科|大专|专科)")
|
||
_ADDR_RE = re.compile(r"(?:家庭住址|现居(?:住)?(?:地|址)?|联系地址|通讯地址|籍贯)[::]\s*([^\n,。;]{2,30})")
|
||
|
||
# 常见简历分节标题(整行匹配,可带冒号)
|
||
_SECTION_TITLES = {
|
||
"个人信息", "基本信息", "求职意向", "教育经历", "教育背景",
|
||
"工作经历", "工作经验", "实习经历", "项目经历", "项目经验",
|
||
"技能清单", "专业技能", "核心技能", "技能特长", "技能",
|
||
"自我评价", "个人评价", "荣誉证书", "荣誉奖项", "证书",
|
||
}
|
||
|
||
|
||
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)
|
||
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 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":
|
||
from docx import Document # python-docx,requirements 已声明
|
||
doc = Document(str(path))
|
||
return "\n".join(p.text for p in doc.paragraphs)
|
||
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 parse_resume(text: str, matcher: SkillMatcher | None = None,
|
||
photo_b64: str = "") -> ResumeProfile:
|
||
"""从简历文本解析脱敏画像(含结构化基本信息与分节)。"""
|
||
matcher = matcher or SkillMatcher(load_lexicon())
|
||
name_m = _NAME_RE.search(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)
|
||
edu_m = _EDU_RE.search(text)
|
||
addr_m = _ADDR_RE.search(text)
|
||
masked = mask_text(text)
|
||
return ResumeProfile(
|
||
name=(name_m.group(1)[0] + "*" * (len(name_m.group(1)) - 1)) if name_m 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 0.0,
|
||
skills=matcher.find_names(text),
|
||
scenarios=matcher.find_scenarios(text),
|
||
raw_masked_text=masked,
|
||
gender=gender_m.group(1) if gender_m else "",
|
||
education=edu_m.group(1) if edu_m else "",
|
||
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))
|