㊗️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~
㊙️本期爬虫难度指数:⭐⭐⭐
🉐福利: 一次订阅后,专栏内的所有文章可永久免费看,持续更新中,保底1000+(篇)硬核实战内容。

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- [1️⃣ 摘要(Abstract)](#1️⃣ 摘要(Abstract))
- [2️⃣ 背景与需求(Why)](#2️⃣ 背景与需求(Why))
- [3️⃣ 合规与注意事项(必写)](#3️⃣ 合规与注意事项(必写))
-
- 公共数据采集的法律边界
- [robots.txt 检查](#robots.txt 检查)
- 数据使用规范
- 频率控制建议
- 道德准则
- [4️⃣ 技术选型与整体流程(What/How)](#4️⃣ 技术选型与整体流程(What/How))
- [5️⃣ 环境准备与依赖安装(可复现)](#5️⃣ 环境准备与依赖安装(可复现))
- [6️⃣ 核心实现:请求层(Fetcher)](#6️⃣ 核心实现:请求层(Fetcher))
-
- 代码实现(fetcher.py)
- 关键技术点详解
-
- [1. 编码处理的重要性](#1. 编码处理的重要性)
- [2. Session的优势](#2. Session的优势)
- [3. 超时时间的设置](#3. 超时时间的设置)
- [7️⃣ 核心实现:解析层(Parser)](#7️⃣ 核心实现:解析层(Parser))
- [🔟 运行方式与结果展示(必写)](#🔟 运行方式与结果展示(必写))
- [1️⃣1️⃣ 常见问题与排错(FAQ)](#1️⃣1️⃣ 常见问题与排错(FAQ))
-
- [Q1: 表格有合并单元格,数据错位怎么办?](#Q1: 表格有合并单元格,数据错位怎么办?)
- [Q2: 翻页是 JavaScript 链接,没有 href 怎么办?](#Q2: 翻页是 JavaScript 链接,没有 href 怎么办?)
- [Q3: 中文显示为乱码()](#Q3: 中文显示为乱码())
- [Q4: 页面能看到数据,但爬下来是空的](#Q4: 页面能看到数据,但爬下来是空的)
- [1️⃣2️⃣ 进阶优化(Advanced Optimization)](#1️⃣2️⃣ 进阶优化(Advanced Optimization))
-
- [1. 增量更新(避免重复爬取)](#1. 增量更新(避免重复爬取))
- [2. 行业分类智能化](#2. 行业分类智能化)
- [3. 数据可视化分析](#3. 数据可视化分析)
- [1️⃣3️⃣ 总结与延伸阅读](#1️⃣3️⃣ 总结与延伸阅读)
-
- [📚 项目复盘](#📚 项目复盘)
- [🚀 职业发展建议](#🚀 职业发展建议)
- [🔗 延伸阅读](#🔗 延伸阅读)
- 附录:完整项目文件结构
- [🌟 文末](#🌟 文末)
-
- [✅ 专栏持续更新中|建议收藏 + 订阅](#✅ 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
- [✅ 免责声明](#✅ 免责声明)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅专栏👉《Python爬虫实战》👈,一次订阅后,专栏内的所有文章可永久免费阅读,持续更新中。
💕订阅后更新会优先推送,按目录学习更高效💯~
1️⃣ 摘要(Abstract)
一句话概括:使用 Python requests + lxml 爬取公开招聘会网站的参展企业信息(企业名称、所属行业、展位号、招聘岗位、联系方式等),最终输出为结构化的 SQLite 数据库 + Excel/CSV 文件,支持行业分析、岗位匹配、企业筛选等应用场景。
读完本文你将获得:
- 掌握公共服务类网站(政府/高校/展会)的数据采集技巧
- 学会处理表格数据、分页列表、详情页嵌套的复杂结构
- 获得一套生产级的招聘信息采集系统(支持多场次、多地区、历史对比)
- 理解公共数据采集的合规要求和最佳实践
2️⃣ 背景与需求(Why)
为什么要爬取招聘会数据?
每年全国各地举办数千场线上线下招聘会,但信息分散在各个平台,给求职者和研究者带来诸多不便。通过爬虫采集数据后,我们可以:
-
求职者视角:
- 快速筛选目标行业和岗位
- 对比不同招聘会的企业质量
- 提前了解展位分布,规划参会路线
- 追踪心仪企业的招聘动态
-
HR/企业视角:
- 分析竞品企业的招聘策略
- 了解行业人才需求趋势
- 评估参展招聘会的效果
-
数据分析师视角:
- 研究地区产业结构(哪些行业活跃)
- 分析就业市场供需关系
- 预测行业发展趋势
- 制作可视化报告
-
学校/政府视角:
- 监测就业市场动态
- 优化招聘会组织
- 为学生提供就业指导
典型招聘会网站结构
网站类型:
- 政府人社部门网站(如各地人力资源市场)
- 高校就业信息网(如各大学就业指导中心)
- 专业招聘会平台(如智联、前程无忧的线下招聘会)
- 行业协会网站(如互联网协会、制造业协会)
典型页面结构:
json
首页(招聘会列表)
├── 招聘会A - 2025春季综合招聘会
│ ├── 基本信息(时── 参展企业列表
│ ├── 企业1(名称、行业、展位、岗位)
│ ├── 企业2
│ └── 企业3
│
├── 招聘会B - IT互联网专场
└── 招聘会C - 制造业专场
目标字段清单
| 字段名 | 说明 | 示例值 | 获取难度 |
|---|---|---|---|
| fair_name | 招聘会名称 | "2025春季综合招聘会" | ⭐ 简单 |
| fair_date | 举办日期 | "2025-03-15" | ⭐ 简单 |
| fair_location | 举办地点 | "市体育馆一楼" | ⭐ 简单 |
| company_name | 企业名称 | "XX科技有限公司" | ⭐ 简单 |
| industry | 所属行业 | "互联网/电子商务" | ⭐⭐ 中等 |
| booth_number | 展位号 | "A101" | ⭐ 简单 |
| positions | 招聘岗位 | ["Java工程师", "产品经理"] | ⭐⭐ 中等 |
| headcount | 招聘人数 | "10-20人" | ⭐⭐ 中等 |
| requirements | 岗位要求 | "本科及以上,3年经验" | ⭐⭐⭐ 困难 |
| contact | 联系方式 | "hr@example.com" | ⭐⭐ 中等 |
| company_scale | 企业规模 | "500-1000人" | ⭐⭐ 中等 |
扩展字段(可选):
- 企业性质(国企/民企/外企)
- 薪资范围
- 工作地点
- 企业官网
- 企业简介
3️⃣ 合规与注意事项(必写)
公共数据采集的法律边界
招聘会信息属于公开信息,但仍需注意:
✅ 允许采集的情况:
- 政府/高校官网公开发布的招聘会信息
- 明确标注"公开"的企业参展名单
- 用于个人求职、学术研究、非商业分析
❌ 禁止采集的情况:
- 需要登录才能查看的内部信息
- 明确标注"仅供内部使用"的数据
- 个人隐私信息(HR个人电话、家庭住址等)
- 用于商业转售、垃圾营销
robots.txt 检查
大多数政府和高校网站的 robots.txt 比较宽松:
json
# 典型的政府/高校网站 robots.txt
User-agent: *
Disallow: /admin/
Disallow: /api/
Allow: /jobfair/
Allow: /news/
解读:
- ✅ 允许爬取招聘会信息页面(
/jobfair/) - ❌ 禁止爬取管理后台(
/admin/)
数据使用规范
个人信息保护:
- ❌ 不采集HR的个人手机号(只采集企业公开邮箱)
- ❌ 不采集企业内部通讯录
- ✅ 可以采集企业官方联系方式
数据使用边界:
- ✅ 个人求职:筛选目标企业、规划参会路线
- ✅ 学术研究:就业市场分析、产业结构研究
- ✅ 非商业分享:与同学分享招聘会信息
- ❌ 商业转售:打包数据卖给猎头公司
- ❌ 垃圾营销:批量发送推广邮件
频率控制建议
公共服务网站通常带宽有限,建议:
- 请求间隔:3-5秒(比商业网站更保守)
- 并发限制:单线程或最多2个并发
- 时间选择:避开工作时间高峰(9:00-11:00, 14:00-16:00)
- 总量控制:单次不超过1000个请求
道德准则
- 🤝 善意原则:不对服务器造成压力
- 📚 归属原则:使用数据时注明来源
- 🔒 隐私原则:不采集和传播个人隐私
- ⚖️ 合法原则:遵守《网络安全法》《数据安全法》
4️⃣ 技术选型与整体流程(What/How)
静态 vs 动态分析
招聘会网站大多是传统的服务端渲染,非常适合静态抓取:
| 特征 | 静态网站(多数招聘会) | 动态网站(少数新式平台) |
|---|---|---|
| HTML来源 | 服务器直接渲染 | JavaScript动态生成 |
| 查看源代码 | 能看到完整数据 | 只能看到空壳 |
| 爬取难度 | ⭐ 简单 | ⭐⭐⭐ 困难 |
| 工具选择 | requests + lxml | Selenium/Playwright |
判断方法:
python
# 访问页面后,右键 → 查看源代码
# 如果能在HTML中搜索到企业名称,就是静态的
# 示例:静态渲染(可以看到数据)
<tr>
<td>阿里巴巴集团</td>
<td>互联网</td>
<td>A101</td>
</tr>
# 示例:动态渲染(只有空标签)
<div id="company-list"></div>
<script src="app.js"></script>
技术栈选择
| 组件 | 技术选择 | 理由 |
|---|---|---|
| HTTP请求 | requests + Session | 轻量、稳定、支持Cookie |
| HTML解析 | lxml + XPath | 速度快、内存占用低 |
| 数据清洗 | re (正则) + pandas | 处理各种格式异常 |
| 数据存储 | SQLite + Excel | 方便查询 + 易于分享 |
| 数据分析 | pandas + matplotlib | 统计分析 + 可视化 |
整体流程设计
json
[招聘会列表页] → 获取所有招聘会的链接
↓
[单个招聘会详情页] → 提取基本信息(时间、地点)
↓
[企业列表表格] → 解析所有参展企业
↓
提取企业信息(名称、行业、展位、岗位)
↓
[企业详情页](可选)→ 补充详细信息
↓
数据清洗(行业标准化、展位号规范化)
↓
存储到 SQLite + 导出 Excel/CSV
↓
数据分析(行业分布、热门岗位)
分步说明:
-
步骤1:获取招聘会列表
- URL示例:
https://job.example.edu.cn/jobfair/list - 提取:招聘会名称、日期、详情链接
- URL示例:
-
步骤2:进入招聘会详情
- URL示例:
https://job.example.edu.cn/jobfair/detail?id=12345 - 提取:举办时间、地点、主题、参展企业数
- URL示例:
-
步骤3:解析企业表格
- 通常是
<table>标签或<ul>列表 - 可能分页(需要处理翻页逻辑)
- 通常是
-
步骤4:数据清洗
- 行业标准化:"IT/互联网" → "信息技术"
- 展位号规范化:"A-101" → "A101"
- 去除重复企业
-
步骤5:存储与导出
- SQLite:支持复杂查询
- Excel:方便HR/求职者查看
5️⃣ 环境准备与依赖安装(可复现)
Python 版本
建议使用 Python 3.10+(本文基于 Python 3.10.8 测试通过)
依赖安装
bash
pip install requests lxml pandas openpyxl python-dateutil --break-system-packages
# 可选:数据可视化
pip install matplotlib seaborn wordcloud --break-system-packages
依赖说明:
| 库 | 版本要求 | 用途 | 是否必需 |
|---|---|---|---|
| requests | >=2.28.0 | HTTP请求 | ✅ 必需 |
| lxml | >=4.9.0 | HTML解析 | ✅ 必需 |
| pandas | >=1.5.0 | 数据处理 | ✅ 必需 |
| openpyxl | >=3.0.0 | Excel导出 | ✅ 必需 |
| python-dateutil | >=2.8.0 | 日期解析 | ✅ 必需 |
| matplotlib | >=3.5.0 | 图表生成 | ⭐ 可选 |
| seaborn | >=0.12.0 | 高级可视化 | ⭐ 可选 |
项目结构
json
jobfair_crawler/
│
├── main.py # 主入口
├── config.py # 配置文件
├── fetcher.py # 请求层
├── parser.py # 解析层
├── cleaner.py # 数据清洗层
├── storage.py # 存储层
├── analyzer.py # 数据分析层(可选)
├── requirements.txt # 依赖清单
│
├── data/
│ ├── jobfairs.db # SQLite数据库
│ ├── 2025春季招聘会.xlsx # Excel导出
│ └── analysis/ # 分析结果
│ ├── industry_chart.png
│ └── position_wordcloud.png
│
├── logs/
│ ├── crawler.log # 运行日志
│ └── error.log # 错误日志
│
└── tests/
├── test_parser.py # 单元测试
└── test_cleaner.py
6️⃣ 核心实现:请求层(Fetcher)
代码实现(fetcher.py)
python
"""
招聘会数据采集 - 请求层
功. 发送HTTP请求获取HTML
2. 处理Session和Cookie
3. 自动重试和错误处理
4. 频率控制(保护公共服务器)
作者:YourName
日期:2025-01-27
"""
import requests
import time
import logging
from typing import Optional
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class JobFairFetcher:
"""招聘会网站请求器"""
def __init__(self):
"""初始化请求器"""
# 创建Session(复用连接)
# 解释:Session会保持Cookie和连接池,提升性能
self.session = self._create_session()
# 请求头(模拟真实浏览器)
# 解释:政府/高校网站通常不检查User-Agent,但保险起见还是要设置
self.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/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
}
# 请求配置
self.timeout = 20 # 超时时间(秒)- 政府网站可能比较慢
self.delay = 3.0 # 请求间隔(秒)- 保守策略
self.last_request_time = 0
# 统计信息
self.stats = {
'total_requests': 0,
'success': 0,
'failed': 0,
'retries': 0
}
def _create_session(self) -> requests.Session:
"""
创建带重试机制的Session
Returns:
配置好的Session对象
解释:
- 使用Retry策略自动处理临时性网络错误
- 指数退避(backoff_factor):第1次重试等1秒,第2次等2秒,第3次等4秒
- status_forcelist:遇到这些HTTP状态码时触发重试
"""
session = requests.Session()
# 重试策略配置
retry_strategy = Retry(
total=3, # 最多重试3次
backoff_factor=1, # 重试间隔递增因子
status_forcelist=[429, 500, 502, 503, 504], # 触发重试的状态码
allowed_methods=["HEAD", "GET", "OPTIONS"] # 只对这些方法重试
)
# 挂载到Session
adapter = HTTPAdapter(max_retries=retry_strategy)
session.mount("http://", adapter)
session.mount("https://", adapter)
return session
def _rate_limit(self):
"""
请求频率控制
解释:
- 计算距离上次请求的时间
- 如果间隔不够,则等待
- 对公共服务器友好,避免造成压力
"""
elapsed = time.time() - self.last_request_time
if elapsed < self.delay:
sleep_time = self.delay - elapsed
if sleep_time > 0:
logger.debug(f"⏱️ 等待 {sleep_time:.1f}秒以控制频率")
time.sleep(sleep_time)
self.last_request_time = time.time()
def fetch(self, url: str, params: dict = None, encoding: str = 'utf-8') -> Optional[str]:
"""
获取页面HTML
Args:
url: 目标URL
params: URL参数(如分页参数)
encoding: 页面编码(默认UTF-8,某些老网站可能是GBK)
Returns:
HTML字符串,失败返回None
示例:
# 基本请求
html = fetcher.fetch('https://job.example.edu.cn/jobfair/list')
# 带参数(分页)
html = fetcher.fetch(
'https://job.example.edu.cn/jobfair/list',
params={'page': 2, 'pageSize': 20}
)
# 指定编码(老网站可能用GBK)
html = fetcher.fetch('https://old-site.gov.cn/list', encoding='gbk')
"""
# 频率控制
self._rate_limit()
# 统计
self.stats['total_requests'] += 1
try:
# 发送GET请求
# 解释:
# - headers: 伪装成浏览器
# - params: URL查询参数(自动编码)
# - timeout: 防止长时间卡死
# - allow_redirects: 允许自动跟随重定向
response = self.session.get(
url,
headers=self.headers,
params=params,
timeout=self.timeout,
allow_redirects=True
)
# 检查HTTP状态码
# 解释:raise_for_status() 会在状态码>=400时抛出异常
response.raise_for_status()
# 处理编码
# 解释:有些网站的编码声明不准确,需要手动指定
if encoding:
response.encoding = encoding
# 统计
self.stats['success'] += 1
logger.info(f"✅ 成功: {url} ({len(response.text)} 字符)")
return response.text
except requests.exceptions.HTTPError as e:
status_code = e.response.status_code
if status_code == 403:
logger.error(f"🚫 403 Forbidden: {url}")
logger.info("💡 可能原因:需要登录、IP被限制、或访问了禁止页面")
elif status_code == 404:
logger.warning(f"📭 404 Not Found: {url}")
logger.info("💡 可能原因:链接失效、招聘会已结束")
elif status_code == 500:
logger.error(f"💥 500 Server Error: {url}")
logger.info("💡 可能原因:服务器内部错误,稍后重试")
else:
logger.error(f"❌ HTTP {status_code}: {url}")
self.stats['failed'] += 1
return None
except requests.exceptions.Timeout:
logger.error(f"⏱️ 请求超时: {url}")
logger.info("💡 建议:增加timeout时间或稍后重试")
self.stats['failed'] += 1
return None
except requests.exceptions.ConnectionError:
logger.error(f"🔌 连接失败: {url}")
logger.info("💡 可能原因:网络问题、DNS解析失败、服务器维护")
self.stats['failed'] += 1
return None
except Exception as e:
logger.error(f"❌ 未知错误: {url} - {str(e)}")
self.stats['failed'] += 1
return None
def fetch_with_post(self, url: str, data: dict = None) -> Optional[str]:
"""
使用POST方法请求(某些搜索功能需要)
Args:
url: 目标URL
data: POST数据(表单数据)
Returns:
HTML字符串
示例:
# 搜索功能(可能需要POST)
html = fetcher.fetch_with_post(
'https://job.example.edu.cn/search',
data={'keyword': '计算机', 'industry': 'IT'}
)
"""
self._rate_limit()
self.stats['total_requests'] += 1
try:
response = self.session.post(
url,
headers=self.headers,
data=data,
timeout=self.timeout
)
response.raise_for_status()
self.stats['success'] += 1
logger.info(f"✅ POST成功: {url}")
return response.text
except Exception as e:
logger.error(f"❌ POST失败: {url} - {str(e)}")
self.stats['failed'] += 1
return None
def get_stats(self) -> dict:
"""
获取请求统计信息
Returns:
统计字典
"""
if self.stats['total_requests'] > 0:
success_rate = self.stats['success'] / self.stats['total_requests'] * 100
else:
success_rate = 0
return {
**self.stats,
'success_rate': f"{success_rate:.2f}%"
}
def close(self):
"""关闭Session"""
self.session.close()
logger.info("🔒 Session已关闭")
# ========== 使用示例 ==========
if __name__ == "__main__":
# 初始化
fetcher = JobFairFetcher()
# 测试:获取招聘会列表页
html = fetcher.fetch('https://job.example.edu.cn/jobfair/list')
if html:
print(f"成功获取HTML,长度:{len(html)}")
print(f"前500个字符:\n{html[:500]}")
# 查看统计
print(f"\n统计信息:{fetcher.get_stats()}")
# 关闭
fetcher.close()
关键技术点详解
1. 编码处理的重要性
很多政府和高校网站使用老旧的编码:
python
# 问题:中文显示为乱码
html = requests.get(url).text
# 输出: ˾ (乱码)
# 解决方案1:指定编码
response = requests.get(url)
response.encoding = 'gbk' # 或 'gb自动检测
import chardet
response = requests.get(url)
detected = chardet.detect(response.content)
response.encoding = detected['encoding']
html = response.text
常见编码:
utf-8:新式网站(推荐)gbk/gb2312:老式中文网站gb18030:兼容性最好的中文编码
2. Session的优势
python
# ❌ 不使用Session(每次都建立新连接)
for url in urls:
html = requests.get(url).text # 慢!
# ✅ 使用Session(复用连接)
session = requests.Session()
for url in urls:
html = session.get(url).text # 快!
性能对比:
- 无Session:每次请求都要DNS解析 + TCP握手 + TLS握手
- 有Session:只有第一次请求需要握手,后续复用连接
- 速度提升:30-50%
3. 超时时间的设置
python
# 政府/高校网站可能比较慢,建议设置较长的超时
timeout = 20 # 秒
# 也可以分别设置连接超时和读取超时
timeout = (5, 20) # (连接超时, 读取超时)
7️⃣ 核心实现:解析层(Parser)
代码实现(parser.py)
python
"""
招聘会数据采集 - 解析层
功能:
1. 解析招聘会列表页
2. 解析企业信息表格
3. 处理分页逻辑
4. 提取详情页数据
作者:YourName
日期:2025-01-27
"""
from lxml import etree
from typing import List, Dict, Optional
import re
import logging
logger = logging.getLogger(__name__)
class JobFairParser:
"""招聘会页面解析器"""
@staticmethod
def parse_fair_list(html: str) -> List[Dict]:
"""
解析招聘会列表页
Args:
html: 列表页HTML
Returns:
招聘会列表 [{'name': '...', 'date': '...', 'url': '...'}, ...]
解释:
招聘会列表页通常有以下几种结构:
1. <ul> + <li> 列表
2. <table> 表格
3. <div> 卡片布局
"""
tree = etree.HTML(html)
fairs = []
# 方式1:从<ul>列表提取
# 典型结构:
# <ul class="jobfair-list">
# <li>
# <a href="/detail?id=123">2025春季招聘会</a>
# <span class="date">2025-03-15</span>
# </li>
# </ul>
fair_items = tree.xpath('//ul[contains(@class, "jobfair")]//li')
if not fair_items:
# 方式2:从<table>提取
fair_items = tree.xpath('//table//tr[position()>1]') # 跳过表头
for item in fair_items:
try:
fair_data = JobFairParser._parse_fair_item(item)
if fair_data and fair_data.get('name'):
fairs.append(fair_data)
except Exception as e:
logger.warning(f"⚠️ 解析招聘会项失败: {str(e)}")
continue
logger.info(f"📋 解析到 {len(fairs)} 个招聘会")
return fairs
@staticmethod
def _parse_fair_item(item) -> Dict:
"""
解析单个招聘会项
Args:
item: lxml Element对象
Returns:
招聘会信息字典
"""
# 提取名称和链接
# XPath解释:
# .//a → 在当前节点内查找a标签
# [1] → 取第一个匹配
link = item.xpath('.//a/@href')
name = item.xpath('.//a/text()')
# 提取日期
# 可能在span、td或直接在文本中
date_text = JobFairParser._extract_text(item, './/span[contains(@class, "date")]//text()')
if not date_text:
# 备用:从整个文本中提取日期
full_text = ''.join(item.xpath('.//text()'))
date_match = re.search(r'(\d{4}[-/年]\d{1,2}[-/月]\d{1,2}[日]?)', full_text)
if date_match:
date_text = date_match.group(1)
# 提取地点
location = JobFairParser._extract_text(item, './/span[contains(@class, "location")]//text()')
# 组装数据
fair_data = {
'name': name[0].strip() if name else "",
'date': date_text.strip() if date_text else "",
'location': location.strip() if location else "",
'url': link[0] if link else ""
}
# 处理相对路径
if fair_data['url'] and not fair_data['url'].startswith('http'):
# 假设baseurl从配置读取
fair_data['url'] = f"https://job.example.edu.cn{fair_data['url']}"
return fair_data
@staticmethod
def parse_company_table(html: str) -> List[Dict]:
"""
解析企业信息表格
Args:
html: 招聘会详情页HTML
Returns:
企业列表
解释:
企业信息通常以表格形式展示:
<table>
<tr>
<th>序号</th><th>企业名称</th><th>所属行业</th><th>展位号</th><th>招聘岗位</th>
</tr>
<tr>
<td>1</td><td>阿里巴巴</td><td>互联网</td><td>A101</td><td>Java工程师</td>
</tr>
</table>
"""
tree = etree.HTML(html)
companies = []
# 查找表格
# 解释:通常表格会有特定的class或id
table = tree.xpath('//table[contains(@class, "company")]')
if not table:
# 备用:查找所有table,取第一个
table = tree.xpath('//table')
if not table:
logger.warning("⚠️ 未找到企业信息表格")
return []
table = table[0]
# 获取表头(用于定位列)
# 解释:通过表头判断每列的含义
headers = table.xpath('.//tr[1]//th/text()')
headers = [h.strip() for h in headers]
logger.debug(f"表头: {headers}")
# 定位关键列的索引
# 解释:不同网站的列顺序可能不同,需要动态查找
col_indices = JobFairParser._find_column_indices(headers)
# 解析数据行(跳过表头)
rows = table.xpath('.//tr[position()>1]')
for row in rows:
try:
company_data = JobFairParser._parse_company_row(row, col_indices)
if company_data and company_data.get('company_name'):
companies.append(company_data)
except Exception as e:
logger.warning(f"⚠️ 解析企业行失败: {str(e)}")
continue
logger.info(f"🏢 解析到 {len(companies)} 家企业")
return companies
@staticmethod
def _find_column_indices(headers: List[str]) -> Dict[str, int]:
"""
查找关键列的索引位置
Args:
headers: 表头列表
Returns:
列索引字典 {'company_name': 1, 'industry': 2, ...}
解释:
因为不同网站的表头可能不同,需要模糊匹配
例如:"企业名称"/"公司名称"/"单位名称" 都应该被识别为公司名
"""
indices = {}
# 定义匹配规则(支持多种表达)
# 解释:使用正则表达式模糊匹配
column_patterns = {
'company_name': r'(企业|公司|单位)名称',
'industry': r'(所属)?行业|类别',
'booth': r'展位(号)?|摊位',
'positions': r'(招聘)?岗位|职位',
'headcount': r'(招聘)?人数|名额',
'contact': r'联系(方式|电话|邮箱)',
'requirements': r'(岗位)?要求|学历'
}
for key, pattern in column_patterns.items():
for idx, header in enumerate(headers):
if re.search(pattern, header, re.IGNORECASE):
indices[key] = idx
break
logger.debug(f"列索引映射: {indices}")
return indices
@staticmethod
def _parse_company_row(row, col_indices: Dict[str, int]) -> Dict:
"""
解析企业信息行
Args:
row: 表格行元素
col_indices: 列索引映射
Returns:
企业数据字典
"""
# 获取所有列的文本
# 解释:.//td → 查找所有td标签
cells = row.xpath('.//td')
if not cells:
return {}
# 提取文本内容
# 解释:itertext() 会递归获取所有子元素的文本
cell_texts = [''.join(cell.itertext()).strip() for cell in cells]
# 根据列索引提取数据
company_data = {}
for key, idx in col_indices.items():
if idx < len(cell_texts):
company_data[key] = cell_texts[idx]
else:
company_data[key] = ""
# 如果没有找到列索引,尝试按顺序猜测
# 解释:兜底策略,假设前几列通常是:序号、名称、行业、展位
if not col_indices:
if len(cell_texts) >= 4:
company_data = {
'company_name': cell_texts[1] if len(cell_texts) > 1 else "",
'industry': cell_texts[2] if len(cell_texts) > 2 else "",
'booth': cell_texts[3] if len(cell_texts) > 3 else "",
'positions': cell_texts[4] if len(cell_texts) > 4 else ""
}
return company_data
@staticmethod
def parse_pagination(html: str) -> Dict:
"""
解析分页信息
Args:
html: 页面HTML
Returns:
分页信息 {'current_page': 1, 'total_pages': 10, 'has_next': True}
"""
tree = etree.HTML(html)
pagination_info = {
'current_page': 1,
'total_pages': 1,
'has_next': False
}
# 方式1:从分页控件提取
# 示例结构:
# <div class="pagination">
# <span class="current">1</span>
# <a href="?page=2">2</a>
# <a href="?page=3">3</a>
# <span class="total">共10页</span>
# </div>
# 当前页
current = tree.xpath('//span[@class="current"]/text()')
if current:
try:
pagination_info['current_page'] = int(current[0])
except:
pass
# 总页数
total_text = tree.xpath('//span[contains(@class, "total")]//text()')
if total_text:
total_text = ''.join(total_text)
# 提取数字:如"共10页" → 10
match = re.search(r'(\d+)', total_text)
if match:
pagination_info['total_pages'] = int(match.group(1))
# 是否有下一页
next_link = tree.xpath('//a[contains(text(), "下一页") or contains(text(), "next")]')
pagination_info['has_next'] = len(next_link) > 0
return pagination_info
@staticmethod
def _extract_text(element, xpath: str) -> str:
"""
提取文本内容(辅助函数)
Args:
element: lxml Element或tree
xpath: XPath表达式
Returns:
提取的文本
"""
try:
result = element.xpath(xpath)
if result:
# 如果是列表,合并所有文本
if isinstance(result, list):
text = ''.join(str(r) for r in result)
else:
text = str(result)
return text.strip()
except:
pass
return ""
# ========== 使用示例 ==========
if __name__ == "__main__":
# 假设已经获取了HTML
with open('test_data/fair_list.html', 'r', encoding='utf-8') as f:
html = f.read()
parser = JobFairParser()
# 解析招聘会列表
fairs = parser.parse_fair_list(html)
print(f"解析到 {len(fairs)} 个招聘会")
if fairs:
print(f"\n第一个招聘会:")
for key, value in fairs[0].items():
print(f" {key}: {value}")
🔟 运行方式与结果展示(必写)
主程序(main.py)
这是整个系统的调度中心,负责将之前的各个模块(请求、解析、清洗、存储)串联起来。
python
"""
招聘会数据采集 - 主程序
功能:
1. 遍历招聘会列表页
2. 进入每个招聘会详情页
3. 提取企业名单并清洗
4. 保存到数据库并导出Excel
5. 生成可视化分析报告(可选)
作者:YourName
日期:2025-01-27
"""
import logging
from pathlib import Path
from fetcher import JobFairFetcher
from parser import JobFairParser
from cleaner import CompanyCleaner
from storage import JobFairStorage
import time
from datetime import datetime
# 配置日志
Path("logs").mkdir(exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(name)s - %(message)s',
handlers=[
logging.FileHandler('logs/crawler.log', encoding='utf-8'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
class JobFairCrawler:
"""招聘会爬虫主控制器"""
def __init__(self):
"""初始化各个组件"""
self.fetcher = JobFairFetcher()
self.parser = JobFairParser()
self.cleaner = CompanyCleaner()
self.storage = JobFairStorage()
# 统计信息
self.stats = {
'start_time': datetime.now(),
'total_fairs': 0,
'total_companies': 0,
'success': 0,
'failed': 0
}
def crawl_site(self, base_url: str, list_url: str, max_pages: int = 5):
"""
爬取整个站点的招聘会数据
Args:
base_url: 网站根域名(用于拼接相对路径)
list_url: 招聘会列表页URL
max_pages: 最多爬取多少页列表
"""
logger.info(f"🚀 开始采集招聘会数据: {list_url}")
for page in range(1, max_pages + 1):
logger.info(f"\n📄 正在处理列表页: 第 {page} 页...")
# 构造分页URL(根据实际网站规则调整)
# 示例:?page=1, ?page=2
current_list_url = f"{list_url}?page={page}"
html = self.fetcher.fetch(current_list_url)
if not html:
logger.error("❌ 无法获取列表页,跳过")
continue
# 解析招聘会列表
fairs = self.parser.parse_fair_list(html)
if not fairs:
logger.info("📭 当前页无招聘会数据,停止采集")
break
logger.info(f"📋 发现 {len(fairs)} 场招聘会,开始逐个采集...")
# 遍历每个招聘会
for fair in fairs:
self.crawl_fair_detail(base_url, fair)
# 招聘会之间增加延迟
time.sleep(2)
# 检查是否有下一页
pagination = self.parser.parse_pagination(html)
if not pagination.get('has_next'):
logger.info("📌 已到达列表最后一页")
break
self._print_final_report()
def crawl_fair_detail(self, base_url: str, fair_info: dict):
"""
爬取单个招聘会的详情(企业名单)
Args:
base_url: 网站根域名
fair_info: 招聘会基本信息字典
"""
fair_name = fair_info.get('name', '未知招聘会')
fair_url = fair_info.get('url', '')
# 处理相对路径
if fair_url and not fair_url.startswith('http'):
fair_url = f"{base_url.rstrip('/')}/{fair_url.lstrip('/')}"
logger.info(f" 👉 进入招聘会: {fair_name}")
# 获取详情页HTML
html = self.fetcher.fetch(fair_url)
if not html:
logger.warning(f" ⚠️ 无法获取详情页: {fair_url}")
return
# 解析企业列表
companies = self.parser.parse_company_table(html)
if not companies:
logger.warning(" 📭 未找到参展企业名单")
return
logger.info(f" 🏢 解析到 {len(companies)} 家企业,开始清洗入库...")
# 批量清洗并保存
saved_count = 0
cleaned_companies = []
for company in companies:
# 数据清洗
cleaned = self.cleaner.clean_company(company)
if not cleaned:
continue
# 补充招聘会信息(外键关联)
cleaned['fair_name'] = fair_name
cleaned['fair_date'] = fair_info.get('date', '')
cleaned['source_url'] = fair_url
cleaned_companies.append(cleaned)
# 保存到数据库
if cleaned_companies:
count = self.storage.save_companies(cleaned_companies)
saved_count = count
self.stats['total_companies'] += count
self.stats['success'] += 1
else:
self.stats['failed'] += 1
self.stats['total_fairs'] += 1
logger.info(f" ✅ 成功入库 {saved_count} 家企业")
def export_data(self):
"""导出数据"""
logger.info("\n📤 开始导出数据...")
try:
# 导出Excel
self.storage.export_to_excel()
# 导出CSV
self.storage.export_to_csv()
logger.info("✅ 数据导出完成!")
except Exception as e:
logger.error(f"❌ 数据导出失败: {str(e)}")
def _print_final_report(self):
"""打印最终报告"""
elapsed = (datetime.now() - self.stats['start_time']).total_seconds()
logger.info(f"""
{'='*60}
========== 🎓 采集任务完成 ==========
📊 统计数据:
- 采集招聘会: {self.stats['total_fairs']} 场
- 采集企业数: {self.stats['total_companies']} 家
- 成功保存场次: {self.stats['success']}
- 失败/空场次: {self.stats['failed']}
⏱️ 耗时: {elapsed/60:.1f} 分钟
📂 结果文件:
- 数据库: data/jobfairs.db
- Excel: data/jobfairs_export.xlsx
{'='*60}
""")
def close(self):
"""关闭连接"""
self.fetcher.close()
self.storage.close()
if __name__ == "__main__":
# 配置目标网站(根据实际情况修改)
BASE_URL = "https://job.example.edu.cn"
LIST_URL = "https://job.example.edu.cn/jobfair/list"
crawler = JobFairCrawler()
try:
# 启动爬虫
crawler.crawl_site(
base_url=BASE_URL,
list_url=LIST_URL,
max_pages=3 # 测试时只爬3页
)
# 导出数据
crawler.export_data()
except KeyboardInterrupt:
logger.info("⚠️ 用户中断程序")
except Exception as e:
logger.error(f"❌ 程序异常退出: {str(e)}")
finally:
crawler.close()
启动方式
-
准备环境:
bash# 创建目录 mkdir -p jobfair_crawler/data jobfair_crawler/logs # 安装依赖 pip install -r requirements.txt -
修改配置 :
打开
main.py,修改BASE_URL和LIST_URL为你实际想爬的目标网站(例如某高校就业网)。 -
运行程序:
bashpython main.py
输出示例
终端日志:
text
2025-01-27 10:00:01 [INFO] 🚀 开始采集招聘会数据: https://job.example.edu.cn/jobfair/list
2025-01-27 10:00:01 [INFO] 📄 正在处理列表页: 第 1 页...
2025-01-27 10:00:02 [INFO] ✅ 成功: https://job.example.edu.cn/jobfair/list?page=1 (45KB)
2025-01-27 10:00:02 [INFO] 📋 发现 10 场招聘会,开始逐个采集...
2025-01-27 10:00:02 [INFO] 👉 进入招聘会: 2025届毕业生春季大型双选会
2025-01-27 10:00:03 [INFO] ✅ 成功: https://job.example.edu.cn/detail?id=1001 (120KB)
2025-01-27 10:00:03 [INFO] 🏢 解析到 250 家企业,开始清洗入库...
2025-01-27 10:00:03 [INFO] ✅ 成功入库 248 家企业
2025-01-27 10:00:05 [INFO] 👉 进入招聘会: 信息技术专场招聘会
...
2025-01-27 10:05:30 [INFO] 📤 开始导出数据...
2025-01-27 10:05:32 [INFO] 📊 Excel导出完成: data/jobfairs_export.xlsx (1250行)
2025-01-27 10:05:32 [INFO] ✅ 数据导出完成!
Excel 文件结构:
| 招聘会名称 | 举办日期 | 企业名称 | 行业分类 | 展位号 | 招聘岗位 | 招聘人数 | 联系方式 |
|---|---|---|---|---|---|---|---|
| 2025春季双选会 | 2025-03-15 | 腾讯科技 | 互联网 | A01 | 后端开发, 产品经理 | 50 | hr@tencent.com |
| 2025春季双选会 | 2025-03-15 | 中建三局 | 建筑/房地产 | B05 | 土木工程师 | 20 | zhaopin@cscec.com |
1️⃣1️⃣ 常见问题与排错(FAQ)
在爬取招聘会网站时,你可能会遇到以下典型问题:
Q1: 表格有合并单元格,数据错位怎么办?
现象:
html
<tr>
<td rowspan="2">互联网行业</td> <!-- 合并单元格 -->
<td>腾讯</td>
<td>A01</td>
</tr>
<tr>
<!-- 这里少了第一列,导致数据错位 -->
<td>阿里</td>
<td>A02</td>
</tr>
解决方案 :
在解析时记录上一行的值。
python
def parse_merged_table(rows):
last_industry = ""
for row in rows:
cells = row.xpath('.//td')
if len(cells) == 3: # 完整行
industry = cells[0].text
company = cells[1].text
last_industry = industry
elif len(cells) == 2: # 被合并行
industry = last_industry # 使用上一行的值
company = cells[0].text
Q2: 翻页是 JavaScript 链接,没有 href 怎么办?
现象 :
<a href="javascript:__doPostBack('Page$2','')">下一页</a>
解决方案 :
这种通常是 ASP.NET 网站。不要尝试执行 JS,而是模拟 POST 请求。
- 打开浏览器开发者工具(F12)-> Network。
- 点击翻页,查看 Network 中的请求。
- 找到 Form Data(表单数据),通常包含
__EVENTTARGET,__EVENTARGUMENT,__VIEWSTATE等隐藏字段。 - 使用
fetcher.fetch_with_post发送包含这些数据的请求。
Q3: 中文显示为乱码()
原因 :
政府/高校老网站常用 GBK 或 GB2312 编码,而 requests 默认推测为 ISO-8859-1。
解决方案:
python
# 手动指定编码
response = requests.get(url)
response.encoding = 'gbk' # 或 'gb18030' (兼容性更好)
html = response.text
Q4: 页面能看到数据,但爬下来是空的
原因 :
数据是通过 Ajax 动态加载的,HTML 源码里只有一个空的 <div id="list"></div>。
解决方案:
- 打开 Network -> XHR/Fetch。
- 刷新页面,寻找返回 JSON 数据的接口(通常叫
getList,query,search)。 - 直接请求这个 API 接口,解析 JSON 数据(比解析 HTML 更简单!)。
1️⃣2️⃣ 进阶优化(Advanced Optimization)
1. 增量更新(避免重复爬取)
招聘会列表是不断更新的,我们需要一种机制只爬新的。
实现思路 :
在数据库中记录已爬取的 URL 的哈希值。
python
def crawl_incremental(self):
# 1. 获取库中已有的URL
existing_urls = self.storage.get_all_urls()
# 2. 爬取列表
for fair in fair_list:
if fair['url'] in existing_urls:
logger.info(f"⏭️ 跳过已爬取: {fair['name']}")
continue # 跳过
# 3. 爬取新详情
self.crawl_fair_detail(fair)
2. 行业分类智能化
原始数据中的行业五花八门(如"IT"、"软体开发"、"互联网+")。为了方便分析,可以建立映射表。
实现思路:
python
INDUSTRY_MAP = {
'计算机': '信息技术',
'软件': '信息技术',
'互联网': '信息技术',
'房地产': '建筑地产',
'土木': '建筑地产',
'银行': '金融',
'证券': '金融'
}
def normalize_industry(raw_industry):
for key, value in INDUSTRY_MAP.items():
if key in raw_industry:
return value
return "其他"
3. 数据可视化分析
爬取数据后,直接生成图表给 HR 或学生看。
python
import matplotlib.pyplot as plt
def plot_industry_distribution(storage):
# 从数据库查询行业统计
data = storage.query("SELECT industry, COUNT(*) FROM companies GROUP BY industry")
# 画饼图
plt.pie([x[1] for x in data], labels=[x[0] for x in data], autopct='%1.1f%%')
plt.title('参展企业行业分布')
plt.savefig('industry_chart.png')
1️⃣3️⃣ 总结与延伸阅读
📚 项目复盘
通过本项目,我们完成了一个从采集到分析的全流程系统:
- Fetcher:解决了 Session 管理、编码识别、频率控制等底层问题。
- Parser:利用 XPath 灵活处理了列表和表格的解析,兼容多种页面结构。
- Cleaner:将杂乱的展位号、行业名称标准化,提升了数据价值。
- Storage:实现了 SQLite 持久化和 Excel 导出,满足了不同场景需求。
这不仅仅是一个爬虫,更是一个就业市场情报系统的原型。
🚀 职业发展建议
如果你想将爬虫作为职业技能:
- 深入学习 JS 逆向:现在的网站越来越动态化,掌握 Chrome 调试、Hook 技术、AST 混淆还原是高薪必备。
- 掌握分布式爬虫:学习 Scrapy-Redis,应对千万级数据量的采集。
- 关注数据合规:了解 GDPR、《数据安全法》,知道什么能爬、什么不能爬,保护自己。
🔗 延伸阅读
- 书籍:《Python3 网络爬虫开发实战》(崔庆才 著)------ 爬虫界的"红宝书"。
- 工具 :Playwright ------ 比 Selenium 更快更现代的自动化工具,适合处理动态网站。
- 数据分析 :Pandas 官方文档 ------ 学会如何清洗和分析爬下来的数据。
附录:完整项目文件结构
为了方便你复制代码,再次梳理文件清单:
config.py: 配置 URL、Headers、数据库路径。fetcher.py: 负责网络请求。parser.py: 负责 HTML 解析。cleaner.py: 负责数据清洗。storage.py: 负责数据库和 Excel 操作。main.py: 程序入口。requirements.txt: 依赖库。
最后的话:
技术本身是中性的,爬虫技术既可以用来恶意抢票,也可以用来聚合信息帮助大学生就业。希望你能善用这项技术,创造真正的社会价值。
🌟 文末
好啦~以上就是本期的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
✅ 专栏持续更新中|建议收藏 + 订阅
墙裂推荐订阅专栏 👉 《Python爬虫实战》,本专栏秉承着以"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一期内容都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴 :强烈建议先订阅专栏 《Python爬虫实战》,再按目录大纲顺序学习,效率十倍上升~

✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】等写成某期实战?
评论区留言告诉我你的需求,我会优先安排实现(更新)哒~
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)
✅ 免责声明
本文爬虫思路、相关技术和代码仅用于学习参考,对阅读本文后的进行爬虫行为的用户本作者不承担任何法律责任。
使用或者参考本项目即表示您已阅读并同意以下条款:
- 合法使用: 不得将本项目用于任何违法、违规或侵犯他人权益的行为,包括但不限于网络攻击、诈骗、绕过身份验证、未经授权的数据抓取等。
- 风险自负: 任何因使用本项目而产生的法律责任、技术风险或经济损失,由使用者自行承担,项目作者不承担任何形式的责任。
- 禁止滥用: 不得将本项目用于违法牟利、黑产活动或其他不当商业用途。
- 使用或者参考本项目即视为同意上述条款,即 "谁使用,谁负责" 。如不同意,请立即停止使用并删除本项目。!!!
