gangtise-openapi-cli 0.23.0 → 0.25.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,42 @@
4
4
 
5
5
  ## Changelog
6
6
 
7
+ ### v0.25.0 — 2026-07-10
8
+
9
+ **新增接口(4)**
10
+ - `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 积分/条
11
+ - `insight report-image list` / `download` — 研报图表:按关键词搜索研报图片,返回 `chunkId` + 元数据(`--keyword` 必填、`--top` 默认 10 上限 20、`--source-id`、时间过滤;**免费**),再 `download --chunk-id` 下二进制原图(JPEG,0.1 积分/张)
12
+ - `reference official-account-search` — 公众号 ID 搜索:输入公众号名 / 机构 / 关键字返回 `accountId`(喂 `insight official-account list --account-id`);`--keyword`(必填)、`--category`(`listedCompany`/`broker`/`government`/`media`,可重复;未分类公众号 `category` 为 `null`,传 `--category` 会漏掉)、`--top`(默认 10 上限 10);免费
13
+
14
+ **变更**
15
+ - `indicator search` / `cross-section` / `time-series` 市场范围从仅 A 股扩展至 A 股 / 港股 / 美股(服务端变更;CLI 早已支持 `--currency` 与多市场证券代码,无需改动)。⚠️ 美股代码用交易所后缀 `.O`(NASDAQ) / `.N`(NYSE),**非 `.US`**——官方示例的 `AAPL.US` 查不到数据,实测须 `AAPL.O`
16
+
17
+ **修复 / 加固**(承接上一批未单独发版的改动)
18
+ - 分页 / 分片 `partial` 检测补全:`requestPaginated` 的短后续页、`MAX_PAGES` 上限、`total` 漂移、失败页四种场景统一触发 `partial`(退出码 3)——失败页独立成判定条件,避免超额返回的兄弟页把行数补满、掩盖失败页空洞;`quote` 全市场分片硬错后熔断、破损形状分片计入 `failedShards`
19
+ - `--top` 本地上限校验(`report-image` / `knowledge-batch` ≤20,reference 六个搜索命令 ≤10)——实测服务端对超限值**静默截断**不报错,现在发请求前本地报错;`securities-search` / `institution-search` / `official-account-search` 的 `--category` 加本地白名单——实测服务端对拼错的分类**不报错**(securities-search 静默忽略过滤返回全类别、另两个静默返回空),拼写错误不再伪装成"无结果"(`insight qa` 的枚举服务端会报 `100003`,故不做本地白名单)
20
+ - 错误码 `100003`(参数值非法)补充中文提示——服务端不指明是哪个参数,提示对照命令 `--help` 检查枚举参数拼写
21
+ - undici `^7.16.0` → `^7.28.0`(修 keep-alive 队列污染 GHSA-35p6-xmwp-9g52),`engines.node` `>=20` → `>=20.18.1` 对齐 undici 实际最低要求
22
+
23
+ ### v0.24.0 — 2026-07-07
24
+
25
+ **新增**
26
+ - `raw list` — 列出所有已注册的 endpoint key(含 method / path / description),配合 `raw call <key>` 使用,不必再翻文档记 key;支持 `--format`(默认 table)/ `--output`
27
+ - AI 同步生成端点内置 120s 超时下限(`one-pager` / `investment-logic` / `peer-comparison` / `theme-tracking` / `research-outline` / `management-discuss-announcement` / `management-discuss-earnings-call`)——生成耗时长不再撞 30s 默认超时触发重试,**不必再手动前缀 `GANGTISE_TIMEOUT_MS`**;显式设更大值仍生效(取 max)
28
+ - 429 响应尊重 `Retry-After`(秒或 HTTP-date;覆盖 JSON、非 JSON、下载三类错误路径),优先于默认指数退避,封顶 60s 防挂死
29
+ - 超大结果(≥5 万行且走非流式渲染:table/json/markdown,或 jsonl/csv 未带 `--output`)在 stderr 提示改用 `--format jsonl --output <path>` 流式落盘
30
+
31
+ **性能**
32
+ - JSON 请求启用 gzip(`accept-encoding: gzip` + 本地解压)——实测 `reference constant-list` 2110B→586B(3.6x),K 线类高重复大 JSON 收益更高;下载二进制路径不变
33
+ - 全市场按日分片(`quote fund-flow` / `day-kline` / `day-kline-us`,均 1 天/片)自动跳过周六日(A/港/美股周末闭市必空),省 ~28% 请求与每日调用配额;多日分片(`day-kline-hk` 2 天、`index-day-kline` 30 天)不受影响
34
+
35
+ **修复**
36
+ - 表格(table/markdown)显示宽度纳入 emoji 码位区(0x1F000–0x1FAFF),含 emoji 的微信群名/消息不再错位
37
+ - `fundamental earning-forecast` 默认 `--end-date`("today")改用运行机器本地日期;此前用 UTC 日期,CST 凌晨 0–8 点会算成"昨天"
38
+
39
+ **文档 / 工程**(不影响已发布 CLI 行为)
40
+ - `insight announcement`(A 股公告)时间过滤时区说明:`--start-time`/`--end-time` 按运行机器时区换算,跨机器精确边界改传 13 位毫秒时间戳
41
+ - CI 测试矩阵增加 Node 24(此前仅 20;发布用 24)
42
+
7
43
  ### v0.23.0 — 2026-07-05
8
44
 
9
45
  **行为变更(注意)**
@@ -49,82 +85,6 @@
49
85
  - `gangtise ... | head` 遇 stdout `EPIPE` 时安静退出;只有首个参数是 `--version` / `-V` 时才触发版本快捷路径
50
86
  - Endpoint registry 的 `key` 改为由记录键自动派生,减少映射漂移;新增真实 CLI 选项到请求体的 stub 测试;测试 272 → 323
51
87
 
52
- ### v0.21.0 — 2026-06-29
53
-
54
- **行为变更(注意)**
55
- - ⚠️ `vault wechat-chatroom-list` 省略 `--size` 现在**拉全量**(此前默认只返回 20 条)。该接口不返回 `total`,CLI 改为串行翻页(翻到不满页为止,单页上限 50);传 `--size N` 仍只取前 N 条。依赖"默认 20 条"的脚本会拿到全部群。
56
-
57
- **修复**
58
- - `quote day-kline --security all` 等大结果集用默认 `table` 格式输出时不再因 `Math.max(...大数组)` 撑爆调用栈崩溃(`RangeError`);`renderTable` 改用 reduce 计算列宽
59
- - CSV 导出:含回车符 `\r` 的字段现在正确加引号(RFC 4180);`table` / `markdown` 的多行字段折叠换行,保持表格对齐
60
- - 下载文件名剥离控制字符 / NUL,避免 `fs.writeFile` 报错
61
-
62
- **修复(安全)**
63
- - token 缓存文件(`~/.config/gangtise/token.json`)改为临时文件 + 原子 `rename` 写入:从第一字节即 `0600`,消除"旧文件宽松权限残留"与"崩溃截断"两个隐患
64
-
65
- **内部 / 工程**
66
- - 依赖 `vitest` 升级到 3.2.6(修复 dev-only 安全告警);新增 `npm run typecheck`;测试 257 → 272
67
-
68
- ### v0.20.0 — 2026-06-26
69
-
70
- **新增接口**
71
- - `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
72
- - `ai stock-summary` — 个股看点(精炼投研总结):`--security` 传具体代码(A股/港股,可重复,单次最多 6000)或市场关键词 `aShares` / `hkStocks` 拉全市场;无看点的证券不返回、不扣分
73
- - `fundamental income-statement-us` / `balance-sheet-us` / `cash-flow-us` — 美股三大财务报表(参数同其他财报:`--security-code` / `--period` / `--report-type` / `--fiscal-year` / `--field` 等)
74
- - `reference chiefs-search` — 首席分析师 ID 搜索(`--keyword` 按姓名/机构/团队匹配,`--top` 默认 10);用于 `insight opinion list --chief` 的入参
75
-
76
- **变更**
77
- - `insight announcement-hk download` 新增 `--file-type`(`1` 原始(默认)/ `2` Markdown),此前无格式选项
78
-
79
- **行为变更(注意)**
80
- - ⚠️ `auth login` / `auth status` 默认脱敏 access token:`--format json` 输出里 `authorization` 与 `cache.accessToken` 显示为 `<redacted>`,仅保留过期时间 / 用户名 / 产品码 / uid 等非敏感字段。**依赖 `auth login` 原始 token 输出的脚本会拿到 `<redacted>`**,需改用 `auth login --show-token` 获取明文。
81
-
82
- **修复(安全)**
83
- - `auth status` / `auth login` token 脱敏:按凭证字段名模式匹配(`token`/`key`/`secret`/`password`/`credential`),覆盖 `apiKey`/`privateKey`/`refreshToken` 等任何可能携带的凭证字段
84
- - 自愈守卫:同时设 `GANGTISE_TOKEN` + AK/SK 时,注入 token 失效后重新登录不再被旧 token 短路,重试改用登录拿到的新 token
85
-
86
- **修复(数据正确性 / 健壮性)**
87
- - ⚠️ **CSV 负数不再被破坏**(影响所有 CSV 导出):此前防公式注入会把负数(如跌幅 `-3.5`)加 `'` 前缀变成文本,Excel/pandas 无法参与计算;现仅对非有限数字的可疑串(`=`/`@`/`-1+cmd` 等)加前缀,合法数字原样输出
88
- - 自动翻页改为 fail-soft:某页遇不可重试错误(限流 `903301` 等)不再丢弃已取的全部数据,返回已取页 + `partial` / `failedPages` 标记,并在首错后停止继续请求(避免撞限流多烧配额)
89
- - 下载文件名 fallback(服务端 `Content-Disposition`)补清洗:含 `/`、`:` 等字符的文件名不再写到意外路径
90
- - `ai stock-summary` / `ai knowledge-batch` 缺 `--security` / `--query` 时本地报错,不再发空请求(stock-summary 借此避免被后台当全市场误扣积分)
91
- - `ai hot-topic` `--no-with-related-securities` / `--no-with-close-reading` 改为显式发 `false`(语义更明确,不依赖"字段缺失=排除"的隐含约定)
92
-
93
- **修复(indicator 适配 EDE 后台新结构)**
94
- - `indicator cross-section` / `time-series` 适配后台改版的返回结构(字段名加 `List` 后缀 `securityCodeList/indicatorCodeList/…`、截面 `values` 改二维 `[指标][证券]`):此前后台改结构后 CLI 拍平失配、退化成原始矩阵,现恢复 `{date, security, name, 指标:值}` 宽表。配合后台同步变化——无数据从 `999999` 报错改为返回 `null`(截面不再 500、不丢行),缺必填参数从笼统 `410106` 改为直接指明缺哪个参数
95
-
96
- ### v0.19.0 — 2026-06-24
97
-
98
- **新增接口(Indicator · 证券级数据指标 EDE)**
99
- - `indicator search` — 按名称搜索证券级数据指标,返回 `indicatorCode` 及可传参数 `parameterList`(含 `required` 必填标记与枚举);取数前必先 search 拿 code,绝不猜编码
100
- - `indicator cross-section` — 指标截面数据(多指标 × 多证券,单日快照):`--indicator` / `--security`(均可重复)/ `--date` / `--currency` / `--scale` / `--indicator-param`
101
- - `indicator time-series` — 指标时间序列(多指标 × 单证券 或 单指标 × 多证券,按区间):另有 `--start-date` / `--end-date` / `--calendar-type`(`ND`/`TD`/`WD`)
102
- - 复权等指标专属参数用 `--indicator-param "code:key=value"`,参数 key 与取值以 search 的 `parameterList` 为准(行情复权键为 `adjustmentType`:`1` 不复权 / `2` 前复权 / `3` 后复权)
103
- - 很多指标有必填参数,默认调用会报 `410106`(缺必填参数):N 期统计补 `periodNum`、区间/周期类补 `startDate`、年度/分红类补 `fiscalYear`;`999999` 多为「该证券公司类型/报告期无数据」而非系统故障。详见 `gangtise-openapi/references/commands/indicator.md`
104
-
105
- **修复**
106
- - `vault stock-pool-stocks --pool-id <id>` 过滤失效:此前因选项默认值 `["all"]` 泄漏,传具体 pool id 仍返回全部股票池证券;现已修复——传 id 精确过滤,省略则默认全量
107
- - `auth` 缺凭证报错补充跨 shell(bash/zsh/fish)的 `export` 提示
108
-
109
- **文档**
110
- - README / SKILL 补充 indicator 命令组与取数最佳实践;`official-account` 命令文档补全
111
-
112
- ### v0.18.0 — 2026-06-17
113
-
114
- **新增接口(Insight · 产业公众号资讯)**
115
- - `insight official-account list` — 查询公众号资讯列表:支持 `--keyword`(需用数据中的具体词,非整句白话)/ `--account-id`(公众号 ID)/ `--security` / `--category`(文章类型枚举:`news`/`law`/`report`/`view`/`data`/`event`/`meeting`/`notice`/`recruit`/`investEdu`/`brand`/`notes`/`other`)/ `--industry`(`citicIndustry`/`swIndustry` 行业 ID)/ `--search-type`(`1` 标题 / `2` 全文)/ `--rank-type`(`1` 综合 / `2` 时间倒序);返回含模型生成摘要 `summary` 及关联行业/题材/证券列表
116
- - `insight official-account download --article-id <id>` — 下载公众号文章:`--file-type 1` txt(默认)/ `2` HTML
117
-
118
- ### v0.17.0 — 2026-06-15
119
-
120
- **接口变更(Breaking)**
121
- - 日程类命令(`roadshow` / `site-visit` / `strategy` / `forum` list)改为各自只暴露 API spec 支持的筛选选项,移除原先一刀切多出的无效选项:`strategy` 仅保留 `--institution` / `--location`;`forum` 仅保留 `--research-area` / `--location`;`site-visit` 移除 `--participant-role` / `--broker-type`;`roadshow` 移除 `--object`。传不支持的选项现由 commander 直接报 `unknown option`(此前会静默发送、服务端返回空结果)
122
- - `insight announcement list` 移除无效的 `--announcement-type`(服务端忽略、恒返全量);A 股公告分类筛选用 `--category`(`aShareAnnouncementCategory` 常量 ID)
123
-
124
- **说明 / 修正**
125
- - `--industry` 用 `citicIndustry` 码(`1008001xx`,全命令通用);`--research-area` 用 `gangtiseIndustry` 码(行业 `1008001xx` + 宏观/策略/固收/金工/海外等方向 `122000xxx`)。详见 `gangtise-openapi/references/commands/reference-and-lookup.md`
126
- - 日程类 `--location`(domesticCity)服务端过滤已生效(v0.16.0 时曾未生效)
127
-
128
88
  > 更早版本及完整更新历史见 [CHANGELOG.md](CHANGELOG.md)。
