feat: Implement thread-safe account and statistics management and integrate proxy support for all external requests.

This commit is contained in:
2026-02-13 03:32:27 +08:00
parent 1c58279292
commit ef23318090
12 changed files with 1041 additions and 84 deletions

180
account_store.py Normal file
View File

@@ -0,0 +1,180 @@
"""
线程安全的账号存储模块
统一管理 accounts.txt 的读写操作,避免并发冲突。
支持删除、统计等功能。
"""
import json
import threading
from pathlib import Path
_ACCOUNTS_FILE = Path(__file__).parent / "accounts.txt"
_STATS_FILE = Path(__file__).parent / "stats.json"
_lock = threading.Lock()
# ====== 账号操作 ======
def append(email: str, session_key: str, org_uuid: str) -> None:
"""追加一个账号"""
with _lock:
with open(_ACCOUNTS_FILE, "a", encoding="utf-8") as f:
f.write(f"{email}|{session_key}|{org_uuid}\n")
def read_all() -> list[dict]:
"""读取所有账号"""
with _lock:
try:
with open(_ACCOUNTS_FILE, "r", encoding="utf-8") as f:
lines = [line.strip() for line in f if line.strip()]
except FileNotFoundError:
return []
result = []
for line in lines:
parts = line.split("|")
result.append({
"email": parts[0] if len(parts) > 0 else "",
"session_key": parts[1] if len(parts) > 1 else "",
"org_uuid": parts[2] if len(parts) > 2 else "",
})
return result
def read_lines() -> list[str]:
"""读取所有原始行"""
with _lock:
try:
with open(_ACCOUNTS_FILE, "r", encoding="utf-8") as f:
return [line.strip() for line in f if line.strip()]
except FileNotFoundError:
return []
def get_last() -> dict | None:
"""获取最后一个账号"""
accounts = read_all()
return accounts[-1] if accounts else None
def get_last_line() -> str | None:
"""获取最后一行原始数据"""
lines = read_lines()
return lines[-1] if lines else None
def count() -> int:
"""账号总数"""
return len(read_all())
def delete_by_index(index: int) -> dict | None:
"""删除指定序号的账号1-based返回被删除的账号信息"""
with _lock:
try:
with open(_ACCOUNTS_FILE, "r", encoding="utf-8") as f:
lines = [line.strip() for line in f if line.strip()]
except FileNotFoundError:
return None
if index < 1 or index > len(lines):
return None
removed_line = lines.pop(index - 1)
with open(_ACCOUNTS_FILE, "w", encoding="utf-8") as f:
for line in lines:
f.write(line + "\n")
parts = removed_line.split("|")
return {
"email": parts[0] if len(parts) > 0 else "",
"session_key": parts[1] if len(parts) > 1 else "",
"org_uuid": parts[2] if len(parts) > 2 else "",
}
def delete_by_email(email: str) -> dict | None:
"""按邮箱删除账号"""
with _lock:
try:
with open(_ACCOUNTS_FILE, "r", encoding="utf-8") as f:
lines = [line.strip() for line in f if line.strip()]
except FileNotFoundError:
return None
removed = None
remaining = []
for line in lines:
parts = line.split("|")
if parts[0] == email and removed is None:
removed = {
"email": parts[0] if len(parts) > 0 else "",
"session_key": parts[1] if len(parts) > 1 else "",
"org_uuid": parts[2] if len(parts) > 2 else "",
}
else:
remaining.append(line)
if removed:
with open(_ACCOUNTS_FILE, "w", encoding="utf-8") as f:
for line in remaining:
f.write(line + "\n")
return removed
# ====== 统计数据 ======
def _load_stats() -> dict:
try:
with open(_STATS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {
"register_total": 0,
"register_success": 0,
"register_fail": 0,
"register_fail_reasons": {},
"cc_total": 0,
"cc_pass": 0,
"cc_fail": 0,
}
def _save_stats(stats: dict):
with open(_STATS_FILE, "w", encoding="utf-8") as f:
json.dump(stats, f, ensure_ascii=False, indent=2)
def record_register(success: bool, fail_reason: str = ""):
"""记录一次注册结果"""
with _lock:
stats = _load_stats()
stats["register_total"] += 1
if success:
stats["register_success"] += 1
else:
stats["register_fail"] += 1
if fail_reason:
reasons = stats.setdefault("register_fail_reasons", {})
reasons[fail_reason] = reasons.get(fail_reason, 0) + 1
_save_stats(stats)
def record_cc(passed: bool):
"""记录一次 CC 检查结果"""
with _lock:
stats = _load_stats()
stats["cc_total"] += 1
if passed:
stats["cc_pass"] += 1
else:
stats["cc_fail"] += 1
_save_stats(stats)
def get_stats() -> dict:
"""获取统计数据"""
with _lock:
return _load_stats()

537
bot.py
View File

@@ -5,16 +5,19 @@ autoClaude Telegram Bot
"""
import asyncio
import json
import logging
import os
import random
import string
import time
import threading
from functools import wraps
from telegram import Update, BotCommand
from telegram import Update, BotCommand, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import (
Application,
CallbackQueryHandler,
CommandHandler,
MessageHandler,
ContextTypes,
@@ -30,6 +33,8 @@ from mail_service import MailPool, extract_magic_link
from stripe_token import StripeTokenizer
from gift_checker import GiftChecker
from claude_auth import attack_claude, finalize_login
import account_store
import proxy_pool
# --- 日志配置 ---
logging.basicConfig(
@@ -37,11 +42,14 @@ logging.basicConfig(
level=logging.INFO,
)
logger = logging.getLogger(__name__)
# 降低 httpx 轮询日志级别,减少刷屏
logging.getLogger("httpx").setLevel(logging.WARNING)
# --- 全局状态 ---
_task_lock = threading.Lock()
_task_running = False
_task_name = ""
_stop_event = threading.Event()
# ============================================================
@@ -68,6 +76,7 @@ def _set_task(name: str) -> bool:
return False
_task_running = True
_task_name = name
_stop_event.clear()
return True
@@ -77,6 +86,12 @@ def _clear_task():
with _task_lock:
_task_running = False
_task_name = ""
_stop_event.clear()
def _is_stopped() -> bool:
"""检查是否收到停止信号"""
return _stop_event.is_set()
async def _edit_or_send(msg, text: str):
@@ -87,6 +102,16 @@ async def _edit_or_send(msg, text: str):
pass
def _progress_bar(current: int, total: int, width: int = 12) -> str:
"""生成可视化进度条 ▓▓▓▓░░░░ 3/10 (30%)"""
if total <= 0:
return ""
pct = current / total
filled = round(width * pct)
bar = "" * filled + "" * (width - filled)
return f"{bar} {current}/{total} ({round(pct * 100)}%)"
# ============================================================
# 命令处理
# ============================================================
@@ -97,12 +122,17 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
welcome = (
"🤖 <b>autoClaude Bot</b>\n\n"
"可用命令:\n"
" /register [N] — 注册 Claude 账号(默认 1 个)\n"
" /check &lt;卡号|月|年|CVC&gt; — 单张 CC 检查\n"
" /register [N] — 注册 Claude 账号\n"
" /check &lt;卡号|月|年|CVC&gt; — CC 检查\n"
" 📎 发送 .txt 文件 — 批量 CC 检查\n"
" /accounts — 查看已注册账号\n"
" /status — 当前任务状态\n"
" /help — 帮助\n\n"
" /accounts — 查看账号 | /delete — 删除账号\n"
" /verify — 验证 SK 有效性\n"
" /stats — 统计面板\n"
" /stop — 中断当前任务\n"
" /proxy on|off — 代理开关\n"
" /proxytest — 测试代理 | /proxystatus — 代理状态\n"
" /mailstatus — 邮件系统状态\n"
" /status — 任务状态 | /help — 帮助\n\n"
f"👤 你的用户 ID: <code>{update.effective_user.id}</code>"
)
await update.message.reply_text(welcome, parse_mode="HTML")
@@ -113,19 +143,23 @@ async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/help — 命令列表"""
text = (
"📖 <b>命令说明</b>\n\n"
"<b>/register [N]</b>\n"
" 注册 N 个 Claude 账号(默认 1\n"
" 流程:创建邮箱 → 发送 Magic Link → 等待邮件 → 交换 SessionKey\n"
" 注册结果自动保存到 accounts.txt\n\n"
"<b>/check &lt;CARD|MM|YY|CVC&gt;</b>\n"
" 单张信用卡检查。\n\n"
"<b>📎 发送 .txt 文件</b>\n"
" 批量 CC 检查,文件每行一张卡:\n"
" <code>卡号|月|年|CVC</code>\n\n"
"<b>/accounts</b>\n"
" 列出 accounts.txt 中保存的所有账号。\n\n"
"<b>/status</b>\n"
" 查看当前是否有后台任务在运行。\n"
"<b>📝 注册与账号</b>\n"
" /register [N] — 注册 N 个账号\n"
" /accounts — 查看已注册账号\n"
" /delete <序号|邮箱> — 删除账号\n"
" /verify — 验证 SK 有效性\n\n"
"<b>💳 CC 检查</b>\n"
" /check <CARD|MM|YY|CVC> — 单张检查\n"
" 📎 发送 .txt 文件 — 批量检查\n\n"
"<b>🛠 工具与状态</b>\n"
" /stop — 中断当前任务\n"
" /stats — 统计面板\n"
" /status — 任务状态\n\n"
"<b>🌐 代理与邮件</b>\n"
" /proxy on|off — 开关代理\n"
" /proxytest — 测试代理\n"
" /proxystatus — 代理池状态\n"
" /mailstatus — 邮件系统状态\n"
)
await update.message.reply_text(text, parse_mode="HTML")
@@ -144,30 +178,301 @@ async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE):
@restricted
async def cmd_accounts(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/accounts — 列出已注册账号"""
try:
with open("accounts.txt", "r") as f:
lines = [l.strip() for l in f if l.strip()]
except FileNotFoundError:
await update.message.reply_text("📭 尚无已注册账号accounts.txt 不存在)。")
async def cmd_stop(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/stop — 中断当前运行的任务"""
with _task_lock:
if not _task_running:
await update.message.reply_text("✅ 当前没有运行中的任务。")
return
_stop_event.set()
await update.message.reply_text(
f"⏹ 正在停止任务:<b>{_task_name}</b>\n"
"任务将在当前步骤完成后安全退出...",
parse_mode="HTML",
)
@restricted
async def cmd_mailstatus(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/mailstatus — 检查邮箱系统连通性"""
status_msg = await update.message.reply_text("🔍 正在检测邮件系统...")
mail_pool = MailPool(MAIL_SYSTEMS)
if mail_pool.count == 0:
await _edit_or_send(status_msg, "❌ 没有可用的邮箱系统!请检查 config.toml 配置。")
return
if not lines:
await update.message.reply_text("📭 accounts.txt 为空。")
text = f"📬 <b>邮件系统状态(共 {mail_pool.count} 个)</b>\n\n"
for i, ms in enumerate(mail_pool.systems, 1):
health = ms.check_health()
icon = "" if health["ok"] else ""
domains = ", ".join(ms.domains)
text += f"{i}. {icon} <code>{ms.base_url}</code>\n"
text += f" 域名: {domains}\n"
text += f" 状态: {health['message']}\n"
await _edit_or_send(status_msg, text)
@restricted
async def cmd_proxytest(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/proxytest — 测试所有代理的连通性"""
pp = proxy_pool.pool
if pp.count == 0:
await update.message.reply_text("❌ 代理池为空proxy.txt 不存在或无有效代理)")
return
text = f"📋 <b>已注册账号(共 {len(lines)} 个)</b>\n\n"
for i, line in enumerate(lines, 1):
parts = line.split("|")
email = parts[0] if len(parts) > 0 else "?"
sk = parts[1][:12] + "..." if len(parts) > 1 and len(parts[1]) > 12 else (parts[1] if len(parts) > 1 else "?")
text += f"{i}. <code>{email}</code>\n SK: <code>{sk}</code>\n"
status_msg = await update.message.reply_text(
f"🔍 正在测试 {pp.count} 个代理...(可能需要一些时间)"
)
# 在线程中执行测试避免阻塞事件循环
loop = asyncio.get_event_loop()
results = await loop.run_in_executor(None, pp.test_all)
# 构建结果报告
ok_count = sum(1 for r in results if r["ok"])
fail_count = len(results) - ok_count
text = (
f"🎯 <b>代理测试结果</b>\n\n"
f"✅ 通过: {ok_count} ❌ 失败: {fail_count} "
f"🚧 剩余可用: {pp.active_count}\n\n"
)
for r in results:
icon = "" if r["ok"] else ""
latency = f"{r['latency_ms']}ms" if r['latency_ms'] > 0 else "-"
prio = r.get('priority', '-')
text += f"{icon} <code>{r['proxy']}</code>\n"
text += f" 延迟: {latency} | 优先级: {prio}\n"
if not r["ok"]:
text += f" 错误: {r.get('error', '?')}\n"
# Telegram 消息限制 4096 字符
if len(text) > 4000:
text = text[:4000] + "\n...(已截断)"
await _edit_or_send(status_msg, text)
@restricted
async def cmd_proxystatus(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/proxystatus — 查看代理池状态"""
pp = proxy_pool.pool
if pp.count == 0:
await update.message.reply_text("❌ 代理池为空proxy.txt 不存在或无有效代理)")
return
items = pp.status_list()
text = f"🌐 <b>代理池状态(共 {len(items)} 个)</b>\n\n"
for i, item in enumerate(items, 1):
icon = "" if item["last_ok"] else ""
text += (
f"{i}. {icon} <code>{item['proxy']}</code>\n"
f" 优先级: {item['priority']} | "
f"延迟: {item['latency_ms']}ms | "
f"{item['success']}{item['fail']}\n"
)
if len(text) > 4000:
text = text[:4000] + "\n...(已截断)"
await update.message.reply_text(text, parse_mode="HTML")
@restricted
async def cmd_proxy(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/proxy on|off — 开关代理"""
pp = proxy_pool.pool
if not context.args:
# 无参数:显示当前状态
status = "✅ 已开启" if pp.enabled else "❌ 已关闭"
await update.message.reply_text(
f"🌐 <b>代理状态</b>: {status}\n"
f"📦 代理池: {pp.count} 个(活跃 {pp.active_count} 个)\n\n"
f"用法: <code>/proxy on</code> 或 <code>/proxy off</code>",
parse_mode="HTML",
)
return
arg = context.args[0].lower()
if arg == "on":
pp.enabled = True
await update.message.reply_text(
f"✅ 代理已开启(池中 {pp.active_count} 个可用)"
)
elif arg == "off":
pp.enabled = False
await update.message.reply_text("❌ 代理已关闭,所有请求将直连")
else:
await update.message.reply_text("❌ 用法: /proxy on 或 /proxy off")
@restricted
async def cmd_accounts(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/accounts — 列出已注册账号"""
accounts = account_store.read_all()
if not accounts:
await update.message.reply_text("📭 尚无已注册账号。")
return
text = f"📋 <b>已注册账号(共 {len(accounts)} 个)</b>\n\n"
for i, acc in enumerate(accounts, 1):
text += f"{i}. <code>{acc['email']}</code>\n SK: <code>{acc['session_key']}</code>\n"
if len(text) > 4000:
text = text[:4000] + "\n...(已截断,请点击下方按钮导出完整数据)"
keyboard = [[InlineKeyboardButton("📥 导出 JSON 文件", callback_data="export_accounts_json")]]
reply_markup = InlineKeyboardMarkup(keyboard)
await update.message.reply_text(text, parse_mode="HTML", reply_markup=reply_markup)
@restricted
async def cmd_delete(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/delete <序号|邮箱> — 删除指定账号"""
user_id = update.effective_user.id
if not context.args:
await update.message.reply_text(
"❌ 用法:\n"
" <code>/delete 3</code> — 按序号删除\n"
" <code>/delete user@example.com</code> — 按邮箱删除\n\n"
"💡 使用 /accounts 查看序号",
parse_mode="HTML",
)
return
arg = context.args[0]
removed = None
# 尝试按序号删除
try:
index = int(arg)
removed = account_store.delete_by_index(index)
if not removed:
total = account_store.count()
await update.message.reply_text(f"❌ 无效序号。当前共 {total} 个账号。")
return
except ValueError:
# 按邮箱删除
removed = account_store.delete_by_email(arg)
if not removed:
await update.message.reply_text(f"❌ 未找到邮箱:{arg}")
return
await update.message.reply_text(
f"🗑 已删除账号:\n"
f"📧 <code>{removed['email']}</code>\n"
f"剩余 {account_store.count()} 个账号",
parse_mode="HTML",
)
@restricted
async def cmd_verify(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/verify — 检测已保存的 Session Key 是否仍然有效"""
accounts = account_store.read_all()
if not accounts:
await update.message.reply_text("📭 尚无已注册账号。")
return
status_msg = await update.message.reply_text(
f"🔍 正在验证 {len(accounts)} 个账号的 Session Key..."
)
from curl_cffi import requests as cffi_requests
from config import get_proxy
results = []
for i, acc in enumerate(accounts, 1):
sk = acc.get("session_key", "")
email = acc.get("email", "?")
if not sk:
results.append({"email": email, "ok": False, "reason": "SK 为空"})
continue
try:
resp = cffi_requests.get(
"https://claude.ai/api/organizations",
headers={
"Cookie": f"sessionKey={sk}",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
impersonate="chrome124",
proxies=get_proxy(),
timeout=15,
)
if resp.status_code == 200:
results.append({"email": email, "ok": True, "reason": "有效"})
elif resp.status_code == 401:
results.append({"email": email, "ok": False, "reason": "已过期"})
elif resp.status_code == 403:
results.append({"email": email, "ok": False, "reason": "被封禁"})
else:
results.append({"email": email, "ok": False, "reason": f"HTTP {resp.status_code}"})
except Exception as e:
results.append({"email": email, "ok": False, "reason": str(e)[:50]})
valid = sum(1 for r in results if r["ok"])
invalid = len(results) - valid
text = (
f"🔑 <b>账号验证结果</b>\n\n"
f"✅ 有效: {valid} ❌ 无效: {invalid}\n\n"
)
for i, r in enumerate(results, 1):
icon = "" if r["ok"] else ""
text += f"{i}. {icon} <code>{r['email']}</code> — {r['reason']}\n"
if len(text) > 4000:
text = text[:4000] + "\n...(已截断)"
await _edit_or_send(status_msg, text)
@restricted
async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/stats — 展示历史统计数据"""
stats = account_store.get_stats()
reg_total = stats.get("register_total", 0)
reg_ok = stats.get("register_success", 0)
reg_fail = stats.get("register_fail", 0)
reg_rate = f"{round(reg_ok / reg_total * 100)}%" if reg_total > 0 else "-"
cc_total = stats.get("cc_total", 0)
cc_ok = stats.get("cc_pass", 0)
cc_fail = stats.get("cc_fail", 0)
cc_rate = f"{round(cc_ok / cc_total * 100)}%" if cc_total > 0 else "-"
text = (
"📊 <b>统计面板</b>\n\n"
f"<b>📝 注册统计</b>\n"
f" 总计: {reg_total} | ✅ {reg_ok} | ❌ {reg_fail}\n"
f" 成功率: {reg_rate}\n"
)
# 失败原因分布
reasons = stats.get("register_fail_reasons", {})
if reasons:
text += " <b>失败原因:</b>\n"
for reason, cnt in sorted(reasons.items(), key=lambda x: -x[1]):
text += f"{reason}: {cnt}\n"
text += (
f"\n<b>💳 CC 检查统计</b>\n"
f" 总计: {cc_total} | ✅ {cc_ok} | ❌ {cc_fail}\n"
f" 通过率: {cc_rate}\n"
)
text += f"\n<b>📦 当前账号数</b>: {account_store.count()}"
await update.message.reply_text(text, parse_mode="HTML")
@@ -210,7 +515,7 @@ async def cmd_register(update: Update, context: ContextTypes.DEFAULT_TYPE):
def _register_worker(loop: asyncio.AbstractEventLoop, status_msg, count: int):
"""注册工作线程"""
results = {"success": 0, "fail": 0, "accounts": []}
results = {"success": 0, "fail": 0, "accounts": [], "fail_reasons": []}
try:
# 初始化邮箱系统池
@@ -233,99 +538,159 @@ def _register_worker(loop: asyncio.AbstractEventLoop, status_msg, count: int):
).result(timeout=10)
for i in range(count):
progress = f"[{i + 1}/{count}]"
bar = _progress_bar(i, count)
step_header = f"📊 {bar}\n"
# Step 1: 创建邮箱(轮询选系统)
asyncio.run_coroutine_threadsafe(
_edit_or_send(
status_msg,
f"{progress} 创建临时邮箱...\n\n"
f"{step_header} 创建临时邮箱...\n\n"
f"✅ 成功: {results['success']} ❌ 失败: {results['fail']}",
),
loop,
).result(timeout=10)
# 检查停止信号
if _is_stopped():
break
random_prefix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=10))
target_email, mail_sys = mail_pool.create_user(random_prefix)
if not target_email or not mail_sys:
results["fail"] += 1
reason = "邮箱创建失败"
results["fail_reasons"].append(reason)
account_store.record_register(False, reason)
continue
# 检查停止信号
if _is_stopped():
break
# Step 2: 发送 Magic Link
asyncio.run_coroutine_threadsafe(
_edit_or_send(
status_msg,
f"{progress} 发送 Magic Link...\n"
f"{step_header} 发送 Magic Link...\n"
f"📧 {target_email}\n\n"
f"✅ 成功: {results['success']} ❌ 失败: {results['fail']}",
),
loop,
).result(timeout=10)
if _is_stopped():
break
if not attack_claude(target_email):
results["fail"] += 1
reason = "Magic Link 发送失败"
results["fail_reasons"].append(reason)
account_store.record_register(False, reason)
continue
# Step 3: 等待邮件(使用创建时对应的系统)
asyncio.run_coroutine_threadsafe(
_edit_or_send(
status_msg,
f"{progress} 等待 Claude 邮件...\n"
f"{step_header} 等待 Claude 邮件...\n"
f"📧 {target_email}\n\n"
f"✅ 成功: {results['success']} ❌ 失败: {results['fail']}",
),
loop,
).result(timeout=10)
email_content = mail_sys.wait_for_email(target_email)
email_content = mail_sys.wait_for_email(target_email, stop_check=_is_stopped)
if _is_stopped():
break
if not email_content:
results["fail"] += 1
reason = "邮件接收超时"
results["fail_reasons"].append(reason)
account_store.record_register(False, reason)
continue
magic_link = extract_magic_link(email_content)
if not magic_link:
results["fail"] += 1
reason = "Magic Link 解析失败"
results["fail_reasons"].append(reason)
account_store.record_register(False, reason)
continue
# Step 4: 交换 SessionKey
asyncio.run_coroutine_threadsafe(
_edit_or_send(
status_msg,
f"{progress} 交换 SessionKey...\n"
f"{step_header} 交换 SessionKey...\n"
f"📧 {target_email}\n\n"
f"✅ 成功: {results['success']} ❌ 失败: {results['fail']}",
),
loop,
).result(timeout=10)
if _is_stopped():
break
account = finalize_login(magic_link)
if account:
results["success"] += 1
results["accounts"].append(account)
# 保存到文件
with open("accounts.txt", "a") as f:
f.write(f"{account.email}|{account.session_key}|{account.org_uuid}\n")
account_store.append(account.email, account.session_key, account.org_uuid)
account_store.record_register(True)
else:
results["fail"] += 1
reason = "SessionKey 交换失败"
results["fail_reasons"].append(reason)
account_store.record_register(False, reason)
# 间隔防止限流
if i < count - 1:
time.sleep(2)
# 最终汇报
stopped = _is_stopped()
done_bar = _progress_bar(results["success"] + results["fail"], count)
title = "⏹ <b>注册已中断</b>" if stopped else "🏁 <b>注册完成</b>"
report = (
f"🏁 <b>注册完成</b>\n\n"
f"{title}\n"
f"📊 {done_bar}\n\n"
f"✅ 成功: {results['success']}\n"
f"❌ 失败: {results['fail']}\n"
)
# 失败原因汇总
if results["fail_reasons"]:
from collections import Counter
reason_counts = Counter(results["fail_reasons"])
report += "\n<b>失败原因:</b>\n"
for reason, cnt in reason_counts.most_common():
report += f"{reason}: {cnt}\n"
if results["accounts"]:
report += "\n<b>新注册账号:</b>\n"
for acc in results["accounts"]:
report += f"• <code>{acc.email}</code>\n"
report += (
f"• <code>{acc.email}</code>\n"
f" SK: <code>{acc.session_key}</code>\n"
)
# Telegram 消息限制 4096 字符
if len(report) > 4000:
report = report[:4000] + "\n...(已截断,使用 /accounts 查看完整列表)"
keyboard = [[InlineKeyboardButton("📥 导出 JSON 文件", callback_data="export_accounts_json")]]
reply_markup = InlineKeyboardMarkup(keyboard)
async def _send_final():
try:
await status_msg.edit_text(report, parse_mode="HTML", reply_markup=reply_markup)
except Exception:
pass
asyncio.run_coroutine_threadsafe(
_edit_or_send(status_msg, report),
_send_final(),
loop,
).result(timeout=10)
@@ -361,13 +726,9 @@ async def cmd_check(update: Update, context: ContextTypes.DEFAULT_TYPE):
return
# 读取可用账号
try:
with open("accounts.txt", "r") as f:
lines = [l.strip() for l in f if l.strip()]
except FileNotFoundError:
lines = []
acc_lines = account_store.read_lines()
if not lines:
if not acc_lines:
await update.message.reply_text("❌ 没有可用账号,请先 /register 注册一个。")
return
@@ -383,7 +744,7 @@ async def cmd_check(update: Update, context: ContextTypes.DEFAULT_TYPE):
loop = asyncio.get_event_loop()
threading.Thread(
target=_check_worker,
args=(loop, status_msg, card_line, lines[-1]),
args=(loop, status_msg, card_line, acc_lines[-1]),
daemon=True,
).start()
@@ -477,11 +838,7 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE):
return
# 读取可用账号
try:
with open("accounts.txt", "r") as f:
acc_lines = [l.strip() for l in f if l.strip()]
except FileNotFoundError:
acc_lines = []
acc_lines = account_store.read_lines()
if not acc_lines:
await update.message.reply_text("❌ 没有可用账号,请先 /register 注册一个。")
@@ -548,6 +905,9 @@ def _batch_check_worker(loop: asyncio.AbstractEventLoop, status_msg, cards: list
checker = GiftChecker(account)
for i, card_line in enumerate(cards):
if _is_stopped():
break
cc, mm, yy, cvc = card_line.split("|")
masked = f"{cc[:4]}****{cc[-4:]}"
@@ -615,6 +975,40 @@ def _batch_check_worker(loop: asyncio.AbstractEventLoop, status_msg, cards: list
_clear_task()
# ============================================================
# 回调处理 — 导出 JSON
# ============================================================
async def callback_export_json(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""处理导出 JSON 文件的回调"""
query = update.callback_query
await query.answer()
accounts = account_store.read_all()
if not accounts:
await query.message.reply_text("📭 没有账号数据。")
return
json_data = json.dumps(accounts, indent=2, ensure_ascii=False)
json_path = "accounts_export.json"
with open(json_path, "w", encoding="utf-8") as f:
f.write(json_data)
with open(json_path, "rb") as f:
await query.message.reply_document(
document=f,
filename="accounts.json",
caption=f"📋 共 {len(accounts)} 个账号",
)
# 清理临时文件
try:
os.remove(json_path)
except OSError:
pass
# ============================================================
# 启动 Bot
# ============================================================
@@ -624,9 +1018,17 @@ async def post_init(application: Application):
commands = [
BotCommand("start", "欢迎信息"),
BotCommand("register", "注册 Claude 账号 [数量]"),
BotCommand("check", "CC 检查 <卡号|月|年|CVC>"),
BotCommand("accounts", "看已注册账号"),
BotCommand("status", "当前任务状态"),
BotCommand("stop", "中断当前任务"),
BotCommand("check", "CC 检"),
BotCommand("accounts", "查看账号"),
BotCommand("delete", "删除账号"),
BotCommand("verify", "验证 SK 有效性"),
BotCommand("stats", "统计面板"),
BotCommand("mailstatus", "邮件系统状态"),
BotCommand("proxy", "代理开关 on/off"),
BotCommand("proxytest", "测试代理"),
BotCommand("proxystatus", "代理池状态"),
BotCommand("status", "任务状态"),
BotCommand("help", "命令帮助"),
]
await application.bot.set_my_commands(commands)
@@ -645,9 +1047,18 @@ def main():
app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("help", cmd_help))
app.add_handler(CommandHandler("register", cmd_register))
app.add_handler(CommandHandler("stop", cmd_stop))
app.add_handler(CommandHandler("check", cmd_check))
app.add_handler(CommandHandler("accounts", cmd_accounts))
app.add_handler(CommandHandler("delete", cmd_delete))
app.add_handler(CommandHandler("verify", cmd_verify))
app.add_handler(CommandHandler("stats", cmd_stats))
app.add_handler(CommandHandler("mailstatus", cmd_mailstatus))
app.add_handler(CommandHandler("proxytest", cmd_proxytest))
app.add_handler(CommandHandler("proxystatus", cmd_proxystatus))
app.add_handler(CommandHandler("proxy", cmd_proxy))
app.add_handler(CommandHandler("status", cmd_status))
app.add_handler(CallbackQueryHandler(callback_export_json, pattern="^export_accounts_json$"))
app.add_handler(MessageHandler(filters.Document.ALL, handle_document))
logger.info("🤖 Bot 启动中...")

View File

@@ -2,7 +2,7 @@ import uuid
import base64
from curl_cffi import requests # 用于模拟指纹
from config import CLAUDE_URL
from config import CLAUDE_URL, get_proxy
from models import ClaudeAccount
from identity import random_ua
@@ -48,7 +48,8 @@ def attack_claude(target_email):
CLAUDE_URL,
json=payload,
headers=headers,
impersonate="chrome124"
impersonate="chrome124",
proxies=get_proxy(),
)
if response.status_code == 200:
@@ -132,7 +133,8 @@ def finalize_login(magic_link_fragment):
verify_url,
json=payload,
headers=headers,
impersonate="chrome124"
impersonate="chrome124",
proxies=get_proxy(),
)
if response.status_code == 200:

View File

@@ -31,3 +31,7 @@ TG_ALLOWED_USERS: list[int] = _cfg["telegram"].get("allowed_users", [])
# --- 邮箱系统 ---
MAIL_SYSTEMS: list[dict] = _cfg.get("mail", [])
# --- 代理池 ---
# 代理逻辑统一由 proxy_pool.py 管理,这里只做 re-export 保持兼容
from proxy_pool import get_proxy, get_proxy_count # noqa: E402, F401

View File

@@ -30,3 +30,6 @@ domains = ["example.com"]
# base_url = "https://mail2.example.com/"
# api_token = "your_api_token_here"
# domains = ["domain2.com", "domain3.com"]
# --- 代理配置 ---
# 代理从 proxy.txt 文件加载,格式: host:port:user:pass每行一个

View File

@@ -44,11 +44,13 @@ echo ""
# ============================================================
info "检查系统依赖..."
# 确保 uv 路径在 PATH 中
export PATH="$HOME/.local/bin:/root/.local/bin:$PATH"
# 安装 uv如果不存在
if ! command -v uv &> /dev/null; then
info "安装 uv..."
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
ok "uv 已安装"
else
ok "uv 已存在 ($(uv --version))"
@@ -59,8 +61,16 @@ fi
# ============================================================
info "安装 Python 依赖..."
cd "$APP_DIR"
sudo -u "$RUN_USER" uv sync 2>/dev/null || sudo -u "$RUN_USER" uv pip install -r pyproject.toml 2>/dev/null || true
ok "依赖安装完成"
if uv sync; then
ok "依赖安装完成"
else
warn "uv sync 失败,尝试 uv pip install..."
if uv pip install -r pyproject.toml; then
ok "依赖安装完成 (pip fallback)"
else
err "依赖安装失败,请手动检查"
fi
fi
# ============================================================
# 3. 检查配置文件
@@ -83,7 +93,16 @@ fi
# ============================================================
# 4. 获取 uv 和 python 路径
# ============================================================
UV_PATH="$(sudo -u "$RUN_USER" bash -c 'which uv')"
UV_PATH="$(which uv 2>/dev/null || echo '')"
if [ -z "$UV_PATH" ]; then
# 尝试常见路径
for p in "$HOME/.local/bin/uv" "/root/.local/bin/uv" "/usr/local/bin/uv"; do
if [ -x "$p" ]; then UV_PATH="$p"; break; fi
done
fi
if [ -z "$UV_PATH" ]; then
err "找不到 uv请检查安装是否成功"
fi
info "uv 路径: ${UV_PATH}"
# ============================================================
@@ -119,7 +138,6 @@ SyslogIdentifier=${APP_NAME}
# 安全加固
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=${APP_DIR}
PrivateTmp=true

View File

@@ -1,6 +1,6 @@
from curl_cffi import requests # 用于模拟指纹
from config import PRODUCT_ID
from config import PRODUCT_ID, get_proxy
from models import ClaudeAccount
from identity import random_address
@@ -42,7 +42,7 @@ class GiftChecker:
try:
print(f"[*] 正在尝试扣款 (Gift Purchase)...")
resp = requests.post(url, json=payload, headers=headers, impersonate="chrome124")
resp = requests.post(url, json=payload, headers=headers, impersonate="chrome124", proxies=get_proxy())
resp_json = {}
try:

View File

@@ -4,6 +4,8 @@ import random
import threading
import requests as standard_requests # 用于普通API交互
from config import get_proxy
class MailSystem:
"""单个邮箱系统实例,支持多域名"""
@@ -32,16 +34,23 @@ class MailSystem:
}
]
}
resp = standard_requests.post(url, json=payload, headers=self.headers)
if resp.json().get('code') == 200:
print(f"[+] 邮箱用户创建成功: {full_email}")
return full_email
else:
print(f"[-] 创建邮箱失败: {resp.text}")
try:
resp = standard_requests.post(url, json=payload, headers=self.headers, proxies=get_proxy(), timeout=15)
if resp.json().get('code') == 200:
print(f"[+] 邮箱用户创建成功: {full_email}")
return full_email
elif resp.status_code in (401, 403):
print(f"[-] 邮箱 API Token 无效或已过期! HTTP {resp.status_code}")
return None
else:
print(f"[-] 创建邮箱失败: {resp.text}")
return None
except Exception as e:
print(f"[-] 创建邮箱请求异常: {e}")
return None
def wait_for_email(self, to_email, retry_count=20, sleep_time=3):
"""像猎人一样耐心等待猎物出现"""
def wait_for_email(self, to_email, retry_count=20, sleep_time=3, stop_check=None):
"""像猎人一样耐心等待猎物出现,支持外部中断"""
url = f"{self.base_url}/api/public/emailList"
payload = {
"toEmail": to_email,
@@ -54,10 +63,19 @@ class MailSystem:
print(f"[*] 开始轮询邮件,目标: {to_email}...")
for i in range(retry_count):
# 检查外部中断信号
if stop_check and stop_check():
print("[!] 收到停止信号,中断邮件轮询")
return None
try:
resp = standard_requests.post(url, json=payload, headers=self.headers)
resp = standard_requests.post(url, json=payload, headers=self.headers, proxies=get_proxy(), timeout=15)
data = resp.json()
if resp.status_code in (401, 403):
print(f"[-] 邮箱 API Token 无效或已过期! HTTP {resp.status_code}")
return None
if data.get('code') == 200 and data.get('data'):
emails = data['data']
for email in emails:
@@ -73,6 +91,27 @@ class MailSystem:
print("[-] 等待超时,未收到邮件。")
return None
def check_health(self) -> dict:
"""检查该邮箱系统的连通性和 Token 有效性"""
if not self.token:
return {"ok": False, "message": "Token 未配置"}
try:
url = f"{self.base_url}/api/public/emailList"
payload = {"toEmail": "health@check.test", "sendName": "", "num": 1, "size": 1}
resp = standard_requests.post(url, json=payload, headers=self.headers, proxies=get_proxy(), timeout=10)
if resp.status_code == 200:
return {"ok": True, "message": "连接正常"}
elif resp.status_code in (401, 403):
return {"ok": False, "message": f"Token 无效 (HTTP {resp.status_code})"}
else:
return {"ok": False, "message": f"异常响应 (HTTP {resp.status_code})"}
except standard_requests.exceptions.ConnectTimeout:
return {"ok": False, "message": "连接超时"}
except standard_requests.exceptions.ConnectionError:
return {"ok": False, "message": "无法连接"}
except Exception as e:
return {"ok": False, "message": f"异常: {e}"}
def __repr__(self):
return f"MailSystem({self.base_url}, domains={self.domains})"

276
proxy_pool.py Normal file
View File

@@ -0,0 +1,276 @@
"""
代理池管理模块
功能:
- 从 proxy.txt 加载代理(支持 host:port:user:pass 格式)
- 基于优先级的智能选取(优先使用表现好的代理)
- 自动测试连通性和延迟
- 测试失败降低优先级,过低则淘汰
- 线程安全
"""
import random
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
import requests as std_requests
# --- 配置常量 ---
_PROXY_FILE = Path(__file__).parent / "proxy.txt"
_TEST_URL = "https://claude.ai" # 测试目标
_TEST_TIMEOUT = 10 # 测试超时秒数
_INITIAL_PRIORITY = 100 # 初始优先级
_FAIL_PENALTY = 30 # 每次失败扣分
_SUCCESS_BONUS = 10 # 每次成功加分
_MAX_PRIORITY = 100 # 最高优先级
_REMOVE_THRESHOLD = 0 # 优先级低于此值则淘汰
@dataclass
class Proxy:
"""代理实例"""
raw: str # 原始行
url: str # 解析后的 URL (http://user:pass@host:port)
host: str
port: str
priority: int = _INITIAL_PRIORITY
latency: float = 0.0 # 最近一次测试延迟 (ms)
fail_count: int = 0
success_count: int = 0
last_test_time: float = 0.0
last_test_ok: bool = True
@property
def masked_url(self) -> str:
"""脱敏显示"""
if "@" in self.url:
prefix = self.url.split("@")[0]
suffix = self.url.split("@")[1]
# 隐藏密码
if ":" in prefix.replace("http://", "").replace("https://", ""):
user_part = prefix.split(":")[-2].split("/")[-1]
return f"{self.host}:{self.port} ({user_part[:8]}...)"
return f"{self.host}:{self.port}"
def _parse_line(line: str) -> Proxy | None:
"""解析一行代理配置"""
line = line.strip()
if not line or line.startswith("#"):
return None
parts = line.split(":")
if len(parts) == 4:
host, port, user, passwd = parts
url = f"http://{user}:{passwd}@{host}:{port}"
return Proxy(raw=line, url=url, host=host, port=port)
elif len(parts) == 2:
host, port = parts
url = f"http://{host}:{port}"
return Proxy(raw=line, url=url, host=host, port=port)
elif line.startswith(("http://", "https://", "socks5://")):
# 从完整 URL 提取 host:port
try:
from urllib.parse import urlparse
parsed = urlparse(line)
return Proxy(raw=line, url=line, host=parsed.hostname or "?", port=str(parsed.port or "?"))
except Exception:
return None
return None
class ProxyPool:
"""线程安全的代理池"""
def __init__(self):
self._proxies: list[Proxy] = []
self._lock = threading.Lock()
self.enabled = True # 代理开关
self._load()
def _load(self):
"""从 proxy.txt 加载代理"""
if not _PROXY_FILE.exists():
print("[*] 未找到 proxy.txt不使用代理")
return
with open(_PROXY_FILE, "r", encoding="utf-8") as f:
for line in f:
proxy = _parse_line(line)
if proxy:
self._proxies.append(proxy)
if self._proxies:
print(f"[+] 代理池: 已加载 {len(self._proxies)} 个代理")
else:
print("[!] proxy.txt 存在但没有有效代理")
def reload(self):
"""重新加载 proxy.txt"""
with self._lock:
self._proxies.clear()
self._load()
@property
def count(self) -> int:
return len(self._proxies)
@property
def active_count(self) -> int:
"""有效代理数量"""
return sum(1 for p in self._proxies if p.priority > _REMOVE_THRESHOLD)
def get(self) -> dict:
"""
基于优先级加权随机选取一个代理,返回 requests 格式的 proxies dict。
代理关闭或无可用代理时返回空 dict直连
"""
if not self.enabled:
return {}
with self._lock:
alive = [p for p in self._proxies if p.priority > _REMOVE_THRESHOLD]
if not alive:
return {}
# 加权随机priority 越高越容易选中
weights = [p.priority for p in alive]
chosen = random.choices(alive, weights=weights, k=1)[0]
return {"http": chosen.url, "https": chosen.url}
def report_success(self, proxies: dict):
"""调用方报告该代理请求成功"""
if not proxies:
return
url = proxies.get("https", "")
with self._lock:
for p in self._proxies:
if p.url == url:
p.success_count += 1
p.priority = min(p.priority + _SUCCESS_BONUS, _MAX_PRIORITY)
break
def report_failure(self, proxies: dict):
"""调用方报告该代理请求失败,降低优先级"""
if not proxies:
return
url = proxies.get("https", "")
with self._lock:
for p in self._proxies:
if p.url == url:
p.fail_count += 1
p.priority -= _FAIL_PENALTY
if p.priority <= _REMOVE_THRESHOLD:
print(f"[!] 代理已淘汰 (优先级归零): {p.masked_url}")
break
def _cleanup(self):
"""移除优先级过低的代理"""
before = len(self._proxies)
self._proxies = [p for p in self._proxies if p.priority > _REMOVE_THRESHOLD]
removed = before - len(self._proxies)
if removed:
print(f"[!] 清理了 {removed} 个失效代理,剩余 {len(self._proxies)}")
self._save()
def _save(self):
"""将当前有效代理写回 proxy.txt"""
with open(_PROXY_FILE, "w", encoding="utf-8") as f:
for p in self._proxies:
f.write(p.raw + "\n")
def test_one(self, proxy: Proxy) -> dict:
"""测试单个代理,返回结果 dict"""
proxies = {"http": proxy.url, "https": proxy.url}
try:
start = time.time()
resp = std_requests.get(
_TEST_URL,
proxies=proxies,
timeout=_TEST_TIMEOUT,
allow_redirects=True,
)
latency = (time.time() - start) * 1000 # ms
proxy.latency = latency
proxy.last_test_time = time.time()
if resp.status_code < 500:
proxy.last_test_ok = True
proxy.success_count += 1
proxy.priority = min(proxy.priority + _SUCCESS_BONUS, _MAX_PRIORITY)
return {"ok": True, "latency_ms": round(latency), "status": resp.status_code}
else:
proxy.last_test_ok = False
proxy.fail_count += 1
proxy.priority -= _FAIL_PENALTY
return {"ok": False, "latency_ms": round(latency), "error": f"HTTP {resp.status_code}"}
except std_requests.exceptions.ConnectTimeout:
proxy.last_test_ok = False
proxy.fail_count += 1
proxy.priority -= _FAIL_PENALTY
proxy.last_test_time = time.time()
return {"ok": False, "latency_ms": -1, "error": "连接超时"}
except std_requests.exceptions.ProxyError as e:
proxy.last_test_ok = False
proxy.fail_count += 1
proxy.priority -= _FAIL_PENALTY
proxy.last_test_time = time.time()
return {"ok": False, "latency_ms": -1, "error": f"代理错误: {e}"}
except Exception as e:
proxy.last_test_ok = False
proxy.fail_count += 1
proxy.priority -= _FAIL_PENALTY
proxy.last_test_time = time.time()
return {"ok": False, "latency_ms": -1, "error": str(e)}
def test_all(self) -> list[dict]:
"""
测试所有代理,返回结果列表。
测试后自动清理优先级过低的代理。
"""
results = []
with self._lock:
proxies_snapshot = list(self._proxies)
for proxy in proxies_snapshot:
result = self.test_one(proxy)
result["proxy"] = proxy.masked_url
result["priority"] = proxy.priority
results.append(result)
with self._lock:
self._cleanup()
return results
def status_list(self) -> list[dict]:
"""返回所有代理的状态信息"""
with self._lock:
return [
{
"proxy": p.masked_url,
"priority": p.priority,
"latency_ms": round(p.latency) if p.latency else "-",
"success": p.success_count,
"fail": p.fail_count,
"last_ok": p.last_test_ok,
}
for p in self._proxies
]
# --- 全局单例 ---
pool = ProxyPool()
def get_proxy() -> dict:
"""供外部模块调用:随机获取一个代理"""
return pool.get()
def get_proxy_count() -> int:
"""代理池大小"""
return pool.count

View File

@@ -6,6 +6,7 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"curl-cffi>=0.14.0",
"faker>=36.0.0",
"python-telegram-bot>=21.0",
"requests>=2.32.5",
]

View File

@@ -2,7 +2,7 @@ import uuid
import random
from curl_cffi import requests # 用于模拟指纹
from config import STRIPE_PK
from config import STRIPE_PK, get_proxy
from identity import random_address, random_name
@@ -77,7 +77,7 @@ class StripeTokenizer:
try:
print(f"[*] 正在向 Stripe 请求 Token: {cc_num[:4]}******{cc_num[-4:]}")
resp = requests.post(url, data=data, headers=headers, impersonate="chrome124")
resp = requests.post(url, data=data, headers=headers, impersonate="chrome124", proxies=get_proxy())
if resp.status_code == 200:
pm_id = resp.json().get("id")

23
uv.lock generated
View File

@@ -25,6 +25,7 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "curl-cffi" },
{ name = "faker" },
{ name = "python-telegram-bot" },
{ name = "requests" },
]
@@ -32,6 +33,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "curl-cffi", specifier = ">=0.14.0" },
{ name = "faker", specifier = ">=36.0.0" },
{ name = "python-telegram-bot", specifier = ">=21.0" },
{ name = "requests", specifier = ">=2.32.5" },
]
@@ -182,6 +184,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/7c/d2ba86b0b3e1e2830bd94163d047de122c69a8df03c5c7c36326c456ad82/curl_cffi-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:2eed50a969201605c863c4c31269dfc3e0da52916086ac54553cfa353022425c", size = 1425067, upload-time = "2025-12-16T03:25:06.454Z" },
]
[[package]]
name = "faker"
version = "40.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/7e/dccb7013c9f3d66f2e379383600629fec75e4da2698548bdbf2041ea4b51/faker-40.4.0.tar.gz", hash = "sha256:76f8e74a3df28c3e2ec2caafa956e19e37a132fdc7ea067bc41783affcfee364", size = 1952221, upload-time = "2026-02-06T23:30:15.515Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/63/58efa67c10fb27810d34351b7a10f85f109a7f7e2a07dc3773952459c47b/faker-40.4.0-py3-none-any.whl", hash = "sha256:486d43c67ebbb136bc932406418744f9a0bdf2c07f77703ea78b58b77e9aa443", size = 1987060, upload-time = "2026-02-06T23:30:13.44Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
@@ -274,6 +288,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "tzdata"
version = "2025.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"