fix: harden auth flow and sync browser support docs

This commit is contained in:
jackwener
2026-03-10 11:02:32 +08:00
parent db7d7e8874
commit 19ab11d6a4
8 changed files with 572 additions and 170 deletions

View File

@@ -32,7 +32,7 @@ A terminal-first CLI for Twitter/X: read timelines, bookmarks, and user profiles
- Delete: remove your own tweets - Delete: remove your own tweets
- Like / Unlike: manage tweet likes - Like / Unlike: manage tweet likes
- Retweet / Unretweet: manage retweets - Retweet / Unretweet: manage retweets
- Bookmark: favorite/unfavorite - Bookmark: bookmark/unbookmark (`favorite/unfavorite` kept as compatibility aliases)
**Auth & Anti-Detection:** **Auth & Anti-Detection:**
- Cookie auth: use browser cookies or environment variables - Cookie auth: use browser cookies or environment variables
@@ -83,8 +83,8 @@ twitter feed --json > tweets.json
twitter feed --input tweets.json twitter feed --input tweets.json
# Bookmarks # Bookmarks
twitter favorites twitter bookmarks
twitter favorites --max 30 --json twitter bookmarks --max 30 --json
# Search # Search
twitter search "Claude Code" twitter search "Claude Code"
@@ -117,8 +117,8 @@ twitter like 1234567890
twitter unlike 1234567890 twitter unlike 1234567890
twitter retweet 1234567890 twitter retweet 1234567890
twitter unretweet 1234567890 twitter unretweet 1234567890
twitter favorite 1234567890 twitter bookmark 1234567890
twitter unfavorite 1234567890 twitter unbookmark 1234567890
``` ```
### Authentication ### Authentication
@@ -126,7 +126,7 @@ twitter unfavorite 1234567890
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 Chrome/Edge/Firefox/Brave 2. **Browser cookies** (recommended): auto-extract from Arc/Chrome/Edge/Firefox/Brave
Browser extraction is recommended — it forwards ALL Twitter cookies (not just `auth_token` + `ct0`), making requests indistinguishable from real browser traffic. Browser extraction is recommended — it forwards ALL Twitter cookies (not just `auth_token` + `ct0`), making requests indistinguishable from real browser traffic.
@@ -174,6 +174,10 @@ rateLimit:
maxCount: 200 # hard cap on fetched items maxCount: 200 # hard cap on fetched items
``` ```
Fetch behavior:
- `fetch.count` is the default item count for read commands when `--max` is omitted
Filter behavior: Filter behavior:
- Default behavior: no ranking filter unless `--filter` is passed - Default behavior: no ranking filter unless `--filter` is passed
@@ -206,8 +210,9 @@ Mode behavior:
### Troubleshooting ### Troubleshooting
- `No Twitter cookies found` - `No Twitter cookies found`
- Ensure you are logged in to `x.com` in a supported browser. - Ensure you are logged in to `x.com` in a supported browser (Arc/Chrome/Edge/Firefox/Brave).
- Or set `TWITTER_AUTH_TOKEN` and `TWITTER_CT0` manually. - Or set `TWITTER_AUTH_TOKEN` and `TWITTER_CT0` manually.
- Run with `-v` to see browser extraction diagnostics.
- `Cookie expired or invalid (HTTP 401/403)` - `Cookie expired or invalid (HTTP 401/403)`
- Re-login to `x.com` and retry. - Re-login to `x.com` and retry.
@@ -290,7 +295,7 @@ After installation, OpenClaw can call `twitter-cli` commands directly.
- 删除:删除自己的推文 - 删除:删除自己的推文
- 点赞 / 取消点赞 - 点赞 / 取消点赞
- 转推 / 取消转推 - 转推 / 取消转推
- 收藏 / 取消收藏:favorite/unfavorite - 书签 / 取消书签bookmark/unbookmark保留 `favorite/unfavorite` 兼容别名)
**认证与反风控:** **认证与反风控:**
- Cookie 认证:支持环境变量和浏览器自动提取 - Cookie 认证:支持环境变量和浏览器自动提取
@@ -317,7 +322,7 @@ twitter feed -t following
twitter feed --filter twitter feed --filter
# 收藏 # 收藏
twitter favorites twitter bookmarks
# 搜索 # 搜索
twitter search "Claude Code" twitter search "Claude Code"
@@ -348,8 +353,8 @@ twitter like 1234567890
twitter unlike 1234567890 twitter unlike 1234567890
twitter retweet 1234567890 twitter retweet 1234567890
twitter unretweet 1234567890 twitter unretweet 1234567890
twitter favorite 1234567890 twitter bookmark 1234567890
twitter unfavorite 1234567890 twitter unbookmark 1234567890
``` ```
### 认证说明 ### 认证说明
@@ -357,7 +362,7 @@ twitter unfavorite 1234567890
认证优先级: 认证优先级:
1. **环境变量**`TWITTER_AUTH_TOKEN` + `TWITTER_CT0` 1. **环境变量**`TWITTER_AUTH_TOKEN` + `TWITTER_CT0`
2. **浏览器提取**推荐Chrome/Edge/Firefox/Brave 全量 Cookie 提取 2. **浏览器提取**(推荐):Arc/Chrome/Edge/Firefox/Brave 全量 Cookie 提取
推荐使用浏览器提取方式,会转发所有 Twitter Cookie让请求和真实浏览器完全一致。 推荐使用浏览器提取方式,会转发所有 Twitter Cookie让请求和真实浏览器完全一致。
@@ -375,6 +380,8 @@ export TWITTER_PROXY=socks5://127.0.0.1:1080
### 筛选算法 ### 筛选算法
未传 `--max` 时,所有读取命令默认使用 `config.yaml` 里的 `fetch.count`
只有在传入 `--filter` 时才会启用筛选评分;默认不筛选。 只有在传入 `--filter` 时才会启用筛选评分;默认不筛选。
评分公式: 评分公式:
@@ -395,7 +402,8 @@ score = likes_w * likes
### 常见问题 ### 常见问题
- 报错 `No Twitter cookies found`:请先登录 `x.com` 或手动设置环境变量。 - 报错 `No Twitter cookies found`:请先登录 `x.com`,并确认浏览器为 Arc/Chrome/Edge/Firefox/Brave 之一,或手动设置环境变量。
- 如需查看浏览器提取细节,可加 `-v` 打开诊断日志。
- 报错 `Cookie expired or invalid`Cookie 过期,重新登录后重试。 - 报错 `Cookie expired or invalid`Cookie 过期,重新登录后重试。
- 报错 `Twitter API error 404`:通常是 queryId 轮换,重试即可。 - 报错 `Twitter API error 404`:通常是 queryId 轮换,重试即可。

View File

