多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Python数据分析实战:从JSON到可视化自制Spotify年度听歌报告

Python数据分析实战:从JSON到可视化自制Spotify年度听歌报告 每年年底 Spotify 都会准时送上一张 Wrapped 卡片告诉你这一年你听了多少小时、哪个歌手被你循环最多。但说实话每次看到那张图我都觉得不够——它只给你结论不给你明细。比如我想知道这一年里凌晨三点的我到底在循环什么歌工作日和周末听的歌风格差别有多大官方页面查不到。后来我发现在 Spotify 的账户隐私设置里可以申请导出完整的听歌历史文件是一堆 JSON。于是我就用 Python 把这份数据拆开、洗干净、做统计最终做出了完全属于自己的Wrapped 报告。这篇文章就沿着我整个折腾过程来写从导出数据、解决环境问题到计算榜单、分析时间规律再到调用 Spotify 官方 API 给每首歌测情绪值最后生成能发朋友圈的可视化图表。不管你是刚接触 Python 的小白还是已经会用 pandas 处理数据的进阶玩家都能从里面找到可以直接复用的代码和思路。顺便说一句很多坑是官方文档不会告诉你的我都会写出来。1. 数据从哪来先拿到你的 Stream History 文件1.1 为什么不用 API 直接抓历史记录很多人第一反应是去豆瓣搜一下 Spotify API 不行吗。我也试过但很快发现官方 Web API 的播放历史接口只保留最近 50 条记录想分析一年前的数据几乎不可能。真正完整的历史数据Spotify 是作为账户数据导出提供给用户的也就是 GDPR 里说的数据可携带权。你可以在设置里申请然后等它打包发给你。后来我理解了这件事的本质Spotify 的 API 是为第三方应用实时读取设计的不是为个人数据分析设计的。官方把你听过什么这份数据主权交还给用户但通过的是文件下载而不是接口。所以如果你想做深度的个人听歌分析第一步永远是导出而不是调接口。1.2 导出步骤和文件长什么样操作路径是打开 Spotify 账户页面进入隐私设置找到下载你的数据然后勾选需要导出的内容。这里有个关键选择如果你只想分析听歌历史只需要勾选扩展听歌历史Extended streaming history不必把搜索记录、播放列表、关注列表全都勾上否则文件会大好几倍处理起来也没必要。提交导出申请后就是等邮件通知。我的经验是快的时候一天内就能收到慢的时候可能要一周。Spotify 会在邮件里给出下载链接链接有效期大概两周记得在过期前下载。下载回来通常是一个 ZIP 压缩包解压之后你会看到一堆StreamingHistory0.json、StreamingHistory1.json……这些是实际的听歌记录分片一个StreamingHistory_sample.json这是示例数据千万别把它和真实数据混在一起可能还有Playlist*.json、SearchQueries.json等其他文件StreamingHistory里的每一条记录长这样{ endTime: 2024-03-14 23:45:12, artistName: a-ha, trackName: Take On Me, msPlayed: 252000 }含义非常直白endTime 是这首歌唱完的时刻或者你切走歌的时刻artistName 是歌手名trackName 是歌曲名msPlayed 是这次播放持续了多少毫秒。注意它是毫秒252000 毫秒就是 252 秒正好 4 分 12 秒。字段就这四个看起来寒酸但已经足够重建一整年的听歌画像了。1.3 一个容易忽视的sample陷阱我第一次拿到压缩包的时候直接写了个脚本把所有StreamingHistory*.json读进去结果统计出来的歌单特别整齐总播放时长才十几个小时——我明明一年听了 200 多个小时。排查了半天发现我读进了一个叫StreamingHistory_sample.json的示例文件它只有几百条记录是官方特意放进去演示数据格式的。从那以后我所有脚本都会显式排除文件名包含sample的文件。另外一个教训是一定要先看一眼文件行数和时间范围再开始分析。比如你导出的第一个文件可能是 2019 年到 2022 年的第二个可能是 2023 到 2024 年的它们的重合边界你要心里有数否则统计总听歌时长的时候很容易重复计算。2. 环境搭建和数据装载几百 MB 的 JSON 处理要点2.1 最小依赖清单分析这份数据只需要三个东西Python 3.9 以上版本、pandas、Jupyter Notebook可选但你大概率会想用。我一般会在项目目录下建一个虚拟环境避免依赖冲突python -m venv spotify-analysis source spotify-analysis/bin/activate # Windows 用 spotify-analysis\Scripts\activate pip install pandas jupyter matplotlib seaborn数据量在意料之中但如果你开的是老账号多个StreamingHistory文件加起来可能到几百 MB。pandas 读这种量级完全没压力所以不用过早担心性能。2.2 用 pathlib 和 json 批量加载文件我建议用json模块逐文件加载再 concat而不是直接用pandas.read_json。原因有两个一是你往往需要跳过 sample 文件自己拼加载逻辑更方便二是多个文件分片加载能避免一次性把整个目录塞给 pandas 时的偶发解析错误。下面这段代码是我日常在用的加载片段from pathlib import Path import json import pandas as pd files [p for p in Path(.).glob(StreamingHistory*.json) if sample not in p.name] frames [] for f in files: with open(f, encodingutf-8) as fh: frames.append(pd.DataFrame(json.load(fh))) df pd.concat(frames, ignore_indexTrue) print(df.shape) print(df.head())ignore_indexTrue很重要因为每个分片的索引都是独立的直接 concat 会导致重复索引后面做切片操作时容易被误导。2.3 编码和类型这两个隐形坑读文件时我显式加了encodingutf-8。别觉得这是多此一举——在 Windows 默认编码是 GBK 的环境里直接用open(f)去读带中文歌名的 JSON 大概率会报UnicodeDecodeError。而且就算不报错控制台打印出来的中文也全是乱码影响排查。读进来之后建议立刻把msPlayed转成int64把endTime转成 datetime 类型df[msPlayed] df[msPlayed].astype(int64) df[endTime] pd.to_datetime(df[endTime])这步看起来基础但非常关键。不转换的话后面做时间聚合时会经常遇到类型报错或者聚合结果完全不符合预期排查起来很浪费时间。3. 我到底在听什么榜单统计与无效播放的过滤3.1 先把毫秒换成小时数据装好了第一件事当然是算总量。我先算总播放时长total_hours df[msPlayed].sum() / (1000 * 60 * 60) print(f总播放时长: {total_hours:.1f} 小时)然后按歌手聚合看谁在我这里霸榜artist_hours ( df.groupby(artistName)[msPlayed] .sum() .div(1000 * 60 * 60) .sort_values(ascendingFalse) .head(10) ) print(artist_hours)如果你只是想快速拿一张 Top 10 榜单到这里已经够了。但如果你把这份数据当回事我强烈建议先做一步数据清洗过滤无效播放否则榜单会被各种碎片播放污染。3.2 为什么必须过滤无效播放你在 Spotify 上听的每一次播放只要超过一定时长就会写入这份历史。这意味着大量你根本没认真听的片段也会混进来切歌时误触了下一首只播了 8 秒朋友借你电脑随手点开一首歌听了 20 秒就关掉睡前定时关闭没生效一首歌循环了一整夜这些记录如果都算进榜单那年度最爱歌手可能不是你真正爱听的而是你某天晚上忘了关机的那个环境音歌单。我做过一次测试把播放时长小于 30 秒的记录全部去掉之后总播放量少了 8%但榜单前几名的排序几乎没变——真正最常听的歌本来就经得起长时间播放。所以我现在的过滤标准是只保留 msPlayed 大于等于 30000即 30 秒的记录小于这个数的几乎都是无意播放。df df[df[msPlayed] 30000]当然你也可以用更激进的阈值比如 2 分钟。但我试下来那样会丢掉很多开车时切歌只听了副歌的合理播放因为很多人并不是每次都把歌听完。3.3 按次数还是按时长排名做榜单的时候要明确一个口径按播放次数排还是按累计时长排次数代表你切到这首歌的频率时长代表你在这首歌上实际花的时间。一首 2 分钟的短歌一天放 20 次和一首 10 分钟的史诗级长歌一天放 6 次次数榜和时长榜会给出完全不同的结论。我一般两个都算但对外展示主要用时长榜因为它更能反映真实的投入程度。如果你想按次数统计可以用df.groupby(trackName).size()。另外一个小细节我看到很多人的分析会把同一首歌做成原版 现场版 重制版好多个版本导致榜单碎裂。对待这种情况我会用artistName - trackName做一个复合键把同一歌手同一曲目名的播放时长合并起来这样同一首歌的排名更合理。但如果连Live和Remaster这两个词都去掉合并得又太狠个人看你自己口味。df[song_key] df[artistName] - df[trackName] song_hours ( df.groupby(song_key)[msPlayed] .sum() .div(1000 * 60 * 60) .sort_values(ascendingFalse) .head(10) )4. endTime 里的时间陷阱周几、几点与昼夜节律分析4.1 检查你的 endTime 是什么时区拿到了排行榜下一步我想知道我都在什么时间听歌。这就要对endTime做时间维度分析了。但这里藏着一个很容易踩的坑端时间字段在不同地区、不同导出批次下存的是不是你的本地时间Spotify 没有统一说清楚。我的做法是对比法把dt.hour画一个直方图如果发现听歌高峰居然集中在下午 2 点到 4 点而我平常这个时段基本不听歌那就说明当前数据可能是 UTC 时间需要转回本地时区。反之如果高峰出现在早上通勤、午休、晚上睡前那大概率就是本地时间。如果你确实需要转时区用 Python 的处理方式是# 如果确认 endTime 是 UTC且你所在时区是 UTC8 df[endTime_local] pd.to_datetime(df[endTime]).dt.tz_localize(UTC).dt.tz_convert(Asia/Shanghai)但不是所有人都看得懂tz_localize那套写法。简单场景下你也可以先用pd.to_datetime(df[endTime]) pd.Timedelta(hours8)手动偏移看看结果是否符合作息。这两种方法都能用关键是先做峰值检查再反推时区而不是看完文档就一劳永逸。4.2 按小时、星期和月份聚合确定好时间口径之后就可以拆出小时、星期、月份特征df[hour] df[endTime].dt.hour df[weekday] df[endTime].dt.dayofweek # 0周一, 6周日 df[month] df[endTime].dt.month这时用 pandas 的pivot_table做一张星期 x 小时的播放量热力矩阵非常直观pivot df.pivot_table(indexweekday, columnshour, valuesmsPlayed, aggfuncsum, fill_value0)拿到这张表你就能一眼看出自己听歌行为的节奏。我当时的发现是周三凌晨 1 点到 3 点有一个特别突出的播放高峰仔细翻了翻那几天的数据全是某个后摇乐队的歌——要不是数字摆在那里我自己都没意识到周三晚上失眠这么规律。4.3 用数据验证你的记忆做时间维度分析的时候我一直强调用数据验证猜测。比如你可能觉得我每天都在听歌但数据会告诉你周一到周五的通勤时段听歌时长其实不到睡前的一个零头又或者你可能以为某个月听歌最多实际是因为那一整个月都在单曲循环同一张专辑总时长被顶高了。这段分析还有一个很实用的变体把播放记录按月拆开看看你听的曲风是不是一直在变。配合后面的音频特征分析你能很清楚地看见我哪段时间过得特别丧——因为那段时间曲子效价全都特别低。5. 上强度了用 Spotify API 给每首歌测情绪值5.1 为什么需要官方 APIStreaming History 只有歌手、歌曲名、播放时间三个信息它回答不了我听的歌到底有多吵、多丧、多适合跳舞这类问题。这就要用到 Spotify 官方的 Audio Features 接口它能给每一首歌返回一堆 0 到 1 之间的评分danceability舞蹈性、energy能量、valence积极程度、acousticness原声程度等等。这个接口的使用场景非常明确它不是用来拿播放历史的而是用来给曲库打标签的。比如我想知道我常听的歌是偏快还是偏慢我深夜循环的歌是不是真的情绪低落只要把所有去重歌曲的音频特征拉一遍再做分组统计答案就出来了。5.2 申请 Client ID 和初始化 Spotipy去 Spotify Developer Dashboard 创建一个应用创建之后会给你Client ID和Client Secret。分析自己的听歌数据用Client Credentials模式就行不需要走 OAuth 用户授权流程因为音频特征接口不涉及用户私人数据。Python 侧用spotipy库最省事pip install spotipy然后初始化import spotipy from spotipy.oauth2 import SpotifyClientCredentials client_id 你的 Client ID client_secret 你的 Client Secret sp spotipy.Spotify(auth_managerSpotifyClientCredentials( client_idclient_id, client_secretclient_secret, ))这里有个安全提示不要把 Client Secret 硬编码在公开脚本里更不要提交到公开仓库。正确做法是放到环境变量或者.env文件里脚本里用os.getenv()读取。5.3 批量获取 track id 和音频特征要拿音频特征首先要把歌手 歌名转成 Spotify 的 track id。我用的是sp.search传入 artist 和 track 关键词results sp.search( qfartist:{row[artistName]} track:{row[trackName]}, typetrack, limit1 )search 接口返回的匹配并不总是精确的同名歌曲很多。为了避免张冠李戴我通常会再比对一下返回结果里的 artist 名称是否和目标一致不一致就跳过宁可少一首也不要拉错一首。当然如果你手里有播放列表、专辑这些信息也可以用sp.search的各种过滤条件进一步缩小范围。得到 track id 列表后音频特征接口一次最多支持传 100 个 id所以必须分批feature_list [] for i in range(0, len(track_ids), 100): batch track_ids[i:i100] feature_list.extend(sp.audio_features(batch))特征返回的字段非常多重点解释几个最常用的字段范围含义danceability0~1这首歌适合跳舞的程度数值越高节奏感越强energy0~1能量强度摇滚/电子普遍高民谣/古典低valence0~1积极情绪分数高代表听起来快乐低代表悲伤压抑acousticness0~1原声程度高说明乐器原声、少电音speechiness0~1说唱/人声密度聊天类播客会很高tempoBPM每分钟节拍数用来判断快慢我第一次把这几个字段和播放数据 join 起来发现我最常听的歌平均 valence 只有 0.36远低于我自认为的积极乐观。那段时间我总跟朋友说自己听的歌很欢快数据直接把我的自我认知纠正了。5.4 API 限速与缓存策略批量调 API 一定会遇到限速问题Spotify 的限速策略说你 30 秒内最多发多少个请求但实际体验更飘忽。我的做法是最朴素的time.sleep(0.3)每批之间加上它然后观察有没有HTTP 429。如果遇到 429就把 sleep 拉到 1 秒再跑基本都能缓解。更聪明的做法是缓存。因为同一首歌在一年里会被反复播放每次跑脚本都调一遍 API 太浪费。我的习惯是把已有特征存成本地 parquet 或 CSV下次脚本启动先检查这个 id 是不是读过了cached pd.read_csv(features_cache.csv) fetched_ids set(cached[id]) # 只对不在 fetched_ids 里的 track 发起请求这样做还有个附带好处即使以后 Spotify 某个接口改了限流策略你上个版本的数据仍然能用。6. 让数据能晒出去可视化与一张自制 Wrapped 长图6.1 榜单的条形图和字体坑分析做完总得把结果画出来。我不喜欢花哨的 dashboard一张 Top 10 横向条形图 一张热度热力图就足够发朋友圈了。先画歌手时长榜import matplotlib.pyplot as plt top10 artist_hours.head(10).sort_values() # 小的放下面大的朝上 fig, ax plt.subplots(figsize(10, 6)) ax.barh(top10.index, top10.values, color#1DB954) ax.set_xlabel(播放时长小时) ax.set_title(我的 Spotify Top 10 歌手) plt.tight_layout() plt.show()但如果你在中国系统里跑这段代码很快就会遇到一个经典问题出图的标题、坐标轴标签全是方框。这是因为 matplotlib 默认字体不支持中文。解法是显式指定一个中文字体plt.rcParams[font.sans-serif] [Microsoft YaHei, PingFang SC, SimHei] plt.rcParams[axes.unicode_minus] Falseaxes.unicode_minus这个参数也很容易忽略。如果不设坐标轴上的负号会被渲染成方块因为它默认用不支持中文减号的字体。6.2 时间热力表和GitHub 风格听歌日历热力图用 seaborn 绘制代码很简洁import seaborn as sns plt.figure(figsize(14, 5)) sns.heatmap(pivot, cmapYlGnBu, cbar_kws{label: 播放毫秒数}) plt.xlabel(小时) plt.ylabel(星期0周一) plt.title(我的一周听歌热力图) plt.show()这张图比任何表格都直观如果某个方块颜色特别深基本就是你本周最上头的时段。另一种我很推荐的可视化是听歌日历——类似 GitHub 贡献热力图那种绿色格子只不过每个格子的颜色深浅代表当天的播放分钟数。实现也不难就是按日期groupby做sum再pivot成日期矩阵后画热力图。发朋友圈的时候这种图特别受欢迎因为它一眼就能看出哪几天你听歌上头了。6.3 输出高分辨率长图做完了图别直接截图。我会用savefig保存带透明背景或清晰白底的 PNGplt.savefig(my_spotify_wrapped.png, dpi180, bbox_inchestight)bbox_inchestight会自动裁掉多余的留白。如果想要更精致的交互式报告我试过把统计结果渲染成一个 HTML 页面用图表库插入瘦长条形图和热力表格分享出去比单张图信息量大得多。不过这个属于进阶玩法容易陷进去出不来回不来我给自己定了个规矩分享出去的版本只看结果不要再折腾样式。7. 边界感与维护这些数据是隐私别随手传出去7.1 你的听歌历史包含了什么我前面说导出数据时只勾了听歌历史但即使只是这个部分它依然很私密。听歌记录能推断你的作息、情绪波动、工作日程甚至你深夜是不是失眠。我见过有人把这类 JSON 文件直接丢到 GitHub 仓库做演示后来被人顺着数据扒出了大量个人信息。所以我在项目目录里永远保留一份.gitignoreStreamingHistory*.json features_cache/* .env这也是为什么我前面强调去重缓存文件不要和数据处理脚本放在同一个公开分享目录里。听歌数据是本地的最好的处理方式是只在本地分析不要同步到任何云端网盘。7.2 重复导出会拖慢你的下一次申请很多人跑完一次脚本觉得数据不够新立刻再去账号设置里点导出。这里我要说一个实战教训Spotify 处理导出请求是需要时间的如果你频繁重复申请新的请求往往会被排在后面反而更慢。我现在的习惯是每年固定时间导一次拿到新数据后只更新增量分析不反复申请全量包。另外导出文件里带的时间戳和旧的会重叠一部分如果你在脚本里直接pd.concat两份导出包很可能会出现重复记录。我自己的做法是以endTime和song_key建一个去重键把两次导出的数据再drop_duplicates一次才能保证统计数字稳。7.3 关于 Spotify API 的合理使用整个项目里最容易被滥用的是音频特征批量请求。我见过有人写循环把几万首歌全部打一遍 API没考虑限速就挂了一晚上结果账号收到警告邮件。我的经验是永远不要写一个没有 sleep 和断点续传的批量脚本。每批请求之间至少要sleep(0.3)并且把已经成功的 id 及时写进缓存文件。脚本中断了下次接着跑就行。做这个项目的过程中我学到最多的其实是先看数据再信直觉。官方 Wrapped 告诉我你今年听了多少小时但不会告诉我你是从哪一天开始突然迷恋后摇的。只有当你把StreamingHistory里的四行字段拆开、重算、再和音乐特征混在一起看那种原来我每个月的听歌风格变化曲线和我的生活状态是对得上的感觉才是真正的惊喜。我身边已经有三个朋友听完我讲这套脚本第一周就把自己的数据也跑了一遍然后跑来跟我对答案。如果你也想做一份不撞车、不雷同的个人听歌报告就从导出那份 JSON 开始吧。第一次跑通的时候你会忍不住把 2019 年到今天每一个深夜单曲循环都翻出来看一遍——那感觉真的很上头。
返回列表