129
89
 
130
90
  ## 首次安装
@@ -254,9 +214,12 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
254
214
  | | `foreign-opinion list` | 外资机构观点 |
255
215
  | | `independent-opinion list` / `download` | 外资独立分析师观点(含原文/翻译HTML下载) |
256
216
  | | `official-account list` / `download` | 产业公众号资讯(含 txt/HTML 下载) |
217
+ | | `qa list` | 投资者问答 QA(互动平台/电话会议/调研纪要,按证券) |
218
+ | | `report-image list` / `download` | 研报图表搜索(按关键词,含原图 JPEG 下载) |
257
219
  | **Reference** | `securities-search` | GTS Code 搜索(按名称/代码/拼音匹配) |
258
220
  | | `chiefs-search` | 首席分析师 ID 搜索(按姓名/机构/团队匹配) |
259
221
  | | `institution-search` | 机构 ID 搜索(内资券商/外资/牵头/观点机构,按名称匹配) |
222
+ | | `official-account-search` | 公众号 ID 搜索(按公众号名/机构/分类匹配,返回 accountId) |
260
223
  | | `constant-category` | 常量分类列表(含各分类适用的接口与参数) |
261
224
  | | `constant-list` | 按分类导出常量值全量列表(行业/城市/公告分类/区域等) |
