ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

豆瓣Top100爬虫实战:从请求到CSV乱码解决的完整复盘

豆瓣Top100爬虫实战:从请求到CSV乱码解决的完整复盘

标题起得挺直白,但真正动手之后才发现,这项目其实分两关:第一关是把豆瓣Top100电影的数据抓下来,第二关是把它存成Excel能正常打开的CSV文件。第二关看着简单,实际上最折磨人,我第一次做的时候卡在编码问题上差不多两个小时,网上搜到的答案千篇一律地告诉你“加BOM”,但没人讲清楚为什么加了就不乱码、不加就乱码。

这篇就当作一次完整复盘。我会把请求怎么写、页面怎么解析、CSV怎么落盘、乱码怎么彻底解决都讲一遍,重点是每一步背后的原因,而不是丢一段能跑就算完。适合刚学爬虫的读者,也适合被CSV乱码坑过但一直没搞懂原理的同学。

1. 项目发起与数据源分析

1.1 为什么选择豆瓣Top100做练手

爬虫项目说多不多,说少不少,但豆瓣电影榜单几乎是所有初学者的必经之路。原因很简单:页面结构清晰,字段语义明确,数据量不大不小,既不会因为解析逻辑太复杂劝退新手,又足够让你练到请求、解析、存储、编码这一整条流水线。

Top100这个范围也很好用。豆瓣官方榜单是Top250,一次展示25条,一共10页。取Top100意味着只需要抓前4页,总共100条记录,请求次数少,分析压力小,跑一次也就几秒钟的事。相比动辄几千条的电商数据,这个体量适合用来做小项目,不会因为反爬策略太强而打击信心。

不少人在这一步会犯一个毛病:一上来就想着“做大”,要抓全部250条,要抓所有评论,要做数据分析可视化。结果请求频率一高,访问一会收到403错误,项目直接卡死在起步阶段。我的建议是,先定一个明确的小目标——比如“只抓Top100,只存4个字段,保证CSV不乱码”——跑通之后再谈扩展。小目标跑通了,大目标只是时间问题。

1.2 数据源页面怎么选

这里有个容易混淆的地方:搜索“豆瓣Top100”,其实没有单独一个叫Top100的榜单页面。比较稳妥的做法,是拿Top250页面作为数据源,解析前100条记录。

Top250的URL规律非常规整:

第一页:https://movie.douban.com/top250?start=0&filter= 第二页:https://movie.douban.com/top250?start=25&filter= 第三页:https://movie.douban.com/top250?start=50&filter= 第四页:https://movie.douban.com/top250?start=75&filter=

start参数每次增加25,代表当前页第一条在总榜单里的偏移量。这个规律对翻页抓取极其友好,不用去解析下一页的链接,直接用循环拼URL就行。

另外,filter=这个参数在页面上是用来筛选语言类型的,留空表示不过滤,可以原样保留。有的教程里会省略它,实测不影响结果,但保留更接近浏览器的真实请求。

1.3 目标字段与文件形式确定

动手前先想清楚要存什么。榜单页面上每部电影可以拿到的字段包括:

  • 排名
  • 中文片名
  • 外文片名
  • 导演和主演信息
  • 上映年份和地区类型
  • 评分
  • 评价人数
  • 一句话短评(如果存在)

做一个小项目不需要贪多,我建议先存四个核心字段:排名、中文片名、评分、评价人数。这四个字段既稳定又有代表性,做后续可视化也够用。导演、演员、年份、类型这些可以放到扩展阶段再做,它们的解析逻辑更复杂,有些电影的外文片名还不止一个,初次做容易把代码绕进去。

文件形式就选CSV。CSV本质上是纯文本表格,可以用记事本打开,可以用Excel打开,也可以直接被pandas读取,是数据交换的“万金油”格式。唯一的痛点就是编码问题,这也是本篇后半部分要重点解决的。

2. 环境准备与请求设置

2.1 依赖库怎么装

核心依赖只有两个:requests和beautifulsoup4。

pip install requests beautifulsoup4

如果后面想用pandas的to_csv做对比验证,再加一个:

pip install pandas

Python版本用3.8以上都行,实测3.8到3.12都没问题。解析器建议指定html.parser,因为BeautifulSoup自带的这个解析器不需要额外编译,兼容性最好。也可以用lxml,解析速度更快,但需要单独安装,对新手反而多一层门槛。

