@dshtrading/kit-crypto 0.1.0

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/lib/news.d.ts ADDED
@@ -0,0 +1,50 @@
1
+ //#region src/news.d.ts
2
+ /**
3
+ * crypto_get_news 取数层(WS2b,#3 spike 推荐:kit 内薄工具,直连公共源,无连接器服务)。
4
+ *
5
+ * 四源全为单端点、无鉴权、无状态的公共 GET(见 spikes/impl-crypto-news/EVIDENCE.md 分级总表):
6
+ * - Binance 公告 CMS(GET 变体;POST 403 勿走)——JSON,链接由 code 构造
7
+ * - OKX v5 support/announcements ——JSON,条目自带直链
8
+ * - CoinDesk / The Block RSS ——RSS 2.0
9
+ * 本模块只做取数 + 归一化为 NewsItem[],时间窗/币种过滤 + 排序截尾;defineTool 装配在 index.ts。
10
+ *
11
+ * 铁律 #5:输出只带元数据(来源名/标题/链接/发布时间),不取正文,不缓存,不再分发。
12
+ * 每源独立容错:单源失败不炸整体,失败源在 `unavailable` 中注明(工具输出可见),fail-soft。
13
+ */
14
+ /** 已接入的新闻来源(EVIDENCE 分级总表:A 级打底/媒体面 + B 级 CryptoPanic 自备 key)。 */
15
+ type NewsSource = 'binance' | 'okx' | 'coindesk' | 'theblock' | 'cointelegraph' | 'decrypt' | 'cryptopanic';
16
+ interface NewsItem {
17
+ /** 来源名(铁律 #5 的来源标注;同时是「引用给 Agent 可以、再分发不行」的边界提醒落点)。 */
18
+ source: NewsSource;
19
+ title: string;
20
+ url: string;
21
+ /** ISO 8601 发布时间。 */
22
+ publishedAt: string;
23
+ }
24
+ interface AggregateNewsOptions {
25
+ /** 币种过滤(市场规范词汇,如 BTCUSDT / BTCUSDT-SWAP);缺省 = 不过滤。 */
26
+ symbol?: string | undefined;
27
+ /** 时间窗(小时):只保留 now - windowHours 内的条目;缺省 24。 */
28
+ windowHours?: number | undefined;
29
+ /** 输出条数上限;缺省 20。 */
30
+ limit?: number | undefined;
31
+ /** 依赖注入的 fetch(测试用 mock;缺省 globalThis.fetch)。 */
32
+ fetch?: typeof globalThis.fetch | undefined;
33
+ /** 注入当前时间戳(ms,测试用);缺省 Date.now()。 */
34
+ now?: number | undefined;
35
+ /** WS2c:CryptoPanic API token。有值时加测 CryptoPanic 免费层(B 增强);无值 = 仅公共源。 */
36
+ cryptoPanicKey?: string | undefined;
37
+ }
38
+ interface AggregateNewsResult {
39
+ items: NewsItem[];
40
+ /** 取数失败的源:`<匿名>`(如 'binance: HTTP 500 — ...'),工具输出需注明缺席。 */
41
+ unavailable: string[];
42
+ }
43
+ declare function parseRss2(xml: string, source: NewsSource): NewsItem[];
44
+ /** 解析 CryptoPanic { results:[...] }(公开 free API 响应形)。 */
45
+ declare function parseCryptoPanic(data: unknown): NewsItem[];
46
+ /** 币种过滤 token 派生:剥离常见报价/后缀(BTCUSDT-SWAP → [BTCUSDT-SWAP, BTC])。 */
47
+ declare function deriveSymbolTokens(symbol?: string): string[];
48
+ declare function aggregateNews(options?: AggregateNewsOptions): Promise<AggregateNewsResult>;
49
+ //#endregion
50
+ export { AggregateNewsOptions, AggregateNewsResult, NewsItem, NewsSource, aggregateNews, deriveSymbolTokens, parseCryptoPanic, parseRss2 };
package/lib/news.js ADDED
@@ -0,0 +1,196 @@
1
+ //#region src/news.ts
2
+ /** 交易相关 Binance 公告分类(EVIDENCE:48 新币上市 / 161 下架 / 51 API 更新 / 157 维护)。 */
3
+ const BINANCE_NEWS_CATALOGS = /* @__PURE__ */ new Set([
4
+ 48,
5
+ 161,
6
+ 51,
7
+ 157
8
+ ]);
9
+ const DSHTRADING_API = "https://www.binance.com/bapi/composite/v1/public/cms/article/list/query";
10
+ const OKX_ANNOUNCEMENT_URL = "https://www.okx.com/api/v5/support/announcements";
11
+ const COINDESK_RSS_URL = "https://www.coindesk.com/arc/outboundfeeds/rss/";
12
+ const THEBLOCK_RSS_URL = "https://www.theblock.co/rss.xml";
13
+ const COINTELEGRAPH_RSS_URL = "https://cointelegraph.com/rss";
14
+ const DECRYPT_RSS_URL = "https://decrypt.co/feed";
15
+ const CRYPTOPANIC_API_URL = "https://cryptopanic.com/api/free/v1/posts/";
16
+ const DEFAULT_WINDOW_HOURS = 24;
17
+ const DEFAULT_LIMIT = 20;
18
+ const MAX_LIMIT = 50;
19
+ /** 下钻 fetch 统一 10s 超时(docs/replication.md §9;上游挂起不得拖垮 60s 轮询链)。 */
20
+ const UPSTREAM_TIMEOUT_MS = 1e4;
21
+ const BINANCE_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (dsh-trading/crypto_get_news)";
22
+ async function fetchText(url, fetchImpl, fetcherName) {
23
+ const response = await fetchImpl(url, {
24
+ headers: {
25
+ accept: "application/json, text/xml, */*",
26
+ "user-agent": BINANCE_UA
27
+ },
28
+ signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS)
29
+ });
30
+ if (!response.ok) {
31
+ const body = await response.text().catch(() => "");
32
+ throw new Error(`${fetcherName}: HTTP ${response.status}${body ? ` — ${body.slice(0, 160)}` : ""}`);
33
+ }
34
+ return response.text();
35
+ }
36
+ async function fetchBinanceNews(fetchImpl) {
37
+ const url = new URL(DSHTRADING_API);
38
+ url.searchParams.set("type", "1");
39
+ url.searchParams.set("pageNo", "1");
40
+ url.searchParams.set("pageSize", "20");
41
+ const text = await fetchText(url.toString(), fetchImpl, "binance");
42
+ const catalogs = JSON.parse(text).data?.catalogs;
43
+ if (!Array.isArray(catalogs)) throw new Error("binance: unexpected payload (expected data.catalogs[])");
44
+ const items = [];
45
+ for (const catalog of catalogs) {
46
+ if (catalog?.catalogId != null && !BINANCE_NEWS_CATALOGS.has(catalog.catalogId)) continue;
47
+ for (const article of catalog?.articles ?? []) {
48
+ const code = article.code;
49
+ if (!article.title || !code) continue;
50
+ const ts = article.releaseDate;
51
+ if (typeof ts !== "number" || !Number.isFinite(ts)) continue;
52
+ items.push({
53
+ source: "binance",
54
+ title: article.title,
55
+ url: `https://www.binance.com/en/support/announcement/${encodeURIComponent(code)}`,
56
+ publishedAt: new Date(ts).toISOString()
57
+ });
58
+ if (items.length >= 60) break;
59
+ }
60
+ if (items.length >= 60) break;
61
+ }
62
+ return items;
63
+ }
64
+ async function fetchOkxNews(fetchImpl) {
65
+ const text = await fetchText(OKX_ANNOUNCEMENT_URL, fetchImpl, "okx");
66
+ const details = JSON.parse(text).data?.[0]?.details;
67
+ if (!Array.isArray(details)) throw new Error("okx: unexpected payload (expected data[0].details[])");
68
+ const items = [];
69
+ for (const a of details) {
70
+ if (!a.title || !a.url) continue;
71
+ const ts = Number(a.pTime);
72
+ if (!Number.isFinite(ts)) continue;
73
+ items.push({
74
+ source: "okx",
75
+ title: a.title,
76
+ url: a.url,
77
+ publishedAt: new Date(ts).toISOString()
78
+ });
79
+ }
80
+ return items;
81
+ }
82
+ function decodeXmlText(raw) {
83
+ return raw.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, (_, m) => m).replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&apos;/g, "'").replace(/&#([0-9]+);/g, (_, n) => String.fromCodePoint(Number(n))).replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCodePoint(parseInt(n, 16))).trim();
84
+ }
85
+ /** 取某个 item 块内首个指定标签的文本(支持 CDATA 与实体;无标签返回 '')。 */
86
+ function extractTag(block, tag) {
87
+ const re = new RegExp(`<${tag}[\\s>][\\s\\S]*?<\\/${tag}>`, "i");
88
+ const match = block.match(re);
89
+ if (!match) return "";
90
+ return decodeXmlText(match[0].replace(new RegExp(`<\\/?${tag}[^>]*>`, "gi"), ""));
91
+ }
92
+ function parseRss2(xml, source) {
93
+ const items = [];
94
+ const blocks = xml.match(/<item[\s>][\s\S]*?<\/item>/gi) ?? [];
95
+ for (const block of blocks) {
96
+ const title = extractTag(block, "title");
97
+ const link = extractTag(block, "link");
98
+ if (!title || !link) continue;
99
+ const pubDate = extractTag(block, "pubDate");
100
+ const ts = Date.parse(pubDate);
101
+ if (!Number.isFinite(ts)) continue;
102
+ items.push({
103
+ source,
104
+ title,
105
+ url: link,
106
+ publishedAt: new Date(ts).toISOString()
107
+ });
108
+ }
109
+ return items;
110
+ }
111
+ async function fetchRssNews(fetchImpl, source, url) {
112
+ return parseRss2(await fetchText(url, fetchImpl, source), source);
113
+ }
114
+ /** 解析 CryptoPanic { results:[...] }(公开 free API 响应形)。 */
115
+ function parseCryptoPanic(data) {
116
+ const results = data.results;
117
+ if (!Array.isArray(results)) throw new Error("cryptopanic: unexpected payload (expected results[])");
118
+ const items = [];
119
+ for (const post of results) {
120
+ if (!post.title || !post.url) continue;
121
+ const ts = Date.parse(post.published_at ?? "");
122
+ if (!Number.isFinite(ts)) continue;
123
+ items.push({
124
+ source: "cryptopanic",
125
+ title: post.title,
126
+ url: post.url,
127
+ publishedAt: new Date(ts).toISOString()
128
+ });
129
+ }
130
+ return items;
131
+ }
132
+ async function fetchCryptoPanicNews(fetchImpl, key, currencies) {
133
+ const url = new URL(CRYPTOPANIC_API_URL);
134
+ url.searchParams.set("auth_token", key);
135
+ if (currencies) url.searchParams.set("currencies", currencies);
136
+ const text = await fetchText(url.toString(), fetchImpl, "cryptopanic");
137
+ return parseCryptoPanic(JSON.parse(text));
138
+ }
139
+ /** 币种过滤 token 派生:剥离常见报价/后缀(BTCUSDT-SWAP → [BTCUSDT-SWAP, BTC])。 */
140
+ function deriveSymbolTokens(symbol) {
141
+ const raw = symbol?.trim().toUpperCase() ?? "";
142
+ if (!raw) return [];
143
+ let base = raw.replace(/[-_](?:SWAP|PERP|FUTURES|DEFAULT)$/i, "");
144
+ base = base.replace(/(?:USDT|USDC|BUSD|FDUSD|USD)$/i, "");
145
+ return [.../* @__PURE__ */ new Set([raw, base])].filter(Boolean);
146
+ }
147
+ function inWindow(publishedAt, nowMs, windowMs) {
148
+ const ts = Date.parse(publishedAt);
149
+ if (!Number.isFinite(ts)) return false;
150
+ return ts >= nowMs - windowMs && ts <= nowMs + 6e4;
151
+ }
152
+ function matchesSymbol(item, tokens) {
153
+ if (tokens.length === 0) return true;
154
+ if (item.source === "binance" || item.source === "okx") return true;
155
+ const title = item.title.toUpperCase();
156
+ return tokens.some((t) => title.includes(t));
157
+ }
158
+ async function aggregateNews(options = {}) {
159
+ const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
160
+ const now = options.now ?? Date.now();
161
+ const windowMs = clampNumber(options.windowHours, DEFAULT_WINDOW_HOURS, 1, 168) * 36e5;
162
+ const limit = clampNumber(options.limit, DEFAULT_LIMIT, 1, MAX_LIMIT);
163
+ const tokens = deriveSymbolTokens(options.symbol);
164
+ const fetchers = [
165
+ fetchBinanceNews(fetchImpl),
166
+ fetchOkxNews(fetchImpl),
167
+ fetchRssNews(fetchImpl, "coindesk", COINDESK_RSS_URL),
168
+ fetchRssNews(fetchImpl, "theblock", THEBLOCK_RSS_URL),
169
+ fetchRssNews(fetchImpl, "cointelegraph", COINTELEGRAPH_RSS_URL),
170
+ fetchRssNews(fetchImpl, "decrypt", DECRYPT_RSS_URL)
171
+ ];
172
+ if (options.cryptoPanicKey && options.cryptoPanicKey.trim()) {
173
+ const currencies = [...new Set(tokens.map((t) => t.replace(/[-_]?(?:SWAP|PERP|FUTURES|DEFAULT)$/i, "")).map((t) => t.replace(/(?:USDT|USDC|BUSD|FDUSD|USD)$/i, "")))].filter(Boolean).join(",");
174
+ fetchers.push(fetchCryptoPanicNews(fetchImpl, options.cryptoPanicKey.trim(), currencies));
175
+ }
176
+ const results = await Promise.allSettled(fetchers);
177
+ const items = [];
178
+ const unavailable = [];
179
+ for (const result of results) if (result.status === "fulfilled") for (const item of result.value) {
180
+ if (!inWindow(item.publishedAt, now, windowMs)) continue;
181
+ if (!matchesSymbol(item, tokens)) continue;
182
+ items.push(item);
183
+ }
184
+ else unavailable.push(result.reason instanceof Error ? result.reason.message : String(result.reason));
185
+ items.sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt));
186
+ return {
187
+ items: items.slice(0, limit),
188
+ unavailable
189
+ };
190
+ }
191
+ function clampNumber(value, fallback, min, max) {
192
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
193
+ return Math.min(Math.max(Math.trunc(value), min), max);
194
+ }
195
+ //#endregion
196
+ export { aggregateNews, deriveSymbolTokens, parseCryptoPanic, parseRss2 };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@dshtrading/kit-crypto",
3
+ "description": "Crypto toolkit plugin for dsh-trading: market-specific tools and bundled trading skills (skill provider); runs as a crypto-trader preset row (session-scoped)",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "./lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": [
16
+ "lib",
17
+ "assets"
18
+ ],
19
+ "license": "PolyForm-Noncommercial-1.0.0",
20
+ "dependencies": {
21
+ "@dshtrading/indicators": "0.1.0",
22
+ "@dshtrading/knowledge": "0.1.0"
23
+ },
24
+ "peerDependencies": {
25
+ "@deepseek-ai/cordis": ">=4.0.0",
26
+ "@deepseek-ai/dsh-skill": ">=0.1.2-alpha.1",
27
+ "@deepseek-ai/dsh-tools": ">=0.1.2-alpha.1",
28
+ "@deepseek-ai/schemastery": ">=3.18.0"
29
+ },
30
+ "devDependencies": {
31
+ "tsdown": "^0.22.0",
32
+ "vitest": "^3.0.0",
33
+ "@dshtrading/api": "0.1.0"
34
+ },
35
+ "scripts": {
36
+ "build": "tsdown",
37
+ "test": "vitest run"
38
+ }
39
+ }