上周我接手了一个爬虫任务,目标是抓一个行情列表页。打开开发者工具,Network面板里清清楚楚能看到数据接口,但用requests模拟的时候,死活拿不到正确的响应,要么直接403,要么返回一段混淆过的JS。再回头看看页面源码,整个列表区域空空荡荡,数据全是JavaScript加载完之后才渲染上去的。这种场景,但凡写过一段时间爬虫的人都不陌生。
这种活儿的痛点是双重的:动态页面意味着你必须先把JS跑起来,反爬又意味着即便你跑起来,也可能因为请求特征不对被拦下来。以前我的方案是Selenium硬开浏览器,再手写一堆cookie同步、请求头修补的逻辑,代码又臭又长,换个目标站点就得重写一半。后来我把整套流程迁到了FEAPDER上,靠它的内置渲染支持和中间件机制,把这两件事变成了纯配置和几个回调函数——这篇文章就把我的完整用法和踩过的坑都盘一遍,给同样被动态页面和反爬折磨的人一个可以直接抄作业的参考。
1. 动态页面问题的根子:HTML是空壳,数据藏在XHR里
1.1 前后端分离之后,requests的第一步就错了
很多人第一次写爬虫的时候,学到的第一行代码都是requests.get(url)拿到HTML,然后用BeautifulSoup去解析。这套思路在十年前是成立的,那时候服务器把数据直接拼在HTML里返回,解析一下就能用。但现在的Web开发主流是前后端分离:服务器只吐一个HTML骨架,真正的数据是页面加载后通过XHR或者fetch请求从接口拿回来的,再通过JavaScript拼装到DOM上。
这就导致一个很尴尬的情况:你用requests取回来的HTML,结构上跟浏览器里看到的页面一模一样,但里面的列表、表格、评论等核心区域全是空的。那是不是顺着Network面板找到XHR接口,直接请求接口就行了?理论上是的,但实际上接口通常有一道或几道防御——请求头校验、动态token、加密参数、Cookie校验,甚至整个接口的返回内容都套了一层自定义加密。这就是为什么网上总有"bs4爬取动态页面"的求助帖,因为问题的关键根本不在解析,而在怎么拿到真实的数据。
1.2 从Selenium到Playwright:渲染这条路是怎么走通的
动态页面问题的解法,本质上就一句话:让请求在一个真正的浏览器环境里发出来。最早的方案是Selenium,它可以驱动本机安装的Chrome或Firefox,页面里所有JS都真实执行,数据自然就渲染出来了。但Selenium有几个很磨人的问题:WebDriver版本要和浏览器版本严格匹配,动不动就不兼容;每次启动一个浏览器实例要花好几秒;对于反爬来说,navigator.webdriver这个特征太明显了,很容易被识别出来。
后来Playwright和Puppeteer这类基于CDP(Chrome DevTools Protocol)的方案普及了,它们不再依赖WebDriver,而是直接和Chromium通信,速度更快、控制粒度更细,还能通过add_init_script在页面加载前注入JS,抹掉很多自动化特征。FEAPDER的内置渲染引擎其实就是基于Playwright封装的,但它把"启动浏览器、创建上下文、注入脚本、等待条件、回收资源"这一整套样板代码全都包了起来。在FEAPDER里,你只需要在请求上标记一下render=True,框架底层就会走渲染通道,把页面加载完后拿到的response返回给你。
1.3 FEAPDER的渲染策略:等元素出现,而不是等秒数
内置渲染支持听起来简单,但真正决定成败的是"什么时候算渲染完成"。很多人的第一反应是time.sleep(5),让页面加载5秒再说。这个办法能用,但极其不稳定——页面内容多的时候5秒不够,内容少的时候又白白浪费好几秒,而且对于有懒加载的页面,你都不知道该等谁。
FEAPDER的做法是基于条件的等待。你可以指定一个wait_selector,比如页面列表区的某个CSS选择器,渲染引擎会持续查询这个元素是否出现,出现了就认为渲染完成,立即返回结果。我具体用的时候会这样写:
import feapder from feapder import Request, Item class QuoteSpider(feapder.AirSpider): def start_requests(self): yield Request( "https://quote.example.com/list", render=True, wait_selector=".quote-table tbody tr", wait_timeout=15, ) def parse(self, request, response): rows = response.xpath("//table/tbody/tr") for row in rows: item = Item( code=row.xpath("./td[1]/text()").extract_first(), price=row.xpath("./td[2]/text()").extract_first(), change=row.xpath("./td[3]/text()").extract_first(), ) yield item if __name__ == "__main__": QuoteSpider().start()wait_timeout是兜底超时,万一元素一直不出现,最多等15秒就结束,不会让任务无线挂起。这里有一个很重要的经验:等待条件一定要选业务数据所在的元素,而不是body或者某个loading图标。我之前在一个页面上等了半天loading动画消失,结果动画是消失了,数据接口又延迟了2秒才返回,最终渲染结果还是空的。后来改成等表格的某一行数据出现,问题立刻解决。
2. 中间件机制:在请求的每个关键时刻插入你的代码
2.1 中间件不是插件,是请求生命周期的关卡
中间件这个词,写过Laravel的人应该很熟——HTTP请求在经过路由之前,会像流水线一样穿过一层一层的中间件,每一层都可以对请求做修改、判断是否放行,甚至直接返回一个自定义响应。这个"管道式"的设计思路放在爬虫里一样用得上,而且特别好用。
在FEAPDER里,中间件围绕请求的生命周期来做文章。一次抓取动作会经历三个阶段:请求发出之前、响应返回之后、以及出现异常的时候。每个阶段都有对应的中间件钩子,你可以在里面注入逻辑,比如统一加请求头、同步Cookie、做限速、记录日志、处理重试。以前这些逻辑你可能散落在各个解析函数里,贴得到处都是,现在它们被收敛到了中间件里,解耦得干干净净。
2.2 三个注册点:请求前、响应后、异常时
FEAPDER的中间件分为三类,各自的职责非常清晰:
| 中间件类型 | 触发时机 | 典型用途 |
|---|---|---|
| 请求中间件 | Request对象发出前 | 补Headers、同步Cookie、加签名、限速 |
| 响应中间件 | Response返回后、进parse前 | 统一校验状态码、解密响应体、提取公共字段 |
| 异常中间件 | 请求抛出异常时 | 判断错误类型、调整重试策略、切换备用逻辑 |
注册方式是装饰器风格的,非常直观。一个比较完整的请求中间件长这样:
import time @spider.request_middleware def inject_headers(request): request.headers.update({ "X-Requested-With": "XMLHttpRequest", "Referer": "https://quote.example.com/", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", }) return request注意中间件函数必须把处理过的request返回出去,否则框架会认为你不想继续处理这个请求了,它会被直接丢弃。这个细节我踩过坑,后面细说。
2.3 手写一个Cookie同步中间件:让渲染结果反哺普通请求
前面提到,动态页面的反爬很多时候是Cookie校验。第一层请求通过渲染引擎打开页面后,浏览器会种下一批Cookie,其中可能包含了关键的会话凭证。第二次再拿这个凭证去请求XHR接口,服务器就认为你是从真实页面过来的合法用户。
问题来了:渲染引擎拿到的Cookie,怎么给普通请求用?手动粘的话一次两次还行,任务一多就废了。我在FEAPDER里写了一个Cookie同步中间件,思路是让所有走渲染通道的请求,在拿到渲染结果之后,自动把render_result里的Cookie合并到后续请求对象上:
@spider.request_middleware def sync_render_cookies(request): # 如果当前请求是上一个渲染流程带出来的后续请求 if getattr(request, "source_render", None): cookie_str = request.source_render.get_cookie_str() if cookie_str: request.headers["Cookie"] = cookie_str return request这个中间件的爽点在于:你在parse里解析完渲染页面,继续yield出下一页的请求时,那个请求会自动继承渲染环境里的Cookie,链路是通的。整套流程做下来,你完全不用手动管理登录态和Cookie过期问题,只要定期让渲染请求刷新一遍Cookie即可。
2.4 中间件的顺序和执行时机
中间件支持注册多个,而且顺序是有讲究的。FEAPDER会按注册顺序依次执行请求中间件,这跟Laravel的全局中间件数组是一个道理。实际踩坑之后,我总结出两条顺序原则:
一是先补身份、后做业务判断。比如Cookie同步、Header注入这种修改请求本身的中间件,要排在前面;依赖Header做逻辑判断的中间件排在后面。如果你把限速中间件放在最前头,那也行,因为限速不依赖其他字段,但如果你有个中间件要判断request.headers里有没有Cookie再决定是否添加,它就必须排在Cookie同步之后。
二是异常中间件最好放在请求中间件的逻辑外围。意思是,异常中间件处理的是请求过程中抛出的错误,它本身不依赖请求中间件改了什么,所以不必担心顺序,但要注意异常中间件里做重试时,重试的请求依然会重新走一遍完整的请求中间件管道,这时候不要在重试逻辑里再次对同一个请求做重复的头部注入,否则可能出现Header里出现两个User-Agent的情况。我的做法是在重试前把request对象浅拷贝一份,保证每次重试都是干净的状态。
3. 实战拆解:动态行情列表页的完整抓取
3.1 先做逆向分析,再写代码
不管框架多好用,拿到一个目标站点,第一步永远是打开开发者工具,搞清楚数据到底是怎么加载的。以下是我固定的分析路径:
- 看Network面板,刷新页面,观察哪些XHR请求返回了真正需要的数据。通常一眼就能看到,因为返回体里就是列表的JSON。
- 看接口请求的Headers,重点关注几个字段:Cookie里有没有关键凭证、请求参数里有没有动态token或时间戳、响应体是纯JSON还是加密过的字符串。
- 看反爬的层级。很多站点的接口其实允许直接访问,只是要在请求头里带上正确的Referer和Cookie;还有一部分会校验加密参数,比如参数里带了个
sign字段,每次请求都不一样,通常是某个JS函数计算出来的。
对于第一类站点,FEAPDER的渲染+中间件方案可以很优雅地解决;对于第二类,也未必需要去硬啃逆向,因为你可以让渲染引擎直接执行页面里的JS来生成参数,或者干脆让渲染引擎直接把接口请求发出去,把xhr的响应内容带回来。这两种方式都比人肉分析混淆代码要省力得多。
3.2 渲染+Cookie同步+数据抽取,三步组合拳
我这次的目标是一个典型的行情列表页。初步分析发现,列表数据通过XHR加载,接口请求时必须携带一个动态token,token是页面级JS生成后存在Cookie里的,而且每天会失效。requests直连的难点在于:你拿不到这个token的生成逻辑,只能干瞪着。
用FEAPDER的思路是这样走的:第一步,启动一个渲染请求打开列表页,让整个页面JS执行完毕,此时浏览器自动完成了token的生成和Cookie的种植。第二步,通过响应中间件把页面里已经渲染好的DOM数据直接抽取出来——这一步最妙,因为数据已经渲染在页面上了,你甚至不需要去单独请求那个XHR接口。第三步,如果数据量很大,需要翻页,那么后续的翻页请求可以从当前响应中携带Cookie,用普通请求通道快速完成。
这个组合的关键在于:渲染负责解决"第一次握手",普通请求负责解决"后续高效抓取"。我实测下来的数据是,渲染一个页面大概需要3到5秒,普通请求一个接口只要0.3秒左右。如果2000页数据全部走渲染,光耗时就得两个多小时,而只让第一页走渲染拿Cookie、剩下1999页走普通请求,总耗时会降到十分钟左右。
3.3 完整可跑的示例代码
下面是一个完整示例,为了演示我把站点简化了,但结构是通用的:
import feapder from feapder import Request, Item from feapder.utils.log import log class QuoteSpider(feapder.AirSpider): def start_requests(self): # 首页走渲染通道,目的是执行JS并拿到有效Cookie yield Request( "https://quote.example.com/list?page=1", render=True, wait_selector=".quote-table tbody tr", callback=self.parse_first_page, download_middleware=self.middleware_chain, ) def middleware_chain(self, request): # 这里补充通用请求头 request.headers["User-Agent"] = "Mozilla/5.0" return request def parse_first_page(self, request, response): # 首页:抽取数据 + 取出Cookie + 生成后续普通请求 yield self.extract_items(response.selector) # 模拟从响应里拿到翻页总数 total_pages = 20 for page in range(2, total_pages + 1): yield Request( f"https://quote.example.com/list?page={page}", callback=self.parse_other_pages, # 关键点:把本页渲染请求产生的Cookie在中间件里继续沿用 source_render=response.render_result, ) @feapder.request_middleware def sync_cookie(self, request): if getattr(request, "source_render", None): cookie = request.source_render.get_cookie_str() request.headers["Cookie"] = cookie return request def parse_other_pages(self, request, response): yield self.extract_items(response.selector) def extract_items(self, selector): for row in selector.xpath("//table/tbody/tr"): yield Item( code=row.xpath("./td[1]/text()").extract_first(), price=row.xpath("./td[2]/text()").extract_first(), change_pct=row.xpath("./td[3]/text()").extract_first(), ) if __name__ == "__main__": QuoteSpider().start()你可能会注意到,我用了一个response.selector来统一处理,因为FEAPDER渲染通道返回的response和普通response都支持xpath/css选择器,写法是一致的,这样抽取逻辑可以复用。
3.4 为什么这套组合能干过纯requests方案
核心原因可以归纳成三点,都是我在实际项目中反复验证过的:
第一,JS执行权的下沉。动态token、加密参数这类反爬,本质上都是"服务器要求客户端必须能执行JavaScript"的变体。渲染方案直接满足这一条,这是requests硬编码模拟永远追不上的,因为站点可以随时改混淆逻辑,但你改requests代码的成本是一次又一次的逆向,改渲染方案的成本几乎为零。
第二,请求身份的连贯性。有了Cookie同步中间件,渲染环境里建立的身份可以无缝传递给一排普通请求,身份不会断。这种连贯性对很多反爬策略是立竿见影的——服务器看到一个IP先开了个浏览器正常访问首页,随后又按节奏一页一页翻,这是非常自然的用户行为。
第三,维护成本低。站点的前端代码改了、接口地址变了、加密算法升级了,对FEAPDER这套方案来说,影响范围通常只是改个wait_selector或者换一下start_requests里的URL。而requests方案一旦加密升级,整个业务的采集链路就要停摆去逆向,这就是根本差别。
4. 只有跑起来才会撞上的坑
4.1 渲染进程悄悄泄漏,内存一路狂飙
我第一次用FEAPDER跑一个长任务时,大概跑到第三个小时,服务器内存从2G蹭蹭涨到了6G,最后任务直接OOM被杀掉。一开始以为是对手站点的问题,排查了很久才发现,是我自己写的代码里,每次拿到渲染结果后没有显式关闭渲染页面。
FEAPDER的渲染引擎虽然内置了页面回收机制,但如果你在一个parse里开了一大堆后续请求,而这些请求都引用了response.render_result,框架需要等这些引用全部用完才能回收页面,引用链一旦拖着不放,页面对象就会堆积。我的解决方案是两招:一是尽早把render_result里的cookie、文本数据抽取到局部变量,不要带着整个渲染句柄到处传;二是给渲染请求配置一个render_close_after_parse=True,解析一结束就强制关闭页面,内存曲线立刻平稳了。
4.2 中间件异常把整个任务吞掉
这个坑相当隐蔽,而且很难排查。现象是:任务跑到某个节点后,部分请求无影无踪,既没有报错,也没有产出数据,log里干干净净,但就是不继续往下走。
后来我把中间件执行逻辑翻出来一行一行读,发现问题出在一个请求中间件上——它在某些边界情况下抛了个一次性的异常,而框架对中间件异常的默认处理是把当前请求直接视为结束,既不会重试,也不会回调异常处理模块。换句话说,那个请求被异常中间件的默认逻辑"静默吸收"了。
解决办法有两层:第一层,异常中间件只负责兜底,不要在请求中间件里做特别容易抛错的复杂计算,尤其是涉及外部IO的操作(比如读写Redis),要做就做好try/except;第二层,如果你确实在异常中间件里处理错误,一定要返回一个request对象并设置retry_count,不要只写日志不返回。
@spider.exception_middleware def handle_request_error(request, exc): if "403" in str(exc) or "Forbidden" in str(exc): request.retry_count = 5 log.warning(f"触发反爬,剩余重试次数: {request.retry_count}") return request # 其他异常:记日志后交给框架默认处理 log.error(f"请求失败: {request.url}, 错误: {exc}")4.3 等待条件写错,抓回来一堆空页面
还有一次,抓回来的数据整体缺了小一半,单看任何一次请求都没问题,但汇总后就是有缺口。后来我统计了失败页面的特征,发现全是列表在首屏以下的内容——也就是说,页面加载时,首屏数据渲染完了,但下一屏的懒加载还没触发,页面就已经被判定为"渲染完成"了。
这暴露了一个关键问题:wait_selector选的元素必须是目标数据全部出现后才存在的元素,而不是首屏某个恰好先出现的元素。我后来把这个场景拆解成两步:先等页面容器出现,再通过滚动操作触发懒加载。FEAPDER在这块提供了页面滚动和延时执行的方法,但更优雅的做法是在render=True的请求里配置一个wait_selector指向最后一行的数据。如果最后一行的数据出现了,说明前面的全都有了。
4.4 反爬对抗的边界:克制本身也是技术
聊到这里,必须泼一盆冷水。前面教的所有手段,适用的前提是你抓取的是公开数据、频率有节制、不绕过登录墙或付费墙。我见过不少人拿着一个能渲染的爬虫框架,对目标站点一顿猛冲,QPS开到几十上百,结果不仅账号被封,还把对方的运维逼到升级WAF,最后谁也抓不了。
我的原则是:所有频率控制都要前置在中间件里,宁可慢,不要横。FEAPDER的请求中间件里加一个简单的限速逻辑,成本极低,但对双方都是一种保护:
import time _rate_limiter_config = { "interval": 0.8, # 每次请求间隔 "last_request_time": 0, } @spider.request_middleware def rate_limiter(request): now = time.monotonic() gap = now - _rate_limiter_config["last_request_time"] if gap < _rate_limiter_config["interval"]: time.sleep(_rate_limiter_config["interval"] - gap) _rate_limiter_config["last_request_time"] = time.monotonic() return request合规的边界有三条,我一直在遵守:不绕过登录认证、不采集非公开的个人隐私数据、不对目标服务器造成显著压力。爬虫技术的价值在于自动化处理你本可以手动浏览的数据,而不是突破别人的安全防线,这个认知比任何框架技巧都重要。
5. 规模化了该怎么办:渲染请求与普通请求的混合调度
5.1 不是所有请求都值得走渲染
渲染请求的成本很高,尤其是在需要大量翻页的场景下。我曾经在一个采集任务里统计过,渲染通道的QPS大概是0.2到0.5,普通请求通道可以到10到20,差了五十倍左右。所以一旦目标页面的数据可以由某个普通请求接口直接返回,优先考虑用普通请求,渲染只负责最初的握手和身份获取。FEAPDER对这种混合调度是天然支持的,因为它是按请求粒度来决定是否走渲染通道的,你可以在start_requests里同时产出两类请求,框架会自动分流。
5.2 并发边界与浏览器实例复用
当任务规模上来之后,并发是一个绕不开的话题。FEAPDER底层用Playwright管理浏览器实例,这里有一个关键概念需要区分:浏览器实例(Browser)、上下文(Context)和页面(Page)。同一个浏览器实例下可以开多个上下文,不同上下文之间是隔离的,Cookie、缓存互不干扰——这意味着你可以在同一个浏览器进程里并行渲染多个页面,只要把它们放到不同的context里就行。但上下文的隔离也意味着,如果你需要多个任务共享同一个登录态,就要在同一个上下文中创建多个页面。
我给一个通用建议:控制并发数,而不是放任到底。我通常是取一个中间值,比如渲染通道并发控制在4到6个页面,普通请求通道控制在20到30个连接。并发太高时瓶颈反而不是框架,而是目标服务器的响应能力,以及你自己这台机器的内存。
5.3 缓存、去重和断点续爬
长任务跑起来,最怕的就是中途挂掉,从头再来。FEAPDER自带请求去重和失败重试机制,但它默认的去重是基于URL的,如果你还要应对URL里带了动态时间戳的情况,那URL的指纹就不稳定——每个时间戳都是新的,去重形同虚设。我的做法是在请求中间件里对URL做一次规整,把时间戳等动态参数剔除后再计算指纹:
import re import hashlib @spider.request_middleware def stable_fingerprint(request): normalized_url = re.sub(r"_t=\d+", "", request.url) fp = hashlib.md5(normalized_url.encode()).hexdigest() request.fingerprint = fp return request另外,我建议把已成功的响应缓存在本地,比如存成pickle文件,这样重跑的时候遇到相同指纹的URL可以直接命中缓存,不需要重新请求。对于上千页的任务来说,这个缓存带来的提速是肉眼可见的。
关于规模化的另一个方向,当任务量级大到单机跑不过来,队列系统和消息中间件会变成下一个扩展点——把待抓取的请求推送到队列,由多台机器订阅消费。但这是后话,大部分人的业务规模用FEAPDER的单机模式加合理的并发配置已经完全够用了,不需要一上来就搞分布式。
最后说一句实在话
这套方案跑通之后,我最大的感受是:把动态页面和反爬的问题交给一个具备渲染能力和中间件机制的框架,确实能省下大量重复劳动,但爬虫的本质——理解目标页面的加载逻辑、分析数据如何在浏览器里流转——这件事框架替代不了,也不应该替代。FEAPDER帮你把"模拟浏览器"和"处理请求链路"这两件脏活累活包装好了,但你自己还是得能读懂Network面板,能判断哪些数据值得抓、哪些反爬策略是底线不能碰。
最后再分享一个小技巧,遇到一个看起来麻烦的页面,先用浏览器的开发者工具手动访问一遍,把关键的请求用"复制为cURL"的命令导出来,在终端里跑一下,看看直连能不能通。如果cURL能通,那大概率只要补Headers就能用普通请求搞定;如果cURL也不通,再上渲染方案也不迟。这个判断顺序能帮你少走不少弯路,也能让你对每个目标站点的真实防御层级心里有数。