argo-search 2.9.7 → 2.9.8

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 (140) hide show
  1. package/README.en.md +58 -145
  2. package/README.es.md +58 -145
  3. package/README.ja.md +64 -151
  4. package/README.ko.md +63 -150
  5. package/README.md +65 -154
  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 +74 -5
  13. package/bin/argo.js +33 -2
  14. package/config.yaml +219 -60
  15. package/docs/ENGINE_CATALOG.md +50 -39
  16. package/docs/MCP_SETUP.md +3 -1
  17. package/docs/RELEASE_NOTES_v2.9.8.md +73 -0
  18. package/engines/specs/cfets.yaml +41 -0
  19. package/engines/specs/defillama.yaml +38 -0
  20. package/engines/specs/firecrawl.yaml +1 -1
  21. package/engines/specs/fxtwitter.yaml +5 -0
  22. package/engines/specs/gleif.yaml +55 -0
  23. package/engines/specs/hkex.yaml +40 -0
  24. package/engines/specs/keenable_free.yaml +36 -0
  25. package/engines/specs/shfe.yaml +39 -0
  26. package/engines/specs/stackexchange.yaml +2 -2
  27. package/engines/specs/tavily_free.yaml +56 -0
  28. package/engines/specs/yuanbao.yaml +93 -0
  29. package/mcp/clients.yaml +32 -0
  30. package/package.json +6 -2
  31. package/packages/dsh-plugin/README.md +23 -5
  32. package/packages/dsh-plugin/dsh/client.js +304 -15
  33. package/packages/dsh-plugin/dsh/diagnostics.js +54 -0
  34. package/packages/dsh-plugin/dsh/index.js +112 -1875
  35. package/packages/dsh-plugin/dsh/native-tools.mjs +7 -1
  36. package/packages/dsh-plugin/dsh/runner.js +666 -0
  37. package/packages/dsh-plugin/dsh/wide-research.js +878 -0
  38. package/packages/dsh-plugin/package.json +2 -2
  39. package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
  40. package/packages/dsh-plugin/test/client.test.js +175 -1
  41. package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
  42. package/packages/dsh-plugin/test/index.test.js +44 -0
  43. package/scripts/adaptive.py +24 -1
  44. package/scripts/article_meta.py +131 -0
  45. package/scripts/budget.py +247 -0
  46. package/scripts/budget_cli.py +93 -0
  47. package/scripts/build_release_assets.sh +65 -0
  48. package/scripts/cache.py +60 -72
  49. package/scripts/ci_drift_gate.py +90 -0
  50. package/scripts/content_boundary.py +46 -0
  51. package/scripts/content_signals.py +26 -5
  52. package/scripts/cross_lingual_trace.py +293 -0
  53. package/scripts/dispatch_result.py +98 -0
  54. package/scripts/engine_dispatch.py +49 -86
  55. package/scripts/engine_env.py +5 -0
  56. package/scripts/engine_families.py +6 -0
  57. package/scripts/engine_policy.py +7 -7
  58. package/scripts/engines.py +21 -0
  59. package/scripts/engines_base.py +3 -3
  60. package/scripts/engines_builders.py +12 -0
  61. package/scripts/engines_builders_batch10.py +48 -3
  62. package/scripts/engines_builders_batch14.py +656 -0
  63. package/scripts/engines_builders_batch15.py +249 -0
  64. package/scripts/engines_builders_keyless.py +111 -0
  65. package/scripts/engines_builders_tech.py +0 -1
  66. package/scripts/evidence.py +31 -3
  67. package/scripts/evidence_loop.py +54 -22
  68. package/scripts/fetch_v3.py +25 -0
  69. package/scripts/gen_routing_snapshot.py +11 -0
  70. package/scripts/install.ps1 +18 -0
  71. package/scripts/install.sh +16 -1
  72. package/scripts/job.py +3 -3
  73. package/scripts/local_seek.py +41 -5
  74. package/scripts/macro_countries.py +15 -0
  75. package/scripts/matrix_search_eval.py +82 -0
  76. package/scripts/mcp_compact.py +234 -0
  77. package/scripts/mcp_handlers.py +60 -642
  78. package/scripts/mcp_launch.sh +10 -8
  79. package/scripts/mcp_local.py +241 -0
  80. package/scripts/mcp_server.py +19 -14
  81. package/scripts/mcp_setup.py +33 -9
  82. package/scripts/mcp_tools.py +1 -0
  83. package/scripts/mcp_transport.py +1 -1
  84. package/scripts/mcp_warm.py +127 -0
  85. package/scripts/output_transparency.py +200 -0
  86. package/scripts/paid_policy.py +98 -0
  87. package/scripts/quota.py +158 -13
  88. package/scripts/recovery_runner.py +150 -0
  89. package/scripts/replay_eval.py +18 -0
  90. package/scripts/research.py +37 -21
  91. package/scripts/research_expand.py +24 -1
  92. package/scripts/retry_policy.py +76 -0
  93. package/scripts/route.py +6 -6
  94. package/scripts/route_cache.py +18 -0
  95. package/scripts/route_combo.py +7 -0
  96. package/scripts/route_domains.py +32 -19
  97. package/scripts/search.py +15 -27
  98. package/scripts/search_attribution.py +100 -3
  99. package/scripts/search_cli.py +24 -11
  100. package/scripts/search_entry.py +7 -1
  101. package/scripts/search_output.py +99 -15
  102. package/scripts/search_pipeline.py +42 -193
  103. package/scripts/search_types.py +107 -9
  104. package/scripts/semantic_evidence.py +179 -3
  105. package/scripts/serp_guard.py +115 -7
  106. package/scripts/serp_kill_log.py +25 -2
  107. package/scripts/stats_cli.py +13 -1
  108. package/scripts/wide_research.py +45 -0
  109. package/scripts/zhihu_quota.py +139 -0
  110. package/sub-skills/local-search/bm25.py +27 -3
  111. package/sub-skills/local-search/cli_runner.py +331 -0
  112. package/sub-skills/local-search/parsers.py +328 -0
  113. package/sub-skills/local-search/search_v3.py +31 -674
  114. package/sub-skills/local-seek/scripts/seek_rank.py +7 -3
  115. package/assets/readme/settings-keys.png +0 -0
  116. package/assets/readme/settings-tools.png +0 -0
  117. package/scripts/image_vision/vision_probe +0 -0
  118. package/scripts/local_engines.py +0 -321
  119. package/sub-skills/ego-search/references/browser-runtime.md +0 -43
  120. package/sub-skills/ego-search/references/install.md +0 -67
  121. package/sub-skills/ego-search/references/original-ego-upgrade.md +0 -51
  122. package/sub-skills/ego-search/tests/test_query_normalize.py +0 -68
  123. package/sub-skills/js-run/tests/fixtures/challenge_like.js +0 -27
  124. package/sub-skills/js-run/tests/test_jsrun.py +0 -165
  125. package/sub-skills/local-seek/references/auto-behaviors.md +0 -24
  126. package/sub-skills/local-seek/references/fd-mdfind-recipes.md +0 -62
  127. package/sub-skills/local-seek/references/git-integration.md +0 -54
  128. package/sub-skills/local-seek/references/remote-handoff.md +0 -46
  129. package/sub-skills/local-seek/references/rg-recipes.md +0 -96
  130. package/sub-skills/local-seek/references/strategies.md +0 -52
  131. package/sub-skills/local-seek/references/structural-search.md +0 -59
  132. package/sub-skills/local-seek/tests/test_content_rank.py +0 -222
  133. package/sub-skills/local-seek/tests/test_determinism_20261006.py +0 -145
  134. package/sub-skills/local-seek/tests/test_noise_tier.py +0 -101
  135. package/sub-skills/local-seek/tests/test_pcre2_probe.py +0 -136
  136. package/sub-skills/local-seek/tests/test_rank_pinyin.py +0 -77
  137. package/sub-skills/local-seek/tests/test_regression_20261003.py +0 -169
  138. package/sub-skills/local-seek/tests/test_regression_20261006.py +0 -146
  139. package/sub-skills/local-seek/tests/test_seek_degradation.py +0 -111
  140. package/sub-skills/local-seek/tests/test_seek_fts.py +0 -313
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env python3
2
+ """批次十五构建器:腾讯元宝搜索(2026-10-21)。
3
+
4
+ yuanbao 腾讯元宝搜索(api.wsa.cloud.tencent.com/SearchPro,需 key)
5
+
6
+ 为什么需要独立 builder(声明式 type: http 表达不了):
7
+
8
+ 1. **Pages 是 JSON 字符串数组**——`Response.Pages[i]` 本身是 JSON 文本,
9
+ 要二次解析;output_map 只能走普通嵌套对象。
10
+ 2. **错误走 HTTP 200**——鉴权失败/欠费/限流全部返回 200 + `Response.Error`。
11
+ 声明式解析会把它当「0 结果」,把「key 错了」上报成「网上没有这个信息」
12
+ (与 2026-09-15 `--engine you` 的 SSL 超时同族,那条踩过一次)。
13
+ 3. **日期是 `YYYY/MM/DD HH:MM:SS`**——不是 argo 的 ISO 约定,要归一。
14
+ 4. **Cnt 只接受 10/20/30/50**——传别的值上游直接回 `illegal Cnt` 且 0 结果。
15
+
16
+ 接口事实(2026-10-21 本机实测,非文档抄录):
17
+
18
+ POST /SearchPro
19
+ Authorization: Bearer <key>;Content-Type: application/json; charset=utf-8
20
+ body {"Query": "...", "Cnt": N}
21
+ 返回 Response.{RequestId, Query, Version, Pages[], Error?}
22
+
23
+ 单条 Pages[i] 解析后:
24
+ {passage, score, date, title, url, content, site, favicon, pics}
25
+ (文档「输出示例」写的是 images,实测字段名是 pics;README 与文档
26
+ 不一致时以实测为准。)
27
+
28
+ 错误码 → argo 失败类别:
29
+ UnauthorizedOperation / ResourceNotFound → auth (key 错 / 服务未开通)
30
+ ResourceUnavailable → quota_exhausted(账号欠费)
31
+ RequestLimitExceeded → rate_limited
32
+ InvalidParameter → upstream (接口契约变了)
33
+ InternalError / 其他 → unknown
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import re
40
+ import urllib.request
41
+ from datetime import datetime, timedelta, timezone
42
+ from typing import Any
43
+
44
+ from engines_base import http_open, note_failure, safe_search
45
+
46
+ _UA = "argo-search/2.9 (unified-search@local)"
47
+ _YUANBAO_URL = "https://api.wsa.cloud.tencent.com/SearchPro"
48
+ # 腾讯侧公布的中间价/新闻口径都按北京时间;本机可能不在东八区,日期归一
49
+ # 必须显式指定偏移,否则跨时区机器上「今天」会错一天。
50
+ _CST = timezone(timedelta(hours=8))
51
+
52
+ # key 候选链(真源在 engine_env.KNOWN_ENV_ALIASES,这里只声明本引擎要用哪条)。
53
+ # ⚠️ 与下方 get_env 调用里的**字面量列表**必须一致——状态层的变体感知契约由
54
+ # tests/test_env_declaration_consistency.py 静态扫描,它只认写死在调用处的
55
+ # ast.Constant/List/Tuple;写成 `get_env(_KEY_ENV)` 会被判成「声明了却没读到」。
56
+ # tests/test_yuanbao_engine.py 有一条把两者钉在一起。
57
+ _KEY_ENV = ("ARGO_YUANBAO_API_KEY", "TENCENTCLOUD_WSA_APIKEY", "YUANBAO_API_KEY")
58
+
59
+ # Cnt 实测只接受 10/20/30/50(1/2/5/100 均回 "illegal Cnt")。
60
+ _CNT_ALLOWED = (10, 20, 30, 50)
61
+
62
+ _ERROR_MAP: dict[str, tuple[str, str]] = {
63
+ "UnauthorizedOperation": ("auth", "key 无效或未授权"),
64
+ "ResourceNotFound": ("auth", "服务未开通"),
65
+ "ResourceUnavailable": ("quota_exhausted", "账号欠费或资源不可用"),
66
+ "RequestLimitExceeded": ("rate_limited", "超出频率限制"),
67
+ "InvalidParameter": ("upstream", "参数被拒(接口契约可能已变)"),
68
+ "InternalError": ("unknown", "上游内部错误"),
69
+ }
70
+
71
+ _TAG_RE = re.compile(r"</?[a-zA-Z][^>]{0,40}>")
72
+
73
+
74
+ def yuanbao_api_key() -> str:
75
+ """按候选链取 key(os.environ 优先,~/.config/argo/env 热读保底)。
76
+
77
+ 候选链以字面量落在 get_env 调用里(原因见 _KEY_ENV 上方注释)。
78
+ """
79
+ try:
80
+ from engine_env import get_env
81
+ except ImportError:
82
+ import os
83
+ for name in _KEY_ENV:
84
+ v = (os.environ.get(name) or "").strip()
85
+ if v:
86
+ return v
87
+ return ""
88
+ return (get_env(["ARGO_YUANBAO_API_KEY", "TENCENTCLOUD_WSA_APIKEY",
89
+ "YUANBAO_API_KEY"]) or "").strip()
90
+
91
+
92
+ def yuanbao_cnt(n: int) -> int:
93
+ """把请求条数夹到上游允许的档位。
94
+
95
+ 取「能覆盖 n 的最小合法档」而不是最大档:额度按次计,多要不产生额外
96
+ 结果价值(本地还会按 n 截断),少要则可能不够。n 超出最大档时给最大档,
97
+ 由本地截断兜底。
98
+ """
99
+ try:
100
+ want = int(n)
101
+ except (TypeError, ValueError):
102
+ want = 5
103
+ if want <= 0:
104
+ want = 5
105
+ for v in _CNT_ALLOWED:
106
+ if want <= v:
107
+ return v
108
+ return _CNT_ALLOWED[-1]
109
+
110
+
111
+ def _clean(text: Any, limit: int | None = None) -> str:
112
+ out = _TAG_RE.sub("", str(text or "")).strip()
113
+ if limit is not None and len(out) > limit:
114
+ out = out[:limit].rstrip()
115
+ return out
116
+
117
+
118
+ def _norm_date(raw: Any) -> str:
119
+ """`YYYY/MM/DD HH:MM:SS`(东八区)→ ISO 带偏移;无法解析返回空串。
120
+
121
+ 交出去空串而不是原样字符串:argo 的 freshness/时间窗按 ISO 或年月解析,
122
+ 把 `2023/06/08 00:00:00` 原样当 published_at 会落到「有字段但读不出日期」
123
+ 那一类——比没有更糟(时间窗会当它无日期剔除,但浏览者会以为有日期)。
124
+ """
125
+ text = str(raw or "").strip()
126
+ if not text:
127
+ return ""
128
+ for fmt in ("%Y/%m/%d %H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y/%m/%d", "%Y-%m-%d"):
129
+ try:
130
+ return datetime.strptime(text, fmt).replace(tzinfo=_CST).isoformat(
131
+ timespec="seconds")
132
+ except ValueError:
133
+ continue
134
+ return ""
135
+
136
+
137
+ def _load_page(raw: Any) -> dict[str, Any] | None:
138
+ """Pages[i] 是 JSON 文本(实测),兼容上游改成对象的情形。"""
139
+ if isinstance(raw, dict):
140
+ return raw
141
+ if not isinstance(raw, str) or not raw.strip():
142
+ return None
143
+ try:
144
+ obj = json.loads(raw)
145
+ except (json.JSONDecodeError, TypeError):
146
+ return None
147
+ return obj if isinstance(obj, dict) else None
148
+
149
+
150
+ def parse_yuanbao_payload(data: Any, n: int, eng: str = "yuanbao"
151
+ ) -> list[dict[str, Any]]:
152
+ """响应体 → argo 结果条目(纯函数,门禁直接打到)。
153
+
154
+ 错误路径**必须**留痕:上游把 4xx/5xx 语义全塞进 HTTP 200 的
155
+ `Response.Error`,不留痕就会退化成 `no-results`,把「欠费」报成
156
+ 「网上没有」。分类交给 retry_policy 的既有映射,不在此另立一套。
157
+ """
158
+ resp = data.get("Response") if isinstance(data, dict) else None
159
+ if not isinstance(resp, dict):
160
+ note_failure(eng, "upstream", "no-response", str(data)[:160])
161
+ return []
162
+
163
+ err = resp.get("Error")
164
+ if isinstance(err, dict):
165
+ code = str(err.get("Code") or "").strip()
166
+ category, reason = _ERROR_MAP.get(code, ("unknown", code or "unknown"))
167
+ note_failure(eng, category, reason,
168
+ f"{code}: {err.get('Message') or ''}")
169
+ return []
170
+
171
+ pages = resp.get("Pages")
172
+ if not isinstance(pages, list):
173
+ note_failure(eng, "upstream", "pages-not-list",
174
+ f"Pages 类型 {type(pages).__name__}")
175
+ return []
176
+
177
+ limit = max(1, int(n or 5))
178
+ out: list[dict[str, Any]] = []
179
+ for raw in pages:
180
+ if len(out) >= limit:
181
+ break
182
+ item = _load_page(raw)
183
+ if not item:
184
+ continue
185
+ url = str(item.get("url") or "").strip()
186
+ title = _clean(item.get("title"), 160)
187
+ if not url or not title:
188
+ continue
189
+ # passage 是摘要档(实测 ~800 字),content 是更长正文(~2000 字)。
190
+ # 搜索层只交摘要:正文按需走 fetch/verify 链,与 DeepSeek 引擎同口径。
191
+ snippet = _clean(item.get("passage")) or _clean(item.get("content"), 300)
192
+ row: dict[str, Any] = {
193
+ "title": title,
194
+ "url": url,
195
+ "snippet": snippet[:600],
196
+ "source": eng,
197
+ }
198
+ site = _clean(item.get("site"), 60)
199
+ if site:
200
+ row["site_name"] = site
201
+ published = _norm_date(item.get("date"))
202
+ if published:
203
+ row["published_at"] = published
204
+ score = item.get("score")
205
+ if isinstance(score, (int, float)) and not isinstance(score, bool):
206
+ row["upstream_score"] = float(score)
207
+ out.append(row)
208
+ return out
209
+
210
+
211
+ def _build_yuanbao_engine(spec: dict[str, Any]) -> Any:
212
+ timeout = spec.get("timeout", 12)
213
+ eng = spec.get("_name", "yuanbao")
214
+ endpoint = spec.get("url") or _YUANBAO_URL
215
+
216
+ @safe_search
217
+ def _engine(query: str, n: int = 5, _timeout: float | None = None,
218
+ depth: str = "fast", **kwargs) -> list[dict[str, Any]]:
219
+ to = _timeout or timeout
220
+ q = (query or "").strip()
221
+ if not q:
222
+ return []
223
+ key = yuanbao_api_key()
224
+ if not key:
225
+ # 缺 key 与「配置错了」要分开:这条是缺配置,路由层已有 env 拦截,
226
+ # 走到这里说明是显式 --engine 调用,留痕让错误可行动。
227
+ note_failure(eng, "auth", "missing-key",
228
+ "未配置 " + " / ".join(_KEY_ENV))
229
+ return []
230
+ body = json.dumps({"Query": q, "Cnt": yuanbao_cnt(n)},
231
+ ensure_ascii=False).encode("utf-8")
232
+ req = urllib.request.Request(
233
+ endpoint, data=body, method="POST",
234
+ headers={
235
+ "Authorization": f"Bearer {key}",
236
+ "Content-Type": "application/json; charset=utf-8",
237
+ "User-Agent": _UA,
238
+ "Accept": "application/json",
239
+ },
240
+ )
241
+ try:
242
+ with http_open(req, timeout=to, engine=eng) as resp:
243
+ data = json.loads(resp.read().decode("utf-8", "replace"))
244
+ except Exception as e:
245
+ note_failure(eng, "network", "exception", str(e)[:160])
246
+ return []
247
+ return parse_yuanbao_payload(data, n, eng)
248
+
249
+ return _engine
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python3
2
+ """免 key(keyless)通道构建器:官方匿名额度源。
3
+
4
+ 与计费通道分开注册的理由:同上游、不同经济模型。匿名额度不读 key,
5
+ 按 IP/匿名池限流;与账号档混在一个引擎里会让配额与成本记账失真
6
+ (与 parallel / parallel_free 的既有处理一致)。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ import urllib.request
14
+ from typing import Any
15
+
16
+ from engines_base import http_open, safe_search
17
+
18
+ from engines_builders_tech import _mcp_error_of, _parse_mcp_wire
19
+
20
+
21
+ # ── Keenable 免 key 通道(官方 MCP 匿名额度)────────────────────────────────
22
+
23
+ _KEENABLE_MCP_URL = "https://api.keenable.ai/mcp"
24
+ _KEENABLE_BLOCK_RE = re.compile(r"\n\s*-{3,}\s*\n")
25
+
26
+
27
+ def _keenable_block_to_result(block: str) -> dict[str, Any] | None:
28
+ """Keenable MCP 的纯文本块 → 统一结果。字段以行首标签分隔。"""
29
+ text = (block or "").strip()
30
+ if not text:
31
+ return None
32
+ title = re.search(r"(?m)^Title:\s*(.+?)\s*$", text)
33
+ url = re.search(r"(?m)^URL:\s*(\S+)\s*$", text)
34
+ if not title or not url:
35
+ return None
36
+ published = re.search(r"(?m)^Published:\s*(\S+)", text)
37
+ body = ""
38
+ m = re.search(r"(?ms)^Snippets:\s*\n?(.*)$", text)
39
+ if m:
40
+ body = m.group(1)
41
+ body = re.sub(r"\[\.\.\.\]", " ", body)
42
+ body = re.sub(r"\s+", " ", body).strip()
43
+ row: dict[str, Any] = {
44
+ "title": title.group(1).strip()[:200],
45
+ "url": url.group(1).strip(),
46
+ "snippet": body[:300],
47
+ "source": "keenable_free",
48
+ }
49
+ if published:
50
+ row["published_at"] = published.group(1).strip()[:32]
51
+ return row
52
+
53
+
54
+ def _build_keenable_free_engine(spec: dict[str, Any]) -> Any:
55
+ """Keenable 免 key:POST 官方 MCP(tools/call search_web_pages)。
56
+
57
+ 为什么单独成引擎而不是让 keenable 回退:与 tavily_free / parallel_free
58
+ 同一条口径——keyless 是匿名额度通道,与按 credits 计费的 REST 通道
59
+ 经济模型不同,合进一个引擎会让配额与成本记账混在一起。
60
+
61
+ 上游返回纯文本块(Title/URL/Published/Snippets,`---` 分隔),不是
62
+ JSON;解析失败按「带 error 的记录」上报,让调度层知道是通道/格式问题,
63
+ 而不是「网上没有」。
64
+ """
65
+ timeout = spec.get("timeout", 20)
66
+
67
+ @safe_search
68
+ def _engine(query: str, n: int = 5, _timeout: float | None = None,
69
+ **kwargs) -> list[dict[str, Any]]:
70
+ to = _timeout or timeout
71
+ body = json.dumps({
72
+ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
73
+ "params": {"name": "search_web_pages",
74
+ "arguments": {"query": query, "mode": "realtime"}},
75
+ }).encode("utf-8")
76
+ headers = {
77
+ "Content-Type": "application/json",
78
+ "Accept": "application/json, text/event-stream",
79
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
80
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
81
+ "Chrome/120.0.0.0 Safari/537.36",
82
+ }
83
+ req = urllib.request.Request(_KEENABLE_MCP_URL, data=body, headers=headers)
84
+ try:
85
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
86
+ raw = resp.read().decode("utf-8", "replace")
87
+ except Exception as e:
88
+ return [{"error": f"keenable_free mcp: {type(e).__name__}: {e}",
89
+ "source": "keenable_free"}]
90
+ data = _parse_mcp_wire(raw)
91
+ if not isinstance(data, dict):
92
+ return [{"error": "keenable_free: 响应不是可解析的 MCP 应答",
93
+ "source": "keenable_free"}]
94
+ mcp_err = _mcp_error_of(data)
95
+ if mcp_err:
96
+ return [{"error": mcp_err, "source": "keenable_free"}]
97
+ content = ((data.get("result") or {}).get("content") or [])
98
+ text = "".join(str(b.get("text") or "") for b in content
99
+ if isinstance(b, dict))
100
+ if not text.strip():
101
+ return []
102
+ out: list[dict[str, Any]] = []
103
+ for block in _KEENABLE_BLOCK_RE.split("\n" + text):
104
+ row = _keenable_block_to_result(block)
105
+ if row:
106
+ out.append(row)
107
+ if len(out) >= max(1, int(n)):
108
+ break
109
+ return out
110
+
111
+ return _engine
@@ -1357,4 +1357,3 @@ def _build_bing_rss_engine(spec: dict[str, Any]) -> Any:
1357
1357
  out.append(r)
