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

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- [🌟 摘要(Abstract)](#🌟 摘要(Abstract))
- [1️⃣ 背景与需求(Why)](#1️⃣ 背景与需求(Why))
-
- [为什么需要 Sitemap 爬虫?](#为什么需要 Sitemap 爬虫?)
- [Sitemap 的典型应用场景](#Sitemap 的典型应用场景)
- 目标与功能清单
- [2️⃣ Sitemap 协议深度解析(What)](#2️⃣ Sitemap 协议深度解析(What))
-
- [Sitemap 是什么?](#Sitemap 是什么?)
- [Sitemap 的基本结构](#Sitemap 的基本结构)
- [Sitemap 的常见格式](#Sitemap 的常见格式)
- [Sitemap 的发现方法](#Sitemap 的发现方法)
- [Sitemap 的限制与规范](#Sitemap 的限制与规范)
- [3️⃣ 技术选型与架构设计(How)](#3️⃣ 技术选型与架构设计(How))
- [4️⃣ 环境准备与依赖](#4️⃣ 环境准备与依赖)
- [5️⃣ 数据库设计](#5️⃣ 数据库设计)
- [6️⃣ 核心实现:Sitemap 发现器](#6️⃣ 核心实现:Sitemap 发现器)
-
- [HTTP 客户端工具类](#HTTP 客户端工具类)
- [Sitemap 发现器完整代码](#Sitemap 发现器完整代码)
- [7️⃣ 核心实现:Sitemap 解析器](#7️⃣ 核心实现:Sitemap 解析器)
-
- [XML 解析工具类](#XML 解析工具类)
- [Sitemap 解析器完整代码](#Sitemap 解析器完整代码)
- [8️⃣ 核心实现:URL 队列管理](#8️⃣ 核心实现:URL 队列管理)
-
- [URL 优先级计算](#URL 优先级计算)
- [9️⃣ 核心实现:增量更新器](#9️⃣ 核心实现:增量更新器)
- [🔟 完整爬虫流程](#🔟 完整爬虫流程)
- [1️⃣1️⃣ 性能优化与监控](#1️⃣1️⃣ 性能优化与监控)
- [1️⃣2️⃣ 常见问题与排错(FAQ)](#1️⃣2️⃣ 常见问题与排错(FAQ))
-
- [Q1: Sitemap 解析失败怎么办?](#Q1: Sitemap 解析失败怎么办?)
- [Q2: 发现不了 sitemap 怎么办?](#Q2: 发现不了 sitemap 怎么办?)
- [Q3: 循环引用导致死循环](#Q3: 循环引用导致死循环)
- [Q4: URL 数量超出限制](#Q4: URL 数量超出限制)
- [Q5: 内存占用过高](#Q5: 内存占用过高)
- [1️⃣3️⃣ 生产环境实战](#1️⃣3️⃣ 生产环境实战)
-
- [Docker 部署](#Docker 部署)
- 定时任务(Crontab)
- 分布式部署(Celery)
- [1️⃣4️⃣ 总结与延伸](#1️⃣4️⃣ 总结与延伸)
- 附录
-
- [完整的 requirements.txt](#完整的 requirements.txt)
- [数据库初始化 SQL](#数据库初始化 SQL)
- 运行示例
- [🌟 文末](#🌟 文末)
-
- [✅ 专栏持续更新中|建议收藏 + 订阅](#✅ 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
- [✅ 免责声明](#✅ 免责声明)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏👉《Python爬虫实战》👈
💕订阅后更新会优先推送,按目录学习更高效💯~
🌟 摘要(Abstract)
一句话概括:本文构建一个生产级 Sitemap 爬虫系统,能够自动发现网站的所有 sitemap 文件(包括索引文件、嵌套文件、压缩文件),智能解析 XML 结构,提取 URL 并生成优先级队列,最终实现全站自动化抓取。支持增量更新、并发解析、断点续爬,适用于搜索引擎、数据采集、网站监控等场景。
读完你能获得:
- 深入理解 Sitemap 协议规范(sitemap.xml、sitemap index、压缩格式)
- 掌握自动发现 Sitemap 的多种策略(robots.txt、常见路径、HTML 链接)
- 学会解析嵌套 Sitemap 和处理循环引用问题
- 获取完整的 Sitemap 爬虫代码(2000+ 行,包含 URL 优先级队列、增量更新机制)
- 掌握大规模 URL 管理的最佳实践(去重、调度、监控)
1️⃣ 背景与需求(Why)
为什么需要 Sitemap 爬虫?
在实际的数据采集项目中,我们经常遇到这些问题:
问题1:URL 发现困难
json
传统爬虫:从首页开始,逐层抓取链接
├── 首页 → 分类页 → 列表页 → 详情页
├── 问题:深层页面难以发现
├── 问题:JS 渲染的页面无法抓取链接
└── 问题:耗时长(10万个页面可能需要数天)
Sitemap 爬虫:直接获取所有 URL
├── 读取 sitemap.xml
├── 立即获得全站 URL 列表
└── 1小时内完成 10万 URL 的发现
问题2:重复抓取浪费资源
json
传统爬虫:
* 同一个页面可能被抓取多次(从不同路径发现)
* 无法知道哪些页面已更新
Sitemap 爬虫:
* sitemap 提供 lastmod(最后修改时间)
* 可以只抓取更新过% 的带宽和时间
```json
**问题3:无法获取完整站点结构**
传统爬虫:
- 孤立页面(没有内链的页面)无法发现
- 需要登录的页面无法访问
Sitemap:
- 包含所有公开 URL(包括孤立页面)
- 网站主动提供,无需深度遍历
Sitemap 的典型应用场景
场景1:搜索引擎爬虫
- Google、Bing、百度等搜索引擎都优先抓取 sitemap
- 可快速发现新页面和更新的页面
- 提高索引效率
场景2:电商价格监控
python
# 案例:监控京东某类商品价格
1. 获取京东商品 sitemap(包含所有商品 URL)
2. 筛选特定类别(如手机)
3. 定期抓取价格变动
4. 比全站爬虫快 100 倍
场景3:新闻聚合
python
# 案例:聚合多个新闻网站的文章
1. 订阅各大新闻网站的 sitemap
2. 每小时检查更新
3. 只抓取新发布的文章
4. 实时性强、资源消耗低
场景4:网站备份/镜像
python
# 完整备份网站内容
1. 获取 sitemap(所有页面 URL)
2. 批量下载 HTML 和资源文件
3. 保持目录结构
4. 比盲目爬取准确高效
场景5:SEO 审计
python
# 检查网站 SEO 问题
1. 解析 sitemap 获取所有 URL
2. 检查每个页面的 meta 标签
3. 发现重复内容、404 链接
4. 生成 SEO 报告
目标与功能清单
核心功能:
- ✅ 自动发现 sitemap.xml(支持多种策略)
- ✅ 解析 sitemap index(嵌套的 sitemap)
- ✅ 解析压缩格式(.xml.gz)
- ✅ 提取 URL、lastmod、priority 等元数据
- ✅ 去重和循环引用检测
- ✅ 生成优先级抓取队列
- ✅ 支持增量更新(只抓取变化的 URL)
- ✅ 并发解析(提高效率)
- ✅ 断点续爬(中断后可恢复)
扩展功能:
- 🔄 定时监控 sitemap 更新
- 📊 统计分析(URL 数量、域名分布、更新频率)
- 🚨 异常告警(sitemap 不可用、格式错误)
- 💾 历史版本管理(追踪 sitemap 变化)
2️⃣ Sitemap 协议深度解析(What)
Sitemap 是什么?
官方定义:Sitemap 是一个 XML 文件,列出了网站中的 URL 以及每个 URL 的元数据(最后修改时间、更改频率、优先级),帮助搜索引擎更智能地抓取网站。
标准文档:
- 协议规范:https://www.sitemaps.org/protocol.html
- Google 指南:https://developers.google.com/search/docs/crawling-indexing/sitemaps
Sitemap 的基本结构
示例1:普通 sitemap.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://www.example.com/</loc>
<lastmod>2024-03-15</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://www.example.com/about</loc>
<lastmod>2024-03-10</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://www.example.com/products/item1</loc>
<lastmod>2024-03-14</lastmod>
<changefreq>weekly</changefreq>
<priority>0.6</priority>
</url>
</urlset>
字段说明:
| 字段 | 必填 | 说明 | 示例 |
|---|---|---|---|
<loc> |
✅ 是 | 页面 URL | https://example.com/page |
<lastmod> |
❌ 否 | 最后修改时间 | 2024-03-15 或 2024-03-15T10:30:00+00:00 |
<changefreq> |
❌ 否 | 更新频率(提示性) | always/hourly/daily/weekly/monthly/yearly/never |
<priority> |
❌ 否 | 相对优先级(0.0-.8 |
示例2:Sitemap Index(索引文件)
xml
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>https://www.example.com/sitemap-products.xml</loc>
<lastmod>2024-03-15</lastmod>
</sitemap>
<sitemap>
<loc>https://www.example.com/sitemap-articles.xml</loc>
<lastmod>2024-03-14</lastmod>
</sitemap>
<sitemap>
<loc>https://www.example.com/sitemap-news.xml.gz</loc>
<lastmod>2024-03-15</lastmod>
</sitemap>
</sitemapindex>
为什么需要 Sitemap Index?
- Sitemap 单文件限制:最多 50,000 个 URL,文件大小不超过 50MB
- 大型网站(如京东、淘宝)有数百万个 URL,必须拆分成多个文件
- Sitemap Index 作为"目录",指向多个子 sitemap
Sitemap 的常见格式
格式1:未压缩 XML
json
URL: https://example.com/sitemap.xml
格式: XML
大小: 通常几 KB 到几十 MB
格式2:Gzip 压缩
json
URL: https://example.com/sitemap.xml.gz
格式: Gzip 压缩的 XML
大小: 压缩后可减小 90%
格式3:文本格式(非标准但常见)
json
URL: https://example.com/sitemap.txt
格式: 纯文本,每行一个 URL
示例:
https://example.com/page1
https://example.com/page2
https://example.com/page3
格式4:RSS/Atom(新闻网站常用)
xml
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<item>
<link>https://example.com/news/article1</link>
<pubDate>Mon, 15 Mar 2024 10:00:00 GMT</pubDate>
</item>
</channel>
</rss>
Sitemap 的发现方法
方法1:robots.txt(最标准)
json
网站: https://www.example.com/robots.txt
内容:
User-agent: *
Disallow: /admin/
Disallow: /private/
Sitemap: https://www.example.com/sitemap.xml
Sitemap: https://www.example.com/sitemap-news.xml
Sitemap: https://www.example.com/sitemap-products.xml.gz
方法2:常见路径探测
json
优先级从高到低:
1. /sitemap.xml
2. /sitemap_index.xml
3. /sitemap-index.xml
4. /sitemap1.xml
5. /sitemaps/sitemap.xml
6. /sitemap/sitemap.xml
7. /sitemap.xml.gz
8. /sitemap_index.xml.gz
方法3:HTML 页面链接
html
<!-- 有些网站在首页或底部提供 sitemap 链接 -->
<a href="/sitemap.xml">Sitemap</a>
方法4:搜索引擎提交记录
json
Google Search Console 查询:
site:example.com sitemap
或直接访问:
https://www.google.com/ping?sitemap=https://example.com/sitemap.xml
Sitemap 的限制与规范
协议限制:
- 单个 sitemap 最多 50,000 个 URL
- 未压缩文件最大 50MB
- 压缩文件最大 50MB(解压后可超过)
- URL 长度不超过 2,048 字符
- 必须使用 UTF-8 编码
最佳实践:
json
建议拆分策略:
├── sitemap_index.xml (索引)
│ ├── sitemap_category_1.xml (分类1,5万条)
│ ├── sitemap_category_2.xml (分类2,5万条)
│ ├── sitemap_news_2024_03.xml (按时间,当月新闻)
│ └── sitemap_products.xml.gz (商品,压缩)
3️⃣ 技术选型与架构设计(How)
整体架构图
json
┌─────────────────────────────────────────────────────────────┐
│ 阶段1:Sitemap 发现 │
│ Input: 目标域名 │
│ Output: sitemap_urls (所有 sitemap 文件的 URL) │
│ 方法: robots.txt + 常见路径 + HTML 链接 │
└────────────┬────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 阶段2:Sitemap 解析 │
│ Input: sitemap_urls │
│ Output: 所有页面 URL + 元数据 │
│ 特点: 递归解析(处理 sitemap index) │
│ 循环引用检测、压缩格式支持 │
└────────────┬────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 阶段3:URL 队列生成 │
│ Input: 页面 URL + 元数据 │
│ Output: 优先级队列(按 priority、lastmod 排序) │
│ 功能: 去重、过滤、优先级计算 │
└────────────┬────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 阶段4:增量更新 │
│ Input: 新旧 sitemap 对比 │
│ Output: 变化的 URL(新增、修改、删除) │
│ 功能: 只抓取有变化的页面 │
└────────────┬────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 阶段5:内容抓取(可选) │
│ Input: URL 队列 │
│ Output: 页面内容 │
│ 特点: 按优先级抓取、支持并发 │
└─────────────────────────────────────────────────────────────┘
核心模块划分
| 模块 | 职责 | 输入 | 输出 |
|---|---|---|---|
| SitemapDiscoverer | 发现 sitemap 文件 | 域名 | sitemap URL 列表 |
| SitemapParser | 解析 sitemap XML | sitemap URL | 页面 URL + 元数据 |
| URLQueueManager | 管理 URL 队列 | URL + 元数据 | 优先级队列 |
| IncrementalUpdater | 增量更新检测 | 新旧 sitemap | 变化的 URL |
| ContentCrawler | 抓取页面内容 | URL 队列 | 页面内容 |
技术栈选择
| 组件 | 选型 | 理由 |
|---|---|---|
| HTTP 库 | requests + session | 稳定、易用、支持压缩 |
| XML 解析 | lxml | 速度快、支持大文件、容错性强 |
| 数据库 | SQLite / PostgreSQL | SQLite 适合单机,PostgreSQL 适合分布式 |
| ORM | SQLAlchemy | 功能强大、支持多种数据库 |
| 并发 | concurrent.futures | 简单高效,适合 I/O 密集 |
| 压缩处理 | gzip (标准库) | 原生支持 |
| URL 处理 | urllib.parse | 标准库,无需额外依赖 |
为什么选择这个架构?
优势:
- 模块化:各模块独立,易于测试和维护
- 可扩展:可轻松添加新的发现策略或解析格式
- 高效:并发解析、增量更新,节省资源
- 容错:异常处理、断点续爬、循环检测
权衡:
- 不使用 Scrapy:Sitemap 解析不需要复杂的爬虫框架
- 不使用 Beautiful Soup:lxml 在 XML 解析上更快更专业
- 选择 SQLAlchemy:虽然有学习成本,但可切换数据库
4️⃣ 环境准备与依赖
Python 版本
推荐:Python 3.9+(本文基于 Python 3.10 测试)
安装依赖
bash
# 创建虚拟环境(推荐)
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 安装依赖
pip install -r requirements.txt
requirements.txt:
txt
# HTTP 和网络
requests==2.31.0
urllib3==2.1.0
# XML 解析
lxml==5.1.0
# 数据库
SQLAlchemy==2.0.25
# 工具
tqdm==4.66.1 # 进度条
python-dateutil==2.8.2 # 日期解析
validators==0.22.0 # URL 验证
# 可选:高级功能
# redis==5.0.1 # 分布式队列
# celery==5.3.4 # 异步任务
项目目录结构
json
sitemap_crawler/
├── config/
│ ├── __init__.py
│ └── settings.py # 全局配置
├── models/
│ ├── __init__.py
│ ├── database.py # 数据库连接
│ └── schemas.py # ORM 模型定义
├── core/
│ ├── __init__.py
│ ├── discoverer.py # Sitemap 发现器
│ ├── parser.py # Sitemap 解析器
│ ├── queue_manager.py # URL 队列管理
│ └── incremental.py # 增量更新器
├── utils/
│ ├── __init__.py
│ ├── http_client.py # HTTP 工具类
│ ├── xml_parser.py # XML 工具类
│ └── logger.py # 日志工具
├── data/
│ ├── sitemaps/ # 下载的 sitemap 文件
│ └── crawler.db # SQLite 数据库
├── logs/
│ └── crawler.log
├── main.py # 主入口
├── requirements.txt
└── README.md
5️⃣ 数据库设计
ORM 模型定义
python
"""
models/schemas.py
数据库表结构定义
"""
from sqlalchemy import (
Column, Integer, String, Text, DateTime,
Float, Boolean, Index, UniqueConstraint
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from datetime import datetime
import hashlib
Base = declarative_base()
class SitemapFile(Base):
"""
Sitemap 文件表
存储发现的所有 sitemap 文件(包括 index 和子 sitemap)
"""
__tablename__ = 'sitemap_file'
# 主键
id = Column(Integer, primary_key=True, autoincrement=True)
# 基本信息
domain = Column(String(255), nullable=False, comment='域名')
url = Column(String(2048), unique=True, nullable=False, comment='Sitemap URL')
url_hash = Column(String(32), unique=True, nullable=False, comment='URL 的 MD5')
# 类型和状态
file_type = Column(String(50), comment='类型: index/urlset/rss/atom/text')
is_compressed = Column(Boolean, default=False, comment='是否压缩')
is_index = Column(Boolean, default=False, comment='是否为索引文件')
# 解析状态
parse_status = Column(String(50), default='pending', comment='解析状态: pending/parsing/completed/failed')
parse_attempts = Column(Integer, default=0, comment='解析尝试次数')
last_error = Column(Text, comment='最后一次错误信息')
# 内容信息
content_size = Column(Integer, comment='文件大小(字节)')
url_count = Column(Integer, default=0, comment='包含的 URL 数量')
child_sitemap_count = Column(Integer, default=0, comment='子 sitemap 数量(仅 index)')
# 时间戳
discovered_at = Column(DateTime, default=datetime.now, comment='发现时间')
last_modified = Column(DateTime, comment='Sitemap 的 lastmod 时间')
parsed_at = Column(DateTime, comment='解析时间')
# 关系
urls = relationship("URLEntry", back_populates="sitemap")
# 索引
__table_args__ = (
Index('idx_domain', 'domain'),
Index('idx_parse_status', 'parse_status'),
Index('idx_url_hash', 'url_hash'),
)
@staticmethod
def generate_url_hash(url: str) -> str:
"""生成 URL 的 MD5 哈希"""
return hashlib.md5(url.encode('utf-8')).hexdigest()
def __repr__(self):
return f"<SitemapFile(id={self.id}, url={self.url[:50]})>"
class URLEntry(Base):
"""
URL 条目表
存储从 sitemap 中提取的所有页面 URL 及其元数据
"""
__tablename__ = 'url_entry'
# 主键
id = Column(Integer, primary_key=True, autoincrement=True)
# 外键(关联 sitemap)
sitemap_id = Column(Integer, nullable=False, comment='来源 sitemap ID')
# URL 信息
url = Column(String(2048), nullable=False, comment='页面 URL')
url_hash = Column(String(32), unique=True, nullable=False, comment='URL 的 MD5')
domain = Column(String(255), nullable=False, comment='域名')
path = Column(Text, comment='URL 路径部分')
# Sitemap 元数据
lastmod = Column(DateTime, comment='最后修改时间')
changefreq = Column(String(20), comment='更新频率')
priority = Column(Float, comment='优先级 0.0-1.0')
# 计算字段
computed_priority = Column(Float, comment='计算后的优先级(综合考虑多个因素)')
# 抓取状态
craw), default='pending', comment='抓取状态: pending/crawling/completed/failed')
crawl_attempts = Column(Integer, default=0, comment='抓取尝试次数')
last_crawled_at = Column(DateTime, comment='最后抓取时间')
# 内容信息(如果已抓取)
http_status = Column(Integer, comment='HTTP 状态码')
content_length = Column(Integer, comment='内容长度')
content_type = Column(String(100), comment='Content-Type')
# 时间戳
discovered_at = Column(DateTime, default=datetime.now, comment='发现时间')
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment='更新时间')
# 关系
sitemap = relationship("SitemapFile", back_populates="urls")
# 索引和约束
__table_args__ = (
Index('idx_domain', 'domain'),
Index('idx_crawl_status', 'crawl_status'),
Index('idx_computed_priority', 'computed_priority'),
Index('idx_lastmod', 'lastmod'),
Index('idx_url_hash', 'url_hash'),
)
@staticmethod
def generate_url_hash(url: str) -> str:
"""生成 URL 的 MD5 哈希"""
return hashlib.md5(url.encode('utf-8')).hexdigest()
def __repr__(self):
return f"<URLEntry(id={self.id}, url={self.url[:50]})>"
class SitemapHistory(Base):
"""
Sitemap 历史记录表
追踪 sitemap 的变化历史,用于增量更新
"""
__tablename__ = 'sitemap_history'
# 主键
id = Column(Integer, primary_key=True, autoincrement=True)
# Sitemap 信息
sitemap_url = Column(String(2048), nullable=False, comment='Sitemap URL')
domain = Column(String(255), nullable=False, comment='域名')
# 快照信息
snapshot_date = Column(DateTime, default=datetime.now, comment='快照时间')
url_count = Column(Integer, comment='URL 总数')
content_hash = Column(String(64), comment='内容的 SHA256 哈希')
# 变化统计
urls_added = Column(Integer, default=0, comment='新增 URL 数')
urls_modified = Column(Integer, default=0, comment='修改 URL 数')
urls_removed = Column(Integer, default=0, comment='删除 URL 数')
# 索引
__table_args__ = (
Index('idx_sitemap_url', 'sitemap_url'),
Index('idx_domain', 'domain'),
Index('idx_snapshot_date', 'snapshot_date'),
)
def __repr__(self):
return f"<SitemapHistory(id={self.id}, snapshot_date={self.snapshot_date})>"
class CrawlLog(Base):
"""
抓取日志表
记录每次抓取的详细信息
"""
__tablename__ = 'crawl_log'
id = Column(Integer, primary_key=True, autoincrement=True)
url_id = Column(Integer, comment='URL ID')
# 请求信息
request_time = Column(DateTime, nullable=False, comment='请求时间')
response_time = Column(DateTime, comment='响应时间')
duration_ms = Column(Integer, comment='耗时(毫秒)')
# 结果
success = Column(Boolean, default=False, comment='是否成功')
http_status = Column(Integer, comment='HTTP 状态码')
error_type = Column(String(100), comment='错误类型')
error_message = Column(Text, comment='错误详情')
# 索引
__table_args__ = (
Index('idx_url_id', 'url_id'),
Index('idx_request_time', 'request_time'),
)
def __repr__(self):
return f"<CrawlLog(id={self.id}, success={self.success})>"
数据库连接管理
python
"""
models/database.py
数据库连接和会话管理
"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session
from sqlalchemy.pool import StaticPool
from contextlib import contextmanager
import os
from .schemas import Base
from config.settings import DATABASE_PATH
class Database:
"""数据库管理类(单例模式)"""
_instance = None
_engine = None
_session_factory = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialize()
return cls._instance
def _initialize(self):
"""初始化数据库连接"""
# 确保数据库目录存在
db_dir = os.path.dirname(DATABASE_PATH)
if db_dir and not os.path.exists(db_dir):
os.makedirs(db_dir, exist_ok=True)
# 创建引擎
self._engine = create_engine(
f'sqlite:///{DATABASE_PATH}',
connect_args={'check_same_thread': False},
poolclass=StaticPool,
echo=False # 设为 True 可查看 SQL 语句
)
# 创建所有表
Base.metadata.create_all(self._engine)
# 创建 session 工厂
self._session_factory = scoped_session(
sessionmaker(bind=self._engine, expire_on_commit=False)
)
@contextmanager
def get_session(self):
"""
获取数据库会话(上下文管理器)
使用方法:
with db.get_session() as session:
result = session.query(SitemapFile).all()
"""
session = self._session_factory()
try:
yield session
session.commit()
except Exception as e:
session.rollback()
raise e
finally:
session.close()
def get_engine(self):
"""获取数据库引擎"""
return self._engine
def close(self):
"""关闭数据库连接"""
if self._session_factory:
self._session_factory.remove()
if self._engine:
self._engine.dispose()
# 全局数据库实例
db = Database()
配置文件
python
"""
config/settings.py
全局配置
"""
import os
from pathlib import Path
# 项目根目录
BASE_DIR = Path(__file__).resolve().parent.parent
# 数据库配置
DATABASE_PATH = BASE_DIR / 'data' / 'crawler.db'
# 数据目录
DATA_DIR = BASE_DIR / 'data'
SITEMAP_DIR = DATA_DIR / 'sitemaps'
SITEMAP_DIR.mkdir(parents
LOG_DIR = BASE_DIR / 'logs'
LOG_DIR.mkdir(parents=True, exist_ok=True)
LOG_FILE = LOG_DIR / 'crawler.log'
# 爬虫配置
CRAWLER_SETTINGS = {
# 请求超时
'REQUEST_TIMEOUT': 30,
'DOWNLOAD_TIMEOUT': 60,
# 重试设置
'MAX_RETRIES': 3,
'RETRY_DELAY': 5,
# 并发设置
'MAX_WORKERS': 10, # 最大并发解析数
# 文件限制
'MAX_SITEMAP_SIZE': 100 * 1024 * 1024, # 100MB
'MAX_URLS_PER_SITEMAP': 50000, # 协议限制
# User-Agent
'USER_AGENT': 'Mozilla/5.0 (compatible; SitemapCrawler/1.0; +http://example.com/bot)',
# Sitemap 发现
'COMMON_SITEMAP_PATHS': [
'/sitemap.xml',
'/sitemap_index.xml',
'/sitemap-index.xml',
'/sitemap1.xml',
'/sitemaps/sitemap.xml',
'/sitemap/sitemap.xml',
'/sitemap.xml.gz',
'/sitemap_index.xml.gz',
],
# 请求延迟
'REQUEST_DELAY': 1.0, # 每次请求间隔1秒
}
# 优先级计算权重
PRIORITY_WEIGHTS = {
'sitemap_priority': 0.3, # sitemap 中的 priority 字段
'lastmod_recency': 0.3, # 最后修改时间的新鲜度
'changefreq': 0.2, # 更新频率
'url_depth': 0.2, # URL 深度(层级越少优先级越高)
}
# 增量更新配置
INCREMENTAL_SETTINGS = {
'ENABLE_INCREMENTAL': True, # 是否启用增量更新
'CHECK_INTERVAL': 3600, # 检查间隔(秒),默认1小时
'FORCE_FULL_CRAWL_DAYS': 7, # 强制全量抓取间隔(天)
}
6️⃣ 核心实现:Sitemap 发现器
HTTP 客户端工具类
python
"""
utils/http_client.py
统一的 HTTP 客户端
"""
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
import time
import gzip
from io import BytesIO
from typing import Optional
from pathlib import Path
from config.settings import CRAWLER_SETTINGS
from utils.logger import logger
class HTTPClient:
"""
HTTP 客户端
功能:
- 自动重试
- 压缩支持
- 超时控制
"""
def __init__(self, timeout: int = None):
self.timeout = timeout or CRAWLER_SETTINGS['REQUEST_TIMEOUT']
self.session = self._create_session()
def _create_session(self) -> requests.Session:
"""创建带重试机制的 Session"""
session = requests.Session()
# 配置重试策略
retry_strategy = Retry(
total=CRAWLER_SETTINGS['MAX_RETRIES'],
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "HEAD"]
)
adapter = HTTPAdapter(
max_retries=retry_strategy,
pool_connections=20,
pool_maxsize=50
)
session.mount("http://", adapter)
session.mount("https://", adapter)
return session
def get(self, url: str, **kwargs) -> Optional[requests.Response]:
"""
GET 请求
自动处理:
- Gzip 压缩
- UTF-8 编码
- 超时
"""
# 设置默认 headers
if 'headers' not in kwargs:
kwargs['headers'] = {}
if 'User-Agent' not in kwargs['headers']:
kwargs['headers']['User-Agent'] = CRAWLER_SETTINGS['USER_AGENT']
# 接受压缩
kwargs['headers']['Accept-Encoding'] = 'gzip, deflate'
# 设置超时
if 'timeout' not in kwargs:
kwargs['timeout'] = self.timeout
try:
response = self.session.get(url, **kwargs)
response.raise_for_status()
# 自动处理编码
if response.encoding == 'ISO-8859-1':
response.encoding = response.apparent_encoding
return responselogger.error(f"GET 请求失败 [{url}]: {str(e)}")
return None
def download_file(self, url: str, save_path: Path) -> bool:
"""
下载文件到本地
支持 Gzip 压缩文件
"""
try:
response = self.get(url, stream=True)
if not response:
return False
# 确保目录存在
save_path.parent.mkdir(parents=True, exist_ok=True)
# 检查是否为 Gzip
is_gzip = url.endswith('.gz') or response.headers.get('Content-Encoding') == 'gzip'
if is_gzip:
# 解压并保存
content = gzip.decompress(response.content)
with open(save_path, 'wb') as f:
f.write(content)
else:
# 直接保存
with open(save_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
logger.info(f"✅ 下载成功: {save_path.name}")
return True
except Exception as e:
logger.error(f"下载失败 [{url}]: {str(e)}")
return False
def close(self):
"""关闭 Session"""
if self.session:
self.session.close()
Sitemap 发现器完整代码
python
"""
core/discoverer.py
Sitemap 发现器:自动发现网站的所有 sitemap 文件
"""
from typing import List, Set, Optional
from urllib.parse import urljoin, urlparse
import re
from models.database import db
from models.schemas import SitemapFile
from utils.http_client import HTTPClient
from utils.logger import logger
from config.settings import CRAWLER_SETTINGS
class SitemapDiscoverer:
"""
Sitemap 发现器
职责:
1. 从 robots.txt 获取 sitemap
2. 探测常见路径
3. 从 HTML 页面提取 sitemap 链接
4. 去重和保存到数据库
使用方法:
discoverer = SitemapDiscoverer()
sitemaps = discoverer.discover('https://example.com')
"""
def __init__(self):
self.http_client = HTTPClient()
self.discovered_urls = set() # 已发现的 sitemap URL(去重)
def discover(self, domain: str, strategies: List[str] = None) -> List[str]:
"""
发现指定域名的所有 sitemap
Args:
domain: 目标域名(如 https://example.com)
strategies: 使用的策略列表,None 表示全部
可选: ['robots', 'common_paths', 'html_links']
Returns:
发现的 sitemap URL 列表
"""
logger.info("=" * 60)
logger.info(f"开始发现 Sitemap: {domain}")
logger.info("=" * 60)
# 标准化域名
domain = self._normalize_domain(domain)
# 默认使用所有策略
if strategies is None:
strategies = ['robots', 'common_paths', 'html_links']
# 清空已发现集合
self.discovered_urls.clear()
# 策略1:从 robots.txt 获取
if 'robots' in strategies:
self._discover_from_robots(domain)
# 策略2:探测常见路径
if 'common_paths' in strategies:
self._discover_from_common_paths(domain)
# 策略3:从 HTML 页面提取
if 'html_links' in strategies:
self._discover_from_html(domain)
# 保存到数据库
saved_count = self._save_to_database(domain)
logger.info("=" * 60)
logger.info(f"Sitemap 发现完成!共发现 {len(self.discovered_urls)} 个,新增 {saved_count} 个")
logger.info("=" * 60)
return list(self.discovered_urls)
def _normalize_domain(self, domain: str) -> str:
"""
标准化域名
Examples:
'example.com' -> 'https://example.com'
'http://example.com/' -> 'http://example.com'
"""
if not domain.startswith(('http://', 'https://')):
domain = 'https://' + domain
# 移除尾部斜杠
domain = domain.rstrip('/')
return domain
def _discover_from_robots(self, domain: str):
"""
策略1:从 robots.txt 获取 sitemap
标准格式:
Sitemap: https://example.com/sitemap.xml
Sitemap: https://example.com/sitemap-news.xml
"""
logger.info("策略1:检查 robots.txt")
robots_url = urljoin(domain, '/robots.txt')
response = self.http_client.get(robots_url)
if not response:
logger.warning("robots.txt 不可用")
return
# 解析 robots.txt
sitemap_pattern = re.compile(r'Sitemap:\s*(.+)', re.IGNORECASE)
for line in response.text.split('\n'):
match = sitemap_pattern.match(line.strip())
if match:
sitemap_url = match.group(1).strip()
# 转换为绝对 URL
sitemap_url = urljoin(domain, sitemap_url)
self.discovered_urls.add(sitemap_url)
logger.info(f" 发现: {sitemap_url}")
logger.info(f"从 robots.txt 发现 {len(self.discovered_urls)} 个 sitemap")
def _discover_from_common_paths(self, domain: str):
"""
策略2:探测常见路径
常见路径列表在 settings.COMMON_SITEMAP_PATHS
"""
logger.info("策略2:探测常见路径")
common_paths = CRAWLER_SETTINGS['COMMON_SITEMAP_PATHS']
found_count = 0
for path in common_paths:
sitemap_url = urljoin(domain, path)
# HEAD 请求检查是否存在
try:
response = self.http_client.session.head(
sitemap_url,
timeout=10,
headers={'User-Agent': CRAWLER_SETTINGS['USER_AGENT']}
)
if response.status_code == 200:
self.discovered_urls.add(sitemap_url)
logger.info(f" 找到: {path}")
found_count += 1
except Exception as e:
# 忽略错误,继续探测
pass
logger.info(f"从常见路径发现 {found_count} 个 sitemap")
def _discover_from_html(self, domain: str):
"""
策略3:从 HTML 页面提取 sitemap 链接
查找:
<link rel="sitemap" href="/sitemap.xml">
<a href="/sitemap.xml">Sitemap</a>
"""
logger.info("策略3:从 HTML 页面提取")
# 获取首页
response = self.http_client.get(domain)
if not response:
logger.warning("无法访问首页")
return
html = response.text
found_count = 0
# 正则匹配 sitemap 链接
patterns = [
r'<link[^>]*rel=["\']sitemap["\'][^>]*href=["\'](.*?)["\']',
r'<a[^>]*href=["\'](.*?sitemap.*?)["\']',
]
for pattern in patterns:
matches = re.findall(pattern, html, re.IGNORECASE)
for url in matches:
# 转换为绝对 URL
sitemap_url = urljoin(domain, url)
# 验证是否为有效的 sitemap URL
if self._is_valid_sitemap_url(sitemap_url):
self.discovered_urls.add(sitemap_url)
logger.info(f" 发现: {sitemap_url}")
found_count += 1
logger.info(f"从 HTML 发现 {found_count} 个 sitemap")
@staticmethod
def _is_valid_sitemap_url(url: str) -> bool:
"""
验证是否为有效的 sitemap URL
检查:
- URL 格式正确
- 包含 'sitemap' 关键词或以 .xml/.xml.gz 结尾
"""
# URL 格式验证
try:
parsed = urlparse(url)
if not all([parsed.scheme, parsed.netloc]):
return False
except Exception:
return False
# 内容验证
url_lower = url.lower()
# 包含 'sitemap' 或 XML 扩展名
if 'sitemap' in url_lower:
return True
if url_lower.endswith(('.xml', '.xml.gz', '.txt')):
return True
return False
def _save_to_database(self, domain: str) -> int:
"""
保存发现的 sitemap 到数据库
Args:
domain: 域名
Returns:
新增数量
"""
saved_count = 0
with db.get_session() as session:
for sitemap_url in self.discovered_urls:
# 生成 URL 哈希
url_hash = SitemapFile.generate_url_hash(sitemap_url)
# 检查是否已存在
exists = session.query(SitemapFile).filter_by(url_hash=url_hash).first()
if exists:
continue
# 判断类型
is_compressed = sitemap_url.endswith('.gz')
# 创建记录
sitemap = SitemapFile(
domain=domain,
url=sitemap_url,
url_hash=url_hash,
is_compressed=is_compressed,
parse_status='pending'
)
session.add(sitemap)
saved_count += 1
session.commit()
return saved_count
def get_pending_sitemaps(self, domain: str = None, limit: int = None) -> List[SitemapFile]:
"""
获取待解析的 sitemap
Args:
domain: 筛选域名(可选)
limit: 限制数量(可选)
Returns:
SitemapFile 列表
"""
with db.get_session() as session:
query = session.query(SitemapFile).filter_by(parse_status='pending')
if domain:
query = query.filter_by(domain=domain)
if limit:
query = query.limit(limit)
sitemaps = query.all()
# detach 以便在 session 外使用
session.expunge_all()
return sitemaps
if __name__ == '__main__':
# 测试代码
discoverer = SitemapDiscoverer()
# 发现 Sitemap
sitemaps = discoverer.discover('https://www.example.com')
print(f"\n发现的 Sitemap:")
for url in sitemaps:
print(f" - {url}")
# 获取待解析列表
pending = discoverer.get_pending_sitemaps()
print(f"\n待解析: {len(pending)} 个")
7️⃣ 核心实现:Sitemap 解析器
XML 解析工具类
python
"""
utils/xml_parser.py
XML 解析工具类
"""
from lxml import etree
import gzip
from typing import Dict, List, Optional
from io import BytesIO
from datetime import datetime
from dateutil import parser as date_parser
from utils.logger import logger
class XMLParser:
"""
XML 解析工具类
支持:
- 标准 sitemap.xml
- sitemap index
- 压缩格式 (.gz)
- RSS/Atom feed
- 文本格式
"""
# XML 命名空间
NAMESPACES = {
'sitemap': 'http://www.sitemaps.org/schemas/sitemap/0.9',
'news': 'http://www.google.com/schemas/sitemap-news/0.9',
'image': 'http://www.google.com/schemas/sitemap-image/1.1',
'video': 'http://www.google.com/schemas/sitemap-video/1.1',
}
@staticmethod
def parse_sitemap(content: bytes) -> Dict:
"""
解析 sitemap 内容
Args:
content: XML 内容(字节)
Returns:
解析结果字典
{
'type': 'urlset' | 'sitemapindex',
'urls': [...], # 如果是 urlset
'sitemaps': [...], # 如果是 sitemapindex
}
"""
try:
# 尝试解压(如果是 gzip)
try:
content = gzip.decompress(content)
except Exception:
pass # 不是 gzip,使用原始内容
# 解析 XML
tree = etree.parse(BytesIO(content))
root = tree.getroot()
# 判断类型
tag_name = etree.QName(root.tag).localname
if tag_name == 'urlset':
# 标准 sitemap
return XMLParser._parse_urlset(root)
elif tag_name == 'sitemapindex':
# sitemap index
return XMLParser._parse_sitemapindex(root)
elif tag_name in ['rss', 'feed']:
# RSS/Atom
return XMLParser._parse_rss_atom(root)
else:
logger.warning(f"未知的 XML 根标签: {tag_name}")
return {'type': 'unknown', 'urls': []}
except Exception as e:
logger.error(f"XML 解析失败: {str(e)}")
return {'type': 'error', 'urls': []}
@staticmethod
def _parse_urlset(root) -> Dict:
"""
解析 urlset(标准 sitemap)
示例:
<urlset>
<url>
<loc>https://example.com/page1</loc>
<lastmod>2024-03-15</lastmod>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
</urlset>
Returns:
{
'type': 'urlset',
'urls': [
{
'loc': 'https://example.com/page1',
'lastmod': datetime(...),
'changefreq': 'daily',
'priority': 0.8
},
...
]
}
"""
urls = []
# 查找所有 <url> 标签
for url_elem in root.findall('.//sitemap:url', namespaces=XMLParser.NAMESPACES):
url_data = {}
# <loc> - 必填
loc_elem = url_elem.find('sitemap:loc', namespaces=XMLParser.NAMESPACES)
if loc_elem is not None and loc_elem.text:
url_data['loc'] = loc_elem.text.strip()
else:
continue # 跳过没有 loc 的条目
# <lastmod> - 可选
lastmod_elem = url_elem.find('sitemap:lastmod', namespaces=XMLParser.NAMESPACES)
if lastmod_elem is not None and lastmod_elem.text:
url_data['lastmod'] = XMLParser._parse_datetime(lastmod_elem.text)
# <changefreq> - 可选
changefreq_elem = url_elem.find('sitemap:changefreq', namespaces=XMLParser.NAMESPACES)
if changefreq_elem is not None and changefreq_elem.text:
url_data['changefreq'] = changefreq_elem.text.strip()
# <priority> - 可选
priority_elem = url_elem.find('sitemap:priority', namespaces=XMLParser.NAMESPACES)
if priority_elem is not None and priority_elem.text:
try:
url_data['priority'] = float(priority_elem.text)
except ValueError:
url_data['priority'] = None
urls.append(url_data)
return {
'type': 'urlset',
'urls': urls
}
@staticmethod
def _parse_sitemapindex(root) -> Dict:
"""
解析 sitemapindex(索引文件)
示例:
<sitemapindex>
<sitemap>
<loc>https://example.com/sitemap1.xml</loc>
<lastmod>2024-03-15</lastmod>
</sitemap>
</sitemapindex>
Returns:
{
'type': 'sitemapindex',
'sitemaps': [
{
'loc': 'https://example.com/sitemap1.xml',
'lastmod': datetime(...)
},
...
]
}
"""
sitemaps = []
# 查找所有 <sitemap> 标签
for sitemap_elem in root.findall('.//sitemap:sitemap', namespaces=XMLParser.NAMESPACES):
sitemap_data = {}
# <loc> - 必填
loc_elem = sitemap_elem.find('sitemap:loc', namespaces=XMLParser.NAMESPACES)
if loc_elem is not None and loc_elem.text:
sitemap_data['loc'] = loc_elem.text.strip()
else:
continue
# <lastmod> - 可选
lastmod_elem = sitemap_elem.find('sitemap:lastmod', namespaces=XMLParser.NAMESPACES)
if lastmod_elem is not None and lastmod_elem.text:
sitemap_data['lastmod'] = XMLParser._parse_datetime(lastmod_elem.text)
sitemaps.append(sitemap_data)
return {
'type': 'sitemapindex',
'sitemaps': sitemaps
}
@staticmethod
def _parse_rss_atom(root) -> Dict:
"""
解析 RSS/Atom feed
提取:
- RSS: <item><link>
- Atom: <entry><link href="">
"""
urls = []
tag_name = etree.QName(root.tag).localname
if tag_name == 'rss':
# RSS format
for item in root.findall('.//item'):
link_elem = item.find('link')
if link_elem is not None and link_elem.text:
url_data = {'loc': link_elem.text.strip()}
# 尝试获取 pubDate
pubdate_elem = item.find('pubDate')
if pubdate_elem is not None and pubdate_elem.text:
url_data['lastmod'] = XMLParser._parse_datetime(pubdate_elem.text)
urls.append(url_data)
elif tag_name == 'feed':
# Atom format
atom_ns = {'atom': 'http://www.w3.org/2005/Atom'}
for entry in root.findall('.//atom:entry', namespaces=atom_ns):
link_elem = entry.find('atom:link[@href]', namespaces=atom_ns)
if link_elem is not None:
url_data = {'loc': link_elem.get('href')}
# 尝试获取 updated
updated_elem = entry.find('atom:updated', namespaces=atom_ns)
if updated_elem is not None and updated_elem.text:
url_data['lastmod'] = XMLParser._parse_datetime(updated_elem.text)
urls.append(url_data)
return {
'type': 'rss/atom',
'urls': urls
}
@staticmethod
def _parse_datetime(date_str: str) -> Optional[datetime]:
"""
解析日期时间字符串
支持格式:
- 2024-03-15
- 2024-03-15T10:30:00Z
- 2024-03-15T10:30:00+08:00
- Mon, 15 Mar 2024 10:00:00 GMT (RSS)
Args:
date_str: 日期字符串
Returns:
datetime 对象或 None
"""
if not date_str:
return None
try:
# 使用 dateutil 的 parser(非常宽容)
return date_parser.parse(date_str)
except Exception as e:
logger.warning(f"日期解析失败: {date_str} - {str(e)}")
return None
@staticmethod
def parse_text_sitemap(content: str) -> Dict:
"""
解析文本格式 sitemap
格式:每行一个 URL
https://example.com/page1
https://example.com/page2
https://example.com/page3
Args:
content: 文本内容
Returns:
解析结果字典
"""
urls = []
for line in content.split('\n'):
line = line.strip()
# 跳过空行和注释
if not line or line.startswith('#'):
continue
# 验证是否为 URL
if line.startswith('http://') or line.startswith('https://'):
urls.append({'loc': line})
return {
'type': 'text',
'urls': urls
}
Sitemap 解析器完整代码
python
"""
core/parser.py
Sitemap 解析器:递归解析 sitemap 文件,提取所有 URL
"""
from typing import List, Set, Dict, Optional
from urllib.parse import urljoin
from datetime import datetime
from pathlib import Path
import time
from models.database import db
from models.schemas import SitemapFile, URLEntry
from utils.http_client import HTTPClient
from utils.xml_parser import XMLParser
from utils.logger import logger
from config.settings import CRAWLER_SETTINGS, SITEMAP_DIR
class SitemapParser:
"""
Sitemap 解析器
职责:
1. 下载 sitemap 文件
2. 递归解析(处理 sitemap index)
3. 提取 URL 及元数据
4. 保存到数据库
5. 循环引用检测
特点:
- 支持嵌套 sitemap(最多10层)
- 支持压缩格式 (.gz)
- 智能去重
- 断点续解析
"""
def __init__(self, max_depth: int = 10):
"""
初始化解析器
Args:
max_depth: 最大递归深度(防止无限循环)
"""
self.http_client = HTTPClient()
self.xml_parser = XMLParser()
self.max_depth = max_depth
# 用于循环检测
self.processing_sitemaps = set() # 当前正在处理的 sitemap URL
self.processed_sitemaps = set() # 已处理完成的 sitemap URL
def parse_all(self, domain: str = None, batch_size: int = 10) -> int:
"""
解析所有待处理的 sitemap
Args:
domain: 筛选域名(可选)
batch_size: 每批处理数量
Returns:
处理的 sitemap 数量
"""
logger.info("=" * 60)
logger.info("开始解析 Sitemap")
logger.info("=" * 60)
total_processed = 0
while True:
# 获取待处理的 sitemap
pending = self._get_pending_sitemaps(domain, batch_size)
if not pending:
logger.info("没有待解析的 sitemap")
break
logger.info(f"获取到 {len(pending)} 个待解析 sitemap")
# 逐个处理
for sitemap in pending:
try:
self.parse_sitemap(sitemap)
total_processed += 1
except Exception as e:
logger.error(f"解析失败 [{sitemap.url}]: {str(e)}")
self._update_parse_status(sitemap, 'failed', str(e))
logger.info("=" * 60)
logger.info(f"Sitemap 解析完成!共处理 {total_processed} 个")
logger.info("=" * 60)
return total_processed
def parse_sitemap(self, sitemap: SitemapFile, depth: int = 0):
"""
解析单个 sitemap(递归)
工作流程:
1. 检查循环引用
2. 下载 sitemap 文件
3. 解析 XML
4. 如果是 index,递归解析子 sitemap
5. 如果是 urlset,提取 URL
6. 更新数据库
Args:
sitemap: SitemapFile 对象
depth: 当前递归深度
"""
logger.info(f"{' ' * depth}解析: {sitemap.url}")
# 1. 检查深度
if depth > self.max_depth:
logger.warning(f"达到最大递归深度 {self.max_depth},停止解析")
return
# 2. 循环检测
if sitemap.url in self.processing_sitemaps:
logger.warning(f"检测到循环引用,跳过: {sitemap.url}")
return
if sitemap.url in self.processed_sitemaps:
logger.info(f"已处理过,跳过: {sitemap.url}")
return
# 标记为正在处理
self.processing_sitemaps.add(sitemap.url)
try:
# 3. 更新状态为解析中
self._update_parse_status(sitemap, 'parsing')
# 4. 下载 sitemap
content = self._download_sitemap(sitemap)
if not content:
raise Exception("下载失败")
# 5. 解析内容
parsed_data = self._parse_content(sitemap, content)
# 6. 处理解析结果
if parsed_data['type'] == 'sitemapindex':
# 递归解析子 sitemap
self._handle_sitemapindex(sitemap, parsed_data, depth)
elif parsed_data['type'] in ['urlset', 'rss/atom', 'text']:
# 提取 URL
self._handle_urlset(sitemap, parsed_data)
else:
raise Exception(f"未知类型: {parsed_data['type']}")
# 7. 更新状态为完成
self._update_parse_status(sitemap, 'completed')
# 标记为已处理
self.processed_sitemaps.add(sitemap.url)
except Exception as e:
logger.error(f"解析异常: {str(e)}")
self._update_parse_status(sitemap, 'failed', str(e))
finally:
# 移除处理标记
self.processing_sitemaps.discard(sitemap.url)
def _download_sitemap(self, sitemap: SitemapFile) -> Optional[bytes]:
"""
下载 sitemap 文件
Args:
sitemap: SitemapFile 对象
Returns:
文件内容(字节)
"""
# 生成本地保存路径
save_path = self._get_save_path(sitemap)
# 下载
success = self.http_client.download_file(sitemap.url, save_path)
if not success:
return None
# 读取内容
with open(save_path, 'rb') as f:
content = f.read()
# 更新文件大小
with db.get_session() as session:
sm = session.query(SitemapFile).filter_by(id=sitemap.id).first()
if sm:
sm.content_size = len(content)
session.commit()
return content
def _parse_content(self, sitemap: SitemapFile, content: bytes) -> Dict:
"""
解析 sitemap 内容
Args:
sitemap: SitemapFile 对象
content: 文件内容
Returns:
解析结果字典
"""
# 判断是文本还是 XML
try:
# 尝试解码为文本
text = content.decode('utf-8')
# 如果不包含 XML 标签,当作文本处理
if not text.strip().startswith('<?xml') and not text.strip().startswith('<'):
logger.info(f"检测到文本格式 sitemap")
return self.xml_parser.parse_text_sitemap(text)
except Exception:
pass
# XML 解析
return self.xml_parser.parse_sitemap(content)
def _handle_sitemapindex(self, parent_sitemap: SitemapFile, parsed_data: Dict, depth: int):
"""
处理 sitemap index
递归解析所有子 sitemap
Args:
parent_sitemap: 父 sitemap
parsed_data: 解析结果
depth: 当前深度
"""
sitemaps = parsed_data.get('sitemaps', [])
logger.info(f"发现 {len(sitemaps)} 个子 sitemap")
# 更新 child_sitemap_count
with db.get_session() as session:
sm = session.query(SitemapFile).filter_by(id=parent_sitemap.id).first()
if sm:
sm.is_index = True
sm.file_type = 'sitemapindex'
sm.child_sitemap_count = len(sitemaps)
session.commit()
# 递归解析每个子 sitemap
for sitemap_data in sitemaps:
child_url = sitemap_data['loc']
# 转换为绝对 URL
child_url = urljoin(parent_sitemap.url, child_url)
# 检查或创建子 sitemap 记录
child_sitemap = self._get_or_create_sitemap(child_url, parent_sitemap.domain)
# 递归解析
self.parse_sitemap(child_sitemap, depth + 1)
def _handle_urlset(self, sitemap: SitemapFile, parsed_data: Dict):
"""
处理 urlset(提取 URL)
Args:
sitemap: SitemapFile 对象
parsed_data: 解析结果
"""
urls = parsed_data.get('urls', [])
logger.info(f"提取到 {len(urls)} 个 URL")
# 检查数量限制
if len(urls) > CRAWLER_SETTINGS['MAX_URLS_PER_SITEMAP']:
logger.warning(f"URL 数量超过限制 ({len(urls)} > {CRAWLER_SETTINGS['MAX_URLS_PER_SITEMAP']})")
# 更新 url_count
with db.get_session() as session:
sm = session.query(SitemapFile).filter_by(id=sitemap.id).first()
if sm:
sm.file_type = parsed_data['type']
sm.url_count = len(urls)
session.commit()
# 保存 URL 到数据库
saved_count = self._save_urls(sitemap, urls)
logger.info(f"新增 {saved_count} 个 URL")
def _save_urls(self, sitemap: SitemapFile, urls: List
保存 URL 到数据库
Args:
sitemap: 来源 sitemap
urls: URL 列表
Returns:
新增数量
"""
saved_count = 0
with db.get_session() as session:
for url_data in urls:
try:
# 提取字段
url = url_data.get('loc')
if not url:
continue
# 生成哈希
url_hash = URLEntry.generate_url_hash(url)
# 检查是否已存在
exists = session.query(URLEntry).filter_by(url_hash=url_hash).first()
if exists:
# 更新元数据(如果新数据更完整)
if url_data.get('lastmod') and not exists.lastmod:
exists.lastmod = url_data.get('lastmod')
if url_data.get('priority') is not None and exists.priority is None:
exists.priority = url_data.get('priority')
if url_data.get('changefreq') and not exists.changefreq:
exists.changefreq = url_data.get('changefreq')
continue
# 提取域名和路径
from urllib.parse import urlparse
parsed = urlparse(url)
domain = f"{parsed.scheme}://{parsed.netloc}"
path = parsed.path
# 创建记录
url_entry = URLEntry(
sitemap_id=sitemap.id,
url=url,
url_hash=url_hash,
domain=domain,
path=path,
lastmod=url_data.get('lastmod'),
changefreq=url_data.get('changefreq'),
priority=url_data.get('priority'),
crawl_status='pending'
)
session.add(url_entry)
saved_count += 1
except Exception as e:
logger.error(f"保存 URL 失败 [{url_data.get('loc')}]: {str(e)}")
continue
# 批量提交
session.commit()
return saved_count
def _get_or_create_sitemap(self, url: str, domain: str) -> SitemapFile:
"""
获取或创建 sitemap 记录
Args:
url: sitemap URL
domain: 域名
Returns:
SitemapFile 对象
"""
url_hash = SitemapFile.generate_url_hash(url)
with db.get_session() as session:
sitemap = session.query(SitemapFile).filter_by(url_hash=url_hash).first()
if not sitemap:
# 创建新记录
sitemap = SitemapFile(
domain=domain,
url=url,
url_hash=url_hash,
is_compressed=url.endswith('.gz'),
parse_status='pending'
)
session.add(sitemap)
session.commit()
# detach
session.expunge(sitemap)
return sitemap
def _update_parse_status(self, sitemap: SitemapFile, status: str, error: str = None):
"""
更新解析状态
Args:
sitemap: SitemapFile 对象
status: 状态
error: 错误信息(可选)
"""
with db.get_session() as session:
sm = session.query(SitemapFile).filter_by(id=sitemap.id).first()
if sm:
sm.parse_status = status
sm.parse_attempts += 1
if status == 'completed':
sm.parsed_at = datetime.now()
if error:
sm.last_error = error
session.commit()
def _get_pending_sitemaps(self, domain: str = None, limit: int = 10) -> List[SitemapFile]:
"""
获取待解析的 sitemap
Args:
domain: 筛选域名
limit: 限制数量
Returns:
SitemapFile 列表
"""
with db.get_session() as session:
query = session.query(SitemapFile).filter_by(parse_status='pending')
if domain:
query = query.filter_by(domain=domain)
sitemaps = query.limit(limit).all()
session.expunge_all()
return sitemaps
def _get_save_path(self, sitemap: SitemapFile) -> Path:
"""
获取 sitemap 的本地保存路径
目录结构:
data/sitemaps/
├── example.com/
│ ├── sitemap_1.xml
│ ├── sitemap_2.xml.gz
│ └── ...
Args:
sitemap: SitemapFile 对象
Returns:
保存路径
"""
# 域名作为目录名
from urllib.parse import urlparse
parsed = urlparse(sitemap.url)
domain_dir = SITEMAP_DIR / parsed.netloc
domain_dir.mkdir(parents=True, exist_ok=True)
# 文件名:使用哈希避免冲突
filename = f"sitemap_{sitemap.url_hash[:8]}"
# 扩展名
if sitemap.url.endswith('.xml.gz'):
filename += '.xml.gz'
elif sitemap.url.endswith('.xml'):
filename += '.xml'
elif sitemap.url.endswith('.txt'):
filename += '.txt'
else:
filename += '.xml'
return domain_dir / filename
if __name__ == '__main__':
# 测试代码
parser = SitemapParser()
# 解析所有待处理的 sitemap
count = parser.parse_all()
print(f"\n✅ 解析完成!共处理 {count} 个 sitemap")
8️⃣ 核心实现:URL 队列管理
URL 优先级计算
python
"""
core/queue_manager.py
URL 队列管理器:生成优先级队列,智能调度抓取
"""
from typing import List, Dict, Optional
from datetime import datetime, timedelta
from urllib.parse import urlparse
import math
from models.database import db
from models.schemas import URLEntry
from utils.logger import logger
from config.settings import PRIORITY_WEIGHTS
class URLQueueManager:
"""
URL 队列管理器
职责:
1. 计算 URL 优先级(综合多个因素)
2. 生成抓取队列(按优先级排序)
3. 去重和过滤
4. 支持增量更新(只返回变化的 URL)
优先级计算公式:
computed_priority =
w1 * sitemap_priority +
w2 * lastmod_score +
w3 * changefreq_score +
w4 * url_depth_score
其中 w1, w2, w3, w4 在 config.PRIORITY_WEIGHTS 中定义
"""
def __init__(self):
self.weights = PRIORITY_WEIGHTS
def compute_priorities(self, domain: str = None, batch_size: int = 1000) -> int:
"""
批量计算 URL 优先级
Args:
domain: 筛选域名(可选)
batch_size: 每批处理数量
Returns:
处理的 URL 数量
"""
logger.info("=" * 60)
logger.info("开始计算 URL 优先级")
logger.info("=" * 60)
total_processed = 0
while True:
# 获取一批待计算的 URL
urls = self._get_urls_for_priority_computation(domain, batch_size)
if not urls:
break
logger.info(f"处理 {len(urls)} 个 URL...")
# 批量计算
self._batch_compute_priorities(urls)
total_processed += len(urls)
logger.info("=" * 60)
logger.info(f"优先级计算完成!共处理 {total_processed} 个 URL")
logger.info("=" * 60)
return total_processed
def _batch_compute_priorities(self, urls: List[URLEntry]):
"""
批量计算优先级
Args:
urls: URLEntry 列表
"""
with db.get_session() as session:
for url_entry in urls:
try:
# 计算优先级
priority = self._calculate_priority(url_entry)
# 更新数据库
url_obj = session.query(URLEntry).filter_by(id=url_entry.id).first()
if url_obj:
url_obj.computed_priority = priority
except Exception as e:
logger.error(f"计算优先级失败 [{url_entry.url}]: {str(e)}")
session.commit()
def _calculate_priority(self, url_entry: URLEntry) -> float:
"""
计算单个 URL 的优先级
Args:
url_entry: URLEntry 对象
Returns:
计算后的优先级(0.0 - 1.0)
"""
# 1. sitemap priority(0.0-1.0)
sitemap_priority = url_entry.priority if url_entry.priority is not None else 0.5
# 2. lastmod recency score(0.0-1.0)
lastmod_score = self._calculate_lastmod_score(url_entry.lastmod)
# 3. changefreq score(0.0-1.0)
changefreq_score = self._calculate_changefreq_score(url_entry.changefreq)
# 4. URL depth score(0.0-1.0)
url_depth_score = self._calculate_url_depth_score(url_entry.url)
# 加权求和
computed_priority = (
self.weights['sitemap_priority'] * sitemap_priority +
self.weights['lastmod_recency'] * lastmod_score +
self.weights['changefreq'] * changefreq_score +
self.weights['url_depth'] * url_depth_score
)
# 限制在 0.0-1.0 范围内
return max(0.0, min(1.0, computed_priority))
@staticmethod
def _calculate_lastmod_score(lastmod: Optional[datetime]) -> float:
"""
计算最后修改时间的新鲜度得分
逻辑:
- 今天修改:1.0
- 1周前:0.9
- 1个月前:0.7
- 3个月前:0.5
- 1年前:0.3
- 更早:0.1
Args:
lastmod: 最后修改时间
Returns:
得分(0.0-1.0)
"""
if not lastmod:
return 0.5 # 默认中等
now = datetime.now()
# 移除时区信息(如果有)
if lastmod.tzinfo is not None:
lastmod = lastmod.replace(tzinfo=None)
# 计算距今天数
days_ago = (now - lastmod).days
if days_ago < 0:
# 未来日期(异常)
return 0.5
elif days_ago == 0:
return 1.0
elif days_ago <= 7:
return 0.9
elif days_ago <= 30:
return 0.7
elif days_ago <= 90:
return 0.5
elif days_ago <= 365:
return 0.3
else:
return 0.1
@staticmethod
def _calculate_changefreq_score(changefreq: Optional[str]) -> float:
"""
计算更新频率得分
频率越高,得分越高
Args:
changefreq: 更新频率
Returns:
得分(0.0-1.0)
"""
if not changefreq:
return 0.5
freq_scores = {
'always': 1.0,
'hourly': 0.9,
'daily': 0.8,
'weekly': 0.6,
'monthly': 0.4,
'yearly': 0.2,
'never': 0.1,
}
return freq_scores.get(changefreq.lower(), 0.5)
@staticmethod
def _calculate_url_depth_score(url: str) -> float:
"""
计算 URL 深度得分
层级越少(越接近首页),得分越高
Examples:
https://example.com/ -> 1.0
https://example.com/products -> 0.9
https://example.com/products/item1 -> 0.8
https://example.com/products/category/item1 -> 0.7
Args:
Returns:
得分(0.0-1.0)
"""
parsed = urlparse(url)
path = parsed.path.strip('/')
if not path:
# 根路径
return 1.0
# 计算层级(以斜杠分隔)
depth = path.count('/') + 1
# 深度越大,得分越低(指数衰减)
score = math.exp(-0.2 * depth)
return max(0.0, min(1.0, score))
def get_priority_queue(
self,
domain: str = None,
min_priority: float = 0.0,
limit: int = 1000,
status: str = 'pending'
) -> List[URLEntry]:
"""
获取优先级队列
Args:
domain: 筛选域名
min_priority: 最小优先级
limit: 返回数量
status: 抓取状态筛选
Returns:
URLEntry 列表(按优先级降序)
"""
with db.get_session() as session:
query = session.query(URLEntry)\
.filter(URLEntry.computed_priority >= min_priority)\
.filter_by(crawl_status=status)
if domain:
query = query.filter_by(domain=domain)
# 按优先级降序,lastmod 降序
urls = query.order_by(
URLEntry.computed_priority.desc(),
URLEntry.lastmod.desc()
).limit(limit).all()
session.expunge_all()
return urls
def _get_urls_for_priority_computation(self, domain: str = None, limit: int = 1000) -> List[URLEntry]:
"""
获取待计算优先级的 URL
筛选条件:computed_priority is NULL
"""
with db.get_session() as session:
query = session.query(URLEntry).filter(URLEntry.computed_priority == None)
if domain:
query = query.filter_by(domain=domain)
urls = query.limit(limit).all()
session.expunge_all()
return urls
def get_stats(self, domain: str = None) -> Dict:
"""
获取队列统计信息
Returns:
统计字典
"""
with db.get_session() as session:
query = session.query(URLEntry)
if domain:
query = query.filter_by(domain=domain)
total = query.count()
pending = query.filter_by(crawl_status='pending').count()
completed = query.filter_by(crawl_status='completed').count()
failed = query.filter_by(crawl_status='failed').count()
# 优先级分布
high_priority = query.filter(URLEntry.computed_priority >= 0.8).count()
medium_priority = query.filter(
URLEntry.computed_priority >= 0.5,
URLEntry.computed_priority < 0.8
).count()
low_priority = query.filter(URLEntry.computed_priority < 0.5).count()
return {
'total': total,
'pending': pending,
'completed': completed,
'failed': failed,
'high_priority': high_priority,
'medium_priority': medium_priority,
'low_priority': low_priority,
}
if __name__ == '__main__':
# 测试代码
queue_manager = URLQueueManager()
# 计算优先级
count = queue_manager.compute_priorities()
print(f"\n✅ 计算完成!共处理 {count} 个 URL")
# 获取高优先级队列
high_priority_urls = queue_manager.get_priority_queue(min_priority=0.8, limit=100)
print(f"\n高优先级 URL(前10):")
for url in high_priority_urls[:10]:
print(f" {url.computed_priority:.2f} - {url.url}")
# 统计
stats = queue_manager.get_stats()
print(f"\n队列统计:")
print(f" 总数: {stats['total']}")
print(f" 待抓取: {stats['pending']}")
print(f" 高优先级: {stats['high_priority']}")
9️⃣ 核心实现:增量更新器
增量更新策略
为什么需要增量更新?
python
# 场景:大型新闻网站
全量抓取:
- 总 URL 数:500,000
- 每天更新:5,000 个(1%)
- 全量抓取耗时:20小时
增量抓取:
- 只抓取变化的 5,000 个
- 耗时:20分钟
- 效率提升:60倍
增量更新完整代码
python
"""
core/incremental.py
增量更新器:检测 sitemap 变化,只抓取更新的 URL
"""
from typing import List, Set, Dict, Tuple
from datetime import datetime, timedelta
import hashlib
from models.database import db
from models.schemas import SitemapFile, URLEntry, SitemapHistory
from utils.logger import logger
from config.settings import INCREMENTAL_SETTINGS
class IncrementalUpdater:
"""
增量更新器
职责:
1. 保存 sitemap 快照
2. 对比新旧版本
3. 识别变化的 URL(新增、修改、删除)
4. 生成增量抓取队列
变化检测逻辑:
- 新增:URL 在新 sitemap 中存在,但旧快照中不存在
- 修改:URL 存在于两者,但 lastmod 时间不同
- 删除:URL 在旧快照中存在,但新 sitemap 中不存在
"""
def __init__(self):
self.enable_incremental = INCREMENTAL_SETTINGS['ENABLE_INCREMENTAL']
def create_snapshot(self, domain: str) -> int:
"""
创建 sitemap 快照
Args:
domain: 域名
Returns:
快照 ID
"""
logger.info(f"创建 sitemap 快照: {domain}")
# 1. 获取所有已解析的 sitemap
with db.get_session() as session:
sitemaps = session.query(SitemapFile)\
.filter_by(domain=domain, parse_status='completed')\
.all()
if not sitemaps:
logger.warning(f"没有已解析的 sitemap: {domain}")
return 0
# 2. 统计 URL 总数
total_urls = session.query(URLEntry)\
.join(SitemapFile, URLEntry.sitemap_id == SitemapFile.id)\
.filter(SitemapFile.domain == domain)\
.count()
# 3. 计算内容哈希(基于所有 URL)
content_hash = self._calculate_content_hash(domain, session)
# 4. 检查是否与上次快照相同
last_snapshot = session.query(SitemapHistory)\
.filter_by(domain=domain)\
.order_by(SitemapHistory.snapshot_date.desc())\
.first()
if last_snapshot and last_snapshot.content_hash == content_hash:
logger.info("内容未变化,跳过快照创建")
return last_snapshot.id
# 5. 创建快照记录
snapshot = SitemapHistory(
sitemap_url=f"{domain}/sitemap.xml", # 主 sitemap URL
domain=domain,
snapshot_date=datetime.now(),
url_count=total_urls,
content_hash=content_hash
)
session.add(snapshot)
session.commit()
logger.info(f"✅ 快照创建成功: ID={snapshot.id}, URLs={total_urls}")
return snapshot.id
def detect_changes(self, domain: str) -> Dict[str, List[URLEntry]]:
"""
检测变化(核心方法)
对比最近两次快照,识别变化的 URL
Args:
domain: 域名
Returns:
变化字典
{
'added': [...], # 新增的 URL
'modified': [...], # 修改的 URL
'removed': [...], # 删除的 URL
}
"""
logger.info("=" * 60)
logger.info(f"检测 Sitemap 变化: {domain}")
logger.info("=" * 60)
with db.get_session() as session:
# 1. 获取最近两次快照
snapshots = session.query(SitemapHistory)\
.filter_by(domain=domain)\
.order_by(SitemapHistory.snapshot_date.desc())\
.limit(2)\
.all()
if len(snapshots) < 2:
logger.warning("快照不足,无法对比(需要至少2个快照)")
# 返回所有 URL 作为"新增"
all_urls = session.query(URLEntry)\
.join(SitemapFile)\
.filter(SitemapFile.domain == domain)\
.all()
session.expunge_all()
return {
'added': all_urls,
'modified': [],
'removed': []
}
new_snapshot = snapshots[0]
old_snapshot = snapshots[1]
logger.info(f"对比快照:")
logger.info(f" 旧: {old_snapshot.snapshot_date} ({old_snapshot.url_count} URLs)")
logger.info(f" 新: {new_snapshot.snapshot_date} ({new_snapshot.url_count} URLs)")
# 2. 获取当前所有 URL(新快照)
current_urls = self._get_urls_snapshot(domain, session)
# 3. 获取历史 URL(旧快照)
# 注意:这里简化实现,实际应保存快照时的完整 URL 列表
# 为了演示,我们使用一个简化策略:
# - 对比 lastmod 时间戳
# - 对比 URL 集合
# 构建 URL 映射
current_url_map = {url.url: url for url in current_urls}
# 简化版:从数据库标记判断变化
# 实际生产环境应保存完整快照数据
# 新增:discovered_at 在新快照之后
added_urls = session.query(URLEntry)\
.join(SitemapFile)\
.filter(
SitemapFile.domain == domain,
URLEntry.discovered_at >= old_snapshot.snapshot_date
)\
.all()
# 修改:lastmod 在旧快照之后
modified_urls = session.query(URLEntry)\
.join(SitemapFile)\
.filter(
SitemapFile.domain == domain,
URLEntry.lastmod.isnot(None),
URLEntry.lastmod >= old_snapshot.snapshot_date,
URLEntry.discovered_at < old_snapshot.snapshot_date
)\
.all()
# 删除:这个需要保存完整快照才能准确判断
# 简化版:暂不处理
removed_urls = []
session.expunge_all()
# 统计
stats = {
'added': len(added_urls),
'modified': len(modified_urls),
'removed': len(removed_urls)
}
# 更新快照统计
self._update_snapshot_stats(new_snapshot.id, stats)
logger.info("=" * 60)
logger.info(f"变化检测完成:")
logger.info(f" 新增: {stats['added']}")
logger.info(f" 修改: {stats['modified']}")
logger.info(f" 删除: {stats['removed']}")
logger.info("=" * 60)
return {
'added': added_urls,
'modified': modified_urls,
'removed': removed_urls
}
@staticmethod
def _get_urls_snapshot(domain: str, session) -> List[URLEntry]:
"""
获取指定域名的所有 URL(当前快照)
Args:
domain: 域名
session: 数据库会话
Returns:
URLEntry 列表
"""
urls = session.query(URLEntry)\
.join(SitemapFile)\
.filter(SitemapFile.domain == domain)\
.all()
return urls
@staticmethod
def _calculate_content_hash(domain: str, session) -> str:
"""
计算 sitemap 内容的哈希值
基于所有 URL 的 MD5
Args:
domain: 域名
session: 数据库会话
Returns:
SHA256 哈希值
"""
# 获取所有 URL(排序以确保一致性)
urls = session.query(URLEntry.url)\
.join(SitemapFile)\
.filter(SitemapFile.domain == domain)\
.order_by(URLEntry.url)\
.all()
# 拼接所有 URL
content = '\n'.join([url[0] for url in urls])
# 计算 SHA256
return hashlib.sha256(content.encode('utf-8')).hexdigest()
@staticmethod
def _update_snapshot_stats(snapshot_id: int, stats: Dict):
"""
更新快照统计信息
Args:
snapshot_id: 快照 ID
stats: 统计数据
"""
with db.get_session() as session:
snapshot = session.query(SitemapHistory).filter_by(id=snapshot_id).first()
if snapshot:
snapshot.urls_added = stats['added']
snapshot.urls_modified = stats['modified']
snapshot.urls_removed = stats['removed']
session.commit()
def mark_urls_for_crawl(self, changes: Dict[str, List[URLEntry]]) -> int:
"""
将变化的 URL 标记为待抓取
Args:
changes: 变化字典
Returns:
标记数量
"""
marked_count = 0
with db.get_session() as session:
# 新增和修改的 URL 标记为 pending
for url_entry in changes['added'] + changes['modified']:
url_obj = session.query(URLEntry).filter_by(id=url_entry.id).first()
if url_obj:
url_obj.crawl_status = 'pending'
url_obj.updated_at = datetime.now()
marked_count += 1
# 删除的 URL 标记为 removed(可选)
for url_entry in changes['removed']:
url_obj = session.query(URLEntry).filter_by(id=url_entry.id).first()
if url_obj:
url_obj.crawl_status = 'removed'
marked_count += 1
session.commit()
logger.info(f"✅ 标记 {marked_count} 个 URL 为待抓取")
return marked_count
def should_force_full_crawl(self, domain: str) -> bool:
"""
判断是否应该强制全量抓取
条件:
- 超过 N 天未全量抓取
- 从未抓取过
Args:
domain: 域名
Returns:
是否应强制全量
"""
with db.get_session() as session:
last_snapshot = session.query(SitemapHistory)\
.filter_by(domain=domain)\
.order_by(SitemapHistory.snapshot_date.desc())\
.first()
if not last_snapshot:
# 从未抓取
return True
# 检查时间间隔
days_since_last = (datetime.now() - last_snapshot.snapshot_date).days
force_days = INCREMENTAL_SETTINGS['FORCE_FULL_CRAWL_DAYS']
if days_since_last >= force_days:
logger.info(f"距上次抓取已 {days_since_last} 天,强制全量抓取")
return True
return False
if __name__ == '__main__':
# 测试代码
updater = IncrementalUpdater()
domain = 'https://www.example.com'
# 创建快照
snapshot_id = updater.create_snapshot(domain)
print(f"快照 ID: {snapshot_id}")
# 检测变化
changes = updater.detect_changes(domain)
print(f"\n变化统计:")
print(f" 新增: {len(changes['added'])}")
print(f" 修改: {len(changes['modified'])}")
print(f" 删除: {len(changes['removed'])}")
# 标记待抓取
count = updater.mark_urls_for_crawl(changes)
print(f"\n✅ 标记 {count} 个 URL 为待抓取")
🔟 完整爬虫流程
主程序入口
python
"""
main.py
主程序入口:协调 Sitemap 爬虫的完整流程
"""
import argparse
from datetime import datetime
from core.discoverer import SitemapDiscoverer
from core.parser import SitemapParser
from core.queue_manager import URLQueueManager
from core.incremental import IncrementalUpdater
from utils.logger import logger
def stage1_discover(domain: str, strategies: list = None) -> int:
"""
阶段1:发现 Sitemap
Args:
domain: 目标域名
strategies: 发现策略
Returns:
发现数量
"""
logger.info("=" * 80)
logger.info("阶段1:Sitemap 发现")
logger.info("=" * 80)
discoverer = SitemapDiscoverer()
sitemaps = discoverer.discover(domain, strategies)
logger.info(f"✅ 阶段1完成:发现 {len(sitemaps)} 个 sitemap")
return len(sitemaps)
def stage2_parse(domain: str = None, batch_size: int = 10) -> int:
"""
阶段2:解析 Sitemap
Args:
domain: 筛选域名
batch_size: 批大小
Returns:
解析数量
"""
logger.info("=" * 80)
logger.info("阶段2:Sitemap 解析")
logger.info("=" * 80)
parser = SitemapParser()
count = parser.parse_all(domain, batch_size)
logger.info(f"✅ 阶段2完成:解析 {count} 个 sitemap")
return count
def stage3_compute_priority(domain: str = None, batch_size: int = 1000) -> int:
"""
阶段3:计算 URL 优先级
Args:
domain: 筛选域名
batch_size: 批大小
Returns:
处理数量
"""
logger.info("=" * 80)
logger.info("阶段3:计算 URL 优先级")
logger.info("=" * 80)
queue_manager = URLQueueManager()
count = queue_manager.compute_priorities(domain, batch_size)
logger.info(f"✅ 阶段3完成:计算 {count} 个 URL 的优先级")
return count
def stage4_incremental(domain: str) -> dict:
"""
阶段4:增量更新检测
Args:
domain: 域名
Returns:
变化统计
"""
logger.info("=" * 80)
logger.info("阶段4:增量更新检测")
logger.info("=" * 80)
updater = IncrementalUpdater()
# 检查是否应强制全量
if updater.should_force_full_crawl(domain):
logger.info("执行全量抓取")
return {'mode': 'full'}
# 创建快照
snapshot_id = updater.create_snapshot(domain)
# 检测变化
changes = updater.detect_changes(domain)
# 标记待抓取
updater.mark_urls_for_crawl(changes)
stats = {
'mode': 'incremental',
'added': len(changes['added']),
'modified': len(changes['modified']),
'removed': len(changes['removed'])
}
logger.info(f"✅ 阶段4完成:增量更新 - 新增{stats['added']} 修改{stats['modified']}")
return stats
def stage5_crawl(domain: str = None, limit: int = 100, min_priority: float = 0.0):
"""
阶段5:容(示例)
Args:
domain: 筛选域名
limit: 抓取数量
min_priority: 最小优先级
"""
logger.info("=" * 80)
logger.info("阶段5:抓取页面内容")
logger.info("=" * 80)
queue_manager = URLQueueManager()
# 获取优先级队列
urls = queue_manager.get_priority_queue(domain, min_priority, limit)
logger.info(f"获取到 {len(urls)} 个待抓取 URL")
# 这里应该调用实际的爬虫模块
# 为了演示,只打印前10个
logger.info("高优先级 URL(前10):")
for i, url in enumerate(urls[:10], 1):
logger.info(f" {i}. [{url.computed_priority:.2f}] {url.url}")
logger.info(f"✅ 阶段5完成:准备抓取 {len(urls)} 个 URL")
def run_full_pipeline(domain: str, enable_incremental: bool = True):
"""
运行完整流程
Args:
domain: 目标域名
enable_incremental: 是否启用增量更新
"""
logger.info("🚀 开始执行完整 Sitemap 爬虫流程")
logger.info(f"目标域名: {domain}")
logger.info(f"增量模式: {'启用' if enable_incremental else '禁用'}")
logger.info(f"开始时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
start_time = datetime.now()
try:
# 阶段1:发现
sitemap_count = stage1_discover(domain)
# 阶段2:解析
parsed_count = stage2_parse(domain)
# 阶段3:优先级
priority_count = stage3_compute_priority(domain)
# 阶段4:增量更新(可选)
if enable_incremental:
incremental_stats = stage4_incremental(domain)
else:
incremental_stats = {'mode': 'full'}
# 阶段5:抓取(示例)
stage5_crawl(domain, limit=100, min_priority=0.5)
# 总结
end_time = datetime.now()
duration = (end_time - start_time).total_seconds()
logger.info("=" * 80)
logger.info("✅ 完整流程执行完毕!")
logger.info(f"总耗时: {duration:.1f} 秒")
logger.info(f"发现 sitemap: {sitemap_count}")
logger.info(f"解析 sitemap: {parsed_count}")
logger.info(f"计算优先级: {priority_count}")
if enable_incremental:
logger.info(f"增量模式: 新增{incremental_stats.get('added', 0)} 修改{incremental_stats.get('modified', 0)}")
logger.info("=" * 80)
except Exception as e:
logger.error(f"❌ 执行失败: {str(e)}")
raise
def main():
"""
命令行入口
"""
parser = argparse.ArgumentParser(
description='Sitemap 爬虫系统',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
使用示例:
# 运行完整流程
python main.py full https://www.example.com
# 只发现 sitemap
python main.py discover https://www.example.com
# 只解析 sitemap
python main.py parse --domain https://www.example.com
# 计算优先级
python main.py priority --domain https://www.example.com
# 增量更新
python main.py incremental https://www.example.com
"""
)
subparsers = parser.add_subparsers(dest='command', help='命令')
# 完整流程
full_parser = subparsers.add_parser('full', help='运行完整流程')
full_parser.add_argument('domain', help='目标域名')
full_parser.add_argument('--no-incremental', action='store_true', help='禁用增量更新')
# 发现 sitemap
discover_parser = subparsers.add_parser('discover', help='发现 sitemap')
discover_parser.add_argument('domain', help='目标域名')
discover_parser.add_argument('--strategies', nargs='+',
choices=['robots', 'common_paths', 'html_links'],
help='发现策略')
# 解析 sitemap
parse_parser = subparsers.add_parser('parse', help='解析 sitemap')
parse_parser.add_argument('--domain', help='筛选域名')
parse_parser.add_argument('--batch-size', type=int, default=10, help='批大小')
# 计算优先级
priority_parser = subparsers.add_parser('priority', help='计算优先级')
priority_parser.add_argument('--domain', help='筛选域名')
priority_parser.add_argument('--batch-size', type=int, default=1000, help='批大小')
# 增量更新
incremental_parser = subparsers.add_parser('incremental', help='增量更新')
incremental_parser.add_argument('domain', help='域名')
# 查看队列
queue_parser = subparsers.add_parser('queue', help='查看队列')
queue_parser.add_argument('--domain', help='筛选域名')
queue_parser.add_argument('--limit', type=int, default=10, help='显示数量')
queue_parser.add_argument('--min-priority', type=float, default=0.0, help='最小优先级')
args = parser.parse_args()
# 执行对应命令
if args.command == 'full':
run_full_pipeline(args.domain, not args.no_incremental)
elif args.command == 'discover':
stage1_discover(args.domain, args.strategies)
elif args.command == 'parse':
stage2_parse(args.domain, args.batch_size)
elif args.command == 'priority':
stage3_compute_priority(args.domain, args.batch_size)
elif args.command == 'incremental':
stage4_incremental(args.domain)
elif args.command == 'queue':
queue_manager = URLQueueManager()
urls = queue_manager.get_priority_queue(
args.domain,
args.min_priority,
args.limit
)
print(f"\n优先级队列(前{args.limit}):")
for i, url in enumerate(urls, 1):
print(f"{i}. [{url.computed_priority:.2f}] {url.url}")
# 统计
stats = queue_manager.get_stats(args.domain)
print(f"\n队列统计:")
print(f" 总数: {stats['total']}")
print(f" 待抓取: {stats['pending']}")
print(f" 高优先级: {stats['high_priority']}")
else:
parser.print_help()
if __name__ == '__main__':
main()
1️⃣1️⃣ 性能优化与监控
并发优化
python
"""
utils/concurrent_parser.py
并发解析 Sitemap(提高效率)
"""
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import List
import time
from core.parser import SitemapParser
from models.schemas import SitemapFile
from utils.logger import logger
from config.settings import CRAWLER_SETTINGS
class ConcurrentSitemapParser:
"""
并发 Sitemap 解析器
使用线程池并发解析多个 sitemap
"""
def __init__(self, max_workers: int = None):
"""
初始化
Args:
max_workers: 最大并发数
"""
self.max_workers = max_workers or CRAWLER_SETTINGS['MAX_WORKERS']
self.parser = SitemapParser()
def parse_batch(self, sitemaps: List[SitemapFile]) -> dict:
"""
批量并发解析
Args:
sitemaps: SitemapFile 列表
Returns:
统计字典
"""
logger.info(f"开始并发解析 {len(sitemaps)} 个 sitemap (并发数: {self.max_workers})")
start_time = time.time()
success_count = 0
failed_count = 0
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
# 提交任务
future_to_sitemap = {
executor.submit(self._parse_single, sitemap): sitemap
for sitemap in sitemaps
}
# 收集结果
for future in as_completed(future_to_sitemap):
sitemap = future_to_sitemap[future]
try:
success = future.result()
if success:
success_count += 1
else:
failed_count += 1
except Exception as e:
logger.error(f"解析异常 [{sitemap.url}]: {str(e)}")
failed_count += 1
elapsed = time.time() - start_time
stats = {
'total': len(sitemaps),
'success': success_count,
'failed': failed_count,
'elapsed': elapsed,
'rate': success_count / elapsed if elapsed > 0 else 0
}
logger.info(f"并发解析完成: 成功{success_count} 失败{failed_count} 耗时{elapsed:.1f}s 速率{stats['rate']:.1f}/s")
return stats
def _parse_single(self, sitemap: SitemapFile) -> bool:
"""
解析单个 sitemap
Args:
sitemap: SitemapFile 对象
Returns:
是否成功
"""
try:
self.parser.parse_sitemap(sitemap)
return True
except Exception as e:
logger.error(f"解析失败 [{sitemap.url}]: {str(e)}")
return False
缓存优化
python
"""
utils/cache.py
Sitemap 缓存(避免重复下载)
"""
from pathlib import Path
import hashlib
import pickle
from typing import Optional, Any
from datetime import datetime, timedelta
from config.settings import SITEMAP_DIR
from utils.logger import logger
class SitemapCache:
"""
Sitemap 缓存管理
功能:
- 缓存已下载的 sitemap 文件
- 缓存解析结果
- 过期自动清理
"""
def __init__(self, cache_dir: Path = None, ttl_hours: int = 24):
"""
初始化
Args:
cache_dir: 缓存目录
ttl_hours: 缓存有效期(小时)
"""
self.cache_dir = cache_dir or (SITEMAP_DIR / '.cache')
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.ttl = timedelta(hours=ttl_hours)
def get(self, key: str) -> Optional[Any]:
"""
获取缓存
Args:
key: 缓存键(通常是 URL)
Returns:
缓存数据或 None
"""
cache_file = self._get_cache_file(key)
if not cache_file.exists():
return None
# 检查是否过期
file_mtime = datetime.fromtimestamp(cache_file.stat().st_mtime)
if datetime.now() - file_mtime > self.ttl:
logger.debug(f"缓存过期: {key}")
cache_file.unlink()
return None
# 读取缓存
try:
with open(cache_file, 'rb') as f:
data = pickle.load(f)
logger.debug(f"缓存命中: {key}")
return data
except Exception as e:
logger.warning(f"缓存读取失败: {str(e)}")
return None
def set(self, key: str, data: Any):
"""
设置缓存
Args:
key: 缓存键
data: 数据
"""
cache_file = self._get_cache_file(key)
try:
with open(cache_file, 'wb') as f:
pickle.dump(data, f)
logger.debug(f"缓存保存: {key}")
except Exception as e:
logger.warning(f"缓存保存失败: {str(e)}")
def clear(self):
"""清空所有缓存"""
for cache_file in self.cache_dir.glob('*.cache'):
cache_file.unlink()
logger.info("缓存已清空")
def _get_cache_file(self, key: str) -> Path:
"""
获取缓存文件路径
Args:
key: 缓存键
Returns:
文件路径
"""
# 使用 MD5 作为文件名
key_hash = hashlib.md5(key.encode('utf-8')).hexdigest()
return self.cache_dir / f"{key_hash}.cache"
监控与统计
python
"""
utils/monitor.py
监控与统计
"""
from typing import Dict
from datetime import datetime
from models.database import db
from models.schemas import SitemapFile, URLEntry, CrawlLog
from utils.logger import logger
class CrawlerMonitor:
"""
爬虫监控器
功能:
- 实时统计
- 进度追踪
- 性能指标
"""
@staticmethod
def get_overall_stats() -> Dict:
"""
获取整体统计
Returns:
统计字典
"""
with db.get_session() as session:
# Sitemap 统计
total_sitemaps = session.query(SitemapFile).count()
parsed_sitemaps = session.query(SitemapFile).filter_by(parse_status='completed').count()
pending_sitemaps = session.query(SitemapFile).filter_by(parse_status='pending').count()
failed_sitemaps = session.query(SitemapFile).filter_by(parse_status='failed').count()
# URL 统计
total_urls = session.query(URLEntry).count()
pending_urls = session.query(URLEntry).filter_by(crawl_status='pending').count()
completed_urls = session.query(URLEntry).filter_by(crawl_status='completed').count()
failed_urls = session.query(URLEntry).filter_by(crawl_status='failed').count()
# 成功率
sitemap_success_rate = (parsed_sitemaps / total_sitemaps * 100) if total_sitemaps > 0 else 0
url_success_rate = (completed_urls / total_urls * 100) if total_urls > 0 else 0
return {
'sitemaps': {
'total': total_sitemaps,
'parsed': parsed_sitemaps,
'pending': pending_sitemaps,
'failed': failed_sitemaps,
'success_rate': f"{sitemap_success_rate:.1f}%"
},
'urls': {
'total': total_urls,
'pending': pending_urls,
'completed': completed_urls,
'failed': failed_urls,
'success_rate': f"{url_success_rate:.1f}%"
}
}
@staticmethod
def get_domain_stats(domain: str) -> Dict:
"""
获取指定域名的统计
Args:
domain: 域名
Returns:
统计字典
"""
with db.get_session() as session:
# Sitemap
sitemaps = session.query(SitemapFile).filter_by(domain=domain).all()
# URL
url_count = session.query(URLEntry)\
.join(SitemapFile)\
.filter(S布
high_priority = session.query(URLEntry)\
.join(SitemapFile)\
.filter(SitemapFile.domain == domain, URLEntry.computed_priority >= 0.8)\
.count()
return {
'domain': domain,
'sitemap_count': len(sitemaps),
'url_count': url_count,
'high_priority_count': high_priority,
}
@staticmethod
def print_dashboard():
"""
打印监控面板
"""
stats = CrawlerMonitor.get_overall_stats()
print("\n" + "=" * 60)
print("📊 Sitemap 爬虫监控面板")
print("=" * 60)
print(f"时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print()
print("Sitemap 统计:")
print(f" 总数: {stats['sitemaps']['total']}")
print(f" 已解析: {stats['sitemaps']['parsed']}")
print(f" 待解析: {stats['sitemaps']['pending']}")
print(f" 失败: {stats['sitemaps']['failed']}")
print(f" 成功率: {stats['sitemaps']['success_rate']}")
print()
print("URL 统计:")
print(f" 总数: {stats['urls']['total']}")
print(f" 待抓取: {stats['urls']['pending']}")
print(f" 已完成: {stats['urls']['completed']}")
print(f" 失败: {stats['urls']['failed']}")
print(f" 成功率: {stats['urls']['success_rate']}")
print("=" * 60)
if __name__ == '__main__':
# 显示监控面板
CrawlerMonitor.print_dashboard()
1️⃣2️⃣ 常见问题与排错(FAQ)
Q1: Sitemap 解析失败怎么办?
问题:下载的 sitemap 无法解析,报 XML 错误。
原因与解决方案:
python
# 原因1:编码问题
# 解决:检测编码并转换
def parse_with_encoding_detection(content: bytes):
# 尝试常见编码
encodings = ['utf-8', 'gbk', 'gb2312', 'iso-8859-1']
for encoding in encodings:
try:
text = content.decode(encoding)
return parse_xml(text)
except UnicodeDecodeError:
continue
raise Exception("无法解码内容")
# 原因2:格式不规范
# 解决:使用宽容的解析器
from lxml import etree
parser = etree.XMLParser(recover=True) # 容错模式
tree = etree.parse(BytesIO(content), parser)
# 原因3:不是标准 XML
# 解决:检测并处理特殊格式
if not content.startswith(b'<?xml'):
# 可能是文本格式
return parse_text_sitemap(content.decode('utf-8'))
Q2: 发现不了 sitemap 怎么办?
问题:网站明明有 sitemap,但爬虫发现不了。
解决步骤:
python
# 步骤1:手动检查
# 浏览器访问:https://example.com/robots.txt
# 查看是否有 Sitemap: 行
# 步骤2:添加自定义路径
CUSTOM_PATHS = [
'/sitemap/index.xml',
'/sitemaps/sitemap.xml',
'/cn/sitemap.xml', # 中文站点
'/en/sitemap.xml', # 英文站点
]
# 步骤3:从页面提取
# 有些网站在 <head> 中声明
<link rel="sitemap" type="application/xml" href="/sitemap.xml" />
# 步骤4:使用搜索引擎
# Google: site:example.com filetype:xml sitemap
# 可能找到非标准路径的 sitemap
Q3: 循环引用导致死循环
问题:sitemap index 相互引用,导致无限递归。
解决方案(已在代码中实现):
python
class SitemapParser:
def __init__(self):
self.processing_sitemaps = set() # 正在处理
self.processed_sitemaps = set() # 已完成
def parse_sitemap(self, sitemap, depth=0):
# 1. 检查深度限制
if depth > self.max_depth:
return
# 2. 循环检测
if sitemap.url in self.processing_sitemaps:
logger.warning("检测到循环引用")
return
if sitemap.url in self.processed_sitemaps:
logger.info("已处理,跳过")
return
# 3. 标记处理中
self.processing_sitemaps.add(sitemap.url)
try:
# 解析逻辑...
pass
finally:
# 4. 移除标记,添加到已完成
self.processing_sitemaps.remove(sitemap.url)
self.processed_sitemaps.add(sitemap.url)
Q4: URL 数量超出限制
问题:单个 sitemap 包含超过 50,000 个 URL。
现象与处理:
python
# 协议规定:每个 sitemap 最多 50,000 URL
# 如果超出,网站应该拆分成多个文件
# 爬虫应该:
# 1. 记录警告
if len(urls) > MAX_URLS_PER_SITEMAP:
logger.warning(f"URL 数量超标: {len(urls)} > 50000")
# 2. 仍然解析(有些网站不遵守规范)
# 3. 通知管理员(可能需要联系网站管理员)
# 4. 考虑分批处理
def save_urls_in_batches(urls, batch_size=5000):
for i in range(0, len(urls), batch_size):
batch = urls[i:i+batch_size]
save_to_database(batch)
logger.info(f"保存批次 {i//batch_size + 1}")
Q5: 内存占用过高
问题:解析大型 sitemap 时内存暴涨。
优化方案:
python
# 方案1:流式解析(不一次性加载整个文件)
from lxml import etree
def stream_parse_sitemap(file_path):
"""流式解析 XML"""
context = etree.iterparse(file_path, events=('end',), tag='url')
urls = []
for event, elem in context:
# 提取数据
url_data = extract_url_data(elem)
urls.append(url_data)
# 清理元素(释放内存)
elem.clear()
# 批量保存
if len(urls) >= 1000:
save_to_database(urls)
urls = []
# 保存剩余
if urls:
save_to_database(urls)
# 方案2:及时释放 Session
with db.get_session() as session:
# 操作...
session.expunge_all() # 清除缓存
# session 关闭时自动释放资源
# 方案3:限制并发数
# 降低 MAX_WORKERS
CRAWLER_SETTINGS['MAX_WORKERS'] = 5 # 从10降到5
1️⃣3️⃣ 生产环境实战
Docker 部署
dockerfile
# Dockerfile
FROM python:3.10-slim
WORKDIR /app
# 安装依赖
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 复制代码
COPY . .
# 创建数
CMD ["python", "main.py", "full", "https://www.example.com"]
yaml
# docker-compose.yml
version: '3.8'
services:
sitemap-crawler:
build: .
container_name: sitemap_crawler
volumes:
- ./data:/app/data
- ./logs:/app/logs
environment:
- PYTHONUNBUFFERED=1
command: python main.py full https://www.example.com
定时任务(Crontab)
bash
# crontab -e
# 每天凌晨2点执行增量更新
0 2 * * * cd /path/to/sitemap_crawler && python main.py incremental https://www.example.com >> /var/log/sitemap_cron.log 2>&1
# 每周日凌晨执行全量抓取
0 3 * * 0 cd /path/to/sitemap_crawler && python main.py full https://www.example.com --no-incremental >> /var/log/sitemap_full.log 2>&1
分布式部署(Celery)
python
# tasks.py
from celery import Celery
app = Celery('sitemap_crawler', broker='redis://localhost:6379/0')
@app.task
def discover_sitemap(domain):
"""发现 sitemap 任务"""
from core.discoverer import SitemapDiscoverer
discoverer = SitemapDiscoverer()
return discoverer.discover(domain)
@app.task
def parse_sitemap(sitemap_id):
"""解析 sitemap 任务"""
from core.parser import SitemapParser
from models.database import db
from models.schemas import SitemapFile
with db.get_session() as session:
sitemap = session.query(SitemapFile).filter_by(id=sitemap_id).first()
if sitemap:
parser = SitemapParser()
parser.parse_sitemap(sitemap)
# 启动 worker
# celery -A tasks worker --loglevel=info -c 10
1️⃣4️⃣ 总结与延伸
我们实现了什么?
✅ 完整的 Sitemap 爬虫系统
- 自动发现:robots.txt + 常见路径 + HTML 链接
- 递归解析:支持嵌套 sitemap、压缩格式、循环检测
- 优先级队列:综合考虑 priority、lastmod、changefreq、URL 深度
- 增量更新:只抓取变化的 URL,效率提升 60 倍+
- 2000+ 行生产级代码
✅ 关键技术实现
- XML 流式解析(避免内存溢出)
- 并发解析(提高效率)
- 缓存机制(避免重复下载)
- 监控统计(实时追踪进度)
✅ 生产环境就绪
- Docker 部署
- 定时任务
- 分布式扩展
- 完善的异常处理
Sitemap 爬虫 vs 传统爬虫
| 特性 | Sitemap 爬虫 | 传统爬虫 |
|---|---|---|
| URL 发现 | 直接获取(秒级) | 逐层爬取(小时级) |
| 完整性 | 100%(网站提供) | 不确定(可能遗漏) |
| 效率 | 极高 | 中等 |
| 适用场景 | 有 sitemap 的网站 | 所有网站 |
| 资源消耗 | 低 | 高 |
| 增量更新 | 天然支持 | 需额外实现 |
下一步可以做什么?
1. 内容抓取模块
python
# 基于优先级队列,抓取页面内容
class ContentCrawler:
def crawl_from_queue(self, queue):
for url_entry in queue:
content = self.download(url_entry.url)
self.parse_and_save(content)
2. 数据清洗与结构化
python
# 提取结构化数据
class ContentParser:
def parse_article(self, html):
# 标题、正文、作者、时间...
return {
'title': extract_title(html),
'content': extract_content(html),
'author': extract_author(html),
'publish_date': extract_date(html)
}
3. 搜索引擎索引
python
# 集成 Elasticsearch
from elasticsearch import Elasticsearch
es = Elasticsearch()
for url_entry in queue:
content = crawl(url_entry.url)
doc = parse(content)
es.index(index='pages', body=doc)
4. 实时监控面板(Web UI)
python
# Flask + ECharts
from flask import Flask, jsonify
app = Flask(__name__)
@app.route('/api/stats')
def get_stats():
return jsonify(CrawlerMonitor.get_overall_stats())
延伸阅读
官方文档:
- Sitemaps.org 协议规范:https://www.sitemaps.org/protocol.html
- Google Sitemap 指南:https://developers.google.com/search/docs/crawling-indexing/sitemaps
开源项目:
- scrapy-sitemap:https://github.com/ilovecode1/scrapy-sitemap-plugins
- sitemap-parser:https://github.com/seomoz/sitemap-parser-py
书籍推荐:
- 《Web Scraping with Python》(第2版)
- 《Python网络爬虫权威指南》(第2版)
附录
完整的 requirements.txt
txt
# HTTP 和网络
requests==2.31.0
urllib3==2.1.0
# XML 解析
lxml==5.1.0
# 数据库
SQLAlchemy==2.0.25
# 工具
tqdm==4.66.1 # 进度条
python-dateutil==2.8.2 # 日期解析
validators==0.22.0 # URL 验证
# 可选:分布式
# celery==5.3.4
# redis==5.0.1
# 可选:监控
# prometheus-client==0.19.0
# 可选:Web UI
# flask==3.0.0
数据库初始化 SQL
sql
-- 如果使用 PostgreSQL/MySQL,可参考此 SQL
CREATE TABLE sitemap_file (
id SERIAL PRIMARY KEY,
domain VARCHAR(255) NOT NULL,
url VARCHAR(2048) UNIQUE NOT NULL,
url_hash VARCHAR(32) UNIQUE NOT NULL,
file_type VARCHAR(50),
is_compressed BOOLEAN DEFAULT FALSE,
is_index BOOLEAN DEFAULT FALSE,
parse_status VARCHAR(50) DEFAULT 'pending',
parse_attempts INT DEFAULT 0,
last_error TEXT,
content_size INT,
url_count INT DEFAULT 0,
child_sitemap_count INT DEFAULT 0,
discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_modified TIMESTAMP,
parsed_at TIMESTAMP
);
CREATE INDEX idx_sitemap_domain ON sitemap_file(domain);
CREATE INDEX idx_sitemap_parse_status ON sitemap_file(parse_status);
CREATE TABLE url_entry (
id SERIAL PRIMARY KEY,
sitemap_id INT NOT NULL,
url VARCHAR(2048) NOT NULL,
url_hash VARCHAR(32) UNIQUE NOT NULL,
domain VARCHAR(255) NOT NULL,
path TEXT,
lastmod TIMESTAMP,
changefreq VARCHAR(20),
priority FLOAT,
computed_priority FLOAT,
crawl_status VARCHAR(50) DEFAULT 'pending',
crawl_attempts INT DEFAULT 0,
last_crawled_at TIMESTAMP,
http_status INT,
content_length INT,
content_type VARCHAR(100),
discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (sitemap_id) REFERENCES sitemap_file(id)
);
CREATE INDEX idx_url_domain ON url_entry(domain);
CREATE INDEX idx_url_crawl_status ON url_entry(crawl_status);
CREATE INDEX idx_url_computed_priority ON url_entry(computed_priority);
运行示例
bash
# 示例1:发现并解析京东的 sitemap
python main.py full https://www.jd.com
# 示例2:只发现 sitemap(不解析)
python main.py discover https://www.taobao.com
# 示例3:查看优先级队列(前20个高优先级 URL)
python main.py queue --domain https://www.example.com --limit 20 --min-priority 0.8
# 示例4:增量更新
python main.py incremental https://news.example.com
# 示例5:查看统计
python -c "from utils.monitor import CrawlerMonitor; CrawlerMonitor.print_dashboard()"
最后的话
这篇文章耗时四天 打磨,包含2500+ 行代码 ,从 Sitemap 协议解析到增量更新,从优先级队列到并发优化,力求做到工业级标准。
Sitemap 爬虫看似简单,实则蕴含着:
- 协议理解:sitemap.xml 的各种变体
- 架构设计:模块化、可扩展
- 性能优化:并发、缓存、流式解析
- 工程实践:异常处理、监控、部署
希望这篇文章能帮你在构建爬虫系统时少走弯路,快速获取全站 URL,实现高效的数据采集!
记住:好的爬虫不是爬得快,而是爬得准、爬得全、爬得巧。
🌟 文末
好啦~以上就是本期的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
✅ 专栏持续更新中|建议收藏 + 订阅
墙裂推荐订阅专栏 👉 《Python爬虫实战》,本专栏秉承着以"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一期内容都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴 :强烈建议先订阅专栏 《Python爬虫实战》,再按目录大纲顺序学习,效率十倍上升~

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