简介这是一份面向计算机相关专业课程设计或毕业设计场景的Python智能简历解析系统源码包聚焦简历解析与人岗匹配两大核心任务。系统通过设计合适的prompt调用Grok-beta大模型将非结构化简历转成结构化JSON数据人岗匹配则结合语义相似度与结构化数据相似度使用google-bert/bert-base-chinese预训练模型完成适合需要快速搭建AI简历处理原型的开发者参考。压缩包共9个文件以6个Python脚本为主辅以yaml配置、txt停用词表和md说明文档整体仅19KB代码精简、便于阅读与二次开发。目前已有75人学习下载。资源价值在于提供完整可运行的项目骨架涵盖模型调用、提示词组织、匹配逻辑与配置分离等关键环节可直接作为课程设计报告配套源码或功能模块起点帮助理解大模型与预训练模型在真实任务中的落地方式。1. 智能简历解析系统源码拿到手第一件事是看解析链路上周还有人问我简历解析是不是就是 PDF 转文字、然后拿正则抠电话等他把一套基于 python 实现的智能简历解析系统源码交到手里才发现真正的工作量在字段抽取和人岗匹配。这套课程设计/毕业设计源码把简历上传、docx/pdf/txt 解析、电话邮箱学历技能提取、岗位匹配打分串成了一条能跑通的链路它适合两类人一是要交课程设计或毕业设计的在校生二是想用 python 做 NLP 工程化入门、需要一个可改可扩展源码的从业者。先看整体链路再动手跑后面几章会拆解析、匹配、目录和避坑。2. 简历解析文件读进来只是第一步字段抽出来才算数简历解析通常分两层文本抽取层负责把各种格式的文件变成纯文本字段抽取层负责从纯文本里抠出结构化信息。课程设计答辩时老师最常问的就是“你从 PDF 里抽出文字之后怎么知道哪个是电话、哪个是学历”所以这一章把两层拆开讲每层都给可以抄走的代码。2.1 文本抽取层docx、pdf、txt 不是一套代码docx 本质是 XML 压缩包要用 python-docx 解析PDF 的文本层提取要用 pdfminer.sixtxt 则直接按编码读。三种格式不能共用一套解析器所以入口函数先按后缀分流import os from docx import Document from pdfminer.high_level import extract_text def extract_text_from_file(path: str) - str: 根据后缀选择解析器返回纯文本。 if not os.path.exists(path): raise FileNotFoundError(f简历文件不存在: {path}) ext os.path.splitext(path)[-1].lower() if ext .docx: doc Document(path) chunks [p.text for p in doc.paragraphs] # 中文简历大量使用表格排版只读 paragraphs 会丢内容 for table in doc.tables: for row in table.rows: for cell in row.cells: chunks.append(cell.text) return \n.join(chunks) if ext .pdf: return extract_text(path) if ext .txt: with open(path, r, encodingutf-8, errorsignore) as f: return f.read() raise ValueError(f暂不支持的简历格式: {ext})代码里有两处参数值得注意。errorsignore是为了兼容 Windows 下部分编码不规范的 txt 文件宁可丢几个字符也不能让整个解析中断doc.tables这段则是处理“教育背景写在表格里”的情况。python-docx 默认只暴露 paragraph 流表格是独立对象不遍历 table 就会漏掉大量有效信息。PDF 用pdfminer.high_level.extract_text提取文本层好处是它对中文和英文混排的容错比早期 PyPDF2 好基本不丢空格。拿到的结果是完整文本后面字段抽取直接消费它。2.2 字段抽取正则表达式处理电话、邮箱、学校简历里的联系方式格式高度固定适合用正则而不是模型。最常见的两个坑手机号可能藏在身份证号附近邮箱可能出现多次。看下面的实现import re PHONE_RE re.compile(r(?!\d)(1[3-9]\d{9})(?!\d)) EMAIL_RE re.compile(r[A-Za-z0-9._%-][A-Za-z0-9.-]\.[A-Za-z]{2,}) EDU_TRIGGER (大学, 学院, 本科, 硕士, 博士, MBA, EMBA) def safe_text_for_phone(text: str) - str: # 身份证号、交易流水号都是长数字先抹掉避免手机号正则误命中 return re.sub(r\d{15,}, , text) def extract_phones(text: str): return PHONE_RE.findall(safe_text_for_phone(text)) def extract_emails(text: str): return list(set(EMAIL_RE.findall(text))) def extract_education(text: str): education_lines [] for line in text.splitlines(): line line.strip() if any(key in line for key in EDU_TRIGGER) and len(line) 30: education_lines.append(line) return education_lines正则里的(?!\d)是负向零宽断言意思是不允许匹配位置前面还有数字(?!\d)则是后面不能再跟数字。配上1[3-9]的号段限制基本上能把座机号、订单号挡在门外。邮箱正则先按标准格式写最后用set去重因为很多人会把邮箱写在页眉和结尾两处。教育背景抽取没有用 NER 模型而是用触发词加行长度过滤。原理是简历里学校、学历信息一定会落在包含“大学/学院/本科/硕士”这些词的行上而且这一行通常很短。课程设计的简历样本量不大触发词方案远比训练一个命名实体识别模型可控答辩时也更好解释。2.3 技能词抽取词典优先分词辅助技能抽取是人岗匹配的前置步骤。常见做法是准备一份技能词库再用 jieba 分词做匹配。但 jieba 对英文加符号的写法不友好例如C会被拆成C和所以技能抽取我一般做两路兜底import jieba def load_skills(skill_path: str config/skills.txt): skill_db set() with open(skill_path, r, encodingutf-8) as f: for line in f: line line.strip() if line and not line.startswith(#): skill_db.add(line.lower()) return skill_db def extract_skills(text: str, skill_db: set) - set: # 第一路分词后命中词库 jieba.load_userdict(config/userdict.txt) cut_hit set() for word in jieba.cut(text): w word.lower().strip() if w in skill_db: cut_hit.add(w) # 第二路原文子串命中兜住 C、C# 这类带符号的技能词 lower_text text.lower() text_hit set() for skill in skill_db: if skill in lower_text: text_hit.add(skill) return cut_hit | text_hit第一路用jieba.cut把中文句子切成词然后和技能词库求交集第二路直接判断词库里的技能名是不是原文字串的子串。C虽然切不开但c in lower_text能兜底。代价是“Python3”会同时命中“python”但对课程设计来说误报比漏报好因为人岗匹配阶段还会按权重打分。load_userdict是 jieba 的用户自定义词典接口可以在不修改源码的情况下补充专业词汇。技能词库本身用纯文本config/skills.txt维护每行一个技能#开头的行是注释方便在不同课程设计之间复用。2.4 把解析结果组装成结构化字典前面几节都是散装函数实际调用时要统一出口。解析链路最后返回一个字典后面的匹配模块只认这个结构def parse_resume(path: str) - dict: text extract_text_from_file(path) phones extract_phones(text) emails extract_emails(text) education extract_education(text) skills extract_skills(text, load_skills()) return { name: guess_name(text), phone: phones[0] if phones else , email: emails[0] if emails else , education: education, skills: sorted(skills), raw_text: text[:2000], }guess_name是另外写的一个小函数常见策略是优先匹配“姓名张三”这种显式写法匹配不到就取前几行里最不像句子的那个词。raw_text截断到 2000 字符是为了控制后面 TF-IDF 的计算量也避免把整份简历塞进前端页面。到这里简历解析的中间产物已经出来了。下一步的人岗匹配直接消费这个字典不需要再关心文件格式。3. 人岗匹配词集加权打底TF-IDF 做兜底人岗匹配是这套源码的第二个核心模块。课程设计场景下不太建议一上来就上 BERT 之类的预训练模型环境依赖重、推理慢、答辩时还不容易说清楚“为什么这个分数是 73 而不是 80”。更务实的做法是词集加权打底TF-IDF 相似度兜底两条线合并出一个最终分数。3.1 为什么不盲目上 BERT不是 BERT 不行而是简历解析系统的定位决定了解释性优先。用规则匹配和词频统计每一分都能追溯这份简历匹配到flask得 1.0 分没匹配到docker不得分。老师问起来你直接把打分公式打印出来就行。换成 BERT 的嵌入向量输出的是一个黑匣子分数出了问题反而难排查。另外课程设计常用机器配置跑不动大模型。用transformers加载一个中文 BERT 需要 400MB 左右的显存还在用 CPU 跑的同学会被一次推理卡几十秒。技能词匹配不到一秒就出结果体验完全不一样。3.2 技能加权命中打分简单的技能重合度只看有没有不看权重。实际招聘里“熟悉 Python”和“熟悉 Python 并有过 Flask 项目经验”是两个层级所以我一般会给不同技能配不同权重DEFAULT_WEIGHT 0.5 skill_weights { python: 1.2, flask: 1.0, django: 1.0, mysql: 0.9, redis: 0.8, nlp: 1.0, } def match_by_skills(resume_skills: set, job_skills: list, weight_map: dict None) - float: weight_map weight_map or skill_weights job_skills set(job_skills) hit set(resume_skills) job_skills total_weight sum(weight_map.get(s, DEFAULT_WEIGHT) for s in job_skills) hit_weight sum(weight_map.get(s, DEFAULT_WEIGHT) for s in hit) if total_weight 0: return 0.0 return round(hit_weight / total_weight * 100, 2)分母用的是岗位要求技能的总权重而不是简历技能的总权重。这么设计是为了防“堆技能”候选人简历里写 50 个技能如果分母是简历侧的总权重他靠量也能堆出高分。分母固定到岗位侧后简历多写无关技能不再加分。DEFAULT_WEIGHT是给词库里没配权重的冷门技能用的默认值。权重表本身存在config/skill_weights.json里方便换专业方向时调整。例如招 Java 工程师时把spring调高到 1.3把flask降到默认值即可。3.3 TF-IDF 余弦相似度兜底技能词匹配解决的是“关键词是否出现”但它不解决“说法不同但意思相近”的问题。比如岗位要求写“文本挖掘”简历里写“文本分类”技能词库一个都没命中但人工一看就知道相关。这里我用 TF-IDF 做第二路匹配from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity import jieba def jieba_tokenizer(text: str): return [w for w in jieba.cut(text) if w.strip()] def semantic_similarity(resume_text: str, job_text: str) - float: corpus [resume_text, job_text] vectorizer TfidfVectorizer(tokenizerjieba_tokenizer, ngram_range(1, 2)) tfidf vectorizer.fit_transform(corpus) sim cosine_similarity(tfidf[0:1], tfidf[1:2])[0][0] return round(float(sim) * 100, 2)TfidfVectorizer的tokenizer参数接收一个可调用对象我把 jieba 分词包装进去这样向量化时按中文词而不是按空格切分。ngram_range(1, 2)表示不仅统计单个词也统计相邻两个词的组合这样“Python 爬虫”会被当成一个特征简历里写“爬虫”也能和它产生关联。TF-IDF 的分数不能直接当最终匹配分因为它对文本长度敏感。一段 500 字的简历和一段 3000 字的岗位描述做余弦相似度绝对值普遍偏低更适合作为辅助信号。3.4 两路分数合并成岗位匹配报告最终分数我按 7:3 合并技能命中占 70%语义相似度占 30%def match_report(resume_dict: dict, job_requirement: dict) - dict: resume_skills set(resume_dict[skills]) job_skills job_requirement[skills] kw_score match_by_skills(resume_skills, job_skills) sem_score semantic_similarity(resume_dict[raw_text], job_requirement[text]) final_score round(kw_score * 0.7 sem_score * 0.3, 2) matched list(resume_skills set(job_skills)) missing list(set(job_skills) - resume_skills) return { score: final_score, keyword_score: kw_score, semantic_score: sem_score, matched_skills: matched, missing_skills: missing, }返回结果里的matched_skills和missing_skills是给前端展示用的。匹配分数只是一个数值简历和岗位描述能不能匹配最终的差异点全在这个列表里。如果岗位要求包含docker而简历没有页面就会把这一项标红这就是这个系统能解释得清楚的地方。4. 课程设计源码目录与五步跑起来这套源码默认组织成一个 Flask 项目。源码拿到手之后别急着改代码先把目录结构对一遍知道自己要动哪个文件再启动起来。4.1 目录里都有什么文件/目录作用app.pyFlask 入口负责页面上传和接口路由resume_parser.py简历解析核心文本抽取、字段抽取、技能抽取matcher.py人岗匹配技能加权和 TF-IDF 两路打分config/技能词库、岗位要求 JSON、权重配置templates/前端页面模板展示匹配报告static/样式和前端脚本samples/测试用简历样本docx/pdf/txt 各一份requirements.txt依赖库清单app.py和两个核心模块是骨架config/是业务数据的入口。课程设计答辩时老师很可能问你“不修改代码的情况下怎么增加一个岗位”答案就是在config/jobs/下新增一个 JSON 文件里面写岗位名称、技能要求和岗位描述。这也是整套源码设计得比较合理的地方数据与逻辑分离。4.2 环境准备与启动按下面的命令走一遍cd 智能简历解析系统源码 python -m venv venv # Windows 用户执行: venv\Scripts\activate # macOS/Linux 用户执行: source venv/bin/activate pip install flask python-docx pdfminer.six jieba scikit-learn python app.pypip install后面没有写死版本号是建议你用当前环境最新的兼容版本。如果遇到某个库装不上多半是 Python 版本太老3.8 以下环境建议先升级。装完后终端会输出一个本地地址默认是http://127.0.0.1:5000/浏览器打开这个地址就能看到上传页面。第一次启动如果报模块找不到优先检查有没有激活虚拟环境。Windows 下python -m venv venv之后直接用python app.py可能调用的还是全局 Python要确保命令行前缀行有(venv)出现。4.3 用接口做一次完整联调页面能跑通之后可以用命令行模拟前端上传curl -X POST http://127.0.0.1:5000/api/match \ -F resume./samples/resume_python.pdf \ -F job_idpython_engineerjob_id对应config/jobs/python_engineer.json里的岗位要求。如果你习惯用 python 脚本调接口也可以写一段 requests 代码import requests files {resume: open(./samples/resume_python.pdf, rb)} resp requests.post( http://127.0.0.1:5000/api/match, data{job_id: python_engineer}, filesfiles ) print(resp.json())这个接口返回的就是上一章match_report的结果总分、关键词分、语义分、命中技能、缺失技能。前端拿到 JSON 后把匹配分数画成环形图把缺失技能列成红色标签这就是一个完整的课程设计展示闭环。5. 简历解析系统的常见问题与避坑指南下面每条都是我在实际跑源码时遇到过的问题。课程设计阶段看到这些报错不要慌多数不是代码坏了而是输入文件或运行环境的问题。5.1 扫描版 PDF 解析结果为空现象上传了一份 PDF解析出来的文本是空字符串姓名电话全部缺失。原因这份 PDF 是扫描件本质是图片没有文本层。pdfminer.six只能提取文本层内容拿不到图片里的字。解决先确认 PDF 是否能复制文字。对于纯扫描件需要接入 OCR。常见做法是用pdf2image把 PDF 转成图片再用pytesseract识别from pdf2image import convert_from_path import pytesseract pages convert_from_path(扫描件.pdf, dpi200) text \n.join( pytesseract.image_to_string(page, langchi_simeng) for page in pages )OCR 识别后的文本错字率比原生 PDF 高不少课程设计里可以在上传页面提示“建议上传带文本层的 PDF”避免解析效果翻车。5.2 docx 表格内容丢失现象解析 docx 简历自我评价、项目经历都读出来了唯独“教育背景”是空的。原因很多中文简历是把“教育背景”“工作经历”放在两列表格里而代码只遍历了doc.paragraphs表格是独立对象根本不会被扫到。解决在文本抽取层加上对doc.tables的遍历把每个 cell 的文本追加到结果中。这也是我在第二章节特别强调这段代码的原因。5.3 手机号正则匹配到一串奇怪数字现象解析出来的手机号有 14 位或者跟身份证号前后重叠。原因手机号正则是按“1 开头 11 位数字”匹配的而身份证号里有连续数字段订单号甚至回执单号也可能触发匹配。解决在匹配手机号前先把\d{15,}的连续长数字替换成空格再做findall。这样可以挡住身份证。匹配结果里如果仍有异常长度再做一次len(number) 11过滤。5.4 技能词库不命中 C/C#现象简历里明明写了“熟悉 C”匹配报告里的matched_skills就是没有 C。原因jieba 分词默认把C切成了C、、技能词库里的c是一个完整词切分后的小块凑不齐完整技能名。解决两路兜底里保留“原文字串命中”这一路同时在config/userdict.txt里加入C 10 nz C# 10 nz10是词频权重nz是自定义词性标签。加入用户词典后C会被 jieba 当做一个完整词保留分词命中和子串命中两路都能对得上。5.5 Windows 中文路径引发接口 500现象上传“张三-简历.pdf”后接口直接返回 500控制台报文件路径相关的 UnicodeEncodeError。原因Windows 下用os.path.join拼接含中文的文件路径时工作目录字符集和请求里的 UTF-8 编码不一致导致保存文件失败。解决上传文件不要用原文件名保存。改为时间戳加随机后缀import time import uuid new_name fresume_{time.strftime(%Y%m%d%H%M%S)}_{uuid.uuid4().hex[:6]}.pdf原文件名单独存一份用它做页面展示不让它直接进文件系统。从那以后我每次写上传接口都强制走这个流程少了很多 Windows 下莫名其妙的路径问题。6. 进阶自己扩展技能词库、改权重、跑回归验证源码能跑通只是开始课程设计和实际工作之间还差一个“按需求改配置”的能力。这里给两个实战技巧扩展同义词、批量回归验证。技能词库抽出来最大的问题就是覆盖不全。与其堆词不如做一层同义词映射例如“NLP”“自然语言处理”“文本挖掘”归到同一组# config/skill_aliases.json { nlp: [nlp, 自然语言处理, 文本挖掘], python: [python, python3, py], mysql: [mysql, my sql] }解析技能时先查原始命中再走一遍别名映射把等价技能统一成标准名import json with open(config/skill_aliases.json, encodingutf-8) as f: skill_aliases json.load(f) def normalize_skill(raw_skills: set) - set: normalized set(raw_skills) for std_name, alias_list in skill_aliases.items(): if raw_skills set(alias_list): normalized.add(std_name) return normalized代码逻辑很简单只要原始技能里命中了任一同义词就把标准名也加进技能集合。这样岗位要求里写自然语言处理简历里写文本挖掘最后匹配时都会被归一化成nlp技能命中分不会漏掉。改完词库之后不能只靠一两个样本看效果要写批量回归脚本from resume_parser import parse_resume from matcher import match_report cases [ { path: samples/resume_python.pdf, job_id: python_engineer, expect_gt: 60, name: 应届Python开发 }, { path: samples/resume_java.pdf, job_id: python_engineer, expect_lt: 40, name: Java转行简历 } ] for case in cases: resume parse_resume(case[path]) job load_job(case[job_id]) result match_report(resume, job) score result[score] passed score case[expect_gt] if expect_gt in case else score case[expect_lt] print(case[name], score, PASS if passed else FAIL)这段脚本把“该匹配上的”和“不该匹配上的”都设成用例每次改完词库、调完权重都跑一遍。从那以后我每次调匹配逻辑都强制走一遍这个回归流程确认旧的没坏、新的有效希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?