《你好,李焕英》上映之后豆瓣评分一路走高,短评区的情绪浓度和话题热度,基本就是那段时间社交平台的风向标。作为一个常年和数据打交道的爬虫玩家,我看到这个现象的第一反应不是去争论电影好坏,而是想能不能用 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 时显式指定parser="lxml",解析速度能快好几倍。
我平时会顺手装一个 Jupyter Notebook 用于调试页面结构。爬虫写不出合适的 CSS 选择器时,把 response.text 丢到 Notebook 里,对照浏览器开发者工具一起看,比一遍遍 print(response.text) 效率高很多。新手很容易忽略这一步,直接在代码里打印响应内容,遇到长页面输出几十屏后整个人都麻了。
2.2 豆瓣短评页面的 DOM 结构拆解
打开《你好,李焕英》的短评页面,按 F12 看元素面板,你会发现评论列表的整体容器是div#comments,里面每一个评论区块常见的 class 是comment-item,后来豆瓣改版后部分字段也做过调整。以我一次实际抓取的页面为例,结构大致是这样的:
<div class="comment-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/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", "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 参数是 start=0,第二页 start=20,第三页 start=40,依次类推。核心翻页函数可以这样写:
def fetch_page(session, movie_id, start): url = f"https://movie.douban.com/subject/{movie_id}/comments" params = { "start": start, "limit": 20, "status": "P", "sort": "new_score", } resp = session.get(url, params=params, timeout=10) 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 会自动处理参数编码。status=P表示只看看过状态,sort=new_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(strip=True)) if vote_tag else 0 content_tag = item.select_one("p.comment-content span.short") content = content_tag.get_text(strip=True) 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", encoding="utf-8")写 CSV,然后用 Excel 打开,中文全部乱码。解决办法是写文件时用utf-8-sig编码,也就是带 BOM 的 UTF-8,Excel 才能正确识别。一行代码就能解决,但见过太多人在群里问这个问题了。
import csv def save_to_csv(comments, filename="lihua_ying_comments.csv"): with open(filename, "w", newline="", encoding="utf-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_workers=3) 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_retries=5): for attempt in range(max_retries): resp = session.get(url, params=params, timeout=10) 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", encoding="utf-8") as f: f.write(html)我建议在爬虫里默认保留一份当天的调试页面,尤其是触发异常解析结果的时候。这样即使后续代码修好了,你也能复盘到底是哪一个节点发生了变化。新手写爬虫最容易犯的错,就是发现解析不到数据就一顿乱改选择器,最后把能用的代码也改坏了。正确的流程应该是:先确认响应内容正常,再确认目标标签存在,最后才去修改解析逻辑。
4.3 短评数量限制与增量采集
很多人抓完这个项目后会问:为什么我翻到第 25 页就没了?豆瓣的短评接口本身就不是全量历史数据,普通未登录的情况下可见条数有限,登录后能多一些,但也存在上限。这不是代码问题,而是平台的数据开放策略决定的。
如果确实需要更多评论,可以考虑几个方向:一是登录账号并携带 Cookie,有些情况下可见范围会扩大;二是做长期增量采集,每天定时抓一次新增评论,日积月累也能攒出可观的样本;三是把目标放宽到其他平台的热门影评,作为补充对照。但不管选哪条路,都不要尝试通过高频刷接口的方式绕过平台限制,轻则影响账号,重则影响整个出口访问的稳定性。
4.4 写给新手的合规提醒
爬虫这件事,技术门槛并不高,难的是对边界的把握。抓豆瓣短评这类公开页面,用于个人学习和数据分析,一般来说没有太大问题,但有几条底线最好从一开始就守住:控制请求频率,不要让服务器感受到明显的压力;不把抓到的数据打包公开发布,尤其不要做成可下载的数据库;不把数据用于商业项目或商业报告,除非你确认了平台的授权条款;留意页面底部的版权声明和平台规则。
我个人觉得,把爬虫当成一种理解数据的工具,而不是一种“无限白嫖别人服务器资源”的手段,心态会健康很多。同样的技术,你可以用它采集公开影评数据做研究,也可以强行抓取用户私密数据做灰产,后者已经不只是技术问题,而是法律风险问题。写到这里想跟各位说的是:珍惜自己的学习兴趣,别让一个本来很有价值的技术点变成给自己惹麻烦的入口。
跑完这轮采集之后,我自己做了一次简单的词频统计,排名靠前的词是“妈妈”、“哭”、“感动”、“贾玲”、“母爱”。这个结果并不意外,但当你亲眼看着几百条真实评论被代码变成一行行统计数字时,那种“数据在讲一个故事”的体验还是很有冲击力的。爬虫技术说到底只是拿数据的手段,真正有意思的是拿数据之后能干什么:分析口碑、观察情绪、理解观众的共鸣点,这些都远比“多抓几条”更有价值。如果你刚接触 Python 爬虫,不妨就拿这个题目练手,跑通以后你会对 HTTP 请求、HTML 解析、反爬应对这些概念有一个非常直观的印象。