nacho commited on
Commit
d12c6be
·
1 Parent(s): b454740

fix: login timeout, target crash, screenshot viewer, prelogin speed

Browse files
Files changed (3) hide show
  1. deepseek_browser.py +233 -356
  2. main.py +26 -2
  3. static/index.html +30 -2
deepseek_browser.py CHANGED
@@ -1,5 +1,6 @@
1
  import asyncio
2
  import logging
 
3
  import random
4
  import time
5
  from pathlib import Path
@@ -9,19 +10,14 @@ from cloakbrowser import launch_persistent_context_async
9
 
10
  logger = logging.getLogger(__name__)
11
 
 
 
12
 
13
  class DeepSeekBrowser:
14
  DEEPSEEK_URL = "https://chat.deepseek.com"
15
 
16
- def __init__(
17
- self,
18
- email: str,
19
- password: str,
20
- profile_dir: str = "./profiles",
21
- headless: bool = True,
22
- humanize: bool = True,
23
- proxy: Optional[str] = None,
24
- ):
25
  self.email = email
26
  self.password = password
27
  self.profile_dir = Path(profile_dir) / email.replace("@", "_at_").replace("+", "_plus_")
@@ -33,69 +29,89 @@ class DeepSeekBrowser:
33
  self._logged_in = False
34
  self._ready = False
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  async def start(self):
37
  self.profile_dir.mkdir(parents=True, exist_ok=True)
38
-
39
- # 极致的省内存参数
40
  args = [
41
- "--disable-gpu",
42
- "--disable-dev-shm-usage",
43
- "--disable-extensions",
44
- "--disable-background-networking",
45
- "--disable-default-apps",
46
- "--disable-sync",
47
- "--mute-audio",
48
- "--no-sandbox",
49
- "--js-flags=--max-old-space-size=128", # 限制 V8 引擎内存
50
- "--renderer-process-limit=1", # 限制渲染进程
51
  ]
52
-
53
  self.context = await launch_persistent_context_async(
54
- user_data_dir=str(self.profile_dir),
55
- headless=self.headless,
56
- humanize=self.humanize,
57
- proxy=self.proxy,
58
- viewport={"width": 1280, "height": 720}, # 减小渲染面积,降低合成内存
59
- locale="zh-CN",
60
- args=args,
61
  )
62
-
63
  self.page = await self.context.new_page()
64
  await self.page.goto(self.DEEPSEEK_URL, timeout=30000)
65
- # Wait for page ready instead of fixed sleep
66
- try:
67
- await self.page.wait_for_selector('textarea', timeout=10000)
68
- except Exception:
69
- await asyncio.sleep(1)
70
-
71
  await self._check_login_state()
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  async def _check_login_state(self):
74
  current_url = self.page.url
75
-
76
- if '/sign_in' in current_url:
 
 
 
 
 
 
 
 
77
  await self._auto_login()
78
  else:
79
  try:
80
- await self.page.wait_for_selector('textarea', timeout=8000)
81
  self._logged_in = True
82
  self._ready = True
83
  except Exception:
84
  await self._auto_login()
85
-
86
- # Check if account is muted after login
87
  if self._logged_in:
88
  await self._check_mute()
89
 
90
  async def _check_mute(self):
91
- """Check if account is muted and extract mute expiry."""
92
  try:
93
  muted, until = await self.page.evaluate("""() => {
94
- const text = document.body.innerText || '';
95
- // Match: 禁言至 YYYYMD HH:MM or 禁言至 YYYY-MM-DD HH:MM
96
- const match = text.match(/禁言至\\s*(\\d{4}[-年]\\d{1,2}[-月]\\d{1,2}[日]?\\s*\\d{1,2}:\\d{2})/);
97
- if (match) return [true, match[1]];
98
- if (text.includes('禁言')) return [true, ''];
99
  return [false, ''];
100
  }""")
101
  self._is_muted = muted
@@ -106,448 +122,309 @@ class DeepSeekBrowser:
106
  self._is_muted = False
107
  self._muted_until = ""
108
 
109
- def is_muted(self) -> bool:
110
- return getattr(self, '_is_muted', False)
111
 
112
- def muted_until(self) -> str:
113
- return getattr(self, '_muted_until', "")
114
 
115
  async def _auto_login(self):
116
  logger.info("Logging in as %s...", self.email)
 
117
 
118
- # 1. 先等待页面加载完成(任意输入框出现),防止因为 Cloudflare 还在转圈导致直接尝试点击失败
119
- try:
120
- any_input = self.page.locator('input').first
121
- await any_input.wait_for(state="visible", timeout=15000)
122
- except Exception:
123
- pass
124
-
125
- # 2. 尝试切换到“密码登录”模式(如果页面默认是手机验证码登录)
126
  try:
127
  pwd_tab = self.page.locator('text="密码登录"').first
128
  if await pwd_tab.is_visible():
129
  await pwd_tab.click()
130
- await asyncio.sleep(0.1)
131
  except Exception as e:
132
- logger.debug("No password login tab found or error: %s", e)
133
 
134
  try:
135
- email_input = self.page.locator('input[placeholder*="邮箱"], input[placeholder*="手机"], input[placeholder*="Email"], input[placeholder*="email"], input[type="text"]').first
136
- await email_input.wait_for(state="visible", timeout=10000)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  await email_input.fill(self.email)
138
  await asyncio.sleep(0.1)
139
  except Exception as e:
140
- try:
141
- await self.page.screenshot(path=f"/tmp/login_fail_{self.email.replace('@','_at_')}.png")
142
- logger.error("Screenshot saved to /tmp/login_fail_%s.png", self.email.replace('@', '_at_'))
143
- except Exception:
144
- pass
145
  logger.error("Email input error: %s", e)
146
  raise
147
 
148
  try:
149
- password_input = self.page.locator('input[type="password"]').first
150
- await password_input.wait_for(state="visible", timeout=5000)
151
- await password_input.fill(self.password)
152
  await asyncio.sleep(0.1)
153
  except Exception as e:
 
154
  logger.error("Password input error: %s", e)
155
  raise
156
 
157
  try:
158
- login_button = self.page.locator('button:has-text("登录")').first
159
- await login_button.click()
160
  await asyncio.sleep(1.5)
161
  except Exception as e:
 
162
  logger.error("Login button error: %s", e)
163
  raise
164
 
165
  try:
166
- await self.page.wait_for_selector('textarea', timeout=20000)
167
  self._logged_in = True
168
  self._ready = True
169
- logger.info("Login successful!")
170
  except Exception:
171
- try:
172
- await self.page.screenshot(path=f"/tmp/login_fail_{self.email.replace('@','_at_')}_final.png")
173
- logger.error("Final login screenshot saved to /tmp/login_fail_%s_final.png", self.email.replace('@', '_at_'))
174
- except Exception:
175
- pass
176
- raise Exception("Login failed")
177
-
178
- async def _human_delay(self, min_ms: int = 5, max_ms: int = 30):
179
- """Minimal delay for speed — just enough to avoid race conditions."""
180
- delay = random.uniform(min_ms, max_ms) / 1000
181
- await asyncio.sleep(delay)
182
 
183
  async def new_chat(self):
184
- """Start a new chat by clicking the new-chat button instead of full page reload."""
185
  try:
186
- # Try clicking the "new chat" button first (much faster than goto)
187
- new_chat_btn = self.page.locator(
188
  'a:has-text("开启新对话"), button:has-text("开启新对话"), '
189
  'a:has-text("新对话"), button:has-text("新对话"), '
190
  '[class*="new-chat"], [class*="newChat"]'
191
  ).first
192
- if await new_chat_btn.count() > 0:
193
- await new_chat_btn.click()
194
- await self.page.wait_for_selector('textarea', timeout=10000)
195
  return
196
-
197
- # Fallback: full page reload
198
  await self.page.goto(self.DEEPSEEK_URL, timeout=30000)
199
- await self.page.wait_for_selector('textarea', timeout=15000)
200
  except Exception as e:
201
  logger.error("New chat error: %s", e)
202
  raise
203
 
204
  async def delete_chat(self):
205
  try:
206
- # Find the sidebar and active conversation
207
  chat_list = self.page.locator(
208
- 'nav, aside, [class*="sidebar"], [class*="Sidebar"], div:has-text("开启新对话")'
209
- )
210
- chat_list_count = await chat_list.count()
211
- if chat_list_count == 0:
212
- logger.debug("[delete_chat] no sidebar")
213
  return
214
-
215
  active_item = chat_list.first.locator(
216
- '[class*="active"], [class*="selected"], [class*="current"]'
217
- ).first
218
- active_count = await active_item.count()
219
- if active_count == 0:
220
- # No active item yet (first chat), skip deletion
221
- logger.debug("[delete_chat] no active item, skipping")
222
- return
223
-
224
- # Get bounding box and click near right edge where "..." should be
225
- box = await active_item.bounding_box()
226
- if not box:
227
- logger.debug("[delete_chat] no bbox")
228
  return
229
-
230
- # Instead of position-based click, find the "..." element in DOM
231
- click_result = await self.page.evaluate("""() => {
232
- // Find the active/highlighted conversation item
233
- const active = document.querySelector('[class*="active"], [class*="selected"]');
234
- if (!active) return 'no-active';
235
-
236
- // Walk down to find a clickable child that looks like "..."
237
- // The "..." is often a button or div with no text (SVG only)
238
- const walk = (node, depth) => {
239
- if (depth > 10) return null;
240
- for (const child of node.children || []) {
241
- const tag = child.tagName;
242
- const cls = (child.className || '').toString();
243
- // Look for small icon-like elements
244
- if ((tag === 'BUTTON' || tag === 'svg' || cls.includes('icon') || cls.includes('more') || cls.includes('menu') || cls.includes('action')) &&
245
- child.offsetWidth < 40 && child.offsetWidth > 0) {
246
- return child;
247
- }
248
- const found = walk(child, depth + 1);
249
- if (found) return found;
250
  }
251
  return null;
252
  };
253
-
254
- const icon = walk(active, 0);
255
- if (icon) {
256
- icon.click();
257
- return 'clicked:' + icon.tagName + ':' + (icon.className || '').substring(0, 40);
258
- }
259
-
260
- // Fallback: find any button/svg in active item
261
- const btn = active.querySelector('button, [role="button"]');
262
- if (btn) {
263
- btn.click();
264
- return 'fallback:' + btn.tagName;
265
- }
266
  return 'no-icon';
267
  }""")
268
- logger.debug("[delete_chat] icon click: %s", click_result)
269
  await asyncio.sleep(0.5)
270
-
271
- # Search for "删除" or "Delete" anywhere on page
272
- delete_btn = self.page.locator(
273
- ':has-text("删除"), :has-text("Delete")'
274
- ).first
275
- delete_count = await delete_btn.count()
276
-
277
- if delete_count == 0:
278
- logger.debug("[delete_chat] no delete option found")
279
  return
280
-
281
- await delete_btn.click()
282
  await asyncio.sleep(0.5)
283
-
284
- # Confirm
285
- confirm_btn = self.page.locator(
286
  'button:has-text("确认"), button:has-text("删除"), '
287
- 'button:has-text("Confirm"), button:has-text("Delete")'
288
- ).last
289
- if await confirm_btn.count() > 0:
290
- await confirm_btn.click()
291
  await asyncio.sleep(1)
292
- logger.debug("[delete_chat] done!")
293
- else:
294
- logger.debug("[delete_chat] no confirm btn")
295
-
296
  except Exception as e:
