简介:基于Python爬取豆瓣电影Top250并完成数据分析与可视化的完整项目,面向计算机相关专业正在做课程大作业的学生,以及需要实战练习的Python学习者。资源共2000个文件,以1830个Python源码文件为主,涵盖爬虫抓取、数据清洗、统计分析、可视化绘图等核心模块,同时包含C扩展、txt配置、HTML/CSS/JS前端展示文件、PDF文档等辅助内容,整体压缩包约80.74MB,文件层级清晰,查找定位方便。目前已有942人学习下载。项目经导师指导并通过高分验收,评审分98分,全部源码已本地编译调试并稳定运行;从豆瓣Top250页面抓取、字段解析、数据清洗,到统计分析与可视化出图,形成完整项目链路。附带文档说明还包括运行环境配置、使用步骤、设计思路和常见问题排查,能帮助读者快速理解数据分析项目开发全流程,借鉴代码组织方式与调试技巧,对完成课程设计或毕业设计具有直接的参考价值。
1. 为什么拿豆瓣Top250当爬虫和数据可视化练手项目:数据规模与交付形态正好匹配
拿“Python爬取豆瓣电影Top250数据分析与可视化”当成一个完整项目来做,最大的好处不是页面难爬,而是它刚好覆盖了从业者日常最常用的一套链路:请求 → 解析 → 清洗 → 分析 → 出图。Top250只有250条数据,单页25条共10页,字段却足够丰富——排名、片名、导演、主演、年份、制片国家或地区、类型、评分、评价人数、引用语。这个规模意味着你不需要分布式爬虫、不需要消息队列,一台普通笔记本用requests加BeautifulSoup就能稳定跑完,但它又足够让你踩到真实项目里最常见的坑:反爬校验、字段拆分、空值处理、中文字体乱码。
很多入门教程跑完就结束了,但真正到交付环节才发现问题:爬到的数据洗不干净,图表画出来没有业务含义,代码改一处别处就崩。这篇笔记会把源码和文档说明里该有的模块拆开讲,从单页请求到翻页循环,从复合字段清洗到pandas指标计算,再到matplotlib与pyecharts的分工,最后给出一套自检和增量更新的验证方式。适合刚跑通Python基础、想做一个完整数据分析与可视化实践项目的人,也适合已经写过爬虫、但想把代码从“能跑”提升到“能交付”的熟手。
2. 请求与解析:先用单页跑通,再把start参数变成翻页循环
2.1 请求头、延时与缓存:把“反爬”从玄学变成工程纪律
豆瓣Top250的反爬严格程度在同类网站里算温和的,但不代表可以不带脑子请求。很多人一上来就写一个for循环连续请求10页,结果第3页开始返回418或者一个“检测到异常访问”的提示页。这里的核心问题不是网站有多强,而是你的请求特征太像脚本了。
我一般的做法是三个动作同时做。第一,固定一个完整的User-Agent和Accept-Language,让请求头接近浏览器;第二,每页请求之间加随机延时,把1秒到2秒的间隔打散;第三,把每次请求到的HTML缓存到本地目录,下次跑的时候优先读缓存。第三点很多人忽略,但它其实是整个爬虫实战里最有用的“后悔药”——豆瓣页面的榜单变化很慢,你完全不需要每次重新请求一遍,缓存还能帮你定位解析问题:页面结构变了和代码写错了,是两种完全不同的排错路径。
import os import time import random import requests 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", } CACHE_DIR = "html_cache" def get_page(start: int, use_cache: bool = True) -> str: os.makedirs(CACHE_DIR, exist_ok=True) cache_file = os.path.join(CACHE_DIR, f"top250_{start}.html") if use_cache and os.path.exists(cache_file): with open(cache_file, encoding="utf-8") as f: return f.read() url = f"https://movie.douban.com/top250?start={start}&filter=" resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() html = resp.text with open(cache_file, "w", encoding="utf-8") as f: f.write(html) time.sleep(random.uniform(1.0, 2.0)) return html逻辑说明:get_page先是构造带start参数的URL,start=0对应第1页,start=25对应第2页,每页25条。拿到响应后先写缓存再延时,这样即使后续解析代码写错了,重新调试时也不会再消耗请求次数。
参数说明:timeout设10秒,避免某个页面卡住拖死整个脚本;random.uniform(1.0, 2.0)把间隔控制在1到2秒之间,这个频率远低于豆瓣的风控阈值,足够稳定。不要为了赶时间把延时去掉,这个项目的数据量决定了你省下的时间最多几秒,但触发的风控可能要等几分钟甚至更久。
提示:缓存目录建议单独建,不要和代码混在一起。文档说明里把这一步写清楚,别人拿到源码后第一眼就知道哪些文件是缓存、哪些是源码。
2.2 用BeautifulSoup提取Top250单页字段:选择器、解析与翻页参数
拿到HTML后,真正核心的解析逻辑其实固定在单页里。Top250列表页的结构一直是ol.grid_view下的li,每个li包含一部电影的完整信息:标题在.hd下的.title里,评分在.star下的.rating_num,引用语在.quote下的span,而导演、主演、年份、地区、类型全部塞在.info .bd下面的p标签里。这个p标签内部用br分隔成两行,是需要单独处理的。
from bs4 import BeautifulSoup import re def parse_page(html: str, start: int) -> list[dict]: soup = BeautifulSoup(html, "lxml") items = [] for li in soup.select("ol.grid_view li"): rank = int(li.select_one(".pic em").text) title_tag = li.select_one(".hd .title") title = title_tag.text.strip() if title_tag else "" rating_tag = li.select_one(".star .rating_num") rating = float(rating_tag.text) if rating_tag else 0.0 quote_tag = li.select_one(".quote span") quote = quote_tag.text.strip() if quote_tag else "" info_p = li.select_one(".info .bd p") info_lines = info_p.get_text(" ", strip=True).split(" ") # info_lines 大概是: # ["导演: 某某 / 主演: 某某", "1994 / 美国 / 犯罪 剧情"] person_line = info_lines[0] if len(info_lines) > 0 else "" meta_line = info_lines[1] if len(info_lines) > 1 else "" items.append({ "rank": rank, "title": title, "rating": rating, "quote": quote, "person_line": person_line, "meta_line": meta_line, }) return items逻辑说明:先用select定位列表项,再逐字段提取。这里的技巧是把导演主演单独留成一个原始字符串,不要急着拆,因为不同条目的主演人数不一样,拆早了容易丢失信息。meta_line同样保留整行,后面清洗阶段再统一按“/”拆分。
参数说明:select_one在目标不存在时返回None,所以每个字段都做了兜底。rank用int转换,rating用float转换,万一豆瓣调整了评分显示精度,这个转换也不会崩。get_text(" ", strip=True)的作用是把p标签里的br换成空格再拼接,这样两行文本还能用split(" ")分开;如果不加这个参数,br会直接把文本拼成一坨,后续清洗就没法做了。
3. 字段清洗与存储:复合字段拆分、去重与CSV落盘的落地规则
3.1 年份、制片地区、类型混在一行:用正则和split拆干净
爬到的数据里最脏的不是评分,而是meta_line这一行文本。它长这样:“1994 / 美国 / 犯罪 剧情”,但有三种变体非常容易让人翻车:年份后面带括号备注,比如“1993 / 中国大陆 / 中国香港”;制片国家或地区本身带斜杠,比如“美国 / 英国”;类型字段由两个英文单词组成,比如“剧情 爱情”。如果无脑用split("/")然后取第0个元素当年份,就会把“1993(中国大陆)”整个当成年份字符串。
def clean_meta(meta_line: str): # 先用正则把年份摘出来,年份一定是4位数字 year_match = re.search(r"(\d{4})", meta_line) year = int(year_match.group(1)) if year_match else None # 去掉年份和可能带括号的备注,剩下的才是“地区 / 类型” rest = re.sub(r"\d{4}.*?/", "", meta_line) parts = [p.strip() for p in rest.split("/")] country = parts[0] if parts else "" genres = [g.strip() for g in parts[1].split()] if len(parts) > 1 else [] return year, country, genres逻辑说明:先正则搜索4位连续数字作为年份,把年份从原字符串里摘出去,避免后续split被年份备注干扰。rest里可能还残留“中国大陆 / 中国香港”这样的多地区字段,直接取第一个作为主要制片地区,类型则用split()按空白切分,因为豆瓣的类型字段中间是空格不是斜杠。
参数说明:re.search而不是re.findall,因为一部电影只有一个年份。re.sub的替换模式里\d{4}.*?/是非贪婪匹配,作用是从年份开始一直去掉到第一个斜杠,这样即使年份后面跟着“(中国大陆)”也能一次清干净。genres用split()而不是split(" "),既能处理单个空格也能处理多个空格。
主演字段同样需要单独处理。person_line长这样:“导演: 弗兰克·德拉邦特 Frank Darabont 主演: 蒂姆·罗宾斯 Tim Robbins / 摩根·弗里曼 Morgan Freeman”,里面中英文混排,演员之间用斜杠分隔。
def clean_person(person_line: str): director = "" actors = [] if "导演:" in person_line: director_part = person_line.split("导演:")[1].split("主演:")[0] director = director_part.strip() if "主演:" in person_line: actors_part = person_line.split("主演:")[1] # 只要前3位主演 actors = [a.strip() for a in actors_part.split("/")[:3]] return director, actors逻辑说明:先按“导演:”切出导演段,再按“主演:”切出演员段。演员只取前3位,因为Top250条目里主演最多能达到六七个,取全了会让后面的词频分析变得很碎。这里有一个隐藏坑:有些老电影条目没有“主演:”字样,只有导演,所以两个if都必须独立判断,不能把逻辑写成else。
参数说明:split("/")[:3]中的[:3]是硬编码,如果某天想分析完整演员阵容,改成不带切片即可。director的strip()不能省,因为导演字段后面可能带着一个空格再拼接主演字段。
3.2 从DataFrame到CSV:编码、去重与断点续爬的落地写法
清洗完单个条目后,把全部10页数据汇总成DataFrame。这里要定一列统一字段顺序:rank、title、year、country、genres、director、actors、rating、quote,其中actors和genres是列表,存CSV前需要转成字符串,否则pandas会写出带引号的Python字面量。
import pandas as pd def build_dataframe(all_items: list[dict]) -> pd.DataFrame: cleaned = [] for item in all_items: year, country, genres = clean_meta(item["meta_line"]) director, actors = clean_person(item["person_line"]) cleaned.append({ "rank": item["rank"], "title": item["title"], "year": year, "country": country, "genres": "/".join(genres), "director": director, "actors": "/".join(actors), "rating": item["rating"], "quote": item["quote"], }) df = pd.DataFrame(cleaned) df = df.drop_duplicates(subset=["rank"]).sort_values("rank") df.to_csv("top250.csv", index=False, encoding="utf-8-sig") return df逻辑说明:清洗函数接收原始字段,输出规整的单条记录。genres和actors用“/”重新拼回字符串,是为了让CSV每一列都是可读文本,而不是Python列表字面量。drop_duplicates按rank去重,防止重复请求或断点续爬时产生重复行。
参数说明:encoding="utf-8-sig"是给Excel用户准备的,不加这个参数,Excel打开CSV时中文会乱码;如果只给pandas自己读,普通utf-8就够。sort_values("rank")保证输出顺序稳定,后续分析图和榜单排名一致,调试时方便对照原始网页。
注意:初版代码不要把清洗和分析混在同一个文件里。源码至少要拆成crawler.py、clean.py、analysis.py三个模块,文档说明里写清楚运行顺序,否则改一个字段格式就要连带改画图代码,这是项目腐化最快的方式。
4. 避坑与常见问题:4条让爬取、清洗与分析翻车的真实记录
4.1 请求返回418或解析结果为空列表:现象、原因与排查顺序
现象:代码第一次跑通,加了一个for循环请求10页,跑到第4页突然打印出“418”状态码;或者页面正常返回,但select("ol.grid_view li")一个元素都选不到。
原因:连续请求触发了风控,豆瓣返回了一个验证页面;还有一种情况是你改了headers里的某个字段,导致页面返回的是默认的静态模板而不是电影列表。这两个原因的表现形式完全一样,都是“没有li”。
解决:先打印response.status_code和response.text的前200个字符,确认是418还是200。如果是418,说明频率太高,把延时从random.uniform(1.0, 2.0)改成random.uniform(2.0, 4.0);如果返回200但没有li,检查headers里是否漏了Accept-Language或者User-Agent被requests库覆盖成了默认值。我踩过一次最蠢的坑:在for循环外面定义的session,循环里却每次新建requests.get,导致cookies没有复用,服务端把每次请求都当成新连接,风控阈值更低。
4.2 正则拆年份“吞掉”了数据:爬到了却洗丢了
现象:写正则的时候想匹配年份和地区,用了re.findall(r"\d{4}", meta_line),发现《霸王别姬》的年份输出是1993,这没问题;但用同一个正则去匹配带备注的条目,比如“1993(中国香港) / 中国大陆”,年份后面莫名多了一段字符串。
原因:正则\d{4}只匹配4位数字,不匹配后面的括号备注,但如果你在提取完后没有把备注部分从原字符串里remove掉,下一步split("/")时“1993(中国香港)”就成了一个整体。
解决:按代码段的顺序来,先re.search提取年份,再re.sub把从年份开始的这截内容连同斜杠一起删掉,最后才做split。这个顺序不能反过来——如果先split再提取年份,遇到“美国 / 英国”这种地区本身带斜杠的条目,年份索引就全乱了。我见过有人用切片取第0个元素当年份,在Top250上大概90%的条目是对的,剩下10%的条目错得毫无规律,这种bug最可怕,因为它不会报错。
4.3 清洗后明明250条,分析时只剩248条:空值溯源
现象:CSV里检查行数是250,但pandas读进来做groupby("year")之后,年份分布的总和只有248。逐行排查发现是两条评分缺失的条目被dropna带走了。
原因:豆瓣Top250并不是每条都有“引用语”,quote字段在解析时被赋了空字符串。清洗阶段如果写了df = df.dropna(),空字符串不会触发dropna,但如果你把空字符串fillna(pd.NA)再dropna,就会把这两条看似“不完整”的数据删掉。
解决:分析脚本里只对参与计算的列做空值处理,比如df.dropna(subset=["rating", "year"]),绝不对整张表做全字段dropna。更稳的做法是清洗阶段把quote的空字符串统一替换成“无”,这样无论是存CSV还是后续做词云,都不会影响主链路。
4.4 matplotlib中文乱码与坐标轴重叠:可视化最常见的两座山
现象:画评分分布图,标题和坐标轴文字全是方块;画年份分布图,X轴年份标签挤成一团,什么都看不清。
原因:matplotlib默认字体不含中文字符,需要显式指定字体;年份从1939到2023跨度太大,默认刻度策略不会自动旋转标签。
解决:绘图前先设置三行rcParams,把字体切到SimHei,并关闭unicode负号。如果运行环境是Linux服务器,SimHei不存在,改成Noto Sans CJK SC。X轴标签在刻度超过10个时加plt.xticks(rotation=45),这一步不是美化,是数据可读性的底线。下面的代码块是每次画图都要带的配置:
import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei"] plt.rcParams["axes.unicode_minus"] = False逻辑说明:font.sans-serif必须放在绘图代码之前,且不能放在函数内部,否则每次调用都需要重复设置。axes.unicode_minus控制负号显示,不设置的话坐标轴负号会变成方块,这个问题在评分分布图里特别明显。
5. 数据分析与可视化:用pandas算出结论、用图表把结论讲清楚的完整链路
5.1 用pandas算评分分布、年代分布与地区Top10:代码与参数
数据清洗完成后的第一步不是画图,而是先想清楚要回答哪几个问题。Top250这个数据能支撑的结论大致有四类:评分分布是否集中在9分以上、哪个十年贡献的榜单影片最多、哪些国家或地区上榜最多、哪些导演多次上榜。每个问题对应一个pandas操作,代码量不大但参数细节值得讲。
import pandas as pd import numpy as np df = pd.read_csv("top250.csv") # 1. 评分分布:按0.1分间隔做分桶统计 df["rating_bin"] = pd.cut(df["rating"], bins=np.arange(8.0, 10.0, 0.1)) rating_dist = df["rating_bin"].value_counts().sort_index() # 2. 年代分布:按十年分桶 df["decade"] = (df["year"] // 10) * 10 decade_dist = df["decade"].value_counts().sort_index() # 3. 地区分布:把“美国 / 英国”拆成多行再计数 df_country = df.assign(country_list=df["country"].str.split("/")).explode("country_list") country_top10 = df_country["country_list"].str.strip().value_counts().head(10) # 4. 导演上榜频次 director_top10 = df["director"].value_counts().head(10)逻辑说明:pd.cut用等距分箱把评分分成从8.0到9.9的区间,这样才能看出“9.0到9.1”和“9.1到9.2”是否有断层。年代分桶用的是整除技巧,year // 10 * 10把1994变成1990、2003变成2000,天然形成十年粒度。地区分布里explode是关键,原始数据里一部合拍片可能同时属于美国和英国,只有拆成两行才能准确统计地区占比。
参数说明:np.arange(8.0, 10.0, 0.1)生成的区间是左闭右开,9.9分会被归到9.9到10.0那一桶,不会丢失。value_counts()默认按频次降序,distinct排序时用sort_index()恢复区间顺序,否则画出来的X轴是乱的。head(10)限制了输出条目,Top250里上榜国家或地区有几十个,只取前10画图,避免条形图X轴被挤爆。
5.2 静态图先对齐结论,交互图再谈美观:matplotlib与pyecharts的分工
数据分析与可视化实践里最常见的错误是一上来就追美观,图表样式调了一下午,结论没验证。我的一般流程是先用matplotlib出四张静态图,确认数据和结论对得上,再用pyecharts做成可交付的HTML看板。后者不改变分析结论,只改变展示层。
import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei"] plt.rcParams["axes.unicode_minus"] = False fig, axes = plt.subplots(2, 2, figsize=(14, 10)) # 左上:评分分布直方图 axes[0, 0].hist(df["rating"], bins=20, edgecolor="white") axes[0, 0].set_title("Top250评分分布") # 右上:年代分布柱状图 decade_dist.plot(kind="bar", ax=axes[0, 1]) axes[0, 1].set_title("上榜电影年代分布") # 左下:地区Top10条形图 country_top10.plot(kind="barh", ax=axes[1, 0]) axes[1, 0].set_title("制片地区Top10") # 右下:评分与评价人数散点图 axes[1, 1].scatter(df["rating"], df["rating_count"]) axes[1, 1].set_xlabel("评分") axes[1, 1].set_ylabel("评价人数") plt.tight_layout() plt.savefig("top250_analysis.png", dpi=150)逻辑说明:四张图分别对应四个分析结论。评分分布直方图能直接看出9.0到9.5是不是密集区;年代柱状图用来观察近几年上榜数量是增还是减;地区条形图回答“哪个国家的电影最受榜单认可”;右下角的散点图是把评分和评价人数放一起,看高评分是否等于高热度。matplotlib的核心价值是快和糙,让你在十分钟内确认数据没有异常。
参数说明:figsize=(14, 10)控制总画布尺寸,四个子图才不会挤在一起。dpi=150是保存清晰度的下限,低于这个值放大看会有锯齿。tight_layout()会自动调整子图间距,不调用的话标题和坐标轴会互相压住。
pyecharts的用法跟matplotlib完全不同。它生成的是HTML文件,可以在浏览器里交互,适合放进交付文档或可视化大屏。下面是用它画地区Top10的完整代码:
from pyecharts.charts import Bar from pyecharts import options as opts bar = ( Bar() .add_xaxis(country_top10.index.tolist()) .add_yaxis("上榜数量", country_top10.values.tolist()) .set_global_opts( title_opts=opts.TitleOpts(title="豆瓣Top250制片地区分布"), xaxis_opts=opts.AxisOpts(axislabel_opts=opts.LabelOpts(rotate=45)), ) ) bar.render("country_top10.html")逻辑说明:pyecharts的链式调用把数据、坐标轴和全局配置组合到一个对象里,render输出独立HTML文件,不需要本地起服务。rotate=45是必须的,Top10地区的名字比较长,不旋转会重叠。这个HTML可以直接发给任何人,双击就能看,hover有数值提示。
pyecharts适合做交付,但我不建议把全部图表都交给它。散点图和直方图用pyecharts配置成本高,matplotlib两行就画完;要做可视化大屏时,再把这几个HTML用Page对象组合起来,一个页面放四五个图,切换tab就能看不同维度。顺序很重要:用matplotlib验证结论,用pyecharts包装展示,不要反着来。
6. 验证与进阶:把一次性脚本变成可复用的数据管道
6.1 用自检脚本对全流程做断言:长度、唯一性、空值与范围校验
分析跑完不等于项目完工。我见过太多案例,前一天清洗逻辑改了一个正则,第二天重新生成图表时少了5条数据没人发现。给这个项目加一个自检函数,成本很低,但能挡住大部分回归问题。
def validate(df: pd.DataFrame) -> None: assert len(df) == 250, f"行数异常: {len(df)}" assert df["rank"].is_unique, "rank列存在重复" assert df["rating"].between(8.0, 10.0).all(), "评分超出合理范围" assert df["year"].between(1930, 2030).all(), "年份超出合理范围" missing = df[["rating", "year", "title"]].isna().sum().sum() assert missing == 0, f"关键字段存在空值: {missing}" print("自检通过: 250条数据完整,字段无异常")逻辑说明:自检脚本只验证不修改,任何一项失败都会让程序中断并报出具体原因。行数校验是最基本的一道闸门,防的是清洗阶段误删数据;rank唯一性校验防的是翻页重复或去重失败;评分和年份区间校验防的是正则拆错导致数据漂移。
参数说明:assert后面的提示信息要具体,宁可写成“rank列存在重复”也不要只写“数据异常”。between(8.0, 10.0)是左闭右闭,豆瓣Top250的最低分是8.2,不会误伤。关键空值校验只查参与分析的列,不查quote这类可有可无的字段,这个设计跟第四章提到的空值坑呼应上了。
6.2 增量更新与自动化:从“跑一次”到“每周跑一次”
如果这个项目只是跑一次出几张图,那源码组织成什么样都无所谓;但如果打算持续跟进榜单变化,就需要考虑增量更新。常见做法是给爬虫脚本加一个force参数:默认读缓存和已有CSV,需要强制刷新时加上--force,重新请求所有页面并覆盖CSV。
我个人现在的习惯是,清洗逻辑每改一次就顺手把自检函数跑一遍,再决定要不要重新爬取。这个项目规模小,不追求自动化调度,但把“自检 + 缓存 + 参数化”这三个意识养成习惯,后面接任何数据源都能复用这套骨架。我的项目文档里会写清楚三件事:环境依赖怎么装、脚本按什么顺序运行、哪些字段是清洗后新增的派生字段。希望帮到你。
本文还有配套的精品资源,点击获取