☰
B站用户信息爬虫实战:从UID到完整用户画像的Python实现
2026/10/13 16:40:48 网站建设 项目流程

简介:面向B站用户信息采集的Python爬虫项目资源,适合Python初学者、爬虫开发入门者或需要分析B站用户公开数据的研究者。脚本聚焦抓取用户id、昵称、性别、头像、等级、经验值、粉丝数、生日、地址、注册时间、签名等字段。资源包共5个文件,压缩包大小14KB,包含2个Python脚本(一个主爬虫脚本、一个头像抓取脚本)、1个SQL建表文件、1个User-Agent轮换列表和1份说明文档。体积虽小,但代码结构清晰,便于直接阅读、修改和扩展。已有145人学习下载。通过该项目可掌握requests与BeautifulSoup/lxml解析库的配合用法,了解应对B站反爬机制的常见策略,如设置请求间隔、轮换代理和User-Agent、处理动态令牌等,同时学习如何将采集结果写入MySQL数据库的完整流程。对于想快速搭建一个可运行、可扩展的用户信息采集脚本的开发者而言,这份资源提供了从请求到解析、从存储到隐私合规处理的轻量级参考。

1. 从UID到一张完整的B站用户画像:这个python爬虫项目能做什么

接某个数据处理需求时,最容易被卡住的一步往往不是分析,而是数据从哪来。某次整理一批B站创作者的基础资料,手动打开空间主页复制十几个字段,一个晚上只处理了五十个用户,速度慢且容易错行。这正是这个python爬虫项目要解决的场景:把用户id、昵称、性别、头像、等级、经验值、粉丝数、生日、地址、注册时间、签名这些散落在不同接口的字段统一抓下来,落成一张可直接做筛选、排序、可视化的用户画像底表。它适合三种人:需要分析B站用户结构的从业者、负责账号矩阵运营的人,以及刚接触爬虫想要一个能跑通的完整示例的开发者。前提只有一个——你能拿到一批待抓取的UID列表,剩下的交给脚本。

2. 拆接口与字段映射:三个数据来源决定这份资源能不能直接用

2.1 主接口选型:为什么先打 acc/info 而不是去解析HTML页面

做B站用户信息抓取,业内常见的有两条路。第一条是直接请求平台对外开放的JSON接口,拿结构化数据;第二条是用页面解析方案去读用户空间页的HTML,再从中提取字段。这个项目选的是接口路线,原因有两点。

接口返回的是稳定的JSON结构,字段名和层级基本固定,适配成本低;而页面HTML的DOM结构改动频繁,今天能跑的选择器下个月可能全部失效,属于典型的“能用但不好维护”的方案。更重要的是空间页面的很多信息是异步加载的,直接请求HTML拿到的内容不完整,比如粉丝数、注册信息往往要等页面加载完成后才填充,解析脚本不得不引入等待机制,复杂度和出错概率都上去了。

主接口选型上,核心请求地址为https://api.bilibili.com/x/space/acc/info,通过GET传参mid指定用户ID。抓取时统一带上Referer和User-Agent两个请求头,前者的作用是让服务端认为请求来自正常的空间页面访问,后者用于标识客户端环境。一个最小可用的请求封装如下。

import requests def fetch_user_info(uid: int) -> dict: url = "https://api.bilibili.com/x/space/acc/info" params = { "mid": uid, "photo": True } headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": f"https://space.bilibili.com/{uid}" } resp = requests.get(url, params=params, headers=headers, timeout=10) data = resp.json() if data["code"] != 0: raise ValueError(f"接口返回异常: code={data['code']}, msg={data.get('message')}") return data["data"]

这段代码的关键点在最后一步校验:data["code"]为 0 才代表请求成功,非零值意味着UID不存在、账号被封禁或接口风控,直接抛异常比返回一个空字典更能帮助调用方定位问题。timeout参数设置为 10 秒,避免网络异常时线程长时间挂住。photo参数表示是否附带头像相关数据,置为 True 可以一并拿到头像图片信息。

2.2 字段级映射:JSON返回里十几个字段的落库规则

acc/info 接口返回的字段并不多,但它覆盖了用户画像里最核心的身份信息。把返回的字典字段与项目要抓取的业务字段做映射,能发现一个现象:粉丝数不在这个接口里,需要单独请求另一个接口;生日和注册时间也不是稳定返回,需要额外的兜底方案。

