From ef2331809010cd8042e1a228da6caa4e0379ed1b Mon Sep 17 00:00:00 2001 From: kyx236 Date: Fri, 13 Feb 2026 03:32:27 +0800 Subject: [PATCH] feat: Implement thread-safe account and statistics management and integrate proxy support for all external requests. --- account_store.py | 180 +++++++++++++++ bot.py | 537 ++++++++++++++++++++++++++++++++++++++------ claude_auth.py | 8 +- config.py | 4 + config.toml.example | 3 + deploy.sh | 28 ++- gift_checker.py | 4 +- mail_service.py | 57 ++++- proxy_pool.py | 276 +++++++++++++++++++++++ pyproject.toml | 1 + stripe_token.py | 4 +- uv.lock | 23 ++ 12 files changed, 1041 insertions(+), 84 deletions(-) create mode 100644 account_store.py create mode 100644 proxy_pool.py diff --git a/account_store.py b/account_store.py new file mode 100644 index 0000000..40091a5 --- /dev/null +++ b/account_store.py @@ -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() diff --git a/bot.py b/bot.py index 3a175da..df0fdd4 100644 --- a/bot.py +++ b/bot.py @@ -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 = ( "🤖 autoClaude Bot\n\n" "可用命令:\n" - " /register [N] — 注册 Claude 账号(默认 1 个)\n" - " /check <卡号|月|年|CVC> — 单张 CC 检查\n" + " /register [N] — 注册 Claude 账号\n" + " /check <卡号|月|年|CVC> — 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: {update.effective_user.id}" ) 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 = ( "📖 命令说明\n\n" - "/register [N]\n" - " 注册 N 个 Claude 账号(默认 1)。\n" - " 流程:创建邮箱 → 发送 Magic Link → 等待邮件 → 交换 SessionKey\n" - " 注册结果自动保存到 accounts.txt\n\n" - "/check <CARD|MM|YY|CVC>\n" - " 单张信用卡检查。\n\n" - "📎 发送 .txt 文件\n" - " 批量 CC 检查,文件每行一张卡:\n" - " 卡号|月|年|CVC\n\n" - "/accounts\n" - " 列出 accounts.txt 中保存的所有账号。\n\n" - "/status\n" - " 查看当前是否有后台任务在运行。\n" + "📝 注册与账号\n" + " /register [N] — 注册 N 个账号\n" + " /accounts — 查看已注册账号\n" + " /delete <序号|邮箱> — 删除账号\n" + " /verify — 验证 SK 有效性\n\n" + "💳 CC 检查\n" + " /check — 单张检查\n" + " 📎 发送 .txt 文件 — 批量检查\n\n" + "🛠 工具与状态\n" + " /stop — 中断当前任务\n" + " /stats — 统计面板\n" + " /status — 任务状态\n\n" + "🌐 代理与邮件\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"⏹ 正在停止任务:{_task_name}\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"📬 邮件系统状态(共 {mail_pool.count} 个)\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} {ms.base_url}\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"📋 已注册账号(共 {len(lines)} 个)\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}. {email}\n SK: {sk}\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"🎯 代理测试结果\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} {r['proxy']}\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"🌐 代理池状态(共 {len(items)} 个)\n\n" + + for i, item in enumerate(items, 1): + icon = "✅" if item["last_ok"] else "❌" + text += ( + f"{i}. {icon} {item['proxy']}\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"🌐 代理状态: {status}\n" + f"📦 代理池: {pp.count} 个(活跃 {pp.active_count} 个)\n\n" + f"用法: /proxy on/proxy off", + 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"📋 已注册账号(共 {len(accounts)} 个)\n\n" + for i, acc in enumerate(accounts, 1): + text += f"{i}. {acc['email']}\n SK: {acc['session_key']}\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" + " /delete 3 — 按序号删除\n" + " /delete user@example.com — 按邮箱删除\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"📧 {removed['email']}\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"🔑 账号验证结果\n\n" + f"✅ 有效: {valid} ❌ 无效: {invalid}\n\n" + ) + for i, r in enumerate(results, 1): + icon = "✅" if r["ok"] else "❌" + text += f"{i}. {icon} {r['email']} — {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 = ( + "📊 统计面板\n\n" + f"📝 注册统计\n" + f" 总计: {reg_total} | ✅ {reg_ok} | ❌ {reg_fail}\n" + f" 成功率: {reg_rate}\n" + ) + + # 失败原因分布 + reasons = stats.get("register_fail_reasons", {}) + if reasons: + text += " 失败原因:\n" + for reason, cnt in sorted(reasons.items(), key=lambda x: -x[1]): + text += f" • {reason}: {cnt}\n" + + text += ( + f"\n💳 CC 检查统计\n" + f" 总计: {cc_total} | ✅ {cc_ok} | ❌ {cc_fail}\n" + f" 通过率: {cc_rate}\n" + ) + + text += f"\n📦 当前账号数: {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 = "⏹ 注册已中断" if stopped else "🏁 注册完成" report = ( - f"🏁 注册完成\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失败原因:\n" + for reason, cnt in reason_counts.most_common(): + report += f" • {reason}: {cnt} 次\n" + if results["accounts"]: report += "\n新注册账号:\n" for acc in results["accounts"]: - report += f"• {acc.email}\n" + report += ( + f"• {acc.email}\n" + f" SK: {acc.session_key}\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 启动中...") diff --git a/claude_auth.py b/claude_auth.py index ddbeb1e..04be313 100644 --- a/claude_auth.py +++ b/claude_auth.py @@ -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: diff --git a/config.py b/config.py index 42fe3db..4c4ea1c 100644 --- a/config.py +++ b/config.py @@ -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 diff --git a/config.toml.example b/config.toml.example index 6471910..19166d6 100644 --- a/config.toml.example +++ b/config.toml.example @@ -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(每行一个) diff --git a/deploy.sh b/deploy.sh index bb80af6..c9d2c12 100644 --- a/deploy.sh +++ b/deploy.sh @@ -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 diff --git a/gift_checker.py b/gift_checker.py index 85ca533..1384a65 100644 --- a/gift_checker.py +++ b/gift_checker.py @@ -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: diff --git a/mail_service.py b/mail_service.py index 9bef2f9..d8b721a 100644 --- a/mail_service.py +++ b/mail_service.py @@ -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})" diff --git a/proxy_pool.py b/proxy_pool.py new file mode 100644 index 0000000..ff3c2fc --- /dev/null +++ b/proxy_pool.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 6375bd2..46caf82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/stripe_token.py b/stripe_token.py index 0efd472..25ba8a0 100644 --- a/stripe_token.py +++ b/stripe_token.py @@ -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") diff --git a/uv.lock b/uv.lock index 1b01890..00559cd 100644 --- a/uv.lock +++ b/uv.lock @@ -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"