Add cookies.txt authentication support
All checks were successful
CI / test (3.10) (push) Successful in 1m51s
CI / test (3.11) (push) Successful in 29s
CI / test (3.12) (push) Successful in 24s

This commit is contained in:
2026-05-08 16:43:01 +02:00
parent 7c634e0d39
commit bc82333e07
5 changed files with 138 additions and 7 deletions

View File

@@ -174,7 +174,22 @@ twitter follow elonmusk --json
twitter-cli uses this auth priority: twitter-cli uses this auth priority:
1. **Environment variables**: `TWITTER_AUTH_TOKEN` + `TWITTER_CT0` 1. **Environment variables**: `TWITTER_AUTH_TOKEN` + `TWITTER_CT0`
2. **Browser cookies** (recommended): auto-extract from Arc/Chrome/Edge/Firefox/Brave 2. **Cookie file**: `TWITTER_COOKIE_FILE` or `config.yaml -> auth.cookieFile`
3. **Browser cookies** (recommended): auto-extract from Arc/Chrome/Edge/Firefox/Brave
If you already exported a Netscape-format `cookies.txt`, point the CLI at it:
```bash
export TWITTER_COOKIE_FILE=/path/to/cookies.txt
twitter whoami
```
Or in `config.yaml`:
```yaml
auth:
cookieFile: /path/to/cookies.txt
```
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. 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.

View File

@@ -1,6 +1,9 @@
fetch: fetch:
count: 50 count: 50
auth:
cookieFile: /mnt/shared/cookies.txt
filter: filter:
mode: "topN" mode: "topN"
topN: 20 topN: 20

View File

@@ -83,6 +83,45 @@ def test_extract_cookies_from_jar_logs_missing_required_cookies(caplog) -> None:
assert "ct0=False" in caplog.text assert "ct0=False" in caplog.text
def test_load_from_cookie_file_parses_netscape_cookie_dump(tmp_path) -> None:
cookie_file = tmp_path / "cookies.txt"
cookie_file.write_text(
"# Netscape HTTP Cookie File\n"
".x.com\tTRUE\t/\tTRUE\t0\tguest_id\tv1%3A123\n"
".x.com\tTRUE\t/\tTRUE\t0\tct0\tcsrf-token\n"
".x.com\tTRUE\t/\tTRUE\t0\tauth_token\tauth-token\n",
encoding="utf-8",
)
cookies = auth.load_from_cookie_file(str(cookie_file))
assert cookies is not None
assert cookies["auth_token"] == "auth-token"
assert cookies["ct0"] == "csrf-token"
assert "guest_id=v1%3A123" in cookies["cookie_string"]
def test_get_cookies_uses_cookie_file_before_browser(monkeypatch) -> None:
monkeypatch.setattr(auth, "load_from_env", lambda: None)
monkeypatch.setattr(
auth,
"load_from_cookie_file",
lambda path: {"auth_token": "file-token", "ct0": "file-csrf", "cookie_string": "a=1"},
)
monkeypatch.setattr(auth, "extract_from_browser", lambda: pytest.fail("should not extract from browser"))
seen = []
monkeypatch.setattr(
auth,
"verify_cookies",
lambda auth_token, ct0, cookie_string=None: seen.append((auth_token, ct0, cookie_string)) or {},
)
cookies = auth.get_cookies({"auth": {"cookieFile": "/tmp/cookies.txt"}})
assert cookies["auth_token"] == "file-token"
assert seen == [("file-token", "file-csrf", "a=1")]
def test_extract_from_browser_logs_warning_when_all_methods_fail(monkeypatch, caplog) -> None: def test_extract_from_browser_logs_warning_when_all_methods_fail(monkeypatch, caplog) -> None:
monkeypatch.setattr(auth, "_extract_in_process", lambda: (None, [])) monkeypatch.setattr(auth, "_extract_in_process", lambda: (None, []))
monkeypatch.setattr(auth, "_extract_via_subprocess", lambda: (None, [])) monkeypatch.setattr(auth, "_extract_via_subprocess", lambda: (None, []))

View File