提示:如果安装很慢,可以换国内镜像源,比如清华源或阿里源,命令形如pip install requests -i https://pypi.tuna.tsinghua.edu.cn/simple。这是我实测最省事的方案。

2.2 headers设置与robots边界

豆瓣对爬虫并不算特别友好,但也没有凶到完全不让访问。第一次发请求如果没有带User-Agent,服务器大概率直接返回403,页面根本拿不到。

我习惯把这些请求头放在一个字典里,统一管理:

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别直接抄一个很老旧的版本,尽量用当前主流浏览器的UA,这会明显降低被拒概率。Accept-Language设置成zh-CN还能保证返回页面里的评分数字、中文标签解析正常,虽然影响不大,但能让解析更稳定。

还有一个容易被忽略的点:robots.txt。爬虫新手往往不知道要看这个文件。豆瓣的robots.txt里对部分路径有限制,公开榜单页面理论上是可以访问的,但任何爬虫都应该遵守基本的网络礼仪——低频、慢速、不影响服务器正常运行。说白了,抓25条数据休息两三秒,和抓250条数据一口气冲过去,给服务器的压力完全不同,后者也更容易触发反爬。

2.3 翻页请求与首次验证

写一个函数负责请求单页,注释写清楚每个参数的用意:

import requests 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;q=0.9,en;q=0.8", } BASE_URL = "https://movie.douban.com/top250" def fetch_page(start): """请求Top250页面的某一页,start为偏移量,0, 25, 50, 75""" url = f"{BASE_URL}?start={start}&filter=" resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() resp.encoding = "utf-8" return resp.text if __name__ == "__main__": html = fetch_page(0) print(len(html), html[:200])

resp.encoding = "utf-8"这一行值得注意。豆瓣返回的响应头里有charset声明,requests通常能自动识别,但偶尔会因为重定向或压缩导致误判。手动指定是最稳妥的,既保证了解析时中文不乱码,也为后面写入文件铺平了路。

跑一次如果能看到一大段HTML输出,说明请求已经通了。此时别急着往下写代码,先在浏览器里打开同一页面,按F12看下结构,对下一步解析会非常有帮助。

3. 页面解析:从HTML到结构化记录

3.1 单个电影节点的结构拆解

拿到HTML后,接下来要回答的问题是:一部电影的数据在页面里长什么样?

打开开发者工具,定位到榜单列表,会发现每部电影都包裹在<div class="item">这个节点里。以排名第一的电影为例,结构大致如下:

<div class="item"> <em class="">1</em> <span class="title">肖申克的救赎</span> <span class="title">/The Shawshank Redemption</span> <span class="other">&nbsp;/&nbsp;...&nbsp;/&nbsp;...</span> <div class="bd"> <div class="star"> <span class="rating5-t"></span> <span class="rating_num">9.7</span> <span>1426286人评价</span> </div> <p class="quote"> <span class="inq">希望让人自由。</span> </p> </div> </div>

这里有几个容易踩坑的细节:

  • 排名在em标签里,直接.text就能拿到。
  • 中文片名在第一个span.title里,注意同名的class出现两次,只能用.select_one("span.title")取第一个。
  • 外文片名在第二个span.title里,但有些电影没有外文名,取不到时要处理None。
  • 评分在span.rating_num里。
  • 评价人数在div.star下面的最后一个span里,文本是"1426286人评价"这种格式,需要提取数字部分。
  • 一句短评在span.inq里,但很多电影没有短评,缺省时必须做兼容。

3.2 解析函数实现

用BeautifulSoup写解析逻辑,我这里给出一个稳妥的写法:

from bs4 import BeautifulSoup def parse_page(html): """解析Top250页面的HTML,提取电影排名、片名、评分、评价人数""" soup = BeautifulSoup(html, "html.parser") items = soup.select("div.item") records = [] for item in items: rank = item.select_one("em").text.strip() title = item.select_one("span.title").text.strip() rating = item.select_one("span.rating_num").text.strip() # 评价人数形如 "1426286人评价",需要去掉括号和单位 star = item.select_one("div.star") comment_span = star.find_all("span")[-1] comment_text = comment_span.text.strip().replace("人评价", "") records.append({ "rank": int(rank), "title": title, "rating": float(rating), "comments": int(comment_text), }) return records

