ARTICLE DETAIL

资讯详情

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

招聘数据抓取与薪资行情分析:Python爬虫实战全流程

招聘数据抓取与薪资行情分析:Python爬虫实战全流程 每年换季我都会跑一遍这个脚本招聘数据抓取与薪资行情分析招聘App刷出来的永远是零星几条岗位真正想看“3-5年经验的Python开发在二线城市大概什么价”还是得自己动手。Python爬虫这个项目听起来很“玩具”但它实际解决的是一个很具体的需求把散落在招聘页面上的公开岗位信息聚合成结构化数据然后清洗、落库、可视化让薪资行情自己开口说话。这篇文章完整记录了我这次招聘数据抓取与薪资行情分析项目的全过程包括字段设计、请求链路、解析逻辑、清洗规则、存储模型、可视化方法以及前后踩过的那些坑。如果你刚学完Python基础想找一个能练手又能出成果的爬虫实战项目或者正在考虑跳槽、想看市场行情这份笔记应该能帮你省下不少弯路。整个项目的核心链路就一条列表页找入口、详情页抓字段、清洗统一口径、SQLAlchemy入库、pandas聚合、pyecharts出图。下面我会按这个顺序把每个环节掰开揉碎地讲包括大家最关心的反爬应对、薪资字符串解析和去重设计。1. 项目整体思路招聘数据抓取到底在抓什么1.1 招聘页面里有哪些“值钱”的字段很多人写爬虫第一反应是“把整个页面扒下来”这其实是个误区。招聘页面真正值钱的不是页面本身而是页面上那几个被精心排版过的字段。以一次完整的岗位卡片信息为例至少包含以下这些数据点岗位名称比如“高级Python开发工程师”这个字段决定了后续按职级分组的分析维度。公司名称与公司规模公司名是识别重复岗位的关键线索规模字段可以做企业类型分布分析。薪资常见表现形式是“1.5-2.5万·14薪”背后隐藏的信息量很大不但有上下限还有发薪月数。工作城市与区域城市是主维度区域可以辅助判断城市内部的就业带。经验要求通常表达为“3-5年”“经验不限”需要转成可排序的数值区间。学历要求大专、本科、硕士、不限属于典型的类别字段。发布时间“今天发布”“3天前”这类相对时间必须转换成绝对日期否则时间维度上的分析会失真。这些字段单独看没什么聚合起来就能回答“初级岗位给多少、资深岗位给多少、哪个城市平均薪资最高”这类问题。所以抓取阶段的核心原则是字段先抓原始文本不要急着加工清洗和分析放在后面统一做。这样可以避免因为页面某个字段格式变化而频繁改抓取脚本。1.2 技术栈选型为什么是requests加parsel加SQLAlchemy技术选型很多人纠结我的建议很直接这个项目完全不需要上重型框架。请求库用requests。它覆盖面广、文档多、出错好排查几乎成了Python HTTP请求的事实标准。目标站点没有太复杂的加密协议时requests足够稳定。解析库用parsel。它是Scrapy框架里的解析模块独立出来的语法上同时支持XPath和CSS选择器API非常简洁两三行代码就能提取一套字段。对比BeautifulSoupparsel在性能和XPath支持上更舒服而BeautifulSoup对格式畸形的HTML更宽容。二选一就行我建议parsel。存储层用SQLAlchemy ORM。它最大的价值不是省那几行SQL而是把表结构定义和业务逻辑分离。字段要调整时改模型类就可以迁移数据库也更平滑。后面想从SQLite换到MySQL只需要改一行连接串。分析可视化用pandas加pyecharts。pandas负责分组聚合pyecharts生成交互式HTML图表可以直接丢到浏览器里看不用装额外工具。很多教程一上来就推荐Scrapy但Scrapy的学习曲线比单纯用requests陡不少项目调度、中间件、Item Pipeline这些概念对第一次做的朋友不友好。这个项目抓取量级在几千条单线程加随机延时已经完全够用性能不是瓶颈代码的可读性才是。1.3 动态页面怎么办JSON接口通常比HTML解析更省事现在不少招聘页面的数据是异步加载的直接在HTML文档里找不到岗位信息。处理这种情况不要第一反应就上Selenium无头浏览器。浏览器自动化方案又慢又容易被识别还浪费内存。正确做法是打开浏览器的开发者工具切到Network面板并刷新页面专门找XHR或Fetch类型的请求。这类请求通常返回JSON里面字段结构比HTML更规整直接用json.loads解析字段就行。JSON里往往还有分页参数翻页逻辑会更清晰。记住一句话页面上能看到的数据底层大概率有对应的接口在提供找到那个接口要比解析渲染后的DOM省力得多。2. 目标站点分析与合规边界2.1 先读robots.txt判断抓取边界爬虫动手之前第一件事是看目标站点的robots.txt。这是一个约定俗成的规则文件用来告诉爬虫哪些路径可以访问、哪些路径不建议访问。招聘平台虽然本质上是公开数据展示但每个站点对爬虫的容忍度不同有人明确在robots里写了Disallow规则也有人设置了比较严格的访问频率阈值。我自己的习惯是先访问目标域名下的robots.txt看一眼如果里面明确禁止了某个路径那就避开那个路径如果遇到登录墙或者验证码说明这个数据不是公开数据应当停下来而不是想尽办法绕过去。很多人把爬虫技术等同于“绕过技巧”这其实是对爬虫的误解。所谓技术含量应该体现在如何稳定、高效地获取公开数据而不是想方设法突破别人的边界。2.2 抓到的数据不等于可以用这些数据招聘页面上的数据有一个特殊点它是由企业和求职者共同产生的有些字段可能涉及个人信息。我的原则是只抓岗位卡片上公开呈现的信息不碰姓名、联系方式、简历明细这类字段。整理出来的数据用于个人学习和行业观察不打包转发、不做成付费报告、不允许别人拿去商用。说到底爬虫从业者的底线决定这个技术方向能走多远。一两个人的无心滥用可能让整个行业都面临更严格的限制。控制好频率、不抓非公开数据、不用于商业用途这三点如果做不到脚本写得再漂亮也没有意义。2.3 制定抓取节奏比“能抓”更重要的是“合理抓”不设置频率限制的爬虫基本活不过十分钟。我在写请求函数的时候会在两次请求之间加一个2到4秒的随机延时让请求间隔看起来更接近手动浏览的自然节奏。随机延时不是说数字越大越好而是让分布不均匀避免出现固定间隔的规律性。另外一定要给整个抓取任务设置停止条件。比如“最多抓满3000条、页面翻到第50页、总运行时间不超过30分钟”满足任何一个条件就自动停止。这种保护机制不是为了防别人而是防自己脚本跑飞了至少能在有限时间内止损。你也不想半夜爬起来发现脚本已经对着目标站点狂轰滥炸了几个小时吧。3. 数据抓取从列表页到详情页的请求链路3.1 用浏览器开发者工具找到真实请求写爬虫第一步不是打开IDE写代码而是打开浏览器开发者工具去观察。按下F12切到Network面板刷新几次页面找到返回HTML文档或JSON数据的那个请求。重点看三样东西请求URL、请求Headers、响应内容结构。这里分享一个我常用的快速起步方法在Network面板找到目标请求后右键复制为cURL格式然后拿到Python里用curl命令的解析结果看一下或者直接用转换工具把cURL转成requests代码。这样能拿到一个带完整Headers的请求样例比自己手写头信息要准确得多。实际测试下来复制出来的Headers稍作清理就能直接用能避免很多403错误。3.2 构造请求头与会话保持卡在403的头号原因不少新手写的爬虫返回403基本都是请求头信息不全导致的。正常浏览器访问一个页面时会带上User-Agent、Accept、Accept-Language、Referer等一系列属性。requests库默认的User-Agent是一个很老的Python字符串目标站点一眼就能识别出这是脚本。我的请求头配置模板大概是这样的import time import random import requests from requests.adapters import HTTPAdapter session requests.Session() session.headers.update({ 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-Language: zh-CN,zh;q0.9,en;q0.8, Referer: https://example.com/, }) adapter HTTPAdapter(max_retries3, pool_connections10, pool_maxsize10) session.mount(http://, adapter) session.mount(https://, adapter) def fetch(url): for attempt in range(3): try: resp session.get(url, timeout10) if resp.status_code 200: return resp.text print(f请求返回状态码: {resp.status_code}) except requests.RequestException as exc: print(f第{attempt 1}次请求异常: {exc}) time.sleep(2 ** attempt) return None用requests.Session()而不是裸requests.get()主要目的是复用底层TCP连接和保持Cookie。某些站点在会话内下发了Cookie换成Session之后就不容易出现“第一次请求正常、第二次请求被拒绝”的情况。Referer字段一般设置为目标站点的首页招聘类网站对缺失Referer的请求往往更敏感这一步别偷懒。适配器里的max_retries参数负责在连接超时或5xx错误时自动重试指数退避的延时策略也不算复杂但效果很好。3.3 列表页解析用CSS选择器定位每个岗位的入口拿到列表页HTML之后第一步是解析出所有岗位详情页的链接。以parsel为例核心逻辑是先选中所有岗位卡片元素再逐个提取卡片里的详情链接、岗位名称和公司名。CSS选择器在这个环节基本够用比如选中id或class包裹的列表项然后用::attr(href)提取链接。有一个容易忽略的细节从页面里提取出来的href可能是相对路径需要手动补全为绝对URL。拼接时建议用urllib.parse.urljoin而不是简单做字符串相加因为不同页面层级下的相对路径规则不同字符串相加很容易拼出错误的地址。列表页常常会有“推荐岗位”和“普通岗位”两套数据源同一个岗位可能在页面里出现两次。解决方案很简单把所有详情页链接收集到一个set里做去重再去请求详情页避免对同一岗位反复抓取。3.4 详情页字段提取优先保留原始值详情页解析是整套抓取最核心的环节。页面结构一般都比列表页更清晰字段丰富度也更高但要注意几个典型的解析陷阱薪资在页面上是渲染好的纯文本比如“1.5-2.5万/月”需要保留原始文本清洗阶段再转换。“面议”是一个合法状态表示薪资未公开应该记录为空值而不是抛异常。发布时间经常是“今天发布”“3天前”这类相对描述清洗时会统一换算成绝对日期。同一站点的PC端页面和移动端页面的DOM结构可能完全不同开发时先固定抓其中一个不要同时追两个版本。以详情页字段提取为例我的解析函数大致长这样import parsel def parse_job_detail(html): selector parsel.Selector(texthtml) salary selector.css(.job-salary::text).get() salary salary.strip() if salary else None title selector.css(.job-title::text).get() company selector.css(.company-name::text).get() city_text selector.css(.job-area::text).get() 经验学历 selector.css(.job-info::text).getall() desc selector.css(.job-description::text).get() return { title: title.strip() if title else None, company: company.strip() if company else None, salary_text: salary, city_text: city_text.strip() if city_text else None, info_list: [item.strip() for item in 经验学历 if item.strip()], desc: desc.strip()[:1000] if desc else None, }规定一个原则详情页解析阶段只做粗清洗去空格、取文本不做语义转换。所有“1.5-2.5万”到“15000-25000”的转换都统一放到第4章的清洗函数里管。这样如果目标站点改了表达方式只需要调整清洗函数不用重新扒一遍页面。4. 字段清洗把“1.5-2.5万·14薪”变成能分析的数字4.1 薪资解析函数的核心逻辑如果说抓取是体力活清洗就是整个项目里最有技术含量的部分而薪资字符串解析又是清洗环节的硬骨头。我见过的真实薪资表达方式包括这些“1.5-2.5万/月”“8千-1.2万/月”“25-35万/年”“400-600元/天”“1万/月”这种单值“面议”“1.5-2.5万·14薪”这种带年终薪数的如果直接用字符串截取会被各种单位组合折磨到崩溃。正确思路是先判断区间再判断单位最后统一换算成“月薪”这个基准口径。我实现的清洗函数是这样写的import re def parse_salary(text: str): if not text or 面议 in text: return None, None, None, None months None money_match re.search(r([\d.])\s*-\s*([\d.])\s*(万|千|元)/?(月|年|天)?, text) if not money_match: money_match re.search(r([\d.])\s*(万|千|元)/?(月|年|天)?, text) if not money_match: return None, None, None, None if - in text: low float(money_match.group(1)) high float(money_match.group(2)) else: low float(money_match.group(1)) high low unit money_match.group(3) period money_match.group(4) or 月 if unit 万: low, high low * 10000, high * 10000 elif unit 千: low, high low * 1000, high * 1000 # unit 元 时保持原值 if period 年: low, high low / 12, high / 12 elif period 天: low, high low * 21.75, high * 21.75 months_match re.search(r(\d)薪, text) if months_match: months int(months_match.group(1)) return round(low, 2), round(high, 2), round((low high) / 2, 2), months几个细节值得说明。第一单位“万”在中文里同时出现在“1.5-2.5万”和年薪的上下文里需要结合后面的“/年”或“/月”来定周期。第二“元/天”的岗位虽然少见但保洁、兼职类岗位会出现统一按每月21.75个工作日换算成月薪方便和全职岗位对比。第三年薪除以12得到的月薪是“税前应发”概念和面试时聊的“月薪”口径存在差距但至少在同一量级上具备可比性。第四“·14薪”这个后缀信息很多表示一年发14个月工资单独存一列比拼进薪资文本更科学。这段函数的边界情况很多建议写完之后用一组合法样例和一个断言测试过一遍再上线比如“1.5-2.5万·14薪”应该解析成low15000、high25000、avg20000、months14。4.2 城市、经验、学历字段归一化城市字段最有迷惑性的地方在于格式不统一。有的页面直接给“上海”有的给“上海-浦东新区”还有的给“北京-朝阳区-望京”。我的处理逻辑是用split(-)切分城市取第一段区域取最后一段。这里有个意想不到的坑有些新区名称里自带“-”比如“内蒙古-呼和浩特”但招聘页面用这种格式的场景其实很少所以加上一个已知城市名单的校验不在名单里就整串保存为城市宁可存脏一点也不要拆错。经验字段的归一化可以做成一组有序映射“经验不限”或空白 - 0到0表示无要求“应届生” - 0到0“1-3年” - 1到3“3-5年” - 3到5“5-10年” - 5到10“10年以上” - 10到99统一的数字区间在后续排序和交叉分析中非常关键。学历字段相对简单把“大专”“本科”“硕士”“博士”“不限”这些值转成标准枚举就行了分析时可以直接按类别分组统计。4.3 发布时间与异常值处理“今天发布”要换算成今天“昨天发布”换算成昨天“x天前”换算成今天减x天。这些转换逻辑在清洗管道里写好入库时统一用date对象存储。异常值处理也是清洗环节的重要组成。比如薪资解析完成之后应该做一个合理范围检查月薪低于2000或者高于100000的数据大概率是单位换算错误或者原始数据质量问题。这种数据不要直接删除而是把薪资字段置空处理保留其他字段在分析时通过dropna过滤。同时另存一份异常记录方便追溯问题源头。我在测试过程中就遇到过把“日薪200”直接当成月薪放进去的情况多亏这个边界检查及时拦住了数据污染。5. 数据落库SQLAlchemy ORM的建表与去重设计5.1 为什么用ORM而不是直接写SQL如果只是自己跑一次直接写SQLite的execute语句也完全可行。但招聘数据抓取这个需求天然是迭代式的今天抓Python岗位明天可能想加一个Java岗位今天想存薪资区间明天可能想加一个岗位标签字段。用裸SQL频繁改表结构改一次就要重新梳理一遍所有insert语句非常容易出错。SQLAlchemy ORM把表结构定义成Python类字段增删直接改类属性其他代码几乎不受影响。而且ORM生成的数据库方言会自动适配从SQLite迁移到PostgreSQL不需要改业务代码只需要改engine连接串。对于这种中期项目来说前期多写一个模型类的成本很低后面改需求的成本会小很多。5.2 表结构设计每个字段都要为分析服务我的Job模型大致长这样from sqlalchemy import create_engine, Column, Integer, String, Float, Date, DateTime, UniqueConstraint from sqlalchemy.orm import declarative_base, sessionmaker Base declarative_base() class Job(Base): __tablename__ jobs id Column(Integer, primary_keyTrue, autoincrementTrue) title Column(String(100), nullableFalse) company Column(String(100)) salary_min Column(Float) salary_max Column(Float) salary_avg Column(Float) salary_months Column(Integer) city Column(String(50)) district Column(String(50)) experience_min Column(Integer) experience_max Column(Integer) education Column(String(20)) publish_date Column(Date) source_url Column(String(500), uniqueTrue) crawl_time Column(DateTime) __table_args__ ( UniqueConstraint(source_url, nameuq_source_url), ) engine create_engine(sqlite:///jobs.db) Base.metadata.create_all(engine) Session sessionmaker(bindengine)这个表设计有几个刻意为之的细节。source_url加了unique约束这是数据库层面的天然去重保险比应用层判断要可靠得多。薪资不用一个字段存“1.5-2.5万”而是拆成salary_min和salary_max两个数值字段这样后续做聚合运算时可以直接参与计算不需要再解析一遍。salary_months单独存“14薪”中的14分析时不至于把它和月薪混在一起。5.3 去重策略与批量写入实践中发现同一岗位在多次抓取之间source_url几乎不会变化所以拿它当唯一键最稳。写入时先查询一次source_url是否存在不存在才插入更彻底的做法是使用数据库的INSERT OR IGNORE或者INSERT ... ON CONFLICT DO NOTHING直接利用唯一索引在数据库层面过滤重复数据。批量写入建议用bulk_save_objects一次塞几百条减少SQLAlchemy与数据库之间的会话往返次数。亲测同样三千条数据逐条addcommit和批量写入相比时间差距在十倍以上。数据库连接池配置也别忽略虽然SQLite不强调连接池但以后换到MySQL或PostgreSQL默认连接池稍作调整就能应对更高并发。6. 可视化和分析让薪资行情开口说话6.1 先确认数据量级抓多少条才够分析数据分析的前提是数据量得够看。如果只抓了50条岗位记录那结论基本是随机波动没有任何参考价值。我的判断标准是300条以上能看到行业薪资的大致分布形态1000条以上才敢做城市维度的细分比较因为每个城市分到手里的样本量才足够支撑结论。跑完抓取脚本后第一时间不是画图而是用pandas读库看一眼整体情况确认总行数、城市覆盖情况、薪资非空比例。这一步可以用一行代码快速完成import sqlalchemy as sa import pandas as pd df pd.read_sql(SELECT * FROM jobs, engine.connect()) print(df.shape) print(df.groupby(city)[salary_avg].agg([count, median]))如果某个城市的样本数只有个位数后续画图时就应该把它排除掉别让偶然性数据干扰判断。6.2 用pyecharts画薪资分布直方图第一张最直观的图是全岗位薪资分布直方图。把salary_avg字段分成若干区间统计落在每个区间的岗位数量可以清楚看到薪资的集中区间和长尾分布。pyecharts画这种图很顺手from pyecharts.charts import Bar from pyecharts import options as opts bins pd.cut(df[salary_avg].dropna(), bins12) counts bins.value_counts().sort_index() bar ( Bar() .add_xaxis([str(x) for x in counts.index]) .add_yaxis(岗位数量, counts.values.tolist()) .set_global_opts(title_optsopts.TitleOpts(title岗位薪资分布直方图), xaxis_optsopts.AxisOpts(name月薪元, rotate30), yaxis_optsopts.AxisOpts(name岗位数)) ) bar.render(salary_distribution.html)第一次跑完这张图的时候还挺意外的整个薪资分布并不是均匀的而是明显堆在某个区间长尾一直拖到很高位这类洞察光靠刷招聘App是不可能获得的。6.3 城市薪资对比和经验学历交叉分析第二张图是城市薪资对比按城市聚合出平均薪资和岗位数量Top10。第三张图可以做经验与薪资的关系按经验区间分组看平均薪资变化。第四张图可以做学历门槛和岗位数量的占比饼图。交叉分析最有意思的是经验区间和学历两列组合起来看。比如我实际跑出来的结论是5-10年经验的岗位平均月薪比1-3年经验明显高出一截但到了10年以上反而回落原因很可能不是资深的人更便宜而是高薪岗位本身很少公开挂出来样本存在天然偏倚。这提示了一个重要原则分析结果只能代表公开招聘市场的情况无法代表真实职场的全部面貌高端岗位往往走的是猎头和内推渠道。7. 常见问题与排查技巧实录7.1 请求突然返回验证页或状态码异常这事几乎每跑一次都会遇到不用慌张。我的排查顺序固定是三步走看状态码。403说明身份不被信任大概率是请求头问题302说明被重定向了大概率是需要登录或Cookie失效。看返回内容。复制response.text前500个字符判断它返回的是正常HTML还是验证页面如果出现类似“访问过于频繁”的文案说明频率触发了风控。看抓取间隔。检查延时是不是被固定成了某个常量固定间隔本身就会暴露脚本特征应该用随机延时。注意如果目标站点已经强制要求登录或者要求人工验证那就不是调参数能解决的事正确做法是降低抓取强度或者直接停止而不是想方设法绕过对方的验证机制。7.2 解析结果为空九成是页面结构变了列表页和详情页的结构都可能在网站改版时发生变化而爬虫代码不会自动跟着更新。发现解析结果为空时第一件事不是改选择器而是打印出response.text的前面一小段肉眼确认网站当前实际返回的HTML结构。很多朋友在这步会直接怀疑是反爬但大多数情况下就是class名变了或者某个标签层级变了。定位具体变化时优先看代码里用到的class或id在当前页面里是否还存在。有些站点会在class里加随机后缀来防爬虫此时应改用包含匹配或者属性定位比如用contains()来选择元素。整个过程其实就是重新做一次开发者工具分析不复杂但非常消耗耐心建议在爬虫代码里加一个DEBUG开关出了问题时快速切换成保存原始HTML的模式方便事后排查。7.3 薪资数据出现800和20000这种怪值薪资清洗函数的输出里如果混入单位不一致的脏数据问题基本都出在“元/天”和“元/月”没有区分或者“万”和“千”的单位换算分支没走通。我排查时会在清洗函数外面套一层数值边界校验把月薪在2000到100000之外的记录标记为异常单独存到一个CSV文件里。这配合单元测试一起用能省下无数个盯屏幕的时间。7.4 数据重复导致分析结果翻倍多次运行脚本之后发现岗位总数对不上最常见的原因是第一次跑的数据没有清空第二次跑又写入了一遍。验证方法SELECT COUNT(*) FROM jobs; SELECT COUNT(DISTINCT source_url) FROM jobs;两条SQL的结果如果差距明显说明有重复数据。当前采用source_url唯一索引之后新的写入会被数据库自动挡住但历史脏数据还是需要手动清理。这带来的教训是去重设计应该在写爬虫的第一天就考虑而不是等数据量大了之后再做补救。8. 从脚本到数据管道爬虫能力进阶的正确方向这个项目跑通之后可以考虑怎么把它从一次性脚本升级成可持续运行的数据管道。我个人的经验是考查一个爬虫工程师和“会写脚本的人”之间的差距不在于是不是会写分布式而在于下面几点任务调度是否自动化。能不能做到每天凌晨自动执行一次增量抓取而不是手动运行脚本。数据质量是否有监控。抓取之后是否会自动检查字段覆盖率、重复率、异常率超过阈值就触发告警。页面结构变化能否感知。当选择器失效时是否有一个机制能提前发现而不是等数据断更一周后才察觉。存储是否分层。原始HTML、清洗后的结构化数据、聚合后的分析结果这三层是否分离。分布式爬虫这个方向很多人问但说实话在单机单线程能轻松抓完几千条数据的量级下引入分布式完全是给自己找麻烦。真正的瓶颈早就不是“抓得快”而是“抓得好、抓得持续”。等到数据规模真的到了百万级、需要多个节点协同抓取的时候再考虑Scrapy加调度框架也不迟。当前最值得投入时间的是把脚本结构写清晰、把数据管道做稳定、把分析框架沉淀成模板这些能力比那些一次性脚本值钱得多。最后再分享一个我自己的习惯每次跑完抓取任务不管成功还是失败都顺手保存一份运行日志和统计摘要。日志里记录每个环节的耗时、成功样本数、失败请求数、异常样本数。这个习惯在项目初期看起来有点浪费但坚持几轮之后你就能非常清楚地知道脚本的健康状态也能在目标站点改版时快速定位到问题环节。好的爬虫项目不是一次写对的而是一轮一轮调出来的。
返回列表