Skip to content

第11章 爬虫监控与日志管理

11.1 添加日志记录:logging 模块配置

在爬虫开发过程中,日志记录是必不可少的环节。它不仅能帮助我们追踪程序运行状态,还能在出现问题时快速定位错误。Python 的 logging 模块提供了强大的日志功能,让我们可以轻松地记录不同级别的信息。

logging 模块基础配置

logging 模块支持多种日志级别:DEBUG、INFO、WARNING、ERROR 和 CRITICAL。对于爬虫项目,我们通常需要记录请求状态、解析结果和异常信息。

python
# 导入 logging 模块
import logging

# 配置日志格式和级别
logging.basicConfig(
    level=logging.INFO,  # 设置日志级别为 INFO
    format='%(asctime)s - %(levelname)s - %(message)s',  # 设置日志格式
    handlers=[
        logging.FileHandler('crawler.log', encoding='utf-8'),  # 输出到文件
        logging.StreamHandler()  # 同时输出到控制台
    ]
)

# 记录不同级别的日志
logging.debug("这是一个调试信息")  # 不会显示,因为级别低于 INFO
logging.info("爬虫开始运行")  # 会显示
logging.warning("发现可疑响应")  # 会显示
logging.error("请求失败")  # 会显示

实例方法表格

功能名称实例调用方法具体功能、注意事项、必需参数/可选参数
基础配置logging.basicConfig()配置日志的基本设置,必需参数包括 level、format,可选参数包括 handlers、filename 等
记录信息logging.info(message)记录一般信息,message 为必需的字符串参数
记录警告logging.warning(message)记录警告信息,表示可能有问题但不影响程序继续运行
记录错误logging.error(message)记录错误信息,通常用于捕获异常后的记录

使用示例

python
import logging
import requests
from urllib.parse import urljoin

# 配置日志
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandlers('spider.log', encoding='utf-8'),
        logging.StreamHandler()
    ]
)

# 创建专门的日志记录器
logger = logging.getLogger('MySpider')

def fetch_page(url):
    """获取网页内容并记录日志"""
    try:
        # 记录请求开始
        logger.info(f"开始请求: {url}")
        
        # 发送请求
        response = requests.get(url, timeout=10)
        response.raise_for_status()  # 检查HTTP状态码
        
        # 记录成功信息
        logger.info(f"请求成功: {url}, 状态码: {response.status_code}")
        return response.text
        
    except requests.exceptions.RequestException as e:
        # 记录错误信息
        logger.error(f"请求失败: {url}, 错误: {str(e)}")
        return None

# 测试函数
if __name__ == "__main__":
    test_url = "https://httpbin.org/get"
    content = fetch_page(test_url)
    if content:
        logger.info("页面获取成功,准备解析...")
    else:
        logger.warning("页面获取失败,跳过解析")

注意事项:

  • 日志文件路径要确保程序有写入权限
  • 在生产环境中,建议将日志级别设置为 WARNING 或 ERROR,避免日志文件过大
  • 对于多线程爬虫,需要考虑日志的线程安全性
  • 日志格式中的时间戳默认使用本地时区,如需 UTC 时间可进行额外配置

日志记录是爬虫监控的基础,通过合理的日志配置,我们可以清晰地了解爬虫的运行状态和问题所在。

11.2 监控成功率与失败重试机制

在实际爬虫项目中,网络请求失败是常见现象。为了提高数据采集的成功率,我们需要建立完善的监控机制和重试策略。

请求成功率监控

成功率监控的核心是统计成功和失败的请求数量,并计算成功率百分比。这有助于我们评估爬虫的稳定性和目标网站的可访问性。

python
class RequestMonitor:
    """请求监控器类"""
    
    def __init__(self):
        self.success_count = 0  # 成功请求数
        self.failure_count = 0  # 失败请求数
        self.total_requests = 0  # 总请求数
        
    def record_success(self):
        """记录成功请求"""
        self.success_count += 1
        self.total_requests += 1
        
    def record_failure(self):
        """记录失败请求"""
        self.failure_count += 1
        self.total_requests += 1
        
    def get_success_rate(self):
        """计算成功率"""
        if self.total_requests == 0:
            return 0.0
        return (self.success_count / self.total_requests) * 100
        
    def get_statistics(self):
        """获取统计信息"""
        return {
            'total': self.total_requests,
            'success': self.success_count,
            'failure': self.failure_count,
            'success_rate': f"{self.get_success_rate():.2f}%"
        }

