diff --git a/README.md b/README.md index c9b029d..9fbb7c5 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ A terminal-first CLI for Twitter/X: read timelines, bookmarks, and user profiles - 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 +- 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 - `--full-text`: disable tweet text truncation in rich table output @@ -117,6 +118,12 @@ twitter tweet 1234567890 twitter tweet 1234567890 --full-text twitter tweet https://x.com/user/status/1234567890 +# Twitter Article +twitter article 1234567890 +twitter article https://x.com/user/article/1234567890 --json +twitter article 1234567890 --markdown +twitter article 1234567890 --output article.md + # List timeline twitter list 1539453138322673664 twitter list 1539453138322673664 --full-text @@ -344,6 +351,7 @@ After installation, OpenClaw can call `twitter-cli` commands directly. - 收藏读取:查看账号书签推文 - 搜索:按关键词搜索推文,支持 Top/Latest/Photos/Videos - 推文详情:查看推文及其回复 +- 文章读取:获取 Twitter 长文,并导出为 Markdown - 列表时间线:获取 Twitter List 的推文 - 用户查询:查看用户资料、推文、点赞、粉丝和关注 - `--full-text`:在 rich table 输出里关闭推文正文截断 @@ -408,6 +416,12 @@ twitter search "trending" --filter # 启用排序筛选 twitter tweet 1234567890 twitter tweet 1234567890 --full-text +# Twitter 长文 +twitter article 1234567890 +twitter article https://x.com/user/article/1234567890 --json +twitter article 1234567890 --markdown +twitter article 1234567890 --output article.md + # 列表时间线 twitter list 1539453138322673664 twitter list 1539453138322673664 --full-text diff --git a/SCHEMA.md b/SCHEMA.md index f8b45eb..558fee7 100644 --- a/SCHEMA.md +++ b/SCHEMA.md @@ -25,10 +25,24 @@ error: - `--yaml` and `--json` both use this envelope - non-TTY stdout defaults to YAML - tweet and user lists are returned under `data` +- `article` returns a single tweet object under `data` - `status` returns `data.authenticated` plus `data.user` - `whoami` returns `data.user` - write commands also support explicit `--json` / `--yaml` +## Article Fields + +`twitter article --json` returns the standard tweet object plus: + +```yaml +data: + id: "1234567890" + articleTitle: "Article Title" + articleText: | + # Heading + Body text... +``` + ## Error Codes Common structured error codes: diff --git a/tests/conftest.py b/tests/conftest.py index 0edb643..445ca3d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,6 +36,8 @@ def tweet_factory(): retweeted_by=overrides.pop("retweeted_by", None), quoted_tweet=overrides.pop("quoted_tweet", None), score=overrides.pop("score", 0.0), + article_title=overrides.pop("article_title", None), + article_text=overrides.pop("article_text", None), ) return _make_tweet diff --git a/tests/test_cli.py b/tests/test_cli.py index 1975c43..7ed515e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,8 +6,8 @@ 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.formatter import article_to_markdown, print_tweet_table +from twitter_cli.models import Author, Metrics, Tweet, UserProfile from twitter_cli.serialization import tweets_to_json @@ -125,6 +125,104 @@ def test_cli_tweet_accepts_shared_url_with_query(monkeypatch) -> None: assert result.exit_code == 0 +def test_cli_article_accepts_article_url_and_json(monkeypatch) -> None: + class FakeClient: + def fetch_article(self, tweet_id: str) -> Tweet: + assert tweet_id == "12345" + return Tweet( + id="12345", + text="https://t.co/article", + author=Author(id="u1", name="Alice", screen_name="alice"), + metrics=Metrics(likes=1, retweets=2, replies=3, views=4, bookmarks=5), + created_at="2026-03-11", + article_title="Title", + article_text="Hello\n\n## Section", + ) + + 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, ["article", "https://x.com/user/article/12345?s=20", "--json"]) + + assert result.exit_code == 0 + payload = yaml.safe_load(result.output) + assert payload["ok"] is True + assert payload["data"]["id"] == "12345" + assert payload["data"]["articleTitle"] == "Title" + assert "Hello" in payload["data"]["articleText"] + + +def test_cli_article_markdown_output_and_save(monkeypatch, tmp_path) -> None: + article = Tweet( + id="12345", + text="https://t.co/article", + author=Author(id="u1", name="Alice", screen_name="alice"), + metrics=Metrics(likes=1, retweets=2, replies=3, views=4, bookmarks=5), + created_at="2026-03-11", + article_title="Title", + article_text="Hello\n\n## Section", + ) + + class FakeClient: + def fetch_article(self, tweet_id: str) -> Tweet: + assert tweet_id == "12345" + return article + + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + monkeypatch.setattr("twitter_cli.cli.load_config", lambda: {}) + output_path = tmp_path / "article.md" + runner = CliRunner() + + result = runner.invoke( + cli, + ["article", "12345", "--markdown", "--output", str(output_path)], + ) + + assert result.exit_code == 0 + assert result.output == article_to_markdown(article) + assert output_path.read_text(encoding="utf-8") == article_to_markdown(article) + + +def test_cli_article_markdown_overrides_auto_structured_output(monkeypatch) -> None: + article = Tweet( + id="12345", + text="https://t.co/article", + author=Author(id="u1", name="Alice", screen_name="alice"), + metrics=Metrics(likes=1, retweets=2, replies=3, views=4, bookmarks=5), + created_at="2026-03-11", + article_title="Title", + article_text="Hello\n\n## Section", + ) + + class FakeClient: + def fetch_article(self, tweet_id: str) -> Tweet: + assert tweet_id == "12345" + return article + + monkeypatch.setenv("OUTPUT", "auto") + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + monkeypatch.setattr("twitter_cli.cli.load_config", lambda: {}) + runner = CliRunner() + + result = runner.invoke(cli, ["article", "12345", "--markdown"]) + + assert result.exit_code == 0 + assert result.output == article_to_markdown(article) + + +def test_cli_article_rejects_compact_mode() -> None: + runner = CliRunner() + + result = runner.invoke(cli, ["-c", "article", "12345"]) + + assert result.exit_code == 2 + assert "does not support --compact" in result.output + + def test_cli_bookmark_alias_works(monkeypatch) -> None: calls = [] diff --git a/tests/test_serialization.py b/tests/test_serialization.py index bb44fa6..9cede7b 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -49,3 +49,16 @@ def test_compact_serialization(tweet_factory) -> None: assert len(parsed) == 1 assert parsed[0]["author"] == "@alice" + +def test_tweet_roundtrip_preserves_article_fields(tweet_factory) -> None: + tweet = tweet_factory( + "88", + article_title="Long-form title", + article_text="Intro\n\n## Details", + ) + + payload = tweet_to_dict(tweet) + restored = tweet_from_dict(payload) + + assert restored.article_title == "Long-form title" + assert restored.article_text == "Intro\n\n## Details" diff --git a/twitter_cli/cli.py b/twitter_cli/cli.py index ab07d94..658a99d 100644 --- a/twitter_cli/cli.py +++ b/twitter_cli/cli.py @@ -9,6 +9,7 @@ Read commands: twitter user-posts elonmusk # user tweets twitter likes elonmusk # user likes twitter tweet # tweet detail + replies + twitter article # Twitter Article as Markdown twitter list # list timeline twitter followers # followers list twitter following # following list @@ -46,7 +47,9 @@ from .client import TwitterClient from .config import load_config from .filter import filter_tweets from .formatter import ( + article_to_markdown, print_filter_stats, + print_article, print_tweet_detail, print_tweet_table, print_user_profile, @@ -63,6 +66,7 @@ from .output import ( use_rich_output, ) from .serialization import ( + tweet_to_dict, tweets_from_json, tweets_to_data, tweets_to_compact_json, @@ -214,7 +218,7 @@ def _normalize_tweet_id(value): candidate = raw if parsed.scheme and parsed.netloc: path = parsed.path.rstrip("/") - match = re.search(r"/status/(\d+)$", path) + match = re.search(r"/(?:status|article)/(\d+)$", path) if not match: raise RuntimeError("Invalid tweet URL: %s" % value) candidate = match.group(1) @@ -656,6 +660,52 @@ def tweet(ctx, tweet_id, max_count, full_text, as_json, as_yaml): console.print() +@cli.command() +@click.argument("tweet_id") +@structured_output_options +@click.option("--markdown", "-m", "as_markdown", is_flag=True, help="Output article as Markdown.") +@click.option("--output", "-o", "output_file", type=str, default=None, help="Save article Markdown to file.") +@click.pass_context +def article(ctx, tweet_id, as_json, as_yaml, as_markdown, output_file): + # type: (Any, str, bool, bool, bool, Optional[str]) -> None + """Fetch a Twitter Article. TWEET_ID is the numeric tweet ID or full URL.""" + compact = ctx.obj.get("compact", False) + if compact: + raise click.UsageError("`twitter article` does not support --compact. Use --markdown or --output.") + if as_markdown and (as_json or as_yaml): + raise click.UsageError("Use only one of --markdown, --json, or --yaml.") + + tweet_id = _normalize_tweet_id(tweet_id) + config = load_config() + rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml, compact=False) and not as_markdown + try: + client = _get_client_for_output(config, quiet=not rich_output) + if rich_output: + console.print("📰 Fetching article %s...\n" % tweet_id) + start = time.time() + article_tweet = client.fetch_article(tweet_id) + elapsed = time.time() - start + if rich_output: + console.print("✅ Fetched article in %.1fs\n" % elapsed) + except RuntimeError as exc: + _exit_with_error(exc) + + markdown = article_to_markdown(article_tweet) + if output_file: + Path(output_file).write_text(markdown, encoding="utf-8") + if rich_output: + console.print("💾 Saved article Markdown to %s\n" % output_file) + + if as_markdown: + click.echo(markdown, nl=False) + return + if emit_structured(tweet_to_dict(article_tweet), as_json=as_json, as_yaml=as_yaml): + return + + print_article(article_tweet, console) + console.print() + + @cli.command(name="list") @click.argument("list_id") @click.option("--max", "-n", "max_count", type=int, default=None, help="Max tweets to fetch.") diff --git a/twitter_cli/client.py b/twitter_cli/client.py index 3aafae8..fd668e1 100644 --- a/twitter_cli/client.py +++ b/twitter_cli/client.py @@ -43,7 +43,7 @@ from .graphql import ( _resolve_query_id, _update_features_from_html, ) -from .models import UserProfile +from .models import Tweet, UserProfile from .parser import ( _deep_get, _parse_int, @@ -321,6 +321,45 @@ class TwitterClient: }, ) + def fetch_article(self, tweet_id): + # type: (str) -> Tweet + """Fetch a Twitter Article by tweet ID.""" + logger.debug("fetch_article: tweet_id=%s", tweet_id) + + data = self._graphql_get( + "TweetResultByRestId", + variables={ + "tweetId": tweet_id, + "withCommunity": False, + "includePromotedContent": False, + "withVoice": False, + }, + features={ + "longform_notetweets_consumption_enabled": True, + "responsive_web_twitter_article_tweet_consumption_enabled": True, + "longform_notetweets_rich_text_read_enabled": True, + "longform_notetweets_inline_media_enabled": True, + "articles_preview_enabled": True, + "responsive_web_graphql_exclude_directive_enabled": True, + "verified_phone_label_enabled": False, + }, + field_toggles={ + "withArticleRichContentState": True, + "withArticlePlainText": True, + }, + ) + + result = _deep_get(data, "data", "tweetResult", "result") + if not result: + raise NotFoundError("Article not found: tweet_id=%s" % tweet_id) + + tweet = parse_tweet_result(result) + if tweet is None or (tweet.article_title is None and tweet.article_text is None): + raise NotFoundError("Tweet %s has no article content" % tweet_id) + + logger.info("fetch_article: tweet_id=%s", tweet_id) + return tweet + def fetch_list_timeline(self, list_id, count=20): # type: (str, int) -> List[Tweet] """Fetch tweets from a Twitter List.""" diff --git a/twitter_cli/formatter.py b/twitter_cli/formatter.py index 9455051..1d7adba 100644 --- a/twitter_cli/formatter.py +++ b/twitter_cli/formatter.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import List, Optional from rich.console import Console +from rich.markdown import Markdown from rich.panel import Panel from rich.table import Table @@ -150,6 +151,61 @@ def print_tweet_detail(tweet: Tweet, console: Optional[Console] = None) -> None: )) +def article_to_markdown(tweet: Tweet) -> str: + """Convert a Twitter Article tweet into a Markdown document.""" + title = tweet.article_title or "Twitter Article" + lines = [ + "# %s" % title, + "", + "- Author: @%s (%s)" % (tweet.author.screen_name, tweet.author.name), + "- Published: %s" % (tweet.created_at or "unknown"), + "- URL: https://x.com/%s/status/%s" % (tweet.author.screen_name, tweet.id), + "- Likes: %s" % format_number(tweet.metrics.likes), + "- Retweets: %s" % format_number(tweet.metrics.retweets), + "- Replies: %s" % format_number(tweet.metrics.replies), + "- Bookmarks: %s" % format_number(tweet.metrics.bookmarks), + "- Views: %s" % format_number(tweet.metrics.views), + ] + + if tweet.article_text: + lines.extend(["", tweet.article_text.strip()]) + + return "\n".join(lines).strip() + "\n" + + +def print_article(tweet: Tweet, console: Optional[Console] = None) -> None: + """Print a Twitter Article with rich formatting.""" + if console is None: + console = Console() + + verified = " ✓" if tweet.author.verified else "" + title = tweet.article_title or "Twitter Article" + meta_parts = [ + "By @%s%s (%s)" % (tweet.author.screen_name, verified, tweet.author.name), + "🕐 %s" % tweet.created_at, + "🔗 x.com/%s/status/%s" % (tweet.author.screen_name, tweet.id), + "", + "❤️ %s 🔄 %s 💬 %s 🔖 %s 👁️ %s" + % ( + format_number(tweet.metrics.likes), + format_number(tweet.metrics.retweets), + format_number(tweet.metrics.replies), + format_number(tweet.metrics.bookmarks), + format_number(tweet.metrics.views), + ), + ] + console.print(Panel( + "\n".join(meta_parts), + title="📰 %s" % title, + border_style="blue", + expand=True, + )) + + if tweet.article_text: + console.print() + console.print(Markdown(tweet.article_text)) + + def print_filter_stats( original_count: int, filtered: List[Tweet], diff --git a/twitter_cli/graphql.py b/twitter_cli/graphql.py index a019460..611f34a 100644 --- a/twitter_cli/graphql.py +++ b/twitter_cli/graphql.py @@ -43,6 +43,7 @@ FALLBACK_QUERY_IDS = { "DeleteRetweet": "iQtK4dl5hBmXewYZuEOKVw", "CreateBookmark": "aoDbu3RHznuiSkQ9aNM67Q", "DeleteBookmark": "Wlmlj2-xISYCixDmuS8KNg", + "TweetResultByRestId": "7xflPyRiUxGVbJd4uWmbfg", } # ── Default feature flags ────────────────────────────────────────────────