Skip to content

第8章 反爬的实战练习

做爬虫最让人头疼的不是代码写不出来,而是代码写完了、跑起来了,数据刚抓了几页就全断了。你检查请求头没毛病,代理 IP 也换了,Cookie 也带了,但目标网站就是不给你数据。这种时候,问题往往不是单一环节出了错,而是你的对手把多种反爬手段组合在了一起,形成了一道组合防线。

我是怕浪猫,一个在反爬战场上打了多年持久战的工程师。前面几章我们分别讲了请求头伪装、代理池搭建、验证码识别、Cookie 管理和 JavaScript 逆向,每一块都是独立的技术点。但实战中,反爬系统从来不会只用一种手段——它是多层防御的组合拳。这一章,怕浪猫就带你把前面学到的所有技术串起来,在一个真实的综合反爬场景中完成从分析到突破的完整闭环。

这篇文章会覆盖综合反爬识别、Scrapy 中间件接入 Cookie 池、代理池与 Cookie 池双重轮换、以及分布式爬虫的架设与部署。全程实战踩坑,代码管够,建议先收藏再看。

单点突破是战术,体系对抗是战略。真正的反爬战场,拼的是体系化能力。

8.1 实战目标说明

8.1.1 目标网站结构分析

为了讲清楚综合反爬的实战流程,我们以一个模拟的电商数据平台为例。这个平台具有典型的多层反爬特征,和你在真实业务中遇到的场景高度相似。

目标网站的结构如下:首页是分类导航页,每个分类下有商品列表页,列表页分页展示商品摘要信息,点击商品进入详情页可以看到完整的数据字段。整体 URL 层级结构清晰:

https://target.example.com/                    # 首页(分类导航)
https://target.example.com/category/electronics  # 分类列表页
https://target.example.com/category/electronics?page=2  # 分页列表页
https://target.example.com/item/10086           # 商品详情页

这个网站的数据是服务端渲染(SSR)和 Ajax 动态加载混合的模式。列表页的 HTML 中包含商品摘要信息,但详情页的部分关键字段需要通过 Ajax 接口获取。这种混合渲染模式在实战中非常常见,意味着你不能只用一种解析方式,需要 HTML 解析和接口请求配合使用。

用浏览器开发者工具抓一下网络请求,列表页的请求返回的是完整 HTML,里面包含商品名称、价格、链接等基础信息。但详情页中有一个 Ajax 请求,返回的是 JSON 格式的扩展数据,包含库存、评价、历史价格等深度信息。这个 Ajax 请求的 URL 中带了一个动态生成的 token 参数,这个 token 是通过前端 JS 算法计算的,每次请求都会变化。

看似简单的页面结构背后,可能藏着多层反爬逻辑。第一步永远是分析,不是写代码。

8.1.2 数据字段定义与抓取策略

在正式写代码之前,怕浪猫习惯先把要抓取的数据字段定义清楚。这一步看似简单,但它直接决定了后续的解析逻辑和存储结构。如果不提前定义好字段,写到一半发现需要改数据结构,整个解析模块都要跟着改。

我们把数据字段分为三类:列表页字段、详情页字段和 Ajax 接口字段。

字段类别字段名称数据类型来源解析方式
列表页product_idstringHTMLCSS选择器
列表页titlestringHTMLCSS选择器
列表页pricefloatHTMLCSS选择器
列表页urlstringHTMLCSS选择器
详情页descriptionstringHTMLXPath
详情页categorystringHTMLCSS选择器
Ajax接口stockintJSON接口请求
Ajax接口ratingfloatJSON接口请求
Ajax接口reviews_countintJSON接口请求
Ajax接口price_historylistJSON接口请求

抓取策略的设计思路是:先从列表页批量抓取商品摘要信息,获取所有商品的 URL 和基础字段。然后对每个商品 URL 发起详情页请求,解析 HTML 获取描述和分类信息。最后对每个详情页发起 Ajax 请求获取扩展数据。三步操作形成一条数据流水线。

这个策略看起来很直接,但每一步都会遇到反爬障碍。列表页有请求头检测和 IP 频率限制,详情页需要登录态(Cookie),Ajax 接口需要动态 token。三步三种反爬,这就是综合反爬的典型特征。

8.1.3 爬虫文件的解析和数据的抓取

基于上述分析,我们用 Scrapy 框架来搭建爬虫。为什么选 Scrapy 而不是 requests + BeautifulSoup?因为 Scrapy 的中间件机制天然适合处理多种反爬策略的组合,而且后续要扩展为分布式爬虫,Scrapy 是最成熟的选择。

先看 Spider 的核心代码:

python
import scrapy
from urllib.parse import urljoin

class ProductSpider(scrapy.Spider):
    name = 'product'
    allowed_domains = ['target.example.com']
    start_urls = ['https://target.example.com/category/electronics']

    def parse(self, response):
        # 解析列表页,提取商品链接
        items = response.css('div.product-item')
        for item in items:
            product = {
                'product_id': item.css('::attr(data-id)').get(),
                'title': item.css('h3.title::text').get(),
                'price': float(item.css('span.price::text').get('0')),
                'url': urljoin(response.url, item.css('a::attr(href)').get())
            }
            yield scrapy.Request(
                product['url'],
                callback=self.parse_detail,
                meta={'item': product}
            )
        # 翻页
        next_page = response.css('a.next-page::attr(href)').get()
        if next_page:
            yield scrapy.Request(urljoin(response.url, next_page))