1358
1358
  return out
1359
1359
  return _engine
1360
-
@@ -370,6 +370,26 @@ def _age_to_score(age_years: int, year: int, precise: bool) -> tuple[float, str]
370
370
 
371
371
  # ── 交叉验证 ──────────────────────────────────────────────────────────────────
372
372
 
373
+ # 单条结果至少覆盖 20% 的 query 单元才算「与查询相关」。与 serp_guard 的
374
+ # SERP 覆盖率门同量级,但两处判据独立:这里回答「这批结果里几条在答这题」,
375
+ # serp_guard 回答「这一页 SERP 是不是整体答非所问」。
376
+ _RELEVANCE_MIN_COVERAGE = 0.2
377
+
378
+
379
+ def _relevance_units(text: str) -> set[str]:
380
+ """查询/结果 → 相关性比对单元(CJK 二元组 + 小写拉丁数字词)。
381
+
382
+ 复用 rank_signals.relevance_units,避免再维护第三套分词口径。
383
+ import 放在函数内:evidence 不是搜索热路径,但 rank_signals 较重,
384
+ 模块级导入会让所有 evidence 调用方一起付启动成本。
385
+ """
386
+ try:
387
+ from rank_signals import relevance_units
388
+ return set(relevance_units(text or ""))
389
+ except ImportError:
390
+ return {(text or "").lower()}
391
+
392
+
373
393
  def cross_validate(results: list[dict[str, Any]], query: str) -> dict[str, Any]:
374
394
  """多来源交叉验证。"""
375
395
  if len(results) < 2:
@@ -399,11 +419,19 @@ def cross_validate(results: list[dict[str, Any]], query: str) -> dict[str, Any]:
399
419
  if not is_serp_or_jump_url(url):
400
420
  content_domains.add(domain)
401
421
 
402
- query_words = set(query.lower().split())
422
+ # 旧口径 `any(w in text for w in query.lower().split())` 在中文查询上
423
+ # 两个方向都错:无空格中文被当成一个整词,结果里永远不会出现整句,
424
+ # 真实相关也报 0/N;反过来,结果只要带「2026」这类年份就被算作相关,
425
+ # 年份表垃圾页也能过。改用 CJK 二元组覆盖率(issue #18 的「0/N」信号
426
+ # 只有在这个口径下才可信)。
427
+ q_units = _relevance_units(query)
403
428
  content_matches = 0
404
429
  for r in results:
405
- text = f"{r.get('title', '')} {r.get('snippet', '')}".lower()
406
- if any(w in text for w in query_words if len(w) > 1):
430
+ text = f"{r.get('title', '')} {r.get('snippet', '')}"
431
+ if not q_units:
432
+ continue
433
+ hit = q_units & _relevance_units(text)
434
+ if len(hit) / len(q_units) >= _RELEVANCE_MIN_COVERAGE:
407
435
  content_matches += 1