失败重试机制

重试机制需要考虑重试次数、重试间隔和退避策略。简单的线性重试可能会对服务器造成压力,而指数退避策略更为友好。

python
import time
import random
from functools import wraps

def retry(max_attempts=3, delay=1, backoff=2, exceptions=(Exception,)):
    """
    重试装饰器
    
    参数:
    max_attempts: 最大重试次数
    delay: 初始延迟时间(秒)
    backoff: 退避因子(每次重试延迟时间乘以该因子)
    exceptions: 需要重试的异常类型
    """
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            current_delay = delay
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    if attempt == max_attempts - 1:
                        # 最后一次尝试失败,抛出异常
                        raise e
                    
                    # 记录重试信息
                    logging.warning(f"第{attempt + 1}次尝试失败: {str(e)}{current_delay}秒后重试")
                    
                    # 等待指定时间
                    time.sleep(current_delay)
                    
                    # 应用退避策略
                    current_delay *= backoff
                    
                    # 添加随机抖动,避免多个爬虫同时重试
                    current_delay += random.uniform(0, 1)
                    
            return None
        return wrapper
    return decorator

实例方法表格

功能名称实例调用方法具体功能、注意事项、必需参数/可选参数
记录成功monitor.record_success()记录一次成功的请求,无参数
记录失败monitor.record_failure()记录一次失败的请求,无参数
获取统计monitor.get_statistics()返回包含统计信息的字典,无参数
重试装饰@retry(max_attempts=3, delay=1)装饰需要重试的函数,必需参数为被装饰的函数

使用示例

python
import logging
import requests
from urllib.parse import urljoin

# 配置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

# 创建监控器实例
monitor = RequestMonitor()

@retry(max_attempts=3, delay=2, backoff=1.5, exceptions=(requests.RequestException,))
def fetch_with_retry(url):
    """带重试机制的请求函数"""
    try:
        response = requests.get(url, timeout=10)
        response.raise_for_status()
        monitor.record_success()  # 记录成功
        return response.text
    except requests.RequestException as e:
        monitor.record_failure()  # 记录失败
        raise e  # 重新抛出异常,让重试机制处理

# 测试监控和重试
if __name__ == "__main__":
    test_urls = [
        "https://httpbin.org/get",
        "https://httpbin.org/status/500",  # 这个会返回500错误
        "https://httpbin.org/delay/3"  # 这个会有延迟
    ]
    
    for url in test_urls:
        try:
            content = fetch_with_retry(url)
            logger.info(f"成功获取: {url}")
        except Exception as e:
            logger.error(f"最终失败: {url}, 错误: {str(e)}")
    
    # 输出统计信息
    stats = monitor.get_statistics()
    logger.info(f"爬取统计: {stats}")

注意事项:

  • 重试次数不宜过多,通常 2-3 次即可,避免浪费资源
  • 退避策略可以有效减少对目标服务器的压力
  • 对于明确的永久性错误(如404),不应该重试
  • 监控数据应该定期保存,避免程序意外终止导致数据丢失
  • 在分布式爬虫中,需要考虑监控数据的聚合问题

通过监控成功率和实现智能重试机制,我们可以显著提高爬虫的健壮性和数据采集效率。

11.3 使用 tqdm 显示进度条

在处理大量数据或长时间运行的爬虫任务时,进度条能够直观地展示任务完成情况,提升用户体验。Python 的 tqdm 库是实现进度条的最佳选择之一。

tqdm 基础用法

tqdm 可以轻松地包装任何可迭代对象,在循环过程中显示进度条。它会自动计算剩余时间、处理速度等信息。

python
from tqdm import tqdm
import time

# 基础进度条
for i in tqdm(range(100)):
    time.sleep(0.01)  # 模拟工作

# 自定义描述
for i in tqdm(range(100), desc="处理数据"):
    time.sleep(0.01)

在爬虫中集成进度条

在爬虫场景中,我们通常需要处理 URL 列表或分页数据。tqdm 可以很好地集成到这些场景中。

python
import requests
from tqdm import tqdm

def crawl_urls(url_list):
    """批量爬取URL列表"""
    results = []
    
    # 包装URL列表显示进度条
    for url in tqdm(url_list, desc="爬取进度"):
        try:
            response = requests.get(url, timeout=10)
            results.append(response.text)
        except Exception as e:
            results.append(None)
            
    return results

