refactor: harden CLI/client/config and centralize serialization

This commit is contained in:
jackwener
2026-03-05 16:13:54 +08:00
parent 7238b932ab
commit 4c08d09304
10 changed files with 1145 additions and 1034 deletions

View File

@@ -1,6 +1,6 @@
# Twitter CLI # Twitter CLI
Twitter/X 命令行工具 — 读取 Timeline、管理推文 Twitter/X 命令行工具 — 读取 Timeline、书签和用户信息
**零 API Key** — 使用浏览器 Cookie 认证,免费访问 Twitter。 **零 API Key** — 使用浏览器 Cookie 认证,免费访问 Twitter。
@@ -22,14 +22,17 @@ twitter feed
### 读取 ### 读取
```bash ```bash
# 抓取首页 timeline # 抓取首页 timelineFor You 算法推荐)
twitter feed twitter feed
# 抓取关注的人的 timelineFollowing 时间线)
twitter feed -t following
# 自定义抓取条数 # 自定义抓取条数
twitter feed --max 50 twitter feed --max 50
# 跳过筛选 # 开启筛选(按 score 排序过滤)
twitter feed --no-filter twitter feed --filter
# JSON 输出 # JSON 输出
twitter feed --json > tweets.json twitter feed --json > tweets.json
@@ -51,31 +54,6 @@ twitter user elonmusk
# 列出用户推文 # 列出用户推文
twitter user-posts elonmusk --max 20 twitter user-posts elonmusk --max 20
# 查看粉丝
twitter followers elonmusk --max 30
# 查看关注
twitter following elonmusk --max 30
```
### 发推
```bash
# 发新推文
twitter post "Hello World"
# 回复推文
twitter reply <tweet_id> "这是回复内容"
# 引用转推(传 URL 或 ID 均可)
twitter quote <tweet_url_or_id> "这是引用内容"
# 删除推文(会有确认提示)
twitter delete <tweet_id>
# 跳过删除确认
twitter delete <tweet_id> --yes
``` ```
## Pipeline ## Pipeline
@@ -132,14 +110,28 @@ export TWITTER_CT0=your_ct0
twitter_cli/ twitter_cli/
├── __init__.py # 版本信息 ├── __init__.py # 版本信息
├── cli.py # CLI 入口 (click) ├── cli.py # CLI 入口 (click)
├── client.py # Twitter GraphQL API Client (GET + POST) ├── client.py # Twitter GraphQL API Client (GET)
├── auth.py # Cookie 提取 (env / browser-cookie3) ├── auth.py # Cookie 提取 (env / browser-cookie3)
├── filter.py # Engagement scoring + 筛选 ├── filter.py # Engagement scoring + 筛选
├── formatter.py # Rich 终端输出 + JSON ├── formatter.py # Rich 终端输出 + JSON
├── config.py # YAML 配置加载 ├── config.py # YAML 配置加载
├── serialization.py # Tweet JSON <-> dataclass
└── models.py # 数据模型 (dataclass) └── models.py # 数据模型 (dataclass)
``` ```
## Development
```bash
# Install development tools
uv sync --extra dev
# Run tests
uv run pytest
# Lint
uv run ruff check .
```
## 注意事项 ## 注意事项
- 使用 Cookie 登录存在被平台检测的风险,建议使用**专用小号** - 使用 Cookie 登录存在被平台检测的风险,建议使用**专用小号**

View File

@@ -29,6 +29,13 @@ dependencies = [
"browser-cookie3>=0.19", "browser-cookie3>=0.19",
"click>=8.0", "click>=8.0",
"rich>=13.0", "rich>=13.0",
"PyYAML>=6.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"ruff>=0.8",
] ]
[project.urls] [project.urls]
@@ -38,3 +45,10 @@ Issues = "https://github.com/jackwener/twitter-cli/issues"
[project.scripts] [project.scripts]
twitter = "twitter_cli.cli:cli" twitter = "twitter_cli.cli:cli"
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
[tool.ruff]
line-length = 100

View File

@@ -13,8 +13,9 @@ import os
import ssl import ssl
import subprocess import subprocess
import sys import sys
import urllib.error
import urllib.request import urllib.request
from typing import Any, Dict, Optional from typing import Dict, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -142,7 +143,8 @@ sys.exit(1)
if not output: if not output:
stderr = result.stderr.strip() stderr = result.stderr.strip()
if stderr: 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( result2 = subprocess.run(
["uv", "run", "--with", "browser-cookie3", "python3", "-c", extract_script], ["uv", "run", "--with", "browser-cookie3", "python3", "-c", extract_script],
capture_output=True, capture_output=True,
@@ -151,6 +153,7 @@ sys.exit(1)
) )
output = result2.stdout.strip() output = result2.stdout.strip()
if not output: if not output:
logger.debug("Cookie extraction stderr from uv fallback: %s", result2.stderr.strip()[:300])
return None return None
data = json.loads(output) 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)" "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 return cookies

View File

