返回教程列表
教程2026年10月11日5 分钟阅读

哔哩哔哩 Bilibili 视频与字幕 API 教程:搜索、详情与字幕提取

通过 Just One API 搜索哔哩哔哩 Bilibili 视频,关联 BVID、AID 和分 P 的 CID,获取字幕轨道与资源链接。包含下载状态区分和文本保存的 Python 示例。

从视频搜索到可整理的字幕文本

整理哔哩哔哩 Bilibili 教程、课程或产品讲解时,可以先搜索视频,再获取详情和可用字幕。字幕文本便于检索、整理知识点,也可以作为后续 AI 摘要的输入。

Just One API 提供视频搜索、视频详情和视频字幕接口。本教程以“Python 入门”为关键词,选择最多两个视频,查询各自第一个分 P 的字幕轨道,并尝试保存第一个语言轨道的文本。视频有无字幕、语言种类和资源是否可下载,都需要根据实际响应判断。

获取 Token 并测试视频搜索

注册 Just One API 后获取 API Token,通过哔哩哔哩视频搜索在线文档免费测试。一个 Token 可用于多个平台接口,后续按实际调用量付费;可用权限及免费测试次数以账户显示为准。

本文使用三个 GET 接口,都通过 token 参数鉴权。先检查外层业务 code=0;视频搜索结果还包含一层平台响应,脚本也会检查这一层的状态,再读取列表。

第一步:搜索视频并保存 BVID

调用 /api/bilibili/search-video/v2,传入 keyword=Python 入门、page=1 和 order=general。接口还提供 click、pubdate、dm、stow 等排序值,具体含义见视频搜索 V2 文档。

当前公开样例的视频列表在 data.data.result,每条记录的 bvid 用于查询详情。示例只读取一页,并按 BVID 去重后选两个视频。若扩展分页,可以递增 page,结合平台返回的页数与空列表设置停止条件,同时限制总页数。

搜索结果可能包含格式化标题。保存原值之后再生成清洗后的标题,避免误把格式标记当成实际视频内容。

第二步:通过详情关联 AID 和分 P 的 CID

把 BVID 传给视频详情 V2,路径为 /api/bilibili/get-video-detail/v2。当前公开样例的主要视频对象在 data.videoData。

  • bvid:用于本次视频详情请求的视频标识。
  • data.videoData.aid:视频的 AID。
  • data.videoData.pages:分 P 列表,各条目包含自己的 cid、序号和分 P 名称。

字幕属于具体分 P。多 P 视频要使用你选择的那一项 cid,不能把 AID 当成 CID,也不要从响应里的相关推荐视频中取 ID。本例使用 pages 第一项,因此并未覆盖同一视频的所有分 P。

第三步:获取字幕轨道,再下载字幕资源

调用视频字幕 V2,路径为 /api/bilibili/get-video-caption/v2,同时传入 bvid、aid 和 cid。

当前公开响应样例在 data.data 中返回字幕轨道,轨道包含 lan、lan_doc 和 subtitle_url。接口返回的是语言与资源链接,需要再请求资源地址才能尝试得到字幕内容。

资源链接可能带有效期或访问条件。获取到了链接,不代表下载一定成功。示例把无轨道、下载失败、空文本和成功下载分别记录,避免把一次 HTTP 403 误判成视频没有字幕。下载时不向资源服务器发送 Just One API Token。

下方代码只接受 HTTPS 的 hdslb.com 及其子域资源,不自动跟随重定向;下载 JSON 后,检查其是否包含 body 数组及字符串 content 字段,再拼接为文本。实际资源结构不符合这一格式时会记录失败,保留原始 API 结果供排查,而不是猜测字段。

Python 示例:保存视频资料和可用字幕

在 macOS 或 Linux 终端安装依赖并设置 Token,把占位符换成自己的值。

bash
python3 -m pip install requests
export JUSTONEAPI_TOKEN='your_token'

保存为 bilibili_sample.py,运行 python3 bilibili_sample.py。最多一次搜索、两个视频的详情与字幕轨道请求,共 5 次 API 请求,另外最多两次字幕资源下载。脚本只处理每个视频的第一个分 P 和第一个字幕轨道。

