☰
从豆瓣电影评论入门Python爬虫:HTML解析到JSON接口实战
2026/10/16 2:06:09 网站建设 项目流程

学爬虫的朋友,十有八九第一个“正经项目”都会落在豆瓣电影评论上。这个题目看起来简单——“不就是发个请求、解析一下HTML嘛”,但真正动手之后你才会发现,它把爬虫路上的所有关键节点全串起来了:请求头伪装、页面结构分析、解析库选择、翻页机制、反爬应对、数据保存。任何一个环节卡住,整个流程就走不通。我当年就是从豆瓣电影评论入门的,到现在带过不少人走这条路,它的难度梯度设计得非常合理:新手能跑通、进阶有挑战、还能扩展出情感分析和可视化,一步到位。

这篇文章我会把整个项目从头到尾拆开讲,从最基础的静态页面解析开始,一直讲到用JSON接口拿全量评论。你会看到完整的代码、每一步为什么要这么写、哪些地方容易踩坑,以及遇到反爬时怎么判断和处理。适合刚学完Python基础语法、想动手写第一个爬虫的初学者,也适合已经写过简单爬虫、想系统搞懂豆瓣这个经典案例的人。

1. 项目整体设计与核心思路拆解

1.1 为什么要拿豆瓣电影评论练手

选练手项目有个很现实的标准:数据要真实、目标要明确、难度要有梯度,最重要的是——你练习完还得有继续玩下去的动力。豆瓣电影评论恰恰满足这几点。

先说数据本身。豆瓣短评是公开可访问的内容,不需要登录、不需要付费,访问一条电影页面就能看到。评论里包含用户名、评分、评论时间、有用数、评论正文,字段类型覆盖了字符串、数字、时间戳,做数据清洗和存储时能练到各种类型处理。而且这些数据“有故事”,同一部电影,好评差评的用词风格完全不一样,后面做词云、情感分析、评分分布统计,数据立刻就有了用武之地。

再说技术难度。豆瓣短评页面是服务端渲染和动态接口混合的模式。第一屏的评论直接写在HTML里,你用requests就能拿;但要翻页拿更多评论,就得去碰它背后的JSON接口。这正好模拟了真实爬虫工作场景——大部分网站都不会把全部数据一次性给你,你得学会找接口、分析参数、模拟请求。一次练习,把静态页面爬虫和动态接口爬虫两种模式全过了一遍,性价比非常高。

1.2 技术选型:为什么用requests加BeautifulSoup

技术选型的核心原则是“够用就好”。市面上Python爬虫方案有三大类:直接用requests加解析库、用Scrapy框架、用playwright或selenium做浏览器自动化。我强烈建议入门阶段用第一类。

Scrapy确实强大,异步并发、中间件、管道一应俱全,但它的学习曲线对新手极不友好。你还没搞明白请求头是什么,就要先理解Spider、Item Pipeline、Downloader Middleware这些概念,会把注意力全耗在框架本身而不是爬虫逻辑上。selenium这类浏览器自动化方案更简单粗暴,真实浏览器打开页面,什么动态加载都给你渲染好,但它慢、耗资源,而且掩盖了“数据到底从哪来”这个核心问题——你拖着一个浏览器去模拟真实用户,却没有真正理解HTTP请求的本质。

requests加BeautifulSoup的组合是另一个极端:极简。requests负责把网页拿下来,BeautifulSoup负责从HTML里挑数据。每一步都是透明的,出了错你能直接看到是哪一步。等这套流程跑通了,再去学Scrapy,你会发现框架里的每个概念都很熟悉,因为那只是把你自己写过的代码工程化了而已。

顺带说一句,解析库我用BeautifulSoup而不是正则或lxml的XPath。正则处理复杂嵌套HTML简直是灾难,写的人痛苦、看的人崩溃;XPath功能强但语法绕。BeautifulSoup的select方法支持CSS选择器,语法和前端写CSS一模一样,对新手最友好,而且容错能力强——HTML标签不闭合、属性带空格,它都能正常解析。

1.3 拆解豆瓣评论页面的数据加载机制

动手写代码之前,必须先搞清楚目标页面的数据是怎么加载的。这一步没做好,后面全靠猜。

打开Chrome浏览器,按F12进入开发者工具,切到“Network”面板,勾选“Preserve log”,然后访问一部电影的短评页面。你会看到浏览器发起了一堆请求,其中一个是文档请求,也就是https://movie.douban.com/subject/XXXX/comments这个地址,返回的是一整页HTML。这页HTML里直接包含了当前能看到的20条短评,这就是“服务端渲染”的部分——数据在服务器端就拼装成了HTML字符串,直接发给浏览器。

