Day 10: 爬虫中间件与管道
学习目标
- 掌握 Scrapy 中间件的使用
- 学会设计和实现管道
- 了解错误处理机制
- 掌握爬虫配置和优化
技术原理
10.1 中间件概述
中间件是 Scrapy 处理请求和响应的钩子:
下载中间件:
- 处理请求(添加代理、User-Agent)
- 处理响应(重试、错误处理)
- 处理异常
爬虫中间件:
- 处理 Spider 的输入输出
- 处理 Item 和请求
- 处理异常
10.2 管道概述
管道用于处理 Spider 提取的数据:
主要功能:
- 数据清洗
- 数据验证
- 数据存储
10.3 配置和优化
主要配置项:
- 并发请求数
- 下载延迟
- 超时设置
- 重试策略
案例
案例1:下载中间件
python
# middlewares.py
import random
class RandomUserAgentMiddleware:
"""随机 User-Agent 中间件"""
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',
]
def process_request(self, request, spider):
"""处理请求"""
request.headers['User-Agent'] = random.choice(self.USER_AGENTS)
return None
def process_response(self, request, response, spider):
"""处理响应"""
return response
def process_exception(self, request, exception, spider):
"""处理异常"""
spider.logger.error(f'请求异常: {request.url}, {exception}')
return None
class ProxyMiddleware:
"""代理中间件"""
PROXIES = [
'http://proxy1:8080',
'http://proxy2:8080',
]
def process_request(self, request, spider):
"""处理请求"""
proxy = random.choice(self.PROXIES)
request.meta['proxy'] = proxy
return None
class RetryMiddleware:
"""重试中间件"""
def __init__(self, max_retry=3):
self.max_retry = max_retry
@classmethod
def from_crawler(cls, crawler):
max_retry = crawler.settings.getint('RETRY_TIMES', 3)
return cls(max_retry)
def process_response(self, request, response, spider):
"""处理响应"""
if response.status in [500, 502, 503, 504]:
retry_times = request.meta.get('retry_times', 0)
if retry_times < self.max_retry:
retry_request = request.copy()
retry_request.meta['retry_times'] = retry_times + 1
retry_request.dont_filter = True
spider.logger.info(f'重试请求: {request.url}, 第{retry_times + 1}次')
return retry_request
spider.logger.error(f'重试失败: {request.url}')
return response案例2:爬虫中间件
python
# middlewares.py
class SpiderMiddleware:
"""爬虫中间件"""
def process_spider_input(self, response, spider):
"""处理 Spider 输入"""
# 可以在这里检查响应
if response.status != 200:
spider.logger.warning(f'非200响应: {response.url}')
return None
def process_spider_output(self, response, result, spider):
"""处理 Spider 输出"""
# 可以过滤或修改 Item
for item in result:
yield item
def process_spider_exception(self, response, exception, spider):
"""处理 Spider 异常"""
spider.logger.error(f'Spider 异常: {response.url}, {exception}')
return None
def process_start_requests(self, start_requests, spider):
"""处理初始请求"""
for request in start_requests:
yield request应用场景
1. 反爬处理
- User-Agent 伪装
- 代理 IP 管理
- Cookie 管理
2. 数据处理
- 数据清洗
- 数据验证
- 数据存储
3. 错误处理
- 请求重试
- 异常捕获
- 日志记录
代码案例
案例3:完整的管道实现
python
# pipelines.py
import json
import csv
from datetime import datetime
class CleanPipeline:
"""清洗管道"""
def process_item(self, item, spider):
"""清洗数据"""
# 去除空白
for key, value in item.items():
if isinstance(value, str):
item[key] = value.strip()
# 添加爬取时间
item['crawl_time'] = datetime.now().isoformat()
# 添加爬虫名称
item['spider'] = spider.name
return item
class ValidationPipeline:
"""验证管道"""
def process_item(self, item, spider):
"""验证数据"""
# 检查必填字段
required_fields = ['name', 'price']
for field in required_fields:
if field not in item or not item[field]:
raise ValueError(f'缺少必填字段: {field}')
# 验证价格
try:
price = float(item['price'])
if price < 0:
raise ValueError(f'价格不能为负数: {price}')
item['price'] = price
except (ValueError, TypeError) as e:
raise ValueError(f'价格格式错误: {item["price"]}')
return item
class DuplicatesPipeline:
"""去重管道"""
def __init__(self):
self.seen_urls = set()
def process_item(self, item, spider):
"""去重"""
url = item.get('url')
if url in self.seen_urls:
raise ValueError(f'重复URL: {url}')
self.seen_urls.add(url)
return item
class JsonWriterPipeline:
"""JSON 写入管道"""
def __init__(self):
self.items = []
def process_item(self, item, spider):
"""收集 Item"""
self.items.append(dict(item))
return item
def close_spider(self, spider):
"""爬虫关闭时保存"""
filename = f'{spider.name}_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
with open(filename, 'w', encoding='utf-8') as f:
json.dump(self.items, f, ensure_ascii=False, indent=2)
spider.logger.info(f'保存 {len(self.items)} 条数据到 {filename}')
class CsvWriterPipeline:
"""CSV 写入管道"""
def __init__(self):
self.items = []
self.fields = None
def process_item(self, item, spider):
"""收集 Item"""
if self.fields is None:
self.fields = list(item.keys())
self.items.append(dict(item))
return item
def close_spider(self, spider):
"""爬虫关闭时保存"""
filename = f'{spider.name}_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
with open(filename, 'w', newline='', encoding='utf-8-sig') as f:
writer = csv.DictWriter(f, fieldnames=self.fields)
writer.writeheader()
writer.writerows(self.items)
spider.logger.info(f'保存 {len(self.items)} 条数据到 {filename}')案例4:MySQL 管道
python
# pipelines.py
import pymysql
class MySQLPipeline:
"""MySQL 管道"""
def __init__(self, host, port, user, password, database):
self.host = host
self.port = port
self.user = user
self.password = password
self.database = database
self.connection = None
self.cursor = None
@classmethod
def from_crawler(cls, crawler):
"""从配置创建"""
return cls(
host=crawler.settings.get('MYSQL_HOST', 'localhost'),
port=crawler.settings.getint('MYSQL_PORT', 3306),
user=crawler.settings.get('MYSQL_USER', 'root'),
password=crawler.settings.get('MYSQL_PASSWORD', ''),
database=crawler.settings.get('MYSQL_DATABASE', 'crawler')
)
def open_spider(self, spider):
"""爬虫启动时连接数据库"""
self.connection = pymysql.connect(
host=self.host,
port=self.port,
user=self.user,
password=self.password,
database=self.database,
charset='utf8mb4'
)
self.cursor = self.connection.cursor()
# 创建表
self.create_table(spider.name)
def close_spider(self, spider):
"""爬虫关闭时关闭连接"""
if self.cursor:
self.cursor.close()
if self.connection:
self.connection.close()
def create_table(self, table_name):
"""创建表"""
sql = f'''
CREATE TABLE IF NOT EXISTS {table_name} (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255),
price DECIMAL(10,2),
url VARCHAR(500),
crawl_time DATETIME,
INDEX idx_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
'''
self.cursor.execute(sql)
self.connection.commit()
def process_item(self, item, spider):
"""插入数据"""
sql = f'''
INSERT INTO {spider.name} (name, price, url, crawl_time)
VALUES (%s, %s, %s, %s)
'''
self.cursor.execute(sql, (
item.get('name'),
item.get('price'),
item.get('url'),
item.get('crawl_time')
))
self.connection.commit()
return item
# settings.py
ITEM_PIPELINES = {
'myproject.pipelines.CleanPipeline': 100,
'myproject.pipelines.ValidationPipeline': 200,
'myproject.pipelines.DuplicatesPipeline': 300,
'myproject.pipelines.MySQLPipeline': 400,
}
MYSQL_HOST = 'localhost'
MYSQL_PORT = 3306
MYSQL_USER = 'root'
MYSQL_PASSWORD = 'password'
MYSQL_DATABASE = 'crawler'案例5:MongoDB 管道
python
# pipelines.py
from pymongo import MongoClient
class MongoDBPipeline:
"""MongoDB 管道"""
def __init__(self, host, port, database, collection):
self.host = host
self.port = port
self.database = database
self.collection = collection
self.client = None
self.db = None
self.coll = None
@classmethod
def from_crawler(cls, crawler):
"""从配置创建"""
return cls(
host=crawler.settings.get('MONGO_HOST', 'localhost'),
port=crawler.settings.getint('MONGO_PORT', 27017),
database=crawler.settings.get('MONGO_DATABASE', 'crawler'),
collection=crawler.settings.get('MONGO_COLLECTION', 'items')
)
def open_spider(self, spider):
"""爬虫启动时连接数据库"""
self.client = MongoClient(self.host, self.port)
self.db = self.client[self.database]
self.coll = self.db[self.collection]
# 创建索引
self.coll.create_index('url', unique=True)
def close_spider(self, spider):
"""爬虫关闭时关闭连接"""
if self.client:
self.client.close()
def process_item(self, item, spider):
"""插入或更新数据"""
# 使用 upsert 避免重复
self.coll.update_one(
{'url': item.get('url')},
{'$set': dict(item)},
upsert=True
)
return item
# settings.py
ITEM_PIPELINES = {
'myproject.pipelines.CleanPipeline': 100,
'myproject.pipelines.MongoDBPipeline': 500,
}
MONGO_HOST = 'localhost'
MONGO_PORT = 27017
MONGO_DATABASE = 'crawler'
MONGO_COLLECTION = 'items'案例6:图片管道
python
# pipelines.py
import scrapy
from scrapy.pipelines.images import ImagesPipeline
from urllib.parse import urlparse
class CustomImagesPipeline(ImagesPipeline):
"""自定义图片管道"""
def get_media_requests(self, item, info):
"""获取图片下载请求"""
for image_url in item.get('image_urls', []):
yield scrapy.Request(image_url)
def file_path(self, request, response=None, info=None, *, item=None):
"""生成文件路径"""
# 从URL提取文件名
url = urlparse(request.url)
filename = url.path.split('/')[-1]
# 按日期分目录
from datetime import datetime
date_dir = datetime.now().strftime('%Y%m%d')
return f'{date_dir}/{filename}'
def item_completed(self, results, item, info):
"""处理完成后的回调"""
# 获取下载结果
image_paths = [x['path'] for ok, x in results if ok]
if not image_paths:
raise ValueError('图片下载失败')
item['image_paths'] = image_paths
return item
# settings.py
ITEM_PIPELINES = {
'myproject.pipelines.CustomImagesPipeline': 300,
}
IMAGES_STORE = './images'
IMAGES_MIN_HEIGHT = 100
IMAGES_MIN_WIDTH = 100案例7:错误处理管道
python
# pipelines.py
import logging
from datetime import datetime
class ErrorHandlingPipeline:
"""错误处理管道"""
def __init__(self):
self.logger = logging.getLogger(__name__)
self.errors = []
def process_item(self, item, spider):
"""处理 Item,捕获异常"""
try:
# 尝试处理 Item
processed = self.process_item_logic(item)
return processed
except Exception as e:
# 记录错误
error_info = {
'item': dict(item),
'error': str(e),
'time': datetime.now().isoformat()
}
self.errors.append(error_info)
self.logger.error(f'处理 Item 失败: {e}')
# 返回原始 Item,继续处理
return item
def process_item_logic(self, item):
"""处理逻辑"""
# 这里可以添加具体的处理逻辑
# 如果处理失败,会抛出异常
# 示例:验证价格
price = item.get('price')
if price is not None:
try:
item['price'] = float(price)
except (ValueError, TypeError):
raise ValueError(f'价格格式错误: {price}')
return item
def close_spider(self, spider):
"""爬虫关闭时保存错误日志"""
if self.errors:
import json
filename = f'errors_{spider.name}_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
with open(filename, 'w', encoding='utf-8') as f:
json.dump(self.errors, f, ensure_ascii=False, indent=2)
self.logger.warning(f'发现 {len(self.errors)} 个错误,已保存到 {filename}')案例8:统计管道
python
# pipelines.py
from collections import defaultdict
from datetime import datetime
class StatsPipeline:
"""统计管道"""
def __init__(self):
self.stats = defaultdict(int)
self.start_time = None
def open_spider(self, spider):
"""爬虫启动"""
self.start_time = datetime.now()
self.stats['start_time'] = self.start_time.isoformat()
def process_item(self, item, spider):
"""统计 Item"""
self.stats['total_items'] += 1
# 按爬虫统计
self.stats[f'spider_{spider.name}'] += 1
# 按字段统计
for key in item.keys():
self.stats[f'field_{key}'] += 1
return item
def close_spider(self, spider):
"""爬虫关闭时输出统计"""
end_time = datetime.now()
duration = (end_time - self.start_time).total_seconds()
self.stats['end_time'] = end_time.isoformat()
self.stats['duration_seconds'] = duration
# 输出统计信息
spider.logger.info('='*50)
spider.logger.info('爬虫统计信息:')
spider.logger.info(f'总耗时: {duration:.2f} 秒')
spider.logger.info(f'总 Item 数: {self.stats["total_items"]}')
if duration > 0:
speed = self.stats['total_items'] / duration
spider.logger.info(f'平均速度: {speed:.2f} 条/秒')
spider.logger.info('='*50)
# settings.py
ITEM_PIPELINES = {
'myproject.pipelines.CleanPipeline': 100,
'myproject.pipelines.ValidationPipeline': 200,
'myproject.pipelines.StatsPipeline': 900,
}课后练习
练习1:实现一个缓存中间件
实现一个支持本地文件缓存的中间件。
练习2:实现一个限流中间件
实现一个支持不同域名不同限流策略的中间件。
练习3:实现一个数据导出管道
实现一个支持导出到 Excel 的管道。
常见问题
Q1: 中间件的执行顺序是怎样的?
A: 数字越小,优先级越高。下载中间件先处理请求,再处理响应。
Q2: 管道的执行顺序是怎样的?
A: 数字越小,优先级越高。通常清洗管道在前,存储管道在后。
Q3: 如何调试中间件?
A: 使用 spider.logger 输出日志,或使用 scrapy shell 测试。