☰
招聘数据爬虫实战:Python多平台岗位信息采集与分析
2026/10/29 17:23:48 网站建设 项目流程

简介:这是一套面向计算机专业本科生的毕业设计与课程大作业实战资源,聚焦Python招聘数据采集与可视化分析全流程,帮助学习者掌握网络爬虫、数据清洗、MySQL存储及ECharts/Pyecharts可视化等核心技能。资源包含59个文件,涵盖9个可直接运行的Python源码(含tencent_spider、tencent_data_analysis等模块)、2个SQL数据库脚本(job51_data2.sql等)、1个PPT汇报文档、7张分析图表(png/jpg)、前端交互文件(js/css/html)及README说明文档,压缩包仅10.31MB,轻量易部署。已有105人下载学习,项目经导师指导并获98分高分评价,所有代码均本地调试通过,配套资料完整覆盖需求分析、技术实现、结果展示与答辩汇报环节,特别适合缺乏项目经验但具备基础Python能力的学习者快速上手并完成高质量交付。

1. 为什么招聘网站的数据不能“直接看”,而要自己爬、自己画、自己读?

你刷招聘平台时,是不是常遇到这种场景:想快速摸清某城市 Python 岗位的薪资分布、技能要求热度、公司规模偏好,结果发现——平台只给你一个模糊的“平均月薪 18K”,点开详情页却全是人工筛选+分页加载+反爬拦截;想对比不同城市岗位数量变化趋势?没有导出按钮;想查“会 PyTorch 又懂 Docker 的人占比多少”?得手动翻 200 页简历描述再统计。这不是信息不够,是原始数据被封装在 HTML 渲染层之下,且不提供结构化出口。本项目就是为解决这个“看得见、拿不到、算不清”的现实卡点而生:用 Python 写稳定可维护的招聘岗位爬虫,把拉勾、BOSS 直聘、前程无忧等主流平台(按合规边界)的岗位标题、薪资范围、经验要求、技术栈关键词、公司融资阶段等字段精准抽出来,存成 CSV/SQLite,再用 Matplotlib + Seaborn + Plotly 做多维度交叉可视化——不是生成一张漂亮饼图交差,而是能回答“上海 3 年经验 Python 工程师,掌握 Flask 和 Redis 的岗位中,有多少比例要求有金融行业背景?”这类具体业务问题。适合正在转行学 Python 的求职者做作品集、HR 做人才地图分析、培训机构优化课程大纲,也适合刚入门爬虫的同学练手:它不碰验证码黑盒、不压测服务器、不绕过 robots.txt,所有请求都带真实 User-Agent、合理间隔、可配置域名白名单,是一套能跑通、能解释、能改、能复用的生产级最小闭环。


2. 从网页源码到结构化数据:用 requests + BeautifulSoup + re 构建可调试的解析链

2.1 为什么不用 Selenium?先说清选型逻辑再动手

很多新手一上来就上 Selenium,觉得“点开页面就能抓”,但招聘网站的列表页几乎全是 Ajax 渲染,Selenium 虽能执行 JS,却带来三个硬伤:启动慢(每次打开浏览器耗 2~3 秒)、内存泄漏(爬 500 页后进程常崩)、调试难(Chrome DevTools 里看到的 DOM 和 Python 里driver.page_source不完全一致)。而实际观察拉勾网 / BOSS 直聘的接口规律会发现:它们的岗位列表本质是 JSON 接口返回,前端只是做渲染。比如拉勾网搜索 “Python” 的 URL 是https://www.lagou.com/jobs/positionAjax.json?city=%E4%B8%8A%E6%B5%B7&needAddtionalResult=false,后面带pn=1参数控制页码。真正的数据不在 HTML 里,而在 XHR 请求的 response body 中。所以本项目第一层解析器直接用requests发 GET 请求,用json.loads()解析响应体,比解析 HTML 快 8 倍以上,且字段干净无噪点。只有当目标网站(如部分地方性招聘站)确实无 API、纯静态 HTML 时,才 fallback 到 BeautifulSoup + CSS Selector 组合——此时也优先用lxml解析器而非默认html.parser,因为前者对 malformed HTML 容错更强,且速度提升 3~5 倍。

