☰
Django+Scrapy用户行为分析系统:从爬虫到画像的全链路实践
2026/10/18 10:05:19 网站建设 项目流程

简介:本资源是一套基于Python与Django开发的网易云音乐数据可视化分析系统,面向计算机专业本科生、毕业设计学生及数据分析初学者,聚焦用户画像构建与播放行为深度分析两大核心问题,可直接用于课程设计、毕设选题或教学演示。压缩包共54个文件,含24个核心Python源码(涵盖Scrapy爬虫、Django后端、Matplotlib/Pyecharts可视化模块)、4个CSV原始数据集、7个PNG/JPG图表结果图、3份Markdown说明文档及1份PDF设计报告,辅以requirements.txt和字体等运行依赖,整体10.57MB,结构清晰、开箱即用。目前已有62人学习下载。读者可获得完整可运行代码、从数据采集(热歌榜爬取+MySQL存储)到大屏展示(Django动态渲染+用户分群画像)的全流程实现、配套详细设计文档与结课项目说明,以及典型排错提示与环境配置要点,具备良好延展性,支持二次开发与功能拓展。

1. 这不是「网易云后台」,而是一套可落地的用户行为分析闭环:从爬虫入库、Django实时渲染,到画像标签生成与热力图联动——高校毕设答辩现场,导师盯着大屏上「用户活跃时段聚类+歌单偏好雷达图」连问三遍“数据怎么来的”,A同学当场调出scrapy crawl hotrank -o hot.json和python manage.py runserver两个终端,5秒切屏演示完整链路

它不卖API、不接官方SDK、不依赖任何外部服务,整套系统跑在本地MySQL + Django + ECharts三件套上。核心价值不是“看起来炫”,而是把「播放行为→清洗规则→用户分群→可视化归因」这条数据链路,用不到2000行Python代码全量串通。适合两类人:一类是正在写毕设开题报告、卡在“如何让分析不浮于表面”的计算机本科生;另一类是想快速验证用户分群逻辑、但又不想搭整套数仓的初级数据工程师。项目里没有黑匣子——爬虫字段映射表写在items.py里,用户画像权重公式藏在analysis/user_profile.py的注释里,连ECharts配置项都按dashboard/views.py里的get_chart_config()函数逐层return。你下载解压后看到的不是一堆静态图表,而是一个能改、能调、能加字段、能换数据库的最小可行分析系统。


2. 环境搭建与模块职责拆解:Django项目结构即业务流程图

2.1 目录结构即数据流地图:从spiders/到templates/dashboard/的6层穿透

解压后你会看到清晰的三层物理结构:

  • 数据采集层:spiders/目录下hotrank_spider.py(热歌榜)、playlist_spider.py(歌单详情)、user_spider.py(用户公开行为)三个Scrapy爬虫,全部继承自cloudmusic/spiders/base_spider.py,共用get_sign()签名算法和parse_song_detail()统一解析器;
  • 数据存储层:models.py中Song,Playlist,UserBehavior三张模型表,字段名直译爬虫item(如play_count,comment_count,score_avg),外键关系明确——UserBehavior.song关联Song.id,Playlist.songs通过ManyToManyField反向关联;
  • 分析与展示层:analysis/包内user_profile.py(基于播放频次/时长/跳过率生成LTV分层标签)、behavior_cluster.py(用KMeans对7日播放时段向量聚类)、recommender.py(协同过滤简易版,仅用user_behavior__song_id和user_behavior__user_id两张表做矩阵分解)。

提示:所有分析脚本都设计为可独立运行的管理命令,比如python manage.py analyze_user_profile --days 30会自动拉取最近30天行为数据,生成UserProfile新记录并更新User模型的profile_tag字段——这比写SQL再手动UPDATE更可控。

2.2 核心依赖版本锚定:requirements.txt里的4个关键约束

requirements.txt看似普通,实则暗藏玄机。重点看这四行:

