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,328 @@
1
+ # local-search 子技能专属配置
2
+ # 与 unified-search/config.yaml 解耦,local_search_adapter.py / search_v3.py 直接读取本文件。
3
+
4
+ settings:
5
+ default_timeout: 8
6
+ default_results: 5
7
+ max_parallel_engines: 5
8
+ user_agent: "Mozilla/5.0 (compatible; unified-search-local/1.0.1; +https://local)"
9
+ request_interval_ms: 500
10
+ health_check:
11
+ canary_query: "test"
12
+ ttl_minutes: 5
13
+ interval_minutes: 5
14
+ unavailable_threshold: 2
15
+ timeout: 8
16
+ max_parallel: 5
17
+
18
+ engines:
19
+ local_bing:
20
+ type: html
21
+ enabled: true
22
+ categories: [web_general]
23
+ url: "https://www.bing.com/search"
24
+ query_param: q
25
+ method: GET
26
+ timeout: 8
27
+ format: html
28
+ extra_params:
29
+ setlang: zh-Hans
30
+
31
+ local_google:
32
+ type: html
33
+ enabled: false
34
+ categories: [web_general]
35
+ url: "https://www.google.com/search"
36
+ query_param: q
37
+ method: GET
38
+ timeout: 8
39
+ format: html
40
+ extra_params:
41
+ hl: zh-CN
42
+
43
+ local_mojeek:
44
+ type: html
45
+ enabled: true
46
+ categories: [web_general]
47
+ url: "https://www.mojeek.com/search"
48
+ query_param: q
49
+ method: GET
50
+ timeout: 10
51
+ format: html
52
+
53
+ local_yandex:
54
+ type: html
55
+ enabled: false
56
+ categories: [web_general]
57
+ url: "https://yandex.com/search/"
58
+ query_param: text
59
+ method: GET
60
+ timeout: 10
61
+ format: html
62
+
63
+ local_startpage:
64
+ type: html
65
+ enabled: true
66
+ categories: [web_general]
67
+ url: "https://www.startpage.com/do/dsearch"
68
+ query_param: query
69
+ method: GET
70
+ timeout: 10
71
+ format: html
72
+
73
+ local_duckduckgo:
74
+ type: html
75
+ enabled: true
76
+ categories: [web_general]
77
+ url: "https://html.duckduckgo.com/html/"
78
+ query_param: q
79
+ method: GET
80
+ timeout: 8
81
+ format: html
82
+
83
+ local_baidu:
84
+ type: html
85
+ enabled: true
86
+ categories: [chinese]
87
+ url: "https://www.baidu.com/s"
88
+ query_param: wd
89
+ method: GET
90
+ timeout: 8
91
+ format: html
92
+ extra_params:
93
+ ie: utf-8
94
+
95
+ local_sogou:
96
+ type: html
97
+ enabled: true
98
+ categories: [chinese]
99
+ url: "https://www.sogou.com/web"
100
+ query_param: query
101
+ method: GET
102
+ timeout: 8
103
+ format: html
104
+
105
+ local_arxiv:
106
+ type: xml
107
+ enabled: true
108
+ categories: [academic]
109
+ url: "http://export.arxiv.org/api/query"
110
+ query_param: search_query
111
+ method: GET
112
+ timeout: 15
113
+ format: xml
114
+ extra_params:
115
+ start: 0
116
+ max_results: "{n}"
117
+
118
+ local_pubmed:
119
+ type: json
120
+ enabled: true
121
+ categories: [academic]
122
+ url: "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
123
+ query_param: term
124
+ method: GET
125
+ timeout: 12
126
+ format: json
127
+ extra_params:
128
+ db: pubmed
129
+ retmode: json
130
+ retmax: "{n}"
131
+
132
+ local_crossref:
133
+ type: json
134
+ enabled: true
135
+ categories: [academic]
136
+ url: "https://api.crossref.org/works"
137
+ query_param: query
138
+ method: GET
139
+ timeout: 12
140
+ format: json
141
+ extra_params:
142
+ rows: "{n}"
143
+ mailto: unified-search@local
144
+
145
+ local_semantic_scholar:
146
+ type: json
147
+ enabled: true
148
+ categories: [academic]
149
+ url: "https://api.semanticscholar.org/graph/v1/paper/search"
150
+ query_param: query
151
+ method: GET
152
+ timeout: 12
153
+ format: json
154
+ extra_params:
155
+ limit: "{n}"
156
+ fields: title,abstract,url
157
+
158
+ local_bing_news:
159
+ type: rss
160
+ enabled: true
161
+ categories: [news]
162
+ url: "https://www.bing.com/news/search"
163
+ query_param: q
164
+ method: GET
165
+ timeout: 8
166
+ format: rss
167
+ extra_params:
168
+ format: rss
169
+
170
+ local_google_news:
171
+ type: rss
172
+ enabled: true
173
+ categories: [news]
174
+ url: "https://news.google.com/rss/search"
175
+ query_param: q
176
+ method: GET
177
+ timeout: 8
178
+ format: rss
179
+
180
+ local_duckduckgo_news:
181
+ type: rss
182
+ enabled: false
183
+ categories: [news]
184
+ url: "https://duckduckgo.com/html/"
185
+ query_param: q
186
+ method: GET
187
+ timeout: 8
188
+ format: html
189
+
190
+ local_github:
191
+ type: json
192
+ enabled: true
193
+ categories: [code]
194
+ url: "https://api.github.com/search/repositories"
195
+ query_param: q
196
+ method: GET
197
+ timeout: 10
198
+ format: json
199
+ extra_params:
200
+ per_page: "{n}"
201
+ headers:
202
+ Accept: application/vnd.github.v3+json
203
+ User-Agent: unified-search-local/1.0.1
204
+
205
+ local_stackoverflow:
206
+ type: json
207
+ enabled: true
208
+ categories: [code]
209
+ url: "https://api.stackexchange.com/2.3/search/advanced"
210
+ query_param: q
211
+ method: GET
212
+ timeout: 10
213
+ format: json
214
+ extra_params:
215
+ site: stackoverflow
216
+ pagesize: "{n}"
217
+ order: desc
218
+ sort: relevance
219
+
220
+ local_gitlab:
221
+ type: json
222
+ enabled: true
223
+ categories: [code]
224
+ url: "https://gitlab.com/api/v4/search"
225
+ query_param: search
226
+ method: GET
227
+ timeout: 10
228
+ format: json
229
+ extra_params:
230
+ per_page: "{n}"
231
+ scope: projects
232
+
233
+ local_npm:
234
+ type: json
235
+ enabled: true
236
+ categories: [code]
237
+ url: "https://registry.npmjs.org/-/v1/search"
238
+ query_param: text
239
+ method: GET
240
+ timeout: 8
241
+ format: json
242
+ extra_params:
243
+ size: "{n}"
244
+
245
+ local_wikipedia:
246
+ type: json
247
+ enabled: true
248
+ categories: [reference]
249
+ url: "https://en.wikipedia.org/w/api.php"
250
+ query_param: srsearch
251
+ method: GET
252
+ timeout: 8
253
+ format: json
254
+ extra_params:
255
+ action: query
256
+ list: search
257
+ format: json
258
+ srlimit: "{n}"
259
+
260
+ local_wiktionary:
261
+ type: json
262
+ enabled: true
263
+ categories: [reference]
264
+ url: "https://en.wiktionary.org/w/api.php"
265
+ query_param: srsearch
266
+ method: GET
267
+ timeout: 8
268
+ format: json
269
+ extra_params:
270
+ action: query
271
+ list: search
272
+ format: json
273
+ srlimit: "{n}"
274
+
275
+ local_wikiquote:
276
+ type: json
277
+ enabled: true
278
+ categories: [reference]
279
+ url: "https://en.wikiquote.org/w/api.php"
280
+ query_param: srsearch
281
+ method: GET
282
+ timeout: 8
283
+ format: json
284
+ extra_params:
285
+ action: query
286
+ list: search
287
+ format: json
288
+ srlimit: "{n}"
289
+
290
+ local_imdb:
291
+ type: html
292
+ enabled: false
293
+ categories: [vertical]
294
+ url: "https://www.imdb.com/find/"
295
+ query_param: q
296
+ method: GET
297
+ timeout: 10
298
+ format: html
299
+ extra_params:
300
+ ref_: nv_sr_sm
301
+
302
+ local_goodreads:
303
+ type: html
304
+ enabled: false
305
+ categories: [vertical]
306
+ url: "https://www.goodreads.com/search"
307
+ query_param: q
308
+ method: GET
309
+ timeout: 10
310
+ format: html
311
+ extra_params:
312
+ search_type: books
313
+
314
+ local_openstreetmap:
315
+ type: json
316
+ enabled: true
317
+ categories: [vertical]
318
+ url: "https://nominatim.openstreetmap.org/search"
319
+ query_param: q
320
+ method: GET
321
+ timeout: 10
322
+ format: json
323
+ extra_params:
324
+ format: json
325
+ limit: "{n}"
326
+ addressdetails: 1
327
+ headers:
328
+ User-Agent: unified-search-local/1.0.1
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env python3
2
+ """engine_registry.py — local-search 引擎注册中心(唯一真源)
3
+
4
+ 读取 sub-skills/local-search/config.yaml 与 parse_maps.yaml,维护 24+ 本地引擎的
5
+ 元数据、分类与可用状态。新增引擎只需改 YAML,无需改代码。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import functools
11
+ import json
12
+ import logging
13
+ import os
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ try:
19
+ import yaml # type: ignore
20
+ except ImportError:
21
+ yaml = None # type: ignore
22
+
23
+ logger = logging.getLogger("local_search.engine_registry")
24
+ if not logger.handlers:
25
+ logger.setLevel(logging.WARNING)
26
+ logger.addHandler(logging.StreamHandler())
27
+
28
+ SKILL_DIR = Path(__file__).resolve().parent
29
+ CONFIG_PATH = SKILL_DIR / "config.yaml"
30
+ PARSE_MAPS_PATH = SKILL_DIR / "parse_maps.yaml"
31
+
32
+ # 领域分类(与 config.yaml 中 engines[*].category 对应)
33
+ DEFAULT_CATEGORIES = [
34
+ "web_general",
35
+ "chinese",
36
+ "academic",
37
+ "news",
38
+ "code",
39
+ "reference",
40
+ "vertical",
41
+ ]
42
+
43
+ # 反爬/拦截标记(用于 health_check 快速判定)
44
+ ANTI_BOT_MARKERS = [
45
+ "captcha", "recaptcha", "robot", "robots", "cloudflare", "challenge",
46
+ "blocked", "verification", "please verify", "access denied",
47
+ "too many requests", "rate limit",
48
+ ]
49
+
50
+
51
+ def _load_yaml(path: Path) -> dict[str, Any]:
52
+ if yaml is None:
53
+ raise ImportError("缺少 PyYAML,请安装:pip install pyyaml")
54
+ try:
55
+ with path.open("r", encoding="utf-8") as f:
56
+ data = yaml.safe_load(f)
57
+ return data if isinstance(data, dict) else {}
58
+ except Exception as e:
59
+ logger.warning(f"加载 YAML 失败 {path}: {e}")
60
+ return {}
61
+
62
+
63
+ def _default_health_state_path() -> Path:
64
+ base = Path(os.path.expanduser("~/.cache/unified-search"))
65
+ base.mkdir(parents=True, exist_ok=True)
66
+ return base / "local_search_health.json"
67
+
68
+
69
+ class EngineRegistry:
70
+ """本地搜索引擎注册表。"""
71
+
72
+ def __init__(
73
+ self,
74
+ config_path: Path | str | None = None,
75
+ parse_maps_path: Path | str | None = None,
76
+ health_state_path: Path | str | None = None,
77
+ ):
78
+ self.config_path = Path(config_path) if config_path else CONFIG_PATH
79
+ self.parse_maps_path = Path(parse_maps_path) if parse_maps_path else PARSE_MAPS_PATH
80
+ self.health_state_path = Path(health_state_path) if health_state_path else _default_health_state_path()
81
+ self._config_mtime: float = 0.0
82
+ self._parse_mtime: float = 0.0
83
+ self._config: dict[str, Any] = {}
84
+ self._parse_maps: dict[str, Any] = {}
85
+ self._health: dict[str, Any] = {}
86
+ self._load()
87
+
88
+ def _load(self) -> None:
89
+ """加载配置、解析映射和健康状态缓存。"""
90
+ cfg = _load_yaml(self.config_path)
91
+ maps = _load_yaml(self.parse_maps_path)
92
+ self._config = cfg
93
+ self._parse_maps = maps
94
+ try:
95
+ self._config_mtime = self.config_path.stat().st_mtime
96
+ self._parse_mtime = self.parse_maps_path.stat().st_mtime
97
+ except OSError:
98
+ pass
99
+ self._health = self._load_health()
100
+
101
+ def _load_health(self) -> dict[str, Any]:
102
+ if not self.health_state_path.exists():
103
+ return {}
104
+ try:
105
+ with self.health_state_path.open("r", encoding="utf-8") as f:
106
+ data = json.load(f)
107
+ if isinstance(data, dict):
108
+ return data
109
+ except Exception as e:
110
+ logger.warning(f"加载健康状态失败: {e}")
111
+ return {}
112
+
113
+ def _save_health(self) -> None:
114
+ try:
115
+ self.health_state_path.parent.mkdir(parents=True, exist_ok=True)
116
+ with self.health_state_path.open("w", encoding="utf-8") as f:
117
+ json.dump(self._health, f, ensure_ascii=False, indent=2)
118
+ except Exception as e:
119
+ logger.warning(f"保存健康状态失败: {e}")
120
+
121
+ def reload_if_changed(self) -> bool:
122
+ """如果 YAML 文件发生变更则热重载,返回是否重载。"""
123
+ try:
124
+ cfg_mtime = self.config_path.stat().st_mtime
125
+ parse_mtime = self.parse_maps_path.stat().st_mtime
126
+ except OSError:
127
+ return False
128
+ if cfg_mtime != self._config_mtime or parse_mtime != self._parse_mtime:
129
+ self._load()
130
+ return True
131
+ return False
132
+
133
+ def force_reload(self) -> None:
134
+ """强制重载所有配置。"""
135
+ self._load()
136
+
137
+ # ── 配置访问 ───────────────────────────────────────────────────────────────
138
+
139
+ @property
140
+ def settings(self) -> dict[str, Any]:
141
+ return self._config.get("settings", {})
142
+
143
+ @property
144
+ def engines(self) -> dict[str, dict[str, Any]]:
145
+ return self._config.get("engines", {})
146
+
147
+ @property
148
+ def parse_maps(self) -> dict[str, Any]:
149
+ return self._parse_maps
150
+
151
+ def get_engine(self, name: str) -> dict[str, Any] | None:
152
+ """获取指定引擎的完整配置(含运行时可用状态)。"""
153
+ spec = self.engines.get(name)
154
+ if spec is None:
155
+ return None
156
+ merged = dict(spec)
157
+ merged["name"] = name
158
+ health = self._health.get(name, {})
159
+ merged["available"] = self._is_available(name, spec, health)
160
+ merged["last_checked"] = health.get("last_checked")
161
+ merged["consecutive_failures"] = health.get("consecutive_failures", 0)
162
+ return merged
163
+
164
+ def _is_available(self, name: str, spec: dict[str, Any], health: dict[str, Any]) -> bool:
165
+ """综合 enabled 与健康状态判定可用性。"""
166
+ if not spec.get("enabled", True):
167
+ return False
168
+ # 没有健康记录时默认可用(由 health_check 负责探针)
169
+ if not health:
170
+ return True
171
+ return bool(health.get("available", True))
172
+
173
+ def list_engines(
174
+ self,
175
+ category: str | None = None,
176
+ available_only: bool = False,
177
+ enabled_only: bool = False,
178
+ ) -> list[str]:
179
+ """列出引擎名,支持按分类、可用性、启用状态过滤。"""
180
+ names: list[str] = []
181
+ for name, spec in self.engines.items():
182
+ if enabled_only and not spec.get("enabled", True):
183
+ continue
184
+ cats = spec.get("categories", [])
185
+ if isinstance(cats, str):
186
+ cats = [cats]
187
+ if category and category not in cats:
188
+ continue
189
+ if available_only:
190
+ eng = self.get_engine(name)
191
+ if not eng or not eng.get("available", True):
192
+ continue
193
+ names.append(name)
194
+ return names
195
+
196
+ def list_categories(self) -> list[str]:
197
+ """返回实际出现的分类(保留顺序)。"""
198
+ seen: set[str] = set()
199
+ cats: list[str] = []
200
+ for spec in self.engines.values():
201
+ for c in spec.get("categories", []):
202
+ if isinstance(c, str) and c not in seen:
203
+ seen.add(c)
204
+ cats.append(c)
205
+ # 确保默认分类存在
206
+ for c in DEFAULT_CATEGORIES:
207
+ if c not in seen:
208
+ cats.append(c)
209
+ return cats
210
+
211
+ # ── 可用性管理 ───────────────────────────────────────────────────────────────
212
+
213
+ def get_health(self, name: str) -> dict[str, Any]:
214
+ return dict(self._health.get(name, {}))
215
+
216
+ def update_availability(self, name: str, available: bool, **extra: Any) -> None:
217
+ """更新单个引擎的可用状态,并持久化。"""
218
+ now = time.time()
219
+ record = self._health.setdefault(name, {})
220
+ record["last_checked"] = now
221
+ record["available"] = available
222
+ if available:
223
+ record["consecutive_failures"] = 0
224
+ record["last_ok"] = now
225
+ record["fail_reason"] = None
226
+ else:
227
+ record["consecutive_failures"] = record.get("consecutive_failures", 0) + 1
228
+ if extra.get("fail_reason"):
229
+ record["fail_reason"] = extra["fail_reason"]
230
+ for k, v in extra.items():
231
+ if k not in ("last_checked", "available", "consecutive_failures"):
232
+ record[k] = v
233
+ self._save_health()
234
+
235
+ def bulk_update_availability(self, updates: dict[str, dict[str, Any]]) -> None:
236
+ """批量更新可用状态。"""
237
+ for name, data in updates.items():
238
+ self.update_availability(name, data.get("available", False), **data)
239
+
240
+ def is_available(self, name: str) -> bool:
241
+ eng = self.get_engine(name)
242
+ if eng is not None:
243
+ return bool(eng.get("available", True))
244
+ # 引擎不在配置中,但健康记录存在时直接取记录状态
245
+ health = self._health.get(name, {})
246
+ return bool(health.get("available", True))
247
+
248
+
249
+ @functools.lru_cache(maxsize=1)
250
+ def get_registry() -> EngineRegistry:
251
+ return EngineRegistry()
252
+
253
+
254
+ def get_engine(name: str) -> dict[str, Any] | None:
255
+ return get_registry().get_engine(name)
256
+
257
+
258
+ def list_engines(
259
+ category: str | None = None,
260
+ available_only: bool = False,
261
+ enabled_only: bool = False,
262
+ ) -> list[str]:
263
+ return get_registry().list_engines(category, available_only, enabled_only)
264
+
265
+
266
+ def update_availability(name: str, available: bool, **extra: Any) -> None:
267
+ get_registry().update_availability(name, available, **extra)
268
+
269
+
270
+ def list_categories() -> list[str]:
271
+ return get_registry().list_categories()
272
+
273
+
274
+ if __name__ == "__main__":
275
+ import argparse
276
+
277
+ parser = argparse.ArgumentParser(description="local-search 引擎注册表调试")
278
+ parser.add_argument("--list", action="store_true", help="列出所有引擎")
279
+ parser.add_argument("--category", default=None, help="按分类过滤")
280
+ parser.add_argument("--available", action="store_true", help="仅可用引擎")
281
+ parser.add_argument("--engine", default=None, help="查看单个引擎")
282
+ parser.add_argument("--reload", action="store_true", help="强制重载")
283
+ args = parser.parse_args()
284
+
285
+ reg = get_registry()
286
+ if args.reload:
287
+ reg.force_reload()
288
+
289
+ if args.engine:
290
+ print(json.dumps(reg.get_engine(args.engine), ensure_ascii=False, indent=2))
291
+ elif args.list or args.category or args.available:
292
+ names = reg.list_engines(category=args.category, available_only=args.available)
293
+ print(json.dumps(names, ensure_ascii=False, indent=2))
294
+ else:
295
+ print(json.dumps({
296
+ "categories": reg.list_categories(),
297
+ "engines": {n: reg.get_engine(n) for n in reg.list_engines()},
298
+ }, ensure_ascii=False, indent=2, default=str))