From 0de77e1bc40185edc1c21b907c98389264809fe9 Mon Sep 17 00:00:00 2001 From: Lucius Date: Fri, 31 Jul 2026 15:45:53 +0800 Subject: [PATCH] feat: support custom Chromium cookie directories --- README.md | 24 +++- tests/test_auth.py | 69 ++++++++++- twitter_cli/auth.py | 282 ++++++++++++++++++++++++++++++++------------ 3 files changed, 298 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index e033cd2..d44c6ca 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,17 @@ TWITTER_CHROME_PROFILE="Profile 2" twitter feed TWITTER_BROWSER=chrome twitter feed # Supported: arc, chrome, edge, firefox, brave ``` +**Custom Chromium profile:** For ungoogled-chromium or another Chromium installation +started with `--user-data-dir`, point twitter-cli at that directory: + +```bash +TWITTER_CHROMIUM_USER_DATA_DIR="/path/to/user-data-dir" twitter feed +``` + +The path may also point directly to a profile directory. Set +`TWITTER_CHROME_PROFILE="Profile 2"` as well to select one profile below a +User Data root. + After loading cookies, the CLI performs lightweight verification. Commands that require account access fail fast on clear auth errors (`401/403`). ### Proxy Support @@ -279,6 +290,7 @@ Mode behavior: - `No Twitter cookies found` - Ensure you are logged in to `x.com` in a supported browser (Arc/Chrome/Edge/Firefox/Brave). + - For a custom Chromium profile, set `TWITTER_CHROMIUM_USER_DATA_DIR` to its `--user-data-dir`. - Or set `TWITTER_AUTH_TOKEN` and `TWITTER_CT0` manually. - Run with `-v` to see browser extraction diagnostics. @@ -513,6 +525,16 @@ TWITTER_CHROME_PROFILE="Profile 2" twitter feed TWITTER_BROWSER=chrome twitter feed # 支持: arc, chrome, edge, firefox, brave ``` +**自定义 Chromium Profile**:如果使用 ungoogled-chromium,或通过 +`--user-data-dir` 启动其他 Chromium,可直接指定该目录: + +```bash +TWITTER_CHROMIUM_USER_DATA_DIR="/path/to/user-data-dir" twitter feed +``` + +该路径也可以直接指向某个 Profile 目录;若它指向 User Data 根目录,可再用 +`TWITTER_CHROME_PROFILE="Profile 2"` 选择其中一个 Profile。 + ### 代理支持 设置 `TWITTER_PROXY` 环境变量即可: @@ -551,7 +573,7 @@ score = likes_w * likes ### 常见问题 -- 报错 `No Twitter cookies found`:请先登录 `x.com`,并确认浏览器为 Arc/Chrome/Edge/Firefox/Brave 之一,或手动设置环境变量。 +- 报错 `No Twitter cookies found`:请先登录 `x.com`,并确认浏览器为 Arc/Chrome/Edge/Firefox/Brave 之一;自定义 Chromium 可将 `TWITTER_CHROMIUM_USER_DATA_DIR` 指向 `--user-data-dir`,或手动设置认证环境变量。 - 如需查看浏览器提取细节,可加 `-v` 打开诊断日志。 - 报错 `Cookie expired or invalid`:Cookie 过期,重新登录后重试。 - 报错 `Unable to get key for cookie decryption`(macOS Keychain 问题): diff --git a/tests/test_auth.py b/tests/test_auth.py index 8010c08..3c30206 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -66,6 +66,16 @@ def test_load_from_env_logs_incomplete_env(monkeypatch, caplog) -> None: assert "Environment cookies incomplete" in caplog.text +def test_get_browser_order_prepends_custom_chromium_dir(monkeypatch, tmp_path) -> None: + monkeypatch.delenv("TWITTER_BROWSER", raising=False) + monkeypatch.setenv("TWITTER_CHROMIUM_USER_DATA_DIR", str(tmp_path)) + + order = auth._get_browser_order() + + assert order[0] == "custom-chromium" + assert "chrome" in order + + def test_extract_cookies_from_jar_logs_missing_required_cookies(caplog) -> None: class Cookie: def __init__(self, domain: str, name: str, value: str) -> None: @@ -135,7 +145,9 @@ def _run(cmd, capture_output=True, text=True, timeout=15): cookies, diagnostics = auth._extract_via_subprocess() assert cookies is None - assert '"arc": browser_cookie3.arc' in seen["script"] + assert 'DEFAULT_ORDER = ["arc", "chrome", "edge", "firefox", "brave"]' in seen["script"] + assert 'CUSTOM_CHROMIUM_BROWSER = "custom-chromium"' in seen["script"] + assert "browser_cookie3.chromium(" in seen["script"] def test_extract_via_subprocess_retries_uv_when_current_env_has_no_output(monkeypatch) -> None: @@ -243,6 +255,25 @@ def test_iter_chrome_cookie_files_env_override(monkeypatch, tmp_path) -> None: assert "Profile 5" in paths[0] +def test_iter_chrome_cookie_files_supports_custom_user_data_dir(monkeypatch, tmp_path) -> None: + root = tmp_path / "Ungoogled Chromium" + network_dir = root / "Profile 1" / "Network" + network_dir.mkdir(parents=True) + cookie_file = network_dir / "Cookies" + cookie_file.touch() + local_state = root / "Local State" + local_state.touch() + + monkeypatch.setenv("TWITTER_CHROMIUM_USER_DATA_DIR", str(root)) + monkeypatch.delenv("TWITTER_CHROME_PROFILE", raising=False) + + paths = auth._iter_chrome_cookie_files("custom-chromium") + + assert paths == [str(cookie_file)] + assert auth._profile_name_from_cookie_file(str(cookie_file)) == "Profile 1" + assert auth._chromium_key_file_for_cookie(str(cookie_file)) == str(local_state) + + def test_iter_chrome_cookie_files_edge_linux_uses_microsoft_edge_path(monkeypatch, tmp_path) -> None: monkeypatch.setattr(auth.sys, "platform", "linux") edge_dir = tmp_path / ".config" / "microsoft-edge" @@ -322,6 +353,42 @@ def mock_arc(cookie_file=None): assert cookies["ct0"] == "csrf456" +def test_extract_in_process_uses_chromium_loader_for_custom_dir(monkeypatch, tmp_path) -> None: + class Cookie: + def __init__(self, domain: str, name: str, value: str) -> None: + self.domain = domain + self.name = name + self.value = value + + root = tmp_path / "User Data" + profile_dir = root / "Default" / "Network" + profile_dir.mkdir(parents=True) + cookie_file = profile_dir / "Cookies" + cookie_file.touch() + local_state = root / "Local State" + local_state.touch() + seen = {} + + def chromium(cookie_file=None, key_file=None): + seen["cookie_file"] = cookie_file + seen["key_file"] = key_file + return [ + Cookie(".x.com", "auth_token", "custom-token"), + Cookie(".x.com", "ct0", "custom-csrf"), + ] + + monkeypatch.setenv("TWITTER_CHROMIUM_USER_DATA_DIR", str(root)) + monkeypatch.setattr(auth, "_get_browser_order", lambda: ["custom-chromium"]) + monkeypatch.setitem(sys.modules, "browser_cookie3", SimpleNamespace(chromium=chromium)) + + cookies, diagnostics = auth._extract_in_process() + + assert diagnostics == [] + assert cookies is not None + assert cookies["auth_token"] == "custom-token" + assert seen == {"cookie_file": str(cookie_file), "key_file": str(local_state)} + + def test_diagnose_keychain_issues_detects_decryption_error(monkeypatch) -> None: """_diagnose_keychain_issues should detect Keychain-related error strings.""" monkeypatch.setattr("sys.platform", "darwin") diff --git a/twitter_cli/auth.py b/twitter_cli/auth.py index 81dd44c..645b9c3 100644 --- a/twitter_cli/auth.py +++ b/twitter_cli/auth.py @@ -203,43 +203,99 @@ def _extract_cookies_from_jar(jar: Any, source: str = "unknown") -> Optional[Dic "brave": os.path.join("BraveSoftware", "Brave-Browser"), } +_CUSTOM_CHROMIUM_BROWSER = "custom-chromium" + # Default browser order for cookie extraction _DEFAULT_BROWSER_ORDER = ["arc", "chrome", "edge", "firefox", "brave"] +_SUPPORTED_BROWSERS = set(_DEFAULT_BROWSER_ORDER) | {_CUSTOM_CHROMIUM_BROWSER} + + +def _custom_chromium_user_data_dir() -> Optional[str]: + """Return a custom Chromium user-data/profile directory, if configured.""" + root = os.environ.get("TWITTER_CHROMIUM_USER_DATA_DIR", "").strip() + if not root: + return None + return os.path.abspath(os.path.expanduser(root)) def _get_browser_order() -> List[str]: """Return browser extraction order, respecting TWITTER_BROWSER env var.""" + default_order = list(_DEFAULT_BROWSER_ORDER) + if _custom_chromium_user_data_dir(): + default_order.insert(0, _CUSTOM_CHROMIUM_BROWSER) + env = os.environ.get("TWITTER_BROWSER", "").strip().lower() if not env: - return _DEFAULT_BROWSER_ORDER - if env not in {"arc", "chrome", "edge", "firefox", "brave"}: + return default_order + if env not in _SUPPORTED_BROWSERS: logger.warning("TWITTER_BROWSER='%s' is invalid, using default order", env) - return _DEFAULT_BROWSER_ORDER - return [env] + [b for b in _DEFAULT_BROWSER_ORDER if b != env] + return default_order + return [env] + [b for b in default_order if b != env] + + +def _profile_cookie_paths(profile_dir: str) -> List[str]: + """Return existing Chromium cookie database paths for one profile.""" + paths = [] + for relative_path in ("Cookies", os.path.join("Network", "Cookies")): + cookie_path = os.path.join(profile_dir, relative_path) + if os.path.exists(cookie_path): + paths.append(cookie_path) + return paths + + +def _profile_name_from_cookie_file(cookie_file: str) -> str: + """Return the Chromium profile name for a cookie database path.""" + parent = os.path.basename(os.path.dirname(cookie_file)) + if parent == "Network": + return os.path.basename(os.path.dirname(os.path.dirname(cookie_file))) + return parent + + +def _chromium_key_file_for_cookie(cookie_file: str) -> Optional[str]: + """Return the Local State file associated with a Chromium cookie database.""" + candidates = [] + custom_root = _custom_chromium_user_data_dir() + if custom_root: + candidates.append(os.path.join(custom_root, "Local State")) + + profile_dir = os.path.dirname(cookie_file) + if os.path.basename(profile_dir) == "Network": + profile_dir = os.path.dirname(profile_dir) + candidates.append(os.path.join(os.path.dirname(profile_dir), "Local State")) + + for candidate in dict.fromkeys(candidates): + if os.path.exists(candidate): + return candidate + return None def _iter_chrome_cookie_files(browser_name: str) -> List[str]: - """Return cookie file paths for all Chrome profiles. + """Return cookie file paths for all Chromium profiles. If TWITTER_CHROME_PROFILE is set, only that profile is returned. Otherwise yields Default first, then Profile 1, Profile 2, ... sorted. """ - base_dir = _CHROMIUM_BASE_DIRS.get(browser_name) - if base_dir is None: - return [] - - if sys.platform == "darwin": - root = os.path.join(os.path.expanduser("~"), "Library", "Application Support", base_dir) - elif sys.platform == "win32": - if browser_name == "edge": - root = os.path.join(os.environ.get("LOCALAPPDATA", ""), "Microsoft", "Edge", "User Data") - else: - root = os.path.join(os.environ.get("LOCALAPPDATA", ""), base_dir) + if browser_name == _CUSTOM_CHROMIUM_BROWSER: + root = _custom_chromium_user_data_dir() + if not root: + return [] else: - if browser_name == "edge": - root = os.path.join(os.path.expanduser("~"), ".config", "microsoft-edge") + base_dir = _CHROMIUM_BASE_DIRS.get(browser_name) + if base_dir is None: + return [] + + if sys.platform == "darwin": + root = os.path.join(os.path.expanduser("~"), "Library", "Application Support", base_dir) + elif sys.platform == "win32": + if browser_name == "edge": + root = os.path.join(os.environ.get("LOCALAPPDATA", ""), "Microsoft", "Edge", "User Data") + else: + root = os.path.join(os.environ.get("LOCALAPPDATA", ""), base_dir) else: - root = os.path.join(os.path.expanduser("~"), ".config", base_dir) + if browser_name == "edge": + root = os.path.join(os.path.expanduser("~"), ".config", "microsoft-edge") + else: + root = os.path.join(os.path.expanduser("~"), ".config", base_dir) if not os.path.isdir(root): return [] @@ -247,28 +303,44 @@ def _iter_chrome_cookie_files(browser_name: str) -> List[str]: # If user explicitly specifies a profile, only use that one env_profile = os.environ.get("TWITTER_CHROME_PROFILE", "").strip() if env_profile: - cookie_path = os.path.join(root, env_profile, "Cookies") - if os.path.exists(cookie_path): - logger.debug("Using specified Chrome profile: %s", env_profile) - return [cookie_path] - logger.warning("TWITTER_CHROME_PROFILE='%s' not found at %s", env_profile, cookie_path) + profile_dir = env_profile if os.path.isabs(env_profile) else os.path.join(root, env_profile) + profile_paths = _profile_cookie_paths(profile_dir) + if profile_paths: + logger.debug("Using specified Chromium profile: %s", env_profile) + return profile_paths + logger.warning("TWITTER_CHROME_PROFILE='%s' not found under %s", env_profile, root) return [] # Auto-discover: Default first, then Profile N sorted paths: List[str] = [] - default_cookies = os.path.join(root, "Default", "Cookies") - if os.path.exists(default_cookies): - paths.append(default_cookies) + seen = set() + + def append_profile(profile_dir: str) -> None: + for cookie_path in _profile_cookie_paths(profile_dir): + if cookie_path not in seen: + seen.add(cookie_path) + paths.append(cookie_path) + # The configured path may be either a User Data root or one profile. + append_profile(root) + append_profile(os.path.join(root, "Default")) profile_dirs = sorted(glob.glob(os.path.join(root, "Profile *"))) for profile_dir in profile_dirs: - cookie_file = os.path.join(profile_dir, "Cookies") - if os.path.exists(cookie_file): - paths.append(cookie_file) + append_profile(profile_dir) return paths +def _get_browser_cookie_fn(browser_cookie3: Any, browser_name: str) -> Any: + """Return a browser-cookie3 loader for a configured browser.""" + if browser_name == _CUSTOM_CHROMIUM_BROWSER: + return lambda cookie_file=None: browser_cookie3.chromium( + cookie_file=cookie_file, + key_file=_chromium_key_file_for_cookie(cookie_file) if cookie_file else None, + ) + return getattr(browser_cookie3, browser_name) + + def _extract_in_process() -> Tuple[Optional[Dict[str, str]], List[str]]: """Extract cookies in the main process (required on macOS for Keychain access). @@ -286,22 +358,25 @@ def _extract_in_process() -> Tuple[Optional[Dict[str, str]], List[str]]: logger.debug("browser_cookie3 not installed, skipping in-process extraction") return None, ["browser-cookie3 not installed"] - browser_fns = { - "arc": browser_cookie3.arc, - "chrome": browser_cookie3.chrome, - "edge": browser_cookie3.edge, - "firefox": browser_cookie3.firefox, - "brave": browser_cookie3.brave, - } attempts: List[str] = [] diagnostics: List[str] = [] for name in _get_browser_order(): - fn = browser_fns[name] - if name in _CHROMIUM_BASE_DIRS: + try: + fn = _get_browser_cookie_fn(browser_cookie3, name) + except AttributeError as e: + logger.debug("%s browser-cookie3 loader missing: %s", name, e) + attempts.append("%s=missing-loader" % name) + diagnostics.append("%s: %s" % (name, e)) + continue + + if name in _CHROMIUM_BASE_DIRS or name == _CUSTOM_CHROMIUM_BROWSER: # Chromium-based: iterate all profiles cookie_files = _iter_chrome_cookie_files(name) if not cookie_files: + if name == _CUSTOM_CHROMIUM_BROWSER: + attempts.append("%s=not-found" % name) + continue # No profile dirs found — try the default (no cookie_file arg) try: jar = fn() @@ -318,7 +393,7 @@ def _extract_in_process() -> Tuple[Optional[Dict[str, str]], List[str]]: continue for cookie_file in cookie_files: - profile_name = os.path.basename(os.path.dirname(cookie_file)) + profile_name = _profile_name_from_cookie_file(cookie_file) try: jar = fn(cookie_file=cookie_file) except Exception as e: @@ -370,39 +445,90 @@ def _extract_via_subprocess() -> Tuple[Optional[Dict[str, str]], List[str]]: "edge": os.path.join("Microsoft Edge"), "brave": os.path.join("BraveSoftware", "Brave-Browser"), } +CUSTOM_CHROMIUM_BROWSER = "custom-chromium" + +def custom_chromium_user_data_dir(): + root = os.environ.get("TWITTER_CHROMIUM_USER_DATA_DIR", "").strip() + if not root: + return None + return os.path.abspath(os.path.expanduser(root)) + +def profile_cookie_paths(profile_dir): + paths = [] + for relative_path in ("Cookies", os.path.join("Network", "Cookies")): + cookie_path = os.path.join(profile_dir, relative_path) + if os.path.exists(cookie_path): + paths.append(cookie_path) + return paths + +def profile_name_from_cookie_file(cookie_file): + parent = os.path.basename(os.path.dirname(cookie_file)) + if parent == "Network": + return os.path.basename(os.path.dirname(os.path.dirname(cookie_file))) + return parent + +def chromium_key_file_for_cookie(cookie_file): + candidates = [] + custom_root = custom_chromium_user_data_dir() + if custom_root: + candidates.append(os.path.join(custom_root, "Local State")) + profile_dir = os.path.dirname(cookie_file) + if os.path.basename(profile_dir) == "Network": + profile_dir = os.path.dirname(profile_dir) + candidates.append(os.path.join(os.path.dirname(profile_dir), "Local State")) + for candidate in dict.fromkeys(candidates): + if os.path.exists(candidate): + return candidate + return None def iter_cookie_files(browser_name): - base_dir = CHROMIUM_BASE_DIRS.get(browser_name) - if base_dir is None: - return [] - if sys.platform == "darwin": - root = os.path.join(os.path.expanduser("~"), "Library", "Application Support", base_dir) - elif sys.platform == "win32": - if browser_name == "edge": - root = os.path.join(os.environ.get("LOCALAPPDATA", ""), "Microsoft", "Edge", "User Data") - else: - root = os.path.join(os.environ.get("LOCALAPPDATA", ""), base_dir) + if browser_name == CUSTOM_CHROMIUM_BROWSER: + root = custom_chromium_user_data_dir() + if not root: + return [] else: - if browser_name == "edge": - root = os.path.join(os.path.expanduser("~"), ".config", "microsoft-edge") + base_dir = CHROMIUM_BASE_DIRS.get(browser_name) + if base_dir is None: + return [] + if sys.platform == "darwin": + root = os.path.join(os.path.expanduser("~"), "Library", "Application Support", base_dir) + elif sys.platform == "win32": + if browser_name == "edge": + root = os.path.join(os.environ.get("LOCALAPPDATA", ""), "Microsoft", "Edge", "User Data") + else: + root = os.path.join(os.environ.get("LOCALAPPDATA", ""), base_dir) else: - root = os.path.join(os.path.expanduser("~"), ".config", base_dir) + if browser_name == "edge": + root = os.path.join(os.path.expanduser("~"), ".config", "microsoft-edge") + else: + root = os.path.join(os.path.expanduser("~"), ".config", base_dir) if not os.path.isdir(root): return [] env_profile = os.environ.get("TWITTER_CHROME_PROFILE", "").strip() if env_profile: - p = os.path.join(root, env_profile, "Cookies") - return [p] if os.path.exists(p) else [] + profile_dir = env_profile if os.path.isabs(env_profile) else os.path.join(root, env_profile) + return profile_cookie_paths(profile_dir) paths = [] - d = os.path.join(root, "Default", "Cookies") - if os.path.exists(d): - paths.append(d) + seen = set() + def append_profile(profile_dir): + for cookie_path in profile_cookie_paths(profile_dir): + if cookie_path not in seen: + seen.add(cookie_path) + paths.append(cookie_path) + append_profile(root) + append_profile(os.path.join(root, "Default")) for pd in sorted(glob.glob(os.path.join(root, "Profile *"))): - cf = os.path.join(pd, "Cookies") - if os.path.exists(cf): - paths.append(cf) + append_profile(pd) return paths +def get_browser_cookie_fn(browser_name): + if browser_name == CUSTOM_CHROMIUM_BROWSER: + return lambda cookie_file=None: browser_cookie3.chromium( + cookie_file=cookie_file, + key_file=chromium_key_file_for_cookie(cookie_file) if cookie_file else None, + ) + return getattr(browser_cookie3, browser_name) + def extract_from_jar(jar, name, profile=""): result = {} all_cookies = {} @@ -424,25 +550,27 @@ def extract_from_jar(jar, name, profile=""): return None DEFAULT_ORDER = ["arc", "chrome", "edge", "firefox", "brave"] +SUPPORTED_BROWSERS = set(DEFAULT_ORDER) | {CUSTOM_CHROMIUM_BROWSER} +browser_order = list(DEFAULT_ORDER) +if custom_chromium_user_data_dir(): + browser_order.insert(0, CUSTOM_CHROMIUM_BROWSER) env_browser = os.environ.get("TWITTER_BROWSER", "").strip().lower() -if env_browser in {"arc", "chrome", "edge", "firefox", "brave"}: - browser_order = [env_browser] + [b for b in DEFAULT_ORDER if b != env_browser] -else: - browser_order = DEFAULT_ORDER -browser_fns = { - "arc": browser_cookie3.arc, - "chrome": browser_cookie3.chrome, - "edge": browser_cookie3.edge, - "firefox": browser_cookie3.firefox, - "brave": browser_cookie3.brave, -} +if env_browser in SUPPORTED_BROWSERS: + browser_order = [env_browser] + [b for b in browser_order if b != env_browser] attempts = [] for name in browser_order: - fn = browser_fns[name] - if name in CHROMIUM_BASE_DIRS: + try: + fn = get_browser_cookie_fn(name) + except AttributeError as exc: + attempts.append(f"{name}=missing-loader: {exc}") + continue + if name in CHROMIUM_BASE_DIRS or name == CUSTOM_CHROMIUM_BROWSER: cookie_files = iter_cookie_files(name) if not cookie_files: + if name == CUSTOM_CHROMIUM_BROWSER: + attempts.append(f"{name}=not-found") + continue try: jar = fn() except Exception as exc: @@ -455,7 +583,7 @@ def extract_from_jar(jar, name, profile=""): attempts.append(f"{name}=no-cookies") continue for cf in cookie_files: - pname = os.path.basename(os.path.dirname(cf)) + pname = profile_name_from_cookie_file(cf) try: jar = fn(cookie_file=cf) except Exception as exc: @@ -615,6 +743,10 @@ def get_cookies() -> Dict[str, str]: lines.append("") lines.append("Option 1: Set TWITTER_AUTH_TOKEN and TWITTER_CT0 environment variables") lines.append("Option 2: Make sure you are logged into x.com in your browser (Arc/Chrome/Edge/Firefox/Brave)") + lines.append( + "Option 3: For a custom Chromium profile, set TWITTER_CHROMIUM_USER_DATA_DIR " + "to its --user-data-dir" + ) lines.append("") lines.append("Run 'twitter -v ' for debug diagnostics.") raise AuthenticationError("\n".join(lines))