operit_loader.py: - OkHttp 新增 newClient()(真实市场插件普遍使用,而非 newBuilder) - OkHttpRequest.execute() 改为 async,与 Operit 运行时异步语义对齐 - OkHttpResponse 新增 json() 与 statusMessage()(基于 http.HTTPStatus) operit_translate.py(新增): - Operit .ts 插件 -> xiaoli-cli .py 脚手架生成器 - 自动提取 METADATA、生成 exports 胶水,原 TS 作参考注释保留 plugins/operit_scripts/ 新增 5 个已跑通的真实插件: - market_time 时间查询/格式化 - market_duckduckgo 网页内容抓取与清洗 - market_crossref Crossref 学术文献检索 - market_network_test HTTP 连通性测试 - market_reader 本地代码递归搜索 注: 工具需注册为 async 包装函数,同步 lambda 会导致 complete() 不执行的假阳性
196 lines
6.3 KiB
Python
196 lines
6.3 KiB
Python
# METADATA
|
|
# {
|
|
# "name": "market_duckduckgo",
|
|
# "display_name": {
|
|
# "zh": "DuckDuckGo 搜索(市场插件翻译)",
|
|
# "en": "DuckDuckGo Search (translated from operit market)"
|
|
# },
|
|
# "description": {
|
|
# "zh": "从 operit 市场 duckduckgo.ts 忠实翻译:搜索与网页内容抓取。",
|
|
# "en": "Faithful translation of operit market duckduckgo.ts: search & fetch."
|
|
# },
|
|
# "category": "Search",
|
|
# "tools": [
|
|
# {
|
|
# "name": "search",
|
|
# "description": {
|
|
# "zh": "搜索 DuckDuckGo 并返回格式化结果",
|
|
# "en": "Search DuckDuckGo and return formatted results"
|
|
# },
|
|
# "parameters": [
|
|
# {"name": "query", "type": "string", "required": true},
|
|
# {"name": "max_results", "type": "string", "required": false}
|
|
# ]
|
|
# },
|
|
# {
|
|
# "name": "fetch_content",
|
|
# "description": {
|
|
# "zh": "抓取并解析网页内容为纯文本",
|
|
# "en": "Fetch and parse webpage content to plain text"
|
|
# },
|
|
# "parameters": [
|
|
# {"name": "url", "type": "string", "required": true}
|
|
# ]
|
|
# }
|
|
# ]
|
|
# }
|
|
|
|
import re
|
|
import html
|
|
import asyncio
|
|
|
|
|
|
BASE_URL = "https://html.duckduckgo.com/html/"
|
|
|
|
_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
|
|
|
|
|
class RateLimiter:
|
|
"""轻量异步令牌桶,忠实于 TS 版 RateLimiter.acquire()"""
|
|
def __init__(self, max_concurrent: int):
|
|
self._sem = asyncio.Semaphore(max_concurrent)
|
|
|
|
async def acquire(self):
|
|
await self._sem.acquire()
|
|
|
|
def release(self):
|
|
self._sem.release()
|
|
|
|
|
|
_search_limiter = RateLimiter(20)
|
|
_fetch_limiter = RateLimiter(20)
|
|
|
|
|
|
def getErrorMessage(error):
|
|
return str(error) if error else "unknown"
|
|
|
|
|
|
def getErrorStack(error):
|
|
import traceback
|
|
return traceback.format_exc()
|
|
|
|
|
|
def _decode_entities(text: str) -> str:
|
|
return html.unescape(text)
|
|
|
|
|
|
def _format_results_for_llm(results: list) -> str:
|
|
lines = []
|
|
for r in results:
|
|
lines.append(f"{r['position']}. {r['title']}")
|
|
lines.append(f" URL: {r['link']}")
|
|
lines.append(f" {r['snippet']}")
|
|
lines.append("")
|
|
return "\n".join(lines).strip()
|
|
|
|
|
|
async def wrap(func, params, ok_msg="操作成功", err_msg="操作失败"):
|
|
try:
|
|
result = await func(params)
|
|
complete({"success": True, "message": ok_msg, "data": result})
|
|
except Exception as error:
|
|
console.error(f"Error: {getErrorMessage(error)}")
|
|
complete({"success": False, "message": getErrorMessage(error), "error_stack": getErrorStack(error)})
|
|
|
|
|
|
async def search(params):
|
|
"""忠实翻译 duckduckgo.ts search()"""
|
|
query = params.get("query", "")
|
|
max_results = 10
|
|
if params.get("max_results"):
|
|
try:
|
|
parsed = int(params["max_results"])
|
|
if not str(parsed).startswith("NaN"):
|
|
max_results = parsed
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
if not query:
|
|
raise ValueError("查询不能为空")
|
|
|
|
await _search_limiter.acquire()
|
|
try:
|
|
console.log(f"正在从DuckDuckGo搜索: {query}")
|
|
client = OkHttp.newClient()
|
|
request = (client.newRequest()
|
|
.url(BASE_URL)
|
|
.method("POST")
|
|
.body({"q": query, "b": "", "kl": ""}, "form")
|
|
.headers({"User-Agent": _UA}))
|
|
|
|
response = await request.build().execute()
|
|
if not response.isSuccessful():
|
|
raise RuntimeError(f"HTTP 错误! 状态码: {response.statusCode}")
|
|
|
|
html_text = response.content
|
|
results = []
|
|
result_regex = re.compile(
|
|
r'<h2 class="result__title">[\s\S]*?<a.*?href="([^"]+)".*?>([\s\S]+?)</a>'
|
|
r'[\s\S]*?</h2>[\s\S]*?<a class="result__snippet".*?>([\s\S]+?)</a>',
|
|
re.IGNORECASE,
|
|
)
|
|
for m in result_regex.finditer(html_text):
|
|
if len(results) >= max_results:
|
|
break
|
|
link = m.group(1)
|
|
if "y.js" in link:
|
|
continue # 跳过广告
|
|
if link.startswith("//duckduckgo.com/l/?uddg="):
|
|
try:
|
|
link = _decode_entities(link.split("uddg=")[1].split("&")[0])
|
|
except Exception:
|
|
console.error(f"URL 解码失败: {link}")
|
|
title = _decode_entities(re.sub(r"<[^>]*>", "", m.group(2)).strip())
|
|
snippet = _decode_entities(re.sub(r"<[^>]*>", "", m.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)
|
|
finally:
|
|
_search_limiter.release()
|
|
|
|
|
|
async def fetch_content(params):
|
|
"""忠实翻译 duckduckgo.ts fetch_content()"""
|
|
url = params.get("url", "")
|
|
if not url:
|
|
raise ValueError("URL不能为空")
|
|
|
|
await _fetch_limiter.acquire()
|
|
try:
|
|
console.log(f"正在抓取内容: {url}")
|
|
client = OkHttp.newClient()
|
|
request = (client.newRequest()
|
|
.url(url)
|
|
.method("GET")
|
|
.headers({"User-Agent": _UA}))
|
|
|
|
response = await request.build().execute()
|
|
if not response.isSuccessful():
|
|
raise RuntimeError(f"无法访问网页 ({response.statusCode})")
|
|
|
|
text = response.content
|
|
for tag in ("style", "script", "nav", "header", "footer"):
|
|
text = re.sub(rf"<{tag}[^>]*>[\s\S]*?</{tag}>", "", text, flags=re.IGNORECASE)
|
|
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
|
|
finally:
|
|
_fetch_limiter.release()
|
|
|
|
|
|
async def search_wrapper(params):
|
|
await wrap(search, params, "搜索完成", "搜索失败")
|
|
|
|
|
|
async def fetch_content_wrapper(params):
|
|
await wrap(fetch_content, params, "抓取完成", "抓取失败")
|
|
|
|
|
|
exports.search = search_wrapper
|
|
exports.fetch_content = fetch_content_wrapper
|