@apocdata-info/mcp-server 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.
Files changed (74) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/LICENSE +191 -0
  3. package/README.md +230 -0
  4. package/dist/cache.d.ts +26 -0
  5. package/dist/cache.js +51 -0
  6. package/dist/cache.js.map +1 -0
  7. package/dist/client.js +113 -0
  8. package/dist/errors.d.ts +37 -0
  9. package/dist/errors.js +56 -0
  10. package/dist/errors.js.map +1 -0
  11. package/dist/index.d.ts +17 -0
  12. package/dist/index.js +259 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/resources.js +229 -0
  15. package/dist/server.d.ts +20 -0
  16. package/dist/server.js +69 -0
  17. package/dist/server.js.map +1 -0
  18. package/dist/tools/factor-registry.d.ts +17 -0
  19. package/dist/tools/factor-registry.js +36 -0
  20. package/dist/tools/factor-registry.js.map +1 -0
  21. package/dist/tools/index.d.ts +21 -0
  22. package/dist/tools/index.js +45 -0
  23. package/dist/tools/index.js.map +1 -0
  24. package/dist/tools/macro-dashboard.d.ts +17 -0
  25. package/dist/tools/macro-dashboard.js +30 -0
  26. package/dist/tools/macro-dashboard.js.map +1 -0
  27. package/dist/tools/macro-indicator.d.ts +17 -0
  28. package/dist/tools/macro-indicator.js +45 -0
  29. package/dist/tools/macro-indicator.js.map +1 -0
  30. package/dist/tools/quotes-snapshot.d.ts +17 -0
  31. package/dist/tools/quotes-snapshot.js +39 -0
  32. package/dist/tools/quotes-snapshot.js.map +1 -0
  33. package/dist/tools/stock-bars-daily.d.ts +17 -0
  34. package/dist/tools/stock-bars-daily.js +45 -0
  35. package/dist/tools/stock-bars-daily.js.map +1 -0
  36. package/dist/tools/stock-detail.d.ts +17 -0
  37. package/dist/tools/stock-detail.js +38 -0
  38. package/dist/tools/stock-detail.js.map +1 -0
  39. package/dist/tools/stock-financials.d.ts +17 -0
  40. package/dist/tools/stock-financials.js +36 -0
  41. package/dist/tools/stock-financials.js.map +1 -0
  42. package/dist/tools/stock-list.d.ts +17 -0
  43. package/dist/tools/stock-list.js +39 -0
  44. package/dist/tools/stock-list.js.map +1 -0
  45. package/dist/tools/stock-news.d.ts +17 -0
  46. package/dist/tools/stock-news.js +36 -0
  47. package/dist/tools/stock-news.js.map +1 -0
  48. package/dist/tools/stock-quote.d.ts +17 -0
  49. package/dist/tools/stock-quote.js +32 -0
  50. package/dist/tools/stock-quote.js.map +1 -0
  51. package/dist/tools/top10-holders.d.ts +17 -0
  52. package/dist/tools/top10-holders.js +35 -0
  53. package/dist/tools/top10-holders.js.map +1 -0
  54. package/dist/tools/trade-days.d.ts +17 -0
  55. package/dist/tools/trade-days.js +36 -0
  56. package/dist/tools/trade-days.js.map +1 -0
  57. package/dist/tools/types.d.ts +29 -0
  58. package/dist/tools/types.js +16 -0
  59. package/dist/tools/types.js.map +1 -0
  60. package/dist/tools.js +540 -0
  61. package/dist/transport.d.ts +44 -0
  62. package/dist/transport.js +87 -0
  63. package/dist/transport.js.map +1 -0
  64. package/dist/types.d.ts +54 -0
  65. package/dist/types.js +16 -0
  66. package/dist/types.js.map +1 -0
  67. package/package.json +67 -0
  68. package/scripts/client-unit-test.mjs +147 -0
  69. package/scripts/contract-test.mjs +167 -0
  70. package/scripts/coverage-test.mjs +126 -0
  71. package/scripts/error-path-test.mjs +110 -0
  72. package/scripts/freshness-probe.mjs +144 -0
  73. package/scripts/integration-test.mjs +284 -0
  74. package/scripts/mcp-e2e-test.mjs +196 -0
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Copyright (c) 2018-2099, Chill Zhuang 庄骞 (bladejava@qq.com).
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ *
14
+ * @author Chill
15
+ */
16
+ /**
17
+ * 后端 R<T> 信封(blade 标准)。
18
+ */
19
+ export interface BladeResponse<T = unknown> {
20
+ code: number;
21
+ success: boolean;
22
+ msg?: string;
23
+ data: T;
24
+ }
25
+ /**
26
+ * 后端 TdcResponse 信封(数据面 /v1/** 用)。
27
+ */
28
+ export interface TdcResponse<T = unknown> {
29
+ data: T;
30
+ meta?: {
31
+ source?: string;
32
+ ingestLagSeconds?: number;
33
+ [key: string]: unknown;
34
+ };
35
+ pagination?: {
36
+ nextCursor?: string;
37
+ hasMore?: boolean;
38
+ total?: number;
39
+ };
40
+ error?: {
41
+ code: string;
42
+ message: string;
43
+ };
44
+ }
45
+ /**
46
+ * MCP tool 调用返回(LLM-friendly)。
47
+ */
48
+ export interface ToolResult {
49
+ error?: boolean;
50
+ message?: string;
51
+ code?: string;
52
+ data?: unknown;
53
+ meta?: Record<string, unknown>;
54
+ }
package/dist/types.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Copyright (c) 2018-2099, Chill Zhuang 庄骞 (bladejava@qq.com).
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ *
14
+ * @author Chill
15
+ */
16
+ export {};
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG"}
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@apocdata-info/mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "天启至数 ApocData - MCP Server for A-share market data (46 free, no-auth endpoints)",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "bin": {
9
+ "apocdata-mcp": "dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "scripts",
14
+ "README.md",
15
+ "CHANGELOG.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "watch": "tsc --watch",
21
+ "start": "node dist/index.js",
22
+ "dev": "tsc && node dist/index.js",
23
+ "test:unit": "node scripts/client-unit-test.mjs",
24
+ "test:skill": "node scripts/skill-doc-test.mjs",
25
+ "test:contract": "node scripts/contract-test.mjs",
26
+ "test:errors": "node scripts/error-path-test.mjs",
27
+ "test:coverage": "node scripts/coverage-test.mjs",
28
+ "test:e2e": "node scripts/mcp-e2e-test.mjs",
29
+ "test:integration": "node scripts/integration-test.mjs",
30
+ "test": "npm run build && npm run test:unit && npm run test:skill && npm run test:contract && npm run test:errors && npm run test:coverage && npm run test:e2e && npm run test:integration",
31
+ "test:ci": "npm run build && npm run test:unit && npm run test:integration",
32
+ "version:patch": "npm version patch -m 'chore(release): :tada: %s'",
33
+ "version:minor": "npm version minor -m 'chore(release): :tada: %s'",
34
+ "version:major": "npm version major -m 'chore(release): :tada: %s'",
35
+ "publish:dry": "npm publish --dry-run --access public",
36
+ "prepublishOnly": "npm run test:ci"
37
+ },
38
+ "keywords": [
39
+ "mcp",
40
+ "model-context-protocol",
41
+ "claude",
42
+ "a-share",
43
+ "stock",
44
+ "tushare",
45
+ "apocdata",
46
+ "tianqi"
47
+ ],
48
+ "engines": {
49
+ "node": ">=18"
50
+ },
51
+ "dependencies": {
52
+ "@modelcontextprotocol/sdk": "^1.0.0"
53
+ },
54
+ "devDependencies": {
55
+ "@types/node": "^22.0.0",
56
+ "typescript": "^5.5.0"
57
+ },
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "git+https://github.com/ApocData/ApocData-skill.git",
61
+ "directory": "mcp-server"
62
+ },
63
+ "homepage": "https://data.tianqis.com",
64
+ "bugs": {
65
+ "url": "https://github.com/ApocData/ApocData-skill/issues"
66
+ }
67
+ }
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ApocDataClient 单元测试。
4
+ *
5
+ * 用 monkeypatch globalThis.fetch 模拟 HTTP 响应,不打外网。
6
+ * 验证:
7
+ * - 4xx 不重试,直接返回
8
+ * - 5xx 重试到成功
9
+ * - 5xx 重试用尽抛 NetworkError
10
+ * - 超时抛 NetworkError(带 timeout 字样)
11
+ * - meta 头里只保留 x-tdc-* / cache-control
12
+ * - URL 构造:空值参数被跳过,多个参数串接
13
+ */
14
+
15
+ import { ApocDataClient, NetworkError } from '../dist/client.js';
16
+
17
+ const checks = [];
18
+ function check(name, cond, detail = '') {
19
+ checks.push({ name, pass: !!cond, detail });
20
+ }
21
+
22
+ // 工具:把 fetch 替换成可编排的 mock
23
+ function mockFetch(handler) {
24
+ const calls = [];
25
+ globalThis.fetch = async (url, init) => {
26
+ calls.push({ url: String(url), init });
27
+ return handler(calls.length, url, init);
28
+ };
29
+ return calls;
30
+ }
31
+
32
+ function jsonResponse(status, body, headers = {}) {
33
+ return new Response(JSON.stringify(body), {
34
+ status,
35
+ headers: { 'content-type': 'application/json', ...headers },
36
+ });
37
+ }
38
+
39
+ // === 1. 4xx 不重试 ===
40
+ {
41
+ const calls = mockFetch((n) => jsonResponse(400, { code: 400, success: false, msg: 'bad' }));
42
+ const client = new ApocDataClient('http://x', { maxRetries: 3, retryBackoffMs: 1 });
43
+ const result = await client.call('any', {});
44
+ check('4xx 直接返回,不重试', calls.length === 1, `actually called ${calls.length} times`);
45
+ check('4xx body 透传', result.status === 400 && result.body.success === false);
46
+ }
47
+
48
+ // === 2. 5xx 重试到成功 ===
49
+ {
50
+ const calls = mockFetch((n) => {
51
+ if (n < 3) return jsonResponse(503, { msg: 'unavailable' });
52
+ return jsonResponse(200, { success: true, data: { ok: true } });
53
+ });
54
+ const client = new ApocDataClient('http://x', { maxRetries: 3, retryBackoffMs: 1 });
55
+ const result = await client.call('any', {});
56
+ check('5xx 重试到成功(3 次试 → 第 3 次返回 200)', result.status === 200, `calls=${calls.length}`);
57
+ check('重试后返回 success=true', result.body.success === true);
58
+ }
59
+
60
+ // === 3. 5xx 重试用尽抛 NetworkError ===
61
+ // 注:客户端 5xx 重试用尽后会返回最后一次的 5xx 响应(不是抛错),因为只有 fetch 异常才会跑到 catch
62
+ // 这是设计选择:5xx 仍是合法 HTTP,body 可能有信息
63
+ {
64
+ const calls = mockFetch((n) => jsonResponse(503, { msg: 'still unavailable' }));
65
+ const client = new ApocDataClient('http://x', { maxRetries: 2, retryBackoffMs: 1 });
66
+ const result = await client.call('any', {});
67
+ check('5xx 重试用尽返回最后响应(不抛错)', result.status === 503);
68
+ check('5xx 总共调用 maxRetries+1 次', calls.length === 3, `calls=${calls.length}`);
69
+ }
70
+
71
+ // === 4. 网络异常重试用尽抛 NetworkError ===
72
+ {
73
+ const calls = mockFetch((n) => {
74
+ throw new TypeError('fetch failed');
75
+ });
76
+ const client = new ApocDataClient('http://x', { maxRetries: 1, retryBackoffMs: 1 });
77
+ let caught = null;
78
+ try { await client.call('any', {}); } catch (e) { caught = e; }
79
+ check('网络异常用尽抛 NetworkError', caught instanceof NetworkError);
80
+ check('网络异常重试次数对', calls.length === 2, `calls=${calls.length}`);
81
+ check('NetworkError 含 cause', caught?.cause instanceof TypeError);
82
+ }
83
+
84
+ // === 5. 超时(AbortError)抛 NetworkError ===
85
+ {
86
+ const calls = mockFetch((n, url, init) => {
87
+ return new Promise((_, reject) => {
88
+ init.signal.addEventListener('abort', () => {
89
+ const err = new Error('aborted');
90
+ err.name = 'AbortError';
91
+ reject(err);
92
+ });
93
+ });
94
+ });
95
+ const client = new ApocDataClient('http://x', { timeoutMs: 50, maxRetries: 0, retryBackoffMs: 1 });
96
+ let caught = null;
97
+ try { await client.call('slow', {}); } catch (e) { caught = e; }
98
+ check('超时抛 NetworkError', caught instanceof NetworkError);
99
+ check('超时错误消息含 "timed out"', caught?.message?.includes('timed out'));
100
+ }
101
+
102
+ // === 6. meta 头提取 ===
103
+ {
104
+ mockFetch((n) => jsonResponse(200, { success: true, data: {} }, {
105
+ 'x-tdc-ratelimit-remaining': '42',
106
+ 'x-tdc-truncated': 'false',
107
+ 'cache-control': 'max-age=300',
108
+ 'x-some-other': 'ignored',
109
+ 'content-length': '100',
110
+ }));
111
+ const client = new ApocDataClient('http://x');
112
+ const result = await client.call('any', {});
113
+ const keys = Object.keys(result.meta).map(k => k.toLowerCase()).sort();
114
+ check('meta 含 x-tdc-* 头', keys.includes('x-tdc-ratelimit-remaining') && keys.includes('x-tdc-truncated'));
115
+ check('meta 含 cache-control', keys.includes('cache-control'));
116
+ check('meta 不含无关头', !keys.includes('x-some-other') && !keys.includes('content-length'));
117
+ }
118
+
119
+ // === 7. URL 构造 ===
120
+ {
121
+ let capturedUrl;
122
+ mockFetch((n, url) => { capturedUrl = String(url); return jsonResponse(200, {}); });
123
+ const client = new ApocDataClient('http://x/base/', { maxRetries: 0 });
124
+ await client.call('/quote', { symbol: '600519', limit: 5, empty: '', missing: undefined, zero: 0 });
125
+ check('URL 拼接(base 末尾 / 与 path 起始 / 都处理)', capturedUrl.startsWith('http://x/base/quote?'));
126
+ check('URL 含非空参数', capturedUrl.includes('symbol=600519') && capturedUrl.includes('limit=5'));
127
+ check('URL 含 zero(0 是有效值)', capturedUrl.includes('zero=0'));
128
+ check('URL 跳过空字符串和 undefined', !capturedUrl.includes('empty=') && !capturedUrl.includes('missing='));
129
+ }
130
+
131
+ // === 8. body 非 JSON 也不崩 ===
132
+ {
133
+ mockFetch((n) => new Response('<html>500</html>', { status: 500 }));
134
+ const client = new ApocDataClient('http://x', { maxRetries: 0 });
135
+ const result = await client.call('any', {});
136
+ check('非 JSON body 包装为 { raw }', result.body?.raw?.includes('<html>') ?? false);
137
+ }
138
+
139
+ // 报告
140
+ const pass = checks.filter(c => c.pass).length;
141
+ console.log('');
142
+ for (const c of checks) {
143
+ console.log(`${c.pass ? '✓' : '✗'} ${c.name}${c.detail ? ` (${c.detail})` : ''}`);
144
+ }
145
+ console.log('');
146
+ console.log(`${pass}/${checks.length} checks pass`);
147
+ process.exit(pass === checks.length ? 0 : 1);
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 契约测试:对全部公开工具逐个发真实 HTTP 调用。
4
+ * 输出表格:tool / HTTP status / API success / data 形状 / 备注
5
+ *
6
+ * 用法:
7
+ * node scripts/contract-test.mjs
8
+ * APOCDATA_BASE_URL=http://localhost:8200/blade-dataplatform/open/data node scripts/contract-test.mjs
9
+ */
10
+
11
+ import { ALL_TOOLS } from '../dist/tools.js';
12
+
13
+ const BASE = process.env.APOCDATA_BASE_URL
14
+ ?? 'https://data.tianqis.com/api/blade-dataplatform/open/data';
15
+
16
+ // 每个 tool 的测试入参。stable A 股:000001 平安银行 / 600519 茅台
17
+ const TEST_INPUTS = {
18
+ quote: { symbol: '000001' },
19
+ quotes: { symbols: '000001,600519' },
20
+ daily: { symbol: '600519', limit: 3 },
21
+ stocks: { q: '茅台', limit: 3 },
22
+ stock: { symbol: '600519' },
23
+ st: { symbol: '600519' },
24
+ ranking: { direction: 'gain', limit: 3 },
25
+ indexes: { q: '沪深300', limit: 3 },
26
+ 'index-daily': { tsCode: '000300.SH', limit: 3 },
27
+ 'hot-rank': { type: 'A股市场', limit: 3 },
28
+
29
+ financial: { symbol: '600519', limit: 1 },
30
+ express: { symbol: '600519', limit: 1 },
31
+ dividend: { symbol: '600519', limit: 3 },
32
+ holders: { symbol: '600519' },
33
+ 'holder-number': { symbol: '600519', limit: 3 },
34
+ 'share-float': { symbol: '600519', limit: 3 },
35
+ repurchase: { symbol: '600519', limit: 3 },
36
+ 'block-trade': { symbol: '600519', limit: 3 },
37
+
38
+ moneyflow: { symbol: '600519', limit: 3 },
39
+ hsgt: { limit: 3 },
40
+ 'hk-hold': { symbol: '600519', limit: 3 },
41
+ 'hk-daily': { tsCode: '00700.HK', limit: 3 },
42
+ margin: { limit: 3 },
43
+ 'dragon-tiger': { limit: 3 },
44
+ 'hot-money': { limit: 3 },
45
+ 'hot-money-detail': { limit: 3 },
46
+
47
+ 'limit-list': { kind: 'U', limit: 3 },
48
+ 'limit-step': { limit: 3 },
49
+ 'sector-flow': { type: 'industry', limit: 3 },
50
+ 'cyq-perf': { symbol: '600519', limit: 3 },
51
+
52
+ announcements: { symbol: '600519', limit: 1, fields: 'title,ann_date' },
53
+ survey: { symbol: '600519', limit: 3 },
54
+
55
+ concepts: { limit: 3 },
56
+ 'concept-stocks': { themeCode: 'INVALID', limit: 3 },
57
+ 'ths-boards': { limit: 3 },
58
+ 'ths-board-stocks': { tsCode: 'INVALID', limit: 3 },
59
+
60
+ 'convertible-bonds': { limit: 3 },
61
+ 'cb-price-chg': { tsCode: '127026.SZ', limit: 3 },
62
+
63
+ factors: {},
64
+ 'tech-factor': { symbol: '600519', limit: 1 },
65
+
66
+ macro: { type: 'CPI', limit: 3 },
67
+ 'macro-latest': { type: 'CPI' },
68
+ 'macro-definition': { type: 'CPI' },
69
+
70
+ calendar: { start: '20260101', end: '20260131' },
71
+
72
+ 'profile-full': { symbol: '600519' },
73
+ 'factor-categories': {},
74
+ };
75
+
76
+ function buildUrl(tool, params) {
77
+ const url = new URL(`${BASE.replace(/\/+$/, '')}/${tool.path.replace(/^\/+/, '')}`);
78
+ for (const [k, v] of Object.entries(params)) {
79
+ if (v === undefined || v === null || v === '') continue;
80
+ url.searchParams.append(k, String(v));
81
+ }
82
+ return url.toString();
83
+ }
84
+
85
+ function shape(data) {
86
+ if (data === null || data === undefined) return 'null';
87
+ if (Array.isArray(data)) return `array[${data.length}]`;
88
+ if (typeof data === 'object') {
89
+ const keys = Object.keys(data);
90
+ if (keys.length === 0) return 'object{}';
91
+ if ('columns' in data && 'rows' in data) {
92
+ return `compact[cols=${data.columns?.length},rows=${data.rows?.length}]`;
93
+ }
94
+ return `object{${keys.length} keys}`;
95
+ }
96
+ return typeof data;
97
+ }
98
+
99
+ const results = [];
100
+
101
+ for (const tool of ALL_TOOLS) {
102
+ const input = TEST_INPUTS[tool.name];
103
+ if (input === undefined) {
104
+ results.push({ tool: tool.name, status: 'SKIP', note: 'no test input defined' });
105
+ continue;
106
+ }
107
+ const url = buildUrl(tool, input);
108
+ let line = { tool: tool.name, path: tool.path };
109
+ try {
110
+ const res = await fetch(url, {
111
+ headers: { 'User-Agent': 'apocdata-contract-test/0.1', Accept: 'application/json' },
112
+ });
113
+ line.http = res.status;
114
+ const errCode = res.headers.get('x-tdc-error-code');
115
+ if (errCode) line.errCode = errCode;
116
+ const truncated = res.headers.get('x-tdc-limit-truncated');
117
+ if (truncated === 'true') line.truncated = true;
118
+
119
+ const text = await res.text();
120
+ let body;
121
+ try { body = JSON.parse(text); } catch { body = null; }
122
+ if (body && typeof body === 'object') {
123
+ line.apiSuccess = body.success;
124
+ line.dataShape = shape(body.data);
125
+ if (body.success === false) line.msg = body.msg;
126
+ else if (body.msg && body.msg !== '操作成功') line.msg = body.msg;
127
+ } else {
128
+ line.dataShape = 'non-json';
129
+ line.msg = text.slice(0, 80);
130
+ }
131
+ } catch (err) {
132
+ line.http = 'ERR';
133
+ line.msg = err.message;
134
+ }
135
+ results.push(line);
136
+ }
137
+
138
+ // 输出表格
139
+ const headers = ['tool', 'http', 'apiSuccess', 'errCode', 'dataShape', 'truncated', 'msg'];
140
+ const colWidth = {
141
+ tool: Math.max(...results.map(r => r.tool.length), 4),
142
+ http: 5,
143
+ apiSuccess: 11,
144
+ errCode: 24,
145
+ dataShape: 22,
146
+ truncated: 5,
147
+ msg: 50,
148
+ };
149
+ const pad = (s, w) => String(s ?? '').slice(0, w).padEnd(w);
150
+ console.log(headers.map(h => pad(h, colWidth[h])).join(' │ '));
151
+ console.log(headers.map(h => '─'.repeat(colWidth[h])).join('─┼─'));
152
+
153
+ const counts = { ok: 0, apiFail: 0, http4xx: 0, http5xx: 0, err: 0 };
154
+ for (const r of results) {
155
+ console.log(headers.map(h => pad(r[h], colWidth[h])).join(' │ '));
156
+ if (r.http === 'ERR') counts.err++;
157
+ else if (typeof r.http === 'number' && r.http >= 500) counts.http5xx++;
158
+ else if (typeof r.http === 'number' && r.http >= 400) counts.http4xx++;
159
+ else if (r.apiSuccess === false) counts.apiFail++;
160
+ else counts.ok++;
161
+ }
162
+
163
+ console.log('');
164
+ console.log(`Total ${results.length} tools │ ✓ ok ${counts.ok} │ ⚠ apiFail ${counts.apiFail} │ http4xx ${counts.http4xx} │ http5xx ${counts.http5xx} │ err ${counts.err}`);
165
+
166
+ const skipped = results.filter((r) => r.status === 'SKIP').length;
167
+ process.exit(counts.http4xx + counts.http5xx + counts.apiFail + counts.err + skipped > 0 ? 1 : 0);
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 覆盖测试:
4
+ * 1. 截断:传 limit=超上限,验证响应头 X-Tdc-Limit-Truncated=true
5
+ * 2. 限流:连续调 N 次,看 X-RateLimit-Remaining 是否递减
6
+ * 3. 枚举遍历:把每个有 enum 的工具所有合法值都跑一遍
7
+ *
8
+ * 限流和截断是 advice 行为,若线上 advice 未部署会被标 LAG。
9
+ */
10
+
11
+ const BASE = process.env.APOCDATA_BASE_URL
12
+ ?? 'https://data.tianqis.com/api/blade-dataplatform/open/data';
13
+
14
+ const checks = [];
15
+ function check(kind, name, cond, detail = '') {
16
+ checks.push({ kind, name, pass: !!cond, detail });
17
+ }
18
+
19
+ async function fetchJson(path, params = {}) {
20
+ const url = new URL(`${BASE.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`);
21
+ for (const [k, v] of Object.entries(params)) {
22
+ if (v === undefined || v === null || v === '') continue;
23
+ url.searchParams.append(k, String(v));
24
+ }
25
+ const res = await fetch(url, {
26
+ headers: { 'User-Agent': 'apocdata-coverage-test/0.1', Accept: 'application/json' },
27
+ });
28
+ const text = await res.text();
29
+ let body;
30
+ try { body = JSON.parse(text); } catch { body = null; }
31
+ const headers = {};
32
+ for (const [k, v] of res.headers) headers[k.toLowerCase()] = v;
33
+ return { status: res.status, body, headers };
34
+ }
35
+
36
+ // ===== 1. 截断 =====
37
+ // daily 上限 30,传 999 应触发截断
38
+ {
39
+ const r = await fetchJson('daily', { symbol: '600519', limit: 999 });
40
+ const truncated = r.headers['x-tdc-limit-truncated'];
41
+ check(truncated ? 'PROD' : 'LAG', 'daily limit=999 触发 X-Tdc-Limit-Truncated 头',
42
+ truncated === 'true', `header=${truncated ?? '(missing)'}`);
43
+ const dataLen = Array.isArray(r.body?.data) ? r.body.data.length : 0;
44
+ check('PROD', 'daily limit=999 实际返回 ≤ 30 条', dataLen <= 30, `dataLen=${dataLen}`);
45
+ }
46
+
47
+ // holders 不带 limit 也会被截断(@RequestParam 默认值),换个 ranking 验
48
+ {
49
+ const r = await fetchJson('ranking', { direction: 'gain', limit: 999 });
50
+ const truncated = r.headers['x-tdc-limit-truncated'];
51
+ check(truncated ? 'PROD' : 'LAG', 'ranking limit=999 触发截断',
52
+ truncated === 'true', `header=${truncated ?? '(missing)'}`);
53
+ const dataLen = Array.isArray(r.body?.data) ? r.body.data.length : 0;
54
+ check('PROD', 'ranking limit=999 实际返回 ≤ 50 条', dataLen <= 50, `dataLen=${dataLen}`);
55
+ }
56
+
57
+ // ===== 2. 限流 =====
58
+ // 连续打 5 次 quote,看 remaining 是否递减
59
+ {
60
+ const remainings = [];
61
+ for (let i = 0; i < 5; i++) {
62
+ const r = await fetchJson('quote', { symbol: '600519' });
63
+ const rem = r.headers['x-ratelimit-remaining'];
64
+ if (rem !== undefined) remainings.push(parseInt(rem, 10));
65
+ }
66
+ if (remainings.length === 0) {
67
+ check('LAG', '限流头 X-RateLimit-Remaining 存在', false, 'no header in any of 5 responses');
68
+ } else {
69
+ check('PROD', '限流头存在且为数值', remainings.every(n => Number.isFinite(n)), `samples=${remainings.join(',')}`);
70
+ const monotone = remainings.every((n, i) => i === 0 || n <= remainings[i - 1]);
71
+ check('PROD', '限流剩余值随调用递减', monotone, `samples=${remainings.join(',')}`);
72
+ }
73
+ }
74
+
75
+ // ===== 3. 枚举遍历 =====
76
+ const ENUM_CASES = [
77
+ // ranking direction
78
+ { path: 'ranking', params: { direction: 'gain', limit: 1 }, label: 'ranking direction=gain' },
79
+ { path: 'ranking', params: { direction: 'loss', limit: 1 }, label: 'ranking direction=loss' },
80
+ // limit-list kind
81
+ { path: 'limit-list', params: { kind: 'U', limit: 1 }, label: 'limit-list kind=U' },
82
+ { path: 'limit-list', params: { kind: 'D', limit: 1 }, label: 'limit-list kind=D' },
83
+ { path: 'limit-list', params: { kind: 'Z', limit: 1 }, label: 'limit-list kind=Z' },
84
+ // sector-flow type
85
+ { path: 'sector-flow', params: { type: 'industry', limit: 1 }, label: 'sector-flow type=industry' },
86
+ { path: 'sector-flow', params: { type: 'concept', limit: 1 }, label: 'sector-flow type=concept' },
87
+ { path: 'sector-flow', params: { type: 'region', limit: 1 }, label: 'sector-flow type=region' },
88
+ // hot-rank type
89
+ { path: 'hot-rank', params: { type: 'A股市场', limit: 1 }, label: 'hot-rank type=A股市场' },
90
+ { path: 'hot-rank', params: { type: 'ETF基金', limit: 1 }, label: 'hot-rank type=ETF基金' },
91
+ { path: 'hot-rank', params: { type: '港股市场', limit: 1 }, label: 'hot-rank type=港股市场' },
92
+ { path: 'hot-rank', params: { type: '美股市场', limit: 1 }, label: 'hot-rank type=美股市场' },
93
+ // margin exchange
94
+ { path: 'margin', params: { exchange: 'SSE', limit: 1 }, label: 'margin exchange=SSE' },
95
+ { path: 'margin', params: { exchange: 'SZSE', limit: 1 }, label: 'margin exchange=SZSE' },
96
+ { path: 'margin', params: { exchange: 'BSE', limit: 1 }, label: 'margin exchange=BSE' },
97
+ // macro type
98
+ { path: 'macro', params: { type: 'GDP', limit: 1 }, label: 'macro type=GDP' },
99
+ { path: 'macro', params: { type: 'CPI', limit: 1 }, label: 'macro type=CPI' },
100
+ { path: 'macro', params: { type: 'PPI', limit: 1 }, label: 'macro type=PPI' },
101
+ { path: 'macro', params: { type: 'PMI', limit: 1 }, label: 'macro type=PMI' },
102
+ ];
103
+
104
+ for (const c of ENUM_CASES) {
105
+ const r = await fetchJson(c.path, c.params);
106
+ check('PROD', `${c.label} → success=true`,
107
+ r.status === 200 && r.body?.success === true,
108
+ `http=${r.status} success=${r.body?.success}`);
109
+ }
110
+
111
+ // 报告
112
+ console.log('');
113
+ for (const c of checks) {
114
+ const sym = c.kind === 'LAG' ? (c.pass ? '✓' : '— LAG') : (c.pass ? '✓' : '✗');
115
+ console.log(`[${c.kind}] ${sym} ${c.name}${c.detail ? ` (${c.detail})` : ''}`);
116
+ }
117
+
118
+ const prod = checks.filter(c => c.kind === 'PROD');
119
+ const lag = checks.filter(c => c.kind === 'LAG');
120
+ const prodPass = prod.filter(c => c.pass).length;
121
+ const lagPass = lag.filter(c => c.pass).length;
122
+
123
+ console.log('');
124
+ console.log(`PROD: ${prodPass}/${prod.length} pass`);
125
+ if (lag.length) console.log(`LAG : ${lagPass}/${lag.length} 线上已生效(其余待发版)`);
126
+ process.exit(prodPass === prod.length ? 0 : 1);