☰
Python爬取豆瓣电影Top250:数据清洗与可视化全流程实战
2026/10/19 0:48:08 网站建设 项目流程

简介:基于Python的豆瓣电影Top250数据分析与可视化项目源码及文档说明,面向计算机相关专业大作业、学生课程设计与需要实战练习的学习者。项目难度适中,源码经过导师指导与本地严格调试,可稳定运行,并获项目评审98分。压缩包内含2000个文件,约80.74MB,其中以1830个py脚本为主要代码文件,另有pdf文档、txt说明文件、html/css/js前端页面、少量c与cpp扩展文件,构成完整的学习资源包。文件类型基本覆盖爬虫采集、数据清洗、统计分析、可视化展示及前端交互等环节,同时配有文档说明,目录结构清晰,便于按模块学习与复用。目前已有943人浏览学习,适合作为课程设计、毕业设计或Python数据分析项目的参考,能帮助读者掌握豆瓣Top250从数据抓取到可视化呈现的全流程,兼具高分作业模板与实战练习价值。

1. 豆瓣电影Top250这个数据集,值得你动手完整跑一遍

第一次看到这份基于Python爬取豆瓣电影Top250数据分析与可视化的源码包时,我第一反应是“又是个爬虫Demo”,但真正把源码和文档过了一遍之后,我发现它并不是那种贴个requests请求就交差的玩具项目。它把爬取、清洗、存储、分析、可视化完整串成了一条链路,数据量不大不小,刚好250条,适合用来练手完整的数据分析流程。无论你是正在做课程设计的在校生,还是想找个练手项目熟悉Python数据分析的从业者,这套源码都能直接运行,评审分98分不是虚的,起码代码逻辑完整、文档齐全,能省掉你大量从零造轮子的时间。下文我会按“爬取 → 清洗 → 可视化 → 排坑 → 进阶”的顺序,把它拆开讲清楚。

2. 爬虫抓取层的拆解:五步把页面数据变成结构化文本

2.1 请求头与UA池:为什么不能直接裸爬

Top250页面的反爬强度不算高,但如果你什么都不带,直接用requests.get去访问,大概率会拿到418或403,服务端会拒绝陌生客户端的访问。常见做法是维护一个UA池,每次请求随机挑一个,模拟浏览器环境。

import requests from fake_useragent import UserAgent ua = UserAgent() headers = { "User-Agent": ua.random, "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", } session = requests.Session() session.headers.update(headers)

这段代码里有几个关键点。fake_useragent库负责随机生成不同浏览器版本的UA字符串,避免每次请求都带着同一个指纹;Session保持了TCP连接复用和Cookie传递,减少了反复握手的开销,也降低了被站点风控盯上的概率。Accept-Language尽量写成中文优先,豆瓣会根据语言字段返回对应编码的页面,如果缺失可能拿到默认繁体或英文界面,后续解析xpath时文本格式会不一样。

2.2 翻页遍历:start参数的正确姿势

Top250一共10页,每页25条,URL里的start参数控制偏移量,从0开始,步长25。这里不建议用循环拼接字符串,直接把start定义成参数列表,方便调试时单独拉某一页。

base_url = "https://movie.douban.com/top250" page_data = [] for page in range(10): params = {"start": page * 25, "filter": ""} resp = session.get(base_url, params=params, timeout=10) resp.raise_for_status() resp.encoding = "utf-8" html = resp.text # 这里把parse_html函数传进去,稍后详解 items = parse_html(html) page_data.extend(items) print(f"第{page + 1}页抓取完成,累计{len(page_data)}条") # 频率控制:避免一次性打满服务器 time.sleep(2)

两个容易被忽略的参数:timeout=10设了单次请求超时上限,避免某个请求卡死导致整个脚本挂起;time.sleep(2)是在每页之间强行插入停顿,虽然拖慢了整体速度,但对目标站点更友好,也降低了IP被临时限制的风险。如果你只想抓前两页做测试,把range(10)改成range(2)即可。

2.3 xpath解析字段:从li节点取八类字段的路径

