☰
Python爬虫实战:抓取豆瓣Top100电影并保存CSV
2026/10/12 14:42:24 网站建设 项目流程

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

1.1 为什么选豆瓣Top100当练手项目

用 Python 爬豆瓣 Top100 电影名单存成 CSV,是我接触过的最适合入门网络爬虫的实战项目之一。很多刚开始学爬虫的朋友,第一个能完整跑完的项目往往就是它:页面数据是服务端直接渲染好的静态 HTML,不需要分析接口的加密参数,不依赖登录态,只要会用浏览器“查看源代码”就能看到完整数据。这就把难点集中在爬虫最基础的三个动作上:发请求、解析内容、保存结果。

标题里写的是 Top100,本质上是豆瓣 Top250 列表的前 100 条。Top250 列表分 10 页展示,每页 25 条,4 页正好凑满 100。这个地方值得单独说一下:很多人在网上抄代码时没有意识到“页码”和“start 参数”的关系,爬来爬去都是第一页的数据,输出的 CSV 只有 25 行。理解了 Top100 = Top250 前 4 页之后,后面想扩展到 Top250,只需要把循环范围从 100 改成 250,一行代码的事,但你没搞懂原理就改不动。

另外,这个项目是典型的“投入产出比极高”的练手项目。写一遍大概 100 行代码,但这 100 行里覆盖了网页结构分析、请求头设置、数据清洗、文件编码处理等一堆实用技能。很多人在课程里学完语法之后不知道怎么落地,卡住的原因往往不是语法不会,而是“不知道从哪一步开始想”。这个项目能帮你把零散的知识点串成完整的链路。

1.2 技术选型背后的几个理由

既然要爬静态页面,技术栈无非就是那几样。我推荐的是 requests + BeautifulSoup + Python 内置的 csv 库。这套组合可以说是爬虫界的街车,丑但稳,人人都看得懂,出了问题也特别好排查。

requests 为什么比 urllib 好用?直接说结论:requests 对 Cookie、请求头、编码的处理比 urllib 人性化太多。urllib 是 Python 标准库自带的不假,但用它可以写,就是每次要处理Request对象、urlopen返回的响应对象还要手动read().decode(),多了一层无意义的样板代码。requests 直接一句requests.get(url, headers=headers)就能拿到内容,响应对象的.text属性还会自动根据响应头推断编码,对新手来说少踩一半的坑。

BeautifulSoup 则解决了“如何在 HTML 里定位目标数据”这个问题。对比一下:用正则表达式去匹配电影信息,写出来的 pattern 又长又脆,页面稍微多个空格就匹配失败;用 BeautifulSoup 的话,本质上是“给你一段 HTML 文档,你告诉我里面哪个节点是什么”,API 设计得符合直觉。你可以在代码里把文档结构打印出来,肉眼确认目标数据落在哪个标签里,再写选择器,这种交互方式对学习阶段特别友好。

至于存储格式,有人说为什么不直接存 Excel,或者干脆用 pandas?我的看法是:CSV 是纯文本格式,任何文本编辑器都能打开,数据可以被 Excel、Numbers、pandas、SQLite 无痛地继续加工,是跨工具兼容性最好的通用格式。用 Python 内置的 csv 模块,不引入额外依赖,这让学生能把注意力放在“爬虫本身”而不是“为了存数据还要再学一个 pandas”。

1.3 整体流程拆解

把整个项目拆开看,其实就是一根线串了四件事:

  1. 分析豆瓣 Top250 的页面 URL 规律和 HTML 结构。
  2. 用 requests 模拟浏览器发起 HTTP 请求,拿到 HTML 文本。
  3. 用 BeautifulSoup 解析 HTML,按字段提取电影信息。
  4. 用 csv 模块把数据写入本地文件,并把编码设置正确。

这四步每一环都有坑。第一步的坑是 URL 参数没搞明白导致翻不了页;第二步的坑是请求头没带全被服务器拒绝,或者没显式指定编码导致中文乱码;第三步的坑是 HTML 结构判断失误,提取出来的是空数据;第四步的坑就更多了,最典型的就是 CSV 用 Excel 打开后中文全部变成乱码。

把项目划分为这四个阶段还有一个好处:当程序报错时,你可以准确判断“你现在卡在哪一环”,而不是在整套代码里毫无头绪地打转。下面我按照这个流程把每一环节的关键细节都过一遍,并给出可直接运行的完整代码。

2. 核心细节解析与实操要点

