前几天我突然起了个念头:自己在 Spotify 上这些年到底听了些什么。点开 App 里的年度回顾,永远只能看到一份官方精修过的榜单——那是一种漂亮的、被规整过的总结,但真实的听歌数据要更粗糙,也更有意思。干脆直接去 Spotify 的隐私中心导出了完整听歌历史,拿到一堆 JSON 文件,然后用 Python 把它们变成了图表、排位和统计数字。这套分析完全跑通之后,我发现自己的收听规律和想象中差了很远:深夜才是真正的播放高峰,有些歌只在通勤路上出现,所谓“最爱的乐队”其实只占据了我很小一段 listening time。这篇文章就把完整的分析思路、代码实现和踩过的坑都拆给你,照着做一遍,你也能生成属于自己的听歌年度报告,而且是完全按你自己的口径来算的版本。
这个项目核心就是三件事:把你的 Spotify 数据导出成文件、用 Python 读取并清洗、然后做聚合统计和可视化。适合三类人:一是刚开始学 pandas 和 matplotlib 的数据分析初学者,手头缺一个够真实、不无聊的练手数据集;二是对隐私数据好奇的普通用户,想看看平台到底记了你什么;三是想用数据重新认识自己的音乐口味,而不是被平台算法反向定义的人。
1. 项目思路与整体设计:先想清楚数据长什么样
1.1 核心需求解析:你的听歌历史本质上是一份日志
拿到导出包后,我打开第一个 JSON 文件时愣了一下——每条记录就是一个对象,里面有时间戳、歌曲名、艺术家、专辑、播放了多久、用什么设备播的、为什么开始、为什么结束。这其实和运维领域熟悉的日志分析是同一套东西:一个带时间、来源、事件类型、持续时长的流水表。理解了这一点,整个分析思路就顺了:先做数据清洗,把脏的、重复的、无效的记录去掉;再做时间序列聚合,看趋势和周期性;最后做分组统计,找 Top 排名和分布规律。
唯一要注意的是,这份日志有两种版本格式。2023 年前后的导出包字段差别很大,老格式是StreamingHistory0.json,字段是endTime、artistName、trackName、msPlayed这种简洁命名;新格式是endsong_0.json,字段变成了ts、master_metadata_track_name、master_metadata_album_artist_name、ms_played这种带前缀的完整命名,还多了reason_start、reason_end、platform、shuffle等一堆行为字段。写脚本的时候必须两种都兼容,这是第一个实操要点。
为什么要强调这一点?因为我发现很多网上的教程只针对老版本,你拿着新导出的数据直接套,跑出来的全是空值。而且新格式里还有播客数据混在里面,字段是episode_name而不是master_metadata_track_name,如果你不把它们单独挑出来,统计图表里会莫名其妙多出一堆“剧集”。
1.2 方案选型:为什么必须走官方导出而不是直接调用 API
可能有人会问:不是有 Spotify Web API 吗?为什么还要费劲导出数据?这里有一个任何用 API 做过音乐数据的人都会立刻理解的限制:官方 API 里的recently played接口最多只能拿到最近 50 条播放记录,而且接口有配额限制;currently playing接口只能返回当前这一首。也就是说,API 适合做“此刻在播什么”或者“最近一小段”的应用,但它根本拿不到你的历史全量数据——API 的定位是给第三方 App 做播放控制用的,不是给你做个人数据分析用的。
唯一能拿到完整历史记录的合法途径,就是走 Spotify 的账号隐私设置,提交“Download your data”请求。这个导出流程通常需要几天到两周,官方会发邮件通知你文件准备好,然后你下载一个 zip 压缩包。包里有账号数据、扩展听歌历史、搜索记录等多个子目录,其中Extended streaming history目录下的endsong_*.json就是我们分析的核心数据。
还有一个很少被提到的点:导出时你会看到两个选项——账号数据和扩展听歌历史。如果你只选了前者,下载包里虽然也有播放记录,但没有reason_start、reason_end这些行为字段。做深度分析的话一定要选完整历史记录,不然很多分析维度就没了。
1.3 分析管线设计:先跑通一条小样本,再全量计算
拿到数据之后不要急着写一个大而全的脚本。我自己的习惯是:先写几行代码读取一个 JSON 文件,把字段打印出来,确认格式;然后只挑其中一段数据做字段映射和可视化,跑通整条链路;最后再全量加载、合并、清洗。这样能避免“写完三千行脚本,一跑发现字段名拼错了”这种灾难。
技术选型上,我用了 pandas 做数据清洗和聚合,matplotlib 加 seaborn 做可视化,就是最普通的 Python 数据分析三件套。不用 Excel 处理是因为数据量往往是几万到几十万条记录,Excel 打开一个 50MB 的 JSON 就卡死了,而且我们后面要做时间序列重采样、分组聚合这类操作,pandas 的语法比手写循环高效得多。另外提一句,如果最后想要精细报表,可以顺手用df.to_excel()把聚合结果导成 Excel,方便在不写代码的时候翻看,但这只是锦上添花。
2. 数据获取与环境准备:把原始材料收拾干净
2.1 申请导出数据的完整流程
去 Spotify 网站登录自己的账号,进入账户设置里的隐私中心,找到“Download your data”选项卡,点击请求导出。页面会提示你可以选择导出范围,这里选“Extended streaming history”,也就是完整的扩展听歌历史。提交之后就是等待,官方说最长可能 30 天,但我几次实测下来,从请求到收到邮件一般是一周以内。
收到通知邮件后,下载 zip 包并解压。我这次拿到的目录结构大概是这样的:
MyData/ ├── AccountData/ │ ├── Identity.json │ ├── UserDetails.json │ └── ... ├── Extended streaming history/ │ ├── endsong_0.json │ ├── endsong_1.json │ └── ... ├── Identifiers/ │ ├── identifiers.json │ └── ... └── Inferences/如果是老账号,在Extended streaming history目录下也可能出现StreamingHistory0.json到StreamingHistory9.json这种老命名文件。这里顺带提醒一句:解压出来的包里有identifiers.json,里面有设备 ID、广告 ID 之类的标识信息,分析的时候用不到,但是属于比较敏感的数据,注意不要随便把整个文件夹发到网上去。
2.2 新老格式的字段对照与理解
为了写兼容脚本,我把两种格式的核心字段列了一个表,这样一眼就能看出映射关系:
| 含义 | 老格式字段 | 新格式字段 |
|---|---|---|
| 播放结束时间 | endTime | ts |
| 歌曲名 | trackName | master_metadata_track_name |
| 艺术家 | artistName | master_metadata_album_artist_name |
| 专辑 | 无 | master_metadata_album_album_name |
| 播放时长(毫秒) | msPlayed | ms_played |
| 曲目 URI | 无 | spotify_track_uri |
| 播放平台 | 无 | platform |
| 开始原因 | 无 | reason_start |
| 结束原因 | 无 | reason_end |
| 是否离线 | 无 | offline |
注意老格式的endTime是形如2021-10-05 14:32的本地时间字符串,不带时区信息;新格式的ts是 ISO 8601 格式,通常是 UTC 时间,后面带着Z或者+00:00这样的时区标记。这个差异直接影响到你算“每天几点听歌最多”的时候,小时分布对不对。我一开始忽视了这个问题,结果发现所有时间都比实际慢或快了几个小时,排查了半天才发现是时区没统一。
2.3 Python 环境与依赖库安装
分析环境不需要多复杂,Python 3.9 以上就行。如果你还没装 Python,去官网下载安装包,安装的时候记得勾选“Add Python to PATH”。装完之后在命令行里执行:
pip install pandas matplotlib seaborn这三个库是核心。pandas 负责数据结构和聚合,matplotlib 负责基础绘图,seaborn 在 matplotlib 之上提供更美观的热力图和统计图。如果你打算后面调用 Spotify 的 Web API 来补充分数数据,再装一个spotipy:
pip install spotipy代码组织上,我建议把分析脚本放在一个单独目录里,例如spotify-analysis/,解压后的 MyData 文件夹放在同目录下,这样脚本里用相对路径引用数据文件,不会因为路径问题报错。不要直接用压缩包里的路径,也不要拷到云盘同步目录里,因为某些云同步服务会在后台占用文件句柄,导致 Python 读取时遇到奇怪的权限错误。
3. 核心代码实现:从原始 JSON 到干净 DataFrame
3.1 读取与合并多个文件,兼容新旧格式
第一步是把所有 JSON 文件都读进来,并且统一成一套字段。因为新老格式存在,我写了一个兼容函数,核心逻辑是先判断记录里有没有master_metadata_track_name这个键,如果有就按新格式解析,没有就按老格式解析:
import json import glob import pandas as pd def load_spotify_history(folder="MyData"): patterns = [ "MyData/**/endsong_*.json", "MyData/**/StreamingHistory*.json", ] files = [] for pat in patterns: files += glob.glob(pat, recursive=True) rows = [] for path in files: with open(path, "r", encoding="utf-8") as f: data = json.load(f) for rec in data: if "master_metadata_track_name" in rec: rows.append({ "ts": rec.get("ts", ""), "artist": rec.get("master_metadata_album_artist_name", "") or "", "track": rec.get("master_metadata_track_name", "") or "", "album": rec.get("master_metadata_album_album_name", "") or "", "ms_played": rec.get("ms_played", 0), "platform": rec.get("platform", ""), "reason_start": rec.get("reason_start", ""), "reason_end": rec.get("reason_end", ""), "track_uri": rec.get("spotify_track_uri", ""), "is_podcast": bool(rec.get("episode_name", "")), }) else: rows.append({ "ts": rec.get("endTime", ""), "artist": rec.get("artistName", "") or "", "track": rec.get("trackName", "") or "", "album": "", "ms_played": rec.get("msPlayed", 0), "platform": "", "reason_start": "", "reason_end": "", "track_uri": "", "is_podcast": False, }) return pd.DataFrame(rows) df = load_spotify_history() print(df.shape) print(df.head())这里有一个关键设计:新格式里只有播客记录才有episode_name,所以is_podcast这一列可以直接从字段是否存在来判断。老格式因为没有播客数据,直接全部置为 False。这一步如果不做,后面的所有统计都会把播客混进歌曲里,排名图表会很奇怪。
3.2 数据清洗:时间解析、时区统一和无效记录过滤
读进来之后,接下来是数据清洗。核心操作有三个:时间解析、时区统一、无效记录过滤。时间解析这一步最容易踩坑,因为新旧格式时间格式不一致。我的处理方式是:先统一用pd.to_datetime解析,然后判断解析出来的时间是否带有时区;如果没有时区信息,说明是旧版本地时间,就把它当成你所在的时区来本地化;如果已经有时区信息,则直接转换到目标时区。
from datetime import datetime def normalize_time(s, tz="Asia/Shanghai"): try: t = pd.to_datetime(s) if t.tz is None: t = t.tz_localize(tz) else: t = t.tz_convert(tz) return t except Exception: return pd.NaT df["ts"] = df["ts"].apply(normalize_time) df = df.dropna(subset=["ts"])时区选择哪里,取决于你大部分时间生活在哪个时区。因为老格式的时间是“播放结束时的本地时间”,没有偏移记录,你只能根据自己经常待的地区来设定。这个选择不会影响总时长、排名这些统计,但会影响“按小时分布”的横轴位置。我自己常驻国内,所以都用Asia/Shanghai统一。
接下来是无效记录过滤。Spotify 的记录里有大量播放很短的情况,比如切歌、误触、自动播放被中断。行业里一个常见口径是:播放时长超过 30 秒才算是有效收听,这也是 Spotify 自己年度总结的统计口径之一。我建议先保留全量数据,但新增一个标记列,而不是直接删除短记录:
df["minutes"] = df["ms_played"] / 60000 df["is_valid"] = df["ms_played"] >= 30000为什么保留而不删除?因为有时候“被跳过的歌”本身就是一种信号——一首歌你反复点了又跳过,说明你可能讨厌它;而自动播放的歌反而听完,说明算法比你更懂你自己。不同的分析目标会用不同的过滤口径,所以标记比删除更灵活。
3.3 数据增强:给每条记录加上分析上下文
清洗完之后,要给 DataFrame 增加一批从时间戳里派生出来的维度字段,这样后续聚合会非常方便。我增加了这些列:
df["hour"] = df["ts"].dt.hour df["weekday"] = df["ts"].dt.dayofweek # 0=周一,6=周日 df["year"] = df["ts"].dt.year df["month"] = df["ts"].dt.month df["date"] = df["ts"].dt.date df["is_weekend"] = df["weekday"] >= 5 df["full_play"] = df["reason_end"] == "trackdone"hour、weekday、year这些列就是用来做时间维度分析的。full_play列更有意思,reason_end字段新格式才有,老格式全为空。当reason_end等于trackdone时,代表这首歌是自然播完的,而不是被手动切换、返回、关掉 App 打断的。这能用来衡量“完整收听率”,某种程度上比播放次数更能说明你对一首歌的真实喜爱程度。
有一点要提醒:如果数据里混着老格式,reason_end会是空字符串,这个full_play标记对老数据没有意义。所以后续如果有按完整收听率排名的分析,必须只针对新格式数据子集来做,不然老格式会全部被算成“未完整播放”,结果完全失真。
4. 深度分析与可视化:把数字变成你的音乐故事
4.1 总览指标与月度趋势:一眼看清你的音乐消费总量
清洗完成之后,先算几个总览数字。我这次导出的数据里,总计有 4 万多条播放记录。用 pandas 算总时长和去重曲目数量非常直接:
total_minutes = df["minutes"].sum() valid_minutes = df.loc[df["is_valid"], "minutes"].sum() total_plays = len(df) valid_plays = df["is_valid"].sum() unique_tracks = df.loc[df["track"] != "", "track_uri"].nunique() print(f"总播放次数: {total_plays}") print(f"有效播放次数(>=30s): {valid_plays}") print(f"累计播放时长: {total_minutes / 60:.1f} 小时") print(f"有效播放时长: {valid_minutes / 60:.1f} 小时") print(f"去重歌曲数: {unique_tracks}")这里有一个要注意的细节:ms_played的单位是毫秒,所以除以 60000 才是分钟,很多人第一次分析会把单位看错,导致数字大得离谱。另外,一条记录里ts是播放结束时间,ms_played是这次播放持续的时间,而不是歌曲本身的时长。如果你的网络不好,一首歌可能分了两次记录播完,这时候按记录次数统计就会把同一首歌算两次,但按时长统计仍然准确。
月度趋势可以直接用 pandas 的重采样功能:
monthly = df.set_index("ts").resample("M")["minutes"].sum() monthly = monthly / 60 # 转为小时 ax = monthly.plot(kind="bar", figsize=(14, 4), color="#1DB954") ax.set_title("每月累计播放时长(小时)") ax.set_xlabel("月份") ax.set_ylabel("小时") plt.tight_layout() plt.savefig("monthly_trend.png", dpi=150)从月度趋势图上可以看到非常明显的周期性变化:每年年初播放量骤降,年末回升,暑假也有一个小高峰。这种周期性本身就是你生活方式的数据投影——比如考试月、工作忙季,听歌量明显变少。
4.2 艺术家与歌曲排名:统计口径不同,结论完全不一样
排名是所有人都最想看的部分,但这里有一个非常关键的口径问题:按播放次数排名,还是按累计播放时长排名?大多数排行榜网站用的是播放次数,但对个人分析来说,累计时长往往更符合“你有多爱这个艺术家”的真实感受——因为一首 6 分钟的后摇能撑起很大的时长,而一首 2 分钟的流行歌需要播三次才能追平。
artist_plays = df[df["is_valid"]].groupby("artist")["track"].count().sort_values(ascending=False) artist_minutes = df[df["is_valid"]].groupby("artist")["minutes"].sum().sort_values(ascending=False) top10_plays = artist_plays.head(10) top10_minutes = artist_minutes.head(10)我跑出来两个榜单之后发现差异很大:按次数排,前几名全是一两分钟的快歌歌手;按时长排,排在前面的是那些动辄五六分钟的乐队。这两种口径没有谁对谁错,取决于你想回答什么问题——“哪个歌手让我频繁点开”和“哪个歌手的音乐真正陪伴了我最久”是两个不同的答案。
歌曲维度的排名类似,但我习惯把歌曲和艺术家组合在一起做分组,避免同名歌曲在不同专辑里混淆:
track_stats = ( df[df["is_valid"]] .groupby(["track", "artist"]) .agg(plays=("ms_played", "count"), total_minutes=("minutes", "sum")) .sort_values("plays", ascending=False) .reset_index() )播放次数最靠前的歌里面,有好几首是我自己都没意识到听了那么多次的歌——它们不是我最喜欢的,但它们出现在我的歌单开头位置,每次随机播放都会遇到,积少成多就成了“播放王”。这其实也是年度总结类 App 的一个盲区:平台知道你的播放数,但只有把“主动收听”和“被动被播放”分开来看,才是真正属于你的音乐画像。
4.3 收听时段与生活规律:热力图揭示你的生物钟
时间维度的分析是这次最有意思的部分。先看按小时的播放分布:
hourly = df[df["is_valid"]].groupby("hour")["minutes"].sum() / 60 ax = hourly.plot(kind="bar", figsize=(12, 4), color="#191414") ax.set_title("一天 24 小时累计播放时长(小时)") ax.set_xlabel("小时") ax.set_ylabel("播放时长(小时)") plt.tight_layout() plt.savefig("hourly_distribution.png", dpi=150)我的结果里,凌晨 1 点到 3 点有明显的播放高峰,早 9 点到 10 点是一个次高峰,下午时段反而是低谷。深夜高峰很好理解,很多人睡前会听歌;但更高的一个深夜峰值出现在凌晨 2 点,那个时间点还在听歌,说明我经常失眠,或者深夜才是真正属于自己的时间。
更有信息量的是“星期 × 小时”的热力图,用 pandas 的透视表加 seaborn 画:
import seaborn as sns heat_data = df[df["is_valid"]].pivot_table( index="weekday", columns="hour", values="minutes", aggfunc="sum", fill_value=0 ) / 60 # 转为小时 fig, ax = plt.subplots(figsize=(16, 4)) sns.heatmap(heat_data, cmap="Greens", linewidths=0.1, ax=ax, cbar_kws={"label": "小时"}) ax.set_xticklabels([f"{h:02d}:00" for h in range(24)]) ax.set_yticklabels(["周一", "周二", "周三", "周四", "周五", "周六", "周日"]) ax.set_title("星期 × 小时 播放时长热力图") plt.tight_layout() plt.savefig("weekday_hour_heatmap.png", dpi=150)从热力图能看出来,这个账号的听歌行为在工作日和周末完全是两种模式:工作日是中午 12 点和晚上 9 点的双峰,周末则整体后移,下午 2 点到晚上 11 点几乎整段都亮。这种数据比任何问卷调查都诚实——带有时间戳的行为记录是骗不了人的。
4.4 显式内容、完整收听率与更深的音乐口味分析
除了排名和时间分布,还可以做几个有意思的口味分析。新格式里没有直接的“是否显式内容”字段,但可以通过曲目 URI 去 Spotify Web API 里查询歌曲的详细信息,包括explicit字段,以及valence(情感积极度)、energy(能量值)、danceability(跳舞适配度)这些音频特征。这里我用了spotipy:
import spotipy from spotipy.oauth2 import SpotifyClientCredentials # 需要先在 Spotify Developer Dashboard 创建应用获取凭证 sp = spotipy.Spotify(auth_manager=SpotifyClientCredentials( client_id="YOUR_CLIENT_ID", client_secret="YOUR_CLIENT_SECRET" ))因为 API 有配额限制,几千首曲目要分批查询,直接用大列表请求很容易触发限流。实际处理时我写了一个简单的批量函数,每次传 50 个曲目 ID 查询,拿到结果后和主 DataFrame 做一次合并。这样就能画出自己的“听歌情感曲线”——按月份聚合valence的平均值,看看自己是不是在某段时间特别低落。
完整收听率也是一个值得关注的维度。用reason_end等于trackdone的占比来衡量:
full_play_rate = ( df[df["reason_end"] != ""] .groupby("artist")["full_play"] .mean() .sort_values(ascending=False) )完整收听率高的艺术家,很可能是你主动循环的、真正沉淀下来的内容;播放次数高但完整收听率低,往往说明这个歌手的歌经常被当作背景音乐随机放到,又被随手切走。这两个指标放在一起看,才是一个立体的人。
5. 常见问题与排查技巧实录
5.1 时间戳解析失败或时区偏移
很多人在导入老版本数据时会遇到endTime格式是2021-10-05 14:32这种没秒数、没时区的字符串。直接用pd.to_datetime(df["endTime"])一般能解析成功,但解析出来的对象是不带时区的naive time。如果后续直接把它和新版 UTC 时间合并,会导致两种数据的时间基准不一致,统计出来的“按小时分布”会有一批记录偏移若干小时。
排查方法很简单:用df["ts"].dt.tz看一下是否为空,如果两者时区不一致,就统一成同一个时区。新版时间戳的格式也可能不是标准Z,可能是+01:00这种带偏移量的写法,pd.to_datetime通常也能处理,但如果遇到极端格式、解析失败,会返回NaT。所以清洗阶段一定要做dropna(subset=["ts"]),否则后面聚合全是 NaN。
5.2 中文字体在图表里变成方块
matplotlib 默认字体在 Linux 环境里对中文支持很差,图表的标题和坐标轴标签会变成一个个方框。这个问题在 Windows 上相对少见,因为系统自带 SimHei 这类中文字体,但如果你在 macOS 上运行,或者用远程 Linux 服务器跑脚本,几乎必现。
最简单的解决方案有两种。一种是在脚本里显式指定系统里已有的中文字体:
import matplotlib import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["PingFang SC", "SimHei", "Microsoft YaHei"] plt.rcParams["axes.unicode_minus"] = False另一种更省事的做法是:图内的标签全部用英文,只在正文里用中文解释。不是所有平台都需要中文标签,而且英文标签在任何环境中都不会乱码。我自己最后是项目初稿用英文标签,等确认分析逻辑无误之后再改成中文出图,省去了反复排查字体问题的麻烦。
5.3 播放次数总和与平台显示的年度总结对不上
这是最常见的“对不上”问题。背后的原因有三个,任何一个都会导致数据不一致。
第一,Spotify 年度总结只统计完整播放或超过 30 秒的记录,而原始数据是全部记录。第二,同一账号可能有多个文件重复记录——比如在多个设备上登录、或者导出了多次数据,合并时没有去重。第三,如果你导出的数据包括新老两种格式,同一个时间段可能存在版本重叠。解决方式是在合并之前做一个去重操作:
df = df.drop_duplicates(subset=["ts", "track", "artist", "ms_played"])这行代码按时间、歌曲、艺术家、播放时长四个维度去重,基本可以消除多文件重复记录。但要注意,如果你确实在同一个时间点反复听了同一首歌,比如用循环播放,那不算是重复记录,因为播放时长通常并不完全相同。
5.4 隐私提醒:导出包里有些字段别乱发
最后想特别提醒一点。导出包里的数据远不止听歌记录。identifiers.json里有设备 ID 和广告 ID,UserDetails.json里甚至有邮箱、用户名信息,旧版数据里的 IP 地址也会随着播放记录一起出现。这些字段用来做分析完全没有必要。
所以在分析的时候,建议先做一次字段过滤,只保留ts、track、artist、album、ms_played、platform、reason_start、reason_end这几个列,然后再开始绘图。如果要把分析过程分享到社交平台,发布图表之前务必再检查一次图片内容,不要把包含 IP 或设备信息的 DataFrame 截图直接发出去。数据是好东西,但自己的数据更要自己有意识地保护。
6. 一点后续扩展和个人体会
这套分析做完之后,我最大的感受是:平台给你看的“年度报告”只是它想让你看的故事,而你自己动手算出来的数据才是真正属于你的记录。用 Python 分析自己的 Spotify 听歌数据,本质上不是一次性的小玩具,它是一套可以反复运行的流程——每年导出一次,跑一遍脚本,就能看到当年的音乐行为变化。如果你把它写成函数模块,甚至可以做跨年对比,看自己的口味迁移轨迹。
后续想继续深挖的话,有两个方向我强烈推荐。一个是歌词文本分析:把排名前 50 的歌曲歌词抓下来,做分词、词频和情绪倾向分析,看看自己常听的歌里是不是藏着某种特定的审美偏好。还有一个是音频特征分析:用spotipy批量拉取valence、energy、danceability这些字段,按时间维度聚合,画一条“情绪曲线”——你会发现那段时间自己是不是真的状态不太好,音乐数据往往比记忆更诚实。
最后分享一个做图的小技巧:分析脚本里所有图表统一用plt.savefig而不是plt.show,这样每次跑完脚本,所有图片都会自动存到当前目录,方便自己连续对比。我在实际运行中,光是把所有图表输出到一个output/文件夹,就已经让整个项目的回看体验提升了一大截。