ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python爬虫实战:抓取豆瓣短评与影评的完整指南

Python爬虫实战:抓取豆瓣短评与影评的完整指南

简介:针对豆瓣影评与短评数据采集的Python爬虫源代码包,面向正在学习网络爬虫、希望获取真实评论数据用于文本分析与舆情观察的开发者。压缩包内共2个文件,均为.py脚本,整体大小仅5KB,结构精简、开箱即用。影评爬虫覆盖影评ID、用户、IP地址、发表时间、推荐度、星级、标题、影评正文、图片,以及赞同、反对、收藏、转发、回应等完整字段;短评爬虫则采集序号、用户、地点、推荐度、点赞数、时间、评论内容与用户主页信息。两份程序分工明确,体现了常规的请求发送、页面解析与字段提取思路,可帮助读者快速掌握requests、BeautifulSoup等库的典型用法,也能在此基础上扩展保存格式、数据清洗或进一步可视化分析。目前已有116人学习下载,适合作为爬虫入门后的实战参考。

1. 为什么用 Python 爬豆瓣短评和影评最练手:反爬密度高、数据形态双、落盘目标明确

“24python爬虫豆瓣短评和影评源代码”这个标题背后,是几乎所有 Python 爬虫学习者都绕不开的一道综合题:同一部电影,短评是一句话的碎片化文本,影评是几百到几千字的长文,两类数据形态不同、页面结构不同、分页逻辑不同,却都处在这套以 UA、Cookie、频率限制为代表的反爬体系之下。把两类数据能抓到本地并落成 CSV,等于把请求构造、HTML 解析、异常处理和存储清洗完整走了一遍。这个项目适合正在学 requests 和 BeautifulSoup 的初学者,也适合需要短评做情感分析、用影评做长文本建模的数据从业者。整篇会从拆页面开始,给出一套能直接跑通的代码,并把高频翻车点集中到一章里讲透。

2. 先拆页面再写代码:短评的列表结构、影评的两级链路和反爬防线从哪里来

2.1 短评页面:一页 20 条,start 是翻页钥匙,评分藏在 CSS 类名里

豆瓣电影短评页的标准形态是 https://movie.douban.com/subject/{电影ID}/comments。打开页面后,每条短评是一个 .comment-item 块,用户名、评分星星、短评正文、发布时间都散落在这个块的不同子节点里。这里有一个新手最容易卡住的点:页面 URL 里看不到分页参数,你需要自己构造 start 和 limit。start 是偏移量,limit 是每页条数,sort 控制排序方式,new_score 按最新、hot 按热门。初次调试,不带任何参数直接访问一次,把返回 HTML 里 .comment-item 的个数打出来,会看到一页正好返回 20 条,这就是翻页的锚点。

为什么豆瓣用 start 而不是页码?这是老式列表接口的设计传统。页码依赖服务端维护“当前页”状态,start 则把状态交给客户端。对爬虫来说,start 意味着你可以跳到任意偏移位置,但也意味着要自己保证每次偏移 20 后不会超出数据末尾。超出时页面返回空列表而不是报错,所以代码里必须检查解析结果为空就停止翻页。这个停止条件在后面 3.3 的代码里有体现,新手最容易在这里写成无限翻页,白白浪费请求次数。

评分星星是一个很隐蔽的解析点。它不是一个可见文本节点,而是一个 CSS 类名,比如 allstar50 表示 5 星、allstar40 表示 4 星。如果你取元素文本,得到的是空字符串。我第一次写这个解析时就在这卡了一晚上,后来打开浏览器开发者工具才发现豆瓣用 CSS 类表达语义。这个经验说明一件事:先用浏览器 F12 看结构,再写选择器,顺序不能反。

2.2 影评页面:列表页只有摘要,正文在独立详情页,必须两级请求

影评的数据链路和短评完全不同。影评列表页 https://movie.douban.com/subject/{电影ID}/reviews 只给你标题、作者和两行摘要,完整正文在 https://movie.douban.com/review/{影评ID}/。抓影评必须分两步:先请求列表页,从每条影评的标题链接里提取影评 ID;再拿着 ID 请求详情页,从 .main-content 里提取正文。列表页的翻页参数是 start 和 count,count 固定为 20。如果不走详情页直接抓列表页,拿到的只是摘要,不是全文。

