Python+B站用户行为分析系统:从爬虫到运营决策的全链路实践
发布时间:2026/10/2 1:22:17来源:尧图网络
简介本资源是一份面向数据分析初学者与Python开发者的B站用户行为分析系统设计文档聚焦UP主运营优化与用户内容偏好挖掘场景。文档完整呈现了基于Python的大数据处理流程涵盖数据采集、清洗、关联规则与聚类分析、matplotlib/seaborn可视化实现等关键技术环节并详细说明视频类型分布、粉丝/获赞趋势、一键三连行为偏好及播放榜/粉丝榜的总量与均值图表展示逻辑。资源为单个1.05MB的Word文档.docx结构规范含中英文摘要、绪论、技术选型Python/Django、系统设计与实现章节及参考文献目录层级清晰便于快速定位核心方法与图表案例。目前已有433人学习下载适合高校课程设计参考、毕业设计选题借鉴或短视频平台运营人员理解用户行为建模路径。1. 基于 Python 的 B 站用户行为分析系统不是爬虫玩具而是可落地的 UP 主运营决策支持工具你有没有试过——花三天写完一个 B 站视频数据爬虫跑出 5000 条播放量、弹幕、三连数据结果打开 Excel 一通筛选排序最后只得出一句“搞笑类视频好像挺火”这不是数据分析这是数据搬运。而这篇笔记要拆解的是一个真实存在于毕业设计文档里的完整系统它不依赖第三方 API 密钥不调用神策或 GrowingIO不用部署 Hadoop 集群纯 Python Django MySQL 构建从数据采集、清洗、建模到多维可视化全链路闭环。它解决的不是“能不能拿到数据”而是“UP 主今天该发什么类型视频”“哪类粉丝最愿意投币”“为什么上周播放量涨了但互动率跌了”这类具体业务问题。系统里没有“大数据”空话只有柱状图上标着“美食类视频平均三连率 23.7%高于均值 8.2pct”的真实刻度没有“智能推荐”玄学只有多维分析页中“粉丝量 50w 且投稿频次 3/周”的 UP 主在“收藏/播放比”维度明显偏低的交叉结论。它面向的不是算法工程师而是刚入行的运营助理、想优化内容策略的中小 UP 主、需要快速验证选题的 MCN 策划以及——正在为毕设卡在“系统怎么才算做完”而焦虑的你。这份资源的价值不在代码有多炫技而在它把“用户行为分析”从论文术语变成了浏览器里点几下就能看懂的决策依据。2. 数据采集与清洗B 站公开接口的稳定抓取策略与反限流实战2.1 为什么放弃 Selenium坚持 Requests 异步协程很多初学者一上来就用 Selenium 模拟浏览器理由很朴素“B 站有反爬”。但实测发现对 B 站公开的 UP 主主页、视频列表页、弹幕 XML 接口Selenium 是性能黑洞。我们对比过单个 UP 主 200 条视频页Selenium 平均耗时 42 秒含渲染、等待而 Requests aiohttp 异步并发 20 个连接仅需 6.3 秒。更关键的是稳定性——Selenium 在服务器无头环境下极易因字体缺失、GPU 驱动异常崩溃而 Requests 只要 header 合理几乎零失败。本系统采用aiohttpasyncio构建采集器核心逻辑如下import aiohttp import asyncio import time # 全局 session 复用避免重复握手开销 session None async def fetch_video_list(session, mid: str, pn: int 1) - dict: url fhttps://api.bilibili.com/x/space/arc/search?mid{mid}ps30pn{pn} headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: fhttps://space.bilibili.com/{mid}/video } try: async with session.get(url, headersheaders, timeout10) as resp: if resp.status 200: return await resp.json() elif resp.status 412: # B站经典反爬码需加 Cookie raise Exception(412 Precondition Failed: need valid cookie) else: raise Exception(fHTTP {resp.status}) except asyncio.TimeoutError: raise Exception(Request timeout) except Exception as e: raise Exception(fFetch failed: {str(e)}) async def main(): global session connector aiohttp.TCPConnector(limit20, limit_per_host20) timeout aiohttp.ClientTimeout(total15) session aiohttp.ClientSession(connectorconnector, timeouttimeout) tasks [ fetch_video_list(session, 20234567, pn1), fetch_video_list(session, 20234567, pn2), fetch_video_list(session, 89012345, pn1) ] results await asyncio.gather(*tasks, return_exceptionsTrue) await session.close() return results # 运行采集 if __name__ __main__: start time.time() data asyncio.run(main()) print(f3 requests done in {time.time() - start:.2f}s)参数说明limit20控制并发连接总数过高易触发风控B 站对单 IP 短时请求数敏感limit_per_host20限制对同一域名如 api.bilibili.com的并发数避免被识别为扫描timeout15总超时设为 15 秒防止个别请求阻塞整个队列Referer必填B 站校验 Referer缺失直接 403User-Agent需模拟主流浏览器避免使用默认 aiohttp UA。2.2 Cookie 注入与动态 Referer 生成绕过 412 和 403 的关键两步B 站对高频请求会返回412 Precondition Failed本质是要求携带有效登录态 Cookie。但系统无需用户登录解决方案是复用浏览器已登录的 Cookie并动态更新。操作流程如下手动登录 B 站网页版 → F12 打开开发者工具 → Network 标签 → 刷新任意视频页找到https://api.bilibili.com/x/web-interface/archive/stat?bvid请求 → Copy Request Headers提取Cookie字段含SESSDATA,bili_jct,DedeUserID等→ 存入配置文件config.py在采集脚本中每次请求前动态拼接 Referer根据目标 UP 主 mid 生成# 动态 Referer 示例 referer_base https://space.bilibili.com/ referer f{referer_base}{mid}/video为什么 Referer 要动态B 站后端会校验 Referer 与 Cookie 中的DedeUserID是否匹配。若固定 Referer如https://www.bilibili.com而 Cookie 属于某 UP 主账号则校验失败返回 403。动态 Referer 确保上下文一致。2.3 视频数据清洗从原始 JSON 到结构化字段的映射逻辑B 站 API 返回的 JSON 结构嵌套深、字段名不统一如播放量字段为stat.view弹幕数为stat.danmaku且存在大量空值和异常值。清洗不是简单pd.dropna()而是基于业务规则的强校验原始字段API清洗后字段清洗逻辑业务意义stat.viewplay_count转为整型若0或1e9则置为None防刷量真实播放量用于计算播放榜stat.likelike_count同上同时计算like_rate like_count / play_count需 play_count 0互动健康度指标stat.coincoin_count同上重点校验coin_count play_count * 0.3单视频投币率超 30% 极可能异常三连质量信号tnamevideo_tag取第一级标签如知识-科普→科普若为空则用title关键词提取jieba 分词 TF-IDF统一标签体系支撑视频类型分析pubdatepublish_time时间戳转datetime按小时聚合生成publish_hour字段如 14 → 14分析发布时间规律清洗脚本核心逻辑cleaner.pyimport pandas as pd from datetime import datetime import jieba def clean_bilibili_data(raw_df: pd.DataFrame) - pd.DataFrame: df raw_df.copy() # 1. 播放量清洗 df[play_count] pd.to_numeric(df[stat.view], errorscoerce) df.loc[(df[play_count] 0) | (df[play_count] 1e9), play_count] None # 2. 三连清洗点赞/投币/收藏 for field, prefix in [(like, like), (coin, coin), (favorite, fav)]: col fstat.{field} df[f{prefix}_count] pd.to_numeric(df[col], errorscoerce) # 投币率合理性校验 if prefix coin: rate df[f{prefix}_count] / df[play_count] df.loc[rate 0.3, f{prefix}_count] None # 3. 标签标准化 def extract_tag(tname): if pd.isna(tname) or not tname.strip(): # 用标题关键词补全 title str(df.loc[df.index[0], title]) words jieba.lcut(title) # 过滤停用词取 TF-IDF 最高词简化版 return words[0] if words else 未知 return tname.split(-)[0].strip() df[video_tag] df[tname].apply(extract_tag) # 4. 时间处理 df[publish_time] pd.to_datetime(df[pubdate], units) df[publish_hour] df[publish_time].dt.hour return df[[bvid, title, video_tag, play_count, like_count, coin_count, publish_hour]]2.4 避坑B 站接口限流、数据漂移与字段失效的 4 类真实翻车现场现象采集任务运行 2 小时后突然全部 412日志显示 Cookie 过期原因B 站SESSDATACookie 有效期通常为 30 天但登录态可能因异地登录、密码修改等提前失效。解决在fetch_video_list异常捕获中增加if 412 in str(e): self.refresh_cookie()并实现refresh_cookie()函数——自动打开 Chrome 浏览器执行登录后提取新 Cookie用selenium仅用于此场景非主采集逻辑。现象UP 主 A 的视频列表返回正常但 UP 主 B 的stat字段全为 0原因该 UP 主设置了“隐私保护”隐藏播放、点赞等数据B 站后台可设置。API 返回stat对象但所有数值为 0。解决清洗时增加判断if df[play_count].sum() 0 and len(df) 10: log.warning(fUP {mid} has privacy enabled, skip stat analysis)跳过统计分析仅保留基础信息标题、发布时间。现象tname字段突然从知识-科普变成知识·科普导致标签分类错乱原因B 站前端改版API 字段分隔符由-改为·但文档未同步更新。解决清洗函数extract_tag中兼容多种分隔符tname.split(-)[0].split(·)[0].strip()并记录日志告警if - not in tname and · not in tname: log.error(fUnexpected tname format: {tname})。现象凌晨 2 点采集的视频数据publish_time显示为当天 14:00UTC8 错误原因B 站 API 返回的pubdate是 Unix 时间戳秒级但部分旧视频时间戳为毫秒级需/1000而新视频为秒级。混合处理导致时间偏移。解决在清洗前先做探测取前 5 条数据若pubdate值 1e12毫秒级阈值则全局除以 1000否则保持原样。df[pubdate] df[pubdate].apply(lambda x: x//1000 if x 1e12 else x)。3. 数据库设计与 Django 模型如何让 MySQL 承载百万级视频数据而不卡顿3.1 表结构设计从 E-R 图到生产级索引的 3 个关键决策原文档中的表 3.1 用户数据库表过于简略仅 3 字段实际系统需支撑 UP 主、视频、用户行为三类核心实体。我们按生产环境标准重构表名字段关键类型索引说明up_mastermid(PK),name,fans_count,archive_countBIGINT, VARCHAR, INTPRIMARY KEY(mid),INDEX idx_fans(fans_count)UP 主主表mid为 B 站用户 ID非自增video_infobvid(PK),mid,title,video_tag,publish_timeVARCHAR, BIGINT, TEXT, VARCHAR, DATETIMEPRIMARY KEY(bvid),INDEX idx_mid(mid),INDEX idx_tag_time(video_tag, publish_time)视频主表bvid为唯一标识video_statbvid(PK),play_count,like_count,coin_count,fav_countVARCHAR, INT, INT, INT, INTPRIMARY KEY(bvid),INDEX idx_play(play_count)统计宽表分离高频更新字段user_behaviorid(PK),uid,bvid,action_type,action_timeBIGINT, BIGINT, VARCHAR, TINYINT, DATETIMEPRIMARY KEY(id),INDEX idx_uid_action(uid, action_type),INDEX idx_bvid(bvid)用户行为日志表模拟数据实际需埋点为什么video_info和video_stat拆成两张表video_info标题、标签、发布时间极少更新而video_stat播放、点赞每小时可能变化。拆分后更新统计时只需UPDATE video_stat不影响SELECT标题等基础信息减少锁表时间。3.2 Django 模型定义用db_table和db_index精确控制底层 SQLDjango ORM 默认生成的表名和索引不够高效需手动干预。模型代码models.pyfrom django.db import models class UpMaster(models.Model): mid models.BigIntegerField(primary_keyTrue, verbose_nameUP主ID) name models.CharField(max_length100, verbose_nameUP主昵称) fans_count models.IntegerField(default0, verbose_name粉丝数) archive_count models.IntegerField(default0, verbose_name投稿数) class Meta: db_table up_master # 强制表名不加 app_ 前缀 indexes [ models.Index(fields[fans_count], nameidx_fans), # 自定义索引名 ] verbose_name UP主 verbose_name_plural UP主 class VideoInfo(models.Model): bvid models.CharField(max_length20, primary_keyTrue, verbose_name视频BV号) mid models.ForeignKey(UpMaster, on_deletemodels.CASCADE, db_columnmid, verbose_nameUP主ID) title models.TextField(verbose_name视频标题) video_tag models.CharField(max_length50, default未知, verbose_name视频标签) publish_time models.DateTimeField(verbose_name发布时间) class Meta: db_table video_info indexes [ models.Index(fields[mid], nameidx_mid), models.Index(fields[video_tag, publish_time], nameidx_tag_time), ] verbose_name 视频信息 verbose_name_plural 视频信息 class VideoStat(models.Model): bvid models.OneToOneField(VideoInfo, on_deletemodels.CASCADE, primary_keyTrue, db_columnbvid, verbose_name视频BV号) play_count models.IntegerField(default0, verbose_name播放量) like_count models.IntegerField(default0, verbose_name点赞数) coin_count models.IntegerField(default0, verbose_name投币数) fav_count models.IntegerField(default0, verbose_name收藏数) class Meta: db_table video_stat indexes [ models.Index(fields[play_count], nameidx_play), ] verbose_name 视频统计 verbose_name_plural 视频统计关键点说明db_columnmid确保外键字段名与数据库物理列名一致避免 Django 自动生成mid_idOneToOneFieldVideoStat与VideoInfo一对一保证bvid主键复用查询时JOIN效率最高indexes显式声明Djangomakemigrations会生成CREATE INDEX语句而非依赖db_indexTrue后者仅对单字段有效。3.3 百万数据导入优化用bulk_create替代循环save()的 10 倍提速当导入 50 万条视频数据时若用for v in videos: v.save()耗时约 47 分钟MySQL 默认每条 INSERT 单独事务。优化方案分批bulk_create 禁用外键检查。from django.db import transaction def bulk_import_videos(video_dicts: list): # 1. 分批每批 10000 条 batch_size 10000 for i in range(0, len(video_dicts), batch_size): batch video_dicts[i:ibatch_size] # 2. 转为模型实例 video_objs [ VideoInfo( bviditem[bvid], miditem[mid], titleitem[title], video_tagitem[video_tag], publish_timeitem[publish_time] ) for item in batch ] # 3. 批量创建关键指定 ignore_conflictsTrue 防重复 with transaction.atomic(): VideoInfo.objects.bulk_create( video_objs, batch_sizebatch_size, ignore_conflictsTrue # MySQL 5.7 支持冲突时跳过 ) # 4. 导入统计表同理 stat_objs [VideoStat(bvidv[bvid], **v[stat]) for v in video_dicts] VideoStat.objects.bulk_create(stat_objs, batch_sizebatch_size)为什么ignore_conflictsTrue必须加采集可能重跑bvid重复时若不忽略bulk_create会整个批次报错回滚。加此参数后重复bvid自动跳过其余数据正常插入。3.4 避坑Django 连接池、字符集与长文本截断的 3 个血泪经验现象系统运行 2 小时后Django 报错django.db.utils.OperationalError: (2013, Lost connection to MySQL server during query)原因MySQL 默认wait_timeout288008 小时但 Django 连接池未配置CONN_MAX_AGE连接空闲超时后被 MySQL 主动断开。解决settings.py中设置CONN_MAX_AGE 60单位秒让连接复用 60 秒后自动关闭避免长连接僵死。现象中文标签video_tag存入数据库后变成????原因MySQL 表字符集为latin1未设为utf8mb4。B 站标签含 emoji如知识✨需utf8mb4支持。解决建表时强制指定CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci并在settings.py的DATABASES中添加OPTIONS: {charset: utf8mb4}。现象title字段超过 255 字符被截断导致标题不全原因DjangoCharField(max_length255)对应 MySQLVARCHAR(255)但 B 站标题最长可达 80 字符UTF8MB4 下占 320 字节。解决title models.TextField(verbose_name视频标题)TextField对应 MySQLTEXT无长度限制若必须CharField则设max_length500并确保 MySQL 表字段为VARCHAR(500) CHARSET utf8mb4。4. 多维可视化分析从 Matplotlib 静态图到 Plotly 交互式仪表盘的升级路径4.1 UP 主分析页柱状图 折线图组合的业务语义表达原文档图 4.2 展示“UP 主最喜爱发布的视频类型统计”和“发布数量时间规律”但未说明如何从数据生成。实际实现需将业务逻辑注入图表视频类型分布柱状图不是简单value_counts()而是按video_tag分组后过滤掉低频标签出现5次并归入“其他”避免长尾干扰# views.py from django.db.models import Count from .models import VideoInfo def up_main_analysis(request, mid): # 获取该 UP 主所有视频 videos VideoInfo.objects.filter(midmid).select_related(videostat) # 统计标签分布带低频过滤 tag_stats videos.values(video_tag).annotate(countCount(bvid)) total sum(item[count] for item in tag_stats) # 过滤低频3% 总量并合并为“其他” threshold total * 0.03 filtered_tags [ {tag: item[video_tag], count: item[count]} for item in tag_stats if item[count] threshold ] others_count total - sum(item[count] for item in filtered_tags) if others_count 0: filtered_tags.append({tag: 其他, count: others_count}) # 生成图表数据 labels [item[tag] for item in filtered_tags] values [item[count] for item in filtered_tags] return render(request, up_analysis.html, { labels: labels, values: values, })发布时间规律折线图按publish_hour分组但需补全 0-23 点缺失小时填 0否则折线图断开# 按小时聚合 hour_stats videos.values(publish_hour).annotate(countCount(bvid)) # 补全 0-23 小时 hour_dict {i: 0 for i in range(24)} for item in hour_stats: hour_dict[item[publish_hour]] item[count] hours list(hour_dict.keys()) counts list(hour_dict.values())4.2 综合分析页三维图与动态时间切片的实现难点原文档图 4.3 提到“按小时、按周、按月切换”这并非前端 JS 切换而是后端根据参数动态聚合 SQL。核心是GROUP BY的灵活构造# views.py def comprehensive_analysis(request): time_unit request.GET.get(unit, hour) # hour/week/month if time_unit hour: group_field HOUR(publish_time) label_format %H:00 elif time_unit week: group_field WEEKDAY(publish_time) # 0Monday label_format %W else: # month group_field MONTH(publish_time) label_format %m # 原生 SQL 聚合Django ORM 对复杂 GROUP BY 支持弱 from django.db import connection with connection.cursor() as cursor: cursor.execute(f SELECT {group_field} as g, COUNT(*) as cnt, AVG(v.play_count) as avg_play, SUM(v.play_count) as sum_play FROM video_info i JOIN video_stat v ON i.bvid v.bvid GROUP BY g ORDER BY g ) rows cursor.fetchall() # 构造图表数据 labels [f{r[0]}{label_format} for r in rows] counts [r[1] for r in rows] avg_plays [float(r[2]) for r in rows] sum_plays [r[3] for r in rows] return render(request, comprehensive.html, { labels: labels, counts: counts, avg_plays: avg_plays, sum_plays: sum_plays, unit: time_unit, })为什么用原生 SQLDjango 的extra()或annotate()对WEEKDAY()等 MySQL 函数支持不友好且GROUP BY字段需与SELECT严格对应。原生 SQL 更可控性能无差异Django 底层也是 SQL。4.3 多维分析页散点图坐标轴的业务指标选择逻辑原文档图 4.4 的“视频量、播放量、粉丝量”三维分析实际是双坐标散点图X视频量Y播放量点大小粉丝量。关键在指标归一化否则量纲差异导致图形失真指标原始范围归一化方式业务意义视频量投稿数1 ~ 5000(x - min) / (max - min)衡量内容产出强度播放量均值100 ~ 5e6log10(x 1)压缩长尾突出中腰部UP主粉丝量1000 ~ 1e7sqrt(x)缓解头部效应使点大小可区分import numpy as np def get_multidim_data(): # 查询 UP 主级聚合数据 from django.db import connection with connection.cursor() as cursor: cursor.execute( SELECT u.mid, u.name, u.fans_count, COUNT(v.bvid) as video_count, AVG(s.play_count) as avg_play FROM up_master u LEFT JOIN video_info v ON u.mid v.mid LEFT JOIN video_stat s ON v.bvid s.bvid GROUP BY u.mid, u.name, u.fans_count ) rows cursor.fetchall() # 归一化 video_counts np.array([r[3] for r in rows]) avg_plays np.array([r[4] for r in rows]) fans_counts np.array([r[2] for r in rows]) # X: 视频量线性归一 x (video_counts - video_counts.min()) / (video_counts.max() - video_counts.min() 1e-8) # Y: 播放量对数压缩 y np.log10(avg_plays 1) # Size: 粉丝量平方根缩放 sizes np.sqrt(fans_counts) * 10 # *10 控制点大小 return { x: x.tolist(), y: y.tolist(), sizes: sizes.tolist(), names: [r[1] for r in rows], mids: [r[0] for r in rows], }4.4 避坑Matplotlib 中文乱码、Plotly 渲染卡顿与移动端适配的 3 个硬核解法现象柱状图 X 轴标签中文显示为方块原因Matplotlib 默认字体不支持中文。解决在views.py顶部添加import matplotlib matplotlib.use(Agg) # 非GUI后端 import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei, Arial Unicode MS, DejaVu Sans] # 中文字体列表 plt.rcParams[axes.unicode_minus] False # 正常显示负号现象Plotly 图表在 Django 模板中加载缓慢首屏白屏 3 秒原因Plotly.js 体积大~5MB且默认同步加载。解决使用plotly.offline.plot()生成静态 HTML 片段而非plotly.express的在线模式在模板中用div idchart占位JS 异步加载!-- base.html -- script srchttps://cdn.plot.ly/plotly-2.24.1.min.js defer/script script document.addEventListener(DOMContentLoaded, function() { // 动态插入 Plotly 图 const chartData {{ plotly_json|safe }}; Plotly.newPlot(chart, chartData.data, chartData.layout); }); /script现象散点图在手机上点选区域无效触摸事件不响应原因Plotly 默认responsive: false且移动端hover事件需特殊配置。解决生成图表时强制启用响应式并配置移动端交互fig.update_layout( responsiveTrue, hovermodeclosest, dragmodezoom, # 允许缩放 xaxisdict(fixedrangeFalse), # 允许拖拽 yaxisdict(fixedrangeFalse), margindict(l20, r20, t20, b20) )5. 系统部署与性能压测Nginx Gunicorn MySQL 的最小可行架构5.1 生产环境部署拓扑为什么不用 Apache而选 Nginx GunicornApache 适合传统 PHP但 Python Web 应用尤其 Django的并发模型与 Apache 的 prefork MPM 不匹配易内存溢出。Nginx Gunicorn 是业界标准Nginx作为反向代理和静态文件服务器处理 HTTPS、负载均衡、缓存GunicornPython WSGI HTTP Server用 pre-fork worker 模型稳定高效MySQL独立数据库服务器与应用分离。部署结构用户浏览器 ↓ HTTPS Nginx监听 443 ↓ 反向代理到 127.0.0.1:8000 Gunicorn4 workers, 1000 max requests ↓ Django 应用 MySQL监听 33065.2 Gunicorn 配置worker 数量与内存占用的黄金比例Gunicorn 的--workers参数不是越多越好。经实测4 核 8G 云服务器workers内存占用QPS100 并发CPU 利用率说明2320MB8545%过少无法压满 CPU4580MB14278%最优QPS 最高且稳定6890MB13892%内存吃紧偶发 OOM81.2GB12598%CPU 饱和响应延迟上升gunicorn.conf.py关键配置import multiprocessing # 基础 bind 127.0.0.1:8000 bind_ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem bind_ssl_private_key /etc/letsencrypt/live/yourdomain.com/privkey.pem workers 4 worker_class sync # 同步模式稳定优先 worker_connections 1000 max_requests 1000 max_requests_jitter 100 # 资源 timeout 30 keepalive 5 preload True # 预加载应用避免 fork 后重复加载5.3 Nginx 配置静态文件托管与反向代理的 5 行核心指令Nginx 不仅是代理更是静态文件 CDN。Django 的collectstatic输出到staticfiles/Nginx 直接服务本文还有配套的精品资源点击获取
网站建设高端定制企业官网