手动更新进度条

有时候我们的任务不是简单的循环,而是复杂的异步操作或条件分支。这时可以使用手动更新模式。

python
from tqdm import tqdm

# 创建进度条对象
pbar = tqdm(total=100, desc="自定义进度")

# 手动更新
for i in range(10):
    # 模拟一些工作
    time.sleep(0.1)
    # 更新进度(可以指定增加的数量)
    pbar.update(10)

# 关闭进度条
pbar.close()

实例方法表格

功能名称实例调用方法具体功能、注意事项、必需参数/可选参数
自动进度tqdm(iterable)包装可迭代对象自动显示进度,iterable 为必需参数
自定义描述tqdm(iterable, desc="描述")设置进度条前缀描述,desc 为可选参数
手动创建tqdm(total=n)创建指定总量的进度条,total 为必需参数
更新进度pbar.update(n)手动增加进度,n 为可选参数,默认为1

使用示例

python
import requests
import logging
from tqdm import tqdm
from urllib.parse import urljoin

# 配置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

def fetch_page(url):
    """获取单个页面"""
    try:
        response = requests.get(url, timeout=10)
        response.raise_for_status()
        return response.text
    except Exception as e:
        logger.error(f"获取页面失败: {url}, 错误: {str(e)}")
        return None

def crawl_with_progress(base_url, page_count):
    """带进度条的分页爬虫"""
    results = []
    
    # 创建URL列表
    urls = [f"{base_url}?page={i}" for i in range(1, page_count + 1)]
    
    # 使用tqdm包装URL列表
    for url in tqdm(urls, desc="分页爬取", unit="页"):
        content = fetch_page(url)
        results.append({
            'url': url,
            'content': content,
            'success': content is not None
        })
        
    return results

# 实际使用示例(使用httpbin模拟)
if __name__ == "__main__":
    # 注意:httpbin的/delay端点最大延迟为10秒
    base_url = "https://httpbin.org/delay/1"
    page_count = 5
    
    logger.info("开始带进度条的爬虫任务")
    results = crawl_with_progress(base_url, page_count)
    
    # 统计结果
    success_count = sum(1 for r in results if r['success'])
    logger.info(f"爬取完成! 成功: {success_count}/{len(results)}")

注意事项:

  • tqdm 在 Jupyter Notebook 中有特殊版本 tqdm.notebook
  • 在多线程或多进程中使用 tqdm 需要特殊处理
  • 进度条会占用终端的一行,如果程序还有其他输出,可能会造成显示混乱
  • 对于非常快速的操作(毫秒级),进度条的刷新可能会影响性能
  • 可以通过 disable=True 参数在不需要时禁用进度条

进度条虽然看似简单,但能极大地改善长时间运行任务的用户体验,让用户清楚地知道任务的进展和预计完成时间。

11.4 异常分类处理:超时、连接错误、解析失败

在爬虫开发中,异常处理是保证程序稳定性的关键。不同类型的异常需要不同的处理策略,盲目地捕获所有异常可能会掩盖真正的问题。

常见异常类型分类

Python 爬虫中常见的异常主要分为以下几类:

  1. 网络连接异常:DNS 解析失败、连接超时、连接被拒绝等
  2. HTTP 协议异常:HTTP 状态码错误(4xx、5xx)
  3. 超时异常:请求超时、读取超时
  4. 解析异常:HTML 解析错误、JSON 解析错误
  5. 其他异常:内存不足、磁盘空间不足等系统级异常

requests 库异常体系

requests 库提供了完善的异常体系,我们可以针对性地处理不同类型的错误:

python
import requests
from requests.exceptions import (
    ConnectionError,    # 连接错误
    Timeout,           # 超时错误
    HTTPError,         # HTTP错误(状态码>=400)
    RequestException   # 所有requests异常的基类
)

BeautifulSoup 解析异常

HTML 解析也可能出现异常,特别是当页面结构不符合预期时:

python
from bs4 import BeautifulSoup, FeatureNotFound

try:
    soup = BeautifulSoup(html_content, 'html.parser')
except FeatureNotFound:
    # 解析器不可用
    soup = BeautifulSoup(html_content, 'html5lib')

异常分类处理策略

