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.
**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` 环境变量即可:

View File

@@ -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

View File

@@ -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/<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
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,6 +222,39 @@ def _extract_in_process() -> Optional[Dict[str, str]]:
attempts = []
for name, fn in browsers:
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:
@@ -175,6 +266,7 @@ def _extract_in_process() -> Optional[Dict[str, str]]:
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))
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(auth_token={'auth_token' in result},ct0={'ct0' in result})"
)
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.",