Python爬虫零基础入门【第九章:实战项目教学·第8节】可观测性:日志规范 + trace_id + 可复现错误包!

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

全文目录:

🌟 开篇语

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

运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO

欢迎大家常来逛逛,一起学习,一起进步~🌟

我长期专注 Python 爬虫工程化实战 ,主理专栏 👉 《Python爬虫实战》:从采集策略反爬对抗 ,从数据清洗分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上

📌 专栏食用指南(建议收藏)

  • ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
  • ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
  • ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
  • ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用

📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏《Python爬虫实战》

订阅后更新会优先推送,按目录学习更高效~

📌 上期回顾

上一期《Python爬虫零基础入门【第九章:实战项目教学·第7节】限速器进阶:令牌桶 + 动态降速(429/5xx)!》我们搞定了限速器------用令牌桶控制节奏,遇到429自动降速,恢复后渐进提速。爬虫终于学会了"察言观色",不会莽撞地把服务器打爆了。

但新问题来了:爬虫跑着跑着突然挂了,你该怎么查?

凌晨3点,监控告警响起:"采集失败率30%"。你睡眼惺忪打开日志,看到的是:

复制代码
Error: NoneType object has no attribute 'text'
Error: list index out of range  
Error: Connection timeout

然后呢?**哪个URL出错?什么时候出错?页面长啥样?请求参数是啥?**一概不知。你只能祈祷白天能复现,或者干脆重跑一遍碰运气。

**这就是缺乏可观测性的代价。**今天咱们就来解决这个痛点。

🎯 本期目标

这一期你会得到:

  • 结构化日志规范:JSON格式,字段统一,方便检索分析
  • trace_id全链路追踪:一个请求从发起到入库的完整日志串联
  • 错误现场打包:HTML原文 + 请求参数 + 异常栈,一键复现
  • 可复现错误包:离线调试神器,不用重跑就能定位问题

验收标准很简单:任何一次失败,你都能在5分钟内定位原因,甚至离线复现🎯

💡 为什么需要可观测性?

传统日志的三大问题

问题1:信息不全
python 复制代码
logger.error("解析失败")  # ❌ 啥都没说

你需要知道:哪个URL?哪个字段?选择器是啥?页面内容是啥?

问题2:无法关联
python 复制代码
[INFO] 开始采集列表页
[ERROR] 详情页解析失败
[INFO] 保存到数据库

这三条日志是一次请求吗?还是三次不同请求?没有trace_id,日志就是一盘散沙。

问题3:现场丢失

错误发生时的HTML页面、请求headers、响应内容...全都没保存。等你想复现时,页面可能已经更新了,永远复现不了。

可观测性的三大支柱

业界有个经典理论:可观测性 = 日志(Logs) + 指标(Metrics) + 追踪(Traces)

对爬虫来说:

  • 日志:记录发生了什么(结构化 + 分级)
  • 指标:统计成功率、耗时分布(上期的RateLimitMetrics)
  • 追踪:串联一次采集的完整链路(trace_id是核心)

今天重点讲日志追踪,并加上爬虫特有的"错误现场打包"能力。

🔧 技术方案拆解

方案一:结构化日志

核心思想:日志即数据

传统文本日志:

复制代码
2026-01-24 03:15:42 ERROR 解析失败 url=https://example.com

结构化JSON日志:

json 复制代码
{
  "time": "2026-01-24T03:15:42.123Z",
  "level": "ERROR",
  "trace_id": "req_7a8f9c2d",
  "module": "parser",
  "message": "解析失败",
  "url": "https://example.com",
  "selector": "div.content > p",
  "error": "NoneType object has no attribute 'text'"
}

优势

  • 方便grep、jq等工具过滤
  • 可直接导入ELK/Loki等日志系统
  • 字段统一,易于统计分析

方案二:trace_id全链路追踪

一次采集流程:

复制代码
1. 发起请求 [trace_id=abc123]
2. 解析列表 [trace_id=abc123]  
3. 提取详情链接 [trace_id=abc123]
4. 采集详情页 [trace_id=abc123]
5. 清洗数据 [trace_id=abc123]
6. 保存入库 [trace_id=abc123]

所有环节共享同一个trace_id,出问题时:

bash 复制代码
grep "abc123" app.log | jq .

