diff --git a/README.md b/README.md index 02be9b5..9998d5e 100644 --- a/README.md +++ b/README.md @@ -58,10 +58,13 @@ without leaking yt-dlp options. yt-dlp runs in isolated worker processes, while downloaded assets live in expiring leases and are removed when `MediaAsset` is closed. Persistence requires an explicit `export_to()` call. -Browser credentials are handled by `CookieService`. A task receives a private, -short-lived Cookie lease that is deleted by default. Opt-in retained credentials -are AEAD-encrypted and their master key is stored in the operating-system -keyring; plaintext Cookie files are never retained. +Browser credentials are handled by `AuthManager`. It validates encrypted stored +cookies and can refresh them from Chrome, Edge, Brave, Arc, Chromium, Firefox, +or Safari. Authentication failures are refreshed and retried at most once. Each +task receives a private `0600` Cookie lease that is deleted immediately after +use; retained credentials are AEAD-encrypted with a key stored in the operating +system keyring. Run `noteforge auth --help` for browser, JSON, stdin, and +interactive login options. --- diff --git a/README.zh-CN.md b/README.zh-CN.md index a91ebac..6ce07f7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -35,10 +35,27 @@ 调用方不会接触其参数或下载路径。媒体默认保存在有 TTL 的临时租约中,退出 `MediaAsset` 上下文后立即删除;只有显式调用 `export_to()` 才会持久化。 -浏览器身份由独立 `CookieService` 管理。每个任务只获得权限为 `0600` 的短期 -Cookie 租约,任务结束默认销毁。用户显式选择 `CookiePersistence.RETAIN` 时, -目标平台 Cookie 使用 AEAD 加密保存,主密钥进入系统 Keyring;不会持久化明文 -`cookies.txt`。`config.yaml`、`.noteforge/` 与 `.cache/` 已被 Git 忽略。 +浏览器身份由独立 `AuthManager` 管理。它会验证加密 Store 中的 Cookie,失效时 +从 Chrome、Edge、Brave、Arc、Chromium、Firefox 或 Safari 重新导入,并让认证 +失败的业务请求最多自动重试一次。每个任务只获得权限为 `0600` 的短期 Cookie +租约,任务结束立即销毁;持久凭据使用 AEAD 加密,主密钥进入系统 Keyring。 +不会持久化明文 `cookies.txt`,也不会在日志和错误中输出 Cookie。 + +```bash +# 自动发现浏览器,也可用 --browser chrome 指定来源 +noteforge auth login --platform bilibili + +# 支持扩展 JSON、标准输入和交互式网页登录 +noteforge auth login --platform bilibili ~/Downloads/cookies.json +noteforge auth login --platform bilibili --raw-stdin +noteforge auth login --platform youtube --qr + +noteforge auth status +noteforge auth logout --platform bilibili +``` + +交互登录首次使用前需要运行 `playwright install chromium`。命令行 `--raw` 可能被 +Shell history 记录,推荐使用 `--raw-stdin`。 字幕 fallback 顺序为人工字幕、自动字幕、可选的音频转录器;媒体层目前可解析 VTT、SRT、ASS 和 JSON3,并为 Whisper 实现保留了 `AudioTranscriber` 接口。 diff --git a/pyproject.toml b/pyproject.toml index 87c8616..4532cf7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "cryptography>=45,<48", "httpx>=0.28,<1", "keyring>=25,<27", + "playwright>=1.48,<2", "rich>=13,<15", "typer>=0.12,<1", "yt-dlp[curl-cffi]>=2026.7.4", diff --git a/src/noteforge/auth/__init__.py b/src/noteforge/auth/__init__.py new file mode 100644 index 0000000..e5b4643 --- /dev/null +++ b/src/noteforge/auth/__init__.py @@ -0,0 +1,39 @@ +"""NoteForge 统一认证公共 API。""" + +from noteforge.auth.errors import ( + AuthError, + AuthRequiredError, + CookieExpiredError, + CookieImportError, + CookieValidationError, + InteractiveLoginError, +) +from noteforge.auth.manager import AuthManager +from noteforge.auth.models import AuthPlatform, AuthResult, AuthStatus, CookieSource +from noteforge.auth.providers import ( + BrowserCookieProvider, + JsonCookieProvider, + PlaywrightCookieProvider, + RawCookieProvider, +) +from noteforge.auth.store import CookieStore, EncryptedCookieStore + +__all__ = [ + "AuthError", + "AuthManager", + "AuthPlatform", + "AuthRequiredError", + "AuthResult", + "AuthStatus", + "BrowserCookieProvider", + "CookieExpiredError", + "CookieImportError", + "CookieSource", + "CookieStore", + "CookieValidationError", + "EncryptedCookieStore", + "InteractiveLoginError", + "JsonCookieProvider", + "PlaywrightCookieProvider", + "RawCookieProvider", +] diff --git a/src/noteforge/auth/errors.py b/src/noteforge/auth/errors.py new file mode 100644 index 0000000..148024b --- /dev/null +++ b/src/noteforge/auth/errors.py @@ -0,0 +1,27 @@ +"""认证生命周期中可供业务层分类处理的异常。""" + +from noteforge.exceptions.base import NoteForgeError + + +class AuthError(NoteForgeError): + """认证错误基类。""" + + +class AuthRequiredError(AuthError): + """当前操作需要有效登录态。""" + + +class CookieExpiredError(AuthRequiredError): + """Cookie 已存在但远程平台确认登录态失效。""" + + +class CookieImportError(AuthError): + """无法从指定来源导入 Cookie。""" + + +class CookieValidationError(AuthError): + """Cookie 验证请求失败或返回了无效数据。""" + + +class InteractiveLoginError(AuthError): + """交互式登录失败、取消或超时。""" diff --git a/src/noteforge/auth/manager.py b/src/noteforge/auth/manager.py new file mode 100644 index 0000000..f945fd2 --- /dev/null +++ b/src/noteforge/auth/manager.py @@ -0,0 +1,188 @@ +"""统一编排 Cookie 获取、验证、刷新、持久化与短期租约。""" + +from __future__ import annotations + +import http.cookiejar +import threading +from collections.abc import Callable +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING + +from noteforge.auth.errors import ( + AuthRequiredError, + CookieExpiredError, + CookieImportError, +) +from noteforge.auth.models import AuthPlatform, AuthResult, AuthStatus, CookieSource +from noteforge.auth.providers import ( + BrowserCookieProvider, + JsonCookieProvider, + PlaywrightCookieProvider, + RawCookieProvider, +) +from noteforge.auth.store import CookieStore, EncryptedCookieStore +from noteforge.auth.validator import CookieValidator + +if TYPE_CHECKING: + from noteforge.media.cookies.service import CookieLease, CookieService + + +class AuthManager: + """业务层唯一允许使用的认证生命周期入口。""" + + def __init__( + self, + store: CookieStore | None = None, + *, + validator: CookieValidator | None = None, + cookie_service: CookieService | None = None, + browser_provider_factory: Callable[[str | None], BrowserCookieProvider] + | None = None, + ) -> None: + # 延迟导入可避免 media 公共包初始化时反向加载 AuthManager。 + if cookie_service is None: + from noteforge.media.cookies.service import CookieService + + cookie_service = CookieService() + self.store = store or EncryptedCookieStore() + self.validator = validator or CookieValidator() + self.cookie_service = cookie_service + self._browser_provider_factory = browser_provider_factory or ( + lambda browser: BrowserCookieProvider(browser) + ) + self._locks = {platform: threading.RLock() for platform in AuthPlatform} + + def get_cookie( + self, platform: AuthPlatform, *, browser: str | None = None + ) -> CookieLease: + """读取并验证 Store;无有效凭据时自动从浏览器刷新。""" + + platform = AuthPlatform(platform) + with self._locks[platform]: + stored = self.store.load(platform) + if stored is not None: + result = self.validator.validate(platform, stored) + if result.status is AuthStatus.AUTHENTICATED: + return self.cookie_service.lease(platform.value, stored) + return self.refresh(platform, browser=browser) + + def refresh( + self, platform: AuthPlatform, *, browser: str | None = None + ) -> CookieLease: + """优先从本机浏览器重新导入、验证并持久化 Cookie。""" + + platform = AuthPlatform(platform) + with self._locks[platform]: + provider = self._browser_provider_factory(browser) + try: + cookies = provider.load(platform) + except CookieImportError as error: + if self.store.exists(platform): + raise CookieExpiredError( + f"{platform.value} Cookie 已过期,自动刷新未成功。" + ) from error + raise AuthRequiredError( + f"未找到有效的 {platform.value} 浏览器登录态。" + ) from error + self._validate_required(platform, cookies) + source = provider.source or CookieSource("browser", browser) + self.store.save(platform, cookies, source) + return self.cookie_service.lease(platform.value, cookies) + + def is_authenticated(self, platform: AuthPlatform) -> bool: + """返回当前持久凭据是否通过真实登录态验证。""" + + return self.status(platform).status is AuthStatus.AUTHENTICATED + + def status(self, platform: AuthPlatform) -> AuthResult: + """返回不包含 Cookie 明文的认证状态。""" + + platform = AuthPlatform(platform) + cookies = self.store.load(platform) + if cookies is None: + return AuthResult(platform, AuthStatus.NO_COOKIE) + result = self.validator.validate(platform, cookies) + metadata = ( + self.store.metadata(platform) + if isinstance(self.store, EncryptedCookieStore) + else {} + ) + refreshed = metadata.get("refreshed_at") + try: + refreshed_at = ( + datetime.fromisoformat(refreshed) + if isinstance(refreshed, str) + else None + ) + except ValueError: + refreshed_at = None + return AuthResult( + platform, + result.status, + str(metadata.get("source")) if metadata.get("source") else None, + str(metadata.get("browser")) if metadata.get("browser") else None, + refreshed_at, + ) + + def login_from_browser( + self, platform: AuthPlatform, *, browser: str | None = None + ) -> AuthResult: + """显式从浏览器导入,不复用 Store 中的旧 Cookie。""" + + platform = AuthPlatform(platform) + provider = self._browser_provider_factory(browser) + cookies = provider.load(platform) + result = self._validate_required(platform, cookies) + source = provider.source or CookieSource("browser", browser) + self.store.save(platform, cookies, source) + return AuthResult( + platform, + result.status, + source.kind, + source.browser, + result.refreshed_at, + ) + + def login_from_json(self, platform: AuthPlatform, path: Path) -> AuthResult: + """从扩展导出的 JSON 导入并验证登录态。""" + + return self._login_provider(platform, JsonCookieProvider(path), "json") + + def login_from_raw(self, platform: AuthPlatform, raw_cookie: str) -> AuthResult: + """从 Cookie Header 文本导入并验证登录态。""" + + return self._login_provider(platform, RawCookieProvider(raw_cookie), "raw") + + def login_interactively( + self, platform: AuthPlatform, *, timeout: int = 180 + ) -> AuthResult: + """启动可见浏览器并在真实验证成功后保存 Cookie。""" + + return self._login_provider( + platform, PlaywrightCookieProvider(timeout), "playwright" + ) + + def logout(self, platform: AuthPlatform) -> None: + """清除 NoteForge 保存的指定平台凭据。""" + + self.store.clear(AuthPlatform(platform)) + + def _login_provider(self, platform, provider, source: str) -> AuthResult: + platform = AuthPlatform(platform) + cookies = provider.load(platform) + result = self._validate_required(platform, cookies) + self.store.save(platform, cookies, CookieSource(source)) + return AuthResult( + platform, result.status, source, refreshed_at=result.refreshed_at + ) + + def _validate_required( + self, platform: AuthPlatform, cookies: http.cookiejar.CookieJar + ) -> AuthResult: + result = self.validator.validate(platform, cookies) + if result.status is AuthStatus.AUTHENTICATED: + return result + if result.status is AuthStatus.COOKIE_EXPIRED: + raise CookieExpiredError(f"{platform.value} Cookie 已失效。") + raise CookieImportError(f"导入的 {platform.value} Cookie 缺少必要登录字段。") diff --git a/src/noteforge/auth/models.py b/src/noteforge/auth/models.py new file mode 100644 index 0000000..fc7aa47 --- /dev/null +++ b/src/noteforge/auth/models.py @@ -0,0 +1,41 @@ +"""认证领域模型;所有公开结果均不得包含 Cookie 明文。""" + +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum + + +class AuthPlatform(StrEnum): + """NoteForge 支持认证生命周期的平台。""" + + BILIBILI = "bilibili" + YOUTUBE = "youtube" + + +class AuthStatus(StrEnum): + """凭据验证后的稳定状态。""" + + AUTHENTICATED = "authenticated" + NO_COOKIE = "no_cookie" + MISSING_REQUIRED_FIELDS = "missing_required_fields" + COOKIE_EXPIRED = "cookie_expired" + IMPORT_FAILED = "import_failed" + + +@dataclass(frozen=True, slots=True) +class CookieSource: + """Cookie 来源的非敏感描述。""" + + kind: str + browser: str | None = None + + +@dataclass(frozen=True, slots=True) +class AuthResult: + """可安全展示和记录的认证结果。""" + + platform: AuthPlatform + status: AuthStatus + source: str | None = None + browser: str | None = None + refreshed_at: datetime | None = None diff --git a/src/noteforge/auth/providers/__init__.py b/src/noteforge/auth/providers/__init__.py new file mode 100644 index 0000000..c197640 --- /dev/null +++ b/src/noteforge/auth/providers/__init__.py @@ -0,0 +1,15 @@ +"""NoteForge 内置 Cookie Provider。""" + +from noteforge.auth.providers.base import CookieProvider +from noteforge.auth.providers.browser import BrowserCookieProvider +from noteforge.auth.providers.json_file import JsonCookieProvider +from noteforge.auth.providers.playwright import PlaywrightCookieProvider +from noteforge.auth.providers.raw import RawCookieProvider + +__all__ = [ + "BrowserCookieProvider", + "CookieProvider", + "JsonCookieProvider", + "PlaywrightCookieProvider", + "RawCookieProvider", +] diff --git a/src/noteforge/auth/providers/base.py b/src/noteforge/auth/providers/base.py new file mode 100644 index 0000000..d68a145 --- /dev/null +++ b/src/noteforge/auth/providers/base.py @@ -0,0 +1,72 @@ +"""Cookie Provider 的公共协议与安全过滤工具。""" + +import http.cookiejar +from typing import Protocol + +from noteforge.auth.models import AuthPlatform + + +class CookieProvider(Protocol): + """从单一来源加载 Cookie 的扩展协议。""" + + def load(self, platform: AuthPlatform) -> http.cookiejar.CookieJar: ... + + +PLATFORM_DOMAINS = { + AuthPlatform.BILIBILI: ("bilibili.com",), + AuthPlatform.YOUTUBE: ( + "youtube.com", + "google.com", + "googlevideo.com", + "youtu.be", + ), +} + + +def filter_cookies( + source: http.cookiejar.CookieJar, platform: AuthPlatform +) -> http.cookiejar.MozillaCookieJar: + """仅复制目标平台根域及其子域 Cookie。""" + + result = http.cookiejar.MozillaCookieJar() + for cookie in source: + domain = cookie.domain.lstrip(".").casefold() + if any( + domain == allowed or domain.endswith("." + allowed) + for allowed in PLATFORM_DOMAINS[platform] + ): + result.set_cookie(cookie) + return result + + +def make_cookie( + name: str, + value: str, + domain: str, + *, + path: str = "/", + expires: int | None = None, + secure: bool = True, +) -> http.cookiejar.Cookie: + """把受控输入转换成标准 CookieJar 条目。""" + + normalized_domain = domain if domain.startswith(".") else "." + domain + return http.cookiejar.Cookie( + 0, + name, + value, + None, + False, + normalized_domain, + True, + normalized_domain.startswith("."), + path or "/", + True, + secure, + expires, + False, + None, + None, + {}, + False, + ) diff --git a/src/noteforge/auth/providers/browser.py b/src/noteforge/auth/providers/browser.py new file mode 100644 index 0000000..10887f9 --- /dev/null +++ b/src/noteforge/auth/providers/browser.py @@ -0,0 +1,65 @@ +"""从本机浏览器安全导入目标平台 Cookie。""" + +from __future__ import annotations + +import http.cookiejar +import sys +from pathlib import Path + +from noteforge.auth.errors import CookieImportError +from noteforge.auth.models import AuthPlatform, CookieSource +from noteforge.auth.providers.base import filter_cookies + + +class BrowserCookieProvider: + """复用 yt-dlp 的跨平台浏览器 Cookie 解密能力。""" + + DEFAULT_BROWSERS = ( + "chrome", + "edge", + "brave", + "arc", + "chromium", + "firefox", + "safari", + ) + + def __init__(self, browser: str | None = None, profile: str | None = None) -> None: + self.browser = browser.casefold() if browser else None + self.profile = profile + self.source: CookieSource | None = None + + def load(self, platform: AuthPlatform) -> http.cookiejar.CookieJar: + """按优先级返回第一个包含目标域 Cookie 的浏览器。""" + + failures: list[str] = [] + browsers = (self.browser,) if self.browser else self.DEFAULT_BROWSERS + for browser in browsers: + try: + jar = self._extract(browser) + filtered = filter_cookies(jar, platform) + if list(filtered): + self.source = CookieSource("browser", browser) + return filtered + except Exception: + # 浏览器错误可能包含系统路径或解密细节,不向上拼接原始消息。 + failures.append(browser) + attempted = "、".join(failures or browsers) + raise CookieImportError(f"无法从本机浏览器导入有效 Cookie:{attempted}") + + def _extract(self, browser: str) -> http.cookiejar.CookieJar: + from yt_dlp.cookies import extract_cookies_from_browser + + if browser != "arc": + return extract_cookies_from_browser(browser, profile=self.profile) + profile = self.profile or str(self._arc_profile()) + # Arc 使用 Chromium Cookie 格式,显式传入其用户数据目录。 + return extract_cookies_from_browser("chrome", profile=profile) + + @staticmethod + def _arc_profile() -> Path: + if sys.platform == "darwin": + return Path.home() / "Library/Application Support/Arc/User Data/Default" + if sys.platform.startswith("win"): + return Path.home() / "AppData/Local/Arc/User Data/Default" + return Path.home() / ".config/Arc/User Data/Default" diff --git a/src/noteforge/auth/providers/json_file.py b/src/noteforge/auth/providers/json_file.py new file mode 100644 index 0000000..8769916 --- /dev/null +++ b/src/noteforge/auth/providers/json_file.py @@ -0,0 +1,72 @@ +"""解析浏览器扩展导出的 JSON Cookie。""" + +import http.cookiejar +import json +from pathlib import Path + +from noteforge.auth.errors import CookieImportError +from noteforge.auth.models import AuthPlatform +from noteforge.auth.providers.base import PLATFORM_DOMAINS, filter_cookies, make_cookie + + +class JsonCookieProvider: + """支持常见 Cookie 数组及简单 name-value 对象。""" + + def __init__(self, path: Path) -> None: + self.path = path + + def load(self, platform: AuthPlatform) -> http.cookiejar.CookieJar: + """读取 JSON 并严格过滤到目标平台域名。""" + + try: + value = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise CookieImportError("无法读取 Cookie JSON 文件。") from error + jar = http.cookiejar.CookieJar() + if isinstance(value, dict): + domain = PLATFORM_DOMAINS[platform][0] + for name, cookie_value in value.items(): + if isinstance(name, str) and isinstance(cookie_value, str): + jar.set_cookie(make_cookie(name, cookie_value, domain)) + elif isinstance(value, list): + for item in value: + if not isinstance(item, dict): + continue + name, cookie_value, domain = ( + item.get("name"), + item.get("value"), + item.get("domain"), + ) + # 分别收窄类型,避免类型检查器无法从 all() 推断三个字段均为字符串。 + if not isinstance(name, str): + continue + if not isinstance(cookie_value, str): + continue + if not isinstance(domain, str): + continue + raw_path = item.get("path") + cookie_path = raw_path if isinstance(raw_path, str) else "/" + expires = item.get("expirationDate", item.get("expires")) + cookie_expires = ( + int(expires) + if isinstance(expires, (int, float)) + and not isinstance(expires, bool) + and expires > 0 + else None + ) + jar.set_cookie( + make_cookie( + name, + cookie_value, + domain, + path=cookie_path, + expires=cookie_expires, + secure=bool(item.get("secure", True)), + ) + ) + else: + raise CookieImportError("Cookie JSON 必须是数组或键值对象。") + result = filter_cookies(jar, platform) + if not list(result): + raise CookieImportError("Cookie JSON 中没有目标平台 Cookie。") + return result diff --git a/src/noteforge/auth/providers/playwright.py b/src/noteforge/auth/providers/playwright.py new file mode 100644 index 0000000..bdd8c7f --- /dev/null +++ b/src/noteforge/auth/providers/playwright.py @@ -0,0 +1,104 @@ +"""使用临时 Playwright 上下文完成交互式网页登录。""" + +import http.cookiejar +from collections.abc import Iterable, Mapping +from time import monotonic, sleep + +from noteforge.auth.errors import InteractiveLoginError +from noteforge.auth.models import AuthPlatform +from noteforge.auth.providers.base import filter_cookies, make_cookie + + +class PlaywrightCookieProvider: + """打开可见浏览器,等待用户完成 Bilibili 或 YouTube 登录。""" + + LOGIN_URLS = { + AuthPlatform.BILIBILI: "https://passport.bilibili.com/login", + AuthPlatform.YOUTUBE: "https://accounts.google.com/ServiceLogin?service=youtube", + } + + def __init__(self, timeout: int = 180) -> None: + self.timeout = timeout + + def load(self, platform: AuthPlatform) -> http.cookiejar.CookieJar: + """登录成功后提取目标域 Cookie,并销毁临时浏览器上下文。""" + + try: + from playwright.sync_api import sync_playwright + except ImportError as error: + raise InteractiveLoginError( + "交互登录需要安装 Playwright,并执行 playwright install chromium。" + ) from error + try: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=False) + context = browser.new_context() + page = context.new_page() + page.goto(self.LOGIN_URLS[platform]) + deadline = monotonic() + self.timeout + while monotonic() < deadline: + jar = self._from_playwright(context.cookies()) + filtered = filter_cookies(jar, platform) + if self._has_session_marker(platform, filtered): + context.close() + browser.close() + return filtered + sleep(1) + context.close() + browser.close() + except InteractiveLoginError: + raise + except Exception as error: + raise InteractiveLoginError("交互登录窗口被关闭或启动失败。") from error + raise InteractiveLoginError("交互登录超时,未检测到有效登录态。") + + @staticmethod + def _from_playwright( + items: Iterable[Mapping[str, object]], + ) -> http.cookiejar.CookieJar: + """把 Playwright Cookie 映射转换为标准 CookieJar。""" + + jar = http.cookiejar.CookieJar() + for item in items: + name, value, domain = ( + item.get("name"), + item.get("value"), + item.get("domain"), + ) + # TypedDict 经过 all() 后无法可靠收窄,逐项检查可兼容 Pyright。 + if not isinstance(name, str): + continue + if not isinstance(value, str): + continue + if not isinstance(domain, str): + continue + raw_path = item.get("path") + cookie_path = raw_path if isinstance(raw_path, str) else "/" + expires = item.get("expires") + cookie_expires = ( + int(expires) + if isinstance(expires, (int, float)) + and not isinstance(expires, bool) + and expires > 0 + else None + ) + jar.set_cookie( + make_cookie( + name, + value, + domain, + path=cookie_path, + expires=cookie_expires, + secure=bool(item.get("secure", True)), + ) + ) + return jar + + @staticmethod + def _has_session_marker( + platform: AuthPlatform, jar: http.cookiejar.CookieJar + ) -> bool: + names = {cookie.name for cookie in jar} + if platform is AuthPlatform.BILIBILI: + return "SESSDATA" in names and "DedeUserID" in names + return bool(names & {"SAPISID", "__Secure-3PAPISID", "SID"}) diff --git a/src/noteforge/auth/providers/raw.py b/src/noteforge/auth/providers/raw.py new file mode 100644 index 0000000..d54f1ae --- /dev/null +++ b/src/noteforge/auth/providers/raw.py @@ -0,0 +1,31 @@ +"""解析用户显式提供的 Cookie Header 文本。""" + +import http.cookiejar +from http.cookies import SimpleCookie + +from noteforge.auth.errors import CookieImportError +from noteforge.auth.models import AuthPlatform +from noteforge.auth.providers.base import PLATFORM_DOMAINS, make_cookie + + +class RawCookieProvider: + """把 name=value 列表转换成平台限定 CookieJar。""" + + def __init__(self, raw_cookie: str) -> None: + self.raw_cookie = raw_cookie + + def load(self, platform: AuthPlatform) -> http.cookiejar.CookieJar: + """解析 Cookie Header;错误消息绝不回显原文。""" + + parsed = SimpleCookie() + try: + parsed.load(self.raw_cookie) + except Exception as error: + raise CookieImportError("无法解析粘贴的 Cookie。") from error + if not parsed: + raise CookieImportError("粘贴的 Cookie 为空或格式无效。") + jar = http.cookiejar.CookieJar() + domain = PLATFORM_DOMAINS[platform][0] + for name, morsel in parsed.items(): + jar.set_cookie(make_cookie(name, morsel.value, domain)) + return jar diff --git a/src/noteforge/auth/signing.py b/src/noteforge/auth/signing.py new file mode 100644 index 0000000..5becad5 --- /dev/null +++ b/src/noteforge/auth/signing.py @@ -0,0 +1,11 @@ +"""平台请求签名扩展点。 + +Bilibili 的 WBI/CSRF 只应在具体接口明确要求时使用;YouTube 播放器签名继续 +由 yt-dlp 维护。本模块刻意不提供与当前平台无关的 MTOP 签名。 +""" + + +def csrf_token(cookie_values: dict[str, str]) -> str | None: + """返回 Bilibili bili_jct CSRF token;不存在时返回空。""" + + return cookie_values.get("bili_jct") diff --git a/src/noteforge/auth/store.py b/src/noteforge/auth/store.py new file mode 100644 index 0000000..b00aa92 --- /dev/null +++ b/src/noteforge/auth/store.py @@ -0,0 +1,226 @@ +"""Cookie 持久化接口及基于现有 Vault 约定的加密实现。""" + +from __future__ import annotations + +import http.cookiejar +import json +import os +import secrets +import shutil +import tempfile +from collections.abc import Iterable +from datetime import UTC, datetime +from pathlib import Path +from typing import Protocol + +from noteforge.auth.models import AuthPlatform, CookieSource + + +class CookieStore(Protocol): + """Cookie 持久化边界,不负责导入或远程验证。""" + + def load(self, platform: AuthPlatform) -> http.cookiejar.CookieJar | None: ... + + def save( + self, + platform: AuthPlatform, + cookies: http.cookiejar.CookieJar, + source: CookieSource, + ) -> None: ... + + def clear(self, platform: AuthPlatform) -> None: ... + + def exists(self, platform: AuthPlatform) -> bool: ... + + +class EncryptedCookieStore: + """使用 AES-GCM 和系统 Keyring 保存每个平台的当前凭据。""" + + def __init__(self, root: Path | None = None) -> None: + self.root = root or Path.home() / ".noteforge" / "credentials" + + def load(self, platform: AuthPlatform) -> http.cookiejar.CookieJar | None: + """解密并返回 CookieJar;不存在时返回空。""" + + target = self._platform_root(platform) + blob = target / "cookies.enc" + if not blob.exists(): + return self._load_legacy(platform) + try: + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + payload = blob.read_bytes() + nonce, ciphertext = payload[:12], payload[12:] + plain = AESGCM(self._vault_key()).decrypt( + nonce, + ciphertext, + f"noteforge:{platform.value}:v2".encode(), + ) + return self._jar_from_bytes(plain) + except Exception as error: + raise RuntimeError("无法读取加密 Cookie 存储。") from error + + def save( + self, + platform: AuthPlatform, + cookies: http.cookiejar.CookieJar, + source: CookieSource, + ) -> None: + """验证非空后原子替换指定平台的加密 Cookie。""" + + if not list(cookies): + raise ValueError("不能保存空 Cookie。") + try: + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + except ImportError as error: + raise RuntimeError("加密保存 Cookie 需要 cryptography。") from error + target = self._platform_root(platform) + target.mkdir(parents=True, exist_ok=True, mode=0o700) + os.chmod(target, 0o700) + nonce = secrets.token_bytes(12) + encrypted = nonce + AESGCM(self._vault_key()).encrypt( + nonce, + self._jar_bytes(cookies), + f"noteforge:{platform.value}:v2".encode(), + ) + self._atomic_write(target / "cookies.enc", encrypted) + metadata = json.dumps( + { + "platform": platform.value, + "source": source.kind, + "browser": source.browser, + "refreshed_at": datetime.now(UTC).isoformat(), + "cookie_count": len(list(cookies)), + "version": 2, + }, + ensure_ascii=False, + ).encode() + self._atomic_write(target / "metadata.json", metadata) + + def clear(self, platform: AuthPlatform) -> None: + """删除 NoteForge 保存的凭据,不修改浏览器 Cookie。""" + + shutil.rmtree(self._platform_root(platform), ignore_errors=True) + for root, metadata in self._legacy_entries(platform): + del metadata + shutil.rmtree(root, ignore_errors=True) + + def exists(self, platform: AuthPlatform) -> bool: + """判断指定平台是否有加密 Cookie。""" + + return (self._platform_root(platform) / "cookies.enc").exists() or bool( + self._legacy_entries(platform) + ) + + def metadata(self, platform: AuthPlatform) -> dict[str, object]: + """读取不包含敏感值的来源元数据。""" + + try: + value = json.loads( + (self._platform_root(platform) / "metadata.json").read_text( + encoding="utf-8" + ) + ) + return value if isinstance(value, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + def _platform_root(self, platform: AuthPlatform) -> Path: + return self.root / platform.value + + def _load_legacy(self, platform: AuthPlatform) -> http.cookiejar.CookieJar | None: + """兼容读取原 CookieService 创建的 v1 随机凭据目录。""" + + entries = self._legacy_entries(platform) + if not entries: + return None + root, metadata = max( + entries, + key=lambda item: str(item[1].get("refreshed_at", "")), + ) + try: + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + payload = (root / "cookies.enc").read_bytes() + nonce, ciphertext = payload[:12], payload[12:] + credential_id = str(metadata["id"]) + plain = AESGCM(self._vault_key()).decrypt( + nonce, + ciphertext, + f"noteforge:{credential_id}:{platform.value}:v1".encode(), + ) + return self._jar_from_bytes(plain) + except Exception as error: + raise RuntimeError("无法读取旧版加密 Cookie 存储。") from error + + def _legacy_entries( + self, platform: AuthPlatform + ) -> list[tuple[Path, dict[str, object]]]: + entries: list[tuple[Path, dict[str, object]]] = [] + if not self.root.exists(): + return entries + for path in self.root.glob("*/metadata.json"): + if path.parent.name in {item.value for item in AuthPlatform}: + continue + try: + metadata = json.loads(path.read_text(encoding="utf-8")) + if ( + isinstance(metadata, dict) + and metadata.get("platform") == platform.value + and (path.parent / "cookies.enc").exists() + ): + entries.append((path.parent, metadata)) + except (OSError, json.JSONDecodeError): + continue + return entries + + @staticmethod + def _atomic_write(path: Path, value: bytes) -> None: + temp = path.with_name(f".{path.name}.{secrets.token_hex(6)}.tmp") + try: + temp.write_bytes(value) + os.chmod(temp, 0o600) + temp.replace(path) + finally: + temp.unlink(missing_ok=True) + + @staticmethod + def _jar_bytes(cookies: Iterable[http.cookiejar.Cookie]) -> bytes: + jar = http.cookiejar.MozillaCookieJar() + for cookie in cookies: + jar.set_cookie(cookie) + with tempfile.NamedTemporaryFile() as output: + jar.filename = output.name + jar.save(ignore_discard=True, ignore_expires=True) + return Path(output.name).read_bytes() + + @staticmethod + def _jar_from_bytes(value: bytes) -> http.cookiejar.MozillaCookieJar: + with tempfile.NamedTemporaryFile() as source: + Path(source.name).write_bytes(value) + jar = http.cookiejar.MozillaCookieJar(source.name) + jar.load(ignore_discard=True, ignore_expires=True) + jar.filename = None + return jar + + @staticmethod + def _vault_key() -> bytes: + supplied = os.environ.get("NOTEFORGE_COOKIE_VAULT_KEY") + if supplied: + try: + key = bytes.fromhex(supplied) + except ValueError as error: + raise RuntimeError("Cookie Vault 密钥必须是十六进制。") from error + if len(key) != 32: + raise RuntimeError("Cookie Vault 密钥必须为 256 位。") + return key + try: + import keyring + except ImportError as error: + raise RuntimeError("加密保存 Cookie 需要系统 keyring。") from error + service, account = "noteforge-cookie-vault", "local-master-key" + stored = keyring.get_password(service, account) + if stored is None: + stored = secrets.token_hex(32) + keyring.set_password(service, account, stored) + return bytes.fromhex(stored) diff --git a/src/noteforge/auth/validator.py b/src/noteforge/auth/validator.py new file mode 100644 index 0000000..9644311 --- /dev/null +++ b/src/noteforge/auth/validator.py @@ -0,0 +1,94 @@ +"""通过关键字段和低成本远程请求验证平台登录态。""" + +from __future__ import annotations + +import http.cookiejar +from collections.abc import Callable +from datetime import UTC, datetime + +import httpx + +from noteforge.auth.errors import CookieValidationError +from noteforge.auth.models import AuthPlatform, AuthResult, AuthStatus + + +class CookieValidator: + """验证 Cookie 完整性以及远程平台确认的真实登录态。""" + + BILIBILI_REQUIRED = {"SESSDATA", "DedeUserID", "bili_jct"} + YOUTUBE_MARKERS = {"SID", "SAPISID", "__Secure-3PAPISID"} + BROWSER_HEADERS = { + "Accept": "application/json, text/plain, */*", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/140.0.0.0 Safari/537.36" + ), + } + + def __init__( + self, + client_factory: Callable[[], httpx.Client] | None = None, + ) -> None: + self._client_factory = client_factory or ( + lambda: httpx.Client(timeout=10, follow_redirects=True) + ) + + def validate( + self, + platform: AuthPlatform, + cookies: http.cookiejar.CookieJar, + ) -> AuthResult: + """返回稳定状态;网络错误不会被误判为 Cookie 过期。""" + + items = list(cookies) + if not items: + return AuthResult(platform, AuthStatus.NO_COOKIE) + names = {cookie.name for cookie in items} + if platform is AuthPlatform.BILIBILI: + complete = self.BILIBILI_REQUIRED <= names + else: + complete = bool(self.YOUTUBE_MARKERS & names) + if not complete: + return AuthResult(platform, AuthStatus.MISSING_REQUIRED_FIELDS) + try: + with self._client_factory() as client: + if platform is AuthPlatform.BILIBILI: + response = client.get( + "https://api.bilibili.com/x/web-interface/nav", + cookies=self._cookie_dict(cookies), + # Bilibili 会拒绝缺少常规浏览器指纹的 API 请求并返回 412。 + headers=self.BROWSER_HEADERS + | { + "Referer": "https://www.bilibili.com/", + "Origin": "https://www.bilibili.com", + }, + ) + response.raise_for_status() + payload = response.json() + valid = bool( + isinstance(payload, dict) + and isinstance(payload.get("data"), dict) + and payload["data"].get("isLogin") is True + ) + else: + response = client.get( + "https://www.youtube.com/feed/subscriptions", + cookies=self._cookie_dict(cookies), + headers=self.BROWSER_HEADERS, + ) + response.raise_for_status() + # YouTube 登录页面会包含稳定的登录标志;不记录响应正文。 + valid = '"LOGGED_IN":true' in response.text.replace(" ", "") + except (httpx.HTTPError, ValueError) as error: + raise CookieValidationError("认证状态验证请求失败。") from error + return AuthResult( + platform, + AuthStatus.AUTHENTICATED if valid else AuthStatus.COOKIE_EXPIRED, + refreshed_at=datetime.now(UTC) if valid else None, + ) + + @staticmethod + def _cookie_dict(cookies: http.cookiejar.CookieJar) -> dict[str, str]: + return {cookie.name: cookie.value for cookie in cookies} diff --git a/src/noteforge/cli/app.py b/src/noteforge/cli/app.py index 99a3432..450075c 100644 --- a/src/noteforge/cli/app.py +++ b/src/noteforge/cli/app.py @@ -4,7 +4,7 @@ import typer -from noteforge.cli.commands import configure, doctor, generate, inspect +from noteforge.cli.commands import auth, configure, doctor, generate, inspect _DISTRIBUTION_NAME = "noteforge-cli" @@ -39,6 +39,7 @@ def cli( app.command()(doctor) app.command()(inspect) app.command()(generate) +app.add_typer(auth.app, name="auth") def main() -> None: diff --git a/src/noteforge/cli/commands/auth.py b/src/noteforge/cli/commands/auth.py new file mode 100644 index 0000000..c590565 --- /dev/null +++ b/src/noteforge/cli/commands/auth.py @@ -0,0 +1,123 @@ +"""认证生命周期 CLI 命令。""" + +import sys +from pathlib import Path + +import typer + +from noteforge.auth import AuthError, AuthManager, AuthPlatform, AuthStatus + +app = typer.Typer(help="管理 Bilibili 和 YouTube 登录态。", no_args_is_help=True) + + +def _platform(value: str) -> AuthPlatform: + try: + return AuthPlatform(value.casefold()) + except ValueError as error: + raise typer.BadParameter("平台必须是 bilibili 或 youtube。") from error + + +@app.command("login") +def login( + source: str | None = typer.Argument( + None, help="Cookie JSON 路径,或与 --raw 配合使用的 Cookie 文本。" + ), + platform: str = typer.Option( + "bilibili", "--platform", help="认证平台:bilibili 或 youtube。" + ), + browser: str | None = typer.Option( + None, "--browser", help="指定浏览器;默认自动发现。" + ), + raw: bool = typer.Option( + False, + "--raw", + help="把位置参数作为 Cookie Header 解析(可能进入 shell history)。", + ), + raw_stdin: bool = typer.Option( + False, "--raw-stdin", help="从标准输入安全读取 Cookie Header。" + ), + qr: bool = typer.Option(False, "--qr", help="打开可见浏览器完成交互登录。"), + timeout: int = typer.Option(180, "--timeout", min=10, help="交互登录超时秒数。"), +) -> None: + """从浏览器、JSON、文本或交互窗口导入并加密保存 Cookie。""" + + selected = _platform(platform) + modes = sum((source is not None, raw_stdin, qr)) + if modes > 1 or (raw and source is None): + raise typer.BadParameter("JSON、raw、stdin 和 QR 登录方式不能同时使用。") + manager = AuthManager() + try: + if qr: + result = manager.login_interactively(selected, timeout=timeout) + elif raw_stdin: + result = manager.login_from_raw(selected, sys.stdin.read().strip()) + elif source is not None and raw: + typer.secho( + "警告:命令行 Cookie 可能被 shell history 记录,推荐使用 --raw-stdin。", + fg=typer.colors.YELLOW, + err=True, + ) + result = manager.login_from_raw(selected, source) + elif source is not None: + result = manager.login_from_json(selected, Path(source)) + else: + typer.echo("正在检查本机浏览器登录态...") + result = manager.login_from_browser(selected, browser=browser) + except AuthError as error: + typer.secho(f"认证失败:{error}", fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) from error + detail = f"({result.browser})" if result.browser else "" + typer.secho(f"✓ {selected.value} 登录状态验证成功{detail}", fg=typer.colors.GREEN) + typer.secho("✓ Cookie 已加密保存", fg=typer.colors.GREEN) + + +@app.command("status") +def status( + platform: str | None = typer.Option( + None, "--platform", help="只检查 bilibili 或 youtube。" + ), +) -> None: + """检查已保存 Cookie 的真实登录状态。""" + + platforms = (_platform(platform),) if platform else tuple(AuthPlatform) + manager = AuthManager() + failed = False + for selected in platforms: + try: + result = manager.status(selected) + except AuthError as error: + typer.secho( + f"{selected.value:<10} validation error {error}", fg=typer.colors.RED + ) + failed = True + continue + label = { + AuthStatus.AUTHENTICATED: "authenticated", + AuthStatus.COOKIE_EXPIRED: "cookie expired", + AuthStatus.NO_COOKIE: "no cookie", + AuthStatus.MISSING_REQUIRED_FIELDS: "missing fields", + AuthStatus.IMPORT_FAILED: "import failed", + }[result.status] + refreshed = ( + result.refreshed_at.strftime("%Y-%m-%d %H:%M") + if result.refreshed_at + else "-" + ) + typer.echo( + f"{selected.value:<10} {label:<18} {result.browser or '-':<10} {refreshed}" + ) + if failed: + raise typer.Exit(code=1) + + +@app.command("logout") +def logout( + platform: str = typer.Option( + "bilibili", "--platform", help="清除 bilibili 或 youtube 凭据。" + ), +) -> None: + """删除 NoteForge 加密凭据,不修改浏览器登录态。""" + + selected = _platform(platform) + AuthManager().logout(selected) + typer.echo(f"已清除 {selected.value} 的 NoteForge 凭据。") diff --git a/src/noteforge/cli/commands/generate.py b/src/noteforge/cli/commands/generate.py index 829fbb2..c367343 100644 --- a/src/noteforge/cli/commands/generate.py +++ b/src/noteforge/cli/commands/generate.py @@ -15,7 +15,7 @@ from noteforge.llm import create_llm_client from noteforge.media import collect_video from noteforge.media import source as inspection -from noteforge.media.models import VideoResource +from noteforge.media.models import SubtitleAccessStatus, VideoResource from noteforge.run import RunRecorder @@ -80,6 +80,15 @@ def _run_preflight( ) if not collection.transcript: ui.failure("字幕", _subtitle_description(collection)) + if collection.subtitle_status is SubtitleAccessStatus.LOGIN_REQUIRED: + raise NoteForgeError( + "匿名状态下未发现字幕;请先运行 " + "`noteforge auth login --platform bilibili` 后重试。" + ) + if collection.subtitle_status is SubtitleAccessStatus.COOKIE_EXPIRED: + raise NoteForgeError( + "Cookie 已过期且自动刷新失败;请重新登录或手动导入 Cookie。" + ) raise NoteForgeError( "视频没有可供处理的 VTT 或 SRT 字幕;" "可先运行 `noteforge doctor <视频URL>` 检查访问权限。" diff --git a/src/noteforge/media/__init__.py b/src/noteforge/media/__init__.py index 6b02d25..1960959 100644 --- a/src/noteforge/media/__init__.py +++ b/src/noteforge/media/__init__.py @@ -6,13 +6,12 @@ PlatformConfig, load_extractor_config, ) -from noteforge.media.cookies import CookieLease, CookieService, CredentialInfo +from noteforge.media.cookies import CookieLease, CookieService from noteforge.media.models import ( AudioFormat, AudioRequest, AuthRequest, Browser, - CookiePersistence, MediaFormats, MediaType, Metadata, @@ -20,6 +19,7 @@ Playlist, PlaylistEntry, Subtitle, + SubtitleAccessStatus, SubtitleRequest, SubtitleSegment, VideoFormat, @@ -46,9 +46,7 @@ "AuthRequest", "Browser", "CookieLease", - "CookiePersistence", "CookieService", - "CredentialInfo", "ExtractorConfig", "MediaAsset", "MediaFormats", @@ -60,6 +58,7 @@ "Playlist", "PlaylistEntry", "Subtitle", + "SubtitleAccessStatus", "SubtitleParser", "SubtitleRequest", "SubtitleSegment", diff --git a/src/noteforge/media/config.py b/src/noteforge/media/config.py index 2303f53..71ce87c 100644 --- a/src/noteforge/media/config.py +++ b/src/noteforge/media/config.py @@ -8,7 +8,7 @@ @dataclass(frozen=True, slots=True) class PlatformConfig: - """平台网络配置;身份认证由 CookieService 独立管理。""" + """平台网络配置;身份认证由 AuthManager 独立管理。""" proxy: str | None = None # 仅作用于该平台;不包含认证信息。 diff --git a/src/noteforge/media/cookies/__init__.py b/src/noteforge/media/cookies/__init__.py index 9d3346c..99c6059 100644 --- a/src/noteforge/media/cookies/__init__.py +++ b/src/noteforge/media/cookies/__init__.py @@ -1,7 +1,6 @@ from noteforge.media.cookies.service import ( CookieLease, CookieService, - CredentialInfo, ) -__all__ = ["CookieLease", "CookieService", "CredentialInfo"] +__all__ = ["CookieLease", "CookieService"] diff --git a/src/noteforge/media/cookies/service.py b/src/noteforge/media/cookies/service.py index 7cf79fe..6962819 100644 --- a/src/noteforge/media/cookies/service.py +++ b/src/noteforge/media/cookies/service.py @@ -1,81 +1,46 @@ -"""Cookie 获取、过滤、租约、保留、更新与删除服务。""" +"""为媒体后端生成权限受限、用后即删的 Cookie 临时租约。""" from __future__ import annotations import http.cookiejar -import json import os -import secrets import shutil import tempfile -import threading -import uuid import weakref -from dataclasses import asdict, dataclass from datetime import UTC, datetime, timedelta from pathlib import Path from noteforge.media.cookies.policy import CookiePolicy, policy_for -from noteforge.media.models import AuthRequest, CookiePersistence, VideoPlatform +from noteforge.media.models import VideoPlatform class CookieSecurityError(RuntimeError): - """Cookie 生命周期或加密约束被破坏。""" - - -@dataclass(frozen=True, slots=True) -class CredentialInfo: - """持久凭据的非敏感索引信息,不包含 Cookie 值。""" - - id: str # 随机凭据 ID,也是 Vault 子目录名。 - platform: VideoPlatform - browser: str - profile: str | None - created_at: datetime - refreshed_at: datetime - expires_at: datetime | None # 平台未提供整体过期时间时为空。 - cookie_count: int - domains: tuple[str, ...] # 经过平台白名单过滤后的域名。 - version: int = 1 # Vault 数据格式版本。 + """Cookie 临时租约的安全约束被破坏。""" class CookieLease: - """仅供媒体后端消费的一次性凭据;关闭后明文文件立即删除。""" + """仅供媒体后端消费的一次性凭据;关闭后立即删除明文文件。""" - def __init__( - self, - lease_id: str, - path: Path | None, - root: Path, - *, - credential: CredentialInfo | None = None, - on_close: object | None = None, - ) -> None: + def __init__(self, lease_id: str, path: Path | None, root: Path) -> None: self.id = lease_id - self.credential = credential self._path = path # 权限为 0600 的短期 Netscape Cookie 文件。 self._root = root # 权限为 0700 的独占租约目录。 - self._on_close = on_close self._closed = False self._finalizer = weakref.finalize(self, shutil.rmtree, root, True) - def _materialize_for_backend(self) -> Path | None: - """内部后端专用;公共 API 不暴露 Cookie 路径。""" + def backend_path(self) -> Path | None: + """返回租约期内的后端路径;调用方不得记录或长期持有。""" if self._closed: raise CookieSecurityError("Cookie 租约已经释放。") return self._path def close(self) -> None: - """可选回写更新后的加密凭据,再无条件删除明文租约。""" + """删除临时 Cookie 文件及其独占目录;可重复调用。""" if not self._closed: self._closed = True - try: - if callable(self._on_close): - self._on_close(self._path) - finally: - self._finalizer() + self._finalizer() def __enter__(self) -> CookieLease: return self @@ -85,183 +50,44 @@ def __exit__(self, *_: object) -> None: class CookieService: - """管理平台凭据;Cookie 明文只存在于权限受限的租约目录。""" + """Cookie 临时租约工厂;不负责导入、验证或持久化。""" def __init__( self, runtime_root: Path | None = None, - vault_root: Path | None = None, *, lease_ttl: timedelta = timedelta(minutes=30), ) -> None: self.runtime_root = ( runtime_root or Path(tempfile.gettempdir()) / "noteforge-credentials" ) - self.vault_root = vault_root or Path.home() / ".noteforge" / "credentials" - self.lease_ttl = lease_ttl # 仅用于回收异常退出后的残留租约。 - self._lock = threading.RLock() + self.lease_ttl = lease_ttl def anonymous(self) -> CookieLease: - """创建不含 Cookie 的租约,统一匿名与认证执行路径。""" + """创建无 Cookie 租约,统一匿名与认证后端调用路径。""" root = self._new_lease_root() return CookieLease(root.name, None, root) - def acquire( + def lease( self, platform: VideoPlatform | str, - request: AuthRequest | None, + jar: http.cookiejar.CookieJar, ) -> CookieLease: - """获取目标平台 Cookie,并按请求决定用后删除或加密保留。""" + """过滤目标平台 Cookie 并生成权限为 0600 的临时文件。""" - if request is None: + filtered = self._filter(jar, policy_for(platform)) + if not list(filtered): return self.anonymous() - platform_value = VideoPlatform(platform) - if request.credential_id: - jar = self._load_retained(request.credential_id, platform_value) - else: - jar = self._extract_browser_cookie_jar(request) - filtered = self._filter(jar, policy_for(platform_value)) root = self._new_lease_root() path = root / "cookies.txt" filtered.filename = str(path) filtered.save(ignore_discard=True, ignore_expires=True) os.chmod(path, 0o600) - credential: CredentialInfo | None = None - retained_request = request - if request.persistence is CookiePersistence.RETAIN: - retained_request = AuthRequest( - request.browser, - request.profile, - request.persistence, - request.credential_id or uuid.uuid4().hex, - ) - credential = self.retain(platform_value, retained_request, filtered) - - def update(updated_path: Path | None) -> None: - if credential is None or updated_path is None or not updated_path.exists(): - return - updated = http.cookiejar.MozillaCookieJar(str(updated_path)) - updated.load(ignore_discard=True, ignore_expires=True) - self.retain( - platform_value, - retained_request, - self._filter(updated, policy_for(platform_value)), - ) - - return CookieLease( - root.name, - path, - root, - credential=credential, - on_close=update if credential else None, - ) - - def retain( - self, - platform: VideoPlatform, - request: AuthRequest, - jar: http.cookiejar.MozillaCookieJar, - ) -> CredentialInfo: - """加密保留目标域 Cookie。需要安装 cryptography。""" - - try: - from cryptography.hazmat.primitives.ciphers.aead import AESGCM - except ImportError as error: - raise CookieSecurityError( - "保留 Cookie 需要 cryptography;安全原因禁止降级为明文存储。" - ) from error - credential_id = request.credential_id or uuid.uuid4().hex - root = self.vault_root / credential_id - root.mkdir(parents=True, exist_ok=True, mode=0o700) - os.chmod(root, 0o700) - plain = self._jar_bytes(jar) - key = self._vault_key() - nonce = secrets.token_bytes(12) - aad = f"noteforge:{credential_id}:{platform.value}:v1".encode() - encrypted = nonce + AESGCM(key).encrypt(nonce, plain, aad) - blob = root / "cookies.enc" - blob.write_bytes(encrypted) - os.chmod(blob, 0o600) - now = datetime.now(UTC) - domains = tuple(sorted({cookie.domain for cookie in jar})) - info = CredentialInfo( - credential_id, - platform, - str(request.browser), - request.profile, - now, - now, - None, - len(list(jar)), - domains, - ) - metadata = asdict(info) - metadata["platform"] = info.platform.value - metadata["created_at"] = info.created_at.isoformat() - metadata["refreshed_at"] = info.refreshed_at.isoformat() - (root / "metadata.json").write_text(json.dumps(metadata), encoding="utf-8") - os.chmod(root / "metadata.json", 0o600) - return info - - def revoke(self, credential_id: str) -> None: - """删除指定加密凭据及其非敏感索引。""" - - if not credential_id or any(c not in "0123456789abcdef" for c in credential_id): - raise ValueError("无效的 credential_id。") - shutil.rmtree(self.vault_root / credential_id, ignore_errors=True) - - def list_credentials(self) -> tuple[CredentialInfo, ...]: - """列出可用凭据元数据,跳过损坏条目。""" - - result: list[CredentialInfo] = [] - if not self.vault_root.exists(): - return () - for path in self.vault_root.glob("*/metadata.json"): - try: - value = json.loads(path.read_text(encoding="utf-8")) - result.append( - CredentialInfo( - value["id"], - VideoPlatform(value["platform"]), - value["browser"], - value.get("profile"), - datetime.fromisoformat(value["created_at"]), - datetime.fromisoformat(value["refreshed_at"]), - datetime.fromisoformat(value["expires_at"]) - if value.get("expires_at") - else None, - int(value["cookie_count"]), - tuple(value["domains"]), - int(value.get("version", 1)), - ) - ) - except (KeyError, OSError, TypeError, ValueError, json.JSONDecodeError): - continue - return tuple(sorted(result, key=lambda item: item.created_at)) - - def refresh( - self, - credential_id: str, - platform: VideoPlatform | str, - request: AuthRequest, - ) -> CredentialInfo: - """从浏览器重新获取并原子替换一个保留凭据。""" - - jar = self._filter( - self._extract_browser_cookie_jar(request), - policy_for(platform), - ) - retained_request = AuthRequest( - request.browser, - request.profile, - CookiePersistence.RETAIN, - credential_id, - ) - return self.retain(VideoPlatform(platform), retained_request, jar) + return CookieLease(root.name, path, root) def cleanup_expired(self) -> int: - """删除超过 TTL 的崩溃残留明文租约。""" + """删除超过 TTL 的异常退出残留租约。""" removed = 0 cutoff = datetime.now(UTC) - self.lease_ttl @@ -284,83 +110,14 @@ def _new_lease_root(self) -> Path: os.chmod(self.runtime_root, 0o700) return Path(tempfile.mkdtemp(prefix="lease-", dir=self.runtime_root)) - @staticmethod - def _extract_browser_cookie_jar(request: AuthRequest) -> http.cookiejar.CookieJar: - """在隔离凭据服务中读取浏览器,再在暴露前执行域过滤。 - - yt-dlp 当前浏览器适配层会解密 Cookie;其返回值绝不离开本方法。 - 对必须保证读取阶段也按域隔离的部署,应替换为浏览器扩展 Provider。 - """ - - from yt_dlp.cookies import extract_cookies_from_browser - - profile = request.profile - return extract_cookies_from_browser(str(request.browser), profile=profile) - @staticmethod def _filter( source: http.cookiejar.CookieJar, policy: CookiePolicy ) -> http.cookiejar.MozillaCookieJar: - """仅复制白名单域 Cookie,源 CookieJar 不会向后端暴露。""" + """按平台白名单复制 Cookie,拒绝把其他域凭据交给后端。""" target = http.cookiejar.MozillaCookieJar() for cookie in source: if policy.allows(cookie.domain): target.set_cookie(cookie) return target - - @staticmethod - def _jar_bytes(jar: http.cookiejar.MozillaCookieJar) -> bytes: - with tempfile.NamedTemporaryFile() as output: - jar.filename = output.name - jar.save(ignore_discard=True, ignore_expires=True) - return Path(output.name).read_bytes() - - def _vault_key(self) -> bytes: - """从显式环境变量或系统 Keyring 获取 256 位主密钥。""" - - supplied = os.environ.get("NOTEFORGE_COOKIE_VAULT_KEY") - if supplied: - try: - return bytes.fromhex(supplied) - except ValueError as error: - raise CookieSecurityError( - "NOTEFORGE_COOKIE_VAULT_KEY 必须是 64 位十六进制密钥。" - ) from error - try: - import keyring - except ImportError as error: - raise CookieSecurityError( - "保留 Cookie 需要系统 keyring;禁止把加密密钥与 Cookie 放在同一目录。" - ) from error - service, account = "noteforge-cookie-vault", "local-master-key" - stored = keyring.get_password(service, account) - if stored is None: - stored = secrets.token_hex(32) - keyring.set_password(service, account, stored) - return bytes.fromhex(stored) - - def _load_retained( - self, credential_id: str, platform: VideoPlatform - ) -> http.cookiejar.MozillaCookieJar: - """解密持久凭据到短期文件,加载后立即删除该文件。""" - - try: - from cryptography.hazmat.primitives.ciphers.aead import AESGCM - except ImportError as error: - raise CookieSecurityError("读取保留 Cookie 需要 cryptography。") from error - root = self.vault_root / credential_id - payload = (root / "cookies.enc").read_bytes() - nonce, ciphertext = payload[:12], payload[12:] - aad = f"noteforge:{credential_id}:{platform.value}:v1".encode() - plain = AESGCM(self._vault_key()).decrypt(nonce, ciphertext, aad) - lease = self._new_lease_root() - path = lease / "restore.txt" - try: - path.write_bytes(plain) - os.chmod(path, 0o600) - jar = http.cookiejar.MozillaCookieJar(str(path)) - jar.load(ignore_discard=True, ignore_expires=True) - return jar - finally: - shutil.rmtree(lease, ignore_errors=True) diff --git a/src/noteforge/media/models.py b/src/noteforge/media/models.py index a8a45e1..866d908 100644 --- a/src/noteforge/media/models.py +++ b/src/noteforge/media/models.py @@ -26,11 +26,16 @@ class MediaType(StrEnum): SUBTITLE = "subtitle" -class CookiePersistence(StrEnum): - """Cookie 在任务结束后的处理策略。""" +class SubtitleAccessStatus(StrEnum): + """字幕发现结果,避免把匿名空列表误判成无字幕。""" - EPHEMERAL = "ephemeral" - RETAIN = "retain" + AVAILABLE = "available" + NO_SUBTITLE = "no_subtitle" + LOGIN_REQUIRED = "login_required" + COOKIE_EXPIRED = "cookie_expired" + VIDEO_NOT_FOUND = "video_not_found" + NETWORK_ERROR = "network_error" + UNKNOWN_ERROR = "unknown_error" class Browser(StrEnum): @@ -48,12 +53,10 @@ class Browser(StrEnum): @dataclass(frozen=True, slots=True) class AuthRequest: - """请求使用浏览器身份;不包含任何 Cookie 明文。""" + """兼容媒体 API 的浏览器偏好;不包含任何 Cookie 明文。""" browser: Browser | str # Cookie 来源浏览器。 profile: str | None = None # 可选的浏览器配置目录。 - persistence: CookiePersistence = CookiePersistence.EPHEMERAL # 默认用后即删。 - credential_id: str | None = None # 已保留凭据 ID;设置后不再读取浏览器。 @dataclass(frozen=True, slots=True) @@ -183,3 +186,4 @@ class VideoResource: audio_path: Path | None = None video_path: Path | None = None transcript_source: str | None = None # cache/manual/automatic/whisper。 + subtitle_status: SubtitleAccessStatus = SubtitleAccessStatus.AVAILABLE diff --git a/src/noteforge/media/service.py b/src/noteforge/media/service.py index 9af73a2..bded447 100644 --- a/src/noteforge/media/service.py +++ b/src/noteforge/media/service.py @@ -10,7 +10,19 @@ from pathlib import Path from typing import Any -from noteforge.exceptions import RemoteCollectionError, UnsupportedSourceError +from noteforge.auth import ( + AuthError, + AuthManager, + AuthPlatform, + AuthRequiredError, + CookieExpiredError, + EncryptedCookieStore, +) +from noteforge.exceptions import ( + LoginRequiredError, + RemoteCollectionError, + UnsupportedSourceError, +) from noteforge.media.assets import AssetReference, MediaAsset from noteforge.media.config import ExtractorConfig, load_extractor_config from noteforge.media.cookies import CookieService @@ -22,6 +34,7 @@ MediaType, Playlist, Subtitle, + SubtitleAccessStatus, SubtitleRequest, VideoMetadata, VideoRequest, @@ -42,6 +55,7 @@ def __init__( config: ExtractorConfig | None = None, *, cookie_service: CookieService | None = None, + auth_manager: AuthManager | None = None, worker: MediaWorker | None = None, transcriber: AudioTranscriber | None = None, asset_ttl: timedelta = timedelta(hours=1), @@ -50,12 +64,18 @@ def __init__( # Repository 只持久化元数据/文本;媒体二进制始终进入临时资产目录。 self.repository = MediaRepository(self.config.cache_path) self.cookies = cookie_service or CookieService( - vault_root=self.config.credential_vault_path + runtime_root=self.config.runtime_path / "credentials" + ) + self.auth = auth_manager or AuthManager( + EncryptedCookieStore(self.config.credential_vault_path), + cookie_service=self.cookies, ) self.worker = worker or ProcessMediaWorker(self.config.worker_count) self.transcriber = transcriber self.asset_ttl = asset_ttl self._legacy_assets: list[MediaAsset] = [] + self._last_request_authenticated = False + self._last_auth_error: AuthError | None = None self._adapters: tuple[PlatformAdapter, ...] = ( BilibiliAdapter(), YouTubeAdapter(), @@ -142,18 +162,18 @@ def download_subtitle( metadata = self.extract_metadata(normalized, auth=auth) root = self._asset_root() try: - with self.cookies.acquire(adapter.platform, auth) as credential: - info = self.worker.execute( - { - "operation": "download_subtitle", - "source": normalized, - "target_dir": str(root), - "language": request.language, - "subtitle_format": request.format, - "cookie_file": self._credential_path(credential), - "platform_options": dict(adapter.backend_options()), - } - ) + info = self._execute_with_auth( + adapter, + auth, + { + "operation": "download_subtitle", + "source": normalized, + "target_dir": str(root), + "language": request.language, + "subtitle_format": request.format, + "platform_options": dict(adapter.backend_options()), + }, + ) requested = info.get("requested_subtitles") item = ( requested.get(request.language) @@ -177,7 +197,20 @@ def discover( info = self._extract(adapter, normalized, auth=auth) metadata = adapter.metadata(info, normalized) self.repository.save_metadata(metadata) - return VideoResource(metadata=metadata, subtitles=adapter.subtitles(info)) + subtitles = adapter.subtitles(info) + if subtitles: + status = SubtitleAccessStatus.AVAILABLE + elif isinstance(self._last_auth_error, CookieExpiredError): + status = SubtitleAccessStatus.COOKIE_EXPIRED + elif self._last_request_authenticated: + status = SubtitleAccessStatus.NO_SUBTITLE + else: + status = SubtitleAccessStatus.LOGIN_REQUIRED + return VideoResource( + metadata=metadata, + subtitles=subtitles, + subtitle_status=status, + ) def extract( self, @@ -318,17 +351,17 @@ def _extract( ) -> Mapping[str, Any]: """在 Cookie 租约范围内执行只读发现操作。""" - with self.cookies.acquire(adapter.platform, auth) as credential: - return self.worker.execute( - { - "operation": "extract", - "source": source, - "options": dict(options or {}), - "cookie_file": self._credential_path(credential), - "platform_options": dict(adapter.backend_options()) - | self._proxy_options(adapter), - } - ) + return self._execute_with_auth( + adapter, + auth, + { + "operation": "extract", + "source": source, + "options": dict(options or {}), + "platform_options": dict(adapter.backend_options()) + | self._proxy_options(adapter), + }, + ) def _download_media( self, @@ -343,22 +376,22 @@ def _download_media( metadata = self.extract_metadata(normalized, auth=auth) root = self._asset_root() try: - with self.cookies.acquire(adapter.platform, auth) as credential: - info = self.worker.execute( - { - "operation": "download_media", - "source": normalized, - "target_dir": str(root), - "audio_only": audio_only, - "format_id": request.format_id, - "codec": request.codec - if isinstance(request, AudioRequest) - else "mp3", - "cookie_file": self._credential_path(credential), - "platform_options": dict(adapter.backend_options()) - | self._proxy_options(adapter), - } - ) + info = self._execute_with_auth( + adapter, + auth, + { + "operation": "download_media", + "source": normalized, + "target_dir": str(root), + "audio_only": audio_only, + "format_id": request.format_id, + "codec": request.codec + if isinstance(request, AudioRequest) + else "mp3", + "platform_options": dict(adapter.backend_options()) + | self._proxy_options(adapter), + }, + ) path = self._downloaded_path(info, audio_only, request) return self._asset( MediaType.AUDIO if audio_only else MediaType.VIDEO, metadata, path, root @@ -371,9 +404,49 @@ def _proxy_options(self, adapter: PlatformAdapter) -> dict[str, Any]: proxy = self.config.for_platform(adapter.platform.value).proxy return {"proxy": proxy} if proxy else {} + def _execute_with_auth( + self, + adapter: PlatformAdapter, + auth: AuthRequest | None, + payload: dict[str, Any], + ) -> Mapping[str, Any]: + """统一注入认证,并在明确认证失败时最多刷新重试一次。""" + + platform = AuthPlatform(adapter.platform.value) + browser = str(auth.browser) if auth is not None else None + self._last_auth_error = None + try: + credential = ( + self.auth.refresh(platform, browser=browser) + if browser + else self.auth.get_cookie(platform) + ) + except AuthError as error: + self._last_auth_error = error + credential = self.cookies.anonymous() + + try: + with credential: + first = dict(payload) + first["cookie_file"] = self._credential_path(credential) + self._last_request_authenticated = first["cookie_file"] is not None + return self.worker.execute(first) + except LoginRequiredError: + # 只有后端明确认定认证失败时才刷新,且整个调用最多重试一次。 + with self.auth.refresh(platform, browser=browser) as refreshed: + second = dict(payload) + second["cookie_file"] = self._credential_path(refreshed) + self._last_request_authenticated = True + try: + return self.worker.execute(second) + except LoginRequiredError as error: + raise AuthRequiredError( + f"{platform.value} 登录态刷新后仍无法访问该资源。" + ) from error + @staticmethod def _credential_path(credential: Any) -> str | None: - path = credential._materialize_for_backend() + path = credential.backend_path() return str(path) if path else None def _asset_root(self) -> Path: diff --git a/src/noteforge/media/ytdlp/errors.py b/src/noteforge/media/ytdlp/errors.py index 8c80ae1..1ae3399 100644 --- a/src/noteforge/media/ytdlp/errors.py +++ b/src/noteforge/media/ytdlp/errors.py @@ -18,18 +18,18 @@ def translate_download_error(error: DownloadError) -> CollectionError: message = str(error) normalized = message.casefold() if "unsupported url" in normalized or "no suitable extractor" in normalized: - return UnsupportedSourceError(f"不支持的视频 URL:{message}") + return UnsupportedSourceError("yt-dlp 不支持该视频 URL。") if any( item in normalized for item in ("login required", "sign in", "cookies", "扫码登录", "登录后") ): - return LoginRequiredError(f"该视频需要登录后访问:{message}") + return LoginRequiredError("该视频需要登录后访问。") if ( "http error 412" in normalized or "risk control" in normalized or "风控" in message ): - return RiskControlError(f"视频平台触发访问风控:{message}") + return RiskControlError("视频平台触发访问风控。") if any( item in normalized for item in ( @@ -40,5 +40,6 @@ def translate_download_error(error: DownloadError) -> CollectionError: "不存在", ) ): - return VideoUnavailableError(f"视频不存在或不可访问:{message}") - return RemoteCollectionError(f"视频资源提取失败:{message}") + return VideoUnavailableError("视频不存在或当前不可访问。") + # 原始后端消息可能包含 URL 或认证上下文,只通过异常链保留给调试器。 + return RemoteCollectionError("视频资源提取失败。") diff --git a/tests/auth/test_auth.py b/tests/auth/test_auth.py new file mode 100644 index 0000000..e0cc1da --- /dev/null +++ b/tests/auth/test_auth.py @@ -0,0 +1,353 @@ +"""统一认证生命周期的单元测试。""" + +import http.cookiejar +from datetime import UTC, datetime +from pathlib import Path + +import httpx +import pytest + +from noteforge.auth import ( + AuthManager, + AuthPlatform, + AuthRequiredError, + AuthResult, + AuthStatus, + CookieExpiredError, + CookieImportError, + CookieSource, + EncryptedCookieStore, + JsonCookieProvider, + PlaywrightCookieProvider, + RawCookieProvider, +) +from noteforge.auth.providers.base import make_cookie +from noteforge.auth.validator import CookieValidator +from noteforge.exceptions import LoginRequiredError +from noteforge.media.config import ExtractorConfig +from noteforge.media.cookies import CookieService +from noteforge.media.models import SubtitleAccessStatus +from noteforge.media.service import MediaService + + +def _cookies(platform: AuthPlatform) -> http.cookiejar.CookieJar: + jar = http.cookiejar.CookieJar() + if platform is AuthPlatform.BILIBILI: + values = { + "SESSDATA": "session-secret", + "DedeUserID": "123", + "bili_jct": "csrf-secret", + } + domain = "bilibili.com" + else: + values = {"SAPISID": "session-secret"} + domain = "youtube.com" + for name, value in values.items(): + jar.set_cookie(make_cookie(name, value, domain)) + return jar + + +class MemoryStore: + """测试专用内存存储。""" + + def __init__(self, jar=None): + self.jar = jar + self.saved = 0 + + def load(self, platform): + del platform + return self.jar + + def save(self, platform, cookies, source): + del platform, source + self.jar = cookies + self.saved += 1 + + def clear(self, platform): + del platform + self.jar = None + + def exists(self, platform): + del platform + return self.jar is not None + + +class FixedValidator: + """按 Cookie 名称返回确定状态。""" + + def validate(self, platform, cookies): + names = {cookie.name for cookie in cookies} + if not names: + status = AuthStatus.NO_COOKIE + elif "expired" in names: + status = AuthStatus.COOKIE_EXPIRED + elif ( + platform is AuthPlatform.BILIBILI + and not { + "SESSDATA", + "DedeUserID", + "bili_jct", + } + <= names + ): + status = AuthStatus.MISSING_REQUIRED_FIELDS + else: + status = AuthStatus.AUTHENTICATED + return AuthResult(platform, status, refreshed_at=datetime.now(UTC)) + + +class FixedProvider: + def __init__(self, jar=None, error=None): + self.jar = jar + self.error = error + self.source = CookieSource("browser", "chrome") + self.calls = 0 + + def load(self, platform): + del platform + self.calls += 1 + if self.error: + raise self.error + return self.jar + + +class FakeHttpClient: + """模拟 httpx 上下文客户端。""" + + def __init__(self, payload): + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, *args): + del args + + def get(self, url, **kwargs): + del kwargs + request = httpx.Request("GET", url) + if "bilibili" in url: + return httpx.Response(200, json=self.payload, request=request) + return httpx.Response(200, text=str(self.payload), request=request) + + +def test_encrypted_cookie_store_round_trip(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("NOTEFORGE_COOKIE_VAULT_KEY", "ab" * 32) + store = EncryptedCookieStore(tmp_path) + store.save( + AuthPlatform.BILIBILI, + _cookies(AuthPlatform.BILIBILI), + CookieSource("browser", "chrome"), + ) + + loaded = store.load(AuthPlatform.BILIBILI) + + assert loaded is not None + assert {cookie.name for cookie in loaded} == { + "SESSDATA", + "DedeUserID", + "bili_jct", + } + assert store.exists(AuthPlatform.BILIBILI) + assert "session-secret" not in (tmp_path / "bilibili/metadata.json").read_text() + store.clear(AuthPlatform.BILIBILI) + assert not store.exists(AuthPlatform.BILIBILI) + + +def test_raw_and_json_providers_filter_domains(tmp_path: Path) -> None: + raw = RawCookieProvider("SESSDATA=value; DedeUserID=1; bili_jct=csrf") + assert {cookie.name for cookie in raw.load(AuthPlatform.BILIBILI)} == { + "SESSDATA", + "DedeUserID", + "bili_jct", + } + path = tmp_path / "cookies.json" + path.write_text( + '[{"name":"SESSDATA","value":"ok","domain":".bilibili.com"},' + '{"name":"stolen","value":"bad","domain":".example.com"}]', + encoding="utf-8", + ) + imported = JsonCookieProvider(path).load(AuthPlatform.BILIBILI) + assert [cookie.name for cookie in imported] == ["SESSDATA"] + + +def test_validator_distinguishes_missing_valid_and_expired() -> None: + missing = http.cookiejar.CookieJar() + missing.set_cookie(make_cookie("SESSDATA", "value", "bilibili.com")) + validator = CookieValidator(lambda: FakeHttpClient({"data": {"isLogin": True}})) + assert ( + validator.validate(AuthPlatform.BILIBILI, missing).status + is AuthStatus.MISSING_REQUIRED_FIELDS + ) + assert ( + validator.validate( + AuthPlatform.BILIBILI, _cookies(AuthPlatform.BILIBILI) + ).status + is AuthStatus.AUTHENTICATED + ) + expired = CookieValidator(lambda: FakeHttpClient({"data": {"isLogin": False}})) + assert ( + expired.validate(AuthPlatform.BILIBILI, _cookies(AuthPlatform.BILIBILI)).status + is AuthStatus.COOKIE_EXPIRED + ) + + +def test_playwright_cookie_mapping_is_type_safe() -> None: + jar = PlaywrightCookieProvider._from_playwright( + [ + { + "name": "SESSDATA", + "value": "secret", + "domain": ".bilibili.com", + "path": "/", + "expires": -1.0, + "secure": True, + }, + {"name": "invalid", "value": 123, "domain": ".bilibili.com"}, + ] + ) + + cookies = list(jar) + assert len(cookies) == 1 + assert cookies[0].name == "SESSDATA" + assert cookies[0].expires is None + + +def test_manager_uses_valid_store_without_browser(tmp_path: Path) -> None: + provider = FixedProvider(error=AssertionError("不应读取浏览器")) + manager = AuthManager( + MemoryStore(_cookies(AuthPlatform.BILIBILI)), + validator=FixedValidator(), + cookie_service=CookieService(runtime_root=tmp_path), + browser_provider_factory=lambda browser: provider, + ) + + with manager.get_cookie(AuthPlatform.BILIBILI) as lease: + assert lease.backend_path().exists() + assert provider.calls == 0 + + +def test_manager_refreshes_missing_store_and_saves(tmp_path: Path) -> None: + store = MemoryStore() + provider = FixedProvider(_cookies(AuthPlatform.BILIBILI)) + manager = AuthManager( + store, + validator=FixedValidator(), + cookie_service=CookieService(runtime_root=tmp_path), + browser_provider_factory=lambda browser: provider, + ) + + with manager.get_cookie(AuthPlatform.BILIBILI): + pass + + assert provider.calls == 1 + assert store.saved == 1 + + +def test_manager_reports_import_failure_without_store(tmp_path: Path) -> None: + provider = FixedProvider(error=CookieImportError("browser unavailable")) + manager = AuthManager( + MemoryStore(), + validator=FixedValidator(), + cookie_service=CookieService(runtime_root=tmp_path), + browser_provider_factory=lambda browser: provider, + ) + + with pytest.raises(AuthRequiredError): + manager.get_cookie(AuthPlatform.BILIBILI) + + +def test_manager_reports_expired_when_refresh_fails(tmp_path: Path) -> None: + expired = http.cookiejar.CookieJar() + expired.set_cookie(make_cookie("expired", "secret", "bilibili.com")) + provider = FixedProvider(error=CookieImportError("browser unavailable")) + manager = AuthManager( + MemoryStore(expired), + validator=FixedValidator(), + cookie_service=CookieService(runtime_root=tmp_path), + browser_provider_factory=lambda browser: provider, + ) + + with pytest.raises(CookieExpiredError): + manager.get_cookie(AuthPlatform.BILIBILI) + + +class FakeAuthManager: + """为 MediaService 提供可计数的认证租约。""" + + def __init__(self, service, *, unavailable=False): + self.service = service + self.unavailable = unavailable + self.refresh_calls = 0 + + def get_cookie(self, platform): + if self.unavailable: + raise AuthRequiredError("没有 Cookie") + return self.service.lease(platform.value, _cookies(platform)) + + def refresh(self, platform, *, browser=None): + del browser + self.refresh_calls += 1 + return self.service.lease(platform.value, _cookies(platform)) + + +class SubtitleWorker: + def __init__(self, failures=0): + self.failures = failures + self.calls = 0 + + def execute(self, payload): + self.calls += 1 + if self.calls <= self.failures: + raise LoginRequiredError("需要登录") + return { + "id": "BV1CkArz1E4o", + "title": "Demo", + "webpage_url": payload["source"], + "subtitles": {}, + } + + def close(self): + pass + + +def _media(tmp_path, worker, *, unavailable=False): + cookie_service = CookieService(runtime_root=tmp_path / "leases") + auth = FakeAuthManager(cookie_service, unavailable=unavailable) + service = MediaService( + ExtractorConfig(cache_path=tmp_path / "cache"), + cookie_service=cookie_service, + auth_manager=auth, + worker=worker, + ) + return service, auth + + +def test_media_refreshes_and_retries_once(tmp_path: Path) -> None: + worker = SubtitleWorker(failures=1) + service, auth = _media(tmp_path, worker) + + resource = service.discover("https://www.bilibili.com/video/BV1CkArz1E4o") + + assert worker.calls == 2 + assert auth.refresh_calls == 1 + assert resource.subtitle_status is SubtitleAccessStatus.NO_SUBTITLE + + +def test_media_does_not_retry_more_than_once(tmp_path: Path) -> None: + worker = SubtitleWorker(failures=2) + service, auth = _media(tmp_path, worker) + + with pytest.raises(AuthRequiredError): + service.discover("https://www.bilibili.com/video/BV1CkArz1E4o") + + assert worker.calls == 2 + assert auth.refresh_calls == 1 + + +def test_anonymous_empty_subtitles_are_login_required(tmp_path: Path) -> None: + service, _ = _media(tmp_path, SubtitleWorker(), unavailable=True) + + resource = service.discover("https://www.bilibili.com/video/BV1CkArz1E4o") + + assert resource.subtitle_status is SubtitleAccessStatus.LOGIN_REQUIRED diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..da82314 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +"""测试环境的全局安全边界。""" + +import pytest + +from noteforge.auth.providers.browser import BrowserCookieProvider + + +@pytest.fixture(autouse=True) +def forbid_real_browser_cookies(monkeypatch: pytest.MonkeyPatch) -> None: + """禁止单元测试读取真实浏览器或触发系统 Keyring 密码框。""" + + def forbidden_load(self, platform): + del self, platform + raise AssertionError("测试禁止读取真实浏览器 Cookie;请注入认证替身。") + + monkeypatch.setattr(BrowserCookieProvider, "load", forbidden_load) + # 即使测试意外创建加密 Store,也只能使用测试密钥,不访问系统 Keyring。 + monkeypatch.setenv("NOTEFORGE_COOKIE_VAULT_KEY", "00" * 32) diff --git a/tests/media/test_media.py b/tests/media/test_media.py index b7ba01f..0a1d80d 100644 --- a/tests/media/test_media.py +++ b/tests/media/test_media.py @@ -3,6 +3,7 @@ from pathlib import Path from unittest.mock import patch +from noteforge.auth import AuthRequiredError from noteforge.media.assets import AssetReference, MediaAsset from noteforge.media.config import ( ExtractorConfig, @@ -25,6 +26,18 @@ from noteforge.media.ytdlp import YTDLPClient +class AnonymousAuthManager: + """媒体测试专用认证替身,确保不会读取本机浏览器。""" + + def get_cookie(self, platform): + del platform + raise AuthRequiredError("测试使用匿名请求。") + + def refresh(self, platform, *, browser=None): + del platform, browser + raise AuthRequiredError("测试禁止刷新真实浏览器 Cookie。") + + def test_platform_adapters_recognize_bilibili_and_youtube() -> None: assert BilibiliAdapter().supports("https://www.bilibili.com/video/BV1CkArz1E4o") assert YouTubeAdapter().supports("https://youtu.be/M7lc1UVf-VE") @@ -142,7 +155,11 @@ def execute(self, payload): def close(self): pass - service = MediaService(ExtractorConfig(cache_path=tmp_path), worker=Worker()) + service = MediaService( + ExtractorConfig(cache_path=tmp_path), + auth_manager=AnonymousAuthManager(), + worker=Worker(), + ) resource = service.discover(info["webpage_url"]) assert resource.metadata.id == "M7lc1UVf-VE" @@ -180,7 +197,11 @@ def close(self): pass config = ExtractorConfig(cache_path=tmp_path / "cache", runtime_path=tmp_path) - service = MediaService(config, worker=Worker()) + service = MediaService( + config, + auth_manager=AnonymousAuthManager(), + worker=Worker(), + ) asset = service.download_audio(info["webpage_url"], AudioRequest(codec="mp3")) lease_root = asset.path.parent assert asset.path.read_bytes() == b"audio" diff --git a/uv.lock b/uv.lock index 9039561..ce6701f 100644 --- a/uv.lock +++ b/uv.lock @@ -268,6 +268,83 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl", hash = "sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82", size = 98830, upload-time = "2026-07-29T22:46:03.52Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/a3/07297917485ee2ca85bc3c8dc6ed85ad3fffcf424047fba62671dba68e97/greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61", size = 294165, upload-time = "2026-08-10T13:25:17.987Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/6f732f9314cda54c5fd48a7620c7160f4f286967e8045ad94b9d66ce80b7/greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c", size = 613610, upload-time = "2026-08-10T14:14:33.829Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c0/b27589e25d220289edcd4d582b2b17b83058d1a56d53d971b6ea1a34f10d/greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da", size = 625481, upload-time = "2026-08-10T14:27:23.647Z" }, + { url = "https://files.pythonhosted.org/packages/39/82/5c873dbb4fb001d22fbbd50e80d4c1b0181ddae106856132160f84b94e88/greenlet-3.5.5-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:abc8bc8d9f935cd685457545b6a53863a877fdc12c2c0f5ee9beee18d9db139c", size = 633329, upload-time = "2026-08-10T14:30:06.062Z" }, + { url = "https://files.pythonhosted.org/packages/51/2d/f2c928218ac52f26d7a2c188c171d1b7e728b23782cb3347e7b4fce1493a/greenlet-3.5.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864", size = 624562, upload-time = "2026-08-10T13:40:48.064Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/f7df98e72a8eb4a7edfec5a08d6d1a4ab53a52c95ba0b2ea6c10b8dd9bd0/greenlet-3.5.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:3134291427bb0f3526e9d90311988caf336eb43730e95244997a4fb15f45144f", size = 428145, upload-time = "2026-08-10T14:30:01.071Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4a/92fc51d5d35912f4f06eec037ba347985defd0be47463a010a325634d9d2/greenlet-3.5.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db", size = 1584909, upload-time = "2026-08-10T14:15:04.343Z" }, + { url = "https://files.pythonhosted.org/packages/ac/58/ed98b80ac5738c149a5258544843c45601ade1fd70f61740cdaead6351b3/greenlet-3.5.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39", size = 1651184, upload-time = "2026-08-10T13:40:28.879Z" }, + { url = "https://files.pythonhosted.org/packages/d8/be/b582ceb80cefdf9d8da34078714e4b12b3d16f509dee0f65e40a5cc8fc7d/greenlet-3.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53", size = 323280, upload-time = "2026-08-10T13:26:07.495Z" }, + { url = "https://files.pythonhosted.org/packages/4d/18/5313c4c58598c38b0373c013e4ff2b3e6d258aaaa338f373335ebecdaddd/greenlet-3.5.5-cp311-cp311-win_arm64.whl", hash = "sha256:2b70a766135540c472ac1393d57c2e1b4a2eb85bf526a1e41e6d096173a8cee5", size = 307785, upload-time = "2026-08-10T13:28:34.874Z" }, + { url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/9b/30/87c212b5c684d0e72974f1063b7a9687631e8985902c06e1016542c874e7/greenlet-3.5.5-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f", size = 629504, upload-time = "2026-08-10T14:30:07.967Z" }, + { url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439, upload-time = "2026-08-10T13:40:49.391Z" }, + { url = "https://files.pythonhosted.org/packages/63/2c/eb487fafc9f50ffff2b1e0b697f70fb34bf150821c08ab225aacf5583a7e/greenlet-3.5.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759", size = 432462, upload-time = "2026-08-10T14:30:02.309Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342, upload-time = "2026-08-10T14:15:05.653Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744, upload-time = "2026-08-10T13:40:30.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171, upload-time = "2026-08-10T13:28:44.472Z" }, + { url = "https://files.pythonhosted.org/packages/76/e5/4dee4d8d2e603fe5fdd7b444e63219f7b9bd852c60c6214511c7157cbe88/greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52", size = 308362, upload-time = "2026-08-10T13:26:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" }, + { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/eaa476d6bf3816828d0d70e80dcc36bf30a058233bd889e707e693f6e860/greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42", size = 632726, upload-time = "2026-08-10T14:30:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/0cc2849ede68579291e9c59b3ab6ec1958f98681cca5b14d8fc75bf674a4/greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b", size = 434966, upload-time = "2026-08-10T14:30:03.729Z" }, + { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/78/649cb5c09d4d81f6dd1444e75474a7206784743283a21d24171562ac4899/greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc", size = 308260, upload-time = "2026-08-10T13:27:50.795Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, + { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/eb/52/f005d579acde46c3d1cc3cab1c9f3d5708c8a3006a4120e8cf5da801afe9/greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8", size = 677863, upload-time = "2026-08-10T14:30:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8a/a75f8a2bdcef3c358a3147cdc9db3aa83755f0a038f766ab0bedb66f512c/greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53", size = 480554, upload-time = "2026-08-10T14:30:05.171Z" }, + { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, + { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, + { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ac/0d7887aa4bbfc9eba075cc428244dfc96f623478454d5ec81180d0d6bd5a/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387", size = 681587, upload-time = "2026-08-10T14:30:13.519Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/4f/18/8d58ba1c429b0383e3219a3d0e0bba241d0444d8ed05b73349953c7d7c7b/greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41", size = 510175, upload-time = "2026-08-10T14:30:07.047Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" }, + { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" }, + { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" }, + { url = "https://files.pythonhosted.org/packages/65/53/4e13642efc4d7ad6554ecb2242a5be42666b2e1a067323e88dfc0124a04b/greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76", size = 681436, upload-time = "2026-08-10T14:30:14.839Z" }, + { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" }, + { url = "https://files.pythonhosted.org/packages/cd/32/188447c9a468d6977d2989397226b0c6b65ab6f4cf943f931643328512fc/greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474", size = 487404, upload-time = "2026-08-10T14:30:08.903Z" }, + { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" }, + { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" }, + { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f4/e450a68a152f819491d8c7df6a8254e761d87e6a78759268961f8c5bd4dd/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1", size = 686022, upload-time = "2026-08-10T14:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/17e63d6bf3b9c9b9dbea981b7f643a71f79603bdfb4f1c3a9cf353e22aed/greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f", size = 516951, upload-time = "2026-08-10T14:30:10.907Z" }, + { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" }, + { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -454,6 +531,7 @@ dependencies = [ { name = "cryptography" }, { name = "httpx" }, { name = "keyring" }, + { name = "playwright" }, { name = "rich" }, { name = "typer" }, { name = "yt-dlp", extra = ["curl-cffi"] }, @@ -471,6 +549,7 @@ requires-dist = [ { name = "cryptography", specifier = ">=45,<48" }, { name = "httpx", specifier = ">=0.28,<1" }, { name = "keyring", specifier = ">=25,<27" }, + { name = "playwright", specifier = ">=1.48,<2" }, { name = "rich", specifier = ">=13,<15" }, { name = "typer", specifier = ">=0.12,<1" }, { name = "yt-dlp", extras = ["curl-cffi"], specifier = ">=2026.7.4" }, @@ -501,6 +580,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/85/9b31b44296cfa3bb56cddb35e6a0f6578bab0b490c0806c0245e32c6110c/platformdirs-4.11.1-py3-none-any.whl", hash = "sha256:2efd27d363e8dd2e661639ffb398865a5e0a46442a11d266bf375a0e0c10e386", size = 23261, upload-time = "2026-08-07T23:06:47.219Z" }, ] +[[package]] +name = "playwright" +version = "1.62.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/5b/ca2abcf3aa69f9fb510215e3064f30b57fe57657c8d04ede45bb966d5606/playwright-1.62.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d8da938f3748841a8754f2e1f0216902c1c8f8ae3720de8b32ccf8e6913a7c4f", size = 43732091, upload-time = "2026-07-31T17:00:44.178Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/0bfbe9904350961f4dbb713f04342e40d548c5fc26c8157bd13617c81492/playwright-1.62.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:db755ab27db21a04186f1fe8169888e42356086e439b1059b923ef417f0b6034", size = 42510842, upload-time = "2026-07-31T17:00:48.596Z" }, + { url = "https://files.pythonhosted.org/packages/66/dc/c0486b407ad0699a250f6bbe3066fca95344009a99ca66e88ca175c69dc1/playwright-1.62.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:5108bd5b3e87169ddf269feee097da5893af7f8aea4634dfc840518d64c1f1da", size = 43732093, upload-time = "2026-07-31T17:00:52.218Z" }, + { url = "https://files.pythonhosted.org/packages/43/6b/b24aebc2b04bffcb342bccf96e287c78b363e1615bed5cea97500cc0393a/playwright-1.62.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ba33bae6a13b3d9d354c751cb618af357d20fe1d57767cbcce52079bbef17ad3", size = 47748926, upload-time = "2026-07-31T17:00:56.438Z" }, + { url = "https://files.pythonhosted.org/packages/36/43/b4b18bdc87e1949568fffdcde3ff9a0456266b2d0c6d4432cc34d89ea6eb/playwright-1.62.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db2d76613a57ad844362ce42f7d0c2fa26b19a4f7a46d4f76b891c631e6e5aff", size = 47441423, upload-time = "2026-07-31T17:01:00.404Z" }, + { url = "https://files.pythonhosted.org/packages/81/22/af5d926fc2c32a339eec00a443644bc40ab9db1dd2dd9017873c59773c0c/playwright-1.62.0-py3-none-win32.whl", hash = "sha256:e5614fa89355d7081457680324bb219f79f69c423c5cb6fa250e30b0d8aebf1c", size = 38164450, upload-time = "2026-07-31T17:01:04.187Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a9/4160c1033c07af98bf841ad079457dd78408a5ee0dd56cbfe50b8b6a1c22/playwright-1.62.0-py3-none-win_amd64.whl", hash = "sha256:92c0d98ed04eb35af557b709875edba415b1f548bdb22ddb5bb3e1e6c835c2f1", size = 38164458, upload-time = "2026-07-31T17:01:08.459Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ec/06b55d619a7082a766aa04f2c6bb31435c87f02930087d8a0517119408fa/playwright-1.62.0-py3-none-win_arm64.whl", hash = "sha256:ea8d3055aa9d5a9f1832ac82517bd8b42c78fac7ebcbebb0107116735c8cb6a1", size = 34208868, upload-time = "2026-07-31T17:01:11.818Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -535,6 +633,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + [[package]] name = "pygments" version = "2.20.0"