ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Django+ECharts构建网易云音乐可视化大屏:从数据清洗到用户画像实战

Django+ECharts构建网易云音乐可视化大屏:从数据清洗到用户画像实战 简介一份面向高校计算机专业学生与科研从业者的网易云音乐可视化项目资料包基于Python与Django框架构建数据大屏聚焦用户画像与播放行为分析适合用作毕业设计、课程设计或项目初期演示。资源共54个文件以24个Python源码文件为核心辅以11个编译文件、4个CSV数据表、7个PNG可视化图表、3份Markdown说明文档并包含设计报告PDF、字体与图片素材整包约10.57MB。内容覆盖从数据采集到展示的完整流程包括Scrapy框架爬取网易云热歌榜数据、存储至MySQL、通过Django进行可视化分析等关键环节数据表保存歌曲与用户信息图表直观呈现分析结果。同时提供设计报告与详细说明文档便于理解系统架构和复现项目。目前已有62人学习适合具备一定Python基础的开发者直接运行体验或在此代码基础上扩展其他音乐平台的可视化分析功能。1. 网易云音乐可视化Python到底在做什么从听歌记录到一张能汇报的Django大屏如果你手里有一批网易云音乐的播放记录——时间、歌曲、歌手、听了多少秒——普通的做法是拉几张 Excel 透视表自己看。但当你需要在周会上把“我们的用户到底喜欢什么”讲清楚时一张静态表格的说服力远不如一面实时跳动的可视化大屏。这个项目标题指向的正是这样一条链路用 Python 清洗数据用 Django 提供数据接口用 ECharts 在浏览器端渲染大屏最终把「用户画像」和「播放行为分析」两个模块做成可交互的看板。它适合三类人想练手 Django 完整项目的进阶新手、需要给运营团队做用户行为看板的数据分析师、以及想把“技术 Demo”做成“能汇报的成品”的开发者。接下来的篇幅我会顺着数据建模、画像计算、行为聚合、大屏渲染这条线把每一步怎么做、参数怎么调、坑在哪讲透。2. 先把数据喂进 Django模型设计与日志清洗的最小闭环2.1 数据从哪来可复现的本地听歌记录导入路径网易云音乐的对外 API 早已收紧直接爬接口的路子既不稳定也不合规。常见的做法是拿自己账号导出的听歌记录 JSON或者找公开的 Last.fm 听歌记录数据集做字段映射。这两类数据里通常包含这样几个字段歌曲名、歌手、播放发生的时间戳、歌曲总时长秒、本次实际播放时长秒。如果你的数据源里没有“实际播放时长”只有一个播放次数也能做行为分析只是“跳过率”这一类指标会退化成“点击次数”。拿到数据后第一步不是写 Django 代码而是先做一次结构体检。我一般会用一段纯 Python 脚本把 JSON 拍平确认字段名和类型再决定 Model 怎么设计。千万别跳过这一步——不同日期导出的记录字段名常常不一致有的叫song_name有的叫music_name在模型层统一字段名能省掉后面 90% 的麻烦。2.2 建三个表还是建一张宽表这个项目推荐单表加索引标题里同时出现了「用户画像」和「播放行为分析」很多新手一开始就设计了三张表用户表、歌曲表、播放记录表。但本地数据集的规模通常只有几千到几万条做三张表关联反而让查询变慢、代码变绕。我更推荐单表方案一张PlayRecord宽表把用户标识、歌曲信息、播放时长、时间戳全放进去用户画像通过聚合这张表得出播放行为分析也直接查这张表。# listening/models.py from django.db import models class PlayRecord(models.Model): user_id models.CharField(max_length64, db_indexTrue) song_name models.CharField(max_length128, db_indexTrue) artist models.CharField(max_length128, db_indexTrue) played_at models.DateTimeField(db_indexTrue) # 播放发生时间 song_duration models.IntegerField(default0) # 歌曲总时长秒 played_seconds models.IntegerField(default0) # 实际播放时长秒 album models.CharField(max_length256, blankTrue) # 专辑名可能为空 class Meta: ordering [-played_at] indexes [ models.Index(fields[user_id, played_at]), ]这段模型里有两个容易忽略的设计点。db_indexTrue不是随便加的后面做“按小时聚合播放量”“按用户分组算平均时长”这类查询全靠这两个索引撑速度played_at用DateTimeField而不是DateField因为播放行为分析里要拆出“几点钟”这个维度存日期会丢掉小时信息。song_duration和played_seconds我特意都设了默认值 0真实数据里脏数据常常缺这两个字段给默认值是清洗兜底的第一道防线。2.3 清洗脚本脏数据过滤与默认值兜底模型建好之后写一个独立的清洗脚本把原始 JSON 转成可直接bulk_create的对象列表。重点处理三类问题时间戳格式不统一、时区偏移、以及播放时长大于歌曲总时长的逻辑错误。# listening/utils/clean_data.py import json from datetime import datetime, timezone, timedelta from listening.models import PlayRecord def parse_ts(ts_str): 兼容两种常见时间格式ISO字符串和毫秒时间戳 if isinstance(ts_str, (int, float)): return datetime.fromtimestamp(ts_str / 1000, tztimezone.utc) return datetime.fromisoformat(ts_str.replace(Z, 00:00)) def load_records(json_path: str, user_id: str) - list[PlayRecord]: with open(json_path, r, encodingutf-8) as f: raw_items json.load(f) records [] for item in raw_items: played_at parse_ts(item[playTime]) # 中国时区转本地时间方便后续按小时/星期聚合 played_at played_at.astimezone(timezone(timedelta(hours8))) played_seconds int(item.get(playedSeconds, 0) or 0) song_duration int(item.get(songDuration, 0) or 0) # 逻辑错误过滤实际播放超过总时长说明数据有问题 if song_duration 0 and played_seconds song_duration: played_seconds song_duration records.append(PlayRecord( user_iduser_id, song_nameitem[songName], artistitem[artist], played_atplayed_at, song_durationsong_duration, played_secondsplayed_seconds, albumitem.get(album, ), )) return records这里几个参数值得解释。tzinfo的转换不是玄学——很多公开数据集存的是 UTC 时间如果不转成东八区后面“凌晨 1 点听歌人数最高”会算到早上 9 点去。played_seconds song_duration这条过滤规则是血泪经验本地导出数据里经常出现播放了 5 分钟但歌曲总共只有 3 分钟的情况不拦截的话“平均播放时长”这个指标会直接失真。写入时用PlayRecord.objects.bulk_create(records, batch_size500)几千条数据一次性写入耗时可忽略比逐条save()快一个数量级这是 Django 大批量导数据的基本操作。3. 用户画像计算五个维度把听众拆成可解释的标签3.1 画像标签体系年龄性别拿不到就用行为倒推拿不到用户的性别和年龄是音乐场景的常态所以用户画像不能照搬电商那套“人口属性画像”要做“行为画像”。我一般拆成五个维度活跃时段偏好夜猫子/晨间型、播放深度完整听完/频繁切歌、风格倾向通过歌手和歌曲名做关键词分类、收听黏性连续活跃天数、曲目多样性去重歌曲数/总播放次数。每个维度都对应一个可解释的标签比如“深夜emo型”“通勤快进党”“老歌收藏家”。这套体系的好处是它不用外部数据直接从PlayRecord表聚合就能算出来。但要注意风格倾向的分类不能靠人工维护一份歌手清单那样会累死人。我用的办法是做一个简单的规则分类器从歌名和歌手名里匹配预设关键词比如“Live”“现场”归为现场版“钢琴”“吉他”归为器乐/民谣匹配不到的统一丢进“其他”类别。3.2 画像计算用 Django ORM 聚合还是 pandas一万条数据怎么选数据量在一万条以内时直接用 Django ORM 聚合就够了不必把数据导到 pandas 里绕一圈。下面这段代码在views.py里完成画像的粗算输出一个字典后续直接 JSON 序列化给前端。# listening/services/profile.py from django.db.models import Count, Avg, F from django.utils import timezone from listening.models import PlayRecord def build_user_profile(user_id: str, days: int 90) - dict: cutoff timezone.now() - timezone.timedelta(daysdays) qs PlayRecord.objects.filter(user_iduser_id, played_at__gtecutoff) # 维度1时段偏好把播放时间按小时拆桶 hour_dist {h: 0 for h in range(24)} for played_at in qs.values_list(played_at, flatTrue): hour_dist[played_at.hour] 1 # 维度2播放深度实际播放/总时长均值 depth qs.aggregate( avg_playedAvg(played_seconds), avg_totalAvg(song_duration), ) play_depth 0.0 if depth[avg_total] and depth[avg_total] 0: play_depth round(depth[avg_played] / depth[avg_total], 2) # 维度3黏性活跃天数 active_days qs.values(played_at__date).distinct().count() # 维度4多样性去重歌曲数/总播放次数 distinct_songs qs.values(song_name).distinct().count() total_plays qs.count() diversity round(distinct_songs / total_plays, 2) if total_plays else 0 return { user_id: user_id, hour_dist: hour_dist, play_depth: play_depth, active_days: active_days, diversity: diversity, }小时维度用played_at.hour遍历是没办法做 ORM 的 group by 的因为数据库函数取小时虽然能聚合但会写出ExtractHour这种可读性差的代码。数据量小的时候直接遍历列表反而清晰。但这里有个隐藏的性能坑qs.values_list(played_at, flatTrue)会一次性把所有记录的时间加载进内存如果记录数超过五万建议改成ExtractHour配合annotate在数据库端完成。3.3 画像结果落库与增量更新给大屏加一层“后悔药”画像计算结果不该每次请求都现算尤其是大屏要定时刷新时重复聚合几十万条记录会让页面越刷越慢。常见做法是建一张UserProfileSnapshot表把画像结果以 JSON 字段落库再设定一个缓存过期时间。前端访问/api/user-profile时先查快照快照过期才触发重算。# listening/models.py class UserProfileSnapshot(models.Model): user_id models.CharField(max_length64, uniqueTrue) profile_json models.JSONField() updated_at models.DateTimeField(auto_nowTrue) # listening/views.py from django.utils import timezone from datetime import timedelta def get_profile_snapshot(user_id: str): CACHE_MINUTES 30 try: snap UserProfileSnapshot.objects.get(user_iduser_id) if timezone.now() - snap.updated_at timedelta(minutesCACHE_MINUTES): return snap.profile_json except UserProfileSnapshot.DoesNotExist: pass profile build_user_profile(user_id) UserProfileSnapshot.objects.update_or_create( user_iduser_id, defaults{profile_json: profile} ) return profile这里CACHE_MINUTES 30是根据大屏刷新频率定的——如果大屏 5 秒刷一次而画像本身变化很慢30 分钟的缓存能挡住绝大多数重复计算。JSONField 是 Django 3.1 以后内置的字段直接存储字典省去序列化转换。落库的另一个好处是“后悔药”前端展示异常时你能立刻查快照是脏数据还是当前计算逻辑的问题而不是面对一片空白的接口。4. 播放行为分析的四个必看指标时段、时长、跳过与循环4.1 时段热力榜工作日的早晚高峰与周末的午后小高峰播放行为分析的核心不是单个用户是整体趋势。我做可视化大屏时最常放的四个指标分别是24 小时播放热力、平均收听时长、歌曲跳过率、循环播放率。其中时段热力直接用 SQL 层的GROUP BY配合 Django 的ExtractHour完成效率远高于 Python 遍历。# listening/services/behavior.py from django.db.models.functions import ExtractHour, ExtractWeekDay from django.db.models import Count def hour_heatmap(user_id: str None): qs PlayRecord.objects.all() if user_id: qs qs.filter(user_iduser_id) # 按小时星期几聚合形成 7x24 热力矩阵 heat (qs .annotate(hourExtractHour(played_at), weekdayExtractWeekDay(played_at)) .values(weekday, hour) .annotate(cntCount(id)) .order_by(weekday, hour)) matrix [[0 for _ in range(24)] for _ in range(7)] for row in heat: matrix[row[weekday] - 1][row[hour]] row[cnt] return matrixExtractWeekDay返回值 1 是周日、7 是周六所以代码里做了row[weekday] - 1的索引偏移这个细节不处理热力图横纵轴会对不上。matrix初始化的7x24全零矩阵是必须的否则数据库里没有记录的时段会是空值前端 ECharts 热力图拿到None会直接不渲染整块区域。返回给前端时记得把matrix包一层return {matrix: matrix, x_labels: [f{h}:00 for h in range(24)]}4.2 跳过率与循环率两个需要定义清楚再做聚合的指标“跳过”和“循环”这两个指标最容易引发歧义不先定义就写代码后面一定返工。我用的定义是跳过率 播放时长小于歌曲总时长 30% 的记录占比循环率 24 小时内同一用户播放同一歌曲至少 2 次的歌曲占比。前者反映内容吸引力后者反映用户黏性。# 跳过率用条件聚合一次算出来 from django.db.models import F, FloatField, ExpressionWrapper def skip_rate(user_id: str None): qs PlayRecord.objects.all() if user_id: qs qs.filter(user_iduser_id) result qs.aggregate( totalCount(id), skippedCount(id, filterQ( played_seconds__ltF(song_duration) * 0.3 )) ) return round((result[skipped] / result[total] * 100), 1)Count(id, filter...)是 Django 2.0 以后支持的条件聚合能在一个查询里同时拿到总数和符合条件的子集数量不必写两条 SQL。F(song_duration) * 0.3直接在数据库里做字段间的比较而不是把数据拉回内存。值得注意的是song_duration 0的脏数据会让这条规则失效——播放时长永远大于总长效 30%所以清洗阶段把song_duration 0的记录剔除掉或者在这里加一条.exclude(song_duration0)。4.3 大屏数据接口JSON 序列化与避免 N1 查询行为分析接口的输出最终要交给前端格式上建议统一成{code, data, msg}三件套。用 Django 原生的JsonResponse最省事但要注意datetime对象的处理Django 内置的DjangoJSONEncoder会把datetime转成 ISO 字符串而ExtractHour返回的int没有这个问题。如果字段里混入了Decimal或UUID就需要自定义json.dumps的default参数。大屏几个接口并发访问时最常见的性能坑是 N1 查询——比如先取 10 首歌再对每首歌查一次播放记录。解决方式是不要循环查库用valuesannotate一次拿到聚合结果。# 播放行为 Top 歌曲榜一条 SQL 搞定 from django.db.models import Sum def top_songs(limit10): return (PlayRecord.objects .values(song_name, artist) .annotate(total_playsCount(id), total_secondsSum(played_seconds)) .order_by(-total_plays) .values(song_name, artist, total_plays, total_seconds) [:limit])values(song_name, artist)后面的annotate会自动按这两个字段分组这是 Django ORM 里最容易忽略的语法细节——values在annotate前出现就变成了GROUP BY的声明而不是查询列。total_seconds字段前端可以直接拿来算“人均收听时长”不必再让后端加工。5. 网易云音乐可视化大屏的避坑清单乱码、空数据与缓存穿透5.1 中文乱码JsonResponse 输出\uXXXX转义序列现象前端拿到接口返回后中文全部变成\u97f3\u4e50这样的转义序列标题栏一片乱码。 原因Django 的JsonResponse默认使用json.dumps(ensure_asciiTrue)会把所有非 ASCII 字符转成\uXXXX。前端如果直接用字符串渲染浏览器能解析但落入页面后一旦被innerHTML二次处理就变成字面量了。 解决from django.http import JsonResponse from django.core.serializers.json import DjangoJSONEncoder def json_response(data): return JsonResponse( {code: 0, data: data}, encoderDjangoJSONEncoder, json_dumps_params{ensure_ascii: False} )ensure_asciiFalse让接口直接输出中文原文配合charsetutf-8响应头彻底断掉乱码的根。5.2 空数据让整个大屏白屏图表组件必须在数据为空时显式返回现象导入的是试用数据集某个月份没有播放记录结果大屏上所有 ECharts 图表直接不渲染浏览器控制台报data is undefined。 原因画布组件在初始化时拿到None或空数组series.data无法建立坐标系。 解决接口层永远返回结构完整的空值不要省略字段。后端做一层兜底if not matrix or all(all(v 0 for v in row) for row in matrix): matrix [[0 for _ in range(24)] for _ in range(7)]前端在setOption前加一句if (!data || data.length 0) { chart.clear(); return; }这样即使没数据大屏也能显示“今日暂无播放”的占位状态而不是整块白屏运营人员不会误以为系统挂了。5.3 高频刷新把数据库拖垮SQLite 与 MySQL 的分水岭现象大屏 5 秒轮询一次/api/hour-heatmap数据库连接数被占满其他页面打开变卡。 原因Django 开发服务器默认开多个线程每个请求都占用一个连接。SQLite 对并发写支持差读多写少时连接池很快就到瓶颈。 解决本地演示时把CONN_MAX_AGE设为 60让连接复用正式部署换 MySQL 或 PostgreSQL 并配置连接池。另一个取巧的办法是给热点接口上 Redis 缓存再用 Redis 可视化工具直接观察 key 过期状况排查到底是接口慢还是数据没更新。高德地图在 Redis 里没有这种体感但缓存穿透时你会立刻看到Cache Miss数量暴涨。5.4 ECharts 大屏在 2K 显示器上错位根字体缩放是唯一解现象在 2560 分辨率下大屏布局正常换到 1920 投影时右侧图表溢出屏幕。 原因大屏设计稿通常按 1920×1080 制作但浏览器默认字号固定ECharts 容器宽度用了固定像素值。 解决在 HTML 模板里加一段全局缩放脚本让根字体跟随宽度变化图表内所有字号和间距全部用rem表达。在 Django 的base.html底部插入script (function () { var baseWidth 1920; var scale document.documentElement.clientWidth / baseWidth; document.documentElement.style.fontSize 100 * scale px; })(); /script这样大屏布局在每个分辨率下等比缩放而不是横向滚动。注意图表初始化需要放在缩放之后否则取到的容器宽度是缩放前的。5.5 播放历史里混入“单曲循环”导致的画像失真去重策略现象某个用户一天内把同一首歌听了 80 遍画像结果显示“该用户风格高度集中”但他其实只是下午写代码时开了单曲循环。 原因单曲循环生成的连续重复记录会被当成强烈的风格偏好信号。 解决在清洗阶段对连续重复记录做合并——同一个user_id song_name artist且相邻两条记录时间差小于 10 秒视为一次循环播放的续听只保留第一条。注意后续的时长统计要从原记录里取总和不能直接丢数据。6. 大屏渲染进阶WebSocket 实时推送、组件联动与刷新验证前面章节的数据接口都是“前端主动拉”这在大屏演示时有个明显缺陷每次刷新都有 100~300ms 的肉眼可见延迟而且轮询太频繁会加重接口压力。进阶做法是用 WebSocket 做服务端推送——Django 里最流行的方案是channels库后端一有新的聚合结果就主动推到浏览器前端不用发请求。# listening/consumers.py使用 channels 的 WebSocket 消费者 import json from asgiref.sync import async_to_sync from channels.generic.websocket import WebsocketConsumer class DashboardConsumer(WebsocketConsumer): def connect(self): self.group_name dashboard async_to_sync(self.channel_layer.group_add)(self.group_name, self.channel_name) self.accept() def disconnect(self, code): async_to_sync(self.channel_layer.group_discard)( self.group_name, self.channel_name ) def send_metrics(self, event): self.send(text_datajson.dumps(event[data]))配套的数据推送逻辑可以放在views.py里当缓存刷新完成时调用async_to_sync(channel_layer.group_send)。我做过的最小闭环是Django 定时任务每 30 秒重算一次指标算完推到 WebSocket 组前端收到后更新 ECharts 的series.data全程无刷新闪烁。这个方案比轮询优雅但也要接受它的代价需要部署daphne或uvicorn作为 ASGI 服务器开发环境的runserver不支持 WebSocket。大屏最终验收时我会做三件事第一用无痕窗口打开页面确认首次加载时间在 3 秒以内第二切到 1366×768 分辨率确认缩放没有裁切右侧图表第三断网刷新一次看页面是否给出了友好的错误提示而不是无限 loading。我自己跑过几次这类项目后的习惯是把刷新频率、缓存时间、数据量三个参数写进配置文件每次调试只改配置不动代码。大屏工具收到告警时第一件事就是去查配置参数而不是翻代码。这个习惯帮我省了很多“昨天还能显示今天突然白屏”的排查时间希望帮到你。本文还有配套的精品资源点击获取
返回列表