refactor: harden CLI/client/config and centralize serialization
This commit is contained in:
@@ -13,8 +13,9 @@ import os
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -142,7 +143,8 @@ sys.exit(1)
|
||||
if not output:
|
||||
stderr = result.stderr.strip()
|
||||
if stderr:
|
||||
# Maybe browser-cookie3 not installed, try with uv
|
||||
logger.debug("Cookie extraction stderr from current env: %s", stderr[:300])
|
||||
# Maybe browser-cookie3 not installed, try with uv.
|
||||
result2 = subprocess.run(
|
||||
["uv", "run", "--with", "browser-cookie3", "python3", "-c", extract_script],
|
||||
capture_output=True,
|
||||
@@ -151,6 +153,7 @@ sys.exit(1)
|
||||
)
|
||||
output = result2.stdout.strip()
|
||||
if not output:
|
||||
logger.debug("Cookie extraction stderr from uv fallback: %s", result2.stderr.strip()[:300])
|
||||
return None
|
||||
|
||||
data = json.loads(output)
|
||||
@@ -185,4 +188,6 @@ def get_cookies() -> Dict[str, str]:
|
||||
"Option 2: Make sure you are logged into x.com in your browser (Chrome/Edge/Firefox/Brave)"
|
||||
)
|
||||
|
||||
# Verify only for explicit auth failures; transient endpoint issues are tolerated.
|
||||
verify_cookies(cookies["auth_token"], cookies["ct0"])
|
||||
return cookies
|
||||
|
||||
@@ -1,28 +1,24 @@
|
||||
"""CLI entry point for twitter-cli.
|
||||
|
||||
Usage:
|
||||
twitter feed # fetch home timeline → filter
|
||||
twitter feed # fetch home timeline (For You)
|
||||
twitter feed -t following # fetch following feed
|
||||
twitter feed --max 50 # custom fetch count
|
||||
twitter feed --no-filter # skip filtering
|
||||
twitter feed --filter # enable score-based filtering
|
||||
twitter feed --json # JSON output
|
||||
twitter favorite # fetch bookmarks
|
||||
twitter favorite --max 30
|
||||
twitter feed --input tweets.json # load existing data
|
||||
twitter feed --output out.json # save filtered tweets
|
||||
twitter post "Hello" # post a tweet
|
||||
twitter reply ID "text" # reply to a tweet
|
||||
twitter quote ID "text" # quote a tweet
|
||||
twitter delete ID # delete a tweet
|
||||
twitter user elonmusk # view user profile
|
||||
twitter user-posts elonmusk # list user tweets
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
@@ -32,17 +28,12 @@ from .auth import get_cookies
|
||||
from .client import TwitterClient
|
||||
from .config import load_config
|
||||
from .filter import filter_tweets
|
||||
from .formatter import (
|
||||
print_filter_stats,
|
||||
print_tweet_table,
|
||||
print_user_profile,
|
||||
print_user_table,
|
||||
tweets_to_json,
|
||||
)
|
||||
from .models import Author, Metrics, Tweet, TweetMedia
|
||||
from .formatter import print_filter_stats, print_tweet_table, print_user_profile
|
||||
from .serialization import tweets_from_json, tweets_to_json
|
||||
|
||||
|
||||
console = Console()
|
||||
FEED_TYPES = ["for-you", "following"]
|
||||
|
||||
|
||||
def _setup_logging(verbose):
|
||||
@@ -58,70 +49,49 @@ def _setup_logging(verbose):
|
||||
def _load_tweets_from_json(path):
|
||||
# type: (str) -> List[Tweet]
|
||||
"""Load tweets from a JSON file (previously exported)."""
|
||||
raw = Path(path).read_text(encoding="utf-8")
|
||||
items = json.loads(raw)
|
||||
tweets = []
|
||||
for d in items:
|
||||
author_data = d.get("author", {})
|
||||
metrics_data = d.get("metrics", {})
|
||||
media_data = d.get("media", [])
|
||||
file_path = Path(path)
|
||||
if not file_path.exists():
|
||||
raise RuntimeError("Input file not found: %s" % path)
|
||||
|
||||
author = Author(
|
||||
id=author_data.get("id", ""),
|
||||
name=author_data.get("name", ""),
|
||||
screen_name=author_data.get("screenName", ""),
|
||||
profile_image_url=author_data.get("profileImageUrl", ""),
|
||||
verified=author_data.get("verified", False),
|
||||
)
|
||||
metrics = Metrics(
|
||||
likes=metrics_data.get("likes", 0),
|
||||
retweets=metrics_data.get("retweets", 0),
|
||||
replies=metrics_data.get("replies", 0),
|
||||
quotes=metrics_data.get("quotes", 0),
|
||||
views=metrics_data.get("views", 0),
|
||||
bookmarks=metrics_data.get("bookmarks", 0),
|
||||
)
|
||||
media = [
|
||||
TweetMedia(
|
||||
type=m.get("type", ""),
|
||||
url=m.get("url", ""),
|
||||
width=m.get("width"),
|
||||
height=m.get("height"),
|
||||
)
|
||||
for m in media_data
|
||||
]
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8")
|
||||
return tweets_from_json(raw)
|
||||
except (ValueError, OSError) as exc:
|
||||
raise RuntimeError("Invalid tweet JSON file %s: %s" % (path, exc))
|
||||
|
||||
qt_data = d.get("quotedTweet")
|
||||
quoted_tweet = None
|
||||
if qt_data:
|
||||
qt_author = qt_data.get("author", {})
|
||||
quoted_tweet = Tweet(
|
||||
id=qt_data.get("id", ""),
|
||||
text=qt_data.get("text", ""),
|
||||
author=Author(
|
||||
id="",
|
||||
name=qt_author.get("name", ""),
|
||||
screen_name=qt_author.get("screenName", ""),
|
||||
),
|
||||
metrics=Metrics(),
|
||||
created_at="",
|
||||
)
|
||||
|
||||
tweets.append(Tweet(
|
||||
id=d.get("id", ""),
|
||||
text=d.get("text", ""),
|
||||
author=author,
|
||||
metrics=metrics,
|
||||
created_at=d.get("createdAt", ""),
|
||||
media=media,
|
||||
urls=d.get("urls", []),
|
||||
is_retweet=d.get("isRetweet", False),
|
||||
lang=d.get("lang", ""),
|
||||
retweeted_by=d.get("retweetedBy"),
|
||||
quoted_tweet=quoted_tweet,
|
||||
score=d.get("score", 0.0),
|
||||
))
|
||||
return tweets
|
||||
def _get_client():
|
||||
# type: () -> TwitterClient
|
||||
"""Create an authenticated API client."""
|
||||
console.print("\n🔐 Getting Twitter cookies...")
|
||||
try:
|
||||
cookies = get_cookies()
|
||||
except RuntimeError as exc:
|
||||
raise RuntimeError(str(exc))
|
||||
return TwitterClient(cookies["auth_token"], cookies["ct0"])
|
||||
|
||||
|
||||
def _resolve_fetch_count(max_count, configured):
|
||||
# type: (Optional[int], int) -> int
|
||||
"""Resolve fetch count with bounds checks."""
|
||||
if max_count is not None:
|
||||
if max_count <= 0:
|
||||
raise RuntimeError("--max must be greater than 0")
|
||||
return max_count
|
||||
return max(configured, 1)
|
||||
|
||||
|
||||
def _apply_filter(tweets, do_filter, config):
|
||||
# type: (List[Tweet], bool, dict) -> List[Tweet]
|
||||
"""Optionally apply tweet filtering."""
|
||||
if not do_filter:
|
||||
return tweets
|
||||
filter_config = config.get("filter", {})
|
||||
original_count = len(tweets)
|
||||
filtered = filter_tweets(tweets, filter_config)
|
||||
print_filter_stats(original_count, filtered, console)
|
||||
console.print()
|
||||
return filtered
|
||||
|
||||
|
||||
@click.group()
|
||||
@@ -133,107 +103,88 @@ def cli(verbose):
|
||||
_setup_logging(verbose)
|
||||
|
||||
|
||||
# ===== Feed =====
|
||||
|
||||
@cli.command()
|
||||
@click.option(
|
||||
"--type",
|
||||
"-t",
|
||||
"feed_type",
|
||||
type=click.Choice(FEED_TYPES),
|
||||
default="for-you",
|
||||
help="Feed type: for-you (algorithmic) or following (chronological).",
|
||||
)
|
||||
@click.option("--max", "-n", "max_count", type=int, default=None, help="Max number of tweets to fetch.")
|
||||
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
|
||||
@click.option("--input", "-i", "input_file", type=str, default=None, help="Load tweets from JSON file.")
|
||||
@click.option("--output", "-o", "output_file", type=str, default=None, help="Save filtered tweets to JSON file.")
|
||||
@click.option("--no-filter", is_flag=True, help="Skip filtering.")
|
||||
def feed(max_count, as_json, input_file, output_file, no_filter):
|
||||
# type: (int, bool, str, str, bool) -> None
|
||||
"""Fetch home timeline with filtering."""
|
||||
@click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.")
|
||||
def feed(feed_type, max_count, as_json, input_file, output_file, do_filter):
|
||||
# type: (str, Optional[int], bool, Optional[str], Optional[str], bool) -> None
|
||||
"""Fetch home timeline with optional filtering."""
|
||||
config = load_config()
|
||||
try:
|
||||
if input_file:
|
||||
console.print("📂 Loading tweets from %s..." % input_file)
|
||||
tweets = _load_tweets_from_json(input_file)
|
||||
console.print(" Loaded %d tweets" % len(tweets))
|
||||
else:
|
||||
fetch_count = _resolve_fetch_count(max_count, config.get("fetch", {}).get("count", 50))
|
||||
client = _get_client()
|
||||
label = "following feed" if feed_type == "following" else "home timeline"
|
||||
console.print("📡 Fetching %s (%d tweets)...\n" % (label, fetch_count))
|
||||
start = time.time()
|
||||
if feed_type == "following":
|
||||
tweets = client.fetch_following_feed(fetch_count)
|
||||
else:
|
||||
tweets = client.fetch_home_timeline(fetch_count)
|
||||
elapsed = time.time() - start
|
||||
console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed))
|
||||
except RuntimeError as exc:
|
||||
console.print("[red]❌ %s[/red]" % exc)
|
||||
sys.exit(1)
|
||||
|
||||
# Step 1: Get tweets
|
||||
if input_file:
|
||||
console.print("📂 Loading tweets from %s..." % input_file)
|
||||
tweets = _load_tweets_from_json(input_file)
|
||||
console.print(" Loaded %d tweets" % len(tweets))
|
||||
else:
|
||||
fetch_count = max_count or config.get("fetch", {}).get("count", 50)
|
||||
console.print("\n🔐 Getting Twitter cookies...")
|
||||
try:
|
||||
cookies = get_cookies()
|
||||
except RuntimeError as e:
|
||||
console.print("[red]❌ %s[/red]" % e)
|
||||
sys.exit(1)
|
||||
filtered = _apply_filter(tweets, do_filter, config)
|
||||
|
||||
client = TwitterClient(cookies["auth_token"], cookies["ct0"])
|
||||
console.print("📡 Fetching home timeline (%d tweets)...\n" % fetch_count)
|
||||
start = time.time()
|
||||
tweets = client.fetch_home_timeline(fetch_count)
|
||||
elapsed = time.time() - start
|
||||
console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed))
|
||||
|
||||
# Step 2: Filter
|
||||
if no_filter:
|
||||
filtered = tweets
|
||||
else:
|
||||
filter_config = config.get("filter", {})
|
||||
original_count = len(tweets)
|
||||
filtered = filter_tweets(tweets, filter_config)
|
||||
print_filter_stats(original_count, filtered, console)
|
||||
console.print()
|
||||
|
||||
# Save filtered tweets
|
||||
if output_file:
|
||||
Path(output_file).write_text(tweets_to_json(filtered), encoding="utf-8")
|
||||
console.print("💾 Saved filtered tweets to %s\n" % output_file)
|
||||
|
||||
# Output
|
||||
if as_json:
|
||||
click.echo(tweets_to_json(filtered))
|
||||
return
|
||||
|
||||
print_tweet_table(filtered, console)
|
||||
title = "👥 Following" if feed_type == "following" else "📱 Twitter"
|
||||
title += " — %d tweets" % len(filtered)
|
||||
print_tweet_table(filtered, console, title=title)
|
||||
console.print()
|
||||
|
||||
|
||||
# ===== Favorite =====
|
||||
|
||||
@cli.command()
|
||||
@click.option("--max", "-n", "max_count", type=int, default=None, help="Max number of tweets to fetch.")
|
||||
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
|
||||
@click.option("--output", "-o", "output_file", type=str, default=None, help="Save tweets to JSON file.")
|
||||
@click.option("--no-filter", is_flag=True, help="Skip filtering.")
|
||||
def favorite(max_count, as_json, output_file, no_filter):
|
||||
# type: (int, bool, str, bool) -> None
|
||||
@click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.")
|
||||
def favorite(max_count, as_json, output_file, do_filter):
|
||||
# type: (Optional[int], bool, Optional[str], bool) -> None
|
||||
"""Fetch bookmarked (favorite) tweets."""
|
||||
config = load_config()
|
||||
fetch_count = max_count or 50
|
||||
|
||||
console.print("\n🔐 Getting Twitter cookies...")
|
||||
try:
|
||||
cookies = get_cookies()
|
||||
except RuntimeError as e:
|
||||
console.print("[red]❌ %s[/red]" % e)
|
||||
fetch_count = _resolve_fetch_count(max_count, config.get("fetch", {}).get("count", 50))
|
||||
client = _get_client()
|
||||
console.print("🔖 Fetching favorites (%d tweets)...\n" % fetch_count)
|
||||
start = time.time()
|
||||
tweets = client.fetch_bookmarks(fetch_count)
|
||||
elapsed = time.time() - start
|
||||
console.print("✅ Fetched %d favorites in %.1fs\n" % (len(tweets), elapsed))
|
||||
except RuntimeError as exc:
|
||||
console.print("[red]❌ %s[/red]" % exc)
|
||||
sys.exit(1)
|
||||
|
||||
client = TwitterClient(cookies["auth_token"], cookies["ct0"])
|
||||
console.print("🔖 Fetching favorites (%d tweets)...\n" % fetch_count)
|
||||
start = time.time()
|
||||
tweets = client.fetch_bookmarks(fetch_count)
|
||||
elapsed = time.time() - start
|
||||
console.print("✅ Fetched %d favorites in %.1fs\n" % (len(tweets), elapsed))
|
||||
filtered = _apply_filter(tweets, do_filter, config)
|
||||
|
||||
# Filter
|
||||
if no_filter:
|
||||
filtered = tweets
|
||||
else:
|
||||
filter_config = config.get("filter", {})
|
||||
original_count = len(tweets)
|
||||
filtered = filter_tweets(tweets, filter_config)
|
||||
print_filter_stats(original_count, filtered, console)
|
||||
console.print()
|
||||
|
||||
# Save
|
||||
if output_file:
|
||||
Path(output_file).write_text(tweets_to_json(filtered), encoding="utf-8")
|
||||
console.print("💾 Saved to %s\n" % output_file)
|
||||
|
||||
# Output
|
||||
if as_json:
|
||||
click.echo(tweets_to_json(filtered))
|
||||
return
|
||||
@@ -242,24 +193,23 @@ def favorite(max_count, as_json, output_file, no_filter):
|
||||
console.print()
|
||||
|
||||
|
||||
# ===== User =====
|
||||
|
||||
@cli.command()
|
||||
@click.argument("screen_name")
|
||||
def user(screen_name):
|
||||
# type: (str,) -> None
|
||||
"""View a user's profile. SCREEN_NAME is the @handle (without @)."""
|
||||
screen_name = screen_name.lstrip("@")
|
||||
client = _get_client()
|
||||
console.print("👤 Fetching user @%s..." % screen_name)
|
||||
try:
|
||||
client = _get_client()
|
||||
console.print("👤 Fetching user @%s..." % screen_name)
|
||||
profile = client.fetch_user(screen_name)
|
||||
console.print()
|
||||
print_user_profile(profile, console)
|
||||
except RuntimeError as e:
|
||||
console.print("[red]❌ %s[/red]" % e)
|
||||
except RuntimeError as exc:
|
||||
console.print("[red]❌ %s[/red]" % exc)
|
||||
sys.exit(1)
|
||||
|
||||
console.print()
|
||||
print_user_profile(profile, console)
|
||||
|
||||
|
||||
@cli.command("user-posts")
|
||||
@click.argument("screen_name")
|
||||
@@ -269,24 +219,20 @@ def user_posts(screen_name, max_count, as_json):
|
||||
# type: (str, int, bool) -> None
|
||||
"""List a user's tweets. SCREEN_NAME is the @handle (without @)."""
|
||||
screen_name = screen_name.lstrip("@")
|
||||
client = _get_client()
|
||||
console.print("👤 Fetching @%s's profile..." % screen_name)
|
||||
try:
|
||||
fetch_count = _resolve_fetch_count(max_count, 20)
|
||||
client = _get_client()
|
||||
console.print("👤 Fetching @%s's profile..." % screen_name)
|
||||
profile = client.fetch_user(screen_name)
|
||||
except RuntimeError as e:
|
||||
console.print("[red]❌ %s[/red]" % e)
|
||||
console.print("📝 Fetching tweets (%d)...\n" % fetch_count)
|
||||
start = time.time()
|
||||
tweets = client.fetch_user_tweets(profile.id, fetch_count)
|
||||
elapsed = time.time() - start
|
||||
console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed))
|
||||
except RuntimeError as exc:
|
||||
console.print("[red]❌ %s[/red]" % exc)
|
||||
sys.exit(1)
|
||||
|
||||
console.print("📝 Fetching tweets (%d)...\n" % max_count)
|
||||
start = time.time()
|
||||
try:
|
||||
tweets = client.fetch_user_tweets(profile.id, max_count)
|
||||
except RuntimeError as e:
|
||||
console.print("[red]❌ %s[/red]" % e)
|
||||
sys.exit(1)
|
||||
elapsed = time.time() - start
|
||||
console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed))
|
||||
|
||||
if as_json:
|
||||
click.echo(tweets_to_json(tweets))
|
||||
return
|
||||
@@ -295,148 +241,5 @@ def user_posts(screen_name, max_count, as_json):
|
||||
console.print()
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("screen_name")
|
||||
@click.option("--max", "-n", "max_count", type=int, default=20, help="Max number of users to show.")
|
||||
def followers(screen_name, max_count):
|
||||
# type: (str, int) -> None
|
||||
"""List a user's followers. SCREEN_NAME is the @handle (without @)."""
|
||||
screen_name = screen_name.lstrip("@")
|
||||
client = _get_client()
|
||||
console.print("👤 Fetching @%s's profile..." % screen_name)
|
||||
try:
|
||||
profile = client.fetch_user(screen_name)
|
||||
except RuntimeError as e:
|
||||
console.print("[red]❌ %s[/red]" % e)
|
||||
sys.exit(1)
|
||||
|
||||
console.print("👥 Fetching followers...\n")
|
||||
try:
|
||||
users = client.fetch_followers(profile.id, max_count)
|
||||
except RuntimeError as e:
|
||||
console.print("[red]❌ %s[/red]" % e)
|
||||
sys.exit(1)
|
||||
print_user_table(users, console, title="👥 @%s's followers — %d" % (screen_name, len(users)))
|
||||
console.print()
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("screen_name")
|
||||
@click.option("--max", "-n", "max_count", type=int, default=20, help="Max number of users to show.")
|
||||
def following(screen_name, max_count):
|
||||
# type: (str, int) -> None
|
||||
"""List users that someone follows. SCREEN_NAME is the @handle (without @)."""
|
||||
screen_name = screen_name.lstrip("@")
|
||||
client = _get_client()
|
||||
console.print("👤 Fetching @%s's profile..." % screen_name)
|
||||
try:
|
||||
profile = client.fetch_user(screen_name)
|
||||
except RuntimeError as e:
|
||||
console.print("[red]❌ %s[/red]" % e)
|
||||
sys.exit(1)
|
||||
|
||||
console.print("👥 Fetching following...\n")
|
||||
try:
|
||||
users = client.fetch_following(profile.id, max_count)
|
||||
except RuntimeError as e:
|
||||
console.print("[red]❌ %s[/red]" % e)
|
||||
sys.exit(1)
|
||||
print_user_table(users, console, title="👥 @%s follows — %d" % (screen_name, len(users)))
|
||||
console.print()
|
||||
|
||||
|
||||
# ===== Post / Reply / Quote / Delete =====
|
||||
|
||||
def _get_client():
|
||||
# type: () -> TwitterClient
|
||||
"""Helper to authenticate and create a TwitterClient."""
|
||||
console.print("\n🔐 Getting Twitter cookies...")
|
||||
try:
|
||||
cookies = get_cookies()
|
||||
except RuntimeError as e:
|
||||
console.print("[red]❌ %s[/red]" % e)
|
||||
sys.exit(1)
|
||||
return TwitterClient(cookies["auth_token"], cookies["ct0"])
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("text")
|
||||
def post(text):
|
||||
# type: (str,) -> None
|
||||
"""Post a new tweet."""
|
||||
client = _get_client()
|
||||
console.print("✏️ Posting tweet...")
|
||||
try:
|
||||
result = client.create_tweet(text)
|
||||
tweet_id = result["tweet_id"]
|
||||
console.print("\n[green]✅ Tweet posted![/green]")
|
||||
console.print(" ID: %s" % tweet_id)
|
||||
console.print(" URL: https://x.com/i/status/%s" % tweet_id)
|
||||
console.print(' Text: "%s"' % result["text"][:100])
|
||||
except RuntimeError as e:
|
||||
console.print("[red]❌ %s[/red]" % e)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("tweet_id")
|
||||
@click.argument("text")
|
||||
def reply(tweet_id, text):
|
||||
# type: (str, str) -> None
|
||||
"""Reply to a tweet."""
|
||||
client = _get_client()
|
||||
console.print("💬 Replying to %s..." % tweet_id)
|
||||
try:
|
||||
result = client.create_tweet(text, reply_to=tweet_id)
|
||||
new_id = result["tweet_id"]
|
||||
console.print("\n[green]✅ Reply posted![/green]")
|
||||
console.print(" ID: %s" % new_id)
|
||||
console.print(" URL: https://x.com/i/status/%s" % new_id)
|
||||
console.print(' Text: "%s"' % result["text"][:100])
|
||||
except RuntimeError as e:
|
||||
console.print("[red]❌ %s[/red]" % e)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("tweet_url")
|
||||
@click.argument("text")
|
||||
def quote(tweet_url, text):
|
||||
# type: (str, str) -> None
|
||||
"""Quote a tweet. TWEET_URL can be a full URL or tweet ID."""
|
||||
# If user passes just an ID, convert to URL
|
||||
if not tweet_url.startswith("http"):
|
||||
tweet_url = "https://x.com/i/status/%s" % tweet_url
|
||||
client = _get_client()
|
||||
console.print("🔄 Quoting %s..." % tweet_url)
|
||||
try:
|
||||
result = client.create_tweet(text, quote_tweet_url=tweet_url)
|
||||
new_id = result["tweet_id"]
|
||||
console.print("\n[green]✅ Quote tweet posted![/green]")
|
||||
console.print(" ID: %s" % new_id)
|
||||
console.print(" URL: https://x.com/i/status/%s" % new_id)
|
||||
console.print(' Text: "%s"' % result["text"][:100])
|
||||
except RuntimeError as e:
|
||||
console.print("[red]❌ %s[/red]" % e)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("tweet_id")
|
||||
@click.confirmation_option(prompt="Are you sure you want to delete this tweet?")
|
||||
def delete(tweet_id):
|
||||
# type: (str,) -> None
|
||||
"""Delete a tweet by ID."""
|
||||
client = _get_client()
|
||||
console.print("🗑️ Deleting tweet %s..." % tweet_id)
|
||||
try:
|
||||
client.delete_tweet(tweet_id)
|
||||
console.print("\n[green]✅ Tweet deleted![/green]")
|
||||
console.print(" ID: %s" % tweet_id)
|
||||
except RuntimeError as e:
|
||||
console.print("[red]❌ %s[/red]" % e)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,16 @@
|
||||
"""Configuration loader — reads config.yaml and merges with defaults.
|
||||
|
||||
Uses a simple built-in YAML parser to avoid adding PyYAML as a dependency.
|
||||
"""
|
||||
"""Configuration loader with YAML parsing and normalization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import copy
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
# Default configuration
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"fetch": {
|
||||
"count": 50,
|
||||
@@ -31,131 +32,118 @@ DEFAULT_CONFIG = {
|
||||
} # type: Dict[str, Any]
|
||||
|
||||
|
||||
def _parse_value(s):
|
||||
# type: (str) -> Union[str, int, float, bool]
|
||||
"""Parse a scalar YAML value."""
|
||||
if s == "true":
|
||||
return True
|
||||
if s == "false":
|
||||
return False
|
||||
# Remove surrounding quotes
|
||||
if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
|
||||
return s[1:-1]
|
||||
# Try number
|
||||
def load_config(config_path=None):
|
||||
# type: (Optional[str]) -> Dict[str, Any]
|
||||
"""Load and normalize config from YAML, merged with defaults."""
|
||||
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||
path = _resolve_config_path(config_path)
|
||||
if not path:
|
||||
return config
|
||||
|
||||
try:
|
||||
if "." in s:
|
||||
return float(s)
|
||||
return int(s)
|
||||
except ValueError:
|
||||
return s
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to read config file %s: %s", path, exc)
|
||||
return config
|
||||
|
||||
try:
|
||||
parsed = yaml.safe_load(raw) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
logger.warning("Failed to parse YAML config %s: %s", path, exc)
|
||||
return config
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
logger.warning("Config root must be a mapping, got %s", type(parsed).__name__)
|
||||
return config
|
||||
|
||||
merged = _deep_merge(config, parsed)
|
||||
return _normalize_config(merged)
|
||||
|
||||
|
||||
def _parse_yaml(text):
|
||||
# type: (str) -> Dict[str, Any]
|
||||
"""Minimal YAML parser for our flat config structure.
|
||||
def _resolve_config_path(config_path):
|
||||
# type: (Optional[str]) -> Optional[Path]
|
||||
"""Find config path from explicit argument or default locations."""
|
||||
if config_path:
|
||||
path = Path(config_path)
|
||||
return path if path.exists() else None
|
||||
|
||||
Supports: scalars, inline arrays [...], indented "- item" arrays,
|
||||
nested objects via indentation.
|
||||
"""
|
||||
result = {} # type: Dict[str, Any]
|
||||
lines = text.split("\n")
|
||||
stack = [{"indent": -1, "obj": result}] # type: List[Dict[str, Any]]
|
||||
|
||||
for line in lines:
|
||||
# Strip comments and trailing whitespace
|
||||
trimmed = re.sub(r"#.*$", "", line).rstrip()
|
||||
if not trimmed or not trimmed.strip():
|
||||
continue
|
||||
|
||||
indent = len(line) - len(line.lstrip())
|
||||
content = trimmed.strip()
|
||||
|
||||
# Handle "- item" array entries
|
||||
if content.startswith("- "):
|
||||
parent = stack[-1]["obj"]
|
||||
keys = list(parent.keys())
|
||||
if keys:
|
||||
last_key = keys[-1]
|
||||
if not isinstance(parent[last_key], list):
|
||||
parent[last_key] = []
|
||||
parent[last_key].append(_parse_value(content[2:].strip()))
|
||||
continue
|
||||
|
||||
colon_idx = content.find(":")
|
||||
if colon_idx == -1:
|
||||
continue
|
||||
|
||||
key = content[:colon_idx].strip()
|
||||
raw_value = content[colon_idx + 1:].strip()
|
||||
|
||||
# Pop stack to find parent at correct indentation
|
||||
while len(stack) > 1 and stack[-1]["indent"] >= indent:
|
||||
stack.pop()
|
||||
parent = stack[-1]["obj"]
|
||||
|
||||
if raw_value == "" or raw_value == "|":
|
||||
# Nested object
|
||||
child = {} # type: Dict[str, Any]
|
||||
parent[key] = child
|
||||
stack.append({"indent": indent, "obj": child})
|
||||
elif raw_value.startswith("[") and raw_value.endswith("]"):
|
||||
# Inline array
|
||||
inner = raw_value[1:-1].strip()
|
||||
if inner == "":
|
||||
parent[key] = []
|
||||
else:
|
||||
parent[key] = [_parse_value(s.strip()) for s in inner.split(",")]
|
||||
else:
|
||||
parent[key] = _parse_value(raw_value)
|
||||
|
||||
return result
|
||||
candidates = [
|
||||
Path.cwd() / "config.yaml",
|
||||
Path(__file__).parent.parent / "config.yaml",
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _deep_merge(target, source):
|
||||
# type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any]
|
||||
# type: (Dict[str, Any], Mapping[str, Any]) -> Dict[str, Any]
|
||||
"""Deep merge source into target (source values override target)."""
|
||||
result = dict(target)
|
||||
for key in source:
|
||||
if (
|
||||
isinstance(source[key], dict)
|
||||
and isinstance(result.get(key), dict)
|
||||
):
|
||||
result[key] = _deep_merge(result[key], source[key])
|
||||
result = copy.deepcopy(target)
|
||||
for key, value in source.items():
|
||||
if isinstance(value, dict) and isinstance(result.get(key), dict):
|
||||
result[key] = _deep_merge(result[key], value)
|
||||
else:
|
||||
result[key] = source[key]
|
||||
result[key] = copy.deepcopy(value)
|
||||
return result
|
||||
|
||||
|
||||
def load_config(config_path=None):
|
||||
# type: (str) -> Dict[str, Any]
|
||||
"""Load config from config.yaml, merged with defaults."""
|
||||
if config_path is None:
|
||||
# Look in current directory first, then script directory
|
||||
candidates = [
|
||||
Path.cwd() / "config.yaml",
|
||||
Path(__file__).parent.parent / "config.yaml",
|
||||
]
|
||||
for p in candidates:
|
||||
if p.exists():
|
||||
config_path = str(p)
|
||||
break
|
||||
def _normalize_config(config):
|
||||
# type: (Dict[str, Any]) -> Dict[str, Any]
|
||||
"""Normalize shape and value types."""
|
||||
normalized = copy.deepcopy(DEFAULT_CONFIG)
|
||||
merged = _deep_merge(normalized, config)
|
||||
|
||||
if config_path and Path(config_path).exists():
|
||||
try:
|
||||
raw = Path(config_path).read_text(encoding="utf-8")
|
||||
parsed = _parse_yaml(raw)
|
||||
config = _deep_merge(DEFAULT_CONFIG, parsed)
|
||||
except Exception:
|
||||
config = dict(DEFAULT_CONFIG)
|
||||
else:
|
||||
config = dict(DEFAULT_CONFIG)
|
||||
fetch = merged.get("fetch")
|
||||
if not isinstance(fetch, dict):
|
||||
fetch = {}
|
||||
fetch_count = _as_int(fetch.get("count"), DEFAULT_CONFIG["fetch"]["count"])
|
||||
fetch["count"] = max(fetch_count, 1)
|
||||
merged["fetch"] = fetch
|
||||
|
||||
# Ensure nested dicts exist
|
||||
config.setdefault("fetch", DEFAULT_CONFIG["fetch"])
|
||||
config.setdefault("filter", DEFAULT_CONFIG["filter"])
|
||||
filter_config = merged.get("filter")
|
||||
if not isinstance(filter_config, dict):
|
||||
filter_config = {}
|
||||
mode = str(filter_config.get("mode", "topN"))
|
||||
if mode not in {"topN", "score", "all"}:
|
||||
mode = "topN"
|
||||
filter_config["mode"] = mode
|
||||
filter_config["topN"] = max(_as_int(filter_config.get("topN"), 20), 1)
|
||||
filter_config["minScore"] = _as_float(filter_config.get("minScore"), 50.0)
|
||||
filter_config["excludeRetweets"] = bool(filter_config.get("excludeRetweets", False))
|
||||
|
||||
# Deep-copy filter weights if needed
|
||||
if "filter" in config and "weights" not in config["filter"]:
|
||||
config["filter"]["weights"] = dict(DEFAULT_CONFIG["filter"]["weights"])
|
||||
langs = filter_config.get("lang", [])
|
||||
if not isinstance(langs, list):
|
||||
langs = []
|
||||
filter_config["lang"] = [str(lang) for lang in langs if str(lang)]
|
||||
|
||||
return config
|
||||
weights = filter_config.get("weights", {})
|
||||
if not isinstance(weights, dict):
|
||||
weights = {}
|
||||
normalized_weights = {}
|
||||
default_weights = DEFAULT_CONFIG["filter"]["weights"]
|
||||
for key, default_value in default_weights.items():
|
||||
normalized_weights[key] = _as_float(weights.get(key), float(default_value))
|
||||
filter_config["weights"] = normalized_weights
|
||||
merged["filter"] = filter_config
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def _as_int(value, default):
|
||||
# type: (Any, int) -> int
|
||||
"""Best-effort int conversion."""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _as_float(value, default):
|
||||
# type: (Any, float) -> float
|
||||
"""Best-effort float conversion."""
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@@ -6,14 +6,14 @@ configurable rules (topN, min score, language, etc.).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import math
|
||||
from typing import Dict, List
|
||||
from typing import Mapping
|
||||
|
||||
from .models import Tweet
|
||||
|
||||
|
||||
# Type alias for filter weights dict
|
||||
FilterWeights = Dict[str, float]
|
||||
FilterWeights = Mapping[str, float]
|
||||
|
||||
DEFAULT_WEIGHTS = {
|
||||
"likes": 1.0,
|
||||
@@ -25,7 +25,7 @@ DEFAULT_WEIGHTS = {
|
||||
|
||||
|
||||
def score_tweet(tweet, weights=None):
|
||||
# type: (Tweet, FilterWeights) -> float
|
||||
# type: (Tweet, Optional[FilterWeights]) -> float
|
||||
"""Calculate engagement score for a single tweet.
|
||||
|
||||
Formula:
|
||||
@@ -35,20 +35,19 @@ def score_tweet(tweet, weights=None):
|
||||
+ w_bookmarks × bookmarks
|
||||
+ w_views_log × log10(views)
|
||||
"""
|
||||
if weights is None:
|
||||
weights = DEFAULT_WEIGHTS
|
||||
weight_map = _build_weights(weights or {})
|
||||
m = tweet.metrics
|
||||
return (
|
||||
weights.get("likes", 1.0) * m.likes
|
||||
+ weights.get("retweets", 3.0) * m.retweets
|
||||
+ weights.get("replies", 2.0) * m.replies
|
||||
+ weights.get("bookmarks", 5.0) * m.bookmarks
|
||||
+ weights.get("views_log", 0.5) * math.log10(max(m.views, 1))
|
||||
weight_map["likes"] * m.likes
|
||||
+ weight_map["retweets"] * m.retweets
|
||||
+ weight_map["replies"] * m.replies
|
||||
+ weight_map["bookmarks"] * m.bookmarks
|
||||
+ weight_map["views_log"] * math.log10(max(m.views, 1))
|
||||
)
|
||||
|
||||
|
||||
def filter_tweets(tweets, config):
|
||||
# type: (List[Tweet], dict) -> List[Tweet]
|
||||
# type: (Sequence[Tweet], Mapping[str, Any]) -> List[Tweet]
|
||||
"""Filter and rank tweets according to config.
|
||||
|
||||
Config keys:
|
||||
@@ -64,27 +63,53 @@ def filter_tweets(tweets, config):
|
||||
# 1. Language filter
|
||||
lang_filter = config.get("lang", [])
|
||||
if lang_filter:
|
||||
filtered = [t for t in filtered if t.lang in lang_filter]
|
||||
lang_set = {str(lang) for lang in lang_filter if str(lang)}
|
||||
filtered = [tweet for tweet in filtered if tweet.lang in lang_set]
|
||||
|
||||
# 2. Exclude retweets
|
||||
if config.get("excludeRetweets", False):
|
||||
filtered = [t for t in filtered if not t.is_retweet]
|
||||
filtered = [tweet for tweet in filtered if not tweet.is_retweet]
|
||||
|
||||
# 3. Score all tweets
|
||||
weights = config.get("weights", DEFAULT_WEIGHTS)
|
||||
for t in filtered:
|
||||
t.score = round(score_tweet(t, weights), 1)
|
||||
weights = _build_weights(config.get("weights", {}))
|
||||
scored = [replace(tweet, score=round(score_tweet(tweet, weights), 1)) for tweet in filtered]
|
||||
|
||||
# 4. Sort by score (descending)
|
||||
filtered.sort(key=lambda t: t.score, reverse=True)
|
||||
scored.sort(key=lambda tweet: tweet.score, reverse=True)
|
||||
|
||||
# 5. Apply filter mode
|
||||
mode = config.get("mode", "topN")
|
||||
mode = str(config.get("mode", "topN"))
|
||||
if mode == "topN":
|
||||
top_n = config.get("topN", 20)
|
||||
return filtered[:top_n]
|
||||
elif mode == "score":
|
||||
min_score = config.get("minScore", 50)
|
||||
return [t for t in filtered if t.score >= min_score]
|
||||
else:
|
||||
return filtered
|
||||
top_n = max(_as_int(config.get("topN"), 20), 1)
|
||||
return scored[:top_n]
|
||||
if mode == "score":
|
||||
min_score = _as_float(config.get("minScore"), 50.0)
|
||||
return [tweet for tweet in scored if tweet.score >= min_score]
|
||||
return scored
|
||||
|
||||
|
||||
def _build_weights(raw_weights):
|
||||
# type: (Mapping[str, Any]) -> Dict[str, float]
|
||||
"""Merge custom weights with defaults and coerce to float."""
|
||||
merged = {}
|
||||
for key, default_value in DEFAULT_WEIGHTS.items():
|
||||
merged[key] = _as_float(raw_weights.get(key), default_value)
|
||||
return merged
|
||||
|
||||
|
||||
def _as_int(value, default):
|
||||
# type: (Any, int) -> int
|
||||
"""Best-effort int conversion."""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _as_float(value, default):
|
||||
# type: (Any, float) -> float
|
||||
"""Best-effort float conversion."""
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
|
||||
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
|
||||
from .serialization import tweets_to_json as _tweets_to_json
|
||||
|
||||
|
||||
def format_number(n):
|
||||
@@ -168,46 +165,7 @@ def print_filter_stats(original_count, filtered, console=None):
|
||||
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)
|
||||
return _tweets_to_json(tweets)
|
||||
|
||||
|
||||
def print_user_profile(user, console=None):
|
||||
|
||||
147
twitter_cli/serialization.py
Normal file
147
twitter_cli/serialization.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Serialization helpers for Tweet models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from .models import Author, Metrics, Tweet, TweetMedia
|
||||
|
||||
|
||||
def tweet_to_dict(tweet: Tweet) -> Dict[str, Any]:
|
||||
"""Convert a Tweet dataclass into a JSON-safe dict."""
|
||||
data = {
|
||||
"id": tweet.id,
|
||||
"text": tweet.text,
|
||||
"author": {
|
||||
"id": tweet.author.id,
|
||||
"name": tweet.author.name,
|
||||
"screenName": tweet.author.screen_name,
|
||||
"profileImageUrl": tweet.author.profile_image_url,
|
||||
"verified": tweet.author.verified,
|
||||
},
|
||||
"metrics": {
|
||||
"likes": tweet.metrics.likes,
|
||||
"retweets": tweet.metrics.retweets,
|
||||
"replies": tweet.metrics.replies,
|
||||
"quotes": tweet.metrics.quotes,
|
||||
"views": tweet.metrics.views,
|
||||
"bookmarks": tweet.metrics.bookmarks,
|
||||
},
|
||||
"createdAt": tweet.created_at,
|
||||
"media": [
|
||||
{
|
||||
"type": media.type,
|
||||
"url": media.url,
|
||||
"width": media.width,
|
||||
"height": media.height,
|
||||
}
|
||||
for media in tweet.media
|
||||
],
|
||||
"urls": list(tweet.urls),
|
||||
"isRetweet": tweet.is_retweet,
|
||||
"retweetedBy": tweet.retweeted_by,
|
||||
"lang": tweet.lang,
|
||||
"score": tweet.score,
|
||||
}
|
||||
if tweet.quoted_tweet:
|
||||
data["quotedTweet"] = {
|
||||
"id": tweet.quoted_tweet.id,
|
||||
"text": tweet.quoted_tweet.text,
|
||||
"author": {
|
||||
"screenName": tweet.quoted_tweet.author.screen_name,
|
||||
"name": tweet.quoted_tweet.author.name,
|
||||
},
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
def tweet_from_dict(data: Dict[str, Any]) -> Tweet:
|
||||
"""Convert a dict into a Tweet dataclass."""
|
||||
author_data = data.get("author") or {}
|
||||
metrics_data = data.get("metrics") or {}
|
||||
media_data = data.get("media") or []
|
||||
quoted_data = data.get("quotedTweet")
|
||||
|
||||
quoted_tweet = None # type: Optional[Tweet]
|
||||
if isinstance(quoted_data, dict):
|
||||
quoted_author = quoted_data.get("author") or {}
|
||||
quoted_tweet = Tweet(
|
||||
id=str(quoted_data.get("id") or ""),
|
||||
text=str(quoted_data.get("text") or ""),
|
||||
author=Author(
|
||||
id="",
|
||||
name=str(quoted_author.get("name") or ""),
|
||||
screen_name=str(quoted_author.get("screenName") or ""),
|
||||
),
|
||||
metrics=Metrics(),
|
||||
created_at="",
|
||||
)
|
||||
|
||||
return Tweet(
|
||||
id=str(data.get("id") or ""),
|
||||
text=str(data.get("text") or ""),
|
||||
author=Author(
|
||||
id=str(author_data.get("id") or ""),
|
||||
name=str(author_data.get("name") or ""),
|
||||
screen_name=str(author_data.get("screenName") or ""),
|
||||
profile_image_url=str(author_data.get("profileImageUrl") or ""),
|
||||
verified=bool(author_data.get("verified", False)),
|
||||
),
|
||||
metrics=Metrics(
|
||||
likes=int(metrics_data.get("likes") or 0),
|
||||
retweets=int(metrics_data.get("retweets") or 0),
|
||||
replies=int(metrics_data.get("replies") or 0),
|
||||
quotes=int(metrics_data.get("quotes") or 0),
|
||||
views=int(metrics_data.get("views") or 0),
|
||||
bookmarks=int(metrics_data.get("bookmarks") or 0),
|
||||
),
|
||||
created_at=str(data.get("createdAt") or ""),
|
||||
media=[
|
||||
TweetMedia(
|
||||
type=str(item.get("type") or ""),
|
||||
url=str(item.get("url") or ""),
|
||||
width=_optional_int(item.get("width")),
|
||||
height=_optional_int(item.get("height")),
|
||||
)
|
||||
for item in media_data
|
||||
if isinstance(item, dict)
|
||||
],
|
||||
urls=[str(url) for url in (data.get("urls") or [])],
|
||||
is_retweet=bool(data.get("isRetweet", False)),
|
||||
lang=str(data.get("lang") or ""),
|
||||
retweeted_by=_optional_str(data.get("retweetedBy")),
|
||||
quoted_tweet=quoted_tweet,
|
||||
score=float(data.get("score") or 0.0),
|
||||
)
|
||||
|
||||
|
||||
def tweets_from_json(raw: str) -> List[Tweet]:
|
||||
"""Parse a JSON string into Tweet objects."""
|
||||
payload = json.loads(raw)
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("Tweet JSON payload must be a list")
|
||||
return [tweet_from_dict(item) for item in payload if isinstance(item, dict)]
|
||||
|
||||
|
||||
def tweets_to_json(tweets: Iterable[Tweet]) -> str:
|
||||
"""Serialize Tweet objects to pretty JSON."""
|
||||
return json.dumps([tweet_to_dict(tweet) for tweet in tweets], ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _optional_int(value: Any) -> Optional[int]:
|
||||
"""Parse an optional integer value."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _optional_str(value: Any) -> Optional[str]:
|
||||
"""Parse an optional string value."""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value)
|
||||
return text if text else None
|
||||
Reference in New Issue
Block a user