Add cookies.txt authentication support
This commit is contained in:
17
README.md
17
README.md
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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, []))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
Reference in New Issue
Block a user