立即看到这次请求的完整生命周期。

方案三:错误现场打包

每次失败时保存:

复制代码
error_bundles/
├── req_abc123/
│   ├── meta.json          # 请求元信息
│   ├── request.json       # 请求参数、headers
│   ├── response.html      # 原始HTML
│   ├── screenshot.png     # 截图(Playwright场景)
│   ├── traceback.txt      # 异常栈
│   └── context.json       # 上下文数据

有了这个"现场包",你可以:

  • 离线用BeautifulSoup重放解析逻辑
  • 看截图确认页面是否正常
  • 对比不同失败case找共性

📝 完整实现

整体架构说明

我们会实现四个核心组件:

  1. StructuredLogger:结构化日志记录器
  2. TraceContext:trace_id上下文管理器
  3. ErrorBundler:错误现场打包器
  4. ObservableSpider:集成可观测性的爬虫基类

数据流向:

复制代码
请求开始 → 生成trace_id → 执行采集 → 记录结构化日志 → 失败时打包现场

代码实现

python 复制代码
"""
爬虫可观测性工具包
包含:结构化日志、trace_id、错误现场打包
"""
import json
import logging
import uuid
import time
import traceback
import hashlib
from pathlib import Path
from typing import Optional, Dict, Any, List
from dataclasses import data import datetime
from contextvars import ContextVar
import requests

# trace_id上下文变量(线程安全)
trace_context: ContextVar[Optional[str]] = ContextVar('trace_context', default=None)


# =============================================================================
# 1. 结构化日志构化日志记录"""
    time: str
    level: str
    trace_id: Optional[str]
    module: str
    message: str
    extra: Dict[str, Any] = field(default_factory=dict)
    
    def to_json(self) -> str:
        """转为JSON字符串"""
        data = asdict(self)
        # 展平extra字段
        extra = data.pop('extra', {})
        data.update(extra)
        return json.dumps(data, ensure_ascii=False)


class StructuredLogger:
    """
    结构化日志记录器
    
    功能:
    - 自动附加trace_id
    - JSON格式输出
    - 支持extra字段扩展
    """
    
    def __init__(self, name: str, log_file: Optional[Path] = None):
        self.name = name
        self.log_file = log_file
        
        # 配置标准logger(用于控制台输出)
        self.logger = logging.getLogger(name)
        self.logger.setLevel(logging.DEBUG)
        
        # 控制台handler(人类可读格式)
        console_handler = logging.StreamHandler()
        console_handler.setFormatter(
            logging.Formatter(
                '%(asctime)s [%(levelname)s] %(name)s: %(message)s',
                datefmt='%Y-%m-%d %H:%M:%S'
            )
        )
        self.logger.addHandler(console_handler)
        
        # 文件handler(JSON格式)
        if log_file:
            log_file.parent.mkdir(parents=True, exist_ok=True)
            file_handler = logging.FileHandler(log_file, encoding='utf-8')
            file_handler.setFormatter(logging.Formatter('%(message)s'))
            
            # 创建专门的JSON logger
            self.json_logger = logging.getLogger(f"{name}.json")
            self.json_logger.setLevel(logging.DEBUG)
            self.json_logger.addHandler(file_handler)
            self.json_logger.propagate = False
        else:
            self.json_logger = None
    
    def _log(self, level: str, message: str, **extra):
        """内部日志方法"""
        record = LogRecord(
            time=datetime.now().isoformat(),
            level=level,
            trace_id=trace_context.get(),
            module=self.name,
            message=message,
            extra=extra
        )
        
        # 控制台输出(人类可读)
        log_func = getattr(self.logger, level.lower())
        extra_str = f" | {extra}" if extra else ""
        log_func(f"{message}{extra_str}")
        
        # 文件输出(JSON)
        if self.json_logger:
            self.json_logger.info(record.to_json())
    
    def debug(self, message: str, **extra):
        self._log("DEBUG", message, **extra)
    
    def info(self, message: str, **extra):
        self._log("INFO", message, **extra)
    
    def warning(self, message: str, **extra):
        self._log("WARNING", message, **extra)
    
    def error(self, message: str, **extra):
        self._log("ERROR", message, **extra)
    
    def critical(self, message: str, **extra):
        self._log("CRITICAL", message, **extra)