但如果你在页面上点击“加载更多”,注意看Network面板,会发现一个新的请求出现,地址类似https://movie.douban.com/j/new_comments,这是个JSON接口。它返回的不是完整页面,而是一段纯JSON数据,里面包含“html”字段和“since”字段,“html”是拼好的评论片段,“since”是下一次翻页的游标。这才是真正拿大量评论的关键。

理解了这套机制,整个爬虫方案就清晰了:先试静态HTML拿前20条,练手;再攻JSON接口,拿全部评论。两条路各写一套代码,后文会详细展开。

2. 请求伪装、页面解析与翻页机制的实操要点

2.1 请求头设置:请求伪装是第一道门槛

用requests直接访问豆瓣短评页面,不加任何伪装,大概率会得到418或403的状态码。418是“I‘m a teapot”的调侃码,实际含义是服务器识别出你不是正常浏览器;403则更直接——禁止访问。豆瓣的反爬策略并不算激进,但基本的请求头校验肯定有。

其中最关键的是User-Agent(简称UA)。浏览器每次发请求都会带这个字段,告诉服务器“我是什么浏览器、什么系统”。你不带UA,服务器看到的是一个来历不明的Python脚本,自然直接拦掉。所以第一步就是伪造一个真实浏览器UA。

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", "Referer": "https://movie.douban.com/", "Accept-Language": "zh-CN,zh;q=0.9", }

除了UA,Referer也很重要。它告诉服务器你从哪个页面跳过来的,正常用户从豆瓣首页逐步点进评论页,Referer是豆瓣域名内的地址;不带这个字段,服务器也容易起疑。Accept-Language则告诉服务器你接受中文页面,对后面解析中文内容有帮助。这个请求头模板是多年经验沉淀下来的通用配置,我至今还在用,只是偶尔更新一下浏览器版本号。

2.2 HTML解析:BeautifulSoup的selector写法

拿到HTML响应之后,下一步就是提取评论。这一步的关键是找到合适的CSS选择器。我的操作是先在浏览器里右键检查一个评论节点,看它的class属性,再回到代码里写选择器。

豆瓣短评页面的每条评论都包在div.comment-item里,评论正文在span.short里,用户名在span.comment-info a里,评分在span[class^="allstar"]的class属性里(比如allstar50代表五星),时间在span.comment-time里,有用数在span.votes里。有了这些信息,用select方法就能精准定位:

from bs4 import BeautifulSoup soup = BeautifulSoup(html, "html.parser") for item in soup.select("div.comment-item"): author = item.select_one("span.comment-info a").text.strip() rating = item.select_one("span[class^='allstar']") rating_value = rating["class"][0].replace("allstar", "") comment_time = item.select_one("span.comment-time").text.strip() content = item.select_one("span.short").text.strip() votes = item.select_one("span.votes").text.strip() print(author, rating_value, comment_time, votes, content)

这里有个小坑值得单独说:select_one返回的是Tag对象,如果你想要某个属性的值,要用["属性名"]的方式取,而不是直接.text。比如评分存在class属性里,就得先拿到rating["class"],再从中提取数字。

还有个更隐蔽的问题:部分评论正文特别长,页面上是显示不全的,后面跟个“展开”按钮,完整内容要请求另一个接口。对练手项目来说,直接取span.short的截断内容完全够用,不用纠结。

2.3 翻页机制:静态start参数和JSON接口的since游标

翻页是爬虫绕不开的坎。豆瓣评论的静态HTML方案,翻页靠start参数:comments?start=0&limit=20是第一页,start=20是第二页,以此类推。看似简单,但实测下来要提醒你:新版豆瓣评论页面的start翻页方式已经不太稳定,很多情况下你只能拿到前20条,再往后翻要么重复、要么直接空。这就是静态方案的天然限制。

要拿全量评论,得用JSON接口。它的翻页逻辑是游标式的,不是页码式。第一次请求时带上since=0,接口返回的JSON里会带一个since值;把这个值填进下一次请求,就能拿到下一页数据。这个设计很像社交媒体的“下拉加载更多”,不告诉你总共有多少页,只给你一页一页往后的钥匙。

url = "https://movie.douban.com/j/new_comments" params = { "subject_id": "1292052", "since": "0", "limit": "20", "sort": "new_score", } resp = requests.get(url, headers=headers, params=params, timeout=10) data = resp.json() next_since = data.get("since") html_fragment = data.get("html")

