xiaoyukkkk commited on
Commit
1e57349
·
unverified ·
1 Parent(s): 8cc0fda

Upload 18 files

Browse files
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 (支持无头/有头) 或 dp (仅有头,更稳定)")
53
- browser_headless: bool = Field(default=False, description="自动化浏览器无头模式(仅 UC 引擎支持)")
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
- msg_id = messages[0].get("id")
152
- if not msg_id:
153
- return None
154
-
155
- detail = self._request(
156
- "GET",
157
- f"{self.base_url}/messages/{msg_id}",
158
- headers={"Authorization": f"Bearer {self.token}"},
159
- )
160
-
161
- if detail.status_code != 200:
162
- return None
163
-
164
- payload = detail.json() if detail.content else {}
165
-
166
- # 获取邮件内容
167
- text_content = payload.get("text") or ""
168
- html_content = payload.get("html") or ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
- if isinstance(html_content, list):
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("error", "verification code timeout")
158
- self._save_screenshot(page, "code_timeout")
159
- return {"success": False, "error": "verification code timeout"}
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- time.sleep(5)
 
 
 
 
 
 
 
 
 
 
 
 
185
 
186
- # Step 7: 处理协议页面(如果有)
187
  self._handle_agreement_page(page)
188
 
189
- # Step 8: 导航到业务页面并等待参数生成
190
- self._log("info", "navigating to business page")
191
- page.get("https://business.gemini.google/", timeout=self.timeout)
192
- time.sleep(3)
 
 
 
 
193
 
194
- # Step 9: 检查是否需要设置用户名
 
 
 
 
 
 
 
 
195
  if "cid" not in page.url:
196
  if self._handle_username_setup(page):
197
- time.sleep(3)
198
 
199
- # Step 10: 等待 URL 参数生成(csesidx 和 cid)
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(3)
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 11: 提取配置
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
- expires_at = datetime.fromtimestamp(ses_obj["expiry"] - 43200).strftime("%Y-%m-%d %H:%M:%S")
 
 
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
- expires_at = datetime.fromtimestamp(ses_obj["expiry"] - 43200).strftime("%Y-%m-%d %H:%M:%S")
 
 
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=False, # DP 不支持无头模式
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=config.basic.browser_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=False, # DP 不支持无头模式
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=config.basic.browser_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()