简介:本资源是一套面向本科毕业生的数据挖掘与可视化综合实践项目,聚焦豆瓣电影数据的采集、清洗与多维呈现,适用于Python初学者进阶实战及毕业设计参考。项目完整实现从网页爬虫(Requests+BeautifulSoup)、数据清洗(Pandas基础处理)、到前端可视化(ECharts+Bootstrap)的全流程,涵盖数据获取、分析逻辑与交互展示三大核心环节。压缩包共112个文件,含4个核心Python脚本(爬取与清洗主逻辑)、20个HTML页面(含动态图表看板)、20个JS文件(ECharts配置与交互控制)、11个CSS样式文件(含bootstrap、map、pagination等主题支持),以及38张可视化结果PNG图,整体仅3.56MB,轻量易部署。目前已有2186人学习下载,资源结构清晰、模块解耦明确,附带可直接运行的前后端代码与静态可视化成果,便于快速复现、调试修改或拓展至其他平台数据源。
1. 豆瓣电影数据爬取及可视化:为什么毕业设计选它,不是因为简单,而是因为它能一次练透「数据获取—清洗—建模—呈现」全链路
很多同学拿到“豆瓣电影数据爬取及可视化”这个毕设题目时第一反应是:“不就是爬个网页、画几张图?两周搞定。”——结果第三周卡在反爬策略上改了十七版requestsheaders,第五周发现豆瓣返回的评分字段在不同页面结构里藏得七零八落,第七周导出的 Excel 里“类型”列全是[‘剧情’, ‘爱情’]这种字符串,根本没法做统计。这不是项目太难,而是它天然覆盖了工程落地中最常翻车的五个断点:动态加载识别、HTML 结构脆弱性、JSON 嵌套解析、中文文本清洗歧义、多维指标与视觉编码错配。它不依赖 GPU、不强求算法创新,但要求你对urllib.parse的quote行为有直觉,对pandas.DataFrame.explode()的触发条件心里有数,对matplotlib中plt.xticks(rotation=45)和plt.tight_layout()的调用顺序有血泪经验。适合想用一个真实、可验证、有业务语义的数据源,把 Python 数据分析栈从“会写 for 循环”推到“能交付可维护脚本”的同学。本文所有代码均基于豆瓣公开页面(非登录态、非接口调用),适配 2024 年主流反爬水位,不依赖任何第三方代理或破解工具。
2. 用 requests + BeautifulSoup 在本地跑通豆瓣 Top250 最小可行爬取:绕过基础 UA 拦截与分页陷阱
2.1 爬取目标锁定:为什么只抓 Top250 页面,且必须放弃“全部电影”幻想
豆瓣电影 Top250 是一个静态 HTML 列表页(URL 形如https://movie.douban.com/top250?start=0&filter=),每页 25 条,共 10 页。它不依赖 JavaScript 渲染,无登录态校验,且页面结构十年未大改——这是唯一能让你在不触碰 Selenium、Playwright 或逆向 JS 的前提下,验证爬虫逻辑闭环的“安全沙盒”。而所谓“豆瓣全量电影”,实际包含超千万条条目,分散在按年份、类型、地区等维度的数百个子列表中,且大量页面启用动态加载+签名参数,强行硬扫不仅效率极低,更会因请求密度触发 IP 限频。常见误用是:一上来就写for i in range(0, 10000, 25):去遍历所有可能 start 值,结果第 37 页开始返回 403,日志里全是ConnectionResetError。
2.2 构造合法请求头:UA 不是复制粘贴,而是模拟“真实浏览器指纹链”
单纯设置User-Agent已无法通过豆瓣基础校验。需同步构造Accept、Accept-Language、Accept-Encoding、Connection四个关键头,并确保它们之间存在合理关联。例如,若User-Agent声明为 Chrome 120,则Accept必须包含text/html,application/xhtml+xml,而不能是*/*;Accept-Language若为zh-CN,zh;q=0.9,则Accept-Encoding必须含gzip, deflate。以下是最小有效组合:
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,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept-Encoding": "gzip, deflate", "Connection": "keep-alive", "Upgrade-Insecure-Requests": "1", "Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Site": "none", "Sec-Fetch-User": "?1" } def fetch_page(url: str) -> BeautifulSoup: try: resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() resp.encoding = 'utf-8' # 强制指定编码,避免乱码 return BeautifulSoup(resp.text, 'html.parser') except requests.exceptions.RequestException as e: print(f"请求失败 {url}: {e}") return None提示:
resp.encoding = 'utf-8'是关键。豆瓣响应头中charset字段常为空或错误,requests自动检测易出错,导致中文标题变成u'\u4f60\u597d'。手动设为utf-8可规避 90% 的乱码问题。
2.3 解析电影条目:用 CSS 选择器替代 XPath,抓住.item容器的稳定锚点
Top250 页面中,每部电影被包裹在<div class="item">内,该 class 十年未变,是比id或>def parse_movie_item(soup: BeautifulSoup) -> list: items = [] for item_div in soup.select('div.item'): # 先抓容器 # 标题:位于 <div class="info"> > <div class="hd"> > <a> > <span class="title"> title_tag = item_div.select_one('div.info div.hd a span.title') title = title_tag.get_text(strip=True) if title_tag else "" # 评分:位于 <div class="info"> > <div class="bd"> > <div class="star"> > <span class="rating_num"> rating_tag = item_div.select_one('div.info div.bd div.star span.rating_num') rating = float(rating_tag.get_text(strip=True)) if rating_tag and rating_tag.get_text(strip=True).replace('.', '').isdigit() else 0.0 # 导演与主演:位于 <div class="info"> > <div class="bd"> > <p class=""> p_tag = item_div.select_one('div.info div.bd p') if p_tag: # 使用正则提取导演("导演: XXX / XXX")和主演("主演: YYY / YYY") import re text = p_tag.get_text(strip=True) director_match = re.search(r'导演[::]\s*([^/]+)', text) director = director_match.group(1).strip() if director_match else "" starring_match = re.search(r'主演[::]\s*([^/]+)', text) starring = starring_match.group(1).strip() if starring_match else "" else: director = starring = "" # 年份与类型:位于 <div class="info"> > <div class="bd"> > <p class=""> 的第二行(含 分隔) year_genre_text = "" if p_tag and len(p_tag.contents) >= 2: # 取第二个子节点(通常是年份+类型文本) second_child = p_tag.contents[1] if len(p_tag.contents) > 1 else "" if hasattr(second_child, 'get_text'): year_genre_text = second_child.get_text(strip=True) # 提取年份(4位数字)和类型(逗号分隔) year_match = re.search(r'(\d{4})', year_genre_text) year = int(year_match.group(1)) if year_match else 0 genre = [g.strip() for g in re.split(r'[,,]', year_genre_text.replace(str(year), '')) if g.strip()] if year_genre_text else [] items.append({ "title": title, "rating": rating, "director": director, "starring": starring, "year": year, "genre": genre }) return items # 测试单页 soup = fetch_page("https://movie.douban.com/top250?start=0&filter=") if soup: movies = parse_movie_item(soup) print(f"成功解析 {len(movies)} 部电影,首部:{movies[0]}")
逻辑说明:select_one()比find()更安全,它只返回第一个匹配项,避免因 HTML 中意外出现多个同名标签导致数据错位;re.search(r'导演[::]\s*([^/]+)', text)同时兼容中文冒号:和英文冒号:,这是豆瓣页面历史遗留的混用问题;genre字段用re.split(r'[,,]')处理中英文逗号,而非简单split(','),否则会漏掉中文逗号分隔的类型。
3. 用 pandas 清洗与结构化数据:把嵌套列表、缺失值、异常字符一次性归零
3.1 将原始字典列表转为 DataFrame:pd.json_normalize()是处理嵌套字段的后悔药
爬取结果中genre是列表(如['剧情', '爱情']),starring是长字符串(如"张国荣, 张丰毅, 巩俐"),直接pd.DataFrame(movies)会导致genre列存为list对象,后续无法value_counts()。此时pd.json_normalize()是最优解——它能把嵌套字段自动展开为多列:
import pandas as pd import numpy as np # 假设 movies 是上一步得到的字典列表 df = pd.json_normalize( movies, sep='_', # 展开后列名分隔符,如 genre_0, genre_1 record_path=['genre'], # 指定要展开的嵌套字段 meta=['title', 'rating', 'director', 'starring', 'year'], # 保留的顶层字段 errors='ignore' ) # 但 json_normalize 会把每个 genre 拆成一行(爆炸式展开),我们需要的是“一部电影一行,类型用 | 连接” # 所以改用 explode + groupby df_raw = pd.DataFrame(movies) df_raw['genre_str'] = df_raw['genre'].apply(lambda x: ' | '.join(x) if isinstance(x, list) else "") df_raw['starring_list'] = df_raw['starring'].str.split(r'[,,、/]') df_raw['director_list'] = df_raw['director'].str.split(r'[,,、/]')3.2 处理三类高频脏数据:空值、异常评分、年份错位
豆瓣 Top250 中约 3% 的条目存在字段缺失或异常。例如《阿甘正传》导演栏为"Robert Zemeckis"(英文),而《霸王别姬》为"陈凯歌"(中文),混合存储导致director列 dtype 为object,无法直接value_counts();又如《这个杀手不太冷》年份显示为1994-09-14,int()强转会报错。清洗必须分步、可回溯:
# 步骤1:统一空值为 NaN,便于后续 dropna 或 fillna df_clean = df_raw.replace('', np.nan) # 步骤2:修复评分:过滤掉非数字、<0 或 >10 的值(豆瓣评分为 0-10) df_clean['rating'] = pd.to_numeric(df_clean['rating'], errors='coerce') df_clean = df_clean[(df_clean['rating'] >= 0) & (df_clean['rating'] <= 10)] # 步骤3:修复年份:提取 4 位数字,过滤掉 0 或 >2030 的值 df_clean['year'] = df_clean['year'].astype(str).str.extract(r'(\d{4})').astype(float) df_clean = df_clean[(df_clean['year'] >= 1920) & (df_clean['year'] <= 2024)] # 步骤4:标准化导演/主演:去除前后空格、合并连续空格、过滤纯空格字符串 for col in ['director', 'starring']: if col in df_clean.columns: df_clean[col] = df_clean[col].astype(str).str.strip().str.replace(r'\s+', ' ', regex=True) df_clean = df_clean[df_clean[col] != ''] # 步骤5:生成唯一 ID,避免后续 merge 时索引错乱 df_clean = df_clean.reset_index(drop=True) df_clean['id'] = df_clean.index + 1参数说明:errors='coerce'是pd.to_numeric()的核心参数,它将无法转换的值设为NaN,而非抛异常;str.extract(r'(\d{4})')使用捕获组确保只取第一个 4 位数字,避免1994-09-14被误判为19940914;str.replace(r'\s+', ' ', regex=True)将所有空白符(空格、制表符、换行)替换为单个空格,解决复制粘贴导致的格式污染。
3.3 用explode()拆解类型与主演:为多对多关系建模铺路
可视化时,我们常需回答:“爱情类电影平均评分多少?”、“张国荣参演过几部 Top250?”——这要求将genre和starring_list这类“一部电影对应多个类型/主演”的字段展开为多行:
# 展开类型列,生成“电影-类型”关系表 df_genre = df_clean.explode('genre').dropna(subset=['genre']) df_genre['genre'] = df_genre['genre'].str.strip() # 展开主演列,生成“电影-主演”关系表 df_starring = df_clean.explode('starring_list').dropna(subset=['starring_list']) df_starring['starring'] = df_starring['starring_list'].str.strip() df_starring = df_starring.drop(columns=['starring_list']) # 验证展开结果 print(f"原始电影数:{len(df_clean)}") print(f"类型关系行数:{len(df_genre)}(应 ≥ 原始数)") print(f"主演关系行数:{len(df_starring)}(应 ≥ 原始数)")注意:
explode()要求列内元素为list或tuple。若starring_list是字符串(如"张国荣, 张丰毅"),需先str.split();若已是列表但含NaN,explode()会保留该行并设为NaN,故需dropna()清理。
4. 用 matplotlib + seaborn 绘制四类高信息密度图表:告别“柱状图+饼图”模板
4.1 年份分布直方图:用plt.hist()替代sns.countplot(),控制 bin 边界防误导
countplot()对年份这种离散数值会默认按整数分桶,导致 1994 和 1995 被分到不同 bin,而实际我们关心的是“90年代”、“00年代”这种区间。plt.hist()可精确控制 bin 边界:
import matplotlib.pyplot as plt import seaborn as sns plt.figure(figsize=(10, 6)) # 设置年份区间:1920-1939, 1940-1959, ..., 2020-2024 bins = [1920, 1940, 1960, 1980, 2000, 2020, 2025] plt.hist(df_clean['year'], bins=bins, edgecolor='black', alpha=0.7, color='#4A90E2') # 自定义 x 轴标签为区间文字 plt.xticks([(bins[i]+bins[i+1])/2 for i in range(len(bins)-1)], ['1920s', '1940s', '1960s', '1980s', '2000s', '2020s']) plt.xlabel('年代区间') plt.ylabel('电影数量') plt.title('豆瓣 Top250 电影出品年代分布') plt.grid(axis='y', alpha=0.3) plt.show()为什么不用countplot?因为countplot(x='year')会为每个具体年份(1994, 1995, ...)生成一个柱子,250 部电影分散在 100 多个年份上,图表密密麻麻无法阅读。hist()的区间聚合是业务需求的本质。
4.2 类型热度词云图:用wordcloud+jieba处理中文分词,过滤停用词
豆瓣类型字段(如"剧情 | 爱情 | 同性")是人工标注的规范词,无需复杂 NLP,但需去重、计数、过滤低频噪声:
from wordcloud import WordCloud import jieba # 统计类型频次 genre_freq = df_genre['genre'].value_counts().to_dict() # 过滤掉出现少于 3 次的类型(如“惊悚”仅出现 2 次,画出来是噪音) genre_freq_filtered = {k: v for k, v in genre_freq.items() if v >= 3} # 生成词云 wc = WordCloud( font_path='simhei.ttf', # 中文字体路径,Windows 下通常为 simhei.ttf width=800, height=400, background_color='white', max_words=100, colormap='viridis' ) wc.generate_from_frequencies(genre_freq_filtered) plt.figure(figsize=(12, 6)) plt.imshow(wc, interpolation='bilinear') plt.axis('off') plt.title('豆瓣 Top250 电影类型热度词云(出现≥3次)') plt.show()提示:
font_path='simhei.ttf'是关键。若不指定中文字体,词云会显示为方块。Linux/macOS 用户需下载simhei.ttf并指定绝对路径,或用font_path='/System/Library/Fonts/PingFang.ttc'(macOS)。
4.3 导演-作品数气泡图:用plt.scatter()编码三个维度,避免seaborn.scatterplot()的图例混乱
我们想同时展示:导演姓名(x轴)、作品数(y轴)、代表作评分均值(气泡大小)、所属国家(颜色)。seaborn.scatterplot()的size参数对离散计数支持差,且图例难以自定义。plt.scatter()更可控:
# 计算每位导演的作品数和平均评分 director_stats = df_clean.groupby('director').agg( count=('id', 'count'), avg_rating=('rating', 'mean') ).reset_index().sort_values('count', ascending=False).head(20) # 取前20 # 为气泡大小缩放:避免过小(<10)或过大(>1000) director_stats['size'] = (director_stats['count'] * 50).clip(50, 1000) plt.figure(figsize=(14, 8)) scatter = plt.scatter( x=director_stats['director'], y=director_stats['avg_rating'], s=director_stats['size'], c=director_stats['count'], cmap='plasma', alpha=0.7, edgecolors='black', linewidth=0.5 ) plt.xticks(rotation=45, ha='right') plt.xlabel('导演') plt.ylabel('平均评分') plt.title('Top20 导演作品数与平均评分(气泡大小=作品数×50)') plt.colorbar(scatter, label='作品数量') plt.grid(axis='y', alpha=0.3) plt.tight_layout() # 关键!防止 x 轴标签被截断 plt.show()逻辑说明:s=director_stats['size']直接传入数组,c=director_stats['count']控制颜色映射,cmap='plasma'提供高对比度色阶;tight_layout()是rotation=45的必加配套,否则标签会重叠或消失。
5. 避坑指南:爬取与可视化过程中的 4 个真实翻车现场与解法
5.1 现象:爬虫运行到第 5 页突然返回空列表,日志显示None
原因:豆瓣对同一 IP 的请求频率有限制,连续请求间隔小于 1 秒即触发临时封禁(返回 403 或空内容)。time.sleep(1)不够,因网络延迟波动大,实际间隔可能 <0.8 秒。
解决:在每次请求后强制休眠 1.5~2.5 秒,并加入随机抖动避免规律性被识别:
import time import random # 在 fetch_page 函数调用后添加 time.sleep(1.5 + random.uniform(0, 1)) # 1.5~2.5 秒随机休眠5.2 现象:genre字段解析出[‘剧情’, ‘\xa0爱情’],'\xa0'导致value_counts()统计错误
原因:\xa0是不间断空格(non-breaking space),strip()无法清除,re.split()也默认不匹配它。
解决:在清洗阶段统一替换所有 Unicode 空格为普通空格:
df_clean['genre'] = df_clean['genre'].str.replace(r'[\xa0\u3000]', ' ', regex=True) df_clean['genre'] = df_clean['genre'].str.split(r'[,,|]').apply( lambda x: [g.strip() for g in x if g.strip()] )5.3 现象:词云图中“爱情”和“爱情片”被当成两个词,重复出现
原因:原始数据中类型字段不规范,部分条目写"爱情",部分写"爱情片",value_counts()视为不同键。
解决:建立类型映射字典,在清洗时标准化:
genre_mapping = { '爱情': '爱情', '爱情片': '爱情', '浪漫': '爱情', '剧情': '剧情', '剧情片': '剧情', '喜剧': '喜剧', '搞笑': '喜剧', '动作': '动作', '动作片': '动作' } df_clean['genre_main'] = df_clean['genre'].map(genre_mapping).fillna(df_clean['genre'])5.4 现象:气泡图 x 轴导演名字重叠严重,rotation=45后仍看不清
原因:plt.xticks()默认只显示部分刻度,rotation仅影响已显示的标签。当导演数 >15,matplotlib 自动稀疏刻度,导致中间名字消失。
解决:强制显示所有 x 刻度,并调整 figure 宽度:
plt.xticks(range(len(director_stats)), director_stats['director'], rotation=45, ha='right') plt.xlim(-0.5, len(director_stats)-0.5) # 确保左右留白 # 同时增大 figsize 宽度:figsize=(16, 8)6. 进阶技巧:用plotly实现交互式导演关系图谱,让毕设答辩多一个“哇”时刻
静态图表能讲清结论,但交互式图谱能让评委直观看到“张艺谋→《红高粱》→巩俐→《霸王别姬》→陈凯歌”这样的隐含路径。plotly的nx集成方案比pyecharts更轻量、更易嵌入 Jupyter:
6.1 构建导演-主演二分图:用 NetworkX 抽取核心关系
我们不画全量关系(太密),只取 Top250 中合作次数 ≥2 的导演-主演对:
import networkx as nx import plotly.graph_objects as go # 从 df_starring 中提取导演-主演对 df_director_actor = df_clean[['director', 'starring_list']].explode('starring_list') df_director_actor = df_director_actor.dropna() df_director_actor['starring'] = df_director_actor['starring_list'].str.strip() df_director_actor = df_director_actor.drop(columns=['starring_list']) # 统计合作频次 edge_counts = df_director_actor.groupby(['director', 'starring']).size().reset_index(name='weight') # 过滤高频合作(≥2 次) edge_counts = edge_counts[edge_counts['weight'] >= 2] # 构建图 G = nx.Graph() for _, row in edge_counts.iterrows(): G.add_edge(row['director'], row['starring'], weight=row['weight']) # 只取最大连通子图,避免孤立节点干扰布局 largest_cc = max(nx.connected_components(G), key=len) G_sub = G.subgraph(largest_cc).copy()6.2 用plotly渲染力导向布局:节点大小=中心性,边粗细=合作次数
networkx.spring_layout()生成坐标,plotly绘制散点+连线,全程无需服务器:
# 计算节点中心性(衡量影响力) centrality = nx.betweenness_centrality(G_sub) # 计算节点度(合作导演/主演数) degree = dict(G_sub.degree()) # 生成布局坐标 pos = nx.spring_layout(G_sub, seed=42, k=3, iterations=50) # 提取节点坐标 node_x, node_y, node_text, node_size, node_color = [], [], [], [], [] for node in G_sub.nodes(): x, y = pos[node] node_x.append(x) node_y.append(y) node_text.append(node) # 节点大小 = 中心性 × 10000(放大可视化) node_size.append(centrality[node] * 10000 + 20) # 颜色 = 度数(区分导演/主演) node_color.append('red' if node in df_clean['director'].values else 'blue') # 提取边坐标 edge_x, edge_y = [], [] for edge in G_sub.edges(): x0, y0 = pos[edge[0]] x1, y1 = pos[edge[1]] edge_x.extend([x0, x1, None]) edge_y.extend([y0, y1, None]) # 绘制边(灰色细线) edge_trace = go.Scatter( x=edge_x, y=edge_y, line=dict(width=0.5, color='#888'), hoverinfo='none', mode='lines' ) # 绘制节点(红=导演,蓝=主演) node_trace = go.Scatter( x=node_x, y=node_y, mode='markers+text', hoverinfo='text', text=node_text, textposition="top center", marker=dict( showscale=True, colorscale='RdBu', size=node_size, color=node_color, line_width=2 ) ) fig = go.Figure(data=[edge_trace, node_trace], layout=go.Layout( title='豆瓣 Top250 导演-主演合作网络(合作≥2次)', showlegend=False, hovermode='closest', margin=dict(b=20,l=5,r=5,t=40), xaxis=dict(showgrid=False, zeroline=False, showticklabels=False), yaxis=dict(showgrid=False, zeroline=False, showticklabels=False) )) fig.show() # 在 Jupyter 中直接显示交互图效果:鼠标悬停显示人名,拖拽旋转视角,滚轮缩放。红色大节点是核心导演(如张艺谋、陈凯歌),蓝色大节点是高频主演(如巩俐、梁朝伟),线条粗细直观反映合作紧密度。答辩时一句“您可以看到,巩俐是连接张艺谋和陈凯歌的关键节点”,瞬间提升技术深度感。
我带过的某高校毕业设计中,有位同学用这套流程做了《豆瓣影评情感分析》,在答辩最后 30 秒切出这个交互图谱,评委当场追问“这个布局算法参数怎么调的”,他答出k=3和iterations=50的作用,顺利拿到优秀。技术不在于多炫,而在于你是否真的踩过坑、调过参、懂它为什么这样动。希望帮到你。
本文还有配套的精品资源,点击获取