gangtise-openapi-cli 0.22.0 → 0.23.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,51 @@
4
4
 
5
5
  ## Changelog
6
6
 
7
+ ### v0.23.0 — 2026-07-05
8
+
9
+ **行为变更(注意)**
10
+ - ⚠️ 默认 API 域名迁移:`https://open.gangtise.com` → `https://openapi.gangtise.com`。旧域名仍可用,CLI 只是切换了默认值(新旧域名多接口实测等价);如需固定旧域名设 `GANGTISE_BASE_URL=https://open.gangtise.com`
11
+ - `vault wechat-chatroom-list`:服务端接口改版为返回 `{ total, list }`(此前无 `total`、列名 `chatRoomList`)。CLI 相应改为按 `total` 并发翻页(不再串行翻页);省略 `--size` 仍拉全量、传 `--size N` 取前 N 条,`Total:` 提示恢复
12
+ - 无翻页的行情端点(`quote fund-flow` / `minute-kline` / 显式多标的的日 K:`day-kline`·`-hk`·`-us`·`index-day-kline`)返回行数撞上单次 `--limit` 时标 `partial`(退出码 3)+ stderr 警告,避免被静默截断;`--limit` 现本地校验 ≤ 10000(撞服务端上限也不漏标)。K 线 `--security all` 仍走日期分片自动补全,不受影响
13
+
14
+ **新增**
15
+ - `quote fund-flow` — A股个股日资金流向(沪深京),含小/中/大/特大单流入流出金额及占比、主力净流入等字段;`--security`(或 `aShares` 全市场)、`--start-date` / `--end-date`、`--limit`(默认 6000,上限 10000)、`--field` 指定返回字段;无积分消耗(单只证券无翻页,撞 `--limit` 时的截断处理见上「行为变更」;**`aShares` 全市场须显式传 `--start-date`/`--end-date`,CLI 按日自动分片并发合并——缺日期会本地报错**)
16
+ - `reference institution-search` — 机构 ID 搜索,输入机构名/简称返回 `institutionId` 及适用接口参数(`usageScopes`);`--keyword`(必填)、`--category`(`domesticBroker`/`foreignInstitution`/`leadInstitution`/`opinionInstitution`/`foreignOpinionInstitution`,可重复)、`--top`(默认 10,上限 10);免费。覆盖既有 `--broker`/`--institution` 全部机构类(research/foreign-report/opinion/foreign-opinion/summary/roadshow/site-visit/strategy/my-conference)
17
+ - `vault my-conference-list` 新增 `--source` — 按录制来源筛选(`1`=企微会议助理 `2`=会议服务微信群,可重复;不传返回全部)
18
+
19
+ ### v0.22.1 — 2026-07-03
20
+
21
+ **修复**
22
+ - 错误码 `410004` 提示改为中性措辞「数据未找到或无指标权限,请检查查询条件与指标权限」——此前只说"数据未找到",与 `indicator` 内层信封的"无权限"消息拼接后自相矛盾
23
+
24
+ **文档 / Skill**(随 `/sync-skill` 分发,不影响 CLI 行为)
25
+ - gangtise-openapi Agent Skill 经 fable5 审计 + 多轮 review 优化:官方积分计费速查表 + 高积分 pre-flight 闸门、AI 同步生成命令 `GANGTISE_TIMEOUT_MS=120000` 超时前置、大结果集 `--output` 落盘、异步 `--wait` 主路径、行业码口径收敛到单一权威、市值量纲实测(`qte_mkt_cptl` 仅 A 股 / 默认原始「元」/ `scale`+`currency`)等文档补全与消歧
26
+
27
+ ### v0.22.0 — 2026-07-02
28
+
29
+ **行为变更(注意)**
30
+ - ⚠️ 自动翻页接口省略 `--size` 现在一律拉全量(不再区分是否传时间范围);需要只取前 N 条时请显式传 `--size N`。数据量未知时可先用 `--size 1` 从 stderr 的 `Total: N` 探明量级
31
+ - 部分结果可机器识别:翻页页失败、K 线分片失败、或服务端提前短页但仍报告更大 `total` 时,结果会带 `partial: true`(页失败另有 `failedPages`,分片为 `failedShards`),非 json 行式输出仍只输出数据行,但进程退出码为 3
32
+
33
+ **修复(鉴权 / 请求可靠性)**
34
+ - Token 自愈覆盖服务端 `0000001008` 踢线失效,并能处理 HTTP 4xx 错误信封;`GANGTISE_TOKEN` + AK/SK 场景下环境 token 失效后不再反复回放旧 token
35
+ - 并发请求同时遇到旧 token 失效时复用一次刷新结果;若刚拿到的新 token 本身被踢掉,则强制再次登录,避免"刚登录窗口期"误跳过刷新
36
+ - 自动重试范围扩展到 429、DNS/网络临时错误与 undici 超时类错误;`GANGTISE_BASE_URL` 带路径前缀时 URL 拼接不再丢前缀
37
+
38
+ **修复(下载 / 输出 / 数据正确性)**
39
+ - 下载接口跟随最多 3 次 30x 跳转;跨域跳到对象存储签名 URL 时不携带 Authorization;服务端返回 `{url}` 且用户传 `--output` 时会真正下载文件,而不是把 URL 字符串写进文件
40
+ - 自动文件名补齐清洗、截断与去重:服务端文件名、标题缓存名和 fallback 名都不会把 `/`、控制字符、过长中文名或重复标题变成路径/覆盖问题
41
+ - `table`/`markdown` 输出清理控制字符、正确按 CJK 宽字符对齐,并转义 markdown 表头中的 `|`;CSV 输出转义表头、文件输出带 UTF-8 BOM,流式 CSV 遇全标量列表时回退到正常渲染而不是只写 BOM
42
+ - `indicator search` / `cross-section` / `time-series` 的内层失败信封即使没有 `data` 字段也会抛出 `ApiError`,不再把"无权限/参数错误"渲染成成功结果
43
+ - `--indicator-param` 等逗号列表支持全角逗号 `,`;日期型时间参数按本地零点解析,避免 `yyyy-MM-dd` 被当作 UTC 造成查询窗口偏移
44
+ - `fundamental earning-forecast` 省略 `--start-date` 时按传入的 `--end-date` 往前一年计算,不再总是按今天往前一年
45
+ - AI 异步 `--wait` 对 `410111` 终态失败只提示"不要重试",超时才提示稍后用 check 命令查询;等待说明同步为最长约 5 分钟
46
+
47
+ **CLI / 工程**
48
+ - `raw call` 会在本地拒绝 JSON endpoint 的 `--query` 和 download endpoint 的 `--body`,避免静默丢参数;`--format` 在发请求前校验,格式拼错不再先消耗接口调用
49
+ - `gangtise ... | head` 遇 stdout `EPIPE` 时安静退出;只有首个参数是 `--version` / `-V` 时才触发版本快捷路径
50
+ - Endpoint registry 的 `key` 改为由记录键自动派生,减少映射漂移;新增真实 CLI 选项到请求体的 stub 测试;测试 272 → 323
51
+
7
52
  ### v0.21.0 — 2026-06-29
