用Python分析Spotify导出数据:从JSON清洗到可视化个人听歌报告 每年年底Spotify 都会生成一份年度听歌报告告诉你这一年听了多少分钟、哪个歌手被翻牌最多、哪首歌在深夜陪你加班。第一次看还挺新鲜第二年开始我就觉得不够用了它只给一张做得很漂亮的总结卡片却不给我中间的过程——每周听歌的波动、那些早就被我删除的歌、深夜和午后的偏好差异它统统不给。于是我直接从账户里导出了自己的原始听歌数据用 Python 把这堆 JSON 文件变成了一组可以反复分析和改写的图表结果挖出来不少官方报告永远不会告诉我的小规律。这篇文章把整套流程原原本本写出来从哪里导出数据、JSON 文件里每个字段是什么意思、怎么用 pandas 把播放记录整理成干净表格、怎么算指标、怎么画图以及我在实操中踩过的坑。适合三类人一是想用真实数据练手 Python 数据分析的初学者二是对自己听歌习惯好奇、想知道官方报告背后还有什么内容的普通用户三是想做个“个人年度数据总结”的博客作者。基础要求不多能装 Python 库、能看懂基本 DataFrame 操作就够了代码我都会贴出来照着跑就行。1. 先想清楚拿到了原始数据你到底想回答什么问题1.1 官方年度报告和本地数据的本质区别官方年度报告是“别人加工过的结论”本地导出的数据是“原始食材”。前者只给你几个最有传播度的数字总分钟数、Top 歌手、Top 歌曲但它是固定模板你没法追问“工作日和周末的听歌差异有多大”“某个歌手在我的播放记录里平均出现多久”“我的音乐口味到底是什么时候开始改变的”。而你自己导出的数据里每一行都是一条真实的播放记录包含这次播放什么时候结束、歌手叫什么、歌名是什么、实际播了多长时间。有了这样一张明细表你几乎可以回答任何关于自己听歌行为的问题。比如说你可以算出自己是不是周五晚上听歌时间最长或者哪个时间段最容易跳过歌。这些内容官方报告完全不会呈现。这也是为什么我强烈建议你试试自己做一次分析数据量不大、字段简单、又是你本人的真实生活记录比网上那些练习用数据集有意思得多。1.2 这份分析适合谁需要哪些基础如果你正在学 Python 数据分析一直找不到真实项目来练手这份听歌数据是非常好的起点。它的格式很规整不需要爬虫、不需要申请接口权限解压之后就是几个 JSON 文件刚好能覆盖json、glob、pandas、matplotlib这些常用工具学完能真正做出几张属于你自己的图。普通用户也不用被“代码”两个字吓到。我见过只有 Excel 基础的朋友跟着教程逐段运行也能完成。你需要准备的只是一个能跑 Python 的本地环境推荐使用 Anaconda 或直接装 Python 3.9 以上版本然后安装 pandas 和 matplotlib 这两个库就够了。如果你连环境都还没配好可以直接用pip install pandas matplotlib seaborn一次性装完后面会用到的库基本上就这几个。1.3 我的整体思路四步走我把整个项目拆成了四步导出数据、解析数据、计算指标、可视化呈现。导出是在 Spotify 账户设置里完成的不需要写任何代码解析阶段负责把 JSON 变成一张规矩的二维表计算指标阶段做分组、求和、排序可视化阶段把结论画成图。这四个步骤是从数据到结论的自然路径每步之间都有清晰的输入和输出。我强烈建议你不要跳步尤其是前两步很多看起来莫名其妙的分析结果最后追根溯源都是数据没读全、字段没清洗导致的。2. 第一步把私人数据从账户里导出顺便聊聊文件结构2.1 导出操作很简单等的时间可能比想象中长登录 Spotify 账户后进入账户设置页面找到隐私设置相关入口里面有一项是请求下载个人数据。点击之后平台会先做身份验证通常是往你邮箱里发一封确认邮件点一下链接就进入等待队列了。整个申请过程不复杂但真正拿到下载链接可能从几天到两三周不等具体看请求量和处理队列这属于正常情况不用着急。下载链接会通过邮件发给你而且有效期有限收到后最好尽快把 zip 包下载到本地。我的习惯是下载后先不解压保留一份原封不动的备份然后再复制一份出来分析。这样就算后面处理出错随时能回到原始状态。2.2 解压后你会看到哪些文件解压 zip 包后你会看到一堆 JSON 文件还有可能包含一些 HTML 说明文件。对于只想分析听歌统计的用户来说最核心的是所有以StreamingHistory开头的文件例如StreamingHistory0.json、StreamingHistory1.json……如果历史上的听歌记录很多平台会把数据拆成多个文件所以别只盯着第一个文件看。其他的文件比如Userdata.json、Playlist1.json里面的内容更偏向账户设置、歌单信息用不用得上取决于你想分析什么。对于入门场景可以暂时放到一边。等你把核心流程跑通之后再回来研究歌单文件也不迟。2.3 StreamingHistory 文件到底长什么样你可以直接用文本编辑器打开一个 StreamingHistory 文件里面是一个 JSON 数组每个元素代表一条播放记录大致长这样{ endTime: 2023-04-12 18:36, artistName: some band, trackName: some song, msPlayed: 243520 }这里面的字段并不复杂但每个都很关键endTime是这条播放记录结束时的时间精确到分钟artistName是歌手或乐队名称trackName是歌曲名msPlayed是这条记录实际播放了多少毫秒。你可以发现它没有记录开始时间如果需要对时间做精确推算可以用结束时间减去播放时长来近似得到开始时间。2.4 一个关于隐私的提醒原始数据千万别乱传这批数据比你想象中敏感得多。它精确记录了你在哪一天、哪一个小时听了什么歌连播放了几秒都知道可以反推出你的作息规律、情绪波动甚至通勤路线。所以我的建议是所有分析都在本地完成不要为了图方便把原始 JSON 直接上传到在线代码环境、公共仓库或任何不可信的服务。如果一定要分享你的分析成果只分享汇总后的图表和结论不要分享原始明细。我自己现在会把 zip 备份放进一个不联网的加密目录里每次分析时另建项目文件夹做完再清理中间文件。这套习惯虽然麻烦一点但能避免很多后续问题。3. 第二步用 Python 把 JSON 读成一张干净的表格3.1 准备环境与导入库这一步开始进入真正的数据分析环节。建议在项目文件夹里建一个analysis.py把后续所有代码按顺序写进去方便反复运行。文件开头先导入需要的库import json import glob import pandas as pd import matplotlib.pyplot as plt我还会顺手设一下绘图风格让图和图之间的字号一致plt.rcParams[figure.figsize] (10, 5) plt.rcParams[axes.grid] True plt.rcParams[grid.alpha] 0.33.2 合并多个 StreamingHistory 文件如果历史记录较多平台会生成多个StreamingHistory文件。直接写一个循环把它们全部读进来再用concat合并成一张总表files sorted(glob.glob(StreamingHistory*.json)) frames [] for f in files: with open(f, r, encodingutf-8) as fp: data json.load(fp) frames.append(pd.DataFrame(data)) df pd.concat(frames, ignore_indexTrue) print(df.shape) print(df.head())这里用glob.glob(StreamingHistory*.json)会自动匹配所有以StreamingHistory开头、以.json结尾的文件。sorted是为了让文件按照编号顺序合并避免顺序混乱。pd.DataFrame(data)足够处理这个扁平的 JSON 数组不需要额外用json_normalize。合并之后打印df.shape你就能看到总共有多少行、多少列。我第一次分析时看到几万行数据还是有点震撼的这些全是我一段一段听出来的。3.3 把时间字符串转成真正的时间类型原始的endTime是字符串比如2023-04-12 18:36。这种字符串没办法算“几点听歌最多”“周几听歌最多”所以第一步是把它们转成 pandas 里的时间类型df[endTime_dt] pd.to_datetime(df[endTime], format%Y-%m-%d %H:%M)format参数对应字符串的结构四位年份、两位月份、两位日期、空格、两位小时、两位分钟。如果某行数据格式不规范导致解析报错可以先去掉format参数让 pandas 自动推断但速度会慢一些而且可能出现你没预期到的解析结果。时间字段转换完成后很多事情就变得很顺手。你可以直接从endTime_dt中拆出小时、星期几、月份等字段后续计算会非常方便。3.4 补充小时、星期、月份、时长字段接下来我在 DataFrame 里增加几个派生字段方便后续分析df[hour] df[endTime_dt].dt.hour df[weekday] df[endTime_dt].dt.dayofweek df[month] df[endTime_dt].dt.to_period(M) df[minutes] df[msPlayed] / 60000hour是 0 到 23 的整数weekday是 0 到 6 的整数0 代表周一month是周期类型比如2023-04后面按月分组求和时会用它minutes是把毫秒转换成分钟的浮点数方便阅读和绘图。3.5 清洗脏数据这几条规则必须加从真实数据里直接得到干净表格是很少见的至少我自己的数据里就有几类问题需要处理。第一类是msPlayed为 0 的记录通常表示播放刚启动就被打断可能只是误触也可能是被其他应用抢占这些记录会拉低平均值统计时应直接删掉。第二类是歌名或歌手名为空这种异常记录不多但一旦混入会让分组统计产生一个神秘的“空组”。第三类是大小写不一致同一个歌手大小写可能有差异如果不统一大小写分组统计时会变成两个组。我的清洗代码如下df df[df[msPlayed] 0].copy() df df[df[trackName].notna() df[artistName].notna()].copy() df[artistName_clean] df[artistName].str.strip().str.lower() df[trackName_clean] df[trackName].str.strip().str.lower()strip()去掉首尾空格lower()统一成小写。后面所有分组操作都基于这两个清洗后的字段统计口径就一致了。对于播放时长的过滤阈值我个人建议用msPlayed 30000也就是播放超过 30 秒才算有效播放这个可以放在更后面按需使用不是强制项。4. 第三步从“听歌记录表”里算出你想知道的指标4.1 先来一组最基础的全局数字很多人一上来就追求漂亮的图表但我习惯先把核心数字算出来因为图表只是数字的可视化。基础指标包括总播放次数、总播放时长、平均每次播放时长、去重后的歌手数、歌曲数total_plays len(df) total_minutes df[minutes].sum() avg_minutes df[minutes].mean() unique_artists df[artistName_clean].nunique() unique_tracks df[trackName_clean].nunique() print(f总播放次数: {total_plays}) print(f总播放时长: {total_minutes / 60:.1f} 小时) print(f平均每次播放: {avg_minutes:.2f} 分钟) print(f听过的歌手数: {unique_artists}) print(f听过的歌曲数: {unique_tracks})总时长单位我习惯换算成小时因为几千分钟的数值显示在控制台里并不直观。比如total_minutes / 60会得到“123.4 小时”比“7404 分钟”明显更容易理解。如果你是第一次跑看到这些数字可能会想起不少已经淡忘的歌。4.2 Top 歌手用播放时长排序比用播放次数更合理关于“谁是我最常听的歌手”有两种常用口径按播放次数排或者按累计播放时长排。播放次数会把那些只有几十秒的短歌抬上来比如你可能把某段白噪音循环了 20 次但它不一定代表你真正“喜欢”。按累计播放时长排序更能反映你愿意在这位歌手身上花多少时间所以我通常会两个都算但最终展示以时长口径为主top_artists_by_count ( df.groupby(artistName_clean) .size() .sort_values(ascendingFalse) .head(10) ) top_artists_by_minutes ( df.groupby(artistName_clean)[minutes] .sum() .sort_values(ascendingFalse) .head(10) )groupby是按歌手分组.size()统计每组的行数也就是播放次数[minutes].sum()则是把组内所有分钟数加总。两个结果你都可以打印出来对比看看通常会发现按次数排名的第一名和按时长排名的第一名可能不是同一个人。4.3 歌曲维度加一个组合字段避免同名歌曲混淆只看歌名做统计有个隐患不同歌手可能唱过同名歌曲比如叫《Run》的歌可能有几十个版本。为了准确区分最好把歌手和歌曲名拼成一个组合字段df[track_key] df[artistName_clean] - df[trackName_clean] top_tracks ( df.groupby(track_key)[minutes] .sum() .sort_values(ascendingFalse) .head(10) )track_key的格式是“歌手名 - 歌名”。这样即使歌名相同只要歌手不一样依然是两条独立记录。这个字段在后面的单曲循环分析和随机歌单生成里也会用到。4.4 时间维度你到底是夜猫子还是早起鸟拿到时间类型字段后最简单的分析就是看一天 24 小时里哪些时段你听歌最多。分组统计时用hour字段hourly df.groupby(hour)[minutes].sum()这样你会得到一个长度为 24 的结果下标是 0 到 23值是对应小时的累计播放分钟数。如果你想知道“工作日和周末的差异”还可以先用weekday区分成两组再分别统计weekday_mask df[weekday] 5 weekday_hourly df[weekday_mask].groupby(hour)[minutes].sum() weekend_hourly df[~weekday_mask].groupby(hour)[minutes].sum()做一个简单对比以后你可能会发现周末听歌高峰比工作日晚一两个小时这种规律看起来很小但正好是官方年度报告不会给你的细节。4.5 按月看趋势你哪个月突然“疯狂听歌”如果数据跨了很长时间我建议按月统计总播放时长monthly df.groupby(df[endTime_dt].dt.to_period(M))[minutes].sum()to_period(M)会把日期归并到所属月份比如把2023-04-12归到2023-04。这样做的好处是月份之间可以直接排序后续画折线图也非常自然。如果你发现某一个月的数值异常突出回看那段时间发生了什么通常能找到答案——可能是备考、赶项目也可能单纯是那几个月迷上了新专辑。5. 第四步用三张图把报告画出来顺便说清中文字体的问题5.1 第一张图Top 歌手横向条形图可视化最忌讳一上来就堆二十张图那样只是把所有变量遍历了一遍根本得不出结论。我会先挑三个问题来回答谁是你最常听的歌手你一天的听歌节奏长什么样你的听歌量随月份怎么变化每个问题对应一张图。第一张图Top 歌手我推荐用横向条形图而不是竖向柱状图因为歌手名字通常比较长横向条形图可以完整显示标签。代码如下fig, ax plt.subplots(figsize(10, 6)) top top_artists_by_minutes.head(10).sort_values() ax.barh(top.index, top.values, color#1DB954) ax.set_xlabel(累计播放时长小时) ax.set_title(Top 10 歌手按播放时长) plt.tight_layout() plt.show()这里先对head(10)的结果.sort_values()是为了让数值从小到大排列这样画横条图时最大的会出现在最上面更符合阅读习惯。颜色我用的是 Spotify 的绿色#1DB954算一种仪式感。5.2 第二张图24 小时听歌节奏折线图小时级别的数据用折线图最直观可以同时画两条线一条表示总播放时长一条表示播放次数。如果两条线的趋势基本一致说明播放总量接近单次平均时长的线性关系如果出现时长高但次数低说明那个时段单曲循环多。hourly_df pd.DataFrame({ 总分钟: df.groupby(hour)[minutes].sum(), 播放次数: df.groupby(hour).size(), }) fig, ax plt.subplots(figsize(10, 5)) hourly_df[总分钟].plot(kindline, markero, axax, label总分钟) ax.set_xticks(range(0, 24)) ax.set_title(一天 24 小时的听歌时长分布) ax.set_xlabel(小时) ax.set_ylabel(累计播放分钟) plt.legend() plt.tight_layout() plt.show()画完之后看两个峰一个是午休时段一个是深夜时段。如果你发现凌晨两三点还有一个不小的峰说明你的“深夜听歌”不是偶尔而是一种稳定习惯。这种图做出来后你会对自己的一天有更清晰的体感。5.3 第三张图月度播放时长折线图月度趋势适合用折线图。如果数据跨了年份月份标签会变成2023-01这样的格式直接画也能看但最好转成字符串方便后续调整monthly df.groupby(df[endTime_dt].dt.to_period(M))[minutes].sum() monthly.index monthly.index.astype(str) fig, ax plt.subplots(figsize(12, 4)) monthly.plot(kindline, markero, axax) ax.set_title(月度播放时长趋势) ax.set_xlabel(月份) ax.set_ylabel(累计播放分钟) plt.tight_layout() plt.show()如果发现时间序列里有缺月比如某个月没有听歌记录折线图会出现断点需要在画图前把缺失月份补上。补全方法可以先用monthly.asfreq(M)把缺失月份变成 NaN再用fillna(0)填 0。否则折线图会直接跨过断点视觉上好像那一个月不存在实际上你确实没怎么听歌。5.4 中文字体乱码问题一个绕不开的坑matplotlib 默认字体对中文支持不好图里的中文标题和坐标轴标签会变成方块。解决方案是手动指定中文字体plt.rcParams[font.sans-serif] [Microsoft YaHei, SimHei, Noto Sans CJK SC] plt.rcParams[axes.unicode_minus] False不同系统支持的字体名不一样Windows 常见Microsoft YaHeimacOS 常见PingFang SCLinux 常见Noto Sans CJK SC。列表里多放几个matplotlib 会按顺序找到第一个可用的字体。如果这几个都不行最省事的方法是放弃中文标签图表直接用英文比如Top Artists by Listening Time分享给其他人也不会乱码。5.5 保存图片时别用默认参数plt.savefig(chart.png)虽然能保存但很容易把边缘标签截掉。我通常这样保存plt.savefig(top_artists.png, dpi300, bbox_inchestight)dpi300对博客和社交分享足够了不需要更高bbox_inchestight会自适应调整画布边缘保证标题、坐标轴标签不会被切掉。如果你是为了投放到打印场景再适当提高 dpi 也行我自己的经验是 300 已经完全够用。6. 进阶玩法把数据变成官方报告没有的新指标6.1 单曲循环指数有些歌你可能只喜欢一整段地“泡”在里面官方报告不会告诉你这种偏执程度。我自己定义了一个指标最近常听的 Top 1 歌曲播放时长占全部播放时长的比例。top1_share top_tracks.iloc[0] / df[minutes].sum() print(f最常听的一首歌占了总时长的 {top1_share:.2%})如果这个比例超过 5%说明你的听歌行为里有明显的单曲循环超过 10% 就更夸张了可能代表有一段时间你反复听同一首歌。你还可以继续算 Top 5 歌曲的累计占比看看自己的注意力集中到什么程度。这个指标的数值没有绝对标准但用来对比不同年份的自己非常有意思。6.2 探索系数你愿意听多少新东西另一个我比较关注的指标是“新歌手探索率”。计算思路很简单把数据按时间排序每个月看当月播放的歌手集合里有多少以前从没听过。新歌手占比越高说明那个月你越愿意探索新音乐。df_sorted df.sort_values(endTime_dt) known_artists set() exploration_records [] for month, group in df_sorted.groupby(df_sorted[endTime_dt].dt.to_period(M)): current_artists set(group[artistName_clean]) new_artists current_artists - known_artists if len(current_artists) 0: rate len(new_artists) / len(current_artists) exploration_records.append((str(month), round(rate, 2))) known_artists.update(current_artists) print(pd.DataFrame(exploration_records, columns[月份, 探索率]))用整个 DataFrame 分组再循环数据集通常只有几万行性能完全没问题。你可能会发现某个月的探索率异常高比如因为某次歌单推荐点了一连串新歌手这比官方报告里的“Top 歌手”更能描述你的口味变化。6.3 重复播放榜单播放次数除以听歌天数只看歌曲的总播放次数不够公平因为“播放次数”会被某一天的单曲循环放大。为了找到那些被你长期反复播放的歌我用“播放次数”除以“听歌天数”listen_days df[endTime_dt].dt.date.nunique() repeat_score ( df.groupby(track_key) .size() .div(listen_days) .sort_values(ascendingFalse) .head(10) )一个结果如果大于 1说明这首歌平均每天被播放超过一次那是相当高的复听密度如果只有 0.1说明它只是某段时间被集中播放过几天。这个榜单排出来的歌通常才是你真正无法忘记的“年度之歌”。6.4 一键导出带中文的 CSV避免 Excel 乱码很多人在生成报表后习惯把结果再存成 CSV 或 Excel。如果使用 pandas 直接导出 CSV遇到中文字段后用 Excel 打开很容易乱码。解决方法是编码用utf-8-sig而不是utf-8df.to_csv(my_spotify_summary.csv, indexFalse, encodingutf-8-sig)utf-8-sig会在文件开头加入 BOM 标记Excel 识别后就正常显示中文。这个技巧同样适用于任何包含中文的数据导出属于非常实用的日常操作。6.5 做个简单的“随机复古歌单”如果你想整理一份自己过去某个时间段的歌单也可以直接从清洗后的 DataFrame 里抽样。比如我想抽 10 首 2020 年以前听过的歌old_tracks df[df[endTime_dt].dt.year 2020][track_key].drop_duplicates() random_pick old_tracks.sample(10, random_state42) print(random_pick.tolist())random_state42让每次抽样结果可复现如果你换了别的数字就会得到另一批随机歌曲。这样一首首重新捡起旧歌比随机播放整个歌单更有“考古”的乐趣。7. 我在这套流程里踩过的坑提前帮你避开7.1 只读了 StreamingHistory0.json漏掉一大半数据我第一次分析时没注意文件的通配符匹配只把StreamingHistory0.json读进来了后来打印总播放次数发现数量少得离谱。原因很简单平台把历史记录拆成了好几个文件文件名从 0 开始递增。从那以后我每次都用glob.glob(StreamingHistory*.json)合并所有文件并且在代码里加了一行print(f共读取 {len(files)} 个流媒体历史文件)确保自己知道合并了几个文件。7.2 endTime 的时区问题别贸然当成 UTC 或本地时间官方导出的endTime是一个没有时区信息的字符串比如2023-04-12 18:36。我在不同账户上观察到的表现不太一样所以最稳妥的做法是把它先当成你账户设定时区的本地时间来用如果你发现分析结果和你的作息明显对不上再做时区偏移调整。一个快速验证方式是画 24 小时分布图看高峰是否落在你实际听歌最多的时段如果深夜时段看起来全被移到了凌晨那大概率是时区字段需要处理。7.3 统计口径播放次数经常会骗人只看播放次数会带来一种失真30 秒的片段被反复播放播放次数会很高但其实你的心智并没有投入那么久。后来我把统计口径改成了“播放时长”并且加了msPlayed 30000的过滤条件过滤掉绝大多数小于 30 秒的误触、跳歌和片段试听。这样得到的 Top 榜单更贴近“真正在听”的歌。要注意过滤阈值不宜设得过高否则一些本来就短但有特色的精致小歌会被误伤。7.4 图表的纵坐标单位别用“秒”也别用“千分钟”有一版图表我用分钟做纵轴Top 歌手的数值动辄上万坐标轴上的数字密密麻麻。改成小时之后阅读体验立刻舒服很多。比如某位歌手累计播放 6000 分钟图表上的标签就是 100 小时信息更直观。如果你的数据跨度很大还可以用“天”做单位换算关系是 1440 分钟等于一天自己看着舒服就行。7.5 数据分析一定要从问题出发别从图表出发这句话听起来像老生常谈但真的是我踩过最多的坑。拿到数据后如果先画二十张图最后只会觉得“我好像做了很多又好像什么都没做”。我会先把想回答的问题写在纸上再决定需要哪个字段、做哪种分组。比如你想知道“我是不是越到深夜越爱听老歌”那就需要先给每首歌打上发行年份标签再对比傍晚和深夜时段的老歌占比而不是先画一堆歌手条形图。7.6 数据量太少时趋势图没有意义如果你的账号才开了一个月或者过滤后只剩两三百条记录月度趋势图基本就是一根直线加个别零散点很难得出稳定结论。这个阶段建议只看 Top 名单、24 小时分布这类对样本量要求不高的图。像探索系数、年度对比这类需要长周期数据的分析最好等积累了至少半年以上的记录再做不然波动太大结论也不可靠。最后再分享一个小技巧把这套脚本整理成一份可以重复运行的analysis.py放在一个固定文件夹里。以后每半年重新导一次数据运行同一个脚本就能看到自己听歌习惯的动态变化。我自己的脚本开头会打印数据集的起止时间和有效记录数每次跑完顺手保存一张“变化最大的歌手对比图”日积月累再看才真正感受到音乐是怎么参与我生活的。如果你也喜欢折腾自己的真实数据不妨从这份 Spotify 听歌数据分析开始数据量不大、字段简单却能给你一份很有“个人档案”感觉的记录。