297
  logger.warning("[delete_chat] error: %s", e)
298
 
299
- async def switch_model(self, model: str):
300
  try:
301
- # Inject a robust clicker that finds the innermost element containing specific text
302
- # and clicks it directly via JS, bypassing Playwright's actionability checks.
303
- click_js = """(texts) => {
304
  const els = Array.from(document.querySelectorAll('*'));
305
- const target = els.reverse().find(el => {
306
  if (!el.innerText || el.children.length > 0) return false;
307
- return texts.some(t => el.innerText.includes(t)) && el.offsetParent !== null;
308
  });
309
- if (target) {
310
- target.click();
311
- return true;
312
- }
313
  return false;
314
  }"""
315
-
316
- # 默认全局开启深度思考 (R1),不再区分类别
317
- await self.page.evaluate(click_js, ['深度思考', 'DeepThink', 'R1'])
318
  await asyncio.sleep(0.5)
319
-
320
  except Exception as e:
321
- logger.warning("[switch_model] click error: %s", e)
322
 
323
- async def send_message(self, prompt: str, timeout: int = 120, model: str = "deepseek-chat") -> dict:
324
- """Send message and return {'content': str, 'reasoning_content': str}."""
325
  try:
326
  await self.new_chat()
327
  await self.switch_model(model)
328
-
329
- input_field = self.page.locator('textarea').first
330
- await input_field.wait_for(state="visible", timeout=15000)
331
-
332
- await input_field.fill(prompt)
333
  await self._human_delay()
334
- await input_field.press('Enter')
335
-
336
  result = await self._wait_for_response(timeout, prompt)
337
-
338
  asyncio.create_task(self._safe_delete_chat())
339
-
340
  return result
341
  except Exception as e:
342
  logger.error("Send message error: %s", e)
343
  raise
344
 
345
  async def _safe_delete_chat(self):
346
- """Non-blocking delete chat wrapper."""
347
  try:
348
  await self.delete_chat()
349
  except Exception as e:
350
  logger.debug("[safe_delete] %s", e)
351
 
352
- # JavaScript to extract thinking and answer content from DOM
353
  _EXTRACT_JS = """() => {
354
- const result = {thinking: '', answer: '', done: false};
355
-
356
- // Find all assistant message containers (last one is current)
357
  const msgs = document.querySelectorAll(
358
- '[class*="assistant"], [class*="bot-"], [class*="message--"], [class*="message-wrapper"], [class*="chat-message"]'
359
- );
360
- let lastMsg = null;
361
  for (let i = msgs.length - 1; i >= 0; i--) {
362
- const cls = (msgs[i].className || '').toLowerCase();
363
- if (!cls.includes('user')) {
364
- lastMsg = msgs[i];
365
- break;
366
- }
367
- }
368
-
369
- // If no assistant message container found yet, it means generation hasn't started. Wait.
370
- if (!lastMsg) {
371
- return result;
372
  }
373
- const scope = lastMsg;
374
-
375
- // 1. Try to extract from Markdown blocks
376
  const mdEls = Array.from(scope.querySelectorAll(
377
- '[class*="markdown"], [class*="Markdown"], [class*="answer"], [class*="content"]'
378
- ));
379
- // Filter out nested ones to get top-level blocks
380
- const topMdEls = mdEls.filter(el => !mdEls.some(p => p !== el && p.contains(el)));
381
-
382
- let extractedThink = '';
383
- let extractedAns = '';
384
-
385
- if (topMdEls.length >= 2) {
386
- // Usually if there are 2 blocks, first is reasoning, last is answer
387
- extractedThink = topMdEls[0].innerText.trim();
388
- extractedAns = topMdEls[topMdEls.length - 1].innerText.trim();
389
- } else if (topMdEls.length === 1) {
390
- // Check if there's a visible "深度思考" toggle near it, or assume it's answer
391
- const t = topMdEls[0].innerText.trim();
392
- // If the whole scope text implies it's still thinking and no answer yet
393
- if (scope.innerText.includes('深度思考') && !scope.innerText.includes('已深度思考')) {
394
- extractedThink = t;
395
- } else {
396
- extractedAns = t;
397
- }
398
  }
399
-
400
- // 2. Fallback: parse raw lines if blocks failed or if it's safer
401
- const bodyText = scope.innerText || '';
402
- const hasThinkMarker = bodyText.includes('深度思考') || bodyText.includes('极速思考') || bodyText.includes('思考过程');
403
-
404
- if (!extractedAns || (!extractedThink && hasThinkMarker)) {
405
- const lines = bodyText.split('\\n').map(l => l.trim()).filter(Boolean);
406
- const skip = ['智能搜索', '快速模式', '专家模式', '极速思考', '内容由 AI 生成', '开启新对话', '暂无历史对话'];
407
-
408
- let isThinking = false;
409
- let thinkLines = [];
410
- let ansLines = [];
411
-
412
  for (const l of lines) {
413
  if (skip.some(s => l === s)) continue;
414
-
415
- // UI markers are usually short
416
  if (l.length < 30 && (l.includes('深度思考') || l.includes('极速思考') || l.includes('思考过程'))) {
417
- if (l.includes('已') || l.includes('用时') || l.includes('完成')) {
418
- isThinking = false;
419
- } else {
420
- isThinking = true;
421
- }
422
  continue;
423
  }
424
-
425
- if (isThinking) {
426
- thinkLines.push(l);
427
- } else {
428
- ansLines.push(l);
429
- }
430
  }
431
-
432
- // Prefer fallback if it extracted something meaningful
433
- if (thinkLines.length > 0) extractedThink = thinkLines.join('\\n');
434
- if (ansLines.length > 0) extractedAns = ansLines.join('\\n');
435
- }
436
-
437
- result.thinking = extractedThink;
438
- result.answer = extractedAns;
439
-
440
- // Check if response is complete
441
- const stopBtn = document.querySelector('[class*="stop"], button[aria-label*="stop"]');
442
- result.done = (!stopBtn || stopBtn.offsetParent === null);
443
-
444
- // If we haven't extracted any text at all, we are NOT done
445
- if (!result.answer && !result.thinking) {
446
- result.done = false;
447
  }
448
-
449
- return result;
 
 
 
450
  }"""