@@ -2,7 +2,8 @@
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 2. Cookie file: TWITTER_COOKIE_FILE or config auth.cookieFile (Netscape cookies.txt)
3. Auto-extract from browser via browser-cookie3
Extracts ALL Twitter cookies for full browser-like fingerprint. Extracts ALL Twitter cookies for full browser-like fingerprint.
Prefers in-process extraction (required on macOS for Keychain access), Prefers in-process extraction (required on macOS for Keychain access),
falls back to subprocess if in-process fails (e.g. SQLite lock). falls back to subprocess if in-process fails (e.g. SQLite lock).
@@ -16,6 +17,7 @@ import logging
import os import os
import subprocess import subprocess
import sys import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from .constants import BEARER_TOKEN, get_user_agent from .constants import BEARER_TOKEN, get_user_agent
@@ -97,6 +99,67 @@ def load_from_env() -> Optional[Dict[str, str]]:
return None return None
def _parse_cookie_file(cookie_file: Path) -> Optional[Dict[str, str]]:
"""Load Twitter cookies from a Netscape cookies.txt file."""
try:
raw = cookie_file.read_text(encoding="utf-8")
except OSError as exc:
logger.debug("Failed to read cookie file %s: %s", cookie_file, exc)
return None
all_cookies: Dict[str, str] = {}
auth_token = ""
ct0 = ""
for line in raw.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
parts = line.split("\t")
if len(parts) != 7:
continue
domain = parts[0].removeprefix("#HttpOnly_")
name = parts[5]
value = parts[6]
if not _is_twitter_domain(domain) or not name:
continue
all_cookies[name] = value
if name == "auth_token":
auth_token = value
elif name == "ct0":
ct0 = value
if not auth_token or not ct0:
logger.debug(
"Cookie file %s did not contain usable Twitter auth cookies (auth_token=%s, ct0=%s)",
cookie_file,
bool(auth_token),
bool(ct0),
)
return None
cookies = {"auth_token": auth_token, "ct0": ct0}
if all_cookies:
cookies["cookie_string"] = "; ".join(f"{key}={value}" for key, value in all_cookies.items())
return cookies
def load_from_cookie_file(cookie_file: Optional[str]) -> Optional[Dict[str, str]]:
"""Load cookies from a configured cookies.txt file path."""
if not cookie_file:
return None
path = Path(cookie_file).expanduser()
if not path.exists():
logger.debug("Cookie file does not exist: %s", path)
return None
return _parse_cookie_file(path)
def verify_cookies(auth_token: str, ct0: str, cookie_string: Optional[str] = None) -> Dict[str, Any]: def verify_cookies(auth_token: str, ct0: str, cookie_string: Optional[str] = None) -> Dict[str, Any]:
"""Verify cookies by calling a Twitter API endpoint. """Verify cookies by calling a Twitter API endpoint.
@@ -586,8 +649,8 @@ def extract_from_browser() -> Tuple[Optional[Dict[str, str]], List[str]]:
return cookies, all_diagnostics return cookies, all_diagnostics
def get_cookies() -> Dict[str, str]: def get_cookies(config: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
"""Get Twitter cookies. Priority: env vars -> browser extraction. """Get Twitter cookies. Priority: env vars -> cookie file -> browser extraction.
Raises RuntimeError if no cookies found. Raises RuntimeError if no cookies found.
""" """
@@ -599,7 +662,17 @@ def get_cookies() -> Dict[str, str]:
if cookies: if cookies:
logger.info("Loaded cookies from environment variables") logger.info("Loaded cookies from environment variables")
# 2. Try browser extraction (auto-detect) # 2. Try cookie file from env/config
if not cookies:
auth_config = (config or {}).get("auth", {})
cookie_file = os.environ.get("TWITTER_COOKIE_FILE", "")
if not cookie_file and isinstance(auth_config, dict):
cookie_file = str(auth_config.get("cookieFile", "") or "")
cookies = load_from_cookie_file(cookie_file)
if cookies:
logger.info("Loaded cookies from cookie file %s", cookie_file)
# 3. Try browser extraction (auto-detect)
if not cookies: if not cookies:
logger.debug("Attempting browser cookie extraction") logger.debug("Attempting browser cookie extraction")
cookies, diagnostics = extract_from_browser() cookies, diagnostics = extract_from_browser()
@@ -614,7 +687,8 @@ def get_cookies() -> Dict[str, str]:
lines.extend(" " + line for line in hint.splitlines()) lines.extend(" " + line for line in hint.splitlines())
lines.append("") lines.append("")
lines.append("Option 1: Set TWITTER_AUTH_TOKEN and TWITTER_CT0 environment variables") lines.append("Option 1: Set TWITTER_AUTH_TOKEN and TWITTER_CT0 environment variables")
lines.append("Option 2: Make sure you are logged into x.com in your browser (Arc/Chrome/Edge/Firefox/Brave)") lines.append("Option 2: Set TWITTER_COOKIE_FILE or config auth.cookieFile to a Netscape cookies.txt export")
lines.append("Option 3: Make sure you are logged into x.com in your browser (Arc/Chrome/Edge/Firefox/Brave)")
lines.append("") lines.append("")
lines.append("Run 'twitter -v <command>' for debug diagnostics.") lines.append("Run 'twitter -v <command>' for debug diagnostics.")
raise AuthenticationError("\n".join(lines)) raise AuthenticationError("\n".join(lines))

View File

@@ -148,7 +148,7 @@ def _get_client(config=None, quiet=False):
"""Create an authenticated API client.""" """Create an authenticated API client."""
if not quiet: if not quiet:
console.print("\n🔐 Getting Twitter cookies...") console.print("\n🔐 Getting Twitter cookies...")
cookies = get_cookies() cookies = get_cookies(config)
rate_limit_config = (config or {}).get("rateLimit") rate_limit_config = (config or {}).get("rateLimit")
return TwitterClient( return TwitterClient(
cookies["auth_token"], cookies["auth_token"],