@@ -1,28 +1,24 @@
"""CLI entry point for twitter-cli. """CLI entry point for twitter-cli.
Usage: 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 --max 50 # custom fetch count
twitter feed --no-filter # skip filtering twitter feed --filter # enable score-based filtering
twitter feed --json # JSON output twitter feed --json # JSON output
twitter favorite # fetch bookmarks twitter favorite # fetch bookmarks
twitter favorite --max 30
twitter feed --input tweets.json # load existing data twitter feed --input tweets.json # load existing data
twitter feed --output out.json # save filtered tweets twitter feed --output out.json # save filtered tweets
twitter post "Hello" # post a tweet twitter user elonmusk # view user profile
twitter reply ID "text" # reply to a tweet twitter user-posts elonmusk # list user tweets
twitter quote ID "text" # quote a tweet
twitter delete ID # delete a tweet
""" """
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
from typing import List
import click import click
from rich.console import Console from rich.console import Console
@@ -32,17 +28,12 @@ from .auth import get_cookies
from .client import TwitterClient 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 print_filter_stats, print_tweet_table, print_user_profile
print_filter_stats, from .serialization import tweets_from_json, tweets_to_json
print_tweet_table,
print_user_profile,
print_user_table,
tweets_to_json,
)
from .models import Author, Metrics, Tweet, TweetMedia
console = Console() console = Console()
FEED_TYPES = ["for-you", "following"]
def _setup_logging(verbose): def _setup_logging(verbose):
@@ -58,70 +49,49 @@ def _setup_logging(verbose):
def _load_tweets_from_json(path): def _load_tweets_from_json(path):
# type: (str) -> List[Tweet] # type: (str) -> List[Tweet]
"""Load tweets from a JSON file (previously exported).""" """Load tweets from a JSON file (previously exported)."""
raw = Path(path).read_text(encoding="utf-8") file_path = Path(path)
items = json.loads(raw) if not file_path.exists():
tweets = [] raise RuntimeError("Input file not found: %s" % path)
for d in items:
author_data = d.get("author", {})
metrics_data = d.get("metrics", {})
media_data = d.get("media", [])
author = Author( try:
id=author_data.get("id", ""), raw = file_path.read_text(encoding="utf-8")
name=author_data.get("name", ""), return tweets_from_json(raw)
screen_name=author_data.get("screenName", ""), except (ValueError, OSError) as exc:
profile_image_url=author_data.get("profileImageUrl", ""), raise RuntimeError("Invalid tweet JSON file %s: %s" % (path, exc))
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
]
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( def _get_client():
id=d.get("id", ""), # type: () -> TwitterClient
text=d.get("text", ""), """Create an authenticated API client."""
author=author, console.print("\n🔐 Getting Twitter cookies...")
metrics=metrics, try:
created_at=d.get("createdAt", ""), cookies = get_cookies()
media=media, except RuntimeError as exc:
urls=d.get("urls", []), raise RuntimeError(str(exc))
is_retweet=d.get("isRetweet", False), return TwitterClient(cookies["auth_token"], cookies["ct0"])
lang=d.get("lang", ""),
retweeted_by=d.get("retweetedBy"),
quoted_tweet=quoted_tweet, def _resolve_fetch_count(max_count, configured):
score=d.get("score", 0.0), # 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 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() @click.group()
@@ -133,107 +103,88 @@ def cli(verbose):
_setup_logging(verbose) _setup_logging(verbose)
# ===== Feed =====
@cli.command() @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("--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("--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("--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("--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.") @click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.")
def feed(max_count, as_json, input_file, output_file, no_filter): def feed(feed_type, max_count, as_json, input_file, output_file, do_filter):
# type: (int, bool, str, str, bool) -> None # type: (str, Optional[int], bool, Optional[str], Optional[str], bool) -> None
"""Fetch home timeline with filtering.""" """Fetch home timeline with optional filtering."""
config = load_config() config = load_config()
try:
# Step 1: Get tweets
if input_file: if input_file:
console.print("📂 Loading tweets from %s..." % input_file) console.print("📂 Loading tweets from %s..." % input_file)
tweets = _load_tweets_from_json(input_file) tweets = _load_tweets_from_json(input_file)
console.print(" Loaded %d tweets" % len(tweets)) console.print(" Loaded %d tweets" % len(tweets))
else: else:
fetch_count = max_count or config.get("fetch", {}).get("count", 50) fetch_count = _resolve_fetch_count(max_count, config.get("fetch", {}).get("count", 50))
console.print("\n🔐 Getting Twitter cookies...") client = _get_client()
try: label = "following feed" if feed_type == "following" else "home timeline"
cookies = get_cookies() console.print("📡 Fetching %s (%d tweets)...\n" % (label, fetch_count))
except RuntimeError as e:
console.print("[red]❌ %s[/red]" % e)
sys.exit(1)
client = TwitterClient(cookies["auth_token"], cookies["ct0"])
console.print("📡 Fetching home timeline (%d tweets)...\n" % fetch_count)
start = time.time() start = time.time()
if feed_type == "following":
tweets = client.fetch_following_feed(fetch_count)
else:
tweets = client.fetch_home_timeline(fetch_count) tweets = client.fetch_home_timeline(fetch_count)
elapsed = time.time() - start elapsed = time.time() - start
console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed)) 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 2: Filter filtered = _apply_filter(tweets, do_filter, config)
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: if output_file:
Path(output_file).write_text(tweets_to_json(filtered), encoding="utf-8") Path(output_file).write_text(tweets_to_json(filtered), encoding="utf-8")
console.print("💾 Saved filtered tweets to %s\n" % output_file) console.print("💾 Saved filtered tweets to %s\n" % output_file)
# Output
if as_json: if as_json:
click.echo(tweets_to_json(filtered)) click.echo(tweets_to_json(filtered))
return 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() console.print()
# ===== Favorite =====
@cli.command() @cli.command()
@click.option("--max", "-n", "max_count", type=int, default=None, help="Max number of tweets to fetch.") @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("--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("--output", "-o", "output_file", type=str, default=None, help="Save tweets to JSON file.")
@click.option("--no-filter", is_flag=True, help="Skip filtering.") @click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.")
def favorite(max_count, as_json, output_file, no_filter): def favorite(max_count, as_json, output_file, do_filter):
# type: (int, bool, str, bool) -> None # type: (Optional[int], bool, Optional[str], bool) -> None
"""Fetch bookmarked (favorite) tweets.""" """Fetch bookmarked (favorite) tweets."""
config = load_config() config = load_config()
fetch_count = max_count or 50
console.print("\n🔐 Getting Twitter cookies...")
try: try:
cookies = get_cookies() fetch_count = _resolve_fetch_count(max_count, config.get("fetch", {}).get("count", 50))
except RuntimeError as e: client = _get_client()
console.print("[red]❌ %s[/red]" % e)
sys.exit(1)
client = TwitterClient(cookies["auth_token"], cookies["ct0"])
console.print("🔖 Fetching favorites (%d tweets)...\n" % fetch_count) console.print("🔖 Fetching favorites (%d tweets)...\n" % fetch_count)
start = time.time() start = time.time()
tweets = client.fetch_bookmarks(fetch_count) tweets = client.fetch_bookmarks(fetch_count)
elapsed = time.time() - start elapsed = time.time() - start
console.print("✅ Fetched %d favorites in %.1fs\n" % (len(tweets), elapsed)) console.print("✅ Fetched %d favorites in %.1fs\n" % (len(tweets), elapsed))
except RuntimeError as exc:
console.print("[red]❌ %s[/red]" % exc)
sys.exit(1)
# Filter filtered = _apply_filter(tweets, do_filter, config)
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: if output_file:
Path(output_file).write_text(tweets_to_json(filtered), encoding="utf-8") Path(output_file).write_text(tweets_to_json(filtered), encoding="utf-8")
console.print("💾 Saved to %s\n" % output_file) console.print("💾 Saved to %s\n" % output_file)
# Output
if as_json: if as_json:
click.echo(tweets_to_json(filtered)) click.echo(tweets_to_json(filtered))
return return
@@ -242,23 +193,22 @@ def favorite(max_count, as_json, output_file, no_filter):
console.print() console.print()
# ===== User =====
@cli.command() @cli.command()
@click.argument("screen_name") @click.argument("screen_name")
def user(screen_name): def user(screen_name):
# type: (str,) -> None # type: (str,) -> None
"""View a user's profile. SCREEN_NAME is the @handle (without @).""" """View a user's profile. SCREEN_NAME is the @handle (without @)."""
screen_name = screen_name.lstrip("@") screen_name = screen_name.lstrip("@")
try:
client = _get_client() client = _get_client()
console.print("👤 Fetching user @%s..." % screen_name) console.print("👤 Fetching user @%s..." % screen_name)
try:
profile = client.fetch_user(screen_name) profile = client.fetch_user(screen_name)
except RuntimeError as exc:
console.print("[red]❌ %s[/red]" % exc)
sys.exit(1)
console.print() console.print()
print_user_profile(profile, console) print_user_profile(profile, console)
except RuntimeError as e:
console.print("[red]❌ %s[/red]" % e)
sys.exit(1)
@cli.command("user-posts") @cli.command("user-posts")
@@ -269,23 +219,19 @@ def user_posts(screen_name, max_count, as_json):
# type: (str, int, bool) -> None # type: (str, int, bool) -> None
"""List a user's tweets. SCREEN_NAME is the @handle (without @).""" """List a user's tweets. SCREEN_NAME is the @handle (without @)."""
screen_name = screen_name.lstrip("@") screen_name = screen_name.lstrip("@")
try:
fetch_count = _resolve_fetch_count(max_count, 20)
client = _get_client() client = _get_client()
console.print("👤 Fetching @%s's profile..." % screen_name) console.print("👤 Fetching @%s's profile..." % screen_name)
try:
profile = client.fetch_user(screen_name) profile = client.fetch_user(screen_name)
except RuntimeError as e: console.print("📝 Fetching tweets (%d)...\n" % fetch_count)
console.print("[red]❌ %s[/red]" % e)
sys.exit(1)
console.print("📝 Fetching tweets (%d)...\n" % max_count)
start = time.time() start = time.time()
try: tweets = client.fetch_user_tweets(profile.id, fetch_count)
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 elapsed = time.time() - start
console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed)) console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed))
except RuntimeError as exc:
console.print("[red]❌ %s[/red]" % exc)
sys.exit(1)
if as_json: if as_json:
click.echo(tweets_to_json(tweets)) click.echo(tweets_to_json(tweets))
@@ -295,148 +241,5 @@ def user_posts(screen_name, max_count, as_json):
console.print() 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__": if __name__ == "__main__":
cli() cli()

View File