2.1 目标页面结构与字段分析

先看目标地址。豆瓣 Top250 的页面地址和翻页逻辑非常规整,首页是:

https://movie.douban.com/top250

第二页以及后续页面的地址是这个结构:

https://movie.douban.com/top250?start=25&filter=

这里的start是“本页第一条电影在完整列表中的偏移量”。第一页相当于start=0,第二页是start=25,第三页是start=50。每页展示 25 条,所以 Top100 就是取start从 0 到 75 的 4 个页面。这里的filter参数有没有实际作用其实不重要,保留它只是为了和浏览器的请求保持一致。

接着看页面里的数据结构。定位到单条电影信息所在的区块,你会看到一个class为item的div,每个div代表一部电影。电影的信息分布大致如下:

  • 电影标题在item下的.hd区块中,class为title的链接文本。注意这地方有中英文两个标题,实际取第 0 个标题,也就是中文名。
  • 评分在class="rating_num"的span标签中。
  • 评价人数在.star区块下的第 4 个span中,文本类似“1,235,456人评价”,后续需要清洗掉中文部分。
  • 电影排名则直接体现在item下的em标签里,从 1 排到 25,每页重新计数,所以存储时要用“当前页偏移量 + 排名”得到真正的 Top 排名。
  • 底部那句电影简介或经典台词放在class="quote"的span内,这一项可能为空。

这里强调一句:豆瓣页面的 HTML 结构并不是永恒不变的,类名的调整时有发生。所以正式写代码之前,先打开页面按下 F12,在开发者工具里确认一下上述选择器是否依然存在。我见过不少人从旧文章里抄了代码没跑通,第一反应是怀疑库装错了,其实大概率是页面结构微调了。养成“先查看,再匹配”的习惯,比记死一套选择器重要得多。

2.2 请求头与访问频率控制

爬虫请求被服务器拒绝,是新手最容易碰到的第一个“硬墙”。直接写一句requests.get("https://movie.douban.com/top250")大概率会得到一个 418 或者 403 状态码——这里的 418 就是从反爬机制返回来的,语义是“你是机器人,我不给你”。豆瓣会通过请求头中的User-Agent等字段判断访问者是不是浏览器。

因此请求头至少要带上User-Agent。最省事的做法是从浏览器复制一段当前版本的信息,比如:

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": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }

我不建议一上来就把所有请求头都复制粘贴一遍。多数情况下带个User-Agent就够用,保留精简的请求头还能减少被识别的概率——请求头太“满”反而容易露出马脚。

比请求头更关键的是访问频率。豆瓣的反爬并不是一次性拒绝你的请求,而是基于“单位时间内请求次数”做限制。初学者最容易犯的错误是写个for循环连续请求 10 页,中间不休息,结果第 3 页开始就被拦截。解决办法就是显式地在每页请求之间加延时。time.sleep(1)到time.sleep(3)之间都可以,宁肯慢一点,保证能拿到完整数据。另外,如果请求返回了包含“检测到有异常请求”字样的页面,说明你访问太频繁了,停个几分钟再继续。

这里再多提一句:看到请求被拒绝时,不要第一反应去查“怎么换代理”。正规的练手项目,遵守目标网站的访问规则本身就是爬虫开发者的基本素养。把频率控制好,加足延时,这个项目根本不需要什么特殊手段。通过设置合理的请求间隔来完成数据采集,才是长久之计。

2.3 CSV字段设计与文本清洗

想清楚“CSV 里存哪些列”,其实比写代码更值得花时间。Top100 电影数据,建议至少包含这几列:

字段含义示例
rank总排名1
title电影中文名肖申克的救赎
rating豆瓣评分9.7
comment_num评价人数2,612,358人评价
quote经典台词希望让人自由。

这样的字段设计既覆盖了“能证明数据有意义”的核心信息,又不会因为字段过多导致解析难度陡增。电影导演、主演、类型等信息也能抓到,但它们的解析逻辑更复杂,通常分布在class="bd"区域的不同标签中,加进来会让代码量翻倍。用一个练手项目先把难度控制住,等你把主流程走通了,再回来扩展字段也不迟。

文本清洗是另一个容易被忽略的环节。直接从 HTML 里拿出来的文本,常常带着多余空格、换行符,甚至夹杂着页面里的标签噪声。在代码里统一加上get_text(strip=True)只保留可见文本并去掉首尾空白,是一个实用的小技巧。评价人数“2,612,358人评价”这种字符串,如果后续想用来排序,通常需要把非数字字符清洗掉,只保留数字部分。排名数字也要注意:页面里的排名是每页从 1 到 25 的局部值,保存时要加上当前页偏移量,才能得到 Top100 里的真实排名。

