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,262 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
config.py — Unified Search v2 配置加载器
|
|
4
|
+
|
|
5
|
+
职责:
|
|
6
|
+
- 从项目根目录的 config.yaml 加载统一配置
|
|
7
|
+
- 支持热加载(按 mtime 缓存)
|
|
8
|
+
- 使用 PyYAML 解析(缺失时给出明确安装提示)
|
|
9
|
+
- 将 ~ 展开为实际用户目录
|
|
10
|
+
- 提供类型化访问接口(引擎列表、域规则、成本分级、预算配置)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
# ── 路径 ──────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
CONFIG_PATH = Path(__file__).parent.parent / "config.yaml"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── 默认配置 ──────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
28
|
+
"version": 2,
|
|
29
|
+
"engines": {
|
|
30
|
+
"anysearch": {
|
|
31
|
+
"enabled": True, "type": "cli",
|
|
32
|
+
"cmd": ["python3", "~/.agents/skills/anysearch-skill/scripts/anysearch_cli.py"],
|
|
33
|
+
"search_args": ["search", "{query}", "--max_results", "{n}"],
|
|
34
|
+
"env": {},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
"domains": [
|
|
38
|
+
{
|
|
39
|
+
"name": "general_search", "desc": "通用搜索(兜底)",
|
|
40
|
+
"patterns": [], "primary": "anysearch",
|
|
41
|
+
"fallback": "anysearch", "parallel": True,
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
"cache": {"enabled": True, "db_path": "~/.cache/unified-search/cache.db", "ttl": 3600, "max_size_mb": 200},
|
|
45
|
+
"execution": {"default_timeout": 8, "parallel_timeout": 6, "max_parallel_engines": 3, "retry_count": 0},
|
|
46
|
+
"cost_tiers": {"free": ["anysearch"], "low": [], "paid": []},
|
|
47
|
+
"budget": {
|
|
48
|
+
"fast": {"max_cost_per_query": 0.0, "allow_paid": False},
|
|
49
|
+
"auto": {"max_cost_per_query": 0.01, "allow_paid": True},
|
|
50
|
+
"deep": {"max_cost_per_query": 1.0, "allow_paid": True},
|
|
51
|
+
"budget": {"max_cost_per_query": 0.005, "allow_paid": False, "quota_threshold": 0.2},
|
|
52
|
+
},
|
|
53
|
+
"output": {"format": "auto", "include_scores": True, "include_routing_decision": True},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ── YAML 加载 ─────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
def _require_yaml():
|
|
60
|
+
try:
|
|
61
|
+
import yaml # type: ignore
|
|
62
|
+
return yaml
|
|
63
|
+
except ImportError as e:
|
|
64
|
+
raise ImportError("缺少 PyYAML,请安装:pip install pyyaml") from e
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _load_yaml(text: str) -> dict[str, Any]:
|
|
68
|
+
yaml = _require_yaml()
|
|
69
|
+
parsed = yaml.safe_load(text)
|
|
70
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── 配置加载与缓存 ─────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
_config_cache: dict[str, Any] | None = None
|
|
76
|
+
_config_mtime: float = 0.0
|
|
77
|
+
_config_load_error: str | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _expand_value(value: Any) -> Any:
|
|
81
|
+
"""递归展开字符串中的 ~ 为用户目录。"""
|
|
82
|
+
if isinstance(value, str):
|
|
83
|
+
return os.path.expanduser(value)
|
|
84
|
+
if isinstance(value, list):
|
|
85
|
+
return [_expand_value(v) for v in value]
|
|
86
|
+
if isinstance(value, dict):
|
|
87
|
+
return {k: _expand_value(v) for k, v in value.items()}
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _resolve_relative_paths(config: dict[str, Any]) -> dict[str, Any]:
|
|
92
|
+
"""将 cli 引擎 cmd/domain_search 中的相对路径解析为 config.yaml 所在目录的绝对路径。"""
|
|
93
|
+
base = CONFIG_PATH.parent
|
|
94
|
+
for name, spec in config.get("engines", {}).items():
|
|
95
|
+
if not isinstance(spec, dict) or spec.get("type") != "cli":
|
|
96
|
+
continue
|
|
97
|
+
for key in ("cmd", "domain_search"):
|
|
98
|
+
if key not in spec:
|
|
99
|
+
continue
|
|
100
|
+
items = spec[key]
|
|
101
|
+
if not isinstance(items, list):
|
|
102
|
+
continue
|
|
103
|
+
resolved: list[Any] = []
|
|
104
|
+
for item in items:
|
|
105
|
+
if isinstance(item, str) and item and not item.startswith(("/", "~", "http://", "https://")) and not item.startswith("{"):
|
|
106
|
+
candidate = base / item
|
|
107
|
+
if candidate.exists() or ("/" in item or "\\" in item):
|
|
108
|
+
resolved.append(str(candidate.resolve()))
|
|
109
|
+
else:
|
|
110
|
+
resolved.append(item)
|
|
111
|
+
else:
|
|
112
|
+
resolved.append(item)
|
|
113
|
+
spec[key] = resolved
|
|
114
|
+
return config
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _validate_engine_paths(config: dict[str, Any]) -> dict[str, Any]:
|
|
118
|
+
"""验证引擎 CLI 路径,不存在则标记为 disabled。"""
|
|
119
|
+
import logging as _logging
|
|
120
|
+
_log = _logging.getLogger("unified_search.config")
|
|
121
|
+
for name, spec in config.get("engines", {}).items():
|
|
122
|
+
if not isinstance(spec, dict) or spec.get("type") != "cli":
|
|
123
|
+
continue
|
|
124
|
+
cmd = spec.get("cmd", [])
|
|
125
|
+
if not cmd or cmd[0] in ("npx", "node"):
|
|
126
|
+
continue
|
|
127
|
+
cmd_path_str = cmd[-1]
|
|
128
|
+
if cmd_path_str.startswith("--"):
|
|
129
|
+
continue
|
|
130
|
+
cmd_path = Path(cmd_path_str).expanduser()
|
|
131
|
+
if not cmd_path.exists():
|
|
132
|
+
spec["enabled"] = False
|
|
133
|
+
return config
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def load_config(force: bool = False) -> dict[str, Any]:
|
|
137
|
+
"""加载配置,支持热加载。"""
|
|
138
|
+
global _config_cache, _config_mtime, _config_load_error
|
|
139
|
+
try:
|
|
140
|
+
mtime = CONFIG_PATH.stat().st_mtime
|
|
141
|
+
except OSError:
|
|
142
|
+
if _config_cache is None:
|
|
143
|
+
_config_cache = _validate_engine_paths(_expand_value(json.loads(json.dumps(DEFAULT_CONFIG))))
|
|
144
|
+
return _config_cache
|
|
145
|
+
|
|
146
|
+
if not force and _config_cache is not None and mtime == _config_mtime:
|
|
147
|
+
return _config_cache
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
text = CONFIG_PATH.read_text(encoding="utf-8")
|
|
151
|
+
parsed = _load_yaml(text)
|
|
152
|
+
if parsed:
|
|
153
|
+
expanded = _expand_value(parsed)
|
|
154
|
+
resolved = _resolve_relative_paths(expanded)
|
|
155
|
+
_config_cache = _validate_engine_paths(resolved)
|
|
156
|
+
_config_mtime = mtime
|
|
157
|
+
_config_load_error = None
|
|
158
|
+
elif _config_cache is None:
|
|
159
|
+
_config_cache = _validate_engine_paths(_resolve_relative_paths(_expand_value(json.loads(json.dumps(DEFAULT_CONFIG)))))
|
|
160
|
+
except ImportError as e:
|
|
161
|
+
_config_load_error = str(e)
|
|
162
|
+
import sys as _sys
|
|
163
|
+
print(f"[unified-search] PyYAML 未安装,使用内置默认配置。安装:pip install pyyaml。原因:{e}", file=_sys.stderr)
|
|
164
|
+
if _config_cache is None:
|
|
165
|
+
_config_cache = _validate_engine_paths(_resolve_relative_paths(_expand_value(json.loads(json.dumps(DEFAULT_CONFIG)))))
|
|
166
|
+
return _config_cache
|
|
167
|
+
except Exception as e:
|
|
168
|
+
_config_load_error = str(e)
|
|
169
|
+
if _config_cache is None:
|
|
170
|
+
_config_cache = _validate_engine_paths(_resolve_relative_paths(_expand_value(json.loads(json.dumps(DEFAULT_CONFIG)))))
|
|
171
|
+
return _config_cache
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def last_load_error() -> str | None:
|
|
175
|
+
return _config_load_error
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ── 类型化访问接口 ─────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
def get_engines(config: dict[str, Any] | None = None) -> dict[str, dict[str, Any]]:
|
|
181
|
+
"""返回启用的引擎配置字典。"""
|
|
182
|
+
cfg = config if config is not None else load_config()
|
|
183
|
+
engines = cfg.get("engines", {})
|
|
184
|
+
return {name: spec for name, spec in engines.items() if spec.get("enabled", True)}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def get_domains(config: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
188
|
+
"""返回按优先级排序的域规则列表。"""
|
|
189
|
+
cfg = config if config is not None else load_config()
|
|
190
|
+
return cfg.get("domains", [])
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def get_cache_config(config: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
194
|
+
cfg = config if config is not None else load_config()
|
|
195
|
+
return cfg.get("cache", DEFAULT_CONFIG["cache"])
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def get_execution_config(config: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
199
|
+
cfg = config if config is not None else load_config()
|
|
200
|
+
return cfg.get("execution", DEFAULT_CONFIG["execution"])
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def get_output_config(config: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
204
|
+
cfg = config if config is not None else load_config()
|
|
205
|
+
return cfg.get("output", DEFAULT_CONFIG["output"])
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def get_cost_tiers(config: dict[str, Any] | None = None) -> dict[str, list[str]]:
|
|
209
|
+
"""返回成本分级配置。"""
|
|
210
|
+
cfg = config if config is not None else load_config()
|
|
211
|
+
return cfg.get("cost_tiers", {})
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_budget_config(mode: str = "auto") -> dict[str, Any]:
|
|
215
|
+
"""返回预算模式配置。"""
|
|
216
|
+
cfg = load_config()
|
|
217
|
+
budgets = cfg.get("execution", {}).get("budget", DEFAULT_CONFIG["budget"])
|
|
218
|
+
return budgets.get(mode, budgets.get("auto", {}))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def get_cost_factor(engine: str) -> float:
|
|
222
|
+
"""获取引擎的 cost_factor:free=1.0, low=0.7, paid=0.3。"""
|
|
223
|
+
tiers = get_cost_tiers()
|
|
224
|
+
if engine in tiers.get("free", []):
|
|
225
|
+
return 1.0
|
|
226
|
+
if engine in tiers.get("low", []):
|
|
227
|
+
return 0.7
|
|
228
|
+
if engine in tiers.get("paid", []):
|
|
229
|
+
return 0.3
|
|
230
|
+
return 1.0 # 未分级默认为 free
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ── CLI 调试用 ─────────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
def _cli():
|
|
236
|
+
import argparse
|
|
237
|
+
parser = argparse.ArgumentParser(description="Unified Search v2 配置查看器")
|
|
238
|
+
parser.add_argument("--engines", action="store_true", help="显示引擎配置")
|
|
239
|
+
parser.add_argument("--domains", action="store_true", help="显示域规则")
|
|
240
|
+
parser.add_argument("--cost-tiers", action="store_true", help="显示成本分级")
|
|
241
|
+
parser.add_argument("--check", action="store_true", help="检查配置")
|
|
242
|
+
args = parser.parse_args()
|
|
243
|
+
cfg = load_config(force=True)
|
|
244
|
+
if args.engines:
|
|
245
|
+
print(json.dumps(get_engines(cfg), ensure_ascii=False, indent=2))
|
|
246
|
+
elif args.domains:
|
|
247
|
+
print(json.dumps(get_domains(cfg), ensure_ascii=False, indent=2))
|
|
248
|
+
elif args.cost_tiers:
|
|
249
|
+
print(json.dumps(get_cost_tiers(cfg), ensure_ascii=False, indent=2))
|
|
250
|
+
elif args.check:
|
|
251
|
+
err = last_load_error()
|
|
252
|
+
print(json.dumps({
|
|
253
|
+
"path": str(CONFIG_PATH), "ok": err is None, "error": err,
|
|
254
|
+
"engines": list(get_engines(cfg).keys()),
|
|
255
|
+
"domains": [d.get("name") for d in get_domains(cfg)],
|
|
256
|
+
}, ensure_ascii=False, indent=2))
|
|
257
|
+
else:
|
|
258
|
+
print(json.dumps(cfg, ensure_ascii=False, indent=2))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
_cli()
|
package/scripts/crawl.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""crawl.py — 站点级爬取"""
|
|
3
|
+
import json, re, sys, time
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
5
|
+
from urllib.parse import urljoin, urlparse
|
|
6
|
+
from fetch import fetch_page
|
|
7
|
+
|
|
8
|
+
def crawl_sitemap(url, max_pages=20, timeout=10):
|
|
9
|
+
"""从 sitemap.xml 爬取"""
|
|
10
|
+
sitemap_url = urljoin(url, '/sitemap.xml')
|
|
11
|
+
result = fetch_page(sitemap_url, max_chars=50000, timeout=timeout, raw=True)
|
|
12
|
+
if not result['success']:
|
|
13
|
+
# 尝试 robots.txt 指定的 sitemap
|
|
14
|
+
robots_url = urljoin(url, '/robots.txt')
|
|
15
|
+
robots_result = fetch_page(robots_url, max_chars=10000, timeout=timeout, raw=True)
|
|
16
|
+
if robots_result['success'] and 'Sitemap:' in robots_result.get('html', ''):
|
|
17
|
+
import re as _re
|
|
18
|
+
sitemap_urls = _re.findall(r'Sitemap:\s*(.+)', robots_result['html'])
|
|
19
|
+
if sitemap_urls:
|
|
20
|
+
sitemap_url = sitemap_urls[0].strip()
|
|
21
|
+
result = fetch_page(sitemap_url, max_chars=50000, timeout=timeout, raw=True)
|
|
22
|
+
if not result.get('success') or not result.get('html'):
|
|
23
|
+
return {'url': url, 'pages': [], 'total': 0, 'error': 'sitemap not found or empty'}
|
|
24
|
+
html = result.get('html', result.get('content', ''))
|
|
25
|
+
# 同时尝试从 raw content 提取(兼容 XML 被 ContentExtractor 过滤的情况)
|
|
26
|
+
urls = re.findall(r'<loc>\s*(.*?)\s*</loc>', html)
|
|
27
|
+
if not urls:
|
|
28
|
+
urls = re.findall(r'<loc>(.*?)</loc>', result.get('content', ''))
|
|
29
|
+
urls = urls[:max_pages]
|
|
30
|
+
pages = []
|
|
31
|
+
with ThreadPoolExecutor(max_workers=5) as ex:
|
|
32
|
+
futures = {ex.submit(fetch_page, u, 2000, timeout): u for u in urls}
|
|
33
|
+
for fut in as_completed(futures, timeout=timeout*2):
|
|
34
|
+
try:
|
|
35
|
+
r = fut.result()
|
|
36
|
+
if r['success']:
|
|
37
|
+
pages.append({'url': r['url'], 'content': r['content'][:500], 'depth': 0})
|
|
38
|
+
except: pass
|
|
39
|
+
return {'url': url, 'pages': pages, 'total': len(pages), 'elapsed_ms': int((time.time())*1000)}
|
|
40
|
+
|
|
41
|
+
def crawl_bfs(url, max_pages=10, max_depth=2, timeout=8):
|
|
42
|
+
"""BFS 爬取"""
|
|
43
|
+
visited = set()
|
|
44
|
+
pages = []
|
|
45
|
+
queue = [(url, 0)]
|
|
46
|
+
while queue and len(pages) < max_pages:
|
|
47
|
+
current_url, depth = queue.pop(0)
|
|
48
|
+
if current_url in visited or depth > max_depth:
|
|
49
|
+
continue
|
|
50
|
+
visited.add(current_url)
|
|
51
|
+
result = fetch_page(current_url, 2000, timeout)
|
|
52
|
+
if result['success']:
|
|
53
|
+
pages.append({'url': current_url, 'content': result['content'][:500], 'depth': depth})
|
|
54
|
+
links = re.findall(r'href=["\']([^"\'#]+)', result['content'])
|
|
55
|
+
for link in links[:5]:
|
|
56
|
+
full = urljoin(current_url, link)
|
|
57
|
+
if urlparse(full).netloc == urlparse(url).netloc and full not in visited:
|
|
58
|
+
queue.append((full, depth+1))
|
|
59
|
+
return {'url': url, 'pages': pages, 'total': len(pages)}
|
|
60
|
+
|
|
61
|
+
if __name__ == '__main__':
|
|
62
|
+
import argparse
|
|
63
|
+
p = argparse.ArgumentParser()
|
|
64
|
+
p.add_argument('url')
|
|
65
|
+
p.add_argument('--strategy', default='bfs', choices=['sitemap','bfs'])
|
|
66
|
+
p.add_argument('--max-pages', type=int, default=10)
|
|
67
|
+
p.add_argument('--max-depth', type=int, default=2)
|
|
68
|
+
args = p.parse_args()
|
|
69
|
+
if args.strategy == 'sitemap':
|
|
70
|
+
r = crawl_sitemap(args.url, args.max_pages)
|
|
71
|
+
else:
|
|
72
|
+
r = crawl_bfs(args.url, args.max_pages, args.max_depth)
|
|
73
|
+
print(json.dumps(r, ensure_ascii=False, indent=2)[:2000])
|