Blog/开发者工具/

B站视频评论与弹幕分析 API 教程 | SandBase

从一个 BV 号出发分析 B站观众反应:用四个 SandBase 端点读取视频数据、翻页热评、抽样楼中楼回复,并把弹幕按分钟分桶。无需 B站登录,只需一个 SandBase API Key。

暗色电影感渲染:玻璃视频屏上划过弹幕光点,汇入分叉的评论气泡,再流入 Agent 核心

一家科技媒体发了条两分钟的发布会速览,第二天早上,视频下面已经有 2,199 条评论,播放器上飘过 1,404 条弹幕。评论区在吵价格,弹幕则是在对着某一秒的画面吐槽。想知道观众怎么看一场发布会、一支预告片或者竞品的广告,B站其实给了你两路信号,而且两路都得变成数据才好分析。

这篇教程从一个 BV 号出发,用四个 SandBase 端点完成整条链路:读视频数据,翻页拉热评,展开回复最多的几条楼中楼,再把弹幕按播放分钟分桶。整体端点地图可以先看 B站公开数据 API 总览。

这里读的都是公开、只读数据。不需要 B站账号,不需要 Cookie,也不需要 SDK,只要一个 SandBase API Key 做鉴权。这四个端点目前在 SandBase 目录里标的是 Free。

参数和响应信封以端点 API 参考为准,参考只保证信封结构。下文出现的业务字段名都来自我自己跑的调用(测试于 2026-10-01,UTC),属于实测观察,不是文档保证。正式依赖之前,请用真实响应再核对一遍。

先说结论

  • 一个 BV 号就够了。bilibili/web/one-video 返回标题、UP 主、stat(播放、点赞、投币、收藏、评论、弹幕),还有 aid 和弹幕要用的 cid。
  • bilibili/app/video-comments 每页 20 条热评。翻页时把返回的 cursor.next 作为整数 next_offset 传回去。
  • bilibili/web/comment-reply 用 bv_id 加父评论的 rpid 读楼中楼,pn 翻页。
  • bilibili/web/video-danmaku 用 cid 取弹幕,返回的是 XML 字符串,不是 JSON。弹幕多的视频只拿到一部分。

为什么要同时看评论和弹幕

评论是观众看完之后的整体态度,弹幕是看的过程中对具体某一秒的即时反应。只看评论,你知道大家在吵价格,却不知道视频哪一段最“炸”;只看弹幕,你有了时间线,但很难读出完整的观点。两者合起来,才是一份像样的观众反应报告。

B站的开放平台主要面向管理自己内容、走授权的创作者和合作方。要读别人公开视频下的反应,很多人最后只能自己写爬虫、维护 Cookie 和请求签名,而这些东西随时会变。做一次发布会复盘,这部分维护成本往往比分析本身还高。

SandBase 这条路范围更窄:用一个密钥读公开的视频数据、评论、回复和弹幕,不发评论、不点赞,也不碰任何需要账号的东西。B站创作者研究教程讲的是热搜、搜索和 UP 主画像;这篇只看一件事:观众对某一条视频说了什么。

你的需求用什么
做研究或反应总结用的公开评论、回复、弹幕SandBase B站公开数据 API
管理、回复或审核自己账号下的内容B站官方创作者工具和开放平台
私密、充电专属或仅账号可见的数据两条路都不适用

流程一览

  1. 从链接(bilibili.com/video/BV...)里取出 BV 号。
  2. 用 bilibili/web/one-video(bv_id)读视频:标题、UP 主、stat、aid、cid。
  3. 用 bilibili/app/video-comments(bv_id、mode,之后加 next_offset)翻页拉热评。
  4. 用 bilibili/web/comment-reply(bv_id、rpid、pn)展开回复最多的楼。
  5. 用 bilibili/web/video-danmaku(cid)读弹幕,按播放分钟分桶。
  6. 把评论、回复和弹幕时间线交给模型做总结。

SandBase B站 API 目录页,列出 B站端点并标注 Free 状态 SandBase 上的 B站目录页:共显示 38 个端点,形式是 GET /apis/v1/bilibili/...;右侧选中的“Get video playurl”状态为 Available、Free。本教程调用的是 Model API 的 POST /v1/api/bilibili/... 路由。