262
225
  | | `concept-search` | 题材 ID 搜索(名称/拼音/分组名匹配) |
@@ -315,7 +278,7 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
315
278
  - `gangtise indicator ...`
316
279
  - `gangtise alternative ...`
317
280
  - `gangtise reference ...`
318
- - `gangtise raw call ...`
281
+ - `gangtise raw call ...` / `gangtise raw list`
319
282
 
320
283
  ## 推荐工作流
321
284
 
@@ -350,7 +313,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
350
313
  - **流式输出**:`jsonl`/`csv` 格式且 `--output` 指定时,超过 1000 行自动切换为逐行写盘,避免一次性构建百 MB 字符串。
351
314
  - **自动重试**:5xx / 429 / `ECONNRESET` / `ETIMEDOUT` / `ENOTFOUND` / `EAI_AGAIN` / `UND_ERR_*`(undici 超时类)/ `999999` 系统错误自动指数退避重试 2 次。
352
315
  - **Token 自愈**:调用返回 8000014/8000015 时自动强制刷新 Token 并重试一次。
353
- - **K线自动分片**:`quote day-kline --security all` 等全市场查询自动按日期切分(A股 1 天/片、美股 1 天/片、HK 2 天/片、指数 30 天/片),并发执行后合并结果。分片时如果用户未传 `--limit`,自动注入 `limit: 10000`(API 上限)避免默认 6000 截断。
316
+ - **K线/资金流向自动分片**:`quote day-kline --security all`、`quote fund-flow --security aShares` 等全市场查询自动按日期切分(A股 K线/资金流向 1 天/片、美股 1 天/片、HK 2 天/片、指数 30 天/片),并发执行后合并结果;按日分片自动跳过周六日。分片时如果用户未传 `--limit`,自动注入 `limit: 10000`(API 上限)避免默认 6000 截断。
354
317
  - **Token 内存缓存**:Token 在进程内存中缓存,避免每次请求读盘。
