Upload 18 files
Browse files- core/config.py +2 -2
- core/duckmail_client.py +45 -29
- core/gemini_automation.py +151 -22
- core/gemini_automation_uc.py +33 -8
- core/google_api.py +20 -20
- core/login_service.py +15 -4
- core/message.py +30 -30
- core/register_service.py +15 -4
- core/session_auth.py +3 -3
- core/uptime.py +139 -139
core/config.py
CHANGED
|
@@ -49,8 +49,8 @@ class BasicConfig(BaseModel):
|
|
| 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_engine: str = Field(default="dp", description="浏览器引擎:uc
|
| 53 |
-
browser_headless: bool = Field(default=False, description="自动化浏览器无头模式
|
| 54 |
refresh_window_hours: int = Field(default=1, ge=0, le=24, description="过期刷新窗口(小时)")
|
| 55 |
register_default_count: int = Field(default=1, ge=1, le=30, description="默认注册数量")
|
| 56 |
register_domain: 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_engine: str = Field(default="dp", description="浏览器引擎:uc 或 dp")
|
| 53 |
+
browser_headless: bool = Field(default=False, description="自动化浏览器无头模式")
|
| 54 |
refresh_window_hours: int = Field(default=1, ge=0, le=24, description="过期刷新窗口(小时)")
|
| 55 |
register_default_count: int = Field(default=1, ge=1, le=30, description="默认注册数量")
|
| 56 |
register_domain: str = Field(default="", description="默认注册域名(推荐)")
|
core/duckmail_client.py
CHANGED
|
@@ -147,36 +147,52 @@ class DuckMailClient:
|
|
| 147 |
if not messages:
|
| 148 |
return None
|
| 149 |
|
| 150 |
-
#
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
-
|
| 171 |
-
html_content = "".join(str(item) for item in html_content)
|
| 172 |
-
if isinstance(text_content, list):
|
| 173 |
-
text_content = "".join(str(item) for item in text_content)
|
| 174 |
-
|
| 175 |
-
content = text_content + html_content
|
| 176 |
-
code = extract_verification_code(content)
|
| 177 |
-
if code:
|
| 178 |
-
self._log("info", f"code found: {code}")
|
| 179 |
-
return code
|
| 180 |
|
| 181 |
except Exception as e:
|
| 182 |
self._log("error", f"fetch code failed: {e}")
|
|
|
|
| 147 |
if not messages:
|
| 148 |
return None
|
| 149 |
|
| 150 |
+
# 遍历邮件,过滤时间
|
| 151 |
+
for msg in messages:
|
| 152 |
+
msg_id = msg.get("id")
|
| 153 |
+
if not msg_id:
|
| 154 |
+
continue
|
| 155 |
+
|
| 156 |
+
# 时间过滤
|
| 157 |
+
if since_time:
|
| 158 |
+
created_at = msg.get("createdAt")
|
| 159 |
+
if created_at:
|
| 160 |
+
from datetime import datetime
|
| 161 |
+
import re
|
| 162 |
+
# 截断纳秒到微秒(fromisoformat 只支持6位小数)
|
| 163 |
+
created_at = re.sub(r'(\.\d{6})\d+', r'\1', created_at)
|
| 164 |
+
# 转换 UTC 时间到本地时区
|
| 165 |
+
msg_time = datetime.fromisoformat(created_at.replace("Z", "+00:00")).astimezone().replace(tzinfo=None)
|
| 166 |
+
if msg_time < since_time:
|
| 167 |
+
continue
|
| 168 |
+
|
| 169 |
+
detail = self._request(
|
| 170 |
+
"GET",
|
| 171 |
+
f"{self.base_url}/messages/{msg_id}",
|
| 172 |
+
headers={"Authorization": f"Bearer {self.token}"},
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
if detail.status_code != 200:
|
| 176 |
+
continue
|
| 177 |
+
|
| 178 |
+
payload = detail.json() if detail.content else {}
|
| 179 |
+
|
| 180 |
+
# 获取邮件内容
|
| 181 |
+
text_content = payload.get("text") or ""
|
| 182 |
+
html_content = payload.get("html") or ""
|
| 183 |
+
|
| 184 |
+
if isinstance(html_content, list):
|
| 185 |
+
html_content = "".join(str(item) for item in html_content)
|
| 186 |
+
if isinstance(text_content, list):
|
| 187 |
+
text_content = "".join(str(item) for item in text_content)
|
| 188 |
+
|
| 189 |
+
content = text_content + html_content
|
| 190 |
+
code = extract_verification_code(content)
|
| 191 |
+
if code:
|
| 192 |
+
self._log("info", f"code found: {code}")
|
| 193 |
+
return code
|
| 194 |
|
| 195 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
except Exception as e:
|
| 198 |
self._log("error", f"fetch code failed: {e}")
|
core/gemini_automation.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 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 |
|
|
@@ -15,6 +16,22 @@ from DrissionPage import ChromiumPage, ChromiumOptions
|
|
| 15 |
AUTH_HOME_URL = "https://auth.business.gemini.google/"
|
| 16 |
DEFAULT_XSRF_TOKEN = "KdLRzKwwBTD5wo8nUollAbY6cW0"
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
class GeminiAutomation:
|
| 20 |
"""Gemini自动化登录"""
|
|
@@ -36,8 +53,10 @@ class GeminiAutomation:
|
|
| 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}")
|
|
@@ -48,16 +67,29 @@ class GeminiAutomation:
|
|
| 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 |
|
|
@@ -70,7 +102,6 @@ class GeminiAutomation:
|
|
| 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()
|
|
@@ -85,6 +116,23 @@ class GeminiAutomation:
|
|
| 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
|
|
@@ -94,6 +142,10 @@ class GeminiAutomation:
|
|
| 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 |
|
|
@@ -132,10 +184,7 @@ class GeminiAutomation:
|
|
| 132 |
if has_business_params:
|
| 133 |
return self._extract_config(page, email)
|
| 134 |
|
| 135 |
-
# Step 3:
|
| 136 |
-
from datetime import datetime
|
| 137 |
-
send_time = datetime.now()
|
| 138 |
-
|
| 139 |
self._log("info", "clicking send verification code button")
|
| 140 |
if not self._click_send_code_button(page):
|
| 141 |
self._log("error", "send code button not found")
|
|
@@ -154,9 +203,22 @@ class GeminiAutomation:
|
|
| 154 |
code = mail_client.poll_for_code(timeout=40, interval=4, since_time=send_time)
|
| 155 |
|
| 156 |
if not code:
|
| 157 |
-
self._log("
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
self._log("info", f"code received: {code}")
|
| 162 |
|
|
@@ -168,46 +230,76 @@ class GeminiAutomation:
|
|
| 168 |
self._log("error", "code input expired")
|
| 169 |
return {"success": False, "error": "code input expired"}
|
| 170 |
|
|
|
|
| 171 |
code_input.input(code, clear=True)
|
| 172 |
time.sleep(0.5)
|
| 173 |
|
| 174 |
verify_btn = page.ele("css:button[jsname='XooR8e']", timeout=3)
|
| 175 |
if verify_btn:
|
|
|
|
| 176 |
verify_btn.click()
|
| 177 |
else:
|
| 178 |
verify_btn = self._find_verify_button(page)
|
| 179 |
if verify_btn:
|
|
|
|
| 180 |
verify_btn.click()
|
| 181 |
else:
|
|
|
|
| 182 |
code_input.input("\n")
|
| 183 |
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
-
# Step
|
| 187 |
self._handle_agreement_page(page)
|
| 188 |
|
| 189 |
-
# Step
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
|
| 194 |
-
# Step
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
if "cid" not in page.url:
|
| 196 |
if self._handle_username_setup(page):
|
| 197 |
-
time.sleep(
|
| 198 |
|
| 199 |
-
# Step
|
| 200 |
self._log("info", "waiting for URL parameters")
|
| 201 |
if not self._wait_for_business_params(page):
|
| 202 |
self._log("warning", "URL parameters not generated, trying refresh")
|
| 203 |
page.refresh()
|
| 204 |
-
time.sleep(
|
| 205 |
if not self._wait_for_business_params(page):
|
| 206 |
self._log("error", "URL parameters generation failed")
|
|
|
|
|
|
|
| 207 |
self._save_screenshot(page, "params_missing")
|
| 208 |
return {"success": False, "error": "URL parameters not found"}
|
| 209 |
|
| 210 |
-
# Step
|
| 211 |
self._log("info", "login success")
|
| 212 |
return self._extract_config(page, email)
|
| 213 |
|
|
@@ -277,6 +369,28 @@ class GeminiAutomation:
|
|
| 277 |
pass
|
| 278 |
return None
|
| 279 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
def _handle_agreement_page(self, page) -> None:
|
| 281 |
"""处理协议页面"""
|
| 282 |
if "/admin/create" in page.url:
|
|
@@ -376,10 +490,14 @@ class GeminiAutomation:
|
|
| 376 |
host = next((c["value"] for c in cookies if c["name"] == "__Host-C_OSES"), None)
|
| 377 |
|
| 378 |
ses_obj = next((c for c in cookies if c["name"] == "__Secure-C_SES"), None)
|
|
|
|
|
|
|
| 379 |
if ses_obj and "expiry" in ses_obj:
|
| 380 |
-
|
|
|
|
|
|
|
| 381 |
else:
|
| 382 |
-
expires_at = (datetime.now() + timedelta(hours=12)).strftime("%Y-%m-%d %H:%M:%S")
|
| 383 |
|
| 384 |
config = {
|
| 385 |
"id": email,
|
|
@@ -412,6 +530,17 @@ class GeminiAutomation:
|
|
| 412 |
except Exception:
|
| 413 |
pass
|
| 414 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
@staticmethod
|
| 416 |
def _get_ua() -> str:
|
| 417 |
"""生成随机User-Agent"""
|
|
|
|
| 1 |
"""
|
| 2 |
Gemini自动化登录模块(用于新账号注册)
|
| 3 |
"""
|
| 4 |
+
import os
|
| 5 |
import random
|
| 6 |
import string
|
| 7 |
import time
|
| 8 |
+
from datetime import datetime, timedelta, timezone
|
| 9 |
from typing import Optional
|
| 10 |
from urllib.parse import quote
|
| 11 |
|
|
|
|
| 16 |
AUTH_HOME_URL = "https://auth.business.gemini.google/"
|
| 17 |
DEFAULT_XSRF_TOKEN = "KdLRzKwwBTD5wo8nUollAbY6cW0"
|
| 18 |
|
| 19 |
+
# Linux 下常见的 Chromium 路径
|
| 20 |
+
CHROMIUM_PATHS = [
|
| 21 |
+
"/usr/bin/chromium",
|
| 22 |
+
"/usr/bin/chromium-browser",
|
| 23 |
+
"/usr/bin/google-chrome",
|
| 24 |
+
"/usr/bin/google-chrome-stable",
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _find_chromium_path() -> Optional[str]:
|
| 29 |
+
"""查找可用的 Chromium/Chrome 浏览器路径"""
|
| 30 |
+
for path in CHROMIUM_PATHS:
|
| 31 |
+
if os.path.isfile(path) and os.access(path, os.X_OK):
|
| 32 |
+
return path
|
| 33 |
+
return None
|
| 34 |
+
|
| 35 |
|
| 36 |
class GeminiAutomation:
|
| 37 |
"""Gemini自动化登录"""
|
|
|
|
| 53 |
def login_and_extract(self, email: str, mail_client) -> dict:
|
| 54 |
"""执行登录并提取配置"""
|
| 55 |
page = None
|
| 56 |
+
user_data_dir = None
|
| 57 |
try:
|
| 58 |
page = self._create_page()
|
| 59 |
+
user_data_dir = getattr(page, 'user_data_dir', None)
|
| 60 |
return self._run_flow(page, email, mail_client)
|
| 61 |
except Exception as exc:
|
| 62 |
self._log("error", f"automation error: {exc}")
|
|
|
|
| 67 |
page.quit()
|
| 68 |
except Exception:
|
| 69 |
pass
|
| 70 |
+
self._cleanup_user_data(user_data_dir)
|
| 71 |
|
| 72 |
def _create_page(self) -> ChromiumPage:
|
| 73 |
"""创建浏览器页面"""
|
| 74 |
options = ChromiumOptions()
|
| 75 |
+
|
| 76 |
+
# 自动检测 Chromium 浏览器路径(Linux/Docker 环境)
|
| 77 |
+
chromium_path = _find_chromium_path()
|
| 78 |
+
if chromium_path:
|
| 79 |
+
options.set_browser_path(chromium_path)
|
| 80 |
+
self._log("info", f"using browser: {chromium_path}")
|
| 81 |
+
|
| 82 |
+
options.set_argument("--incognito")
|
| 83 |
options.set_argument("--no-sandbox")
|
| 84 |
options.set_argument("--disable-setuid-sandbox")
|
| 85 |
options.set_argument("--disable-blink-features=AutomationControlled")
|
| 86 |
options.set_argument("--window-size=1280,800")
|
| 87 |
options.set_user_agent(self.user_agent)
|
| 88 |
|
| 89 |
+
# 语言设置(确保使用中文界面)
|
| 90 |
+
options.set_argument("--lang=zh-CN")
|
| 91 |
+
options.set_pref("intl.accept_languages", "zh-CN,zh")
|
| 92 |
+
|
| 93 |
if self.proxy:
|
| 94 |
options.set_argument(f"--proxy-server={self.proxy}")
|
| 95 |
|
|
|
|
| 102 |
options.set_argument("--disable-extensions")
|
| 103 |
# 反检测参数
|
| 104 |
options.set_argument("--disable-infobars")
|
|
|
|
| 105 |
options.set_argument("--enable-features=NetworkService,NetworkServiceInProcess")
|
| 106 |
|
| 107 |
options.auto_port()
|
|
|
|
| 116 |
Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3, 4, 5]});
|
| 117 |
Object.defineProperty(navigator, 'languages', {get: () => ['zh-CN', 'zh', 'en']});
|
| 118 |
window.chrome = {runtime: {}};
|
| 119 |
+
|
| 120 |
+
// 额外的反检测措施
|
| 121 |
+
Object.defineProperty(navigator, 'maxTouchPoints', {get: () => 1});
|
| 122 |
+
Object.defineProperty(navigator, 'platform', {get: () => 'Win32'});
|
| 123 |
+
Object.defineProperty(navigator, 'vendor', {get: () => 'Google Inc.'});
|
| 124 |
+
|
| 125 |
+
// 隐藏 headless 特征
|
| 126 |
+
Object.defineProperty(navigator, 'hardwareConcurrency', {get: () => 8});
|
| 127 |
+
Object.defineProperty(navigator, 'deviceMemory', {get: () => 8});
|
| 128 |
+
|
| 129 |
+
// 模拟真实的 permissions
|
| 130 |
+
const originalQuery = window.navigator.permissions.query;
|
| 131 |
+
window.navigator.permissions.query = (parameters) => (
|
| 132 |
+
parameters.name === 'notifications' ?
|
| 133 |
+
Promise.resolve({state: Notification.permission}) :
|
| 134 |
+
originalQuery(parameters)
|
| 135 |
+
);
|
| 136 |
""")
|
| 137 |
except Exception:
|
| 138 |
pass
|
|
|
|
| 142 |
def _run_flow(self, page, email: str, mail_client) -> dict:
|
| 143 |
"""执行登录流程"""
|
| 144 |
|
| 145 |
+
# 记录开始时间,用于邮件时间过滤
|
| 146 |
+
from datetime import datetime
|
| 147 |
+
send_time = datetime.now()
|
| 148 |
+
|
| 149 |
# Step 1: 导航到首页并设置 Cookie
|
| 150 |
self._log("info", f"navigating to login page for {email}")
|
| 151 |
|
|
|
|
| 184 |
if has_business_params:
|
| 185 |
return self._extract_config(page, email)
|
| 186 |
|
| 187 |
+
# Step 3: 点击发送验证码按钮
|
|
|
|
|
|
|
|
|
|
| 188 |
self._log("info", "clicking send verification code button")
|
| 189 |
if not self._click_send_code_button(page):
|
| 190 |
self._log("error", "send code button not found")
|
|
|
|
| 203 |
code = mail_client.poll_for_code(timeout=40, interval=4, since_time=send_time)
|
| 204 |
|
| 205 |
if not code:
|
| 206 |
+
self._log("warning", "verification code timeout, trying to resend")
|
| 207 |
+
# 更新发送时间(在点击按钮之前记录)
|
| 208 |
+
send_time = datetime.now()
|
| 209 |
+
# 尝试点击重新发送按钮
|
| 210 |
+
if self._click_resend_code_button(page):
|
| 211 |
+
self._log("info", "resend button clicked, waiting for new code")
|
| 212 |
+
# 再次轮询验证码
|
| 213 |
+
code = mail_client.poll_for_code(timeout=40, interval=4, since_time=send_time)
|
| 214 |
+
if not code:
|
| 215 |
+
self._log("error", "verification code timeout after resend")
|
| 216 |
+
self._save_screenshot(page, "code_timeout_after_resend")
|
| 217 |
+
return {"success": False, "error": "verification code timeout after resend"}
|
| 218 |
+
else:
|
| 219 |
+
self._log("error", "verification code timeout and resend button not found")
|
| 220 |
+
self._save_screenshot(page, "code_timeout")
|
| 221 |
+
return {"success": False, "error": "verification code timeout"}
|
| 222 |
|
| 223 |
self._log("info", f"code received: {code}")
|
| 224 |
|
|
|
|
| 230 |
self._log("error", "code input expired")
|
| 231 |
return {"success": False, "error": "code input expired"}
|
| 232 |
|
| 233 |
+
self._log("info", "inputting verification code")
|
| 234 |
code_input.input(code, clear=True)
|
| 235 |
time.sleep(0.5)
|
| 236 |
|
| 237 |
verify_btn = page.ele("css:button[jsname='XooR8e']", timeout=3)
|
| 238 |
if verify_btn:
|
| 239 |
+
self._log("info", "clicking verify button (method 1)")
|
| 240 |
verify_btn.click()
|
| 241 |
else:
|
| 242 |
verify_btn = self._find_verify_button(page)
|
| 243 |
if verify_btn:
|
| 244 |
+
self._log("info", "clicking verify button (method 2)")
|
| 245 |
verify_btn.click()
|
| 246 |
else:
|
| 247 |
+
self._log("info", "pressing enter to submit")
|
| 248 |
code_input.input("\n")
|
| 249 |
|
| 250 |
+
# Step 7: 等待页面自动重定向(提交验证码后 Google 会自动跳转)
|
| 251 |
+
self._log("info", "waiting for auto-redirect after verification")
|
| 252 |
+
time.sleep(12) # 增加等待时间,让页面有足够时间完成重定向(如果网络慢可以继续增加)
|
| 253 |
+
|
| 254 |
+
# 记录当前 URL 状态
|
| 255 |
+
current_url = page.url
|
| 256 |
+
self._log("info", f"current URL after verification: {current_url}")
|
| 257 |
+
|
| 258 |
+
# 检查是否还停留在验证码页面(说明提交失败)
|
| 259 |
+
if "verify-oob-code" in current_url:
|
| 260 |
+
self._log("error", "verification code submission failed, still on verification page")
|
| 261 |
+
self._save_screenshot(page, "verification_submit_failed")
|
| 262 |
+
return {"success": False, "error": "verification code submission failed"}
|
| 263 |
|
| 264 |
+
# Step 8: 处理协议页面(如果有)
|
| 265 |
self._handle_agreement_page(page)
|
| 266 |
|
| 267 |
+
# Step 9: 检查是否已经在正确的页面
|
| 268 |
+
current_url = page.url
|
| 269 |
+
has_business_params = "business.gemini.google" in current_url and "csesidx=" in current_url and "/cid/" in current_url
|
| 270 |
+
|
| 271 |
+
if has_business_params:
|
| 272 |
+
# 已经在正确的页面,不需要再次导航
|
| 273 |
+
self._log("info", "already on business page with parameters")
|
| 274 |
+
return self._extract_config(page, email)
|
| 275 |
|
| 276 |
+
# Step 10: 如果不在正确的页面,尝试导航
|
| 277 |
+
if "business.gemini.google" not in current_url:
|
| 278 |
+
self._log("info", "navigating to business page")
|
| 279 |
+
page.get("https://business.gemini.google/", timeout=self.timeout)
|
| 280 |
+
time.sleep(5) # 增加等待时间
|
| 281 |
+
current_url = page.url
|
| 282 |
+
self._log("info", f"URL after navigation: {current_url}")
|
| 283 |
+
|
| 284 |
+
# Step 11: 检查是否需要设置用户名
|
| 285 |
if "cid" not in page.url:
|
| 286 |
if self._handle_username_setup(page):
|
| 287 |
+
time.sleep(5) # 增加等待时间
|
| 288 |
|
| 289 |
+
# Step 12: 等待 URL 参数生成(csesidx 和 cid)
|
| 290 |
self._log("info", "waiting for URL parameters")
|
| 291 |
if not self._wait_for_business_params(page):
|
| 292 |
self._log("warning", "URL parameters not generated, trying refresh")
|
| 293 |
page.refresh()
|
| 294 |
+
time.sleep(5) # 增加等待时间
|
| 295 |
if not self._wait_for_business_params(page):
|
| 296 |
self._log("error", "URL parameters generation failed")
|
| 297 |
+
current_url = page.url
|
| 298 |
+
self._log("error", f"final URL: {current_url}")
|
| 299 |
self._save_screenshot(page, "params_missing")
|
| 300 |
return {"success": False, "error": "URL parameters not found"}
|
| 301 |
|
| 302 |
+
# Step 13: 提取配置
|
| 303 |
self._log("info", "login success")
|
| 304 |
return self._extract_config(page, email)
|
| 305 |
|
|
|
|
| 369 |
pass
|
| 370 |
return None
|
| 371 |
|
| 372 |
+
def _click_resend_code_button(self, page) -> bool:
|
| 373 |
+
"""点击重新发送验证码按钮"""
|
| 374 |
+
time.sleep(2)
|
| 375 |
+
|
| 376 |
+
# 查找包含重新发送关键词的按钮(与 _find_verify_button 相反)
|
| 377 |
+
try:
|
| 378 |
+
buttons = page.eles("tag:button")
|
| 379 |
+
for btn in buttons:
|
| 380 |
+
text = (btn.text or "").strip().lower()
|
| 381 |
+
if text and ("重新" in text or "resend" in text):
|
| 382 |
+
try:
|
| 383 |
+
self._log("info", f"found resend button: {text}")
|
| 384 |
+
btn.click()
|
| 385 |
+
time.sleep(2)
|
| 386 |
+
return True
|
| 387 |
+
except Exception:
|
| 388 |
+
pass
|
| 389 |
+
except Exception:
|
| 390 |
+
pass
|
| 391 |
+
|
| 392 |
+
return False
|
| 393 |
+
|
| 394 |
def _handle_agreement_page(self, page) -> None:
|
| 395 |
"""处理协议页面"""
|
| 396 |
if "/admin/create" in page.url:
|
|
|
|
| 490 |
host = next((c["value"] for c in cookies if c["name"] == "__Host-C_OSES"), None)
|
| 491 |
|
| 492 |
ses_obj = next((c for c in cookies if c["name"] == "__Secure-C_SES"), None)
|
| 493 |
+
# 使用北京时区,确保时间计算正确(Cookie expiry 是 UTC 时间戳)
|
| 494 |
+
beijing_tz = timezone(timedelta(hours=8))
|
| 495 |
if ses_obj and "expiry" in ses_obj:
|
| 496 |
+
# 将 UTC 时间戳转为北京时间,再减去12小时作为刷新窗口
|
| 497 |
+
cookie_expire_beijing = datetime.fromtimestamp(ses_obj["expiry"], tz=beijing_tz)
|
| 498 |
+
expires_at = (cookie_expire_beijing - timedelta(hours=12)).strftime("%Y-%m-%d %H:%M:%S")
|
| 499 |
else:
|
| 500 |
+
expires_at = (datetime.now(beijing_tz) + timedelta(hours=12)).strftime("%Y-%m-%d %H:%M:%S")
|
| 501 |
|
| 502 |
config = {
|
| 503 |
"id": email,
|
|
|
|
| 530 |
except Exception:
|
| 531 |
pass
|
| 532 |
|
| 533 |
+
def _cleanup_user_data(self, user_data_dir: Optional[str]) -> None:
|
| 534 |
+
"""清理浏览器用户数据目录"""
|
| 535 |
+
if not user_data_dir:
|
| 536 |
+
return
|
| 537 |
+
try:
|
| 538 |
+
import shutil
|
| 539 |
+
if os.path.exists(user_data_dir):
|
| 540 |
+
shutil.rmtree(user_data_dir, ignore_errors=True)
|
| 541 |
+
except Exception:
|
| 542 |
+
pass
|
| 543 |
+
|
| 544 |
@staticmethod
|
| 545 |
def _get_ua() -> str:
|
| 546 |
"""生成随机User-Agent"""
|
core/gemini_automation_uc.py
CHANGED
|
@@ -5,7 +5,7 @@ Gemini自动化登录模块(使用 undetected-chromedriver)
|
|
| 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 |
|
|
@@ -39,6 +39,7 @@ class GeminiAutomationUC:
|
|
| 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 |
"""执行登录并提取配置"""
|
|
@@ -53,13 +54,25 @@ class GeminiAutomationUC:
|
|
| 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}")
|
|
@@ -88,6 +101,10 @@ class GeminiAutomationUC:
|
|
| 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 |
# 访问登录页面
|
|
@@ -130,10 +147,6 @@ class GeminiAutomationUC:
|
|
| 130 |
self._save_screenshot("continue_button_failed")
|
| 131 |
return {"success": False, "error": f"failed to click continue: {e}"}
|
| 132 |
|
| 133 |
-
# 记录发送验证码的时间
|
| 134 |
-
from datetime import datetime
|
| 135 |
-
send_time = datetime.now()
|
| 136 |
-
|
| 137 |
# 检查是否需要点击"发送验证码"按钮
|
| 138 |
self._log("info", "clicking send verification code button")
|
| 139 |
if not self._click_send_code_button():
|
|
@@ -401,12 +414,15 @@ class GeminiAutomationUC:
|
|
| 401 |
ses = next((c["value"] for c in cookies if c["name"] == "__Secure-C_SES"), None)
|
| 402 |
host = next((c["value"] for c in cookies if c["name"] == "__Host-C_OSES"), None)
|
| 403 |
|
| 404 |
-
# 计算过期时间
|
| 405 |
ses_obj = next((c for c in cookies if c["name"] == "__Secure-C_SES"), None)
|
|
|
|
| 406 |
if ses_obj and "expiry" in ses_obj:
|
| 407 |
-
|
|
|
|
|
|
|
| 408 |
else:
|
| 409 |
-
expires_at = (datetime.now() + timedelta(hours=12)).strftime("%Y-%m-%d %H:%M:%S")
|
| 410 |
|
| 411 |
config = {
|
| 412 |
"id": email,
|
|
@@ -439,6 +455,15 @@ class GeminiAutomationUC:
|
|
| 439 |
except Exception:
|
| 440 |
pass
|
| 441 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
def _log(self, level: str, message: str) -> None:
|
| 443 |
"""记录日志"""
|
| 444 |
if self.log_callback:
|
|
|
|
| 5 |
import random
|
| 6 |
import string
|
| 7 |
import time
|
| 8 |
+
from datetime import datetime, timedelta, timezone
|
| 9 |
from typing import Optional
|
| 10 |
from urllib.parse import quote
|
| 11 |
|
|
|
|
| 39 |
self.timeout = timeout
|
| 40 |
self.log_callback = log_callback
|
| 41 |
self.driver = None
|
| 42 |
+
self.user_data_dir = None
|
| 43 |
|
| 44 |
def login_and_extract(self, email: str, mail_client) -> dict:
|
| 45 |
"""执行登录并提取配置"""
|
|
|
|
| 54 |
|
| 55 |
def _create_driver(self):
|
| 56 |
"""创建浏览器驱动"""
|
| 57 |
+
import tempfile
|
| 58 |
options = uc.ChromeOptions()
|
| 59 |
|
| 60 |
+
# 创建临时用户数据目录
|
| 61 |
+
self.user_data_dir = tempfile.mkdtemp(prefix='uc-profile-')
|
| 62 |
+
options.add_argument(f"--user-data-dir={self.user_data_dir}")
|
| 63 |
+
|
| 64 |
# 基础参数
|
| 65 |
+
options.add_argument("--incognito")
|
| 66 |
options.add_argument("--no-sandbox")
|
| 67 |
options.add_argument("--disable-setuid-sandbox")
|
| 68 |
options.add_argument("--window-size=1280,800")
|
| 69 |
|
| 70 |
+
# 语言设置(确保使用中文界面)
|
| 71 |
+
options.add_argument("--lang=zh-CN")
|
| 72 |
+
options.add_experimental_option("prefs", {
|
| 73 |
+
"intl.accept_languages": "zh-CN,zh"
|
| 74 |
+
})
|
| 75 |
+
|
| 76 |
# 代理设置
|
| 77 |
if self.proxy:
|
| 78 |
options.add_argument(f"--proxy-server={self.proxy}")
|
|
|
|
| 101 |
def _run_flow(self, email: str, mail_client) -> dict:
|
| 102 |
"""执行登录流程"""
|
| 103 |
|
| 104 |
+
# 记录开始时间,用于邮件时间过滤
|
| 105 |
+
from datetime import datetime
|
| 106 |
+
send_time = datetime.now()
|
| 107 |
+
|
| 108 |
self._log("info", f"navigating to login page for {email}")
|
| 109 |
|
| 110 |
# 访问登录页面
|
|
|
|
| 147 |
self._save_screenshot("continue_button_failed")
|
| 148 |
return {"success": False, "error": f"failed to click continue: {e}"}
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
# 检查是否需要点击"发送验证码"按钮
|
| 151 |
self._log("info", "clicking send verification code button")
|
| 152 |
if not self._click_send_code_button():
|
|
|
|
| 414 |
ses = next((c["value"] for c in cookies if c["name"] == "__Secure-C_SES"), None)
|
| 415 |
host = next((c["value"] for c in cookies if c["name"] == "__Host-C_OSES"), None)
|
| 416 |
|
| 417 |
+
# 计算过期时间(使用北京时区,确保时间计算正确)
|
| 418 |
ses_obj = next((c for c in cookies if c["name"] == "__Secure-C_SES"), None)
|
| 419 |
+
beijing_tz = timezone(timedelta(hours=8))
|
| 420 |
if ses_obj and "expiry" in ses_obj:
|
| 421 |
+
# Cookie expiry 是 UTC 时间戳,转为北京时间后减去12小时作为刷新窗口
|
| 422 |
+
cookie_expire_beijing = datetime.fromtimestamp(ses_obj["expiry"], tz=beijing_tz)
|
| 423 |
+
expires_at = (cookie_expire_beijing - timedelta(hours=12)).strftime("%Y-%m-%d %H:%M:%S")
|
| 424 |
else:
|
| 425 |
+
expires_at = (datetime.now(beijing_tz) + timedelta(hours=12)).strftime("%Y-%m-%d %H:%M:%S")
|
| 426 |
|
| 427 |
config = {
|
| 428 |
"id": email,
|
|
|
|
| 455 |
except Exception:
|
| 456 |
pass
|
| 457 |
|
| 458 |
+
if self.user_data_dir:
|
| 459 |
+
try:
|
| 460 |
+
import shutil
|
| 461 |
+
import os
|
| 462 |
+
if os.path.exists(self.user_data_dir):
|
| 463 |
+
shutil.rmtree(self.user_data_dir, ignore_errors=True)
|
| 464 |
+
except Exception:
|
| 465 |
+
pass
|
| 466 |
+
|
| 467 |
def _log(self, level: str, message: str) -> None:
|
| 468 |
"""记录日志"""
|
| 469 |
if self.log_callback:
|
core/google_api.py
CHANGED
|
@@ -2,12 +2,12 @@
|
|
| 2 |
|
| 3 |
负责与Google Gemini Business API的所有交互操作
|
| 4 |
"""
|
| 5 |
-
import asyncio
|
| 6 |
-
import json
|
| 7 |
-
import logging
|
| 8 |
-
import os
|
| 9 |
-
import time
|
| 10 |
-
import uuid
|
| 11 |
from typing import TYPE_CHECKING, List
|
| 12 |
|
| 13 |
import httpx
|
|
@@ -163,20 +163,20 @@ async def upload_context_file(
|
|
| 163 |
)
|
| 164 |
|
| 165 |
req_tag = f"[req_{request_id}] " if request_id else ""
|
| 166 |
-
if r.status_code != 200:
|
| 167 |
-
logger.error(f"[FILE] [{account_manager.config.account_id}] {req_tag}文件上传失败: {r.status_code}")
|
| 168 |
-
error_text = r.text
|
| 169 |
-
if r.status_code == 400:
|
| 170 |
-
try:
|
| 171 |
-
payload = json.loads(r.text or "{}")
|
| 172 |
-
message = payload.get("error", {}).get("message", "")
|
| 173 |
-
except Exception:
|
| 174 |
-
message = ""
|
| 175 |
-
if "Unsupported file type" in message:
|
| 176 |
-
mime_type = message.split("Unsupported file type:", 1)[-1].strip()
|
| 177 |
-
hint = f"不支持的文件类型: {mime_type}。请转换为 PDF、图片或纯文本后再上传。"
|
| 178 |
-
raise HTTPException(400, hint)
|
| 179 |
-
raise HTTPException(r.status_code, f"Upload failed: {error_text}")
|
| 180 |
|
| 181 |
data = r.json()
|
| 182 |
file_id = data.get("addContextFileResponse", {}).get("fileId")
|
|
|
|
| 2 |
|
| 3 |
负责与Google Gemini Business API的所有交互操作
|
| 4 |
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
import json
|
| 7 |
+
import logging
|
| 8 |
+
import os
|
| 9 |
+
import time
|
| 10 |
+
import uuid
|
| 11 |
from typing import TYPE_CHECKING, List
|
| 12 |
|
| 13 |
import httpx
|
|
|
|
| 163 |
)
|
| 164 |
|
| 165 |
req_tag = f"[req_{request_id}] " if request_id else ""
|
| 166 |
+
if r.status_code != 200:
|
| 167 |
+
logger.error(f"[FILE] [{account_manager.config.account_id}] {req_tag}文件上传失败: {r.status_code}")
|
| 168 |
+
error_text = r.text
|
| 169 |
+
if r.status_code == 400:
|
| 170 |
+
try:
|
| 171 |
+
payload = json.loads(r.text or "{}")
|
| 172 |
+
message = payload.get("error", {}).get("message", "")
|
| 173 |
+
except Exception:
|
| 174 |
+
message = ""
|
| 175 |
+
if "Unsupported file type" in message:
|
| 176 |
+
mime_type = message.split("Unsupported file type:", 1)[-1].strip()
|
| 177 |
+
hint = f"不支持的文件类型: {mime_type}。请转换为 PDF、图片或纯文本后再上传。"
|
| 178 |
+
raise HTTPException(400, hint)
|
| 179 |
+
raise HTTPException(r.status_code, f"Upload failed: {error_text}")
|
| 180 |
|
| 181 |
data = r.json()
|
| 182 |
file_id = data.get("addContextFileResponse", {}).get("fileId")
|
core/login_service.py
CHANGED
|
@@ -154,20 +154,31 @@ class LoginService(BaseTaskService[LoginTask]):
|
|
| 154 |
|
| 155 |
# 根据配置选择浏览器引擎
|
| 156 |
browser_engine = (config.basic.browser_engine or "dp").lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
if browser_engine == "dp":
|
| 158 |
-
# DrissionPage 引擎:
|
| 159 |
automation = GeminiAutomation(
|
| 160 |
user_agent=self.user_agent,
|
| 161 |
proxy=config.basic.proxy,
|
| 162 |
-
headless=
|
| 163 |
log_callback=log_cb,
|
| 164 |
)
|
| 165 |
else:
|
| 166 |
-
# undetected-chromedriver 引擎:
|
| 167 |
automation = GeminiAutomationUC(
|
| 168 |
user_agent=self.user_agent,
|
| 169 |
proxy=config.basic.proxy,
|
| 170 |
-
headless=
|
| 171 |
log_callback=log_cb,
|
| 172 |
)
|
| 173 |
try:
|
|
|
|
| 154 |
|
| 155 |
# 根据配置选择浏览器引擎
|
| 156 |
browser_engine = (config.basic.browser_engine or "dp").lower()
|
| 157 |
+
headless = config.basic.browser_headless
|
| 158 |
+
|
| 159 |
+
# Linux 环境强制使用 DP 无头模式(无图形界面无法运行有头模式)
|
| 160 |
+
import sys
|
| 161 |
+
is_linux = sys.platform.startswith("linux")
|
| 162 |
+
if is_linux:
|
| 163 |
+
if browser_engine != "dp" or not headless:
|
| 164 |
+
log_cb("warning", "Linux environment: forcing DP engine with headless mode")
|
| 165 |
+
browser_engine = "dp"
|
| 166 |
+
headless = True
|
| 167 |
+
|
| 168 |
if browser_engine == "dp":
|
| 169 |
+
# DrissionPage 引擎:支持有头和无头模式
|
| 170 |
automation = GeminiAutomation(
|
| 171 |
user_agent=self.user_agent,
|
| 172 |
proxy=config.basic.proxy,
|
| 173 |
+
headless=headless,
|
| 174 |
log_callback=log_cb,
|
| 175 |
)
|
| 176 |
else:
|
| 177 |
+
# undetected-chromedriver 引擎:仅有头模式可用
|
| 178 |
automation = GeminiAutomationUC(
|
| 179 |
user_agent=self.user_agent,
|
| 180 |
proxy=config.basic.proxy,
|
| 181 |
+
headless=headless,
|
| 182 |
log_callback=log_cb,
|
| 183 |
)
|
| 184 |
try:
|
core/message.py
CHANGED
|
@@ -103,36 +103,36 @@ async def parse_last_message(messages: List['Message'], http_client: httpx.Async
|
|
| 103 |
else:
|
| 104 |
logger.warning(f"[FILE] [req_{request_id}] 不支持的文件格式: {url[:30]}...")
|
| 105 |
|
| 106 |
-
# 并行下载所有 URL 文件(支持图片、PDF、文档等)
|
| 107 |
-
if image_urls:
|
| 108 |
-
async def download_url(url: str):
|
| 109 |
-
try:
|
| 110 |
-
resp = await http_client.get(url, timeout=30, follow_redirects=True)
|
| 111 |
-
if resp.status_code == 404:
|
| 112 |
-
logger.warning(f"[FILE] [req_{request_id}] URL文件已失效(404),已跳过: {url[:50]}...")
|
| 113 |
-
return None
|
| 114 |
-
resp.raise_for_status()
|
| 115 |
-
content_type = resp.headers.get("content-type", "application/octet-stream").split(";")[0]
|
| 116 |
-
# 移除图片类型限制,支持所有文件类型
|
| 117 |
-
b64 = base64.b64encode(resp.content).decode()
|
| 118 |
-
logger.info(f"[FILE] [req_{request_id}] URL文件下载成功: {url[:50]}... ({len(resp.content)} bytes, {content_type})")
|
| 119 |
-
return {"mime": content_type, "data": b64}
|
| 120 |
-
except httpx.HTTPStatusError as e:
|
| 121 |
-
status_code = e.response.status_code if e.response else "unknown"
|
| 122 |
-
logger.warning(f"[FILE] [req_{request_id}] URL文件下载失败({status_code}): {url[:50]}... - {e}")
|
| 123 |
-
return None
|
| 124 |
-
except Exception as e:
|
| 125 |
-
logger.warning(f"[FILE] [req_{request_id}] URL文件下载失败: {url[:50]}... - {e}")
|
| 126 |
-
return None
|
| 127 |
-
|
| 128 |
-
results = await asyncio.gather(*[download_url(u) for u in image_urls], return_exceptions=True)
|
| 129 |
-
safe_results = []
|
| 130 |
-
for result in results:
|
| 131 |
-
if isinstance(result, Exception):
|
| 132 |
-
logger.warning(f"[FILE] [req_{request_id}] URL文件下载异常: {type(result).__name__}: {str(result)[:120]}")
|
| 133 |
-
continue
|
| 134 |
-
safe_results.append(result)
|
| 135 |
-
images.extend([r for r in safe_results if r])
|
| 136 |
|
| 137 |
return text_content, images
|
| 138 |
|
|
|
|
| 103 |
else:
|
| 104 |
logger.warning(f"[FILE] [req_{request_id}] 不支持的文件格式: {url[:30]}...")
|
| 105 |
|
| 106 |
+
# 并行下载所有 URL 文件(支持图片、PDF、文档等)
|
| 107 |
+
if image_urls:
|
| 108 |
+
async def download_url(url: str):
|
| 109 |
+
try:
|
| 110 |
+
resp = await http_client.get(url, timeout=30, follow_redirects=True)
|
| 111 |
+
if resp.status_code == 404:
|
| 112 |
+
logger.warning(f"[FILE] [req_{request_id}] URL文件已失效(404),已跳过: {url[:50]}...")
|
| 113 |
+
return None
|
| 114 |
+
resp.raise_for_status()
|
| 115 |
+
content_type = resp.headers.get("content-type", "application/octet-stream").split(";")[0]
|
| 116 |
+
# 移除图片类型限制,支持所有文件类型
|
| 117 |
+
b64 = base64.b64encode(resp.content).decode()
|
| 118 |
+
logger.info(f"[FILE] [req_{request_id}] URL文件下载成功: {url[:50]}... ({len(resp.content)} bytes, {content_type})")
|
| 119 |
+
return {"mime": content_type, "data": b64}
|
| 120 |
+
except httpx.HTTPStatusError as e:
|
| 121 |
+
status_code = e.response.status_code if e.response else "unknown"
|
| 122 |
+
logger.warning(f"[FILE] [req_{request_id}] URL文件下载失败({status_code}): {url[:50]}... - {e}")
|
| 123 |
+
return None
|
| 124 |
+
except Exception as e:
|
| 125 |
+
logger.warning(f"[FILE] [req_{request_id}] URL文件下载失败: {url[:50]}... - {e}")
|
| 126 |
+
return None
|
| 127 |
+
|
| 128 |
+
results = await asyncio.gather(*[download_url(u) for u in image_urls], return_exceptions=True)
|
| 129 |
+
safe_results = []
|
| 130 |
+
for result in results:
|
| 131 |
+
if isinstance(result, Exception):
|
| 132 |
+
logger.warning(f"[FILE] [req_{request_id}] URL文件下载异常: {type(result).__name__}: {str(result)[:120]}")
|
| 133 |
+
continue
|
| 134 |
+
safe_results.append(result)
|
| 135 |
+
images.extend([r for r in safe_results if r])
|
| 136 |
|
| 137 |
return text_content, images
|
| 138 |
|
core/register_service.py
CHANGED
|
@@ -118,20 +118,31 @@ class RegisterService(BaseTaskService[RegisterTask]):
|
|
| 118 |
|
| 119 |
# 根据配置选择浏览器引擎
|
| 120 |
browser_engine = (config.basic.browser_engine or "dp").lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
if browser_engine == "dp":
|
| 122 |
-
# DrissionPage 引擎:
|
| 123 |
automation = GeminiAutomation(
|
| 124 |
user_agent=self.user_agent,
|
| 125 |
proxy=config.basic.proxy,
|
| 126 |
-
headless=
|
| 127 |
log_callback=log_cb,
|
| 128 |
)
|
| 129 |
else:
|
| 130 |
-
# undetected-chromedriver 引擎:
|
| 131 |
automation = GeminiAutomationUC(
|
| 132 |
user_agent=self.user_agent,
|
| 133 |
proxy=config.basic.proxy,
|
| 134 |
-
headless=
|
| 135 |
log_callback=log_cb,
|
| 136 |
)
|
| 137 |
|
|
|
|
| 118 |
|
| 119 |
# 根据配置选择浏览器引擎
|
| 120 |
browser_engine = (config.basic.browser_engine or "dp").lower()
|
| 121 |
+
headless = config.basic.browser_headless
|
| 122 |
+
|
| 123 |
+
# Linux 环境强制使用 DP 无头模式(无图形界面无法运行有头模式)
|
| 124 |
+
import sys
|
| 125 |
+
is_linux = sys.platform.startswith("linux")
|
| 126 |
+
if is_linux:
|
| 127 |
+
if browser_engine != "dp" or not headless:
|
| 128 |
+
log_cb("warning", "Linux environment: forcing DP engine with headless mode")
|
| 129 |
+
browser_engine = "dp"
|
| 130 |
+
headless = True
|
| 131 |
+
|
| 132 |
if browser_engine == "dp":
|
| 133 |
+
# DrissionPage 引擎:支持有头和无头模式
|
| 134 |
automation = GeminiAutomation(
|
| 135 |
user_agent=self.user_agent,
|
| 136 |
proxy=config.basic.proxy,
|
| 137 |
+
headless=headless,
|
| 138 |
log_callback=log_cb,
|
| 139 |
)
|
| 140 |
else:
|
| 141 |
+
# undetected-chromedriver 引擎:仅有头模式可用
|
| 142 |
automation = GeminiAutomationUC(
|
| 143 |
user_agent=self.user_agent,
|
| 144 |
proxy=config.basic.proxy,
|
| 145 |
+
headless=headless,
|
| 146 |
log_callback=log_cb,
|
| 147 |
)
|
| 148 |
|
core/session_auth.py
CHANGED
|
@@ -46,11 +46,11 @@ def require_login(redirect_to_login: bool = True):
|
|
| 46 |
wants_html = "text/html" in accept_header or request.url.path.endswith("/html")
|
| 47 |
|
| 48 |
if wants_html:
|
| 49 |
-
# 清理掉 URL 中可能重复的 PATH_PREFIX
|
| 50 |
-
# 避免重定向路径出现多层前缀
|
| 51 |
path = request.url.path
|
| 52 |
|
| 53 |
-
# 兼容 main 中 PATH_PREFIX 为空的情况
|
| 54 |
import main
|
| 55 |
prefix = main.PATH_PREFIX
|
| 56 |
|
|
|
|
| 46 |
wants_html = "text/html" in accept_header or request.url.path.endswith("/html")
|
| 47 |
|
| 48 |
if wants_html:
|
| 49 |
+
# 清理掉 URL 中可能重复的 PATH_PREFIX
|
| 50 |
+
# 避免重定向路径出现多层前缀
|
| 51 |
path = request.url.path
|
| 52 |
|
| 53 |
+
# 兼容 main 中 PATH_PREFIX 为空的情况
|
| 54 |
import main
|
| 55 |
prefix = main.PATH_PREFIX
|
| 56 |
|
core/uptime.py
CHANGED
|
@@ -1,139 +1,139 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Uptime 实时监控与心跳历史持久化。
|
| 3 |
-
"""
|
| 4 |
-
|
| 5 |
-
from collections import deque
|
| 6 |
-
from datetime import datetime, timezone, timedelta
|
| 7 |
-
from typing import Dict, List, Optional
|
| 8 |
-
import json
|
| 9 |
-
import os
|
| 10 |
-
from threading import Lock
|
| 11 |
-
|
| 12 |
-
# 北京时区 UTC+8
|
| 13 |
-
BEIJING_TZ = timezone(timedelta(hours=8))
|
| 14 |
-
|
| 15 |
-
# 每个服务保留最近 60 条心跳
|
| 16 |
-
MAX_HEARTBEATS = 60
|
| 17 |
-
SLOW_THRESHOLD_MS = 40000
|
| 18 |
-
WARNING_STATUS_CODES = {429}
|
| 19 |
-
|
| 20 |
-
_storage_path: Optional[str] = None
|
| 21 |
-
_storage_lock = Lock()
|
| 22 |
-
|
| 23 |
-
# 服务注册表
|
| 24 |
-
SERVICES = {
|
| 25 |
-
"api_service": {"name": "API 服务", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 26 |
-
"account_pool": {"name": "服务资源", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 27 |
-
"gemini-2.5-flash": {"name": "Gemini 2.5 Flash", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 28 |
-
"gemini-2.5-pro": {"name": "Gemini 2.5 Pro", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 29 |
-
"gemini-3-flash-preview": {"name": "Gemini 3 Flash Preview", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 30 |
-
"gemini-3-pro-preview": {"name": "Gemini 3 Pro Preview", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
SUPPORTED_MODELS = ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-3-flash-preview", "gemini-3-pro-preview"]
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
def configure_storage(path: Optional[str]) -> None:
|
| 37 |
-
"""配置心跳持久化路径。"""
|
| 38 |
-
global _storage_path
|
| 39 |
-
_storage_path = path
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
def _classify_level(success: bool, status_code: Optional[int], latency_ms: Optional[int]) -> str:
|
| 43 |
-
if status_code in WARNING_STATUS_CODES:
|
| 44 |
-
return "warn"
|
| 45 |
-
if success and latency_ms is not None and latency_ms >= SLOW_THRESHOLD_MS:
|
| 46 |
-
return "warn"
|
| 47 |
-
return "up" if success else "down"
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def _save_heartbeats() -> None:
|
| 51 |
-
if not _storage_path:
|
| 52 |
-
return
|
| 53 |
-
try:
|
| 54 |
-
payload = {}
|
| 55 |
-
for service_id, service_data in SERVICES.items():
|
| 56 |
-
payload[service_id] = list(service_data["heartbeats"])
|
| 57 |
-
os.makedirs(os.path.dirname(_storage_path), exist_ok=True)
|
| 58 |
-
with _storage_lock, open(_storage_path, "w", encoding="utf-8") as f:
|
| 59 |
-
json.dump(payload, f, ensure_ascii=True, indent=2)
|
| 60 |
-
except Exception:
|
| 61 |
-
return
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def load_heartbeats() -> None:
|
| 65 |
-
if not _storage_path or not os.path.exists(_storage_path):
|
| 66 |
-
return
|
| 67 |
-
try:
|
| 68 |
-
with _storage_lock, open(_storage_path, "r", encoding="utf-8") as f:
|
| 69 |
-
payload = json.load(f)
|
| 70 |
-
for service_id, heartbeats in payload.items():
|
| 71 |
-
if service_id not in SERVICES:
|
| 72 |
-
continue
|
| 73 |
-
SERVICES[service_id]["heartbeats"].clear()
|
| 74 |
-
for beat in heartbeats[-MAX_HEARTBEATS:]:
|
| 75 |
-
SERVICES[service_id]["heartbeats"].append(beat)
|
| 76 |
-
except Exception:
|
| 77 |
-
return
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
def record_request(
|
| 81 |
-
service: str,
|
| 82 |
-
success: bool,
|
| 83 |
-
latency_ms: Optional[int] = None,
|
| 84 |
-
status_code: Optional[int] = None
|
| 85 |
-
):
|
| 86 |
-
"""记录一次心跳。"""
|
| 87 |
-
if service not in SERVICES:
|
| 88 |
-
return
|
| 89 |
-
|
| 90 |
-
level = _classify_level(success, status_code, latency_ms)
|
| 91 |
-
heartbeat = {
|
| 92 |
-
"time": datetime.now(BEIJING_TZ).strftime("%H:%M:%S"),
|
| 93 |
-
"success": success,
|
| 94 |
-
"level": level,
|
| 95 |
-
}
|
| 96 |
-
if latency_ms is not None:
|
| 97 |
-
heartbeat["latency_ms"] = latency_ms
|
| 98 |
-
if status_code is not None:
|
| 99 |
-
heartbeat["status_code"] = status_code
|
| 100 |
-
|
| 101 |
-
SERVICES[service]["heartbeats"].append(heartbeat)
|
| 102 |
-
_save_heartbeats()
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
def get_realtime_status() -> Dict:
|
| 106 |
-
"""返回实时监控数据。"""
|
| 107 |
-
result = {"services": {}}
|
| 108 |
-
|
| 109 |
-
for service_id, service_data in SERVICES.items():
|
| 110 |
-
heartbeats = list(service_data["heartbeats"])
|
| 111 |
-
total = len(heartbeats)
|
| 112 |
-
success = sum(1 for h in heartbeats if h.get("success"))
|
| 113 |
-
|
| 114 |
-
uptime = (success / total * 100) if total > 0 else 100.0
|
| 115 |
-
|
| 116 |
-
last_status = "unknown"
|
| 117 |
-
if heartbeats:
|
| 118 |
-
last_level = heartbeats[-1].get("level")
|
| 119 |
-
if last_level in {"up", "down", "warn"}:
|
| 120 |
-
last_status = last_level
|
| 121 |
-
else:
|
| 122 |
-
last_status = "up" if heartbeats[-1].get("success") else "down"
|
| 123 |
-
|
| 124 |
-
result["services"][service_id] = {
|
| 125 |
-
"name": service_data["name"],
|
| 126 |
-
"status": last_status,
|
| 127 |
-
"uptime": round(uptime, 1),
|
| 128 |
-
"total": total,
|
| 129 |
-
"success": success,
|
| 130 |
-
"heartbeats": heartbeats[-MAX_HEARTBEATS:],
|
| 131 |
-
}
|
| 132 |
-
|
| 133 |
-
result["updated_at"] = datetime.now(BEIJING_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
| 134 |
-
return result
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
async def get_uptime_summary(days: int = 90) -> Dict:
|
| 138 |
-
"""兼容旧接口。"""
|
| 139 |
-
return get_realtime_status()
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Uptime 实时监控与心跳历史持久化。
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from collections import deque
|
| 6 |
+
from datetime import datetime, timezone, timedelta
|
| 7 |
+
from typing import Dict, List, Optional
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
from threading import Lock
|
| 11 |
+
|
| 12 |
+
# 北京时区 UTC+8
|
| 13 |
+
BEIJING_TZ = timezone(timedelta(hours=8))
|
| 14 |
+
|
| 15 |
+
# 每个服务保留最近 60 条心跳
|
| 16 |
+
MAX_HEARTBEATS = 60
|
| 17 |
+
SLOW_THRESHOLD_MS = 40000
|
| 18 |
+
WARNING_STATUS_CODES = {429}
|
| 19 |
+
|
| 20 |
+
_storage_path: Optional[str] = None
|
| 21 |
+
_storage_lock = Lock()
|
| 22 |
+
|
| 23 |
+
# 服务注册表
|
| 24 |
+
SERVICES = {
|
| 25 |
+
"api_service": {"name": "API 服务", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 26 |
+
"account_pool": {"name": "服务资源", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 27 |
+
"gemini-2.5-flash": {"name": "Gemini 2.5 Flash", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 28 |
+
"gemini-2.5-pro": {"name": "Gemini 2.5 Pro", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 29 |
+
"gemini-3-flash-preview": {"name": "Gemini 3 Flash Preview", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 30 |
+
"gemini-3-pro-preview": {"name": "Gemini 3 Pro Preview", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
SUPPORTED_MODELS = ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-3-flash-preview", "gemini-3-pro-preview"]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def configure_storage(path: Optional[str]) -> None:
|
| 37 |
+
"""配置心跳持久化路径。"""
|
| 38 |
+
global _storage_path
|
| 39 |
+
_storage_path = path
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _classify_level(success: bool, status_code: Optional[int], latency_ms: Optional[int]) -> str:
|
| 43 |
+
if status_code in WARNING_STATUS_CODES:
|
| 44 |
+
return "warn"
|
| 45 |
+
if success and latency_ms is not None and latency_ms >= SLOW_THRESHOLD_MS:
|
| 46 |
+
return "warn"
|
| 47 |
+
return "up" if success else "down"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _save_heartbeats() -> None:
|
| 51 |
+
if not _storage_path:
|
| 52 |
+
return
|
| 53 |
+
try:
|
| 54 |
+
payload = {}
|
| 55 |
+
for service_id, service_data in SERVICES.items():
|
| 56 |
+
payload[service_id] = list(service_data["heartbeats"])
|
| 57 |
+
os.makedirs(os.path.dirname(_storage_path), exist_ok=True)
|
| 58 |
+
with _storage_lock, open(_storage_path, "w", encoding="utf-8") as f:
|
| 59 |
+
json.dump(payload, f, ensure_ascii=True, indent=2)
|
| 60 |
+
except Exception:
|
| 61 |
+
return
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def load_heartbeats() -> None:
|
| 65 |
+
if not _storage_path or not os.path.exists(_storage_path):
|
| 66 |
+
return
|
| 67 |
+
try:
|
| 68 |
+
with _storage_lock, open(_storage_path, "r", encoding="utf-8") as f:
|
| 69 |
+
payload = json.load(f)
|
| 70 |
+
for service_id, heartbeats in payload.items():
|
| 71 |
+
if service_id not in SERVICES:
|
| 72 |
+
continue
|
| 73 |
+
SERVICES[service_id]["heartbeats"].clear()
|
| 74 |
+
for beat in heartbeats[-MAX_HEARTBEATS:]:
|
| 75 |
+
SERVICES[service_id]["heartbeats"].append(beat)
|
| 76 |
+
except Exception:
|
| 77 |
+
return
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def record_request(
|
| 81 |
+
service: str,
|
| 82 |
+
success: bool,
|
| 83 |
+
latency_ms: Optional[int] = None,
|
| 84 |
+
status_code: Optional[int] = None
|
| 85 |
+
):
|
| 86 |
+
"""记录一次心跳。"""
|
| 87 |
+
if service not in SERVICES:
|
| 88 |
+
return
|
| 89 |
+
|
| 90 |
+
level = _classify_level(success, status_code, latency_ms)
|
| 91 |
+
heartbeat = {
|
| 92 |
+
"time": datetime.now(BEIJING_TZ).strftime("%H:%M:%S"),
|
| 93 |
+
"success": success,
|
| 94 |
+
"level": level,
|
| 95 |
+
}
|
| 96 |
+
if latency_ms is not None:
|
| 97 |
+
heartbeat["latency_ms"] = latency_ms
|
| 98 |
+
if status_code is not None:
|
| 99 |
+
heartbeat["status_code"] = status_code
|
| 100 |
+
|
| 101 |
+
SERVICES[service]["heartbeats"].append(heartbeat)
|
| 102 |
+
_save_heartbeats()
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def get_realtime_status() -> Dict:
|
| 106 |
+
"""返回实时监控数据。"""
|
| 107 |
+
result = {"services": {}}
|
| 108 |
+
|
| 109 |
+
for service_id, service_data in SERVICES.items():
|
| 110 |
+
heartbeats = list(service_data["heartbeats"])
|
| 111 |
+
total = len(heartbeats)
|
| 112 |
+
success = sum(1 for h in heartbeats if h.get("success"))
|
| 113 |
+
|
| 114 |
+
uptime = (success / total * 100) if total > 0 else 100.0
|
| 115 |
+
|
| 116 |
+
last_status = "unknown"
|
| 117 |
+
if heartbeats:
|
| 118 |
+
last_level = heartbeats[-1].get("level")
|
| 119 |
+
if last_level in {"up", "down", "warn"}:
|
| 120 |
+
last_status = last_level
|
| 121 |
+
else:
|
| 122 |
+
last_status = "up" if heartbeats[-1].get("success") else "down"
|
| 123 |
+
|
| 124 |
+
result["services"][service_id] = {
|
| 125 |
+
"name": service_data["name"],
|
| 126 |
+
"status": last_status,
|
| 127 |
+
"uptime": round(uptime, 1),
|
| 128 |
+
"total": total,
|
| 129 |
+
"success": success,
|
| 130 |
+
"heartbeats": heartbeats[-MAX_HEARTBEATS:],
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
result["updated_at"] = datetime.now(BEIJING_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
| 134 |
+
return result
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
async def get_uptime_summary(days: int = 90) -> Dict:
|
| 138 |
+
"""兼容旧接口。"""
|
| 139 |
+
return get_realtime_status()
|