这个两级链路的加入是影评爬虫和短评爬虫工作量差异的来源。短评把内容直接内联在列表页里,一次请求就能拿到所有字段;影评为了让列表页渲染更快,把长正文拆到独立页面,列表页只做索引。你不可能从列表页直接拼接出全文,因为摘要本身就被截断在固定字数。这也是为什么抓影评时 ID 提取必须是第一优先级,没有 review_id 就什么都没有。

维度短评影评
入口页面/comments/reviews 拿列表,/review/{id}/ 拿正文
正文位置列表页 HTML 内详情页 .main-content
翻页参数start、limit列表用 start、count,详情无翻页
单条数据量一句到几句话几百到几千字
登录依赖未登录可见部分未登录可看列表,详情页可见性受限

2.3 反爬第一道防线:UA、Referer、Cookie 的手动获取与逐项验证

豆瓣的反爬从请求头开始。没有 User-Agent 的 requests 请求会被直接拒绝,Referer 缺失会让部分接口返回 403,Cookie 缺失或过期则导致短评条目被截断。获取方式不复杂:用 Chrome 登录豆瓣,按 F12 打开开发者工具,切到 Network 面板,刷新任意一个短评或影评页面,点开任意一条请求,把 Request Headers 里的字段值复制出来。动手之前先在浏览器地址栏输入目标 URL 确认能正常打开,这是很多人会跳过的一步,浏览器都打不开的页面,requests 大概率也拿不到数据。

拿到 UA 后要注意,复制的是完整字符串,不要只截取中间一段,也不要在里面加多余空格。Referer 先填豆瓣首页,到具体请求评论页时再改成电影主页。Accept-Language 容易被漏,但中文站点没带它偶尔会触发一次额外校验。Cookie 的管理建议单独放,不要写死在代码里:

提示:Cookie 会过期。最好把 Cookie 单独放到配置文件里读取。一旦发现 403 比例上升,先检查 Cookie 是否失效,再考虑调整频率。

3. 短评爬虫最小跑通方案:requests 会话、CSS 选择器解析、CSV 落盘一屏讲完

短评爬虫的最小骨架就是三块:会话构造、页面解析、CSV 落盘。先解决会话。

3.1 建立会话与请求头:把 requests 伪装成浏览器的第一步

import requests import csv import time import random from bs4 import BeautifulSoup def get_session(): session = requests.Session() session.headers.update({ "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", "Referer": "https://movie.douban.com/", "Cookie": "把浏览器里的 Cookie 粘到这里", "Accept-Language": "zh-CN,zh;q=0.9", }) return session

这里解释一下为什么用 Session 而不是裸的 requests.get。Session 对象会保存请求头和 Cookie 状态,后续每个请求都自动携带,不用每条请求单独传 headers。如果你用裸的 requests.get,每次都要把三个头写一遍,代码冗余不说,还容易漏写某一个头踩 403。Session 还有连接池的作用,复用底层 TCP 连接,请求特征更接近真实浏览器的行为。

UA 直接复制浏览器的完整值,不要自己拼接短字符串。豆瓣对明显非浏览器的 UA 识别很快。Referer 先填豆瓣首页,到具体请求时会改成对应电影页。Cookie 是登录凭证,缺失时短评条数明显变少。Accept-Language 容易漏,但建议带上,它对中文站点有实际影响。

3.2 解析短评条目:一次取出评分、正文、时间和点赞数

def parse_comments(html): soup = BeautifulSoup(html, "html.parser") results = [] for item in soup.select(".comment-item"): user_tag = item.select_one(".comment-info a") content_tag = item.select_one(".short") rating_tag = item.select_one(".comment-info .rating") time_tag = item.select_one(".comment-time") if user_tag is None or content_tag is None: continue rating_class = rating_tag.get("class", [""])[0] if rating_tag else "" results.append({ "user": user_tag.text.strip(), "rating": rating_class.replace("allstar", ""), "content": content_tag.text.strip(), "time": time_tag.get("title", time_tag.text).strip() if time_tag else "", "vote": item.select_one(".vote-count").text if item.select_one(".vote-count") else "0", }) return results

解析的核心是理解评分表达方式。rating_class 的取值是 allstar50、allstar40、allstar30 这种格式,replace 掉 allstar 前缀后剩两位数,第一位就是星数。所以 allstar50 变成 "50",取第一位得到 "5"。你也可以保留两位,在统计时转成 1 到 10 分制。time 字段页面上显示的是“3 天前”这类相对时间,但 title 属性里存着完整时间,所以优先取 title,代码里用 get("title", time_tag.text) 做兜底。vote 是这条短评的点赞数,抓下来是为了给后续排序和去重留指标。

