Files
twitter-cli-cookiefile/twitter_cli/formatter.py
jackwener 7238b932ab feat: add user commands, auto-detect browser, optimize performance
- Add user/user-posts/followers/following commands
- Add UserProfile model and GraphQL API methods
- Add print_user_profile and print_user_table formatters
- Auto-detect browser for cookies (Chrome → Edge → Firefox → Brave)
- Remove --browser option from all commands
- Remove cookie verification (v1.1 endpoints are gone)
- Use hardcoded fallback query IDs first (skip slow JS bundle scan)
- Update FEATURES from latest twitter-openapi config
- Fix user-posts: add required withVoice variable
- Add tweet URL links in feed output
- Add error handling to all user commands
2026-03-05 00:41:26 +08:00

290 lines
8.8 KiB
Python

"""Tweet formatter for terminal output (rich) and JSON export."""
from __future__ import annotations
import json
from typing import List, Optional
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from .models import Tweet, UserProfile
def format_number(n):
# type: (int) -> str
"""Format number with K/M suffixes."""
if n >= 1_000_000:
return "%.1fM" % (n / 1_000_000)
if n >= 1_000:
return "%.1fK" % (n / 1_000)
return str(n)
def print_tweet_table(tweets, console=None, title=None):
# type: (List[Tweet], Optional[Console], Optional[str]) -> None
"""Print tweets as a rich table."""
if console is None:
console = Console()
if not title:
title = "📱 Twitter — %d tweets" % len(tweets)
table = Table(title=title, show_lines=True, expand=True)
table.add_column("#", style="dim", width=3, justify="right")
table.add_column("Author", style="cyan", width=18, no_wrap=True)
table.add_column("Tweet", ratio=3)
table.add_column("Stats", style="green", width=22, no_wrap=True)
table.add_column("Score", style="yellow", width=6, justify="right")
for i, tweet in enumerate(tweets):
# Author
verified = "" if tweet.author.verified else ""
author_text = "@%s%s" % (tweet.author.screen_name, verified)
if tweet.is_retweet and tweet.retweeted_by:
author_text += "\n🔄 @%s" % tweet.retweeted_by
# Tweet text (truncated)
text = tweet.text.replace("\n", " ").strip()
if len(text) > 120:
text = text[:117] + "..."
# Media indicators
if tweet.media:
media_icons = []
for m in tweet.media:
if m.type == "photo":
media_icons.append("📷")
elif m.type == "video":
media_icons.append("📹")
else:
media_icons.append("🎞️")
text += " " + " ".join(media_icons)
# Quoted tweet
if tweet.quoted_tweet:
qt = tweet.quoted_tweet
qt_text = qt.text.replace("\n", " ")[:60]
text += "\n┌ @%s: %s" % (qt.author.screen_name, qt_text)
# Tweet link
text += "\n🔗 x.com/%s/status/%s" % (tweet.author.screen_name, tweet.id)
# Stats
stats = (
"❤️ %s 🔄 %s\n💬 %s 👁️ %s"
% (
format_number(tweet.metrics.likes),
format_number(tweet.metrics.retweets),
format_number(tweet.metrics.replies),
format_number(tweet.metrics.views),
)
)
# Score
score_str = "%.1f" % tweet.score if tweet.score else "-"
table.add_row(str(i + 1), author_text, text, stats, score_str)
console.print(table)
def print_tweet_detail(tweet, console=None):
# type: (Tweet, Optional[Console]) -> None
"""Print a single tweet in detail using a rich panel."""
if console is None:
console = Console()
verified = "" if tweet.author.verified else ""
header = "@%s%s (%s)" % (tweet.author.screen_name, verified, tweet.author.name)
body_parts = []
if tweet.is_retweet and tweet.retweeted_by:
body_parts.append("🔄 Retweeted by @%s\n" % tweet.retweeted_by)
body_parts.append(tweet.text)
if tweet.media:
body_parts.append("")
for m in tweet.media:
icon = "📷" if m.type == "photo" else ("📹" if m.type == "video" else "🎞️")
body_parts.append("%s %s: %s" % (icon, m.type, m.url))
if tweet.urls:
body_parts.append("")
for url in tweet.urls:
body_parts.append("🔗 %s" % url)
if tweet.quoted_tweet:
qt = tweet.quoted_tweet
body_parts.append("")
body_parts.append("┌── Quoted @%s ──" % qt.author.screen_name)
body_parts.append(qt.text[:200])
body_parts.append("")
body_parts.append(
"❤️ %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),
)
)
body_parts.append(
"🕐 %s · https://x.com/%s/status/%s"
% (tweet.created_at, tweet.author.screen_name, tweet.id)
)
console.print(Panel(
"\n".join(body_parts),
title=header,
border_style="blue",
expand=True,
))
def print_filter_stats(original_count, filtered, console=None):
# type: (int, List[Tweet], Optional[Console]) -> None
"""Print filter statistics."""
if console is None:
console = Console()
console.print(
"📊 Filter: %d%d tweets" % (original_count, len(filtered))
)
if filtered:
top_score = filtered[0].score
bottom_score = filtered[-1].score
console.print(
" Score range: %.1f ~ %.1f" % (bottom_score, top_score)
)
def tweets_to_json(tweets):
# type: (List[Tweet]) -> str
"""Export tweets as JSON string."""
result = []
for t in tweets:
d = {
"id": t.id,
"text": t.text,
"author": {
"id": t.author.id,
"name": t.author.name,
"screenName": t.author.screen_name,
"profileImageUrl": t.author.profile_image_url,
"verified": t.author.verified,
},
"metrics": {
"likes": t.metrics.likes,
"retweets": t.metrics.retweets,
"replies": t.metrics.replies,
"quotes": t.metrics.quotes,
"views": t.metrics.views,
"bookmarks": t.metrics.bookmarks,
},
"createdAt": t.created_at,
"media": [
{"type": m.type, "url": m.url, "width": m.width, "height": m.height}
for m in t.media
],
"urls": t.urls,
"isRetweet": t.is_retweet,
"retweetedBy": t.retweeted_by,
"lang": t.lang,
"score": t.score,
}
if t.quoted_tweet:
qt = t.quoted_tweet
d["quotedTweet"] = {
"id": qt.id,
"text": qt.text,
"author": {"screenName": qt.author.screen_name, "name": qt.author.name},
}
result.append(d)
return json.dumps(result, ensure_ascii=False, indent=2)
def print_user_profile(user, console=None):
# type: (UserProfile, Optional[Console]) -> None
"""Print user profile as a rich panel."""
if console is None:
console = Console()
verified = "" if user.verified else ""
header = "@%s%s (%s)" % (user.screen_name, verified, user.name)
lines = []
if user.bio:
lines.append(user.bio)
lines.append("")
if user.location:
lines.append("📍 %s" % user.location)
if user.url:
lines.append("🔗 %s" % user.url)
if user.location or user.url:
lines.append("")
lines.append(
"👥 %s followers · %s following · %s tweets · %s likes"
% (
format_number(user.followers_count),
format_number(user.following_count),
format_number(user.tweets_count),
format_number(user.likes_count),
)
)
if user.created_at:
lines.append("📅 Joined %s" % user.created_at)
lines.append("🔗 x.com/%s" % user.screen_name)
console.print(Panel(
"\n".join(lines),
title=header,
border_style="cyan",
expand=True,
))
def print_user_table(users, console=None, title=None):
# type: (List[UserProfile], Optional[Console], Optional[str]) -> None
"""Print a list of users as a rich table."""
if console is None:
console = Console()
if not title:
title = "👥 Users — %d" % len(users)
table = Table(title=title, show_lines=True, expand=True)
table.add_column("#", style="dim", width=3, justify="right")
table.add_column("User", style="cyan", width=20, no_wrap=True)
table.add_column("Bio", ratio=3)
table.add_column("Stats", style="green", width=22, no_wrap=True)
for i, user in enumerate(users):
verified = "" if user.verified else ""
user_text = "@%s%s\n%s" % (user.screen_name, verified, user.name)
bio = (user.bio or "").replace("\n", " ").strip()
if len(bio) > 100:
bio = bio[:97] + "..."
stats = (
"👥 %s followers\n📝 %s following"
% (
format_number(user.followers_count),
format_number(user.following_count),
)
)
table.add_row(str(i + 1), user_text, bio, stats)
console.print(table)