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,347 @@
1
+ #!/usr/bin/env python3
2
+ """health_check.py — local-search 轻量健康探针
3
+
4
+ 对每个启用引擎发送 canary 查询,检测 HTTP 状态、延迟、解析成功与反爬拦截,
5
+ 将结果持久化到 ~/.cache/unified-search/local_search_health.json。
6
+
7
+ 判定规则(与设计方案一致):
8
+ - 连续 2 次失败 / 单次延迟 > 8s / HTTP 4xx/5xx → 标记 unavailable
9
+ - 成功 1 次即可恢复 available
10
+ - 启动时读取缓存(TTL 5 分钟),避免每次查询都发探针
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import concurrent.futures
16
+ import json
17
+ import logging
18
+ import os
19
+ import re
20
+ import time
21
+ import urllib.error
22
+ import urllib.parse
23
+ import urllib.request
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from engine_registry import EngineRegistry, get_registry, update_availability
28
+
29
+ logger = logging.getLogger("local_search.health_check")
30
+ if not logger.handlers:
31
+ logger.setLevel(logging.WARNING)
32
+ logger.addHandler(logging.StreamHandler())
33
+
34
+ # 常见反爬/拦截标记(大小写不敏感)
35
+ ANTI_BOT_MARKERS = [
36
+ "captcha", "recaptcha", "robot", "robots", "cloudflare", "challenge",
37
+ "blocked", "verification", "please verify", "access denied",
38
+ "too many requests", "rate limit", "forbidden", "unauthorized",
39
+ ]
40
+
41
+ # HTTP 状态码分组
42
+ RETRYABLE_STATUS = {429, 503, 502, 504}
43
+
44
+
45
+ def _now() -> float:
46
+ return time.time()
47
+
48
+
49
+ def _detect_anti_bot(text: str, status: int | None = None) -> str | None:
50
+ """检测是否命中验证码/拦截页面。"""
51
+ if status == 429:
52
+ return "rate_limited"
53
+ if status and 500 <= status < 600:
54
+ return f"http_{status}"
55
+ lowered = text.lower()
56
+ for marker in ANTI_BOT_MARKERS:
57
+ if marker in lowered:
58
+ return f"anti_bot:{marker}"
59
+ return None
60
+
61
+
62
+ def _build_canary_url(spec: dict[str, Any], query: str, n: int = 1) -> tuple[str, dict[str, str]]:
63
+ """构造 canary 请求的 URL 与 headers。"""
64
+ url = spec.get("url", "")
65
+ qp = spec.get("query_param", "q")
66
+ method = spec.get("method", "GET")
67
+ extra = spec.get("extra_params", {})
68
+ headers = spec.get("headers", {})
69
+
70
+ resolved_url = url.replace("{query}", urllib.parse.quote(query)).replace("{n}", str(n))
71
+ if method == "GET":
72
+ params: dict[str, str] = {qp: query}
73
+ for k, v in extra.items():
74
+ params[k] = str(v).replace("{query}", query).replace("{n}", str(n))
75
+ sep = "&" if "?" in resolved_url else "?"
76
+ full_url = f"{resolved_url}{sep}{urllib.parse.urlencode(params)}"
77
+ else:
78
+ full_url = resolved_url
79
+
80
+ resolved_headers = {k: str(v).replace("{n}", str(n)) for k, v in headers.items()}
81
+ return full_url, resolved_headers
82
+
83
+
84
+ def _fetch_probe(
85
+ url: str,
86
+ headers: dict[str, str],
87
+ method: str = "GET",
88
+ timeout: float = 8,
89
+ user_agent: str = "Mozilla/5.0 (compatible; unified-search-local/1.0.1; +https://local)",
90
+ ) -> tuple[int | None, float, str, str | None]:
91
+ """发送探针请求,返回 (status, latency_ms, text, fail_reason)。"""
92
+ req_headers = dict(headers)
93
+ if user_agent and "User-Agent" not in req_headers:
94
+ req_headers["User-Agent"] = user_agent
95
+ req_headers.setdefault("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
96
+ req_headers.setdefault("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
97
+
98
+ req = urllib.request.Request(url, headers=req_headers, method=method)
99
+ t0 = time.time()
100
+ try:
101
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
102
+ raw = resp.read()
103
+ try:
104
+ text = raw.decode("utf-8", errors="replace")
105
+ except Exception:
106
+ text = ""
107
+ status = resp.getcode()
108
+ except urllib.error.HTTPError as e:
109
+ status = e.code
110
+ text = ""
111
+ try:
112
+ text = e.read().decode("utf-8", errors="replace")[:500]
113
+ except Exception:
114
+ pass
115
+ except urllib.error.URLError as e:
116
+ return None, round((time.time() - t0) * 1000, 2), "", f"url_error:{e.reason}"
117
+ except Exception as e:
118
+ return None, round((time.time() - t0) * 1000, 2), "", f"exception:{type(e).__name__}:{e}"
119
+
120
+ elapsed = round((time.time() - t0) * 1000, 2)
121
+ fail_reason = _detect_anti_bot(text, status)
122
+ return status, elapsed, text, fail_reason
123
+
124
+
125
+ def _parse_success(engine_name: str, text: str, fmt: str, registry: EngineRegistry) -> bool:
126
+ """粗略判断解析是否可能成功(通过 parse_maps.yaml 与格式特征)。"""
127
+ if not text:
128
+ return False
129
+ if fmt == "json":
130
+ try:
131
+ json.loads(text)
132
+ return True
133
+ except json.JSONDecodeError:
134
+ return False
135
+ if fmt in ("xml", "rss"):
136
+ import xml.etree.ElementTree as ET
137
+ try:
138
+ ET.fromstring(text)
139
+ return True
140
+ except ET.ParseError:
141
+ return False
142
+ # html:检查 parse_maps 中 container 是否出现在文本中
143
+ maps = registry.parse_maps.get("html", {}).get(engine_name, {})
144
+ container = maps.get("container", "")
145
+ if container:
146
+ # 简单把 CSS 选择器转成正则特征
147
+ parts = [p.strip(". ") for p in container.replace(",", " ").split() if p.strip(". ")]
148
+ return any(p in text for p in parts)
149
+ return len(text) > 200
150
+
151
+
152
+ def check_engine(
153
+ engine_name: str,
154
+ registry: EngineRegistry | None = None,
155
+ canary_query: str = "test",
156
+ n: int = 1,
157
+ timeout: float = 8,
158
+ ) -> dict[str, Any]:
159
+ """对单个引擎执行健康探针,返回状态报告(不直接修改注册表)。"""
160
+ reg = registry or get_registry()
161
+ spec = reg.get_engine(engine_name)
162
+ if spec is None:
163
+ return {"name": engine_name, "available": False, "fail_reason": "not_configured"}
164
+ if not spec.get("enabled", True):
165
+ return {"name": engine_name, "available": False, "fail_reason": "disabled"}
166
+
167
+ url, headers = _build_canary_url(spec, canary_query, n)
168
+ method = spec.get("method", "GET")
169
+ status, latency_ms, text, fail_reason = _fetch_probe(url, headers, method=method, timeout=timeout)
170
+
171
+ fmt = spec.get("format", "html")
172
+ parse_ok = _parse_success(engine_name, text, fmt, reg) if text else False
173
+
174
+ report: dict[str, Any] = {
175
+ "name": engine_name,
176
+ "url": url,
177
+ "status": status,
178
+ "latency_ms": latency_ms,
179
+ "parse_ok": parse_ok,
180
+ "text_sample": text[:300] if text else "",
181
+ "fail_reason": fail_reason,
182
+ }
183
+
184
+ # 判定逻辑
185
+ http_failed = status is None or status >= 400
186
+ too_slow = latency_ms > 8000
187
+ blocked = fail_reason is not None
188
+ parse_failed = not parse_ok and not blocked and not http_failed
189
+
190
+ if http_failed or too_slow or blocked:
191
+ report["available"] = False
192
+ if not fail_reason:
193
+ if status is None:
194
+ report["fail_reason"] = "network_error"
195
+ else:
196
+ report["fail_reason"] = f"http_{status}"
197
+ elif parse_failed:
198
+ # 解析失败单独记录,但不直接判 unavailable(可能是页面改版)
199
+ report["available"] = True
200
+ report["parse_warning"] = True
201
+ else:
202
+ report["available"] = True
203
+
204
+ return report
205
+
206
+
207
+ def apply_threshold(report: dict[str, Any], previous: dict[str, Any] | None = None) -> bool:
208
+ """根据本次报告与上一次状态,应用可用性阈值判定。
209
+
210
+ 规则:
211
+ - 本次成功 → available = True
212
+ - 本次失败且(连续失败 >=2 或 延迟>8s 或 HTTP 4xx/5xx)→ available = False
213
+ - 其他失败情况继承上一次状态(保守策略)
214
+ """
215
+ available = report.get("available", False)
216
+ if available:
217
+ return True
218
+
219
+ status = report.get("status")
220
+ latency_ms = report.get("latency_ms", 0)
221
+ consecutive = previous.get("consecutive_failures", 0) if previous else 0
222
+
223
+ http_failed = status is None or (isinstance(status, int) and status >= 400)
224
+ too_slow = latency_ms > 8000
225
+ now_consecutive = consecutive + 1
226
+
227
+ if http_failed or too_slow or now_consecutive >= 2:
228
+ return False
229
+ # 单次软性失败,继承之前状态
230
+ return previous.get("available", True) if previous else True
231
+
232
+
233
+ def run_health_check(
234
+ registry: EngineRegistry | None = None,
235
+ canary_query: str = "test",
236
+ n: int = 1,
237
+ timeout: float = 8,
238
+ max_parallel: int = 5,
239
+ ttl_minutes: float = 5,
240
+ engine_names: list[str] | None = None,
241
+ ) -> dict[str, dict[str, Any]]:
242
+ """运行一轮健康检查,返回每个引擎的最新报告。
243
+
244
+ 若缓存 TTL 未过期,则直接返回已有健康状态。
245
+ 传入 engine_names 可只检查指定引擎,避免对所有启用引擎全量探针。
246
+ """
247
+ reg = registry or get_registry()
248
+ settings = reg.settings.get("health_check", {})
249
+ canary_query = settings.get("canary_query", canary_query)
250
+ timeout = settings.get("timeout", timeout)
251
+ ttl_minutes = settings.get("ttl_minutes", ttl_minutes)
252
+ max_parallel = settings.get("max_parallel", max_parallel)
253
+
254
+ enabled = engine_names if engine_names is not None else reg.list_engines(enabled_only=True)
255
+
256
+ now = _now()
257
+ # TTL 检查:在有效期内直接复用缓存
258
+ cached = reg._health
259
+ all_recent = all(
260
+ (now - h.get("last_checked", 0)) < ttl_minutes * 60
261
+ for h in cached.values() if engine_names is None or h in enabled
262
+ )
263
+ if cached and all_recent:
264
+ return {name: dict(h) for name, h in cached.items() if name in enabled}
265
+
266
+ reports: dict[str, dict[str, Any]] = {}
267
+
268
+ def _probe(name: str) -> tuple[str, dict[str, Any]]:
269
+ return name, check_engine(name, reg, canary_query=canary_query, n=n, timeout=timeout)
270
+
271
+ with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(enabled), max_parallel)) as ex:
272
+ futures = [ex.submit(_probe, name) for name in enabled]
273
+ for fut in concurrent.futures.as_completed(futures):
274
+ name, report = fut.result()
275
+ reports[name] = report
276
+
277
+ # 应用阈值并更新注册表
278
+ for name, report in reports.items():
279
+ previous = reg.get_health(name)
280
+ final_available = apply_threshold(report, previous)
281
+ update_data = {
282
+ "available": final_available,
283
+ "latency_ms": report.get("latency_ms"),
284
+ "status": report.get("status"),
285
+ "parse_ok": report.get("parse_ok"),
286
+ "fail_reason": report.get("fail_reason"),
287
+ }
288
+ if final_available:
289
+ update_data["success_rate"] = 1.0
290
+ else:
291
+ update_data["success_rate"] = 0.0
292
+ reg.update_availability(name, final_available, **update_data)
293
+
294
+ return reports
295
+
296
+
297
+ def get_available_engines(
298
+ registry: EngineRegistry | None = None,
299
+ use_cache: bool = True,
300
+ engine_names: list[str] | None = None,
301
+ ) -> list[str]:
302
+ """获取当前可用引擎列表;若缓存过期则自动执行健康检查。
303
+
304
+ 通过 engine_names 可只检查指定引擎,避免全量探针。
305
+ """
306
+ reg = registry or get_registry()
307
+ if engine_names is None:
308
+ engine_names = reg.list_engines(enabled_only=True)
309
+ if use_cache:
310
+ now = _now()
311
+ ttl = reg.settings.get("health_check", {}).get("ttl_minutes", 5) * 60
312
+ cached = reg._health
313
+ if cached and all((now - h.get("last_checked", 0)) < ttl for h in cached.values() if h in engine_names):
314
+ return [n for n in engine_names if reg.is_available(n)]
315
+ run_health_check(registry=reg, engine_names=engine_names)
316
+ return [n for n in engine_names if reg.is_available(n)]
317
+
318
+
319
+ if __name__ == "__main__":
320
+ import argparse
321
+
322
+ parser = argparse.ArgumentParser(description="local-search 健康检查")
323
+ parser.add_argument("--engine", default=None, help="只检查单个引擎")
324
+ parser.add_argument("--query", default="test", help="canary 查询词")
325
+ parser.add_argument("--timeout", type=float, default=8)
326
+ parser.add_argument("--max-parallel", type=int, default=5)
327
+ parser.add_argument("--force", action="store_true", help="忽略 TTL 强制检查")
328
+ parser.add_argument("--json", action="store_true", help="输出 JSON")
329
+ args = parser.parse_args()
330
+
331
+ reg = get_registry()
332
+ if args.force:
333
+ reg._health.clear()
334
+ reg._save_health()
335
+
336
+ if args.engine:
337
+ report = check_engine(args.engine, reg, canary_query=args.query, timeout=args.timeout)
338
+ print(json.dumps(report, ensure_ascii=False, indent=2))
339
+ else:
340
+ reports = run_health_check(reg, canary_query=args.query, timeout=args.timeout, max_parallel=args.max_parallel)
341
+ summary = {
342
+ "total": len(reports),
343
+ "available": sum(1 for r in reports.values() if r.get("available")),
344
+ "unavailable": sum(1 for r in reports.values() if not r.get("available")),
345
+ "reports": reports,
346
+ }
347
+ print(json.dumps(summary, ensure_ascii=False, indent=2, default=str))
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env python3
2
+ """local_search_adapter.py — local-search 兼容入口
3
+
4
+ 保留原 adapter 接口,内部委托 search_v3.search_engines 执行。
5
+ unified-search 可通过 --sub-skill local-search 或 --local-first 调用本入口。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ SKILL_DIR = Path(__file__).resolve().parent
17
+ sys.path.insert(0, str(SKILL_DIR))
18
+
19
+ from search_v3 import search_engines
20
+
21
+
22
+ def _parse_engine_list(value: str) -> list[str]:
23
+ return [x.strip() for x in value.replace(",", ",").split(",") if x.strip()]
24
+
25
+
26
+ def main():
27
+ parser = argparse.ArgumentParser(description="local-search 兼容入口(委托 search_v3)")
28
+ parser.add_argument("query", nargs="?", help="搜索关键词")
29
+ parser.add_argument("--engine", "-e", default="", help="引擎名,多个用逗号分隔")
30
+ parser.add_argument("--n", type=int, default=5, help="每引擎结果数")
31
+ parser.add_argument("--timeout", "-t", type=float, default=None, help="超时秒数")
32
+ parser.add_argument("--max-parallel", type=int, default=5)
33
+ parser.add_argument("--no-cache", action="store_true", help="跳过缓存")
34
+ parser.add_argument("--mode", default="fast", choices=["fast", "auto", "deep", "budget"],
35
+ help="unified-search 模式透传")
36
+ parser.add_argument("--json", action="store_true", help="输出 JSON")
37
+ args = parser.parse_args()
38
+
39
+ if not args.query:
40
+ parser.error("必须提供搜索关键词")
41
+
42
+ engines = _parse_engine_list(args.engine) if args.engine else None
43
+ result = search_engines(
44
+ args.query,
45
+ engines=engines,
46
+ n=args.n,
47
+ timeout=args.timeout,
48
+ max_parallel=args.max_parallel,
49
+ skip_cache=args.no_cache,
50
+ mode=args.mode,
51
+ )
52
+ print(json.dumps(result, ensure_ascii=False, indent=2))
53
+
54
+
55
+ if __name__ == "__main__":
56
+ main()
@@ -0,0 +1,184 @@
1
+ # local-search HTML/RSS/JSON 解析映射
2
+ # 页面结构变化时,只需修改本文件,无需改代码。
3
+
4
+ settings:
5
+ max_snippet_length: 300
6
+ max_title_length: 200
7
+ resolve_relative_urls: true
8
+
9
+ html:
10
+ local_bing:
11
+ container: "li.b_algo"
12
+ title: "h2 a"
13
+ url: "h2 a"
14
+ snippet: ".b_caption p, .b_lineclamp2, .b_paractl"
15
+ url_attr: href
16
+ score: 0.75
17
+
18
+ local_google:
19
+ container: "div.g"
20
+ title: "h3"
21
+ url: "a"
22
+ snippet: "div.VwiC3b, div.s, span.aCOpRe"
23
+ url_attr: href
24
+ score: 0.75
25
+
26
+ local_mojeek:
27
+ container: "li.results-standard-list"
28
+ title: "a.ob"
29
+ url: "a.ob"
30
+ snippet: "p.s"
31
+ url_attr: href
32
+ score: 0.7
33
+
34
+ local_yandex:
35
+ container: "li.serp-item"
36
+ title: "a.Link"
37
+ url: "a.Link"
38
+ snippet: "div.TextContainer"
39
+ url_attr: href
40
+ score: 0.65
41
+
42
+ local_startpage:
43
+ container: "div.result"
44
+ title: "a.result-title"
45
+ url: "a.result-title"
46
+ snippet: "p.result-snippet"
47
+ url_attr: href
48
+ score: 0.7
49
+
50
+ local_duckduckgo:
51
+ container: ".result"
52
+ title: "a.result__a"
53
+ url: "a.result__a"
54
+ snippet: "a.result__snippet"
55
+ url_attr: href
56
+ score: 0.7
57
+
58
+ local_baidu:
59
+ container: ".result, .c-container"
60
+ title: "h3"
61
+ url: "a"
62
+ snippet: ".content-right_8Zs40, .c-abstract, .content-left_3d_H4"
63
+ url_attr: href
64
+ score: 0.7
65
+
66
+ local_sogou:
67
+ container: ".vrwrap, .result"
68
+ title: "h3 a, h3"
69
+ url: "h3 a"
70
+ snippet: "p.str-text, .str_info"
71
+ url_attr: href
72
+ score: 0.65
73
+
74
+ local_stackoverflow:
75
+ container: ".question-summary"
76
+ title: "a.question-hyperlink"
77
+ url: "a.question-hyperlink"
78
+ snippet: ".excerpt"
79
+ url_attr: href
80
+ score: 0.8
81
+
82
+ local_imdb:
83
+ container: ".findResult"
84
+ title: "td.result_text a"
85
+ url: "td.result_text a"
86
+ snippet: "td.result_text"
87
+ url_attr: href
88
+ score: 0.7
89
+
90
+ local_goodreads:
91
+ container: "tr.bookBox"
92
+ title: "a.bookTitle"
93
+ url: "a.bookTitle"
94
+ snippet: "span.smallText, span.greyText"
95
+ url_attr: href
96
+ score: 0.7
97
+
98
+ xml:
99
+ local_arxiv:
100
+ entry_path: ".//{http://www.w3.org/2005/Atom}entry"
101
+ title: "atom:title"
102
+ url: "atom:id"
103
+ snippet: "atom:summary"
104
+ namespaces:
105
+ atom: "http://www.w3.org/2005/Atom"
106
+
107
+ rss:
108
+ default:
109
+ item_path: "./channel/item"
110
+ title: "title"
111
+ url: "link"
112
+ snippet: "description"
113
+
114
+ json:
115
+ local_wikipedia:
116
+ items: "query.search"
117
+ title: "title"
118
+ url: null
119
+ snippet: "snippet"
120
+ url_template: "https://en.wikipedia.org/wiki/{title}"
121
+
122
+ local_wiktionary:
123
+ items: "query.search"
124
+ title: "title"
125
+ url: null
126
+ snippet: "snippet"
127
+ url_template: "https://en.wiktionary.org/wiki/{title}"
128
+
129
+ local_wikiquote:
130
+ items: "query.search"
131
+ title: "title"
132
+ url: null
133
+ snippet: "snippet"
134
+ url_template: "https://en.wikiquote.org/wiki/{title}"
135
+
136
+ local_github:
137
+ items: "items"
138
+ title: "full_name"
139
+ url: "html_url"
140
+ snippet: "description"
141
+
142
+ local_npm:
143
+ items: "objects"
144
+ title: "package.name"
145
+ url: "package.links.npm"
146
+ snippet: "package.description"
147
+
148
+ local_gitlab:
149
+ items: "."
150
+ title: "name_with_namespace"
151
+ url: "web_url"
152
+ snippet: "description"
153
+
154
+ local_stackoverflow:
155
+ items: "items"
156
+ title: "title"
157
+ url: "link"
158
+ snippet: null
159
+
160
+ local_crossref:
161
+ items: "message.items"
162
+ title: "title"
163
+ url: "URL"
164
+ snippet: "abstract"
165
+
166
+ local_semantic_scholar:
167
+ items: "data"
168
+ title: "title"
169
+ url: "url"
170
+ snippet: "abstract"
171
+
172
+ local_pubmed:
173
+ items: "esearchresult.idlist"
174
+ title: null
175
+ url: null
176
+ snippet: null
177
+ url_template: "https://pubmed.ncbi.nlm.nih.gov/{pmid}/"
178
+
179
+ local_openstreetmap:
180
+ items: "."
181
+ title: "display_name"
182
+ url: null
183
+ snippet: "type"
184
+ url_template: "https://www.openstreetmap.org/{osm_type}/{osm_id}"