1. 项目概述与背景
最近在做一个电影数据分析项目,需要获取豆瓣TOP250电影的评论数据。传统爬虫对豆瓣这种动态加载内容的网站效果不佳,经过多次尝试,最终选择了Scrapy框架结合Selenium的方案。这个组合既能利用Scrapy强大的爬取和数据处理能力,又能通过Selenium解决JavaScript渲染问题。
在实际操作中,我发现这个方案有几个明显优势:
- 可以完整获取到页面动态加载的评论内容
- 能够模拟真实用户行为,降低被封风险
- 数据处理流程规范,便于后续分析
- 扩展性强,可以方便地添加各种中间件和管道
2. 环境准备与项目搭建
2.1 基础环境配置
首先需要准备好Python环境,建议使用Python 3.7+版本。我使用的是Python 3.8.5,这个版本对各种库的兼容性都很好。
创建虚拟环境是个好习惯:
bash复制python -m venv douban_env
source douban_env/bin/activate # Linux/Mac
douban_env\Scripts\activate # Windows
2.2 安装必要依赖
在项目根目录创建requirements.txt文件,内容如下:
code复制scrapy>=2.5.0
selenium>=4.0.0
webdriver-manager>=3.5.0
pymongo>=3.12.0 # 如果需要存储到MongoDB
安装依赖:
bash复制pip install -r requirements.txt
注意:webdriver-manager会自动管理浏览器驱动版本,省去了手动下载和配置的麻烦,强烈推荐使用。
2.3 创建Scrapy项目
使用Scrapy命令行工具创建项目:
bash复制scrapy startproject douban_comments
cd douban_comments
这会产生标准的Scrapy项目结构,我们需要在此基础上进行修改和扩展。
3. 核心代码实现详解
3.1 数据模型定义(items.py)
在items.py中定义我们要爬取的数据结构:
python复制import scrapy
class DoubanCommentsItem(scrapy.Item):
movie_name = scrapy.Field() # 电影名称
comment_user = scrapy.Field() # 评论用户
comment_time = scrapy.Field() # 评论时间
comment_content = scrapy.Field() # 评论内容
comment_votes = scrapy.Field() # 有用数
comment_rating = scrapy.Field() # 用户评分(新增字段)
user_location = scrapy.Field() # 用户所在地(新增字段)
我后来在实际使用中增加了comment_rating和user_location两个字段,因为发现这些信息对分析很有价值。Scrapy的Item设计非常灵活,可以根据需要随时扩展。
3.2 Selenium中间件配置(middlewares.py)
这是整个项目的核心之一,负责处理动态页面加载:
python复制from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from webdriver_manager.chrome import ChromeDriverManager
from scrapy.http import HtmlResponse
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('--window-size=1920,1080')
# 设置中文编码
chrome_options.add_argument('lang=zh_CN.UTF-8')
# 禁用图片加载提升速度
chrome_options.add_experimental_option(
"prefs", {"profile.managed_default_content_settings.images": 2}
)
self.driver = webdriver.Chrome(
ChromeDriverManager().install(),
options=chrome_options
)
self.driver.implicitly_wait(10) # 隐式等待
def process_request(self, request, spider):
try:
self.driver.get(request.url)
# 显式等待关键元素加载
WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.CSS_SELECTOR, '.comment-item'))
)
# 模拟滚动加载更多评论
for _ in range(3): # 滚动3次加载更多评论
self.driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
time.sleep(2) # 等待加载
return HtmlResponse(
url=self.driver.current_url,
body=self.driver.page_source.encode('utf-8'),
encoding='utf-8',
request=request
)
except Exception as e:
spider.logger.error(f"Selenium Error: {str(e)}")
return HtmlResponse(url=request.url, status=500, request=request)
这个中间件做了几个关键改进:
- 增加了更多Chrome选项配置,提升稳定性和性能
- 添加了显式等待,确保关键元素加载完成
- 实现了自动滚动加载更多评论
- 加入了完善的错误处理
3.3 爬虫核心逻辑(comments_spider.py)
python复制import scrapy
from douban_comments.items import DoubanCommentsItem
from scrapy.loader import ItemLoader
from urllib.parse import urljoin
import random
import time
class CommentsSpider(scrapy.Spider):
name = 'douban_comments'
allowed_domains = ['movie.douban.com']
start_urls = ['https://movie.douban.com/top250']
custom_settings = {
'CONCURRENT_REQUESTS': 2,
'DOWNLOAD_DELAY': random.uniform(2, 5),
'RANDOMIZE_DOWNLOAD_DELAY': True,
}
def parse(self, response):
for movie in response.css('.item'):
detail_url = movie.css('.hd a::attr(href)').get()
yield response.follow(
detail_url,
self.parse_movie,
meta={'handle_httpstatus_list': [403, 404, 500]}
)
next_page = response.css('.next a::attr(href)').get()
if next_page:
yield response.follow(
next_page,
self.parse,
meta={'handle_httpstatus_list': [403, 404, 500]}
)
def parse_movie(self, response):
if response.status not in [200, 304]:
self.logger.warning(f"Failed to fetch movie page: {response.url}")
return
movie_name = response.css('h1 span::text').get()
comments_url = urljoin(response.url, 'comments?status=P')
yield scrapy.Request(
comments_url,
callback=self.parse_comments,
meta={'movie_name': movie_name},
headers={'Referer': response.url},
dont_filter=True
)
def parse_comments(self, response):
if response.status not in [200, 304]:
self.logger.warning(f"Failed to fetch comments: {response.url}")
return
movie_name = response.meta['movie_name']
for comment in response.css('.comment-item'):
loader = ItemLoader(item=DoubanCommentsItem(), selector=comment)
loader.add_value('movie_name', movie_name)
loader.add_css('comment_user', '.comment-info a::text')
loader.add_css('comment_time', '.comment-time::attr(title)')
loader.add_css('comment_content', '.short::text')
loader.add_css('comment_votes', '.votes::text')
# 提取用户评分
rating = comment.css('.comment-info span.rating::attr(title)').get()
loader.add_value('comment_rating', rating)
# 提取用户位置
location = comment.css('.comment-info span::text').re_first(r'来自(.+)')
loader.add_value('user_location', location.strip() if location else None)
yield loader.load_item()
# 处理分页
next_page = response.css('.paginator .next a::attr(href)').get()
if next_page:
next_page_url = urljoin(response.url, next_page)
yield scrapy.Request(
next_page_url,
callback=self.parse_comments,
meta={'movie_name': movie_name},
headers={'Referer': response.url},
dont_filter=True
)
这个爬虫的主要改进点:
- 增加了更完善的错误处理
- 实现了评论分页抓取
- 提取了更多字段信息
- 添加了随机延迟和请求头设置
- 使用ItemLoader规范化数据处理
4. 配置与优化
4.1 配置文件(settings.py)
python复制BOT_NAME = 'douban_comments'
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
ROBOTSTXT_OBEY = False
DOWNLOADER_MIDDLEWARES = {
'douban_comments.middlewares.SeleniumMiddleware': 543,
'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware': None,
'scrapy.downloadermiddlewares.retry.RetryMiddleware': 550,
}
# 并发控制
CONCURRENT_REQUESTS = 2
DOWNLOAD_DELAY = 3
RANDOMIZE_DOWNLOAD_DELAY = True
# 重试设置
RETRY_ENABLED = True
RETRY_TIMES = 3
RETRY_HTTP_CODES = [500, 502, 503, 504, 400, 403, 404, 408]
# 缓存设置
HTTPCACHE_ENABLED = True
HTTPCACHE_EXPIRATION_SECS = 60 * 60 * 24
HTTPCACHE_DIR = 'httpcache'
HTTPCACHE_IGNORE_HTTP_CODES = [500, 502, 503, 504]
# 日志设置
LOG_LEVEL = 'INFO'
LOG_FORMAT = '%(asctime)s [%(name)s] %(levelname)s: %(message)s'
LOG_DATEFORMAT = '%Y-%m-%d %H:%M:%S'
# MongoDB配置
MONGO_URI = 'mongodb://localhost:27017'
MONGO_DATABASE = 'douban'
ITEM_PIPELINES = {
'douban_comments.pipelines.MongoPipeline': 300,
'douban_comments.pipelines.DuplicatesPipeline': 200,
}
4.2 数据存储管道(pipelines.py)
python复制import pymongo
from itemadapter import ItemAdapter
from scrapy.exceptions import DropItem
import logging
class MongoPipeline:
def __init__(self, mongo_uri, mongo_db):
self.mongo_uri = mongo_uri
self.mongo_db = mongo_db
self.logger = logging.getLogger(__name__)
@classmethod
def from_crawler(cls, crawler):
return cls(
mongo_uri=crawler.settings.get('MONGO_URI'),
mongo_db=crawler.settings.get('MONGO_DATABASE', 'items')
)
def open_spider(self, spider):
try:
self.client = pymongo.MongoClient(self.mongo_uri)
self.db = self.client[self.mongo_db]
# 创建索引
self.db['comments'].create_index([('movie_name', pymongo.ASCENDING)])
self.db['comments'].create_index([('comment_user', pymongo.ASCENDING)])
self.db['comments'].create_index([('comment_time', pymongo.DESCENDING)])
except Exception as e:
self.logger.error(f"MongoDB connection error: {str(e)}")
raise
def close_spider(self, spider):
self.client.close()
def process_item(self, item, spider):
try:
self.db['comments'].update_one(
{
'movie_name': item['movie_name'],
'comment_user': item['comment_user'],
'comment_time': item['comment_time']
},
{'$set': dict(item)},
upsert=True
)
return item
except Exception as e:
self.logger.error(f"MongoDB insert error: {str(e)}")
raise DropItem(f"Failed to insert item: {str(e)}")
class DuplicatesPipeline:
def __init__(self):
self.ids_seen = set()
self.logger = logging.getLogger(__name__)
def process_item(self, item, spider):
unique_id = f"{item['movie_name']}_{item['comment_user']}_{item['comment_time']}"
if unique_id in self.ids_seen:
raise DropItem(f"Duplicate item found: {unique_id}")
else:
self.ids_seen.add(unique_id)
return item
这个管道实现了:
- MongoDB存储
- 数据去重
- 索引创建
- 完善的错误处理
5. 反爬策略与应对措施
豆瓣有比较严格的反爬机制,在实际操作中我遇到了几个常见问题:
5.1 常见反爬现象
- 请求返回403状态码
- 出现验证码
- IP被封
- 返回空数据
5.2 应对策略
5.2.1 请求频率控制
python复制# settings.py中配置
DOWNLOAD_DELAY = random.uniform(3, 8) # 随机延迟
CONCURRENT_REQUESTS = 1 # 降低并发
AUTOTHROTTLE_ENABLED = True # 启用自动限速
5.2.2 请求头优化
python复制# middlewares.py中添加
headers = {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Cache-Control': 'max-age=0',
}
5.2.3 使用代理IP
python复制# middlewares.py中添加代理支持
class ProxyMiddleware:
def process_request(self, request, spider):
request.meta['proxy'] = "http://your-proxy-address:port"
5.2.4 模拟人类行为
python复制# 在SeleniumMiddleware中添加随机行为
def random_behavior(self):
# 随机滚动
scroll_times = random.randint(1, 3)
for _ in range(scroll_times):
self.driver.execute_script("window.scrollBy(0, {})".format(random.randint(200, 800)))
time.sleep(random.uniform(0.5, 2))
# 随机鼠标移动
action = webdriver.ActionChains(self.driver)
action.move_by_offset(random.randint(10, 100), random.randint(10, 100)).perform()
time.sleep(random.uniform(0.1, 0.5))
6. 数据质量与清洗
6.1 常见数据问题
- 评论内容包含HTML标签
- 用户位置信息不规范
- 评分数据缺失
- 时间格式不统一
6.2 数据清洗方案
6.2.1 创建数据清洗管道
python复制# pipelines.py中添加
class DataCleaningPipeline:
def process_item(self, item, spider):
# 清理评论内容
if 'comment_content' in item:
item['comment_content'] = self.clean_text(item['comment_content'])
# 标准化时间格式
if 'comment_time' in item:
item['comment_time'] = self.format_time(item['comment_time'])
# 处理评分
if 'comment_rating' in item:
item['comment_rating'] = self.parse_rating(item['comment_rating'])
return item
def clean_text(self, text):
# 移除HTML标签
import re
cleanr = re.compile('<.*?>')
cleantext = re.sub(cleanr, '', text)
# 移除多余空格和换行
cleantext = ' '.join(cleantext.split())
return cleantext
def format_time(self, time_str):
from datetime import datetime
try:
return datetime.strptime(time_str, '%Y-%m-%d %H:%M:%S')
except:
return time_str
def parse_rating(self, rating_str):
if not rating_str:
return None
rating_map = {
'力荐': 5,
'推荐': 4,
'还行': 3,
'较差': 2,
'很差': 1
}
return rating_map.get(rating_str, None)
6.2.2 启用清洗管道
python复制# settings.py中配置
ITEM_PIPELINES = {
'douban_comments.pipelines.DuplicatesPipeline': 200,
'douban_comments.pipelines.DataCleaningPipeline': 250,
'douban_comments.pipelines.MongoPipeline': 300,
}
7. 项目部署与运行
7.1 本地运行
bash复制scrapy crawl douban_comments -o comments.json
7.2 服务器部署建议
对于大规模抓取,建议:
- 使用Docker容器化部署
- 配置定时任务控制抓取频率
- 使用消息队列管理抓取任务
- 实现分布式抓取
7.2.1 Dockerfile示例
dockerfile复制FROM python:3.8-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["scrapy", "crawl", "douban_comments", "-o", "/data/comments.json"]
7.2.2 使用Scrapyd实现分布式
bash复制# 安装Scrapyd
pip install scrapyd
# 启动Scrapyd服务
scrapyd
# 部署项目
scrapyd-deploy
8. 数据分析与应用
获取到数据后可以进行多种分析:
8.1 基础统计分析
python复制import pandas as pd
from pymongo import MongoClient
# 连接MongoDB
client = MongoClient('mongodb://localhost:27017')
db = client['douban']
collection = db['comments']
# 转换为DataFrame
df = pd.DataFrame(list(collection.find()))
# 基本统计
print(df['comment_rating'].value_counts())
print(df.groupby('movie_name')['comment_votes'].sum().sort_values(ascending=False))
8.2 情感分析示例
python复制from textblob import TextBlob
def analyze_sentiment(text):
analysis = TextBlob(text)
return analysis.sentiment.polarity
df['sentiment'] = df['comment_content'].apply(analyze_sentiment)
8.3 可视化展示
python复制import matplotlib.pyplot as plt
import seaborn as sns
# 评分分布
plt.figure(figsize=(10, 6))
sns.countplot(x='comment_rating', data=df)
plt.title('Rating Distribution')
plt.show()
# 情感分析结果
plt.figure(figsize=(10, 6))
sns.histplot(df['sentiment'], bins=20, kde=True)
plt.title('Sentiment Analysis Distribution')
plt.show()
9. 项目优化与扩展
9.1 性能优化
- 使用Selenium Grid实现并行抓取
- 实现请求缓存减少重复抓取
- 优化XPath/CSS选择器提高解析效率
9.2 功能扩展
- 增加用户画像分析
- 实现评论关键词提取
- 构建电影推荐系统
- 添加定时监控和自动报警
9.3 异常处理增强
python复制# 在spider中添加更完善的异常处理
def parse_comments(self, response):
try:
# 解析逻辑...
except Exception as e:
self.logger.error(f"Error parsing comments: {str(e)}")
# 重试逻辑
retries = response.meta.get('retries', 0)
if retries < 3:
retries += 1
yield scrapy.Request(
response.url,
callback=self.parse_comments,
meta={'movie_name': response.meta['movie_name'], 'retries': retries},
dont_filter=True
)
else:
self.logger.warning(f"Max retries reached for {response.url}")
10. 经验总结与避坑指南
在实际开发过程中,我积累了一些宝贵经验:
-
关于Selenium配置:
- Headless模式虽然节省资源,但容易被识别
- 适当添加
user-agent和window-size参数更接近真实浏览器 - 禁用图片加载可以显著提升性能
-
关于反爬策略:
- 随机延迟设置在3-8秒比较安全
- 凌晨时段(0:00-6:00)抓取成功率更高
- 遇到验证码时最好暂停1-2小时再继续
-
关于数据存储:
- MongoDB的upsert操作能有效避免重复数据
- 建立合适的索引可以大幅提升查询性能
- 定期备份数据很重要
-
常见问题解决:
- 出现403错误时,先检查User-Agent和Cookie
- 数据缺失可能是选择器问题,建议先用浏览器开发者工具验证
- 连接不稳定时,添加重试机制很有必要
-
性能调优:
- 减少不必要的页面滚动和等待时间
- 优化CSS选择器,避免过于复杂的表达式
- 合理设置并发数,过高容易触发反爬
这个项目从开始到稳定运行花了大约两周时间,期间遇到了各种问题,但最终实现了一个稳定可靠的抓取方案。对于想要获取豆瓣数据的开发者,建议从小规模开始,逐步调整参数,找到最适合的抓取策略。