gangtise-openapi-cli 0.20.0 → 0.22.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/README.md +35 -24
- package/dist/src/cli.js +67 -38
- package/dist/src/core/args.js +15 -3
- package/dist/src/core/asyncContent.js +4 -5
- package/dist/src/core/auth.js +19 -2
- package/dist/src/core/client.js +201 -38
- package/dist/src/core/download.js +64 -7
- package/dist/src/core/endpoints.js +6 -87
- package/dist/src/core/indicatorMatrix.js +11 -6
- package/dist/src/core/output.js +88 -33
- package/dist/src/core/printer.js +11 -7
- package/dist/src/core/quoteSharding.js +16 -9
- package/dist/src/core/titleCache.js +5 -3
- package/dist/src/core/transport.js +2 -0
- package/dist/src/version.js +1 -1
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -4,6 +4,22 @@
|
|
|
4
4
|
|
|
5
5
|
## Changelog
|
|
6
6
|
|
|
7
|
+
### v0.21.0 — 2026-06-29
|
|
8
|
+
|
|
9
|
+
**行为变更(注意)**
|
|
10
|
+
- ⚠️ `vault wechat-chatroom-list` 省略 `--size` 现在**拉全量**(此前默认只返回 20 条)。该接口不返回 `total`,CLI 改为串行翻页(翻到不满页为止,单页上限 50);传 `--size N` 仍只取前 N 条。依赖"默认 20 条"的脚本会拿到全部群。
|
|
11
|
+
|
|
12
|
+
**修复**
|
|
13
|
+
- `quote day-kline --security all` 等大结果集用默认 `table` 格式输出时不再因 `Math.max(...大数组)` 撑爆调用栈崩溃(`RangeError`);`renderTable` 改用 reduce 计算列宽
|
|
14
|
+
- CSV 导出:含回车符 `\r` 的字段现在正确加引号(RFC 4180);`table` / `markdown` 的多行字段折叠换行,保持表格对齐
|
|
15
|
+
- 下载文件名剥离控制字符 / NUL,避免 `fs.writeFile` 报错
|
|
16
|
+
|
|
17
|
+
**修复(安全)**
|
|
18
|
+
- token 缓存文件(`~/.config/gangtise/token.json`)改为临时文件 + 原子 `rename` 写入:从第一字节即 `0600`,消除"旧文件宽松权限残留"与"崩溃截断"两个隐患
|
|
19
|
+
|
|
20
|
+
**内部 / 工程**
|
|
21
|
+
- 依赖 `vitest` 升级到 3.2.6(修复 dev-only 安全告警);新增 `npm run typecheck`;测试 257 → 272
|
|
22
|
+
|
|
7
23
|
### v0.20.0 — 2026-06-26
|
|
8
24
|
|
|
9
25
|
**新增接口**
|
|
@@ -64,21 +80,7 @@
|
|
|
64
80
|
- `--industry` 用 `citicIndustry` 码(`1008001xx`,全命令通用);`--research-area` 用 `gangtiseIndustry` 码(行业 `1008001xx` + 宏观/策略/固收/金工/海外等方向 `122000xxx`)。详见 `gangtise-openapi/references/commands/reference-and-lookup.md`
|
|
65
81
|
- 日程类 `--location`(domesticCity)服务端过滤已生效(v0.16.0 时曾未生效)
|
|
66
82
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
**新增接口(参考数据 · 常量查询,均免积分)**
|
|
70
|
-
- `reference constant-category` — 查询常量分类:全量导出常量分类及各分类适用于哪些接口的哪些参数(7 个分类:中信/申万/Gangtise 行业、国内城市、A股/港股公告分类、区域)
|
|
71
|
-
- `reference constant-list --category <code>` — 查询常量值:按分类导出全量常量(`constantId` / `constantName`,树形分类含 `children` 嵌套)
|
|
72
|
-
- `reference concept-search --keyword <kw>` — 查询题材 ID:按名称/拼音/分组名搜索,返回 `conceptId`(供 `alternative concept-info / concept-securities`、`ai theme-tracking` 使用)
|
|
73
|
-
- `reference sector-search --keyword <kw>` — 查询板块 ID:返回 `sectorId` + `hierarchy` 层级路径
|
|
74
|
-
- `reference sector-constituents --sector-id <id>` — 查询板块成分股:返回该板块全量成分股(`gtsCode` / `gtsName`);注意 sectorId 必须来自 sector-search,题材 conceptId 查不到成分
|
|
75
|
-
|
|
76
|
-
**接口变更(Breaking)**
|
|
77
|
-
- 移除已被新 API 覆盖的 6 个本地 lookup 子命令及静态数据:`lookup research-area / industry / region / announcement-category / theme-id / industry-code list`,请改用 `reference constant-list` / `reference concept-search` / `reference sector-constituents`(申万行业代码 `821xxx.SWI` 全量:`sector-constituents --sector-id 2000000014`,即申万一级行业指数板块)
|
|
78
|
-
- `lookup` 仅保留 2 个 API 未覆盖的本地表:`broker-org` / `meeting-org`
|
|
79
|
-
- 路演/调研/策略会/论坛 list 新增 `--location <id>` 按城市过滤(domesticCity 常量 ID;服务端过滤 v0.17.0 起已生效)
|
|
80
|
-
|
|
81
|
-
> 更早版本(v0.15.0 及之前)的完整更新历史见 [GitHub Releases](https://github.com/gangtiser/gangtise-openapi-cli/releases)。
|
|
83
|
+
> 更早版本及完整更新历史见 [CHANGELOG.md](CHANGELOG.md)。
|
|
82
84
|
|
|
83
85
|
## 首次安装
|
|
84
86
|
|
|
@@ -121,9 +123,10 @@ export GANGTISE_TOKEN="Bearer xxx"
|
|
|
121
123
|
export GANGTISE_PAGE_CONCURRENCY=5 # 翻页并发数(默认 5)
|
|
122
124
|
export GANGTISE_VERBOSE=1 # 打印每个请求的耗时与字节数
|
|
123
125
|
export GANGTISE_TIMEOUT_MS=30000 # 请求超时(默认 30s)
|
|
126
|
+
export GANGTISE_TOKEN_CACHE_PATH=... # 覆盖 token 缓存路径(默认 ~/.config/gangtise/token.json)
|
|
124
127
|
```
|
|
125
128
|
|
|
126
|
-
如果没有 `GANGTISE_TOKEN`,CLI 会自动调用 token 接口并缓存到本地(`~/.config/gangtise/token.json`,权限 0600)。Token 失效(8000014/8000015)时会自动重新登录并重试一次。
|
|
129
|
+
如果没有 `GANGTISE_TOKEN`,CLI 会自动调用 token 接口并缓存到本地(`~/.config/gangtise/token.json`,权限 0600)。Token 失效(8000014/8000015/0000001008)时会自动重新登录并重试一次。
|
|
127
130
|
|
|
128
131
|
|
|
129
132
|
## AI Agent Skill
|
|
@@ -131,6 +134,7 @@ export GANGTISE_TIMEOUT_MS=30000 # 请求超时(默认 30s)
|
|
|
131
134
|
本项目包含 Skill 定义(`gangtise-openapi/SKILL.md`),可让 AI agent 自动调用 `gangtise` CLI 完成投研数据查询。支持以下 AI 编程助手:
|
|
132
135
|
|
|
133
136
|
- [Claude Code](https://claude.ai/claude-code) — `~/.claude/skills/`
|
|
137
|
+
- [Codex](https://github.com/openai/codex) — `~/.codex/skills/`
|
|
134
138
|
- [OpenClaw](https://github.com/openclaw/openclaw) — `~/.openclaw/skills/`
|
|
135
139
|
- [Hermes](https://github.com/nicepkg/hermes) — `~/.hermes/skills/`
|
|
136
140
|
|
|
@@ -161,6 +165,9 @@ gangtise-openapi/
|
|
|
161
165
|
# Claude Code
|
|
162
166
|
cp -r gangtise-openapi ~/.claude/skills/gangtise-openapi
|
|
163
167
|
|
|
168
|
+
# Codex
|
|
169
|
+
cp -r gangtise-openapi ~/.codex/skills/gangtise-openapi
|
|
170
|
+
|
|
164
171
|
# OpenClaw
|
|
165
172
|
cp -r gangtise-openapi ~/.openclaw/skills/gangtise-openapi
|
|
166
173
|
|
|
@@ -258,6 +265,7 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
|
|
|
258
265
|
- `gangtise fundamental ...`
|
|
259
266
|
- `gangtise ai ...`
|
|
260
267
|
- `gangtise vault ...`
|
|
268
|
+
- `gangtise indicator ...`
|
|
261
269
|
- `gangtise alternative ...`
|
|
262
270
|
- `gangtise reference ...`
|
|
263
271
|
- `gangtise raw call ...`
|
|
@@ -293,7 +301,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
|
|
|
293
301
|
- **HTTP keep-alive**:所有请求复用同一个 `undici.Agent`(连接池 16),避免重复 TLS 握手。
|
|
294
302
|
- **流式下载**:指定 `--output` 时,二进制响应(PDF 等)直接 `pipeline` 到磁盘,不经过内存缓冲;50MB PDF 内存占用近乎为零。
|
|
295
303
|
- **流式输出**:`jsonl`/`csv` 格式且 `--output` 指定时,超过 1000 行自动切换为逐行写盘,避免一次性构建百 MB 字符串。
|
|
296
|
-
- **自动重试**:5xx / `ECONNRESET` / `ETIMEDOUT` / `999999` 系统错误自动指数退避重试 2 次。
|
|
304
|
+
- **自动重试**:5xx / 429 / `ECONNRESET` / `ETIMEDOUT` / `ENOTFOUND` / `EAI_AGAIN` / `UND_ERR_*`(undici 超时类)/ `999999` 系统错误自动指数退避重试 2 次。
|
|
297
305
|
- **Token 自愈**:调用返回 8000014/8000015 时自动强制刷新 Token 并重试一次。
|
|
298
306
|
- **K线自动分片**:`quote day-kline --security all` 等全市场查询自动按日期切分(A股 1 天/片、美股 1 天/片、HK 2 天/片、指数 30 天/片),并发执行后合并结果。分片时如果用户未传 `--limit`,自动注入 `limit: 10000`(API 上限)避免默认 6000 截断。
|
|
299
307
|
- **Token 内存缓存**:Token 在进程内存中缓存,避免每次请求读盘。
|
|
@@ -321,15 +329,18 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
|
|
|
321
329
|
- `vault record-list`
|
|
322
330
|
- `vault my-conference-list`
|
|
323
331
|
- `vault wechat-message-list`
|
|
332
|
+
- `vault wechat-chatroom-list`(特例:接口无 `total`,CLI 串行翻页)
|
|
324
333
|
- `ai hot-topic`
|
|
325
334
|
|
|
326
335
|
规则:
|
|
327
|
-
-
|
|
328
|
-
-
|
|
336
|
+
- **省略 `--size` 一律拉全量**(无论是否传时间范围),CLI 自动翻页查完
|
|
337
|
+
- 数据量未知时,可先 `--size 1` 从 stderr 的 `Total: N` 探明量级,再决定是否全量
|
|
329
338
|
- 如果显式传了 `--size`,则按指定值翻页,直到达到 `size` 或数据取完
|
|
330
339
|
- `--from` 必须是非负整数,`--size` 必须是正整数;非法数字会在本地直接报 `ValidationError`,不会继续请求 API
|
|
331
340
|
- 安全上限:自动翻页最多 1000 页,防止异常循环
|
|
332
|
-
-
|
|
341
|
+
- 部分页失败、或服务端实际返回行数与 `total` 矛盾(提前短页)时,不丢弃已取到的数据:结果带 `partial: true`(页失败时另有 `failedPages`;K线分片为 `failedShards`;`--format json` 可见),stderr 输出警告,**进程退出码为 3**(完整成功为 0)
|
|
342
|
+
- 分页结果中 `total` 字段会被保留(json 格式输出 `{total, list}`);其他格式下 stderr 输出 `Total: N, showing: M`(json 格式不输出该行)
|
|
343
|
+
- `vault wechat-chatroom-list` 是特例:接口不返回 `total`,CLI 改为串行翻页——省略 `--size` 拉全量、传 `--size N` 取前 N 条,单页 50,无 `Total:` 提示
|
|
333
344
|
|
|
334
345
|
## 智能文件命名
|
|
335
346
|
|
|
@@ -353,11 +364,11 @@ gangtise auth status
|
|
|
353
364
|
### Insight
|
|
354
365
|
|
|
355
366
|
```bash
|
|
356
|
-
#
|
|
367
|
+
# 省略 --size → 自动翻页查全
|
|
357
368
|
gangtise insight research list --start-time "2026-04-01 00:00:00" --end-time "2026-04-09 23:59:59"
|
|
358
369
|
|
|
359
|
-
#
|
|
360
|
-
gangtise insight research list --industry 100800126 --category company --llm-tag inDepth --rating buy
|
|
370
|
+
# 无时间范围也是拉全量;只要前 200 条就显式传 --size
|
|
371
|
+
gangtise insight research list --industry 100800126 --category company --llm-tag inDepth --rating buy --size 200
|
|
361
372
|
|
|
362
373
|
# 多值 List 模式:一次查多家券商 + 多个行业 + 多个评级
|
|
363
374
|
gangtise insight research list --broker C100000027 --broker C100000014 --industry 100800119 --industry 100800118 --rating buy --rating overweight --format json
|
|
@@ -535,7 +546,7 @@ gangtise ai management-discuss-earnings-call --report-date 2025-06-30 --security
|
|
|
535
546
|
gangtise ai viewpoint-debate --viewpoint "飞天茅台的批价低点是1500元"
|
|
536
547
|
# 等待生成完成后查询结果
|
|
537
548
|
gangtise ai viewpoint-debate-check --data-id 202603310528
|
|
538
|
-
# 也可以 --wait
|
|
549
|
+
# 也可以 --wait 同步等待结果(最长约 5 分钟:14 次指数退避轮询,累计 ≈316s)
|
|
539
550
|
gangtise ai viewpoint-debate --viewpoint "比亚迪股价将突破500元" --wait
|
|
540
551
|
gangtise ai knowledge-resource-download --resource-type 60 --source-id 3052524 --output ./resource.txt
|
|
541
552
|
```
|
package/dist/src/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import { buildIndicatorCrossSectionBody, buildIndicatorTimeSeriesBody, buildQuot
|
|
|
7
7
|
import { flattenCrossSection, flattenTimeSeries, unwrapIndicatorData } from "./core/indicatorMatrix.js";
|
|
8
8
|
import { callKlineWithSharding } from "./core/quoteSharding.js";
|
|
9
9
|
import { loadConfig } from "./core/config.js";
|
|
10
|
-
import { resolveTitle, saveDownloadResult } from "./core/download.js";
|
|
10
|
+
import { resolveTitle, saveDownloadResult, uniquePath } from "./core/download.js";
|
|
11
11
|
import { ENDPOINTS } from "./core/endpoints.js";
|
|
12
12
|
import { ApiError, ConfigError, ValidationError } from "./core/errors.js";
|
|
13
13
|
import { normalizeRows } from "./core/normalize.js";
|
|
@@ -25,8 +25,11 @@ async function createClient() {
|
|
|
25
25
|
* query command repeated.
|
|
26
26
|
*/
|
|
27
27
|
async function emit(options, produce, cache) {
|
|
28
|
+
// Validate --format before fetching: a typo'd format must not burn a full
|
|
29
|
+
// (possibly credit-metered) data pull only to fail at render time.
|
|
30
|
+
const format = parseOutputFormat(options.format);
|
|
28
31
|
const client = await createClient();
|
|
29
|
-
await printData(await produce(client),
|
|
32
|
+
await printData(await produce(client), format, options.output, cache);
|
|
30
33
|
}
|
|
31
34
|
/** Acquire a client and run an arbitrary action (downloads, polling, custom shaping). */
|
|
32
35
|
async function withClient(fn) {
|
|
@@ -45,7 +48,8 @@ async function runDownload(client, endpointKey, query, options) {
|
|
|
45
48
|
}
|
|
46
49
|
const result = await client.call(endpointKey, undefined, query);
|
|
47
50
|
const resolved = options.resolveOutputPath ? await options.resolveOutputPath(result) : undefined;
|
|
48
|
-
|
|
51
|
+
// Title-derived names are auto-generated too — dedupe them like the fallback names.
|
|
52
|
+
await saveDownloadResult(result, options.fallbackName, resolved ? await uniquePath(resolved) : undefined);
|
|
49
53
|
}
|
|
50
54
|
/**
|
|
51
55
|
* Register a download subcommand. All download commands share one shape: a
|
|
@@ -326,6 +330,7 @@ addFinancialReport("balance-sheet-us", "fundamental.balance-sheet-us", "Period:
|
|
|
326
330
|
addFinancialReport("cash-flow-us", "fundamental.cash-flow-us", "Period: q1/h1/q3/nsd/annual/latest");
|
|
327
331
|
fundamental.command("main-business").requiredOption("--security-code <code>").option("--start-date <date>").option("--end-date <date>").addOption(new Option("--breakdown <type>", "Breakdown: product/industry/region").choices(["product", "industry", "region"]).default("product")).option("--period <type>", "Period: interim/annual", collectList, []).option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("fundamental.main-business", { securityCode: options.securityCode, startDate: options.startDate, endDate: options.endDate, breakdown: options.breakdown, periodList: maybeArray(options.period), fieldList: maybeArray(options.field) })));
|
|
328
332
|
fundamental.command("valuation-analysis").requiredOption("--security-code <code>").addOption(new Option("--indicator <name>", "Indicator").choices(["peTtm", "pbMrq", "peg", "psTtm", "pcfTtm", "em"]).makeOptionMandatory()).option("--start-date <date>").option("--end-date <date>").option("--limit <number>").option("--field <field>", "Field", collectList, []).option("--skip-null", "Drop rows where value or percentileRank is null").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
|
|
333
|
+
const format = parseOutputFormat(options.format);
|
|
329
334
|
let data = await client.call("fundamental.valuation-analysis", { securityCode: options.securityCode, indicator: options.indicator, startDate: options.startDate, endDate: options.endDate, limit: parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1 }), fieldList: maybeArray(options.field) });
|
|
330
335
|
if (options.skipNull) {
|
|
331
336
|
const normalized = normalizeRows(data);
|
|
@@ -342,12 +347,14 @@ fundamental.command("valuation-analysis").requiredOption("--security-code <code>
|
|
|
342
347
|
}
|
|
343
348
|
}
|
|
344
349
|
}
|
|
345
|
-
await printData(data,
|
|
350
|
+
await printData(data, format, options.output);
|
|
346
351
|
}));
|
|
347
352
|
fundamental.command("top-holders").requiredOption("--security-code <code>").addOption(new Option("--holder-type <type>", "Holder type: top10/top10Float").choices(["top10", "top10Float"]).makeOptionMandatory()).option("--start-date <date>").option("--end-date <date>").option("--fiscal-year <year>", "Fiscal year", collectList, []).option("--period <period>", "Period: q1/interim/q3/annual/latest", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("fundamental.top-holders", { securityCode: options.securityCode, holderType: options.holderType, startDate: options.startDate, endDate: options.endDate, fiscalYear: maybeArray(options.fiscalYear), period: options.period.length ? options.period : undefined })));
|
|
348
353
|
fundamental.command("earning-forecast").requiredOption("--security-code <code>").option("--start-date <date>", "Start date (default: 1 year before end-date)").option("--end-date <date>", "End date (default: today)").option("--consensus <name>", "Consensus indicator: netIncome/netIncomeYoy/eps/pe/bps/pb/peg/roe/ps", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => {
|
|
349
354
|
const endDate = options.endDate ?? new Date().toISOString().slice(0, 10);
|
|
350
|
-
|
|
355
|
+
// Anchor the default window to endDate (as the help text promises), not to today —
|
|
356
|
+
// a historical --end-date without --start-date should mean "the year before it".
|
|
357
|
+
const startDate = options.startDate ?? new Date(new Date(`${endDate}T00:00:00Z`).getTime() - 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
351
358
|
return client.call("fundamental.earning-forecast", { securityCode: options.securityCode, startDate, endDate, consensusList: maybeArray(options.consensus) });
|
|
352
359
|
}));
|
|
353
360
|
program.addCommand(fundamental);
|
|
@@ -367,7 +374,8 @@ ai.command("security-clue").option("--from <number>", "Starting offset", "0").op
|
|
|
367
374
|
ai.command("one-pager").requiredOption("--security-code <code>").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("ai.one-pager", { securityCode: options.securityCode })));
|
|
368
375
|
ai.command("investment-logic").requiredOption("--security-code <code>").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("ai.investment-logic", { securityCode: options.securityCode })));
|
|
369
376
|
ai.command("peer-comparison").requiredOption("--security-code <code>").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("ai.peer-comparison", { securityCode: options.securityCode })));
|
|
370
|
-
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
|
|
377
|
+
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 ~5 min)").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => withClient(async (client) => {
|
|
378
|
+
const format = parseOutputFormat(options.format);
|
|
371
379
|
const idResult = await client.call("ai.earnings-review.get-id", { securityCode: options.securityCode, period: options.period });
|
|
372
380
|
const dataId = idResult?.dataId;
|
|
373
381
|
if (!dataId) {
|
|
@@ -381,8 +389,13 @@ ai.command("earnings-review").requiredOption("--security-code <code>").requiredO
|
|
|
381
389
|
return;
|
|
382
390
|
}
|
|
383
391
|
process.stderr.write(`Got dataId: ${dataId}, waiting for content generation...\n`);
|
|
384
|
-
|
|
385
|
-
|
|
392
|
+
const outcome = await pollAsyncContent(client, "ai.earnings-review.get-content", dataId, format, options.output);
|
|
393
|
+
if (outcome !== "ok") {
|
|
394
|
+
// "failed" already printed its terminal "Do not retry" line — only a timeout
|
|
395
|
+
// gets the retry hint.
|
|
396
|
+
if (outcome === "timeout") {
|
|
397
|
+
process.stderr.write(`Content not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise ai earnings-review-check --data-id ${dataId}\n`);
|
|
398
|
+
}
|
|
386
399
|
process.exitCode = 1;
|
|
387
400
|
}
|
|
388
401
|
}));
|
|
@@ -414,7 +427,8 @@ ai.command("management-discuss-earnings-call").requiredOption("--report-date <da
|
|
|
414
427
|
securityCode: options.securityCode,
|
|
415
428
|
discussionDimension: options.dimension,
|
|
416
429
|
})));
|
|
417
|
-
ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint text (max 1000 chars)").option("--wait", "Wait for content generation (blocking, up to
|
|
430
|
+
ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint text (max 1000 chars)").option("--wait", "Wait for content generation (blocking, up to ~5 min)").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => withClient(async (client) => {
|
|
431
|
+
const format = parseOutputFormat(options.format);
|
|
418
432
|
const idResult = await client.call("ai.viewpoint-debate.get-id", { viewpoint: options.viewpoint });
|
|
419
433
|
const dataId = idResult?.dataId;
|
|
420
434
|
if (!dataId) {
|
|
@@ -428,8 +442,11 @@ ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint t
|
|
|
428
442
|
return;
|
|
429
443
|
}
|
|
430
444
|
process.stderr.write(`Got dataId: ${dataId}, waiting for content generation...\n`);
|
|
431
|
-
|
|
432
|
-
|
|
445
|
+
const outcome = await pollAsyncContent(client, "ai.viewpoint-debate.get-content", dataId, format, options.output);
|
|
446
|
+
if (outcome !== "ok") {
|
|
447
|
+
if (outcome === "timeout") {
|
|
448
|
+
process.stderr.write(`Content not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise ai viewpoint-debate-check --data-id ${dataId}\n`);
|
|
449
|
+
}
|
|
433
450
|
process.exitCode = 1;
|
|
434
451
|
}
|
|
435
452
|
}));
|
|
@@ -471,7 +488,7 @@ addDownloadCommand(vault, { endpointKey: "vault.record.download", name: "record-
|
|
|
471
488
|
vault.command("my-conference-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("--research-area <id>", "Research area ID", collectList, []).option("--security <code>", "Security code", collectList, []).option("--institution <id>", "Institution ID", collectList, []).option("--category <name>", "Conference category: earningsCall/strategyMeeting/fundRoadshow/shareholdersMeeting/maMeeting/specialMeeting/companyAnalysis/industryAnalysis/other", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.my-conference.list", { from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword, researchAreaList: maybeArray(options.researchArea), securityList: maybeArray(options.security), institutionList: maybeArray(options.institution), categoryList: maybeArray(options.category) }), { endpointKey: "vault.my-conference.list", idField: "conferenceId" }));
|
|
472
489
|
addDownloadCommand(vault, { endpointKey: "vault.my-conference.download", name: "my-conference-download", idOption: "--conference-id", idField: "conferenceId", fallbackPrefix: "conference", contentTypeDescription: "Content type: asr/summary", titleListEndpoint: "vault.my-conference.list" });
|
|
473
490
|
vault.command("wechat-message-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("--security <code>", "Security code (e.g. 000001.SZ)", collectList, []).option("--wechat-group-id <id>", "WeChat group ID", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--category <name>", "Message type: text/image/documents/url", collectList, []).option("--tag <name>", "Tag: roadShow/research/strategyMeeting/meetingSummary/industryComment/companyComment/earningsReview", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.wechat-message.list", buildWechatMessageListBody(options))));
|
|
474
|
-
vault.command("wechat-chatroom-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "
|
|
491
|
+
vault.command("wechat-chatroom-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--room-name <name>", "WeChat group name; repeat or comma-separate for multiple names", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.wechat-chatroom.list", buildWechatChatroomListBody(options))));
|
|
475
492
|
vault.command("stock-pool-list").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.stock-pool.list", {})));
|
|
476
493
|
vault.command("stock-pool-stocks").option("--pool-id <id>", "Pool ID; repeat for multiple; omit (or 'all') for all pools", collectList).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.stock-pool.stocks", buildStockPoolStocksBody(options))));
|
|
477
494
|
program.addCommand(vault);
|
|
@@ -482,6 +499,7 @@ alternative.command("edb-search").requiredOption("--keyword <text>", "Search key
|
|
|
482
499
|
limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1 }),
|
|
483
500
|
})));
|
|
484
501
|
alternative.command("edb-data").option("--indicator-id <id>", "Indicator ID (repeat, max 10)", collectList, []).requiredOption("--start-date <date>", "Start date (yyyy-MM-dd)").requiredOption("--end-date <date>", "End date (yyyy-MM-dd)").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
|
|
502
|
+
const format = parseOutputFormat(options.format);
|
|
485
503
|
const raw = await client.call("alternative.edb-data", {
|
|
486
504
|
indicatorIdList: options.indicatorId,
|
|
487
505
|
startDate: options.startDate,
|
|
@@ -495,26 +513,29 @@ alternative.command("edb-data").option("--indicator-id <id>", "Indicator ID (rep
|
|
|
495
513
|
}, {}));
|
|
496
514
|
data = { list, total: list.length };
|
|
497
515
|
}
|
|
498
|
-
await printData(data,
|
|
516
|
+
await printData(data, format, options.output);
|
|
499
517
|
}));
|
|
500
518
|
alternative.command("concept-info").requiredOption("--concept-id <id>", "Concept (theme index) ID, e.g. 121000130 机器人; discover via 'gangtise reference concept-search'").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("alternative.concept-info", { conceptId: options.conceptId })));
|
|
501
519
|
alternative.command("concept-securities").requiredOption("--concept-id <id>", "Concept (theme index) ID, e.g. 121000130 机器人; discover via 'gangtise reference concept-search'").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("alternative.concept-securities", { conceptId: options.conceptId })));
|
|
502
520
|
program.addCommand(alternative);
|
|
503
521
|
const indicator = new Command("indicator").description("Data indicator (EDE) APIs: search codes, cross-section, time-series");
|
|
504
522
|
indicator.command("search").requiredOption("--keyword <text>", "Search keyword, e.g. '收盘价' '成交量' '营业收入' (not free-form questions)").option("--limit <number>", "Max results (default: 50, max: 100)", "50").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
|
|
523
|
+
const format = parseOutputFormat(options.format);
|
|
505
524
|
const raw = await client.call("indicator.search", {
|
|
506
525
|
keyword: options.keyword,
|
|
507
526
|
limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1 }),
|
|
508
527
|
});
|
|
509
|
-
await printData(unwrapIndicatorData(raw),
|
|
528
|
+
await printData(unwrapIndicatorData(raw), format, options.output);
|
|
510
529
|
}));
|
|
511
530
|
indicator.command("cross-section").option("--indicator <code>", "Indicator code, e.g. qte_close (repeat for multiple)", collectList, []).option("--security <code>", "Security code, e.g. 600519.SH (repeat for multiple)", collectList, []).requiredOption("--date <date>", "Data date (yyyy-MM-dd)").option("--currency <code>", "Currency: DFT/CNY/HKD/USD/EUR/GBP/JPY/TWD/MOP/AUD (default DFT)").option("--scale <code>", "Scale: 0=个 3=千 4=万 6=百万 8=亿 9=十亿 (default 0)").option("--indicator-param <spec>", "Per-indicator param 'code:key=value', e.g. qte_close:adjustmentType=2 for 前复权 (repeat)", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
|
|
531
|
+
const format = parseOutputFormat(options.format);
|
|
512
532
|
const raw = await client.call("indicator.cross-section", buildIndicatorCrossSectionBody(options));
|
|
513
|
-
await printData(flattenCrossSection(unwrapIndicatorData(raw)),
|
|
533
|
+
await printData(flattenCrossSection(unwrapIndicatorData(raw)), format, options.output);
|
|
514
534
|
}));
|
|
515
535
|
indicator.command("time-series").option("--indicator <code>", "Indicator code, e.g. qte_close (repeat for multiple)", collectList, []).option("--security <code>", "Security code, e.g. 600519.SH (repeat for multiple)", collectList, []).requiredOption("--start-date <date>", "Start date (yyyy-MM-dd)").requiredOption("--end-date <date>", "End date (yyyy-MM-dd)").option("--calendar-type <type>", "Calendar: ND=natural TD=trading WD=weekday (default TD)").option("--currency <code>", "Currency: DFT/CNY/HKD/USD/EUR/GBP/JPY/TWD/MOP/AUD (default DFT)").option("--scale <code>", "Scale: 0=个 3=千 4=万 6=百万 8=亿 9=十亿 (default 0)").option("--indicator-param <spec>", "Per-indicator param 'code:key=value', e.g. qte_close:adjustmentType=2 for 前复权 (repeat)", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
|
|
536
|
+
const format = parseOutputFormat(options.format);
|
|
516
537
|
const raw = await client.call("indicator.time-series", buildIndicatorTimeSeriesBody(options));
|
|
517
|
-
await printData(flattenTimeSeries(unwrapIndicatorData(raw)),
|
|
538
|
+
await printData(flattenTimeSeries(unwrapIndicatorData(raw)), format, options.output);
|
|
518
539
|
}));
|
|
519
540
|
program.addCommand(indicator);
|
|
520
541
|
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) => {
|
|
@@ -522,6 +543,7 @@ program.command("raw").description("Raw API calls").addCommand(new Command("call
|
|
|
522
543
|
if (!endpoint) {
|
|
523
544
|
throw new ConfigError(`Unknown endpoint key: ${endpointKey}`);
|
|
524
545
|
}
|
|
546
|
+
const format = parseOutputFormat(options.format);
|
|
525
547
|
const client = await createClient();
|
|
526
548
|
let body;
|
|
527
549
|
if (options.body) {
|
|
@@ -532,39 +554,46 @@ program.command("raw").description("Raw API calls").addCommand(new Command("call
|
|
|
532
554
|
throw new ConfigError(`Invalid JSON in --body: ${options.body}`);
|
|
533
555
|
}
|
|
534
556
|
}
|
|
557
|
+
// Fail loudly on arguments the endpoint kind can't use — they used to be
|
|
558
|
+
// silently dropped, leaving the user to puzzle over server-side errors.
|
|
535
559
|
if (endpoint.kind === "download") {
|
|
560
|
+
if (body !== undefined) {
|
|
561
|
+
throw new ValidationError(`--body is not supported for download endpoints (use --query key=value); ${endpointKey} is kind=download`);
|
|
562
|
+
}
|
|
536
563
|
await runDownload(client, endpointKey, options.query, {
|
|
537
564
|
output: options.output,
|
|
538
565
|
fallbackName: "download.bin",
|
|
539
566
|
});
|
|
540
567
|
return;
|
|
541
568
|
}
|
|
542
|
-
|
|
543
|
-
|
|
569
|
+
if (Object.keys(options.query).length > 0) {
|
|
570
|
+
throw new ValidationError(`--query is not supported for JSON endpoints (use --body '{...}'); ${endpointKey} is kind=json`);
|
|
571
|
+
}
|
|
572
|
+
const data = await client.call(endpointKey, body);
|
|
573
|
+
await printData(data, format, options.output);
|
|
544
574
|
}));
|
|
545
575
|
async function checkForUpdate(timeoutMs = 2000) {
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
if (latest && latest !== CLI_VERSION) {
|
|
555
|
-
process.stderr.write(`Update available: ${CLI_VERSION} → ${latest}\nRun: npm update -g gangtise-openapi-cli\n`);
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
catch { /* ignore */ }
|
|
559
|
-
resolve();
|
|
560
|
-
});
|
|
561
|
-
});
|
|
562
|
-
req.on("error", () => resolve());
|
|
563
|
-
req.setTimeout(timeoutMs, () => { req.destroy(); resolve(); });
|
|
564
|
-
});
|
|
576
|
+
try {
|
|
577
|
+
const response = await fetch("https://registry.npmjs.org/gangtise-openapi-cli/latest", { signal: AbortSignal.timeout(timeoutMs) });
|
|
578
|
+
const latest = (await response.json()).version;
|
|
579
|
+
if (latest && latest !== CLI_VERSION) {
|
|
580
|
+
process.stderr.write(`Update available: ${CLI_VERSION} → ${latest}\nRun: npm update -g gangtise-openapi-cli\n`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
catch { /* best-effort: offline or a slow registry must not break --version */ }
|
|
565
584
|
}
|
|
585
|
+
// `gangtise ... | head` closes stdout early; without a handler the final big write
|
|
586
|
+
// crashes Node with an unhandled 'error' event. Exit quietly like a normal CLI.
|
|
587
|
+
process.stdout.on("error", (error) => {
|
|
588
|
+
if (error?.code === "EPIPE")
|
|
589
|
+
process.exit(0);
|
|
590
|
+
throw error;
|
|
591
|
+
});
|
|
566
592
|
async function main() {
|
|
567
|
-
|
|
593
|
+
// Positional check, not argv.includes: "--version" appearing later (e.g. as
|
|
594
|
+
// another option's value) must not short-circuit the whole command.
|
|
595
|
+
const firstArg = process.argv[2];
|
|
596
|
+
if (firstArg === "--version" || firstArg === "-V") {
|
|
568
597
|
process.stdout.write(`${CLI_VERSION}\n`);
|
|
569
598
|
await checkForUpdate();
|
|
570
599
|
return;
|
package/dist/src/core/args.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { ValidationError } from "./errors.js";
|
|
2
2
|
export function splitCsv(value) {
|
|
3
|
+
// Also split on full-width ",": voice-input IMEs produce it, and an unsplit
|
|
4
|
+
// "600519,000858" goes to the API as one bogus code with no local hint.
|
|
3
5
|
return value
|
|
4
|
-
.split(
|
|
6
|
+
.split(/[,,]/)
|
|
5
7
|
.map((item) => item.trim())
|
|
6
8
|
.filter(Boolean);
|
|
7
9
|
}
|
|
@@ -42,12 +44,12 @@ export function collectNumberList(value, previous = []) {
|
|
|
42
44
|
export function collectKeyValue(value, previous = {}) {
|
|
43
45
|
const index = value.indexOf("=");
|
|
44
46
|
if (index === -1) {
|
|
45
|
-
throw new
|
|
47
|
+
throw new ValidationError(`Invalid key=value pair: ${value}`);
|
|
46
48
|
}
|
|
47
49
|
const key = value.slice(0, index).trim();
|
|
48
50
|
const rawValue = value.slice(index + 1).trim();
|
|
49
51
|
if (!key) {
|
|
50
|
-
throw new
|
|
52
|
+
throw new ValidationError(`Invalid key=value pair: ${value}`);
|
|
51
53
|
}
|
|
52
54
|
return {
|
|
53
55
|
...previous,
|
|
@@ -65,6 +67,16 @@ export function toTimestamp13(value) {
|
|
|
65
67
|
return num;
|
|
66
68
|
if (!Number.isNaN(num) && num > 1e9)
|
|
67
69
|
return num * 1000;
|
|
70
|
+
// `new Date("yyyy-MM-dd")` parses as UTC midnight while `new Date("yyyy-MM-dd HH:mm:ss")`
|
|
71
|
+
// parses as local time — for CST users the two forms would differ by 8 hours and
|
|
72
|
+
// silently shift the query window. Anchor date-only input to local midnight so both
|
|
73
|
+
// forms mean the same wall-clock day.
|
|
74
|
+
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
75
|
+
if (dateOnly) {
|
|
76
|
+
const d = new Date(Number(dateOnly[1]), Number(dateOnly[2]) - 1, Number(dateOnly[3]));
|
|
77
|
+
const valid = d.getMonth() === Number(dateOnly[2]) - 1 && d.getDate() === Number(dateOnly[3]);
|
|
78
|
+
return valid ? d.getTime() : undefined;
|
|
79
|
+
}
|
|
68
80
|
const ms = new Date(value).getTime();
|
|
69
81
|
if (Number.isNaN(ms))
|
|
70
82
|
return undefined;
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { ApiError } from "./errors.js";
|
|
2
2
|
import { printData } from "./printer.js";
|
|
3
|
+
// 14 attempts with exponential backoff (5s→30s cap) ≈ 316s total wait budget.
|
|
3
4
|
export const POLL_MAX_ATTEMPTS = 14;
|
|
4
5
|
const POLL_INITIAL_DELAY_MS = 5_000;
|
|
5
6
|
const POLL_MAX_DELAY_MS = 30_000;
|
|
6
|
-
/** Total wait time stays close to the previous 12*15s=180s budget. */
|
|
7
|
-
export const POLL_DELAY_MS = POLL_INITIAL_DELAY_MS;
|
|
8
7
|
function nextDelayMs(attempt) {
|
|
9
8
|
// 5s, 8s, 13s, 20s, 30s, 30s, ...
|
|
10
9
|
const grown = POLL_INITIAL_DELAY_MS * 1.6 ** (attempt - 1);
|
|
@@ -19,13 +18,13 @@ export async function pollAsyncContent(client, getContentEndpoint, dataId, forma
|
|
|
19
18
|
const result = await client.call(getContentEndpoint, { dataId });
|
|
20
19
|
if (result?.content != null) {
|
|
21
20
|
await printData(result, format, output);
|
|
22
|
-
return
|
|
21
|
+
return "ok";
|
|
23
22
|
}
|
|
24
23
|
}
|
|
25
24
|
catch (error) {
|
|
26
25
|
if (error instanceof ApiError && error.code === "410111") {
|
|
27
26
|
process.stderr.write("Content generation failed (terminal). Do not retry.\n");
|
|
28
|
-
return
|
|
27
|
+
return "failed";
|
|
29
28
|
}
|
|
30
29
|
if (!isAsyncPending(error))
|
|
31
30
|
throw error;
|
|
@@ -36,7 +35,7 @@ export async function pollAsyncContent(client, getContentEndpoint, dataId, forma
|
|
|
36
35
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
37
36
|
}
|
|
38
37
|
}
|
|
39
|
-
return
|
|
38
|
+
return "timeout";
|
|
40
39
|
}
|
|
41
40
|
export async function checkAsyncContent(client, getContentEndpoint, dataId, format, output) {
|
|
42
41
|
try {
|
package/dist/src/core/auth.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import fs from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { ConfigError } from "./errors.js";
|
|
@@ -33,7 +34,20 @@ export function redactTokenCache(cache) {
|
|
|
33
34
|
}
|
|
34
35
|
export async function writeTokenCache(filePath, cache) {
|
|
35
36
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
36
|
-
|
|
37
|
+
// Write to a fresh 0600 temp file then rename over the target. Writing the bearer
|
|
38
|
+
// token straight to token.json would (a) keep an existing file's lax perms — the
|
|
39
|
+
// `mode` option only applies on creation, so a follow-up chmod still leaves a brief
|
|
40
|
+
// world-readable window — and (b) risk a truncated file on crash. A temp file is
|
|
41
|
+
// 0600 from the first byte and rename is atomic, carrying the 0600 perms over.
|
|
42
|
+
const tmp = `${filePath}.tmp-${randomUUID()}`;
|
|
43
|
+
await fs.writeFile(tmp, JSON.stringify(cache, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
44
|
+
try {
|
|
45
|
+
await fs.rename(tmp, filePath);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
await fs.unlink(tmp).catch(() => { });
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
37
51
|
}
|
|
38
52
|
export function isTokenCacheValid(cache, bufferSeconds = 300) {
|
|
39
53
|
if (!cache?.accessToken || !cache.expiresAt) {
|
|
@@ -43,7 +57,10 @@ export function isTokenCacheValid(cache, bufferSeconds = 300) {
|
|
|
43
57
|
return cache.expiresAt - bufferSeconds > now;
|
|
44
58
|
}
|
|
45
59
|
export function normalizeToken(token) {
|
|
46
|
-
|
|
60
|
+
// Case-insensitive prefix check: GANGTISE_TOKEN="bearer xxx" must become
|
|
61
|
+
// "Bearer xxx", not the silently-invalid "Bearer bearer xxx".
|
|
62
|
+
const prefix = /^bearer\s+/i.exec(token);
|
|
63
|
+
return `Bearer ${prefix ? token.slice(prefix[0].length) : token}`;
|
|
47
64
|
}
|
|
48
65
|
export function requireAccessCredentials(accessKey, secretKey) {
|
|
49
66
|
if (!accessKey || !secretKey) {
|