408
436
 
409
437
  match_ratio = content_matches / max(len(results), 1)
@@ -19,6 +19,8 @@ MECE 分工(互不重叠):
19
19
 
20
20
  from __future__ import annotations
21
21
 
22
+ import os
23
+ import time
22
24
  from typing import Any, Callable, Optional
23
25
 
24
26
 
@@ -34,6 +36,23 @@ def _log(message: str) -> None:
34
36
  import logging
35
37
  logging.getLogger("unified_search.evidence_loop").debug(message)
36
38
 
39
+
40
+ # verify 的全局墙钟预算(秒)。0 = 关闭,逐 URL 的 8s 降级链照旧。
41
+ #
42
+ # why(2026-10-22 实测):--verify 3 的最坏一次 9.7s,而单 URL 的 fetch 链
43
+ # deadline 是 8s(见 fetch_v3 的「deadline + 5s 上界」),3 个 URL 并行时
44
+ # 墙钟由最慢那个定义。结果是:主搜索 1.2s,核验尾巴 9.7s,而调用方在
45
+ # timing.elapsed_ms 里只看到 1.2s。核验是「加分项」不是「必须项」:到点
46
+ # 未回来的 URL 进 pending(语义与 fetch 失败完全一致),已回来的照常回填。
47
+ # 默认 5s 高于典型核验 2.9s,只砍尾部;需要完整降级链时设 0。
48
+ def _verify_budget_s() -> float:
49
+ try:
50
+ raw = os.environ.get("ARGO_VERIFY_BUDGET_S", "5") or "5"
51
+ value = float(raw)
52
+ except ValueError:
53
+ return 5.0
54
+ return value if value > 0 else 0.0
55
+
37
56
  # ── 高后果域(finance / health / legal / 事实与安全)──────────────────────────
