gangtise-openapi-cli 0.24.0 → 0.26.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 CHANGED
@@ -4,6 +4,38 @@
4
4
 
5
5
  ## Changelog
6
6
 
7
+ ### v0.26.0 — 2026-07-11
8
+
9
+ **资金安全(重要)**
10
+ - 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 类维持原全量重试(失败响应没有数据行、不计费)
11
+ - 连接失败 `ECONNREFUSED` / `UND_ERR_CONNECT_TIMEOUT` 纳入默认重试范围(此前这两类不重试)
12
+
13
+ **文件安全**
14
+ - 所有 `--output` 落盘(导出、流式下载主路径、签名 URL 跟随下载)改为原子写:先写同目录 `.part` 成功后 rename——重跑失败不再毁掉已有旧文件;顺带修掉中止路径上 `.part` 因流懒打开竞态残留的问题
15
+ - 签名 URL 跟随下载改走 transport 层:遵守 `GANGTISE_TIMEOUT_MS`(此前裸 `fetch` 无超时,慢滴速 CDN 可无限挂起)、网络错误自动重试、跟随最多 3 跳重定向(undici 不自动跟随,超限/缺 `Location` 报错而非把跳转页存成文件)、`--verbose` 日志剥离签名 query 只留 origin+path
16
+ - 自动命名去重后缀试尽 `-1`…`-99` 仍冲突时报错,不再静默覆盖最早的文件
17
+
18
+ **修复 / 加固**
19
+ - 下载重定向超过 3 跳或缺 `Location` 时报错,不再把跳转页 HTML 当文件内容保存
20
+ - 损坏的 gzip 响应包装为带请求上下文的 `ApiError`(此前抛裸 zlib `Z_DATA_ERROR`,与请求无关联且不可定位)
21
+ - `alternative edb-search --limit` ≤200、`indicator search --limit` ≤100 本地上限校验——实测服务端对超限值静默截断(201→200、101→100),与 v0.25.0 的 `--top` 同类同修法
22
+
23
+ ### v0.25.0 — 2026-07-10
24
+
25
+ **新增接口(4)**
26
+ - `insight qa list` — 投资者问答 QA:按证券提取互动平台 / 电话会议 / 调研纪要的提问与回答;`--security-code`(必填)、`--source`(`conference`/`interactive`/`survey`)、`--question-category`(11 类,见 `insight.md`)、`--answer-important`(`1` 是 / `0` 否)、`--start-time`/`--end-time`(字符串直传);自动翻页(单页上限 500);0.1 积分/条
27
+ - `insight report-image list` / `download` — 研报图表:按关键词搜索研报图片,返回 `chunkId` + 元数据(`--keyword` 必填、`--top` 默认 10 上限 20、`--source-id`、时间过滤;**免费**),再 `download --chunk-id` 下二进制原图(JPEG,0.1 积分/张)
28
+ - `reference official-account-search` — 公众号 ID 搜索:输入公众号名 / 机构 / 关键字返回 `accountId`(喂 `insight official-account list --account-id`);`--keyword`(必填)、`--category`(`listedCompany`/`broker`/`government`/`media`,可重复;未分类公众号 `category` 为 `null`,传 `--category` 会漏掉)、`--top`(默认 10 上限 10);免费
29
+
30
+ **变更**
31
+ - `indicator search` / `cross-section` / `time-series` 市场范围从仅 A 股扩展至 A 股 / 港股 / 美股(服务端变更;CLI 早已支持 `--currency` 与多市场证券代码,无需改动)。⚠️ 美股代码用交易所后缀 `.O`(NASDAQ) / `.N`(NYSE),**非 `.US`**——官方示例的 `AAPL.US` 查不到数据,实测须 `AAPL.O`
32
+
33
+ **修复 / 加固**(承接上一批未单独发版的改动)
34
+ - 分页 / 分片 `partial` 检测补全:`requestPaginated` 的短后续页、`MAX_PAGES` 上限、`total` 漂移、失败页四种场景统一触发 `partial`(退出码 3)——失败页独立成判定条件,避免超额返回的兄弟页把行数补满、掩盖失败页空洞;`quote` 全市场分片硬错后熔断、破损形状分片计入 `failedShards`
35
+ - `--top` 本地上限校验(`report-image` / `knowledge-batch` ≤20,reference 六个搜索命令 ≤10)——实测服务端对超限值**静默截断**不报错,现在发请求前本地报错;`securities-search` / `institution-search` / `official-account-search` 的 `--category` 加本地白名单——实测服务端对拼错的分类**不报错**(securities-search 静默忽略过滤返回全类别、另两个静默返回空),拼写错误不再伪装成"无结果"(`insight qa` 的枚举服务端会报 `100003`,故不做本地白名单)
36
+ - 错误码 `100003`(参数值非法)补充中文提示——服务端不指明是哪个参数,提示对照命令 `--help` 检查枚举参数拼写
37
+ - undici `^7.16.0` → `^7.28.0`(修 keep-alive 队列污染 GHSA-35p6-xmwp-9g52),`engines.node` `>=20` → `>=20.18.1` 对齐 undici 实际最低要求
38
+
7
39
  ### v0.24.0 — 2026-07-07
8
40
 
9
41
  **新增**
@@ -44,75 +76,6 @@
44
76
  **文档 / Skill**(随 `/sync-skill` 分发,不影响 CLI 行为)
45
77
  - gangtise-openapi Agent Skill 经 fable5 审计 + 多轮 review 优化:官方积分计费速查表 + 高积分 pre-flight 闸门、AI 同步生成命令 `GANGTISE_TIMEOUT_MS=120000` 超时前置、大结果集 `--output` 落盘、异步 `--wait` 主路径、行业码口径收敛到单一权威、市值量纲实测(`qte_mkt_cptl` 仅 A 股 / 默认原始「元」/ `scale`+`currency`)等文档补全与消歧
46
78
 