@@ -25,7 +25,7 @@ uv tool install twitter-cli
## Authentication ## Authentication
- Auto-extracts browser cookies from Chrome/Edge/Firefox/Brave. - Auto-extracts browser cookies from Arc/Chrome/Edge/Firefox/Brave.
- Or set environment variables: `TWITTER_AUTH_TOKEN` + `TWITTER_CT0`. - Or set environment variables: `TWITTER_AUTH_TOKEN` + `TWITTER_CT0`.
## Command Reference ## Command Reference
@@ -44,9 +44,9 @@ twitter feed --input tweets.json # Read from local JSON file
### Bookmarks ### Bookmarks
```bash ```bash
twitter favorites # List bookmarked tweets twitter bookmarks # List bookmarked tweets
twitter favorites --max 30 --json twitter bookmarks --max 30 --json
twitter favorites --filter # Apply ranking filter twitter bookmarks --filter # Apply ranking filter
``` ```
### Search ### Search
@@ -94,8 +94,8 @@ twitter like 1234567890 # Like
twitter unlike 1234567890 # Unlike twitter unlike 1234567890 # Unlike
twitter retweet 1234567890 # Retweet twitter retweet 1234567890 # Retweet
twitter unretweet 1234567890 # Unretweet twitter unretweet 1234567890 # Unretweet
twitter favorite 1234567890 # Bookmark twitter bookmark 1234567890 # Bookmark
twitter unfavorite 1234567890 # Unbookmark twitter unbookmark 1234567890 # Unbookmark
``` ```
## JSON / Scripting ## JSON / Scripting
@@ -115,7 +115,7 @@ Filtering is opt-in (disabled by default). Enable with `--filter`.
```bash ```bash
twitter feed --filter twitter feed --filter
twitter favorites --filter twitter bookmarks --filter
``` ```
The scoring formula: The scoring formula:
@@ -144,12 +144,12 @@ twitter user elonmusk --json
# Daily reading workflow # Daily reading workflow
twitter feed -t following --filter twitter feed -t following --filter
twitter favorites --filter twitter bookmarks --filter
``` ```
## Error Handling ## Error Handling
- `No Twitter cookies found` — login to `x.com` in a supported browser, or set env vars. - `No Twitter cookies found` — login to `x.com` in Arc/Chrome/Edge/Firefox/Brave, or set env vars.
- `Cookie expired or invalid (HTTP 401/403)` — re-login to `x.com` and retry. - `Cookie expired or invalid (HTTP 401/403)` — re-login to `x.com` and retry.
- `Twitter API error 404` — queryId rotation, retry the command (client has live fallback). - `Twitter API error 404` — queryId rotation, retry the command (client has live fallback).

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path
from typing import Any from typing import Any
import pytest import pytest
@@ -34,3 +36,13 @@ def tweet_factory():
) )
return _make_tweet return _make_tweet
@pytest.fixture()
def fixture_loader():
fixture_dir = Path(__file__).parent / "fixtures"
def _load(name: str) -> Any:
return json.loads((fixture_dir / name).read_text(encoding="utf-8"))
return _load

166
tests/test_auth.py Normal file
View File

@@ -0,0 +1,166 @@
from __future__ import annotations
import json
import sys
from types import SimpleNamespace
import pytest
from twitter_cli import auth
def test_get_cookies_prefers_env(monkeypatch) -> None:
monkeypatch.setattr(auth, "load_from_env", lambda: {"auth_token": "env-token", "ct0": "env-csrf"})
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()
assert cookies == {"auth_token": "env-token", "ct0": "env-csrf"}
assert seen == [("env-token", "env-csrf", None)]
def test_get_cookies_reextracts_after_verify_failure(monkeypatch) -> None:
monkeypatch.setattr(auth, "load_from_env", lambda: None)
extracted = iter(
[
{"auth_token": "stale-token", "ct0": "stale-csrf", "cookie_string": "stale=1"},
{"auth_token": "fresh-token", "ct0": "fresh-csrf", "cookie_string": "fresh=1"},
]
)
monkeypatch.setattr(auth, "extract_from_browser", lambda: next(extracted))
calls = []
def _verify(auth_token, ct0, cookie_string=None):
calls.append((auth_token, ct0, cookie_string))
if auth_token == "stale-token":
raise RuntimeError("expired")
return {}
monkeypatch.setattr(auth, "verify_cookies", _verify)
cookies = auth.get_cookies()
assert cookies["auth_token"] == "fresh-token"
assert calls == [
("stale-token", "stale-csrf", "stale=1"),
("fresh-token", "fresh-csrf", "fresh=1"),
]
def test_load_from_env_logs_incomplete_env(monkeypatch, caplog) -> None:
monkeypatch.setenv("TWITTER_AUTH_TOKEN", "token")
monkeypatch.delenv("TWITTER_CT0", raising=False)
with caplog.at_level("DEBUG"):
cookies = auth.load_from_env()
assert cookies is None
assert "Environment cookies incomplete" in caplog.text
def test_extract_cookies_from_jar_logs_missing_required_cookies(caplog) -> None:
class Cookie:
def __init__(self, domain: str, name: str, value: str) -> None:
self.domain = domain
self.name = name
self.value = value
jar = [Cookie(".x.com", "auth_token", "token")]
with caplog.at_level("DEBUG"):
cookies = auth._extract_cookies_from_jar(jar, source="test-jar")
assert cookies is None
assert "test-jar" in caplog.text
assert "ct0=False" in caplog.text
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)
with caplog.at_level("WARNING"):
cookies = auth.extract_from_browser()
assert cookies is None
assert "Twitter cookie extraction failed in both in-process and subprocess modes" in caplog.text
def test_extract_in_process_supports_arc(monkeypatch) -> None:
class Cookie:
def __init__(self, domain: str, name: str, value: str) -> None:
self.domain = domain
self.name = name
self.value = value
fake_module = SimpleNamespace(
arc=lambda: [Cookie(".x.com", "auth_token", "token"), Cookie(".x.com", "ct0", "csrf")],
chrome=lambda: pytest.fail("chrome should not be used when arc succeeds"),
edge=lambda: pytest.fail("edge should not be used when arc succeeds"),
firefox=lambda: pytest.fail("firefox should not be used when arc succeeds"),
brave=lambda: pytest.fail("brave should not be used when arc succeeds"),
)
monkeypatch.setitem(sys.modules, "browser_cookie3", fake_module)
cookies = auth._extract_in_process()
assert cookies is not None
assert cookies["auth_token"] == "token"
assert cookies["ct0"] == "csrf"
def test_extract_via_subprocess_script_includes_arc(monkeypatch) -> None:
class Completed:
def __init__(self, stdout: str, stderr: str = "") -> None:
self.stdout = stdout
self.stderr = stderr
seen = {}
def _run(cmd, capture_output=True, text=True, timeout=15):
script = cmd[2]
seen["script"] = script
return Completed(json.dumps({"error": "No Twitter cookies found", "attempts": []}))
monkeypatch.setattr(auth.subprocess, "run", _run)
cookies = auth._extract_via_subprocess()
assert cookies is None
assert '("arc", browser_cookie3.arc)' in seen["script"]
def test_verify_cookies_logs_attempt_summary_on_non_auth_failures(monkeypatch, caplog) -> None:
class Response:
def __init__(self, status_code: int, payload=None) -> None:
self.status_code = status_code
self._payload = payload or {}
def json(self):
return self._payload
class Session:
def __init__(self) -> None:
self.calls = 0
def get(self, url, headers=None, timeout=5):
self.calls += 1
if self.calls == 1:
return Response(404)
raise Exception("network")
monkeypatch.setattr("twitter_cli.client._get_cffi_session", lambda: Session())
with caplog.at_level("INFO"):
result = auth.verify_cookies("token", "csrf")
assert result == {}
assert "verify_credentials.json=404" in caplog.text
assert "settings.json=Exception" in caplog.text

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from click.testing import CliRunner from click.testing import CliRunner
import pytest
from twitter_cli.cli import cli from twitter_cli.cli import cli
from twitter_cli.models import UserProfile from twitter_cli.models import UserProfile
@@ -26,3 +27,64 @@ def test_cli_feed_json_input_path(tmp_path, tweet_factory) -> None:
result = runner.invoke(cli, ["feed", "--input", str(json_path), "--json"]) result = runner.invoke(cli, ["feed", "--input", str(json_path), "--json"])
assert result.exit_code == 0 assert result.exit_code == 0
assert '"id": "1"' in result.output assert '"id": "1"' in result.output
@pytest.mark.parametrize(
"args",
[
["favorites"],
["bookmarks"],
["search", "x"],
["user-posts", "alice"],
["likes", "alice"],
["list", "123"],
],
)
def test_cli_commands_wrap_client_creation_errors(monkeypatch, args) -> None:
monkeypatch.setattr(
"twitter_cli.cli._get_client",
lambda config=None: (_ for _ in ()).throw(RuntimeError("boom")),
)
runner = CliRunner()
result = runner.invoke(cli, args)
assert result.exit_code == 1
assert "boom" in result.output
assert type(result.exception).__name__ == "SystemExit"
def test_cli_tweet_accepts_shared_url_with_query(monkeypatch) -> None:
class FakeClient:
def fetch_tweet_detail(self, tweet_id: str, max_count: int):
assert tweet_id == "12345"
assert max_count == 50
return []
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient())
monkeypatch.setattr(
"twitter_cli.cli.load_config",
lambda: {"fetch": {"count": 50}, "filter": {}, "rateLimit": {}},
)
runner = CliRunner()
result = runner.invoke(cli, ["tweet", "https://x.com/user/status/12345?s=20"])
assert result.exit_code == 0
def test_cli_bookmark_alias_works(monkeypatch) -> None:
calls = []
class FakeClient:
def bookmark_tweet(self, tweet_id: str) -> bool:
calls.append(tweet_id)
return True
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient())
runner = CliRunner()
result = runner.invoke(cli, ["bookmark", "123"])
assert result.exit_code == 0
assert calls == ["123"]

