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,656 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""批次十四构建器:港股披露 / 商品期货 / 人民币中间价 / 链上 TVL(2026-10-21)。
|
|
3
|
+
|
|
4
|
+
hkex 港交所披露易(HKEXnews)公告检索:代码/公司名解析 + 标题关键词
|
|
5
|
+
shfe 上海期货交易所日行情(铜/铝/黄金/螺纹钢等,按品种过滤)
|
|
6
|
+
cfets 中国外汇交易中心人民币中间价(25 个币种,官方口径)
|
|
7
|
+
defillama DeFiLlama 协议 TVL(链上资金规模/24h 与 7d 变化)
|
|
8
|
+
|
|
9
|
+
四个源都是免 key 的官方/一手接口,补的是既有覆盖的结构性空白:
|
|
10
|
+
港股公告(A 股有巨潮、美股有 SEC,港股此前没有)、商品期货(此前零源)、
|
|
11
|
+
人民币中间价(此前只有 ECB/聚合汇率,没有在岸官方价)、链上 TVL(此前只有
|
|
12
|
+
CoinGecko 币价,没有协议资金规模)。
|
|
13
|
+
|
|
14
|
+
共同约束:
|
|
15
|
+
- 上游都不是「关键词全文检索」,而是「按标的枚举」——查询先做标的解析
|
|
16
|
+
(代码/品种/币种/协议名),解析不出来就诚实返回空,不猜、不拿全量
|
|
17
|
+
数据冒充结果。
|
|
18
|
+
- 全部走 http_open(失败现场归因),异常由 safe_search 兜底成空结果。
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
import time
|
|
26
|
+
import urllib.parse
|
|
27
|
+
import urllib.request
|
|
28
|
+
from datetime import datetime, timedelta, timezone
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from engines_base import http_open, safe_search
|
|
32
|
+
|
|
33
|
+
_UA = "argo-search/2.9 (unified-search@local)"
|
|
34
|
+
_CST = timezone(timedelta(hours=8)) # 三个中国市场源都按北京时间取「今天」
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _today_cst() -> datetime:
|
|
38
|
+
return datetime.now(_CST)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _json_open(url: str, timeout: float, engine: str,
|
|
42
|
+
headers: dict[str, str] | None = None) -> Any:
|
|
43
|
+
hdr = {"User-Agent": _UA, "Accept": "application/json"}
|
|
44
|
+
hdr.update(headers or {})
|
|
45
|
+
req = urllib.request.Request(url, headers=hdr)
|
|
46
|
+
with http_open(req, timeout=timeout, engine=engine) as resp:
|
|
47
|
+
return json.loads(resp.read().decode("utf-8", "replace"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── 港交所披露易(HKEXnews)──────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
# 披露易文档标题是繁体中文, mainland 用户习惯输入简体;不做整表转换
|
|
53
|
+
# (引入 OpenCC 太重),只覆盖港股公告里的高频词。未命中的简体词仍可通过
|
|
54
|
+
# 代码/公司名解析路径命中,不会整源失效。
|
|
55
|
+
_HKEX_S2T_CHARS = str.maketrans({
|
|
56
|
+
"腾": "騰", "讯": "訊", "汇": "匯", "丰": "豐", "团": "團", "亚": "亞",
|
|
57
|
+
"银": "銀", "证": "證", "产": "產", "业": "業", "务": "務", "国": "國",
|
|
58
|
+
"际": "際", "华": "華", "东": "東", "电": "電", "车": "車", "险": "險",
|
|
59
|
+
"药": "藥", "医": "醫", "疗": "療", "网": "網", "络": "絡", "软": "軟",
|
|
60
|
+
"数": "數", "据": "據", "显": "顯", "传": "傳", "铁": "鐵", "钢": "鋼",
|
|
61
|
+
"铝": "鋁", "铜": "銅", "矿": "礦", "黄": "黃", "锂": "鋰", "风": "風",
|
|
62
|
+
"环": "環", "气": "氣", "农": "農", "养": "養", "饲": "飼", "纺": "紡",
|
|
63
|
+
"织": "織", "装": "裝", "递": "遞", "线": "線", "导": "導", "体": "體",
|
|
64
|
+
"制": "製", "筑": "築", "饰": "飾", "轮": "輪", "纸": "紙", "贸": "貿",
|
|
65
|
+
"资": "資", "权": "權", "兴": "興", "发": "發", "长": "長", "龙": "龍",
|
|
66
|
+
"马": "馬", "湾": "灣", "门": "門", "开": "開", "关": "關", "报": "報",
|
|
67
|
+
"总": "總", "结": "結", "构": "構", "变": "變", "调": "調", "库": "庫",
|
|
68
|
+
"检": "檢", "测": "測", "试": "試", "验": "驗", "认": "認", "书": "書",
|
|
69
|
+
"许": "許", "营": "營", "销": "銷", "价": "價", "额": "額", "页": "頁",
|
|
70
|
+
"码": "碼", "视": "視", "频": "頻", "图": "圖", "买": "買", "卖": "賣",
|
|
71
|
+
"货": "貨", "币": "幣", "债": "債", "万": "萬", "亿": "億", "广": "廣",
|
|
72
|
+
"宁": "寧", "辽": "遼", "苏": "蘇", "无": "無", "与": "與", "为": "為",
|
|
73
|
+
"会": "會", "员": "員", "计": "計", "划": "劃", "实": "實", "现": "現",
|
|
74
|
+
"场": "場", "区": "區", "儿": "兒", "妇": "婦", "学": "學", "术": "術",
|
|
75
|
+
"艺": "藝", "乐": "樂", "闻": "聞", "题": "題", "问": "問", "见": "見",
|
|
76
|
+
"观": "觀", "识": "識", "说": "說", "请": "請", "谢": "謝", "让": "讓",
|
|
77
|
+
"对": "對", "应": "應", "该": "該", "个": "個", "这": "這", "时": "時",
|
|
78
|
+
"间": "間", "户": "戶", "决": "決", "议": "議", "注": "註",
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
# 常见公告主题词(简体→繁体)。字表之外的整词映射,覆盖「回购/业绩」这类
|
|
82
|
+
# 高频查询;未覆盖的简体生僻词仍可用代码查询命中。
|
|
83
|
+
_HKEX_TERMS = {
|
|
84
|
+
"回购": "購回", "回購": "購回", "回购注销": "購回註銷",
|
|
85
|
+
"业绩": "業績", "年报": "年報", "月报表": "月報表", "中期报告": "中期報告",
|
|
86
|
+
"分红": "分紅", "股东": "股東", "股东大会": "股東大會",
|
|
87
|
+
"董事会": "董事會", "收购": "收購", "合并": "合併",
|
|
88
|
+
"私有化": "私有化", "供股": "供股", "配股": "配股",
|
|
89
|
+
"停牌": "停牌", "复牌": "復牌", "招股": "招股",
|
|
90
|
+
"股权": "股權", "债券": "債券", "增持": "增持", "减持": "減持",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# 查询里描述「要什么」而非「哪家公司」的词,解析公司名前先剥掉。
|
|
94
|
+
# 主题词(回购/业绩/停牌…)不在此列——它们是过滤条件,剥掉就退化成
|
|
95
|
+
# 「该公司最新公告」,用户要的特定公告会被淹掉。
|
|
96
|
+
_HKEX_NOISE = re.compile(
|
|
97
|
+
r"(?i)(公告|通告|披露|文件|最新|查询|查詢|全部|港股|港交所|披露易|hkex|H股|红筹|"
|
|
98
|
+
r"恒生指数|announcements?|filing|filings?)"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _hkex_s2t(text: str) -> str:
|
|
103
|
+
out = str(text or "")
|
|
104
|
+
# 最长词优先替换:防止「回购」先把「回购注销」前半截换掉而漏掉「注销→註銷」
|
|
105
|
+
for s, t in sorted(_HKEX_TERMS.items(), key=lambda kv: len(kv[0]), reverse=True):
|
|
106
|
+
out = out.replace(s, t)
|
|
107
|
+
return out.translate(_HKEX_S2T_CHARS)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
_HKEX_PREFIX_CACHE: dict[str, list[dict[str, Any]]] = {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _hkex_prefix(probe: str) -> list[dict[str, Any]]:
|
|
114
|
+
hit = _HKEX_PREFIX_CACHE.get(probe)
|
|
115
|
+
if hit is not None:
|
|
116
|
+
return hit
|
|
117
|
+
url = ("https://www1.hkexnews.hk/search/prefix.do?"
|
|
118
|
+
+ urllib.parse.urlencode({"callback": "callback", "lang": "ZH",
|
|
119
|
+
"type": "A", "name": probe,
|
|
120
|
+
"market": "SEHK"}))
|
|
121
|
+
req = urllib.request.Request(url, headers={
|
|
122
|
+
"User-Agent": _UA,
|
|
123
|
+
"Referer": "https://www1.hkexnews.hk/search/titlesearch.xhtml",
|
|
124
|
+
})
|
|
125
|
+
with http_open(req, timeout=10, engine="hkex") as resp:
|
|
126
|
+
body = resp.read().decode("utf-8", "replace")
|
|
127
|
+
m = re.search(r"callback\((.*)\)\s*;?\s*$", body, re.S)
|
|
128
|
+
info: list[dict[str, Any]] = []
|
|
129
|
+
if m:
|
|
130
|
+
try:
|
|
131
|
+
info = (json.loads(m.group(1)) or {}).get("stockInfo") or []
|
|
132
|
+
except (json.JSONDecodeError, ValueError):
|
|
133
|
+
info = []
|
|
134
|
+
if len(_HKEX_PREFIX_CACHE) > 200:
|
|
135
|
+
_HKEX_PREFIX_CACHE.clear()
|
|
136
|
+
_HKEX_PREFIX_CACHE[probe] = info
|
|
137
|
+
return info
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
_HKEX_NON_EQUITY = re.compile(r"(購|沽|牛|熊|證|ETF|基金|債券|票據|優先|票據)")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
144
|
+
|
|
145
|
+
_TOPIC_TERMS = tuple(sorted(
|
|
146
|
+
frozenset(_HKEX_TERMS) | frozenset(_HKEX_TERMS.values()),
|
|
147
|
+
key=len, reverse=True,
|
|
148
|
+
))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _hkex_resolve(query: str) -> tuple[list[tuple[str, str, str]], str] | None:
|
|
152
|
+
"""查询 → ([(stockId, code, name)…], core)。
|
|
153
|
+
|
|
154
|
+
code 优先;公司名先剥主题词(「腾讯 回购」→「騰訊」)再做简→繁,然后按
|
|
155
|
+
「原名 → 加控股/股份/集團 → 截短」探测,前 6 个候选并发发(单次 2~3s,
|
|
156
|
+
串行会把整次查询顶到 10s 预算外)。
|
|
157
|
+
|
|
158
|
+
加后缀是必需的:前缀接口对两字简称只回窝轮(实测「騰訊」前 6 条全是
|
|
159
|
+
「騰訊中銀…購」),「騰訊控股」才回正股 00700。返回的 core 是命中的
|
|
160
|
+
那截,调用方据此把主题词从查询里扣出来。
|
|
161
|
+
"""
|
|
162
|
+
q = (query or "").strip()
|
|
163
|
+
if not q:
|
|
164
|
+
return None
|
|
165
|
+
m = re.search(r"(?<!\d)(\d{5})(?!\d)", q)
|
|
166
|
+
if not m:
|
|
167
|
+
m4 = re.search(r"(?<!\d)(\d{4})(?!\d)", q)
|
|
168
|
+
code = m4.group(1).zfill(5) if m4 else ""
|
|
169
|
+
else:
|
|
170
|
+
code = m.group(1)
|
|
171
|
+
if code:
|
|
172
|
+
rows = [(str(i.get("stockId") or "").strip(), str(i.get("code") or ""),
|
|
173
|
+
str(i.get("name") or "")) for i in _hkex_prefix(code)]
|
|
174
|
+
rows = [r for r in rows if r[0]]
|
|
175
|
+
return (rows[:2], code) if rows else None
|
|
176
|
+
|
|
177
|
+
raw = _HKEX_NOISE.sub(" ", q)
|
|
178
|
+
raw = re.sub(r"\s+", " ", raw).strip()
|
|
179
|
+
full = _hkex_s2t(raw)
|
|
180
|
+
if not full:
|
|
181
|
+
return None
|
|
182
|
+
name_only = full
|
|
183
|
+
for term in _TOPIC_TERMS:
|
|
184
|
+
if term and term in name_only:
|
|
185
|
+
name_only = name_only.replace(term, " ")
|
|
186
|
+
name_only = re.sub(r"\s+", " ", name_only).strip() or full
|
|
187
|
+
|
|
188
|
+
candidates: list[tuple[str, str]] = [] # (probe, core)
|
|
189
|
+
|
|
190
|
+
def _add(probe: str, core: str) -> None:
|
|
191
|
+
probe = probe.strip()
|
|
192
|
+
if probe and probe not in [c[0] for c in candidates]:
|
|
193
|
+
candidates.append((probe, core))
|
|
194
|
+
|
|
195
|
+
for base in (name_only, full):
|
|
196
|
+
for suffix in ("控股", "股份", "集團", "集团", ""):
|
|
197
|
+
_add(base + suffix, base)
|
|
198
|
+
parts = name_only.split()
|
|
199
|
+
for i in range(len(parts) - 1, 0, -1):
|
|
200
|
+
_add(" ".join(parts[:i]), " ".join(parts[:i]))
|
|
201
|
+
for size in (4, 3, 2):
|
|
202
|
+
if len(name_only) > size:
|
|
203
|
+
_add(name_only[:size], name_only[:size])
|
|
204
|
+
candidates = candidates[:4]
|
|
205
|
+
if not candidates:
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
def _probe(item: tuple[str, str]) -> list[tuple[str, str, str]]:
|
|
209
|
+
probe, _core = item
|
|
210
|
+
info = _hkex_prefix(probe)
|
|
211
|
+
rows = [(str(i.get("stockId") or "").strip(), str(i.get("code") or ""),
|
|
212
|
+
str(i.get("name") or "")) for i in info
|
|
213
|
+
if not _HKEX_NON_EQUITY.search(str(i.get("name") or ""))]
|
|
214
|
+
return [r for r in rows if r[0]]
|
|
215
|
+
|
|
216
|
+
with ThreadPoolExecutor(max_workers=4) as pool:
|
|
217
|
+
results = list(pool.map(_probe, candidates))
|
|
218
|
+
for (probe, core), rows in zip(candidates, results):
|
|
219
|
+
if rows:
|
|
220
|
+
rows.sort(key=lambda r: abs(len(r[2]) - len(core)))
|
|
221
|
+
return rows[:2], core
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _hkex_query(params: dict[str, Any], kw_tokens: list[str], filt: bool,
|
|
226
|
+
to: float, n: int, seen: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
227
|
+
"""按一组参数拉公告并转成统一结果;filt=True 时按主题词过滤标题。"""
|
|
228
|
+
url = ("https://www1.hkexnews.hk/search/titleSearchServlet.do?"
|
|
229
|
+
+ urllib.parse.urlencode(params))
|
|
230
|
+
req = urllib.request.Request(url, headers={
|
|
231
|
+
"User-Agent": _UA,
|
|
232
|
+
"Referer": "https://www1.hkexnews.hk/search/titlesearch.xhtml",
|
|
233
|
+
})
|
|
234
|
+
with http_open(req, timeout=to, engine="hkex") as resp:
|
|
235
|
+
payload = json.loads(resp.read().decode("utf-8", "replace"))
|
|
236
|
+
raw = payload.get("result") or "[]"
|
|
237
|
+
rows = json.loads(raw) if isinstance(raw, str) else raw
|
|
238
|
+
if not isinstance(rows, list):
|
|
239
|
+
return []
|
|
240
|
+
have = {str(r.get("url") or "") for r in seen}
|
|
241
|
+
out: list[dict[str, Any]] = []
|
|
242
|
+
for row in rows:
|
|
243
|
+
if not isinstance(row, dict):
|
|
244
|
+
continue
|
|
245
|
+
title = re.sub(r"<[^>]+>", "", str(row.get("TITLE") or "")).strip()
|
|
246
|
+
long_text = re.sub(r"<[^>]+>", "", str(row.get("LONG_TEXT") or "")).strip()
|
|
247
|
+
body = long_text or title
|
|
248
|
+
if filt and kw_tokens and not all(t in body for t in kw_tokens):
|
|
249
|
+
continue
|
|
250
|
+
code = re.sub(r"<[^>]+>", " ", str(row.get("STOCK_CODE") or "")).split()
|
|
251
|
+
name = re.sub(r"<[^>]+>", " ", str(row.get("STOCK_NAME") or "")).split()
|
|
252
|
+
link = str(row.get("FILE_LINK") or "").strip()
|
|
253
|
+
if not link:
|
|
254
|
+
continue
|
|
255
|
+
full = "https://www1.hkexnews.hk" + link
|
|
256
|
+
if full in have:
|
|
257
|
+
continue
|
|
258
|
+
have.add(full)
|
|
259
|
+
code_s = code[0] if code else ""
|
|
260
|
+
name_s = name[0] if name else ""
|
|
261
|
+
when = str(row.get("DATE_TIME") or "").strip()
|
|
262
|
+
published = ""
|
|
263
|
+
m = re.match(r"(\d{2})/(\d{2})/(\d{4})\s+(\d{2}):(\d{2})", when)
|
|
264
|
+
if m:
|
|
265
|
+
d, mo, y, hh, mm = m.groups()
|
|
266
|
+
published = f"{y}-{mo}-{d}T{hh}:{mm}:00+08:00"
|
|
267
|
+
out.append({
|
|
268
|
+
"title": f"{code_s} {name_s}|{body}"[:160],
|
|
269
|
+
"url": full,
|
|
270
|
+
"snippet": (f"{body} · {when} · {row.get('FILE_TYPE') or ''} "
|
|
271
|
+
f"{row.get('FILE_INFO') or ''}").strip(" ·"),
|
|
272
|
+
"source": "hkex",
|
|
273
|
+
"published_at": published,
|
|
274
|
+
"facts": {"stock_code": code_s, "stock_name": name_s,
|
|
275
|
+
"news_id": str(row.get("NEWS_ID") or "")},
|
|
276
|
+
})
|
|
277
|
+
if len(seen) + len(out) >= max(1, n):
|
|
278
|
+
break
|
|
279
|
+
return out
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _build_hkex_engine(spec: dict[str, Any]) -> Any:
|
|
283
|
+
timeout = spec.get("timeout", 20)
|
|
284
|
+
|
|
285
|
+
@safe_search
|
|
286
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None,
|
|
287
|
+
**kwargs) -> list[dict[str, Any]]:
|
|
288
|
+
to = _timeout or timeout
|
|
289
|
+
q = (query or "").strip()
|
|
290
|
+
if not q:
|
|
291
|
+
return []
|
|
292
|
+
|
|
293
|
+
hit = None
|
|
294
|
+
try:
|
|
295
|
+
hit = _hkex_resolve(q)
|
|
296
|
+
except Exception:
|
|
297
|
+
hit = None
|
|
298
|
+
|
|
299
|
+
# 剩余主题词:公司解析命中时扣掉 probe,否则用整条查询
|
|
300
|
+
base = _hkex_s2t(_HKEX_NOISE.sub(" ", q))
|
|
301
|
+
base = re.sub(r"(?<!\d)\d{4,5}(?!\d)", " ", base)
|
|
302
|
+
base = re.sub(r"\s+", " ", base).strip()
|
|
303
|
+
topic = base
|
|
304
|
+
if hit and hit[1] and hit[1] in base:
|
|
305
|
+
topic = base.replace(hit[1], " ", 1).strip()
|
|
306
|
+
topic = re.sub(r"\s+", " ", topic).strip()
|
|
307
|
+
|
|
308
|
+
has_cjk = bool(re.search(r"[\u4e00-\u9fff]", topic))
|
|
309
|
+
targets: list[tuple[str, str, str]] = []
|
|
310
|
+
if hit:
|
|
311
|
+
targets = hit[0]
|
|
312
|
+
elif topic and has_cjk:
|
|
313
|
+
targets = [("-1", "", "")]
|
|
314
|
+
else:
|
|
315
|
+
# 既没有可解析的标的、也没有中文主题词:不猜,交回上游路由
|
|
316
|
+
return []
|
|
317
|
+
|
|
318
|
+
kw_tokens = [t for t in re.split(r"\s+", topic) if t] if (has_cjk and hit) else []
|
|
319
|
+
# 公司+主题时只查最匹配的一家(前缀接口对简称会返回多家关联主体,
|
|
320
|
+
# 逐家拉 60 条会把单次查询推到 10s 预算之上);纯代码/纯公司名查 2 家。
|
|
321
|
+
targets = targets[:1] if kw_tokens else targets[:2]
|
|
322
|
+
out: list[dict[str, Any]] = []
|
|
323
|
+
for sid, _code, _name in targets:
|
|
324
|
+
if len(out) >= max(1, n):
|
|
325
|
+
break
|
|
326
|
+
params: dict[str, Any] = {
|
|
327
|
+
"sortDir": "0", "sortByOptions": "DateTime", "category": "0",
|
|
328
|
+
"market": "SEHK", "documentType": "-1",
|
|
329
|
+
# 公司+主题词查的是「历史上哪份公告讲了这个」,45 天窗口
|
|
330
|
+
# 会漏掉半年报/年报(业绩类公告的间隔本来就长),放宽到 180 天。
|
|
331
|
+
"fromDate": (_today_cst() - timedelta(
|
|
332
|
+
days=180 if kw_tokens else 45)).strftime("%Y%m%d"),
|
|
333
|
+
"toDate": _today_cst().strftime("%Y%m%d"),
|
|
334
|
+
"title": "", "searchType": "0",
|
|
335
|
+
"t1code": "-2", "t2Gcode": "-2", "t2code": "-2",
|
|
336
|
+
"rowRange": str(max(5, min(int(n) * 3, 30))), "lang": "ZH",
|
|
337
|
+
}
|
|
338
|
+
if sid == "-1":
|
|
339
|
+
params["stockId"] = "-1"
|
|
340
|
+
params["title"] = topic
|
|
341
|
+
params["searchType"] = "1"
|
|
342
|
+
else:
|
|
343
|
+
params["stockId"] = sid
|
|
344
|
+
if kw_tokens:
|
|
345
|
+
params["rowRange"] = "60" # 公司+主题:多拉再本地筛
|
|
346
|
+
out.extend(_hkex_query(params, kw_tokens, filt=bool(hit), to=to,
|
|
347
|
+
n=n, seen=out))
|
|
348
|
+
return out[:max(1, n)]
|
|
349
|
+
|
|
350
|
+
return _engine
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ── 上海期货交易所日行情 ─────────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
_SHFE_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
|
|
356
|
+
_SHFE_TTL = 600.0
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _shfe_daily(engine: str, timeout: float) -> tuple[str, dict[str, Any]] | None:
|
|
360
|
+
"""取最近一个可用交易日的全量日行情(周末/节假日自动往前找)。"""
|
|
361
|
+
today = _today_cst()
|
|
362
|
+
for back in range(0, 12):
|
|
363
|
+
day = today - timedelta(days=back)
|
|
364
|
+
if day.weekday() >= 5:
|
|
365
|
+
continue
|
|
366
|
+
key = day.strftime("%Y%m%d")
|
|
367
|
+
hit = _SHFE_CACHE.get(key)
|
|
368
|
+
if hit and time.time() - hit[0] < _SHFE_TTL:
|
|
369
|
+
return key, hit[1]
|
|
370
|
+
url = ("https://www.shfe.com.cn/data/tradedata/future/dailydata/"
|
|
371
|
+
f"kx{key}.dat")
|
|
372
|
+
try:
|
|
373
|
+
data = _json_open(url, timeout, engine)
|
|
374
|
+
except Exception:
|
|
375
|
+
continue
|
|
376
|
+
rows = (data or {}).get("o_curinstrument") if isinstance(data, dict) else None
|
|
377
|
+
# 当日收盘前文件已生成但价格为空的快照(实测 2026-10-21 14:00 的
|
|
378
|
+
# kx20261021.dat:331 行、CLOSEPRICE 全空)不能当结果用,继续往前找。
|
|
379
|
+
if not rows or not any(str(r.get("CLOSEPRICE") or "").strip() for r in rows
|
|
380
|
+
if isinstance(r, dict)):
|
|
381
|
+
continue
|
|
382
|
+
_SHFE_CACHE[key] = (time.time(), data)
|
|
383
|
+
if len(_SHFE_CACHE) > 8:
|
|
384
|
+
oldest = min(_SHFE_CACHE, key=lambda k: _SHFE_CACHE[k][0])
|
|
385
|
+
_SHFE_CACHE.pop(oldest, None)
|
|
386
|
+
return key, data
|
|
387
|
+
return None
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _fmt_num(v: Any) -> str:
|
|
391
|
+
try:
|
|
392
|
+
f = float(v)
|
|
393
|
+
except (TypeError, ValueError):
|
|
394
|
+
return "-"
|
|
395
|
+
if f == int(f):
|
|
396
|
+
return f"{int(f):,}"
|
|
397
|
+
return f"{f:,.2f}"
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _build_shfe_engine(spec: dict[str, Any]) -> Any:
|
|
401
|
+
timeout = spec.get("timeout", 20)
|
|
402
|
+
|
|
403
|
+
@safe_search
|
|
404
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None,
|
|
405
|
+
**kwargs) -> list[dict[str, Any]]:
|
|
406
|
+
to = _timeout or timeout
|
|
407
|
+
q = (query or "").strip()
|
|
408
|
+
if not q:
|
|
409
|
+
return []
|
|
410
|
+
# 「沪铜主力」「螺纹钢 2610」这类查询:剥掉交易所/意图噪声,留品种名
|
|
411
|
+
core = re.sub(
|
|
412
|
+
r"(?i)(上期所|上海期货交易所|期货|主力|连续|合约|行情|价格|报价|今日|最新|"
|
|
413
|
+
r"收盘|结算价|shfe|futures?)", " ", q)
|
|
414
|
+
core = core.replace("沪", " ")
|
|
415
|
+
core = re.sub(r"(?<!\d)\d{4}(?!\d)", " ", core)
|
|
416
|
+
core = re.sub(r"\s+", " ", core).strip().lower()
|
|
417
|
+
if not core:
|
|
418
|
+
return []
|
|
419
|
+
got = _shfe_daily("shfe", to)
|
|
420
|
+
if not got:
|
|
421
|
+
return []
|
|
422
|
+
day, data = got
|
|
423
|
+
rows = data.get("o_curinstrument") or []
|
|
424
|
+
out: list[dict[str, Any]] = []
|
|
425
|
+
for row in rows:
|
|
426
|
+
if not isinstance(row, dict):
|
|
427
|
+
continue
|
|
428
|
+
pname = str(row.get("PRODUCTNAME") or "").strip()
|
|
429
|
+
pgroup = str(row.get("PRODUCTGROUPID") or "").strip().lower()
|
|
430
|
+
pid = str(row.get("PRODUCTID") or "").strip().lower()
|
|
431
|
+
if not pname:
|
|
432
|
+
continue
|
|
433
|
+
hay = f"{pname} {pgroup} {pid}".lower()
|
|
434
|
+
if core not in hay and not any(tok in hay for tok in core.split()):
|
|
435
|
+
continue
|
|
436
|
+
close = _fmt_num(row.get("CLOSEPRICE"))
|
|
437
|
+
settle = _fmt_num(row.get("SETTLEMENTPRICE"))
|
|
438
|
+
chg = _fmt_num(row.get("ZD1_CHG"))
|
|
439
|
+
month = str(row.get("DELIVERYMONTH") or "").strip()
|
|
440
|
+
out.append({
|
|
441
|
+
"title": f"{pname} {month} 收 {close} 结算 {settle}({day})",
|
|
442
|
+
"url": f"https://www.shfe.com.cn/products/future/{pgroup}/",
|
|
443
|
+
"snippet": (f"上海期货交易所日行情 · {pname} {month} · "
|
|
444
|
+
f"收盘 {close} · 结算 {settle} · 涨跌 {chg} · "
|
|
445
|
+
f"成交 {_fmt_num(row.get('VOLUME'))} 手 · "
|
|
446
|
+
f"持仓 {_fmt_num(row.get('OPENINTEREST'))} 手"),
|
|
447
|
+
"source": "shfe",
|
|
448
|
+
"published_at": f"{day[:4]}-{day[4:6]}-{day[6:]}T15:00:00+08:00",
|
|
449
|
+
"facts": {"product": pname, "month": month, "date": day,
|
|
450
|
+
"close": close, "settlement": settle},
|
|
451
|
+
})
|
|
452
|
+
if len(out) >= max(1, n):
|
|
453
|
+
break
|
|
454
|
+
return out
|
|
455
|
+
|
|
456
|
+
return _engine
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
# ── 中国外汇交易中心人民币中间价 ─────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
# 币种词 → 披露易口径的货币对(100JPY 表示每 100 日元报价,是 CFETS 惯例)
|
|
462
|
+
_CFETS_CCY = {
|
|
463
|
+
"美元": "USD/CNY", "美金": "USD/CNY", "usd": "USD/CNY",
|
|
464
|
+
"欧元": "EUR/CNY", "歐元": "EUR/CNY", "eur": "EUR/CNY",
|
|
465
|
+
"日元": "100JPY/CNY", "日币": "100JPY/CNY", "jpy": "100JPY/CNY",
|
|
466
|
+
"港元": "HKD/CNY", "港币": "HKD/CNY", "港幣": "HKD/CNY", "hkd": "HKD/CNY",
|
|
467
|
+
"英镑": "GBP/CNY", "英鎊": "GBP/CNY", "gbp": "GBP/CNY",
|
|
468
|
+
"澳元": "AUD/CNY", "aud": "AUD/CNY",
|
|
469
|
+
"新西兰元": "NZD/CNY", "nzd": "NZD/CNY",
|
|
470
|
+
"新加坡元": "SGD/CNY", "sgd": "SGD/CNY",
|
|
471
|
+
"瑞士法郎": "CHF/CNY", "chf": "CHF/CNY",
|
|
472
|
+
"加元": "CAD/CNY", "加拿大元": "CAD/CNY", "cad": "CAD/CNY",
|
|
473
|
+
"澳门元": "CNY/MOP", "mop": "CNY/MOP",
|
|
474
|
+
"林吉特": "CNY/MYR", "myr": "CNY/MYR",
|
|
475
|
+
"卢布": "CNY/RUB", "rub": "CNY/RUB",
|
|
476
|
+
"兰特": "CNY/ZAR", "zar": "CNY/ZAR",
|
|
477
|
+
"韩元": "CNY/KRW", "韩币": "CNY/KRW", "krw": "CNY/KRW",
|
|
478
|
+
"迪拉姆": "CNY/AED", "aed": "CNY/AED",
|
|
479
|
+
"里亚尔": "CNY/SAR", "sar": "CNY/SAR",
|
|
480
|
+
"福林": "CNY/HUF", "huf": "CNY/HUF",
|
|
481
|
+
"兹罗提": "CNY/PLN", "pln": "CNY/PLN",
|
|
482
|
+
"丹麦克朗": "CNY/DKK", "dkk": "CNY/DKK",
|
|
483
|
+
"瑞典克朗": "CNY/SEK", "sek": "CNY/SEK",
|
|
484
|
+
"挪威克朗": "CNY/NOK", "nok": "CNY/NOK",
|
|
485
|
+
"里拉": "CNY/TRY", "try": "CNY/TRY",
|
|
486
|
+
"比索": "CNY/MXN", "mxn": "CNY/MXN",
|
|
487
|
+
"泰铢": "CNY/THB", "thb": "CNY/THB",
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _cfets_pair(query: str) -> str:
|
|
492
|
+
q = (query or "").lower()
|
|
493
|
+
for word, pair in _CFETS_CCY.items():
|
|
494
|
+
if word in q:
|
|
495
|
+
return pair
|
|
496
|
+
m = re.search(r"\b([a-z]{3})\s*[/\-]\s*(cny|rmb)\b", q)
|
|
497
|
+
if m:
|
|
498
|
+
code = m.group(1).upper()
|
|
499
|
+
for pair in _CFETS_CCY.values():
|
|
500
|
+
if pair.startswith(code + "/"):
|
|
501
|
+
return pair
|
|
502
|
+
m = re.search(r"\b(cny|rmb)\s*[/\-]\s*([a-z]{3})\b", q)
|
|
503
|
+
if m:
|
|
504
|
+
code = m.group(2).upper()
|
|
505
|
+
for pair in _CFETS_CCY.values():
|
|
506
|
+
if pair.endswith("/" + code):
|
|
507
|
+
return pair
|
|
508
|
+
return "USD/CNY" # 只说「人民币中间价」时按最常用的美元对
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _build_cfets_engine(spec: dict[str, Any]) -> Any:
|
|
512
|
+
timeout = spec.get("timeout", 15)
|
|
513
|
+
|
|
514
|
+
@safe_search
|
|
515
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None,
|
|
516
|
+
**kwargs) -> list[dict[str, Any]]:
|
|
517
|
+
to = _timeout or timeout
|
|
518
|
+
if not (query or "").strip():
|
|
519
|
+
return []
|
|
520
|
+
pair = _cfets_pair(query)
|
|
521
|
+
end = _today_cst()
|
|
522
|
+
start = end - timedelta(days=14)
|
|
523
|
+
url = ("https://www.chinamoney.com.cn/ags/ms/cm-u-bk-ccpr/CcprHisNew?"
|
|
524
|
+
+ urllib.parse.urlencode({
|
|
525
|
+
"startDate": start.strftime("%Y-%m-%d"),
|
|
526
|
+
"endDate": end.strftime("%Y-%m-%d"),
|
|
527
|
+
"currency": pair,
|
|
528
|
+
"pageNum": 1,
|
|
529
|
+
"pageSize": max(1, min(int(n), 20)),
|
|
530
|
+
}))
|
|
531
|
+
data = _json_open(url, to, "cfets", headers={
|
|
532
|
+
"Referer": "https://www.chinamoney.com.cn/",
|
|
533
|
+
})
|
|
534
|
+
head = ((data or {}).get("data") or {})
|
|
535
|
+
searchlist = head.get("searchlist") or [pair]
|
|
536
|
+
out: list[dict[str, Any]] = []
|
|
537
|
+
for rec in (data or {}).get("records") or []:
|
|
538
|
+
if not isinstance(rec, dict):
|
|
539
|
+
continue
|
|
540
|
+
vals = rec.get("values") or []
|
|
541
|
+
if not vals:
|
|
542
|
+
continue
|
|
543
|
+
day = str(rec.get("date") or "")
|
|
544
|
+
value = str(vals[0]).strip()
|
|
545
|
+
label = str(searchlist[0]) if searchlist else pair
|
|
546
|
+
out.append({
|
|
547
|
+
"title": f"{label} 人民币中间价 {value}({day})",
|
|
548
|
+
"url": "https://www.chinamoney.com.cn/chinese/bkccpr/",
|
|
549
|
+
"snippet": (f"中国外汇交易中心受权公布 · {label} · {day} · "
|
|
550
|
+
f"中间价 {value}"),
|
|
551
|
+
"source": "cfets",
|
|
552
|
+
"published_at": f"{day}T09:15:00+08:00" if day else "",
|
|
553
|
+
"facts": {"pair": label, "date": day, "mid_price": value},
|
|
554
|
+
})
|
|
555
|
+
return out
|
|
556
|
+
|
|
557
|
+
return _engine
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
# ── DeFiLlama 协议 TVL ─────────────────────────────────────────────────────
|
|
561
|
+
|
|
562
|
+
_DLLAMA_CACHE: dict[str, Any] = {"ts": 0.0, "rows": []}
|
|
563
|
+
_DLLAMA_TTL = 300.0
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _dllama_rows(timeout: float) -> list[dict[str, Any]]:
|
|
567
|
+
now = time.time()
|
|
568
|
+
if _DLLAMA_CACHE["rows"] and now - float(_DLLAMA_CACHE["ts"]) < _DLLAMA_TTL:
|
|
569
|
+
return _DLLAMA_CACHE["rows"]
|
|
570
|
+
data = _json_open("https://api.llama.fi/protocols", max(timeout, 20), "defillama")
|
|
571
|
+
rows = data if isinstance(data, list) else []
|
|
572
|
+
_DLLAMA_CACHE["rows"] = rows
|
|
573
|
+
_DLLAMA_CACHE["ts"] = now
|
|
574
|
+
return rows
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _usd(v: Any) -> str:
|
|
578
|
+
try:
|
|
579
|
+
f = float(v)
|
|
580
|
+
except (TypeError, ValueError):
|
|
581
|
+
return "-"
|
|
582
|
+
for unit, scale in (("B", 1e9), ("M", 1e6), ("K", 1e3)):
|
|
583
|
+
if abs(f) >= scale:
|
|
584
|
+
return f"${f / scale:.2f}{unit}"
|
|
585
|
+
return f"${f:.0f}"
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _build_defillama_engine(spec: dict[str, Any]) -> Any:
|
|
589
|
+
timeout = spec.get("timeout", 30)
|
|
590
|
+
|
|
591
|
+
@safe_search
|
|
592
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None,
|
|
593
|
+
**kwargs) -> list[dict[str, Any]]:
|
|
594
|
+
to = _timeout or timeout
|
|
595
|
+
q = (query or "").strip().lower()
|
|
596
|
+
if not q:
|
|
597
|
+
return []
|
|
598
|
+
noise = re.compile(
|
|
599
|
+
r"(?i)(defillama|tvl|总锁仓|liquidity|protocol|协议|链上|资金|规模|排名|"
|
|
600
|
+
r"排名前|top|多少|查询|最新)")
|
|
601
|
+
core = noise.sub(" ", q)
|
|
602
|
+
tokens = [t for t in re.split(r"\s+", core) if len(t) >= 2]
|
|
603
|
+
if not tokens:
|
|
604
|
+
return []
|
|
605
|
+
rows = _dllama_rows(to)
|
|
606
|
+
if not rows:
|
|
607
|
+
return []
|
|
608
|
+
scored: list[tuple[int, float, dict[str, Any]]] = []
|
|
609
|
+
for p in rows:
|
|
610
|
+
if not isinstance(p, dict):
|
|
611
|
+
continue
|
|
612
|
+
name = str(p.get("name") or "")
|
|
613
|
+
symbol = str(p.get("symbol") or "")
|
|
614
|
+
category = str(p.get("category") or "")
|
|
615
|
+
chains = p.get("chains") or []
|
|
616
|
+
hay = f"{name} {symbol} {category} {' '.join(map(str, chains[:8]))}".lower()
|
|
617
|
+
score = sum(1 for t in tokens if t in hay)
|
|
618
|
+
if score == 0:
|
|
619
|
+
continue
|
|
620
|
+
try:
|
|
621
|
+
tvl = float(p.get("tvl") or 0)
|
|
622
|
+
except (TypeError, ValueError):
|
|
623
|
+
tvl = 0.0
|
|
624
|
+
scored.append((score, tvl, p))
|
|
625
|
+
scored.sort(key=lambda x: (x[0], x[1]), reverse=True)
|
|
626
|
+
out: list[dict[str, Any]] = []
|
|
627
|
+
for score, tvl, p in scored[:max(1, n)]:
|
|
628
|
+
name = str(p.get("name") or "")
|
|
629
|
+
symbol = str(p.get("symbol") or "")
|
|
630
|
+
slug = str(p.get("slug") or "")
|
|
631
|
+
cat = str(p.get("category") or "")
|
|
632
|
+
chg1 = p.get("change_1d")
|
|
633
|
+
chg7 = p.get("change_7d")
|
|
634
|
+
chg_txt = ""
|
|
635
|
+
try:
|
|
636
|
+
chg_txt = f"24h {float(chg1):+.2f}%"
|
|
637
|
+
except (TypeError, ValueError):
|
|
638
|
+
pass
|
|
639
|
+
try:
|
|
640
|
+
chg_txt += f" · 7d {float(chg7):+.2f}%"
|
|
641
|
+
except (TypeError, ValueError):
|
|
642
|
+
pass
|
|
643
|
+
desc = re.sub(r"\s+", " ", str(p.get("description") or "")).strip()
|
|
644
|
+
out.append({
|
|
645
|
+
"title": f"{name}({symbol or '-'})TVL {_usd(tvl)}"
|
|
646
|
+
+ (f" · {cat}" if cat else ""),
|
|
647
|
+
"url": (f"https://defillama.com/protocol/{slug}" if slug
|
|
648
|
+
else str(p.get("url") or "https://defillama.com/protocols")),
|
|
649
|
+
"snippet": " · ".join(x for x in (chg_txt, desc[:180]) if x),
|
|
650
|
+
"source": "defillama",
|
|
651
|
+
"facts": {"protocol": name, "tvl_usd": tvl,
|
|
652
|
+
"change_1d": chg1, "change_7d": chg7},
|
|
653
|
+
})
|
|
654
|
+
return out
|
|
655
|
+
|
|
656
|
+
return _engine
|