做爬虫开发的兄弟们肯定都经历过这种绝望时刻:周五下班前满心欢喜地部署了一个包含几十万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内置了 HttpProxyMiddleware 和 RetryMiddleware,对于跑跑小众网站勉强够用,但在高并发和复杂反爬环境下,简直漏洞百出。
案例一:代理节点的“薛定谔状态”(网络层异常丢请求)
现象: 使用动态转发代理(每次请求随机切换IP),有时某个底层的IP节点不稳定,导致请求一直卡在建立连接的阶段,最后抛出 TimeoutError 或 TunnelError。
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] 进行重试。如果你遇到了 403 或 429,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 数据。
做爬虫,把异常处理和重试机制牢牢抓在自己手里,才是保证数据完整性的唯一出路。