
简介一份基于Python的招聘岗位数据爬取与可视化分析项目借助Requests库采集智联招聘、前程无忧、拉钩、BOSS直聘等平台招聘信息覆盖职位名称、薪资、地点、经验、学历等核心字段并完成数据清洗、存储与图表化展示。面向人力资源从业者、求职者以及Python爬虫和数据分析学习者可当作完整的实战参考。压缩包共61个文件大小10.32MB主要包括源码、pyc编译文件、MySQL数据库脚本、可视化图表、网页展示文件、演示文稿和说明文档能够呈现从爬虫采集到Flask展示、数据分析的完整流程。已有66人学习浏览。借助该项目可了解多平台招聘页面的解析思路和常见反爬应对手法学习数据库表结构设计与较大数据量SQL脚本的使用并参考Matplotlib等工具生成薪资分布、岗位热度等图形演示文稿有助于答辩汇报或团队分享适合按模块逐步阅读和二次开发。1. 先想清楚招聘数据爬这个题目到底在爬什么、给谁用你看到“基于Python编程语言构建的招聘岗位数据爬取与可视化分析系统”这个标题先别急着写代码。它不是一个单点爬虫而是一条从采集到展示的完整链路用Requests库向智联招聘、前程无忧、拉勾、BOSS直聘这些招聘站点发起请求拿到岗位列表里的岗位名称、薪资、城市、经验要求、公司名等字段清洗后存入数据库最后用图表把“某城市Python岗平均月薪”“三年经验占比多少”这类结论直观画出来。这套东西对两类人最有用一是想选城市、谈薪资的求职者可以在面试前快速了解行情二是做人事或行业分析的人想通过岗位量变化判断某个技术方向的供需热度。我自己当初做这个起因是想搞清楚“Python开发在二线城市到底值多少钱”手动翻了几十页招聘网页后决定写个脚本。如果你也有类似诉求这篇文章会给你一个能直接跑通的方案同时告诉你哪些参数必须调、哪些坑别踩。2. 整体设计为什么是Requests而不是Scrapy或Selenium2.1 选型理由Requests库的适用边界在哪里很多爬虫教程一上来就上Scrapy框架但对招聘平台这种目标我强烈建议先用Requests。原因很直接招聘站点没有太夸张的动态渲染绝大多数岗位列表是通过一个JSON接口返回的你用浏览器开发者工具能看到请求地址用Requests模拟这个请求就能拿到数据响应速度比Selenium快一个数量级。Selenium适合处理JavaScript渲染出来的页面但代价是每个请求都要拉起一个浏览器内存和CPU占用高并发上来很容易被服务器识别。Scrapy则是个完整框架学习曲线陡而且它的异步并发机制对新手不友好调试一个请求要熟悉整个爬虫的生命周期。Requests不一样它是一个没有魔法的最小HTTP客户端你写三行就能发出第一个请求遇到问题也容易排查。import requests url https://example.com/api/job/list headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } resp requests.get(url, headersheaders, timeout10) print(resp.status_code) print(resp.text[:500])这段代码是入门模板。timeout10是关键参数它告诉Requests如果服务器10秒内没响应就抛异常不会让程序一直挂着。headers里至少要带User-Agent否则很多服务器直接返回403。你如果需要登录后的数据可以用requests.Session()把Cookie保持住避免每次请求都重新登录。这套逻辑比Selenium省资源也比Scrapy直观适合招聘数据这种中等规模的采集需求。2.2 系统流转从URL到图表要经过哪五步整个系统的数据流可以拆成五个环节请求构造、响应解析、数据清洗、存储、可视化。每个环节都有独立的职责后面每一章会对应展开。你在写代码之前最好先把目录结构定下来我通常会这样组织job_crawler/ ├── main.py # 主程序入口 ├── config.py # 请求头、目标URL、数据库配置 ├── crawler.py # 爬虫核心请求与解析 ├── cleaner.py # 数据清洗去重、补全、类型转换 ├── database.py # SQLite入库 ├── visualizer.py # pyecharts生成图表 └── requirements.txt # 依赖列表这个结构的价值在于爬虫逻辑、清洗逻辑和可视化逻辑互相解耦。比如你发现字段解析错了只需要改crawler.py不会影响数据库和图表。主程序main.py负责把五个环节串起来。# main.py from config import HEADERS, STORAGE_DB from crawler import fetch_jobs from cleaner import clean_data from database import save_to_sqlite from visualizer import generate_charts if __name__ __main__: raw_data fetch_jobs() # 1.请求 df clean_data(raw_data) # 2.清洗 save_to_sqlite(df, STORAGE_DB) # 3.入库 generate_charts(db_pathSTORAGE_DB) # 4.图表这样写的好处是每一步都能单独测试。你可以在fetch_jobs()里打印原始数据在clean_data()里打印清洗后的DataFrame确保问题被隔离在具体模块。数据库我选SQLite而不是MySQL因为单机分析场景不需要分布式SQLite零配置一个文件搞定后续你想迁移到MySQL也只需要改database.py里的连接方法。3. 爬取模块实现用Requests高效抓取招聘列表3.1 构造Session、Header与请求参数先过第一层反爬招聘平台的反爬主要集中在User-Agent校验、Cookie校验和访问频率限制。你不能用默认的python-requests/2.x那等于告诉服务器自己是机器人。常见的做法是准备一个随机UA池每次请求换一个同时用requests.Session()让服务器认为你是同一个浏览器在连续访问。import requests from random import choice UA_POOL [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36, Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/123.0 ] def make_session(): session requests.Session() session.headers.update({ User-Agent: choice(UA_POOL), Accept: application/json, text/plain, */*, Referer: https://example.com/ }) return sessionReferer字段也很重要很多站点的服务端会校验请求来源如果你的请求没有从它们首页过来的记录就可能被拒绝。这里的choice(UA_POOL)是从列表里随机选一个避免每次请求都带同样的UA减少被识别为脚本的概率。Session对象会自动管理Cookie你可以在第一次请求时带上登录信息后续请求直接复用。请求参数方面招聘平台普遍支持按关键词、城市、页码查询。你需要用浏览器开发者工具F12看一下实际发出的请求格式一般有两种GET请求把参数拼在URL上POST请求放在body里。以常见的搜索接口为例至少要有这三个参数# config.py CITY_CODE { 北京: 101010100, 上海: 101020100, 广州: 101280100, 深圳: 101280600 } JOB_KEYWORD Python PAGE_SIZE 20 MAX_PAGE 10城市代码是平台内部编码不同平台不一样你得先抓一次当样本。这里我写的是模拟值正式使用时必须从平台网页源码里提取。MAX_PAGE控制爬取深度我一般不会超过20页否则很容易触发异常流量限制。3.2 解析响应JSON接口和HTML正则该怎么选登录后你看到的招聘列表大多数情况是一个接口直接返回JSON字段结构清晰用r.json()就能搞定。但有些老平台或催熟页面会返回HTML片段这时候就要用正则或lxml来提取。我建议优先找JSON接口因为HTML解析容易受页面改版影响而JSON结构相对稳定。import re from lxml import html def parse_json_response(resp_text): import json data json.loads(resp_text) jobs [] for item in data.get(result, []): job { title: item.get(jobName), salary: item.get(salary), city: item.get(cityName), experience: item.get(jobExp), company: item.get(companyName) } jobs.append(job) return jobs def parse_html_response(resp_text): tree html.fromstring(resp_text) titles tree.xpath(//div[classjob-title]/text()) salaries tree.xpath(//span[classsalary]/text()) return [{title: t, salary: s} for t, s in zip(titles, salaries)]这里两种解析都写了。parse_json_response里json.loads把字符串转成字典然后取result字段。parse_html_response用XPath定位页面元素但XPath表达式非常容易因改版失效所以我一般只在没有JSON接口时才用。你写的时候先打印resp.text[:500]看一眼返回内容判断它是JSON还是HTML再决定走哪个函数。3.3 数据清洗与入库先把脏数据挡在数据库之外从接口拿到的数据往往不干净薪资字段可能是“15-20K·14薪”也可能直接是“面议”城市可能带有“市”字经验字段可能是“1-3年”“经验不限”。如果直接入库后续可视化做统计会非常痛苦。所以清洗模块至少要干三件事统一薪资格式、清理空值、去重。import pandas as pd def clean_data(raw_data): df pd.DataFrame(raw_data) df[salary_high] df[salary].apply(parse_salary_high) df[salary_low] df[salary].apply(parse_salary_low) df[city] df[city].str.replace(市, ) df[is_negotiable] df[salary].str.contains(面议) df.drop_duplicates(subset[title, company, city], keepfirst, inplaceTrue) df df.dropna(subset[title, company]) return df def parse_salary_high(s): if not isinstance(s, str) or K not in s: return None if - in s: return float(s.split(-)[1].replace(K, )) return None def parse_salary_low(s): if not isinstance(s, str) or K not in s: return None if - in s: return float(s.split(-)[0].replace(K, )) return None我专门写了parse_salary_high和parse_salary_low把“15-20K”拆成下限15和上限20。这样在可视化时可以直接用salary_high画薪资上限分布用salary_low画下限。drop_duplicates按岗位名、公司、城市三个维度去重因为同一家公司可能在多个渠道重复发同一个岗位。去重后dropna把没有标题或公司的垃圾记录删掉。这些操作完成后数据才能安全入库。import sqlite3 def save_to_sqlite(df, db_path): conn sqlite3.connect(db_path) cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, company TEXT, city TEXT, salary TEXT, salary_low REAL, salary_high REAL, experience TEXT, is_negotiable INTEGER ) ) df.to_sql(jobs, conn, if_existsreplace, indexFalse) conn.close()这里用to_sql一次性写入比逐条INSERT快很多。注意if_existsreplace如果你要做增量爬取需要改成append我们会在第六章聊增量方案。入库前我先手工执行了建表语句确保字段类型符合预期比如salary_low是REAL、is_negotiable是INTEGER。4. 可视化分析把岗位数据变成能看懂的图表4.1 用pyecharts生成城市-平均薪资柱状图数据入库之后可视化就是最出效果的环节。我常用pyecharts因为它能生成交互式HTML图表鼠标悬停能看到具体数值比matplotlib的静态图更适合展示给业务方看。核心思路是先按城市分组算平均薪资再绘制柱状图。import pandas as pd from pyecharts.charts import Bar from pyecharts import options as opts def generate_city_salary_bar(db_path): df pd.read_sql(SELECT city, salary_low, salary_high FROM jobs, fsqlite:///{db_path}) city_avg df.groupby(city)[salary_high].mean().sort_values(ascendingFalse) bar ( Bar() .add_xaxis(city_avg.index.tolist()) .add_yaxis(平均薪资上限K, city_avg.round(1).tolist()) .set_global_opts( title_optsopts.TitleOpts(title各城市Python岗位平均薪资上限), yaxis_optsopts.AxisOpts(name月薪K) ) ) bar.render(city_salary_bar.html)groupby(city)[salary_high].mean()会忽略空值所以前面清洗时已经把无法解析的薪资字段置成None。这里按salary_high做均值是因为很多人的真薪资落在区间上限附近用上限排序更贴近市场宣传口径。如果你想知道真实成交价可以用(salary_low salary_high) / 2构造一个中间值再聚合。4.2 用WordCloud展示热门技能关键词除了城市薪资招聘需求里的技能词也很有参考价值。比如你是技术负责人想判断“招聘Python岗位的公司最看重什么技能”把岗位描述拿出来做词云很直观。这里偷懒一点直接对岗位标题做分词和统计。import jieba from collections import Counter from pyecharts.charts import WordCloud def generate_skill_cloud(db_path): df pd.read_sql(SELECT title FROM jobs, fsqlite:///{db_path}) text .join(df[title].tolist()) words [w for w in jieba.cut(text) if len(w) 2 and w not in {Python, 开发}] counter Counter(words).most_common(100) cloud WordCloud() cloud.add(, counter, word_size_range[20, 100], shapecircle) cloud.render(title_wordcloud.html)这里jieba.cut做中文分词再用Counter统计词频。我特意排除了“Python”“开发”这种没有区分度的词否则词云会被这两个词占满。你还可以用正则过滤只保留中文和英文单词去掉“工程师”“岗位”这类泛化词。4.3 让图表自动合并成一个Dashboard单独生成两个HTML文件已经很实用了但如果能让它们出现在同一个页面里分享给同事只需要发一个文件。pyecharts支持Page组合。from pyecharts.charts import Page def generate_all_charts(db_path): bar generate_city_salary_bar(db_path) cloud generate_skill_cloud(db_path) page Page(layoutPage.SimplePageLayout) page.add(bar, cloud) page.render(dashboard.html)Page.SimplePageLayout是垂直排列布局也可以改成DraggablePageLayout让用户拖拽图表位置。这样你跑一次爬虫加可视化就能拿到一个完整的dashboard.html直接在浏览器打开查看。5. 避坑指南招聘平台爬取最容易踩的5个坑5.1 连续请求被拒绝提示“429 Too Many Requests”现象爬了大概二三十页后requests返回状态码429响应内容提示“Too Many Requests”。有些平台还会返回一个验证码页面让你去点选。原因你短时间内请求次数太多服务器触发了限流策略exceeded retry limit就是这个机制的直接结果。解决一是降低请求频率并在每两个请求之间加随机延迟例如time.sleep(random.uniform(1, 3))二是加入重试机制遇到429时等待更长时间后重新请求。下面的重试逻辑可以放在爬虫里import time import random from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def make_retry_session(retries3, backoff_factor1): session requests.Session() retry Retry( totalretries, status_forcelist[429, 500, 503], backoff_factorbackoff_factor ) adapter HTTPAdapter(max_retriesretry) session.mount(http://, adapter) session.mount(https://, adapter) return sessionbackoff_factor1表示第一次重试等待1秒第二次等待2秒指数递增。status_forcelist里必须包含429因为很多平台的限流就是靠这个状态码通知你。这个逻辑写好后你的爬虫从“一遇到429就死”变成“自动退避重试”稳定性提升非常多。5.2 IP被临时封禁所有请求都返回403现象爬虫运行一段时间后突然所有请求都返回403即使换了UA也无效浏览器却还能正常访问。原因服务器在你请求时检测到同一IP的高频访问把IP临时加入了黑名单。这不是User-Agent的问题而是IP维度的封禁。解决如果在公司或校园网可以申请更换网络出口或者使用代理池让每个IP只承担少量请求。注意我这里说的代理池是普通的HTTP代理用于分布式采集场景如果你不想配代理最简单的方法是把单线程爬虫改成“爬几页就休息一会儿”的节奏比如每爬5页就强制暂停60秒。我在项目里临时救急时会写if page_num % 5 0: print(暂停60秒防止IP被封) time.sleep(60)这种土办法在数据量不大时非常有效。如果数据量很大就一定要考虑代理池或换用平台官方的开放接口。5.3 解析字段突然变None程序没报错但数据全是空的现象爬虫没有任何异常数据库里也写入了数据但可视化时发现字段全是空或None。原因平台改版了页面的JSON结构把字段名从jobName改成了positionName而你的解析代码还按旧字段取。解决写解析函数时先做异常兜底用dict.get()并给默认值同时每次运行前打印一条样本数据肉眼确认字段是否还在。def safe_extract(item, *keys, defaultNone): for key in keys: if key in item: return item[key] return default我把这个设计成“多字段名回退”模式比如薪资字段同时尝试[salary, salaryDesc, salaryStr]。这样即使平台改了一个名只要另一个还在程序就不用中断。5.4 薪资范围解析出错“面议”被当成None导致统计图缺一大块现象可视化图表上很多城市没有薪资数据或者平均薪资低得离谱。原因“面议”字段没有统一处理有些地方被解析成了None有些地方被当成字符串的上下限提取产生错乱。解决在清洗层统一增加is_negotiable标记并且让parse_salary_high返回None。做均值时mean()会默认跳过None所以不会污染平均值。我还额外做了一次过滤df df[df[salary_high].notna() df[salary_low].notna()]这样“面议”的岗位就不会参与薪资统计整个图表才不被异常值带偏。5.5 HTML页面改版XPath选择器全部失效现象之前跑得好好的HTML解析今天突然取不到标题、薪资了报错没有但返回的列表长度为0。原因前端模板改了class名比如div[classjob-title]变成了div[classposition-title]。解决优先用平台的JSON接口这是长期可持续的方案。不得已解析HTML时不要写死类名应该用更通用的标签路径比如//div[contains(class, title)]同时保留一份“解析失败时输出原始HTML片段”的日志便于改版后快速定位新XPath。def parse_html_response(resp_text): tree html.fromstring(resp_text) title_nodes tree.xpath(//div[contains(class, title) or contains(class, job)]/text()) if not title_nodes: print(警告解析结果为空可能是页面结构变更请检查前500字符, resp_text[:500]) return title_nodes6. 进阶玩法增量爬取、请求控制与自动化调度当你把基础链路跑通后会发现最麻烦的不是写代码而是怎么让这个系统长期稳定运行。这里我给出三个最值得做的进阶改造。第一个是增量爬取。你现在只要爬一次数据是静态的但如果想每周刷新行情就必须把数据库的写入模式从replace改成append并在表中增加一个crawl_date字段。import datetime df[crawl_date] datetime.date.today().isoformat() df.to_sql(jobs, conn, if_existsappend, indexFalse)然后查询时用SELECT DISTINCT * FROM jobs WHERE crawl_date (SELECT MAX(crawl_date) FROM jobs)就能拿到最新快照。为了避免重复存储去重逻辑要加上crawl_date维度否则同一个岗位每天都会插入一次。第二个是请求频率控制。你可以在配置里放松采样率比如每页之间随机等待2到5秒同时把timeout设置成(1, 5)表示连接超时1秒、读取超时5秒。这个元组参数能在网络抖动时快速失败并重试而不是傻等30秒。实际项目中我把随机延迟和重试逻辑封装成一个装饰器这样每个爬取函数都自动具备“请求失败后指数退避”的能力代码不会到处重复。第三个是定时执行。在Windows上可以用计划任务Linux和macOS用crontab。比如每周一早上8点运行一次0 8 * * 1 cd /path/to/job_crawler /usr/bin/python3 main.py跑完自动生成新的报表然后你可以把HTML文件挂到内部网盘或一个静态HTTP服务上让同事通过URL访问。这时候整个系统就从一个一次性脚本变成了持续提供决策信息的工具。最后说一个我一直保留下来的习惯每次爬完我都会把返回状态码分布、入库条数、清洗丢弃率打印到一个日志文件里。不要小看这些数字它们能告诉你哪一天平台换了反爬策略哪一次数据质量变差了。你会渐渐明白爬虫系统真正难的不是代码而是监控与维护。希望这个从Requests到可视化的完整方案能让你在自己做招聘数据分析时少走弯路也祝你能跑出第一张属于自己的薪资分布图。希望帮到你。本文还有配套的精品资源点击获取