47
- ### v0.22.0 — 2026-07-02
48
-
49
- **行为变更(注意)**
50
- - ⚠️ 自动翻页接口省略 `--size` 现在一律拉全量(不再区分是否传时间范围);需要只取前 N 条时请显式传 `--size N`。数据量未知时可先用 `--size 1` 从 stderr 的 `Total: N` 探明量级
51
- - 部分结果可机器识别:翻页页失败、K 线分片失败、或服务端提前短页但仍报告更大 `total` 时,结果会带 `partial: true`(页失败另有 `failedPages`,分片为 `failedShards`),非 json 行式输出仍只输出数据行,但进程退出码为 3
52
-
53
- **修复(鉴权 / 请求可靠性)**
54
- - Token 自愈覆盖服务端 `0000001008` 踢线失效,并能处理 HTTP 4xx 错误信封;`GANGTISE_TOKEN` + AK/SK 场景下环境 token 失效后不再反复回放旧 token
55
- - 并发请求同时遇到旧 token 失效时复用一次刷新结果;若刚拿到的新 token 本身被踢掉,则强制再次登录,避免"刚登录窗口期"误跳过刷新
56
- - 自动重试范围扩展到 429、DNS/网络临时错误与 undici 超时类错误;`GANGTISE_BASE_URL` 带路径前缀时 URL 拼接不再丢前缀
57
-
58
- **修复(下载 / 输出 / 数据正确性)**
59
- - 下载接口跟随最多 3 次 30x 跳转;跨域跳到对象存储签名 URL 时不携带 Authorization;服务端返回 `{url}` 且用户传 `--output` 时会真正下载文件,而不是把 URL 字符串写进文件
60
- - 自动文件名补齐清洗、截断与去重:服务端文件名、标题缓存名和 fallback 名都不会把 `/`、控制字符、过长中文名或重复标题变成路径/覆盖问题
61
- - `table`/`markdown` 输出清理控制字符、正确按 CJK 宽字符对齐,并转义 markdown 表头中的 `|`;CSV 输出转义表头、文件输出带 UTF-8 BOM,流式 CSV 遇全标量列表时回退到正常渲染而不是只写 BOM
62
- - `indicator search` / `cross-section` / `time-series` 的内层失败信封即使没有 `data` 字段也会抛出 `ApiError`,不再把"无权限/参数错误"渲染成成功结果
63
- - `--indicator-param` 等逗号列表支持全角逗号 `,`;日期型时间参数按本地零点解析,避免 `yyyy-MM-dd` 被当作 UTC 造成查询窗口偏移
64
- - `fundamental earning-forecast` 省略 `--start-date` 时按传入的 `--end-date` 往前一年计算,不再总是按今天往前一年
65
- - AI 异步 `--wait` 对 `410111` 终态失败只提示"不要重试",超时才提示稍后用 check 命令查询;等待说明同步为最长约 5 分钟
66
-
67
- **CLI / 工程**
68
- - `raw call` 会在本地拒绝 JSON endpoint 的 `--query` 和 download endpoint 的 `--body`,避免静默丢参数;`--format` 在发请求前校验,格式拼错不再先消耗接口调用
69
- - `gangtise ... | head` 遇 stdout `EPIPE` 时安静退出;只有首个参数是 `--version` / `-V` 时才触发版本快捷路径
70
- - Endpoint registry 的 `key` 改为由记录键自动派生,减少映射漂移;新增真实 CLI 选项到请求体的 stub 测试;测试 272 → 323
71
-
72
- ### v0.21.0 — 2026-06-29
73
-
74
- **行为变更(注意)**
75
- - ⚠️ `vault wechat-chatroom-list` 省略 `--size` 现在**拉全量**(此前默认只返回 20 条)。该接口不返回 `total`,CLI 改为串行翻页(翻到不满页为止,单页上限 50);传 `--size N` 仍只取前 N 条。依赖"默认 20 条"的脚本会拿到全部群。
76
-
77
- **修复**
78
- - `quote day-kline --security all` 等大结果集用默认 `table` 格式输出时不再因 `Math.max(...大数组)` 撑爆调用栈崩溃(`RangeError`);`renderTable` 改用 reduce 计算列宽
79
- - CSV 导出:含回车符 `\r` 的字段现在正确加引号(RFC 4180);`table` / `markdown` 的多行字段折叠换行,保持表格对齐
80
- - 下载文件名剥离控制字符 / NUL,避免 `fs.writeFile` 报错
81
-
82
- **修复(安全)**
83
- - token 缓存文件(`~/.config/gangtise/token.json`)改为临时文件 + 原子 `rename` 写入:从第一字节即 `0600`,消除"旧文件宽松权限残留"与"崩溃截断"两个隐患
84
-
85
- **内部 / 工程**
86
- - 依赖 `vitest` 升级到 3.2.6(修复 dev-only 安全告警);新增 `npm run typecheck`;测试 257 → 272
87
-
88
- ### v0.20.0 — 2026-06-26
89
-
90
- **新增接口**
91
- - `insight announcement-us list` / `download` — 美股公司公告列表与下载(`--security TSLA.O`、`--category`〔分类用 `reference constant-list --category usShareAnnouncementCategory`,美股独立的 `103980xxx` 段〕、`--search-type`、`--rank-type`、下载 `--file-type 1` 原始 PDF / `2` Markdown);自动翻页,单页上限 50
92
- - `ai stock-summary` — 个股看点(精炼投研总结):`--security` 传具体代码(A股/港股,可重复,单次最多 6000)或市场关键词 `aShares` / `hkStocks` 拉全市场;无看点的证券不返回、不扣分
93
- - `fundamental income-statement-us` / `balance-sheet-us` / `cash-flow-us` — 美股三大财务报表(参数同其他财报:`--security-code` / `--period` / `--report-type` / `--fiscal-year` / `--field` 等)
94
- - `reference chiefs-search` — 首席分析师 ID 搜索(`--keyword` 按姓名/机构/团队匹配,`--top` 默认 10);用于 `insight opinion list --chief` 的入参
95
-
96
- **变更**
97
- - `insight announcement-hk download` 新增 `--file-type`(`1` 原始(默认)/ `2` Markdown),此前无格式选项
98
-
99
- **行为变更(注意)**
100
- - ⚠️ `auth login` / `auth status` 默认脱敏 access token:`--format json` 输出里 `authorization` 与 `cache.accessToken` 显示为 `<redacted>`,仅保留过期时间 / 用户名 / 产品码 / uid 等非敏感字段。**依赖 `auth login` 原始 token 输出的脚本会拿到 `<redacted>`**,需改用 `auth login --show-token` 获取明文。
101
-
102
- **修复(安全)**
103
- - `auth status` / `auth login` token 脱敏:按凭证字段名模式匹配(`token`/`key`/`secret`/`password`/`credential`),覆盖 `apiKey`/`privateKey`/`refreshToken` 等任何可能携带的凭证字段
104
- - 自愈守卫:同时设 `GANGTISE_TOKEN` + AK/SK 时,注入 token 失效后重新登录不再被旧 token 短路,重试改用登录拿到的新 token
105
-
106
- **修复(数据正确性 / 健壮性)**
107
- - ⚠️ **CSV 负数不再被破坏**(影响所有 CSV 导出):此前防公式注入会把负数(如跌幅 `-3.5`)加 `'` 前缀变成文本,Excel/pandas 无法参与计算;现仅对非有限数字的可疑串(`=`/`@`/`-1+cmd` 等)加前缀,合法数字原样输出
108
- - 自动翻页改为 fail-soft:某页遇不可重试错误(限流 `903301` 等)不再丢弃已取的全部数据,返回已取页 + `partial` / `failedPages` 标记,并在首错后停止继续请求(避免撞限流多烧配额)
109
- - 下载文件名 fallback(服务端 `Content-Disposition`)补清洗:含 `/`、`:` 等字符的文件名不再写到意外路径
110
- - `ai stock-summary` / `ai knowledge-batch` 缺 `--security` / `--query` 时本地报错,不再发空请求(stock-summary 借此避免被后台当全市场误扣积分)
111
- - `ai hot-topic` `--no-with-related-securities` / `--no-with-close-reading` 改为显式发 `false`(语义更明确,不依赖"字段缺失=排除"的隐含约定)
112
-
113
- **修复(indicator 适配 EDE 后台新结构)**
114
- - `indicator cross-section` / `time-series` 适配后台改版的返回结构(字段名加 `List` 后缀 `securityCodeList/indicatorCodeList/…`、截面 `values` 改二维 `[指标][证券]`):此前后台改结构后 CLI 拍平失配、退化成原始矩阵,现恢复 `{date, security, name, 指标:值}` 宽表。配合后台同步变化——无数据从 `999999` 报错改为返回 `null`(截面不再 500、不丢行),缺必填参数从笼统 `410106` 改为直接指明缺哪个参数
115
-
116
79
  > 更早版本及完整更新历史见 [CHANGELOG.md](CHANGELOG.md)。
