Add OpenClaw skills, platform kit, and template docs

Made-with: Cursor
This commit is contained in:
2026-04-04 10:35:02 +08:00
parent e37b03c00f
commit 35f4758da2
83 changed files with 8971 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
"""
OpenAI 兼容 API 引擎:适用于 DeepSeek、通义千问、Kimi、文心一言、豆包火山方舟
只要平台提供 OpenAI 兼容接口,均可用此引擎,无需 Playwright。
"""
class ApiEngine:
def __init__(self, api_base: str, api_key: str, model: str):
self.api_base = api_base
self.api_key = api_key
self.model = model
def generate(self, prompt: str) -> str:
try:
from openai import OpenAI
except ImportError:
return "ERROR:缺少依赖pip install openai"
try:
client = OpenAI(base_url=self.api_base, api_key=self.api_key)
response = client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
)
content = response.choices[0].message.content
return (content or "").strip()
except Exception as e:
return f"ERROR:API调用失败{e}"

View File

@@ -0,0 +1,15 @@
from abc import ABC, abstractmethod
from playwright.async_api import Page
class BaseEngine(ABC):
def __init__(self, page: Page):
self.page = page
@abstractmethod
async def generate(self, prompt: str) -> str:
"""
基于操作页面的具体大模型的生成逻辑。输入文本,抓取并返回文本。
返回值:正常结果字符串,或以 "ERROR:" 开头的错误描述。
"""
pass

View File

@@ -0,0 +1,70 @@
"""
DeepSeek 网页版驱动引擎chat.deepseek.com
选择器说明(如页面改版需更新):
- 输入框:#chat-inputtextarea
- 发送按钮:[aria-label="发送消息"] 或 div[class*="send"] > button
- 停止生成按钮:[aria-label="停止生成"](生成中可见,生成结束消失)
- 复制按钮:最后一条回复下的 [aria-label="复制"]
"""
import asyncio
from .base import BaseEngine
class DeepSeekEngine(BaseEngine):
async def generate(self, prompt: str) -> str:
await self.page.goto("https://chat.deepseek.com")
await self.page.wait_for_load_state("networkidle")
# 登录检测:若能找到输入框则已登录
try:
editor = self.page.locator("textarea#chat-input").first
await editor.wait_for(state="visible", timeout=10000)
except Exception:
return "ERROR:REQUIRE_LOGIN"
# 输入提示词
await editor.click()
await self.page.keyboard.insert_text(prompt)
await asyncio.sleep(0.5)
# 发送(优先点按钮,失败则按 Enter
sent = False
for sel in ('[aria-label="发送消息"]', 'div[class*="send"] > button', 'button[type="submit"]'):
try:
btn = self.page.locator(sel).first
if await btn.is_visible(timeout=1000):
await btn.click()
sent = True
break
except Exception:
continue
if not sent:
await self.page.keyboard.press("Enter")
print("💡 [llm-manager/deepseek] 已发送提示词,等待 DeepSeek 生成响应...")
await asyncio.sleep(2)
# 等待生成完毕:停止按钮消失即为完成,超时 150 秒
stop_sel = '[aria-label="停止生成"]'
deadline = asyncio.get_event_loop().time() + 150
while asyncio.get_event_loop().time() < deadline:
try:
visible = await self.page.locator(stop_sel).first.is_visible(timeout=500)
if not visible:
break
except Exception:
break
await asyncio.sleep(2)
await asyncio.sleep(2)
# 通过最后一个复制按钮取结果
try:
copy_btn = self.page.locator('[aria-label="复制"]').last
await copy_btn.click()
await asyncio.sleep(0.5)
result = await self.page.evaluate("navigator.clipboard.readText()")
return result.strip()
except Exception as e:
return f"ERROR:抓取 DeepSeek 内容时异常:{e}"

View 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

View File

