feat: add full-text option for tweet tables

This commit is contained in:
jackwener
2026-03-11 20:58:12 +08:00
parent 88a9f4ce97
commit 1313eb0be1
5 changed files with 145 additions and 32 deletions

View File

@@ -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 更稳定地调用本工具。

View File

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

View File

@@ -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",
[

View File

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

View File

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