feat: add twitter article markdown command (#16)

This commit is contained in:
jakevin
2026-03-12 14:47:49 +08:00
committed by GitHub
parent 1c0e4b0c39
commit 79eadd2579
9 changed files with 291 additions and 4 deletions

View File

@@ -24,6 +24,7 @@ A terminal-first CLI for Twitter/X: read timelines, bookmarks, and user profiles
- Bookmarks: list saved tweets from your account - Bookmarks: list saved tweets from your account
- Search: find tweets by keyword with Top/Latest/Photos/Videos tabs - 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
- Article: fetch a Twitter Article and export it as Markdown
- List timeline: fetch tweets from a Twitter List - List timeline: fetch tweets from a Twitter List
- User lookup: fetch user profile, tweets, likes, followers, and following - User lookup: fetch user profile, tweets, likes, followers, and following
- `--full-text`: disable tweet text truncation in rich table output - `--full-text`: disable tweet text truncation in rich table output
@@ -117,6 +118,12 @@ twitter tweet 1234567890
twitter tweet 1234567890 --full-text twitter tweet 1234567890 --full-text
twitter tweet https://x.com/user/status/1234567890 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 # List timeline
twitter list 1539453138322673664 twitter list 1539453138322673664
twitter list 1539453138322673664 --full-text twitter list 1539453138322673664 --full-text
@@ -344,6 +351,7 @@ After installation, OpenClaw can call `twitter-cli` commands directly.
- 收藏读取:查看账号书签推文 - 收藏读取:查看账号书签推文
- 搜索:按关键词搜索推文,支持 Top/Latest/Photos/Videos - 搜索:按关键词搜索推文,支持 Top/Latest/Photos/Videos
- 推文详情:查看推文及其回复 - 推文详情:查看推文及其回复
- 文章读取:获取 Twitter 长文,并导出为 Markdown
- 列表时间线:获取 Twitter List 的推文 - 列表时间线:获取 Twitter List 的推文
- 用户查询:查看用户资料、推文、点赞、粉丝和关注 - 用户查询:查看用户资料、推文、点赞、粉丝和关注
- `--full-text`:在 rich table 输出里关闭推文正文截断 - `--full-text`:在 rich table 输出里关闭推文正文截断
@@ -408,6 +416,12 @@ twitter search "trending" --filter # 启用排序筛选
twitter tweet 1234567890 twitter tweet 1234567890
twitter tweet 1234567890 --full-text 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
twitter list 1539453138322673664 --full-text twitter list 1539453138322673664 --full-text

View File

@@ -25,10 +25,24 @@ error:
- `--yaml` and `--json` both use this envelope - `--yaml` and `--json` both use this envelope
- non-TTY stdout defaults to YAML - non-TTY stdout defaults to YAML
- tweet and user lists are returned under `data` - tweet and user lists are returned under `data`
- `article` returns a single tweet object under `data`
- `status` returns `data.authenticated` plus `data.user` - `status` returns `data.authenticated` plus `data.user`
- `whoami` returns `data.user` - `whoami` returns `data.user`
- write commands also support explicit `--json` / `--yaml` - write commands also support explicit `--json` / `--yaml`
## Article Fields
`twitter article <id> --json` returns the standard tweet object plus:
```yaml
data:
id: "1234567890"
articleTitle: "Article Title"
articleText: |
# Heading
Body text...
```
## Error Codes ## Error Codes
Common structured error codes: Common structured error codes:

View File

@@ -36,6 +36,8 @@ def tweet_factory():
retweeted_by=overrides.pop("retweeted_by", None), retweeted_by=overrides.pop("retweeted_by", None),
quoted_tweet=overrides.pop("quoted_tweet", None), quoted_tweet=overrides.pop("quoted_tweet", None),
score=overrides.pop("score", 0.0), score=overrides.pop("score", 0.0),
article_title=overrides.pop("article_title", None),
article_text=overrides.pop("article_text", None),
) )
return _make_tweet return _make_tweet

View File

@@ -6,8 +6,8 @@ from rich.console import Console
import yaml import yaml
from twitter_cli.cli import cli from twitter_cli.cli import cli
from twitter_cli.formatter import print_tweet_table from twitter_cli.formatter import article_to_markdown, print_tweet_table
from twitter_cli.models import UserProfile from twitter_cli.models import Author, Metrics, Tweet, UserProfile
from twitter_cli.serialization import tweets_to_json 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 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: def test_cli_bookmark_alias_works(monkeypatch) -> None:
calls = [] calls = []

