From c53daa0ee9f80d9d3e958c32870cea5e5e8996d4 Mon Sep 17 00:00:00 2001 From: Hawk <3236310017@qq.com> Date: Fri, 3 Apr 2026 16:07:30 +0800 Subject: [PATCH 1/3] feat: add Chromium support and improve login flow - Add Chromium to browser cookie extraction list - Redesign bili login: try all browsers with per-browser terminal feedback, then fall back to QR code login automatically - Add --browser option to target a specific browser - Add --cookie-file option for importing cookies from JSON file (for non-standard browsers) - Refactor browser probe logic to collect all results instead of stopping at first match, enabling detailed user feedback - Update README: authentication docs, usage examples, troubleshooting --- README.md | 42 ++++++---- bili_cli/auth.py | 149 +++++++++++++++++++++++++++-------- bili_cli/commands/account.py | 75 +++++++++++++++++- 3 files changed, 219 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 5825823..2d3e307 100644 --- a/README.md +++ b/README.md @@ -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` @@ -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 @@ -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`. @@ -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 ` / `bili login --cookie-file ` 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. @@ -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 返回 @@ -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 # 结构化个人信息 @@ -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`)。 @@ -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` — 检查网络连接 diff --git a/bili_cli/auth.py b/bili_cli/auth.py index 4e7e7b7..10996bc 100644 --- a/bili_cli/auth.py +++ b/bili_cli/auth.py @@ -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 @@ -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, @@ -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( @@ -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) diff --git a/bili_cli/commands/account.py b/bili_cli/commands/account.py index 5d7f9c3..7760a16 100644 --- a/bili_cli/commands/account.py +++ b/bili_cli/commands/account.py @@ -8,12 +8,83 @@ from rich.panel import Panel from .. import payloads +from ..auth import SUPPORTED_BROWSERS from . import common +_STATUS_LABELS: dict[str, str] = { + "found": "[green]找到有效 Cookie[/green]", + "no_cookie": "[dim]未找到 bilibili Cookie[/dim]", + "error": "[dim]无法访问(未安装或无权限)[/dim]", + "probe_failed": "[dim]探测失败[/dim]", +} + + +def _print_browser_attempts(attempts: list[dict[str, str]]) -> None: + """Print per-browser probe results to the terminal.""" + for a in attempts: + label = _STATUS_LABELS.get(a["status"], a["status"]) + common.console.print(f" {a['browser']:<12} {label}") + @click.command() -def login(): - """扫码登录 Bilibili。""" +@click.option( + "--browser", "-b", + type=click.Choice(SUPPORTED_BROWSERS, case_sensitive=False), + default=None, + help="仅从指定浏览器提取 cookie(跳过其他浏览器和扫码)。", +) +@click.option( + "--cookie-file", "-c", + type=click.Path(exists=True, dir_okay=False), + default=None, + help="从 JSON 文件加载 cookie(适用于非常规浏览器)。", +) +def login(browser: str | None, cookie_file: str | None): + """登录 Bilibili。 + + 默认依次尝试所有浏览器,失败后启动扫码登录。 + 可用 --browser 指定浏览器,或 --cookie-file 导入 cookie 文件。 + """ + from ..auth import ( + _validate_credential, + load_credential_from_cookie_file, + probe_browser_cookies, + save_credential, + ) + + if cookie_file: + try: + file_cred = load_credential_from_cookie_file(cookie_file) + except (FileNotFoundError, ValueError) as e: + common.exit_error(str(e)) + return + save_credential(file_cred) + common.console.print("[green]✅ 已从 cookie 文件加载凭证并保存![/green]") + return + + common.console.print("[bold]🔍 正在尝试从本地浏览器提取 Cookie...[/bold]") + cred, attempts = probe_browser_cookies(browser_name=browser) + _print_browser_attempts(attempts) + + if cred: + found_name = next((a["browser"] for a in attempts if a["status"] == "found"), "browser") + validation = _validate_credential(cred) + if validation is False: + common.console.print( + f"\n[yellow]⚠️ 从 {found_name} 提取的 Cookie 已过期或无效," + "请重新在浏览器登录 bilibili.com。[/yellow]" + ) + else: + save_credential(cred) + common.console.print(f"\n[green]✅ 已从 {found_name} 提取 Cookie 并保存![/green]") + return + + if browser: + common.exit_error(f"无法从 {browser} 提取有效 Cookie。请确保已在该浏览器登录 bilibili.com。") + + common.console.print("\n[yellow]⚠️ 未能从任何浏览器提取到有效的 bilibili Cookie[/yellow]") + common.console.print("[bold]📱 正在启动二维码登录...[/bold]\n") + try: common.run(common.qr_login()) except RuntimeError as e: From db323d357b9c6ad1fb32f59ac73d0fe5cc9831f8 Mon Sep 17 00:00:00 2001 From: Hawk <3236310017@qq.com> Date: Fri, 3 Apr 2026 16:56:21 +0800 Subject: [PATCH 2/3] fix: treat network timeouts as indeterminate during credential validation TimeoutError, ConnectionError, and OSError were falling through to the generic Exception handler, causing valid credentials to be reported as expired when the bilibili API is unreachable or slow. Made-with: Cursor --- bili_cli/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bili_cli/auth.py b/bili_cli/auth.py index 10996bc..422d639 100644 --- a/bili_cli/auth.py +++ b/bili_cli/auth.py @@ -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 From 10d8aa9221cbccb111573564be776ebf141160fe Mon Sep 17 00:00:00 2001 From: Hawk <3236310017@qq.com> Date: Fri, 3 Apr 2026 17:08:59 +0800 Subject: [PATCH 3/3] fix: auto-detect proxy from environment for bilibili-api-python bilibili-api-python's AioHTTPClient defaults proxy="" which overrides aiohttp's trust_env=True, bypassing system proxy (e.g. Clash on RPi). Detect proxy from env vars and set it via request_settings.set_proxy(). Also add trust_env=True to custom aiohttp sessions in client.py. Made-with: Cursor --- bili_cli/cli.py | 29 +++++++++++++++++++++++++++++ bili_cli/client.py | 6 +++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/bili_cli/cli.py b/bili_cli/cli.py index e9da6fd..94d33f0 100644 --- a/bili_cli/cli.py +++ b/bili_cli/cli.py @@ -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. diff --git a/bili_cli/client.py b/bili_cli/client.py index 4edff44..35de269 100644 --- a/bili_cli/client.py +++ b/bili_cli/client.py @@ -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) @@ -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() @@ -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)