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.
- package/README.en.md +58 -145
- package/README.es.md +58 -145
- package/README.ja.md +64 -151
- package/README.ko.md +63 -150
- package/README.md +65 -154
- 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 +74 -5
- package/bin/argo.js +33 -2
- package/config.yaml +219 -60
- 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/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 +6 -2
- package/packages/dsh-plugin/README.md +23 -5
- package/packages/dsh-plugin/dsh/client.js +304 -15
- package/packages/dsh-plugin/dsh/diagnostics.js +54 -0
- package/packages/dsh-plugin/dsh/index.js +112 -1875
- 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 +2 -2
- package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
- package/packages/dsh-plugin/test/client.test.js +175 -1
- package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
- package/packages/dsh-plugin/test/index.test.js +44 -0
- package/scripts/adaptive.py +24 -1
- package/scripts/article_meta.py +131 -0
- 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/content_boundary.py +46 -0
- package/scripts/content_signals.py +26 -5
- package/scripts/cross_lingual_trace.py +293 -0
- package/scripts/dispatch_result.py +98 -0
- package/scripts/engine_dispatch.py +49 -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 +54 -22
- package/scripts/fetch_v3.py +25 -0
- package/scripts/gen_routing_snapshot.py +11 -0
- 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 +82 -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/output_transparency.py +200 -0
- package/scripts/paid_policy.py +98 -0
- package/scripts/quota.py +158 -13
- 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 +7 -0
- package/scripts/route_domains.py +32 -19
- 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 +7 -1
- package/scripts/search_output.py +99 -15
- package/scripts/search_pipeline.py +42 -193
- package/scripts/search_types.py +107 -9
- package/scripts/semantic_evidence.py +179 -3
- package/scripts/serp_guard.py +115 -7
- package/scripts/serp_kill_log.py +25 -2
- package/scripts/stats_cli.py +13 -1
- 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/scripts/image_vision/vision_probe +0 -0
- package/scripts/local_engines.py +0 -321
- 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,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
|
package/scripts/evidence.py
CHANGED
|
@@ -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
|
-
|
|
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', '')}"
|
|
406
|
-
if
|
|
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)
|
package/scripts/evidence_loop.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
package/scripts/fetch_v3.py
CHANGED
|
@@ -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"
|
package/scripts/install.ps1
CHANGED
|
@@ -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
|
|
package/scripts/install.sh
CHANGED
|
@@ -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
|
|