argo-search 2.9.7 → 2.9.9

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.
Files changed (154) hide show
  1. package/README.en.md +73 -150
  2. package/README.es.md +73 -150
  3. package/README.ja.md +79 -156
  4. package/README.ko.md +78 -155
  5. package/README.md +80 -159
  6. package/SKILL.md +3 -3
  7. package/assets/readme/hero.svg +2 -2
  8. package/backends/domain_profiles.json +48 -0
  9. package/backends/engine_registry.yaml +118 -4
  10. package/backends/quota_profiles.json +92 -12
  11. package/backends/source_types_cn.json +27 -1
  12. package/bin/argo +78 -5
  13. package/bin/argo.js +33 -2
  14. package/config.yaml +269 -63
  15. package/docs/ARGO_INTRO.md +5 -3
  16. package/docs/ENGINE_CATALOG.md +50 -39
  17. package/docs/MCP_SETUP.md +3 -1
  18. package/docs/RELEASE_NOTES_v2.9.8.md +73 -0
  19. package/docs/RELEASE_NOTES_v2.9.9.md +78 -0
  20. package/engines/specs/cfets.yaml +41 -0
  21. package/engines/specs/defillama.yaml +38 -0
  22. package/engines/specs/firecrawl.yaml +1 -1
  23. package/engines/specs/fxtwitter.yaml +5 -0
  24. package/engines/specs/gleif.yaml +55 -0
  25. package/engines/specs/hkex.yaml +40 -0
  26. package/engines/specs/keenable_free.yaml +36 -0
  27. package/engines/specs/shfe.yaml +39 -0
  28. package/engines/specs/stackexchange.yaml +2 -2
  29. package/engines/specs/tavily_free.yaml +56 -0
  30. package/engines/specs/yuanbao.yaml +93 -0
  31. package/mcp/clients.yaml +32 -0
  32. package/package.json +7 -2
  33. package/packages/dsh-plugin/README.md +25 -6
  34. package/packages/dsh-plugin/dsh/client.js +402 -47
  35. package/packages/dsh-plugin/dsh/diagnostics.js +97 -0
  36. package/packages/dsh-plugin/dsh/index.js +135 -1876
  37. package/packages/dsh-plugin/dsh/native-tools.mjs +7 -1
  38. package/packages/dsh-plugin/dsh/runner.js +666 -0
  39. package/packages/dsh-plugin/dsh/wide-research.js +878 -0
  40. package/packages/dsh-plugin/package.json +3 -3
  41. package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
  42. package/packages/dsh-plugin/test/client.test.js +177 -2
  43. package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
  44. package/packages/dsh-plugin/test/index.test.js +44 -0
  45. package/scripts/adaptive.py +126 -43
  46. package/scripts/article_meta.py +131 -0
  47. package/scripts/bench_tail_ab.py +281 -0
  48. package/scripts/bounded_run.py +91 -49
  49. package/scripts/budget.py +247 -0
  50. package/scripts/budget_cli.py +93 -0
  51. package/scripts/build_release_assets.sh +65 -0
  52. package/scripts/cache.py +60 -72
  53. package/scripts/ci_drift_gate.py +90 -0
  54. package/scripts/clock_bridge.py +22 -0
  55. package/scripts/content_boundary.py +46 -0
  56. package/scripts/content_security.py +119 -4
  57. package/scripts/content_signals.py +26 -5
  58. package/scripts/cross_lingual_trace.py +293 -0
  59. package/scripts/dispatch_result.py +105 -0
  60. package/scripts/engine_add.py +602 -0
  61. package/scripts/engine_dispatch.py +58 -86
  62. package/scripts/engine_env.py +5 -0
  63. package/scripts/engine_families.py +6 -0
  64. package/scripts/engine_policy.py +7 -7
  65. package/scripts/engines.py +21 -0
  66. package/scripts/engines_base.py +3 -3
  67. package/scripts/engines_builders.py +12 -0
  68. package/scripts/engines_builders_batch10.py +48 -3
  69. package/scripts/engines_builders_batch14.py +656 -0
  70. package/scripts/engines_builders_batch15.py +249 -0
  71. package/scripts/engines_builders_keyless.py +111 -0
  72. package/scripts/engines_builders_tech.py +0 -1
  73. package/scripts/evidence.py +31 -3
  74. package/scripts/evidence_loop.py +58 -22
  75. package/scripts/fetch_quality.py +13 -0
  76. package/scripts/fetch_v3.py +28 -3
  77. package/scripts/gen_routing_snapshot.py +11 -0
  78. package/scripts/http_client.py +24 -38
  79. package/scripts/install.ps1 +18 -0
  80. package/scripts/install.sh +16 -1
  81. package/scripts/job.py +3 -3
  82. package/scripts/local_seek.py +41 -5
  83. package/scripts/macro_countries.py +15 -0
  84. package/scripts/matrix_search_eval.py +192 -0
  85. package/scripts/mcp_compact.py +234 -0
  86. package/scripts/mcp_handlers.py +60 -642
  87. package/scripts/mcp_launch.sh +10 -8
  88. package/scripts/mcp_local.py +241 -0
  89. package/scripts/mcp_server.py +19 -14
  90. package/scripts/mcp_setup.py +33 -9
  91. package/scripts/mcp_tools.py +1 -0
  92. package/scripts/mcp_transport.py +1 -1
  93. package/scripts/mcp_warm.py +127 -0
  94. package/scripts/net_proxy.py +27 -0
  95. package/scripts/output_transparency.py +200 -0
  96. package/scripts/paid_policy.py +98 -0
  97. package/scripts/quota.py +158 -13
  98. package/scripts/recovery.py +7 -3
  99. package/scripts/recovery_runner.py +150 -0
  100. package/scripts/replay_eval.py +18 -0
  101. package/scripts/research.py +37 -21
  102. package/scripts/research_expand.py +24 -1
  103. package/scripts/retry_policy.py +76 -0
  104. package/scripts/route.py +6 -6
  105. package/scripts/route_cache.py +18 -0
  106. package/scripts/route_combo.py +26 -5
  107. package/scripts/route_domains.py +170 -31
  108. package/scripts/safe_conn.py +52 -0
  109. package/scripts/search.py +15 -27
  110. package/scripts/search_attribution.py +100 -3
  111. package/scripts/search_cli.py +24 -11
  112. package/scripts/search_entry.py +8 -1
  113. package/scripts/search_output.py +99 -15
  114. package/scripts/search_pipeline.py +62 -195
  115. package/scripts/search_rank.py +6 -0
  116. package/scripts/search_types.py +107 -9
  117. package/scripts/semantic_evidence.py +179 -3
  118. package/scripts/serp_guard.py +138 -7
  119. package/scripts/serp_kill_log.py +25 -2
  120. package/scripts/stats_cli.py +13 -1
  121. package/scripts/tail_completion.py +212 -0
  122. package/scripts/url_safety.py +126 -3
  123. package/scripts/usage_log.py +45 -0
  124. package/scripts/wide_research.py +45 -0
  125. package/scripts/zhihu_quota.py +139 -0
  126. package/sub-skills/local-search/bm25.py +27 -3
  127. package/sub-skills/local-search/cli_runner.py +331 -0
  128. package/sub-skills/local-search/parsers.py +328 -0
  129. package/sub-skills/local-search/search_v3.py +31 -674
  130. package/sub-skills/local-seek/scripts/seek_rank.py +7 -3
  131. package/assets/readme/settings-keys.png +0 -0
  132. package/assets/readme/settings-tools.png +0 -0
  133. package/sub-skills/ego-search/references/browser-runtime.md +0 -43
  134. package/sub-skills/ego-search/references/install.md +0 -67
  135. package/sub-skills/ego-search/references/original-ego-upgrade.md +0 -51
  136. package/sub-skills/ego-search/tests/test_query_normalize.py +0 -68
  137. package/sub-skills/js-run/tests/fixtures/challenge_like.js +0 -27
  138. package/sub-skills/js-run/tests/test_jsrun.py +0 -165
  139. package/sub-skills/local-seek/references/auto-behaviors.md +0 -24
  140. package/sub-skills/local-seek/references/fd-mdfind-recipes.md +0 -62
  141. package/sub-skills/local-seek/references/git-integration.md +0 -54
  142. package/sub-skills/local-seek/references/remote-handoff.md +0 -46
  143. package/sub-skills/local-seek/references/rg-recipes.md +0 -96
  144. package/sub-skills/local-seek/references/strategies.md +0 -52
  145. package/sub-skills/local-seek/references/structural-search.md +0 -59
  146. package/sub-skills/local-seek/tests/test_content_rank.py +0 -222
  147. package/sub-skills/local-seek/tests/test_determinism_20261006.py +0 -145
  148. package/sub-skills/local-seek/tests/test_noise_tier.py +0 -101
  149. package/sub-skills/local-seek/tests/test_pcre2_probe.py +0 -136
  150. package/sub-skills/local-seek/tests/test_rank_pinyin.py +0 -77
  151. package/sub-skills/local-seek/tests/test_regression_20261003.py +0 -169
  152. package/sub-skills/local-seek/tests/test_regression_20261006.py +0 -146
  153. package/sub-skills/local-seek/tests/test_seek_degradation.py +0 -111
  154. package/sub-skills/local-seek/tests/test_seek_fts.py +0 -313
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env python3
2
+ """cli_runner.py — local-search CLI 工具引擎调度(ddgs 等)。
3
+
4
+ 拆分自 search_v3.py:负责外部命令行搜索工具执行、临时结构化输出文件管理、
5
+ 错误标记探测与快速失败重试策略。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import atexit
11
+ import json
12
+ import os
13
+ import re
14
+ import subprocess
15
+ import time
16
+ from typing import Any
17
+
18
+ # ddgs -o 的结构化输出临时文件清理
19
+ _TMP_FILES: set[str] = set()
20
+
21
+
22
+ def _cleanup_tmp_files() -> None:
23
+ for p in list(_TMP_FILES):
24
+ try:
25
+ os.unlink(p)
26
+ except OSError:
27
+ pass
28
+ _TMP_FILES.discard(p)
29
+
30
+
31
+ atexit.register(_cleanup_tmp_files)
32
+
33
+ # ddgs CLI 可用性检查缓存
34
+ _CLI_AVAIL_CACHE: dict[str, tuple[float, bool]] = {}
35
+ _CLI_AVAIL_TTL = 60.0
36
+
37
+ _DDGS_TIMELIMITS: dict[str, set[str]] = {
38
+ "text": {"d", "w", "m", "y"},
39
+ "news": {"d", "w", "m", "y"},
40
+ "images": {"d", "w", "m", "y"},
41
+ "videos": {"d", "w", "m"},
42
+ }
43
+
44
+ _CLI_ERROR_MARKERS: tuple[str, ...] = (
45
+ "DDGSException", "No results", "ConnectError", "ConnectTimeout",
46
+ "ReadTimeout", "Timeout", "backends do not exist",
47
+ )
48
+ _CLI_RETRIES = 1
49
+ _RETRY_FAST_S = 2.5
50
+
51
+
52
+ def _since_to_ddgs_timelimit(since: str | None, until: str | None) -> str | None:
53
+ """Convert since/until to ddgs timelimit argument."""
54
+ if not since:
55
+ return None
56
+ match = re.match(r"^(\d+)([dwmy])$", since.lower())
57
+ if match:
58
+ num, unit = match.groups()
59
+ num = int(num)
60
+ if unit == "y":
61
+ return "y"
62
+ elif unit == "m":
63
+ return "m"
64
+ elif unit == "w":
65
+ return "w"
66
+ elif unit == "d":
67
+ if num >= 365:
68
+ return "y"
69
+ elif num >= 30:
70
+ return "m"
71
+ elif num >= 7:
72
+ return "w"
73
+ else:
74
+ return "d"
75
+ return None
76
+
77
+
78
+ def _check_cli_available(cmd: str) -> bool:
79
+ """Check if a CLI command is available(结果缓存 TTL=_CLI_AVAIL_TTL)。"""
80
+ import shutil
81
+ now = time.time()
82
+ hit = _CLI_AVAIL_CACHE.get(cmd)
83
+ if hit is not None and now - hit[0] < _CLI_AVAIL_TTL:
84
+ return hit[1]
85
+ ok = shutil.which(cmd) is not None
86
+ _CLI_AVAIL_CACHE[cmd] = (time.time(), ok)
87
+ return ok
88
+
89
+
90
+ def _parse_cli_json(data: Any, engine_name: str) -> list[dict[str, Any]]:
91
+ """解析 ddgs 结构化 JSON 输出(-o json 写入文件)。"""
92
+ if not isinstance(data, list):
93
+ return []
94
+ results: list[dict[str, Any]] = []
95
+ for item in data:
96
+ if not isinstance(item, dict):
97
+ continue
98
+ title = item.get("title", "")
99
+ url = item.get("href", item.get("url", item.get("link", "")))
100
+ snippet = item.get("body", item.get("snippet",
101
+ item.get("description", item.get("content", ""))))
102
+ if not title and not url:
103
+ continue
104
+ r: dict[str, Any] = {
105
+ "title": str(title),
106
+ "url": str(url),
107
+ "snippet": str(snippet),
108
+ "source": engine_name,
109
+ }
110
+ for key in ("image", "thumbnail", "source", "duration", "provider",
111
+ "height", "width"):
112
+ if key in item and item[key] not in (None, ""):
113
+ r[key] = item[key]
114
+ date = item.get("date")
115
+ if date:
116
+ r["published_at"] = str(date)[:64]
117
+ meta = " · ".join(
118
+ str(item[k]) for k in ("author", "publisher", "info")
119
+ if item.get(k)
120
+ )
121
+ if meta:
122
+ r["snippet"] = (str(snippet) + " · " + meta)[:400] if snippet else meta[:400]
123
+ results.append(r)
124
+ return results
125
+
126
+
127
+ def _parse_cli_output(stdout: str, engine_name: str) -> tuple[list[dict[str, Any]], str]:
128
+ """Parse output from CLI search tools like ddgs."""
129
+ results = []
130
+ try:
131
+ data = json.loads(stdout)
132
+ if isinstance(data, list):
133
+ for item in data:
134
+ result = {
135
+ "title": item.get("title", ""),
136
+ "url": item.get("href", item.get("url", item.get("link", ""))),
137
+ "snippet": item.get("body", item.get("snippet", item.get("description", item.get("content", "")))),
138
+ "source": engine_name,
139
+ }
140
+ for key in ["date", "image", "thumbnail", "source"]:
141
+ if key in item:
142
+ result[key] = item[key]
143
+ results.append(result)
144
+ return results, ""
145
+ except json.JSONDecodeError:
146
+ pass
147
+
148
+ current_result = {}
149
+ current_field = None
150
+
151
+ for line in stdout.split("\n"):
152
+ line_stripped = line.strip()
153
+ if not line_stripped:
154
+ continue
155
+ if re.fullmatch(r"\d+\.\s*", line_stripped):
156
+ if current_result:
157
+ results.append(current_result)
158
+ current_result = {}
159
+ current_field = None
160
+ continue
161
+
162
+ field_matched = False
163
+ for field, target_key in [
164
+ ("title", "title"),
165
+ ("href", "url"),
166
+ ("url", "url"),
167
+ ("body", "snippet"),
168
+ ("snippet", "snippet"),
169
+ ("description", "snippet"),
170
+ ("content", "snippet"),
171
+ ("date", "date"),
172
+ ("image", "image"),
173
+ ("thumbnail", "thumbnail"),
174
+ ("source", "source"),
175
+ ("duration", "duration"),
176
+ ("provider", "provider"),
177
+ ("height", "height"),
178
+ ("width", "width"),
179
+ ]:
180
+ if line_stripped.startswith(field) and len(line_stripped) > len(field):
181
+ value = line_stripped[len(field):].strip()
182
+ if value:
183
+ current_result[target_key] = value
184
+ current_field = field
185
+ field_matched = True
186
+ break
187
+
188
+ if not field_matched and current_field:
189
+ if current_field in ("body", "snippet", "description", "content"):
190
+ current_result.setdefault("snippet", "")
191
+ current_result["snippet"] += " " + line_stripped
192
+
193
+ if current_result:
194
+ results.append(current_result)
195
+
196
+ for i, r in enumerate(results):
197
+ r.setdefault("source", engine_name)
198
+ r.setdefault("title", "")
199
+ r.setdefault("url", "")
200
+ r.setdefault("snippet", "")
201
+ r.setdefault("score", round(0.9 - (i * 0.05), 2))
202
+ return results, ""
203
+
204
+
205
+ def _attempt_loop(cmd: list[str], cli_cmd: str, key: str,
206
+ tmp_path: str | None, timeout: float):
207
+ """CLI 引擎的单引擎尝试循环。"""
208
+ last_err = ""
209
+ for attempt in range(_CLI_RETRIES + 1):
210
+ t_attempt = time.monotonic()
211
+ try:
212
+ result = subprocess.run(
213
+ cmd,
214
+ capture_output=True,
215
+ text=True,
216
+ encoding="utf-8",
217
+ errors="replace",
218
+ timeout=timeout,
219
+ )
220
+ except subprocess.TimeoutExpired:
221
+ return [], f"{cli_cmd} timed out after {timeout}s"
222
+ except FileNotFoundError:
223
+ return [], f"{cli_cmd} not found"
224
+ except Exception as e:
225
+ return [], f"{cli_cmd} error: {e}"
226
+
227
+ combined_output = result.stdout + result.stderr
228
+
229
+ if tmp_path is not None:
230
+ json_ok = False
231
+ try:
232
+ with open(tmp_path, encoding="utf-8") as f:
233
+ data = json.load(f)
234
+ parsed = _parse_cli_json(data, key)
235
+ if parsed:
236
+ return parsed, ""
237
+ json_ok = True
238
+ except (OSError, ValueError):
239
+ json_ok = False
240
+ finally:
241
+ try:
242
+ os.unlink(tmp_path)
243
+ except OSError:
244
+ pass
245
+ if json_ok:
246
+ return [], ""
247
+ else:
248
+ json_ok = False
249
+
250
+ err_marker = next(
251
+ (m for m in _CLI_ERROR_MARKERS if m in combined_output), None
252
+ )
253
+ if err_marker:
254
+ detail = combined_output.strip().replace("\n", " ")[:120]
255
+ last_err = f"{cli_cmd}: {err_marker}: {detail}"
256
+ if attempt < _CLI_RETRIES and time.monotonic() - t_attempt <= _RETRY_FAST_S:
257
+ time.sleep(0.3)
258
+ continue
259
+ return [], last_err
260
+ if result.returncode != 0:
261
+ last_err = f"{cli_cmd} exit {result.returncode}: {combined_output[:200]}"
262
+ if attempt < _CLI_RETRIES and time.monotonic() - t_attempt <= _RETRY_FAST_S:
263
+ time.sleep(0.3)
264
+ continue
265
+ return [], last_err
266
+
267
+ parsed, parse_err = _parse_cli_output(result.stdout, key)
268
+ if parsed:
269
+ return parsed, parse_err
270
+ last_err = f"{cli_cmd}: empty output"
271
+ if attempt < _CLI_RETRIES and time.monotonic() - t_attempt <= _RETRY_FAST_S:
272
+ time.sleep(0.3)
273
+ continue
274
+ return [], last_err
275
+ return [], last_err
276
+
277
+
278
+ def _run_cli_engine(spec: dict[str, Any], query: str, n: int, timeout: float,
279
+ since: str | None = None, until: str | None = None,
280
+ lang_param_fn=None) -> tuple[list[dict[str, Any]], str]:
281
+ """Run a CLI-based search engine (e.g., ddgs)."""
282
+ cli_cmd = spec.get("cli_command", "")
283
+ cli_args_template = spec.get("cli_args", [])
284
+ key = spec.get("key", cli_cmd)
285
+
286
+ if not cli_cmd:
287
+ return [], "CLI engine missing cli_command"
288
+
289
+ if not _check_cli_available(cli_cmd):
290
+ return [], f"{cli_cmd} not available"
291
+
292
+ time_limit = _since_to_ddgs_timelimit(since, until)
293
+
294
+ cli_args = []
295
+ for arg in cli_args_template:
296
+ formatted = arg.format(query=query, n=n)
297
+ cli_args.append(formatted)
298
+
299
+ if time_limit and "-t" not in cli_args and "--timelimit" not in cli_args:
300
+ sub = next((a for a in cli_args_template if not str(a).startswith("-")), None)
301
+ allowed = _DDGS_TIMELIMITS.get(str(sub), _DDGS_TIMELIMITS["text"])
302
+ if time_limit in allowed:
303
+ cli_args.extend(["-t", time_limit])
304
+
305
+ if spec.get("use_lang_region") and not any(a in ("-r", "--region") for a in cli_args) and lang_param_fn:
306
+ region = lang_param_fn("region", query)
307
+ if region:
308
+ cli_args.extend(["-r", region])
309
+
310
+ output_format = spec.get("output_format", "")
311
+ tmp_path: str | None = None
312
+ if output_format == "json" and not any(
313
+ a in ("-o", "--output") for a in cli_args
314
+ ):
315
+ import tempfile
316
+ fd, tmp_path = tempfile.mkstemp(suffix=".json", prefix="ddgs_")
317
+ os.close(fd)
318
+ _TMP_FILES.add(tmp_path)
319
+ cli_args.extend(["-o", tmp_path])
320
+
321
+ cmd = [cli_cmd] + cli_args
322
+
323
+ try:
324
+ return _attempt_loop(cmd, cli_cmd, key, tmp_path, timeout)
325
+ finally:
326
+ if tmp_path is not None:
327
+ try:
328
+ os.unlink(tmp_path)
329
+ except OSError:
330
+ pass
331
+ _TMP_FILES.discard(tmp_path)
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env python3
2
+ """parsers.py — local-search 多源响应解析器(HTML / RSS / XML / JSON)。
3
+
4
+ 拆分自 search_v3.py:负责选择器匹配、启发式链接回退(_fallback_extract)、
5
+ XML 命名空间解析与 JSON 字典路径提取。无网络 IO。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ import sys
14
+ import urllib.parse
15
+ import xml.etree.ElementTree as ET
16
+ from typing import Any
17
+
18
+ # 尝试连接主仓 serp_guard 内容农场判定
19
+ _SPAM_CHECKER = None
20
+ try:
21
+ _SCRIPTS_PATH = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "scripts"))
22
+ if _SCRIPTS_PATH not in sys.path:
23
+ sys.path.insert(0, _SCRIPTS_PATH)
24
+ from serp_guard import is_content_farm as _SPAM_CHECKER
25
+ except Exception:
26
+ _SPAM_CHECKER = None
27
+
28
+
29
+ def _is_spam_entry(url: str, title: str = "", snippet: str = "") -> bool:
30
+ if _SPAM_CHECKER is not None:
31
+ try:
32
+ return bool(_SPAM_CHECKER(url, title, snippet))
33
+ except Exception:
34
+ return False
35
+ return False
36
+
37
+
38
+
39
+ def _select_with_bs4(soup: Any, selector: str):
40
+ try:
41
+ return soup.select(selector)
42
+ except Exception:
43
+ return []
44
+
45
+
46
+ def _select_first_with_bs4(soup: Any, selector: str) -> Any:
47
+ items = _select_with_bs4(soup, selector)
48
+ return items[0] if items else None
49
+
50
+
51
+ def _fallback_extract(html: str, engine_name: str, base: str,
52
+ default_score: float = 0.45, max_items: int = 8) -> list[dict[str, Any]]:
53
+ """通用解析保底:选择器 0 命中时,用链接 + 锚文本启发式回退。"""
54
+ try:
55
+ from bs4 import BeautifulSoup
56
+ soup = BeautifulSoup(html, "html.parser")
57
+ except Exception:
58
+ return []
59
+ for sel in ("nav", "header", "footer", "script", "style", "noscript"):
60
+ try:
61
+ for tag in soup.select(sel):
62
+ tag.decompose()
63
+ except Exception:
64
+ pass
65
+ candidates: list[tuple[str, str, str]] = []
66
+ for a in soup.find_all("a", href=True):
67
+ try:
68
+ href = str(a.get("href") or "").strip()
69
+ title = a.get_text(strip=True)
70
+ except Exception:
71
+ continue
72
+ if not title or len(title) < 6:
73
+ continue
74
+ if href.startswith("#") or href.startswith("javascript:"):
75
+ continue
76
+ low = href.lower()
77
+ if any(x in low for x in ("login", "signin", "signup", "cookie", "privacy", "terms")):
78
+ continue
79
+ parent = a.parent
80
+ snippet = ""
81
+ try:
82
+ if parent is not None:
83
+ snippet = parent.get_text(" ", strip=True)[:300]
84
+ if snippet == title:
85
+ gp = parent.parent
86
+ snippet = gp.get_text(" ", strip=True)[:300] if gp is not None else ""
87
+ except Exception:
88
+ snippet = ""
89
+ if href.startswith("/"):
90
+ try:
91
+ href = urllib.parse.urljoin(base, href)
92
+ except Exception:
93
+ pass
94
+ if _is_spam_entry(href, title, snippet):
95
+ continue
96
+ candidates.append((title[:200], href[:500], snippet))
97
+ if len(candidates) >= max_items * 3:
98
+ break
99
+ seen: set[str] = set()
100
+ out: list[dict[str, Any]] = []
101
+ for title, url, snippet in candidates:
102
+ key = url or title
103
+ if not key or key in seen:
104
+ continue
105
+ seen.add(key)
106
+ out.append({
107
+ "title": title,
108
+ "url": url,
109
+ "snippet": snippet,
110
+ "score": round(max(default_score - len(out) * 0.05, 0.1), 3),
111
+ "source": engine_name,
112
+ "_fallback": True,
113
+ })
114
+ if len(out) >= max_items:
115
+ break
116
+ return out
117
+
118
+
119
+ def _parse_html(engine_name: str, html: str, spec: dict[str, Any],
120
+ maps: dict[str, Any]) -> list[dict[str, Any]]:
121
+ """HTML 解析映射:选择器容器抽取与属性提取。"""
122
+ html_maps = maps.get("html", {})
123
+ mapping = html_maps.get(engine_name, html_maps.get("default", {}))
124
+ container_sel = mapping.get("container")
125
+ title_sel = mapping.get("title")
126
+ url_sel = mapping.get("url")
127
+ snippet_sel = mapping.get("snippet")
128
+ url_attr = mapping.get("url_attr", "href")
129
+ default_score = mapping.get("score", 0.5)
130
+
131
+ if not container_sel:
132
+ return []
133
+
134
+ try:
135
+ from bs4 import BeautifulSoup
136
+ soup = BeautifulSoup(html, "html.parser")
137
+ containers = _select_with_bs4(soup, container_sel)
138
+ except Exception:
139
+ return []
140
+
141
+ results: list[dict[str, Any]] = []
142
+ base = spec.get("_base", spec.get("url", ""))
143
+ for idx, item in enumerate(containers):
144
+ try:
145
+ title_el = _select_first_with_bs4(item, title_sel) if title_sel else None
146
+ url_el = _select_first_with_bs4(item, url_sel) if url_sel else None
147
+ snippet_el = _select_first_with_bs4(item, snippet_sel) if snippet_sel else None
148
+
149
+ title = title_el.get_text(strip=True)[:200] if title_el else ""
150
+ url = ""
151
+ if url_el and url_el.has_attr(url_attr):
152
+ url = url_el[url_attr]
153
+ snippet = snippet_el.get_text(strip=True)[:300] if snippet_el else ""
154
+
155
+ if not title and not url:
156
+ continue
157
+
158
+ if url and url.startswith("/"):
159
+ url = urllib.parse.urljoin(base, url)
160
+
161
+ if _is_spam_entry(url, title, snippet):
162
+ continue
163
+
164
+ score = max(default_score - idx * 0.05, 0.1)
165
+ results.append({
166
+ "title": title,
167
+ "url": url,
168
+ "snippet": snippet,
169
+ "score": round(score, 3),
170
+ "source": engine_name,
171
+ })
172
+ except Exception:
173
+ continue
174
+ if not results:
175
+ try:
176
+ fb = _fallback_extract(html, engine_name, base, default_score=0.45, max_items=8)
177
+ if fb:
178
+ return fb
179
+ except Exception:
180
+ pass
181
+ return results
182
+
183
+
184
+ def _parse_xml(engine_name: str, text: str, maps: dict[str, Any],
185
+ is_rss: bool = False, normalize_date_fn=None) -> list[dict[str, Any]]:
186
+ """XML / RSS 解析:支持命名空间与 pubDate 提取。"""
187
+ if is_rss:
188
+ mapping = maps.get("rss", {}).get("default", {})
189
+ else:
190
+ mapping = maps.get("xml", {}).get(engine_name, {})
191
+
192
+ entry_path = mapping.get("entry_path") or mapping.get("item_path", ".//item")
193
+ title_tag = mapping.get("title", "title")
194
+ url_tag = mapping.get("url", "link")
195
+ snippet_tag = mapping.get("snippet", "description")
196
+ pub_tag = mapping.get("published_at", "pubDate")
197
+ namespaces = mapping.get("namespaces", {})
198
+
199
+ def _find_tag(entry: Any, tag: str) -> str:
200
+ if tag.startswith("atom:"):
201
+ tag_name = tag.split(":")[1]
202
+ ns = namespaces.get("atom")
203
+ node = entry.find(f"{{{ns}}}{tag_name}") if ns else None
204
+ return (node.text or "").strip() if node is not None else ""
205
+ node = entry.findtext(tag, default="")
206
+ return (node or "").strip()
207
+
208
+ results: list[dict[str, Any]] = []
209
+ try:
210
+ root = ET.fromstring(text)
211
+ except ET.ParseError:
212
+ return results
213
+
214
+ if entry_path.startswith(".//{"):
215
+ entries = root.findall(entry_path, namespaces)
216
+ else:
217
+ entries = root.findall(entry_path)
218
+
219
+ for idx, entry in enumerate(entries):
220
+ try:
221
+ title = _find_tag(entry, title_tag)
222
+ url = _find_tag(entry, url_tag)
223
+ snippet = _find_tag(entry, snippet_tag)
224
+ raw_pub = _find_tag(entry, pub_tag) if pub_tag else ""
225
+
226
+ title = re.sub(r"\s+", " ", title)[:200]
227
+ snippet = re.sub(r"\s+", " ", snippet)[:300]
228
+ score = max(0.7 - idx * 0.05, 0.1)
229
+ pub_at = normalize_date_fn(raw_pub) if normalize_date_fn else raw_pub
230
+ results.append({
231
+ "title": title,
232
+ "url": url,
233
+ "snippet": snippet,
234
+ "score": round(score, 3),
235
+ "source": engine_name,
236
+ "published_at": pub_at,
237
+ })
238
+ except Exception:
239
+ continue
240
+ return results
241
+
242
+
243
+ def _get_path(data: Any, path: str) -> Any:
244
+ """提取嵌套字典/列表路径,如 'items.0.title'。"""
245
+ if path == ".":
246
+ return data
247
+ obj = data
248
+ for part in path.split("."):
249
+ if part.isdigit() and isinstance(obj, (list, tuple)):
250
+ idx = int(part)
251
+ obj = obj[idx] if idx < len(obj) else None
252
+ elif isinstance(obj, dict):
253
+ obj = obj.get(part)
254
+ else:
255
+ return None
256
+ return obj
257
+
258
+
259
+ def _format_url(template: str | None, item: dict[str, Any], default: str = "") -> str:
260
+ """按模板格式化 URL 字段。"""
261
+ if not template:
262
+ return default
263
+ try:
264
+ return template.format(**item)
265
+ except (KeyError, IndexError):
266
+ return default
267
+
268
+
269
+ def _parse_json(engine_name: str, text: str, maps: dict[str, Any],
270
+ normalize_date_fn=None) -> list[dict[str, Any]]:
271
+ """JSON 响应解析:提取标题、URL、摘要及时间。"""
272
+ mapping = maps.get("json", {}).get(engine_name, {})
273
+ items_path = mapping.get("items", ".")
274
+ title_key = mapping.get("title")
275
+ url_key = mapping.get("url")
276
+ snippet_key = mapping.get("snippet")
277
+ url_template = mapping.get("url_template")
278
+ published_key = mapping.get("published_at")
279
+
280
+ results: list[dict[str, Any]] = []
281
+ try:
282
+ data = json.loads(text)
283
+ except json.JSONDecodeError:
284
+ return results
285
+
286
+ items = _get_path(data, items_path)
287
+ if not isinstance(items, list):
288
+ return results
289
+
290
+ for idx, item in enumerate(items):
291
+ if not isinstance(item, dict):
292
+ if engine_name == "local_pubmed" and isinstance(item, str):
293
+ item = {"pmid": item}
294
+ else:
295
+ continue
296
+
297
+ title = url = snippet = ""
298
+ if title_key:
299
+ raw = _get_path(item, title_key)
300
+ if isinstance(raw, list):
301
+ raw = raw[0] if raw else ""
302
+ title = str(raw or "")[:200]
303
+ if url_key:
304
+ raw = _get_path(item, url_key)
305
+ url = str(raw or "")[:500]
306
+ elif url_template:
307
+ url = _format_url(url_template, item)
308
+ if snippet_key:
309
+ raw = _get_path(item, snippet_key)
310
+ snippet = str(raw or "")[:300]
311
+ raw_pub = _get_path(item, published_key) if published_key else None
312
+ published_at = normalize_date_fn(raw_pub) if normalize_date_fn and raw_pub else raw_pub
313
+
314
+ title = re.sub(r"<[^>]+>", " ", title)
315
+ title = re.sub(r"\s+", " ", title).strip()
316
+ snippet = re.sub(r"<[^>]+>", " ", snippet)
317
+ snippet = re.sub(r"\s+", " ", snippet).strip()
318
+
319
+ score = max(0.7 - idx * 0.05, 0.1)
320
+ results.append({
321
+ "title": title,
322
+ "url": url,
323
+ "snippet": snippet,
324
+ "score": round(score, 3),
325
+ "source": engine_name,
326
+ "published_at": published_at,
327
+ })
328
+ return results