我一般把数据来源设计成三路。第一路是acc/info,拿用户ID、昵称、性别、头像、等级、经验值、签名;第二路是https://api.bilibili.com/x/relation/stat,通过vmid参数获取粉丝数、关注数等社交指标;第三路是登录态下的个人资料接口,用于补全生日这类隐私字段。字段映射表如下,这也是这份资源里数据字典部分的核心内容。

目标字段来源接口返回字段落库处理
用户idacc/infomidint,主键
昵称acc/infoname直接存,注意emoji编码
性别acc/infosex保留“保密”原值,统一归一
头像acc/infoface强制转https存储
等级acc/infolevelint
经验值acc/infolevel_info.current_expint,与等级分开存
粉丝数relation/statfollowerint
生日登录态个人资料接口birthday可能为空,置NULL
地址acc/infoplace粒度不稳定,可能只有“中国”
注册时间acc/infojointime时间戳,缺失时置空
签名acc/infosign保留换行符

relation/stat 的请求写法和主接口类似,只是参数名不同。

def fetch_follower_count(uid: int) -> int: url = "https://api.bilibili.com/x/relation/stat" params = {"vmid": uid} headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": f"https://space.bilibili.com/{uid}" } resp = requests.get(url, params=params, headers=headers, timeout=10) data = resp.json() if data["code"] != 0: return 0 return data["data"].get("follower", 0)

这里返回的默认值 0 是有讲究的。粉丝数缺失时,用 0 填充不会影响统计口径,但如果你用 NULL 填充,在做平均值聚合时会把整列结果拉偏。类似这种细节,在字段落库时最好先统一约定口径,否则后续清洗阶段会花掉大量时间。

2.3 等级与经验值:两个字段的语义差异别搞混

项目标题里专门提到了“等级与经验值”,实际接口里的语义值得多说一句。acc/info 返回的level是账号当前等级,比如 5 或者 6,而level_info.current_exp是当前等级内的经验值,注意不是累计经验值。接口里还有next_exp字段,表示升到下一级需要的经验值。

这里有一个常见的理解偏差:有人会把 current_exp 当成账号总经验来用,导致分析报告里“高等级账号经验值反而低”这种错误结论。正确的取法是等级用level,经验值用current_exp,两者在后续分析中分别建模。如果要看账号活跃程度,拿current_exp和next_exp的比值做活跃度指标,比单纯看等级更有参考价值。这个字段语义在资源的README里有单独说明,建议先读完再动手改代码。

3. 批量抓取的用户级调度:限速、失败重试与断点续抓

3.1 一个能直接跑通的基础请求封装

单用户抓取能跑通只是第一步,真正让这份资源产生价值的是批量能力。批量任务首先要解决的事情是请求封装要足够健壮,不能因为某个用户返回异常就让整个流程中断。

上面两段代码已经覆盖了基本信息加粉丝数两个维度的获取,这里把它们合并成一个带完整异常处理的调度函数。合并时需要额外注意超时和异常类型,requests模块在超时、连接错误、DNS解析失败时抛出的异常类型各不相同,统一捕获Exception虽然粗暴,但可以保证任务不中断,把具体错误信息记录到失败列表里。

import time import json def fetch_user_full(uid: int) -> dict: info = fetch_user_info(uid) follower = fetch_follower_count(uid) return { "uid": uid, "nickname": info.get("name"), "sex": info.get("sex"), "face": info.get("face"), "level": info.get("level"), "exp": info.get("level_info", {}).get("current_exp"), "follower": follower, "sign": info.get("sign"), "place": info.get("place") }

合并函数把两个接口的返回值组装成一个扁平字典,这样后续写CSV或存数据库都不用再做二次映射。level_info字段本身是一个嵌套字典,用.get("level_info", {})再取current_exp,避免接口某次不返回该字段时抛出NoneType异常。

参数说明上,所有涉及网络请求的函数都保留了独立的 timeout 参数,运行时如果网络抖动严重,建议把 timeout 从 10 调到 15。但不要调太高,否则批量任务里单个卡住的请求会拖慢整体进度,得不偿失。

3.2 批量任务的限速与断点续抓

批量抓取时,最容易踩的坑是没有节奏控制。一次性连续请求大量接口,前几百个没问题,跑到上千个的时候会突然全部返回 412 状态码,这是平台对高频访问的限流信号。我的处理方式是每个用户请求之间固定 sleep,并且把延时做成可配置的参数。另一个重要设计是断点续抓——如果跑了一半进程崩了,靠失败列表和已写入的文件就能恢复,不用从头再来。

