Spaces:
Running
Running
nacho commited on
Commit ·
d12c6be
1
Parent(s): b454740
fix: login timeout, target crash, screenshot viewer, prelogin speed
Browse files- deepseek_browser.py +233 -356
- main.py +26 -2
- 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 |
-
|
| 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-
|
| 43 |
-
"--disable-
|
| 44 |
-
"--
|
| 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 |
-
|
| 56 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
await self._auto_login()
|
| 78 |
else:
|
| 79 |
try:
|
| 80 |
-
await self.page.wait_for_selector(
|
| 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
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
if (
|
| 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)
|
| 110 |
-
return getattr(self,
|
| 111 |
|
| 112 |
-
def muted_until(self)
|
| 113 |
-
return getattr(self,
|
| 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.
|
| 131 |
except Exception as e:
|
| 132 |
-
logger.debug("No password login tab
|
| 133 |
|
| 134 |
try:
|
| 135 |
-
email_input =
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
await email_input.fill(self.email)
|
| 138 |
await asyncio.sleep(0.1)
|
| 139 |
except Exception as e:
|
| 140 |
-
|
| 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 |
-
|
| 150 |
-
await
|
| 151 |
-
await
|
| 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 |
-
|
| 159 |
-
await
|
| 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(
|
| 167 |
self._logged_in = True
|
| 168 |
self._ready = True
|
| 169 |
-
logger.info("Login successful
|
| 170 |
except Exception:
|
| 171 |
-
|
| 172 |
-
|
| 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 |
-
|
| 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
|
| 193 |
-
await
|
| 194 |
-
await self.page.wait_for_selector(
|
| 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(
|
| 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 |
-
|
| 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 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 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 |
-
|
| 255 |
-
|
| 256 |
-
|
| 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",
|
| 269 |
await asyncio.sleep(0.5)
|
| 270 |
-
|
| 271 |
-
|
| 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 |
-
|
| 289 |
-
|
| 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
|
| 300 |
try:
|
| 301 |
-
|
| 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
|
| 306 |
if (!el.innerText || el.children.length > 0) return false;
|
| 307 |
-
return texts.some(
|
| 308 |
});
|
| 309 |
-
if (
|
| 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]
|
| 322 |
|
| 323 |
-
async def send_message(self, prompt
|
| 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 |
-
|
| 330 |
-
await
|
| 331 |
-
|
| 332 |
-
await input_field.fill(prompt)
|
| 333 |
await self._human_delay()
|
| 334 |
-
await
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 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 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 450 |
}"""
|
| 451 |
|
| 452 |
-
async def _wait_for_response(self, timeout
|
| 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 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 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
|
| 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 |
-
|
| 497 |
-
await
|
| 498 |
-
|
| 499 |
-
await input_field.fill(prompt)
|
| 500 |
await self._human_delay()
|
| 501 |
-
await
|
| 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 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
if
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 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]
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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(
|
| 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} ${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>
|