Django==4.2.7 scrapy==2.11.2 pymysql==1.1.0 pyecharts==2.0.3
  • Django==4.2.7:项目使用asgi.py启动Daphne服务器支持WebSocket实时推送(用于大屏在线人数计数),若升级到5.x需重写consumers.py中的AsyncWebsocketConsumer继承链;
  • scrapy==2.11.2:高版本Scrapy默认启用twisted异步DNS解析,而网易云反爬策略会拦截非常规User-Agent头,本项目在middlewares.py中强制关闭DNSCACHE_ENABLED = False,该配置在2.11.2中生效,在2.12+需改用DOWNLOADER_CLIENTCONTEXTFACTORY;
  • pymysql==1.1.0:MySQL驱动必须锁定此版本,高版本默认开启autocommit=True,导致transaction.atomic()装饰器失效,用户画像批量更新时会出现部分记录未落库;
  • pyecharts==2.0.3:这是最后一个兼容Jinja2<3.1的版本,项目模板中{% include "charts/song_heatmap.html" %}依赖Jinja2的|tojson过滤器转义ECharts配置,新版pyecharts已弃用该模式。

2.3 数据库初始化三步走:从空库到可运行的最小数据集

项目不提供SQL dump文件,而是用Django原生迁移机制保证环境一致性。执行顺序不能错:

# 1. 创建MySQL数据库(字符集必须为utf8mb4) mysql -u root -p -e "CREATE DATABASE cloudmusic DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" # 2. 执行Django迁移(注意:migrations/0001_initial.py已预置基础表结构) python manage.py makemigrations python manage.py migrate # 3. 加载初始测试数据(含3个模拟用户+10首热歌+2个歌单) python manage.py loaddata fixtures/initial_data.json

fixtures/initial_data.json是关键——它包含User、Song、Playlist三类对象的JSON序列化数据,其中Song对象的duration_ms字段值为245000(4分5秒),score_avg为4.72,这些数值在dashboard/views.py的get_song_stats()函数中被硬编码为计算基准值。若你跳过此步直接运行爬虫,Django Admin后台的「用户画像分析」页面会因缺少基准数据抛出UserProfile.DoesNotExist异常。


3. 爬虫模块深度解析:绕过网易云反爬的3个实战技巧

3.1 签名算法逆向:utils/crypto.py中get_sign()函数的参数组装逻辑

网易云所有接口请求头必须携带X-Real-IP和X-Forwarded-For,且URL参数需经AES加密。项目将核心逻辑封装在utils/crypto.py:

# utils/crypto.py from Crypto.Cipher import AES import base64 import json def get_sign(params: dict) -> str: # 步骤1:参数按key字典序排序并拼接成query string sorted_params = "&".join([f"{k}={v}" for k, v in sorted(params.items())]) # 步骤2:AES加密(密钥固定为'0CoJUm6Qyw8W8jud',IV为'0102030405060708') cipher = AES.new(b"0CoJUm6Qyw8W8jud", AES.MODE_CBC, b"0102030405060708") padded = sorted_params + (16 - len(sorted_params) % 16) * chr(16 - len(sorted_params) % 16) encrypted = cipher.encrypt(padded.encode()) # 步骤3:Base64编码并替换+/为-_(URL安全Base64) return base64.b64encode(encrypted).decode().replace('+', '-').replace('/', '_')

逻辑说明:params字典必须包含csrf_token(从首页HTML中正则提取)、limit、offset等字段,缺一不可。sorted_params拼接后长度必须为16的倍数,否则AES加密报错——项目用chr(16 - len(...) % 16)填充,而非PKCS#7标准填充,这是网易云服务端的兼容性要求。
参数说明:params['csrf_token']需从https://music.163.com/响应HTML中用re.search(r'csrf_token.*?content="(.*?)"', html)提取;params['limit']在热歌榜爬虫中固定为100,offset每次递增100。

3.2 动态User-Agent轮询:middlewares.py中RandomUserAgentMiddleware的实现细节

反爬第一道关卡是User-Agent检测。项目不依赖第三方库,而是维护一个本地UA池:

# middlewares.py import random class RandomUserAgentMiddleware: def __init__(self): self.user_agents = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36' ] def process_request(self, request, spider): # 关键:只对网易云域名启用UA轮询,避免影响其他中间件 if 'music.163.com' in request.url: request.headers['User-Agent'] = random.choice(self.user_agents) # 同时注入X-Real-IP(模拟真实IP,非代理IP) request.headers['X-Real-IP'] = f"11{random.randint(0,9)}.{random.randint(0,255)}.{random.randint(0,255)}.{random.randint(1,254)}"

逻辑说明:X-Real-IP必须是公网可路由IP段(11.x.x.x属于IANA保留的TEST-NET-1),且末位不能为0或255(避免被识别为网关地址)。若填入127.0.0.1或内网IP,网易云返回HTTP 403。
参数说明:self.user_agents列表在spiders/base_spider.py的custom_settings中被覆盖为{'DOWNLOADER_MIDDLEWARES': {...}},确保仅对网易云请求生效。

3.3 请求频率控制:settings.py中DOWNLOAD_DELAY与RANDOMIZE_DOWNLOAD_DELAY的协同机制

网易云对单IP每分钟请求上限约60次,超限返回{"code": -1, "msg": "param error"}。项目采用双保险策略:

# settings.py DOWNLOAD_DELAY = 1.5 # 基础延迟1.5秒 RANDOMIZE_DOWNLOAD_DELAY = True # 启用随机抖动(0.5~1.5倍) CONCURRENT_REQUESTS_PER_DOMAIN = 1 # 单域名并发=1,杜绝请求堆积

逻辑说明:RANDOMIZE_DOWNLOAD_DELAY=True会使实际延迟在0.75~2.25秒间浮动,有效规避固定间隔被识别为脚本特征。CONCURRENT_REQUESTS_PER_DOMAIN=1强制串行请求,虽然降低效率,但保证start_requests()生成的每个Request都严格按序执行——这对需要csrf_token传递的登录态爬虫至关重要。
参数说明:若你修改DOWNLOAD_DELAY为0.5,即使启用了随机抖动,仍可能触发反爬,因为0.5*0.5=0.25秒低于安全阈值。


4. 用户画像与行为分析模块:从原始点击到可解释标签的转换公式

4.1 用户活跃度分层:analysis/user_profile.py中calculate_activity_score()的加权逻辑

用户画像不靠机器学习黑盒,而是用可审计的规则引擎。核心函数calculate_activity_score(user_id: int)返回0~100分:

# analysis/user_profile.py def calculate_activity_score(user_id: int) -> float: # 数据来源:UserBehavior表近30天记录 behaviors = UserBehavior.objects.filter( user_id=user_id, created_at__gte=timezone.now() - timedelta(days=30) ) # 公式:活跃分 = 0.4*日均访问次数 + 0.3*周均播放歌曲数 + 0.3*月均互动次数 daily_visits = behaviors.values('date').distinct().count() / 30 weekly_songs = behaviors.values('song_id').distinct().count() / 4 monthly_interactions = behaviors.filter( Q(action_type='like') | Q(action_type='comment') | Q(action_type='share') ).count() score = ( min(daily_visits, 5) * 0.4 * 20 + # 日均访问上限5次,换算为20分 min(weekly_songs, 20) * 0.3 * 30 + # 周均播放上限20首,换算为30分 min(monthly_interactions, 100) * 0.3 * 50 # 月互动上限100次,换算为50分 ) return round(score, 2)

逻辑说明:所有指标设硬上限(如日均访问5次),防止极端用户拉高均值。分数区间0~100对应UserProfile.ACTIVITY_LEVEL_CHOICES中的'low'/'mid'/'high'三级标签,UserProfile.save()时自动调用此函数更新activity_score字段。
参数说明:behaviors.filter(...).count()在MySQL中走INDEX(created_at, user_id)联合索引,若未建索引会导致全表扫描——项目migrations/0002_add_indexes.py已预置该索引。

4.2 歌单偏好雷达图:analysis/playlist_analyzer.py中generate_preference_vector()的7维构造

大屏上的「用户歌单偏好雷达图」并非简单统计歌单类型,而是基于用户播放行为反推音乐DNA:

# analysis/playlist_analyzer.py def generate_preference_vector(user_id: int) -> List[float]: # 步骤1:获取用户近90天播放过的所有歌单ID playlist_ids = UserBehavior.objects.filter( user_id=user_id, action_type='play', created_at__gte=timezone.now() - timedelta(days=90) ).values_list('playlist_id', flat=True).distinct() # 步骤2:统计每个歌单的7个维度占比(来自Playlist模型的genre字段) # genre字段格式:'华语,流行,男声,慢节奏,伤感,钢琴,深夜' vectors = [] for pid in playlist_ids: try: playlist = Playlist.objects.get(id=pid) genres = playlist.genre.split(',') # 维度顺序固定:[华语, 流行, 男声, 慢节奏, 伤感, 钢琴, 深夜] vector = [1.0 if g in genres else 0.0 for g in ['华语','流行','男声','慢节奏','伤感','钢琴','深夜']] vectors.append(vector) except Playlist.DoesNotExist: continue # 步骤3:取平均值作为用户偏好向量(归一化到0~1) if not vectors: return [0.0] * 7 avg_vector = [sum(col)/len(vectors) for col in zip(*vectors)] return [round(v, 2) for v in avg_vector]

逻辑说明:genre字段由爬虫在parse_playlist_detail()中用正则re.search(r'"tags":\["(.*?)"\]', response.text)提取,原始数据为字符串数组,项目存为逗号分隔字符串以简化查询。雷达图7个维度完全对应genre中高频标签,无额外映射表。
参数说明:zip(*vectors)将[[1,0,1],[0,1,0]]转为[(1,0),(0,1),(1,0)],实现列聚合。若用户未播放任何歌单,返回全零向量,前端ECharts自动隐藏该用户雷达图。

4.3 行为聚类可视化:analysis/behavior_cluster.py中cluster_by_time_pattern()的KMeans实现

播放时段分析不用热力图,而用KMeans聚类揭示用户作息规律:

# analysis/behavior_cluster.py from sklearn.cluster import KMeans import numpy as np def cluster_by_time_pattern(user_ids: List[int]) -> Dict[int, int]: # 构造特征矩阵:每行=用户7日播放时段向量(24小时*7天=168维) X = [] user_map = [] for uid in user_ids: # 统计该用户近7天每小时播放次数(0点~23点) hourly_counts = [0] * 168 behaviors = UserBehavior.objects.filter( user_id=uid, action_type='play', created_at__gte=timezone.now() - timedelta(days=7) ) for b in behaviors: # 将created_at转为"第N天第H小时"索引(0~167) day_offset = (timezone.now().date() - b.created_at.date()).days hour_idx = day_offset * 24 + b.created_at.hour if 0 <= hour_idx < 168: hourly_counts[hour_idx] += 1 X.append(hourly_counts) user_map.append(uid) # KMeans聚类(k=3,对应早鸟/午间族/夜猫子) kmeans = KMeans(n_clusters=3, random_state=42, n_init=10) labels = kmeans.fit_predict(np.array(X)) return {user_map[i]: int(labels[i]) for i in range(len(user_map))}

逻辑说明:n_init=10确保聚类结果稳定,random_state=42固定随机种子便于复现。聚类中心坐标可导出为kmeans.cluster_centers_,项目在management/commands/analyze_behavior.py中将其存入ClusterCenter模型供前端调用。
参数说明:hourly_counts向量稀疏度极高(单日最多24次播放),故采用np.array(X)而非稀疏矩阵,内存占用可控(1000用户≈13MB)。


5. 常见问题排查:5条血泪经验总结,每条都踩过坑

5.1 现象:Django Admin后台「用户画像」页面空白,Network面板显示GET /api/user-profile/返回500

原因:UserProfile模型的user外键未设置on_delete=models.CASCADE,当User对象被删除时,UserProfile记录残留但关联失效,get_object_or_404(UserProfile, user=request.user)抛出UserProfile.DoesNotExist。
解决:执行python manage.py migrate重新应用migrations/0003_fix_userprofile_foreignkey.py,该迁移已修正外键约束。

