gangtise-openapi-cli 0.22.1 → 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,18 @@
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
+
7
19
  ### v0.22.1 — 2026-07-03
8
20
 
9
21
  **修复**
@@ -149,7 +161,7 @@ npm run dev -- --help
149
161
  ```bash
150
162
  export GANGTISE_ACCESS_KEY="your-ak"
151
163
  export GANGTISE_SECRET_KEY="your-sk"
152
- export GANGTISE_BASE_URL="https://open.gangtise.com"
164
+ export GANGTISE_BASE_URL="https://openapi.gangtise.com"
153
165
  export GANGTISE_TOKEN="Bearer xxx"
154
166
 
155
167
  # 性能/调试可选项
@@ -227,7 +239,7 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
227
239
  | 模块 | 子命令 | 说明 |
228
240
  |------|--------|------|
229
241
  | **Auth** | `login` / `status` | 认证登录、状态查询 |
230
- | **Lookup** | `broker-org list` / `meeting-org list` | 本地枚举表(API 未覆盖的部分;行业/区域/公告分类/题材/申万行业代码改用 Reference 接口) |
242
+ | **Lookup** | `broker-org list` / `meeting-org list` | 券商/会议机构本地全量枚举表(按名称找 ID 优先 `reference institution-search`;行业/区域/公告分类/题材/申万码已改用 Reference 接口) |
231
243
  | **Insight** | `opinion list` | 内资机构观点 |
232
244
  | | `summary list` / `download` | 纪要(含下载,支持 `--file-type` 选原始/HTML) |
233
245
  | | `roadshow list` | 路演 |
@@ -244,6 +256,7 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
244
256
  | | `official-account list` / `download` | 产业公众号资讯(含 txt/HTML 下载) |
245
257
  | **Reference** | `securities-search` | GTS Code 搜索(按名称/代码/拼音匹配) |
246
258
  | | `chiefs-search` | 首席分析师 ID 搜索(按姓名/机构/团队匹配) |
259
+ | | `institution-search` | 机构 ID 搜索(内资券商/外资/牵头/观点机构,按名称匹配) |
247
260
  | | `constant-category` | 常量分类列表(含各分类适用的接口与参数) |
248
261
  | | `constant-list` | 按分类导出常量值全量列表(行业/城市/公告分类/区域等) |
249
262
  | | `concept-search` | 题材 ID 搜索(名称/拼音/分组名匹配) |
@@ -253,6 +266,7 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
253
266
  | | `index-day-kline` | 沪深京指数日K线 |
254
267
  | | `minute-kline` | A股分钟K线 |
255
268
  | | `realtime` | 个股实时行情快照(A股/港股/美股) |
269
+ | | `fund-flow` | A股个股日资金流向(沪深京;小/中/大/特大单 + 主力净流入) |
256
270
  | **Fundamental** | `income-statement` / `balance-sheet` / `cash-flow` | A股三大财务报表(累计) |
257
271
  | | `income-statement-quarterly` / `cash-flow-quarterly` | A股利润表/现金流量表(单季度) |
258
272
  | | `income-statement-hk` / `balance-sheet-hk` / `cash-flow-hk` | 港股三大财务报表(中国会计准则) |
@@ -362,7 +376,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
362
376
  - `vault record-list`
363
377
  - `vault my-conference-list`
364
378
  - `vault wechat-message-list`
365
- - `vault wechat-chatroom-list`(特例:接口无 `total`,CLI 串行翻页)
379
+ - `vault wechat-chatroom-list`
366
380
  - `ai hot-topic`
367
381
 
368
382
  规则:
@@ -373,7 +387,6 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
373
387
  - 安全上限:自动翻页最多 1000 页,防止异常循环
374
388
  - 部分页失败、或服务端实际返回行数与 `total` 矛盾(提前短页)时,不丢弃已取到的数据:结果带 `partial: true`(页失败时另有 `failedPages`;K线分片为 `failedShards`;`--format json` 可见),stderr 输出警告,**进程退出码为 3**(完整成功为 0)
375
389
  - 分页结果中 `total` 字段会被保留(json 格式输出 `{total, list}`);其他格式下 stderr 输出 `Total: N, showing: M`(json 格式不输出该行)
376
- - `vault wechat-chatroom-list` 是特例:接口不返回 `total`,CLI 改为串行翻页——省略 `--size` 拉全量、传 `--size N` 取前 N 条,单页 50,无 `Total:` 提示
377
390
 
378
391
  ## 智能文件命名
379
392
 
@@ -462,6 +475,8 @@ gangtise reference securities-search --keyword "银行" --category stock --categ
462
475
  # 首席分析师 ID 搜索(按姓名/机构/团队;拿 chiefId 供 insight opinion list --chief 使用)
463
476
  gangtise reference chiefs-search --keyword 东吴证券 --top 3 --format json
464
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
465
480
 
466
481
  # 常量查询:先看分类,再按分类导出全量常量值
467
482
  gangtise reference constant-category --format json
@@ -502,6 +517,8 @@ gangtise quote minute-kline --security 600519.SH --start-time "2026-04-15 09:30:
502
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
503
518
  # 实时行情:全市场批量(建议配合 --field 精简字段)
504
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
505
522
  ```
506
523
 
507
524
  > **历史 vs 实时**:`day-kline*` 仅返回历史数据(当日数据入库时间:A 股 ~15:30 / 港股 ~16:30 / 美股 ~07:00 北京时间)。盘中需要最新成交价、振幅等实时字段必须走 `quote realtime`。
@@ -598,8 +615,9 @@ gangtise vault record-list --keyword 晨会 --category upload --category mobile
598
615
  # 录音速记下载(--content-type: original/asr/summary)
599
616
  gangtise vault record-download --record-id 49412 --content-type summary
600
617
 
601
- # 我的会议列表
618
+ # 我的会议列表(--source 录制来源:1=企微会议助理 2=会议服务微信群,可重复;不传返回全部)
602
619
  gangtise vault my-conference-list --keyword AI --category earningsCall --institution C100000027
620
+ gangtise vault my-conference-list --source 2 --category earningsCall --size 20
603
621
  # 我的会议下载(--content-type: asr/summary)
604
622
  gangtise vault my-conference-download --conference-id 43319 --content-type asr
605
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,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.1";
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.1",
3
+ "version": "0.23.0",
4
4
  "description": "CLI for Gangtise OpenAPI",
5
5
  "license": "MIT",
6
6
  "repository": {