- 新增 operit_runtime.py: 跨平台 Python 运行时 - 实现 Tools.System/Files/Net/UI/Memory/FFmpeg/Workflow/Chat/SoftwareSettings/Tasker - 实现 OkHttp 链式 HTTP 客户端兼容层 - 实现 complete()/exports/console 全局对象 - 实现 OperitScriptLoader 脚本加载器 - 支持 Windows/macOS/Linux 三平台 - 新增 operit_py/ 目录: 47 个 Python 翻译脚本 - 搜索类: duckduckgo, google_search, tavily, zhipu_search, various_search, crossref - 绘图类: openai_draw, qwen_draw, xai_draw, minimax_draw, siliconflow_draw, zhipu_draw, nanobanana_draw - 媒体类: ffmpeg, file_converter, douyin_download, bilibili_tools, jmcomic - 文件类: extended_file_tools, hex_editor, reader, beeimg_image_uploader - 网络类: extended_http_tools, network_test - 自动化类: browser, automatic_ui_base, automatic_ui_subagent, automatic_bilibili_assistant, automatic_xiaohongshu_assistant, automatic_baidu_map_assistant - 系统类: system_tools, super_admin, daily_life, operit_editor - 对话类: ai_chat, extended_chat, qq_intelligent - 工作流类: workflow, vflow_trigger, tasker - 工具类: time, code_runner, quick_start, extended_memory_tools, baidu_map, 12306, pdf_vision_parser - 测试结果: 47/47 语法检查通过, 38 脚本加载成功, 280 个工具注册 - Android 专属功能已降级处理(保留接口,返回不支持提示)
219 lines
7.7 KiB
Python
219 lines
7.7 KiB
Python
# METADATA
|
|
# {
|
|
# "name": "duckduckgo",
|
|
#
|
|
# "display_name": {
|
|
# "zh": "DuckDuckGo 搜索",
|
|
# "en": "DuckDuckGo Search"
|
|
# },
|
|
# "description": { "zh": "使用DuckDuckGo进行网络搜索和内容抓取。", "en": "Use DuckDuckGo for web search and content extraction." },
|
|
# "category": "Search",
|
|
# "tools": [
|
|
# {
|
|
# "name": "search",
|
|
# "description": { "zh": "执行DuckDuckGo搜索并返回格式化的结果。", "en": "Run a DuckDuckGo search and return formatted results." },
|
|
# "parameters": [
|
|
# {
|
|
# "name": "query",
|
|
# "description": { "zh": "搜索查询字符串", "en": "Search query string." },
|
|
# "type": "string",
|
|
# "required": true
|
|
# },
|
|
# {
|
|
# "name": "max_results",
|
|
# "description": { "zh": "返回的最大结果数 (默认: 10)", "en": "Maximum number of results to return (default: 10)." },
|
|
# "type": "string",
|
|
# "required": false
|
|
# }
|
|
# ]
|
|
# },
|
|
# {
|
|
# "name": "fetch_content",
|
|
# "description": { "zh": "从网页URL抓取和解析内容。", "en": "Fetch and parse content from a webpage URL." },
|
|
# "parameters": [
|
|
# {
|
|
# "name": "url",
|
|
# "description": { "zh": "要抓取内容的网页URL", "en": "Webpage URL to fetch and parse." },
|
|
# "type": "string",
|
|
# "required": true
|
|
# }
|
|
# ]
|
|
# }
|
|
# ]
|
|
# }
|
|
|
|
import re
|
|
import time
|
|
import asyncio
|
|
import traceback
|
|
from urllib.parse import unquote
|
|
|
|
client = OkHttp.newClient()
|
|
BASE_URL = "https://html.duckduckgo.com/html/"
|
|
|
|
|
|
class RateLimiter:
|
|
"""A simple rate limiter to avoid sending too many requests."""
|
|
|
|
def __init__(self, requestsPerMinute=30):
|
|
self.requestsPerMinute = requestsPerMinute
|
|
self.requests = []
|
|
|
|
async def acquire(self):
|
|
now = time.time() * 1000
|
|
# Remove requests older than 1 minute (60000 ms)
|
|
self.requests = [reqTime for reqTime in self.requests if now - reqTime < 60000]
|
|
|
|
if len(self.requests) >= self.requestsPerMinute:
|
|
timeSinceFirstRequest = now - self.requests[0]
|
|
waitTime = 60000 - timeSinceFirstRequest
|
|
if waitTime > 0:
|
|
await asyncio.sleep(waitTime / 1000)
|
|
|
|
self.requests.append(time.time() * 1000)
|
|
|
|
|
|
searchRateLimiter = RateLimiter(30)
|
|
fetchRateLimiter = RateLimiter(20)
|
|
|
|
|
|
def decodeHtmlEntities(text):
|
|
"""Decodes basic HTML entities."""
|
|
text = text.replace("&", "&")
|
|
text = text.replace("<", "<")
|
|
text = text.replace(">", ">")
|
|
text = text.replace(""", '"')
|
|
text = text.replace("'", "'")
|
|
return text
|
|
|
|
|
|
async def search(params):
|
|
query = params.get("query")
|
|
max_results = 10
|
|
if params.get("max_results"):
|
|
try:
|
|
parsedMaxResults = int(params["max_results"])
|
|
max_results = parsedMaxResults
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
if not query:
|
|
raise Exception("查询不能为空")
|
|
|
|
await searchRateLimiter.acquire()
|
|
console.log(f"正在从DuckDuckGo搜索: {query}")
|
|
|
|
request = client.newRequest()
|
|
request = request.url(BASE_URL)
|
|
request = request.method("POST")
|
|
request = request.body({"q": query, "b": "", "kl": ""}, "form")
|
|
request = request.headers({
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
|
})
|
|
|
|
response = await request.build().execute()
|
|
if not response.isSuccessful():
|
|
raise Exception(f"HTTP 错误! 状态码: {response.statusCode}")
|
|
|
|
html = response.content
|
|
results = []
|
|
resultRegex = re.compile(
|
|
r'<h2 class="result__title">.*?<a.*?href="([^"]+)".*?>(.*?)</a>.*?</h2>.*?<a class="result__snippet".*?>(.*?)</a>',
|
|
re.DOTALL
|
|
)
|
|
|
|
for match in resultRegex.finditer(html):
|
|
if len(results) >= max_results:
|
|
break
|
|
|
|
link = match.group(1)
|
|
if "y.js" in link:
|
|
continue # Skip ads
|
|
|
|
if link.startswith("//duckduckgo.com/l/?uddg="):
|
|
try:
|
|
link = unquote(link.split("uddg=")[1].split("&")[0])
|
|
except Exception as e:
|
|
console.error(f"URL 解码失败: {link}")
|
|
|
|
title = decodeHtmlEntities(re.sub(r'<[^>]*>', '', match.group(2)).strip())
|
|
snippet = decodeHtmlEntities(re.sub(r'<[^>]*>', '', match.group(3)).strip())
|
|
|
|
results.append({"title": title, "link": link, "snippet": snippet, "position": len(results) + 1})
|
|
|
|
console.log(f"成功找到 {len(results)} 个结果")
|
|
return format_results_for_llm(results)
|
|
|
|
|
|
async def fetch_content(params):
|
|
url = params.get("url")
|
|
if not url:
|
|
raise Exception("URL不能为空")
|
|
|
|
await fetchRateLimiter.acquire()
|
|
console.log(f"正在抓取内容: {url}")
|
|
|
|
try:
|
|
request = client.newRequest()
|
|
request = request.url(url)
|
|
request = request.method("GET")
|
|
request = request.headers({"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"})
|
|
|
|
response = await request.build().execute()
|
|
if not response.isSuccessful():
|
|
raise Exception(f"无法访问网页 ({response.statusCode})")
|
|
|
|
text = response.content
|
|
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.IGNORECASE | re.DOTALL)
|
|
text = re.sub(r'<script[^>]*>.*?</script>', '', text, flags=re.IGNORECASE | re.DOTALL)
|
|
text = re.sub(r'<nav[^>]*>.*?</nav>', '', text, flags=re.IGNORECASE | re.DOTALL)
|
|
text = re.sub(r'<header[^>]*>.*?</header>', '', text, flags=re.IGNORECASE | re.DOTALL)
|
|
text = re.sub(r'<footer[^>]*>.*?</footer>', '', text, flags=re.IGNORECASE | re.DOTALL)
|
|
text = re.sub(r'<[^>]+>', ' ', text)
|
|
text = re.sub(r'\s+', ' ', text).strip()
|
|
|
|
if len(text) > 8000:
|
|
text = text[:8000] + "... [内容被截断]"
|
|
|
|
console.log(f"成功抓取并解析内容 ({len(text)} 字符)")
|
|
return text
|
|
except Exception as error:
|
|
msg = str(getattr(error, "message", error))
|
|
console.error(f"从 {url} 抓取内容时出错: {msg}")
|
|
return f"错误: 从网页抓取内容时发生意外错误 ({msg})"
|
|
|
|
|
|
def format_results_for_llm(results):
|
|
if not results or len(results) == 0:
|
|
return "没有为您的搜索查询找到结果。这可能是由于DuckDuckGo的机器人检测或查询没有匹配项。请尝试重新措辞您的搜索或在几分钟后重试。"
|
|
|
|
output = [
|
|
f"{r['position']}. {r['title']}\n URL: {r['link']}\n 摘要: {r['snippet']}"
|
|
for r in results
|
|
]
|
|
|
|
return f"找到 {len(results)} 个搜索结果:\n\n" + "\n\n".join(output)
|
|
|
|
|
|
async def duckduckgo_wrap(func, params, successMessage, failMessage):
|
|
try:
|
|
console.log(f"开始执行函数: {func.__name__ or '匿名函数'}")
|
|
result = await func(params)
|
|
complete({"success": True, "message": successMessage, "data": result})
|
|
except Exception as error:
|
|
msg = str(getattr(error, "message", error))
|
|
console.error(f"函数 {func.__name__ or '匿名函数'} 执行失败! 错误: {msg}")
|
|
complete({"success": False, "message": f"{failMessage}: {msg}", "error_stack": traceback.format_exc()})
|
|
|
|
|
|
async def search_wrapper(params):
|
|
await duckduckgo_wrap(search, params, "搜索完成", "搜索失败")
|
|
|
|
|
|
async def fetch_content_wrapper(params):
|
|
await duckduckgo_wrap(fetch_content, params, "内容抓取完成", "内容抓取失败")
|
|
|
|
|
|
exports.search = search_wrapper
|
|
exports.fetch_content = fetch_content_wrapper
|