feat: full cookie forwarding from browser and TWITTER_PROXY support
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user