DouBao2Api/app/doubao.py

397 lines
13 KiB
Python
Raw Normal View History

2026-08-20 11:32:32 +08:00
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)