xiaoyukkkk commited on
Commit
b373ec8
·
unverified ·
1 Parent(s): 641f10a

Upload 18 files

Browse files
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) # 读取手动禁用状态,默认为 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, PATH_PREFIX, SESSION_SECRET_KEY)
11
- - 业务配置:环境变量 > YAML,支持热更新(API_KEY, PROXY, 重试策略等)
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 os.getenv("API_KEY", ""),
125
- base_url=basic_data.get("base_url") or os.getenv("BASE_URL", ""),
126
- proxy=basic_data.get("proxy") or os.getenv("PROXY", "")
 
 
 
 
 
 
 
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}