这段代码做了两件事:解析列表页提取商品信息,然后对每个商品发起详情页请求。注意这里用了 meta 参数把已经解析到的商品信息传递给下一个回调函数,避免重复解析。

详情页的解析逻辑:

python
    def parse_detail(self, response):
        item = response.meta['item']
        item['description'] = response.xpath(
            '//div[@class="description"]/text()'
        ).get()
        item['category'] = response.css(
            'span.category::text'
        ).get()
        # 发起Ajax请求获取扩展数据
        token = self.generate_token(item['product_id'])
        ajax_url = f'https://target.example.com/api/item/{item["product_id"]}'
        yield scrapy.Request(
            ajax_url,
            callback=self.parse_ajax,
            meta={'item': item},
            headers={'X-Token': token}
        )

这里有个关键点:Ajax 请求需要带一个动态 token。这个 token 是通过前端 JS 算法生成的,我们在上一章已经完成了逆向,这里直接调用逆向后的算法。generate_token 方法封装了 token 生成逻辑:

python
    def generate_token(self, product_id):
        import hashlib
        import time
        ts = str(int(time.time() * 1000))
        raw = f"{product_id}_{ts}_secret_key"
        return hashlib.md5(raw.encode()).hexdigest()

最后是 Ajax 响应的解析:

python
    def parse_ajax(self, response):
        item = response.meta['item']
        import json
        data = json.loads(response.text)
        item['stock'] = data.get('stock', 0)
        item['rating'] = data.get('rating', 0.0)
        item['reviews_count'] = data.get('reviews_count', 0)
        item['price_history'] = data.get('price_history', [])
        yield item

到这里,爬虫的核心解析逻辑就完成了。但如果你直接运行这段代码,大概率前几页就会被封。原因很简单:请求头没伪装、IP 没轮换、Cookie 没带、没有频率控制。这些就是下一节要解决的反爬问题。

写爬虫代码只占工作量的三成,剩下七成都在和反爬系统博弈。代码是骨架,反爬策略才是血肉。

8.2 反爬分析与突破

8.2.1 综合反爬手段识别

在动手突破之前,怕浪猫先带大家系统地识别一下目标网站到底用了哪些反爬手段。这一步非常关键,因为如果连对手用了什么都不知道,突破就无从谈起。

我用控制变量法逐个测试:保持其他条件不变,只改变一个变量,观察响应结果。这种方法虽然耗时,但能最准确地定位反爬类型。

请求头检测:用最简陋的请求(只有 URL,不带任何额外请求头)访问目标网站,返回 403。然后逐步添加请求头,发现至少需要 User-Agent、Accept、Accept-Language 三个字段才能通过。再进一步测试,发现目标网站还会校验 Referer 字段——从详情页发起的 Ajax 请求如果不带 Referer,直接返回 403。

IP 频率限制:用完整的请求头,在同一个 IP 下连续请求 20 次列表页,第 15 次开始返回 429(Too Many Requests)。等待 60 秒后恢复,说明是短期频率限制。但如果一天内同一个 IP 请求超过 500 次,会被加入长期黑名单,需要 24 小时才能解封。

登录态验证:详情页本身不需要登录态,但详情页中的 Ajax 接口需要 Cookie。不带 Cookie 访问 Ajax 接口,返回 {"code": 401, "message": "请先登录"}。带上从浏览器复制的 Cookie 后,正常返回数据。说明 Ajax 接口有登录态校验。

数据加密:Ajax 接口返回的 JSON 数据中,部分字段是加密的。比如 price_history 字段的值不是明文数组,而是一串 Base64 编码的字符串,解码后还需要用 AES 解密才能拿到真实数据。加密密钥在 JS 代码中动态生成,和当前时间戳有关。

用一个表格总结:

反爬手段触发条件响应特征严重程度
请求头检测缺少必要请求头403 Forbidden
IP频率限制同IP 15次/分钟429 Too Many Requests
长期IP封禁同IP 500次/天403 + 黑名单页面
登录态验证Ajax接口无Cookie401 未登录
数据加密Ajax响应部分字段字段值为密文

反爬分析就像战场侦察,花在侦察上的时间永远比花在冲锋上的时间值得。

8.2.2 针对性突破方案设计

识别完反爬手段,接下来设计突破方案。怕浪猫的原则是:每种反爬手段对应一种突破策略,多管齐下,形成完整的反反爬体系。

请求头检测的突破:构建完整的请求头模板,每次请求随机选择一个 User-Agent,并确保 Referer 字段与请求上下文一致。我们维护一个 User-Agent 池,包含 Chrome、Firefox、Safari 等多种浏览器的真实 UA 字符串。

python
import random

USER_AGENTS = [
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
    '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
    '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) '
    'Gecko/20100101 Firefox/121.0',
]

def get_random_headers(referer=None):
    headers = {
        'User-Agent': random.choice(USER_AGENTS),
        'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9',
        'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
    }
    if referer:
        headers['Referer'] = referer
    return headers

IP 频率限制的突破:接入代理池,每个请求使用不同的代理 IP。代理池的搭建在前面的章节已经详细讲过,这里不再重复。关键是控制请求间隔,避免单个代理 IP 的请求频率触发限制。我们设置每个 IP 的请求间隔为 4-6 秒的随机值,确保不超过每分钟 15 次的阈值。