# =============================================================================
# 2. Trace上下文管理
# =============================================================================

class TraceContext:
    """
    Trace上下文管理器
    
    用法:
        with TraceContext() as trace_id:
            logger.info("采集开始")  # 自动附加trace_id
    """
    
    def __init__(self, trace_id: Optional[str] = None, prefix: str = "req"):
        self.trace_id = trace_id or f"{prefix}_{uuid.uuid4().hex[:12]}"
        self.token = None
    
    def __enter__(self):
        self.token = trace_context.set(self.trace_id)
        return self.trace_id
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        trace_context.reset(self.token)
        return False  # 不吞异常


def get_trace_id() -> Optional[str]:
    """获取当前trace_id"""
    return trace_context.get()


# =============================================================================
# 3. 错误现场打包器
# =============================================================================

@dataclass
class ErrorBundle:
    """错误现场数据包"""
    trace_id: str
    timestamp: str
    url: str
    error_type: str
    error_message: str
    traceback: str
    
    # 请求相关
    request_method: str = "GET"
    request_headers: Dict[str, str] = field(default_factory=dict)
    request_params: Dict[str, Any] = field(default_factory=dict)
    
    # 响应相关
    response_status: Optional[int] = None
    response_headers: Dict[str, str] = field(default_factory=dict)
    response_html: Optional[str] = None
    
    # 上下文
    context: Dict[str, Any] = field(default_factory=dict)


class ErrorBundler:
    """
    错误现场打包器
    
    功能:
    - 保存HTML原文
    - 记录请求/响应信息
    - 打包异常栈
    - 支持离线复现
    """
    
    def __init__(self, bundle_dir: Path = Path("error_bundles")):
        self.bundle_dir = bundle_dir
        self.bundle_dir.mkdir(parents=True, exist_ok=True)
    
    def create_bundle(
        self,
        url: str,
        exception: Exception,
        response: Optional[requests.Response] = None,
        context: Optional[Dict[str, Any]] = None
    ) -> Path:
        """
        创建错误现场包
        
        Args:
            url: 请求URL
            exception: 异常对象
            response: 响应对象(如有)
            context: 额外上下文数据
        
        Returns:
            现场包目录路径
        """
        trace_id = get_trace_id() or f"unknown_{int(time.time())}"
        
        # 创建bundle目录
        bundle_path = self.bundle_dir / trace_id
        bundle_path.mkdir(parents=True, exist_ok=True)
        
        # 构建ErrorBundle
        bundle = ErrorBundle(
            trace_id=trace_id,
            timestamp=datetime.now().isoformat(),
            url=url,
            error_type=type(exception).__name__,
            error_message=str(exception),
            traceback=traceback.format_exc(),
            context=context or {}
        )
        
        # 填充响应信息
        if response is not None:
            bundle.response_status = response.status_code
            bundle.response_headers = dict(response.headers)
            bundle.request_method = response.request.method
            bundle.request_headers = dict(response.request.headers)
            
            # 保存HTML原文
            try:
                html_content = response.text
                bundle.response_html = html_content
                
                # 单独保存HTML文件
                (bundle_path / "response.html").write_text(
                    html_content, encoding='utf-8'
                )
            except Exception as e:
                bundle.context['html_save_error'] = str(e)
        
        # 保存元信息
        meta_data = asdict(bundle)
        # HTML内容太大,不放在meta.json里
        meta_data['response_html'] = f"见 response.html ({len(bundle.response_html or '')} 字符)"
        
        (bundle_path / "meta.json").write_text(
            json.dumps(meta_data, ensure_ascii=False, indent=2),
            encoding='utf-8'
        )
        
        # 保存异常栈
        (bundle_path / "traceback.txt").write_text(
            bundle.traceback, encoding='utf-8'
        )
        
        # 保存请求信息
        request_info = {
            'method': bundle.request_method,
            'url': url,
            'headers': bundle.request_headers,
            'params': bundle.request_params
        }
        (bundle_path / "request.json").write_text(
            json.dumps(request_info, ensure_ascii=False, indent=2),
            encoding='utf-8'
        )
        
        return bundle_path
    
    def load_bundle(self, trace_id: str) -> ErrorBundle:
        """加载错误现场包"""
        bundle_path = self.bundle_dir / trace_id
        
        if not bundle_path.exists():
            raise FileNotFoundError(f"Bundle not found: {trace_id}")
        
        meta = json.loads((bundle_path / "meta.json").read_text(encoding='utf-8'))
        
        # 加载HTML
        html_file = bundle_path / "response.html"
        if html_file.exists():
            meta['response_html'] = html_file.read_text(encoding='utf-8')
        
        return ErrorBundle(**meta)
    
    def list_bundles(self) -> List[str]:
        """列出所有错误包"""
        return [d.name for d in self.bundle_dir.iterdir() if d.is_dir()]


