ARTICLE DETAIL

资讯详情

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

B站视频接口批量抓取实战:视频列表与详情数据采集全指南

B站视频接口批量抓取实战:视频列表与详情数据采集全指南 做内容运营和数据分析的人迟早会遇到一个需求批量拉取某个B站账号的视频列表和详情数据。可能是想把自己账号的投稿导出成表格做季度复盘可能是想研究某个垂直领域头部UP主都在发什么选题也可能是想给内部工具加一个稳定的数据源。B站确实没有对外开放官方数据API网上流传的接口资料又多停留在2022年之前照着抄经常一头撞在-403或者-352上。这篇文章把我这两年前前后后调B站接口的经验完整梳理了一遍重点围绕视频详情和视频列表这两类数据覆盖接口选型、WBI签名、分页逻辑、字段清洗和频控排查目标是让不同基础的读者照着操作半小时内能跑通第一版。1. 三个核心接口视频详情、热门列表、用户投稿B站的接口体系非常庞杂弹幕、评论、直播、动态各有一套端点但跟视频列表和详情数据强相关的其实只有三个。先把这三个接口的能力边界搞清楚后面写代码就不会东一榔头西一棒子。1.1 视频详情接口单条视频的所有元数据单条视频最标准的数据来源是https://api.bilibili.com/x/web-interface/view传一个bvid参数就能拿到这条视频的完整元数据。我实测下来这个接口比较稳定虽然新版也加了WBI签名校验但大多数情况下直接请求也能通。返回数据里值得关注的字段大致分几组视频基础信息title、desc、pic、duration、pubdate、tname分区名、videos分P数量UP主信息owner.mid、owner.name、owner.face互动统计数据stat.view播放量、stat.danmaku弹幕数、stat.reply评论数、stat.favorite收藏数、stat.coin、stat.share、stat.like分P信息pages数组里面每一P的cid、part、duration后续取播放地址和弹幕要用到cid一个容易忽略的点是duration。详情接口返回的duration单位是秒纯数字而下面要讲的投稿列表接口里length字段是一个mm:ss格式的字符串。两个接口混用的时候最容易被这个字段坑到后面清洗数据时我会专门展开。另外view接口的bvid和aid只能传一个两个都传或者都不传大概率返回-400。如果需要从BV号反查AV号直接调一次这个接口从aid字段拿就行不用自己折腾社区里流传的算法。1.2 热门与排行榜接口不带签名就能直接跑的列表如果只是想先跑通流程、看明白B站返回的数据结构用https://api.bilibili.com/x/web-interface/popular最省事。这个接口返回当前热门视频列表支持ps每页条数和pn页码两个参数不需要登录Cookie也不需要WBI签名直接GET就能拿到干净的JSON。实际返回结构是data.list数组data.count是热门视频总量。list里的每条视频字段和详情接口基本一致省去了二次请求的麻烦特别适合用来做第一轮测试——我先不碰用户投稿这种重接口先用热门列表把整个请求流程跑通再逐步加签名、加Cookie。同理排行榜接口https://api.bilibili.com/x/web-interface/ranking/v2也可以直接访问支持rid分区id和type排序维度拿到的也是格式化好的列表数据。如果业务场景只需要某个分区当前的头部内容这两个接口基本够用完全不需要动签名。1.3 用户投稿接口真正的“全部视频列表”来源热门接口能跑通但解决不了某个UP主全部视频这种核心需求。真正干这个活的是https://api.bilibili.com/x/space/wbi/arc/search这是B站个人空间页面的底层数据接口。这个接口在2022年前后经历了比较大的调整路径从x/space/arc/search改成了带wbi标识的x/space/wbi/arc/search并对参数做了严格的WBI签名校验。不带签名直接请求接口会冷冷地甩给你一个-403。参数方面需要传mid用户UID、ps每页条数、pn页码。返回的data.list.vlist里是当前页的视频列表每条包含bvid、title、pic、play、comment、created、length、author等字段。data.page.count是该用户公开投稿总数配合分页可以遍历完整个列表。这里要先打个预防针这个接口返回的是用户公开发布的视频稿件不包含动态里发的短视频也不包含充电专属内容。早期资料里还有人说可以通过page_count之类字段推导总数实际新版接口路径和字段都变了网上旧教程抄了多半会踩坑。2. WBI签名B站列表接口的核心门禁用户投稿接口能不能跑通全看WBI签名对不对。这是整个B站API调用里最劝退新手的一环但其实拆开看逻辑并不复杂。2.1 WBI签名到底在防什么B站的大部分Web接口以前是裸奔的只要知道URL和参数就能随便调。后来服务端发现太多爬虫脚本在批量抓取才逐步给核心接口加了签名校验。WBI签名的思路是客户端发起请求前先用一组动态密钥对参数做一次MD5计算生成一个w_rid字段服务端用同一套逻辑验签验不过就拒绝。这组密钥不是写死在网页里的而是藏在https://api.bilibili.com/x/web-interface/nav这个导航接口的返回数据中。也就是说要签名先得去拿密钥拿到密钥才能签名签名之后才能请求真正的业务接口。整个过程有点像一个双向验证的小循环。2.2 签名的完整流程与实现代码签名的标准流程分四步请求nav接口从返回的data.wbi_img.img_url和data.wbi_img.sub_url两个图片URL中取出文件名分别记为img_key和sub_key。将两个key拼接后用一张64位的重排表做字符重排得到真正的mixin_key。把所有业务参数按字典序排序加入当前Unix时间戳wts拼接成查询字符串。用mixin_key对查询字符串做MD5得到w_rid连同wts一起加入请求参数。用Python实现大概是这个效果import time import hashlib import requests from urllib.parse import urlencode MIXIN_KEY_ENC_TAB [ 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52 ] def get_mixin_key(orig: str) - str: return .join([orig[i] for i in MIXIN_KEY_ENC_TAB]) def wbi_sign(params: dict, img_key: str, sub_key: str) - dict: mixin_key get_mixin_key(img_key sub_key) params[wts] int(time.time()) params dict(sorted(params.items())) params { k: .join(ch for ch in str(v) if ch not in !()*) for k, v in params.items() } query urlencode(params) params[w_rid] hashlib.md5((query mixin_key).encode()).hexdigest() return params拿到img_key和sub_key的代码也很直接def fetch_wbi_keys(session): url https://api.bilibili.com/x/web-interface/nav data session.get(url, timeout10).json() img_url data[data][wbi_img][img_url] sub_url data[data][wbi_img][sub_url] img_key img_url.rsplit(/, 1)[-1].split(.)[0] sub_key sub_url.rsplit(/, 1)[-1].split(.)[0] return img_key, sub_key2.3 几个签名环节容易搞错的细节签名流程看着简单实操中有三个细节最容易踩坑。第一MIXIN_KEY_ENC_TAB这张重排表不是随便猜的。B站对密钥字符串做了固定的字符重排社区逆向出来的表就是上面那64个数字覆盖的是0-9、大小写字母和少量符号的排列顺序。这个表在很长一段时间内是稳定的但保不齐哪天B站调整算法就会变代码里最好单独做成配置方便出问题时快速替换。第二参数过滤不能省。签名前要把参数值里的!、、(、)、*这几个特殊字符过滤掉如果不做这一步自己算出来的w_rid和服务器端的不一致就会永远卡在-403。这个问题隐蔽到很多人调了一整天也没发现最后一行一行对字符才找出原因。第三img_key和sub_key是有时效性的不是拿一次就能永久使用。我实测下来密钥大概每天会轮换一次长时间跑批任务时要定期重新请求nav接口刷新否则凌晨还在跑的任务突然雪崩式报-403。最简单的策略是每5到10小时刷新一次或者检测到-403时主动刷新重试。3. 从零写一个“用户全部视频列表”抓取器签名搞定了剩下的就是写一个能实际干活的抓取器。这一节直接给完整方案从分页到清洗再到可运行的代码照抄就能用。3.1 接口参数与分页逻辑用户投稿接口的基本参数是mid、ps、pn三个。其中ps我建议固定用30实测超过这个值容易被截断或直接拒绝。分页逻辑很简单先请求第一页拿到data.page.count计算总页数然后依次请求后续页面。这里要强调一个反直觉的细节翻页过深之后B站偶尔会返回重复数据或者空列表不要盲目相信页码越往后数据越多。我的处理方式是做一个去重集合以bvid为唯一标识遇到重复的直接跳过直到拿满全部count条记录为止。3.2 详情数据字段解析与清洗从接口拿到的原始字段不能直接入库有几个清洗规则是必须做的。时间字段。view接口的pubdate和投稿列表的created都是Unix秒级时间戳转成可读格式要用datetime.fromtimestamp。常见错误是拿created当毫秒处理一转换直接变成1970年。时长字段。前面提过投稿列表的时间长度length是mm:ss字符串而详情接口的duration是秒。建议统一转成秒数方便后续做时长聚合分析。数字字段。play、comment这些统计字段理论上返回纯数字但我见过个别视频返回空字符串或None入库前统一做int()转换转换失败就填0避免中途报错中断整批任务。封面防盗链。pic字段拿到的封面URL在浏览器里直接打开可能403因为B站做了防盗链校验。如果要保存封面或者在其他平台展示请求时带上Referer: https://www.bilibili.com/就能解决。3.3 可直接跑的完整示例代码下面是一个精简但完整的抓取器集合了签名、分页和字段清洗核心逻辑都在复制到一个.py文件里就能跑。import time import hashlib import requests from datetime import datetime from urllib.parse import urlencode MIXIN_KEY_ENC_TAB [ 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52 ] class BiliVideoCrawler: def __init__(self, cookie): self.session requests.Session() self.session.headers.update({ 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, Referer: https://www.bilibili.com/, }) if cookie: self.session.headers[Cookie] cookie def _get_wbi_keys(self): url https://api.bilibili.com/x/web-interface/nav data self.session.get(url, timeout10).json() img_url data[data][wbi_img][img_url] sub_url data[data][wbi_img][sub_url] img_key img_url.rsplit(/, 1)[-1].split(.)[0] sub_key sub_url.rsplit(/, 1)[-1].split(.)[0] return img_key, sub_key def _sign(self, params): img_key, sub_key self._get_wbi_keys() mixin_key .join([(img_key sub_key)[i] for i in MIXIN_KEY_ENC_TAB]) params[wts] int(time.time()) params dict(sorted(params.items())) params { k: .join(ch for ch in str(v) if ch not in !()*) for k, v in params.items() } query urlencode(params) params[w_rid] hashlib.md5((query mixin_key).encode()).hexdigest() return params def get_user_videos(self, mid, page1, page_size30): params self._sign({mid: mid, ps: page_size, pn: page}) resp self.session.get( https://api.bilibili.com/x/space/wbi/arc/search, paramsparams, timeout10 ).json() return resp[data] def get_all_videos(self, mid, max_pages100): videos [] seen set() first self.get_user_videos(mid, 1) count first[page][count] total_pages min((count // 30) 1, max_pages) print(f[info] total videos: {count}, pages: {total_pages}) for pn in range(1, total_pages 1): data self.get_user_videos(mid, pn) for item in data[list][vlist]: bvid item[bvid] if bvid in seen: continue seen.add(bvid) videos.append({ bvid: bvid, title: item[title], play: int(item[play] or 0), comment: int(item[comment] or 0), created: datetime.fromtimestamp(item[created]).strftime(%Y-%m-%d %H:%M:%S), duration: item[length], }) time.sleep(1) return videos if __name__ __main__: crawler BiliVideoCrawler() result crawler.get_all_videos(你的目标UID) for v in result: print(v[bvid], v[title], v[play], v[created])这段代码里我故意做了一个简单处理每次签名都重新拿一次密钥虽然效率低但不会因为密钥过期翻车适合刚开始跑通流程的阶段。后续要批量大规模抓取可以把密钥缓存到内存或本地定时刷新即可。3.4 拿这些数据能做的合规应用接口跑通只是第一步这些数据到底能用在哪得想清楚边界。最常见的用途是内容运营复盘。把自己账号的投稿列表拉下来按月份聚合播放增量、点赞数、评论数看看哪类选题的数据表现最好这是完全正当的自我数据分析场景。其次是竞品研究。分析同赛道头部账号的发布频率、选题方向、时长分布和互动表现能有效帮助制定选题规划。B站本身没有提供这么细的创作者工具用公开数据做二次透视是很普遍的做法。第三种是建立个人档案库。定时把目标账号的公开稿件快照保存下来用于学术研究、内容趋势分析等场景。需要提醒的是采集公开数据时要遵守平台规则、尊重用户隐私不要让数据用于骚扰、诽谤、人肉搜索等不当用途。我自己的习惯是把采集频率控制在不影响平台正常服务的范围内数据也只做聚合分析不做个人层面的画像散布。4. 跑批实测频控、错误码与稳定性优化接口逻辑能跑通真正决定能不能长期稳定运行的是频控策略和异常处理。这一节把我实测踩过的坑和最终总结下来的套路直接写出来。4.1 请求频率控制策略B站对接口频率的感知比想象中敏感得多。尤其是用户投稿列表这类带WBI签名的接口短时间高频请求很容易触发风控。我的经验值是这样单次调试、拿几十条数据无脑请求问题不大批量遍历几百页时每页之间至少延时1到2秒多账号轮询时每个账号独立session不要共用IP和Cookie尽量避开晚上8点到11点的流量高峰时段跑大批任务延时不能太规律。写死sleep(1.5)反而容易形成固定的请求节律被识别为脚本。我的做法是在1到3秒之间取随机值模拟真实用户翻页的节奏。import random time.sleep(random.uniform(1, 3))如果确实有大规模需求只有一两组账号和IP是不够的。更靠谱的路子是降低单账号请求密度把任务拆碎后分散到更多的时间和出口上。4.2 常见错误码与排查方法跑B站接口一定会遇到各种负数错误码有些是必现的有些是玄学。我把最常见的几种整理成了对照表方便快速定位。错误码常见含义排查建议-400参数错误检查bvid、mid格式确认必填参数是否缺失-403拒绝访问WBI签名错误或账号无权限刷新密钥检查登录态-404视频不存在稿件被删除/转为私密/审核未通过直接跳过-412请求被拦截频率过高或UA异常降低频率检查请求头-352风控校验失败需要携带登录Cookie浏览器登录后复制SESSDATA-799稿件不可见删除、锁稿或审核中建议记录下来后续复核-352是最容易让人蒙圈的错误。它和签名错误不一样签名错了通常是-403而-352更多是风控体系判定当前环境或账号存在风险。解决办法最直接的就是给请求头加上Cookie其中SESSDATA是登录态的核心字段。获取方式很简单浏览器登录B站后打开开发者工具在任意请求的Cookie里找到SESSDATAxxxx把这段值传进爬虫即可。不要拿别人的Cookie只用自己的账号做合法数据访问。4.3 封装成服务前的最后三个建议如果只是跑一两次脚本写到上面那步已经够了。但如果想把列表和详情数据的抓取能力封装成长期使用的服务还有三个建议值得提前考虑。第一把密钥刷新做成独立模块。WBI密钥的获取是有网络开销的每次都现取会让启动变慢也增加了被风控的节点。更好的做法是启动时加载一次之后定时每4到6小时强制刷新刷新失败时保留旧密钥并告警。第二做好返回内容的双重校验。B站接口返回的JSON即使code为0data字段也可能在某些极端情况下缺失。代码里不要只检查code 0还要对data[list][vlist]这类关键路径做None判断否则一个大号KeyError直接击穿整个抓取任务。第三将详情补全和列表抓取解耦。列表接口已经包含了标题、播放数、评论数等大部分字段只有需要深度分析比如完整描述、分P信息、全部互动数据时才对每条视频调用一次view接口。列表过了全量之后用一个单独的任务队列去消费详情数据可以避免密集调用拖垮整个采集系统。我自己实际跑的时候还有一个习惯每次抓完都把原始JSON落一份到本地不直接覆盖。B站的字段结构不是一成不变的保留原始快照未来接口变了还能拿历史数据重新解析不用从头再抓一遍。批量调B站接口这件事门槛不算高但细节确实多。最深的感受是B站的接口永远在动态调整今天能跑通的代码不代表三个月后还能跑通。与其追求一套一劳永逸的方案不如把签名逻辑、错误码映射、数据清洗这些环节都做成可配置、可替换的模块平台一变改一处配置就能继续用。最后一个实用小技巧如果你只是为了分析自己的账号数据先用热门列表接口跑通流程再切到用户投稿接口加签名分步调试能省掉大量排查时间。
返回列表