登录态验证的突破:维护一个 Cookie 池,包含多个有效登录账号的 Cookie。每次 Ajax 请求从 Cookie 池中轮换取用。Cookie 的获取方式有两种:一种是通过模拟登录自动获取,另一种是手动登录后在浏览器中复制。对于大规模爬取,建议用自动化登录脚本定期补充 Cookie 池。

数据加密的突破:Ajax 响应中的加密字段,通过 JS 逆向已经拿到了解密算法。在 Python 中用 pycryptodome 库实现对应的解密逻辑:

python
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import base64
import time

def decrypt_field(encrypted_b64, ts):
    """解密Ajax响应中的加密字段"""
    key = f"key_{ts}".encode('utf-8').ljust(16, b'0')[:16]
    cipher = AES.new(key, AES.MODE_ECB)
    decrypted = unpad(
        cipher.decrypt(base64.b64decode(encrypted_b64)),
        AES.block_size
    )
    return decrypted.decode('utf-8')

这里有一个坑怕浪猫必须提醒你:AES 密钥的生成逻辑和时间戳有关,如果客户端时间和服务端时间偏差超过 30 秒,解密就会失败。所以在实际运行中,需要定期从服务器同步时间,或者在第一次请求时获取服务器时间戳作为基准。

8.2.3 突破效果验证

方案设计完了,不跑一遍验证怎么行。我用 Scrapy 写了一个完整的测试 Spider,集成了上述所有突破策略,跑了 1000 个商品详情页的数据。

测试结果如下:

指标突破前突破后
成功率12%94.3%
平均响应时间1.2s2.1s
封IP率85%3.2%
数据完整度40%96%
1000条耗时失败38分钟

突破前只有 12% 的成功率,主要失败原因是 IP 被封和 Cookie 缺失。突破后成功率提升到 94.3%,剩余的 5.7% 失败主要是代理 IP 质量不稳定和网络超时。平均响应时间从 1.2 秒增加到 2.1 秒,这是因为代理转发和请求间隔控制带来的额外延迟。数据完整度从 40% 提升到 96%,之前缺失的字段主要是需要 Cookie 才能获取的 Ajax 数据。

还有几个踩坑经验值得分享。第一个坑:代理 IP 的质量直接决定成功率。我用过几家代理服务商,便宜的那些 IP 存活率不到 50%,表面上代理池里有 1000 个 IP,实际能用的不到 500 个。后来换了质量更好的代理商,IP 存活率稳定在 90% 以上,成功率直接拉满。

第二个坑:Cookie 的有效期比想象中短。有些网站的 Cookie 有效期只有 2 小时,超时后 Ajax 接口返回 401。所以 Cookie 池需要一个后台任务定期刷新,检测到失效 Cookie 立即标记并替换。

第三个坑:请求间隔不能完全随机。完全随机的间隔在某些极端情况下可能产生连续的短间隔请求,触发频率限制。更好的方案是设置一个最小间隔保底值,然后在此基础上加随机增量。

纸上得来终觉浅,绝知此事要躬行。反爬方案不是设计出来的,是调出来的。

8.3.1 Scrapy 架构回顾

在讲 Cookie 池接入之前,怕浪猫先带大家回顾一下 Scrapy 的核心架构。理解架构是理解中间件的前提,因为 Cookie 池就是通过中间件接入的。

Scrapy 的架构由以下几个核心组件构成:

┌──────────┐     ┌─────────┐     ┌────────────┐     ┌──────────┐
│  Spider  │────→│ Engine  │────→│ Downloader │────→│ 目标网站  │
│          │←────│         │←────│            │←────│          │
└──────────┘     └─────────┘     └────────────┘     └──────────┘
     │               ↑                   ↑
     │               │                   │
     ↓          ┌────┴────┐        ┌─────┴──────┐
┌──────────┐    │ Item    │        │ Downloader │
│ Pipeline │    │ Pipeline│        │ Middleware │
└──────────┘    └─────────┘        └────────────┘

数据流是这样的:Spider 生成 Request 对象交给 Engine,Engine 把 Request 交给 Downloader Middleware 处理(在这里可以修改请求头、添加 Cookie、设置代理等),处理后的 Request 由 Downloader 发送给目标网站。目标网站返回的 Response 经过 Downloader Middleware 的 process_response 方法处理后,回到 Engine,再交给 Spider 的回调函数解析。解析出的 Item 对象交给 Item Pipeline 处理(在这里做数据清洗和存储)。

理解数据流是理解中间件的前提。中间件不是一个独立组件,而是数据流上的一个处理节点。

Cookie 池应该接在哪里?答案是 Downloader Middleware。因为 Cookie 是请求级别的——每个请求都需要分配不同的 Cookie,而 Downloader Middleware 正好可以在请求发出之前对 Request 对象进行修改。

先定义 Cookie 池的数据结构。Cookie 池本质上是一个存储多个有效 Cookie 的集合,需要支持以下操作:获取一个可用 Cookie、添加新 Cookie、标记失效 Cookie、统计 Cookie 状态。

python
import redis
import json
import time

