1. 项目背景与核心需求
在数据分析和内容挖掘领域,豆瓣电影TOP250榜单一直被视为重要的文化指标数据集。这个榜单汇集了中文互联网中最受好评的影视作品,其用户评论数据蕴含着丰富的观点和情感倾向。传统爬虫工具如Scrapy虽然高效,但在处理动态加载内容时存在明显局限——这正是我们需要引入Selenium的关键原因。
我最近为一个影视分析项目采集数据时发现,豆瓣电影详情页的评论数据采用了渐进式加载机制。当使用纯Scrapy请求时,只能获取到前20条左右的"热门评论",而页面底部"最新评论"区域的数据完全无法抓取。更棘手的是,部分电影的长评内容需要点击"展开"按钮才会显示完整文本。这些动态交互元素的存在,使得传统静态爬虫束手无策。
2. 技术选型与工具链搭建
2.1 Scrapy与Selenium的协同机制
Scrapy作为异步爬虫框架,其核心优势在于高效的请求调度和数据提取。但当遇到JavaScript渲染的内容时,我们需要借助Selenium这个浏览器自动化工具来"看到"完整的DOM结构。两者的配合原理是:
- Scrapy负责整体爬取逻辑:URL队列管理、请求调度、数据存储
- 遇到需要渲染的页面时,通过中间件将请求转交给Selenium
- Selenium控制真实浏览器加载页面,执行必要的交互操作
- 将渲染后的HTML返回给Scrapy进行数据提取
这种架构既保留了Scrapy的高效性,又获得了处理动态内容的能力。在我的实现中,特别添加了请求类型判断——只有评论页面的请求才会触发Selenium渲染,其他静态资源仍由Scrapy直接处理,这样能显著提升整体采集效率。
2.2 环境配置要点
python复制# 必需的核心库
pip install scrapy selenium webdriver-manager
# ChromeDriver自动管理(推荐)
from webdriver_manager.chrome import ChromeDriverManager
from selenium.webdriver.chrome.service import Service
service = Service(ChromeDriverManager().install())
driver = webdriver.Chrome(service=service)
在实际部署时,我强烈建议使用webdriver-manager来自动管理浏览器驱动版本。手动下载ChromeDriver经常会出现版本不匹配的问题,特别是在团队协作或服务器部署场景下。另一个容易忽略的细节是浏览器无头模式的内存配置:
python复制options = webdriver.ChromeOptions()
options.add_argument('--headless')
options.add_argument('--disable-gpu')
options.add_argument('--no-sandbox') # 服务器环境必需
options.add_argument('--disable-dev-shm-usage') # 解决内存不足问题
提示:豆瓣对爬虫有一定反制措施,建议在中间件中随机切换User-Agent,并设置合理的请求间隔(建议3-5秒)。我在实际测试中发现,过于频繁的请求会导致IP被临时封禁。
3. 爬虫架构设计与实现
3.1 项目结构规划
code复制douban_comment_crawler/
├── spiders/
│ └── movie_comments.py
├── middlewares.py
├── items.py
├── pipelines.py
└── settings.py
关键点在于自定义中间件的实现,这是连接Scrapy和Selenium的桥梁。我在middlewares.py中创建了SeleniumMiddleware类,核心逻辑如下:
python复制from scrapy.http import HtmlResponse
class SeleniumMiddleware:
def process_request(self, request, spider):
if 'comment' in request.url: # 仅处理评论页面
driver.get(request.url)
# 模拟滚动加载
for _ in range(3):
driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
time.sleep(1)
# 点击"展开"长评
try:
expand_buttons = driver.find_elements(By.XPATH, '//span[contains(text(),"展开")]')
for btn in expand_buttons:
btn.click()
time.sleep(0.5)
except:
pass
return HtmlResponse(driver.current_url, body=driver.page_source, encoding='utf-8')
3.2 数据提取策略
豆瓣评论页面的DOM结构相对复杂,但通过精心设计的XPath可以准确提取所需数据。经过多次测试,我确定了以下字段提取方案:
python复制def parse_comments(self, response):
for comment in response.xpath('//div[@class="comment-item"]'):
yield {
'movie_id': response.meta['movie_id'],
'user_id': comment.xpath('.//a[@class=""]/@href').re_first(r'people/(.*)/'),
'rating': comment.xpath('.//span[contains(@class,"rating")]/@title').get(),
'content': ''.join(comment.xpath('.//span[@class="short"]/text()').getall()).strip(),
'votes': comment.xpath('.//span[@class="votes"]/text()').get(),
'comment_time': comment.xpath('.//span[@class="comment-time"]/@title').get()
}
特别需要注意的是,有些用户可能没有设置昵称,此时user_id的提取会失败。我在实际处理中添加了异常捕获和默认值设置:
python复制user_id = comment.xpath('.//a[contains(@href,"people/")]/@href').re_first(r'people/(.*?)/') or 'anonymous'
4. 反爬对抗与稳定性优化
4.1 常见反爬机制破解
豆瓣采用了多种反爬手段,经过实测验证,以下方法效果显著:
-
请求频率控制:在settings.py中配置
python复制DOWNLOAD_DELAY = 3 RANDOMIZE_DOWNLOAD_DELAY = True CONCURRENT_REQUESTS_PER_DOMAIN = 2 -
请求头伪装:使用随机User-Agent中间件
python复制from fake_useragent import UserAgent ua = UserAgent() request.headers['User-Agent'] = ua.random -
IP轮换策略:对于大规模采集,建议使用代理IP池
python复制class ProxyMiddleware: def process_request(self, request, spider): request.meta['proxy'] = "http://your-proxy-ip:port"
4.2 异常处理机制
在实际运行中,我发现以下几种异常最为常见:
-
元素定位失败:由于页面加载延迟导致
python复制from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC try: element = WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.XPATH, '//div[@class="comment"]')) ) except TimeoutException: print("元素加载超时") -
验证码触发:建议在出现验证码时暂停爬取,手动处理
python复制if "验证码" in driver.page_source: input("出现验证码,请手动处理后按回车继续...") -
连接重置:使用retry中间件自动重试
python复制RETRY_TIMES = 3 RETRY_HTTP_CODES = [500, 502, 503, 504, 400, 403, 404, 408]
5. 数据存储与后续处理
5.1 存储方案选择
根据数据量大小和后续使用场景,我推荐以下几种存储方案:
-
中小规模数据(<10万条):
python复制# pipelines.py import json class JsonWriterPipeline: def open_spider(self, spider): self.file = open('comments.jl', 'a', encoding='utf-8') def process_item(self, item, spider): line = json.dumps(dict(item), ensure_ascii=False) + "\n" self.file.write(line) return item -
大规模数据:使用MongoDB分片集群
python复制import pymongo class MongoPipeline: def __init__(self, mongo_uri, mongo_db): self.mongo_uri = mongo_uri self.mongo_db = mongo_db @classmethod def from_crawler(cls, crawler): return cls( mongo_uri=crawler.settings.get('MONGO_URI'), mongo_db=crawler.settings.get('MONGO_DATABASE') ) def open_spider(self, spider): self.client = pymongo.MongoClient(self.mongo_uri) self.db = self.client[self.mongo_db] def process_item(self, item, spider): self.db['comments'].insert_one(dict(item)) return item
5.2 数据清洗建议
原始采集的数据通常需要清洗才能用于分析:
-
评分标准化:将"力荐"、"推荐"等文本转为数值
python复制rating_map = {'力荐': 5, '推荐': 4, '还行': 3, '较差': 2, '很差': 1} item['rating_score'] = rating_map.get(item['rating'], 0) -
时间格式统一:
python复制from datetime import datetime item['timestamp'] = datetime.strptime(item['comment_time'], '%Y-%m-%d %H:%M:%S') -
文本清洗:
python复制import re item['clean_content'] = re.sub(r'\s+', ' ', item['content']).strip()
6. 实战经验与进阶技巧
在完成这个项目的过程中,我积累了几个特别有价值的经验:
-
智能等待策略:不要使用固定sleep时间,而是结合ExpectedConditions和自定义等待条件
python复制def wait_for_comments_loaded(driver): return len(driver.find_elements(By.CLASS_NAME, 'comment-item')) >= 20 WebDriverWait(driver, 10).until(wait_for_comments_loaded) -
浏览器指纹伪装:高级反爬系统会检测浏览器特征
python复制options.add_argument("--disable-blink-features=AutomationControlled") options.add_experimental_option("excludeSwitches", ["enable-automation"]) -
分布式扩展:使用Scrapy-Redis实现分布式爬取
python复制# settings.py SCHEDULER = "scrapy_redis.scheduler.Scheduler" DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter" REDIS_URL = 'redis://your-redis-server:6379' -
性能监控:添加统计扩展监控爬取效率
python复制EXTENSIONS = { 'scrapy.extensions.corestats.CoreStats': 500, 'scrapy.extensions.telnet.TelnetConsole': None, } STATS_DUMP = True
对于需要长期运行的爬虫,我建议添加自动恢复机制——定期保存爬取状态,当程序意外中断时可以从断点继续。这可以通过记录已处理的movie_id来实现:
python复制class CheckpointMiddleware:
def __init__(self):
self.processed_ids = set()
try:
with open('checkpoint.txt') as f:
self.processed_ids = set(line.strip() for line in f)
except FileNotFoundError:
pass
def process_spider_output(self, response, result, spider):
for item in result:
if isinstance(item, dict) and 'movie_id' in item:
if item['movie_id'] not in self.processed_ids:
self.processed_ids.add(item['movie_id'])
with open('checkpoint.txt', 'a') as f:
f.write(f"{item['movie_id']}\n")
yield item
