From 5335516d5709623302a4f55b5862a9c7ab19c3ba Mon Sep 17 00:00:00 2001 From: Pleasurecruise <3196812536@qq.com> Date: Thu, 12 Mar 2026 22:12:27 +0000 Subject: [PATCH 1/2] feat: add `show` command with short-index cache Persist last displayed tweet lists and allow opening a tweet by index. - Add twitter_cli/cache.py: stores a short-index cache (~/.twitter-cli/last_results.json) with a 1h TTL and helpers to resolve index->tweet-id and cache size. - Update twitter_cli/cli.py: save list results to cache, display a hint, and add `twitter show ` command which fetches a tweet by cached index and prints detail/replies (supports --full-text, --json, structured output, and max replies). - Update README.md and SKILL.md to document the new `show` usage. - Add .idea/ to .gitignore and bump package version in uv.lock to 0.8.0. This change makes it easy to open items from the last feed/search without copying IDs. --- .gitignore | 1 + README.md | 14 ++++++++-- SKILL.md | 3 +++ twitter_cli/cache.py | 62 ++++++++++++++++++++++++++++++++++++++++++++ twitter_cli/cli.py | 56 +++++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 6 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 twitter_cli/cache.py diff --git a/.gitignore b/.gitignore index 5c3c9b2..a78b3ea 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build/ *.json !tests/fixtures/*.json !config.yaml +.idea/ diff --git a/README.md b/README.md index 3da4991..f472abc 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ A terminal-first CLI for Twitter/X: read timelines, bookmarks, and user profiles - Timeline: fetch `for-you` and `following` feeds - Bookmarks: list saved tweets from your account - Search: find tweets by keyword with Top/Latest/Photos/Videos tabs -- Tweet detail: view a tweet and its replies +- Tweet detail: view a tweet and its replies; use `show ` to open tweet #N from the last list output - Article: fetch a Twitter Article and export it as Markdown - List timeline: fetch tweets from a Twitter List - User lookup: fetch user profile, tweets, likes, followers, and following @@ -122,6 +122,11 @@ twitter tweet 1234567890 twitter tweet 1234567890 --full-text twitter tweet https://x.com/user/status/1234567890 +# Open tweet by index from last list output +twitter show 2 # Open tweet #2 from last feed/search +twitter show 2 --full-text # Full text in reply table +twitter show 2 --json # Structured output + # Twitter Article twitter article 1234567890 twitter article https://x.com/user/article/1234567890 --json @@ -358,7 +363,7 @@ After installation, OpenClaw can call `twitter-cli` commands directly. - 时间线读取:支持 `for-you` 和 `following` - 收藏读取:查看账号书签推文 - 搜索:按关键词搜索推文,支持 Top/Latest/Photos/Videos -- 推文详情:查看推文及其回复 +- 推文详情:查看推文及其回复;用 `show ` 可直接打开上次列表里的第 N 条推文 - 文章读取:获取 Twitter 长文,并导出为 Markdown - 列表时间线:获取 Twitter List 的推文 - 用户查询:查看用户资料、推文、点赞、粉丝和关注 @@ -425,6 +430,11 @@ twitter search "trending" --filter # 启用排序筛选 twitter tweet 1234567890 twitter tweet 1234567890 --full-text +# 通过序号打开上次列表里的推文 +twitter show 2 # 打开上次 feed/search 的第 2 条 +twitter show 2 --full-text # 在回复表格里显示完整正文 +twitter show 2 --json # 结构化输出 + # Twitter 长文 twitter article 1234567890 twitter article https://x.com/user/article/1234567890 --json diff --git a/SKILL.md b/SKILL.md index 181260b..b984fb7 100644 --- a/SKILL.md +++ b/SKILL.md @@ -165,6 +165,9 @@ twitter search "topic" -o results.json # Save to file twitter tweet 1234567890 # Tweet detail + replies twitter tweet 1234567890 --full-text # Full text in reply table twitter tweet https://x.com/user/status/12345 # Accepts URL +twitter show 2 # Open tweet #2 from last feed/search list +twitter show 2 --full-text # Full text in reply table +twitter show 2 --json # Structured output twitter list 1539453138322673664 # List timeline twitter list 1539453138322673664 --full-text twitter user-posts elonmusk --max 20 # User's tweets diff --git a/twitter_cli/cache.py b/twitter_cli/cache.py new file mode 100644 index 0000000..e232d0f --- /dev/null +++ b/twitter_cli/cache.py @@ -0,0 +1,62 @@ +"""Short-index cache: persist the last displayed tweet list for quick `show` access.""" + +from __future__ import annotations + +import json +import logging +import time +from pathlib import Path +from typing import List, Optional + +from .models import Tweet + +logger = logging.getLogger(__name__) + +_CACHE_DIR = Path.home() / ".twitter-cli" +_CACHE_FILE = _CACHE_DIR / "last_results.json" +_TTL = 3600 # seconds + + +def save_tweet_cache(tweets: List[Tweet]) -> None: + """Persist tweet list so indices can be resolved by `show`.""" + try: + _CACHE_DIR.mkdir(parents=True, exist_ok=True) + entries = [ + {"index": i + 1, "id": t.id, "author": t.author.screen_name, "text": t.text[:80]} + for i, t in enumerate(tweets) + if t.id + ] + payload = {"created_at": time.time(), "tweets": entries} + _CACHE_FILE.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + except OSError as exc: + logger.debug("Failed to write tweet cache: %s", exc) + + +def _load_cache() -> Optional[List[dict]]: + """Load and validate the cache file; return tweet entries or None if stale/missing.""" + try: + if not _CACHE_FILE.exists(): + return None + payload = json.loads(_CACHE_FILE.read_text(encoding="utf-8")) + if time.time() - payload.get("created_at", 0) > _TTL: + return None + return payload.get("tweets", []) + except (OSError, json.JSONDecodeError): + return None + + +def get_tweet_id_by_index(index: int) -> Optional[str]: + """Return tweet ID at *index* (1-based), or None if not found.""" + entries = _load_cache() + if entries is None: + return None + for entry in entries: + if entry.get("index") == index: + return str(entry["id"]) + return None + + +def get_cache_size() -> int: + """Return number of tweets in the current cache (0 if unavailable).""" + entries = _load_cache() + return len(entries) if entries is not None else 0 \ No newline at end of file diff --git a/twitter_cli/cli.py b/twitter_cli/cli.py index e5d08ba..70e62e7 100644 --- a/twitter_cli/cli.py +++ b/twitter_cli/cli.py @@ -45,6 +45,7 @@ from rich.console import Console from . import __version__ from .auth import get_cookies +from .cache import get_cache_size, get_tweet_id_by_index, save_tweet_cache from .client import TwitterClient from .config import load_config from .filter import filter_tweets @@ -351,12 +352,14 @@ def _fetch_and_display(fetch_fn, label, emoji, max_count, as_json, as_yaml, outp if emit_structured(tweets_to_data(filtered), as_json=as_json, as_yaml=as_yaml): return + save_tweet_cache(filtered) print_tweet_table( filtered, console, title="%s %s — %d tweets" % (emoji, label, len(filtered)), full_text=full_text, ) + console.print("[dim]💡 Use `twitter show ` to view tweet #N from this list.[/dim]") console.print() @@ -445,7 +448,9 @@ def feed(ctx, feed_type, max_count, as_json, as_yaml, input_file, output_file, d title = "👥 Following" if feed_type == "following" else "📱 Twitter" title += " — %d tweets" % len(filtered) + save_tweet_cache(filtered) print_tweet_table(filtered, console, title=title, full_text=full_text) + console.print("[dim]💡 Use `twitter show ` to view tweet #N from this list.[/dim]") console.print() @@ -714,6 +719,57 @@ def tweet(ctx, tweet_id, max_count, full_text, as_json, as_yaml): console.print() +@cli.command() +@click.argument("index", type=int) +@click.option("--max", "-n", "max_count", type=int, default=None, help="Max replies to fetch.") +@click.option("--full-text", is_flag=True, help="Show full reply text in table output.") +@structured_output_options +@click.pass_context +def show(ctx, index, max_count, full_text, as_json, as_yaml): + # type: (Any, int, Optional[int], bool, bool, bool) -> None + """View tweet #INDEX from the last feed/search results.""" + compact = ctx.obj.get("compact", False) + + tweet_id = get_tweet_id_by_index(index) + if tweet_id is None: + cache_size = get_cache_size() + if cache_size == 0: + raise click.UsageError( + "No cached results found. Run `twitter feed` or `twitter search` first." + ) + raise click.UsageError( + "Index %d is out of range (cache has %d tweets)." % (index, cache_size) + ) + + config = load_config() + rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml, compact=compact) + try: + client = _get_client_for_output(config, quiet=not rich_output) + if rich_output: + console.print("🐦 Fetching tweet #%d (id: %s)...\n" % (index, tweet_id)) + start = time.time() + tweets = client.fetch_tweet_detail(tweet_id, _resolve_configured_count(config, max_count)) + elapsed = time.time() - start + if rich_output: + console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed)) + except RuntimeError as exc: + _exit_with_error(exc) + + if compact: + click.echo(tweets_to_compact_json(tweets)) + return + + if emit_structured(tweets_to_data(tweets), as_json=as_json, as_yaml=as_yaml): + return + + if tweets: + print_tweet_detail(tweets[0], console) + if len(tweets) > 1: + console.print("\n💬 Replies:") + print_tweet_table(tweets[1:], console, title="💬 Replies — %d" % (len(tweets) - 1), full_text=full_text) + console.print() + + @cli.command() @click.argument("tweet_id") @structured_output_options diff --git a/uv.lock b/uv.lock index df020d6..be2d881 100644 --- a/uv.lock +++ b/uv.lock @@ -609,7 +609,7 @@ wheels = [ [[package]] name = "twitter-cli" -version = "0.7.1" +version = "0.8.0" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 72f62cedea5ba7e50ffb2188eb2da54996a6704c Mon Sep 17 00:00:00 2001 From: Pleasurecruise <3196812536@qq.com> Date: Thu, 12 Mar 2026 22:26:52 +0000 Subject: [PATCH 2/2] fix: improve show handling, cache validation and tests Add extensive tests for the `show` command (happy path, empty/expired/malformed cache, out-of-range, zero/negative indices) and a test helper to write cache fixtures. Harden twitter_cli.cache._load_cache to validate payload types (dict/tweets list), filter non-dict entries, and treat malformed payloads as empty; also handle missing tweet id when resolving an index. Refactor CLI output logic by extracting _emit_tweet_detail and reuse it for both `tweet` and `show`; enforce 1-based indices for `show` via click.IntRange(1) and expand the "no cached results" error text to mention other list commands. These changes improve robustness against corrupted caches and increase test coverage for cache-based behavior. --- tests/test_cli.py | 105 +++++++++++++++++++++++++++++++++++++++++++ twitter_cli/cache.py | 10 ++++- twitter_cli/cli.py | 25 +++++------ 3 files changed, 123 insertions(+), 17 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 491419f..0d535ce 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,8 @@ from __future__ import annotations +import json +import time + from click.testing import CliRunner import pytest from rich.console import Console @@ -538,3 +541,105 @@ def test_cli_compact_mode(tmp_path, tweet_factory) -> None: assert '"@alice"' in result.output # Compact output should NOT have full metrics keys assert '"metrics"' not in result.output + + +def _write_cache(cache_file, tweets, created_at=None): + """Write a test cache file.""" + if created_at is None: + created_at = time.time() + entries = [ + {"index": i + 1, "id": t.id, "author": t.author.screen_name, "text": t.text[:80]} + for i, t in enumerate(tweets) + ] + payload = {"created_at": created_at, "tweets": entries} + cache_file.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + + +def test_show_happy_path(monkeypatch, tmp_path, tweet_factory): + """show resolves cached index and fetches tweet detail.""" + tw = tweet_factory("42", text="hello world") + cache_file = tmp_path / "last_results.json" + _write_cache(cache_file, [tweet_factory("10"), tw]) # tw is index 2 + + monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file) + + class FakeClient: + def fetch_tweet_detail(self, tweet_id, count): + assert tweet_id == "42" + return [tw] + + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient()) + monkeypatch.setattr("twitter_cli.cli.load_config", lambda: {}) + + runner = CliRunner() + result = runner.invoke(cli, ["show", "2"]) + assert result.exit_code == 0 + + +def test_show_empty_cache(monkeypatch, tmp_path): + """show fails with a helpful message when no cache exists.""" + cache_file = tmp_path / "last_results.json" + monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file) + + runner = CliRunner() + result = runner.invoke(cli, ["show", "1"]) + assert result.exit_code != 0 + assert "No cached results" in result.output + + +def test_show_out_of_range(monkeypatch, tmp_path, tweet_factory): + """show fails with out-of-range message when index exceeds cache size.""" + cache_file = tmp_path / "last_results.json" + _write_cache(cache_file, [tweet_factory("1")]) + monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file) + + runner = CliRunner() + result = runner.invoke(cli, ["show", "99"]) + assert result.exit_code != 0 + assert "out of range" in result.output + assert "1" in result.output # cache has 1 tweet + + +def test_show_expired_cache(monkeypatch, tmp_path, tweet_factory): + """show treats an expired cache the same as no cache.""" + cache_file = tmp_path / "last_results.json" + expired_time = time.time() - 7200 # 2 hours ago + _write_cache(cache_file, [tweet_factory("1")], created_at=expired_time) + monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file) + + runner = CliRunner() + result = runner.invoke(cli, ["show", "1"]) + assert result.exit_code != 0 + assert "No cached results" in result.output + + +def test_show_rejects_zero_index(monkeypatch, tmp_path): + """show rejects index=0 because indices are 1-based.""" + cache_file = tmp_path / "last_results.json" + monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file) + + runner = CliRunner() + result = runner.invoke(cli, ["show", "0"]) + assert result.exit_code != 0 + + +def test_show_rejects_negative_index(monkeypatch, tmp_path): + """show rejects negative indices.""" + cache_file = tmp_path / "last_results.json" + monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file) + + runner = CliRunner() + result = runner.invoke(cli, ["show", "-1"]) + assert result.exit_code != 0 + + +def test_show_malformed_cache_treated_as_empty(monkeypatch, tmp_path): + """show handles a corrupted cache file gracefully.""" + cache_file = tmp_path / "last_results.json" + cache_file.write_text("not valid json{{}", encoding="utf-8") + monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file) + + runner = CliRunner() + result = runner.invoke(cli, ["show", "1"]) + assert result.exit_code != 0 + assert "No cached results" in result.output diff --git a/twitter_cli/cache.py b/twitter_cli/cache.py index e232d0f..29c7962 100644 --- a/twitter_cli/cache.py +++ b/twitter_cli/cache.py @@ -38,9 +38,14 @@ def _load_cache() -> Optional[List[dict]]: if not _CACHE_FILE.exists(): return None payload = json.loads(_CACHE_FILE.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + return None if time.time() - payload.get("created_at", 0) > _TTL: return None - return payload.get("tweets", []) + entries = payload.get("tweets", []) + if not isinstance(entries, list): + return None + return [e for e in entries if isinstance(e, dict)] except (OSError, json.JSONDecodeError): return None @@ -52,7 +57,8 @@ def get_tweet_id_by_index(index: int) -> Optional[str]: return None for entry in entries: if entry.get("index") == index: - return str(entry["id"]) + tweet_id = entry.get("id") + return str(tweet_id) if tweet_id is not None else None return None diff --git a/twitter_cli/cli.py b/twitter_cli/cli.py index 70e62e7..4084d09 100644 --- a/twitter_cli/cli.py +++ b/twitter_cli/cli.py @@ -704,6 +704,12 @@ def tweet(ctx, tweet_id, max_count, full_text, as_json, as_yaml): except RuntimeError as exc: _exit_with_error(exc) + _emit_tweet_detail(tweets, compact=compact, as_json=as_json, as_yaml=as_yaml, full_text=full_text) + + +def _emit_tweet_detail(tweets, compact, as_json, as_yaml, full_text): + # type: (list, bool, bool, bool, bool) -> None + """Render tweet detail + replies in the requested output format.""" if compact: click.echo(tweets_to_compact_json(tweets)) return @@ -720,7 +726,7 @@ def tweet(ctx, tweet_id, max_count, full_text, as_json, as_yaml): @cli.command() -@click.argument("index", type=int) +@click.argument("index", type=click.IntRange(1)) @click.option("--max", "-n", "max_count", type=int, default=None, help="Max replies to fetch.") @click.option("--full-text", is_flag=True, help="Show full reply text in table output.") @structured_output_options @@ -735,7 +741,8 @@ def show(ctx, index, max_count, full_text, as_json, as_yaml): cache_size = get_cache_size() if cache_size == 0: raise click.UsageError( - "No cached results found. Run `twitter feed` or `twitter search` first." + "No cached results found. Run `twitter feed`, `twitter search`, " + "`twitter bookmarks`, or another list command first." ) raise click.UsageError( "Index %d is out of range (cache has %d tweets)." % (index, cache_size) @@ -755,19 +762,7 @@ def show(ctx, index, max_count, full_text, as_json, as_yaml): except RuntimeError as exc: _exit_with_error(exc) - if compact: - click.echo(tweets_to_compact_json(tweets)) - return - - if emit_structured(tweets_to_data(tweets), as_json=as_json, as_yaml=as_yaml): - return - - if tweets: - print_tweet_detail(tweets[0], console) - if len(tweets) > 1: - console.print("\n💬 Replies:") - print_tweet_table(tweets[1:], console, title="💬 Replies — %d" % (len(tweets) - 1), full_text=full_text) - console.print() + _emit_tweet_detail(tweets, compact=compact, as_json=as_json, as_yaml=as_yaml, full_text=full_text) @cli.command()