简介本资源是一套面向Python初学者与数据分析爱好者的B站番剧全链路分析实践项目聚焦ACG文化数据采集、清洗、分析与可视化全流程可作为爬虫与数据分析入门的完整学习范例。压缩包共23个文件含3个Jupyter Notebook分别实现爬虫、数据预处理与分析建模、2个Excel数据集原始与清洗后动漫数据、10张可视化结果图如追番趋势、评分分布、题材热度等以及README说明文档和IDE配置文件整体仅3.58MB轻量易解压运行。已有2980人学习下载覆盖高校课程实践、自学项目复现及技术面试准备场景。读者可直接复用爬虫逻辑获取B站动态数据调用现成分析代码生成多维度统计图表并通过.md文档快速掌握从网页结构解析、反爬应对、缺失值处理到Matplotlib/Seaborn可视化落地的关键细节具备强实操性与教学参考价值。1. 为什么爬B站动漫数据不能只靠“requests BeautifulSoup”硬刚去年带某高校课程设计时A同学交上来一份“B站番剧热度分析”代码跑通了但答辩被导师当场叫停首页番剧列表页返回空数据、UP主投稿页抓不到播放量、弹幕接口403频繁报错——不是他代码写得差而是把B站当成了静态博客在爬。B站的反爬机制早不是加个User-Agent就能绕过的前端渲染依赖JavaScript动态注入、关键字段如aid、cid、season_id藏在加密JSON里、弹幕需携带valid sign参数、登录态强绑定cookiebuvid3device_id三元组。这套“Python爬虫-B站动漫数据分析与可视化”方案就是为解决这类真实翻车场景而生它不教你怎么绕过风控而是用合法合规的公开接口模拟行为边界结构化清洗逻辑完整覆盖从番剧元数据采集、用户行为日志补全、弹幕文本解析到多维可视化落地的闭环。适合毕设/课设需要交付可复现、可答辩、有业务逻辑深度的Python项目者——你不需要成为安全研究员但得让导师相信这数据真能采回来且分析结论经得起推敲。2. 接口选型与合法性边界哪些能爬、哪些必须绕开、哪些根本不能碰B站公开接口体系庞大但并非所有都适合作为教学或课程设计的数据源。我们严格遵循《Bilibili开放平台开发者协议》第3.2条“禁止高频调用非授权接口”及第4.1条“不得采集用户隐私信息”的约束将接口划分为三类可用、慎用、禁用。下表是实际项目中验证过的接口清单与调用策略接口类型示例URL调用频率限制是否需登录态合法性说明本方案采用方式可用推荐https://api.bilibili.com/pgc/web/tv/seasons?area1无显式限频实测5qps稳定否公开番剧分类API返回season_id、title、rating等结构化元数据直接调用作为主数据源慎用需降频随机UAhttps://api.bilibili.com/pgc/view/web/season?season_id{sid}单IP约30次/分钟否返回单部番剧详情集数、更新时间、评分人数但部分字段如播放量已隐藏加入time.sleep(random.uniform(1.2, 2.5))UA轮换禁用明确违反协议https://api.bilibili.com/x/v2/dm/web/seg.so?oid{oid}type1segment_index1无公开限制是弹幕原始二进制流接口需valid sign签名且协议明令禁止未授权采集完全弃用改用官方弹幕XML接口提示别信“破解sign算法”的教程网上流传的sign生成逻辑如md5(tssalt)早已失效。B站2023年Q4起对所有需sign的接口启用动态密钥分发密钥有效期30秒且与设备指纹强绑定。强行逆向不仅违法还会导致IP被加入风控黑名单——我们实测某测试IP在触发3次无效sign后连首页都返回503。2.1 番剧元数据采集用PGC接口构建基础数据骨架核心思路是放弃“从网页HTML解析”转而用B站PGCProfessional Generated Content频道提供的标准REST API。该接口返回JSON结构清晰字段含义明确且无需登录即可获取90%以上分析所需元数据import requests import time import random def fetch_anime_list(area_code: int 1, page: int 1, pagesize: int 20) - list: 获取指定地区area_code的番剧列表 area_code: 1国产, 2日本, 3欧美, 4其他 返回字段示例: {season_id: 42781, title: 咒术回战, rating: {score: 9.8}, pub_time: 2020-10-02} url https://api.bilibili.com/pgc/web/tv/seasons params { area: area_code, page: page, pagesize: pagesize, platform: web, season_status: 1 # 仅获取已上线番剧 } headers { User-Agent: random.choice([ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36, Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ]), Referer: https://www.bilibili.com/ } try: resp requests.get(url, paramsparams, headersheaders, timeout10) resp.raise_for_status() data resp.json() return data.get(result, {}).get(list, []) except Exception as e: print(f[ERROR] 获取番剧列表失败: {e}) return [] # 实际调用示例采集国产区前100部番剧 anime_list [] for p in range(1, 6): # 5页 × 20条 100部 batch fetch_anime_list(area_code1, pagep, pagesize20) anime_list.extend(batch) time.sleep(random.uniform(1.5, 3.0)) # 严格控频避免触发风控这段代码的关键设计点在于area_code参数精准定位国产/日本/欧美分区避免用关键词搜索带来的噪声如“海贼王”可能匹配到游戏视频season_status1过滤掉预告、未上线状态确保后续分析对象均为真实可播内容timeout10防止卡死配合raise_for_status()快速失败便于批量任务中断重试time.sleep区间设为1.5~3.0秒比B站文档建议的“不低于1秒”更保守——这是A同学第一次答辩翻车后我强制加上的血泪经验他原用固定1秒连续跑满5页后IP被临时封禁10分钟。2.2 用户行为补全用“番剧详情页”补充播放量、追番数等动态指标PGC列表接口不返回播放量、追番人数等运营指标需调用/pgc/view/web/season接口补全。该接口虽未明文要求登录但B站会校验Referer与Cookie中的buvid3有效性。我们的处理策略是不登录、不维持会话、每次请求伪造最小必要Cookie。def fetch_season_detail(season_id: int) - dict: 获取单部番剧详情含播放量、追番数、评分人数 注意此接口返回字段不稳定需做容错处理 url fhttps://api.bilibili.com/pgc/view/web/season?season_id{season_id} # 伪造最小化Cookie仅提供buvid3B站设备标识不包含SESSDATA等敏感字段 cookies { buvid3: 12345678-ABCD-EF01-2345-678901234567 # 格式为uuid-v4实际使用时可固定一个合法值 } headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://www.bilibili.com/, Origin: https://www.bilibili.com } try: resp requests.get(url, cookiescookies, headersheaders, timeout8) if resp.status_code 200: data resp.json() result data.get(result, {}) # 关键字段提取兼容字段名变更 return { season_id: season_id, play_count: result.get(stat, {}).get(views, 0), follow: result.get(stat, {}).get(followers, 0), score: result.get(rating, {}).get(score, 0.0), score_count: result.get(rating, {}).get(count, 0), episodes: len(result.get(episodes, [])) } else: print(f[WARN] season_id{season_id} 详情页返回{resp.status_code}) return {season_id: season_id, play_count: 0, follow: 0, score: 0.0, score_count: 0, episodes: 0} except Exception as e: print(f[ERROR] 获取season_id{season_id}详情失败: {e}) return {season_id: season_id, play_count: 0, follow: 0, score: 0.0, score_count: 0, episodes: 0} # 批量补全示例对前50部番剧执行 detail_list [] for item in anime_list[:50]: detail fetch_season_detail(item[season_id]) detail_list.append(detail) time.sleep(random.uniform(2.0, 4.0)) # 比列表页更严苛的间隔这里的设计深意在于buvid3伪造而非窃取buvid3是B站设备指纹格式为标准UUID v4只要符合格式且不与其他真实设备冲突B站服务端不会校验其真实性。我们用固定值规避了“登录态维护”这一高风险操作字段提取做双重容错.get(stat, {}).get(views, 0)避免因API字段结构调整导致整个程序崩溃play_count与follow分离存储B站2024年起对播放量展示做模糊化如“123.4万”但API仍返回精确整数此处保留原始值用于分析可视化时再做格式化。2.3 弹幕数据采集用官方XML接口替代二进制SO流B站官方明确支持通过/x/v1/dm/list.so获取弹幕XML见 开放平台文档 该接口无需sign、无需登录、响应稳定。关键在于如何获取oid弹幕池ID——它不等于番剧season_id而是每集视频的av/bv号对应的一个独立ID。def get_oid_by_bv(bv_id: str) - int: 通过BV号获取对应视频的oid弹幕池ID 调用B站通用视频信息接口 /x/web-interface/view url fhttps://api.bilibili.com/x/web-interface/view?bvid{bv_id} try: resp requests.get(url, timeout8) data resp.json() return data.get(data, {}).get(aid, 0) # aid即oid except: return 0 def fetch_danmaku_xml(oid: int, segment_index: int 1) - str: 获取指定oid的弹幕XML分段每段最多2000条 返回原始XML字符串供后续解析 url fhttps://api.bilibili.com/x/v1/dm/list.so?oid{oid}segment_index{segment_index} try: resp requests.get(url, timeout12) if resp.status_code 200: return resp.content.decode(utf-8) else: print(f[WARN] oid{oid} 弹幕XML获取失败: {resp.status_code}) return except Exception as e: print(f[ERROR] 获取oid{oid}弹幕XML失败: {e}) return # 实际流程先查番剧各集BV号 → 转oid → 抓弹幕XML # 注此步骤需额外调用season详情接口获取episodes列表代码略选择XML而非SO流的核心原因是协议合规性XML接口在B站开放平台白名单内调用日志可查、责任可溯解析确定性XML结构稳定d p...文本/dp属性含时间戳、字体、颜色等12个维度远超JSON接口的信息密度规避加密黑匣子SO流需解密ZlibBase64且密钥随版本更新——我们曾为解密v2.3版SO流耗时17小时最终发现B站已悄然切到v2.4密钥算法变更。XML是真正的“后悔药”。3. 数据清洗与结构化从原始JSON/XML到可分析的DataFrame爬取的原始数据充满噪声番剧标题含广告词如“【官方正版】鬼灭之刃”、播放量字段存在“万”“亿”单位混用、弹幕XML含HTML实体编码、评分出现“null”或空字符串。若直接喂给Pandas分析图表会集体崩坏。本方案采用“四层清洗流水线”字段标准化 → 单位归一化 → 缺失值填充 → 语义增强。3.1 字段标准化统一命名与类型约束定义核心数据表结构强制字段类型与非空约束字段名类型是否必填说明清洗规则season_idINT是番剧唯一标识原始值转int非法值置0titleVARCHAR(100)是番剧标题去除【】、内广告词如re.sub(r【.*?】areaENUM(CN,JP,US,OTHER)是地区分类area_code映射1→CN, 2→JP...play_countBIGINT否播放量次单位归一化后存整数followINT否追番人数同上scoreDECIMAL(3,1)否评分0.0~10.0null→0.0超界值截断score_countINT否评分人数null→0episodesINT否总集数0值视为“未完结”不填充import pandas as pd import re def standardize_title(title: str) - str: 清洗标题去除广告标识、统一空格、截断超长 if not isinstance(title, str): return # 去除【】、、『』、[]内的广告词 title re.sub(r[【\[\(『].*?[』\)\]\】], , title) # 去除多余空格与换行 title re.sub(r\s, , title).strip() # 截断至100字符MySQL VARCHAR(100)限制 return title[:100] def normalize_play_count(raw: str) - int: 将123.4万、5.6亿、7890统一转为整数 if not raw or raw 0: return 0 if isinstance(raw, (int, float)): return int(raw) raw str(raw).strip() if 亿 in raw: return int(float(raw.replace(亿, )) * 100000000) elif 万 in raw: return int(float(raw.replace(万, )) * 10000) else: try: return int(float(raw)) except: return 0 # 构建DataFrame并应用清洗 df_raw pd.DataFrame(anime_list) df_clean pd.DataFrame({ season_id: df_raw[season_id].apply(lambda x: int(x) if pd.notna(x) else 0), title: df_raw[title].apply(standardize_title), area: df_raw[area].map({1: CN, 2: JP, 3: US, 4: OTHER}).fillna(OTHER), play_count: df_raw[play_count].apply(normalize_play_count), follow: df_raw[follow].apply(lambda x: int(x) if pd.notna(x) else 0), score: pd.to_numeric(df_raw[score], errorscoerce).fillna(0.0).round(1), score_count: pd.to_numeric(df_raw[score_count], errorscoerce).fillna(0).astype(int), episodes: pd.to_numeric(df_raw[episodes], errorscoerce).fillna(0).astype(int) })注意不要用df.fillna(methodffill)填充评分A同学曾用前向填充补全score字段结果把《工作细胞》的9.7分错误继承给下一部冷门番剧导致相关性分析完全失真。正确做法是缺失即未知填0.0并在分析时用df[df[score_count] 100]过滤低样本量数据。3.2 弹幕文本清洗从XML到情感可计算的语料库弹幕XML需解析出时间戳、用户ID、弹幕内容三要素并进行中文NLP预处理import xml.etree.ElementTree as ET from typing import List, Dict def parse_danmaku_xml(xml_str: str) - List[Dict]: 解析弹幕XML返回[{time, uid, text}, ...]列表 if not xml_str.strip(): return [] try: root ET.fromstring(xml_str) danmakus [] for d in root.findall(d): p_attr d.get(p, ) if not p_attr: continue # p123.45,1,25,16777215,1721234567,0,0,0,0 → 时间戳在第1位 parts p_attr.split(,) if len(parts) 5: continue timestamp float(parts[0]) # 秒级时间戳 # 提取弹幕文本处理HTML实体 text d.text or text text.replace(quot;, ).replace(apos;, ) text text.replace(lt;, ).replace(gt;, ) text text.replace(amp;, ) danmakus.append({ time: round(timestamp, 2), uid: parts[4], # B站UID非用户昵称 text: text.strip() }) return danmakus except Exception as e: print(f[ERROR] XML解析失败: {e}) return [] def clean_danmaku_text(text: str) - str: 弹幕文本清洗去广告、去链接、去重复符号 if not isinstance(text, str): return # 去除网址http/https/www开头 text re.sub(rhttps?://\S|www\.\S, , text) # 去除连续重复标点如→ text re.sub(r([!?.。]){2,}, r\1, text) # 去除纯数字/字母刷屏如111111、aaaaaa if re.fullmatch(r[0-9]{5,}|[a-zA-Z]{5,}, text.strip()): return # 去除广告话术 ad_keywords [关注, 点赞, 转发, 进群, vx, 微信, QQ] for kw in ad_keywords: if kw in text: return return text.strip() # 应用清洗 danmaku_list parse_danmaku_xml(xml_content) df_danmaku pd.DataFrame(danmaku_list) df_danmaku[clean_text] df_danmaku[text].apply(clean_danmaku_text) df_danmaku df_danmaku[df_danmaku[clean_text] ! ] # 过滤空弹幕这段代码的玄学在于clean_danmaku_text的过滤顺序先去链接再处理标点避免https://xxx!!!被误判为刷屏纯字符刷屏检测用fullmatch而非find防止“哈哈哈”被误杀广告词过滤放在最后因为“关注”可能是弹幕情绪表达如“关注进度”需结合上下文但课程设计场景下宁可误杀不可漏放。3.3 语义增强为番剧打上题材、年代、制作公司标签原始数据缺少题材分类如“热血”“恋爱”“科幻”需通过标题关键词人工规则库补全。我们构建了一个轻量级规则引擎不依赖外部NLP模型避免部署复杂度# 题材规则库关键词→题材映射 GENRE_RULES { 热血: [热血, 战斗, 格斗, 武道, 剑道, 忍术], 恋爱: [恋爱, 校园, 青春, 纯爱, 百合, BL], 科幻: [科幻, 机甲, 机器人, 太空, 未来, 赛博], 奇幻: [奇幻, 魔法, 异世界, 龙, 精灵, 魔幻], 日常: [日常, 搞笑, 治愈, 美食, 萌系, 泡面], 悬疑: [悬疑, 推理, 侦探, 犯罪, 惊悚, 恐怖] } def extract_genre(title: str) - str: 基于标题关键词提取题材返回最高匹配题材 if not isinstance(title, str): return 未知 title_lower title.lower() matches [] for genre, keywords in GENRE_RULES.items(): for kw in keywords: if kw.lower() in title_lower: matches.append(genre) break # 每个题材只匹配一次避免重复计数 return matches[0] if matches else 未知 # 年代提取从标题或pub_time推断 def extract_year(pub_time: str) - int: 从pub_time字符串提取年份如2020-10-02→2020 if not pub_time or not isinstance(pub_time, str): return 0 match re.search(r^(\d{4})-, pub_time) return int(match.group(1)) if match else 0 # 应用到DataFrame df_clean[genre] df_clean[title].apply(extract_genre) df_clean[year] df_clean[pub_time].apply(extract_year) # 假设pub_time已从season详情中获取这个规则引擎的优势是零依赖不调用jieba或hanlp避免环境配置问题可解释性强导师问“为什么《咒术回战》判为热血”可直接指出标题含“战斗”“咒灵”等关键词易扩展新增题材只需往GENRE_RULES加字典项无需改代码逻辑。4. 可视化设计与交互逻辑不只是画图而是讲好数据故事可视化不是把plt.bar()堆出来就完事。B站动漫数据有鲜明业务特征时间维度年份/季度、地域维度国产/日本、质量维度评分/播放量、情感维度弹幕情绪。本方案采用“四象限叙事法”每个图表解决一个具体问题且图表间存在逻辑递进。4.1 番剧热度雷达图播放量、追番数、评分、集数四维对比用雷达图直观展示番剧综合热度避免单一指标误导如高播放量但低评分的“流量番”import numpy as np import matplotlib.pyplot as plt def plot_heat_radar(df: pd.DataFrame, season_id: int, title: str): 绘制单部番剧四维热度雷达图 # 提取数据归一化到0-100 row df[df[season_id] season_id].iloc[0] values [ min(row[play_count] / 100000000 * 100, 100), # 播放量亿为单位 min(row[follow] / 5000000 * 100, 100), # 追番数500万为基准 row[score], # 评分0-10→0-100 min(row[episodes] / 50 * 100, 100) # 集数50集为满 ] labels [播放量, 追番数, 评分, 集数] angles [n / float(len(labels)) * 2 * np.pi for n in range(len(labels))] values values[:1] # 闭合图形 angles angles[:1] fig, ax plt.subplots(figsize(6, 6), subplot_kwdict(polarTrue)) ax.plot(angles, values, linewidth2, linestylesolid, labeltitle) ax.fill(angles, values, alpha0.25) ax.set_xticks(angles[:-1]) ax.set_xticklabels(labels) ax.set_ylim(0, 100) ax.set_title(f{title} 热度雷达图, pad20) ax.legend(locupper right, bbox_to_anchor(1.3, 1.1)) plt.tight_layout() plt.show() # 调用示例 plot_heat_radar(df_clean, season_id42781, title咒术回战)为什么用雷达图而不是散点图散点图擅长看两变量相关性但“播放量高是否意味着评分高”这种问题雷达图能一眼看出《咒术回战》播放量和评分双高但集数偏低因季番特性而《工作细胞》评分极高但播放量中等——这引出后续分析“高口碑是否必然带来高流量”4.2 地域-年代热力图国产与日本番剧的年度热度变迁用热力图揭示市场格局变化X轴为年份Y轴为地区色块值为该年该地区番剧平均播放量import seaborn as sns def plot_region_year_heatmap(df: pd.DataFrame): 绘制地域×年代热力图 # 按年份和地区分组计算平均播放量 pivot_df df.groupby([year, area])[play_count].mean().unstack(fill_value0) # 转为百万为单位提升可读性 pivot_df pivot_df / 1000000 plt.figure(figsize(10, 6)) sns.heatmap( pivot_df, annotTrue, fmt.1f, cmapYlOrRd, cbar_kws{label: 平均播放量百万} ) plt.title(国产与日本番剧年度平均播放量热力图2018-2024) plt.xlabel(地区) plt.ylabel(年份) plt.tight_layout() plt.show() plot_region_year_heatmap(df_clean)这张图的价值在于暴露趋势2020年后国产区播放量曲线陡升印证“国创崛起”现象日本区2022年出现断崖下跌需结合事件分析如《间谍过家家》爆火但分流了传统番剧流量图中空白单元格如2018年US区自动显示0提醒数据覆盖不全避免过度解读。4.3 弹幕情感词云用TF-IDF提取高区分度词汇不直接用wordcloud而是先做TF-IDF加权突出“某部番剧特有”而非“全站通用”的弹幕词from sklearn.feature_extraction.text import TfidfVectorizer from wordcloud import WordCloud import jieba def generate_danmaku_wordcloud(df_danmaku: pd.DataFrame, top_n: int 100): 生成弹幕TF-IDF词云 # 中文分词 texts df_danmaku[clean_text].dropna().tolist() seg_texts [ .join(jieba.cut(t)) for t in texts] # TF-IDF向量化仅统计词频不计算IDF因单部番剧语料有限 vectorizer TfidfVectorizer( max_featurestop_n, stop_words[的, 了, 在, 是, 我, 有, 和, 就, 不, 人, 都, 一, 一个], token_patternr(?u)\b\w\b ) tfidf_matrix vectorizer.fit_transform(seg_texts) # 提取词频权重 feature_names vectorizer.get_feature_names_out() tfidf_scores tfidf_matrix.sum(axis0).A1 # 求和所有文档的TF-IDF值 word_score_dict {word: score for word, score in zip(feature_names, tfidf_scores)} # 生成词云 wc WordCloud( font_pathsimhei.ttf, # 中文字体路径 width800, height400, background_colorwhite, max_wordstop_n, colormapviridis ) wc.generate_from_frequencies(word_score_dict) plt.figure(figsize(12, 6)) plt.imshow(wc, interpolationbilinear) plt.axis(off) plt.title(弹幕高频情感词云TF-IDF加权) plt.show() # 调用示例需传入某部番剧的弹幕DataFrame generate_danmaku_wordcloud(df_danmaku_subset)关键设计点停用词表精简只保留最常用虚词避免“啊”“哦”等语气词霸榜max_featurestop_n控制词数课程设计场景下100词足够呈现主题过多反而杂乱colormapviridis比默认jet更符合学术图表规范且色盲友好。5. 常见问题排查这5个坑90%的人至少踩过3个做B站数据项目最大的成本不是写代码而是排查那些“看起来正常却结果诡异”的问题。以下是我在带项目过程中学生反馈最高频、最耗时的5个问题按“现象→原因→解决”给出可立即执行的方案。5.1 现象fetch_anime_list()返回空列表但浏览器访问同一URL能正常看到数据原因B站对Referer头做了严格校验https://www.bilibili.com/末尾必须带斜杠且大小写敏感。若headers中写成Referer: https://www.bilibili.com缺/服务端直接返回空result。解决统一写为Referer: https://www.bilibili.com/并在所有请求中用urljoin拼接避免手写错误。5.2 现象fetch_season_detail()偶尔返回{code: -403, message: 无权限访问}原因B站会动态校验buvid3的有效期固定buvid3值超过24小时后会被拒绝。这不是IP封禁而是设备标识过期。解决不伪造buvid3改用requests.Session()维持一个短期会话。在脚本开头创建session首次请求https://www.bilibili.com/获取真实buvid3后续请求复用该sessionsession requests.Session() session.get(https://www.bilibili.com/, timeout5) # 触发set-cookie # 此时session.cookies已含有效buvid3直接用于后续请求5.3 现象弹幕XML解析后df_danmaku行数远少于预期如1000条弹幕只解析出200条原因d标签内含HTML实体如quot;但ET.fromstring()默认不解析实体导致d.text为None。解决在解析前用html.unescape()预处理XML字符串import html xml_clean html.unescape(xml_str) # 解析HTML实体 root ET.fromstring(xml_clean) # 再解析XML5.4 现象normalize_play_count(123.4万)返回1234000.0但期望整数原因float(123.4万)会报错而re.sub未处理小数点导致123.4万被错误替换为123.4再转float得123.4。解决正则表达本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?