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:
12
README.md
12
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` 环境变量即可:
|
||||
|
||||
1
SKILL.md
1
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user