第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_id | string | HTML | CSS选择器 |
| 列表页 | title | string | HTML | CSS选择器 |
| 列表页 | price | float | HTML | CSS选择器 |
| 列表页 | url | string | HTML | CSS选择器 |
| 详情页 | description | string | HTML | XPath |
| 详情页 | category | string | HTML | CSS选择器 |
| Ajax接口 | stock | int | JSON | 接口请求 |
| Ajax接口 | rating | float | JSON | 接口请求 |
| Ajax接口 | reviews_count | int | JSON | 接口请求 |
| Ajax接口 | price_history | list | JSON | 接口请求 |
抓取策略的设计思路是:先从列表页批量抓取商品摘要信息,获取所有商品的 URL 和基础字段。然后对每个商品 URL 发起详情页请求,解析 HTML 获取描述和分类信息。最后对每个详情页发起 Ajax 请求获取扩展数据。三步操作形成一条数据流水线。
这个策略看起来很直接,但每一步都会遇到反爬障碍。列表页有请求头检测和 IP 频率限制,详情页需要登录态(Cookie),Ajax 接口需要动态 token。三步三种反爬,这就是综合反爬的典型特征。
8.1.3 爬虫文件的解析和数据的抓取
基于上述分析,我们用 Scrapy 框架来搭建爬虫。为什么选 Scrapy 而不是 requests + BeautifulSoup?因为 Scrapy 的中间件机制天然适合处理多种反爬策略的组合,而且后续要扩展为分布式爬虫,Scrapy 是最成熟的选择。
先看 Spider 的核心代码:
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 参数把已经解析到的商品信息传递给下一个回调函数,避免重复解析。
详情页的解析逻辑:
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 生成逻辑:
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 响应的解析:
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接口无Cookie | 401 未登录 | 高 |
| 数据加密 | Ajax响应部分字段 | 字段值为密文 | 中 |
反爬分析就像战场侦察,花在侦察上的时间永远比花在冲锋上的时间值得。
8.2.2 针对性突破方案设计
识别完反爬手段,接下来设计突破方案。怕浪猫的原则是:每种反爬手段对应一种突破策略,多管齐下,形成完整的反反爬体系。
请求头检测的突破:构建完整的请求头模板,每次请求随机选择一个 User-Agent,并确保 Referer 字段与请求上下文一致。我们维护一个 User-Agent 池,包含 Chrome、Firefox、Safari 等多种浏览器的真实 UA 字符串。
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 headersIP 频率限制的突破:接入代理池,每个请求使用不同的代理 IP。代理池的搭建在前面的章节已经详细讲过,这里不再重复。关键是控制请求间隔,避免单个代理 IP 的请求频率触发限制。我们设置每个 IP 的请求间隔为 4-6 秒的随机值,确保不超过每分钟 15 次的阈值。
登录态验证的突破:维护一个 Cookie 池,包含多个有效登录账号的 Cookie。每次 Ajax 请求从 Cookie 池中轮换取用。Cookie 的获取方式有两种:一种是通过模拟登录自动获取,另一种是手动登录后在浏览器中复制。对于大规模爬取,建议用自动化登录脚本定期补充 Cookie 池。
数据加密的突破:Ajax 响应中的加密字段,通过 JS 逆向已经拿到了解密算法。在 Python 中用 pycryptodome 库实现对应的解密逻辑:
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.2s | 2.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 Scrapy 接入 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 对象进行修改。
8.3.2 Downloader Middleware 中间件接入 Cookie 池
先定义 Cookie 池的数据结构。Cookie 池本质上是一个存储多个有效 Cookie 的集合,需要支持以下操作:获取一个可用 Cookie、添加新 Cookie、标记失效 Cookie、统计 Cookie 状态。
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 的实现:
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 中启用中间件:
DOWNLOADER_MIDDLEWARES = {
'myproject.middlewares.CookieRotationMiddleware': 543,
'myproject.middlewares.UserAgentMiddleware': 544,
'myproject.middlewares.ProxyMiddleware': 545,
}中间件的优先级数字越小越先执行。这里把 Cookie 中间件设为 543,表示在 User-Agent 和代理中间件之后执行。这样请求到达 Downloader 时,请求头、代理 IP 和 Cookie 都已经设置好了。
8.3.3 Cookie 轮换策略
Cookie 轮换看起来简单——每个请求分配一个不同的 Cookie 就行。但实际操作中,轮换策略的好坏直接影响成功率和 Cookie 消耗速度。怕浪猫测试过三种轮换策略,各有优劣。
随机轮换:每次请求从池中随机取一个 Cookie。优点是实现简单,Cookie 使用均匀。缺点是同一个 Cookie 可能在短时间内被多次使用,如果两次请求间隔太短,可能触发频率限制。
# 随机轮换
cookie = random.choice(self.cookie_pool.get_all())顺序轮换:按顺序依次使用每个 Cookie,用完一轮再从头开始。优点是每个 Cookie 的使用间隔最均匀。缺点是需要维护一个全局计数器,在分布式环境下需要用 Redis 的 incr 命令实现原子递增。
# 顺序轮换(Redis原子递增)
index = self.redis.incr('cookie_index') % pool_size
cookie = self.redis.lindex('cookie_pool', index)权重轮换:根据 Cookie 的历史成功率分配权重,成功率高的 Cookie 被选中的概率更大。优点是能自动优先使用高质量 Cookie。缺点是实现复杂,需要维护每个 Cookie 的成功/失败计数。
# 权重轮换
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 质量差异不大时,效果和顺序轮换差不多,但实现更复杂。
轮换策略没有银弹,最适合你目标网站的策略,只能通过实测来确定。
8.3.4 Cookie 失效自动标记与替换
Cookie 失效是必然发生的事情。关键不是如何避免失效,而是如何快速检测失效并自动替换。怕浪猫设计了一套完整的 Cookie 生命周期管理机制。
Cookie 的生命周期分为四个状态:活跃(active)、冷却(cooling)、失效(blocked)、过期(expired)。活跃状态的 Cookie 可以正常使用。冷却状态表示该 Cookie 刚刚触发了一次错误(比如 401),暂时不使用,等待一段时间后自动恢复为活跃状态。失效状态表示该 Cookie 已确认无法使用(比如连续触发 401 三次),需要从池中移除。过期状态表示 Cookie 超过最大使用时间,需要重新登录获取。
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 的使用寿命。
8.3.5 集成代理池 + 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 池:
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 的安装和配置非常简单。先安装:
pip install scrapy-redis然后在 settings.py 中修改调度器和去重组件:
# 替换调度器为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 = TrueSpider 的改动也很小,把基类从 scrapy.Spider 换成 RedisSpider:
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:
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 可以实时查看队列状态:
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:
pip install scrapyd
scrapyd # 默认监听 6800 端口然后用 scrapyd-client 打包上传项目:
pip install scrapyd-client
scrapyd-deploy target_node -p myprojectscrapyd-deploy 会把项目打包成 egg 文件,通过 HTTP 上传到 Scrapyd 服务器。你需要在项目根目录的 scrapy.cfg 中配置目标服务器信息:
[settings]
default = myproject.settings
[deploy:target_node]
url = http://192.168.1.101:6800/
project = myproject上传成功后,通过 API 启动爬虫:
curl http://192.168.1.101:6800/schedule.json \
-d project=myproject \
-d spider=distributed_productScrapyd 返回任务 ID,你可以用这个 ID 查询任务状态:
curl http://192.168.1.101:6800/status.json?job=<job_id>停止任务:
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 中配置日志轮转:
[scrapyd]
logs_dir = /var/log/scrapyd
max_proc = 4
items_dir = /var/data/scrapyd
logs_limit = 7Scrapyd 的 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 的安装和使用:
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 的核心特性对比:
| 特性 | Gerapy | Crawlab |
|---|---|---|
| 爬虫类型 | 仅 Scrapy | Scrapy/Python/Node.js |
| 节点管理 | 简单 | 完善的主从架构 |
| 任务调度 | 手动/定时 | 手动/定时/依赖链 |
| 数据管理 | 无 | 内置 MongoDB 存储 |
| 日志查看 | Web UI | Web UI + 实时流 |
| 代理管理 | 无 | 内置代理池 |
| 部署方式 | Django | Docker/K8s |
如果你只需要管理 Scrapy 爬虫且节点数量不多,Gerapy 足够用。如果你有多种类型的爬虫、需要复杂的任务调度、或者需要内置代理池和数据存储,Crawlab 是更好的选择。
Crawlab 的 Docker 部署非常方便:
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 左右,代价是有极低的误判率(可能重复抓取少量页面,但对大部分场景可以接受)。
# 使用Bloom Filter替换默认去重
DUPEFILTER_CLASS = 'scrapy_redis_bloomfilter.dupefilter.RFPDupeFilter'
BLOOMFILTER_HASH_NUMBER = 6
BLOOMFILTER_BIT = 30坑三:分布式环境下的 Pipeline 重复写入。多个 Worker 同时向数据库写入数据时,如果不做去重,同一个商品可能被多个节点抓取并写入多次。解决方案是在 Pipeline 中用数据库的唯一索引做兜底,或者用 Redis 做一层分布式锁。
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 中配置重试机制:
RETRY_TIMES = 3
RETRY_HTTP_CODES = [500, 502, 503, 504, 408, 429]
DOWNLOAD_TIMEOUT = 30同时在 Redis 连接配置中加入连接池和超时参数:
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 池、代理池、分布式架构,这些技术手段的本质都是消除信息不对称——让对方看到的你,看起来更像一个真实用户。但记住,技术只是工具,对目标网站的敬畏心才是你走得更远的保障。别把对方的服务器搞挂了,这是爬虫工程师的底线。
好了,这章就到这里。如果你觉得内容有用,收藏一下,下次实战的时候翻出来照着写。有什么问题评论区见,怕浪猫会一一回复。我们下章见。