爬取《你好,李焕英》的豆瓣评论数据,是我去年春节后接的一个练手项目,也是我带过不少新人入门爬虫时最常用到的实战案例。这部电影上映时票房口碑双爆,豆瓣短评数量非常可观,前后跨度又覆盖了上映期、口碑发酵期和长尾期,数据本身就有分析价值。更重要的是,豆瓣短评页面的结构相对规整,反爬策略虽然存在但并不过分,对新手来说既踩得到坑,又不至于被劝退——这个度刚刚好。
这篇文章我不打算写成那种教科书式的教程,而是把整个项目从设计思路、技术选型、代码实现到踩坑实录完整串一遍。无论你是刚学完Python基础语法、想找一个正经练手项目的新手,还是已经写过一些简单脚本、想了解如何处理反爬和并发问题的进阶玩家,这篇内容都能给你一些参考。
1. 项目整体设计与技术选型
1.1 为什么选《你好,李焕英》作为爬取对象
选这部电影并不是随手定的。我在确定爬虫目标时一般会考虑三个维度:数据量够不够大、数据结构是否规整、话题热度有没有分析价值。
《你好,李焕英》在豆瓣上积累了几十万条短评和长评,短评数据分布在多个页面里,覆盖了从首映到后续长尾期的完整周期。这类数据适合做情感分析、用户画像、评分趋势变化等二次挖掘。另外一个实际原因是,这部电影的评论区讨论焦点比较集中——亲情、母爱、穿越叙事、贾玲的导演处女作——文本内容的分群特征比较明显,即使只做简单的关键词统计也能看出清晰结论,对入门者来说反馈非常直观。
还有一个隐性好处:豆瓣短评的HTML结构多年没大改,网上能找到大量参考代码,出了问题也好排查。对一个练手项目来说,“有问题可查、有资料可依”是非常重要的加分项。
1.2 确定数据维度:评论里能挖出什么
在写第一行代码之前,先把目标字段列清楚。我爬取时锁定了以下字段:
- 用户名
- 评论内容
- 评分(力荐/推荐/还行/较差/很差)
- 评论时间
- 点赞数
- 评论所属页数
其中评分这个字段获取时有个小坑——不是每条短评都带评分,很多用户只写文字不打星。后面我会详细介绍处理方法。
确定字段的意义在于,它能倒推你要解析哪些HTML节点、数据清洗时保留哪些信息、最终存成什么样的表格结构。我在做项目时习惯先拿几条数据做样本,手动标出各部分对应的位置,再开始写解析逻辑,这样能省掉不少调试时间。
1.3 技术栈选型:requests还是scrapy,解析用哪个库
这个项目我最终选了requests + BeautifulSoup + pandas的组合,没有上scrapy,理由很实际:
第一,数据量级决定工具复杂度。几十万条评论看着多,但对于练手项目来说,用requests写单线程脚本、控制好请求间隔,一小时左右就能抓完。scrapy的优势在于大规模分布式采集和成熟的中间件体系,对这个项目来说属于杀鸡用牛刀,反而增加学习成本。
第二,requests + BeautifulSoup的组合更容易理解HTTP请求和HTML解析的本质。新人能清楚看到“发请求→拿响应→提取数据”这个完整链路,而用scrapy这种框架时很多步骤被封装了,出了反爬问题反而不容易定位。
第三,pandas处理CSV非常方便,后续如果要转成DataFrame做分析,数据格式完全兼容。
1.4 页面分析与数据位置确认
这一步是爬虫项目的“踩点”工作,也是很多初学者最容易跳过的环节。我在动笔前会先打开浏览器开发者工具,逐项确认:
- 评论列表在哪个div节点下
- 每条评论是独立div还是li包裹
- 点赞数在什么class名下
- 评论时间取data属性里的原始值还是直接取文本内容
豆瓣短评页面的评论区块大致结构是:外层div.comment-item,内部包含span.votes(点赞数)、span.comment-info(用户信息和评分)、span.short(评论文本)。这个结构近年来基本稳定,但浏览器F12看到的DOM有时和requests拿到的源码略有差异,因为页面可能经过JavaScript动态渲染。豆瓣的短评页面首屏数据是服务端直接输出的,所以直接用requests就能拿全,但如果哪天豆瓣改成前端渲染,就需要换用Selenium或分析接口了。这也是我判断一个爬虫项目难易程度的重要标准——数据是HTML里直接有的,还是需要额外解析异步请求。
2. 核心细节解析与预处理
2.1 豆瓣反爬到底在防什么
很多人一上来就被豆瓣封IP或者弹验证码,然后就觉得反爬很玄学。其实豆瓣的反爬策略归纳起来就是三板斧:请求头检验、频率限制、行为模式识别。
请求头检验是最基础的。默认的python-requests的User-Agent会被识别并直接拒绝,所以伪造一个浏览器User-Agent是基本操作。
频率限制是真正卡脖子的。豆瓣对短评页面的限制大约是单个IP每秒钟最多1-2次请求,超过这个阈值就会触发封禁,轻则返回403,重则要求输入验证码。实测下来,把请求间隔控制在3-5秒比较安全,也就是一分钟最多20次请求。按这个速率抓取几十万条数据确实慢,但封IP之后等待解封的时间成本更高。
行为模式识别更隐蔽一些。比如你每次访问的页面跳转是否有规律、请求时间分布是否均匀、要不要随机停顿,这些都是可以通过统计特征发现的。反爬和反反爬说到底是博弈,但对我们这种正当爬取公开数据的场景来说,只要保持礼貌频率基本不会有大问题。
2.2 请求头的完整配置思路
请求头配置不是简单加一个User-Agent就完事了。我在实际项目里会带上这么几个关键字段:
headers = { "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", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept-Encoding": "gzip, deflate", "Referer": "https://movie.douban.com/", "Connection": "keep-alive", "Upgrade-Insecure-Requests": "1" }其中Referer字段容易被忽略,但也挺关键。豆瓣会校验请求来源,如果Referer和当前访问的域名不匹配,有可能被判定为异常请求。另外建议直接用浏览器里复制出来的完整UA字符串,不要手打或者用网上的老模板——很多老模板已经被拉黑得差不多了。
2.3 Cookie与登录态处理方式
关于豆瓣爬虫要不要带Cookie,我的结论是:爬短评页面不登录也能拿到数据,但带Cookie更稳,能显著降低触发验证码的概率。原因是豆瓣对登录用户可以展示更完整的评论内容,同时登录态的请求在风控层面更友好。
获取Cookie最简单的方式是在浏览器登录豆瓣后,从开发者工具里复制Cookie请求头的值,直接塞到headers里。需要注意Cookie是有效期的,脚本跑太久失效了要重新复制。我一般在代码里加一个异常检测,当返回内容里出现“登录”提示时,提示用户更新Cookie。
有个细节值得说明:Cookie里可能会包含个人隐私信息,代码写完之后如果要公开分享记得把Cookie清掉再发出来。
2.4 请求频率控制的正确姿势
我用的是最简单的方案——time.sleep(),但有一些细节新手往往掌握不好。
首先,间隔不要是固定值。固定3秒的请求间隔在服务端看来反而像机器行为,人工浏览网页不可能这么精准。我用的是随机间隔:
import random import time time.sleep(random.uniform(2, 5))其次是记录每次请求的状态码。如果连续出现几次403,说明频率还是太快了,需要把间隔上限调大。我在代码里维护了一个简单的请求状态列表,每20次请求统计一次失败率,超过阈值就自动增加等待时间。
最后一点是控制总体并发度。我用的单线程顺序请求,这样最简单可控。如果你想加速可以引入concurrent.futures做多线程,但每增加一个并发线程,被风控的概率就指数级上升。对《你好,李焕英》这个项目来说,单线程配合合理间隔已经够用了。
2.5 代理IP要不要用
搜索热词里出现了“python 爬虫ip代理”,这里我多说一句。对于豆瓣这种规模的反爬强度,短时间单IP是够用的。只有在遇到以下情况时才建议考虑代理:
- 需要短期大量抓取,比如去做全网舆情分析
- 本机IP已经被封
- 需要多地域节点采集对比数据
如果你确实需要代理,选择上也有些讲究。免费代理池的可用率极低,维护成本很高,对于入门项目不建议折腾。付费代理按量计费的那种,配合requests的proxies参数使用即可。但这里我要强调一个合规前提:爬取公开数据必须在法律法规允许范围内,不能对目标站点造成压力或影响正常运行,务必遵守目标网站的robots协议和相关条款。
3. 实操过程与核心代码实现
3.1 环境准备与依赖安装
前提是你本机有Python环境。这里不展开讲安装步骤,只说项目依赖:
pip install requests beautifulsoup4 pandas lxmllxml是BeautifulSoup的解析器,比默认的html.parser解析速度更快,处理大型HTML文档时建议带上。
整个项目文件结构建议:
douban_crawler/ ├── crawler.py # 主爬虫脚本 ├── requirements.txt # 依赖清单 ├── data/ │ └── raw_comments.csv # 爬取结果存储 └── logs/ └── crawl.log # 请求日志,方便排查3.2 核心代码:构造请求并获取评论页面
确定目标URL是关键第一步。《你好,李焕英》的豆瓣条目ID是34841067,短评页面路径是:
https://movie.douban.com/subject/34841067/comments?status=P其中status=P表示按热门排序,如果想要按时间排序,可以改成status=P&sort=new_score(这里的规则需要实际测试确认)。评论是通过start参数分页的,每页20条:
base_url = "https://movie.douban.com/subject/34841067/comments" params = { "start": 0, "limit": 20, "status": "P", "sort": "new_score" } resp = requests.get(base_url, headers=headers, params=params, timeout=10)start=0是第1页,start=20是第2页,以此类推。
写请求逻辑时我建议加两层保护:超时设置和重试机制。
def fetch_page(session, url, params, max_retry=3): for attempt in range(max_retry): try: resp = session.get(url, headers=headers, params=params, timeout=10) if resp.status_code == 200: return resp.text elif resp.status_code == 403: print(f"触发风控,等待较长时间后重试,第{attempt + 1}次") time.sleep(random.uniform(10, 15)) else: print(f"状态码异常: {resp.status_code}") time.sleep(random.uniform(3, 5)) except requests.RequestException as e: print(f"请求异常: {e}") time.sleep(random.uniform(5, 8)) return None3.3 解析HTML提取评论数据
拿到HTML源码后,用BeautifulSoup解析。豆瓣短评页面的每条评论都在div.comment-item节点下:
from bs4 import BeautifulSoup def parse_comments(html): soup = BeautifulSoup(html, "lxml") items = soup.select("div.comment-item") comments = [] for item in items: try: votes = item.select_one("span.votes").text.strip() user = item.select_one("span.comment-info a").text.strip() rating = item.select_one("span[class*=rating]") rating_text = rating.get("class")[0] if rating else "" comment_time = item.select_one("span.comment-time").get("title", "").strip() short = item.select_one("span.short").text.strip() comments.append({ "点赞数": int(votes) if votes.isdigit() else 0, "用户名": user, "评分": rating_text, "评论时间": comment_time, "评论内容": short }) except AttributeError as e: print(f"解析单条评论失败: {e}") continue return comments这里有两个细节值得展开:
第一个是评分字段。span.comment-info里如果有评分,会是一个span标签,class名类似allstar50(代表5星)、allstar40(4星)、allstar30(3星)等。判断评分的逻辑是查找包含rating的class属性,然后映射成星数。
rating_map = { "allstar50": 5, "allstar40": 4, "allstar30": 3, "allstar20": 2, "allstar10": 1 }没有评分标签的评论,星级直接记为None,后期分析时按缺失值处理就行。
第二个是评论时间。豆瓣页面显示的文本可能是“2021-02-12”这样,但源码里通常有更精确的时间藏在title属性里。我选择取title属性,能拿到完整的时间戳。
3.4 翻页循环与去重设计
评论的翻页逻辑比较简单,但要注意循环终止条件。豆瓣短评页面有一个特点——最后一页的有效数据可能不足20条,此时页面返回的内容里comment-item节点数量小于20就说明到头了。更稳妥的判断方式是:如果连续几页的评论内容出现重复,说明翻过了有效数据区,直接停止。
我采用的翻页逻辑如下:
def crawl_all_comments(max_pages=500): session = requests.Session() session.headers.update(headers) all_comments = [] seen = set() empty_count = 0 for page in range(max_pages): start = page * 20 params = { "start": start, "limit": 20, "status": "P", "sort": "new_score" } html = fetch_page(session, base_url, params) if html is None: print(f"第{page + 1}页请求失败,跳过") empty_count += 1 if empty_count >= 3: break continue comments = parse_comments(html) if not comments: empty_count += 1 if empty_count >= 3: break else: empty_count = 0 deduped = [] for c in comments: key = (c["用户名"], c["评论内容"][:20]) if key not in seen: seen.add(key) deduped.append(c) all_comments.extend(deduped) print(f"完成第{page + 1}页,累计{len(all_comments)}条有效评论,当前页{len(comments)}条原始数据") time.sleep(random.uniform(2, 5)) return all_comments去重这个环节看着简单但非常必要。豆瓣评论翻页时偶尔会出现重复数据,尤其是按热门排序时权重变化导致的列表波动。用“用户名+评论内容前20个字符”的组合作为唯一键足够了。
3.5 数据清洗与保存到CSV
爬虫拿到的原始数据不能直接用,必须做一遍清洗。以《你好,李焕英》为例,评论里会夹杂表情符号、HTML实体(比如&)、多余空白字符等。这些都需要处理:
import re import pandas as pd def clean_text(text): if not isinstance(text, str): return text # 去除HTML实体 text = text.replace("&", "&").replace("<", "<").replace(">", ">") # 去除多余空白 text = re.sub(r"\s+", " ", text).strip() return text def transform_data(comments): df = pd.DataFrame(comments) df["评论内容"] = df["评论内容"].apply(clean_text) df["评论时间"] = pd.to_datetime(df["评论时间"], errors="coerce") df["评分"] = df["评分"].map(rating_map) return df df = transform_data(all_comments) df.to_csv("data/douban_lihuanying_comments.csv", index=False, encoding="utf-8-sig")utf-8-sig编码是一个细节——直接用utf-8存的CSV用Excel打开会乱码,utf-8-sig加上了BOM头,Excel能正确识别中文。
3.6 完整流程整合与运行体验
把以上模块串起来,完整脚本大概200行左右。我在运行时观察到这样的现象:每页20条评论,如果保持3-5秒的间隔,一分钟大约能抓240-300条,《你好,李焕英》的热门短评大概有几千条,跑20-30分钟就能集齐。如果想抓全部几万条,需要按时间排序多翻几百页,可能要跑几个小时。
过程中每完成10页我会打印一次进度,统计有效评论数、失败次数和当前速率,方便心里有数。中途如果断网或者触发风控,也不用慌,把已经保存的数据备份好,下次运行时用start参数接续未完成的部分。
4. 常见问题与排查技巧实录
4.1 403 Forbidden与验证码:最让人头疼的拦路虎
403是爬豆瓣最常碰到的报错。第一次遇到时我也懵了,但排查下来主要就是两个原因:请求头不合格、请求频率太快。
排查思路很简单——先在浏览器里手动访问一次目标URL,确认自己的IP没有封。如果能正常打开,说明浏览器带的请求头字段是合法的,直接把新的UA和Cookie复制到脚本里替换;如果浏览器也打不开或者要过验证码,说明IP被临时限制了,等几分钟再试。
如果出现验证码页面,我的处理方式是停止脚本,等10-15分钟再继续。不要试图在线破解验证码,既违反平台规则也没必要,等风控过去就行。
4.2 评论时间全是NaN的诡异问题
有同学照着网上的教程爬,发现评论时间字段全是NaN或者空字符串。这多半是时间字段取的节点不对。豆瓣的span.comment-time文本内容是“2021-02-12”或“02-12 12:00”这样的格式,并没有包含完整的年月日时分秒信息在纯文本里,完整值是放在title属性上的。
我在解析时用的是get("title", "")而不是.text,很多人忽略了这个细节。还有一点,如果CSS选择器写的是.comment-time,页面里可能有多个节点匹配,需要用select_one只取第一个。
4.3 评分字段丢失,星标去哪了
很多短评左下角是没有星星图标的。我初步统计过,大约三成以上的评论不评分,只写文字。这不是爬虫的问题,是豆瓣本身的设计。
处理方法我前面提到过——用select_one("span[class*=rating]")去匹配,如果找不到就不填评分,保留为None。这样后续分析时可以用“缺失值”单独处理,也可以把未评分的评论单独过滤出来做纯文本情感分析。
4.4 评论数量对不上:热门排序和时间排序的区别
如果在代码里不指定sort参数,豆瓣默认按热门排序返回,也就是点赞数高的评论排在前面。同样总条数下,按时间排序能拿到的页面更多、数据更古老。
我自己抓完后对比过:按热门排序的前几页点赞数动辄几百上千,评论也更有看头;按时间排序的评论更偏向实时反馈,能反映上映初期的真实口碑。建议有选择地爬取,或者两种都爬,后期做对比分析更有意思。
具体到代码里,不同的排序对应不同的URL参数组合,建议测试几页确认返回数据的差异。我最终选择sort=new_score按时间排序,因为对情感分析来说,时间维度对趋势判断更有价值。
4.5 代码运行了一段时间后突然变慢
爬虫跑着跑着变慢,基本就是被限速了。豆瓣不会直接拒绝请求,而是通过增加响应时间让你自己知难而退。遇到这种情况,先看日志里最近的请求耗时,如果响应时间从几百毫秒涨到几秒,说明已经触发了隐性限流。
我的应对方式是增大随机间隔,从random.uniform(2, 5)改成random.uniform(5, 8),同时每50页暂停1分钟。实测这样能提高长跑稳定性。另外建议设置max_pages上限,防止脚本永久跑下去。
5. 数据应用与后续扩展思路
5.1 情感分析初体验
爬到数据后,最自然的下一步是情感分析。《你好,李焕英》的评论情感倾向非常集中,赞美母爱、感动落泪是主旋律,但也有少量“煽情过度”“剧情简单”的批评声音。
用最简单的方法——词典法配上SnowNLP或者自己打标签——就能把评论粗略分成正向、中性、负向三类。配合前面的评分字段,可以验证一个假设:打5星的用户评论里出现“妈妈”“哭”“感动”这类词的频率是不是显著更高。
这个项目我做完后最大的收获不是抓了几万条数据,而是第一次体会到:一手数据怎么变成有分析价值的结论,这比爬虫本身更值得深入研究。
5.2 可视化展示
清洗后的DataFrame可以直接用matplotlib或pyplot画图。比如:
- 按日期的评论数量折线图,能看出电影上映后口碑传播的节奏
- 评分分布的饼图,直观展示用户对电影的整体态度
- 点赞数和评论长度的散点图,分析什么样的评论更容易获得共鸣
这些图做好之后放到博客或者GitHub上,整个项目的完成度一下就不一样了。
5.3 项目扩展可能性
这个项目后续扩展的方向很多:加上长评爬取,对比短评和长评的情感差异;引入多线程或异步爬虫提升效率;接入数据库替换CSV存储;甚至配合selenium爬取动态加载的“更多评论”。
我个人建议先别急着加复杂度。把当前版本的代码跑通、把数据存好、尝试一次简单分析,消化完整个流程后,再决定往哪个方向深入。
根据我自己踩过的坑,最后分享两个实用心得:第一个,爬虫项目的调试时间通常比写代码时间长,一定要在代码里加上日志和异常捕获,否则出了问题全靠猜;第二个,数据分析应用的权重应该比爬虫本身更高——数据只是手段,能不能从评论里提炼出有价值的信息,才是这类项目真正的看点。