comment_text的处理我用的是replace("人评价", "")。在初次跑通阶段,这个方案最直接,只要页面格式不变化就不会出问题。如果想写得更健壮,可以用正则re.search(r"\d+", comment_span.text),只提取数字,这样即使以后页面文字从"人评价"变成"人看过"也能兼容。

至于短评字段,我故意没放进核心字段,因为Top100里大概有十几部电影没有短评。如果一开始就把它加进去,解析逻辑里要多写一个if inq else ""的分支,会分散注意力。扩展阶段再加也不迟。

3.3 翻页循环与数据汇总

前4页每页25条,正好是100条。循环请求时别忘了控制节奏:

def crawl_top100(): all_records = [] for start in range(0, 100, 25): try: html = fetch_page(start) records = parse_page(html) all_records.extend(records) print(f"已抓取 offset={start}, 本页 {len(records)} 条, 累计 {len(all_records)} 条") except Exception as e: print(f"offset={start} 请求失败: {e}") finally: time.sleep(2) # 低频请求,既减少服务器压力,也降低被识别的概率 return all_records[:100]

time.sleep(2)是这段代码里最不起眼但最关键的一行。很多爬虫脚本被服务器限制,并不是因为代码写得差,而是因为完全没有间隔。25条数据,每页休息2秒,总共才等8秒,体验完全不受影响,但服务器压力小得多。做人留一线,爬虫也能跑得久。

如果你发现某页偶尔请求超时,最简单的处理方式是加一个重试机制:同页请求失败后等3秒再试一次,最多试3次。这在后面的完整代码里我会一并给出。

4. 写入CSV:两种写法和格式陷阱

4.1 用csv模块写入的完整逻辑

Python标准库里的csv模块就能轻松完成任务,不需要额外引入pandas。它的核心优势是自动处理字段里的逗号、引号、换行,避免生成破损的表格。