View File

@@ -49,3 +49,16 @@ def test_compact_serialization(tweet_factory) -> None:
assert len(parsed) == 1 assert len(parsed) == 1
assert parsed[0]["author"] == "@alice" 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"

View File

@@ -9,6 +9,7 @@ Read commands:
twitter user-posts elonmusk # user tweets twitter user-posts elonmusk # user tweets
twitter likes elonmusk # user likes twitter likes elonmusk # user likes
twitter tweet <id> # tweet detail + replies twitter tweet <id> # tweet detail + replies
twitter article <id> # Twitter Article as Markdown
twitter list <id> # list timeline twitter list <id> # list timeline
twitter followers <handle> # followers list twitter followers <handle> # followers list
twitter following <handle> # following list twitter following <handle> # following list
@@ -46,7 +47,9 @@ from .client import TwitterClient
from .config import load_config from .config import load_config
from .filter import filter_tweets from .filter import filter_tweets
from .formatter import ( from .formatter import (
article_to_markdown,
print_filter_stats, print_filter_stats,
print_article,
print_tweet_detail, print_tweet_detail,
print_tweet_table, print_tweet_table,
print_user_profile, print_user_profile,
@@ -63,6 +66,7 @@ from .output import (
use_rich_output, use_rich_output,
) )
from .serialization import ( from .serialization import (
tweet_to_dict,
tweets_from_json, tweets_from_json,
tweets_to_data, tweets_to_data,
tweets_to_compact_json, tweets_to_compact_json,
@@ -214,7 +218,7 @@ def _normalize_tweet_id(value):
candidate = raw candidate = raw
if parsed.scheme and parsed.netloc: if parsed.scheme and parsed.netloc:
path = parsed.path.rstrip("/") path = parsed.path.rstrip("/")
match = re.search(r"/status/(\d+)$", path) match = re.search(r"/(?:status|article)/(\d+)$", path)
if not match: if not match:
raise RuntimeError("Invalid tweet URL: %s" % value) raise RuntimeError("Invalid tweet URL: %s" % value)
candidate = match.group(1) candidate = match.group(1)
@@ -656,6 +660,52 @@ def tweet(ctx, tweet_id, max_count, full_text, as_json, as_yaml):
console.print() 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") @cli.command(name="list")
@click.argument("list_id") @click.argument("list_id")
@click.option("--max", "-n", "max_count", type=int, default=None, help="Max tweets to fetch.") @click.option("--max", "-n", "max_count", type=int, default=None, help="Max tweets to fetch.")

View File

@@ -43,7 +43,7 @@ from .graphql import (
_resolve_query_id, _resolve_query_id,
_update_features_from_html, _update_features_from_html,
) )
from .models import UserProfile from .models import Tweet, UserProfile
from .parser import ( from .parser import (
_deep_get, _deep_get,
_parse_int, _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): def fetch_list_timeline(self, list_id, count=20):
# type: (str, int) -> List[Tweet] # type: (str, int) -> List[Tweet]
"""Fetch tweets from a Twitter List.""" """Fetch tweets from a Twitter List."""

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from typing import List, Optional from typing import List, Optional
from rich.console import Console from rich.console import Console
from rich.markdown import Markdown
from rich.panel import Panel from rich.panel import Panel
from rich.table import Table 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( def print_filter_stats(
original_count: int, original_count: int,
filtered: List[Tweet], filtered: List[Tweet],

View File

@@ -43,6 +43,7 @@ FALLBACK_QUERY_IDS = {
"DeleteRetweet": "iQtK4dl5hBmXewYZuEOKVw", "DeleteRetweet": "iQtK4dl5hBmXewYZuEOKVw",
"CreateBookmark": "aoDbu3RHznuiSkQ9aNM67Q", "CreateBookmark": "aoDbu3RHznuiSkQ9aNM67Q",
"DeleteBookmark": "Wlmlj2-xISYCixDmuS8KNg", "DeleteBookmark": "Wlmlj2-xISYCixDmuS8KNg",
"TweetResultByRestId": "7xflPyRiUxGVbJd4uWmbfg",
} }
# ── Default feature flags ──────────────────────────────────────────────── # ── Default feature flags ────────────────────────────────────────────────