用Python爬取豆瓣《你好,李焕英》短评:从请求到情感分析的实战指南
发布时间:2026/10/4 15:27:36 作者:尧图编辑部 阅读量:1,286

《你好李焕英》上映之后豆瓣评分一路走高短评区的情绪浓度和话题热度基本就是那段时间社交平台的风向标。作为一个常年和数据打交道的爬虫玩家我看到这个现象的第一反应不是去争论电影好坏而是想能不能用 Python 把豆瓣电影《你好李焕英》的评论数据完整拉下来做一轮情感分析和口碑拆解。这篇文章就把整个流程记录下来从页面分析、requests 请求、BeautifulSoup 解析到 CSV 落地、反爬应对和排查技巧给想入门 Python 爬虫、尤其对影视评论数据感兴趣的朋友一份可以直接照做的实操笔记。整个项目代码量不大却涵盖了爬虫最核心的请求、解析、存储、反爬四个环节跑通一遍你对爬虫的基本功也就有了。1. 整体设计与需求拆解1.1 为什么拿“李焕英”短评当爬虫练手选电影短评作为爬虫练习对象有几个很实际的理由。首先是数据量适中豆瓣短评区不像微博评论那样动辄几十万条一般稳定在可见的几千条以内既能让爬虫跑起来不至于瞬间被反爬机制盯上又有足够的数据支撑后续的词频统计和情感分析。其次是数据结构清晰每条短评基本都能拆出“用户、评分、时间、有用数、评论正文”这几个字段没有复杂的嵌套关系用 BeautifulSoup 写选择器不会把人绕晕。再具体到《你好李焕英》这部电影它是春节档的现象级爆款短评覆盖的人群跨度很大有人从亲情角度打高分有人因为口碑营销逆反打低分评论的长短差距也很明显。这种高情绪浓度的文本很适合做 NLP 分析也方便你检验爬虫抓到的数据质量。比如你统计完“妈妈”“哭”“感动”“贾玲”这些高频词基本能还原当时大众讨论的焦点这是单纯看评分排行榜得不到的细节。从实战角度讲选它的另一个好处是页面相对稳定。豆瓣电影短评页面的 DOM 结构这么多年算是比较规范的评论列表在同一个容器里反复出现写一个循环解析器就能覆盖整页。比起去爬那些动态渲染、接口加密的网站体验会舒服很多。对新手而言这个标的既能学到东西又不会在入门阶段就被劝退。1.2 豆瓣评论页面的反爬机制分析豆瓣看着是个节奏很慢的社区实际上反爬意识比很多商业网站都强。它的反爬策略属于“温和但毒辣”的类型不会一上来就封号但会通过各种细节判断你是不是真人。第一道关卡是 User-Agent 识别。如果用 requests 默认的 Python-UA 直接请求豆瓣很可能直接返回 418连页面主体都不给你。第二道关卡是 Cookie 追踪。豆瓣有一个叫 bid 的 Cookie相当于你在豆瓣内部的随机标识没有它或者它长期不变化连续翻几页就会触发验证码页面。第三道关卡是请求频率限制。按照我的实测经验单 IP 下如果每秒钟请求超过一次连续几十页之后大概率会收到 418 或者要求输入验证码的页面。除了这几道硬关卡还有一些软性检测维度比如请求头里有没有 Referer、Sec-Fetch-Site、Accept-Language 这类浏览器会自动携带的字段以及两次请求之间的时间间隔是否呈现随机分布。真人浏览的时间间隔总是不规律的而脚本写死了 sleep(1) 反而容易被识别。这个细节在后面做并发提速时尤其关键。值得一提的是豆瓣的验证码触发机制并不完全透明。我遇到过抓前 30 页完全正常、第 31 页突然返回验证码的情况也遇到过把频率压到 0.5 QPS 仍然被限的个例。这说明它的反爬策略里可能还叠加了账号权重、IP 段信誉等因素。作为爬虫开发者我们能做的是尽量逼真地模仿浏览器行为同时设置合理的兜底策略而不是指望一套代码永远畅通无阻。1.3 技术栈选型为什么是 requests BeautifulSoup看完反爬机制再聊技术选型会踏实很多。这个项目本质上是静态页面的 GET 请求加 HTML 解析不需要处理 JavaScript 渲染也不需要模拟鼠标点击因此根本没必要上 Selenium 或 Playwright 这种重武器。Selenium 虽然能避开一部分基于请求头的检测但启动浏览器实例的开销很大单位时间能抓的评论数反而更低被识别成自动化工具后照样会卡在验证码上。我最终选的是 requests BeautifulSoup 这个组合。requests 负责处理 HTTP 层的细节比如 Session 会话保持、请求头设置、超时重试BeautifulSoup 配合 lxml 解析器负责从 HTML 中提取结构化字段。整个项目核心代码不到 200 行没有额外的学习成本环境也容易搭对爬虫新手来说明显更友好。当然如果你的目标是每天抓几万条评论或者需要同时采集多部电影的短评那我会建议你换 Scrapy。Scrapy 自带调度器、去重、并发和中间件机制分布式扩展也方便但它的问题在于抽象层数多新手遇到问题很难快速定位。所以我的建议是单项目、千条级数据、想快速验证想法用 requests多项目、万条级数据、有定时任务需求再换成 Scrapy。做技术选型先判断需求规模再决定工具复杂度这比盲目追新有意义得多。2. 环境准备与页面解析2.1 本地 Python 环境与依赖库安装正式写代码之前先把环境收拾干净。我用的是 Python 3.10建议至少 3.8 以上的版本太老的版本在语法特性和第三方库支持上会有麻烦。推荐用 venv 创建独立虚拟环境别把项目依赖装到全局否则后面 pip 升级包的时候很容易把其他项目的环境搞崩。python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install requests beautifulsoup4 lxml pandas这里有个小坑很多人装完 requests 就直接开写等到解析的时候才发现 parser 没装好。BeautifulSoup 在没有 lxml 的情况下会退回 Python 内置的 html.parser速度慢不说对某些容错写法还不太友好。建议把 lxml 一起装上并在初始化 BeautifulSoup 时显式指定parserlxml解析速度能快好几倍。我平时会顺手装一个 Jupyter Notebook 用于调试页面结构。爬虫写不出合适的 CSS 选择器时把 response.text 丢到 Notebook 里对照浏览器开发者工具一起看比一遍遍 print(response.text) 效率高很多。新手很容易忽略这一步直接在代码里打印响应内容遇到长页面输出几十屏后整个人都麻了。2.2 豆瓣短评页面的 DOM 结构拆解打开《你好李焕英》的短评页面按 F12 看元素面板你会发现评论列表的整体容器是div#comments里面每一个评论区块常见的 class 是comment-item后来豆瓣改版后部分字段也做过调整。以我一次实际抓取的页面为例结构大致是这样的div classcomment-item>import requests import random import time from bs4 import BeautifulSoup 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, Referer: https://movie.douban.com/subject/xxx/, Sec-Fetch-Dest: document, Sec-Fetch-Mode: navigate, Sec-Fetch-Site: same-origin, } s requests.Session() s.headers.update(HEADERS)关于 Cookie很多教程会让你登录后把一整串 Cookie 复制进来这确实有效但要注意不同账号的 bid 不一样如果 Cookie 里的 bid 和实际请求时的 bid 不一致反而更容易触发风控。我的做法是先在浏览器里正常打开短评页从开发者工具的 Network 面板里复制完整 Cookie 字段然后贴成常量。为了防检测每次请求前随机 sleep 2 到 5 秒既不太慢又不会呈现出完美的固定间隔。这里必须说清楚headers 不能只造一个 User-Agent像 Accept、Sec-Fetch-* 这些字段是浏览器每次请求都会自动携带的如果缺失部分反爬策略会直接判为异常。这些字段的具体值会随浏览器版本变化动手前最好打开 DevTools 看一眼当前环境的真实请求头照抄下来比网上找的万能 headers 更可靠。3.2 翻页逻辑从第 1 页刷到最后一页豆瓣的短评翻页是通过 start 参数控制的每页固定 20 条。第一页的 URL 参数是 start0第二页 start20第三页 start40依次类推。核心翻页函数可以这样写def fetch_page(session, movie_id, start): url fhttps://movie.douban.com/subject/{movie_id}/comments params { start: start, limit: 20, status: P, sort: new_score, } resp session.get(url, paramsparams, timeout10) resp.encoding utf-8 if resp.status_code ! 200: print(f请求失败: {resp.status_code}, start{start}) return None return resp.text这个函数里有一个新手容易踩的坑URL 里带了 params 之后不要再手动把 start 拼到 URL 字符串里requests 会自动处理参数编码。statusP表示只看看过状态sortnew_score是按推荐排序这两个参数保持默认就能拿到正常的短评列表。循环翻页方面我建议通过解析页面中是否还存在“后页 ”这个链接来判断是否到底而不是硬性设置一个“最多抓 100 页”。因为豆瓣短评的可见条数有限很可能翻到第 25 页就没有下一页了硬循环只会浪费请求额度。判断逻辑很简单在解析完当前页后查找 class 为 next 的 a 标签如果不存在就退出循环。start 0 all_comments [] while True: html fetch_page(s, MOVIE_ID, start) if not html: break page_comments, has_next parse_comments(html) all_comments.extend(page_comments) if not has_next: break start 20 time.sleep(random.uniform(2, 5))3.3 解析函数与数据清洗落地解析函数是整段爬虫最核心的部分直接关系到数据质量。我这里写的 parse_comments 会返回两个值当前页的评论列表和是否存在下一页。def parse_comments(html): soup BeautifulSoup(html, lxml) comments [] for item in soup.select(div.comment-item): cid item.get(data-cid, ) username_tag item.select_one(h3 a.avatar) username username_tag.get(title, ) if username_tag else rating_tag item.select_one(span.main-title-rating) rating_map {allstar50: 5, allstar40: 4, allstar30: 3, allstar20: 2, allstar10: 1} rating 0 if rating_tag: for cls, val in rating_map.items(): if cls in rating_tag.get(class, []): rating val break time_tag item.select_one(span.comment-time) comment_time time_tag.get(title, ) if time_tag else vote_tag item.select_one(span.votes) vote_count int(vote_tag.get_text(stripTrue)) if vote_tag else 0 content_tag item.select_one(p.comment-content span.short) content content_tag.get_text(stripTrue) if content_tag else comments.append({ cid: cid, username: username, rating: rating, comment_time: comment_time, vote_count: vote_count, content: content, }) next_btn soup.select_one(a.next) return comments, next_btn is not None这个函数里有两个处理细节值得单独拿出来说。第一个是评分解析豆瓣的 class 里包含 allstar50、allstar40 等要用包含匹配而不是精确相等因为该类可能同时还有其他修饰 class。第二个是 next 的判断用 select_one 取a.next如果存在说明还有下一页这个判断在豆瓣页面上很稳定比检查评论条数是否小于 20 更可靠。解析完成后把列表写入 CSV。这里要特别提醒一个很多人都会踩的坑直接用open(filename, w, encodingutf-8)写 CSV然后用 Excel 打开中文全部乱码。解决办法是写文件时用utf-8-sig编码也就是带 BOM 的 UTF-8Excel 才能正确识别。一行代码就能解决但见过太多人在群里问这个问题了。import csv def save_to_csv(comments, filenamelihua_ying_comments.csv): with open(filename, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[ cid, username, rating, comment_time, vote_count, content ]) writer.writeheader() writer.writerows(comments)最后把抓取结果读出来后可以用一个简单的清洗流程把 content 里所有换行替换成空格把 comment_time 字符串标准化成 datetime 对象把 rating 为 0 的记录单独标记为“未评分”。这样处理完的数据直接就能交给 jieba 做分词或者交给 pandas 做统计。3.4 并发提速的正确姿势串行抓 500 条评论大概需要 10 分钟如果只是个人分析其实完全够用。但你如果想扩展到其他电影或者需要定期更新数据串行就太慢了。此时可以用 concurrent.futures 的线程池做一层轻量提速逻辑比手写 threading 清晰很多。from concurrent.futures import ThreadPoolExecutor, as_completed def fetch_and_parse(start): local requests.Session() local.headers.update(HEADERS) html fetch_page(local, MOVIE_ID, start) if not html: return [] comments, _ parse_comments(html) time.sleep(random.uniform(3, 6)) return comments starts list(range(0, 500, 20)) all_comments [] with ThreadPoolExecutor(max_workers3) as pool: futures [pool.submit(fetch_and_parse, start) for start in starts] for fut in as_completed(futures): all_comments.extend(fut.result())这里的最大陷阱是并发数和频率的平衡。我实测过3 个线程、每个线程请求间隔 3 到 6 秒的情况下抓几百条问题不大如果改成 10 个线程、间隔压到 1 秒很短时间内就会触发验证码。所以并发提速不是把线程数拉满而是在保证数据成功率和自身访问安全的前提下做微调。还有一个细节是每个线程尽量持有独立的 Session 对象。虽然 requests.Session 是线程安全的但共享同一个 Session 在并发场景下可能出现请求头被覆盖、Cookie 状态混乱的问题。上面的写法让每个任务自己创建 Session代价是多几次 TCP 连接但对反爬检测更友好。4. 常见问题与排查技巧实录4.1 高频出现的 418 和 490 怎么办我在这个项目里遇到最频繁的状态码就是 418 和 490。418 的官方语义是“我是一个茶壶”在这里实质表示豆瓣认定你不是浏览器490 在豆瓣语境里通常与请求头缺失或访问异常有关。遇到这些状态码时第一反应不要是“网站改版了”而是按优先级检查三件事User-Agent 是否完整、Cookie 是否过期、请求频率是否过高。如果只是偶尔一两个请求失败我的做法是加一个简单的重试机制重试前等待更长时间。正常情况下一次请求返回非 200 状态码就 sleep 30 秒再试一次如果连续失败 5 次就主动停掉整个爬虫。这种“熔断”策略看似保守实际能避免访问被拉进更高层级的限制名单长期来看反而能抓到更多数据。def get_with_retry(session, url, params, max_retries5): for attempt in range(max_retries): resp session.get(url, paramsparams, timeout10) if resp.status_code 200: return resp print(f第{attempt 1}次请求失败状态码: {resp.status_code}) time.sleep(30) return None4.2 页面结构变动导致解析失败豆瓣的 CSS class 并不是永远不变的某次改版后把 comment-item 改名或者把 short 移到别的位置都会让爬虫瞬间失效。要判断是不是页面结构问题不要凭空猜直接把当前返回的 HTML 保存下来用浏览器打开搜索关键词看评论内容到底在哪个标签里。with open(debug.html, w, encodingutf-8) as f: f.write(html)我建议在爬虫里默认保留一份当天的调试页面尤其是触发异常解析结果的时候。这样即使后续代码修好了你也能复盘到底是哪一个节点发生了变化。新手写爬虫最容易犯的错就是发现解析不到数据就一顿乱改选择器最后把能用的代码也改坏了。正确的流程应该是先确认响应内容正常再确认目标标签存在最后才去修改解析逻辑。4.3 短评数量限制与增量采集很多人抓完这个项目后会问为什么我翻到第 25 页就没了豆瓣的短评接口本身就不是全量历史数据普通未登录的情况下可见条数有限登录后能多一些但也存在上限。这不是代码问题而是平台的数据开放策略决定的。如果确实需要更多评论可以考虑几个方向一是登录账号并携带 Cookie有些情况下可见范围会扩大二是做长期增量采集每天定时抓一次新增评论日积月累也能攒出可观的样本三是把目标放宽到其他平台的热门影评作为补充对照。但不管选哪条路都不要尝试通过高频刷接口的方式绕过平台限制轻则影响账号重则影响整个出口访问的稳定性。4.4 写给新手的合规提醒爬虫这件事技术门槛并不高难的是对边界的把握。抓豆瓣短评这类公开页面用于个人学习和数据分析一般来说没有太大问题但有几条底线最好从一开始就守住控制请求频率不要让服务器感受到明显的压力不把抓到的数据打包公开发布尤其不要做成可下载的数据库不把数据用于商业项目或商业报告除非你确认了平台的授权条款留意页面底部的版权声明和平台规则。我个人觉得把爬虫当成一种理解数据的工具而不是一种“无限白嫖别人服务器资源”的手段心态会健康很多。同样的技术你可以用它采集公开影评数据做研究也可以强行抓取用户私密数据做灰产后者已经不只是技术问题而是法律风险问题。写到这里想跟各位说的是珍惜自己的学习兴趣别让一个本来很有价值的技术点变成给自己惹麻烦的入口。跑完这轮采集之后我自己做了一次简单的词频统计排名靠前的词是“妈妈”、“哭”、“感动”、“贾玲”、“母爱”。这个结果并不意外但当你亲眼看着几百条真实评论被代码变成一行行统计数字时那种“数据在讲一个故事”的体验还是很有冲击力的。爬虫技术说到底只是拿数据的手段真正有意思的是拿数据之后能干什么分析口碑、观察情绪、理解观众的共鸣点这些都远比“多抓几条”更有价值。如果你刚接触 Python 爬虫不妨就拿这个题目练手跑通以后你会对 HTTP 请求、HTML 解析、反爬应对这些概念有一个非常直观的印象。