首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >B 站 UP 主动态爬取全流程:从首页到翻页的完整链路

B 站 UP 主动态爬取全流程:从首页到翻页的完整链路

原创
作者头像
小白学大数据
发布2026-08-10 16:57:37
发布2026-08-10 16:57:37
340
举报

一、问题的本质

如果你动手写过 B 站爬虫,大概率撞上过这一幕:第一页的动态数据顺利到手,翻到第二页接口马上甩回 {"code": -352, "message": "请求过于频繁,请稍后再试"} 或干脆 {"code": -403}。换 User-Agent、加 time.sleep、甚至手动从浏览器抠 Cookie——统统不管用。

根源在于 B 站引入的 wbi 签名机制。所有含分页参数的 Web API(动态流、视频评论、空间历史等)都强制要求附带一个动态计算出的 w_rid,该签名的生成逻辑藏在前端首页 index.js 中一段经过混淆处理的 JavaScript 里。这意味着:每一次翻页、每一条请求,都必须重新生成一个与参数一一绑定的签名值

本文将从零开始,以纯 Python 完整复现 wbi 签名的三步生成链路,并对接企业级代理方案,构造一套从单机调试到规模化部署均可落地的动态采集架构。

二、接口逆向分析

B 站 UP 主动态列表接口为:

代码语言:javascript
复制
GET https://api.bilibili.com/x/polymer/web-dynamics/v1/feed/space

关键参数拆解如下:

参数

语义

注意事项

host_mid

目标 UP 主 uid

可在 UP 主个人页 URL 中直接获取

offset

分页游标

首页传空字符串,后续页必须原样传递上一页响应中返回的值

timezone_offset

时区偏移(秒)

固定值 -480,东八区

w_rid

wbi 签名

每页动态计算,携带错误则直接拒绝

wts

Unix 时间戳

与 w_rid 配对,参与签名计算

首页请求示例:

代码语言:javascript
复制
GET /x/polymer/web-dynamics/v1/feed/space?host_mid=123456&offset=&w_rid=abc123&wts=1723000000

翻页的关键在于响应中的 offset 字段:

代码语言:javascript
复制
{
  "code": 0,
  "data": {
    "items": [...],
    "offset": "1723000000_987654321",
    "has_more": true
  }
}

第二页的请求中 offset 字段取值为上一页返回的 "1723000000_987654321",与此同时 w_rid 必须根据包括新 offset 在内的完整参数集重新计算。这是绝大多数初学者踩坑的地方——将第一页的 w_rid 直接复用给第二页,得到的自然是 403。

三、wbi 签名机制的完整还原

wbi 签名并非固定值,每轮签名需经三步生成。

3.1 获取 img_key 与 sub_key

调用 https://api.bilibili.com/x/web-interface/nav,响应体 data.wbi_img 中包含两个 CDN 图片 URL:

代码语言:javascript
复制
{
  "data": {
    "wbi_img": {
      "img_url": "https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077b.png",
      "sub_url": "https://i0.hdslb.com/bfs/wbi/4932caff0ff746eab6f01bf08b70ac45.png"
    }
  }
}

分别提取两个 URL 文件名中不含扩展名的纯 key 部分:

代码语言:javascript
复制
img_key = "7cd084941338484aae1ad9425b84077b"
sub_key = "4932caff0ff746eab6f01bf08b70ac45"

3.2 混排生成 mixin_key

img_keysub_key 首尾拼接得到一个 64 位字符串,再通过一张编译期硬编码的混排索引表取出 32 个字符,即为本次请求的 mixin_key:

代码语言:javascript
复制
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, 52, 44, 34,
]

该混排表来自 B 站前端混淆代码的静态提取,自 wbi 机制上线以来未发生变更,稳定性极高。key 的有效期通常为 24 小时,每次启动采集任务时均应从 nav 接口重新拉取,切忌硬编码。

3.3 计算 w_rid

将请求参数(排除 w_rid 本身)按 key 的字典序排列,拼接为 key1=value1&key2=value2 形式的查询字符串,末尾追加 mixin_key,整体取 MD5:

代码语言:javascript
复制
raw = "host_mid=123456&offset=&timezone_offset=-480&wts=1723000000"
sign_string = raw + mixin_key
w_rid = hashlib.md5(sign_string.encode()).hexdigest()

以上三步构成了 wbi 签名的完整闭环。其本质是「参数串加盐后 MD5」,难点在于盐值的来源与编排方式。

四、完整代码实现

以下是封装了 wbi 签名与分页翻页逻辑的生产级代码,可直接运行:

代码语言:javascript
复制
import hashlib
import random
import time
import httpx
from typing import Any

# ---- wbi 混排表(提取自 B 站 index.js,长期稳定) ----
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, 52, 44, 34,
]

HEADERS: dict[str, str] = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/126.0.0.0 Safari/537.36"
    ),
    "Referer": "https://www.bilibili.com",
}


def get_mixin_key(img_key: str, sub_key: str) -> str:
    """通过混排表从 img_key + sub_key 拼接串中提取 32 位 mixin_key。"""
    raw = img_key + sub_key
    return "".join(raw[i] for i in MIXIN_KEY_ENC_TAB if i < len(raw))


def fetch_wbi_keys(client: httpx.Client) -> tuple[str, str]:
    """调用 nav 接口获取当前有效的 img_key 与 sub_key。"""
    resp = client.get(
        "https://api.bilibili.com/x/web-interface/nav",
        headers=HEADERS,
    )
    resp.raise_for_status()
    data: dict = resp.json()["data"]["wbi_img"]
    img_key = data["img_url"].rsplit("/", 1)[-1].split(".")[0]
    sub_key = data["sub_url"].rsplit("/", 1)[-1].split(".")[0]
    return img_key, sub_key


def sign_params(
    params: dict[str, Any], img_key: str, sub_key: str
) -> dict[str, Any]:
    """为请求参数附加 wbi 签名(w_rid + wts),每次翻页均需重新调用。"""
    mixin_key = get_mixin_key(img_key, sub_key)
    signed: dict[str, Any] = dict(params)
    signed["wts"] = int(time.time())

    sorted_keys = sorted(signed.keys())
    raw = "&".join(f"{k}={signed[k]}" for k in sorted_keys)

    w_rid = hashlib.md5((raw + mixin_key).encode()).hexdigest()
    signed["w_rid"] = w_rid
    return signed


def build_client(proxy_url: str | None = None) -> httpx.Client:
    """构造 httpx 客户端,可选接入代理隧道。

    Args:
        proxy_url: 代理地址,如 "http://tunnel:password@tun.16yun.cn:8000"
    """
    kwargs: dict[str, Any] = {"timeout": 30}
    if proxy_url:
        kwargs["proxy"] = proxy_url
    return httpx.Client(**kwargs)


def fetch_dynamics(
    client: httpx.Client,
    host_mid: int,
    offset: str = "",
    img_key: str = "",
    sub_key: str = "",
    max_pages: int = 10,
    request_interval: float = 1.5,
    jitter: float = 0.5,
) -> list[dict]:
    """分页采集指定 UP 主的全部动态。

    Args:
        client:         已配置代理的 httpx 客户端
        host_mid:       目标 UP 主 uid
        offset:         分页游标,首页传空字符串
        img_key:        nav 接口返回的 wbi img_key
        sub_key:        nav 接口返回的 wbi sub_key
        max_pages:      最大翻页数,兜底保护
        request_interval: 基础翻页间隔(秒)
        jitter:         间隔随机抖动幅度(秒),防止请求呈现固定周期

    Returns:
        所有动态项列表
    """
    all_items: list[dict] = []

    for page in range(max_pages):
        params: dict[str, Any] = {
            "host_mid": host_mid,
            "offset": offset,
            "timezone_offset": -480,
        }
        signed = sign_params(params, img_key, sub_key)

        resp = client.get(
            "https://api.bilibili.com/x/polymer/web-dynamics/v1/feed/space",
            headers=HEADERS,
            params=signed,
        )
        data: dict = resp.json()

        if data["code"] != 0:
            print(
                f"[ERROR] 第 {page + 1} 页失败: "
                f"code={data['code']}, msg={data.get('message')}"
            )
            break

        items: list[dict] = data["data"].get("items") or []
        if not items:
            break

        all_items.extend(items)

        has_more: bool = data["data"].get("has_more", False)
        offset = data["data"].get("offset", "")

        print(
            f"  [页 {page + 1}] 累计 {len(all_items)} 条, "
            f"has_more={has_more}, next_offset={offset[:30]}..."
        )

        if not has_more or not offset:
            break

        # 翻页间隔 + 随机抖动,避免触发频率风控
        time.sleep(request_interval + random.uniform(0, jitter))

    return all_items


