Scrapy爬虫大面积报错Timeout/403?彻底解决代理IP失效导致的“丢数据”痛点

0 阅读6分钟

做爬虫开发的兄弟们肯定都经历过这种绝望时刻:周五下班前满心欢喜地部署了一个包含几十万URL的爬虫任务,挂上代理池,看着控制台刷刷地跑,安心回家过周末。结果周一早上回来一看数据库,本该有30万条数据,却只存了区区5000条。

打开日志一看,满屏血红:

  • [scrapy.downloadermiddlewares.retry] Gave up retrying <GET ...> (failed 3 times): TCP connection timed out
  • [scrapy.core.engine] DEBUG: Crawled (403) <GET ...>
  • twisted.internet.error.ConnectionLost: Connection to the other side was lost in a non-clean fashion.

为什么明明接了代理IP,爬虫还是会大面积崩溃、丢数据?今天我们就来彻底扒一扒Scrapy默认代理和重试机制的坑,并手写一个深度定制的中间件来解决它。

痛点案例分析:Scrapy默认机制为什么不够用?

Scrapy内置了 HttpProxyMiddlewareRetryMiddleware,对于跑跑小众网站勉强够用,但在高并发和复杂反爬环境下,简直漏洞百出。

案例一:代理节点的“薛定谔状态”(网络层异常丢请求)

现象: 使用动态转发代理(每次请求随机切换IP),有时某个底层的IP节点不稳定,导致请求一直卡在建立连接的阶段,最后抛出 TimeoutErrorTunnelError
Scrapy的默认处理: RetryMiddleware 确实会捕获一部分异常,但对于Twisted底层爆出的一些冷门网络错误(如 TCPRoutedConnectionLost),它可能会直接漏掉。更致命的是,如果代理质量差,连续3次重试都碰上了坏节点,Scrapy就会直接抛弃(Gave up)这个URL,导致数据永久丢失

案例二:服务器的“温柔一刀”(状态码异常不重试)

现象: 目标网站并没有断开你的连接,而是直接返回了一个 403 Forbidden,或者 429 Too Many Requests,甚至有些网站会针对代理IP返回 502 Bad Gateway
Scrapy的默认处理: RetryMiddleware 默认只针对 [500, 502, 503, 504, 522, 524, 408] 进行重试。如果你遇到了 403429,Scrapy会认为这是一个“正常的响应状态”,直接放行给Spider去解析。Spider找不到数据节点,最终导致解析报错或者存入空数据。

案例三:“死磕到底”的盲目重试

现象: 使用固定代理池时,IP已经被目标网站拉黑(封禁了)。
Scrapy的默认处理: 带着这个已经被封的IP连续重试3次,白白浪费请求次数和时间,没有任何意义。正确的做法应该是:一旦发现被封,立刻丢弃该IP,换一个新代理再次重试

终极解决方案:深度定制代理中间件

为了解决上述痛点,我们需要打破 Scrapy “代理是代理,重试是重试” 的隔离机制,将代理注入、状态码校验、异常捕获、自动重试全部收拢到一个自定义的 Downloader Middleware 中。

下面我们以接入“爬虫代理”(账密隧道转发模式)为例,直接上企业级解决代码。

1. 编写定制化中间件 (middlewares.py)

import base64
import logging
from scrapy.utils.response import response_status_message
from scrapy.core.downloader.handlers.http11 import TunnelError
from twisted.internet import defer
from twisted.internet.error import (
    TimeoutError, DNSLookupError, 
    ConnectionRefusedError, ConnectionDone, ConnectError, 
    ConnectionLost, TCPRoutedConnectionLost
)

logger = logging.getLogger(__name__)

