Merge pull request #18 from Pleasurecruise/main

feat: add `show` command with short-index cache
This commit is contained in:
jakevin
2026-03-14 04:06:47 +08:00
committed by GitHub
7 changed files with 241 additions and 3 deletions

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ build/
*.json
!tests/fixtures/*.json
!config.yaml
.idea/

View File

@@ -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 <N>` 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>` 可直接打开上次列表里的第 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

View File

@@ -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

View File

@@ -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 <N> 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

68
twitter_cli/cache.py Normal file
View File

@@ -0,0 +1,68 @@
"""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 not isinstance(payload, dict):
return None
if time.time() - payload.get("created_at", 0) > _TTL:
return None
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
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:
tweet_id = entry.get("id")
return str(tweet_id) if tweet_id is not None else None
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

View File

@@ -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 <N>` 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 <N>` to view tweet #N from this list.[/dim]")
console.print()
@@ -699,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
@@ -714,6 +725,46 @@ def tweet(ctx, tweet_id, max_count, full_text, as_json, as_yaml):
console.print()
@cli.command()
@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
@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`, `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)
)
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)
_emit_tweet_detail(tweets, compact=compact, as_json=as_json, as_yaml=as_yaml, full_text=full_text)
@cli.command()
@click.argument("tweet_id")
@structured_output_options

2
uv.lock generated
View File

@@ -609,7 +609,7 @@ wheels = [
[[package]]
name = "twitter-cli"
version = "0.7.1"
version = "0.8.0"
source = { editable = "." }
dependencies = [
{ name = "beautifulsoup4" },