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
This commit is contained in:
jackwener
2026-03-11 12:53:25 +08:00
parent 84504b1477
commit 53a700ec60
4 changed files with 311 additions and 32 deletions

View File

@@ -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. 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`). After loading cookies, the CLI performs lightweight verification. Commands that require account access fail fast on clear auth errors (`401/403`).
### Proxy Support ### Proxy Support
@@ -407,6 +413,12 @@ twitter follow elonmusk --json
推荐使用浏览器提取方式,会转发所有 Twitter Cookie并按本机运行环境生成语言和平台请求头它比仅发送 `auth_token` + `ct0` 更接近普通浏览器流量,但不等于完整浏览器自动化。 推荐使用浏览器提取方式,会转发所有 Twitter Cookie并按本机运行环境生成语言和平台请求头它比仅发送 `auth_token` + `ct0` 更接近普通浏览器流量,但不等于完整浏览器自动化。
**Chrome 多 Profile 支持**:会自动遍历所有 Chrome profile。也可以通过环境变量指定
```bash
TWITTER_CHROME_PROFILE="Profile 2" twitter feed
```
### 代理支持 ### 代理支持
设置 `TWITTER_PROXY` 环境变量即可: 设置 `TWITTER_PROXY` 环境变量即可:

View File

@@ -48,6 +48,7 @@ If `AUTH_NEEDED`, proceed to guide the user:
**Method A: Browser cookie extraction (recommended)** **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. 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 ```bash
twitter whoami twitter whoami

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import sys import sys
from types import SimpleNamespace from types import SimpleNamespace
@@ -187,3 +188,110 @@ def test_verify_cookies_logs_attempt_summary_on_non_auth_failures(monkeypatch, c
assert result == {} assert result == {}
assert "verify_credentials.json=404" in caplog.text assert "verify_credentials.json=404" in caplog.text
assert "settings.json=Exception" 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/<base>
# 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"

View File

@@ -10,12 +10,13 @@ Supports:
from __future__ import annotations from __future__ import annotations
import glob
import json import json
import logging import logging
import os import os
import subprocess import subprocess
import sys 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 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 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]]: def _extract_in_process() -> Optional[Dict[str, str]]:
"""Extract cookies in the main process (required on macOS for Keychain access). """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. On macOS, Chrome encrypts cookies using a key stored in the system Keychain.
Child processes do NOT inherit the parent's Keychain authorization, so Child processes do NOT inherit the parent's Keychain authorization, so
browser_cookie3 must run in the main process to decrypt cookies. browser_cookie3 must run in the main process to decrypt cookies.
For Chromium-based browsers, iterates all profiles to find Twitter cookies.
""" """
try: try:
import browser_cookie3 import browser_cookie3
@@ -164,17 +222,51 @@ def _extract_in_process() -> Optional[Dict[str, str]]:
attempts = [] attempts = []
for name, fn in browsers: for name, fn in browsers:
try: if name in _CHROMIUM_BASE_DIRS:
jar = fn() # Chromium-based: iterate all profiles
except Exception as e: cookie_files = _iter_chrome_cookie_files(name)
logger.debug("%s in-process extraction failed: %s", name, e) if not cookie_files:
attempts.append("%s=%s" % (name, type(e).__name__)) # No profile dirs found — try the default (no cookie_file arg)
continue try:
cookies = _extract_cookies_from_jar(jar, source="%s(in-process)" % name) jar = fn()
if cookies: except Exception as e:
logger.info("Found cookies in %s (in-process)", name) logger.debug("%s in-process extraction failed: %s", name, e)
return cookies attempts.append("%s=%s" % (name, type(e).__name__))
attempts.append("%s=no-cookies" % 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: if attempts:
logger.debug("In-process extraction attempts: %s", ", ".join(attempts)) logger.debug("In-process extraction attempts: %s", ", ".join(attempts))
return None return None
@@ -183,28 +275,47 @@ def _extract_in_process() -> Optional[Dict[str, str]]:
def _extract_via_subprocess() -> 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 cookies via subprocess (fallback if in-process fails, e.g. SQLite lock)."""
extract_script = ''' extract_script = '''
import json, sys import glob, json, os, sys
try: try:
import browser_cookie3 import browser_cookie3
except ImportError: except ImportError:
print(json.dumps({"error": "browser-cookie3 not installed"})) print(json.dumps({"error": "browser-cookie3 not installed"}))
sys.exit(1) sys.exit(1)
browsers = [ CHROMIUM_BASE_DIRS = {
("arc", browser_cookie3.arc), "chrome": os.path.join("Google", "Chrome"),
("chrome", browser_cookie3.chrome), "arc": os.path.join("Arc", "User Data"),
("edge", browser_cookie3.edge), "edge": os.path.join("Microsoft Edge"),
("firefox", browser_cookie3.firefox), "brave": os.path.join("BraveSoftware", "Brave-Browser"),
("brave", browser_cookie3.brave), }
]
attempts = []
for name, fn in browsers: def iter_cookie_files(browser_name):
try: base_dir = CHROMIUM_BASE_DIRS.get(browser_name)
jar = fn() if base_dir is None:
except Exception as exc: return []
attempts.append(f"{name}={type(exc).__name__}") if sys.platform == "darwin":
continue 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 = {} result = {}
all_cookies = {} all_cookies = {}
for cookie in jar: for cookie in jar:
@@ -218,12 +329,59 @@ for name, fn in browsers:
all_cookies[cookie.name] = cookie.value all_cookies[cookie.name] = cookie.value
if "auth_token" in result and "ct0" in result: if "auth_token" in result and "ct0" in result:
result["browser"] = name result["browser"] = name
if profile:
result["profile"] = profile
result["all_cookies"] = all_cookies result["all_cookies"] = all_cookies
print(json.dumps(result)) return result
sys.exit(0) return None
attempts.append(
f"{name}=no-cookies(auth_token={'auth_token' in result},ct0={'ct0' in result})" 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({ print(json.dumps({
"error": "No Twitter cookies found in any browser. Make sure you are logged into x.com.", "error": "No Twitter cookies found in any browser. Make sure you are logged into x.com.",