class CookiePool:
    def __init__(self, host='localhost', port=6379):
        self.redis = redis.Redis(host=host, port=port, db=2)
        self.pool_key = 'cookie_pool'
        self.blocked_key = 'cookie_blocked'

    def get_cookie(self):
        """随机获取一个可用Cookie"""
        cookies = self.redis.lrange(self.pool_key, 0, -1)
        if not cookies:
            return None
        cookie = random.choice(cookies)
        return json.loads(cookie)

    def add_cookie(self, cookie_data):
        """添加新Cookie到池中"""
        cookie_data['add_time'] = time.time()
        self.redis.rpush(self.pool_key, json.dumps(cookie_data))

    def block_cookie(self, cookie_id):
        """标记Cookie失效,从池中移除"""
        cookies = self.redis.lrange(self.pool_key, 0, -1)
        for cookie in cookies:
            data = json.loads(cookie)
            if data.get('id') == cookie_id:
                self.redis.lrem(self.pool_key, 1, cookie)
                data['block_time'] = time.time()
                self.redis.lpush(self.blocked_key, json.dumps(data))
                break

这里用 Redis 作为 Cookie 池的存储后端,原因有两个:一是 Redis 的列表结构天然适合做池子,lpush/rpop 操作是原子的;二是后续要扩展为分布式爬虫时,多个 Worker 节点需要共享同一个 Cookie 池,Redis 天然支持多进程访问。

接下来是 Downloader Middleware 的实现:

python
import random
import logging

class CookieRotationMiddleware:
    def __init__(self, cookie_pool):
        self.cookie_pool = cookie_pool
        self.logger = logging.getLogger(__name__)

    def process_request(self, request, spider):
        # 只对需要Cookie的请求添加Cookie
        if request.meta.get('need_cookie', False):
            cookie = self.cookie_pool.get_cookie()
            if cookie:
                request.cookies = cookie.get('cookies')
                request.meta['cookie_id'] = cookie.get('id')
                self.logger.debug(f"使用Cookie: {cookie.get('id')}")
            else:
                self.logger.warning("Cookie池为空!")
                return None
        return None

    def process_response(self, request, response, spider):
        # 检测Cookie是否失效
        if response.status == 401:
            cookie_id = request.meta.get('cookie_id')
            if cookie_id:
                self.cookie_pool.block_cookie(cookie_id)
                self.logger.info(f"Cookie {cookie_id} 已失效,从池中移除")
                return request  # 返回request触发重试
        return response

这段代码有两个核心逻辑。process_request 方法在请求发出之前执行,从 Cookie 池中随机取一个 Cookie 附加到请求上。注意这里用了 request.meta['need_cookie'] 来控制哪些请求需要加 Cookie——列表页不需要登录态,只有详情页的 Ajax 接口才需要。

process_response 方法在响应返回后执行,检测响应状态码。如果返回 401(未授权),说明当前 Cookie 已经失效,立即从池中移除这个 Cookie,并返回 request 对象触发 Scrapy 的重试机制。重试时会从池中取一个新的 Cookie,大概率能正常获取数据。

settings.py 中启用中间件:

python
DOWNLOADER_MIDDLEWARES = {
    'myproject.middlewares.CookieRotationMiddleware': 543,
    'myproject.middlewares.UserAgentMiddleware': 544,
    'myproject.middlewares.ProxyMiddleware': 545,
}

中间件的优先级数字越小越先执行。这里把 Cookie 中间件设为 543,表示在 User-Agent 和代理中间件之后执行。这样请求到达 Downloader 时,请求头、代理 IP 和 Cookie 都已经设置好了。

Cookie 轮换看起来简单——每个请求分配一个不同的 Cookie 就行。但实际操作中,轮换策略的好坏直接影响成功率和 Cookie 消耗速度。怕浪猫测试过三种轮换策略,各有优劣。

随机轮换:每次请求从池中随机取一个 Cookie。优点是实现简单,Cookie 使用均匀。缺点是同一个 Cookie 可能在短时间内被多次使用,如果两次请求间隔太短,可能触发频率限制。

python
# 随机轮换
cookie = random.choice(self.cookie_pool.get_all())

顺序轮换:按顺序依次使用每个 Cookie,用完一轮再从头开始。优点是每个 Cookie 的使用间隔最均匀。缺点是需要维护一个全局计数器,在分布式环境下需要用 Redis 的 incr 命令实现原子递增。

python
# 顺序轮换(Redis原子递增)
index = self.redis.incr('cookie_index') % pool_size
cookie = self.redis.lindex('cookie_pool', index)

权重轮换:根据 Cookie 的历史成功率分配权重,成功率高的 Cookie 被选中的概率更大。优点是能自动优先使用高质量 Cookie。缺点是实现复杂,需要维护每个 Cookie 的成功/失败计数。

python
# 权重轮换
def get_weighted_cookie(self):
    cookies = self.redis.lrange('cookie_pool', 0, -1)
    weights = []
    for c in cookies:
        data = json.loads(c)
        success = data.get('success_count', 1)
        fail = data.get('fail_count', 0)
        weight = success / (success + fail + 1)
        weights.append(weight)
    return json.choices(cookies, weights=weights)[0]

实测下来,对于我们的目标网站,顺序轮换的效果最好。原因是目标网站的 IP 频率限制是按 Cookie(即账号)维度计数的,顺序轮换能确保每个 Cookie 的请求间隔最均匀。随机轮换偶尔会出现连续使用同一 Cookie 的情况,导致频率限制。权重轮换在 Cookie 质量差异不大时,效果和顺序轮换差不多,但实现更复杂。

