☰
Python爬虫实战:豆瓣短评稳定抓取与反爬避坑指南
2026/10/13 16:03:45 网站建设 项目流程

简介:一套完整的Python爬虫实战项目资料,面向希望掌握网络数据采集、清洗与可视化全流程的初学者及进阶者。项目聚焦豆瓣平台病毒与疾病题材电影的短评数据,从零演示如何使用Requests库发送HTTP请求、借助BeautifulSoup解析网页结构并提取电影ID、短评内容和评分字段,同时针对常见反爬机制给出了设置User-Agent、处理Cookie、使用代理IP等实用规避技巧。数据阶段引入Pandas进行去重、缺失值处理和文本清洗,再通过jieba分词与Counter计数完成高频词统计;可视化部分则利用Matplotlib、Seaborn和WordCloud生成词云及统计图表。压缩包为RAR格式,约7.84MB,涵盖项目源代码、爬取到的原始数据文件以及生成的可视化结果,结构清晰,便于边读边练。已有1205人浏览学习,适合作为课程设计参考或爬虫技能从入门到实践的完整样例。

1. 抓豆瓣短评前先想清楚:这四件事决定你能不能跑通

Python爬取豆瓣短评,看起来是“拿requests请求一个页面再解析HTML”的入门操作,但实际做起来你会发现,同样的代码白天能跑晚上就403,换台电脑可能直接重定向到登录页。这篇笔记不是教你把豆瓣当成免费语料库无节制地抓,而是把“如何稳定地抓取一个电影的短评列表”讲清楚:请求头里少了什么、分页参数是什么语义、限速该怎么设计、字段解析怎么不漏。适合刚学Python不久、想用真实网页练手的学习者,也适合正在积攒影评文本做分析的人。读完你能跑通“单页短评抓取→多页翻页→CSV落盘”的完整链路,遇到反爬也能定位问题。

2. 短评页面结构与请求分析:为什么直接requests能看到数据

2.1 短评页URL和HTML结构

豆瓣电影短评页的URL遵循固定模式。例如一部电影的subject_id(豆瓣内部编号)是1292052,那么短评页就是:

https://movie.douban.com/subject/1292052/comments

页面通过URL参数控制翻页和排序,常见参数包括:

  • start:从第几条短评开始,默认0
  • limit:每页返回多少条,常见20
  • status:短评类型,P表示看过,F表示在看等
  • sort:排序方式,new_score是按推荐/热度,time是按时间

打开短评页后,“查看网页源代码”能看到每条短评被包裹在<div class="comment-item">节点内。里面至少有五个有用字段:评论者昵称、评分(通常用<span>的class表示几星)、评论发表时间、有用数(“有用XX”)以及短评正文(一般放在<span class="short">里)。有的老版本页面还会出现“展开”按钮,说明正文超过几十字被截断,这些细节我们后面解析要注意。

2.2 不带头请求头会发生什么

直接用requests.get("https://movie.douban.com/subject/1292052/comments")访问,大概率返回200但里面没有短评,而是一个“安全检测”提示页,甚至重定向到登录页。这是因为豆瓣的访问策略会先检查请求头中的User-Agent和Referer,如果看起来不像浏览器,就返回一个简化页面或直接302。

解决的办法是模拟浏览器的请求头。最关键的是User-Agent,比如Chrome浏览器的UA字符串;其次是Referer,它应当指向这部电影的主页https://movie.douban.com/subject/1292052/;再一个就是Cookie。豆瓣的爬虫识别在近两年变得更严格,即使有UA和Referer,也不一定每次都能过,所以Cookie通常要从你自己的浏览器里复制一份,带着它请求的成功率会高很多。

注意这里有个边界:Cookie相当于是你的登录凭证,自己学习使用没问题,不要打包在公开代码里,更不要用别人的账号信息。我们后面所有代码里的Cookie都应该是你自己的,而且用完记得在分享时抹掉。

2.3 用开发者工具确认关键请求参数