写代码前先把接口面说清楚。目录页展示的是 GET /apis/v1/bilibili/<path>;本文用的是端点参考里的 Model API,也就是 POST /v1/api/bilibili/<path>,JSON 请求体里只放该端点自己的参数。参考页里 app/video-comments 的 cURL 示例在请求体里放了一个 model 字段,我实际调用时没带它也能正常返回,因为路径本身已经指明了端点。复制示例时请保留 POST 方法和 /v1/api/ 前缀,别和目录页的 GET 路径混用。

第 0 步:一个通用调用函数

import os
import re
import time
import xml.etree.ElementTree as ET
from collections import Counter

import requests

API = "https://api.sandbase.ai/v1/api"
HEADERS = {
    "Authorization": f"Bearer {os.environ['SANDBASE_API_KEY']}",
    "Content-Type": "application/json",
}

def call(path: str, payload: dict, retries: int = 2):
    for attempt in range(retries + 1):
        try:
            resp = requests.post(f"{API}/{path}", headers=HEADERS, json=payload, timeout=90)
        except requests.exceptions.ConnectionError:
            if attempt < retries:
                time.sleep(2 * (attempt + 1))  # TLS 连接被断开,重试
                continue
            raise
        if resp.status_code >= 500 and attempt < retries:
            time.sleep(2 * (attempt + 1))
            continue
        resp.raise_for_status()
        body = resp.json()
        if body.get("status") != "completed":
            raise RuntimeError(body.get("error", {}).get("message", f"{path} did not complete"))
        # 文档结构是 outputs[0].data;同时兼容顶层 `output`。
        output = body.get("output")
        if output is None and body.get("outputs"):
            output = body["outputs"][0].get("data")
        print(f"  {path}: run {body.get('id')}")
        return output
    return None

def bvid_from_url(url: str) -> str:
    m = re.search(r"(BV[0-9A-Za-z]{10})", url)
    if not m:
        raise ValueError(f"no BV id in {url}")
    return m.group(1)

def clean_text(message: str) -> str:
    # 回复开头是“回复 @某人 :”,去掉昵称,只留内容。
    return re.sub(r"^回复\s*@[^::]+[::]\s*", "", message or "").strip()

参考文档写的完成态结构是 outputs[0].data,这次测试里 B站的每次调用也都是这个结构。我在 SandBase 其他平台的端点上见过顶层 output,所以函数两种都读,有 output 时优先用它。和别的平台相比,这里有两点不一样:

  • 函数返回的是 data 里的原样内容,不一定是字典。 JSON 类端点返回的是 B站自己的外壳 {code, data, message, ttl},有用的字段还要再往下取一层 data;弹幕端点返回的则是一整段 XML 字符串。
  • 重试同时覆盖连接错误和 5xx。 我对测试视频的第一次 one-video 调用,还没拿到 HTTP 状态码就在 TLS 层断开了,同样的请求重试一次就成功了。

clean_text 是因为回复文本里会带上被回复者的昵称。做反应分析要的是观点,不是人名。

第 1 步:读视频数据

def read_video(bv_id: str) -> dict:
    v = (call("bilibili/web/one-video", {"bv_id": bv_id}) or {}).get("data") or {}
    stat = v.get("stat") or {}
    return {
        "bv_id": bv_id,
        "aid": v.get("aid"),
        "cid": v.get("cid"),  # 第一个分 P;多 P 视频在 `pages` 里
        "title": v.get("title"),
        "channel": (v.get("owner") or {}).get("name"),
        "published": v.get("pubdate"),  # Unix 秒
        "duration": v.get("duration"),
        "views": stat.get("view"),
        "likes": stat.get("like"),
        "coins": stat.get("coin"),
        "favorites": stat.get("favorite"),
        "shares": stat.get("share"),
        "comments": stat.get("reply"),
        "danmaku": stat.get("danmaku"),
    }

测试视频是科技媒体“科技美学”发布的公开速览视频《两分钟发布会 | 小米 18 Pro / Pro Max亮相…》(BV19uhb6mEWd,发布于 2026-09-23 UTC)。run id 11f78f98-533c-463e-8b1f-c8de5c165752 的 outputs[0].data 节选如下:

{
  "code": 0,
  "data": {
    "aid": 117321125400871,
    "bvid": "BV19uhb6mEWd",
    "cid": 42143190519,
    "title": "两分钟发布会 | 小米 18 Pro / Pro Max亮相 小米平板9 小米手环11 小米手表S5 还有咖啡机??",
    "owner": { "name": "科技美学" },
    "pubdate": 1790178608,
    "duration": 710,
    "pages": [{ "cid": 42143190519, "page": 1, "duration": 710 }],
    "stat": {
      "view": 247824, "like": 6310, "coin": 445, "favorite": 741,
      "share": 266, "reply": 2199, "danmaku": 1404
    }
  },
  "message": "OK"
}

计数都是普通整数,不用像 YouTube 那样解析“7.5K”之类的字符串。stat.reply 告诉你评论量有多大,stat.danmaku 让你对第 4 步心里有数。cid 是唯一没法从链接里直接拿到的 id。one-video 已经把它带回来了,省掉一次 bilibili/web/video-parts 调用;我单独调了一次 video-parts(run f3862a4d-034b-451e-b924-c452af230b24),对这个单 P 视频返回的是同一个 cid。多 P 视频的话,遍历 pages,每个分 P 分别读弹幕。

bilibili/web/bv-to-aid 这次也没用上:后面用到的评论端点直接接受 bv_id,而 one-video 本来就会返回 aid。另外,这条视频的 tname(分区名)是空的。

SandBase B站 web one-video 端点 API 参考 bilibili/web/one-video 参考页(Get single video data):POST /v1/api/bilibili/web/one-video,只有一个必填字符串参数 bv_id。文档的响应示例里 outputs[0].data 是空对象。

第 2 步:翻页拉热评

def top_comments(bv_id: str, max_pages: int = 3, mode: int = 3) -> list[dict]:
    payload = {"bv_id": bv_id, "mode": mode}  # 3 = 热度,2 = 时间
    out, seen = [], set()
    for _ in range(max_pages):
        data = (call("bilibili/app/video-comments", payload) or {}).get("data") or {}
        for r in data.get("replies") or []:
            if r.get("rpid") in seen:
                continue
            seen.add(r.get("rpid"))
            out.append({
                "rpid": str(r.get("rpid")),
                "text": clean_text((r.get("content") or {}).get("message", "")),
                "likes": r.get("like", 0),
                "replies": r.get("rcount", 0),
                "ctime": r.get("ctime"),
            })
        cursor = data.get("cursor") or {}
        if cursor.get("is_end") or not cursor.get("next"):
            break
        payload = {"bv_id": bv_id, "mode": mode, "next_offset": cursor["next"]}
    return out

我最开始用的是 bilibili/web/video-comments,参数是 bv_id 加页码 pn,后来因为它不稳定才换掉。第 1 页(run b1b5caeb-43f0-43de-b4e9-44f20245f03c)返回 20 条评论,page.count 为 2199;第 2 页(run fa847910-aa82-4166-b3b4-17cf5357f45d)一条都没有,page.count 变成 0。重试第 2 页(6f7e6258-f781-4ffb-b00b-a6b1bc168090)又正常了,第 3 页(139f2dca-660c-4fa9-9c25-d5caef347687)又是空的。空页和“已经翻到底”长得一模一样,用它翻页很难写对。

app 版端点稳定得多。第一页(run 98654147-fe5a-4a47-8a2a-b23282e23575)返回 20 条评论,外加一个 cursor 对象:

{
  "all_count": 2199,
  "is_begin": true,
  "is_end": false,
  "mode": 3,
  "name": "热门评论",
  "next": 2,
  "pagination_reply": { "next_offset": "CAEiAggC" },
  "support_mode": [2, 3]
}

这里有两个看起来都像游标的字段,只有一个符合参数定义。参考里 next_offset 是整数,把字符串 "CAEiAggC" 传过去,直接返回 HTTP 400:expected integer, but got string。改传 cursor.next 就对了:next_offset: 2(run 9706405a-a879-4769-a3b5-918945e400b0)返回 20 条新评论,rpid 和第一页没有重复,next 变成 3;next_offset: 3(run af057287-9ad9-44b6-b434-4bb7c0ae93b3)又是 20 条,next 为 4。函数遇到 is_end 就停,同时按 rpid 去重,因为文档没有保证页与页之间一定不重叠。

