资讯动态

B站用户信息爬虫实战:从UID到完整用户画像的Python实现

发布时间:2026/10/13 16:40:50 来源:尧图企业网站定制
简介面向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: fhttps://space.bilibili.com/{uid} } resp requests.get(url, paramsparams, headersheaders, timeout10) 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: fhttps://space.bilibili.com/{uid} } resp requests.get(url, paramsparams, headersheaders, timeout10) 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, encodingutf-8) as f: json.dump(results, f, ensure_asciiFalse, indent2) with open(out_file.replace(.json, _failed.json), w, encodingutf-8) as f: json.dump(fail_ids, f, ensure_asciiFalse, indent2)这个函数的逻辑很直白循环里逐个抓取成功的追加到 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 CHARSETutf8mb4 COLLATEutf8mb4_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, encodingutf-8) as f: raw_record { uid: uid, raw: info, crawled_at: int(time.time()) } f.write(json.dumps(raw_record, ensure_asciiFalse) \n)这样做的价值在两周后体现得最明显。当分析需求突然多出来一个字段比如“用户主页头图”而抓取时根本没存这个字段你有两个选择重新跑一遍全部UID或者从原始响应里提取。前者平均要浪费几十分钟到一个小时后者只需要一条过滤命令。从那以后我每次上线新字段都会先拿20个UID做冒烟测试确认字段落地无误再全量跑并且强制预留原始响应留底文件。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