简介:本资源是一套完整的本科毕业设计项目实现,面向大数据专业学生及Spark初学者,聚焦音乐平台真实场景的数据分析实践。项目基于Apache Spark构建分布式分析流水线,覆盖用户行为、歌曲热度、群体画像、时段偏好与评论情感五大分析方向,可直接用于课程设计、毕设开题与工程复现。压缩包共404个文件,9.67MB,包含123个Java/Scala核心业务代码(含Spark SQL与Streaming模块)、56个JS+36个HTML前端可视化页面、35个PNG/JPG图表素材,以及log4j、Flume、Bootstrap等配套配置与样式资源,结构清晰,模块解耦明确。已有2604人学习下载,提供从数据采集、清洗、计算到Web展示的全链路代码与配置,附带AmazeUI/Font Awesome等成熟前端组件集成方案,便于快速部署与二次开发。
1. 毕业设计真能跑通 Spark 分析网易云音乐数据?别被“爬虫+Spark”标题骗了:这其实是数据工程能力的完整闭环检验
很多同学看到“基于Spark网易云音乐数据分析”这个毕业设计标题,第一反应是:爬点歌单、用Spark SQL查个播放量TOP10、画个词云交差。但真实落地时,90%的人卡在第三步——数据根本进不了Spark。不是因为不会写sc.textFile(),而是因为原始数据压根没清洗成可计算形态:歌单ID混着用户ID、评论时间戳格式不统一、歌手字段里塞着“/”分隔的多人名、甚至JSON嵌套三层还带HTML转义字符。这不是Spark的问题,是数据管道没建起来。这个选题真正考验的,是能否把一个非结构化、高噪声、强时效性的互联网公开数据流,通过合理分层(原始层→清洗层→主题层→应用层),变成可复用、可验证、可回溯的分析资产。适合想扎实掌握大数据开发全流程的本科生,尤其适合那些简历上写着“熟悉Hadoop生态”,但连spark-submit --jars加依赖都配错三次的同学。它不追求模型多炫,而要求每一步都有日志、有校验、有退路——比如某次ETL任务失败后,5分钟内能定位到是某条歌单的封面URL里多了个不可见的零宽空格。
2. 从网页源码到DataFrame:三步构建可复用的网易云音乐数据采集链路
2.1 为什么不用Selenium?用Requests+BeautifulSoup+正则组合拳才是毕业设计的务实选择
毕业设计不是工业级爬虫项目,不需要模拟登录、处理滑块验证码或应对JS渲染。网易云音乐的歌单页、歌曲详情页、热门评论页,其核心数据(歌单名、创建者、歌曲列表、评论内容、点赞数)全部存在于HTML静态响应中。Selenium启动浏览器、等待渲染、管理驱动版本,对本地调试极其不友好——你改一行XPath,等Chrome加载完再看报错,节奏全乱。而Requests+BS4组合,配合requests.adapters.HTTPAdapter设置重试策略和连接池,5分钟就能跑通一页。关键代码如下:
import requests from bs4 import BeautifulSoup import time import random def fetch_playlist_page(playlist_id: str, headers: dict) -> str: url = f"https://music.163.com/playlist?id={playlist_id}" session = requests.Session() # 复用连接,避免TIME_WAIT堆积 adapter = requests.adapters.HTTPAdapter(max_retries=3, pool_connections=10, pool_maxsize=10) session.mount('https://', adapter) try: resp = session.get(url, headers=headers, timeout=10) resp.raise_for_status() # 网易云有反爬,必须加随机延迟,否则IP被限速 time.sleep(random.uniform(0.8, 1.5)) return resp.text except Exception as e: print(f"获取歌单 {playlist_id} 失败: {e}") return "" # headers 必须包含 Referer 和 User-Agent,否则返回 403 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", "Referer": "https://music.163.com/" }提示:
Referer是硬性要求,网易云会校验来源页。漏掉这行,所有请求返回空HTML。User-Agent建议用最新版Chrome,避免被识别为爬虫。
2.2 解析歌单页:用正则提取JSON数据比XPath更稳,因为网易云把结构化数据藏在<script>里
翻看网易云歌单页源码,你会发现页面主体是空的<div id="g_main">,真正的数据藏在<script>标签里的一段JavaScript变量赋值中,形如var playlist = { "id": 123, "name": "..."};。用XPath定位<div class="u-cover u-cover-1">下的文本,极易因前端微调而失效;而正则匹配var playlist = (.*?);,只要变量名不变,就稳如磐石。实测对比:XPath在网易云2023年Q4前端重构后全部失效,正则方案零修改继续运行。
import re import json def parse_playlist_html(html: str) -> dict: # 匹配 var playlist = {...}; 中的 JSON 字符串 pattern = r'var\s+playlist\s*=\s*(\{.*?\});' match = re.search(pattern, html, re.DOTALL) if not match: return {} try: # 去除注释、修复末尾逗号(部分版本JS有语法错误) json_str = re.sub(r'//.*?$', '', match.group(1), flags=re.MULTILINE) json_str = re.sub(r',\s*}', '}', json_str) # 修复非法结尾逗号 return json.loads(json_str) except json.JSONDecodeError as e: print(f"JSON解析失败: {e}") return {} # 示例调用 html = fetch_playlist_page("23456789", HEADERS) playlist_data = parse_playlist_html(html) print(f"歌单名: {playlist_data.get('name')}, 歌曲数: {playlist_data.get('trackCount')}")逻辑说明:re.DOTALL让.匹配换行符,确保跨行JSON能捕获;json.loads()前先做两处容错:删JS单行注释、修结尾逗号——这是网易云前端工程师留下的“彩蛋”,不处理就会JSONDecodeError。参数playlist_data字典结构稳定,含tracks键(歌曲列表)、creator键(创建者信息)、tags键(歌单标签),直接可转为Pandas DataFrame。
2.3 构建最小可行数据集:只抓10个高质量歌单,胜过1000个脏数据
毕业设计不是数据竞赛,数据质量>数据量。盲目扩大爬取范围,只会让后续清洗工作爆炸式增长。我的做法是:人工筛选10个典型歌单——3个华语热歌榜(含周杰伦、陈绮贞、新裤子乐队)、3个小众独立音乐(实验电子、后摇、City Pop)、2个语种混合(日语动漫OST、韩语K-Pop)、2个场景化歌单(“咖啡馆背景音”“深夜emo专用”)。理由很实在:覆盖不同数据模式(中文名/外文名/混合名、单歌手/多歌手/乐队名、标签丰富/标签稀疏),且每个歌单歌曲数控制在50首以内,总数据量约500条,本地CSV仅2MB,Spark本地模式秒级完成测试。记住:毕业答辩时,老师问“你如何保证数据代表性”,你指着这10个歌单的命名逻辑和覆盖维度,比说“我爬了10万条”有力十倍。
3. 数据清洗与分层建模:用PySpark把脏数据炼成分析燃料
3.1 清洗层(Clean Layer):用DataFrame API做原子化操作,拒绝UDF黑匣子
很多同学一上来就写UDF(用户自定义函数)处理歌手名分割,结果性能暴跌、调试困难。PySpark的内置函数足够应付90%清洗需求。以“歌手字段含‘/’分隔多人”为例,正确姿势是:
from pyspark.sql import SparkSession from pyspark.sql.functions import col, split, explode, trim, when, regexp_replace, size spark = SparkSession.builder \ .appName("NeteaseMusicClean") \ .master("local[*]") \ .getOrCreate() # 假设原始DF有列:song_id, song_name, artists, album_name df_raw = spark.read.csv("data/raw/songs.csv", header=True, inferSchema=True) # 步骤1:清理artists字段——去空格、去HTML实体、标准化分隔符 df_cleaned = df_raw.withColumn( "artists_clean", trim(regexp_replace(col("artists"), r" |
", " ")) # 替换HTML空格和换行 ).withColumn( "artists_clean", regexp_replace(col("artists_clean"), r"\s+/\s+", "/") # 统一分隔符为单斜杠 ) # 步骤2:拆分为多行(一对多),每行一个歌手 df_exploded = df_cleaned.withColumn( "artist_list", split(col("artists_clean"), "/") ).withColumn( "artist", explode(col("artist_list")) ).withColumn( "artist", trim(col("artist")) # 再次去首尾空格 ).filter(col("artist") != "") # 过滤空歌手 # 步骤3:处理专辑名中的括号和年份(如“范特西 (2001)” → “范特西”) df_final = df_exploded.withColumn( "album_name_clean", regexp_replace(col("album_name"), r"\s*\(\d{4}\)\s*", "") ).withColumn( "album_name_clean", trim(col("album_name_clean")) )参数说明:split(..., "/")按斜杠切分,explode()将数组展开为多行,trim()防一手中间空格。全程无Python循环、无UDF,执行计划清晰可见,Shuffle可控。若强行用UDF,pandas_udf需序列化/反序列化,udf在JVM侧调用Python进程,小数据集都慢半拍,答辩演示时卡顿就是事故。
3.2 主题层(Theme Layer):构建“歌曲-歌手-歌单”星型模型,为分析打地基
清洗后的数据是扁平的,但分析需要关联。例如“周杰伦的歌在哪些歌单里被收藏最多”,就需要歌曲、歌手、歌单三张表关联。我们建三张表:
| 表名 | 主键 | 关键字段 | 存储路径 |
|---|---|---|---|
dim_song | song_id | song_name,album_name_clean,duration_ms | data/dim/song/ |
dim_artist | artist_id(MD5(artist)) | artist_name,artist_type(主唱/伴唱/乐队) | data/dim/artist/ |
fact_playlist_song | (playlist_id, song_id) | add_time,position_in_playlist | data/fact/playlist_song/ |
建模逻辑:dim_artist用md5(artist)作主键,避免中文名重复(如“张楚”和“张楚(歌手)”);fact_playlist_song不存歌单名,只存ID和关系,解耦维度;所有表用Parquet格式存储,压缩率高、谓词下推快。代码示例(生成dim_artist):
from pyspark.sql.functions import md5, lower, when # 从df_exploded中提取唯一歌手 df_artist = df_exploded.select("artist").distinct() \ .withColumn("artist_id", md5(lower(col("artist")))) \ .withColumn("artist_type", when(col("artist").contains("合唱"), "chorus") .when(col("artist").contains("乐队"), "band") .otherwise("solo")) df_artist.write.mode("overwrite").parquet("data/dim/artist/")注意:
md5(lower())确保大小写不敏感去重;artist_type用when/otherwise而非UDF,保持SQL优化器可见性。
3.3 应用层(Application Layer):用Spark SQL写分析脚本,比DataFrame API更贴近业务语言
毕业设计答辩时,老师更愿听你讲“我分析了用户收藏行为”,而不是“我用了join()和groupBy()”。把分析逻辑写成SQL,可读性、可维护性、可解释性拉满。例如“各语种歌单的平均歌曲时长分布”:
-- 文件: sql/analysis_lang_duration.sql WITH lang_tag AS ( SELECT playlist_id, CASE WHEN tags LIKE '%日语%' OR tags LIKE '%J-POP%' THEN 'Japanese' WHEN tags LIKE '%韩语%' OR tags LIKE '%K-POP%' THEN 'Korean' WHEN tags LIKE '%英语%' OR tags LIKE '%英文%' THEN 'English' ELSE 'Chinese' END AS lang FROM dim_playlist ), playlist_avg AS ( SELECT l.lang, AVG(s.duration_ms) / 1000.0 AS avg_duration_sec FROM fact_playlist_song f JOIN lang_tag l ON f.playlist_id = l.playlist_id JOIN dim_song s ON f.song_id = s.song_id GROUP BY l.lang ) SELECT * FROM playlist_avg ORDER BY avg_duration_sec DESC;执行方式:
spark-sql -f sql/analysis_lang_duration.sql -o result/lang_duration.csv优势:SQL天然支持CTE、窗口函数、复杂CASE,业务逻辑一目了然;.sql文件可单独测试、版本管理;答辩时直接贴SQL,老师扫一眼就懂你在算什么。
4. 避坑指南:那些让我重跑3遍Spark任务的血泪经验
4.1 现象:spark-submit本地运行正常,集群提交后java.lang.ClassNotFoundException: org.jsoup.Jsoup
原因:Jsoup是解析HTML必需的jar包,本地模式(local[*])自动加载$SPARK_HOME/jars/下的jar,但YARN集群模式默认不传。--jars参数必须显式指定,且路径要是HDFS或HTTP可访问地址。
解决:
- 将
jsoup-1.17.2.jar上传至HDFS:hdfs dfs -put jsoup-1.17.2.jar /lib/ - 提交命令加
--jars hdfs:///lib/jsoup-1.17.2.jar - 更稳妥做法:用
--packages org.jsoup:jsoup:1.17.2,Spark自动下载(需集群能联网)
4.2 现象:清洗后artist字段出现``乱码,且数量随数据量增大而增多
原因:网易云部分歌单页响应头声明charset=GBK,但实际内容是UTF-8。Requests默认按响应头解码,导致中文变``。
解决:强制指定编码:
resp = session.get(url, headers=headers, timeout=10) resp.encoding = 'utf-8' # 覆盖响应头声明 html = resp.text4.3 现象:fact_playlist_song表中playlist_id为空,导致后续JOIN全丢弃
原因:爬取时部分歌单页<script>里playlist.id字段缺失(如私密歌单),parse_playlist_html()返回空字典,song_id等字段全为None,写入Parquet后变NULL。
解决:清洗层加强校验,在df_raw读入后立即过滤:
df_raw = df_raw.filter(col("playlist_id").isNotNull() & (col("playlist_id") != ""))4.4 现象:spark-sql执行COUNT(*)极慢,EXPLAIN显示全表Scan未走分区剪枝
原因:Parquet表未按常用过滤字段(如date_partition)分区,物理文件无目录结构。
解决:写入时显式分区:
df_cleaned.write \ .mode("overwrite") \ .partitionBy("date_partition") \ # date_partition为字符串列,如"20240315" .parquet("data/clean/songs/")查询时加WHERE date_partition = '20240315',Spark自动跳过其他分区目录。
4.5 现象:本地spark-shell能跑通,IDEA里spark-submit报NoClassDefFoundError: scala/Product
原因:Scala版本冲突。Spark 3.4+用Scala 2.13,而你的项目pom.xml可能引了Scala 2.12的库。
解决:统一Scala版本,在pom.xml中:
<properties> <scala.version>2.13.12</scala.version> </properties> <dependencies> <dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-sql_2.13</artifactId> <version>3.4.1</version> </dependency> </dependencies>注意spark-sql_2.13的_2.13后缀必须匹配。
5. 让分析结果“活”起来:用轻量级Web服务暴露Spark计算结果
5.1 为什么不用Flask+Spark Context?用REST API桥接更安全、更解耦
有人想在Flask路由里直接调spark.sql(),这是大忌。SparkContext是线程不安全的,Web服务器多线程并发请求会引发状态混乱;且Web进程常驻,Spark资源无法及时释放。正确做法:Spark预计算结果存入轻量数据库(SQLite),Web服务只读取。SQLite单文件、零配置、ACID,完美匹配毕业设计——result.db就是一个文件,答辩拷贝走即可。
# job/export_to_sqlite.py:每日定时导出分析结果 import sqlite3 from pyspark.sql import SparkSession spark = SparkSession.builder.appName("ExportToSQLite").getOrCreate() conn = sqlite3.connect("result.db") # 导出“各语种平均时长”结果 df_result = spark.sql("SELECT lang, avg_duration_sec FROM ...") df_result.toPandas().to_sql("lang_duration", conn, if_exists="replace", index=False) conn.close()5.2 用FastAPI写一个30行接口,让老师扫码看图表
FastAPI比Flask更现代、自动生成文档、异步友好。核心接口只需3个:
GET /api/lang-duration:返回JSON数据GET /api/top-songs:返回播放量TOP10GET /:返回Vue前端(单HTML文件,内联Chart.js)
# api/main.py from fastapi import FastAPI from fastapi.responses import HTMLResponse, JSONResponse import sqlite3 import json app = FastAPI() @app.get("/api/lang-duration") def get_lang_duration(): conn = sqlite3.connect("result.db") cur = conn.cursor() cur.execute("SELECT lang, avg_duration_sec FROM lang_duration ORDER BY avg_duration_sec DESC") rows = cur.fetchall() conn.close() return JSONResponse(content=[{"lang": r[0], "avg_sec": r[1]} for r in rows]) @app.get("/", response_class=HTMLResponse) def read_root(): with open("static/index.html", "r", encoding="utf-8") as f: return HTMLResponse(content=f.read(), status_code=200)前端static/index.html用Chart.js画柱状图,数据通过fetch("/api/lang-duration")加载。部署只需uvicorn api.main:app --host 0.0.0.0 --port 8000,老师手机扫码即看,比截图PPT高级十倍。
5.3 答辩现场应急技巧:当Spark任务卡住,5分钟内救场的3个命令
毕业答辩演示最怕卡死。我备了三招:
- 查进度:
curl http://localhost:4040/api/v1/applications/获取当前App ID,再查/applications/{app-id}/stages看Stage卡在哪; - 杀任务:
yarn application -kill {app-id}(YARN模式)或kill -9 {pid}(本地模式); - 切降级数据:提前准备
data/sample/小数据集(100行),演示脚本里加开关:if os.getenv("DEMO_MODE"): df = spark.read.csv("data/sample/songs.csv") else: df = spark.read.parquet("data/clean/songs/")
最后说句实在话:这个选题的价值,不在于你做出多惊艳的结论,而在于你能否把“数据从网页到图表”的每一步,都解释清楚为什么这么做、不那么做会怎样。我带过的某高校毕业设计,学生答辩时被问“为什么用Parquet不用CSV”,他答:“CSV没Schema,每次读都要infer,耗时且不准;Parquet自带Schema,还能列裁剪,我查10个字段只读2个,快3倍。”——老师当场点头。这种细节里的确定性,才是工程师的底气。希望帮到你。
本文还有配套的精品资源,点击获取