
简介本资源是一套面向本科毕业生的数据挖掘与可视化综合实践项目聚焦豆瓣电影数据的采集、清洗与多维呈现适用于Python初学者进阶实战及毕业设计参考。项目完整实现从网页爬虫RequestsBeautifulSoup、数据清洗Pandas基础处理、到前端可视化EChartsBootstrap的全流程涵盖数据获取、分析逻辑与交互展示三大核心环节。压缩包共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(rotation45)和plt.tight_layout()的调用顺序有血泪经验。适合想用一个真实、可验证、有业务语义的数据源把 Python 数据分析栈从“会写 for 循环”推到“能交付可维护脚本”的同学。本文所有代码均基于豆瓣公开页面非登录态、非接口调用适配 2024 年主流反爬水位不依赖任何第三方代理或破解工具。2. 用 requests BeautifulSoup 在本地跑通豆瓣 Top250 最小可行爬取绕过基础 UA 拦截与分页陷阱2.1 爬取目标锁定为什么只抓 Top250 页面且必须放弃“全部电影”幻想豆瓣电影 Top250 是一个静态 HTML 列表页URL 形如https://movie.douban.com/top250?start0filter每页 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/xhtmlxml而不能是*/*Accept-Language若为zh-CN,zh;q0.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/xhtmlxml,application/xml;q0.9,image/avif,image/webp,image/apng,*/*;q0.8,application/signed-exchange;vb3;q0.7, Accept-Language: zh-CN,zh;q0.9,en;q0.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, headersheaders, timeout10) 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 classitem内该 class 十年未变是比id或>def parse_movie_item(soup: BeautifulSoup) - list: items [] for item_div in soup.select(div.item): # 先抓容器 # 标题位于 div classinfo div classhd a span classtitle title_tag item_div.select_one(div.info div.hd a span.title) title title_tag.get_text(stripTrue) if title_tag else # 评分位于 div classinfo div classbd div classstar span classrating_num rating_tag item_div.select_one(div.info div.bd div.star span.rating_num) rating float(rating_tag.get_text(stripTrue)) if rating_tag and rating_tag.get_text(stripTrue).replace(., ).isdigit() else 0.0 # 导演与主演位于 div classinfo div classbd 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(stripTrue) 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 classinfo div classbd p class 的第二行含 nbsp; 分隔 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(stripTrue) # 提取年份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?start0filter) 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 将原始字典列表转为 DataFramepd.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], # 保留的顶层字段 errorsignore ) # 但 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-14int()强转会报错。清洗必须分步、可回溯# 步骤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], errorscoerce) 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, , regexTrue) df_clean df_clean[df_clean[col] ! ] # 步骤5生成唯一 ID避免后续 merge 时索引错乱 df_clean df_clean.reset_index(dropTrue) df_clean[id] df_clean.index 1参数说明errorscoerce是pd.to_numeric()的核心参数它将无法转换的值设为NaN而非抛异常str.extract(r(\d{4}))使用捕获组确保只取第一个 4 位数字避免1994-09-14被误判为19940914str.replace(r\s, , regexTrue)将所有空白符空格、制表符、换行替换为单个空格解决复制粘贴导致的格式污染。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()若已是列表但含NaNexplode()会保留该行并设为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], binsbins, edgecolorblack, alpha0.7, color#4A90E2) # 自定义 x 轴标签为区间文字 plt.xticks([(bins[i]bins[i1])/2 for i in range(len(bins)-1)], [1920s, 1940s, 1960s, 1980s, 2000s, 2020s]) plt.xlabel(年代区间) plt.ylabel(电影数量) plt.title(豆瓣 Top250 电影出品年代分布) plt.grid(axisy, alpha0.3) plt.show()为什么不用countplot因为countplot(xyear)会为每个具体年份1994, 1995, ...生成一个柱子250 部电影分散在 100 多个年份上图表密密麻麻无法阅读。hist()的区间聚合是业务需求的本质。4.2 类型热度词云图用wordcloudjieba处理中文分词过滤停用词豆瓣类型字段如剧情 | 爱情 | 同性是人工标注的规范词无需复杂 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_pathsimhei.ttf, # 中文字体路径Windows 下通常为 simhei.ttf width800, height400, background_colorwhite, max_words100, colormapviridis ) wc.generate_from_frequencies(genre_freq_filtered) plt.figure(figsize(12, 6)) plt.imshow(wc, interpolationbilinear) plt.axis(off) plt.title(豆瓣 Top250 电影类型热度词云出现≥3次) plt.show()提示font_pathsimhei.ttf是关键。若不指定中文字体词云会显示为方块。Linux/macOS 用户需下载simhei.ttf并指定绝对路径或用font_path/System/Library/Fonts/PingFang.ttcmacOS。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, ascendingFalse).head(20) # 取前20 # 为气泡大小缩放避免过小10或过大1000 director_stats[size] (director_stats[count] * 50).clip(50, 1000) plt.figure(figsize(14, 8)) scatter plt.scatter( xdirector_stats[director], ydirector_stats[avg_rating], sdirector_stats[size], cdirector_stats[count], cmapplasma, alpha0.7, edgecolorsblack, linewidth0.5 ) plt.xticks(rotation45, haright) plt.xlabel(导演) plt.ylabel(平均评分) plt.title(Top20 导演作品数与平均评分气泡大小作品数×50) plt.colorbar(scatter, label作品数量) plt.grid(axisy, alpha0.3) plt.tight_layout() # 关键防止 x 轴标签被截断 plt.show()逻辑说明sdirector_stats[size]直接传入数组cdirector_stats[count]控制颜色映射cmapplasma提供高对比度色阶tight_layout()是rotation45的必加配套否则标签会重叠或消失。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 spacestrip()无法清除re.split()也默认不匹配它。解决在清洗阶段统一替换所有 Unicode 空格为普通空格df_clean[genre] df_clean[genre].str.replace(r[\xa0\u3000], , regexTrue) 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 轴导演名字重叠严重rotation45后仍看不清原因plt.xticks()默认只显示部分刻度rotation仅影响已显示的标签。当导演数 15matplotlib 自动稀疏刻度导致中间名字消失。解决强制显示所有 x 刻度并调整 figure 宽度plt.xticks(range(len(director_stats)), director_stats[director], rotation45, haright) plt.xlim(-0.5, len(director_stats)-0.5) # 确保左右留白 # 同时增大 figsize 宽度figsize(16, 8)6. 进阶技巧用plotly实现交互式导演关系图谱让毕设答辩多一个“哇”时刻静态图表能讲清结论但交互式图谱能让评委直观看到“张艺谋→《红高粱》→巩俐→《霸王别姬》→陈凯歌”这样的隐含路径。plotly的nx集成方案比pyecharts更轻量、更易嵌入 Jupyter6.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(nameweight) # 过滤高频合作≥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], weightrow[weight]) # 只取最大连通子图避免孤立节点干扰布局 largest_cc max(nx.connected_components(G), keylen) 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, seed42, k3, iterations50) # 提取节点坐标 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( xedge_x, yedge_y, linedict(width0.5, color#888), hoverinfonone, modelines ) # 绘制节点红导演蓝主演 node_trace go.Scatter( xnode_x, ynode_y, modemarkerstext, hoverinfotext, textnode_text, textpositiontop center, markerdict( showscaleTrue, colorscaleRdBu, sizenode_size, colornode_color, line_width2 ) ) fig go.Figure(data[edge_trace, node_trace], layoutgo.Layout( title豆瓣 Top250 导演-主演合作网络合作≥2次, showlegendFalse, hovermodeclosest, margindict(b20,l5,r5,t40), xaxisdict(showgridFalse, zerolineFalse, showticklabelsFalse), yaxisdict(showgridFalse, zerolineFalse, showticklabelsFalse) )) fig.show() # 在 Jupyter 中直接显示交互图效果鼠标悬停显示人名拖拽旋转视角滚轮缩放。红色大节点是核心导演如张艺谋、陈凯歌蓝色大节点是高频主演如巩俐、梁朝伟线条粗细直观反映合作紧密度。答辩时一句“您可以看到巩俐是连接张艺谋和陈凯歌的关键节点”瞬间提升技术深度感。我带过的某高校毕业设计中有位同学用这套流程做了《豆瓣影评情感分析》在答辩最后 30 秒切出这个交互图谱评委当场追问“这个布局算法参数怎么调的”他答出k3和iterations50的作用顺利拿到优秀。技术不在于多炫而在于你是否真的踩过坑、调过参、懂它为什么这样动。希望帮到你。本文还有配套的精品资源点击获取