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.
@@ -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()