☰
用Python分析Spotify听歌数据:从JSON清洗到可视化全流程
2026/10/12 3:14:48 网站建设 项目流程

你有没有在年底翻自己的年度听歌报告时想过一个问题:这些数据能不能直接导出来,自己用 Python 跑一遍分析?我试过几次,结论是可以,而且玩法比官方给的年度报告多得多。今天这篇博客就拿自己的真实导出数据,从拿到原始 JSON 文件开始,到生成可视化和结论,完整走一遍流程。中间会穿插不少我踩过的坑,以及你大概率会关心的边界场景。适合想拿 Spotify 听歌数据练手的同学,也适合那种“只会用 pandas 读 csv、看到嵌套 JSON 就头大”的初级选手。照着这套思路操作,你至少能回答“我到底在几点最爱听歌”“哪段时间循环得最凶”“自己是不是真的把某一首循环了两百遍”这三个问题。

1. 先摸清数据从哪来:两种方案各有各的取舍

1.1 官方导出:胜在全量历史,但文件结构要先熟悉

很多人不知道,流媒体平台在账户设置里提供了“导出个人数据”的功能。去账户页面的数据与隐私相关入口,申请一份完整的数据副本,提交之后等通知邮件,下载压缩包解压,就能拿到自己在平台上的历史记录。这个申请过程不是秒回,有时候几小时,有时候好几天。我自己的经验是:把它当作“沉淀式分析”的唯一数据源,因为它覆盖了你注册以来的几乎所有播放行为,时间跨度最长,颗粒度也最稳定。

打开压缩包之后不要懵,里面并不是一个单文件,而是一堆按类型组织好的 json、csv、html。跟播放历史直接相关的是StreamingHistory开头的文件;历史越长,文件越多,常见的有StreamingHistory0.json、StreamingHistory1.json……直到 N。这类文件的结构非常统一,每条记录只有四个字段:

字段名含义备注
ts播放发生的时间ISO 8601 格式,默认是 UTC 时间
msPlayed播放时长单位是毫秒,不是秒
artistName演出者名称注意存在同名艺术家
trackName曲目名称有时会有重名歌曲

导出数据里没有歌曲总时长,也没有专辑封面、音频特征、设备信息。这意味着“我完整听完了这首歌的百分之多少”这种问题,官方导出文件本身回答不了,需要后续去对接曲目信息库补数据。但作为“行为数据”,它已经非常够用了,尤其是在统计分析层面,已经有 mp 的毫秒级时间戳,比很多爬虫拿到的数据都干净。

1.2 接口拉取:适合增量数据和音频特征,但有限流

第二种方式是通过开发接口直接拉取自己账户的数据。使用第三方封装库,比如社区里最常见的 spotipy,就可以拿到“最近播放记录”“音频特征”“艺术家详情”等信息。但用之前必须先去开放平台后台创建应用,拿到 client id 和 client secret。这里有一条安全红线:不要把凭据硬编码进脚本里,更不要把凭据写成常量提交到公开仓库。我习惯用环境变量来注入,脚本里只读取os.environ.get("SPOTIFY_CLIENT_ID")。

接口方案有一个硬伤:它无法提供完整历史。以“最近播放”接口为例,最多只能拿到最近 50 条播放记录,分页上限也远够不到年度报告那种全量统计。那它存在的意义是什么?答案是:补足导出文件缺失的部分,尤其是音频特征。导出文件没有“这首歌能量多少、节奏多快、调性是什么”,但接口可以按曲目 ID 批量查询。所以我更推荐的做法是“两条腿走路”:导出数据负责全量时间线,接口数据负责特征和补充信息,最后不管是 Excel 还是 pandas 的 merge 都能把两边合起来。

两种方案的适用路径,我放在一起对比一下:

对比维度官方导出接口拉取
历史完整性全量覆盖只保留最近 50 条左右
音频特征没有有,按曲目批量查询
数据延迟申请需等待,有延迟实时
获取成本零门槛需要申请开发者凭据
适合场景年度/月度全量分析增量监控、特征分析、歌单构建

