我一直觉得,Spotify最值钱的不是它给我推荐的歌,而是它记下来的那些“听歌流水账”。每天上下班、写代码、睡前放松,这些零散的播放记录看起来没什么用,但当你把三年的历史数据导出,用Python按小时、按艺人、按月份聚合一遍,会发现自己对音乐的选择其实藏着非常清晰的规律。这篇文章就是一次完整的上手实验:把Spotify听歌数据从文件里读出来,清洗成规整的表格,再回答几个最经典的统计问题,最后画成图表。适合刚学完pandas基础、想找真实项目练手的人,也适合纯粹好奇自己一年到底听了多少小时歌的朋友。整个过程不需要复杂的机器学习,一台电脑、一杯咖啡,完全够用。
1. 分析之前,先明确你的听歌数据分析目标
很多人的第一步走错了,不是不会写代码,而是没想明白自己到底想从数据里得到什么。Spotify导出给你的不是一张现成报表,而是一条条播放事件,每条事件都包含时间、艺人、歌名、播放时长等字段。如果你把这些字段全部拿来算一遍,很快就会被“接下来该算什么”这个问题卡住。
1.1 想解答的问题决定字段的加工深度
同样是听歌记录,做“年度总结”需要按时间聚合,做“口味画像”需要按艺人或流派聚合,做“切歌行为”分析则需要关注单条播放的持续时间和结束原因。如果一开始就把所有字段都纳入计算,反而不知道如何处理。
我建议先写下三个最想回答的问题,然后只保留跟这些问题相关的字段。以我自己为例,当时的三个问题是:这三年来累计听了多少小时;每天在哪个时间段听歌最多;哪些歌手占据了我超过一半的播放时长。这三个问题决定了后续清洗过程中我只需要关心 ts、ms_played、master_metadata_album_artist_name 这几个字段,平台、国家、设备之类的一律先放一边。问题越具体,后面的代码越不容易跑偏。
1.2 先做一个“最小可运行脚本”再谈扩展
不要一上来就铺开五个图表。先写一个只有二十行的脚本,把数据读进来、转格式、输出播放总时长,跑通之后再逐步加入按周聚合、按歌手聚合。这样做的原因是,每一步都有可验证的中间结果,出错时你能立刻知道问题出在刚加的那段代码里。
我见过不少新手一开始就直接画热力图,跑出来发现时间字段还是字符串,后面的聚合结果全是错的,再回头排查浪费了很长时间。先把一个数字跑对,比如“我总共听了123456分钟”,这个数字验证通过后,再做其他扩展。数据分析项目里,正确性永远是第一位的,图再好看也不如数字踏实。
如果你完全没用过pandas,建议先用半小时过一遍DataFrame的创建、groupby 和 plot 这三个基础操作,再做这个项目会轻松很多。数据量方面也不用担心,三年历史一般只有几十万行,pandas内存完全可以吃得下。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据怎么拿:导出历史音频数据与官方API的比较
拿到正确的数据是分析的前提。这里要区分两条路线:官方API和账户数据导出。很多教程一上来就让你注册应用、拿Access Token,但其实对于“分析完整听歌历史”这个目标来说,API并不是最优解。
2.1 官方API只能拿到“最近”而不是“全部”
Spotify Web API里有一个 Recently Played 接口,可以读取当前用户最近播放过的曲目。我用它写过一个小工具,用来在终端里显示“最近在听什么”,体验不错。但它的限制很明显:每次最多返回50条记录,而且只能拿到最近一段较短的窗口,根本覆盖不了你三个月前、一年前听过的歌。
如果只是想做“最近一周我循环了哪些歌”这种短周期分析,官方API是够用的。用spotipy库实现也很简单,大概是这样:
python复制import spotipy
from spotipy.oauth2 import SpotifyOAuth
scope = "user-read-recently-played"
sp = spotipy.Spotify(auth_manager=SpotifyOAuth(
client_id="你的Client ID",
client_secret="你的Client Secret",
redirect_uri="http://localhost:8888/callback",
scope=scope
))
recent = sp.current_user_recently_played(limit=50)
for item in recent["items"]:
print(item["played_at"], item["track"]["name"])
但这里有两个壁垒:第一,你需要去Spotify Developer后台创建应用,配置回调地址;第二,OAuth授权流程需要跳转浏览器,自动化脚本写起来会多不少代码。除非你后面还想用API去拿曲目的音频特征(比如能量值、舞蹈性),否则为了听歌历史单独走API,性价比很低。
2.2 完整听歌历史的正确打开方式:账户数据导出
Spotify的隐私设置里提供“下载你的数据”功能,可以申请导出完整的播放历史。提交申请后,通常需要等一两天,邮箱会收到下载链接。解压后你会看到按时间拆分的JSON文件,文件名一般是 AudioStreamingHistory0.json,也可能有0、1、2多个。
每个JSON数组里的元素对应一次播放事件,字段很多,长这样:
json复制{
"ts": "2023-05-14T08:32:01Z",
"platform": "Android OS 13",
"ms_played": 243000,
"conn_country": "US",
"ip_addr": "192.168.1.1",
"master_metadata_track_name": "Blinding Lights",
"master_metadata_album_artist_name": "The Weeknd",
"master_metadata_album_album_name": "After Hours",
"spotify_track_uri": "spotify:track:0VjIjW4GlUZAMYd2vXMi3b",
"reason_start": "clickrow",
"reason_end": "trackdone",
"shuffle": true,
"skipped": null,
"offline": false,
"incognito_mode": false
}
注意,这个文件里的 ip_addr 是你的IP地址,属于敏感信息。分析时用不到它,最好直接忽略,更不要打印出来或者传到公开仓库。
读取这些文件时,最稳妥的方式是用glob匹配所有文件后合并:
python复制import json
import glob
import pandas as pd
def load_streaming_history(pattern="AudioStreamingHistory*.json"):
frames = []
for file_path in glob.glob(pattern):
with open(file_path, encoding="utf-8") as f:
data = json.load(f)
frames.append(pd.DataFrame(data))
return pd.concat(frames, ignore_index=True)
df = load_streaming_history()
print(df.shape)
这段代码会把所有历史文件读成一个统一的DataFrame。如果你下载的压缩包里还有其他非流媒体历史的JSON,比如搜索历史或偏好设置,只要文件名不符合 AudioStreamingHistory*.json 就不会被读进来。
2.3 两种数据源怎么选
为了让你快速决策,我把两条路线的核心差异整理成一张表:
| 对比维度 | 官方API(recently played) | 导出音频历史 |
|---|---|---|
| 历史范围 | 仅最近约50条播放记录 | 从开始使用以来的完整历史 |
| 字段丰富度 | 曲目信息为主,缺少行为字段 | 包含播放、跳过、设备、网络等完整行为 |
| 获取成本 | 需要注册应用、配置OAuth | 需要申请下载并等待邮件 |
| 适合场景 | 实时展示“最近在听什么” | 做长期回忆分析、习惯挖掘、年度报告 |
| 进阶能力 | 可以继续调用API获取音频特征 | 数据文件本身不含音频特征 |
结论很直接:做完整听歌历史分析,选导出文件;做实时状态展示或想继续挖歌曲特征,选API。我在这个项目里主要用导出文件,API只是最后简单看了一眼音频特征,属于加分项。
3. 清洗与改造:从毫秒时间戳到可聚合的表格
拿到原始DataFrame之后,先不要急着算,因为字段类型基本不满足分析需求。ts是字符串,ms_played是毫秒整数,spotify_track_uri里可能混着空值。清洗步骤虽然枯燥,但决定后面所有结果是否可靠。
3.1 先看数据长什么样,再谈清理
我每次拿到新数据都会先运行三行代码:print(df.head())、df.info()、df.isna().sum()。df.info()能让你看到每列类型和缺失情况,df.isna().sum()可以看到每个字段空了多少。别小看这一步,很多坑都藏在缺失值里。
以我自己的数据为例,master_metadata_track_name 缺失了大概2%,这些记录大概率是本地文件或无法识别的音频,对分析没有价值。但我在删除前还是先看了一下缺失比例,如果缺失比例异常高,那可能是文件编码或读取逻辑出了问题,而不是数据本身就缺。先确认比例再决定是否删除,这个顺序不能乱。
3.2 时间字段和时区:让每一首歌落在正确的日期
ts字段看起来像是普通字符串,但它末尾的Z表示这是UTC时间。如果你直接用pd.to_datetime(df['ts'])然后提取日期,在有夏令时或者你所在时区不是UTC的情况下,日期会整体偏移。
正确的做法是先转成带时区的datetime,再转换到目标时区:
python复制df["ts"] = pd.to_datetime(df["ts"], utc=True)
df["ts_local"] = df["ts"].dt.tz_convert("Asia/Shanghai")
df["date"] = df["ts_local"].dt.date
df["hour"] = df["ts_local"].dt.hour
df["weekday"] = df["ts_local"].dt.dayofweek
这里的时间转换非常重要。如果你在凌晨零点后听歌但没转时区,这条播放事件会被记到UTC的前一天,周统计和日统计都会出现偏差。我一开始就踩过这个坑,后面专门会细说。
3.3 播放时长和有效收听:毫秒换成分钟,再区分“听完”和“切歌”
ms_played 是毫秒数,除以60000就是分钟数。这一步很简单,但它后面引出的问题是:什么样的播放事件才算“有效收听”。
python复制df["duration_min"] = df["ms_played"] / 60000
# 只保留至少听了一分钟的记录
df_listened = df[df["duration_min"] >= 1]
一分钟阈值只是最粗浅的过滤方式。如果你只想理解“我到底把时间花在哪些歌上”,应该结合 reason_end 字段。reason_end 等于 trackdone 表示这首歌被完整播放到了结束,等于 fwdbtn 或其他值则说明你手动切歌了。可以这样计算每首歌的完成率:
python复制df["completed"] = df["reason_end"] == "trackdone"
有了这个字段,你可以进一步统计“听完率”,甚至分析“哪一首歌我经常开头就切掉”。不过要注意,播客类内容和短曲目不能用同一个阈值。对于大多数歌曲,我建议先看 duration_min 的分布,再决定过滤线,而不是直接拍脑袋定成30秒。
3.4 删除空歌名记录,但要先看比例
当 master_metadata_track_name 为空时,master_metadata_album_artist_name 通常也是空的,这类记录在歌曲聚合中会制造一堆“无名”的行。删除它们很简单:
python复制df = df.dropna(subset=["master_metadata_track_name"])
但删除前一定要看比例。如果只有2%,删了没问题;如果达到20%,可能说明你的历史里有很多非音乐内容,或者Spotify未能正确识别这些音频,这时候应该改为单独分析这些记录的来源,而不是直接抛弃。从数据清洗的角度来说,任何一步过滤操作都应该能解释为什么,否则你拿到的最终结论经不起追问。
4. 回答问题的三种聚合视角:时间、艺人、歌曲
清洗完的数据表已经有了规整的时间、艺人、歌名、时长字段。接下来就是从不同维度把数据压缩成更容易理解的统计结果。
4.1 按时间聚合:一天几小时、几点最嗨
我想知道的第一件事是累计播放总时长,这个数字能让人直观感受到听歌的时间成本:
python复制total_minutes = df["duration_min"].sum()
print(f"累计播放时长: {total_minutes / 60:.1f} 小时")
接下来可以按日期、按周几、按小时分别聚合:
python复制daily_minutes = df.groupby("date")["duration_min"].sum()
weekday_minutes = df.groupby("weekday")["duration_min"].sum()
hourly_minutes = df.groupby("hour")["duration_min"].sum()
这三个序列分别展示长期趋势、工作日规律和一天内的活跃时段。我当时看到 weekday_minutes 后发现周一到周五的通勤时段明显有两个高峰,一个是早上九点,一个是晚上六点,周末则非常散。这种结论不是凭空猜测,而是数据直接给的。
如果你不满足于只看“平均值”,还可以把工作日和周末分开聚合,看看两者差异。比如用 df["is_weekend"] = df["weekday"].isin([5, 6]) 加一列,再分组求和,对比会更加明显。
4.2 按艺人或歌曲聚合:Top榜单的两种口径
统计Top艺人时,既能按“播放次数”排,也能按“累计播放时长”排。两者会给出不同的答案。有些艺人的歌很短但播放频繁,次数排名靠前;另一些艺术家的歌很长,虽然播放次数不多但贡献了大量时长。
python复制artist_stats = df.groupby("master_metadata_album_artist_name").agg(
play_count=("duration_min", "count"),
total_minutes=("duration_min", "sum")
).sort_values("play_count", ascending=False)
歌曲榜的坑比艺人榜更隐蔽。同一首歌可能有录音室版、Live版、Remix版,不同版本的 track_name 是一样的,但 spotify_track_uri 不一样。如果直接按歌名聚合,这些版本会被混在一起。想要更严谨的排名,应该先按 spotify_track_uri 聚合,再把uri映射回歌名:
python复制track_stats = df.groupby(
["spotify_track_uri", "master_metadata_track_name"]
)["duration_min"].sum().reset_index()
这样每个uri独立成行,再排序取前10,得到的榜单才真正代表“同一个录音版本”的播放量。不要小看这个细节,我身边不少人在分析流媒体数据时都忽略了版本差异。
4.3 组合视角:同一首歌在一天内的分布
单纯排名看的是“总量”,但音乐品味还有一个有趣的角度:时间段。你深夜循环的歌,和上午通勤时听的歌,往往是完全不同的两种风格。用pandas的透视表可以把“小时”和“星期几”组合起来:
python复制weekly_hourly = df.pivot_table(
index="hour",
columns="weekday",
values="duration_min",
aggfunc="sum",
fill_value=0
)
这个透视表看起来是一张7列24行的网格,非常适合后面画热力图。通过它你能发现自己的工作日晚间和周末午后分别消耗了哪些时长,甚至能判断出自己是不是“深夜emo听歌型”用户。组合视角是探索数据最有趣的部分,因为它不会直接回答你最初提出的问题,却往往能带来新的发现。
5. 图表化的正确姿势:别再只画一个柱状图
数据算出来后,图表是快速说服自己和读者的方式。但图表不是越花哨越好,关键是能准确回答你一开始提出的问题。我的经验是:先从pandas自带的绘图功能出草稿,再用Matplotlib精修。
5.1 用pandas内置plot先出草稿
pandas的 plot() 方法适合快速验证数据:
python复制artist_stats.sort_values("total_minutes", ascending=False).head(10)["total_minutes"].plot(kind="barh")
这一行代码能在几秒钟内画出一个横向条形图,虽然样式很原始,但足以让你确认“这个排名看起来合不合理”。如果数字分布特别极端,比如第一名把后面全甩开了,你就要想清楚是歌手本身循环太多,还是清洗阶段没有过滤掉循环播放的重复事件。
5.2 用Matplotlib做出可对外展示的图
草稿确认没问题后,再改用Matplotlib精修。这里有几个常见问题需要处理:默认字体可能显示不了中文,需要设置字体;柱状图的排序最好用 sort_values() 提前排好;横向条形图要 invert_yaxis() 让第一名在上方。
python复制import matplotlib.pyplot as plt
plt.rcParams["font.sans-serif"] = ["Noto Sans CJK SC", "SimHei", "Microsoft YaHei"]
plt.rcParams["axes.unicode_minus"] = False
top10 = artist_stats.sort_values("total_minutes", ascending=False).head(10)
fig, ax = plt.subplots(figsize=(10, 6))
top10["total_minutes"].plot(kind="barh", ax=ax, color="#1DB954")
ax.invert_yaxis()
ax.set_xlabel("累计播放时长(小时)")
ax.set_ylabel("")
ax.set_title("我的 Spotify Top 10 艺人")
plt.tight_layout()
plt.show()
这个图的信息量比默认pandas版本大得多,而且颜色统一,图名清楚,分享出去不会显得太敷衍。
5.3 热力图:一周听歌节奏的最佳表达
如果你已经生成了 weekly_hourly 透视表,一张热力图能直观显示“星期几”和“几点钟”两个维度下的播放时长分布。用seaborn的heatmap是最省事的:
python复制import seaborn as sns
import matplotlib.pyplot as plt
plt.figure(figsize=(12, 6))
sns.heatmap(weekly_hourly, cmap="mako")
plt.xlabel("星期几(0=周一)")
plt.ylabel("小时")
plt.title("一周听歌时长热力图")
plt.show()
这张图的优势在于,它把24小时和7天的交叉点全放在一张图上,你能立刻看出工作日早晚高峰明显,周末午后深色区域集中,深夜也有一个小尾巴。如果只有柱状图,这些信息需要好几张图才能表达清楚。
5.4 进阶:用Plotly让图表能交互
如果你想把分析结果分享给朋友,静态图还不够直观。Plotly Express可以用很少的代码生成可交互的图表,鼠标悬浮能看到具体数值。
python复制import plotly.express as px
fig = px.bar(
top10,
x="total_minutes",
y=top10.index,
orientation="h",
title="我的 Spotify Top 10 艺人"
)
fig.show()
交互图适合导出成HTML文件,朋友打开就能看,不需要装Python环境。但我自己的经验是,交互图更适合展示,不适合探索。探索阶段用静态图更直接,因为画图速度快,迭代成本低。
6. 我踩过的坑与一个小封装建议
这部分没什么高深理论,但都是真实消耗过时间的问题。写出来帮你少走弯路。
6.1 时区问题差点毁掉我的周统计
我第一次分析时直接用 pd.to_datetime(df["ts"]),没有指定UTC,然后提取日期做每日聚合。结果发现某一周的总播放时长比预期少了几个小时。排查后发现,问题出在凌晨0点到8点的播放记录上:ts 里的 Z 后缀表示UTC时间,而我在东八区,不转换时区时,那段时间被记到了前一天。最终改用 utc=True 再 tz_convert("Asia/Shanghai") 才解决。
这个坑对国内的复杂时区环境尤其明显。如果你所在的国家/地区有夏令时,还可能出现“一天只有23小时”的情况。所以时间字段永远要明确时区,不要默认pandas会帮你处理好。
6.2 过滤条件不要拍脑袋
很多教程说“跳过率 = 播放时长小于30秒的播放事件 / 总播放事件”,但这个定义很粗糙。一首只有45秒的朋克歌曲,听到第35秒已经算听了一大半;一集播客节目,听了30秒可能只是刚开头。正确做法是先画出 duration_min 的分布,看看数据长什么样,再决定过滤阈值。更进一步,用 reason_end 判断是否被手动跳过,比单纯按时长更准确。
我后来写过滤逻辑时,同时考虑了 completed 和 duration_min,允许用户传入阈值参数。这样既不会误杀短歌,也能单独检查“没有听完但播放时间很短”的事件是不是被手动跳过的。
6.3 把代码封装成一个可复用的脚本
听歌历史不是一次性数据,以后每个月你都可以重新导出,把新数据追加进来。为了这个过程不被反复的JSON解析拖累,我创建了一个简单的清洗函数:
python复制def load_and_clean(pattern="AudioStreamingHistory*.json", tz="Asia/Shanghai"):
df = load_streaming_history(pattern)
df["ts"] = pd.to_datetime(df["ts"], utc=True)
df["ts_local"] = df["ts"].dt.tz_convert(tz)
df["duration_min"] = df["ms_played"] / 60000
df["date"] = df["ts_local"].dt.date
df["hour"] = df["ts_local"].dt.hour
df["weekday"] = df["ts_local"].dt.dayofweek
return df
df_clean = load_and_clean()
df_clean.to_csv("cleaned_history.csv", index=False)
保存成CSV后,后续分析就不需要每次都重新解析JSON了,直接从CSV读取。以后导入了新数据,只要重新运行这个函数再覆盖保存即可。
6.4 隐私提醒:别把原始数据传到公开仓库
导出数据里包含IP地址、设备平台、国家编码等信息。为了分析使用,ip_addr 完全没有必要保留。我建议无论做什么项目,都应该最小化收集和保存个人信息。在写代码时你可以把用不到的敏感列直接排除,或者在使用前就丢弃:
python复制drop_cols = ["ip_addr", "username", "conn_country", "platform"]
df = df.drop(columns=drop_cols, errors="ignore")
这既是保护自己,也是让长期维护的数据文件更干净。
我当初做这个项目最大的感受是,用数据的方式重新认识了那些陪我度过通勤、加班和深夜的歌。你不需要一开始就把分析做得多么完整,先把数据读进来,算出一个总分钟数,那一刻你会对“时间都去哪了”有非常直观的感受。之后再慢慢加上艺人榜、时间热力图和切歌率,你的Spotify听歌数据就会变成一个真正属于你自己的年度报告。
