gangtise-openapi-cli 0.25.0 → 0.27.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 +56 -40
- package/dist/src/cli.js +6 -4
- package/dist/src/core/args.js +17 -0
- package/dist/src/core/asyncContent.js +11 -2
- package/dist/src/core/client.js +50 -9
- package/dist/src/core/download.js +78 -13
- package/dist/src/core/endpoints.js +20 -0
- package/dist/src/core/errors.js +5 -2
- package/dist/src/core/indicatorMatrix.js +3 -1
- package/dist/src/core/output.js +58 -13
- package/dist/src/core/quoteSharding.js +9 -5
- package/dist/src/core/transport.js +31 -6
- package/dist/src/version.js +1 -1
- package/gangtise-openapi/SKILL.md +326 -0
- package/gangtise-openapi/references/commands/ai.md +149 -0
- package/gangtise-openapi/references/commands/alternative.md +102 -0
- package/gangtise-openapi/references/commands/fundamental.md +108 -0
- package/gangtise-openapi/references/commands/indicator.md +136 -0
- package/gangtise-openapi/references/commands/insight.md +187 -0
- package/gangtise-openapi/references/commands/quote.md +93 -0
- package/gangtise-openapi/references/commands/reference-and-lookup.md +229 -0
- package/gangtise-openapi/references/commands/vault.md +79 -0
- package/gangtise-openapi/references/examples.md +291 -0
- package/gangtise-openapi/references/fields.md +166 -0
- package/gangtise-openapi/references/lookup-ids.md +264 -0
- package/gangtise-openapi/references/response-schema.md +85 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -4,6 +4,49 @@
|
|
|
4
4
|
|
|
5
5
|
## Changelog
|
|
6
6
|
|
|
7
|
+
### v0.27.0 — 2026-07-11
|
|
8
|
+
|
|
9
|
+
**EDE 指标(体验修复)**
|
|
10
|
+
- `indicator` 三端点对 `999999` 不再自动重试——实测服务端用 `999999` + HTTP 500 表示「查询无数据」(节假日 / 未来日期 / 未覆盖标的),此前每次空查询白烧 3 个请求 + ~4 秒;错误提示改为指向检查查询条件而非「稍后重试」
|
|
11
|
+
|
|
12
|
+
**资金与下载加固(承接 v0.26.0)**
|
|
13
|
+
- 下载路径同样接入重试策略:50/篇 的 `summary` / `foreign-report` / `my-conference` download 改为 no-replay(与 AI Agent 同价档;下载中断重试可能重复计费),10-30/篇 的下载维持默认重试
|
|
14
|
+
- 签名 URL 下载增加整体硬截止(10× 单请求超时)——headers/body 超时是空闲型,慢滴速传输可无限续命;最终 rename 失败时清理 `.part`
|
|
15
|
+
- `GANGTISE_PAGE_CONCURRENCY` 防御性解析:非法/非正数回退默认 5、上限 32——负值此前被底层钳制成**单 worker 串行**(静默变慢),过大值可能造成过度并发触发限流
|
|
16
|
+
- `--version` 更新提示改为数值分段版本比较(不处理预发布号;本项目只发 x.y.z)——刚发版的 registry 滞后窗口不再把旧版本提示成"可更新"
|
|
17
|
+
|
|
18
|
+
**体验与正确性小修**
|
|
19
|
+
- `--wait` 异步轮询容忍瞬态错误:5xx/网络抖动只消耗一次尝试并继续等待,不再作废整段等待(积分不足等终态错误仍立即中止)
|
|
20
|
+
- table 输出单元格显示宽度上限 120(超长截断加 `…`)——一个超长字段不再把整列所有行 pad 成同宽(行数 × 宽度的空格放大)
|
|
21
|
+
- markdown 输出先转义反斜杠再转义竖线,字面 `\|` 单元格不再错位列;table/markdown 过滤 C1 控制符(U+009B 单字节 CSI 注入面)
|
|
22
|
+
- 自动文件名按码点截断,emoji 不再被截成 `�`;EDE 矩阵中与 `date`/`security`/`name` 同名的指标列自动加后缀,不再覆盖元数据列
|
|
23
|
+
- 全市场分片截断时输出 `truncatedShards`(具体日期区间,与 `failedShards` 对称),脚本/AI 消费者可定向缩窗补拉
|
|
24
|
+
- 分页端点首页形状漂移(如 `total` 变字符串)时 `--verbose` 下告警,不再完全静默退化单页
|
|
25
|
+
|
|
26
|
+
**Skill 分发**
|
|
27
|
+
- `gangtise-openapi/` 目录纳入 npm 包;README 安装命令改为从 `$(npm root -g)` 复制——此前的相对路径命令对 npm 用户不可执行
|
|
28
|
+
|
|
29
|
+
**防漂移门禁(工程,不影响 CLI 行为)**
|
|
30
|
+
- 新增 README↔ENDPOINTS 一致性测试:「自动翻页」清单与注册表 pagination 标记双向比对(此类手抄清单漂移已发生两次);insight/reference 子命令的 `--help` 覆盖改为从端点注册表派生,新命令漏接线直接测试失败
|
|
31
|
+
- `npm run prepare` 前置断言 README/CHANGELOG 含当前版本条目(写盘前检查,失败零残留);`npm run typecheck` 纳入 tests/(tsconfig.test.json)
|
|
32
|
+
- CI:`npm pack` 装包冒烟(`--help` + skill 文件存在校验)、测试矩阵 Node 下限改精确 20.18.1、CI typecheck;publish 的 `workflow_dispatch` 必须指向 `v*` tag(关闭无护栏发布通道)
|
|
33
|
+
|
|
34
|
+
### v0.26.0 — 2026-07-11
|
|
35
|
+
|
|
36
|
+
**资金安全(重要)**
|
|
37
|
+
- 13 个贵档端点(`one-pager` / `investment-logic` / `peer-comparison` / `research-outline` / `theme-tracking` / `management-discuss-*`×2 / `hot-topic` / `knowledge-batch` / `earnings-review get-id` / `viewpoint-debate get-id` / `concept-info` / `concept-securities`)改为 **no-replay 重试策略**:5xx / 超时 / `999999` 不再自动重放——实测(2026-07-11)平台按次计费且**缓存命中不豁免**,同参数重放每次都扣分;仅连接期错误(`ECONNREFUSED`/DNS,请求未发出)、429 限流和 token 自愈仍重试。便宜按条计费的 list 类维持原全量重试(失败响应没有数据行、不计费)
|
|
38
|
+
- 连接失败 `ECONNREFUSED` / `UND_ERR_CONNECT_TIMEOUT` 纳入默认重试范围(此前这两类不重试)
|
|
39
|
+
|
|
40
|
+
**文件安全**
|
|
41
|
+
- 所有 `--output` 落盘(导出、流式下载主路径、签名 URL 跟随下载)改为原子写:先写同目录 `.part` 成功后 rename——重跑失败不再毁掉已有旧文件;顺带修掉中止路径上 `.part` 因流懒打开竞态残留的问题
|
|
42
|
+
- 签名 URL 跟随下载改走 transport 层:遵守 `GANGTISE_TIMEOUT_MS`(此前裸 `fetch` 无超时,慢滴速 CDN 可无限挂起)、网络错误自动重试、跟随最多 3 跳重定向(undici 不自动跟随,超限/缺 `Location` 报错而非把跳转页存成文件)、`--verbose` 日志剥离签名 query 只留 origin+path
|
|
43
|
+
- 自动命名去重后缀试尽 `-1`…`-99` 仍冲突时报错,不再静默覆盖最早的文件
|
|
44
|
+
|
|
45
|
+
**修复 / 加固**
|
|
46
|
+
- 下载重定向超过 3 跳或缺 `Location` 时报错,不再把跳转页 HTML 当文件内容保存
|
|
47
|
+
- 损坏的 gzip 响应包装为带请求上下文的 `ApiError`(此前抛裸 zlib `Z_DATA_ERROR`,与请求无关联且不可定位)
|
|
48
|
+
- `alternative edb-search --limit` ≤200、`indicator search --limit` ≤100 本地上限校验——实测服务端对超限值静默截断(201→200、101→100),与 v0.25.0 的 `--top` 同类同修法
|
|
49
|
+
|
|
7
50
|
### v0.25.0 — 2026-07-10
|
|
8
51
|
|
|
9
52
|
**新增接口(4)**
|
|
@@ -52,39 +95,6 @@
|
|
|
52
95
|
- `reference institution-search` — 机构 ID 搜索,输入机构名/简称返回 `institutionId` 及适用接口参数(`usageScopes`);`--keyword`(必填)、`--category`(`domesticBroker`/`foreignInstitution`/`leadInstitution`/`opinionInstitution`/`foreignOpinionInstitution`,可重复)、`--top`(默认 10,上限 10);免费。覆盖既有 `--broker`/`--institution` 全部机构类(research/foreign-report/opinion/foreign-opinion/summary/roadshow/site-visit/strategy/my-conference)
|
|
53
96
|
- `vault my-conference-list` 新增 `--source` — 按录制来源筛选(`1`=企微会议助理 `2`=会议服务微信群,可重复;不传返回全部)
|
|
54
97
|
|
|
55
|
-
### v0.22.1 — 2026-07-03
|
|
56
|
-
|
|
57
|
-
**修复**
|
|
58
|
-
- 错误码 `410004` 提示改为中性措辞「数据未找到或无指标权限,请检查查询条件与指标权限」——此前只说"数据未找到",与 `indicator` 内层信封的"无权限"消息拼接后自相矛盾
|
|
59
|
-
|
|
60
|
-
**文档 / Skill**(随 `/sync-skill` 分发,不影响 CLI 行为)
|
|
61
|
-
- gangtise-openapi Agent Skill 经 fable5 审计 + 多轮 review 优化:官方积分计费速查表 + 高积分 pre-flight 闸门、AI 同步生成命令 `GANGTISE_TIMEOUT_MS=120000` 超时前置、大结果集 `--output` 落盘、异步 `--wait` 主路径、行业码口径收敛到单一权威、市值量纲实测(`qte_mkt_cptl` 仅 A 股 / 默认原始「元」/ `scale`+`currency`)等文档补全与消歧
|
|
62
|
-
|
|
63
|
-
### v0.22.0 — 2026-07-02
|
|
64
|
-
|
|
65
|
-
**行为变更(注意)**
|
|
66
|
-
- ⚠️ 自动翻页接口省略 `--size` 现在一律拉全量(不再区分是否传时间范围);需要只取前 N 条时请显式传 `--size N`。数据量未知时可先用 `--size 1` 从 stderr 的 `Total: N` 探明量级
|
|
67
|
-
- 部分结果可机器识别:翻页页失败、K 线分片失败、或服务端提前短页但仍报告更大 `total` 时,结果会带 `partial: true`(页失败另有 `failedPages`,分片为 `failedShards`),非 json 行式输出仍只输出数据行,但进程退出码为 3
|
|
68
|
-
|
|
69
|
-
**修复(鉴权 / 请求可靠性)**
|
|
70
|
-
- Token 自愈覆盖服务端 `0000001008` 踢线失效,并能处理 HTTP 4xx 错误信封;`GANGTISE_TOKEN` + AK/SK 场景下环境 token 失效后不再反复回放旧 token
|
|
71
|
-
- 并发请求同时遇到旧 token 失效时复用一次刷新结果;若刚拿到的新 token 本身被踢掉,则强制再次登录,避免"刚登录窗口期"误跳过刷新
|
|
72
|
-
- 自动重试范围扩展到 429、DNS/网络临时错误与 undici 超时类错误;`GANGTISE_BASE_URL` 带路径前缀时 URL 拼接不再丢前缀
|
|
73
|
-
|
|
74
|
-
**修复(下载 / 输出 / 数据正确性)**
|
|
75
|
-
- 下载接口跟随最多 3 次 30x 跳转;跨域跳到对象存储签名 URL 时不携带 Authorization;服务端返回 `{url}` 且用户传 `--output` 时会真正下载文件,而不是把 URL 字符串写进文件
|
|
76
|
-
- 自动文件名补齐清洗、截断与去重:服务端文件名、标题缓存名和 fallback 名都不会把 `/`、控制字符、过长中文名或重复标题变成路径/覆盖问题
|
|
77
|
-
- `table`/`markdown` 输出清理控制字符、正确按 CJK 宽字符对齐,并转义 markdown 表头中的 `|`;CSV 输出转义表头、文件输出带 UTF-8 BOM,流式 CSV 遇全标量列表时回退到正常渲染而不是只写 BOM
|
|
78
|
-
- `indicator search` / `cross-section` / `time-series` 的内层失败信封即使没有 `data` 字段也会抛出 `ApiError`,不再把"无权限/参数错误"渲染成成功结果
|
|
79
|
-
- `--indicator-param` 等逗号列表支持全角逗号 `,`;日期型时间参数按本地零点解析,避免 `yyyy-MM-dd` 被当作 UTC 造成查询窗口偏移
|
|
80
|
-
- `fundamental earning-forecast` 省略 `--start-date` 时按传入的 `--end-date` 往前一年计算,不再总是按今天往前一年
|
|
81
|
-
- AI 异步 `--wait` 对 `410111` 终态失败只提示"不要重试",超时才提示稍后用 check 命令查询;等待说明同步为最长约 5 分钟
|
|
82
|
-
|
|
83
|
-
**CLI / 工程**
|
|
84
|
-
- `raw call` 会在本地拒绝 JSON endpoint 的 `--query` 和 download endpoint 的 `--body`,避免静默丢参数;`--format` 在发请求前校验,格式拼错不再先消耗接口调用
|
|
85
|
-
- `gangtise ... | head` 遇 stdout `EPIPE` 时安静退出;只有首个参数是 `--version` / `-V` 时才触发版本快捷路径
|
|
86
|
-
- Endpoint registry 的 `key` 改为由记录键自动派生,减少映射漂移;新增真实 CLI 选项到请求体的 stub 测试;测试 272 → 323
|
|
87
|
-
|
|
88
98
|
> 更早版本及完整更新历史见 [CHANGELOG.md](CHANGELOG.md)。
|
|
89
99
|
|
|
90
100
|
## 首次安装
|
|
@@ -105,6 +115,8 @@ gangtise --help
|
|
|
105
115
|
npm update -g gangtise-openapi-cli
|
|
106
116
|
```
|
|
107
117
|
|
|
118
|
+
> 更新后若使用 Agent Skill:包内 skill 已随包更新,但复制到 `~/.claude/skills/` 等目录的副本是快照,**需重新执行下方「安装」段的复制命令**才能让 AI 助手拿到新版 skill。
|
|
119
|
+
|
|
108
120
|
本地开发:
|
|
109
121
|
|
|
110
122
|
```bash
|
|
@@ -125,7 +137,7 @@ export GANGTISE_BASE_URL="https://openapi.gangtise.com"
|
|
|
125
137
|
export GANGTISE_TOKEN="Bearer xxx"
|
|
126
138
|
|
|
127
139
|
# 性能/调试可选项
|
|
128
|
-
export GANGTISE_PAGE_CONCURRENCY=5 #
|
|
140
|
+
export GANGTISE_PAGE_CONCURRENCY=5 # 翻页/分片并发数(默认 5,上限 32;非法值回退默认)
|
|
129
141
|
export GANGTISE_VERBOSE=1 # 打印每个请求的耗时与字节数
|
|
130
142
|
export GANGTISE_TIMEOUT_MS=30000 # 请求超时(默认 30s)
|
|
131
143
|
export GANGTISE_TOKEN_CACHE_PATH=... # 覆盖 token 缓存路径(默认 ~/.config/gangtise/token.json)
|
|
@@ -164,22 +176,26 @@ gangtise-openapi/
|
|
|
164
176
|
└── response-schema.md # 各接口响应字段说明
|
|
165
177
|
```
|
|
166
178
|
|
|
167
|
-
|
|
179
|
+
安装(skill 目录随 npm 包分发,`npm install -g` 之后即可从全局安装位置复制):
|
|
168
180
|
|
|
169
181
|
```bash
|
|
182
|
+
SKILL_SRC="$(npm root -g)/gangtise-openapi-cli/gangtise-openapi"
|
|
183
|
+
|
|
170
184
|
# Claude Code
|
|
171
|
-
cp -r
|
|
185
|
+
cp -r "$SKILL_SRC" ~/.claude/skills/gangtise-openapi
|
|
172
186
|
|
|
173
187
|
# Codex
|
|
174
|
-
cp -r
|
|
188
|
+
cp -r "$SKILL_SRC" ~/.codex/skills/gangtise-openapi
|
|
175
189
|
|
|
176
190
|
# OpenClaw
|
|
177
|
-
cp -r
|
|
191
|
+
cp -r "$SKILL_SRC" ~/.openclaw/skills/gangtise-openapi
|
|
178
192
|
|
|
179
193
|
# Hermes
|
|
180
|
-
cp -r
|
|
194
|
+
cp -r "$SKILL_SRC" ~/.hermes/skills/gangtise-openapi
|
|
181
195
|
```
|
|
182
196
|
|
|
197
|
+
> 从仓库 clone 开发时,把 `$SKILL_SRC` 换成仓库内的 `gangtise-openapi` 目录即可。
|
|
198
|
+
|
|
183
199
|
> **版本更新**:每次 CLI 发版时,`gangtise-openapi/SKILL.md` 的 `version` 字段会自动同步。更新 CLI 后,请将项目中的 `gangtise-openapi/` 目录重新复制到对应的 skills 目录覆盖更新:
|
|
184
200
|
>
|
|
185
201
|
> ```bash
|
|
@@ -311,7 +327,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
|
|
|
311
327
|
- **HTTP keep-alive**:所有请求复用同一个 `undici.Agent`(连接池 16),避免重复 TLS 握手。
|
|
312
328
|
- **流式下载**:指定 `--output` 时,二进制响应(PDF 等)直接 `pipeline` 到磁盘,不经过内存缓冲;50MB PDF 内存占用近乎为零。
|
|
313
329
|
- **流式输出**:`jsonl`/`csv` 格式且 `--output` 指定时,超过 1000 行自动切换为逐行写盘,避免一次性构建百 MB 字符串。
|
|
314
|
-
- **自动重试**:5xx / 429 / `ECONNRESET` / `ETIMEDOUT` / `ENOTFOUND` / `EAI_AGAIN` / `UND_ERR_*`(undici
|
|
330
|
+
- **自动重试**:5xx / 429 / `ECONNREFUSED` / `ECONNRESET` / `ETIMEDOUT` / `ENOTFOUND` / `EAI_AGAIN` / `UND_ERR_*`(undici 连接/超时类)/ `999999` 系统错误自动指数退避重试 2 次。**贵档端点例外**(one-pager 等 13 个生成/提交类 + 50/篇 的 summary/foreign-report/my-conference 下载,共 16 个):5xx/超时不重放——按次计费不幂等,重放即重复扣分;仅连接失败、429 与 token 自愈重试。**`indicator`(EDE)端点对 `999999` 不重试**——该码实为「查询无数据」(实测),重试纯浪费。
|
|
315
331
|
- **Token 自愈**:调用返回 8000014/8000015 时自动强制刷新 Token 并重试一次。
|
|
316
332
|
- **K线/资金流向自动分片**:`quote day-kline --security all`、`quote fund-flow --security aShares` 等全市场查询自动按日期切分(A股 K线/资金流向 1 天/片、美股 1 天/片、HK 2 天/片、指数 30 天/片),并发执行后合并结果;按日分片自动跳过周六日。分片时如果用户未传 `--limit`,自动注入 `limit: 10000`(API 上限)避免默认 6000 截断。
|
|
317
333
|
- **Token 内存缓存**:Token 在进程内存中缓存,避免每次请求读盘。
|
package/dist/src/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Command, Option } from "commander";
|
|
3
3
|
import { checkAsyncContent, pollAsyncContent, POLL_MAX_ATTEMPTS } from "./core/asyncContent.js";
|
|
4
4
|
import { readTokenCache, redactTokenCache } from "./core/auth.js";
|
|
5
|
-
import { collectKeyValue, collectList, collectNumberList, localDateString, maybeArray, parseChoiceList, parseFrom, parseNumberOption, parseOptionalNumberOption, parseSize, parseTimestamp13 } from "./core/args.js";
|
|
5
|
+
import { collectKeyValue, collectList, collectNumberList, isVersionNewer, localDateString, maybeArray, parseChoiceList, parseFrom, parseNumberOption, parseOptionalNumberOption, parseSize, parseTimestamp13 } from "./core/args.js";
|
|
6
6
|
import { buildIndicatorCrossSectionBody, buildIndicatorTimeSeriesBody, buildQuoteKlineBody, buildStockPoolStocksBody, buildWechatChatroomListBody, buildWechatMessageListBody } from "./core/commandBodies.js";
|
|
7
7
|
import { flattenCrossSection, flattenTimeSeries, unwrapIndicatorData } from "./core/indicatorMatrix.js";
|
|
8
8
|
import { callKlineWithSharding, isAllMarket, isFullMarket } from "./core/quoteSharding.js";
|
|
@@ -601,7 +601,7 @@ program.addCommand(ai);
|
|
|
601
601
|
const alternative = new Command("alternative").description("Alternative data APIs");
|
|
602
602
|
alternative.command("edb-search").requiredOption("--keyword <text>", "Search keyword (e.g. '空调')").option("--limit <number>", "Max results (default: 100, max: 200)", "100").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("alternative.edb-search", {
|
|
603
603
|
keyword: options.keyword,
|
|
604
|
-
limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1 }),
|
|
604
|
+
limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 200 }),
|
|
605
605
|
})));
|
|
606
606
|
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) => {
|
|
607
607
|
const format = parseOutputFormat(options.format);
|
|
@@ -628,7 +628,7 @@ indicator.command("search").requiredOption("--keyword <text>", "Search keyword,
|
|
|
628
628
|
const format = parseOutputFormat(options.format);
|
|
629
629
|
const raw = await client.call("indicator.search", {
|
|
630
630
|
keyword: options.keyword,
|
|
631
|
-
limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1 }),
|
|
631
|
+
limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 100 }),
|
|
632
632
|
});
|
|
633
633
|
await printData(unwrapIndicatorData(raw), format, options.output);
|
|
634
634
|
}));
|
|
@@ -681,7 +681,9 @@ async function checkForUpdate(timeoutMs = 2000) {
|
|
|
681
681
|
try {
|
|
682
682
|
const response = await fetch("https://registry.npmjs.org/gangtise-openapi-cli/latest", { signal: AbortSignal.timeout(timeoutMs) });
|
|
683
683
|
const latest = (await response.json()).version;
|
|
684
|
-
|
|
684
|
+
// Ordered compare, not inequality: during the just-published window the
|
|
685
|
+
// registry still serves the PREVIOUS version — don't suggest a "downgrade".
|
|
686
|
+
if (latest && isVersionNewer(latest, CLI_VERSION)) {
|
|
685
687
|
process.stderr.write(`Update available: ${CLI_VERSION} → ${latest}\nRun: npm update -g gangtise-openapi-cli\n`);
|
|
686
688
|
}
|
|
687
689
|
}
|
package/dist/src/core/args.js
CHANGED
|
@@ -62,6 +62,23 @@ export function collectKeyValue(value, previous = {}) {
|
|
|
62
62
|
export function maybeArray(value) {
|
|
63
63
|
return value.length > 0 ? value : undefined;
|
|
64
64
|
}
|
|
65
|
+
/** True when `latest` is strictly newer than `current` (numeric per-segment
|
|
66
|
+
* compare). Plain inequality would nag "update available" during the
|
|
67
|
+
* just-published window while the registry still serves the previous version. */
|
|
68
|
+
export function isVersionNewer(latest, current) {
|
|
69
|
+
const parse = (v) => v.split(".").map(Number);
|
|
70
|
+
const a = parse(latest);
|
|
71
|
+
const b = parse(current);
|
|
72
|
+
if (a.some(Number.isNaN) || b.some(Number.isNaN))
|
|
73
|
+
return false;
|
|
74
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
75
|
+
const x = a[i] ?? 0;
|
|
76
|
+
const y = b[i] ?? 0;
|
|
77
|
+
if (x !== y)
|
|
78
|
+
return x > y;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
65
82
|
// Whitelist for enum-valued repeatable options. Only used where the server was
|
|
66
83
|
// probed NOT to reject bad values (it silently ignores the filter or returns
|
|
67
84
|
// empty instead) — endpoints that answer 100003 keep server-side validation.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ApiError } from "./errors.js";
|
|
2
2
|
import { printData } from "./printer.js";
|
|
3
|
+
import { isTransientError } from "./transport.js";
|
|
3
4
|
// 14 attempts with exponential backoff (5s→30s cap) ≈ 316s total wait budget.
|
|
4
5
|
export const POLL_MAX_ATTEMPTS = 14;
|
|
5
6
|
const POLL_INITIAL_DELAY_MS = 5_000;
|
|
@@ -26,8 +27,16 @@ export async function pollAsyncContent(client, getContentEndpoint, dataId, forma
|
|
|
26
27
|
process.stderr.write("Content generation failed (terminal). Do not retry.\n");
|
|
27
28
|
return "failed";
|
|
28
29
|
}
|
|
29
|
-
if (!isAsyncPending(error))
|
|
30
|
-
|
|
30
|
+
if (!isAsyncPending(error)) {
|
|
31
|
+
// AI generation windows are exactly when the server is busiest: one 5xx
|
|
32
|
+
// (after the client's own retries) must not void minutes of waiting —
|
|
33
|
+
// the dataId is still valid. Transient errors consume this attempt and
|
|
34
|
+
// polling continues; anything else (no credits, bad params) aborts.
|
|
35
|
+
if (!isTransientError(error))
|
|
36
|
+
throw error;
|
|
37
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
38
|
+
process.stderr.write(`Attempt ${attempt}/${POLL_MAX_ATTEMPTS}: transient error (${msg.slice(0, 80)}), continuing to wait...\n`);
|
|
39
|
+
}
|
|
31
40
|
}
|
|
32
41
|
if (attempt < POLL_MAX_ATTEMPTS) {
|
|
33
42
|
const delay = nextDelayMs(attempt);
|
package/dist/src/core/client.js
CHANGED
|
@@ -175,8 +175,14 @@ export class GangtiseClient {
|
|
|
175
175
|
from: startFrom,
|
|
176
176
|
size: firstPageSize,
|
|
177
177
|
});
|
|
178
|
-
if (!this.isPaginatedListResponse(firstPage))
|
|
178
|
+
if (!this.isPaginatedListResponse(firstPage)) {
|
|
179
|
+
// Shape drift (e.g. total arriving as a string) silently degrades fetch-all
|
|
180
|
+
// to a single page with no partial marker — make it visible on verbose.
|
|
181
|
+
if (isVerbose()) {
|
|
182
|
+
process.stderr.write(`[gangtise] warning: ${endpoint.key} is marked paginated but the first page has an unexpected shape (no numeric total + list); returning it as-is\n`);
|
|
183
|
+
}
|
|
179
184
|
return firstPage;
|
|
185
|
+
}
|
|
180
186
|
const total = firstPage.total;
|
|
181
187
|
const collected = [...firstPage.list];
|
|
182
188
|
const available = Math.max(total - startFrom, 0);
|
|
@@ -354,9 +360,22 @@ export class GangtiseClient {
|
|
|
354
360
|
// response reads as text directly (and keeps existing behavior on that path).
|
|
355
361
|
const encoding = response.headers['content-encoding'];
|
|
356
362
|
const gzipped = (Array.isArray(encoding) ? encoding[0] : encoding)?.toLowerCase().trim() === 'gzip';
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
363
|
+
let text;
|
|
364
|
+
if (gzipped) {
|
|
365
|
+
// A proxy/middlebox can declare gzip and deliver garbage — surface that as
|
|
366
|
+
// an ApiError with request context instead of a bare zlib Z_DATA_ERROR.
|
|
367
|
+
const bytes = new Uint8Array(await response.body.arrayBuffer());
|
|
368
|
+
try {
|
|
369
|
+
text = decodeResponseBody(bytes, encoding);
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
373
|
+
throw new ApiError(`Failed to decode gzip response for ${endpoint.method} ${endpoint.path}: ${detail}`, undefined, response.statusCode);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
text = await response.body.text();
|
|
378
|
+
}
|
|
360
379
|
logTiming(`${endpoint.method} ${endpoint.path}`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
|
|
361
380
|
// Parse Retry-After once so every error path below (JSON parse failure AND the
|
|
362
381
|
// envelope/HTTP-error throw) carries it — a non-JSON 429/503 must still honor
|
|
@@ -385,12 +404,21 @@ export class GangtiseClient {
|
|
|
385
404
|
throw error;
|
|
386
405
|
}
|
|
387
406
|
}, {
|
|
407
|
+
policy: endpoint.retry ?? "default",
|
|
388
408
|
onRetry: (attempt, error, delay) => {
|
|
389
409
|
if (!isVerbose())
|
|
390
410
|
return;
|
|
391
411
|
const msg = error instanceof Error ? error.message : String(error);
|
|
392
412
|
process.stderr.write(`[gangtise] retry ${attempt} after ${delay.toFixed(0)}ms: ${msg.slice(0, 120)}\n`);
|
|
393
413
|
},
|
|
414
|
+
}).catch((error) => {
|
|
415
|
+
// EDE uses 999999 for "no data for this query" (probed 2026-07-11) — the
|
|
416
|
+
// generic "系统错误,请稍后重试" hint would send the user retrying a query
|
|
417
|
+
// that will never have data. Swap in a context-specific hint.
|
|
418
|
+
if (endpoint.retry === 'no-999999' && error instanceof ApiError && error.code === '999999') {
|
|
419
|
+
throw new ApiError(error.message, error.code, error.statusCode, error.details, error.retryAfterMs, 'EDE 的 999999 多为查询无数据(节假日 / 未来日期 / 未覆盖标的)——先检查查询条件,确认应有数据再重试。');
|
|
420
|
+
}
|
|
421
|
+
throw error;
|
|
394
422
|
});
|
|
395
423
|
}
|
|
396
424
|
async download(endpoint, query, options) {
|
|
@@ -434,6 +462,13 @@ export class GangtiseClient {
|
|
|
434
462
|
dispatcher,
|
|
435
463
|
});
|
|
436
464
|
}
|
|
465
|
+
// The loop above can exit with a 3xx still in hand (hop limit exceeded, or a
|
|
466
|
+
// redirect without Location) — that response must never be treated as file
|
|
467
|
+
// content: its HTML placeholder body would be saved as the "downloaded file".
|
|
468
|
+
if (response.statusCode >= 300 && response.statusCode < 400) {
|
|
469
|
+
await response.body.text().catch(() => { });
|
|
470
|
+
throw new ApiError(`Download failed: unresolved redirect (HTTP ${response.statusCode})`, undefined, response.statusCode);
|
|
471
|
+
}
|
|
437
472
|
const contentType = Array.isArray(response.headers['content-type']) ? response.headers['content-type'][0] : response.headers['content-type'];
|
|
438
473
|
// From the final (post-redirect) response, so a rate-limited download honors
|
|
439
474
|
// Retry-After too — every error branch below passes it into the ApiError.
|
|
@@ -499,14 +534,17 @@ export class GangtiseClient {
|
|
|
499
534
|
// Stream directly to disk when caller already knows the destination
|
|
500
535
|
if (options?.streamTo) {
|
|
501
536
|
await fs.mkdir(path.dirname(options.streamTo), { recursive: true });
|
|
537
|
+
// Stream into a .part sibling and rename over the target only on success:
|
|
538
|
+
// writing to the target directly would truncate an existing file on the
|
|
539
|
+
// FIRST byte and delete it on failure — a failed re-download (or each
|
|
540
|
+
// withRetry attempt) must never destroy the user's previous good file.
|
|
541
|
+
const partPath = `${options.streamTo}.part`;
|
|
502
542
|
try {
|
|
503
|
-
await pipeline(response.body, createWriteStream(
|
|
543
|
+
await pipeline(response.body, createWriteStream(partPath));
|
|
544
|
+
await fs.rename(partPath, options.streamTo);
|
|
504
545
|
}
|
|
505
546
|
catch (error) {
|
|
506
|
-
|
|
507
|
-
// failed download never looks like a complete one. withRetry may still
|
|
508
|
-
// replay the request (the next attempt re-creates the file).
|
|
509
|
-
await fs.unlink(options.streamTo).catch(() => { });
|
|
547
|
+
await fs.unlink(partPath).catch(() => { });
|
|
510
548
|
throw error;
|
|
511
549
|
}
|
|
512
550
|
logTiming(`GET ${endpoint.path} (stream)`, Date.now() - startedAt, `${response.statusCode}`);
|
|
@@ -520,6 +558,9 @@ export class GangtiseClient {
|
|
|
520
558
|
filename,
|
|
521
559
|
};
|
|
522
560
|
}, {
|
|
561
|
+
// Download endpoints carry per-篇 billing too (summary/foreign-report/
|
|
562
|
+
// my-conference at 50/篇) — honor the endpoint's retry policy here as well.
|
|
563
|
+
policy: endpoint.retry ?? "default",
|
|
523
564
|
onRetry: (attempt, error, delay) => {
|
|
524
565
|
if (!isVerbose())
|
|
525
566
|
return;
|
|
@@ -18,15 +18,19 @@ function truncateFilename(name, maxBytes = 200) {
|
|
|
18
18
|
if (Buffer.byteLength(name, "utf8") <= maxBytes)
|
|
19
19
|
return name;
|
|
20
20
|
const ext = extname(name);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
// Trim by code point, not UTF-16 unit: a cut inside a surrogate pair would put
|
|
22
|
+
// a lone surrogate in the name, which reaches the filesystem as U+FFFD (�).
|
|
23
|
+
const stem = [...name.slice(0, name.length - ext.length)];
|
|
24
|
+
while (stem.length > 1 && Buffer.byteLength(stem.join("") + ext, "utf8") > maxBytes)
|
|
25
|
+
stem.pop();
|
|
26
|
+
return stem.join("") + ext;
|
|
25
27
|
}
|
|
26
28
|
/** Pick a non-existing path by suffixing -1, -2, … before the extension, so batch
|
|
27
29
|
* downloads whose titles collide ("2025年第一季度报告" from several companies) don't
|
|
28
30
|
* silently overwrite each other. Only auto-derived names go through this — an
|
|
29
|
-
* explicit --output path keeps plain overwrite semantics.
|
|
31
|
+
* explicit --output path keeps plain overwrite semantics. Throws instead of
|
|
32
|
+
* falling back to the original path once the suffixes run out: returning `p`
|
|
33
|
+
* there would silently overwrite the very first file. */
|
|
30
34
|
export async function uniquePath(p) {
|
|
31
35
|
const exists = (f) => fs.access(f).then(() => true, () => false);
|
|
32
36
|
if (!(await exists(p)))
|
|
@@ -38,7 +42,7 @@ export async function uniquePath(p) {
|
|
|
38
42
|
if (!(await exists(candidate)))
|
|
39
43
|
return candidate;
|
|
40
44
|
}
|
|
41
|
-
|
|
45
|
+
throw new DownloadError(`Refusing to overwrite: 100 files already share the name "${p}" — pass --output or clean up the directory`);
|
|
42
46
|
}
|
|
43
47
|
const MIME_EXT = {
|
|
44
48
|
"application/pdf": ".pdf",
|
|
@@ -106,19 +110,80 @@ export async function resolveTitle(client, result, listEndpoint, idField, idValu
|
|
|
106
110
|
}
|
|
107
111
|
async function downloadUrlTo(url, outputPath) {
|
|
108
112
|
const { createWriteStream } = await import("node:fs");
|
|
109
|
-
const { Readable } = await import("node:stream");
|
|
110
113
|
const { pipeline } = await import("node:stream/promises");
|
|
111
114
|
const { dirname } = await import("node:path");
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
const { request } = await import("undici");
|
|
116
|
+
const { getDispatcher, logTiming, withRetry } = await import("./transport.js");
|
|
117
|
+
const { loadConfig } = await import("./config.js");
|
|
118
|
+
// Through the transport layer instead of a bare global fetch: the configured
|
|
119
|
+
// timeout applies (a slow-drip CDN can no longer hang the CLI indefinitely),
|
|
120
|
+
// network-level failures retry, and --verbose logs the request.
|
|
121
|
+
const timeoutMs = loadConfig().timeoutMs;
|
|
116
122
|
await fs.mkdir(dirname(outputPath), { recursive: true });
|
|
123
|
+
// Signed URLs carry credentials in the query string — verbose logs must strip
|
|
124
|
+
// search/hash so signatures never land in terminal/CI logs.
|
|
125
|
+
const redactUrl = (u) => {
|
|
126
|
+
try {
|
|
127
|
+
const parsed = new URL(u);
|
|
128
|
+
return parsed.origin + parsed.pathname;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return "signed-url";
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
// .part + rename so a failed follow-download never destroys an existing file.
|
|
135
|
+
const partPath = `${outputPath}.part`;
|
|
136
|
+
await withRetry(async () => {
|
|
137
|
+
const startedAt = Date.now();
|
|
138
|
+
const requestOptions = {
|
|
139
|
+
method: "GET",
|
|
140
|
+
headersTimeout: timeoutMs,
|
|
141
|
+
bodyTimeout: timeoutMs,
|
|
142
|
+
// headers/body timeouts are IDLE timeouts — a stream trickling one byte
|
|
143
|
+
// per interval resets them forever. A generous total deadline (10× the
|
|
144
|
+
// per-request timeout) bounds the whole transfer without killing large
|
|
145
|
+
// legitimate downloads.
|
|
146
|
+
signal: AbortSignal.timeout(timeoutMs * 10),
|
|
147
|
+
dispatcher: getDispatcher(),
|
|
148
|
+
};
|
|
149
|
+
let currentUrl = url;
|
|
150
|
+
let response = await request(currentUrl, requestOptions);
|
|
151
|
+
// undici does not follow redirects (the old global fetch did): a signed URL
|
|
152
|
+
// may 302 to the real CDN object. Follow a bounded number of hops; no
|
|
153
|
+
// Authorization header is involved on this path, so nothing leaks cross-origin.
|
|
154
|
+
for (let hops = 0; hops < 3 && response.statusCode >= 300 && response.statusCode < 400; hops++) {
|
|
155
|
+
const locationHeader = response.headers.location;
|
|
156
|
+
const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader;
|
|
157
|
+
if (!location)
|
|
158
|
+
break;
|
|
159
|
+
await response.body.text().catch(() => { });
|
|
160
|
+
currentUrl = new URL(location, currentUrl).toString();
|
|
161
|
+
response = await request(currentUrl, requestOptions);
|
|
162
|
+
}
|
|
163
|
+
if (response.statusCode >= 300 && response.statusCode < 400) {
|
|
164
|
+
await response.body.text().catch(() => { });
|
|
165
|
+
throw new DownloadError(`Failed to fetch download URL: unresolved redirect (HTTP ${response.statusCode})`);
|
|
166
|
+
}
|
|
167
|
+
if (response.statusCode >= 400) {
|
|
168
|
+
await response.body.text().catch(() => { });
|
|
169
|
+
// DownloadError (not retried): signed URLs expire — replaying a 403 is useless.
|
|
170
|
+
throw new DownloadError(`Failed to fetch download URL (HTTP ${response.statusCode})`);
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
await pipeline(response.body, createWriteStream(partPath));
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
await fs.unlink(partPath).catch(() => { });
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
logTiming(`GET ${redactUrl(currentUrl)}`, Date.now() - startedAt, String(response.statusCode));
|
|
180
|
+
});
|
|
117
181
|
try {
|
|
118
|
-
await
|
|
182
|
+
await fs.rename(partPath, outputPath);
|
|
119
183
|
}
|
|
120
184
|
catch (error) {
|
|
121
|
-
|
|
185
|
+
// e.g. outputPath turned out to be a directory — don't leave the .part behind.
|
|
186
|
+
await fs.unlink(partPath).catch(() => { });
|
|
122
187
|
throw error;
|
|
123
188
|
}
|
|
124
189
|
}
|
|
@@ -46,6 +46,8 @@ const ENDPOINT_DEFS = {
|
|
|
46
46
|
path: "/application/open-insight/summary/v2/download/file",
|
|
47
47
|
kind: "download",
|
|
48
48
|
description: "Download summary file",
|
|
49
|
+
// 50/篇 — same price tier as the AI Agent calls; billing probed non-idempotent.
|
|
50
|
+
retry: "no-replay",
|
|
49
51
|
},
|
|
50
52
|
"insight.roadshow.list": {
|
|
51
53
|
method: "POST",
|
|
@@ -100,6 +102,7 @@ const ENDPOINT_DEFS = {
|
|
|
100
102
|
path: "/application/open-insight/foreign-report/download/file",
|
|
101
103
|
kind: "download",
|
|
102
104
|
description: "Download foreign report",
|
|
105
|
+
retry: "no-replay",
|
|
103
106
|
},
|
|
104
107
|
"insight.announcement.list": {
|
|
105
108
|
method: "POST",
|
|
@@ -394,6 +397,7 @@ const ENDPOINT_DEFS = {
|
|
|
394
397
|
path: "/application/open-data/ai/search/knowledge/batch",
|
|
395
398
|
kind: "json",
|
|
396
399
|
description: "Batch knowledge search",
|
|
400
|
+
retry: "no-replay",
|
|
397
401
|
},
|
|
398
402
|
"ai.knowledge-resource.download": {
|
|
399
403
|
method: "GET",
|
|
@@ -414,6 +418,7 @@ const ENDPOINT_DEFS = {
|
|
|
414
418
|
kind: "json",
|
|
415
419
|
description: "Generate one pager",
|
|
416
420
|
timeoutMs: 120_000,
|
|
421
|
+
retry: "no-replay",
|
|
417
422
|
},
|
|
418
423
|
"ai.investment-logic": {
|
|
419
424
|
method: "POST",
|
|
@@ -421,6 +426,7 @@ const ENDPOINT_DEFS = {
|
|
|
421
426
|
kind: "json",
|
|
422
427
|
description: "Generate investment logic",
|
|
423
428
|
timeoutMs: 120_000,
|
|
429
|
+
retry: "no-replay",
|
|
424
430
|
},
|
|
425
431
|
"ai.peer-comparison": {
|
|
426
432
|
method: "POST",
|
|
@@ -428,12 +434,14 @@ const ENDPOINT_DEFS = {
|
|
|
428
434
|
kind: "json",
|
|
429
435
|
description: "Generate peer comparison",
|
|
430
436
|
timeoutMs: 120_000,
|
|
437
|
+
retry: "no-replay",
|
|
431
438
|
},
|
|
432
439
|
"ai.earnings-review.get-id": {
|
|
433
440
|
method: "POST",
|
|
434
441
|
path: "/application/open-ai/agent/earnings-review-getid",
|
|
435
442
|
kind: "json",
|
|
436
443
|
description: "Get earnings review ID",
|
|
444
|
+
retry: "no-replay",
|
|
437
445
|
},
|
|
438
446
|
"ai.earnings-review.get-content": {
|
|
439
447
|
method: "POST",
|
|
@@ -447,6 +455,7 @@ const ENDPOINT_DEFS = {
|
|
|
447
455
|
kind: "json",
|
|
448
456
|
description: "Get theme tracking daily report",
|
|
449
457
|
timeoutMs: 120_000,
|
|
458
|
+
retry: "no-replay",
|
|
450
459
|
},
|
|
451
460
|
"ai.research-outline": {
|
|
452
461
|
method: "POST",
|
|
@@ -454,6 +463,7 @@ const ENDPOINT_DEFS = {
|
|
|
454
463
|
kind: "json",
|
|
455
464
|
description: "Get company research outline",
|
|
456
465
|
timeoutMs: 120_000,
|
|
466
|
+
retry: "no-replay",
|
|
457
467
|
},
|
|
458
468
|
"ai.hot-topic": {
|
|
459
469
|
method: "POST",
|
|
@@ -461,6 +471,7 @@ const ENDPOINT_DEFS = {
|
|
|
461
471
|
kind: "json",
|
|
462
472
|
description: "List hot topic reports",
|
|
463
473
|
pagination: { enabled: true, maxPageSize: 20 },
|
|
474
|
+
retry: "no-replay",
|
|
464
475
|
},
|
|
465
476
|
"ai.management-discuss-announcement": {
|
|
466
477
|
method: "POST",
|
|
@@ -468,6 +479,7 @@ const ENDPOINT_DEFS = {
|
|
|
468
479
|
kind: "json",
|
|
469
480
|
description: "Management discussion from financial reports (half-year/annual)",
|
|
470
481
|
timeoutMs: 120_000,
|
|
482
|
+
retry: "no-replay",
|
|
471
483
|
},
|
|
472
484
|
"ai.management-discuss-earnings-call": {
|
|
473
485
|
method: "POST",
|
|
@@ -475,12 +487,14 @@ const ENDPOINT_DEFS = {
|
|
|
475
487
|
kind: "json",
|
|
476
488
|
description: "Management discussion from earnings calls",
|
|
477
489
|
timeoutMs: 120_000,
|
|
490
|
+
retry: "no-replay",
|
|
478
491
|
},
|
|
479
492
|
"ai.viewpoint-debate.get-id": {
|
|
480
493
|
method: "POST",
|
|
481
494
|
path: "/application/open-ai/agent/viewpoint-debate-getid",
|
|
482
495
|
kind: "json",
|
|
483
496
|
description: "Get viewpoint debate ID",
|
|
497
|
+
retry: "no-replay",
|
|
484
498
|
},
|
|
485
499
|
"ai.viewpoint-debate.get-content": {
|
|
486
500
|
method: "POST",
|
|
@@ -527,6 +541,7 @@ const ENDPOINT_DEFS = {
|
|
|
527
541
|
path: "/application/open-vault/my-conference/download/file",
|
|
528
542
|
kind: "download",
|
|
529
543
|
description: "Download my conference resource",
|
|
544
|
+
retry: "no-replay",
|
|
530
545
|
},
|
|
531
546
|
"vault.wechat-message.list": {
|
|
532
547
|
method: "POST",
|
|
@@ -573,12 +588,14 @@ const ENDPOINT_DEFS = {
|
|
|
573
588
|
path: "/application/open-alternative/concept/info",
|
|
574
589
|
kind: "json",
|
|
575
590
|
description: "Query latest concept (theme index) profile by conceptId",
|
|
591
|
+
retry: "no-replay",
|
|
576
592
|
},
|
|
577
593
|
"alternative.concept-securities": {
|
|
578
594
|
method: "POST",
|
|
579
595
|
path: "/application/open-alternative/concept/securities",
|
|
580
596
|
kind: "json",
|
|
581
597
|
description: "Query concept (theme index) constituent securities, grouped",
|
|
598
|
+
retry: "no-replay",
|
|
582
599
|
},
|
|
583
600
|
// ─── indicator (EDE: security-level data indicators) ───
|
|
584
601
|
"indicator.search": {
|
|
@@ -586,18 +603,21 @@ const ENDPOINT_DEFS = {
|
|
|
586
603
|
path: "/application/open-indicator/EDE/search",
|
|
587
604
|
kind: "json",
|
|
588
605
|
description: "Search data indicators by keyword (returns indicatorCode + params)",
|
|
606
|
+
retry: "no-999999",
|
|
589
607
|
},
|
|
590
608
|
"indicator.cross-section": {
|
|
591
609
|
method: "POST",
|
|
592
610
|
path: "/application/open-indicator/EDE/cross-section",
|
|
593
611
|
kind: "json",
|
|
594
612
|
description: "Get cross-section data (multi-indicator x multi-security, single date)",
|
|
613
|
+
retry: "no-999999",
|
|
595
614
|
},
|
|
596
615
|
"indicator.time-series": {
|
|
597
616
|
method: "POST",
|
|
598
617
|
path: "/application/open-indicator/EDE/time-series",
|
|
599
618
|
kind: "json",
|
|
600
619
|
description: "Get time-series data (multi-indicator x single-security OR single-indicator x multi-security)",
|
|
620
|
+
retry: "no-999999",
|
|
601
621
|
},
|
|
602
622
|
};
|
|
603
623
|
export const ENDPOINTS = Object.fromEntries(Object.entries(ENDPOINT_DEFS).map(([key, def]) => [key, { key, ...def }]));
|
package/dist/src/core/errors.js
CHANGED
|
@@ -40,12 +40,15 @@ export class ApiError extends CliError {
|
|
|
40
40
|
constructor(message, code, statusCode, details,
|
|
41
41
|
/** Server-specified Retry-After (ms), set on 429 responses so the transport
|
|
42
42
|
* backoff can honor it instead of the default exponential schedule. */
|
|
43
|
-
retryAfterMs
|
|
43
|
+
retryAfterMs,
|
|
44
|
+
/** Context-specific hint that beats the generic per-code table — e.g. EDE's
|
|
45
|
+
* 999999 means "no data", not the table's "系统错误,请稍后重试". */
|
|
46
|
+
hintOverride) {
|
|
44
47
|
super(message);
|
|
45
48
|
this.code = code;
|
|
46
49
|
this.statusCode = statusCode;
|
|
47
50
|
this.details = details;
|
|
48
51
|
this.retryAfterMs = retryAfterMs;
|
|
49
|
-
this.hint = code ? ERROR_HINTS[code] : undefined;
|
|
52
|
+
this.hint = hintOverride ?? (code ? ERROR_HINTS[code] : undefined);
|
|
50
53
|
}
|
|
51
54
|
}
|