argo-search 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +410 -0
- package/backends/domain_profiles.json +364 -0
- package/backends/engine_registry.yaml +505 -0
- package/backends/quota_profiles.json +395 -0
- package/bin/argo.js +54 -0
- package/config.yaml +554 -0
- package/package.json +43 -0
- package/scripts/adaptive.py +179 -0
- package/scripts/benchmark.py +124 -0
- package/scripts/cache.py +374 -0
- package/scripts/clarify.py +689 -0
- package/scripts/config.py +262 -0
- package/scripts/crawl.py +73 -0
- package/scripts/engines.py +386 -0
- package/scripts/evidence.py +381 -0
- package/scripts/extract.py +69 -0
- package/scripts/fetch.py +118 -0
- package/scripts/health_check.py +437 -0
- package/scripts/health_probe.py +218 -0
- package/scripts/mcp_diag.py +81 -0
- package/scripts/mcp_server.py +488 -0
- package/scripts/query_rewriter.py +278 -0
- package/scripts/quota.py +196 -0
- package/scripts/research.py +499 -0
- package/scripts/route.py +341 -0
- package/scripts/search.py +508 -0
- package/scripts/search_types.py +72 -0
- package/scripts/tfidf_router.py +312 -0
- package/sub-skills/local-search/SKILL.md +104 -0
- package/sub-skills/local-search/config.yaml +328 -0
- package/sub-skills/local-search/engine_registry.py +298 -0
- package/sub-skills/local-search/health_check.py +347 -0
- package/sub-skills/local-search/local_search_adapter.py +56 -0
- package/sub-skills/local-search/parse_maps.yaml +184 -0
- package/sub-skills/local-search/search_v3.py +558 -0
- package/sub-skills/local-search/smart_router.py +215 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""search_v3.py — local-search 主入口 v3
|
|
3
|
+
|
|
4
|
+
职责:
|
|
5
|
+
- 解析命令行参数(兼容 unified-search CLI 调用)
|
|
6
|
+
- 通过 smart_router 选择本地引擎组合
|
|
7
|
+
- 通过 health_check 过滤不可用引擎(TTL 5min)
|
|
8
|
+
- 并行抓取,解析 HTML/RSS/JSON/XML
|
|
9
|
+
- 复用 unified-search/scripts/cache.py 的 L1/L2 缓存
|
|
10
|
+
- 输出 strict unified-search schema
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import functools
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.parse
|
|
25
|
+
import urllib.request
|
|
26
|
+
import xml.etree.ElementTree as ET
|
|
27
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
# 将 unified-search/scripts 加入路径,以复用 cache.py
|
|
32
|
+
SKILL_DIR = Path(__file__).resolve().parent
|
|
33
|
+
UNIFIED_SCRIPT_DIR = SKILL_DIR.parent.parent / "scripts"
|
|
34
|
+
if UNIFIED_SCRIPT_DIR.exists() and str(UNIFIED_SCRIPT_DIR) not in sys.path:
|
|
35
|
+
sys.path.insert(0, str(UNIFIED_SCRIPT_DIR))
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
from cache import SearchCache
|
|
39
|
+
except ImportError:
|
|
40
|
+
SearchCache = None # type: ignore
|
|
41
|
+
|
|
42
|
+
from engine_registry import EngineRegistry, get_registry
|
|
43
|
+
from health_check import get_available_engines
|
|
44
|
+
from smart_router import route_query
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger("local_search.search_v3")
|
|
47
|
+
if not logger.handlers:
|
|
48
|
+
logger.setLevel(logging.WARNING)
|
|
49
|
+
logger.addHandler(logging.StreamHandler(sys.stderr))
|
|
50
|
+
|
|
51
|
+
CONFIG_PATH = SKILL_DIR / "config.yaml"
|
|
52
|
+
PARSE_MAPS_PATH = SKILL_DIR / "parse_maps.yaml"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _load_yaml(path: Path) -> dict[str, Any]:
|
|
56
|
+
try:
|
|
57
|
+
import yaml # type: ignore
|
|
58
|
+
with path.open("r", encoding="utf-8") as f:
|
|
59
|
+
data = yaml.safe_load(f)
|
|
60
|
+
return data if isinstance(data, dict) else {}
|
|
61
|
+
except Exception as e:
|
|
62
|
+
logger.warning(f"加载 YAML 失败 {path}: {e}")
|
|
63
|
+
return {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@functools.lru_cache(maxsize=1)
|
|
67
|
+
def _load_config() -> dict[str, Any]:
|
|
68
|
+
return _load_yaml(CONFIG_PATH)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@functools.lru_cache(maxsize=1)
|
|
72
|
+
def _load_parse_maps() -> dict[str, Any]:
|
|
73
|
+
return _load_yaml(PARSE_MAPS_PATH)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_HEADERS = {
|
|
77
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
78
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
79
|
+
"Accept-Encoding": "gzip, deflate",
|
|
80
|
+
"DNT": "1",
|
|
81
|
+
"Connection": "keep-alive",
|
|
82
|
+
"Upgrade-Insecure-Requests": "1",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _resolve(template: str | list[str], query: str, n: int, **extra: Any) -> str | list[str]:
|
|
87
|
+
if isinstance(template, list):
|
|
88
|
+
return [_resolve(item, query, n, **extra) for item in template] # type: ignore
|
|
89
|
+
s = str(template).replace("{query}", query).replace("{n}", str(n))
|
|
90
|
+
for k, v in extra.items():
|
|
91
|
+
s = s.replace(f"{{{k}}}", str(v))
|
|
92
|
+
return s
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _fetch(url: str, method: str = "GET", data: bytes | None = None,
|
|
96
|
+
headers: dict[str, str] | None = None, timeout: float = 8,
|
|
97
|
+
user_agent: str = "") -> str:
|
|
98
|
+
req_headers = dict(_HEADERS)
|
|
99
|
+
if headers:
|
|
100
|
+
req_headers.update(headers)
|
|
101
|
+
if user_agent:
|
|
102
|
+
req_headers["User-Agent"] = user_agent
|
|
103
|
+
req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
|
|
104
|
+
try:
|
|
105
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
106
|
+
raw = resp.read()
|
|
107
|
+
if raw.startswith(b"\x1f\x8b"):
|
|
108
|
+
import gzip
|
|
109
|
+
raw = gzip.decompress(raw)
|
|
110
|
+
return raw.decode("utf-8", errors="replace")
|
|
111
|
+
except urllib.error.HTTPError as e:
|
|
112
|
+
logger.warning(f"HTTP {e.code} for {url}")
|
|
113
|
+
except urllib.error.URLError as e:
|
|
114
|
+
logger.warning(f"URL error for {url}: {e.reason}")
|
|
115
|
+
except Exception as e:
|
|
116
|
+
logger.warning(f"Fetch error for {url}: {e}")
|
|
117
|
+
return ""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _build_url(spec: dict[str, Any], query: str, n: int) -> str:
|
|
121
|
+
url = _resolve(spec["url"], query, n)
|
|
122
|
+
qp = spec.get("query_param", "q")
|
|
123
|
+
extra = spec.get("extra_params", {})
|
|
124
|
+
params = {qp: query}
|
|
125
|
+
for k, v in extra.items():
|
|
126
|
+
params[k] = _resolve(str(v), query, n)
|
|
127
|
+
sep = "&" if "?" in url else "?"
|
|
128
|
+
return f"{url}{sep}{urllib.parse.urlencode(params)}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ── HTML 解析 ──────────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
def _select_with_bs4(soup: Any, selector: str):
|
|
134
|
+
try:
|
|
135
|
+
return soup.select(selector)
|
|
136
|
+
except Exception:
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _select_first_with_bs4(soup: Any, selector: str) -> Any:
|
|
141
|
+
items = _select_with_bs4(soup, selector)
|
|
142
|
+
return items[0] if items else None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _parse_html(engine_name: str, html: str, spec: dict[str, Any],
|
|
146
|
+
maps: dict[str, Any]) -> list[dict[str, Any]]:
|
|
147
|
+
html_maps = maps.get("html", {})
|
|
148
|
+
mapping = html_maps.get(engine_name, html_maps.get("default", {}))
|
|
149
|
+
container_sel = mapping.get("container")
|
|
150
|
+
title_sel = mapping.get("title")
|
|
151
|
+
url_sel = mapping.get("url")
|
|
152
|
+
snippet_sel = mapping.get("snippet")
|
|
153
|
+
url_attr = mapping.get("url_attr", "href")
|
|
154
|
+
default_score = mapping.get("score", 0.5)
|
|
155
|
+
|
|
156
|
+
if not container_sel:
|
|
157
|
+
return []
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
from bs4 import BeautifulSoup
|
|
161
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
162
|
+
containers = _select_with_bs4(soup, container_sel)
|
|
163
|
+
except Exception:
|
|
164
|
+
return []
|
|
165
|
+
|
|
166
|
+
results: list[dict[str, Any]] = []
|
|
167
|
+
base = spec.get("_base", spec.get("url", ""))
|
|
168
|
+
for idx, item in enumerate(containers):
|
|
169
|
+
try:
|
|
170
|
+
title_el = _select_first_with_bs4(item, title_sel) if title_sel else None
|
|
171
|
+
url_el = _select_first_with_bs4(item, url_sel) if url_sel else None
|
|
172
|
+
snippet_el = _select_first_with_bs4(item, snippet_sel) if snippet_sel else None
|
|
173
|
+
|
|
174
|
+
title = title_el.get_text(strip=True)[:200] if title_el else ""
|
|
175
|
+
url = ""
|
|
176
|
+
if url_el and url_el.has_attr(url_attr):
|
|
177
|
+
url = url_el[url_attr]
|
|
178
|
+
snippet = snippet_el.get_text(strip=True)[:300] if snippet_el else ""
|
|
179
|
+
|
|
180
|
+
if not title and not url:
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
if url and url.startswith("/"):
|
|
184
|
+
url = urllib.parse.urljoin(base, url)
|
|
185
|
+
|
|
186
|
+
score = max(default_score - idx * 0.05, 0.1)
|
|
187
|
+
results.append({
|
|
188
|
+
"title": title,
|
|
189
|
+
"url": url,
|
|
190
|
+
"snippet": snippet,
|
|
191
|
+
"score": round(score, 3),
|
|
192
|
+
"source": engine_name,
|
|
193
|
+
})
|
|
194
|
+
except Exception:
|
|
195
|
+
continue
|
|
196
|
+
return results
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ── XML / RSS 解析 ─────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
def _parse_xml(engine_name: str, text: str, maps: dict[str, Any],
|
|
202
|
+
is_rss: bool = False) -> list[dict[str, Any]]:
|
|
203
|
+
if is_rss:
|
|
204
|
+
mapping = maps.get("rss", {}).get("default", {})
|
|
205
|
+
else:
|
|
206
|
+
mapping = maps.get("xml", {}).get(engine_name, {})
|
|
207
|
+
|
|
208
|
+
entry_path = mapping.get("entry_path") or mapping.get("item_path", ".//item")
|
|
209
|
+
title_tag = mapping.get("title", "title")
|
|
210
|
+
url_tag = mapping.get("url", "link")
|
|
211
|
+
snippet_tag = mapping.get("snippet", "description")
|
|
212
|
+
namespaces = mapping.get("namespaces", {})
|
|
213
|
+
|
|
214
|
+
results: list[dict[str, Any]] = []
|
|
215
|
+
try:
|
|
216
|
+
root = ET.fromstring(text)
|
|
217
|
+
except ET.ParseError:
|
|
218
|
+
return results
|
|
219
|
+
|
|
220
|
+
if entry_path.startswith(".//{"):
|
|
221
|
+
entries = root.findall(entry_path, namespaces)
|
|
222
|
+
else:
|
|
223
|
+
entries = root.findall(entry_path)
|
|
224
|
+
|
|
225
|
+
for idx, entry in enumerate(entries):
|
|
226
|
+
try:
|
|
227
|
+
title = url = snippet = ""
|
|
228
|
+
if title_tag.startswith("atom:"):
|
|
229
|
+
tag = title_tag.split(":")[1]
|
|
230
|
+
ns = namespaces.get("atom")
|
|
231
|
+
node = entry.find(f"{{{ns}}}{tag}") if ns else None
|
|
232
|
+
title = (node.text or "").strip() if node is not None else ""
|
|
233
|
+
else:
|
|
234
|
+
title = (entry.findtext(title_tag, default="")).strip()
|
|
235
|
+
|
|
236
|
+
if url_tag.startswith("atom:"):
|
|
237
|
+
tag = url_tag.split(":")[1]
|
|
238
|
+
ns = namespaces.get("atom")
|
|
239
|
+
node = entry.find(f"{{{ns}}}{tag}") if ns else None
|
|
240
|
+
url = (node.text or "").strip() if node is not None else ""
|
|
241
|
+
else:
|
|
242
|
+
url = (entry.findtext(url_tag, default="")).strip()
|
|
243
|
+
|
|
244
|
+
if snippet_tag.startswith("atom:"):
|
|
245
|
+
tag = snippet_tag.split(":")[1]
|
|
246
|
+
ns = namespaces.get("atom")
|
|
247
|
+
node = entry.find(f"{{{ns}}}{tag}") if ns else None
|
|
248
|
+
snippet = (node.text or "").strip() if node is not None else ""
|
|
249
|
+
else:
|
|
250
|
+
snippet = (entry.findtext(snippet_tag, default="")).strip()
|
|
251
|
+
|
|
252
|
+
title = re.sub(r"\s+", " ", title)[:200]
|
|
253
|
+
snippet = re.sub(r"\s+", " ", snippet)[:300]
|
|
254
|
+
score = max(0.7 - idx * 0.05, 0.1)
|
|
255
|
+
results.append({
|
|
256
|
+
"title": title,
|
|
257
|
+
"url": url,
|
|
258
|
+
"snippet": snippet,
|
|
259
|
+
"score": round(score, 3),
|
|
260
|
+
"source": engine_name,
|
|
261
|
+
})
|
|
262
|
+
except Exception:
|
|
263
|
+
continue
|
|
264
|
+
return results
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ── JSON 解析 ──────────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
def _get_path(data: Any, path: str) -> Any:
|
|
270
|
+
if path == ".":
|
|
271
|
+
return data
|
|
272
|
+
obj = data
|
|
273
|
+
for part in path.split("."):
|
|
274
|
+
if isinstance(obj, dict):
|
|
275
|
+
obj = obj.get(part)
|
|
276
|
+
else:
|
|
277
|
+
return None
|
|
278
|
+
return obj
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _format_url(template: str | None, item: dict[str, Any], default: str = "") -> str:
|
|
282
|
+
if not template:
|
|
283
|
+
return default
|
|
284
|
+
try:
|
|
285
|
+
return template.format(**item)
|
|
286
|
+
except (KeyError, IndexError):
|
|
287
|
+
return default
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _parse_json(engine_name: str, text: str, maps: dict[str, Any]) -> list[dict[str, Any]]:
|
|
291
|
+
mapping = maps.get("json", {}).get(engine_name, {})
|
|
292
|
+
items_path = mapping.get("items", ".")
|
|
293
|
+
title_key = mapping.get("title")
|
|
294
|
+
url_key = mapping.get("url")
|
|
295
|
+
snippet_key = mapping.get("snippet")
|
|
296
|
+
url_template = mapping.get("url_template")
|
|
297
|
+
|
|
298
|
+
results: list[dict[str, Any]] = []
|
|
299
|
+
try:
|
|
300
|
+
data = json.loads(text)
|
|
301
|
+
except json.JSONDecodeError:
|
|
302
|
+
return results
|
|
303
|
+
|
|
304
|
+
items = _get_path(data, items_path)
|
|
305
|
+
if not isinstance(items, list):
|
|
306
|
+
return results
|
|
307
|
+
|
|
308
|
+
for idx, item in enumerate(items):
|
|
309
|
+
if not isinstance(item, dict):
|
|
310
|
+
if engine_name == "local_pubmed" and isinstance(item, str):
|
|
311
|
+
item = {"pmid": item}
|
|
312
|
+
else:
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
title = url = snippet = ""
|
|
316
|
+
if title_key:
|
|
317
|
+
raw = _get_path(item, title_key)
|
|
318
|
+
if isinstance(raw, list):
|
|
319
|
+
raw = raw[0] if raw else ""
|
|
320
|
+
title = str(raw or "")[:200]
|
|
321
|
+
if url_key:
|
|
322
|
+
raw = _get_path(item, url_key)
|
|
323
|
+
url = str(raw or "")[:500]
|
|
324
|
+
elif url_template:
|
|
325
|
+
url = _format_url(url_template, item)
|
|
326
|
+
if snippet_key:
|
|
327
|
+
raw = _get_path(item, snippet_key)
|
|
328
|
+
snippet = str(raw or "")[:300]
|
|
329
|
+
|
|
330
|
+
title = re.sub(r"<[^>]+>", " ", title)
|
|
331
|
+
title = re.sub(r"\s+", " ", title).strip()
|
|
332
|
+
snippet = re.sub(r"<[^>]+>", " ", snippet)
|
|
333
|
+
snippet = re.sub(r"\s+", " ", snippet).strip()
|
|
334
|
+
|
|
335
|
+
score = max(0.7 - idx * 0.05, 0.1)
|
|
336
|
+
results.append({
|
|
337
|
+
"title": title,
|
|
338
|
+
"url": url,
|
|
339
|
+
"snippet": snippet,
|
|
340
|
+
"score": round(score, 3),
|
|
341
|
+
"source": engine_name,
|
|
342
|
+
})
|
|
343
|
+
return results
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ── 单个引擎执行 ─────────────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
def _search_one(engine_name: str, query: str, n: int = 5,
|
|
349
|
+
timeout: float | None = None) -> tuple[list[dict[str, Any]], str]:
|
|
350
|
+
cfg = _load_config()
|
|
351
|
+
maps = _load_parse_maps()
|
|
352
|
+
settings = cfg.get("settings", {})
|
|
353
|
+
engines = cfg.get("engines", {})
|
|
354
|
+
spec = engines.get(engine_name, {})
|
|
355
|
+
if not spec:
|
|
356
|
+
return [], f"未找到引擎配置: {engine_name}"
|
|
357
|
+
if not spec.get("enabled", True):
|
|
358
|
+
return [], f"引擎已禁用: {engine_name}"
|
|
359
|
+
|
|
360
|
+
to = timeout or spec.get("timeout") or settings.get("default_timeout", 8)
|
|
361
|
+
user_agent = settings.get("user_agent", "")
|
|
362
|
+
fmt = spec.get("format", "html")
|
|
363
|
+
method = spec.get("method", "GET")
|
|
364
|
+
headers = spec.get("headers", {})
|
|
365
|
+
|
|
366
|
+
url = _build_url(spec, query, n)
|
|
367
|
+
spec["_base"] = spec.get("url", "")
|
|
368
|
+
|
|
369
|
+
t0 = time.time()
|
|
370
|
+
try:
|
|
371
|
+
text = _fetch(url, method=method, headers=headers, timeout=to, user_agent=user_agent)
|
|
372
|
+
except Exception as e:
|
|
373
|
+
return [], f"{engine_name} 请求异常: {e}"
|
|
374
|
+
elapsed = round((time.time() - t0) * 1000, 2)
|
|
375
|
+
|
|
376
|
+
if not text:
|
|
377
|
+
return [], f"{engine_name} 返回空内容"
|
|
378
|
+
|
|
379
|
+
if fmt == "html":
|
|
380
|
+
results = _parse_html(engine_name, text, spec, maps)
|
|
381
|
+
elif fmt == "xml":
|
|
382
|
+
results = _parse_xml(engine_name, text, maps, is_rss=False)
|
|
383
|
+
elif fmt == "rss":
|
|
384
|
+
results = _parse_xml(engine_name, text, maps, is_rss=True)
|
|
385
|
+
elif fmt == "json":
|
|
386
|
+
results = _parse_json(engine_name, text, maps)
|
|
387
|
+
else:
|
|
388
|
+
results = []
|
|
389
|
+
|
|
390
|
+
for r in results:
|
|
391
|
+
r["_engine"] = engine_name
|
|
392
|
+
r["_elapsed"] = elapsed
|
|
393
|
+
return results[:n], ""
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# ── 缓存 key ───────────────────────────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
def _cache_key(engines: list[str]) -> str:
|
|
399
|
+
return "local_search+" + "+".join(sorted(engines)) if engines else "local_search"
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _cache_domain(domain: str | None) -> str:
|
|
403
|
+
return domain or "local_general"
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ── 批量执行 ─────────────────────────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
def search_engines(
|
|
409
|
+
query: str,
|
|
410
|
+
engines: list[str] | None = None,
|
|
411
|
+
n: int = 5,
|
|
412
|
+
timeout: float | None = None,
|
|
413
|
+
max_parallel: int = 5,
|
|
414
|
+
skip_cache: bool = False,
|
|
415
|
+
registry: EngineRegistry | None = None,
|
|
416
|
+
mode: str = "fast",
|
|
417
|
+
) -> dict[str, Any]:
|
|
418
|
+
"""local-search 主入口:批量调用本地引擎,返回 unified-search schema。"""
|
|
419
|
+
reg = registry or get_registry()
|
|
420
|
+
cfg = _load_config()
|
|
421
|
+
settings = cfg.get("settings", {})
|
|
422
|
+
max_parallel = max_parallel or settings.get("max_parallel_engines", 5)
|
|
423
|
+
|
|
424
|
+
# 自动路由
|
|
425
|
+
if not engines:
|
|
426
|
+
decision = route_query(query, registry=reg, max_engines=3, require_available=False)
|
|
427
|
+
engines = decision["engines"]
|
|
428
|
+
domain = decision.get("domain")
|
|
429
|
+
else:
|
|
430
|
+
domain = None
|
|
431
|
+
|
|
432
|
+
# 健康过滤(fast/budget 模式下更严格,只检查实际要用的引擎)
|
|
433
|
+
if mode in ("fast", "budget"):
|
|
434
|
+
try:
|
|
435
|
+
available = set(get_available_engines(registry=reg, engine_names=engines))
|
|
436
|
+
engines = [e for e in engines if e in available]
|
|
437
|
+
except Exception as e:
|
|
438
|
+
logger.warning(f"可用性检查失败: {e}")
|
|
439
|
+
|
|
440
|
+
if not engines:
|
|
441
|
+
# 全部不可用,回退到启用的引擎
|
|
442
|
+
engines = reg.list_engines(enabled_only=True)[:3]
|
|
443
|
+
|
|
444
|
+
# 缓存读取
|
|
445
|
+
cache = SearchCache() if SearchCache is not None else None
|
|
446
|
+
cache_key = _cache_key(engines)
|
|
447
|
+
cache_domain = _cache_domain(domain)
|
|
448
|
+
if not skip_cache and cache is not None:
|
|
449
|
+
hit = cache.get(query, cache_key, n, domain=cache_domain)
|
|
450
|
+
if hit:
|
|
451
|
+
return {
|
|
452
|
+
"query": query,
|
|
453
|
+
"engine": engines[0] if engines else "local_search",
|
|
454
|
+
"engines": engines,
|
|
455
|
+
"engines_combo": engines,
|
|
456
|
+
"cached": True,
|
|
457
|
+
"cache_level": hit.get("_cache_level", "L?"),
|
|
458
|
+
"domain": cache_domain,
|
|
459
|
+
"elapsed_ms": 0,
|
|
460
|
+
"tfidf_scores": [],
|
|
461
|
+
"results": hit.get("results", []),
|
|
462
|
+
"count": len(hit.get("results", [])),
|
|
463
|
+
"engines_used": engines,
|
|
464
|
+
"errors": [],
|
|
465
|
+
"mode": mode,
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
t0_all = time.time()
|
|
469
|
+
all_results: list[dict[str, Any]] = []
|
|
470
|
+
engines_used: list[str] = []
|
|
471
|
+
errors: list[str] = []
|
|
472
|
+
|
|
473
|
+
def _task(name: str) -> tuple[str, list[dict[str, Any]], str]:
|
|
474
|
+
res, err = _search_one(name, query, n=n, timeout=timeout)
|
|
475
|
+
return name, res, err
|
|
476
|
+
|
|
477
|
+
with ThreadPoolExecutor(max_workers=min(len(engines), max_parallel)) as ex:
|
|
478
|
+
futures = {ex.submit(_task, name): name for name in engines}
|
|
479
|
+
for fut in as_completed(futures, timeout=timeout or 30):
|
|
480
|
+
name = futures[fut]
|
|
481
|
+
try:
|
|
482
|
+
_, res, err = fut.result()
|
|
483
|
+
if res:
|
|
484
|
+
all_results.extend(res)
|
|
485
|
+
engines_used.append(name)
|
|
486
|
+
if err:
|
|
487
|
+
errors.append(err)
|
|
488
|
+
except Exception as e:
|
|
489
|
+
errors.append(f"{name}: {e}")
|
|
490
|
+
|
|
491
|
+
all_results.sort(key=lambda x: x.get("score", 0), reverse=True)
|
|
492
|
+
elapsed = int((time.time() - t0_all) * 1000)
|
|
493
|
+
final_results = all_results[: n * len(engines)] if engines else all_results[:n]
|
|
494
|
+
|
|
495
|
+
payload = {
|
|
496
|
+
"results": final_results,
|
|
497
|
+
"engines_used": engines_used,
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
# 写缓存
|
|
501
|
+
if not skip_cache and cache is not None:
|
|
502
|
+
cache.set(query, cache_key, n, payload, domain=cache_domain)
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
"query": query,
|
|
506
|
+
"engine": engines[0] if engines else "local_search",
|
|
507
|
+
"engines": engines,
|
|
508
|
+
"engines_combo": engines,
|
|
509
|
+
"cached": False,
|
|
510
|
+
"cache_level": None,
|
|
511
|
+
"domain": cache_domain,
|
|
512
|
+
"elapsed_ms": elapsed,
|
|
513
|
+
"tfidf_scores": [],
|
|
514
|
+
"results": final_results,
|
|
515
|
+
"count": len(final_results),
|
|
516
|
+
"engines_used": engines_used,
|
|
517
|
+
"errors": errors,
|
|
518
|
+
"mode": mode,
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# ── CLI ─────────────────────────────────────────────────────────────────────────
|
|
523
|
+
|
|
524
|
+
def _parse_engine_list(value: str) -> list[str]:
|
|
525
|
+
return [x.strip() for x in value.replace(",", ",").split(",") if x.strip()]
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def main():
|
|
529
|
+
parser = argparse.ArgumentParser(description="local-search v3 子技能入口")
|
|
530
|
+
parser.add_argument("query", nargs="?", help="搜索关键词")
|
|
531
|
+
parser.add_argument("--engine", "-e", default="", help="引擎名,多个用逗号分隔")
|
|
532
|
+
parser.add_argument("--n", type=int, default=5, help="每引擎结果数")
|
|
533
|
+
parser.add_argument("--timeout", "-t", type=float, default=None, help="超时秒数")
|
|
534
|
+
parser.add_argument("--max-parallel", type=int, default=5)
|
|
535
|
+
parser.add_argument("--no-cache", action="store_true", help="跳过缓存")
|
|
536
|
+
parser.add_argument("--mode", default="fast", choices=["fast", "auto", "deep", "budget"],
|
|
537
|
+
help="unified-search 模式透传")
|
|
538
|
+
parser.add_argument("--json", action="store_true", help="输出 JSON")
|
|
539
|
+
args = parser.parse_args()
|
|
540
|
+
|
|
541
|
+
if not args.query:
|
|
542
|
+
parser.error("必须提供搜索关键词")
|
|
543
|
+
|
|
544
|
+
engines = _parse_engine_list(args.engine) if args.engine else None
|
|
545
|
+
result = search_engines(
|
|
546
|
+
args.query,
|
|
547
|
+
engines=engines,
|
|
548
|
+
n=args.n,
|
|
549
|
+
timeout=args.timeout,
|
|
550
|
+
max_parallel=args.max_parallel,
|
|
551
|
+
skip_cache=args.no_cache,
|
|
552
|
+
mode=args.mode,
|
|
553
|
+
)
|
|
554
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
if __name__ == "__main__":
|
|
558
|
+
main()
|