View File

@@ -0,0 +1,93 @@
from __future__ import annotations
from twitter_cli.client import TwitterClient, _deep_get
def _make_client() -> TwitterClient:
client = TwitterClient.__new__(TwitterClient)
client._ct_init_attempted = True
client._client_transaction = None
client._request_delay = 0.0
client._max_retries = 0
client._retry_base_delay = 0.0
client._max_count = 200
return client
def test_parse_home_timeline_fixture(fixture_loader) -> None:
client = _make_client()
payload = fixture_loader("home_timeline.json")
tweets, cursor = client._parse_timeline_response(
payload,
lambda data: _deep_get(data, "data", "home", "home_timeline_urt", "instructions"),
)
assert [tweet.id for tweet in tweets] == ["1", "20"]
assert cursor == "cursor-bottom-1"
assert tweets[0].media[0].type == "photo"
assert tweets[0].urls == ["https://example.com/post"]
assert tweets[1].is_retweet is True
assert tweets[1].retweeted_by == "bob"
assert tweets[1].quoted_tweet is not None
assert tweets[1].quoted_tweet.id == "30"
def test_parse_tweet_detail_fixture_with_nested_items(fixture_loader) -> None:
client = _make_client()
payload = fixture_loader("tweet_detail.json")
tweets, cursor = client._parse_timeline_response(
payload,
lambda data: _deep_get(data, "data", "threaded_conversation_with_injections_v2", "instructions"),
)
assert [tweet.id for tweet in tweets] == ["100", "101"]
assert cursor == "conversation-cursor"
def test_parse_search_timeline_fixture_with_module_items(fixture_loader) -> None:
client = _make_client()
payload = fixture_loader("search_timeline.json")
tweets, cursor = client._parse_timeline_response(
payload,
lambda data: _deep_get(data, "data", "search_by_raw_query", "search_timeline", "timeline", "instructions"),
)
assert [tweet.id for tweet in tweets] == ["500"]
assert cursor == "search-cursor"
assert tweets[0].media[0].type == "video"
assert tweets[0].media[0].url == "https://video-high.mp4"
def test_parse_list_timeline_fixture_with_visibility_wrapper(fixture_loader) -> None:
client = _make_client()
payload = fixture_loader("list_timeline.json")
tweets, cursor = client._parse_timeline_response(
payload,
lambda data: _deep_get(data, "data", "list", "tweets_timeline", "timeline", "instructions"),
)
assert [tweet.id for tweet in tweets] == ["700"]
assert cursor == "list-cursor"
assert tweets[0].author.verified is True
assert tweets[0].lang == "zh"
def test_fetch_user_list_with_fixture(monkeypatch, fixture_loader) -> None:
client = _make_client()
payload = fixture_loader("followers_page.json")
monkeypatch.setattr(client, "_graphql_get", lambda operation_name, variables, features: payload)
users = client._fetch_user_list(
"Followers",
"user-id",
20,
lambda data: _deep_get(data, "data", "user", "result", "timeline", "timeline", "instructions"),
)
assert len(users) == 1
assert users[0].screen_name == "follower1"
assert users[0].verified is True

View File