这个接口返回的HTML片段同样可以用BeautifulSoup解析,解析逻辑和静态页面一致。唯一要注意的是,since游标不是递增的整数,而是一个时间戳字符串,每次取值都以上一次返回的since字段为准,不能自己算。

2.4 请求频率控制:不给服务器添麻烦的基本功

写爬虫的人最容易被忽略也最容易被封号的一个细节,就是请求频率。很多初学者一口气循环几百次请求,中间没有任何停顿,结果被服务器识别成恶意访问,轻则IP被临时封禁,重则看到验证码弹窗。

合理的方式是每次请求之间加随机延时。Python的time.sleep配合random.uniform可以实现一个随机等待区间。为什么要随机?因为固定的1秒等待,服务器也能轻松识别出这是脚本行为——人类的操作节奏是波动的,不是节拍器。

import time import random time.sleep(random.uniform(1, 3))

这个策略我用了很久,实测下来,对豆瓣这种中等强度的反爬站点,1到3秒的随机间隔足够友好,也不会让你等太久。如果你要抓几千条评论,建议把间隔拉大到2到4秒,并且设置一个单次运行上限,比如抓500条就主动停,分几次任务执行。

3. 实操过程:完整代码实现与参数讲解

3.1 环境准备:Python、pip和两个关键库

正式写代码之前,先把环境收拾利索。我默认你装了Python 3.8以上版本。在命令行里执行下面这段,确认版本没问题:

python --version

然后安装本次要用到的第三方库:requests和beautifulsoup4。如果后续想把数据存成表格再分析,可以顺手装上pandas。

pip install requests beautifulsoup4 pandas

安装完成后,用pip list看一下能否正常显示这三个包。有个容易踩的坑是:如果你电脑里同时装了多个Python版本,pip install装到了旧版本里,运行时却用的新版本,就会报ModuleNotFoundError。出现这种情况,改用python -m pip install来安装,能保证装进当前Python环境。

3.2 第一版代码:静态HTML抓取前20条评论

环境搞定后,写第一版代码。目标很朴素:拿一部电影的短评页面前20条评论,解析成结构化数据,打印出来看效果。我挑了经典的《肖申克的救赎》,豆瓣编号1292052,你也可以换成任意你喜欢的电影编号。

import requests from bs4 import BeautifulSoup def fetch_comments_static(movie_id): url = f"https://movie.douban.com/subject/{movie_id}/comments?status=P&sort=new_score" 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", "Referer": f"https://movie.douban.com/subject/{movie_id}/", "Accept-Language": "zh-CN,zh;q=0.9", } resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() resp.encoding = "utf-8" soup = BeautifulSoup(resp.text, "html.parser") items = soup.select("div.comment-item") if not items: print("没有解析到评论,可能被反爬拦截或页面结构改版") return for item in items: author = item.select_one("span.comment-info a").text.strip() rating_tag = item.select_one("span[class^='allstar']") rating = rating_tag["class"][0].replace("allstar", "") if rating_tag else "" comment_time = item.select_one("span.comment-time").text.strip() votes = item.select_one("span.votes").text.strip() content = item.select_one("span.short").text.strip() print(f"{author} | 评分:{rating} | 有用:{votes} | {comment_time}") print(content) print("-" * 40) if __name__ == "__main__": fetch_comments_static("1292052")

运行这段代码,你在终端里能看到20条评论和对应的字段信息。有几个细节解释一下:resp.encoding = "utf-8"这行很重要,豆瓣页面是UTF-8编码,如果不手动指定,requests可能根据响应头猜成别的编码,导致中文乱码。resp.raise_for_status()会在状态码不是200时直接抛出异常,帮你快速发现问题。

3.3 第二版代码:JSON接口抓取多页评论

静态版能跑通后,就进入升级环节:用JSON接口抓更多评论。接口返回的html字段就是评论片段,解析方式和静态版一样,只是数据来源变了。还需要把since字段作为下一次请求的参数。