451
 
452
- async def _wait_for_response(self, timeout: int, prompt: str = "") -> dict:
453
- """Wait for response and return {content, reasoning_content}."""
454
  deadline = time.time() + timeout
455
  await asyncio.sleep(0.8)
456
-
457
- last_answer = ""
458
- last_thinking = ""
459
- stable_count = 0
460
-
461
  while time.time() < deadline:
462
  try:
463
  result = await self.page.evaluate(self._EXTRACT_JS)
464
-
465
- answer = (result.get("answer") or "").strip()
466
- thinking = (result.get("thinking") or "").strip()
467
-
468
- if answer or thinking:
469
- if answer != last_answer or thinking != last_thinking:
470
- last_answer = answer
471
- last_thinking = thinking
472
- stable_count = 0
473
- else:
474
- stable_count += 1
475
-
476
- if stable_count >= 3:
477
- return {"content": last_answer, "reasoning_content": last_thinking}
478
-
479
- except Exception:
480
- pass
481
-
482
  await asyncio.sleep(0.5)
483
-
484
  if last_answer or last_thinking:
485
- logger.info("[_wait_for_response] Done. Think len: %d, Ans len: %d", len(last_thinking), len(last_answer))
486
  return {"content": last_answer, "reasoning_content": last_thinking}
487
-
488
  raise TimeoutError("No response received")
489
 
490
- async def stream_message(self, prompt: str, timeout: int = 120, model: str = "deepseek-chat") -> AsyncGenerator[dict, None]:
491
- """Stream response, yielding dicts: {'type': 'thinking'|'content', 'chunk': str}."""
492
  try:
493
  await self.new_chat()
494
  await self.switch_model(model)
495
-
496
- input_field = self.page.locator('textarea').first
497
- await input_field.wait_for(state="visible", timeout=15000)
498
-
499
- await input_field.fill(prompt)
500
  await self._human_delay()
501
- await input_field.press('Enter')
502
-
503
  deadline = time.time() + timeout
504
- last_thinking = ""
505
- last_answer = ""
506
- stable_count = 0
507
-
508
  await asyncio.sleep(0.8)
509
-
510
  while time.time() < deadline:
511
  try:
512
  result = await self.page.evaluate(self._EXTRACT_JS)
513
-
514
- thinking = (result.get("thinking") or "").strip()
515
- answer = (result.get("answer") or "").strip()
516
-
517
- if thinking and thinking != last_thinking:
518
- new_think = thinking[len(last_thinking):]
519
- if new_think:
520
- logger.info("[Stream] Thinking: %s", new_think.replace('\\n', ' ')[:50])
521
- yield {"type": "thinking", "chunk": new_think}
522
- last_thinking = thinking
523
-
524
- if answer and answer != last_answer:
525
- new_ans = answer[len(last_answer):]
526
- if new_ans:
527
- logger.info("[Stream] Content: %s", new_ans.replace('\\n', ' ')[:50])
528
- yield {"type": "content", "chunk": new_ans}
529
- last_answer = answer
530
- stable_count = 0
531
- elif answer:
532
- stable_count += 1
533
-
534
- if stable_count >= 3:
535
- break
536
-
537
- except Exception:
538
- pass
539
-
540
  await asyncio.sleep(0.3)
541
-
542
  try:
543
  await self.delete_chat()
544
  except Exception as e:
545
- logger.warning("[stream_message] delete_chat cleanup error: %s", e)
546
-
547
  except Exception as e:
548
  logger.error("Stream message error: %s", e)
549
  raise
550
 
551
  async def close(self):
552
  if self.context:
553
- await self.context.close()
 
 
 
 
 
 
 
 
1
  import asyncio
2
  import logging
3
+ import os
4
  import random
5
  import time
6
  from pathlib import Path
 
10
 
11
  logger = logging.getLogger(__name__)
12
 
13
+ SCREENSHOT_DIR = Path(__file__).parent / "static" / "screenshots"
14
+
15
 
16
  class DeepSeekBrowser:
17
  DEEPSEEK_URL = "https://chat.deepseek.com"
18
 
19
+ def __init__(self, email, password, profile_dir="./profiles",
20
+ headless=True, humanize=True, proxy=None):
 
 
 
 
 
 
 
21
  self.email = email
22
  self.password = password
23
  self.profile_dir = Path(profile_dir) / email.replace("@", "_at_").replace("+", "_plus_")
 
29
  self._logged_in = False
30
  self._ready = False
31
 
32
+ def _safe_email(self):
33
+ return self.email.replace("@", "_at_").replace("+", "_plus_")
34
+
35
+ async def _save_screenshot(self, tag):
36
+ try:
37
+ SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
38
+ path = SCREENSHOT_DIR / f"{tag}_{self._safe_email()}.png"
39
+ await self.page.screenshot(path=str(path))
40
+ logger.error("Screenshot saved to %s", path)
41
+ except Exception as e:
42
+ logger.debug("Screenshot failed: %s", e)
43
+
44
+ async def _human_delay(self, min_ms=5, max_ms=30):
45
+ await asyncio.sleep(random.uniform(min_ms, max_ms) / 1000)
46
+
47
  async def start(self):
48
  self.profile_dir.mkdir(parents=True, exist_ok=True)
 
 
