每年的听歌报告,Spotify 只给你看一张汇总页,但你有没有想过把原始数据抓下来自己算一遍?我试过之后最深的感受是:平台给的有限,自己用 Python 做出来的能多出不少东西,比如具体到每个小时的听歌习惯、播放时长和次数的真实占比、按音频特征聚类后的歌单逻辑。这篇文章就围绕如何分析 Spotify 听歌数据,完整讲清楚数据从哪来、怎么清洗、怎么画图、怎么用 API 补足细节,全程用中文讲解,代码可以直接抄。
我不打算把所有东西一次性铺开,而是按我自己做这个项目时的顺序来写:先明确要分析的问题,再拿数据、清洗数据,然后画图、调 API,最后聊聊踩过的坑。这篇文章适合刚学 pandas 想找真实数据练手的 Python 新手,也适合重度音乐用户想深入了解自己听歌习惯的人。核心分析逻辑不复杂,就是“数据获取—清洗—分组统计—可视化—特征补强”这条线。
1. 先把思路理清楚:数据藏在哪、怎么取、要分析什么
1.1 两种拿数据的姿势,各有各的局限
Spotify 听歌数据的获取主要有两条路,准确理解它们的区别,后面才不会白折腾。第一条是官方数据导出,登录账号后在隐私设置里申请“扩展流媒体历史”,官方会把你的播放记录打包成 JSON 文件发给你,这里有完整的流媒体历史,不只是最近几十首,而是从你开始使用起的所有记录。缺点是申请后会等几天,而且不能频繁重复申请。
第二条是 Web API 方式,用 Python 的 spotipy 库走 OAuth 授权,实时拉取最近播放记录、收藏歌曲、播放列表和音频特征。优点是立刻能拿、字段丰富,缺陷是拿不到完整的历史播放记录,最近播放接口通常只能拿到最近 50 首左右,根本没法做年度大盘分析。
我在实际项目中是两条腿走路:先用官方导出数据做整体统计,再通过 API 给重点歌曲补充音频特征。这样既有完整历史,又有更深的分析维度。如果只靠 API,你会发现辛苦写好的年度歌单统计缺了一大段数据;如果只用导出数据,你又拿不到类似 danceability、energy 这种反应歌曲“情绪”的细节字段。
| 数据来源 | 获取方式 | 覆盖范围 | 主要字段 | 时效性 |
|---|---|---|---|---|
| 官方导出 | 隐私设置申请,等几天 | 完整历史 | 播放时间、歌手、歌曲、播放毫秒数 | 离线,定期导出 |
| Web API | OAuth 授权实时拉取 | 近 50 首播放、收藏、歌单 | 歌曲信息、音频特征、播放上下文 | 实时,受频率限制 |
这里还要提示一下:导出文件一般会有多个 JSON 文件,常见命名是 StreamingHistory0.json、StreamingHistory1.json 这样的序号文件。不要只拿一个文件就开跑,否则你统计到的可能只是某一年或某一段时间的片段。
1.2 先想好要回答哪些问题
数据分析最忌讳一上来就写代码,我见过很多人刚拿到数据就开始画图,结果画了一大堆,一张有用的都没有。正确做法是先列问题清单,把“想知道什么”变成“用什么指标回答”。
我自己做这个项目时列的问题大概是这样的:我的总播放时长有多少、相当于听了多少天;播放次数最多的歌手是不是真的喜欢;哪些歌被我重复听了最多遍;周末和平时相比,听歌时长有没有明显变化;每天哪个时段是我最“沉浸”的时候;我的年度歌单整体是偏“燃”还是偏平静。这些问题每个都能对应一个统计指标,统计指标又能对应一段代码。
把问题定义清楚还有个好处,就是避免画图时没有边界。一次做一个图表,每张图都有明确的问题指向,做出来的东西就能直接用。这个习惯可以说适用于所有数据类个人项目,不只是 Spotify 数据。
2. 环境准备与原始数据获取:从申请导出到 API 凭证
2.1 环境装哪些东西
正式开始前先把环境准备好,我用的是 Python 3.10 以上的版本,配合 Jupyter 或 VS Code 都很顺手。核心的库只有五个:pandas 负责数据清洗和聚合,matplotlib 负责画图,spotipy 负责调 API,wordcloud 生成歌手词云,python-dotenv 用来保存密钥(如果不想用可以不装)。安装命令如下:
pip install pandas matplotlib spotipy wordcloud python-dotenv为什么选 pandas 而不是直接用 Excel?Excel 打开几十万行的 JSON 会很痛苦,而且时间处理、分组聚合这类操作在 pandas 里就是一两行代码的事。这个项目本质上是个人数据分析项目,用 pandas 是最省精力也最不容易翻车的组合。
2.2 从官方后台导出历史数据
申请导出的步骤不复杂,但很多人找不到入口。登录账号后进入隐私设置,找到数据下载相关的选项,申请一份“扩展流媒体历史”,里面会包含播放历史和部分操作记录。提交后不会立刻下载,官方会往邮箱发一封邮件告诉你准备好了,短则一两天、长则一周左右,按照邮件提示下载压缩包就可以了。
需要注意,这种申请通常有冷却期,不能为了测试反复提交。我在第一次做的时候申请完第二天就再点了一次,结果被后台拒绝了,提示要间隔一段时间。建议你第一次申请后就踏实等邮件,别重复操作。
下载得到的压缩包里除了多处播放历史 JSON 文件,有时还有播放列表等目录。如果你只关心播放历史,就把所有 StreamingHistory*.json 文件挑出来,先把它们放到一个独立文件夹,例如 audio_data 目录,方便后面用 glob 批量匹配。
文件内容长这样,每一条记录包含几个固定字段:
{ "endTime": "2024-11-15 22:13:45", "artistName": "Some Artist", "trackName": "A Certain Song", "msPlayed": 180123 }这里的 endTime 表示这首歌播放结束后被记录的时间点,msPlayed 是这条记录的播放毫秒数。你没有听错的含义,一条记录的时长可能因为跳过、暂停、切歌等原因小于歌曲实际长度。
2.3 申请 API 凭证,为后面的特征分析做准备
官方导出数据里没有歌曲 ID,也没有 danceability、valence 这些音频特征,所以后面在进阶分析部分必须用 API 补一节。申请 API 凭证的方式是进入 Spotify 官方开发者后台,创建一个应用,拿到 Client ID 和 Client Secret,并且设置一个 Redirect URI,我一般会写成:
http://localhost:8888/callback写的时候一定要把回调地址保存好,因为 OAuth 流程需要它。访问 API 时还需要明确 scope,也就是你申请的数据访问范围。我常用的 scope 是 "user-library-read user-read-recently-played user-top-read",代表可以读取收藏歌曲、最近播放和你的热门歌曲。不同 scope 对应的权限不同,后面如果有 403 或者权限不足报错,先检查 scope 写全了没有。
很多人在这一步被卡住,原因不是代码有问题,而是回调地址和 scope 不匹配。代码里写的 redirect_uri 必须和开发者后台填的一模一样,包括端口号,少一个字符都不行。这个坑我后面专门整理了一个排查清单。
3. 清洗和统计:把 JSON 变成能回答问题的 DataFrame
3.1 加载和合并多个 JSON 文件
导出数据是多文件结构,所以我第一步永远是先用 glob 把所有 StreamingHistory 文件找出来,再逐个读取合并。这样处理到最后得到一个完整的 DataFrame,后续所有统计都基于它执行。
import glob import pandas as pd files = glob.glob(r"audio_data/StreamingHistory*.json") print("文件数量:", len(files)) df_list = [] for f in files: tmp = pd.read_json(f) df_list.append(tmp) df = pd.concat(df_list, ignore_index=True) print(df.shape)如果文件特别多或者体积很大,建议在 read_json 时只读需要的列,或者用 chunksize 分批读取。个人项目的数据量一般不至于大到那个程度,但养成这个习惯没有坏处。
合并完可以先看一眼字段覆盖情况。有时某些条目可能缺失 artistName 或 trackName,比如某些现场录音或本地文件,建议统一填充为"未知",避免后续 groupby 时出现大量空值堆积。
3.2 时间处理:它是 UTC,不是你的本地时间
我第一次做这个项目时踩过一个很经典的时区坑。导出的 endTime 字段是 UTC 时间,不是本地时间。如果直接拿它统计 “我每天几点听歌最多”,结果会整体偏移,尤其在中国区会偏移 8 小时左右,原本晚上 10 点的沉浸听歌时间会被记成下午 2 点。
正确做法是先把 endTime 转成带时区的 datetime,再转换到本地时区,最后提取小时、星期几和月份字段。
df["endTime"] = pd.to_datetime(df["endTime"], utc=True) df["endTime_local"] = df["endTime"].dt.tz_convert("Asia/Shanghai") df["hour"] = df["endTime_local"].dt.hour df["weekday"] = df["endTime_local"].dt.dayofweek # 0=周一 df["month"] = df["endTime_local"].dt.month df["year"] = df["endTime_local"].dt.year df["date"] = df["endTime_local"].dt.date这里为什么强调 UTC 转换?因为如果时间字符串恰好不带时区标记,直接 tz_convert 会报错,先 utc=True 再转换是最稳妥的顺序。如果你身处其他时区,把 Asia/Shanghai 换成你所在城市的 IANA 时区名即可。
3.3 播放时长字段的坑
msPlayed 是以毫秒为单位的播放时长,第一步先转成分钟。但是真正会让分析结果变形的不是单位,而是怎么处理“短播放”数据。
什么算短播放?你搜到一首歌顺手点了进去,听了 10 秒就切走了,这种情况算你“喜欢”这首歌吗?很多人统计高频歌曲时会发现一些奇奇怪怪的歌排进前排,原因就是短播放没有过滤。但过滤阈值又不能拍脑袋定死,比如一首实验性后摇只有 30 秒,你却用 30 秒标准把它过滤掉了,逻辑上就反了。
我常用的做法是保留两套数据:一套全量原始数据,用来统计“曾播放过的作品”;另一套过滤掉 msPlayed 小于 30000 的记录,也就是播放不足 30 秒的算作“未完成或跳过”,专门用于统计有效播放。如果歌曲本身时长很短,30 秒阈值确实会误伤,我一般通过比较 trackDuration 字段来辅助判断,没有该字段就先接受 30 秒阈值带来的误差。
还有一点容易被忽略:排名应该基于播放时长总和,而不是播放次数。一首 8 分钟的长作品播放 3 次,和一首 2 分钟的短作品播放 12 次,播放次数相同吗?显然不对。用 msPlayed 总和能更真实反映你在一首歌或一位歌手身上投入的注意力时长。所以我的做法是先把播放毫秒数聚合为小时数,再用这个小时数排序。
3.4 第一版统计指标
数据清洗完就能做第一版统计了,这个阶段不用追求好看,先追求“能回答基础问题”。我先算总播放时长、总播放条数、艺术家数量、单首歌最大重复次数,然后看每天平均听歌时长。
total_hours = df["msPlayed"].sum() / 3600000 total_plays = len(df) total_days = df["date"].nunique() daily_avg_hours = total_hours / total_days print(f"总播放时长: {total_hours:.1f} 小时") print(f"总播放条数: {total_plays}") print(f"活跃天数: {total_days}") print(f"日均播放: {daily_avg_hours:.2f} 小时")然后是歌手排行和歌曲排行。这里我会把“有效播放”和“全量数据”对比着看,有时候你会惊讶地发现,某位你无感的歌手其实长期占领你的碎片时间。
artist_hours = ( df.groupby("artistName")["msPlayed"] .sum() .sort_values(ascending=False) .head(20) / 3600000 ) song_hours = ( df.groupby(["artistName", "trackName"])["msPlayed"] .sum() .sort_values(ascending=False) .head(20) / 3600000 )看到这里,第一部分分析已经基本成型,接下来就是把这些数字变成能看懂的图表。
4. 画图模块:制作自己的年度听歌报告
4.1 TOP 歌手和 TOP 歌曲横向条形图
画图之前先解决中文字体问题,这是 matplotlib 最常见的坑。默认字体不支持中文,直接画图会出现一个个方框。我每次做这类个人项目都会先设置字体:
import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["Noto Sans CJK SC", "Microsoft YaHei", "SimHei"] plt.rcParams["axes.unicode_minus"] = FalseTOP 歌手用横向条形图最直观,因为歌手名通常比较长,横向展示不会被截断。在柱子上直接标注小时数,一眼就能看出谁是你的听歌时长霸主。
top_artists = artist_hours.head(10) fig, ax = plt.subplots(figsize=(10, 6)) top_artists.sort_values().plot.barh(ax=ax, color="#1DB954") ax.set_title("我的 TOP10 歌手(按听歌时长,小时)") ax.set_xlabel("听歌小时数") for i, v in enumerate(top_artists.sort_values().values): ax.text(v, i, f" {v:.0f}h", va="center") plt.tight_layout() plt.savefig("top_artists.png", dpi=150) plt.show()条形图做完记得保存 PNG,后面能拼进 PDF 或者直接分享。
4.2 按小时、星期、月份的热度趋势
这个部分是我个人最爱的,因为平台报告里不会给你这么细的视角。先看“星期几 + 一天中的小时”的播放热度矩阵,横轴是 0 点到 23 点,纵轴是周一到周日,格子颜色越深代表那段时间播放量越大。这种热力图能直接看出你的“通勤听歌高峰”和“深夜循环时间”。
import numpy as np pivot = df.pivot_table( index="weekday", columns="hour", values="msPlayed", aggfunc="sum", ) pivot = pivot.reindex(index=range(7), columns=range(24)).fillna(0) / 3600000 fig, ax = plt.subplots(figsize=(12, 5)) heatmap = ax.imshow(pivot, aspect="auto", cmap="YlGnBu") ax.set_xticks(range(24)) ax.set_yticks(range(7)) ax.set_yticklabels(["周一", "周二", "周三", "周四", "周五", "周六", "周日"]) ax.set_xlabel("小时") ax.set_ylabel("星期") plt.colorbar(heatmap, ax=ax, label="播放小时数") plt.title("一周内的听歌热度分布") plt.tight_layout() plt.savefig("heatmap_weekly.png", dpi=150) plt.show()如果你发现自己的热力图在工作日的早上 8 点到 9 点有一道非常亮的横向亮带,那基本可以断定那是在通勤路上听的。这种洞察比单纯看总时长有意思得多。
再做一个按月播放时长的折线图,能看出口味波动,比如某个月突然出现一个高峰,大概率是因为某张新专辑或者某位歌手巡回演出的时间点。折线图用普通 line 图即可,不复杂。
4.3 歌手词云,做一个直观的名片
词云是很多人喜欢的可视化方式,字越大代表播放越频繁。生成词云前要把歌手名按播放次数重复成字符串,再用 wordcloud 绘制。这里有两个容易踩的坑:一是文本量不能无穷大,建议只取 TOP100 歌手;二是一定要指定中文字体路径,否则所有歌手名都会变成方框。
from wordcloud import WordCloud artist_counts = df["artistName"].value_counts().head(100) text = " ".join(artist_counts.index) wc = WordCloud( width=1600, height=800, background_color="white", font_path="/System/Library/Fonts/PingFang.ttc", # 改成你的中文字体路径 colormap="viridis", ).generate(text) fig, ax = plt.subplots(figsize=(12, 6)) ax.imshow(wc, interpolation="bilinear") ax.axis("off") plt.title("我的歌手词云") plt.tight_layout() plt.savefig("wordcloud_artists.png", dpi=150) plt.show()字体路径每个操作系统不一样,Windows 常见是 C:/Windows/Fonts/msyh.ttc,macOS 常见是 /System/Library/Fonts/PingFang.ttc,Linux 则要安装中文字体后找到对应位置。找不到就先用英文确认逻辑,再把字体路径补上。
5. 进阶玩法:音频特征告诉你为什么一直循环那首歌
5.1 批量拉取音频特征,补齐无法从导出数据获得的部分
官方导出的数据里没有歌曲 ID,也没有音频特征,所以需要让 API 根据“歌手 + 歌曲名”去搜索歌曲并拿特征值。先试一首,确认匹配率能接受,再批量补。
import spotipy import time from spotipy.oauth2 import SpotifyOAuth scope = "user-library-read user-read-recently-played user-top-read" sp = spotipy.Spotify(auth_manager=SpotifyOAuth( client_id="你的ClientID", client_secret="你的ClientSecret", redirect_uri="http://localhost:8888/callback", scope=scope, )) def search_track(artist, title): query = f"artist:{artist} track:{title}" result = sp.search(query, type="track", limit=1) tracks = result["tracks"]["items"] if not tracks: return None return tracks[0]["id"] top_songs = ( df.groupby(["artistName", "trackName"])["msPlayed"] .sum() .sort_values(ascending=False) .head(50) ) track_ids = [] for (artist, title), _ in top_songs.items(): track_id = search_track(artist, title) track_ids.append(track_id) time.sleep(0.2) features = [] for i in range(0, len(track_ids), 100): batch = track_ids[i:i+100] features.extend(sp.audio_features(tracks=batch)) time.sleep(0.3)搜索接口是有频率限制的,批量补数据时一定要在循环里加 sleep,否则很容易触发 429 错误。上面代码里放慢了请求速度,虽然会多花几十秒,但比起被限速后反复重试要划算得多。
如果搜索返回空,那大概率是本地音乐、现场录音或未上架版本。我的做法是保留空位并按“未知特征”处理,不阻塞整个流程。
5.2 雷达图和散点图:看懂你的听歌偏好
拿到音频特征后,比较有价值的是以下六个字段:
| 特征 | 含义 | 范围 |
|---|---|---|
| danceability | 舞蹈适合度 | 0 到 1 |
| energy | 能量强度 | 0 到 1 |
| valence | 情感积极度 | 0 到 1,越高越“快乐” |
| acousticness | 原声程度 | 0 到 1 |
| instrumentalness | 器乐占比 | 0 到 1 |
| tempo | 速度 | BPM |
先用雷达图画出平均水平,和你的 TOP50 歌曲特征做一个对比。你会发现一个很有意思的规律:很多人 TOP50 歌曲的平均 energy 反而低于整体平均,因为高频循环的歌往往是那些适合做背景音的歌曲,而不是最炸裂的歌曲。
散点图选 energy 和 valence 两个维度,横轴 valence 表示情绪积极度,纵轴 energy 表示激烈程度,这样你的歌单会被自动分成几个区域。右上角是“燃曲区”,左上角是“狂躁区”或者“暗黑区”,右下角是“温柔低情绪”区,左下角是“安静丧曲区”。我在实际画完之后发现,我深夜听的歌大量集中在左下角,而通勤时候听的集中在右上角,完全能对应到不同的使用场景。
5.3 歌曲级归类,让报告更有故事性
有了特征值,就可以做简单的歌曲归类。不需要上复杂模型,写几个阈值规则就够了:
def classify(row): if row["energy"] > 0.7 and row["valence"] > 0.6: return "提神/燃曲" if row["energy"] < 0.4 and row["valence"] < 0.35: return "深夜/丧曲" if row["acousticness"] > 0.5: return "安静/原声" return "日常/普通" feat_df["category"] = feat_df.apply(classify, axis=1)对每个分类做汇总,你能知道自己的年度播放时长里有多少属于“提神/燃曲”、多少属于“深夜/丧曲”。如果觉得阈值规则不够生动,也可以顺手用 sklearn 的 KMeans 聚类,把歌曲自动分为几类,再给几个典型代表起个名字。这是可选功能,基础版规则足够完成一篇完整的个人报告。
6. 实操中的坑:排查清单与我的习惯
6.1 常见问题速查表
做这个项目过程中,我把最常遇到的坑整理成了表格,对号入座排查更快:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| OAuth 认证失败 | Redirect URI 和后台不一致 | 复制后台完整地址到代码 |
| 提示权限不足 | scope 没有包含对应权限 | 把 scope 扩为 user-library-read 等 |
| 时间统计整体偏移 | 没有把 UTC 转本地时区 | 先 utc=True 再 tz_convert |
| 图表中文乱码 | matplotlib 中文字体未设置 | 设置 font.sans-serif 和中文字体路径 |
| 词云全是方框 | wordcloud 未指定中文字体 | 用 font_path 指定本机中文字体 |
| API 报 429 | 请求频率过高 | 在循环里加 time.sleep,分批请求 |
| 统计结果比记忆少很多 | 只读取了部分 JSON 文件 | 用 glob 匹配所有 StreamingHistory 文件 |
| 高频歌曲榜单有奇怪歌曲 | 没有过滤短播放 | 增加“有效播放”维度,过滤小于 30 秒的记录 |
这里单独说一句 429 报错。Spotify 对 API 请求有严格的频率限制,批量拉取音频特征时比较容易碰到。除了 sleep,还可以把请求拆成小批次,单次最多 100 个 track ID,这是接口限制,不要贪多。
6.2 隐私和数据管理习惯
这个项目会涉及比较私密的个人听歌数据,我自己在做的过程中养成了一些习惯。导出 JSON 文件不会上传到任何公开仓库;演示代码里也不放真实的 Client ID 和 Client Secret,而是存到本地环境变量文件里,用一个变量名引用。如果你想复现,请一定把敏感信息全部脱敏。
还有一个体验上的心得:不要把图表文件随便用数字命名,我习惯把关键图表保存成 top_artists.png、heatmap_weekly.png、feature_profile.png 这种名字,最后放进一个 report 目录里。连续做几个月之后,你会得到一组时间序列图片,可以直观看到自己的口味漂移。
6.3 如何把这个项目继续玩下去
这个项目的扩展空间其实比想象中大。你可以按月生成月度小结,自动输出“本月新发现歌手”“本月循环最多次的歌曲”“本月的深夜歌曲”等内容;也可以把听歌数据和自己的日历对比,看出出差、加班、休假对听歌节奏的影响;更进阶的玩法是把音频特征和每天的心情评分放在一起,做一个小型的数据回归。
我个人在实际操作中最喜欢的一个扩展是把热力图做成 GIF,按月份一帧一帧播放,能看到一年里的听歌习惯如何迁徙。生成 GIF 只需要把每个月热力图保存成帧,电影界面的工具并不多,pillow 就能搞定。
最后分享一个小技巧:请一定保留清洗后的 DataFrame,不要只存图表。后面再想做一些新指标,比如统计某个歌手在不同年份的“占据率”变化,直接读清洗后的 DataFrame 就能马上算出来,而不用重新跑一遍下载、解析、合并的流程。数据管线的每个环节都应该是可重跑的,这是这类个人数据分析项目最有价值的地方。