Files
CareerNexus/app/match/resume.py
T
RoyirinRosmontis a10442d78c CareerNexus 职联:岗位能力图谱构建与动态演化分析平台(挑战杯 XH-202621)
- 全链路:多源JD接入/清洗交叉验证/结构化抽取(星火+离线双通道)/时序知识图谱/新岗位发现/演化分析/人岗匹配/学习路径
- WebUI 五视图:驾驶舱/图谱工作台/岗位演化/匹配(单人+批量)/学习路径
- 设置中心(config.json)/Token鉴权/SQLite状态库/语义向量通道(BGE-M3本地或联网API)
- 71 个 pytest 用例;数据目录仅保留结构(.gitkeep),数据由 scripts/generate_dataset.py 复现
2026-08-03 13:15:35 +08:00

152 lines
6.7 KiB
Python
Raw 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,}")
_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))