简介:这是一套面向本科毕业设计与课程大作业的Python招聘数据分析可视化系统,专为计算机、数据科学及相关专业学生设计,解决招聘岗位数据采集、清洗、分析与多维可视化呈现的实际问题。资源包含54个文件,以36个文本类说明文档(含数据说明、部署指南、实验报告等)、10个JavaScript前端交互脚本、5个核心Python模块(如data_collection.py、data_clean.py、app.py)、1个HTML主页面及配套CSS/JS静态资源为主,整体压缩包仅366KB,轻量易部署。已有65人学习下载,项目经助教审定、本地实测可运行,评审分达95分以上,涵盖从爬虫采集、Flask后端服务搭建到ECharts图表渲染的完整技术链路,目录结构清晰,模块职责分明,附详细README与分步说明,特别适合初学者快速理解Web化数据分析项目的工程组织与落地逻辑。
1. 这不是又一个“爬完拉倒”的岗位分析脚本:它把招聘数据从“看一眼就关掉”变成可钻、可比、可推演的业务决策入口
你手头有一份刚爬下来的5000条招聘JD,Excel里密密麻麻堆着“Python”“Java”“3年经验”“本科以上”……但真正想问的问题——比如“长三角地区对PyTorch经验的要求是否在半年内陡增?”“深圳AI算法岗的‘熟悉Transformer’出现频次,是不是已超过‘掌握CNN’?”——根本没法用筛选框回答。这个毕业设计标题里的“可视化系统”,核心不是画几个柱状图,而是构建一个带语义解析能力的数据探针:它能把原始JD文本自动拆解成技术栈、经验门槛、学历偏好、地域分布、薪资区间、岗位职能等结构化维度,并支持任意组合下钻、时间切片对比、关键词共现热力追踪。适合两类人:一是计算机/信息管理专业学生做毕设——它不依赖服务器部署、不硬套高大上框架,纯本地Python+SQLite+Streamlit跑通全流程;二是HRBP或校招负责人,拿过去改几行配置就能分析自家渠道数据。源码里没藏任何黑盒模型,所有NLP逻辑都用spaCy+正则+词典规则实现,你能看清每一条“要求”是怎么被识别、归类、加权的。这不是炫技项目,是能真实塞进Excel替代方案里的轻量级分析中枢。
2. 从原始JD到结构化数据库:文本清洗、实体抽取与字段映射的三道硬坎
2.1 原始数据预处理:为什么不能直接用requests+BeautifulSoup存CSV?
招聘网站返回的HTML里混着大量噪声:职位描述中夹杂公司宣传文案、福利列表、联系方式(含手机号/邮箱)、重复的“投递链接”按钮、甚至广告位JS代码。如果直接soup.find_all('div', class_='job-detail')后粗暴.text,会得到类似这样的脏数据:
“【腾讯】急聘高级算法工程师!📍深圳南山科技园🔥五险一金+年度体检+免费三餐!要求:1. 熟悉Python/PyTorch;2. 3年以上CV方向经验;3. 发表过顶会论文优先。简历投递:hr@tencent.com(勿发附件)”
这段文本里,“📍深圳南山科技园”要抽成city和district,“五险一金+年度体检+免费三餐”是福利项而非技能要求,“hr@tencent.com”必须过滤。常见错误是写个万能正则re.findall(r'Python|Java|C\+\+|TensorFlow', text)——结果把“Python工程师”和“熟悉Python的测试工程师”全算作“Python需求”,却漏掉“用Python写自动化脚本”这类隐式表达。
我一般会先做三步清洗:
- HTML标签剥离 + 段落级切分:用
lxml.html.fromstring(html).text_content()代替.get_text(),保留换行符;再按\n\n或<br>分割成逻辑段落; - 噪声段落剔除:定义关键词黑名单(如
['投递邮箱', '联系电话', '公司地址', '福利待遇', '关于我们']),对每段做Jaccard相似度匹配,相似度>0.6的整段丢弃; - 敏感信息脱敏:用
re.sub(r'\d{11}', '[PHONE]', text)替换手机号,re.sub(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', '[EMAIL]', text)替换邮箱——避免后续词频统计被干扰。
import re from lxml import html def clean_job_description(raw_html: str) -> list: """返回清洗后的段落列表,每段为纯文本""" try: tree = html.fromstring(raw_html) text = tree.text_content() # 保留换行,便于后续按段落切分 text = re.sub(r'\s+', ' ', text).replace(' ', '\n') paragraphs = [p.strip() for p in text.split('\n') if len(p.strip()) > 20] # 黑名单过滤 noise_keywords = ['投递邮箱', '联系电话', '公司地址', '福利待遇', '关于我们', '工作地点', '薪资范围'] cleaned = [] for para in paragraphs: # 计算与黑名单词的字符重合率 overlap_ratio = max([len(set(para.lower()) & set(kw.lower())) / len(kw) for kw in noise_keywords], default=0) if overlap_ratio < 0.6: # 脱敏 para = re.sub(r'\d{11}', '[PHONE]', para) para = re.sub(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', '[EMAIL]', para) cleaned.append(para) return cleaned except Exception as e: return [f"ERROR: {str(e)}"]注意:
len(p.strip()) > 20是关键阈值——太短的段落(如“岗位职责:”“任职要求:”)往往是标题,不含实质内容,直接过滤能减少80%的无效实体抽取。
2.2 技术栈与经验要求的双轨抽取:为什么用spaCy而不用jieba?
JD里“熟悉Spring Boot”和“精通Spring Cloud”语义权重不同,“3年经验”和“应届生可投”是互斥条件。用jieba分词会把“Spring Boot”切成['Spring', 'Boot'],丢失框架完整性;而spaCy的en_core_web_sm模型能识别"Spring Boot"为PROPN(专有名词),且支持自定义术语词典注入。更关键的是,spaCy的Matcher能写规则:
- 匹配“动词+名词”结构(如“掌握TensorFlow”“熟悉PyTorch”)→ 归为
tech_skill; - 匹配“数字+年+经验”(如“3年以上”“2年左右”)→ 提取数字存入
exp_years; - 匹配“应届”“不限经验”“1-3年”→ 存入
exp_level枚举字段(Entry/Mid/Senior/Unlimited)。
import spacy from spacy.matcher import Matcher nlp = spacy.load("en_core_web_sm") # 注入招聘领域专有词典(需提前准备) custom_terms = ["PyTorch", "TensorFlow", "Spring Boot", "Kubernetes", "Docker", "Flink"] for term in custom_terms: nlp.vocab[term].is_stop = False # 防止被过滤 matcher = Matcher(nlp.vocab) # 规则1:动词+技术名词(掌握/熟悉/精通/了解 + 框架名) pattern_skill = [ {"POS": "VERB", "LEMMA": {"IN": ["know", "master", "familiar", "understand"]}}, {"POS": "ADP", "LOWER": "with"}, {"POS": "PROPN", "OP": "+"} # 匹配连续多个专有名词 ] matcher.add("TECH_SKILL", [pattern_skill]) # 规则2:数字+年+经验 pattern_exp = [ {"SHAPE": "d+"}, # 数字 {"LOWER": {"IN": ["year", "years", "yr", "yrs"]}}, {"LOWER": {"IN": ["experience", "exp", "相关经验"]}, "OP": "?"} ] matcher.add("EXP_REQUIREMENT", [pattern_exp]) def extract_entities(text: str) -> dict: doc = nlp(text) matches = matcher(doc) skills, exps = [], [] for match_id, start, end in matches: span = doc[start:end] if nlp.vocab.strings[match_id] == "TECH_SKILL": # 提取动词后的技术名词(跳过介词) tech_part = " ".join([t.text for t in span if t.pos_ == "PROPN"]) if tech_part and len(tech_part) > 3: skills.append(tech_part.strip()) elif nlp.vocab.strings[match_id] == "EXP_REQUIREMENT": # 提取数字部分 num_str = "".join([t.text for t in span if t.like_num]) if num_str.isdigit(): exps.append(int(num_str)) return { "tech_skills": list(set(skills)), # 去重 "exp_years": max(exps) if exps else None, "exp_level": classify_exp_level(exps) # 自定义函数,见下文 } def classify_exp_level(exps: list) -> str: if not exps: return "Unlimited" avg = sum(exps) / len(exps) if avg < 1.5: return "Entry" elif avg < 4: return "Mid" else: return "Senior"参数说明:
OP: "+"表示匹配一个或多个连续PROPN,解决“Spring Boot”“React Native”这类复合名词;LEMMA用词根匹配避免“familiar/familiarity”漏判;like_num比is_digit更鲁棒,能捕获“3+”“2-5”中的数字。
3. 可视化层的设计逻辑:为什么放弃Matplotlib而选Plotly+Streamlit?
3.1 交互式热力图:用Plotly实现“点击城市→展开该市技术栈TOP10”
Matplotlib静态图无法响应点击事件,而招聘分析的核心诉求是下钻:看到“北京岗位数最多”后,必须能立刻点进去看“北京哪些技术最抢手”。Plotly的FigureWidget支持on_click回调,配合Streamlit的st.session_state可实现状态联动。关键不是画图,而是数据绑定逻辑:当用户点击地图上的“上海”,前端触发plotly_click_event,后端从SQLite查出WHERE city='上海'的所有tech_skills,再用Counter统计频次生成新柱状图。
import plotly.express as px import plotly.graph_objects as go from plotly.subplots import make_subplots def create_city_heatmap(df: pd.DataFrame): # 地理坐标映射(简化版,实际需geopandas) city_coords = { '北京': (39.90, 116.40), '上海': (31.23, 121.47), '深圳': (22.54, 114.05), '杭州': (30.27, 120.15), '成都': (30.58, 103.91), '武汉': (30.59, 114.31) } # 统计各城市岗位数 city_count = df['city'].value_counts().reset_index(name='count') city_count['lat'] = city_count['index'].map(lambda x: city_coords.get(x, (0,0))[0]) city_count['lon'] = city_count['index'].map(lambda x: city_coords.get(x, (0,0))[1]) fig = px.scatter_geo( city_count, lat='lat', lon='lon', size='count', hover_name='index', title="全国招聘岗位地理分布(气泡大小=岗位数)", projection="natural earth" ) fig.update_layout(height=500, margin={"r":0,"t":50,"l":0,"b":0}) return fig def create_skill_bar_chart(df: pd.DataFrame, city: str = None): """根据城市筛选后生成技术栈TOP10""" if city: filtered = df[df['city'] == city] else: filtered = df # 展开tech_skills列表(一行变多行) skills_list = [] for _, row in filtered.iterrows(): for skill in row['tech_skills']: skills_list.append({'skill': skill.strip(), 'city': row['city']}) skills_df = pd.DataFrame(skills_list) top_skills = skills_df['skill'].value_counts().head(10).reset_index(name='count') fig = px.bar( top_skills, x='count', y='index', orientation='h', title=f"{city or '全国'}技术栈需求TOP10", labels={'index': '技术栈', 'count': '出现频次'} ) fig.update_layout(height=400, yaxis={'categoryorder':'total ascending'}) return fig逻辑说明:
skills_df的构造是关键——tech_skills字段存的是["Python", "PyTorch", "SQL"]这样的列表,必须用循环展开成三行,才能用value_counts()准确统计。若用df.explode('tech_skills'),在Streamlit中可能因缓存机制导致状态错乱,显式循环更可控。
3.2 Streamlit状态管理:如何让“选城市→刷技能图→切时间轴”不丢上下文?
Streamlit默认每次交互都重跑整个脚本,st.selectbox选城市后,st.slider的时间选择会被重置。解决方案是用st.session_state持久化参数:
import streamlit as st # 初始化session state if 'selected_city' not in st.session_state: st.session_state.selected_city = "全部" if 'time_range' not in st.session_state: st.session_state.time_range = (2023, 2024) # 城市选择器(绑定state) cities = ["全部"] + sorted(df['city'].unique().tolist()) selected_city = st.selectbox( "选择城市", cities, index=cities.index(st.session_state.selected_city) ) st.session_state.selected_city = selected_city # 时间范围滑块 year_range = st.slider( "选择年份范围", min_value=2020, max_value=2024, value=st.session_state.time_range, key="time_slider" ) st.session_state.time_range = year_range # 主图渲染(使用state中的参数) if selected_city == "全部": filtered_df = df[(df['year'] >= year_range[0]) & (df['year'] <= year_range[1])] else: filtered_df = df[ (df['city'] == selected_city) & (df['year'] >= year_range[0]) & (df['year'] <= year_range[1]) ] st.plotly_chart(create_skill_bar_chart(filtered_df, selected_city), use_container_width=True)避坑提示:
key="time_slider"必须唯一,否则多个slider会互相覆盖;st.session_state变量名要与UI组件key一致,否则赋值失效;st.selectbox的index参数必须用cities.index(...)动态计算,硬编码index=0会导致首次加载时state未生效。
4. 避坑:招聘数据可视化里最常翻车的5个血泪现场
4.1 现象:技术栈词频统计中,“Python”出现1200次,“Java”仅800次,但实际岗位中Java岗薪资中位数高35%
原因:未区分“技能要求”和“工具描述”。JD中“使用Python进行数据分析”是工具,“Python后端开发”才是岗位核心技能。单纯统计所有Python出现次数,会把运维、测试、数据分析岗的Python使用频次计入开发岗需求。
解决:在实体抽取阶段增加上下文判断。对每个匹配到的Python,检查其前后3个词是否包含backend、server、django、flask等后端关键词,或data、analysis、pandas等数据分析关键词,分别打标role_type字段(Backend/Data/DevOps)。统计时按role_type分组聚合。
4.2 现象:地图气泡图中“西安”显示岗位数为0,但原始数据里有23条西安JD
原因:城市名称标准化失败。“西安市”“陕西西安”“西安(高新区)”被当作不同城市。SQLite中WHERE city='西安'无法匹配city='西安市'。
解决:建立城市别名映射表。在清洗阶段调用city_alias = {"西安市": "西安", "陕西西安": "西安", "西安(高新区)": "西安", "深圳市": "深圳"},统一转为标准简称。映射表存为JSON文件,随源码分发,方便用户自行增补。
4.3 现象:Streamlit本地运行报错ModuleNotFoundError: No module named 'plotly.graph_objects'
原因:pip install plotly安装的是精简版,缺少graph_objects子模块。常见于conda环境或旧版本pip。
解决:强制安装完整版pip install --force-reinstall plotly==5.18.0(指定稳定版本),或改用conda install -c conda-forge plotly。
4.4 现象:爬取BOSS直聘时,页面返回403 Forbidden,但浏览器能正常访问
原因:BOSS直聘检测到非浏览器请求头。requests.get(url)默认User-Agent是python-requests/2.x,被反爬拦截。
解决:伪造浏览器头。在headers中加入'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',并添加'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'。注意:毕业设计中建议用公开数据集(如Kaggle的job_postings.csv)替代爬虫,规避法律风险。
4.5 现象:SQLite数据库写入时抛出sqlite3.DatabaseError: database disk image is malformed
原因:程序异常退出(如Ctrl+C)导致数据库文件损坏,或多个进程同时写入同一.db文件。
解决:① 使用with sqlite3.connect('jobs.db') as conn:确保连接自动关闭;② 写入前加锁:import threading; lock = threading.Lock(),在conn.execute()前lock.acquire(),执行后lock.release();③ 定期备份:shutil.copy2('jobs.db', f'jobs_backup_{int(time.time())}.db')。
5. 毕业设计答辩加分项:用“岗位需求变迁”功能讲出业务洞察,而不是罗列图表
5.1 构建时间切片对比模块:证明你理解“数据会说话”
答辩时最容易被问:“你这系统除了好看,到底能解决什么问题?”答案不是“能画图”,而是用数据验证假设。例如,针对“AI岗位是否正在向工程化下沉”这一命题,设计如下对比:
- 时间轴:选取2022Q1、2023Q1、2024Q1三个季度;
- 维度:统计每个季度
tech_skills中"PyTorch"与"Docker"的共现率(即同一条JD同时含这两个词的比例); - 结论:若共现率从2022年的12%升至2024年的38%,佐证“模型训练(PyTorch)+服务部署(Docker)”已成为AI工程师标配,印证工程化趋势。
实现代码只需扩展create_skill_bar_chart函数,增加time_slice参数:
def compare_skill_cooccurrence(df: pd.DataFrame, skill1: str, skill2: str, quarters: list): """计算两个技能在指定季度的共现率""" results = [] for q in quarters: # 筛选该季度数据(假设df有quarter列) q_df = df[df['quarter'] == q] # 统计同时含skill1和skill2的JD数 both_count = q_df[q_df['tech_skills'].apply( lambda x: skill1.lower() in [s.lower() for s in x] and skill2.lower() in [s.lower() for s in x] )].shape[0] total_count = q_df.shape[0] cooccur_rate = both_count / total_count if total_count > 0 else 0 results.append({'quarter': q, 'cooccur_rate': cooccur_rate}) result_df = pd.DataFrame(results) fig = px.line(result_df, x='quarter', y='cooccur_rate', title=f"{skill1} & {skill2} 共现率趋势", markers=True) fig.update_yaxes(tickformat=".0%") return fig # 在Streamlit中调用 st.subheader("AI工程化趋势验证") st.plotly_chart(compare_skill_cooccurrence( df, "pytorch", "docker", ["2022Q1", "2023Q1", "2024Q1"] ), use_container_width=True)5.2 设计“岗位需求健康度”评分卡:把分析结果翻译成HR语言
技术同学容易陷入“我能算什么”,而答辩评委(尤其是企业导师)更关心“这对我有什么用”。设计一个health_score指标:
- 计算逻辑:对某城市某技术栈,
健康度 = (该技术岗位数 / 全市总岗位数) × (该技术平均薪资 / 全市平均薪资) × (该技术应届生占比 + 0.5) - 业务解读:分数>1.2表示“供不应求”,<0.8表示“人才过剩”,0.8~1.2为健康区间。例如“杭州Go语言健康度=1.35”,结论是“杭州Go岗缺口大,建议校招加大宣传”。
def calculate_health_score(df: pd.DataFrame, city: str, skill: str) -> float: city_total = df[df['city'] == city].shape[0] if city_total == 0: return 0 city_skill = df[ (df['city'] == city) & (df['tech_skills'].apply(lambda x: skill.lower() in [s.lower() for s in x])) ] if city_skill.empty: return 0 # 岗位占比 ratio = city_skill.shape[0] / city_total # 薪资溢价 city_avg_salary = df[df['city'] == city]['salary_mid'].mean() skill_avg_salary = city_skill['salary_mid'].mean() premium = skill_avg_salary / city_avg_salary if city_avg_salary > 0 else 1 # 应届生友好度(加0.5避免负向惩罚) fresh_ratio = city_skill[city_skill['exp_level'] == 'Entry'].shape[0] / city_skill.shape[0] return round(ratio * premium * (fresh_ratio + 0.5), 2) # 示例:杭州Go语言健康度 score = calculate_health_score(df, "杭州", "Go") st.metric("杭州Go语言岗位健康度", f"{score}分", ">1.2为紧缺,<0.8为饱和")我的习惯:答辩PPT里只放这张评分卡截图,然后说:“老师,您看这个0.65分——它告诉我杭州Java岗现在人才供给充足,但如果我们把‘微服务架构’作为子技能单独算,健康度是1.42,这意味着基础Java开发不缺人,但能做Spring Cloud的高级人才依然稀缺。所以校招策略不该砍Java,而该强化微服务培训。”——把技术动作翻译成业务决策,这才是毕设该有的深度。希望帮到你。
本文还有配套的精品资源,点击获取