最近不少朋友私信问我同一个问题:网上Python爬虫教程一大堆,为什么照着别人的代码跑豆瓣影评,不是各种报错就是被拒绝访问,偶尔能跑通一次,数据又存不进Excel?我复盘了一下,问题通常集中在三块:环境没配好、页面解析逻辑没讲透、最后落盘的时候踩了坑。这篇文章就把整条链路完整拆开——用Python发请求获取豆瓣影评页面,用XPath把评论内容从HTML里精准挖出来,再用openpyxl写入带格式的Excel表格。我尽量按零基础的标准写,每一步的"为什么"也顺带讲清楚,你不需要会正则、不需要懂框架,跟着思路一步步走就能跑通。
1. 项目拆解:一个"零基础爬虫"真正要解决的四个问题
1.1 明确目标和边界:我们要爬什么、存什么
项目的目标很简单:给定一部豆瓣电影的ID,获取它的前若干页热门影评,把评论者昵称、评分星级、评论时间、有用数和评论正文这几个字段抓下来,存进一个能直接双击打开的Excel文件。
很多人一上来就写代码,结果写一半发现不知道解析什么、存给谁看。我建议动手之前先回答四个问题:数据从哪里来、数据结构长什么样、用什么方式存、被目标网站拦截了怎么办。这四个问题想清楚,你写的每一行代码都有明确指向,调试起来也快得多。
从技术角度看,这个项目的完整链路是:requests请求影评页面HTML,lxml把HTML解析成可查询的节点树,XPath按条件定位评论节点,最后把清洗过的数据逐行写入Excel。整条链路覆盖了爬虫最常见的四个环节,这也是我推荐它作为入门项目的原因——麻雀虽小,五脏俱全。
1.2 技术选型:为什么是requests + lxml + openpyxl
Python爬虫的方案不少,新手最容易困惑的是选择太多。我直接说我为什么选这三件套:
| 环节 | 工具 | 选择理由 |
|---|---|---|
| 网络请求 | requests | 语法简单,十几行就能发起请求拿到HTML,不像Scrapy需要理解框架和中间件 |
| 页面解析 | lxml + XPath | 用路径表达式定位节点,比正则表达式直观太多,不需要记忆各种转义规则 |
| 数据存储 | openpyxl | 能生成真正的.xlsx文件,还能设置样式,保存后Excel直接打开就能看 |
不是说Scrapy不好、正则不能用,而是这个项目是给零基础入门用的,工具的上手成本必须低。requests发请求、XPath提取数据、openpyxl写文件,三个库的职责非常清晰,学完以后你也能清楚地把"爬虫"这件事拆成几个独立模块。等你理解了这套流程,以后再去看Scrapy或pandas,会发现它们的核心思路其实是一样的。
另外说一下为什么不用csv推荐Excel:csv虽然简单,但中文用记事本打开容易乱码,也没法加样式和筛选。openpyxl生成的文件是标准的.xlsx格式,带表头、带列宽、还能冻结首行,给人看或者继续做数据分析都更方便。
2. 环境准备:零基础也能一次装齐的开发环境
2.1 安装Python时最容易忽略的那一步
如果你电脑上还没有Python,去官网下载3.10或3.11的稳定版本即可。安装时有一件事必须做:勾选"Add Python to PATH"。我见过太多新手代码写好了,双击运行却提示python不是内部或外部命令,根源就是漏了这一步。
安装完成后,打开命令行(Windows按Win+R输cmd),输入python回车,看到类似Python 3.11.x的版本信息,就说明安装成功了。我再多说一句,命令行里输完python之后,再输入exit()退出交互模式,咱们后面要用命令行来装依赖库。
IDE这一块,我建议新手首选VS Code加Python插件,也可以用PyCharm Community版。VS Code更轻量,启动快,装个插件就能写代码和运行脚本;PyCharm对初学者更"大而全",但启动和索引时间会更长。我个人的经验是:如果你只是学Python和爬虫,VS Code完全够用,别在编辑器上花太多时间纠结。
2.2 安装requests、lxml、openpyxl三件套
打开命令行,执行下面这条命令安装运行本项目所需的三个库:
pip install requests lxml openpyxl如果下载速度特别慢或者超时,可以用国内镜像源加速:
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple requests lxml openpyxl这里顺便解释一下镜像源是什么。pip默认从国外官方源下载,而国内网络访问国外服务器偶尔不稳定,清华、阿里这些机构做了同步备份,你从这些备份地址下载会快很多。这不算什么高深操作,但是新手经常会卡在这一步——库装不上,后续代码跑不起来,又不知道去哪找原因。
2.3 验证环境是否真的可用
环境装没装好,不要等跑项目的时候再验证,先用一个三行代码把底探清楚。新建一个Python文件,比如叫test_env.py,写入:
import requests import lxml import openpyxl print("所有库导入成功")在命令行里运行python test_env.py,如果打印出"所有库导入成功",说明环境已经全部就绪。这一步值得每次都做,它能帮你把"代码问题"和"环境问题"先切分开,后面报错时心里有数。
3. 第一层核心:requests请求豆瓣页面的正确姿势
3.1 为什么直接爬会被拒:User-Agent与请求头的必要性
先说一个很多人踩过的坑:直接用requests默认配置去请求豆瓣,大概率会得到一个不太正常的响应,比如403状态码,或者一段提示"检测到异常请求"的页面——豆瓣通过User-Agent就能识别出你不是真实浏览器。
User-Agent是HTTP请求头里的一串文本,用来告诉服务器"我是谁"。requests的默认UA是python-requests/版本号,暴露了这是一个爬虫脚本。破解方法不是去伪造一个假UA硬刚,而是把你的请求头设置得和正常浏览器一样。比如这样:
headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }这个UA就是正常Chrome浏览器会带的信息,豆瓣会把它当作真实用户放行。理解这一点很重要:所谓反爬,大多数情况就是服务器在识别"你是不是真人",而请求头是它最基础的判断依据。我实测下来,光是把UA设置正确,爬取成功率就能提升一大截。
3.2 目标URL是怎么拼出来的
豆瓣影评页面的URL结构有规律:https://movie.douban.com/subject/{电影ID}/comments?start={偏移量}。先解释电影ID是什么——你在豆瓣点开任意一部电影,网址里subject/后面那一串数字就是它的ID,比如《肖申克的救赎》是1292052。
下面是几个关键参数:
- start:从第几条评论开始取,第一页是0,第二页是20,第三页是40,以此类推。这是豆瓣的偏移量分页逻辑,不是页数。
- limit:每页条数,默认20,保持20就好。
- status=P:只看看过这部电影的评论,过滤掉想看和在看。
- sort=new_score:按最新排序,这样能拿到比较新的影评。
把URL拼接和请求封装成一个函数,后面翻页就方便了:
def fetch_comments_page(movie_id, start, headers): url = f"https://movie.douban.com/subject/{movie_id}/comments?start={start}&limit=20&status=P&sort=new_score" resp = requests.get(url, headers=headers, timeout=10) resp.encoding = "utf-8" return resp.text注意resp.encoding这行不能少。豆瓣页面是UTF-8编码,你不手动指定,requests可能会用错误的方式解码,导致HTML里中文变成乱码,后面XPath怎么解析都是空的——很多人卡在这一步,根本不是XPath的问题,是编码没设置。
3.3 判断请求成功的三个信号
拿到返回值后先别急着解析,先确认这次请求是"有效"的。我给新手三个判断信号:
- HTTP状态码是200,不是403或418;
- 返回的HTML文本里有div class="comment-item"这样的节点,说明影评内容真的在页面里;
- 返回文本里没有出现"检测到"或"安全验证"之类的字样。
用代码快速验证一下:
html = fetch_comments_page(1292052, 0, headers) print(resp.status_code) print(len(html)) print("comment-item" in html)如果第三个输出是True,恭喜你,页面请求成功了,可以进入下一环节。如果输出False,先回头检查请求头和编码,再考虑是不是被风控拦了。我强烈建议每个爬虫项目都做这步"请求结果体检",它能把问题提前暴露,比一股脑写完几十行再debug快得多。
4. 第二层核心:用XPath精准定位影评数据
4.1 XPath基础:把网页当成一棵可以精确寻址的树
XPath是这门课的第二个门槛,但你别被"语言"两个字吓到。它本质上就是一套在HTML树里找东西的规则,核心就三个概念:节点路径、条件筛选、属性选取。
- //div 表示从任意位置找所有div节点;
- //div[@class="comment-item"] 表示只要class属性等于comment-item的div;
- .//span 表示从当前节点往下找span,这个点在解析循环里非常重要,代表"以当前位置为起点";
- @href 表示取某个节点的href属性值。
你可以在浏览器里右键点击评论内容,选择"检查",然后按Ctrl+F打开搜索框,输入XPath,比如//div[@class="comment-item"],浏览器会高亮匹配到的节点。这个方法是我最推荐新手用的调试技巧,不需要任何额外工具,能实时验证你的XPath写没写对。
4.2 豆瓣影评页面的结构与XPath推导
豆瓣的影评列表,每个评论的整体结构大致是这样的:一个class为comment-item的div节点,里面包含评论者信息区(comment-info)、评分类节点(allstar开头)、评论时间(comment-time)、评论正文(short或full)、有用数(votes)。
注意"大致"这个词。网站改版是常有的事,所以比起死记下面的XPath,你更需要掌握的是定位思路。以2025年初我实测的情况来看,比较稳定的定位写法如下:
from lxml import etree tree = etree.HTML(html) items = tree.xpath('//div[@class="comment-item"]') for item in items: author = item.xpath('.//span[@class="comment-info"]/a//text()') star_class = item.xpath('.//span[contains(@class, "allstar")]/@class') time = item.xpath('.//span[@class="comment-time"]/@title') votes = item.xpath('.//span[@class="votes"]/text()') short = item.xpath('.//span[@class="short"]/text()')这里有个非常重要的语法细节:XPath里的//和./. 我们在item上继续向下找,必须用 .// 而不是 //。.//表示从当前评论节点往下搜索,//则表示从整个文档根节点搜索。如果写错成//span,就会把所有评论的span全部混在一起,提取结果乱套。
4.3 text()的常见误用:为什么有的text()取不到内容
XPath里的text()是新手掉坑的重灾区。它的准确含义是"取当前节点的直接文本子节点"。所以如果HTML结构长这样:
<span class="comment-info"> <a>张三</a> </span>你写 .//span[@class="comment-info"]/text() 是取不到"张三"的,因为张三在a标签里面,不在span的直接文本里。正确写法是 .//span[@class="comment-info"]/a/text() 或者更宽容一点,用 .//span[@class="comment-info"]//text()。
我建议新手统一用"先定位到最细的标签,再取text()"这个思路,尽最大可能避免多层嵌套带来的问题。比如评论者昵称就定位到a标签,评论时间就定位到comment-time的title属性,这样每一步都清晰可查。
4.4 评分、展开评论和空值的细节处理
评分字段比较特殊,它不是文本内容,而是藏在class属性里,比如class里有allstar40,就代表4星。我们提取到class字符串后,用正则把数字抠出来:
import re star_text = "".join(star_class) match = re.search(r"allstar(\d+)", star_text) star = str(int(match.group(1)) // 10) if match else ""正则在这里只干一件事:从allstar40里提取出40,再除以10变成4。这算是本项目里唯一用到的正则,如果你完全不想用正则,也可以直接判断allstar后面的数字再切片,不过正则一行写完更简洁。
评论正文还有一个坑:部分长评论在列表页默认只显示摘要,完整内容在点击"展开"后才能看到,体现在HTML里就是full类节点。所以提取正文时要做兜底——优先取short,如果short为空,再尝试取full:
if short: content = "".join(short).strip() else: full = item.xpath('.//span[@class="full"]//text()') content = "".join(full).strip()顺便说一句,整页评论里偶尔会混入几条只有"有用数"但没有评分的评论,提取结果为空很正常,后面清洗时统一补默认值就行,不用为这个纠结。
5. 数据清洗与写入Excel:让结果能直接打开看
5.1 清洗的三件事:去空白、去重复、补缺失
很多新手把HTML解析完就算完事了,直接拿去写Excel,结果打开一看,评论内容里全是换行和空格,还有重复行,观感很差。清洗这步花不了几行代码,但决定文件质量。
我的做法是先把每条评论整理成一个字典,收集到一个列表里:
all_comments = [] # 在解析循环里,把字段填入data_dict data = { "作者": "".join(author).strip() if author else "匿名", "评分": star, "时间": time[0] if time else "", "有用数": votes[0].strip() if votes else "0", "内容": content, } all_comments.append(data)字段少、格式统一,后面写Excel就变成简单的循环逐行写入。这里我顺手处理两件事:一是内容里的换行符替换成空格,避免Excel单元格自动换行导致行高混乱;二是基于"作者+内容"这个组合键去重,防止翻页时评论重复出现。
seen = set() cleaned = [] for item in all_comments: key = (item["作者"], item["内容"]) if key not in seen: seen.add(key) cleaned.append(item)5.2 openpyxl写入:工作簿、表头、循环写行
openpyxl的用法非常直接。先创建工作簿,获取活动工作表,用append逐行写入,最后保存:
from openpyxl import Workbook from openpyxl.styles import Font, Alignment from openpyxl.utils import get_column_letter wb = Workbook() ws = wb.active ws.title = "豆瓣影评" headers = ["作者", "评分", "时间", "有用数", "内容"] ws.append(headers) for d in cleaned: ws.append([d["作者"], d["评分"], d["时间"], d["有用数"], d["内容"]]) wb.save("豆瓣影评.xlsx")见过不少人不用append,而是先ws.cell(row=1, column=1)去设置每个单元格,那样代码冗余、容易出错。append每次写入一行,完美契合我们"列表嵌套字典"的数据结构。
5.3 样式与打开兼容性:别让Excel打不开或者看不清
如果只是把数据存进去,上面那段代码已经够了。但作为"拿得出手"的成果,我建议再加三点润色:
# 表头加粗、居中 for cell in ws[1]: cell.font = Font(bold=True) cell.alignment = Alignment(horizontal="center") # 根据内容长度设置列宽 widths = [12, 8, 20, 10, 60] for i, w in enumerate(widths, start=1): ws.column_dimensions[get_column_letter(i)].width = w # 冻结首行,方便滚动查看 ws.freeze_panes = "A2"这个步骤不是必须的,但加上以后,Excel文件打开的第一眼观感完全不同。另外有一个非常常见的坑:如果电脑上已经打开了这个Excel文件,脚本再次保存会报PermissionError。这不是你代码写错了,而是文件被占用。我通常在保存前先os.path.exists检查文件是否存在,如果存在就让用户先关掉Excel,或者在文件名里加上当前时间戳生成新文件:
from datetime import datetime filename = f"豆瓣影评_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" wb.save(filename)加了时间戳以后,每次运行生成的文件名都不一样,彻底避开文件占用问题,也方便多次爬取留档对比。
6. 实测反馈与合规边界:关于反爬拦截的几点现实提醒
6.1 我实测中遇到的正常与异常情况
我按每页间隔3秒左右,从首屏一直翻到第10页,整个过程比较顺利,偶尔有一两次返回提醒"检测到异常请求",遇到这种情况我的处理方式是立即停止当前循环,等30秒左右再续跑,而不是换个UA硬刚或者去折腾各种绕过手段。
从实测结果看,保持合理频率后,豆瓣对低频、少量的爬取是有容忍空间的。但你千万别把"有容忍空间"理解成"随便爬"。如果你用一个循环把start从0爬到几万,全是短短几毫秒内发起请求,那没有哪个网站能扛住,被拦截是必然的。无论从技术上还是从礼貌上,限制请求频率都应该是爬虫脚本的标配。我在翻页循环里一定会写上time.sleep(3),这个sleep不是给你看的装饰,而是给双方服务器都留出喘息空间。
6.2 什么样的用法算合规:尊重规则、控制规模和用途
这一部分我多说几句。很多人学爬虫的第一步就是拿豆瓣开刀,因为它结构清晰、公开可见,适合当教学案例。但"公开可见"不等于"可以无限抓取"。我的建议是:第一,控制规模,个人学习使用抓几十页以内足够,不要有抓完整个网站的想法;第二,控制频率,任何请求间隔至少1秒以上,合理做法是2到3秒;第三,限定用途,拿到的数据只用于个人学习、本地演示、数据格式练习,不用于商业用途,也不对外发布或转售。
如果你用requests学明白了request的写法,也不是说就万事大吉。很多网站的数据不是直接包在HTML里的,而是前端异步请求JSON接口,你需要打开浏览器开发者工具的Network面板去观察真实的数据请求。这个技能和你学XPath一样,本质上都是"看网页结构、找数据规律",是同一个分析思维方式在不同场景下的延伸。
6.3 从"复制代码"到"会拆新网站":进阶的关键一步
最后分享一个我自己的体会:这个项目做完,你能得到的最大收获不是一段能跑的代码,而是拆解陌生网页的能力——面对任何网站,你不慌,而是会分析:请求发到哪个URL、数据藏在哪个标签、翻页的规律是什么、落地用什么格式。这个流程才是真正的通用技能。
如果你还想往深走一步,可以尝试自己改改需求,比如换一部电影、多爬几个维度的字段(评论者主页、IP属地等),或者把数据导入到pandas里做排序和统计。这些扩展都是在同一个框架里加内容,操作起来没有本质难度。我个人最喜欢拿这个项目做压箱底的练习,每过一段时间重写一遍,因为每次都能发现自己对请求、解析、存储这三层有了新的理解。
说到底,爬虫不是一件神秘的事,它就是有节制、有边界地利用公开数据。你把这个思路和内功练好,后面再学什么框架都是顺水推舟。