117
80
 
118
81
  ## 首次安装
@@ -242,9 +205,12 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
242
205
  | | `foreign-opinion list` | 外资机构观点 |
243
206
  | | `independent-opinion list` / `download` | 外资独立分析师观点(含原文/翻译HTML下载) |
244
207
  | | `official-account list` / `download` | 产业公众号资讯(含 txt/HTML 下载) |
208
+ | | `qa list` | 投资者问答 QA(互动平台/电话会议/调研纪要,按证券) |
209
+ | | `report-image list` / `download` | 研报图表搜索(按关键词,含原图 JPEG 下载) |
245
210
  | **Reference** | `securities-search` | GTS Code 搜索(按名称/代码/拼音匹配) |
246
211
  | | `chiefs-search` | 首席分析师 ID 搜索(按姓名/机构/团队匹配) |
247
212
  | | `institution-search` | 机构 ID 搜索(内资券商/外资/牵头/观点机构,按名称匹配) |
213
+ | | `official-account-search` | 公众号 ID 搜索(按公众号名/机构/分类匹配,返回 accountId) |
248
214
  | | `constant-category` | 常量分类列表(含各分类适用的接口与参数) |
249
215
  | | `constant-list` | 按分类导出常量值全量列表(行业/城市/公告分类/区域等) |
250
216
  | | `concept-search` | 题材 ID 搜索(名称/拼音/分组名匹配) |
@@ -336,9 +302,9 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
336
302
  - **HTTP keep-alive**:所有请求复用同一个 `undici.Agent`(连接池 16),避免重复 TLS 握手。
337
303
  - **流式下载**:指定 `--output` 时,二进制响应(PDF 等)直接 `pipeline` 到磁盘,不经过内存缓冲;50MB PDF 内存占用近乎为零。
338
304
  - **流式输出**:`jsonl`/`csv` 格式且 `--output` 指定时,超过 1000 行自动切换为逐行写盘,避免一次性构建百 MB 字符串。
339
- - **自动重试**:5xx / 429 / `ECONNRESET` / `ETIMEDOUT` / `ENOTFOUND` / `EAI_AGAIN` / `UND_ERR_*`(undici 超时类)/ `999999` 系统错误自动指数退避重试 2 次。
305
+ - **自动重试**:5xx / 429 / `ECONNREFUSED` / `ECONNRESET` / `ETIMEDOUT` / `ENOTFOUND` / `EAI_AGAIN` / `UND_ERR_*`(undici 连接/超时类)/ `999999` 系统错误自动指数退避重试 2 次。**贵档生成类端点例外**(one-pager 等 13 个):5xx/超时不重放——按次计费不幂等,重放即重复扣分;仅连接失败、429 与 token 自愈重试。
340
306
  - **Token 自愈**:调用返回 8000014/8000015 时自动强制刷新 Token 并重试一次。
341
- - **K线自动分片**:`quote day-kline --security all` 等全市场查询自动按日期切分(A股 1 天/片、美股 1 天/片、HK 2 天/片、指数 30 天/片),并发执行后合并结果。分片时如果用户未传 `--limit`,自动注入 `limit: 10000`(API 上限)避免默认 6000 截断。
307
+ - **K线/资金流向自动分片**:`quote day-kline --security all`、`quote fund-flow --security aShares` 等全市场查询自动按日期切分(A股 K线/资金流向 1 天/片、美股 1 天/片、HK 2 天/片、指数 30 天/片),并发执行后合并结果;按日分片自动跳过周六日。分片时如果用户未传 `--limit`,自动注入 `limit: 10000`(API 上限)避免默认 6000 截断。
342
308
  - **Token 内存缓存**:Token 在进程内存中缓存,避免每次请求读盘。
343
309
  - **`--verbose`**:打印每个请求的方法、路径、状态码、耗时和响应大小到 stderr,方便定位慢查询。
344
310
 
@@ -359,6 +325,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
359
325
  - `insight foreign-opinion list`
360
326
  - `insight independent-opinion list`
361
327
  - `insight official-account list`
328
+ - `insight qa list`
362
329
  - `ai security-clue`
363
330
  - `vault drive-list`
364
331
  - `vault record-list`
@@ -447,6 +414,12 @@ gangtise insight independent-opinion download --independent-opinion-id 207051900
447
414
  gangtise insight official-account list --keyword 泡泡玛特 --rank-type 2 --size 20 --format json
448
415
  gangtise insight official-account download --article-id 7286248 --file-type 2
449
416
 
417
+ # 投资者问答 QA(按证券;--source/--question-category/--answer-important 精筛,自动翻页)
418
+ gangtise insight qa list --security-code 601012.SH --source interactive --answer-important 1 --size 20 --format json
419
+ # 研报图表:按关键词搜图拿 chunkId,再下原图(JPEG)
420
+ gangtise insight report-image list --keyword AI --top 5 --format json
421
+ gangtise insight report-image download --chunk-id image_10_384655917758685184_8 --output ./ai-chart.jpg
422
+
450
423
  # 纪要下载(会议平台来源可选 HTML 格式)
451
424
  gangtise insight summary download --summary-id 4906813 --file-type 2