在动手写代码前,我习惯先把浏览器开发者工具打开(F12),切到Network面板,手动刷新一次短评页,然后找到名字是comments的XHR或Doc请求。不同类型页面加载出的文件名不一样:老版短评是普通HTML文档,新版可能是接口JSON。如果看到的是/j/review/...之类的接口,说明你访问到了新版页面,那么解析方式就得换成直接消费JSON字段,比解析HTML更省事。但大多数电影短评页仍然返回HTML文档,所以这篇文章以HTML解析为主,后面会提一句JSON接口的适配。

这一步的真正目的是确认三件事:请求URL带了哪些query参数、请求头里实际发送的Cookie是什么、响应体中出现短评内容的节点。把这些确认清楚,下面写代码就不会靠猜。

这里也可以提一句robots:豆瓣的robots协议并不允许爬虫肆意抓取所有页面,所以我们只做低频率、少量页面的学习爬取,永远不要把短评页的limit参数改成100。很多“被封”案例就是单次请求太贪心。

3. 写第一个可用的爬虫脚本:requests + BeautifulSoup 拉取短评

3.1 环境准备与最小依赖

在开始之前,确保Python环境为3.8以上。需要安装requests和beautifulsoup4,以及后面保存数据用的pandas(非必须,但处理起来方便)。安装命令:

pip install requests beautifulsoup4 pandas

说明:pandas用来把结构化数据写成CSV或Excel,如果你不想装,也可以在代码末尾用csv.writer自行写文件。这里用pandas的主要理由是它对中文编码的处理更顺手,也方便做后续的情感分析。

3.2 解析单页短评的代码

我们先把单页短评解析做成一个函数,输入是HTML文本,输出是这个页面的短评列表。以“评论项、评论者、评分、时间、有用数、内容”为字段。参考代码:

from bs4 import BeautifulSoup def parse_comment_page(html): soup = BeautifulSoup(html, "html.parser") items = soup.select("div.comment-item") records = [] for item in items: # 评论者昵称,通常在 comment-info 里的第一个 a 标签 author = item.select_one("div.comment-item .comment-info a") nickname = author.text.strip() if author else "" # 评分:rating 元素 class 形如 allstar40,表示4星 rating = item.select_one("span.allstar40, span.allstar50, span.allstar30, span.allstar20") rating_text = "" if rating: class_name = rating.get("class", []) if class_name: star_num = class_name[0].replace("allstar", "") rating_text = f"{int(star_num) / 10:.1f} 星" # 时间:comment-time 里的 title 或者文本 time_node = item.select_one("span.comment-time") comment_time = time_node.get("title", "") if time_node else "" # 有用数:vote-count 节点 vote = item.select_one("span.vote-count") vote_count = vote.text.strip() if vote else "0" # 短评正文:span.short content_node = item.select_one("span.short") content = content_node.text.strip() if content_node else "" records.append({ "author": nickname, "rating": rating_text, "time": comment_time, "votes": vote_count, "content": content, }) return records

代码解析:选择器div.comment-item定位每条评论的容器;昵称、评分、时间、有用数、正文都在固定子节点中。这里用select_one,所以某个字段缺失不会让整个脚本崩溃。

参数说明:select_one("span.allstar40, span.allstar50, ...")是一个并集选择器,实际豆瓣评分节点class就是allstar40这样的格式,数字为星数乘以10。如果你只想要特定星级的评论,也可以自行过滤。解析时间时,推荐读取title属性,因为文本里的时间格式可能带“刚刚”等相对时间,而title里是标准ISO时间,更方便排序。

3.3 请求模块:带上请求头与分页控制

解析函数准备好后,需要写请求函数。注意我们把Cookie、User-Agent、Referer放在一个字典里,并且用一个Session对象来保持连接。这样做的原因是豆瓣会短暂记住你访问的主页和评论页之间的关系,如果用独立的requests.get,每次连接都是独立的,更容易被判定为脚本。

import requests import time import random HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Referer": "https://movie.douban.com/subject/", "Accept-Language": "zh-CN,zh;q=0.9", } def fetch_short_comments(session, subject_id, start=0, limit=20): url = f"https://movie.douban.com/subject/{subject_id}/comments" params = { "start": start, "limit": limit, "status": "P", "sort": "new_score", } # 注意:这里可以用 session.get,因为 session 里保存了 cookie resp = session.get(url, params=params, headers=HEADERS, timeout=15) if resp.status_code == 403: raise RuntimeError(f"访问被拒绝,start={start},请检查Cookie或UA") resp.raise_for_status() return resp.text