每条评论里观察到 rpid、like、rcount、ctime 和 content.message,另外还有一个 member 对象,装着评论者的昵称、头像和 mid。函数把身份相关字段全部丢掉,保留下来的一条长这样:

{ "rpid": "314848758273", "text": "哈哈,我花这钱来买安卓?哈哈哈哈哈", "likes": 92, "replies": 21 }

回复数请用 rcount,别用 count。这条评论的 rcount 是 21,count 却是 47;第 3 步的回复接口报出的总数是 21,和 rcount 一致。count 多出来的 26 是什么,从接口本身看不出来。

SandBase B站 app video-comments 端点 API 参考 bilibili/app/video-comments 参考页:可选的 av_id 或 bv_id(二选一)、mode(3 为热度,2 为时间,默认 3),以及整数类型的分页游标 next_offset,默认值为 1。

第 3 步:展开回复最多的楼

def thread_replies(bv_id: str, rpid: str, max_pages: int = 2) -> list[dict]:
    out = []
    for pn in range(1, max_pages + 1):
        data = (call("bilibili/web/comment-reply", {"bv_id": bv_id, "rpid": rpid, "pn": pn}) or {}).get("data") or {}
        if not (data.get("page") or {}).get("count"):
            # 遇到过一次:某页返回 count 为 0、没有回复,重试后恢复正常。
            data = (call("bilibili/web/comment-reply", {"bv_id": bv_id, "rpid": rpid, "pn": pn}) or {}).get("data") or {}
        batch = data.get("replies") or []
        out += [{"text": clean_text((r.get("content") or {}).get("message", "")), "likes": r.get("like", 0)}
                for r in batch]
        page = data.get("page") or {}
        if not batch or pn * (page.get("size") or 20) >= (page.get("count") or 0):
            break
    return out

comment-reply 要传视频的 bv_id 和父评论的 rpid,pn 可选。上面那条 21 个回复的楼,第 1 页(run b3d5734d-0e61-4890-9cab-ab4e37b4800b)返回 20 条回复,page 为 {count: 21, num: 1, size: 20},另外有一个 root 对象装着父评论本身。第 2 页(run 54035a3e-044e-4bfa-8247-48a10125db05)却是空的,count 为 0,和 web 版评论接口的毛病一样;重试(1d3c6dc2-a2ba-4fd3-b883-bbd68c9b0c72)后 page.num 为 2、count 为 21,恢复正常。所以函数在 count 为 0 时会重试一次。循环在 pn * size 覆盖 count 时停下,不会去请求本来就不存在的页。

clean_text 在回复里最有用。这一页 20 条回复里有 15 条以 回复 @某某 : 开头,等于把另一个用户的昵称写进了你的数据。去掉这个前缀,回复内容照样读得通(比如“iPhone Air,12G+256G,国行,常年5000左右…”),昵称就不进库了。

SandBase B站 web comment-reply 端点 API 参考 bilibili/web/comment-reply 参考页(Get reply to the specified comment):必填字符串 bv_id 和 rpid,可选整数页码 pn。

第 4 步:读弹幕,按分钟分桶

def danmaku(cid) -> list[dict]:
    xml_text = call("bilibili/web/video-danmaku", {"cid": str(cid)})
    if not isinstance(xml_text, str) or not xml_text.strip():
        return []
    root = ET.fromstring(xml_text.encode("utf-8"))
    rows = []
    for d in root.findall("d"):
        attrs = (d.get("p") or "").split(",")
        # 只保留播放时间(第一个字段)和文本,其余全部丢弃。
        rows.append({"t": float(attrs[0]) if attrs and attrs[0] else 0.0, "text": d.text or ""})
    return rows

这一步最出乎我意料。参考里 outputs[0].data 的类型写的是 object | array,而弹幕接口(run 3a0efcf1-8b87-43bd-a60f-b3393d4f8ce2)返回的是一段 123,221 个字符的 XML 字符串。节选:

<i>
  <chatid>42143190519</chatid>
  <maxlimit>1500</maxlimit>
  <d p="99.22400,1,25,16777215,…">懂了:pro性能不足 max烫手</d>
  <d p="390.45500,5,25,16765698,…">我为什么要在手机背屏上弹吉他?</d>
</i>

每个 <d> 元素是一条弹幕,p 属性是逗号分隔的一串值。这条视频里第一个值始终落在 0.7 到 707.9 之间,正好在 710 秒的时长内,所以我把它当作播放时间(秒)来用。后面的字段里有一个看起来像发送者哈希的值。函数只保留时间和文本,其余全部丢掉。

拿到之后先数一下条数。这条视频的 XML 里正好有 1,404 个 <d>,和 stat.danmaku 一致。换一条更早、弹幕更多的视频(BV1M1421t7hT,stat.danmaku 为 3,281),同一个接口只返回了 1,200 条,maxlimit 是 1,000(run 196f4179-b89e-457b-ae43-88828b7bef66)。所以弹幕多的视频,拿到的只是样本,别把它的条数当成视频的弹幕总数来报。

SandBase B站 web video-danmaku 端点 API 参考 bilibili/web/video-danmaku 参考页(Get Video Danmaku):只有一个必填字符串 cid。示例响应里 outputs[0].data 是空对象,而我实际调用拿到的是 XML 字符串。

串起来:每条视频一份反应记录