# =============================================================================
# 4. 可观测爬虫基类
# =============================================================================

class ObservableSpider:
    """
    集成可观测性的爬虫基类
    
    功能:
    - 自动trace_id管理
    - 结构化日志
    - 错误自动打包
    """
    
    def __init__(
        self,
        name: str = "spider",
        log_file: Optional[Path] = None,
        bundle_dir: Optional[Path] = None
    ):
        self.name = name
        self.logger = StructuredLogger(
            name,
            log_file or Path(f"logs/{name}.jsonl")
        )
        self.bundler = ErrorBundler(
            bundle_dir or Path(f"error_bundles/{name}")
        )
        
        # 会话
        self.session = requests.Session()
        self.session.headers.update({
            'User-Agent': 'ObservableSpider/1.0'
        })
        
        # 统计
        self.stats = {
            'total': 0,
            'success': 0,
            'failed': 0,
            'error_bundles': []
        }
    
    def fetch(
        self,
        url: str,
        method: str = "GET",
        trace_id: Optional[str] = None,
        **kwargs
    ) -> requests.Response:
        """
        执行HTTP请求(带完整可观测性)
        
        Args:
            url: 目标URL
            method: HTTP方法
            trace_id: 指定trace_id(可选)
            **kwargs: 传递给requests的参数
        
        Returns:
            Response对象
        
        Raises:
            可能抛出requests异常(会自动打包错误现场)
        """
        with TraceContext(trace_id) as tid:
            self.stats['total'] += 1
            
            self.logger.info(
                f"开始请求",
                url=url,
                method=method
            )
            
            start_time = time.time()
            response = None
            
            try:
                response = self.session.request(
                    method,
                    url,
                    timeout=kwargs.pop('timeout', 10),
                    **kwargs
                )
                
                elapsed = time.time() - start_time
                
                self.logger.info(
                    f"请求完成",
                    url=url,
                    status=response.status_code,
                    elapsed_ms=int(elapsed * 1000)
                )
                
                # 检查状态码
                if response.status_code >= 400:
                    self.logger.warning(
                        f"请求返回错误状态",
                        url=url,
                        status=response.status_code
                    )
                
                self.stats['success'] += 1
                return response
            
            except Exception as e:
                self.stats['failed'] += 1
                elapsed = time.time() - start_time
                
                self.logger.error(
                    f"请求失败",
                    url=url,
                    error=str(e),
                    elapsed_ms=int(elapsed * 1000)
                )
                
                # 打包错误现场
                bundle_path = self.bundler.create_bundle(
                    url=url,
                    exception=e,
                    response=response,
                    context={
                        'method': method,
                        'kwargs': kwargs,
                        'elapsed': elapsed
                    }
                )
                
                self.stats['error_bundles'].append(str(bundle_path))
                
                self.logger.info(
                    f"错误现场已保存",
                    bundle=str(bundle_path)
                )
                
                raise
    
    def parse(self, html: str, url: str) -> Dict[str, Any]:
        """
        解析HTML(子类实现)
        
        Args:
            html: HTML内容
            url: 来源URL
        
        Returns:
            解析结果
        """
        raise NotImplementedError("子类需实现parse方法")
    
    def run(self, urls: List[str]):
        """批量采集"""
        self.logger.info(f"开始批量采集", total=len(urls))
        
        for i, url in enumerate(urls, 1):
            try:
                response = self.fetch(url)
                data = self.parse(response.text, url)
                
                self.logger.info(
                    f"采集成功 [{i}/{len(urls)}]",
                    url=url,
                    fields=len(data)
                )
            
            except Exception as e:
                self.logger.error(
                    f"采集失败 [{i}/{len(urls)}]",
                    url=url,
                    error=str(e)
                )
        
        self.print_stats()
    
    def print_stats(self):
        """打印统计信息"""
        print("\n" + "="*60)
        print(f"📊 采集统计 - {self.name}")
        print("="*60)
        print(f"总请求:     {self.stats['total']}")
        print(f"成功:       {self.stats['success']}")
        print(f"失败:       {self.stats['failed']}")
        print(f"成功率:     {self.stats['success']/self.stats['total']*100:.1f}%")
        print(f"错误包数:   {len(self.stats['error_bundles'])}")
        
        if self.stats['error_bundles']:
            print("\n错误包列表:")
            for bundle in self.stats['error_bundles'][:5]:
                print(f"  - {bundle}")
            if len(self.stats['error_bundles']) > 5:
                print(f"  ... 还有 {len(self.stats['error_bundles'])-5} 个")
        
        print("="*60 + "\n")