452
425
  ```
@@ -463,8 +436,10 @@ gangtise reference securities-search --keyword "银行" --category stock --categ
463
436
  # 首席分析师 ID 搜索(按姓名/机构/团队;拿 chiefId 供 insight opinion list --chief 使用)
464
437
  gangtise reference chiefs-search --keyword 东吴证券 --top 3 --format json
465
438
  gangtise reference chiefs-search --keyword 芦哲 --format json
466
- # 机构 ID 搜索(--category: domesticBroker/foreignInstitution/leadInstitution/opinionInstitution)
439
+ # 机构 ID 搜索(--category: domesticBroker/foreignInstitution/leadInstitution/opinionInstitution/foreignOpinionInstitution
467
440
  gangtise reference institution-search --keyword 招商证券 --category domesticBroker --top 3 --format json
441
+ # 公众号 ID 搜索(按名称/机构/分类;拿 accountId 供 insight official-account list --account-id)
442
+ gangtise reference official-account-search --keyword 中信证券 --top 3 --format json
468
443
 
469
444
  # 常量查询:先看分类,再按分类导出全量常量值
470
445
  gangtise reference constant-category --format json
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, parseFrom, parseNumberOption, parseOptionalNumberOption, parseSize, parseTimestamp13 } from "./core/args.js";
5
+ import { collectKeyValue, collectList, collectNumberList, 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";
@@ -169,6 +169,8 @@ const announcementUs = new Command("announcement-us");
169
169
  const foreignOpinion = new Command("foreign-opinion");
170
170
  const independentOpinion = new Command("independent-opinion");
171
171
  const officialAccount = new Command("official-account");
172
+ const qa = new Command("qa");
173
+ const reportImage = new Command("report-image");
172
174
  addTimeFilters(opinion.command("list").option("--rank-type <number>", "Rank type", "1").option("--research-area <id>", "Research area ID", collectList, []).option("--chief <id>", "Chief ID", collectList, []).option("--security <code>", "Security code", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--concept <id>", "Concept ID", collectList, []).option("--llm-tag <tag>", "Semantic tag", collectList, []).option("--source <source>", "Source", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.opinion.list", {
173
175
  from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime,
174
176
  rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }), keyword: options.keyword, researchAreaList: maybeArray(options.researchArea), chiefList: maybeArray(options.chief),
@@ -310,6 +312,19 @@ addTimeFilters(officialAccount.command("list").option("--search-type <number>",
310
312
  categoryList: maybeArray(options.category), industryList: maybeArray(options.industry),
311
313
  }), { endpointKey: "insight.official-account.list", idField: "articleId" }));
312
314
  addDownloadCommand(officialAccount, { endpointKey: "insight.official-account.download", idOption: "--article-id", idField: "articleId", fallbackPrefix: "official-account", fileType: { description: "File type: 1=txt(default) 2=HTML", default: "1" }, titleListEndpoint: "insight.official-account.list" });
315
+ // QA request keys are BARE (source/questionCategory/answerImportant), not the *List
316
+ // convention — the body below mirrors the spec exactly. Datetimes pass through as strings.
317
+ qa.command("list").requiredOption("--security-code <code>", "Security code, e.g. 601012.SH").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all (max page 500)").option("--start-time <datetime>", "Start time (yyyy-MM-dd or yyyy-MM-dd HH:mm:ss)").option("--end-time <datetime>", "End time (yyyy-MM-dd or yyyy-MM-dd HH:mm:ss)").option("--source <type>", "Source: conference/interactive/survey (repeat)", collectList, []).option("--question-category <name>", "Question category (repeat): productAndBusiness/capacityAndProjects/ordersAndCustomers/financialData/materialEvents/capitalOperations/shareholdersAndDividends/corporateGovernance/marketAndValuation/macroAndIndustry/risksAndOthers", collectList, []).option("--answer-important <flag>", "Answer involves key info: 1=yes 0=no (repeat; omit for all)", collectNumberList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("insight.qa.list", {
318
+ from: parseFrom(options.from), size: parseSize(options.size),
319
+ securityCode: options.securityCode, startTime: options.startTime, endTime: options.endTime,
320
+ source: maybeArray(options.source), questionCategory: maybeArray(options.questionCategory),
321
+ answerImportant: options.answerImportant.length ? options.answerImportant : undefined,
322
+ })));
323
+ reportImage.command("list").requiredOption("--keyword <text>", "Search keyword, e.g. 'AI' '新能源汽车'").option("--top <number>", "Max results (default: 10, max: 20)", "10").option("--source-id <id>", "Report source ID, to filter to one report (from a report list or knowledge base)").option("--start-time <datetime>", "Start time (yyyy-MM-dd HH:mm:ss; yyyy-MM-dd auto-completed)").option("--end-time <datetime>", "End time (yyyy-MM-dd HH:mm:ss; yyyy-MM-dd auto-completed)").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("insight.report-image.list", {
324
+ keyword: options.keyword, top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 20 }),
325
+ sourceId: options.sourceId, startTime: options.startTime, endTime: options.endTime,
326
+ })));
327
+ addDownloadCommand(reportImage, { endpointKey: "insight.report-image.download", idOption: "--chunk-id", idField: "chunkId", fallbackPrefix: "report-image" });
313
328
  insight.addCommand(opinion);
314
329
  insight.addCommand(summary);
315
330
  insight.addCommand(roadshow);
@@ -324,6 +339,8 @@ insight.addCommand(announcementUs);
324
339
  insight.addCommand(foreignOpinion);
325
340
  insight.addCommand(independentOpinion);
326
341
  insight.addCommand(officialAccount);
342
+ insight.addCommand(qa);
343
+ insight.addCommand(reportImage);
327
344
  program.addCommand(insight);
328
345
  const quote = new Command("quote").description("Quote APIs");
329
346
  const addKlineCommand = (name, endpointKey, securityHelp, shardDays) => quote.command(name)
@@ -436,10 +453,10 @@ fundamental.command("earning-forecast").requiredOption("--security-code <code>")
436
453
  }));
437
454
  program.addCommand(fundamental);
438
455
  const ai = new Command("ai").description("AI APIs");
439
- ai.command("knowledge-batch").option("--query <text>", "Query", collectList, []).option("--top <number>", "Top", "10").option("--resource-type <number>", "Resource type", collectNumberList, []).option("--knowledge-name <name>", "Knowledge name", collectList, []).option("--start-time <ms>").option("--end-time <ms>").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => {
456
+ ai.command("knowledge-batch").option("--query <text>", "Query", collectList, []).option("--top <number>", "Max results (default: 10, max: 20)", "10").option("--resource-type <number>", "Resource type", collectNumberList, []).option("--knowledge-name <name>", "Knowledge name", collectList, []).option("--start-time <ms>").option("--end-time <ms>").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => {
440
457
  if (!options.query.length)
441
458
  throw new ValidationError("--query is required: pass at least one --query");
442
- return emit(options, (client) => client.call("ai.knowledge-batch", { queries: options.query, top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }), resourceTypes: options.resourceType.length ? options.resourceType : undefined, knowledgeNames: maybeArray(options.knowledgeName), startTime: parseOptionalNumberOption(options.startTime, "--start-time", { integer: true, min: 0 }), endTime: parseOptionalNumberOption(options.endTime, "--end-time", { integer: true, min: 0 }) }));
459
+ return emit(options, (client) => client.call("ai.knowledge-batch", { queries: options.query, top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 20 }), resourceTypes: options.resourceType.length ? options.resourceType : undefined, knowledgeNames: maybeArray(options.knowledgeName), startTime: parseOptionalNumberOption(options.startTime, "--start-time", { integer: true, min: 0 }), endTime: parseOptionalNumberOption(options.endTime, "--end-time", { integer: true, min: 0 }) }));
443
460
  });
444
461
  ai.command("knowledge-resource-download").requiredOption("--resource-type <number>").requiredOption("--source-id <id>").option("--output <path>").action((options) => withClient(async (client) => {
445
462
  await runDownload(client, "ai.knowledge-resource.download", { resourceType: parseNumberOption(options.resourceType, "--resource-type", { integer: true, min: 0 }), sourceId: options.sourceId }, {
@@ -538,28 +555,34 @@ ai.command("stock-summary").description("Stock highlights: refined research summ
538
555
  const reference = new Command("reference").description("Reference data APIs");
539
556
  reference.command("securities-search").requiredOption("--keyword <text>", "Search keyword (name/code/pinyin/English)").option("--category <type>", "Category: stock/dr/index/fund", collectList, []).option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.securities-search", {
540
557
  keyword: options.keyword,
541
- category: options.category.length ? options.category : undefined,
542
- top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
558
+ category: parseChoiceList(options.category, "--category", ["stock", "dr", "index", "fund"]),
559
+ top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
543
560
  })));
544
561
  reference.command("constant-category").description("List constant categories and which API params accept them").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.constant-category")));
545
562
  reference.command("constant-list").requiredOption("--category <code>", "Category code from 'reference constant-category' (e.g. citicIndustry/swIndustry/regionCategory)").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.constant-list", { category: options.category })));
546
563
  reference.command("concept-search").requiredOption("--keyword <text>", "Search keyword (name/pinyin/group name)").option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.concept-search", {
547
564
  keyword: options.keyword,
548
- top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
565
+ top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
549
566
  })));
550
567
  reference.command("sector-search").option("--keyword <text>", "Search keyword (name/pinyin)").option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.sector-search", {
551
568
  keyword: options.keyword,
552
- top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
569
+ top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
553
570
  })));
554
571
  reference.command("sector-constituents").requiredOption("--sector-id <id>", "Sector ID from 'reference sector-search'").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.sector-constituents", { sectorId: options.sectorId })));
555
572
  reference.command("chiefs-search").requiredOption("--keyword <text>", "Search keyword (chief name / institution / team)").option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.chiefs-search", {
556
573
  keyword: options.keyword,
557
- top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
574
+ top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
558
575
  })));
559
576
  reference.command("institution-search").requiredOption("--keyword <text>", "Search keyword (institution name / abbreviation)").option("--category <name>", "Category: domesticBroker/foreignInstitution/leadInstitution/opinionInstitution/foreignOpinionInstitution (repeat); omit for all", collectList, []).option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.institution-search", {
560
577
  keyword: options.keyword,
561
- categoryList: maybeArray(options.category),
562
- top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
578
+ categoryList: parseChoiceList(options.category, "--category", ["domesticBroker", "foreignInstitution", "leadInstitution", "opinionInstitution", "foreignOpinionInstitution"]),
579
+ top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
580
+ })));
581
+ // Note: request key is BARE `category` here (spec), unlike institution-search's `categoryList`.
582
+ reference.command("official-account-search").requiredOption("--keyword <text>", "Search keyword (account name / institution / keyword, e.g. 东吴证券)").option("--category <name>", "Category: listedCompany/broker/government/media (repeat); omit for all incl. uncategorized", collectList, []).option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.official-account-search", {
583
+ keyword: options.keyword,
584
+ category: parseChoiceList(options.category, "--category", ["listedCompany", "broker", "government", "media"]),
585
+ top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
563
586
  })));
564
587
  program.addCommand(reference);
565
588
  const vault = new Command("vault").description("Vault APIs");
@@ -578,7 +601,7 @@ program.addCommand(ai);
578
601
  const alternative = new Command("alternative").description("Alternative data APIs");
579
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", {
580
603
  keyword: options.keyword,
581
- limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1 }),
604
+ limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 200 }),
582
605
  })));
583
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) => {
584
607
  const format = parseOutputFormat(options.format);
@@ -605,7 +628,7 @@ indicator.command("search").requiredOption("--keyword <text>", "Search keyword,
605
628
  const format = parseOutputFormat(options.format);
606
629
  const raw = await client.call("indicator.search", {
607
630
  keyword: options.keyword,
608
- limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1 }),
631
+ limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 100 }),
609
632
  });
610
633
  await printData(unwrapIndicatorData(raw), format, options.output);
611
634
  }));
@@ -62,6 +62,17 @@ export function collectKeyValue(value, previous = {}) {
62
62
  export function maybeArray(value) {
63
63
  return value.length > 0 ? value : undefined;
64
64
  }
65
+ // Whitelist for enum-valued repeatable options. Only used where the server was
66
+ // probed NOT to reject bad values (it silently ignores the filter or returns
67
+ // empty instead) — endpoints that answer 100003 keep server-side validation.
68
+ export function parseChoiceList(values, optionName, allowed) {
69
+ for (const value of values) {
70
+ if (!allowed.includes(value)) {
71
+ throw new ValidationError(`Invalid ${optionName}: "${value}" is not one of ${allowed.join("/")}`);
72
+ }
73
+ }
74
+ return maybeArray(values);
75
+ }
65
76
  export function toTimestamp13(value) {
66
77
  if (value === undefined)
67
78
  return undefined;
@@ -285,13 +285,30 @@ export class GangtiseClient {
285
285
  total,
286
286
  list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
287
287
  };
288
- if (failedPages.length > 0) {
288
+ // Unified completeness backstop. Whatever the cause — a failed/shape-broken page,
289
+ // a short later page (server page cap < maxPageSize), the MAX_PAGES cap, or `total`
290
+ // drifting mid-fetch — the result is partial. A short row count or a total drift each
291
+ // force it; so does any failedPages entry on its own — an over-returning sibling page
292
+ // can lift the row count back to target and mask the hole (short would read false), yet
293
+ // the failedPages branch below still writes "results are partial" to stderr, so the flag
294
+ // must agree. printData maps partial → exit 3 so a script can't read a truncated export
295
+ // as complete. The cap and drift branches above already warned on stderr; failedPages
296
+ // warns below.
297
+ const short = collected.length < target;
298
+ if (short || totalDrift || failedPages.length > 0)
289
299
  out.partial = true;
300
+ if (failedPages.length > 0) {
290
301
  out.failedPages = failedPages.map((p) => ({ from: p.from, size: p.size }));
291
302
  const detail = firstError instanceof Error ? `: ${firstError.message}` : "";
292
303
  const skippedHint = aborted ? " A page hit a non-retryable error (e.g. rate limit); remaining pages were skipped." : "";
293
304
  process.stderr.write(`[gangtise] warning: ${failedPages.length}/${pageRequests.length} pages not fetched${detail}; results are partial — got ${collected.length}/${total} rows (see failedPages).${skippedHint}\n`);
294
305
  }
306
+ else if (short && !truncatedByPageCap && !totalDrift) {
307
+ // A short later page with no failure, cap, or drift to explain it: the server
308
+ // simply delivered fewer rows than `total` promised. Warn so an interactive run
309
+ // sees why the result is partial (the other causes each warn on their own path).
310
+ process.stderr.write(`[gangtise] warning: server returned ${collected.length} of ${total} rows (a later page came back short); results may be incomplete\n`);
311
+ }
295
312
  return out;
296
313
  }
297
314
  async login() {
@@ -337,9 +354,22 @@ export class GangtiseClient {
337
354
  // response reads as text directly (and keeps existing behavior on that path).
338
355
  const encoding = response.headers['content-encoding'];
339
356
  const gzipped = (Array.isArray(encoding) ? encoding[0] : encoding)?.toLowerCase().trim() === 'gzip';
340
- const text = gzipped
341
- ? decodeResponseBody(new Uint8Array(await response.body.arrayBuffer()), encoding)
342
- : await response.body.text();
357
+ let text;
358
+ if (gzipped) {
359
+ // A proxy/middlebox can declare gzip and deliver garbage — surface that as
360
+ // an ApiError with request context instead of a bare zlib Z_DATA_ERROR.
361
+ const bytes = new Uint8Array(await response.body.arrayBuffer());
362
+ try {
363
+ text = decodeResponseBody(bytes, encoding);
364
+ }
365
+ catch (error) {
366
+ const detail = error instanceof Error ? error.message : String(error);
367
+ throw new ApiError(`Failed to decode gzip response for ${endpoint.method} ${endpoint.path}: ${detail}`, undefined, response.statusCode);
368
+ }
369
+ }
370
+ else {
371
+ text = await response.body.text();
372
+ }
343
373
  logTiming(`${endpoint.method} ${endpoint.path}`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
344
374
  // Parse Retry-After once so every error path below (JSON parse failure AND the
345
375
  // envelope/HTTP-error throw) carries it — a non-JSON 429/503 must still honor
@@ -368,6 +398,7 @@ export class GangtiseClient {
368
398
  throw error;
369
399
  }
370
400
  }, {
401
+ policy: endpoint.retry === "no-replay" ? "no-replay" : "default",
371
402
  onRetry: (attempt, error, delay) => {
372
403
  if (!isVerbose())
373
404
  return;
@@ -417,6 +448,13 @@ export class GangtiseClient {
417
448
  dispatcher,
418
449
  });
419
450
  }
451
+ // The loop above can exit with a 3xx still in hand (hop limit exceeded, or a
452
+ // redirect without Location) — that response must never be treated as file
453
+ // content: its HTML placeholder body would be saved as the "downloaded file".
454
+ if (response.statusCode >= 300 && response.statusCode < 400) {
455
+ await response.body.text().catch(() => { });
456
+ throw new ApiError(`Download failed: unresolved redirect (HTTP ${response.statusCode})`, undefined, response.statusCode);
457
+ }
420
458
  const contentType = Array.isArray(response.headers['content-type']) ? response.headers['content-type'][0] : response.headers['content-type'];
421
459
  // From the final (post-redirect) response, so a rate-limited download honors
422
460
  // Retry-After too — every error branch below passes it into the ApiError.
@@ -482,14 +520,17 @@ export class GangtiseClient {
482
520
  // Stream directly to disk when caller already knows the destination
483
521
  if (options?.streamTo) {
484
522
  await fs.mkdir(path.dirname(options.streamTo), { recursive: true });
523
+ // Stream into a .part sibling and rename over the target only on success:
524
+ // writing to the target directly would truncate an existing file on the
525
+ // FIRST byte and delete it on failure — a failed re-download (or each
526
+ // withRetry attempt) must never destroy the user's previous good file.
527
+ const partPath = `${options.streamTo}.part`;
485
528
  try {
486
- await pipeline(response.body, createWriteStream(options.streamTo));
529
+ await pipeline(response.body, createWriteStream(partPath));
530
+ await fs.rename(partPath, options.streamTo);
487
531
  }
488
532
  catch (error) {
489
- // A mid-stream failure leaves a truncated file on disk; remove it so a
490
- // failed download never looks like a complete one. withRetry may still
491
- // replay the request (the next attempt re-creates the file).
492
- await fs.unlink(options.streamTo).catch(() => { });
533
+ await fs.unlink(partPath).catch(() => { });
493
534
  throw error;
494
535
  }
495
536
  logTiming(`GET ${endpoint.path} (stream)`, Date.now() - startedAt, `${response.statusCode}`);
@@ -26,7 +26,9 @@ function truncateFilename(name, maxBytes = 200) {
26
26
  /** Pick a non-existing path by suffixing -1, -2, … before the extension, so batch
27
27
  * downloads whose titles collide ("2025年第一季度报告" from several companies) don't
28
28
  * silently overwrite each other. Only auto-derived names go through this — an
29
- * explicit --output path keeps plain overwrite semantics. */
29
+ * explicit --output path keeps plain overwrite semantics. Throws instead of
30
+ * falling back to the original path once the suffixes run out: returning `p`
31
+ * there would silently overwrite the very first file. */
30
32
  export async function uniquePath(p) {
31
33
  const exists = (f) => fs.access(f).then(() => true, () => false);
32
34
  if (!(await exists(p)))
@@ -38,7 +40,7 @@ export async function uniquePath(p) {
38
40
  if (!(await exists(candidate)))
39
41
  return candidate;
40
42
  }
41
- return p;
43
+ throw new DownloadError(`Refusing to overwrite: 100 files already share the name "${p}" — pass --output or clean up the directory`);
42
44
  }
43
45
  const MIME_EXT = {
44
46
  "application/pdf": ".pdf",
@@ -106,21 +108,70 @@ export async function resolveTitle(client, result, listEndpoint, idField, idValu
106
108
  }
107
109
  async function downloadUrlTo(url, outputPath) {
108
110
  const { createWriteStream } = await import("node:fs");
109
- const { Readable } = await import("node:stream");
110
111
  const { pipeline } = await import("node:stream/promises");
111
112
  const { dirname } = await import("node:path");
112
- const response = await fetch(url);
113
- if (!response.ok || !response.body) {
114
- throw new DownloadError(`Failed to fetch download URL (HTTP ${response.status})`);
115
- }
113
+ const { request } = await import("undici");
114
+ const { getDispatcher, logTiming, withRetry } = await import("./transport.js");
115
+ const { loadConfig } = await import("./config.js");
116
+ // Through the transport layer instead of a bare global fetch: the configured
117
+ // timeout applies (a slow-drip CDN can no longer hang the CLI indefinitely),
118
+ // network-level failures retry, and --verbose logs the request.
119
+ const timeoutMs = loadConfig().timeoutMs;
116
120
  await fs.mkdir(dirname(outputPath), { recursive: true });
117
- try {
118
- await pipeline(Readable.fromWeb(response.body), createWriteStream(outputPath));
119
- }
120
- catch (error) {
121
- await fs.unlink(outputPath).catch(() => { });
122
- throw error;
123
- }
121
+ // Signed URLs carry credentials in the query string — verbose logs must strip
122
+ // search/hash so signatures never land in terminal/CI logs.
123
+ const redactUrl = (u) => {
124
+ try {
125
+ const parsed = new URL(u);
126
+ return parsed.origin + parsed.pathname;
127
+ }
128
+ catch {
129
+ return "signed-url";
130
+ }
131
+ };
132
+ // .part + rename so a failed follow-download never destroys an existing file.
133
+ const partPath = `${outputPath}.part`;
134
+ await withRetry(async () => {
135
+ const startedAt = Date.now();
136
+ const requestOptions = {
137
+ method: "GET",
138
+ headersTimeout: timeoutMs,
139
+ bodyTimeout: timeoutMs,
140
+ dispatcher: getDispatcher(),
141
+ };
142
+ let currentUrl = url;
143
+ let response = await request(currentUrl, requestOptions);
144
+ // undici does not follow redirects (the old global fetch did): a signed URL
145
+ // may 302 to the real CDN object. Follow a bounded number of hops; no
146
+ // Authorization header is involved on this path, so nothing leaks cross-origin.
147
+ for (let hops = 0; hops < 3 && response.statusCode >= 300 && response.statusCode < 400; hops++) {
148
+ const locationHeader = response.headers.location;
149
+ const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader;
150
+ if (!location)
151
+ break;
152
+ await response.body.text().catch(() => { });
153
+ currentUrl = new URL(location, currentUrl).toString();
154
+ response = await request(currentUrl, requestOptions);
155
+ }
156
+ if (response.statusCode >= 300 && response.statusCode < 400) {
157
+ await response.body.text().catch(() => { });
158
+ throw new DownloadError(`Failed to fetch download URL: unresolved redirect (HTTP ${response.statusCode})`);
159
+ }
160
+ if (response.statusCode >= 400) {
161
+ await response.body.text().catch(() => { });
162
+ // DownloadError (not retried): signed URLs expire — replaying a 403 is useless.
163
+ throw new DownloadError(`Failed to fetch download URL (HTTP ${response.statusCode})`);
164
+ }
165
+ try {
166
+ await pipeline(response.body, createWriteStream(partPath));
167
+ }
168
+ catch (error) {
169
+ await fs.unlink(partPath).catch(() => { });
170
+ throw error;
171
+ }
172
+ logTiming(`GET ${redactUrl(currentUrl)}`, Date.now() - startedAt, String(response.statusCode));
173
+ });
174
+ await fs.rename(partPath, outputPath);
124
175
  }
125
176
  export async function saveDownloadResult(result, fallbackName, output) {
126
177
  if (!(result && typeof result === "object")) {
@@ -173,6 +173,26 @@ const ENDPOINT_DEFS = {
173
173
  kind: "download",
174
174
  description: "Download WeChat official account article (txt/HTML)",
175
175
  },
176
+ "insight.qa.list": {
177
+ method: "POST",
178
+ // The literal '&' is the vendor's path segment (Q&A-data), not a query separator.
179
+ path: "/application/open-insight/Q&A-data/getList",
180
+ kind: "json",
181
+ description: "List investor Q&A (conference/interactive/survey) for a security",
182
+ pagination: { enabled: true, maxPageSize: 500 },
183
+ },
184
+ "insight.report-image.list": {
185
+ method: "POST",
186
+ path: "/application/open-insight/report-image/getList",
187
+ kind: "json",
188
+ description: "Search research report images by keyword (returns chunkId + metadata)",
189
+ },
190
+ "insight.report-image.download": {
191
+ method: "GET",
192
+ path: "/application/open-insight/report-image/download/file",
193
+ kind: "download",
194
+ description: "Download a research report image by chunkId",
195
+ },
176
196
  // ─── reference ───
177
197
  "reference.securities-search": {
178
198
  method: "POST",
@@ -192,6 +212,12 @@ const ENDPOINT_DEFS = {
192
212
  kind: "json",
193
213
  description: "Search institution IDs by keyword (domestic broker / foreign / lead / opinion institution)",
194
214
  },
215
+ "reference.official-account-search": {
216
+ method: "POST",
217
+ path: "/application/open-reference/officialAccount/search",
218
+ kind: "json",
219
+ description: "Search official account (WeChat public account) IDs by name / institution / category",
220
+ },
195
221
  "reference.constant-category": {
196
222
  method: "GET",
197
223
  path: "/application/open-reference/constants/category",
@@ -368,6 +394,7 @@ const ENDPOINT_DEFS = {
368
394
  path: "/application/open-data/ai/search/knowledge/batch",
369
395
  kind: "json",
370
396
  description: "Batch knowledge search",
397
+ retry: "no-replay",
371
398
  },
372
399
  "ai.knowledge-resource.download": {
373
400
  method: "GET",
@@ -388,6 +415,7 @@ const ENDPOINT_DEFS = {
388
415
  kind: "json",
389
416
  description: "Generate one pager",
390
417
  timeoutMs: 120_000,
418
+ retry: "no-replay",
391
419
  },
392
420
  "ai.investment-logic": {
393
421
  method: "POST",
@@ -395,6 +423,7 @@ const ENDPOINT_DEFS = {
395
423
  kind: "json",
396
424
  description: "Generate investment logic",
397
425
  timeoutMs: 120_000,
426
+ retry: "no-replay",
398
427
  },
399
428
  "ai.peer-comparison": {
400
429
  method: "POST",
@@ -402,12 +431,14 @@ const ENDPOINT_DEFS = {
402
431
  kind: "json",
403
432
  description: "Generate peer comparison",
404
433
  timeoutMs: 120_000,
434
+ retry: "no-replay",
405
435
  },
406
436
  "ai.earnings-review.get-id": {
407
437
  method: "POST",
408
438
  path: "/application/open-ai/agent/earnings-review-getid",
409
439
  kind: "json",
410
440
  description: "Get earnings review ID",
441
+ retry: "no-replay",
411
442
  },
412
443
  "ai.earnings-review.get-content": {
413
444
  method: "POST",
@@ -421,6 +452,7 @@ const ENDPOINT_DEFS = {
421
452
  kind: "json",
422
453
  description: "Get theme tracking daily report",
423
454
  timeoutMs: 120_000,
455
+ retry: "no-replay",
424
456
  },
425
457
  "ai.research-outline": {
426
458
  method: "POST",
@@ -428,6 +460,7 @@ const ENDPOINT_DEFS = {
428
460
  kind: "json",
429
461
  description: "Get company research outline",
430
462
  timeoutMs: 120_000,
463
+ retry: "no-replay",
431
464
  },
432
465
  "ai.hot-topic": {
433
466
  method: "POST",
@@ -435,6 +468,7 @@ const ENDPOINT_DEFS = {
435
468
  kind: "json",
436
469
  description: "List hot topic reports",
437
470
  pagination: { enabled: true, maxPageSize: 20 },
471
+ retry: "no-replay",
438
472
  },
439
473
  "ai.management-discuss-announcement": {
440
474
  method: "POST",
@@ -442,6 +476,7 @@ const ENDPOINT_DEFS = {
442
476
  kind: "json",
443
477
  description: "Management discussion from financial reports (half-year/annual)",
444
478
  timeoutMs: 120_000,
479
+ retry: "no-replay",
445
480
  },
446
481
  "ai.management-discuss-earnings-call": {
447
482
  method: "POST",
@@ -449,12 +484,14 @@ const ENDPOINT_DEFS = {
449
484
  kind: "json",
450
485
  description: "Management discussion from earnings calls",
451
486
  timeoutMs: 120_000,
487
+ retry: "no-replay",
452
488
  },
453
489
  "ai.viewpoint-debate.get-id": {
454
490
  method: "POST",
455
491
  path: "/application/open-ai/agent/viewpoint-debate-getid",
456
492
  kind: "json",
457
493
  description: "Get viewpoint debate ID",
494
+ retry: "no-replay",
458
495
  },
459
496
  "ai.viewpoint-debate.get-content": {
460
497
  method: "POST",
@@ -547,12 +584,14 @@ const ENDPOINT_DEFS = {
547
584
  path: "/application/open-alternative/concept/info",
548
585
  kind: "json",
549
586
  description: "Query latest concept (theme index) profile by conceptId",
587
+ retry: "no-replay",
550
588
  },
551
589
  "alternative.concept-securities": {
552
590
  method: "POST",
553
591
  path: "/application/open-alternative/concept/securities",
554
592
  kind: "json",
555
593
  description: "Query concept (theme index) constituent securities, grouped",
594
+ retry: "no-replay",
556
595
  },
557
596
  // ─── indicator (EDE: security-level data indicators) ───
558
597
  "indicator.search": {
@@ -16,6 +16,7 @@ const ERROR_HINTS = {
16
16
  "999995": "当前账号积分不足。",
17
17
  "900002": "请求缺少 uid。",
18
18
  "900001": "请求参数为空或缺少必填项。",
19
+ "100003": "参数值非法——服务端不会指明是哪个参数,多为枚举参数拼写或取值超范围(如 --source / --question-category / --answer-important),对照命令 --help 列出的合法值检查。",
19
20
  "0000001008": "Token 已失效(多为他处登录挤掉本会话);有 AK/SK 时 CLI 会自动重新登录重试一次,否则请重新登录。",
20
21
  "8000014": "GANGTISE_ACCESS_KEY 错误。",
21
22
  "8000015": "GANGTISE_SECRET_KEY 错误。",
@@ -147,7 +147,10 @@ export async function streamOutputToFile(value, format, outputPath) {
147
147
  const { dirname } = await import("node:path");
148
148
  const { createWriteStream } = await import("node:fs");
149
149
  await fs.mkdir(dirname(outputPath), { recursive: true });
150
- const stream = createWriteStream(outputPath, { encoding: "utf8" });
150
+ // Stream into a .part sibling and rename over the target only on success, so a
151
+ // failed re-export never destroys the previous good file.
152
+ const partPath = `${outputPath}.part`;
153
+ const stream = createWriteStream(partPath, { encoding: "utf8" });
151
154
  // A stream 'error' with no listener (EACCES on open, ENOSPC mid-write) crashes the
152
155
  // process before any write callback fires. Swallow the event here — the failure
153
156
  // still surfaces through the write/end callbacks below.
@@ -169,11 +172,20 @@ export async function streamOutputToFile(value, format, outputPath) {
169
172
  await new Promise((resolve, reject) => {
170
173
  stream.end((err) => err ? reject(err) : resolve());
171
174
  });
175
+ await fs.rename(partPath, outputPath);
172
176
  }
173
177
  catch (error) {
174
178
  // Mirror the download path: never leave a truncated file that looks complete.
179
+ // createWriteStream opens lazily — an early abort can reach unlink BEFORE the
180
+ // open() creates the file, so wait for 'close' (fd released or open aborted)
181
+ // or the .part would reappear right after being "removed".
175
182
  stream.destroy();
176
- await fs.unlink(outputPath).catch(() => { });
183
+ // Already-closed happens when the failure was the rename (stream ended fine);
184
+ // then 'close' has fired and waiting for it again would hang forever.
185
+ if (!stream.closed) {
186
+ await new Promise((resolve) => stream.once("close", resolve));
187
+ }
188
+ await fs.unlink(partPath).catch(() => { });
177
189
  throw error;
178
190
  }
179
191
  return true;
@@ -222,9 +234,20 @@ export async function saveOutputIfNeeded(content, outputPath) {
222
234
  }
223
235
  const { dirname } = await import("node:path");
224
236
  await fs.mkdir(dirname(outputPath), { recursive: true });
225
- if (typeof content === "string") {
226
- await fs.writeFile(outputPath, content, "utf8");
227
- return;
237
+ // Write to a .part sibling and rename over the target only on success, so a
238
+ // failed re-export/download never destroys the previous good file.
239
+ const partPath = `${outputPath}.part`;
240
+ try {
241
+ if (typeof content === "string") {
242
+ await fs.writeFile(partPath, content, "utf8");
243
+ }
244
+ else {
245
+ await fs.writeFile(partPath, content);
246
+ }
247
+ await fs.rename(partPath, outputPath);
248
+ }
249
+ catch (error) {
250
+ await fs.unlink(partPath).catch(() => { });
251
+ throw error;
228
252
  }
229
- await fs.writeFile(outputPath, content);
230
253
  }
@@ -121,13 +121,31 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
121
121
  // every shard on the first rejection.
122
122
  const failedShards = [];
123
123
  let firstError = null;
124
+ let aborted = false;
124
125
  const results = await runWithConcurrency(shards, config.concurrency ?? PAGE_CONCURRENCY, async (shard) => {
126
+ // A prior shard hit a hard error (rate limit, no-perm, retries exhausted). Stop
127
+ // dispatching the rest rather than burning quota into the same failure; record them
128
+ // as failed so the merged result is flagged partial. Mirrors requestPaginated.
129
+ if (aborted) {
130
+ failedShards.push(shard);
131
+ return null;
132
+ }
125
133
  try {
126
- return await client.call(endpointKey, { ...allMarketBody, startDate: shard.startDate, endDate: shard.endDate });
134
+ const res = await client.call(endpointKey, { ...allMarketBody, startDate: shard.startDate, endDate: shard.endDate });
135
+ // A shard that resolves but carries no `list` array is shape-broken (an error
136
+ // object, a truncated envelope) — its rows are missing. Treat it exactly like a
137
+ // thrown shard so the result is marked partial, not silently short. Unlike a hard
138
+ // error this doesn't abort the fan-out: one malformed shard isn't systemic.
139
+ if (!(res && typeof res === "object" && Array.isArray(res.list))) {
140
+ failedShards.push(shard);
141
+ return null;
142
+ }
143
+ return res;
127
144
  }
128
145
  catch (error) {
129
146
  if (!firstError)
130
147
  firstError = error;
148
+ aborted = true;
131
149
  failedShards.push(shard);
132
150
  return null;
133
151
  }
@@ -65,13 +65,20 @@ export async function runWithConcurrency(items, concurrency, fn) {
65
65
  /** Fan-out width for pagination and kline shards — one env knob tunes both. */
66
66
  export const PAGE_CONCURRENCY = Number(process.env.GANGTISE_PAGE_CONCURRENCY ?? 5) || 5;
67
67
  const RETRYABLE_HTTP_STATUS = new Set([429, 500, 502, 503, 504]);
68
- const RETRYABLE_NETWORK_CODES = new Set(["ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_SOCKET", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
68
+ const RETRYABLE_NETWORK_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
69
69
  const RETRYABLE_API_CODES = new Set(["999999"]);
70
- function isRetryableError(error) {
70
+ // Connect-phase / DNS failures: the request provably never reached the server, so a
71
+ // replay cannot double-execute (or double-bill) anything even under "no-replay".
72
+ const NO_REPLAY_NETWORK_CODES = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT"]);
73
+ function isRetryableError(error, policy) {
71
74
  if (error && typeof error === "object" && error.__retryable === true) {
72
75
  return true;
73
76
  }
74
77
  if (error instanceof ApiError) {
78
+ if (error.statusCode === 429)
79
+ return true;
80
+ if (policy === "no-replay")
81
+ return false;
75
82
  if (error.statusCode != null && RETRYABLE_HTTP_STATUS.has(error.statusCode))
76
83
  return true;
77
84
  if (error.code && RETRYABLE_API_CODES.has(error.code))
@@ -80,9 +87,11 @@ function isRetryableError(error) {
80
87
  }
81
88
  if (error && typeof error === "object" && "code" in error) {
82
89
  const code = String(error.code);
83
- if (RETRYABLE_NETWORK_CODES.has(code))
90
+ if ((policy === "no-replay" ? NO_REPLAY_NETWORK_CODES : RETRYABLE_NETWORK_CODES).has(code))
84
91
  return true;
85
92
  }
93
+ if (policy === "no-replay")
94
+ return false;
86
95
  if (error instanceof Error && /timeout|ETIMEDOUT|ECONNRESET|socket hang up/i.test(error.message)) {
87
96
  return true;
88
97
  }
@@ -101,7 +110,7 @@ export async function withRetry(fn, options = {}) {
101
110
  return await fn();
102
111
  }
103
112
  catch (error) {
104
- if (attempt >= retries || !isRetryableError(error))
113
+ if (attempt >= retries || !isRetryableError(error, options.policy ?? "default"))
105
114
  throw error;
106
115
  // A server-sent Retry-After (429) wins over exponential backoff, but is capped
107
116
  // so it can't stall the CLI; otherwise fall back to jittered exponential backoff.
@@ -1,2 +1,2 @@
1
1
  // Auto-generated — DO NOT EDIT
2
- export const CLI_VERSION = "0.24.0";
2
+ export const CLI_VERSION = "0.26.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gangtise-openapi-cli",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "CLI for Gangtise OpenAPI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,6 +23,7 @@
23
23
  "README.md"
24
24
  ],
25
25
  "scripts": {
26
+ "prebuild": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
26
27
  "build": "tsc -p tsconfig.json",
27
28
  "dev": "tsx src/cli.ts",
28
29
  "test": "vitest run",
@@ -30,11 +31,11 @@
30
31
  "prepare": "node scripts/prepare.cjs && npm run build"
31
32
  },
32
33
  "engines": {
33
- "node": ">=20"
34
+ "node": ">=20.18.1"
34
35
  },
35
36
  "dependencies": {
36
37
  "commander": "^14.0.0",
37
- "undici": "^7.16.0"
38
+ "undici": "^7.28.0"
38
39
  },
39
40
  "devDependencies": {
40
41
  "@types/node": "^20.19.43",