From e98e237d478d161a470f779355570d90ea77762a Mon Sep 17 00:00:00 2001 From: skernelx Date: Mon, 16 Mar 2026 01:23:43 +0800 Subject: [PATCH] backup current state --- .gitignore | 22 + README.md | 170 +++++ api_solver.py | 1123 ++++++++++++++++++++++++++++++++++ browser_configs.py | 17 + config.py | 23 + db_results.py | 27 + proxy/.env.example | 1 + proxy/Dockerfile | 7 + proxy/README.md | 106 ++++ proxy/database.py | 272 ++++++++ proxy/docker-compose.yml | 10 + proxy/key_pool.py | 44 ++ proxy/requirements.txt | 4 + proxy/server.py | 199 ++++++ proxy/templates/console.html | 383 ++++++++++++ requirements.txt | 7 + run.py | 290 +++++++++ tavily_core.py | 522 ++++++++++++++++ tavily_core.py.bak | 366 +++++++++++ 19 files changed, 3593 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 api_solver.py create mode 100644 browser_configs.py create mode 100644 config.py create mode 100644 db_results.py create mode 100644 proxy/.env.example create mode 100644 proxy/Dockerfile create mode 100644 proxy/README.md create mode 100644 proxy/database.py create mode 100644 proxy/docker-compose.yml create mode 100644 proxy/key_pool.py create mode 100644 proxy/requirements.txt create mode 100644 proxy/server.py create mode 100644 proxy/templates/console.html create mode 100644 requirements.txt create mode 100755 run.py create mode 100644 tavily_core.py create mode 100644 tavily_core.py.bak diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f83523b --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +ENV/ + +# 账号信息 +accounts.txt +accounts.csv + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# 系统 +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7bc364 --- /dev/null +++ b/README.md @@ -0,0 +1,170 @@ +# Tavily 全自动注册工具 + +自动注册 Tavily 账号并获取 API Key,支持邮件验证的完整自动化工具。 + +## ✨ 核心特性 + +- 🚀 **零配置启动** - 自动创建虚拟环境并安装依赖 +- 🔐 **本地 Turnstile Solver** - 免费稳定的验证码解决方案 +- 📧 **邮件自动验证** - 使用 Cloudflare Email Workers 自动接收验证邮件 +- 💾 **自动保存账号** - 格式兼容代理服务器直接导入 +- 🎨 **交互式界面** - 简洁友好的命令行交互 +- 🌐 **跨平台支持** - Windows / macOS / Linux + +## 🚀 快速开始 + +### 一键运行 + +```bash +python3 run.py +``` + +就这么简单!脚本会自动: +1. 创建虚拟环境 +2. 安装所有依赖 +3. 下载浏览器 +4. 启动 Turnstile Solver +5. 进入交互界面 + +### 使用流程 + +1. 运行 `python3 run.py` +2. 选择 `1` 开始注册 +3. 输入注册数量(默认 5) +4. 输入间隔秒数(默认 10) +5. 选择是否上传到代理服务器 +6. 等待注册完成 + +生成的账号保存在 `accounts.txt`,格式: +``` +email,password,api_key +email,password,api_key +... +``` + +## 📁 项目结构 + +``` +tavily-key-generator/ +├── run.py # 主程序(一键启动) +├── tavily_core.py # 注册核心逻辑 +├── api_solver.py # Turnstile Solver +├── config.py # 配置文件 +├── requirements.txt # Python 依赖 +├── browser_configs.py # Solver 浏览器配置 +├── db_results.py # Solver 数据库 +├── proxy/ # 代理服务器(可选部署) +│ ├── server.py +│ ├── database.py +│ ├── key_pool.py +│ └── templates/ +└── README.md +``` + +## ⚙️ 配置说明 + +编辑 `config.py` 自定义配置: + +```python +# 邮箱配置(Cloudflare Email Workers) +EMAIL_API_URL = "https://mail.nashome.me" +EMAIL_API_TOKEN = "your-token" +EMAIL_DOMAIN = "nashome.me" + +# 代理服务器配置(上传目标) +SERVER_URL = "https://tavily.hunters.works" +SERVER_ADMIN_PASSWORD = "your-password" + +# 默认参数 +DEFAULT_COUNT = 5 # 默认注册数量 +DEFAULT_DELAY = 10 # 每次注册间隔(秒) +``` + +## 🔧 技术架构 + +- **HTTP 客户端**: curl_cffi(模拟真实浏览器 TLS 指纹) +- **验证码破解**: 本地 Turnstile Solver(Camoufox 浏览器自动化) +- **邮件接收**: Cloudflare Email Workers API +- **认证流程**: Auth0 Universal Login + +## 📦 依赖说明 + +所有依赖会自动安装,无需手动操作: + +- `curl_cffi` - HTTP 客户端(绕过 Cloudflare) +- `requests` - 标准 HTTP 库 +- `quart` - 异步 Web 框架(Solver) +- `camoufox` - 反检测浏览器(Solver) +- `patchright` - Playwright 分支(Solver) +- `rich` - 终端美化(Solver) + +## 🎯 代理服务器 + +项目内置代理服务器(`proxy/` 目录),支持: +- API Key 池管理 +- 自动轮询使用 +- 配额限制 +- 使用统计 +- Web 控制台 + +### 部署代理服务器 + +```bash +cd proxy/ +docker compose up -d +``` + +访问 `http://localhost:9874` 进入管理控制台。 + +### 批量导入 API Key + +在控制台中选择"批量导入",粘贴 `accounts.txt` 内容即可。 + +## ⚠️ 注意事项 + +- 首次运行会下载 Camoufox 浏览器(约 200MB) +- Solver 启动需要 10-15 秒初始化时间 +- 每个 API Key 有 1000 次免费调用额度(需要邮件验证激活) +- 建议批量注册时设置 10-15 秒延迟 +- 邮件验证最多等待 120 秒 + +## 🐛 故障排查 + +**Solver 无法启动**: +```bash +# 检查端口占用 +lsof -i :5072 + +# 手动清理进程 +kill -9 $(lsof -ti :5072) +``` + +**注册失败**: +- 确认 Solver 正在运行(会显示"✅ Solver 已启动") +- 检查网络连接 +- 确认邮箱 API Token 有效 + +**邮件验证超时**: +- 检查 `config.py` 中的 `EMAIL_API_TOKEN` 是否正确 +- 访问 https://mail.nashome.me 测试邮箱服务是否正常 +- 增加等待时间(修改 `tavily_core.py` 中的 `timeout` 参数) + +**虚拟环境问题**: +```bash +# 删除虚拟环境重新创建 +rm -rf venv +python3 run.py +``` + +## 📄 输出文件 + +- `accounts.txt` - 账号信息(email,password,api_key) +- `venv/` - 虚拟环境(自动创建,已加入 .gitignore) + +## 🤝 贡献 + +欢迎提交 Issue 和 Pull Request! + +## 📜 许可证 + +MIT License diff --git a/api_solver.py b/api_solver.py new file mode 100644 index 0000000..ad04902 --- /dev/null +++ b/api_solver.py @@ -0,0 +1,1123 @@ +import os +import sys +import time +import uuid +import random +import logging +import asyncio +from typing import Optional, Union +import argparse +from quart import Quart, request, jsonify +from camoufox.async_api import AsyncCamoufox +from patchright.async_api import async_playwright +from db_results import init_db, save_result, load_result, cleanup_old_results +from browser_configs import browser_config +from rich.console import Console +from rich.panel import Panel +from rich.text import Text +from rich.align import Align +from rich import box + + + +COLORS = { + 'MAGENTA': '\033[35m', + 'BLUE': '\033[34m', + 'GREEN': '\033[32m', + 'YELLOW': '\033[33m', + 'RED': '\033[31m', + 'RESET': '\033[0m', +} + + +class CustomLogger(logging.Logger): + @staticmethod + def format_message(level, color, message): + timestamp = time.strftime('%H:%M:%S') + return f"[{timestamp}] [{COLORS.get(color)}{level}{COLORS.get('RESET')}] -> {message}" + + def debug(self, message, *args, **kwargs): + super().debug(self.format_message('DEBUG', 'MAGENTA', message), *args, **kwargs) + + def info(self, message, *args, **kwargs): + super().info(self.format_message('INFO', 'BLUE', message), *args, **kwargs) + + def success(self, message, *args, **kwargs): + super().info(self.format_message('SUCCESS', 'GREEN', message), *args, **kwargs) + + def warning(self, message, *args, **kwargs): + super().warning(self.format_message('WARNING', 'YELLOW', message), *args, **kwargs) + + def error(self, message, *args, **kwargs): + super().error(self.format_message('ERROR', 'RED', message), *args, **kwargs) + + +logging.setLoggerClass(CustomLogger) +logger: CustomLogger = logging.getLogger("TurnstileAPIServer") # type: ignore +logger.setLevel(logging.DEBUG) +handler = logging.StreamHandler(sys.stdout) +logger.addHandler(handler) + + +class TurnstileAPIServer: + + def __init__(self, headless: bool, useragent: Optional[str], debug: bool, browser_type: str, thread: int, proxy_support: bool, use_random_config: bool = False, browser_name: Optional[str] = None, browser_version: Optional[str] = None): + self.app = Quart(__name__) + self.debug = debug + self.browser_type = browser_type + self.headless = headless + self.thread_count = thread + self.proxy_support = proxy_support + self.browser_pool = asyncio.Queue() + self.use_random_config = use_random_config + self.browser_name = browser_name + self.browser_version = browser_version + self.console = Console() + + # Initialize useragent and sec_ch_ua attributes + self.useragent = useragent + self.sec_ch_ua = None + + + if self.browser_type in ['chromium', 'chrome', 'msedge']: + if browser_name and browser_version: + config = browser_config.get_browser_config(browser_name, browser_version) + if config: + useragent, sec_ch_ua = config + self.useragent = useragent + self.sec_ch_ua = sec_ch_ua + elif useragent: + self.useragent = useragent + else: + browser, version, useragent, sec_ch_ua = browser_config.get_random_browser_config(self.browser_type) + self.browser_name = browser + self.browser_version = version + self.useragent = useragent + self.sec_ch_ua = sec_ch_ua + + self.browser_args = [] + if self.useragent: + self.browser_args.append(f"--user-agent={self.useragent}") + + self._setup_routes() + + def display_welcome(self): + """Displays welcome screen with logo.""" + self.console.clear() + + combined_text = Text() + combined_text.append("\n📢 Channel: ", style="bold white") + combined_text.append("https://t.me/D3_vin", style="cyan") + combined_text.append("\n💬 Chat: ", style="bold white") + combined_text.append("https://t.me/D3vin_chat", style="cyan") + combined_text.append("\n📁 GitHub: ", style="bold white") + combined_text.append("https://github.com/D3-vin", style="cyan") + combined_text.append("\n📁 Version: ", style="bold white") + combined_text.append("1.2a", style="green") + combined_text.append("\n") + + info_panel = Panel( + Align.left(combined_text), + title="[bold blue]Turnstile Solver[/bold blue]", + subtitle="[bold magenta]Dev by D3vin[/bold magenta]", + box=box.ROUNDED, + border_style="bright_blue", + padding=(0, 1), + width=50 + ) + + self.console.print(info_panel) + self.console.print() + + + + + def _setup_routes(self) -> None: + """Set up the application routes.""" + self.app.before_serving(self._startup) + self.app.route('/turnstile', methods=['GET'])(self.process_turnstile) + self.app.route('/result', methods=['GET'])(self.get_result) + self.app.route('/')(self.index) + + + async def _startup(self) -> None: + """Initialize the browser and page pool on startup.""" + self.display_welcome() + logger.info("Starting browser initialization") + try: + await init_db() + await self._initialize_browser() + + # Запускаем периодическую очистку старых результатов + asyncio.create_task(self._periodic_cleanup()) + + except Exception as e: + logger.error(f"Failed to initialize browser: {str(e)}") + raise + + async def _initialize_browser(self) -> None: + """Initialize the browser and create the page pool.""" + playwright = None + camoufox = None + + if self.browser_type in ['chromium', 'chrome', 'msedge']: + playwright = await async_playwright().start() + elif self.browser_type == "camoufox": + camoufox = AsyncCamoufox(headless=self.headless) + + browser_configs = [] + for _ in range(self.thread_count): + if self.browser_type in ['chromium', 'chrome', 'msedge']: + if self.use_random_config: + browser, version, useragent, sec_ch_ua = browser_config.get_random_browser_config(self.browser_type) + elif self.browser_name and self.browser_version: + config = browser_config.get_browser_config(self.browser_name, self.browser_version) + if config: + useragent, sec_ch_ua = config + browser = self.browser_name + version = self.browser_version + else: + browser, version, useragent, sec_ch_ua = browser_config.get_random_browser_config(self.browser_type) + else: + browser = getattr(self, 'browser_name', 'custom') + version = getattr(self, 'browser_version', 'custom') + useragent = self.useragent + sec_ch_ua = getattr(self, 'sec_ch_ua', '') + else: + # Для camoufox и других браузеров используем значения по умолчанию + browser = self.browser_type + version = 'custom' + useragent = self.useragent + sec_ch_ua = getattr(self, 'sec_ch_ua', '') + + + browser_configs.append({ + 'browser_name': browser, + 'browser_version': version, + 'useragent': useragent, + 'sec_ch_ua': sec_ch_ua + }) + + for i in range(self.thread_count): + config = browser_configs[i] + + browser_args = [ + "--window-position=0,0", + "--force-device-scale-factor=1" + ] + if config['useragent']: + browser_args.append(f"--user-agent={config['useragent']}") + + browser = None + if self.browser_type in ['chromium', 'chrome', 'msedge'] and playwright: + browser = await playwright.chromium.launch( + channel=self.browser_type, + headless=self.headless, + args=browser_args + ) + elif self.browser_type == "camoufox" and camoufox: + browser = await camoufox.start() + + if browser: + await self.browser_pool.put((i+1, browser, config)) + + if self.debug: + logger.info(f"Browser {i + 1} initialized successfully with {config['browser_name']} {config['browser_version']}") + + logger.info(f"Browser pool initialized with {self.browser_pool.qsize()} browsers") + + if self.use_random_config: + logger.info(f"Each browser in pool received random configuration") + elif self.browser_name and self.browser_version: + logger.info(f"All browsers using configuration: {self.browser_name} {self.browser_version}") + else: + logger.info("Using custom configuration") + + if self.debug: + for i, config in enumerate(browser_configs): + logger.debug(f"Browser {i+1} config: {config['browser_name']} {config['browser_version']}") + logger.debug(f"Browser {i+1} User-Agent: {config['useragent']}") + logger.debug(f"Browser {i+1} Sec-CH-UA: {config['sec_ch_ua']}") + + async def _periodic_cleanup(self): + """Periodic cleanup of old results every hour""" + while True: + try: + await asyncio.sleep(3600) + deleted_count = await cleanup_old_results(days_old=7) + if deleted_count > 0: + logger.info(f"Cleaned up {deleted_count} old results") + except Exception as e: + logger.error(f"Error during periodic cleanup: {e}") + + async def _antishadow_inject(self, page): + await page.add_init_script(""" + (function() { + const originalAttachShadow = Element.prototype.attachShadow; + Element.prototype.attachShadow = function(init) { + const shadow = originalAttachShadow.call(this, init); + if (init.mode === 'closed') { + window.__lastClosedShadowRoot = shadow; + } + return shadow; + }; + })(); + """) + + + + async def _optimized_route_handler(self, route): + """Оптимизированный обработчик маршрутов для экономии ресурсов.""" + url = route.request.url + resource_type = route.request.resource_type + + allowed_types = {'document', 'script', 'xhr', 'fetch'} + + allowed_domains = [ + 'challenges.cloudflare.com', + 'static.cloudflareinsights.com', + 'cloudflare.com' + ] + + if resource_type in allowed_types: + await route.continue_() + elif any(domain in url for domain in allowed_domains): + await route.continue_() + else: + await route.abort() + + async def _block_rendering(self, page): + """Блокировка рендеринга для экономии ресурсов""" + await page.route("**/*", self._optimized_route_handler) + + async def _unblock_rendering(self, page): + """Разблокировка рендеринга""" + await page.unroute("**/*", self._optimized_route_handler) + + async def _find_turnstile_elements(self, page, index: int): + """Умная проверка всех возможных Turnstile элементов""" + selectors = [ + '.cf-turnstile', + '[data-sitekey]', + 'iframe[src*="turnstile"]', + 'iframe[title*="widget"]', + 'div[id*="turnstile"]', + 'div[class*="turnstile"]' + ] + + elements = [] + for selector in selectors: + try: + # Безопасная проверка count() + try: + count = await page.locator(selector).count() + except Exception: + # Если count() дает ошибку, пропускаем этот селектор + continue + + if count > 0: + elements.append((selector, count)) + if self.debug: + logger.debug(f"Browser {index}: Found {count} elements with selector '{selector}'") + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Selector '{selector}' failed: {str(e)}") + continue + + return elements + + async def _find_and_click_checkbox(self, page, index: int): + """Найти и кликнуть по чекбоксу Turnstile CAPTCHA внутри iframe""" + try: + # Пробуем разные селекторы iframe с защитой от ошибок + iframe_selectors = [ + 'iframe[src*="challenges.cloudflare.com"]', + 'iframe[src*="turnstile"]', + 'iframe[title*="widget"]' + ] + + iframe_locator = None + for selector in iframe_selectors: + try: + test_locator = page.locator(selector).first + # Безопасная проверка count для iframe + try: + iframe_count = await test_locator.count() + except Exception: + iframe_count = 0 + + if iframe_count > 0: + iframe_locator = test_locator + if self.debug: + logger.debug(f"Browser {index}: Found Turnstile iframe with selector: {selector}") + break + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Iframe selector '{selector}' failed: {str(e)}") + continue + + if iframe_locator: + try: + # Получаем frame из iframe + iframe_element = await iframe_locator.element_handle() + frame = await iframe_element.content_frame() + + if frame: + # Ищем чекбокс внутри iframe + checkbox_selectors = [ + 'input[type="checkbox"]', + '.cb-lb input[type="checkbox"]', + 'label input[type="checkbox"]' + ] + + for selector in checkbox_selectors: + try: + # Полностью избегаем locator.count() в iframe - используем альтернативный подход + try: + # Пробуем кликнуть напрямую без count проверки + checkbox = frame.locator(selector).first + await checkbox.click(timeout=2000) + if self.debug: + logger.debug(f"Browser {index}: Successfully clicked checkbox in iframe with selector '{selector}'") + return True + except Exception as click_e: + # Если прямой клик не сработал, записываем в debug но не падаем + if self.debug: + logger.debug(f"Browser {index}: Direct checkbox click failed for '{selector}': {str(click_e)}") + continue + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Iframe checkbox selector '{selector}' failed: {str(e)}") + continue + + # Если нашли iframe, но не смогли кликнуть чекбокс, пробуем клик по iframe + try: + if self.debug: + logger.debug(f"Browser {index}: Trying to click iframe directly as fallback") + await iframe_locator.click(timeout=1000) + return True + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Iframe direct click failed: {str(e)}") + + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Failed to access iframe content: {str(e)}") + + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: General iframe search failed: {str(e)}") + + return False + + async def _try_click_strategies(self, page, index: int): + strategies = [ + ('checkbox_click', lambda: self._find_and_click_checkbox(page, index)), + ('direct_widget', lambda: self._safe_click(page, '.cf-turnstile', index)), + ('iframe_click', lambda: self._safe_click(page, 'iframe[src*="turnstile"]', index)), + ('js_click', lambda: page.evaluate("document.querySelector('.cf-turnstile')?.click()")), + ('sitekey_attr', lambda: self._safe_click(page, '[data-sitekey]', index)), + ('any_turnstile', lambda: self._safe_click(page, '*[class*="turnstile"]', index)), + ('xpath_click', lambda: self._safe_click(page, "//div[@class='cf-turnstile']", index)) + ] + + for strategy_name, strategy_func in strategies: + try: + result = await strategy_func() + if result is True or result is None: # None означает успех для большинства стратегий + if self.debug: + logger.debug(f"Browser {index}: Click strategy '{strategy_name}' succeeded") + return True + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Click strategy '{strategy_name}' failed: {str(e)}") + continue + + return False + + async def _safe_click(self, page, selector: str, index: int): + """Полностью безопасный клик с максимальной защитой от ошибок""" + try: + # Пробуем кликнуть напрямую без count() проверки + locator = page.locator(selector).first + await locator.click(timeout=1000) + return True + except Exception as e: + # Логируем ошибку только в debug режиме + if self.debug and "Can't query n-th element" not in str(e): + logger.debug(f"Browser {index}: Safe click failed for '{selector}': {str(e)}") + return False + + async def _inject_captcha_directly(self, page, websiteKey: str, action: str = '', cdata: str = '', index: int = 0): + """Inject CAPTCHA directly into the target website or use existing one""" + script = f""" + (function() {{ + // Check if there's already a turnstile widget on the page with matching sitekey + const existingWidgets = document.querySelectorAll('.cf-turnstile, [data-sitekey]'); + console.log('Turnstile Debug: Found ' + existingWidgets.length + ' potential widgets'); + let useExisting = false; + let foundSitekey = null; + + for (const widget of existingWidgets) {{ + const widgetSitekey = widget.getAttribute('data-sitekey'); + console.log('Turnstile Debug: Checking widget with sitekey:', widgetSitekey); + if (widgetSitekey === '{websiteKey}') {{ + useExisting = true; + foundSitekey = widgetSitekey; + console.log('Turnstile Debug: Found existing turnstile widget with matching sitekey'); + break; + }} + }} + + // Also check for iframe-based turnstile + const turnstileIframes = document.querySelectorAll('iframe[src*="turnstile"], iframe[src*="challenges.cloudflare"]'); + console.log('Turnstile Debug: Found ' + turnstileIframes.length + ' turnstile iframes'); + + // Create hidden input for token if not exists + let tokenInput = document.querySelector('input[name="cf-turnstile-response"]'); + if (!tokenInput) {{ + tokenInput = document.createElement('input'); + tokenInput.type = 'hidden'; + tokenInput.name = 'cf-turnstile-response'; + document.body.appendChild(tokenInput); + }} + + // Setup token capture callback + window._turnstileTokenCallback = function(token) {{ + console.log('Turnstile token captured:', token); + tokenInput.value = token; + }}; + + // If existing widget found, try to hook into it + if (useExisting) {{ + console.log('Using existing turnstile widget'); + // Try to override the existing callback or add our own + const originalCallback = window.turnstileCallback; + window.turnstileCallback = function(token) {{ + window._turnstileTokenCallback(token); + if (originalCallback) originalCallback(token); + }}; + // Also try to set up a mutation observer to capture token changes + return 'existing'; + }} + + // Remove any existing turnstile widgets that don't match our sitekey + document.querySelectorAll('.cf-turnstile').forEach(el => el.remove()); + document.querySelectorAll('[data-sitekey]').forEach(el => {{ + if (el.getAttribute('data-sitekey') !== '{websiteKey}') el.remove(); + }}); + + // Create turnstile widget directly on the page + const captchaDiv = document.createElement('div'); + captchaDiv.className = 'cf-turnstile'; + captchaDiv.setAttribute('data-sitekey', '{websiteKey}'); + captchaDiv.setAttribute('data-callback', '_turnstileTokenCallback'); + {f'captchaDiv.setAttribute("data-action", "{action}");' if action else ''} + {f'captchaDiv.setAttribute("data-cdata", "{cdata}");' if cdata else ''} + captchaDiv.style.position = 'fixed'; + captchaDiv.style.top = '20px'; + captchaDiv.style.left = '20px'; + captchaDiv.style.zIndex = '9999'; + captchaDiv.style.backgroundColor = 'white'; + captchaDiv.style.padding = '15px'; + captchaDiv.style.border = '2px solid #0f79af'; + captchaDiv.style.borderRadius = '8px'; + captchaDiv.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.3)'; + + // Add to body immediately + document.body.appendChild(captchaDiv); + + // Load Turnstile script and render widget + const loadTurnstile = () => {{ + const script = document.createElement('script'); + script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; + script.async = true; + script.defer = true; + script.onload = function() {{ + console.log('Turnstile script loaded'); + // Wait a bit for script to initialize + setTimeout(() => {{ + if (window.turnstile && window.turnstile.render) {{ + try {{ + window.turnstile.render(captchaDiv, {{ + sitekey: '{websiteKey}', + {f'action: "{action}",' if action else ''} + {f'cdata: "{cdata}",' if cdata else ''} + callback: function(token) {{ + console.log('Turnstile solved with token:', token); + window._turnstileTokenCallback(token); + }}, + 'error-callback': function(error) {{ + console.log('Turnstile error:', error); + }} + }}); + }} catch (e) {{ + console.log('Turnstile render error:', e); + }} + }} else {{ + console.log('Turnstile API not available'); + }} + }}, 1000); + }}; + script.onerror = function() {{ + console.log('Failed to load Turnstile script'); + }}; + document.head.appendChild(script); + }}; + + // Check if Turnstile is already loaded + if (window.turnstile) {{ + console.log('Turnstile already loaded, rendering immediately'); + try {{ + window.turnstile.render(captchaDiv, {{ + sitekey: '{websiteKey}', + {f'action: "{action}",' if action else ''} + {f'cdata: "{cdata}",' if cdata else ''} + callback: function(token) {{ + console.log('Turnstile solved with token:', token); + window._turnstileTokenCallback(token); + }}, + 'error-callback': function(error) {{ + console.log('Turnstile error:', error); + }} + }}); + }} catch (e) {{ + console.log('Immediate render error:', e); + loadTurnstile(); + }} + }} else {{ + loadTurnstile(); + }} + + // Setup global callback + window.onTurnstileCallback = function(token) {{ + console.log('Global turnstile callback executed:', token); + }}; + + return 'injected'; + }})(); + """ + + result = await page.evaluate(script) + if self.debug: + if result == 'existing': + logger.debug(f"Browser {index}: Detected existing turnstile widget with matching sitekey") + else: + logger.debug(f"Browser {index}: Injected new CAPTCHA widget with sitekey: {websiteKey}") + return result + + async def _solve_turnstile(self, task_id: str, url: str, sitekey: str, action: Optional[str] = None, cdata: Optional[str] = None): + """Solve the Turnstile challenge.""" + proxy = None + + index, browser, browser_config = await self.browser_pool.get() + + try: + if hasattr(browser, 'is_connected') and not browser.is_connected(): + if self.debug: + logger.warning(f"Browser {index}: Browser disconnected, skipping") + await self.browser_pool.put((index, browser, browser_config)) + await save_result(task_id, "turnstile", {"value": "CAPTCHA_FAIL", "elapsed_time": 0}) + return + except Exception as e: + if self.debug: + logger.warning(f"Browser {index}: Cannot check browser state: {str(e)}") + + if self.proxy_support: + proxy_file_path = os.path.join(os.getcwd(), "proxies.txt") + + try: + with open(proxy_file_path) as proxy_file: + proxies = [line.strip() for line in proxy_file if line.strip()] + + proxy = random.choice(proxies) if proxies else None + + if self.debug and proxy: + logger.debug(f"Browser {index}: Selected proxy: {proxy}") + elif self.debug and not proxy: + logger.debug(f"Browser {index}: No proxies available") + + except FileNotFoundError: + logger.warning(f"Proxy file not found: {proxy_file_path}") + proxy = None + except Exception as e: + logger.error(f"Error reading proxy file: {str(e)}") + proxy = None + + if proxy: + if '@' in proxy: + try: + scheme_part, auth_part = proxy.split('://') + auth, address = auth_part.split('@') + username, password = auth.split(':') + ip, port = address.split(':') + if self.debug: + logger.debug(f"Browser {index}: Creating context with proxy {scheme_part}://{ip}:{port} (auth: {username}:***)") + context_options = { + "proxy": { + "server": f"{scheme_part}://{ip}:{port}", + "username": username, + "password": password + }, + "user_agent": browser_config['useragent'] + } + + if browser_config['sec_ch_ua'] and browser_config['sec_ch_ua'].strip(): + context_options['extra_http_headers'] = { + 'sec-ch-ua': browser_config['sec_ch_ua'] + } + + context = await browser.new_context(**context_options) + except ValueError: + raise ValueError(f"Invalid proxy format: {proxy}") + else: + parts = proxy.split(':') + if len(parts) == 5: + proxy_scheme, proxy_ip, proxy_port, proxy_user, proxy_pass = parts + if self.debug: + logger.debug(f"Browser {index}: Creating context with proxy {proxy_scheme}://{proxy_ip}:{proxy_port} (auth: {proxy_user}:***)") + context_options = { + "proxy": { + "server": f"{proxy_scheme}://{proxy_ip}:{proxy_port}", + "username": proxy_user, + "password": proxy_pass + }, + "user_agent": browser_config['useragent'] + } + + if browser_config['sec_ch_ua'] and browser_config['sec_ch_ua'].strip(): + context_options['extra_http_headers'] = { + 'sec-ch-ua': browser_config['sec_ch_ua'] + } + + context = await browser.new_context(**context_options) + elif len(parts) == 3: + if self.debug: + logger.debug(f"Browser {index}: Creating context with proxy {proxy}") + context_options = { + "proxy": {"server": f"{proxy}"}, + "user_agent": browser_config['useragent'] + } + + if browser_config['sec_ch_ua'] and browser_config['sec_ch_ua'].strip(): + context_options['extra_http_headers'] = { + 'sec-ch-ua': browser_config['sec_ch_ua'] + } + + context = await browser.new_context(**context_options) + else: + raise ValueError(f"Invalid proxy format: {proxy}") + else: + if self.debug: + logger.debug(f"Browser {index}: Creating context without proxy") + context_options = {"user_agent": browser_config['useragent']} + + if browser_config['sec_ch_ua'] and browser_config['sec_ch_ua'].strip(): + context_options['extra_http_headers'] = { + 'sec-ch-ua': browser_config['sec_ch_ua'] + } + + context = await browser.new_context(**context_options) + else: + context_options = {"user_agent": browser_config['useragent']} + + if browser_config['sec_ch_ua'] and browser_config['sec_ch_ua'].strip(): + context_options['extra_http_headers'] = { + 'sec-ch-ua': browser_config['sec_ch_ua'] + } + + context = await browser.new_context(**context_options) + + page = await context.new_page() + + await self._antishadow_inject(page) + + await self._block_rendering(page) + + await page.add_init_script(""" + Object.defineProperty(navigator, 'webdriver', { + get: () => undefined, + }); + + window.chrome = { + runtime: {}, + loadTimes: function() {}, + csi: function() {}, + }; + """) + + if self.browser_type in ['chromium', 'chrome', 'msedge']: + await page.set_viewport_size({"width": 500, "height": 100}) + if self.debug: + logger.debug(f"Browser {index}: Set viewport size to 500x240") + + start_time = time.time() + + try: + if self.debug: + logger.debug(f"Browser {index}: Starting Turnstile solve for URL: {url} with Sitekey: {sitekey} | Action: {action} | Cdata: {cdata} | Proxy: {proxy}") + logger.debug(f"Browser {index}: Setting up optimized page loading with resource blocking") + + if self.debug: + logger.debug(f"Browser {index}: Loading real website directly: {url}") + + await page.goto(url, wait_until='networkidle', timeout=30000) + + await self._unblock_rendering(page) + + # Wait for turnstile to load (it may be lazy loaded) + if self.debug: + logger.debug(f"Browser {index}: Waiting for turnstile to appear...") + + # Try to wait for turnstile widget to appear + turnstile_found = False + for wait_attempt in range(15): + widget_count = await page.locator('.cf-turnstile, [data-sitekey]').count() + iframe_count = await page.locator('iframe[src*="turnstile"], iframe[src*="challenges.cloudflare"]').count() + + # Also check if turnstile API is available + turnstile_ready = await page.evaluate("""() => { + return typeof window.turnstile !== 'undefined'; + }""") + + if self.debug and wait_attempt % 3 == 0: + logger.debug(f"Browser {index}: Wait {wait_attempt + 1}s - {widget_count} widgets, {iframe_count} iframes, turnstile API: {turnstile_ready}") + + if widget_count > 0 or iframe_count > 0 or turnstile_ready: + if self.debug: + logger.debug(f"Browser {index}: Turnstile ready after {wait_attempt + 1}s (widgets: {widget_count}, iframes: {iframe_count}, API: {turnstile_ready})") + turnstile_found = True + break + await asyncio.sleep(1) + + if not turnstile_found: + if self.debug: + logger.debug(f"Browser {index}: Turnstile not found naturally, will inject our own") + + # Сразу инъектируем виджет Turnstile на целевой сайт + if self.debug: + logger.debug(f"Browser {index}: Injecting Turnstile widget directly into target site") + + # Debug: Check page state before injection + if self.debug: + widget_count = await page.locator('.cf-turnstile, [data-sitekey]').count() + iframe_count = await page.locator('iframe[src*="turnstile"], iframe[src*="challenges.cloudflare"]').count() + logger.debug(f"Browser {index}: Before injection - {widget_count} widgets, {iframe_count} iframes") + # Check for sitekey attribute + try: + sitekey_elem = await page.locator('[data-sitekey]').get_attribute('data-sitekey') + logger.debug(f"Browser {index}: Found sitekey: {sitekey_elem}") + except: + pass + + inject_result = await self._inject_captcha_directly(page, sitekey, action or '', cdata or '', index) + + if self.debug: + if inject_result == 'existing': + logger.debug(f"Browser {index}: Using existing turnstile widget on page") + else: + logger.debug(f"Browser {index}: Injected new turnstile widget") + + # Ждем время для загрузки и рендеринга виджета + await asyncio.sleep(3) + + locator = page.locator('input[name="cf-turnstile-response"]') + max_attempts = 30 + click_count = 0 + max_clicks = 10 + + for attempt in range(max_attempts): + try: + # Безопасная проверка количества элементов с токеном + try: + count = await locator.count() + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Locator count failed on attempt {attempt + 1}: {str(e)}") + count = 0 + + if count == 0: + if self.debug and attempt % 5 == 0: + logger.debug(f"Browser {index}: No token elements found on attempt {attempt + 1}") + # Also check if turnstile widget exists on page + if self.debug and attempt == 0: + widget_count = await page.locator('.cf-turnstile, [data-sitekey]').count() + iframe_count = await page.locator('iframe[src*="turnstile"], iframe[src*="challenges.cloudflare"]').count() + logger.debug(f"Browser {index}: Page has {widget_count} turnstile widgets and {iframe_count} iframes") + elif count == 1: + # Если только один элемент, проверяем его токен + try: + token = await locator.input_value(timeout=500) + if token: + elapsed_time = round(time.time() - start_time, 3) + logger.success(f"Browser {index}: Successfully solved captcha - {COLORS.get('MAGENTA')}{token[:10]}{COLORS.get('RESET')} in {COLORS.get('GREEN')}{elapsed_time}{COLORS.get('RESET')} Seconds") + await save_result(task_id, "turnstile", {"value": token, "elapsed_time": elapsed_time}) + return + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Single token element check failed: {str(e)}") + else: + # Если несколько элементов, проверяем все по очереди + if self.debug: + logger.debug(f"Browser {index}: Found {count} token elements, checking all") + + for i in range(count): + try: + element_token = await locator.nth(i).input_value(timeout=500) + if element_token: + elapsed_time = round(time.time() - start_time, 3) + logger.success(f"Browser {index}: Successfully solved captcha - {COLORS.get('MAGENTA')}{element_token[:10]}{COLORS.get('RESET')} in {COLORS.get('GREEN')}{elapsed_time}{COLORS.get('RESET')} Seconds") + await save_result(task_id, "turnstile", {"value": element_token, "elapsed_time": elapsed_time}) + return + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Token element {i} check failed: {str(e)}") + continue + + if attempt > 2 and attempt % 3 == 0 and click_count < max_clicks: + click_success = await self._try_click_strategies(page, index) + click_count += 1 + if click_success and self.debug: + logger.debug(f"Browser {index}: Click successful (click #{click_count}/{max_clicks})") + elif not click_success and self.debug: + logger.debug(f"Browser {index}: All click strategies failed on attempt {attempt + 1} (click #{click_count}/{max_clicks})") + + # Адаптивное ожидание + wait_time = min(0.5 + (attempt * 0.05), 2.0) + await asyncio.sleep(wait_time) + + if self.debug and attempt % 5 == 0: + logger.debug(f"Browser {index}: Attempt {attempt + 1}/{max_attempts} - Waiting for token (clicks: {click_count}/{max_clicks})") + + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Attempt {attempt + 1} error: {str(e)}") + continue + + elapsed_time = round(time.time() - start_time, 3) + await save_result(task_id, "turnstile", {"value": "CAPTCHA_FAIL", "elapsed_time": elapsed_time}) + if self.debug: + logger.error(f"Browser {index}: Error solving Turnstile in {COLORS.get('RED')}{elapsed_time}{COLORS.get('RESET')} Seconds") + except Exception as e: + elapsed_time = round(time.time() - start_time, 3) + await save_result(task_id, "turnstile", {"value": "CAPTCHA_FAIL", "elapsed_time": elapsed_time}) + if self.debug: + logger.error(f"Browser {index}: Error solving Turnstile: {str(e)}") + finally: + if self.debug: + logger.debug(f"Browser {index}: Closing browser context and cleaning up") + + try: + await context.close() + if self.debug: + logger.debug(f"Browser {index}: Context closed successfully") + except Exception as e: + if self.debug: + logger.warning(f"Browser {index}: Error closing context: {str(e)}") + + try: + if hasattr(browser, 'is_connected') and browser.is_connected(): + await self.browser_pool.put((index, browser, browser_config)) + if self.debug: + logger.debug(f"Browser {index}: Browser returned to pool") + else: + if self.debug: + logger.warning(f"Browser {index}: Browser disconnected, not returning to pool") + except Exception as e: + if self.debug: + logger.warning(f"Browser {index}: Error returning browser to pool: {str(e)}") + + + + + + + async def process_turnstile(self): + """Handle the /turnstile endpoint requests.""" + url = request.args.get('url') + sitekey = request.args.get('sitekey') + action = request.args.get('action') + cdata = request.args.get('cdata') + + if not url or not sitekey: + return jsonify({ + "errorId": 1, + "errorCode": "ERROR_WRONG_PAGEURL", + "errorDescription": "Both 'url' and 'sitekey' are required" + }), 200 + + task_id = str(uuid.uuid4()) + await save_result(task_id, "turnstile", { + "status": "CAPTCHA_NOT_READY", + "createTime": int(time.time()), + "url": url, + "sitekey": sitekey, + "action": action, + "cdata": cdata + }) + + try: + asyncio.create_task(self._solve_turnstile(task_id=task_id, url=url, sitekey=sitekey, action=action, cdata=cdata)) + + if self.debug: + logger.debug(f"Request completed with taskid {task_id}.") + return jsonify({ + "errorId": 0, + "taskId": task_id + }), 200 + except Exception as e: + logger.error(f"Unexpected error processing request: {str(e)}") + return jsonify({ + "errorId": 1, + "errorCode": "ERROR_UNKNOWN", + "errorDescription": str(e) + }), 200 + + async def get_result(self): + """Return solved data""" + task_id = request.args.get('id') + + if not task_id: + return jsonify({ + "errorId": 1, + "errorCode": "ERROR_WRONG_CAPTCHA_ID", + "errorDescription": "Invalid task ID/Request parameter" + }), 200 + + result = await load_result(task_id) + if not result: + return jsonify({ + "errorId": 1, + "errorCode": "ERROR_CAPTCHA_UNSOLVABLE", + "errorDescription": "Task not found" + }), 200 + + if result == "CAPTCHA_NOT_READY" or (isinstance(result, dict) and result.get("status") == "CAPTCHA_NOT_READY"): + return jsonify({"status": "processing"}), 200 + + if isinstance(result, dict) and result.get("value") == "CAPTCHA_FAIL": + return jsonify({ + "errorId": 1, + "errorCode": "ERROR_CAPTCHA_UNSOLVABLE", + "errorDescription": "Workers could not solve the Captcha" + }), 200 + + if isinstance(result, dict) and result.get("value") and result.get("value") != "CAPTCHA_FAIL": + return jsonify({ + "errorId": 0, + "status": "ready", + "solution": { + "token": result["value"] + } + }), 200 + else: + return jsonify({ + "errorId": 1, + "errorCode": "ERROR_CAPTCHA_UNSOLVABLE", + "errorDescription": "Workers could not solve the Captcha" + }), 200 + + + + @staticmethod + async def index(): + """Serve the API documentation page.""" + return """ + + + + + + Turnstile Solver API + + + +
+

Welcome to Turnstile Solver API

+ +

To use the turnstile service, send a GET request to + /turnstile with the following query parameters:

+ + + +
+

Example usage:

+ /turnstile?url=https://example.com&sitekey=sitekey +
+ + +
+

📢 Connect with Us

+
+

+ 📢 Channel: + https://t.me/D3_vin + - Latest updates and releases +

+

+ 💬 Chat: + https://t.me/D3vin_chat + - Community support and discussions +

+

+ 📁 GitHub: + https://github.com/D3-vin + - Source code and development +

+
+
+
+ + + """ + + +def parse_args(): + """Parse command-line arguments.""" + parser = argparse.ArgumentParser(description="Turnstile API Server") + + parser.add_argument('--no-headless', action='store_true', help='Run the browser with GUI (disable headless mode). By default, headless mode is enabled.') + parser.add_argument('--useragent', type=str, help='User-Agent string (if not specified, random configuration is used)') + parser.add_argument('--debug', action='store_true', help='Enable or disable debug mode for additional logging and troubleshooting information (default: False)') + parser.add_argument('--browser_type', type=str, default='chromium', help='Specify the browser type for the solver. Supported options: chromium, chrome, msedge, camoufox (default: chromium)') + parser.add_argument('--thread', type=int, default=4, help='Set the number of browser threads to use for multi-threaded mode. Increasing this will speed up execution but requires more resources (default: 1)') + parser.add_argument('--proxy', action='store_true', help='Enable proxy support for the solver (Default: False)') + parser.add_argument('--random', action='store_true', help='Use random User-Agent and Sec-CH-UA configuration from pool') + parser.add_argument('--browser', type=str, help='Specify browser name to use (e.g., chrome, firefox)') + parser.add_argument('--version', type=str, help='Specify browser version to use (e.g., 139, 141)') + parser.add_argument('--host', type=str, default='0.0.0.0', help='Specify the IP address where the API solver runs. (Default: 127.0.0.1)') + parser.add_argument('--port', type=str, default='5072', help='Set the port for the API solver to listen on. (Default: 5072)') + return parser.parse_args() + + +def create_app(headless: bool, useragent: str, debug: bool, browser_type: str, thread: int, proxy_support: bool, use_random_config: bool, browser_name: str, browser_version: str) -> Quart: + server = TurnstileAPIServer(headless=headless, useragent=useragent, debug=debug, browser_type=browser_type, thread=thread, proxy_support=proxy_support, use_random_config=use_random_config, browser_name=browser_name, browser_version=browser_version) + return server.app + + +if __name__ == '__main__': + args = parse_args() + browser_types = [ + 'chromium', + 'chrome', + 'msedge', + 'camoufox', + ] + if args.browser_type not in browser_types: + logger.error(f"Unknown browser type: {COLORS.get('RED')}{args.browser_type}{COLORS.get('RESET')} Available browser types: {browser_types}") + else: + app = create_app( + headless=not args.no_headless, + debug=args.debug, + useragent=args.useragent, + browser_type=args.browser_type, + thread=args.thread, + proxy_support=args.proxy, + use_random_config=args.random, + browser_name=args.browser, + browser_version=args.version + ) + app.run(host=args.host, port=int(args.port)) diff --git a/browser_configs.py b/browser_configs.py new file mode 100644 index 0000000..70dfb4f --- /dev/null +++ b/browser_configs.py @@ -0,0 +1,17 @@ +import random + +class browser_config: + @staticmethod + def get_random_browser_config(browser_type): + # 返回: 浏览器名, 版本, User-Agent, Sec-CH-UA + versions = ["120.0.0.0", "121.0.0.0", "122.0.0.0", "124.0.0.0"] + ver = random.choice(versions) + ua = f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{ver} Safari/537.36" + sec_ch_ua = f'"Not(A:Brand";v="99", "Google Chrome";v="{ver.split(".")[0]}", "Chromium";v="{ver.split(".")[0]}"' + return "chrome", ver, ua, sec_ch_ua + + @staticmethod + def get_browser_config(name, version): + ua = f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{version} Safari/537.36" + sec_ch_ua = f'"Google Chrome";v="{version}", "Chromium";v="{version}"' + return ua, sec_ch_ua \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..97dd8bf --- /dev/null +++ b/config.py @@ -0,0 +1,23 @@ +""" +Tavily 注册工具 - 配置文件 +修改此文件以匹配你的环境 +""" + +# ──���────────── 邮箱配置 ───────────── +# Cloudflare Email Workers +EMAIL_API_URL = "https://mail.nashome.me" +EMAIL_API_TOKEN = "0e5ad283dc25405dfdeb47fbb876648ce95d9daadda92d4175a17e3e426c0d4b" +EMAIL_DOMAIN = "nashome.me" + +# DuckMail +DUCKMAIL_API_URL = "https://api.duckmail.sbs" +DUCKMAIL_API_KEY = "dk_05d110d3bb873e617406f0946fb9e55f02f6ab926aeac06ac7419e9ac62d5d08" + +# ───────────── 代理服务器配置(上传目标)───────────── +SERVER_URL = "https://tavily.hunters.works" +SERVER_ADMIN_PASSWORD = "Jelly120425" + +# ───────────── 注册默认参数 ───────────── +DEFAULT_COUNT = 5 # 默认注册数量 +DEFAULT_DELAY = 10 # 每次注册间隔(秒) +FIXED_PASSWORD = "Tavily2024!@" # 固定密码(方便登录) diff --git a/db_results.py b/db_results.py new file mode 100644 index 0000000..7f5c044 --- /dev/null +++ b/db_results.py @@ -0,0 +1,27 @@ +import time +import asyncio + +# 内存数据库,用于临时存储验证码结果 +results_db = {} + +async def init_db(): + print("[系统] 结果数据库初始化成功 (内存模式)") + +async def save_result(task_id, task_type, data): + # 存储结果,如果 data 是字典则存入,否则构造字典 + results_db[task_id] = data + print(f"[系统] 任务 {task_id} 状态更新: {data.get('value', '正在处理')}") + +async def load_result(task_id): + return results_db.get(task_id) + +async def cleanup_old_results(days_old=7): + # 简单的清理逻辑 + now = time.time() + to_delete = [] + for tid, res in results_db.items(): + if isinstance(res, dict) and now - res.get('createTime', now) > days_old * 86400: + to_delete.append(tid) + for tid in to_delete: + del results_db[tid] + return len(to_delete) \ No newline at end of file diff --git a/proxy/.env.example b/proxy/.env.example new file mode 100644 index 0000000..b4e76df --- /dev/null +++ b/proxy/.env.example @@ -0,0 +1 @@ +ADMIN_PASSWORD=change-me diff --git a/proxy/Dockerfile b/proxy/Dockerfile new file mode 100644 index 0000000..f5e149a --- /dev/null +++ b/proxy/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 9874 +CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "9874"] diff --git a/proxy/README.md b/proxy/README.md new file mode 100644 index 0000000..d09b08b --- /dev/null +++ b/proxy/README.md @@ -0,0 +1,106 @@ +# Tavily API Proxy + +将多个 Tavily API Key 池化,对外暴露统一端点和 Token,附带 Web 管理控制台。 + +## 功能 + +- **Key 池化轮询**:Round-robin 分配请求到多个 API Key,连续失败 3 次自动禁用 +- **Token 管理**:创建多个访问 Token,每个 Token 独立配额(小时/日/月) +- **用量统计**:实时查看成功/失败次数、延迟、配额使用情况 +- **Web 控制台**:可视化管理 Key、Token 和用量 +- **批量导入**:支持从 `api_keys.md` 格式文本批量导入 Key +- **兼容 Tavily 官方 API**:客户端只需改 base URL 即可 + +## 快速开始 + +### Docker 部署(推荐) + +```bash +cd proxy/ + +# 修改管理密码 +cp .env.example .env +# 编辑 .env 中的 ADMIN_PASSWORD + +# 启动 +docker compose up -d +``` + +服务运行在 `http://localhost:9874`。 + +### 本地运行 + +```bash +cd proxy/ +pip install -r requirements.txt +ADMIN_PASSWORD=your-password uvicorn server:app --host 0.0.0.0 --port 9874 +``` + +## 使用流程 + +1. 访问 `http://localhost:9874/`,输入管理密码登录 +2. 在 Key 管理中导入 Tavily API Key(支持单个添加或批量导入) +3. 创建 Token,复制 Token ID +4. 在应用中使用代理端点: + +```bash +curl -X POST http://localhost:9874/api/search \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"query": "hello world"}' +``` + +也可以把 Token 放在 body 的 `api_key` 字段中: + +```bash +curl -X POST http://localhost:9874/api/search \ + -H "Content-Type: application/json" \ + -d '{"query": "hello world", "api_key": "YOUR_TOKEN"}' +``` + +## API 端点 + +### 代理端点 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/search` | 代理 Tavily Search API | +| POST | `/api/extract` | 代理 Tavily Extract API | + +认证方式:`Authorization: Bearer {token}` 或 body 中 `api_key` 字段。 + +### 管理端点 + +所有管理端点需要 `X-Admin-Password` 请求头或 `Authorization: Bearer {ADMIN_PASSWORD}`。 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/` | Web 管理控制台 | +| GET | `/api/stats` | 用量统计概览 | +| GET | `/api/keys` | 列出所有 Key(脱敏显示) | +| POST | `/api/keys` | 添加 Key:`{"key":"tvly-xxx"}` 或批量 `{"file":"文本内容"}` | +| DELETE | `/api/keys/{id}` | 删除 Key | +| PUT | `/api/keys/{id}/toggle` | 启用/禁用 Key:`{"active": 1}` | +| GET | `/api/tokens` | 列出所有 Token | +| POST | `/api/tokens` | 创建 Token:`{"name":"备注"}` | +| DELETE | `/api/tokens/{id}` | 删除 Token | + +## 配置 + +| 环境变量 | 默认值 | 说明 | +|----------|--------|------| +| `ADMIN_PASSWORD` | `admin` | 管理控制台和管理 API 的密码 | + +## Token 配额 + +每个 Token 默认配额: + +- 小时限制:100 次 +- 日限制:500 次 +- 月限制:5000 次 + +超出配额返回 `429 Too Many Requests`。 + +## 数据持久化 + +SQLite 数据库存储在 `data/proxy.db`。Docker 部署时通过 volume 挂载 `./data:/app/data` 持久化。 diff --git a/proxy/database.py b/proxy/database.py new file mode 100644 index 0000000..f228c04 --- /dev/null +++ b/proxy/database.py @@ -0,0 +1,272 @@ +""" +SQLite 数据库管理 +""" +import sqlite3 +import os +from datetime import datetime, timezone + +DB_PATH = os.path.join(os.path.dirname(__file__), "data", "proxy.db") + + +def get_conn(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + return conn + + +def init_db(): + conn = get_conn() + conn.executescript(""" + CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY, + key TEXT UNIQUE NOT NULL, + email TEXT, + active INTEGER DEFAULT 1, + total_used INTEGER DEFAULT 0, + total_failed INTEGER DEFAULT 0, + consecutive_fails INTEGER DEFAULT 0, + last_used_at TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS tokens ( + id INTEGER PRIMARY KEY, + token TEXT UNIQUE NOT NULL, + name TEXT DEFAULT '', + hourly_limit INTEGER DEFAULT 0, + daily_limit INTEGER DEFAULT 0, + monthly_limit INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS usage_logs ( + id INTEGER PRIMARY KEY, + token_id INTEGER, + api_key_id INTEGER, + endpoint TEXT, + success INTEGER, + latency_ms INTEGER, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_usage_created ON usage_logs(created_at); + CREATE INDEX IF NOT EXISTS idx_usage_token ON usage_logs(token_id); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + """) + conn.commit() + conn.close() + + +# ═══ Settings ═══ + +def get_setting(key, default=None): + conn = get_conn() + try: + row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() + return row["value"] if row else default + finally: + conn.close() + + +def set_setting(key, value): + conn = get_conn() + try: + conn.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, value)) + conn.commit() + finally: + conn.close() + + +# ═══ API Keys ═══ + +def add_key(key, email=""): + conn = get_conn() + try: + conn.execute("INSERT OR IGNORE INTO api_keys (key, email) VALUES (?, ?)", (key, email)) + conn.commit() + return conn.execute("SELECT * FROM api_keys WHERE key = ?", (key,)).fetchone() + finally: + conn.close() + + +def get_all_keys(): + conn = get_conn() + try: + return conn.execute("SELECT * FROM api_keys ORDER BY id").fetchall() + finally: + conn.close() + + +def get_active_keys(): + conn = get_conn() + try: + return conn.execute("SELECT * FROM api_keys WHERE active = 1 ORDER BY id").fetchall() + finally: + conn.close() + + +def update_key_usage(key_id, success): + conn = get_conn() + try: + now = datetime.now(timezone.utc).isoformat() + if success: + conn.execute( + "UPDATE api_keys SET total_used = total_used + 1, consecutive_fails = 0, last_used_at = ? WHERE id = ?", + (now, key_id), + ) + else: + conn.execute( + "UPDATE api_keys SET total_failed = total_failed + 1, consecutive_fails = consecutive_fails + 1, last_used_at = ? WHERE id = ?", + (now, key_id), + ) + # 连续失败 3 次自动禁用 + row = conn.execute("SELECT consecutive_fails FROM api_keys WHERE id = ?", (key_id,)).fetchone() + if row and row["consecutive_fails"] >= 3: + conn.execute("UPDATE api_keys SET active = 0 WHERE id = ?", (key_id,)) + conn.commit() + finally: + conn.close() + + +def toggle_key(key_id, active): + conn = get_conn() + try: + conn.execute("UPDATE api_keys SET active = ?, consecutive_fails = 0 WHERE id = ?", (active, key_id)) + conn.commit() + finally: + conn.close() + + +def delete_key(key_id): + conn = get_conn() + try: + conn.execute("DELETE FROM api_keys WHERE id = ?", (key_id,)) + conn.commit() + finally: + conn.close() + + +def import_keys_from_text(text): + """从 api_keys.md 格式文本批量导入 key""" + import re + count = 0 + for line in text.strip().split("\n"): + line = line.strip() + if not line: + continue + # 格式: email,password,tvly-xxx,timestamp; + match = re.search(r"(tvly-[A-Za-z0-9\-_]{20,})", line) + if match: + key = match.group(1) + parts = line.split(",") + email = parts[0] if len(parts) >= 3 else "" + add_key(key, email) + count += 1 + return count + + +# ═══ Tokens ═══ + +def create_token(name=""): + import random + import string + # 生成类似真实 Tavily key 的长 token: tvly-xxxxxxxx... + token = "tvly-" + "".join(random.choices(string.ascii_letters + string.digits, k=32)) + conn = get_conn() + try: + conn.execute("INSERT INTO tokens (token, name) VALUES (?, ?)", (token, name)) + conn.commit() + return conn.execute("SELECT * FROM tokens WHERE token = ?", (token,)).fetchone() + finally: + conn.close() + + +def get_all_tokens(): + conn = get_conn() + try: + return conn.execute("SELECT * FROM tokens ORDER BY id").fetchall() + finally: + conn.close() + + +def get_token_by_value(token_value): + conn = get_conn() + try: + return conn.execute("SELECT * FROM tokens WHERE token = ?", (token_value,)).fetchone() + finally: + conn.close() + + +def delete_token(token_id): + conn = get_conn() + try: + conn.execute("DELETE FROM tokens WHERE id = ?", (token_id,)) + conn.commit() + finally: + conn.close() + + +# ═══ Usage Logs ═══ + +def log_usage(token_id, api_key_id, endpoint, success, latency_ms): + conn = get_conn() + try: + conn.execute( + "INSERT INTO usage_logs (token_id, api_key_id, endpoint, success, latency_ms) VALUES (?, ?, ?, ?, ?)", + (token_id, api_key_id, endpoint, success, latency_ms), + ) + conn.commit() + finally: + conn.close() + + +def get_usage_stats(token_id=None): + """获取用量统计""" + conn = get_conn() + try: + now = datetime.now(timezone.utc) + today = now.strftime("%Y-%m-%d") + month = now.strftime("%Y-%m") + hour_ago = now.replace(minute=0, second=0, microsecond=0).isoformat() + + where = "" + params = [] + if token_id: + where = "AND token_id = ?" + params = [token_id] + + def count(condition, extra_params=None): + p = (extra_params or []) + list(params) + row = conn.execute( + f"SELECT COUNT(*) as c FROM usage_logs WHERE {condition} {where}", p + ).fetchone() + return row["c"] + + return { + "today_success": count("success = 1 AND created_at >= ?", [today]), + "today_failed": count("success = 0 AND created_at >= ?", [today]), + "month_success": count("success = 1 AND created_at >= ?", [month]), + "hour_count": count("created_at >= ?", [hour_ago]), + "today_count": count("created_at >= ?", [today]), + "month_count": count("created_at >= ?", [month]), + } + finally: + conn.close() + + +def check_quota(token_id, hourly_limit, daily_limit, monthly_limit): + """检查 token 配额是否超限,返回 (ok, reason)""" + stats = get_usage_stats(token_id) + if hourly_limit and stats["hour_count"] >= hourly_limit: + return False, "hourly quota exceeded" + if daily_limit and stats["today_count"] >= daily_limit: + return False, "daily quota exceeded" + if monthly_limit and stats["month_count"] >= monthly_limit: + return False, "monthly quota exceeded" + return True, "" diff --git a/proxy/docker-compose.yml b/proxy/docker-compose.yml new file mode 100644 index 0000000..95f4757 --- /dev/null +++ b/proxy/docker-compose.yml @@ -0,0 +1,10 @@ +services: + tavily-proxy: + build: . + ports: + - "9874:9874" + environment: + - ADMIN_PASSWORD=change-me + volumes: + - ./data:/app/data + restart: unless-stopped diff --git a/proxy/key_pool.py b/proxy/key_pool.py new file mode 100644 index 0000000..8bcf176 --- /dev/null +++ b/proxy/key_pool.py @@ -0,0 +1,44 @@ +""" +API Key 轮询池 +""" +import threading +from database import get_active_keys, update_key_usage + + +class KeyPool: + def __init__(self): + self._lock = threading.Lock() + self._index = 0 + self._keys = [] + self._initialized = False + + def reload(self): + with self._lock: + self._keys = [dict(row) for row in get_active_keys()] + if self._index >= len(self._keys): + self._index = 0 + self._initialized = True + + def get_next_key(self): + """Round-robin 返回下一个可用 key,返回 dict 或 None""" + if not self._initialized: + self.reload() + with self._lock: + if not self._keys: + return None + key = self._keys[self._index] + self._index = (self._index + 1) % len(self._keys) + return key + + def report_result(self, key_id, success): + """记录使用结果,失败 3 次自动禁用并从池中移除""" + update_key_usage(key_id, success) + if not success: + with self._lock: + self._keys = [k for k in self._keys if k["id"] != key_id or k.get("consecutive_fails", 0) < 2] + # 完整重载以获取最新状态 + self.reload() + + +# 全局单例 +pool = KeyPool() diff --git a/proxy/requirements.txt b/proxy/requirements.txt new file mode 100644 index 0000000..1d07256 --- /dev/null +++ b/proxy/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.104.0 +uvicorn>=0.24.0 +httpx>=0.25.0 +jinja2>=3.1.0 diff --git a/proxy/server.py b/proxy/server.py new file mode 100644 index 0000000..a5a6989 --- /dev/null +++ b/proxy/server.py @@ -0,0 +1,199 @@ +""" +Tavily API Proxy — FastAPI 主服务 +""" +import os +import time +import httpx +from fastapi import FastAPI, Request, HTTPException, Depends +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.templating import Jinja2Templates + +import database as db +from key_pool import pool + +ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "admin") +TAVILY_API_BASE = "https://api.tavily.com" + +app = FastAPI(title="Tavily API Proxy") +templates = Jinja2Templates(directory=os.path.join(os.path.dirname(__file__), "templates")) +http_client = httpx.AsyncClient(timeout=60) + + +def get_admin_password(): + return db.get_setting("admin_password", ADMIN_PASSWORD) + + +# ═══ Auth helpers ═══ + +def verify_admin(request: Request): + auth = request.headers.get("Authorization", "") + password = request.headers.get("X-Admin-Password", "") + pwd = get_admin_password() + if auth == f"Bearer {pwd}" or password == pwd: + return True + raise HTTPException(status_code=401, detail="Unauthorized") + + +def extract_token(request: Request, body: dict = None): + """从请求中提取用户 token""" + # 1. Authorization header + auth = request.headers.get("Authorization", "") + if auth.startswith("Bearer "): + return auth[7:] + # 2. body 中的 api_key 字段 + if body and body.get("api_key"): + return body["api_key"] + return None + + +# ═══ 启动 ═══ + +@app.on_event("startup") +def startup(): + db.init_db() + + +# ═══ 代理端点 ═══ + +@app.post("/api/search") +@app.post("/api/extract") +async def proxy_tavily(request: Request): + body = await request.json() + endpoint = request.url.path.replace("/api/", "") # search or extract + + # 验证 token + token_value = extract_token(request, body) + if not token_value: + raise HTTPException(status_code=401, detail="Missing API token") + + token_row = db.get_token_by_value(token_value) + if not token_row: + raise HTTPException(status_code=401, detail="Invalid token") + + # 检查配额 + ok, reason = db.check_quota( + token_row["id"], + token_row["hourly_limit"], + token_row["daily_limit"], + token_row["monthly_limit"], + ) + if not ok: + raise HTTPException(status_code=429, detail=reason) + + # 从 key pool 取 key + key_info = pool.get_next_key() + if not key_info: + raise HTTPException(status_code=503, detail="No available API keys") + + # 替换 api_key 并转发 + body["api_key"] = key_info["key"] + start = time.time() + try: + resp = await http_client.post(f"{TAVILY_API_BASE}/{endpoint}", json=body) + latency = int((time.time() - start) * 1000) + success = resp.status_code == 200 + + # 记录结果 + pool.report_result(key_info["id"], success) + db.log_usage(token_row["id"], key_info["id"], endpoint, int(success), latency) + + return JSONResponse(content=resp.json(), status_code=resp.status_code) + except Exception as e: + latency = int((time.time() - start) * 1000) + pool.report_result(key_info["id"], False) + db.log_usage(token_row["id"], key_info["id"], endpoint, 0, latency) + raise HTTPException(status_code=502, detail=str(e)) + + +# ═══ 控制台 ═══ + +@app.get("/", response_class=HTMLResponse) +async def console(request: Request): + return templates.TemplateResponse("console.html", {"request": request}) + + +# ═══ 管理 API ═══ + +@app.get("/api/stats") +async def stats(request: Request, _=Depends(verify_admin)): + all_stats = db.get_usage_stats() + tokens = [dict(t) for t in db.get_all_tokens()] + for t in tokens: + t["stats"] = db.get_usage_stats(t["id"]) + keys = db.get_all_keys() + return { + "overview": all_stats, + "tokens": tokens, + "keys_total": len(keys), + "keys_active": sum(1 for k in keys if k["active"]), + } + + +@app.get("/api/keys") +async def list_keys(request: Request, _=Depends(verify_admin)): + keys = [dict(k) for k in db.get_all_keys()] + for k in keys: + # 脱敏显示 + raw = k["key"] + k["key_masked"] = raw[:8] + "***" + raw[-4:] if len(raw) > 12 else raw + return {"keys": keys} + + +@app.post("/api/keys") +async def add_keys(request: Request, _=Depends(verify_admin)): + body = await request.json() + if "file" in body: + count = db.import_keys_from_text(body["file"]) + pool.reload() + return {"imported": count} + elif "key" in body: + db.add_key(body["key"], body.get("email", "")) + pool.reload() + return {"ok": True} + raise HTTPException(status_code=400, detail="Provide 'key' or 'file'") + + +@app.delete("/api/keys/{key_id}") +async def remove_key(key_id: int, _=Depends(verify_admin)): + db.delete_key(key_id) + pool.reload() + return {"ok": True} + + +@app.put("/api/keys/{key_id}/toggle") +async def toggle_key(key_id: int, request: Request, _=Depends(verify_admin)): + body = await request.json() + db.toggle_key(key_id, body.get("active", 1)) + pool.reload() + return {"ok": True} + + +@app.get("/api/tokens") +async def list_tokens(request: Request, _=Depends(verify_admin)): + tokens = [dict(t) for t in db.get_all_tokens()] + for t in tokens: + t["stats"] = db.get_usage_stats(t["id"]) + return {"tokens": tokens} + + +@app.post("/api/tokens") +async def create_token(request: Request, _=Depends(verify_admin)): + body = await request.json() + token = db.create_token(body.get("name", "")) + return {"token": dict(token)} + + +@app.delete("/api/tokens/{token_id}") +async def remove_token(token_id: int, _=Depends(verify_admin)): + db.delete_token(token_id) + return {"ok": True} + + +@app.put("/api/password") +async def change_password(request: Request, _=Depends(verify_admin)): + body = await request.json() + new_pwd = body.get("password", "").strip() + if not new_pwd or len(new_pwd) < 4: + raise HTTPException(status_code=400, detail="Password too short (min 4)") + db.set_setting("admin_password", new_pwd) + return {"ok": True} diff --git a/proxy/templates/console.html b/proxy/templates/console.html new file mode 100644 index 0000000..3f8bca3 --- /dev/null +++ b/proxy/templates/console.html @@ -0,0 +1,383 @@ + + + + + +Tavily API Proxy + + + + + +
+

Tavily API Proxy

+

输入管理密码进入控制台

+ + + +
+ + + + + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..498d6ce --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +camoufox==0.4.11 +curl_cffi==0.14.0 +patchright==1.58.2 +quart==0.20.0 +requests==2.32.5 +rich==14.3.3 +playwright==1.49.1 diff --git a/run.py b/run.py new file mode 100755 index 0000000..579bd53 --- /dev/null +++ b/run.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +import os +import sys +import subprocess + +# ────────────────────────────────────────────── +# 启动前自动检查并安装依赖 +# ────────────────────────────────────────────── + +def _ensure_venv(): + """确保虚拟环境存在并激活""" + _HERE = os.path.dirname(os.path.abspath(__file__)) + venv_dir = os.path.join(_HERE, "venv") + + # 如果已经在虚拟环境中,直接返回 + if hasattr(sys, 'real_prefix') or (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix): + return + + # 创建虚拟环境 + if not os.path.exists(venv_dir): + print("创建虚拟环境...") + subprocess.check_call([sys.executable, "-m", "venv", venv_dir]) + print("✅ 虚拟环境创建完成\n") + + # 重新启动脚本在虚拟环境中 + if sys.platform == "win32": + python_exe = os.path.join(venv_dir, "Scripts", "python.exe") + else: + python_exe = os.path.join(venv_dir, "bin", "python") + + os.execv(python_exe, [python_exe] + sys.argv) + +def _ensure_deps(): + _HERE = os.path.dirname(os.path.abspath(__file__)) + req_file = os.path.join(_HERE, "requirements.txt") + missing = [] + pkg_map = { + "camoufox": "camoufox", + "curl_cffi": "curl_cffi", + "patchright": "patchright", + "quart": "quart", + "requests": "requests", + "rich": "rich", + "playwright": "playwright", + } + for mod, pkg in pkg_map.items(): + try: + __import__(mod) + except ImportError: + missing.append(pkg) + + if missing: + print(f"正在安装依赖: {', '.join(missing)}...") + subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", req_file, "-q"]) + print("✅ 依赖安装完成\n") + + # 检查 camoufox 浏览器 + try: + import camoufox + data_dir = os.path.join(os.path.dirname(camoufox.__file__), "data") + if not os.path.exists(data_dir) or not os.listdir(data_dir): + print("正在下载 Camoufox 浏览器...") + subprocess.check_call([sys.executable, "-m", "camoufox", "fetch"]) + print("✅ 浏览器下载完成\n") + except Exception: + pass + + # 安装 Playwright 浏览器 + try: + import playwright + pw_browsers = os.path.join(os.path.dirname(playwright.__file__), "driver", "package", ".local-browsers") + if not os.path.exists(pw_browsers): + print("正在安装 Playwright 浏览器...") + subprocess.check_call([sys.executable, "-m", "playwright", "install", "chromium"]) + print("✅ Playwright 浏览器安装完成\n") + except Exception: + pass + +_ensure_venv() +_ensure_deps() + +import time +import signal +import requests as std_requests +from config import SERVER_URL, SERVER_ADMIN_PASSWORD, DEFAULT_COUNT, DEFAULT_DELAY +from tavily_core import create_email, register + +# ────────────────────────────────────────────── +# Solver 管理 +# ────────────────────────────────────────────── + +solver_proc = None + +def start_solver(): + global solver_proc + + # 清理旧进程 + try: + import psutil + for proc in psutil.process_iter(['pid', 'name', 'cmdline']): + try: + cmdline = proc.info.get('cmdline') or [] + if any('api_solver.py' in str(c) for c in cmdline): + print(f"清理旧 Solver 进程 (PID: {proc.pid})") + proc.kill() + time.sleep(1) + except: + pass + except ImportError: + # 没有 psutil,用 lsof 检查端口 + import subprocess + try: + result = subprocess.run(['lsof', '-ti', ':5072'], capture_output=True, text=True) + if result.stdout.strip(): + pid = result.stdout.strip() + subprocess.run(['kill', '-9', pid]) + time.sleep(1) + except: + pass + + # 启动 Solver + print("启动 Turnstile Solver...") + + # 获取 Python 路径 + if os.path.exists('venv'): + if sys.platform == 'win32': + python_path = os.path.join('venv', 'Scripts', 'python.exe') + else: + python_path = os.path.join('venv', 'bin', 'python3') + else: + python_path = sys.executable + + solver_proc = subprocess.Popen( + [python_path, 'api_solver.py', '--browser_type', 'chromium', '--thread', '1'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + + # 等待启动 + for i in range(30): + try: + r = std_requests.get("http://127.0.0.1:5072/", timeout=1) + if r.status_code == 200: + print("✅ Solver 已启动\n") + return True + except: + pass + time.sleep(1) + if i % 5 == 0: + print(f"等待 Solver 启动... ({i}s)") + + print("❌ Solver 启动超时") + return False + +def stop_solver(): + global solver_proc + if solver_proc: + solver_proc.terminate() + solver_proc.wait() + solver_proc = None + +def signal_handler(sig, frame): + print("\n\n正在退出...") + stop_solver() + sys.exit(0) + +signal.signal(signal.SIGINT, signal_handler) + +# ────────────────────────────────────────────── +# 上传到代理服务器 +# ────────────────────────────────────────────── + +def upload_key(email, api_key): + try: + r = std_requests.post( + f"{SERVER_URL}/api/keys", + json={"key": api_key, "email": email}, + headers={"Authorization": f"Bearer {SERVER_ADMIN_PASSWORD}"}, + timeout=15, + ) + if r.status_code in (200, 201): + print("✅ 已上传服务器") + return True + print(f"⚠️ 上传失败 {r.status_code}: {r.text[:100]}") + return False + except Exception as e: + print(f"⚠️ 上传失败: {e}") + return False + +# ────────────────────────────────────────────── +# 注册流程 +# ────────────────────────────────────────────── + +def do_register(count, delay, upload, provider="cloudflare"): + success = 0 + failed = 0 + + for i in range(count): + if i > 0: + print(f"\n⏳ 等待 {delay} 秒...\n") + time.sleep(delay) + + print(f"{'='*60}") + print(f"📧 注册 ({i+1}/{count})") + print(f"{'='*60}\n") + + try: + email, password, actual_provider = create_email(provider) + result = register(email, password, actual_provider) + + if result and result != "SUCCESS_NO_KEY": + success += 1 + if upload: + upload_key(email, result) + elif result == "SUCCESS_NO_KEY": + success += 1 + else: + failed += 1 + + except Exception as e: + print(f"❌ 注册异常: {e}") + failed += 1 + + print(f"\n{'='*60}") + print(f"✅ 成功: {success} ❌ 失败: {failed}") + print(f"{'='*60}\n") + +# ────────────────────────────────────────────── +# 交互菜单 +# ────────────────────────────────────────────── + +def menu_register(): + print(f"\n 邮箱服务商:") + print(f" 1. Cloudflare Email Workers (nashome.me)") + print(f" 2. DuckMail") + print(f" 选择 [1]: ", end="") + provider_input = input().strip() + provider = "duckmail" if provider_input == "2" else "cloudflare" + + print(f"\n 注册数量 (默认 {DEFAULT_COUNT}): ", end="") + count_input = input().strip() + count = int(count_input) if count_input.isdigit() else DEFAULT_COUNT + + print(f" 间隔秒数 (默认 {DEFAULT_DELAY}): ", end="") + delay_input = input().strip() + delay = int(delay_input) if delay_input.isdigit() else DEFAULT_DELAY + + print(f" 上传服务器? [Y/n]: ", end="") + upload_input = input().strip().lower() + upload = upload_input not in ("n", "no") + + provider_name = "DuckMail" if provider == "duckmail" else "Cloudflare" + print(f"\n 邮箱: {provider_name} 数量: {count} 间隔: {delay}s 上传: {'是' if upload else '否'}") + + do_register(count, delay, upload, provider) + +def main(): + # 启动 Solver + if not start_solver(): + print("无法启动 Solver,退出") + return + + try: + while True: + print(""" +┌──────────────────────────────────────────┐ +│ Tavily 全自动注册工具 │ +├──────────────────────────────────────────┤ +│ 注册无需邮件验证,邮箱地址随机生成 │ +│ 账号/密码/API Key 保存至 accounts.txt │ +├──────────────────────────────────────────┤ +│ 1. 开始注册 │ +│ 0. 退出 │ +└──────────────────────────────────────────┘ +选择: """, end="") + + choice = input().strip() + + if choice == "1": + menu_register() + elif choice == "0": + break + else: + print("无效选择") + + finally: + stop_solver() + +if __name__ == "__main__": + main() diff --git a/tavily_core.py b/tavily_core.py new file mode 100644 index 0000000..fa3fd14 --- /dev/null +++ b/tavily_core.py @@ -0,0 +1,522 @@ +""" +Tavily 注册核心模块 - 简化版 +""" +import os +import re +import time +import random +import string +import requests as std_requests +from curl_cffi import requests +from camoufox.sync_api import Camoufox + +from config import EMAIL_API_URL, EMAIL_API_TOKEN, EMAIL_DOMAIN, DUCKMAIL_API_URL, DUCKMAIL_API_KEY, FIXED_PASSWORD + +# ────────────────────────────────────────────── +# 配置 +# ────────────────────────────────────────────── + +LOCAL_SOLVER_URL = "http://127.0.0.1:5072" +TURNSTILE_SITEKEY = "0x4AAAAAAAQFNSW6xordsuIq" +_HERE = os.path.dirname(os.path.abspath(__file__)) +_SAVE_FILE = os.path.join(_HERE, "accounts.txt") + +def rand_str(n=8): + return ''.join(random.choices(string.ascii_lowercase + string.digits, k=n)) + +def _save_account(email, password, api_key=None): + with open(_SAVE_FILE, 'a', encoding='utf-8') as f: + f.write(f"{email},{password},{api_key or ''}\n") + +# ────────────────────────────────────────────── +# 邮箱相关 +# ────────────────────────────────────────────── + +def create_email(provider="cloudflare"): + """生成随机邮箱地址 + + Args: + provider: "cloudflare" 或 "duckmail" + """ + username = f"tavily-{rand_str()}" + password = FIXED_PASSWORD # 使用固定密码 + + if provider == "duckmail": + # 获取 DuckMail 可用域名 + try: + r = std_requests.get( + f"{DUCKMAIL_API_URL}/domains", + headers={"Authorization": f"Bearer {DUCKMAIL_API_KEY}"}, + timeout=10 + ) + if r.status_code == 200: + domains = r.json() + if domains and len(domains) > 0: + domain = domains[0].get('domain', 'duckmail.sbs') + else: + domain = 'duckmail.sbs' + else: + domain = 'duckmail.sbs' + except: + domain = 'duckmail.sbs' + + email = f"{username}@{domain}" + + # 创建 DuckMail 账户 + try: + r = std_requests.post( + f"{DUCKMAIL_API_URL}/accounts", + json={"address": email, "password": password}, + headers={"Authorization": f"Bearer {DUCKMAIL_API_KEY}"}, + timeout=10 + ) + if r.status_code == 201: + print(f"✅ 邮箱: {email}") + return email, password, provider + else: + print(f"⚠️ DuckMail 创建失败: {r.status_code}, 使用 Cloudflare") + return create_email("cloudflare") + except Exception as e: + print(f"⚠️ DuckMail 错误: {e}, 使用 Cloudflare") + return create_email("cloudflare") + + # Cloudflare Email Workers(默认) + email = f"{username}@{EMAIL_DOMAIN}" + print(f"✅ 邮箱: {email}") + return email, password, "cloudflare" + +def get_verification_link(email, password, provider, timeout=120): + """等待验证邮件并提取验证链接 + + Args: + email: 邮箱地址 + password: 邮箱密码 + provider: "cloudflare" 或 "duckmail" + timeout: 超时时间(秒) + """ + print(f"⏳ 等待验证邮件(最多 {timeout} 秒)...", end="", flush=True) + + start_time = time.time() + token = None + + # DuckMail 需要先获取 token + if provider == "duckmail": + try: + r = std_requests.post( + f"{DUCKMAIL_API_URL}/token", + json={"address": email, "password": password}, + timeout=10 + ) + if r.status_code == 200: + token = r.json().get('token') + else: + print(f"\n⚠️ DuckMail 认证失败: {r.status_code}") + return None + except Exception as e: + print(f"\n⚠️ DuckMail 认证错误: {e}") + return None + + while time.time() - start_time < timeout: + try: + if provider == "duckmail": + # DuckMail API + r = std_requests.get( + f"{DUCKMAIL_API_URL}/messages", + headers={"Authorization": f"Bearer {token}"}, + timeout=10 + ) + + if r.status_code == 200: + messages = r.json() + + for msg in messages: + subject = msg.get("subject", "").lower() + if "verify" in subject or "tavily" in subject: + # 获取完整邮件内容 + msg_id = msg.get("id") + r_detail = std_requests.get( + f"{DUCKMAIL_API_URL}/messages/{msg_id}", + headers={"Authorization": f"Bearer {token}"}, + timeout=10 + ) + + if r_detail.status_code == 200: + detail = r_detail.json() + html = detail.get("html", "") + text = detail.get("text", "") + content = html + " " + text + + match = re.search(r'https://[^\s<>"]*verif[^\s<>"]*', content, re.IGNORECASE) + if match: + verify_url = match.group(0) + print(f"\n✅ 找到验证链接") + return verify_url + else: + # Cloudflare Email Workers API + r = std_requests.get( + f"{EMAIL_API_URL}/messages", + params={"address": email}, + headers={"Authorization": f"Bearer {EMAIL_API_TOKEN}"}, + timeout=10 + ) + + if r.status_code == 200: + messages = r.json().get("messages", []) + + for msg in messages: + subject = msg.get("subject", "").lower() + if "verify" in subject or "tavily" in subject.lower(): + # 提取验证链接 + html = msg.get("html", "") + text = msg.get("text", "") + content = html + " " + text + + match = re.search(r'https://[^\s<>"]*verif[^\s<>"]*', content, re.IGNORECASE) + if match: + verify_url = match.group(0) + print(f"\n✅ 找到验证链接") + return verify_url + + time.sleep(5) + print(".", end="", flush=True) + + except Exception as e: + print(f"\n⚠️ 检查邮件出错: {e}") + time.sleep(5) + + print(f"\n❌ 验证邮件超时") + return None + +# ────────────────────────────────────────────── +# 浏览器验证 +# ────────────────────────────────────────────── + +def verify_with_browser(verify_url, session): + """使用 Camoufox 处理验证链接并获取 API Key""" + print("🌐 启动浏览器验证...") + + try: + with Camoufox(headless=True) as browser: + page = browser.new_page() + + # 转移 cookies + cookies = [] + for name, value in session.cookies.items(): + cookies.append({ + "name": name, + "value": value, + "domain": ".tavily.com", + "path": "/" + }) + + # 同时添加 auth.tavily.com 的 cookies + for name, value in session.cookies.items(): + cookies.append({ + "name": name, + "value": value, + "domain": ".auth.tavily.com", + "path": "/" + }) + + page.context.add_cookies(cookies) + + # 访问验证链接 + page.goto(verify_url, wait_until="networkidle", timeout=60000) + + # 等待跳转到主页 + page.wait_for_url("**/app.tavily.com/**", timeout=60000) + + # 等待页面完全加载 + time.sleep(5) + + # 访问主页获取 API Key + print("🔑 获取 API Key...") + page.goto("https://app.tavily.com/home", wait_until="networkidle") + time.sleep(3) + + # 从页面 HTML 中提取 API Key + html = page.content() + # 匹配完整的 API Key 格式 + api_key_matches = re.findall(r'tvly-[a-zA-Z0-9_-]{50,}', html) + + if api_key_matches: + # 取最长的那个(排除占位符) + api_keys = [k for k in api_key_matches if k != "tvly-YOUR_API_KEY"] + if api_keys: + api_key = max(api_keys, key=len) + browser.close() + print("✅ 浏览器验证完成") + return api_key + + browser.close() + print("⚠️ 未找到 API Key") + return None + + except Exception as e: + print(f"❌ 浏览器验证失败: {e}") + return None + +# ────────────────────────────────────────────── +# Turnstile Solver +# ────────────────────────────────────────────── + +def solve_turnstile(url): + """调用本地 Solver 解析 Turnstile""" + try: + r = std_requests.get( + f"{LOCAL_SOLVER_URL}/turnstile", + params={"url": url, "sitekey": TURNSTILE_SITEKEY}, + timeout=10 + ) + + if r.status_code != 200: + print(f"❌ Solver 请求失败: {r.status_code}") + return None + + task_id = r.json().get("taskId") + if not task_id: + print("❌ 未获取到 Task ID") + return None + + # 轮询结果 + for _ in range(60): + time.sleep(2) + res = std_requests.get( + f"{LOCAL_SOLVER_URL}/result", + params={"id": task_id}, + timeout=10 + ) + + if res.status_code == 200: + data = res.json() + if data.get("status") == "ready": + token = data.get("solution", {}).get("token") + if token: + return token + elif data.get("status") == "CAPTCHA_FAIL": + print("❌ Turnstile 解析失败") + return None + + print("❌ Turnstile 超时") + return None + + except Exception as e: + print(f"❌ Solver 异常: {e}") + return None + +# ────────────────────────────────────────────── +# Tavily 注册 +# ────────────────────────────────────────────── + +def register(email, password, provider="cloudflare"): + """ + 注册 Tavily 账号 + 返回: API Key 或 None + """ + s = requests.Session(impersonate="chrome") + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + } + + # 1. 访问登录页 + r1 = s.get("https://app.tavily.com/sign-in", headers=headers) + if r1.status_code != 200: + print("❌ 无法访问登录页") + return None + + # 提取 state + state_m = re.search(r'state=([^"&]+)', r1.text) + if not state_m: + print("❌ 未找到 state") + return None + state = state_m.group(1) + + # 2. 第一次 Turnstile + print("🔐 Turnstile (1/2)...") + token1 = solve_turnstile(f"https://auth.tavily.com/u/login/identifier?state={state}") + if not token1: + return None + print("✅ Turnstile OK") + + # 3. 提交邮箱 + r2 = s.post( + f"https://auth.tavily.com/u/login/identifier?state={state}", + data={ + "state": state, + "username": email, + "action": "default", + "captcha": token1 + }, + headers=headers, + allow_redirects=False + ) + + if r2.status_code != 302: + print("❌ 邮箱提交失败") + return None + + # 检查是否跳转到注册页 + loc = r2.headers.get('Location', '') + if '/login/password' in loc: + # 已有账号,切换到注册 + r2_text = s.get(f"https://auth.tavily.com{loc}", headers=headers).text + signup_m = re.search(r'href="(/u/signup/identifier[^"]*)"', r2_text) + if not signup_m: + print("❌ 无法切换到注册页") + return None + + signup_url = f"https://auth.tavily.com{signup_m.group(1)}" + r2 = s.get(signup_url, headers=headers) + + # 重新提取 state + state_m2 = re.search(r'name="state"\s+value="([^"]+)"', r2.text) + if state_m2: + state = state_m2.group(1) + + # 第二次 Turnstile + print("🔐 Turnstile (2/2)...") + token2 = solve_turnstile(signup_url) + if not token2: + return None + print("✅ Turnstile OK") + + # 提交注册 + r3 = s.post( + signup_url, + data={ + "state": state, + "email": email, + "action": "default", + "captcha": token2 + }, + headers=headers, + allow_redirects=True + ) + else: + r3 = s.get(f"https://auth.tavily.com{loc}", headers=headers, allow_redirects=True) + + # 4. 到达密码页 + if '/password' not in r3.url: + print(f"❌ 未到达密码页面: {r3.url}") + return None + + # 第三次 Turnstile + print("🔐 Turnstile (3/3)...") + token3 = solve_turnstile(r3.url) + if not token3: + return None + print("✅ Turnstile OK") + + # 提取 state 和 email + state_m3 = re.search(r'name="state"\s+value="([^"]+)"', r3.text) + if state_m3: + state = state_m3.group(1) + email_m = re.search(r'name="email"\s+value="([^"]*)"', r3.text) + email_val = email_m.group(1) if email_m else email + + # 5. 提交密码 + r4 = s.post( + r3.url, + data={ + "state": state, + "email": email_val, + "password": password, + "action": "default", + "captcha": token3 + }, + headers=headers, + allow_redirects=False + ) + + if r4.status_code != 302: + print(f"❌ 密码提交失败 (状态码: {r4.status_code})") + return None + + # 6. 跟随回调 + callback = r4.headers.get('Location', '') + if callback.startswith('/'): + callback = f"https://auth.tavily.com{callback}" + + r5 = s.get(callback, allow_redirects=True) + + if 'app.tavily.com' not in r5.url: + print("❌ 登录失败") + return None + + # 7. 检查账号状态 + print("检查账号状态...") + r_account = s.get("https://app.tavily.com/api/account") + + if r_account.status_code == 403: + account_data = r_account.json() + if account_data.get('code') == 'email_not_verified': + print("📧 需要邮件验证") + verify_url = get_verification_link(email, password, provider, timeout=60) + if not verify_url: + print("❌ 未收到验证邮件") + _save_account(email, password, None) + return None + + # 使用浏览器处理验证链接并直接获取 API Key + api_key = verify_with_browser(verify_url, s) + if not api_key: + print("❌ 验证失败或未获取到 API Key") + _save_account(email, password, None) + return None + + # 保存账号 + _save_account(email, password, api_key) + print(f"🎉 注册成功") + print(f" 邮箱: {email}") + print(f" 密码: {password}") + print(f" Key : {api_key}") + return api_key + + # 8. 获取 API Key + api_key = get_api_key(s, email, password) + + if api_key: + _save_account(email, password, api_key) + print(f"🎉 注册成功") + print(f" 邮箱: {email}") + print(f" 密码: {password}") + print(f" Key : {api_key}") + return api_key + else: + _save_account(email, password, None) + print("⚠️ 注册成功,未获取到 API Key") + print(f" 邮箱: {email}") + print(f" 密码: {password}") + return "SUCCESS_NO_KEY" + +def get_api_key(session, email, password): + """获取 API Key(不创建新的)""" + time.sleep(3) + session.get("https://app.tavily.com/home") + time.sleep(2) + + r_orgs = session.get("https://app.tavily.com/api/organizations") + if r_orgs.status_code != 200: + return None + + orgs = r_orgs.json() + + if not orgs: + print("⚠️ 没有找到组织") + return None + + org_id = orgs[0].get('id') or orgs[0].get('oid') + if not org_id: + return None + + # 直接获取已有的 API Keys(不创建新的) + r_keys = session.get(f"https://app.tavily.com/api/keys?oid={org_id}") + if r_keys.status_code == 200: + keys = r_keys.json() + if keys and len(keys) > 0: + return keys[0].get('key') + + print("⚠️ 未找到 API Key") + return None diff --git a/tavily_core.py.bak b/tavily_core.py.bak new file mode 100644 index 0000000..0eb7c6e --- /dev/null +++ b/tavily_core.py.bak @@ -0,0 +1,366 @@ +""" +Tavily 注册核心模块 - 简化版 +""" +import os +import re +import time +import random +import string +import requests as std_requests +from curl_cffi import requests + +from config import EMAIL_API_URL, EMAIL_API_TOKEN, EMAIL_DOMAIN + +# ────────────────────────────────────────────── +# 配置 +# ────────────────────────────────────────────── + +LOCAL_SOLVER_URL = "http://127.0.0.1:5072" +TURNSTILE_SITEKEY = "0x4AAAAAAAQFNSW6xordsuIq" +_HERE = os.path.dirname(os.path.abspath(__file__)) +_SAVE_FILE = os.path.join(_HERE, "accounts.txt") + +def rand_str(n=8): + return ''.join(random.choices(string.ascii_lowercase + string.digits, k=n)) + +def _save_account(email, password, api_key=None): + with open(_SAVE_FILE, 'a', encoding='utf-8') as f: + f.write(f"{email},{password},{api_key or ''}\n") + +# ────────────────────────────────────────────── +# 邮箱相关 +# ────────────────────────────────────────────── + +def create_email(): + """生成随机邮箱地址(无需预创建,自动接收)""" + username = f"tavily-{rand_str()}" + email = f"{username}@{EMAIL_DOMAIN}" + password = f"Tv{rand_str(4)}{random.randint(10,99)}!" + print(f"✅ 邮箱: {email}") + return email, password + +def get_verification_link(email, timeout=120): + """等待验证邮件并提取验证链接""" + print(f"⏳ 等待验证邮件(最多 {timeout} 秒)...", end="", flush=True) + + start_time = time.time() + while time.time() - start_time < timeout: + try: + r = std_requests.get( + f"{EMAIL_API_URL}/messages", + params={"address": email}, + headers={"Authorization": f"Bearer {EMAIL_API_TOKEN}"}, + timeout=10 + ) + + if r.status_code == 200: + messages = r.json().get("messages", []) + + for msg in messages: + subject = msg.get("subject", "").lower() + if "verify" in subject or "tavily" in subject.lower(): + # 提取验证链接 + html = msg.get("html", "") + text = msg.get("text", "") + content = html + " " + text + + match = re.search(r'https://[^\s<>"]+verify[^\s<>"]*', content, re.IGNORECASE) + if match: + verify_url = match.group(0) + print(f"\n✅ 找到验证链接") + return verify_url + + time.sleep(5) + print(".", end="", flush=True) + + except Exception as e: + print(f"\n⚠️ 检查邮件出错: {e}") + time.sleep(5) + + print(f"\n❌ 验证邮件超时") + return None + +# ────────────────────────────────────────────── +# Turnstile Solver +# ────────────────────────────────────────────── + +def solve_turnstile(url): + """调用本地 Solver 解析 Turnstile""" + try: + r = std_requests.get( + f"{LOCAL_SOLVER_URL}/turnstile", + params={"url": url, "sitekey": TURNSTILE_SITEKEY}, + timeout=10 + ) + + if r.status_code != 200: + print(f"❌ Solver 请求失败: {r.status_code}") + return None + + task_id = r.json().get("taskId") + if not task_id: + print("❌ 未获取到 Task ID") + return None + + # 轮询结果 + for _ in range(60): + time.sleep(2) + res = std_requests.get( + f"{LOCAL_SOLVER_URL}/result", + params={"id": task_id}, + timeout=10 + ) + + if res.status_code == 200: + data = res.json() + if data.get("status") == "ready": + token = data.get("solution", {}).get("token") + if token: + return token + elif data.get("status") == "CAPTCHA_FAIL": + print("❌ Turnstile 解析失败") + return None + + print("❌ Turnstile 超时") + return None + + except Exception as e: + print(f"❌ Solver 异常: {e}") + return None + +# ────────────────────────────────────────────── +# Tavily 注册 +# ────────────────────────────────────────────── + +def register(email, password): + """ + 注册 Tavily 账号 + 返回: API Key 或 None + """ + s = requests.Session(impersonate="chrome") + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + } + + # 1. 访问登录页 + r1 = s.get("https://app.tavily.com/sign-in", headers=headers) + if r1.status_code != 200: + print("❌ 无法访问登录页") + return None + + # 提取 state + state_m = re.search(r'state=([^"&]+)', r1.text) + if not state_m: + print("❌ 未找到 state") + return None + state = state_m.group(1) + + # 2. 第一次 Turnstile + print("🔐 Turnstile (1/2)...") + token1 = solve_turnstile(f"https://auth.tavily.com/u/login/identifier?state={state}") + if not token1: + return None + print("✅ Turnstile OK") + + # 3. 提交邮箱 + r2 = s.post( + f"https://auth.tavily.com/u/login/identifier?state={state}", + data={ + "state": state, + "username": email, + "action": "default", + "captcha": token1 + }, + headers=headers, + allow_redirects=False + ) + + if r2.status_code != 302: + print("❌ 邮箱提交失败") + return None + + # 检查是否跳转到注册页 + loc = r2.headers.get('Location', '') + if '/login/password' in loc: + # 已有账号,切换到注册 + r2_text = s.get(f"https://auth.tavily.com{loc}", headers=headers).text + signup_m = re.search(r'href="(/u/signup/identifier[^"]*)"', r2_text) + if not signup_m: + print("❌ 无法切换到注册页") + return None + + signup_url = f"https://auth.tavily.com{signup_m.group(1)}" + r2 = s.get(signup_url, headers=headers) + + # 重新提取 state + state_m2 = re.search(r'name="state"\s+value="([^"]+)"', r2.text) + if state_m2: + state = state_m2.group(1) + + # 第二次 Turnstile + print("🔐 Turnstile (2/2)...") + token2 = solve_turnstile(signup_url) + if not token2: + return None + print("✅ Turnstile OK") + + # 提交注册 + r3 = s.post( + signup_url, + data={ + "state": state, + "email": email, + "action": "default", + "captcha": token2 + }, + headers=headers, + allow_redirects=True + ) + else: + r3 = s.get(f"https://auth.tavily.com{loc}", headers=headers, allow_redirects=True) + + # 4. 到达密码页 + if '/password' not in r3.url: + print(f"❌ 未到达密码页面: {r3.url}") + return None + + # 第三次 Turnstile + print("🔐 Turnstile (3/3)...") + token3 = solve_turnstile(r3.url) + if not token3: + return None + print("✅ Turnstile OK") + + # 提取 state 和 email + state_m3 = re.search(r'name="state"\s+value="([^"]+)"', r3.text) + if state_m3: + state = state_m3.group(1) + email_m = re.search(r'name="email"\s+value="([^"]*)"', r3.text) + email_val = email_m.group(1) if email_m else email + + # 5. 提交密码 + r4 = s.post( + r3.url, + data={ + "state": state, + "email": email_val, + "password": password, + "action": "default", + "captcha": token3 + }, + headers=headers, + allow_redirects=False + ) + + if r4.status_code != 302: + print(f"❌ 密码提交失败 (状态码: {r4.status_code})") + return None + + # 6. 跟随回调 + callback = r4.headers.get('Location', '') + if callback.startswith('/'): + callback = f"https://auth.tavily.com{callback}" + + r5 = s.get(callback, allow_redirects=True) + + # 7. 检查是否需要邮件验证 + if 'verify' in r5.url.lower() or 'verification' in r5.text.lower(): + print("📧 需要邮件验证") + verify_url = get_verification_link(email) + if not verify_url: + print("❌ 未收到验证邮件") + _save_account(email, password, None) + return None + + # 点击验证链接 + print("🔗 点击验证链接...") + s.get(verify_url, allow_redirects=True) + time.sleep(2) + + # 重新访问主页 + r5 = s.get("https://app.tavily.com/home", allow_redirects=True) + + if 'app.tavily.com' not in r5.url: + print("❌ 登录失败") + return None + + # 8. 获取 API Key + api_key = get_api_key(s, email, password) + + if api_key: + _save_account(email, password, api_key) + print(f"🎉 注册成功") + print(f" 邮箱: {email}") + print(f" 密码: {password}") + print(f" Key : {api_key}") + return api_key + else: + _save_account(email, password, None) + print("⚠️ 注册成功,未获取到 API Key") + print(f" 邮箱: {email}") + print(f" 密码: {password}") + return "SUCCESS_NO_KEY" + +def get_api_key(session, email, password): + """获取 API Key""" + time.sleep(3) + session.get("https://app.tavily.com/home") + time.sleep(2) + + r_orgs = session.get("https://app.tavily.com/api/organizations") + if r_orgs.status_code != 200: + return None + + orgs = r_orgs.json() + + # 如果没有组织,创建一个 + if not orgs: + r_create = session.post( + "https://app.tavily.com/api/organizations", + json={"name": "My Organization"}, + headers={"Content-Type": "application/json"} + ) + if r_create.status_code != 201: + return None + orgs = [r_create.json()] + + org_id = orgs[0].get('id') or orgs[0].get('oid') + if not org_id: + return None + + # 获取 API Keys + r_keys = session.get(f"https://app.tavily.com/api/keys?oid={org_id}") + if r_keys.status_code == 200: + keys = r_keys.json() + if keys and len(keys) > 0: + return keys[0].get('key') + + # 尝试创建 API Key + r_create_key = session.post( + f"https://app.tavily.com/api/keys?oid={org_id}", + json={}, + headers={"Content-Type": "application/json"} + ) + + if r_create_key.status_code in (200, 201): + key_data = r_create_key.json() + return key_data.get('key') + + # 如果还是失败,尝试邮件验证 + print("📧 尝试邮件验证...") + verify_url = get_verification_link(email) + if verify_url: + print("🔗 点击验证链接...") + session.get(verify_url, allow_redirects=True) + time.sleep(3) + + # 重新尝试获取 API Key + r_keys2 = session.get(f"https://app.tavily.com/api/keys?oid={org_id}") + if r_keys2.status_code == 200: + keys2 = r_keys2.json() + if keys2 and len(keys2) > 0: + return keys2[0].get('key') + + return None