解析部分使用的是lxml库的xpath,比BeautifulSoup在复杂节点嵌套时更稳定。Top250每个电影条目都包在li标签里的div.item结构中,核心字段全部能一层层取到。

from lxml import etree def parse_html(html_doc): tree = etree.HTML(html_doc) results = [] for li in tree.xpath('//div[@class="item"]'): title_node = li.xpath('.//span[@class="title"][1]/text()') title = title_node[0] if title_node else "" rating_node = li.xpath('.//span[@class="rating_num"]/text()') rating = float(rating_node[0]) if rating_node else 0.0 people_node = li.xpath('.//div[@class="star"]/span[4]/text()') comment_people = int(people_node[0].replace("人评价", "")) if people_node else 0 quote_node = li.xpath('.//p[@class="quote"]/span/text()') quote = quote_node[0].strip() if quote_node else "" detail_node = li.xpath('.//div[@class="bd"]/p[1]//text()') detail_text = "".join(detail_node).strip().replace("\n", "") link_node = li.xpath('./div[@class="pic"]/a/@href') link = link_node[0] if link_node else "" results.append({ "title": title, "rating": rating, "comment_people": comment_people, "quote": quote, "detail": detail_text, "link": link, }) return results

这段代码最值得学习的是detail字段的处理方式。豆瓣电影条目里导演、主演、年份、地区、类型都挤在同一个p标签里,并且夹杂着空格和换行,直接取text()会得到一堆碎片。这里把//text()把所有子文本节点全部取出,再用join合并,后续清洗阶段再统一拆分。注意people_node里的评价人数是“xxxx人评价”的字符串,取完要replace掉中文后缀再转int,不处理的话后面做数值统计会直接类型报错。

2.4 落盘与日志:csv模块和重试机制

数据清洗前的原始数据直接存成CSV,用Python内置的csv模块而不是手动拼逗号。手动拼接遇到电影名里有逗号的情况会错列,csv.writer会自动处理字段包裹,这是最省心的写法。

