ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python招聘数据爬虫分析与可视化:从爬取到看板的完整链路

Python招聘数据爬虫分析与可视化:从爬取到看板的完整链路 简介这是一套基于Python的招聘数据爬虫分析与可视化课程设计源码包面向计算机专业学生及爬虫数据分析初学者可用于期末大作业、课程设计或毕业项目实践。项目覆盖从招聘网站页面爬取、字段解析清洗、MySQL入库存储到Flask后台服务与多维度可视化展示的完整技术链路学业评估98分代码经调试测试可直接部署。资源包共73个文件约10.48MB主体为py源码与pyc编译文件配合sql数据库脚本、xls数据表、图表及前端静态页面另有pptx演示文稿与README说明文档。演示文稿与说明文档可帮助使用者理解腾讯招聘、51job岗位信息采集、城市坐标转换及数据分析可视化的全流程实现思路。平台目前已有28人学习下载适合希望快速借鉴完整项目框架、掌握工程化爬虫项目组织方式的开发者。1. 招聘数据爬虫分析与可视化一条能把简历调研跑通的完整链路「Python招聘数据爬虫分析与可视化系统」听起来像课程大作业实际上它是一个很实用的个人项目把招聘网站上「岗位名称、公司、城市、薪资、经验要求、技能描述」抓下来清洗成一张干净规整的二维表再用图表把城市岗位分布、薪资中位数、技能需求量呈现在浏览器看板里。对求职者它能快速回答「我这个方向在哪个城市岗位多、钱多」对正在学 Python 的人它是一段能串起 python爬虫、数据分析、可视化三块技能的完整代码。下面的方案按最小可运行组合拆开requests 抓页面BeautifulSoup 解析SQLAlchemy SQLite 落库pandas 洗数据pyecharts 出图最后用 Flask 拼成一个可视化大屏。2. 爬虫采集层用 requests 抓招聘网站用 SQLAlchemy SQLite 落库2.1 选型为什么不是 Scrapy而是 requests BeautifulSoup招聘网站列表页大多数是服务端渲染的 HTML岗位名称、薪资、公司名直接写在 DOM 里。这类页面用 requests 发一个 GET 请求再用 BeautifulSoup 解析十几行代码就能拿到结构化数据。网络爬虫原理就是这样构造请求、获取响应、解析提取、循环翻页。那为什么不直接上 ScrapyScrapy 的并发和去重确实强但它有一套自己的 Twisted 调度体系项目拆成 items、spiders、pipelines新手拿到源码先被框架本身绊住。这个场景单机、单任务、每天抓几百页requests 完全够用出问题也容易定位。同理除非目标站点是纯 JavaScript 动态渲染、接口又难找否则不建议一开始就上 python selenium 反爬虫那套重型方案Selenium 慢、耗内存放到静态页上是杀鸡用牛刀。存储层我用 SQLAlchemy SQLite。SQLite 零配置文件即用SQLAlchemy 的 ORM 模型让代码可读性高后面 pandas 分析时用 SQLAlchemy engine 直接读表非常顺手。网上这套系统的源码喜欢用 MySQL但给本地学习用的项目配 MySQL 属于给自己找事装服务、配账号、处理远程连接权限跑通成本全花在环境上。2.2 岗位表结构设计先想清楚要分析什么再动手建表很多项目翻车的第一个原因就是字段没设计好抓完发现「经验要求」和「学历要求」挤在一个字符串里后期清洗非常痛苦。我建表的原则是一个字段只放一种语义能拆的提前拆。# models.py from sqlalchemy import create_engine, Column, Integer, String, Text from sqlalchemy.orm import declarative_base, sessionmaker DB_URI sqlite:///../data/jobs.db engine create_engine(DB_URI, echoFalse) Base declarative_base() class JobPosting(Base): __tablename__ job_posting id Column(Integer, primary_keyTrue, autoincrementTrue) url Column(String(500), uniqueTrue, indexTrue) # 岗位详情页URL唯一键防重复 title Column(String(200)) # 岗位名称 company Column(String(200)) # 公司名称 city Column(String(50)) # 城市 salary Column(String(100)) # 原始薪资字符串保留现场 experience Column(String(100)) # 经验要求文案 education Column(String(100)) # 学历要求文案 description Column(Text) # 岗位描述正文技能词统计的材料 published_at Column(String(50)) # 发布时间 crawl_time Column(String(50)) # 抓取时间增量抓取时用 Base.metadata.create_all(engine) Session sessionmaker(bindengine)逻辑说明url字段加uniqueTrue是整张表最重要的设计同一个岗位详情页的 URL 稳定不变拿它当唯一键重复抓取时数据库层面就能挡住。salary字段存原始字符串而不是直接拆好的数值这是个「后悔药」设计——解析规则写错了原始数据还在改完规则重跑清洗即可。description存岗位描述全文技能词统计全靠它不能省。参数说明echoFalse关闭 SQLAlchemy 的 SQL 日志否则控制台会被刷屏String(500)留给 URL短了会报数据超长错误。2.3 列表页采集requests 请求与 BeautifulSoup 解析定义好表结构写采集函数。核心是三步组装 headers 发请求、用 BeautifulSoup 找职位卡片、把每个卡片字段填进 ORM 模型。# crawler/spider.py import time import random import requests from bs4 import BeautifulSoup from models import Session, JobPosting BASE_URL https://example.com/jobs?page{} # 替换为目标站列表页模板 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: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9, } def fetch_list_page(page: int): 抓取列表页返回 BeautifulSoup 对象失败时返回 None url BASE_URL.format(page) try: resp requests.get(url, headersHEADERS, timeout10) if resp.status_code ! 200: print(fpage {page} status_code: {resp.status_code}) return None resp.encoding utf-8 return BeautifulSoup(resp.text, html.parser) except requests.RequestException as e: print(fpage {page} error: {e}) return None def parse_list_page(soup): 从职位卡片中提取字段返回字典列表 items [] # 以下选择器按目标站实际HTML结构调整当前写法是通用占位 for card in soup.select(li.job-card): link card.select_one(a.job-title) if not link: continue items.append({ url: link.get(href), title: link.get_text(stripTrue), company: card.select_one(.company-name).get_text(stripTrue), city: card.select_one(.job-city).get_text(stripTrue), salary: card.select_one(.salary).get_text(stripTrue), experience: card.select_one(.exp).get_text(stripTrue), }) return items def save_jobs(items): 入库前用URL去重已存在则跳过 session Session() saved 0 for it in items: if not it[url]: continue exists session.query(JobPosting).filter_by(urlit[url]).first() if exists: continue session.add(JobPosting(**it)) saved 1 session.commit() session.close() return saved逻辑说明parse_list_page里的选择器是占位写法不同招聘网站 HTML 差异极大拿到这套源码后第一件事就是把li.job-card、.job-title、.company-name改成目标站实际类名。save_jobs先按 URL 查一次再插入配合表的唯一索引重复抓同一页不会产生重复数据。参数说明timeout10是请求超时兜底网络抖动时不至于让整个脚本卡死resp.encoding utf-8是手动指定解码部分站点的响应头没写 charset 或写错不指定的话中文会变乱码。.get_text(stripTrue)会去掉标签内文字首尾空白比直接.text干净。2.4 翻页循环与限速把「能抓一页」变成「能抓全站」单页解析没问题后写循环。招聘站点普遍有反爬限速是必须的。我一般让每次请求间隔 0.5 到 1.5 秒随机浮动固定间隔容易被识别成脚本随机间隔能明显降低触发验证码的概率。# crawler/run_spider.py import time import random from spider import fetch_list_page, parse_list_page, save_jobs def crawl(max_pages30): for page in range(1, max_pages 1): soup fetch_list_page(page) if soup is None: continue items parse_list_page(soup) if not items: print(fpage {page}: no items, maybe blocked) break saved save_jobs(items) print(fpage {page}: parsed {len(items)}, new {saved}) time.sleep(random.uniform(0.5, 1.5)) # 随机延时降低请求特征 if __name__ __main__: crawl(30)逻辑说明if not items判断很关键很多站点反爬不是返回 403而是返回一个内容为空的正常页面。如果连续两页解析不到职位卡片说明触发了风控继续翻页只会加重封禁直接break保留现场数据等冷却后再从当前页续抓。参数说明max_pages30是单轮上限具体值看目标站总岗位数和你的耐性建议首次跑 5 页验证链路再放量。延时random.uniform(0.5, 1.5)表示每次等待 0.5 到 1.5 秒之间的随机值——单位是秒。提示不要对任何招聘网站发起高频、高并发抓取。这套代码的量级控制在单机、低频率、个人学习与调研范围内抓下来的数据只做统计分析不要二次发布或用于商业用途。3. 数据清洗层把薪资、城市、技能词从乱文本里抠出来3.1 用 pandas 把 SQLite 读成分析表爬虫落库之后数据是「能看但没法算」的状态薪资是15-25K·14薪这种字符串城市是广州-天河区这种带区划的写法技能全堆在description长文本里。数据分析的第一步是把表读成 DataFrame。# analysis/load_data.py import pandas as pd from models import engine df pd.read_sql(SELECT * FROM job_posting, engine) print(df.shape) print(df.head(3))pd.read_sql直接接收 SQLAlchemy 的 engine不需要自己拼连接串。df.shape先看行数列数df.head(3)看前三条长什么样。拿到手先检查空值数量df.isnull().sum()对每一列统计缺失值后续清洗才有方向。这一步不需要写入任何中间文件内存里操作最方便。3.2 薪资字符串拆分统一成「月薪中位数千/月」薪资字段是整张表里最需要小心的。常见格式有15-25K、1-1.5万/月、30-50K·14薪、面议、200-300/天。如果不做单位归一后面一算平均值全是错的。我的做法是写一个净化函数把字符串转成「月薪下限、上限、中位数」三个数值字段单位统一为千/月。# analysis/clean_salary.py import re import pandas as pd def parse_salary(s): 把薪资字符串转成 (下限K, 上限K, 中位数K)无法解析返回 (None, None, None) if not isinstance(s, str) or 面议 in s: return None, None, None # 提取数值区间兼容 15-25K、1-1.5万、30-50K·14薪 nums re.findall(r(\d\.?\d*), s) if len(nums) 2: return None, None, None low, high float(nums[0]), float(nums[1]) # 单位归一万/月 - 千/月乘以10 if 万 in s: low, high low * 10, high * 10 # 如果写的是年薪或日薪这里需要单独处理建议先过滤再解析 if 年 in s or 天 in s: return None, None, None mid round((low high) / 2, 1) return low, high, mid df[salary_low], df[salary_high], df[salary_mid] zip( *df[salary].map(parse_salary) ) print(df[[salary, salary_low, salary_high, salary_mid]].head(5))逻辑说明先判断单位再转数值万出现时乘 10 统一成 K。出现「年薪」「日薪」的样本直接置空宁可少统计也不能把口径不同的数据混进月薪均值。zip(*df[salary].map(parse_salary))把函数返回的三列展开赋给三个新字段。参数说明正则\d\.?\d*能匹配15和1.5这类整数与小数面议在解析前就被过滤。实际项目里还会遇到薪资面议、5-8千/月这种「千」字格式需要再加一层单位判断规则如下出现千不缩放出现万乘 10什么都不写默认 K。3.3 城市字段归并解决「广州」「广州-天河区」「广州(番禺)」并存招聘网站的 city 字段看似规整实际上同一个城市有多种写法广州、广州-天河区、广州(番禺)、广东-广州。做城市维度分析前必须归并。# analysis/clean_city.py import re def normalize_city(city): 把带区划的城市字段归并成地级市名称 if not isinstance(city, str): return city city city.strip() # 去掉括号内容如 (番禺)、-天河区 等后缀 city re.sub(r[(].*?[)], , city) city re.sub(r[-].*$, , city) return city df[city_clean] df[city].map(normalize_city) print(df[city_clean].value_counts().head(10))逻辑说明两个正则分别处理括号后缀和横线后缀[(].*?[)]用非贪婪匹配去掉括号里的区划[-].*$从横线开始截断。value_counts()是最直观的核对方式归并后如果还有广州天河区这种无标点粘连再按字典手动映射。参数说明re.sub的替换结果是新字符串原字段保留不动这样万一归并规则写错还能回到原始数据重来。城市归并是典型的「看起来简单、实际坑多」环节跑完一定要肉眼抽查几十条别信正则一次到位。3.4 技能关键词统计Java、Javascript、Python 不能混技能需求量是招聘分析里最有价值的部分统计方法却不复杂准备一份技能关键词表逐个去description里匹配。真正的坑在于大小写和词边界——javascript里面包含java简单用java in text会把所有 Javascript 岗位都算进 Java 里。# analysis/skill_stats.py import re from collections import Counter SKILLS [Python, Java, Javascript, Vue, React, Spring, Docker, Kubernetes, Redis, MySQL, Hadoop, Spark, Flink, Linux] def count_skills(descriptions): 统计技能词在岗位描述中出现的次数英文词按边界匹配 counter Counter() for desc in descriptions: if not isinstance(desc, str): continue text desc.lower() for skill in SKILLS: key skill.lower() if key java: # 用正则词边界匹配避免命中 javascript/java 的子串 if re.search(r\bjava\b, text): counter[skill] 1 else: if key in text: counter[skill] 1 return counter skill_counter count_skills(df[description].tolist()) for skill, count in skill_counter.most_common(10): print(f{skill}: {count})逻辑说明\bjava\b里的\b是单词边界能把java从javascript里区分出来。中文里没有空格分词所以 Vue、React 这类英文词用简单in判断即可如果描述里出现Java开发工程师\bjava\b匹配java后面的空格或标点能正常命中。参数说明关键词表SKILLS按目标岗位方向自行扩展比如分析前端岗位就加Vue、React、Webpack分析大数据就加Hadoop、Spark、Flink。Counter.most_common(10)输出频次前十这就是后面可视化图表的原料。想在清洗阶段就用上分词工具的话可以引入 jieba 做中文分词再统计但词表匹配在岗位描述这种半结构化文本上更快、结果更可控。4. 可视化层用 pyecharts Flask 把统计结果拼成招聘大屏4.1 pyecharts 2.x 的链式写法底层就是 EChartspyecharts 生成的图表本质上是一套 ECharts 配置Python 负责算数据、拼 JSON浏览器负责渲染。网上很多老源码用的是 pyecharts 0.5.x 的写法Bar(标题)这种实例化方式在 2.x 里已经废了新版本统一用链式写法数据配置和样式配置分开代码结构更清晰。岗位分析看板通常放四张图城市岗位数量柱状图、城市平均薪资横向柱状图、技能需求 Top 10 条形图、学历要求饼图。下面先用 pyecharts 生成前两张图。4.2 从 DataFrame 到图表城市岗位分布与薪资对比# visual/charts.py from pyecharts.charts import Bar, Pie from pyecharts import options as opts def city_job_bar(city_counts): 城市岗位数量柱状图 cities city_counts.index.tolist() counts city_counts.values.tolist() bar ( Bar(opts.InitOpts(width800px, height400px)) .add_xaxis(cities) .add_yaxis(岗位数量, counts, category_gap40%) .set_global_opts( title_optsopts.TitleOpts(title城市岗位数量 Top20), xaxis_optsopts.AxisOpts(axislabel_optsopts.LabelOpts(rotate45)), yaxis_optsopts.AxisOpts(name岗位数), ) ) return bar def city_salary_bar(city_salary): 城市平均薪资横向柱状图 cities city_salary.index.tolist() salaries city_salary.values.tolist() bar ( Bar(opts.InitOpts(width800px, height500px)) .add_xaxis(cities) .add_yaxis(平均月薪(K), salaries) .reversal_axis() # 横向排列 .set_global_opts( title_optsopts.TitleOpts(title城市平均月薪 Top20), xaxis_optsopts.AxisOpts(name薪资(K/月)), ) ) return bar逻辑说明city_counts来自第 3 章的df[city_clean].value_counts()DataFrame 的 index 是城市名、values 是对应数量city_salary来自df.groupby(city_clean)[salary_mid].median().sort_values()。两张图对同一个数据中心做不同分组聚合体现的是清洗结果不需要额外计算。参数说明opts.InitOpts(width800px, height400px)控制画布尺寸rotate45让 X 轴城市名倾斜 45 度否则城市名互相遮挡reversal_axis()把柱状图转成横向城市名放左侧更易读。pyecharts 2.x 里category_gap控制柱子间距数值越大柱越细。4.3 可视化大屏Flask 本地服务 多图表拼接图表对象生成后怎么把它们拼成一个可浏览的页面我一般用 Flask 起本地服务在路由里把每张图渲染成 HTML 片段再嵌入一个拼好的大屏模板。pyecharts 2.x 中图表对象调用.render_embed()就能输出完整的div和script片段直接塞进 Jinja2 模板即可。# visual/app.py from flask import Flask, render_template from charts import city_job_bar, city_salary_bar app Flask(__name__) app.route(/) def dashboard(): # 实际数据从第3章清洗后的 df 聚合而来这里省略重复计算 city_counts get_city_counts() city_salary get_city_salary() job_bar city_job_bar(city_counts) salary_bar city_salary_bar(city_salary) return render_template( dashboard.html, job_barjob_bar.render_embed(), salary_barsalary_bar.render_embed(), ) if __name__ __main__: app.run(host127.0.0.1, port8000, debugFalse)!-- templates/dashboard.html -- !DOCTYPE html html langzh-CN head meta charsetUTF-8 title招聘数据分析看板/title style .chart-container { display: flex; flex-wrap: wrap; gap: 16px; padding: 16px; } .chart-box { border: 1px solid #eee; border-radius: 8px; padding: 8px; } /style /head body h2 stylepadding-left:16px;招聘数据可视化大屏/h2 div classchart-container div classchart-box{{ job_bar | safe }}/div div classchart-box{{ salary_bar | safe }}/div /div /body /html逻辑说明render_template把图表 HTML 片段传进模板| safe是 Jinja2 的过滤器告诉模板引擎这段内容是可信 HTML不要转义。这个页面只放了两张图实际项目可以按同样方式继续加 Pie、Map、WordCloud栅格布局由 CSSflex-wrap控制。参数说明app.run(host127.0.0.1, port8000, debugFalse)里debugFalse是运行模式本地调试可以开 True但开 True 时 Flask 会加载重载器多进程下日志会很吵。render_embed()相比chart.render(chart.html)的优点是不落文件模板渲染时动态生成适合数据每天更新的场景。5. 招聘数据爬虫项目的 5 个高频翻车点现象、原因与解法5.1 网上拿到的源码跑不起来九成是版本问题现象pip install -r requirements.txt之后运行报ModuleNotFoundError: No module named pyecharts或者提示Bar初始化参数不对、chart.render()找不到文件。原因Python 版本不同依赖库版本不同。老源码多半基于 pyecharts 0.5.xnew版本的 API 变化很大还有的源码要求的flask、sqlalchemy版本和你环境里的不一致。很多人第一反应是怀疑代码有问题实际上环境不匹配才是黑匣子。解决先python --version确认解释器版本再pip list看关键库版本。建议建虚拟环境按源码附带的requirements.txt原样安装不要用全局环境跑如果源码没带依赖清单就按报错栈逐个排查通常是pyecharts的写法不兼容。老代码改新 API 的工作量不小看你手里的源码质量决定是改还是重写。5.2 列表页浏览器能打开爬虫却拿不到数据现象requests 请求返回 200但解析结果为空或者页面内容里全是登录框、验证码组件有的站点返回 403 Forbidden。原因浏览器请求带了完整的请求头尤其是User-Agent和Cookie而脚本只带了 UA站点风控直接拒绝。更隐蔽的情况是「页面照常返回但岗位列表是通过接口异步加载的」静态 HTML 里根本没有职位卡片BeautifulSoup 自然捞不到。解决先打印响应前 500 个字符肉眼判断返回的是不是正常页面。如果是登录页打开浏览器手动登录一次把Cookie复制进HEADERS如果是异步加载按 F12 找数据接口直接抓 JSON 比解析 HTML 更稳。遇到反爬加塞 Selenium 不是首选先试试「带 Cookie 随机延时 低频」这套组合。5.3 平均薪资算出离谱数字几万和几十万混在一起现象按城市算平均月薪出来一个三千另一个三十万图表完全没法看。原因15-25K·14薪里的14薪被正则当成第二个数字取走了1-1.5万/月没做单位转换按 K 计算直接差 10 倍200-300/天的日薪被当成月薪参与均值。解决清洗函数里加三条规则——第一14薪这类数字必须排除正则改为只取「数字 单位」紧挨着的片段第二单位万出现时整体乘 10第三天、年、小时出现的直接置空不参与月薪统计。调完规则后打印salary_mid.describe()看min、max、median是否符合常识。薪资清洗没有捷径每个异常格式都来自真实数据遇到一条补一条规则。5.4 控制台打印正常CSV 导出后中文全乱码现象pandas 输出到控制台是正常中文df.to_csv(data.csv)后用 Excel 打开全乱码Notepad 打开正常。原因to_csv默认用 UTF-8 编码写文件Excel 打开 CSV 时默认按 GBK 解码两边对不上。控制台不乱码是因为终端用的就是 UTF-8掩盖了文件编码问题。解决导出时指定encodingutf-8-sig。utf-8-sig会在文件头部写入 BOM 标记Excel 识别到 BOM 后自动按 UTF-8 解码。同样的问题也出现在 requests 抓取时resp.encoding不手动指定就可能用错字符集抓回来就是乱码这一步在爬虫里就要处理好。df.to_csv(data/export.csv, indexFalse, encodingutf-8-sig)5.5 重复跑两次爬虫图表数量翻倍现象第一次抓完 1000 条第二次再跑变成 2000 条技能词统计和城市分布全部失真。原因没有去重逻辑。程序重跑时同一个岗位详情页被重复入库ORM 里也没做存在性检查。后面图表都是基于count()做的聚合重复数据直接影响所有统计结果。解决两层防护。第一层在表结构上url字段设uniqueTrue第二层在save_jobs里先query.filter_by(url...)判断再插入。满数据库的去重本身也有成本岗位量到几十万级别后可以换redis集合做 URL 指纹去重抓之前先SADD判断是否已存在内存操作比查 SQLite 快一个数量级。6. 给系统加个增量抓取让数据每天自动更新现在整套链路能跑通但每次手动执行run_spider.py、clean_data.py、app.py三步很烦而且全量重抓会重复消耗目标站资源。常见做法是把抓取改成增量模式每次只抓「前几页里上次没见过的新岗位」配合定时任务每天自动执行一次。增量判断不需要额外字段URL 唯一键就够了。抓取前先查一下最新一条crawl_time或者直接每次抓前 5 页URL 已在库里的跳过新的入库。新岗位更新间隔一般不会太短每天抓一次前几页足够跟上节奏# scheduler.py from apscheduler.schedulers.blocking import BlockingScheduler from crawler.run_spider import crawl from analysis.clean_salary import run_clean from visual.charts import refresh_charts def daily_job(): crawl(max_pages10) # 增量抓取 run_clean() # 重新清洗 refresh_charts() # 重新生成图表 if __name__ __main__: scheduler BlockingScheduler(timezoneAsia/Shanghai) scheduler.add_job(daily_job, cron, hour2, minute30) print(scheduler started, daily job at 02:30) scheduler.start()逻辑说明APScheduler的cron触发器指定每天凌晨 2 点 30 分执行这个时段目标站负载低反爬也相对不敏感。crawl(max_pages10)只抓前 10 页已存在的 URL 自动跳过相当于每天只采集新增岗位。清理和图表刷新放在同一个任务里数据更新后看板自动跟上。验证方法很简单第一次跑完记录df.shape[0]第二天同一时间看这个数字如果比前一天大说明增量生效如果连续几天不变不是没新岗位就是被反爬拦截了去查看日志里的no items, maybe blocked。岗位量到了百万级再去引入 redis 做 URL 去重抓取前先判断成员是否存在比查数据库更省。我自己做这类项目有个习惯清洗函数永远比爬虫函数先写。爬虫跑出来的数据是脏的清洗规则写清楚后爬虫的字段设计才有依据反过来先抓了一堆数据再想怎么洗规则改了还要回头重抓那才是真折腾。把这套链路跑通之后换数据源、换分析维度、换图表样式都是顺手的事骨架没变变的是解析规则和展示形式。希望帮到你。本文还有配套的精品资源点击获取
返回列表