@@ -0,0 +1,48 @@
import asyncio
from .base import BaseEngine
class KimiEngine(BaseEngine):
async def generate(self, prompt: str) -> str:
await self.page.goto("https://kimi.moonshot.cn/")
await self.page.wait_for_load_state("networkidle")
# 登录检测:等待输入框出现,超时则未登录
try:
editor = self.page.locator(".chat-input-editor").first
await editor.wait_for(state="visible", timeout=10000)
except Exception:
return "ERROR:REQUIRE_LOGIN"
# 输入提示词insert_text 避免换行符被误触发)
await editor.click()
await self.page.keyboard.insert_text(prompt)
await asyncio.sleep(0.5)
# 点击发送按钮
await self.page.locator(".send-button-container").first.click()
print("💡 [llm-manager/kimi] 已发送提示词,等待 Kimi 生成响应...")
# 等待 2 秒让发送按钮进入"生成中"状态
await asyncio.sleep(2)
# 等待 Send SVG 图标重新出现(表示生成完毕)
try:
send_icon = self.page.locator('.send-button-container svg[name="Send"]')
await send_icon.wait_for(state="visible", timeout=150000)
except Exception:
return "ERROR:Kimi 生成超时150秒未见完成标志可能网络卡住或内容被拦截。"
# 稳妥起见多等 2 秒,让复制按钮完全渲染
await asyncio.sleep(2)
# 点击最后一条回复的复制按钮,通过剪贴板取完整文本
try:
copy_btn = self.page.locator('svg[name="Copy"]').last
await copy_btn.click()
await asyncio.sleep(0.5)
result = await self.page.evaluate("navigator.clipboard.readText()")
return result.strip()
except Exception as e:
return f"ERROR:抓取 Kimi 内容时异常:{e}"

View File

@@ -0,0 +1,91 @@
"""
通义千问网页版驱动引擎tongyi.aliyun.com/qianwen
选择器说明(如页面改版需更新):
- 输入框:#search-input 或 textarea[class*="input"]textarea
- 发送按钮button[class*="send"] 或 [aria-label="发送"]
- 停止生成button[class*="stop"] 或 [aria-label="停止"]
- 复制按钮:最后一条回复下的 [class*="copy"] 或 [aria-label="复制"]
"""
import asyncio
from .base import BaseEngine
class QianwenEngine(BaseEngine):
async def generate(self, prompt: str) -> str:
await self.page.goto("https://tongyi.aliyun.com/qianwen/")
await self.page.wait_for_load_state("networkidle")
# 登录检测
input_selectors = [
"#search-input",
"textarea[class*='input']",
"div[class*='input'][contenteditable='true']",
]
editor = None
for sel in input_selectors:
try:
loc = self.page.locator(sel).first
await loc.wait_for(state="visible", timeout=4000)
editor = loc
break
except Exception:
continue
if editor is None:
return "ERROR:REQUIRE_LOGIN"
# 输入提示词
await editor.click()
await self.page.keyboard.insert_text(prompt)
await asyncio.sleep(0.5)
# 发送
sent = False
for sel in (
'button[class*="send"]',
'[aria-label="发送"]',
'button[type="submit"]',
):
try:
btn = self.page.locator(sel).first
if await btn.is_visible(timeout=1000):
await btn.click()
sent = True
break
except Exception:
continue
if not sent:
await self.page.keyboard.press("Enter")
print("💡 [llm-manager/qianwen] 已发送提示词,等待通义千问生成响应...")
await asyncio.sleep(2)
# 等待生成完毕
stop_selectors = ['button[class*="stop"]', '[aria-label="停止"]', '[aria-label="停止生成"]']
deadline = asyncio.get_event_loop().time() + 150
while asyncio.get_event_loop().time() < deadline:
stop_visible = False
for sel in stop_selectors:
try:
if await self.page.locator(sel).first.is_visible(timeout=300):
stop_visible = True
break
except Exception:
pass
if not stop_visible:
break
await asyncio.sleep(2)
await asyncio.sleep(2)
# 取结果
try:
copy_btn = self.page.locator(
'[aria-label="复制"], [class*="copy-btn"], button:has(svg[class*="copy"])'
).last
await copy_btn.click()
await asyncio.sleep(0.5)
result = await self.page.evaluate("navigator.clipboard.readText()")
return result.strip()
except Exception as e:
return f"ERROR:抓取通义千问内容时异常:{e}"

View File

