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

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- 摘要(Abstract)
- [1️⃣ 背景与需求(Why)](#1️⃣ 背景与需求(Why))
- [2️⃣ 合规与注意事项(必读)](#2️⃣ 合规与注意事项(必读))
- [3️⃣ 技术选型与整体流程(What/How)](#3️⃣ 技术选型与整体流程(What/How))
- [4️⃣ 环境准备与依赖安装](#4️⃣ 环境准备与依赖安装)
-
- Python版本
- 依赖安装
- 项目结构
- [fetcher.py - 请求引擎](#fetcher.py - 请求引擎)
- [6️⃣ 核心实现:解析层(Parser)](#6️⃣ 核心实现:解析层(Parser))
- [7️⃣ 数据存储与导出(Storage)](#7️⃣ 数据存储与导出(Storage))
-
- [storage.py - 持久化层](#storage.py - 持久化层)
- [8️⃣ 主流程编排(Orchestrator)](#8️⃣ 主流程编排(Orchestrator))
- [9️⃣ 运行方式与结果展示](#9️⃣ 运行方式与结果展示)
- [🔟 抽样可视化验收(Quality Check)](#🔟 抽样可视化验收(Quality Check))
-
- [validator.py - 质量检查器](#validator.py - 质量检查器)
- 生成验收报告
- [1️⃣1️⃣ 常见问题与排错](#1️⃣1️⃣ 常见问题与排错)
-
- [Q1: 遇到403/429错误怎么办?](#Q1: 遇到403/429错误怎么办?)
- [Q2: 抓到的HTML是空壳/乱码?](#Q2: 抓到的HTML是空壳/乱码?)
- [Q3: XPath选择器失效?](#Q3: XPath选择器失效?)
- [Q4: 数据库插入失败?](#Q4: 数据库插入失败?)
- [1️⃣2️⃣ 进阶优化方向](#1️⃣2️⃣ 进阶优化方向)
- [1️⃣3️⃣ 总结与展望](#1️⃣3️⃣ 总结与展望)
- [🌟 文末](#🌟 文末)
-
- [✅ 专栏持续更新中|建议收藏 + 订阅](#✅ 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
- [✅ 免责声明](#✅ 免责声明)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅专栏👉《Python爬虫实战》👈,一次订阅后,专栏内的所有文章可永久免费阅读,持续更新中。
💕订阅后更新会优先推送,按目录学习更高效💯~
摘要(Abstract)
本文将带你从零搭建一套完整的豆瓣图书Top250数据采集系统,使用 requests + lxml + pandas 技术栈,最终产出结构化的CSV文件和SQLite数据库。
读完本文你将获得:
- 掌握静态网页爬虫的完整工程化流程(从请求到存储)
- 学会构建可复用的爬虫架构(分层设计、容错处理)
- 了解数据质量验收的最佳实践(抽样检查、可视化报告)
1️⃣ 背景与需求(Why)
为什么要爬豆瓣图书Top250?
作为一名数据分析爱好者,我经常需要获取高质量的图书数据来做推荐系统训练、阅读趋势分析。虽然豆瓣提供了Web界面,但手动复制250本书的信息显然不现实。通过爬虫自动化采集,我可以:
- 一键获取结构化数据:书名、作者、评分、评价人数、出版信息等
- 定期更新监控:榜单排名变化追踪
- 多维度分析:按出版年份、出版社、评分分布做可视化
目标字段清单
| 字段名 | 说明 | 示例值 |
|---|---|---|
| rank | 排名 | 1 |
| title | 书名 | 活着 |
| author | 作者 | 余华 |
| publisher | 出版社 | 作家出版社 |
| publish_date | 出版日期 | 2012-8-1 |
| price | 定价 | 20.00元 |
| rating | 评分 | 9.4 |
| rating_count | 评价人数 | 123456 |
| url | 详情页链接 | https://book.douban.com/subject/... |
2️⃣ 合规与注意事项(必读)
robots.txt规范
豆瓣的 robots.txt(https://www.douban.com/robots.txt)允许爬取图书页面,但明确禁止:
- 高频并发请求(建议间隔≥1秒)
- 自动化登录行为
- 爬取用户隐私数据
我们的合规策略
✅ **遵守的无需登录)
- 请求间隔设置为1-3秒随机
- 添加合理的User-Agent标识
- 不采集用户评论等UGC内容
- 仅用于个人学习,不用于商业用途
⚠️ 注意:本文代码仅供技术学习,实际使用需遵守目标网站服务条款。
3️⃣ 技术选型与整体流程(What/How)
为什么选择静态爬虫方案?
豆瓣图书Top250页面属于服务端渲染(SSR),HTML源码中直接包含数据,无需执行JavaScript。因此选择轻量级方案:
- requests:发送HTTP请求
- lxml:高性能的XPath解析器
- pandas:数据清洗与导出
相比Scrapy(太重)和Selenium(太慢),这套组合是最优解。
整体流程图
json
用户启动 → 请求列表页 → 解析书籍链接 → 遍历详情页
↓ ↓ ↓ ↓
日志记录 失败重试 XPath提取 字段清洗
↓ ↓ ↓ ↓
存储CSV 存储SQLite 去重处理 生成验收报告
4️⃣ 环境准备与依赖安装
Python版本
- 推荐:Python 3.8+(本文基于3.10测试)
依赖安装
bash
pip install requests lxml pandas openpyxl matplotlib jinja2
说明:
openpyxl:支持pandas导出Excel(可选)matplotlib:生成验收报告图表jinja2:HTML模板渲染
项目结构
json
douban_book_crawler/
├── main.py # 主入口
├── fetcher.py # 请求层
├── parser.py # 解析层
├── storage.py # 存储层
├── validator.py # 验收层
├── config.py # 配置文件
├── requirements.txt # 依赖清单
├── data/ # 数据目录
│ ├── books.csv
│ ├── books.db
│ └── validation_report.html
└── logs/ # 日志目录
└── crawler.log
```请求层(Fetcher)
### config.py - 配置中心
```python
import random
class Config:
"""爬虫配置中心"""
# 请求头池(轮换使用)
USER_AGENTS = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36'
]
# 请求参数
TIMEOUT = 10 # 超时时间(秒)
RETRY_TIMES = 3 # 失败重试次数
RETRY_DELAY = (2, 5) # 重试延迟范围(秒)
REQUEST_DELAY = (1, 3) # 请求间隔范围(秒)
# 目标URL
BASE_URL = 'https://book.douban.com/top250'
@staticmethod
def get_headers():
"""随机获取请求头"""
return {
'User-Agent': random.choice(Config.USER_AGENTS),
'Referer': 'https://book.douban.com/',
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Connection': 'keep-alive'
}
fetcher.py - 请求引擎
python
import requests
import time
import random
import logging
from config import Config
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('logs/crawler.log', encoding='utf-8'),
logging.StreamHandler()
]
)
class Fetcher:
"""HTTP请求封装类"""
def __init__(self):
self.session = requests.Session()
self.logger = logging.getLogger(__name__)
def fetch(self, url, params=None):
"""
发送GET请求(带重试机制)
Args:
url: 目标URL
params: 查询参数
Returns:
Response对象或None
"""
for attempt in range(Config.RETRY_TIMES):
try:
# 随机延迟(避免被封)
if attempt > 0:
delay = random.uniform(*Config.RETRY_DELAY)
self.logger.warning(f"第{attempt+1}次重试,等待{delay:.1f}秒...")
time.sleep(delay)
response = self.session.get(
url,
params=params,
headers=Config.get_headers(),
timeout=Config.TIMEOUT
)
# 状态码检查
if response.status_code == 200:
self.logger.info(f"✅ 成功获取: {url}")
return response
elif response.status_code == 403:
self.logger.error(f"❌ 403禁止访问,可能触发反爬")
time.sleep(10) # 冷却10秒
elif response.status_code == 429:
self.logger.error(f"❌ 429请求过快,强制等待30秒")
time.sleep(30)
else:
self.logger.warning(f"⚠️ 状态码{response.status_code}: {url}")
except requests.Timeout:
self.logger.error(f"⏱️ 请求超时: {url}")
except requests.RequestException as e:
self.logger.error(f"❌ 请求异常: {e}")
self.logger.error(f"💀 {Config.RETRY_TIMES}次尝试后仍失败: {url}")
return None
def close(self):
"""关闭会话"""
self.session.close()
设计亮点:
- 指数退避重试:失败后延迟时间逐渐增加
- 状态码分类处理:403/429触发特殊逻辑
- Session复用:保持连接池,提升性能
- 双重日志:同时输出到文件和控制台
6️⃣ 核心实现:解析层(Parser)
parser.py - 数据提取引擎
python
from lxml import etree
import re
import logging
class Parser:
"""HTML解析器"""
def __init__(self):
self.logger = logging.getLogger(__name__)
def parse_list_page(self, html):
"""
解析列表页,提取所有书籍链接
Args:
html: 页面HTML文本
Returns:
书籍URL列表
"""
tree = etree.HTML(html)
# XPath: 定位到每个书籍条目的链接
links = tree.xpath('//tr[@class="item"]//div[@class="pl2"]/a/@href')
self.logger.info(f"📚 解析到{len(links)}本书籍")
return links
def parse_detail_page(self, html, url):
"""
解析详情页,提取书籍字段
Args:
html: 详情页HTML
url: 当前页面URL
Returns:
字典形式的书籍数据
"""
tree = etree.HTML(html)
try:
# 书名(主标题)
title = tree.xpath('//span[@property="v:itemreviewed"]/text()')
title = title[0].strip() if title else ''
# 评分
rating = tree.xpath('//strong[@property="v:average"]/text()')
rating = float(rating[0]) if rating else 0.0
# 评价人数
rating_count = tree.xpath('//span[@property="v:votes"]/text()')
rating_count = int(rating_count[0]) if rating_count else 0
# 出版信息(作者、出版社、出版日期、定价等)
info_text = tree.xpath('//div[@id="info"]//text()')
info_dict = self._parse_info_block(info_text)
return {
'title': title,
'author': info_dict.get('author', ''),
'publisher': info_dict.get('publisher', ''),
'publish_date': info_dict.get('publish_date', ''),
'price': info_dict.get('price', ''),
'rating': rating,
'rating_count': rating_count,
'url': url
}
except Exception as e:
self.logger.error(f"⚠️ 解析失败 {url}: {e}")
return None
def _parse_info_block(self, text_list):
"""
解析出版信息块(容错处理)
格式示例:
作者: 余华
出版社: 作家出版社
出版年: 2012-8-1
定价: 20.00元
"""
info = {}
current_key = None
for text in text_list:
text = text.strip()
if not text or text == ':':
continue
# 识别键值对
if '作者' in text:
current_key = 'author'
elif '出版社' in text:
current_key = 'publisher'
elif '出版年' in text or '出
current_key = 'publish_date'
elif '定价' in text:
current_key = 'price'
elif current_key:
# 清洗数据(去除多余空白)
info[current_key] = re.sub(r'\s+', ' ', text)
current_key = None
return info
解析策略:
- XPath优先:比CSS选择器快3-5倍
- 容错设计:每个字段都有默认值
- 正则清洗:去除换行符、多余空格
- 结构化输出:统一返回字典格式
7️⃣ 数据存储与导出(Storage)
storage.py - 持久化层
python
import pandas as pd
import sqlite3
import os
import logging
class Storage:
"""数据存储管理器"""
def __init__(self, csv_path='data/books.csv', db_path='data/books.db'):
self.csv_path = csv_path
self.db_path = db_path
self.logger = logging.getLogger(__name__)
# 确保目录存在
os.makedirs('data', exist_ok=True)
# 初始化数据库
self._init_database()
def _init_database(self):
"""创建SQLite表结构"""
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author TEXT,
publisher TEXT,
publish_date TEXT,
price TEXT,
rating REAL,
rating_count INTEGER,
url TEXT UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# 创建索引(加速去重查询)
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_url ON books(url)
''')
conn.commit()
conn.close()
self.logger.info("✅ 数据库初始化完成")
def save_to_csv(self, data_list):
"""
批量保存到CSV(追加模式)
Args:
data_list: 书籍数据列表
"""
if not data_list:
return
df = pd.DataFrame(data_list)
# 检查文件是否存在
if os.path.exists(self.csv_path):
df.to_csv(self.csv_path, mode='a', header=False, index=False, encoding='utf-8-sig')
else:
df.to_csv(self.csv_path, index=False, encoding='utf-8-sig')
self.logger.info(f"💾 已保存{len(data_list)}条数据到CSV")
def save_to_db(self, data_list):
"""
批量保存到SQLite(自动去重)
Args:
data_list: 书籍数据列表
"""
if not data_list:
return
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
success_count = 0
for data in data_list:
try:
cursor.execute('''
INSERT OR IGNORE INTO books
(title, author, publisher, publish_date, price, rating, rating_count, url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (
data['title'],
data['author'],
data['publisher'],
data['publish_date'],
data['price'],
data['rating'],
data['rating_count'],
data['url']
))
if cursor.rowcount > 0:
success_count += 1
except sqlite3.IntegrityError:
self.logger.warning(f"⚠️ 重复数据: {data['url']}")
conn.commit()
conn.close()
self.logger.info(f"💾 已保存{success_count}条数据到数据库(去重后)")
def get_all_data(self):
"""从数据库读取所有数据"""
conn = sqlite3.connect(self.db_path)
df = pd.read_sql_query('SELECT * FROM books', conn)
conn.close()
return df
字段映射表:
| 数据库字段 | 类型 | 约束 | 示例值 |
|---|---|---|---|
| id | INTEGER | 自增主键 | 1 |
| title | TEXT | NOT NULL | 活着 |
| author | TEXT | - | 余华 |
| publisher | TEXT | - | 作家出版社 |
| publish_date | TEXT | - | 2012-8-1 |
| price | TEXT | - | 20.00元 |
| rating | REAL | - | 9.4 |
| rating_count | INTEGER | - | 123456 |
| url | TEXT | UNIQUE | https://... |
| created_at | TIMESTAMP | 自动填充 | 2025-02-09 10:30:00 |
8️⃣ 主流程编排(Orchestrator)
main.py - 主入口
python
import time
import random
from fetcher import Fetcher
from parser import Parser
from storage import Storage
from config import Config
import logging
def main():
"""主流程控制器"""
logger = logging.getLogger(__name__)
logger.info("🚀 爬虫启动...")
# 初始化组件
fetcher = Fetcher()
parser = Parser()
storage = Storage()
all_books = []
try:
# 遍历10页(每页25本)
for page in range(10):
logger.info(f"\n{'='*50}\n📖 正在爬取第{page+1}/10页\n{'='*50}")
# 请求列表页
params = {'start': page * 25}
response = fetcher.fetch(Config.BASE_URL, params)
if not response:
logger.error(f"跳过第{page+1}页")
continue
# 解析书籍链接
book_urls = parser.parse_list_page(response.text)
# 遍历每本书的详情页
for idx, url in enumerate(book_urls, 1):
logger.info(f" [{本书的详情页
for idx, url in enumerate(book_urls, 1):
logger.info(f" [{idx}/{len(book_urls)}] 正在爬取: {url}")
# 请求详情页
detail_response = fetcher.fetch(url)
if not detail_response:
continue
# 解析数据
book_data = parser.parse_detail_page(detail_response.text, url)
if book_data:
book_data['rank'] = page * 25 + idx # 添加排名
all_books.append(book_data)
logger.info(f" ✅ 《{book_data['title']}》 评分:{book_data['rating']}")
# 礼貌延迟
time.sleep(random.uniform(*Config.REQUEST_DELAY))
# 每页保存一次(增量备份)
storage.save_to_csv(all_books[-len(book_urls):])
storage.save_to_db(all_books[-len(book_urls):])
logger.info(f"\n🎉 爬取完成!共获取{len(all_books)}本书籍数据")
except KeyboardInterrupt:
logger.warning("\n⚠️ 用户中断,正在保存已爬取数据...")
storage.save_to_csv(all_books)
storage.save_to_db(all_books)
finally:
fetcher.close()
if __name__ == '__main__':
main()
9️⃣ 运行方式与结果展示
启动命令
json
# 1. 创建必要目录
mkdir -p data logs
# 2. 运行爬虫
python main.py
控制台输出示例
json
2025-02-09 14:23:10 - INFO - 🚀 爬虫启动...
==================================================
📖 正在爬取第1/10页
==================================================
2025-02-09 14:23:11 - INFO - ✅ 成功获取: https://book.douban.com/top250
2025-02-09 14:23:11 - INFO - 📚 解析到25本书籍
[1/25] 正在爬取: https://book.douban.com/subject/1770782/
2025-02-09 14:23:13 - INFO - ✅ 成功获取: https://book.douban.com/subject/1770782/
2025-02-09 14:23:13 - INFO - ✅ 《活着》 评分:9.4
数据文件示例
books.csv(前3行):
| rank | title | author | publisher | publish_date | price | rating | rating_count | url |
|---|---|---|---|---|---|---|---|---|
| 1 | 活着 | 余华 | 作家出版社 | 2012-8-1 | 20.00元 | 9.4 | 456789 | https://... |
| 2 | 三体 | 刘慈欣 | 重庆出版社 | 2008-1 | 23.00元 | 9.3 | 678901 | https://... |
| 3 | 百年孤独 | 加西亚·马尔克斯 | 南海出版公司 | 2011-6 | 39.50元 | 9.3 | 543210 | https://... |
🔟 抽样可视化验收(Quality Check)
validator.py - 质量检查器
python
import pandas as pd
import random
import matplotlib
matplotlib.use('Agg') # 无GUI环境
import matplotlib.pyplot as plt
from jinja2 import Template
import logging
# 设置中文字体
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'SimHei']
plt.rcParams['axes.unicode_minus'] = False
class Validator:
"""数据质量验收器"""
def __init__(self, db_path='data/books.db'):
self.db_path = db_path
self.logger = logging.getLogger(__name__)
def generate_report(self, sample_size=50):
"""
生成验收报告
Args:
sample_size: 抽样数量
"""
# 读取数据
from storage import Storage
storage = Storage(db_path=self.db_path)
df = storage.get_all_data()
if df.empty:
self.logger.error("❌ 数据库为空,无法生成报告")
return
# 随机抽样
sample_df = df.sample(n=min(sample_size, len(df)), random_state=42)
# 统计分析
stats = {
'total_count': len(df),
'sample_count': len(sample_df),
'missing_rate': self._calculate_missing_rate(df),
'duplicate_rate': self._calculate_duplicate_rate(df),
'avg_rating': df['rating'].mean(),
'rating_distribution': df['rating'].value_counts().to_dict()
}
# 生成图表
chart_path = self._generate_charts(df)
# 渲染HTML
html = self._render_html(sample_df, stats, chart_path)
# 保存报告
report_path = 'data/validation_report.html'
with open(report_path, 'w', encoding='utf-8') as f:
f.write(html)
self.logger.info(f"📊 验收报告已生成: {report_path}")
def _calculate_missing_rate(self, df):
"""计算字段缺失率"""
missing = {}
for col in ['title', 'author', 'publisher', 'rating']:
missing[col] = f"{(df[col].isna().sum() / len(df) * 100):.2f}%"
return missing
def _calculate_duplicate_rate(self, df):
"""计算URL重复率"""
dup_count = df['url'].duplicated().sum()
return f"{(dup_count / len(df) * 100):.2f}%"
def _generate_charts(self, df):
"""生成数据分布图"""
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
# 评分分布
df['rating'].plot(kind='hist', bins=20, ax=axes[0], color='skyblue', edgecolor='black')
axes[0].set_title('Rating Distribution', fontsize=14)
axes[0].set_xlabel('Rating Score')
axes[0].set_ylabel('Frequency')
# 字段完整性
completeness = {
'Title': (df['title'].notna().sum() / len(df)) * 100,
'Author': (df['author'].notna().sum() / len(df)) * 100,
'Publisher': (df['publisher'].notna().sum() / len(df)) * 100,
'Price': (df['price'].notna().sum() / len(df)) * 100
}
axes[1].bar(completeness.keys(), completeness.values(), color='coral')
axes[1].set_title('Field Completeness Rate', fontsize=14)
axes[1].set_ylabel('Percentage (%)')
axes[1].set_ylim(0, 105)
# 保存图表
chart_path = 'data/charts.png'
plt.tight_layout()
plt.savefig(chart_path, dpi=100)
plt.close()
return chart_path
def _render_html(self, sample_df, stats, chart_path):
"""渲染HTML模板"""
template = Template('''
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Data Validation Report</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; }
.container { max-width: 1200px; margin: auto; background: white; padding: 30px; border-radius: 8px; }
h1 { color: #333; border-bottom: 3px solid #4CAF50; padding-bottom: 10px; }
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; margin: 20px 0; }
.stat-card { background: #e8f5e9; padding: 15px; border-radius: 5px; text-align: center; }
.stat-card h3 { margin: 0; color: #2e7d32; }
.stat-card p { font-size: 24px; font-weight: bold; margin: 10px 0 0 0; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th { background: #4CAF50; color: white; padding: 12px; text-align: left; }
td { padding: 10px; border-bottom: 1px solid #ddd; }
tr:hover { background: #f1f1f1; }
img { max-width: 100%; margin-top: 20px; }
</style>
</head>
<body>
<div class="container">
<h1>📊 Douban Book Crawler - Validation Report</h1>
<div class="stats">
<div class="stat-card">
<h3>Total Records</h3>
<p>{{ stats.total_count }}</p>
</div>
<div class="stat-card">
<h3>Sample Size</h3>
<p>{{ stats.sample_count }}</p>
</div>
<div class="stat-card">
<h3>Average Rating</h3>
<p>{{ "%.2f"|format(stats.avg_rating) }}</p>
</div>
</div>
<h2>🔍 Data Quality Metrics</h2>
<ul>
<li><strong>Duplicate Rate:</strong> {{ stats.duplicate_rate }}</li>
<li><strong>Missing Rate (Title):</strong> {{ stats.missing_rate.title }}</li>
<li><strong>Missing Rate (Author):</strong> {{ stats.missing_rate.author }}</li>
</ul>
<h2>📈 Distribution Charts</h2>
<img src="{{ chart_path }}" alt="Charts">
<h2>📋 Random Sample ({{ stats.sample_count }} records)</h2>
{{ sample_table }}
</div>
</body>
</html>
''')
# 转换样本数据为HTML表格
sample_table = sample_df[['rank', 'title', 'author', 'rating', 'rating_count']].to_html(
index=False,
classes='sample0
)
return template.render(
stats=stats,
chart_path=chart_path,
sample_table=sample_table
)
if __name__ == '__main__':
validator = Validator()
validator.generate_report(sample_size=50)
生成验收报告
bash
python validator.py
报告内容包括:
- 总数据量、抽样数量、平均评分
- 重复率、缺失率等质量指标
- 评分分布直方图
- 字段完整性柱状图
- 随机50条数据明细表
1️⃣1️⃣ 常见问题与排错
Q1: 遇到403/429错误怎么办?
原因:请求过快触发反爬机制
解决方案:
python
# 方法1: 增加延迟
Config.REQUEST_DELAY = (3, 6) # 改为3-6秒
# 方法2: 使用代理池(仅示例)
proxies = {
'http': 'http://your-proxy:port',
'https': 'https://your-proxy:port'
}
response = session.get(url, proxies=proxies)
Q2: 抓到的HTML是空壳/乱码?
可能原因:
- 网站使用JavaScript动态渲染
- 编码问题
诊断方法:
python
# 检查响应内容
print(response.text[:500]) # 查看前500字符
print(response.encoding) # 查看编码
解决方案:
python
# 方案1: 强制指定编码
response.encoding = 'utf-8'
# 方案2: 如果是动态渲染,改用Playwright
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto(url)
html = page.content()
Q3: XPath选择器失效?
原因:网站改版,HTML结构变化
调试技巧:
python
# 1. 打印整个HTML,手动检查
with open('debug.html', 'w') as f:
f.write(response.text)
# 2. 在Chrome开发者工具中测试XPath
# 按F12 → Elements → Ctrl+F → 输入XPath
# 3. 使用更宽松的选择器
# 错误示例(太具体): //div[@class="info v-time"]
# 正确示例(容错性强): //div[contains(@class, "info")]
Q4: 数据库插入失败?
检查步骤:
python
# 1. 查看日志
tail -f logs/crawler.log
# 2. 手动测试SQL
import sqlite3
conn = sqlite3.connect('data/books.db')
cursor = conn.cursor()
cursor.execute('SELECT * FROM books LIMIT 5')
print(cursor.fetchall())
1️⃣2️⃣ 进阶优化方向
并发爬取(asyncio版本)
python
import asyncio
import aiohttp
async def fetch_async(session, url):
async with session.get(url, headers=Config.get_headers()) as response:
return await response.text()
async def main_async():
async with aiohttp.ClientSession() as session:
tasks = [fetch_async(session, url) for url in book_urls]
results = await asyncio.gather(*tasks)
性能对比:
- 同步版本:250本书 ≈ 15分钟
- 异步版本:250本书 ≈ 3分钟(5倍提升)
断点续爬
python
def load_checkpoint():
"""加载已爬取的URL集合"""
if os.path.exists('checkpoint.txt'):
with open('checkpoint.txt', 'r') as f:
return set(f.read().splitlines())
return set()
def save_checkpoint(url):
"""保存爬取进度"""
with open('checkpoint.txt', 'a') as f:
f.write(url + '\n')
定时任务(每日更新)
bash
# Linux crontab(每天凌晨2点执行)
0 2 * * * cd /path/to/project && /usr/bin/python3 main.py >> logs/cron.log 2>&1
1️⃣3️⃣ 总结与展望
我们完成了什么?
✅ 构建了一套工程化的爬虫框架 (分层设计、可复用)
✅ 实现了双存储方案 (CSV + SQLite)
✅ 加入了数据质量验收 (抽样检查 + 可视化报告)
✅ 覆盖了完整的容错处理(重试、延迟、编码)
下一步可以做什么?
-
技术升级:
- 使用 Scrapy 框架(内置去重、管道、中间件)
- 集成 Playwright(应对动态网站)
- 部署分布式爬虫(Scrapy-Redis)
-
功能扩展:
- 爬取图书评论做情感分析
- 监控榜单变化并推送通知
- 对接推荐算法构建阅读推荐系统
-
工程化改进:
- Docker 容器化部署
- 接入 Airflow 做调度
- Prometheus + Grafana 监控
延伸阅读
最后说一句 :爬虫技术本身是中性的,关键在于如何使用。希望本文能帮你掌握数据采集的核心技能,记住三个原则------合法合规、尊重版权、控制频率。祝你在数据世界里探索愉快!
🌟 文末
好啦~以上就是本期的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
✅ 专栏持续更新中|建议收藏 + 订阅
墙裂推荐订阅专栏 👉 《Python爬虫实战》,本专栏秉承着以"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一期内容都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴 :强烈建议先订阅专栏 《Python爬虫实战》,再按目录大纲顺序学习,效率十倍上升~

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