python
import json
import os
from datetime import datetime, timezone
from pathlib import Path

import requests

BASE_URL = "https://api.justoneapi.com"
TOKEN = os.environ.get("JUSTONEAPI_TOKEN", "")


def call_api(path, **params):
    try:
        response = requests.get(
            BASE_URL + path,
            params={"token": TOKEN, **params},
            timeout=120, allow_redirects=False,
        )
    except requests.RequestException:
        raise RuntimeError("API network error or timeout") from None
    if response.status_code != 200:
        raise RuntimeError(f"API HTTP {response.status_code}")
    try:
        result = response.json()
    except ValueError:
        raise RuntimeError("API response is not JSON") from None
    if not isinstance(result, dict) or result.get("code") != 0:
        code = result.get("code") if isinstance(result, dict) else None
        raise RuntimeError(f"API failed: code={code}; check the docs")
    data = result.get("data")
    if not isinstance(data, dict):
        raise RuntimeError("Unexpected data shape; inspect the docs")
    inner = data.get("base_resp")
    if isinstance(inner, dict) and inner.get("status_code") not in (0, "0"):
        raise RuntimeError("Platform response was unsuccessful")
    return result


def save_json(folder, name, value):
    (folder / name).write_text(
        json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8"
    )


def require_list(data, key):
    value = data.get(key)
    if not isinstance(value, list):
        raise RuntimeError(f"Expected a list at {key}; inspect saved JSON")
    return value


def output_folder(topic):
    if not TOKEN or TOKEN == "your_token":
        raise RuntimeError("Set JUSTONEAPI_TOKEN before running")
    run_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
    folder = Path(topic + "-output") / run_id
    folder.mkdir(parents=True, exist_ok=False)
    return folder


from urllib.parse import urlparse

KEYWORD = "Python 入门"
MAX_VIDEOS = 2


def download_subtitle(url):
    if url.startswith("//"):
        url = "https:" + url
    parsed = urlparse(url)
    host = parsed.hostname or ""
    if (parsed.scheme != "https" or parsed.username or parsed.password
            or parsed.port not in (None, 443)
            or not (host == "hdslb.com" or host.endswith(".hdslb.com"))):
        raise RuntimeError("Subtitle host needs manual review")
    try:
        response = requests.get(url, timeout=30, allow_redirects=False)
    except requests.RequestException:
        raise RuntimeError("Subtitle download failed") from None
    if response.status_code != 200:
        raise RuntimeError(f"Subtitle HTTP {response.status_code}")
    try:
        payload = response.json()
    except ValueError:
        raise RuntimeError("Subtitle response is not JSON") from None
    if not isinstance(payload, dict):
        raise RuntimeError("Unexpected subtitle document")
    segments = require_list(payload, "body")
    if any(not isinstance(s, dict) or not isinstance(s.get("content"), str)
           for s in segments):
        raise RuntimeError("Unexpected subtitle segment")
    return payload, "\n".join(s["content"] for s in segments)


