Spaces:
Sleeping
Sleeping
Upload 5 files
Browse files- Dockerfile +27 -0
- app.py +787 -0
- browser_worker.py +881 -0
- config.py +617 -0
- email_manager.py +121 -0
Dockerfile
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim-bullseye
|
| 2 |
+
|
| 3 |
+
# 安装必要的系统依赖
|
| 4 |
+
# DrissionPage 需要浏览器才能工作
|
| 5 |
+
RUN apt-get update && apt-get install -y \
|
| 6 |
+
chromium \
|
| 7 |
+
chromium-driver \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
# 复制项目文件
|
| 13 |
+
COPY . .
|
| 14 |
+
|
| 15 |
+
# 安装 Python 依赖
|
| 16 |
+
RUN pip install --no-cache-dir \
|
| 17 |
+
flask \
|
| 18 |
+
python-dotenv \
|
| 19 |
+
DrissionPage \
|
| 20 |
+
requests
|
| 21 |
+
|
| 22 |
+
# 暴露端口
|
| 23 |
+
ENV PORT=7860
|
| 24 |
+
EXPOSE 7860
|
| 25 |
+
|
| 26 |
+
# 运行应用
|
| 27 |
+
CMD ["python", "app.py"]
|
app.py
ADDED
|
@@ -0,0 +1,787 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import json
|
| 4 |
+
import threading
|
| 5 |
+
import queue
|
| 6 |
+
import time
|
| 7 |
+
import uuid
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from typing import Dict, List, Optional
|
| 10 |
+
from functools import wraps
|
| 11 |
+
|
| 12 |
+
from flask import Flask, request, jsonify, render_template, Response
|
| 13 |
+
from dotenv import load_dotenv
|
| 14 |
+
|
| 15 |
+
from browser_worker import BrowserWorker, AccountInfo, AccountStatus
|
| 16 |
+
from email_manager import EmailManager
|
| 17 |
+
from config import Config, setup_logging
|
| 18 |
+
import logging
|
| 19 |
+
|
| 20 |
+
log = logging.getLogger('werkzeug')
|
| 21 |
+
log.setLevel(logging.ERROR)
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
# 加载环境变量
|
| 25 |
+
load_dotenv()
|
| 26 |
+
|
| 27 |
+
if getattr(sys, 'frozen', False):
|
| 28 |
+
template_folder = os.path.join(os.getcwd(), 'templates')
|
| 29 |
+
static_folder = os.path.join(os.getcwd(), 'static')
|
| 30 |
+
app = Flask(__name__, template_folder=template_folder, static_folder=static_folder)
|
| 31 |
+
else:
|
| 32 |
+
app = Flask(__name__)
|
| 33 |
+
app.secret_key = os.getenv('SECRET_KEY', 'your-secret-key-change-in-production')
|
| 34 |
+
|
| 35 |
+
# 全局配置和状态
|
| 36 |
+
setup_logging()
|
| 37 |
+
config = Config()
|
| 38 |
+
accounts: Dict[str, AccountInfo] = {} # email -> AccountInfo
|
| 39 |
+
workers: Dict[int, BrowserWorker] = {} # worker_id -> BrowserWorker
|
| 40 |
+
task_queue = queue.Queue()
|
| 41 |
+
accounts_lock = threading.Lock()
|
| 42 |
+
workers_lock = threading.Lock()
|
| 43 |
+
|
| 44 |
+
# 管理员认证
|
| 45 |
+
ADMIN_USERNAME = os.getenv('ADMIN_USERNAME', 'admin')
|
| 46 |
+
ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', 'admin123')
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def check_auth(username, password):
|
| 50 |
+
"""验证管理员凭据"""
|
| 51 |
+
return username == ADMIN_USERNAME and password == ADMIN_PASSWORD
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def authenticate():
|
| 55 |
+
"""返回401认证请求"""
|
| 56 |
+
return Response(
|
| 57 |
+
json.dumps({'error': '需要认证'}),
|
| 58 |
+
401,
|
| 59 |
+
{'WWW-Authenticate': 'Basic realm="Admin Area"'}
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def requires_auth(f):
|
| 64 |
+
"""认证装饰器"""
|
| 65 |
+
@wraps(f)
|
| 66 |
+
def decorated(*args, **kwargs):
|
| 67 |
+
auth = request.authorization
|
| 68 |
+
if not auth or not check_auth(auth.username, auth.password):
|
| 69 |
+
api_key = (
|
| 70 |
+
request.headers.get("Authorization", "").replace("Bearer ", "") or
|
| 71 |
+
request.headers.get("X-API-Key")
|
| 72 |
+
)
|
| 73 |
+
adminpassword = os.getenv('ADMIN_PASSWORD', '')
|
| 74 |
+
|
| 75 |
+
if api_key != os.getenv('ADMIN_TOKEN', '') and api_key != os.getenv('ADMIN_PASSWORD', ''):
|
| 76 |
+
return authenticate()
|
| 77 |
+
return f(*args, **kwargs)
|
| 78 |
+
return decorated
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def get_available_worker_slot() -> Optional[int]:
|
| 82 |
+
"""获取可用的工作槽位"""
|
| 83 |
+
max_workers = config.get_max_workers()
|
| 84 |
+
with workers_lock:
|
| 85 |
+
active_count = sum(1 for w in workers.values() if w.is_alive())
|
| 86 |
+
if active_count >= max_workers:
|
| 87 |
+
return None
|
| 88 |
+
for i in range(max_workers):
|
| 89 |
+
if i not in workers or not workers[i].is_alive():
|
| 90 |
+
return i
|
| 91 |
+
return None
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def start_worker(worker_id: int, account: AccountInfo, mode: str = "register"):
|
| 95 |
+
"""启动工作线程"""
|
| 96 |
+
worker = BrowserWorker(
|
| 97 |
+
worker_id=worker_id,
|
| 98 |
+
account=account,
|
| 99 |
+
config=config,
|
| 100 |
+
mode=mode,
|
| 101 |
+
on_update=on_account_update,
|
| 102 |
+
on_complete=on_worker_complete
|
| 103 |
+
)
|
| 104 |
+
with workers_lock:
|
| 105 |
+
workers[worker_id] = worker
|
| 106 |
+
worker.start()
|
| 107 |
+
return worker
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def on_account_update(email: str, account: AccountInfo):
|
| 111 |
+
"""账号更新回调"""
|
| 112 |
+
with accounts_lock:
|
| 113 |
+
accounts[email] = account
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def on_worker_complete(worker_id: int, email: str, success: bool):
|
| 117 |
+
"""工作线程完成回调"""
|
| 118 |
+
with workers_lock:
|
| 119 |
+
if worker_id in workers:
|
| 120 |
+
del workers[worker_id]
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def get_email_config_by_domain(email: str) -> Optional[Dict]:
|
| 124 |
+
"""根据邮箱地址获取对应的邮箱配置"""
|
| 125 |
+
if '@' not in email:
|
| 126 |
+
return None
|
| 127 |
+
|
| 128 |
+
domain = email.split('@')[1].lower()
|
| 129 |
+
email_configs = config.get_email_configs()
|
| 130 |
+
|
| 131 |
+
for cfg in email_configs:
|
| 132 |
+
if cfg.get('email_domain', '').lower() == domain:
|
| 133 |
+
return cfg
|
| 134 |
+
|
| 135 |
+
return None
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def create_account_from_email(email: str) -> Optional[AccountInfo]:
|
| 139 |
+
"""根据邮箱地址创建账号(用于刷新场景)"""
|
| 140 |
+
email_config = get_email_config_by_domain(email)
|
| 141 |
+
if not email_config:
|
| 142 |
+
return None
|
| 143 |
+
|
| 144 |
+
# 尝试获取JWT
|
| 145 |
+
email_manager = EmailManager(
|
| 146 |
+
email_config['worker_domain'],
|
| 147 |
+
email_config['email_domain'],
|
| 148 |
+
email_config['admin_password']
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
account = AccountInfo(
|
| 152 |
+
email=email,
|
| 153 |
+
jwt= "",
|
| 154 |
+
status=AccountStatus.PENDING,
|
| 155 |
+
email_config=email_config,
|
| 156 |
+
created_at=datetime.now().isoformat()
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
return account
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ==================== API 路由 ====================
|
| 163 |
+
|
| 164 |
+
@app.route('/')
|
| 165 |
+
def index():
|
| 166 |
+
"""管理面板首页"""
|
| 167 |
+
return render_template('index.html')
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@app.route('/api/login', methods=['POST'])
|
| 171 |
+
def login():
|
| 172 |
+
"""登录验证"""
|
| 173 |
+
data = request.get_json()
|
| 174 |
+
username = data.get('username', '')
|
| 175 |
+
password = data.get('password', '')
|
| 176 |
+
|
| 177 |
+
if check_auth(username, password):
|
| 178 |
+
token = str(uuid.uuid4())
|
| 179 |
+
return jsonify({
|
| 180 |
+
'success': True,
|
| 181 |
+
'token': token,
|
| 182 |
+
'message': '登录成功'
|
| 183 |
+
})
|
| 184 |
+
|
| 185 |
+
return jsonify({
|
| 186 |
+
'success': False,
|
| 187 |
+
'message': '用户名或密码错误'
|
| 188 |
+
}), 401
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@app.route('/api/accounts', methods=['POST'])
|
| 192 |
+
@requires_auth
|
| 193 |
+
def create_account():
|
| 194 |
+
"""创建新账号"""
|
| 195 |
+
data = request.get_json() or {}
|
| 196 |
+
username = data.get('username', '')
|
| 197 |
+
|
| 198 |
+
worker_id = get_available_worker_slot()
|
| 199 |
+
if worker_id is None:
|
| 200 |
+
return jsonify({
|
| 201 |
+
'success': False,
|
| 202 |
+
'error': '浏览器线程已达上限,请稍后再试'
|
| 203 |
+
}), 429
|
| 204 |
+
|
| 205 |
+
email_config = config.get_random_email_config()
|
| 206 |
+
if not email_config:
|
| 207 |
+
return jsonify({
|
| 208 |
+
'success': False,
|
| 209 |
+
'error': '没有配置邮箱域'
|
| 210 |
+
}), 500
|
| 211 |
+
|
| 212 |
+
email_manager = EmailManager(
|
| 213 |
+
email_config['worker_domain'],
|
| 214 |
+
email_config['email_domain'],
|
| 215 |
+
email_config['admin_password']
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
jwt, email = email_manager.create_email(username)
|
| 219 |
+
if not jwt or not email:
|
| 220 |
+
return jsonify({
|
| 221 |
+
'success': False,
|
| 222 |
+
'error': '创建邮箱失败'
|
| 223 |
+
}), 500
|
| 224 |
+
|
| 225 |
+
account = AccountInfo(
|
| 226 |
+
email=email,
|
| 227 |
+
jwt=jwt,
|
| 228 |
+
status=AccountStatus.PENDING,
|
| 229 |
+
email_config=email_config,
|
| 230 |
+
created_at=datetime.now().isoformat()
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
with accounts_lock:
|
| 234 |
+
accounts[email] = account
|
| 235 |
+
|
| 236 |
+
worker = start_worker(worker_id, account, mode="register")
|
| 237 |
+
worker.join() # 等待任务完成
|
| 238 |
+
|
| 239 |
+
if account.status == AccountStatus.SUCCESS:
|
| 240 |
+
return jsonify({
|
| 241 |
+
'success': True,
|
| 242 |
+
'account': account.to_dict(),
|
| 243 |
+
'message': '账号创建成功'
|
| 244 |
+
})
|
| 245 |
+
else:
|
| 246 |
+
return jsonify({
|
| 247 |
+
'success': False,
|
| 248 |
+
'error': account.error_message or '创建失败',
|
| 249 |
+
'details': account.to_dict()
|
| 250 |
+
}), 500
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
@app.route('/api/accounts', methods=['GET'])
|
| 254 |
+
@requires_auth
|
| 255 |
+
def get_accounts():
|
| 256 |
+
"""获取账号列表"""
|
| 257 |
+
email = request.args.get('email')
|
| 258 |
+
status_filter = request.args.get('status')
|
| 259 |
+
search = request.args.get('search', '')
|
| 260 |
+
page = int(request.args.get('page', 1))
|
| 261 |
+
per_page = int(request.args.get('per_page', 20))
|
| 262 |
+
|
| 263 |
+
with accounts_lock:
|
| 264 |
+
if email:
|
| 265 |
+
if email in accounts:
|
| 266 |
+
return jsonify({
|
| 267 |
+
'success': True,
|
| 268 |
+
'account': accounts[email].to_dict()
|
| 269 |
+
})
|
| 270 |
+
else:
|
| 271 |
+
return jsonify({
|
| 272 |
+
'success': False,
|
| 273 |
+
'error': '账号不存在'
|
| 274 |
+
}), 404
|
| 275 |
+
|
| 276 |
+
result = []
|
| 277 |
+
for acc in accounts.values():
|
| 278 |
+
if status_filter:
|
| 279 |
+
if status_filter == 'success' and acc.status != AccountStatus.SUCCESS:
|
| 280 |
+
continue
|
| 281 |
+
elif status_filter == 'failed' and acc.status != AccountStatus.FAILED:
|
| 282 |
+
continue
|
| 283 |
+
elif status_filter == 'creating' and acc.status not in [
|
| 284 |
+
AccountStatus.PENDING, AccountStatus.CREATING_EMAIL,
|
| 285 |
+
AccountStatus.ENTERING_EMAIL, AccountStatus.WAITING_CODE,
|
| 286 |
+
AccountStatus.VERIFYING, AccountStatus.COMPLETING
|
| 287 |
+
]:
|
| 288 |
+
continue
|
| 289 |
+
elif status_filter == 'updating' and acc.status != AccountStatus.UPDATING:
|
| 290 |
+
continue
|
| 291 |
+
|
| 292 |
+
if search and search.lower() not in acc.email.lower():
|
| 293 |
+
continue
|
| 294 |
+
|
| 295 |
+
result.append(acc.to_dict())
|
| 296 |
+
|
| 297 |
+
total = len(result)
|
| 298 |
+
success_count = sum(1 for acc in accounts.values() if acc.status == AccountStatus.SUCCESS)
|
| 299 |
+
creating_count = sum(1 for acc in accounts.values() if acc.status in [
|
| 300 |
+
AccountStatus.PENDING, AccountStatus.CREATING_EMAIL,
|
| 301 |
+
AccountStatus.ENTERING_EMAIL, AccountStatus.WAITING_CODE,
|
| 302 |
+
AccountStatus.VERIFYING, AccountStatus.COMPLETING, AccountStatus.UPDATING
|
| 303 |
+
])
|
| 304 |
+
failed_count = sum(1 for acc in accounts.values() if acc.status == AccountStatus.FAILED)
|
| 305 |
+
|
| 306 |
+
start = (page - 1) * per_page
|
| 307 |
+
end = start + per_page
|
| 308 |
+
paginated = result[start:end]
|
| 309 |
+
|
| 310 |
+
return jsonify({
|
| 311 |
+
'success': True,
|
| 312 |
+
'accounts': paginated,
|
| 313 |
+
'total': total,
|
| 314 |
+
'page': page,
|
| 315 |
+
'per_page': per_page,
|
| 316 |
+
'total_pages': (total + per_page - 1) // per_page,
|
| 317 |
+
'stats': {
|
| 318 |
+
'total': len(accounts),
|
| 319 |
+
'success': success_count,
|
| 320 |
+
'creating': creating_count,
|
| 321 |
+
'failed': failed_count
|
| 322 |
+
}
|
| 323 |
+
})
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
@app.route('/api/accounts/<email>', methods=['DELETE'])
|
| 327 |
+
@requires_auth
|
| 328 |
+
def delete_account(email: str):
|
| 329 |
+
"""删除账号"""
|
| 330 |
+
with accounts_lock:
|
| 331 |
+
if email in accounts:
|
| 332 |
+
del accounts[email]
|
| 333 |
+
return jsonify({
|
| 334 |
+
'success': True,
|
| 335 |
+
'message': '账号已删除'
|
| 336 |
+
})
|
| 337 |
+
else:
|
| 338 |
+
return jsonify({
|
| 339 |
+
'success': False,
|
| 340 |
+
'error': '账号不存在'
|
| 341 |
+
}), 404
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
@app.route('/api/accounts/<email>/refresh', methods=['POST'])
|
| 345 |
+
@requires_auth
|
| 346 |
+
def refresh_account(email: str):
|
| 347 |
+
"""
|
| 348 |
+
刷新账号Cookie
|
| 349 |
+
- 如果账号存在,直接刷新
|
| 350 |
+
- 如果账号不存在但邮箱域名匹配,自动创建并刷新
|
| 351 |
+
"""
|
| 352 |
+
with accounts_lock:
|
| 353 |
+
account = accounts.get(email)
|
| 354 |
+
|
| 355 |
+
# 账号不存在,尝试根据邮箱域名创建
|
| 356 |
+
if not account:
|
| 357 |
+
account = create_account_from_email(email)
|
| 358 |
+
if not account:
|
| 359 |
+
return jsonify({
|
| 360 |
+
'success': False,
|
| 361 |
+
'error': f'账号不存在,且邮箱域名 {email.split("@")[1] if "@" in email else "unknown"} 未配置'
|
| 362 |
+
}), 404
|
| 363 |
+
|
| 364 |
+
# 保存新创建的账号
|
| 365 |
+
with accounts_lock:
|
| 366 |
+
accounts[email] = account
|
| 367 |
+
|
| 368 |
+
# 检查是否有可用的工作槽位
|
| 369 |
+
worker_id = get_available_worker_slot()
|
| 370 |
+
if worker_id is None:
|
| 371 |
+
return jsonify({
|
| 372 |
+
'success': False,
|
| 373 |
+
'error': '浏览器线程已达上限,请稍后再试'
|
| 374 |
+
}), 429
|
| 375 |
+
|
| 376 |
+
# 更新状态
|
| 377 |
+
account.status = AccountStatus.UPDATING
|
| 378 |
+
with accounts_lock:
|
| 379 |
+
accounts[email] = account
|
| 380 |
+
|
| 381 |
+
# 启动刷新工作线程
|
| 382 |
+
start_worker(worker_id, account, mode="refresh")
|
| 383 |
+
|
| 384 |
+
return jsonify({
|
| 385 |
+
'success': True,
|
| 386 |
+
'message': '刷新已开始',
|
| 387 |
+
'email': email
|
| 388 |
+
})
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
@app.route('/api/accounts/refresh-all', methods=['POST'])
|
| 392 |
+
@requires_auth
|
| 393 |
+
def refresh_all_accounts():
|
| 394 |
+
"""刷新所有成功的账号"""
|
| 395 |
+
with accounts_lock:
|
| 396 |
+
success_accounts = [acc for acc in accounts.values() if acc.status == AccountStatus.SUCCESS]
|
| 397 |
+
|
| 398 |
+
if not success_accounts:
|
| 399 |
+
return jsonify({
|
| 400 |
+
'success': False,
|
| 401 |
+
'error': '没有可刷新的账号'
|
| 402 |
+
}), 400
|
| 403 |
+
|
| 404 |
+
max_workers = config.get_max_workers()
|
| 405 |
+
queued_count = 0
|
| 406 |
+
|
| 407 |
+
for account in success_accounts:
|
| 408 |
+
worker_id = get_available_worker_slot()
|
| 409 |
+
if worker_id is not None:
|
| 410 |
+
account.status = AccountStatus.UPDATING
|
| 411 |
+
with accounts_lock:
|
| 412 |
+
accounts[account.email] = account
|
| 413 |
+
start_worker(worker_id, account, mode="refresh")
|
| 414 |
+
queued_count += 1
|
| 415 |
+
else:
|
| 416 |
+
task_queue.put(('refresh', account))
|
| 417 |
+
queued_count += 1
|
| 418 |
+
|
| 419 |
+
return jsonify({
|
| 420 |
+
'success': True,
|
| 421 |
+
'message': f'已开始刷新 {queued_count} 个账号'
|
| 422 |
+
})
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
@app.route('/api/accounts/<email>/retry', methods=['POST'])
|
| 426 |
+
@requires_auth
|
| 427 |
+
def retry_account(email: str):
|
| 428 |
+
"""重试失败的账号"""
|
| 429 |
+
with accounts_lock:
|
| 430 |
+
if email not in accounts:
|
| 431 |
+
return jsonify({
|
| 432 |
+
'success': False,
|
| 433 |
+
'error': '账号不存在'
|
| 434 |
+
}), 404
|
| 435 |
+
|
| 436 |
+
account = accounts[email]
|
| 437 |
+
|
| 438 |
+
if account.status != AccountStatus.FAILED:
|
| 439 |
+
return jsonify({
|
| 440 |
+
'success': False,
|
| 441 |
+
'error': '只能重试失败的账号'
|
| 442 |
+
}), 400
|
| 443 |
+
|
| 444 |
+
worker_id = get_available_worker_slot()
|
| 445 |
+
if worker_id is None:
|
| 446 |
+
return jsonify({
|
| 447 |
+
'success': False,
|
| 448 |
+
'error': '浏览器线程已达上限,请稍后再试'
|
| 449 |
+
}), 429
|
| 450 |
+
|
| 451 |
+
account.status = AccountStatus.PENDING
|
| 452 |
+
account.error_message = ""
|
| 453 |
+
with accounts_lock:
|
| 454 |
+
accounts[email] = account
|
| 455 |
+
|
| 456 |
+
start_worker(worker_id, account, mode="register")
|
| 457 |
+
|
| 458 |
+
return jsonify({
|
| 459 |
+
'success': True,
|
| 460 |
+
'message': '重试已开始'
|
| 461 |
+
})
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
@app.route('/api/accounts/<email>/stop', methods=['POST'])
|
| 465 |
+
@requires_auth
|
| 466 |
+
def stop_account(email: str):
|
| 467 |
+
"""停止账号操作"""
|
| 468 |
+
with workers_lock:
|
| 469 |
+
for worker_id, worker in list(workers.items()):
|
| 470 |
+
if worker.account.email == email:
|
| 471 |
+
worker.stop()
|
| 472 |
+
del workers[worker_id]
|
| 473 |
+
|
| 474 |
+
with accounts_lock:
|
| 475 |
+
if email in accounts:
|
| 476 |
+
accounts[email].status = AccountStatus.FAILED
|
| 477 |
+
accounts[email].error_message = "用户手动停止"
|
| 478 |
+
|
| 479 |
+
return jsonify({
|
| 480 |
+
'success': True,
|
| 481 |
+
'message': '已停止'
|
| 482 |
+
})
|
| 483 |
+
|
| 484 |
+
return jsonify({
|
| 485 |
+
'success': False,
|
| 486 |
+
'error': '未找到正在运行的任务'
|
| 487 |
+
}), 404
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
@app.route('/api/accounts/stop-all', methods=['POST'])
|
| 491 |
+
@requires_auth
|
| 492 |
+
def stop_all():
|
| 493 |
+
"""停止所有操作"""
|
| 494 |
+
stopped_count = 0
|
| 495 |
+
with workers_lock:
|
| 496 |
+
for worker_id, worker in list(workers.items()):
|
| 497 |
+
worker.stop()
|
| 498 |
+
email = worker.account.email
|
| 499 |
+
|
| 500 |
+
with accounts_lock:
|
| 501 |
+
if email in accounts:
|
| 502 |
+
accounts[email].status = AccountStatus.FAILED
|
| 503 |
+
accounts[email].error_message = "用户手动停止"
|
| 504 |
+
|
| 505 |
+
stopped_count += 1
|
| 506 |
+
workers.clear()
|
| 507 |
+
|
| 508 |
+
while not task_queue.empty():
|
| 509 |
+
try:
|
| 510 |
+
task_queue.get_nowait()
|
| 511 |
+
except:
|
| 512 |
+
break
|
| 513 |
+
|
| 514 |
+
return jsonify({
|
| 515 |
+
'success': True,
|
| 516 |
+
'message': f'已停止 {stopped_count} 个任务'
|
| 517 |
+
})
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
@app.route('/api/accounts/export', methods=['GET'])
|
| 521 |
+
@requires_auth
|
| 522 |
+
def export_accounts():
|
| 523 |
+
"""导出成功的账号(包含时间戳和邮箱)"""
|
| 524 |
+
with accounts_lock:
|
| 525 |
+
success_accounts = [acc for acc in accounts.values() if acc.status == AccountStatus.SUCCESS and acc.is_complete()]
|
| 526 |
+
|
| 527 |
+
export_data = {
|
| 528 |
+
'accounts': []
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
for acc in success_accounts:
|
| 532 |
+
export_data['accounts'].append({
|
| 533 |
+
'available': True,
|
| 534 |
+
'email': acc.email, # 重要:包含邮箱用于刷新
|
| 535 |
+
'csesidx': acc.csesidx,
|
| 536 |
+
'host_c_oses': acc.c_oses,
|
| 537 |
+
'secure_c_ses': acc.c_ses,
|
| 538 |
+
'team_id': acc.config_id,
|
| 539 |
+
'user_agent': config.get_user_agent(),
|
| 540 |
+
'created_at': acc.created_at or datetime.now().isoformat(),
|
| 541 |
+
'updated_at': acc.updated_at or acc.created_at or datetime.now().isoformat()
|
| 542 |
+
})
|
| 543 |
+
|
| 544 |
+
return jsonify(export_data)
|
| 545 |
+
|
| 546 |
+
|
| 547 |
+
@app.route('/api/settings', methods=['GET'])
|
| 548 |
+
@requires_auth
|
| 549 |
+
def get_settings():
|
| 550 |
+
"""获取设置"""
|
| 551 |
+
return jsonify({
|
| 552 |
+
'success': True,
|
| 553 |
+
'settings': {
|
| 554 |
+
'user_agent': config.get_user_agent(),
|
| 555 |
+
'max_workers': config.get_max_workers(),
|
| 556 |
+
'headless': config.get_headless(),
|
| 557 |
+
'email_configs': config.get_email_configs_safe(),
|
| 558 |
+
'browser_fingerprint': config.get_browser_fingerprint()
|
| 559 |
+
}
|
| 560 |
+
})
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
@app.route('/api/settings', methods=['POST'])
|
| 564 |
+
@requires_auth
|
| 565 |
+
def update_settings():
|
| 566 |
+
"""更新设置"""
|
| 567 |
+
data = request.get_json()
|
| 568 |
+
|
| 569 |
+
if 'user_agent' in data:
|
| 570 |
+
config.set_user_agent(data['user_agent'])
|
| 571 |
+
|
| 572 |
+
if 'max_workers' in data:
|
| 573 |
+
config.set_max_workers(int(data['max_workers']))
|
| 574 |
+
|
| 575 |
+
if 'headless' in data:
|
| 576 |
+
config.set_headless(bool(data['headless']))
|
| 577 |
+
|
| 578 |
+
if 'browser_fingerprint' in data:
|
| 579 |
+
config.set_browser_fingerprint(data['browser_fingerprint'])
|
| 580 |
+
|
| 581 |
+
return jsonify({
|
| 582 |
+
'success': True,
|
| 583 |
+
'message': '设置已更新'
|
| 584 |
+
})
|
| 585 |
+
|
| 586 |
+
|
| 587 |
+
@app.route('/api/email-configs', methods=['GET'])
|
| 588 |
+
@requires_auth
|
| 589 |
+
def get_email_configs():
|
| 590 |
+
"""获取邮箱配置列表"""
|
| 591 |
+
return jsonify({
|
| 592 |
+
'success': True,
|
| 593 |
+
'configs': config.get_email_configs_safe()
|
| 594 |
+
})
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
@app.route('/api/email-configs', methods=['POST'])
|
| 598 |
+
@requires_auth
|
| 599 |
+
def add_email_config():
|
| 600 |
+
"""添加邮箱配置"""
|
| 601 |
+
data = request.get_json()
|
| 602 |
+
|
| 603 |
+
worker_domain = data.get('worker_domain', '')
|
| 604 |
+
email_domain = data.get('email_domain', '')
|
| 605 |
+
admin_password = data.get('admin_password', '')
|
| 606 |
+
|
| 607 |
+
if not all([worker_domain, email_domain, admin_password]):
|
| 608 |
+
return jsonify({
|
| 609 |
+
'success': False,
|
| 610 |
+
'error': '缺少必要参数'
|
| 611 |
+
}), 400
|
| 612 |
+
|
| 613 |
+
config.add_email_config(worker_domain, email_domain, admin_password)
|
| 614 |
+
|
| 615 |
+
return jsonify({
|
| 616 |
+
'success': True,
|
| 617 |
+
'message': '邮箱配置已添加'
|
| 618 |
+
})
|
| 619 |
+
|
| 620 |
+
|
| 621 |
+
@app.route('/api/email-configs/<int:index>', methods=['PUT'])
|
| 622 |
+
@requires_auth
|
| 623 |
+
def update_email_config(index: int):
|
| 624 |
+
"""更新邮箱配置"""
|
| 625 |
+
data = request.get_json()
|
| 626 |
+
|
| 627 |
+
try:
|
| 628 |
+
config.update_email_config(
|
| 629 |
+
index,
|
| 630 |
+
data.get('worker_domain'),
|
| 631 |
+
data.get('email_domain'),
|
| 632 |
+
data.get('admin_password')
|
| 633 |
+
)
|
| 634 |
+
return jsonify({
|
| 635 |
+
'success': True,
|
| 636 |
+
'message': '邮箱配置已更新'
|
| 637 |
+
})
|
| 638 |
+
except IndexError:
|
| 639 |
+
return jsonify({
|
| 640 |
+
'success': False,
|
| 641 |
+
'error': '配置不存在'
|
| 642 |
+
}), 404
|
| 643 |
+
|
| 644 |
+
|
| 645 |
+
@app.route('/api/email-configs/<int:index>', methods=['DELETE'])
|
| 646 |
+
@requires_auth
|
| 647 |
+
def delete_email_config(index: int):
|
| 648 |
+
"""删除邮箱配置"""
|
| 649 |
+
try:
|
| 650 |
+
config.delete_email_config(index)
|
| 651 |
+
return jsonify({
|
| 652 |
+
'success': True,
|
| 653 |
+
'message': '邮箱配置已删除'
|
| 654 |
+
})
|
| 655 |
+
except IndexError:
|
| 656 |
+
return jsonify({
|
| 657 |
+
'success': False,
|
| 658 |
+
'error': '配置不存在'
|
| 659 |
+
}), 404
|
| 660 |
+
|
| 661 |
+
|
| 662 |
+
@app.route('/api/status', methods=['GET'])
|
| 663 |
+
@requires_auth
|
| 664 |
+
def get_status():
|
| 665 |
+
"""获取系统状态"""
|
| 666 |
+
with accounts_lock:
|
| 667 |
+
total = len(accounts)
|
| 668 |
+
success = sum(1 for acc in accounts.values() if acc.status == AccountStatus.SUCCESS)
|
| 669 |
+
creating = sum(1 for acc in accounts.values() if acc.status in [
|
| 670 |
+
AccountStatus.PENDING, AccountStatus.CREATING_EMAIL,
|
| 671 |
+
AccountStatus.ENTERING_EMAIL, AccountStatus.WAITING_CODE,
|
| 672 |
+
AccountStatus.VERIFYING, AccountStatus.COMPLETING
|
| 673 |
+
])
|
| 674 |
+
updating = sum(1 for acc in accounts.values() if acc.status == AccountStatus.UPDATING)
|
| 675 |
+
failed = sum(1 for acc in accounts.values() if acc.status == AccountStatus.FAILED)
|
| 676 |
+
|
| 677 |
+
with workers_lock:
|
| 678 |
+
active_workers = sum(1 for w in workers.values() if w.is_alive())
|
| 679 |
+
|
| 680 |
+
return jsonify({
|
| 681 |
+
'success': True,
|
| 682 |
+
'status': {
|
| 683 |
+
'accounts': {
|
| 684 |
+
'total': total,
|
| 685 |
+
'success': success,
|
| 686 |
+
'creating': creating,
|
| 687 |
+
'updating': updating,
|
| 688 |
+
'failed': failed
|
| 689 |
+
},
|
| 690 |
+
'workers': {
|
| 691 |
+
'active': active_workers,
|
| 692 |
+
'max': config.get_max_workers()
|
| 693 |
+
},
|
| 694 |
+
'queue_size': task_queue.qsize()
|
| 695 |
+
}
|
| 696 |
+
})
|
| 697 |
+
|
| 698 |
+
|
| 699 |
+
@app.route('/api/screenshot', methods=['GET'])
|
| 700 |
+
@requires_auth
|
| 701 |
+
def get_screenshot():
|
| 702 |
+
"""获取浏览器截图"""
|
| 703 |
+
email = request.args.get('email')
|
| 704 |
+
|
| 705 |
+
if email:
|
| 706 |
+
# 获取特定账号的截图
|
| 707 |
+
target_worker = None
|
| 708 |
+
with workers_lock:
|
| 709 |
+
for worker in workers.values():
|
| 710 |
+
if worker.account.email == email:
|
| 711 |
+
target_worker = worker
|
| 712 |
+
break
|
| 713 |
+
|
| 714 |
+
if not target_worker:
|
| 715 |
+
return jsonify({
|
| 716 |
+
'success': False,
|
| 717 |
+
'error': '未找到该账号的运行实例'
|
| 718 |
+
}), 404
|
| 719 |
+
|
| 720 |
+
screenshot = target_worker.get_screenshot()
|
| 721 |
+
if screenshot:
|
| 722 |
+
return jsonify({
|
| 723 |
+
'success': True,
|
| 724 |
+
'email': email,
|
| 725 |
+
'image': f''
|
| 726 |
+
})
|
| 727 |
+
else:
|
| 728 |
+
return jsonify({
|
| 729 |
+
'success': False,
|
| 730 |
+
'error': '截图失败'
|
| 731 |
+
}), 500
|
| 732 |
+
|
| 733 |
+
else:
|
| 734 |
+
# 获取所有活跃实例的截图
|
| 735 |
+
results = []
|
| 736 |
+
with workers_lock:
|
| 737 |
+
active_workers = list(workers.values())
|
| 738 |
+
|
| 739 |
+
for worker in active_workers:
|
| 740 |
+
if worker.is_alive():
|
| 741 |
+
screenshot = worker.get_screenshot()
|
| 742 |
+
if screenshot:
|
| 743 |
+
results.append({
|
| 744 |
+
'email': worker.account.email,
|
| 745 |
+
'image': f''
|
| 746 |
+
})
|
| 747 |
+
|
| 748 |
+
return jsonify({
|
| 749 |
+
'success': True,
|
| 750 |
+
'screenshots': results
|
| 751 |
+
})
|
| 752 |
+
|
| 753 |
+
|
| 754 |
+
# 后台任务处理器
|
| 755 |
+
def background_task_processor():
|
| 756 |
+
"""后台任务处理器"""
|
| 757 |
+
while True:
|
| 758 |
+
try:
|
| 759 |
+
task = task_queue.get(timeout=1)
|
| 760 |
+
if task is None:
|
| 761 |
+
break
|
| 762 |
+
|
| 763 |
+
mode, account = task
|
| 764 |
+
worker_id = get_available_worker_slot()
|
| 765 |
+
|
| 766 |
+
if worker_id is not None:
|
| 767 |
+
start_worker(worker_id, account, mode=mode)
|
| 768 |
+
else:
|
| 769 |
+
task_queue.put(task)
|
| 770 |
+
time.sleep(1)
|
| 771 |
+
|
| 772 |
+
except queue.Empty:
|
| 773 |
+
continue
|
| 774 |
+
except Exception as e:
|
| 775 |
+
logger.error(f"后台任务处理器错误: {e}")
|
| 776 |
+
|
| 777 |
+
|
| 778 |
+
# 启动后台任务处理器
|
| 779 |
+
task_processor_thread = threading.Thread(target=background_task_processor, daemon=True)
|
| 780 |
+
task_processor_thread.start()
|
| 781 |
+
|
| 782 |
+
|
| 783 |
+
if __name__ == '__main__':
|
| 784 |
+
port = int(os.getenv('PORT', 5000))
|
| 785 |
+
debug = os.getenv('DEBUG', 'false').lower() == 'true'
|
| 786 |
+
logger.info(f"启动 Flask 应用, http://127.0.0.1:{port}")
|
| 787 |
+
app.run(host='0.0.0.0', port=port, debug=debug, threaded=True)
|
browser_worker.py
ADDED
|
@@ -0,0 +1,881 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import time
|
| 3 |
+
import random
|
| 4 |
+
import threading
|
| 5 |
+
from enum import Enum
|
| 6 |
+
from typing import Optional, Callable
|
| 7 |
+
from dataclasses import dataclass, field
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from urllib.parse import urlparse, parse_qs
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
from DrissionPage import Chromium, ChromiumOptions
|
| 13 |
+
|
| 14 |
+
from email_manager import EmailManager
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
class AccountStatus(Enum):
|
| 19 |
+
PENDING = "pending"
|
| 20 |
+
CREATING_EMAIL = "creating_email"
|
| 21 |
+
OPENING_PAGE = "opening_page"
|
| 22 |
+
ENTERING_EMAIL = "entering_email"
|
| 23 |
+
WAITING_CODE = "waiting_code"
|
| 24 |
+
ENTERING_CODE = "entering_code"
|
| 25 |
+
VERIFYING = "verifying"
|
| 26 |
+
ENTERING_NAME = "entering_name"
|
| 27 |
+
AGREEING = "agreeing"
|
| 28 |
+
WAITING_REDIRECT = "waiting_redirect"
|
| 29 |
+
COMPLETING = "completing"
|
| 30 |
+
EXTRACTING_DATA = "extracting_data"
|
| 31 |
+
SUCCESS = "success"
|
| 32 |
+
FAILED = "failed"
|
| 33 |
+
UPDATING = "updating"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class AccountInfo:
|
| 38 |
+
email: str = ""
|
| 39 |
+
jwt: str = ""
|
| 40 |
+
status: AccountStatus = AccountStatus.PENDING
|
| 41 |
+
error_message: str = ""
|
| 42 |
+
verification_code: str = ""
|
| 43 |
+
c_oses: str = ""
|
| 44 |
+
c_ses: str = ""
|
| 45 |
+
csesidx: str = ""
|
| 46 |
+
config_id: str = ""
|
| 47 |
+
created_at: str = ""
|
| 48 |
+
updated_at: str = ""
|
| 49 |
+
email_config: dict = field(default_factory=dict)
|
| 50 |
+
|
| 51 |
+
def to_dict(self) -> dict:
|
| 52 |
+
return {
|
| 53 |
+
"email": self.email,
|
| 54 |
+
"status": self.status.value,
|
| 55 |
+
"error_message": self.error_message,
|
| 56 |
+
"verification_code": self.verification_code,
|
| 57 |
+
"c_oses": self.c_oses,
|
| 58 |
+
"c_ses": self.c_ses,
|
| 59 |
+
"csesidx": self.csesidx,
|
| 60 |
+
"config_id": self.config_id,
|
| 61 |
+
"created_at": self.created_at,
|
| 62 |
+
"updated_at": self.updated_at,
|
| 63 |
+
"is_complete": self.is_complete()
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
def to_export_dict(self) -> dict:
|
| 67 |
+
"""导出格式,包含时间戳"""
|
| 68 |
+
return {
|
| 69 |
+
"available": True,
|
| 70 |
+
"csesidx": self.csesidx,
|
| 71 |
+
"host_c_oses": self.c_oses,
|
| 72 |
+
"secure_c_ses": self.c_ses,
|
| 73 |
+
"team_id": self.config_id,
|
| 74 |
+
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
|
| 75 |
+
"created_at": self.created_at or datetime.now().isoformat(),
|
| 76 |
+
"updated_at": self.updated_at or self.created_at or datetime.now().isoformat()
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
def is_complete(self) -> bool:
|
| 80 |
+
return all([
|
| 81 |
+
self.c_oses,
|
| 82 |
+
self.c_ses,
|
| 83 |
+
self.csesidx,
|
| 84 |
+
self.config_id
|
| 85 |
+
])
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class BrowserWorker(threading.Thread):
|
| 89 |
+
"""浏览器工作线程"""
|
| 90 |
+
|
| 91 |
+
def __init__(
|
| 92 |
+
self,
|
| 93 |
+
worker_id: int,
|
| 94 |
+
account: AccountInfo,
|
| 95 |
+
config,
|
| 96 |
+
mode: str = "register",
|
| 97 |
+
on_update: Optional[Callable] = None,
|
| 98 |
+
on_complete: Optional[Callable] = None
|
| 99 |
+
):
|
| 100 |
+
super().__init__(daemon=True)
|
| 101 |
+
self.worker_id = worker_id
|
| 102 |
+
self.account = account
|
| 103 |
+
self.config = config
|
| 104 |
+
self.mode = mode
|
| 105 |
+
self.on_update = on_update
|
| 106 |
+
self.on_complete = on_complete
|
| 107 |
+
self.is_running = True
|
| 108 |
+
self.browser: Optional[Chromium] = None
|
| 109 |
+
self.page = None
|
| 110 |
+
|
| 111 |
+
def update_status(self, status: AccountStatus, error: str = ""):
|
| 112 |
+
"""更新状态"""
|
| 113 |
+
self.account.status = status
|
| 114 |
+
self.account.error_message = error
|
| 115 |
+
self.account.updated_at = datetime.now().isoformat()
|
| 116 |
+
if self.on_update:
|
| 117 |
+
self.on_update(self.account.email, self.account)
|
| 118 |
+
|
| 119 |
+
def create_browser(self) -> bool:
|
| 120 |
+
"""创建浏览器实例"""
|
| 121 |
+
try:
|
| 122 |
+
self.close_browser()
|
| 123 |
+
|
| 124 |
+
options = ChromiumOptions().auto_port()
|
| 125 |
+
# 设置浏览器路径
|
| 126 |
+
browser_path = self.config.get_browser_path()
|
| 127 |
+
if browser_path:
|
| 128 |
+
options.set_browser_path(browser_path)
|
| 129 |
+
logger.info(f"[{self.worker_id}] 使用浏览器: {browser_path}")
|
| 130 |
+
|
| 131 |
+
ua = self.config.get_user_agent()
|
| 132 |
+
options.set_user_agent(ua)
|
| 133 |
+
|
| 134 |
+
if self.config.get_headless():
|
| 135 |
+
options.set_argument('--headless=new')
|
| 136 |
+
options.set_argument('--incognito')
|
| 137 |
+
|
| 138 |
+
options.set_argument('--disable-blink-features=AutomationControlled')
|
| 139 |
+
options.set_argument('--no-sandbox')
|
| 140 |
+
options.set_argument('--disable-dev-shm-usage')
|
| 141 |
+
options.set_argument('--disable-gpu')
|
| 142 |
+
options.set_argument('--lang=zh-CN')
|
| 143 |
+
options.set_argument('--disable-web-security')
|
| 144 |
+
options.set_argument('--disable-features=VizDisplayCompositor')
|
| 145 |
+
|
| 146 |
+
fingerprint = self.config.get_browser_fingerprint()
|
| 147 |
+
if fingerprint:
|
| 148 |
+
if fingerprint.get('window_size'):
|
| 149 |
+
w, h = fingerprint['window_size'].split('x')
|
| 150 |
+
options.set_argument(f'--window-size={w},{h}')
|
| 151 |
+
|
| 152 |
+
if fingerprint.get('timezone'):
|
| 153 |
+
options.set_argument(f'--timezone={fingerprint["timezone"]}')
|
| 154 |
+
|
| 155 |
+
if fingerprint.get('locale'):
|
| 156 |
+
options.set_argument(f'--lang={fingerprint["locale"]}')
|
| 157 |
+
|
| 158 |
+
options.set_argument('--disable-reading-from-canvas')
|
| 159 |
+
options.set_pref('credentials_enable_service', False)
|
| 160 |
+
options.set_pref('profile.password_manager_enabled', False)
|
| 161 |
+
|
| 162 |
+
self.browser = Chromium(options)
|
| 163 |
+
self.page = self.browser.latest_tab
|
| 164 |
+
|
| 165 |
+
self._inject_fingerprint_script()
|
| 166 |
+
|
| 167 |
+
return True
|
| 168 |
+
|
| 169 |
+
except Exception as e:
|
| 170 |
+
logger.error(f"创建浏览器失败: {e}")
|
| 171 |
+
return False
|
| 172 |
+
|
| 173 |
+
def _inject_fingerprint_script(self):
|
| 174 |
+
"""注入指纹混淆脚本"""
|
| 175 |
+
script = '''
|
| 176 |
+
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
| 177 |
+
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
| 178 |
+
Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en'] });
|
| 179 |
+
window.chrome = { runtime: {} };
|
| 180 |
+
'''
|
| 181 |
+
try:
|
| 182 |
+
self.page.run_js(script)
|
| 183 |
+
except:
|
| 184 |
+
pass
|
| 185 |
+
|
| 186 |
+
def close_browser(self):
|
| 187 |
+
"""关闭浏览器"""
|
| 188 |
+
if self.browser:
|
| 189 |
+
try:
|
| 190 |
+
self.browser.quit()
|
| 191 |
+
except:
|
| 192 |
+
pass
|
| 193 |
+
finally:
|
| 194 |
+
self.browser = None
|
| 195 |
+
self.page = None
|
| 196 |
+
|
| 197 |
+
def get_screenshot(self) -> Optional[str]:
|
| 198 |
+
"""获取当前页面截图 (Base64)"""
|
| 199 |
+
if self.page:
|
| 200 |
+
try:
|
| 201 |
+
return self.page.get_screenshot(as_base64=True, full_page=False)
|
| 202 |
+
except Exception as e:
|
| 203 |
+
logger.error(f"截图失败: {e}")
|
| 204 |
+
return None
|
| 205 |
+
return None
|
| 206 |
+
|
| 207 |
+
def safe_input(self, selector: str, text: str, max_retries: int = 3) -> bool:
|
| 208 |
+
"""安全输入文本"""
|
| 209 |
+
for attempt in range(max_retries):
|
| 210 |
+
try:
|
| 211 |
+
ele = self.page.ele(selector, timeout=10)
|
| 212 |
+
if not ele:
|
| 213 |
+
continue
|
| 214 |
+
|
| 215 |
+
ele.clear()
|
| 216 |
+
time.sleep(0.3)
|
| 217 |
+
clean_text = ''.join(c for c in text if ord(c) < 128)
|
| 218 |
+
ele.input(clean_text)
|
| 219 |
+
time.sleep(0.5)
|
| 220 |
+
|
| 221 |
+
input_value = ele.attr('value') or ele.value
|
| 222 |
+
if input_value and clean_text in input_value:
|
| 223 |
+
return True
|
| 224 |
+
else:
|
| 225 |
+
ele.clear()
|
| 226 |
+
time.sleep(0.5)
|
| 227 |
+
|
| 228 |
+
except Exception as e:
|
| 229 |
+
logger.error(f"输入失败: {e}")
|
| 230 |
+
time.sleep(1)
|
| 231 |
+
|
| 232 |
+
return False
|
| 233 |
+
|
| 234 |
+
def wait_and_input(self, selector: str, text: str, timeout: float = 10, description: str = "") -> bool:
|
| 235 |
+
"""等待元素并输入"""
|
| 236 |
+
try:
|
| 237 |
+
ele = self.page.ele(selector, timeout=timeout)
|
| 238 |
+
if ele:
|
| 239 |
+
ele.clear()
|
| 240 |
+
ele.input(text)
|
| 241 |
+
logger.info(f"输入成功: {description}")
|
| 242 |
+
return True
|
| 243 |
+
else:
|
| 244 |
+
logger.warning(f"未找到输入框: {description}")
|
| 245 |
+
return False
|
| 246 |
+
except Exception as e:
|
| 247 |
+
logger.error(f"输入失败 {description}: {e}")
|
| 248 |
+
return False
|
| 249 |
+
|
| 250 |
+
def wait_and_click(self, selector: str, timeout: float = 10) -> bool:
|
| 251 |
+
"""等待并点击元素"""
|
| 252 |
+
try:
|
| 253 |
+
ele = self.page.ele(selector, timeout=timeout)
|
| 254 |
+
if ele:
|
| 255 |
+
ele.click()
|
| 256 |
+
return True
|
| 257 |
+
return False
|
| 258 |
+
except Exception as e:
|
| 259 |
+
logger.error(f"点击失败: {e}")
|
| 260 |
+
return False
|
| 261 |
+
|
| 262 |
+
def wait_for_url_pattern(self, pattern: str, timeout: float = 60) -> bool:
|
| 263 |
+
"""等待URL匹配模式"""
|
| 264 |
+
start_time = time.time()
|
| 265 |
+
while time.time() - start_time < timeout:
|
| 266 |
+
if not self.is_running:
|
| 267 |
+
return False
|
| 268 |
+
try:
|
| 269 |
+
current_url = self.page.url
|
| 270 |
+
if re.search(pattern, current_url):
|
| 271 |
+
return True
|
| 272 |
+
except:
|
| 273 |
+
pass
|
| 274 |
+
time.sleep(1)
|
| 275 |
+
return False
|
| 276 |
+
|
| 277 |
+
def wait_for_element(self, selector: str, timeout: float = 30) -> bool:
|
| 278 |
+
"""等待元素出现"""
|
| 279 |
+
start_time = time.time()
|
| 280 |
+
while time.time() - start_time < timeout:
|
| 281 |
+
if not self.is_running:
|
| 282 |
+
return False
|
| 283 |
+
try:
|
| 284 |
+
ele = self.page.ele(selector, timeout=1)
|
| 285 |
+
if ele:
|
| 286 |
+
return True
|
| 287 |
+
except:
|
| 288 |
+
pass
|
| 289 |
+
time.sleep(0.5)
|
| 290 |
+
return False
|
| 291 |
+
|
| 292 |
+
def extract_data(self) -> bool:
|
| 293 |
+
"""提取数据"""
|
| 294 |
+
try:
|
| 295 |
+
cookies = self.page.cookies()
|
| 296 |
+
for cookie in cookies:
|
| 297 |
+
name = cookie.get('name', '')
|
| 298 |
+
if name == '__Host-C_OSES':
|
| 299 |
+
self.account.c_oses = cookie.get('value', '')
|
| 300 |
+
elif name == '__Secure-C_SES':
|
| 301 |
+
self.account.c_ses = cookie.get('value', '')
|
| 302 |
+
|
| 303 |
+
current_url = self.page.url
|
| 304 |
+
parsed = urlparse(current_url)
|
| 305 |
+
query_params = parse_qs(parsed.query)
|
| 306 |
+
self.account.csesidx = query_params.get('csesidx', [''])[0]
|
| 307 |
+
|
| 308 |
+
path_match = re.search(r'/cid/([a-f0-9-]+)', parsed.path)
|
| 309 |
+
if path_match:
|
| 310 |
+
self.account.config_id = path_match.group(1)
|
| 311 |
+
|
| 312 |
+
# 设置更新时间
|
| 313 |
+
self.account.updated_at = datetime.now().isoformat()
|
| 314 |
+
|
| 315 |
+
return self.account.is_complete()
|
| 316 |
+
|
| 317 |
+
except Exception as e:
|
| 318 |
+
logger.error(f"提取数据失败: {e}")
|
| 319 |
+
return False
|
| 320 |
+
|
| 321 |
+
def handle_welcome_dialog(self) -> bool:
|
| 322 |
+
"""处理欢迎对话框"""
|
| 323 |
+
try:
|
| 324 |
+
time.sleep(2)
|
| 325 |
+
|
| 326 |
+
try:
|
| 327 |
+
app_ele = self.page.ele('tag:ucs-standalone-app', timeout=5)
|
| 328 |
+
if app_ele:
|
| 329 |
+
sr1 = app_ele.shadow_root
|
| 330 |
+
if sr1:
|
| 331 |
+
dialog_ele = sr1.ele('tag:ucs-welcome-dialog', timeout=3)
|
| 332 |
+
if dialog_ele:
|
| 333 |
+
sr2 = dialog_ele.shadow_root
|
| 334 |
+
if sr2:
|
| 335 |
+
btn_ele = sr2.ele('tag:md-text-button', timeout=3)
|
| 336 |
+
if btn_ele:
|
| 337 |
+
sr3 = btn_ele.shadow_root
|
| 338 |
+
if sr3:
|
| 339 |
+
inner_btn = sr3.ele('tag:button', timeout=3)
|
| 340 |
+
if inner_btn:
|
| 341 |
+
inner_btn.click()
|
| 342 |
+
return True
|
| 343 |
+
except:
|
| 344 |
+
pass
|
| 345 |
+
|
| 346 |
+
try:
|
| 347 |
+
js_code = '''
|
| 348 |
+
function clickWelcomeButton() {
|
| 349 |
+
const app = document.querySelector('ucs-standalone-app');
|
| 350 |
+
if (app && app.shadowRoot) {
|
| 351 |
+
const dialog = app.shadowRoot.querySelector('ucs-welcome-dialog');
|
| 352 |
+
if (dialog && dialog.shadowRoot) {
|
| 353 |
+
const btn = dialog.shadowRoot.querySelector('md-text-button');
|
| 354 |
+
if (btn) {
|
| 355 |
+
if (btn.shadowRoot) {
|
| 356 |
+
const innerBtn = btn.shadowRoot.querySelector('button');
|
| 357 |
+
if (innerBtn) { innerBtn.click(); return true; }
|
| 358 |
+
}
|
| 359 |
+
btn.click();
|
| 360 |
+
return true;
|
| 361 |
+
}
|
| 362 |
+
}
|
| 363 |
+
}
|
| 364 |
+
return false;
|
| 365 |
+
}
|
| 366 |
+
return clickWelcomeButton();
|
| 367 |
+
'''
|
| 368 |
+
result = self.page.run_js(js_code)
|
| 369 |
+
return bool(result)
|
| 370 |
+
except:
|
| 371 |
+
pass
|
| 372 |
+
|
| 373 |
+
return False
|
| 374 |
+
|
| 375 |
+
except:
|
| 376 |
+
return False
|
| 377 |
+
|
| 378 |
+
def click_verify_button(self) -> bool:
|
| 379 |
+
"""点击验证按钮"""
|
| 380 |
+
verify_selectors = [
|
| 381 |
+
'xpath://button[@jsname="XooR8e"]',
|
| 382 |
+
'xpath://button[@aria-label="验证"]',
|
| 383 |
+
'xpath://button[contains(@class, "YUhpIc-LgbsSe") and @type="submit"]',
|
| 384 |
+
'xpath://button[.//span[contains(text(), "验证")]]',
|
| 385 |
+
'css:button[jsname="XooR8e"]',
|
| 386 |
+
'css:button[aria-label="验证"]',
|
| 387 |
+
]
|
| 388 |
+
|
| 389 |
+
for selector in verify_selectors:
|
| 390 |
+
try:
|
| 391 |
+
ele = self.page.ele(selector, timeout=3)
|
| 392 |
+
if ele:
|
| 393 |
+
time.sleep(0.5)
|
| 394 |
+
ele.click()
|
| 395 |
+
logger.info(f"成功点击验证按钮: {selector}")
|
| 396 |
+
return True
|
| 397 |
+
except:
|
| 398 |
+
continue
|
| 399 |
+
|
| 400 |
+
try:
|
| 401 |
+
js_code = '''
|
| 402 |
+
const buttons = document.querySelectorAll('button');
|
| 403 |
+
for (const btn of buttons) {
|
| 404 |
+
if (btn.getAttribute('jsname') === 'XooR8e' ||
|
| 405 |
+
btn.getAttribute('aria-label') === '验证' ||
|
| 406 |
+
btn.innerText.includes('验证')) {
|
| 407 |
+
btn.click();
|
| 408 |
+
return true;
|
| 409 |
+
}
|
| 410 |
+
}
|
| 411 |
+
return false;
|
| 412 |
+
'''
|
| 413 |
+
result = self.page.run_js(js_code)
|
| 414 |
+
if result:
|
| 415 |
+
logger.info("通过 JavaScript 成功点击验证按钮")
|
| 416 |
+
return True
|
| 417 |
+
except:
|
| 418 |
+
pass
|
| 419 |
+
|
| 420 |
+
return False
|
| 421 |
+
|
| 422 |
+
def click_resend_button(self) -> bool:
|
| 423 |
+
"""点��重新发送按钮"""
|
| 424 |
+
resend_selectors = [
|
| 425 |
+
'button[aria-label="Resend Code"]',
|
| 426 |
+
'button[aria-label="重新发送"]',
|
| 427 |
+
'xpath://button[contains(., "Resend")]',
|
| 428 |
+
'xpath://button[contains(., "重新发送验证码")]',
|
| 429 |
+
]
|
| 430 |
+
|
| 431 |
+
for sel in resend_selectors:
|
| 432 |
+
if self.wait_and_click(sel, timeout=5):
|
| 433 |
+
logger.info(f"[{self.worker_id}] 已点击重新发送按钮")
|
| 434 |
+
return True
|
| 435 |
+
|
| 436 |
+
return False
|
| 437 |
+
|
| 438 |
+
def get_verification_code_with_retry(self, email_manager) -> Optional[str]:
|
| 439 |
+
"""带重试机制的获取验证码"""
|
| 440 |
+
max_loops = 1
|
| 441 |
+
|
| 442 |
+
for i in range(max_loops):
|
| 443 |
+
logger.info(f"[{self.worker_id}] 第 {i+1} 次尝试获取验证码流程")
|
| 444 |
+
|
| 445 |
+
# 1. 第一次检测 (15次)
|
| 446 |
+
logger.info(f"[{self.worker_id}] 正在检测邮件 (10次尝试)...")
|
| 447 |
+
code = email_manager.check_verification_code(self.account.email, max_retries=10)
|
| 448 |
+
if code:
|
| 449 |
+
return code
|
| 450 |
+
|
| 451 |
+
# 2. 如果没收到,检测是否有重发按钮
|
| 452 |
+
logger.info(f"[{self.worker_id}] 未收到邮件,检查重发按钮...")
|
| 453 |
+
if self.click_resend_button():
|
| 454 |
+
logger.info(f"[{self.worker_id}] 点击了重发按钮,等待邮件发送...")
|
| 455 |
+
time.sleep(10) # 等待邮件发送
|
| 456 |
+
continue # 重新开始循环,会再次检测邮件
|
| 457 |
+
|
| 458 |
+
# 3. 如果没有重发按钮,继续等待15次
|
| 459 |
+
logger.info(f"[{self.worker_id}] 未找到重发按钮,继续等待邮件 (10次尝试)...")
|
| 460 |
+
code = email_manager.check_verification_code(self.account.email, max_retries=10)
|
| 461 |
+
if code:
|
| 462 |
+
return code
|
| 463 |
+
|
| 464 |
+
# 4. 再次检测重发按钮
|
| 465 |
+
logger.info(f"[{self.worker_id}] 仍未收到邮件,再次检查重发按钮...")
|
| 466 |
+
if self.click_resend_button():
|
| 467 |
+
logger.info(f"[{self.worker_id}] 点击了重发按钮,等待邮件发送...")
|
| 468 |
+
time.sleep(10)
|
| 469 |
+
continue
|
| 470 |
+
else:
|
| 471 |
+
logger.warning(f"[{self.worker_id}] 无法找到重发按钮且未收到邮件,本轮失败")
|
| 472 |
+
return None
|
| 473 |
+
|
| 474 |
+
return None
|
| 475 |
+
|
| 476 |
+
def register_account(self) -> bool:
|
| 477 |
+
"""注册账号"""
|
| 478 |
+
try:
|
| 479 |
+
self.update_status(AccountStatus.OPENING_PAGE)
|
| 480 |
+
logger.info(f"[{self.worker_id}] 正在打开页面...")
|
| 481 |
+
|
| 482 |
+
self.page.get('https://business.gemini.google')
|
| 483 |
+
|
| 484 |
+
email_input_selectors = [
|
| 485 |
+
'xpath://input[@id="email-input"]',
|
| 486 |
+
'xpath://input[@name="loginHint"]',
|
| 487 |
+
'#email-input',
|
| 488 |
+
]
|
| 489 |
+
|
| 490 |
+
email_input_found = False
|
| 491 |
+
for selector in email_input_selectors:
|
| 492 |
+
if self.wait_for_element(selector, timeout=30):
|
| 493 |
+
email_input_found = True
|
| 494 |
+
break
|
| 495 |
+
|
| 496 |
+
if not email_input_found:
|
| 497 |
+
raise Exception("等待邮箱输入框超时")
|
| 498 |
+
|
| 499 |
+
time.sleep(1)
|
| 500 |
+
|
| 501 |
+
self.update_status(AccountStatus.ENTERING_EMAIL)
|
| 502 |
+
logger.info(f"[{self.worker_id}] 正在输入邮箱: {self.account.email}")
|
| 503 |
+
|
| 504 |
+
input_success = False
|
| 505 |
+
for selector in email_input_selectors:
|
| 506 |
+
if self.safe_input(selector, self.account.email):
|
| 507 |
+
input_success = True
|
| 508 |
+
break
|
| 509 |
+
|
| 510 |
+
if not input_success:
|
| 511 |
+
raise Exception("无法输入邮箱")
|
| 512 |
+
|
| 513 |
+
time.sleep(1)
|
| 514 |
+
|
| 515 |
+
continue_selectors = [
|
| 516 |
+
'xpath://button[@id="log-in-button"]',
|
| 517 |
+
'xpath://button[contains(@aria-label, "使用邮箱继续")]',
|
| 518 |
+
'#log-in-button',
|
| 519 |
+
]
|
| 520 |
+
|
| 521 |
+
clicked = False
|
| 522 |
+
for selector in continue_selectors:
|
| 523 |
+
if self.wait_and_click(selector, timeout=5):
|
| 524 |
+
clicked = True
|
| 525 |
+
break
|
| 526 |
+
|
| 527 |
+
if not clicked:
|
| 528 |
+
raise Exception("无法点击继续按钮")
|
| 529 |
+
|
| 530 |
+
time.sleep(2)
|
| 531 |
+
|
| 532 |
+
self.update_status(AccountStatus.WAITING_CODE)
|
| 533 |
+
logger.info(f"[{self.worker_id}] 正在等待验证码...")
|
| 534 |
+
|
| 535 |
+
email_manager = EmailManager(
|
| 536 |
+
self.account.email_config.get('worker_domain', ''),
|
| 537 |
+
self.account.email_config.get('email_domain', ''),
|
| 538 |
+
self.account.email_config.get('admin_password', '')
|
| 539 |
+
)
|
| 540 |
+
|
| 541 |
+
verification_code = self.get_verification_code_with_retry(email_manager)
|
| 542 |
+
|
| 543 |
+
if not verification_code:
|
| 544 |
+
raise Exception("未收到验证码 (已重试)")
|
| 545 |
+
|
| 546 |
+
self.account.verification_code = verification_code
|
| 547 |
+
logger.info(f"[{self.worker_id}] 获取到验证码: {verification_code}")
|
| 548 |
+
|
| 549 |
+
self.update_status(AccountStatus.ENTERING_CODE)
|
| 550 |
+
|
| 551 |
+
code_input_selectors = [
|
| 552 |
+
'xpath://input[@name="pinInput"]',
|
| 553 |
+
'xpath://input[contains(@aria-label, "验证码")]',
|
| 554 |
+
'input[name="pinInput"]',
|
| 555 |
+
]
|
| 556 |
+
logger.info(f"[{self.worker_id}] 正在输入验证码: {self.account.email}")
|
| 557 |
+
input_success = False
|
| 558 |
+
for selector in code_input_selectors:
|
| 559 |
+
if self.wait_and_input(selector, verification_code, timeout=10, description="验证码输入框"):
|
| 560 |
+
input_success = True
|
| 561 |
+
break
|
| 562 |
+
|
| 563 |
+
if not input_success:
|
| 564 |
+
raise Exception("无法输入验证码")
|
| 565 |
+
|
| 566 |
+
time.sleep(1)
|
| 567 |
+
|
| 568 |
+
self.update_status(AccountStatus.VERIFYING)
|
| 569 |
+
|
| 570 |
+
if not self.click_verify_button():
|
| 571 |
+
raise Exception("无法点击验证按钮")
|
| 572 |
+
|
| 573 |
+
time.sleep(3)
|
| 574 |
+
|
| 575 |
+
# 检查验证码错误并重试
|
| 576 |
+
if self.page.ele('text:请输入验证码。', timeout=2):
|
| 577 |
+
logger.warning(f"[{self.worker_id}] 验证码失效,尝试重新发送...")
|
| 578 |
+
|
| 579 |
+
resend_selectors = [
|
| 580 |
+
'button[aria-label="重新发送验证码"]',
|
| 581 |
+
'xpath://button[contains(., "重新发送验证码")]'
|
| 582 |
+
]
|
| 583 |
+
|
| 584 |
+
resend_clicked = False
|
| 585 |
+
for sel in resend_selectors:
|
| 586 |
+
if self.wait_and_click(sel, timeout=5):
|
| 587 |
+
resend_clicked = True
|
| 588 |
+
break
|
| 589 |
+
|
| 590 |
+
if resend_clicked:
|
| 591 |
+
logger.info(f"[{self.worker_id}] 已点击重新发送,等待新验证码...")
|
| 592 |
+
time.sleep(10) # 等待邮件发送
|
| 593 |
+
|
| 594 |
+
verification_code = email_manager.check_verification_code(self.account.email)
|
| 595 |
+
if not verification_code:
|
| 596 |
+
raise Exception("重发后未收到验证码")
|
| 597 |
+
|
| 598 |
+
self.account.verification_code = verification_code
|
| 599 |
+
logger.info(f"[{self.worker_id}] 获取到新验证码: {verification_code}")
|
| 600 |
+
|
| 601 |
+
input_success = False
|
| 602 |
+
for selector in code_input_selectors:
|
| 603 |
+
if self.wait_and_input(selector, verification_code, timeout=10, description="验证码输入框"):
|
| 604 |
+
input_success = True
|
| 605 |
+
break
|
| 606 |
+
|
| 607 |
+
if input_success:
|
| 608 |
+
time.sleep(1)
|
| 609 |
+
if not self.click_verify_button():
|
| 610 |
+
raise Exception("无法点击验证按钮(重试)")
|
| 611 |
+
time.sleep(3)
|
| 612 |
+
else:
|
| 613 |
+
raise Exception("无法重新输入验证码")
|
| 614 |
+
else:
|
| 615 |
+
logger.warning(f"[{self.worker_id}] 未找到重新发送按钮")
|
| 616 |
+
|
| 617 |
+
self.update_status(AccountStatus.ENTERING_NAME)
|
| 618 |
+
|
| 619 |
+
fullname = ''.join(random.choices('abcdefghijklmnopqrstuvwxyz', k=5))
|
| 620 |
+
name_selectors = [
|
| 621 |
+
'xpath://input[@formcontrolname="fullName"]',
|
| 622 |
+
'xpath://input[@placeholder="全名"]',
|
| 623 |
+
'input[formcontrolname="fullName"]',
|
| 624 |
+
]
|
| 625 |
+
|
| 626 |
+
name_input_found = False
|
| 627 |
+
for selector in name_selectors:
|
| 628 |
+
if self.wait_for_element(selector, timeout=15):
|
| 629 |
+
name_input_found = True
|
| 630 |
+
break
|
| 631 |
+
|
| 632 |
+
if not name_input_found:
|
| 633 |
+
raise Exception("等待姓名输入框超时")
|
| 634 |
+
|
| 635 |
+
input_success = False
|
| 636 |
+
for selector in name_selectors:
|
| 637 |
+
if self.safe_input(selector, fullname):
|
| 638 |
+
input_success = True
|
| 639 |
+
break
|
| 640 |
+
|
| 641 |
+
if not input_success:
|
| 642 |
+
raise Exception("无法输入姓名")
|
| 643 |
+
|
| 644 |
+
time.sleep(1)
|
| 645 |
+
|
| 646 |
+
self.update_status(AccountStatus.AGREEING)
|
| 647 |
+
|
| 648 |
+
agree_selectors = [
|
| 649 |
+
'xpath://button[contains(@class, "agree-button")]',
|
| 650 |
+
'xpath://button[contains(., "同意并开始使用")]',
|
| 651 |
+
'xpath://button[contains(., "同意")]',
|
| 652 |
+
'button.agree-button',
|
| 653 |
+
]
|
| 654 |
+
|
| 655 |
+
clicked = False
|
| 656 |
+
for selector in agree_selectors:
|
| 657 |
+
if self.wait_and_click(selector, timeout=5):
|
| 658 |
+
clicked = True
|
| 659 |
+
break
|
| 660 |
+
|
| 661 |
+
if not clicked:
|
| 662 |
+
raise Exception("无法点击同意按钮")
|
| 663 |
+
|
| 664 |
+
self.update_status(AccountStatus.WAITING_REDIRECT)
|
| 665 |
+
|
| 666 |
+
target_pattern = r'business\.gemini\.google/home/cid/[a-f0-9-]+\?csesidx=\d+'
|
| 667 |
+
if not self.wait_for_url_pattern(target_pattern, timeout=90):
|
| 668 |
+
current_url = self.page.url
|
| 669 |
+
if '/admin/create' in current_url:
|
| 670 |
+
time.sleep(15)
|
| 671 |
+
if not self.wait_for_url_pattern(target_pattern, timeout=120):
|
| 672 |
+
raise Exception("页面跳转超时")
|
| 673 |
+
else:
|
| 674 |
+
raise Exception("页面跳转超时")
|
| 675 |
+
|
| 676 |
+
time.sleep(3)
|
| 677 |
+
|
| 678 |
+
self.update_status(AccountStatus.COMPLETING)
|
| 679 |
+
self.handle_welcome_dialog()
|
| 680 |
+
time.sleep(2)
|
| 681 |
+
|
| 682 |
+
self.update_status(AccountStatus.EXTRACTING_DATA)
|
| 683 |
+
|
| 684 |
+
if self.extract_data():
|
| 685 |
+
# 设置创建时间
|
| 686 |
+
if not self.account.created_at:
|
| 687 |
+
self.account.created_at = datetime.now().isoformat()
|
| 688 |
+
self.update_status(AccountStatus.SUCCESS)
|
| 689 |
+
logger.info(f"[{self.worker_id}] 注册成功!")
|
| 690 |
+
return True
|
| 691 |
+
else:
|
| 692 |
+
raise Exception("未能获取完整数据")
|
| 693 |
+
|
| 694 |
+
except Exception as e:
|
| 695 |
+
error_msg = str(e)
|
| 696 |
+
logger.error(f"[{self.worker_id}] 注册失败: {error_msg}")
|
| 697 |
+
self.update_status(AccountStatus.FAILED, error_msg)
|
| 698 |
+
return False
|
| 699 |
+
|
| 700 |
+
def refresh_account(self) -> bool:
|
| 701 |
+
"""刷新账号Cookie"""
|
| 702 |
+
try:
|
| 703 |
+
self.update_status(AccountStatus.UPDATING)
|
| 704 |
+
logger.info(f"[{self.worker_id}] 正在刷新账号: {self.account.email}")
|
| 705 |
+
|
| 706 |
+
self.page.get('https://business.gemini.google')
|
| 707 |
+
|
| 708 |
+
email_input_selectors = [
|
| 709 |
+
'xpath://input[@id="email-input"]',
|
| 710 |
+
'xpath://input[@name="loginHint"]',
|
| 711 |
+
'#email-input',
|
| 712 |
+
]
|
| 713 |
+
logger.info(f"[{self.worker_id}] 正在等待邮箱输入框: {self.account.email}")
|
| 714 |
+
email_input_found = False
|
| 715 |
+
for selector in email_input_selectors:
|
| 716 |
+
if self.wait_for_element(selector, timeout=30):
|
| 717 |
+
email_input_found = True
|
| 718 |
+
break
|
| 719 |
+
|
| 720 |
+
if not email_input_found:
|
| 721 |
+
raise Exception("等待邮箱输入框超时")
|
| 722 |
+
|
| 723 |
+
time.sleep(1)
|
| 724 |
+
|
| 725 |
+
input_success = False
|
| 726 |
+
for selector in email_input_selectors:
|
| 727 |
+
if self.safe_input(selector, self.account.email):
|
| 728 |
+
input_success = True
|
| 729 |
+
break
|
| 730 |
+
|
| 731 |
+
if not input_success:
|
| 732 |
+
raise Exception("无法输入邮箱")
|
| 733 |
+
|
| 734 |
+
time.sleep(1)
|
| 735 |
+
|
| 736 |
+
logger.info(f"[{self.worker_id}] 正在点击继续按钮: {self.account.email}")
|
| 737 |
+
continue_selectors = [
|
| 738 |
+
'xpath://button[@id="log-in-button"]',
|
| 739 |
+
'xpath://button[contains(@aria-label, "使用邮箱继续")]',
|
| 740 |
+
'#log-in-button',
|
| 741 |
+
]
|
| 742 |
+
|
| 743 |
+
clicked = False
|
| 744 |
+
for selector in continue_selectors:
|
| 745 |
+
if self.wait_and_click(selector, timeout=5):
|
| 746 |
+
clicked = True
|
| 747 |
+
break
|
| 748 |
+
|
| 749 |
+
if not clicked:
|
| 750 |
+
raise Exception("无法点击继续按钮")
|
| 751 |
+
|
| 752 |
+
time.sleep(2)
|
| 753 |
+
|
| 754 |
+
email_manager = EmailManager(
|
| 755 |
+
self.account.email_config.get('worker_domain', ''),
|
| 756 |
+
self.account.email_config.get('email_domain', ''),
|
| 757 |
+
self.account.email_config.get('admin_password', '')
|
| 758 |
+
)
|
| 759 |
+
|
| 760 |
+
logger.info(f"[{self.worker_id}] 正在获取邮箱验证码: {self.account.email}")
|
| 761 |
+
verification_code = self.get_verification_code_with_retry(email_manager)
|
| 762 |
+
|
| 763 |
+
if not verification_code:
|
| 764 |
+
raise Exception("未收到验证码 (已重试)")
|
| 765 |
+
|
| 766 |
+
self.account.verification_code = verification_code
|
| 767 |
+
|
| 768 |
+
code_input_selectors = [
|
| 769 |
+
'xpath://input[@name="pinInput"]',
|
| 770 |
+
'xpath://input[contains(@aria-label, "验证码")]',
|
| 771 |
+
'input[name="pinInput"]',
|
| 772 |
+
]
|
| 773 |
+
logger.info(f"[{self.worker_id}] 正在输入验证码: {self.account.email}")
|
| 774 |
+
input_success = False
|
| 775 |
+
for selector in code_input_selectors:
|
| 776 |
+
if self.wait_and_input(selector, verification_code, timeout=10, description="验证码输入框"):
|
| 777 |
+
input_success = True
|
| 778 |
+
break
|
| 779 |
+
|
| 780 |
+
if not input_success:
|
| 781 |
+
raise Exception("无法输入验证码")
|
| 782 |
+
|
| 783 |
+
time.sleep(1)
|
| 784 |
+
|
| 785 |
+
if not self.click_verify_button():
|
| 786 |
+
raise Exception("无法点击验证按钮")
|
| 787 |
+
|
| 788 |
+
time.sleep(3)
|
| 789 |
+
|
| 790 |
+
# 检查验证码错误并重试
|
| 791 |
+
if self.page.ele('text:请输入验证码。', timeout=2):
|
| 792 |
+
logger.warning(f"[{self.worker_id}] 验证码失效,尝试重新发送...")
|
| 793 |
+
|
| 794 |
+
resend_selectors = [
|
| 795 |
+
'button[aria-label="重新发送验证码"]',
|
| 796 |
+
'xpath://button[contains(., "重新发送验证码")]'
|
| 797 |
+
]
|
| 798 |
+
|
| 799 |
+
resend_clicked = False
|
| 800 |
+
for sel in resend_selectors:
|
| 801 |
+
if self.wait_and_click(sel, timeout=5):
|
| 802 |
+
resend_clicked = True
|
| 803 |
+
break
|
| 804 |
+
|
| 805 |
+
if resend_clicked:
|
| 806 |
+
logger.info(f"[{self.worker_id}] 已点击重新发送,等待新验证码...")
|
| 807 |
+
time.sleep(10) # 等待邮件发送
|
| 808 |
+
|
| 809 |
+
verification_code = email_manager.check_verification_code(self.account.email)
|
| 810 |
+
if not verification_code:
|
| 811 |
+
raise Exception("重发后未收到验证码")
|
| 812 |
+
|
| 813 |
+
self.account.verification_code = verification_code
|
| 814 |
+
logger.info(f"[{self.worker_id}] 获取到新验证码: {verification_code}")
|
| 815 |
+
|
| 816 |
+
input_success = False
|
| 817 |
+
for selector in code_input_selectors:
|
| 818 |
+
if self.wait_and_input(selector, verification_code, timeout=10, description="验证码输入框"):
|
| 819 |
+
input_success = True
|
| 820 |
+
break
|
| 821 |
+
|
| 822 |
+
if input_success:
|
| 823 |
+
time.sleep(1)
|
| 824 |
+
if not self.click_verify_button():
|
| 825 |
+
raise Exception("无法点击验证按钮(重试)")
|
| 826 |
+
time.sleep(3)
|
| 827 |
+
else:
|
| 828 |
+
raise Exception("无法重新输入验证码")
|
| 829 |
+
else:
|
| 830 |
+
logger.warning(f"[{self.worker_id}] 未找到重新发送按钮")
|
| 831 |
+
|
| 832 |
+
logger.info(f"[{self.worker_id}] 正在等待跳转: {self.account.email}")
|
| 833 |
+
|
| 834 |
+
target_pattern = r'business\.gemini\.google/home/cid/[a-f0-9-]+\?csesidx=\d+'
|
| 835 |
+
if not self.wait_for_url_pattern(target_pattern, timeout=120):
|
| 836 |
+
raise Exception("页面跳转超时")
|
| 837 |
+
|
| 838 |
+
time.sleep(3)
|
| 839 |
+
|
| 840 |
+
self.handle_welcome_dialog()
|
| 841 |
+
time.sleep(2)
|
| 842 |
+
|
| 843 |
+
if self.extract_data():
|
| 844 |
+
self.account.updated_at = datetime.now().isoformat()
|
| 845 |
+
self.update_status(AccountStatus.SUCCESS)
|
| 846 |
+
logger.info(f"[{self.worker_id}] 刷新成功!")
|
| 847 |
+
return True
|
| 848 |
+
else:
|
| 849 |
+
raise Exception("未能获取完整数据")
|
| 850 |
+
|
| 851 |
+
except Exception as e:
|
| 852 |
+
error_msg = str(e)
|
| 853 |
+
logger.error(f"[{self.worker_id}] 刷新失败: {error_msg}")
|
| 854 |
+
self.update_status(AccountStatus.FAILED, error_msg)
|
| 855 |
+
return False
|
| 856 |
+
|
| 857 |
+
def run(self):
|
| 858 |
+
"""运行工作线程"""
|
| 859 |
+
success = False
|
| 860 |
+
|
| 861 |
+
try:
|
| 862 |
+
if not self.create_browser():
|
| 863 |
+
self.update_status(AccountStatus.FAILED, "创建浏览器失败")
|
| 864 |
+
return
|
| 865 |
+
|
| 866 |
+
if self.mode == "register":
|
| 867 |
+
success = self.register_account()
|
| 868 |
+
else:
|
| 869 |
+
success = self.refresh_account()
|
| 870 |
+
|
| 871 |
+
except Exception as e:
|
| 872 |
+
self.update_status(AccountStatus.FAILED, str(e))
|
| 873 |
+
finally:
|
| 874 |
+
self.close_browser()
|
| 875 |
+
if self.on_complete:
|
| 876 |
+
self.on_complete(self.worker_id, self.account.email, success)
|
| 877 |
+
|
| 878 |
+
def stop(self):
|
| 879 |
+
"""停止工作线程"""
|
| 880 |
+
self.is_running = False
|
| 881 |
+
self.close_browser()
|
config.py
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import glob
|
| 4 |
+
import random
|
| 5 |
+
import threading
|
| 6 |
+
import shutil
|
| 7 |
+
import platform
|
| 8 |
+
import subprocess
|
| 9 |
+
from typing import List, Dict, Optional
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
def setup_logging():
|
| 13 |
+
"""配置日志"""
|
| 14 |
+
logging.basicConfig(
|
| 15 |
+
level=logging.INFO,
|
| 16 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 17 |
+
datefmt='%Y-%m-%d %H:%M:%S'
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class BrowserPathFinder:
|
| 24 |
+
"""跨平台浏览器路径查找器"""
|
| 25 |
+
|
| 26 |
+
# 浏览器可执行文件名(按优先级排序)
|
| 27 |
+
BROWSER_EXECUTABLES = {
|
| 28 |
+
'windows': [
|
| 29 |
+
'chrome.exe',
|
| 30 |
+
'msedge.exe',
|
| 31 |
+
'chromium.exe',
|
| 32 |
+
'brave.exe',
|
| 33 |
+
'vivaldi.exe',
|
| 34 |
+
'opera.exe',
|
| 35 |
+
],
|
| 36 |
+
'darwin': [ # macOS
|
| 37 |
+
'Google Chrome',
|
| 38 |
+
'Chromium',
|
| 39 |
+
'Microsoft Edge',
|
| 40 |
+
'Brave Browser',
|
| 41 |
+
'Vivaldi',
|
| 42 |
+
'Opera',
|
| 43 |
+
],
|
| 44 |
+
'linux': [
|
| 45 |
+
'google-chrome',
|
| 46 |
+
'google-chrome-stable',
|
| 47 |
+
'google-chrome-beta',
|
| 48 |
+
'google-chrome-dev',
|
| 49 |
+
'google-chrome-unstable',
|
| 50 |
+
'chromium',
|
| 51 |
+
'chromium-browser',
|
| 52 |
+
'microsoft-edge',
|
| 53 |
+
'microsoft-edge-stable',
|
| 54 |
+
'microsoft-edge-beta',
|
| 55 |
+
'microsoft-edge-dev',
|
| 56 |
+
'brave-browser',
|
| 57 |
+
'brave-browser-stable',
|
| 58 |
+
'brave',
|
| 59 |
+
'vivaldi',
|
| 60 |
+
'vivaldi-stable',
|
| 61 |
+
'opera',
|
| 62 |
+
'opera-stable',
|
| 63 |
+
'chrome', # 通用名称
|
| 64 |
+
],
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
# 各平台常见安装路径
|
| 68 |
+
COMMON_PATHS = {
|
| 69 |
+
'windows': [
|
| 70 |
+
# Chrome
|
| 71 |
+
os.path.expandvars(r'%ProgramFiles%\Google\Chrome\Application'),
|
| 72 |
+
os.path.expandvars(r'%ProgramFiles(x86)%\Google\Chrome\Application'),
|
| 73 |
+
os.path.expandvars(r'%LocalAppData%\Google\Chrome\Application'),
|
| 74 |
+
# Edge
|
| 75 |
+
os.path.expandvars(r'%ProgramFiles%\Microsoft\Edge\Application'),
|
| 76 |
+
os.path.expandvars(r'%ProgramFiles(x86)%\Microsoft\Edge\Application'),
|
| 77 |
+
os.path.expandvars(r'%LocalAppData%\Microsoft\Edge\Application'),
|
| 78 |
+
# Brave
|
| 79 |
+
os.path.expandvars(r'%ProgramFiles%\BraveSoftware\Brave-Browser\Application'),
|
| 80 |
+
os.path.expandvars(r'%LocalAppData%\BraveSoftware\Brave-Browser\Application'),
|
| 81 |
+
# Chromium
|
| 82 |
+
os.path.expandvars(r'%LocalAppData%\Chromium\Application'),
|
| 83 |
+
# Vivaldi
|
| 84 |
+
os.path.expandvars(r'%LocalAppData%\Vivaldi\Application'),
|
| 85 |
+
# Opera
|
| 86 |
+
os.path.expandvars(r'%LocalAppData%\Programs\Opera'),
|
| 87 |
+
# Playwright
|
| 88 |
+
os.path.expandvars(r'%LocalAppData%\ms-playwright'),
|
| 89 |
+
os.path.expandvars(r'%UserProfile%\.cache\ms-playwright'),
|
| 90 |
+
# Puppeteer
|
| 91 |
+
os.path.expandvars(r'%LocalAppData%\puppeteer'),
|
| 92 |
+
os.path.expandvars(r'%UserProfile%\.cache\puppeteer'),
|
| 93 |
+
],
|
| 94 |
+
'darwin': [ # macOS
|
| 95 |
+
# 标准应用目录
|
| 96 |
+
'/Applications',
|
| 97 |
+
os.path.expanduser('~/Applications'),
|
| 98 |
+
# Chrome
|
| 99 |
+
'/Applications/Google Chrome.app/Contents/MacOS',
|
| 100 |
+
os.path.expanduser('~/Applications/Google Chrome.app/Contents/MacOS'),
|
| 101 |
+
# Chromium
|
| 102 |
+
'/Applications/Chromium.app/Contents/MacOS',
|
| 103 |
+
os.path.expanduser('~/Applications/Chromium.app/Contents/MacOS'),
|
| 104 |
+
# Edge
|
| 105 |
+
'/Applications/Microsoft Edge.app/Contents/MacOS',
|
| 106 |
+
os.path.expanduser('~/Applications/Microsoft Edge.app/Contents/MacOS'),
|
| 107 |
+
# Brave
|
| 108 |
+
'/Applications/Brave Browser.app/Contents/MacOS',
|
| 109 |
+
os.path.expanduser('~/Applications/Brave Browser.app/Contents/MacOS'),
|
| 110 |
+
# Vivaldi
|
| 111 |
+
'/Applications/Vivaldi.app/Contents/MacOS',
|
| 112 |
+
# Opera
|
| 113 |
+
'/Applications/Opera.app/Contents/MacOS',
|
| 114 |
+
# Homebrew
|
| 115 |
+
'/opt/homebrew/bin',
|
| 116 |
+
'/usr/local/bin',
|
| 117 |
+
'/opt/homebrew/Caskroom/google-chrome/latest/Google Chrome.app/Contents/MacOS',
|
| 118 |
+
# Playwright
|
| 119 |
+
os.path.expanduser('~/.cache/ms-playwright'),
|
| 120 |
+
os.path.expanduser('~/Library/Caches/ms-playwright'),
|
| 121 |
+
# Puppeteer
|
| 122 |
+
os.path.expanduser('~/.cache/puppeteer'),
|
| 123 |
+
],
|
| 124 |
+
'linux': [
|
| 125 |
+
# 标准路径
|
| 126 |
+
'/usr/bin',
|
| 127 |
+
'/usr/local/bin',
|
| 128 |
+
'/bin',
|
| 129 |
+
'/sbin',
|
| 130 |
+
# Chrome
|
| 131 |
+
'/opt/google/chrome',
|
| 132 |
+
'/opt/google/chrome-stable',
|
| 133 |
+
'/opt/google/chrome-beta',
|
| 134 |
+
'/opt/google/chrome-unstable',
|
| 135 |
+
# Chromium
|
| 136 |
+
'/opt/chromium',
|
| 137 |
+
'/opt/chromium-browser',
|
| 138 |
+
'/usr/lib/chromium',
|
| 139 |
+
'/usr/lib/chromium-browser',
|
| 140 |
+
'/usr/lib64/chromium-browser',
|
| 141 |
+
# Edge
|
| 142 |
+
'/opt/microsoft/msedge',
|
| 143 |
+
'/opt/microsoft/msedge-stable',
|
| 144 |
+
'/opt/microsoft/msedge-beta',
|
| 145 |
+
'/opt/microsoft/msedge-dev',
|
| 146 |
+
# Brave
|
| 147 |
+
'/opt/brave.com/brave',
|
| 148 |
+
'/opt/brave.com/brave-browser',
|
| 149 |
+
'/usr/lib/brave-browser',
|
| 150 |
+
# Snap 安装路径
|
| 151 |
+
'/snap/bin',
|
| 152 |
+
'/snap/chromium/current/usr/lib/chromium-browser',
|
| 153 |
+
'/snap/chromium/current/usr/lib/chromium',
|
| 154 |
+
'/var/lib/snapd/snap/bin',
|
| 155 |
+
# Flatpak 路径
|
| 156 |
+
'/var/lib/flatpak/exports/bin',
|
| 157 |
+
os.path.expanduser('~/.local/share/flatpak/exports/bin'),
|
| 158 |
+
# AppImage 路径
|
| 159 |
+
os.path.expanduser('~/Applications'),
|
| 160 |
+
os.path.expanduser('~/.local/bin'),
|
| 161 |
+
# Playwright
|
| 162 |
+
os.path.expanduser('~/.cache/ms-playwright'),
|
| 163 |
+
'/root/.cache/ms-playwright',
|
| 164 |
+
'/home/*/.cache/ms-playwright',
|
| 165 |
+
# Puppeteer
|
| 166 |
+
os.path.expanduser('~/.cache/puppeteer'),
|
| 167 |
+
'/root/.cache/puppeteer',
|
| 168 |
+
'/home/*/.cache/puppeteer',
|
| 169 |
+
# Docker 常见路径
|
| 170 |
+
'/headless-shell',
|
| 171 |
+
'/chrome',
|
| 172 |
+
'/chromium',
|
| 173 |
+
'/browser',
|
| 174 |
+
'/app/chrome',
|
| 175 |
+
'/app/chromium',
|
| 176 |
+
'/usr/share/chromium',
|
| 177 |
+
# NixOS
|
| 178 |
+
'/run/current-system/sw/bin',
|
| 179 |
+
os.path.expanduser('~/.nix-profile/bin'),
|
| 180 |
+
],
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
# Playwright/Puppeteer 浏览器的 glob 模式
|
| 184 |
+
GLOB_PATTERNS = [
|
| 185 |
+
# Playwright Chromium
|
| 186 |
+
os.path.expanduser('~/.cache/ms-playwright/chromium-*/chrome-linux/chrome'),
|
| 187 |
+
os.path.expanduser('~/.cache/ms-playwright/chromium-*/chrome-mac/Chromium.app/Contents/MacOS/Chromium'),
|
| 188 |
+
os.path.expanduser('~/.cache/ms-playwright/chromium-*/chrome-win/chrome.exe'),
|
| 189 |
+
'/root/.cache/ms-playwright/chromium-*/chrome-linux/chrome',
|
| 190 |
+
'/home/*/.cache/ms-playwright/chromium-*/chrome-linux/chrome',
|
| 191 |
+
# Playwright Chrome
|
| 192 |
+
os.path.expanduser('~/.cache/ms-playwright/chrome-*/chrome-linux/chrome'),
|
| 193 |
+
os.path.expanduser('~/.cache/ms-playwright/chrome-*/chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
|
| 194 |
+
'/root/.cache/ms-playwright/chrome-*/chrome-linux/chrome',
|
| 195 |
+
# Puppeteer
|
| 196 |
+
os.path.expanduser('~/.cache/puppeteer/chrome/*/chrome-linux64/chrome'),
|
| 197 |
+
os.path.expanduser('~/.cache/puppeteer/chrome/*/chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
|
| 198 |
+
os.path.expanduser('~/.cache/puppeteer/chrome/*/chrome-win64/chrome.exe'),
|
| 199 |
+
'/root/.cache/puppeteer/chrome/*/chrome-linux64/chrome',
|
| 200 |
+
'/home/*/.cache/puppeteer/chrome/*/chrome-linux64/chrome',
|
| 201 |
+
# Windows Playwright
|
| 202 |
+
os.path.expandvars(r'%LocalAppData%\ms-playwright\chromium-*\chrome-win\chrome.exe'),
|
| 203 |
+
os.path.expandvars(r'%UserProfile%\.cache\ms-playwright\chromium-*\chrome-win\chrome.exe'),
|
| 204 |
+
]
|
| 205 |
+
|
| 206 |
+
@classmethod
|
| 207 |
+
def get_platform(cls) -> str:
|
| 208 |
+
"""获取当前平台"""
|
| 209 |
+
system = platform.system().lower()
|
| 210 |
+
if system == 'windows':
|
| 211 |
+
return 'windows'
|
| 212 |
+
elif system == 'darwin':
|
| 213 |
+
return 'darwin'
|
| 214 |
+
else:
|
| 215 |
+
return 'linux' # Linux 和其他 Unix-like 系统
|
| 216 |
+
|
| 217 |
+
@classmethod
|
| 218 |
+
def find_browser(cls) -> Optional[str]:
|
| 219 |
+
"""
|
| 220 |
+
查找可用的浏览器路径
|
| 221 |
+
返回找到的第一个可执行浏览器路径,未找到返回None
|
| 222 |
+
"""
|
| 223 |
+
current_platform = cls.get_platform()
|
| 224 |
+
|
| 225 |
+
# 1. 首先检查环境变量
|
| 226 |
+
for env_var in ['BROWSER_PATH', 'CHROME_PATH', 'CHROMIUM_PATH', 'CHROME_EXECUTABLE_PATH']:
|
| 227 |
+
env_path = os.getenv(env_var)
|
| 228 |
+
if env_path and cls._is_valid_executable(env_path):
|
| 229 |
+
return env_path
|
| 230 |
+
|
| 231 |
+
# 2. 使用 which/where 命令查找
|
| 232 |
+
executables = cls.BROWSER_EXECUTABLES.get(current_platform, cls.BROWSER_EXECUTABLES['linux'])
|
| 233 |
+
for name in executables:
|
| 234 |
+
path = shutil.which(name)
|
| 235 |
+
if path and cls._is_valid_executable(path):
|
| 236 |
+
return path
|
| 237 |
+
|
| 238 |
+
# 3. 检查 glob 模式(Playwright/Puppeteer)
|
| 239 |
+
for pattern in cls.GLOB_PATTERNS:
|
| 240 |
+
matches = glob.glob(pattern)
|
| 241 |
+
for match in sorted(matches, reverse=True): # 优先使用最新版本
|
| 242 |
+
if cls._is_valid_executable(match):
|
| 243 |
+
return match
|
| 244 |
+
|
| 245 |
+
# 4. 遍历常见路径
|
| 246 |
+
common_paths = cls.COMMON_PATHS.get(current_platform, cls.COMMON_PATHS['linux'])
|
| 247 |
+
for base_path in common_paths:
|
| 248 |
+
# 处理包含通配符的路径
|
| 249 |
+
if '*' in base_path:
|
| 250 |
+
expanded_paths = glob.glob(base_path)
|
| 251 |
+
else:
|
| 252 |
+
expanded_paths = [base_path]
|
| 253 |
+
|
| 254 |
+
for expanded_path in expanded_paths:
|
| 255 |
+
if not os.path.isdir(expanded_path):
|
| 256 |
+
continue
|
| 257 |
+
|
| 258 |
+
for name in executables:
|
| 259 |
+
full_path = os.path.join(expanded_path, name)
|
| 260 |
+
if cls._is_valid_executable(full_path):
|
| 261 |
+
return full_path
|
| 262 |
+
|
| 263 |
+
# 5. macOS 特殊处理:检查 .app 包
|
| 264 |
+
if current_platform == 'darwin':
|
| 265 |
+
found = cls._find_macos_app()
|
| 266 |
+
if found:
|
| 267 |
+
return found
|
| 268 |
+
|
| 269 |
+
# 6. 递归搜索特定目录
|
| 270 |
+
search_roots = {
|
| 271 |
+
'windows': [
|
| 272 |
+
os.path.expandvars(r'%ProgramFiles%'),
|
| 273 |
+
os.path.expandvars(r'%LocalAppData%'),
|
| 274 |
+
],
|
| 275 |
+
'darwin': [
|
| 276 |
+
'/Applications',
|
| 277 |
+
os.path.expanduser('~/Applications'),
|
| 278 |
+
],
|
| 279 |
+
'linux': [
|
| 280 |
+
'/opt',
|
| 281 |
+
'/usr/lib',
|
| 282 |
+
'/snap',
|
| 283 |
+
os.path.expanduser('~/.cache'),
|
| 284 |
+
],
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
for search_root in search_roots.get(current_platform, []):
|
| 288 |
+
if os.path.isdir(search_root):
|
| 289 |
+
found = cls._recursive_search(search_root, executables, max_depth=4)
|
| 290 |
+
if found:
|
| 291 |
+
return found
|
| 292 |
+
|
| 293 |
+
# 7. Linux: 使用 find 命令(最后手段)
|
| 294 |
+
if current_platform == 'linux':
|
| 295 |
+
found = cls._find_with_command(executables)
|
| 296 |
+
if found:
|
| 297 |
+
return found
|
| 298 |
+
|
| 299 |
+
return None
|
| 300 |
+
|
| 301 |
+
@classmethod
|
| 302 |
+
def _is_valid_executable(cls, path: str) -> bool:
|
| 303 |
+
"""检查路径是否是有效的可执行文件"""
|
| 304 |
+
if not path:
|
| 305 |
+
return False
|
| 306 |
+
|
| 307 |
+
# 处理 Windows 路径
|
| 308 |
+
path = os.path.normpath(path)
|
| 309 |
+
|
| 310 |
+
if not os.path.isfile(path):
|
| 311 |
+
return False
|
| 312 |
+
|
| 313 |
+
# Windows 不需要检查执行权限
|
| 314 |
+
if cls.get_platform() == 'windows':
|
| 315 |
+
return path.lower().endswith('.exe')
|
| 316 |
+
|
| 317 |
+
return os.access(path, os.X_OK)
|
| 318 |
+
|
| 319 |
+
@classmethod
|
| 320 |
+
def _find_macos_app(cls) -> Optional[str]:
|
| 321 |
+
"""macOS 特殊处理:查找 .app 包"""
|
| 322 |
+
app_dirs = ['/Applications', os.path.expanduser('~/Applications')]
|
| 323 |
+
app_names = [
|
| 324 |
+
('Google Chrome.app', 'Contents/MacOS/Google Chrome'),
|
| 325 |
+
('Chromium.app', 'Contents/MacOS/Chromium'),
|
| 326 |
+
('Microsoft Edge.app', 'Contents/MacOS/Microsoft Edge'),
|
| 327 |
+
('Brave Browser.app', 'Contents/MacOS/Brave Browser'),
|
| 328 |
+
('Vivaldi.app', 'Contents/MacOS/Vivaldi'),
|
| 329 |
+
('Opera.app', 'Contents/MacOS/Opera'),
|
| 330 |
+
]
|
| 331 |
+
|
| 332 |
+
for app_dir in app_dirs:
|
| 333 |
+
for app_name, executable_path in app_names:
|
| 334 |
+
full_path = os.path.join(app_dir, app_name, executable_path)
|
| 335 |
+
if cls._is_valid_executable(full_path):
|
| 336 |
+
return full_path
|
| 337 |
+
|
| 338 |
+
return None
|
| 339 |
+
|
| 340 |
+
@classmethod
|
| 341 |
+
def _recursive_search(cls, root: str, executables: List[str], max_depth: int = 3, current_depth: int = 0) -> Optional[str]:
|
| 342 |
+
"""递归搜索目录"""
|
| 343 |
+
if current_depth >= max_depth:
|
| 344 |
+
return None
|
| 345 |
+
|
| 346 |
+
# 跳过的目录
|
| 347 |
+
skip_dirs = {'.git', 'node_modules', '__pycache__', 'cache', 'tmp', 'temp',
|
| 348 |
+
'logs', 'log', '.npm', '.yarn', 'site-packages', 'dist-packages'}
|
| 349 |
+
|
| 350 |
+
try:
|
| 351 |
+
for entry in os.scandir(root):
|
| 352 |
+
try:
|
| 353 |
+
if entry.is_file(follow_symlinks=True):
|
| 354 |
+
if entry.name in executables or entry.name.lower() in [e.lower() for e in executables]:
|
| 355 |
+
if cls._is_valid_executable(entry.path):
|
| 356 |
+
return entry.path
|
| 357 |
+
elif entry.is_dir(follow_symlinks=False):
|
| 358 |
+
if entry.name.lower() in skip_dirs:
|
| 359 |
+
continue
|
| 360 |
+
found = cls._recursive_search(entry.path, executables, max_depth, current_depth + 1)
|
| 361 |
+
if found:
|
| 362 |
+
return found
|
| 363 |
+
except (PermissionError, OSError):
|
| 364 |
+
continue
|
| 365 |
+
except (PermissionError, OSError):
|
| 366 |
+
pass
|
| 367 |
+
|
| 368 |
+
return None
|
| 369 |
+
|
| 370 |
+
@classmethod
|
| 371 |
+
def _find_with_command(cls, executables: List[str]) -> Optional[str]:
|
| 372 |
+
"""使用系统命令查找浏览器(Linux)"""
|
| 373 |
+
for name in executables[:5]: # 只检查前几个常见的
|
| 374 |
+
try:
|
| 375 |
+
result = subprocess.run(
|
| 376 |
+
['find', '/', '-maxdepth', '6', '-name', name, '-type', 'f', '-executable', '-print', '-quit'],
|
| 377 |
+
capture_output=True,
|
| 378 |
+
text=True,
|
| 379 |
+
timeout=30,
|
| 380 |
+
stderr=subprocess.DEVNULL
|
| 381 |
+
)
|
| 382 |
+
if result.returncode == 0 and result.stdout.strip():
|
| 383 |
+
path = result.stdout.strip().split('\n')[0]
|
| 384 |
+
if cls._is_valid_executable(path):
|
| 385 |
+
return path
|
| 386 |
+
except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
|
| 387 |
+
continue
|
| 388 |
+
|
| 389 |
+
return None
|
| 390 |
+
|
| 391 |
+
@classmethod
|
| 392 |
+
def get_browser_info(cls) -> Dict:
|
| 393 |
+
"""获取浏览器信息"""
|
| 394 |
+
path = cls.find_browser()
|
| 395 |
+
info = {
|
| 396 |
+
'found': path is not None,
|
| 397 |
+
'path': path,
|
| 398 |
+
'version': None,
|
| 399 |
+
'platform': cls.get_platform()
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
return info
|
| 403 |
+
|
| 404 |
+
@classmethod
|
| 405 |
+
def find_all_browsers(cls) -> List[Dict]:
|
| 406 |
+
"""查找所有可用的浏览器"""
|
| 407 |
+
browsers = []
|
| 408 |
+
seen_paths = set()
|
| 409 |
+
current_platform = cls.get_platform()
|
| 410 |
+
executables = cls.BROWSER_EXECUTABLES.get(current_platform, cls.BROWSER_EXECUTABLES['linux'])
|
| 411 |
+
|
| 412 |
+
# 查找所有可能的浏览器
|
| 413 |
+
for name in executables:
|
| 414 |
+
path = shutil.which(name)
|
| 415 |
+
if path and path not in seen_paths and cls._is_valid_executable(path):
|
| 416 |
+
seen_paths.add(path)
|
| 417 |
+
browsers.append({'name': name, 'path': path})
|
| 418 |
+
|
| 419 |
+
# 检查 glob 模式
|
| 420 |
+
for pattern in cls.GLOB_PATTERNS:
|
| 421 |
+
matches = glob.glob(pattern)
|
| 422 |
+
for match in matches:
|
| 423 |
+
if match not in seen_paths and cls._is_valid_executable(match):
|
| 424 |
+
seen_paths.add(match)
|
| 425 |
+
browsers.append({'name': os.path.basename(match), 'path': match})
|
| 426 |
+
|
| 427 |
+
return browsers
|
| 428 |
+
class Config:
|
| 429 |
+
"""配置管理器"""
|
| 430 |
+
|
| 431 |
+
def __init__(self):
|
| 432 |
+
self._lock = threading.Lock()
|
| 433 |
+
self._browser_path = None
|
| 434 |
+
self._user_agent = None
|
| 435 |
+
self._max_workers = 1
|
| 436 |
+
self._headless = True
|
| 437 |
+
self._email_configs = []
|
| 438 |
+
self._browser_fingerprint = {}
|
| 439 |
+
self._load_from_env()
|
| 440 |
+
self._detect_browser()
|
| 441 |
+
|
| 442 |
+
def _detect_browser(self):
|
| 443 |
+
"""检测浏览器路径"""
|
| 444 |
+
import os
|
| 445 |
+
|
| 446 |
+
# 优先使用环境变量
|
| 447 |
+
env_path = os.getenv('BROWSER_PATH') or os.getenv('CHROME_PATH')
|
| 448 |
+
if env_path and os.path.isfile(env_path):
|
| 449 |
+
self._browser_path = env_path
|
| 450 |
+
logger.info(f"[Config] 使用环境变量指定的浏览器: {env_path}")
|
| 451 |
+
return
|
| 452 |
+
|
| 453 |
+
# 自动检测
|
| 454 |
+
# 自动检测
|
| 455 |
+
logger.info(f"[Config] 正在自动检测浏览器路径 (平台: {BrowserPathFinder.get_platform()})...")
|
| 456 |
+
browser_info = BrowserPathFinder.get_browser_info()
|
| 457 |
+
|
| 458 |
+
if browser_info['found']:
|
| 459 |
+
self._browser_path = browser_info['path']
|
| 460 |
+
if browser_info['found']:
|
| 461 |
+
self._browser_path = browser_info['path']
|
| 462 |
+
logger.info(f"[Config] ✓ 检测到浏览器: {browser_info['path']}")
|
| 463 |
+
if browser_info['version']:
|
| 464 |
+
logger.info(f"[Config] ✓ 浏览器版本: {browser_info['version']}")
|
| 465 |
+
else:
|
| 466 |
+
logger.warning("[Config] ✗ 警告: 未检测到可用的浏览器!")
|
| 467 |
+
logger.warning("[Config] 请确保已安装 Chrome/Chromium/Edge 或设置 BROWSER_PATH 环境变量")
|
| 468 |
+
logger.warning("[Config] 支持的浏览器: Chrome, Chromium, Edge, Brave, Vivaldi, Opera")
|
| 469 |
+
|
| 470 |
+
# 列出所有检查过的路径(调试用)
|
| 471 |
+
all_browsers = BrowserPathFinder.find_all_browsers()
|
| 472 |
+
if all_browsers:
|
| 473 |
+
logger.info(f"[Config] 找到以下浏览器但可能不兼容:")
|
| 474 |
+
for b in all_browsers:
|
| 475 |
+
logger.info(f"[Config] - {b['name']}: {b['path']}")
|
| 476 |
+
|
| 477 |
+
def get_browser_path(self) -> Optional[str]:
|
| 478 |
+
"""获取浏览器路径"""
|
| 479 |
+
with self._lock:
|
| 480 |
+
return self._browser_path
|
| 481 |
+
|
| 482 |
+
def set_browser_path(self, path: str):
|
| 483 |
+
"""设置浏览器路径"""
|
| 484 |
+
with self._lock:
|
| 485 |
+
if os.path.isfile(path):
|
| 486 |
+
self._browser_path = path
|
| 487 |
+
else:
|
| 488 |
+
raise ValueError(f"无效的浏览器路径: {path}")
|
| 489 |
+
|
| 490 |
+
def _load_from_env(self):
|
| 491 |
+
"""从环境变量加载配置"""
|
| 492 |
+
# User-Agent
|
| 493 |
+
self._user_agent = os.getenv(
|
| 494 |
+
'USER_AGENT',
|
| 495 |
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36'
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
+
# 最大工作线程数
|
| 499 |
+
self._max_workers = int(os.getenv('MAX_WORKERS', '1'))
|
| 500 |
+
|
| 501 |
+
# 无头模式
|
| 502 |
+
self._headless = os.getenv('HEADLESS', 'true').lower() == 'true'
|
| 503 |
+
|
| 504 |
+
# 邮箱配置(支持多个,用分号分隔)
|
| 505 |
+
self._email_configs = []
|
| 506 |
+
|
| 507 |
+
worker_domains = os.getenv('WORKER_DOMAINS', '').split(';')
|
| 508 |
+
email_domains = os.getenv('EMAIL_DOMAINS', '').split(';')
|
| 509 |
+
admin_passwords = os.getenv('ADMIN_PASSWORDS', '').split(';')
|
| 510 |
+
|
| 511 |
+
# 确保三个列表长度一致
|
| 512 |
+
max_len = max(len(worker_domains), len(email_domains), len(admin_passwords))
|
| 513 |
+
|
| 514 |
+
for i in range(max_len):
|
| 515 |
+
worker = worker_domains[i].strip() if i < len(worker_domains) else ''
|
| 516 |
+
email = email_domains[i].strip() if i < len(email_domains) else ''
|
| 517 |
+
password = admin_passwords[i].strip() if i < len(admin_passwords) else ''
|
| 518 |
+
|
| 519 |
+
if worker and email and password:
|
| 520 |
+
self._email_configs.append({
|
| 521 |
+
'worker_domain': worker,
|
| 522 |
+
'email_domain': email,
|
| 523 |
+
'admin_password': password
|
| 524 |
+
})
|
| 525 |
+
|
| 526 |
+
# 浏览器指纹配置
|
| 527 |
+
self._browser_fingerprint = {
|
| 528 |
+
'window_size': os.getenv('WINDOW_SIZE', '1920x1080'),
|
| 529 |
+
'timezone': os.getenv('TIMEZONE', 'Asia/Shanghai'),
|
| 530 |
+
'locale': os.getenv('LOCALE', 'zh-CN'),
|
| 531 |
+
'platform': os.getenv('PLATFORM', 'Win64'),
|
| 532 |
+
'color_depth': int(os.getenv('COLOR_DEPTH', '24')),
|
| 533 |
+
'device_memory': int(os.getenv('DEVICE_MEMORY', '8')),
|
| 534 |
+
'hardware_concurrency': int(os.getenv('HARDWARE_CONCURRENCY', '8'))
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
def get_user_agent(self) -> str:
|
| 538 |
+
with self._lock:
|
| 539 |
+
return self._user_agent
|
| 540 |
+
|
| 541 |
+
def set_user_agent(self, ua: str):
|
| 542 |
+
with self._lock:
|
| 543 |
+
self._user_agent = ua
|
| 544 |
+
|
| 545 |
+
def get_max_workers(self) -> int:
|
| 546 |
+
with self._lock:
|
| 547 |
+
return self._max_workers
|
| 548 |
+
|
| 549 |
+
def set_max_workers(self, count: int):
|
| 550 |
+
with self._lock:
|
| 551 |
+
self._max_workers = max(1, min(count, 10))
|
| 552 |
+
|
| 553 |
+
def get_headless(self) -> bool:
|
| 554 |
+
with self._lock:
|
| 555 |
+
return self._headless
|
| 556 |
+
|
| 557 |
+
def set_headless(self, headless: bool):
|
| 558 |
+
with self._lock:
|
| 559 |
+
self._headless = headless
|
| 560 |
+
|
| 561 |
+
def get_email_configs(self) -> List[Dict]:
|
| 562 |
+
with self._lock:
|
| 563 |
+
return self._email_configs.copy()
|
| 564 |
+
|
| 565 |
+
def get_email_configs_safe(self) -> List[Dict]:
|
| 566 |
+
"""获取邮箱配置(隐藏密码)"""
|
| 567 |
+
with self._lock:
|
| 568 |
+
return [
|
| 569 |
+
{
|
| 570 |
+
'worker_domain': c['worker_domain'],
|
| 571 |
+
'email_domain': c['email_domain'],
|
| 572 |
+
'admin_password': '***'
|
| 573 |
+
}
|
| 574 |
+
for c in self._email_configs
|
| 575 |
+
]
|
| 576 |
+
|
| 577 |
+
def get_random_email_config(self) -> Optional[Dict]:
|
| 578 |
+
with self._lock:
|
| 579 |
+
if self._email_configs:
|
| 580 |
+
return random.choice(self._email_configs)
|
| 581 |
+
return None
|
| 582 |
+
|
| 583 |
+
def add_email_config(self, worker_domain: str, email_domain: str, admin_password: str):
|
| 584 |
+
with self._lock:
|
| 585 |
+
self._email_configs.append({
|
| 586 |
+
'worker_domain': worker_domain,
|
| 587 |
+
'email_domain': email_domain,
|
| 588 |
+
'admin_password': admin_password
|
| 589 |
+
})
|
| 590 |
+
|
| 591 |
+
def update_email_config(self, index: int, worker_domain: str = None,
|
| 592 |
+
email_domain: str = None, admin_password: str = None):
|
| 593 |
+
with self._lock:
|
| 594 |
+
if 0 <= index < len(self._email_configs):
|
| 595 |
+
if worker_domain:
|
| 596 |
+
self._email_configs[index]['worker_domain'] = worker_domain
|
| 597 |
+
if email_domain:
|
| 598 |
+
self._email_configs[index]['email_domain'] = email_domain
|
| 599 |
+
if admin_password:
|
| 600 |
+
self._email_configs[index]['admin_password'] = admin_password
|
| 601 |
+
else:
|
| 602 |
+
raise IndexError("配置索引不存在")
|
| 603 |
+
|
| 604 |
+
def delete_email_config(self, index: int):
|
| 605 |
+
with self._lock:
|
| 606 |
+
if 0 <= index < len(self._email_configs):
|
| 607 |
+
del self._email_configs[index]
|
| 608 |
+
else:
|
| 609 |
+
raise IndexError("配置索引不存在")
|
| 610 |
+
|
| 611 |
+
def get_browser_fingerprint(self) -> Dict:
|
| 612 |
+
with self._lock:
|
| 613 |
+
return self._browser_fingerprint.copy()
|
| 614 |
+
|
| 615 |
+
def set_browser_fingerprint(self, fingerprint: Dict):
|
| 616 |
+
with self._lock:
|
| 617 |
+
self._browser_fingerprint.update(fingerprint)
|
email_manager.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import time
|
| 3 |
+
import random
|
| 4 |
+
import string
|
| 5 |
+
from typing import Optional, Tuple
|
| 6 |
+
from datetime import datetime, timezone, timedelta
|
| 7 |
+
from email.utils import parsedate_to_datetime
|
| 8 |
+
|
| 9 |
+
import requests
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class EmailManager:
|
| 16 |
+
"""邮箱管理器"""
|
| 17 |
+
|
| 18 |
+
def __init__(self, worker_domain: str, email_domain: str, admin_password: str):
|
| 19 |
+
self.worker_domain = worker_domain
|
| 20 |
+
self.email_domain = email_domain
|
| 21 |
+
self.admin_password = admin_password
|
| 22 |
+
|
| 23 |
+
@staticmethod
|
| 24 |
+
def generate_random_name() -> str:
|
| 25 |
+
"""生成随机邮箱名称"""
|
| 26 |
+
letters1 = ''.join(random.choices(string.ascii_lowercase, k=4))
|
| 27 |
+
numbers = ''.join(random.choices(string.digits, k=2))
|
| 28 |
+
letters2 = ''.join(random.choices(string.ascii_lowercase, k=3))
|
| 29 |
+
return letters1 + numbers + letters2
|
| 30 |
+
|
| 31 |
+
def create_email(self, username: str = "") -> Tuple[Optional[str], Optional[str]]:
|
| 32 |
+
"""创建邮箱"""
|
| 33 |
+
try:
|
| 34 |
+
name = username if username else self.generate_random_name()
|
| 35 |
+
|
| 36 |
+
res = requests.post(
|
| 37 |
+
f"https://{self.worker_domain}/admin/new_address",
|
| 38 |
+
json={
|
| 39 |
+
"enablePrefix": True,
|
| 40 |
+
"name": name,
|
| 41 |
+
"domain": self.email_domain,
|
| 42 |
+
},
|
| 43 |
+
headers={
|
| 44 |
+
'x-admin-auth': self.admin_password,
|
| 45 |
+
"Content-Type": "application/json"
|
| 46 |
+
},
|
| 47 |
+
timeout=30
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
if res.status_code == 200:
|
| 51 |
+
data = res.json()
|
| 52 |
+
return data.get('jwt'), data.get('address')
|
| 53 |
+
else:
|
| 54 |
+
return None, None
|
| 55 |
+
except Exception as e:
|
| 56 |
+
logger.error(f"创建邮箱出错: {e}")
|
| 57 |
+
return None, None
|
| 58 |
+
|
| 59 |
+
def check_verification_code(self, email: str, max_retries: int = 15, interval: float = 3.0) -> Optional[str]:
|
| 60 |
+
"""检查验证码邮件"""
|
| 61 |
+
for attempt in range(max_retries):
|
| 62 |
+
try:
|
| 63 |
+
api_url = f"https://{self.worker_domain}/admin/mails"
|
| 64 |
+
res = requests.get(
|
| 65 |
+
api_url,
|
| 66 |
+
params={"limit": 5, "offset": 0, "address": email},
|
| 67 |
+
headers={
|
| 68 |
+
"x-admin-auth": self.admin_password,
|
| 69 |
+
"Content-Type": "application/json"
|
| 70 |
+
},
|
| 71 |
+
timeout=30
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
if res.status_code == 200:
|
| 75 |
+
data = res.json()
|
| 76 |
+
|
| 77 |
+
if data.get('results') and len(data['results']) > 0:
|
| 78 |
+
email_data = data['results'][0]
|
| 79 |
+
raw_content = email_data.get('raw', '')
|
| 80 |
+
|
| 81 |
+
# 检查邮件时间
|
| 82 |
+
try:
|
| 83 |
+
# 提取 Received 头中的时间
|
| 84 |
+
received_match = re.search(r'Received:.*?;\s*(.*?)\r\n', raw_content, re.DOTALL)
|
| 85 |
+
if received_match:
|
| 86 |
+
date_str = received_match.group(1).strip()
|
| 87 |
+
email_time = parsedate_to_datetime(date_str)
|
| 88 |
+
current_time = datetime.now(timezone.utc)
|
| 89 |
+
|
| 90 |
+
# 如果邮件时间与当前时间相差超过1分钟,则认为是旧邮件
|
| 91 |
+
if (current_time - email_time) > timedelta(minutes=1):
|
| 92 |
+
logger.warning(f"忽略过期邮件 (时间: {email_time}, 当前: {current_time})")
|
| 93 |
+
time.sleep(interval)
|
| 94 |
+
continue
|
| 95 |
+
except Exception as e:
|
| 96 |
+
logger.warning(f"解析邮件时间失败: {e}")
|
| 97 |
+
|
| 98 |
+
cleaned_content = raw_content.replace('=\r\n', '').replace('=\n', '').replace('=3D', '=')
|
| 99 |
+
|
| 100 |
+
patterns = [
|
| 101 |
+
r'class=["\']?verification-code["\']?[^>]*>([A-Z0-9]{6})</span>',
|
| 102 |
+
r'verification-code[^>]*>([A-Z0-9]{6})<',
|
| 103 |
+
r'>([A-Z0-9]{6})</span>',
|
| 104 |
+
r'font-size:\s*28px[^>]*>([A-Z0-9]{6})<',
|
| 105 |
+
]
|
| 106 |
+
|
| 107 |
+
for pattern in patterns:
|
| 108 |
+
match = re.search(pattern, cleaned_content, re.IGNORECASE | re.DOTALL)
|
| 109 |
+
if match:
|
| 110 |
+
code = match.group(1).upper()
|
| 111 |
+
if len(code) == 6 and code.isalnum():
|
| 112 |
+
logger.info(f"找到验证码: {code}")
|
| 113 |
+
return code
|
| 114 |
+
|
| 115 |
+
time.sleep(interval)
|
| 116 |
+
|
| 117 |
+
except Exception as e:
|
| 118 |
+
logger.error(f"检查验证码出错: {e}")
|
| 119 |
+
time.sleep(interval)
|
| 120 |
+
|
| 121 |
+
return None
|