1.3 一个更优的合并思路:时间线走导出,特征走接口

如果只是玩票,只分析导出文件就够;但如果想让分析跑得更深,我强烈建议把两边数据结合起来。具体做法是:先把导出文件整理成一张大表,得到每天、每小时的播放行为;再把出现的曲目统一去重,去调用接口批量取音频特征;最后以曲目 ID 或者“歌曲+艺术家”作为关联键把特征合并回主表。这样既不会因为接口限流拿不到全量数据,又能让后面的分析覆盖节拍、情绪、能量等维度。

这一步也是我看过很多半途而废的分析项目失败的原因:他们一开始就想用接口拉全部历史,结果发现限流严重,或者发现接口只给最近 50 条,最后拿到一份根本没有分析价值的数据。所以不要偷懒,先把官方导出流程走通,再去碰接口。

2. 数据清洗:把一堆 JSON 变成一张能分析的表格

2.1 多文件合并:先写个健壮的拼接脚本

拿到多个StreamingHistory文件之后,最容易想到的方法是手动复制,但这不是该做的事。正确姿势是写几行 Python,用glob批量匹配文件,再用json逐个读取。这里不需要太复杂的逻辑,但要注意每个文件都可能有不同大小的数据集,所以最后要ignore_index=True重新编号索引。