# =============================================================================
# 5. 使用示例
# =============================================================================

class DemoSpider(ObservableSpider):
    """示例爬虫"""
    
    def parse(self, html: str, url: str) -> Dict[str, Any]:
        """简单解析(示例)"""
        from bs4 import BeautifulSoup
        
        soup = BeautifulSoup(html, 'html.parser')
        
        # 故意制造一些解析错误用于演示
        title = soup.select_one('title')
        if title is None:
            raise ValueError("未找到title标签")
        
        return {
            'title': title.get_text(strip=True),
            'url': url,
            'length': len(html)
        }


def demo_basic_usage():
    """示例1: 基础使用"""
    print("\n🔹 示例1: 基础可观测性")
    
    spider = DemoSpider(name="demo")
    
    # 单个请求
    with TraceContext() as trace_id:
        print(f"当前trace_id: {trace_id}")
        
        try:
            response = spider.fetch("https://httpbin.org/html")
            data = spider.parse(response.text, response.url)
            print(f"解析结果: {data}")
        except Exception as e:
            print(f"请求失败: {e}")
    
    spider.print_stats()


def demo_error_bundling():
    """示例2: 错误现场打包"""
    print("\n🔹 示例2: 错误现场打包")
    
    spider = DemoSpider(name="error_demo")
    
    # 故意请求错误的URL
    bad_urls = [
        "https://httpbin.org/status/404",
        "https://httpbin.org/status/500",
        "https://this-domain-does-not-exist-12345.com"
    ]
    
    for url in bad_urls:
        try:
            response = spider.fetch(url)
            spider.parse(response.text, url)
        except Exception:
            pass  # 已自动打包
    
    spider.print_stats()
    
    # 查看错误包
    bundles = spider.bundler.list_bundles()
    print(f"\n错误包列表: {bundles}")
    
    if bundles:
        # 加载第一个错误包
        bundle = spider.bundler.load_bundle(bundles[0])
        print(f"\n错误包详情:")
        print(f"  URL: {bundle.url}")
        print(f"  错误类型: {bundle.error_type}")
        print(f"  错误信息: {bundle.error_message}")
        print(f"  响应状态: {bundle.response_status}")


def demo_batch_crawl():
    """示例3: 批量采集"""
    print("\n🔹 示例3: 批量采集")
    
    spider = DemoSpider(name="batch")
    
    urls = [
        "https://httpbin.org/html",
        "https://httpbin.org/html",
        "https://httpbin.org/status/404",  # 会失败
        "https://httpbin.org/html",
    ]
    
    spider.run(urls)


def demo_replay_error():
    """示例4: 离线复现错误"""
    print("\n🔹 示例4: 离线复现错误")
    
    bundler = ErrorBundler(Path("error_bundles/error_demo"))
    bundles = bundler.list_bundles()
    
    if not bundles:
        print("没有错误包,先运行 demo_error_bundling()")
        return
    
    # 加载错误包
    bundle = bundler.load_bundle(bundles[0])
    
    print(f"准备复现错误: {bundle.trace_id}")
    print(f"原始URL: {bundle.url}")
    print(f"错误类型: {bundle.error_type}\n")
    
    # 使用保存的HTML重新解析(离线)
    if bundle.response_html:
        from bs4 import BeautifulSoup
        
        try:
            soup = BeautifulSoup(bundle.response_html, 'html.parser')
            title = soup.select_one('title')
            
            if title:
                print(f"✅ 重新解析成功: {title.get_text(strip=True)}")
            else:
                print("❌ 重新解析失败: 未找到title")
        
        except Exception as e:
            print(f"❌ 重新解析异常: {e}")
    else:
        print("⚠️  无HTML内容")


