ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Python爬虫实战:爬取豆瓣《你好,李焕英》短评数据全解析

Python爬虫实战:爬取豆瓣《你好,李焕英》短评数据全解析 爬取《你好李焕英》的豆瓣评论数据是我去年春节后接的一个练手项目也是我带过不少新人入门爬虫时最常用到的实战案例。这部电影上映时票房口碑双爆豆瓣短评数量非常可观前后跨度又覆盖了上映期、口碑发酵期和长尾期数据本身就有分析价值。更重要的是豆瓣短评页面的结构相对规整反爬策略虽然存在但并不过分对新手来说既踩得到坑又不至于被劝退——这个度刚刚好。这篇文章我不打算写成那种教科书式的教程而是把整个项目从设计思路、技术选型、代码实现到踩坑实录完整串一遍。无论你是刚学完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/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.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?statusP其中statusP表示按热门排序如果想要按时间排序可以改成statusPsortnew_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, headersheaders, paramsparams, timeout10)start0是第1页start20是第2页以此类推。写请求逻辑时我建议加两层保护超时设置和重试机制。def fetch_page(session, url, params, max_retry3): for attempt in range(max_retry): try: resp session.get(url, headersheaders, paramsparams, timeout10) 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星、allstar404星、allstar303星等。判断评分的逻辑是查找包含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_pages500): 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实体比如amp;、多余空白字符等。这些都需要处理import re import pandas as pd def clean_text(text): if not isinstance(text, str): return text # 去除HTML实体 text text.replace(amp;, ).replace(lt;, ).replace(gt;, ) # 去除多余空白 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[评论时间], errorscoerce) df[评分] df[评分].map(rating_map) return df df transform_data(all_comments) df.to_csv(data/douban_lihuanying_comments.csv, indexFalse, encodingutf-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参数组合建议测试几页确认返回数据的差异。我最终选择sortnew_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爬取动态加载的“更多评论”。我个人建议先别急着加复杂度。把当前版本的代码跑通、把数据存好、尝试一次简单分析消化完整个流程后再决定往哪个方向深入。根据我自己踩过的坑最后分享两个实用心得第一个爬虫项目的调试时间通常比写代码时间长一定要在代码里加上日志和异常捕获否则出了问题全靠猜第二个数据分析应用的权重应该比爬虫本身更高——数据只是手段能不能从评论里提炼出有价值的信息才是这类项目真正的看点。
返回列表