import glob import json import pandas as pd frames = [] for file_path in sorted(glob.glob("MyData/StreamingHistory*.json")): with open(file_path, "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)

这里多提一句:StreamingHistory文件为了兼容老系统,字段名一直是很朴素的artistName、trackName,没有用下划线命名。你可以在第一次读入后顺手统一改成artist_name、track_name之类的列名,后面写聚合代码会舒服很多。另外,如果输出的结果有 0 行,先检查 glob 的路径对不对,别纠结后面的分析逻辑。

2.2 时间戳换算:最容易翻车的一道坎

官方导出文件里的时间戳是 UTC 时间。如果你处于东八区,直接对ts做dt.hour统计,那你的凌晨两点就会变成前一天晚上的 18 点,整个“熬夜热力图”会完全错位。这个问题我踩过一次,当时看到热力图里下午四点到六点是收听高峰,还以为自己是什么健康作息标兵,实际那是我凌晨两点到四点的真实写照,只是时区没换算。

正确的做法是用 pandas 先转成带时区的时间,再转换到你所在的时区,然后才提取小时和星期字段。下面这段代码我把“原字段”和“本地时间字段”都保存下来,方便后面做时间区间筛选:

import pandas as pd df["ts"] = pd.to_datetime(df["ts"], utc=True) # 把 UTC 时间换算到本地时区,这里以 UTC+8 为例 df["local_time"] = df["ts"].dt.tz_convert("Asia/Shanghai") df["hour"] = df["local_time"].dt.hour df["weekday"] = df["local_time"].dt.weekday # 0=周一, 6=周日 df["date"] = df["local_time"].dt.date

细心的人可能会问:tz_convert的Asia/Shanghai是什么?就是一个普通时区名,自己所在时区不在这个列表里的,可以用pytz.all_timezones查一下,或者直接使用timezone(timedelta(hours=8))这种相对偏移。关键在于“先标 UTC,再转本地”,别直接对ts字符串做字符串替换去加几个小时,否则不同日期之间的夏令时问题迟早会冒出来。

2.3 零长度播放记录:不删会严重拉低平均时长

导出数据里有一种很常见的脏数据:msPlayed等于 0。看起来像是“没听就结束”,实际是音频在后台被拉起、被唤醒预加载、或者设备在锁屏状态下发生了错误播放。这种 0 毫秒记录如果保留在表里,会让“平均播放时长”这种统计被拉得极其难看,甚至在聚合时把它们当成一次完整的播放。所以在清洗阶段,我会直接过滤掉msPlayed <= 0的记录。

df = df[df["msPlayed"] > 0].copy()

另外一个容易被忽略的点是“短时长播放”,例如msPlayed只有几秒。它可能是用户手动切歌,也可能是误触。做总体趋势分析时可以先保留,但做“单曲循环率”分析时要把低于 5 秒的记录单独拆出来观察,否则循环率会被非真实播放刷上去。

2.4 构造辅助列:让后续聚合少写十行代码

清洗完成后,我会顺手生成一组辅助列:播放时长(秒)、年-月、年-周、播放日期、歌曲唯一键。这样做的好处是,后面所有分组聚合都不需要再反复写pd.to_datetime和字符串拼接。

df["play_seconds"] = df["msPlayed"] / 1000 df["year_month"] = df["local_time"].dt.to_period("M").astype(str) df["year_week"] = df["local_time"].dt.to_period("W").astype(str) # 用“歌曲名 + 分隔符 + 艺术家名”做唯一键 df["track_key"] = df["track_name"] + " // " + df["artist_name"]

track_key这个字段比直接合并track_name和artist_name更好用,因为同一首歌在不同精选集里可能被标成不同版本,但track_name经常完全一样。以后你想去重、去重后用特征数据回填,都可以直接对这个键操作。

3. 画像分析:从几个经典维度看懂自己的听歌习惯

3.1 时间热力图:你的熬夜指数会先暴露自己

清洗好的表已经有weekday和hour,接下来只需要一次分组就能得到一周 7 天 × 24 小时的热力图数据。这种图非常适合回答“我是不是只有半夜才听歌”“周末和平时作息差异多大”这类问题。

month_day_hour = ( df.groupby(["weekday", "hour"]) .size() .reset_index(name="play_count") )

如果你用的是matplotlib,可以直接pivot成宽表后用imshow画热力图;更省事的方案是用数据分析可视化库,比如seaborn的heatmap。想要一劳永逸,推荐把结果转为透视表:

pivot = month_day_hour.pivot(index="weekday", columns="hour", values="play_count") pivot = pivot.fillna(0)

解读时不要只盯着最大值所在格子,真正有价值的是“夜间时段是否单独形成一座小山峰”。如果周日凌晨 2 点到 4 点有明显的隆起,而工作日没有,说明周末熬夜听歌是你固定的休息仪式;如果一周七天都一样,说明你的生物钟已经被长期且均匀地破坏掉了。

3.2 歌手和单曲榜:统计口径决定了你看到的真相

统计“我最爱听谁”时,必须想清楚一个问题:按播放次数排,还是按播放时长排?很多年度报告默认是“次数”,这对那些只有一分半钟的口水歌极其有利,因为你一天能循环它 40 遍。但如果你想衡量“陪伴自己最久的音乐”,时长更重要。一位歌手用一首 7 分钟的长歌陪你跑了 5 公里,和另一位歌手用一首 90 秒的短歌被你在通勤路上快进,两种度量会给出完全不同的排名。

我的做法是两个指标都算,并且把二者放在一起观察:

artist_stats = ( df.groupby("artist_name") .agg(play_count=("track_key", "count"), total_seconds=("play_seconds", "sum")) .reset_index() .sort_values("total_seconds", ascending=False) ) artist_stats["total_hours"] = artist_stats["total_seconds"] / 3600

这样就能发现藏在“次数榜”里的长情歌手,也能找出“次数多但实际听得很浅”的列表。如果发现自己某位歌手只在入睡前出现,可能意味着你已经把他当成了环境音而不是欣赏对象。

3.3 月度/年度总量:挖掘情绪转折点

播放总量的时间趋势不需要太复杂的统计,按月份汇总播放时长,画一条折线图就够了。但真正有趣的是把月度总量和当时经历过的场景跨起来——某个月突然跌到谷底,很可能对应着出差、备考、失恋或者赶项目;某个月突然冲高,往往是换了一副降噪耳机,或者找到了新的歌单。

从代码角度,按月聚合就是一行:

monthly = df.groupby("year_month")["play_seconds"].sum() / 3600

如果想看累计曲线,再加一个cumsum(),那就会得到一条“从零开始、到年底累计了 xxx 小时”的平滑曲线。这条曲线特别适合用来做自己的年终数据复盘:哪个月你在加班,哪个月你在放假,曲线都写得很诚实。

3.4 切歌与循环:给“听歌行为”增加的微观指标

听歌数据里最细颗粒度的信号就是“短播放”和“重复播放”。我给它们起了两个非正式但很贴切的指标:跳歌率和循环率。

跳歌率计算的是“播放时长小于 30 秒”的记录占比。如果某张歌单的跳歌率特别高,说明它不是用来认真听的,而是被用来随机刷氛围。循环率就更有意思了:同一首歌在一天内播放次数超过 5 次,基本可以认定为“循环上头”。这里要小心区分“因为单曲循环导致的多次播放”和“因为专辑顺序播放导致的前后多次播放”,用track_key按日期分组计数就能看出来。

daily_track_count = ( df.groupby(["date", "track_key"]) .size() .reset_index(name="daily_plays") ) repeat_tracks = daily_track_count[daily_track_count["daily_plays"] >= 5]

这个 step 很轻量,但能大幅提升分析故事的细节感。如果你把结果按月份聚合,还会发现每年总有那么几个月“重复次数猛增”,那几个点通常对应着刚经历完一次大型情感波动的时期。

4. 进阶玩法:把音频特征和日历行为结合在一起

4.1 给每首歌补一组“情绪坐标”

官方导出文件里没有歌曲本身的属性,只记录了播放行为和曲目名,所以先要从曲目信息接口把每首歌曲的音频特征批量拉回来。音频特征包含调性、节拍、能量值、舞蹈性、声学性、愉悦度等数值。说得夸张点,这相当于把每首歌变成了一组情绪坐标:高能量的歌适合运动,低愉悦度的歌适合雨天发呆。

批量请求时,需要注意接口单次查询上限是 100 个曲目 ID。先对track_key去重,再分批请求,并把结果统一按曲目 ID 合并。这里还会遇到一个常见问题:你自己听的一些翻唱、播客、本地导入文件可能没有对应的曲目 ID。所以接口数据返回后要做一次“外连接检查”,看看有多少缺失,缺失太多就考虑用导出数据里的artistName + trackName再去模糊匹配。

4.2 情绪—时间交叉表:回答“我几点听什么样的歌”

拿到音频特征之后,可以做一件很出效果的分析:按小时分组,看不同小时的“平均能量”和“平均愉悦度”。大多数人的曲线会呈现一个 U 形或者倒 U 形:白天听歌偏噪,深夜听歌情绪往往两极分化。这时候用一小段代码就足够看清全貌:

feature_cols = ["danceability", "energy", "valence", "acousticness"] hourly_features = ( df.groupby("hour")[feature_cols] .mean() .round(3) )

你不用急着读懂所有特征含义,只看energy和valence两条曲线就行。energy高说明你当时需要刺激,valence高说明你听到的歌整体情绪偏积极。如果深夜 1 点的valence明显低于白天,那基本可以确认“深夜是情绪小作文时段”,如果深夜和白天几乎没差别,只能说你已经是稳定的情绪管理大师。

4.3 把分析结果沉淀成“个人音乐年报”

分析完了不能只留在代码里,我建议输出一张简单的个人音乐年报。不用做成很复杂的页面,把几个关键结论点拼成一张 PDF 或 HTML 即可:总播放时长、最常听的歌手们、最常单曲循环的 Top 10、周内时间分布、音频特征曲线。

在输出 HTML 时,可以用plotly生成交互式图表,方便你在浏览器里 hover 看具体数值。如果要导出 PDF,matplotlib的savefig加PdfPages也足够。更轻量的做法是直接生成一个summary.md,把表格和图片路径丢进去,既适合自己复盘,也适合发到社交平台秒变“数据党”朋友圈素材。

5. 避坑清单:这些问题我全都遇到过

5.1 数据里少了一段历史,怎么办

先确认是不是漏掉了StreamingHistory的多个文件。很多新手只解压出第一个 json,看到只有几千行就以为拿到了全部数据。正解是看压缩包里的完整文件列表,把所有StreamingHistory*.json全部读进来。还有一种可能是“离线模式”或“隐私会话”下产生的播放记录不会进导出文件,这部分缺失无解,不用过分纠结。

5.2 时间戳对不上,热力图整体偏移

最常见的偏移就是“忘记把 UTC 转本地时区”。排查方法很简单:随便取一条播放记录,看它当前时间字段和你的真实播放场景对不对得上。如果所有记录都比真实时间慢 8 小时,那就是时区问题。碰到这种问题别去手动改字符串,直接用pd.to_datetime(..., utc=True)再tz_convert。

5.3 接口批量取音频特征失败

失败原因通常集中在两个点:一是没有在请求头里带 token,或者 token 过期了;二是单次查询超过 100 个 ID。前者按官方文档重新刷新即可,后者只要分批循环就会解决。建议把所有音频特征结果缓存成一个本地 parquet 或 CSV 文件,这样后续再跑分析时,重复歌曲不用重新请求接口。

5.4 智能音箱和手机后台会产生虚高播放

家里有智能音箱的同学要格外注意:音箱经常把环境音乐当背景播放,工作时听歌和睡前助眠可能形成非常大的播放时长占比。如果你的目的是分析“这段时间我在干嘛”,可以不处理;但如果目的是“品味自己的听歌审美”,建议先按时间把明显是助眠、白噪音、轻音乐的类型过滤掉,或者至少单列一个“环境音”分类。这在导出数据里没有现成字段,但可以通过在深夜音频特征里看到低能量、低节奏的高聚合自动识别。

5.5 常见问题快速参考

问题现象可能原因处理方案
时间热力图整体偏移未处理时区用tz_convert统一转本地
统计次数明显偏少只读了一个 Stream 文件glob 匹配所有文件
播放时长为 0后台预加载/错误播放过滤msPlayed <= 0
音频特征大量缺失请求超限或 ID 不存在分批查询+缓存+外连接检查
切歌率偏高误触/试听按 30 秒阈值统计,单独观察
月份趋势不自然换了设备/家庭共享检查是否有多个账号数据混入

6. 把分析流程沉淀成可复用脚本

6.1 模块化:清洗、分析、出图三件事分开

我之前也写过一版几百行的“一步到位”脚本,后来维护时发现改起来很难受。这里建议把整个流程拆成三个文件:load_data.py负责读取和清洗,analysis.py负责生成聚合指标,visualize.py负责出图。这样之后每年导出一次新数据,只需要重新跑一遍load_data.py,分析逻辑基本不用动。

6.2 把中间结果缓存下来

清洗后的 DataFrame 一定不要每次重新生成,我会直接存成 parquet 或者 CSV。原因是官方导出文件可能很大,多文件拼接虽然不慢,但反复执行会消耗时间;更重要的是,当你去调试可视化代码时,反复重新解析原文件会让你失去专注度。缓存好之后,可视化脚本每次只读中间结果,分析迭代速度快十倍。

6.3 保留原始数据,别在源文件上动手脚

还有一点是花了很多次教训才养成的习惯:永远保留下载压缩包的原始 json,清洗脚本只做“读取-转换-输出新文件”,绝对不回头改写源文件。分析思路到后期往往会变,比如最开始只用artist_name,后来想做“版本区分的专辑分析”,这时原始 json 里保留的完整字段就能派上用场。如果你一开始就把原始文件洗得面目全非,后面想换角度重跑就只能重新下载导出数据,非常被动。

最后再分享一个小技巧:分析完自己的播放历史后,如果你还想做预测下一首会听什么、或者给歌单排序这类更复杂的项目,前面清洗出来的这张大表已经是最好的训练集。不用刻意找公开数据,你自己的播放记录就记录了大量上下文信息。对着这份数据多跑几轮,你会发现很多官方年度报告里不会告诉你的细节。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询