From bc82333e07de3170558ccdea64ec9ba91e935fdc Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 8 May 2026 16:43:01 +0200 Subject: [PATCH] Add cookies.txt authentication support --- README.md | 17 ++++++++- config.yaml | 3 ++ tests/test_auth.py | 39 +++++++++++++++++++++ twitter_cli/auth.py | 84 ++++++++++++++++++++++++++++++++++++++++++--- twitter_cli/cli.py | 2 +- 5 files changed, 138 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e033cd2..3910432 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,22 @@ twitter follow elonmusk --json twitter-cli uses this auth priority: 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. diff --git a/config.yaml b/config.yaml index 0a62952..a575bf6 100644 --- a/config.yaml +++ b/config.yaml @@ -1,6 +1,9 @@ fetch: count: 50 +auth: + cookieFile: /mnt/shared/cookies.txt + filter: mode: "topN" topN: 20 diff --git a/tests/test_auth.py b/tests/test_auth.py index 8010c08..d84551a 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -83,6 +83,45 @@ def test_extract_cookies_from_jar_logs_missing_required_cookies(caplog) -> None: 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: monkeypatch.setattr(auth, "_extract_in_process", lambda: (None, [])) monkeypatch.setattr(auth, "_extract_via_subprocess", lambda: (None, [])) diff --git a/twitter_cli/auth.py b/twitter_cli/auth.py index 81dd44c..902f677 100644 --- a/twitter_cli/auth.py +++ b/twitter_cli/auth.py @@ -2,7 +2,8 @@ Supports: 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. Prefers in-process extraction (required on macOS for Keychain access), falls back to subprocess if in-process fails (e.g. SQLite lock). @@ -16,6 +17,7 @@ import logging import os import subprocess import sys +from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from .constants import BEARER_TOKEN, get_user_agent @@ -97,6 +99,67 @@ def load_from_env() -> Optional[Dict[str, str]]: 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]: """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 -def get_cookies() -> Dict[str, str]: - """Get Twitter cookies. Priority: env vars -> browser extraction. +def get_cookies(config: Optional[Dict[str, Any]] = None) -> Dict[str, str]: + """Get Twitter cookies. Priority: env vars -> cookie file -> browser extraction. Raises RuntimeError if no cookies found. """ @@ -599,7 +662,17 @@ def get_cookies() -> Dict[str, str]: if cookies: 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: logger.debug("Attempting browser cookie extraction") cookies, diagnostics = extract_from_browser() @@ -614,7 +687,8 @@ def get_cookies() -> Dict[str, str]: lines.extend(" " + line for line in hint.splitlines()) lines.append("") 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("Run 'twitter -v ' for debug diagnostics.") raise AuthenticationError("\n".join(lines)) diff --git a/twitter_cli/cli.py b/twitter_cli/cli.py index d2dc523..87595eb 100644 --- a/twitter_cli/cli.py +++ b/twitter_cli/cli.py @@ -148,7 +148,7 @@ def _get_client(config=None, quiet=False): """Create an authenticated API client.""" if not quiet: console.print("\nšŸ” Getting Twitter cookies...") - cookies = get_cookies() + cookies = get_cookies(config) rate_limit_config = (config or {}).get("rateLimit") return TwitterClient( cookies["auth_token"],