Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 28 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ A CLI for Bilibili — browse videos, users, favorites from the terminal 📺
- 📰 **Feed** — dynamic timeline from your follows
- 📂 **Favorites** — browse favorite folders, watch-later, and watch history
- 👍 **Interactions** — like, coin, triple (一键三连)
- 🔐 **Smart auth** — auto-extracts cookies from Chrome/Firefox, or QR code login
- 🔐 **Smart auth** — auto-extracts cookies from Chrome/Chromium/Firefox/Edge/Brave, cookie file import for non-standard browsers, or QR code login
- 📊 **Structured output** — major query commands support `--yaml` and `--json`
- 🤖 **Agent-friendly defaults** — non-TTY stdout defaults to YAML; override with `OUTPUT=yaml|json|rich|auto`
- 📦 **Stable envelope** — see [SCHEMA.md](./SCHEMA.md) for `ok/schema_version/data/error`
Expand Down Expand Up @@ -85,7 +85,9 @@ uv sync --extra dev --reinstall
# Login & account
bili status # Check login status
bili status --yaml # Structured auth status
bili login # QR code login
bili login # Try all browsers, then QR code login
bili login --browser chromium # Try only Chromium
bili login --cookie-file cookies.json # Import cookies from a JSON file
bili whoami # Detailed profile (level, coins, followers)
bili whoami --yaml # Structured profile

Expand Down Expand Up @@ -144,12 +146,17 @@ bili coin BV1ABcsztEcY --yaml # Structured write result

## Authentication

bilibili-cli uses a 3-tier authentication strategy:
`bili login` uses a 3-step strategy — tries each in order and stops on first success:

1. **Saved credential** — loads from `~/.bilibili-cli/credential.json`
2. **Browser cookies** — auto-extracts from Chrome, Firefox, Edge, or Brave
3. **QR code login** — `bili login` displays a QR code in the terminal
1. **Browser cookies** — auto-extracts from Chrome, Chromium, Firefox, Edge, and Brave (shows per-browser results in terminal)
2. **QR code login** — if no browser cookies found, displays a QR code for Bilibili App scan

You can also target a specific browser or load cookies from a file:

- `bili login --browser chromium` — only try Chromium (skip others and QR)
- `bili login --cookie-file cookies.json` — import cookies from a JSON file (for non-standard browsers)

Saved credentials are stored at `~/.bilibili-cli/credential.json` and reused automatically.
Credentials are validated on use for authenticated commands. Expired cookies are automatically cleared, while transient network validation failures keep local credentials for best-effort fallback.
`bili status` exits with code `0` only when authenticated; otherwise it exits with `1`.

Expand Down Expand Up @@ -223,7 +230,7 @@ Once added, AI agents that support the `.agents/skills/` convention will automat

## Troubleshooting

