最近有个朋友跟我抱怨,说自己B站关注了上千个UP主,想趁过年整理一份名单,看看哪些是常年不更新的“僵尸号”,顺便把粉丝变化做个记录。结果他一个个点进去看,弄了两小时才翻了几十页,眼睛都快瞎了。我听完直接告诉他:这活儿根本不用手工干,B站网页端的数据全靠接口返回,你只要用浏览器自带的开发者工具,配合一段JavaScript脚本,就能把这些关注或粉丝的详细信息批量抓下来,整理成表格。
这篇文章就是把整套流程摊开讲。我会先带你在开发者工具里找到B站关注/粉丝列表背后真正在请求的接口,然后逐字段拆解返回的数据结构,再给出一段可以直接复制到控制台运行的JavaScript脚本,最后把导出CSV、处理Cookie失效、应对风控限流这些坑都过一遍。整个过程不需要装任何第三方工具,只用一个Chrome或Edge浏览器。无论你是想备份自己的关注列表,还是想分析账号数据变化,都能直接照着操作。注意,这里说的一直都是你自己的账号数据,别拿这套方法去处理别人的信息,下面我也会专门聊这个边界。
1. 整体思路:从“看网页”到“问接口”,再让浏览器帮你跑循环
1.1 为什么一定要先看开发者工具里的请求
很多人一开始会想,我是不是得写个爬虫脚本,专门去解析网页HTML?其实没必要。现在主流网站的页面几乎都是前后端分离的:你看到的列表、头像、签名,本质上都是JavaScript向服务器发请求拿回JSON数据,再渲染成网页。B站的关注页也是这样。当你打开关注列表往下滚动时,浏览器其实在背后偷偷请求一个接口,服务端返回一坨结构化数据,页面再用它画出一张张卡片。
我们要做的,就是把这层“渲染”的外壳扒掉,直接找到背后那个数据接口。浏览器开发者工具里的Network面板就是干这个用的。你按F12打开它,刷新页面,所有发出去的请求都会出现在列表里。我们要找的,是那些返回JSON的XHR请求,而不是图片、CSS或者JS文件。这也是我说“用开发者工具配合JavaScript”而不是“用Python自己模拟请求”的根本原因:你不需要猜测接口地址、不需要手动构造复杂的请求头,浏览器已经把一切准备好了。
1.2 为什么用JavaScript,而不是搭一套Python环境
提到批量抓数据,很多人第一反应是Python写爬虫。那套方案确实功能强大,但门槛也高:你得装Python、装requests库、处理Cookie、处理登录态、还要考虑请求头伪装。对于只是想导出自己一份关注列表的普通用户来说,这明显属于“杀鸡用牛刀”。
JavaScript脚本则是另一套逻辑。它直接运行在浏览器控制台里,天然拥有当前页面的登录Cookie。只要你在B站页面里已经登录了账号,在Console里发同源请求,浏览器会自动把Cookie带上,服务端一看就知道是你本人,不需要你手动复制任何凭证。而且脚本写完直接粘贴回车就能看到结果,连文件都不用建。对于临时性的个人数据备份,这种“用完即走”的方式体验非常好。我之前帮另一位朋友做粉丝数据分析时,从打开控制台到跑完全部数据,前后不到十分钟。
1.3 这套方案能做什么,不能做什么
这套方案非常契合“个人数据备份”和“数据分析”这两类场景。你可以用它:
- 导出自己关注的所有UP主的基本信息(UID、昵称、签名、粉丝数等),整理成表格慢慢看;
- 定期跑一次,对比哪个UP主取关了你,或者你新增关注了谁;
- 拉取自己的粉丝列表,统计粉丝增长趋势,分析粉丝的头像、签名等公开信息。
但它也有明确的边界。B站关注和粉丝接口通常只能拿到“自己的”数据,别人的关注列表如果对方设置了隐私,你也是拿不到的。更重要的是,这套方案只适合低频、小量、合规的个人用途。如果你拿它去高频请求、批量抓取、甚至用来做骚扰或营销,且不说平台风控会让你直接吃闭门羹,这种行为本身也不值得提倡。后文我会再展开讲合规的细节。
2. 开发者工具抓包:亲手找到那个藏在页面背后的接口
2.1 打开Network面板,定位关注列表请求
我用Chrome演示,Edge的操作完全一样。你先在浏览器里打开B站首页并确认已经登录,然后按F12打开开发者工具,切到Network(网络)标签页。这里有个容易踩的坑:如果直接停在当前页面不刷新,Network里往往是一片空白,因为页面加载期间发过的请求早就过去了。正确做法是,打开Network面板后,去勾选Preserve log(保留日志),再按F5刷新页面,或者直接在页面上点击“关注”入口重新进入关注列表页,让新的请求在你眼皮底下发生。
接着在Network的筛选框里输入“relation”,或者点一下“Fetch/XHR”按钮,只看接口请求。这时候你会看到不少以api.bilibili.com开头的请求,其中跟关注列表直接相关的是一个叫followings的接口,完整路径一般长这样:
https://api.bilibili.com/x/relation/followings?vmid=你的UID&pn=1&ps=50&order=desc&order_type=attention_time
点击这条请求,右侧会弹出详情面板。你切到Preview(预览)标签,会看到返回的JSON数据,里面的data.list数组就是当前页的关注用户列表,data.total则是关注总数。这个发现非常关键,因为total决定了你需要翻多少页。
2.2 逐个拆解返回字段:list里到底有什么
点开Preview里的数据,你会看到每个关注用户都是一个对象,字段大概长这样:
{ "mid": 123456789, "uname": "某UP主", "face": "http://i0.hdslb.com/bfs/face/xxx.jpg", "sign": "这个人很懒,什么都没有写", "fans": 12345, "attention": 67, "official_verify": { "type": -1, "desc": "" } }这里每个字段的含义我简单说一下。mid是用户唯一标识ID,以后想跳转他的主页,拼上https://space.bilibili.com/123456789就可以了;uname是当前昵称;face是头像地址;sign是个人签名;fans是他的粉丝数;attention是他关注的人数;official_verify是认证信息,type为0表示有官方认证,-1表示没有。这些字段足够你做一般的筛选和排序了。注意,不同接口版本字段可能会多一点或少一点,但核心这几个基本都有。
2.3 分页参数与total的关系:先算清楚要跑几页
接口返回的data.total是关注总数,但data.list只是当前页的数据。接口通过两个参数控制翻页:pn表示页码,从1开始;ps表示每页数量,B站这里一般最大能设置到50。所以如果你关注了1000个人,用ps=50来跑,理论上就需要跑20页。脚本里的逻辑就是不断把pn加1,直到累计抓到的条数等于total为止。
我建议写脚本之前,先在Network里手动点一遍这个接口,把pn改成2、ps改成50,看看返回的数据是否正常。这样能确认两件事:第一,你自己的账号是否有权限访问完整列表;第二,返回结构是否稳定。确认无误后再交给脚本去跑,能少踩很多坑。我一开始就是没做这个测试,直接写了个大循环,结果跑到第3页就报错了,回头排查才发现是参数名写错了一个。
3. 核心实现:一段JavaScript脚本搞定关注与粉丝数据
3.1 不需要手动复制Cookie:同源请求自动带登录凭证
在正式写循环抓取脚本之前,你先在控制台做一个最简单的连通性测试。打开B站任意页面,按F12切到Console(控制台),直接输入下面这行代码,然后回车:
const res = await fetch('https://api.bilibili.com/x/relation/followings?pn=1&ps=2&order=desc&order_type=attention_time'); const json = await res.json(); console.log(json);因为这条请求跟当前页面同源,浏览器会自动带上B站的Cookie,所以只要你是登录状态,这里就会返回code为0,并且data.list里有两条关注用户数据。如果返回code是-101,说明你当前没登录;如果返回其他错误码,下文第四部分我会专门讲。
我个人非常推荐用这种“直接在页面上下文里发请求”的方式,而不是手动去复制Cookie到脚本里。Cookie是会过期的,而且复制粘贴过程中特别容易带上多余的引号或换行符。更重要的是,你一旦手动构造请求,就要自己去维护一堆请求头,稍不注意就会被风控拦下来。让浏览器替你处理这些,等于所有脏活累活都甩给了浏览器内核,你只管往里面填写参数就行。
3.2 关注列表批量抓取:分页循环加延时
连通性测试通过了,接下来就是真正的分页抓取脚本。下面这段脚本会从第1页开始,按每页50条抓取你的关注列表,并自动拼接所有页的数据。脚本里加了一个500毫秒的延时,目的是给接口一个缓冲,避免请求发送太快触发风控。
// 抓取我的关注列表 const myUid = 你的UID; // 替换成你自己的UID,在个人空间地址栏能看到 const pageSize = 50; // 每页条数,B站这里通常最大支持50 let allList = []; let pn = 1; let total = null; while (true) { const url = `https://api.bilibili.com/x/relation/followings?vmid=${myUid}&pn=${pn}&ps=${pageSize}&order=desc&order_type=attention_time`; const res = await fetch(url); const json = await res.json(); if (json.code !== 0) { console.error('请求出错,错误码:', json.code, ',信息:', json.message); break; } if (total === null) { total = json.data.total; console.log('关注总数:', total); } const list = json.data.list; if (!list || list.length === 0) { console.log('没有更多数据了,停止翻页'); break; } allList = allList.concat(list); console.log(`第 ${pn} 页完成,累计 ${allList.length} 条`); pn++; if (allList.length >= total) { console.log('已经抓完全部数据'); break; } // 延时500毫秒再继续,避免请求太快 await new Promise(resolve => setTimeout(resolve, 500)); } console.log('最终条数:', allList.length);脚本核心逻辑其实就是一个while循环:每次请求当前页码的数据,判断返回码,把list拼进allList数组,然后页码加1,直到累计数量大于等于total或者返回空列表为止。这里有个细节,我用了“所有数据存在allList里,抓完再统一处理”的策略,而不是每抓一页就导出一次。原因是这样更高效,也方便你最后一次性导出成一个完整的CSV文件。
你可以直接在Console里输入这段代码。注意把“你的UID”替换掉,你的UID在自己个人空间地址栏里能看到,是一串纯数字。如果不知道在哪找,我教个土办法:在B站点开自己的头像进入个人空间,看浏览器地址栏,space.bilibili.com/后面的那串数字就是。
3.3 粉丝列表抓取:换个接口路径就行
粉丝列表的接口路径跟关注列表很像,区别在于关注是followings,粉丝是followers。路径格式如下:
https://api.bilibili.com/x/relation/followers?vmid=你的UID&pn=1&ps=50&order=desc
抓取脚本跟上面几乎一模一样,只是把接口地址换一下。这里我直接给出完整版,方便你复制:
// 抓取我的粉丝列表 const myUid = 你的UID; const pageSize = 50; let allList = []; let pn = 1; let total = null; while (true) { const url = `https://api.bilibili.com/x/relation/followers?vmid=${myUid}&pn=${pn}&ps=${pageSize}&order=desc`; const res = await fetch(url); const json = await res.json(); if (json.code !== 0) { console.error('请求出错,错误码:', json.code, ',信息:', json.message); break; } if (total === null) { total = json.data.total; console.log('粉丝总数:', total); } const list = json.data.list; if (!list || list.length === 0) { console.log('没有更多数据了,停止翻页'); break; } allList = allList.concat(list); console.log(`第 ${pn} 页完成,累计 ${allList.length} 条`); pn++; if (allList.length >= total) { console.log('已经抓完全部数据'); break; } await new Promise(resolve => setTimeout(resolve, 500)); } console.log('最终条数:', allList.length);如果你只想获取粉丝的UID和昵称,不想管签名头像这些,后面导出的时候只挑这两个字段就行。我给大家的建议是,抓的时候尽量把所有字段都拿到,毕竟数据在内存里不占地方,但以后如果想换个角度分析,你就不用重新跑一遍脚本了。
3.4 把数据导出成CSV:解决Excel打开中文乱码的问题
数据抓到了,但存在浏览器的内存变量里,关掉控制台就没了。所以下一步是把它落盘。最省事的方式是把数据转成CSV文件并下载到本地,再用Excel或者WPS打开。下面这段函数可以把allList数组转成带表头的CSV,并自动触发浏览器下载:
function exportCSV(data, filename) { if (!data || data.length === 0) { console.error('没有数据可以导出'); return; } // 只挑我关心的几个字段,顺序就是表头顺序 const keys = ['mid', 'uname', 'sign', 'fans']; const header = keys.join(','); const rows = data.map(item => { return keys.map(key => { const value = item[key] !== undefined && item[key] !== null ? item[key] : ''; // 用JSON.stringify自动处理引号和逗号转义 return JSON.stringify(String(value)); }).join(','); }); // 加BOM,防止Excel打开UTF-8 CSV时中文乱码 const csv = '\uFEFF' + header + '\n' + rows.join('\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename || 'bilibili_data.csv'; a.click(); URL.revokeObjectURL(a.href); }你可以在抓完数据后,直接执行:
exportCSV(allList, '我的关注列表.csv');这行代码会把你刚才抓的allList数组导出成一个名为“我的关注列表.csv”的文件。有几个细节要注意:第一,表头我用的是数组顺序,keys数组里写哪个字段,表头就是哪个字段,顺序可以自己调;第二,JSON.stringify在处理字符串时,会自动把特殊字符转义,所以昵称里如果有逗号或引号,也不会把CSV结构搞乱;第三,文件开头加了一个\uFEFF字符,也就是BOM,这是为了解决Excel打开UTF-8编码CSV文件时中文变成乱码的老问题,不加的话你很可能导出来的文件中文全是乱码,加了之后正常情况下就没事了。
如果你不想用CSV,也可以直接在控制台执行JSON.stringify(allList, null, 2),把结果复制出来存成.json文件。这个格式适合以后写程序再处理。对于只是想看表格的人来说,CSV更直观。
4. 常见问题排查与避坑心得
4.1 返回code为-101:登录态失效
如果你跑脚本的时候发现json.code是-101,说明当前接口把你当成未登录用户了。最常见的原因是B站登录Cookie过期,或者你在隐私无痕窗口里没有登录。解决办法很简单:去B站首页确认自己是不是登录状态,如果没登录,重新登录一次,再回到控制台重新跑脚本。还有一种情况是,你人在A页面,但脚本里请求的是B域名的接口,虽然都是bilibili.com域名,但如果在跨子域场景下遇到问题,最稳妥的办法还是回到对应页面再执行。
4.2 返回code为-412:请求太频繁被风控盯上了
-412这个错误码,做过抓取的人应该都不陌生。它表示平台风控系统判断当前请求频率异常,主动拦截了你的请求。如果你遇到这个错误,先冷静,别把锅甩给脚本。原因大概率是你在短时间内请求次数太多,或者请求间隔太短。
我的建议是,把脚本里的延时从500毫秒改成1000到1500毫秒再跑。数据量几千条也就多等一两分钟,完全能接受。另外我强烈建议第一次跑的时候,先只跑3到5页看看返回是否正常,确认没问题了再跑全量。还有一个关键是,如果某个页面触发了-412,不要立刻反复重试同一页,等十几秒再试一次。要是已经被限制,再急也没用,过段时间再跑就行。从我个人经验来看,只要延时合理,关注列表这种低频接口一般不会触发风控,真正危险的是那种一秒发好几条请求的脚本。
4.3 翻页翻了半天,list突然空了
如果你的脚本在某一页开始,json.code还是0,但data.list已经变成空数组,并且循环没有退出,这通常意味着两种可能。第一,你已经翻到了最后一页,但前面的总数判断没写对。比如total字段在你这个接口里可能没有返回,或者变量读取的位置不对,导致allList.length永远到不了total,脚本就一直翻下去。这种情况我在初版脚本里就踩过,所以正式版我把判断逻辑改成了“list为空就break”,而不是只依赖total。
第二,当前账号的数据本身有隐私限制。如果你的粉丝列表设置为不公开,接口可能只返回部分数据,或者干脆一直返回空list。这时候你需要去B站的隐私设置里检查一下相关选项,确认账号的对外可见范围。关注列表一般不受这个限制,但粉丝列表因为涉及反骚扰,平台在隐私上会严一点。
4.4 字段结构跟你抓到的对不上怎么办
B站的接口迭代不是特别频繁,但偶尔也会调整。比如早些年关注的返回字段里有vmid,后来改成了mid;又比如新版接口可能增加一些类似tag、attribute这样的字段。如果你的控制台输出跟我在上面写的字段对不上,别慌,先回到Network面板,点开那个接口的Preview,看实际返回的JSON长什么样,再对照着修改脚本里的字段名就行。这也是我为什么反复强调“先抓包再写脚本”的原因,纸上谈兵永远不如亲眼看一眼实际返回。
还有一个相关的点,B站部分接口正在逐步引入签名校验机制。如果你复制的请求因为缺签名而返回412或者403,最直接的解决办法不是去逆向签名算法,而是回到Network面板,在任意一条成功的请求上右键,选择Copy as fetch,然后把这段fetch代码粘贴到控制台执行。因为这份代码会带齐页面环境里的所有请求头和参数,等于是原封不动地重放一次成功请求。然后你基于这段代码去改分页参数,比手动构造请求靠谱得多。
4.5 合规与隐私:只碰自己的数据,别滥用
这个部分我不想说得太教条,但有些边界真的得讲清楚。本文这套方法,前提是处理你自己的关注列表、粉丝列表。你导出来的数据里包含其他用户的部分公开信息(昵称、头像、签名这些),这些信息虽然本身是公开的,但并不意味着你可以拿去做骚扰、群发、营销或者任何让他人困扰的事情。我见过有人拿粉丝列表去批量私信推广,这种行为既破坏社区氛围,也容易让自己的账号陷入风险。
从平台规则的角度讲,低频导出自己账号的数据,大多数情况下不会有什么问题;但高频、大规模地刷接口,不管是不是自己的数据,都可能触发风控甚至封号。所以我一直说:放慢速度、控制频次、以备份或分析为目的,用完即止。这个原则不是因为我胆小,而是真的很管用,它既能让你完成任务,又能最大程度避免账号受损。
写在最后的一点体会
这套流程我前前后后帮人跑过不少次,最大的感受是:真正难的不是写循环脚本,而是你愿不愿意静下心来先花十分钟去看接口返回的数据结构。很多人一上来就急着写代码,结果参数写错、字段名对不上、乱码三连,最后又跑回来问东问西。
我个人的习惯是,第一次跑的时候先设一个很小的页数,比如ps=50、只跑一页,打印出来看一眼数据结构,确认无误再放开全量跑。另外,脚本跑完以后,我会顺手用JSON.stringify导出一份原始JSON存底,再另存一份CSV用于表格查看。这样就算以后格式变更,原始数据还在,不至于变成“一次性备份”。
如果你要定期监控粉丝数变化,还可以把这个脚本包装成一个手动书签或者控制台片段,每次需要数据的时候运行一次,把CSV按日期命名存到本地。配合Excel的简单透视表,就能做出一张非常直观的粉丝增长曲线。这个过程没有多高技术含量,但确实解决了我“想分析但不想手工翻页”的痛点。希望这篇文章也能帮你省下那两小时的重复劳动。