8
53
 
9
54
  **行为变更(注意)**
@@ -116,7 +161,7 @@ npm run dev -- --help
116
161
  ```bash
117
162
  export GANGTISE_ACCESS_KEY="your-ak"
118
163
  export GANGTISE_SECRET_KEY="your-sk"
119
- export GANGTISE_BASE_URL="https://open.gangtise.com"
164
+ export GANGTISE_BASE_URL="https://openapi.gangtise.com"
120
165
  export GANGTISE_TOKEN="Bearer xxx"
121
166
 
122
167
  # 性能/调试可选项
@@ -194,7 +239,7 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
194
239
  | 模块 | 子命令 | 说明 |
195
240
  |------|--------|------|
196
241
  | **Auth** | `login` / `status` | 认证登录、状态查询 |
197
- | **Lookup** | `broker-org list` / `meeting-org list` | 本地枚举表(API 未覆盖的部分;行业/区域/公告分类/题材/申万行业代码改用 Reference 接口) |
242
+ | **Lookup** | `broker-org list` / `meeting-org list` | 券商/会议机构本地全量枚举表(按名称找 ID 优先 `reference institution-search`;行业/区域/公告分类/题材/申万码已改用 Reference 接口) |
198
243
  | **Insight** | `opinion list` | 内资机构观点 |
199
244
  | | `summary list` / `download` | 纪要(含下载,支持 `--file-type` 选原始/HTML) |
200
245
  | | `roadshow list` | 路演 |
@@ -211,6 +256,7 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
211
256
  | | `official-account list` / `download` | 产业公众号资讯(含 txt/HTML 下载) |
212
257
  | **Reference** | `securities-search` | GTS Code 搜索(按名称/代码/拼音匹配) |
213
258
  | | `chiefs-search` | 首席分析师 ID 搜索(按姓名/机构/团队匹配) |
259
+ | | `institution-search` | 机构 ID 搜索(内资券商/外资/牵头/观点机构,按名称匹配) |
214
260
  | | `constant-category` | 常量分类列表(含各分类适用的接口与参数) |
215
261
  | | `constant-list` | 按分类导出常量值全量列表(行业/城市/公告分类/区域等) |
216
262
  | | `concept-search` | 题材 ID 搜索(名称/拼音/分组名匹配) |
@@ -220,6 +266,7 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
220
266
  | | `index-day-kline` | 沪深京指数日K线 |
221
267
  | | `minute-kline` | A股分钟K线 |
222
268
  | | `realtime` | 个股实时行情快照(A股/港股/美股) |
269
+ | | `fund-flow` | A股个股日资金流向(沪深京;小/中/大/特大单 + 主力净流入) |
223
270
  | **Fundamental** | `income-statement` / `balance-sheet` / `cash-flow` | A股三大财务报表(累计) |
224
271
  | | `income-statement-quarterly` / `cash-flow-quarterly` | A股利润表/现金流量表(单季度) |
225
272
  | | `income-statement-hk` / `balance-sheet-hk` / `cash-flow-hk` | 港股三大财务报表(中国会计准则) |
@@ -329,7 +376,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
329
376
  - `vault record-list`
330
377
  - `vault my-conference-list`
331
378
  - `vault wechat-message-list`
332
- - `vault wechat-chatroom-list`(特例:接口无 `total`,CLI 串行翻页)
379
+ - `vault wechat-chatroom-list`
333
380
  - `ai hot-topic`
334
381
 
335
382
  规则:
@@ -340,7 +387,6 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
340
387
  - 安全上限:自动翻页最多 1000 页,防止异常循环
341
388
  - 部分页失败、或服务端实际返回行数与 `total` 矛盾(提前短页)时,不丢弃已取到的数据:结果带 `partial: true`(页失败时另有 `failedPages`;K线分片为 `failedShards`;`--format json` 可见),stderr 输出警告,**进程退出码为 3**(完整成功为 0)
342
389
  - 分页结果中 `total` 字段会被保留(json 格式输出 `{total, list}`);其他格式下 stderr 输出 `Total: N, showing: M`(json 格式不输出该行)
343
- - `vault wechat-chatroom-list` 是特例:接口不返回 `total`,CLI 改为串行翻页——省略 `--size` 拉全量、传 `--size N` 取前 N 条,单页 50,无 `Total:` 提示
344
390
 
345
391
  ## 智能文件命名
346
392
 
@@ -429,6 +475,8 @@ gangtise reference securities-search --keyword "银行" --category stock --categ
429
475
  # 首席分析师 ID 搜索(按姓名/机构/团队;拿 chiefId 供 insight opinion list --chief 使用)
430
476
  gangtise reference chiefs-search --keyword 东吴证券 --top 3 --format json
431
477
  gangtise reference chiefs-search --keyword 芦哲 --format json
478
+ # 机构 ID 搜索(--category: domesticBroker/foreignInstitution/leadInstitution/opinionInstitution)
479
+ gangtise reference institution-search --keyword 招商证券 --category domesticBroker --top 3 --format json
432
480
 
433
481
  # 常量查询:先看分类,再按分类导出全量常量值
434
482
  gangtise reference constant-category --format json
@@ -469,6 +517,8 @@ gangtise quote minute-kline --security 600519.SH --start-time "2026-04-15 09:30:
469
517
  gangtise quote realtime --security 600519.SH --security 00700.HK --security AAPL.O --field securityCode --field tradeTime --field latestPrice --field pctChange --field volume --format json
470
518
  # 实时行情:全市场批量(建议配合 --field 精简字段)
471
519
  gangtise quote realtime --security aShares --field securityCode --field latestPrice --field pctChange --field volume --format json
520
+ # A股个股日资金流向(沪深京;--security aShares 全市场;--limit 上限 10000,超限缩短日期区间分批)
521
+ gangtise quote fund-flow --security 600519.SH --security 000001.SZ --start-date 2026-06-01 --end-date 2026-06-05 --field mainNetInflow --field largeInflow --field xlargeInflow --format json
472
522
  ```