if __name__ == "__main__":
    # 运行示例
    demo_basic_usage()
    # demo_error_bundling()
    # demo_batch_crawl()
    # demo_replay_error()

🔍 代码关键点解析

1. ContextVar实现线程安全的trace_id

python 复制代码
from contextvars import ContextVar

trace_context: ContextVar[Optional[str]] = ContextVar('trace_context', default=None)

为什么不用threading.local?

ContextVar是Python 3.7+引入的更现代的方案,支持异步环境(asyncio),而threading.local只支持线程。

2. 结构化日志的双重输出

python 复制代码
# 控制台: 人类可读
logger.info("采集成功 | url=https://example.com")

# 文件: JSON格式
{"time":"2026-01-24T10:30:15","level":"INFO","trace_id":"req_abc","message":"采集成功","url":"https://example.com"}

为什么要两种格式?

  • 开发调试时看控制台更直观
  • 生产环境用JSON方便工具解析

3. 错误包的目录结构设计

复制代码
error_bundles/
└── req_abc123/
    ├── meta.json          # 元信息(便于快速浏览)
    ├── request.json       # 请求详情
    ├── response.html      # 原始HTML(可能很大)
    └── traceback.txt      # 异常栈(方便直接查看)

设计原则

  • 小文件分离(meta.json)方便快速扫描
  • 大文件独立(response.html)避免拖慢加载
  • 文本文件(traceback.txt)支持直接cat查看

4. 上下文管理器的优雅用法

python 复制代码
with TraceContext() as trace_id:
    # 这个代码块内的所有日志自动附加trace_id
    logger.info("步骤1")
    logger.info("步骤2")
    # 退出时自动清理

核心技巧 :利用__enter____exit__自动管理上下文变量的生命周期。

📊 实战验收

运行测试

bash 复制代码
python observability_demo.py

预期输出

复制代码
🔹 示例1: 基础可观测性
当前trace_id: req_7a8f9c2d

2026-01-24 10:30:15 [INFO] demo: 开始请求 | {'url': 'https://httpbin.org/html', 'method': 'GET'}
2026-01-24 10:30:16 [INFO] demo: 请求完成 | {'status': 200, 'elapsed_ms': 234}

解析结果: {'title': 'Herman Melville - Moby-Dick', 'url': '...', 'length': 3741}

📊 采集统计 - demo
总请求:     1
成功:       1
失败:       0
成功率:     100.0%

查看JSON日志

bash 复制代码
cat logs/demo.jsonl | jq .

输出:

json 复制代码
{
 "time": "2026-01-24T10:30:15.123Z",
"level": "INFO",
"trace_id": "req_7a8f9c2d",
"module": "demo",
"message": "开始请求",
"url": "[https://httpbin.org/html](https://httpbin.org/html)",
"method": "GET"
}
{
"time": "2026-01-24T10:30:16.357Z",
"level": "INFO",
"trace_id": "req_7a8f9c2d",
"module": "demo",
"message": "请求完成",
"url": "[https://httpbin.org/html](https://httpbin.org/html)",
"status": 200,
"elapsed_ms": 234
}

查看错误包

bash 复制代码
# 列出所有错误包
ls error_bundles/error_demo/

# 查看某个错误包的元信息
cat error_bundles/error_demo/req_xxx/meta.json | jq .

# 查看异常栈
cat error_bundles/error_demo/req_xxx/traceback.txt

验收标准

  • 每次请求都有唯一trace_id
  • JSON日志格式正确,可用jq解析
  • 同一trace_id的日志能串联完整流程
  • 错误时自动创建error_bundle
  • 错误包包含HTML原文、异常栈、请求信息
  • 可以用error_bundle离线复现解析逻辑

🎨 进阶优化方向

1. 日志分级存储

当前所有日志写一个文件,生产环境可以分级:

python 复制代码
logs/
├── app.jsonl          # 所有日志
├── error.jsonl        # 只有ERROR及以上
└── metrics.jsonl      # 只有指标类日志

