From 53a700ec608f82d9edb1246d9fb79ede3b3c50be Mon Sep 17 00:00:00 2001 From: jackwener Date: Wed, 11 Mar 2026 12:53:25 +0800 Subject: [PATCH] feat: support Chrome multi-profile cookie extraction Auto-iterates all Chrome/Arc/Edge/Brave profiles (Default, Profile 1, Profile 2, ...) to find Twitter cookies. Falls back to the default browser_cookie3 behavior when no profile dirs are found. Set TWITTER_CHROME_PROFILE env var to specify a profile explicitly: TWITTER_CHROME_PROFILE='Profile 2' twitter feed Closes #6 --- README.md | 12 +++ SKILL.md | 1 + tests/test_auth.py | 108 +++++++++++++++++++++ twitter_cli/auth.py | 222 +++++++++++++++++++++++++++++++++++++------- 4 files changed, 311 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index becffc3..b395674 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,12 @@ twitter-cli uses this auth priority: Browser extraction is recommended — it forwards ALL Twitter cookies (not just `auth_token` + `ct0`) and aligns request headers with your local runtime, which is closer to normal browser traffic than minimal cookie auth. +**Chrome multi-profile**: All Chrome profiles are scanned automatically. To specify a profile: + +```bash +TWITTER_CHROME_PROFILE="Profile 2" twitter feed +``` + After loading cookies, the CLI performs lightweight verification. Commands that require account access fail fast on clear auth errors (`401/403`). ### Proxy Support @@ -407,6 +413,12 @@ twitter follow elonmusk --json 推荐使用浏览器提取方式,会转发所有 Twitter Cookie,并按本机运行环境生成语言和平台请求头;它比仅发送 `auth_token` + `ct0` 更接近普通浏览器流量,但不等于完整浏览器自动化。 +**Chrome 多 Profile 支持**:会自动遍历所有 Chrome profile。也可以通过环境变量指定: + +```bash +TWITTER_CHROME_PROFILE="Profile 2" twitter feed +``` + ### 代理支持 设置 `TWITTER_PROXY` 环境变量即可: diff --git a/SKILL.md b/SKILL.md index 9a27566..6e6da21 100644 --- a/SKILL.md +++ b/SKILL.md @@ -48,6 +48,7 @@ If `AUTH_NEEDED`, proceed to guide the user: **Method A: Browser cookie extraction (recommended)** Ensure user is logged into x.com in one of: Arc, Chrome, Edge, Firefox, Brave. twitter-cli auto-extracts cookies. +All Chrome profiles are scanned automatically. To specify a profile: `TWITTER_CHROME_PROFILE="Profile 2" twitter feed`. ```bash twitter whoami diff --git a/tests/test_auth.py b/tests/test_auth.py index 72e4999..4726140 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import sys from types import SimpleNamespace @@ -187,3 +188,110 @@ def test_verify_cookies_logs_attempt_summary_on_non_auth_failures(monkeypatch, c assert result == {} assert "verify_credentials.json=404" in caplog.text assert "settings.json=Exception" in caplog.text + + +def test_iter_chrome_cookie_files_default_first(monkeypatch, tmp_path) -> None: + """Default profile should be yielded first, then Profile N sorted.""" + chrome_dir = tmp_path / "Google" / "Chrome" + (chrome_dir / "Default").mkdir(parents=True) + (chrome_dir / "Default" / "Cookies").touch() + (chrome_dir / "Profile 2").mkdir() + (chrome_dir / "Profile 2" / "Cookies").touch() + (chrome_dir / "Profile 1").mkdir() + (chrome_dir / "Profile 1" / "Cookies").touch() + + monkeypatch.delenv("TWITTER_CHROME_PROFILE", raising=False) + + # Patch the platform root to use tmp_path + if sys.platform == "darwin": + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(auth, "_CHROMIUM_BASE_DIRS", {"chrome": os.path.join("Google", "Chrome")}) + # On macOS, root = ~/Library/Application Support/ + # We need to adjust: create the macOS path structure + mac_chrome = tmp_path / "Library" / "Application Support" / "Google" / "Chrome" + mac_chrome.mkdir(parents=True, exist_ok=True) + (mac_chrome / "Default").mkdir(exist_ok=True) + (mac_chrome / "Default" / "Cookies").touch() + (mac_chrome / "Profile 1").mkdir(exist_ok=True) + (mac_chrome / "Profile 1" / "Cookies").touch() + (mac_chrome / "Profile 2").mkdir(exist_ok=True) + (mac_chrome / "Profile 2" / "Cookies").touch() + + paths = auth._iter_chrome_cookie_files("chrome") + + basenames = [os.path.basename(os.path.dirname(p)) for p in paths] + assert basenames[0] == "Default" + assert "Profile 1" in basenames + assert "Profile 2" in basenames + # Profile 1 should come before Profile 2 + assert basenames.index("Profile 1") < basenames.index("Profile 2") + + +def test_iter_chrome_cookie_files_env_override(monkeypatch, tmp_path) -> None: + """TWITTER_CHROME_PROFILE should restrict to that single profile.""" + if sys.platform == "darwin": + chrome_dir = tmp_path / "Library" / "Application Support" / "Google" / "Chrome" + else: + chrome_dir = tmp_path / ".config" / "Google" / "Chrome" + + (chrome_dir / "Default").mkdir(parents=True) + (chrome_dir / "Default" / "Cookies").touch() + (chrome_dir / "Profile 5").mkdir() + (chrome_dir / "Profile 5" / "Cookies").touch() + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("TWITTER_CHROME_PROFILE", "Profile 5") + + paths = auth._iter_chrome_cookie_files("chrome") + + assert len(paths) == 1 + assert "Profile 5" in paths[0] + + +def test_extract_in_process_tries_multiple_profiles(monkeypatch, tmp_path) -> None: + """When Default has no Twitter cookies but Profile 1 does, it should find them.""" + + class Cookie: + def __init__(self, domain: str, name: str, value: str) -> None: + self.domain = domain + self.name = name + self.value = value + + default_cookies_path = str(tmp_path / "Default" / "Cookies") + profile1_cookies_path = str(tmp_path / "Profile 1" / "Cookies") + os.makedirs(os.path.dirname(default_cookies_path), exist_ok=True) + os.makedirs(os.path.dirname(profile1_cookies_path), exist_ok=True) + open(default_cookies_path, "w").close() + open(profile1_cookies_path, "w").close() + + # Mock _iter_chrome_cookie_files to return our tmp paths + def mock_iter(browser_name): + if browser_name == "arc": + return [default_cookies_path, profile1_cookies_path] + return [] + + monkeypatch.setattr(auth, "_iter_chrome_cookie_files", mock_iter) + + # Arc: Default returns empty jar, Profile 1 returns valid cookies + def mock_arc(cookie_file=None): + if cookie_file == profile1_cookies_path: + return [ + Cookie(".x.com", "auth_token", "tok123"), + Cookie(".x.com", "ct0", "csrf456"), + ] + return [] # Default — no cookies + + fake_module = SimpleNamespace( + arc=mock_arc, + chrome=lambda cookie_file=None: [], + edge=lambda cookie_file=None: [], + firefox=lambda: [], + brave=lambda cookie_file=None: [], + ) + monkeypatch.setitem(sys.modules, "browser_cookie3", fake_module) + + cookies = auth._extract_in_process() + + assert cookies is not None + assert cookies["auth_token"] == "tok123" + assert cookies["ct0"] == "csrf456" diff --git a/twitter_cli/auth.py b/twitter_cli/auth.py index 14c11c0..5a198d4 100644 --- a/twitter_cli/auth.py +++ b/twitter_cli/auth.py @@ -10,12 +10,13 @@ Supports: from __future__ import annotations +import glob import json import logging import os import subprocess import sys -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple from .constants import BEARER_TOKEN, get_user_agent @@ -141,12 +142,69 @@ def _extract_cookies_from_jar(jar: Any, source: str = "unknown") -> Optional[Dic return None +# Base directories for Chromium-based browsers, keyed by browser name. +# Each entry maps to the directory under the platform-specific app data root. +_CHROMIUM_BASE_DIRS: Dict[str, str] = { + "chrome": os.path.join("Google", "Chrome"), + "arc": os.path.join("Arc", "User Data"), + "edge": os.path.join("Microsoft Edge"), + "brave": os.path.join("BraveSoftware", "Brave-Browser"), +} + + +def _iter_chrome_cookie_files(browser_name: str) -> List[str]: + """Return cookie file paths for all Chrome 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": + root = os.path.join(os.environ.get("LOCALAPPDATA", ""), base_dir, "User Data") + else: + root = os.path.join(os.path.expanduser("~"), ".config", base_dir) + + if not os.path.isdir(root): + return [] + + # 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) + 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) + + 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) + + return paths + + def _extract_in_process() -> Optional[Dict[str, str]]: """Extract cookies in the main process (required on macOS for Keychain access). On macOS, Chrome encrypts cookies using a key stored in the system Keychain. Child processes do NOT inherit the parent's Keychain authorization, so browser_cookie3 must run in the main process to decrypt cookies. + + For Chromium-based browsers, iterates all profiles to find Twitter cookies. """ try: import browser_cookie3 @@ -164,17 +222,51 @@ def _extract_in_process() -> Optional[Dict[str, str]]: attempts = [] for name, fn in browsers: - try: - jar = fn() - except Exception as e: - logger.debug("%s in-process extraction failed: %s", name, e) - attempts.append("%s=%s" % (name, type(e).__name__)) - continue - cookies = _extract_cookies_from_jar(jar, source="%s(in-process)" % name) - if cookies: - logger.info("Found cookies in %s (in-process)", name) - return cookies - attempts.append("%s=no-cookies" % name) + if name in _CHROMIUM_BASE_DIRS: + # Chromium-based: iterate all profiles + cookie_files = _iter_chrome_cookie_files(name) + if not cookie_files: + # No profile dirs found — try the default (no cookie_file arg) + try: + jar = fn() + except Exception as e: + logger.debug("%s in-process extraction failed: %s", name, e) + attempts.append("%s=%s" % (name, type(e).__name__)) + continue + cookies = _extract_cookies_from_jar(jar, source="%s(in-process)" % name) + if cookies: + logger.info("Found cookies in %s (in-process, default)", name) + return cookies + attempts.append("%s=no-cookies" % name) + continue + + for cookie_file in cookie_files: + profile_name = os.path.basename(os.path.dirname(cookie_file)) + try: + jar = fn(cookie_file=cookie_file) + except Exception as e: + logger.debug("%s[%s] in-process extraction failed: %s", name, profile_name, e) + attempts.append("%s[%s]=%s" % (name, profile_name, type(e).__name__)) + continue + cookies = _extract_cookies_from_jar(jar, source="%s[%s](in-process)" % (name, profile_name)) + if cookies: + logger.info("Found cookies in %s profile '%s' (in-process)", name, profile_name) + return cookies + attempts.append("%s[%s]=no-cookies" % (name, profile_name)) + else: + # Non-Chromium (Firefox): use default behavior + try: + jar = fn() + except Exception as e: + logger.debug("%s in-process extraction failed: %s", name, e) + attempts.append("%s=%s" % (name, type(e).__name__)) + continue + cookies = _extract_cookies_from_jar(jar, source="%s(in-process)" % name) + if cookies: + logger.info("Found cookies in %s (in-process)", name) + return cookies + attempts.append("%s=no-cookies" % name) + if attempts: logger.debug("In-process extraction attempts: %s", ", ".join(attempts)) return None @@ -183,28 +275,47 @@ def _extract_in_process() -> Optional[Dict[str, str]]: def _extract_via_subprocess() -> Optional[Dict[str, str]]: """Extract cookies via subprocess (fallback if in-process fails, e.g. SQLite lock).""" extract_script = ''' -import json, sys +import glob, json, os, sys try: import browser_cookie3 except ImportError: print(json.dumps({"error": "browser-cookie3 not installed"})) sys.exit(1) -browsers = [ - ("arc", browser_cookie3.arc), - ("chrome", browser_cookie3.chrome), - ("edge", browser_cookie3.edge), - ("firefox", browser_cookie3.firefox), - ("brave", browser_cookie3.brave), -] -attempts = [] +CHROMIUM_BASE_DIRS = { + "chrome": os.path.join("Google", "Chrome"), + "arc": os.path.join("Arc", "User Data"), + "edge": os.path.join("Microsoft Edge"), + "brave": os.path.join("BraveSoftware", "Brave-Browser"), +} -for name, fn in browsers: - try: - jar = fn() - except Exception as exc: - attempts.append(f"{name}={type(exc).__name__}") - continue +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": + root = os.path.join(os.environ.get("LOCALAPPDATA", ""), base_dir, "User Data") + 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 [] + paths = [] + d = os.path.join(root, "Default", "Cookies") + if os.path.exists(d): + paths.append(d) + 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) + return paths + +def extract_from_jar(jar, name, profile=""): result = {} all_cookies = {} for cookie in jar: @@ -218,12 +329,59 @@ for name, fn in browsers: all_cookies[cookie.name] = cookie.value if "auth_token" in result and "ct0" in result: result["browser"] = name + if profile: + result["profile"] = profile result["all_cookies"] = all_cookies - print(json.dumps(result)) - sys.exit(0) - attempts.append( - f"{name}=no-cookies(auth_token={'auth_token' in result},ct0={'ct0' in result})" - ) + return result + return None + +browsers = [ + ("arc", browser_cookie3.arc), + ("chrome", browser_cookie3.chrome), + ("edge", browser_cookie3.edge), + ("firefox", browser_cookie3.firefox), + ("brave", browser_cookie3.brave), +] +attempts = [] + +for name, fn in browsers: + if name in CHROMIUM_BASE_DIRS: + cookie_files = iter_cookie_files(name) + if not cookie_files: + try: + jar = fn() + except Exception as exc: + attempts.append(f"{name}={type(exc).__name__}") + continue + r = extract_from_jar(jar, name) + if r: + print(json.dumps(r)) + sys.exit(0) + attempts.append(f"{name}=no-cookies") + continue + for cf in cookie_files: + pname = os.path.basename(os.path.dirname(cf)) + try: + jar = fn(cookie_file=cf) + except Exception as exc: + attempts.append(f"{name}[{pname}]={type(exc).__name__}") + continue + r = extract_from_jar(jar, name, pname) + if r: + print(json.dumps(r)) + sys.exit(0) + attempts.append(f"{name}[{pname}]=no-cookies") + else: + try: + jar = fn() + except Exception as exc: + attempts.append(f"{name}={type(exc).__name__}") + continue + r = extract_from_jar(jar, name) + if r: + print(json.dumps(r)) + sys.exit(0) + attempts.append(f"{name}=no-cookies") print(json.dumps({ "error": "No Twitter cookies found in any browser. Make sure you are logged into x.com.",