backup current state
This commit is contained in:
commit
e98e237d47
19 files changed
+3593
No files matched your search
+22
@@ -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
|
||||
@@ -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
|
||||
+1123
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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!@" # 固定密码(方便登录)
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
ADMIN_PASSWORD=change-me
|
||||
@@ -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"]
|
||||
+106
@@ -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` 持久化。
|
||||
@@ -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, ""
|
||||
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
tavily-proxy:
|
||||
build: .
|
||||
ports:
|
||||
- "9874:9874"
|
||||
environment:
|
||||
- ADMIN_PASSWORD=change-me
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
@@ -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()
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.104.0
|
||||
uvicorn>=0.24.0
|
||||
httpx>=0.25.0
|
||||
jinja2>=3.1.0
|
||||
+199
@@ -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}
|
||||
@@ -0,0 +1,383 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tavily API Proxy</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0f2f5; color: #1a1a2e; min-height: 100vh; }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }
|
||||
.header-left h1 { font-size: 24px; margin-bottom: 4px; }
|
||||
.header-left .subtitle { color: #666; font-size: 14px; }
|
||||
.user-menu { position: relative; }
|
||||
.user-btn { display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: #fff; border: 1px solid #ddd; border-radius: 8px; cursor: pointer; font-size: 14px; }
|
||||
.user-btn:hover { background: #f5f5f5; }
|
||||
.user-btn svg { width: 20px; height: 20px; }
|
||||
.dropdown { display: none; position: absolute; right: 0; top: 44px; background: #fff; border: 1px solid #e0e0e0; border-radius: 10px; box-shadow: 0 4px 16px rgba(0,0,0,.12); width: 280px; z-index: 100; padding: 16px; }
|
||||
.dropdown.show { display: block; }
|
||||
.dropdown h3 { font-size: 14px; margin-bottom: 12px; color: #333; }
|
||||
.dropdown .pwd-row { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.dropdown .pwd-row input { flex: 1; padding: 6px 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; }
|
||||
.dropdown .pwd-row button { white-space: nowrap; }
|
||||
.dropdown-divider { height: 1px; background: #eee; margin: 12px 0; }
|
||||
.dropdown .logout-btn { display: block; width: 100%; text-align: center; padding: 8px; color: #e74c3c; border: 1px solid #e74c3c; border-radius: 6px; cursor: pointer; font-size: 13px; background: #fff; }
|
||||
.dropdown .logout-btn:hover { background: #fdf0ef; }
|
||||
.card { background: #fff; border-radius: 12px; padding: 24px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
||||
.card h2 { font-size: 18px; margin-bottom: 6px; }
|
||||
.card .desc { color: #888; font-size: 13px; margin-bottom: 16px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
|
||||
.stat-box { border: 1px solid #e8e8e8; border-radius: 8px; padding: 14px; }
|
||||
.stat-box .label { font-size: 13px; color: #888; margin-bottom: 4px; }
|
||||
.stat-box .value { font-size: 28px; font-weight: 700; }
|
||||
.stat-box .value span { font-size: 16px; color: #888; font-weight: 400; }
|
||||
.stat-box .remain { font-size: 13px; color: #1a8a1a; margin-top: 4px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th { text-align: left; padding: 10px 12px; color: #888; font-weight: 500; border-bottom: 1px solid #eee; }
|
||||
td { padding: 10px 12px; border-bottom: 1px solid #f5f5f5; }
|
||||
.btn { display: inline-block; padding: 6px 16px; border-radius: 6px; border: 1px solid #ddd; background: #fff; cursor: pointer; font-size: 13px; }
|
||||
.btn:hover { background: #f5f5f5; }
|
||||
.btn-primary { background: #1a1a6e; color: #fff; border-color: #1a1a6e; }
|
||||
.btn-primary:hover { background: #2a2a8e; }
|
||||
.btn-danger { color: #e74c3c; border-color: #e74c3c; }
|
||||
.btn-danger:hover { background: #fdf0ef; }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
input[type="text"], input[type="password"], textarea { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
|
||||
textarea { min-height: 80px; resize: vertical; }
|
||||
.flex { display: flex; gap: 8px; align-items: center; }
|
||||
.mt { margin-top: 12px; }
|
||||
.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; }
|
||||
.tag-ok { background: #e8f5e9; color: #2e7d32; }
|
||||
.tag-off { background: #fce4ec; color: #c62828; }
|
||||
.mono { font-family: 'SF Mono', Monaco, Consolas, monospace; font-size: 13px; }
|
||||
.quota-bar { height: 4px; background: #e8e8e8; border-radius: 2px; margin-top: 3px; }
|
||||
.quota-bar-fill { height: 100%; border-radius: 2px; background: #1a8a1a; transition: width .3s; }
|
||||
.quota-bar-fill.warn { background: #f39c12; }
|
||||
.quota-bar-fill.danger { background: #e74c3c; }
|
||||
#login-box { max-width: 360px; margin: 120px auto; }
|
||||
.hidden { display: none; }
|
||||
.code-block { background: #1a1a2e; color: #e0e0e0; border-radius: 8px; padding: 16px; font-family: 'SF Mono', Monaco, Consolas, monospace; font-size: 13px; line-height: 1.6; overflow-x: auto; position: relative; }
|
||||
.code-block .comment { color: #6a9955; }
|
||||
.code-block .string { color: #ce9178; }
|
||||
.code-block .key { color: #9cdcfe; }
|
||||
.copy-code-btn { position: absolute; top: 8px; right: 8px; padding: 4px 10px; font-size: 11px; background: rgba(255,255,255,.1); border: 1px solid rgba(255,255,255,.2); color: #ccc; border-radius: 4px; cursor: pointer; }
|
||||
.copy-code-btn:hover { background: rgba(255,255,255,.2); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Login -->
|
||||
<div id="login-box" class="card">
|
||||
<h2>Tavily API Proxy</h2>
|
||||
<p class="desc">输入管理密码进入控制台</p>
|
||||
<input type="password" id="pwd-input" placeholder="密码" onkeydown="if(event.key==='Enter')doLogin()">
|
||||
<button class="btn btn-primary mt" style="width:100%" onclick="doLogin()">登录</button>
|
||||
<p id="login-err" style="color:#e74c3c;font-size:13px;margin-top:8px" class="hidden"></p>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<div id="dashboard" class="hidden">
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<h1>控制台</h1>
|
||||
<p class="subtitle">Tavily API Proxy · Key 池管理</p>
|
||||
</div>
|
||||
<div class="user-menu">
|
||||
<div class="user-btn" onclick="toggleDropdown()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a6 6 0 0112 0v1"/></svg>
|
||||
Admin
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</div>
|
||||
<div class="dropdown" id="user-dropdown">
|
||||
<h3>修改密码</h3>
|
||||
<div class="pwd-row">
|
||||
<input type="password" id="new-pwd" placeholder="输入新密码">
|
||||
<button class="btn btn-primary btn-sm" onclick="changePwd()">确认</button>
|
||||
</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="logout-btn" onclick="logout()">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overview -->
|
||||
<div class="card">
|
||||
<h2>用量概览</h2>
|
||||
<p class="desc">账户级成功率、失败量与总余额</p>
|
||||
<div class="stats-grid" id="overview-stats"></div>
|
||||
</div>
|
||||
|
||||
<!-- API Usage -->
|
||||
<div class="card">
|
||||
<h2>调用方式</h2>
|
||||
<p class="desc">兼容 Tavily 官方 API,替换 base URL 和 api_key 即可</p>
|
||||
<div style="margin-bottom:12px">
|
||||
<div style="font-size:13px;color:#666;margin-bottom:6px">API 地址</div>
|
||||
<div class="mono" style="font-size:14px;padding:8px 12px;background:#f5f5f5;border-radius:6px;display:inline-block" id="api-base-url"></div>
|
||||
</div>
|
||||
<div style="font-size:13px;color:#666;margin-bottom:6px">调用示例 (curl)</div>
|
||||
<div class="code-block" id="curl-example">
|
||||
<button class="copy-code-btn" onclick="copyCurl()">复制</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tokens -->
|
||||
<div class="card">
|
||||
<h2>Token 列表</h2>
|
||||
<p class="desc">代理访问 Token,分配给应用使用</p>
|
||||
<div class="flex mt" style="margin-bottom:12px">
|
||||
<input type="text" id="token-name" placeholder="Token 备注名(可选)" style="max-width:240px">
|
||||
<button class="btn btn-primary" onclick="createToken()">创建 Token</button>
|
||||
</div>
|
||||
<div style="overflow-x:auto">
|
||||
<table id="tokens-table"><thead><tr><th>Token</th><th>备注</th><th>配额 / 余额</th><th>用量统计</th><th>操作</th></tr></thead><tbody></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keys -->
|
||||
<div class="card">
|
||||
<h2>API Key 池</h2>
|
||||
<p class="desc">管理 Tavily API Key,支持批量导入</p>
|
||||
<div class="flex mt" style="margin-bottom:12px;flex-wrap:wrap">
|
||||
<input type="text" id="single-key" placeholder="tvly-xxx" style="max-width:360px">
|
||||
<button class="btn btn-primary" onclick="addSingleKey()">添加</button>
|
||||
<button class="btn" onclick="document.getElementById('import-area').classList.toggle('hidden')">批量导入</button>
|
||||
</div>
|
||||
<div id="import-area" class="hidden" style="margin-bottom:12px">
|
||||
<textarea id="import-text" placeholder="粘贴 api_keys.md 内容,每行一条 格式: email,password,tvly-xxx,timestamp;"></textarea>
|
||||
<button class="btn btn-primary mt" onclick="importKeys()">导入</button>
|
||||
</div>
|
||||
<div style="overflow-x:auto">
|
||||
<table id="keys-table"><thead><tr><th>ID</th><th>Key</th><th>邮箱</th><th>成功</th><th>失败</th><th>状态</th><th>操作</th></tr></thead><tbody></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = '';
|
||||
let PWD = localStorage.getItem('tavily_proxy_pwd') || '';
|
||||
|
||||
function headers() {
|
||||
return { 'Content-Type': 'application/json', 'X-Admin-Password': PWD };
|
||||
}
|
||||
|
||||
async function api(method, path, body) {
|
||||
const opts = { method, headers: headers() };
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const r = await fetch(API + path, opts);
|
||||
if (r.status === 401) { logout(); throw new Error('Unauthorized'); }
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function doLogin() {
|
||||
PWD = document.getElementById('pwd-input').value;
|
||||
localStorage.setItem('tavily_proxy_pwd', PWD);
|
||||
api('GET', '/api/stats').then(() => {
|
||||
document.getElementById('login-box').classList.add('hidden');
|
||||
document.getElementById('dashboard').classList.remove('hidden');
|
||||
refresh();
|
||||
}).catch(() => {
|
||||
document.getElementById('login-err').textContent = '密码错误';
|
||||
document.getElementById('login-err').classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
function logout() {
|
||||
PWD = '';
|
||||
localStorage.removeItem('tavily_proxy_pwd');
|
||||
document.getElementById('login-box').classList.remove('hidden');
|
||||
document.getElementById('dashboard').classList.add('hidden');
|
||||
document.getElementById('user-dropdown').classList.remove('show');
|
||||
}
|
||||
|
||||
function toggleDropdown() {
|
||||
document.getElementById('user-dropdown').classList.toggle('show');
|
||||
}
|
||||
|
||||
// Close dropdown on outside click
|
||||
document.addEventListener('click', e => {
|
||||
const menu = document.querySelector('.user-menu');
|
||||
if (menu && !menu.contains(e.target)) {
|
||||
document.getElementById('user-dropdown').classList.remove('show');
|
||||
}
|
||||
});
|
||||
|
||||
function quotaBar(used, limit) {
|
||||
const pct = limit ? Math.min(100, (used / limit) * 100) : 0;
|
||||
const cls = pct >= 90 ? 'danger' : pct >= 70 ? 'warn' : '';
|
||||
return `<div class="quota-bar"><div class="quota-bar-fill ${cls}" style="width:${pct}%"></div></div>`;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const stats = await api('GET', '/api/stats');
|
||||
const o = stats.overview;
|
||||
|
||||
// 每个 Tavily 免费 key 月限额 1000 次
|
||||
const KEY_MONTHLY_QUOTA = 1000;
|
||||
const totalQuota = stats.keys_active * KEY_MONTHLY_QUOTA;
|
||||
const monthUsed = o.month_count;
|
||||
const monthRemain = Math.max(0, totalQuota - monthUsed);
|
||||
const usedPct = totalQuota ? Math.min(100, (monthUsed / totalQuota) * 100) : 0;
|
||||
const remainCls = usedPct >= 90 ? 'color:#e74c3c' : usedPct >= 70 ? 'color:#f39c12' : 'color:#1a8a1a';
|
||||
|
||||
document.getElementById('overview-stats').innerHTML = `
|
||||
<div class="stat-box"><div class="label">本月总额度</div><div class="value">${totalQuota.toLocaleString()}</div><div class="remain">Key ${stats.keys_active} × ${KEY_MONTHLY_QUOTA}</div></div>
|
||||
<div class="stat-box"><div class="label">已用次数</div><div class="value">${monthUsed}</div><div class="remain">成功 ${o.month_success} / 失败 ${monthUsed - o.month_success}</div></div>
|
||||
<div class="stat-box"><div class="label">剩余次数</div><div class="value" style="${remainCls}">${monthRemain.toLocaleString()}</div>${quotaBar(monthUsed, totalQuota)}</div>
|
||||
<div class="stat-box"><div class="label">今日用量</div><div class="value">${o.today_count}</div><div class="remain">成功 ${o.today_success} / 失败 ${o.today_failed}</div></div>
|
||||
<div class="stat-box"><div class="label">Key 池</div><div class="value">${stats.keys_active} <span>/ ${stats.keys_total}</span></div></div>
|
||||
<div class="stat-box"><div class="label">Token 数</div><div class="value">${stats.tokens.length}</div></div>
|
||||
`;
|
||||
|
||||
// API usage info
|
||||
const baseUrl = location.origin;
|
||||
document.getElementById('api-base-url').textContent = baseUrl;
|
||||
const firstToken = stats.tokens.length > 0 ? stats.tokens[0].token : 'YOUR_TOKEN';
|
||||
document.getElementById('curl-example').innerHTML = `<button class="copy-code-btn" onclick="copyCurl()">复制</button><span class="comment"># Search 搜索</span>
|
||||
curl -X POST <span class="string">${baseUrl}/api/search</span> \\
|
||||
-H <span class="string">"Content-Type: application/json"</span> \\
|
||||
-H <span class="string">"Authorization: Bearer <span class="key">${firstToken}</span>"</span> \\
|
||||
-d <span class="string">'{"query": "your search query"}'</span>
|
||||
|
||||
<span class="comment"># Extract 内容提取</span>
|
||||
curl -X POST <span class="string">${baseUrl}/api/extract</span> \\
|
||||
-H <span class="string">"Content-Type: application/json"</span> \\
|
||||
-H <span class="string">"Authorization: Bearer <span class="key">${firstToken}</span>"</span> \\
|
||||
-d <span class="string">'{"urls": ["https://example.com"]}'</span>
|
||||
|
||||
<span class="comment"># 或在 body 中传 api_key</span>
|
||||
curl -X POST <span class="string">${baseUrl}/api/search</span> \\
|
||||
-H <span class="string">"Content-Type: application/json"</span> \\
|
||||
-d <span class="string">'{"api_key": "<span class="key">${firstToken}</span>", "query": "your search query"}'</span>`;
|
||||
|
||||
// Tokens
|
||||
const tokensData = await api('GET', '/api/tokens');
|
||||
const tbody = document.querySelector('#tokens-table tbody');
|
||||
tbody.innerHTML = '';
|
||||
for (const t of tokensData.tokens) {
|
||||
const s = t.stats || {};
|
||||
const unlimited = !t.hourly_limit && !t.daily_limit && !t.monthly_limit;
|
||||
let quotaHtml;
|
||||
if (unlimited) {
|
||||
quotaHtml = '<span style="color:#1a8a1a;font-weight:600">无限制</span>';
|
||||
} else {
|
||||
const hr = t.hourly_limit - (s.hour_count||0);
|
||||
const dr = t.daily_limit - (s.today_count||0);
|
||||
const mr = t.monthly_limit - (s.month_count||0);
|
||||
quotaHtml = `
|
||||
<div>小时 ${s.hour_count||0}/${t.hourly_limit} <b style="color:${hr>0?'#1a8a1a':'#e74c3c'}">余 ${hr}</b>${quotaBar(s.hour_count||0, t.hourly_limit)}</div>
|
||||
<div>今日 ${s.today_count||0}/${t.daily_limit} <b style="color:${dr>0?'#1a8a1a':'#e74c3c'}">余 ${dr}</b>${quotaBar(s.today_count||0, t.daily_limit)}</div>
|
||||
<div>本月 ${s.month_count||0}/${t.monthly_limit} <b style="color:${mr>0?'#1a8a1a':'#e74c3c'}">余 ${mr}</b>${quotaBar(s.month_count||0, t.monthly_limit)}</div>`;
|
||||
}
|
||||
tbody.innerHTML += `<tr>
|
||||
<td class="mono">${t.token}</td>
|
||||
<td style="font-size:12px">${t.name||'-'}</td>
|
||||
<td style="font-size:12px;line-height:2">${quotaHtml}</td>
|
||||
<td style="font-size:12px;line-height:1.8">
|
||||
成功 ${s.today_success||0} / 失败 ${s.today_failed||0}<br>
|
||||
本月 ${s.month_success||0}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm" onclick="copyText('${t.token}')">复制</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="delToken(${t.id})">删除</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// Keys
|
||||
const keysData = await api('GET', '/api/keys');
|
||||
const ktbody = document.querySelector('#keys-table tbody');
|
||||
ktbody.innerHTML = '';
|
||||
for (const k of keysData.keys) {
|
||||
const active = k.active === 1;
|
||||
ktbody.innerHTML += `<tr>
|
||||
<td>${k.id}</td>
|
||||
<td class="mono">${k.key_masked}</td>
|
||||
<td style="font-size:12px">${k.email||'-'}</td>
|
||||
<td>${k.total_used}</td>
|
||||
<td>${k.total_failed}</td>
|
||||
<td><span class="tag ${active?'tag-ok':'tag-off'}">${active?'正常':'禁用'}</span></td>
|
||||
<td>
|
||||
<button class="btn btn-sm" onclick="toggleKey(${k.id},${active?0:1})">${active?'禁用':'启用'}</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="delKey(${k.id})">删除</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function createToken() {
|
||||
const name = document.getElementById('token-name').value;
|
||||
await api('POST', '/api/tokens', { name });
|
||||
document.getElementById('token-name').value = '';
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function delToken(id) { if(confirm('确认删除?')){ await api('DELETE', `/api/tokens/${id}`); refresh(); } }
|
||||
|
||||
async function addSingleKey() {
|
||||
const key = document.getElementById('single-key').value.trim();
|
||||
if (!key) return;
|
||||
await api('POST', '/api/keys', { key });
|
||||
document.getElementById('single-key').value = '';
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function importKeys() {
|
||||
const text = document.getElementById('import-text').value;
|
||||
if (!text.trim()) return;
|
||||
const r = await api('POST', '/api/keys', { file: text });
|
||||
alert(`已导入 ${r.imported} 个 Key`);
|
||||
document.getElementById('import-text').value = '';
|
||||
document.getElementById('import-area').classList.add('hidden');
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function delKey(id) { if(confirm('确认删除?')){ await api('DELETE', `/api/keys/${id}`); refresh(); } }
|
||||
async function toggleKey(id, active) { await api('PUT', `/api/keys/${id}/toggle`, { active }); refresh(); }
|
||||
|
||||
async function changePwd() {
|
||||
const newPwd = document.getElementById('new-pwd').value.trim();
|
||||
if (!newPwd || newPwd.length < 4) { alert('密码至少 4 位'); return; }
|
||||
try {
|
||||
await api('PUT', '/api/password', { password: newPwd });
|
||||
PWD = newPwd;
|
||||
localStorage.setItem('tavily_proxy_pwd', PWD);
|
||||
document.getElementById('new-pwd').value = '';
|
||||
document.getElementById('user-dropdown').classList.remove('show');
|
||||
alert('密码已修改');
|
||||
} catch(e) { alert('修改失败: ' + e.message); }
|
||||
}
|
||||
|
||||
function copyCurl() {
|
||||
const el = document.getElementById('curl-example');
|
||||
const text = el.innerText.replace('复制', '').trim();
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const btn = el.querySelector('.copy-code-btn');
|
||||
btn.textContent = '已复制';
|
||||
setTimeout(() => btn.textContent = '复制', 1500);
|
||||
});
|
||||
}
|
||||
|
||||
function copyText(t) {
|
||||
navigator.clipboard.writeText(t).then(() => {
|
||||
const el = event.target;
|
||||
el.textContent = '已复制';
|
||||
setTimeout(() => el.textContent = '复制', 1500);
|
||||
});
|
||||
}
|
||||
|
||||
// Auto login
|
||||
if (PWD) {
|
||||
api('GET', '/api/stats').then(() => {
|
||||
document.getElementById('login-box').classList.add('hidden');
|
||||
document.getElementById('dashboard').classList.remove('hidden');
|
||||
refresh();
|
||||
}).catch(() => {});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
@@ -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()
|
||||
+522
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user