From 16752c3115fe882f24a6d30f4a0cb6711fa49cdc Mon Sep 17 00:00:00 2001 From: jackwener Date: Wed, 4 Mar 2026 17:56:42 +0800 Subject: [PATCH] Initial commit: twitter-cli v0.1.0 --- .gitignore | 9 + README.md | 127 ++++++++++ config.yaml | 22 ++ pyproject.toml | 40 ++++ twitter_cli/__init__.py | 3 + twitter_cli/auth.py | 125 ++++++++++ twitter_cli/cli.py | 290 +++++++++++++++++++++++ twitter_cli/client.py | 470 ++++++++++++++++++++++++++++++++++++++ twitter_cli/config.py | 175 ++++++++++++++ twitter_cli/filter.py | 90 ++++++++ twitter_cli/formatter.py | 207 +++++++++++++++++ twitter_cli/models.py | 52 +++++ twitter_cli/summarizer.py | 164 +++++++++++++ uv.lock | 359 +++++++++++++++++++++++++++++ 14 files changed, 2133 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.yaml create mode 100644 pyproject.toml create mode 100644 twitter_cli/__init__.py create mode 100644 twitter_cli/auth.py create mode 100644 twitter_cli/cli.py create mode 100644 twitter_cli/client.py create mode 100644 twitter_cli/config.py create mode 100644 twitter_cli/filter.py create mode 100644 twitter_cli/formatter.py create mode 100644 twitter_cli/models.py create mode 100644 twitter_cli/summarizer.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f6d2e49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.venv/ +.env +*.json +!config.yaml diff --git a/README.md b/README.md new file mode 100644 index 0000000..56303ea --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# Twitter CLI + +从你的 Twitter/X 首页抓取推文,智能筛选高价值内容,AI 自动生成摘要。 + +**零 API Key** — 使用浏览器 Cookie 认证,免费访问 Twitter。 + +## Quick Start + +```bash +# 安装 +cd twitter-cli +uv sync + +# 运行(自动从 Chrome 提取 Cookie) +twitter feed +``` + +首次运行确保 Chrome 已登录 x.com。 + +## 使用方式 + +```bash +# 完整 pipeline:抓取 50 条 → 筛选 top 20 → AI 总结 +twitter feed + +# 自定义抓取条数 +twitter feed --count 50 + +# 只抓取 + 筛选,跳过 AI 总结 +twitter feed --no-summary + +# JSON 输出(可重定向到文件) +twitter feed --json > tweets.json + +# 对已有数据做筛选 + 总结 +twitter feed --input tweets.json + +# 跳过筛选 +twitter feed --no-filter + +# 指定浏览器 +twitter feed --browser firefox + +# 抓取收藏 +twitter bookmarks +twitter bookmarks --count 30 --json +``` + +## Pipeline + +``` +抓取 (GraphQL API) → 筛选 (Engagement Score) → AI 总结 + 50 条 top 20 按主题分组 +``` + +### 筛选算法 + +加权评分公式,收藏权重最高(代表"值得回看"): + +``` +score = 1.0 × likes + 3.0 × retweets + 2.0 × replies + + 5.0 × bookmarks + 0.5 × log10(views) +``` + +### AI 总结 + +支持 **OpenAI-compatible**(doubao / deepseek / openai)和 **Anthropic**(Claude)两种 API 格式。 + +## 配置 + +编辑 `config.yaml`: + +```yaml +fetch: + count: 50 + +filter: + mode: "topN" # "topN" | "score" | "all" + topN: 20 + weights: + likes: 1.0 + retweets: 3.0 + replies: 2.0 + bookmarks: 5.0 + views_log: 0.5 + +ai: + provider: "openai" # "openai" or "anthropic" + api_key: "" # 或设置环境变量 AI_API_KEY + model: "doubao-seed-2.0-code" + base_url: "https://ark.cn-beijing.volces.com/api/coding" + language: "zh-CN" +``` + +### Cookie 配置 + +**方式 1:自动提取**(推荐) — 确保 Chrome 已登录 x.com,程序自动通过 `browser-cookie3` 读取。 + +**方式 2:环境变量** — 设置: + +```bash +export TWITTER_AUTH_TOKEN=your_auth_token +export TWITTER_CT0=your_ct0 +``` + +可通过 [Cookie-Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) 浏览器插件导出。 + +## 项目结构 + +``` +twitter_cli/ +├── __init__.py # 版本信息 +├── cli.py # CLI 入口 (click) +├── client.py # Twitter GraphQL API Client +├── auth.py # Cookie 提取 (env / browser-cookie3) +├── filter.py # Engagement scoring + 筛选 +├── summarizer.py # AI 总结 (OpenAI + Anthropic) +├── formatter.py # Rich 终端输出 + JSON +├── config.py # YAML 配置加载 +└── models.py # 数据模型 (dataclass) +``` + +## 注意事项 + +- 使用 Cookie 登录存在被平台检测的风险,建议使用**专用小号** +- Cookie 只存在本地,不上传不外传 +- GraphQL `queryId` 会从 Twitter 前端 JS 自动检测,无需手动维护 diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..ad4ee75 --- /dev/null +++ b/config.yaml @@ -0,0 +1,22 @@ +fetch: + count: 50 + +filter: + mode: "topN" + topN: 20 + minScore: 50 + lang: [] + excludeRetweets: false + weights: + likes: 1.0 + retweets: 3.0 + replies: 2.0 + bookmarks: 5.0 + views_log: 0.5 + +ai: + provider: "openai" + api_key: "" + model: "doubao-seed-2.0-code" + base_url: "https://ark.cn-beijing.volces.com/api/coding" + language: "zh-CN" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5066be2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "twitter-cli" +version = "0.1.0" +description = "A CLI for Twitter/X — feed, bookmarks, filtering, AI summary" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.8" +authors = [{ name = "jackwener", email = "jakevingoo@gmail.com" }] +keywords = ["twitter", "x", "cli", "feed", "timeline"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Utilities", +] +dependencies = [ + "browser-cookie3>=0.19", + "click>=8.0", + "rich>=13.0", +] + +[project.urls] +Homepage = "https://github.com/jackwener/twitter-cli" +Repository = "https://github.com/jackwener/twitter-cli" +Issues = "https://github.com/jackwener/twitter-cli/issues" + +[project.scripts] +twitter = "twitter_cli.cli:cli" diff --git a/twitter_cli/__init__.py b/twitter_cli/__init__.py new file mode 100644 index 0000000..494cca7 --- /dev/null +++ b/twitter_cli/__init__.py @@ -0,0 +1,3 @@ +"""twitter-cli: A CLI for Twitter/X.""" + +__version__ = "0.1.0" diff --git a/twitter_cli/auth.py b/twitter_cli/auth.py new file mode 100644 index 0000000..a525f50 --- /dev/null +++ b/twitter_cli/auth.py @@ -0,0 +1,125 @@ +"""Cookie authentication for Twitter/X. + +Supports: +1. Environment variables: TWITTER_AUTH_TOKEN + TWITTER_CT0 +2. Auto-extract from browser via browser-cookie3 (subprocess) +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from typing import Dict, Optional + + +def load_from_env() -> Optional[Dict[str, str]]: + """Load cookies from environment variables.""" + auth_token = os.environ.get("TWITTER_AUTH_TOKEN", "") + ct0 = os.environ.get("TWITTER_CT0", "") + if auth_token and ct0: + return {"auth_token": auth_token, "ct0": ct0} + return None + + +def extract_from_browser(browser: str = "chrome") -> Optional[Dict[str, str]]: + """Auto-extract cookies from local browser using browser-cookie3. + + Runs in a subprocess to avoid SQLite database lock issues when the + browser is running. + """ + extract_script = ''' +import json, sys +try: + import browser_cookie3 +except ImportError: + print(json.dumps({"error": "browser-cookie3 not installed"})) + sys.exit(1) + +browser_funcs = { + "chrome": browser_cookie3.chrome, + "firefox": browser_cookie3.firefox, + "edge": browser_cookie3.edge, + "brave": browser_cookie3.brave, +} + +browser_name = "%s" +fn = browser_funcs.get(browser_name) +if not fn: + print(json.dumps({"error": "Unsupported browser: " + browser_name})) + sys.exit(1) + +try: + jar = fn() +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + +result = {} +for cookie in jar: + domain = cookie.domain or "" + if domain.endswith(".x.com") or domain.endswith(".twitter.com") or domain in ("x.com", "twitter.com", ".x.com", ".twitter.com"): + if cookie.name == "auth_token": + result["auth_token"] = cookie.value + elif cookie.name == "ct0": + result["ct0"] = cookie.value + +if "auth_token" in result and "ct0" in result: + print(json.dumps(result)) +else: + print(json.dumps({"error": "Could not find auth_token and ct0 cookies. Make sure you are logged into x.com in " + browser_name + "."})) + sys.exit(1) +''' % browser + + try: + result = subprocess.run( + [sys.executable, "-c", extract_script], + capture_output=True, + text=True, + timeout=15, + ) + output = result.stdout.strip() + if not output: + stderr = result.stderr.strip() + if stderr: + # Maybe browser-cookie3 not installed, try with uv + result2 = subprocess.run( + ["uv", "run", "--with", "browser-cookie3", "python3", "-c", extract_script], + capture_output=True, + text=True, + timeout=30, + ) + output = result2.stdout.strip() + if not output: + return None + + data = json.loads(output) + if "error" in data: + return None + return {"auth_token": data["auth_token"], "ct0": data["ct0"]} + except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, FileNotFoundError): + return None + + +def get_cookies(browser: str = "chrome") -> Dict[str, str]: + """Get Twitter cookies. Priority: env vars -> browser extraction. + + Returns dict with 'auth_token' and 'ct0' keys. + Raises RuntimeError if no cookies found. + """ + # 1. Try environment variables + env_cookies = load_from_env() + if env_cookies: + return env_cookies + + # 2. Try browser extraction + browser_cookies = extract_from_browser(browser) + if browser_cookies: + return browser_cookies + + raise RuntimeError( + "No Twitter cookies found.\n" + "Option 1: Set TWITTER_AUTH_TOKEN and TWITTER_CT0 environment variables\n" + "Option 2: Make sure you are logged into x.com in your browser" + ) diff --git a/twitter_cli/cli.py b/twitter_cli/cli.py new file mode 100644 index 0000000..38d388f --- /dev/null +++ b/twitter_cli/cli.py @@ -0,0 +1,290 @@ +"""CLI entry point for twitter-cli. + +Usage: + twitter feed # full pipeline: fetch → filter → AI summarize + twitter feed --count 50 # custom fetch count + twitter feed --no-summary # skip AI summary + twitter feed --no-filter # skip filtering + twitter feed --json # JSON output + twitter feed --browser firefox # specify browser for cookie extraction + twitter bookmarks # fetch bookmarks + twitter bookmarks --count 30 + twitter feed --input tweets.json # summarize existing data + twitter feed --output out.json # save filtered tweets +""" + +from __future__ import annotations + +import json +import logging +import sys +import time +from pathlib import Path +from typing import List + +import click +from rich.console import Console + +from . import __version__ +from .auth import get_cookies +from .client import TwitterClient +from .config import load_config +from .filter import filter_tweets +from .formatter import ( + print_filter_stats, + print_tweet_table, + tweets_to_json, +) +from .models import Author, Metrics, Tweet, TweetMedia +from .summarizer import summarize + +console = Console() + + +def _setup_logging(verbose): + # type: (bool) -> None + level = logging.DEBUG if verbose else logging.WARNING + logging.basicConfig( + level=level, + format="%(levelname)s %(name)s: %(message)s", + stream=sys.stderr, + ) + + +def _load_tweets_from_json(path): + # type: (str) -> List[Tweet] + """Load tweets from a JSON file (previously exported).""" + raw = Path(path).read_text(encoding="utf-8") + items = json.loads(raw) + tweets = [] + for d in items: + author_data = d.get("author", {}) + metrics_data = d.get("metrics", {}) + media_data = d.get("media", []) + + author = Author( + id=author_data.get("id", ""), + name=author_data.get("name", ""), + screen_name=author_data.get("screenName", ""), + profile_image_url=author_data.get("profileImageUrl", ""), + verified=author_data.get("verified", False), + ) + metrics = Metrics( + likes=metrics_data.get("likes", 0), + retweets=metrics_data.get("retweets", 0), + replies=metrics_data.get("replies", 0), + quotes=metrics_data.get("quotes", 0), + views=metrics_data.get("views", 0), + bookmarks=metrics_data.get("bookmarks", 0), + ) + media = [ + TweetMedia( + type=m.get("type", ""), + url=m.get("url", ""), + width=m.get("width"), + height=m.get("height"), + ) + for m in media_data + ] + + qt_data = d.get("quotedTweet") + quoted_tweet = None + if qt_data: + qt_author = qt_data.get("author", {}) + quoted_tweet = Tweet( + id=qt_data.get("id", ""), + text=qt_data.get("text", ""), + author=Author( + id="", + name=qt_author.get("name", ""), + screen_name=qt_author.get("screenName", ""), + ), + metrics=Metrics(), + created_at="", + ) + + tweets.append(Tweet( + id=d.get("id", ""), + text=d.get("text", ""), + author=author, + metrics=metrics, + created_at=d.get("createdAt", ""), + media=media, + urls=d.get("urls", []), + is_retweet=d.get("isRetweet", False), + lang=d.get("lang", ""), + retweeted_by=d.get("retweetedBy"), + quoted_tweet=quoted_tweet, + score=d.get("score", 0.0), + )) + return tweets + + +@click.group() +@click.option("--verbose", "-v", is_flag=True, help="Enable debug logging.") +@click.version_option(version=__version__) +def cli(verbose): + # type: (bool) -> None + """twitter — Twitter/X CLI tool 🐦""" + _setup_logging(verbose) + + +# ===== Feed ===== + +@cli.command() +@click.option("--count", "-n", type=int, default=None, help="Number of tweets to fetch.") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON.") +@click.option("--browser", "-b", default="chrome", help="Browser to extract cookies from.") +@click.option("--input", "-i", "input_file", type=str, default=None, help="Load tweets from JSON file.") +@click.option("--output", "-o", "output_file", type=str, default=None, help="Save filtered tweets to JSON file.") +@click.option("--no-filter", is_flag=True, help="Skip filtering.") +@click.option("--no-summary", is_flag=True, help="Skip AI summary.") +def feed(count, as_json, browser, input_file, output_file, no_filter, no_summary): + # type: (int, bool, str, str, str, bool, bool) -> None + """Fetch home timeline — full pipeline: fetch → filter → AI summarize.""" + config = load_config() + + # Step 1: Get tweets + if input_file: + console.print("📂 Loading tweets from %s..." % input_file) + tweets = _load_tweets_from_json(input_file) + console.print(" Loaded %d tweets" % len(tweets)) + else: + fetch_count = count or config.get("fetch", {}).get("count", 50) + console.print("\n🔐 Getting Twitter cookies...") + try: + cookies = get_cookies(browser) + 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() + tweets = client.fetch_home_timeline(fetch_count) + elapsed = time.time() - start + console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed)) + + # Step 2: Filter + if no_filter: + filtered = tweets + else: + filter_config = config.get("filter", {}) + original_count = len(tweets) + filtered = filter_tweets(tweets, filter_config) + print_filter_stats(original_count, filtered, console) + console.print() + + # Save filtered tweets + if output_file: + Path(output_file).write_text(tweets_to_json(filtered), encoding="utf-8") + console.print("💾 Saved filtered tweets to %s\n" % output_file) + + # Output + if as_json: + click.echo(tweets_to_json(filtered)) + return + + print_tweet_table(filtered, console) + console.print() + + # Step 3: AI Summary + if no_summary: + return + + ai_config = config.get("ai", {}) + if not ai_config.get("api_key"): + console.print( + "[yellow]⚠️ AI summary skipped: no API key configured.[/yellow]\n" + " Set ai.api_key in config.yaml or export AI_API_KEY=your_key" + ) + return + + try: + console.print("🤖 Calling AI (%s/%s)..." % (ai_config.get("provider", "openai"), ai_config.get("model", ""))) + summary = summarize(filtered, ai_config) + console.print("\n" + "═" * 50) + console.print("📝 AI Summary") + console.print("═" * 50 + "\n") + console.print(summary) + console.print() + except Exception as e: + console.print("[red]❌ AI summary failed: %s[/red]" % e) + + +# ===== Bookmarks ===== + +@cli.command() +@click.option("--count", "-n", type=int, default=None, help="Number of tweets to fetch.") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON.") +@click.option("--browser", "-b", default="chrome", help="Browser to extract cookies from.") +@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("--no-summary", is_flag=True, help="Skip AI summary.") +def bookmarks(count, as_json, browser, output_file, no_filter, no_summary): + # type: (int, bool, str, str, bool, bool) -> None + """Fetch bookmarked tweets.""" + config = load_config() + fetch_count = count or 50 + + console.print("\n🔐 Getting Twitter cookies...") + try: + cookies = get_cookies(browser) + except RuntimeError as e: + console.print("[red]❌ %s[/red]" % e) + sys.exit(1) + + client = TwitterClient(cookies["auth_token"], cookies["ct0"]) + console.print("🔖 Fetching bookmarks (%d tweets)...\n" % fetch_count) + start = time.time() + tweets = client.fetch_bookmarks(fetch_count) + elapsed = time.time() - start + console.print("✅ Fetched %d bookmarks in %.1fs\n" % (len(tweets), elapsed)) + + # Filter + if no_filter: + filtered = tweets + else: + filter_config = config.get("filter", {}) + original_count = len(tweets) + filtered = filter_tweets(tweets, filter_config) + print_filter_stats(original_count, filtered, console) + console.print() + + # Save + if output_file: + Path(output_file).write_text(tweets_to_json(filtered), encoding="utf-8") + console.print("💾 Saved to %s\n" % output_file) + + # Output + if as_json: + click.echo(tweets_to_json(filtered)) + return + + print_tweet_table(filtered, console, title="🔖 Bookmarks — %d tweets" % len(filtered)) + console.print() + + # AI Summary + if no_summary: + return + + ai_config = config.get("ai", {}) + if not ai_config.get("api_key"): + console.print( + "[yellow]⚠️ AI summary skipped: no API key configured.[/yellow]" + ) + return + + try: + console.print("🤖 Calling AI...") + summary = summarize(filtered, ai_config) + console.print("\n" + "═" * 50) + console.print("📝 AI Summary") + console.print("═" * 50 + "\n") + console.print(summary) + except Exception as e: + console.print("[red]❌ AI summary failed: %s[/red]" % e) + + +if __name__ == "__main__": + cli() diff --git a/twitter_cli/client.py b/twitter_cli/client.py new file mode 100644 index 0000000..306b651 --- /dev/null +++ b/twitter_cli/client.py @@ -0,0 +1,470 @@ +"""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 + +import json +import logging +import math +import re +import ssl +import urllib.request +from typing import Any, Callable, Dict, List, Optional, Tuple + +from .models import Author, Metrics, Tweet, TweetMedia + +logger = logging.getLogger(__name__) + +# Public bearer token shared by all Twitter web clients +BEARER_TOKEN = ( + "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs" + "%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" +) + +# Last-resort fallback query IDs +FALLBACK_QUERY_IDS = { + "HomeTimeline": "HJFjzBgCs16TqxewQOeLNg", + "Bookmarks": "VFdMm9iVZxlU6hD86gfW_A", +} + +# Community-maintained API definition (auto-updated daily) +TWITTER_OPENAPI_URL = ( + "https://raw.githubusercontent.com/fa0311/twitter-openapi/" + "main/src/config/placeholder.json" +) + +# Default features flags required by the GraphQL endpoint +FEATURES = { + "rweb_tipjar_consumption_enabled": True, + "responsive_web_graphql_exclude_directive_enabled": True, + "verified_phone_label_enabled": False, + "creator_subscriptions_tweet_preview_api_enabled": True, + "responsive_web_graphql_timeline_navigation_enabled": True, + "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False, + "communities_web_enable_tweet_community_results_fetch": True, + "c9s_tweet_anatomy_moderator_badge_enabled": True, + "articles_preview_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, + "freedom_of_speech_not_reach_fetch_enabled": True, + "standardized_nudges_misinfo": True, + "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True, + "rweb_video_timestamps_enabled": True, + "longform_notetweets_rich_text_read_enabled": True, + "longform_notetweets_inline_media_enabled": True, + "responsive_web_enhance_cards_enabled": False, +} + +USER_AGENT = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/131.0.0.0 Safari/537.36" +) + +# Module-level cache for query IDs +_cached_query_ids = {} # type: Dict[str, str] +_bundles_scanned = False + + +def _create_ssl_context(): + # type: () -> ssl.SSLContext + """Create a permissive SSL context for urllib.""" + ctx = ssl.create_default_context() + return ctx + + +def _url_fetch(url, headers=None): + # type: (str, Optional[Dict[str, str]]) -> str + """Simple URL fetch using urllib.""" + req = urllib.request.Request(url) + if headers: + for k, v in headers.items(): + req.add_header(k, v) + ctx = _create_ssl_context() + with urllib.request.urlopen(req, context=ctx, timeout=30) as resp: + return resp.read().decode("utf-8") + + +def _scan_bundles(): + # type: () -> None + """Tier 1: Scan Twitter's main-page JS bundles to extract queryId/operationName pairs.""" + global _bundles_scanned + if _bundles_scanned: + return + _bundles_scanned = True + + try: + html = _url_fetch("https://x.com", {"user-agent": USER_AGENT}) + + script_pattern = re.compile( + r'(?:src|href)=["\']' + r'(https://abs\.twimg\.com/responsive-web/client-web[^"\']+\.js)' + r'["\']' + ) + script_urls = script_pattern.findall(html) + + for url in script_urls: + try: + js = _url_fetch(url) + op_pattern = re.compile( + r'queryId:\s*"([A-Za-z0-9_-]+)"[^}]{0,200}' + r'operationName:\s*"([^"]+)"' + ) + for m in op_pattern.finditer(js): + qid, name = m.group(1), m.group(2) + if name not in _cached_query_ids: + _cached_query_ids[name] = qid + except Exception: + continue + + count = 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): + # type: (str) -> Optional[str] + """Tier 2: Fetch queryId from community-maintained twitter-openapi.""" + try: + logger.info("Fetching latest queryId from GitHub (twitter-openapi)...") + data_str = _url_fetch(TWITTER_OPENAPI_URL) + data = json.loads(data_str) + op = data.get(operation_name, {}) + qid = op.get("queryId") + if qid: + logger.info("Found %s queryId from GitHub: %s", operation_name, qid) + return qid + return None + except Exception as e: + logger.warning("GitHub lookup failed: %s", e) + return None + + +def _resolve_query_id(operation_name): + # type: (str) -> str + """Resolve queryId using three-tier strategy: bundle scan -> GitHub -> fallback.""" + if operation_name in _cached_query_ids: + return _cached_query_ids[operation_name] + + logger.info("Auto-detecting %s queryId...", operation_name) + + # Tier 1: JS bundle scan + _scan_bundles() + if operation_name in _cached_query_ids: + logger.info("Found %s queryId: %s", operation_name, _cached_query_ids[operation_name]) + return _cached_query_ids[operation_name] + + # 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: Hardcoded fallback + fallback = FALLBACK_QUERY_IDS.get(operation_name) + if fallback: + logger.info("Using hardcoded fallback queryId for %s: %s", operation_name, fallback) + _cached_query_ids[operation_name] = fallback + return fallback + + raise RuntimeError( + 'Cannot resolve queryId for "%s" — all detection methods failed' % operation_name + ) + + +class TwitterClient: + """Twitter GraphQL API client using cookie authentication.""" + + def __init__(self, auth_token, ct0): + # type: (str, str) -> None + self._auth_token = auth_token + self._ct0 = ct0 + + def fetch_home_timeline(self, count=20): + # type: (int) -> List[Tweet] + """Fetch home timeline tweets.""" + query_id = _resolve_query_id("HomeTimeline") + return self._fetch_timeline( + query_id, + "HomeTimeline", + count, + lambda data: _deep_get(data, "data", "home", "home_timeline_urt", "instructions"), + ) + + def fetch_bookmarks(self, count=50): + # type: (int) -> List[Tweet] + """Fetch bookmarked tweets.""" + query_id = _resolve_query_id("Bookmarks") + + def get_instructions(data): + # type: (Any) -> Any + result = _deep_get(data, "data", "bookmark_timeline", "timeline", "instructions") + if result is None: + result = _deep_get(data, "data", "bookmark_timeline_v2", "timeline", "instructions") + return result + + return self._fetch_timeline(query_id, "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 _parse_timeline_response(self, data, get_instructions): + # type: (Any, Callable) -> Tuple[List[Tweet], Optional[str]] + """Parse timeline GraphQL response into tweets + next cursor.""" + tweets = [] # type: List[Tweet] + next_cursor = None # type: Optional[str] + + try: + instructions = get_instructions(data) + if not isinstance(instructions, list): + logger.warning("No instructions found in response") + return tweets, next_cursor + + for instruction in instructions: + entries = instruction.get("entries") or instruction.get("moduleItems") or [] + + for entry in entries: + content = entry.get("content", {}) + + # 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", {}) + tweet_results = item_content.get("tweet_results", {}) + result = tweet_results.get("result") + if result: + tweet = self._parse_tweet_result(result) + if tweet: + tweets.append(tweet) + + # Handle conversation module (tweet threads) + items = content.get("items", []) + for item in items: + nested = ( + item.get("item", {}) + .get("itemContent", {}) + .get("tweet_results", {}) + .get("result") + ) + if nested: + tweet = self._parse_tweet_result(nested) + if tweet: + tweets.append(tweet) + except Exception as e: + logger.warning("Error parsing timeline response: %s", e) + + return tweets, next_cursor + + def _parse_tweet_result(self, result): + # type: (Dict[str, Any]) -> Optional[Tweet] + """Parse a single TweetResult from GraphQL response.""" + try: + tweet_data = result + + # Handle TweetWithVisibilityResults wrapper + if result.get("__typename") == "TweetWithVisibilityResults" and result.get("tweet"): + tweet_data = result["tweet"] + + if tweet_data.get("__typename") == "TweetTombstone": + return None + if not tweet_data.get("legacy") or not tweet_data.get("core"): + return None + + legacy = tweet_data["legacy"] + user = tweet_data["core"]["user_results"]["result"] + user_legacy = user.get("legacy", {}) + user_core = user.get("core", {}) + + # Check if this is a retweet + is_retweet = bool(legacy.get("retweeted_status_result", {}).get("result")) + actual_data = tweet_data + actual_legacy = legacy + actual_user = user + actual_user_legacy = user_legacy + + if is_retweet: + rt_result = legacy["retweeted_status_result"]["result"] + # Handle wrapped retweet + if rt_result.get("__typename") == "TweetWithVisibilityResults" and rt_result.get("tweet"): + rt_result = rt_result["tweet"] + if rt_result.get("legacy") and rt_result.get("core"): + actual_data = rt_result + actual_legacy = rt_result["legacy"] + actual_user = rt_result["core"]["user_results"]["result"] + actual_user_legacy = actual_user.get("legacy", {}) + + # Parse media + media = [] # type: List[TweetMedia] + ext_media = actual_legacy.get("extended_entities", {}).get("media", []) + for m in ext_media: + m_type = m.get("type", "") + if m_type == "photo": + media.append(TweetMedia( + type="photo", + url=m.get("media_url_https", ""), + width=_deep_get(m, "original_info", "width"), + height=_deep_get(m, "original_info", "height"), + )) + elif m_type in ("video", "animated_gif"): + variants = m.get("video_info", {}).get("variants", []) + mp4_variants = [v for v in variants if v.get("content_type") == "video/mp4"] + mp4_variants.sort(key=lambda v: v.get("bitrate", 0), reverse=True) + video_url = mp4_variants[0]["url"] if mp4_variants else m.get("media_url_https", "") + media.append(TweetMedia( + type=m_type, + url=video_url, + width=_deep_get(m, "original_info", "width"), + height=_deep_get(m, "original_info", "height"), + )) + + # Parse URLs + urls = [u.get("expanded_url", "") for u in actual_legacy.get("entities", {}).get("urls", [])] + + # Parse quoted tweet + quoted_tweet = None # type: Optional[Tweet] + quoted_result = actual_data.get("quoted_status_result", {}).get("result") + if quoted_result: + quoted_tweet = self._parse_tweet_result(quoted_result) + + # Extract user info — try user.core (new API), then user.legacy (old API) + au = actual_user + aul = actual_user_legacy + auc = au.get("core", {}) + 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: + rt_screen_name = user_core.get("screen_name") or user_legacy.get("screen_name", "unknown") + + return Tweet( + id=actual_data.get("rest_id", ""), + text=actual_legacy.get("full_text", ""), + author=Author( + id=au.get("rest_id", ""), + name=user_name, + screen_name=user_screen_name, + profile_image_url=user_profile_image, + verified=bool(user_verified), + ), + metrics=Metrics( + likes=actual_legacy.get("favorite_count", 0), + retweets=actual_legacy.get("retweet_count", 0), + replies=actual_legacy.get("reply_count", 0), + quotes=actual_legacy.get("quote_count", 0), + views=int(actual_data.get("views", {}).get("count", "0") or "0"), + bookmarks=actual_legacy.get("bookmark_count", 0), + ), + created_at=actual_legacy.get("created_at", ""), + media=media, + urls=urls, + is_retweet=is_retweet, + retweeted_by=rt_screen_name, + quoted_tweet=quoted_tweet, + lang=actual_legacy.get("lang", ""), + ) + except Exception as e: + logger.warning("Failed to parse tweet: %s", e) + return None + + +def _deep_get(d, *keys): + # type: (Any, *str) -> Any + """Safely get a nested value from a dict.""" + for key in keys: + if isinstance(d, dict): + d = d.get(key) + else: + return None + return d diff --git a/twitter_cli/config.py b/twitter_cli/config.py new file mode 100644 index 0000000..22ce784 --- /dev/null +++ b/twitter_cli/config.py @@ -0,0 +1,175 @@ +"""Configuration loader — reads config.yaml and merges with defaults. + +Uses a simple built-in YAML parser to avoid adding PyYAML as a dependency. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path +from typing import Any, Dict, List, Union + +# Default configuration +DEFAULT_CONFIG = { + "fetch": { + "count": 50, + }, + "filter": { + "mode": "topN", + "topN": 20, + "minScore": 50, + "lang": [], + "excludeRetweets": False, + "weights": { + "likes": 1.0, + "retweets": 3.0, + "replies": 2.0, + "bookmarks": 5.0, + "views_log": 0.5, + }, + }, + "ai": { + "provider": "openai", + "api_key": "", + "model": "doubao-seed-2.0-code", + "base_url": "https://ark.cn-beijing.volces.com/api/coding", + "language": "zh-CN", + }, +} # 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): + # type: (str) -> Dict[str, Any] + """Load config from config.yaml, merged with defaults.""" + if config_path is None: + # Look in current directory first, then script directory + candidates = [ + Path.cwd() / "config.yaml", + Path(__file__).parent.parent / "config.yaml", + ] + for p in candidates: + if p.exists(): + config_path = str(p) + break + + if config_path and Path(config_path).exists(): + try: + raw = Path(config_path).read_text(encoding="utf-8") + parsed = _parse_yaml(raw) + config = _deep_merge(DEFAULT_CONFIG, parsed) + except Exception: + config = dict(DEFAULT_CONFIG) + else: + config = dict(DEFAULT_CONFIG) + + # Ensure nested dicts exist + config.setdefault("fetch", DEFAULT_CONFIG["fetch"]) + config.setdefault("filter", DEFAULT_CONFIG["filter"]) + config.setdefault("ai", DEFAULT_CONFIG["ai"]) + + # Deep-copy filter weights if needed + if "filter" in config and "weights" not in config["filter"]: + config["filter"]["weights"] = dict(DEFAULT_CONFIG["filter"]["weights"]) + + # AI API key fallback to env var + ai = config.get("ai", {}) + if not ai.get("api_key"): + ai["api_key"] = os.environ.get("AI_API_KEY", "") + + return config diff --git a/twitter_cli/filter.py b/twitter_cli/filter.py new file mode 100644 index 0000000..8f42fa5 --- /dev/null +++ b/twitter_cli/filter.py @@ -0,0 +1,90 @@ +"""Tweet filtering and engagement scoring. + +Scores tweets by a weighted engagement formula and filters by +configurable rules (topN, min score, language, etc.). +""" + +from __future__ import annotations + +import math +from typing import Dict, List + +from .models import Tweet + + +# Type alias for filter weights dict +FilterWeights = Dict[str, float] + +DEFAULT_WEIGHTS = { + "likes": 1.0, + "retweets": 3.0, + "replies": 2.0, + "bookmarks": 5.0, + "views_log": 0.5, +} + + +def score_tweet(tweet, weights=None): + # type: (Tweet, FilterWeights) -> float + """Calculate engagement score for a single tweet. + + Formula: + score = w_likes × likes + + w_retweets × retweets + + w_replies × replies + + w_bookmarks × bookmarks + + w_views_log × log10(views) + """ + if weights is None: + weights = DEFAULT_WEIGHTS + m = tweet.metrics + return ( + weights.get("likes", 1.0) * m.likes + + weights.get("retweets", 3.0) * m.retweets + + weights.get("replies", 2.0) * m.replies + + weights.get("bookmarks", 5.0) * m.bookmarks + + weights.get("views_log", 0.5) * math.log10(max(m.views, 1)) + ) + + +def filter_tweets(tweets, config): + # type: (List[Tweet], dict) -> List[Tweet] + """Filter and rank tweets according to config. + + Config keys: + mode: "topN" | "score" | "all" + topN: int + minScore: float + lang: list[str] (empty = no filter) + excludeRetweets: bool + weights: dict + """ + filtered = list(tweets) + + # 1. Language filter + lang_filter = config.get("lang", []) + if lang_filter: + filtered = [t for t in filtered if t.lang in lang_filter] + + # 2. Exclude retweets + if config.get("excludeRetweets", False): + filtered = [t for t in filtered if not t.is_retweet] + + # 3. Score all tweets + weights = config.get("weights", DEFAULT_WEIGHTS) + for t in filtered: + t.score = round(score_tweet(t, weights), 1) + + # 4. Sort by score (descending) + filtered.sort(key=lambda t: t.score, reverse=True) + + # 5. Apply filter mode + mode = config.get("mode", "topN") + if mode == "topN": + top_n = config.get("topN", 20) + return filtered[:top_n] + elif mode == "score": + min_score = config.get("minScore", 50) + return [t for t in filtered if t.score >= min_score] + else: + return filtered diff --git a/twitter_cli/formatter.py b/twitter_cli/formatter.py new file mode 100644 index 0000000..1d52aae --- /dev/null +++ b/twitter_cli/formatter.py @@ -0,0 +1,207 @@ +"""Tweet formatter for terminal output (rich) and JSON export.""" + +from __future__ import annotations + +import json +from typing import List, Optional + +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from .models import Tweet + + +def format_number(n): + # type: (int) -> str + """Format number with K/M suffixes.""" + if n >= 1_000_000: + return "%.1fM" % (n / 1_000_000) + if n >= 1_000: + return "%.1fK" % (n / 1_000) + return str(n) + + +def print_tweet_table(tweets, console=None, title=None): + # type: (List[Tweet], Optional[Console], Optional[str]) -> None + """Print tweets as a rich table.""" + if console is None: + console = Console() + + if not title: + title = "📱 Twitter — %d tweets" % len(tweets) + + table = Table(title=title, show_lines=True, expand=True) + table.add_column("#", style="dim", width=3, justify="right") + table.add_column("Author", style="cyan", width=18, no_wrap=True) + table.add_column("Tweet", ratio=3) + table.add_column("Stats", style="green", width=22, no_wrap=True) + table.add_column("Score", style="yellow", width=6, justify="right") + + for i, tweet in enumerate(tweets): + # Author + verified = " ✓" if tweet.author.verified else "" + author_text = "@%s%s" % (tweet.author.screen_name, verified) + if tweet.is_retweet and tweet.retweeted_by: + author_text += "\n🔄 @%s" % tweet.retweeted_by + + # Tweet text (truncated) + text = tweet.text.replace("\n", " ").strip() + if len(text) > 120: + text = text[:117] + "..." + + # Media indicators + if tweet.media: + media_icons = [] + for m in tweet.media: + if m.type == "photo": + media_icons.append("📷") + elif m.type == "video": + media_icons.append("📹") + else: + media_icons.append("🎞️") + text += " " + " ".join(media_icons) + + # Quoted tweet + if tweet.quoted_tweet: + qt = tweet.quoted_tweet + qt_text = qt.text.replace("\n", " ")[:60] + text += "\n┌ @%s: %s" % (qt.author.screen_name, qt_text) + + # Stats + stats = ( + "❤️ %s 🔄 %s\n💬 %s 👁️ %s" + % ( + format_number(tweet.metrics.likes), + format_number(tweet.metrics.retweets), + format_number(tweet.metrics.replies), + format_number(tweet.metrics.views), + ) + ) + + # Score + score_str = "%.1f" % tweet.score if tweet.score else "-" + + table.add_row(str(i + 1), author_text, text, stats, score_str) + + console.print(table) + + +def print_tweet_detail(tweet, console=None): + # type: (Tweet, Optional[Console]) -> None + """Print a single tweet in detail using a rich panel.""" + if console is None: + console = Console() + + verified = " ✓" if tweet.author.verified else "" + header = "@%s%s (%s)" % (tweet.author.screen_name, verified, tweet.author.name) + + body_parts = [] + + if tweet.is_retweet and tweet.retweeted_by: + body_parts.append("🔄 Retweeted by @%s\n" % tweet.retweeted_by) + + body_parts.append(tweet.text) + + if tweet.media: + body_parts.append("") + for m in tweet.media: + icon = "📷" if m.type == "photo" else ("📹" if m.type == "video" else "🎞️") + body_parts.append("%s %s: %s" % (icon, m.type, m.url)) + + if tweet.urls: + body_parts.append("") + for url in tweet.urls: + body_parts.append("🔗 %s" % url) + + if tweet.quoted_tweet: + qt = tweet.quoted_tweet + body_parts.append("") + body_parts.append("┌── Quoted @%s ──" % qt.author.screen_name) + body_parts.append(qt.text[:200]) + + body_parts.append("") + body_parts.append( + "❤️ %s 🔄 %s 💬 %s 🔖 %s 👁️ %s" + % ( + format_number(tweet.metrics.likes), + format_number(tweet.metrics.retweets), + format_number(tweet.metrics.replies), + format_number(tweet.metrics.bookmarks), + format_number(tweet.metrics.views), + ) + ) + body_parts.append( + "🕐 %s · https://x.com/%s/status/%s" + % (tweet.created_at, tweet.author.screen_name, tweet.id) + ) + + console.print(Panel( + "\n".join(body_parts), + title=header, + border_style="blue", + expand=True, + )) + + +def print_filter_stats(original_count, filtered, console=None): + # type: (int, List[Tweet], Optional[Console]) -> None + """Print filter statistics.""" + if console is None: + console = Console() + + console.print( + "📊 Filter: %d → %d tweets" % (original_count, len(filtered)) + ) + if filtered: + top_score = filtered[0].score + bottom_score = filtered[-1].score + console.print( + " Score range: %.1f ~ %.1f" % (bottom_score, top_score) + ) + + +def tweets_to_json(tweets): + # type: (List[Tweet]) -> str + """Export tweets as JSON string.""" + result = [] + for t in tweets: + d = { + "id": t.id, + "text": t.text, + "author": { + "id": t.author.id, + "name": t.author.name, + "screenName": t.author.screen_name, + "profileImageUrl": t.author.profile_image_url, + "verified": t.author.verified, + }, + "metrics": { + "likes": t.metrics.likes, + "retweets": t.metrics.retweets, + "replies": t.metrics.replies, + "quotes": t.metrics.quotes, + "views": t.metrics.views, + "bookmarks": t.metrics.bookmarks, + }, + "createdAt": t.created_at, + "media": [ + {"type": m.type, "url": m.url, "width": m.width, "height": m.height} + for m in t.media + ], + "urls": t.urls, + "isRetweet": t.is_retweet, + "retweetedBy": t.retweeted_by, + "lang": t.lang, + "score": t.score, + } + if t.quoted_tweet: + qt = t.quoted_tweet + d["quotedTweet"] = { + "id": qt.id, + "text": qt.text, + "author": {"screenName": qt.author.screen_name, "name": qt.author.name}, + } + result.append(d) + return json.dumps(result, ensure_ascii=False, indent=2) diff --git a/twitter_cli/models.py b/twitter_cli/models.py new file mode 100644 index 0000000..e8f9faa --- /dev/null +++ b/twitter_cli/models.py @@ -0,0 +1,52 @@ +"""Data models for twitter-cli. + +Defines Tweet, Author, Metrics, and TweetMedia as simple dataclasses. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List, Optional + + +@dataclass +class Author: + id: str + name: str + screen_name: str + profile_image_url: str = "" + verified: bool = False + + +@dataclass +class Metrics: + likes: int = 0 + retweets: int = 0 + replies: int = 0 + quotes: int = 0 + views: int = 0 + bookmarks: int = 0 + + +@dataclass +class TweetMedia: + type: str # "photo" | "video" | "animated_gif" + url: str + width: Optional[int] = None + height: Optional[int] = None + + +@dataclass +class Tweet: + id: str + text: str + author: Author + metrics: Metrics + created_at: str + media: List[TweetMedia] = field(default_factory=list) + urls: List[str] = field(default_factory=list) + is_retweet: bool = False + lang: str = "" + retweeted_by: Optional[str] = None + quoted_tweet: Optional[Tweet] = None + score: float = 0.0 diff --git a/twitter_cli/summarizer.py b/twitter_cli/summarizer.py new file mode 100644 index 0000000..1c0ef4d --- /dev/null +++ b/twitter_cli/summarizer.py @@ -0,0 +1,164 @@ +"""AI summarization module. + +Supports OpenAI-compatible (doubao, deepseek, openai) and Anthropic APIs. +Uses urllib.request for zero extra dependencies. +""" + +from __future__ import annotations + +import json +import logging +import ssl +import urllib.request +from typing import Any, Dict, List + +from .models import Tweet + +logger = logging.getLogger(__name__) + +SYSTEM_MESSAGE = "你是一个专业的 Twitter/X 信息流分析师,擅长提炼关键信息和发现趋势。" + + +def _build_prompt(tweets, language="zh-CN"): + # type: (List[Tweet], str) -> str + """Build the summarization prompt.""" + lines = [] + for i, t in enumerate(tweets): + score_str = " [score: %.1f]" % t.score if t.score else "" + rt = " (RT by @%s)" % t.retweeted_by if t.is_retweet and t.retweeted_by else "" + media_str = "" + if t.media: + media_str = " [%s]" % ", ".join(m.type for m in t.media) + url_str = "" + if t.urls: + url_str = "\n Links: %s" % ", ".join(t.urls) + quoted = "" + if t.quoted_tweet: + qt = t.quoted_tweet + quoted = "\n Quoting @%s: %s..." % (qt.author.screen_name, qt.text[:100].replace("\n", " ")) + + text_preview = t.text.replace("\n", " ")[:300] + lines.append( + '%d. @%s (%s)%s%s\n' + ' "%s"\n' + ' ❤️%d 🔄%d 💬%d 🔖%d 👁️%d%s%s%s' + % ( + i + 1, t.author.screen_name, t.author.name, rt, score_str, + text_preview, + t.metrics.likes, t.metrics.retweets, t.metrics.replies, + t.metrics.bookmarks, t.metrics.views, + media_str, url_str, quoted, + ) + ) + + tweet_summaries = "\n\n".join(lines) + + if language.startswith("zh"): + lang_inst = "请用中文输出。" + else: + lang_inst = "Please output in %s." % language + + return ( + "你是一个 Twitter/X 信息流分析师。请对以下 %d 条推文进行摘要总结。\n\n" + "要求:\n" + "1. 按主题分组(如:AI & 编程、Crypto、工具推荐、生活观点等)\n" + "2. 每组列出关键推文和核心观点,标注作者 @handle\n" + "3. 标注数据亮点(高赞/高收藏推文用 🔥 标记)\n" + "4. 最后用 2-3 句话总结今天 timeline 的整体趋势\n" + "5. %s\n\n" + "推文数据:\n\n%s" + ) % (len(tweets), lang_inst, tweet_summaries) + + +def _call_openai(prompt, config): + # type: (str, Dict[str, Any]) -> str + """Call OpenAI-compatible API.""" + url = config.get("base_url", "").rstrip("/") + if not url.endswith("/chat/completions"): + if not url.endswith("/v1"): + url += "/v1" + url += "/chat/completions" + + payload = json.dumps({ + "model": config.get("model", ""), + "messages": [ + {"role": "system", "content": SYSTEM_MESSAGE}, + {"role": "user", "content": prompt}, + ], + "temperature": 0.3, + "max_tokens": 4096, + }).encode("utf-8") + + req = urllib.request.Request(url, data=payload) + req.add_header("Content-Type", "application/json") + req.add_header("Authorization", "Bearer %s" % config.get("api_key", "")) + + ctx = ssl.create_default_context() + with urllib.request.urlopen(req, context=ctx, timeout=120) as resp: + data = json.loads(resp.read().decode("utf-8")) + + choices = data.get("choices", []) + if choices: + return choices[0].get("message", {}).get("content", "") + return "" + + +def _call_anthropic(prompt, config): + # type: (str, Dict[str, Any]) -> str + """Call Anthropic Messages API.""" + url = config.get("base_url", "").rstrip("/") + if not url.endswith("/messages"): + if not url.endswith("/v1"): + url += "/v1" + url += "/messages" + + payload = json.dumps({ + "model": config.get("model", ""), + "system": SYSTEM_MESSAGE, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.3, + "max_tokens": 4096, + }).encode("utf-8") + + req = urllib.request.Request(url, data=payload) + req.add_header("Content-Type", "application/json") + req.add_header("x-api-key", config.get("api_key", "")) + req.add_header("anthropic-version", "2023-06-01") + + ctx = ssl.create_default_context() + with urllib.request.urlopen(req, context=ctx, timeout=120) as resp: + data = json.loads(resp.read().decode("utf-8")) + + content_blocks = data.get("content", []) + for block in content_blocks: + if block.get("type") == "text": + return block.get("text", "") + return "" + + +def summarize(tweets, config): + # type: (List[Tweet], Dict[str, Any]) -> str + """Summarize tweets using the configured AI provider. + + Config keys: provider, api_key, model, base_url, language + """ + api_key = config.get("api_key", "") + if not api_key: + raise RuntimeError( + "AI API key not configured.\n" + "Set ai.api_key in config.yaml or export AI_API_KEY=your_key" + ) + + if not tweets: + return "No tweets to summarize." + + language = config.get("language", "zh-CN") + prompt = _build_prompt(tweets, language) + provider = config.get("provider", "openai") + + logger.info("Calling AI (%s/%s)...", provider, config.get("model", "")) + + if provider == "anthropic": + return _call_anthropic(prompt, config) + else: + return _call_openai(prompt, config) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..c25540b --- /dev/null +++ b/uv.lock @@ -0,0 +1,359 @@ +version = 1 +revision = 3 +requires-python = ">=3.8" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", + "python_full_version < '3.9'", +] + +[[package]] +name = "browser-cookie3" +version = "0.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jeepney", marker = "'bsd' in sys_platform or sys_platform == 'linux'" }, + { name = "lz4", version = "4.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "lz4", version = "4.4.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "pycryptodomex" }, + { name = "shadowcopy", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/e1/652adea0ce25948e613ef78294c8ceaf4b32844aae00680d3a1712dde444/browser_cookie3-0.20.1.tar.gz", hash = "sha256:6d8d0744bf42a5327c951bdbcf77741db3455b8b4e840e18bab266d598368a12", size = 22665, upload-time = "2024-12-20T00:31:30.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/57/2a716f4ecf6c50b2dbe27439507c480bb7ca5725edef82349ecdcfcdd084/browser_cookie3-0.20.1-py3-none-any.whl", hash = "sha256:4b38bf669d386250733c8339f0036e1cf09c3d8e4d326fd507b9afb84def13d6", size = 17229, upload-time = "2025-01-04T14:46:14.753Z" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", + "python_full_version < '3.9'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +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'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +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" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "lz4" +version = "4.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/31/ec1259ca8ad11568abaf090a7da719616ca96b60d097ccc5799cd0ff599c/lz4-4.3.3.tar.gz", hash = "sha256:01fe674ef2889dbb9899d8a67361e0c4a2c833af5aeb37dd505727cf5d2a131e", size = 171509, upload-time = "2024-01-01T23:03:13.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/53/61258b5effac76dea5768b07042b2c3c56e15a91194cef92284a0dc0f5e7/lz4-4.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b891880c187e96339474af2a3b2bfb11a8e4732ff5034be919aa9029484cd201", size = 254266, upload-time = "2024-01-01T23:02:12.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/84/c243a5515950d72ff04220fd49903801825e4ac23691e19e7082d9d9f94b/lz4-4.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:222a7e35137d7539c9c33bb53fcbb26510c5748779364014235afc62b0ec797f", size = 212359, upload-time = "2024-01-01T23:02:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/10/26/5287564a909d069fdd6c25f2f420c58c5758993fa3ad2e064a7b610e6e5f/lz4-4.3.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f76176492ff082657ada0d0f10c794b6da5800249ef1692b35cf49b1e93e8ef7", size = 1237799, upload-time = "2024-01-01T23:02:16.805Z" }, + { url = "https://files.pythonhosted.org/packages/cf/50/75c8f966dbcc524e7253f99b8e04c6cad7328f517eb0323abf8b4068f5bb/lz4-4.3.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1d18718f9d78182c6b60f568c9a9cec8a7204d7cb6fad4e511a2ef279e4cb05", size = 1263957, upload-time = "2024-01-01T23:02:18.953Z" }, + { url = "https://files.pythonhosted.org/packages/91/54/0f61c77a9599beb14ac5b828e8da20a04c6eaadb4f3fdbd79a817c66eb74/lz4-4.3.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6cdc60e21ec70266947a48839b437d46025076eb4b12c76bd47f8e5eb8a75dcc", size = 1184035, upload-time = "2024-01-01T23:02:20.535Z" }, + { url = "https://files.pythonhosted.org/packages/8e/84/3be7fad87d84b67cd43174d67fc567e0aa3be154f8b0a1c2c0ff8df30854/lz4-4.3.3-cp310-cp310-win32.whl", hash = "sha256:c81703b12475da73a5d66618856d04b1307e43428a7e59d98cfe5a5d608a74c6", size = 87235, upload-time = "2024-01-01T23:02:22.552Z" }, + { url = "https://files.pythonhosted.org/packages/21/08/dc4714eb771b502deec8a714e40e5fbd2242bacd5fe55dcd29a0cb35c567/lz4-4.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:43cf03059c0f941b772c8aeb42a0813d68d7081c009542301637e5782f8a33e2", size = 99781, upload-time = "2024-01-01T23:02:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f7/cfb942edd53c8a6aba168720ccf3d6a0cac3e891a7feba97d5823b5dd047/lz4-4.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:30e8c20b8857adef7be045c65f47ab1e2c4fabba86a9fa9a997d7674a31ea6b6", size = 254267, upload-time = "2024-01-01T23:02:25.993Z" }, + { url = "https://files.pythonhosted.org/packages/71/ca/046bd7e7e1ed4639eb398192374bc3fbf5010d3c168361fec161b63e8bfa/lz4-4.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2f7b1839f795315e480fb87d9bc60b186a98e3e5d17203c6e757611ef7dcef61", size = 212353, upload-time = "2024-01-01T23:02:28.022Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c2/5beb6a7bb7fd27cd5fe5bb93c15636d30987794b161e4609fbf20dc3b5c7/lz4-4.3.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edfd858985c23523f4e5a7526ca6ee65ff930207a7ec8a8f57a01eae506aaee7", size = 1239095, upload-time = "2024-01-01T23:02:29.319Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d4/12915eb3083dfd1746d50b71b73334030b129cd25abbed9133dd2d413c21/lz4-4.3.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e9c410b11a31dbdc94c05ac3c480cb4b222460faf9231f12538d0074e56c563", size = 1265760, upload-time = "2024-01-01T23:02:30.791Z" }, + { url = "https://files.pythonhosted.org/packages/94/7b/5e72b7504d7675b484812bfc65fe958f7649a64e0d6fe35c11812511f0b5/lz4-4.3.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d2507ee9c99dbddd191c86f0e0c8b724c76d26b0602db9ea23232304382e1f21", size = 1185451, upload-time = "2024-01-01T23:02:32.845Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b5/3726a678b3a0c64d24e71179e35e7ff8e3553da9d32c2fddce879d042b63/lz4-4.3.3-cp311-cp311-win32.whl", hash = "sha256:f180904f33bdd1e92967923a43c22899e303906d19b2cf8bb547db6653ea6e7d", size = 87232, upload-time = "2024-01-01T23:02:34.361Z" }, + { url = "https://files.pythonhosted.org/packages/55/f9/69ed96043dae4d982286a4dda2feb473f49e95e4c90a928ec583d93769a2/lz4-4.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:b14d948e6dce389f9a7afc666d60dd1e35fa2138a8ec5306d30cd2e30d36b40c", size = 99794, upload-time = "2024-01-01T23:02:35.651Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6f/081811b17ccaec5f06b3030756af2737841447849118a6e1078481a78c6c/lz4-4.3.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e36cd7b9d4d920d3bfc2369840da506fa68258f7bb176b8743189793c055e43d", size = 254213, upload-time = "2024-01-01T23:02:37.507Z" }, + { url = "https://files.pythonhosted.org/packages/53/4d/8e04ef75feff8848ba3c624ce81c7732bdcea5f8f994758afa88cd3d7764/lz4-4.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:31ea4be9d0059c00b2572d700bf2c1bc82f241f2c3282034a759c9a4d6ca4dc2", size = 212354, upload-time = "2024-01-01T23:02:38.795Z" }, + { url = "https://files.pythonhosted.org/packages/a3/04/257a72d6a879dbc8c669018989f776fcdd5b4bf3c2c51c09a54f1ca31721/lz4-4.3.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33c9a6fd20767ccaf70649982f8f3eeb0884035c150c0b818ea660152cf3c809", size = 1238643, upload-time = "2024-01-01T23:02:41.217Z" }, + { url = "https://files.pythonhosted.org/packages/d9/93/4a7e489156fa7ded03ba9cde4a8ca7f373672b5787cac9a0391befa752a1/lz4-4.3.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca8fccc15e3add173da91be8f34121578dc777711ffd98d399be35487c934bf", size = 1265014, upload-time = "2024-01-01T23:02:42.692Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a4/f84ebc23bc7602623b1b003b4e1120cbf86fb03a35c595c226be1985449b/lz4-4.3.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d84b479ddf39fe3ea05387f10b779155fc0990125f4fb35d636114e1c63a2e", size = 1184881, upload-time = "2024-01-01T23:02:44.053Z" }, + { url = "https://files.pythonhosted.org/packages/de/3d/8ba48305378e84908221de143a21ba0c0ce52778893865cf85b66b1068da/lz4-4.3.3-cp312-cp312-win32.whl", hash = "sha256:337cb94488a1b060ef1685187d6ad4ba8bc61d26d631d7ba909ee984ea736be1", size = 87241, upload-time = "2024-01-01T23:02:45.744Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5d/7b70965a0692de29af2af1007fe837f46fd456bbe2aa8f838a8543a3b5cb/lz4-4.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:5d35533bf2cee56f38ced91f766cd0038b6abf46f438a80d50c52750088be93f", size = 99776, upload-time = "2024-01-01T23:02:47.095Z" }, + { url = "https://files.pythonhosted.org/packages/34/aa/f3cdb730fc54845a733930db132b9b9e01299ee2316a1f4c30b7336d02bf/lz4-4.3.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:363ab65bf31338eb364062a15f302fc0fab0a49426051429866d71c793c23394", size = 254252, upload-time = "2024-01-01T23:02:49.461Z" }, + { url = "https://files.pythonhosted.org/packages/da/93/f6a57e1b6700fe859a43bbe6c6235c16fee22189297edfe9ab16b2b6e9a8/lz4-4.3.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0a136e44a16fc98b1abc404fbabf7f1fada2bdab6a7e970974fb81cf55b636d0", size = 212352, upload-time = "2024-01-01T23:02:51.388Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f8/906a0033c36ba83f43e4cbd0bd271bdd268b6e91179f9784144983df772e/lz4-4.3.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abc197e4aca8b63f5ae200af03eb95fb4b5055a8f990079b5bdf042f568469dd", size = 1238847, upload-time = "2024-01-01T23:02:53.332Z" }, + { url = "https://files.pythonhosted.org/packages/3d/9e/c22ae78e8e4459af27a8a4e80ae93047809bf4108aafa1d1414b57638fd2/lz4-4.3.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56f4fe9c6327adb97406f27a66420b22ce02d71a5c365c48d6b656b4aaeb7775", size = 1265018, upload-time = "2024-01-01T23:02:54.771Z" }, + { url = "https://files.pythonhosted.org/packages/9c/33/31fe8904a8eb1f2d4deec1538c2797ad80bc05aaa55fcd6207217a0a6ff7/lz4-4.3.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0e822cd7644995d9ba248cb4b67859701748a93e2ab7fc9bc18c599a52e4604", size = 1185021, upload-time = "2024-01-01T23:02:56.343Z" }, + { url = "https://files.pythonhosted.org/packages/10/84/957d1427414d787a1350158c1f6e0e672e5b631315e993d111f68011e0d2/lz4-4.3.3-cp38-cp38-win32.whl", hash = "sha256:24b3206de56b7a537eda3a8123c644a2b7bf111f0af53bc14bed90ce5562d1aa", size = 87231, upload-time = "2024-01-01T23:02:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f5/d7564e562e349f882924e4f57cbe699d2e510cc143ea6646feffceab4b9d/lz4-4.3.3-cp38-cp38-win_amd64.whl", hash = "sha256:b47839b53956e2737229d70714f1d75f33e8ac26e52c267f0197b3189ca6de24", size = 99785, upload-time = "2024-01-01T23:02:59.587Z" }, + { url = "https://files.pythonhosted.org/packages/8c/50/02c6024b56517555b6a4e7e66d429ac643e62995c617f519890d74e6acaa/lz4-4.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6756212507405f270b66b3ff7f564618de0606395c0fe10a7ae2ffcbbe0b1fba", size = 254250, upload-time = "2024-01-01T23:03:00.89Z" }, + { url = "https://files.pythonhosted.org/packages/c5/db/0ace70b2545d90d14e7edd02d283624bc4c34bb9a4735641c4250ac5eebe/lz4-4.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ee9ff50557a942d187ec85462bb0960207e7ec5b19b3b48949263993771c6205", size = 212360, upload-time = "2024-01-01T23:03:03.059Z" }, + { url = "https://files.pythonhosted.org/packages/af/0c/8c6b3426e7f40b89cffdc094e7bb205f1bddbe540a00f720565b3dc025b1/lz4-4.3.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b901c7784caac9a1ded4555258207d9e9697e746cc8532129f150ffe1f6ba0d", size = 1237170, upload-time = "2024-01-01T23:03:04.569Z" }, + { url = "https://files.pythonhosted.org/packages/10/39/baa1138796c410449ec1d8942cd8105c1ed41745e2b16f64dbe02ff10ee3/lz4-4.3.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6d9ec061b9eca86e4dcc003d93334b95d53909afd5a32c6e4f222157b50c071", size = 1263305, upload-time = "2024-01-01T23:03:06.687Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/2d94c35667928fe2bea272d9cbdfcd1c847eb47abe19d8abe5464a0469da/lz4-4.3.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f4c7bf687303ca47d69f9f0133274958fd672efaa33fb5bcde467862d6c621f0", size = 1183475, upload-time = "2024-01-01T23:03:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f6/8ecd4100e9650d615cb380482716fbdecd5e968b50d5d2edcf7acb25762c/lz4-4.3.3-cp39-cp39-win32.whl", hash = "sha256:054b4631a355606e99a42396f5db4d22046a3397ffc3269a348ec41eaebd69d2", size = 87230, upload-time = "2024-01-01T23:03:10.469Z" }, + { url = "https://files.pythonhosted.org/packages/46/e0/d1260caaea03089ac9bbf4cce3e1afc8affbeb9719aeb4f0e2430b15329a/lz4-4.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:eac9af361e0d98335a02ff12fb56caeb7ea1196cf1a49dbf6f17828a131da807", size = 99784, upload-time = "2024-01-01T23:03:11.733Z" }, +] + +[[package]] +name = "lz4" +version = "4.4.5" +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/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/45/2466d73d79e3940cad4b26761f356f19fd33f4409c96f100e01a5c566909/lz4-4.4.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d221fa421b389ab2345640a508db57da36947a437dfe31aeddb8d5c7b646c22d", size = 207396, upload-time = "2025-11-03T13:01:24.965Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/7da96077a7e8918a5a57a25f1254edaf76aefb457666fcc1066deeecd609/lz4-4.4.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7dc1e1e2dbd872f8fae529acd5e4839efd0b141eaa8ae7ce835a9fe80fbad89f", size = 207154, upload-time = "2025-11-03T13:01:26.922Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0e/0fb54f84fd1890d4af5bc0a3c1fa69678451c1a6bd40de26ec0561bb4ec5/lz4-4.4.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e928ec2d84dc8d13285b4a9288fd6246c5cde4f5f935b479f50d986911f085e3", size = 1291053, upload-time = "2025-11-03T13:01:28.396Z" }, + { url = "https://files.pythonhosted.org/packages/15/45/8ce01cc2715a19c9e72b0e423262072c17d581a8da56e0bd4550f3d76a79/lz4-4.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daffa4807ef54b927451208f5f85750c545a4abbff03d740835fc444cd97f758", size = 1278586, upload-time = "2025-11-03T13:01:29.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/34/7be9b09015e18510a09b8d76c304d505a7cbc66b775ec0b8f61442316818/lz4-4.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a2b7504d2dffed3fd19d4085fe1cc30cf221263fd01030819bdd8d2bb101cf1", size = 1367315, upload-time = "2025-11-03T13:01:31.054Z" }, + { url = "https://files.pythonhosted.org/packages/2a/94/52cc3ec0d41e8d68c985ec3b2d33631f281d8b748fb44955bc0384c2627b/lz4-4.4.5-cp310-cp310-win32.whl", hash = "sha256:0846e6e78f374156ccf21c631de80967e03cc3c01c373c665789dc0c5431e7fc", size = 88173, upload-time = "2025-11-03T13:01:32.643Z" }, + { url = "https://files.pythonhosted.org/packages/ca/35/c3c0bdc409f551404355aeeabc8da343577d0e53592368062e371a3620e1/lz4-4.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:7c4e7c44b6a31de77d4dc9772b7d2561937c9588a734681f70ec547cfbc51ecd", size = 99492, upload-time = "2025-11-03T13:01:33.813Z" }, + { url = "https://files.pythonhosted.org/packages/1d/02/4d88de2f1e97f9d05fd3d278fe412b08969bc94ff34942f5a3f09318144a/lz4-4.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:15551280f5656d2206b9b43262799c89b25a25460416ec554075a8dc568e4397", size = 91280, upload-time = "2025-11-03T13:01:35.081Z" }, + { url = "https://files.pythonhosted.org/packages/93/5b/6edcd23319d9e28b1bedf32768c3d1fd56eed8223960a2c47dacd2cec2af/lz4-4.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d6da84a26b3aa5da13a62e4b89ab36a396e9327de8cd48b436a3467077f8ccd4", size = 207391, upload-time = "2025-11-03T13:01:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/34/36/5f9b772e85b3d5769367a79973b8030afad0d6b724444083bad09becd66f/lz4-4.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61d0ee03e6c616f4a8b69987d03d514e8896c8b1b7cc7598ad029e5c6aedfd43", size = 207146, upload-time = "2025-11-03T13:01:37.928Z" }, + { url = "https://files.pythonhosted.org/packages/04/f4/f66da5647c0d72592081a37c8775feacc3d14d2625bbdaabd6307c274565/lz4-4.4.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:33dd86cea8375d8e5dd001e41f321d0a4b1eb7985f39be1b6a4f466cd480b8a7", size = 1292623, upload-time = "2025-11-03T13:01:39.341Z" }, + { url = "https://files.pythonhosted.org/packages/85/fc/5df0f17467cdda0cad464a9197a447027879197761b55faad7ca29c29a04/lz4-4.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609a69c68e7cfcfa9d894dc06be13f2e00761485b62df4e2472f1b66f7b405fb", size = 1279982, upload-time = "2025-11-03T13:01:40.816Z" }, + { url = "https://files.pythonhosted.org/packages/25/3b/b55cb577aa148ed4e383e9700c36f70b651cd434e1c07568f0a86c9d5fbb/lz4-4.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75419bb1a559af00250b8f1360d508444e80ed4b26d9d40ec5b09fe7875cb989", size = 1368674, upload-time = "2025-11-03T13:01:42.118Z" }, + { url = "https://files.pythonhosted.org/packages/fb/31/e97e8c74c59ea479598e5c55cbe0b1334f03ee74ca97726e872944ed42df/lz4-4.4.5-cp311-cp311-win32.whl", hash = "sha256:12233624f1bc2cebc414f9efb3113a03e89acce3ab6f72035577bc61b270d24d", size = 88168, upload-time = "2025-11-03T13:01:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/18/47/715865a6c7071f417bef9b57c8644f29cb7a55b77742bd5d93a609274e7e/lz4-4.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:8a842ead8ca7c0ee2f396ca5d878c4c40439a527ebad2b996b0444f0074ed004", size = 99491, upload-time = "2025-11-03T13:01:44.167Z" }, + { url = "https://files.pythonhosted.org/packages/14/e7/ac120c2ca8caec5c945e6356ada2aa5cfabd83a01e3170f264a5c42c8231/lz4-4.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:83bc23ef65b6ae44f3287c38cbf82c269e2e96a26e560aa551735883388dcc4b", size = 91271, upload-time = "2025-11-03T13:01:45.016Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/016e4f6de37d806f7cc8f13add0a46c9a7cfc41a5ddc2bc831d7954cf1ce/lz4-4.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5aa4cead2044bab83e0ebae56e0944cc7fcc1505c7787e9e1057d6d549897e", size = 207163, upload-time = "2025-11-03T13:01:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/8d/df/0fadac6e5bd31b6f34a1a8dbd4db6a7606e70715387c27368586455b7fc9/lz4-4.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d0bf51e7745484d2092b3a51ae6eb58c3bd3ce0300cf2b2c14f76c536d5697a", size = 207150, upload-time = "2025-11-03T13:01:47.205Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/34e36cc49bb16ca73fb57fbd4c5eaa61760c6b64bce91fcb4e0f4a97f852/lz4-4.4.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b62f94b523c251cf32aa4ab555f14d39bd1a9df385b72443fd76d7c7fb051f5", size = 1292045, upload-time = "2025-11-03T13:01:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/90/1c/b1d8e3741e9fc89ed3b5f7ef5f22586c07ed6bb04e8343c2e98f0fa7ff04/lz4-4.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c3ea562c3af274264444819ae9b14dbbf1ab070aff214a05e97db6896c7597e", size = 1279546, upload-time = "2025-11-03T13:01:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/e3867222474f6c1b76e89f3bd914595af69f55bf2c1866e984c548afdc15/lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e", size = 1368249, upload-time = "2025-11-03T13:01:51.273Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e7/d667d337367686311c38b580d1ca3d5a23a6617e129f26becd4f5dc458df/lz4-4.4.5-cp312-cp312-win32.whl", hash = "sha256:214e37cfe270948ea7eb777229e211c601a3e0875541c1035ab408fbceaddf50", size = 88189, upload-time = "2025-11-03T13:01:52.605Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0b/a54cd7406995ab097fceb907c7eb13a6ddd49e0b231e448f1a81a50af65c/lz4-4.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:713a777de88a73425cf08eb11f742cd2c98628e79a8673d6a52e3c5f0c116f33", size = 99497, upload-time = "2025-11-03T13:01:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7e/dc28a952e4bfa32ca16fa2eb026e7a6ce5d1411fcd5986cd08c74ec187b9/lz4-4.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:a88cbb729cc333334ccfb52f070463c21560fca63afcf636a9f160a55fac3301", size = 91279, upload-time = "2025-11-03T13:01:54.419Z" }, + { url = "https://files.pythonhosted.org/packages/2f/46/08fd8ef19b782f301d56a9ccfd7dafec5fd4fc1a9f017cf22a1accb585d7/lz4-4.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6bb05416444fafea170b07181bc70640975ecc2a8c92b3b658c554119519716c", size = 207171, upload-time = "2025-11-03T13:01:56.595Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3f/ea3334e59de30871d773963997ecdba96c4584c5f8007fd83cfc8f1ee935/lz4-4.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b424df1076e40d4e884cfcc4c77d815368b7fb9ebcd7e634f937725cd9a8a72a", size = 207163, upload-time = "2025-11-03T13:01:57.721Z" }, + { url = "https://files.pythonhosted.org/packages/41/7b/7b3a2a0feb998969f4793c650bb16eff5b06e80d1f7bff867feb332f2af2/lz4-4.4.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:216ca0c6c90719731c64f41cfbd6f27a736d7e50a10b70fad2a9c9b262ec923d", size = 1292136, upload-time = "2025-11-03T13:02:00.375Z" }, + { url = "https://files.pythonhosted.org/packages/89/d1/f1d259352227bb1c185288dd694121ea303e43404aa77560b879c90e7073/lz4-4.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:533298d208b58b651662dd972f52d807d48915176e5b032fb4f8c3b6f5fe535c", size = 1279639, upload-time = "2025-11-03T13:02:01.649Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fb/ba9256c48266a09012ed1d9b0253b9aa4fe9cdff094f8febf5b26a4aa2a2/lz4-4.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451039b609b9a88a934800b5fc6ee401c89ad9c175abf2f4d9f8b2e4ef1afc64", size = 1368257, upload-time = "2025-11-03T13:02:03.35Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6d/dee32a9430c8b0e01bbb4537573cabd00555827f1a0a42d4e24ca803935c/lz4-4.4.5-cp313-cp313-win32.whl", hash = "sha256:a5f197ffa6fc0e93207b0af71b302e0a2f6f29982e5de0fbda61606dd3a55832", size = 88191, upload-time = "2025-11-03T13:02:04.406Z" }, + { url = "https://files.pythonhosted.org/packages/18/e0/f06028aea741bbecb2a7e9648f4643235279a770c7ffaf70bd4860c73661/lz4-4.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:da68497f78953017deb20edff0dba95641cc86e7423dfadf7c0264e1ac60dc22", size = 99502, upload-time = "2025-11-03T13:02:05.886Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/5bef44afb303e56078676b9f2486f13173a3c1e7f17eaac1793538174817/lz4-4.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:c1cfa663468a189dab510ab231aad030970593f997746d7a324d40104db0d0a9", size = 91285, upload-time = "2025-11-03T13:02:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/49/55/6a5c2952971af73f15ed4ebfdd69774b454bd0dc905b289082ca8664fba1/lz4-4.4.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67531da3b62f49c939e09d56492baf397175ff39926d0bd5bd2d191ac2bff95f", size = 207348, upload-time = "2025-11-03T13:02:08.117Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d7/fd62cbdbdccc35341e83aabdb3f6d5c19be2687d0a4eaf6457ddf53bba64/lz4-4.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a1acbbba9edbcbb982bc2cac5e7108f0f553aebac1040fbec67a011a45afa1ba", size = 207340, upload-time = "2025-11-03T13:02:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/225ffadaacb4b0e0eb5fd263541edd938f16cd21fe1eae3cd6d5b6a259dc/lz4-4.4.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a482eecc0b7829c89b498fda883dbd50e98153a116de612ee7c111c8bcf82d1d", size = 1293398, upload-time = "2025-11-03T13:02:10.272Z" }, + { url = "https://files.pythonhosted.org/packages/c6/9e/2ce59ba4a21ea5dc43460cba6f34584e187328019abc0e66698f2b66c881/lz4-4.4.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e099ddfaa88f59dd8d36c8a3c66bd982b4984edf127eb18e30bb49bdba68ce67", size = 1281209, upload-time = "2025-11-03T13:02:12.091Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/4d946bd1624ec229b386a3bc8e7a85fa9a963d67d0a62043f0af0978d3da/lz4-4.4.5-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2af2897333b421360fdcce895c6f6281dc3fab018d19d341cf64d043fc8d90d", size = 1369406, upload-time = "2025-11-03T13:02:13.683Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/d429ba4720a9064722698b4b754fb93e42e625f1318b8fe834086c7c783b/lz4-4.4.5-cp313-cp313t-win32.whl", hash = "sha256:66c5de72bf4988e1b284ebdd6524c4bead2c507a2d7f172201572bac6f593901", size = 88325, upload-time = "2025-11-03T13:02:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/7ba10c9b97c06af6c8f7032ec942ff127558863df52d866019ce9d2425cf/lz4-4.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:cdd4bdcbaf35056086d910d219106f6a04e1ab0daa40ec0eeef1626c27d0fddb", size = 99643, upload-time = "2025-11-03T13:02:15.978Z" }, + { url = "https://files.pythonhosted.org/packages/77/4d/a175459fb29f909e13e57c8f475181ad8085d8d7869bd8ad99033e3ee5fa/lz4-4.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:28ccaeb7c5222454cd5f60fcd152564205bcb801bd80e125949d2dfbadc76bbd", size = 91504, upload-time = "2025-11-03T13:02:17.313Z" }, + { url = "https://files.pythonhosted.org/packages/63/9c/70bdbdb9f54053a308b200b4678afd13efd0eafb6ddcbb7f00077213c2e5/lz4-4.4.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c216b6d5275fc060c6280936bb3bb0e0be6126afb08abccde27eed23dead135f", size = 207586, upload-time = "2025-11-03T13:02:18.263Z" }, + { url = "https://files.pythonhosted.org/packages/b6/cb/bfead8f437741ce51e14b3c7d404e3a1f6b409c440bad9b8f3945d4c40a7/lz4-4.4.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c8e71b14938082ebaf78144f3b3917ac715f72d14c076f384a4c062df96f9df6", size = 207161, upload-time = "2025-11-03T13:02:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/e7/18/b192b2ce465dfbeabc4fc957ece7a1d34aded0d95a588862f1c8a86ac448/lz4-4.4.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b5e6abca8df9f9bdc5c3085f33ff32cdc86ed04c65e0355506d46a5ac19b6e9", size = 1292415, upload-time = "2025-11-03T13:02:20.829Z" }, + { url = "https://files.pythonhosted.org/packages/67/79/a4e91872ab60f5e89bfad3e996ea7dc74a30f27253faf95865771225ccba/lz4-4.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b84a42da86e8ad8537aabef062e7f661f4a877d1c74d65606c49d835d36d668", size = 1279920, upload-time = "2025-11-03T13:02:22.013Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/d52c7b11eaa286d49dae619c0eec4aabc0bf3cda7a7467eb77c62c4471f3/lz4-4.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bba042ec5a61fa77c7e380351a61cb768277801240249841defd2ff0a10742f", size = 1368661, upload-time = "2025-11-03T13:02:23.208Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/137ddeea14c2cb86864838277b2607d09f8253f152156a07f84e11768a28/lz4-4.4.5-cp314-cp314-win32.whl", hash = "sha256:bd85d118316b53ed73956435bee1997bd06cc66dd2fa74073e3b1322bd520a67", size = 90139, upload-time = "2025-11-03T13:02:24.301Z" }, + { url = "https://files.pythonhosted.org/packages/18/2c/8332080fd293f8337779a440b3a143f85e374311705d243439a3349b81ad/lz4-4.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:92159782a4502858a21e0079d77cdcaade23e8a5d252ddf46b0652604300d7be", size = 101497, upload-time = "2025-11-03T13:02:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/ca/28/2635a8141c9a4f4bc23f5135a92bbcf48d928d8ca094088c962df1879d64/lz4-4.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:d994b87abaa7a88ceb7a37c90f547b8284ff9da694e6afcfaa8568d739faf3f7", size = 93812, upload-time = "2025-11-03T13:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/508f2ee73c126e4de53a3b8523ad14d666aeb00a6795425315f770dbf2f4/lz4-4.4.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f6538aaaedd091d6e5abdaa19b99e6e82697d67518f114721b5248709b639fad", size = 207384, upload-time = "2025-11-03T13:02:27.043Z" }, + { url = "https://files.pythonhosted.org/packages/64/84/da7fda86dcc7b6d40d45dd28201fc136adfc390815126db41411bf1e5205/lz4-4.4.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:13254bd78fef50105872989a2dc3418ff09aefc7d0765528adc21646a7288294", size = 207137, upload-time = "2025-11-03T13:02:28.021Z" }, + { url = "https://files.pythonhosted.org/packages/01/95/fb9c5bffed0f985eab70daf2087a94ad55cbbf83024175f39ff663f48b22/lz4-4.4.5-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e64e61f29cf95afb43549063d8433b46352baf0c8a70aa45e2585618fcf59d86", size = 1290508, upload-time = "2025-11-03T13:02:29.485Z" }, + { url = "https://files.pythonhosted.org/packages/57/6e/6a39b5ca9b9538cc9d61248c431065ad76cc0f10b40cb07d60b5bdde7750/lz4-4.4.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff1b50aeeec64df5603f17984e4b5be6166058dcf8f1e26a3da40d7a0f6ab547", size = 1278102, upload-time = "2025-11-03T13:02:30.878Z" }, + { url = "https://files.pythonhosted.org/packages/73/57/551a7f95825c9721d8bee4ec02d8b139b1a44796e63d09a737ca0d67b6b1/lz4-4.4.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1dd4d91d25937c2441b9fc0f4af01704a2d09f30a38c5798bc1d1b5a15ec9581", size = 1366651, upload-time = "2025-11-03T13:02:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/4f/85/daa1ae5695ce40924813257d7f5a8990ba5dd78a9170f912dd85c498f97c/lz4-4.4.5-cp39-cp39-win32.whl", hash = "sha256:d64141085864918392c3159cdad15b102a620a67975c786777874e1e90ef15ce", size = 88165, upload-time = "2025-11-03T13:02:33.413Z" }, + { url = "https://files.pythonhosted.org/packages/df/db/3e84e506fdd5e04c9e8564d30bb08b0f3103dd9a2fb863c86bd46accb99a/lz4-4.4.5-cp39-cp39-win_amd64.whl", hash = "sha256:f32b9e65d70f3684532358255dc053f143835c5f5991e28a5ac4c93ce94b9ea7", size = 99487, upload-time = "2025-11-03T13:02:34.246Z" }, + { url = "https://files.pythonhosted.org/packages/6a/85/40aa9d006fdebc4ae868c86ce2108a9453c2b524284817427de1284b5b00/lz4-4.4.5-cp39-cp39-win_arm64.whl", hash = "sha256:f9b8bde9909a010c75b3aea58ec3910393b758f3c219beed67063693df854db0", size = 91275, upload-time = "2025-11-03T13:02:35.117Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", + "python_full_version < '3.9'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +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" }, +] + +[[package]] +name = "pycryptodomex" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z" }, + { url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z" }, + { url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z" }, + { url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" }, + { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" }, + { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b8/3e76d948c3c4ac71335bbe75dac53e154b40b0f8f1f022dfa295257a0c96/pycryptodomex-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ebfff755c360d674306e5891c564a274a47953562b42fb74a5c25b8fc1fb1cb5", size = 1627695, upload-time = "2025-05-17T17:23:17.38Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/80f4297a4820dfdfd1c88cf6c4666a200f204b3488103d027b5edd9176ec/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eca54f4bb349d45afc17e3011ed4264ef1cc9e266699874cdd1349c504e64798", size = 1675772, upload-time = "2025-05-17T17:23:19.202Z" }, + { url = "https://files.pythonhosted.org/packages/d1/42/1e969ee0ad19fe3134b0e1b856c39bd0b70d47a4d0e81c2a8b05727394c9/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2596e643d4365e14d0879dc5aafe6355616c61c2176009270f3048f6d9a61f", size = 1668083, upload-time = "2025-05-17T17:23:21.867Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c3/1de4f7631fea8a992a44ba632aa40e0008764c0fb9bf2854b0acf78c2cf2/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fdfac7cda115bca3a5abb2f9e43bc2fb66c2b65ab074913643803ca7083a79ea", size = 1706056, upload-time = "2025-05-17T17:23:24.031Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5f/af7da8e6f1e42b52f44a24d08b8e4c726207434e2593732d39e7af5e7256/pycryptodomex-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:14c37aaece158d0ace436f76a7bb19093db3b4deade9797abfc39ec6cd6cc2fe", size = 1806478, upload-time = "2025-05-17T17:23:26.066Z" }, + { url = "https://files.pythonhosted.org/packages/e2/eb/022ae689a90f4101847d3f43c2319b3f7f5ed53ba6a49b2c7af7d72c2523/pycryptodomex-3.23.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7de1e40a41a5d7f1ac42b6569b10bcdded34339950945948529067d8426d2785", size = 1627595, upload-time = "2025-05-17T17:23:28.211Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5f/566de54abb78a0a7f4ca7730e8a1fd372509e257d15d9f0f076aa30e73a5/pycryptodomex-3.23.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bffc92138d75664b6d543984db7893a628559b9e78658563b0395e2a5fb47ed9", size = 1675678, upload-time = "2025-05-17T17:23:30.36Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e4/8240294e46b1ceb027b432be861b641752486691f675b9f0a4b0495c1cb5/pycryptodomex-3.23.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df027262368334552db2c0ce39706b3fb32022d1dce34673d0f9422df004b96a", size = 1667977, upload-time = "2025-05-17T17:23:32.922Z" }, + { url = "https://files.pythonhosted.org/packages/0e/ac/2b8eee86b73811e3d814e429f3aeebf84ca07a5c1912a0a33246bfc4675f/pycryptodomex-3.23.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e79f1aaff5a3a374e92eb462fa9e598585452135012e2945f96874ca6eeb1ff", size = 1705980, upload-time = "2025-05-17T17:23:34.796Z" }, + { url = "https://files.pythonhosted.org/packages/37/be/2e75f36f368068d87656a04e07f998fd345a5ba7a3a56fa8c3f80484a506/pycryptodomex-3.23.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:27e13c80ac9a0a1d050ef0a7e0a18cc04c8850101ec891815b6c5a0375e8a245", size = 1806361, upload-time = "2025-05-17T17:23:37.331Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +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" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, + { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, + { url = "https://files.pythonhosted.org/packages/75/20/6cd04d636a4c83458ecbb7c8220c13786a1a80d3f5fb568df39310e73e98/pywin32-311-cp38-cp38-win32.whl", hash = "sha256:6c6f2969607b5023b0d9ce2541f8d2cbb01c4f46bc87456017cf63b73f1e2d8c", size = 8766775, upload-time = "2025-07-14T20:12:55.029Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/94c10268bae5d0d0c6509bdfb5aa08882d11a9ccdf89ff1cde59a6161afb/pywin32-311-cp38-cp38-win_amd64.whl", hash = "sha256:c8015b09fb9a5e188f83b7b04de91ddca4658cee2ae6f3bc483f0b21a77ef6cd", size = 9594743, upload-time = "2025-07-14T20:12:57.59Z" }, + { url = "https://files.pythonhosted.org/packages/59/42/b86689aac0cdaee7ae1c58d464b0ff04ca909c19bb6502d4973cdd9f9544/pywin32-311-cp39-cp39-win32.whl", hash = "sha256:aba8f82d551a942cb20d4a83413ccbac30790b50efb89a75e4f586ac0bb8056b", size = 8760837, upload-time = "2025-07-14T20:12:59.59Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8a/1403d0353f8c5a2f0829d2b1c4becbf9da2f0a4d040886404fc4a5431e4d/pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91", size = 9590187, upload-time = "2025-07-14T20:13:01.419Z" }, + { 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 = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +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" }, +] + +[[package]] +name = "shadowcopy" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wmi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/44/c00420a3b7bcdf830529b933392b566c876a4944a24f31e126eb6fd28647/shadowcopy-0.0.4.tar.gz", hash = "sha256:ed89817dda065f893607a04c0b7d6b3b34c3507a4711f441111a4bcb1b1826c0", size = 4138, upload-time = "2023-07-08T00:01:35.266Z" } +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" }, +] + +[[package]] +name = "twitter-cli" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { 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.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "rich" }, +] + +[package.metadata] +requires-dist = [ + { name = "browser-cookie3", specifier = ">=0.19" }, + { name = "click", specifier = ">=8.0" }, + { name = "rich", specifier = ">=13.0" }, +] + +[[package]] +name = "wmi" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/66/6364deb0a03415f96c66803d8c4379f808f2401da3bdb183348487b10510/WMI-1.5.1.tar.gz", hash = "sha256:b6a6be5711b1b6c8d55bda7a8befd75c48c12b770b9d227d31c1737dbf0d40a6", size = 26254, upload-time = "2020-04-28T08:22:58.096Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/b9/a80d1ed4d115dac8e2ac08d16af046a77ab58e3d186e22395bf2add24090/WMI-1.5.1-py2.py3-none-any.whl", hash = "sha256:1d6b085e5c445141c475476000b661f60fff1aaa19f76bf82b7abb92e0ff4942", size = 28912, upload-time = "2020-04-28T08:22:56.055Z" }, +]