Upload 18 files
Browse files- core/account.py +13 -1
- core/base_task_service.py +165 -0
- core/config.py +43 -10
- core/duckmail_client.py +272 -0
- core/gemini_automation.py +400 -0
- core/gemini_automation_uc.py +390 -0
- core/login_service.py +261 -0
- core/mail_utils.py +33 -0
- core/microsoft_mail_client.py +176 -0
- core/register_service.py +150 -0
core/account.py
CHANGED
|
@@ -38,6 +38,12 @@ class AccountConfig:
|
|
| 38 |
config_id: str
|
| 39 |
expires_at: Optional[str] = None # 账户过期时间 (格式: "2025-12-23 10:59:21")
|
| 40 |
disabled: bool = False # 手动禁用状态
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
def get_remaining_hours(self) -> Optional[float]:
|
| 43 |
"""计算账户剩余小时数"""
|
|
@@ -440,7 +446,13 @@ def load_multi_account_config(
|
|
| 440 |
csesidx=acc["csesidx"],
|
| 441 |
config_id=acc["config_id"],
|
| 442 |
expires_at=acc.get("expires_at"),
|
| 443 |
-
disabled=acc.get("disabled", False) # 读取手动禁用状态,默认为
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
)
|
| 445 |
|
| 446 |
# 检查账户是否已过期(已过期也加载到管理面板)
|
|
|
|
| 38 |
config_id: str
|
| 39 |
expires_at: Optional[str] = None # 账户过期时间 (格式: "2025-12-23 10:59:21")
|
| 40 |
disabled: bool = False # 手动禁用状态
|
| 41 |
+
mail_provider: Optional[str] = None
|
| 42 |
+
mail_address: Optional[str] = None
|
| 43 |
+
mail_password: Optional[str] = None
|
| 44 |
+
mail_client_id: Optional[str] = None
|
| 45 |
+
mail_refresh_token: Optional[str] = None
|
| 46 |
+
mail_tenant: Optional[str] = None
|
| 47 |
|
| 48 |
def get_remaining_hours(self) -> Optional[float]:
|
| 49 |
"""计算账户剩余小时数"""
|
|
|
|
| 446 |
csesidx=acc["csesidx"],
|
| 447 |
config_id=acc["config_id"],
|
| 448 |
expires_at=acc.get("expires_at"),
|
| 449 |
+
disabled=acc.get("disabled", False), # 读取手动禁用状态,默认为False
|
| 450 |
+
mail_provider=acc.get("mail_provider"),
|
| 451 |
+
mail_address=acc.get("mail_address"),
|
| 452 |
+
mail_password=acc.get("mail_password") or acc.get("email_password"),
|
| 453 |
+
mail_client_id=acc.get("mail_client_id"),
|
| 454 |
+
mail_refresh_token=acc.get("mail_refresh_token"),
|
| 455 |
+
mail_tenant=acc.get("mail_tenant"),
|
| 456 |
)
|
| 457 |
|
| 458 |
# 检查账户是否已过期(已过期也加载到管理面板)
|
core/base_task_service.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
基础任务服务类
|
| 3 |
+
提供通用的任务管理、日志记录和账户更新功能
|
| 4 |
+
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
import logging
|
| 7 |
+
import threading
|
| 8 |
+
import time
|
| 9 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from enum import Enum
|
| 12 |
+
from typing import Any, Callable, Dict, Generic, List, Optional, TypeVar
|
| 13 |
+
|
| 14 |
+
from core.account import update_accounts_config
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("gemini.base_task")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class TaskStatus(str, Enum):
|
| 20 |
+
"""任务状态枚举"""
|
| 21 |
+
PENDING = "pending"
|
| 22 |
+
RUNNING = "running"
|
| 23 |
+
SUCCESS = "success"
|
| 24 |
+
FAILED = "failed"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class BaseTask:
|
| 29 |
+
"""基础任务数据类"""
|
| 30 |
+
id: str
|
| 31 |
+
status: TaskStatus = TaskStatus.PENDING
|
| 32 |
+
progress: int = 0
|
| 33 |
+
success_count: int = 0
|
| 34 |
+
fail_count: int = 0
|
| 35 |
+
created_at: float = field(default_factory=time.time)
|
| 36 |
+
finished_at: Optional[float] = None
|
| 37 |
+
results: List[Dict[str, Any]] = field(default_factory=list)
|
| 38 |
+
error: Optional[str] = None
|
| 39 |
+
logs: List[Dict[str, str]] = field(default_factory=list)
|
| 40 |
+
|
| 41 |
+
def to_dict(self) -> dict:
|
| 42 |
+
"""转换为字典"""
|
| 43 |
+
return {
|
| 44 |
+
"id": self.id,
|
| 45 |
+
"status": self.status.value,
|
| 46 |
+
"progress": self.progress,
|
| 47 |
+
"success_count": self.success_count,
|
| 48 |
+
"fail_count": self.fail_count,
|
| 49 |
+
"created_at": self.created_at,
|
| 50 |
+
"finished_at": self.finished_at,
|
| 51 |
+
"results": self.results,
|
| 52 |
+
"error": self.error,
|
| 53 |
+
"logs": self.logs,
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
T = TypeVar('T', bound=BaseTask)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class BaseTaskService(Generic[T]):
|
| 61 |
+
"""
|
| 62 |
+
基础任务服务类
|
| 63 |
+
提供通用的任务管理、日志记录和账户更新功能
|
| 64 |
+
"""
|
| 65 |
+
|
| 66 |
+
def __init__(
|
| 67 |
+
self,
|
| 68 |
+
multi_account_mgr,
|
| 69 |
+
http_client,
|
| 70 |
+
user_agent: str,
|
| 71 |
+
account_failure_threshold: int,
|
| 72 |
+
rate_limit_cooldown_seconds: int,
|
| 73 |
+
session_cache_ttl_seconds: int,
|
| 74 |
+
global_stats_provider: Callable[[], dict],
|
| 75 |
+
set_multi_account_mgr: Optional[Callable[[Any], None]] = None,
|
| 76 |
+
log_prefix: str = "TASK",
|
| 77 |
+
) -> None:
|
| 78 |
+
"""
|
| 79 |
+
初始化基础任务服务
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
multi_account_mgr: 多账户管理器
|
| 83 |
+
http_client: HTTP客户端
|
| 84 |
+
user_agent: 用户代理
|
| 85 |
+
account_failure_threshold: 账户失败阈值
|
| 86 |
+
rate_limit_cooldown_seconds: 速率限制冷却秒数
|
| 87 |
+
session_cache_ttl_seconds: 会话缓存TTL秒数
|
| 88 |
+
global_stats_provider: 全局统计提供者
|
| 89 |
+
set_multi_account_mgr: 设置多账户管理器的回调
|
| 90 |
+
log_prefix: 日志前缀
|
| 91 |
+
"""
|
| 92 |
+
self._executor = ThreadPoolExecutor(max_workers=1)
|
| 93 |
+
self._tasks: Dict[str, T] = {}
|
| 94 |
+
self._current_task_id: Optional[str] = None
|
| 95 |
+
self._lock = asyncio.Lock()
|
| 96 |
+
self._log_lock = threading.Lock()
|
| 97 |
+
self._log_prefix = log_prefix
|
| 98 |
+
|
| 99 |
+
self.multi_account_mgr = multi_account_mgr
|
| 100 |
+
self.http_client = http_client
|
| 101 |
+
self.user_agent = user_agent
|
| 102 |
+
self.account_failure_threshold = account_failure_threshold
|
| 103 |
+
self.rate_limit_cooldown_seconds = rate_limit_cooldown_seconds
|
| 104 |
+
self.session_cache_ttl_seconds = session_cache_ttl_seconds
|
| 105 |
+
self.global_stats_provider = global_stats_provider
|
| 106 |
+
self.set_multi_account_mgr = set_multi_account_mgr
|
| 107 |
+
|
| 108 |
+
def get_task(self, task_id: str) -> Optional[T]:
|
| 109 |
+
"""获取指定任务"""
|
| 110 |
+
return self._tasks.get(task_id)
|
| 111 |
+
|
| 112 |
+
def get_current_task(self) -> Optional[T]:
|
| 113 |
+
"""获取当前任务"""
|
| 114 |
+
if not self._current_task_id:
|
| 115 |
+
return None
|
| 116 |
+
return self._tasks.get(self._current_task_id)
|
| 117 |
+
|
| 118 |
+
def _append_log(self, task: T, level: str, message: str) -> None:
|
| 119 |
+
"""
|
| 120 |
+
添加日志到任务
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
task: 任务对象
|
| 124 |
+
level: 日志级别 (info, warning, error)
|
| 125 |
+
message: 日志消息
|
| 126 |
+
"""
|
| 127 |
+
entry = {
|
| 128 |
+
"time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
| 129 |
+
"level": level,
|
| 130 |
+
"message": message,
|
| 131 |
+
}
|
| 132 |
+
with self._log_lock:
|
| 133 |
+
task.logs.append(entry)
|
| 134 |
+
if len(task.logs) > 200:
|
| 135 |
+
task.logs = task.logs[-200:]
|
| 136 |
+
|
| 137 |
+
log_message = f"[{self._log_prefix}] {message}"
|
| 138 |
+
if level == "warning":
|
| 139 |
+
logger.warning(log_message)
|
| 140 |
+
elif level == "error":
|
| 141 |
+
logger.error(log_message)
|
| 142 |
+
else:
|
| 143 |
+
logger.info(log_message)
|
| 144 |
+
|
| 145 |
+
def _apply_accounts_update(self, accounts_data: list) -> None:
|
| 146 |
+
"""
|
| 147 |
+
应用账户更新
|
| 148 |
+
|
| 149 |
+
Args:
|
| 150 |
+
accounts_data: 账户数据列表
|
| 151 |
+
"""
|
| 152 |
+
global_stats = self.global_stats_provider() or {}
|
| 153 |
+
new_mgr = update_accounts_config(
|
| 154 |
+
accounts_data,
|
| 155 |
+
self.multi_account_mgr,
|
| 156 |
+
self.http_client,
|
| 157 |
+
self.user_agent,
|
| 158 |
+
self.account_failure_threshold,
|
| 159 |
+
self.rate_limit_cooldown_seconds,
|
| 160 |
+
self.session_cache_ttl_seconds,
|
| 161 |
+
global_stats,
|
| 162 |
+
)
|
| 163 |
+
self.multi_account_mgr = new_mgr
|
| 164 |
+
if self.set_multi_account_mgr:
|
| 165 |
+
self.set_multi_account_mgr(new_mgr)
|
core/config.py
CHANGED
|
@@ -2,13 +2,12 @@
|
|
| 2 |
统一配置管理系统
|
| 3 |
|
| 4 |
优先级规则:
|
| 5 |
-
1. 环境变量(
|
| 6 |
-
2. YAML 配置文件
|
| 7 |
-
3. 默认值(最低优先级)
|
| 8 |
|
| 9 |
配置分类:
|
| 10 |
-
- 安全配置:仅从环境变量读取,不可热更新(ADMIN_KEY,
|
| 11 |
-
- 业务配置:
|
| 12 |
"""
|
| 13 |
|
| 14 |
import os
|
|
@@ -24,6 +23,21 @@ from core import storage
|
|
| 24 |
# 加载 .env 文件
|
| 25 |
load_dotenv()
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
# ==================== 配置模型定义 ====================
|
| 29 |
|
|
@@ -32,6 +46,13 @@ class BasicConfig(BaseModel):
|
|
| 32 |
api_key: str = Field(default="", description="API访问密钥(留空则公开访问)")
|
| 33 |
base_url: str = Field(default="", description="服务器URL(留空则自动检测)")
|
| 34 |
proxy: str = Field(default="", description="代理地址")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
class ImageGenerationConfig(BaseModel):
|
|
@@ -107,7 +128,7 @@ class ConfigManager:
|
|
| 107 |
|
| 108 |
优先级规则:
|
| 109 |
1. 安全配置(ADMIN_KEY, SESSION_SECRET_KEY):仅从环境变量读取
|
| 110 |
-
2. 其他配置:YAML >
|
| 111 |
"""
|
| 112 |
# 1. 加载 YAML 配置
|
| 113 |
yaml_data = self._load_yaml()
|
|
@@ -118,12 +139,24 @@ class ConfigManager:
|
|
| 118 |
session_secret_key=os.getenv("SESSION_SECRET_KEY", self._generate_secret())
|
| 119 |
)
|
| 120 |
|
| 121 |
-
# 3. 加载基础配置(YAML >
|
| 122 |
basic_data = yaml_data.get("basic", {})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
basic_config = BasicConfig(
|
| 124 |
-
api_key=basic_data.get("api_key") or
|
| 125 |
-
base_url=basic_data.get("base_url") or
|
| 126 |
-
proxy=basic_data.get("proxy") or
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
)
|
| 128 |
|
| 129 |
# 4. 加载其他配置(从 YAML)
|
|
|
|
| 2 |
统一配置管理系统
|
| 3 |
|
| 4 |
优先级规则:
|
| 5 |
+
1. 安全配置:仅环境变量(ADMIN_KEY, SESSION_SECRET_KEY)
|
| 6 |
+
2. 业务配置:YAML 配置文件 > 默认值
|
|
|
|
| 7 |
|
| 8 |
配置分类:
|
| 9 |
+
- 安全配置:仅从环境变量读取,不可热更新(ADMIN_KEY, SESSION_SECRET_KEY)
|
| 10 |
+
- 业务配置:仅从 YAML 读取,支持热更新(API_KEY, BASE_URL, PROXY, 重试策略等)
|
| 11 |
"""
|
| 12 |
|
| 13 |
import os
|
|
|
|
| 23 |
# 加载 .env 文件
|
| 24 |
load_dotenv()
|
| 25 |
|
| 26 |
+
def _parse_bool(value, default: bool) -> bool:
|
| 27 |
+
if isinstance(value, bool):
|
| 28 |
+
return value
|
| 29 |
+
if value is None:
|
| 30 |
+
return default
|
| 31 |
+
if isinstance(value, (int, float)):
|
| 32 |
+
return value != 0
|
| 33 |
+
if isinstance(value, str):
|
| 34 |
+
lowered = value.strip().lower()
|
| 35 |
+
if lowered in ("1", "true", "yes", "y", "on"):
|
| 36 |
+
return True
|
| 37 |
+
if lowered in ("0", "false", "no", "n", "off"):
|
| 38 |
+
return False
|
| 39 |
+
return default
|
| 40 |
+
|
| 41 |
|
| 42 |
# ==================== 配置模型定义 ====================
|
| 43 |
|
|
|
|
| 46 |
api_key: str = Field(default="", description="API访问密钥(留空则公开访问)")
|
| 47 |
base_url: str = Field(default="", description="服务器URL(留空则自动检测)")
|
| 48 |
proxy: str = Field(default="", description="代理地址")
|
| 49 |
+
duckmail_base_url: str = Field(default="https://api.duckmail.sbs", description="DuckMail API地址")
|
| 50 |
+
duckmail_api_key: str = Field(default="", description="DuckMail API key")
|
| 51 |
+
duckmail_verify_ssl: bool = Field(default=True, description="DuckMail SSL校验")
|
| 52 |
+
browser_headless: bool = Field(default=True, description="自动化浏览器无头模式")
|
| 53 |
+
refresh_window_hours: int = Field(default=1, ge=0, le=24, description="过期刷新窗口(小时)")
|
| 54 |
+
register_default_count: int = Field(default=1, ge=1, le=30, description="默认注册数量")
|
| 55 |
+
register_domain: str = Field(default="", description="默认注册域名(推荐)")
|
| 56 |
|
| 57 |
|
| 58 |
class ImageGenerationConfig(BaseModel):
|
|
|
|
| 128 |
|
| 129 |
优先级规则:
|
| 130 |
1. 安全配置(ADMIN_KEY, SESSION_SECRET_KEY):仅从环境变量读取
|
| 131 |
+
2. 其他配置:YAML > 默认值
|
| 132 |
"""
|
| 133 |
# 1. 加载 YAML 配置
|
| 134 |
yaml_data = self._load_yaml()
|
|
|
|
| 139 |
session_secret_key=os.getenv("SESSION_SECRET_KEY", self._generate_secret())
|
| 140 |
)
|
| 141 |
|
| 142 |
+
# 3. 加载基础配置(YAML > 默认值)
|
| 143 |
basic_data = yaml_data.get("basic", {})
|
| 144 |
+
refresh_window_raw = basic_data.get("refresh_window_hours", 1)
|
| 145 |
+
register_default_raw = basic_data.get("register_default_count", 1)
|
| 146 |
+
register_domain_raw = basic_data.get("register_domain", "")
|
| 147 |
+
duckmail_api_key_raw = basic_data.get("duckmail_api_key", "")
|
| 148 |
+
|
| 149 |
basic_config = BasicConfig(
|
| 150 |
+
api_key=basic_data.get("api_key") or "",
|
| 151 |
+
base_url=basic_data.get("base_url") or "",
|
| 152 |
+
proxy=basic_data.get("proxy") or "",
|
| 153 |
+
duckmail_base_url=basic_data.get("duckmail_base_url") or "https://api.duckmail.sbs",
|
| 154 |
+
duckmail_api_key=str(duckmail_api_key_raw or "").strip(),
|
| 155 |
+
duckmail_verify_ssl=_parse_bool(basic_data.get("duckmail_verify_ssl"), True),
|
| 156 |
+
browser_headless=_parse_bool(basic_data.get("browser_headless"), True),
|
| 157 |
+
refresh_window_hours=int(refresh_window_raw),
|
| 158 |
+
register_default_count=int(register_default_raw),
|
| 159 |
+
register_domain=str(register_domain_raw or "").strip(),
|
| 160 |
)
|
| 161 |
|
| 162 |
# 4. 加载其他配置(从 YAML)
|
core/duckmail_client.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import random
|
| 3 |
+
import string
|
| 4 |
+
import time
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
import requests
|
| 8 |
+
|
| 9 |
+
from core.mail_utils import extract_verification_code
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class DuckMailClient:
|
| 13 |
+
"""DuckMail客户端"""
|
| 14 |
+
|
| 15 |
+
def __init__(
|
| 16 |
+
self,
|
| 17 |
+
base_url: str = "https://api.duckmail.sbs",
|
| 18 |
+
proxy: str = "",
|
| 19 |
+
verify_ssl: bool = True,
|
| 20 |
+
api_key: str = "",
|
| 21 |
+
log_callback=None,
|
| 22 |
+
) -> None:
|
| 23 |
+
self.base_url = base_url.rstrip("/")
|
| 24 |
+
self.verify_ssl = verify_ssl
|
| 25 |
+
self.proxies = {"http": proxy, "https": proxy} if proxy else None
|
| 26 |
+
self.api_key = api_key.strip()
|
| 27 |
+
self.log_callback = log_callback
|
| 28 |
+
|
| 29 |
+
self.email: Optional[str] = None
|
| 30 |
+
self.password: Optional[str] = None
|
| 31 |
+
self.account_id: Optional[str] = None
|
| 32 |
+
self.token: Optional[str] = None
|
| 33 |
+
|
| 34 |
+
def set_credentials(self, email: str, password: str) -> None:
|
| 35 |
+
self.email = email
|
| 36 |
+
self.password = password
|
| 37 |
+
|
| 38 |
+
def _request(self, method: str, url: str, **kwargs) -> requests.Response:
|
| 39 |
+
"""发送请求并打印详细日志"""
|
| 40 |
+
headers = kwargs.pop("headers", None) or {}
|
| 41 |
+
if self.api_key and "Authorization" not in headers:
|
| 42 |
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
| 43 |
+
kwargs["headers"] = headers
|
| 44 |
+
self._log("info", f"[HTTP] {method} {url}")
|
| 45 |
+
if "json" in kwargs:
|
| 46 |
+
self._log("info", f"[HTTP] Request body: {kwargs['json']}")
|
| 47 |
+
|
| 48 |
+
try:
|
| 49 |
+
res = requests.request(
|
| 50 |
+
method,
|
| 51 |
+
url,
|
| 52 |
+
proxies=self.proxies,
|
| 53 |
+
verify=self.verify_ssl,
|
| 54 |
+
timeout=kwargs.pop("timeout", 15),
|
| 55 |
+
**kwargs,
|
| 56 |
+
)
|
| 57 |
+
self._log("info", f"[HTTP] Response: {res.status_code}")
|
| 58 |
+
log_body = os.getenv("DUCKMAIL_LOG_BODY", "").strip().lower() in ("1", "true", "yes", "y", "on")
|
| 59 |
+
if res.content and (log_body or res.status_code >= 400):
|
| 60 |
+
try:
|
| 61 |
+
self._log("info", f"[HTTP] Response body: {res.text[:500]}")
|
| 62 |
+
except Exception:
|
| 63 |
+
pass
|
| 64 |
+
return res
|
| 65 |
+
except Exception as e:
|
| 66 |
+
self._log("error", f"[HTTP] Request failed: {e}")
|
| 67 |
+
raise
|
| 68 |
+
|
| 69 |
+
def register_account(self, domain: Optional[str] = None) -> bool:
|
| 70 |
+
"""注册新邮箱账号"""
|
| 71 |
+
# 获取域名
|
| 72 |
+
if not domain:
|
| 73 |
+
domain = self._get_domain()
|
| 74 |
+
self._log("info", f"DuckMail domain: {domain}")
|
| 75 |
+
|
| 76 |
+
# 生成随机邮箱和密码
|
| 77 |
+
rand = "".join(random.choices(string.ascii_lowercase + string.digits, k=10))
|
| 78 |
+
timestamp = str(int(time.time()))[-4:]
|
| 79 |
+
self.email = f"t{timestamp}{rand}@{domain}"
|
| 80 |
+
self.password = f"Pwd{rand}{timestamp}"
|
| 81 |
+
self._log("info", f"DuckMail register email: {self.email}")
|
| 82 |
+
|
| 83 |
+
try:
|
| 84 |
+
res = self._request(
|
| 85 |
+
"POST",
|
| 86 |
+
f"{self.base_url}/accounts",
|
| 87 |
+
json={"address": self.email, "password": self.password},
|
| 88 |
+
)
|
| 89 |
+
if res.status_code in (200, 201):
|
| 90 |
+
data = res.json() if res.content else {}
|
| 91 |
+
self.account_id = data.get("id")
|
| 92 |
+
self._log("info", "DuckMail register success")
|
| 93 |
+
return True
|
| 94 |
+
except Exception as e:
|
| 95 |
+
self._log("error", f"DuckMail register failed: {e}")
|
| 96 |
+
return False
|
| 97 |
+
|
| 98 |
+
self._log("error", "DuckMail register failed")
|
| 99 |
+
return False
|
| 100 |
+
|
| 101 |
+
def login(self) -> bool:
|
| 102 |
+
"""登录获取token"""
|
| 103 |
+
if not self.email or not self.password:
|
| 104 |
+
return False
|
| 105 |
+
|
| 106 |
+
try:
|
| 107 |
+
res = self._request(
|
| 108 |
+
"POST",
|
| 109 |
+
f"{self.base_url}/token",
|
| 110 |
+
json={"address": self.email, "password": self.password},
|
| 111 |
+
)
|
| 112 |
+
if res.status_code == 200:
|
| 113 |
+
data = res.json() if res.content else {}
|
| 114 |
+
token = data.get("token")
|
| 115 |
+
if token:
|
| 116 |
+
self.token = token
|
| 117 |
+
self._log("info", f"DuckMail login success, token: {token[:20]}...")
|
| 118 |
+
return True
|
| 119 |
+
except Exception as e:
|
| 120 |
+
self._log("error", f"DuckMail login failed: {e}")
|
| 121 |
+
return False
|
| 122 |
+
|
| 123 |
+
self._log("error", "DuckMail login failed")
|
| 124 |
+
return False
|
| 125 |
+
|
| 126 |
+
def fetch_verification_code(self, since_time=None) -> Optional[str]:
|
| 127 |
+
"""获取验证码"""
|
| 128 |
+
if not self.token:
|
| 129 |
+
if not self.login():
|
| 130 |
+
return None
|
| 131 |
+
|
| 132 |
+
try:
|
| 133 |
+
# 获取邮件列表
|
| 134 |
+
res = self._request(
|
| 135 |
+
"GET",
|
| 136 |
+
f"{self.base_url}/messages",
|
| 137 |
+
headers={"Authorization": f"Bearer {self.token}"},
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
if res.status_code != 200:
|
| 141 |
+
self._log("warning", f"DuckMail messages request failed: {res.status_code}")
|
| 142 |
+
return None
|
| 143 |
+
|
| 144 |
+
data = res.json() if res.content else {}
|
| 145 |
+
messages = data.get("hydra:member", [])
|
| 146 |
+
self._log("info", f"DuckMail messages count: {len(messages)}")
|
| 147 |
+
|
| 148 |
+
if not messages:
|
| 149 |
+
return None
|
| 150 |
+
|
| 151 |
+
# 获取第一封邮件的详情(最新的邮件)
|
| 152 |
+
msg_id = messages[0].get("id")
|
| 153 |
+
msg_created_at = messages[0].get("createdAt", "unknown")
|
| 154 |
+
if not msg_id:
|
| 155 |
+
return None
|
| 156 |
+
|
| 157 |
+
self._log("info", f"DuckMail fetching message: {msg_id} (created: {msg_created_at})")
|
| 158 |
+
|
| 159 |
+
# 检查邮件时间是否在 since_time 之后
|
| 160 |
+
self._log("info", f"DuckMail since_time check: since_time={since_time}, msg_created_at={msg_created_at}")
|
| 161 |
+
if since_time and msg_created_at != "unknown":
|
| 162 |
+
try:
|
| 163 |
+
from dateutil import parser
|
| 164 |
+
email_time = parser.parse(msg_created_at)
|
| 165 |
+
# 移除时区信息进行比较
|
| 166 |
+
if email_time.tzinfo:
|
| 167 |
+
email_time = email_time.replace(tzinfo=None)
|
| 168 |
+
if since_time.tzinfo:
|
| 169 |
+
since_time = since_time.replace(tzinfo=None)
|
| 170 |
+
|
| 171 |
+
self._log("info", f"DuckMail comparing times: email={email_time}, since={since_time}")
|
| 172 |
+
if email_time < since_time:
|
| 173 |
+
self._log("info", f"DuckMail email too old: {email_time} < {since_time}")
|
| 174 |
+
return None
|
| 175 |
+
else:
|
| 176 |
+
self._log("info", f"DuckMail email is new: {email_time} >= {since_time}")
|
| 177 |
+
except Exception as e:
|
| 178 |
+
self._log("warning", f"DuckMail time comparison failed: {e}")
|
| 179 |
+
else:
|
| 180 |
+
self._log("info", f"DuckMail skipping time check (since_time={since_time}, msg_created_at={msg_created_at})")
|
| 181 |
+
|
| 182 |
+
detail = self._request(
|
| 183 |
+
"GET",
|
| 184 |
+
f"{self.base_url}/messages/{msg_id}",
|
| 185 |
+
headers={"Authorization": f"Bearer {self.token}"},
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
if detail.status_code != 200:
|
| 189 |
+
return None
|
| 190 |
+
|
| 191 |
+
payload = detail.json() if detail.content else {}
|
| 192 |
+
subject = payload.get("subject", "")
|
| 193 |
+
created_at = payload.get("createdAt", "unknown")
|
| 194 |
+
self._log("info", f"DuckMail message subject: {subject} (created: {created_at})")
|
| 195 |
+
|
| 196 |
+
# 获取邮件内容(text可能是字符串,html可能是列表)
|
| 197 |
+
text_content = payload.get("text") or ""
|
| 198 |
+
html_content = payload.get("html") or ""
|
| 199 |
+
|
| 200 |
+
# 如果html是列表,转换为字符串
|
| 201 |
+
if isinstance(html_content, list):
|
| 202 |
+
html_content = "".join(str(item) for item in html_content)
|
| 203 |
+
if isinstance(text_content, list):
|
| 204 |
+
text_content = "".join(str(item) for item in text_content)
|
| 205 |
+
|
| 206 |
+
content = text_content + html_content
|
| 207 |
+
self._log("info", f"DuckMail email content length: {len(content)} chars")
|
| 208 |
+
code = extract_verification_code(content)
|
| 209 |
+
if code:
|
| 210 |
+
self._log("info", f"DuckMail extracted code: {code}")
|
| 211 |
+
else:
|
| 212 |
+
self._log("warning", f"DuckMail no code found in message")
|
| 213 |
+
# 打印部分内容用于调试
|
| 214 |
+
preview = content[:200] if content else "(empty)"
|
| 215 |
+
self._log("warning", f"DuckMail content preview: {preview}")
|
| 216 |
+
return code
|
| 217 |
+
|
| 218 |
+
except Exception as e:
|
| 219 |
+
self._log("error", f"DuckMail fetch code failed: {e}")
|
| 220 |
+
return None
|
| 221 |
+
|
| 222 |
+
def poll_for_code(
|
| 223 |
+
self,
|
| 224 |
+
timeout: int = 120,
|
| 225 |
+
interval: int = 4,
|
| 226 |
+
since_time=None,
|
| 227 |
+
) -> Optional[str]:
|
| 228 |
+
"""轮询获取验证码"""
|
| 229 |
+
if not self.token:
|
| 230 |
+
if not self.login():
|
| 231 |
+
self._log("error", "DuckMail token missing")
|
| 232 |
+
return None
|
| 233 |
+
|
| 234 |
+
self._log("info", "DuckMail polling for code")
|
| 235 |
+
max_retries = timeout // interval
|
| 236 |
+
|
| 237 |
+
for i in range(1, max_retries + 1):
|
| 238 |
+
self._log("info", f"DuckMail attempt {i}/{max_retries}")
|
| 239 |
+
code = self.fetch_verification_code(since_time=since_time)
|
| 240 |
+
if code:
|
| 241 |
+
self._log("info", f"DuckMail code found: {code}")
|
| 242 |
+
return code
|
| 243 |
+
|
| 244 |
+
if i < max_retries:
|
| 245 |
+
time.sleep(interval)
|
| 246 |
+
|
| 247 |
+
self._log("error", "DuckMail code timeout")
|
| 248 |
+
return None
|
| 249 |
+
|
| 250 |
+
def _get_domain(self) -> str:
|
| 251 |
+
"""获取可用域名"""
|
| 252 |
+
try:
|
| 253 |
+
res = self._request("GET", f"{self.base_url}/domains")
|
| 254 |
+
if res.status_code == 200:
|
| 255 |
+
data = res.json() if res.content else {}
|
| 256 |
+
members = data.get("hydra:member", [])
|
| 257 |
+
if members:
|
| 258 |
+
return members[0].get("domain") or "duck.com"
|
| 259 |
+
except Exception:
|
| 260 |
+
pass
|
| 261 |
+
return "duck.com"
|
| 262 |
+
|
| 263 |
+
def _log(self, level: str, message: str) -> None:
|
| 264 |
+
if self.log_callback:
|
| 265 |
+
try:
|
| 266 |
+
self.log_callback(level, message)
|
| 267 |
+
except Exception:
|
| 268 |
+
pass
|
| 269 |
+
|
| 270 |
+
@staticmethod
|
| 271 |
+
def _extract_code(text: str) -> Optional[str]:
|
| 272 |
+
return extract_verification_code(text)
|
core/gemini_automation.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gemini自动化登录模块(用于新账号注册)
|
| 3 |
+
"""
|
| 4 |
+
import random
|
| 5 |
+
import string
|
| 6 |
+
import time
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from urllib.parse import quote
|
| 10 |
+
|
| 11 |
+
from DrissionPage import ChromiumPage, ChromiumOptions
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# 常量
|
| 15 |
+
AUTH_HOME_URL = "https://auth.business.gemini.google/"
|
| 16 |
+
DEFAULT_XSRF_TOKEN = "KdLRzKwwBTD5wo8nUollAbY6cW0"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class GeminiAutomation:
|
| 20 |
+
"""Gemini自动化登录"""
|
| 21 |
+
|
| 22 |
+
def __init__(
|
| 23 |
+
self,
|
| 24 |
+
user_agent: str = "",
|
| 25 |
+
proxy: str = "",
|
| 26 |
+
headless: bool = True,
|
| 27 |
+
timeout: int = 60,
|
| 28 |
+
log_callback=None,
|
| 29 |
+
) -> None:
|
| 30 |
+
self.user_agent = user_agent or self._get_ua()
|
| 31 |
+
self.proxy = proxy
|
| 32 |
+
self.headless = headless
|
| 33 |
+
self.timeout = timeout
|
| 34 |
+
self.log_callback = log_callback
|
| 35 |
+
|
| 36 |
+
def login_and_extract(self, email: str, mail_client) -> dict:
|
| 37 |
+
"""执行登录并提取配置"""
|
| 38 |
+
page = None
|
| 39 |
+
try:
|
| 40 |
+
page = self._create_page()
|
| 41 |
+
return self._run_flow(page, email, mail_client)
|
| 42 |
+
except Exception as exc:
|
| 43 |
+
self._log("error", f"automation error: {exc}")
|
| 44 |
+
return {"success": False, "error": str(exc)}
|
| 45 |
+
finally:
|
| 46 |
+
if page:
|
| 47 |
+
try:
|
| 48 |
+
page.quit()
|
| 49 |
+
except Exception:
|
| 50 |
+
pass
|
| 51 |
+
|
| 52 |
+
def _create_page(self) -> ChromiumPage:
|
| 53 |
+
"""创建浏览器页面"""
|
| 54 |
+
options = ChromiumOptions()
|
| 55 |
+
options.set_argument("--no-sandbox")
|
| 56 |
+
options.set_argument("--disable-setuid-sandbox")
|
| 57 |
+
options.set_argument("--disable-blink-features=AutomationControlled")
|
| 58 |
+
options.set_argument("--window-size=1280,800")
|
| 59 |
+
options.set_user_agent(self.user_agent)
|
| 60 |
+
|
| 61 |
+
if self.proxy:
|
| 62 |
+
options.set_argument(f"--proxy-server={self.proxy}")
|
| 63 |
+
|
| 64 |
+
if self.headless:
|
| 65 |
+
# 使用新版无头模式,更接近真实浏览器
|
| 66 |
+
options.set_argument("--headless=new")
|
| 67 |
+
options.set_argument("--disable-gpu")
|
| 68 |
+
options.set_argument("--disable-dev-shm-usage")
|
| 69 |
+
options.set_argument("--no-first-run")
|
| 70 |
+
options.set_argument("--disable-extensions")
|
| 71 |
+
# 反检测参数
|
| 72 |
+
options.set_argument("--disable-infobars")
|
| 73 |
+
options.set_argument("--lang=zh-CN,zh")
|
| 74 |
+
options.set_argument("--enable-features=NetworkService,NetworkServiceInProcess")
|
| 75 |
+
|
| 76 |
+
options.auto_port()
|
| 77 |
+
page = ChromiumPage(options)
|
| 78 |
+
page.set.timeouts(self.timeout)
|
| 79 |
+
|
| 80 |
+
# 反检测:注入脚本隐藏自动化特征
|
| 81 |
+
if self.headless:
|
| 82 |
+
try:
|
| 83 |
+
page.run_cdp("Page.addScriptToEvaluateOnNewDocument", source="""
|
| 84 |
+
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
|
| 85 |
+
Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3, 4, 5]});
|
| 86 |
+
Object.defineProperty(navigator, 'languages', {get: () => ['zh-CN', 'zh', 'en']});
|
| 87 |
+
window.chrome = {runtime: {}};
|
| 88 |
+
""")
|
| 89 |
+
except Exception:
|
| 90 |
+
pass
|
| 91 |
+
|
| 92 |
+
return page
|
| 93 |
+
|
| 94 |
+
def _run_flow(self, page, email: str, mail_client) -> dict:
|
| 95 |
+
"""执行登录流程"""
|
| 96 |
+
|
| 97 |
+
# Step 1: 导航到首页并设置 Cookie
|
| 98 |
+
self._log("info", f"navigating to login page for {email}")
|
| 99 |
+
|
| 100 |
+
page.get(AUTH_HOME_URL, timeout=self.timeout)
|
| 101 |
+
time.sleep(2)
|
| 102 |
+
|
| 103 |
+
# 设置两个关键 Cookie
|
| 104 |
+
try:
|
| 105 |
+
page.set.cookies({
|
| 106 |
+
"name": "__Host-AP_SignInXsrf",
|
| 107 |
+
"value": DEFAULT_XSRF_TOKEN,
|
| 108 |
+
"url": AUTH_HOME_URL,
|
| 109 |
+
"path": "/",
|
| 110 |
+
"secure": True,
|
| 111 |
+
})
|
| 112 |
+
# 添加 reCAPTCHA Cookie
|
| 113 |
+
page.set.cookies({
|
| 114 |
+
"name": "_GRECAPTCHA",
|
| 115 |
+
"value": "09ABCL...",
|
| 116 |
+
"url": "https://google.com",
|
| 117 |
+
"path": "/",
|
| 118 |
+
"secure": True,
|
| 119 |
+
})
|
| 120 |
+
except Exception as e:
|
| 121 |
+
self._log("warning", f"failed to set cookies: {e}")
|
| 122 |
+
|
| 123 |
+
login_hint = quote(email, safe="")
|
| 124 |
+
login_url = f"https://auth.business.gemini.google/login/email?continueUrl=https%3A%2F%2Fbusiness.gemini.google%2F&loginHint={login_hint}&xsrfToken={DEFAULT_XSRF_TOKEN}"
|
| 125 |
+
page.get(login_url, timeout=self.timeout)
|
| 126 |
+
time.sleep(5)
|
| 127 |
+
|
| 128 |
+
# Step 2: 检查当前页面状态
|
| 129 |
+
current_url = page.url
|
| 130 |
+
has_business_params = "business.gemini.google" in current_url and "csesidx=" in current_url and "/cid/" in current_url
|
| 131 |
+
|
| 132 |
+
if has_business_params:
|
| 133 |
+
return self._extract_config(page, email)
|
| 134 |
+
|
| 135 |
+
# Step 3: 尝试触发发送验证码
|
| 136 |
+
if not self._click_send_code_button(page):
|
| 137 |
+
self._log("error", "send code button not found")
|
| 138 |
+
self._save_screenshot(page, "send_code_button_missing")
|
| 139 |
+
return {"success": False, "error": "send code button not found"}
|
| 140 |
+
|
| 141 |
+
# Step 4: 等待验证码输入框出现
|
| 142 |
+
code_input = self._wait_for_code_input(page)
|
| 143 |
+
if not code_input:
|
| 144 |
+
self._log("error", "code input not found")
|
| 145 |
+
self._save_screenshot(page, "code_input_missing")
|
| 146 |
+
return {"success": False, "error": "code input not found"}
|
| 147 |
+
|
| 148 |
+
time.sleep(3)
|
| 149 |
+
|
| 150 |
+
# Step 5: 轮询邮件获取验证码
|
| 151 |
+
self._log("info", "polling for verification code")
|
| 152 |
+
code = mail_client.poll_for_code(timeout=40, interval=4)
|
| 153 |
+
|
| 154 |
+
if not code:
|
| 155 |
+
self._log("error", "verification code timeout")
|
| 156 |
+
self._save_screenshot(page, "code_timeout")
|
| 157 |
+
return {"success": False, "error": "verification code timeout"}
|
| 158 |
+
|
| 159 |
+
self._log("info", f"code received: {code}")
|
| 160 |
+
|
| 161 |
+
# Step 6: 输入验证码并提交
|
| 162 |
+
code_input = page.ele("css:input[jsname='ovqh0b']", timeout=3) or \
|
| 163 |
+
page.ele("css:input[type='tel']", timeout=2)
|
| 164 |
+
|
| 165 |
+
if not code_input:
|
| 166 |
+
self._log("error", "code input expired")
|
| 167 |
+
return {"success": False, "error": "code input expired"}
|
| 168 |
+
|
| 169 |
+
code_input.input(code, clear=True)
|
| 170 |
+
time.sleep(0.5)
|
| 171 |
+
|
| 172 |
+
verify_btn = page.ele("css:button[jsname='XooR8e']", timeout=3)
|
| 173 |
+
if verify_btn:
|
| 174 |
+
verify_btn.click()
|
| 175 |
+
else:
|
| 176 |
+
verify_btn = self._find_verify_button(page)
|
| 177 |
+
if verify_btn:
|
| 178 |
+
verify_btn.click()
|
| 179 |
+
else:
|
| 180 |
+
code_input.input("\n")
|
| 181 |
+
|
| 182 |
+
time.sleep(5)
|
| 183 |
+
|
| 184 |
+
# Step 7: 处理协议页面(如果有)
|
| 185 |
+
self._handle_agreement_page(page)
|
| 186 |
+
|
| 187 |
+
# Step 8: 导航到业务页面
|
| 188 |
+
page.get("https://business.gemini.google/", timeout=self.timeout)
|
| 189 |
+
time.sleep(3)
|
| 190 |
+
|
| 191 |
+
# Step 9: 检查是否需要设置用户名
|
| 192 |
+
if "cid" not in page.url:
|
| 193 |
+
if self._handle_username_setup(page):
|
| 194 |
+
time.sleep(3)
|
| 195 |
+
|
| 196 |
+
# Step 10: 提取配置
|
| 197 |
+
if "cid" in page.url or self._wait_for_cid(page):
|
| 198 |
+
self._log("info", "login success")
|
| 199 |
+
return self._extract_config(page, email)
|
| 200 |
+
|
| 201 |
+
self._log("error", "login failed")
|
| 202 |
+
self._save_screenshot(page, "login_failed")
|
| 203 |
+
return {"success": False, "error": "login failed"}
|
| 204 |
+
|
| 205 |
+
def _click_send_code_button(self, page) -> bool:
|
| 206 |
+
"""点击发送验证码按钮(如果需要)"""
|
| 207 |
+
time.sleep(2)
|
| 208 |
+
|
| 209 |
+
# 方法1: 直接通过ID查找
|
| 210 |
+
direct_btn = page.ele("#sign-in-with-email", timeout=5)
|
| 211 |
+
if direct_btn:
|
| 212 |
+
try:
|
| 213 |
+
direct_btn.click()
|
| 214 |
+
return True
|
| 215 |
+
except Exception:
|
| 216 |
+
pass
|
| 217 |
+
|
| 218 |
+
# 方法2: 通过关键词查找
|
| 219 |
+
keywords = ["通过电子邮件发送验证码", "通过电子邮件发送", "email", "Email", "Send code", "Send verification", "Verification code"]
|
| 220 |
+
try:
|
| 221 |
+
buttons = page.eles("tag:button")
|
| 222 |
+
for btn in buttons:
|
| 223 |
+
text = (btn.text or "").strip()
|
| 224 |
+
if text and any(kw in text for kw in keywords):
|
| 225 |
+
try:
|
| 226 |
+
btn.click()
|
| 227 |
+
return True
|
| 228 |
+
except Exception:
|
| 229 |
+
pass
|
| 230 |
+
except Exception:
|
| 231 |
+
pass
|
| 232 |
+
|
| 233 |
+
# 检查是否已经在验证码输入页面
|
| 234 |
+
code_input = page.ele("css:input[jsname='ovqh0b']", timeout=2) or page.ele("css:input[name='pinInput']", timeout=1)
|
| 235 |
+
if code_input:
|
| 236 |
+
return True
|
| 237 |
+
|
| 238 |
+
return False
|
| 239 |
+
|
| 240 |
+
def _wait_for_code_input(self, page, timeout: int = 30):
|
| 241 |
+
"""等待验证码输入框出现"""
|
| 242 |
+
selectors = [
|
| 243 |
+
"css:input[jsname='ovqh0b']",
|
| 244 |
+
"css:input[type='tel']",
|
| 245 |
+
"css:input[name='pinInput']",
|
| 246 |
+
"css:input[autocomplete='one-time-code']",
|
| 247 |
+
]
|
| 248 |
+
for _ in range(timeout // 2):
|
| 249 |
+
for selector in selectors:
|
| 250 |
+
try:
|
| 251 |
+
el = page.ele(selector, timeout=1)
|
| 252 |
+
if el:
|
| 253 |
+
return el
|
| 254 |
+
except Exception:
|
| 255 |
+
continue
|
| 256 |
+
time.sleep(2)
|
| 257 |
+
return None
|
| 258 |
+
|
| 259 |
+
def _find_verify_button(self, page):
|
| 260 |
+
"""查找验证按钮(排除重新发送按钮)"""
|
| 261 |
+
try:
|
| 262 |
+
buttons = page.eles("tag:button")
|
| 263 |
+
for btn in buttons:
|
| 264 |
+
text = (btn.text or "").strip().lower()
|
| 265 |
+
if text and "重新" not in text and "发送" not in text and "resend" not in text and "send" not in text:
|
| 266 |
+
return btn
|
| 267 |
+
except Exception:
|
| 268 |
+
pass
|
| 269 |
+
return None
|
| 270 |
+
|
| 271 |
+
def _handle_agreement_page(self, page) -> None:
|
| 272 |
+
"""处理协��页面"""
|
| 273 |
+
if "/admin/create" in page.url:
|
| 274 |
+
agree_btn = page.ele("css:button.agree-button", timeout=5)
|
| 275 |
+
if agree_btn:
|
| 276 |
+
agree_btn.click()
|
| 277 |
+
time.sleep(2)
|
| 278 |
+
|
| 279 |
+
def _wait_for_cid(self, page, timeout: int = 10) -> bool:
|
| 280 |
+
"""等待URL包含cid"""
|
| 281 |
+
for _ in range(timeout):
|
| 282 |
+
if "cid" in page.url:
|
| 283 |
+
return True
|
| 284 |
+
time.sleep(1)
|
| 285 |
+
return False
|
| 286 |
+
|
| 287 |
+
def _handle_username_setup(self, page) -> bool:
|
| 288 |
+
"""处理用户名设置页面"""
|
| 289 |
+
current_url = page.url
|
| 290 |
+
|
| 291 |
+
if "auth.business.gemini.google/login" in current_url:
|
| 292 |
+
return False
|
| 293 |
+
|
| 294 |
+
selectors = [
|
| 295 |
+
"css:input[type='text']",
|
| 296 |
+
"css:input[name='displayName']",
|
| 297 |
+
"css:input[aria-label*='用户名' i]",
|
| 298 |
+
"css:input[aria-label*='display name' i]",
|
| 299 |
+
]
|
| 300 |
+
|
| 301 |
+
username_input = None
|
| 302 |
+
for selector in selectors:
|
| 303 |
+
try:
|
| 304 |
+
username_input = page.ele(selector, timeout=2)
|
| 305 |
+
if username_input:
|
| 306 |
+
break
|
| 307 |
+
except Exception:
|
| 308 |
+
continue
|
| 309 |
+
|
| 310 |
+
if not username_input:
|
| 311 |
+
return False
|
| 312 |
+
|
| 313 |
+
suffix = "".join(random.choices(string.ascii_letters + string.digits, k=3))
|
| 314 |
+
username = f"Test{suffix}"
|
| 315 |
+
|
| 316 |
+
try:
|
| 317 |
+
username_input.click()
|
| 318 |
+
time.sleep(0.2)
|
| 319 |
+
username_input.clear()
|
| 320 |
+
username_input.input(username)
|
| 321 |
+
time.sleep(0.3)
|
| 322 |
+
|
| 323 |
+
buttons = page.eles("tag:button")
|
| 324 |
+
submit_btn = None
|
| 325 |
+
for btn in buttons:
|
| 326 |
+
text = (btn.text or "").strip().lower()
|
| 327 |
+
if any(kw in text for kw in ["确认", "提交", "继续", "submit", "continue", "confirm", "save", "保存", "下一步", "next"]):
|
| 328 |
+
submit_btn = btn
|
| 329 |
+
break
|
| 330 |
+
|
| 331 |
+
if submit_btn:
|
| 332 |
+
submit_btn.click()
|
| 333 |
+
else:
|
| 334 |
+
username_input.input("\n")
|
| 335 |
+
|
| 336 |
+
time.sleep(5)
|
| 337 |
+
return True
|
| 338 |
+
except Exception:
|
| 339 |
+
return False
|
| 340 |
+
|
| 341 |
+
def _extract_config(self, page, email: str) -> dict:
|
| 342 |
+
"""提取配置"""
|
| 343 |
+
try:
|
| 344 |
+
if "cid/" not in page.url:
|
| 345 |
+
page.get("https://business.gemini.google/", timeout=self.timeout)
|
| 346 |
+
time.sleep(3)
|
| 347 |
+
|
| 348 |
+
url = page.url
|
| 349 |
+
if "cid/" not in url:
|
| 350 |
+
return {"success": False, "error": "cid not found"}
|
| 351 |
+
|
| 352 |
+
config_id = url.split("cid/")[1].split("?")[0].split("/")[0]
|
| 353 |
+
csesidx = url.split("csesidx=")[1].split("&")[0] if "csesidx=" in url else ""
|
| 354 |
+
|
| 355 |
+
cookies = page.cookies()
|
| 356 |
+
ses = next((c["value"] for c in cookies if c["name"] == "__Secure-C_SES"), None)
|
| 357 |
+
host = next((c["value"] for c in cookies if c["name"] == "__Host-C_OSES"), None)
|
| 358 |
+
|
| 359 |
+
ses_obj = next((c for c in cookies if c["name"] == "__Secure-C_SES"), None)
|
| 360 |
+
if ses_obj and "expiry" in ses_obj:
|
| 361 |
+
expires_at = datetime.fromtimestamp(ses_obj["expiry"] - 43200).strftime("%Y-%m-%d %H:%M:%S")
|
| 362 |
+
else:
|
| 363 |
+
expires_at = (datetime.now() + timedelta(hours=12)).strftime("%Y-%m-%d %H:%M:%S")
|
| 364 |
+
|
| 365 |
+
config = {
|
| 366 |
+
"id": email,
|
| 367 |
+
"csesidx": csesidx,
|
| 368 |
+
"config_id": config_id,
|
| 369 |
+
"secure_c_ses": ses,
|
| 370 |
+
"host_c_oses": host,
|
| 371 |
+
"expires_at": expires_at,
|
| 372 |
+
}
|
| 373 |
+
return {"success": True, "config": config}
|
| 374 |
+
except Exception as e:
|
| 375 |
+
return {"success": False, "error": str(e)}
|
| 376 |
+
|
| 377 |
+
def _save_screenshot(self, page, name: str) -> None:
|
| 378 |
+
"""保存截图"""
|
| 379 |
+
try:
|
| 380 |
+
import os
|
| 381 |
+
screenshot_dir = os.path.join("data", "automation")
|
| 382 |
+
os.makedirs(screenshot_dir, exist_ok=True)
|
| 383 |
+
path = os.path.join(screenshot_dir, f"{name}_{int(time.time())}.png")
|
| 384 |
+
page.get_screenshot(path=path)
|
| 385 |
+
except Exception:
|
| 386 |
+
pass
|
| 387 |
+
|
| 388 |
+
def _log(self, level: str, message: str) -> None:
|
| 389 |
+
"""记录日志"""
|
| 390 |
+
if self.log_callback:
|
| 391 |
+
try:
|
| 392 |
+
self.log_callback(level, message)
|
| 393 |
+
except Exception:
|
| 394 |
+
pass
|
| 395 |
+
|
| 396 |
+
@staticmethod
|
| 397 |
+
def _get_ua() -> str:
|
| 398 |
+
"""生成随机User-Agent"""
|
| 399 |
+
v = random.choice(["120.0.0.0", "121.0.0.0", "122.0.0.0"])
|
| 400 |
+
return f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{v} Safari/537.36"
|
core/gemini_automation_uc.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gemini自动化登录模块(使用 undetected-chromedriver)
|
| 3 |
+
更强的反检测能力,支持无头模式
|
| 4 |
+
"""
|
| 5 |
+
import random
|
| 6 |
+
import string
|
| 7 |
+
import time
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
from typing import Optional
|
| 10 |
+
from urllib.parse import quote
|
| 11 |
+
|
| 12 |
+
import undetected_chromedriver as uc
|
| 13 |
+
from selenium.webdriver.common.by import By
|
| 14 |
+
from selenium.webdriver.support.ui import WebDriverWait
|
| 15 |
+
from selenium.webdriver.support import expected_conditions as EC
|
| 16 |
+
from selenium.common.exceptions import TimeoutException, NoSuchElementException
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# 常量
|
| 20 |
+
AUTH_HOME_URL = "https://auth.business.gemini.google/"
|
| 21 |
+
LOGIN_URL = "https://auth.business.gemini.google/login?continueUrl=https:%2F%2Fbusiness.gemini.google%2F&wiffid=CAoSJDIwNTlhYzBjLTVlMmMtNGUxZS1hY2JkLThmOGY2ZDE0ODM1Mg"
|
| 22 |
+
DEFAULT_XSRF_TOKEN = "KdLRzKwwBTD5wo8nUollAbY6cW0"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class GeminiAutomationUC:
|
| 26 |
+
"""Gemini自动化登录(使用 undetected-chromedriver)"""
|
| 27 |
+
|
| 28 |
+
def __init__(
|
| 29 |
+
self,
|
| 30 |
+
user_agent: str = "",
|
| 31 |
+
proxy: str = "",
|
| 32 |
+
headless: bool = True,
|
| 33 |
+
timeout: int = 60,
|
| 34 |
+
log_callback=None,
|
| 35 |
+
) -> None:
|
| 36 |
+
self.user_agent = user_agent or self._get_ua()
|
| 37 |
+
self.proxy = proxy
|
| 38 |
+
self.headless = headless
|
| 39 |
+
self.timeout = timeout
|
| 40 |
+
self.log_callback = log_callback
|
| 41 |
+
self.driver = None
|
| 42 |
+
|
| 43 |
+
def login_and_extract(self, email: str, mail_client) -> dict:
|
| 44 |
+
"""执行登录并提取配置"""
|
| 45 |
+
try:
|
| 46 |
+
self._create_driver()
|
| 47 |
+
return self._run_flow(email, mail_client)
|
| 48 |
+
except Exception as exc:
|
| 49 |
+
self._log("error", f"automation error: {exc}")
|
| 50 |
+
return {"success": False, "error": str(exc)}
|
| 51 |
+
finally:
|
| 52 |
+
self._cleanup()
|
| 53 |
+
|
| 54 |
+
def _create_driver(self):
|
| 55 |
+
"""创建浏览器驱动"""
|
| 56 |
+
options = uc.ChromeOptions()
|
| 57 |
+
|
| 58 |
+
# 基础参数
|
| 59 |
+
options.add_argument("--no-sandbox")
|
| 60 |
+
options.add_argument("--disable-setuid-sandbox")
|
| 61 |
+
options.add_argument("--window-size=1280,800")
|
| 62 |
+
|
| 63 |
+
# 代理设置
|
| 64 |
+
if self.proxy:
|
| 65 |
+
options.add_argument(f"--proxy-server={self.proxy}")
|
| 66 |
+
|
| 67 |
+
# 无头模式
|
| 68 |
+
if self.headless:
|
| 69 |
+
options.add_argument("--headless=new")
|
| 70 |
+
options.add_argument("--disable-gpu")
|
| 71 |
+
options.add_argument("--disable-dev-shm-usage")
|
| 72 |
+
|
| 73 |
+
# User-Agent
|
| 74 |
+
if self.user_agent:
|
| 75 |
+
options.add_argument(f"--user-agent={self.user_agent}")
|
| 76 |
+
|
| 77 |
+
# 创建驱动(undetected-chromedriver 会自动处理反检测)
|
| 78 |
+
self.driver = uc.Chrome(
|
| 79 |
+
options=options,
|
| 80 |
+
version_main=None, # 自动检测 Chrome 版本
|
| 81 |
+
use_subprocess=True,
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
# 设置超时
|
| 85 |
+
self.driver.set_page_load_timeout(self.timeout)
|
| 86 |
+
self.driver.implicitly_wait(10)
|
| 87 |
+
|
| 88 |
+
def _run_flow(self, email: str, mail_client) -> dict:
|
| 89 |
+
"""执行登录流程"""
|
| 90 |
+
|
| 91 |
+
self._log("info", f"navigating to login page for {email}")
|
| 92 |
+
|
| 93 |
+
# 访问登录页面
|
| 94 |
+
self.driver.get(LOGIN_URL)
|
| 95 |
+
time.sleep(3)
|
| 96 |
+
|
| 97 |
+
# 检查当前页面状态
|
| 98 |
+
current_url = self.driver.current_url
|
| 99 |
+
has_business_params = "business.gemini.google" in current_url and "csesidx=" in current_url and "/cid/" in current_url
|
| 100 |
+
|
| 101 |
+
if has_business_params:
|
| 102 |
+
return self._extract_config(email)
|
| 103 |
+
|
| 104 |
+
# 输入邮箱地址
|
| 105 |
+
try:
|
| 106 |
+
self._log("info", "entering email address")
|
| 107 |
+
email_input = WebDriverWait(self.driver, 30).until(
|
| 108 |
+
EC.element_to_be_clickable((By.XPATH, "/html/body/c-wiz/div/div/div[1]/div/div/div/form/div[1]/div[1]/div/span[2]/input"))
|
| 109 |
+
)
|
| 110 |
+
email_input.click()
|
| 111 |
+
email_input.clear()
|
| 112 |
+
for char in email:
|
| 113 |
+
email_input.send_keys(char)
|
| 114 |
+
time.sleep(0.02)
|
| 115 |
+
time.sleep(0.5)
|
| 116 |
+
except Exception as e:
|
| 117 |
+
self._log("error", f"failed to enter email: {e}")
|
| 118 |
+
self._save_screenshot("email_input_failed")
|
| 119 |
+
return {"success": False, "error": f"failed to enter email: {e}"}
|
| 120 |
+
|
| 121 |
+
# 点击继续按钮
|
| 122 |
+
try:
|
| 123 |
+
continue_btn = WebDriverWait(self.driver, 10).until(
|
| 124 |
+
EC.element_to_be_clickable((By.XPATH, "/html/body/c-wiz/div/div/div[1]/div/div/div/form/div[2]/div/button"))
|
| 125 |
+
)
|
| 126 |
+
self.driver.execute_script("arguments[0].click();", continue_btn)
|
| 127 |
+
time.sleep(2)
|
| 128 |
+
except Exception as e:
|
| 129 |
+
self._log("error", f"failed to click continue: {e}")
|
| 130 |
+
self._save_screenshot("continue_button_failed")
|
| 131 |
+
return {"success": False, "error": f"failed to click continue: {e}"}
|
| 132 |
+
|
| 133 |
+
# 等待验证码输入框出现
|
| 134 |
+
code_input = self._wait_for_code_input()
|
| 135 |
+
if not code_input:
|
| 136 |
+
self._log("error", "code input not found")
|
| 137 |
+
self._save_screenshot("code_input_missing")
|
| 138 |
+
return {"success": False, "error": "code input not found"}
|
| 139 |
+
|
| 140 |
+
time.sleep(3)
|
| 141 |
+
|
| 142 |
+
# 获取验证码
|
| 143 |
+
self._log("info", "polling for verification code")
|
| 144 |
+
code = mail_client.poll_for_code(timeout=40, interval=4)
|
| 145 |
+
|
| 146 |
+
if not code:
|
| 147 |
+
self._log("error", "verification code timeout")
|
| 148 |
+
self._save_screenshot("code_timeout")
|
| 149 |
+
return {"success": False, "error": "verification code timeout"}
|
| 150 |
+
|
| 151 |
+
self._log("info", f"code received: {code}")
|
| 152 |
+
|
| 153 |
+
# 输入验证码
|
| 154 |
+
time.sleep(1)
|
| 155 |
+
try:
|
| 156 |
+
code_input = WebDriverWait(self.driver, 10).until(
|
| 157 |
+
EC.presence_of_element_located((By.CSS_SELECTOR, "input[name='pinInput']"))
|
| 158 |
+
)
|
| 159 |
+
code_input.click()
|
| 160 |
+
time.sleep(0.1)
|
| 161 |
+
for char in code:
|
| 162 |
+
code_input.send_keys(char)
|
| 163 |
+
time.sleep(0.05)
|
| 164 |
+
except Exception:
|
| 165 |
+
try:
|
| 166 |
+
span = self.driver.find_element(By.CSS_SELECTOR, "span[data-index='0']")
|
| 167 |
+
span.click()
|
| 168 |
+
time.sleep(0.2)
|
| 169 |
+
self.driver.switch_to.active_element.send_keys(code)
|
| 170 |
+
except Exception as e:
|
| 171 |
+
self._log("error", f"failed to input code: {e}")
|
| 172 |
+
self._save_screenshot("code_input_failed")
|
| 173 |
+
return {"success": False, "error": f"failed to input code: {e}"}
|
| 174 |
+
|
| 175 |
+
# 点击验证按钮
|
| 176 |
+
time.sleep(0.5)
|
| 177 |
+
try:
|
| 178 |
+
verify_btn = self.driver.find_element(By.XPATH, "/html/body/c-wiz/div/div/div[1]/div/div/div/form/div[2]/div/div[1]/span/div[1]/button")
|
| 179 |
+
self.driver.execute_script("arguments[0].click();", verify_btn)
|
| 180 |
+
except Exception:
|
| 181 |
+
try:
|
| 182 |
+
buttons = self.driver.find_elements(By.TAG_NAME, "button")
|
| 183 |
+
for btn in buttons:
|
| 184 |
+
if "验证" in btn.text:
|
| 185 |
+
self.driver.execute_script("arguments[0].click();", btn)
|
| 186 |
+
break
|
| 187 |
+
except Exception as e:
|
| 188 |
+
self._log("warning", f"failed to click verify button: {e}")
|
| 189 |
+
|
| 190 |
+
time.sleep(5)
|
| 191 |
+
|
| 192 |
+
# 处理协议页面
|
| 193 |
+
self._handle_agreement_page()
|
| 194 |
+
|
| 195 |
+
# 导航到业务页面
|
| 196 |
+
self.driver.get("https://business.gemini.google/")
|
| 197 |
+
time.sleep(3)
|
| 198 |
+
|
| 199 |
+
# 处理用户名设置
|
| 200 |
+
if "cid" not in self.driver.current_url:
|
| 201 |
+
if self._handle_username_setup():
|
| 202 |
+
time.sleep(3)
|
| 203 |
+
|
| 204 |
+
# 提取配置
|
| 205 |
+
if "cid" in self.driver.current_url or self._wait_for_cid():
|
| 206 |
+
self._log("info", "login success")
|
| 207 |
+
return self._extract_config(email)
|
| 208 |
+
|
| 209 |
+
self._log("error", "login failed")
|
| 210 |
+
self._save_screenshot("login_failed")
|
| 211 |
+
return {"success": False, "error": "login failed"}
|
| 212 |
+
|
| 213 |
+
def _wait_for_code_input(self, timeout: int = 30):
|
| 214 |
+
"""等待验证码输入框出现"""
|
| 215 |
+
try:
|
| 216 |
+
element = WebDriverWait(self.driver, timeout).until(
|
| 217 |
+
EC.presence_of_element_located((By.CSS_SELECTOR, "input[name='pinInput']"))
|
| 218 |
+
)
|
| 219 |
+
return element
|
| 220 |
+
except TimeoutException:
|
| 221 |
+
return None
|
| 222 |
+
|
| 223 |
+
def _find_code_input(self):
|
| 224 |
+
"""查找验证码输入框"""
|
| 225 |
+
try:
|
| 226 |
+
return self.driver.find_element(By.CSS_SELECTOR, "input[name='pinInput']")
|
| 227 |
+
except NoSuchElementException:
|
| 228 |
+
return None
|
| 229 |
+
|
| 230 |
+
def _find_verify_button(self):
|
| 231 |
+
"""查找验证按钮"""
|
| 232 |
+
try:
|
| 233 |
+
return self.driver.find_element(By.XPATH, "/html/body/c-wiz/div/div/div[1]/div/div/div/form/div[2]/div/div[1]/span/div[1]/button")
|
| 234 |
+
except NoSuchElementException:
|
| 235 |
+
pass
|
| 236 |
+
|
| 237 |
+
try:
|
| 238 |
+
buttons = self.driver.find_elements(By.TAG_NAME, "button")
|
| 239 |
+
for btn in buttons:
|
| 240 |
+
text = btn.text.strip()
|
| 241 |
+
if text and "验证" in text:
|
| 242 |
+
return btn
|
| 243 |
+
except Exception:
|
| 244 |
+
pass
|
| 245 |
+
|
| 246 |
+
return None
|
| 247 |
+
|
| 248 |
+
def _handle_agreement_page(self) -> None:
|
| 249 |
+
"""处理协议页面"""
|
| 250 |
+
if "/admin/create" in self.driver.current_url:
|
| 251 |
+
try:
|
| 252 |
+
agree_btn = WebDriverWait(self.driver, 5).until(
|
| 253 |
+
EC.element_to_be_clickable((By.CSS_SELECTOR, "button.agree-button"))
|
| 254 |
+
)
|
| 255 |
+
agree_btn.click()
|
| 256 |
+
time.sleep(2)
|
| 257 |
+
except TimeoutException:
|
| 258 |
+
pass
|
| 259 |
+
|
| 260 |
+
def _wait_for_cid(self, timeout: int = 10) -> bool:
|
| 261 |
+
"""等待URL包含cid"""
|
| 262 |
+
for _ in range(timeout):
|
| 263 |
+
if "cid" in self.driver.current_url:
|
| 264 |
+
return True
|
| 265 |
+
time.sleep(1)
|
| 266 |
+
return False
|
| 267 |
+
|
| 268 |
+
def _handle_username_setup(self) -> bool:
|
| 269 |
+
"""处理用户名设置页面"""
|
| 270 |
+
current_url = self.driver.current_url
|
| 271 |
+
|
| 272 |
+
if "auth.business.gemini.google/login" in current_url:
|
| 273 |
+
return False
|
| 274 |
+
|
| 275 |
+
selectors = [
|
| 276 |
+
"input[formcontrolname='fullName']",
|
| 277 |
+
"input[placeholder='全名']",
|
| 278 |
+
"input[placeholder='Full name']",
|
| 279 |
+
"input#mat-input-0",
|
| 280 |
+
"input[type='text']",
|
| 281 |
+
"input[name='displayName']",
|
| 282 |
+
]
|
| 283 |
+
|
| 284 |
+
username_input = None
|
| 285 |
+
for _ in range(30):
|
| 286 |
+
for selector in selectors:
|
| 287 |
+
try:
|
| 288 |
+
username_input = self.driver.find_element(By.CSS_SELECTOR, selector)
|
| 289 |
+
if username_input.is_displayed():
|
| 290 |
+
break
|
| 291 |
+
except Exception:
|
| 292 |
+
continue
|
| 293 |
+
if username_input and username_input.is_displayed():
|
| 294 |
+
break
|
| 295 |
+
time.sleep(1)
|
| 296 |
+
|
| 297 |
+
if not username_input or not username_input.is_displayed():
|
| 298 |
+
return False
|
| 299 |
+
|
| 300 |
+
suffix = "".join(random.choices(string.ascii_letters + string.digits, k=3))
|
| 301 |
+
username = f"Test{suffix}"
|
| 302 |
+
|
| 303 |
+
try:
|
| 304 |
+
username_input.click()
|
| 305 |
+
time.sleep(0.2)
|
| 306 |
+
username_input.clear()
|
| 307 |
+
for char in username:
|
| 308 |
+
username_input.send_keys(char)
|
| 309 |
+
time.sleep(0.02)
|
| 310 |
+
time.sleep(0.3)
|
| 311 |
+
|
| 312 |
+
from selenium.webdriver.common.keys import Keys
|
| 313 |
+
username_input.send_keys(Keys.ENTER)
|
| 314 |
+
time.sleep(1)
|
| 315 |
+
|
| 316 |
+
return True
|
| 317 |
+
except Exception:
|
| 318 |
+
return False
|
| 319 |
+
|
| 320 |
+
def _extract_config(self, email: str) -> dict:
|
| 321 |
+
"""提取配置"""
|
| 322 |
+
try:
|
| 323 |
+
if "cid/" not in self.driver.current_url:
|
| 324 |
+
self.driver.get("https://business.gemini.google/")
|
| 325 |
+
time.sleep(3)
|
| 326 |
+
|
| 327 |
+
url = self.driver.current_url
|
| 328 |
+
if "cid/" not in url:
|
| 329 |
+
return {"success": False, "error": "cid not found"}
|
| 330 |
+
|
| 331 |
+
# 提取参数
|
| 332 |
+
config_id = url.split("cid/")[1].split("?")[0].split("/")[0]
|
| 333 |
+
csesidx = url.split("csesidx=")[1].split("&")[0] if "csesidx=" in url else ""
|
| 334 |
+
|
| 335 |
+
# 提取 Cookie
|
| 336 |
+
cookies = self.driver.get_cookies()
|
| 337 |
+
ses = next((c["value"] for c in cookies if c["name"] == "__Secure-C_SES"), None)
|
| 338 |
+
host = next((c["value"] for c in cookies if c["name"] == "__Host-C_OSES"), None)
|
| 339 |
+
|
| 340 |
+
# 计算过期时间
|
| 341 |
+
ses_obj = next((c for c in cookies if c["name"] == "__Secure-C_SES"), None)
|
| 342 |
+
if ses_obj and "expiry" in ses_obj:
|
| 343 |
+
expires_at = datetime.fromtimestamp(ses_obj["expiry"] - 43200).strftime("%Y-%m-%d %H:%M:%S")
|
| 344 |
+
else:
|
| 345 |
+
expires_at = (datetime.now() + timedelta(hours=12)).strftime("%Y-%m-%d %H:%M:%S")
|
| 346 |
+
|
| 347 |
+
config = {
|
| 348 |
+
"id": email,
|
| 349 |
+
"csesidx": csesidx,
|
| 350 |
+
"config_id": config_id,
|
| 351 |
+
"secure_c_ses": ses,
|
| 352 |
+
"host_c_oses": host,
|
| 353 |
+
"expires_at": expires_at,
|
| 354 |
+
}
|
| 355 |
+
return {"success": True, "config": config}
|
| 356 |
+
except Exception as e:
|
| 357 |
+
return {"success": False, "error": str(e)}
|
| 358 |
+
|
| 359 |
+
def _save_screenshot(self, name: str) -> None:
|
| 360 |
+
"""保存截图"""
|
| 361 |
+
try:
|
| 362 |
+
import os
|
| 363 |
+
screenshot_dir = os.path.join("data", "automation")
|
| 364 |
+
os.makedirs(screenshot_dir, exist_ok=True)
|
| 365 |
+
path = os.path.join(screenshot_dir, f"{name}_{int(time.time())}.png")
|
| 366 |
+
self.driver.save_screenshot(path)
|
| 367 |
+
except Exception:
|
| 368 |
+
pass
|
| 369 |
+
|
| 370 |
+
def _cleanup(self) -> None:
|
| 371 |
+
"""清理资源"""
|
| 372 |
+
if self.driver:
|
| 373 |
+
try:
|
| 374 |
+
self.driver.quit()
|
| 375 |
+
except Exception:
|
| 376 |
+
pass
|
| 377 |
+
|
| 378 |
+
def _log(self, level: str, message: str) -> None:
|
| 379 |
+
"""记录日志"""
|
| 380 |
+
if self.log_callback:
|
| 381 |
+
try:
|
| 382 |
+
self.log_callback(level, message)
|
| 383 |
+
except Exception:
|
| 384 |
+
pass
|
| 385 |
+
|
| 386 |
+
@staticmethod
|
| 387 |
+
def _get_ua() -> str:
|
| 388 |
+
"""生成随机User-Agent"""
|
| 389 |
+
v = random.choice(["120.0.0.0", "121.0.0.0", "122.0.0.0"])
|
| 390 |
+
return f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{v} Safari/537.36"
|
core/login_service.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
import os
|
| 4 |
+
import time
|
| 5 |
+
import uuid
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from datetime import datetime, timedelta, timezone
|
| 8 |
+
from typing import Any, Callable, Dict, List, Optional
|
| 9 |
+
|
| 10 |
+
from core.account import load_accounts_from_source
|
| 11 |
+
from core.base_task_service import BaseTask, BaseTaskService, TaskStatus
|
| 12 |
+
from core.config import config
|
| 13 |
+
from core.duckmail_client import DuckMailClient
|
| 14 |
+
from core.gemini_automation import GeminiAutomation
|
| 15 |
+
from core.gemini_automation_uc import GeminiAutomationUC
|
| 16 |
+
from core.microsoft_mail_client import MicrosoftMailClient
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger("gemini.login")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class LoginTask(BaseTask):
|
| 23 |
+
"""登录任务数据类"""
|
| 24 |
+
account_ids: List[str] = field(default_factory=list)
|
| 25 |
+
|
| 26 |
+
def to_dict(self) -> dict:
|
| 27 |
+
"""转换为字典"""
|
| 28 |
+
base_dict = super().to_dict()
|
| 29 |
+
base_dict["account_ids"] = self.account_ids
|
| 30 |
+
return base_dict
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class LoginService(BaseTaskService[LoginTask]):
|
| 34 |
+
"""登录服务类"""
|
| 35 |
+
|
| 36 |
+
def __init__(
|
| 37 |
+
self,
|
| 38 |
+
multi_account_mgr,
|
| 39 |
+
http_client,
|
| 40 |
+
user_agent: str,
|
| 41 |
+
account_failure_threshold: int,
|
| 42 |
+
rate_limit_cooldown_seconds: int,
|
| 43 |
+
session_cache_ttl_seconds: int,
|
| 44 |
+
global_stats_provider: Callable[[], dict],
|
| 45 |
+
set_multi_account_mgr: Optional[Callable[[Any], None]] = None,
|
| 46 |
+
) -> None:
|
| 47 |
+
super().__init__(
|
| 48 |
+
multi_account_mgr,
|
| 49 |
+
http_client,
|
| 50 |
+
user_agent,
|
| 51 |
+
account_failure_threshold,
|
| 52 |
+
rate_limit_cooldown_seconds,
|
| 53 |
+
session_cache_ttl_seconds,
|
| 54 |
+
global_stats_provider,
|
| 55 |
+
set_multi_account_mgr,
|
| 56 |
+
log_prefix="REFRESH",
|
| 57 |
+
)
|
| 58 |
+
self._is_polling = False
|
| 59 |
+
|
| 60 |
+
async def start_login(self, account_ids: List[str]) -> LoginTask:
|
| 61 |
+
"""启动登录任务"""
|
| 62 |
+
async with self._lock:
|
| 63 |
+
if self._current_task_id:
|
| 64 |
+
current = self._tasks.get(self._current_task_id)
|
| 65 |
+
if current and current.status == TaskStatus.RUNNING:
|
| 66 |
+
raise ValueError("login task already running")
|
| 67 |
+
|
| 68 |
+
task = LoginTask(id=str(uuid.uuid4()), account_ids=account_ids)
|
| 69 |
+
self._tasks[task.id] = task
|
| 70 |
+
self._current_task_id = task.id
|
| 71 |
+
self._append_log(task, "info", f"login task created ({len(account_ids)} accounts)")
|
| 72 |
+
asyncio.create_task(self._run_login_async(task))
|
| 73 |
+
return task
|
| 74 |
+
|
| 75 |
+
async def _run_login_async(self, task: LoginTask) -> None:
|
| 76 |
+
"""异步执行登录任务"""
|
| 77 |
+
task.status = TaskStatus.RUNNING
|
| 78 |
+
loop = asyncio.get_running_loop()
|
| 79 |
+
self._append_log(task, "info", "login task started")
|
| 80 |
+
|
| 81 |
+
for account_id in task.account_ids:
|
| 82 |
+
try:
|
| 83 |
+
result = await loop.run_in_executor(self._executor, self._refresh_one, account_id, task)
|
| 84 |
+
except Exception as exc:
|
| 85 |
+
result = {"success": False, "email": account_id, "error": str(exc)}
|
| 86 |
+
task.progress += 1
|
| 87 |
+
task.results.append(result)
|
| 88 |
+
|
| 89 |
+
if result.get("success"):
|
| 90 |
+
task.success_count += 1
|
| 91 |
+
self._append_log(task, "info", f"refresh success: {account_id}")
|
| 92 |
+
else:
|
| 93 |
+
task.fail_count += 1
|
| 94 |
+
self._append_log(task, "error", f"refresh failed: {account_id} - {result.get('error')}")
|
| 95 |
+
|
| 96 |
+
task.status = TaskStatus.SUCCESS if task.fail_count == 0 else TaskStatus.FAILED
|
| 97 |
+
task.finished_at = time.time()
|
| 98 |
+
self._current_task_id = None
|
| 99 |
+
self._append_log(task, "info", f"login task finished ({task.success_count}/{len(task.account_ids)})")
|
| 100 |
+
|
| 101 |
+
def _refresh_one(self, account_id: str, task: LoginTask) -> dict:
|
| 102 |
+
"""刷新单个账户"""
|
| 103 |
+
accounts = load_accounts_from_source()
|
| 104 |
+
account = next((acc for acc in accounts if acc.get("id") == account_id), None)
|
| 105 |
+
if not account:
|
| 106 |
+
return {"success": False, "email": account_id, "error": "account not found"}
|
| 107 |
+
|
| 108 |
+
if account.get("disabled"):
|
| 109 |
+
return {"success": False, "email": account_id, "error": "account disabled"}
|
| 110 |
+
|
| 111 |
+
# 获取邮件提供商
|
| 112 |
+
mail_provider = (account.get("mail_provider") or "").lower()
|
| 113 |
+
if not mail_provider:
|
| 114 |
+
if account.get("mail_client_id") or account.get("mail_refresh_token"):
|
| 115 |
+
mail_provider = "microsoft"
|
| 116 |
+
else:
|
| 117 |
+
mail_provider = "duckmail"
|
| 118 |
+
|
| 119 |
+
# 获取邮件配置
|
| 120 |
+
mail_password = account.get("mail_password") or account.get("email_password")
|
| 121 |
+
mail_client_id = account.get("mail_client_id")
|
| 122 |
+
mail_refresh_token = account.get("mail_refresh_token")
|
| 123 |
+
mail_tenant = account.get("mail_tenant") or "consumers"
|
| 124 |
+
|
| 125 |
+
log_cb = lambda level, message: self._append_log(task, level, f"[{account_id}] {message}")
|
| 126 |
+
|
| 127 |
+
# 创建邮件客户端
|
| 128 |
+
if mail_provider == "microsoft":
|
| 129 |
+
if not mail_client_id or not mail_refresh_token:
|
| 130 |
+
return {"success": False, "email": account_id, "error": "microsoft oauth missing"}
|
| 131 |
+
mail_address = account.get("mail_address") or account_id
|
| 132 |
+
client = MicrosoftMailClient(
|
| 133 |
+
client_id=mail_client_id,
|
| 134 |
+
refresh_token=mail_refresh_token,
|
| 135 |
+
tenant=mail_tenant,
|
| 136 |
+
proxy=config.basic.proxy,
|
| 137 |
+
log_callback=log_cb,
|
| 138 |
+
)
|
| 139 |
+
client.set_credentials(mail_address)
|
| 140 |
+
elif mail_provider == "duckmail":
|
| 141 |
+
if not mail_password:
|
| 142 |
+
return {"success": False, "email": account_id, "error": "mail password missing"}
|
| 143 |
+
# DuckMail: account_id 就是邮箱地址
|
| 144 |
+
client = DuckMailClient(
|
| 145 |
+
base_url=config.basic.duckmail_base_url,
|
| 146 |
+
proxy=config.basic.proxy,
|
| 147 |
+
verify_ssl=config.basic.duckmail_verify_ssl,
|
| 148 |
+
api_key=config.basic.duckmail_api_key,
|
| 149 |
+
log_callback=log_cb,
|
| 150 |
+
)
|
| 151 |
+
client.set_credentials(account_id, mail_password)
|
| 152 |
+
else:
|
| 153 |
+
return {"success": False, "email": account_id, "error": f"unsupported mail provider: {mail_provider}"}
|
| 154 |
+
|
| 155 |
+
automation = GeminiAutomationUC(
|
| 156 |
+
user_agent=self.user_agent,
|
| 157 |
+
proxy=config.basic.proxy,
|
| 158 |
+
headless=config.basic.browser_headless,
|
| 159 |
+
log_callback=log_cb,
|
| 160 |
+
)
|
| 161 |
+
try:
|
| 162 |
+
result = automation.login_and_extract(account_id, client)
|
| 163 |
+
except Exception as exc:
|
| 164 |
+
return {"success": False, "email": account_id, "error": str(exc)}
|
| 165 |
+
if not result.get("success"):
|
| 166 |
+
return {"success": False, "email": account_id, "error": result.get("error", "automation failed")}
|
| 167 |
+
|
| 168 |
+
# 更新账户配置
|
| 169 |
+
config_data = result["config"]
|
| 170 |
+
config_data["mail_provider"] = mail_provider
|
| 171 |
+
config_data["mail_password"] = mail_password
|
| 172 |
+
if mail_provider == "microsoft":
|
| 173 |
+
config_data["mail_address"] = account.get("mail_address") or account_id
|
| 174 |
+
config_data["mail_client_id"] = mail_client_id
|
| 175 |
+
config_data["mail_refresh_token"] = mail_refresh_token
|
| 176 |
+
config_data["mail_tenant"] = mail_tenant
|
| 177 |
+
config_data["disabled"] = account.get("disabled", False)
|
| 178 |
+
|
| 179 |
+
for acc in accounts:
|
| 180 |
+
if acc.get("id") == account_id:
|
| 181 |
+
acc.update(config_data)
|
| 182 |
+
break
|
| 183 |
+
|
| 184 |
+
self._apply_accounts_update(accounts)
|
| 185 |
+
return {"success": True, "email": account_id, "config": config_data}
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _get_expiring_accounts(self) -> List[str]:
|
| 189 |
+
accounts = load_accounts_from_source()
|
| 190 |
+
expiring = []
|
| 191 |
+
beijing_tz = timezone(timedelta(hours=8))
|
| 192 |
+
now = datetime.now(beijing_tz)
|
| 193 |
+
|
| 194 |
+
for account in accounts:
|
| 195 |
+
if account.get("disabled"):
|
| 196 |
+
continue
|
| 197 |
+
mail_provider = (account.get("mail_provider") or "").lower()
|
| 198 |
+
if not mail_provider:
|
| 199 |
+
if account.get("mail_client_id") or account.get("mail_refresh_token"):
|
| 200 |
+
mail_provider = "microsoft"
|
| 201 |
+
else:
|
| 202 |
+
mail_provider = "duckmail"
|
| 203 |
+
|
| 204 |
+
mail_password = account.get("mail_password") or account.get("email_password")
|
| 205 |
+
if mail_provider == "microsoft":
|
| 206 |
+
if not account.get("mail_client_id") or not account.get("mail_refresh_token"):
|
| 207 |
+
continue
|
| 208 |
+
else:
|
| 209 |
+
if not mail_password:
|
| 210 |
+
continue
|
| 211 |
+
expires_at = account.get("expires_at")
|
| 212 |
+
if not expires_at:
|
| 213 |
+
continue
|
| 214 |
+
|
| 215 |
+
try:
|
| 216 |
+
expire_time = datetime.strptime(expires_at, "%Y-%m-%d %H:%M:%S")
|
| 217 |
+
expire_time = expire_time.replace(tzinfo=beijing_tz)
|
| 218 |
+
remaining = (expire_time - now).total_seconds() / 3600
|
| 219 |
+
except Exception:
|
| 220 |
+
continue
|
| 221 |
+
|
| 222 |
+
if remaining <= config.basic.refresh_window_hours:
|
| 223 |
+
expiring.append(account.get("id"))
|
| 224 |
+
|
| 225 |
+
return expiring
|
| 226 |
+
|
| 227 |
+
async def check_and_refresh(self) -> None:
|
| 228 |
+
if os.environ.get("ACCOUNTS_CONFIG"):
|
| 229 |
+
logger.info("[LOGIN] ACCOUNTS_CONFIG set, skipping refresh")
|
| 230 |
+
return
|
| 231 |
+
expiring_accounts = self._get_expiring_accounts()
|
| 232 |
+
if not expiring_accounts:
|
| 233 |
+
logger.debug("[LOGIN] no accounts need refresh")
|
| 234 |
+
return
|
| 235 |
+
|
| 236 |
+
try:
|
| 237 |
+
await self.start_login(expiring_accounts)
|
| 238 |
+
except ValueError as exc:
|
| 239 |
+
logger.warning("[LOGIN] %s", exc)
|
| 240 |
+
|
| 241 |
+
async def start_polling(self) -> None:
|
| 242 |
+
if self._is_polling:
|
| 243 |
+
logger.warning("[LOGIN] polling already running")
|
| 244 |
+
return
|
| 245 |
+
|
| 246 |
+
self._is_polling = True
|
| 247 |
+
logger.info("[LOGIN] refresh polling started (interval: 30 minutes)")
|
| 248 |
+
try:
|
| 249 |
+
while self._is_polling:
|
| 250 |
+
await self.check_and_refresh()
|
| 251 |
+
await asyncio.sleep(1800)
|
| 252 |
+
except asyncio.CancelledError:
|
| 253 |
+
logger.info("[LOGIN] polling stopped")
|
| 254 |
+
except Exception as exc:
|
| 255 |
+
logger.error("[LOGIN] polling error: %s", exc)
|
| 256 |
+
finally:
|
| 257 |
+
self._is_polling = False
|
| 258 |
+
|
| 259 |
+
def stop_polling(self) -> None:
|
| 260 |
+
self._is_polling = False
|
| 261 |
+
logger.info("[LOGIN] stopping polling")
|
core/mail_utils.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def extract_verification_code(text: str) -> Optional[str]:
|
| 6 |
+
"""提取验证码"""
|
| 7 |
+
if not text:
|
| 8 |
+
return None
|
| 9 |
+
|
| 10 |
+
# 策略1: 上下文关键词匹配(中英文冒号)
|
| 11 |
+
# 排除 CSS 样式值(如 14px, 16pt 等)
|
| 12 |
+
context_pattern = r"(?:验证码|code|verification|passcode|pin).*?[::]\s*([A-Za-z0-9]{4,8})\b"
|
| 13 |
+
match = re.search(context_pattern, text, re.IGNORECASE)
|
| 14 |
+
if match:
|
| 15 |
+
candidate = match.group(1)
|
| 16 |
+
# 排除 CSS 单位值
|
| 17 |
+
if not re.match(r"^\d+(?:px|pt|em|rem|vh|vw|%)$", candidate, re.IGNORECASE):
|
| 18 |
+
return candidate
|
| 19 |
+
|
| 20 |
+
# 策略2: 6位数字
|
| 21 |
+
digits = re.findall(r"\b\d{6}\b", text)
|
| 22 |
+
if digits:
|
| 23 |
+
return digits[0]
|
| 24 |
+
|
| 25 |
+
# 策略3: 6位字母数字混合
|
| 26 |
+
alphanumeric = re.findall(r"\b[A-Z0-9]{6}\b", text)
|
| 27 |
+
for candidate in alphanumeric:
|
| 28 |
+
has_letter = any(c.isalpha() for c in candidate)
|
| 29 |
+
has_digit = any(c.isdigit() for c in candidate)
|
| 30 |
+
if has_letter and has_digit:
|
| 31 |
+
return candidate
|
| 32 |
+
|
| 33 |
+
return None
|
core/microsoft_mail_client.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import imaplib
|
| 2 |
+
import time
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
from email import message_from_bytes
|
| 5 |
+
from email.utils import parsedate_to_datetime
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
import requests
|
| 9 |
+
|
| 10 |
+
from core.mail_utils import extract_verification_code
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class MicrosoftMailClient:
|
| 14 |
+
def __init__(
|
| 15 |
+
self,
|
| 16 |
+
client_id: str,
|
| 17 |
+
refresh_token: str,
|
| 18 |
+
tenant: str = "consumers",
|
| 19 |
+
proxy: str = "",
|
| 20 |
+
log_callback=None,
|
| 21 |
+
) -> None:
|
| 22 |
+
self.client_id = client_id
|
| 23 |
+
self.refresh_token = refresh_token
|
| 24 |
+
self.tenant = tenant or "consumers"
|
| 25 |
+
self.proxies = {"http": proxy, "https": proxy} if proxy else None
|
| 26 |
+
self.log_callback = log_callback
|
| 27 |
+
self.email: Optional[str] = None
|
| 28 |
+
|
| 29 |
+
def set_credentials(self, email: str, password: Optional[str] = None) -> None:
|
| 30 |
+
self.email = email
|
| 31 |
+
|
| 32 |
+
def _get_access_token(self) -> Optional[str]:
|
| 33 |
+
url = f"https://login.microsoftonline.com/{self.tenant}/oauth2/v2.0/token"
|
| 34 |
+
data = {
|
| 35 |
+
"client_id": self.client_id,
|
| 36 |
+
"grant_type": "refresh_token",
|
| 37 |
+
"refresh_token": self.refresh_token,
|
| 38 |
+
}
|
| 39 |
+
try:
|
| 40 |
+
res = requests.post(url, data=data, proxies=self.proxies, timeout=15)
|
| 41 |
+
if res.status_code != 200:
|
| 42 |
+
self._log("error", f"Microsoft token error: {res.status_code}")
|
| 43 |
+
return None
|
| 44 |
+
payload = res.json() if res.content else {}
|
| 45 |
+
token = payload.get("access_token")
|
| 46 |
+
if not token:
|
| 47 |
+
self._log("error", "Microsoft token missing")
|
| 48 |
+
return None
|
| 49 |
+
return token
|
| 50 |
+
except Exception as exc:
|
| 51 |
+
self._log("error", f"Microsoft token exception: {exc}")
|
| 52 |
+
return None
|
| 53 |
+
|
| 54 |
+
def fetch_verification_code(self, since_time: Optional[datetime] = None) -> Optional[str]:
|
| 55 |
+
if not self.email:
|
| 56 |
+
return None
|
| 57 |
+
token = self._get_access_token()
|
| 58 |
+
if not token:
|
| 59 |
+
return None
|
| 60 |
+
|
| 61 |
+
auth_string = f"user={self.email}\x01auth=Bearer {token}\x01\x01".encode()
|
| 62 |
+
client = imaplib.IMAP4_SSL("outlook.office365.com", 993)
|
| 63 |
+
try:
|
| 64 |
+
client.authenticate("XOAUTH2", lambda _: auth_string)
|
| 65 |
+
except Exception as exc:
|
| 66 |
+
self._log("error", f"Microsoft IMAP auth failed: {exc}")
|
| 67 |
+
try:
|
| 68 |
+
client.logout()
|
| 69 |
+
except Exception:
|
| 70 |
+
pass
|
| 71 |
+
return None
|
| 72 |
+
|
| 73 |
+
search_since = since_time or (datetime.utcnow() - timedelta(minutes=5))
|
| 74 |
+
since_str = search_since.strftime("%d-%b-%Y")
|
| 75 |
+
|
| 76 |
+
try:
|
| 77 |
+
for mailbox in ("INBOX", "Junk"):
|
| 78 |
+
try:
|
| 79 |
+
status, _ = client.select(mailbox, readonly=True)
|
| 80 |
+
if status != "OK":
|
| 81 |
+
continue
|
| 82 |
+
except Exception:
|
| 83 |
+
continue
|
| 84 |
+
|
| 85 |
+
status, data = client.search(None, "SINCE", since_str)
|
| 86 |
+
if status != "OK" or not data or not data[0]:
|
| 87 |
+
continue
|
| 88 |
+
|
| 89 |
+
ids = data[0].split()
|
| 90 |
+
ids = ids[-20:]
|
| 91 |
+
for msg_id in reversed(ids):
|
| 92 |
+
status, msg_data = client.fetch(msg_id, "(RFC822)")
|
| 93 |
+
if status != "OK" or not msg_data:
|
| 94 |
+
continue
|
| 95 |
+
raw_bytes = None
|
| 96 |
+
for item in msg_data:
|
| 97 |
+
if isinstance(item, tuple) and len(item) > 1:
|
| 98 |
+
raw_bytes = item[1]
|
| 99 |
+
break
|
| 100 |
+
if not raw_bytes:
|
| 101 |
+
continue
|
| 102 |
+
|
| 103 |
+
msg = message_from_bytes(raw_bytes)
|
| 104 |
+
msg_date = self._parse_message_date(msg.get("Date"))
|
| 105 |
+
if msg_date and msg_date < search_since:
|
| 106 |
+
continue
|
| 107 |
+
|
| 108 |
+
content = self._message_to_text(msg)
|
| 109 |
+
code = extract_verification_code(content)
|
| 110 |
+
if code:
|
| 111 |
+
return code
|
| 112 |
+
finally:
|
| 113 |
+
try:
|
| 114 |
+
client.logout()
|
| 115 |
+
except Exception:
|
| 116 |
+
pass
|
| 117 |
+
|
| 118 |
+
return None
|
| 119 |
+
|
| 120 |
+
def poll_for_code(
|
| 121 |
+
self,
|
| 122 |
+
timeout: int = 120,
|
| 123 |
+
interval: int = 4,
|
| 124 |
+
since_time: Optional[datetime] = None,
|
| 125 |
+
) -> Optional[str]:
|
| 126 |
+
if not self.email:
|
| 127 |
+
return None
|
| 128 |
+
|
| 129 |
+
max_retries = max(1, timeout // interval)
|
| 130 |
+
for _ in range(max_retries):
|
| 131 |
+
code = self.fetch_verification_code(since_time=since_time)
|
| 132 |
+
if code:
|
| 133 |
+
return code
|
| 134 |
+
if interval:
|
| 135 |
+
time.sleep(interval)
|
| 136 |
+
return None
|
| 137 |
+
|
| 138 |
+
@staticmethod
|
| 139 |
+
def _message_to_text(msg) -> str:
|
| 140 |
+
if msg.is_multipart():
|
| 141 |
+
parts = []
|
| 142 |
+
for part in msg.walk():
|
| 143 |
+
content_type = part.get_content_type()
|
| 144 |
+
if content_type not in ("text/plain", "text/html"):
|
| 145 |
+
continue
|
| 146 |
+
payload = part.get_payload(decode=True)
|
| 147 |
+
if not payload:
|
| 148 |
+
continue
|
| 149 |
+
charset = part.get_content_charset() or "utf-8"
|
| 150 |
+
parts.append(payload.decode(charset, errors="ignore"))
|
| 151 |
+
return "".join(parts)
|
| 152 |
+
payload = msg.get_payload(decode=True)
|
| 153 |
+
if isinstance(payload, bytes):
|
| 154 |
+
return payload.decode(msg.get_content_charset() or "utf-8", errors="ignore")
|
| 155 |
+
return str(payload) if payload else ""
|
| 156 |
+
|
| 157 |
+
@staticmethod
|
| 158 |
+
def _parse_message_date(value: Optional[str]) -> Optional[datetime]:
|
| 159 |
+
if not value:
|
| 160 |
+
return None
|
| 161 |
+
try:
|
| 162 |
+
parsed = parsedate_to_datetime(value)
|
| 163 |
+
if parsed is None:
|
| 164 |
+
return None
|
| 165 |
+
if parsed.tzinfo:
|
| 166 |
+
return parsed.astimezone(tz=None).replace(tzinfo=None)
|
| 167 |
+
return parsed
|
| 168 |
+
except Exception:
|
| 169 |
+
return None
|
| 170 |
+
|
| 171 |
+
def _log(self, level: str, message: str) -> None:
|
| 172 |
+
if self.log_callback:
|
| 173 |
+
try:
|
| 174 |
+
self.log_callback(level, message)
|
| 175 |
+
except Exception:
|
| 176 |
+
pass
|
core/register_service.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
import os
|
| 4 |
+
import time
|
| 5 |
+
import uuid
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from typing import Any, Callable, Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
from core.account import load_accounts_from_source
|
| 10 |
+
from core.base_task_service import BaseTask, BaseTaskService, TaskStatus
|
| 11 |
+
from core.config import config
|
| 12 |
+
from core.duckmail_client import DuckMailClient
|
| 13 |
+
from core.gemini_automation import GeminiAutomation
|
| 14 |
+
from core.gemini_automation_uc import GeminiAutomationUC
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("gemini.register")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class RegisterTask(BaseTask):
|
| 21 |
+
"""注册任务数据类"""
|
| 22 |
+
count: int = 0
|
| 23 |
+
|
| 24 |
+
def to_dict(self) -> dict:
|
| 25 |
+
"""转换为字典"""
|
| 26 |
+
base_dict = super().to_dict()
|
| 27 |
+
base_dict["count"] = self.count
|
| 28 |
+
return base_dict
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class RegisterService(BaseTaskService[RegisterTask]):
|
| 32 |
+
"""注册服务类"""
|
| 33 |
+
|
| 34 |
+
def __init__(
|
| 35 |
+
self,
|
| 36 |
+
multi_account_mgr,
|
| 37 |
+
http_client,
|
| 38 |
+
user_agent: str,
|
| 39 |
+
account_failure_threshold: int,
|
| 40 |
+
rate_limit_cooldown_seconds: int,
|
| 41 |
+
session_cache_ttl_seconds: int,
|
| 42 |
+
global_stats_provider: Callable[[], dict],
|
| 43 |
+
set_multi_account_mgr: Optional[Callable[[Any], None]] = None,
|
| 44 |
+
) -> None:
|
| 45 |
+
super().__init__(
|
| 46 |
+
multi_account_mgr,
|
| 47 |
+
http_client,
|
| 48 |
+
user_agent,
|
| 49 |
+
account_failure_threshold,
|
| 50 |
+
rate_limit_cooldown_seconds,
|
| 51 |
+
session_cache_ttl_seconds,
|
| 52 |
+
global_stats_provider,
|
| 53 |
+
set_multi_account_mgr,
|
| 54 |
+
log_prefix="REGISTER",
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
async def start_register(self, count: Optional[int] = None, domain: Optional[str] = None) -> RegisterTask:
|
| 58 |
+
"""启动注册任务"""
|
| 59 |
+
async with self._lock:
|
| 60 |
+
if os.environ.get("ACCOUNTS_CONFIG"):
|
| 61 |
+
raise ValueError("ACCOUNTS_CONFIG is set; register is disabled")
|
| 62 |
+
if self._current_task_id:
|
| 63 |
+
current = self._tasks.get(self._current_task_id)
|
| 64 |
+
if current and current.status == TaskStatus.RUNNING:
|
| 65 |
+
raise ValueError("register task already running")
|
| 66 |
+
|
| 67 |
+
domain_value = (domain or "").strip()
|
| 68 |
+
if not domain_value:
|
| 69 |
+
domain_value = (config.basic.register_domain or "").strip() or None
|
| 70 |
+
|
| 71 |
+
register_count = count or config.basic.register_default_count
|
| 72 |
+
register_count = max(1, min(30, int(register_count)))
|
| 73 |
+
task = RegisterTask(id=str(uuid.uuid4()), count=register_count)
|
| 74 |
+
self._tasks[task.id] = task
|
| 75 |
+
self._current_task_id = task.id
|
| 76 |
+
self._append_log(task, "info", f"register task created (count={register_count})")
|
| 77 |
+
asyncio.create_task(self._run_register_async(task, domain_value))
|
| 78 |
+
return task
|
| 79 |
+
|
| 80 |
+
async def _run_register_async(self, task: RegisterTask, domain: Optional[str]) -> None:
|
| 81 |
+
"""异步执行注册任务"""
|
| 82 |
+
task.status = TaskStatus.RUNNING
|
| 83 |
+
loop = asyncio.get_running_loop()
|
| 84 |
+
self._append_log(task, "info", "register task started")
|
| 85 |
+
|
| 86 |
+
for _ in range(task.count):
|
| 87 |
+
try:
|
| 88 |
+
result = await loop.run_in_executor(self._executor, self._register_one, domain, task)
|
| 89 |
+
except Exception as exc:
|
| 90 |
+
result = {"success": False, "error": str(exc)}
|
| 91 |
+
task.progress += 1
|
| 92 |
+
task.results.append(result)
|
| 93 |
+
|
| 94 |
+
if result.get("success"):
|
| 95 |
+
task.success_count += 1
|
| 96 |
+
self._append_log(task, "info", f"register success: {result.get('email')}")
|
| 97 |
+
else:
|
| 98 |
+
task.fail_count += 1
|
| 99 |
+
self._append_log(task, "error", f"register failed: {result.get('error')}")
|
| 100 |
+
|
| 101 |
+
task.status = TaskStatus.SUCCESS if task.fail_count == 0 else TaskStatus.FAILED
|
| 102 |
+
task.finished_at = time.time()
|
| 103 |
+
self._current_task_id = None
|
| 104 |
+
self._append_log(task, "info", f"register task finished ({task.success_count}/{task.count})")
|
| 105 |
+
|
| 106 |
+
def _register_one(self, domain: Optional[str], task: RegisterTask) -> dict:
|
| 107 |
+
"""注册单个账户"""
|
| 108 |
+
log_cb = lambda level, message: self._append_log(task, level, message)
|
| 109 |
+
client = DuckMailClient(
|
| 110 |
+
base_url=config.basic.duckmail_base_url,
|
| 111 |
+
proxy=config.basic.proxy,
|
| 112 |
+
verify_ssl=config.basic.duckmail_verify_ssl,
|
| 113 |
+
api_key=config.basic.duckmail_api_key,
|
| 114 |
+
log_callback=log_cb,
|
| 115 |
+
)
|
| 116 |
+
if not client.register_account(domain=domain):
|
| 117 |
+
return {"success": False, "error": "duckmail register failed"}
|
| 118 |
+
|
| 119 |
+
automation = GeminiAutomationUC(
|
| 120 |
+
user_agent=self.user_agent,
|
| 121 |
+
proxy=config.basic.proxy,
|
| 122 |
+
headless=config.basic.browser_headless,
|
| 123 |
+
log_callback=log_cb,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
try:
|
| 127 |
+
result = automation.login_and_extract(client.email, client)
|
| 128 |
+
except Exception as exc:
|
| 129 |
+
return {"success": False, "error": str(exc)}
|
| 130 |
+
if not result.get("success"):
|
| 131 |
+
return {"success": False, "error": result.get("error", "automation failed")}
|
| 132 |
+
|
| 133 |
+
config_data = result["config"]
|
| 134 |
+
config_data["mail_provider"] = "duckmail"
|
| 135 |
+
config_data["mail_address"] = client.email
|
| 136 |
+
config_data["mail_password"] = client.password
|
| 137 |
+
|
| 138 |
+
accounts_data = load_accounts_from_source()
|
| 139 |
+
updated = False
|
| 140 |
+
for acc in accounts_data:
|
| 141 |
+
if acc.get("id") == config_data["id"]:
|
| 142 |
+
acc.update(config_data)
|
| 143 |
+
updated = True
|
| 144 |
+
break
|
| 145 |
+
if not updated:
|
| 146 |
+
accounts_data.append(config_data)
|
| 147 |
+
|
| 148 |
+
self._apply_accounts_update(accounts_data)
|
| 149 |
+
|
| 150 |
+
return {"success": True, "email": client.email, "config": config_data}
|