清洗这块不做到位,后面读数据做分析时会有各种隐性问题。比如排名列混入了换行符、评分列解析成字符串而不是小数、引用台词里的引号破坏了 CSV 的列结构——一步没做好,后续成本都会放大。

3. 实操过程与核心环节实现

3.1 完整可运行的参考代码

基于上面的分析,我给出一个完整版本。这段代码是经过多次测试的“稳”版本,核心逻辑很直白,适合复制下来一点一点读懂,也可以直接跑通看到效果。

import time import csv import requests from bs4 import BeautifulSoup BASE_URL = "https://movie.douban.com/top250" 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;q=0.9,en;q=0.8", } def fetch_page(start): """请求当前偏移量的页面,返回 HTML 文本。""" params = {"start": start, "filter": ""} response = requests.get(BASE_URL, headers=HEADERS, params=params, timeout=10) response.encoding = "utf-8" response.raise_for_status() return response.text def parse_page(html, start): """从 HTML 中解析出当前页的电影信息列表。""" soup = BeautifulSoup(html, "lxml") movies = [] for item in soup.select(".item"): title = item.select_one(".title") rating = item.select_one(".rating_num") star_spans = item.select_one(".star").find_all("span") quote = item.select_one(".quote span") # 清洗:标题里中英文并存,只保留第一个 movie_title = title.get_text(strip=True) if title else "" # 去重:HTML 中可能把中文和英文标题合并显示 movie_title = movie_title.split("/")[0].strip() movie_rating = rating.get_text(strip=True) if rating else "" comment_num = star_spans[-1].get_text(strip=True) if star_spans else "" movie_quote = quote.get_text(strip=True) if quote else "" rank = item.select_one("em").get_text(strip=True) # 加上偏移量转为全榜排名 real_rank = start + int(rank) movies.append({ "rank": real_rank, "title": movie_title, "rating": movie_rating, "comment_num": comment_num, "quote": movie_quote, }) return movies def save_to_csv(movies, filename="douban_top100.csv"): """将电影数据写入 CSV 文件,使用 utf-8-sig 编码防止乱码。""" with open(filename, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["rank", "title", "rating", "comment_num", "quote"]) writer.writeheader() writer.writerows(movies) def main(): all_movies = [] # 每页25条,取前100条 = 4页 for start in range(0, 100, 25): print(f"正在抓取 start={start} 的页面...") html = fetch_page(start) page_movies = parse_page(html, start) all_movies.extend(page_movies) # 控制访问频率,避免触发反爬 time.sleep(2) save_to_csv(all_movies) print(f"抓取完成,共保存 {len(all_movies)} 条数据到 douban_top100.csv") print("前3条数据预览:") for movie in all_movies[:3]: print(movie) if __name__ == "__main__": main()

这段代码的大致路径是:主函数循环请求 4 个页码,每页解析出 25 条电影数据,列表汇总后一次性写入 CSV。代码里用到了lxml作为 BeautifulSoup 的解析器,如果没有安装会报错,运行前记得执行pip install lxml。

3.2 重要代码分步解读

先看fetch_page。这里我把请求方式写成了response.encoding = "utf-8"。为什么需要这一行?因为 requests 会根据响应头去推断页面编码,但豆瓣页面的响应头有时并不会显式给出charset,requests 可能会错误地猜测成ISO-8859-1之类的编码,导致后面解析时中文乱码。手动指定为 utf-8 是从源头解决问题,比事后清洗要省心得多。raise_for_status()的作用是当服务器返回 4xx 或 5xx 状态码时直接抛出异常,避免拿到错误页面还继续解析。

再看parse_page。soup.select(".item")返回当前页所有电影条目,后面每条都用select_one去找子元素。这里使用.select_one而不是find,是因为 CSS 选择器的表达更紧凑。标题多行时如果get_text(strip=True)直接拼接,中文和英文名会用/分隔,因此我加了split("/")[0]只保留第一个名字。这个处理方式不一定对所有页面版本通用,如果输出标题异常,可以把这个逻辑去掉再看看。

在解析评价人数时,star_spans[-1]取的是.star区块的最后一个span,正常情况下内容就是“xxx人评价”。如果 HTML 结构变了导致这个元素不存在,取[-1]会直接抛 IndexError。稳妥的办法是加一个长度判断。这里不做过多防御,是为了保持代码好读,但你心里要有数。

save_to_csv里最关键的两处:newline=""和encoding="utf-8-sig"。newline=""是为了避免在 Windows 上写入 CSV 时每两行之间多出一个空行;utf-8-sig则是乱码解决方案的核心,后面我会专门用一个章节详细解释。

3.3 运行结果与 CSV 内容预览

代码跑完后,控制台会打印出类似于下面前 3 条的数据:

{'rank': 1, 'title': '肖申克的救赎', 'rating': '9.7', 'comment_num': '2,612,358人评价', 'quote': '希望让人自由。'} {'rank': 2, 'title': '霸王别姬', 'rating': '9.6', 'comment_num': '2,118,662人评价', 'quote': '风华绝代。'} {'rank': 3, 'title': '阿甘正传', 'rating': '9.7', 'comment_num': '2,345,981人评价', 'quote': '生活就像一盒巧克力。'}

对应的 CSV 文件内容,用 Excel 正确打开后会是这样的表格:

ranktitleratingcomment_numquote
1肖申克的救赎9.72,612,358人评价希望让人自由。
2霸王别姬9.62,118,662人评价风华绝代。
3阿甘正传9.72,345,981人评价生活就像一盒巧克力。

值得说明的是,以上三条数据只是示意,实际运行时以当时页面的实时数据为准。豆瓣评分和评价人数会随着时间变化,CSV 文件的内容也会随之更新,这本来就是爬虫项目的一个特点——你抓的是“那一刻”的数据。

3.4 从 Top100 扩展到 Top250 的参数调整

如果你跑通了 Top100,再想扩展成 Top250 其实只需要改main函数里的一处循环范围:

# 改前:只取前100条,4页 for start in range(0, 100, 25): # 改后:取全部250条,10页 for start in range(0, 250, 25):

同时把time.sleep(2)的延时稍微加长到 3 秒左右,因为页面数量变多,访问频率更高,被抓的风险也更高。其他逻辑一概不用动。这就是把“翻页逻辑”抽象成start参数的好处,改动面被控制在最小范围。

如果你还想把每条电影的详情页链接也保存下来,可以在parse_page里从.hd a标签的href属性取值,然后加一个字段。这一步的代码改动也不大,但它会让 CSV 的意义上一个台阶——拿到链接,后续就能继续爬取每部电影的更多资料。

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

4.1 CSV 乱码的根源:编码不一致

CSV 本质上是纯文本文件,而文本文件在计算机里保存的是字节序列。同一个“肖申克的救赎”这串字符,用 UTF-8 编码保存的字节和用 GBK 编码保存的字节完全不同。Excel 打开 CSV 时,默认按系统本地语言对应的编码去解码——在中文 Windows 上,这就是 GBK(准确说是 GB2312 的扩展,系统代码页 936)。如果你的 CSV 文件是用 UTF-8 写入的而 Excel 用 GBK 去读,两者对不上,屏幕上自然就是“铏蹭紕鏂囩珷”这类鬼画符。

所以乱码根本不是“文件损坏了”,而是“写文件用的编码”和“读文件用的编码”不一致。理解这一点之后,解决方案无非两个方向:要么让写入的编码与 Excel 默认读取的编码一致,要么让 Excel 告诉它改用什么编码去读。下面三种方案都是围绕这两条思路展开的。

4.2 方案一:写入时使用 UTF-8-SIG 编码

最推荐的做法,是写入 CSV 时把编码从常见的utf-8改成utf-8-sig。差一个-sig后缀,效果完全不同:utf-8是一种无 BOM 的编码形式;utf-8-sig会在文件最开头写入一个字节顺序标记(BOM),也就是EF BB BF这三个字节。Excel 检查到 BOM 时,就能据此判断“这个文件是 UTF-8 编码”,自动切换解码方式,中文正常显示。

在代码里就是改动save_to_csv的 open 参数:

# 乱码写法 with open(filename, "w", newline="", encoding="utf-8") as f: # 不乱码的写法 with open(filename, "w", newline="", encoding="utf-8-sig") as f:

这也是我写在完整代码里的方案。说实话,多数人和我一样,第一次打开 CSV 看到乱码后,网上搜教程会得到“用 utf-8-sig 存就好了”的答案,但如果不明白 BOM 的作用,下次换个场景又会懵。记住一个判断原则:如果你的 CSV 文件主要是给中文 Windows 用户用 Excel 打开的,优先utf-8-sig;如果你的文件是给其他程序员写代码读取的,可以不用 BOM,但从项目兼容性角度,带 BOM 才能同时照顾到两边。

4.3 方案二:改用 GBK 编码写入

第二种思路是让写入编码直接匹配 Excel 的默认编码,也就是用 GBK 写文件:

with open(filename, "w", newline="", encoding="gbk") as f:

这样写出来的文件,Excel 双击打开默认就是正常的,不需要任何额外操作。这个方法胜在“彻底不需要用户操作”,但我通常不推荐作为首选,原因有两点。

第一,GBK 是中文环境下的编码,它并不包含世界上所有的字符。电影标题里如果出现类似“嘅”“瀞”等生僻汉字,编码时可能会抛出UnicodeEncodeError异常,导致写文件中途失败。第二,这个文件拿到非中文系统或非中文版本的 Excel 上,又会因为编码不匹配出现乱码,兼容性更差。相比之下,utf-8-sig在跨平台共用方面明显更稳妥。

顺带提一句:如果你用 pandas 来写 CSV,同样要指定编码:

df.to_csv("douban_top100.csv", index=False, encoding="utf-8-sig")

同一个原理,只是换了 API。

4.4 方案三:文件已生成后,用 Excel 手工导入

如果你的 CSV 文件已经写成了无 BOM 的 UTF-8,或者你在别人那里收到一个乱码 CSV,文件已经生成,不可能再回头改代码,那就直接在 Excel 里操作:

  1. 打开一个空白 Excel。
  2. 点击“数据”选项卡 → “自文本/CSV”入口。
  3. 选择目标 CSV 文件。
  4. 在弹出的导入向导里,把“文件原始编码”选为“65001: Unicode (UTF-8)”。
  5. 确认分隔符是逗号,点击加载。

这样就算文件本身没有 BOM,Excel 也会按 UTF-8 去读,乱码自然消失。这个方法同样适用于你已经关闭的、由别人发来的 UTF-8 文件,是一招“事后急救”。

4.5 其他爬虫与数据过程中的常见问题

除了乱码,整个项目里我被人问得最多的问题还有下面几个,每个都附上排查思路。

第一个是请求被拒,返回状态码 418 或 403。排查顺序:先看请求头里有没有User-Agent;再看循环里的time.sleep延时是否足够;最后确认是不是短时间内跑太多次程序。如果连续触发,就先歇几分钟再继续。请求头伪装到位、频率控制好,正常采集 Top100 不会有什么问题。

第二个是解析结果全是空列表,程序不报错但 CSV 里只有表头。这种情况九成是 HTML 结构与你写选择器时不一致,最简单的方法是先把html保存成文件,用文本编辑器打开,搜索class="item"看看还在不在。不要在程序里猜,直接肉眼确认数据在哪,再反向改代码。

第三个是 CSV 里出现莫名其妙的换行,每两行数据中间有个空行。这是open时没有指定newline=""造成的,Windows 环境下 csv 模块写入时会发生额外的换行符转换。解决方案就是在open里加上newline="",我已写在示例代码中。

第四个是排名是每页重新从 1 开始的,合并后会出现多个排名为 1、2、3 的条目。解决办法在解析时加上偏移量:real_rank = start + int(rank),也就是示例代码里的做法。

第五个是评价人数带“人评价”三个字,后续想转成纯数字做排序就不可能了。解决方法是正则替换掉非数字字符,比如re.sub(r"\D", "", comment_num),如果需要再保留单位可以组合处理。

还有一个容易踩的坑:用 Excel 打开 CSV 后,会发现某些字段的首尾空格被自动剪掉,或者带逗号的字段被强制加上引号。这些其实是 Excel 显示 CSV 的正常行为,不必惊慌,具体的存储格式以文本编辑器打开为准。

我在实际跑这个项目的过程中,最深的一个体会是:爬虫代码本身不难,难的是“代码写完之后,数据的价值有没有被正确释放”。一次 Top100 的抓取,从 URL 分析到存储编码,每一步都有人在同一个坑里跌倒。如果你把这份代码跑通了,乱码也解决了,我更建议你顺手做两件事:第一,把抓下来的 CSV 丢到 pandas 里算一算 Top100 评分的均值和分布;第二,试着把字段扩展到电影年份和导演,当你能自由地改字段、调翻页、自适应页面变化时,这个入门项目就算真正吃透了。

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

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

立即咨询