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,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()
@@ -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])