如果解析结果全为空,先别怀疑代码,用浏览器开发者工具检查当前页面结构。豆瓣改版不频繁,但评论区 class 结构确实调整过。把页面实际 HTML 和选择器对照一遍,比反复改代码更高效。这个习惯适用于所有爬虫项目,不只是豆瓣。

3.3 翻页与限速:start 偏移量加随机 sleep 的节奏

def fetch_all_comments(session, movie_id, max_pages=10): base_url = f"https://movie.douban.com/subject/{movie_id}/comments" all_comments = [] for page in range(max_pages): params = { "start": page * 20, "limit": 20, "sort": "new_score", "status": "P", } resp = session.get(base_url, params=params, timeout=10) if resp.status_code != 200: print(f"第 {page + 1} 页失败,状态码 {resp.status_code}") break comments = parse_comments(resp.text) if not comments: print(f"第 {page + 1} 页无数据,停止翻页") break all_comments.extend(comments) time.sleep(random.uniform(2, 4)) return all_comments

参数说明:start 每页偏移 20,limit 固定 20 是最自然的组合。sort=new_score 按最新排序,sort=hot 按热门排序,影响的是数据口径,要先想清楚需要哪种再选。status=P 代表只看看过该电影的用户的短评,这是豆瓣评论区的过滤条件之一,去掉它可能混入非观影用户的评论。max_pages 是保护上限,脚本失控时最多请求这么多页,先设 10 跑通再调大。timeout=10 必须写,否则请求挂起会让整个脚本停在原地。

sleep 放在每页结束后,随机 2 到 4 秒。固定间隔比随机间隔更容易被频率规则识别,这一点有点玄学,但实际测试下来固定 3 秒的脚本被限流的概率明显更高。如果你只需要最新 50 条短评,max_pages 设 3 就够了,没必要把所有页都爬完。每多一页就是多一次风险暴露,够用就行。

3.4 落盘 CSV:utf-8-sig 编码与多字段去重

def save_to_csv(comments, filename="douban_comments.csv"): with open(filename, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["user", "rating", "content", "time", "vote"]) writer.writeheader() seen = set() for row in comments: key = (row["user"], row["content"], row["time"]) if key in seen: continue seen.add(key) writer.writerow(row) print(f"已保存 {len(seen)} 条短评到 {filename}")

encoding 用 utf-8-sig 而不是 utf-8,这是血泪经验。utf-8-sig 会在文件头写入 BOM,Excel 双击打开时中文才能正常显示;直接用 utf-8 写出来的文件,记事本和 Excel 打开都会乱码。fieldnames 的顺序决定了 CSV 的列顺序,按 user、rating、content、time、vote 排,后续用 pandas 读进来时列顺序稳定,不用手动调整。

去重的 key 用“用户 + 内容 + 时间”三元组。为什么不用内容单独做 key?因为豆瓣短评里同一句话被多个用户复用的场景太常见了,只看内容会误删大量有效数据。加上用户和时间,重复的概率低很多。热门排序下翻页必然出现重复数据,这一步不做,统计结果会虚高。

4. 影评爬虫完整链路:列表页拿 ID、详情页取正文、超时重试与频率控制

影评的复杂度比短评高一截,核心原因是两级链路。你需要先有一份影评 ID 清单,再去逐条抓正文。这一步不需要并发,但要稳定。

4.1 从列表页提取影评 ID:解析标题链接并去掉尾斜杠

影评列表页的每条影评都包在 .review-item 块中,标题链接的 href 形如 /review/1234567/。提取逻辑如下:

def parse_review_list(html): soup = BeautifulSoup(html, "html.parser") items = [] for item in soup.select(".review-item"): a_tag = item.select_one("a.title") if a_tag is None: continue href = a_tag["href"] review_id = href.rstrip("/").split("/")[-1] items.append({ "review_id": review_id, "title": a_tag.text.strip(), "author": item.select_one(".author a").text.strip() if item.select_one(".author a") else "", "summary": (item.select_one(".main-content .short-content").text .strip().replace("(展开)", "") if item.select_one(".main-content .short-content") else ""), }) return items

href 处理时的 rstrip("/") 是细节但很致命。如果不先去掉末尾斜杠,/review/1234567/ 经过 split("/") 后取最后一个元素是空字符串。先 rstrip 再 split,最后一个元素才是纯数字 ID。summary 字段提取后要 replace 掉“(展开)”字样,这是列表页摘要常见的拼接内容。author 字段不一定在列表页里存在,用了条件表达式兜底,取不到就填空字符串,不影响主流程。

