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