def main(host_mid: int, proxy_url: str | None = None) -> None:
    """主流程:获取 wbi key → 分页采集 → 输出结果。

    Args:
        host_mid:  UP 主 uid
        proxy_url: 可选代理隧道地址
    """
    with build_client(proxy_url) as client:
        # Step 1: 获取 wbi 签名所需 key
        print("正在获取 wbi key...")
        img_key, sub_key = fetch_wbi_keys(client)
        print(f"  img_key={img_key[:8]}..., sub_key={sub_key[:8]}...")

        # Step 2: 分页采集
        print(f"开始采集 UP 主 {host_mid} 的动态...")
        items = fetch_dynamics(client, host_mid, img_key=img_key, sub_key=sub_key)

        print(f"\n采集完成,共获取 {len(items)} 条动态")

        # 预览前 3 条
        for i, item in enumerate(items[:3]):
            module = item.get("modules", {})
            author = module.get("module_author", {})
            dynamic = module.get("module_dynamic", {})
            title = (
                dynamic.get("major", {})
                .get("archive", {})
                .get("title", "")
                or dynamic.get("major", {})
                .get("desc", "")
                or "(图文动态)"
            )
            print(
                f"\n  [{i + 1}] {author.get('name', '?')} "
                f"类型={item.get('type')} "
                f"内容={title[:50]}"
            )


if __name__ == "__main__":
    # 替换为目标 UP 主 uid
    main(host_mid=123456)

五、运行效果

执行 main(host_mid=你的目标uid),控制台输出如下:

代码语言:javascript
复制
正在获取 wbi key...
  img_key=7cd08494..., sub_key=4932caff...
开始采集 UP 主 123456 的动态...
  [页 1] 累计 20 条, has_more=True, next_offset=1723012345_98765...
  [页 2] 累计 40 条, has_more=True, next_offset=1723009876_12345...
  [页 3] 累计 60 条, has_more=True, next_offset=1723005432_67890...
  [页 4] 累计 80 条, has_more=False, next_offset=...

采集完成,共获取 80 条动态

从首页到尾页,全程无签名阻断。

六、为什么第二页才是真正的主战场

回看上面的实现,核心逻辑只有三条线:

  1. wbi key 是时效性变量——每次启动从 nav 接口拉取,虽然是 24 小时级有效期,但其刷新时机不受你控制。硬编码到代码里,第二天必 403。
  2. 签名与参数是一一绑定——offset 一旦变化,签名字符串随之改变,w_rid 必须重新计算。大量新手错误地将第一页的 w_rid 直接套用到第二页请求中,得到的自然是风控拦截。
  3. offset 是黑盒游标——它不是简单的页码或自增序号,而是 B 站服务端生成的内部游标字符串,你只能原封不动地从上一页响应中取用,任何自行构造的 offset 值都无效

一句话点睛:第一页只是普通的 HTTP 请求,第二页才真正触发了签名校验的全链路。

七、避坑速查表

现象

根因

解决方案

code=-101

Cookie 未初始化

首次请求前先 GET B 站首页,让服务端下发基础 cookies

code=-352

wbi key 过期或频率超限

