- 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
290 lines
8.8 KiB
Python
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)
|