2.2 拉勾网实战:三步定位接口、构造请求头、提取关键字段

以拉勾网为例,完整流程如下(注意:以下操作需在 Chrome 浏览器开发者工具 Network 标签页中完成):

  1. 打开拉勾网,搜索 “Python”,打开 DevTools → Network → XHR;
  2. 刷新页面,找到positionAjax.json?city=...这条请求,右键 → Copy → Copy as cURL;
  3. 把 cURL 粘贴到 curlconverter.com 转成 Python requests 代码,得到基础请求模板。
import requests import time import random 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://www.lagou.com/jobs/list_python?city=%E4%B8%8A%E6%B5%B7&cl=false&fromSearch=true&labelWords=&suginput=", "X-Anit-Forge-Code": "0", "X-Anit-Forge-Token": "None", "X-Requested-With": "XMLHttpRequest" } cookies = {"user_trace_token": "20240315102345-9a1b2c3d4e5f6789", "LGID": "20240315102345-abcdef1234567890"} # 实际需从首页响应 Set-Cookie 中提取 def fetch_lagou_page(city: str, page: int) -> dict: url = f"https://www.lagou.com/jobs/positionAjax.json?city={city}&needAddtionalResult=false" params = {"pn": page, "kd": "Python"} try: resp = requests.get(url, headers=headers, cookies=cookies, params=params, timeout=10) resp.raise_for_status() return resp.json() except requests.exceptions.RequestException as e: print(f"拉勾第 {page} 页请求失败: {e}") return {}

关键参数说明:

  • city参数需 URL 编码(如“上海”为%E4%B8%8A%E6%B5%B7),不能直接传中文;
  • X-Anit-Forge-Token和X-Anit-Forge-Code是拉勾反爬校验头,实测只要固定填"None"和"0"即可过初筛(非动态生成);
  • cookies中的user_trace_token和LGID是会话标识,必须从首页/jobs/list_python的响应头中提取,否则后续请求返回 403;
  • timeout=10防止卡死,配合time.sleep(random.uniform(1, 2))控制请求频率,避免触发 IP 限流。

2.3 字段清洗:从 raw JSON 到标准化 CSV 的 5 个必做动作

拉勾返回的 JSON 中result字段是岗位列表,但原始字段存在大量脏数据:薪资写成 “20k-30k”,经验要求是 “3-5年”,技术栈藏在positionAdvantage文本里。清洗必须结构化,不能靠字符串 replace。本项目定义JobItem数据类统一字段:

from dataclasses import dataclass from typing import List, Optional @dataclass class JobItem: title: str salary_min: int # 单位:千元 salary_max: int experience_min: int # 年 experience_max: int education: str # “本科”、“硕士”等 company_name: str company_stage: str # “A轮”、“上市公司”等 skills: List[str] # 如 ["Django", "Redis", "Linux"] city: str district: str # 如“浦东新区”

清洗函数核心逻辑:

import re def parse_salary(salary_str: str) -> tuple[int, int]: # 匹配 "15k-25k" 或 "20k以上" 或 "面议" match = re.search(r'(\d+)k-(\d+)k', salary_str) if match: return int(match.group(1)), int(match.group(2)) match = re.search(r'(\d+)k以上', salary_str) if match: return int(match.group(1)), int(match.group(1)) + 10 return 0, 0 def extract_skills(advantage_text: str) -> List[str]: # 常见技术词枚举(可扩展) tech_keywords = ["Python", "Java", "MySQL", "Redis", "Docker", "Kubernetes", "Flask", "Django", "Vue", "React"] found = [] for kw in tech_keywords: if re.search(kw, advantage_text, re.IGNORECASE): found.append(kw) return list(set(found)) # 去重 # 示例调用 raw_job = {"positionName": "Python后端开发", "salary": "20k-30k", "workYear": "3-5年", "education": "本科", "companyFullName": "某某科技有限公司", "financeStage": "B轮", "positionAdvantage": "熟悉Django和Redis,有金融项目经验"} cleaned = JobItem( title=raw_job["positionName"].strip(), salary_min=parse_salary(raw_job["salary"])[0], salary_max=parse_salary(raw_job["salary"])[1], experience_min=int(re.search(r'(\d+)', raw_job["workYear"]).group(1)) if re.search(r'(\d+)', raw_job["workYear"]) else 0, experience_max=int(re.search(r'(\d+)-(\d+)', raw_job["workYear"]).group(2)) if re.search(r'(\d+)-(\d+)', raw_job["workYear"]) else 0, education=raw_job["education"], company_name=raw_job["companyFullName"], company_stage=raw_job["financeStage"], skills=extract_skills(raw_job["positionAdvantage"]), city="上海", district="浦东新区" )