import csv def save_to_csv(records, filename="top100.csv"): with open(filename, "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["排名", "电影名称", "评分", "评价人数"]) for r in records: writer.writerow([r["rank"], r["title"], r["rating"], r["comments"]])

编码用了utf-8-sig,这就是解决Excel乱码的关键,后面专门讲。newline=""则是一个必写参数,如果不写,在Windows下打开生成的CSV文件,行与行之间会多出空行,非常难看。

这里我解释一下utf-8-sig到底是什么。普通的UTF-8编码文件,在最开头会有一个三字节的字节顺序标记:EF BB BF,也就是所谓的BOM。utf-8-sig这个编码名在Python里就是“带BOM的UTF-8”。Excel在Windows上默认用系统本地编码去猜文件,当它看到这个文件头时就会知道“这是UTF-8编码的,我要按UTF-8来解析”,于是中文就不会乱码。

这也是为什么很多教程直接说“加个BOM就好了”,因为他们用utf-8-sig写,本质就是在文件开头预置了BOM。

4.2 用pandas写入的对比方案

如果你本来就装了pandas,写入可以更短:

import pandas as pd df = pd.DataFrame(records) df.to_csv("top100_pandas.csv", index=False, encoding="utf-8-sig")

index=False的作用是不把行号写进文件。如果不写,第一列会多出一列0到99的索引,虽然不是大问题,但很业余。

两种方案对比下来,我推荐新手用标准库csv模块,因为不用多装一个几百兆的依赖,出错时也更容易定位。pandas方案更适合你已经熟悉DataFrame、后面还想做数据分析的场景。两种方案的乱码解决逻辑一模一样,都是utf-8-sig。

4.3 CSV格式化背后的三个隐藏坑

先说说我在这个项目里踩过的坑:

第一,字段里包含逗号。比如片名是“你这个逗号,让人很难办”这种,不处理就会把CSV的列拆碎。csv.writer会正确处理,自动在字段外层加引号。如果你自己用f.write()拼字符串,就一定会踩到。

第二,评价人数提取后是字符串,最好转成int,排名转成int,评分转成float。这样落到文件里数字才会规整,后续排序、可视化也顺手。别小看这个步骤,很多新手抓完数据才发现评分是字符串,想做个排序都排不对。

第三,列表页偶尔会出现数据缺失,比如某部电影没有短评、评价人数显示异常。我的建议是核心字段严格要求,出现异常直接跳过该条,并打印一条日志。扩展字段则用默认值填充,保证文件记录数不减少。

5. 乱码成因与三个解决方案

5.1 为什么Excel打开会乱码

先做一个实验,用普通的utf-8编码写入文件:

with open("top100_utf8.csv", "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["排名", "电影名称", "评分", "评价人数"])

然后用Excel双击打开这个文件,大概率看到的是一行行“çæé é³”之类的乱码,或者所有中文都挤成一坨。用记事本打开却完全正常。

原因在于:记事本会尝试识别编码,包括无BOM的UTF-8;而Excel在Windows中文系统上的默认行为是读取本地ANSI编码,也就是GBK/GB2312。当一个UTF-8编码但没有任何标记的中文文件被当成GBK读时,多字节字符就会被拆错、错位,最终显示成乱码。

这就是为什么单纯换用utf-8姿势不对,必须加BOM。BOM就像是一个写在文件第一行的“我是UTF-8编码”的声明牌,Excel看到这个声明后,就不会再用GBK去猜了。

5.2 方案一:写入阶段用utf-8-sig(推荐)

最根本、最省事的办法就是在写入时就带上BOM。所有代码已经在上文给出了,核心就一行:

encoding="utf-8-sig"

不管用csv模块还是pandas,都是这个参数。

建议你从一开始就写成utf-8-sig,而不是先写普通UTF-8再补救。这个习惯不需要额外付出什么,却能让你生成的文件在任何主流工具里都能直接打开。数据文件,本身就应该做到“谁拿到手都能用”,而不是只在自己的电脑上正常。

5.3 方案二:已有乱码文件的应急处理

文件已经生成了,而且是用普通UTF-8写的,不想重新抓取怎么办?也有办法。

用记事本打开乱码的CSV文件,如果显示正常,直接选择“文件 -> 另存为”,在右下角编码下拉框里选“带有BOM的UTF-8”保存,Excel再打开就正常了。这个方法能用,但有一个前提:文件里的中文没有被破坏。如果文件本身已经是乱码,用记事本打开同样乱码,那说明编码信息已经丢失,最好的办法是重写文件。

另外,也可以用Python一行命令给已有文件“补”上BOM:

with open("top100_utf8.csv", "rb") as f: content = f.read() with open("top100_utf8_bom.csv", "wb") as f: f.write(b"\xef\xbb\xbf" + content)

这个方法是纯字节操作,读取文件原始字节流,在开头插入BOM的三字节,再另存为。不会改动原有内容,只是给文件加上声明。

5.4 方案三:用Excel的导入向导解读UTF-8

我见过不少同事,电脑上做大表格用的还是老版本WPS或者旧版Excel,即使加了BOM,打开依然是乱码。这种情况下可以用导入功能做一次“曲线救国”。

在Excel中选择“数据 -> 从文本/CSV”,在选择文件时,会弹出一个预览窗口,里面可以手动选择文件编码。把它选为“UTF-8”或“65001: Unicode(UTF-8)”,预览正常后点加载,乱码就消失了。

这个方案适合处理那些别人发给你的、已经无法修改源头编码的CSV。它不需要编程,也不需要改文件,是纯手动操作,但对每个要打开的文件都得来一遍,不省事。所以这个方案是应急用的,不是首选。

提示:加BOM的UTF-8文件在绝大多数场景下都能被正确识别,但如果你的文件将来要交给Linux或者服务器上的程序处理,BOM有时反而会造成干扰,因为这些程序按无BOM的UTF-8解析,会出现第一个字段多出一个看不见的字符。处理方式是让对方读取时指定utf-8-sig,或者在解析阶段剥掉BOM。简单说,给Excel用户发文件,用utf-8-sig;给程序接口发文件,用普通utf-8,两种场景分开对待。

5.5 编码问题的最终验证

写完文件后,别急着关闭程序,做一个简单验证:

with open("top100.csv", "rb") as f: raw = f.read() print(raw[:10])

如果输出是b'\xef\xbb\xbf...',说明BOM已经写进去了,这个文件发给谁打开都不会乱码。如果开头直接是b'\xe6...',说明没有BOM,Excel在中文系统上仍然可能产生乱码。

再进一步,用pandas读回来检查数据完整性:

import pandas as pd df = pd.read_csv("top100.csv", encoding="utf-8-sig") print(df.shape) print(df.head())

能看到100行4列,且中文显示正常,整个数据链路就通了。这个验证步骤非常重要,它保证的不只是“写成功了”,而是“写出来能被正确读回去”。

6. 完整代码整合与进阶建议

6.1 集成后的完整脚本

把前面所有模块拼到一起,得到一个可以直接运行的完整脚本。为了方便阅读,我加上了必要的注释和控制台日志。

import time import csv import requests from bs4 import BeautifulSoup 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", } BASE_URL = "https://movie.douban.com/top250" def fetch_page(start, retries=3): """请求单页,失败自动重试""" for attempt in range(1, retries + 1): try: url = f"{BASE_URL}?start={start}&filter=" resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() resp.encoding = "utf-8" return resp.text except Exception as e: print(f"offset={start} 第 {attempt} 次请求失败:{e}") if attempt < retries: time.sleep(3) return None def parse_page(html): """解析单页电影数据""" soup = BeautifulSoup(html, "html.parser") records = [] for item in soup.select("div.item"): try: rank = int(item.select_one("em").text.strip()) title = item.select_one("span.title").text.strip() rating = float(item.select_one("span.rating_num").text.strip()) star_div = item.select_one("div.star") comment_span = star_div.find_all("span")[-1] comment_count = int(comment_span.text.strip().replace("人评价", "")) records.append({ "rank": rank, "title": title, "rating": rating, "comments": comment_count, }) except AttributeError: print("发现一条数据字段缺失,已跳过") continue return records def save_to_csv(records, filename="top100.csv"): """写入CSV,使用utf-8-sig编码,避免Excel打开乱码""" with open(filename, "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["排名", "电影名称", "评分", "评价人数"]) for r in records: writer.writerow([r["rank"], r["title"], r["rating"], r["comments"]]) print(f"数据已保存到 {filename}") def main(): all_records = [] for start in range(0, 100, 25): html = fetch_page(start) if html is None: print(f"offset={start} 请求最终失败,跳过本页") continue records = parse_page(html) all_records.extend(records) print(f"已抓取 offset={start}, 累计 {len(all_records)} 条") time.sleep(2) top100 = all_records[:100] save_to_csv(top100) print(f"总计写入 {len(top100)} 条记录") if __name__ == "__main__": main()

这段代码整体跑下来,在正常网络环境下大约需要20秒。日志会清楚地告诉你每一页抓了几条数据,哪一页失败,最终写入多少条记录。整个过程的容错处理不算复杂,但对这个规模的项目来说,已经是足够的工程化水平了。

6.2 进阶:把脚本往工程化方向改造

初级脚本能跑通是一回事,能不能长期稳定运行是另一回事。如果你打算每周跑一次这个脚本,建议在三个方向上做增强。

第一,日志系统。把print换成logging模块,记录到文件里。以后哪天脚本半夜跑挂了你才知道它是什么时候出的问题。

第二,数据库持久化。CSV适合交付给人类阅读,但如果你想把数据积累下来看趋势,最好入库。轻量级的可以用SQLite,重一点的可以用MySQL或PostgreSQL,同一条记录按日期打时间戳,能分析出榜单波动。

第三,请求策略精细化。现在固定sleep 2秒,是保守稳妥的做法。更精细的方案是用随机间隔,比如在1到3秒之间抖动,这样请求模式更接近真人操作,也不太会给服务器造成规律性压力。但要注意,随机间隔不等于可以加快速度,礼貌永远是底线。

6.3 后续扩展方向

这个项目的天花板不低,至少有三个方向值得继续做:

  • 字段扩展:把导演、演员、年份、地区、类型、剧情简介都抓下来,完整的电影信息库就出来了。
  • 榜单联动:同时抓上映时间、热度、评价人数,做一个多维度榜单分析,能看出哪些老片长盛不衰,哪些新片昙花一现。
  • 可视化展示:对抓下来的评分和评论数做分布统计,画直方图或散点图,看Top100里的评分分布是否符合预期。

我自己在做完这个项目之后,最深的体会是:爬虫项目的难点往往不是“爬”,而是数据落地那一公里。页面分析一小时,代码写一小时,剩下的时间几乎都耗在编码、格式、兼容性这些看似不起眼的小问题上。把这些问题一次性想清楚,后面再遇到类似的数据采集需求,你就能直接把整套流程复用过去。

最后再说一个小技巧:保存CSV之前,先输出一行完整的中文记录到控制台,确认解析没有出现“口口”或“问号”,再落盘。这个动作看似多余,却能帮你把“解析乱码”和“文件乱码”两类问题隔离开,排查时至少能排除一半的可能性。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进