
Bilibili Cli
- 589 installs
- 957 repo stars
- Updated March 14, 2026
- jackwener/bilibili-cli
bilibili-cli is a Claude Code skill that downloads Bilibili videos, fetches metadata, and scrapes public content from the terminal or inside agent workflows for developers automating Bilibili media pipelines.
About
bilibili-cli is a terminal skill from jackwener/bilibili-cli ranked 14 on skills.sh with 455 installs, providing CLI commands to download videos, fetch metadata, and scrape public Bilibili content. Developers invoke bilibili-cli from the shell or embed it in agent workflows when they need programmatic access to Bilibili media without manual browser downloads. The skill suits data collection, archival, and content pipeline automation where Bilibili is the source platform. Reach for bilibili-cli when terminal-based video retrieval or metadata extraction must run inside coding agent sessions or scripted workflows.
- Terminal-first Bilibili video and metadata downloader
- Supports batch operations and multiple quality options
- Integrates cleanly with agentic coding tools and shell scripts
- 455 installs on skills.sh
- Lightweight GitHub-hosted CLI with no heavy dependencies
Bilibili Cli by the numbers
- 589 all-time installs (skills.sh)
- +16 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #110 of 550 CLI & Terminal skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/jackwener/bilibili-cli --skill bilibili-cliAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 589 |
|---|---|
| repo stars | ★ 957 |
| Last updated | March 14, 2026 |
| Repository | jackwener/bilibili-cli ↗ |
How do you download Bilibili videos from CLI?
Download videos, fetch metadata, or scrape public Bilibili content directly from the terminal or inside an agent workflow.
Who is it for?
Developers automating Bilibili video downloads or metadata extraction from the terminal or inside coding agent pipelines.
Skip if: Developers who only need YouTube or Vimeo downloads without any Bilibili-specific public content requirements.
When should I use this skill?
A workflow needs Bilibili video download, metadata fetch, or public content scraping from the terminal or an agent session.
What you get
Downloaded Bilibili video files, extracted metadata records, and scraped public content datasets from terminal commands.
- Downloaded video files
- Bilibili metadata JSON
By the numbers
- 455 installs listed on skills.sh catalog
- Ranked 14 on skills.sh for jackwener/bilibili-cli
Files
bilibili-cli Skill
A CLI tool for interacting with Bilibili (哔哩哔哩). Use it to fetch video info, search content, browse user profiles, and perform interactions like liking or triple-clicking.
Agent Defaults
When you need machine-readable output:
1. Prefer --yaml first because it is usually more token-efficient than pretty JSON. 2. Use --json only when downstream tooling strictly requires JSON. 3. Keep result sets small with --max, --page, or --offset. 4. Prefer specific commands over broad ones. Example: use bili user-videos 946974 --max 3 --yaml instead of fetching large timelines. 5. When summarizing a video, fetch subtitles first. Subtitles usually contain the video's core content and are the best primary source for summaries. 6. Only fall back to --ai, comments, or audio extraction when subtitles are unavailable or clearly insufficient.
Prerequisites
# Install (requires Python 3.10+)
uv tool install bilibili-cli
# Or: pipx install bilibili-cli
# If you need audio extraction support (requires PyAV)
uv tool install "bilibili-cli[audio]"
# Or: pipx install "bilibili-cli[audio]"
# Upgrade to latest (recommended to avoid API errors)
uv tool upgrade bilibili-cli
# Or: pipx upgrade bilibili-cliAuthentication
Most read commands work without login. Subtitles, favorites/following/watch-later/history, feed, and interactions require login.
bili status # Check if logged in (exit 0 = yes, 1 = no)
bili login # QR code login (if not authenticated)Authentication auto-detects local browser cookies (Chrome/Firefox/Edge/Brave). If cookies are found and valid, no manual login needed. Credentials are saved to ~/.bilibili-cli/credential.json.
Command Reference
Video
# Get video details (accepts BV ID or full URL)
bili video BV1ABcsztEcY
bili video https://www.bilibili.com/video/BV1ABcsztEcY
# Options
bili video BV1ABcsztEcY --subtitle # Show subtitles (plain text)
bili video BV1ABcsztEcY --subtitle-timeline # Show subtitles with timestamps
bili video BV1ABcsztEcY -st --subtitle-format srt # Export as SRT format
bili video BV1ABcsztEcY --ai # Show B站 AI summary
bili video BV1ABcsztEcY --comments # Show top comments
bili video BV1ABcsztEcY --related # Show related videos
bili video BV1ABcsztEcY --yaml # Token-efficient YAML output
bili video BV1ABcsztEcY --json # Structured JSON envelopeUser
# Look up user profile (by UID or username)
bili user 946974
bili user "影视飓风"
# List user's videos
bili user-videos 946974 --max 20
bili user-videos "影视飓风" --yamlSearch
# Search users (default)
bili search "关键词"
# Search videos
bili search "关键词" --type video
# Pagination and limit
bili search "关键词" --type video --max 5
bili search "关键词" --page 2Discovery
bili hot # Trending/popular videos
bili hot --page 2 --max 10 # Page 2, limit 10
bili rank # Site-wide ranking (3-day)
bili rank --day 7 --max 30 # 7-day ranking, top 30
bili feed # Dynamic timeline (requires login)
bili feed --offset 1234567890 # Next page via returned cursor
bili my-dynamics # My posted dynamics (requires login)
bili dynamic-post "hello" # Publish text dynamic (requires write credential)
bili dynamic-delete 123456789 # Delete one dynamic (requires write credential)Collections (require login)
bili favorites # List favorite folders
bili favorites <ID> --page 2 # Videos in a folder
bili following # Following list
bili watch-later # Watch later list
bili history # Watch historyAudio Extraction
Requires bilibili-cli[audio] extra (PyAV). Install with uv tool install "bilibili-cli[audio]".
# Download audio and split into ASR-ready WAV segments (25s each, 16kHz mono)
bili audio BV1ABcsztEcY # Split to /tmp/bilibili-cli/{title}/
bili audio BV1ABcsztEcY --segment 60 # 60s per segment
bili audio BV1ABcsztEcY --no-split # Full m4a file, no splitting
bili audio BV1ABcsztEcY -o ~/data/ # Custom output directoryInteractions (require login)
bili like BV1ABcsztEcY # Like a video
bili like BV1ABcsztEcY --undo # Unlike
bili coin BV1ABcsztEcY # Give 1 coin
bili coin BV1ABcsztEcY -n 2 # Give 2 coins
bili triple BV1ABcsztEcY # 一键三连 (like + coin + favorite)
bili unfollow 946974 # Unfollow by UIDAccount
bili status # Quick login check
bili status --yaml # Structured auth status
bili whoami # Detailed profile info
bili whoami --yaml # Profile as YAML
bili whoami --json # Profile as JSON
bili login # QR code login
bili logout # Clear credentialsStructured Output
Major query commands support both --yaml and --json for machine-readable output. Prefer YAML for agent use:
bili status --yaml # Quick structured auth check
bili video BV1ABcsztEcY --yaml # Preferred for AI agents
bili hot --max 5 --yaml # Smaller, token-efficient payload
bili user 946974 --json | jq -r '.data.user.name' # JSON when jq is neededWhen stdout is not a TTY, bilibili-cli defaults to YAML automatically. Use OUTPUT=yaml|json|rich|auto to override the default output mode. All machine-readable output uses the envelope documented in SCHEMA.md.
Debugging
bili -v <command> # Enable verbose/debug logging for any commandCommon Patterns for AI Agents
# For video summarization, fetch subtitles first
bili video BV1ABcsztEcY --subtitle
# Only use AI summary as a fallback or secondary signal
bili video BV1ABcsztEcY --ai
# Get comments for sentiment analysis
bili video BV1ABcsztEcY --comments
# Extract audio for speech-to-text (ASR)
# Segments are saved to /tmp/bilibili-cli/{title}/seg_000.wav, seg_001.wav, ...
bili audio BV1ABcsztEcY --segment 25
# Find a user's latest video BV ID with minimal payload
bili user-videos 946974 --max 1 --yaml
# Check if logged in before performing actions
bili status && bili like BV1ABcsztEcY
# Search and inspect the first few results
bili search "topic" --type video --max 3 --yamlWorkflow: Video Content Analysis
# 1. Search for a topic
bili search "AI" --type video --max 5
# 2. Get subtitles first for summarization
bili video BV1xxx --subtitle
# 3. If subtitles are missing or incomplete, try AI summary
bili video BV1xxx --ai
# 4. If there is still not enough content, extract audio for ASR
bili audio BV1xxx --segment 25
# 5. Get comments for audience reaction
bili video BV1xxx --commentsWorkflow: UP主 Research
# 1. Look up UP主 profile
bili user "影视飓风"
# 2. Get their recent videos
bili user-videos 946974 --max 10
# 3. Inspect a specific video
bili video BV1xxx --ai --commentsError Handling
- Commands exit with code 0 on success, non-zero on failure
- Error messages are prefixed with ❌
- Login-required commands show ⚠️ with instruction to run
bili login - Invalid BV IDs show a clear error message
Safety Notes
- Do not ask users to share raw credential/cookie values in chat logs.
- Prefer local browser cookie extraction over manual secret copy/paste.
- If auth fails, ask the user to re-login via
bili login.
name: Bug Report
description: Report a bug or unexpected behavior
labels: ["bug"]
body:
- type: input
id: version
attributes:
label: Version
description: "Run `bilibili --version` or `pip show bilibili-cli | grep Version`"
placeholder: "e.g. 1.0.0"
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating System
options:
- macOS
- Linux
- Windows
- Other
validations:
required: true
- type: textarea
id: description
attributes:
label: What happened?
description: Describe the bug clearly.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What did you expect to happen?
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Steps to reproduce
description: "Commands or steps to reproduce the issue. Use `bilibili -v <command>` for debug output."
render: bash
- type: textarea
id: logs
attributes:
label: Error output / logs
description: Paste any error messages or verbose output here.
render: text
name: Feature Request
description: Suggest a new feature or improvement
labels: ["enhancement"]
body:
- type: input
id: version
attributes:
label: Current version
description: "Run `bilibili --version` or `pip show bilibili-cli | grep Version`"
placeholder: "e.g. 1.0.0"
validations:
required: false
- type: textarea
id: description
attributes:
label: Describe the feature
description: What would you like to see added or changed?
validations:
required: true
- type: textarea
id: use_case
attributes:
label: Use case
description: Why do you need this feature? How would you use it?
name: CI
on:
workflow_call:
pull_request:
push:
branches:
- main
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync --extra dev
- name: Ruff
run: uv run ruff check .
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync --extra dev
- name: Mypy
run: uv run mypy bili_cli
test:
needs: [lint, typecheck]
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync --extra dev
- name: Run tests
run: uv run pytest tests/ -v
name: Publish to PyPI
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
verify:
uses: ./.github/workflows/ci.yml
publish:
needs: verify
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Build
run: uv build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
# Python
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
dist/
build/
*.egg
# Virtual environments
.venv/
venv/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
# uv
"""bilibili-cli — browse Bilibili from the terminal."""
try:
from importlib.metadata import version
__version__ = version("bilibili-cli")
except Exception:
__version__ = "0.0.0"
"""Authentication for Bilibili.
Strategy:
1. Try loading saved credential from ~/.bilibili-cli/credential.json
2. Try extracting cookies from local browsers via browser-cookie3
3. Fallback: QR code login via bilibili-api-python + terminal display
"""
from __future__ import annotations
import asyncio
import json
import logging
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import Literal
import qrcode
from bilibili_api.login_v2 import QrCodeLogin, QrCodeLoginEvents
from bilibili_api.utils.network import Credential
logger = logging.getLogger(__name__)
CONFIG_DIR = Path.home() / ".bilibili-cli"
CREDENTIAL_FILE = CONFIG_DIR / "credential.json"
# Required cookies for a valid Bilibili session
REQUIRED_COOKIES = {"SESSDATA"}
# Extra cookie fields that help bypass Bilibili's 412 anti-scraping checks
EXTRA_COOKIE_FIELDS = ("buvid3", "buvid4", "dedeuserid")
# Credential TTL: warn and attempt refresh after 7 days
CREDENTIAL_TTL_DAYS = 7
_CREDENTIAL_TTL_SECONDS = CREDENTIAL_TTL_DAYS * 86400
AuthMode = Literal["optional", "read", "write"]
def get_credential(mode: AuthMode = "read") -> Credential | None:
"""Try auth methods in order and return credential according to mode.
- optional: only load saved credential (no network validation, no browser scan)
- read: prefer validated credential; if validation is indeterminate (network),
return saved/browser credential as best effort
- write: same as read, but require bili_jct capability
"""
require_write = mode == "write"
# 1. Saved credential file
cred = _load_saved_credential()
if cred:
# Check TTL — try to refresh from browser if stale
if _is_credential_stale():
logger.info("Credential older than %d days, attempting browser refresh", CREDENTIAL_TTL_DAYS)
fresh = _extract_browser_credential()
if fresh:
validation = _validate_credential(fresh, require_write=require_write)
if validation is True:
logger.info("Refreshed credential from browser")
save_credential(fresh)
return fresh
# Refresh failed — validate existing credential
logger.warning(
"Credential is %d+ days old; browser refresh failed. Validating existing credential...",
CREDENTIAL_TTL_DAYS,
)
if mode == "optional":
return cred
validation = _validate_credential(cred, require_write=require_write)
if validation is True:
logger.info("Loaded valid credential from %s", CREDENTIAL_FILE)
return cred
if validation is None:
logger.warning("Credential validation failed due to network; using saved credential as best effort")
return cred
if validation is False:
logger.warning("Saved credential is expired, clearing")
clear_credential()
if mode == "optional":
return None
# 2. Browser cookie extraction
cred = _extract_browser_credential()
if cred:
validation = _validate_credential(cred, require_write=require_write)
if validation is True:
logger.info("Extracted valid credential from local browser")
save_credential(cred)
return cred
if validation is None:
logger.warning("Skipping browser credential validation due to network; using best effort")
return cred
if validation is False:
logger.warning("Browser cookies are expired/invalid")
return None
def _is_credential_stale() -> bool:
"""Check if saved credential file is older than TTL."""
if not CREDENTIAL_FILE.exists():
return False
try:
data = json.loads(CREDENTIAL_FILE.read_text())
saved_at = data.get("saved_at", 0)
if not saved_at:
# Legacy file without saved_at — treat as stale to add the field
return True
return (time.time() - saved_at) > _CREDENTIAL_TTL_SECONDS
except (json.JSONDecodeError, OSError):
return False
def _validate_credential(cred: Credential, require_write: bool = False) -> bool | None:
"""Check if a credential is valid.
Returns:
- True: credential validated by API
- False: credential confirmed invalid or missing required fields
- None: validation is indeterminate due to network/runtime issues
"""
from bilibili_api import user
from bilibili_api.exceptions import NetworkException
if not getattr(cred, "sessdata", ""):
return False
if require_write and not getattr(cred, "bili_jct", ""):
return False
async def _check():
try:
await user.get_self_info(cred)
return True
except NetworkException:
return None
except Exception:
return False
try:
return asyncio.run(_check())
except Exception:
return None
def _load_saved_credential() -> Credential | None:
"""Load credential from saved file."""
if not CREDENTIAL_FILE.exists():
return None
try:
data = json.loads(CREDENTIAL_FILE.read_text())
sessdata = data.get("sessdata", "")
if not sessdata:
return None
return Credential(
sessdata=sessdata,
bili_jct=data.get("bili_jct", ""),
ac_time_value=data.get("ac_time_value", ""),
buvid3=data.get("buvid3", ""),
buvid4=data.get("buvid4", ""),
dedeuserid=data.get("dedeuserid", ""),
)
except (json.JSONDecodeError, KeyError) as e:
logger.warning("Failed to load saved credential: %s", e)
return None
def _extract_browser_credential() -> Credential | None:
"""Extract Bilibili cookies from local browsers using browser-cookie3.
Runs extraction in a subprocess with timeout to avoid hanging
when the browser is running (Chrome DB lock issue).
"""
extract_script = '''
import json, sys
try:
import browser_cookie3 as bc3
except ImportError:
print(json.dumps({"error": "not_installed"}))
sys.exit(0)
browsers = [
("Chrome", bc3.chrome),
("Firefox", bc3.firefox),
("Edge", bc3.edge),
("Brave", bc3.brave),
]
for name, loader in browsers:
try:
cj = loader(domain_name=".bilibili.com")
cookies = {c.name: c.value for c in cj if "bilibili.com" in (c.domain or "")}
if "SESSDATA" in cookies:
print(json.dumps({"browser": name, "cookies": cookies}))
sys.exit(0)
except Exception:
pass
print(json.dumps({"error": "no_cookies"}))
'''
try:
result = subprocess.run(
[sys.executable, "-c", extract_script],
capture_output=True,
text=True,
timeout=15,
)
if result.returncode != 0:
logger.debug("Cookie extraction subprocess failed: %s", result.stderr)
return None
output = result.stdout.strip()
if not output:
logger.debug("Cookie extraction returned empty output")
return None
data = json.loads(output)
if "error" in data:
if data["error"] == "not_installed":
logger.debug("browser-cookie3 not installed, skipping")
else:
logger.debug("No valid Bilibili cookies found in any browser")
return None
cookies = data["cookies"]
browser_name = data["browser"]
if not REQUIRED_COOKIES.issubset(cookies):
logger.debug("Browser cookies missing required keys: %s", REQUIRED_COOKIES)
return None
logger.info(
"Found valid cookies in %s (%d cookies)", browser_name, len(cookies)
)
return Credential(
sessdata=cookies.get("SESSDATA", ""),
bili_jct=cookies.get("bili_jct", ""),
ac_time_value=cookies.get("ac_time_value", ""),
buvid3=cookies.get("buvid3", ""),
buvid4=cookies.get("buvid4", ""),
dedeuserid=cookies.get("DedeUserID", ""),
)
except subprocess.TimeoutExpired:
logger.warning(
"Cookie extraction timed out (browser may be running). "
"Try closing your browser or use `bili login`."
)
return None
except (json.JSONDecodeError, KeyError) as e:
logger.warning("Cookie extraction parse error: %s", e)
return None
def save_credential(credential: Credential):
"""Save credential to config file with timestamp for TTL tracking."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
data = {
"sessdata": credential.sessdata,
"bili_jct": credential.bili_jct,
"ac_time_value": credential.ac_time_value or "",
"buvid3": credential.buvid3 or "",
"buvid4": credential.buvid4 or "",
"dedeuserid": credential.dedeuserid or "",
"saved_at": time.time(),
}
CREDENTIAL_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
CREDENTIAL_FILE.chmod(0o600) # Owner-only read/write
logger.info("Credential saved to %s", CREDENTIAL_FILE)
def clear_credential():
"""Remove saved credential file."""
if CREDENTIAL_FILE.exists():
CREDENTIAL_FILE.unlink()
logger.info("Credential removed: %s", CREDENTIAL_FILE)
def _supports_unicode_half_blocks() -> bool:
"""Return True when stdout encoding can represent half-block glyphs."""
encoding = getattr(sys.stdout, "encoding", None)
if not encoding:
return False
try:
"▀▄█".encode(encoding)
except (LookupError, UnicodeEncodeError):
return False
return True
def _render_compact_qr(data: str) -> str | None:
"""Render a compact QR code using Unicode half-block characters.
Uses ▀, ▄, █, and space to encode two vertical modules per character row,
reducing the QR code height by half compared to full-block rendering.
Each module is 1 character wide (vs 2 in qrcode-terminal), so total area
is ~25% of the original.
"""
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L)
qr.add_data(data)
qr.make(fit=True)
matrix = qr.get_matrix()
# Add 1-module quiet zone
size = len(matrix)
padded = [[False] * (size + 2)]
for row in matrix:
padded.append([False] + list(row) + [False])
padded.append([False] * (size + 2))
matrix = padded
rows = len(matrix)
# Check terminal width and warn if too narrow
term_cols = shutil.get_terminal_size(fallback=(80, 24)).columns
qr_width = len(matrix[0])
if qr_width > term_cols:
logger.warning(
"Terminal width (%d) too narrow for compact QR (%d), falling back",
term_cols,
qr_width,
)
return None
lines: list[str] = []
# Process two rows at a time using half-block characters
# top=black, bottom=black → █ (full block)
# top=black, bottom=white → ▀ (upper half)
# top=white, bottom=black → ▄ (lower half)
# top=white, bottom=white → ' ' (space)
for y in range(0, rows, 2):
line = ""
top_row = matrix[y]
bottom_row = matrix[y + 1] if y + 1 < rows else [False] * len(top_row)
for x in range(len(top_row)):
top = top_row[x]
bottom = bottom_row[x]
if top and bottom:
line += "█"
elif top and not bottom:
line += "▀"
elif not top and bottom:
line += "▄"
else:
line += " "
lines.append(line)
return "\n".join(lines)
def _get_qr_terminal_output(login: QrCodeLogin) -> str:
"""Choose compact QR rendering when possible, otherwise use default output."""
default_qr = login.get_qrcode_terminal()
qr_link = getattr(login, "_QrCodeLogin__qr_link", None)
if not qr_link:
logger.warning("QR link unavailable from QrCodeLogin internals, using default renderer")
return default_qr
if not _supports_unicode_half_blocks():
logger.warning("stdout encoding cannot render Unicode QR blocks, using default renderer")
return default_qr
compact_qr = _render_compact_qr(qr_link)
if compact_qr is None:
return default_qr
return compact_qr
async def qr_login() -> Credential:
"""QR code login via terminal.
Displays a QR code in the terminal, polls until login completes,
then saves and returns the credential.
"""
login = QrCodeLogin()
await login.generate_qrcode()
# Display QR code in terminal
print("\n📱 请使用 Bilibili App 扫描以下二维码登录:\n")
print(_get_qr_terminal_output(login))
print("\n⭐ 扫码后请在手机上确认登录...")
# Poll login state
while True:
state = await login.check_state()
if state == QrCodeLoginEvents.DONE:
credential = login.get_credential()
save_credential(credential)
print("\n✅ 登录成功!凭证已保存")
return credential
elif state == QrCodeLoginEvents.TIMEOUT:
raise RuntimeError("二维码已过期,请重试")
elif state == QrCodeLoginEvents.CONF:
print(" 📲 已扫码,请在手机上确认...")
await asyncio.sleep(2)
"""CLI entry point for bilibili-cli.
Usage:
bili login / logout / status / whoami
bili video <BV号或URL> [--subtitle] [--ai] [--comments] [--related] [--yaml|--json]
bili user <UID或用户名> bili user-videos <UID> [--max N]
bili search <关键词> [--type user|video] [--yaml|--json]
bili hot / rank / feed / my-dynamics / following / history / watch-later / favorites
bili dynamic-post <TEXT> / dynamic-delete <动态ID>
bili like / coin / triple <BV号> / unfollow <UID>
bili audio <BV号> [--segment N] [--no-split] [-o DIR]
"""
from __future__ import annotations
import click
from . import __version__
from .commands import account, audio, collections, common, discovery, interactions, user_search, video
# Keep helper names for backward compatibility with tests/importers.
def _format_duration(seconds: int) -> str:
return common.format_duration(seconds)
def _format_count(n: int) -> str:
return common.format_count(n)
@click.group()
@click.version_option(version=__version__, prog_name="bili")
@click.option("-v", "--verbose", is_flag=True, help="Enable debug logging.")
def cli(verbose: bool):
"""bili — Bilibili CLI tool 📺"""
common.setup_logging(verbose)
# Register commands.
cli.add_command(account.login)
cli.add_command(account.logout)
cli.add_command(account.status)
cli.add_command(account.whoami)
cli.add_command(video.video)
cli.add_command(user_search.user)
cli.add_command(user_search.user_videos)
cli.add_command(user_search.search)
cli.add_command(collections.favorites)
cli.add_command(collections.following)
cli.add_command(collections.history)
cli.add_command(collections.watch_later)
cli.add_command(collections.feed)
cli.add_command(collections.my_dynamics)
cli.add_command(collections.dynamic_post)
cli.add_command(collections.dynamic_delete)
cli.add_command(discovery.hot_cmd)
cli.add_command(discovery.rank_cmd)
cli.add_command(interactions.like)
cli.add_command(interactions.coin)
cli.add_command(interactions.triple)
cli.add_command(interactions.unfollow)
cli.add_command(audio.audio)
if __name__ == "__main__":
cli()
"""Bilibili API client — thin async wrappers around bilibili-api-python.
All public functions are async and accept an optional Credential for
authenticated operations (subtitles, favorites, etc.).
"""
from __future__ import annotations
import asyncio
import logging
import os
import re
from typing import Any
import aiohttp
from bilibili_api import comment, dynamic, favorite_list, homepage, hot, rank, search, user, video
from bilibili_api.exceptions import (
ApiException,
CredentialNoBiliJctException,
CredentialNoSessdataException,
NetworkException,
ResponseCodeException,
ResponseException,
)
from bilibili_api.utils.network import Credential
from .exceptions import AuthenticationError, BiliError, InvalidBvidError, NetworkError, NotFoundError, RateLimitError
logger = logging.getLogger(__name__)
_USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/133.0.0.0 Safari/537.36"
)
# ---------------------------------------------------------------------------
# BV ID helpers
# ---------------------------------------------------------------------------
_BVID_RE = re.compile(r"\bBV[0-9A-Za-z]{10}\b")
def extract_bvid(url_or_bvid: str) -> str:
"""Extract BV ID from a Bilibili URL or return as-is if already a BV ID."""
match = _BVID_RE.search(url_or_bvid)
if match:
return match.group(0)
raise InvalidBvidError(f"无法提取 BV 号: {url_or_bvid}")
def _map_api_error(action: str, exc: Exception) -> BiliError:
"""Map third-party API exceptions into stable local exception types."""
if isinstance(exc, BiliError):
return exc
if isinstance(exc, (CredentialNoSessdataException, CredentialNoBiliJctException)):
return AuthenticationError(f"{action}: {exc}")
if isinstance(exc, ResponseCodeException):
code = getattr(exc, "code", None)
# Auth failures
if code in {-101, -111}:
return AuthenticationError(f"{action}: {exc}")
# Not found
if code in {-404, 62002, 62004}:
return NotFoundError(f"{action}: {exc}")
# Rate limit / anti-scraping
if code in {-412, 412}:
return RateLimitError(f"{action}: {exc}")
return BiliError(f"{action}: [{code}] {exc}")
if isinstance(exc, (NetworkException, ResponseException, aiohttp.ClientError, asyncio.TimeoutError)):
return NetworkError(f"{action}: {exc}")
if isinstance(exc, ApiException):
return BiliError(f"{action}: {exc}")
return BiliError(f"{action}: {exc}")
async def _call_api(action: str, awaitable):
"""Run an awaitable and normalize API/network/auth errors."""
try:
return await awaitable
except Exception as exc:
raise _map_api_error(action, exc) from exc
# ---------------------------------------------------------------------------
# Video
# ---------------------------------------------------------------------------
async def get_video_info(bvid: str, credential: Credential | None = None) -> dict[str, Any]:
"""Fetch video metadata (title, duration, stats, owner, etc.)."""
v = video.Video(bvid=bvid, credential=credential)
return await _call_api("获取视频信息", v.get_info())
def format_subtitle_timeline(
raw: list[dict[str, Any]] | None,
output_format: str = "timeline",
) -> str:
"""Format subtitle items with timestamps."""
if not raw:
return ""
if output_format == "srt":
lines: list[str] = []
for index, item in enumerate(raw, 1):
lines.append(str(index))
lines.append(
f"{_format_subtitle_srt_time(item.get('from', 0.0))} --> "
f"{_format_subtitle_srt_time(item.get('to', 0.0))}"
)
lines.append(item.get("content", ""))
lines.append("")
return "\n".join(lines)
return "\n".join(
(
f"[{_format_subtitle_time(item.get('from', 0.0))} --> "
f"{_format_subtitle_time(item.get('to', 0.0))}] "
f"{item.get('content', '')}"
)
for item in raw
)
def _format_subtitle_time(seconds: float) -> str:
"""Format seconds as MM:SS.mmm."""
minutes = int(seconds // 60)
secs = seconds % 60
return f"{minutes:02d}:{secs:06.3f}"
def _format_subtitle_srt_time(seconds: float) -> str:
"""Format seconds as HH:MM:SS,mmm for SRT output."""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = seconds % 60
return f"{hours:02d}:{minutes:02d}:{secs:06.3f}".replace(".", ",")
async def get_video_subtitle(
bvid: str, credential: Credential | None = None
) -> tuple[str, list]:
"""Fetch video subtitle content.
Returns (plain_text, raw_subtitle_items).
An empty tuple element means no subtitle available.
"""
v = video.Video(bvid=bvid, credential=credential)
# Get cid from first page
pages = await _call_api("获取视频分P信息", v.get_pages())
if not pages:
logger.warning("No pages found for %s", bvid)
return "", []
cid = pages[0].get("cid")
if not cid:
logger.warning("No cid found for %s", bvid)
return "", []
# Get subtitle list from player info
player_info = await _call_api("获取播放器信息", v.get_player_info(cid=cid))
subtitle_info = player_info.get("subtitle", {})
if not subtitle_info or not subtitle_info.get("subtitles"):
return "", []
subtitle_list = subtitle_info["subtitles"]
# Prefer Chinese subtitles
subtitle_url = None
for sub in subtitle_list:
if "zh" in sub.get("lan", "").lower():
subtitle_url = sub.get("subtitle_url", "")
break
if not subtitle_url and subtitle_list:
subtitle_url = subtitle_list[0].get("subtitle_url", "")
if not subtitle_url:
return "", []
# Ensure absolute URL
if subtitle_url.startswith("//"):
subtitle_url = "https:" + subtitle_url
# Download subtitle JSON
try:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(subtitle_url) as resp:
resp.raise_for_status()
subtitle_data = await resp.json(content_type=None)
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError) as e:
raise NetworkError(f"下载字幕失败: {e}") from e
if "body" in subtitle_data:
raw = subtitle_data["body"]
texts = [item.get("content", "") for item in raw]
return "\n".join(texts), raw
return "", []
# ---------------------------------------------------------------------------
# User
# ---------------------------------------------------------------------------
async def get_user_info(uid: int, credential: Credential | None = None) -> dict[str, Any]:
"""Fetch user profile information."""
u = user.User(uid=uid, credential=credential)
return await _call_api("获取用户信息", u.get_user_info())
async def get_user_relation_info(uid: int, credential: Credential | None = None) -> dict[str, Any]:
"""Fetch user relation stats (follower count, following count)."""
u = user.User(uid=uid, credential=credential)
return await _call_api("获取用户关系信息", u.get_relation_info())
async def get_user_videos(
uid: int, count: int = 10, credential: Credential | None = None
) -> list[dict[str, Any]]:
"""Fetch a user's latest videos.
Returns list of video dicts (bvid, title, play, length, etc.).
"""
u = user.User(uid=uid, credential=credential)
results: list[dict[str, Any]] = []
page = 1
per_page = min(count, 50)
while len(results) < count:
try:
data = await _call_api("获取用户视频列表", u.get_videos(ps=per_page, pn=page))
except BiliError as e:
if page == 1:
raise
logger.warning("Failed to get videos page %d: %s", page, e)
break
vlist = data.get("list", {}).get("vlist", [])
if not vlist:
break
for v in vlist:
results.append(v)
if len(results) >= count:
break
page += 1
if page > 20:
break
return results
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
async def search_user(keyword: str, page: int = 1) -> list[dict[str, Any]]:
"""Search for users by keyword.
Returns list of user result dicts.
"""
res = await _call_api("搜索用户", search.search_by_type(
keyword=keyword,
search_type=search.SearchObjectType.USER,
page=page,
))
return res.get("result", [])
# ---------------------------------------------------------------------------
# Favorites
# ---------------------------------------------------------------------------
async def get_self_info(credential: Credential) -> dict[str, Any]:
"""Get logged-in user's own info."""
return await _call_api("获取当前登录用户信息", user.get_self_info(credential))
async def get_favorite_list(credential: Credential) -> list[dict[str, Any]]:
"""List all favorite folders for the logged-in user."""
me = await get_self_info(credential)
uid = me.get("mid")
if uid is None:
raise BiliError("获取收藏夹列表: 当前用户信息缺少 mid")
fav_data = await _call_api(
"获取收藏夹列表",
favorite_list.get_video_favorite_list(uid=uid, credential=credential),
)
return fav_data.get("list", [])
async def get_favorite_videos(
fav_id: int, credential: Credential, page: int = 1
) -> dict[str, Any]:
"""Get videos in a specific favorite folder.
Returns the raw response dict with 'medias', 'has_more', etc.
"""
return await _call_api(
"获取收藏夹内容",
favorite_list.get_video_favorite_list_content(
media_id=fav_id, page=page, credential=credential
),
)
# ---------------------------------------------------------------------------
# Hot & Rank
# ---------------------------------------------------------------------------
async def get_hot_videos(pn: int = 1, ps: int = 20) -> dict[str, Any]:
"""Fetch popular/hot videos."""
return await _call_api("获取热门视频", hot.get_hot_videos(pn=pn, ps=ps))
async def get_rank_videos(day: int = 3) -> dict[str, Any]:
"""Fetch ranking videos (default: 3-day rank)."""
day_type = rank.RankDayType.THREE_DAY if day == 3 else rank.RankDayType.WEEK
return await _call_api("获取排行榜", rank.get_rank(day=day_type))
# ---------------------------------------------------------------------------
# Video extras
# ---------------------------------------------------------------------------
async def _get_video_comments_direct(
aid: int,
bvid: str,
page: int,
credential: Credential | None = None,
) -> dict[str, Any]:
"""Fallback direct API call for video comments when SDK returns empty."""
api_url = "https://api.bilibili.com/x/v2/reply"
params = {
"oid": aid,
"type": 1,
"pn": page,
"ps": 20,
"sort": 2, # hot/popular
}
headers = {
"User-Agent": _USER_AGENT,
"Origin": "https://www.bilibili.com",
"Referer": f"https://www.bilibili.com/video/{bvid}/",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
"sec-ch-ua": '"Chromium";v="133", "Not(A:Brand";v="99", "Google Chrome";v="133"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
}
if credential and credential.sessdata:
cookies = [f"SESSDATA={credential.sessdata}"]
if credential.bili_jct:
cookies.append(f"bili_jct={credential.bili_jct}")
headers["Cookie"] = "; ".join(cookies)
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(api_url, params=params, headers=headers) as resp:
resp.raise_for_status()
payload = await resp.json()
if payload.get("code") != 0:
raise BiliError(
f"获取视频评论: [{payload.get('code')}] {payload.get('message', 'Unknown error')}"
)
data = payload.get("data")
return data if isinstance(data, dict) else {}
async def get_video_comments(
bvid: str, page: int = 1, credential: Credential | None = None
) -> dict[str, Any]:
"""Fetch video comments with SDK-first + direct-API fallback strategy."""
v = video.Video(bvid=bvid, credential=credential)
info = await _call_api("获取视频信息", v.get_info())
aid = info.get("aid")
if aid is None:
raise BiliError("获取视频评论: 视频信息缺少 aid")
sdk_result: dict[str, Any] | None = None
try:
sdk_result = await _call_api(
"获取视频评论",
comment.get_comments(
oid=aid,
type_=comment.CommentResourceType.VIDEO,
page_index=page,
order=comment.OrderType.LIKE,
credential=credential,
),
)
except BiliError as exc:
logger.warning("SDK comment fetch failed, fallback to direct API: %s", exc)
if isinstance(sdk_result, dict) and sdk_result.get("replies"):
return sdk_result
try:
direct_result = await _call_api(
"获取视频评论",
_get_video_comments_direct(aid=aid, bvid=bvid, page=page, credential=credential),
)
if isinstance(direct_result, dict):
return direct_result
except BiliError as exc:
if isinstance(sdk_result, dict) and sdk_result.get("replies"):
logger.warning("Direct comment fallback failed, return non-empty SDK result: %s", exc)
return sdk_result
logger.warning("Direct comment fallback failed after SDK empty/error: %s", exc)
raise
if isinstance(sdk_result, dict):
return sdk_result
return {}
async def get_video_ai_conclusion(
bvid: str, credential: Credential | None = None
) -> dict[str, Any]:
"""Fetch AI-generated video summary."""
v = video.Video(bvid=bvid, credential=credential)
pages = await _call_api("获取视频分P信息", v.get_pages())
if not pages:
return {}
cid = pages[0].get("cid")
if not cid:
return {}
return await _call_api("获取 AI 总结", v.get_ai_conclusion(cid=cid))
async def get_related_videos(
bvid: str, credential: Credential | None = None
) -> list[dict[str, Any]]:
"""Fetch related/recommended videos."""
v = video.Video(bvid=bvid, credential=credential)
data = await _call_api("获取相关推荐", v.get_related())
if isinstance(data, list):
return data
return []
# ---------------------------------------------------------------------------
# Search (video)
# ---------------------------------------------------------------------------
async def search_video(keyword: str, page: int = 1) -> list[dict[str, Any]]:
"""Search for videos by keyword."""
res = await _call_api("搜索视频", search.search_by_type(
keyword=keyword,
search_type=search.SearchObjectType.VIDEO,
page=page,
))
return res.get("result", [])
# ---------------------------------------------------------------------------
# Following & Toview
# ---------------------------------------------------------------------------
async def get_followings(
uid: int, pn: int = 1, ps: int = 20, credential: Credential | None = None
) -> dict[str, Any]:
"""Fetch user's following list."""
u = user.User(uid=uid, credential=credential)
return await _call_api("获取关注列表", u.get_followings(pn=pn, ps=ps))
async def modify_user_relation(
uid: int,
relation: user.RelationType,
credential: Credential,
) -> dict[str, Any]:
"""Modify relation to a user (subscribe/unsubscribe/block...)."""
u = user.User(uid=uid, credential=credential)
return await _call_api("修改用户关系", u.modify_relation(relation=relation))
async def unfollow_user(uid: int, credential: Credential) -> dict[str, Any]:
"""Unfollow a user by UID."""
return await modify_user_relation(
uid=uid,
relation=user.RelationType.UNSUBSCRIBE,
credential=credential,
)
async def get_watch_history(
page: int = 1, count: int = 30, credential: Credential | None = None
) -> dict[str, Any]:
"""Fetch watch history (观看历史)."""
if credential is None:
raise AuthenticationError("credential is required for watch history")
per_page = max(1, min(count, 100))
return await _call_api(
"获取观看历史",
user.get_self_history(page_num=page, per_page_item=per_page, credential=credential),
)
async def get_toview(credential: Credential) -> dict[str, Any]:
"""Fetch watch-later (稍后再看) list."""
data = await _call_api("获取稍后再看列表", homepage.get_favorite_list_and_toview(credential))
if not isinstance(data, list):
logger.warning("Unexpected toview payload type: %s", type(data).__name__)
return {"list": [], "count": 0}
# data is a list; the item with name="稍后再看" contains toview videos
for item in data:
if item.get("name") == "稍后再看" or item.get("id") == 2:
resp = item.get("mediaListResponse", {})
return {
"list": resp.get("list", []),
"count": resp.get("count", 0),
}
return {"list": [], "count": 0}
# ---------------------------------------------------------------------------
# Dynamic Feed
# ---------------------------------------------------------------------------
async def get_dynamic_feed(
offset: str | int | None = "", credential: Credential | None = None
) -> dict[str, Any]:
"""Fetch dynamic feed (动态时间线)."""
if credential is None:
raise AuthenticationError("credential is required for dynamic feed")
if offset in ("", None):
parsed_offset = None
elif isinstance(offset, int):
parsed_offset = offset
elif isinstance(offset, str):
try:
parsed_offset = int(offset)
except ValueError as e:
raise BiliError(f"获取动态时间线: offset 非法: {offset}") from e
else:
raise BiliError(f"获取动态时间线: offset 类型不支持: {type(offset).__name__}")
return await _call_api(
"获取动态时间线",
dynamic.get_dynamic_page_info(
credential=credential,
pn=1,
offset=parsed_offset,
),
)
async def post_text_dynamic(text: str, credential: Credential) -> dict[str, Any]:
"""Publish a plain-text dynamic."""
content = text.strip()
if not content:
raise BiliError("发布动态: 文本不能为空")
info = dynamic.BuildDynamic.empty().add_text(content)
return await _call_api(
"发布动态",
dynamic.send_dynamic(info=info, credential=credential),
)
async def get_user_dynamics(
uid: int,
offset: int = 0,
need_top: bool = False,
credential: Credential | None = None,
) -> dict[str, Any]:
"""Fetch a user's own dynamics timeline."""
u = user.User(uid=uid, credential=credential)
return await _call_api(
"获取用户动态",
u.get_dynamics(offset=offset, need_top=need_top),
)
async def delete_dynamic(dynamic_id: int, credential: Credential) -> dict[str, Any]:
"""Delete a dynamic by dynamic id."""
d = dynamic.Dynamic(dynamic_id=dynamic_id, credential=credential)
return await _call_api("删除动态", d.delete())
# ---------------------------------------------------------------------------
# Interactions (like, coin, triple)
# ---------------------------------------------------------------------------
async def like_video(bvid: str, credential: Credential, undo: bool = False) -> dict[str, Any]:
"""Like or unlike a video."""
v = video.Video(bvid=bvid, credential=credential)
return await _call_api("点赞视频", v.like(status=not undo))
async def coin_video(bvid: str, credential: Credential, num: int = 1) -> dict[str, Any]:
"""Give coins to a video (1 or 2)."""
v = video.Video(bvid=bvid, credential=credential)
return await _call_api("投币", v.pay_coin(num=num))
async def triple_video(bvid: str, credential: Credential) -> dict[str, Any]:
"""Triple (like + coin + favorite) a video."""
v = video.Video(bvid=bvid, credential=credential)
return await _call_api("一键三连", v.triple())
# ---------------------------------------------------------------------------
# Audio extraction
# ---------------------------------------------------------------------------
_DOWNLOAD_HEADERS = {
"User-Agent": _USER_AGENT,
"Referer": "https://www.bilibili.com",
}
async def get_audio_url(bvid: str, credential: Credential | None = None) -> str:
"""Get the best audio stream URL for a video (DASH preferred)."""
from bilibili_api.video import AudioQuality, VideoDownloadURLDataDetecter
v = video.Video(bvid=bvid, credential=credential)
download_data = await _call_api("获取下载地址", v.get_download_url(page_index=0))
detector = VideoDownloadURLDataDetecter(download_data)
streams = detector.detect_best_streams(
audio_max_quality=AudioQuality._64K,
no_dolby_audio=True,
no_hires=True,
)
if detector.check_flv_mp4_stream():
if streams and streams[0] and hasattr(streams[0], "url"):
return streams[0].url
else:
# DASH: audio is at index 1
if len(streams) >= 2 and streams[1] is not None and hasattr(streams[1], "url"):
return streams[1].url
# Fallback: find any stream with audio_quality
for s in streams:
if s is not None and hasattr(s, "audio_quality"):
return s.url
raise BiliError("无法获取音频流(可能是会员专属视频)")
async def download_audio(audio_url: str, output_path: str) -> int:
"""Download audio stream to a file. Returns bytes written."""
timeout = aiohttp.ClientTimeout(total=300)
max_retries = 3
for attempt in range(max_retries):
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(audio_url, headers=_DOWNLOAD_HEADERS) as resp:
if resp.status == 200:
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
total_bytes = 0
with open(output_path, "wb") as f:
async for chunk in resp.content.iter_chunked(256 * 1024):
if not chunk:
continue
f.write(chunk)
total_bytes += len(chunk)
return total_bytes
if attempt < max_retries - 1:
logger.warning("Download HTTP %d, retrying...", resp.status)
await asyncio.sleep(2)
else:
raise NetworkError(f"音频下载失败: HTTP {resp.status}")
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
if attempt < max_retries - 1:
logger.warning("Download error: %s, retrying...", e)
await asyncio.sleep(2)
else:
raise NetworkError(f"音频下载失败: {e}") from e
raise NetworkError("音频下载失败: 重试次数用尽")
def split_audio(input_path: str, output_dir: str, segment_seconds: int = 25) -> list[str]:
"""Split audio into WAV segments using PyAV.
Returns list of segment file paths.
Each segment is 16kHz mono PCM s16le WAV — ready for ASR APIs.
"""
try:
import av as pyav
except ImportError:
raise BiliError(
"音频切分需要 PyAV 库。请安装: pip install av\n"
"或: uv add av"
) from None
if segment_seconds <= 0:
raise BiliError("segment_seconds 必须大于 0")
os.makedirs(output_dir, exist_ok=True)
def _write_segment(frames: list, seg_idx: int) -> str:
seg_path = os.path.join(output_dir, f"seg_{seg_idx:03d}.wav")
out = None
try:
out = pyav.open(seg_path, "w", format="wav")
out_stream = out.add_stream("pcm_s16le", rate=16000, layout="mono")
resampler = pyav.AudioResampler(format="s16", layout="mono", rate=16000)
for fr in frames:
fr.pts = None
for resampled in resampler.resample(fr):
for pkt in out_stream.encode(resampled):
out.mux(pkt)
for pkt in out_stream.encode():
out.mux(pkt)
finally:
if out is not None:
out.close()
return seg_path
input_container = None
try:
input_container = pyav.open(input_path)
if not input_container.streams.audio:
raise BiliError("音频解码失败: 无音频流")
chunk_paths = []
current_samples = 0
segment_frames: list = []
seg_idx = 0
samples_per_segment = None
decoded_any = False
for frame in input_container.decode(audio=0):
decoded_any = True
if samples_per_segment is None:
frame_rate = frame.sample_rate or 16000
samples_per_segment = segment_seconds * frame_rate
segment_frames.append(frame)
current_samples += frame.samples or 0
if samples_per_segment and current_samples >= samples_per_segment:
chunk_paths.append(_write_segment(segment_frames, seg_idx))
seg_idx += 1
current_samples = 0
segment_frames = []
if not decoded_any:
raise BiliError("音频解码失败: 无帧数据")
if segment_frames:
chunk_paths.append(_write_segment(segment_frames, seg_idx))
return chunk_paths
finally:
if input_container is not None:
input_container.close()
"""CLI command modules."""
"""Account and authentication related commands."""
from __future__ import annotations
import sys
import click
from rich.panel import Panel
from .. import payloads
from . import common
@click.command()
def login():
"""扫码登录 Bilibili。"""
try:
common.run(common.qr_login())
except RuntimeError as e:
common.exit_error(str(e))
except Exception as e:
common.exit_error(f"登录失败: {e}")
@click.command()
def logout():
"""注销并清除保存的凭证。"""
common.clear_credential()
common.console.print("[green]✅ 已注销,凭证已清除[/green]")
@click.command()
@common.structured_output_options
def status(as_json: bool, as_yaml: bool):
"""检查登录状态。"""
output_format = common.resolve_output_format(as_json=as_json, as_yaml=as_yaml)
cred = common.get_credential(mode="read")
if not cred:
payload = common.error_payload("not_authenticated", "未登录。使用 bili login 登录。")
if common.emit_structured(payload, output_format):
raise SystemExit(1) from None
common.print_login_required("未登录。使用 [bold]bili login[/bold] 登录。")
sys.exit(1)
from .. import client
try:
info = common.run(client.get_self_info(cred))
except Exception as exc:
payload = common.error_payload("api_error", f"检查登录状态失败: {exc}")
if common.emit_structured(payload, output_format):
raise SystemExit(1) from None
common.exit_error(f"检查登录状态失败: {exc}")
payload = common.success_payload(
{
"authenticated": True,
"user": payloads.normalize_user(info),
}
)
def render() -> None:
name = info.get("name", "unknown")
uid = info.get("mid", "unknown")
common.console.print(f"[green]✅ 已登录:[bold]{name}[/bold] (UID: {uid})[/green]")
if common.emit_or_print(payload, output_format, render):
return
@click.command()
@common.structured_output_options
def whoami(as_json: bool, as_yaml: bool):
"""查看当前登录用户的详细信息。"""
from .. import client
output_format = common.resolve_output_format(as_json=as_json, as_yaml=as_yaml)
cred = common.get_credential(mode="read")
if not cred:
payload = common.error_payload("not_authenticated", "未登录。使用 bili login 登录。")
if common.emit_structured(payload, output_format):
raise SystemExit(1) from None
common.print_login_required("未登录。使用 [bold]bili login[/bold] 登录。")
sys.exit(1)
try:
info = common.run(client.get_self_info(cred))
uid = info.get("mid", "unknown")
relation = common.run(client.get_user_relation_info(uid, credential=cred))
except Exception as exc:
payload = common.error_payload("api_error", f"获取用户信息失败: {exc}")
if common.emit_structured(payload, output_format):
raise SystemExit(1) from None
common.exit_error(f"获取用户信息失败: {exc}")
payload = common.success_payload(
{"user": payloads.normalize_user(info), "relation": payloads.normalize_relation(relation)}
)
def render() -> None:
name = info.get("name", "unknown")
level = info.get("level", "?")
coins = info.get("coins", 0)
follower = relation.get("follower", 0)
following = relation.get("following", 0)
vip = info.get("vip", {})
vip_label = ""
if vip.get("status") == 1:
vip_type = "大会员" if vip.get("type") == 2 else "小会员"
vip_label = f" | 🏅 {vip_type}"
sign = info.get("sign", "").strip()
lines = [
f"👤 [bold]{name}[/bold] (UID: {uid})",
f"⭐ Level {level} | 🪙 硬币 {coins}{vip_label}",
f"👥 粉丝 {common.format_count(follower)} | 🔔 关注 {common.format_count(following)}",
]
if sign:
lines.append(f"📝 {sign}")
common.console.print(Panel(
"\n".join(lines),
title="个人信息",
border_style="green",
))
if common.emit_or_print(payload, output_format, render):
return
"""Audio extraction command — download and split video audio for ASR."""
from __future__ import annotations
import os
import re
import tempfile
import click
from .common import console, exit_error, extract_bvid_or_exit, get_credential, run_or_exit
DEFAULT_TMP_DIR = os.path.join(tempfile.gettempdir(), "bilibili-cli")
def _sanitize_filename(title: str) -> str:
"""Remove or replace characters that are unsafe in file paths."""
title = re.sub(r'[<>:"/\\|?*]', "_", title)
title = title.strip(". ")
return title[:120] or "audio"
@click.command()
@click.argument("bv_or_url")
@click.option("--segment", "-s", default=25, type=click.IntRange(5, 300),
help="每段时长(秒),默认 25。")
@click.option("--no-split", is_flag=True, help="不切分,直接保存完整音频文件。")
@click.option("--output", "-o", default=None, type=click.Path(),
help=f"输出目录(默认 {DEFAULT_TMP_DIR}/{{title}}/)。")
def audio(bv_or_url: str, segment: int, no_split: bool, output: str | None):
"""下载视频音频并切分为 ASR-ready WAV 片段。
默认输出到 /tmp/bilibili-cli/{title}/ 目录,
每段 25 秒,16kHz mono PCM WAV 格式,可直接用于语音转文字 API。
\b
示例:
bili audio BV1ABcsztEcY # 下载并切分
bili audio BV1ABcsztEcY --segment 60 # 每段 60 秒
bili audio BV1ABcsztEcY --no-split # 保存完整 m4a
bili audio BV1ABcsztEcY -o ~/data/ # 自定义输出目录
"""
from .. import client
bvid = extract_bvid_or_exit(bv_or_url)
# 1. Get video info for title
cred = get_credential(mode="optional")
info = run_or_exit(client.get_video_info(bvid, credential=cred), "获取视频信息")
title = info.get("title", bvid)
duration = info.get("duration", 0)
safe_title = _sanitize_filename(title)
console.print(f"[bold]🎵 {title}[/bold] ({_format_time(duration)})")
# 2. Get audio stream URL
console.print("[dim]获取音频流地址...[/dim]")
audio_url = run_or_exit(client.get_audio_url(bvid, credential=cred), "获取音频流")
# 3. Determine output directory
if output:
out_dir = os.path.expanduser(output)
else:
out_dir = os.path.join(DEFAULT_TMP_DIR, safe_title)
if no_split:
# Download full audio without splitting
out_file = os.path.join(out_dir, f"{safe_title}.m4a")
console.print("[dim]下载音频中...[/dim]")
nbytes = run_or_exit(client.download_audio(audio_url, out_file), "下载音频")
size_mb = nbytes / (1024 * 1024)
console.print(f"[green]✅ 音频已保存: {out_file} ({size_mb:.1f} MB)[/green]")
else:
# Download to temp file, then split
os.makedirs(out_dir, exist_ok=True)
tmp_path = os.path.join(out_dir, "_raw.m4s")
console.print("[dim]下载音频中...[/dim]")
nbytes = run_or_exit(client.download_audio(audio_url, tmp_path), "下载音频")
size_mb = nbytes / (1024 * 1024)
console.print(f"[dim]下载完成 ({size_mb:.1f} MB),切分中...[/dim]")
try:
segments = client.split_audio(tmp_path, out_dir, segment_seconds=segment)
except Exception as e:
exit_error(f"音频切分失败: {e}")
finally:
# Clean up raw download
if os.path.exists(tmp_path):
os.unlink(tmp_path)
console.print(f"[green]✅ 切分完成: {len(segments)} 段 (每段 ~{segment}s)[/green]")
console.print(f"[green] 输出目录: {out_dir}[/green]")
for _i, seg in enumerate(segments):
basename = os.path.basename(seg)
size_kb = os.path.getsize(seg) / 1024
console.print(f"[dim] {basename} ({size_kb:.0f} KB)[/dim]")
def _format_time(seconds: int) -> str:
"""Format duration for display."""
if seconds >= 3600:
h, rem = divmod(seconds, 3600)
m, s = divmod(rem, 60)
return f"{h}:{m:02d}:{s:02d}"
m, s = divmod(seconds, 60)
return f"{m:02d}:{s:02d}"
"""Collection and timeline related commands."""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Any
import click
from rich.table import Table
from .. import payloads
from . import common
def _decode_json(value: object) -> dict[str, Any]:
if isinstance(value, dict):
return value
if not isinstance(value, str):
return {}
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
def _extract_dynamic_id(item: dict[str, Any]) -> int:
desc = item.get("desc", {}) if isinstance(item, dict) else {}
candidates = [
desc.get("dynamic_id"),
desc.get("dynamic_id_str"),
item.get("id_str"),
item.get("id"),
]
for candidate in candidates:
if isinstance(candidate, int):
return candidate
if isinstance(candidate, str):
try:
return int(candidate)
except ValueError:
continue
return 0
def _extract_dynamic_timestamp(item: dict[str, Any]) -> int:
desc = item.get("desc", {}) if isinstance(item, dict) else {}
ts = desc.get("timestamp")
if isinstance(ts, int):
return ts
if isinstance(ts, str):
try:
return int(ts)
except ValueError:
return 0
return 0
def _extract_dynamic_text(item: dict[str, Any]) -> str:
parts: list[str] = []
modules = item.get("modules", {}) if isinstance(item, dict) else {}
if isinstance(modules, dict):
dynamic_mod = modules.get("module_dynamic", {})
if isinstance(dynamic_mod, dict):
desc = dynamic_mod.get("desc", {})
if isinstance(desc, dict) and isinstance(desc.get("text"), str):
parts.append(desc["text"])
card = _decode_json(item.get("card"))
for key in ("title", "description", "dynamic", "summary"):
value = card.get(key)
if isinstance(value, str) and value.strip():
parts.append(value.strip())
card_item = card.get("item")
if isinstance(card_item, dict):
for key in ("title", "description", "content"):
value = card_item.get(key)
if isinstance(value, str) and value.strip():
parts.append(value.strip())
if not parts:
desc = item.get("desc", {}) if isinstance(item, dict) else {}
if isinstance(desc, dict):
for key in ("description", "dynamic_id_str"):
value = desc.get(key)
if isinstance(value, str) and value.strip():
parts.append(value.strip())
return " ".join(parts).strip()
@click.command()
@click.argument("fav_id", required=False, type=int)
@click.option("--page", "-p", default=1, type=click.IntRange(1), help="页码 (默认 1,最小 1)。")
@common.structured_output_options
def favorites(fav_id: int | None, page: int, as_json: bool, as_yaml: bool):
"""浏览收藏夹。
不带参数列出所有收藏夹,带 FAV_ID 查看收藏夹内的视频。
"""
from .. import client
output_format = common.resolve_output_format(as_json=as_json, as_yaml=as_yaml)
cred = common.require_login(message="需要登录才能查看收藏夹。使用 [bold]bili login[/bold] 登录。")
if fav_id is None:
fav_list = common.run_or_exit(client.get_favorite_list(cred), "获取收藏夹列表失败")
if common.emit_structured([payloads.normalize_favorite_folder(item) for item in fav_list], output_format):
return
if not fav_list:
common.console.print("[yellow]未找到收藏夹[/yellow]")
return
table = Table(title="📂 收藏夹列表", border_style="blue")
table.add_column("ID", style="cyan", width=12)
table.add_column("名称", width=20)
table.add_column("视频数", width=10, justify="right")
for f in fav_list:
table.add_row(
str(f.get("id", "")),
f.get("title", ""),
str(f.get("media_count", 0)),
)
common.console.print(table)
common.console.print("\n[dim]使用 [bold]bili favorites <ID>[/bold] 查看收藏夹内容[/dim]")
else:
data = common.run_or_exit(
client.get_favorite_videos(fav_id, cred, page=page),
"获取收藏夹内容失败",
)
if common.emit_structured(
{
"folder_id": fav_id,
"page": page,
"has_more": bool(data.get("has_more", False)),
"items": [payloads.normalize_favorite_media(item) for item in (data.get("medias") or [])],
},
output_format,
):
return
medias = data.get("medias") or []
if not medias:
common.console.print("[yellow]收藏夹为空或不存在[/yellow]")
return
table = Table(title=f"📂 收藏夹 #{fav_id} (第 {page} 页)", border_style="blue")
table.add_column("#", style="dim", width=4)
table.add_column("BV号", style="cyan", width=14)
table.add_column("标题", max_width=40)
table.add_column("UP主", width=12)
table.add_column("时长", width=8)
for i, m in enumerate(medias, 1 + (page - 1) * 20):
upper = m.get("upper", {})
table.add_row(
str(i),
m.get("bvid", ""),
(m.get("title", "") or "")[:40],
(upper.get("name", "") or "")[:12],
common.format_duration(m.get("duration", 0)),
)
common.console.print(table)
has_more = data.get("has_more", False)
if has_more:
common.console.print(f"\n[dim]还有更多内容,使用 [bold]bili favorites {fav_id} --page {page + 1}[/bold] 查看下一页[/dim]")
@click.command()
@click.option("--page", "-p", default=1, type=click.IntRange(1), help="页码 (默认 1,最小 1)。")
@common.structured_output_options
def following(page: int, as_json: bool, as_yaml: bool):
"""查看关注列表。"""
from .. import client
output_format = common.resolve_output_format(as_json=as_json, as_yaml=as_yaml)
cred = common.require_login()
me = common.run_or_exit(client.get_self_info(cred), "获取关注列表失败")
uid = me["mid"]
data = common.run_or_exit(
client.get_followings(uid, pn=page, credential=cred),
"获取关注列表失败",
)
if common.emit_structured(
{
"page": page,
"total": data.get("total", 0),
"items": [payloads.normalize_following_user(item) for item in (data.get("list") or [])],
},
output_format,
):
return
flist = data.get("list") or []
if not flist:
common.console.print("[yellow]关注列表为空[/yellow]")
return
total = data.get("total", "?")
table = Table(title=f"🔔 关注列表 (共 {total}, 第 {page} 页)", border_style="blue")
table.add_column("#", style="dim", width=4)
table.add_column("UID", style="cyan", width=12)
table.add_column("用户名", width=16)
table.add_column("签名", max_width=40)
for i, u in enumerate(flist, 1 + (page - 1) * 20):
table.add_row(
str(i),
str(u.get("mid", "")),
u.get("uname", ""),
(u.get("sign", "") or "")[:40],
)
common.console.print(table)
common.console.print(f"\n[dim]使用 [bold]bili following --page {page + 1}[/bold] 查看下一页[/dim]")
@click.command()
@click.option("--page", "-p", default=1, type=click.IntRange(1), help="页码 (默认 1,最小 1)。")
@click.option("--max", "-n", "count", default=30, type=click.IntRange(1, 100), help="显示数量 (默认 30,1-100)。")
@common.structured_output_options
def history(page: int, count: int, as_json: bool, as_yaml: bool):
"""查看观看历史。"""
from .. import client
output_format = common.resolve_output_format(as_json=as_json, as_yaml=as_yaml)
cred = common.require_login()
data = common.run_or_exit(
client.get_watch_history(page=page, count=count, credential=cred),
"获取观看历史失败",
)
if isinstance(data, list):
vlist = data
else:
vlist = data.get("list") or data.get("items") or data.get("data") or []
history_items = vlist if isinstance(vlist, list) else []
if common.emit_structured(
{
"page": page,
"count": min(count, len(history_items)) if history_items else 0,
"items": [payloads.normalize_history_item(item) for item in history_items[:count] if isinstance(item, dict)],
},
output_format,
):
return
if not isinstance(vlist, list) or not vlist:
common.console.print("[yellow]观看历史为空[/yellow]")
return
table = Table(title=f"🕘 观看历史 (第 {page} 页)", border_style="blue")
table.add_column("#", style="dim", width=4)
table.add_column("标识", style="cyan", width=14)
table.add_column("标题", max_width=36)
table.add_column("UP主", width=12)
table.add_column("观看时间", width=12)
for i, v in enumerate(vlist[:count], 1):
history_info = v.get("history", {}) if isinstance(v, dict) else {}
owner = v.get("owner", {}) if isinstance(v, dict) else {}
view_at = history_info.get("view_at") or (v.get("view_at", 0) if isinstance(v, dict) else 0)
if isinstance(view_at, int) and view_at > 0:
view_time = datetime.fromtimestamp(view_at).strftime("%m-%d %H:%M")
else:
view_time = "-"
table.add_row(
str(i),
history_info.get("bvid") or v.get("bvid", "") or str(history_info.get("oid", "")),
(v.get("title", "") or v.get("name", ""))[:36],
(owner.get("name", "") or v.get("author_name", "") or v.get("author", ""))[:12],
view_time,
)
common.console.print(table)
@click.command(name="watch-later")
@common.structured_output_options
def watch_later(as_json: bool, as_yaml: bool):
"""查看稍后再看列表。"""
from .. import client
output_format = common.resolve_output_format(as_json=as_json, as_yaml=as_yaml)
cred = common.require_login()
data = common.run_or_exit(client.get_toview(cred), "获取稍后再看失败")
if common.emit_structured(
{
"count": data.get("count", 0),
"items": [payloads.normalize_watch_later_item(item) for item in (data.get("list") or [])],
},
output_format,
):
return
vlist = data.get("list") or []
if not vlist:
common.console.print("[yellow]稍后再看列表为空[/yellow]")
return
total = data.get("count", len(vlist))
table = Table(title=f"⏰ 稍后再看 (共 {total} 个)", border_style="blue")
table.add_column("#", style="dim", width=4)
table.add_column("BV号", style="cyan", width=14)
table.add_column("标题", max_width=36)
table.add_column("UP主", width=12)
table.add_column("时长", width=8)
for i, v in enumerate(vlist[:30], 1):
owner = v.get("owner", {})
table.add_row(
str(i),
v.get("bvid", ""),
v.get("title", "")[:36],
owner.get("name", "")[:12],
common.format_duration(v.get("duration", 0)),
)
common.console.print(table)
@click.command()
@click.option("--offset", default="", help="分页游标;留空为最新。可使用上一页返回的 next_offset/offset。")
@common.structured_output_options
def feed(offset: str, as_json: bool, as_yaml: bool):
"""查看动态时间线。"""
from .. import client
output_format = common.resolve_output_format(as_json=as_json, as_yaml=as_yaml)
cred = common.require_login()
data = common.run_or_exit(
client.get_dynamic_feed(offset=offset, credential=cred),
"获取动态失败",
)
items = data.get("items") or []
if common.emit_structured(
{
"items": [payloads.normalize_dynamic_item(item) for item in items if isinstance(item, dict)],
"next_offset": data.get("next_offset") or data.get("offset") or "",
},
output_format,
):
return
if not items:
common.console.print("[yellow]暂无动态[/yellow]")
return
common.console.print("[bold]📰 动态时间线[/bold]\n")
for item in items[:15]:
modules = item.get("modules", {})
author = modules.get("module_author", {})
dyn_main = modules.get("module_dynamic", {})
stat = modules.get("module_stat", {})
name = author.get("name", "")
pub_time = author.get("pub_time", "")
desc = dyn_main.get("desc", {})
text = desc.get("text", "") if desc else ""
major = dyn_main.get("major", {})
title = ""
if major:
archive = major.get("archive", {})
if archive:
title = archive.get("title", "")
article = major.get("article", {})
if article:
title = article.get("title", "")
comment_info = stat.get("comment", {})
like_info = stat.get("like", {})
comment_count = comment_info.get("count", 0) if comment_info else 0
like_count = like_info.get("count", 0) if like_info else 0
common.console.print(f" [cyan]{name}[/cyan] [dim]{pub_time}[/dim]")
if title:
common.console.print(f" 📺 {title}")
if text:
common.console.print(f" {text[:100]}")
if comment_count or like_count:
common.console.print(f" [dim]👍 {like_count} 💬 {comment_count}[/dim]")
common.console.print()
next_offset = data.get("next_offset") or data.get("offset")
if next_offset not in ("", None):
common.console.print(f"[dim]下一页:bili feed --offset {next_offset}[/dim]")
@click.command(name="my-dynamics")
@click.option("--offset", default=0, type=click.IntRange(0), help="分页偏移量;默认 0。")
@click.option("--top/--no-top", "need_top", default=False, help="是否包含置顶动态。")
@click.option("--max", "-n", "count", default=20, type=click.IntRange(1, 50), help="显示条数 (1-50)。")
@common.structured_output_options
def my_dynamics(offset: int, need_top: bool, count: int, as_json: bool, as_yaml: bool):
"""查看我发布的动态。"""
from .. import client
output_format = common.resolve_output_format(as_json=as_json, as_yaml=as_yaml)
cred = common.require_login()
me = common.run_or_exit(client.get_self_info(cred), "获取我的动态失败")
uid = me.get("mid")
if not isinstance(uid, int):
common.exit_error("获取我的动态失败: 当前用户信息缺少 mid")
data = common.run_or_exit(
client.get_user_dynamics(uid=uid, offset=offset, need_top=need_top, credential=cred),
"获取我的动态失败",
)
cards = data.get("cards") or []
if common.emit_structured(
{
"offset": offset,
"next_offset": data.get("next_offset") or data.get("offset") or "",
"items": [payloads.normalize_dynamic_item(card) for card in cards[:count] if isinstance(card, dict)],
},
output_format,
):
return
if not isinstance(cards, list) or not cards:
common.console.print("[yellow]暂无我发布的动态[/yellow]")
return
table = Table(title=f"📝 我的动态 (offset={offset})", border_style="blue")
table.add_column("#", style="dim", width=4)
table.add_column("动态ID", style="cyan", width=16)
table.add_column("发布时间", width=12)
table.add_column("内容", max_width=60)
for idx, card in enumerate(cards[:count], 1):
if not isinstance(card, dict):
continue
dynamic_id = _extract_dynamic_id(card)
ts = _extract_dynamic_timestamp(card)
pub_time = datetime.fromtimestamp(ts).strftime("%m-%d %H:%M") if ts > 0 else "-"
text = _extract_dynamic_text(card)[:60] or "-"
table.add_row(str(idx), str(dynamic_id), pub_time, text)
common.console.print(table)
next_offset = data.get("next_offset") or data.get("offset")
if next_offset not in ("", None, offset):
common.console.print(f"\n[dim]下一页:bili my-dynamics --offset {next_offset}[/dim]")
@click.command(name="dynamic-post")
@click.argument("text", required=False)
@click.option(
"--from-file",
"from_file",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
default=None,
help="从文件读取动态文本。",
)
@common.structured_output_options
def dynamic_post(text: str | None, from_file: Path | None, as_json: bool, as_yaml: bool):
"""发布一条纯文本动态。"""
from .. import client
output_format = common.resolve_output_format(as_json=as_json, as_yaml=as_yaml)
cred = common.require_login(require_write=True)
raw_text = text or ""
if from_file is not None:
raw_text = from_file.read_text(encoding="utf-8")
content = raw_text.strip()
if not content:
common.exit_error("请提供动态文本。可用参数:TEXT 或 --from-file FILE")
data = common.run_or_exit(
client.post_text_dynamic(content, credential=cred),
"发布动态失败",
)
dynamic_id = data.get("dynamic_id") or data.get("dynamic_id_str") or data.get("dyn_id")
if common.emit_structured(
payloads.action_result("dynamic_post", dynamic_id=str(dynamic_id or ""), text=content),
output_format,
):
return
if dynamic_id:
common.console.print(f"[green]✅ 已发布动态: {dynamic_id}[/green]")
else:
common.console.print("[green]✅ 已发布动态[/green]")
@click.command(name="dynamic-delete")
@click.argument("dynamic_id", type=int)
@click.option("--yes", is_flag=True, help="跳过确认,直接删除。")
@common.structured_output_options
def dynamic_delete(dynamic_id: int, yes: bool, as_json: bool, as_yaml: bool):
"""删除一条动态。"""
from .. import client
output_format = common.resolve_output_format(as_json=as_json, as_yaml=as_yaml)
cred = common.require_login(require_write=True)
if not yes:
confirmed = click.confirm(f"确认删除动态 {dynamic_id} 吗?", default=False)
if not confirmed:
common.console.print("[yellow]已取消删除[/yellow]")
return
common.run_or_exit(
client.delete_dynamic(dynamic_id=dynamic_id, credential=cred),
"删除动态失败",
)
if common.emit_structured(
payloads.action_result("dynamic_delete", dynamic_id=str(dynamic_id)),
output_format,
):
return
common.console.print(f"[green]🗑️ 已删除动态: {dynamic_id}[/green]")
"""Shared helpers for CLI command modules."""
from __future__ import annotations
import logging
import sys
import click
from .. import auth
from ..exceptions import AuthenticationError, BiliError, InvalidBvidError, NetworkError, NotFoundError, RateLimitError
# Re-export all formatting utilities from formatter.py for backward compatibility.
# Command modules do `from .common import emit_structured, format_count, ...`
from ..formatter import ( # noqa: F401
OutputFormat,
_to_int,
console,
emit_or_print,
emit_structured,
error_payload,
exit_error,
format_count,
format_duration,
resolve_output_format,
structured_output_options,
success_payload,
)
def setup_logging(verbose: bool):
"""Configure global logging based on CLI verbosity."""
level = logging.DEBUG if verbose else logging.WARNING
logging.basicConfig(level=level, format="%(name)s: %(message)s")
def run(coro):
"""Bridge async coroutine into synchronous click command."""
import asyncio
return asyncio.run(coro)
def run_or_exit(coro, action: str):
"""Run async call and convert unexpected errors to CLI-friendly failures."""
try:
return run(coro)
except InvalidBvidError as e:
exit_error(f"{action}: {e}", code="invalid_input")
except AuthenticationError as e:
exit_error(f"{action}: {e}", code="not_authenticated")
except RateLimitError as e:
exit_error(f"{action}: {e}", code="rate_limited")
except NotFoundError as e:
exit_error(f"{action}: {e}", code="not_found")
except NetworkError as e:
exit_error(f"{action}: {e}", code="network_error")
except BiliError as e:
exit_error(f"{action}: {e}", code="upstream_error")
except Exception as e:
exit_error(f"{action}: {e}", code="internal_error")
def get_credential(mode: auth.AuthMode = "read"):
"""Read credential from configured auth strategy."""
return auth.get_credential(mode=mode)
def clear_credential():
"""Remove saved credential."""
return auth.clear_credential()
def qr_login():
"""Return login coroutine for QR login flow."""
return auth.qr_login()
def print_login_required(message: str | None = None):
"""Print a standard login-required warning message."""
if message:
console.print(f"[yellow]⚠️ {message}[/yellow]")
return
console.print("[yellow]⚠️ 需要登录。使用 [bold]bili login[/bold] 登录。[/yellow]")
def require_login(require_write: bool = False, message: str | None = None):
"""Require login credential and optional write capability."""
mode: auth.AuthMode = "write" if require_write else "read"
cred = get_credential(mode=mode)
if cred:
return cred
if require_write:
# Diagnose a common case: saved session exists but lacks bili_jct.
saved = get_credential(mode="optional")
if saved and getattr(saved, "sessdata", "") and not getattr(saved, "bili_jct", ""):
exit_error(
"当前登录凭证不支持写操作(缺少 bili_jct)。请执行 bili login 重新登录。",
code="permission_denied",
)
ctx = click.get_current_context(silent=True)
params = ctx.params if ctx is not None else {}
output_format = resolve_output_format(
as_json=bool(params.get("as_json", False)),
as_yaml=bool(params.get("as_yaml", False)),
)
error_message = message or "未登录。使用 bili login 登录。"
if emit_structured(error_payload("not_authenticated", error_message), output_format):
sys.exit(1)
print_login_required(message)
sys.exit(1)
def run_optional(coro, action: str):
"""Run optional sub-request and print warning on failure."""
try:
return run(coro)
except BiliError as e:
console.print(f"[yellow]⚠️ {action}: {e}[/yellow]")
except Exception as e:
console.print(f"[yellow]⚠️ {action}: {e}[/yellow]")
return None
def extract_bvid_or_exit(bv_or_url: str) -> str:
"""Extract BV ID from input; print a user-friendly error on failure."""
from .. import client
try:
return client.extract_bvid(bv_or_url)
except (InvalidBvidError, ValueError) as e:
exit_error(str(e), code="invalid_input")
"""Discovery commands (hot/rank)."""
from __future__ import annotations
import click
from rich.table import Table
from .. import payloads
from . import common
@click.command(name="hot")
@click.option("--page", "-p", default=1, type=click.IntRange(1), help="页码 (默认 1,最小 1)。")
@click.option("--max", "-n", "count", default=20, type=click.IntRange(1), help="显示数量 (默认 20,最小 1)。")
@common.structured_output_options
def hot_cmd(page: int, count: int, as_json: bool, as_yaml: bool):
"""查看热门视频。"""
from .. import client
output_format = common.resolve_output_format(as_json=as_json, as_yaml=as_yaml)
data = common.run_or_exit(client.get_hot_videos(pn=page, ps=count), "获取热门视频失败")
if common.emit_structured(
{
"items": [payloads.normalize_video_summary(item) for item in (data.get("list") or [])[:count]],
"page": page,
"count": count,
},
output_format,
):
return
vlist = data.get("list") or []
if not vlist:
common.console.print("[yellow]未获取到热门视频[/yellow]")
return
table = Table(title="🔥 热门视频", border_style="red")
table.add_column("#", style="dim", width=4)
table.add_column("BV号", style="cyan", width=14)
table.add_column("标题", max_width=36)
table.add_column("UP主", width=12)
table.add_column("播放", width=8, justify="right")
table.add_column("点赞", width=8, justify="right")
for i, v in enumerate(vlist[:count], 1):
owner = v.get("owner", {})
stat = v.get("stat", {})
table.add_row(
str(i),
v.get("bvid", ""),
v.get("title", "")[:36],
owner.get("name", "")[:12],
common.format_count(stat.get("view", 0)),
common.format_count(stat.get("like", 0)),
)
common.console.print(table)
@click.command(name="rank")
@click.option("--day", default="3", type=click.Choice(["3", "7"]), help="排行周期:3 或 7 天(默认 3)。")
@click.option("--max", "-n", "count", default=20, type=click.IntRange(1), help="显示数量 (默认 20,最小 1)。")
@common.structured_output_options
def rank_cmd(day: str, count: int, as_json: bool, as_yaml: bool):
"""查看全站排行榜。"""
from .. import client
output_format = common.resolve_output_format(as_json=as_json, as_yaml=as_yaml)
data = common.run_or_exit(client.get_rank_videos(day=int(day)), "获取排行榜失败")
if common.emit_structured(
{
"items": [payloads.normalize_video_summary(item) for item in (data.get("list") or [])[:count]],
"day": int(day),
"count": count,
},
output_format,
):
return
vlist = data.get("list") or []
if not vlist:
common.console.print("[yellow]未获取到排行榜数据[/yellow]")
return
table = Table(title="🏆 全站排行榜", border_style="yellow")
table.add_column("#", style="bold", width=4)
table.add_column("BV号", style="cyan", width=14)
table.add_column("标题", max_width=36)
table.add_column("UP主", width=12)
table.add_column("播放", width=8, justify="right")
table.add_column("综合分", width=8, justify="right")
for i, v in enumerate(vlist[:count], 1):
owner = v.get("owner", {})
stat = v.get("stat", {})
table.add_row(
str(i),
v.get("bvid", ""),
v.get("title", "")[:36],
owner.get("name", "")[:12],
common.format_count(stat.get("view", 0)),
str(v.get("score", "")),
)
common.console.print(table)
"""Video interaction commands."""
from __future__ import annotations
import click
from .. import payloads
from . import common
@click.command()
@click.argument("bv_or_url")
@click.option("--undo", is_flag=True, help="取消点赞。")
@common.structured_output_options
def like(bv_or_url: str, undo: bool, as_json: bool, as_yaml: bool):
"""点赞视频。"""
from .. import client
output_format = common.resolve_output_format(as_json=as_json, as_yaml=as_yaml)
cred = common.require_login(require_write=True)
bvid = common.extract_bvid_or_exit(bv_or_url)
common.run_or_exit(client.like_video(bvid, credential=cred, undo=undo), "操作失败")
payload = payloads.action_result("unlike" if undo else "like", bvid=bvid, undo=undo)
def render() -> None:
if undo:
common.console.print(f"[yellow]👎 已取消点赞: {bvid}[/yellow]")
else:
common.console.print(f"[green]👍 已点赞: {bvid}[/green]")
if common.emit_or_print(payload, output_format, render):
return
@click.command()
@click.argument("bv_or_url")
@click.option("--num", "-n", default=1, type=click.IntRange(1, 2), help="投币数量 (1 或 2)。")
@common.structured_output_options
def coin(bv_or_url: str, num: int, as_json: bool, as_yaml: bool):
"""给视频投币。"""
from .. import client
output_format = common.resolve_output_format(as_json=as_json, as_yaml=as_yaml)
cred = common.require_login(require_write=True)
bvid = common.extract_bvid_or_exit(bv_or_url)
common.run_or_exit(client.coin_video(bvid, credential=cred, num=num), "投币失败")
payload = payloads.action_result("coin", bvid=bvid, coins=num)
def render() -> None:
common.console.print(f"[green]🪙 已投 {num} 枚硬币: {bvid}[/green]")
if common.emit_or_print(payload, output_format, render):
return
@click.command()
@click.argument("bv_or_url")
@common.structured_output_options
def triple(bv_or_url: str, as_json: bool, as_yaml: bool):
"""一键三连(点赞 + 投币 + 收藏)。"""
from .. import client
output_format = common.resolve_output_format(as_json=as_json, as_yaml=as_yaml)
cred = common.require_login(require_write=True)
bvid = common.extract_bvid_or_exit(bv_or_url)
result = common.run_or_exit(client.triple_video(bvid, credential=cred), "三连失败")
parts = []
if result.get("like"):
parts.append("👍 点赞")
if result.get("coin"):
parts.append("🪙 投币")
if result.get("multiply") or result.get("fav"):
parts.append("⭐ 收藏")
payload = payloads.action_result(
"triple",
bvid=bvid,
result={
"like": bool(result.get("like")),
"coin": bool(result.get("coin")),
"favorite": bool(result.get("multiply") or result.get("fav")),
},
)
def render() -> None:
common.console.print(f"[green]🎉 一键三连成功: {bvid}[/green]")
if parts:
common.console.print(f"[dim] {' + '.join(parts)}[/dim]")
if common.emit_or_print(payload, output_format, render):
return
@click.command()
@click.argument("uid", type=int)
@click.option("--yes", is_flag=True, help="跳过确认,直接取消关注。")
@common.structured_output_options
def unfollow(uid: int, yes: bool, as_json: bool, as_yaml: bool):
"""取消关注某个 UP(按 UID)。"""
from .. import client
output_format = common.resolve_output_format(as_json=as_json, as_yaml=as_yaml)
cred = common.require_login(require_write=True)
if not yes:
confirmed = click.confirm(f"确认取消关注 UID={uid} 吗?", default=False)
if not confirmed:
common.console.print("[yellow]已取消操作[/yellow]")
return
common.run_or_exit(
client.unfollow_user(uid=uid, credential=cred),
"取消关注失败",
)
payload = payloads.action_result("unfollow", uid=uid)
def render() -> None:
common.console.print(f"[green]✅ 已取消关注 UID={uid}[/green]")
if common.emit_or_print(payload, output_format, render):
return
"""User and search related commands."""
from __future__ import annotations
import re
import click
from rich.panel import Panel
from rich.table import Table
from .. import payloads
from . import common
def _resolve_uid(uid_or_name: str) -> int:
"""Resolve a UID or username to a numeric UID."""
from .. import client
if uid_or_name.isdigit():
return int(uid_or_name)
results = common.run_or_exit(client.search_user(uid_or_name), "搜索用户失败")
if not results:
common.exit_error(f"未找到用户: {uid_or_name}", code="not_found")
uid_raw = results[0].get("mid")
if uid_raw is None:
common.exit_error(f"搜索结果缺少 UID: {uid_or_name}", code="upstream_error")
try:
uid = int(uid_raw)
except (TypeError, ValueError):
common.exit_error(f"搜索结果 UID 非法: {uid_raw}", code="upstream_error")
common.console.print(f"[dim]🔍 匹配到: {results[0].get('uname', '')} (UID: {uid})[/dim]\n")
return uid
def _format_video_length(length_raw: object) -> str:
"""Normalize different video length payloads into display string."""
if isinstance(length_raw, str):
if ":" in length_raw:
return length_raw
if not length_raw:
return "00:00"
try:
return common.format_duration(int(length_raw))
except ValueError:
return "00:00"
if isinstance(length_raw, int):
return common.format_duration(length_raw)
return "00:00"
@click.command()
@click.argument("uid_or_name")
@common.structured_output_options
def user(uid_or_name: str, as_json: bool, as_yaml: bool):
"""查看 UP 主资料。
UID_OR_NAME 可以是 UID(纯数字)或用户名(搜索第一个匹配)。
"""
from .. import client
output_format = common.resolve_output_format(as_json=as_json, as_yaml=as_yaml)
uid = _resolve_uid(uid_or_name)
info = common.run_or_exit(client.get_user_info(uid, credential=None), "获取用户信息失败")
relation = common.run_or_exit(
client.get_user_relation_info(uid, credential=None),
"获取用户信息失败",
)
structured = {
"user": payloads.normalize_user(info),
"relation": payloads.normalize_relation(relation),
}
if common.emit_structured(structured, output_format):
return
follower = relation.get("follower", 0)
following = relation.get("following", 0)
common.console.print(Panel(
f"👤 [bold]{info.get('name', '')}[/bold] (UID: {uid})\n"
f"⭐ Level {info.get('level', '?')} | "
f"👥 粉丝 {common.format_count(follower)} | "
f"🔔 关注 {common.format_count(following)}",
title="UP 主信息",
border_style="cyan",
))
sign = info.get("sign", "").strip()
if sign:
common.console.print(f"[dim]{sign}[/dim]")
@click.command(name="user-videos")
@click.argument("uid_or_name")
@click.option("--max", "-n", "count", default=10, type=click.IntRange(1), help="显示的视频数量 (默认 10,最小 1)。")
@common.structured_output_options
def user_videos(uid_or_name: str, count: int, as_json: bool, as_yaml: bool):
"""查看 UP 主的视频列表。
UID_OR_NAME 可以是 UID(纯数字)或用户名(搜索第一个匹配)。
"""
from .. import client
output_format = common.resolve_output_format(as_json=as_json, as_yaml=as_yaml)
uid = _resolve_uid(uid_or_name)
videos = common.run_or_exit(
client.get_user_videos(uid, count=count, credential=None),
"获取视频列表失败",
)
if common.emit_structured([payloads.normalize_video_summary(video) for video in videos], output_format):
return
if not videos:
common.console.print("[yellow]该用户暂无视频[/yellow]")
return
table = Table(title=f"最新 {len(videos)} 个视频", border_style="blue")
table.add_column("#", style="dim", width=4)
table.add_column("BV号", style="cyan", width=14)
table.add_column("标题", max_width=40)
table.add_column("时长", width=8)
table.add_column("播放", width=8, justify="right")
for i, v in enumerate(videos, 1):
length_str = _format_video_length(v.get("length", "0"))
table.add_row(
str(i),
v.get("bvid", ""),
v.get("title", "")[:40],
length_str,
common.format_count(v.get("play", 0)),
)
common.console.print(table)
@click.command()
@click.argument("keyword")
@click.option("--type", "search_type", default="user", type=click.Choice(["user", "video"]), help="搜索类型 (默认 user)。")
@click.option("--page", default=1, type=click.IntRange(1), help="页码 (默认 1,最小 1)。")
@click.option("--max", "-n", "count", default=20, type=click.IntRange(1), help="显示数量 (默认 20,最小 1)。")
@common.structured_output_options
def search(keyword: str, search_type: str, page: int, count: int, as_json: bool, as_yaml: bool):
"""搜索用户或视频。"""
from .. import client
output_format = common.resolve_output_format(as_json=as_json, as_yaml=as_yaml)
if search_type == "video":
results = common.run_or_exit(client.search_video(keyword, page=page), "搜索视频失败")
if common.emit_structured([payloads.normalize_search_video(item) for item in results[:count]], output_format):
return
display_results = [v for v in results if v.get("bvid")]
if not display_results:
common.console.print(f"[yellow]未找到与 '{keyword}' 相关的视频[/yellow]")
return
table = Table(title=f"🔍 视频搜索: {keyword}", border_style="blue")
table.add_column("#", style="dim", width=4)
table.add_column("BV号", style="cyan", width=14)
table.add_column("标题", max_width=40)
table.add_column("UP主", width=12)
table.add_column("播放", width=10, justify="right")
table.add_column("时长", width=8)
for i, v in enumerate(display_results[:count], 1):
title = re.sub(r'<[^>]+>', '', v.get("title", ""))[:40]
table.add_row(
str(i),
v.get("bvid", ""),
title,
v.get("author", "")[:12],
common.format_count(v.get("play", 0)),
v.get("duration", ""),
)
common.console.print(table)
else:
results = common.run_or_exit(client.search_user(keyword, page=page), "搜索用户失败")
if common.emit_structured([payloads.normalize_search_user(item) for item in results[:count]], output_format):
return
if not results:
common.console.print(f"[yellow]未找到与 '{keyword}' 相关的用户[/yellow]")
return
table = Table(title=f"🔍 搜索: {keyword}", border_style="blue")
table.add_column("UID", style="cyan", width=12)
table.add_column("用户名", width=20)
table.add_column("粉丝", width=10, justify="right")
table.add_column("视频数", width=8, justify="right")
table.add_column("签名", max_width=40)
for u in results[:count]:
usign = u.get("usign", "")
table.add_row(
str(u.get("mid", "")),
u.get("uname", ""),
common.format_count(u.get("fans", 0)),
str(u.get("videos", 0)),
usign[:40] if usign else "",
)
common.console.print(table)
"""Video related command."""
from __future__ import annotations
import click
from rich.table import Table
from .. import payloads
from . import common
@click.command()
@click.argument("bv_or_url")
@click.option("--subtitle", "-s", is_flag=True, help="显示字幕内容。")
@click.option("--subtitle-timeline", "-st", is_flag=True, help="显示带时间线的字幕。")
@click.option(
"--subtitle-format",
type=click.Choice(["timeline", "srt"]),
default="timeline",
help="字幕格式:timeline 或 srt。",
)
@click.option("--comments", "-c", is_flag=True, help="显示评论。")
@click.option("--ai", is_flag=True, help="显示 AI 总结。")
@click.option("--related", "-r", is_flag=True, help="显示相关推荐视频。")
@common.structured_output_options
def video(
bv_or_url: str,
subtitle: bool,
subtitle_timeline: bool,
subtitle_format: str,
comments: bool,
ai: bool,
related: bool,
as_json: bool,
as_yaml: bool,
):
"""查看视频详情。
BV_OR_URL 可以是 BV 号(如 BV1xxx)或完整 URL。
"""
from .. import client
output_format = common.resolve_output_format(as_json=as_json, as_yaml=as_yaml)
bvid = common.extract_bvid_or_exit(bv_or_url)
needs_optional_cred = subtitle or subtitle_timeline or comments or ai or related
cred = common.get_credential(mode="optional") if needs_optional_cred else None
info = common.run_or_exit(
client.get_video_info(bvid, credential=None),
"获取视频信息失败",
)
subtitle_text = ""
subtitle_items: list[dict] = []
ai_summary = ""
comments_items: list[dict] = []
related_items: list[dict] = []
warnings: list[dict[str, str]] = []
if subtitle or subtitle_timeline:
sub_data = common.run_optional(
client.get_video_subtitle(bvid, credential=cred),
"获取字幕失败",
)
if sub_data is not None:
subtitle_text, subtitle_items = sub_data
else:
warnings.append({"code": "subtitle_unavailable", "message": "获取字幕失败"})
if ai:
ai_data = common.run_optional(
client.get_video_ai_conclusion(bvid, credential=cred),
"获取 AI 总结失败",
)
if ai_data is not None:
ai_summary = ai_data.get("model_result", {}).get("summary", "")
else:
warnings.append({"code": "ai_summary_unavailable", "message": "获取 AI 总结失败"})
if comments:
cm_data = common.run_optional(
client.get_video_comments(bvid, credential=cred),
"获取评论失败",
)
if cm_data is not None:
comments_items = cm_data.get("replies") or []
else:
warnings.append({"code": "comments_unavailable", "message": "获取评论失败"})
if related:
rel_list = common.run_optional(
client.get_related_videos(bvid, credential=cred),
"获取相关推荐失败",
)
if rel_list is not None:
related_items = rel_list
else:
warnings.append({"code": "related_unavailable", "message": "获取相关推荐失败"})
structured_payload = payloads.normalize_video_command_payload(
info,
subtitle_text=subtitle_text,
subtitle_items=subtitle_items,
subtitle_format=subtitle_format if subtitle_timeline else "plain",
ai_summary=ai_summary,
comments=comments_items,
related=related_items,
warnings=warnings,
)
if common.emit_structured(structured_payload, output_format):
return
stat = info.get("stat", {})
owner = info.get("owner", {})
table = Table(title=f"📺 {info.get('title', bvid)}", show_header=False, border_style="blue")
table.add_column("Field", style="bold cyan", width=12)
table.add_column("Value")
table.add_row("BV号", bvid)
table.add_row("标题", info.get("title", ""))
table.add_row("UP主", f"{owner.get('name', '')} (UID: {owner.get('mid', '')})")
table.add_row("时长", common.format_duration(info.get("duration", 0)))
table.add_row("播放", common.format_count(stat.get("view", 0)))
table.add_row("弹幕", common.format_count(stat.get("danmaku", 0)))
table.add_row("点赞", common.format_count(stat.get("like", 0)))
table.add_row("投币", common.format_count(stat.get("coin", 0)))
table.add_row("收藏", common.format_count(stat.get("favorite", 0)))
table.add_row("分享", common.format_count(stat.get("share", 0)))
table.add_row("链接", f"https://www.bilibili.com/video/{bvid}")
desc = info.get("desc", "").strip()
if desc:
table.add_row("简介", desc[:200])
common.console.print(table)
if subtitle or subtitle_timeline:
common.console.print("\n[bold]📝 字幕内容:[/bold]\n")
if subtitle_timeline and subtitle_items:
display_content = client.format_subtitle_timeline(subtitle_items, output_format=subtitle_format)
else:
display_content = subtitle_text
if display_content:
common.console.print(display_content)
else:
common.console.print("[yellow]⚠️ 无字幕(可能需要登录或视频无字幕)[/yellow]")
if ai:
common.console.print("\n[bold]🤖 AI 总结:[/bold]\n")
if ai_summary:
common.console.print(ai_summary)
else:
common.console.print("[yellow]⚠️ 该视频暂无 AI 总结[/yellow]")
if comments:
common.console.print("\n[bold]💬 热门评论:[/bold]\n")
if not comments_items:
common.console.print("[yellow]暂无评论[/yellow]")
else:
for c in comments_items[:10]:
member = c.get("member", {})
content = c.get("content", {}).get("message", "")
likes = c.get("like", 0)
uname = member.get("uname", "")
common.console.print(f" [cyan]{uname}[/cyan] [dim](👍 {likes})[/dim]")
common.console.print(f" {content[:120]}")
common.console.print()
if related:
common.console.print()
if related_items:
table = Table(title="📎 相关推荐", border_style="blue")
table.add_column("#", style="dim", width=4)
table.add_column("BV号", style="cyan", width=14)
table.add_column("标题", max_width=40)
table.add_column("UP主", width=12)
table.add_column("播放", width=8, justify="right")
for i, rv in enumerate(related_items[:10], 1):
ro = rv.get("owner", {})
rs = rv.get("stat", {})
table.add_row(
str(i),
rv.get("bvid", ""),
rv.get("title", "")[:40],
ro.get("name", "")[:12],
common.format_count(rs.get("view", 0)),
)
common.console.print(table)
"""Custom exceptions for bilibili-cli."""
class BiliError(Exception):
"""Base exception for bilibili-cli errors."""
class InvalidBvidError(BiliError):
"""Raised when a BV ID cannot be parsed or is malformed."""
class NetworkError(BiliError):
"""Raised when upstream network/API requests fail."""
class AuthenticationError(BiliError):
"""Raised when authentication data is missing or invalid."""
class RateLimitError(BiliError):
"""Raised when Bilibili rate-limits the request (HTTP 412/429)."""
class NotFoundError(BiliError):
"""Raised when a video, user, or resource is not found."""
"""Shared formatting utilities for bilibili-cli output.
Centralizes structured output (JSON/YAML), number formatting, and the
agent-friendly schema envelope shared across all command modules.
"""
from __future__ import annotations
import json
import os
import sys
from collections.abc import Callable
from typing import NoReturn
import click
import yaml
from rich.console import Console
console = Console(stderr=True)
OutputFormat = str | None
_OUTPUT_ENV = "OUTPUT"
_SCHEMA_VERSION = "1"
def structured_output_options(command: Callable) -> Callable:
"""Add --json/--yaml options to a Click command."""
command = click.option("--yaml", "as_yaml", is_flag=True, help="输出 YAML,推荐给 AI Agent。")(command)
command = click.option("--json", "as_json", is_flag=True, help="输出 JSON。")(command)
return command
def resolve_output_format(*, as_json: bool = False, as_yaml: bool = False) -> OutputFormat:
"""Resolve mutually exclusive machine-readable output flags."""
if as_json and as_yaml:
exit_error("不能同时使用 --json 和 --yaml。")
if as_yaml:
return "yaml"
if as_json:
return "json"
output_mode = os.getenv(_OUTPUT_ENV, "auto").strip().lower()
if output_mode == "yaml":
return "yaml"
if output_mode == "json":
return "json"
if output_mode == "rich":
return None
if not sys.stdout.isatty():
return "yaml"
return None
def emit_structured(data: object, output_format: OutputFormat) -> bool:
"""Serialize data for machine-readable output and report whether emission happened."""
payload = _normalize_success_payload(data)
if output_format == "json":
click.echo(json.dumps(payload, ensure_ascii=False, indent=2))
return True
if output_format == "yaml":
click.echo(yaml.safe_dump(payload, allow_unicode=True, sort_keys=False))
return True
return False
def emit_or_print(data: object, output_format: OutputFormat, render: Callable[[], None]) -> bool:
"""Emit structured data or fall back to a rich/text renderer."""
if emit_structured(data, output_format):
return True
render()
return False
def success_payload(data: object) -> dict[str, object]:
"""Wrap structured success data in the shared agent schema."""
return {
"ok": True,
"schema_version": _SCHEMA_VERSION,
"data": data,
}
def error_payload(code: str, message: str, *, details: object | None = None) -> dict[str, object]:
"""Wrap structured error data in the shared agent schema."""
error: dict[str, object] = {
"code": code,
"message": message,
}
if details is not None:
error["details"] = details
return {
"ok": False,
"schema_version": _SCHEMA_VERSION,
"error": error,
}
def _normalize_success_payload(data: object) -> object:
"""Wrap plain structured data in the shared agent success schema."""
if isinstance(data, dict) and data.get("schema_version") == _SCHEMA_VERSION and "ok" in data:
return data
return success_payload(data)
def exit_error(message: str, *, code: str = "api_error", details: object | None = None) -> NoReturn:
"""Print an error message and exit with non-zero status."""
ctx = click.get_current_context(silent=True)
params = ctx.params if ctx is not None else {}
as_json = bool(params.get("as_json", False))
as_yaml = bool(params.get("as_yaml", False))
output_format = None if as_json and as_yaml else resolve_output_format(as_json=as_json, as_yaml=as_yaml)
if emit_structured(error_payload(code, message, details=details), output_format):
sys.exit(1)
console.print(f"[red]❌ {message}[/red]")
sys.exit(1)
# ── Display formatting ────────────────────────────────────────────────────
def _to_int(value: object, default: int = 0) -> int:
"""Best-effort convert value to int for display-oriented formatting."""
if isinstance(value, int):
return value
if isinstance(value, str):
try:
return int(value.strip())
except ValueError:
return default
return default
def format_duration(seconds: object) -> str:
"""Format seconds into MM:SS or HH:MM:SS."""
seconds_int = _to_int(seconds, default=0)
if seconds_int < 0:
seconds_int = 0
if seconds_int >= 3600:
h, rem = divmod(seconds_int, 3600)
m, s = divmod(rem, 60)
return f"{h}:{m:02d}:{s:02d}"
m, s = divmod(seconds_int, 60)
return f"{m:02d}:{s:02d}"
def format_count(n: object) -> str:
"""Format large numbers with 万 suffix."""
value = _to_int(n, default=0)
if value >= 10000:
return f"{value / 10000:.1f}万"
return str(value)
"""Stable structured payload builders for bilibili-cli commands."""
from __future__ import annotations
import json
import re
from datetime import datetime
from typing import Any
def _to_int(value: object, default: int = 0) -> int:
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
try:
return int(value.strip())
except ValueError:
return default
return default
def _format_duration(seconds: object) -> str:
total = max(_to_int(seconds, 0), 0)
if total >= 3600:
hours, rem = divmod(total, 3600)
minutes, secs = divmod(rem, 60)
return f"{hours}:{minutes:02d}:{secs:02d}"
minutes, secs = divmod(total, 60)
return f"{minutes:02d}:{secs:02d}"
def _strip_html(text: object) -> str:
if not isinstance(text, str):
return ""
return re.sub(r"<[^>]+>", "", text).strip()
def _normalize_url(url: object) -> str:
if not isinstance(url, str):
return ""
return url.strip()
def normalize_user(info: dict[str, Any]) -> dict[str, Any]:
return {
"id": str(info.get("mid", "")),
"name": info.get("name", ""),
"username": info.get("name", ""),
"level": _to_int(info.get("level"), 0),
"coins": _to_int(info.get("coins"), 0),
"sign": info.get("sign", ""),
"vip": info.get("vip", {}) if isinstance(info.get("vip"), dict) else {},
}
def normalize_relation(info: dict[str, Any]) -> dict[str, Any]:
return {
"following": _to_int(info.get("following"), 0),
"follower": _to_int(info.get("follower"), 0),
}
def normalize_video_summary(video: dict[str, Any]) -> dict[str, Any]:
owner = video.get("owner", {}) if isinstance(video.get("owner"), dict) else {}
stat = video.get("stat", {}) if isinstance(video.get("stat"), dict) else {}
duration_seconds = _to_int(video.get("duration"), _to_int(video.get("length"), 0))
url = ""
if isinstance(video.get("bvid"), str) and video.get("bvid"):
url = f"https://www.bilibili.com/video/{video['bvid']}"
return {
"id": str(video.get("bvid") or video.get("aid") or ""),
"bvid": video.get("bvid", ""),
"aid": _to_int(video.get("aid"), 0),
"title": _strip_html(video.get("title")),
"description": video.get("desc", "") or video.get("description", ""),
"duration_seconds": duration_seconds,
"duration": _format_duration(duration_seconds),
"url": url,
"owner": {
"id": str(owner.get("mid", owner.get("id", ""))),
"name": owner.get("name", owner.get("uname", "")),
},
"stats": {
"view": _to_int(stat.get("view", video.get("play", 0)), 0),
"danmaku": _to_int(stat.get("danmaku"), 0),
"like": _to_int(stat.get("like"), 0),
"coin": _to_int(stat.get("coin"), 0),
"favorite": _to_int(stat.get("favorite"), 0),
"share": _to_int(stat.get("share"), 0),
},
}
def normalize_subtitle_items(raw: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
for item in raw or []:
if not isinstance(item, dict):
continue
items.append(
{
"from": float(item.get("from", 0.0) or 0.0),
"to": float(item.get("to", 0.0) or 0.0),
"content": item.get("content", ""),
}
)
return items
def normalize_comment(item: dict[str, Any]) -> dict[str, Any]:
member = item.get("member", {}) if isinstance(item.get("member"), dict) else {}
content = item.get("content", {}) if isinstance(item.get("content"), dict) else {}
return {
"id": str(item.get("rpid_str") or item.get("rpid") or ""),
"author": {
"id": str(member.get("mid", "")),
"name": member.get("uname", ""),
},
"message": content.get("message", ""),
"like": _to_int(item.get("like"), 0),
"reply_count": _to_int(item.get("rcount"), 0),
}
def normalize_related_video(item: dict[str, Any]) -> dict[str, Any]:
return normalize_video_summary(item)
def normalize_search_user(item: dict[str, Any]) -> dict[str, Any]:
return {
"id": str(item.get("mid", "")),
"name": item.get("uname", ""),
"sign": item.get("usign", ""),
"fans": _to_int(item.get("fans"), 0),
"videos": _to_int(item.get("videos"), 0),
}
def normalize_search_video(item: dict[str, Any]) -> dict[str, Any]:
duration = item.get("duration", "")
if not isinstance(duration, str):
duration = _format_duration(duration)
return {
"id": str(item.get("bvid", "")),
"bvid": item.get("bvid", ""),
"title": _strip_html(item.get("title")),
"author": item.get("author", ""),
"play": _to_int(item.get("play"), 0),
"duration": duration,
}
def normalize_favorite_folder(item: dict[str, Any]) -> dict[str, Any]:
return {
"id": _to_int(item.get("id"), 0),
"title": item.get("title", ""),
"media_count": _to_int(item.get("media_count"), 0),
}
def normalize_favorite_media(item: dict[str, Any]) -> dict[str, Any]:
upper = item.get("upper", {}) if isinstance(item.get("upper"), dict) else {}
return {
"id": str(item.get("bvid", "") or item.get("id", "")),
"bvid": item.get("bvid", ""),
"title": item.get("title", ""),
"duration_seconds": _to_int(item.get("duration"), 0),
"duration": _format_duration(item.get("duration")),
"upper": {
"name": upper.get("name", ""),
},
}
def normalize_following_user(item: dict[str, Any]) -> dict[str, Any]:
return {
"id": str(item.get("mid", "")),
"name": item.get("uname", ""),
"sign": item.get("sign", ""),
}
def normalize_history_item(item: dict[str, Any]) -> dict[str, Any]:
history = item.get("history", {}) if isinstance(item.get("history"), dict) else {}
owner = item.get("owner", {}) if isinstance(item.get("owner"), dict) else {}
view_at = _to_int(history.get("view_at", item.get("view_at", 0)), 0)
viewed_at = datetime.fromtimestamp(view_at).isoformat() if view_at > 0 else ""
return {
"id": str(history.get("bvid") or item.get("bvid") or history.get("oid") or ""),
"bvid": history.get("bvid") or item.get("bvid", ""),
"title": item.get("title", "") or item.get("name", ""),
"author": owner.get("name", "") or item.get("author_name", "") or item.get("author", ""),
"viewed_at": viewed_at,
}
def normalize_watch_later_item(item: dict[str, Any]) -> dict[str, Any]:
owner = item.get("owner", {}) if isinstance(item.get("owner"), dict) else {}
return {
"id": str(item.get("bvid", "")),
"bvid": item.get("bvid", ""),
"title": item.get("title", ""),
"author": owner.get("name", ""),
"duration_seconds": _to_int(item.get("duration"), 0),
"duration": _format_duration(item.get("duration")),
}
def _decode_json(value: object) -> dict[str, Any]:
if isinstance(value, dict):
return value
if not isinstance(value, str):
return {}
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
def normalize_dynamic_item(item: dict[str, Any]) -> dict[str, Any]:
modules = item.get("modules", {}) if isinstance(item.get("modules"), dict) else {}
author = modules.get("module_author", {}) if isinstance(modules.get("module_author"), dict) else {}
dynamic_mod = modules.get("module_dynamic", {}) if isinstance(modules.get("module_dynamic"), dict) else {}
stat = modules.get("module_stat", {}) if isinstance(modules.get("module_stat"), dict) else {}
desc = dynamic_mod.get("desc", {}) if isinstance(dynamic_mod.get("desc"), dict) else {}
major = dynamic_mod.get("major", {}) if isinstance(dynamic_mod.get("major"), dict) else {}
archive = major.get("archive", {}) if isinstance(major.get("archive"), dict) else {}
article = major.get("article", {}) if isinstance(major.get("article"), dict) else {}
card = _decode_json(item.get("card"))
desc_info = item.get("desc", {}) if isinstance(item.get("desc"), dict) else {}
dynamic_id = desc_info.get("dynamic_id_str") or desc_info.get("dynamic_id") or item.get("id_str") or item.get("id") or ""
ts = _to_int(desc_info.get("timestamp"), 0)
published_at = datetime.fromtimestamp(ts).isoformat() if ts > 0 else ""
text = desc.get("text", "")
if not text:
for key in ("dynamic", "description", "summary", "title"):
if isinstance(card.get(key), str) and card.get(key):
text = card[key]
break
item_info = card.get("item")
if isinstance(item_info, dict) and not text:
text = item_info.get("content", "") or item_info.get("description", "") or item_info.get("title", "")
title = archive.get("title", "") or article.get("title", "")
comment_info = stat.get("comment", {}) if isinstance(stat.get("comment"), dict) else {}
like_info = stat.get("like", {}) if isinstance(stat.get("like"), dict) else {}
return {
"id": str(dynamic_id),
"author": {
"name": author.get("name", ""),
},
"published_at": published_at,
"published_label": author.get("pub_time", ""),
"title": title,
"text": text,
"stats": {
"comment": _to_int(comment_info.get("count"), 0),
"like": _to_int(like_info.get("count"), 0),
},
}
def normalize_video_command_payload(
info: dict[str, Any],
*,
subtitle_text: str = "",
subtitle_items: list[dict[str, Any]] | None = None,
subtitle_format: str = "timeline",
ai_summary: str = "",
comments: list[dict[str, Any]] | None = None,
related: list[dict[str, Any]] | None = None,
warnings: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
subtitle_payload = {
"available": bool(subtitle_text or subtitle_items),
"format": subtitle_format,
"text": subtitle_text,
"items": normalize_subtitle_items(subtitle_items),
}
return {
"video": normalize_video_summary(info),
"subtitle": subtitle_payload,
"ai_summary": ai_summary,
"comments": [normalize_comment(item) for item in comments or []],
"related": [normalize_related_video(item) for item in related or []],
"warnings": warnings or [],
}
def action_result(action: str, *, success: bool = True, **fields: Any) -> dict[str, Any]:
payload = {"success": success, "action": action}
payload.update(fields)
return payload
Changelog
0.5.0
- Add subtitle timeline output via
bili video --subtitle-timeline/-st - Add
--subtitle-format timeline|srt - Keep subtitle timeline compatible with current
--yaml/--jsoncommand surface - Ensure subtitle timeline requests load optional credentials like plain subtitles
- Restore README badges and fix CI type-checking with
types-PyYAML
[project]
name = "bilibili-cli"
version = "0.6.2"
description = "A CLI for Bilibili — browse videos, users, favorites from the terminal"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.10"
authors = [{ name = "jackwener", email = "jakevingoo@gmail.com" }]
keywords = ["bilibili", "cli", "terminal", "video"]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Console",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Topic :: Multimedia :: Video",
]
dependencies = [
"bilibili-api-python>=16.0",
"click>=8.0",
"rich>=13.0",
"aiohttp>=3.0",
"browser-cookie3>=0.19",
"pyyaml>=6.0",
"qrcode>=7.0",
]
[project.optional-dependencies]
audio = [
"av>=14.0",
]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"pytest-mock>=3.0",
"ruff>=0.11.0",
"mypy>=1.15.0",
"types-PyYAML>=6.0.12",
"av>=14.0",
]
[project.urls]
Homepage = "https://github.com/jackwener/bilibili-cli"
Repository = "https://github.com/jackwener/bilibili-cli"
Issues = "https://github.com/jackwener/bilibili-cli/issues"
[project.scripts]
bili = "bili_cli.cli:cli"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["bili_cli"]
[tool.ruff]
target-version = "py310"
line-length = 140
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
[tool.mypy]
python_version = "3.10"
ignore_missing_imports = true
check_untyped_defs = true
warn_unused_ignores = true
no_implicit_optional = true
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
check_untyped_defs = false
disable_error_code = ["var-annotated", "arg-type"]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = "-m 'not smoke'"
markers = [
"smoke: real-API integration tests (run with: pytest -m smoke)",
]
Structured Output Schema
bilibili-cli uses a shared agent-friendly envelope for machine-readable output.
Success
ok: true
schema_version: "1"
data: ...Error
ok: false
schema_version: "1"
error:
code: api_error
message: 未找到用户: fooNotes
--yamland--jsonboth use this envelope- non-TTY stdout defaults to YAML
- command payloads are normalized at the CLI layer
- list-like results are typically returned under
data.items statusreturnsdata.authenticatedplusdata.userwhoamireturnsdata.useranddata.relationvideoreturnsdata.video,data.subtitle,data.ai_summary,data.comments,data.related, anddata.warnings- write commands return normalized action payloads with
data.successanddata.action
Error Codes
Common structured error codes:
not_authenticatedpermission_deniedinvalid_inputnetwork_errorupstream_errornot_foundinternal_error
"""Shared test fixtures."""
import os
import pytest
from bilibili_api.utils.network import Credential
os.environ.setdefault("OUTPUT", "rich")
@pytest.fixture
def mock_credential():
"""A fake credential for testing."""
return Credential(sessdata="test_sessdata", bili_jct="test_bili_jct")
@pytest.fixture
def mock_video_info():
"""Sample video info response."""
return {
"bvid": "BV1test123",
"title": "测试视频标题",
"aid": 12345,
"duration": 125,
"desc": "这是一个测试视频",
"owner": {"mid": 946974, "name": "TestUP"},
"stat": {
"view": 15000,
"danmaku": 200,
"like": 1200,
"coin": 300,
"favorite": 500,
"share": 100,
},
}
@pytest.fixture
def mock_user_info():
"""Sample user info response."""
return {
"mid": 946974,
"name": "TestUP",
"level": 6,
"sign": "这是签名",
"coins": 2000,
"vip": {"type": 2, "status": 1},
}
@pytest.fixture
def mock_relation_info():
"""Sample user relation info response."""
return {
"mid": 946974,
"following": 100,
"follower": 50000,
}
Related skills
How it compares
Choose bilibili-cli for Bilibili-specific terminal automation; general yt-dlp workflows cover broader video sites but lack Bilibili-focused agent skill integration.
FAQ
What can bilibili-cli do from the terminal?
bilibili-cli downloads Bilibili videos, fetches video metadata, and scrapes public Bilibili content from the command line. The skill integrates into shell sessions and coding agent workflows for automated media pipelines.
How popular is bilibili-cli on skills.sh?
bilibili-cli from jackwener/bilibili-cli ranks 14 on skills.sh with 455 installs. Developers use it for terminal-based Bilibili video retrieval and metadata extraction inside agent pipelines.