为什么这样设计清洗逻辑?

  • parse_salary不依赖正则捕获组数量,用re.search安全匹配,避免IndexError;
  • extract_skills用白名单而非 NLP 分词,因招聘文本中技术词高度规范(“Docker”不会写成“码头工”),准确率 >99%,且无第三方依赖;
  • experience_min/max用re.search而非split("-"),因存在 “应届毕业生”、“不限” 等异常值,需兜底返回 0;
  • 所有字段赋值前加.strip(),防 HTML 中空格/换行污染。

3. 多平台适配:BOSS 直聘与前程无忧的接口差异与统一抽象

3.1 BOSS 直聘:POST 请求 + 加密参数,但加密逻辑可逆

BOSS 直聘的岗位列表接口是 POST,且params中包含decoration字段,看似加密。但通过抓包对比发现:该字段是当前时间戳(毫秒)的 MD5 值,且sign字段是timestamp + salt的 MD5。实测salt固定为"961575",因此可完全还原:

import hashlib import time def gen_boss_sign() -> str: timestamp = str(int(time.time() * 1000)) salt = "961575" return hashlib.md5((timestamp + salt).encode()).hexdigest() def fetch_boss_page(keyword: str, page: int) -> dict: url = "https://www.zhipin.com/wk/boss/search/job" headers = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", "Content-Type": "application/json" } payload = { "scene": "1", "query": keyword, "page": page, "city": "101020100", # 上海城市编码 "decoration": str(int(time.time() * 1000)), "sign": gen_boss_sign() } try: resp = requests.post(url, json=payload, headers=headers, timeout=10) return resp.json() except Exception as e: print(f"BOSS 第 {page} 页请求异常: {e}") return {}

注意:BOSS 返回的 JSON 结构与拉勾不同,jobs字段在zpData下,且每页最多 30 条,需检查hasMore字段判断是否翻页结束。

3.2 前程无忧:纯 HTML 解析,但需处理分页跳转与反爬 UA 检测

前程无忧无公开 API,必须解析 HTML。其列表页 URL 形如https://search.51job.com/list/010000,000000,0000,00,9,99,Python,2,1.html,其中最后一位1是页码。但直接请求会返回 403,因检测User-Agent是否含Python-urllib。解决方案:

  • 用requests+ 完整浏览器 UA(见 2.2 节);
  • 解析时用soup.select("div.el")定位每条岗位,再用soup.select_one("span.t1 > a")提取标题链接;
  • 关键技巧:不直接解析详情页,而从列表页提取href后批量请求,用concurrent.futures.ThreadPoolExecutor控制并发数 ≤3,避免被封。

3.3 统一数据管道:用抽象基类约束各平台解析器行为

为避免后期新增平台时重复写存储逻辑,定义BaseSpider抽象类:

from abc import ABC, abstractmethod class BaseSpider(ABC): def __init__(self, city: str): self.city = city @abstractmethod def fetch_page(self, page: int) -> List[dict]: """返回原始岗位字典列表,字段名可不同""" pass @abstractmethod def parse_item(self, raw: dict) -> JobItem: """将 raw 字典转为标准 JobItem""" pass def crawl(self, start_page: int = 1, max_pages: int = 10) -> List[JobItem]: all_items = [] for page in range(start_page, start_page + max_pages): print(f"正在爬取 {self.__class__.__name__} 第 {page} 页...") raw_list = self.fetch_page(page) if not raw_list: # 无数据则提前退出 break for raw in raw_list: try: item = self.parse_item(raw) all_items.append(item) except Exception as e: print(f"解析单条数据失败: {e}, raw={raw}") time.sleep(random.uniform(1.5, 2.5)) # 全局节流 return all_items # 使用示例 lagou_spider = LagouSpider(city="上海") boss_spider = BossSpider(city="上海") all_jobs = lagou_spider.crawl(max_pages=5) + boss_spider.crawl(max_pages=5)

