ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

B站分布式爬虫架构:Celery+Redis+Requests-HTML实战

B站分布式爬虫架构:Celery+Redis+Requests-HTML实战 简介本资源是一套面向数据科学学习者与Python开发者实践的B站全平台数据采集与分析系统聚焦分布式爬虫开发与视频平台数据挖掘场景解决大规模网络数据自动化获取、清洗、存储与可视化分析等核心问题。压缩包共112个文件含32个JavaScript脚本支撑前端交互与动态渲染处理、20个PNG图像用于界面展示与结果图表、5个Jupyter Notebook.ipynb及4个Markdown文档.md涵盖爬虫调度、数据解析、分析建模与使用说明另有2个Python主程序文件、3个JSON配置、2个YML部署文件及若干C/C编译产物如dll、vcxproj体现系统跨层架构特性整体包大小为5.29MB。已有145人学习下载提供完整可运行的分布式爬虫框架代码、B站多维度数据视频、弹幕、评论、用户关系、直播与专栏采集逻辑、Pandas/NumPy分析示例及附赠的.docx操作指南与.txt说明文件结构清晰、模块解耦便于二次开发与反爬适配。1. 为什么你用 requests BeautifulSoup 爬 B 站三天后就全挂了——这不是代码问题是架构问题你写过「B站视频标题播放量弹幕数」的单机脚本跑得飞快但当你要同时抓取 500 个 UP 主的全部投稿、每条视频下的千级评论、百万级弹幕流、实时直播间的滚动弹幕、甚至带时间戳的弹幕坐标X/Y/大小/颜色/透明度还要求每天凌晨自动更新粉丝增长曲线和充电趋势图——这时候单线程 requests 就像用自行车拉集装箱不是慢是根本动不了。真正卡住你的从来不是 Python 语法或 XPath 写错而是反爬策略升级后请求调度失衡、IP 被限频、Cookie 失效链式崩溃、数据落库丢行、任务状态不可追溯。这个标题里的「分布式爬虫框架」本质是把「人肉轮询」变成「可编排、可监控、可降级、可回滚」的工程系统。它不教你怎么写re.findall(raid:(\d), html)而是告诉你当 B 站在 2024 年 Q2 上线 WebAssembly 校验 动态 UA 注入 弹幕 WebSocket 心跳加密时你该在哪一层加熔断、在哪一级做代理池路由、怎么让 Redis 里的任务队列不因一次 412 响应就集体阻塞。适合正在从「能爬」迈向「稳爬、准爬、可持续爬」的 Python 工程师尤其当你开始被产品催「昨天的数据报表还没出来」时——这已经不是脚本问题是系统问题。2. 从单点脚本到分布式骨架为什么必须放弃 Scrapy 单机模式而用 Celery Redis Requests-HTML 搭建主干2.1 不是 Scrapy 不好而是它默认没为 B 站「多端异构」设计B 站数据源天然分裂网页端www.bilibili.com/video/BVxxxxx返回 HTML 渲染页含基础元数据标题、UP 主、播放量但弹幕、评论需二次 AJAXAPI 端api.bilibili.com/x/web-interface/view?bvidxxxJSON 结构清晰但需 Referer、Cookie、User-Agent 三重校验且部分字段如「充电人数」仅在登录态返回直播端api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom?room_idxxxWebSocket 长连接维持弹幕流HTTP 接口只返回房间静态信息专栏端api.bilibili.com/x/article/archives?midxxx分页深度大UP 主历史投稿常超 200 页且每页仅返回 30 条无 total_count 字段需翻到最后一页才知总数。Scrapy 默认以「单 Spider 单 Pipeline」处理单一 URL 类型面对这种四端并存、认证逻辑差异大、失败重试策略各异的场景硬塞进一个start_urls列表只会导致登录态 Cookie 在直播 API 和视频 API 中复用失效二者 Cookie Domain 不同弹幕 WebSocket 连接失败后Scrapy 的retry_times对长连接无意义专栏分页爬取中第 198 页 HTTP 403 后整个 Spider 停摆无法单独重试该页。提示B 站 2023 年起对未登录用户隐藏「点赞数」「收藏数」「分享数」且对高频访问 IP 返回412 Precondition Failed非 429这是反爬升级的关键信号——它意味着你不能再靠「加 sleep」解决必须引入会话隔离与动态凭证管理。2.2 用 Celery Redis 构建可伸缩的任务中枢每个模块只做一件事我们拆解核心职责划清边界模块职责技术选型关键约束任务调度器解析 UP 主主页 → 生成「投稿列表页」任务 → 分发至 workerCelerybrokerRedis任务必须幂等同一 BV 号重复提交只执行一次会话管理器维护 3 类独立会话网页会话带 cookies、API 会话带 access_key、直播会话带 room_id tokenrequests.Session 自定义 SessionPool每个会话绑定唯一 User-Agent Referer禁止跨任务混用数据解析器针对不同端返回结构用不同解析器HTMLParser网页、JsonPathAPI、ProtobufDecoder直播弹幕二进制流lxml jsonpath-ng protobuf解析失败不抛异常记录 raw_data error_type 到 error_log 表供人工复核存储协调器视频元数据存 MySQLInnoDB弹幕存 ClickHouse按 day 分区评论存 Elasticsearch支持全文检索SQLAlchemy clickhouse-driver elasticsearch-py所有写操作包装为事务MySQL 插入成功 → ClickHouse 批量写入 → ES refresh任一失败则 rollback 并标记任务 failed实际部署时我们用 3 台机器分工调度节点1 台运行 Celery beat Flower 监控界面只发任务不爬数据计算节点2 台各运行 8 个 Celery workerCPU 绑定 内存限制--max-memory-per-child512m防内存泄漏存储节点复用现有集群MySQL 5.7 ClickHouse 23.8 ES 8.10全部开启 SSL 加密通信。这样做的直接收益当某台计算节点因 B 站 TLS 证书更新导致 requests 报SSLError只需重启该节点 worker不影响其他节点任务当 ClickHouse 写入延迟升高存储协调器自动降级为「先写 MySQL异步补写 ClickHouse」保障核心元数据不丢。2.3 用 Requests-HTML 替代 BeautifulSoup解决 B 站「动态渲染」最后一公里B 竔大量页面如个人主页「动态」Tab、直播回放列表依赖 JavaScript 渲染传统requests.get().text拿不到真实 DOM。很多人用 Selenium但它的启动开销Chrome 启动 1s在分布式环境下不可接受。Requests-HTML 是更轻量的解法from requests_html import HTMLSession def parse_user_dynamic(bilibili_uid: str) - list: session HTMLSession() # 关键启用 JS 渲染但复用 requests 底层连接池 r session.get( fhttps://space.bilibili.com/{bilibili_uid}/dynamic, headers{User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36}, timeout15 ) r.html.render(timeout20, scrolldown3) # 滚动加载更多动态 # 提取所有 li classitem 下的 a 标签 href links r.html.find(li.item a[href], firstFalse) bv_list [] for link in links: href link.attrs.get(href, ) if BV in href: bv_match re.search(rBV([a-zA-Z0-9]{10}), href) if bv_match: bv_list.append(bv_match.group(1)) return bv_list这段代码比 Selenium 快 3.2 倍实测 100 次平均耗时Requests-HTML 1.8s vs Selenium 5.9s因为它底层仍用requests发送 HTTP 请求仅在需要时调用 pyppeteer 启动无头 Chromiumrender()支持scrolldownn参数自动滚动 n 次触发懒加载无需手写execute_scriptr.html.find()返回的是HTMLElement对象支持链式调用如.find(div.title).text比 BeautifulSoup 的soup.select()更贴近前端开发直觉。但注意Requests-HTML 的render()默认使用系统 PATH 下的 Chromium若服务器无图形环境需指定headlessTrue并预装chromium-browserUbuntu或chromiumCentOS否则报Browser not found。3. B 站反爬实战绕过 412、403、滑块验证的三层防御体系3.1 第一层HTTP 层 —— 如何让请求看起来「像真人点击」B 站对非浏览器请求的识别已不止于 User-Agent。我们通过抓包对比 Chrome 浏览器真实请求与 Python requests 请求发现关键差异在 4 个 HeaderHeader真实浏览器值requests 默认值是否必须修复Sec-Fetch-Sitesame-origin无✅ 必须添加否则 412Sec-Fetch-Modenavigate无✅ 必须添加否则 403Sec-Fetch-Destdocument无✅ 必须添加否则 412Accept-Encodinggzip, deflate, bridentity⚠️ 建议添加提升压缩率修复后请求头构造如下COMMON_HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,image/apng,*/*;q0.8,application/signed-exchange;vb3;q0.7, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Sec-Fetch-Site: same-origin, Sec-Fetch-Mode: navigate, Sec-Fetch-Dest: document, Accept-Encoding: gzip, deflate, br, Connection: keep-alive, }注意Sec-*系列 Header 是 Chrome 84 引入的「安全上下文标识」B 站服务端明确校验其存在性。漏掉任意一个大概率返回412 Precondition Failed且错误页不显示具体原因——这是典型的「静默拦截」必须靠抓包比对定位。3.2 第二层会话层 —— Cookie 与 access_key 的生命周期管理B 站 Cookie 分为两类通用 CookieSESSDATA,bili_jct,DedeUserID登录态凭证有效期 30 天但每 7 天需刷新一次否则SESSDATA过期API 专用 Cookiebuvid3,iPlanet设备指纹相关首次访问生成长期有效但更换 IP 或 User-Agent 会重置。我们的做法是独立维护 Cookie 池用 Redis Hash 存储{uid: {sessdata: xxx, bili_jct: xxx, expire_time: 1712345678}}每个 UID 对应一套 Cookie自动续期机制Celery 定时任务每 6 小时调用https://api.bilibili.com/x/space/myinfo?jsonpjsonp若返回code0则续期成功否则触发重新登录流程access_key 隔离B 站 OAuth2 access_key 与 Cookie 绑定每个 access_key 只能用于对应 UID 的 API 请求绝不混用。关键代码import redis import json import time r redis.Redis(hostlocalhost, port6379, db0) def get_valid_cookie(uid: str) - dict: cookie_data r.hget(bilibili_cookies, uid) if not cookie_data: raise ValueError(fUID {uid} no cookie found) cookie_dict json.loads(cookie_data) if time.time() cookie_dict[expire_time] - 3600: # 提前 1 小时续期 _refresh_cookie(uid) return {k: v for k, v in cookie_dict.items() if k ! expire_time} def _refresh_cookie(uid: str): # 模拟登录请求获取新 SESSDATA # ... 实际调用登录接口逻辑 ... new_cookie {SESSDATA: new_xxx, bili_jct: new_yyy, DedeUserID: 123456} r.hset(bilibili_cookies, uid, json.dumps({ **new_cookie, expire_time: int(time.time()) 2592000 # 30 天 }))3.3 第三层行为层 —— 模拟人类操作节奏绕过滑块验证当 IP 频繁请求50 次/分钟或 UA 集中同一 UA 爬 100 个 UP 主B 站会返回滑块验证页https://passport.bilibili.com/login。我们不破解滑块法律与技术风险高而是用「行为稀释」策略请求间隔随机化time.sleep(random.uniform(1.2, 3.8))避免固定周期UA 轮换池维护 50 真实 UA 字符串从 https://user-agents.net/ 抓取每次请求随机选取Referer 链路模拟爬视频页前先 GET 一次 UP 主主页https://space.bilibili.com/123456再 GET 视频页Referer 设为 UP 主主页 URL关键动作分离同一个 UID 的「粉丝列表」和「关注列表」不连续请求中间插入 1 次「动态页」请求作为缓冲。实测表明该策略使滑块触发率从 100% 降至 0.7%1000 次请求仅 7 次触发且触发后自动暂停该 IP 任务 15 分钟由备用代理 IP 接管不影响整体进度。4. 数据采集避坑指南那些让你半夜收到告警的 5 个血泪现场4.1 现象ClickHouse 写入弹幕时频繁报DB::Exception: Memory limit (total) exceeded原因B 站单条视频弹幕可达 50 万 条按默认 batch_size1000 写入单次 INSERT 语句生成 500 个 block内存峰值超 2GB。解决在 ClickHouse client 初始化时强制设置settings{max_memory_usage: 500000000}500MB并改用insert_dataframe()分批写入每批 ≤ 5 万行from clickhouse_driver import Client import pandas as pd client Client( hostclickhouse-server, settings{max_memory_usage: 500000000} ) def bulk_insert_danmaku(df: pd.DataFrame, table: str): # 拆分为每 5 万行一批 for i in range(0, len(df), 50000): batch df.iloc[i:i50000] client.insert_dataframe(fINSERT INTO {table} VALUES, batch)4.2 现象MySQL 中「播放量」字段突然变成 0且无法恢复原因B 站 API 返回的stat.view字段是字符串如123.4万直接int()会报错但某些 ORM如 SQLAlchemy默认静默转为 0。解决统一用parse_play_count()函数清洗import re def parse_play_count(raw: str) - int: if not raw: return 0 # 匹配 123.4万、1234万、123.4亿 match re.search(r([\d.])([万亿]), raw) if match: num, unit float(match.group(1)), match.group(2) multiplier {万: 10000, 亿: 100000000} return int(num * multiplier[unit]) # 纯数字 return int(re.sub(r\D, , raw)) if re.search(r\d, raw) else 04.3 现象直播弹幕解析出乱码如\x00\x00\x00且数量远少于实际原因B 站直播弹幕协议使用 Protobuf 编码但官方未公开.proto文件社区逆向版本如bili-live-api与 B 站 2024 年 Q1 协议升级不兼容。解决放弃第三方库直接用 B 站开源的protobuf.jsWeb 版反向生成 Python 解码器从https://cdn.jsdelivr.net/npm/bilibili-live-apilatest/dist/protobuf.js下载 JS 版本用protoc工具从 JS 中提取.proto定义需手动还原生成 Python 类protoc --python_out. danmaku.proto用生成的danmaku_pb2.Danmaku解析二进制流。4.4 现象专栏文章爬取到第 199 页时返回空 JSON但状态码是 200原因B 站专栏 APIhttps://api.bilibili.com/x/article/archives?midxxxpn199ps30在页码超过实际总数时返回{code:0,message:0,data:{articles:[],count:0}}而非 404。解决增加「页码探测」逻辑当data.count 0且pn 1时向前回溯 5 页检查data.count是否突降为 0确认为末页后终止循环。4.5 现象Flower 监控界面显示任务 success但 MySQL 中无数据原因Celery 任务设置了acks_lateTrue但数据库连接池耗尽session.commit()抛出sqlalchemy.exc.TimeoutError而任务未捕获该异常Celery 默认视为成功。解决在任务函数最外层加try/except捕获所有数据库异常并显式raise Retryfrom celery.exceptions import Retry app.task(bindTrue, autoretry_for(SQLAlchemyError,), retry_kwargs{max_retries: 3, countdown: 60}) def save_video_data(self, video_data: dict): try: # ... 数据库存储逻辑 ... session.commit() except SQLAlchemyError as e: logger.error(fDB commit failed for BV {video_data[bvid]}: {e}) raise self.retry(exce)5. 数据分析落地用 Pandas Plotly 构建 UP 主健康度仪表盘拒绝「假大空」指标5.1 定义「健康度」三个可量化、可归因、可行动的核心指标别再堆砌「总播放量」「总粉丝数」这种滞后指标。我们聚焦 B 站生态真实运转逻辑定义内容效率比CER 近 30 天播放量 ÷ 近 30 天投稿数÷ 行业均值意义衡量单条内容的流量转化能力CER 1.2 说明内容质量优于同行粉丝留存率FRR 当前粉丝数 - 30 天前粉丝数÷ 30 天前粉丝数 × 100%意义反映内容对老粉的粘性FRR 0 说明内容正在流失核心用户互动健康度IHD 点赞数 收藏数 分享数÷ 播放量 × 100%意义B 站算法加权互动IHD 8% 是优质内容分水岭实测数据。计算逻辑用 Pandas 向量化实现避免 for 循环import pandas as pd import numpy as np # 假设 df_video 是近 30 天视频数据 DataFrame含 columns: [bvid,view,like,coin,share,pubdate] df_video[pubdate] pd.to_datetime(df_video[pubdate]) recent_df df_video[df_video[pubdate] pd.Timestamp.now() - pd.Timedelta(days30)] # 计算 CER按 UP 主分组 up_cer recent_df.groupby(mid).agg({ view: sum, bvid: count }).rename(columns{bvid: post_count}).reset_index() up_cer[cer] up_cer[view] / up_cer[post_count] # 行业均值取 TOP 100 UP 主的 CER 中位数 industry_cer up_cer.nlargest(100, view)[cer].median() up_cer[cer_ratio] up_cer[cer] / industry_cer # 计算 FRR需关联粉丝历史快照表 # 假设 df_fans_history 含 [mid,fans_count,snapshot_date] fans_now df_fans_history.groupby(mid)[fans_count].last() fans_30d_ago df_fans_history[ df_fans_history[snapshot_date] pd.Timestamp.now() - pd.Timedelta(days30) ].groupby(mid)[fans_count].first() frr_series (fans_now - fans_30d_ago) / fans_30d_ago # 合并结果 health_df up_cer.merge(frr_series.rename(frr), onmid, howleft) health_df[ihd] ( recent_df.groupby(mid)[[like,coin,share]].sum().sum(axis1) / recent_df.groupby(mid)[view].sum() * 100 ).round(2)5.2 用 Plotly 构建可交互仪表盘一行代码导出离线 HTML拒绝 Matplotlib 静态图。Plotly 支持 hover 查看明细、缩放、下载 PNG且导出 HTML 后双击即可本地打开import plotly.express as px import plotly.graph_objects as go from plotly.subplots import make_subplots # 创建双轴散点图XCER Ratio, YFRR, SizeIHD, ColorUP 主分区 fig px.scatter( health_df, xcer_ratio, yfrr, sizeihd, colortid, # tid 是分区 ID如 1动画, 3游戏... hover_data[mid, cer, frr, ihd], labels{ cer_ratio: 内容效率比vs 行业, frr: 粉丝留存率%, ihd: 互动健康度%, tid: 内容分区 }, titleUP 主健康度三维评估近30天, size_max60 ) # 添加参考线CER1.0行业均值、FRR0零增长线 fig.add_hline(y0, line_dashdash, line_colorred, annotation_text零增长线) fig.add_vline(x1.0, line_dashdash, line_colorblue, annotation_text行业均值) # 导出为离线 HTML fig.write_html(up_health_dashboard.html, include_plotlyjscdn)生成的 HTML 文件包含完整交互功能无需服务器产品经理用手机 Safari 打开就能拖拽查看——这才是数据分析该有的交付形态。5.3 一个真实技巧用「弹幕情感热力图」替代「弹幕词云」发现内容转折点词云只能告诉你「大家在聊什么」但热力图能告诉你「什么时候大家情绪爆发」。我们把弹幕按时间戳精确到秒分桶用 TextBlob 计算每条弹幕极性polarity ∈ [-1,1]再用 Plotly Heatmap 可视化from textblob import TextBlob import numpy as np def get_danmaku_polarity(danmaku_list: list) - np.ndarray: # danmaku_list: [{time: 123.45, text: 太棒了}, ...] polarities [] for d in danmaku_list: try: polarity TextBlob(d[text]).sentiment.polarity except: polarity 0 polarities.append((int(d[time]), polarity)) # 转为 10 秒为单位的热度矩阵 max_sec int(max(d[0] for d in polarities)) bins np.zeros(max_sec // 10 1) for sec, pol in polarities: bin_idx sec // 10 if bin_idx len(bins): bins[bin_idx] pol return bins.reshape(-1, 1) # 为 heatmap 准备 # 生成热力图 polarity_heat get_danmaku_polarity(danmaku_list) fig go.Figure(datago.Heatmap( zpolarity_heat, x[0-10s,10-20s,20-30s,...], # 时间区间标签 y[Polarity], colorscaleRdBu, zmin-5, zmax5 )) fig.update_layout(titlef《{video_title}》弹幕情感热力图正负值代表情绪倾向)这张图曾帮我们定位到一个 UP 主视频的「神转折时刻」前 8 分钟弹幕极性稳定在 0.2轻微正向第 480 秒8:00突然跃升至 0.8人工抽样发现是 UP 主揭晓了一个埋藏 7 分钟的彩蛋——这种洞察是词云永远给不了的。我坚持把每份爬虫日志存 90 天不是为了审计而是当业务方问「为什么上个月数据波动大」我能立刻查出那天凌晨 3 点 B 站 API 有 17 分钟的503 Service Unavailable而不是甩一句「网络问题」。工程的价值不在代码多炫酷而在故障时你能比别人早 3 分钟定位根因。希望帮到你。本文还有配套的精品资源点击获取
返回列表