
1. 项目概述与整体思路1.1 为什么要拿豆瓣电影评论练手如果你在学Python想找一个小而美、不复杂但能串起知识点的练手项目爬虫几乎是绕不开的选择。而在众多爬虫目标里豆瓣电影评论是一个很棒的实际案例原因很简单页面结构稳定、数据格式相对规整、不需要登录就能看到大部分内容而且评论本身是真实的用户文本后续做词云、情感分析都有现成的素材。网上有很多爬虫教程拿豆瓣举例但老实说大部分教程只讲了“用Requests请求一下URL然后解析HTML”就结束了真正实操起来你会发现一堆问题抓下来的评论乱码、翻页翻不了几页就被封了、某些短评页面结构和你预期的不一样……这篇文章就是把整个流程从零走一遍把我踩过的坑和最终的方案都写出来和初学者一起从这个项目中把Python爬虫常用的技术栈摸透。这次的目标是抓到《流浪地球2》的短评包括评论内容、评分、评论时间、点赞数、评论者昵称然后保存成CSV文件方便后续做分析。1.2 整体方案选型为何这样设计爬虫的技术栈选择上我见过很多人一上来就上Scrapy、上Selenium后来发现完全是杀鸡用牛刀还把自己卡在环境问题上。对于豆瓣评论这个场景我的建议是用Requests做HTTP请求不用Scrapy。原因在于这个项目的数据量不大普通脚本足够Scrapy的异步框架反而增加了学习和排错的成本。用BeautifulSoup lxml还是正则表达式这里其实有个取舍。BeautifulSoup对新手更友好代码可读性高正则表达式性能好但维护性差。实际上我在短评页用的是正则因为豆瓣短评页的HTML结构里评论数据是嵌在span classshort标签里的用正则提取非常直接。当然如果你用BeautifulSoup也一样能做看个人习惯。存储用CSV而不是数据库。评论数据量在几百到几千条级别CSV可以直接用Excel打开也方便后续用pandas读取分析。在正式开始之前需要说明的是做爬虫一定要有边界意识。本文所有代码仅用于Python学习和技术交流抓取频率要控制好不要给对方服务器造成压力也不要将抓取的数据用于商业用途。这一点很重要。2. 准备工作环境搭建与爬虫基础知识2.1 Python环境的快速检查我假设你已经安装了Python 3.8以上版本。如果你还在用Python 2现在真的是时候升级了。检查方式python --version如果提示找不到命令可能是环境变量没配好Windows用户请检查“系统属性 - 环境变量 - Path”里有没有添加Python安装路径。Mac和Linux用户一般自带Python 3但要留意是不是系统默认的版本太老。我个人的建议是装一个虚拟环境来隔离依赖避免污染全局环境。用venv就够python -m venv douban_crawler # Windows进入虚拟环境 douban_crawler\Scripts\activate # Mac/Linux进入虚拟环境 source douban_crawler/bin/activate2.2 安装核心库这个项目只需要三个库requests、beautifulsoup4、lxml。pandas不是必须的但如果你后面想读CSV做分析可以装上。pip install requests beautifulsoup4 lxml如果你在安装过程中遇到网络超时建议用国内镜像源加速pip install requests beautifulsoup4 lxml -i https://pypi.tuna.tsinghua.edu.cn/simple这三个库各自的作用一句话就能说清requests负责发HTTP请求拿到网页源码beautifulsoup4负责解析HTML结构提取数据lxml是它的解析引擎速度比Python自带的html.parser快很多。2.3 爬虫基本流程的一次快速梳理可能你在网上看过很多版本本质其实就四步发送请求构造一个符合HTTP协议的Request带上必要的Header特别是User-Agent发送给目标服务器。获取响应服务器返回Response里面有HTML、JSON或者其他格式的数据。解析提取从响应内容中定位并抠出你需要的字段。存储数据把结构化数据写到文件或数据库。就这么简单。后面每一段实操都是这四步的变体。3. 第一版爬虫抓取静态页面评论3.1 先解析目标页面结构打开豆瓣电影《流浪地球2》的短评页面网址是https://movie.douban.com/subject/35162911/comments?statusP我建议你在开始写代码之前先在浏览器里打开这个页面然后按F12打开开发者工具点击“Elements”标签页定位到任意一条评论。你会发现评论内容都在span classshort这个标签里评论者的昵称在a classname里面评分信息在span classallstar40 rating之类的类名里。这一步说白了就是先看清楚你抓的是什么不要一上来就写代码。我在初学的时候跳过这一步直接开写结果翻了车——因为页面里评论不止一个模块我还抓到了演职员介绍里的评论片段。3.2 写第一版代码Requests 正则我们先用最直接的方式抓一次短评。import requests import re import time # 设置请求头模拟浏览器访问 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-Language: zh-CN,zh;q0.9, } url https://movie.douban.com/subject/35162911/comments?statusP response requests.get(url, headersheaders) print(HTTP状态码:, response.status_code) print(响应内容长度:, len(response.text)) # 用正则提取评论内容 pattern re.compile(rspan classshort(.*?)/span, re.S) comments pattern.findall(response.text) for i, comment in enumerate(comments[:5], 1): print(f第{i}条评论:, comment.strip())跑一下这段代码如果你运气好会看到页面里的短评被成功输出。但大概率你会遇到一个问题评论区是空白的或者你收到的内容和你预期的不一样。为什么因为豆瓣在新版页面里对短评做了动态加载初次请求拿到的是“加载中”的占位内容真实的评论是通过异步接口在后续请求中拿到的。这就是我前面强调“先看页面结构”的原因。如果你在浏览器里看到的评论内容和Requests抓到的HTML源码不一样说明存在动态加载。3.3 第一次翻车短评动态加载的问题排查我当时的排查思路是把response.text保存成HTML文件然后用浏览器打开看看到底拿到了什么再和在线页面做对比。这个方法很笨但很有效强烈推荐你也试试。with open(debug.html, w, encodingutf-8) as f: f.write(response.text)打开本地debug.html如果搜不到“流浪地球”这四个字说明评论数据确实不是静态页面里的需要找到真正的接口。这就引出了爬虫的一个重要分支——Ajax接口爬取。4. 抓取动态加载的短评接口4.1 用开发者工具定位真实接口按F12打开开发者工具切换到“Network”网络标签然后刷新页面。你会看到大量的请求我们关心的评论数据其实是通过一个XHRXMLHttpRequest请求返回的JSON。我实际操作中找到的接口URL格式长这样https://movie.douban.com/j/new_comments?subject_id35162911statusPcklimit20start0这个地址的含义拆开看很清晰subject_id电影在豆瓣的编号35162911对应《流浪地球2》statusP只看短评limit每次请求返回的评论条数最大可以到200start从第几条开始取翻页就靠它用这个接口去请求返回的数据是JSON格式结构和页面HTML完全不同但包含的字段更干净评论内容text、评分value、时间time、点赞数vote_count都有。直接拿JSON解析比解析HTML舒服多了。4.2 请求JSON接口并解析数据来看第二版代码import requests import json import csv import time 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/subject/35162911/, } def fetch_comments(subject_id, start0, limit20): url https://movie.douban.com/j/new_comments params { subject_id: subject_id, status: P, ck: , limit: limit, start: start, } resp requests.get(url, headersheaders, paramsparams, timeout10) print(请求URL:, resp.url) print(HTTP状态码:, resp.status_code) data resp.json() return data # 测试请求 data fetch_comments(35162911, start0, limit20) print(返回数据中的键:, data.keys()) print(评论数量:, len(data.get(comments, []))) if data.get(comments): first data[comments][0] print(示例评论字段:, first.keys()) print(评论内容:, first.get(text, )[:50])这段代码做了几件重要的事加了Referer请求头告诉服务器“我是从电影详情页面过来的”这在豆瓣的接口校验中很重要。用params参数而不是直接拼接URLrequests会自动处理URL编码。返回的JSON直接用resp.json()解析不需要手动处理字符串。如果你运行正常会看到返回的comments列表里每一条包含约10个字段。这里的关键字段是字段名含义数据类型text评论内容字符串value评分40表示4星整数time评论时间字符串vote_count有用数点赞数整数author_id评论者ID字符串author_name评论者昵称字符串注意value字段是星级的10倍比如45表示4.5星这不算缺点只是你得知道转换规则。4.3 分页逻辑与评论去重短评接口的翻页逻辑比想象中要复杂一点。豆瓣在返回数据里带了一个total字段表示总评论数。但如果你想用start20、start40这样一路翻下去翻到后面会发现返回的评论开始重复甚至出现total字段消失的情况。这是因为豆瓣对短评接口的深度访问有保护机制它不会让你一次性把所有评论都拉完。实际限制大约是前220条评论可以稳定抓取超过后返回的数据就会乱序或重复。我的策略是控制每页limit20总共抓11页也就是220条评论。每抓一页把所有评论的ID记录到一个集合里遇到重复ID就跳过这样即使接口返回重复数据也不会污染结果。每页请求之间间隔2到4秒模拟真人浏览节奏。这样既保证数据质量也避免对服务器造成太大压力。5. 核心实现完整爬虫代码与数据清洗5.1 完整代码到这里把前面所有部分整合成一份完整的爬虫脚本。这份代码我实测可以稳定运行抓下来200多条短评存成CSV打开不乱码。import requests import json import csv import time import random class DoubanCommentCrawler: def __init__(self, subject_id): self.subject_id subject_id self.base_url https://movie.douban.com/j/new_comments self.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: fhttps://movie.douban.com/subject/{subject_id}/, Accept: application/json, text/plain, */*, } self.seen_ids set() self.comments [] def fetch_page(self, start, limit20): params { subject_id: self.subject_id, status: P, ck: , limit: limit, start: start, } try: resp requests.get(self.base_url, headersself.headers, paramsparams, timeout10) resp.raise_for_status() return resp.json() except requests.exceptions.RequestException as e: print(f请求失败start{start}: {e}) return None def parse_comments(self, data): if not data or comments not in data: return [] parsed [] for item in data[comments]: cid item.get(id) if cid in self.seen_ids: continue self.seen_ids.add(cid) parsed.append({ 评论ID: cid, 评论者昵称: item.get(author_name, ), 评分: item.get(value, 0) / 10, 评论时间: item.get(time, ), 点赞数: item.get(vote_count, 0), 是否剧透: 是 if item.get(is_spoiler) else 否, 评论内容: self.clean_text(item.get(text, )), }) return parsed staticmethod def clean_text(text): # 清理可能存在的空白字符和控制字符保证CSV可读性 return text.replace(\n, ).replace(\r, ).replace(\u3000, ).strip() def crawl(self, max_pages11, limit20): for page in range(max_pages): start page * limit print(f正在抓取第{page 1}页start{start}...) data self.fetch_page(start, limit) if data is None: break page_comments self.parse_comments(data) if not page_comments: print(f第{page 1}页没有新评论停止翻页) break self.comments.extend(page_comments) print(f本页新增{len(page_comments)}条评论累计{len(self.comments)}条) # 随机延时避免访问过于频繁 time.sleep(random.uniform(2, 4)) return self.comments def save_to_csv(self, filename): if not self.comments: print(没有数据可保存) return fieldnames [评论ID, 评论者昵称, 评分, 评论时间, 点赞数, 是否剧透, 评论内容] with open(filename, w, encodingutf-8-sig, newline) as f: writer csv.DictWriter(f, fieldnamesfieldnames) writer.writeheader() writer.writerows(self.comments) print(f数据已保存到 {filename}共 {len(self.comments)} 条评论) if __name__ __main__: crawler DoubanCommentCrawler(subject_id35162911) comments crawler.crawl(max_pages11, limit20) crawler.save_to_csv(douban_comments.csv)你把这整段代码保存成douban_crawler.py在虚拟环境里运行等它跑完就能得到一个CSV文件。5.2 数据清洗环节为什么重要你可能会想评论内容不是直接从JSON里拿出来的吗怎么还要清洗我一开始也这么想直到保存CSV后打开发现两个问题有的评论里包含了换行符导致CSV里一行数据被拆成了两行表格全乱了。有的评论开头会有\u3000全角空格或者尾部有多余空白后续做文本分析时会增加噪音。所以clean_text方法绝对不是可有可无。它的功能概括成一句话就是把不可见字符统一成普通空格再去掉首尾空白。另外CSV写入时用了encodingutf-8-sig这个细节也值得说。单纯用utf-8写入的CSVExcel直接打开会乱码因为Excel默认用ANSI编码读取而utf-8-sig会在文件开头加入BOM标记Excel就能正确识别为UTF-8格式。这是很多教程没提到的坑。5.3 抓取结果的验证跑完脚本后我自己做了几个快速验证保证数据不是“看起来有”而是“真的对”随机抽5条评论在豆瓣网页上人工对比内容、时间和点赞数完全一致。检查评分字段我抓到的评分值除以10后全部是4.0、4.5、5.0这样的数字符合豆瓣星级评分规则。检查评论时间分布集中在2023年1月22日到2023年2月之间和电影上映时间吻合。这几步验证花不了两分钟但能让你对数据质量有底气后续分析才不会基于错误数据得出离谱结论。6. 反爬策略与常见问题排查实录6.1 豆瓣常见的反爬手段豆瓣作为一个老牌网站反爬策略不算激进但也不是完全没有。我实际遇到过的有三种User-Agent检测如果请求头里没有UA或者UA明显是非浏览器比如Python-requests豆瓣会直接返回403或者一个验证页面。访问频率限制短时间请求次数过多会触发风控返回418或503状态码。此时不管你带什么UA都没用唯一的办法是停一段时间再抓。Cookie校验部分接口要求带有有效的ck参数这个参数正常是从Cookie里取的。但我们用的new_comments接口实测传空字符串即可这里存疑的人不用纠结跑一遍代码就知道行不行了。遇到这些情况我的排查顺序是先看状态码403说明被拒绝多半是UA问题503说明被限流应该停止抓取200但内容不对说明接口变了。这个顺序很重要能帮你快速缩小问题范围。6.2 状态码快速排查表状态码含义可能原因应对策略200请求成功无正常解析403禁止访问User-Agent缺失或不合法检查请求头UA设置418触发风控请求频率过高停止抓取等待几分钟或几小时503服务不可用服务器限流或临时故障降低频率增加延时404页面不存在链接错误或页面下线检查URL是否拼写正确6.3 我在实操中踩过的几个具体问题问题1requests请求返回403刚写完第一版代码时我没有设置任何请求头直接requests.get(url)结果豆瓣返回403。当时还以为是网络问题查了很久才发现是UA没伪装。解决办法就是加上User-Agent模拟Chrome浏览器请求。如果你用的是很旧的UA字符串也可能被拦截建议直接复制浏览器开发者工具里看到的UA比如Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36这个。问题2抓到一半出现验证码页面翻页抓到第50条评论左右突然发现返回的内容不是JSON而是一个HTML验证页面。这是因为请求频率太高触发了风控。解决办法是先停半小时再去抓同时给每页请求之间加上2到4秒的随机延时。用一个random.uniform(2, 4)就够了不用很复杂。有人用固定2秒延时其实反而不安全——固定规律更容易被识别。问题3评论内容里出现乱码起初我直接用resp.text解析JSON偶尔会输出乱码。原因是豆瓣的接口返回的是UTF-8编码而requests如果不指定编码可能用默认的ISO-8859-1去解码。解决办法是在获取文本前明确指定响应编码resp.encoding utf-8或者在解析前检查resp.encoding的值。这一行代码看似简单但能避免很多坑。问题4CSV文件用Excel打开后中文乱码这个前面已经提过原因是写入CSV时用的编码是utf-8而不是utf-8-sig。直接用utf-8-sig写入就能解决。6.4 关于动态延时的小技巧很多爬虫教程会建议“每次请求后睡1秒”我不太推荐。固定间隔的请求模式反而更容易被反爬系统识别因为真人点击是不可能那么稳定的。更合理的做法是time.sleep(random.uniform(2, 4))这样每次请求之间的间隔不太规律从日志看更像人工操作。另外我也习惯在每次翻页前随机停一下再看当前累计条数避免一次性狂拉200条之后触发风控。有些朋友可能会觉得延时2到4秒会不会太慢其实你算一下220条评论每页20条一共11页每次等待2到4秒总耗时大约30秒完全在可接受范围内。6.5 一个容易被忽略的坑网络环境变化有一次我在公司网络环境里运行脚本一切正常。回家换了一个网络环境脚本突然报requests.exceptions.ConnectionError。一开始我以为是代码问题后来发现是网络代理的差异。如果你也遇到类似的连接问题建议先在你的终端里测试一下能不能正常访问豆瓣curl -I https://movie.douban.com如果curl正常而Python请求失败那大概率是requests的代理配置问题。可以在请求时禁用代理排查proxies {http: None, https: None} resp requests.get(url, headersheaders, proxiesproxies)6.6 反爬调试日志的思路调试爬虫的时候最忌讳“盲人摸象”——不知道服务器返回了什么就瞎改代码。我通常会在请求之后打印回包的前200个字符快速判断返回的到底是什么。resp requests.get(url, headersheaders) print(resp.text[:200])如果返回的是{comments: [...]}那就是正常的JSON如果返回的是!DOCTYPE html那就是验证页面或者错误页面如果返回的是空字符串可能是IP被临时封禁。这些只需一眼就能判断。这个方法在处理任何网站的反爬问题都适用不只是豆瓣。7. 进阶方向评论数据还能用来做什么7.1 做一份最简单的词云拿到的评论数据如果只是躺在CSV里其实浪费了。我个人建议下一步学一下词云这是爬虫入门后最自然的衔接点。用wordcloud库加jieba分词几十行代码就能把评论生成一张词云图from wordcloud import WordCloud import jieba import pandas as pd df pd.read_csv(douban_comments.csv) text .join(df[评论内容].tolist()) words .join(jieba.cut(text)) wc WordCloud(font_pathmsyh.ttc, width800, height600, background_colorwhite).generate(words) wc.to_file(comments_wordcloud.png)注意Windows下中文字体路径一般填msyh.ttc微软雅黑Mac下可以填/System/Library/Fonts/PingFang.ttc否则词云里的中文会显示成方框。7.2 情感分析的简单思路如果你学了一点机器学习基础可以拿这些评论做情感分类把评分为4星以上的当作正向2星以下的当作负向3星作为中性然后训练一个简单的分类器看能不能根据评论文本自动预测情感倾向。这类项目的完整流程是清洗数据 - 分词 - 构建词向量 - 训练模型 - 评估效果。评论条数越多效果越好220条偏少但做一个演示足够。7.3 评论时间的分布分析与票房关联还有一个好玩的视角把评论时间按天聚合能看到电影上映后第几天的讨论热度最高。如果你有票房数据甚至可以把每日评论数铺在时间线上对比票房变化趋势。这已经不纯是爬虫了是数据分析和商业洞察的入门体验。8. 最终实操心得与避坑建议把这个项目完整跑下来说实话难度不大但过程中能学到的东西不少。最后分享几个我在实际编写和调试中的体会第一爬虫最大的成本其实在于“看清目标”。也许你花在写代码上的时间是20分钟但前期分析页面结构、找到真实接口、确认字段含义可能花了1小时。这很正常也是真实开发中的常态。不要跳过分析和验证的步骤否则后期返工成本更高。第二控制抓取节奏比什么技术都重要。很多人一聊反爬就想着怎么对抗其实温和地爬取、只抓自己需要的数据、不追求“全量数据”往往是最省事的路线。豆瓣短评抓220条做演示完全够用不需要硬刚。第三保存数据的格式要提前想好。如果你只打算打印到控制台那无所谓但要存文件就要一开始确定编码、字段顺序、换行符。不然后面分析的时候再重新清洗痛苦加倍。第四学会读错误信息而不是猜。遇到请求失败先打印状态码和响应内容再判断问题出在哪一层。我见过很多人一遇到报错就怀疑是IP被封结果其实是URL少了个参数。这两个问题的处理方式完全不同。最后再补充一点爬虫技术本身是中立的用途和边界取决于使用的人。如果你的目标是把Python学好用它做一些自动化、数据处理的工作那就够了。学到这个项目结束你至少应该掌握requests请求、JSON解析、正则提取、CSV存储这几个技能它们会在你后续的Python学习中反复用到。