"""简历解析:.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))