简介:一套基于Python的招聘岗位数据爬虫与可视化分析完整项目,面向正在准备毕业设计或期末大作业的计算机相关专业学生,也适合需要实战练习的Python数据分析初级开发者。包内含五十九个文件,主要包含9个.py源码、12个.pyc编译文件、SQL数据库脚本、Flask配置与前端页面、图片素材与演示PPT报告,压缩包整体约10.34MB;其中Python脚本支撑爬虫与后端逻辑,SQL用于数据库初始化,图片与PPT用于结果展示,源码经严格调试可运行,层次清晰便于二次改造。项目曾获导师认可的高分评审,内置腾讯招聘爬虫与51job数据集,可利用依赖清单文件快速搭建环境,配合MySQL完成数据存储与统计图表展示,代码结构涵盖爬虫采集模块、Flask服务端和数据分析模块。目前已有113人学习,整体难度适中,能为理解招聘数据分析全流程提供完整参考,无论是课程展示还是毕业答辩,都具有较强的参考价值。
1. 招聘岗位数据爬虫与可视化分析,究竟在解决什么问题
招聘岗位的数据爬虫与可视化分析,是Python技术栈里少有的、能把requests、Scrapy、Pandas、pyecharts全部串起来的综合型项目。它的核心价值不在于“把网页上的职位信息抓下来”,而在于把一条完整的链路走通:从数据采集的合法性判断、页面结构分析、字段抽取,到数据清洗入库,再到用可视化图表回答“哪个城市岗位多、薪资分布如何、技术栈要求是什么”这类实际问题。换句话说,一个招聘爬虫系统,本质上是一个微缩版的数据工程,选型、架构、边界、性能,一个都不少。
这类项目最常见的落地场景是求职市场分析、薪酬调研、岗位技能趋势跟踪。适合的人群有三类:一是正在学习爬虫与数据分析的Python开发者,需要一份能讲清楚原理的完整项目;二是需要做行业报告或市场调研的运营与HR,他们要的是可信的数据结论;三是准备写毕业设计或技术总结的工程师,需要有架构、有代码、有验证的项目骨架。本文以招聘网站的岗位列表页与详情页为对象,从数据源分析开始,逐步拆解爬虫的工程化设计、字段清洗与入库、可视化指标设计,最后落在并发与反爬的边界处理上,确保你拿到的是一套可以照着落地的方案。
2. 数据字段设计、反爬边界与招聘网站的技术选型
2.1 先看数据源,再谈爬虫设计
招聘类网站与普通内容站的最大区别在于:它们几乎都会在前端渲染职位列表时同步加载结构化的 JSON 数据。这意味着爬虫的第一件事不是解析 HTML,而是判断数据的载体。常见的招聘网站有四种数据形态:
| 数据形态 | 典型特征 | 爬取策略 |
|---|---|---|
| 服务端渲染 HTML | 职位信息直接写在 DOM 中,右键查看网页源代码即可看到岗位名称、薪资等字段 | 直接解析 HTML,用 XPath 或 CSS 选择器抽取 |
| 异步加载 JSON 接口 | 列表页只有框架,数据由fetch或XMLHttpRequest请求返回,网络面板里能看到 JSON | 直接请求 API 接口,注意请求头与参数签名 |
| 页面内嵌 JSON 数据 | 数据打包在<script type="application/json">或window.__INITIAL_STATE__中 | 用正则或 JSON 解析提取,无需二次请求 |
| 图片或 PDF 形式 | 部分高端岗位详情以图片方式呈现,搜索引擎与爬虫均无法直接读取 | 需要 OCR 或放弃该字段 |
我一般的做法是:先用浏览器开发者工具打开列表页,查看“网络”面板里的 XHR 请求。如果发现职位数据走的是 JSON 接口,就优先抓接口;如果接口有复杂的签名参数(如signature、token),再退回解析 HTML。对招聘岗位这种字段结构相对固定的场景,接口通常比 HTML 更稳定,因为招聘网站的接口很少频繁改版,而页面结构却经常调整。
2.2 爬虫的合法性与 robots 协议
任何招聘数据爬虫的代码设计,都应该从检查robots.txt开始。常见招聘网站的 robots 规则差异很大,有的完全开放职位列表页,有的对搜索接口做了限制。爬虫脚本运行时,应该先请求该文件,把禁止爬取的路径过滤掉,再进入采集逻辑。
from urllib.robotparser import RobotFileParser rp = RobotFileParser() rp.set_url("https://example.com/robots.txt") rp.read() can_fetch = rp.can_fetch("MySpider/1.0", "https://example.com/jobs") print("允许爬取职位列表:", can_fetch)这段代码的逻辑是:用RobotFileParser读取目标网站的robots.txt,然后传入爬虫的 User-Agent 和待抓取的 URL,得到布尔值。User-Agent最好定义成有辨识度的字符串,比如MySpider/1.0 (mailto:your@email.com),而不是直接写成浏览器 UA,这样网站管理员可以在日志里联系到你。can_fetch返回False时,应当跳过该路径,这是爬虫工程化的底线。
2.3 字段模型:先定义你需要的字段
爬虫代码的维护成本有一半来自字段规则的变化。所以开工前,需要把目标字段抽象成一张表。招聘岗位数据一般包含三类字段:基础信息、职位要求、公司信息。
| 字段分组 | 字段名 | 示例值 | 类型 | 说明 |
|---|---|---|---|---|
| 基础信息 | job_name | Python开发工程师 | string | 职位名称 |
| 基础信息 | salary_min | 20 | int | 最低月薪/千元 |
| 基础信息 | salary_max | 40 | int | 最高月薪/千元 |
| 基础信息 | city | 上海 | string | 工作城市 |
| 职位要求 | experience | 3-5年 | string | 经验要求 |
| 职位要求 | education | 本科 | string | 学历要求 |
| 职位要求 | skills | ["Python", "Django"] | list | 技能标签 |
| 公司信息 | company_name | 某科技公司 | string | 公司名称 |
| 公司信息 | company_size | 100-499人 | string | 公司规模 |
定义字段表的价值在于:后面处理薪资区间、做技能词频统计时,能够直接在清洗阶段做结构化转换,而不是在采集时匆忙处理。需要留意的一个细节是:很多招聘网站的接口会把薪资写成"20k-40k"或"面议",这决定了清洗函数的逻辑至少要有三种分支:区间、固定值、面议,而不是简单地 split 一下。
2.4 从静态页面到接口直连的演进路径
对于学习项目或毕设场景,最稳妥的路径是:先跑通静态模板页的爬取,再过渡到接口请求。因为静态页面的解析逻辑直观,XPath 写错一眼就能定位;而接口请求虽然返回干净,但往往需要处理headers、cookies、加密参数三层问题。
以某个常见的职位搜索页为例,接口返回的核心结构如下:
{ "code": 0, "data": { "list": [ { "jobName": "Python爬虫工程师", "salary": "25k-40k", "city": "北京", "company": {"name": "某数据服务公司", "size": "100-499人"} } ], "totalCount": 2341 } }接口请求的代码比解析 HTML 更简单,但有一个隐蔽的坑:接口通常校验请求头里的Referer字段,少了这个字段会返回 403。常用的处理手段是构造一个请求头字典,把User-Agent、Referer、Accept都补齐:
import requests headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://example.com/jobs?city=beijing", "Accept": "application/json, text/plain, */*", } resp = requests.get("https://api.example.com/job/list", headers=headers, timeout=10) data = resp.json()这段代码里,timeout=10是一个很重要的参数。招聘网站的接口有时会因负载高而变慢,如果没有超时设置,爬虫会挂在其中某一条请求上,导致整个采集流程停滞。超时的阈值根据实际网络情况调节,局域网内抓取可以设到 5 秒,公网建议 10 到 15 秒。resp.json()解析失败时,还需要额外捕获JSONDecodeError,因为部分网站在反爬时会返回一段 HTML 而不是 JSON。
3. Scrapy 分布式爬虫框架下的招聘数据采集实现
3.1 为什么选择 Scrapy 而不是 requests
招聘数据爬虫如果只跑一次,用requests加BeautifulSoup完全够用。但当需要抓取的城市多、岗位类型多、翻页页数深时,单线程的requests方案在效率上会输得很惨。Scrapy 的优势在于它自带了并发调度、去重、重试、日志和中间件机制,这些能力在生产环境里不是可选项,而是刚需。
Scrapy 的请求调度是基于异步事件循环的,这意味着它可以同时维持几十个连接去请求不同页面,而不需要像requests那样靠ThreadPoolExecutor手动管理线程池。另一个关键点是 Scrapy 的去重过滤器,它会把已经请求过的 URL 指纹存下来,避免翻页时重复采集同一份数据。相比之下,requests方案里的去重逻辑需要你自己写集合或 Redis 存储。
对于“招聘岗位数据爬虫”这个场景,我的建议是:小批量测试用requests,正式采集或毕设展示直接用 Scrapy。后者在代码组织上天然分出了spiders、items、pipelines、middlewares四层结构,这份架构本身就是答辩或文档报告里最好的素材。
3.2 创建 Scrapy 项目和核心代码实现
创建项目的命令非常简单:
scrapy startproject job_spider cd job_spider scrapy genspider job "example.com"执行完这条命令后,项目的目录结构是:
job_spider/ ├── scrapy.cfg └── job_spider/ ├── __init__.py ├── items.py ├── middlewares.py ├── pipelines.py ├── settings.py └── spiders/ └── job.py在写爬虫代码之前,需要先定义 Item 数据结构。Item 的作用相当于数据表结构,每个字段都是后面 Pipeline 处理的入口:
import scrapy class JobItem(scrapy.Item): job_name = scrapy.Field() salary_min = scrapy.Field() salary_max = scrapy.Field() city = scrapy.Field() experience = scrapy.Field() education = scrapy.Field() company_name = scrapy.Field() company_size = scrapy.Field() skills = scrapy.Field()这个 Item 定义的逻辑是为后续的数据清洗与可视化提供统一的数据格式。Field()并不做类型校验,它更像一个字典的 Key,但定义在这里,Postgres 或 MySQL 建表时的字段顺序、类型转换就能一一对应起来,少了代码层面的“字段名拼写不一致”的问题。
3.3 爬虫主逻辑中的翻页、解析与回调
接下来是爬虫的核心逻辑。这个文件要解决三件事:第一,生成翻页 URL 列表;第二,解析列表页拿到每个职位的详情页链接或接口数据;第三,把字段组装成 Item 递给 Pipeline。以接口直连的方案为例:
import json import scrapy from job_spider.items import JobItem class JobSpider(scrapy.Spider): name = "job" base_url = "https://api.example.com/job/list?city={city}&page={page}" def start_requests(self): cities = ["北京", "上海", "广州", "深圳", "杭州"] for city in cities: for page in range(1, 11): url = self.base_url.format(city=city, page=page) yield scrapy.Request(url, callback=self.parse, meta={"city": city}) def parse(self, response): data = json.loads(response.text) job_list = data.get("data", {}).get("list", []) for job in job_list: item = JobItem() item["job_name"] = job.get("jobName") item["city"] = response.meta["city"] salary = job.get("salary", "面议") item["salary_min"], item["salary_max"] = self.parse_salary(salary) item["experience"] = job.get("experience") item["education"] = job.get("education") item["company_name"] = job.get("company", {}).get("name") item["company_size"] = job.get("company", {}).get("size") item["skills"] = job.get("skills", []) yield item def parse_salary(self, salary): if "k" in salary: parts = salary.lower().replace("k", "").split("-") if len(parts) == 2: return int(float(parts[0]) * 1000), int(float(parts[1]) * 1000) return 0, 0这段代码里有两个设计值得说明。一是start_requests里手动生成了城市与页码的笛卡尔积,这在需要精确控制采集范围时非常方便,不需要依赖网站的分页参数;二是parse_salary把"25k-40k"统一转换成数值型的最低与最高月薪,存入 MySQL 或 CSV 后做区间统计时直接用salary_max做中位数排序就可以。需要注意salary.replace("k", "")中必须强制转小写,否则K在 Python 里替换后没变化,会产生非数字字符串导致int()抛异常。
3.4 配置 Item Pipeline 完成数据落库
Pipeline 是 Scrapy 里的数据出口。我一般会在 Pipeline 里做两层处理:第一层清洗,比如把空值替换为默认值、去掉首尾空格;第二层持久化,把 Item 写入 MySQL。下面是一个写入 MySQL 的 Pipeline 示例:
import pymysql class JobPipeline: def open_spider(self, spider): self.conn = pymysql.connect( host="localhost", port=3306, user="root", password="123456", database="job_db", charset="utf8mb4", ) self.cursor = self.conn.cursor() def process_item(self, item, spider): sql = """ INSERT INTO job_position (job_name, salary_min, salary_max, city, experience, education, company_name, company_size, skills) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) """ skills_str = ",".join(item["skills"]) if item["skills"] else "" self.cursor.execute(sql, ( item["job_name"], item["salary_min"], item["salary_max"], item["city"], item["experience"], item["education"], item["company_name"], item["company_size"], skills_str, )) self.conn.commit() return item def close_spider(self, spider): self.cursor.close() self.conn.close()这段代码的逻辑要点是:open_spider在爬虫启动时建立数据库连接,process_item在每一条数据被抓取后执行插入,close_spider在爬虫结束时清理连接。skills是列表类型,MySQL 原生不支持数组,所以用join方法拼成逗号分隔的字符串;如果后续要做技能词频分析,在查询时再GROUP_CONCAT或FIND_IN_SET还原。commit()逐条提交在数据量大时性能差,可以考虑每 100 条批量提交一次,用executemany处理效率更高。
3.5 settings.py 里的关键参数与反爬中间件
Scrapy 项目能稳定跑多久,取决于settings.py里的几项参数。以下是推荐配置和解释:
| 参数名 | 推荐值 | 作用说明 |
|---|---|---|
ROBOTSTXT_OBEY | True | 遵循 robots 协议,正式环境务必开启 |
CONCURRENT_REQUESTS | 16 | 并发请求数,调大提速但增加封 IP 风险 |
DOWNLOAD_DELAY | 0.5 | 请求间隔秒数,越小越快,越容易被识别 |
DEFAULT_REQUEST_HEADERS | 带 UA 与 Referer 的字典 | 模拟浏览器基础行为 |
DOWNLOADER_MIDDLEWARES | 配置代理中间件 | 用于 IP 轮换,应对频率限制 |
ITEM_PIPELINES | {"job_spider.pipelines.JobPipeline": 300} | 注册数据管道,数字越小优先级越高 |
这里需要特别提醒的是ROBOTSTXT_OBEY。它是一个开关式的全局设置,如果你的爬虫只采集公开的岗位列表页,保持True;但如果目标网站 robots 里把接口路径全部Disallow,爬虫会直接拒绝所有请求。这种情况一般出现在需要登录后才能访问的招聘平台,处理方式是改为False并在代码层面自己控制爬取边界,而不是盲目依赖这个开关。
一个常见的反爬手段是DOWNLOAD_DELAY与CONCURRENT_REQUESTS的配合。多数招聘网站的限流规则是基于单 IP 在单位时间内的请求次数,比如 1 秒超过 5 次就临时封禁。这时把并发降到 8、DELAY设为 1 秒,比换代理 IP 更稳定——因为代理 IP 的可用性和速度往往是更大的不确定性来源。
4. 招聘数据清洗与可视化分析前的标准化处理
4.1 原始数据的脏数据问题
写入数据库的数据并不等于可以分析的数据。招聘岗位数据的典型脏数据集中在薪资、城市、经验、技能四类字段上。薪资可能是"面议"、"1.5-2.5万/月"、"25-40万/年"三种格式;城市可能是"北京-海淀区"或"北京·望京";经验可能是"在校/应届"或"经验不限"。这些格式如果不处理,salary_min在排序时会出现字符型与数值型混排,导致统计结果完全错误。
处理方案是写一个独立的清洗脚本,在可视化前对数据库表执行一次原地更新,而不是在每次可视化查询时重复清洗逻辑。这样做的效率最高,也方便人工检查中间结果。
4.2 清洗代码:薪资归一化、经验字段映射与技能拆解
下面的脚本演示如何把 MySQL 中的job_position表进行标准化处理:
import re import pymysql import pandas as pd conn = pymysql.connect(host="localhost", user="root", password="123456", database="job_db", charset="utf8mb4") df = pd.read_sql("SELECT id, salary_text, experience, skills FROM job_position", conn) def normalize_salary(text): text = str(text) if "万/月" in text: nums = re.findall(r"[\d.]+", text) return [int(float(n) * 10000) for n in nums] if "k" in text.lower(): nums = re.findall(r"[\d.]+", text.lower().replace("k", "")) return [int(float(n) * 1000) for n in nums] if "元/天" in text: nums = re.findall(r"[\d.]+", text) return [int(n) * 22 * 12 for n in nums] return [0, 0] df["salary_min"] = df["salary_text"].apply(lambda x: normalize_salary(x)[0]) df["salary_max"] = df["salary_text"].apply(lambda x: normalize_salary(x)[1]) exp_map = { "在校/应届": 1, "1年以内": 1, "1-3年": 2, "3-5年": 3, "5-10年": 4, "经验不限": 0, } df["experience_level"] = df["experience"].map(exp_map).fillna(0) for idx, row in df.iterrows(): conn.cursor().execute( "UPDATE job_position SET salary_min=%s, salary_max=%s, experience_level=%s WHERE id=%s", (row["salary_min"], row["salary_max"], row["experience_level"], row["id"]), ) conn.commit() conn.close()这段代码的关键在于normalize_salary函数的分支判断。"25-40万/年"这种格式需要先除以 12 再乘 10000,我在上面用万/月和k的方式匹配;如果你抓取的网站包含万/年格式,需要在最后加一个elif "万/年" in text分支并除以 12。re.findall负责把"1.5-2.5"中的两个数字都提取出来,注意float转换必须放在int()之前,1.5会被截断为3千而不是期望的15000,这是新手最容易踩的坑。
4.3 基于 DataFrame 的统计口径设计
清洗完成后,就需要设计分析指标。招聘数据可视化不追求复杂模型,核心关注四个统计口径:
| 分析维度 | 指标 | SQL / Pandas 实现方式 |
|---|---|---|
| 城市分布 | 岗位数量占比 | GROUP BY city ORDER BY count DESC |
| 薪资水平 | 城市平均薪资 / 中位数 | GROUP BY city 后取 salary_max 均值 |
| 经验要求 | 各经验层次岗位数量 | 映射后的experience_level分组 |
| 技能需求 | Top20 技能词频 | 将skills字段拆成多行再GROUP BY |
统计口径的差异会直接影响可视化呈现,同一份数据用均值和中位数绘图,结论可能完全不同。招聘薪资的分布通常是右偏的(少数高薪岗位拉高均值),所以我在可视化时更推荐用箱线图展示薪资分布,而不是只画一个平均值的柱状图——后者会把数据失真地“平滑化”,写报告时也经不起质疑。
import pandas as pd df = pd.read_sql("SELECT city, salary_max, experience_level FROM job_position", conn) city_salary = df.groupby("city")["salary_max"].agg(["mean", "median", "count"]) city_salary = city_salary.sort_values("mean", ascending=False).head(10)这段代码直接输出各城市的岗位数量、平均薪资、中位数薪资,三个指标一起对比时,你才能判断“这个城市岗位多是因为大厂多,还是因为中小公司投递活跃”。可视化分析的目标不是做出漂亮的图,而是让看图的人能够从图里看出结论、提出追问。
4.4 Excel 交叉透视:城市与经验层级的矩阵统计
除了库内统计,招聘岗位数据还经常需要导出成交叉表。交叉表的价值在于能够一眼看出“北京的资深岗位比例是否明显高于其他城市”,这是单纯条形图做不到的。
import pandas as pd from sqlalchemy import create_engine engine = create_engine("mysql+pymysql://root:123456@localhost:3306/job_db?charset=utf8mb4") df = pd.read_sql("SELECT city, experience_level, salary_max FROM job_position", engine) pivot = pd.pivot_table(df, index="city", columns="experience_level", values="salary_max", aggfunc="count", fill_value=0) pivot.to_excel("city_experience_pivot.xlsx")pivot_table的aggfunc="count"统计的是满足条件的记录数,它会忽略NaN值并用 0 填充。交叉表导出后可以放进 PPT 文档报告里作为附页,这是招聘数据可视化项目中性价比最高的输出物之一。需要注意的是,create_engine里的密码如果包含特殊字符要使用urllib.parse.quote_plus编码,否则连接数据库时会报Access denied。
5. 招聘岗位数据的可视化分析与三维联动图表设计
5.1 pyecharts 在地理分布与薪资散点图上的应用
招聘数据的可视化选型上,我建议直接用pyecharts而不是matplotlib。原因有三:一是 pyecharts 基于 ECharts,交互效果远好于静态图,适合放进 PPT;二是地理图表(地图)不需要额外处理底图数据,内置的中国地图组件直接用;三是代码风格接近链式调用,写起来比matplotlib的面向对象 API 更顺手。
安装方式:
pip install pyecharts pandas pymysql对于城市薪资分布,散点图是最适合展示城市吸引力的图表。横轴为城市岗位数量、纵轴为平均薪资、点的大小为招聘数量,这样的气泡图能够在同一张图中同时回答“哪里机会多”“哪里薪资高”“哪座城市两者兼具”三个问题。
5.2 城市-薪资气泡图代码实现
import pymysql import pandas as pd from pyecharts.charts import Scatter, Bar, Pie from pyecharts import options as opts conn = pymysql.connect(host="localhost", user="root", password="123456", database="job_db", charset="utf8mb4") df = pd.read_sql( "SELECT city, AVG(salary_max) AS avg_salary, COUNT(*) AS job_count " "FROM job_position GROUP BY city ORDER BY job_count DESC LIMIT 20", conn, ) scatter = ( Scatter() .add_xaxis(df["city"].tolist()) .add_yaxis( "平均薪资", df[["avg_salary", "job_count"]].values.tolist(), symbol_size=lambda val: max(10, min(60, val[1] / 5)), ) .set_global_opts( title_opts=opts.TitleOpts(title="各城市招聘岗位平均薪资与数量分布"), xaxis_opts=opts.AxisOpts(name="城市", axislabel_opts={"rotate": 45}), yaxis_opts=opts.AxisOpts(name="平均薪资(元/月)"), tooltip_opts=opts.TooltipOpts( formatter="{b}: {c[0]}元/月, {c[1]}个岗位" ), ) ) scatter.render("city_salary_bubble.html")这段代码里最值得展开说明的是symbol_size参数。pyecharts 的 Scatter 图默认symbol_size是统一大小,但如果把数据以[平均薪资, 岗位数]的二元列表传入,就需要通过symbol_size函数动态计算气泡大小,val[1] / 5是为了缩放,避免岗位数过多的城市把图撑爆。tooltip_formatter里的{c[0]}和{c[1]}分别对应传入列表中的第一个和第二个值,显示效果是“上海: 28000元/月, 350个岗位”。
5.3 技能关键词词频统计与柱状图展示
招聘网站详情页里的技能标签通常是逗号分隔的字符串,需要先拆散再统计。下面是计算 Top20 技能词频并输出横向柱状图的代码:
from collections import Counter import pymysql import pandas as pd from pyecharts.charts import Bar conn = pymysql.connect(host="localhost", user="root", password="123456", database="job_db", charset="utf8mb4") df = pd.read_sql("SELECT skills FROM job_position WHERE skills != ''", conn) skill_counter = Counter() for row in df["skills"].str.split(","): for skill in row: skill = skill.strip() if skill: skill_counter[skill] += 1 top_skills = skill_counter.most_common(20) skills, counts = zip(*top_skills) bar = ( Bar() .add_xaxis(list(skills)) .add_yaxis("岗位需求数", list(counts)) .reversal_axis() .set_series_opts(label_opts=opts.LabelOpts(position="right")) .set_global_opts( title_opts=opts.TitleOpts(title="招聘岗位 Top20 技能需求排名"), yaxis_opts=opts.AxisOpts(name="技能名称"), xaxis_opts=opts.AxisOpts(name="出现次数"), ) ) bar.render("top_skills.html")reversal_axis()是 pyecharts 里非常实用的方法,作用是把坐标系翻转,横向条形图在技能名称过长时视觉上更舒适。str.split(",")会把"Python, Django, Flask"按逗号拆成三元列表,再逐个放入 Counter 对象进行计数。注意抓到的 skills 字段如果是以中文逗号“,”分隔,这里还需要先做一次replace(",", ",")的预处理,这是招聘网站数据里最容易出现的编码差异问题。
5.4 多图串联成可交互报告页面的实践
单一图表的信息量有限,招聘数据分析通常会将地图、柱状图、饼图、表格拼装成一个 HTML Dashboard。pyecharts 提供了Tab或Page组件来组合多张图表。我建议用Page(layout=Page.SimplePageLayout)纵向排列,这样在 PPT 文档报告里可以直接嵌入整张长截图。
from pyecharts.charts import Page, Pie pie = ( Pie() .add("", [("经验不限", 120), ("1-3年", 340), ("3-5年", 260), ("5-10年", 80)]) .set_global_opts(title_opts=opts.TitleOpts(title="经验要求分布")) ) page = Page(layout=Page.SimplePageLayout) page.add(scatter, bar, pie) page.render("job_analysis_report.html")Page组件的add方法支持多图共存,渲染后是一份可以上下滚动的单文件报告。整份报告包含城市气泡图、技能排名柱状图、经验要求饼图三个核心视角。这份 HTML 文件后续可以直接套进 PPT 的 Web 浏览器插件里演示,不需要额外启动本地服务。如果图表数据量较大,首次加载会有几百毫秒的白屏,需要控制在图表数量不超过 5 张为宜。
6. 从可视化页面反推爬虫设计结构改进
可视化分析做完后,接下来回到源头检查一遍爬虫的工程化程度。很多人做完图表就收工了,但招聘岗位爬虫与可视化系统真正值钱的地方在于:能否增量更新、能否自动重试、能否应对反爬。这一章节的核心技巧是从图表中反馈的异常反推爬虫设计——比如若非北京岗位数据明显偏少,说明抓取请求被目标接口的部分参数限制。这些判断标准如下:
| 可视化发现 | 可能原因 | 爬虫改进方向 |
|---|---|---|
城市字段出现大量NaN | 列表页的城市字段只在详情页返回 | 增加一次详情页请求或从 URL 参数中提取城市 |
| 薪资集中在 0-5k 区间 | salary_text清洗不完整 | 扩展正则匹配万/年、元/天格式 |
| 技能标签几乎没有重复 | skills字段在接口中未拼接完整 | 检查接口返回的skillName是否嵌套在子对象 |
| 某一页的数据全部缺失 | 翻页地址使用了pageIndex但实际参数名为pageNo | 爬虫翻页参数命名与实际请求参数不一致 |
一个实用的改进手段是给爬虫增加一个增量爬取逻辑。招聘网站每天都有新岗位发布,全量爬取既耗时又会触发反爬。常见的做法是记录每条数据的job_id或job_url指纹,在process_item中查重,只有新记录才插入数据库:
def process_item(self, item, spider): sql_check = "SELECT id FROM job_position WHERE job_url = %s LIMIT 1" sql_insert = """ INSERT INTO job_position (job_name, salary_min, salary_max, city, job_url) VALUES (%s, %s, %s, %s, %s) """ self.cursor.execute(sql_check, (item["job_url"],)) exists = self.cursor.fetchone() if not exists: self.cursor.execute(sql_insert, ( item["job_name"], item["salary_min"], item["salary_max"], item["city"], item["job_url"], )) self.conn.commit() return item这段代码的思路是:先按job_url查一次表,如果已经存在则跳过插入。job_url是爬虫抓取时从详情页链接中解析到的唯一标识,它比岗位名称更可靠——因为同一家公司的同一个职位名称可能在多个渠道发布多条。增量逻辑避免了反复抓取同一份数据导致的图表统计虚高,让图表中的岗位数更接近真实需求。
最后还要提到一个容易被忽略的点:可视化页面本身也可以作为爬虫代码的回归测试工具。你只需隔一段时间重新抓取少量数据并刷新图表,如果城市分布、技能 Top 榜出现明显异常波动,大概率不是市场行情发生了变化,而是页面结构或接口字段被改动过。这个“以图测爬”的思路,比每次人工看爬虫日志更直观,也更适合放进文档报告作为运维实践的一部分。
本文还有配套的精品资源,点击获取