尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Python招聘网站爬虫实战:从数据采集到清洗入库全解析

Python招聘网站爬虫实战:从数据采集到清洗入库全解析 简介网络爬虫是数据采集与行业研究的重要工具尤其在招聘领域通过爬虫批量获取职位信息能够为薪资分析、技能图谱和人才流动研究提供高质量数据基础。Python生态中的requests、BeautifulSoup与pandas组合为垂直型批量爬虫提供了轻量灵活的解决方案requests负责模拟浏览器请求并应对基础反爬策略BeautifulSoup结合XPath精准解析HTML结构pandas则承担薪资字段拆分、相对时间转换等脏数据清洗工作。数据经过结构化处理后既可存入MySQL支撑历史积累与去重查询也可导出CSV配合Excel快速分析。实际工程中合理的请求间隔、断点续跑、异常重试机制以及robots协议合规校验决定了爬虫的稳定性与合法性。本文以招聘网站数据爬虫设计为例完整拆解从模块化架构、核心代码实现到数据预处理入库的全流程为相关数据采集项目提供可直接落地的参考方案。 从招聘网站批量抓取职位信息是很多做数据分析和行业研究的人都会遇到的问题。我最早接触这个需求是因为想统计某个城市技术岗位的薪资分布和技能要求变化手动翻网页翻到崩溃才决定自己写一套爬虫。这篇文章就把这套基于 Python 的招聘网站数据爬虫设计源码拆开来讲覆盖整体设计思路、请求与解析实现、数据清洗入库以及实际运行中踩过的坑。无论你是想入门爬虫还是正在做类似的数据采集项目都可以直接参考这套方案落地。1. 项目整体设计思路与技术选型1.1 招聘网站爬虫到底属于哪类爬虫在动手之前先想清楚一个前提招聘网站的数据采集到底应该用什么样的爬虫架构业界一般把爬虫分成三类批量型、增量型和垂直型。批量型爬虫是一次性把目标数据尽可能完整地抓下来适合做离线分析和建库缺点是重复抓取成本高增量型爬虫主要针对更新频率高的数据只抓取新增或变化的部分适合做持续监控但需要额外维护一个去重和比对机制垂直型爬虫则是聚焦某一个细分领域或特定网站为某个具体业务目标服务。招聘网站爬虫本质上就是「垂直 批量」的组合垂直是因为目标明确只抓职位信息这一个垂直领域批量是因为做行业分析往往需要一段时间内的大量快照数据。我在这套源码里采用的是模块化的批量抓取架构同时留了增量更新的接口方便后续维护。还有一个容易被忽略的点招聘网站本身数据量大、更新快而且各家的 HTML 结构和反爬策略差异很大。这意味着爬虫在设计时就要考虑「可配置化」不能把选择器、URL 规则写死在代码里。实际工程中我通常会把站点配置、请求头、字段映射都抽到配置文件中换目标站点时只改配置不动代码。1.2 为什么选 requests BeautifulSoup pandas 这套组合技术选型上常见的方案有 Scrapy 框架和轻量级 requests 组合。我的选择是后者核心原因是招聘网站的反爬复杂度中等数据量级在万级到十万级之间requests BeautifulSoup pandas 足够灵活调试也更直接。Scrapy 确实功能强大内置了并发调度、去重、中间件等机制非常适合大规模爬取。但它的学习曲线和项目复杂度也相应更高——需要理解引擎、调度器、下载器、管道这些组件的配合关系。对于招聘网站这种目标明确的采集任务Scrapy 有点重。用 requests 写出来的代码更直观出了问题也好定位。数据清洗和存储部分pandas 是绕不开的利器。抓下来的原始数据通常很脏薪资可能是「15-20K·14薪」这样的文本发布时间可能是「3天前」这种相对表述公司规模可能是「1000-9999人」这种区间。这些都需要用 pandas 做二次清洗和结构化处理。后面我会详细展开清洗流程。这套方案的存储选型我用了 MySQL CSV 双通道MySQL 承担结构化查询和历史数据积累的职责CSV 则方便把数据直接交给 Pandas 做分析或者导出到 Excel。实际使用下来这个组合在灵活性和稳定性之间取得了很好的平衡。1.3 源码工程结构拆解整个项目的目录结构大致如下job_spider/ ├── config/ │ └── settings.py # 全局配置请求头、URL、字段映射 ├── spiders/ │ ├── base.py # 爬虫基础类请求、重试、解析入口 │ ├── job_spider.py # 招聘网站爬虫主逻辑 │ └── parsers.py # 页面解析函数 ├── cleaner/ │ └── data_cleaner.py # pandas 数据清洗模块 ├── storage/ │ ├── mysql_store.py # MySQL 存储 │ └── csv_store.py # CSV 导出 ├── utils/ │ └── html_fetcher.py # 请求封装随机UA、重试、代理 ├── main.py # 程序入口 └── requirements.txt模块化拆分的好处很明显请求、解析、清洗、存储互相独立任何一个环节改动都不影响其他部分。特别是解析层和清洗层分离当目标网站改版导致 HTML 结构变化时只需要修改 parsers.py清洗逻辑完全不受影响。2. 核心代码实现与解析逻辑2.1 请求模块伪装的 User-Agent 与合理的请求间隔第一步是让我们的请求看起来像真实用户。招聘网站通常会检查请求头中的 User-Agent 字段如果检测到 Python 默认的python-requests/xxxUA大概率直接拒绝访问。我在html_fetcher.py里维护了一个 UA 池每次请求随机取一个import random import requests from requests.adapters import HTTPAdapter USER_AGENTS [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36, Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0, ] class HtmlFetcher: def __init__(self): self.session requests.Session() self.session.headers.update({Accept: text/html,application/xhtmlxml}) self.session.mount(https://, HTTPAdapter(max_retries3)) def get(self, url, timeout10): ua random.choice(USER_AGENTS) self.session.headers.update({User-Agent: ua}) try: resp self.session.get(url, timeouttimeout) resp.raise_for_status() return resp except requests.exceptions.RequestException as e: print(f[请求失败] {url} - {e}) return None除了 UA请求间隔也很关键。我实测下来每两个请求之间加 2~5 秒随机延迟比固定 3 秒的效果更好因为固定间隔更容易被识别为机器行为。用time.sleep(random.uniform(2, 5))就够用了。另外要注意 Session 的连接复用——同一 Session 会复用底层的 TCP 连接减少重复握手带来的延迟和特征。这个细节在短时间大量请求时特别有用。2.2 数据解析用 XPath 提取职位关键字段招聘网站的 HTML 结构通常比较复杂我用的是 BeautifulSoup XPath 的方式。BeautifulSoup 负责把 HTML 解析成树形结构XPath 则用来精确定位每个字段。以某大型招聘网站为例职位列表页的每个职位卡片通常包含这些信息职位名称、公司名称、薪资范围、工作地点、学历要求、经验要求、发布时间。它们往往在特定的 class 或 data 属性里面。from bs4 import BeautifulSoup def parse_job_list(html): soup BeautifulSoup(html, lxml) jobs [] for item in soup.select(.job-list-item): job { title: item.select_one(.job-title).get_text(stripTrue) if item.select_one(.job-title) else , company: item.select_one(.company-name).get_text(stripTrue) if item.select_one(.company-name) else , salary: item.select_one(.salary).get_text(stripTrue) if item.select_one(.salary) else , location: item.select_one(.job-area).get_text(stripTrue) if item.select_one(.job-area) else , experience: item.select_one(.job-experience).get_text(stripTrue) if item.select_one(.job-experience) else , education: item.select_one(.job-education).get_text(stripTrue) if item.select_one(.job-education) else , published_at: item.select_one(.publish-time).get_text(stripTrue) if item.select_one(.publish-time) else , } jobs.append(job) return jobs这里有一个重要经验写选择器时优先用稳定的 class 名称不要用被 CSS 框架动态生成的随机类名比如以j_xxx_12345这种格式出现的类名。一旦发现选择器失效先打开开发者工具检查当前页面结构看看是不是网站改版了。再看分页逻辑。招聘网站的分页通常有两种形式一种是 URL 带参数比如?page1size30另一种是点击「加载更多」用 AJAX 动态加载。第一种好办直接循环改 page 参数即可。第二种需要模拟请求返回 JSON 的接口解析方式就变成处理 JSON 而不是 HTML 了。下面是两种分页的兼容处理思路def build_page_url(base_url, page): # 情况一URL 带页码参数 if page in base_url: return base_url.split(page)[0] fpage{page} # 情况二列表页依次递增 return f{base_url}{page}2.3 一个容易被忽略的解析对象职位详情页列表页拿到的字段终究是「列表级别」的——比如很多网站列表页只有薪资范围不包含具体的技能要求、福利待遇、岗位职责。做行业分析时这些详情字段往往更有价值。我在这套源码里设计了两级抓取先抓列表页获取职位 ID 和跳转链接再并发抓取详情页补齐字段。需要注意控制详情页的请求频率否则很容易触发反爬。from concurrent.futures import ThreadPoolExecutor def fetch_detail(link): fetcher HtmlFetcher() resp fetcher.get(link) if resp is None: return {} return parse_job_detail(resp.text) def batch_fetch_detail(links, max_workers5): with ThreadPoolExecutor(max_workersmax_workers) as executor: results list(executor.map(fetch_detail, links)) return results多线程并发能显著提升效率但线程数不要贪多。我测试过5 个线程加随机延迟比 20 个线程加无延迟的稳定性高很多。毕竟目标是数据完整而不是把自己页面推到对方服务器压力测试。3. 数据清洗与结构化存储3.1 pandas 处理脏数据的标准流程抓下来的原始数据直接入库是没有分析价值的。比如薪资字段「15-20K·14薪」需要用 pandas 拆出下限、上限和月薪基数发布时间「3天前」需要换算成具体日期。我整理了一套标准清洗流程分三步走第一步统一格式。把字符串中的空格、全角字符、换行符清掉确保字段一致性。import pandas as pd def clean_basic(df): df.columns [col.strip() for col in df.columns] for col in df.columns: if df[col].dtype object: df[col] df[col].str.replace(r\s, , regexTrue) return df第二步字段解析。把薪资文本拆成结构化的数字字段。def parse_salary(salary_text): if not salary_text or salary_text in (薪资面议, 面议): return None, None, None salary_text salary_text.replace(·, ) import re match re.search(r(\d)-(\d)K, salary_text) if match: low int(match.group(1)) high int(match.group(2)) months 12 m_match re.search(r(\d)薪, salary_text) if m_match: months int(m_match.group(1)) return low, high, months return None, None, None第三步发布时间相对化。把「今天」「3天前」「1周前」转成具体的datetime日期。实际开发中我发现清洗逻辑一定要写在解析之后、存储之前的独立模块里不要混在爬虫主流程里。因为清洗规则会随着分析需求的变化不断调整——比如后来我加了一个「按城市统计平均薪资」的需求就需要把城市字段标准化成统一的城市名「北京」vs「北京市」vs「朝阳区」的问题。独立模块让这些调整变得很轻松。3.2 MySQL 建表与去重策略数据存储我用的是 MySQL。建表时要注意字段类型和索引设计CREATE TABLE IF NOT EXISTS job_posts ( id INT PRIMARY KEY AUTO_INCREMENT, job_id VARCHAR(64) UNIQUE, title VARCHAR(255), company VARCHAR(255), salary_low DECIMAL(8,1), salary_high DECIMAL(8,1), salary_months INT, location VARCHAR(128), experience VARCHAR(64), education VARCHAR(64), skills TEXT, published_date DATE, source_url VARCHAR(500), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里的job_id是去重的关键。每个职位在招聘网站都有唯一的发布 ID通常出现在职位详情页的 URL 里。抓取时提取这个 ID写库时用INSERT IGNORE或ON DUPLICATE KEY UPDATE来避免重复插入。def save_to_mysql(df): from sqlalchemy import create_engine engine create_engine(mysqlpymysql://user:passwordlocalhost:3306/job_db?charsetutf8mb4) df.to_sql(job_posts, engine, if_existsappend, indexFalse)注意一点用to_sql批量入库时如果 DataFrame 里有 nan 值MySQL 层面会出现写入异常。需要在入库前先fillna()或dropna(subset[job_id])。这是新手很容易踩的坑。3.3 CSV 导出与分析接口除了 MySQL我还做了一个轻量的 CSV 导出模块。分析场景下直接把清洗后的数据导出成 CSV用 Excel 打开就能做透视表比查数据库直观得多。def save_to_csv(df, filenamejobs.csv): df.to_csv(filename, indexFalse, encodingutf-8-sig)重点说一下utf-8-sig这个编码。Python 默认的utf-8编码在 Excel 打开会出现中文乱码加 BOMutf-8-sig之后 Excel 就能正常识别。这个细节看起来小但数据交付时特别影响体验。4. 反爬应对策略与合规操作4.1 常见反爬手段及对应策略招聘网站是目前反爬做得最扎实的站点类型之一。我实际遇到的常见反爬手段大概有四种请求头校验、访问频率限制、登录墙、验证码。这里分享我的应对思路前提是合规合法、控制频率、不攻击对方服务器。请求头校验是最常见的。除了 UA现在很多网站还会校验Referer、Accept-Language甚至通过 TLS 指纹识别请求来源。对于前两者直接在请求头里补齐真实浏览器的完整字段即可。对于 TLS 指纹这种高级校验requests 基本无解通常要考虑使用浏览器自动化工具但那属于另一个层面的方案了日常采集用不到这个深度。访问频率限制是最普遍的。核心策略就是「慢」——降低单任务并发数提高相邻请求的间隔给每轮抓取之间留出休息时间。我一般会把抓取任务分批执行比如每 50 页暂停 30 秒比一次性跑完更不容易被拉黑。登录墙的处理要特别小心。一部分招聘网站要求登录才能看完整职位列表这需要维护 Cookie 会话。但这里必须明确一个界限凡是需要破解验证码、绕过登录鉴权的行为都不是自己写爬虫时应该碰的内容——合规性永远在采集效率前面。至于验证码策略就是「遇到就停」。识别到验证码出现时暂停当前任务并告警人工介入处理而不是写代码去破解。这套源码做了验证码识别的回调函数检测到异常时自动停止该线程的任务。4.2 请求重试机制与异常恢复爬虫长时间运行遇到网络抖动、服务器返回 5xx、请求超时是在所难免的。我在请求封装里加了重试逻辑对超时和 5xx 状态码自动重试 2 次每次间隔 5 秒对 403 和 404 则直接跳过——403 通常意味着被反爬拦了重试多少遍都没用反而加重对方服务器负担404 说明页面失效也没必要重试。def fetch_with_retry(url, max_retries2): fetcher HtmlFetcher() for attempt in range(max_retries 1): resp fetcher.get(url) if resp is None: continue if resp.status_code in (200, 404): return resp if resp.status_code 403: print(f[反爬触发] {url}) return None return None还有一点程序要支持断点续跑。爬虫跑一半挂了是常事如果没有断点续跑能力前几个小时的成果就全浪费了。我的做法是把已经成功抓取的职位 ID 记录到一个本地文件或者数据库表里重启时先加载已完成的 ID 集合遇到重复 ID 直接跳过。这个机制配合 MySQL 的job_id唯一索引双保险。4.3 robots 协议与抓取边界这部分我必须强调一下爬虫的合规底线是尊重目标网站的robots.txt和用户协议。robots.txt 里明确禁止抓取的路径不要碰网站服务条款里禁止爬虫的站点要慎重最好先获取授权或者选择公开数据源。做数据采集和分析的人应该把「采集边界」当成代码的一部分来设计。我在项目里加了一个should_crawl(url)函数启动时读取 robots.txt用urllib.robotparser判断当前 URL 是否允许抓取不允许的直接过滤掉from urllib.robotparser import RobotFileParser def should_crawl(url): rp RobotFileParser() rp.set_url(https://example.com/robots.txt) rp.read() return rp.can_fetch(*, url)这个函数虽然简单但代表了一个很重要的态度做爬虫不是和网站方「对抗」而是在合理的边界内获取公开数据。数据采集本身是中性的但方式和分寸决定了它的性质。5. 岗位数据价值与扩展应用场景5.1 数据能做什么薪资分析、技能图谱与人才流动爬下来的数据不是终点后面的分析才是价值所在。我基于这套爬虫采集的数据做了几个比较成功的分析方向薪资分析。把海量岗位的薪资下限和上线拆解出来之后按城市、按岗位、按经验年限可以做非常精细的统计。比如「Python 开发工程师在杭州的中位数薪资」「3-5 年经验的算法工程师薪资区间」这些话题都可以用数据说话而不是靠感觉。技能图谱。详情页的岗位描述里有大量的技能要求关键词Python、Java、Go、Docker、Kubernetes、TensorFlow……用简单的词频统计或者 TF-IDF 就能刻画出不同岗位的技能分布情况甚至可以追踪某个技术栈在一段时间内的热度变化。这个分析结果对求职者、培训机构和招聘方都有参考价值。人才流动。如果定时采集同一批岗位的数据可以看到哪些公司在持续招人、哪些岗位反复发布又下架间接判断一个城市的岗位供求情况。这个方向需要增量型爬虫的支持也是我留增量接口的原因。5.2 技术扩展从单机脚本到分布式采集目前的架构是单机多线程对于招聘网站这种量级的站点完全够用。但如果要扩展到全网级别的数据采集就需要考虑分布式架构——比如用 Scrapy Redis 做任务调度或者用 Celery 做异步任务队列。我建议不要一上来就追求分布式先从单机方案把数据收集的完整链路跑通确认数据质量没问题再往分布式演进。大多数分析项目卡住的不是采集速度而是数据清洗和数据质量问题。先把这两块做好价值远大于盲目堆并发。5.3 与自动化运维的联动定时任务与监控报警爬虫上线之后运维层面的设计也能让效率提升一个台阶。我在这套项目里加了定时任务的支持用系统的 cron 或者 Python 的schedule库每天固定时间跑一次增量更新import schedule import time def job(): print(开始增量抓取...) run_spider(incrementalTrue) schedule.every().day.at(03:00).do(job) while True: schedule.run_pending() time.sleep(60)选凌晨 3 点跑任务是有讲究的这个时段网站访问量低爬虫对服务器侧的压力感知更小也更容易避开反爬的峰时策略。同时落库时也不会影响白天其他业务的读写。监控方面我在代码里埋了几个关键指标成功率、耗时、抓取条数定期写入日志文件并用简单的脚本检测异常——比如成功率低于 80% 就发告警邮件。这套轻量监控方案在数据采集项目中非常实用避免了半夜爬起来看日志的尴尬。6. 常见问题排查与避坑指南6.1 问题速查表我整理了在这套爬虫开发与运行过程中最常遇到的问题做成速查表希望能帮你少走弯路。现象可能原因解决方案返回 403 状态码User-Agent 被识别更换 UA 池检查请求头完整性请求超时目标服务器响应慢或临时封锁增加超时时间降低请求频率页面空白或数据为空HTML 结构变化选择器失效打开开发者工具重新提取选择器中文乱码编码识别错误解析前指定resp.encoding utf-8数据重复不同页面返回相同职位依据 job_id 去重数据库加唯一索引写入数据库失败DataFrame 存在 NaN入库前dropna或fillna()程序中途崩溃网络抖动、资源耗尽增加断点续跑记录已完成 ID被要求验证码请求频率过高暂停任务延长间隔人工处理6.2 定位问题的思路排查爬虫问题时我的经验是「先隔离再定位」。比如页面数据为空先确认是不是请求出了问题——在代码里打印响应状态码和响应体开头 500 个字符看看返回的是不是正常的 HTML如果请求正常再检查解析逻辑用单条样例数据跑一次解析函数看看每个字段是不是都能提取到;如果解析也正常再检查清洗和存储。很多时候问题不在「爬」本身而在「解析」或「清洗」环节。比如列表页和详情页的字段命名不同清洗时字段名对不上导致入库全是空值。这类问题用日志追踪比一步步调试更高效——我在关键环节都加了print日志跑一遍之后看日志重点排查。6.3 一些源码里的设计习惯这套源码里我沉淀了几个值得介绍的设计习惯在后续其他爬虫项目里也一直在用。第一个是「所有外部可变的参数一律走配置」。URL 规则、请求头、字段映射、数据库连接串全部放在config/settings.py里。换站点、改需求的时候只改配置不动核心代码省心很多。第二个是「解析函数保持纯函数」。parsers.py里的每个解析函数只接收字符串返回字典或列表不依赖全局状态也不发网络请求。这样非常便于单元测试——直接喂一段 HTML 就能验证解析逻辑是否正确。第三个是「日志留痕」。不只是记录错误更要记录成功——每轮任务结束输出本次抓取了多少条、成功多少条、失败多少条、耗时多少秒。这些数据看起来简单但在长期运行优化时是判断爬虫健康度的核心指标。写在最后几点个人体会这套基于 Python 的招聘网站数据爬虫从最早的手工脚本走到现在的模块化工程中间经历过无数次选择器失效、库表结构重调、反爬策略升级。回看整个开发过程我觉得最有价值的不是某一个具体技术技巧而是「模块化 日志 可配置」这三个习惯的组合。最后分享一个小技巧如果你对某个招聘网站的页面结构不够熟悉不要急着写解析代码。先用 Chrome 开发者工具手工点几次把列表页和详情页的 URL 规律、关键字段的 class 名称都记录下来形成一份「站点字段清单」再写代码效率会高很多。实际上这套源码里最核心的设计不是代码本身而是这些藏在代码之外的调研工作。数据采集是一件需要耐心和分寸感的事。希望这篇文章能帮你少踩一些坑把精力放到更有价值的分析上。本文还有配套的精品资源点击获取
返回列表