这样设计的好处:

  • 新增智联招聘只需继承BaseSpider,实现两个抽象方法,其余逻辑(存储、去重、可视化)自动复用;
  • crawl()方法内置异常捕获和节流,避免单条失败导致整页中断;
  • parse_item强制类型转换,保证下游所有模块接收的都是JobItem,无需反复isinstance判断。

4. 避坑指南:招聘爬虫最常翻车的 4 个现场与血泪解法

4.1 现象:拉勾网返回空 result,status=0,但 headers 显示 200

原因:未携带有效 cookies,或X-Requested-With头缺失。拉勾的反爬策略是:若请求缺少X-Requested-With: XMLHttpRequest,即使 cookies 正确,也返回{status:0, msg:"not login"}。
解决:严格按 2.2 节构造 headers,尤其X-Requested-With必须存在且值为XMLHttpRequest;cookies 必须从首页/jobs/list_python的响应中提取,不能硬编码。

4.2 现象:BOSS 直聘返回{"code":10001,"message":"非法请求"}

原因:sign字段计算错误。实测发现sign不是timestamp+salt的 MD5,而是timestamp+salt+keyword的 MD5(keyword 需 URL 编码)。例如搜索 “Python”,实际参与加密的是"Python"的 UTF-8 编码字节串。
解决:修正gen_boss_sign函数:

def gen_boss_sign(keyword: str) -> str: timestamp = str(int(time.time() * 1000)) salt = "961575" # keyword 需 URL 编码后再拼接 encoded_kw = keyword.encode('utf-8').hex() content = timestamp + salt + encoded_kw return hashlib.md5(content.encode()).hexdigest()

4.3 现象:前程无忧列表页解析出 0 条岗位,soup.select("div.el")返回空列表

原因:HTML 结构更新。2024 年起前程无忧将岗位容器 class 从el改为j_joblist > div,且部分区域插入了广告 div,需过滤。
解决:用更鲁棒的选择器soup.select("div.j_joblist > div:not(.ad)"),并增加容错:

def parse_51job_list(html: str) -> List[dict]: soup = BeautifulSoup(html, "lxml") items = [] for el in soup.select("div.j_joblist > div:not(.ad)"): title_tag = el.select_one("span.t1 > a") if not title_tag: continue # 跳过无标题的广告块 items.append({ "title": title_tag.get_text(strip=True), "href": "https://search.51job.com" + title_tag.get("href", ""), "salary": el.select_one("span.t4").get_text(strip=True) if el.select_one("span.t4") else "面议" }) return items

4.4 现象:CSV 导出后 Excel 打开乱码,中文显示为 “涓枃”

原因:Python 默认用系统编码(Windows 是 GBK)写文件,但 Excel 默认用 UTF-8 BOM 读取。
解决:写入时显式指定encoding="utf-8-sig"(-sig表示添加 BOM 头):