列表页解析失败时,先确认页面里真的存在 .review-item。豆瓣偶尔会为某些影片展示不同的列表模板。我遇到过冷门电影的影评列表外层结构不同,但内部 .review-item 依然存在,所以解析器核心选择器不用变,只需要确认容器存在。经验是不要把选择器写得太深,依赖外层越多,越容易在页面改版时挂掉。

4.2 详情页正文提取:多段落拼接与三次重试

def parse_review_detail(session, review_id): url = f"https://movie.douban.com/review/{review_id}/" for attempt in range(3): try: resp = session.get(url, timeout=10) if resp.status_code == 200: soup = BeautifulSoup(resp.text, "html.parser") article = soup.select_one(".main-content") if article is None: print(f"影评 {review_id} 正文为空,可能已被删除") return None paragraphs = article.select("p") content = "\n".join(p.text.strip() for p in paragraphs if p.text.strip()) return { "review_id": review_id, "title": soup.select_one("h1").text.strip() if soup.select_one("h1") else "", "content": content, } elif resp.status_code == 403: print(f"影评 {review_id} 返回 403,等待 10 秒后重试") time.sleep(10) else: print(f"影评 {review_id} 状态码 {resp.status_code}") return None except requests.Timeout: print(f"影评 {review_id} 超时,重试 {attempt + 1}/3") time.sleep(5) return None

三块要点。第一,正文拼接时过滤掉空 p 标签,避免段落之间出现大量空行,这样落盘后的文本直接可以用 NLP 流程处理。第二,403 和超时的重试策略不同:403 是频率被标记的强烈信号,等 10 秒再试;超时等 5 秒。第三,三次重试后仍失败就放弃这条,返回 None,由主循环决定跳过。

有人会把重试次数调到 10 次甚至无限循环,这在连续 403 场景下没有意义,只会让请求堆积得更严重。如果重试期间一直 403,说明整体频率太高,正确的做法是把所有 sleep 拉长到 10 秒以上,或者直接停止脚本,过一段时间再跑。重试不是万能药,它是兜底机制,不是对抗反爬的工具。

4.3 主循环:列表页与详情页分层的限速节奏

def crawl_reviews(session, movie_id, max_pages=5): base_url = f"https://movie.douban.com/subject/{movie_id}/reviews" all_reviews = [] for page in range(max_pages): params = {"start": page * 20, "count": 20} resp = session.get(base_url, params=params, timeout=10) if resp.status_code != 200: break review_list = parse_review_list(resp.text) if not review_list: break for review_item in review_list: detail = parse_review_detail(session, review_item["review_id"]) if detail: all_reviews.append(detail) time.sleep(random.uniform(4, 6)) # 详情页之间间隔更久 time.sleep(random.uniform(3, 5)) # 列表页之间间隔 return all_reviews

列表页 count 固定 20,start 每页加 20。详情页没有额外参数,纯靠 URL 定位,这是两级链路的主循环。详情页之间的 sleep 比列表页之间更长,因为详情页请求更密集、更容易触发风控。max_pages 先设 5,因为每页 20 条影评,每条影评还要请求一次详情页,5 页就是 100 次详情页请求,这个量级足够测试稳定性了。如果某页列表解析为空,直接 break,不要继续空转。

两种 sleep 写成两行而不是合成一行,是为了方便分别调整粒度。实际跑的时候你很快会感受到一个矛盾:sleep 太短会 403,sleep 太长抓取 5 页影评可能要十几分钟。我的做法是先短间隔跑通流程,再逐步拉长到稳定区间。不要一上来就追求最快速度,爬虫项目的寿命取决于你如何管理请求节奏。

5. 豆瓣反爬避坑清单:403、验证码、乱码、重复数据的五个翻车现场

5.1 高频 403:Referer 缺失或 Cookie 过期

现象:脚本前几页正常,持续翻页后开始连续 403,即使把 sleep 拉到 5 秒也无效。 原因:最常见的是 Referer 没带或 Cookie 过期,其次是请求了不存在的电影 ID,豆瓣对无效 ID 的反应就是 403。第三个容易被忽视的原因是 UA 字符串被截断或拼错,导致请求头不符合浏览器特征。 解决:把 Referer 改成对应电影主页地址,确认 Cookie 仍有效。连续 403 时停 10 分钟再跑,不要靠调高重试次数硬冲。判断 UA 是否有效的快速方法,是在浏览器里复制那份 UA,用命令行工具带它访问目标页面,看返回码是否正常。

