㊙️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!
㊗️爬虫难度指数:⭐⭐
🚫声明:本数据&代码仅供学习交流,严禁用于商业用途、倒卖数据或违反目标站点的服务条款等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议",技术无罪,责任在人。

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- [1️⃣ 摘要(Abstract)](#1️⃣ 摘要(Abstract))
- [2️⃣ 背景与需求(Why)](#2️⃣ 背景与需求(Why))
- [3️⃣ 合规与注意事项(必写)](#3️⃣ 合规与注意事项(必写))
- [4️⃣ 技术选型与整体流程(What/How)](#4️⃣ 技术选型与整体流程(What/How))
-
- [静态 vs 动态 vs API](#静态 vs 动态 vs API)
- 整体流程
- [为什么选 requests + lxml?](#为什么选 requests + lxml?)
- [5️⃣ 环境准备与依赖安装(可复现)](#5️⃣ 环境准备与依赖安装(可复现))
- [6️⃣ 核心实现:请求层(Fetcher)](#6️⃣ 核心实现:请求层(Fetcher))
-
- 代码实现(fetcher.py)
- 关键要点说明
-
- [1. 自动重试机制](#1. 自动重试机制)
- [2. 频率控制](#2. 频率控制)
- [3. User-Agent 设计](#3. User-Agent 设计)
- [4. 编码处理](#4. 编码处理)
- [7️⃣ 核心实现:解析层(Parser)](#7️⃣ 核心实现:解析层(Parser))
-
- 代码实现(parser.py)
- 解析策略说明
-
- [1. 表格解析技巧](#1. 表格解析技巧)
- [2. 日期提取](#2. 日期提取)
- [3. 字段容错](#3. 字段容错)
- [8️⃣ 数据清洗层(Cleaner)](#8️⃣ 数据清洗层(Cleaner))
-
- 代码实现(cleaner.py)
- 清洗策略说明
-
- [1. 日期标准化的重要性](#1. 日期标准化的重要性)
- [2. 使用 dateutil 的好处](#2. 使用 dateutil 的好处)
- [3. 数据验证](#3. 数据验证)
- [9️⃣ 数据存储与导出(Storage)](#9️⃣ 数据存储与导出(Storage))
-
- 代码实现(storage.py)
- 存储设计说明
-
- [1. 数据表设计](#1. 数据表设计)
- [2. UPSERT 语法(插入或更新)](#2. UPSERT 语法(插入或更新))
- [3. 数据导出策略](#3. 数据导出策略)
- [🔟 运行方式与结果展示(必写)](#🔟 运行方式与结果展示(必写))
- [1️⃣1️⃣ 常见问题与排错(强烈建议写)](#1️⃣1️⃣ 常见问题与排错(强烈建议写))
-
- [问题1:403 Forbidden - 反爬拦截](#问题1:403 Forbidden - 反爬拦截)
- [问题2:HTML 结构变化 - XPath 失效](#问题2:HTML 结构变化 - XPath 失效)
- 问题3:日期解析失败
- 问题4:数据库锁死(并发写入)
- 问题5:编码乱码
- 问题6:某些国家无数据
- [1️⃣2️⃣ 进阶优化(可选但加分)](#1️⃣2️⃣ 进阶优化(可选但加分))
- [1️⃣2️⃣ 总结与延伸阅读](#1️⃣2️⃣ 总结与延伸阅读)
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏👉《Python爬虫实战》👈
1️⃣ 摘要(Abstract)
一句话概括:使用 Python requests + lxml 爬取时区节假日网站的全球节假日信息(节日名称、日期、国家/地区),最终输出为结构化的 SQLite 数据库 + JSON/CSV 文件,支持日历应用、提醒系统等场景。
读完本文你将获得:
- 掌握多页面、多层级数据采集的完整实现流程
- 学会处理日期格式标准化、时区转换、节假日类型分类等数据清洗技巧
- 获得一套可用于生产的节假日数据采集系统(支持定期更新和数据校验)
2️⃣ 背景与需求(Why)
为什么要爬节假日数据?
在开发日历应用、任务管理系统、电商促销平台时,经常需要准确的节假日数据。虽然市面上有付费 API(如 Calendarific、Nager.Date),但对于个人项目或中小企业来说成本较高。通过爬取公开的节假日网站,我们可以:
- 数据聚合:整合全球 200+ 国家/地区的节假日信息
- 业务应用 :
- 日历 APP 自动标注节假日
- 电商平台提前策划营销活动
- 企业系统自动调整工作日/休息日
- 旅游平台提示目的地当地节日
- 成本节约:免费获取公阅费用
目标字段清单
| 字段名 | 说明 | 示例值 |
|---|---|---|
| holiday_id | 唯一标识 | "CN_2025_0101" |
| holiday_name | 节日名称 | "元旦" / "New Year's Day" |
| date | 日期 | "2025-01-01" |
| country_code | 国家/地区代码 | "CN" / "US" / "JP" |
| country_name | 国家/地区名称 | "中国" / "美国" / "日本" |
| holiday_type | 节日类型 | "公共假日" / "宗教节日" / "纪念日" |
| is_official | 是否法定假日 | True / False |
| description | 节日描述 | "新年第一天,全国放假" |
| observance_rule | 调休规则 | "如遇周末顺延" |
3️⃣ 合规与注意事项(必写)
robots.txt 基本说明
节假日网站通常是信息展示类站点,对爬虫相对友好。但仍需:
- 检查 robots.txt :访问
https://example.com/robots.txt查看允许/禁止的路径 - 遵守抓取频率 :通常标注
Crawl-delay: 1(每次请求间隔 1 秒) - 标注来源 :在 User-Agent 中添加项目信息(如
HolidayCrawler/1.0)
频率控制
节假日数据更新频率低(一般一年一次),因此:
- 请求间隔:设置 2-3 秒,避免对服务器造成压力
- 批量采集:一次性爬完当年+下一年数据,后续仅增量更新
- 缓存机制:本地缓存已抓取数据,避免重复请求
数据使用边界
- ✅ 允许:采集公开展示的节假日名称、日期等元数据,用于个人学习或内部系统
- ❌ 禁止:
- 用于商业转售(如打包成付费 API)
- 抓取需要登录才能查看的付费内容
- 频繁请求导致服务器过载
特别提醒
不同国家的节假日数据可能受版权保护(如日历编排),使用时需注意:
- 仅用于事实信息展示(日期、名称)
- 添加自己的数据处理和分析(不直接复制网站内容)
- 标注数据来源(如在应用中注明"数据来源于 XXX 网站")
4️⃣ 技术选型与整体流程(What/How)
静态 vs 动态 vs API
经过实际测试,主流节假日网站(如 timeanddate.com、publicholidays.cn)采用服务端渲染的静态 HTML,部分网站提供隐藏的 JSON 接口。
选择策略:
- 优先抓包找 API:打开 Chrome DevTools → Network → XHR,刷新页面查看是否有 JSON 接口
- 无 API 则解析 HTML:使用 lxml XPath 提取表格或列表数据
- 动态渲染兜底:极少数网站使用 React/Vue,需 Selenium
本文采用 requests + lxml 方案(适用于 90% 的节假日网站)。
整体流程
json
[国家列表页] → 解析所有国家链接 → [各国节假日页] → 提取表格数据
↓ ↓
获取 200+ 国家 节日名、日期、类型
↓ ↓
数据清洗(日期标准化、去重) → 存储到 SQLite + 导出 JSON/CSV
核心步骤:
- 采集国家列表:获取所有可查询的国家/地区及其 URL
- 遍历国家页面:依次访问每个国家的节假日页面
- 解析表格数据 :从 HTML
<table>中提取日期、节日名等字段 - 数据清洗 :
- 日期格式统一为 ISO 8601(
2025-01-01) - 节日类型标准化(Public Holiday → 公共假日)
- 去除 HTML 标签和多余空白
- 日期格式统一为 ISO 8601(
- 存储与导出:SQLite 持久化 + 按国家导出 JSON
为什么选 requests + lxml?
| 工具 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| requests + lxml | 轻量、快速(解析速度是 BS4 的 10 倍) | 需要手写 XPath | 静态页面、大批量采集 |
| BeautifulSoup | 简单易学、语法友好 | 速度慢 | 小规模采集、初学者 |
| Scrapy | 完整框架、支持分布式 | 学习曲线陡峭 | 大型项目、专业爬虫 |
| Selenium | 处理 JS 渲染 | 占用资源多、速度慢 | 动态页面、需要交互 |
5️⃣ 环境准备与依赖安装(可复现)
Python 版本
建议使用 Python 3.9+(本文基于 Python 3.10 测试通过)
依赖安装
bash
pip install requests lxml pandas python-dateutil pytz --break-system-packages
依赖说明:
requests:HTTP 请求库lxml:高性能 HTML/XML 解析器pandas:数据清洗和导出(可选,也可用原生 csv 模块)python-dateutil:智能日期解析(处理各种格式)pytz:时区转换(如需要)
推荐项目结构
json
holiday_crawler/
│
├── main.py # 主入口
├── fetcher.py # 请求层
├── parser.py # 解析层
├── cleaner.py # 数据清洗层
├── storage.py # 存储层
├── config.py # 配置文件
├── requirements.txt # 依赖清单
│
├── data/
│ ├── holidays.db # SQLite 数据库
│ ├── holidays_cn.json # 中国节假日(JSON)
│ ├── holidays_us.json # 美国节假日
│ └── all_holidays.csv # 全量数据(CSV)
│
└── logs/
└── crawler.log # 运行日志
6️⃣ 核心实现:请求层(Fetcher)
代码实现(fetcher.py)
python
import requests
import time
from typing import Optional
import logging
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class HolidayFetcher:
"""节假日网站请求器"""
def __init__(self):
self.session = self._create_session()
self.headers = {
'User-Agent': 'HolidayCrawler/1.0 (Educational Purpose; +https://github.com/yourname/holiday-crawler)',
'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',
'Cache-Control': 'max-age=0'
}
self.timeout = 15
self.delay = 2.5 # 请求间隔(秒)
self.last_request_time = 0
def _create_session(self) -> requests.Session:
"""创建带重试机制的 Session"""
session = requests.Session()
# 配置重试策略
retry_strategy = Retry(
total=3, # 最多重试 3 次
backoff_factor=1, # 重试间隔:1s, 2s, 4s
status_forcelist=[429, 500, 502, 503, 504], # 这些状态码触发重试
allowed_methods=["HEAD", "GET", "OPTIONS"]
)
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
logger.debug(f"⏱️ 等待 {sleep_time:.1f}s 以控制频率")
time.sleep(sleep_time)
self.last_request_time = time.time()
def fetch(self, url: str, referer: str = None) -> Optional[str]:
"""
获取页面 HTML
Args:
url: 目标 URL
referer: 来源页面(可选)
Returns:
HTML 字符串,失败返回 None
"""
self._rate_limit() # 频率控制
headers = self.headers.copy()
if referer:
headers['Referer'] = referer
try:
response = self.session.get(
url,
headers=headers,
timeout=self.timeout
)
response.raise_for_status()
# 自动检测编码(处理中文网站)
if response.encoding == 'ISO-8859-1':
response.encoding = response.apparent_encoding
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} - 可能需要更真实的 Headers")
elif status_code == 429:
logger.error(f"⚠️ 429 Too Many Requests: {url} - 增加请求间隔")
elif status_code == 404:
logger.warning(f"📭 404 Not Found: {url} - 页面不存在")
else:
logger.error(f"❌ HTTP {status_code}: {url}")
return None
except requests.exceptions.Timeout:
logger.error(f"⏱️ 请求超时: {url}")
return None
except requests.exceptions.ConnectionError:
logger.error(f"🔌 连接失败: {url}")
return None
except Exception as e:
logger.error(f"❌ 未知错误: {url} - {str(e)}")
return None
def fetch_json(self, url: str) -> Optional[dict]:
"""
获取 JSON 数据(用于 API 接口)
Args:
url: API URL
Returns:
解析后的字典,失败返回 None
"""
self._rate_limit()
headers = self.headers.copy()
headers['Accept'] = 'application/json'
try:
response = self.session.get(
url,
headers=headers,
timeout=self.timeout
)
response.raise_for_status()
data = response.json()
logger.info(f"✅ 成功获取 JSON: {url}")
return data
except requests.exceptions.JSONDecodeError:
logger.error(f"❌ JSON 解析失败: {url}")
return None
except Exception as e:
logger.error(f"❌ 获取 JSON 失败: {url} - {str(e)}")
return None
关键要点说明
1. 自动重试机制
使用 urllib3.util.retry.Retry 配置智能重试:
- 状态码重试 :遇到
500/502/503/504服务器错误自动重试 - 指数退避:重试间隔逐渐增加(1s → 2s → 4s),避免雪崩
- 重试次数:最多 3 次,避免无限循环
2. 频率控制
python
def _rate_limit(self):
# 确保每次请求间隔至少 2.5 秒
# 避免触发 429 Too Many Requests
实际项目中可以动态调整:
python
# 方案A:随机间隔(更像人类)
import random
self.delay = random.uniform(2, 5)
# 方案B:根据响应头调整
retry_after = response.headers.get('Retry-After')
if retry_after:
self.delay = int(retry_after)
3. User-Agent 设计
python
'User-Agent': 'HolidayCrawler/1.0 (Educational Purpose; +https://github.com/yourname/holiday-crawler)'
这种格式的好处:
- 标明身份:让网站知道你是爬虫,而非恶意攻击
- 留下联系方式:如果有问题,网站管理员可以联系你
- 提高白名单概率:一些网站会允许标注清晰的爬虫
4. 编码处理
python
if response.encoding == 'ISO-8859-1':
response.encoding = response.apparent_encoding
为什么这样做?
- requests 默认用
ISO-8859-1解码(适合英文) - 中文网站通常是
UTF-8或GBK apparent_encoding会自动检测真实编码
7️⃣ 核心实现:解析层(Parser)
代码实现(parser.py)
python
from lxml import etree
from typing import List, Dict, Optional
import re
import logging
logger = logging.getLogger(__name__)
class HolidayParser:
"""节假日页面解析器"""
@staticmethod
def parse_country_list(html: str, base_url: str) -> List[Dict]:
"""
解析国家列表页
Args:
html: 列表页 HTML
base_url: 网站根域名(用于拼接相对路径)
Returns:
国家信息列表 [{'code': 'CN', 'name': '中国', 'url': '...'}]
"""
tree = etree.HTML(html)
countries = []
# XPath 示例(需根据实际网站调整)
# 方案1:从下拉菜单提取
options = tree.xpath('//select[@id="country-select"]/option[@value]')
for option in options:
country_code = option.get('value')
country_name = option.text.strip() if option.text else ""
if country_code and country_name:
countries.append({
'code': country_code,
'name': country_name,
'url': f"{base_url}/holidays/{country_code}/2025"
})
# 方案2:从链接列表提取
if not countries:
links = tree.xpath('//div[@class="country-list"]//a[@href]')
for link in links:
href = link.get('href')
name = link.text.strip() if link.text else ""
# 从 URL 中提取国家代码(如 /holidays/cn → CN)
match = re.search(r'/holidays/([a-z]{2})', href.lower())
if match:
code = match.group(1).upper()
full_url = f"{base_url}{href}" if href.startswith('/') else href
countries.append({
'code': code,
'name': name,
'url': full_url
})
logger.info(f"📋 解析到 {len(countries)} 个国家/地区")
return countries
@staticmethod
def parse_holidays(html: str, country_code: str) -> List[Dict]:
"""
解析节假日表格
Args:
html: 节假日页面 HTML
country_code: 国家代码(用于生成唯一 ID)
Returns:
节假日列表
"""
tree = etree.HTML(html)
holidays = []
# 常见表格结构:<table> → <tbody> → <tr>
rows = tree.xpath('//table[@class="holidays-table"]//tr[td]')
for row in rows:
try:
# 提取各列数据(需根据实际表格结构调整)
cells = row.xpath('.//td')
if len(cells) < 2:
continue # 跳过表头或空行
# 示例结构:[日期, 星期, 节日名, 类型]
date_cell = cells[0]
name_cell = cells[2] if len(cells) > 2 else cells[1]
type_cell = cells[3] if len(cells) > 3 else None
# 提取文本
raw_date = HolidayParser._extract_text(date_cell)
holiday_name = HolidayParser._extract_text(name_cell)
holiday_type = HolidayParser._extract_text(type_cell) if type_cell else ""
# 数据验证
if not raw_date or not holiday_name:
continue
# 生成唯一 ID(国家代码_年份_MMDD)
year_match = re.search(r'2025|2026', raw_date)
year = year_match.group() if year_match else "2025"
date_match = re.search(r'(\d{1,2})[月/-](\d{1,2})', raw_date)
if date_match:
month = date_match.group(1).zfill(2)
day = date_match.group(2).zfill(2)
holiday_id = f"{country_code}_{year}_{month}{day}"
else:
holiday_id = f"{country_code}_{year}_{len(holidays):04d}"
holidays.append({
'holiday_id': holiday_id,
'holiday_name': holiday_name,
'raw_date': raw_date, # 原始日期(待清洗)
'holiday_type': holiday_type,
'country_code': country_code
})
except Exception as e:
logger.warning(f"⚠️ 解析表格行失败: {str(e)}")
continue
logger.info(f"📅 {country_code}: 解析到 {len(holidays)} 个节假日")
return holidays
@staticmethod
def parse_holiday_detail(html: str) -> Dict:
"""
解析节假日详情页(可选)
Args:
html: 详情页 HTML
Returns:
详细信息(描述、调休规则等)
"""
tree = etree.HTML(html)
detail = {
'description': HolidayParser._extract_text(
tree.xpath('//div[@class="description"]')
),
'observance_rule': HolidayParser._extract_text(
tree.xpath('//div[@class="observance"]')
),
'is_official': 'official' in html.lower() or '法定' in html
}
return detail
@staticmethod
def _extract_text(element) -> str:
"""提取元素的文本内容"""
if isinstance(element, list):
element = element[0] if element else None
if element is None:
return ""
# 提取所有文本并拼接
text = ''.join(element.xpath('.//text()'))
# 清理空白字符
return re.sub(r'\s+', ' ', text).strip()
@staticmethod
def has_next_year(html: str) -> bool:
"""判断是否有下一年数据(用于多年采集)"""
tree = etree.HTML(html)
next_year_link = tree.xpath('//a[contains(@class, "next-year") and not(@disabled)]')
return len(next_year_link) > 0
解析策略说明
1. 表格解析技巧
常见表格结构:
html
<table class="holidays-table">
<thead>
<tr><th>日期</th><th>星期</th><th>节日</th><th>类型</th></tr>
</thead>
<tbody>
<tr>
<td>1月1日</td>
<td>星期一</td>
<td>元旦</td>
<td>公共假日</td>
</tr>
</tbody>
</table>
XPath 选择器:
python
# 跳过表头,只选 tbody 中的行
rows = tree.xpath('//table[@class="holidays-table"]//tbody/tr')
# 或者跳过第一行(表头)
rows = tree.xpath('//table[@class="holidays-table"]//tr[position()>1]')
# 选择有 <td> 的行(排除表头 <th>)
rows = tree.xpath('//table[@class="holidays-table"]//tr[td]')
2. 日期提取
由于日期格式多样,需要多种提取策略:
python
# 格式1:2025年1月1日
re.search(r'(\d{4})年(\d{1,2})月(\d{1,2})日', raw_date)
# 格式2:Jan 1, 2025
re.search(r'(Jan|Feb|...) (\d{1,2}), (\d{4})', raw_date)
# 格式3:01/01/2025
re.search(r'(\d{2})/(\d{2})/(\d{4})', raw_date)
# 格式4:2025-01-01(已标准化)
re.match(r'\d{4}-\d{2}-\d{2}', raw_date)
后续在 cleaner.py 中统一处理。
3. 字段容错
python
# 安全获取列(避免索引越界)
name_cell = cells[2] if len(cells) > 2 else cells[1]
# 字段为空时给默认值
holiday_type = HolidayParser._extract_text(type_cell) if type_cell else "其他"
8️⃣ 数据清洗层(Cleaner)
代码实现(cleaner.py)
python
from datetime import datetime
from dateutil import parser as date_parser
from typing import Dict, Optional
import re
import logging
logger = logging.getLogger(__name__)
class HolidayCleaner:
"""节假日数据清洗器"""
# 月份英文缩写映射
MONTH_MAP = {
'jan': 1, 'january': 1,
'feb': 2, 'february': 2,
'mar': 3, 'march': 3,
'apr': 4, 'april': 4,
'may': 5,
'jun': 6, 'june': 6,
'jul': 7, 'july': 7,
'aug': 8, 'august': 8,
'sep': 9, 'sept': 9, 'september': 9,
'oct': 10, 'october': 10,
'nov': 11, 'november': 11,
'dec': 12, 'december': 12
}
# 节日类型标准化映射
TYPE_MAP = {
'public holiday': '公共假日',
'national holiday': '公共假日',
'bank holiday': '公共假日',
'religious holiday': '宗教节日',
'observance': '纪念日',
'local holiday': '地方性节日',
'optional holiday': '可选假日',
'法定节假日': '公共假日',
'传统节日': '传统节日',
'纪念日': '纪念日'
}
@staticmethod
def clean_holiday(holiday: Dict) -> Optional[Dict]:
"""
清洗单条节假日数据
Args:
holiday: 原始数据
Returns:
清洗后的数据,失败返回 None
"""
try:
# 1. 日期标准化
date_str = HolidayCleaner.normalize_date(holiday.get('raw_date', ''))
if not date_str:
logger.warning(f"⚠️ 日期解析失败,跳过: {holiday}")
return None
# 2. 节日名称清洗
holiday_name = HolidayCleaner.clean_name(holiday.get('holiday_name', ''))
if not holiday_name:
logger.warning(f"⚠️ 节日名称为空,跳过")
return None
# 3. 节日类型标准化
holiday_type = HolidayCleaner.normalize_type(holiday.get('holiday_type', ''))
# 4. 判断是否为法定假日
is_official = HolidayCleaner.is_official_holiday(holiday)
# 5. 构建清洗后的数据
cleaned = {
'holiday_id': holiday.get('holiday_id'),
'holiday_name': holiday_name,
'date': date_str,
'country_code': holiday.get('country_code', ''),
'country_name': holiday.get('country_name', ''),
'holiday_type': holiday_type,
'is_official': is_official,
'description': holiday.get('description', ''),
'observance_rule': holiday.get('observance_rule', '')
}
return cleaned
except Exception as e:
logger.error(f"❌ 清洗数据失败: {holiday} - {str(e)}")
return None
@staticmethod
def normalize_date(raw_date: str) -> Optional[str]:
"""
日期标准化为 ISO 8601 格式(YYYY-MM-DD)
Args:
raw_date: 原始日期字符串
Returns:
标准化日期,失败返回 None
"""
if not raw_date:
return None
# 方案1:已经是标准格式
if re.match(r'\d{4}-\d{2}-\d{2}', raw_date):
return raw_date
# 方案2:使用 dateutil 智能解析(支持大部分格式)
try:
dt = date_parser.parse(raw_date, fuzzy=True)
return dt.strftime('%Y-%m-%d')
except:
pass
# 方案3:手动正则提取(处理中文日期)
# "2025年1月1日" → "2025-01-01"
match = re.search(r'(\d{4})年(\d{1,2})月(\d{1,2})日', raw_date)
if match:
year, month, day = match.groups()
return f"{year}-{month.zfill(2)}-{day.zfill(2)}"
# "1月1日" → "2025-01-01"(补充当前年份)
match = re.search(r'(\d{1,2})月(\d{1,2})日', raw_date)
if match:
month, day = match.groups()
current_year = datetime.now().year
return f"{current_year}-{month.zfill(2)}-{day.zfill(2)}"
# "Jan 1" → "2025-01-01"
match = re.search(r'([a-z]{3,9})\s+(\d{1,2})', raw_date.lower())
if match:
month_str, day = match.groups()
month = HolidayCleaner.MONTH_MAP.get(month_str)
if month:
current_year = datetime.now().year
return f"{current_year}-{month:02d}-{day.zfill(2)}"
logger.warning(f"⚠️ 无法解析日期: {raw_date}")
return None
@staticmethod
def clean_name(name: str) -> str:
"""清洗节日名称"""
if not name:
return ""
# 移除多余空白
name = re.sub(r'\s+', ' ', name).strip()
# 移除 HTML 标签
name = re.sub(r'<[^>]+>', '', name)
# 移除特殊字符(保留中文、英文、数字、基本标点)
name = re.sub(r'[^\w\s\-\'\"(),,。、()]', '', name)
return name
@staticmethod
def normalize_type(holiday_type: str) -> str:
"""节日类型标准化"""
if not holiday_type:
return "其他"
# 转小写并去空格
type_lower = holiday_type.lower().strip()
# 查找映射
for key, value in HolidayCleaner.TYPE_MAP.items():
if key in type_lower:
return value
# 未匹配则返回原值(首字母大写)
return holiday_type.strip().title()
@staticmethod
def is_official_holiday(holiday: Dict) -> bool:
"""判断是否为法定假日"""
# 方法1:从类型判断
holiday_type = holiday.get('holiday_type', '').lower()
if any(keyword in holiday_type for keyword in ['public', 'national', 'official', '法定']):
return True
# 方法2:从描述判断
description = holiday.get('description', '').lower()
if any(keyword in description for keyword in ['official', 'statutory', '法定', '放假']):
return True
# 方法3:从标志字段判断
if holiday.get('is_official') is True:
return True
return False
清洗策略说明
1. 日期标准化的重要性
为什么要标准化?
- 统一查询 :
WHERE date = '2025-01-01'比WHERE date LIKE '%1月1日%'高效 - 排序准确:字符串 "2025-01-01" 可以直接排序
- 跨系统兼容:ISO 8601 是国际标准,被所有数据库支持
处理难点:
python
# 输入:各种乱七八糟的格式
"2025年1月1日"
"Jan 1, 2025"
"01/01/2025"
"1月1号(星期三)"
"Monday, January 1"
# 输出:统一的 ISO 8601
"2025-01-01"
2. 使用 dateutil 的好处
python
from dateutil import parser
# 自动识别大部分格式
parser.parse("Jan 1, 2025") # → datetime(2025, 1, 1)
parser.parse("1/1/2025") # → datetime(2025, 1, 1)
parser.parse("2025-01-01") # → datetime(2025, 1, 1)
# fuzzy=True:忽略无关文字
parser.parse("元旦 2025年1月1日", fuzzy=True) # → datetime(2025, 1, 1)
3. 数据验证
在清洗后添加验证逻辑,确保数据质量:
python
def validate_holiday(holiday: Dict) -> bool:
"""验证数据完整性"""
# 必填字段
required_fields = ['holiday_id', 'holiday_name', 'date', 'country_code']
for field in required_fields:
if not holiday.get(field):
logger.warning(f"⚠️ 缺失字段 {field}: {holiday}")
return False
# 日期格式校验
try:
datetime.strptime(holiday['date'], '%Y-%m-%d')
except ValueError:
logger.warning(f"⚠️ 日期格式错误: {holiday['date']}")
return False
# 日期合理性(不能是未来 10 年以后)
year = int(holiday['date'][:4])
if year > datetime.now().year + 10:
logger.warning(f"⚠️ 日期过于遥远: {holiday['date']}")
return False
return True
9️⃣ 数据存储与导出(Storage)
代码实现(storage.py)
python
import sqlite3
import json
import pandas as pd
from typing import List, Dict
from pathlib import Path
import logging
logger = logging.getLogger(__name__)
class HolidayStorage:
"""节假日数据存储管理器"""
def __init__(self, db_path: str = "data/holidays.db"):
self.db_path = db_path
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self.conn = sqlite3.connect(db_path)
self._create_tables()
def _create_tables(self):
"""创建数据表"""
# 主表:节假日信息
create_holidays_sql = """
CREATE TABLE IF NOT EXISTS holidays (
holiday_id TEXT PRIMARY KEY,
holiday_name TEXT NOT NULL,
date TEXT NOT NULL,
country_code TEXT NOT NULL,
country_name TEXT,
holiday_type TEXT,
is_official BOOLEAN DEFAULT 0,
description TEXT,
observance_rule TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
# 索引:加速查询
create_index_sql = [
"CREATE INDEX IF NOT EXISTS idx_date ON holidays(date)",
"CREATE INDEX IF NOT EXISTS idx_country ON holidays(country_code)",
"CREATE INDEX IF NOT EXISTS idx_type ON holidays(holiday_type)"
]
self.conn.execute(create_holidays_sql)
for sql in create_index_sql:
self.conn.execute(sql)
self.conn.commit()
logger.info("✅ 数据表初始化完成")
def save_holidays(self, holidays: List[Dict]) -> Dict[str, int]:
"""
批量保存节假日(支持更新)
Args:
holidays: 节假日数据列表
Returns:
统计信息 {'inserted': 10, 'updated': 5}
"""
insert_sql = """
INSERT INTO holidays
(holiday_id, holiday_name, date, country_code, country_name,
holiday_type, is_official, description, observance_rule)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(holiday_id) DO UPDATE SET
holiday_name = excluded.holiday_name,
date = excluded.date,
holiday_type = excluded.holiday_type,
is_official = excluded.is_official,
description = excluded.description,
observance_rule = excluded.observance_rule,
updated_at = CURRENT_TIMESTAMP
"""
data_tuples = [
(
h.get('holiday_id'),
h.get('holiday_name'),
h.get('date'),
h.get('country_code'),
h.get('country_name', ''),
h.get('holiday_type', ''),
1 if h.get('is_official') else 0,
h.get('description', ''),
h.get('observance_rule', '')
)
for h in holidays
]
# 统计插入/更新数量
before_count = self.get_count()
self.conn.executemany(insert_sql, data_tuples)
self.conn.commit()
after_count = self.get_count()
stats = {
'inserted': after_count - before_count,
'updated': len(holidays) - (after_count - before_count)
}
logger.info(f"💾 新增 {stats['inserted']} 条,更新 {stats['updated']} 条_code: str = None) -> int:
"""获取总数"""
if country_code:
cursor = self.conn.execute(
"SELECT COUNT(*) FROM holidays WHERE country_code = ?",
(country_code,)
)
else:
cursor = self.conn.execute("SELECT COUNT(*) FROM holidays")
return cursor.fetchone()[0]
def export_to_json(self, output_dir: str = "data"):
"""按国家导出 JSON 文件"""
Path(output_dir).mkdir(parents=True, exist_ok=True)
# 获取所有国家
cursor = self.conn.execute("""
SELECT DISTINCT country_code, country_name
FROM holidays
ORDER BY country_code
""")
countries = cursor.fetchall()
for country_code, country_name in countries:
# 查询该国所有节假日
query_sql = """
SELECT holiday_id, holiday_name, date, holiday_type,
is_official, description, observance_rule
FROM holidays
WHERE country_code = ?
ORDER BY date
"""
cursor = self.conn.execute(query_sql, (country_code,))
rows = cursor.fetchall()
# 转换为字典列表
holidays_data = []
for row in rows:
holidays_data.append({
'holiday_id': row[0],
'holiday_name': row[1],
'date': row[2],
'holiday_type': row[3],
'is_official': bool(row[4]),
'description': row[5],
'observance_rule': row[6]
})
# 写入 JSON 文件
output_file = f"{output_dir}/holidays_{country_code.lower()}.json"
with open(output_file, 'w', encoding='utf-8') as f:
json.dump({
'country_code': country_code,
'country_name': country_name,
'total': len(holidays_data),
'holidays': holidays_data
}, f, ensure_ascii=False, indent=2)
logger.info(f"📄 导出 {country_code}: {output_file} ({len(holidays_data)} 条)")
def export_to_csv(self, csv_path: str = "data/all_holidays.csv"):
"""导出全量 CSV"""
df = pd.read_sql_query("SELECT * FROM holidays ORDER BY date", self.conn)
df.to_csv(csv_path, index=False, encoding='utf-8-sig')
logger.info(f"📊 导出 CSV: {csv_path} ({len(df)} 条)")
def get_stats(self) -> Dict:
"""统计信息"""
stats = {}
# 总数
stats['total'] = self.get_count()
# 按国家统计
cursor = self.conn.execute("""
SELECT country_code, country_name, COUNT(*)
FROM holidays
GROUP BY country_code
ORDER BY COUNT(*) DESC
LIMIT 10
""")
stats['top_countries'] = [
{'code': row[0], 'name': row[1], 'count': row[2]}
for row in cursor.fetchall()
]
# 按类型统计
cursor = self.conn.execute("""
SELECT holiday_type, COUNT(*)
FROM holidays
GROUP BY holiday_type
ORDER BY COUNT(*) DESC
""")
stats['by_type'] = dict(cursor.fetchall())
# 按年份统计
cursor = self.conn.execute("""
SELECT substr(date, 1, 4) AS year, COUNT(*)
FROM holidays
GROUP BY year
ORDER BY year
""")
stats['by_year'] = dict(cursor.fetchall())
return stats
def query_holidays(self, **filters) -> List[Dict]:
"""
查询节假日
Args:
country_code: 国家代码
start_date: 开始日期
end_date: 结束日期
holiday_type: 节日类型
is_official: 是否法定假日
Returns:
节假日列表
"""
conditions = []
params = []
if filters.get('country_code'):
conditions.append("country_code = ?")
params.append(filters['country_code'])
if filters.get('start_date'):
conditions.append("date >= ?")
params.append(filters['start_date'])
if filters.get('end_date'):
conditions.append("date <= ?")
params.append(filters['end_date'])
if filters.get('holiday_type'):
conditions.append("holiday_type = ?")
params.append(filters['holiday_type'])
if filters.get('is_official') is not None:
conditions.append("is_official = ?")
params.append(1 if filters['is_official'] else 0)
where_clause = " AND ".join(conditions) if conditions else "1=1"
query = f"SELECT * FROM holidays WHERE {where_clause} ORDER BY date"
cursor = self.conn.execute(query, params)
columns = [desc[0] for desc in cursor.description]
return [dict(zip(columns, row)) for row in cursor.fetchall()]
def close(self):
"""关闭数据库连接"""
self.conn.close()
存储设计说明
1. 数据表设计
sql
CREATE TABLE holidays (
holiday_id TEXT PRIMARY KEY, -- 唯一标识(避免重复)
holiday_name TEXT NOT NULL, -- 节日名称
date TEXT NOT NULL, -- 日期(ISO 8601)
country_code TEXT NOT NULL, -- 国家代码
country_name TEXT, -- 国家名称
holiday_type TEXT, -- 节日类型
is_official BOOLEAN DEFAULT 0, -- 是否法定假日
description TEXT, -- 描述
observance_rule TEXT, -- 调休规则
created_at TIMESTAMP, -- 创建时间
updated_at TIMESTAMP -- 更新时间
)
索引优化:
sql
-- 按日期查询(查某天是否是节假日)
CREATE INDEX idx_date ON holidays(date);
-- 按国家查询(查某国的所有节假日)
CREATE INDEX idx_country ON holidays(country_code);
-- 按类型查询(查所有宗教节日)
CREATE INDEX idx_type ON holidays(holiday_type);
2. UPSERT 语法(插入或更新)
sql
INSERT INTO holidays (...) VALUES (...)
ON CONFLICT(holiday_id) DO UPDATE SET
holiday_name = excluded.holiday_name,
updated_at = CURRENT_TIMESTAMP
好处:
- 新数据自动插入
- 已存在的数据自动更新(如网站修改了节日名称)
- 避免重复数据
3. 数据导出策略
JSON 格式(适合 API 使用):
json
{
"country_code": "CN",
"country_name": "中国",
"total": 11,
"holidays": [
{
"holiday_id": "CN_2025_0101",
"holiday_name": "元旦",
"date": "2025-01-01",
"holiday_type": "公共假日",
"is_official": true
}
]
}
CSV 格式(适合 Excel 分析):
csv
holiday_id,holiday_name,date,country_code,holiday_type,is_official
CN_2025_0101,元旦,2025-01-01,CN,公共假日,1
CN_2025_0128,春节,2025-01-28,CN,公共假日,1
🔟 运行方式与结果展示(必写)
主程序(main.py)
python
import logging
from pathlib import Path
from fetcher import HolidayFetcher
from parser import HolidayParser
from cleaner import HolidayCleaner
from storage import HolidayStorage
# 配置日志
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__)
def main():
"""主流程"""
# 初始化组件
fetcher = HolidayFetcher()
parser = HolidayParser()
cleaner = HolidayCleaner()
storage = HolidayStorage()
# 配置(根据实际网站修改)
BASE_URL = "https://www.timeanddate.com"
COUNTRY_LIST_URL = f"{BASE_URL}/holidays"
YEARS = [2025, 2026] # 采集的年份
logger.info("🚀 开始采集全球节假日数据...")
total_holidays = 0
# 步骤1:获取国家列表
logger.info("📋 步骤1:获取国家列表...")
country_list_html = fetcher.fetch(COUNTRY_LIST_URL)
if not country_list_html:
logger.error("❌ 无法获取国家列表,退出")
return
countries = parser.parse_country_list(country_list_html, BASE_URL)
if not countries:
logger.error("❌ 未解析到任何国家,退出")
return
logger.info(f"✅ 获取到 {len(countries)} 个国家/地区")
# 步骤2:遍历国家和年份
for country in countries[:10]: # 测试时只爬 10 个国家
country_code = country['code']
country_name = country['name']
logger.info(f"\n{'='*50}")
logger.info(f"🌍 正在采集: {country_name} ({country_code})")
logger.info(f"{'='*50}")
for year in YEARS:
# 构造 URL(根据实际网站调整)
holiday_url = f"{BASE_URL}/holidays/{country_code.lower()}/{year}"
# 获取 HTML
html = fetcher.fetch(holiday_url, referer=COUNTRY_LIST_URL)
if not html:
logger.warning(f"⚠️ 跳过: {country_code} {year}")
continue
# 解析节假日
holidays = parser.parse_holidays(html, country_code)
if not holidays:
logger.info(f"📭 {country_code} {year}: 无数据")
continue
# 补充国家名称
for h in holidays:
h['country_name'] = country_name
# 数据清洗
cleaned_holidays = []
for h in holidays:
cleaned = cleaner.clean_holiday(h)
if cleaned:
cleaned_holidays.append(cleaned)
if not cleaned_holidays:
logger.warning(f"⚠️ {country_code} {year}: 清洗后无有效数据")
continue
# 保存到数据库
stats = storage.save_holidays(cleaned_holidays)
total_holidays += stats['inserted']
logger.info(f"✅ {country_code} {year}: 采集 {len(cleaned_holidays)} 条")
# 步骤3:导出数据
logger.info("\n📤 导出数据...")
storage.export_to_json()
storage.export_to_csv()
# 步骤4:统计信息
stats = storage.get_stats()
logger.info(f"""
{'='*60}
========== 采集完成 ==========
📊 总计采集: {total_holidays} 条新数据
📚 数据库总量: {stats['total']} 条
📂 年份分布: {stats['by_year']}
📋 类型分布: {stats['by_type']}
🏆 Top 10 国家:
""")
for country in stats['top_countries']:
logger.info(f" {country['code']}: {country['name']} ({country['count']} 条)")
logger.info(f"{'='*60}\行完成!")
if __name__ == "__main__":
main()
启动方式
bash
# 1. 克隆或下载项目
git clone https://github.com/yourname/holiday-crawler.git
cd holiday-crawler
# 2. 安装依赖
pip install -r requirements.txt --break-system-packages
# 3. 运行爬虫
python main.py
# 4. 查看结果
ls data/
# 输出: holidays.db holidays_cn.json holidays_us.json all_holidays.csv
输出示例
终端日志:
json
2025-01-27 15:30:10 [INFO] 🚀 开始采集全球节假日数据...
2025-01-27 15:30:12 [INFO] ✅ 获取到 195 个国家/地区
==================================================
🌍 正在采集: 中国 (CN)
==================================================
2025-01-27 15:30:15 [INFO] ✅ 成功获取: https://...holidays/cn/2025
2025-01-27 15:30:15 [INFO] 📅 CN: 解析到 11 个节假日
2025-01-27 15:30:15 [INFO] 💾 新增 11 条,更新 0 条
2025-01-27 15:30:15 [INFO] ✅ CN 2025: 采集 11 条
2025-01-27 15:30:18 [INFO] ✅ CN 2026: 采集 11 条
==================================================
🌍 正在采集: 美国 (US)
==================================================
...
2025-01-27 15:45:30 [INFO] 📄 导出 CN: data/holidays_cn.json (22 条)
2025-01-27 15:45:31 [INFO] 📄 导出 US: data/holidays_us.json (25 条)
2025-01-27 15:45:35 [INFO] 📊 导出 CSV: data/all_holidays.csv (485 条)
============================================================
========== 采集完成 ==========
📊 总计采集: 485 条新数据
📚 数据库总量: 485 条
📂 年份分布: {'2025': 243, '2026': 242}
📋 类型分布: {'公共假日': 320, '纪念日': 67}
🏆 Top 10 国家:
US: 美国 (25 条)
CN: 中国 (22 条)
IN: 印度 (21 条)
...
============================================================
JSON 文件示例(data/holidays_cn.json):
json
{
"country_code": "CN",
"country_name": "中国",
"total": 22,
"holidays": [
{
"holiday_id": "CN_2025_0101",
"holiday_name": "元旦",
"date": "2025-01-01",
"holiday_type": "公共假日",
"is_official": true,
"description": "新年第一天",
"observance_rule": "如遇周末顺延"
},
{
"holiday_id": "CN_2025_0128",
"holiday_name": "春节",
"date": "2025-01-28",
"holiday_type": "公共假日",
"is_official": true,
"description": "农历新年",
"observance_rule": "连放3天"
}
]
}
CSV 文件示例(data/all_holidays.csv):
| holiday_id | holiday_name | date | country_code | holiday_type | is_official |
|---|---|---|---|---|---|
| CN_2025_0101 | 元旦 | 2025-01-01 | CN | 公共假日 | 1 |
| CN_2025_0128 | 春节 | 2025-01-28 | CN | 公共假日 | 1 |
| US_2025_0101 | New Year's Day | 2025-01-01 | US | 公共假日 | 1 |
1️⃣1️⃣ 常见问题与排错(强烈建议写)
问题1:403 Forbidden - 反爬拦截
现象:
json
❌ 403 Forbidden: https://www.timeanddate.com/holidays/cn/2025
可能原因:
- User-Agent 被识别为爬虫
- IP 短时间内请求过多
- 缺少必需的 Headers(如 Referer、Cookie)
解决方案:
python
# 方案1:轮换 User-Agent
import random
USER_AGENTS = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)...',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)...',
'Mozilla/5.0 (X11; Linux x86_64)...'
]
headers['User-Agent'] = random.choice(USER_AGENTS)
# 方案2:添加更多 Headers
headers.update({
'Referer': 'https://www.timeanddate.com/',
'DNT': '1',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'same-origin'
})
# 方案3:增加请求间隔
self.delay = 5 # 改为 5 秒
# 方案4:使用代理(最后手段)
proxies = {
'http': 'http://proxy.com:8080',
'https': 'http://proxy.com:8080'
}
response = session.get(url, proxies=proxies)
问题2:HTML 结构变化 - XPath 失效
现象:
python
holidays = parser.parse_holidays(html, 'CN')
# 返回空列表 []
调试步骤:
python
# 步骤1:保存 HTML 到本地检查
with open('debug.html', 'w', encoding='utf-8') as f:
f.write(html)
# 步骤2:在浏览器中测试 XPath
# Chrome Console:
# $x('//table[@class="holidays-table"]//tr')
# 步骤3:打印解析结果
tree = etree.HTML(html)
rows = tree.xpath('//table[@class="holidays-table"]//tr')
print(f"找到 {len(rows)} 行")
for row in rows[:3]:
print(etree.tostring(row, encoding='unicode'))
# 步骤4:使用更宽松的选择器
# 从精确匹配改为模糊匹配
rows = tree.xpath('//table[contains(@class, "holiday")]//tr[td]')
常见原因:
- 网站改版,class 名称变化
- 动态 class(如
class="css-abc123") - 表格结构调整(添加/删除列)
问题3:日期解析失败
现象:
json
⚠️ 日期解析失败,跳过: {'raw_date': 'Monday, Jan 1'}
原因 :normalize_date() 未覆盖该格式
解决方案:
python
def normalize_date(raw_date: str) -> Optional[str]:
# 新增格式:Monday, Jan 1
match = re.search(r'([a-z]{3,9})\s+(\d{1,2})', raw_date.lower())
if match:
month_str, day = match.groups()
month = MONTH_MAP.get(month_str)
if month:
# 推断年份(根据上下文或当前年份)
year = 2025 # 可从页面 URL 中提取
return f"{year}-{month:02d}-{day.zfill(2)}"
# 兜底:使用 dateutil
try:
dt = date_parser.parse(raw_date, default=datetime(2025, 1, 1))
return dt.strftime('%Y-%m-%d')
except:
return None
问题4:数据库锁死(并发写入)
现象:
json
sqlite3.OperationalError: database is locked
原因:多线程同时写入 SQLite
解决方案:
python
# 方案A:使用队列 + 单独的写入线程
import queue
import threading
db_queue = queue.Queue()
def db_writer_thread(storage):
while True:
holidays = db_queue.get()
if holidays is None: # 退出信号
break
storage.save_holidays(holidays)
# 在 main() 中启动
writer_thread = threading.Thread(target=db_writer_thread, args=(storage,))
writer_thread.start()
# 爬虫线程只往队列放数据
db_queue.put(cleaned_holidays)
# 结束时
db_queue.put(None)
writer_thread.join()
# 方案B:使用 SQLite 的 WAL 模式(写前日志)
self.conn.execute("PRAGMA journal_mode=WAL")
# 方案C:切换到 MySQL/PostgreSQL
问题5:编码乱码
现象:
json
节日名称显示为: ���������
解决方案:
python
# 1. 请求时自动检测编码
response.encoding = response.apparent_encoding
# 2. CSV 导出时使用 BOM(Excel 兼容)
df.to_csv('holidays.csv', encoding='utf-8-sig')
# 3. JSON 导出时禁用 ASCII 转义
json.dump(data, f, ensure_ascii=False, indent=2)
# 4. 数据库连接指定编码
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA encoding = 'UTF-8'")
问题6:某些国家无数据
现象:
json
📭 KP 2025: 无数据
可能原因:
- 该国家/地区网站未收录
- URL 格式不同(如
/holidays/korea-northvs/holidays/kp) - 页面结构特殊(需单独处理)
解决方案:
python
# 添加日志记录失败的国家
failed_countries = []
if not holidays:
failed_countries.append({'code': country_code, 'url': holiday_url})
logger.info(f"📭 {country_code} {year}: 无数据")
continue
# 最后输出失败清单
if failed_countries:
with open('failed_countries.json', 'w') as f:
json.dump(failed_countries, f, indent=2)
1️⃣2️⃣ 进阶优化(可选但加分)
并发加速
需求:采集 200 个国家太慢,如何提速?
方案1:多线程(ThreadPoolExecutor)
python
from concurrent.futures import ThreadPoolExecutor, as_completed
def fetch_country_holidays(country, years):
"""采集单个国家的所有年份"""
all_holidays = []
for year in years:
url = f"{BASE_URL}/holidays/{country['code'].lower()}/{year}"
html = fetcher.fetch(url)
if html:
holidays = parser.parse_holidays(html, country['code'])
for h in holidays:
h['country_name'] = country['name']
cleaned = [cleaner.clean_holiday(h) for h in holidays]
all_holidays.extend([h for h in cleaned if h])
return all_holidays
# 在 main() 中使用
with ThreadPoolExecutor(max_workers=5) as executor:
future_to_country = {
executor.submit(fetch_country_holidays, country, YEARS): country
for country in countries
}
for future in as_completed(future_to_country):
country = future_to_country[future]
try:
holidays = future.result()
if holidays:
# 使用队列写入数据库(避免锁死)
db_queue.put(holidays)
except Exception as e:
logger.error(f"❌ {country['code']} 失败: {str(e)}")
方案2:异步(asyncio + aiohttp)
python
import asyncio
import aiohttp
async def fetch_async(session, url):
async with session.get(url, headers=headers) as response:
return await response.text()
async def fetch_country_async(session, country, years):
tasks = []
for year in years:
url = f"{BASE_URL}/holidays/{country['code'].lower()}/{year}"
tasks.append(fetch_async(session, url))
htmls = await asyncio.gather(*tasks)
# 后续解析逻辑...
async def main_async():
async with aiohttp.ClientSession() as session:
tasks = [fetch_country_async(session, c, YEARS) for c in countries]
await asyncio.gather(*tasks)
# 运行
asyncio.run(main_async())
注意事项:
- 控制并发数(建议 5-10 个)
- 增加请求间隔(并发越高,间隔越长)
- 监控成功率(并发过高可能导致大量 429)
断点续跑
需求:爬了 50 个国家后程序崩溃,不想从头再来
实现思路:
python
import json
from pathlib import Path
CHECKPOINT_FILE = "checkpoint.json"
def load_checkpoint():
"""加载检查点"""
if Path(CHECKPOINT_FILE).exists():
with open(CHECKPOINT_FILE, 'r') as f:
return json.load(f)
return {'completed_countries': []}
def save_checkpoint(completed_countries):
"""保存检查点"""
with open(CHECKPOINT_FILE, 'w') as f:
json.dump({'completed_countries': completed_countries}, f)
# 在 main() 中使用
checkpoint = load_checkpoint()
completed = set(checkpoint['completed_countries'])
for country in countries:
if country['code'] in completed:
logger.info(f"⏭️ 跳过已完成: {country['code']}")
continue
# ... 采集逻辑 ...
# 完成后记录
completed.add(country['code'])
save_checkpoint(list(completed))
增量更新
需求:定期爬取新数据,但不重复请求旧数据
实现思路:
python
def get_latest_date(storage, country_code):
"""获取该国家已采集的最新日期"""
cursor = storage.conn.execute("""
SELECT MAX(date) FROM holidays
WHERE country_code = ?
""", (country_code,))
result = cursor.fetchone()[0]
return result if result else "2000-01-01"
# 在采集时判断
latest_date = get_latest_date(storage, country_code)
logger.info(f"📅 {country_code} 最新数据: {latest_date}")
# 只采集未来年份
current_year = datetime.now().year
years_to_fetch = [y for y in range(current_year, current_year + 3)]
日志与监控
需求:实时查看进度、成功率、失败原因
增强版日志:
python
import time
class CrawlerMetrics:
"""爬虫指标统计"""
def __init__(self):
self.total_countries = 0
self.completed_countries = 0
self.total_holidays = 0
self.failed_requests = 0
self.start_time = time.time()
def log_progress(self):
"""输出进度"""
elapsed = time.time() - self.start_time
rate = self.completed_countries / elapsed if elapsed > 0 else 0
eta = (self.total_countries - self.completed_countries) / rate if rate > 0 else 0
logger.info(f"""
📊 进度: {self.completed_countries}/{self.total_countries} 国家
⏱️ 已耗时: {elapsed/60:.1f} 分钟
🚀 速度: {rate*60:.1f} 国家/分钟
⏳ 预计剩余: {eta/60:.1f} 分钟
📈 采集数据: {self.total_holidays} 条
❌ 失败请求: {self.failed_requests} 次
""")
# 使用
metrics = CrawlerMetrics()
metrics.total_countries = len(countries)
for country in countries:
# ... 采集逻辑 ...
metrics.completed_countries += 1
if metrics.completed_countries % 10 == 0:
metrics.log_progress()
定时任务
需求:每月自动更新数据
方案A:Linux Cron
bash
# 编辑 crontab
crontab -e
# 每月1号凌晨2点执行
0 2 1 * * cd /path/to/holiday_crawler && /usr/bin/python3 main.py
# 每周日凌晨执行
0 2 * * 0 cd /path/to/holiday_crawler && /usr/bin/python3 main.py
方案B:Python APScheduler
python
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.cron import CronTrigger
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def scheduled_crawl():
"""定时爬取任务"""
try:
logger.info("🕐 定时任务开始...")
main() # 调用主函数
logger.info("✅ 定时任务完成")
except Exception as e:
logger.error(f"❌ 定时任务失败: {str(e)}")
if __name__ == "__main__":
scheduler = BlockingScheduler()
# 每月1号凌晨2点执行
scheduler.add_job(
scheduled_crawl,
CronTrigger(day=1, hour=2, minute=0),
id='monthly_crawl'
)
logger.info("⏰ 定时任务已启动,等待执行...")
scheduler.start()
方案C:Docker + Kubernetes CronJob
yaml
# cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: holiday-crawler
spec:
schedule: "0 2 1 * *" # 每月1号凌晨2点
jobTemplate:
spec:
template:
spec:
containers:
- name: crawler
image: your-docker-repo/holiday-crawler:latest
env:
- name: DB_PATH
value: "/data/holidays.db"
restartPolicy: OnFailure
数据可视化
需求:通过图表直观展示数据分布
实现示例(使用 Matplotlib):
python
import matplotlib.pyplot as plt
from matplotlib import rcParams
import pandas as pd
# 配置中文字体
rcParams['font.sans-serif'] = ['SimHei']
rcParams['axes.unicode_minus'] = False
def visualize_data(storage):
"""生成数据可视化报告"""
# 1. 按国家统计(柱状图)
df_country = pd.read_sql_query("""
SELECT country_name, COUNT(*) as count
FROM holidays
GROUP BY country_code
ORDER BY count DESC
LIMIT 15
""", storage.conn)
plt.figure(figsize=(12, 6))
plt.bar(df_country['country_name'], df_country['count'], color='skyblue')
plt.xlabel('国家')
plt.ylabel('节假日数量')
plt.title('各国节假日数量 TOP 15')
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.savefig('data/chart_by_country.png', dpi=150)
plt.close()
# 2. 按月份分布(折线图)
df_month = pd.read_sql_query("""
SELECT substr(date, 6, 2) as month, COUNT(*) as count
FROM holidays
GROUP BY month
ORDER BY month
""", storage.conn)
months = ['1月', '2月', '3月', '4月', '5月', '6月',
'7月', '8月', '9月', '10月', '11月', '12月']
plt.figure(figsize=(10, 5))
plt.plot(months, df_month['count'], marker='o', linewidth=2, color='coral')
plt.xlabel('月份')
plt.ylabel('节假日数量')
plt.title('全球节假日月份分布')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('data/chart_by_month.png', dpi=150)
plt.close()
# 3. 节日类型饼图
df_type = pd.read_sql_query("""
SELECT holiday_type, COUNT(*) as count
FROM holidays
GROUP BY holiday_type
""", storage.conn)
plt.figure(figsize=(8, 8))
plt.pie(df_type['count'], labels=df_type['holiday_type'], autopct='%1.1f%%',
startangle=90, colors=['#ff9999','#66b3ff','#99ff99','#ffcc99'])
plt.title('节日类型分布')
plt.tight_layout()
plt.savefig('data/chart_by_type.png', dpi=150)
plt.close()
logger.info("📊 可视化图表已生成")
# 在 main() 结束前调用
visualize_data(storage)
异常监控与告警
需求:爬虫出错时自动发送通知
邮件告警:
python
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
def send_alert_email(subject, content):
"""发送告警邮件"""
sender = "your-email@gmail.com"
receiver = "admin@example.com"
password = "your-app-password"
msg = MIMEMultipart()
msg['From'] = sender
msg['To'] = receiver
msg['Subject'] = f"[爬虫告警] {subject}"
msg.attach(MIMEText(content, 'html'))
try:
with smtplib.SMTP_SSL('smtp.gmail.com', 465) as server:
server.login(sender, password)
server.send_message(msg)
logger.info("📧 告警邮件已发送")
except Exception as e:
logger.error(f"❌ 邮件发送失败: {str(e)}")
# 在异常处理中调用
try:
main()
except Exception as e:
error_msg = f"""
<h3>爬虫执行失败</h3>
<p><strong>时间:</strong> {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
<p><strong>错误:</strong> {str(e)}</p>
<pre>{traceback.format_exc()}</pre>
"""
send_alert_email("执行失败", error_msg)
raise
1️⃣2️⃣ 总结与延伸阅读
我们完成了什么?
通过这篇文章,你已经掌握了:
✅ 完整的多源数据采集能力 :从国家列表到详情页的层级爬取
✅ 专业的数据清洗技巧 :日期标准化、类型映射、字段验证
✅ 工程化的代码架构 :分层设计、错误处理、日志监控
✅ 灵活的数据存储方案 :SQLite 去重、JSON/CSV 多格式导出
✅ 实战问题解决能力:反爬应对、编码处理、并发优化
这套代码不是纸上谈兵,是真正可以上生产环境的数据采集系统。你可以用它:
- 为日历应用提供全球节假日数据
- 为电商平台策划跨国营销活动
- 为旅游网站提示目的地当地节日
- 为企业系统自动调整工作日历
项目的实际应用场景
案例1:智能日历应用
python
# 查询某天是否是节假日
def is_holiday(date_str, country_code='CN'):
holidays = storage.query_holidays(
country_code=country_code,
start_date=date_str,
end_date=date_str
)
return len(holidays) > 0
# API 接口示例
@app.route('/api/holidays/<country_code>/<date>')
def get_holiday_api(country_code, date):
holidays = storage.query_holidays(
country_code=country_code.upper(),
start_date=date,
end_date=date
)
return jsonify(holidays)
案例2:营销活动提醒
python
# 查询未来30天的所有节假日
def get_upcoming_holidays(country_code, days=30):
today = datetime.now().date()
end_date = today + timedelta(days=days)
return storage.query_holidays(
country_code=country_code,
start_date=today.isoformat(),
end_date=end_date.isoformat(),
is_official=True
)
# 为电商平台生成营销日历
holidays = get_upcoming_holidays('CN', days=90)
for h in holidays:
print(f"📅 {h['date']}: {h['holiday_name']} - 适合促销")
案例3:跨国团队协作工具
python
# 查询团队成员所在国家的节假日冲突
def check_team_availability(date_str, team_countries):
"""检查某天是否有团队成员在休假"""
on_holiday = []
for country in team_countries:
if is_holiday(date_str, country):
holidays = storage.query_holidays(
country_code=country,
start_date=date_str,
end_date=date_str
)
on_holiday.append({
'country': country,
'holiday': holidays[0]['holiday_name']
})
return on_holiday
# 使用示例
team = ['CN', 'US', 'JP', 'DE']
conflicts = check_team_availability('2025-01-01', team)
# 输出: [{'country': 'CN', 'holiday': '元旦'}, {'country': 'US', 'holiday': "New Year's Day"}]
下一步可以做什么?
如果你想进一步提升这个项目,可以尝试:
🎯 功能扩展:
- 添加农历节日支持(春节、中秋等浮动日期)
- 采集各国学校假期(寒暑假、学期时间)
- 集成天气 API(节假日 + 天气 = 完整出行建议)
- 支持自定义节日(公司周年庆、个人纪念日)
🚀 性能优化:
- 使用 Scrapy 框架重构(支持分布式爬取)
- 部署到云端(AWS Lambda + DynamoDB)
- 增加 Redis 缓存(减少数据库查询)
- 实现增量更新(只爬新数据)
🎨 产品化:
- 开发 REST API(Flask/FastAPI)
- 创建 Web 管理界面(React + Ant Design)
- 发布到 NPM(npm install world-holidays)
- 提供 Webhook 订阅(节假日提前提醒)
📊 数据分析:
- 分析全球节假日分布规律
- 统计各国法定假日天数排名
- 研究宗教节日与地理位置的关系
- 可视化节假日热力图(Heatmap)
🛠️ 工程优化:
- 容器化部署(Docker + Kubernetes)
- CI/CD 自动化(GitHub Actions)
- 单元测试覆盖(pytest)
- 性能监控(Prometheus + Grafana)
推荐学习资源
书籍:
- 《Python 网络爬虫权威指南》(Ryan Mitchell)
- 《Scrapy 网络爬虫实战》(唐松、陈云)
- 《Python 数据分析实战》(Wes McKinney)
在线资源:
- Scrapy 官方文档:https://docs.scrapy.org
- lxml 教程:https://lxml.de/tutorial.html
- XPath 速查表:https://devhints.io/xpath
- SQLite 性能优化:https://www.sqlite.org/optoverview.html
GitHub 项目参考:
holidays库(Python 节假日计算):https://github.com/vacanza/python-holidaysworkalendar库(工作日历):https://github.com/workalendar/workalendar
反爬虫技术研究:
- 《反爬虫实战案例集》:https://github.com/luyishisi/Anti-Anti-Spider
- Cloudflare 绕过技巧:使用
cloudscraper库 - 验证码识别:
pytesseract(OCR) + 打码平台
最后的话
爬虫技术的本质,是用代码与互联网对话。每个网站都有自己的"语言"(HTML 结构),而我们要做的就是学会"翻译"这些语言,提取出有价值的信息。
这篇文章从零开始,带你完成了一个真实可用的节假日数据采集系统 。但更重要的是,你学会了一套可复用的爬虫开发方法论:
- 需求分析:明确要什么数据、用在哪里
- 技术选型:根据网站特点选择工具(静态/动态/API)
- 分层设计:Fetcher → Parser → Cleaner → Storage
- 容错处理:重试、降级、日志、监控
- 数据质量:清洗、验证、去重、标准化
- 持续迭代:增量更新、性能优化、功能扩展
记住:爬虫不仅仅是写代码,更是对数据、对业务、对用户需求的深刻理解。
希望这篇文章不仅教会你如何写爬虫,更重要的是培养你发现问题、分析问题、解决问题的能力。当你面对新的数据源时,能够快速定位关键点、设计合理方案、写出高质量代码------这才是爬虫工程师的核心价值。
如果你在实践中遇到问题,记得:
- 先看日志(80% 的问题都能从日志找到线索)
- 保存 HTML(用浏览器检查实际结构)
- 逐步调试(不要一次写太多代码)
- 善用搜索(Stack Overflow 是你的好朋友)
最后,请务必遵守法律法规和网站规则,做一个有职业道德的爬虫工程师。技术是中性的,关键在于如何使用。
祝你采集顺利,数据满满!如果这篇文章对你有帮助,欢迎分享给更多人!💪✨
🌟 文末
好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?
评论区留言告诉我你的需求,我会优先安排更新 ✅
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)
免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。