轮换策略没有银弹,最适合你目标网站的策略,只能通过实测来确定。

Cookie 失效是必然发生的事情。关键不是如何避免失效,而是如何快速检测失效并自动替换。怕浪猫设计了一套完整的 Cookie 生命周期管理机制。

Cookie 的生命周期分为四个状态:活跃(active)、冷却(cooling)、失效(blocked)、过期(expired)。活跃状态的 Cookie 可以正常使用。冷却状态表示该 Cookie 刚刚触发了一次错误(比如 401),暂时不使用,等待一段时间后自动恢复为活跃状态。失效状态表示该 Cookie 已确认无法使用(比如连续触发 401 三次),需要从池中移除。过期状态表示 Cookie 超过最大使用时间,需要重新登录获取。

python
class CookieLifecycle:
    def __init__(self, redis_client):
        self.redis = redis_client
        self.max_fail_count = 3      # 最大失败次数
        self.max_age_hours = 2        # 最大存活时间(小时)
        self.cooling_seconds = 60     # 冷却时间(秒)

    def use_cookie(self, cookie_id):
        """标记Cookie被使用"""
        self.redis.hincrby('cookie_stats', f'{cookie_id}:used', 1)

    def report_fail(self, cookie_id):
        """报告Cookie使用失败"""
        fail_count = self.redis.hincrby(
            'cookie_stats', f'{cookie_id}:fail', 1
        )
        if fail_count >= self.max_fail_count:
            self.block_cookie(cookie_id)
            return 'blocked'
        else:
            self.cool_cookie(cookie_id)
            return 'cooling'

    def report_success(self, cookie_id):
        """报告Cookie使用成功,重置失败计数"""
        self.redis.hset('cookie_stats', f'{cookie_id}:fail', 0)
        self.redis.hincrby('cookie_stats', f'{cookie_id}:success', 1)

    def clean_expired(self):
        """清理过期Cookie"""
        now = time.time()
        cookies = self.redis.lrange('cookie_pool', 0, -1)
        for c in cookies:
            data = json.loads(c)
            age = now - data.get('add_time', 0)
            if age > self.max_age_hours * 3600:
                self.block_cookie(data['id'])

这套机制的核心思想是:不要一遇到错误就立即移除 Cookie。网络抖动、服务端临时故障都可能导致偶发的 401 错误,如果立即移除 Cookie,会造成不必要的浪费。先用冷却机制暂停使用,如果冷却后仍然失败,再判定为失效。这样能最大化 Cookie 的使用寿命。

单靠 Cookie 池或单靠代理池都能在一定程度上突破反爬,但真正强大的反爬系统需要两者配合。原理很简单:如果只用代理池不用 Cookie 池,同一个 Cookie 从多个 IP 发起请求,服务端可能判定为 Cookie 泄露而封禁该 Cookie。如果只用 Cookie 池不用代理池,同一个 IP 使用多个 Cookie,服务端可能判定为异常行为而封禁该 IP。只有代理 IP 和 Cookie 同时轮换,每个请求看起来都像来自不同用户的不同设备,才能最大程度地模拟真实用户行为。

请求1: IP-A + Cookie-A  →  看起来是用户A在设备A上操作
请求2: IP-B + Cookie-B  →  看起来是用户B在设备B上操作
请求3: IP-C + Cookie-C  →  看起来是用户C在设备C上操作
...

在 Scrapy 中实现双重轮换,只需要在 Downloader Middleware 中同时接入代理池和 Cookie 池:

python
class DoubleAntiAntiCrawlMiddleware:
    def __init__(self, cookie_pool, proxy_pool):
        self.cookie_pool = cookie_pool
        self.proxy_pool = proxy_pool

    def process_request(self, request, spider):
        # 设置代理
        proxy = self.proxy_pool.get_proxy()
        if proxy:
            request.meta['proxy'] = proxy['url']
            request.meta['proxy_id'] = proxy['id']

        # 设置Cookie
        if request.meta.get('need_cookie'):
            cookie = self.cookie_pool.get_cookie()
            if cookie:
                request.cookies = cookie['cookies']
                request.meta['cookie_id'] = cookie['id']
        return None

    def process_response(self, request, response, spider):
        if response.status == 401:
            cookie_id = request.meta.get('cookie_id')
            if cookie_id:
                self.cookie_pool.block_cookie(cookie_id)
            return request  # 触发重试
        if response.status in (403, 429):
            proxy_id = request.meta.get('proxy_id')
            if proxy_id:
                self.proxy_pool.block_proxy(proxy_id)
            return request  # 触发重试
        return response

这段代码的精妙之处在于 process_response 方法中对不同错误码的差异化处理。401 错误说明 Cookie 失效,标记 Cookie。403 或 429 错误说明代理 IP 被封,标记代理 IP。两种情况都返回 request 对象触发重试,重试时会自动使用新的代理 IP 和 Cookie。

还有一个细节需要注意:代理和 Cookie 的轮换要保持独立性。不要把某个代理 IP 和某个 Cookie 绑定在一起轮换,否则一旦其中一个失效,另一个也会被连带影响。正确的做法是让两者各自独立轮换,每次请求都从代理池和 Cookie 池中分别随机取用。

