feat: full cookie forwarding from browser and TWITTER_PROXY support

This commit is contained in:
jackwener
2026-03-09 19:12:06 +08:00
parent 27d73efee5
commit b83abadb73
5 changed files with 56 additions and 17 deletions

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "twitter-cli" name = "twitter-cli"
version = "0.2.0" version = "0.3.0"
description = "A CLI for Twitter/X — feed, bookmarks, and user timeline in terminal" description = "A CLI for Twitter/X — feed, bookmarks, and user timeline in terminal"
readme = "README.md" readme = "README.md"
license = "Apache-2.0" license = "Apache-2.0"

View File

@@ -3,6 +3,7 @@
Supports: Supports:
1. Environment variables: TWITTER_AUTH_TOKEN + TWITTER_CT0 1. Environment variables: TWITTER_AUTH_TOKEN + TWITTER_CT0
2. Auto-extract from browser via browser-cookie3 (subprocess) 2. Auto-extract from browser via browser-cookie3 (subprocess)
Extracts ALL Twitter cookies for full browser-like fingerprint.
""" """
from __future__ import annotations from __future__ import annotations
@@ -30,8 +31,8 @@ def load_from_env() -> Optional[Dict[str, str]]:
return None return None
def verify_cookies(auth_token, ct0): def verify_cookies(auth_token, ct0, cookie_string=None):
# type: (str, str) -> Dict[str, Any] # type: (str, str, Optional[str]) -> Dict[str, Any]
"""Verify cookies by calling a Twitter API endpoint. """Verify cookies by calling a Twitter API endpoint.
Uses curl_cffi for proper TLS fingerprint. 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", "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 = { headers = {
"Authorization": "Bearer %s" % BEARER_TOKEN, "Authorization": "Bearer %s" % BEARER_TOKEN,
"Cookie": "auth_token=%s; ct0=%s" % (auth_token, ct0), "Cookie": cookie_header,
"X-Csrf-Token": ct0, "X-Csrf-Token": ct0,
"X-Twitter-Active-User": "yes", "X-Twitter-Active-User": "yes",
"X-Twitter-Auth-Type": "OAuth2Session", "X-Twitter-Auth-Type": "OAuth2Session",
"User-Agent": USER_AGENT, "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: for url in urls:
try: try:
@@ -78,11 +86,14 @@ def verify_cookies(auth_token, ct0):
def extract_from_browser() -> Optional[Dict[str, str]]: 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. Tries browsers in order: Chrome -> Edge -> Firefox -> Brave.
Runs in a subprocess to avoid SQLite database lock issues when the Runs in a subprocess to avoid SQLite database lock issues.
browser is running.
""" """
extract_script = ''' extract_script = '''
import json, sys import json, sys
@@ -105,6 +116,7 @@ for name, fn in browsers:
except Exception: except Exception:
continue continue
result = {} result = {}
all_cookies = {}
for cookie in jar: for cookie in jar:
domain = cookie.domain or "" 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"): 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 result["auth_token"] = cookie.value
elif cookie.name == "ct0": elif cookie.name == "ct0":
result["ct0"] = cookie.value 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: if "auth_token" in result and "ct0" in result:
result["browser"] = name result["browser"] = name
result["all_cookies"] = all_cookies
print(json.dumps(result)) print(json.dumps(result))
sys.exit(0) sys.exit(0)
@@ -149,7 +165,15 @@ sys.exit(1)
if "error" in data: if "error" in data:
return None return None
logger.info("Found cookies in %s", data.get("browser", "unknown")) 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): except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, FileNotFoundError):
return None return None
@@ -178,5 +202,6 @@ def get_cookies() -> Dict[str, str]:
) )
# Verify only for explicit auth failures; transient endpoint issues are tolerated. # 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 return cookies

View File

@@ -80,7 +80,12 @@ def _get_client(config=None):
console.print("\n🔐 Getting Twitter cookies...") console.print("\n🔐 Getting Twitter cookies...")
cookies = get_cookies() cookies = get_cookies()
rate_limit_config = (config or {}).get("rateLimit") 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): def _resolve_fetch_count(max_count, configured):

View File

@@ -23,6 +23,7 @@ logger = logging.getLogger(__name__)
# Shared curl_cffi session — impersonates Chrome 133 TLS/JA3/HTTP2 fingerprint # Shared curl_cffi session — impersonates Chrome 133 TLS/JA3/HTTP2 fingerprint
_cffi_session = None # type: Optional[Any] # lazy init _cffi_session = None # type: Optional[Any] # lazy init
_cffi_proxy = None # type: Optional[str]
FALLBACK_QUERY_IDS = { FALLBACK_QUERY_IDS = {
@@ -110,10 +111,17 @@ class TwitterAPIError(RuntimeError):
def _get_cffi_session(): def _get_cffi_session():
# type: () -> Any # type: () -> Any
"""Return shared curl_cffi session with Chrome impersonation.""" """Return shared curl_cffi session with Chrome impersonation and optional proxy."""
global _cffi_session global _cffi_session
if _cffi_session is None: 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 return _cffi_session
@@ -235,10 +243,11 @@ _ABSOLUTE_MAX_COUNT = 500
class TwitterClient: class TwitterClient:
"""Twitter GraphQL API client using cookie authentication.""" """Twitter GraphQL API client using cookie authentication."""
def __init__(self, auth_token, ct0, rate_limit_config=None): def __init__(self, auth_token, ct0, rate_limit_config=None, cookie_string=None):
# type: (str, str, Optional[Dict[str, Any]]) -> None # type: (str, str, Optional[Dict[str, Any]], Optional[str]) -> None
self._auth_token = auth_token self._auth_token = auth_token
self._ct0 = ct0 self._ct0 = ct0
self._cookie_string = cookie_string # Full browser cookie string
rl = rate_limit_config or {} rl = rate_limit_config or {}
self._request_delay = float(rl.get("requestDelay", 2.5)) self._request_delay = float(rl.get("requestDelay", 2.5))
self._max_retries = int(rl.get("maxRetries", 3)) self._max_retries = int(rl.get("maxRetries", 3))
@@ -599,7 +608,7 @@ class TwitterClient:
"""Build shared headers for authenticated API calls.""" """Build shared headers for authenticated API calls."""
headers = { headers = {
"Authorization": "Bearer %s" % BEARER_TOKEN, "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-Csrf-Token": self._ct0,
"X-Twitter-Active-User": "yes", "X-Twitter-Active-User": "yes",
"X-Twitter-Auth-Type": "OAuth2Session", "X-Twitter-Auth-Type": "OAuth2Session",

2
uv.lock generated
View File

@@ -1116,7 +1116,7 @@ wheels = [
[[package]] [[package]]
name = "twitter-cli" name = "twitter-cli"
version = "0.1.1" version = "0.2.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "beautifulsoup4" }, { name = "beautifulsoup4" },