代码里的params会由requests自动拼接成查询字符串。status=P表示只看“看过”的用户短评,这类评论通常更正式;如果你需要“在看/想看”的评论,可以改F或W。每次请求之间必须间隔,否则会很快触发反爬。建议间隔时间设置在2到5秒之间随机,不要固定一个值。

3.4 多页爬取与CSV保存

单页搞定后,多页只是循环start偏移量。豆瓣普通短评列表最多能翻到500条左右,再往后需要登录或者进入“全部短评”的聚合页。我们以一个电影ID为例,抓取前10页,每页20条,共200条。代码:

def crawl(subject_id, max_pages=10): session = requests.Session() session.headers.update(HEADERS) # 把浏览器里的cookie放进session # 注意:填Cookie时不要带 Cookie= 前缀,只填 value 字符串 session.cookies.update({"your_cookie_key": "your_cookie_value"}) all_records = [] for page in range(max_pages): start = page * 20 try: html = fetch_short_comments(session, subject_id, start=start) except RuntimeError as e: print(e) break records = parse_comment_page(html) all_records.extend(records) print(f"第{page+1}页抓到{len(records)}条") # 随机间隔 2~5 秒,让请求看起来更接近人工浏览 time.sleep(random.uniform(2, 5)) return all_records if __name__ == "__main__": subject_id = "1292052" # 改成你要抓的电影ID data = crawl(subject_id, max_pages=3) print(f"总共抓取 {len(data)} 条短评")

补充说明CSV保存:

import pandas as pd def save_to_csv(records, filename="douban_comments.csv"): df = pd.DataFrame(records) # utf-8-sig 编码能避免 Excel 打开中文乱码 df.to_csv(filename, index=False, encoding="utf-8-sig") print(f"已保存到 {filename},共{len(df)}条")

参数说明:subject_id是豆瓣电影在URL中的编号,可以通过搜索电影后从地址栏复制;max_pages要谨慎,建议从3页开始跑通,确认字段无误后再增加。如果打印出来的记录数和页面实际数量对不上,多半是解析结构变了,回到2.3用开发者工具重新看节点。

你可能会问,为什么这个版本没有处理登录过期?如果你的Cookie失效,通常第二次请求就返回403。我们在第4章讲重试和限速时会补上。

4. 反爬识别与请求频率控制:状态码、间隔和重试

4.1 豆瓣的反爬状态码:403与302分别说明什么

豆瓣的反爬机制并不神秘,但你需要学会看状态码。简单来说,如果请求返回403 Forbidden,说明请求被服务器判定为不被允许的客户端,常见原因是缺Cookie、IP频繁、UA异常。如果返回302并跳到passport.douban.com,说明服务器要求你登录或者验证,常见于未带Cookie访问“全部短评”接口,或者当前IP被要求重新验证。

在实际运行中,我的习惯是写一个简单的响应检查函数:

def check_response(resp): if resp.status_code == 403: print("403:IP或请求头被拒,尝试等待并更换UA") return False if resp.status_code == 302: print("302:被要求登录/安全验证,检查Cookie并稍后重试") return False if "验证码" in resp.text or "安全验证" in resp.text: print("遇到验证码页面,停止抓取并延长间隔") return False return True

这个方法的价值不只是打印状态,而是帮你快速定位原因。例如出现验证码页面时,状态码可能是200,如果只看状态码会误以为成功,所以一定要检查响应文本关键字。

4.2 随机间隔与限速设计

请求频率是反爬的第一道门槛。很多翻车案例是循环里没有sleep,几十个请求在几秒内触发,然后IP被限制一段时间。短评页的合理间隔至少是2秒,稳妥一点是3到6秒,并且用随机值。固定间隔其实也有规律,反爬系统会统计请求时间差,完全等间隔反而更容易被发现。

我通常这样实现限速:

import random import time def random_delay(min_sec=2.0, max_sec=5.0): delay = random.uniform(min_sec, max_sec) time.sleep(delay)