双重轮换的效果是显著的。我做过对比测试:单用代理池时成功率为 78%,单用 Cookie 池时成功率为 71%,两者配合使用时成功率提升到 94%。原因很简单:双重轮换让每个请求的"指纹"都不同,服务端的风控系统无法将多个请求关联到同一个爬虫实体。

两层防线叠加不是简单的 1+1=2,而是指数级的提升。代理池解决 IP 维度的问题,Cookie 池解决账号维度的问题,两者结合才能应对综合反爬。

8.4 分布式爬虫架设

8.4.1 Scrapy-Redis 分布式方案

单机爬虫的吞吐量是有上限的。当你需要抓取的数据量达到百万级甚至千万级时,单台机器无论怎么优化都无法在可接受的时间内完成。这时候就需要引入分布式爬虫。

Scrapy-Redis 是最成熟的 Scrapy 分布式方案。它的核心思想是用 Redis 替换 Scrapy 默认的内存队列,让多个 Worker 节点共享同一个请求队列和去重集合。

┌──────────────────────────────────────────────────┐
│                    Redis 服务器                   │
│  ┌──────────────┐  ┌───────────────────────────┐  │
│  │  请求队列      │  │  去重集合 (FIFO)            │  │
│  │  (List)       │  │  (Set)                    │  │
│  └──────┬───────┘  └───────────┬───────────────┘  │
└─────────┼──────────────────────┼──────────────────┘
          │                      │
     ┌────┴────┐            ┌────┴────┐
     ↓         ↓            ↓         ↓
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│Worker A │ │Worker B │ │Worker C │ │Worker D │
│ 节点1    │ │ 节点2    │ │ 节点3    │ │ 节点4    │
└─────────┘ └─────────┘ └─────────┘ └─────────┘

请求队列用 Redis 的 List 结构实现,每个 Worker 从队列中 lpop 取出请求,处理后再把新产生的请求 rpush 到队列尾部。去重集合用 Redis 的 Set 结构实现,每个请求的 URL 计算一个指纹(通常是 URL 的 MD5),入队前先检查指纹是否已存在,存在则跳过。

Scrapy-Redis 的安装和配置非常简单。先安装:

bash
pip install scrapy-redis

然后在 settings.py 中修改调度器和去重组件:

python
# 替换调度器为Scrapy-Redis
SCHEDULER = 'scrapy_redis.scheduler.Scheduler'

# 启用Redis去重
DUPEFILTER_CLASS = 'scrapy_redis.dupefilter.RFPDupeFilter'

# Redis连接配置
REDIS_URL = 'redis://:password@192.168.1.100:6379/0'

# 是否在关闭时持久化队列
SCHEDULER_PERSIST = True

Spider 的改动也很小,把基类从 scrapy.Spider 换成 RedisSpider

python
from scrapy_redis.spiders import RedisSpider

class DistributedProductSpider(RedisSpider):
    name = 'distributed_product'
    redis_key = 'product:start_urls'

    def parse(self, response):
        # 解析逻辑与单机版完全一致
        items = response.css('div.product-item')
        for item in items:
            product = {
                'product_id': item.css('::attr(data-id)').get(),
                'title': item.css('h3.title::text').get(),
            }
            yield product
            yield scrapy.Request(
                item.css('a::attr(href)').get(),
                callback=self.parse_detail
            )

注意 redis_key 参数,它指定了 Redis 中存放起始 URL 的键名。启动时,你不需要在 Spider 中定义 start_urls,而是通过 Redis 推送起始 URL:

bash
redis-cli lpush product:start_urls https://target.example.com/category/electronics

多个 Worker 节点同时运行时,它们会从同一个 Redis 队列中竞争请求。谁先 lpop 到某个请求,谁就负责处理它。这就是分布式调度的核心——共享队列实现负载均衡。

分布式爬虫的本质不是把代码复制到多台机器,而是把状态集中管理,把计算分散执行。

8.4.2 分布式 Spider 调度

多节点 Worker 部署涉及几个关键问题:节点启动与停止、任务分配、状态监控、故障恢复。

节点启动与停止:每个 Worker 节点就是一个普通的 Scrapy 进程,通过 scrapy crawl distributed_product 启动。停止时直接 Ctrl+C 或 kill 进程即可。由于队列和去重集合都在 Redis 中,Worker 的启停不会丢失数据。新产生的请求已经写入 Redis 队列,即使所有 Worker 都停了,下次启动后还能继续处理。

任务分配:Scrapy-Redis 天然支持负载均衡。多个 Worker 从同一个队列竞争请求,处理速度快的节点自然会处理更多请求。不需要手动分配任务。

状态监控:通过 Redis 可以实时查看队列状态:

python
import redis

def monitor_queue(redis_url):
    r = redis.from_url(redis_url)
    queue_len = r.llen('product:requests')
    dupefilter_size = r.scard('product:dupefilter')
    print(f'待处理请求: {queue_len}')
    print(f'已去重URL: {dupefilter_size}')

故障恢复:这是分布式系统的核心问题。假设 Worker A 取出了一个请求但在处理过程中崩溃了,这个请求就丢失了。Scrapy-Redis 的解决方案是设置请求超时时间。如果一个请求被取出后超过超时时间仍未完成,通过 Redis 中记录的时间戳判断,该请求会被重新放回队列。

多节点部署时还有一个容易被忽视的问题:各节点的代码版本必须一致。如果某个节点的解析逻辑更新了但其他节点没有更新,会导致数据格式不一致。建议使用 Docker 镜像来保证各节点环境一致,配合 CI/CD 流水线实现自动化部署。