实现方式:给StructuredLogger添加多个FileHandler,用Filter过滤级别。

2. 日志轮转

避免单个日志文件过大:

python 复制代码
from logging.handlers import RotatingFileHandler

handler = RotatingFileHandler(
    'app.jsonl',
    maxBytes=100*1024*1024,  # 100MB
    backupCount=10           # 保留10个历史文件
)

3. 分布式trace_id

如果爬虫分布式部署,trace_id需要包含机器标识:

python 复制代码
import socket

hostname = socket.gethostname()
trace_id = f"{hostname}_{uuid.uuid4().hex[:12]}"

这样即使多台机器并发跑,trace_id也不会冲突。

4. 自动上传错误包

错误包保存到本地还不够,可以自动上传到对象存储:

python 复制代码
def create_bundle(self, ...):
    bundle_path = self._save_locally(...)
    
    # 异步上传到S3/OSS
    asyncio.create_task(
        self._upload_to_s3(bundle_path)
    )
    
    return bundle_path

5. 错误聚合分析

相同错误可能发生多次,可以做聚合:

python 复制代码
# 按error_type和error_message分组
errors = defaultdict(list)
for bundle_id in bundler.list_bundles():
    bundle = bundler.load_bundle(bundle_id)
    key = (bundle.error_type, bundle.error_message)
    errors[key].append(bundle)

# 输出TopN错误
for (err_type, err_msg), bundles in sorted(errors.items(), key=lambda x: len(x[1]), reverse=True)[:5]:
    print(f"{err_type}: {err_msg} (发生{len(bundles)}次)")

6. 集成告警

当错误包数量超过阈值时发送告警:

python 复制代码
if len(self.stats['error_bundles']) > 10:
    send_alert(
        title="爬虫错误率过高",
        content=f"最近产生了{len(self.stats['error_bundles'])}个错误包"
    )

⚠️ 常见坑点

1. 日志写入性能

问题:每次请求都写磁盘,高并发时可能成为瓶颈。

解决:使用BufferedIOBase或QueueHandler异步写入:

python 复制代码
from logging.handlers import QueueHandler
import queue

log_queue = queue.Queue()
queue_handler = QueueHandler(log_queue)

# 另起线程消费队列
def log_writer():
    while True:
        record = log_queue.get()
        # 批量写入磁盘

2. HTML文件过大

问题:某些页面HTML有几MB,error_bundle占用空间爆炸。

解决

  • 只保存关键部分(比如body内容)
  • 或者压缩存储:
python 复制代码
import gzip

with gzip.open(bundle_path / "response.html.gz", 'wt', encoding='utf-8') as f:
    f.write(html_content)

3. trace_id丢失

问题:多线程环境下,trace_id传递容易丢。

解决

  • 使用ContextVar而不是全局变量
  • 或者显式传递trace_id:
python 复制代码
def worker(url, trace_id):
    with TraceContext(trace_id):
        fetch(url)

4. 时区问题

问题:日志时间用本地时区,服务器在不同时区时难以对比。

解决:统一用UTC:

python 复制代码
from datetime import timezone

time = datetime.now(timezone.utc).isoformat()

5. 敏感信息泄露

问题:请求headers可能包含Authorization等敏感信息。

解决:打包前脱敏:

python 复制代码
sensitive_keys = {'Authorization', 'Cookie', 'X-API-Key'}

def sanitize_headers(headers: dict) -> dict:
    return {
        k: '***REDACTED***' if k in sensitive_keys else v
        for k, v in headers.items()
    }

💼 实际应用场景

场景1:调试选择器失效

某天爬虫突然大量解析失败,但页面看起来正常。

传统方式:手动访问URL,对比HTML,猜测哪里变了。

有可观测性

  1. 找到失败的trace_id
  2. 打开error_bundle看保存的HTML
  3. 对比成功case的HTML,发现class名称从content改成了main-content
  4. 5分钟定位问题,修改选择器

场景2:批量复现解析错误

采集10万条数据,其中500条解析失败。

传统方式:重跑500次?但可能页面已更新,复现不了。

有可观测性

python 复制代码
# 批量加载所有失败case
failed_bundles = [bundler.load_bundle(bid) for bid in bundler.list_bundles()]

