mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-10 05:10:45 +08:00
本次迁移将网关/通道/工具/技能/脚本与协议资源集中到新结构,统一路径常量与脚本转发机制,减少顶层噪音并保证运行与测试行为一致。 Made-with: Cursor
136 lines
5.9 KiB
Python
136 lines
5.9 KiB
Python
"""基于 DuckDuckGo(ddgs 包)的公网搜索工具(无需 API Key)。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime, timezone
|
||
from typing import Any
|
||
|
||
from oclaw.runtime.tools.base import ToolSpec
|
||
|
||
_MAX_SNIPPET = 800
|
||
_DDGS_TIMEOUT = 15
|
||
|
||
|
||
def _utc_now_iso() -> str:
|
||
return datetime.now(timezone.utc).isoformat()
|
||
|
||
|
||
def _truncate(s: str, limit: int) -> str:
|
||
t = (s or "").strip()
|
||
if len(t) <= limit:
|
||
return t
|
||
return t[: limit - 3] + "..."
|
||
|
||
|
||
def _published_display_and_sort_key(raw: Any) -> tuple[str | None, float]:
|
||
if raw is None:
|
||
return None, float("-inf")
|
||
if isinstance(raw, (int, float)):
|
||
try:
|
||
ts = float(raw)
|
||
dt = datetime.fromtimestamp(ts, timezone.utc)
|
||
return dt.isoformat(), ts
|
||
except (OSError, OverflowError, ValueError):
|
||
return str(raw), float("-inf")
|
||
s = str(raw).strip()
|
||
if not s:
|
||
return None, float("-inf")
|
||
try:
|
||
s2 = s[:-1] + "+00:00" if s.endswith("Z") else s
|
||
dt = datetime.fromisoformat(s2)
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=timezone.utc)
|
||
iso = dt.astimezone(timezone.utc).isoformat()
|
||
return iso, dt.timestamp()
|
||
except Exception:
|
||
return s, float("-inf")
|
||
|
||
|
||
def web_search_tool() -> ToolSpec:
|
||
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||
q = str(args.get("query") or "").strip()
|
||
if not q:
|
||
return {"ok": False, "error": "query is required"}
|
||
raw_max = args.get("max_results")
|
||
try:
|
||
max_n = int(raw_max) if raw_max is not None else 8
|
||
except (TypeError, ValueError):
|
||
max_n = 8
|
||
max_n = max(1, min(15, max_n))
|
||
stype = str(args.get("search_type") or "web").strip().lower()
|
||
if stype not in ("web", "news"):
|
||
return {"ok": False, "error": "search_type must be 'web' or 'news'"}
|
||
timelimit = args.get("time_range")
|
||
if timelimit is not None and timelimit != "":
|
||
tl = str(timelimit).strip().lower()
|
||
allowed = {"d", "w", "m", "y"}
|
||
if tl not in allowed:
|
||
return {"ok": False, "error": f"time_range must be one of {sorted(allowed)} or omitted"}
|
||
timelimit = tl
|
||
else:
|
||
timelimit = None
|
||
try:
|
||
from ddgs import DDGS
|
||
except ImportError:
|
||
return {"ok": False, "error": "Package `ddgs` is not installed. Run: pip install ddgs"}
|
||
|
||
retrieved_at = _utc_now_iso()
|
||
try:
|
||
rows: list[dict[str, Any]] = []
|
||
with DDGS(timeout=_DDGS_TIMEOUT) as ddgs:
|
||
if stype == "web":
|
||
for r in ddgs.text(q, max_results=max_n, timelimit=timelimit):
|
||
if not isinstance(r, dict):
|
||
continue
|
||
title = _truncate(str(r.get("title") or ""), 300)
|
||
url = str(r.get("href") or r.get("url") or "").strip()
|
||
body = _truncate(str(r.get("body") or ""), _MAX_SNIPPET)
|
||
if title or url or body:
|
||
rows.append({"title": title, "url": url, "snippet": body, "published_time": None})
|
||
sort_mode = "relevance"
|
||
note = "Web index does not provide reliable per-result publication times; order follows search relevance. Use search_type=news for time-sorted news."
|
||
else:
|
||
decorated: list[tuple[float, dict[str, Any]]] = []
|
||
for r in ddgs.news(q, max_results=max_n, timelimit=timelimit):
|
||
if not isinstance(r, dict):
|
||
continue
|
||
title = _truncate(str(r.get("title") or ""), 300)
|
||
url = str(r.get("url") or r.get("href") or "").strip()
|
||
body = _truncate(str(r.get("body") or ""), _MAX_SNIPPET)
|
||
pub, sk = _published_display_and_sort_key(r.get("date"))
|
||
src = str(r.get("source") or "").strip()
|
||
item = {"title": title, "url": url, "snippet": body, "published_time": pub}
|
||
if src:
|
||
item["source"] = src
|
||
if title or url or body:
|
||
decorated.append((sk, item))
|
||
decorated.sort(key=lambda x: x[0], reverse=True)
|
||
rows = [x[1] for x in decorated]
|
||
sort_mode = "published_time_desc"
|
||
note = "News results sorted by published_time (newest first). Snippets are from third-party indexes; verify critical facts."
|
||
|
||
if not rows:
|
||
return {"ok": True, "query": q, "search_type": stype, "retrieved_at": retrieved_at, "sort": sort_mode, "results": [], "note": "No results (empty or blocked). Try rephrasing the query."}
|
||
return {"ok": True, "query": q, "search_type": stype, "retrieved_at": retrieved_at, "sort": sort_mode, "results": rows, "source": "duckduckgo", "note": note}
|
||
except Exception as e:
|
||
return {"ok": False, "error": f"Web search failed: {e}"}
|
||
|
||
return ToolSpec(
|
||
name="web_search",
|
||
description="Search the public web (DuckDuckGo via ddgs, no API key).",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {"type": "string", "description": "Search keywords or question."},
|
||
"max_results": {"type": "integer", "description": "Optional. Number of results (1–15). Default 8."},
|
||
"search_type": {"type": "string", "enum": ["web", "news"], "description": "Optional. 'web' or 'news'."},
|
||
"time_range": {"type": "string", "enum": ["d", "w", "m", "y"], "description": "Optional time limit."},
|
||
},
|
||
"required": ["query"],
|
||
"additionalProperties": False,
|
||
},
|
||
handler=handler,
|
||
)
|
||
|
||
|
||
__all__ = ["web_search_tool"]
|