Add OpenClaw skills, platform kit, and template docs
Made-with: Cursor
This commit is contained in:
222
llm-manager/scripts/engines/doubao.py
Normal file
222
llm-manager/scripts/engines/doubao.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
豆包网页版驱动(doubao.com/chat/)。
|
||||
|
||||
生成后不依赖「喇叭瞬时出现/消失」这种不稳定信号。
|
||||
而是轮询判断两种输出是否已进入可复制状态:
|
||||
1) 右侧写作模式:出现「改用对话直接回答」入口并可点右侧复制按钮
|
||||
2) 左侧对话模式:出现可点的消息复制按钮(拿最后一条新增回复)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from .base import BaseEngine
|
||||
|
||||
_DOUBAO_SPEAKER_PATH_SNIPPET = "M19.8628 9.29346C20.3042"
|
||||
|
||||
|
||||
class DoubaoEngine(BaseEngine):
|
||||
async def generate(self, prompt: str) -> str:
|
||||
self.page.set_default_timeout(60_000)
|
||||
await self.page.goto(
|
||||
"https://www.doubao.com/chat/",
|
||||
wait_until="domcontentloaded",
|
||||
timeout=60_000,
|
||||
)
|
||||
|
||||
editor = self.page.locator('textarea[data-testid="chat_input_input"]').first
|
||||
try:
|
||||
await editor.wait_for(state="visible", timeout=30_000)
|
||||
except Exception:
|
||||
return "ERROR:REQUIRE_LOGIN"
|
||||
|
||||
text = (prompt or "").strip()
|
||||
if not text:
|
||||
return "ERROR:PROMPT_EMPTY"
|
||||
|
||||
await editor.click()
|
||||
await editor.fill(text)
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
send = self.page.locator(
|
||||
'#flow-end-msg-send, [data-testid="chat_input_send_button"]'
|
||||
).first
|
||||
try:
|
||||
await send.wait_for(state="visible", timeout=15_000)
|
||||
await send.click(timeout=10_000)
|
||||
except Exception as e:
|
||||
return f"ERROR:DOUBAO_SEND_FAILED {e}"
|
||||
|
||||
print("💡 [llm-manager/doubao] 已发送,等待生成完成后再复制(最长 180s)...")
|
||||
|
||||
# 发送前记录:用于确认点的是新增复制按钮,且剪贴板确实变化了
|
||||
left_copy = self.page.locator('[data-testid="message_action_copy"]')
|
||||
right_copy = self.page.locator('[data-testid="container_inner_copy_btn"]')
|
||||
left_prev = await left_copy.count()
|
||||
right_prev = await right_copy.count()
|
||||
clipboard_before = await self._safe_read_clipboard()
|
||||
|
||||
# 稳定检测:避免生成中“喇叭短暂出现”误判(需要连续多次可见)
|
||||
speaker = self.page.locator(
|
||||
f'svg path[d*="{_DOUBAO_SPEAKER_PATH_SNIPPET}"]'
|
||||
).last
|
||||
stable_needed = 3
|
||||
stable = 0
|
||||
interval_sec = 0.5
|
||||
deadline = time.monotonic() + 180.0
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
if await speaker.is_visible():
|
||||
stable += 1
|
||||
else:
|
||||
stable = 0
|
||||
except Exception:
|
||||
stable = 0
|
||||
if stable >= stable_needed:
|
||||
break
|
||||
await asyncio.sleep(interval_sec)
|
||||
else:
|
||||
return "ERROR:DOUBAO_WAIT_COMPLETE_TIMEOUT 180 秒内未检测到“喇叭稳定回显”。"
|
||||
|
||||
# 生成完成后:依据当前页面状态决定点右侧还是左侧复制
|
||||
write_mode = await self._is_write_mode()
|
||||
if write_mode:
|
||||
if await self._wait_count_gt(right_copy, right_prev, timeout_sec=15.0):
|
||||
copy_btn = right_copy.last
|
||||
elif await self._wait_count_gt(left_copy, left_prev, timeout_sec=15.0):
|
||||
copy_btn = left_copy.last
|
||||
else:
|
||||
return "ERROR:DOUBAO_COPY_BUTTON_NOT_READY 右侧/左侧复制按钮均未出现新增。"
|
||||
else:
|
||||
if await self._wait_count_gt(left_copy, left_prev, timeout_sec=15.0):
|
||||
copy_btn = left_copy.last
|
||||
elif await self._wait_count_gt(right_copy, right_prev, timeout_sec=5.0):
|
||||
copy_btn = right_copy.last
|
||||
else:
|
||||
return "ERROR:DOUBAO_COPY_BUTTON_NOT_READY 左侧复制按钮未出现新增。"
|
||||
|
||||
try:
|
||||
await copy_btn.scroll_into_view_if_needed()
|
||||
await copy_btn.click(timeout=15_000, force=True)
|
||||
except Exception as e:
|
||||
return f"ERROR:DOUBAO_COPY_CLICK_FAILED {e}"
|
||||
|
||||
out = await self._wait_clipboard_changed(clipboard_before, timeout_sec=20.0)
|
||||
if out is None:
|
||||
return "ERROR:DOUBAO_CLIPBOARD_NOT_UPDATED 剪贴板未在规定时间内更新。"
|
||||
if self._is_invalid_clipboard(out, clipboard_before=clipboard_before):
|
||||
return f"ERROR:DOUBAO_CLIPBOARD_INVALID {out[:200]}"
|
||||
|
||||
await asyncio.sleep(5)
|
||||
return out
|
||||
|
||||
async def _is_write_mode(self) -> bool:
|
||||
marker = self.page.locator('div.bottom-entry-GSWErB:has-text("改用对话直接回答")').first
|
||||
try:
|
||||
return await marker.is_visible()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _wait_count_gt(self, locator, prev_count: int, timeout_sec: float) -> bool:
|
||||
deadline = time.monotonic() + timeout_sec
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
if await locator.count() > prev_count:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(0.3)
|
||||
return False
|
||||
|
||||
async def _wait_clipboard_changed(
|
||||
self, clipboard_before: str | None, timeout_sec: float
|
||||
) -> str | None:
|
||||
deadline = time.monotonic() + timeout_sec
|
||||
last = clipboard_before
|
||||
while time.monotonic() < deadline:
|
||||
cur = await self._safe_read_clipboard()
|
||||
if cur and cur != last and not self._is_invalid_clipboard(
|
||||
cur, clipboard_before=clipboard_before
|
||||
):
|
||||
return cur.strip()
|
||||
last = cur
|
||||
await asyncio.sleep(0.4)
|
||||
return None
|
||||
|
||||
async def _try_copy_right(self, right_copy) -> str | None:
|
||||
# 右侧复制按钮
|
||||
try:
|
||||
btn = right_copy.first
|
||||
await btn.wait_for(state="visible", timeout=2000)
|
||||
await btn.scroll_into_view_if_needed()
|
||||
await btn.click(timeout=5000, force=True)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
await asyncio.sleep(0.4)
|
||||
out = await self._safe_read_clipboard()
|
||||
if self._is_invalid_clipboard(out):
|
||||
return None
|
||||
if out == (await self._safe_read_clipboard()):
|
||||
pass
|
||||
return (out or "").strip()
|
||||
|
||||
async def _try_copy_left(self, left_copy, prev_count: int) -> str | None:
|
||||
n = await left_copy.count()
|
||||
if n <= prev_count:
|
||||
return None
|
||||
|
||||
# 从新增区间末尾往前找第一个可点击的复制按钮
|
||||
for idx in range(n - 1, prev_count - 1, -1):
|
||||
btn = left_copy.nth(idx)
|
||||
try:
|
||||
if not await btn.is_visible():
|
||||
continue
|
||||
await btn.scroll_into_view_if_needed()
|
||||
await btn.click(timeout=5000, force=True)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
await asyncio.sleep(0.4)
|
||||
out = await self._safe_read_clipboard()
|
||||
if self._is_invalid_clipboard(out):
|
||||
continue
|
||||
return (out or "").strip()
|
||||
return None
|
||||
|
||||
def _is_invalid_clipboard(
|
||||
self, text: str | None, clipboard_before: str | None = None
|
||||
) -> bool:
|
||||
if not text:
|
||||
return True
|
||||
t = str(text).strip()
|
||||
if not t:
|
||||
return True
|
||||
if clipboard_before is not None and t == str(clipboard_before).strip():
|
||||
return True
|
||||
if t.startswith("ERROR:"):
|
||||
return True
|
||||
# 防止误点拿到 UI 文案/占位文本
|
||||
banned_substrings = (
|
||||
"改用对话直接回答",
|
||||
"新对话",
|
||||
"正在生成",
|
||||
"复制",
|
||||
"下载",
|
||||
"快捷",
|
||||
"发消息",
|
||||
)
|
||||
if any(s in t for s in banned_substrings):
|
||||
return True
|
||||
if "data-testid" in t or "<button" in t:
|
||||
return True
|
||||
# 经验阈值:正常正文不会太短
|
||||
if len(t) < 30:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _safe_read_clipboard(self) -> str | None:
|
||||
try:
|
||||
return await self.page.evaluate("navigator.clipboard.readText()")
|
||||
except Exception:
|
||||
return None
|
||||
Reference in New Issue
Block a user