Python爬虫实战:构建全球节假日数据库 - requests+lxml 实战时区节假日网站采集(附CSV导出 + SQLite持久化存储)!

㊙️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!

㊗️爬虫难度指数:⭐⭐

🚫声明:本数据&代码仅供学习交流,严禁用于商业用途、倒卖数据或违反目标站点的服务条款等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议",技术无罪,责任在人。

全文目录:

🌟 开篇语

哈喽,各位小伙伴们你们好呀~我是【喵手】。

运营社区: 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.compublicholidays.cn)采用服务端渲染的静态 HTML,部分网站提供隐藏的 JSON 接口。

选择策略

  1. 优先抓包找 API:打开 Chrome DevTools → Network → XHR,刷新页面查看是否有 JSON 接口
  2. 无 API 则解析 HTML:使用 lxml XPath 提取表格或列表数据
  3. 动态渲染兜底:极少数网站使用 React/Vue,需 Selenium

本文采用 requests + lxml 方案(适用于 90% 的节假日网站)。

整体流程

json 复制代码
[国家列表页] → 解析所有国家链接 → [各国节假日页] → 提取表格数据
↓                                       ↓
获取 200+ 国家                      节日名、日期、类型
↓                                       ↓
数据清洗(日期标准化、去重)  →  存储到 SQLite + 导出 JSON/CSV

核心步骤

  1. 采集国家列表:获取所有可查询的国家/地区及其 URL
  2. 遍历国家页面:依次访问每个国家的节假日页面
  3. 解析表格数据 :从 HTML <table> 中提取日期、节日名等字段
  4. 数据清洗
    • 日期格式统一为 ISO 8601(2025-01-01
    • 节日类型标准化(Public Holiday → 公共假日)
    • 去除 HTML 标签和多余空白
  5. 存储与导出: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-8GBK
  • 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

可能原因

  1. User-Agent 被识别为爬虫
  2. IP 短时间内请求过多
  3. 缺少必需的 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: 无数据

可能原因

  1. 该国家/地区网站未收录
  2. URL 格式不同(如 /holidays/korea-north vs /holidays/kp
  3. 页面结构特殊(需单独处理)

解决方案

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)

在线资源

GitHub 项目参考

反爬虫技术研究

最后的话

爬虫技术的本质,是用代码与互联网对话。每个网站都有自己的"语言"(HTML 结构),而我们要做的就是学会"翻译"这些语言,提取出有价值的信息。

这篇文章从零开始,带你完成了一个真实可用的节假日数据采集系统 。但更重要的是,你学会了一套可复用的爬虫开发方法论

  1. 需求分析:明确要什么数据、用在哪里
  2. 技术选型:根据网站特点选择工具(静态/动态/API)
  3. 分层设计:Fetcher → Parser → Cleaner → Storage
  4. 容错处理:重试、降级、日志、监控
  5. 数据质量:清洗、验证、去重、标准化
  6. 持续迭代:增量更新、性能优化、功能扩展

记住:爬虫不仅仅是写代码,更是对数据、对业务、对用户需求的深刻理解

希望这篇文章不仅教会你如何写爬虫,更重要的是培养你发现问题、分析问题、解决问题的能力。当你面对新的数据源时,能够快速定位关键点、设计合理方案、写出高质量代码------这才是爬虫工程师的核心价值。

如果你在实践中遇到问题,记得:

  • 先看日志(80% 的问题都能从日志找到线索)
  • 保存 HTML(用浏览器检查实际结构)
  • 逐步调试(不要一次写太多代码)
  • 善用搜索(Stack Overflow 是你的好朋友)

最后,请务必遵守法律法规和网站规则,做一个有职业道德的爬虫工程师。技术是中性的,关键在于如何使用。

祝你采集顺利,数据满满!如果这篇文章对你有帮助,欢迎分享给更多人!💪✨

🌟 文末

好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

📌 专栏持续更新中|建议收藏 + 订阅

专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:

✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集

想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?

评论区留言告诉我你的需求,我会优先安排更新 ✅


⭐️ 若喜欢我,就请关注我叭~(更新不迷路)

⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)

⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。

相关推荐
galaxyffang1 小时前
A2A协议的简单应用
python·ai
一晌小贪欢1 小时前
Python在物联网(IoT)中的应用:从边缘计算到云端数据处理
开发语言·人工智能·python·物联网·边缘计算
好家伙VCC2 小时前
# 发散创新:基于Solidity的DeFi协议设计与实现——从原理到实战代码解析在区块链世界中,**DeFi(去中心化金
java·python·去中心化·区块链
墨染青竹梦悠然2 小时前
基于SpringBoot + vue的农产品销售系统(华夏鲜仓)
vue.js·spring boot·python·django·毕业设计·毕设
维度攻城狮2 小时前
Python控制系统仿真案例-RLC电路系统
python·线性代数·矩阵
静谧空间2 小时前
linux安装Squid
linux·运维·爬虫
zhangfeng11332 小时前
GitHub博主hiyouga与LlamaFactory项目研究报告
python·大语言模型
wanderful_2 小时前
自定义用户体系下 Django 业务模块开发踩坑与通用解决方案(技术分享版)
后端·python·django
纯.Pure_Jin(g)2 小时前
【Python练习五】Python 正则与网络爬虫实战:专项练习(2道经典练习带你巩固基础——看完包会)
开发语言·vscode·python