class RobustProxyMiddleware:
    """
    终极防丢数据:自定义代理与重试中间件
    """
    
    def __init__(self, settings):
        # 1. 初始化代理配置 (参考亿牛云代理)
        self.proxy_host = settings.get('PROXY_HOST', 'proxy.16yun.cn')
        self.proxy_port = settings.get('PROXY_PORT', '8100')
        self.proxy_user = settings.get('PROXY_USER', '16YUN')
        self.proxy_pass = settings.get('PROXY_PASS', '16IP')
        self.proxy_url = f"http://{self.proxy_host}:{self.proxy_port}"
        
        # 构造鉴权头
        auth_bytes = f"{self.proxy_user}:{self.proxy_pass}".encode('utf-8')
        self.proxy_auth_header = f"Basic {base64.b64encode(auth_bytes).decode('utf-8')}"
        
        # 2. 核心痛点解决:扩大状态码重试范围,加入403和429
        self.retry_http_codes = set(int(x) for x in settings.getlist('RETRY_HTTP_CODES', [403, 429, 500, 502, 503, 504, 521]))

        # 3. 核心痛点解决:全面捕获Twisted底层网络异常,防止漏网之鱼
        self.exceptions_to_retry = (
            defer.TimeoutError, TimeoutError, DNSLookupError,
            ConnectionRefusedError, ConnectionDone, ConnectError,
            ConnectionLost, TCPRoutedConnectionLost, TunnelError
        )
        
        # 最大重试次数
        self.max_retry_times = settings.getint('RETRY_TIMES', 5)

    @classmethod
    def from_crawler(cls, crawler):
        return cls(crawler.settings)

    def process_request(self, request, spider):
        """挂载代理"""
        if 'dont_proxy' not in request.meta:
            request.meta['proxy'] = self.proxy_url
            request.headers['Proxy-Authorization'] = self.proxy_auth_header

    def process_response(self, request, response, spider):
        """
        拦截响应:解决状态码异常不重试的问题
        """
        if response.status in self.retry_http_codes:
            reason = response_status_message(response.status)
            logger.warning(f"【拦截异常状态码】 {response.status} - 触发重试: {request.url}")
            return self._retry(request, reason, spider) or response
        return response

    def process_exception(self, request, exception, spider):
        """
        拦截异常:解决网络层抖动导致的丢包问题
        """
        if isinstance(exception, self.exceptions_to_retry):
            logger.warning(f"【拦截网络异常】 {exception} - 触发重试: {request.url}")
            return self._retry(request, exception, spider)

    def _retry(self, request, reason, spider):
        """
        重试调度核心逻辑
        """
        retries = request.meta.get('retry_times', 0) + 1
        if retries <= self.max_retry_times:
            logger.debug(f"重试 {retries}/{self.max_retry_times}: {request.url}")
            retryreq = request.copy()
            retryreq.meta['retry_times'] = retries
            retryreq.dont_filter = True # 必须设置为True,防止重试的URL被去重器过滤掉
            
            # 由于使用的是隧道代理,重新发起请求时,代理服务端会自动分配一个新的底层IP,
            # 从而完美避开了“死磕同一个被封IP”的窘境。
            return retryreq
        else:
            # 达到最大重试次数,建议在此处将URL记录到Redis或死信队列,人工介入
            logger.error(f"【放弃重试】达到最大次数 {self.max_retry_times}: {request.url}")
            return None

2. 生效配置 (settings.py)

写好代码后,最关键的一步是替换掉Scrapy自带的残疾组件

# 亿牛云代理配置
PROXY_HOST = 'proxy.16yun.cn'
PROXY_PORT = '8100'
PROXY_USER = 'your_username'
PROXY_PASS = 'your_password'

# 自定义我们要拦截重试的状态码 (务必加上你的目标网站喜欢返回的反爬码)
RETRY_HTTP_CODES = [403, 408, 429, 500, 502, 503, 504]
RETRY_TIMES = 5 # 建议适当调大重试次数,以空间换成功率

# 关闭默认组件,启用我们的终极防御中间件
DOWNLOADER_MIDDLEWARES = {
    'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': None,
    'scrapy.downloadermiddlewares.retry.RetryMiddleware': None,
    'myproject.middlewares.RobustProxyMiddleware': 543, # 数值设置在中间位置即可
}

DOWNLOAD_TIMEOUT = 15 # 隧道代理建议设置10-15秒超时

效果对比与总结

换上这套定制中间件后,你的控制台输出会发生根本性的变化:

  • 以前: 遇到 TimeoutError,红字报错,进程继续,数据悄悄丢失。
  • 现在: 控制台输出黄字警告 【拦截网络异常】 TimeoutError - 触发重试...,请求被打回调度器,利用隧道代理的新IP重新采集,最终返回 200 OK数据一条不丢
  • 以前: 网站返回 403,直接进入 Spider,Xpath提取不到元素,爆出 AttributeError 异常终止。
  • 现在: 中间件在 Download 环节直接截杀 403,输出 【拦截异常状态码】 403 - 触发重试...,自动洗白IP后再次请求,Spider只负责处理干净的 200 数据。

做爬虫,把异常处理和重试机制牢牢抓在自己手里,才是保证数据完整性的唯一出路。