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..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 @@ -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/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) 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: