很多人在每年年末晒 Spotify Wrapped 时都会好奇:官方到底是怎么算出这些数据的?其实 Spotify 允许用户直接下载自己的完整听歌历史,拿到那份 JSON 日志之后,用 Python 做一套自己的听歌数据分析,能挖到的信息量远比官方年终总结多得多。这个项目我从数据导出、清洗、指标计算到可视化完整跑了一遍,踩了不少坑,也总结出一些很实用的分析思路,下面按实际执行的顺序记录全过程,希望能给想折腾自己听歌数据的人一份可以直接参考的路线图。
这篇文章适合所有对数据分析感兴趣、又每天都在用 Spotify 听歌的人,Python 基础不需要多深,能跑通 pandas 和 matplotlib 就够。核心思路是把 Spotify 官方允许导出的个人数据变成一张能反复查询的播放记录表,再结合官方 API 补充音频特征,最后产出时间、歌手、曲目、口味画像四个维度的分析结果。整个流程不涉及任何非官方接口,数据都是你自己账号下的合法数据。
1. 原始数据从哪来:导出文件还是 API,各有各的账
1.1 方法一:Spotify 个人数据导出,免费且数据类型最全
Spotify 在账户设置里提供了一个“下载数据”的入口,路径大致是 Account → Privacy settings → Download your data,提交之后官方会打包好一份 zip 文件发到你邮箱,通常在几天内到达。我实测下来的等待时间从几小时到三天不等,高峰期可能更慢,建议提交后耐心等邮件。压缩包解压后会看到一组 JSON 文件,其中对听歌分析最核心的是 StreamingHistory0.json、StreamingHistory1.json 这类命名连续的文件,里面就是从你开始用这个账号到现在,逐条记录的播放日志。
打开一个 StreamingHistory 文件看一眼,结构非常简单,每条记录只有四个字段:endTime 表示这条播放在什么时候结束,artistName 是艺术家名,trackName 是歌名,msPlayed 是这条记录对应的实际播放毫秒数。没有专辑名、没有曲目 ID、没有播放设备信息,这些缺失的部分后面需要通过 API 补齐。压缩包里还有 SearchQueries.json 这类搜索历史文件,对分析“你怎么发现新歌”有价值,但对听歌习惯分析不是主菜。
1.2 方法二:开发者 API 接入,实时但需要申请凭据
另一种获取数据的方式是走 Spotify 官方 Web API,去 developer.spotify.com 注册一个应用,拿到 Client ID 和 Client Secret,然后通过授权流程(OAuth)拿到访问令牌,就可以查询当前用户的播放历史、曲库信息、音频特征等。这种方式最大的优势是数据新鲜——任何时候调用都能拿到最近的播放记录;缺点是 API 的播放历史接口只能回溯最近 50 条记录,想拿完整历史是不可能的。所以 API 适合做补充,不适合做全量分析的主数据源。
调用 API 需要装一个官方推荐的 Python 库叫 spotipy,它把授权和请求细节都封装好了。基本用法是先设置三个环境变量:SPOTIPY_CLIENT_ID、SPOTIPY_CLIENT_SECRET、SPOTIPY_REDIRECT_URI,然后用 SpotifyOAuth 完成授权流程,获得一个有权限的 spotipy 客户端对象。这里有一个很容易卡住的点:Spotify 应用后台必须把重定向地址配置成和 SPOTIPY_REDIRECT_URI 完全一致,否则会报 redirect_uri_mismatch 错误,我第一次配置时就被这个问题卡了半小时。
1.3 两种数据源怎么选,以及我最推荐的实际组合
我把两种数据源的适用场景整理了一下,方便你根据自己的需求快速判断:
| 数据源 | 获取成本 | 数据完整性 | 实时性 | 典型用途 |
|---|---|---|---|---|
| 个人数据导出 | 低,点几下按钮 | 完整历史,逐条播放日志 | 有几天延迟 | 全量行为分析、趋势统计 |
| Web API 播放历史 | 需要注册应用和授权 | 仅最近 50 条 | 实时 | 本地播放器集成、增量更新 |
| Web API 音频特征 | 需要注册应用和授权 | 按需查询曲目元数据 | 实时 | 补充歌曲风格特征、口味画像 |
我在实际项目里的选择是:用导出文件做全量播放日志分析,用 API 给高频曲目补充音频特征。这两者配合起来,一个负责回答“我在什么时候听了什么”,另一个负责回答“我听的这些歌是什么风格、什么情绪”。如果你只打算跑通一个版本,先从导出文件开始,不需要注册任何 API 凭据,门槛最低。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 把 JSON 日志整理成干净的播放记录表
2.1 StreamingHistory 文件的字段含义和合并方式
导出的 zip 里经常有多个 StreamingHistory 文件,可能是 Spotify 按时间区间切分的,不同账号的切分规则不完全一样。读取逻辑很简单:用标准库 json 加载每个文件,转成 DataFrame 后纵向拼接即可。
python复制import json
import pandas as pd
import glob
frames = []
for file in glob.glob("StreamingHistory*.json"):
with open(file, "r", encoding="utf-8") as f:
data = json.load(f)
frames.append(pd.DataFrame(data))
df = pd.concat(frames, ignore_index=True)
print(df.shape)
print(df.dtypes)
合并之后先看 df.shape 确认总量,再看每一列的 dtype,你会发现 endTime 是字符串类型、msPlayed 是整数类型,后面都要转换。多个文件合并的顺序在这个场景里不影响结果,因为最终所有记录只是拼在一起,统计时会统一排序。
2.2 时间字段的时区陷阱
endTime 的原始格式是类似 2024-05-17 22:15:32 的字符串,不带时区信息。我看到网上很多教程默认它是 UTC 时间,但实测下来并不一定。Spotify 导出数据时用的是你账户当前设置的时区,如果你之前在多个时区生活过,或者账号的时区设置改过,这份数据里不同时间段的时间其实不一定对齐。这个问题最稳妥的处理方式不是盲猜,而是先随机抽几条记录,和自己实际听歌的时间对照一下,确认时区基准后再统一转换。
我的处理方式是把字符串先读成 pandas 的 datetime,再统一转换成自己所在的 Asia/Shanghai 时区,转换后额外增加小时、星期、日期三列,方便后续聚合分析。
python复制df["endTime"] = pd.to_datetime(df["endTime"])
df["endTime"] = df["endTime"].dt.tz_localize("UTC").dt.tz_convert("Asia/Shanghai")
df["hour"] = df["endTime"].dt.hour
df["weekday"] = df["endTime"].dt.dayofweek
df["date"] = df["endTime"].dt.date
这里有个细节要说明:如果你的账号时区设置本身就在东八区,tz_localize("UTC") 再转换其实是双重转换,会导致时间偏移八小时。更严谨的做法是先验证原始字符串对应的真实时刻,再决定要不要转换。我自己踩过的教训是:不要直接照搬任何人的时区代码,先比较几条记录和你的实际生活时间,哪个对得上就用哪个。
2.3 毫秒播放时长转成可读分钟数,以及噪音记录清理
msPlayed 是毫秒数,直接除以 60000 就是分钟数,这个很简单。真正需要动脑筋的是过滤噪音记录——哪些记录不应该参与统计。我的过滤规则是:
msPlayed < 30000:播放不足 30 秒的记录,大概率是误触播放、切歌或者不感兴趣的歌,这类记录占总播放次数的比例其实相当高,如果不剔除,会严重拉低平均播放时长;msPlayed == 0:完全没有播放的记录,直接删掉;- 播客内容:如果账号的历史里有 podcast,建议单独区分出来,因为播客的播放行为分布和音乐完全不一样,混在一起统计会在小时分布上产生误导;
- 歌曲名和艺术家名带有
(Live)、(Remix)、(Acoustic)等后缀的版本问题,不建议在清洗阶段合并,先保留原始版本,后续按需处理。
做完清洗之后再统计,你会发现有效记录的数量通常只有原始记录里的一部分,这才是接近真实收听行为的数据集。保留一份清洗前的备份也很重要,后续想切换统计口径时不至于重新读一遍 JSON。
3. 听歌行为分析:时间、歌手、曲目三个维度的核心指标
3.1 时间维度:小时、星期、季节的收听习惯
时间维度是所有分析里最容易出效果也最直观的。先按小时聚合,看一天 24 小时里哪个时段播放量最高;再按星期聚合,看工作日和周末的差异;最后可以按小时×星期做交叉表,这个后面可视化部分会用到。
python复制hourly = df.groupby("hour").size()
weekly = df.groupby("weekday").size()
小时分布的解读要结合个人生活节奏。我的数据里明显有早高峰、午休、晚高峰和深夜四个波峰,深夜时段的播放时长往往比白天更长,因为晚上听歌不容易被打断,播放完成率更高。这说明只看播放次数是不够的,一定要结合播放时长一起看。
星期维度同样有意思。有人工作日午休播放量高,有人周末下午是高峰,这些行为模式直接影响后面给不同歌单做时段推荐时的优先级。如果按照月份聚合,还能看到一年内的听歌量变化趋势,比如某个月份因为集中备考、加班,听歌量明显下降。
3.2 歌手维度:播放次数之外还要看完整率
歌手维度的第一个指标是播放次数和播放时长,这个用 groupby 就能算。但只按播放次数排 Top 会出现一个失真现象:某些艺术家的歌是通勤、加班时当背景音放的,播放次数虚高但不代表你多喜欢。为了更准确地衡量“真爱”,我增加了一个收藏倾向指标:完整播放率。
完整播放率的基本定义是:这个艺术家的所有播放记录里,达到完整播放(播放时长大于等于歌曲总时长的 90%)的占比。不过导出的数据没有歌曲总时长,所以我先用 API 补齐了高频歌曲的时长相,再计算完整率。实现思路是先对每个艺术家聚合出总播放次数、总播放时长,再在歌曲级别上补充时长后统计完整播放次数,最后计算完整率。
python复制artist_stats = df.groupby("artistName").agg(
play_count=("trackName", "count"),
total_minutes=("msPlayed", "sum")
).sort_values("play_count", ascending=False)
这个指标的解读很有意思。一个播放次数很多但完整率很低的艺术家,很可能是你的“歌单工具人”,你习惯用他当环境音;而一个播放次数不是最高但完整率超过八成、并且多次出现在深夜时段的艺术家,才是真正的情感连接对象。两组之间做一个交叉对比,就能把歌手分成“背景型”“探索型”“真爱型”三类,还能进一步和 Wrapped 里给出的 Top Artist 做对照,看看官方到底用的是哪种统计口径。
3.3 单曲维度:识别单曲循环和年度神曲
单曲循环是听歌数据里最容易识别的强信号行为。一个简单粗暴的方法是找同一个 artistName 和 trackName 在很短时间内多次出现的记录。更严谨的做法是按时间排序后,用同一首歌相邻两条记录之间的时间差来判断是否连续播放。
python复制shuffle_df = df.sort_values("endTime").reset_index(drop=True)
shuffle_df["prev_time"] = shuffle_df.groupby(["artistName", "trackName"])["endTime"].shift(1)
shuffle_df["gap_minutes"] = (shuffle_df["endTime"] - shuffle_df["prev_time"]).dt.total_seconds() / 60
loops = shuffle_df[shuffle_df["gap_minutes"] < 5]
gap 小于 5 分钟可以认为是同一首歌的连续重复播放。按这个逻辑统计出来单曲循环次数最高的歌,基本就是你这段时间真正上头的东西。单曲循环还有一个数据特征:它会把某首歌的播放次数堆得很高,但同时完整率也极高,所以在单纯按播放次数排名时,这类歌曲会占据榜首。如果你更想找“意料之外的宝藏歌曲”,可以做一个过滤,把单曲循环的场景单独拎出来看。
4. 用官方 API 补齐音频特征,从“听了什么”到“喜欢什么口味”
4.1 通过 trackName 和 artistName 反查曲目 ID
到这一步,你已经有了艺人名、歌名和播放行为,但分析口味画像还需要每首歌的音频特征——能量值、活跃度、舞蹈性这些。导出数据里没有曲目 ID,所以第一步是根据歌名和艺人名去 API 搜索,拿到官方曲目 ID。
python复制import spotipy
from spotipy.oauth2 import SpotifyOAuth
sp = spotipy.Spotify(auth_manager=SpotifyOAuth(
client_id="你的Client_ID",
client_secret="你的Client_Secret",
redirect_uri="http://localhost:8080/callback",
scope="user-read-playback-state"
))
搜索请求的核心逻辑是:遍历播放记录里出现过的每个曲目组合,调用 sp.search(),取第一个结果。这里有一个非常实际的工程问题:歌曲重名现象很常见,一个搜索请求可能返回多个同名歌曲,如果直接取第一个,很有可能是翻唱或 remix 版本,导致音频特征错位。我的解决方法是搜索时同时传入 artist:XXX track:YYY 做精确过滤,再用 duration_ms 对播放时长做校验,如果搜索结果的时长和本地记录的播放时长差太多就标记为待人工确认。
python复制def get_track_id(artist_name, track_name, played_ms):
query = f"artist:{artist_name} track:{track_name}"
result = sp.search(q=query, type="track", limit=5)
for item in result["tracks"]["items"]:
if abs(item["duration_ms"] - played_ms) < 15000:
return item["id"]
return None
这个匹配的准确率取决于本地记录与线上曲库的对应程度。大部分主流歌曲都能准确匹配,但小众现场版、Demo 或地区独占曲目往往匹配失败,这种匹配不上的歌就跳过,不影响整体画像。
4.2 音频特征字段解读:energy、valence、danceability 到底代表什么
Spotify 官方 API 返回的音频特征字段有十几个,每个字段是 0.0 到 1.0 之间的浮点数,它们的含义需要理解准确,否则画出的图表很容易误导。重点看这几个:
| 字段 | 含义 | 我的解读 |
|---|---|---|
| danceability | 舞蹈性,衡量节奏稳定性、节拍清晰度 | 数值越高越适合跟着晃,典型如舞曲、流行歌 |
| energy | 能量感,感知强度和活跃度 | 数值高不代表吵闹,更接近“输出强度” |
| valence | 音乐积极情绪 | 数值低往往显得悲伤压抑,但和曲风不完全对应 |
| acousticness | 原声程度 | 数值高说明接近纯乐器原声录制 |
| instrumentalness | 器乐化程度 | 数值高说明没有人声或人声极少 |
| tempo | 每分钟节拍数(BPM) | 反映整体速度 |
做一个生活化类比:energy 像是一个人讲话时的音量大小,valence 更像说话时的表情和语气,danceability 则像语气里是不是带着让人想跟着晃的节奏感。这三个字段组合起来基本能描述一首歌在情绪上的画像,也是我做个人口味分析时最常看的三个指标。
4.3 个人口味画像:Top 曲目和全部曲目的特征对比
有了音频特征之后,就可以回答一个很有意思的问题:你单曲循环的歌、完整听完的歌、以及在深夜听的那些歌,它们的风格特征是不是高度相似?
我的做法是先筛选出三类曲目集合:全量播放的所有歌曲、播放次数 Top 100 的歌曲、单曲循环次数最多的 20 首歌。然后分别计算每个集合里曲目特征的平均值和标准差,再做对比。
python复制feature_cols = ["danceability", "energy", "valence", "acousticness", "instrumentalness"]
summary = top_tracks[feature_cols].mean()
从结果能非常直观地看出:你的口味到底是集中在某个特定风格区间,还是跨越了多个风格。有些人听歌的 valence 均值长期偏低,说明他歌单里悲伤情绪的歌占比偏高;有些人 instrumentalness 均值明显高于平均水平,说明大量纯音乐、电子氛围乐出现在他的播放历史里。这些结论配合时间维度一起看会更有趣,比如白天听的歌 energy 值高、晚上听的歌 acousticness 值高,这种情况说明你很会根据时段选歌,而不是随机播放。
音频特征还有一个实用场景:拿两个歌单做对比。我用之前分析出的“单曲循环歌单”和“从未完整播放过的歌单”做一个特征均值对比,就能看出自己在不同状态下听歌口味的差异,这个比单独看数字有说服力得多。
5. 可视化:把分析结果变成能直接发朋友圈的图
5.1 播放时间热力图:小时×星期,一眼看出通勤和深夜
在清洗阶段已经生成了 hour 和 weekday 两列,直接用 pivot_table 构造一个每小时一条、每星期一列的矩阵,用 seaborn 的热力图展示。
python复制import seaborn as sns
import matplotlib.pyplot as plt
pivot = df.pivot_table(index="hour", columns="weekday", values="trackName", aggfunc="count")
plt.figure(figsize=(10, 6))
sns.heatmap(pivot, annot=False, cmap="YlOrRd")
这张图是整份分析里信息密度最高的单张图。我曾注意到不少人在做热力图时直接用 pandas 的 dt.dayofweek 得到数字 0 到 6,但忘了 0 是周一而不是周日,导致图表横轴顺序和实际星期错位。建议在画图前先把数字映射到星期名称,确保可读性。
热力图的解读重点看“亮块”的位置分布。我的数据里周一到周五的早八点和晚十点都是亮块,周六的下午变成了亮块,说明工作日听歌高度集中在通勤和睡前,而周末的听歌场景明显不同。这种模式也可以继续细化到按月份做多个热力图对比,观察季节变化带来的习惯迁移。
5.2 Top 歌手横向条形图和月度趋势折线
Top 歌手用横向条形图展示,因为歌手名通常比较长,横向排列比纵向排列更容易阅读。做法是先算 Top 20,再 sort_values 后画图。
python复制top_artists = df.groupby("artistName")["msPlayed"].sum().nlargest(20).sort_values()
plt.figure(figsize=(8, 8))
plt.barh(top_artists.index, top_artists.values / 60000)
另一个必画的是月度播放时长折线图,它能看出你对音乐的总投入随时间的变化。我使用 dt.to_period("M") 做按月分组,再对 msPlayed 求和并转换为小时。这里要注意的是月与月之间的播放量差异可能很大,比如有一段时间你在准备考试,听歌量会明显下降,这类波动本身也是故事的一部分,不需要平滑处理。
5.3 音频特征对比图:两个歌单一眼看出差异
音频特征用分组条形图或者雷达图都可以,我更推荐分组条形图,因为雷达图在特征数量少时容易过度美化信息,而分组条形图更诚实。具体做法是把手机里某个“工作歌单”和你“单曲循环列表”的曲目特征分别取均值,并排画出来。
python复制import numpy as np
features = ["danceability", "energy", "valence", "acousticness", "instrumentalness"]
playlist_a = df_group_a[features].mean()
playlist_b = df_group_b[features].mean()
如果两个歌单的 valence 相差很大,说明一个是提神向,一个是低情绪向。这不仅仅是一个好玩的发现,还能反过来指导你管理歌单:比如把高 energy、高 valence 的歌单独拉出来做“早晨通勤”歌单,把低 energy、高 acousticness 的歌做成“深夜阅读”歌单。整个流程跑通后,可以顺手打包成一个 HTML 报告,把上面所有图表和表格汇总成一份可分享的个人听歌年报。
6. 这趟跑下来最值得说的四个坑
6.1 导出的时间有滞后,最新数据一直在变
这个坑最容易踩。你申请数据下载时,拿到的 zip 包并不是实时快照,通常只更新到申请之前几天。如果你一边等导出邮件一边继续听歌,拿到数据的当天,最新几天的记录其实不在里面。所以做任何“年度总结”或者“当月报告”时,一定要在数据切分边界留出冗余,不要用今天的日志去算昨天的一整周,因为今天的数据只更新到几天前。最稳妥的做法是在下载完成后再停两天,期间继续正常听,留出完整的最新时段。
6.2 API 搜索匹配不稳定的问题
用 API 反查曲目 ID 时,sp.search() 返回的结果顺序不是恒定的,而且翻唱、原声版、加速版非常容易混入。如果你的本地播放记录里有大量类似 (sped up)、(live)、(acoustic) 后缀的歌,先做后缀清洗再搜索。另一个办法是拿到结果后不仅比较时长,还比较 album 的发布时间,用发布时间和你听这首歌的日期做交叉验证,能明显提升匹配准确率。匹配不上的记录不要硬编,跳过即可,只要跳过的比例不高,对整体画像影响很小。
6.3 播放记录的重复计数问题
StreamingHistory 记录里的 msPlayed 表示这条记录实际播放了多少毫秒。如果一首歌在一台设备上播放到一半,又在另一台设备从头播放,就会产生两条记录,同一首歌在统计播放次数时会被重复计算。如果你关心的是“行为触达”,重复计数没有问题;如果你关心的是“这首歌被完整听完的意愿”,就需要对同一首歌同日多次出现的记录做时间窗口合并。两种口径都有意义,关键是要在分析开头就说清楚自己采用了哪种口径,否则后续所有结论都会出现偏差。
6.4 音频特征请求上限和限流处理
sp.audio_features() 接口一次最多接受 100 个曲目 ID,超过上限会报错。如果你的 Top 歌曲超过 100 首,需要分批次调用并拼接结果。另外 API 有速率限制,请求太密集会返回 429 状态码。我在处理 2000 多首歌时,按每批 100 首、每批间隔 1 秒的节奏调用,没有触发限流。如果你需要调用的量更大,建议把请求函数封装成带重试机制的版本,捕获到 429 就 sleep 一段时间再重试。这个封装在很多别的 API 场景里也能复用,一次写好省得后面反复补。
python复制import time
def batch_audio_features(sp, ids, batch_size=100):
results = {}
for i in range(0, len(ids), batch_size):
batch = ids[i:i+batch_size]
features = sp.audio_features(batch)
results.update({item["id"]: item for item in features if item is not None})
time.sleep(1)
return results
我个人在实际操作中最深的体会是,数据分析的乐趣并不完全在于图表多漂亮,而在于你终于能用自己的逻辑去验证那些官方 Wrapped 里被包装过的结论。比如你可以算出自己的“深夜单曲循环艺术家”、可以发现某个你认为冷门的独立音乐人其实占了全年播放时长的两成。这份数据是你自己生活节奏的投影,换个统计口径就会看到不同的故事,多调几次参数,你会发现很多意外的自我认知。