49
  args = [
50
+ "--disable-gpu", "--disable-dev-shm-usage", "--disable-extensions",
51
+ "--disable-background-networking", "--disable-default-apps",
52
+ "--disable-sync", "--mute-audio", "--no-sandbox",
53
+ "--js-flags=--max-old-space-size=128", "--renderer-process-limit=1",
 
 
 
 
 
 
54
  ]
 
55
  self.context = await launch_persistent_context_async(
56
+ user_data_dir=str(self.profile_dir), headless=self.headless,
57
+ humanize=self.humanize, proxy=self.proxy,
58
+ viewport={"width": 1280, "height": 720}, locale="zh-CN", args=args,
 
 
 
 
59
  )
 
60
  self.page = await self.context.new_page()
61
  await self.page.goto(self.DEEPSEEK_URL, timeout=30000)
62
+ await self._wait_for_cloudflare()
 
 
 
 
 
63
  await self._check_login_state()
64
 
65
+ async def _wait_for_cloudflare(self):
66
+ deadline = time.time() + 25
67
+ last_url = ""
68
+ while time.time() < deadline:
69
+ try:
70
+ url = self.page.url
71
+ if url == last_url and "/cdn-cgi" not in url:
72
+ try:
73
+ await self.page.wait_for_selector(
74
+ 'textarea, input[type="text"], input[type="password"]',
75
+ timeout=3000)
76
+ return
77
+ except Exception:
78
+ pass
79
+ last_url = url
80
+ except Exception:
81
+ pass
82
+ await asyncio.sleep(1)
83
+ await asyncio.sleep(2)
84
+
85
  async def _check_login_state(self):
86
  current_url = self.page.url
87
+ try:
88
+ await self.page.wait_for_selector("textarea", timeout=10000)
89
+ self._logged_in = True
90
+ self._ready = True
91
+ if self._logged_in:
92
+ await self._check_mute()
93
+ return
94
+ except Exception:
95
+ pass
96
+ if "/sign_in" in current_url:
97
  await self._auto_login()
98
  else:
99
  try:
100
+ await self.page.wait_for_selector("textarea", timeout=8000)
101
  self._logged_in = True
102
  self._ready = True
103
  except Exception:
104
  await self._auto_login()
 
 
105
  if self._logged_in:
106
  await self._check_mute()
107
 
108
  async def _check_mute(self):
 
109
  try:
110
  muted, until = await self.page.evaluate("""() => {
111
+ const t = document.body.innerText || '';
112
+ const m = t.match(/禁言至\\s*(\\d{4}[-]\\d{1,2}[-]\\d{1,2}[]?\\s*\\d{1,2}:\\d{2})/);
113
+ if (m) return [true, m[1]];
114
+ if (t.includes('禁言')) return [true, ''];
 
115
  return [false, ''];
116
  }""")
117
  self._is_muted = muted
 
122
  self._is_muted = False
123
  self._muted_until = ""
124
 
125
+ def is_muted(self):
126
+ return getattr(self, "_is_muted", False)
127
 
128
+ def muted_until(self):
129
+ return getattr(self, "_muted_until", "")
130
 
131
  async def _auto_login(self):
132
  logger.info("Logging in as %s...", self.email)
133
+ await self._wait_for_cloudflare()
134
 
 
 
 
 
 
 
 
 
135
  try:
136
  pwd_tab = self.page.locator('text="密码登录"').first
137
  if await pwd_tab.is_visible():
138
  await pwd_tab.click()
139
+ await asyncio.sleep(0.3)
140
  except Exception as e:
141
+ logger.debug("No password login tab: %s", e)
142
 
143
  try:
144
+ email_input = None
145
+ deadline = time.time() + 15
146
+ while time.time() < deadline:
147
+ for sel in [
148
+ 'input[placeholder*="邮箱"]',
149
+ 'input[placeholder*="手机号"]',
150
+ 'input[placeholder*="Email"]',
151
+ 'input.ds-input__input[type="text"]',
152
+ ]:
153
+ el = self.page.locator(sel).first
154
+ try:
155
+ if await el.count() > 0 and await el.is_visible():
156
+ email_input = el
157
+ break
158
+ except Exception:
159
+ continue
160
+ if email_input:
161
+ break
162
+ await asyncio.sleep(0.8)
163
+ if not email_input:
164
+ raise TimeoutError("Email input not found")
165
  await email_input.fill(self.email)
166
  await asyncio.sleep(0.1)
167
  except Exception as e:
168
+ await self._save_screenshot("login_fail_email")
 
 
 
 
169
  logger.error("Email input error: %s", e)
170
  raise
171
 
172
  try:
173
+ pwd = self.page.locator('input[type="password"]').first
174
+ await pwd.wait_for(state="visible", timeout=5000)
175
+ await pwd.fill(self.password)
176
  await asyncio.sleep(0.1)
177
  except Exception as e:
178
+ await self._save_screenshot("login_fail_password")
179
  logger.error("Password input error: %s", e)
180
  raise
181
 
182
  try:
183
+ btn = self.page.locator('button:has-text("登录")').first
184
+ await btn.click()
185
  await asyncio.sleep(1.5)
186
  except Exception as e:
187
+ await self._save_screenshot("login_fail_button")
188
  logger.error("Login button error: %s", e)
189
  raise
190
 
191
  try:
192
+ await self.page.wait_for_selector("textarea", timeout=20000)
193
  self._logged_in = True
194
  self._ready = True
195
+ logger.info("Login successful for %s", self.email)
196
  except Exception:
197
+ await self._save_screenshot("login_fail_final")
198
+ raise Exception("Login failed — screenshot at /static/screenshots/")
 
 
 
 
 
 
 
 
 
199
 
200
  async def new_chat(self):
 
201
  try:
202
+ btn = self.page.locator(
 
203
  'a:has-text("开启新对话"), button:has-text("开启新对话"), '
204
  'a:has-text("新对话"), button:has-text("新对话"), '
205
  '[class*="new-chat"], [class*="newChat"]'
206
  ).first
207
+ if await btn.count() > 0:
208
+ await btn.click()
209
+ await self.page.wait_for_selector("textarea", timeout=10000)
210
  return
 
 
211
  await self.page.goto(self.DEEPSEEK_URL, timeout=30000)
212
+ await self.page.wait_for_selector("textarea", timeout=15000)
213
  except Exception as e:
214
  logger.error("New chat error: %s", e)
215
  raise
216
 
217
  async def delete_chat(self):
218
  try:
 
219
  chat_list = self.page.locator(
220
+ 'nav, aside, [class*="sidebar"], [class*="Sidebar"], div:has-text("开启新对话")')
221
+ if await chat_list.count() == 0:
 
 
 
222
  return
 
223
  active_item = chat_list.first.locator(
224
+ '[class*="active"], [class*="selected"], [class*="current"]').first
225
+ if await active_item.count() == 0:
 
 
 
 
 
 
 
 
 
 
226
  return
227
+ result = await self.page.evaluate("""() => {
228
+ const a = document.querySelector('[class*="active"], [class*="selected"]');
229
+ if (!a) return 'no-active';
230
+ const walk = (n, d) => {
231
+ if (d > 10) return null;
232
+ for (const c of n.children || []) {
233
+ const t = c.tagName, cls = (c.className || '').toString();
234
+ if ((t === 'BUTTON' || t === 'svg' || cls.includes('icon')
235
+ || cls.includes('more') || cls.includes('menu') || cls.includes('action'))
236
+ && c.offsetWidth < 40 && c.offsetWidth > 0) return c;
237
+ const f = walk(c, d + 1);
238
+ if (f) return f;
 
 
 
 
 
 
 
 
 
239
  }
240
  return null;
241
  };
242
+ const icon = walk(a, 0);
243
+ if (icon) { icon.click(); return 'clicked'; }
244
+ const btn = a.querySelector('button, [role="button"]');
245
+ if (btn) { btn.click(); return 'fallback'; }
 
 
 
 
 
 
 
 
 
246
  return 'no-icon';
247
  }""")
248
+ logger.debug("[delete_chat] icon click: %s", result)
249
  await asyncio.sleep(0.5)
250
+ del_btn = self.page.locator(':has-text("删除"), :has-text("Delete")').first
251
+ if await del_btn.count() == 0:
 
 
 
 
 
 
 
252
  return
253
+ await del_btn.click()
 
254
  await asyncio.sleep(0.5)
255
+ confirm = self.page.locator(
 
 
256
  'button:has-text("确认"), button:has-text("删除"), '
257
+ 'button:has-text("Confirm"), button:has-text("Delete")').last
258
+ if await confirm.count() > 0:
259
+ await confirm.click()
 
260
  await asyncio.sleep(1)
 
 
 
 
261
  except Exception as e:
262
  logger.warning("[delete_chat] error: %s", e)
263
 
264
+ async def switch_model(self, model):
265
  try:
266
+ js = """(texts) => {
 
 
267
  const els = Array.from(document.querySelectorAll('*'));
268
+ const t = els.reverse().find(el => {
269
  if (!el.innerText || el.children.length > 0) return false;
270
+ return texts.some(x => el.innerText.includes(x)) && el.offsetParent !== null;
271
  });
272
+ if (t) { t.click(); return true; }
 
 
 
273
  return false;
274
  }"""
275
+ await self.page.evaluate(js, ['深度思考', 'DeepThink', 'R1'])
 
 
276
  await asyncio.sleep(0.5)
 
277
  except Exception as e:
278
+ logger.warning("[switch_model] error: %s", e)
279
 
280
+ async def send_message(self, prompt, timeout=120, model="deepseek-chat"):
 
281
  try:
282
  await self.new_chat()
283
  await self.switch_model(model)
284
+ inp = self.page.locator("textarea").first
285
+ await inp.wait_for(state="visible", timeout=15000)
286
+ await inp.fill(prompt)
 
 
287
  await self._human_delay()
288
+ await inp.press("Enter")
 
289
  result = await self._wait_for_response(timeout, prompt)
 
290
  asyncio.create_task(self._safe_delete_chat())
 
291
  return result
292
  except Exception as e:
293
  logger.error("Send message error: %s", e)
294
  raise
295
 
296
  async def _safe_delete_chat(self):
 
297
  try:
298
  await self.delete_chat()
299
  except Exception as e:
300
  logger.debug("[safe_delete] %s", e)