5.2 现象:Scrapy爬虫运行后items.json为空,日志显示DEBUG: Crawled (200) <GET https://music.163.com/#/discover/toplist>

原因:网易云将真实数据埋在iframe内,https://music.163.com/#/discover/toplist是前端路由,实际数据接口为https://music.163.com/weapi/v1/resource/comments/R_SO_4_,爬虫未正确解析<iframe src="https://music.163.com/#/discover/toplist?id=3778678">中的id参数。
解决:打开spiders/hotrank_spider.py,将start_urls = ['https://music.163.com/#/discover/toplist']改为start_urls = ['https://music.163.com/discover/toplist'](去掉#),Scrapy才能抓取真实HTML。

5.3 现象:ECharts雷达图显示「数据为空」,浏览器Console报错Uncaught TypeError: Cannot read property 'length' of undefined

原因:dashboard/views.py中get_radar_data()函数调用generate_preference_vector(user_id)时,若用户无播放行为,函数返回[0.0]*7,但前端radar.js期望接收{value: [0,0,0,0,0,0,0], name: '用户A'}格式对象,未做空值校验。
解决:修改templates/dashboard/charts/radar_chart.html中JavaScript,增加判断:

if (!data || data.length === 0) { option.series[0].data = [{ value: [0,0,0,0,0,0,0], name: '无数据' }]; }

5.4 现象:python manage.py analyze_user_profile命令执行后,Admin中用户profile_tag字段仍为空

原因:analysis/user_profile.py中update_user_profile()函数调用UserProfile.objects.get_or_create(user=user)时,未传入defaults参数,导致新创建记录不填充activity_score等字段。
解决:检查management/commands/analyze_user_profile.py第45行,确保get_or_create()调用形如:

profile, created = UserProfile.objects.get_or_create( user=user, defaults={'activity_score': score, 'profile_tag': tag} )

5.5 现象:大屏WebSocket连接失败,浏览器Network显示ws://localhost:8000/ws/online/状态为(pending)

原因:Django Channels配置缺失,asgi.py中未添加application = ProtocolTypeRouter({ ... 'websocket': AuthMiddlewareStack(URLRouter([...])) }),且settings.py未配置CHANNEL_LAYERS。
解决:确认requirements.txt已安装channels==4.0.0,并在settings.py末尾添加:

CHANNEL_LAYERS = { 'default': { 'BACKEND': 'channels.layers.InMemoryChannelLayer', }, }

6. 二次开发实战:3个可立即落地的改造方案与验证方法

6.1 方案一:接入真实用户数据——替换爬虫为MySQL直连,5分钟完成数据源切换

项目默认爬取公开榜单,但毕设常需分析真实用户行为日志。若你已有用户播放日志表user_play_log(字段:user_id,song_id,play_time,duration_ms),只需两步替换数据源:

步骤1:新建数据适配器
在analysis/adapters/下创建mysql_adapter.py:

# analysis/adapters/mysql_adapter.py from django.db import connection def fetch_user_behaviors(user_id: int, days: int = 30) -> List[dict]: with connection.cursor() as cursor: cursor.execute(""" SELECT song_id, 'play' as action_type, play_time as created_at FROM user_play_log WHERE user_id = %s AND play_time >= DATE_SUB(NOW(), INTERVAL %s DAY) """, [user_id, days]) return [ {'song_id': row[0], 'action_type': row[1], 'created_at': row[2]} for row in cursor.fetchall() ]

步骤2:修改分析入口
打开analysis/user_profile.py,将calculate_activity_score()开头的ORM查询替换为:

# 替换前(ORM) behaviors = UserBehavior.objects.filter(...) # 替换后(直连MySQL) from analysis.adapters.mysql_adapter import fetch_user_behaviors raw_data = fetch_user_behaviors(user_id, days=30) # 后续逻辑保持不变,将raw_data转为QuerySet模拟对象

验证方法:在Django Shell中执行python manage.py shell,输入:

from analysis.user_profile import calculate_activity_score print(calculate_activity_score(123)) # 123为你数据库中真实user_id

若返回非零分数,说明直连成功。此时manage.py analyze_user_profile命令将自动使用新数据源。

6.2 方案二:扩展画像维度——在UserProfile模型中新增「设备偏好」字段并自动填充

用户常在手机/PC/平板多端使用,设备信息是重要分群维度。改造只需三处:

① 修改模型
在models.py中UserProfile类添加字段:

class UserProfile(models.Model): # ...原有字段 device_preference = models.CharField( max_length=20, choices=[('mobile', '手机'), ('pc', '电脑'), ('tablet', '平板')], default='mobile' )

② 编写填充逻辑
在analysis/user_profile.py中新增函数:

def detect_device_preference(user_id: int) -> str: # 统计该用户近7天各设备UA出现频次 from django.db import connection with connection.cursor() as cursor: cursor.execute(""" SELECT CASE WHEN user_agent LIKE '%Mobile%' THEN 'mobile' WHEN user_agent LIKE '%Windows%' OR user_agent LIKE '%Mac%' THEN 'pc' ELSE 'tablet' END as device, COUNT(*) as cnt FROM user_play_log WHERE user_id = %s AND play_time >= DATE_SUB(NOW(), INTERVAL 7 DAY) GROUP BY device ORDER BY cnt DESC LIMIT 1 """, [user_id]) result = cursor.fetchone() return result[0] if result else 'mobile'

③ 更新保存逻辑
在UserProfile.save()方法中插入:

def save(self, *args, **kwargs): if not self.pk: # 新建时填充 self.device_preference = detect_device_preference(self.user_id) super().save(*args, **kwargs)

验证方法:在Admin后台创建新UserProfile,查看「设备偏好」字段是否自动显示「手机」或「电脑」;或执行python manage.py shell:

from myapp.models import UserProfile p = UserProfile.objects.get(user_id=123) print(p.device_preference) # 应输出'mobile'等值

6.3 方案三:大屏交互升级——为ECharts图表添加点击钻取,点击歌单跳转详情页

当前大屏图表仅展示,无法下钻。要实现「点击雷达图某维度,弹出该类型Top10歌单」,需前后端联调:

前端修改(static/js/dashboard.js)
找到radarChart.on('click', function (params) {事件,添加:

radarChart.on('click', function (params) { const dimension = ['华语','流行','男声','慢节奏','伤感','钢琴','深夜'][params.dataIndex]; // 发送请求获取该类型歌单 fetch(`/api/top-playlists/?genre=${dimension}`) .then(r => r.json()) .then(data => { // 渲染弹窗(使用Bootstrap Modal) $('#playlistModal .modal-title').text(`"${dimension}"风格Top10歌单`); $('#playlistModal .modal-body').html( data.map((item, i) => `<div class="mb-2"><strong>${i+1}.</strong> ${item.name} (${item.play_count}万播放)</div>` ).join('') ); $('#playlistModal').modal('show'); }); });

后端API(dashboard/views.py)
新增视图函数:

from django.http import JsonResponse from django.db.models import Count def top_playlists_by_genre(request): genre = request.GET.get('genre', '') if not genre: return JsonResponse({'error': 'genre required'}, status=400) playlists = Playlist.objects.filter( genre__contains=genre ).annotate(play_count=Count('userbehavior')).order_by('-play_count')[:10] return JsonResponse([ {'name': p.name, 'play_count': p.play_count} for p in playlists ], safe=False)

URL路由(dashboard/urls.py)
添加:

urlpatterns = [ # ...其他路由 path('api/top-playlists/', views.top_playlists_by_genre, name='top_playlists'), ]

验证方法:启动服务后打开http://localhost:8000/dashboard/,点击雷达图任意区域,观察是否弹出对应风格歌单列表。若Network面板中/api/top-playlists/?genre=流行返回JSON数组,则改造成功。

从那以后我每次接到可视化类毕设需求,都会先检查三点:爬虫能否稳定产出结构化数据、Django模型字段是否与分析维度一一对应、ECharts配置是否预留了on('click')钩子——这三处不卡死,后续所有交互扩展都是体力活。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询