8.4.3 Scrapyd 部署与管理

手动在每台服务器上启动 Scrapy 进程显然不够优雅。Scrapyd 是一个专门用于部署和运行 Scrapy 爬虫的服务,提供了 REST API 来管理爬虫任务。

Scrapyd 的架构:Scrapyd 作为一个独立服务运行在每个 Worker 节点上。它接收 HTTP 请求来启动、停止、查询爬虫任务。爬虫以子进程方式运行,Scrapyd 负责监控进程状态并收集日志。

先在 Worker 节点上安装和启动 Scrapyd:

bash
pip install scrapyd
scrapyd  # 默认监听 6800 端口

然后用 scrapyd-client 打包上传项目:

bash
pip install scrapyd-client
scrapyd-deploy target_node -p myproject

scrapyd-deploy 会把项目打包成 egg 文件,通过 HTTP 上传到 Scrapyd 服务器。你需要在项目根目录的 scrapy.cfg 中配置目标服务器信息:

ini
[settings]
default = myproject.settings

[deploy:target_node]
url = http://192.168.1.101:6800/
project = myproject

上传成功后,通过 API 启动爬虫:

bash
curl http://192.168.1.101:6800/schedule.json \
  -d project=myproject \
  -d spider=distributed_product

Scrapyd 返回任务 ID,你可以用这个 ID 查询任务状态:

bash
curl http://192.168.1.101:6800/status.json?job=<job_id>

停止任务:

bash
curl http://192.168.1.101:6800/cancel.json \
  -d project=myproject \
  -d job=<job_id>

这里有一个实战踩坑经验:Scrapyd 默认用 pickle 序列化方式保存任务状态,如果你的 Scrapy 版本和 Scrapyd 版本不匹配,可能会出现序列化兼容性问题。建议在所有节点上使用相同版本的 Scrapy 和 Scrapyd,或者用 Docker 统一环境。

另一个坑:Scrapyd 的日志默认写到磁盘文件,长时间运行会占用大量磁盘空间。需要在 scrapyd.conf 中配置日志轮转:

ini
[scrapyd]
logs_dir = /var/log/scrapyd
max_proc = 4
items_dir = /var/data/scrapyd
logs_limit = 7

Scrapyd 的 API 文档可以在其官方 GitHub 仓库找到:https://github.com/scrapy/scrapyd。更多 API 接口和参数说明请参考官方文档。

8.4.4 分布式爬虫管理系统

当 Worker 节点超过 5 个以上时,手动管理每个节点的 Scrapyd 就变得很痛苦了。你需要一个统一的管理界面来管理所有节点的爬虫任务。这里介绍两个主流的开源方案:Gerapy 和 Crawlab。

Gerapy 是一个基于 Django 的 Scrapyd 管理界面。它提供了 Web UI 来管理多个 Scrapyd 节点,支持项目管理、定时任务、日志查看等功能。Gerapy 的 GitHub 地址:https://github.com/Gerapy/Gerapy。

Gerapy 的安装和使用:

bash
pip install gerapy
gerapy init
cd gerapy
gerapy makemigrations
gerapy migrate
gerapy runserver 0.0.0.0:8000

访问 http://localhost:8000 就可以看到管理界面。添加 Scrapyd 节点只需要填入 IP 和端口,Gerapy 会自动检测节点状态。你可以在界面上直接部署项目、启动爬虫、查看日志,不用再敲 curl 命令了。

Gerapy 的局限性在于它只管理 Scrapyd 节点,不管理代理池和 Cookie 池。如果你的反爬体系比较复杂,Gerapy 的功能可能不够用。

Crawlab 是一个更全面的分布式爬虫管理平台。它不仅支持 Scrapy,还支持 Python 脚本、Node.js 脚本等各种类型的爬虫。Crawlab 的架构更复杂,但功能也更强大。Crawlab 的 GitHub 地址:https://github.com/crawlab-team/crawlab。

Crawlab 的核心特性对比:

特性GerapyCrawlab
爬虫类型仅 ScrapyScrapy/Python/Node.js
节点管理简单完善的主从架构
任务调度手动/定时手动/定时/依赖链
数据管理内置 MongoDB 存储
日志查看Web UIWeb UI + 实时流
代理管理内置代理池
部署方式DjangoDocker/K8s

如果你只需要管理 Scrapy 爬虫且节点数量不多,Gerapy 足够用。如果你有多种类型的爬虫、需要复杂的任务调度、或者需要内置代理池和数据存储,Crawlab 是更好的选择。

Crawlab 的 Docker 部署非常方便:

bash
docker run -d --name crawlab \
  -p 8080:8080 \
  -v /data/crawlab:/data \
  tikazyq/crawlab:latest

部署完成后访问 http://localhost:8080,默认账号密码是 admin/admin。在界面上添加 Worker 节点、创建爬虫任务、配置定时调度,一切都在 Web 界面完成。

怕浪猫在实际项目中用 Crawlab 管理过 20+ 个 Worker 节点的爬虫集群。最大的体会是:Crawlab 的节点自动注册功能非常好用,新加一台服务器只需要装好 Crawlab Worker 并启动,主节点会自动发现并纳入管理。但 Crawlab 的资源占用比 Gerapy 高不少,如果服务器配置不高,建议用 Gerapy。