def analyze(bv_id: str, comment_pages: int = 3, threads: int = 3) -> dict:
    video = read_video(bv_id)
    comments = top_comments(bv_id, comment_pages)
    busiest = sorted(comments, key=lambda c: c["replies"], reverse=True)[:threads]
    for c in busiest:
        c["reply_sample"] = thread_replies(bv_id, c["rpid"])
    dm = danmaku(video["cid"]) if video.get("cid") else []
    per_minute = Counter(int(d["t"] // 60) for d in dm)
    repeated = Counter(d["text"].strip() for d in dm if d["text"].strip()).most_common(10)
    return {
        "video": video,
        "comments": comments,
        "danmaku_count": len(dm),
        "danmaku_per_minute": dict(sorted(per_minute.items())),
        "danmaku_repeated": repeated,
        "danmaku_sample": [d["text"] for d in dm[:200]],
    }

report = analyze(bvid_from_url("https://www.bilibili.com/video/BV19uhb6mEWd/"))

我把这段代码完整跑了一遍,一共 11 次调用:one-video 一次(a8abc9c6-d802-410a-8e47-01192919cc77),热评三页(ca89f260-162e-429b-a9c4-2bb79ea01195、7bcadf99-7938-47d5-80fd-2a7505e0b9ca、647f6ca4-0535-46f2-a59b-ae913ef4373e),三条楼共六页回复(第一页是 d8fe09de-1123-4096-a067-33bab9194d8f),弹幕一次(e3c4c8af-d0b7-44ba-84b4-1c630fc8fc72)。

结果是:2,199 条评论里拉到 60 条热评;三条楼分别有 54、27、30 个回复,抽到 40、27、30 条;带问号的评论 9 条;1,404 条弹幕全部读到。

弹幕时间线明显前重后轻:第 1 分钟 210 条,第 2 分钟 184 条,第 10 分钟最少,只有 55 条,第 8 分钟又冒到 163 条。重复最多的弹幕是数字:“0” 出现 99 次,“2” 33 次,“1” 30 次。观众在回答什么,接口本身看不出来,但 “0” 集中在第 40 秒和第 460 秒附近,要看的话我会先跳到这两处。

下面是我读这 60 条评论的印象,不是统计出来的分布:价格是绝对的主线。点赞最多的一条(368 赞)认为厂商已经把国补算进了发布会定价,不少人拿价格和 iPhone 比,也有人质疑背屏和局部防窥到底有没有用。这类归纳交给模型做最合适:把 report 整个传进去,提示词可以写“把这些评论按主题分组,标注正面、负面或混合,列出不重复的问题,并指出哪几分钟的弹幕最密集”。点赞数要一起带上,模型才知道 368 赞的评论比 0 赞的分量重。

调用次数随页数增长:视频一次,每 20 条热评一次,每 20 条回复一次,每个 cid 的弹幕一次。

文档保证 vs. 实测观察

项目状态
POST /v1/api/bilibili/web/one-video,参数 bv_id参考文档已列明
POST /v1/api/bilibili/app/video-comments,参数 bv_id/av_id、mode、整数 next_offset参考文档已列明
POST /v1/api/bilibili/web/comment-reply,参数 bv_id、rpid、pn参考文档已列明
POST /v1/api/bilibili/web/video-danmaku,参数 cid参考文档已列明
信封 id / status / model / outputs[0].data参考文档已列明
上游外壳 {code, data, message},以及 stat、aid、cid、owner、pages仅实测观察
评论 rpid、like、rcount、content.message,cursor.next / is_end仅实测观察
回复 page.count / num / size 和 root仅实测观察
弹幕以 XML 字符串返回,p 第一个值为播放秒数仅实测观察
弹幕多的视频返回条数少于 stat.danmaku仅实测观察
web 版评论、回复接口偶发 count: 0 的空页仅实测观察

常见用法

发布会反应复盘

对品牌官方的发布会视频,以及之后各家媒体的速览视频都跑一遍。对比评论主题,再把弹幕时间线和视频分段对齐。

  • 输入:几个 BV 号
  • 输出:每条视频一份反应记录
  • 端点:全部四个

预告片、广告的高光时刻

弹幕带时间戳,评论没有。把弹幕按 10 秒或 30 秒分桶,看预告片哪几秒反应最强烈,再去读最密的几个桶里的弹幕原文。

  • 输入:BV 号
  • 输出:按秒分桶的直方图和弹幕样本
  • 端点:one-video、video-danmaku

挖掘常见问题

把一个产品所有教程、测评视频下带问号的评论收集起来做聚类,就能看出文档和客服还缺哪些答案。

  • 输入:BV 号列表
  • 输出:去重后的问题清单
  • 端点:app/video-comments

商单前的受众检查

谈合作之前,抽一下 UP 主近期视频的热评,看观众是在认真讨论内容,还是基本在玩梗。

  • 输入:近期视频的 BV 号
  • 输出:带点赞数的评论样本
  • 端点:one-video、app/video-comments

实用提示

  • 拆两层。 先取 outputs[0].data(或 output),再取 B站自己的 data。
  • 热评翻页用整数 cursor.next。 字符串形式的 pagination_reply.next_offset 会被参数校验拒掉。
  • 空页重试一次。 web 版评论和回复接口返回 count: 0 的空页,有时只是偶发。
  • 回复数看 rcount, 不要看 count。
  • 弹幕按 XML 解析, 报总数之前先和 stat.danmaku 对一下。
  • 去掉身份信息。 丢掉 member、mid,弹幕 p 只留时间,回复里的 回复 @昵称 : 前缀也要去掉。
  • 连接错误也要重试, 不只是 5xx。我有一次调用在 TLS 层断开,重试后成功。
  • 只读公开数据。 不发评论,不点赞,不做审核。

常见问题

需要 B站账号或 Cookie 吗? 不需要。你用 SANDBASE_API_KEY 向 SandBase 鉴权,这几个只读端点不需要你这边登录 B站。

收费吗? 这四个端点目前在 SandBase 目录里标的是 Free,以目录页当前状态为准。

要先把 BV 号转成 aid 吗? 这条链路不用。one-video 会返回 aid 和 cid,评论端点直接接受 bv_id。如果别的端点需要 aid,可以用 bilibili/web/bv-to-aid。

弹幕接口会返回全部弹幕吗? 不一定。1,404 条弹幕的视频和 stat.danmaku 完全一致;弹幕 3,281 条的视频只返回了 1,200 条。

能按最新排序拉评论吗? 参考里 mode: 2 是按时间排序。本文用的是默认的热度排序(mode: 3)。

小结

一个 BV 号就能拿到 B站的两路反应信号:one-video 告诉你规模并给出 cid,app/video-comments 用整数游标翻页拉热评,comment-reply 打开争论最激烈的楼,video-danmaku 告诉你观众在哪几秒最想说话。记得拆两层、空页重试、把弹幕多的视频当样本看,就能得到一份可以交给模型归纳主题、问题和逐分钟时间线的记录。

其他 B站端点的用法,可以看 B站公开数据 API 总览。准备好了就可以开始: