diff --git a/pyproject.toml b/pyproject.toml index 75b1977..1f6b5a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "twitter-cli" -version = "0.2.0" +version = "0.3.0" description = "A CLI for Twitter/X — feed, bookmarks, and user timeline in terminal" readme = "README.md" license = "Apache-2.0" diff --git a/twitter_cli/auth.py b/twitter_cli/auth.py index 0c66661..0319c35 100644 --- a/twitter_cli/auth.py +++ b/twitter_cli/auth.py @@ -3,6 +3,7 @@ Supports: 1. Environment variables: TWITTER_AUTH_TOKEN + TWITTER_CT0 2. Auto-extract from browser via browser-cookie3 (subprocess) + Extracts ALL Twitter cookies for full browser-like fingerprint. """ from __future__ import annotations @@ -30,8 +31,8 @@ def load_from_env() -> Optional[Dict[str, str]]: return None -def verify_cookies(auth_token, ct0): - # type: (str, str) -> Dict[str, Any] +def verify_cookies(auth_token, ct0, cookie_string=None): + # type: (str, str, Optional[str]) -> Dict[str, Any] """Verify cookies by calling a Twitter API endpoint. Uses curl_cffi for proper TLS fingerprint. @@ -43,16 +44,23 @@ def verify_cookies(auth_token, ct0): "https://x.com/i/api/1.1/account/settings.json", ] + # Use full cookie string if available, otherwise minimal + cookie_header = cookie_string or "auth_token=%s; ct0=%s" % (auth_token, ct0) + headers = { "Authorization": "Bearer %s" % BEARER_TOKEN, - "Cookie": "auth_token=%s; ct0=%s" % (auth_token, ct0), + "Cookie": cookie_header, "X-Csrf-Token": ct0, "X-Twitter-Active-User": "yes", "X-Twitter-Auth-Type": "OAuth2Session", "User-Agent": USER_AGENT, } - session = _cffi_requests.Session(impersonate="chrome133") + proxy = os.environ.get("TWITTER_PROXY", "") + session = _cffi_requests.Session( + impersonate="chrome133", + proxies={"https": proxy, "http": proxy} if proxy else None, + ) for url in urls: try: @@ -78,11 +86,14 @@ def verify_cookies(auth_token, ct0): def extract_from_browser() -> Optional[Dict[str, str]]: - """Auto-extract cookies from local browser using browser-cookie3. + """Auto-extract ALL Twitter cookies from local browser using browser-cookie3. + + Extracts every cookie for .x.com and .twitter.com domains, not just + auth_token and ct0. This makes requests indistinguishable from real + browser traffic at the cookie level. Tries browsers in order: Chrome -> Edge -> Firefox -> Brave. - Runs in a subprocess to avoid SQLite database lock issues when the - browser is running. + Runs in a subprocess to avoid SQLite database lock issues. """ extract_script = ''' import json, sys @@ -105,6 +116,7 @@ for name, fn in browsers: except Exception: continue result = {} + all_cookies = {} for cookie in jar: domain = cookie.domain or "" if domain.endswith(".x.com") or domain.endswith(".twitter.com") or domain in ("x.com", "twitter.com", ".x.com", ".twitter.com"): @@ -112,8 +124,12 @@ for name, fn in browsers: result["auth_token"] = cookie.value elif cookie.name == "ct0": result["ct0"] = cookie.value + # Collect ALL cookies for full browser fingerprint + if cookie.name and cookie.value: + all_cookies[cookie.name] = cookie.value if "auth_token" in result and "ct0" in result: result["browser"] = name + result["all_cookies"] = all_cookies print(json.dumps(result)) sys.exit(0) @@ -149,7 +165,15 @@ sys.exit(1) if "error" in data: return None logger.info("Found cookies in %s", data.get("browser", "unknown")) - return {"auth_token": data["auth_token"], "ct0": data["ct0"]} + + # Build full cookie string from all extracted cookies + cookies = {"auth_token": data["auth_token"], "ct0": data["ct0"]} + all_cookies = data.get("all_cookies", {}) + if all_cookies: + cookie_str = "; ".join("%s=%s" % (k, v) for k, v in all_cookies.items()) + cookies["cookie_string"] = cookie_str + logger.info("Extracted %d total cookies for full browser fingerprint", len(all_cookies)) + return cookies except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, FileNotFoundError): return None @@ -178,5 +202,6 @@ def get_cookies() -> Dict[str, str]: ) # Verify only for explicit auth failures; transient endpoint issues are tolerated. - verify_cookies(cookies["auth_token"], cookies["ct0"]) + verify_cookies(cookies["auth_token"], cookies["ct0"], cookies.get("cookie_string")) return cookies + diff --git a/twitter_cli/cli.py b/twitter_cli/cli.py index e697aa2..0915bc2 100644 --- a/twitter_cli/cli.py +++ b/twitter_cli/cli.py @@ -80,7 +80,12 @@ def _get_client(config=None): console.print("\nšŸ” Getting Twitter cookies...") cookies = get_cookies() rate_limit_config = (config or {}).get("rateLimit") - return TwitterClient(cookies["auth_token"], cookies["ct0"], rate_limit_config) + return TwitterClient( + cookies["auth_token"], + cookies["ct0"], + rate_limit_config, + cookie_string=cookies.get("cookie_string"), + ) def _resolve_fetch_count(max_count, configured): diff --git a/twitter_cli/client.py b/twitter_cli/client.py index 0769f86..cc8d7c9 100644 --- a/twitter_cli/client.py +++ b/twitter_cli/client.py @@ -23,6 +23,7 @@ logger = logging.getLogger(__name__) # Shared curl_cffi session — impersonates Chrome 133 TLS/JA3/HTTP2 fingerprint _cffi_session = None # type: Optional[Any] # lazy init +_cffi_proxy = None # type: Optional[str] FALLBACK_QUERY_IDS = { @@ -110,10 +111,17 @@ class TwitterAPIError(RuntimeError): def _get_cffi_session(): # type: () -> Any - """Return shared curl_cffi session with Chrome impersonation.""" + """Return shared curl_cffi session with Chrome impersonation and optional proxy.""" global _cffi_session if _cffi_session is None: - _cffi_session = _cffi_requests.Session(impersonate="chrome133") + import os + proxy = os.environ.get("TWITTER_PROXY", "") + _cffi_session = _cffi_requests.Session( + impersonate="chrome133", + proxies={"https": proxy, "http": proxy} if proxy else None, + ) + if proxy: + logger.info("Using proxy: %s", proxy[:20] + "...") return _cffi_session @@ -235,10 +243,11 @@ _ABSOLUTE_MAX_COUNT = 500 class TwitterClient: """Twitter GraphQL API client using cookie authentication.""" - def __init__(self, auth_token, ct0, rate_limit_config=None): - # type: (str, str, Optional[Dict[str, Any]]) -> None + def __init__(self, auth_token, ct0, rate_limit_config=None, cookie_string=None): + # type: (str, str, Optional[Dict[str, Any]], Optional[str]) -> None self._auth_token = auth_token self._ct0 = ct0 + self._cookie_string = cookie_string # Full browser cookie string rl = rate_limit_config or {} self._request_delay = float(rl.get("requestDelay", 2.5)) self._max_retries = int(rl.get("maxRetries", 3)) @@ -599,7 +608,7 @@ class TwitterClient: """Build shared headers for authenticated API calls.""" headers = { "Authorization": "Bearer %s" % BEARER_TOKEN, - "Cookie": "auth_token=%s; ct0=%s" % (self._auth_token, self._ct0), + "Cookie": self._cookie_string or "auth_token=%s; ct0=%s" % (self._auth_token, self._ct0), "X-Csrf-Token": self._ct0, "X-Twitter-Active-User": "yes", "X-Twitter-Auth-Type": "OAuth2Session", diff --git a/uv.lock b/uv.lock index aec7f76..e312255 100644 --- a/uv.lock +++ b/uv.lock @@ -1116,7 +1116,7 @@ wheels = [ [[package]] name = "twitter-cli" -version = "0.1.1" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" },