DouBao2Api/app/doubao.py

397 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import random
import time
from typing import AsyncGenerator, List, Optional, Tuple
from loguru import logger
from playwright.async_api import Locator, Page, TimeoutError as PWTimeout
from .config import settings
from .models import ChatMessage
FIND_INPUT_JS = """
() => {
const selectors = [
'.tiptap.ProseMirror[contenteditable="true"]',
'div[contenteditable="true"]',
'textarea[placeholder*=""]',
'textarea[placeholder*="输入"]',
'textarea[placeholder*=""]',
'textarea',
'[role="textbox"]',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el && el.offsetParent !== null) {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) return sel;
}
}
return null;
}
"""
FIND_SEND_BTN_JS = """
() => {
const selectors = [
'button[aria-label*="发送"]',
'button[aria-label*="Send"]',
'button[type="submit"]',
'button[class*="send"]',
'button[class*="submit"]',
'[data-testid*="send"]',
'button[class*="Send"]',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el && el.offsetParent !== null) return sel;
}
return null;
}
"""
GET_RESPONSE_TEXT_JS = """
() => {
const messageList = document.querySelector('[class*="message-list"]');
if (messageList) {
const msgs = Array.from(messageList.children).filter(el => {
const cls = el.className || '';
return el.offsetParent !== null
&& !cls.includes('action-bar')
&& !cls.includes('suggest-')
&& el.innerText.trim();
});
if (msgs.length > 0) {
return msgs[msgs.length - 1].innerText.trim();
}
}
return null;
}
"""
IS_GENERATING_JS = """
() => {
const stopSelectors = [
'[class*="stop-generate"]',
'[class*="stop_generate"]',
'[aria-label*="停止"]',
'[aria-label*="Stop"]',
'[data-testid*="stop"]',
'button[disabled][class*="send"]',
];
for (const sel of stopSelectors) {
const el = document.querySelector(sel);
if (el && el.offsetParent !== null) return true;
}
const spinnerSelectors = [
'[class*="typing-indicator"]',
'[class*="thinking"]',
'[class*="loading-dot"]',
'svg[class*="loading"]',
'[class*="generating"]',
];
for (const sel of spinnerSelectors) {
const el = document.querySelector(sel);
if (el && el.offsetParent !== null) return true;
}
return false;
}
"""
NEW_CHAT_BTN_JS = """
() => {
const selectors = [
'button[aria-label*="新对话"]',
'button[aria-label*="New"]',
'a[aria-label*="新对话"]',
'a[aria-label*="New"]',
'[class*="new-chat"]',
'[class*="new_chat"]',
'[data-testid*="new-chat"]',
'[data-testid*="new_chat"]',
'a[href*="/chat"][class*="new"]',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el && el.offsetParent !== null) return sel;
}
return null;
}
"""
INSPECT_DOM_JS = """
() => {
const result = {
url: window.location.href,
title: document.title,
textareas: [],
contentEditables: [],
buttons: [],
messageContainers: [],
};
document.querySelectorAll('textarea').forEach(el => {
result.textareas.push({
placeholder: el.placeholder,
className: el.className.slice(0, 80),
visible: el.offsetParent !== null,
});
});
document.querySelectorAll('[contenteditable="true"]').forEach(el => {
result.contentEditables.push({
className: el.className.slice(0, 80),
visible: el.offsetParent !== null,
});
});
document.querySelectorAll('button').forEach(el => {
if (el.offsetParent !== null) {
result.buttons.push({
text: el.innerText.slice(0, 40),
ariaLabel: el.getAttribute('aria-label'),
className: el.className.slice(0, 80),
});
}
});
const msgPatterns = ['message', 'chat-item', 'conversation', 'receive', 'bot', 'ai-msg'];
msgPatterns.forEach(pattern => {
document.querySelectorAll(`[class*="${pattern}"]`).forEach(el => {
if (el.offsetParent !== null && result.messageContainers.length < 20) {
result.messageContainers.push({
pattern: pattern,
className: el.className.slice(0, 80),
text: el.innerText.slice(0, 100),
childCount: el.children.length,
});
}
});
});
return result;
}
"""
class DoubaoChat:
def __init__(self, page: Page):
self.page = page
async def start_new_chat(self) -> bool:
"""Try to start a new chat conversation."""
try:
selector = await self.page.evaluate(NEW_CHAT_BTN_JS)
if selector:
btn = self.page.locator(selector).first
await btn.click()
await self.page.wait_for_timeout(2000)
logger.info("Started new chat via button")
return True
except Exception as e:
logger.debug(f"New chat button not found: {e}")
try:
await self.page.goto(settings.doubao_url, wait_until="domcontentloaded")
await self.page.wait_for_timeout(3000)
logger.info("Navigated to fresh chat URL")
return True
except Exception as e:
logger.warning(f"Failed to navigate to chat URL: {e}")
return False
async def _find_input(self) -> Optional[Tuple[Locator, str]]:
"""Find the input element, returns (locator, selector)."""
selector = await self.page.evaluate(FIND_INPUT_JS)
if not selector:
return None
loc = self.page.locator(selector).first
try:
await loc.wait_for(state="visible", timeout=5000)
return loc, selector
except PWTimeout:
return None
async def _find_send_button(self) -> Optional[Locator]:
"""Find the send button."""
selector = await self.page.evaluate(FIND_SEND_BTN_JS)
if not selector:
return None
loc = self.page.locator(selector).first
try:
await loc.wait_for(state="visible", timeout=3000)
return loc
except PWTimeout:
return None
async def _type_message(self, input_loc: Locator, text: str):
"""Type a message with human-like delays."""
await input_loc.click()
await asyncio.sleep(0.2)
if len(text) > 1000:
await self.page.keyboard.insert_text(text)
await asyncio.sleep(0.3)
else:
delay = random.randint(settings.typing_delay_min, settings.typing_delay_max)
await self.page.keyboard.type(text, delay=delay)
await asyncio.sleep(settings.inter_message_delay)
async def _send_via_enter(self, input_loc: Locator) -> bool:
"""Try sending by pressing Enter."""
await input_loc.press("Enter")
await asyncio.sleep(0.5)
return True
async def _send_via_button(self) -> bool:
"""Try sending by clicking the send button."""
btn = await self._find_send_button()
if btn:
await btn.click()
await asyncio.sleep(0.5)
return True
return False
async def _is_input_empty(self, input_loc: Locator, selector: str) -> bool:
"""Check if the input element is empty (message was sent)."""
try:
if "textarea" in selector:
val = await input_loc.input_value()
else:
val = await input_loc.inner_text()
return not val.strip()
except Exception:
return True
async def send_message(self, text: str) -> str:
"""Send a message to the Doubao chat.
Returns the previous response text (for change detection).
"""
result = await self._find_input()
if not result:
logger.error("Input element not found on page")
raise RuntimeError("无法找到输入框请通过VNC检查页面状态")
input_loc, selector = result
old_response = await self.page.evaluate(GET_RESPONSE_TEXT_JS) or ""
await self._type_message(input_loc, text)
sent = await self._send_via_enter(input_loc)
if sent:
await asyncio.sleep(0.8)
if not await self._is_input_empty(input_loc, selector):
logger.info("Enter didn't send, trying send button")
sent = await self._send_via_button()
if not sent:
logger.error("Failed to send message")
raise RuntimeError("消息发送失败请通过VNC检查页面状态")
return old_response
async def _wait_for_response_start(
self, old_text: str, timeout: int = 30
) -> Optional[str]:
"""Wait for the response to start appearing.
Uses two-phase detection:
Phase 1: wait for user message to appear (text changes from old_text)
Phase 2: wait for AI response to start (text changes from baseline)
Returns baseline text (after user message, before AI response),
or None on timeout.
"""
start = time.time()
phase = 1
baseline = None
while time.time() - start < timeout:
current = await self.page.evaluate(GET_RESPONSE_TEXT_JS) or ""
if phase == 1:
if current and current != old_text:
baseline = current
phase = 2
await asyncio.sleep(0.5)
elif phase == 2:
if current != baseline:
logger.debug("Response started")
return baseline
await asyncio.sleep(0.3)
if baseline is not None:
logger.warning("Response may have started during baseline capture")
return baseline
logger.warning("Response did not start within timeout")
return None
async def stream_response(self, old_text: str = "") -> AsyncGenerator[str, None]:
"""Yield response text chunks as they appear.
Args:
old_text: Previous response text from before the message was sent.
Used to detect when the new response starts.
"""
baseline = await self._wait_for_response_start(old_text)
if baseline is None:
return
last_text = baseline
stable_count = 0
STABLE_THRESHOLD = 6
POLL_INTERVAL = 0.15
start_time = time.time()
while True:
current_text = await self.page.evaluate(GET_RESPONSE_TEXT_JS) or ""
if current_text and len(current_text) > len(last_text):
new_chunk = current_text[len(last_text):]
last_text = current_text
stable_count = 0
if new_chunk.strip():
yield new_chunk
elif current_text and current_text == last_text:
stable_count += 1
if stable_count >= STABLE_THRESHOLD:
is_gen = await self.page.evaluate(IS_GENERATING_JS)
if not is_gen:
logger.debug("Response complete (stable + not generating)")
break
elapsed = time.time() - start_time
if elapsed > settings.response_timeout:
logger.warning(f"Response timeout after {elapsed:.1f}s")
break
await asyncio.sleep(POLL_INTERVAL)
async def get_response_full(self, old_text: str = "") -> str:
"""Get the complete response text (non-streaming)."""
chunks = []
async for chunk in self.stream_response(old_text):
chunks.append(chunk)
return "".join(chunks)
async def inspect(self) -> dict:
"""Return DOM structure info for debugging."""
return await self.page.evaluate(INSPECT_DOM_JS)
@staticmethod
def messages_to_prompt(messages: List[ChatMessage]) -> str:
"""Convert OpenAI messages array to a single text prompt."""
if len(messages) == 1 and messages[0].role == "user":
return messages[0].content
parts: List[str] = []
for msg in messages:
if msg.role == "system":
parts.append(f"【系统设定】\n{msg.content}")
elif msg.role == "user":
parts.append(f"【用户】\n{msg.content}")
elif msg.role == "assistant":
parts.append(f"【AI回复】\n{msg.content}")
return "\n\n---\n\n".join(parts)