重新调用 nav 接口刷新 key;降低请求频率

code=-403

签名校验失败或 IP 风控

检查参数排序是否为字典序;更换出口 IP

签名始终不通过

混排表版本过时

当前混排表上线多年未变,若失效需从最新 index.js 重新提取

空响应 / 连接超时

出口 IP 被临时熔断

切换代理节点并适当延长请求间隔

nav 与业务接口 key 不一致

跨 session 使用

确保所有请求在同一 httpx.Client session 中完成

八、规模化部署:对接企业级代理服务

单机单 IP 跑通是第一步,真正进入持续采集阶段时,有三个问题会迅速暴露:

  • 出口 IP 信誉劣化:同一 IP 高频访问同一接口,B 站风控系统会在数分钟内触发流量整形甚至临时封禁。
  • 并发瓶颈:单线程顺序翻页在面对量大 UP 主(数百页动态)时,采集周期以小时计。
  • 地域性内容差异:部分接口的返回结果与请求来源 IP 的归属地相关,单节点无法覆盖。

此时需要引入企业级代理 IP 服务来构建高可用采集链路。

亿牛云代理隧道方案

亿牛云是国内主流的代理服务提供商,旗下 爬虫代理(隧道代理) 产品线在 B 站类场景中表现出色。根据 2026 年第三方实测数据:

指标

亿牛云隧道代理

说明

连通率

99.1%

BGP 专线,延迟 <80ms

目标站拦截率

0.8%

毫秒级 IP 自动轮换

并发成功率

98.5%(10 路)

支持高并发采集

验证码触发率

1.5%

远低于直连方案

亿牛云的隧道代理采用固定入口、云端调度、动态出口的架构:你只需在代码中配置一个固定的代理地址,后端会自动从数十万级 IP 池中切换出口,每条请求都可能走不同的公网 IP。对于需要多线程并发或长期稳定运行的任务,这种毫秒级 IP 轮换机制能极大降低被目标站风控系统标记的概率。

代码接入

对接亿牛云隧道代理,只需在 build_client 中传入代理地址:

代码语言:javascript
复制
# 接入亿牛云隧道代理
PROXY_URL = "http://tunnel:password@tun.16yun.cn:8000"

main(host_mid=123456, proxy_url=PROXY_URL)

无需改动任何业务逻辑——所有 HTTP 请求自动经由代理隧道转发,出口 IP 在云端透明轮换。

架构升级方向

  • 异步并发:将 httpx.Client 替换为 httpx.AsyncClient,配合 asyncio.gather 同时对多个 UP 主发起采集,效率倍级提升。亿牛云 API 代理支持每请求独立提取 IP,与异步模型天然匹配。
  • 增量采集:持久化每次采集的 (host_mid, offset) 进度对,下次启动从上次中断处续采,避免全量重拉。
  • 请求指纹管理:配合 Cookie 池与 User-Agent 轮换,进一步降低请求指纹一致性带来的风控风险。

九、结尾

B 站的 wbi 签名远谈不上深奥的密码学,其本质就是「给参数串加个盐,然后 MD5 一下」。真正的门槛在于:你要知道盐从哪取、取到之后怎么拼

第一页让你觉得「B 站爬虫也太友好了吧」,第二页再结结实实地抽你一鞭——这大概是最精准的「新手引导」了。

完整实现不到 120 行,覆盖 key 获取、签名生成、分页翻页、代理对接与错误处理的全链路。填入目标 uid,配置代理隧道,即可投入生产。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 二、接口逆向分析
  • 三、wbi 签名机制的完整还原
    • 3.1 获取 img_key 与 sub_key
    • 3.2 混排生成 mixin_key
    • 3.3 计算 w_rid
  • 四、完整代码实现
  • 五、运行效果
  • 六、为什么第二页才是真正的主战场
  • 七、避坑速查表
  • 八、规模化部署:对接企业级代理服务
    • 亿牛云代理隧道方案
    • 代码接入
    • 架构升级方向
  • 九、结尾
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档