def batch_fetch(uids: list, out_file: str, delay: float = 1.0): results = [] fail_ids = [] for idx, uid in enumerate(uids): try: record = fetch_user_full(uid) record["crawled_at"] = int(time.time()) results.append(record) print(f"[{idx + 1}/{len(uids)}] uid={uid} ok") except Exception as e: fail_ids.append({"uid": uid, "error": str(e)}) print(f"[{idx + 1}/{len(uids)}] uid={uid} failed: {e}") time.sleep(delay) with open(out_file, "w", encoding="utf-8") as f: json.dump(results, f, ensure_ascii=False, indent=2) with open(out_file.replace(".json", "_failed.json"), "w", encoding="utf-8") as f: json.dump(fail_ids, f, ensure_ascii=False, indent=2)

这个函数的逻辑很直白:循环里逐个抓取,成功的追加到 results,失败的追加到 fail_ids。每轮结束把结果和失败列表分别落盘。这么做的好处是即使程序在中途被强制杀死,已经成功抓取的部分不会丢失,重跑时只需要读取_failed.json里的UID列表,继续执行剩余部分。

delay参数是限速的关键所在。我实际使用时,默认 1 秒已经算比较激进的取值,如果发现 412 频繁出现,把这个值调到 1.5 到 2 秒会明显改善。批量规模在几千个用户以内,1 秒延时的总耗时大约是一个小时出头,属于可以接受的量级。如果数据量更大,可以考虑用random.uniform(0.8, 1.5)代替固定延时,让请求间隔有一定的随机性,比固定间隔更不容易触发风控。

3.3 cookie与登录态:哪些字段必须登录才能拿到

关于登录态,这份资源里有一个明确的边界说明。先说结论:昵称、性别、头像、等级、经验值、签名、粉丝数这些字段,匿名请求就能拿到;而生日、注册时间这两个字段,匿名状态下不可靠,需要登录态,而且注册时间在若干年内时有时无。

造成这种现象的原因是接口设计策略不同。acc/info 这个接口设计初衷是支撑用户空间页面的公开展示,所以公开字段不需要登录。生日字段属于用户在个人资料中设置的隐私项,只有本人查看自己资料时接口才会返回,爬取他人时拿到空值是正常现象。注册时间的处理更特殊,老版接口曾返回过jointime字段,但后来大量账号这个字段变成了空值。

我一般这样设计兜底策略:抓取时先尝试读jointime,拿到时间戳就转成%Y-%m-%d格式存储;拿不到就置空,同时把该用户ID记入一份“待补全清单”,后续如果有登录态Cookie可用,再单独跑一轮补全。不要为了一个字段去硬解页面上的隐藏信息,投入产出比不划算。

字段匿名可用登录可用备注
昵称、性别、头像是是唯一稳定来源
等级、经验值是是语义差异需注意
粉丝数是是来自relation/stat
生日否本人可读无公开替代方案
注册时间不稳定不稳定缺失率高,需兜底

4. 避坑指南:B站用户信息抓取最常见的四个翻车现场

4.1 性别返回的不是空值而是“保密”

现象:数据库里sex字段出现了一堆“保密”字符串,程序里做if user["sex"] is None判断失效,后续逻辑全部走不到默认分支。

原因:acc/info 接口的性别字段本身就允许三个取值:男、女、保密。用户没有设置性别时,接口返回的不是空字符串也不是 null,而是中文字符串“保密”。这跟大多数平台的null/unknown处理方式不一样,直接按空值判断自然翻车。

解决:在字段清洗阶段做一次显式映射。代码里统一将“保密”转成“未知”或None,并把这个逻辑写进数据字典。

SEX_MAP = { "男": "male", "女": "female", "保密": "unknown" } def normalize_sex(raw: str) -> str: return SEX_MAP.get(raw, "unknown")

这个坑看着小,实际影响不小。如果你后续做性别分布统计,“保密”和缺失值是两类数据,混在一起会直接污染报表口径。从那以后,我处理任何接口返回的枚举值,都会先做一次全量取值扫描,而不是只按文档里的示例写判断。

4.2 头像URL时好时坏:http与防盗链问题

现象:抓下来的头像链接存进数据库,前端页面引用时图片一半能显示一半裂掉。下载头像到本地时,部分请求返回403。

原因:接口返回的face字段有时候是http://开头,有时候是https://。现代浏览器对混合内容有限制,http 资源在 https 页面下默认不加载;同时平台对图片有防盗链校验,直接下载不带 Referer 就会被拒。