import csv def save_to_csv(rows, filename="top250_raw.csv"): fieldnames = ["title", "rating", "comment_people", "quote", "detail", "link"] with open(filename, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() writer.writerows(rows)

encoding="utf-8-sig"是给Excel用户准备的,普通utf-8文件用Excel打开中文会乱码,带BOM的utf-8-sig打开就能正常显示。写完CSV后建议立刻用pd.read_csv读回来看一眼行数,确认250条都在,别等清洗阶段才发现少页。如果你在Windows上运行,文件路径里的斜杠要注意转义,或者直接使用相对路径。

3. 清洗与入库:把爬下来的脏文本变成可统计的DataFrame

3.1 读CSV和类型检查:先看dtypes再看值

爬下来的数据虽然结构完整,但所有字段都是字符串格式,评分列虽然能转float,但评价人数列里还有中文残留,detail字段更是一团混着导演、年份、国别、类型的混合文本。第一步先把CSV读进来,检查每列的类型。

import pandas as pd df = pd.read_csv("top250_raw.csv") print(df.dtypes) print(df.head(3)) print(df.isnull().sum())

这段代码会暴露三个典型问题:comment_people是object类型,因为爬取时没做转换;quote字段存在空值——不是所有电影都有一句话短评;detail列是完整字符串,没法直接做分组统计。df.isnull().sum()一定要跑,能提前知道每个字段缺多少值,判断后续策略。

3.2 拆分导演、年份、地区、类型:正则与split的配合

detail字段的原始格式类似“导演: 某某 主演: 某某 / 某某 2020 / 中国大陆 / 剧情 / 爱情”,分隔规则是斜杠,但导演和主演之间还有空格。最稳妥的办法是先按“/”split,再对每一段做二次清洗。

import re def parse_detail(text): parts = [p.strip() for p in text.split("/")] director_line = parts[0] # 常见格式:导演: 某某 主演: ... director = director_line.replace("导演:", "").strip() if "导演:" in director_line else "" year = "" region = "" genres = [] for p in parts: if re.search(r"(19|20)\d{2}", p): year_match = re.search(r"(19|20)\d{2}", p) year = year_match.group(0) elif p in ["中国大陆", "中国香港", "中国台湾", "美国", "日本", "韩国", "法国", "英国"]: region = p else: genres.append(p) return director, year, region, "/".join(genres) df["director"], df["year"], df["region"], df["genre"] = zip(*df["detail"].apply(parse_detail))

这里用正则(19|20)\d{2}匹配年份,是因为豆瓣早期电影里年份可能出现在导演、出品地区之后,位置不固定,正则比硬编码位置更可靠。zip(*df["detail"].apply(...))这个写法一次性把四列拆出来,比apply返回Series再逐个赋值快得多。genres字段最终用“/”拼接而不是存列表,是为了后续直接做分组统计方便。

3.3 缺失与重复值处理策略:什么字段值得dropna

拿到清洗结果后,统计一下缺失情况再决定去留。Top250数据整体质量很高,但quote字段有缺失,director字段偶尔因为解析失败变成空串。

df["year"] = pd.to_numeric(df["year"], errors="coerce") df = df.dropna(subset=["rating", "title"]) df = df[df["year"].notna()] df = df.drop_duplicates(subset=["title"]) print(df.shape)

逻辑上遵循“稀缺字段缺失不影响主流程”。dropna(subset=["rating", "title"])只删核心字段为空的行,quote缺失的行保留;year列通过pd.to_numeric转换,解析不了的变NaN,再统一丢掉;drop_duplicates(subset=["title"])去掉重复电影名——Top250本身不应该有重复,但如果你手动在原URL上加了多余参数,个别条目可能重复抓取。处理完的行数大概率还是250,如果少于250就要回头看爬虫逻辑。

3.4 SQLite入库:幂等写入与查询验证

清洗完的数据落SQLite,比存回CSV更适合做后续查询。代码里注意做幂等处理,每次运行先删掉旧表再重建,保证重复跑脚本不会出现重复数据。

import sqlite3 conn = sqlite3.connect("top250.db") df.to_sql("movies", conn, if_exists="replace", index=False) verify_df = pd.read_sql_query("SELECT COUNT(*) as cnt FROM movies", conn) print("入库行数:", verify_df["cnt"].iloc[0]) conn.close()

if_exists="replace"是关键的幂等参数,每次运行都会先drop旧表再写入,不留重复数据。入库后立刻用一个COUNT查询验证行数,和清洗阶段的行数对得上才继续往下走。如果对SQL不熟,至少学会这条验证命令,比盲信DataFrame里看到的数字可靠得多。

4. 可视化与分析:评分分布、年份趋势和风格词云

4.1 图表库选型:pyecharts出交互图,matplotlib出统计图

这份资源的可视化部分同时用了两个库,分工很清晰。pyecharts负责生成可交互的HTML图表,便于嵌入项目报告展示;matplotlib负责生成静态统计图,适合放在论文或文档里。pyecharts的API是新版写法,老教程里Bar().add()的用法已经废弃,新版必须用add_xaxis和add_yaxis,这个差异容易让第一次跑的人报错。

from pyecharts.charts import Bar from pyecharts import options as opts year_count = df["year"].value_counts().sort_index() bar = Bar() bar.add_xaxis([str(y) for y in year_count.index]) bar.add_yaxis("每年上榜电影数", [int(v) for v in year_count.values]) bar.set_global_opts(title_opts=opts.TitleOpts(title="豆瓣Top250电影年份分布")) bar.render("year_distribution.html")

注意bar.add_xaxis里要把年份转成字符串,pyecharts的x轴不接受int类型列表,否则直接抛类型错误。render生成的是独立HTML文件,浏览器打开就能交互,还可以在set_global_opts里加datazoom_opts,方便年份跨度大时拖动查看细节。

4.2 评分与评论数的分布:直方图与散点图

评分和评价人数是这份数据里最核心的两个数值字段,评分集中在7.5到9.5之间,评价人数跨度从几千到几百万,分布非常偏斜。直方图用来观察评分分布形态,散点图用来检查评分和评论数是否存在相关性。

import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei"] plt.rcParams["axes.unicode_minus"] = False fig, axes = plt.subplots(1, 2, figsize=(14, 5)) axes[0].hist(df["rating"], bins=20, edgecolor="black", alpha=0.8) axes[0].set_title("评分分布") axes[0].set_xlabel("评分") axes[0].set_ylabel("电影数量") axes[1].scatter(df["rating"], df["comment_people"], alpha=0.5) axes[1].set_title("评分与评论人数散点图") axes[1].set_xlabel("评分") axes[1].set_ylabel("评论人数") axes[1].set_yscale("log") plt.tight_layout() plt.savefig("rating_analysis.png", dpi=150)

两行rcParams必须放在所有绘图代码之前,否则中文标签全是方框。axes[1].set_yscale("log")把评论人数转成对数刻度,因为原始数据从几千到数百万,线性坐标下低评分的点会被压扁看不清。savefig的dpi=150足够放进报告里打印,内存占用也不大。

4.3 年份与评分趋势:识别不同时代的作品特征

把年份作为横轴、评分作为纵轴,可以直观看出各年代作品的评分水平。这里用rolling均值做平滑,否则单年样本太少,折线会像心电图一样乱跳。

trend = df.groupby("year")["rating"].mean().reset_index() trend["rolling_avg"] = trend["rating"].rolling(window=5, min_periods=1).mean() plt.figure(figsize=(12, 5)) plt.plot(trend["year"], trend["rating"], alpha=0.3, linestyle="--", label="年度平均评分") plt.plot(trend["year"], trend["rolling_avg"], linewidth=2, color="red", label="5年滑动平均") plt.xlabel("年份") plt.ylabel("平均评分") plt.legend() plt.title("Top250电影年份-评分趋势") plt.savefig("year_trend.png", dpi=150)

rolling(window=5, min_periods=1)是滑动窗口平均,window=5表示每5年取一次均值,min_periods=1保证早期年份数据不够5条时也能输出。从这张图能明显看出,早期入选Top250的电影评分整体偏高,近年入选门槛越来越高,这个发现可以直接写进报告的分析章节。

4.4 电影类型词云与导演频次:让报告有“读图感”

词云是这类课程设计报告里最讨喜的配图,但直接用原始文本生成的结果往往乱七八糟。类型词云需要先把多类型字段拆开,再用jieba分词过滤停用词,不然“电影”“剧情”这类高频泛词会占据大面积词云。

from wordcloud import WordCloud import jieba genre_text = " ".join(df["genre"].dropna().tolist()) word_list = jieba.cut(genre_text) stopwords = {"电影", "一部", "一个", "以及", "关于"} filtered = " ".join([w for w in word_list if w not in stopwords and len(w.strip()) > 1]) wc = WordCloud( font_path="C:/Windows/Fonts/simhei.ttf", width=800, height=400, background_color="white", max_words=80, ).generate(filtered) wc.to_file("genre_wordcloud.png")

font_path必须指定一个系统中真实存在的中文字体,Windows一般用simhei.ttf,macOS用户要改成/System/Library/Fonts/PingFang.ttc,Linux需要先安装中文字体包,否则词云全是乱码方块。len(w.strip()) > 1过滤掉单字,stopwords里的词按你的数据情况自行调整,跑一次看效果再迭代,这一步基本上靠感觉调。

5. 复现中的常见问题排查:六条真实踩坑记录

5.1 爬取下来count是249不是250

现象:CSV写入后总行数少一条,或者某页抓到23条。 原因:页面偶尔会有动态加载延迟,lxml解析时节点还没渲染完,少抓了当页一两个条目。 解决:先确认print里每页的条数日志,哪一页少了就单独重抓那一页,不要重跑全量。在循环里对每页结果做长度校验,长度不等于25的时候sleep三秒再重试一次。

5.2 encoding设为utf-8后Excel打开中文乱码

现象:CSV文件在记事本里看正常,但Excel打开全是乱码。 原因:普通utf-8编码不带BOM,Excel默认用系统本地编码(如GBK)猜测文件格式,猜错就显示乱码。 解决:写入CSV时用utf-8-sig代替utf-8,这是血泪经验,第一次跑这个项目就被这问题坑了十几分钟。

5.3 xpath路径写对但取到空列表

现象:调试时li节点能取到,但子节点的xpath路径返回[]。 原因:豆瓣页面层级中部分字段嵌套在其他属性里,例如span[4]的索引位置可能因页面结构微调而变化。 解决:不要依赖太长的绝对路径,用li.xpath('.//span[@class="rating_num"]/text()')这类相对路径。如果还是取不到,先li.xpath('.//span')打印所有span的class和text,再决定用哪个定位。

5.4 高频请求后IP被临时限制

现象:抓完两页后突然返回418,之后所有请求都失败。 原因:请求频率太高,触发了站点的反爬策略。 解决:把time.sleep(2)改成time.sleep(3),并加上随机抖动time.sleep(random.uniform(2, 4))。这个项目只是250条数据,没必要追求高并发,慢一点反而稳。

5.5 pyecharts运行报错AttributeError: 'Bar' object has no attribute 'add'

现象:代码在旧版pyecharts上能跑,升级最新版后报错。 原因:pyecharts v1.0以上版本彻底移除了旧版add()链式调用方法。 解决:统一使用add_xaxis/add_yaxis+set_global_opts的新版API。检查项目环境里安装的pyecharts版本,建议锁在1.2.0以上。

5.6 matplotlib绘图出现乱码和负号方块

现象:图表的标题和坐标轴中文全部显示成方块,坐标轴负号也变成异常字符。 原因:matplotlib默认字体不包含中文字符集,且默认unicode_minus为True。 解决:绘图前固定写两行配置:plt.rcParams["font.sans-serif"]=["SimHei"]和plt.rcParams["axes.unicode_minus"]=False。Linux环境下SimHei不存在,要先安装fonts-wqy-microhei或直接指定系统已有的字体路径。

6. 从复现到改造:把Top250数据链路迁移到自己的数据集

跑通这份资源之后,最有价值的动作是把整套数据链路迁移到其他业务场景。核心逻辑其实只有四件事:请求页面、解析节点、清洗入库、可视化输出。我把这套结构抽成了一个模板,遇到任何需要采集分析的新需求,只换URL和解析规则就能复用。

def generic_crawl_and_analyze( url_list, parse_func, sleep_interval=2, ): all_rows = [] for url in url_list: resp = session.get(url, timeout=10) rows = parse_func(resp.text) all_rows.extend(rows) time.sleep(sleep_interval) df = pd.DataFrame(all_rows) df = clean(df) df.to_sql("target_table", conn, if_exists="replace", index=False) return df

以豆瓣的新片榜单为例,改写思路是:把parse_func里的xpath常量抽成参数,年份、评分字段保持不变,增加票价、上映日期等新字段。如果你要爬的是其他站点,需要把fake_useragent换成对方接受的请求头,解析逻辑重新写一遍,但清洗和可视化层基本可以原封不动搬过去。我一般会把每页字段的解析函数单独写成一个文件,换站点只改这个文件,其他部分不动。

实际验证改造是否成功,我会在迁移后做三个检查:一是爬取条数与原网站列表页数量一致;二是随机抽三条数据,打开详情页对一下评分和评论数;三是把df.dtypes打印出来,确认新字段类型不是object。这三步走完,基本能确定新链路是可靠的。

这套资源的价值在于它构建了一个完整可扩展的分析脚手架。第一次完整跑下来之后,从那以后我每次拿到新数据集,都会强制自己走一遍“解析→清洗→入库→可视化”的完整链路,遇到再简单的爬虫项目也不跳步。这条流程已经成了我的固定习惯,帮我在后续好几个模拟项目中少走了不少弯路。如果你也在做数据分析类的大作业,直接下载这套源码对照着跑,比从零开始省力得多,希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询