Initial commit: twitter-cli v0.1.0
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
.env
|
||||
*.json
|
||||
!config.yaml
|
||||
127
README.md
Normal file
127
README.md
Normal file
@@ -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 自动检测,无需手动维护
|
||||
22
config.yaml
Normal file
22
config.yaml
Normal file
@@ -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"
|
||||
40
pyproject.toml
Normal file
40
pyproject.toml
Normal file
@@ -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"
|
||||
3
twitter_cli/__init__.py
Normal file
3
twitter_cli/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""twitter-cli: A CLI for Twitter/X."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
125
twitter_cli/auth.py
Normal file
125
twitter_cli/auth.py
Normal file
@@ -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"
|
||||
)
|
||||
290
twitter_cli/cli.py
Normal file
290
twitter_cli/cli.py
Normal file
@@ -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()
|
||||
470
twitter_cli/client.py
Normal file
470
twitter_cli/client.py
Normal file
@@ -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
|
||||
175
twitter_cli/config.py
Normal file
175
twitter_cli/config.py
Normal file
@@ -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
|
||||
90
twitter_cli/filter.py
Normal file
90
twitter_cli/filter.py
Normal file
@@ -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
|
||||
207
twitter_cli/formatter.py
Normal file
207
twitter_cli/formatter.py
Normal file
@@ -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)
|
||||
52
twitter_cli/models.py
Normal file
52
twitter_cli/models.py
Normal file
@@ -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
|
||||
164
twitter_cli/summarizer.py
Normal file
164
twitter_cli/summarizer.py
Normal file
@@ -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)
|
||||
359
uv.lock
generated
Normal file
359
uv.lock
generated
Normal file
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user