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.
- package/README.en.md +73 -150
- package/README.es.md +73 -150
- package/README.ja.md +79 -156
- package/README.ko.md +78 -155
- package/README.md +80 -159
- package/SKILL.md +3 -3
- package/assets/readme/hero.svg +2 -2
- package/backends/domain_profiles.json +48 -0
- package/backends/engine_registry.yaml +118 -4
- package/backends/quota_profiles.json +92 -12
- package/backends/source_types_cn.json +27 -1
- package/bin/argo +78 -5
- package/bin/argo.js +33 -2
- package/config.yaml +269 -63
- package/docs/ARGO_INTRO.md +5 -3
- package/docs/ENGINE_CATALOG.md +50 -39
- package/docs/MCP_SETUP.md +3 -1
- package/docs/RELEASE_NOTES_v2.9.8.md +73 -0
- package/docs/RELEASE_NOTES_v2.9.9.md +78 -0
- package/engines/specs/cfets.yaml +41 -0
- package/engines/specs/defillama.yaml +38 -0
- package/engines/specs/firecrawl.yaml +1 -1
- package/engines/specs/fxtwitter.yaml +5 -0
- package/engines/specs/gleif.yaml +55 -0
- package/engines/specs/hkex.yaml +40 -0
- package/engines/specs/keenable_free.yaml +36 -0
- package/engines/specs/shfe.yaml +39 -0
- package/engines/specs/stackexchange.yaml +2 -2
- package/engines/specs/tavily_free.yaml +56 -0
- package/engines/specs/yuanbao.yaml +93 -0
- package/mcp/clients.yaml +32 -0
- package/package.json +7 -2
- package/packages/dsh-plugin/README.md +25 -6
- package/packages/dsh-plugin/dsh/client.js +402 -47
- package/packages/dsh-plugin/dsh/diagnostics.js +97 -0
- package/packages/dsh-plugin/dsh/index.js +135 -1876
- package/packages/dsh-plugin/dsh/native-tools.mjs +7 -1
- package/packages/dsh-plugin/dsh/runner.js +666 -0
- package/packages/dsh-plugin/dsh/wide-research.js +878 -0
- package/packages/dsh-plugin/package.json +3 -3
- package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
- package/packages/dsh-plugin/test/client.test.js +177 -2
- package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
- package/packages/dsh-plugin/test/index.test.js +44 -0
- package/scripts/adaptive.py +126 -43
- package/scripts/article_meta.py +131 -0
- package/scripts/bench_tail_ab.py +281 -0
- package/scripts/bounded_run.py +91 -49
- package/scripts/budget.py +247 -0
- package/scripts/budget_cli.py +93 -0
- package/scripts/build_release_assets.sh +65 -0
- package/scripts/cache.py +60 -72
- package/scripts/ci_drift_gate.py +90 -0
- package/scripts/clock_bridge.py +22 -0
- package/scripts/content_boundary.py +46 -0
- package/scripts/content_security.py +119 -4
- package/scripts/content_signals.py +26 -5
- package/scripts/cross_lingual_trace.py +293 -0
- package/scripts/dispatch_result.py +105 -0
- package/scripts/engine_add.py +602 -0
- package/scripts/engine_dispatch.py +58 -86
- package/scripts/engine_env.py +5 -0
- package/scripts/engine_families.py +6 -0
- package/scripts/engine_policy.py +7 -7
- package/scripts/engines.py +21 -0
- package/scripts/engines_base.py +3 -3
- package/scripts/engines_builders.py +12 -0
- package/scripts/engines_builders_batch10.py +48 -3
- package/scripts/engines_builders_batch14.py +656 -0
- package/scripts/engines_builders_batch15.py +249 -0
- package/scripts/engines_builders_keyless.py +111 -0
- package/scripts/engines_builders_tech.py +0 -1
- package/scripts/evidence.py +31 -3
- package/scripts/evidence_loop.py +58 -22
- package/scripts/fetch_quality.py +13 -0
- package/scripts/fetch_v3.py +28 -3
- package/scripts/gen_routing_snapshot.py +11 -0
- package/scripts/http_client.py +24 -38
- package/scripts/install.ps1 +18 -0
- package/scripts/install.sh +16 -1
- package/scripts/job.py +3 -3
- package/scripts/local_seek.py +41 -5
- package/scripts/macro_countries.py +15 -0
- package/scripts/matrix_search_eval.py +192 -0
- package/scripts/mcp_compact.py +234 -0
- package/scripts/mcp_handlers.py +60 -642
- package/scripts/mcp_launch.sh +10 -8
- package/scripts/mcp_local.py +241 -0
- package/scripts/mcp_server.py +19 -14
- package/scripts/mcp_setup.py +33 -9
- package/scripts/mcp_tools.py +1 -0
- package/scripts/mcp_transport.py +1 -1
- package/scripts/mcp_warm.py +127 -0
- package/scripts/net_proxy.py +27 -0
- package/scripts/output_transparency.py +200 -0
- package/scripts/paid_policy.py +98 -0
- package/scripts/quota.py +158 -13
- package/scripts/recovery.py +7 -3
- package/scripts/recovery_runner.py +150 -0
- package/scripts/replay_eval.py +18 -0
- package/scripts/research.py +37 -21
- package/scripts/research_expand.py +24 -1
- package/scripts/retry_policy.py +76 -0
- package/scripts/route.py +6 -6
- package/scripts/route_cache.py +18 -0
- package/scripts/route_combo.py +26 -5
- package/scripts/route_domains.py +170 -31
- package/scripts/safe_conn.py +52 -0
- package/scripts/search.py +15 -27
- package/scripts/search_attribution.py +100 -3
- package/scripts/search_cli.py +24 -11
- package/scripts/search_entry.py +8 -1
- package/scripts/search_output.py +99 -15
- package/scripts/search_pipeline.py +62 -195
- package/scripts/search_rank.py +6 -0
- package/scripts/search_types.py +107 -9
- package/scripts/semantic_evidence.py +179 -3
- package/scripts/serp_guard.py +138 -7
- package/scripts/serp_kill_log.py +25 -2
- package/scripts/stats_cli.py +13 -1
- package/scripts/tail_completion.py +212 -0
- package/scripts/url_safety.py +126 -3
- package/scripts/usage_log.py +45 -0
- package/scripts/wide_research.py +45 -0
- package/scripts/zhihu_quota.py +139 -0
- package/sub-skills/local-search/bm25.py +27 -3
- package/sub-skills/local-search/cli_runner.py +331 -0
- package/sub-skills/local-search/parsers.py +328 -0
- package/sub-skills/local-search/search_v3.py +31 -674
- package/sub-skills/local-seek/scripts/seek_rank.py +7 -3
- package/assets/readme/settings-keys.png +0 -0
- package/assets/readme/settings-tools.png +0 -0
- package/sub-skills/ego-search/references/browser-runtime.md +0 -43
- package/sub-skills/ego-search/references/install.md +0 -67
- package/sub-skills/ego-search/references/original-ego-upgrade.md +0 -51
- package/sub-skills/ego-search/tests/test_query_normalize.py +0 -68
- package/sub-skills/js-run/tests/fixtures/challenge_like.js +0 -27
- package/sub-skills/js-run/tests/test_jsrun.py +0 -165
- package/sub-skills/local-seek/references/auto-behaviors.md +0 -24
- package/sub-skills/local-seek/references/fd-mdfind-recipes.md +0 -62
- package/sub-skills/local-seek/references/git-integration.md +0 -54
- package/sub-skills/local-seek/references/remote-handoff.md +0 -46
- package/sub-skills/local-seek/references/rg-recipes.md +0 -96
- package/sub-skills/local-seek/references/strategies.md +0 -52
- package/sub-skills/local-seek/references/structural-search.md +0 -59
- package/sub-skills/local-seek/tests/test_content_rank.py +0 -222
- package/sub-skills/local-seek/tests/test_determinism_20261006.py +0 -145
- package/sub-skills/local-seek/tests/test_noise_tier.py +0 -101
- package/sub-skills/local-seek/tests/test_pcre2_probe.py +0 -136
- package/sub-skills/local-seek/tests/test_rank_pinyin.py +0 -77
- package/sub-skills/local-seek/tests/test_regression_20261003.py +0 -169
- package/sub-skills/local-seek/tests/test_regression_20261006.py +0 -146
- package/sub-skills/local-seek/tests/test_seek_degradation.py +0 -111
- 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
|