针对不同类型的异常,我们应该采用不同的处理策略:

  • 连接错误:通常是临时性问题,适合重试
  • 超时错误:可以增加超时时间或重试
  • HTTP 4xx 错误:客户端错误,通常不应该重试(429 除外)
  • HTTP 5xx 错误:服务器错误,适合重试
  • 解析错误:可能是页面结构变化,需要人工检查

实例方法表格

功能名称实例调用方法具体功能、注意事项、必需参数/可选参数
连接错误处理except ConnectionError as e处理网络连接相关错误,如DNS失败、连接拒绝
超时处理except Timeout as e处理请求超时或读取超时,可调整timeout参数
HTTP错误处理except HTTPError as e处理HTTP状态码错误,可通过response.status_code获取具体状态码
通用异常处理except RequestException as e捕获所有requests相关的异常

使用示例

python
import requests
import logging
from bs4 import BeautifulSoup
from requests.exceptions import ConnectionError, Timeout, HTTPError, RequestException

# 配置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

def safe_fetch_and_parse(url, timeout=10):
    """
    安全地获取并解析网页
    
    参数:
    url: 目标URL
    timeout: 超时时间(秒)
    
    返回:
    dict: 包含成功状态、内容和错误信息的字典
    """
    result = {
        'success': False,
        'content': None,
        'error_type': None,
        'error_message': None
    }
    
    try:
        # 发送请求
        logger.info(f"请求URL: {url}")
        response = requests.get(url, timeout=timeout)
        response.raise_for_status()  # 检查HTTP状态码
        
        # 解析HTML
        soup = BeautifulSoup(response.text, 'html.parser')
        
        # 提取标题作为示例
        title = soup.find('title')
        result['content'] = title.get_text() if title else "No title found"
        result['success'] = True
        
        logger.info(f"成功解析: {url}")
        
    except ConnectionError as e:
        # 连接错误:网络问题、DNS解析失败等
        error_msg = f"连接错误: {str(e)}"
        logger.error(error_msg)
        result['error_type'] = 'connection_error'
        result['error_message'] = error_msg
        
    except Timeout as e:
        # 超时错误:请求或读取超时
        error_msg = f"超时错误: {str(e)}"
        logger.error(error_msg)
        result['error_type'] = 'timeout_error'
        result['error_message'] = error_msg
        
    except HTTPError as e:
        # HTTP错误:4xx或5xx状态码
        status_code = e.response.status_code if e.response else 'Unknown'
        error_msg = f"HTTP错误 (状态码: {status_code}): {str(e)}"
        logger.error(error_msg)
        
        # 根据状态码决定是否重试
        if status_code == 429:  # Too Many Requests
            result['error_type'] = 'rate_limit'
        elif 500 <= status_code < 600:  # 服务器错误
            result['error_type'] = 'server_error'
        else:  # 客户端错误
            result['error_type'] = 'client_error'
            
        result['error_message'] = error_msg
        
    except Exception as e:
        # 其他未预期的错误
        error_msg = f"未知错误: {str(e)}"
        logger.error(error_msg)
        result['error_type'] = 'unknown_error'
        result['error_message'] = error_msg
        
    return result

# 测试不同类型的异常
if __name__ == "__main__":
    test_cases = [
        "https://httpbin.org/get",           # 正常情况
        "https://nonexistent-domain-12345.com",  # 连接错误
        "https://httpbin.org/delay/15",      # 超时错误(如果timeout<15)
        "https://httpbin.org/status/404",    # HTTP 404错误
        "https://httpbin.org/status/500",    # HTTP 500错误
    ]
    
    for url in test_cases:
        result = safe_fetch_and_parse(url, timeout=5)
        print(f"\nURL: {url}")
        print(f"成功: {result['success']}")
        if not result['success']:
            print(f"错误类型: {result['error_type']}")
            print(f"错误信息: {result['error_message'][:50]}...")  # 截断长信息

注意事项:

  • 异常处理应该具体而不是笼统,避免使用裸露的 except:
  • 对于可恢复的错误(如网络超时、服务器错误),应该实现重试机制
  • 对于不可恢复的错误(如404、403),应该记录并跳过,而不是无限重试
  • 异常信息应该包含足够的上下文,便于后续分析和调试
  • 在生产环境中,应该将异常分类统计数据定期汇总,用于优化爬虫策略

通过精细化的异常分类处理,我们可以让爬虫更加智能和健壮,既能应对各种异常情况,又能为后续的优化提供有价值的数据。