301
 
 
302
  _EXTRACT_JS = """() => {
303
+ const r = {thinking: '', answer: '', done: false};
 
 
304
  const msgs = document.querySelectorAll(
305
+ '[class*="assistant"], [class*="bot-"], [class*="message--"], [class*="message-wrapper"], [class*="chat-message"]');
306
+ let last = null;
 
307
  for (let i = msgs.length - 1; i >= 0; i--) {
308
+ if (!(msgs[i].className || '').toLowerCase().includes('user')) { last = msgs[i]; break; }
 
 
 
 
 
 
 
 
 
309
  }
310
+ if (!last) return r;
311
+ const scope = last;
 
312
  const mdEls = Array.from(scope.querySelectorAll(
313
+ '[class*="markdown"], [class*="Markdown"], [class*="answer"], [class*="content"]'));
314
+ const top = mdEls.filter(el => !mdEls.some(p => p !== el && p.contains(el)));
315
+ let think = '', ans = '';
316
+ if (top.length >= 2) { think = top[0].innerText.trim(); ans = top[top.length - 1].innerText.trim(); }
317
+ else if (top.length === 1) {
318
+ const t = top[0].innerText.trim();
319
+ if (scope.innerText.includes('深度思考') && !scope.innerText.includes('已深度思考')) think = t;
320
+ else ans = t;
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  }
322
+ const bt = scope.innerText || '';
323
+ const hasMarker = bt.includes('深度思考') || bt.includes('极速思考') || bt.includes('思考过程');
324
+ if (!ans || (!think && hasMarker)) {
325
+ const lines = bt.split('\\n').map(l => l.trim()).filter(Boolean);
326
+ const skip = ['智能搜索','快速模式','专家模式','极速思考','内容由 AI 生成','开启新对话','暂无历史对话'];
327
+ let isTh = false, tl = [], al = [];
 
 
 
 
 
 
 
328
  for (const l of lines) {
329
  if (skip.some(s => l === s)) continue;
 
 
330
  if (l.length < 30 && (l.includes('深度思考') || l.includes('极速思考') || l.includes('思考过程'))) {
331
+ isTh = !(l.includes('已') || l.includes('用时') || l.includes('完成'));
 
 
 
 
332
  continue;
333
  }
334
+ if (isTh) tl.push(l); else al.push(l);
 
 
 
 
 
335
  }
336
+ if (tl.length) think = tl.join('\\n');
337
+ if (al.length) ans = al.join('\\n');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  }
339
+ r.thinking = think; r.answer = ans;
340
+ const sb = document.querySelector('[class*="stop"], button[aria-label*="stop"]');
341
+ r.done = (!sb || sb.offsetParent === null);
342
+ if (!r.answer && !r.thinking) r.done = false;
343
+ return r;
344
  }"""
345
 
346
+ async def _wait_for_response(self, timeout, prompt=""):
 
347
  deadline = time.time() + timeout
348
  await asyncio.sleep(0.8)
349
+ last_answer, last_thinking, stable = "", "", 0
 
 
 
 
350
  while time.time() < deadline:
351
  try:
352
  result = await self.page.evaluate(self._EXTRACT_JS)
353
+ except Exception as e:
354
+ err = str(e)
355
+ if "Target crashed" in err or "Target closed" in err:
356
+ logger.error("[_wait_for_response] Browser crashed: %s", e)
357
+ raise
358
+ await asyncio.sleep(0.5)
359
+ continue
360
+ answer = (result.get("answer") or "").strip()
361
+ thinking = (result.get("thinking") or "").strip()
362
+ if answer or thinking:
363
+ if answer != last_answer or thinking != last_thinking:
364
+ last_answer, last_thinking, stable = answer, thinking, 0
365
+ else:
366
+ stable += 1
367
+ if stable >= 3:
368
+ return {"content": last_answer, "reasoning_content": last_thinking}
 
 
369
  await asyncio.sleep(0.5)
 
370
  if last_answer or last_thinking:
 
371
  return {"content": last_answer, "reasoning_content": last_thinking}
 
372
  raise TimeoutError("No response received")
373
 
374
+ async def stream_message(self, prompt, timeout=120, model="deepseek-chat"):
 
375
  try:
376
  await self.new_chat()
377
  await self.switch_model(model)
378
+ inp = self.page.locator("textarea").first
379
+ await inp.wait_for(state="visible", timeout=15000)
380
+ await inp.fill(prompt)
 
 
381
  await self._human_delay()
382
+ await inp.press("Enter")
 
383
  deadline = time.time() + timeout
384
+ last_thinking, last_answer, stable = "", "", 0
 
 
 
385
  await asyncio.sleep(0.8)
 
386
  while time.time() < deadline:
387
  try:
388
  result = await self.page.evaluate(self._EXTRACT_JS)
389
+ except Exception as e:
390
+ err = str(e)
391
+ if "Target crashed" in err or "Target closed" in err:
392
+ logger.error("[stream_message] Browser crashed: %s", e)
393
+ raise
394
+ await asyncio.sleep(0.3)
395
+ continue
396
+ thinking = (result.get("thinking") or "").strip()
397
+ answer = (result.get("answer") or "").strip()
398
+ if thinking and thinking != last_thinking:
399
+ new = thinking[len(last_thinking):]
400
+ if new:
401
+ yield {"type": "thinking", "chunk": new}
402
+ last_thinking = thinking
403
+ if answer and answer != last_answer:
404
+ new = answer[len(last_answer):]
405
+ if new:
406
+ yield {"type": "content", "chunk": new}
407
+ last_answer, stable = answer, 0
408
+ elif answer:
409
+ stable += 1
410
+ if stable >= 3:
411
+ break
 
 
 
 
412
  await asyncio.sleep(0.3)
 
413
  try:
414
  await self.delete_chat()
415
  except Exception as e:
416
+ logger.warning("[stream_message] cleanup error: %s", e)
 
417
  except Exception as e:
418
  logger.error("Stream message error: %s", e)
419
  raise
420
 
421
  async def close(self):
422
  if self.context:
423
+ try:
424
+ await self.context.close()
425
+ except Exception as e:
426
+ logger.debug("Error closing browser: %s", e)
427
+ self.context = None
428
+ self.page = None
429
+ self._logged_in = False
430
+ self._ready = False
main.py CHANGED
@@ -382,7 +382,7 @@ async def import_accounts(request: Request, admin_key: str = Header(...)):
382
 
383
  # 异步触发新导入账号的并行预登录
384
  async def prelogin_new_accounts():
385
- sem = asyncio.Semaphore(20)
386
  async def _login_one(account):
387
  async with sem:
388
  try:
@@ -655,6 +655,30 @@ async def clear_logs(admin_key: str = Header(...)):
655
  return {"ok": True}
656
 
657
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
658
  @app.post("/admin/logs/level")
659
  async def set_log_level(request: Request, admin_key: str = Header(...)):
660
  if admin_key != config.server.admin_key:
@@ -696,7 +720,7 @@ async def startup():
696
 
