
简介面向计算机专业课程设计与期末大作业的Python招聘数据爬虫与可视化分析系统源码覆盖爬虫采集、数据清洗、存储与图表展示完整流程适合具备基础Python语法、希望完整走通数据项目流程的学习者。整套源码压缩包共73个文件约10.48MB以Python脚本.py/.pyc为主辅以SQL建表脚本、Excel城市坐标文件、Flask前端页面HTML/CSS/JS、演示文稿和备份文件目录中涵盖腾讯招聘与51job平台爬虫、数据处理及可视化分析模块。项目从抓取招聘岗位数据开始经过城市坐标匹配、MySQL建表入库最终生成多张可视化图表并可通过Web页面交互查看SQL脚本提供不同数据规模的建表与导入版本方便按需求选用。资源已系统调试可直接部署运行适合作为毕业设计或实践训练的参考资料目前已有28人学习下载。1. 招聘数据爬虫最容易死在“只爬不析”说个挺扎心的现状很多人做招聘数据爬虫requests一写、页面一抓上万条岗位数据到手最后却只能导出一张 Excel 表格。数据没清洗薪资单位混着放可视化就是把几个数字贴到 PPT 里——这离一个“系统”还差得远。这套 Python 招聘数据爬虫分析与可视化系统核心是把完整链路跑通爬虫采集、pandas 清洗、SQLite 存储、统计分析、ECharts 展示。它能解决什么求职者看城市和岗位的真实薪资分布HR 摸竞对岗位结构学生党拿它当综合实战项目。适合谁有 Python 基础但没做过完整闭环项目的人以及想在简历里放一个业务系统的人。2. 系统拆解采集、清洗、存储、展示四条链路怎么串2.1 数据流与模块边界把系统拆开看它一共四段采集层拿原始 HTML 或 JSON清洗层做字段归一化存储层落库展示层出图表。很多教程把爬虫和分析写在一个脚本里跑完就完事但招聘数据场景不行——招聘网站的页面结构、反爬字段、薪资写法一直变今天能跑通的 xpath下周可能就失效。所以我建议模块边界从一开始就切清楚spider.py 只负责拿数据cleaner.py 只负责洗数据analysis.py 只负责算指标app.py 和前端只负责展示。这样哪个环节坏了打开对应文件就能修不用在三千行里找 bug。数据流向是单向的spider 把原始字段落进数据库cleaner 从库里读出来清洗后再更新回去analysis 基于清洗后的表算聚合结果前端接口直接读聚合 JSON。这里有一个很多人忽视的决策原始数据一定要保留。我见过太多人清洗时直接覆盖原字段后面想换一种薪资归一化规则数据已经回不去了。保留原始字段等价于给自己留了后悔药代价只是数据库多几列非常值得。2.2 模块拆到什么粒度才合适招聘数据爬虫系统不需要做成微服务那是过度设计。我一般维持六个文件config.py 放请求头、URL 模板、爬取关键词和城市列表spider.py 管请求和解析cleaner.py 管字段清洗analysis.py 管聚合统计app.py 起 Flask 服务前端模板 templates/index.html 画图表。配置单独放一个文件非常重要因为招聘网站的反爬参数和搜索条件经常要调没有 config.py每次改参数都要翻代码。选型上爬虫端用 requests 就够了不需要一上来就上 Scrapy 或分布式爬虫。招聘数据这个体量单机单线程跑一晚上也就几万条Scrapy 的学习和维护成本远超收益。只有当你需要同时爬几十个城市、并且目标网站封禁策略比较激进时再考虑 Scrapy 的并发调度——注意我说的是“同时爬几十个城市”不是“数据量大就要分布式”。数据量大用数据库和去重就能解决分布式解决的是请求频率问题。2.3 存储选型为什么默认 SQLite 而不是 MySQL几万条招聘岗位数据SQLite 一个文件就能装下零部署、零配置跑完直接把 db 文件拷贝走分析环境也能直接用。MySQL 在这个规模下没有优势还要装服务、建账号、配权限纯粹给自己增加工作量。SQLite 唯一的坑是并发写锁但爬虫是单进程顺序写根本不触发这个问题。表结构我习惯这样设计CREATE TABLE jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, job_id TEXT UNIQUE, title TEXT, company TEXT, city TEXT, salary_raw TEXT, salary_min NUMERIC, salary_max NUMERIC, exp_req TEXT, edu_req TEXT, tags TEXT, description TEXT, crawled_at TEXT );job_id 字段是去重的关键它来自列表页里每个岗位卡片对应的唯一标识。很多招聘网站列表页的 job_id 和详情页 ID 是同一个但也不一定爬虫里要确认清楚。UNIQUE 约束加上之后重复插入会直接报错配合代码里的“先查再插”或“INSERT OR IGNORE”就能挡住绝大多数重复数据。description 字段存详情页拿到的完整职位描述这是后续做关键词分析的数据源。3. 爬虫落地写法两段式抓取、请求头配置与 xpath 边界3.1 从列表页到详情页的两段式抓取招聘网站的信息分布有个共同特点列表页只有岗位名称、公司、城市、薪资字符串这些概要字段完整的职位描述和任职要求要进详情页才有。如果只爬列表页后续分析只能做薪资和城市分布做不了技能词频和岗位职责分析如果每个岗位都从列表页直接拼详情页 URL又需要额外请求。所以标准做法是两段式先请求搜索页拿到岗位列表和 job_id再对每个 job_id 请求详情页拿 description。URL 模板每家网站都不一样而且经常改版我不建议照抄任何教程里的固定地址。正确做法是打开目标网站搜索一个岗位把地址栏里页码、关键词、城市对应的参数抽出来替换成模板。下面这段代码是我习惯的写法URL 模板按实际站点替换即可# spider.py —— 列表页请求与解析 import time import random import requests from lxml import html LIST_URL_TEMPLATE https://search.targetsite.com/list/{city},{keyword},{page}.html DETAIL_URL_TEMPLATE https://detail.targetsite.com/job/{job_id}.html UA (Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36) session requests.Session() session.headers.update({User-Agent: UA}) def fetch_list(city, keyword, page): url LIST_URL_TEMPLATE.format(citycity, keywordkeyword, pagepage) resp session.get(url, timeout10) resp.encoding resp.apparent_encoding return resp.text def parse_list(html_text): doc html.fromstring(html_text) items [] # 以目标站岗位卡片节点为例class 按实际页面调整 nodes doc.xpath(//div[contains(class,job-list-item)]) for node in nodes: job_id node.xpath(.//a/data-jobid) title node.xpath(.//span[contains(class,job-title)]/text()) company node.xpath(.//a[contains(class,company-name)]/text()) city node.xpath(.//span[contains(class,job-city)]/text()) salary node.xpath(.//span[contains(class,job-salary)]/text()) if not (job_id and title and company and city and salary): continue items.append({ job_id: job_id[0].strip(), title: title[0].strip(), company: company[0].strip(), city: city[0].strip(), salary_raw: salary[0].strip(), }) return items这段代码的逻辑fetch_list 负责发请求parse_list 负责从 HTML 里抽字段。注意 xpath 返回的都是列表即使只有一个匹配也要先取 [0]再判空。strip() 必须加招聘页面里的中文文本经常带着换行和空格不清理会让后续 groupby 出现一堆“北京 ”和“北京”两个城市。session 对象在整个爬虫生命周期里复用好处是复用 HTTP 连接速度快而且部分站点会校验 Cookie 连续性Session 能保存登录态。3.2 requests 爬虫的请求头、超时与频控参数头信息里最关键的是 User-Agent、Referer、Cookie 三个。User-Agent 用来伪装成浏览器Referer 告诉服务端你是从哪个页面跳过来的缺了它不少网站直接返回 403。Cookie 在未登录场景下一般不需要主动带requests 的 Session 会自己保存服务器下发的 Cookie但如果目标站第一次访问就要求 Cookie就需要从浏览器复制一份到 config.py。超时和重试是必配的。requests.get 不设 timeout 的话连接卡住时整个脚本会挂在那里看起来很像是被反爬封了实际上是网络层问题。重试我习惯用 requests 自带的 HTTPAdapter 封装from requests.adapters import HTTPAdapter adapter HTTPAdapter(max_retries2) session.mount(http://, adapter) session.mount(https://, adapter)max_retries2 表示连接失败时重试两次不是请求失败重试两次。要区分这两个场景连接失败重试是安全的请求被拒绝后盲目重试反而更容易触发封禁。频控方面我的习惯是每条请求之间随机 sleep 0.5 到 1.5 秒单机就好别拉满并发。招聘站的反爬阈值通常不高曾经用 10 个线程去爬二十分钟后返回的全是验证码页。这个项目不需要追求速度稳定跑完才是目标。3.3 xpath 提取岗位字段text() 函数与节点边界xpath 的 text() 函数是爬虫解析里最容易被问到的点。很多新手写 .//a/text() 发现只能拿到一部分文本或者拿到一串空白原因是对 text() 的语义理解不够text() 只取当前节点的直接文本节点不包含子标签里的文本。比如一个岗位标题是“高级 Python 开发工程师”但 HTML 结构是 高级 Python 开发工程师 直接用 .//a/text() 拿到的是“高级 ”和“开发工程师”中间的“Python”跑到 span 里去了顺序还会被拆散。解决办法有两个。第一个是把 text() 换成 .//a//text()它拿的是所有后代文本节点的集合再用 join 拼起来。第二个更稳妥用 string(.//a) 或者 normalize-space()def get_text(node, xpath_expr): parts node.xpath(xpath_expr //text()) return .join(parts).strip()注意这里拼完之后要 strip因为列表页文本节点之间经常夹着换行和制表符。如果拼出来还是乱的优先检查目标标签内部是不是用了多个子标签。我见过最典型的问题是薪资字段写成“10K-15K”但 HTML 里是两个独立的 span每个 span 里有自己的 text()这种情况用 contains 判断父节点再拼子节点比硬写 xpath 表达式稳妥得多。node.xpath(.//a/text()) 返回的是字符串列表node.xpath(.//a/data-jobid) 返回的是属性值列表这两种写法是爬虫里每天都要用的。记住一个原则xpath 永远返回列表不要直接用索引方式去拼到字段上先判空再取第一个值。3.4 去重与断点续爬写去重逻辑前先确认 job_id 是不是真的唯一。有些招聘网站的列表页 URL 里带的是搜索 ID同一个岗位在不同关键词下会生成不同 ID以它做唯一键会漏数据。稳妥做法是把“job_id company title”拼起来做复合键。数据库层面加上 UNIQUE 约束代码层面再挡一道seen set() for job in parse_list(html_text): dedup_key f{job[job_id]}|{job[company]}|{job[title]} if dedup_key in seen: continue seen.add(dedup_key) save_to_db(job)断点续爬的好处是爬虫跑到一半挂了重新启动时不用从头爬。实现思路请求详情页之前先查库里有没有这个 job_id有就跳过。这样一来爬虫进度就持久化在数据库里了而不是内存里的 set。这也意味着抓列表页和抓详情页要分开跑列表页更新得快详情页爬得慢分开跑才能利用断点续爬。4. 清洗与分析把“10-15K·13薪”变成可统计的数值4.1 薪资字段归一化单位识别是核心招聘网站的薪资写法五花八门“10-15K”“1-1.5万/月”“15-30万·14薪”“200元/天”“面议”。如果不统一单位pandas 算出来是一堆字符串排序和求均值全乱。归一化思路分三步先抽数字区间再识别单位最后换算成“元/月”。有一个坑要提很多教程只处理“K”和“万”遇到“万/年”就直接当“万/月”算导致年薪岗位的月薪虚高三倍。薪资归一化代码import re def parse_salary(raw): if not raw or 面议 in raw: return None, None nums re.findall(r\d\.?\d*, raw) if len(nums) 2: return None, None lo, hi float(nums[0]), float(nums[1]) if 万 in raw: # 出现“年”或“薪”比如 15-30万·14薪、20-40万/年按年薪处理 if 年 in raw or 薪 in raw: lo, hi lo * 10000 / 12, hi * 10000 / 12 else: lo, hi lo * 10000, hi * 10000 elif k in raw.lower(): lo, hi lo * 1000, hi * 1000 elif 千 in raw: lo, hi lo * 1000, hi * 1000 elif 天 in raw: # 按每月 22 个工作日粗算只写 ROUND 不写死 lo, hi lo * 22, hi * 22 return round(lo), round(hi)分隔符和单位识别是两个独立环节。先用 re.findall 把数字全抽出来第一个是下限第二个是上限再用中文字符判断单位。判断顺序很重要必须先判断“万”再判断“K/千”因为“万”的字符串里也可能出现字母比如“XX万起”不常见但“15-30万·14薪”是常见句式。单位识别之后计算 min 和 max 的均值作为 salary_mid后续分析就用中位数。为什么不用平均值招聘薪资分布是有偏的几个 P7 级别的高薪岗位会把平均值拉高中位数更接近大部分岗位的真实水平。4.2 城市、学历、经验字段的文本映射城市字段不能直接拿来 groupby因为列表页里的城市可能是“北京-海淀区”或者“广州·天河”。处理方式是取第一个分隔符之前的部分def normalize_city(raw): for sep in [-, –, ·, ]: if sep in raw: return raw.split(sep)[0].strip() return raw.strip()学历字段要转成有序枚举因为在分析里需要按学历高低排序而不是按拼音排序。文本“本科及以上”“本科”都要归到“本科”这一档。经验字段不要转成字符串最好解析出最低年限“3-5年”取 3“5-10年”取 5“经验不限”取 0。这样以后可以画“经验要求 × 薪资”的散点图或者做相关性分析。这部分没有复杂逻辑但却是数据质量的基石爬虫爬回来是脏的分析做得再好看也是错的。4.3 聚合分析输出给可视化层喂 JSON清洗完成之后分析模块的输出应该是 JSON 结构体前端直接拿来画图。不要在前端再做统计前端做统计会让页面卡顿而且指标口径容易在改版时漂移。我一般在 analysis.py 里写一个 build_report一次性把图表需要的数据全部准备好import pandas as pd def build_report(df): report { city_median: ( df.groupby(city)[salary_mid] .median() .round(0) .sort_values(ascendingFalse) .head(15) .to_dict() ), job_top: ( df[title].value_counts().head(20).to_dict() ), edu_dist: df[edu_req].value_counts().to_dict(), exp_dist: df[exp_req].value_counts().head(10).to_dict(), } return reportcity_median 是各城市薪资中位数job_top 是岗位关键词频次edu_dist 是学历分布exp_dist 是经验要求分布。这四个图表足够支撑一个初始的招聘分析看板。注意 to_dict() 之前先 round否则前端拿到 12345.678901 这种数字还要自己格式化。数据量只有几万条时pandas 的 groupby 是瞬间完成的完全不需要上 Spark 或者数据库分析那套重型方案。什么时候才需要考虑 Spark至少是上千万条、多数据源、需要跑小时级批任务的时候招聘数据爬虫这个项目远达不到这个量级。5. 招聘数据爬虫的 5 个常见坑与排查方法5.1 翻页到第 11 页就抓不到数据现象前 10 页正常第 11 页开始返回空列表或者返回一页看不懂的验证码 HTML。原因招聘网站普遍对未登录用户的搜索结果页做页码截断超过阈值后故意返回空数据这是服务端逻辑不是 xpath 出了问题。解决把爬取任务拆细用“城市 × 关键词”组合去爬每个组合限制在 10 页以内。同时把翻页循环写成 try-except发现连续两页返回空就 break别让脚本空转。我自己的经验是先看目标网站网页端到底能翻多少页然后故意把每组的页码上限设成比它少两页。这样可以避免触发风控也拿够了足够分析的数据。如果你真的需要更多数据换个城市比硬翻页有效得多。5.2 中文全部变成乱码现象打印出来的岗位名称是“程åºå¼€å‘”或者 Excel 里全是问号。原因Requests 默认用响应头里的 charset 去解码但很多招聘网站返回的是 GBK/GB2312响应头里没写 charset 或者写错了Requests 就会用 UTF-8 去解结果全乱。解决在拿到 response 之后先检查 response.encoding 和 response.apparent_encoding再手动指定resp session.get(url, timeout10) if resp.encoding and utf in resp.encoding.lower(): resp.encoding resp.apparent_encodingapparent_encoding 是 Requests 根据字节流推断出来的编码对中文页面通常能推断出 gbk。要注意apparent_encoding 推断也要基于字节流如果你在获取 resp 之后先访问了 resp.text编码推断就可能出问题。所以正确的顺序是先设置 encoding再访问 text。5.3 xpath 抓 description 一直是空现象列表页字段都正常进详情页之后description 怎么取都是空列表。原因详情页的描述不是服务端渲染的而是页面加载后用 JavaScript 异步请求接口再填进去的初始 HTML 里根本没有这些文字。解决打开浏览器开发者工具的 Network 面板筛选 XHR 请求刷新详情页找到返回 JSON 的那个接口把接口地址和参数复制到爬虫里。这个接口返回的往往就是干净的 JSON解析比 HTML 更省事。很多人遇到这个问题会反复调 xpath其实方向错了。判断依据很简单用浏览器右键“查看网页源代码”如果源代码里没有职位描述那就不是 xpath 的问题是数据源的问题。这时候走 XHR 接口或者从 HTML 里的 window 全局变量比如 window.INITIAL_STATE去取都是可行的方案。5.4 月薪被年薪混算分析结果虚高现象做完城市薪资排名发现某二线城市排到了第一平均月薪三万。原因这个城市的大量岗位写的是“20-40万·14薪”或“30-50万/年”清洗脚本没识别“薪”和“年”把年薪当成了月薪。解决在薪资归一化函数里先判断文本里有没有“年”或“薪”有就先除以 12再进单位换算。这就是上一章 parse_salary 里先用 isinstance 判断再计算的顺序问题。另外要注意“14薪”要不要摊进月薪。我的默认做法是不摊因为 14 薪的发放通常和绩效绑定存在不确定性。如果需要算“含年终奖月薪”就再写一个函数去处理别在原函数里加参数保持清洗逻辑可追溯。5.5 增量爬虫把数据爬重复现象第二次运行时数据量翻倍同一条岗位出现了多次。原因去重键没有生效或者 job_id 在目标站根本不是唯一标识。解决先在数据库里给 job_id 建 UNIQUE 索引插入时用 INSERT OR IGNOREcursor.execute( INSERT OR IGNORE INTO jobs (job_id, title, company, city, salary_raw, crawled_at) VALUES (?, ?, ?, ?, ?, ?), (job[job_id], job[title], job[company], job[city], job[salary_raw], timestamp) )如果 job_id 不唯一就把去重键改成 job_id title company 的复合键数据库里建复合唯一索引。这里最容易踩的坑是列表页的 job_id 和详情页 URL 里的 ID 不是同一个。爬虫里要先确认 job_id 的生成规则是不是稳定。我最近做的一个项目就是列表页 ID 带首字母前缀详情页 ID 不带结果用详情页 ID 去重同一岗位被抓了三遍。6. 可视化进阶把分析结果变成可筛选的大屏页面Flask 后端不需要复杂四个接口就够用/api/report 返回聚合统计/api/city 返回城市明细/api/jobs 返回岗位列表用于表格展示首页模板负责渲染图表。from flask import Flask, jsonify, render_template import sqlite3 import pandas as pd app Flask(__name__) def load_jobs(): conn sqlite3.connect(jobs.db) df pd.read_sql_query(SELECT * FROM jobs, conn) conn.close() return df app.route(/api/report) def report(): df load_jobs() return jsonify(build_report(df)) app.route(/) def index(): return render_template(index.html)前端用 ECharts页面布局用 CSS Grid 排成三列这样的排版很容易扩展成可视化大屏。两个值得调整的参数图表高度建议固定 360px 或 400px太高会挤压其他图表太低又看不清柱状图标签tooltip 的 trigger 建议设成 axis鼠标在柱状图上滑过时能看到整列数据而不是只看到当前柱子。文字大小在 1920×1080 的屏上标题用 20px图表内部文字用 12px 起步就够了。进阶的做法是给页面加筛选器城市下拉框、岗位关键词输入框、薪资范围滑块。筛选逻辑在前端实现选中条件之后重新请求 /api/jobs再在前端用 ECharts 更新图表。这比每次筛选都请求后端重新聚合要流畅得多因为少量数据在前端 filter 是瞬间完成的。做这个可视化系统我最大的教训是先验证数据再调图表。第一次跑完数据用 SQL 查了一下各城市的薪资中位数发现一个内陆三线城市排第一追查下去才知道是该城市只有 12 条数据其中 3 条是“50-100万”的总监岗样本量太小中位数失真。后来我在 build_report 里加了最少样本数过滤少于 20 条记录的城市不进排名。这个坑比任何 ECharts 配置都值钱——可视化再好数据底子不对展示出来反而误导人。希望这个思路能帮你在自己的招聘数据系统里少踩几个坑。本文还有配套的精品资源点击获取