feat: add twitter article markdown command (#16)
This commit is contained in:
@@ -9,6 +9,7 @@ Read commands:
|
||||
twitter user-posts elonmusk # user tweets
|
||||
twitter likes elonmusk # user likes
|
||||
twitter tweet <id> # tweet detail + replies
|
||||
twitter article <id> # Twitter Article as Markdown
|
||||
twitter list <id> # list timeline
|
||||
twitter followers <handle> # followers list
|
||||
twitter following <handle> # 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.")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -43,6 +43,7 @@ FALLBACK_QUERY_IDS = {
|
||||
"DeleteRetweet": "iQtK4dl5hBmXewYZuEOKVw",
|
||||
"CreateBookmark": "aoDbu3RHznuiSkQ9aNM67Q",
|
||||
"DeleteBookmark": "Wlmlj2-xISYCixDmuS8KNg",
|
||||
"TweetResultByRestId": "7xflPyRiUxGVbJd4uWmbfg",
|
||||
}
|
||||
|
||||
# ── Default feature flags ────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user