@@ -36,6 +36,12 @@ def load_from_env() -> Optional[Dict[str, str]]:
ct0 = os.environ.get("TWITTER_CT0", "") ct0 = os.environ.get("TWITTER_CT0", "")
if auth_token and ct0: if auth_token and ct0:
return {"auth_token": auth_token, "ct0": ct0} return {"auth_token": auth_token, "ct0": ct0}
if auth_token or ct0:
logger.debug(
"Environment cookies incomplete: auth_token=%s ct0=%s",
bool(auth_token),
bool(ct0),
)
return None return None
@@ -68,8 +74,15 @@ def verify_cookies(auth_token, ct0, cookie_string=None):
# Reuse the shared curl_cffi session for consistent TLS fingerprint # Reuse the shared curl_cffi session for consistent TLS fingerprint
session = _get_cffi_session() session = _get_cffi_session()
attempts = []
logger.debug(
"Verifying Twitter cookies with %s cookie header",
"full forwarded" if cookie_string else "minimal",
)
for url in urls: for url in urls:
endpoint = url.split("/")[-1]
try: try:
resp = session.get(url, headers=headers, timeout=5) resp = session.get(url, headers=headers, timeout=5)
if resp.status_code in (401, 403): if resp.status_code in (401, 403):
@@ -78,28 +91,37 @@ def verify_cookies(auth_token, ct0, cookie_string=None):
) )
if resp.status_code == 200: if resp.status_code == 200:
data = resp.json() data = resp.json()
attempts.append("%s=200" % endpoint)
logger.debug("Cookie verification succeeded via %s", endpoint)
return {"screen_name": data.get("screen_name", "")} return {"screen_name": data.get("screen_name", "")}
attempts.append("%s=%d" % (endpoint, resp.status_code))
logger.debug("Verification endpoint %s returned HTTP %d, trying next...", url, resp.status_code) logger.debug("Verification endpoint %s returned HTTP %d, trying next...", url, resp.status_code)
continue continue
except RuntimeError: except RuntimeError:
raise raise
except Exception as e: except Exception as e:
attempts.append("%s=%s" % (endpoint, type(e).__name__))
logger.debug("Verification endpoint %s failed: %s", url, e) logger.debug("Verification endpoint %s failed: %s", url, e)
continue continue
# All endpoints failed with non-auth errors — proceed without verification # All endpoints failed with non-auth errors — proceed without verification
logger.info("Cookie verification skipped (no working endpoint), will verify on first API call") logger.info(
"Cookie verification skipped (attempts: %s), will verify on first API call",
", ".join(attempts) if attempts else "none",
)
return {} return {}
def _extract_cookies_from_jar(jar): def _extract_cookies_from_jar(jar, source="unknown"):
# type: (Any) -> Optional[Dict[str, str]] # type: (Any, str) -> Optional[Dict[str, str]]
"""Extract Twitter cookies from a cookie jar.""" """Extract Twitter cookies from a cookie jar."""
result = {} # type: Dict[str, str] result = {} # type: Dict[str, str]
all_cookies = {} # type: Dict[str, str] all_cookies = {} # type: Dict[str, str]
twitter_cookie_count = 0
for cookie in jar: for cookie in jar:
domain = cookie.domain or "" domain = cookie.domain or ""
if _is_twitter_domain(domain): if _is_twitter_domain(domain):
twitter_cookie_count += 1
if cookie.name == "auth_token": if cookie.name == "auth_token":
result["auth_token"] = cookie.value result["auth_token"] = cookie.value
elif cookie.name == "ct0": elif cookie.name == "ct0":
@@ -112,6 +134,13 @@ def _extract_cookies_from_jar(jar):
cookies["cookie_string"] = "; ".join("%s=%s" % (k, v) for k, v in all_cookies.items()) cookies["cookie_string"] = "; ".join("%s=%s" % (k, v) for k, v in all_cookies.items())
logger.info("Extracted %d total cookies for full browser fingerprint", len(all_cookies)) logger.info("Extracted %d total cookies for full browser fingerprint", len(all_cookies))
return cookies return cookies
logger.debug(
"Cookie jar %s did not contain usable Twitter auth cookies (twitter_cookies=%d, auth_token=%s, ct0=%s)",
source,
twitter_cookie_count,
"auth_token" in result,
"ct0" in result,
)
return None return None
@@ -136,17 +165,22 @@ def _extract_in_process():
("firefox", browser_cookie3.firefox), ("firefox", browser_cookie3.firefox),
("brave", browser_cookie3.brave), ("brave", browser_cookie3.brave),
] ]
attempts = []
for name, fn in browsers: for name, fn in browsers:
try: try:
jar = fn() jar = fn()
except Exception as e: except Exception as e:
logger.debug("%s in-process extraction failed: %s", name, e) logger.debug("%s in-process extraction failed: %s", name, e)
attempts.append("%s=%s" % (name, type(e).__name__))
continue continue
cookies = _extract_cookies_from_jar(jar) cookies = _extract_cookies_from_jar(jar, source="%s(in-process)" % name)
if cookies: if cookies:
logger.info("Found cookies in %s (in-process)", name) logger.info("Found cookies in %s (in-process)", name)
return cookies return cookies
attempts.append("%s=no-cookies" % name)
if attempts:
logger.debug("In-process extraction attempts: %s", ", ".join(attempts))
return None return None
@@ -168,11 +202,13 @@ browsers = [
("firefox", browser_cookie3.firefox), ("firefox", browser_cookie3.firefox),
("brave", browser_cookie3.brave), ("brave", browser_cookie3.brave),
] ]
attempts = []
for name, fn in browsers: for name, fn in browsers:
try: try:
jar = fn() jar = fn()
except Exception: except Exception as exc:
attempts.append(f"{name}={type(exc).__name__}")
continue continue
result = {} result = {}
all_cookies = {} all_cookies = {}
@@ -190,8 +226,14 @@ for name, fn in browsers:
result["all_cookies"] = all_cookies result["all_cookies"] = all_cookies
print(json.dumps(result)) print(json.dumps(result))
sys.exit(0) sys.exit(0)
attempts.append(
f"{name}=no-cookies(auth_token={'auth_token' in result},ct0={'ct0' in result})"
)
print(json.dumps({"error": "No Twitter cookies found in any browser. Make sure you are logged into x.com."})) print(json.dumps({
"error": "No Twitter cookies found in any browser. Make sure you are logged into x.com.",
"attempts": attempts,
}))
sys.exit(1) sys.exit(1)
''' '''
@@ -221,6 +263,9 @@ sys.exit(1)
data = json.loads(output) data = json.loads(output)
if "error" in data: if "error" in data:
attempts = data.get("attempts") or []
if attempts:
logger.debug("Subprocess extraction attempts: %s", ", ".join(str(item) for item in attempts))
return None return None
logger.info("Found cookies in %s (subprocess)", data.get("browser", "unknown")) logger.info("Found cookies in %s (subprocess)", data.get("browser", "unknown"))
@@ -232,7 +277,17 @@ sys.exit(1)
cookies["cookie_string"] = cookie_str cookies["cookie_string"] = cookie_str
logger.info("Extracted %d total cookies for full browser fingerprint", len(all_cookies)) logger.info("Extracted %d total cookies for full browser fingerprint", len(all_cookies))
return cookies return cookies
except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, FileNotFoundError): except subprocess.TimeoutExpired:
logger.debug("Cookie extraction subprocess timed out")
return None
except json.JSONDecodeError as exc:
logger.debug("Cookie extraction subprocess returned invalid JSON: %s", exc)
return None
except KeyError as exc:
logger.debug("Cookie extraction subprocess returned incomplete payload: %s", exc)
return None
except FileNotFoundError as exc:
logger.debug("Cookie extraction subprocess launcher missing: %s", exc)
return None return None
@@ -250,11 +305,14 @@ def extract_from_browser() -> Optional[Dict[str, str]]:
# 2. Subprocess fallback (handles SQLite lock, but fails on macOS Keychain) # 2. Subprocess fallback (handles SQLite lock, but fails on macOS Keychain)
logger.debug("In-process extraction failed, trying subprocess fallback") logger.debug("In-process extraction failed, trying subprocess fallback")
return _extract_via_subprocess() cookies = _extract_via_subprocess()
if not cookies:
logger.warning("Twitter cookie extraction failed in both in-process and subprocess modes")
return cookies
def get_cookies() -> Dict[str, str]: def get_cookies() -> Dict[str, str]:
"""Get Twitter cookies. Priority: env vars -> cache file -> browser extraction. """Get Twitter cookies. Priority: env vars -> browser extraction.
Raises RuntimeError if no cookies found. Raises RuntimeError if no cookies found.
""" """
@@ -265,17 +323,10 @@ 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 cached cookies (file cache with TTL) # 2. Try browser extraction (auto-detect)
if not cookies:
cookies = _load_cookie_cache()
if cookies:
logger.info("Loaded cookies from cache")
# 3. Try browser extraction (auto-detect)
if not cookies: if not cookies:
logger.debug("Attempting browser cookie extraction")
cookies = extract_from_browser() cookies = extract_from_browser()
if cookies:
_save_cookie_cache(cookies)
if not cookies: if not cookies:
raise RuntimeError( raise RuntimeError(
@@ -288,66 +339,12 @@ def get_cookies() -> Dict[str, str]:
try: try:
verify_cookies(cookies["auth_token"], cookies["ct0"], cookies.get("cookie_string")) verify_cookies(cookies["auth_token"], cookies["ct0"], cookies.get("cookie_string"))
except RuntimeError: except RuntimeError:
# Auth failure — invalidate cache and re-extract from browser # Auth failure — re-extract from browser and retry verification
logger.info("Cookie verification failed, invalidating cache and re-extracting") logger.info("Cookie verification failed, re-extracting from browser")
invalidate_cookie_cache()
fresh_cookies = extract_from_browser() fresh_cookies = extract_from_browser()
if fresh_cookies: if fresh_cookies:
_save_cookie_cache(fresh_cookies)
# Verify fresh cookies — if this also fails, let it raise # Verify fresh cookies — if this also fails, let it raise
verify_cookies(fresh_cookies["auth_token"], fresh_cookies["ct0"], fresh_cookies.get("cookie_string")) verify_cookies(fresh_cookies["auth_token"], fresh_cookies["ct0"], fresh_cookies.get("cookie_string"))
return fresh_cookies return fresh_cookies
raise raise
return cookies return cookies
# ── Cookie file cache ───────────────────────────────────────────────────
_CACHE_DIR = os.path.join(os.path.expanduser("~"), ".cache", "twitter-cli")
_CACHE_FILE = os.path.join(_CACHE_DIR, "cookies.json")
_CACHE_TTL_SECONDS = 24 * 3600 # 24 hours
def _load_cookie_cache():
# type: () -> Optional[Dict[str, str]]
"""Load cookies from file cache if within TTL."""
try:
if not os.path.exists(_CACHE_FILE):
return None
import time as _time
mtime = os.path.getmtime(_CACHE_FILE)
if _time.time() - mtime > _CACHE_TTL_SECONDS:
logger.debug("Cookie cache expired (>%ds)", _CACHE_TTL_SECONDS)
return None
with open(_CACHE_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict) and "auth_token" in data and "ct0" in data:
return data
except Exception as exc:
logger.debug("Failed to load cookie cache: %s", exc)
return None
def _save_cookie_cache(cookies):
# type: (Dict[str, str]) -> None
"""Save cookies to file cache."""
try:
os.makedirs(_CACHE_DIR, exist_ok=True)
with open(_CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(cookies, f, ensure_ascii=False)
# Restrict permissions — cookies are sensitive
os.chmod(_CACHE_FILE, 0o600)
logger.info("Saved cookies to cache (%s)", _CACHE_FILE)
except Exception as exc:
logger.debug("Failed to save cookie cache: %s", exc)
def invalidate_cookie_cache():
# type: () -> None
"""Delete the cookie cache file."""
try:
if os.path.exists(_CACHE_FILE):
os.remove(_CACHE_FILE)
logger.info("Cookie cache invalidated")
except Exception as exc:
logger.debug("Failed to invalidate cookie cache: %s", exc)

View File

@@ -3,7 +3,7 @@
Read commands: Read commands:
twitter feed # home timeline (For You) twitter feed # home timeline (For You)
twitter feed -t following # following feed twitter feed -t following # following feed
twitter favorites # bookmarks twitter bookmarks # bookmarks
twitter search "query" # search tweets twitter search "query" # search tweets
twitter user elonmusk # user profile twitter user elonmusk # user profile
twitter user-posts elonmusk # user tweets twitter user-posts elonmusk # user tweets
@@ -17,19 +17,20 @@ Write commands:
twitter post "text" # post a tweet twitter post "text" # post a tweet
twitter delete <id> # delete a tweet twitter delete <id> # delete a tweet
twitter like/unlike <id> # like/unlike twitter like/unlike <id> # like/unlike
twitter favorite/unfavorite <id> # bookmark/unbookmark twitter bookmark/unbookmark <id> # bookmark/unbookmark
twitter retweet/unretweet <id> # retweet/unretweet twitter retweet/unretweet <id> # retweet/unretweet
""" """
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import re
import sys import sys
import time import time
import urllib.parse
from pathlib import Path from pathlib import Path
import json
import click import click
from rich.console import Console from rich.console import Console
@@ -50,6 +51,7 @@ from .serialization import tweets_from_json, tweets_to_json, user_profile_to_dic
console = Console(stderr=True) console = Console(stderr=True)
FEED_TYPES = ["for-you", "following"] FEED_TYPES = ["for-you", "following"]
SEARCH_PRODUCTS = ["Top", "Latest", "Photos", "Videos"]
def _setup_logging(verbose): def _setup_logging(verbose):
@@ -90,6 +92,20 @@ def _get_client(config=None):
) )
def _exit_with_error(exc):
# type: (RuntimeError) -> None
console.print("[red]❌ %s[/red]" % exc)
sys.exit(1)
def _run_guarded(action):
# type: (Callable[[], Any]) -> Any
try:
return action()
except RuntimeError as exc:
_exit_with_error(exc)
def _resolve_fetch_count(max_count, configured): def _resolve_fetch_count(max_count, configured):
# type: (Optional[int], int) -> int # type: (Optional[int], int) -> int
"""Resolve fetch count with bounds checks.""" """Resolve fetch count with bounds checks."""
@@ -100,6 +116,35 @@ def _resolve_fetch_count(max_count, configured):
return max(configured, 1) return max(configured, 1)
def _resolve_configured_count(config, max_count):
# type: (dict, Optional[int]) -> int
return _resolve_fetch_count(max_count, config.get("fetch", {}).get("count", 50))
def _normalize_tweet_id(value):
# type: (str) -> str
"""Extract a numeric tweet ID from raw input or a full X/Twitter URL."""
raw = value.strip()
if not raw:
raise RuntimeError("Tweet ID or URL is required")
parsed = urllib.parse.urlparse(raw)
candidate = raw
if parsed.scheme and parsed.netloc:
path = parsed.path.rstrip("/")
match = re.search(r"/status/(\d+)$", path)
if not match:
raise RuntimeError("Invalid tweet URL: %s" % value)
candidate = match.group(1)
else:
candidate = raw.rstrip("/").split("/")[-1]
candidate = candidate.split("?", 1)[0].split("#", 1)[0]
if not candidate.isdigit():
raise RuntimeError("Invalid tweet ID: %s" % value)
return candidate
def _apply_filter(tweets, do_filter, config): def _apply_filter(tweets, do_filter, config):
# type: (List[Tweet], bool, dict) -> List[Tweet] # type: (List[Tweet], bool, dict) -> List[Tweet]
"""Optionally apply tweet filtering.""" """Optionally apply tweet filtering."""
@@ -128,15 +173,14 @@ def _fetch_and_display(fetch_fn, label, emoji, max_count, as_json, output_file,
if config is None: if config is None:
config = load_config() config = load_config()
try: try:
fetch_count = _resolve_fetch_count(max_count, config.get("fetch", {}).get("count", 50)) fetch_count = _resolve_configured_count(config, max_count)
console.print("%s Fetching %s (%d tweets)...\n" % (emoji, label, fetch_count)) console.print("%s Fetching %s (%d tweets)...\n" % (emoji, label, fetch_count))
start = time.time() start = time.time()
tweets = fetch_fn(fetch_count) tweets = fetch_fn(fetch_count)
elapsed = time.time() - start elapsed = time.time() - start
console.print("✅ Fetched %d %s in %.1fs\n" % (len(tweets), label, elapsed)) console.print("✅ Fetched %d %s in %.1fs\n" % (len(tweets), label, elapsed))
except RuntimeError as exc: except RuntimeError as exc:
console.print("[red]❌ %s[/red]" % exc) _exit_with_error(exc)
sys.exit(1)
filtered = _apply_filter(tweets, do_filter, config) filtered = _apply_filter(tweets, do_filter, config)
@@ -176,7 +220,7 @@ def feed(feed_type, max_count, as_json, input_file, output_file, do_filter):
tweets = _load_tweets_from_json(input_file) tweets = _load_tweets_from_json(input_file)
console.print(" Loaded %d tweets" % len(tweets)) console.print(" Loaded %d tweets" % len(tweets))
else: else:
fetch_count = _resolve_fetch_count(max_count, config.get("fetch", {}).get("count", 50)) fetch_count = _resolve_configured_count(config, max_count)
client = _get_client(config) client = _get_client(config)
label = "following feed" if feed_type == "following" else "home timeline" label = "following feed" if feed_type == "following" else "home timeline"
console.print("📡 Fetching %s (%d tweets)...\n" % (label, fetch_count)) console.print("📡 Fetching %s (%d tweets)...\n" % (label, fetch_count))
@@ -188,8 +232,7 @@ def feed(feed_type, max_count, as_json, input_file, output_file, do_filter):
elapsed = time.time() - start elapsed = time.time() - start
console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed)) console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed))
except RuntimeError as exc: except RuntimeError as exc:
console.print("[red]❌ %s[/red]" % exc) _exit_with_error(exc)
sys.exit(1)
filtered = _apply_filter(tweets, do_filter, config) filtered = _apply_filter(tweets, do_filter, config)
@@ -216,11 +259,24 @@ def favorites(max_count, as_json, output_file, do_filter):
# type: (Optional[int], bool, Optional[str], bool) -> None # type: (Optional[int], bool, Optional[str], bool) -> None
"""Fetch bookmarked (favorite) tweets.""" """Fetch bookmarked (favorite) tweets."""
config = load_config() config = load_config()
def _run():
client = _get_client(config) client = _get_client(config)
_fetch_and_display( _fetch_and_display(
lambda count: client.fetch_bookmarks(count), lambda count: client.fetch_bookmarks(count),
"favorites", "🔖", max_count, as_json, output_file, do_filter, config, "bookmarks", "🔖", max_count, as_json, output_file, do_filter, config,
) )
_run_guarded(_run)
@cli.command(name="bookmarks")
@click.option("--max", "-n", "max_count", type=int, default=None, help="Max number of tweets to fetch.")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
@click.option("--output", "-o", "output_file", type=str, default=None, help="Save tweets to JSON file.")
@click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.")
def bookmarks(max_count, as_json, output_file, do_filter):
# type: (Optional[int], bool, Optional[str], bool) -> None
"""Fetch bookmarked tweets."""
favorites.callback(max_count=max_count, as_json=as_json, output_file=output_file, do_filter=do_filter)
@cli.command() @cli.command()
@@ -236,8 +292,7 @@ def user(screen_name, as_json):
console.print("👤 Fetching user @%s..." % screen_name) console.print("👤 Fetching user @%s..." % screen_name)
profile = client.fetch_user(screen_name) profile = client.fetch_user(screen_name)
except RuntimeError as exc: except RuntimeError as exc:
console.print("[red]❌ %s[/red]" % exc) _exit_with_error(exc)
sys.exit(1)
if as_json: if as_json:
click.echo(json.dumps(user_profile_to_dict(profile), ensure_ascii=False, indent=2)) click.echo(json.dumps(user_profile_to_dict(profile), ensure_ascii=False, indent=2))
@@ -248,7 +303,7 @@ def user(screen_name, as_json):
@cli.command("user-posts") @cli.command("user-posts")
@click.argument("screen_name") @click.argument("screen_name")
@click.option("--max", "-n", "max_count", type=int, default=20, help="Max number of tweets to fetch.") @click.option("--max", "-n", "max_count", type=int, default=None, help="Max number of tweets to fetch.")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.") @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
@click.option("--output", "-o", "output_file", type=str, default=None, help="Save tweets to JSON file.") @click.option("--output", "-o", "output_file", type=str, default=None, help="Save tweets to JSON file.")
def user_posts(screen_name, max_count, as_json, output_file): def user_posts(screen_name, max_count, as_json, output_file):
@@ -256,20 +311,15 @@ def user_posts(screen_name, max_count, as_json, output_file):
"""List a user's tweets. SCREEN_NAME is the @handle (without @).""" """List a user's tweets. SCREEN_NAME is the @handle (without @)."""
screen_name = screen_name.lstrip("@") screen_name = screen_name.lstrip("@")
config = load_config() config = load_config()
def _run():
client = _get_client(config) client = _get_client(config)
console.print("👤 Fetching @%s's profile..." % screen_name) console.print("👤 Fetching @%s's profile..." % screen_name)
try:
profile = client.fetch_user(screen_name) profile = client.fetch_user(screen_name)
except RuntimeError as exc:
console.print("[red]❌ %s[/red]" % exc)
sys.exit(1)
_fetch_and_display( _fetch_and_display(
lambda count: client.fetch_user_tweets(profile.id, count), lambda count: client.fetch_user_tweets(profile.id, count),
"@%s tweets" % screen_name, "📝", max_count, as_json, output_file, False, config, "@%s tweets" % screen_name, "📝", max_count, as_json, output_file, False, config,
) )
_run_guarded(_run)
SEARCH_PRODUCTS = ["Top", "Latest", "Photos", "Videos"]
@cli.command() @cli.command()
@@ -282,7 +332,7 @@ SEARCH_PRODUCTS = ["Top", "Latest", "Photos", "Videos"]
default="Top", default="Top",
help="Search tab: Top, Latest, Photos, or Videos.", help="Search tab: Top, Latest, Photos, or Videos.",
) )
@click.option("--max", "-n", "max_count", type=int, default=20, help="Max number of tweets to fetch.") @click.option("--max", "-n", "max_count", type=int, default=None, help="Max number of tweets to fetch.")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.") @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
@click.option("--output", "-o", "output_file", type=str, default=None, help="Save tweets to JSON file.") @click.option("--output", "-o", "output_file", type=str, default=None, help="Save tweets to JSON file.")
@click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.") @click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.")
@@ -290,16 +340,18 @@ def search(query, product, max_count, as_json, output_file, do_filter):
# type: (str, str, int, bool, Optional[str], bool) -> None # type: (str, str, int, bool, Optional[str], bool) -> None
"""Search tweets by QUERY string.""" """Search tweets by QUERY string."""
config = load_config() config = load_config()
def _run():
client = _get_client(config) client = _get_client(config)
_fetch_and_display( _fetch_and_display(
lambda count: client.fetch_search(query, count, product), lambda count: client.fetch_search(query, count, product),
"'%s' (%s)" % (query, product), "🔍", max_count, as_json, output_file, do_filter, config, "'%s' (%s)" % (query, product), "🔍", max_count, as_json, output_file, do_filter, config,
) )
_run_guarded(_run)
@cli.command() @cli.command()
@click.argument("screen_name") @click.argument("screen_name")
@click.option("--max", "-n", "max_count", type=int, default=20, help="Max number of tweets to fetch.") @click.option("--max", "-n", "max_count", type=int, default=None, help="Max number of tweets to fetch.")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.") @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
@click.option("--output", "-o", "output_file", type=str, default=None, help="Save tweets to JSON file.") @click.option("--output", "-o", "output_file", type=str, default=None, help="Save tweets to JSON file.")
@click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.") @click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.")
@@ -308,39 +360,35 @@ def likes(screen_name, max_count, as_json, output_file, do_filter):
"""Show tweets liked by a user. SCREEN_NAME is the @handle (without @).""" """Show tweets liked by a user. SCREEN_NAME is the @handle (without @)."""
screen_name = screen_name.lstrip("@") screen_name = screen_name.lstrip("@")
config = load_config() config = load_config()
def _run():
client = _get_client(config) client = _get_client(config)
console.print("👤 Fetching @%s's profile..." % screen_name) console.print("👤 Fetching @%s's profile..." % screen_name)
try:
profile = client.fetch_user(screen_name) profile = client.fetch_user(screen_name)
except RuntimeError as exc:
console.print("[red]❌ %s[/red]" % exc)
sys.exit(1)
_fetch_and_display( _fetch_and_display(
lambda count: client.fetch_user_likes(profile.id, count), lambda count: client.fetch_user_likes(profile.id, count),
"@%s likes" % screen_name, "❤️", max_count, as_json, output_file, do_filter, config, "@%s likes" % screen_name, "❤️", max_count, as_json, output_file, do_filter, config,
) )
_run_guarded(_run)
@cli.command() @cli.command()
@click.argument("tweet_id") @click.argument("tweet_id")
@click.option("--max", "-n", "max_count", type=int, default=20, help="Max replies to fetch.") @click.option("--max", "-n", "max_count", type=int, default=None, help="Max replies to fetch.")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.") @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
def tweet(tweet_id, max_count, as_json): def tweet(tweet_id, max_count, as_json):
# type: (str, int, bool) -> None # type: (str, int, bool) -> None
"""View a tweet and its replies. TWEET_ID is the numeric tweet ID or full URL.""" """View a tweet and its replies. TWEET_ID is the numeric tweet ID or full URL."""
# Extract tweet ID from URL if given tweet_id = _normalize_tweet_id(tweet_id)
tweet_id = tweet_id.strip().rstrip("/").split("/")[-1]
config = load_config() config = load_config()
try: try:
client = _get_client(config) client = _get_client(config)
console.print("🐦 Fetching tweet %s...\n" % tweet_id) console.print("🐦 Fetching tweet %s...\n" % tweet_id)
start = time.time() start = time.time()
tweets = client.fetch_tweet_detail(tweet_id, max_count) tweets = client.fetch_tweet_detail(tweet_id, _resolve_configured_count(config, max_count))
elapsed = time.time() - start elapsed = time.time() - start
console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed)) console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed))
except RuntimeError as exc: except RuntimeError as exc:
console.print("[red]❌ %s[/red]" % exc) _exit_with_error(exc)
sys.exit(1)
if as_json: if as_json:
click.echo(tweets_to_json(tweets)) click.echo(tweets_to_json(tweets))
@@ -356,23 +404,25 @@ def tweet(tweet_id, max_count, as_json):
@cli.command(name="list") @cli.command(name="list")
@click.argument("list_id") @click.argument("list_id")
@click.option("--max", "-n", "max_count", type=int, default=20, help="Max tweets to fetch.") @click.option("--max", "-n", "max_count", type=int, default=None, help="Max tweets to fetch.")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.") @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
@click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.") @click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.")
def list_timeline(list_id, max_count, as_json, do_filter): def list_timeline(list_id, max_count, as_json, do_filter):
# type: (str, int, bool, bool) -> None # type: (str, int, bool, bool) -> None
"""Fetch tweets from a Twitter List. LIST_ID is the numeric list ID.""" """Fetch tweets from a Twitter List. LIST_ID is the numeric list ID."""
config = load_config() config = load_config()
def _run():
client = _get_client(config) client = _get_client(config)
_fetch_and_display( _fetch_and_display(
lambda count: client.fetch_list_timeline(list_id, count), lambda count: client.fetch_list_timeline(list_id, count),
"list %s" % list_id, "📋", max_count, as_json, None, do_filter, config, "list %s" % list_id, "📋", max_count, as_json, None, do_filter, config,
) )
_run_guarded(_run)
@cli.command() @cli.command()
@click.argument("screen_name") @click.argument("screen_name")
@click.option("--max", "-n", "max_count", type=int, default=20, help="Max users to fetch.") @click.option("--max", "-n", "max_count", type=int, default=None, help="Max users to fetch.")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.") @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
def followers(screen_name, max_count, as_json): def followers(screen_name, max_count, as_json):
# type: (str, int, bool) -> None # type: (str, int, bool) -> None
@@ -383,14 +433,14 @@ def followers(screen_name, max_count, as_json):
client = _get_client(config) client = _get_client(config)
console.print("👤 Fetching @%s's profile..." % screen_name) console.print("👤 Fetching @%s's profile..." % screen_name)
profile = client.fetch_user(screen_name) profile = client.fetch_user(screen_name)
console.print("👥 Fetching followers (%d)...\n" % max_count) fetch_count = _resolve_configured_count(config, max_count)
console.print("👥 Fetching followers (%d)...\n" % fetch_count)
start = time.time() start = time.time()
users = client.fetch_followers(profile.id, max_count) users = client.fetch_followers(profile.id, fetch_count)
elapsed = time.time() - start elapsed = time.time() - start
console.print("✅ Fetched %d followers in %.1fs\n" % (len(users), elapsed)) console.print("✅ Fetched %d followers in %.1fs\n" % (len(users), elapsed))
except RuntimeError as exc: except RuntimeError as exc:
console.print("[red]❌ %s[/red]" % exc) _exit_with_error(exc)
sys.exit(1)
if as_json: if as_json:
click.echo(users_to_json(users)) click.echo(users_to_json(users))
@@ -402,7 +452,7 @@ def followers(screen_name, max_count, as_json):
@cli.command() @cli.command()
@click.argument("screen_name") @click.argument("screen_name")
@click.option("--max", "-n", "max_count", type=int, default=20, help="Max users to fetch.") @click.option("--max", "-n", "max_count", type=int, default=None, help="Max users to fetch.")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.") @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
def following(screen_name, max_count, as_json): def following(screen_name, max_count, as_json):
# type: (str, int, bool) -> None # type: (str, int, bool) -> None
@@ -413,14 +463,14 @@ def following(screen_name, max_count, as_json):
client = _get_client(config) client = _get_client(config)
console.print("👤 Fetching @%s's profile..." % screen_name) console.print("👤 Fetching @%s's profile..." % screen_name)
profile = client.fetch_user(screen_name) profile = client.fetch_user(screen_name)
console.print("👥 Fetching following (%d)...\n" % max_count) fetch_count = _resolve_configured_count(config, max_count)
console.print("👥 Fetching following (%d)...\n" % fetch_count)
start = time.time() start = time.time()
users = client.fetch_following(profile.id, max_count) users = client.fetch_following(profile.id, fetch_count)
elapsed = time.time() - start elapsed = time.time() - start
console.print("✅ Fetched %d following in %.1fs\n" % (len(users), elapsed)) console.print("✅ Fetched %d following in %.1fs\n" % (len(users), elapsed))
except RuntimeError as exc: except RuntimeError as exc:
console.print("[red]❌ %s[/red]" % exc) _exit_with_error(exc)
sys.exit(1)
if as_json: if as_json:
click.echo(users_to_json(users)) click.echo(users_to_json(users))
@@ -442,8 +492,7 @@ def _write_action(emoji, action_desc, client_method, tweet_id):
getattr(client, client_method)(tweet_id) getattr(client, client_method)(tweet_id)
console.print("[green]✅ Done.[/green]") console.print("[green]✅ Done.[/green]")
except RuntimeError as exc: except RuntimeError as exc:
console.print("[red]❌ %s[/red]" % exc) _exit_with_error(exc)
sys.exit(1)
@cli.command() @cli.command()
@@ -461,8 +510,7 @@ def post(text, reply_to):
console.print("[green]✅ Tweet posted![/green]") console.print("[green]✅ Tweet posted![/green]")
console.print("🔗 https://x.com/i/status/%s" % tweet_id) console.print("🔗 https://x.com/i/status/%s" % tweet_id)
except RuntimeError as exc: except RuntimeError as exc:
console.print("[red]❌ %s[/red]" % exc) _exit_with_error(exc)
sys.exit(1)
@cli.command(name="delete") @cli.command(name="delete")
@@ -514,6 +562,14 @@ def favorite(tweet_id):
_write_action("🔖", "Bookmarking tweet", "bookmark_tweet", tweet_id) _write_action("🔖", "Bookmarking tweet", "bookmark_tweet", tweet_id)
@cli.command()
@click.argument("tweet_id")
def bookmark(tweet_id):
# type: (str,) -> None
"""Bookmark a tweet. TWEET_ID is the numeric tweet ID."""
_write_action("🔖", "Bookmarking tweet", "bookmark_tweet", tweet_id)
@cli.command() @cli.command()
@click.argument("tweet_id") @click.argument("tweet_id")
def unfavorite(tweet_id): def unfavorite(tweet_id):
@@ -522,5 +578,13 @@ def unfavorite(tweet_id):
_write_action("🔖", "Removing bookmark", "unbookmark_tweet", tweet_id) _write_action("🔖", "Removing bookmark", "unbookmark_tweet", tweet_id)
@cli.command()
@click.argument("tweet_id")
def unbookmark(tweet_id):
# type: (str,) -> None
"""Remove a tweet from bookmarks. TWEET_ID is the numeric tweet ID."""
_write_action("🔖", "Removing bookmark", "unbookmark_tweet", tweet_id)
if __name__ == "__main__": if __name__ == "__main__":
cli() cli()