import requests import time import random from bs4 import BeautifulSoup import csv def fetch_comments_dynamic(movie_id, max_pages=5): comments = [] base_url = "https://movie.douban.com/j/new_comments" since = "0" 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", "Referer": f"https://movie.douban.com/subject/{movie_id}/", "Accept-Language": "zh-CN,zh;q=0.9", } for page in range(max_pages): params = { "subject_id": movie_id, "since": since, "limit": "20", "sort": "new_score", "status": "P", } try: resp = requests.get(base_url, headers=headers, params=params, timeout=10) if resp.status_code != 200: print(f"第{page + 1}页请求失败,状态码: {resp.status_code}") break data = resp.json() html_fragment = data.get("html", "") if not html_fragment: print("没有更多评论了") break since = data.get("since", since) soup = BeautifulSoup(html_fragment, "html.parser") items = soup.select("div.comment-item") for item in items: author = item.select_one("span.comment-info a").text.strip() rating_tag = item.select_one("span[class^='allstar']") rating = rating_tag["class"][0].replace("allstar", "") if rating_tag else "" comment_time = item.select_one("span.comment-time").text.strip() votes = item.select_one("span.votes").text.strip() content = item.select_one("span.short").text.strip() comments.append([author, rating, comment_time, votes, content]) print(f"第{page + 1}页抓取成功,累计: {len(comments)}条") time.sleep(random.uniform(1, 3)) except Exception as e: print(f"第{page + 1}页出错: {e}") break return comments if __name__ == "__main__": comments = fetch_comments_dynamic("1292052", max_pages=5) print(f"共抓取 {len(comments)} 条评论")

这里再强调一次:since的值必须以上一次请求返回的data.get("since")为准。这个字段本质是最后一条评论的时间戳,服务器用它做游标,逻辑上叫“按时间往前取”。如果你的循环里一直传since=0,就会永远重复第一页。

3.4 数据落地:保存为CSV文件

光打印到终端不够,数据要落盘才能后续分析。CSV是最通用的格式,既能用Excel打开,又方便Pandas读取。保存时有个老生常谈的坑:用utf-8编码打开中文CSV会乱码,必须换成utf-8-sig。utf-8-sig会在文件开头写入BOM头,Excel识别后就能正确显示中文。

import csv def save_to_csv(comments, file_path="douban_comments.csv"): with open(file_path, "w", newline="", encoding="utf-8-sig", errors="ignore") as f: writer = csv.writer(f) writer.writerow(["用户名", "评分", "评论时间", "有用数", "评论内容"]) writer.writerows(comments) print(f"数据已保存到 {file_path}")

调用方法很简单:save_to_csv(comments)。newline=""是Python写CSV时的一个细节,不加的话Windows平台会在每行后面多出一个空行。errors="ignore"用于忽略个别特殊字符导致的编码问题,防止整个任务中断。

3.5 参数计算:抓多少数据、多久能跑完

聊点实际的:5页评论大概100条,跑完要多久?每一页请求耗时会根据网络状况在0.5到2秒之间波动,中间还有1到3秒的随机延时。按最保守的算法,每页平均耗时3秒,5页就是15秒。这个时间成本完全可以接受。

如果你想把这部片子的热门短评全部抓完,比如500条,那就是25页,预计耗时75到120秒。如果上升到几千条,建议把max_pages拆成多次任务执行,每次跑完休息几分钟再继续,降低被封风险。这不是技术难题,而是频率控制和资源利用的权衡。

3.6 用Pandas快速验证数据质量

数据保存后,最好立刻验证一下。用Pandas把CSV读回来,看看字段有没有缺失、评分分布是否合理:

import pandas as pd df = pd.read_csv("douban_comments.csv") print(df.head()) print(df.info()) print(df["评分"].value_counts())

正常情况下,df.info()应该显示100条数据、5列字段、非空值数量一致。如果某一列有很多缺失,回看解析逻辑,大概率是选择器没匹配到对应的标签。评分列的长度应该能看出五档分布,比如5星最多、1星最少,符合常理。这一步验证能让你在第一时间发现爬虫逻辑的Bug,而不是等分析阶段才发现数据是残缺的。

4. 常见问题与排查技巧实录

4.1 状态码异常速查表

这一节是给你应急用的。我把平时最常遇到的状态码和现象整理成了表格,你遇到问题直接对照排查。

现象状态码可能原因处理方法
返回418或403418/403请求头缺UA或服务器怀疑请求异常检查请求头,补充完整浏览器UA和Referer
页面正常但评论为空200页面结构改版或选择器写错打开开发者工具重新检查节点class名
中文乱码200编码没有被正确识别手动指定resp.encoding = "utf-8"
请求超时无网络不稳定或被限流增大timeout值,增加重试机制
JSON接口返回空html200评论已抓完或since游标失效打印返回的完整JSON,检查字段内容