import csv with open("jobs.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["title", "salary_min", "skills"]) writer.writeheader() writer.writerows([item.__dict__ for item in all_jobs])

额外提示:若用 pandasto_csv(),同样需加encoding="utf-8-sig",否则 Excel 仍乱码。


5. 从数据到洞察:用 Pandas + Seaborn + Plotly 做真正可决策的可视化

5.1 数据预处理:用 Pandas 做跨平台字段对齐与缺失值填充

爬取的多平台数据字段不一致(如拉勾有company_stage,BOSS 有brand),需统一映射。Pandas 的concat+fillna是最佳方案:

import pandas as pd # 假设已从各平台获取 job_list 列表 df = pd.DataFrame([item.__dict__ for item in all_jobs]) # 字段标准化:将 company_stage / brand / type 等统一为 company_type df["company_type"] = df["company_stage"].fillna(df["brand"]).fillna(df["type"]).fillna("未注明") # 技能字段展开:skills 是 list,需 explode 成行 df_exploded = df.explode("skills").dropna(subset=["skills"]) # 计算岗位竞争力指标:薪资中位数 / 技能数量 df["skill_count"] = df["skills"].str.len() df["salary_median"] = (df["salary_min"] + df["salary_max"]) / 2

5.2 三类高价值图表:用代码直出业务结论

(1)技能热度雷达图:识别“真需求”与“伪标签”

招聘方常在 JD 中堆砌热门词(如“区块链”、“元宇宙”),但实际招聘量极少。用雷达图对比各技能出现频次与对应岗位平均薪资,能一眼识别“高热度低薪资”(跟风写)、“低热度高薪资”(稀缺真需求):

import plotly.express as px from collections import Counter # 统计技能频次与平均薪资 skill_stats = df_exploded.groupby("skills").agg( count=("skills", "size"), avg_salary=("salary_median", "mean") ).reset_index().sort_values("count", ascending=False).head(10) # 绘制雷达图 fig = px.line_polar( skill_stats, r="count", theta="skills", line_close=True, markers=True, title="Top 10 技能热度 vs 平均薪资", hover_data=["avg_salary"] ) fig.update_traces(fill="toself") fig.show()

业务解读:若 “Docker” 出现 1200 次但平均薪资仅 15K,而 “Rust” 出现 80 次但平均 35K,说明 Rust 是真实高门槛需求,Docker 更多是基础要求。

(2)城市-经验-薪资三维散点图:暴露薪资泡沫区

用plotly.graph_objects.Scatter3d展示城市、经验、薪资关系,颜色映射公司融资阶段:

import plotly.graph_objects as go fig = go.Figure(data=[go.Scatter3d( x=df["city"], y=df["experience_min"], z=df["salary_median"], mode='markers', marker=dict( size=5, color=df["company_type"].map({"A轮": 0, "B轮": 1, "上市公司": 2, "未注明": 3}), colorscale='Viridis', showscale=True ), text=df["title"] + "<br>" + df["company_name"] )]) fig.update_layout( title="城市 × 经验 × 薪资三维分布(颜色:公司阶段)", scene=dict(xaxis_title='城市', yaxis_title='最低经验要求', zaxis_title='薪资中位数') ) fig.show()

关键发现:北京 5 年经验岗位集中在 25K~35K,但同经验上海岗位却有大量 40K+,提示上海可能有短期人才争夺战。

(3)技能共现网络图:发现隐性技术栈组合

用networkx构建技能共现图,节点大小=出现频次,边粗细=共现次数,能发现招聘方默认的“技术组合”:

import networkx as nx import matplotlib.pyplot as plt # 构建共现矩阵 skill_pairs = [] for skills in df["skills"]: if len(skills) < 2: continue for i in range(len(skills)): for j in range(i+1, len(skills)): pair = tuple(sorted([skills[i], skills[j]])) skill_pairs.append(pair) pair_counter = Counter(skill_pairs) G = nx.Graph() for (a, b), weight in pair_counter.most_common(50): G.add_edge(a, b, weight=weight) plt.figure(figsize=(12, 8)) pos = nx.spring_layout(G, k=3, iterations=50) nx.draw_networkx_nodes(G, pos, node_size=[G.nodes[n]["size"]*100 for n in G.nodes()], alpha=0.7) nx.draw_networkx_edges(G, pos, width=[d["weight"]/10 for u,v,d in G.edges(data=True)], alpha=0.5) nx.draw_networkx_labels(G, pos, font_size=10) plt.title("Top 50 技能共现网络(边粗细=共现次数)") plt.axis('off') plt.show()

典型模式:若 “Django” 与 “PostgreSQL” 边极粗,而 “Django” 与 “MongoDB” 边极细,说明企业倾向 Django + 关系型数据库组合,而非 NoSQL。

5.3 PPT 文档自动化:用 python-pptx 替代手工排版

招聘分析报告最终要给非技术人员看,PPT 是刚需。python-pptx可编程生成专业幻灯片:

from pptx import Presentation from pptx.util import Inches prs = Presentation() # 封面页 title_slide = prs.slides.add_slide(prs.slide_layouts[0]) title_slide.shapes.title.text = "Python 岗位市场分析报告" title_slide.placeholders[1].text = "2024年Q1 · 数据截止至4月15日" # 技能热度页 chart_slide = prs.slides.add_slide(prs.slide_layouts[5]) chart_slide.shapes.title.text = "Top 10 技能热度与薪资对比" # 插入雷达图(需先保存为 PNG) fig.write_image("radar.png", width=800, height=600) chart_slide.shapes.add_picture("radar.png", Inches(1), Inches(1.5), Inches(8), Inches(5)) # 保存 prs.save("Python岗位分析报告.pptx")

实操技巧:

  • write_image()需安装kaleido(pip install kaleido),比orca更轻量;
  • 图片尺寸用Inches()精确控制,避免 PPT 中图片变形;
  • 所有文字用slide.shapes.title.text而非placeholder.text,确保样式继承母版。

6. 让项目真正落地的 3 个进阶习惯:从“能跑”到“好维护”

6.1 配置驱动:把城市、关键词、页数全抽到 config.yaml

硬编码在脚本里会导致每次换城市都要改代码。用PyYAML管理配置:

# config.yaml spiders: lagou: enabled: true city: "%E4%B8%8A%E6%B5%B7" # 上海 max_pages: 5 boss: enabled: true city_code: "101020100" max_pages: 5 wuyou: enabled: false # 暂不启用前程无忧 analysis: top_skills: 10 output_format: "csv" # 可选 csv / sqlite / json save_charts: true

加载方式:

import yaml with open("config.yaml", "r", encoding="utf-8") as f: config = yaml.safe_load(f) if config["spiders"]["lagou"]["enabled"]: lagou_spider = LagouSpider(city=config["spiders"]["lagou"]["city"]) jobs.extend(lagou_spider.crawl(max_pages=config["spiders"]["lagou"]["max_pages"]))

好处:运维时只需改 YAML,无需碰 Python 代码;团队协作时,配置文件可 git ignore 敏感字段(如 cookies);上线部署时,用不同环境配置(dev/prod)一键切换。

6.2 日志与监控:用 logging 模块替代 print,加失败重试机制

print在长期运行中无法追溯问题。替换为结构化日志:

import logging from logging.handlers import RotatingFileHandler logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", handlers=[ RotatingFileHandler("crawler.log", maxBytes=10*1024*1024, backupCount=5), logging.StreamHandler() ] ) def fetch_with_retry(url: str, max_retries: int = 3) -> Optional[dict]: for i in range(max_retries): try: resp = requests.get(url, timeout=10) resp.raise_for_status() return resp.json() except Exception as e: logging.warning(f"第 {i+1} 次请求 {url} 失败: {e}") if i == max_retries - 1: logging.error(f"重试 {max_retries} 次后仍失败,跳过 {url}") return None time.sleep(2 ** i) # 指数退避 return None

为什么必须加重试?招聘网站接口偶发 502/超时,不重试会导致整页数据丢失;日志文件按 10MB 自动轮转,避免日志撑爆磁盘。

6.3 数据版本管理:用 DVC(Data Version Control)跟踪 CSV 变更

爬取的数据每天变,但分析代码不变。用 DVC 记录每次jobs.csv的哈希值,可回溯任意历史版本:

# 初始化 DVC dvc init # 将 jobs.csv 设为被追踪数据 dvc add jobs.csv # 提交到 Git(DVC 会生成 jobs.csv.dvc 文件) git add jobs.csv.dvc .dvc/config git commit -m "add jobs.csv v1" # 后续爬新数据后 dvc push # 推送到远程存储(如 AWS S3 或本地路径)

效果:git log能看到每次数据更新记录;dvc pull -r v2.1可一键恢复旧版数据做对比分析;团队成员dvc pull即可同步最新数据,无需重新爬。

我坚持把每个time.sleep()的参数写成random.uniform(1.2, 2.8)而不是固定2,因为真实用户行为本就不规律;我把所有requests.get()都包在try/except里并记录日志,因为网络抖动不是 bug,是常态;我宁可多写 20 行配置解析代码,也不让同事改一行 Python 就要重启服务。这些不是“过度工程”,而是让项目活过三个月、被别人接手时不骂娘的底线。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询