工具的选择没有标准答案,适合你的团队规模和技术栈的,就是最好的。

实战踩坑总结

这一章的实战过程中,怕浪猫踩了不少坑,这里把最有价值的经验分享出来。

坑一:Cookie 池和代理池的容量配比。一开始我按照 1:1 的比例配置 Cookie 池和代理池,各有 100 个。结果发现 Cookie 消耗速度远快于代理 IP,因为 Cookie 有效期短(2 小时),而代理 IP 有效期长(大部分代理商的 IP 有效期在 1-24 小时)。后来调整为 Cookie 池 300 个、代理池 100 个,比例 3:1,消耗速度才匹配上。

坑二:Scrapy-Redis 的去重集合膨胀。长时间运行后,Redis 中的去重集合会越来越大,占用大量内存。解决方案是定期清理过期的指纹,或者用 Bloom Filter 替换 Set 结构。Bloom Filter 的空间占用只有 Set 的 1/10 左右,代价是有极低的误判率(可能重复抓取少量页面,但对大部分场景可以接受)。

python
# 使用Bloom Filter替换默认去重
DUPEFILTER_CLASS = 'scrapy_redis_bloomfilter.dupefilter.RFPDupeFilter'
BLOOMFILTER_HASH_NUMBER = 6
BLOOMFILTER_BIT = 30

坑三:分布式环境下的 Pipeline 重复写入。多个 Worker 同时向数据库写入数据时,如果不做去重,同一个商品可能被多个节点抓取并写入多次。解决方案是在 Pipeline 中用数据库的唯一索引做兜底,或者用 Redis 做一层分布式锁。

python
class DedupPipeline:
    def __init__(self, redis_client):
        self.redis = redis_client

    def process_item(self, item, spider):
        item_key = f"dedup:{item['product_id']}"
        if self.redis.set(item_key, 1, nx=True, ex=86400):
            return item
        else:
            raise DropItem(f"重复数据: {item['product_id']}")

坑四:Scrapyd 的版本兼容性。Scrapyd 和 Scrapy 的版本必须匹配,否则会出现各种奇怪的问题。建议用 requirements.txt 锁定版本,并用 Docker 镜像部署。

坑五:网络超时处理。分布式环境下,Worker 节点和 Redis 服务器之间的网络可能不稳定。如果 Redis 连接超时,Scrapy-Redis 会抛出异常导致爬虫崩溃。需要在 settings.py 中配置重试机制:

python
RETRY_TIMES = 3
RETRY_HTTP_CODES = [500, 502, 503, 504, 408, 429]
DOWNLOAD_TIMEOUT = 30

同时在 Redis 连接配置中加入连接池和超时参数:

python
REDIS_PARAMS = {
    'socket_timeout': 5,
    'socket_connect_timeout': 5,
    'retry_on_timeout': True,
    'max_connections': 50,
}

每一个坑都是用血泪换来的经验。踩坑不可怕,可怕的是同一个坑踩两次。

本章核心知识图谱

最后,怕浪猫把这一章的核心知识点整理成一张知识图谱,方便你回顾:

反爬实战练习
├── 反爬识别
│   ├── 请求头检测 → UA轮换 + Referer构造
│   ├── IP频率限制 → 代理池轮换
│   ├── 登录态验证 → Cookie池管理
│   └── 数据加密 → JS逆向 + Python解密
├── Scrapy中间件
│   ├── Downloader Middleware架构
│   ├── Cookie轮换策略(随机/顺序/权重)
│   ├── Cookie生命周期管理
│   └── 代理池+Cookie池双重轮换
├── 分布式架构
│   ├── Scrapy-Redis(共享队列+去重集合)
│   ├── 多节点Worker部署
│   ├── Scrapyd(REST API管理)
│   └── 管理系统(Gerapy/Crawlab)
└── 踩坑经验
    ├── 池容量配比
    ├── 去重集合膨胀
    ├── Pipeline重复写入
    └── 网络超时处理

系列进度 8/11

到这里,第8章的内容就讲完了。我们从目标网站分析开始,逐步拆解了综合反爬的识别与突破,然后用 Scrapy 中间件实现了 Cookie 池和代理池的双重轮换,最后扩展到分布式爬虫的架设与部署。这些内容形成了一个完整的从单机到分布式、从分析到突破的实战闭环。

下章预告:第9章将深入分布式爬虫架构方案,从下游数据消费到大数据存储选型,解决海量数据存储问题。当你的爬虫规模从万级增长到亿级时,数据存储将取代反爬成为最大的技术挑战。我们会聊 MongoDB、Elasticsearch、ClickHouse 等存储方案的选型对比,以及如何设计支撑亿级数据的数据架构。

怕浪猫说:反爬这件事,说到底是一场信息不对称的博弈。你比对方多想一步,就能多抓一条数据。Cookie 池、代理池、分布式架构,这些技术手段的本质都是消除信息不对称——让对方看到的你,看起来更像一个真实用户。但记住,技术只是工具,对目标网站的敬畏心才是你走得更远的保障。别把对方的服务器搞挂了,这是爬虫工程师的底线。

好了,这章就到这里。如果你觉得内容有用,收藏一下,下次实战的时候翻出来照着写。有什么问题评论区见,怕浪猫会一一回复。我们下章见。

热爱生活,喜好美食,追求未来!