@@ -0,0 +1,91 @@
"""
文心一言网页版驱动引擎yiyan.baidu.com
选择器说明(如页面改版需更新):
- 输入框:[class*="editor"] 或 div[contenteditable="true"](富文本编辑器)
- 发送按钮:[class*="send-btn"] 或 [aria-label="发送"]
- 停止生成:[class*="stop"] 相关按钮
- 复制按钮:最后一条回复下的复制按钮
"""
import asyncio
from .base import BaseEngine
class YiyanEngine(BaseEngine):
async def generate(self, prompt: str) -> str:
await self.page.goto("https://yiyan.baidu.com")
await self.page.wait_for_load_state("networkidle")
# 登录检测
input_selectors = [
"div[class*='editor'][contenteditable='true']",
"textarea[class*='input']",
"[contenteditable='true']",
]
editor = None
for sel in input_selectors:
try:
loc = self.page.locator(sel).first
await loc.wait_for(state="visible", timeout=4000)
editor = loc
break
except Exception:
continue
if editor is None:
return "ERROR:REQUIRE_LOGIN"
# 输入提示词
await editor.click()
await self.page.keyboard.insert_text(prompt)
await asyncio.sleep(0.5)
# 发送
sent = False
for sel in (
'[class*="send-btn"]',
'[aria-label="发送"]',
'button[class*="send"]',
):
try:
btn = self.page.locator(sel).first
if await btn.is_visible(timeout=1000):
await btn.click()
sent = True
break
except Exception:
continue
if not sent:
await self.page.keyboard.press("Enter")
print("💡 [llm-manager/yiyan] 已发送提示词,等待文心一言生成响应...")
await asyncio.sleep(2)
# 等待生成完毕
stop_selectors = ['[class*="stop"]', '[aria-label="停止"]', '[aria-label="停止生成"]']
deadline = asyncio.get_event_loop().time() + 150
while asyncio.get_event_loop().time() < deadline:
stop_visible = False
for sel in stop_selectors:
try:
if await self.page.locator(sel).first.is_visible(timeout=300):
stop_visible = True
break
except Exception:
pass
if not stop_visible:
break
await asyncio.sleep(2)
await asyncio.sleep(2)
# 取结果
try:
copy_btn = self.page.locator(
'[aria-label="复制"], [class*="copy"], button:has(svg[class*="copy"])'
).last
await copy_btn.click()
await asyncio.sleep(0.5)
result = await self.page.evaluate("navigator.clipboard.readText()")
return result.strip()
except Exception as e:
return f"ERROR:抓取文心一言内容时异常:{e}"

View File

@@ -0,0 +1,94 @@
"""
腾讯元宝网页版驱动引擎yuanbao.tencent.com
元宝暂无公开 API仅支持网页模式。
选择器说明(如页面改版需更新):
- 输入框:[class*="input-area"] textarea 或 [contenteditable="true"]
- 发送按钮:[class*="send-btn"] 或 [aria-label="发送"]
- 停止生成:[class*="stop"] 相关按钮
- 复制按钮:最后一条回复下的复制按钮
"""
import asyncio
from .base import BaseEngine
class YuanbaoEngine(BaseEngine):
async def generate(self, prompt: str) -> str:
await self.page.goto("https://yuanbao.tencent.com/chat")
await self.page.wait_for_load_state("networkidle")
# 登录检测
input_selectors = [
"textarea[class*='input']",
"div[class*='input-area'] textarea",
"[contenteditable='true']",
"textarea",
]
editor = None
for sel in input_selectors:
try:
loc = self.page.locator(sel).first
await loc.wait_for(state="visible", timeout=4000)
editor = loc
break
except Exception:
continue
if editor is None:
return "ERROR:REQUIRE_LOGIN"
# 输入提示词
await editor.click()
await self.page.keyboard.insert_text(prompt)
await asyncio.sleep(0.5)
# 发送
sent = False
for sel in (
'[class*="send-btn"]',
'[aria-label="发送"]',
'button[class*="send"]',
'button[type="submit"]',
):
try:
btn = self.page.locator(sel).first
if await btn.is_visible(timeout=1000):
await btn.click()
sent = True
break
except Exception:
continue
if not sent:
await self.page.keyboard.press("Enter")
print("💡 [llm-manager/yuanbao] 已发送提示词,等待腾讯元宝生成响应...")
await asyncio.sleep(2)
# 等待生成完毕
stop_selectors = ['[class*="stop"]', '[aria-label="停止"]', '[aria-label="停止生成"]']
deadline = asyncio.get_event_loop().time() + 150
while asyncio.get_event_loop().time() < deadline:
stop_visible = False
for sel in stop_selectors:
try:
if await self.page.locator(sel).first.is_visible(timeout=300):
stop_visible = True
break
except Exception:
pass
if not stop_visible:
break
await asyncio.sleep(2)
await asyncio.sleep(2)
# 取结果
try:
copy_btn = self.page.locator(
'[aria-label="复制"], [class*="copy"], button:has(svg[class*="copy"])'
).last
await copy_btn.click()
await asyncio.sleep(0.5)
result = await self.page.evaluate("navigator.clipboard.readText()")
return result.strip()
except Exception as e:
return f"ERROR:抓取腾讯元宝内容时异常:{e}"