697
  async def _prelogin_all():
698
  """并行预登录全部账号,信号量控制并发避免打崩服务器。"""
699
- sem = asyncio.Semaphore(20)
700
  total = len(manager.accounts)
701
  done = 0
702
 
 
382
 
383
  # 异步触发新导入账号的并行预登录
384
  async def prelogin_new_accounts():
385
+ sem = asyncio.Semaphore(10) # 最多同时 10 个登录,避免浏览器启动过载
386
  async def _login_one(account):
387
  async with sem:
388
  try:
 
655
  return {"ok": True}
656
 
657
 
658
+ SCREENSHOT_DIR = Path(__file__).parent / "static" / "screenshots"
659
+
660
+
661
+ @app.get("/admin/screenshots")
662
+ async def list_screenshots(admin_key: str = Header(...)):
663
+ """List debug screenshots with file sizes and timestamps."""
664
+ if admin_key != config.server.admin_key:
665
+ raise HTTPException(status_code=401, detail="Invalid admin key")
666
+ if not SCREENSHOT_DIR.exists():
667
+ return {"screenshots": []}
668
+ files = sorted(SCREENSHOT_DIR.glob("*.png"), key=lambda p: p.stat().st_mtime, reverse=True)
669
+ return {
670
+ "screenshots": [
671
+ {
672
+ "name": f.name,
673
+ "url": f"/static/screenshots/{f.name}",
674
+ "size_kb": round(f.stat().st_size / 1024, 1),
675
+ "time": time.strftime("%m-%d %H:%M", time.localtime(f.stat().st_mtime)),
676
+ }
677
+ for f in files[:50]
678
+ ]
679
+ }
680
+
681
+
682
  @app.post("/admin/logs/level")
683
  async def set_log_level(request: Request, admin_key: str = Header(...)):
684
  if admin_key != config.server.admin_key:
 
720
 
721
  async def _prelogin_all():
722
  """并行预登录全部账号,信号量控制并发避免打崩服务器。"""
723
+ sem = asyncio.Semaphore(10) # 最多同时 10 个登录,避免浏览器启动过载
724
  total = len(manager.accounts)
725
  done = 0
726
 
static/index.html CHANGED
@@ -384,6 +384,19 @@ html.light-mode .log-viewer{background:rgba(0,0,0,.04);color:#475569}
384
  </div>
385
  </div>
386
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  </main>
388
  </div>
389
 
@@ -633,11 +646,12 @@ async function loadAll(){await loadStats();await loadAccounts()}
633
 
634
  document.getElementById('prompt').addEventListener('keydown',e=>{if(e.ctrlKey&&e.key==='Enter')sendMsg()});
635
 
636
- let _pollTimer=null,_logTimer=null;
637
  function initApp(){
638
- loadAll();loadSettings();loadLogs();
639
  if(!_pollTimer)_pollTimer=setInterval(loadAll,12000);
640
  if(!_logTimer)_logTimer=setInterval(loadLogs,3000);
 
641
  }
642
 
643
  async function loadSettings(){
@@ -697,6 +711,20 @@ async function setLevel(lvl){
697
  toast('日志级别: '+lvl,1);
698
  }catch(e){toast(e.message,0)}
699
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
700
  </script>
701
  </body>
702
  </html>
 
384
  </div>
385
  </div>
386
 
387
+ <!-- Screenshots -->
388
+ <div class="card span-full" style="animation-delay:.5s">
389
+ <div class="card-header">
390
+ <h2><span class="icon">📸</span> 调试截图</h2>
391
+ <button class="btn btn-sm" onclick="loadScreenshots()">刷新</button>
392
+ </div>
393
+ <div class="card-body" style="padding:10px">
394
+ <div id="ssList" style="display:flex;flex-wrap:wrap;gap:8px;font-size:11px;color:var(--text-dim)">
395
+ 加载中…
396
+ </div>
397
+ </div>
398
+ </div>
399
+
400
  </main>
401
  </div>
402
 
 
646
 
647
  document.getElementById('prompt').addEventListener('keydown',e=>{if(e.ctrlKey&&e.key==='Enter')sendMsg()});
648
 
649
+ let _pollTimer=null,_logTimer=null,_ssTimer=null;
650
  function initApp(){
651
+ loadAll();loadSettings();loadLogs();loadScreenshots();
652
  if(!_pollTimer)_pollTimer=setInterval(loadAll,12000);
653
  if(!_logTimer)_logTimer=setInterval(loadLogs,3000);
654
+ if(!_ssTimer)_ssTimer=setInterval(loadScreenshots,30000);
655
  }
656
 
657
  async function loadSettings(){
 
711
  toast('日志级别: '+lvl,1);
712
  }catch(e){toast(e.message,0)}
713
  }
714
+
715
+ async function loadScreenshots(){
716
+ try{
717
+ const d=await api('/admin/screenshots',{headers:{'admin-key':getAdminKey()}});
718
+ const el=document.getElementById('ssList');
719
+ if(!d.screenshots||!d.screenshots.length){el.innerHTML='<span>暂无截图</span>';return}
720
+ el.innerHTML=d.screenshots.map(s=>
721
+ `<a href="${s.url}" target="_blank" style="display:inline-flex;align-items:center;gap:4px;padding:4px 10px;background:var(--surface-solid);border:1px solid var(--border);border-radius:6px;color:var(--text);text-decoration:none;font-size:11px" title="${s.name}&#10;${s.time} · ${s.size_kb}KB">
722
+ 🖼️ ${s.name.replace('login_fail_','').replace('_at_gmail.com','').substring(0,25)}
723
+ <span style="color:var(--text-dim);font-size:10px">${s.time}</span>
724
+ </a>`
725
+ ).join('');
726
+ }catch(e){}
727
+ }
728
  </script>
729
  </body>
730
  </html>