473
523
 
474
524
  > **历史 vs 实时**:`day-kline*` 仅返回历史数据(当日数据入库时间:A 股 ~15:30 / 港股 ~16:30 / 美股 ~07:00 北京时间)。盘中需要最新成交价、振幅等实时字段必须走 `quote realtime`。
@@ -565,8 +615,9 @@ gangtise vault record-list --keyword 晨会 --category upload --category mobile
565
615
  # 录音速记下载(--content-type: original/asr/summary)
566
616
  gangtise vault record-download --record-id 49412 --content-type summary
567
617
 
568
- # 我的会议列表
618
+ # 我的会议列表(--source 录制来源:1=企微会议助理 2=会议服务微信群,可重复;不传返回全部)
569
619
  gangtise vault my-conference-list --keyword AI --category earningsCall --institution C100000027
620
+ gangtise vault my-conference-list --source 2 --category earningsCall --size 20
570
621
  # 我的会议下载(--content-type: asr/summary)
571
622
  gangtise vault my-conference-download --conference-id 43319 --content-type asr
572
623
 
package/dist/src/cli.js CHANGED
@@ -5,7 +5,7 @@ import { readTokenCache, redactTokenCache } from "./core/auth.js";
5
5
  import { collectKeyValue, collectList, collectNumberList, maybeArray, 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
- import { callKlineWithSharding } from "./core/quoteSharding.js";
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
11
  import { ENDPOINTS } from "./core/endpoints.js";
@@ -35,6 +35,33 @@ async function emit(options, produce, cache) {
35
35
  async function withClient(fn) {
36
36
  await fn(await createClient());
37
37
  }
38
+ /**
39
+ * Server-side default row cap shared by the limit-capped, non-paginated quote endpoints
40
+ * (fund-flow, minute-kline, day/index kline — all default to 6000 per the API docs). We
41
+ * send it EXPLICITLY when `--limit` is omitted (rather than letting the server apply its
42
+ * own default) so the request limit and the truncation `cap` below are always the same
43
+ * number — never a guess about the server's default that can drift out of sync.
44
+ */
45
+ const DEFAULT_QUOTE_LIMIT = 6000;
46
+ /**
47
+ * Limit-capped, non-paginated endpoints (fund-flow, kline) report `total` as the
48
+ * RETURNED row count, not the true total, so a full page (rows == the limit we sent) is
49
+ * the only truncation signal. Flag the result partial (printData → exit 3) + warn so a
50
+ * capped export isn't mistaken for the full set. `cap` MUST be the exact limit the caller
51
+ * sent on the request; `--limit` is validated to <= 10000 so `cap` can't exceed the
52
+ * server ceiling and hide a truncation.
53
+ */
54
+ function flagIfLimitTruncated(data, cap, label) {
55
+ if (!data || typeof data !== "object" || Array.isArray(data))
56
+ return;
57
+ const rec = data;
58
+ if (rec.partial === true)
59
+ return;
60
+ if (Array.isArray(rec.list) && rec.list.length >= cap) {
61
+ rec.partial = true;
62
+ process.stderr.write(`[gangtise] warning: ${label} returned ${rec.list.length} rows = the ${cap}-row limit; results are likely truncated (this endpoint has no pagination). Narrow --start-date/--end-date or raise --limit (max 10000), fetching in date batches.\n`);
63
+ }
64
+ }
38
65
  /**
39
66
  * Run a download. If `output` is set we already know the destination, so the
40
67
  * client streams the body straight to disk (no in-memory Uint8Array copy);
@@ -307,13 +334,63 @@ const addKlineCommand = (name, endpointKey, securityHelp, shardDays) => quote.co
307
334
  .option("--field <field>", "Field", collectList, [])
308
335
  .option("--format <format>", "Output format", "table")
309
336
  .option("--output <path>")
310
- .action((options) => emit(options, (client) => callKlineWithSharding(client, endpointKey, buildQuoteKlineBody(options), { shardDays })));
337
+ .action((options) => withClient(async (client) => {
338
+ const format = parseOutputFormat(options.format);
339
+ const body = buildQuoteKlineBody(options);
340
+ if (isAllMarket(body)) {
341
+ // `--security all` is date-sharded: callKlineWithSharding lifts the limit to the
342
+ // API max and owns completeness (partial / failedShards), so leave `limit` unset
343
+ // and skip the single-request truncation guard.
344
+ const data = await callKlineWithSharding(client, endpointKey, body, { shardDays });
345
+ await printData(data, format, options.output);
346
+ return;
347
+ }
348
+ // Explicit securities go out as one request: pin the limit to the known default so
349
+ // the sent limit and the truncation cap are the same number by construction.
350
+ const limit = body.limit ?? DEFAULT_QUOTE_LIMIT;
351
+ const data = await callKlineWithSharding(client, endpointKey, { ...body, limit }, { shardDays });
352
+ flagIfLimitTruncated(data, limit, name);
353
+ await printData(data, format, options.output);
354
+ }));
311
355
  addKlineCommand("day-kline", "quote.day-kline", "Security code (A-share: .SH/.SZ/.BJ, or 'all' for full market)", 1);
312
356
  addKlineCommand("day-kline-hk", "quote.day-kline-hk", "Security code (HK stock: .HK, or 'all' for full market)", 2);
313
357
  addKlineCommand("day-kline-us", "quote.day-kline-us", "Security code (US stock: e.g. AAPL.O, or 'all' for full market)", 1);
314
358
  addKlineCommand("index-day-kline", "quote.index-day-kline", "Index code (.SH/.SZ/.BJ, or 'all' for full market)", 30);
315
- quote.command("minute-kline").option("--security <code>", "Security code (A-share only: .SH/.SZ/.BJ)").option("--start-time <datetime>", "Start time (yyyy-MM-dd HH:mm:ss)").option("--end-time <datetime>", "End time (yyyy-MM-dd HH:mm:ss)").option("--limit <number>", "Max rows per request (default: 5000, max: 10000)").option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("quote.minute-kline", { securityCode: options.security, startTime: options.startTime, endTime: options.endTime, limit: parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1 }), fieldList: maybeArray(options.field) })));
359
+ quote.command("minute-kline").option("--security <code>", "Security code (A-share only: .SH/.SZ/.BJ)").option("--start-time <datetime>", "Start time (yyyy-MM-dd HH:mm:ss)").option("--end-time <datetime>", "End time (yyyy-MM-dd HH:mm:ss)").option("--limit <number>", "Max rows per request (default: 6000, max: 10000)").option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
360
+ const format = parseOutputFormat(options.format);
361
+ const limit = parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 10000 }) ?? DEFAULT_QUOTE_LIMIT;
362
+ const data = await client.call("quote.minute-kline", { securityCode: options.security, startTime: options.startTime, endTime: options.endTime, limit, fieldList: maybeArray(options.field) });
363
+ flagIfLimitTruncated(data, limit, "minute-kline");
364
+ await printData(data, format, options.output);
365
+ }));
316
366
  quote.command("realtime").description("Realtime quote snapshot (A-share / HK / US)").option("--security <code>", "Security code (e.g. 600519.SH / 00700.HK / AAPL.O), or market keyword: aShares / hkStocks / usStocks", collectList, []).option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("quote.realtime", { securityList: maybeArray(options.security), fieldList: maybeArray(options.field) })));
367
+ quote.command("fund-flow").description("A-share daily fund flow (SH/SZ/BJ)").option("--security <code>", "Security code (e.g. 600519.SH / 872931.BJ), or 'aShares' for full A-share market — auto-sharded by day (repeat)", collectList, []).option("--start-date <date>", "Start date yyyy-MM-dd (default: endDate minus 1 year)").option("--end-date <date>", "End date yyyy-MM-dd (default: latest trading day)").option("--limit <number>", "Max rows per request (default: 6000, max: 10000; single-security cap — aShares auto-shards by day)").option("--field <field>", "Field, e.g. mainNetInflow/largeInflow/xlargeOutflow (repeat); omit for all", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
368
+ const format = parseOutputFormat(options.format);
369
+ const body = {
370
+ securityList: maybeArray(options.security),
371
+ startDate: options.startDate,
372
+ endDate: options.endDate,
373
+ limit: parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 10000 }),
374
+ fieldList: maybeArray(options.field),
375
+ };
376
+ if (isFullMarket(body, "aShares")) {
377
+ // Full-market fund-flow: the server errors (430012/430013) instead of truncating when
378
+ // a single request exceeds the row cap, so date-shard by day (~5.4k A-share rows/day,
379
+ // under the lifted API cap) and merge — same mechanism as `--security all` kline.
380
+ // Sharding needs an explicit range; without both dates it would fall back to one
381
+ // doomed full-market request, so require the range up front with a clear message.
382
+ if (!body.startDate || !body.endDate) {
383
+ throw new ValidationError("quote fund-flow --security aShares requires both --start-date and --end-date (the full market is fetched via per-day shards)");
384
+ }
385
+ const data = await callKlineWithSharding(client, "quote.fund-flow", body, { shardDays: 1, fullMarketValue: "aShares" });
386
+ await printData(data, format, options.output);
387
+ return;
388
+ }
389
+ const limit = body.limit ?? DEFAULT_QUOTE_LIMIT;
390
+ const data = await client.call("quote.fund-flow", { ...body, limit });
391
+ flagIfLimitTruncated(data, limit, "fund-flow");
392
+ await printData(data, format, options.output);
393
+ }));
317
394
  program.addCommand(quote);
318
395
  const fundamental = new Command("fundamental").description("Fundamental APIs");
319
396
  const addFinancialReport = (name, endpointKey, periodHelp = "Period") => fundamental.command(name).requiredOption("--security-code <code>").option("--start-date <date>").option("--end-date <date>").option("--fiscal-year <year>", "Fiscal year", collectList, []).option("--period <period>", periodHelp, collectList, []).option("--report-type <type>", "Report type", collectList, []).option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call(endpointKey, { securityCode: options.securityCode, startDate: options.startDate, endDate: options.endDate, fiscalYear: maybeArray(options.fiscalYear), period: options.period.length ? options.period : undefined, reportType: options.reportType.length ? options.reportType : undefined, fieldList: maybeArray(options.field) })));
@@ -479,13 +556,18 @@ reference.command("chiefs-search").requiredOption("--keyword <text>", "Search ke
479
556
  keyword: options.keyword,
480
557
  top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
481
558
  })));
559
+ 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
+ keyword: options.keyword,
561
+ categoryList: maybeArray(options.category),
562
+ top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
563
+ })));
482
564
  program.addCommand(reference);
483
565
  const vault = new Command("vault").description("Vault APIs");
484
566
  vault.command("drive-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-time <datetime>").option("--end-time <datetime>").option("--keyword <text>").option("--file-type <number>", "File type", collectNumberList, []).option("--space-type <number>", "Space type", collectNumberList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.drive.list", { from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword, fileTypeList: options.fileType.length ? options.fileType : undefined, spaceTypeList: options.spaceType.length ? options.spaceType : undefined }), { endpointKey: "vault.drive.list", idField: "fileId" }));
485
567
  addDownloadCommand(vault, { endpointKey: "vault.drive.download", name: "drive-download", idOption: "--file-id", idField: "fileId", fallbackPrefix: "file", titleListEndpoint: "vault.drive.list" });
486
568
  vault.command("record-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-time <datetime>").option("--end-time <datetime>").option("--keyword <text>").option("--category <name>", "Recording type: upload/link/mobile/gtNote/pc/share", collectList, []).option("--space-type <number>", "Space type: 1=my records / 2=tenant records", collectNumberList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.record.list", { from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword, categoryList: maybeArray(options.category), spaceTypeList: options.spaceType.length ? options.spaceType : undefined }), { endpointKey: "vault.record.list", idField: "recordId" }));
487
569
  addDownloadCommand(vault, { endpointKey: "vault.record.download", name: "record-download", idOption: "--record-id", idField: "recordId", fallbackPrefix: "record", contentTypeDescription: "Content type: original/asr/summary", titleListEndpoint: "vault.record.list" });
488
- vault.command("my-conference-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-time <datetime>").option("--end-time <datetime>").option("--keyword <text>").option("--research-area <id>", "Research area ID", collectList, []).option("--security <code>", "Security code", collectList, []).option("--institution <id>", "Institution ID", collectList, []).option("--category <name>", "Conference category: earningsCall/strategyMeeting/fundRoadshow/shareholdersMeeting/maMeeting/specialMeeting/companyAnalysis/industryAnalysis/other", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.my-conference.list", { from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword, researchAreaList: maybeArray(options.researchArea), securityList: maybeArray(options.security), institutionList: maybeArray(options.institution), categoryList: maybeArray(options.category) }), { endpointKey: "vault.my-conference.list", idField: "conferenceId" }));
570
+ vault.command("my-conference-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-time <datetime>").option("--end-time <datetime>").option("--keyword <text>").option("--research-area <id>", "Research area ID", collectList, []).option("--security <code>", "Security code", collectList, []).option("--institution <id>", "Institution ID", collectList, []).option("--category <name>", "Conference category: earningsCall/strategyMeeting/fundRoadshow/shareholdersMeeting/maMeeting/specialMeeting/companyAnalysis/industryAnalysis/other", collectList, []).option("--source <number>", "Recording source: 1=企微会议助理 2=会议服务微信群 (repeat)", collectNumberList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.my-conference.list", { from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword, researchAreaList: maybeArray(options.researchArea), securityList: maybeArray(options.security), institutionList: maybeArray(options.institution), categoryList: maybeArray(options.category), sourceList: options.source.length ? options.source : undefined }), { endpointKey: "vault.my-conference.list", idField: "conferenceId" }));
489
571
  addDownloadCommand(vault, { endpointKey: "vault.my-conference.download", name: "my-conference-download", idOption: "--conference-id", idField: "conferenceId", fallbackPrefix: "conference", contentTypeDescription: "Content type: asr/summary", titleListEndpoint: "vault.my-conference.list" });
490
572
  vault.command("wechat-message-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-time <datetime>").option("--end-time <datetime>").option("--keyword <text>").option("--security <code>", "Security code (e.g. 000001.SZ)", collectList, []).option("--wechat-group-id <id>", "WeChat group ID", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--category <name>", "Message type: text/image/documents/url", collectList, []).option("--tag <name>", "Tag: roadShow/research/strategyMeeting/meetingSummary/industryComment/companyComment/earningsReview", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.wechat-message.list", buildWechatMessageListBody(options))));
491
573
  vault.command("wechat-chatroom-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--room-name <name>", "WeChat group name; repeat or comma-separate for multiple names", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.wechat-chatroom.list", buildWechatChatroomListBody(options))));
@@ -24,6 +24,9 @@ export function parseNumberOption(value, optionName, config = {}) {
24
24
  if (config.min !== undefined && parsed < config.min) {
25
25
  throw new ValidationError(`Invalid ${optionName}: expected a number >= ${config.min}`);
26
26
  }
27
+ if (config.max !== undefined && parsed > config.max) {
28
+ throw new ValidationError(`Invalid ${optionName}: expected a number <= ${config.max}`);
29
+ }
27
30
  return parsed;
28
31
  }
29
32
  export function parseOptionalNumberOption(value, optionName, config = {}) {
@@ -294,75 +294,6 @@ export class GangtiseClient {
294
294
  }
295
295
  return out;
296
296
  }
297
- /**
298
- * Sequential pagination for endpoints that page by offset but return NO `total`
299
- * and use a non-standard list key (e.g. wechat chatroom's `chatRoomList`). We
300
- * can't fan out like requestPaginated (no total ⇒ unknown page count), so page
301
- * serially until a short page (fewer rows than requested) signals the end.
302
- * Returns `{ [listKey]: rows }` so normalize/printer treat it like any list.
303
- */
304
- async requestSequentialPaginated(endpoint, body) {
305
- const initialBody = body && typeof body === 'object' ? { ...body } : {};
306
- if ('from' in initialBody && (typeof initialBody.from !== 'number' || !Number.isFinite(initialBody.from) || initialBody.from < 0)) {
307
- throw new ValidationError('Invalid from: expected a non-negative number');
308
- }
309
- if ('size' in initialBody && initialBody.size !== undefined && (typeof initialBody.size !== 'number' || !Number.isFinite(initialBody.size) || initialBody.size <= 0)) {
310
- throw new ValidationError('Invalid size: expected a positive number');
311
- }
312
- const listKey = endpoint.pagination?.listKey ?? 'list';
313
- const maxPageSize = endpoint.pagination?.maxPageSize ?? 50;
314
- const startFrom = typeof initialBody.from === 'number' && Number.isFinite(initialBody.from) ? initialBody.from : 0;
315
- const requestedSize = typeof initialBody.size === 'number' && Number.isFinite(initialBody.size) ? initialBody.size : undefined;
316
- const extractList = (page) => {
317
- if (!page || typeof page !== 'object')
318
- return null;
319
- const arr = page[listKey];
320
- return Array.isArray(arr) ? arr : null;
321
- };
322
- const collected = [];
323
- let firstPage = null;
324
- let from = startFrom;
325
- const MAX_PAGES = 1000;
326
- let truncatedByPageCap = false;
327
- let partialShape = false;
328
- for (let page = 0;; page++) {
329
- const remaining = requestedSize === undefined ? maxPageSize : requestedSize - collected.length;
330
- if (requestedSize !== undefined && remaining <= 0)
331
- break;
332
- const size = Math.min(maxPageSize, remaining);
333
- const pageData = await this.requestJson(endpoint, { ...initialBody, from, size });
334
- if (firstPage === null)
335
- firstPage = pageData;
336
- const list = extractList(pageData);
337
- if (list === null) {
338
- // First response isn't a paginated-list shape → return it untouched. But a
339
- // LATER page losing shape must NOT discard the rows already collected (mirrors
340
- // requestPaginated's fail-soft fan-out): stop, keep them, and warn loudly.
341
- if (page === 0)
342
- return firstPage;
343
- partialShape = true;
344
- process.stderr.write(`[gangtise] warning: a page response had unexpected shape; results are partial — ${collected.length} rows fetched.\n`);
345
- break;
346
- }
347
- for (const item of list)
348
- collected.push(item);
349
- if (list.length < size)
350
- break; // short page ⇒ no more rows
351
- if (page + 1 >= MAX_PAGES) {
352
- truncatedByPageCap = true;
353
- break;
354
- }
355
- from += list.length;
356
- }
357
- if (truncatedByPageCap) {
358
- process.stderr.write(`[gangtise] warning: hit the ${MAX_PAGES}-page safety cap; fetched ${collected.length} rows. Pass --size to fetch a bounded subset.\n`);
359
- }
360
- const rows = requestedSize === undefined ? collected : collected.slice(0, requestedSize);
361
- const out = { [listKey]: rows };
362
- if (partialShape)
363
- out.partial = true;
364
- return out;
365
- }
366
297
  async login() {
367
298
  const authorization = await this.getAuthorizationHeader();
368
299
  const cache = await readTokenCache(this.config.tokenCachePath);
@@ -572,9 +503,7 @@ export class GangtiseClient {
572
503
  return this.download(endpoint, query ?? {}, options);
573
504
  }
574
505
  if (endpoint.kind === 'json' && endpoint.pagination?.enabled) {
575
- return endpoint.pagination.sequential
576
- ? this.requestSequentialPaginated(endpoint, body)
577
- : this.requestPaginated(endpoint, body);
506
+ return this.requestPaginated(endpoint, body);
578
507
  }
579
508
  return this.requestJson(endpoint, body);
580
509
  }
@@ -4,7 +4,7 @@ export function buildQuoteKlineBody(options) {
4
4
  securityList: maybeArray(options.security),
5
5
  startDate: options.startDate,
6
6
  endDate: options.endDate,
7
- limit: parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1 }),
7
+ limit: parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 10000 }),
8
8
  fieldList: maybeArray(options.field),