38
57
  # 命中这些域时,Agent 在把搜索结果当答案前必须先核验正文。
39
58
  HIGH_CONSEQUENCE_DOMAINS: frozenset[str] = frozenset({
@@ -516,33 +535,46 @@ def verify_results(results: list[dict[str, Any]],
516
535
  # 语义层(默认关)的输入:正文已在内存,批量一次调用;关闭时不产生任何行为
517
536
  scored_inputs: list[dict[str, Any]] = []
518
537
 
519
- if len(targets) > 1:
538
+ def _consume(r: dict, ev: dict | None, text: str) -> None:
539
+ """把一条 fetch 结果记进 verified/pending;成功才产生语义输入。"""
540
+ if ev is None:
541
+ pending.append(r.get("url") or "")
542
+ return
543
+ # 不再在这里重复写证据:fetch_fn(fetch_v3)成功时已把证据分
544
+ # 并入该 URL 的正文条目。此处再写一遍是同源同刻的双写,
545
+ # 合并存储后还会因为「没有正文条目」而变成静默空操作。
546
+ _record_verify(r, ev, verified, revisions)
547
+ if text.strip():
548
+ scored_inputs.append({"url": r.get("url") or "",
549
+ "title": r.get("title") or "",
550
+ "text": text})
551
+
552
+ budget_s = _verify_budget_s()
553
+ if targets and budget_s > 0:
554
+ # 硬墙钟:到点就返回,未完成的 URL 进 pending,不 join 卡住的线程。
555
+ # 预算只约束「这次核验等多久」,不改单 URL 的降级链语义。
556
+ from bounded_run import TaskError, run_bounded
557
+ finished, unfinished = run_bounded(
558
+ targets, _fetch_one, budget_s,
559
+ max_workers=min(len(targets), 3))
560
+ for item, value in finished:
561
+ if isinstance(value, TaskError):
562
+ _log(f"verify fetch 任务异常 {item.get('url') or ''}: "
563
+ f"{type(value.exc).__name__}")
564
+ pending.append(item.get("url") or "")
565
+ continue
566
+ _consume(*value)
567
+ for item in unfinished:
568
+ # 与 fetch 失败同语义:拿不到正文就标 pending,不伪造已核验
569
+ pending.append(item.get("url") or "")
570
+ elif len(targets) > 1:
520
571
  with ThreadPoolExecutor(max_workers=min(len(targets), 3)) as ex:
521
572
  futures = [ex.submit(_fetch_one, r) for r in targets]
522
573
  for fut in as_completed(futures):
523
- r, ev, text = fut.result()
524
- if ev is None:
525
- pending.append(r.get("url") or "")
526
- continue
527
- # 不再在这里重复写证据:fetch_fn(fetch_v3)成功时已把证据分
528
- # 并入该 URL 的正文条目。此处再写一遍是同源同刻的双写,
529
- # 合并存储后还会因为「没有正文条目」而变成静默空操作。
530
- _record_verify(r, ev, verified, revisions)
531
- if text.strip():
532
- scored_inputs.append({"url": r.get("url") or "",
533
- "title": r.get("title") or "",
534
- "text": text})
574
+ _consume(*fut.result())
535
575
  else:
536
576
  for r in targets:
537
- r2, ev, text = _fetch_one(r)
538
- if ev is None:
539
- pending.append(r2.get("url") or "")
540
- continue
541
- _record_verify(r2, ev, verified, revisions)
542
- if text.strip():
543
- scored_inputs.append({"url": r2.get("url") or "",
544
- "title": r2.get("title") or "",
545
- "text": text})
577
+ _consume(*_fetch_one(r))
546
578
 
547
579
  semantic_meta: dict[str, Any] | None = None
548
580
  if scored_inputs:
@@ -865,6 +865,14 @@ def _make_result(url: str, html: str, max_chars: int,
865
865
  "error": error,
866
866
  "fetch_method": method,
867
867
  }
868
+ # 元数据必须从**完整 HTML** 里提:delivered_html 已按 max_chars*2 截断,
869
+ # 链接通常在正文中后段,截断后再解析只剩 head 里的 meta(2026-10-22 实测)。
870
+ if ok and html:
871
+ try:
872
+ from article_meta import extract_article_meta
873
+ result.update(extract_article_meta(html, url))
874
+ except Exception:
875
+ pass
868
876
  result.update(extra)
869
877
  return result
870
878
 
@@ -1530,6 +1538,17 @@ def fetch_v3(url: str, max_chars: int = 8000, timeout: float = 8.0,
1530
1538
  # 第三级:质量评估
1531
1539
  result = _assess_quality(result)
1532
1540
 
1541
+ # 文章级元数据(链接/作者/站点名/摘要):与正文同源于本次 HTML,一次解析
1542
+ # 拿全;调用方不必为了顺着引用继续取证而重抓页面。缓存一起存(链接上限
1543
+ # 30 条,体积可控),输出侧由 CLI/MCP 决定是否展示。
1544
+ if result.get("success") and result.get("html"):
1545
+ try:
1546
+ from article_meta import extract_article_meta
1547
+ for _mk, _mv in extract_article_meta(result["html"], url).items():
1548
+ result.setdefault(_mk, _mv)
1549
+ except Exception:
1550
+ pass
1551
+
1533
1552
  # 写 URL 缓存(不存大块 html,省空间)
1534
1553
  if not skip_cache and result.get("success"):
1535
1554
  try:
@@ -1693,6 +1712,8 @@ def build_parser():
1693
1712
  help="从正文第 N 个字符开始输出(配合 --limit 翻页读长文)")
1694
1713
  p.add_argument("--limit", type=int, default=0,
1695
1714
  help="本次输出字符数上限;0 表示不额外限制")
1715
+ p.add_argument("--include-links", action="store_true",
1716
+ help="JSON 摘要附带正文链接(默认只给 links_count,省 token)")
1696
1717
  p.add_argument("--json", action="store_true",
1697
1718
  help="只输出 JSON 摘要(缺省还会附人类可读的正文段;"
1698
1719
  "Usage 一直把 --json 列为 Common flags,此前却报"
@@ -1740,6 +1761,10 @@ if __name__ == "__main__":
1740
1761
  summary["focus_applied"] = bool(r.get("focus_applied"))
1741
1762
  if r.get("error"):
1742
1763
  summary["error"] = r["error"]
1764
+ if r.get("links"):
1765
+ summary["links_count"] = len(r["links"])
1766
+ if args.include_links:
1767
+ summary["links"] = r["links"]
1743
1768
  print(dumps(summary))
1744
1769
  # __main__ 块是模块级代码,不能用 return 短路——用 if 包住人类可读段
1745
1770
  if not args.json:
@@ -29,9 +29,20 @@ from __future__ import annotations
29
29
 
30
30
  import argparse
31
31
  import json
32
+ import os
32
33
  import sys
34
+ import tempfile
33
35
  from pathlib import Path
34
36
 
37
+ # 与 tests/conftest.py 对齐:快照对账必须**确定性**。默认状态目录
38
+ # (~/.cache/unified-search)含本机历史(学习器分数 / 熔断器计数 / 配额),
39
+ # 会让「生成时的路由」与「pytest 里的路由」不一致——实测 2026-10-22
40
+ # 「settlement 结算 周期」在两种环境下分别是 regex 与 tfidf,门禁稳定假红
41
+ # (单独跑 --check 通过、pytest 里 3/3 红,根因就是两边状态目录不同)。
42
+ # 隔离到临时目录后两边都从空状态出发,逐条一致。调用方显式设置
43
+ # ARGO_STATE_DIR 时尊重之(便于复现某个具体状态)。
44
+ os.environ.setdefault("ARGO_STATE_DIR", tempfile.mkdtemp(prefix="argo-snapshot-"))
45
+
35
46
  ROOT = Path(__file__).resolve().parent.parent
36
47
  SCRIPTS = ROOT / "scripts"
37
48
  TESTS = ROOT / "tests"
@@ -77,6 +77,15 @@ if (Test-Path (Join-Path $InstallDir ".git")) {
77
77
  Write-Host "==> 克隆仓库…"
78
78
  New-Item -ItemType Directory -Force -Path (Split-Path $InstallDir) | Out-Null
79
79
  git clone --depth 1 --branch $Branch $Repo $InstallDir
80
+ if ($LASTEXITCODE -ne 0 -and $Repo -like "*github.com*") {
81
+ $MirrorRepo = "https://ghfast.top/" + $Repo
82
+ Write-Host "[warn] 直连 GitHub 失败,尝试加速镜像: $MirrorRepo …"
83
+ git clone --depth 1 --branch $Branch $MirrorRepo $InstallDir
84
+ if ($LASTEXITCODE -ne 0) {
85
+ Write-Error "克隆失败。若处于受限网络环境,可尝试手动克隆或设置 ARGO_REPO 环境变量。"
86
+ exit 1
87
+ }
88
+ }
80
89
  }
81
90
 
82
91
  # 供应链加固:固定 commit 校验(ARGO_PIN),与 install.sh 对齐
@@ -94,11 +103,20 @@ if ($Pin) {
94
103
  if (-not $SkipPip) {
95
104
  Write-Host "==> 安装依赖 (PyYAML)…"
96
105
  & $script:py -m pip install pyyaml
106
+ if ($LASTEXITCODE -ne 0) {
107
+ & $script:py -m pip install -i https://pypi.tuna.tsinghua.edu.cn/simple pyyaml
108
+ }
97
109
  if ($LASTEXITCODE -ne 0) { Write-Host "[warn] pip 安装 PyYAML 失败,可手动: pip install pyyaml" }
98
110
  Write-Host "==> 安装可选增强 (curl_cffi: TLS 指纹伪造;rapidfuzz: 去重模糊近重复复核;缺失不影响核心功能)…"
99
111
  & $script:py -m pip install curl_cffi
112
+ if ($LASTEXITCODE -ne 0) {
113
+ & $script:py -m pip install -i https://pypi.tuna.tsinghua.edu.cn/simple curl_cffi
114
+ }
100
115
  if ($LASTEXITCODE -ne 0) { Write-Host "[warn] pip 安装 curl_cffi 失败(可选依赖)" }
101
116
  & $script:py -m pip install rapidfuzz
117
+ if ($LASTEXITCODE -ne 0) {
118
+ & $script:py -m pip install -i https://pypi.tuna.tsinghua.edu.cn/simple rapidfuzz
119
+ }
102
120
  if ($LASTEXITCODE -ne 0) { Write-Host "[warn] pip 安装 rapidfuzz 失败(可选依赖,缺失时去重回退纯 minhash)" }
103
121
  }
104
122
 
@@ -104,7 +104,19 @@ elif [[ -d "$INSTALL_DIR" ]] && [[ -f "$INSTALL_DIR/scripts/search.py" ]]; then
104
104
  else
105
105
  echo "==> 克隆仓库…"
106
106
  mkdir -p "$(dirname "$INSTALL_DIR")"
107
- git clone --depth 1 --branch "$BRANCH" "$REPO" "$INSTALL_DIR"
107
+ if ! git clone --depth 1 --branch "$BRANCH" "$REPO" "$INSTALL_DIR"; then
108
+ if [[ "$REPO" == *"github.com"* ]]; then
109
+ MIRROR_REPO="https://ghfast.top/${REPO}"
110
+ echo "[warn] 从 GitHub 直连克隆失败,尝试加速镜像: $MIRROR_REPO …"
111
+ if ! git clone --depth 1 --branch "$BRANCH" "$MIRROR_REPO" "$INSTALL_DIR"; then
112
+ echo "!! 克隆失败。若处于受限网络环境,可尝试手动克隆或设置 ARGO_REPO 环境变量。" >&2
113
+ exit 1
114
+ fi
115
+ else
116
+ echo "!! 克隆失败,请检查网络连接。" >&2
117
+ exit 1
118
+ fi
119
+ fi
108
120
  fi
109
121
 
110
122
  # 供应链加固:校验 HEAD 与预期 commit 一致(ARGO_PIN 固定)
@@ -122,13 +134,16 @@ if [[ "$SKIP_PIP" != "1" ]]; then
122
134
  echo "==> 安装依赖 (PyYAML)…"
123
135
  python3 -m pip install --user -q pyyaml 2>/dev/null \
124
136
  || python3 -m pip install -q pyyaml 2>/dev/null \
137
+ || python3 -m pip install --user -q -i https://pypi.tuna.tsinghua.edu.cn/simple pyyaml 2>/dev/null \
125
138
  || echo "[warn] pip 安装 PyYAML 失败,可手动: pip install pyyaml"
126
139
  echo "==> 安装可选增强 (curl_cffi: TLS 指纹伪造;rapidfuzz: 去重模糊近重复复核;缺失不影响核心功能)…"
127
140
  python3 -m pip install --user -q curl_cffi 2>/dev/null \
128
141
  || python3 -m pip install -q curl_cffi 2>/dev/null \
142
+ || python3 -m pip install --user -q -i https://pypi.tuna.tsinghua.edu.cn/simple curl_cffi 2>/dev/null \
129
143
  || echo "[warn] pip 安装 curl_cffi 失败(可选依赖)。需要反爬 TLS 指纹伪造时手动: pip install curl_cffi"
130
144
  python3 -m pip install --user -q rapidfuzz 2>/dev/null \
131
145
  || python3 -m pip install -q rapidfuzz 2>/dev/null \
146
+ || python3 -m pip install --user -q -i https://pypi.tuna.tsinghua.edu.cn/simple rapidfuzz 2>/dev/null \
132
147
  || echo "[warn] pip 安装 rapidfuzz 失败(可选依赖)。缺失时去重回退纯 minhash,需要模糊近重复复核时手动: pip install rapidfuzz"
133
148
  fi
134
149