解决:存储时强制替换协议头,下载时带上 Referer 请求头。

face_url = info.get("face") or "" if face_url.startswith("http://"): face_url = "https://" + face_url[7:] image_headers = { "Referer": "https://www.bilibili.com/", "User-Agent": "Mozilla/5.0" }

这段代码里的or ""也是防坑细节,不是所有用户都有头像,接口可能返回空字符串或 null,取数时先做兜底再处理,后续逻辑才不会因为NoneType报错。

4.3 昵称里的emoji把数据库写入搞崩

现象:脚本跑完,写MySQL时抛异常,错误信息类似Incorrect string value: '\xF0\x9F\x98\x80',整批数据回滚。

原因:部分用户昵称里带emoji或特殊符号,这类字符的编码超出了常见的utf8字符集范围,需要utf8mb4才能存储。如果你建表时用了默认的utf8,写入就会报错。这次踩坑还好发生在测试阶段,如果上了生产环境才发现,清洗成本会高一个量级。

解决:建表语句里明确指定utf8mb4,同时字段级字符集用utf8mb4_unicode_ci。

CREATE TABLE bili_users ( uid BIGINT PRIMARY KEY, nickname VARCHAR(255) CHARACTER SET utf8mb4, sex VARCHAR(16), face_url VARCHAR(512), level INT, current_exp INT ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

如果你不想改库表结构,也可以写入前过滤emoji。用字符范围剔除法是常见做法,我个人更推荐直接改库,因为保留昵称原貌对后续对账、搜索都有价值。

4.4 未登录高频请求触发412:昨天能跑今天全挂

现象:前一天批量任务跑得正常,第二天重新执行,所有请求都返回 412 状态码,连手工请求接口都提示访问异常。

原因:412 是平台对高频访问的限流响应。未登录状态下,同一IP短期请求总量超过阈值,就会被临时标记。标记后即使把请求频率降下来,也会持续一段时间。

解决:第一优先级是降低任务整体频率,把固定延时提高到2秒以上;第二是给请求头加一点随机特征,避免所有请求的User-Agent一模一样;第三是把412响应对应的UID单独记录,不要混在普通失败列表里。

if resp.status_code == 412: risk_ids.append(uid)

多一个列表多一层数据维度。412的UID列表意味着这批用户不是数据问题,是访问策略问题,后续补抓时换一批延时策略单独处理就好。血泪经验告诉我,这个列表最好单独落盘,不然和普通失败混在一起,你根本分不清哪些该重试、哪些该怎么重试。

5. 进阶收尾:把抓取结果变成可复用的本地数据资产

5.1 抓完先校验:pandas快速检查重复与缺字段

批量任务跑完,别急着入库,先在本地用 pandas 做一次快速体检。重复UID、缺失字段、空字符串过多,这些问题用肉眼根本看不出来,用脚本查最靠谱。

import pandas as pd df = pd.read_json("batch_result.json") print("总行数:", len(df)) print("重复UID数:", df["uid"].duplicated().sum()) print("各字段缺失情况:") print(df.isna().sum()) print("性别分布:") print(df["sex"].value_counts())

这段脚本的输出能直接告诉你三件事:任务实际抓到了多少条、有没有重复请求、哪些字段缺失严重。如果isna()统计里某个字段缺失率超过预期,趁数据量还不大,赶紧补跑对应UID,别等入库以后再做修补,那时候对账成本要高很多。

5.2 每一行都带上抓取快照:crawled_at与原始响应留底

最后补一个我个人强烈建议的习惯:把原始接口响应留底。批量任务里每个用户抓完后,除了保存处理好的扁平字段,再把原始JSON响应用一个jsonl文件按行追加存下来。这个看起来多余的步骤,实际上是你会感谢自己的“后悔药”。

with open("raw_responses.jsonl", "a", encoding="utf-8") as f: raw_record = { "uid": uid, "raw": info, "crawled_at": int(time.time()) } f.write(json.dumps(raw_record, ensure_ascii=False) + "\n")

这样做的价值在两周后体现得最明显。当分析需求突然多出来一个字段,比如“用户主页头图”,而抓取时根本没存这个字段,你有两个选择:重新跑一遍全部UID,或者从原始响应里提取。前者平均要浪费几十分钟到一个小时,后者只需要一条过滤命令。从那以后,我每次上线新字段都会先拿20个UID做冒烟测试,确认字段落地无误再全量跑,并且强制预留原始响应留底文件。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询