我最早意识到“关注数、粉丝数”这种基础数据值得专门写个脚本去拿是因为一次账号排查的需求手头有一批B站用户UID需要快速知道每个账号的粉丝体量、关注量是否匹配判断哪些像普通用户、哪些像内容创作者、哪些有明显异常的增长痕迹。人工打开网页一个个看几十个还能忍上百个就直接崩溃了。所以就有了这个非常具体、也非常实用的小任务用Python批量获取B站用户的关注数和粉丝数。这篇博文没有任何绕弯子的地方就是把我自己从接口定位、请求封装、批量采集到数据落地的完整过程拆开讲清楚。适合刚接触接口调用、想用脚本解决重复查询问题的Python新手也适合做竞品分析、KOL筛选、账号运营的朋友直接拿走代码改改用。全程只涉及B站的公开数据频率控制在合理范围不碰任何私密信息。1. 先从需求说起为什么盯上关注数和粉丝数这两个指标1.1 一次真实的账号排查需求我接到这个活儿的时候对方给了一张Excel表里面是一百多个B站UID要求很简单统计每个账号的关注数、粉丝数按粉丝量从高到低排序。这些账号是从某个话题的评论区、弹幕区、关注列表里捞出来的初步判断有一部分是真人创作者有一部分像营销号或者批量注册的“僵尸号”。判断逻辑其实就藏在关注数和粉丝数的组合关系里粉丝数远大于关注数通常是有内容产出的UP主关注数远大于粉丝数大概率是普通观众号两者都低且均匀可能是新号或者不活跃号粉丝数突然异常高但内容很少就有刷量嫌疑。所以这两个数字看似简单组合在一起就是一套初筛逻辑。问题在于一百多个账号手工查每个至少点两三次页面耗时太长而且容易看错行、记混账号。1.2 手工操作的低效是怎么逼我写脚本的手工方案大概是这样的打开B站首页搜索用户点进主页找到“关注”“粉丝”两个数字抄下来。遇到重名的还要去核对UID又是一轮折腾。实测下来处理十来个账号就有点烦躁了因为B站网页版每个主页都要加载头像、动态、视频列表这些无关内容真正有用的只有两个数字。我当时想的是能不能直接通过某个接口拿到一段JSON里面就包含关注数和粉丝数然后循环一把梭批量搞定。答案是可以的而且B站的接口非常直白比很多网站的接口都好调。这也是这篇文章想讲的第一个重点很多时候你需要的不是页面本身而是页面背后的数据接口。1.3 这个需求接口化之后能扩展出什么从“获取某个用户关注数和粉丝数”这个最小需求出发其实能延伸出不少场景定时采集每天固定时间跑一遍记录粉丝数变化画出增长曲线判断账号是否在稳定涨粉批量对比筛选一批竞品账号按月拉数据看谁增长快、谁在掉粉异常检测如果某个账号的粉丝数在某天突然暴涨说明可能买了推广或者刷了量关系链分析结合关注列表接口还能进一步分析某个UP主关注了哪些同类账号做兴趣图谱。我这次先聚焦在“关注数粉丝数”的获取上后面可以基于同样的思路去扩展其他接口。2. 找到数据源B站接口是怎么被我定位到的2.1 浏览器开发者工具立了大功当时我在一个UP主主页上按F12打开开发者工具切到Network面板勾选Fetch/XHR然后刷新页面。网络请求列表里立刻出现了一堆接口其中有一个特别显眼https://api.bilibili.com/x/relation/stat?vmid2点开看响应是标准的JSON格式里面清清楚楚地写着{ code: 0, message: 0, ttl: 1, data: { mid: 2, following: 336, whisper: 0, black: 0, follower: 397 } }这里vmid2是B站创始人账号的UIDfollowing是关注数336follower是粉丝数397。响应里面还有whisper悄悄关注数、black黑名单数不过这些不是我要关注的重点。2.2 参数拆解vmid到底是什么vmid其实就是用户UID也就是B站用户的唯一数字ID。在用户主页的URL里就能看到比如https://space.bilibili.com/2最后的那个数字2就是vmid。这个ID在每个用户注册时就固定了不会变所以非常适合作为批量采集的入参。需要注意的是有些人在网上会分享用户名而不是UID但在脚本层面一定要用UID做查询。用户名会重复而且可能被修改UID是稳定主键。我这次拿到的Excel表里已经给了UID省去了从用户名反查UID的步骤。2.3 为什么优先选接口而不是解析网页HTML其实还有一个方案是直接请求用户主页的HTML然后用正则或者解析库把页面里的“粉丝 397”这种文本抠出来。我不推荐这么做原因有三网页HTML包含大量动态内容结构复杂解析效率低B站前端改版比较频繁HTML结构可能随时变今天能解析的代码明天就失效接口返回的是结构化JSON字段固定、语义清晰处理成本低了不止一个量级。接口虽然也会偶尔调整但变动频率远低于前端页面而且接口是按功能拆分的稳定性要高出很多。所以只要数据不是特别敏感、接口没有被严格加密优先找接口永远是首选策略。2.4 需要留意的一个字段code状态码B站接口的返回里有一个code字段用来标识请求是否成功code: 0表示成功code: -404表示资源不存在通常是对应的UID无效或者用户被封禁code: -412表示请求被风控拦截这是最麻烦的一种情况后面专门讲。写代码的时候一定不要只取data字段必须先判断code是否为0否则遇到异常账号时程序可能会报KeyError之类的错中断整个采集过程。3. 搞定单用户查询一个函数解决核心逻辑3.1 环境准备其实只需两个库这个脚本我用的就是Python自带的requests和json外加time做延迟控制没有任何重型依赖。环境要求Python 3.6以上即可安装requests库pip install requests不需要selenium不需要playwright不需要scrapy因为这些都用不上。整个程序的逻辑就是“拼URL——发请求——解析JSON”清爽得很。3.2 核心代码单用户数据获取先写一个最基础的版本传入UID返回关注数和粉丝数import requests import time import json 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, Referer: https://www.bilibili.com/ } def get_user_follow_info(vmid): url fhttps://api.bilibili.com/x/relation/stat?vmid{vmid} try: resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() result resp.json() except requests.exceptions.RequestException as e: print(f[网络错误] UID {vmid}: {e}) return None except json.JSONDecodeError as e: print(f[解析错误] UID {vmid}: 响应不是合法JSON) return None if result.get(code) 0: data result[data] return { uid: vmid, following: data[following], follower: data[follower] } else: print(f[接口错误] UID {vmid}: code{result.get(code)}, message{result.get(message)}) return None这个函数干了几件重要的事通过f-string把UID拼到URL里设置了超时时间10秒防止某个请求卡死拖慢整体进度对网络错误和JSON解析错误做了异常捕获判断code字段只返回成功的结构。3.3 我为什么要写这么多错误处理最开始我写的版本非常简单直接resp.json()然后一把梭。结果跑到第三个账号就崩了——那个账号已经被封禁接口返回code-404data字段是空的我的代码试图去取data[following]直接抛KeyError。从那以后我就学乖了任何网络接口调用都必须假设自己不一定会成功网络可能超时、IP可能被限流、账号可能异常、接口可能临时变更。把异常处理的逻辑前置后面批量跑的时候才能安心睡觉。3.4 实测验证拿真实UID跑一遍用B站创始人账号UID2做测试调用上面的函数if __name__ __main__: info get_user_follow_info(2) if info: print(fUID {info[uid]} 关注数: {info[following]} 粉丝数: {info[follower]}) else: print(获取失败)输出结果会类似UID 2 关注数: 336 粉丝数: 397以网页上显示的数字为准两边能对上说明接口调用是通的。这个验证步骤很重要不要写完代码就直接上批量先手动验证一两个账号确认URL、参数、解析逻辑都没问题再进入循环采集阶段。4. 从单用户到批量循环、节奏和容错4.1 让脚本循环处理一组UID在单用户函数稳定运行后我开始往批量方向扩展。这次我用到的是一个简单列表循环uid_list [2, 3, 4, 5, 6, 7, ...] # 这里替换成实际需要查询的UID列表 results [] for uid in uid_list: info get_user_follow_info(uid) if info: results.append(info) print(f已获取 UID {uid}: 关注数 {info[following]}, 粉丝数 {info[follower]}) else: results.append({uid: uid, following: None, follower: None}) print(fUID {uid} 获取失败) time.sleep(1) # 每次请求间隔1秒循环本身没什么技术含量真正的细节在于time.sleep。这里每请求一次就暂停1秒很多人可能会觉得没必要但这是整个批量采集里最容易翻车的地方。4.2 请求频率的掌控教训是用-412换来的我第一次跑批量脚本的时候没有加sleep一百多个账号秒刷完看着控制台哗哗往下滚觉得特别爽。结果没到一半连续出现了好几次code-412那一段的账号全部获取失败只能重新跑。-412这个错误码在B站意味着请求被风控系统识别为异常流量。连续高频请求同一个接口触发的概率会急剧上升。风控的判断维度通常包含请求频率、IP地址、User-Agent特征、Cookie状态等。解决办法很简单降低请求频率。我后来把每次请求之间的间隔调整到了2到3秒并且加了一点随机抖动让请求节奏不那么规律import random # 在循环内使用 time.sleep(random.uniform(2, 3))这样处理之后再没有出现过-412。实测下来单IP情况下每次请求间隔1秒是一个临界值稳妥起见最好在2秒以上。单次任务查询几百个账号多花几分钟换取不被风控这笔账是划算的。4.3 随机延迟为什么不显得“机械”有人可能觉得固定2秒和随机2到3秒有什么区别反正都是等。实际上服务器端的风控系统会看请求时间戳的分布特征。如果请求间隔恒定反而容易被识别成机器行为因为人在手工操作时不可能保持精确的2.000秒间隔。加入随机抖动之后时间分布更接近真实用户的操作节奏被风控盯上的概率会低一些。4.4 失败重试机制给自己多一次机会即使节奏控制到位网络波动、临时限流还是可能发生。我的做法是给请求加一个简单的重试机制def get_user_follow_info_with_retry(vmid, retries3): for attempt in range(retries): info get_user_follow_info(vmid) if info is not None: return info wait_time 2 ** attempt # 退避策略1, 2, 4 print(fUID {vmid} 第{attempt 1}次重试等待{wait_time}秒) time.sleep(wait_time) return None这里的思路是第一次失败后等1秒再失败等2秒第三次失败等4秒三次都失败就放弃把这条记录标记为失败继续处理下一个账号。指数退避的好处是给服务器喘息的时间避免重试时的请求再次集中涌入。4.5 断点续传的思路防止半路翻车批量采集最怕的就是跑到最后几个账号突然网络断开、电脑重启或者脚本报错结果前面几十个数据白跑了。解决思路很简单边跑边存不要把数据都累积在内存里最后统一写入。我当时是把每一条成功的记录立即追加写入CSV文件import csv def append_to_csv(row, filepathbilibili_users.csv): with open(filepath, a, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[uid, following, follower]) writer.writerow(row)因为CSV追加写入不会覆盖已有数据所以哪怕是中途挂了前面已经写入的内容也还在。重新跑的时候只需要跳过已经处理成功的UID即可。这个“边跑边存”的习惯看起来不起眼在数据量稍大一点的任务里却能省很多事。4.6 并发的诱惑什么时候值得用查询接口是I/O密集型的理论上用ThreadPoolExecutor并发能显著提速。比如一次查100个账号串行加延迟要三四分钟并发10个线程可能十几秒就完成了。但我个人的建议是除非你非常确定自己的网络环境稳定、账号的查询量不大否则不要轻易上并发。B站的风控是动态的并发请求会让单位时间内的请求数成倍增加触发-412的概率也会跟着涨。而且很多做数据采集的人核心需求是稳定拿到数据而不是比谁跑得快。真到了需要并发的那一步建议用较少的线程数比如3到5个并且把每个线程内的请求间隔适当拉长。性价比最高的方案仍然是串行加随机延迟简单可靠还容易排查问题。5. 数据落地从CSV到分析这一步决定了采集的价值5.1 CSV的编码坑用utf-8-sig而不是utf-8把数据写入CSV的时候最常见的坑是编码问题。直接用encodingutf-8写入生成的CSV用Excel打开会是乱码。原因是Excel默认用GBK编码打开CSV而Python写入的是UTF-8。解决办法是用utf-8-sig编码。这个编码会在文件开头加一个BOM头Excel看了就能正确识别UTF-8格式。一行代码的区别体验天差地别# 正确写法 with open(output.csv, w, newline, encodingutf-8-sig) as f: pass5.2 字段设计三个基本列和一个时间戳我在输出CSV的时候除了uid、following、follower三个核心字段还额外加了一个采集时间字段import datetime row { uid: info[uid], following: info[following], follower: info[follower], crawl_time: datetime.datetime.now().strftime(%Y-%m-%d %H:%M:%S) }加这个时间戳非常有用。因为B站用户的关注数和粉丝数都是动态数据今天跑出来的数据和下个月跑出来的肯定不一样。有了采集时间后面可以做增量对比比如同一个UID这个月的粉丝数和上个月差了多少增长趋势一目了然。5.3 用pandas做简单分析粉丝量排序和异常识别采集完成后的CSV我会用pandas加载进来做一些基础分析import pandas as pd df pd.read_csv(bilibili_users.csv) df[follower] pd.to_numeric(df[follower], errorscoerce) df[following] pd.to_numeric(df[following], errorscoerce) # 按照粉丝量降序排列 df_sorted df.sort_values(follower, ascendingFalse) # 计算关注数/粉丝数比值 df[follow_fan_ratio] df[following] / df[follower].replace(0, 1)这个follow_fan_ratio就是前面说的初筛依据。如果某个账号粉丝数只有几十关注数却有两三千这种账号通常是“关注狂魔”类型的普通用户不是内容生产者。反过来粉丝几千但关注只有几十基本可以判断是专心做内容的UP主。5.4 多次采集的增量对比让数据活起来单次采集是一张静态快照只能看某个时间点的状态多次采集之后就能看到变化趋势。我当时连续采集了五天把每天的CSV合并到一张表里按UID分组计算粉丝数的变化量df_list [] for date in [2024-01-01, 2024-01-02, 2024-01-03, 2024-01-04, 2024-01-05]: df_temp pd.read_csv(fbilibili_users_{date}.csv) df_temp[date] date df_list.append(df_temp) df_all pd.concat(df_list, ignore_indexTrue) df_pivot df_all.pivot_table(indexuid, columnsdate, valuesfollower, aggfuncmax) df_pivot[increase] df_pivot.iloc[:, -1] - df_pivot.iloc[:, 0]这个增量字段非常直观正常的创作者每天涨几个到几十个粉丝比较常见某一天突然从几百蹦到几万就要留意是不是有刷量操作。当然这只是初筛信号最终判断还要结合具体内容、播放数据等因素但至少数据本身已经把异常暴露出来了。5.5 可视化展示简单折线图如果配合matplotlib把某个账号的粉丝数增长画成折线图一眼就能看出增长是否平稳、有没有异常跳变。这里贴一个最简单的绘图代码import matplotlib.pyplot as plt uid_target 12345 series df_pivot.loc[uid_target] plt.plot(series.index, series.values, markero) plt.title(fUID {uid_target} Follower Trend) plt.xlabel(Date) plt.ylabel(Follower Count) plt.xticks(rotation45) plt.tight_layout() plt.show()不过这个属于锦上添花了基础的数据排序和增量计算已经能覆盖绝大多数业务判断需求。6. 采集时容易翻车的几个细节我的实测避坑记录6.1 请求头必须带完整尤其是Referer我试过只带User-Agent不带Referer接口多数时候也能正常返回但偶尔会碰到校验失败。B站的风控体系对Referer字段是有感知的api.bilibili.com下的接口Referer设置为https://www.bilibili.com/是常规操作。建议请求头至少包含以下两个字段字段推荐值作用User-AgentChrome/Firefox等浏览器的UA标识标识请求来自浏览器Refererhttps://www.bilibili.com/模拟从B站页面发起的请求用requests库自带的UA形如python-requests/2.31.0虽然也能通但风控识别度很高很容易被列入异常请求名单。就算只改一下UA请求成功率都会有明显提升。6.2 未登录可以查但带上Cookie更稳B站这个公开接口未登录状态下是可以调用成功的我验证过。但未登录的请求更像“陌生人”风控策略会更严格。如果你在跑批量数据时发现没一会儿就被-412拦可以试试在请求头里加上登录后的Cookie。获取Cookie的方式浏览器登录B站F12打开开发者工具在Network面板里找到任意请求复制请求头里的Cookie字段贴到脚本的HEADERS字典里HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ..., Referer: https://www.bilibili.com/, Cookie: 你的Cookie字符串 }需要注意Cookie是有时效性的过期之后需要重新登录获取。另外Cookie涉及账号信息安全不要写进公开的代码仓库里。6.3 批量采集的合规边界这里说一句经验之谈这个接口提供的是公开数据任何人打开B站用户主页都能看到关注数和粉丝数所以获取这些数据本身不涉及敏感信息。但批量采集时要控制合理频率不要对服务器造成压力。拿这些数据做正当的运营分析、竞品分析是没问题的但不要用于骚扰他人、非法营销或者任何侵犯用户权益的用途。6.4 接口字段可能变化代码里要做兼容B站接口虽然相对稳定但也不是永远不会变。万一哪天某个字段从follower改成了fans我的代码就会直接崩掉。为了应对这种情况建议在解析字段时做一个保护def get_user_follow_info(vmid): # ... data result[data] return { uid: vmid, following: data.get(following), follower: data.get(follower) or data.get(fans) }用.get()而不是直接下标取值字段缺失时返回None而不是报错。这样即使接口有小幅调整脚本也只是某些字段为空不会整批挂掉。6.5 验证成功的标志抽样人工核对批量采集完成后一定要抽样验证。我当时的做法是从结果里随机挑5个UID逐个在浏览器里打开B站主页核对粉丝数是否一致。这一步能有效发现URL拼接错误、UID映射错位、字段解析错误等隐蔽问题。虽然看起来“很原始”但确实是数据质量的有效保障。6.6 断点续跑的设计把失败账号单独存文件最后补一个实用技巧把获取失败的UID单独存到一个文件里等这一轮跑完再针对失败列表做一次补充采集。这样即使某次任务过程中碰到网络抖动也不需要整个任务重跑只需要针对失败列表再执行一轮即可。failed_uids [uid for uid in uid_list if uid not in success_uids] with open(failed_uids.txt, w, encodingutf-8) as f: for uid in failed_uids: f.write(str(uid) \n)采集公开数据这种事情很多时候不是技术难点有多高而是细节处理得够不够稳。把频率控制好、异常处理写好、数据边采边存就已经超过了绝大多数半途而废的脚本。我在这套小工具的实际使用中最大的体会是别一上来就想着并发提速、上框架先把串行流程跑稳把数据拿到手、落好地再谈优化。毕竟采集的目标是那份完整可靠的数据表不是追求脚本跑得多炫。