Scrapy 结合 Selenium 爬取豆瓣 TOP250 电影的评论数据,这个标题我第一眼看到的时候,心里就想,这兄弟八成是被豆瓣的反爬折腾过。因为但凡你只是想抓个电影列表,那纯 Scrapy 不到十分钟就能写完,根本不需要动用 Selenium 这种重武器。但一旦涉及到评论数据,尤其是要翻页、要展开、要应对登录态验证码,那问题就完全不是一个量级了。这个项目最核心的看点,不是 Scrapy 也不是 Selenium 本身,而是你怎么把这两套完全不同工作方式的工具,像齿轮一样无缝咬合在一起,让一个负责高速抓取,一个负责搞定那些 JavaScript 渲染的动态内容。
先说结论,这个方案是可行的,而且如果你打算长期稳定地把豆瓣 TOP250 里的评论做成数据集,这应该是最不容易被封、也最省心的一种组合方式。我自己的经验是,纯 Scrapy 去硬抗豆瓣的接口,短时间小规模无所谓,但一旦超过几百条,那个数据质量就会开始肉眼可见地下降——缺失、乱码、被重定向到验证页,甚至有时候你以为抓到了数据,其实是豆瓣返回的一个空壳。而 Selenium 虽然慢,但它模拟的是真实的浏览器行为,面对那些异步加载的评论列表,它能等、能点、能滚动,就像是你真人坐在电脑前刷网页一样。这篇文章我会从项目整体的架构设想,逐步拆解到中间件怎么写、Seleium 怎么嵌入、数据管道怎么清洗,最后把我踩过的几个典型坑直接摊开讲。
1. 整体项目设计与技术方案推演
1.1 核心需求拆解:你要的到底是一堆 HTML,还是结构化数据
如果光看“爬取豆瓣 TOP250 电影的评论数据”这一句话,大多数人第一反应是直接对着一个详情页开始写 XPath。但真正动起手来,你会发现至少有四个层级的需求需要理清。
第一层,你要拿的是 TOP250 这个榜单的 25 个页面列表。这一层是纯静态内容,Scrapy 原生就能搞,几乎不需要什么特殊技巧,只要把 URL 参数按规律递增就好。第二层,是每一部电影对应的详情页链接。这个更简单,其实只是从列表页的 HTML 里提取href,但这里有一个容易被忽略的细节——豆瓣的列表页里,标题的链接和封面图的链接是同一个地址,但里面还混着“某年某地某类型”的标签链接,如果不加contains(@class, 'title')这种限定,很容易把无关链接也收进来。
第三层,就是真正让人头疼的地方——评论数据。豆瓣的电影评论页面大体上分为两块,一类是“短评”,也就是大家在首页能看到的那几条,另一类是“影评”(长评),它们各自有不同的分页机制和展示方式。最麻烦的是,短评默认只展示精华的热门几条,你要是想拿到“最新”或者“按时间排序”的短评,必须得先点击页面里的排序按钮。这不光触发了 JavaScript 异步请求,还经常会让 Selenium 的WebDriver等待超时。第四层,是数据的结构化处理,评论者昵称、评星、日期、有用数、评论正文,这些字段要拆得清清楚楚,存进数据库或者 CSV 才算是真正“拿到了数据”。
所以这个项目的本质,是一个多层级的爬虫管道:列表页是静态的,详情页是半静态的,评论页是动态的。你用一种工具去统一处理,必然有一个地方是别扭的,这也是为什么我现在很坚持混合方案的原因。
1.2 为什么是 Scrapy 加 Selenium:各自的短板恰好被对方补上
很多人会问,既然 Selenium 什么都能爬,为什么还要套一层 Scrapy?直接全用 Selenium 不就行了吗?
这里有一个非常现实的考量——速度。Scrapy 是异步框架,走的是 Twisted 事件驱动的网络模型,并发请求的时候,单机几百个并发是轻轻松松的,抓取速度非常快。而 Selenium 是一个实打实的浏览器进程,每个 tab 都是一个会吃内存的真实网页,如果你要并发开十个浏览器实例,普通 8G 内存的电脑就已经开始吃紧了。所以如果整个流程全部 Selenium,那 TOP250 的 25 个列表页还好,但 250 部电影的详情页加上几十万条评论,你起码得跑一个通宵,而且中途还不能出任何岔子。
而反过来,如果你只依赖 Scrapy 不引入 Selenium,又会发现一个极其别扭的问题——模拟登录和动态渲染。虽然豆瓣的评论区有静态的 HTML 数据埋在里面,但那些“展开更多回复”“按热度排序”之类的交互控件,根本不是简单构造一个 POST 请求就能搞定的。你得先去分析网络接口、解析参数加密逻辑、模拟各种 Header,这套流程走下来,成本和直接甩一个 Selenium 进去差不了多少,但稳定性却差很多——只要豆瓣前端代码一变,你的加密分析就全都白做。
所以我的建议是:Scrapy 负责所有请求的调度和数据的结构化,Selenium 只负责在遇到需要渲染的页面时“出手救场”。两者各管一段,活不累,而且后续维护也好做。
1.3 技术上还有一条“轻量级”的岔路:直接用 Requests 模拟 API
这里我想插一段岔路,就是你可能在网上看到过一种方案,说不用 Selenium,直接用 Requests 去调豆瓣内部的 Ajax 接口。坦白说,这个方法在豆瓣的某些接口上是真的可行,因为有段时间豆瓣的几个 HTTP 接口是裸奔的,没有加签名、没有加密,你只需要带上正确的 Cookie 就能拿到 JSON 数据。
但为什么我在实际项目中还是放弃了这条路呢?核心原因有两点。一是接口的稳定性没有保障。豆瓣的接口看似开放,其实内部一直在做小版本迭代,时不时就给你调整一下参数格式或者返回值结构。你用 Requests 调接口写好的解析代码,可能过两周就破了,根本来不及维护。第二点是评论区有很多数据是嵌在动态组件里的,比如那个“有用”按钮旁边的数字,或者折叠起来的回复楼中楼,接口返回的 JSON 里字段名非常暧昧,经常是count不知道是评论数还是点赞数,你在解析的时候还得靠猜。
所以我最后的结论是:Requests 调 API 适合临时用用,如果你想做一个能反复跑、能维护的数据采集项目,Selenium 的稳定性目前来说还是最可靠的。
2. 核心细节拆解:Scrapy 中间件里的 Selenium 到底该怎么藏
2.1 架构思路:中间件不是唯一入口,但一定是最优入口
如果说 Scrapy 是工厂流水线,Selenium 就好比是流水线中段的一个特殊工位,专门负责处理那些“普通工具拧不动”的螺丝。在 Scrapy 里,这个特殊工位的名字叫 Downloader Middleware。
为什么要放在 Downloader Middleware 里,而不是直接在 Spider 里写一个函数去调用 Selenium?这其实涉及到 Scrapy 的请求生命周期管理。一个 Request 从生成到返回 Response,中间会经过 Spider 的start_requests、引擎的调度、下载器的处理。如果你在 Spider 里直接掉 Selenium,那么这个下载过程就脱离了 Scrapy 的调度体系——并发控制失效了、下载超时设置失效了、重试机制也失效了。而如果你把 Selenium 放在下载中间件里,它对外表现仍然是一个“下载器”,Scrapy 的引擎并不知道你背地里用了浏览器,该有的延迟、重试、日志统计一项都不会少。
这个设计思路很重要。很多初学者一上来就在parse方法里写driver.get(url),然后就发现整个爬虫失控了,没办法做限速、没办法做超时控制,甚至失败了一个请求,整个爬虫就卡死在那里。而用中间件的话,这些问题都能完美规避。
2.2 编写一个兼容静态与动态请求的下载中间件
这个中间件的核心思路,就是给 Request 打一个标记,比如在meta里塞一个参数render=True,然后中间件拿到请求之后判断一下,如果标记存在,就走 Selenium 渲染的流程,如果不存在,就直接放行给原生的下载器处理。
# middlewares.py from scrapy.http import HtmlResponse from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By import time class SeleniumMiddleware: def __init__(self): chrome_options = Options() chrome_options.add_argument('--headless') chrome_options.add_argument('--disable-gpu') chrome_options.add_argument('--no-sandbox') chrome_options.add_argument('--disable-dev-shm-usage') chrome_options.add_argument( 'user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' ) self.driver = webdriver.Chrome(options=chrome_options) def process_request(self, request, spider): if request.meta.get('render'): self.driver.get(request.url) # 针对评论页的加载情况,这里可以自定义不同的等待时长 if request.meta.get('wait_selector'): WebDriverWait(self.driver, 15).until( EC.presence_of_element_located((By.CSS_SELECTOR, request.meta['wait_selector'])) ) body = self.driver.page_source return HtmlResponse( url=request.url, body=body, encoding='utf-8', request=request ) return None def __del__(self): if self.driver: self.driver.quit()这段代码的核心要点有三个:一是headless模式必须戴上,因为它能省掉浏览器窗口渲染的开销,虽然速度还是没有纯请求快,但至少比完整 GUI 模式好一截。第二是必须带上disable-dev-shm-usage,这个参数如果你在 Docker 或者低内存机器上跑,绝对不能漏,不然浏览器进程会频繁崩溃。第三是process_request的返回值,一旦返回了HtmlResponse,Scrapy 就会跳过默认的下载流程,直接把这个响应交给 Spider,而如果我们返回None,则继续走默认的下载逻辑。
这里面有一个重要的设计哲学:不是所有请求都该走 Selenium,只有那些你明确知道是动态加载的请求才需要带上render=True。这种“白名单”思维能让你最大程度地保留 Scrapy 的原始速度。
2.3 让 Selenium 和 Scrapy 的配置管理做到“互不干扰”
在实际项目里,Selenium 如果只靠代码里写死参数,那维护起来会非常痛苦。我习惯把所有配置统一丢到settings.py里,然后用 spider 的from_crawler方法读进来。
# settings.py 中示例配置 SELENIUM_TIMEOUT = 15000 SELENIUM_WAIT_SELECTOR = "div.comment-item" SELENIUM_HEADLESS = True SELENIUM_USER_AGENT = "Mozilla/5.0 ..."然后中间件的__init__改成接收这些参数,这样你就无需修改代码就能调整等待时间、切换是否显示浏览器窗口,甚至换一套 UA。另外,Scrapy 的DOWNLOAD_DELAY设置对 Selenium 请求同样有效,只不过因为 Selenium 本身加载就慢,我建议对静态请求设置一个较小的延迟比如0.5s,对动态请求单独用meta里的download_delay来覆盖,避免整个爬虫被拖得慢吞吞的。
3. 实战拆解:从列表页爬到评论数据,整个流程该怎么走
3.1 先搞定静态榜单页:抓取 TOP250 的链接与基础信息
建立好管道之后,开始写 spider。首先是列表页,这里直接用常规的 Scrapy 方式,完全不需要 Selenium。核心代码非常简单,我直接把比较关键的几个解析逻辑亮出来。
# spiders/top250.py import scrapy from scrapy.selector import Selector class Top250Spider(scrapy.Spider): name = 'top250' allowed_domains = ['movie.douban.com'] start_urls = [f'https://movie.douban.com/top250?start={i * 25}' for i in range(10)] def parse(self, response): # 提取所有电影条目 items = response.xpath('//div[@class="item"]') for item in items: movie_url = item.xpath('.//div[@class="hd"]/a/@href').get() title = item.xpath('.//span[@class="title"][1]/text()').get() rating = item.xpath('.//span[@class="rating_num"]/text()').get() yield scrapy.Request( url=movie_url, callback=self.parse_movie_detail, meta={ 'title': title, 'rating': rating, 'movie_url': movie_url } )这里我特别想提醒一个细节,提取电影详情页链接的时候,最好用div[@class="hd"]/a/@href,而不是div[@class="pic"]/a/@href。虽然两个链接指向的 URL 是一样的,但用pic的话,它会把封面图片周围的那一层空白区域也算进去,导致某些情况下拿到的是图片的src而不是 href,这个坑不深,但很容易让人排查半天。
另外,列表页部分其实非常稳定,豆瓣对它基本没有动过太狠的反爬,你只要保持适当的下载延迟,爬完 25 页是一点问题都没有的,大多数情况下连 Cookie 都不需要带。
3.2 详情页也不能全走 Selenium:先尝试原生请求,失败再渲染
进入到电影详情页之后,你首先要判断一件事:这个页面的数据,哪些是你需要的,以及它是不是纯静态的。通过我自己的分析,豆瓣详情页的电影基本信息——片名、评分、导演、主演、简介等等,是直接在首次 HTML 加载时写入的,也就是说,这个页面用普通的 Scrapy 请求就能搞定。
但这个页面里有一块是动态的——评论区。如果你只是想要 TOP250 的电影基础信息(片名、评分、简介),那完全没有必要动用render,整个详情页的下载用原生 Scrapy 即可。但如果你想在进入详情页的时候,顺便把 10 条左右的“最新短评”也拿到,那你就得让详情页的请求也走 Selenium。
我的做法是:详情页的数据分成两个阶段抓。第一阶段先发一个普通的 Scrapy Request 去拿基础信息;第二阶段再从 Spider 里发一个新请求,这个请求专门带上render=True,并且 URL 指向评论区的排序链接。这样做的好处是,即使评论区渲染失败,也不影响基础信息的入库。
def parse_movie_detail(self, response): movie_url = response.meta['movie_url'] # 先提取基础信息 director = response.xpath('//div[@id="info"]/span[contains(text(), "导演")]/following-sibling::a[1]/text()').get() # ... 其他字段省略 # 再发一个动态请求去拿短评 comments_url = response.urljoin('comments?status=P') yield scrapy.Request( url=comments_url, callback=self.parse_comments, meta={ 'render': True, 'wait_selector': 'div.comment-item', 'movie_url': movie_url, 'title': response.meta['title'] } )3.3 评论区的 Selenium 操作为什么是难点:排序、滚动、翻页
评论区的数据抓取,是整个项目最考验耐心的部分。虽然说评论的底层数据也是接口传输的,但它的前端交互太复杂了——有按热门排序、按时间排序两种模式,还有分页按钮、有展开全文的按钮,如果你想抓“按时间排序”的数据,你还得在浏览器里先点击一次那个排序 tab。
在 Selenium 里,这个操作对应的就是WebDriverWait加element_to_be_clickable条件。我固定会写一个等待函数,以确保页面元素出来之后再继续操作,否则你硬操作一个还在加载的 DOM,大概率会碰到ElementNotInteractableException。
# spider 中评论解析 def parse_comments(self, response): comments = response.xpath('//div[@class="comment-item"]') for comment in comments: user = comment.xpath('.//span[@class="comment-info"]/a/text()').get() rating = comment.xpath('.//span[contains(@class, "rating")]/@title').get() comment_time = comment.xpath('.//span[@class="comment-time"]/text()').get().strip() useful_count = comment.xpath('.//span[@id^="useful"]//span/text()').get() content = comment.xpath('.//span[@class="short"]/text()').get() yield { 'movie_title': response.meta['title'], 'user': user, 'rating': rating, 'comment_time': comment_time, 'useful_count': useful_count, 'content': content } # 处理翻页 next_page = response.xpath('//a[contains(text(), "后页")]/@href').get() if next_page: yield scrapy.Request( url=response.urljoin(next_page), callback=self.parse_comments, meta={**response.meta, 'render': True, 'wait_selector': 'div.comment-item'} )因为这个评论分页也是动态加载的,所以翻页的请求同样要带上render=True,让中间件去接管。有个细节值得注意:response.urljoin处理的是相对路径,如果你直接拿到next_page是?start=30&status=P这种形式,它也能正确拼成完整的绝对地址。
3.4 数据管道的清洗细节:不只是简单地塞进 CSV
数据抓下来了,但你不能直接往 CSV 里塞,因为豆瓣的评论数据里藏着很多“噪音”。比如评论时间经常是“2019-10-03 14:32:21”这种字符串,但有时候会是“2020-03-04”这种不带时分秒的,如果你在入库的时候需要统一格式就必须先处理。还有“有用数”,那个字段在 HTML 里经常是"有用 (23)"这种带括号的文字,你需要用正则把数字剥出来。
我习惯在pipelines.py里写一个统一的数据清洗逻辑,把所有字符串字段做strip(),把空字符串转None,把数字字段做正则提取。这样无论 Selenium 渲染出来的是什么格式,数据到数据库的时候都是干净整洁的。
import re from itemadapter import ItemAdapter class CommentPipeline: def process_item(self, item, spider): adapter = ItemAdapter(item) # 有用数清洗 if adapter.get('useful_count'): num = re.search(r'\d+', str(adapter['useful_count'])) adapter['useful_count'] = int(num.group()) if num else 0 else: adapter['useful_count'] = 0 # 评论时间清洗 if adapter.get('comment_time'): adapter['comment_time'] = adapter['comment_time'].strip() # 评星等级映射 rating_map = { '力荐': 5, '推荐': 4, '还行': 3, '较差': 2, '很差': 1 } if adapter.get('rating'): adapter['rating'] = rating_map.get(adapter['rating'], 0) return item4. 常见问题与排查技巧实录
4.1 评论区加载不出来,Selenium 老是卡死
这个是我被问得最多的问题。症状一般是这样的:代码跑了三四分钟,突然卡在某一个页面上不动了,日志没有任何报错,进程也不死,就是停在那里。
正常原因基本都是那个WebDriverWait的等待条件没有生效。很多评论页面首屏加载的时候,只有页头和左侧的导航栏先出来,评论列表是在底部异步插入的,如果你的等待条件是评论列表里的某个元素,理论上应该没什么问题。但豆瓣的评论接口偶尔会超时,或者返回一个空列表,此时页面一直不出div.comment-item,等待就会一直挂着直到超时。
我实测下来,比较好的处理方式是给WebDriverWait包一层try/except,超时之后不要直接抛异常,而是主动重试一次,或者降级为读取当前页面已有的静态数据。我有一次跑了整整一晚上,结果早上来看,发现有几十个请求因为同样的原因卡死了,从那以后我就彻底学乖了。
try: WebDriverWait(self.driver, 15).until( EC.presence_of_element_located((By.CSS_SELECTOR, request.meta['wait_selector'])) ) except Exception: spider.logger.warning(f"Selenium 等待超时: {request.url}") # 允许降级:即便没有加载完整,也把当前源码返回,由后续解析容错4.2 频繁触发验证码,或者被限制访问
这个是所有爬豆瓣项目的人都绕不开的话题。其实豆瓣的验证码触发逻辑没有外界传的那么玄乎,它基本取决于你的请求频率和请求来源。如果你是用纯 Scrapy 高频请求,十几个并发怼上去,那个 IP 很快就会被风控盯上,返回一堆验证码页面。而 Selenium 的方式相对模拟了真实用户在浏览器里操作,风险会低不少,但它也绝对不是绝对安全的。
我处理这个问题主要从三个方向下手。一是限制并发,CONCURRENT_REQUESTS_PER_DOMAIN设成 1 或 2,别看它慢,但稳定;二是加随机延迟,下载延迟不能是固定的 3 秒,最好在 2-5 秒之间浮动;三是给 Selenium 的浏览器设一个比较自然的窗口大小和鼠标轨迹,不要在刚打开页面就去点击按钮,稍微停顿几百毫秒。
最有效的一招是:电脑上先手动登录一次豆瓣,把登录后的 Cookie 导出,然后注入到 Selenium 的浏览器实例里。这个方法的效果立竿见影——因为你携带了一个真实的已登录 Cookie,风控会默认你是真实用户,验证码出现的概率大幅降低。具体操作上,你可以用 Selenium 的add_cookie()接口,也可以用 Chrome DevTools 的 Application 面板手动导出。
4.3 数据里出现大量重复,或者漏抓
重复的问题多半出现在评论翻页的时候。因为豆瓣的评论列表是动态加载的,当你从第 1 页翻到第 2 页,页面源码里可能还保留着上一页的部分 DOM 节点,或者两页之间有重叠数据。我的解决办法很简单,在管道里做一个简单的去重判断——以电影标题 + 评论者昵称 + 评论时间三个字段组合出一个唯一标识,如果库里已经有同一条数据,就丢弃。
漏抓的问题则主要集中在“评论时间”字段。有些短评是不显示具体时间的,只显示“今天”“昨天”这种相对时间,这种数据如果你直接入库,后面做时序分析就会出问题。我的建议是,看到这种值,先判断一下,如果能通过当前时间倒推,就转成绝对时间格式;如果拿不准,宁可置空也不硬猜,否则数据分析阶段会出现明显异常值。
4.4 Selenium 浏览器内存泄漏问题
如果爬虫持续运行几个小时以上,你应该能观察到浏览器进程的内存占用一直在上涨,最后可能导致系统卡死。这主要是因为你不断地开新页面、加载新 DOM,但没把旧的页面对象释放掉。
我的处理方式比较粗暴,但也非常有效:在中间件里加一个计数器,比如每处理 30 个请求之后,强制把当前的浏览器实例quit()掉,然后重新初始化一个。这样每次重启浏览器,内存都能回到初始状态,虽然会损失一点点速度,但稳定性提升是巨大的。
if self.request_count % 30 == 0: self.driver.quit() self.driver = self._create_driver() else: self.request_count += 15. 一些同样值得花时间的扩展与升级方向
这个项目跑通之后,你会发现整个爬虫体系已经具备了一个很扎实的骨架,接下来要扩展就是顺水推舟的事。
首先是评论的情感分析。TOP250 的电影评论数据量巨大,你可以用现有的中文情感分析库(比如 SnowNLP 或者哈工大 LTP)把这些评论打上正面、负面、中性的标签,然后按电影聚合出一个情感得分,和豆瓣的评分做对比分析。这个事情会非常有意思,因为你会发现有些电影评分很高但评论里负面情绪不少,有些评分不高的电影反而口碑里全是一致好评——这种撕裂感就是做数据分析时最好的切入点。
其次是做一个定时任务。Scrapy 本身不擅长调度,但是你可以用系统自带的cron或者APScheduler定期触发爬虫,比如每周六凌晨自动爬一次 TOP250 的最新评论。这样你就等于有了一个持续更新的评论数据集,可以做时间序列分析,观察一部电影的口碑变化趋势。
再有一个方向就是把 Selenium 的部分替换成 Playwright。Playwright 在自动等待和网络拦截方面比 Selenium 更聪明,它对每个元素都有一套自动重试机制,代码更简洁,启动速度也更快。如果你的封号风险不是特别高,未来可以考虑把中间件里的 Selenium 换成 Playwright,性能提升还是比较可观的。
说回这个项目本身。我做了几年爬虫相关的工作,最大的体会是:爬虫的核心从来不是代码,而是异常处理。你要在写代码之前就先想清楚,如果这个页面加载不出来怎么办,如果这条数据是脏的怎么办,如果IP被风控了之后怎么降级。Scrapy 加 Selenium 这套组合拳,本质上就是给了你两个不同维度的兜底策略——静态的快,动态的稳,两者相互配合,你才能在数据采集这条路上走得久一点。特别是针对豆瓣这种反爬策略比较稳健的平台,用浏览器去模拟真实用户的操作行为,往往比硬刚接口更加省心,也更加可持续。希望这套方案能帮你少走一点弯路,早日把这批 TOP250 评论数据稳稳当当地收进自己的数据库里。