简介面向Web爬虫、数据分析及机器学习方向的开发者这份课程报告完整记录了基于Python的招聘网站数据爬取与分析系统的设计与实现全过程。系统采用Scrapy框架构建分布式爬虫从Boss直聘、前程无忧等主流平台自动采集岗位信息结合Pandas完成数据清洗与关键词提取利用随机森林/XGBoost模型预测薪资通过Apriori算法挖掘技能关联规则并以Pyecharts实现岗位分布、薪资趋势、技能需求等多维可视化。压缩包内共3个文件涵盖PDF文档、Markdown笔记与HTML页面整体约1.42MB兼顾深度阅读与在线浏览文档覆盖绪论、需求分析、总体设计、详细设计与实现、系统测试及附件实现指南等完整章节为课程设计提供可直接参考的架构与实现思路。已有105人学习浏览适合正在完成爬虫或招聘数据分析类项目的学生系统参考。1. 基于Python的招聘网站数据爬取与岗位分析系统为什么值得做基于Python的招聘网站数据爬取与岗位分析系统一句话描述是用Python爬虫从招聘网站收集岗位数据再用pandas和可视化工具回答“哪类岗位多、薪资在什么区间、技能要求是什么”。这类系统之所以常用是因为大多数招聘平台不提供便于下载的批量岗位接口求职者或HR只能手动翻页信息既零散又容易过期。它适合三类读者正在找工作的人想把自己岗位横向对比一下做招聘或行业数据的人需要周度或月度观察某个职位的市场变化准备转数据分析岗的工程师需要一个从采集、清洗到展示的完整项目。只要愿意动手最终跑起来的是一个能更新、能筛选、能看到图表的最小数据产品。但也要先说一句爬招聘网站不是万能药。目标站反爬策略各有差别数据字段缺失严重时分析结果可能和真实市场差得很远。因此这篇文章不打算给你一个“复制就能跑”的成品而是把从爬虫到岗位分析系统的每一步怎么选、怎么做、哪里最容易翻车讲清楚。2. 数据爬取选型与最小实现先把列表页变成结构化数据在真正写代码之前先确定技术选型否则后面会被并发调度、爬虫中间件这些东西拖住。招聘网站数据爬取最常见的组合有两种requests parsel以及 Scrapy。前者适合页面不算多、结构经常变的场景后者适合目标站点多、需要分布式调度和统一管道的场景。做岗位分析系统时源头站点通常就是三五个招聘网站且它们的前端结构改版比想象中频繁所以更推荐用轻量组合方便快速改XPath和字段映射。环境准备也很直接尽量在项目目录里建虚拟环境避免污染系统Python。以下命令在macOS和Linux下通用Windows用户把source venv/bin/activate换成venv\Scripts\activate即可。mkdir job-analysis cd job-analysis python -m venv venv source venv/bin/activate pip install requests parsel pandas lxml这套依赖里requests负责发HTTP请求parsel负责XPath和CSS解析pandas负责后续清洗和分析。如果你是VS Code用户先完成vscode python环境配置把解释器指到当前venv再运行上面的安装命令不然装好的包在调试时可能找不到。2.1 爬虫选型requestsparsel还是Scrapy招聘站要用哪套很多人一提到“python爬虫爬取网页数据”就以为必须上框架。实际上Scrapy的学习成本集中在Spider、Item Pipeline、Downloader Middleware这些抽象上对于只爬一两个招聘站的系统来说这些抽象反而会拖慢开发速度。我看过不少项目爬虫部分才几十行却为了跑Scrapy配了一堆settings最后页面改版后改规则还要重启整个爬虫进程。招聘站的反爬主要靠请求频率、请求头校验和动态接口签名并不需要复杂调度。用requests循环翻页配合parsel写XPath足以完成90%的列表页抓取。后续如果爬的站点多了再把同一套解析逻辑迁移到Scrapy也来得及。requests parsel还有一个好处调试简单。代码是可以一行行看下来的哪里取不到值直接在浏览器复制XPath试试Scrapy的错误栈虽然完整但对初学者不友好。做增量更新时requests也能轻松和pandas配合把去重逻辑放在循环里不用额外引入数据库。2.2 最小爬虫跑通请求头、翻页参数与XPath解析以某个招聘网站的列表页为例先用一个最小脚本把前五页岗位抓下来。不要一上来就抓全站先把一页数据解析对再放开页码。import requests import parsel import time import pandas as pd 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, Referer: https://example.com/ } BASE_URL https://example.com/jobs all_items [] for page in range(1, 6): # 先爬前5页验证解析再放开 params {page: page, city: 101010100} resp requests.get(BASE_URL, paramsparams, headersHEADERS, timeout10) resp.encoding resp.apparent_encoding sel parsel.Selector(resp.text) for li in sel.xpath(//li[contains(class,job-item)]): title li.xpath(.//h3//text()).get() company li.xpath(.//a[contains(class,company-name)]/text()).get() salary_text li.xpath(.//span[contains(class,salary)]/text()).get() detail_url li.xpath(.//h3/a/href).get() if title and salary_text: all_items.append({ title: title.strip(), company: company.strip() if company else , salary_text: salary_text.strip(), detail_url: detail_url.strip() if detail_url else }) time.sleep(1) # 低频访问避免触发反爬 pd.DataFrame(all_items).to_csv(jobs_raw.csv, indexFalse, encodingutf-8-sig) print(saved, len(all_items))这段代码的逻辑很简单用requests请求列表页设置编码避免中文乱码再用parsel把页面转成可查询的Selector对象。循环里的XPath先定位每一个岗位卡片li再从卡片下分别抽标题、公司、薪资和详情链接。判断条件title and salary_text是为了过滤掉广告位或空数据因为招聘网站经常在列表里混入推荐职位的模块它们不一定有完整字段。参数说明range(1, 6)控制页码验证阶段只跑5页time.sleep(1)是防止访问频率太高timeout10让请求不会因为网络卡住而无限等待encoding resp.apparent_encoding用来处理响应头没给对charset的情况。示例中的URL、XPath和job-item这个class名必须换成实际站点里的值这类页面结构每家都不一样没有一套选择器能通吃。提示如果列表页是静态HTML这个脚本就能跑通如果返回内容里没有岗位数据说明列表是动态加载的跳到下一节处理。2.3 动态页面先找XHR接口再决定要不要上Playwright招聘网站在改版后特别流行动态加载列表页只是一个壳职位数据是通过XHR请求拿到的JSON。用requests直接请求静态页面得到的HTML里什么都没有XPath自然抓不到内容。正确做法是先打开浏览器开发者工具刷新列表页在Network面板里找返回JSON的接口。如果接口没有加密签名用requests请求它比解析HTML快得多。import requests HEADERS { User-Agent: Mozilla/5.0, Referer: https://example.com/ } resp requests.get( https://example.com/api/jobs?page1city101010100, headersHEADERS, timeout10 ) data resp.json() for row in data.get(data, {}).get(list, []): print(row.get(jobName), row.get(salary))这段代码里resp.json()直接拿到结构化数据省去XPath解析。关键是把接口URL里的query参数和返回层级搞清楚比如data.list、data.total这些字段不同站点差异很大。如果接口只有复杂签名或者响应内容带加密字段再去考虑Playwright渲染整页。不要一开始就用浏览器自动化它占用资源高而且更容易被反爬识别不应该是首选方案。3. 数据清洗与岗位分析把杂乱招聘文本变成可用统计爬下来只是开始。招聘数据是全互联网最脏的文本之一因为招聘网站为了排版好看会在同一个字符串里塞进薪资、经验、学历、福利等多种信息。如果不做清洗后面的分析结果就是“数字看着有结论全是坑”。3.1 薪资字段清洗从“15-20K·14薪”里算中位数薪资字段最常见的形态是“15-20K·14薪”也可能是“10k-15k”“面议”“30-50万/年”。我们做前两步时最需要的统计口径是月薪中位数所以要先把字符串里的数字提取出来做区间平均。import re import pandas as pd def parse_salary(text: str): if not text or 面议 in text: return None # 先把14薪这种计薪月数去掉不然会混进薪资区间 clean re.sub(r(\d(?:\.\d)?)\s*薪, , text) # 统一大小写去掉K clean clean.replace(K, ).replace(k, ) nums [float(x) for x in re.findall(r\d\.?\d*, clean)] if len(nums) 2: return (nums[0] nums[1]) / 2 if len(nums) 1: return nums[0] return None df pd.read_csv(jobs_raw.csv) df[salary_avg] df[salary_text].apply(parse_salary) df df.dropna(subset[salary_avg])这段代码的关键是先用正则去掉“X薪”否则“15-20K·14薪”会被解析出15、20、14三个数字中位数会变成15明显不对。然后统一大小写并去掉K再用正则提取数字。这里的“月薪区间中位数”是一个通用口径如果你的分析目标是年包需要单独提取“14薪”里的14存成字段。参数说明dropna(subset[salary_avg])会把“面议”和解析失败的记录删掉这是无奈但必要的操作。统计薪资时优先看中位数而不是均值因为一个“80K”的高管岗位就能把均价拉高几千块。建议在最终报告里同时保留岗位数和样本量样本太少的中位数没有代表性。3.2 JD文本分词与技能词频统计过滤停用词、补充自定义词岗位分析系统里最有价值的输出不是薪资柱状图而是“这个岗位到底在要求什么技能”。JD文本是长文本不能靠肉眼一条条看必须分词后统计词频。常见做法是用jieba分词再配合技能字典做匹配。import jieba import re from collections import Counter SKILL_SET {python, java, c, go, mysql, redis, docker, kafka, spring, vue, react, hadoop, spark, flink, linux} CN_SKILLS {数据分析, 数据挖掘, 机器学习, 深度学习, 团队协作, 项目管理} def count_skills(df): counter Counter() for jd in df[jd_text].dropna(): # 英文技能词统一小写后按正则提取 for en_word in re.findall(r[a-zA-Z][a-zA-Z0-9#.]*, jd.lower()): if en_word in SKILL_SET: counter[en_word] 1 # 中文技能词先用jieba分词再匹配 for cn_word in jieba.lcut(jd): if cn_word in CN_SKILLS: counter[cn_word] 1 return counter.most_common(30)逻辑说明英文技能词用正则提取比如JD里出现“Python”会被转成小写“python”正好匹配技能字典“C”和“C#”也能通过[a-zA-Z0-9#.]这个字符集被完整保留。中文能力词先由jieba切分再和自定义集合比对避免“数据分析”被拆成“数据”和“分析”。参数说明SKILL_SET和CN_SKILLS是两个字典需要根据自己的岗位方向持续补充。把“python”写进技能字典后统计出来的词频才是报告而不是统计“负责”“要求”“工作”这些高频废话。如果跑出来的Top词全是“工作”“相关”那就是停用词表没建好先加过滤条件再谈分析。3.3 岗位分析的几个必算维度城市、学历、经验与薪资中位数清洗完成后可以开始算维度。不要一开始就画复杂图表先把四张基础表建出来城市岗位数、城市薪资中位数、学历薪资中位数、经验与学历交叉表。它们能回答求职者最关心的大部分问题。import pandas as pd df pd.read_csv(jobs_clean.csv) df[salary_avg] pd.to_numeric(df[salary_avg], errorscoerce) city_stats ( df.groupby(city)[salary_avg] .agg([count, median]) .reset_index() .rename(columns{count: 岗位数, median: 薪资中位数}) .sort_values(岗位数, ascendingFalse) ) EDU_ORDER {高中: 1, 大专: 2, 本科: 3, 硕士: 4, 博士: 5} df[edu_rank] df[education].map(EDU_ORDER).fillna(0) edu_stats df.groupby(education)[salary_avg].median().reset_index() print(city_stats.head(20)) print(edu_stats)这里用groupby做分组统计agg([count, median])一次算出岗位数和薪资中位数。教育程度不能直接按拼音或字符串排序所以要映射成edu_rank后续做相关性分析时也必须转换成数值类型。fillna(0)表示学历缺失的记录不参与排序但不能删除因为招聘网站的数据缺失率通常不低删了会让样本量骤降。经验字段的处理逻辑类似把“1-3年”“3-5年”转成连续变量或有序分类。到这一步已经走完python数据分析与可视化的前半段下一步是把这些统计结果变成能交互的页面。4. 可视化与轻量看板把分析结果变成可筛选的系统一个完整的招聘数据爬取与分析系统最后一定需要一个能让人点来点去的界面而不是只输出一堆CSV。这个阶段常见的选择是Streamlit和FlaskECharts各有适用场景。4.1 用Streamlit还是FlaskECharts做展示层如果你不需要很复杂的权限系统也不想写前端代码Streamlit是成本最低的路线。它把下拉框、多选框、滑杆都封装成了Python函数一天之内就能做出一版可交互的岗位分析看板。FlaskECharts则适合要做成公司内部系统、需要定制样式和接口的情况但工作量明显更大。对基于Python的招聘网站数据爬取与岗位分析系统来说Streamlit更合适。数据量不大分析交互也就是筛城市、筛岗位、看图表用Streamlit的组件足够。它还能通过st.cache_data缓存结果避免每次拖动筛选都重新读文件。4.2 岗位分析看板的最小实现筛选器与统计图表新建一个app.py写入下面的代码然后用streamlit run app.py启动。import streamlit as st import pandas as pd import altair as alt st.cache_data(ttl1800) def load_data(): df pd.read_csv(jobs_clean.csv) df[salary_avg] pd.to_numeric(df[salary_avg], errorscoerce) return df df load_data() st.title(招聘岗位分析看板) cities st.multiselect(城市, optionssorted(df[city].dropna().unique()), default[]) if cities: df df[df[city].isin(cities)] edu_options [全部] sorted(df[education].dropna().unique()) edu st.selectbox(学历要求, edu_options) if edu ! 全部: df df[df[education] edu] st.subheader(岗位数量前10) top_titles df[title].value_counts().head(10).reset_index() top_titles.columns [岗位, 数量] st.altair_chart(alt.Chart(top_titles).mark_bar().encode( xalt.X(数量:Q), yalt.Y(岗位:N, sort-x) )) st.subheader(城市薪资中位数) city_data df.groupby(city)[salary_avg].median().dropna().reset_index() city_data.columns [城市, 薪资中位数] st.altair_chart(alt.Chart(city_data).mark_bar().encode( xalt.X(薪资中位数:Q), yalt.Y(城市:N, sort-x) ))这段代码做了三件事第一用st.cache_data缓存读取的文件ttl1800表示缓存30分钟超过之后重新读盘第二用multiselect和selectbox实现城市和学历筛选第三用Altair画两个横向条形图。sort-x让条形图从高到低排列。这里有一个参数容易踩坑value_counts()返回的索引是岗位名如果直接传给Altair轴会反。所以要先用reset_index()并把列名改成“岗位”和“数量”再映射给图表。pd.to_numeric里的errorscoerce用来把清洗时留下的空值变成NaN避免图表报错。4.3 把爬虫和分析串成一条流水线脚本与调度看板能跑之后要做的是把爬虫、清洗、分析、展示串成一条自动化流水线。常见做法是把脚本拆成crawl.py、clean.py、app.py三个文件crawl.py负责生成jobs_raw.csvclean.py负责生成jobs_clean.csvapp.py只读清洗后的文件。这样每次改爬虫规则不会影响展示层。在Linux服务器上用crontab定时执行即可。例如每周一早9点先爬取再清洗0 9 * * 1 cd /home/user/job-analysis python crawl.py python clean.pyWindows系统则用任务计划程序命令换成cd /d D:\job-analysis python crawl.py python clean.py。定时任务意义不在于实时而在于让数据有连续性没有增量更新的爬虫只能看某一瞬间的切片。5. 避坑与常见问题招聘数据爬取与分析的血泪踩坑记录这个系统真正让人头疼的不是第一个版本跑通而是跑了一周之后突然数据异常。下面几条踩坑记录基本覆盖了招聘数据爬取与分析最常见的意外。5.1 爬下来是空列表先分清页面是静态还是动态现象items为空或CSV只有表头没有数据行打印resp.text却发现内容是完整的。原因这个招聘网站的数据不是服务端渲染在HTML里的而是浏览器加载后通过XHR接口填充。requests拿到的HTML只是空壳XPath在空壳上自然取不到职位。解决打开浏览器Network面板刷新列表页找到返回JSON的那个请求。如果接口没有复杂签名直接改爬虫去请求它如果接口带加密参数再用Playwright渲染整页。改完之后先打印resp.url和响应前200个字符确认拿到的确实是岗位数据而不是验证页。5.2 同一个岗位重复计数URL去重与缓存指纹现象统计“Python岗位数量”时一个岗位出现了几十次薪资中位数也被重叠样本抬高。原因列表页存在推荐职位和排序偏移同一岗位会跨多页出现有些网站在“最新职位”和“热门职位”两张列表里都放同一JD。解决抓取时把detail_url作为去重键没有detail_url时可以用“公司名岗位名薪资区间”拼接后做哈希。import hashlib def dedup_rows(rows): seen set() result [] for row in rows: raw row.get(detail_url, ) key hashlib.md5(raw.encode(utf-8)).hexdigest() if raw or key in seen: continue seen.add(key) result.append(row) return result逻辑说明每行数据先计算detail_url的MD5指纹重复的跳过。raw 时直接丢掉因为详情页地址为空的分析价值很低。参数说明MD5已经足够不需要用SHA256用哈希而不是原URL是为了避免URL过长导致CSV里出现逗号或引号干扰。5.3 薪资解析乱中位数和均值到底用哪个现象分析结果里薪资中位数突然升高或者出现一个明显不可能的值。原因最常见的错误是把“面议”当成0或者把“15-20K·14薪”中的“14薪”也当成薪资数字。另一个问题是少量超高薪岗位会把均值拉得很高而均值被当作“市场水平”写进结论。解决清洗时先提取“X薪”并单独保存再把“面议”置空统计时用中位数而不是均值。如果必须报告均值同时给出样本量和标准差否则会误导读者。5.4 访问太频繁触发风控频率控制比什么都重要现象连续跑上百页后开始超时再继续就出现验证码或IP被限制访问。原因脚本没有延时或者把并发数调得过高。招聘网站的接口往往做了请求频率统计同一个IP短时间内请求过多必然被限。解决每次请求之间至少time.sleep(1)页面越多间隔越要拉长失败时用指数退避重试第一次等2秒第二次等4秒第三次等8秒。代码里不要死等设置最大重试次数超限就退出并留下日志。这个“全站五分钟抓完”的心态是导致反爬阈值被不断推高的最大原因。5.5 日志和现场比代码更重要把原始HTML存下来现象昨天解析还好好的今天字段缺了一半但代码一个字没改。原因页面结构改版了或者某个字段在部分页面被隐藏。如果只盯着清洗后的CSV很难确认问题出在解析还是清洗。解决爬虫在请求成功后每页保存一份原始HTML到debug/目录文件名带上页码和时间戳。出现异常时先拿当天原始页面和之前的样本做对比一眼就能看出是div类名变了还是整个接口换了。下面的代码可以放在解析循环里from datetime import datetime save_name fdebug/page_{page}_{datetime.now():%Y%m%d%H%M%S}.html with open(save_name, w, encodingutf-8) as fp: fp.write(resp.text)逻辑说明datetime.now():%Y%m%d%H%M%S生成精确到秒的文件名避免同一次运行内覆盖。参数说明debug/目录要提前创建定期清理两周前的文件否则硬盘会堆满。不要把原始HTML只留在内存里程序崩了之后线索就全没了。6. 增量爬取与结果校验把系统从能跑变成可信6.1 增量更新用detail_url做去重键爬虫只跑一次就是玩具想按月观察岗位变化必须做增量更新。常见做法是每天抓一次当天新增或三天内的岗位再用detail_url合并到历史数据中。import pandas as pd new_df pd.read_csv(today_jobs.csv) old_df pd.read_csv(all_jobs.csv) merged pd.concat([old_df, new_df], ignore_indexTrue) merged merged.drop_duplicates(subsetdetail_url, keeplast) merged.to_csv(all_jobs.csv, indexFalse, encodingutf-8-sig)逻辑说明把历史数据和当天数据纵向拼接再按detail_url去重保留后者。这样如果某条岗位当天刷新了薪资历史统计不会留下旧值。参数说明keeplast非常关键改成keepfirst会导致后续抓到的变更永远不生效。6.2 结果校验清单数据可信度不能靠代码保证要靠人工校验。我会每月做一次对比检查用招聘网站自带的搜索结果作为参照。校验项爬虫结果手动抽样说明最近7天Python岗位数爬虫统计网站搜索计数差异超过10%则检查翻页是否漏抓薪资中位数清洗后计算随机抽查30条偏差超过10%优先查“面议”和“薪”处理Top5技能词频jieba统计人工浏览JD若“数据分析”异常高检查自定义词典这三项对上了系统才敢继续跑下一个周期。校验不是可选项尤其是拿分析结果去做判断的时候。6.3 一个习惯先验后扩我一般的习惯是改完XPath或清洗函数后先跑100条样本做横向对比确认薪资中位数没有突变再放开全量爬取。这个习惯救过我很多次因为很多问题只在数据量达到几千条后才暴露比如某个城市字段出现新值或者某个岗位的薪资单位从K变成了万。数据这类东西宁可在前面慢一点也不要在后面被一堆脏数据带着走。希望帮到你。本文还有配套的精品资源点击获取