@@ -1,9 +1,4 @@
"""Twitter GraphQL API client. """Twitter GraphQL API client."""
Uses the same internal GraphQL endpoint that the Twitter web app uses,
authenticated via cookies (auth_token + ct0). QueryId is resolved
dynamically using a three-tier strategy.
"""
from __future__ import annotations from __future__ import annotations
@@ -12,38 +7,33 @@ import logging
import math import math
import re import re
import ssl import ssl
import urllib.error
import urllib.parse
import urllib.request import urllib.request
from typing import Any, Callable, Dict, List, Optional, Tuple
from .models import Author, Metrics, Tweet, TweetMedia, UserProfile from .models import Author, Metrics, Tweet, TweetMedia, UserProfile
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Public bearer token shared by all Twitter web clients
BEARER_TOKEN = ( BEARER_TOKEN = (
"AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs" "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs"
"%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" "%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
) )
# Last-resort fallback query IDs
FALLBACK_QUERY_IDS = { FALLBACK_QUERY_IDS = {
"HomeTimeline": "c-CzHF1LboFilMpsx4ZCrQ", "HomeTimeline": "c-CzHF1LboFilMpsx4ZCrQ",
"HomeLatestTimeline": "BKB7oi212Fi7kQtCBGE4zA",
"Bookmarks": "VFdMm9iVZxlU6hD86gfW_A", "Bookmarks": "VFdMm9iVZxlU6hD86gfW_A",
"CreateTweet": "oB-5XsHNAbjvARJEc8CZFw",
"DeleteTweet": "VaenaVgh5q5ih7kvyVjgtg",
"UserByScreenName": "1VOOyvKkiI3FMmkeDNxM9A", "UserByScreenName": "1VOOyvKkiI3FMmkeDNxM9A",
"UserTweets": "E3opETHurmVJflFsUBVuUQ", "UserTweets": "E3opETHurmVJflFsUBVuUQ",
"Followers": "IOh4aS6UdGWGJUYTqliQ7Q",
"Following": "zx6e-TLzRkeDO_a7p4b3JQ",
} }
# Community-maintained API definition (auto-updated daily)
TWITTER_OPENAPI_URL = ( TWITTER_OPENAPI_URL = (
"https://raw.githubusercontent.com/fa0311/twitter-openapi/" "https://raw.githubusercontent.com/fa0311/twitter-openapi/"
"main/src/config/placeholder.json" "main/src/config/placeholder.json"
) )
# Default features flags required by the GraphQL endpoint
FEATURES = { FEATURES = {
"rweb_video_screen_enabled": False, "rweb_video_screen_enabled": False,
"profile_label_improvements_pcf_label_in_post_enabled": True, "profile_label_improvements_pcf_label_in_post_enabled": True,
@@ -84,33 +74,50 @@ USER_AGENT = (
"Chrome/131.0.0.0 Safari/537.36" "Chrome/131.0.0.0 Safari/537.36"
) )
# Module-level cache for query IDs
_cached_query_ids = {} # type: Dict[str, str] _cached_query_ids = {} # type: Dict[str, str]
_bundles_scanned = False _bundles_scanned = False
class TwitterAPIError(RuntimeError):
"""Represents HTTP/network errors from Twitter APIs."""
def __init__(self, status_code, message):
# type: (int, str) -> None
super().__init__(message)
self.status_code = status_code
def _create_ssl_context(): def _create_ssl_context():
# type: () -> ssl.SSLContext # type: () -> ssl.SSLContext
"""Create a permissive SSL context for urllib.""" """Create SSL context for urllib."""
ctx = ssl.create_default_context() return ssl.create_default_context()
return ctx
def _url_fetch(url, headers=None): def _url_fetch(url, headers=None):
# type: (str, Optional[Dict[str, str]]) -> str # type: (str, Optional[Dict[str, str]]) -> str
"""Simple URL fetch using urllib.""" """Simple URL fetch for metadata/bootstrap lookups."""
req = urllib.request.Request(url) req = urllib.request.Request(url)
if headers: if headers:
for k, v in headers.items(): for key, value in headers.items():
req.add_header(k, v) req.add_header(key, value)
ctx = _create_ssl_context() with urllib.request.urlopen(req, context=_create_ssl_context(), timeout=30) as response:
with urllib.request.urlopen(req, context=ctx, timeout=30) as resp: return response.read().decode("utf-8")
return resp.read().decode("utf-8")
def _build_graphql_url(query_id, operation_name, variables, features):
# type: (str, str, Dict[str, Any], Dict[str, Any]) -> str
"""Build GraphQL GET URL with encoded variables/features."""
return "https://x.com/i/api/graphql/%s/%s?variables=%s&features=%s" % (
query_id,
operation_name,
urllib.parse.quote(json.dumps(variables, separators=(",", ":"))),
urllib.parse.quote(json.dumps(features, separators=(",", ":"))),
)
def _scan_bundles(): def _scan_bundles():
# type: () -> None # type: () -> None
"""Tier 1: Scan Twitter's main-page JS bundles to extract queryId/operationName pairs.""" """Scan Twitter JS bundles and cache queryId mappings."""
global _bundles_scanned global _bundles_scanned
if _bundles_scanned: if _bundles_scanned:
return return
@@ -118,82 +125,80 @@ def _scan_bundles():
try: try:
html = _url_fetch("https://x.com", {"user-agent": USER_AGENT}) html = _url_fetch("https://x.com", {"user-agent": USER_AGENT})
script_pattern = re.compile( script_pattern = re.compile(
r'(?:src|href)=["\']' r'(?:src|href)=["\']'
r'(https://abs\.twimg\.com/responsive-web/client-web[^"\']+\.js)' r'(https://abs\.twimg\.com/responsive-web/client-web[^"\']+\.js)'
r'["\']' r'["\']'
) )
script_urls = script_pattern.findall(html) script_urls = script_pattern.findall(html)
except Exception as exc: # pragma: no cover - network-dependent branch
logger.warning("Failed to scan JS bundles: %s", exc)
return
for url in script_urls: for script_url in script_urls:
try: try:
js = _url_fetch(url) bundle = _url_fetch(script_url)
op_pattern = re.compile( op_pattern = re.compile(
r'queryId:\s*"([A-Za-z0-9_-]+)"[^}]{0,200}' r'queryId:\s*"([A-Za-z0-9_-]+)"[^}]{0,200}'
r'operationName:\s*"([^"]+)"' r'operationName:\s*"([^"]+)"'
) )
for m in op_pattern.finditer(js): for match in op_pattern.finditer(bundle):
qid, name = m.group(1), m.group(2) query_id, operation_name = match.group(1), match.group(2)
if name not in _cached_query_ids: _cached_query_ids.setdefault(operation_name, query_id)
_cached_query_ids[name] = qid
except Exception: except Exception:
continue continue
count = len(_cached_query_ids) logger.info("Scanned %d JS bundles, cached %d query IDs", len(script_urls), len(_cached_query_ids))
logger.info("Scanned %d JS bundles, found %d operations", len(script_urls), count)
except Exception as e:
logger.warning("Failed to scan JS bundles: %s", e)
def _fetch_from_github(operation_name): def _fetch_from_github(operation_name):
# type: (str) -> Optional[str] # type: (str) -> Optional[str]
"""Tier 2: Fetch queryId from community-maintained twitter-openapi.""" """Fetch queryId from community-maintained twitter-openapi file."""
try: try:
logger.info("Fetching latest queryId from GitHub (twitter-openapi)...") payload = _url_fetch(TWITTER_OPENAPI_URL)
data_str = _url_fetch(TWITTER_OPENAPI_URL) parsed = json.loads(payload)
data = json.loads(data_str) operation = parsed.get(operation_name, {})
op = data.get(operation_name, {}) query_id = operation.get("queryId")
qid = op.get("queryId") if isinstance(query_id, str) and query_id:
if qid: return query_id
logger.info("Found %s queryId from GitHub: %s", operation_name, qid) except Exception as exc: # pragma: no cover - network-dependent branch
return qid logger.debug("GitHub queryId lookup failed: %s", exc)
return None
except Exception as e:
logger.warning("GitHub lookup failed: %s", e)
return None return None
def _resolve_query_id(operation_name): def _invalidate_query_id(operation_name):
# type: (str) -> str # type: (str) -> None
"""Resolve queryId using three-tier strategy: fallback -> GitHub -> bundle scan.""" """Remove a cached queryId for an operation."""
if operation_name in _cached_query_ids: _cached_query_ids.pop(operation_name, None)
return _cached_query_ids[operation_name]
def _resolve_query_id(operation_name, prefer_fallback=True):
# type: (str, bool) -> str
"""Resolve queryId using cache, remote sources, and fallback constants."""
cached = _cached_query_ids.get(operation_name)
if cached:
return cached
# Tier 1: Hardcoded fallback (instant, no network)
fallback = FALLBACK_QUERY_IDS.get(operation_name) fallback = FALLBACK_QUERY_IDS.get(operation_name)
if fallback: if prefer_fallback and fallback:
logger.debug("Using fallback queryId for %s: %s", operation_name, fallback)
_cached_query_ids[operation_name] = fallback _cached_query_ids[operation_name] = fallback
return fallback return fallback
logger.info("Auto-detecting %s queryId (no fallback available)...", operation_name) github_query_id = _fetch_from_github(operation_name)
if github_query_id:
_cached_query_ids[operation_name] = github_query_id
return github_query_id
# Tier 2: GitHub
github_id = _fetch_from_github(operation_name)
if github_id:
_cached_query_ids[operation_name] = github_id
return github_id
# Tier 3: JS bundle scan
_scan_bundles() _scan_bundles()
if operation_name in _cached_query_ids: cached = _cached_query_ids.get(operation_name)
logger.info("Found %s queryId: %s", operation_name, _cached_query_ids[operation_name]) if cached:
return _cached_query_ids[operation_name] return cached
raise RuntimeError( if fallback:
'Cannot resolve queryId for "%s" — all detection methods failed' % operation_name _cached_query_ids[operation_name] = fallback
) return fallback
raise RuntimeError('Cannot resolve queryId for "%s"' % operation_name)
class TwitterClient: class TwitterClient:
@@ -207,230 +212,36 @@ class TwitterClient:
def fetch_home_timeline(self, count=20): def fetch_home_timeline(self, count=20):
# type: (int) -> List[Tweet] # type: (int) -> List[Tweet]
"""Fetch home timeline tweets.""" """Fetch home timeline tweets."""
query_id = _resolve_query_id("HomeTimeline")
return self._fetch_timeline( return self._fetch_timeline(
query_id,
"HomeTimeline", "HomeTimeline",
count, count,
lambda data: _deep_get(data, "data", "home", "home_timeline_urt", "instructions"), lambda data: _deep_get(data, "data", "home", "home_timeline_urt", "instructions"),
) )
def fetch_following_feed(self, count=20):
# type: (int) -> List[Tweet]
"""Fetch chronological following feed."""
return self._fetch_timeline(
"HomeLatestTimeline",
count,
lambda data: _deep_get(data, "data", "home", "home_timeline_urt", "instructions"),
)
def fetch_bookmarks(self, count=50): def fetch_bookmarks(self, count=50):
# type: (int) -> List[Tweet] # type: (int) -> List[Tweet]
"""Fetch bookmarked tweets.""" """Fetch bookmarked tweets."""
query_id = _resolve_query_id("Bookmarks")
def get_instructions(data): def get_instructions(data):
# type: (Any) -> Any # type: (Any) -> Any
result = _deep_get(data, "data", "bookmark_timeline", "timeline", "instructions") instructions = _deep_get(data, "data", "bookmark_timeline", "timeline", "instructions")
if result is None: if instructions is None:
result = _deep_get(data, "data", "bookmark_timeline_v2", "timeline", "instructions") instructions = _deep_get(data, "data", "bookmark_timeline_v2", "timeline", "instructions")
return result return instructions
return self._fetch_timeline(query_id, "Bookmarks", count, get_instructions) return self._fetch_timeline("Bookmarks", count, get_instructions)
def _fetch_timeline(self, query_id, operation_name, count, get_instructions, extra_variables=None):
# type: (str, str, int, Callable, Optional[Dict[str, Any]]) -> List[Tweet]
"""Generic timeline fetcher with pagination and deduplication."""
tweets = [] # type: List[Tweet]
cursor = None # type: Optional[str]
attempts = 0
max_attempts = int(math.ceil(count / 20.0)) + 2
while len(tweets) < count and attempts < max_attempts:
attempts += 1
variables = {
"count": min(count - len(tweets) + 5, 40),
"includePromotedContent": False,
"latestControlAvailable": True,
"requestContext": "launch",
} # type: Dict[str, Any]
if extra_variables:
variables.update(extra_variables)
if cursor:
variables["cursor"] = cursor
url = "https://x.com/i/api/graphql/%s/%s?" % (query_id, operation_name)
url += "variables=%s&features=%s" % (
urllib.request.quote(json.dumps(variables)),
urllib.request.quote(json.dumps(FEATURES)),
)
data = self._api_get(url)
new_tweets, next_cursor = self._parse_timeline_response(data, get_instructions)
seen_ids = {t.id for t in tweets}
for tweet in new_tweets:
if tweet.id not in seen_ids:
tweets.append(tweet)
seen_ids.add(tweet.id)
if not next_cursor or not new_tweets:
break
cursor = next_cursor
return tweets[:count]
def _build_headers(self):
# type: () -> Dict[str, str]
return {
"Authorization": "Bearer %s" % BEARER_TOKEN,
"Cookie": "auth_token=%s; ct0=%s" % (self._auth_token, self._ct0),
"X-Csrf-Token": self._ct0,
"X-Twitter-Active-User": "yes",
"X-Twitter-Auth-Type": "OAuth2Session",
"X-Twitter-Client-Language": "en",
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
"Referer": "https://x.com/home",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9",
}
def _api_get(self, url):
# type: (str) -> Any
"""Make authenticated GET request to Twitter API."""
headers = self._build_headers()
req = urllib.request.Request(url)
for k, v in headers.items():
req.add_header(k, v)
ctx = _create_ssl_context()
try:
with urllib.request.urlopen(req, context=ctx, timeout=30) as resp:
body = resp.read().decode("utf-8")
return json.loads(body)
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
raise RuntimeError("Twitter API error %d: %s" % (e.code, body[:500]))
def _api_post(self, url, payload):
# type: (str, Dict[str, Any]) -> Any
"""Make authenticated POST request to Twitter API."""
headers = self._build_headers()
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, method="POST")
for k, v in headers.items():
req.add_header(k, v)
ctx = _create_ssl_context()
try:
with urllib.request.urlopen(req, context=ctx, timeout=30) as resp:
body = resp.read().decode("utf-8")
return json.loads(body)
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
raise RuntimeError("Twitter API error %d: %s" % (e.code, body[:500]))
def create_tweet(self, text, reply_to=None, quote_tweet_url=None):
# type: (str, Optional[str], Optional[str]) -> Dict[str, Any]
"""Create a tweet, reply, or quote tweet.
Args:
text: Tweet text content.
reply_to: Tweet ID to reply to (optional).
quote_tweet_url: URL of tweet to quote (optional).
Returns:
Dict with tweet_id and text of the created tweet.
"""
query_id = _resolve_query_id("CreateTweet")
url = "https://x.com/i/api/graphql/%s/CreateTweet" % query_id
variables = {
"tweet_text": text,
"dark_request": False,
"media": {"media_entities": [], "possibly_sensitive": False},
"semantic_annotation_ids": [],
} # type: Dict[str, Any]
if reply_to:
variables["reply"] = {
"in_reply_to_tweet_id": reply_to,
"exclude_reply_user_ids": [],
}
if quote_tweet_url:
variables["attachment_url"] = quote_tweet_url
features = {
"communities_web_enable_tweet_community_results_fetch": True,
"c9s_tweet_anatomy_moderator_badge_enabled": True,
"responsive_web_edit_tweet_api_enabled": True,
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
"view_counts_everywhere_api_enabled": True,
"longform_notetweets_consumption_enabled": True,
"responsive_web_twitter_article_tweet_consumption_enabled": True,
"tweet_awards_web_tipping_enabled": False,
"creator_subscriptions_quote_tweet_preview_enabled": False,
"longform_notetweets_rich_text_read_enabled": True,
"longform_notetweets_inline_media_enabled": True,
"articles_preview_enabled": True,
"rweb_video_timestamps_enabled": True,
"rweb_tipjar_consumption_enabled": True,
"responsive_web_graphql_exclude_directive_enabled": True,
"verified_phone_label_enabled": False,
"freedom_of_speech_not_reach_fetch_enabled": True,
"standardized_nudges_misinfo": True,
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
"responsive_web_graphql_timeline_navigation_enabled": True,
"responsive_web_enhance_cards_enabled": False,
}
payload = {
"variables": variables,
"features": features,
"queryId": query_id,
}
data = self._api_post(url, payload)
# Parse response
result = _deep_get(data, "data", "create_tweet", "tweet_results", "result")
if not result:
errors = data.get("errors", [])
if errors:
raise RuntimeError("CreateTweet failed: %s" % errors[0].get("message", str(errors)))
raise RuntimeError("CreateTweet failed: unexpected response")
tweet_id = result.get("rest_id", "")
tweet_text = _deep_get(result, "legacy", "full_text") or text
return {"tweet_id": tweet_id, "text": tweet_text}
def delete_tweet(self, tweet_id):
# type: (str) -> bool
"""Delete a tweet by ID.
Returns:
True if deletion was successful.
"""
query_id = _resolve_query_id("DeleteTweet")
url = "https://x.com/i/api/graphql/%s/DeleteTweet" % query_id
payload = {
"variables": {"tweet_id": tweet_id, "dark_request": False},
"queryId": query_id,
}
data = self._api_post(url, payload)
# Check response
result = _deep_get(data, "data", "delete_tweet", "tweet_results")
if result is not None:
return True
errors = data.get("errors", [])
if errors:
raise RuntimeError("DeleteTweet failed: %s" % errors[0].get("message", str(errors)))
# Some successful deletions return empty result
return True
def fetch_user(self, screen_name): def fetch_user(self, screen_name):
# type: (str) -> UserProfile # type: (str) -> UserProfile
"""Fetch user profile by screen name.""" """Fetch user profile by screen name."""
query_id = _resolve_query_id("UserByScreenName")
variables = { variables = {
"screen_name": screen_name, "screen_name": screen_name,
"withSafetyModeUserFields": True, "withSafetyModeUserFields": True,
@@ -449,14 +260,7 @@ class TwitterClient:
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False, "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
"responsive_web_graphql_timeline_navigation_enabled": True, "responsive_web_graphql_timeline_navigation_enabled": True,
} }
data = self._graphql_get("UserByScreenName", variables, features)
url = "https://x.com/i/api/graphql/%s/UserByScreenName?" % query_id
url += "variables=%s&features=%s" % (
urllib.request.quote(json.dumps(variables)),
urllib.request.quote(json.dumps(features)),
)
data = self._api_get(url)
result = _deep_get(data, "data", "user", "result") result = _deep_get(data, "data", "user", "result")
if not result: if not result:
raise RuntimeError("User @%s not found" % screen_name) raise RuntimeError("User @%s not found" % screen_name)
@@ -474,10 +278,10 @@ class TwitterClient:
if legacy.get("entities", {}).get("url") if legacy.get("entities", {}).get("url")
else "" else ""
), ),
followers_count=legacy.get("followers_count", 0), followers_count=_to_int(legacy.get("followers_count"), 0),
following_count=legacy.get("friends_count", 0), following_count=_to_int(legacy.get("friends_count"), 0),
tweets_count=legacy.get("statuses_count", 0), tweets_count=_to_int(legacy.get("statuses_count"), 0),
likes_count=legacy.get("favourites_count", 0), likes_count=_to_int(legacy.get("favourites_count"), 0),
verified=bool(result.get("is_blue_verified") or legacy.get("verified", False)), verified=bool(result.get("is_blue_verified") or legacy.get("verified", False)),
profile_image_url=legacy.get("profile_image_url_https", ""), profile_image_url=legacy.get("profile_image_url_https", ""),
created_at=legacy.get("created_at", ""), created_at=legacy.get("created_at", ""),
@@ -486,9 +290,7 @@ class TwitterClient:
def fetch_user_tweets(self, user_id, count=20): def fetch_user_tweets(self, user_id, count=20):
# type: (str, int) -> List[Tweet] # type: (str, int) -> List[Tweet]
"""Fetch tweets posted by a user.""" """Fetch tweets posted by a user."""
query_id = _resolve_query_id("UserTweets")
return self._fetch_timeline( return self._fetch_timeline(
query_id,
"UserTweets", "UserTweets",
count, count,
lambda data: _deep_get(data, "data", "user", "result", "timeline_v2", "timeline", "instructions"), lambda data: _deep_get(data, "data", "user", "result", "timeline_v2", "timeline", "instructions"),
@@ -500,239 +302,285 @@ class TwitterClient:
}, },
) )
def fetch_followers(self, user_id, count=20): def _fetch_timeline(self, operation_name, count, get_instructions, extra_variables=None):
# type: (str, int) -> List[UserProfile] # type: (str, int, Callable[[Any], Any], Optional[Dict[str, Any]]) -> List[Tweet]
"""Fetch user's followers.""" """Generic timeline fetcher with pagination and deduplication."""
query_id = _resolve_query_id("Followers") if count <= 0:
return self._fetch_user_list(query_id, "Followers", user_id, count) return []
def fetch_following(self, user_id, count=20): tweets = [] # type: List[Tweet]
# type: (str, int) -> List[UserProfile] seen_ids = set() # type: Set[str]
"""Fetch users that this user follows.""" cursor = None # type: Optional[str]
query_id = _resolve_query_id("Following") attempts = 0
return self._fetch_user_list(query_id, "Following", user_id, count) max_attempts = int(math.ceil(count / 20.0)) + 2
def _fetch_user_list(self, query_id, operation_name, user_id, count): while len(tweets) < count and attempts < max_attempts:
# type: (str, str, str, int) -> List[UserProfile] attempts += 1
"""Generic user list fetcher (followers/following)."""
variables = { variables = {
"userId": user_id, "count": min(count - len(tweets) + 5, 40),
"count": min(count, 50),
"includePromotedContent": False, "includePromotedContent": False,
"latestControlAvailable": True,
"requestContext": "launch",
} # type: Dict[str, Any] } # type: Dict[str, Any]
if extra_variables:
variables.update(extra_variables)
if cursor:
variables["cursor"] = cursor
url = "https://x.com/i/api/graphql/%s/%s?" % (query_id, operation_name) data = self._graphql_get(operation_name, variables, FEATURES)
url += "variables=%s&features=%s" % ( new_tweets, next_cursor = self._parse_timeline_response(data, get_instructions)
urllib.request.quote(json.dumps(variables)),
urllib.request.quote(json.dumps(FEATURES)),
)
data = self._api_get(url) for tweet in new_tweets:
users = [] # type: List[UserProfile] if tweet.id and tweet.id not in seen_ids:
seen_ids.add(tweet.id)
tweets.append(tweet)
instructions = _deep_get(data, "data", "user", "result", "timeline", "timeline", "instructions") if not next_cursor or not new_tweets:
if not isinstance(instructions, list): break
return users cursor = next_cursor
for instruction in instructions: return tweets[:count]
entries = instruction.get("entries", [])
for entry in entries:
content = entry.get("content", {})
item_content = content.get("itemContent", {})
user_results = item_content.get("user_results", {}).get("result")
if not user_results:
continue
legacy = user_results.get("legacy", {})
core = user_results.get("core", {})
if not legacy:
continue
users.append(UserProfile(
id=user_results.get("rest_id", ""),
name=core.get("name") or legacy.get("name", ""),
screen_name=core.get("screen_name") or legacy.get("screen_name", ""),
bio=legacy.get("description", ""),
followers_count=legacy.get("followers_count", 0),
following_count=legacy.get("friends_count", 0),
verified=bool(user_results.get("is_blue_verified") or legacy.get("verified", False)),
profile_image_url=legacy.get("profile_image_url_https", ""),
))
return users[:count] def _graphql_get(self, operation_name, variables, features):
# type: (str, Dict[str, Any], Dict[str, Any]) -> Dict[str, Any]
"""Issue GraphQL GET request with automatic stale-fallback retry."""
query_id = _resolve_query_id(operation_name, prefer_fallback=True)
using_fallback = query_id == FALLBACK_QUERY_IDS.get(operation_name)
url = _build_graphql_url(query_id, operation_name, variables, features)
try:
return self._api_get(url)
except TwitterAPIError as exc:
# Fallback query IDs can go stale. Retry with live lookup if 404.
if exc.status_code == 404 and using_fallback:
logger.info("Retrying %s with live queryId after 404", operation_name)
_invalidate_query_id(operation_name)
refreshed_query_id = _resolve_query_id(operation_name, prefer_fallback=False)
retry_url = _build_graphql_url(refreshed_query_id, operation_name, variables, features)
return self._api_get(retry_url)
raise RuntimeError(str(exc))
def _build_headers(self):
# type: () -> Dict[str, str]
"""Build shared headers for authenticated API calls."""
return {
"Authorization": "Bearer %s" % BEARER_TOKEN,
"Cookie": "auth_token=%s; ct0=%s" % (self._auth_token, self._ct0),
"X-Csrf-Token": self._ct0,
"X-Twitter-Active-User": "yes",
"X-Twitter-Auth-Type": "OAuth2Session",
"X-Twitter-Client-Language": "en",
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
"Referer": "https://x.com/home",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9",
}
def _api_get(self, url):
# type: (str) -> Dict[str, Any]
"""Make authenticated GET request to Twitter API."""
headers = self._build_headers()
request = urllib.request.Request(url)
for key, value in headers.items():
request.add_header(key, value)
try:
with urllib.request.urlopen(request, context=_create_ssl_context(), timeout=30) as response:
payload = response.read().decode("utf-8")
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
message = "Twitter API error %d: %s" % (exc.code, body[:500])
raise TwitterAPIError(exc.code, message)
except urllib.error.URLError as exc:
raise TwitterAPIError(0, "Twitter API network error: %s" % exc.reason)
try:
parsed = json.loads(payload)
except json.JSONDecodeError:
raise TwitterAPIError(0, "Twitter API returned invalid JSON")
if isinstance(parsed, dict) and parsed.get("errors"):
message = parsed["errors"][0].get("message", "Unknown error")
raise TwitterAPIError(0, "Twitter API returned errors: %s" % message)
return parsed
def _parse_timeline_response(self, data, get_instructions): def _parse_timeline_response(self, data, get_instructions):
# type: (Any, Callable) -> Tuple[List[Tweet], Optional[str]] # type: (Any, Callable[[Any], Any]) -> Tuple[List[Tweet], Optional[str]]
"""Parse timeline GraphQL response into tweets + next cursor.""" """Parse timeline GraphQL response into tweets and next cursor."""
tweets = [] # type: List[Tweet] tweets = [] # type: List[Tweet]
next_cursor = None # type: Optional[str] next_cursor = None # type: Optional[str]
try:
instructions = get_instructions(data) instructions = get_instructions(data)
if not isinstance(instructions, list): if not isinstance(instructions, list):
logger.warning("No instructions found in response") logger.warning("No timeline instructions found")
return tweets, next_cursor return tweets, next_cursor
for instruction in instructions: for instruction in instructions:
entries = instruction.get("entries") or instruction.get("moduleItems") or [] entries = instruction.get("entries") or instruction.get("moduleItems") or []
for entry in entries: for entry in entries:
content = entry.get("content", {}) content = entry.get("content", {})
next_cursor = _extract_cursor(content) or next_cursor
# Handle cursor entries
if content.get("cursorType") == "Bottom" or content.get("entryType") == "TimelineTimelineCursor":
val = content.get("value")
if val:
next_cursor = val
continue
# Handle single tweet entries
item_content = content.get("itemContent", {}) item_content = content.get("itemContent", {})
tweet_results = item_content.get("tweet_results", {}) result = _deep_get(item_content, "tweet_results", "result")
result = tweet_results.get("result")
if result: if result:
tweet = self._parse_tweet_result(result) tweet = self._parse_tweet_result(result)
if tweet: if tweet:
tweets.append(tweet) tweets.append(tweet)
# Handle conversation module (tweet threads) for nested_item in content.get("items", []):
items = content.get("items", []) nested_result = _deep_get(
for item in items: nested_item,
nested = ( "item",
item.get("item", {}) "itemContent",
.get("itemContent", {}) "tweet_results",
.get("tweet_results", {}) "result",
.get("result")
) )
if nested: if nested_result:
tweet = self._parse_tweet_result(nested) tweet = self._parse_tweet_result(nested_result)
if tweet: if tweet:
tweets.append(tweet) tweets.append(tweet)
except Exception as e:
logger.warning("Error parsing timeline response: %s", e)
return tweets, next_cursor return tweets, next_cursor
def _parse_tweet_result(self, result): def _parse_tweet_result(self, result, depth=0):
# type: (Dict[str, Any]) -> Optional[Tweet] # type: (Dict[str, Any], int) -> Optional[Tweet]
"""Parse a single TweetResult from GraphQL response.""" """Parse a single TweetResult into a Tweet dataclass."""
try: if depth > 2:
tweet_data = result return None
# Handle TweetWithVisibilityResults wrapper tweet_data = result
if result.get("__typename") == "TweetWithVisibilityResults" and result.get("tweet"): if result.get("__typename") == "TweetWithVisibilityResults" and result.get("tweet"):
tweet_data = result["tweet"] tweet_data = result["tweet"]
if tweet_data.get("__typename") == "TweetTombstone": if tweet_data.get("__typename") == "TweetTombstone":
return None return None
if not tweet_data.get("legacy") or not tweet_data.get("core"):
legacy = tweet_data.get("legacy")
core = tweet_data.get("core")
if not isinstance(legacy, dict) or not isinstance(core, dict):
return None return None
legacy = tweet_data["legacy"] user = _deep_get(core, "user_results", "result") or {}
user = tweet_data["core"]["user_results"]["result"]
user_legacy = user.get("legacy", {}) user_legacy = user.get("legacy", {})
user_core = user.get("core", {}) user_core = user.get("core", {})
# Check if this is a retweet is_retweet = bool(_deep_get(legacy, "retweeted_status_result", "result"))
is_retweet = bool(legacy.get("retweeted_status_result", {}).get("result"))
actual_data = tweet_data actual_data = tweet_data
actual_legacy = legacy actual_legacy = legacy
actual_user = user actual_user = user
actual_user_legacy = user_legacy actual_user_legacy = user_legacy
if is_retweet: if is_retweet:
rt_result = legacy["retweeted_status_result"]["result"] retweet_result = _deep_get(legacy, "retweeted_status_result", "result") or {}
# Handle wrapped retweet if retweet_result.get("__typename") == "TweetWithVisibilityResults" and retweet_result.get("tweet"):
if rt_result.get("__typename") == "TweetWithVisibilityResults" and rt_result.get("tweet"): retweet_result = retweet_result["tweet"]
rt_result = rt_result["tweet"] rt_legacy = retweet_result.get("legacy")
if rt_result.get("legacy") and rt_result.get("core"): rt_core = retweet_result.get("core")
actual_data = rt_result if isinstance(rt_legacy, dict) and isinstance(rt_core, dict):
actual_legacy = rt_result["legacy"] actual_data = retweet_result
actual_user = rt_result["core"]["user_results"]["result"] actual_legacy = rt_legacy
actual_user = _deep_get(rt_core, "user_results", "result") or {}
actual_user_legacy = actual_user.get("legacy", {}) actual_user_legacy = actual_user.get("legacy", {})
# Parse media
media = [] # type: List[TweetMedia] media = [] # type: List[TweetMedia]
ext_media = actual_legacy.get("extended_entities", {}).get("media", []) for media_item in _deep_get(actual_legacy, "extended_entities", "media") or []:
for m in ext_media: media_type = media_item.get("type", "")
m_type = m.get("type", "") if media_type == "photo":
if m_type == "photo": media.append(
media.append(TweetMedia( TweetMedia(
type="photo", type="photo",
url=m.get("media_url_https", ""), url=media_item.get("media_url_https", ""),
width=_deep_get(m, "original_info", "width"), width=_deep_get(media_item, "original_info", "width"),
height=_deep_get(m, "original_info", "height"), height=_deep_get(media_item, "original_info", "height"),
)) )
elif m_type in ("video", "animated_gif"): )
variants = m.get("video_info", {}).get("variants", []) elif media_type in {"video", "animated_gif"}:
mp4_variants = [v for v in variants if v.get("content_type") == "video/mp4"] variants = media_item.get("video_info", {}).get("variants", [])
mp4_variants.sort(key=lambda v: v.get("bitrate", 0), reverse=True) mp4_variants = [item for item in variants if item.get("content_type") == "video/mp4"]
video_url = mp4_variants[0]["url"] if mp4_variants else m.get("media_url_https", "") mp4_variants.sort(key=lambda item: item.get("bitrate", 0), reverse=True)
media.append(TweetMedia( media.append(
type=m_type, TweetMedia(
url=video_url, type=media_type,
width=_deep_get(m, "original_info", "width"), url=mp4_variants[0]["url"] if mp4_variants else media_item.get("media_url_https", ""),
height=_deep_get(m, "original_info", "height"), width=_deep_get(media_item, "original_info", "width"),
)) height=_deep_get(media_item, "original_info", "height"),
)
)
# Parse URLs urls = [item.get("expanded_url", "") for item in _deep_get(actual_legacy, "entities", "urls") or []]
urls = [u.get("expanded_url", "") for u in actual_legacy.get("entities", {}).get("urls", [])] quoted = _deep_get(actual_data, "quoted_status_result", "result")
quoted_tweet = self._parse_tweet_result(quoted, depth=depth + 1) if isinstance(quoted, dict) else None
# Parse quoted tweet actual_user_core = actual_user.get("core", {})
quoted_tweet = None # type: Optional[Tweet] user_name = actual_user_core.get("name") or actual_user_legacy.get("name") or actual_user.get("name", "Unknown")
quoted_result = actual_data.get("quoted_status_result", {}).get("result") user_screen_name = (
if quoted_result: actual_user_core.get("screen_name")
quoted_tweet = self._parse_tweet_result(quoted_result) or actual_user_legacy.get("screen_name")
or actual_user.get("screen_name", "unknown")
# Extract user info — try user.core (new API), then user.legacy (old API) )
au = actual_user user_profile_image = actual_user.get("avatar", {}).get("image_url") or actual_user_legacy.get("profile_image_url_https", "")
aul = actual_user_legacy user_verified = bool(actual_user.get("is_blue_verified") or actual_user_legacy.get("verified", False))
auc = au.get("core", {}) retweeted_by = None # type: Optional[str]
user_name = auc.get("name") or aul.get("name") or au.get("name", "Unknown")
user_screen_name = auc.get("screen_name") or aul.get("screen_name") or au.get("screen_name", "unknown")
user_profile_image = au.get("avatar", {}).get("image_url") or aul.get("profile_image_url_https", "")
user_verified = au.get("is_blue_verified") or aul.get("verified", False)
# Retweeted by info
rt_screen_name = None # type: Optional[str]
if is_retweet: if is_retweet:
rt_screen_name = user_core.get("screen_name") or user_legacy.get("screen_name", "unknown") retweeted_by = user_core.get("screen_name") or user_legacy.get("screen_name", "unknown")
return Tweet( return Tweet(
id=actual_data.get("rest_id", ""), id=actual_data.get("rest_id", ""),
text=actual_legacy.get("full_text", ""), text=actual_legacy.get("full_text", ""),
author=Author( author=Author(
id=au.get("rest_id", ""), id=actual_user.get("rest_id", ""),
name=user_name, name=user_name,
screen_name=user_screen_name, screen_name=user_screen_name,
profile_image_url=user_profile_image, profile_image_url=user_profile_image,
verified=bool(user_verified), verified=user_verified,
), ),
metrics=Metrics( metrics=Metrics(
likes=actual_legacy.get("favorite_count", 0), likes=_to_int(actual_legacy.get("favorite_count"), 0),
retweets=actual_legacy.get("retweet_count", 0), retweets=_to_int(actual_legacy.get("retweet_count"), 0),
replies=actual_legacy.get("reply_count", 0), replies=_to_int(actual_legacy.get("reply_count"), 0),
quotes=actual_legacy.get("quote_count", 0), quotes=_to_int(actual_legacy.get("quote_count"), 0),
views=int(actual_data.get("views", {}).get("count", "0") or "0"), views=_to_int(_deep_get(actual_data, "views", "count"), 0),
bookmarks=actual_legacy.get("bookmark_count", 0), bookmarks=_to_int(actual_legacy.get("bookmark_count"), 0),
), ),
created_at=actual_legacy.get("created_at", ""), created_at=actual_legacy.get("created_at", ""),
media=media, media=media,
urls=urls, urls=urls,
is_retweet=is_retweet, is_retweet=is_retweet,
retweeted_by=rt_screen_name, retweeted_by=retweeted_by,
quoted_tweet=quoted_tweet, quoted_tweet=quoted_tweet,
lang=actual_legacy.get("lang", ""), lang=actual_legacy.get("lang", ""),
) )
except Exception as e:
logger.warning("Failed to parse tweet: %s", e)
return None
def _deep_get(d, *keys): def _deep_get(data, *keys):
# type: (Any, *str) -> Any # type: (Any, *str) -> Any
"""Safely get a nested value from a dict.""" """Safely get nested dict values."""
current = data
for key in keys: for key in keys:
if isinstance(d, dict): if not isinstance(current, dict):
d = d.get(key)
else:
return None return None
return d current = current.get(key)
return current
def _extract_cursor(content):
# type: (Dict[str, Any]) -> Optional[str]
"""Extract pagination cursor from timeline content."""
if content.get("cursorType") == "Bottom":
return content.get("value")
if content.get("entryType") == "TimelineTimelineCursor":
return content.get("value")
return None
def _to_int(value, default):
# type: (Any, int) -> int
"""Best-effort integer conversion."""
try:
text = str(value).replace(",", "").strip()
if not text:
return default
return int(float(text))
except (TypeError, ValueError):
return default

View File

@@ -1,15 +1,16 @@
"""Configuration loader — reads config.yaml and merges with defaults. """Configuration loader with YAML parsing and normalization."""
Uses a simple built-in YAML parser to avoid adding PyYAML as a dependency.
"""
from __future__ import annotations from __future__ import annotations
import re import copy
import logging
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Union
# Default configuration import yaml
logger = logging.getLogger(__name__)
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
"fetch": { "fetch": {
"count": 50, "count": 50,
@@ -31,131 +32,118 @@ DEFAULT_CONFIG = {
} # type: Dict[str, Any] } # 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
try:
if "." in s:
return float(s)
return int(s)
except ValueError:
return s
def _parse_yaml(text):
# type: (str) -> Dict[str, Any]
"""Minimal YAML parser for our flat config structure.
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
def _deep_merge(target, source):
# type: (Dict[str, Any], Dict[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])
else:
result[key] = source[key]
return result
def load_config(config_path=None): def load_config(config_path=None):
# type: (str) -> Dict[str, Any] # type: (Optional[str]) -> Dict[str, Any]
"""Load config from config.yaml, merged with defaults.""" """Load and normalize config from YAML, merged with defaults."""
if config_path is None: config = copy.deepcopy(DEFAULT_CONFIG)
# Look in current directory first, then script directory path = _resolve_config_path(config_path)
if not path:
return config
try:
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 _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
candidates = [ candidates = [
Path.cwd() / "config.yaml", Path.cwd() / "config.yaml",
Path(__file__).parent.parent / "config.yaml", Path(__file__).parent.parent / "config.yaml",
] ]
for p in candidates: for candidate in candidates:
if p.exists(): if candidate.exists():
config_path = str(p) return candidate
break return None
if config_path and Path(config_path).exists():
try: def _deep_merge(target, source):
raw = Path(config_path).read_text(encoding="utf-8") # type: (Dict[str, Any], Mapping[str, Any]) -> Dict[str, Any]
parsed = _parse_yaml(raw) """Deep merge source into target (source values override target)."""
config = _deep_merge(DEFAULT_CONFIG, parsed) result = copy.deepcopy(target)
except Exception: for key, value in source.items():
config = dict(DEFAULT_CONFIG) if isinstance(value, dict) and isinstance(result.get(key), dict):
result[key] = _deep_merge(result[key], value)
else: else:
config = dict(DEFAULT_CONFIG) result[key] = copy.deepcopy(value)
return result
# Ensure nested dicts exist
config.setdefault("fetch", DEFAULT_CONFIG["fetch"])
config.setdefault("filter", DEFAULT_CONFIG["filter"])
# Deep-copy filter weights if needed def _normalize_config(config):
if "filter" in config and "weights" not in config["filter"]: # type: (Dict[str, Any]) -> Dict[str, Any]
config["filter"]["weights"] = dict(DEFAULT_CONFIG["filter"]["weights"]) """Normalize shape and value types."""
normalized = copy.deepcopy(DEFAULT_CONFIG)
merged = _deep_merge(normalized, config)
return 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
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))
langs = filter_config.get("lang", [])
if not isinstance(langs, list):
langs = []
filter_config["lang"] = [str(lang) for lang in langs if str(lang)]
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

View File

@@ -6,14 +6,14 @@ configurable rules (topN, min score, language, etc.).
from __future__ import annotations from __future__ import annotations
from dataclasses import replace
import math import math
from typing import Dict, List from typing import Mapping
from .models import Tweet
# Type alias for filter weights dict # Type alias for filter weights dict
FilterWeights = Dict[str, float] FilterWeights = Mapping[str, float]
DEFAULT_WEIGHTS = { DEFAULT_WEIGHTS = {
"likes": 1.0, "likes": 1.0,
@@ -25,7 +25,7 @@ DEFAULT_WEIGHTS = {
def score_tweet(tweet, weights=None): def score_tweet(tweet, weights=None):
# type: (Tweet, FilterWeights) -> float # type: (Tweet, Optional[FilterWeights]) -> float
"""Calculate engagement score for a single tweet. """Calculate engagement score for a single tweet.
Formula: Formula:
@@ -35,20 +35,19 @@ def score_tweet(tweet, weights=None):
+ w_bookmarks × bookmarks + w_bookmarks × bookmarks
+ w_views_log × log10(views) + w_views_log × log10(views)
""" """
if weights is None: weight_map = _build_weights(weights or {})
weights = DEFAULT_WEIGHTS
m = tweet.metrics m = tweet.metrics
return ( return (
weights.get("likes", 1.0) * m.likes weight_map["likes"] * m.likes
+ weights.get("retweets", 3.0) * m.retweets + weight_map["retweets"] * m.retweets
+ weights.get("replies", 2.0) * m.replies + weight_map["replies"] * m.replies
+ weights.get("bookmarks", 5.0) * m.bookmarks + weight_map["bookmarks"] * m.bookmarks
+ weights.get("views_log", 0.5) * math.log10(max(m.views, 1)) + weight_map["views_log"] * math.log10(max(m.views, 1))
) )
def filter_tweets(tweets, config): 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. """Filter and rank tweets according to config.
Config keys: Config keys:
@@ -64,27 +63,53 @@ def filter_tweets(tweets, config):
# 1. Language filter # 1. Language filter
lang_filter = config.get("lang", []) lang_filter = config.get("lang", [])
if lang_filter: 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 # 2. Exclude retweets
if config.get("excludeRetweets", False): 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 # 3. Score all tweets
weights = config.get("weights", DEFAULT_WEIGHTS) weights = _build_weights(config.get("weights", {}))
for t in filtered: scored = [replace(tweet, score=round(score_tweet(tweet, weights), 1)) for tweet in filtered]
t.score = round(score_tweet(t, weights), 1)
# 4. Sort by score (descending) # 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 # 5. Apply filter mode
mode = config.get("mode", "topN") mode = str(config.get("mode", "topN"))
if mode == "topN": if mode == "topN":
top_n = config.get("topN", 20) top_n = max(_as_int(config.get("topN"), 20), 1)
return filtered[:top_n] return scored[:top_n]
elif mode == "score": if mode == "score":
min_score = config.get("minScore", 50) min_score = _as_float(config.get("minScore"), 50.0)
return [t for t in filtered if t.score >= min_score] return [tweet for tweet in scored if tweet.score >= min_score]
else: return scored
return filtered
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

View File

@@ -2,15 +2,12 @@
from __future__ import annotations from __future__ import annotations
import json
from typing import List, Optional
from rich.console import Console from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
from rich.table import Table 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): def format_number(n):
@@ -168,46 +165,7 @@ def print_filter_stats(original_count, filtered, console=None):
def tweets_to_json(tweets): def tweets_to_json(tweets):
# type: (List[Tweet]) -> str # type: (List[Tweet]) -> str
"""Export tweets as JSON string.""" """Export tweets as JSON string."""
result = [] return _tweets_to_json(tweets)
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): def print_user_profile(user, console=None):

View 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

331
uv.lock generated
View File

@@ -63,6 +63,44 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
] ]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version == '3.9.*'",
"python_full_version < '3.9'",
]
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]] [[package]]
name = "jeepney" name = "jeepney"
version = "0.9.0" version = "0.9.0"
@@ -226,6 +264,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
] ]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.9'",
]
sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.10'",
"python_full_version == '3.9.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]] [[package]]
name = "pycryptodomex" name = "pycryptodomex"
version = "3.23.0" version = "3.23.0"
@@ -275,6 +347,68 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
] ]
[[package]]
name = "pytest"
version = "8.3.5"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.9'",
]
dependencies = [
{ name = "colorama", marker = "python_full_version < '3.9' and sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.9'" },
{ name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "packaging", marker = "python_full_version < '3.9'" },
{ name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "tomli", marker = "python_full_version < '3.9'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" },
]
[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version == '3.9.*'",
]
dependencies = [
{ name = "colorama", marker = "python_full_version == '3.9.*' and sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version == '3.9.*'" },
{ name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
{ name = "packaging", marker = "python_full_version == '3.9.*'" },
{ name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
{ name = "pygments", marker = "python_full_version == '3.9.*'" },
{ name = "tomli", marker = "python_full_version == '3.9.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.10'",
]
dependencies = [
{ name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
{ name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "packaging", marker = "python_full_version >= '3.10'" },
{ name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "pygments", marker = "python_full_version >= '3.10'" },
{ name = "tomli", marker = "python_full_version == '3.10.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]] [[package]]
name = "pywin32" name = "pywin32"
version = "311" version = "311"
@@ -302,6 +436,86 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/60/22/e0e8d802f124772cec9c75430b01a212f86f9de7546bda715e54140d5aeb/pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d", size = 8778162, upload-time = "2025-07-14T20:13:03.544Z" }, { url = "https://files.pythonhosted.org/packages/60/22/e0e8d802f124772cec9c75430b01a212f86f9de7546bda715e54140d5aeb/pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d", size = 8778162, upload-time = "2025-07-14T20:13:03.544Z" },
] ]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/a2/09f67a3589cb4320fb5ce90d3fd4c9752636b8b6ad8f34b54d76c5a54693/PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f", size = 186824, upload-time = "2025-09-29T20:27:35.918Z" },
{ url = "https://files.pythonhosted.org/packages/02/72/d972384252432d57f248767556ac083793292a4adf4e2d85dfe785ec2659/PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4", size = 795069, upload-time = "2025-09-29T20:27:38.15Z" },
{ url = "https://files.pythonhosted.org/packages/a7/3b/6c58ac0fa7c4e1b35e48024eb03d00817438310447f93ef4431673c24138/PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3", size = 862585, upload-time = "2025-09-29T20:27:39.715Z" },
{ url = "https://files.pythonhosted.org/packages/25/a2/b725b61ac76a75583ae7104b3209f75ea44b13cfd026aa535ece22b7f22e/PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6", size = 806018, upload-time = "2025-09-29T20:27:41.444Z" },
{ url = "https://files.pythonhosted.org/packages/6f/b0/b2227677b2d1036d84f5ee95eb948e7af53d59fe3e4328784e4d290607e0/PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369", size = 802822, upload-time = "2025-09-29T20:27:42.885Z" },
{ url = "https://files.pythonhosted.org/packages/99/a5/718a8ea22521e06ef19f91945766a892c5ceb1855df6adbde67d997ea7ed/PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295", size = 143744, upload-time = "2025-09-29T20:27:44.487Z" },
{ url = "https://files.pythonhosted.org/packages/76/b2/2b69cee94c9eb215216fc05778675c393e3aa541131dc910df8e52c83776/PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b", size = 160082, upload-time = "2025-09-29T20:27:46.049Z" },
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
{ url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" },
{ url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" },
{ url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" },
{ url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" },
{ url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" },
{ url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" },
{ url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" },
{ url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" },
{ url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" },
]
[[package]] [[package]]
name = "rich" name = "rich"
version = "14.3.3" version = "14.3.3"
@@ -316,6 +530,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
] ]
[[package]]
name = "ruff"
version = "0.15.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" },
{ url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" },
{ url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" },
{ url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" },
{ url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" },
{ url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" },
{ url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" },
{ url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" },
{ url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" },
{ url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" },
{ url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" },
{ url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" },
{ url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" },
{ url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" },
]
[[package]] [[package]]
name = "shadowcopy" name = "shadowcopy"
version = "0.0.4" version = "0.0.4"
@@ -328,6 +567,60 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/32/fdea8e7f7b2b8bae13ee6ab7df1a10d28a24dbeb03a62ff033563f95d77c/shadowcopy-0.0.4-py3-none-any.whl", hash = "sha256:fc51e59a639dc6a5a3a7a9b4e3ecadc71989e339f2d995d90aaa491acd4ba4eb", size = 4212, upload-time = "2023-07-08T00:01:34.042Z" }, { url = "https://files.pythonhosted.org/packages/ad/32/fdea8e7f7b2b8bae13ee6ab7df1a10d28a24dbeb03a62ff033563f95d77c/shadowcopy-0.0.4-py3-none-any.whl", hash = "sha256:fc51e59a639dc6a5a3a7a9b4e3ecadc71989e339f2d995d90aaa491acd4ba4eb", size = 4212, upload-time = "2023-07-08T00:01:34.042Z" },
] ]
[[package]]
name = "tomli"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" },
{ url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" },
{ url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" },
{ url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" },
{ url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" },
{ url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" },
{ url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" },
{ url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" },
{ url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" },
{ url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" },
{ url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" },
{ url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" },
{ url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" },
{ url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" },
{ url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" },
{ url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" },
{ url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" },
{ url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" },
{ url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" },
{ url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" },
{ url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" },
{ url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" },
{ url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" },
{ url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" },
{ url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" },
{ url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" },
{ url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" },
{ url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" },
{ url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" },
{ url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" },
{ url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" },
{ url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" },
{ url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" },
{ url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" },
{ url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" },
{ url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" },
{ url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" },
{ url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" },
{ url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" },
{ url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" },
{ url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" },
{ url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" },
{ url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" },
{ url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
]
[[package]] [[package]]
name = "twitter-cli" name = "twitter-cli"
version = "0.1.0" version = "0.1.0"
@@ -336,14 +629,52 @@ dependencies = [
{ name = "browser-cookie3" }, { name = "browser-cookie3" },
{ name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "click", version = "8.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "click", version = "8.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "pyyaml" },
{ name = "rich" }, { name = "rich" },
] ]
[package.optional-dependencies]
dev = [
{ name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
{ name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "ruff" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "browser-cookie3", specifier = ">=0.19" }, { name = "browser-cookie3", specifier = ">=0.19" },
{ name = "click", specifier = ">=8.0" }, { name = "click", specifier = ">=8.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "rich", specifier = ">=13.0" }, { name = "rich", specifier = ">=13.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" },
]
provides-extras = ["dev"]
[[package]]
name = "typing-extensions"
version = "4.13.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.9'",
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.10'",
"python_full_version == '3.9.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
] ]
[[package]] [[package]]