355
318
  - **`--verbose`**:打印每个请求的方法、路径、状态码、耗时和响应大小到 stderr,方便定位慢查询。
356
319
 
@@ -371,6 +334,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
371
334
  - `insight foreign-opinion list`
372
335
  - `insight independent-opinion list`
373
336
  - `insight official-account list`
337
+ - `insight qa list`
374
338
  - `ai security-clue`
375
339
  - `vault drive-list`
376
340
  - `vault record-list`
@@ -459,6 +423,12 @@ gangtise insight independent-opinion download --independent-opinion-id 207051900
459
423
  gangtise insight official-account list --keyword 泡泡玛特 --rank-type 2 --size 20 --format json
460
424
  gangtise insight official-account download --article-id 7286248 --file-type 2
461
425
 
426
+ # 投资者问答 QA(按证券;--source/--question-category/--answer-important 精筛,自动翻页)
427
+ gangtise insight qa list --security-code 601012.SH --source interactive --answer-important 1 --size 20 --format json
428
+ # 研报图表:按关键词搜图拿 chunkId,再下原图(JPEG)
429
+ gangtise insight report-image list --keyword AI --top 5 --format json
430
+ gangtise insight report-image download --chunk-id image_10_384655917758685184_8 --output ./ai-chart.jpg
431
+
462
432
  # 纪要下载(会议平台来源可选 HTML 格式)
463
433
  gangtise insight summary download --summary-id 4906813 --file-type 2
464
434
  ```
@@ -475,8 +445,10 @@ gangtise reference securities-search --keyword "银行" --category stock --categ
475
445
  # 首席分析师 ID 搜索(按姓名/机构/团队;拿 chiefId 供 insight opinion list --chief 使用)
476
446
  gangtise reference chiefs-search --keyword 东吴证券 --top 3 --format json
477
447
  gangtise reference chiefs-search --keyword 芦哲 --format json
478
- # 机构 ID 搜索(--category: domesticBroker/foreignInstitution/leadInstitution/opinionInstitution)
448
+ # 机构 ID 搜索(--category: domesticBroker/foreignInstitution/leadInstitution/opinionInstitution/foreignOpinionInstitution
479
449
  gangtise reference institution-search --keyword 招商证券 --category domesticBroker --top 3 --format json
450
+ # 公众号 ID 搜索(按名称/机构/分类;拿 accountId 供 insight official-account list --account-id)
451
+ gangtise reference official-account-search --keyword 中信证券 --top 3 --format json
480
452
 
481
453
  # 常量查询:先看分类,再按分类导出全量常量值
482
454
  gangtise reference constant-category --format json
@@ -696,6 +668,10 @@ gangtise alternative concept-securities --concept-id 121000130 --format json
696
668
  ### Raw
697
669
 
698
670
  ```bash
671
+ # 先列出所有 endpoint key(配合 raw call,不必翻文档记 key)
672
+ gangtise raw list
673
+ gangtise raw list --format json # key / method / path / description
674
+
699
675
  gangtise raw call insight.opinion.list --body '{"from":0,"size":120}'
