gangtise-openapi-cli 0.10.3 → 0.10.5
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.md +72 -50
- package/dist/src/cli.js +97 -91
- package/dist/src/core/auth.js +7 -3
- package/dist/src/core/client.js +17 -7
- package/dist/src/core/lookupData/index.js +4 -1
- package/dist/src/core/normalize.js +7 -2
- package/dist/src/core/output.js +20 -3
- package/dist/src/version.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,19 +1,13 @@
|
|
|
1
1
|
# Gangtise OpenAPI CLI
|
|
2
2
|
|
|
3
|
-
一个可直接调用 Gangtise OpenAPI
|
|
3
|
+
一个可直接调用 Gangtise OpenAPI 获取金融数据的命令行工具,同时提供Agent Skill。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 首次安装
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
npm install -g gangtise-openapi-cli
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
更新到最新版:
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
npm update -g gangtise-openapi-cli
|
|
15
|
-
```
|
|
16
|
-
|
|
17
11
|
验证安装:
|
|
18
12
|
|
|
19
13
|
```bash
|
|
@@ -28,8 +22,22 @@ cd gangtise-openapi-cli
|
|
|
28
22
|
npm install
|
|
29
23
|
npm run dev -- --help
|
|
30
24
|
```
|
|
25
|
+
## 版本更新
|
|
26
|
+
|
|
27
|
+
查看当前版本(自动与线上版本比对):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
gangtise --version
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
手动更新到最新版:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm update -g gangtise-openapi-cli
|
|
37
|
+
```
|
|
31
38
|
|
|
32
|
-
|
|
39
|
+
|
|
40
|
+
## 环境配置
|
|
33
41
|
|
|
34
42
|
优先读取以下环境变量:
|
|
35
43
|
|
|
@@ -40,8 +48,53 @@ export GANGTISE_BASE_URL="https://open.gangtise.com"
|
|
|
40
48
|
export GANGTISE_TOKEN="Bearer xxx"
|
|
41
49
|
```
|
|
42
50
|
|
|
43
|
-
如果没有 `GANGTISE_TOKEN`,CLI 会自动调用 token
|
|
51
|
+
如果没有 `GANGTISE_TOKEN`,CLI 会自动调用 token 接口并缓存到本地(`~/.config/gangtise/token.json`,权限 0600)。
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
## AI Agent Skill
|
|
55
|
+
|
|
56
|
+
本项目包含 Skill 定义(`gangtise-openapi/SKILL.md`),可让 AI agent 自动调用 `gangtise` CLI 完成投研数据查询。支持以下 AI 编程助手:
|
|
57
|
+
|
|
58
|
+
- [Claude Code](https://claude.ai/claude-code) — `~/.claude/skills/`
|
|
59
|
+
- [OpenClaw](https://github.com/openclaw/openclaw) — `~/.openclaw/skills/`
|
|
60
|
+
- [Hermes](https://github.com/nicepkg/hermes) — `~/.hermes/skills/`
|
|
61
|
+
|
|
62
|
+
Skill 目录结构:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
gangtise-openapi/
|
|
66
|
+
├── SKILL.md # 主 skill 文件(命令参考、参数枚举、使用规则)
|
|
67
|
+
└── references/
|
|
68
|
+
├── fields.md # 字段中英文对照速查表
|
|
69
|
+
└── lookup-ids.md # 常用 ID 速查表(行业/券商/机构/公告分类等)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
安装:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# Claude Code
|
|
76
|
+
cp -r gangtise-openapi ~/.claude/skills/gangtise-openapi
|
|
77
|
+
|
|
78
|
+
# OpenClaw
|
|
79
|
+
cp -r gangtise-openapi ~/.openclaw/skills/gangtise-openapi
|
|
80
|
+
|
|
81
|
+
# Hermes
|
|
82
|
+
cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
> **版本更新**:每次 CLI 发版时,`gangtise-openapi/SKILL.md` 的 `version` 字段会自动同步。更新 CLI 后,请将项目中的 `gangtise-openapi/` 目录重新复制到对应的 skills 目录覆盖更新:
|
|
86
|
+
>
|
|
87
|
+
> ```bash
|
|
88
|
+
> # 示例:更新 Claude Code 的 skill
|
|
89
|
+
> cp -r gangtise-openapi ~/.claude/skills/gangtise-openapi
|
|
90
|
+
> ```
|
|
91
|
+
>
|
|
92
|
+
> 可通过查看 SKILL.md 头部的 `version` 字段确认当前版本。
|
|
44
93
|
|
|
94
|
+
安装后,可以用自然语言触发,例如:
|
|
95
|
+
- "帮我查今天所有的研报"
|
|
96
|
+
- "用 gangtise 命令查一下贵州茅台的日K线"
|
|
97
|
+
- "导出最近一周的首席观点到 jsonl"
|
|
45
98
|
|
|
46
99
|
## 数据接口覆盖
|
|
47
100
|
|
|
@@ -83,43 +136,6 @@ export GANGTISE_TOKEN="Bearer xxx"
|
|
|
83
136
|
| | `my-conference-list` / `my-conference-download` | 我的会议列表与下载 |
|
|
84
137
|
| **Raw** | `call` | 原始接口调用(可访问任意 endpoint) |
|
|
85
138
|
|
|
86
|
-
## AI Agent Skill
|
|
87
|
-
|
|
88
|
-
本项目包含 SKill 定义(`gangtise-openapi/SKILL.md`),可让 AI agent 自动调用 `gangtise` CLI 完成投研数据查询。支持以下 AI 编程助手:
|
|
89
|
-
|
|
90
|
-
- [Claude Code](https://claude.ai/claude-code) — `~/.claude/skills/`
|
|
91
|
-
- [OpenClaw](https://github.com/openclaw/openclaw) — `~/.openclaw/skills/`
|
|
92
|
-
- [Hermes](https://github.com/nicepkg/hermes) — `~/.hermes/skills/`
|
|
93
|
-
|
|
94
|
-
Skill 目录结构:
|
|
95
|
-
|
|
96
|
-
```
|
|
97
|
-
gangtise-openapi/
|
|
98
|
-
├── SKILL.md # 主 skill 文件(命令参考、参数枚举、使用规则)
|
|
99
|
-
└── references/
|
|
100
|
-
├── fields.md # 字段中英文对照速查表
|
|
101
|
-
└── lookup-ids.md # 常用 ID 速查表(行业/券商/机构/公告分类等)
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
安装:
|
|
105
|
-
|
|
106
|
-
```bash
|
|
107
|
-
# Claude Code
|
|
108
|
-
cp -r gangtise-openapi ~/.claude/skills/gangtise-openapi
|
|
109
|
-
|
|
110
|
-
# OpenClaw
|
|
111
|
-
cp -r gangtise-openapi ~/.openclaw/skills/gangtise-openapi
|
|
112
|
-
|
|
113
|
-
# Hermes
|
|
114
|
-
cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
安装后,可以用自然语言触发,例如:
|
|
118
|
-
- "帮我查今天所有的研报"
|
|
119
|
-
- "用 gangtise 命令查一下贵州茅台的日K线"
|
|
120
|
-
- "导出最近一周的首席观点到 jsonl"
|
|
121
|
-
|
|
122
|
-
|
|
123
139
|
## 命令概览
|
|
124
140
|
|
|
125
141
|
- `gangtise auth ...`
|
|
@@ -176,6 +192,8 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
|
|
|
176
192
|
- **有时间范围时**(传了 `--start-time/--end-time` 或 `--start-date/--end-date`):**省略 `--size`**,CLI 自动翻页查全
|
|
177
193
|
- **无时间范围时**(未传时间参数):默认 `--size 200`,防止一次查询数据量过大
|
|
178
194
|
- 如果显式传了 `--size`,则按指定值翻页,直到达到 `size` 或数据取完
|
|
195
|
+
- 安全上限:自动翻页最多 1000 页,防止异常循环
|
|
196
|
+
- 分页结果中 `total` 字段会被保留(json 格式输出 `{total, list}`),同时 stderr 输出 `Total: N, showing: M`
|
|
179
197
|
|
|
180
198
|
## 智能文件命名
|
|
181
199
|
|
|
@@ -279,7 +297,7 @@ gangtise ai peer-comparison --security-code 600519.SH
|
|
|
279
297
|
gangtise ai earnings-review --security-code 600519.SH --period 2025q3
|
|
280
298
|
gangtise ai theme-tracking --theme-id 121000131 --date 2026-03-01 --type morning
|
|
281
299
|
gangtise ai hot-topic --start-date 2026-03-22 --end-date 2026-03-27 --category morningBriefing --category noonBriefing --with-related-securities --with-close-reading
|
|
282
|
-
# 不传 --category 默认查全部类型(早报+午报+盘中快报+晚报),--with-related-securities 和 --with-close-reading
|
|
300
|
+
# 不传 --category 默认查全部类型(早报+午报+盘中快报+晚报),--with-related-securities 和 --with-close-reading 默认开启,可用 --no-with-related-securities / --no-with-close-reading 关闭
|
|
283
301
|
gangtise ai hot-topic --start-date 2026-04-15 --end-date 2026-04-17
|
|
284
302
|
gangtise ai research-outline --security-code 600519.SH
|
|
285
303
|
# 管理层讨论-财报
|
|
@@ -328,11 +346,13 @@ gangtise raw call insight.opinion.list --body '{"from":0,"size":120}'
|
|
|
328
346
|
支持:
|
|
329
347
|
|
|
330
348
|
- `table`
|
|
331
|
-
- `json`
|
|
332
|
-
- `jsonl
|
|
349
|
+
- `json`(分页结果保留 `{total, list}` 结构)
|
|
350
|
+
- `jsonl`(每行一条记录)
|
|
333
351
|
- `csv`
|
|
334
352
|
- `markdown`
|
|
335
353
|
|
|
354
|
+
所有格式均支持 `--output <path>` 输出到文件(自动创建父目录)。
|
|
355
|
+
|
|
336
356
|
## 常见错误
|
|
337
357
|
|
|
338
358
|
| 错误码 | 说明 |
|
|
@@ -349,3 +369,5 @@ gangtise raw call insight.opinion.list --body '{"from":0,"size":120}'
|
|
|
349
369
|
| `999999` | Gangtise 系统错误,请稍后重试 |
|
|
350
370
|
| `433007` | 不支持该数据源(`knowledge-resource-download` 需正确的 `resourceType + sourceId` 组合) |
|
|
351
371
|
| `430007` | 行情查询超出限制(日K线/分钟K线不传 `--security` 返回全市场,数据量过大;请指定证券代码或缩短日期范围) |
|
|
372
|
+
| `410110` | 异步任务生成中(非终态,需继续轮询) |
|
|
373
|
+
| `410111` | 异步任务生成失败(终态,不可重试) |
|
package/dist/src/cli.js
CHANGED
|
@@ -37,7 +37,8 @@ function getTitleCachePath() {
|
|
|
37
37
|
}
|
|
38
38
|
return _titleCachePath;
|
|
39
39
|
}
|
|
40
|
-
const
|
|
40
|
+
const TITLE_LOOKUP_SIZE = 200;
|
|
41
|
+
const TITLE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
41
42
|
async function readTitleCache() {
|
|
42
43
|
try {
|
|
43
44
|
const fs = await import("node:fs/promises");
|
|
@@ -63,11 +64,15 @@ function lookupTitleCache(data, endpoint, id) {
|
|
|
63
64
|
}
|
|
64
65
|
async function printData(data, format, output, cache) {
|
|
65
66
|
const normalized = await normalizeRows(data);
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
const items = Array.isArray(normalized)
|
|
68
|
+
? normalized
|
|
69
|
+
: (normalized && typeof normalized === "object" && Array.isArray(normalized.list))
|
|
70
|
+
? normalized.list
|
|
71
|
+
: null;
|
|
72
|
+
if (cache && items) {
|
|
68
73
|
const titleField = cache.titleField ?? "title";
|
|
69
74
|
const titles = {};
|
|
70
|
-
for (const row of
|
|
75
|
+
for (const row of items) {
|
|
71
76
|
if (row && typeof row === "object") {
|
|
72
77
|
const r = row;
|
|
73
78
|
const id = r[cache.idField];
|
|
@@ -79,6 +84,13 @@ async function printData(data, format, output, cache) {
|
|
|
79
84
|
if (Object.keys(titles).length > 0)
|
|
80
85
|
writeTitleCache(cache.endpointKey, titles).catch(() => { });
|
|
81
86
|
}
|
|
87
|
+
if (normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
|
|
88
|
+
const meta = normalized;
|
|
89
|
+
if (typeof meta.total === "number") {
|
|
90
|
+
const listLen = Array.isArray(meta.list) ? meta.list.length : 0;
|
|
91
|
+
process.stderr.write(`Total: ${meta.total}, showing: ${listLen}\n`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
82
94
|
const content = await renderOutput(normalized, format);
|
|
83
95
|
if (output) {
|
|
84
96
|
await saveOutputIfNeeded(content, output);
|
|
@@ -141,7 +153,7 @@ async function resolveTitle(client, result, listEndpoint, idField, idValue, titl
|
|
|
141
153
|
catch { /* ignore */ }
|
|
142
154
|
// 2. Fallback: query list API (scan recent 200 items)
|
|
143
155
|
try {
|
|
144
|
-
const resp = await client.call(listEndpoint, { from: 0, size:
|
|
156
|
+
const resp = await client.call(listEndpoint, { from: 0, size: TITLE_LOOKUP_SIZE });
|
|
145
157
|
const items = Array.isArray(resp) ? resp : (resp.list ?? []);
|
|
146
158
|
const match = items.find(f => String(f[idField]) === String(idValue));
|
|
147
159
|
const rawTitle = match?.[titleField];
|
|
@@ -179,6 +191,46 @@ async function saveDownloadResult(result, fallbackName, output) {
|
|
|
179
191
|
}
|
|
180
192
|
throw new DownloadError("Unexpected download response");
|
|
181
193
|
}
|
|
194
|
+
const POLL_MAX_ATTEMPTS = 12;
|
|
195
|
+
const POLL_DELAY_MS = 15_000;
|
|
196
|
+
async function pollAsyncContent(client, getContentEndpoint, dataId, format, output) {
|
|
197
|
+
for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
|
|
198
|
+
try {
|
|
199
|
+
const result = await client.call(getContentEndpoint, { dataId });
|
|
200
|
+
if (result?.content) {
|
|
201
|
+
await printData(result, format, output);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (!(error instanceof ApiError && (error.code === "410110" || error.message?.includes("生成中")))) {
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (attempt < POLL_MAX_ATTEMPTS) {
|
|
211
|
+
process.stderr.write(`Attempt ${attempt}/${POLL_MAX_ATTEMPTS}: content not ready, retrying in 15s...\n`);
|
|
212
|
+
await new Promise(resolve => setTimeout(resolve, POLL_DELAY_MS));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
function checkAsyncContent(client, getContentEndpoint, dataId, format, output) {
|
|
218
|
+
return (async () => {
|
|
219
|
+
try {
|
|
220
|
+
const result = await client.call(getContentEndpoint, { dataId });
|
|
221
|
+
if (result?.content) {
|
|
222
|
+
await printData(result, format, output);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (!(error instanceof ApiError && (error.code === "410110" || error.message?.includes("生成中")))) {
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
process.stdout.write(`${JSON.stringify({ dataId, status: "pending", hint: "Content not ready yet, retry in ~2 minutes" })}\n`);
|
|
232
|
+
})();
|
|
233
|
+
}
|
|
182
234
|
function addTimeFilters(command) {
|
|
183
235
|
return command
|
|
184
236
|
.option("--from <number>", "Starting offset", "0")
|
|
@@ -424,7 +476,6 @@ ai.command("peer-comparison").requiredOption("--security-code <code>").option("-
|
|
|
424
476
|
});
|
|
425
477
|
ai.command("earnings-review").requiredOption("--security-code <code>").requiredOption("--period <period>", "Report period (e.g. 2025q3, 2025interim, 2025annual)").option("--wait", "Wait for content generation (blocking, up to 3 min)").option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
|
|
426
478
|
const client = await createClient();
|
|
427
|
-
// Step 1: get dataId
|
|
428
479
|
const idResult = await client.call("ai.earnings-review.get-id", { securityCode: options.securityCode, period: options.period });
|
|
429
480
|
const dataId = idResult?.dataId;
|
|
430
481
|
if (!dataId) {
|
|
@@ -432,56 +483,20 @@ ai.command("earnings-review").requiredOption("--security-code <code>").requiredO
|
|
|
432
483
|
process.exitCode = 1;
|
|
433
484
|
return;
|
|
434
485
|
}
|
|
435
|
-
// Non-blocking: return dataId immediately
|
|
436
486
|
if (!options.wait) {
|
|
437
487
|
process.stderr.write(`Earnings review task submitted. dataId: ${dataId}\n`);
|
|
438
488
|
process.stdout.write(`${JSON.stringify({ dataId, status: "pending", hint: `Run 'gangtise ai earnings-review-check --data-id ${dataId}' in ~2 minutes to get results` })}\n`);
|
|
439
489
|
return;
|
|
440
490
|
}
|
|
441
|
-
// Blocking (--wait): poll for content
|
|
442
491
|
process.stderr.write(`Got dataId: ${dataId}, waiting for content generation...\n`);
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
while (attempts < maxAttempts) {
|
|
447
|
-
attempts++;
|
|
448
|
-
try {
|
|
449
|
-
const contentResult = await client.call("ai.earnings-review.get-content", { dataId });
|
|
450
|
-
if (contentResult?.content) {
|
|
451
|
-
await printData(contentResult, parseFormat(options.format), options.output);
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
catch (error) {
|
|
456
|
-
if (!(error instanceof ApiError && (error.code === "410110" || error.message?.includes("生成中")))) {
|
|
457
|
-
throw error;
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
if (attempts < maxAttempts) {
|
|
461
|
-
process.stderr.write(`Attempt ${attempts}/${maxAttempts}: content not ready, retrying in 15s...\n`);
|
|
462
|
-
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
463
|
-
}
|
|
492
|
+
if (!await pollAsyncContent(client, "ai.earnings-review.get-content", dataId, parseFormat(options.format), options.output)) {
|
|
493
|
+
process.stderr.write(`Content not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise ai earnings-review-check --data-id ${dataId}\n`);
|
|
494
|
+
process.exitCode = 1;
|
|
464
495
|
}
|
|
465
|
-
process.stderr.write(`Content not available after ${maxAttempts} attempts. Try again later with: gangtise ai earnings-review-check --data-id ${dataId}\n`);
|
|
466
|
-
process.exitCode = 1;
|
|
467
496
|
});
|
|
468
497
|
ai.command("earnings-review-check").requiredOption("--data-id <id>", "dataId from earnings-review").option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
|
|
469
498
|
const client = await createClient();
|
|
470
|
-
|
|
471
|
-
const contentResult = await client.call("ai.earnings-review.get-content", { dataId: options.dataId });
|
|
472
|
-
if (contentResult?.content) {
|
|
473
|
-
await printData(contentResult, parseFormat(options.format), options.output);
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
476
|
-
process.stdout.write(`${JSON.stringify({ dataId: options.dataId, status: "pending", hint: "Content not ready yet, retry in ~2 minutes" })}\n`);
|
|
477
|
-
}
|
|
478
|
-
catch (error) {
|
|
479
|
-
if (error instanceof ApiError && (error.code === "410110" || error.message?.includes("生成中"))) {
|
|
480
|
-
process.stdout.write(`${JSON.stringify({ dataId: options.dataId, status: "pending", hint: "Content not ready yet, retry in ~2 minutes" })}\n`);
|
|
481
|
-
return;
|
|
482
|
-
}
|
|
483
|
-
throw error;
|
|
484
|
-
}
|
|
499
|
+
await checkAsyncContent(client, "ai.earnings-review.get-content", options.dataId, parseFormat(options.format), options.output);
|
|
485
500
|
});
|
|
486
501
|
ai.command("theme-tracking").requiredOption("--theme-id <id>", "Theme ID (use lookup theme-id list)").requiredOption("--date <date>", "Date (yyyy-MM-dd)").option("--type <name>", "Report type: morning/night", collectList, []).option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
|
|
487
502
|
const client = await createClient();
|
|
@@ -492,7 +507,7 @@ ai.command("research-outline").requiredOption("--security-code <code>").option("
|
|
|
492
507
|
const client = await createClient();
|
|
493
508
|
await printData(await client.call("ai.research-outline", { securityCode: options.securityCode }), parseFormat(options.format), options.output);
|
|
494
509
|
});
|
|
495
|
-
ai.command("hot-topic").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-date <date>", "Start date (yyyy-MM-dd)").option("--end-date <date>", "End date (yyyy-MM-dd)").option("--category <name>", "Report type: morningBriefing/noonBriefing/afternoonFlash/eveningBriefing", collectList, []).option("--with-related-securities", "Include related securities info",
|
|
510
|
+
ai.command("hot-topic").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-date <date>", "Start date (yyyy-MM-dd)").option("--end-date <date>", "End date (yyyy-MM-dd)").option("--category <name>", "Report type: morningBriefing/noonBriefing/afternoonFlash/eveningBriefing", collectList, []).option("--with-related-securities", "Include related securities info").option("--no-with-related-securities", "Exclude related securities info").option("--with-close-reading", "Include close reading content").option("--no-with-close-reading", "Exclude close reading content").option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
|
|
496
511
|
const client = await createClient();
|
|
497
512
|
const ALL_CATEGORIES = ["morningBriefing", "noonBriefing", "afternoonFlash", "eveningBriefing"];
|
|
498
513
|
await printData(await client.call("ai.hot-topic", {
|
|
@@ -501,8 +516,8 @@ ai.command("hot-topic").option("--from <number>", "Starting offset", "0").option
|
|
|
501
516
|
startDate: options.startDate,
|
|
502
517
|
endDate: options.endDate,
|
|
503
518
|
categoryList: options.category.length > 0 ? options.category : ALL_CATEGORIES,
|
|
504
|
-
withRelatedSecurities: options.withRelatedSecurities
|
|
505
|
-
withCloseReading: options.withCloseReading
|
|
519
|
+
withRelatedSecurities: options.withRelatedSecurities === false ? undefined : true,
|
|
520
|
+
withCloseReading: options.withCloseReading === false ? undefined : true,
|
|
506
521
|
}), parseFormat(options.format), options.output);
|
|
507
522
|
});
|
|
508
523
|
ai.command("management-discuss-announcement").requiredOption("--report-date <date>", "Report date (yyyy-MM-dd, e.g. 2025-06-30)").requiredOption("--security-code <code>", "Security code (e.g. 000001.SZ)").addOption(new Option("--dimension <name>", "Discussion dimension").choices(["businessOperation", "financialPerformance", "developmentAndRisk"]).makeOptionMandatory()).option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
|
|
@@ -523,7 +538,6 @@ ai.command("management-discuss-earnings-call").requiredOption("--report-date <da
|
|
|
523
538
|
});
|
|
524
539
|
ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint text (max 1000 chars)").option("--wait", "Wait for content generation (blocking, up to 3 min)").option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
|
|
525
540
|
const client = await createClient();
|
|
526
|
-
// Step 1: get dataId
|
|
527
541
|
const idResult = await client.call("ai.viewpoint-debate.get-id", { viewpoint: options.viewpoint });
|
|
528
542
|
const dataId = idResult?.dataId;
|
|
529
543
|
if (!dataId) {
|
|
@@ -531,56 +545,20 @@ ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint t
|
|
|
531
545
|
process.exitCode = 1;
|
|
532
546
|
return;
|
|
533
547
|
}
|
|
534
|
-
// Non-blocking: return dataId immediately
|
|
535
548
|
if (!options.wait) {
|
|
536
549
|
process.stderr.write(`Viewpoint debate task submitted. dataId: ${dataId}\n`);
|
|
537
550
|
process.stdout.write(`${JSON.stringify({ dataId, status: "pending", hint: `Run 'gangtise ai viewpoint-debate-check --data-id ${dataId}' in ~2 minutes to get results` })}\n`);
|
|
538
551
|
return;
|
|
539
552
|
}
|
|
540
|
-
// Blocking (--wait): poll for content
|
|
541
553
|
process.stderr.write(`Got dataId: ${dataId}, waiting for content generation...\n`);
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
while (attempts < maxAttempts) {
|
|
546
|
-
attempts++;
|
|
547
|
-
try {
|
|
548
|
-
const contentResult = await client.call("ai.viewpoint-debate.get-content", { dataId });
|
|
549
|
-
if (contentResult?.content) {
|
|
550
|
-
await printData(contentResult, parseFormat(options.format), options.output);
|
|
551
|
-
return;
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
catch (error) {
|
|
555
|
-
if (!(error instanceof ApiError && (error.code === "410110" || error.message?.includes("生成中")))) {
|
|
556
|
-
throw error;
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
if (attempts < maxAttempts) {
|
|
560
|
-
process.stderr.write(`Attempt ${attempts}/${maxAttempts}: content not ready, retrying in 15s...\n`);
|
|
561
|
-
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
562
|
-
}
|
|
554
|
+
if (!await pollAsyncContent(client, "ai.viewpoint-debate.get-content", dataId, parseFormat(options.format), options.output)) {
|
|
555
|
+
process.stderr.write(`Content not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise ai viewpoint-debate-check --data-id ${dataId}\n`);
|
|
556
|
+
process.exitCode = 1;
|
|
563
557
|
}
|
|
564
|
-
process.stderr.write(`Content not available after ${maxAttempts} attempts. Try again later with: gangtise ai viewpoint-debate-check --data-id ${dataId}\n`);
|
|
565
|
-
process.exitCode = 1;
|
|
566
558
|
});
|
|
567
559
|
ai.command("viewpoint-debate-check").requiredOption("--data-id <id>", "dataId from viewpoint-debate").option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
|
|
568
560
|
const client = await createClient();
|
|
569
|
-
|
|
570
|
-
const contentResult = await client.call("ai.viewpoint-debate.get-content", { dataId: options.dataId });
|
|
571
|
-
if (contentResult?.content) {
|
|
572
|
-
await printData(contentResult, parseFormat(options.format), options.output);
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
575
|
-
process.stdout.write(`${JSON.stringify({ dataId: options.dataId, status: "pending", hint: "Content not ready yet, retry in ~2 minutes" })}\n`);
|
|
576
|
-
}
|
|
577
|
-
catch (error) {
|
|
578
|
-
if (error instanceof ApiError && (error.code === "410110" || error.message?.includes("生成中"))) {
|
|
579
|
-
process.stdout.write(`${JSON.stringify({ dataId: options.dataId, status: "pending", hint: "Content not ready yet, retry in ~2 minutes" })}\n`);
|
|
580
|
-
return;
|
|
581
|
-
}
|
|
582
|
-
throw error;
|
|
583
|
-
}
|
|
561
|
+
await checkAsyncContent(client, "ai.viewpoint-debate.get-content", options.dataId, parseFormat(options.format), options.output);
|
|
584
562
|
});
|
|
585
563
|
const vault = new Command("vault").description("Vault APIs");
|
|
586
564
|
vault.command("drive-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-time <datetime>").option("--end-time <datetime>").option("--keyword <text>").option("--file-type <number>", "File type", collectNumberList, []).option("--space-type <number>", "Space type", collectNumberList, []).option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
|
|
@@ -617,7 +595,15 @@ program.addCommand(vault);
|
|
|
617
595
|
program.addCommand(ai);
|
|
618
596
|
program.command("raw").description("Raw API calls").addCommand(new Command("call").argument("<endpointKey>").option("--body <json>").option("--query <key=value>", "Query string pair", collectKeyValue, {}).option("--format <format>", "Output format", "json").option("--output <path>").action(async (endpointKey, options) => {
|
|
619
597
|
const client = await createClient();
|
|
620
|
-
|
|
598
|
+
let body;
|
|
599
|
+
if (options.body) {
|
|
600
|
+
try {
|
|
601
|
+
body = JSON.parse(options.body);
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
throw new ConfigError(`Invalid JSON in --body: ${options.body}`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
621
607
|
const data = await client.call(endpointKey, body, options.query);
|
|
622
608
|
if (data && typeof data === "object" && "data" in data && data.data instanceof Uint8Array) {
|
|
623
609
|
await saveDownloadResult(data, "download.bin", options.output);
|
|
@@ -646,3 +632,23 @@ async function main() {
|
|
|
646
632
|
}
|
|
647
633
|
}
|
|
648
634
|
void main();
|
|
635
|
+
// Background update check on --version
|
|
636
|
+
if (process.argv.includes("--version") || process.argv.includes("-V")) {
|
|
637
|
+
import("node:https").then((https) => {
|
|
638
|
+
const req = https.get("https://registry.npmjs.org/gangtise-openapi-cli/latest", (res) => {
|
|
639
|
+
let body = "";
|
|
640
|
+
res.on("data", (chunk) => { body += chunk; });
|
|
641
|
+
res.on("end", () => {
|
|
642
|
+
try {
|
|
643
|
+
const latest = JSON.parse(body).version;
|
|
644
|
+
if (latest && latest !== CLI_VERSION) {
|
|
645
|
+
process.stderr.write(`\nUpdate available: ${CLI_VERSION} → ${latest}\nRun: npm update -g gangtise-openapi-cli\n`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
catch { /* ignore */ }
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
req.on("error", () => { });
|
|
652
|
+
req.setTimeout(3000, () => { req.destroy(); });
|
|
653
|
+
}).catch(() => { });
|
|
654
|
+
}
|
package/dist/src/core/auth.js
CHANGED
|
@@ -4,18 +4,22 @@ import { ConfigError } from "./errors.js";
|
|
|
4
4
|
export async function readTokenCache(filePath) {
|
|
5
5
|
try {
|
|
6
6
|
const content = await fs.readFile(filePath, "utf8");
|
|
7
|
-
|
|
7
|
+
const parsed = JSON.parse(content);
|
|
8
|
+
if (parsed && typeof parsed === "object" && typeof parsed.accessToken === "string" && typeof parsed.expiresAt === "number") {
|
|
9
|
+
return parsed;
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
8
12
|
}
|
|
9
13
|
catch (error) {
|
|
10
14
|
if (error.code === "ENOENT") {
|
|
11
15
|
return null;
|
|
12
16
|
}
|
|
13
|
-
|
|
17
|
+
return null;
|
|
14
18
|
}
|
|
15
19
|
}
|
|
16
20
|
export async function writeTokenCache(filePath, cache) {
|
|
17
21
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
18
|
-
await fs.writeFile(filePath, JSON.stringify(cache, null, 2), "utf8");
|
|
22
|
+
await fs.writeFile(filePath, JSON.stringify(cache, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
19
23
|
}
|
|
20
24
|
export function isTokenCacheValid(cache, bufferSeconds = 300) {
|
|
21
25
|
if (!cache?.accessToken || !cache.expiresAt) {
|
package/dist/src/core/client.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { request } from "undici";
|
|
2
|
-
import { normalizeToken, readTokenCache, requireAccessCredentials, writeTokenCache } from "./auth.js";
|
|
2
|
+
import { isTokenCacheValid, normalizeToken, readTokenCache, requireAccessCredentials, writeTokenCache } from "./auth.js";
|
|
3
3
|
import { ApiError, ValidationError } from "./errors.js";
|
|
4
4
|
import { ENDPOINTS, ENDPOINT_REGISTRY } from "./endpoints.js";
|
|
5
5
|
import { getLookupData } from "./lookupData/index.js";
|
|
6
6
|
export class GangtiseClient {
|
|
7
7
|
config;
|
|
8
|
+
refreshPromise = null;
|
|
8
9
|
constructor(config) {
|
|
9
10
|
this.config = config;
|
|
10
11
|
}
|
|
@@ -13,17 +14,22 @@ export class GangtiseClient {
|
|
|
13
14
|
return normalizeToken(this.config.token);
|
|
14
15
|
}
|
|
15
16
|
const cache = await readTokenCache(this.config.tokenCachePath);
|
|
16
|
-
if (cache
|
|
17
|
+
if (isTokenCacheValid(cache)) {
|
|
17
18
|
return normalizeToken(cache.accessToken);
|
|
18
19
|
}
|
|
20
|
+
if (!this.refreshPromise) {
|
|
21
|
+
this.refreshPromise = this.doTokenRefresh().finally(() => { this.refreshPromise = null; });
|
|
22
|
+
}
|
|
23
|
+
return this.refreshPromise;
|
|
24
|
+
}
|
|
25
|
+
async doTokenRefresh() {
|
|
19
26
|
const credentials = requireAccessCredentials(this.config.accessKey, this.config.secretKey);
|
|
20
27
|
const envelope = await this.requestJson(ENDPOINTS.authLogin, {
|
|
21
28
|
accessKey: credentials.accessKey,
|
|
22
29
|
secretKey: credentials.secretKey,
|
|
23
30
|
}, false);
|
|
24
31
|
const accessToken = normalizeToken(envelope.accessToken);
|
|
25
|
-
const
|
|
26
|
-
const expiresAt = issuedAt + envelope.expiresIn;
|
|
32
|
+
const expiresAt = Math.floor(Date.now() / 1000) + envelope.expiresIn;
|
|
27
33
|
await writeTokenCache(this.config.tokenCachePath, {
|
|
28
34
|
...envelope,
|
|
29
35
|
accessToken,
|
|
@@ -83,7 +89,8 @@ export class GangtiseClient {
|
|
|
83
89
|
let firstPage;
|
|
84
90
|
let total;
|
|
85
91
|
let nextFrom = startFrom;
|
|
86
|
-
|
|
92
|
+
const MAX_PAGES = 1000;
|
|
93
|
+
for (let pageCount = 0; pageCount < MAX_PAGES; pageCount++) {
|
|
87
94
|
const remaining = requestedSize === undefined
|
|
88
95
|
? maxPageSize
|
|
89
96
|
: Math.min(maxPageSize, requestedSize - collected.length);
|
|
@@ -126,7 +133,7 @@ export class GangtiseClient {
|
|
|
126
133
|
}
|
|
127
134
|
}
|
|
128
135
|
if (!firstPage) {
|
|
129
|
-
return
|
|
136
|
+
return { total: 0, list: [] };
|
|
130
137
|
}
|
|
131
138
|
return {
|
|
132
139
|
...firstPage,
|
|
@@ -160,12 +167,15 @@ export class GangtiseClient {
|
|
|
160
167
|
bodyTimeout: this.config.timeoutMs,
|
|
161
168
|
});
|
|
162
169
|
const text = await response.body.text();
|
|
170
|
+
if (response.statusCode >= 500) {
|
|
171
|
+
throw new ApiError(`Server error (HTTP ${response.statusCode})`, undefined, response.statusCode, text.slice(0, 500));
|
|
172
|
+
}
|
|
163
173
|
let parsed;
|
|
164
174
|
try {
|
|
165
175
|
parsed = JSON.parse(text);
|
|
166
176
|
}
|
|
167
177
|
catch {
|
|
168
|
-
throw new ApiError('Failed to parse API response', undefined, response.statusCode, text);
|
|
178
|
+
throw new ApiError('Failed to parse API response', undefined, response.statusCode, text.slice(0, 500));
|
|
169
179
|
}
|
|
170
180
|
return this.unwrapEnvelope(parsed, response.statusCode);
|
|
171
181
|
}
|
|
@@ -13,7 +13,10 @@ export async function getLookupData(key) {
|
|
|
13
13
|
if (cache.has(key))
|
|
14
14
|
return cache.get(key);
|
|
15
15
|
const mod = await loaders[key]();
|
|
16
|
-
const
|
|
16
|
+
const values = Object.values(mod);
|
|
17
|
+
const data = values.find(v => Array.isArray(v));
|
|
18
|
+
if (!data)
|
|
19
|
+
throw new Error(`Lookup module "${key}" has no exported array`);
|
|
17
20
|
cache.set(key, data);
|
|
18
21
|
return data;
|
|
19
22
|
}
|
|
@@ -7,7 +7,7 @@ export function normalizeRows(value) {
|
|
|
7
7
|
}
|
|
8
8
|
const record = value;
|
|
9
9
|
if (Array.isArray(record.fieldList) && Array.isArray(record.list)) {
|
|
10
|
-
|
|
10
|
+
const normalizedList = record.list.map((row) => {
|
|
11
11
|
if (!Array.isArray(row))
|
|
12
12
|
return row;
|
|
13
13
|
return record.fieldList.reduce((acc, field, index) => {
|
|
@@ -15,9 +15,14 @@ export function normalizeRows(value) {
|
|
|
15
15
|
return acc;
|
|
16
16
|
}, {});
|
|
17
17
|
});
|
|
18
|
+
const { fieldList, list, ...meta } = record;
|
|
19
|
+
const hasMeta = Object.keys(meta).length > 0;
|
|
20
|
+
return hasMeta ? { ...meta, list: normalizedList } : normalizedList;
|
|
18
21
|
}
|
|
19
22
|
if (Array.isArray(record.list)) {
|
|
20
|
-
|
|
23
|
+
const { list, ...meta } = record;
|
|
24
|
+
const hasMeta = Object.keys(meta).length > 0;
|
|
25
|
+
return hasMeta ? { ...meta, list } : list;
|
|
21
26
|
}
|
|
22
27
|
return value;
|
|
23
28
|
}
|
package/dist/src/core/output.js
CHANGED
|
@@ -16,7 +16,15 @@ function toRows(value) {
|
|
|
16
16
|
return value.map((item, index) => ({ index, value: item }));
|
|
17
17
|
}
|
|
18
18
|
if (value && typeof value === "object") {
|
|
19
|
-
|
|
19
|
+
const record = value;
|
|
20
|
+
if (Array.isArray(record.list)) {
|
|
21
|
+
const list = record.list;
|
|
22
|
+
if (list.every((item) => item && typeof item === "object" && !Array.isArray(item))) {
|
|
23
|
+
return list;
|
|
24
|
+
}
|
|
25
|
+
return list.map((item, index) => ({ index, value: item }));
|
|
26
|
+
}
|
|
27
|
+
return [record];
|
|
20
28
|
}
|
|
21
29
|
return [{ value }];
|
|
22
30
|
}
|
|
@@ -51,6 +59,9 @@ function renderCsv(rows) {
|
|
|
51
59
|
}
|
|
52
60
|
const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
|
|
53
61
|
const escape = (value) => {
|
|
62
|
+
if (/^[=+\-@\t\r]/.test(value)) {
|
|
63
|
+
value = "'" + value;
|
|
64
|
+
}
|
|
54
65
|
if (/[",\n]/.test(value)) {
|
|
55
66
|
return `"${value.replaceAll("\"", "\"\"")}"`;
|
|
56
67
|
}
|
|
@@ -65,8 +76,12 @@ export function renderOutput(value, format) {
|
|
|
65
76
|
switch (format) {
|
|
66
77
|
case "json":
|
|
67
78
|
return JSON.stringify(value, null, 2);
|
|
68
|
-
case "jsonl":
|
|
69
|
-
|
|
79
|
+
case "jsonl": {
|
|
80
|
+
const items = value && typeof value === "object" && !Array.isArray(value) && Array.isArray(value.list)
|
|
81
|
+
? value.list
|
|
82
|
+
: null;
|
|
83
|
+
return (items ?? rows).map((item) => JSON.stringify(item)).join("\n");
|
|
84
|
+
}
|
|
70
85
|
case "csv":
|
|
71
86
|
return renderCsv(rows);
|
|
72
87
|
case "markdown":
|
|
@@ -80,6 +95,8 @@ export async function saveOutputIfNeeded(content, outputPath) {
|
|
|
80
95
|
if (!outputPath) {
|
|
81
96
|
return;
|
|
82
97
|
}
|
|
98
|
+
const { dirname } = await import("node:path");
|
|
99
|
+
await (await import("node:fs/promises")).mkdir(dirname(outputPath), { recursive: true });
|
|
83
100
|
if (typeof content === "string") {
|
|
84
101
|
await fs.writeFile(outputPath, content, "utf8");
|
|
85
102
|
return;
|
package/dist/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated — DO NOT EDIT
|
|
2
|
-
export const CLI_VERSION = "0.10.
|
|
2
|
+
export const CLI_VERSION = "0.10.5";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gangtise-openapi-cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.5",
|
|
4
4
|
"description": "CLI for Gangtise OpenAPI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"build": "tsc -p tsconfig.json",
|
|
24
24
|
"dev": "tsx src/cli.ts",
|
|
25
25
|
"test": "vitest run",
|
|
26
|
-
"prepare": "node
|
|
26
|
+
"prepare": "node scripts/prepare.cjs && npm run build"
|
|
27
27
|
},
|
|
28
28
|
"engines": {
|
|
29
29
|
"node": ">=20"
|