可以在每次请求前调用。如果你要抓多个电影,建议每切换一个subject_id,额外等待10秒以上,模拟从搜索到进入页面的过程。这里不是玄学,而是从请求来源规律上降低被关联的概率。

另外,limit参数不要调得太大。老版短评接口单页支持20条,新版可能支持到50条,但拉取条数越多,单次响应体越大,也会更显眼。新手从一开始就养成“抓得少、间隔大”的习惯,后面做大规模采集时才不会踩无线程控制层面的坑。

4.3 重试与异常处理的兜底逻辑

网络请求不可能永远成功,超时、连接重置、响应不完整都会发生。我们最好给请求函数加一个简单的重试机制。这里不引入第三方重试库,用函数本身循环处理即可:

def request_with_retry(session, url, params, headers, max_retries=3): for attempt in range(1, max_retries + 1): try: resp = session.get(url, params=params, headers=headers, timeout=15) if check_response(resp): return resp except requests.exceptions.RequestException as e: print(f"第{attempt}次请求异常:{e}") wait_time = 30 * attempt print(f"等待 {wait_time} 秒后重试") time.sleep(wait_time) raise RuntimeError("重试三次后仍然失败")

这里的等待时间采用线性增长,第一次失败等30秒,第二次等60秒,第三次等90秒。你也可以换成指数退避,但短评爬取场景线性就够了,因为大概率不是服务器故障,而是触发了频控。

重试时要注意:如果响应文本里出现“验证码”,再等多久都没有意义,因为你的Cookie或IP已经进入风险名单,这种情况下继续重试只会加重限制。应该停下来,换一个会话或者手动确认后再继续。

4.4 代理池:什么时候需要,什么时候不需要

很多教程会把代理池写得很复杂。坦白说,抓个几百条短评完全不需要代理。proxy只有在两种情况才需要考虑:一是同一个IP有大量并发需求(比如每天抓上万条),二是IP已经被封,需要换个出口继续。对学习项目来说,代理不只是成本,还会带来稳定性问题:免费代理经常失效,响应延迟不稳定,反而让你的代码更脆。

如果一定要用,requests的代理参数很简单:

proxies = { "http": "http://127.0.0.1:7890", "https": "http://127.0.0.1:7890", } session.proxies.update(proxies)

但这里不推荐抓豆瓣短评时使用代理,原因是豆瓣本身有地域验证,代理出口如果被其他爬虫污染过,你会更容易被拦截。与其依赖代理,不如老老实实降低频率、用你自己的真实网络环境。

5. 避坑指南:短评爬取中最容易翻车的五个细节

5.1 现象:爬到一半IP被限制,全部请求返回403

原因:爬取脚本没有控制请求频率,或者短时间内在同一个电影的不同分页上连续请求,被计量识别为异常流量。有些情况下,仅仅是被解析到设置了浏览器环境指纹,但你的requests库特征与浏览器不同,即使有UA也容易误伤。

解决:立即停止程序,等待30分钟到数小时。恢复后把请求间隔提升到5秒以上,并且每次启动从第1页开始试跑,确认能正常返回200再继续。如果是长时间大批量使用,建议在每次请求前随机切换User-Agent(从一批常用UA里选择),不要所有请求都用同一个UA字符串。同时可以用Session维持cookie,而不是每次重建会话。

5.2 现象:短评内容被截断,只剩“展开”之前的几十字

原因:豆瓣为了页面美观和加载性能,超过某一长度的短评默认只展示一部分,在HTML中只返回截断文本,完整内容需要点击“展开”后通过接口加载。我们的span.short选择器只能拿到截断后的文本。

解决:判断逻辑上,如果短评正文末尾出现“展开”相关标识,说明存在截断,此时可以放弃抓取完整内容,或者改抓>content_node = item.select_one("span.short") content = content_node.text.strip() if content_node.get("data-original"): content = content_node["data-original"].strip()

数据可用性上,大多数短评不超过140字,截断情况不多。如果确实需要完整文本,可以转而访问每条评论对应的短评接口(点击展开时网络面板里能看到),但这样会让请求量翻倍,需要重新评估频率限制。

5.3 现象:Cookie失效后请求仍返回200,但数据是空的

原因:Cookie失效时,豆瓣可能不会直接拒绝,而是返回一个包含“请登录”的通用页面,状态码依然是200。如果你只检查状态码,脚本会继续往下跑,最终解析出0条评论。

解决:在parse_comment_page之后加一个记录数量判断,如果为0且页面文本包含“请登录”“安全验证”等关键字,则抛出异常停止。同时定期手动确认浏览器中豆瓣仍是登录状态,因为Cookie的session有效期通常只有几天,几天前复制的一般已经失效。建议每次运行前重新复制,或者使用更稳定的登录态方案(但不要自动登录,那会涉及更多的验证风险)。

5.4 现象:CSV打开后中文乱码,或emoji变成问号

原因:默认使用utf-8编码写CSV,而Excel在Windows上默认以GBK打开,会看到乱码;如果文本包含emoji,Excel的CSV格式本身不支持良好,直接显示?或错误。

解决:用encoding="utf-8-sig"写CSV,保存时保留BOM头,Excel即可正确识别。如果希望彻底避免emoji问题,可以在写入前用encode('ascii', 'ignore')丢弃非ASCII字符,但那样会损失信息。更好的办法是直接保存为JSONL或使用SQLite,不经过Excel显示。对文本分析来说,JSONL是更合适的选择。

5.5 现象:解析出的时间全是“今天”“昨天”而非标准日期

原因:豆瓣页面为了用户体验,列表页显示相对时间,而标准时间藏在了title属性中。如果直接用span.comment-time的文本内容,拿到的就是“刚刚”“昨天”这类相对时间。

解决:优先读取time_node.get("title"),title属性通常是一个标准ISO格式。如果title不存在,再后退到文本内容,并自己写一个相对时间转换函数。这个坑在时间序列分析时影响很大,不要以为抓到“参差不齐”的时间就是网站问题。

另外提醒一个容易忽略的:聚合页的短评排序每次刷新会变化,所以如果你要做统计分析,必须把抓取时间(crawled_at)也记录下来。

6. 把抓到的短评变成初步分析结果:评分分布与快速词频

代码跑通后,别急着扩大抓取范围,先用已有的几百条数据验证质量。我习惯做两个最小分析:一是看评分分布是否和你对该电影的认知一致,二是用简单的词频统计看看高频词。

评分分布可以直接用pandas完成:

import pandas as pd df = pd.read_csv("douban_comments.csv") rating_counts = df["rating"].value_counts().sort_index() print(rating_counts)

这里需要确认rating字段是“4.0 星”这样的字符串,如果是,可以提取数字:

df["rating_num"] = df["rating"].str.replace("星", "").astype(float) df["rating_num"].plot.bar() # 需要matplotlib

词云是另一个常见展示方式。如果不想安装额外的中文分词库,可以先用jieba分词并统计词频,然后画词云。安装依赖:

pip install jieba wordcloud matplotlib

代码:

import jieba from collections import Counter all_text = " ".join(df["content"].tolist()) words = jieba.lcut(all_text) # 过滤掉单字、停用词和标点,这里简单示意 filtered = [w for w in words if len(w) > 1 and w not in ["什么", "没有", "就是", "一个", "这部", "电影"]] counter = Counter(filtered).most_common(30) print(counter)

wordcloud做图时注意两点:第一,需要指定中文字体,否则中文全部显示成方框;第二,词云的随机性比较大,不要把它当作严谨的分析结论。它更适合作为验证数据可用的装饰性输出。

如果你继续做情感分析,可以按评分把短评分成“好评”(评分>=4)和“差评”(评分<=2),再对比两类文本的高频词。这样能简单看出观众抱怨最多的是什么。对于分析项目,这个流程足够了。

做完整轮之后,我自己的教训是:爬取这件事,最宝贵的不是代码,而是对目标网站节奏的尊重。以前我图快,把间隔调到0.5秒,结果IP被封了一天,连正常上网都受影响。后来老老实实随机延迟、控制总量,反而稳定跑完了计划数据。希望你也能从这篇笔记里少走这个弯路,先小批量跑通,再谈放大。希望帮到你。

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

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

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

立即咨询