diff --git a/README.md b/README.md index 0d29a84..c9b029d 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ A terminal-first CLI for Twitter/X: read timelines, bookmarks, and user profiles - Tweet detail: view a tweet and its replies - List timeline: fetch tweets from a Twitter List - User lookup: fetch user profile, tweets, likes, followers, and following +- `--full-text`: disable tweet text truncation in rich table output - Structured output: export any data as YAML or JSON for scripting and AI agent integration - Optional scoring filter: rank tweets by engagement weights - Structured output contract: [SCHEMA.md](./SCHEMA.md) @@ -94,32 +95,39 @@ twitter feed --filter ```bash # Feed twitter feed --max 50 +twitter feed --full-text twitter feed --json > tweets.json twitter feed --input tweets.json # Bookmarks twitter bookmarks +twitter bookmarks --full-text twitter bookmarks --max 30 --yaml # Search twitter search "Claude Code" twitter search "AI agent" -t Latest --max 50 +twitter search "AI agent" --full-text twitter search "机器学习" --yaml twitter search "topic" -o results.json # Save to file twitter search "trending" --filter # Apply ranking filter # Tweet detail (view tweet + replies) twitter tweet 1234567890 +twitter tweet 1234567890 --full-text twitter tweet https://x.com/user/status/1234567890 # List timeline twitter list 1539453138322673664 +twitter list 1539453138322673664 --full-text # User twitter user elonmusk twitter user-posts elonmusk --max 20 +twitter user-posts elonmusk --full-text twitter user-posts elonmusk -o tweets.json twitter likes elonmusk --max 30 # ⚠️ own likes only (private since Jun 2024) +twitter likes elonmusk --full-text twitter likes elonmusk -o likes.json twitter followers elonmusk --max 50 twitter following elonmusk --max 50 @@ -201,6 +209,7 @@ rateLimit: Fetch behavior: - `fetch.count` is the default item count for read commands when `--max` is omitted +- Rich table output truncates long tweet text by default; use `--full-text` to show full body text in list views Filter behavior: @@ -231,6 +240,13 @@ Mode behavior: - **Use browser cookie extraction** — provides full cookie fingerprint - **Avoid datacenter IPs** — residential proxies are much safer +### Output Modes + +- Use the default rich table for interactive reading +- Use `--full-text` when reading long posts in terminal tables +- Use `--yaml` or `--json` for scripts and agent pipelines +- Use `-c` / `--compact` when token efficiency matters more than completeness + ### Troubleshooting - `No Twitter cookies found` @@ -330,6 +346,7 @@ After installation, OpenClaw can call `twitter-cli` commands directly. - 推文详情:查看推文及其回复 - 列表时间线:获取 Twitter List 的推文 - 用户查询:查看用户资料、推文、点赞、粉丝和关注 +- `--full-text`:在 rich table 输出里关闭推文正文截断 - 结构化输出:支持 YAML 和 JSON,便于脚本处理和 AI agent 集成 > **AI Agent 提示:** 需要结构化输出时优先使用 `--yaml`,除非下游必须是 JSON。stdout 不是 TTY 时默认输出 YAML。用 `--max` 控制返回数量。 @@ -374,27 +391,34 @@ uv tool upgrade twitter-cli twitter feed twitter feed -t following twitter feed --filter +twitter feed --full-text # 收藏 twitter bookmarks +twitter bookmarks --full-text # 搜索 twitter search "Claude Code" twitter search "AI agent" -t Latest --max 50 +twitter search "AI agent" --full-text twitter search "topic" -o results.json # 保存到文件 twitter search "trending" --filter # 启用排序筛选 # 推文详情 twitter tweet 1234567890 +twitter tweet 1234567890 --full-text # 列表时间线 twitter list 1539453138322673664 +twitter list 1539453138322673664 --full-text # 用户 twitter user elonmusk twitter user-posts elonmusk --max 20 +twitter user-posts elonmusk --full-text twitter user-posts elonmusk -o tweets.json twitter likes elonmusk --max 30 # ⚠️ 仅可查看自己的点赞(2024年6月起平台已私密化) +twitter likes elonmusk --full-text twitter likes elonmusk -o likes.json twitter followers elonmusk twitter following elonmusk @@ -445,6 +469,8 @@ export TWITTER_PROXY=socks5://127.0.0.1:1080 未传 `--max` 时,所有读取命令默认使用 `config.yaml` 里的 `fetch.count`。 +rich table 输出默认会截断较长正文;如果需要在列表视图中查看完整正文,可加 `--full-text`。 + 只有在传入 `--filter` 时才会启用筛选评分;默认不筛选。 评分公式: @@ -490,6 +516,13 @@ score = likes_w * likes - **避免数据中心 IP** — 住宅代理更安全 - Cookie 仅在本地使用,不会被本工具上传 +### 输出模式建议 + +- 默认 rich table 适合终端交互式浏览 +- 需要在表格里看完整正文时,使用 `--full-text` +- 需要脚本消费时,优先使用 `--yaml` 或 `--json` +- 需要节省 token 时,使用 `-c` / `--compact` + ### 作为 AI Agent Skill 使用 twitter-cli 提供了 [`SKILL.md`](./SKILL.md),可让 AI Agent 更稳定地调用本工具。 diff --git a/SKILL.md b/SKILL.md index 6e6da21..0678370 100644 --- a/SKILL.md +++ b/SKILL.md @@ -114,6 +114,19 @@ twitter feed --json | jq '.[0].text' All machine-readable output uses the envelope documented in [SCHEMA.md](./SCHEMA.md). Tweet and user payloads now live under `.data`. +### Full text: `--full-text` flag (rich tables only) + +Use `--full-text` when the user wants complete post bodies in terminal tables. +It affects rich table list views such as `feed`, `bookmarks`, `search`, `user-posts`, `likes`, `list`, and reply tables in `tweet`. +It does **not** change `--json`, `--yaml`, or `-c` compact output. + +```bash +twitter feed --full-text +twitter search "AI agent" --full-text +twitter user-posts elonmusk --max 20 --full-text +twitter tweet 1234567890 --full-text +``` + ### Compact: `-c` flag (minimal tokens for LLM) ```bash @@ -138,19 +151,26 @@ twitter user elonmusk --json # JSON output twitter feed # Home timeline (For You) twitter feed -t following # Following timeline twitter feed --max 50 # Limit count +twitter feed --full-text # Show full post body in table twitter feed --filter # Enable ranking filter twitter feed --yaml > tweets.yaml # Export as YAML twitter feed --input tweets.json # Read from local JSON file twitter bookmarks # Bookmarked tweets +twitter bookmarks --full-text # Full text in bookmarks table twitter bookmarks --max 30 --yaml twitter search "keyword" # Search tweets twitter search "AI agent" -t Latest --max 50 +twitter search "AI agent" --full-text # Full text in search results 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 list 1539453138322673664 # List timeline +twitter list 1539453138322673664 --full-text twitter user-posts elonmusk --max 20 # User's tweets +twitter user-posts elonmusk --full-text twitter likes elonmusk --max 30 # User's likes (own only, see note) +twitter likes elonmusk --full-text twitter followers elonmusk --max 50 # Followers twitter following elonmusk --max 50 # Following ``` @@ -241,6 +261,10 @@ twitter followers "$MY_NAME" --max 200 --json | jq -r '.data[].username' | grep twitter -c feed -t following --max 30 twitter -c bookmarks --max 20 +# Rich table with complete post bodies +twitter feed -t following --max 20 --full-text +twitter search "AI agent" --max 20 --full-text + # Full JSON for analysis twitter feed -t following --max 30 -o following.json twitter bookmarks --max 20 -o bookmarks.json diff --git a/tests/test_cli.py b/tests/test_cli.py index f71ea87..1975c43 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,9 +2,11 @@ from __future__ import annotations from click.testing import CliRunner import pytest +from rich.console import Console import yaml from twitter_cli.cli import cli +from twitter_cli.formatter import print_tweet_table from twitter_cli.models import UserProfile from twitter_cli.serialization import tweets_to_json @@ -42,6 +44,27 @@ def test_cli_feed_json_input_path(tmp_path, tweet_factory) -> None: assert '"id": "1"' in result.output +def test_print_tweet_table_truncates_text_by_default(tweet_factory) -> None: + long_text = "A" * 140 + console = Console(record=True, width=400) + + print_tweet_table([tweet_factory("1", text=long_text)], console=console) + + output = console.export_text() + assert ("A" * 117 + "...") in output + + +def test_print_tweet_table_full_text_shows_untruncated_text(tweet_factory) -> None: + long_text = "B" * 140 + console = Console(record=True, width=400) + + print_tweet_table([tweet_factory("1", text=long_text)], console=console, full_text=True) + + output = console.export_text() + assert long_text in output + assert ("B" * 117 + "...") not in output + + @pytest.mark.parametrize( "args", [ diff --git a/twitter_cli/cli.py b/twitter_cli/cli.py index e1371be..ab07d94 100644 --- a/twitter_cli/cli.py +++ b/twitter_cli/cli.py @@ -311,8 +311,8 @@ def cli(ctx, verbose, compact): ctx.obj["compact"] = compact -def _fetch_and_display(fetch_fn, label, emoji, max_count, as_json, as_yaml, output_file, do_filter, config=None, compact=False): - # type: (Any, str, str, Optional[int], bool, bool, Optional[str], bool, Optional[dict], bool) -> None +def _fetch_and_display(fetch_fn, label, emoji, max_count, as_json, as_yaml, output_file, do_filter, config=None, compact=False, full_text=False): + # type: (Any, str, str, Optional[int], bool, bool, Optional[str], bool, Optional[dict], bool, bool) -> None """Common fetch-filter-display logic for timeline-like commands.""" if config is None: config = load_config() @@ -343,12 +343,17 @@ 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 - print_tweet_table(filtered, console, title="%s %s — %d tweets" % (emoji, label, len(filtered))) + print_tweet_table( + filtered, + console, + title="%s %s — %d tweets" % (emoji, label, len(filtered)), + full_text=full_text, + ) console.print() -def _run_bookmarks_command(max_count, as_json, as_yaml, output_file, do_filter, compact=False): - # type: (Optional[int], bool, bool, Optional[str], bool, bool) -> None +def _run_bookmarks_command(max_count, as_json, as_yaml, output_file, do_filter, compact=False, full_text=False): + # type: (Optional[int], bool, bool, Optional[str], bool, bool, bool) -> None config = load_config() def _run(): @@ -364,6 +369,7 @@ def _run_bookmarks_command(max_count, as_json, as_yaml, output_file, do_filter, do_filter, config, compact=compact, + full_text=full_text, ) _run_guarded(_run) @@ -383,9 +389,10 @@ def _run_bookmarks_command(max_count, as_json, as_yaml, output_file, do_filter, @click.option("--input", "-i", "input_file", type=str, default=None, help="Load tweets from JSON file.") @click.option("--output", "-o", "output_file", type=str, default=None, help="Save filtered tweets to JSON file.") @click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.") +@click.option("--full-text", is_flag=True, help="Show full tweet text in table output.") @click.pass_context -def feed(ctx, feed_type, max_count, as_json, as_yaml, input_file, output_file, do_filter): - # type: (Any, str, Optional[int], bool, bool, Optional[str], Optional[str], bool) -> None +def feed(ctx, feed_type, max_count, as_json, as_yaml, input_file, output_file, do_filter, full_text): + # type: (Any, str, Optional[int], bool, bool, Optional[str], Optional[str], bool, bool) -> None """Fetch home timeline with optional filtering.""" compact = ctx.obj.get("compact", False) rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml, compact=compact) @@ -430,7 +437,7 @@ 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) - print_tweet_table(filtered, console, title=title) + print_tweet_table(filtered, console, title=title, full_text=full_text) console.print() @@ -439,11 +446,20 @@ def feed(ctx, feed_type, max_count, as_json, as_yaml, input_file, output_file, d @structured_output_options @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("--full-text", is_flag=True, help="Show full tweet text in table output.") @click.pass_context -def favorites(ctx, max_count, as_json, as_yaml, output_file, do_filter): - # type: (Any, Optional[int], bool, bool, Optional[str], bool) -> None +def favorites(ctx, max_count, as_json, as_yaml, output_file, do_filter, full_text): + # type: (Any, Optional[int], bool, bool, Optional[str], bool, bool) -> None """Fetch bookmarked (favorite) tweets.""" - _run_bookmarks_command(max_count, as_json, as_yaml, output_file, do_filter, compact=ctx.obj.get("compact", False)) + _run_bookmarks_command( + max_count, + as_json, + as_yaml, + output_file, + do_filter, + compact=ctx.obj.get("compact", False), + full_text=full_text, + ) @cli.command(name="bookmarks") @@ -451,11 +467,20 @@ def favorites(ctx, max_count, as_json, as_yaml, output_file, do_filter): @structured_output_options @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("--full-text", is_flag=True, help="Show full tweet text in table output.") @click.pass_context -def bookmarks(ctx, max_count, as_json, as_yaml, output_file, do_filter): - # type: (Any, Optional[int], bool, bool, Optional[str], bool) -> None +def bookmarks(ctx, max_count, as_json, as_yaml, output_file, do_filter, full_text): + # type: (Any, Optional[int], bool, bool, Optional[str], bool, bool) -> None """Fetch bookmarked tweets.""" - _run_bookmarks_command(max_count, as_json, as_yaml, output_file, do_filter, compact=ctx.obj.get("compact", False)) + _run_bookmarks_command( + max_count, + as_json, + as_yaml, + output_file, + do_filter, + compact=ctx.obj.get("compact", False), + full_text=full_text, + ) @cli.command() @@ -485,9 +510,10 @@ def user(screen_name, as_json, as_yaml): @click.option("--max", "-n", "max_count", type=int, default=None, help="Max number of tweets to fetch.") @structured_output_options @click.option("--output", "-o", "output_file", type=str, default=None, help="Save tweets to JSON file.") +@click.option("--full-text", is_flag=True, help="Show full tweet text in table output.") @click.pass_context -def user_posts(ctx, screen_name, max_count, as_json, as_yaml, output_file): - # type: (Any, str, int, bool, bool, Optional[str]) -> None +def user_posts(ctx, screen_name, max_count, as_json, as_yaml, output_file, full_text): + # type: (Any, str, int, bool, bool, Optional[str], bool) -> None """List a user's tweets. SCREEN_NAME is the @handle (without @).""" screen_name = screen_name.lstrip("@") compact = ctx.obj.get("compact", False) @@ -501,7 +527,7 @@ def user_posts(ctx, screen_name, max_count, as_json, as_yaml, output_file): _fetch_and_display( lambda count: client.fetch_user_tweets(profile.id, count), "@%s tweets" % screen_name, "📝", max_count, as_json, as_yaml, output_file, False, config, - compact=compact, + compact=compact, full_text=full_text, ) _run_guarded(_run) @@ -520,9 +546,10 @@ def user_posts(ctx, screen_name, max_count, as_json, as_yaml, output_file): @structured_output_options @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("--full-text", is_flag=True, help="Show full tweet text in table output.") @click.pass_context -def search(ctx, query, product, max_count, as_json, as_yaml, output_file, do_filter): - # type: (Any, str, str, int, bool, bool, Optional[str], bool) -> None +def search(ctx, query, product, max_count, as_json, as_yaml, output_file, do_filter, full_text): + # type: (Any, str, str, int, bool, bool, Optional[str], bool, bool) -> None """Search tweets by QUERY string.""" compact = ctx.obj.get("compact", False) config = load_config() @@ -532,7 +559,7 @@ def search(ctx, query, product, max_count, as_json, as_yaml, output_file, do_fil _fetch_and_display( lambda count: client.fetch_search(query, count, product), "'%s' (%s)" % (query, product), "🔍", max_count, as_json, as_yaml, output_file, do_filter, config, - compact=compact, + compact=compact, full_text=full_text, ) _run_guarded(_run) @@ -543,9 +570,10 @@ def search(ctx, query, product, max_count, as_json, as_yaml, output_file, do_fil @structured_output_options @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("--full-text", is_flag=True, help="Show full tweet text in table output.") @click.pass_context -def likes(ctx, screen_name, max_count, as_json, as_yaml, output_file, do_filter): - # type: (Any, str, int, bool, bool, Optional[str], bool) -> None +def likes(ctx, screen_name, max_count, as_json, as_yaml, output_file, do_filter, full_text): + # type: (Any, str, int, bool, bool, Optional[str], bool, bool) -> None """Show tweets liked by a user. SCREEN_NAME is the @handle (without @). NOTE: Twitter/X made all likes private since June 2024. You can only view @@ -583,7 +611,7 @@ def likes(ctx, screen_name, max_count, as_json, as_yaml, output_file, do_filter) _fetch_and_display( lambda count: client.fetch_user_likes(profile.id, count), "@%s likes" % screen_name, "❤️", max_count, as_json, as_yaml, output_file, do_filter, config, - compact=compact, + compact=compact, full_text=full_text, ) _run_guarded(_run) @@ -591,10 +619,11 @@ def likes(ctx, screen_name, max_count, as_json, as_yaml, output_file, do_filter) @cli.command() @click.argument("tweet_id") @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 tweet(ctx, tweet_id, max_count, as_json, as_yaml): - # type: (Any, str, int, bool, bool) -> None +def tweet(ctx, tweet_id, max_count, full_text, as_json, as_yaml): + # type: (Any, str, int, bool, bool, bool) -> None """View a tweet and its replies. TWEET_ID is the numeric tweet ID or full URL.""" compact = ctx.obj.get("compact", False) tweet_id = _normalize_tweet_id(tweet_id) @@ -623,7 +652,7 @@ def tweet(ctx, tweet_id, max_count, as_json, as_yaml): 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)) + print_tweet_table(tweets[1:], console, title="💬 Replies — %d" % (len(tweets) - 1), full_text=full_text) console.print() @@ -632,9 +661,10 @@ def tweet(ctx, tweet_id, max_count, as_json, as_yaml): @click.option("--max", "-n", "max_count", type=int, default=None, help="Max tweets to fetch.") @structured_output_options @click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.") +@click.option("--full-text", is_flag=True, help="Show full tweet text in table output.") @click.pass_context -def list_timeline(ctx, list_id, max_count, as_json, as_yaml, do_filter): - # type: (Any, str, int, bool, bool, bool) -> None +def list_timeline(ctx, list_id, max_count, as_json, as_yaml, do_filter, full_text): + # type: (Any, str, int, bool, bool, bool, bool) -> None """Fetch tweets from a Twitter List. LIST_ID is the numeric list ID.""" compact = ctx.obj.get("compact", False) config = load_config() @@ -643,7 +673,7 @@ def list_timeline(ctx, list_id, max_count, as_json, as_yaml, do_filter): _fetch_and_display( lambda count: client.fetch_list_timeline(list_id, count), "list %s" % list_id, "📋", max_count, as_json, as_yaml, None, do_filter, config, - compact=compact, + compact=compact, full_text=full_text, ) _run_guarded(_run) diff --git a/twitter_cli/formatter.py b/twitter_cli/formatter.py index e1e4653..9455051 100644 --- a/twitter_cli/formatter.py +++ b/twitter_cli/formatter.py @@ -24,6 +24,7 @@ def print_tweet_table( tweets: List[Tweet], console: Optional[Console] = None, title: Optional[str] = None, + full_text: bool = False, ) -> None: """Print tweets as a rich table.""" if console is None: @@ -46,9 +47,9 @@ def print_tweet_table( if tweet.is_retweet and tweet.retweeted_by: author_text += "\n🔄 @%s" % tweet.retweeted_by - # Tweet text (truncated) + # Tweet text text = tweet.text.replace("\n", " ").strip() - if len(text) > 120: + if not full_text and len(text) > 120: text = text[:117] + "..." # Media indicators @@ -66,7 +67,9 @@ def print_tweet_table( # Quoted tweet if tweet.quoted_tweet: qt = tweet.quoted_tweet - qt_text = qt.text.replace("\n", " ")[:60] + qt_text = qt.text.replace("\n", " ") + if not full_text and len(qt_text) > 60: + qt_text = qt_text[:57] + "..." text += "\n┌ @%s: %s" % (qt.author.screen_name, qt_text) # Tweet link