700
676
  ```
701
677
 
package/dist/src/cli.js CHANGED
@@ -2,13 +2,13 @@
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, 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";
9
9
  import { loadConfig } from "./core/config.js";
10
10
  import { resolveTitle, saveDownloadResult, uniquePath } from "./core/download.js";
11
- import { ENDPOINTS } from "./core/endpoints.js";
11
+ import { ENDPOINTS, listEndpoints } from "./core/endpoints.js";
12
12
  import { ApiError, ConfigError, ValidationError } from "./core/errors.js";
13
13
  import { normalizeRows } from "./core/normalize.js";
14
14
  import { parseOutputFormat } from "./core/output.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)
@@ -428,7 +445,7 @@ fundamental.command("valuation-analysis").requiredOption("--security-code <code>
428
445
  }));
429
446
  fundamental.command("top-holders").requiredOption("--security-code <code>").addOption(new Option("--holder-type <type>", "Holder type: top10/top10Float").choices(["top10", "top10Float"]).makeOptionMandatory()).option("--start-date <date>").option("--end-date <date>").option("--fiscal-year <year>", "Fiscal year", collectList, []).option("--period <period>", "Period: q1/interim/q3/annual/latest", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("fundamental.top-holders", { securityCode: options.securityCode, holderType: options.holderType, startDate: options.startDate, endDate: options.endDate, fiscalYear: maybeArray(options.fiscalYear), period: options.period.length ? options.period : undefined })));
430
447
  fundamental.command("earning-forecast").requiredOption("--security-code <code>").option("--start-date <date>", "Start date (default: 1 year before end-date)").option("--end-date <date>", "End date (default: today)").option("--consensus <name>", "Consensus indicator: netIncome/netIncomeYoy/eps/pe/bps/pb/peg/roe/ps", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => {
431
- const endDate = options.endDate ?? new Date().toISOString().slice(0, 10);
448
+ const endDate = options.endDate ?? localDateString(new Date());
432
449
  // Anchor the default window to endDate (as the help text promises), not to today —
433
450
  // a historical --end-date without --start-date should mean "the year before it".
434
451
  const startDate = options.startDate ?? new Date(new Date(`${endDate}T00:00:00Z`).getTime() - 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
@@ -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");
@@ -653,7 +676,7 @@ program.command("raw").description("Raw API calls").addCommand(new Command("call
653
676
  }
654
677
  const data = await client.call(endpointKey, body);
655
678
  await printData(data, format, options.output);
656
- }));
679
+ })).addCommand(new Command("list").description("List all registered endpoint keys (for use with 'raw call')").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => printData(listEndpoints(), parseOutputFormat(options.format), options.output)));
657
680
  async function checkForUpdate(timeoutMs = 2000) {
658
681
  try {
659
682
  const response = await fetch("https://registry.npmjs.org/gangtise-openapi-cli/latest", { signal: AbortSignal.timeout(timeoutMs) });
@@ -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;
@@ -94,6 +105,15 @@ export function parseTimestamp13(value, optionName) {
94
105
  }
95
106
  return parsed;
96
107
  }
108
+ /** Machine-local calendar date as `yyyy-MM-dd`, for CLI "default: today" options.
109
+ * `new Date().toISOString().slice(0,10)` renders the UTC day — for CST users a
110
+ * pre-08:00 "today" resolves to yesterday. Anchoring to local components matches
111
+ * toTimestamp13's local-midnight convention. */
112
+ export function localDateString(d) {
113
+ const month = String(d.getMonth() + 1).padStart(2, "0");
114
+ const day = String(d.getDate()).padStart(2, "0");
115
+ return `${d.getFullYear()}-${month}-${day}`;
116
+ }
97
117
  // Parse repeatable `--indicator-param "code:key=value"` specs into the nested
98
118
  // indicatorParamList the EDE cross-section / time-series endpoints expect.
99
119
  // Multiple specs for the same code accumulate into one group, first-seen order.
@@ -5,9 +5,9 @@ import { pipeline } from "node:stream/promises";
5
5
  import { request } from "undici";
6
6
  import { isTokenCacheValid, normalizeToken, readTokenCache, requireAccessCredentials, writeTokenCache } from "./auth.js";
7
7
  import { ApiError, ValidationError } from "./errors.js";
8
- import { ENDPOINTS } from "./endpoints.js";
8
+ import { ENDPOINTS, resolveTimeoutMs } from "./endpoints.js";
9
9
  import { getLookupData } from "./lookupData/index.js";
10
- import { getDispatcher, isVerbose, logTiming, markRetryable, PAGE_CONCURRENCY, runWithConcurrency, withRetry } from "./transport.js";
10
+ import { decodeResponseBody, getDispatcher, isVerbose, logTiming, markRetryable, PAGE_CONCURRENCY, parseRetryAfterMs, runWithConcurrency, withRetry } from "./transport.js";
11
11
  // Auth errors that warrant a forced re-login + one replay. 8000014/8000015 are
12
12
  // AK/SK errors; 0000001008 is a server-side token invalidation (the token still
13
13
  // looks valid by local expiry, so only a forced refresh recovers it).
@@ -119,12 +119,12 @@ export class GangtiseClient {
119
119
  return false;
120
120
  return 'msg' in obj || 'data' in obj || 'success' in obj || 'status' in obj;
121
121
  }
122
- throwHttpError(parsed, statusCode) {
122
+ throwHttpError(parsed, statusCode, retryAfterMs) {
123
123
  if (this.isEnvelope(parsed)) {
124
124
  const code = parsed.code === undefined ? undefined : String(parsed.code);
125
- throw new ApiError(parsed.msg || `API request failed (HTTP ${statusCode})`, code, statusCode, parsed);
125
+ throw new ApiError(parsed.msg || `API request failed (HTTP ${statusCode})`, code, statusCode, parsed, retryAfterMs);
126
126
  }
127
- throw new ApiError(`API request failed (HTTP ${statusCode})`, undefined, statusCode, parsed);
127
+ throw new ApiError(`API request failed (HTTP ${statusCode})`, undefined, statusCode, parsed, retryAfterMs);
128
128
  }
129
129
  unwrapEnvelope(parsed, statusCode) {
130
130
  if (!this.isEnvelope(parsed)) {
@@ -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() {
@@ -309,9 +326,13 @@ export class GangtiseClient {
309
326
  const dispatcher = getDispatcher();
310
327
  const url = this.buildUrl(endpoint.path);
311
328
  const authState = { retried: false };
329
+ const timeoutMs = resolveTimeoutMs(this.config.timeoutMs, endpoint);
312
330
  return withRetry(async () => {
313
331
  const headers = {
314
332
  'content-type': 'application/json',
333
+ // undici does not auto-decompress; decodeResponseBody gunzips below. Server
334
+ // gzip cuts JSON payloads ~3-10x (measured 3.6x on constant-list).
335
+ 'accept-encoding': 'gzip',
315
336
  };
316
337
  // Keep the header we actually sent: the self-heal check compares it against
317
338
  // the current memoCache token to tell "stale token" from "fresh token died".
@@ -325,12 +346,22 @@ export class GangtiseClient {
325
346
  method: endpoint.method,
326
347
  headers,
327
348
  body: endpoint.method === 'GET' ? undefined : JSON.stringify(body ?? {}),
328
- headersTimeout: this.config.timeoutMs,
329
- bodyTimeout: this.config.timeoutMs,
349
+ headersTimeout: timeoutMs,
350
+ bodyTimeout: timeoutMs,
330
351
  dispatcher,
331
352
  });
332
- const text = await response.body.text();
353
+ // Only buffer + gunzip when the server actually compressed; an unencoded
354
+ // response reads as text directly (and keeps existing behavior on that path).
355
+ const encoding = response.headers['content-encoding'];
356
+ const gzipped = (Array.isArray(encoding) ? encoding[0] : encoding)?.toLowerCase().trim() === 'gzip';
357
+ const text = gzipped
358
+ ? decodeResponseBody(new Uint8Array(await response.body.arrayBuffer()), encoding)
359
+ : await response.body.text();
333
360
  logTiming(`${endpoint.method} ${endpoint.path}`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
361
+ // Parse Retry-After once so every error path below (JSON parse failure AND the
362
+ // envelope/HTTP-error throw) carries it — a non-JSON 429/503 must still honor
363
+ // the server's rate window instead of falling back to default backoff.
364
+ const retryAfterMs = parseRetryAfterMs(response.headers['retry-after'], Date.now());
334
365
  let parsed;
335
366
  try {
336
367
  parsed = JSON.parse(text);
@@ -339,13 +370,13 @@ export class GangtiseClient {
339
370
  const message = response.statusCode >= 400
340
371
  ? `API request failed (HTTP ${response.statusCode})`
341
372
  : 'Failed to parse API response';
342
- throw new ApiError(message, undefined, response.statusCode, text.slice(0, 500));
373
+ throw new ApiError(message, undefined, response.statusCode, text.slice(0, 500), retryAfterMs);
343
374
  }
344
375
  try {
345
376
  // Auth errors can arrive as HTTP 4xx or as a 200-wrapped error envelope;
346
377
  // both routes must reach the self-heal check below.
347
378
  if (response.statusCode >= 400) {
348
- this.throwHttpError(parsed, response.statusCode);
379
+ this.throwHttpError(parsed, response.statusCode, retryAfterMs);
349
380
  }
350
381
  return this.unwrapEnvelope(parsed, response.statusCode);
351
382
  }
@@ -404,6 +435,9 @@ export class GangtiseClient {
404
435
  });
405
436
  }
406
437
  const contentType = Array.isArray(response.headers['content-type']) ? response.headers['content-type'][0] : response.headers['content-type'];
438
+ // From the final (post-redirect) response, so a rate-limited download honors
439
+ // Retry-After too — every error branch below passes it into the ApiError.
440
+ const retryAfterMs = parseRetryAfterMs(response.headers['retry-after'], Date.now());
407
441
  if (contentType?.includes('application/json')) {
408
442
  const text = await response.body.text();
409
443
  logTiming(`GET ${endpoint.path} (json)`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
@@ -413,14 +447,14 @@ export class GangtiseClient {
413
447
  }
414
448
  catch {
415
449
  if (response.statusCode >= 400) {
416
- throw new ApiError('Download failed', undefined, response.statusCode, text);
450
+ throw new ApiError('Download failed', undefined, response.statusCode, text, retryAfterMs);
417
451
  }
418
452
  return { text, contentType };
419
453
  }
420
454
  let data;
421
455
  try {
422
456
  if (response.statusCode >= 400) {
423
- this.throwHttpError(parsed, response.statusCode);
457
+ this.throwHttpError(parsed, response.statusCode, retryAfterMs);
424
458
  }
425
459
  data = this.unwrapEnvelope(parsed, response.statusCode);
426
460
  }
@@ -437,13 +471,13 @@ export class GangtiseClient {
437
471
  const text = await response.body.text();
438
472
  logTiming(`GET ${endpoint.path} (text)`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
439
473
  if (response.statusCode >= 400) {
440
- throw new ApiError('Download failed', undefined, response.statusCode, text);
474
+ throw new ApiError('Download failed', undefined, response.statusCode, text, retryAfterMs);
441
475
  }
442
476
  return { text, contentType };
443
477
  }
444
478
  if (response.statusCode >= 400) {
445
479
  const text = await response.body.text();
446
- throw new ApiError('Download failed', undefined, response.statusCode, text);
480
+ throw new ApiError('Download failed', undefined, response.statusCode, text, retryAfterMs);
447
481
  }
448
482
  const contentDisposition = response.headers['content-disposition'];
449
483
  const filenameMatch = Array.isArray(contentDisposition)
@@ -1,3 +1,8 @@
1
+ /** Effective request timeout: the endpoint's floor, or the config timeout if higher
2
+ * (a user who raised GANGTISE_TIMEOUT_MS keeps their value). */
3
+ export function resolveTimeoutMs(configTimeoutMs, endpoint) {
4
+ return Math.max(configTimeoutMs, endpoint.timeoutMs ?? 0);
5
+ }
1
6
  // Registry entries omit `key`: it is derived from the record key when ENDPOINTS is
2
7
  // built below, so the two can never drift.
3
8
  const ENDPOINT_DEFS = {
@@ -168,6 +173,26 @@ const ENDPOINT_DEFS = {
168
173
  kind: "download",
169
174
  description: "Download WeChat official account article (txt/HTML)",
170
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
+ },
171
196
  // ─── reference ───
172
197
  "reference.securities-search": {
173
198
  method: "POST",
@@ -187,6 +212,12 @@ const ENDPOINT_DEFS = {
187
212
  kind: "json",
188
213
  description: "Search institution IDs by keyword (domestic broker / foreign / lead / opinion institution)",
189
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
+ },
190
221
  "reference.constant-category": {
191
222
  method: "GET",
192
223
  path: "/application/open-reference/constants/category",
@@ -382,18 +413,21 @@ const ENDPOINT_DEFS = {
382
413
  path: "/application/open-ai/agent/one-pager",
383
414
  kind: "json",
384
415
  description: "Generate one pager",
416
+ timeoutMs: 120_000,
385
417
  },
386
418
  "ai.investment-logic": {
387
419
  method: "POST",
388
420
  path: "/application/open-ai/agent/investment-logic",
389
421
  kind: "json",
390
422
  description: "Generate investment logic",
423
+ timeoutMs: 120_000,
391
424
  },
392
425
  "ai.peer-comparison": {
393
426
  method: "POST",
394
427
  path: "/application/open-ai/agent/peer-comparison",
395
428
  kind: "json",
396
429
  description: "Generate peer comparison",
430
+ timeoutMs: 120_000,
397
431
  },
398
432
  "ai.earnings-review.get-id": {
399
433
  method: "POST",
@@ -412,12 +446,14 @@ const ENDPOINT_DEFS = {
412
446
  path: "/application/open-ai/agent/theme-tracking",
413
447
  kind: "json",
414
448
  description: "Get theme tracking daily report",
449
+ timeoutMs: 120_000,
415
450
  },
416
451
  "ai.research-outline": {
417
452
  method: "POST",
418
453
  path: "/application/open-ai/agent/research-outline",
419
454
  kind: "json",
420
455
  description: "Get company research outline",
456
+ timeoutMs: 120_000,
421
457
  },
422
458
  "ai.hot-topic": {
423
459
  method: "POST",
@@ -431,12 +467,14 @@ const ENDPOINT_DEFS = {
431
467
  path: "/application/open-ai/management-discuss/from-announcement",
432
468
  kind: "json",
433
469
  description: "Management discussion from financial reports (half-year/annual)",
470
+ timeoutMs: 120_000,
434
471
  },
435
472
  "ai.management-discuss-earnings-call": {
436
473
  method: "POST",
437
474
  path: "/application/open-ai/management-discuss/from-earningsCall",
438
475
  kind: "json",
439
476
  description: "Management discussion from earnings calls",
477
+ timeoutMs: 120_000,
440
478
  },
441
479
  "ai.viewpoint-debate.get-id": {
442
480
  method: "POST",
@@ -563,3 +601,8 @@ const ENDPOINT_DEFS = {
563
601
  },
564
602
  };
565
603
  export const ENDPOINTS = Object.fromEntries(Object.entries(ENDPOINT_DEFS).map(([key, def]) => [key, { key, ...def }]));
604
+ /** Flat catalog of every registered endpoint, for `raw list` discoverability
605
+ * (so `raw call` doesn't require memorizing endpoint keys). */
606
+ export function listEndpoints() {
607
+ return Object.values(ENDPOINTS).map(({ key, method, path, description }) => ({ key, method, path, description }));
608
+ }
@@ -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 错误。",
@@ -34,12 +35,17 @@ export class ApiError extends CliError {
34
35
  code;
35
36
  statusCode;
36
37
  details;
38
+ retryAfterMs;
37
39
  hint;
38
- constructor(message, code, statusCode, details) {
40
+ constructor(message, code, statusCode, details,
41
+ /** Server-specified Retry-After (ms), set on 429 responses so the transport
42
+ * backoff can honor it instead of the default exponential schedule. */
43
+ retryAfterMs) {
39
44
  super(message);
40
45
  this.code = code;
41
46
  this.statusCode = statusCode;
42
47
  this.details = details;
48
+ this.retryAfterMs = retryAfterMs;
43
49
  this.hint = code ? ERROR_HINTS[code] : undefined;
44
50
  }
45
51
  }
@@ -14,8 +14,9 @@ export function parseOutputFormat(value) {
14
14
  function sanitizeCell(value) {
15
15
  return value.replace(/[\r\n]+/g, " ").replace(/[\u0000-\u001f\u007f]/g, "");
16
16
  }
17
- /** Terminal display width: CJK/fullwidth chars occupy 2 columns — padEnd counts
18
- * UTF-16 code units and misaligns every table containing Chinese text. */
17
+ /** Terminal display width: CJK/fullwidth chars and emoji occupy 2 columns — padEnd
18
+ * counts UTF-16 code units and misaligns every table containing Chinese text or emoji
19
+ * (e.g. WeChat group names). */
19
20
  function displayWidth(value) {
20
21
  let width = 0;
21
22
  for (const ch of value) {
@@ -23,7 +24,8 @@ function displayWidth(value) {
23
24
  const wide = (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf)
24
25
  || (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff)
25
26
  || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60)
26
- || (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x20000 && cp <= 0x3fffd);
27
+ || (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x1f000 && cp <= 0x1faff)
28
+ || (cp >= 0x20000 && cp <= 0x3fffd);
27
29
  width += wide ? 2 : 1;
28
30
  }
29
31
  return width;
@@ -1,6 +1,18 @@
1
1
  import { normalizeRows } from "./normalize.js";
2
2
  import { pickList, renderOutput, saveOutputIfNeeded, streamOutputToFile } from "./output.js";
3
3
  import { extractTitles, writeTitleCache } from "./titleCache.js";
4
+ /** Rows above which renderOutput's single in-memory string risks high memory / the V8
5
+ * max-string-length cap. Well above normal result sizes, so it only fires on huge exports. */
6
+ const LARGE_RESULT_ROWS = 50_000;
7
+ /** Warn when we're about to renderOutput a huge result. Called only on the paths that
8
+ * actually render — never after streamOutputToFile streamed — so it can't misfire on a
9
+ * genuinely streamed export, and it DOES fire on the all-scalar-csv list that streaming
10
+ * declines (which then falls back to a full in-memory string). */
11
+ function warnIfLargeInMemory(items, format) {
12
+ if (items && items.length >= LARGE_RESULT_ROWS) {
13
+ process.stderr.write(`[gangtise] note: ${items.length} rows in '${format}' is built entirely in memory; stream large exports to a file with --format jsonl --output <path> (or csv).\n`);
14
+ }
15
+ }
4
16
  export async function printData(data, format, output, cache) {
5
17
  const normalized = normalizeRows(data);
6
18
  const items = pickList(normalized);
@@ -28,6 +40,9 @@ export async function printData(data, format, output, cache) {
28
40
  process.stdout.write(`${output}\n`);
29
41
  return;
30
42
  }
43
+ // streamOutputToFile declined (non-stream format, or an all-scalar csv list) \u2192 we
44
+ // fall back to renderOutput, which builds the whole result as one string.
45
+ warnIfLargeInMemory(items, format);
31
46
  const content = renderOutput(normalized, format);
32
47
  // CSV files get a BOM so Excel double-click decodes Chinese as UTF-8 (stdout
33
48
  // stays BOM-free for pipes).
@@ -35,5 +50,6 @@ export async function printData(data, format, output, cache) {
35
50
  process.stdout.write(`${output}\n`);
36
51
  return;
37
52
  }
53
+ warnIfLargeInMemory(items, format);
38
54
  process.stdout.write(`${renderOutput(normalized, format)}\n`);
39
55
  }
@@ -27,16 +27,28 @@ export function isFullMarket(body, fullMarketValue) {
27
27
  export function isAllMarket(body) {
28
28
  return isFullMarket(body, "all");
29
29
  }
30
+ /** Sat/Sun in UTC — shard dates are formatted from UTC midnight (see parseDate). */
31
+ function isWeekendUtc(d) {
32
+ const day = d.getUTCDay();
33
+ return day === 0 || day === 6;
34
+ }
30
35
  function buildShards(start, end, shardDays) {
31
36
  const shards = [];
32
37
  let cursor = start.getTime();
33
38
  const endTime = end.getTime();
34
39
  while (cursor <= endTime) {
35
40
  const shardEnd = Math.min(cursor + (shardDays - 1) * DAY_MS, endTime);
36
- shards.push({
37
- startDate: formatDate(new Date(cursor)),
38
- endDate: formatDate(new Date(shardEnd)),
39
- });
41
+ // Per-day sharding (shardDays===1): a lone weekend day always returns empty (A/HK/US
42
+ // markets closed) — skip it to save ~28% of requests and daily quota. This covers
43
+ // every 1-day-sharded full-market query: fund-flow AND day-kline / day-kline-us
44
+ // (both shardDays 1 in cli.ts). Multi-day shards (day-kline-hk=2, index=30) always
45
+ // straddle weekdays, so they're never dropped.
46
+ if (!(shardDays === 1 && isWeekendUtc(new Date(cursor)))) {
47
+ shards.push({
48
+ startDate: formatDate(new Date(cursor)),
49
+ endDate: formatDate(new Date(shardEnd)),
50
+ });
51
+ }
40
52
  cursor = shardEnd + DAY_MS;
41
53
  }
42
54
  return shards;
@@ -93,6 +105,11 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
93
105
  return callSingle();
94
106
  }
95
107
  const shards = buildShards(start, end, config.shardDays);
108
+ // A range that's entirely weekends (per-day sharding) filters down to nothing — no
109
+ // trading days to fetch. Return empty rather than falling into the all-shards-failed
110
+ // check below, which would misread 0 shards as a total outage and throw.
111
+ if (shards.length === 0)
112
+ return { list: [] };
96
113
  // isVerbose() (not a direct env read) so the global --verbose flag reaches
97
114
  // shard logging too — cli.ts enables it via setVerbose in a preAction hook.
98
115
  if (isVerbose()) {
@@ -104,13 +121,31 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
104
121
  // every shard on the first rejection.
105
122
  const failedShards = [];
106
123
  let firstError = null;
124
+ let aborted = false;
107
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
+ }
108
133
  try {
109
- 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;
110
144
  }
111
145
  catch (error) {
112
146
  if (!firstError)
113
147
  firstError = error;
148
+ aborted = true;
114
149
  failedShards.push(shard);
115
150
  return null;
116
151
  }
@@ -1,5 +1,38 @@
1
+ import { gunzipSync } from "node:zlib";
1
2
  import { Agent } from "undici";
2
3
  import { ApiError } from "./errors.js";
4
+ /** Decode an HTTP response body honoring Content-Encoding. undici does not
5
+ * auto-decompress, so requestJson advertises `accept-encoding: gzip` and gunzips
6
+ * here; an unencoded body is read as-is. */
7
+ export function decodeResponseBody(buf, contentEncoding) {
8
+ const enc = (Array.isArray(contentEncoding) ? contentEncoding[0] : contentEncoding)?.toLowerCase().trim();
9
+ const b = Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
10
+ return enc === "gzip" ? gunzipSync(b).toString("utf8") : b.toString("utf8");
11
+ }
12
+ /** A Retry-After delay we'll honor even if it exceeds maxDelay — but never past this
13
+ * ceiling, so a hostile/misconfigured header can't hang the CLI for minutes. */
14
+ const RETRY_AFTER_CEILING_MS = 60_000;
15
+ /** Parse a Retry-After header (delta-seconds or an HTTP-date) into a delay in ms.
16
+ * Returns undefined when absent or unparseable. `nowMs` is injected for testing. */
17
+ export function parseRetryAfterMs(value, nowMs) {
18
+ const raw = (Array.isArray(value) ? value[0] : value)?.trim();
19
+ if (!raw)
20
+ return undefined;
21
+ if (/^\d+$/.test(raw))
22
+ return Number(raw) * 1000;
23
+ const dateMs = Date.parse(raw);
24
+ return Number.isNaN(dateMs) ? undefined : Math.max(0, dateMs - nowMs);
25
+ }
26
+ /** A retryable error may carry a server-specified Retry-After (attached by the
27
+ * client on a 429); prefer it over the computed backoff. */
28
+ function retryAfterFromError(error) {
29
+ if (error && typeof error === "object") {
30
+ const v = error.retryAfterMs;
31
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0)
32
+ return v;
33
+ }
34
+ return undefined;
35
+ }
3
36
  let cachedDispatcher = null;
4
37
  export function getDispatcher() {
5
38
  if (!cachedDispatcher) {
@@ -70,8 +103,12 @@ export async function withRetry(fn, options = {}) {
70
103
  catch (error) {
71
104
  if (attempt >= retries || !isRetryableError(error))
72
105
  throw error;
73
- const jitter = Math.random() * baseDelay;
74
- const delay = Math.min(maxDelay, baseDelay * 2 ** attempt + jitter);
106
+ // A server-sent Retry-After (429) wins over exponential backoff, but is capped
107
+ // so it can't stall the CLI; otherwise fall back to jittered exponential backoff.
108
+ const retryAfter = retryAfterFromError(error);
109
+ const delay = retryAfter !== undefined
110
+ ? Math.min(retryAfter, RETRY_AFTER_CEILING_MS)
111
+ : Math.min(maxDelay, baseDelay * 2 ** attempt + Math.random() * baseDelay);
75
112
  options.onRetry?.(attempt + 1, error, delay);
76
113
  await new Promise(resolve => setTimeout(resolve, delay));
77
114
  attempt++;
@@ -1,2 +1,2 @@
1
1
  // Auto-generated — DO NOT EDIT
2
- export const CLI_VERSION = "0.23.0";
2
+ export const CLI_VERSION = "0.25.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gangtise-openapi-cli",
3
- "version": "0.23.0",
3
+ "version": "0.25.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",