每年年底,音乐平台都会给你生成一份漂亮的年度报告,告诉你这一年听了多少小时、哪个歌手上榜最多。可我这种爱较真的人,光是看海报级别的总结根本不过瘾,总想把原始数据拿到手里自己翻——比如最近我到底在哪天半夜循环了那首老歌,通勤路上我的曲风偏好有没有发生变化,我的"年度歌手"是真爱还是只靠三首歌刷出来的。试过几个第三方统计网站,发现个人能拿到的数据很有限,于是我干脆用 Python 从 Spotify 官方接口拉数据自己分析。这篇东西就是我从零开始搭建一套"自己听歌数据"分析流程的完整记录,从开发者应用创建、OAuth 授权、数据拉取,到时间维度和音频特征的挖掘,全都踩了一遍坑,希望能给同样有兴趣折腾自己数据的读者省点时间。
1. 先别急着写代码,盘清楚你的 Spotify 数据到底长什么样
很多人上手第一步就卡住了,是因为根本没搞清楚 Spotify 到底能给你什么数据。我一开始也是这样,以为只要调一个接口就能拿到"从注册至今的全部播放记录",结果发现根本不是那么回事。
1.1 两套数据源:实时 API 与历史导出
Spotify 的数据来源实际上有两条路,各管一段。
第一套是 Web API。它提供的是"当前状态"类数据,比如你最近播放了什么、收藏了哪些歌、建过哪些歌单、播放列表里的曲目有哪些、每首歌的音频特征是什么。这类数据实时性高,但历史深度有限,尤其是"最近播放"这个接口,最多只能给我最近 50 条记录,想用 offset 翻页往前翻?门都没有。
第二套是 账号数据导出。在 Spotify 的隐私设置里,你可以申请将自己账号的完整数据导出,Spotify 会以邮件形式发一个压缩包给你,里面有一个或多个 StreamingHistory0.json 这样的文件,记录了你从开始听歌那天起每一首歌的播放时间、艺术家、歌曲名和播放时长。这才是真正意义上的"完整历史"。
我的做法是两条路结合:先用 Web API 拿到最新的收藏曲库和音频特征,做实时画像;再用导出的 StreamingHistory 数据回填历史上听过但没收藏的歌。两套数据配合使用,基本上能把"我喜欢什么"和"我怎么听歌"这两个问题回答得很完整。
1.2 API 返回的结构里,哪些字段能直接变成分析指标
以 current_user_recently_played 为例,返回的 JSON 结构大致长这样:
json复制{
"items": [
{
"track": {
"id": "4uLU6hMCjMI75M1A2tKUQC",
"name": "Karma Police",
"artists": [{"name": "Radiohead"}],
"duration_ms": 253266,
"popularity": 72
},
"played_at": "2024-01-15T20:14:30.000Z"
}
]
}
这里面有两个字段特别值得注意:played_at 记录播放时间,duration_ms 记录歌曲总长度。把 duration_ms 和播放记录里的实际播放时长(导出数据里有 msPlayed)对比,可以判断这首歌是被完整听完还是听了几句就切掉了,进而区分"真正喜欢"和"随手划走"。
保存的曲库接口 current_user_saved_tracks 多返回一个 added_at 字段,也就是收藏时间。把 added_at 和歌曲发行时间放在一起看,能分析出你是"早就喜欢这首歌,最近才收藏",还是"新歌一出来就上头"。
1.3 规划你的分析目标
在写任何代码之前,先想清楚你想回答什么问题。我问了自己三个问题:
- 我一天之中什么时候听歌最多?早上通勤还是深夜?
- 我的曲库整体是偏暴躁还是偏安静?
- 我保存的歌单里,哪些歌是被我"雪藏"的?
这三个问题分别对应三条技术路线:时间维度分析、音频特征聚合、歌单内部排序。你的分析目标可以不一样,但一定要明确,否则很容易陷入"把数据拉出来不知道下一步干嘛"的尴尬。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 开发者应用配置和 Python 环境准备,这一步最容易被忽略
我见过不少人栽在这第一步,其实并不是代码难,而是几个配置细节没做好。
2.1 在 Developer Dashboard 创建应用
打开 Spotify 的 Developer Dashboard,登录后点 Create App,填一个应用名称和描述,关键是 Redirect URI 这一栏,必须填:
code复制http://localhost:8888/callback
这个地址是本地回调地址。Spotify 的授权流程是:用户同意授权后,Spotify 会带着一个授权码跳到这个地址,本地的脚本捕捉到这个授权码,再换到访问令牌。如果不提前在 Dashboard 里登记这个地址,授权时就会报 redirect_uri_mismatch 错误。
创建完成后,你会在应用页面看到两个重要字符串:Client ID 和 Client Secret。Client ID 相当于应用的公开标识,Client Secret 是密码,绝对不能泄露,也不能提交到 GitHub。我一般把它们写进一个 .env 文件,用 python-dotenv 读取,或者直接放到脚本文件顶部、用 os.environ.get() 读取环境变量。
2.2 Python 环境与依赖
这套分析流程需要的第三方库其实就几个:
bash复制python3 -m venv spotify-analysis
source spotify-analysis/bin/activate
pip install spotipy pandas matplotlib python-dotenv
spotipy 是社区里最成熟的 Spotify Web API 封装库,pandas 用来做数据聚合,matplotlib 做可视化。如果是在国内网络环境,可以给 pip 加镜像参数把下载速度提上去。
需要注意 Python 版本,建议 3.10 及以上。我在 3.8 上跑过也正常,但新版本对类型标注和 JS 类的库兼容性更好,没必要跟自己过不去。
2.3 敏感信息管理的小习惯
把 Client ID 和 Client Secret 写进 config.py 时,记得加一行:
python复制import os
CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID")
CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET")
REDIRECT_URI = "http://localhost:8888/callback"
然后在项目根目录建一个 .env:
code复制SPOTIFY_CLIENT_ID=你的ID
SPOTIFY_CLIENT_SECRET=你的Secret
在 .gitignore 里忽略 .env 和 config.py。项目虽小,但这个习惯能避免很多不该有的麻烦。
3. 授权流程实测:让脚本替你安全地访问听歌记录
这一章是整个流程里最绕、也最容易出问题的地方。我把它单独拎出来讲,是因为理解了 OAuth 流程,后面所有接口调用都会顺理成章。
3.1 为什么非要 Authorization Code Flow
Spotify 的授权方式有好几种,简单来说分两类:一类是只访问公开数据,不需要用户授权的 Client Credentials Flow;另一类是访问用户私有数据,比如你的收藏、播放记录,必须用 Authorization Code Flow。
你可以把前者理解成"凭记者证进发布会现场",只能听到公开宣布的消息;后者是"拿到住户的钥匙进家里参观",能看的东西更私密、权限更高。分析自己的听歌数据,显然属于后者。
Authorization Code Flow 的完整链路是:
- 脚本打开一个浏览器页面,跳转到 Spotify 授权页
- 你登录并点击同意授权
- Spotify 回调到你预设的
Redirect URI,地址栏里带一个code参数 - 脚本截获这个
code,结合Client Secret,向 Spotify 请求访问令牌 - 拿到令牌后,用它去请求各种数据接口
3.2 授权核心代码
用 spotipy 的话,这个流程可以压缩得很短:
python复制import spotipy
from spotipy.oauth2 import SpotifyOAuth
sp = spotipy.Spotify(auth_manager=SpotifyOAuth(
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
redirect_uri=REDIRECT_URI,
scope="user-read-recently-played user-library-read user-top-read playlist-read-private",
cache_path=".spotify_cache",
open_browser=True
))
scope 是一个空格分隔的权限列表。这里我申请的四个权限分别是:
| Scope | 能做什么 |
|---|---|
user-read-recently-played |
读取最近播放记录 |
user-library-read |
读取收藏的歌曲 |
user-top-read |
读取你的 Top 歌手和 Top 歌曲 |
playlist-read-private |
读取私有播放列表 |
第一次运行脚本时,open_browser=True 会自动打开浏览器,把授权页面弹到你面前。同意之后,脚本会拿到令牌并把它写入 .spotify_cache 这个缓存文件。之后每次调用,spotipy 都会自动检测令牌是否需要刷新,完全不需要你操心。
3.3 token 缓存和多项目冲突
.spotify_cache 这个缓存文件很多人会忽略,但恰恰是坑的根源。一次授权换到的 access token 有效期只有一小时,但 refresh token 的有效期很长(理论上不过期,除非用户撤销授权)。spotipy 会把这两个 token 都写进 cache 文件,需要刷新时拿 refresh token 去换新 token。
问题在于:如果你有两个项目用了同一个 cache_path,但它们的 client_id 不同,后一个项目在授权时会覆盖掉前一个项目的 refresh token,前一个项目下次运行就会报 401 未授权错误。我吃过这个亏:为了美观,把缓存文件统一放到了 ~/.spotify_cache,结果 A 项目一跑,B 项目就废了。
所以每个项目必须用独立的 cache_path,比如 .spotify_cache 直接放在各自项目根目录里。
4. 拉数据不是一条命令的事:分页、限额与字段陷阱
当你把授权搞通之后,真正开始拉数据,会发现接口调用远没有想象中那么简单。
4.1 最近播放只有50条,说了你可能不信
current_user_recently_played 这个接口,文档上写的是返回最近播放记录,但实际最多只给你 50 条,而且不支持 offset 翻页。也就是说,不管你是今天第一次用这个接口,还是三个月后再次调用,都只能拿到当下最新的 50 条。
这意味着,如果你想分析自己的长期听歌习惯,Web API 这条路是走不通的。第三方年度报告网站拿到的"全年数据",如果不是用户提前授权并持续调用接口存下来的,根本不可能做到。这也是为什么我一直强调:长期分析必须用账号数据导出。
4.2 保存曲库的分页拉取
相比播放记录,收藏曲库就大方得多,可以翻页拉取全部收藏。但"全部"也可能有大几千首,一页最多 50 条,需要写分页逻辑:
python复制def fetch_all_saved_tracks(sp):
all_items = []
offset = 0
while True:
batch = sp.current_user_saved_tracks(limit=50, offset=offset)
all_items.extend(batch["items"])
if batch["next"] is None:
break
offset += 50
return all_items
注意 batch["next"] 这个字段:如果它是 None,说明后面没有更多数据了,循环结束;如果它有值,就继续 offset += 50。这个写法适用于所有带 offset 分页的 Spotify 接口。
4.3 限流 429 与优雅重试
Spotify API 是有速率限制的,虽然实践中很少遇到,但一旦你拉取大量数据,触发 429 错误是意料之中的事情。429 响应里会带一个 Retry-After 头,告诉你要等多少秒。
我封装了一个带重试的请求函数:
python复制import time
from spotipy.exceptions import SpotifyException
def fetch_url_with_retry(func, *args, retries=5, **kwargs):
for attempt in range(retries):
try:
return func(*args, **kwargs)
except SpotifyException as e:
if e.http_status == 429:
wait_time = int(e.headers.get("Retry-After", 1)) + 1
time.sleep(wait_time)
else:
raise
raise RuntimeError("请求失败,重试次数超限")
实际使用中,碰到 429 之后睡几秒再重试基本都能成功。真正要注意的反而是代码逻辑里的死循环——如果接口因为鉴权失败反复返回错误,你的重试代码可能会把请求打到一个失效的 endpoint 上,白白浪费时间。
4.4 真·历史数据:从账号里导出 StreamingHistory
要拿到从第一天听歌到现在的完整记录,唯一可靠的办法就是账号数据导出。
具体操作:打开 Spotify 的 Privacy Settings,找到 Download your data 相关入口,申请导出。Spotify 通常会在几个小时到一周不等的时间内发一封邮件,里面是一个 my_spotify_data.zip。解压之后,你会看到若干个 StreamingHistory0.json、StreamingHistory1.json 之类的文件。
每个文件里的记录格式很简洁:
json复制{
"endTime": "2023-09-01 22:14",
"artistName": "Radiohead",
"trackName": "Karma Police",
"msPlayed": 253266
}
endTime 表示这首歌播完的时间,msPlayed 表示这次实际播放了多少毫秒。注意,endTime 不一定带时区信息,在跨时区分析时要小心,具体我在第 5 章会细说。
5. 从"什么时间听了什么歌"看你的作息规律
数据都拿到手了,接下来才是有意思的部分。第一个我想分析的角度是时间:我自己到底什么时候最沉迷音乐。
5.1 时间戳处理:时区、字符串格式化
Web API 返回的 played_at 是 ISO 8601 格式,带时区信息,比如 2024-01-15T20:14:30.000Z,这个 Z 表示 UTC 时间。如果你在别的时区,要先把时间转成本地时间再聚合,否则统计结果会整体偏移几个小时。
导出数据里的 endTime 则是 2023-09-01 22:14 这种格式,没有时区后缀。我比较了多个样本后发现,它一般就是记录时的本地时间,但不排除不同账号有差异。稳妥的做法是拿几天的数据和你实际的作息对照一下,确认没有偏移再投入使用。
处理时间戳的代码:
python复制import pandas as pd
df = pd.read_json("StreamingHistory0.json")
df["end_time"] = pd.to_datetime(df["endTime"], format="%Y-%m-%d %H:%M")
df["hour"] = df["end_time"].dt.hour
df["weekday"] = df["end_time"].dt.dayofweek
5.2 按小时和星期聚合的 pandas 写法
把数据聚合成"每天各个时段的播放时长",一行代码:
python复制hourly_play_ms = df.groupby("hour")["msPlayed"].sum()
hourly_play_min = hourly_play_ms / 60000
weekday 从 0 到 6 分别对应周一到周日。我想看一周的作息,可以用透视表:
python复制weekday_hour = df.pivot_table(
index="weekday",
columns="hour",
values="msPlayed",
aggfunc="sum",
fill_value=0
)
这样生成一张 7x24 的表格,行是周几,列是小时,值是播放毫秒数。转成分钟之后,热力图一画,一周的音乐作息直接视觉化。
5.3 可视化:柱状图看一天的听歌分布
画图用 matplotlib 就够了:
python复制import matplotlib.pyplot as plt
plt.figure(figsize=(12, 5))
plt.bar(hourly_play_min.index, hourly_play_min.values)
plt.xlabel("Hour")
plt.ylabel("Played minutes")
plt.title("24-hour listening habit")
plt.show()
我跑完自己的数据发现,一天里有三个明显的峰值:早上 7 点到 9 点,下午 4 点到 6 点,以及晚上 10 点到凌晨 1 点。前两个很容易理解,通勤时间;第三个就值得玩味了,说明我是一个典型的"睡前不折腾点声音就难受"的人。
5.4 清洗规则:30 秒以下的切歌不值得分析
拿到 StreamingHistory 数据后,第一步不是直接分析,而是清洗。我定了一条规则:msPlayed < 30000(30 秒)的播放记录直接过滤掉。
原因很简单:很多人在一首歌播放开头就切掉了,可能因为这是自动播放的下一条,也可能因为试听了一下不感兴趣。这 30 秒内的播放更多是"随机行为",不是真实的收听习惯。如果不滤掉,会导致大量低质量数据混进来,把那些"完整听完"的歌的权重稀释掉。
python复制df_filtered = df[df["msPlayed"] >= 30000]
当然,这个阈值根据你自己的习惯可以调整。如果你是一个很喜欢在听歌时预览开头的人,可以提高到 60 秒,关键是保证剩下的记录能代表"你有意识地听了"。
6. 用音频特征给歌单打分:danceability、energy 与 valence
时间维度回答的是"我什么时候听歌",音频特征回答的则是"我到底喜欢什么样的歌"。这一块在 Spotify API 里非常特别,几乎是各家平台里独一份的能力。
6.1 audio_features 里最常用的指标
Spotify 给每首歌算了一套音频特征,取值大多在 0 到 1 之间,含义非常直白:
danceability:这首歌适合跳舞的程度,节奏感强不强energy:整体能量强度,偏高通常意味着"吵、炸、热血"valence:情绪积极程度,越低越丧,越高越欢快acousticness:原声乐器的比例,越高越像不插电现场instrumentalness:器乐占比,越高越接近纯音乐tempo:每分钟节拍数,BPM
我用一个生活化的理解:energy 决定了这首歌会不会让你想跑步,valence 决定了你在跑步时是面带微笑还是面无表情。这两个指标放在一起,基本能粗略还原一首歌的氛围。
6.2 批量获取与均值计算
获取收藏曲库里所有歌的音频特征,注意接口一次最多只能传 100 个 track id,多了会报错:
python复制ids = [item["track"]["id"] for item in saved_items if item["track"]["id"]]
features = []
for i in range(0, len(ids), 100):
batch = sp.audio_features(ids[i:i+100])
features.extend(batch)
然后转成 DataFrame:
python复制feature_df = pd.DataFrame(features)
feature_df = feature_df.dropna(subset=["id"])
这里有个隐藏坑:audio_features 返回的列表里可能出现 None,表示某些 track id 无法获取特征。最常见的原因是本地文件、播客、以及某些版权受限的歌曲没有音频特征数据。dropna(subset=["id"]) 就是干这个的。
算均值:
python复制mean_cols = ["danceability", "energy", "valence", "acousticness", "instrumentalness"]
print(feature_df[mean_cols].mean())
我自己曲库的结果是:danceability 0.56,energy 0.62,valence 0.48。翻译成人话就是:整体偏中能适中,情绪略微低沉,偶尔想动一动但也没有很嗨。
6.3 一个实用小工具:按"能量"排序生成通勤歌单
光看平均数还不够过瘾。我写了个小脚本,把保存的歌曲按"综合能量指数"排序,给早高峰通勤生成一版提神歌单:
python复制feature_df["mood_score"] = feature_df["energy"] * 0.6 + feature_df["valence"] * 0.4
top_mood_songs = feature_df.sort_values("mood_score", ascending=False).head(20)
效果还挺好,排在前面的明显都是那种打击乐密集、节奏偏快、旋律上头的歌。排在最末尾的则是几首安静到不行的民谣,非常适合睡前听。
这个思路可以继续发散:想找"深夜悲伤歌单",就把 valence 排升序;想找"跑步歌单",就按 tempo 在 120 到 140 之间筛。
6.4 特征分析的局限与更多想象空间
要说局限,最明显的就是音频特征只能反映声学属性,不能反映歌词内容。比如一首歌词很丧但旋律特别欢乐的歌,valence 可能会给出一个偏高甚至接近 0.9 的乐观评分,实际听感却完全是另一回事。
另外一个可以深度玩的方向是按时间给特征分组:把每个月收藏的歌单独算一遍均值,看看自己的音乐口味是逐渐变硬还是变软。这种时间序列上的口味漂移分析,比单纯看全年均值有意思得多。
7. 真实踩坑记录:401、空数据和时区偏移的定位链路
前面把流程讲得很顺,实际操作里我踩过的坑一个都不少。最后说三个最典型的,每个都是花了小半天才定位到根因的。
7.1 现象:脚本跑得好好的,今天突然 401
有一天我打开前一天还能正常跑的脚本,结果一行 SpotifyException: 401 Unauthorized 直接打在脸上。第一反应是 token 过期了,但 spotipy 明明会自动刷新。
查了 auth_manager 的日志才发现,脚本去刷新 token 时,用的是缓存文件里的 refresh token,而那个 token 对应的 client_id 是另一个项目的。这就回到了第 3 章提到的多项目共享缓存文件的问题。我两个项目用了同一个 cache_path,后授权的那个项目把前一个的缓存覆盖掉了,两个项目互相踢皮球,最终谁都跑不了。
7.2 定位过程:cache、scope、Dashboard
遇到这种问题,不要慌了就删除整个缓存重新授权,先按顺序排查:
- 打开 cache 文件,看里面对应的
client_id是否和当前项目一致 - 如果一致,check 一下当前账号在 Dashboard 里是否撤销了应用授权
- 如果以上都没问题,再看
scope是否和你请求数据需要的权限匹配
我那次是第一步就中了。解决办法很简单:每个项目用独立的 cache_path,删除旧的混淆缓存文件,重新授权一次,问题立刻消失。
7.3 数据里为什么全是 None:清洗 filter 的必要性
还有一次,我把 audio_features 返回的数据直接塞进 DataFrame,发现很多行都是 None。刚开始以为接口坏了,仔细一看,是收藏曲库里混进了几首本地文件和播客节目。本地文件没有 Spotify 的 track id,audio_features 拿不到特征数据时不会报错,而是在对应位置返回 None。
这类脏数据不清理干净,统计分析会被整体带偏。建议在拉数据阶段就做一次过滤:
python复制valid_items = [item for item in saved_items if item["track"] and item["track"]["id"]]
我后来还会顺手把 track["is_local"] 为 True 的记录排除掉。
7.4 时区偏移让统计结果差了两小时
导出数据里的 endTime 时不带时区,我最初直接按本地时间处理,结果早上 7 点的听歌高峰整体向后移了 8 个小时,变成了下午 3 点。一开始我以为是自己最近作息太乱,后来拿着具体某一首歌的对播记录核对,才发现是时区偏移。
处理办法是统一把字符串先转成不带时区的 datetime,再手动指定时区。如果你所有的数据都是在本地产生的,导出数据一般记录的就是本地时间,不需要再多转一次;但如果 Web API 数据也在同一张表里,played_at 是 UTC,需要先 .dt.tz_convert 成当地时区,再提取小时字段。两种数据放一起比较时,这一点特别容易翻车。
7.5 收尾经验
整套流程跑下来,我最真切的体会是:分析自己的数据这件事,技术上其实门槛不高,真正的价值在于你把数据拿到手里后,会发现很多原本只存在于"感觉"层面的东西,突然就变成了可以量化的规律。周末早上我适合听什么、加班到深夜我到底循环了多少次同一首歌、我嘴上说着讨厌口水歌但收藏列表里躺着多少首高 danceability 的歌——这些问题图表一出来,答案全在那里,还挺打脸。有机会的话,建议你也试一次,说不定会发现一个自己都没意识到的"音乐人格"。