def main():
    folder = output_folder("bilibili")
    result = call_api(
        "/api/bilibili/search-video/v2", keyword=KEYWORD,
        page="1", order="general",
    )
    save_json(folder, "search-1.json", result)
    search = result["data"]
    if search.get("code") != 0 or not isinstance(search.get("data"), dict):
        raise RuntimeError("Video search was unsuccessful")
    rows = require_list(search["data"], "result")
    ids, seen = [], set()
    for row in rows:
        bvid = row.get("bvid") if isinstance(row, dict) else None
        if bvid and str(bvid) not in seen:
            seen.add(str(bvid))
            ids.append(str(bvid))
    selected = ids[:MAX_VIDEOS]
    save_json(folder, "selected-videos.json", selected)
    outcomes = []
    for index, bvid in enumerate(selected, start=1):
        detail = call_api("/api/bilibili/get-video-detail/v2", bvid=bvid)
        save_json(folder, f"video-{index}-detail.json", detail)
        video = detail["data"].get("videoData")
        if not isinstance(video, dict) or not video.get("aid"):
            raise RuntimeError("Missing videoData/AID; inspect saved JSON")
        pages = require_list(video, "pages")
        if not pages or not isinstance(pages[0], dict) or not pages[0].get("cid"):
            raise RuntimeError("Missing segment CID; inspect saved JSON")
        cid = str(pages[0]["cid"])
        captions = call_api(
            "/api/bilibili/get-video-caption/v2",
            bvid=bvid, aid=str(video["aid"]), cid=cid,
        )
        save_json(folder, f"video-{index}-captions.json", captions)
        tracks = require_list(captions["data"], "data")
        if not tracks:
            outcomes.append({"bvid": bvid, "cid": cid, "status": "no_tracks"})
            continue
        track = tracks[0]
        url = track.get("subtitle_url") if isinstance(track, dict) else None
        if not isinstance(url, str) or not url:
            raise RuntimeError("Missing subtitle URL; inspect saved JSON")
        outcome = {"bvid": bvid, "cid": cid, "language": track.get("lan")}
        try:
            payload, text = download_subtitle(url)
        except (RuntimeError, ValueError) as error:
            outcome.update(status="download_failed", error=str(error))
        else:
            save_json(folder, f"video-{index}-subtitle.json", payload)
            (folder / f"video-{index}-subtitle.txt").write_text(text, encoding="utf-8")
            outcome["status"] = "downloaded" if text else "empty_text"
        outcomes.append(outcome)
    save_json(folder, "subtitle-outcomes.json", outcomes)
    print(f"Saved {len(selected)} video samples to {folder}")


if __name__ == "__main__":
    try:
        main()
    except RuntimeError as error:
        raise SystemExit(str(error)) from None

用输出状态判断下一步

结果写入 bilibili-output 下的独立时间目录。subtitle-outcomes.json 汇总每个视频的字幕处理结果:

  • downloaded:已下载并保存非空文本,可查看对应的 video-*-subtitle.txt。
  • empty_text:资源结构被识别,但拼接后的文本为空。
  • no_tracks:当次字幕接口返回空轨道列表。
  • download_failed:有轨道但资源下载或解析未完成,应结合错误信息与原始响应排查。

成功时还会保存完整字幕 JSON,便于保留时间片段等信息;详情与字幕轨道 API 响应也单独保存。资源下载失败可以先核对链接时效与访问条件,再决定是否重新获取轨道,避免对过期地址无限重试。

把字幕交给 AI 整理

取得非空文本后,可以按段落或时间片段提交给 AI,要求总结主题并保留引用位置。建议使用这样的提示词:

text
请仅根据提供的字幕整理本视频的主要知识点。
列出每个知识点对应的原文片段;若提供了时间信息,也保留时间位置。
字幕没有说明的内容标为“字幕未说明”,不要自行补充。

自动字幕可能包含识别错误,视频画面中的信息也可能未出现在字幕里。重要结论仍需回看原视频。本文脚本只负责采集和保存,没有调用额外 AI 服务。

常见问题

可以获取没有字幕的视频文本吗?

本流程读取现有字幕轨道,不执行语音转写。空轨道响应不能直接生成文本;如果需要转写,应另行确认可用音频及相应处理流程。

多 P 和多语言怎么处理?

遍历详情的 pages,用每一项自己的 CID 请求字幕,再按 lan 选择需要的语言。扩大范围时,请重新计算接口调用和资源下载数量,而不是假设一次请求覆盖整个合集。

获取到字幕链接为什么还会失败?

API 查询与字幕资源访问是两个独立步骤。保存两者的状态,检查时效、访问条件和响应格式,再按需重试。通用 API 错误处理可参考接口失败后的处理策略。

从视频搜索文档找到一个熟悉的视频,核对它的分 P 与字幕语言,再运行完整示例。

继续使用 Just One API

登录 Dashboard 获取 Token,查看完整 API 文档,或打开 MCP GitHub 项目了解最新配置。