很多人学Python爬虫的第一个实战项目就是爬取豆瓣电影Top250,这个项目我前前后后带过不少新人跑通,也亲眼看着它从一个练手demo变成很多人简历里拿得出手的项目经历。今天把这套东西完整摊开讲一遍,从页面分析、请求构造、数据解析,到清洗落库,再到最后的数据分析和可视化,一条线走完,所有代码都能直接跑。
这个项目能练到什么?requests发HTTP请求、BeautifulSoup解析HTML、pandas做清洗聚合、SQLite和CSV双轨存储、matplotlib和pyecharts出图,一套组合拳全给串起来了。适合刚学完Python基础语法、想找一个真实场景练手的人,也适合已经写过零散脚本、想系统走一遍“采集→清洗→分析→可视化”全流程的初级开发者。不要觉得Top250这个题目太老,恰恰因为它页面规整、数据量适中、字段丰富,才是把整条链路跑明白的最佳训练场。
1. 项目拆解:Top250这口井到底挖什么
很多人一看到“爬虫项目”就兴奋,脑子里立刻冒出IP代理池、逆向分析、验证码识别这些词,结果动手第一步就卡住了。豆瓣Top250能成为经典练手项目,核心原因是它的难度梯度设计得非常合理:页面是静态HTML,没有复杂的接口鉴权和加密参数,翻页逻辑肉眼可见,反爬力度也适中,不会让你轻易被封,但也逼着你学会设置请求头、控制请求频率。这些恰恰是日常工作中真正用得上的基础能力。
我建议的技术组合是:requests负责发请求,BeautifulSoup负责解析,pandas负责清洗,SQLite加CSV负责双轨存储,matplotlib加pyecharts负责可视化。整套全在Python生态内,不需要额外装数据库服务,不依赖浏览器自动化框架,跑起来不挑机器。等你把这套流程跑通,以后换任何目标站点,无非是换页面解析规则和字段提取逻辑,骨架完全可以复用。
1.1 为什么偏偏是Top250
选这个目标有几个很实际的理由。第一,数据字段丰富。每部电影包含排名、中文名、原名、导演、主演、年份、地区、类型、评分、评价人数、经典台词,这些字段对后续数据分析来说几乎是为教学量身定做的。第二,分页规律清晰。每页25条,一共10页,URL里只有start参数以25为步进递增,从0到225,不需要处理动态加载和复杂参数签名。第三,数据量恰到好处。250条不多不少,清洗时能遇到缺失值、格式混杂、多值字段等问题,分析时又能得出有统计意义的结论,比抓几百条小打小闹或者爬几万条算不动都更合适。
还有一点容易被忽略:豆瓣Top250的页面结构已经稳定了很多年。这意味着你查到的资料、踩过的坑、网上的解决方案都还有效,学习成本被大幅降低。比起去爬一个改版频繁的站点,这个项目能让你的注意力完全集中在爬虫和数据处理的本身。
1.2 流程设计先想清楚再动手
动手写代码之前,我习惯先把整条流水线在脑子里过一遍,拆成下面几步:
- 页面分析:用浏览器开发者工具定位数据所在的HTML节点结构。
- 构造请求:带上合理的请求头,模拟正常访问,处理翻页URL。
- 解析提取:从每个电影条目中提取需要的字段,做必要的字符串清洗。
- 落盘存储:把结果同时写入CSV和SQLite,双保险。
- 数据清洗:用pandas读取,处理缺失值、格式归一化、拆分多值字段。
- 分析论证:从评分、年代、地区、类型、导演等维度寻找规律。
- 可视化呈现:用图表把结论直观地表达出来。
每一步都有明确的产出物。这样做的好处是,当你中途卡住的时候,你能很快定位是采集的问题还是清洗的问题,而不是把代码从头到尾翻一遍还没头绪。接下来我们从最核心的采集环节说起。
2. 数据采集:跟页面解析死磕到底
采集是整个项目的地基,地基没打好,后面分析再漂亮也是空中楼阁。这一部分我拆成页面分析、请求头构造、翻页逻辑、异常处理四个小节来讲。
2.1 先用手刨开页面结构再写代码
任何爬虫项目拿到手,第一步永远不是写代码,而是打开浏览器按F12,老老实实看页面结构。豆瓣Top250的列表框架非常规整:所有250部电影都躺在ol标签里,每个li就是一部电影的完整卡片。
拿其中一个li来说,里面信息分布如下:
- 排名在
div.pic里的em标签中,比如1、2。 - 电影主名在
div.hd下的span.title[property="v:itemreviewed"]里。 - 原名在第二个
span.title,通常以斜杠开头。 - 导演、主演、年份、地区、类型全部挤在
div.bd里第一行p中,以空格和换行分隔。 - 评分在
div.star里的span.rating_num。 - 评价人数在
div.star里的最后一个span,格式像1551495人评价。 - 经典台词在
p.quote的span中,注意它是可选的,不是每部电影都有。
我用浏览器开发者工具逐层点开确认了这些节点之后,才动手写解析代码。经验是:先手动在一个页面里定位到所有字段的完整XPath或CSS选择器路径,再写通用解析逻辑,能省掉大量调试时间。
2.2 请求头是底线,别裸奔
豆瓣对爬虫的防护并不算严厉,但如果你的请求头里没有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,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Connection": "keep-alive" }这里的User-Agent我取的是新版Chrome的标准UA,实测用下来很稳。Accept和Accept-Language是加分项,能进一步让请求看起来像真实浏览器。有人会问只写User-Agent行不行?行,但遇到严格点的站点就容易漏馅,养成带全请求头的好习惯,后面爬别的站能少踩很多坑。
还有一个小细节:我用了一个timeout=10参数给请求设置超时,防止某个请求卡住导致整个程序挂在那里不动。超时时间设太短容易被网络抖动误伤,设太长又影响效率,10秒是实测下来还算合理的中位数。
2.3 翻页逻辑与解析完整代码
翻页是这个项目里最不需要动脑的部分,但很多人第一次写还是容易搞错。Top250的URL规律是:第一页是https://movie.douban.com/top250?start=0,第二页是start=25,以此类推,第十页是start=225。所以翻页的循环就一句话:for start in range(0, 250, 25)。
下面是完整的采集代码,每一步我都加了注释:
import time import csv import random 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": "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", } def fetch_page(start): """抓取单页Top250,返回解析后的电影列表""" url = f"https://movie.douban.com/top250?start={start}" resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() resp.encoding = "utf-8" soup = BeautifulSoup(resp.text, "html.parser") movies = [] for item in soup.select("ol.grid_view li"): # 排名 rank = item.select_one("div.pic em").get_text(strip=True) # 电影名:第一个title是中文名,第二个是原名 titles = item.select("div.hd span.title") name_cn = titles[0].get_text(strip=True) if titles else "" name_other = titles[1].get_text(strip=True).replace(" / ", "") if len(titles) > 1 else "" # 导演、主演、年份、地区、类型 info_line = item.select_one("div.bd p").get_text(" ", strip=True).split(" ") # 评分与评价人数 rating = item.select_one("span.rating_num").get_text(strip=True) rating_count = item.select("div.star span")[-1].get_text(strip=True) # 经典台词,不一定有 quote_tag = item.select_one("p.quote span") quote = quote_tag.get_text(strip=True) if quote_tag else "" movies.append({ "rank": int(rank), "name_cn": name_cn, "name_other": name_other, "info_line": info_line, "rating": float(rating), "rating_count": rating_count, "quote": quote, }) return movies def main(): all_movies = [] for start in range(0, 250, 25): print(f"正在抓取 start={start} ...") try: page_movies = fetch_page(start) all_movies.extend(page_movies) except Exception as e: print(f"抓取失败: {start}, 错误: {e}") # 随机延时,避免请求过快 time.sleep(random.uniform(1, 2)) print(f"共抓取 {len(all_movies)} 条数据") # 临时落盘CSV,方便检查 with open("top250_raw.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["rank", "name_cn", "name_other", "info_line", "rating", "rating_count", "quote"]) writer.writeheader() writer.writerows(all_movies) if __name__ == "__main__": main()这里我把info_line一整行先原样存下来,没有立刻拆分。原因是拆分逻辑放在pandas里做更顺手,批量处理比逐条用字符串切割要方便得多。后面清洗环节再集中处理字段拆分的活。
2.4 限速、重试,做一个绅士爬虫
爬虫写出来跑通不是终点,能稳定地跑完才是。豆瓣虽然对爬虫容忍度还可以,但你要是每秒发十几个请求,IP照样会被封。我的做法是每抓一页之后time.sleep(random.uniform(1, 2)),让每次请求之间至少有1到2秒的随机间隔。这个速度下250条数据不到20秒就能抓完,完全够用,又不会给服务器带来压力。
再就是异常重试。网络波动和目标站点临时抽风都是常有的事。我只加了简单的try-except把异常打出来,建议你在实际项目里升级成带重试的版本:
def fetch_with_retry(start, retries=3): for attempt in range(retries): try: return fetch_page(start) except Exception as e: print(f"第 {attempt + 1} 次尝试失败: {e}") time.sleep(3) raise RuntimeError(f"连续 {retries} 次抓取失败, start={start}")这个模式在真实生产环境里很常用,爬虫跑得越久,越能体会“自动重试”比“人工守着盯”靠谱得多。
3. 数据清洗与入库:把脏数据收拾成规整表格
采集完的原始数据直接拿来分析是能出图的,但字段都挤在一坨字符串里,根本没法聚合。这章就是把info_line拆开、把格式统一、把数据写进数据库的完整过程。
3.1 拆字段:一行文字藏着五六个属性
在采集阶段我们把导演、演员、年份、地区、类型全部塞进了info_line。实际打印出来长这样:
导演: 弗兰克·德拉邦特 Frank Darabont / 主演: 蒂姆·罗宾斯 / 1994 / 美国 / 犯罪 剧情注意年份前面没有中文标签,它是被空格分隔的第四段。地区也是一样,只有一个裸的字符串。更麻烦的是类型可能不止一个,用空格连接了多个词。清洗这段信息,我先把这行按字段位置切割,再用pandas做多值展开。
下面是我实际用的清洗代码:
import pandas as pd df = pd.read_csv("top250_raw.csv", encoding="utf-8-sig") # 拆分info_line def split_info(line): # line 形如: 导演: xxx / 主演: xxx / 1994 / 美国 / 犯罪 剧情 parts = line.split(" / ") if len(parts) >= 4: director = parts[0].replace("导演:", "").strip() actors = parts[1].replace("主演:", "").strip() year = int(parts[2].strip()) country = parts[3].strip() genres = parts[4].strip() if len(parts) > 4 else "" else: director = actors = country = genres = "" year = None return director, actors, year, country, genres df[["director", "actors", "year", "country", "genres"]] = df["info_line"].apply( lambda x: pd.Series(split_info(x)) )这里要注意,info_line在采集时我用get_text(" ", strip=True)把换行符替换成了空格,所以到了pandas里格式比较规整。如果你直接在浏览器里复制文本,换行会造成切分错位,这也是为什么我一直强调先用统一的方式把原始数据“定格式”再落盘的原因。
3.2 去重、缺失值和格式归一化
250条数据理论上不会重复,但清洗环节依然要养成去重的习惯,毕竟很多真实场景的数据源会不定期给你塞重复记录。我用电影名作为主键:
df = df.drop_duplicates(subset=["name_cn"], keep="first")缺失值方面,经典台词是重灾区,很多电影没有quote字段,采集时补了空字符串,这里统一填充成“无”:
df["quote"] = df["quote"].fillna("无") df = df.fillna("")评分在清洗时已经是float类型,但评价人数还是像1551495人评价这样的字符串,需要抽出数字部分:
import re def extract_count(text): match = re.search(r"\d+", text) return int(match.group()) if match else 0 df["rating_count"] = df["rating_count"].apply(extract_count)到这一步,DataFrame里已经是干干净净的规整结构化数据了,每一列都是一个独立维度,终于可以开始分析了。
3.3 双轨存储:CSV垫底、SQLite主力
清洗之前存了一份CSV,那是临时检查用的。清洗之后,我建议把最终结果同时存两份。一份CSV,方便你用Excel打开直观查看;一份SQLite,方便后续用SQL做复杂的多条件查询,也方便以后扩展项目时接其他数据表。
SQLite建表和入库代码不长:
import sqlite3 conn = sqlite3.connect("top250.db") df.to_sql("movies", conn, if_exists="replace", index=False) conn.close()对,pandas的to_sql一行就能搞定建表和写入。真正需要注意的反而是要不要带索引列、数据库文件放哪里、后续查询要不要建索引这些细节。250条数据量不大,索引先不加也行;但如果以后扩展成爬全部豆瓣电影,就得在name_cn和year上建索引了。
4. 数据分析:从250行数据里挖出金矿
数据清洗完之后,就到了整个项目最有意思的部分——看看这些电影背后到底藏了什么规律。我选了几个最典型的分析维度,每一个都能从数据得到明确的结论。
4.1 评分分布:高分区间到底有多拥挤
先看评分的分布情况,用pandas就能直接输出统计量:
print(df["rating"].describe())实测你会发现Top250的评分最小值也有8.2,最高分9.7,中位数在8.6左右,分布高度集中在9分附近。换句话说,能进这个榜单的,几乎没有“平庸之作”,评分区间被压缩得非常狭窄。这也侧面说明评分的区分度有限,9.0和9.3之间的差距,远没有普通榜单那样有直观感受上的巨大落差。
如果把评分做成直方图分箱,最直观的结论就是:8.5到9.0这个区间差不多占掉半壁江山。这能让人直观感受什么叫“幸存者偏差”——你看到的都是经过筛选的顶尖影片,而高分区间内部的内卷反而没那么明显。
4.2 年代与地区:谁在霸榜
把年份和评分一起看,能发现一些有趣的时代脉络。我先按年代分组统计:
df["decade"] = (df["year"] // 10) * 10 decade_stats = df.groupby("decade")["rating"].agg(["count", "mean"]).sort_index()从数量上看,1990年代和2000年代是上榜电影最密集的两个十年。这不难理解,这两个年代是电影工业的黄金期,也在互联网评分体系逐渐成型的时间窗口内。往上追溯,1970年代之前的黑白片和早期彩色片数量明显偏少,一是年代久远,打分人数基数不够,二是大众评分天然偏向“近当代”作品。
地区方面要注意一个细节:虽然我单独拆出了country字段,但部分电影是合拍片,country里会有多个国家名用空格或斜杠连着。分析前需要先按空格拆分再展开成多行,否则统计出来的占比会失真。我做地区占比时用的是Counter展开每个国家独立计数,结果里美国电影占比接近一半,日本和英国、法国紧随其后。这个结果符合大多数人对经典电影版图的认知,算是数据对直觉的一次验证。
4.3 类型、导演、演员的隐形规律
类型是另一个有意思的维度。同样因为合拍片和跨类型片,一列genres里可能包含“犯罪 剧情”这种多个类型。我把genres按空格拆分后依次计数,最终发现剧情、爱情、犯罪、悬疑这几个类型的上榜次数遥遥领先。这背后的逻辑很容易理解:进得了Top250的电影,大多靠的是深入人心的叙事和完整的情感表达,重工业特效大片反而因为评分受众的分化,更难挤进前列。
导演维度的统计也很直白。循环统计全部250部电影的导演出现频次,排名靠前的那几位几乎都是公认的大师级人物。我还统计过男女配比、演员合作次数之类更细颗粒度的指标,数据量虽然小,但拿来练手完全足够。
5. 可视化:让结论自己说话
数据算出来了,光在终端里打印数字太干瘪,用图表呈现会直观得多。这一步我用matplotlib和pyecharts各做了一版,简单、出图快。
5.1 用matplotlib画评分分布和年代趋势
评分直方图是最容易出效果的第一张图:
import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei"] plt.rcParams["axes.unicode_minus"] = False df["rating"].hist(bins=15, edgecolor="black") plt.title("豆瓣电影Top250评分分布") plt.xlabel("评分") plt.ylabel("数量") plt.show()这里有个新手高频踩坑点:matplotlib默认字体不含中文,不设置rcParams的话,标题会显示成方块。我代码里直接指定了SimHei字体,如果你的环境里没有这个字体,可以改成别的中文字体名,或者用英文标题绕过去。
年代趋势图用折线图呈现,看看每个年代的上榜数量起伏:
decade_counts = df["decade"].value_counts().sort_index() decade_counts.plot(kind="line", marker="o") plt.title("不同年代上榜电影数量变化") plt.xlabel("年代") plt.ylabel("数量") plt.show()这张图能看到从1950年代一路走高、在1990年代见顶、随后缓慢回落的整体趋势,比单纯打印一组数字直观很多。
5.2 用pyecharts做类型占比和词云
如果有交互式展示的需求,pyecharts是更趁手的工具。类型占比的饼图用pyecharts的Pie组件几行就能实现,词云则更适合把genres拆出来的词频可视化。词云这个图不承担精确统计的功能,但胜在视觉冲击力。做词云之前先准备好词频字典:
from collections import Counter genre_list = df["genres"].dropna().str.split().sum() genre_counter = Counter(genre_list)拿到词频字典之后,换任何词云库都能直接接上。pyecharts的WordCloud需要数据是[(词, 频次), ...]的格式,直接把counter转成列表传入就行。如果只是想快速出一张静态词云图,wordcloud这个库也是不错的选择。
6. 常见问题与避坑记录
每个环节都有新手高频踩坑的地方,我把这些年被问得最多的几个问题整理成一张速查表,方便你排错。
6.1 常见问题速查表
| 问题表现 | 根本原因 | 解决办法 |
|---|---|---|
| 返回内容不是电影列表,而是“检测到有异常请求” | 未设置User-Agent,或请求频率过快 | 配置完整请求头,每次请求间隔1秒以上 |
| 打开CSV后中文乱码 | CSV编码用了utf-8,Excel默认用其他编码打开 | 写入时使用encoding="utf-8-sig",不要用utf-8 |
| 抓取过程中途失败,程序直接退出 | 没有异常捕获,或没有重试机制 | 用try-except包住请求,失败后重试3次 |
| 排名全部是0 | 解析器没有正确匹配到div.pic em标签 | 检查页面结构是否被反爬替换,重新用F12确认选择器 |
| 电影名抓出来是空的 | 页面结构中有动态加载内容,或者CSS类名发生了变化 | 打开浏览器开发者工具,重新定位真实节点 |
| 某些电影没有经典台词导致字段不对齐 | p.quote标签在某些条目里不存在 | 解析时用条件判断,缺省时填“无” |
| 评分小数位被截断 | 解析后直接转int | 先转float保存,再入库 |
| 分析时年份列报错 | 当年份是None时做了运算 | 清洗阶段统一处理缺失值,保证year无空值 |
6.2 爬虫的边界与个人经验
最后必须多说一句。很多人做了爬虫项目容易产生一种错觉,觉得“能爬到就是王道”,其实不然。这个项目抓的是公开的榜单页面,数据本身是半公开信息,个人学习使用通常没有问题。但练习过程中要养成三个习惯:第一,控制频率,别去压垮对方服务器;第二,只抓自己分析需要的数据,不碰用户隐私和需要登录后才能访问的内容;第三,如果项目要商用或发布,先确认目标站点的条款和数据的版权边界。
把爬虫的技术边界和数据使用的合规边界分开看,这个项目才能做得踏实。磨刀不误砍柴工,态度和手法都对了,写代码只是一层薄薄的执行力问题。
我在实际带人做这个项目时有几个体会特别深。第一次跑通采集代码的时候,多数人会兴奋地盯着终端刷出来的250行数据,觉得大功告成。但真正能拉开差距的,是后面清洗那一步——很多人在拆info_line那一刻才发现自己写的解析器不够健壮。同一个字段,有的条目多了一个空格,有的缺失了某个标签,这些细节正是真实数据常见的状态。能把这些边角处理干净,比会写一百个爬虫函数更值钱。
后续想扩展,还可以往这几个方向走:抓取每部电影的短评和影评做情感分析,把Top250作为种子数据去爬更多关联电影,做一个简单的推荐系统,或者部署成定时任务每天自动更新榜单变化。这个项目最不缺的就是继续生长的空间,先把这一条链路练扎实,后面的事水到渠成。