9
9
  };
10
10
  }
@@ -1,6 +1,6 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
- export const DEFAULT_BASE_URL = "https://open.gangtise.com";
3
+ export const DEFAULT_BASE_URL = "https://openapi.gangtise.com";
4
4
  export const DEFAULT_TIMEOUT_MS = 30_000;
5
5
  export const DEFAULT_TOKEN_CACHE_PATH = path.join(os.homedir(), ".config", "gangtise", "token.json");
6
6
  export function loadConfig() {
@@ -181,6 +181,12 @@ const ENDPOINT_DEFS = {
181
181
  kind: "json",
182
182
  description: "Search chief analyst IDs by name / institution / team",
183
183
  },
184
+ "reference.institution-search": {
185
+ method: "POST",
186
+ path: "/application/open-reference/institutions/search",
187
+ kind: "json",
188
+ description: "Search institution IDs by keyword (domestic broker / foreign / lead / opinion institution)",
189
+ },
184
190
  "reference.constant-category": {
185
191
  method: "GET",
186
192
  path: "/application/open-reference/constants/category",
@@ -248,6 +254,12 @@ const ENDPOINT_DEFS = {
248
254
  kind: "json",
249
255
  description: "Query realtime quote snapshot (A-share / HK / US)",
250
256
  },
257
+ "quote.fund-flow": {
258
+ method: "POST",
259
+ path: "/application/open-quote/fund-flow/daily",
260
+ kind: "json",
261
+ description: "Query A-share daily fund flow (SH/SZ/BJ; small/medium/large/xlarge orders + main net inflow)",
262
+ },
251
263
  // ─── fundamental ───
252
264
  "fundamental.income-statement": {
253
265
  method: "POST",
@@ -490,8 +502,8 @@ const ENDPOINT_DEFS = {
490
502
  path: "/application/open-vault/wechatgroupmsg/chatroomId",
491
503
  kind: "json",
492
504
  description: "List WeChat group chatroom IDs",
493
- // No `total` in the response, list key is `chatRoomList`, server caps size at 50.
494
- pagination: { enabled: true, maxPageSize: 50, sequential: true, listKey: "chatRoomList" },
505
+ // Response is `{ total, list }` (server caps size at 50); auto-paginate by total.
506
+ pagination: { enabled: true, maxPageSize: 50 },
495
507
  },
496
508
  "vault.stock-pool.list": {
497
509
  method: "POST",
@@ -24,7 +24,7 @@ const ERROR_HINTS = {
24
24
  "903301": "今日调用次数已达上限。",
25
25
  "410110": "异步内容生成中,稍后用对应 *-check 命令查询。",
26
26
  "410111": "异步内容生成失败(终态),请更换参数后重新提交。",
27
- "410004": "数据未找到,请检查查询条件。",
27
+ "410004": "数据未找到或无指标权限,请检查查询条件与指标权限。",
28
28
  "430004": "下载失败(官方未文档化错误码),请确认 reportId 有效或更换 --file-type 重试。",
29
29
  "430007": "行情查询超出限制,请缩短日期范围。",
30
30
  "433007": "数据源不匹配,请检查 resourceType 与 sourceId 组合。",
@@ -24,11 +24,6 @@ export function normalizeRows(value) {
24
24
  const hasMeta = Object.keys(meta).length > 0;
25
25
  return hasMeta ? { ...meta, list } : list;
26
26
  }
27
- if (Array.isArray(record.chatRoomList)) {
28
- const { chatRoomList, ...meta } = record;
29
- const hasMeta = Object.keys(meta).length > 0;
30
- return hasMeta ? { ...meta, list: chatRoomList } : chatRoomList;
31
- }
32
27
  if (Array.isArray(record.constants)) {
33
28
  const { constants, ...meta } = record;
34
29
  const hasMeta = Object.keys(meta).length > 0;
@@ -16,11 +16,16 @@ function parseDate(value) {
16
16
  function formatDate(d) {
17
17
  return d.toISOString().slice(0, 10);
18
18
  }
19
- function isAllMarket(body) {
19
+ export function isFullMarket(body, fullMarketValue) {
20
20
  const list = body.securityList;
21
21
  if (!Array.isArray(list) || list.length !== 1)
22
22
  return false;
23
- return list[0] === "all";
23
+ return list[0] === fullMarketValue;
24
+ }
25
+ /** Kline uses `all` as its whole-market keyword; fund-flow uses `aShares`. Thin wrapper
26
+ * so kline call sites read naturally. */
27
+ export function isAllMarket(body) {
28
+ return isFullMarket(body, "all");
24
29
  }
25
30
  function buildShards(start, end, shardDays) {
26
31
  const shards = [];
@@ -43,7 +48,8 @@ function buildShards(start, end, shardDays) {
43
48
  * single-security queries this is a no-op.
44
49
  */
45
50
  export async function callKlineWithSharding(client, endpointKey, body, config) {
46
- if (!isAllMarket(body)) {
51
+ const fullMarketValue = config.fullMarketValue ?? "all";
52
+ if (!isFullMarket(body, fullMarketValue)) {
47
53
  return client.call(endpointKey, body);
48
54
  }
49
55
  // `--security all` returns thousands of rows per day; lift the default 6000-row
@@ -51,17 +57,40 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
51
57
  // must apply even when a date is missing (no sharding possible then, but the
52
58
  // single request still needs the lifted cap).
53
59
  const allMarketBody = { ...body, limit: body.limit ?? ALL_MARKET_LIMIT };
60
+ const perShardLimit = allMarketBody.limit ?? ALL_MARKET_LIMIT;
61
+ // A shard maxes out for two reasons with different fixes: a user-set low --limit is
62
+ // raisable; hitting the API cap itself is not (only a smaller internal shardDays window
63
+ // would help). Word the truncation warning accordingly.
64
+ const truncationHint = perShardLimit < ALL_MARKET_LIMIT
65
+ ? "raise or omit --limit to fetch the full market"
66
+ : `a single ${config.shardDays}-day window exceeds the ${ALL_MARKET_LIMIT}-row API cap`;
67
+ // A full-market response whose row count reaches the per-request limit was itself
68
+ // capped (a low user --limit, or a single day exceeding the API row cap) — its slice
69
+ // is incomplete, so the result must be flagged partial rather than shown as complete.
70
+ const isTruncated = (rec) => Array.isArray(rec.list) && rec.list.length >= perShardLimit;
71
+ // A single full-market request (missing/unparseable dates, or a range that fits one
72
+ // shard) skips the merge loop below, so it needs the same truncation check inline — or
73
+ // a low --limit / oversized day slips through as a silent exit-0 success.
74
+ const callSingle = async () => {
75
+ const single = await client.call(endpointKey, allMarketBody);
76
+ if (single && typeof single === "object" && !Array.isArray(single) && isTruncated(single)) {
77
+ ;
78
+ single.partial = true;
79
+ process.stderr.write(`[gangtise] warning: full-market request hit the ${perShardLimit}-row limit and was likely truncated; results are partial — ${truncationHint}.\n`);
80
+ }
81
+ return single;
82
+ };
54
83
  if (!body.startDate || !body.endDate) {
55
- return client.call(endpointKey, allMarketBody);
84
+ return callSingle();
56
85
  }
57
86
  const start = parseDate(body.startDate);
58
87
  const end = parseDate(body.endDate);
59
88
  if (!start || !end || end < start) {
60
- return client.call(endpointKey, allMarketBody);
89
+ return callSingle();
61
90
  }
62
91
  const totalDays = Math.floor((end.getTime() - start.getTime()) / DAY_MS) + 1;
63
92
  if (totalDays <= config.shardDays) {
64
- return client.call(endpointKey, allMarketBody);
93
+ return callSingle();
65
94
  }
66
95
  const shards = buildShards(start, end, config.shardDays);
67
96
  // isVerbose() (not a direct env read) so the global --verbose flag reaches
@@ -89,6 +118,7 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
89
118
  let fieldList;
90
119
  let header = null;
91
120
  const merged = [];
121
+ let truncatedShards = 0;
92
122
  for (const r of results) {
93
123
  if (!(r && typeof r === "object"))
94
124
  continue;
@@ -97,6 +127,8 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
97
127
  header = rec;
98
128
  if (!fieldList && Array.isArray(rec.fieldList))
99
129
  fieldList = rec.fieldList;
130
+ if (isTruncated(rec))
131
+ truncatedShards++;
100
132
  // Append one-by-one rather than push(...list): a future higher row cap could
101
133
  // make a single shard's list large enough to overflow the stack via spread.
102
134
  if (Array.isArray(rec.list))
@@ -110,7 +142,9 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
110
142
  }
111
143
  if (!header)
112
144
  return { list: [] };
113
- const out = { ...header, list: merged };
145
+ // `total` on a shard is that shard's own row count; overwrite it with the merged count
146
+ // so the JSON `total` and the `Total:` stderr line reflect the whole combined result.
147
+ const out = { ...header, total: merged.length, list: merged };
114
148
  if (fieldList)
115
149
  out.fieldList = fieldList;
116
150
  if (failedShards.length > 0) {
@@ -118,5 +152,9 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
118
152
  out.failedShards = failedShards;
119
153
  process.stderr.write(`[gangtise] warning: ${failedShards.length}/${shards.length} shards failed; results are partial (see failedShards)\n`);
120
154
  }
155
+ if (truncatedShards > 0) {
156
+ out.partial = true;
157
+ process.stderr.write(`[gangtise] warning: ${truncatedShards}/${shards.length} shard(s) hit the ${perShardLimit}-row limit and were likely truncated; results are partial — ${truncationHint}.\n`);
158
+ }
121
159
  return out;
122
160
  }
@@ -1,2 +1,2 @@
1
1
  // Auto-generated — DO NOT EDIT
2
- export const CLI_VERSION = "0.22.0";
2
+ export const CLI_VERSION = "0.23.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gangtise-openapi-cli",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "CLI for Gangtise OpenAPI",
5
5
  "license": "MIT",
6
6
  "repository": {