其中“页面正常但评论为空”最坑。有一次我发现select("div.comment-item")返回空列表,排查了好久才发现豆瓣页面把一条热门评论单独置顶了,它的DOM结构和其他评论不一样,导致解析逻辑没覆盖到。经验是:写选择器之前,先print(resp.text[:500])看一眼返回的HTML长什么样,再动手解析。

4.2 案例复盘:一次请求频率过高导致的临时封禁

说个真实经历。有一次我循环抓某部电影的所有短评,time.sleep只设了0.1秒,运行了几分钟之后,请求返回的状态码突然从200变成403。我当时第一反应是IP被封了,赶紧停了脚本。换浏览器访问豆瓣没问题,但代码里怎么请求都是403。

解决办法很干脆:停手。大概等了十几分钟,IP就恢复了。你要知道,豆瓣对爬虫的策略是“临时限制”优先于“永久封禁”,你的IP大概率只是被短暂标记了。遇到这种情况,关键是立刻停手,而不是换UA继续硬刚——继续请求只会把标记时间拉长,甚至触发验证码。

后来我就学乖了,所有爬虫任务都设置“冷却时间”和“最大页数”的双重保险。max_pages=5是页数上限,time.sleep(random.uniform(1, 3))是冷却间隔,两个缺一不可。我还额外加了异常处理逻辑,连续两次403就直接退出程序,而不是傻乎乎地继续申请。

4.3 阶段性学习守则:爬虫的边界意识

聊点技术之外的东西。爬虫能力越强,越要清楚它的使用边界。拿豆瓣评论练手这件事,本身是安全、合规的学习用途——数据是公开的、目标是学习技术、频率控制得当。但我要强调几点:

第一,严格遵守robots.txt协议。访问https://movie.douban.com/robots.txt能看到豆瓣对爬虫的态度,明确禁止的部分不要碰。第二,控制请求频率,不要对目标网站造成压力,这是每个爬虫工程师的基本职业素养。第三,抓下来的数据只用于个人学习和研究,不用于商业用途,不批量转发。第四,遇到验证码或账号登录要求时,说明目标页面有明确的访问控制,这时候应该停止,而不是想办法绕过。

我一直跟初学者强调一个观点:爬虫技术本身没有对错,取决于用来做什么。练手就老老实实练手,抓公开数据就规规矩矩地抓,每一步都经得起推敲。这不只是职业道德问题,也是保护自己的方式。

4.4 练成之后的扩展方向

代码跑通了、数据也落地了,这个项目就算完成了。但它的价值远不止于此,你可以沿着这条线继续深入。

最直接的扩展是数据分析和可视化。用pandas统计评分分布、用jieba对评论做分词、用wordcloud生成词云、用matplotlib画柱状图,一套流程下来,你从“爬虫入门”升级到了“数据分析入门”,简历上能写的东西立刻多了一行。再进一步,可以用snownlp做简单的情感分析,对比好评和差评的情绪差异,甚至可以按电影类型横向对比几部片子的评论关键词。

技术上同样有升级空间。爬单个电影变成爬多个电影,就需要考虑数据去重和存储方案,可以引入sqlite3或pymongo。想提高效率,可以学Scrapy框架,用并发下载替代串行请求。想应对更复杂的页面,可以学playwright处理JavaScript渲染。每一步都从豆瓣这个案例自然延伸出去,不会突兀。

几个实操中的个人经验分享

最后分享几条我在反复做这类项目后沉淀下来的习惯。第一条:写爬虫之前先花五分钟用浏览器开发者工具看网络请求。很多初学者拿到一个网站就急着写代码,结果被动态加载的数据卡半天。先看清是静态渲染还是走接口,决定技术路线,能省下一大半调试时间。第二条:代码里处处留“后路”。请求前打印URL、拿到响应先看一眼状态码、解析结果为空时落一条日志,这些看似琐碎的习惯,在项目复杂之后能帮你快速定位问题。第三条:延时一定要做随机,固定间隔比没有间隔更容易触发反爬。防封不靠运气,靠的是对服务器策略的基本尊重。第四条:数据落盘用utf-8-sig编码,这个坑我踩过不止一次,让我记到现在。

豆瓣电影评论这个项目,看起来只是一个练手案例,但它把HTTP请求、HTML解析、JSON处理、数据存储、异常应对这些爬虫核心技能完整地过了一遍。跑通它,你对“爬虫是怎么一回事”就有了从抽象到具体的完整认知。接下来无论你面对什么网站,核心思路都是相通的:先观察、再请求、后解析、最后存储。把这套方法论装进脑子里,比记住任何具体的代码都重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询