- `需要登录` / `not_authenticated` — Run `bili login` to scan QR code, or ensure you're logged in to bilibili.com in Chrome/Firefox/Edge/Brave.
- `需要登录` / `not_authenticated` — Run `bili login` (auto-tries browsers then QR), or use `bili login --browser <name>` / `bili login --cookie-file <path>` for non-standard setups.
- `HTTP 412` / `RateLimitError` — Bilibili anti-scraping triggered. Wait a moment and retry, or reduce `--max`.
- `无法提取 BV 号` / `InvalidBvidError` — Check the BV ID or URL format. Must be `BV` followed by 10 alphanumeric characters.
- `NetworkError` — Check your network connection. If behind a proxy, ensure it supports the target domain.
Expand All @@ -250,7 +257,7 @@ Structured error codes: `not_authenticated`, `permission_denied`, `invalid_input
- 📰 **动态** — 关注的人的动态时间线
- 📂 **收藏** — 收藏夹浏览、稍后再看、观看历史
- 👍 **互动** — 点赞、投币、一键三连
- 🔐 **智能认证** — 自动提取浏览器 Cookie,或扫码登录
- 🔐 **智能认证** — 自动提取浏览器 Cookie(Chrome/Chromium/Firefox/Edge/Brave),支持 cookie 文件导入,或扫码登录
- 📊 **结构化输出** — 主要查询命令支持 `--yaml` 和 `--json`
- 🤖 **更适合 Agent** — stdout 不是 TTY 时默认输出 YAML,也可以用 `OUTPUT=yaml|json|rich|auto` 覆盖
- 🧱 **规范化 payload** — 结构化输出在命令层做了收口,不再直接暴露原始上游 SDK 返回
Expand Down Expand Up @@ -308,7 +315,9 @@ uv sync --extra dev --reinstall
# 登录与账号
bili status # 检查登录状态
bili status --yaml # 结构化认证状态
bili login # 扫码登录
bili login # 依次尝试浏览器,失败后扫码登录
bili login --browser chromium # 仅从 Chromium 提取 cookie
bili login --cookie-file cookies.json # 从 JSON 文件导入 cookie
bili whoami # 查看个人信息(等级、硬币、粉丝数)
bili whoami --yaml # 结构化个人信息

Expand Down Expand Up @@ -366,12 +375,17 @@ bili coin BV1ABcsztEcY --yaml # 结构化写操作结果

## 认证策略

bilibili-cli 采用三级认证策略:
`bili login` 按顺序尝试以下策略,成功即停止:

1. **浏览器 Cookie** — 依次尝试 Chrome、Chromium、Firefox、Edge、Brave,并在终端逐个显示结果
2. **扫码登录** — 所有浏览器均未找到有效 Cookie 时,在终端显示二维码

也可以指定浏览器或从文件导入 Cookie:

1. **已保存凭证** — 从 `~/.bilibili-cli/credential.json` 加载
2. **浏览器 Cookie** — 自动从 Chrome、Firefox、Edge、Brave 提取
3. **扫码登录** — `bili login` 在终端显示二维码
- `bili login --browser chromium` — 仅尝试 Chromium(跳过其他浏览器和扫码)
- `bili login --cookie-file cookies.json` — 从 JSON 文件导入 Cookie(适用于非常规浏览器)

凭证保存在 `~/.bilibili-cli/credential.json`,后续命令自动复用。
需要认证的命令会自动校验凭证。过期 Cookie 会自动清除;如果只是临时网络异常,不会误清本地凭证(会以 best-effort 继续尝试)。

大部分命令无需登录。字幕、收藏夹、动态和互动操作需要登录。写操作(like/coin/triple/unfollow/dynamic-post/dynamic-delete)需要可写凭证(包含 `bili_jct`)。
Expand Down Expand Up @@ -444,7 +458,7 @@ git clone git@github.com:jackwener/bilibili-cli.git .agents/skills/bilibili-cli

## 常见问题

- `需要登录` — 执行 `bili login` 扫码登录,或确保已在 Chrome/Firefox/Edge/Brave 登录 bilibili.com
- `需要登录` — 执行 `bili login`(自动尝试浏览器后扫码),或使用 `bili login --browser <浏览器>` / `bili login --cookie-file <路径>` 指定来源
- `HTTP 412` / `RateLimitError` — B 站反爬触发,稍等后重试,或减小 `--max`
- `无法提取 BV 号` — 检查 BV 号或 URL 格式,必须是 `BV` + 10 位字母数字
- `NetworkError` — 检查网络连接
Expand Down
151 changes: 119 additions & 32 deletions bili_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ async def _check():
try:
await user.get_self_info(cred)
return True
except NetworkException:
except (NetworkException, TimeoutError, ConnectionError, OSError):
return None
except Exception:
return False
Expand Down Expand Up @@ -172,13 +172,9 @@ def _load_saved_credential() -> Credential | None:
return None


def _extract_browser_credential() -> Credential | None:
"""Extract Bilibili cookies from local browsers using browser-cookie3.
SUPPORTED_BROWSERS = ("chrome", "chromium", "firefox", "edge", "brave")

Runs extraction in a subprocess with timeout to avoid hanging
when the browser is running (Chrome DB lock issue).
"""
extract_script = '''
_BROWSER_PROBE_SCRIPT = '''
import json, sys
try:
import browser_cookie3 as bc3
Expand All @@ -188,27 +184,36 @@ def _extract_browser_credential() -> Credential | None:

browsers = [
("Chrome", bc3.chrome),
("Chromium", bc3.chromium),
("Firefox", bc3.firefox),
("Edge", bc3.edge),
("Brave", bc3.brave),
]

results = []
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
results.append({"browser": name, "status": "found", "cookies": cookies})
else:
results.append({"browser": name, "status": "no_cookie"})
except Exception as e:
results.append({"browser": name, "status": "error", "message": str(e)})

print(json.dumps({"error": "no_cookies"}))
print(json.dumps({"results": results}))
'''


def _run_browser_probe() -> list[dict] | None:
"""Run the browser cookie extraction subprocess and return per-browser results.

Returns None if browser-cookie3 is not installed or the subprocess fails.
"""
try:
result = subprocess.run(
[sys.executable, "-c", extract_script],
[sys.executable, "-c", _BROWSER_PROBE_SCRIPT],
capture_output=True,
text=True,
timeout=15,
Expand All @@ -228,27 +233,9 @@ def _extract_browser_credential() -> Credential | None:
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", ""),
)
return data.get("results", [])

except subprocess.TimeoutExpired:
logger.warning(
Expand All @@ -261,6 +248,106 @@ def _extract_browser_credential() -> Credential | None:
return None


def _credential_from_cookies(cookies: dict[str, str]) -> Credential | None:
"""Build a Credential from a cookie dict, returning None if SESSDATA is missing."""
if not REQUIRED_COOKIES.issubset(cookies):
return None
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", ""),
)


def _extract_browser_credential() -> Credential | None:
"""Extract Bilibili cookies from local browsers (first match wins).

Used internally by get_credential(); for user-facing login with feedback,
use probe_browser_cookies() instead.
"""
results = _run_browser_probe()
if results is None:
return None

for entry in results:
if entry.get("status") == "found":
cred = _credential_from_cookies(entry["cookies"])
if cred:
logger.info("Found valid cookies in %s", entry["browser"])
return cred

logger.debug("No valid Bilibili cookies found in any browser")
return None


def probe_browser_cookies(
browser_name: str | None = None,
) -> tuple[Credential | None, list[dict[str, str]]]:
"""Try browsers and return (credential, attempt_details).

Each entry in attempt_details is {"browser": ..., "status": ...}
where status is one of: "found", "no_cookie", "error".

Args:
browser_name: If set, only return the result for this browser.
"""
results = _run_browser_probe()

if results is None:
browsers = [browser_name] if browser_name else list(SUPPORTED_BROWSERS)
return None, [{"browser": b, "status": "probe_failed"} for b in browsers]

if browser_name:
results = [r for r in results if r["browser"].lower() == browser_name.lower()]

credential = None
attempts: list[dict[str, str]] = []
for entry in results:
status = entry.get("status", "error")
attempt: dict[str, str] = {"browser": entry["browser"], "status": status}
if status == "error":
attempt["message"] = entry.get("message", "")
attempts.append(attempt)

if status == "found" and credential is None:
credential = _credential_from_cookies(entry.get("cookies", {}))

return credential, attempts


def load_credential_from_cookie_file(path: str | Path) -> Credential:
"""Load credential from a user-supplied cookie JSON file.

Accepts both uppercase cookie names (SESSDATA, DedeUserID) and
lowercase field names (sessdata, dedeuserid).
Raises FileNotFoundError or ValueError on failure.
"""
file_path = Path(path)
if not file_path.exists():
raise FileNotFoundError(f"Cookie 文件不存在: {path}")

try:
data = json.loads(file_path.read_text())
except json.JSONDecodeError as e:
raise ValueError(f"Cookie 文件 JSON 格式错误: {e}") from e

sessdata = data.get("SESSDATA", "") or data.get("sessdata", "")
if not sessdata:
raise ValueError("Cookie 文件缺少必填字段: SESSDATA")

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", "") or data.get("dedeuserid", ""),
)


def save_credential(credential: Credential):
"""Save credential to config file with timestamp for TTL tracking."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
Expand Down
29 changes: 29 additions & 0 deletions bili_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,41 @@ def _format_count(n: int) -> str:
return common.format_count(n)


def _configure_proxy():
"""Detect proxy from environment and apply to bilibili-api-python.

bilibili-api-python's AioHTTPClient passes proxy="" by default, which
overrides aiohttp's trust_env and bypasses system proxy settings.
We work around this by explicitly setting the proxy on request_settings.
"""
import logging
import os

proxy = (
os.environ.get("https_proxy")
or os.environ.get("HTTPS_PROXY")
or os.environ.get("http_proxy")
or os.environ.get("HTTP_PROXY")
or os.environ.get("ALL_PROXY")
or os.environ.get("all_proxy")
)
if not proxy:
return

from bilibili_api.utils.network import request_settings

if not request_settings.get_proxy():
request_settings.set_proxy(proxy)
logging.getLogger(__name__).debug("Auto-detected proxy: %s", proxy)


@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)
_configure_proxy()


# Register commands.
Expand Down
6 changes: 3 additions & 3 deletions bili_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ async def get_video_subtitle(
# Download subtitle JSON
try:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(subtitle_url) as resp:
resp.raise_for_status()
subtitle_data = await resp.json(content_type=None)
Expand Down Expand Up @@ -374,7 +374,7 @@ async def _get_video_comments_direct(
headers["Cookie"] = "; ".join(cookies)

timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(api_url, params=params, headers=headers) as resp:
resp.raise_for_status()
payload = await resp.json()
Expand Down Expand Up @@ -668,7 +668,7 @@ async def download_audio(audio_url: str, output_path: str) -> int:

for attempt in range(max_retries):
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) 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)
Expand Down
Loading