☰
Scrapy反爬对抗实战:从中间件到动态渲染的完整方案
2026/10/9 4:00:51 网站建设 项目流程

1. 认清拦截信号,判断你的爬虫卡在哪一层

在爬虫进阶这条路上,多数人遇到的第一道坎并不是代码写不出来,而是代码明明写对了、请求也发出去了,返回的数据怎么都不对。这种时刻最忌讳的就是盲目改代码,一遍又一遍调 Header,越调越乱。我自己早期也这么折腾过,后来总结出一条经验:遇到反爬虫问题,先不要问“怎么突破”,先问“它在哪一层拦的我”。定位清楚了,方案自然就出来了。

1.1 状态码拦截与误判陷阱

状态码是最直白的拦截信号。服务器返回 403、404、503,很多人的第一反应是“URL 是不是写错了”,但在实际场景里,这几个状态码要分开看。

403 大概率是权限问题,可能是目标站有 IP 黑名单,也可能是指纹识别判定你为脚本,还可能是防盗链设置。遇到 403,最常做的检查是请求头里的 Referer 和 User-Agent 是否模拟到位。404 也未必是 URL 错误,有些站点会针对特定 User-Agent 或特定区域返回 404,故意藏起资源,这在数据敏感的场景里很常见。503 通常是服务端过载或限流,也可能是 WAF 在返回伪装的 503,这时配合重试与退避策略,比强制换代理更有效。

提示:单看一个状态码很容易被误导。应该把 Headers、响应时间、响应体大小放在一起判断。例如状态码 200 但响应体为空,比直接返回 403 更值得警惕。

1.2 内容污染型拦截

这种拦截方式最容易让人抓狂。状态码 200,页面结构看起来也很完整,但里面的数据是假的、被打乱的,或者直接返回一个验证码页面的 HTML。这属于典型的“伪正常响应”。

我之前写过一段爬虫去抓某个商城的分类页,页面正常返回,解析之后发现商品名称全是乱码字符串,价格也都是随机数。这就是服务端识破了脚本之后给出的“水数据”。内容污染型拦截的核心意图是让你“即使拿到数据也不敢用”。应对手段有两种:一是如果站点存在正常的前端数据接口,优先抓接口而不是抓渲染后的 HTML;二是加入数据合理性校验,比如关键字段正则匹配、价格区间判断、非空校验,在数据入库之前就把脏数据过滤掉。

1.3 行为维度封禁的逻辑

当单次请求层面的伪装都做到位之后,服务器还有一种更高级的手段:记录一段时间内某个 IP、某个设备指纹的访问频率和访问路径,再通过行为分析判断你是不是程序。正常人一分钟可能看 5 到 10 个页面,访问路径有搜索、筛选、详情、加购这样的逻辑顺序;而爬虫经常是不带规律地放射状访问几十上百个 URL,频率远超正常阈值,于是 IP 被临时封禁。

行为封禁是最难处理的拦截方式,因为这已经不是“改个 Header”就能解决的层面了。真正的思路是做合规限速与模拟真实用户节奏,而不是无限升级对抗手段。这一点后面我会专门展开。

1.4 动手之前,先做一轮合规判断

在决定“怎么抓”之前,一定要先做一次合规评估。很多爬虫教程不教这个,但进阶开发者必须有自己的底线:爬取任何网站之前,先看一眼站点根目录的 robots.txt,确认哪些路径允许访问;再看服务条款里关于数据抓取的规定;涉及个人信息数据的一律谨慎处理。并不是所有数据都适合爬,也并不是所有“反爬虫突破”都值得尝试。合规判断不是为了限制技术,而是保护自己。

2. 从 requests 到 Scrapy:不仅是换库,更是换架构

不少朋友问过我同一个问题:“我明明用 requests 写得好好的,为什么要换 Scrapy?Scrapy 看起来又复杂,还要配各种组件。”说实话,中小规模的任务用 requests 完全没问题,我自己也保留了很多 requests 写的脚本用于临时需求。但当你开始频繁处理数以万计的 URL、需要并发控制、需要自动重试、需要把数据清洗和入库流程独立出来时,Scrapy 的价值才会真正体现出来。

2.1 同步 requests 在大规模抓取时的瓶颈

requests 踩过的坑,我都踩过。最开始写爬虫用 for 循环加 requests.get(),逻辑很直接:

import requests for url in url_list: resp = requests.get(url, headers=headers) parse(resp.text)

这个写法在几十个 URL 时完全没问题,但到上万量级时,单线程同步请求的短板就暴露得很明显。每个请求发出后,程序几乎都在等服务器返回,如果服务器平均响应时间是 0.5 秒,那一秒最多发出两个请求,一万个 URL 就要跑 5000 秒,大概一个半小时,这还没算解析数据和网络波动的时间成本。

后来我试过用线程池提速:

from concurrent.futures import ThreadPoolExecutor with ThreadPoolExecutor(max_workers=8) as pool: pool.map(fetch, url_list)

速度确实上来了,但新问题也不少:部分请求超时怎么重试?如何控制全局并发,避免把目标服务器压垮?中途挂了怎么恢复进度?共享状态的维护、日志记录、失败续跑全都得自己实现。当脚本里的临时逻辑越堆越多时,本质上你已经在手写一个小型爬虫框架了。

2.2 Scrapy 的异步引擎与并发调度

Scrapy 的骨架是构建在 Twisted 异步框架之上的。它在一个线程中通过事件循环驱动整个抓取流程:引擎把 Request 交给调度器排队,调度器依次取出请求交给下载器发送,下载器在等待响应的空档,事件循环会继续处理其他已经完成的响应和回调,而不是干等。所以在同样的单进程模型下,Scrapy 能同时维持十几个甚至几十个在途请求,抓取效率和for + requests.get()完全是两个量级。

决定抓取速度的几个关键配置项:

  • CONCURRENT_REQUESTS:全局并发请求数,默认 16。
  • CONCURRENT_REQUESTS_PER_DOMAIN:每个域名的并发上限,默认 8。
  • DOWNLOAD_DELAY:两次请求之间的延迟秒数,用于限速。

我的经验是:“快”不等于“好”,长期稳定抓取更依赖限额速而不是一味拉高并发。一个比较保守但稳妥的开始配置是:

CONCURRENT_REQUESTS = 8 CONCURRENT_REQUESTS_PER_DOMAIN = 4 DOWNLOAD_DELAY = 3

这个配置看起来不激进,但能让爬虫长时间稳定运行,不容易因为频繁访问触发服务端的封禁策略。

2.3 工程化组件带来的隐性收益

Scrapy 最有价值的一点,是它把爬虫项目天然拆成了几个你迟早会需要的模块:Spider 负责定义如何抓取和解析,Pipeline 负责数据清洗与存储,Middleware 负责请求前后的拦截处理,Item 承担字段定义和校验。这个结构让多人协作和后期维护都舒服很多。

举个例子,你要把抓取结果分别写入 MySQL 和 Elasticsearch,只需要定义两条 Pipeline,再在配置里挂上:

ITEM_PIPELINES = { "your_project.pipelines.MysqlPipeline": 300, "your_project.pipelines.ElasticPipeline": 400, }

Pipeline 的数值代表执行顺序,数值小的先执行。如果某个 Pipeline 处理失败,还可以通过open_spider与close_spider方法管理连接池、批量提交等生命周期问题。这些“边角功能”在手写 requests 脚本时每一样都要从零实现,在 Scrapy 里却只是配置项而已。

分布式爬虫也是从这套工程化结构上长出来的,后面我们会单独聊。

3. Scrapy 中间件:反爬对抗的真正主战场

如果说 Scrapy 是一台机器,那么中间件就是流水线上可插拔的关卡。反爬对抗的大部分处理逻辑都能在这一层完成,因为它允许你在请求“出站”前和响应“回站”后插入自定义逻辑,而且完全不用动主流程代码。

3.1 下载中间件的调用顺序与执行链路

Scrapy 的下载中间件主要通过三个方法切入请求生命周期:

  • process_request:每个 Request 发出前被调用,可以修改 Headers、更换代理、计算签名,也可以直接返回一个 Response 来跳过下载。
  • process_response:每个 Response 返回后被调用,可以检查状态码、判断内容是否需要重试。
  • process_exception:请求发生异常时调用,比如超时、连接失败,可以决定重试还是放弃。

中间件在DOWNLOADER_MIDDLEWARES里的数字,决定了它在执行链上的位置。数字越小越靠近引擎,越大越靠近下载器。以一套典型配置为例:

DOWNLOADER_MIDDLEWARES = { "scrapy.downloadermiddlewares.useragent.UserAgentMiddleware": 400, "scrapy.downloadermiddlewares.retry.RetryMiddleware": 500, "your_project.middlewares.RandomUserAgentMiddleware": 350, "your_project.middlewares.ProxyMiddleware": 600, }

当你同时挂载多个中间件时,process_request按数字从低到高执行,而process_response按数字从高到低执行。理解这个顺序是排查中间件问题的第一步,否则你可能会困惑为什么某个中间件的修改在另一个中间件里读不到。

3.2 User-Agent 轮换与请求头指纹修正

目标站最常见的检测维度就是 User-Agent。如果所有请求都带着同一个 UA,在服务端日志里会非常扎眼。解决方案是在 settings 里维护一个 UA 列表,在自定义中间件中随机选择:

import random class RandomUserAgentMiddleware: def __init__(self, ua_list): self.ua_list = ua_list @classmethod def from_crawler(cls, crawler): return cls(ua_list=crawler.settings.getlist("USER_AGENT_LIST")) def process_request(self, request, spider): request.headers["User-Agent"] = random.choice(self.ua_list) request.headers["Accept-Language"] = "zh-CN,zh;q=0.9,en;q=0.8"

对应在 settings.py 中维护 UA 池:

USER_AGENT_LIST = [ "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/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", ]

一个容易忽略的细节是:除了 User-Agent,Accept、Accept-Encoding、Sec-Fetch-*这些头也会被服务端组合起来分析。只换 UA 但保留一个明显异常的 Accept 头,还是会被识别。最简单的做法是先用浏览器开发者工具抓一次真实请求,把请求头原样拷贝进项目里,再在中间件里只对 UA 做轮换。

3.3 代理池与智能重试策略的配合

在需要大规模抓取或目标站对单 IP 有严格频率限制时,代理池几乎是标配。Scrapy 中给某个请求加代理的方式是通过request.meta:

class ProxyMiddleware: def process_request(self, request, spider): proxy = self.get_proxy_from_pool() request.meta["proxy"] = "http://" + proxy

这里有个关键经验:不要对所有请求无差别使用同一个代理。代理 IP 一旦被目标站识别出集中访问的特征,整个代理都会被连坐封禁。正确做法是按一定策略轮换,同时限制单个代理 IP 的访问频率,让它尽量贴近真实用户的使用节奏。

重试策略同样值得定制。Scrapy 默认的 RetryMiddleware 对 500、502、503 等状态码会直接重试,但它不会区分“服务端过载”和“单个请求被误伤”两种场景。更常见的需求是:遇到 429(Too Many Requests)时不要立刻重试,而是退避一段时间再试。下面这个简单实现可以做到按状态码精细控制:

class TooManyRequestsRetryMiddleware: def process_response(self, request, response, spider): if response.status == 429: retry_times = request.meta.get("retry_times", 0) if retry_times < 3: retry_times += 1 request.meta["retry_times"] = retry_times request.meta["retry_wait"] = retry_times * 30 spider.logger.warning(f"收到429,第{retry_times}次重试") return request return response

注意,process_response里返回 request 对象,这个请求就会重新进入调度队列,而不是直接交给下载器。重试请求会保留原有的 meta 信息,所以你可以通过 meta 中的字段控制最大重试次数,避免无限循环。

3.4 限速与并发:别让自己变成攻击流量

聊到反爬对抗,我想强调一个经常被忽略的原则:服务端不是傻瓜,如果你每个 IP 每秒发 20 个请求,再怎么伪装 Headers 都藏不住。真正的工程化处理是让自己的流量特征贴近真实用户。

Scrapy 内置的 AutoThrottle 扩展就是为此设计的,它会根据服务器的响应时间和延迟,自动调整并发数和下载延迟。开启方式:

AUTOTHROTTLE_ENABLED = True AUTOTHROTTLE_START_DELAY = 2 AUTOTHROTTLE_MAX_DELAY = 10 AUTOTHROTTLE_TARGET_CONCURRENCY = 2

开启后,Scrapy 会通过分析请求的响应时间,自动控制对同一站点的访问节奏:服务器响应慢就自动放慢,响应快也不盲目加速。对绝大多数目标站来说,把AUTOTHROTTLE_ENABLED打开是我强烈建议的默认操作,而不是在遇到封禁之后才想起来配限速。

4. 动态渲染与 iframe:Playwright 接入的完整方案

动态页面是反爬环节里和“前端技术”关系最密切的分支。现在很多网站的数据都是通过 JavaScript 异步加载出来的,你拿到的 HTML 只是空壳,真正的数据在浏览器执行完脚本之后才出现在 DOM 中。requests 和 Scrapy 的默认下载器只能拿到未经渲染的 HTML,自然提取不到目标内容。

4.1 数据“拿不到”的底层原因

常见的“拿不到数据”可以归为三类:

第一种是异步接口渲染。页面初始 HTML 只有空壳,数据是浏览器通过 XHR 或 fetch 请求后端接口后,再通过 JavaScript 动态填充到 DOM 里的。你直接抓 HTML 当然什么都拿不到。

第二种是数据在接口层做了混淆。数据不是简单 JSON,而是经过编码、需要在前端脚本里解密后再渲染的。这种场景里,单纯找接口很费劲,浏览器渲染反而是最省力的路径。

第三种是 iframe 嵌套。整块内容放在子 frame 里,父页面的 DOM 里根本没有这部分数据,你在主文档里怎么 XPath 都定位不到。

我在处理动态页面时的原则是:优先寻找后端 API 接口,只有在前端接口不直接暴露、或数据被加密封装的情况下,才引入浏览器渲染方案。浏览器渲染消耗的内存和 CPU 远高于普通请求,速度也更慢,能不用就不用。

4.2 scrapy-playwright 的安装与核心配置

Scrapy 接入 Playwright 有成熟的第三方扩展scrapy-playwright。它让 Scrapy 的下载处理器支持由 Chromium 等浏览器渲染页面,这样你就能在 Scrapy 的回调里拿到渲染完成后的 DOM。

关键配置如下:

DOWNLOAD_HANDLERS = { "http": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler", "https": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler", } TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor" PLAYWRIGHT_BROWSER_TYPE = "chromium"

这里有一点要特别提醒:TWISTED_REACTOR必须设置为AsyncioSelectorReactor,因为 Playwright 是异步库,依赖 asyncio 事件循环。如果漏掉这行配置,程序会在启动时直接报错,而且这个报错还不太直观。

4.3 在 Spider 中控制页面渲染与资源回收

在 Spider 中,通过请求的 meta 字段来标记需要渲染的请求:

import scrapy class DynamicSpider(scrapy.Spider): name = "dynamic_spider" def start_requests(self): url = "https://example.com/list" yield scrapy.Request( url, meta={ "playwright": True, "playwright_include_page": True, "playwright_page_methods": [ {"method": "wait_for_selector", "args": [".list-item"]}, ], }, callback=self.parse_list, ) async def parse_list(self, response): page = response.meta.get("playwright_page") try: html = await page.content() selector = scrapy.Selector(text=html) for item in selector.xpath("//div[@class='list-item']"): yield {"title": item.xpath(".//h2/text()").get()} finally: if page: await page.close()

这段代码里有几个实操细节:

playwright_include_page为 True 时,response 里会携带真实的 page 对象,你可以在回调里继续做操作,比如wait_for_selector、模拟点击等。但关键是,用完必须手动关闭 page,否则浏览器窗口会随着请求数量不断增加,内存很快就爆掉。上面的finally块中关闭 page,是跑大量页面时防止内存泄漏的必修课。

回调函数必须写成async def,因为内部要awaitplaywright 的异步方法。这是一个很容易被忽略的点——写成普通def的话,协程内部的 await 会直接报语法错误。

4.4 iframe 切换与跨文档数据合并

当你需要模拟真实用户操作并抓取 iframe 中的内容时,Playwright 的方法相当直接:

frame = page.frame_locator("iframe#content") title = frame.locator("h1.product-title").inner_text()

frame_locator可以直接进入 iframe 上下文,后续的locator查找都是在 frame 内部进行的。如果有嵌套 iframe,就连续调用两次frame_locator。

但很多场景里,主页面有列表信息,iframe 里有细节信息,你需要把两部分拼在一起组成一条完整数据。更合理的做法是在同一段异步逻辑里同时取主页面数据和 iframe 数据,再合并成一条 Item:

async def parse_detail(self, response): page = response.meta.get("playwright_page") try: main_title = await page.locator("h1.main-title").inner_text() spec_frame = page.frame_locator("iframe#spec") spec_text = await spec_frame.locator(".spec-content").inner_text() yield { "title": main_title, "spec": spec_text, } finally: if page: await page.close()

这种“主页面 + 子 frame”组合提取的方式,比先抓父页面再单独抓 iframe URL 要稳定得多,因为父子页面的会话和渲染状态天然保持一致。

5. XPath 中的 text() 细节:解析阶段的翻车与修复

XPath 是解析 HTML 最核心的技能之一,而text()这个函数看起来简单,实际用起来却藏着一堆门道。这里我总结几个自己踩过的坑。

5.1 get() 只取第一个节点,后面还有一半数据

先看一个典型 HTML:

<div class="price"> 299 <span>元</span> </div>

如果你写:

selector.xpath("//div[@class='price']/text()").get()

返回的是"299"。注意,get()只取 XPath 匹配结果中的第一个文本节点,而不是把 div 下所有文本都拼起来。如果 div 里有多个文本节点,比如换行、空格、后续追加的文本,只有getall()才能拿到全部:

selector.xpath("//div[@class='price']/text()").getall()

这个“get()只取第一个”的逻辑经常让人误以为 XPath 写错了。我团队里的新人就犯过好几次这个错,把//div/text()换成各种奇怪写法去调,其实问题只是出在没用getall()。

5.2 normalize-space():一行函数解决空白数据

网页中的文本经常夹杂大量换行和缩进,直接拿 text() 通常会得到类似\n 299元\n的脏数据。正确的做法是用normalize-space():

selector.xpath("normalize-space(//div[@class='price']/text())").get()

normalize-space()会去除首尾空白,并把内部连续的空白压缩成一个空格。处理价格、标题、摘要这类短文本字段时,这个函数比我见过的大部分字符串清洗逻辑都简洁高效。

还有一类常见情况:标题文本被分散嵌套在多个标签中,例如<h2>Hello <em>World</em></h2>,直接用//h2/text()只会拿到 “Hello”。此时应该用//h2//text(),它会把 h2 下所有子孙文本节点都取出来:

"".join(selector.xpath("//h2//text()").getall())

5.3 位置、兄弟节点与混用 CSS 的选择策略

对于结构比较复杂的目标,XPath 的条件匹配能玩出很多花样。比如取列表中第三项的标题:

//ul[@class='list']/li[3]//h2/text()

再比如取某个节点后面紧跟的兄弟节点文本:

//h2[contains(text(),'价格')]/following-sibling::div[1]/text()

在实际项目中,我通常会混用 XPath 和 CSS 选择器:CSS 在 class、id 选择上更简洁,适合定位容器节点;XPath 在文本匹配、位置选择、兄弟节点关系上更强。比如先用 CSS 定位到一个大区块,再用 XPath 在区块内部精确定位目标字段。两套工具都能熟练用,比只盯着一套死磕要高效得多。

6. 去重、分页与增量更新:规模化爬虫的工程底盘

规模化的意义不只是“跑起来”,还要“跑得住”。随着抓取轮次增多,三个工程问题会浮出水面:重复请求、分页断裂、数据过期。

6.1 指纹去重:从内存 Set 到 Redis 布隆

Scrapy 默认用请求指纹做内存级去重:把请求的 method、URL、部分 headers 字段拼成指纹存进集合。单机爬虫用默认方案就够了,但分布式或者数据量大时,内存会扛不住。

一个常见做法是把去重集合放到 Redis 里,既可以用 Redis 的 Set 结构,也可以用布隆过滤器来节省内存。Scrapy-Redis 提供了现成的去重组件,核心思路是重写RFPDupeFilter,通过 Redis 的sadd和sismember判断指纹是否已存在。简单来说,就是让“请求去重”这件事从单机内存中解放出来,变成多个爬虫节点共享的全局去重。

我自己的实践是:即使在单机场景,如果 URL 数量超过百万级,也会把去重逻辑挪到 Redis。内存去重在大规模任务里并不比 Redis 快多少,但内存管理上要省心得多。

6.2 分页循环的终止条件设计

分页处理是高频翻车点。很多网站的翻页参数不是简单的page=1,2,3,可能是offset、cursor或者某种签名参数。如果只是机械递增页码,很容易爬到重复内容,或者直接触发风控。

建议在代码里写明确的“终止条件”,而不是死循环。当 response 中没有“下一页”按钮,或者发现当前页与上一页的数据指纹重复时,立即停止。这样的逻辑能避免在分页循环里浪费大量请求和时间。

一个简单实用的判断方法:

next_page = response.xpath("//a[contains(text(),'下一页')]/@href").get() if next_page: yield scrapy.Request(response.urljoin(next_page), callback=self.parse_list)

这个写法比循环里硬编码“抓满 N 页”更可靠,因为它完全跟着页面实际返回走,页面不给下一页就自动停止。

6.3 增量更新与数据失效判断

增量更新比全量重爬重要得多。全量重爬不仅对目标服务器压力大,数据量大之后几乎不可行。我的做法是给每个 Item 设计业务主键,比如商品 ID 或者内容 ID,入库时用它判断数据是否已存在。如果已存在,就对比关键字段,比如价格、库存、发布时间,有变化才执行更新。

还有一个容易忽略的细节:失效 URL 不要直接扔进异常日志。单独记录到一个失效表里,作为判断目标网站是否调整页面结构的参考。如果某个频道的失效 URL 短时间内大量增加,往往意味着页面模板变了,需要回头检查解析规则,而不是继续傻等下一次重试。

7. 最后关于边界,和一点点个人的经验

最后聊一点不写代码但我觉得同等重要的东西。

爬虫和反爬虫的对抗不同于普通编程问题,它是一个动态博弈:你换了 UA,服务端就加指纹检测;你上了代理,服务端就做行为建模;你引入浏览器渲染,服务端还能上验证码和人机识别。想靠“无限加对抗手段”去赢下每一次博弈,注定是一条越走越窄的路,也很容易踩到灰色地带。

我自己在实操中建立的几条底线是:第一,始终遵守 robots.txt 中明确的限制;第二,无论目标站点是否设置了反爬,都保持一个合理、有节制的请求频率,不让自己变成目标服务的负担;第三,涉及登录后才能访问的数据、个人信息和其他敏感内容,不去尝试通过爬虫获取;第四,如果数据有明确商业用途,优先寻找官方 API,或者直接和网站运营方沟通合作。这种路径的成本往往远低于无休止地琢磨“怎么突破反爬虫”,也更安全。

回到技术本身,Scrapy、Playwright、XPath 这套组合,加上合理的中间件设计,已经能覆盖绝大多数正常公共数据的抓取场景。把这些地基打扎实,比钻研某个网站的单点绕过技巧要有价值得多。爬虫这项技能的核心能力,不应该是无限突破边界,而是在边界之内高效地解决问题——这是我做了这么多年爬虫,最想提醒后来者的一句话。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询