# 统一测试新的解析逻辑
for bundle in failed_bundles:
    try:
        new_data = new_parser.parse(bundle.response_html)
        print(f"✅ {bundle.trace_id} 修复成功")
    except Exception as e:
        print(f"❌ {bundle.trace_id} 仍失败: {e}")

离线验证,一次搞定500个case。

场景3:性能问题排查

爬虫越跑越慢,怀疑某些URL特别慢。

有可观测性

bash 复制代码
# 从JSON日志统计耗时分布
cat logs/spider.jsonl | jq 'select(.elapsed_ms != null) | .elapsed_ms' | \
  awk '{sum+=$1; count+=1} END {print "平均耗时:", sum/count, "ms"}'

# 找出最慢的10个请求
cat logs/spider.jsonl | jq 'select(.elapsed_ms != null) | {url, elapsed_ms}' | \
  jq -s 'sort_by(.elapsed_ms) | reverse | .[0:10]'

数据说话,不用靠猜。

场景4:异常趋势分析

想知道每天哪个时段错误率最高。

有可观测性

python 复制代码
import pandas as pd

# 读取日志
logs = [json.loads(line) for line in open('logs/spider.jsonl')]
df = pd.DataFrame(logs)

# 转时间戳
df['hour'] = pd.to_datetime(df['time']).dt.hour

# 按小时统计错误率
error_rate = df.groupby('hour')['level'].apply(
    lambda x: (x == 'ERROR').sum() / len(x)
)

print(error_rate)

发现凌晨3点错误率最高?可能对方在维护,调整采集时段。

🎯 本期总结

今天我们构建了一套生产级的可观测性体系

结构化日志 :JSON格式,字段统一,方便分析

trace_id追踪 :串联请求全生命周期

错误现场打包 :保存HTML、异常栈、上下文

离线复现能力 :不用重跑就能调试

统计分析友好:日志即数据,支持各种查询

核心理念就一句话:让每一次失败都有迹可循,每一个问题都能快速定位🔍

有了这套工具,爬虫出问题时你不再两眼一抹黑。凌晨3点被告警吵醒?打开error_bundle,5分钟定位问题,改完代码继续睡觉💤

📖 下期预告

下载型资源采集:PDF/附件下载 + 去重校验

前面我们都在抓文本数据,但很多场景需要下载文件:

  • 公告附件(PDF、Word、Excel)
  • 研报文档
  • 图片、视频

下一期我们聊:

  • 如何识别下载链接并提取文件名
  • 流式下载大文件(不爆内存)
  • SHA256去重(避免重复下载)
  • 断点续传(网络中断后继续)
  • 文件目录规范(方便管理)

从此不仅能抓网页,还能批量下载资源库


作业(可选)

  1. 运行demo,查看生成的JSON日志和error_bundle
  2. 用jq查询:cat logs/*.jsonl | jq 'select(.level=="ERROR")'
  3. 尝试修改DemoSpider的parse方法,故意制造错误,然后用error_bundle离线复现
  4. 思考:如果要做一个"错误包查看器Web界面",需要哪些功能?

有问题随时在评论区讨论,咱们下期见!

🌟 文末

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

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

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

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

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

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

✅ 互动征集

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

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


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

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

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


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

相关推荐
嫂子开门我是_我哥2 小时前
第五节:字符串处理大全:文本操作的“万能工具箱”
开发语言·python
独行soc2 小时前
2026年渗透测试面试题总结-10(题目+回答)
android·网络·python·安全·web安全·渗透测试·安全狮
aiguangyuan2 小时前
词向量的艺术:从Word2Vec到GloVe的完整实践指南
人工智能·python·nlp
嫂子的姐夫2 小时前
24-MD5:红人点集登录+凡客网登录
爬虫·python·逆向·小白逆向练手
不绝1912 小时前
MonoBehavior/GameObject/Time/Transform/位移/角度旋转/缩放看向/坐标转换
开发语言·python
曲幽2 小时前
FastAPI实战:Redis缓存与分布式锁的深度解析
redis·python·cache·fastapi·web·lock
小码过河.2 小时前
17装饰器模式
开发语言·python·装饰器模式
gf13211113 小时前
python_生成RPA运行数据报告
windows·python·rpa
嫂子开门我是_我哥3 小时前
第八节:条件判断与循环:解锁Python的逻辑控制能力
开发语言·python