5.2 短评数远少于页面显示:登录态截断与折叠

现象:短评页面能打开,解析结果只有几条,跟页面标题上标的“共 1000 条”差距巨大。 原因:未登录状态下豆瓣对短评做了截断展示,只给热门几条。还有一部分短评被折叠在“展开”后面,初始 HTML 里根本没有这些内容。 解决:使用登录后的 Cookie 重新请求,短评条数会显著提升。如果场景不允许使用登录态,就要在数据说明里写清楚口径是“未登录可见短评”,不要拿它当全量数据做模型训练。被折叠的短评可以尝试请求时带 status=P 参数,但最终可见范围仍受登录态限制。

5.3 中文乱码:requests 编码推断有时会翻车

现象:短评正文在爬虫里显示乱码,但浏览器里正常。 原因:requests 依赖响应头里 charset 字段推断编码,豆瓣部分页面没有明确的 charset 声明,requests 会回退到 ISO-8859-1,对中文来说必然乱码。 解决:在 session.get 之后主动设置 resp.encoding = "utf-8",放在解析之前。如果先把响应保存到文件再读取,读取时也要用 encoding="utf-8"。乱码问题优先查编码,不要怀疑选择器。把编码写死在代码里不是最优解,但针对豆瓣这个站点是稳定且可靠的。

5.4 翻页出现重复数据:热门排序下 start 偏移不稳定

现象:抓 10 页短评,去重后发现大量跨页重复,有效数据只有预期的一半。 原因:热门排序按点赞数动态变化,点赞数一变,排序就变,start 偏移定位到的区间会与上一页重叠。最新排序也会因为新评论写入产生轻微偏移。 解决:按“用户 + 内容 + 时间”三元组去重,不要只按内容去重。同一句话可能被不同用户重复写,只看内容会把有效数据误删。去重逻辑要放在写入前,见 3.4 的代码。如果对时效性要求高,切到最新排序后定期抓取,并用时间戳做二次判断。

5.5 影评正文被截断:列表页摘要和详情页全文混在一起看

现象:影评“正文”只有两行字,怀疑解析漏了段落。 原因:列表页的摘要本来就是截断的,完整正文在详情页。如果详情页正文也很短,可能是该条影评本身就是“短书评”形态,字数就少。 解决:确认请求地址是 /review/{id}/ 详情页而不是列表页。在详情页里检查 .main-content 下是否有多段 p 标签,如果只有一段文本且长度很短,说明这条影评本身是短评形态,数据没问题。这个场景下要判断的是自己是否需要长文本,而不是代码有没有写对。

6. 断点续爬与增量抓取:把脚本从“能跑一次”变成“能跑一年”

到这里,短评和影评两条链路都能跑了,但这类项目最容易翻车的其实是中断后的处理。我自己的经历是脚本跑到第 3 页被验证码打断,数据只存了一半,重新跑又抓了一堆重复。后来我给自己定下两条规矩。第一条是把“已抓 ID”落成文件。在写入正文之前,先把 review_id 或短评三元组写进 done.txt,下次启动时读一遍这个文件,跳过已存在的记录。这样断点续爬只需要把 max_pages 调大,不需要改任何逻辑。第二条是抓下来的数据要做二次校验。rating 字段应该能转成整数,时间字段不应该早于对应电影的上映日期,影评 content 长度至少大于 50 字。校验不通过的数据单独写进 error.log,不直接丢弃,方便回溯到底是编码问题还是页面结构变了。

验证阶段给一个可操作的小习惯:抓完 20 条短评和 5 条完整影评后,用 pandas 读一次 CSV,打印每一列的 dtype 和空值计数。然后做两个粗糙统计:短评评分分布、影评平均字数。如果评分分布集中在两三个分值上,大概率是 rating 解析错位;如果平均字数异常低,可能把摘要当正文用了。这两个指标能快速暴露解析层的问题,比逐条对页面省时间得多。

我保留到现在的一个习惯是:每次启动爬虫前,先去浏览器手动刷新一次目标页面,确认自己能正常打开。浏览器都进不去,脚本一定抓不到数据。频率和耐心比代码技巧更能决定一个爬虫项目能跑多久。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进