gangtise-openapi-cli 0.24.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,22 @@
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
+
7
23
  ### v0.24.0 — 2026-07-07
8
24
 
9
25
  **新增**
@@ -69,50 +85,6 @@
69
85
  - `gangtise ... | head` 遇 stdout `EPIPE` 时安静退出;只有首个参数是 `--version` / `-V` 时才触发版本快捷路径
70
86
  - Endpoint registry 的 `key` 改为由记录键自动派生,减少映射漂移;新增真实 CLI 选项到请求体的 stub 测试;测试 272 → 323
71
87
 
72
- ### v0.21.0 — 2026-06-29
73
-
74
- **行为变更(注意)**
75
- - ⚠️ `vault wechat-chatroom-list` 省略 `--size` 现在**拉全量**(此前默认只返回 20 条)。该接口不返回 `total`,CLI 改为串行翻页(翻到不满页为止,单页上限 50);传 `--size N` 仍只取前 N 条。依赖"默认 20 条"的脚本会拿到全部群。
76
-
77
- **修复**
78
- - `quote day-kline --security all` 等大结果集用默认 `table` 格式输出时不再因 `Math.max(...大数组)` 撑爆调用栈崩溃(`RangeError`);`renderTable` 改用 reduce 计算列宽
79
- - CSV 导出:含回车符 `\r` 的字段现在正确加引号(RFC 4180);`table` / `markdown` 的多行字段折叠换行,保持表格对齐
80
- - 下载文件名剥离控制字符 / NUL,避免 `fs.writeFile` 报错
81
-
82
- **修复(安全)**
83
- - token 缓存文件(`~/.config/gangtise/token.json`)改为临时文件 + 原子 `rename` 写入:从第一字节即 `0600`,消除"旧文件宽松权限残留"与"崩溃截断"两个隐患
84
-
85
- **内部 / 工程**
86
- - 依赖 `vitest` 升级到 3.2.6(修复 dev-only 安全告警);新增 `npm run typecheck`;测试 257 → 272
87
-
88
- ### v0.20.0 — 2026-06-26
89
-
90
- **新增接口**
91
- - `insight announcement-us list` / `download` — 美股公司公告列表与下载(`--security TSLA.O`、`--category`〔分类用 `reference constant-list --category usShareAnnouncementCategory`,美股独立的 `103980xxx` 段〕、`--search-type`、`--rank-type`、下载 `--file-type 1` 原始 PDF / `2` Markdown);自动翻页,单页上限 50
92
- - `ai stock-summary` — 个股看点(精炼投研总结):`--security` 传具体代码(A股/港股,可重复,单次最多 6000)或市场关键词 `aShares` / `hkStocks` 拉全市场;无看点的证券不返回、不扣分
93
- - `fundamental income-statement-us` / `balance-sheet-us` / `cash-flow-us` — 美股三大财务报表(参数同其他财报:`--security-code` / `--period` / `--report-type` / `--fiscal-year` / `--field` 等)
94
- - `reference chiefs-search` — 首席分析师 ID 搜索(`--keyword` 按姓名/机构/团队匹配,`--top` 默认 10);用于 `insight opinion list --chief` 的入参
95
-
96
- **变更**
97
- - `insight announcement-hk download` 新增 `--file-type`(`1` 原始(默认)/ `2` Markdown),此前无格式选项
98
-
99
- **行为变更(注意)**
100
- - ⚠️ `auth login` / `auth status` 默认脱敏 access token:`--format json` 输出里 `authorization` 与 `cache.accessToken` 显示为 `<redacted>`,仅保留过期时间 / 用户名 / 产品码 / uid 等非敏感字段。**依赖 `auth login` 原始 token 输出的脚本会拿到 `<redacted>`**,需改用 `auth login --show-token` 获取明文。
101
-
102
- **修复(安全)**
103
- - `auth status` / `auth login` token 脱敏:按凭证字段名模式匹配(`token`/`key`/`secret`/`password`/`credential`),覆盖 `apiKey`/`privateKey`/`refreshToken` 等任何可能携带的凭证字段
104
- - 自愈守卫:同时设 `GANGTISE_TOKEN` + AK/SK 时,注入 token 失效后重新登录不再被旧 token 短路,重试改用登录拿到的新 token
105
-
106
- **修复(数据正确性 / 健壮性)**
107
- - ⚠️ **CSV 负数不再被破坏**(影响所有 CSV 导出):此前防公式注入会把负数(如跌幅 `-3.5`)加 `'` 前缀变成文本,Excel/pandas 无法参与计算;现仅对非有限数字的可疑串(`=`/`@`/`-1+cmd` 等)加前缀,合法数字原样输出
108
- - 自动翻页改为 fail-soft:某页遇不可重试错误(限流 `903301` 等)不再丢弃已取的全部数据,返回已取页 + `partial` / `failedPages` 标记,并在首错后停止继续请求(避免撞限流多烧配额)
109
- - 下载文件名 fallback(服务端 `Content-Disposition`)补清洗:含 `/`、`:` 等字符的文件名不再写到意外路径
110
- - `ai stock-summary` / `ai knowledge-batch` 缺 `--security` / `--query` 时本地报错,不再发空请求(stock-summary 借此避免被后台当全市场误扣积分)
111
- - `ai hot-topic` `--no-with-related-securities` / `--no-with-close-reading` 改为显式发 `false`(语义更明确,不依赖"字段缺失=排除"的隐含约定)
112
-
113
- **修复(indicator 适配 EDE 后台新结构)**
114
- - `indicator cross-section` / `time-series` 适配后台改版的返回结构(字段名加 `List` 后缀 `securityCodeList/indicatorCodeList/…`、截面 `values` 改二维 `[指标][证券]`):此前后台改结构后 CLI 拍平失配、退化成原始矩阵,现恢复 `{date, security, name, 指标:值}` 宽表。配合后台同步变化——无数据从 `999999` 报错改为返回 `null`(截面不再 500、不丢行),缺必填参数从笼统 `410106` 改为直接指明缺哪个参数
115
-
116
88
  > 更早版本及完整更新历史见 [CHANGELOG.md](CHANGELOG.md)。
117
89
 
118
90
  ## 首次安装
@@ -242,9 +214,12 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
242
214
  | | `foreign-opinion list` | 外资机构观点 |
243
215
  | | `independent-opinion list` / `download` | 外资独立分析师观点(含原文/翻译HTML下载) |
244
216
  | | `official-account list` / `download` | 产业公众号资讯(含 txt/HTML 下载) |
217
+ | | `qa list` | 投资者问答 QA(互动平台/电话会议/调研纪要,按证券) |
218
+ | | `report-image list` / `download` | 研报图表搜索(按关键词,含原图 JPEG 下载) |
245
219
  | **Reference** | `securities-search` | GTS Code 搜索(按名称/代码/拼音匹配) |
246
220
  | | `chiefs-search` | 首席分析师 ID 搜索(按姓名/机构/团队匹配) |
247
221
  | | `institution-search` | 机构 ID 搜索(内资券商/外资/牵头/观点机构,按名称匹配) |
222
+ | | `official-account-search` | 公众号 ID 搜索(按公众号名/机构/分类匹配,返回 accountId) |
248
223
  | | `constant-category` | 常量分类列表(含各分类适用的接口与参数) |
249
224
  | | `constant-list` | 按分类导出常量值全量列表(行业/城市/公告分类/区域等) |
250
225
  | | `concept-search` | 题材 ID 搜索(名称/拼音/分组名匹配) |
@@ -338,7 +313,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
338
313
  - **流式输出**:`jsonl`/`csv` 格式且 `--output` 指定时,超过 1000 行自动切换为逐行写盘,避免一次性构建百 MB 字符串。
339
314
  - **自动重试**:5xx / 429 / `ECONNRESET` / `ETIMEDOUT` / `ENOTFOUND` / `EAI_AGAIN` / `UND_ERR_*`(undici 超时类)/ `999999` 系统错误自动指数退避重试 2 次。
340
315
  - **Token 自愈**:调用返回 8000014/8000015 时自动强制刷新 Token 并重试一次。
341
- - **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 截断。
342
317
  - **Token 内存缓存**:Token 在进程内存中缓存,避免每次请求读盘。
343
318
  - **`--verbose`**:打印每个请求的方法、路径、状态码、耗时和响应大小到 stderr,方便定位慢查询。
344
319
 
@@ -359,6 +334,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
359
334
  - `insight foreign-opinion list`
360
335
  - `insight independent-opinion list`
361
336
  - `insight official-account list`
337
+ - `insight qa list`
362
338
  - `ai security-clue`
363
339
  - `vault drive-list`
364
340
  - `vault record-list`
@@ -447,6 +423,12 @@ gangtise insight independent-opinion download --independent-opinion-id 207051900
447
423
  gangtise insight official-account list --keyword 泡泡玛特 --rank-type 2 --size 20 --format json
448
424
  gangtise insight official-account download --article-id 7286248 --file-type 2
449
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
+
450
432
  # 纪要下载(会议平台来源可选 HTML 格式)
451
433
  gangtise insight summary download --summary-id 4906813 --file-type 2
452
434
  ```
@@ -463,8 +445,10 @@ gangtise reference securities-search --keyword "银行" --category stock --categ
463
445
  # 首席分析师 ID 搜索(按姓名/机构/团队;拿 chiefId 供 insight opinion list --chief 使用)
464
446
  gangtise reference chiefs-search --keyword 东吴证券 --top 3 --format json
465
447
  gangtise reference chiefs-search --keyword 芦哲 --format json
466
- # 机构 ID 搜索(--category: domesticBroker/foreignInstitution/leadInstitution/opinionInstitution)
448
+ # 机构 ID 搜索(--category: domesticBroker/foreignInstitution/leadInstitution/opinionInstitution/foreignOpinionInstitution
467
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
468
452
 
469
453
  # 常量查询:先看分类,再按分类导出全量常量值
470
454
  gangtise reference constant-category --format json
package/dist/src/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { Command, Option } from "commander";
3
3
  import { checkAsyncContent, pollAsyncContent, POLL_MAX_ATTEMPTS } from "./core/asyncContent.js";
4
4
  import { readTokenCache, redactTokenCache } from "./core/auth.js";
5
- import { collectKeyValue, collectList, collectNumberList, localDateString, maybeArray, parseFrom, parseNumberOption, parseOptionalNumberOption, parseSize, parseTimestamp13 } from "./core/args.js";
5
+ import { collectKeyValue, collectList, collectNumberList, localDateString, maybeArray, parseChoiceList, parseFrom, parseNumberOption, parseOptionalNumberOption, parseSize, parseTimestamp13 } from "./core/args.js";
6
6
  import { buildIndicatorCrossSectionBody, buildIndicatorTimeSeriesBody, buildQuoteKlineBody, buildStockPoolStocksBody, buildWechatChatroomListBody, buildWechatMessageListBody } from "./core/commandBodies.js";
7
7
  import { flattenCrossSection, flattenTimeSeries, unwrapIndicatorData } from "./core/indicatorMatrix.js";
8
8
  import { callKlineWithSharding, isAllMarket, isFullMarket } from "./core/quoteSharding.js";
@@ -169,6 +169,8 @@ const announcementUs = new Command("announcement-us");
169
169
  const foreignOpinion = new Command("foreign-opinion");
170
170
  const independentOpinion = new Command("independent-opinion");
171
171
  const officialAccount = new Command("official-account");
172
+ const qa = new Command("qa");
173
+ const reportImage = new Command("report-image");
172
174
  addTimeFilters(opinion.command("list").option("--rank-type <number>", "Rank type", "1").option("--research-area <id>", "Research area ID", collectList, []).option("--chief <id>", "Chief ID", collectList, []).option("--security <code>", "Security code", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--concept <id>", "Concept ID", collectList, []).option("--llm-tag <tag>", "Semantic tag", collectList, []).option("--source <source>", "Source", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.opinion.list", {
173
175
  from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime,
174
176
  rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }), keyword: options.keyword, researchAreaList: maybeArray(options.researchArea), chiefList: maybeArray(options.chief),
@@ -310,6 +312,19 @@ addTimeFilters(officialAccount.command("list").option("--search-type <number>",
310
312
  categoryList: maybeArray(options.category), industryList: maybeArray(options.industry),
311
313
  }), { endpointKey: "insight.official-account.list", idField: "articleId" }));
312
314
  addDownloadCommand(officialAccount, { endpointKey: "insight.official-account.download", idOption: "--article-id", idField: "articleId", fallbackPrefix: "official-account", fileType: { description: "File type: 1=txt(default) 2=HTML", default: "1" }, titleListEndpoint: "insight.official-account.list" });
315
+ // QA request keys are BARE (source/questionCategory/answerImportant), not the *List
316
+ // convention — the body below mirrors the spec exactly. Datetimes pass through as strings.
317
+ qa.command("list").requiredOption("--security-code <code>", "Security code, e.g. 601012.SH").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all (max page 500)").option("--start-time <datetime>", "Start time (yyyy-MM-dd or yyyy-MM-dd HH:mm:ss)").option("--end-time <datetime>", "End time (yyyy-MM-dd or yyyy-MM-dd HH:mm:ss)").option("--source <type>", "Source: conference/interactive/survey (repeat)", collectList, []).option("--question-category <name>", "Question category (repeat): productAndBusiness/capacityAndProjects/ordersAndCustomers/financialData/materialEvents/capitalOperations/shareholdersAndDividends/corporateGovernance/marketAndValuation/macroAndIndustry/risksAndOthers", collectList, []).option("--answer-important <flag>", "Answer involves key info: 1=yes 0=no (repeat; omit for all)", collectNumberList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("insight.qa.list", {
318
+ from: parseFrom(options.from), size: parseSize(options.size),
319
+ securityCode: options.securityCode, startTime: options.startTime, endTime: options.endTime,
320
+ source: maybeArray(options.source), questionCategory: maybeArray(options.questionCategory),
321
+ answerImportant: options.answerImportant.length ? options.answerImportant : undefined,
322
+ })));
323
+ reportImage.command("list").requiredOption("--keyword <text>", "Search keyword, e.g. 'AI' '新能源汽车'").option("--top <number>", "Max results (default: 10, max: 20)", "10").option("--source-id <id>", "Report source ID, to filter to one report (from a report list or knowledge base)").option("--start-time <datetime>", "Start time (yyyy-MM-dd HH:mm:ss; yyyy-MM-dd auto-completed)").option("--end-time <datetime>", "End time (yyyy-MM-dd HH:mm:ss; yyyy-MM-dd auto-completed)").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("insight.report-image.list", {
324
+ keyword: options.keyword, top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 20 }),
325
+ sourceId: options.sourceId, startTime: options.startTime, endTime: options.endTime,
326
+ })));
327
+ addDownloadCommand(reportImage, { endpointKey: "insight.report-image.download", idOption: "--chunk-id", idField: "chunkId", fallbackPrefix: "report-image" });
313
328
  insight.addCommand(opinion);
314
329
  insight.addCommand(summary);
315
330
  insight.addCommand(roadshow);
@@ -324,6 +339,8 @@ insight.addCommand(announcementUs);
324
339
  insight.addCommand(foreignOpinion);
325
340
  insight.addCommand(independentOpinion);
326
341
  insight.addCommand(officialAccount);
342
+ insight.addCommand(qa);
343
+ insight.addCommand(reportImage);
327
344
  program.addCommand(insight);
328
345
  const quote = new Command("quote").description("Quote APIs");
329
346
  const addKlineCommand = (name, endpointKey, securityHelp, shardDays) => quote.command(name)
@@ -436,10 +453,10 @@ fundamental.command("earning-forecast").requiredOption("--security-code <code>")
436
453
  }));
437
454
  program.addCommand(fundamental);
438
455
  const ai = new Command("ai").description("AI APIs");
439
- ai.command("knowledge-batch").option("--query <text>", "Query", collectList, []).option("--top <number>", "Top", "10").option("--resource-type <number>", "Resource type", collectNumberList, []).option("--knowledge-name <name>", "Knowledge name", collectList, []).option("--start-time <ms>").option("--end-time <ms>").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => {
456
+ ai.command("knowledge-batch").option("--query <text>", "Query", collectList, []).option("--top <number>", "Max results (default: 10, max: 20)", "10").option("--resource-type <number>", "Resource type", collectNumberList, []).option("--knowledge-name <name>", "Knowledge name", collectList, []).option("--start-time <ms>").option("--end-time <ms>").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => {
440
457
  if (!options.query.length)
441
458
  throw new ValidationError("--query is required: pass at least one --query");
442
- return emit(options, (client) => client.call("ai.knowledge-batch", { queries: options.query, top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }), resourceTypes: options.resourceType.length ? options.resourceType : undefined, knowledgeNames: maybeArray(options.knowledgeName), startTime: parseOptionalNumberOption(options.startTime, "--start-time", { integer: true, min: 0 }), endTime: parseOptionalNumberOption(options.endTime, "--end-time", { integer: true, min: 0 }) }));
459
+ return emit(options, (client) => client.call("ai.knowledge-batch", { queries: options.query, top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 20 }), resourceTypes: options.resourceType.length ? options.resourceType : undefined, knowledgeNames: maybeArray(options.knowledgeName), startTime: parseOptionalNumberOption(options.startTime, "--start-time", { integer: true, min: 0 }), endTime: parseOptionalNumberOption(options.endTime, "--end-time", { integer: true, min: 0 }) }));
443
460
  });
444
461
  ai.command("knowledge-resource-download").requiredOption("--resource-type <number>").requiredOption("--source-id <id>").option("--output <path>").action((options) => withClient(async (client) => {
445
462
  await runDownload(client, "ai.knowledge-resource.download", { resourceType: parseNumberOption(options.resourceType, "--resource-type", { integer: true, min: 0 }), sourceId: options.sourceId }, {
@@ -538,28 +555,34 @@ ai.command("stock-summary").description("Stock highlights: refined research summ
538
555
  const reference = new Command("reference").description("Reference data APIs");
539
556
  reference.command("securities-search").requiredOption("--keyword <text>", "Search keyword (name/code/pinyin/English)").option("--category <type>", "Category: stock/dr/index/fund", collectList, []).option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.securities-search", {
540
557
  keyword: options.keyword,
541
- category: options.category.length ? options.category : undefined,
542
- top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
558
+ category: parseChoiceList(options.category, "--category", ["stock", "dr", "index", "fund"]),
559
+ top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
543
560
  })));
544
561
  reference.command("constant-category").description("List constant categories and which API params accept them").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.constant-category")));
545
562
  reference.command("constant-list").requiredOption("--category <code>", "Category code from 'reference constant-category' (e.g. citicIndustry/swIndustry/regionCategory)").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.constant-list", { category: options.category })));
546
563
  reference.command("concept-search").requiredOption("--keyword <text>", "Search keyword (name/pinyin/group name)").option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.concept-search", {
547
564
  keyword: options.keyword,
548
- top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
565
+ top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
549
566
  })));
550
567
  reference.command("sector-search").option("--keyword <text>", "Search keyword (name/pinyin)").option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.sector-search", {
551
568
  keyword: options.keyword,
552
- top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
569
+ top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
553
570
  })));
554
571
  reference.command("sector-constituents").requiredOption("--sector-id <id>", "Sector ID from 'reference sector-search'").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.sector-constituents", { sectorId: options.sectorId })));
555
572
  reference.command("chiefs-search").requiredOption("--keyword <text>", "Search keyword (chief name / institution / team)").option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.chiefs-search", {
556
573
  keyword: options.keyword,
557
- top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
574
+ top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
558
575
  })));
559
576
  reference.command("institution-search").requiredOption("--keyword <text>", "Search keyword (institution name / abbreviation)").option("--category <name>", "Category: domesticBroker/foreignInstitution/leadInstitution/opinionInstitution/foreignOpinionInstitution (repeat); omit for all", collectList, []).option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.institution-search", {
560
577
  keyword: options.keyword,
561
- categoryList: maybeArray(options.category),
562
- top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
578
+ categoryList: parseChoiceList(options.category, "--category", ["domesticBroker", "foreignInstitution", "leadInstitution", "opinionInstitution", "foreignOpinionInstitution"]),
579
+ top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
580
+ })));
581
+ // Note: request key is BARE `category` here (spec), unlike institution-search's `categoryList`.
582
+ reference.command("official-account-search").requiredOption("--keyword <text>", "Search keyword (account name / institution / keyword, e.g. 东吴证券)").option("--category <name>", "Category: listedCompany/broker/government/media (repeat); omit for all incl. uncategorized", collectList, []).option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.official-account-search", {
583
+ keyword: options.keyword,
584
+ category: parseChoiceList(options.category, "--category", ["listedCompany", "broker", "government", "media"]),
585
+ top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
563
586
  })));
564
587
  program.addCommand(reference);
565
588
  const vault = new Command("vault").description("Vault APIs");
@@ -62,6 +62,17 @@ export function collectKeyValue(value, previous = {}) {
62
62
  export function maybeArray(value) {
63
63
  return value.length > 0 ? value : undefined;
64
64
  }
65
+ // Whitelist for enum-valued repeatable options. Only used where the server was
66
+ // probed NOT to reject bad values (it silently ignores the filter or returns
67
+ // empty instead) — endpoints that answer 100003 keep server-side validation.
68
+ export function parseChoiceList(values, optionName, allowed) {
69
+ for (const value of values) {
70
+ if (!allowed.includes(value)) {
71
+ throw new ValidationError(`Invalid ${optionName}: "${value}" is not one of ${allowed.join("/")}`);
72
+ }
73
+ }
74
+ return maybeArray(values);
75
+ }
65
76
  export function toTimestamp13(value) {
66
77
  if (value === undefined)
67
78
  return undefined;
@@ -285,13 +285,30 @@ export class GangtiseClient {
285
285
  total,
286
286
  list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
287
287
  };
288
- if (failedPages.length > 0) {
288
+ // Unified completeness backstop. Whatever the cause — a failed/shape-broken page,
289
+ // a short later page (server page cap < maxPageSize), the MAX_PAGES cap, or `total`
290
+ // drifting mid-fetch — the result is partial. A short row count or a total drift each
291
+ // force it; so does any failedPages entry on its own — an over-returning sibling page
292
+ // can lift the row count back to target and mask the hole (short would read false), yet
293
+ // the failedPages branch below still writes "results are partial" to stderr, so the flag
294
+ // must agree. printData maps partial → exit 3 so a script can't read a truncated export
295
+ // as complete. The cap and drift branches above already warned on stderr; failedPages
296
+ // warns below.
297
+ const short = collected.length < target;
298
+ if (short || totalDrift || failedPages.length > 0)
289
299
  out.partial = true;
300
+ if (failedPages.length > 0) {
290
301
  out.failedPages = failedPages.map((p) => ({ from: p.from, size: p.size }));
291
302
  const detail = firstError instanceof Error ? `: ${firstError.message}` : "";
292
303
  const skippedHint = aborted ? " A page hit a non-retryable error (e.g. rate limit); remaining pages were skipped." : "";
293
304
  process.stderr.write(`[gangtise] warning: ${failedPages.length}/${pageRequests.length} pages not fetched${detail}; results are partial — got ${collected.length}/${total} rows (see failedPages).${skippedHint}\n`);
294
305
  }
306
+ else if (short && !truncatedByPageCap && !totalDrift) {
307
+ // A short later page with no failure, cap, or drift to explain it: the server
308
+ // simply delivered fewer rows than `total` promised. Warn so an interactive run
309
+ // sees why the result is partial (the other causes each warn on their own path).
310
+ process.stderr.write(`[gangtise] warning: server returned ${collected.length} of ${total} rows (a later page came back short); results may be incomplete\n`);
311
+ }
295
312
  return out;
296
313
  }
297
314
  async login() {
@@ -173,6 +173,26 @@ const ENDPOINT_DEFS = {
173
173
  kind: "download",
174
174
  description: "Download WeChat official account article (txt/HTML)",
175
175
  },
176
+ "insight.qa.list": {
177
+ method: "POST",
178
+ // The literal '&' is the vendor's path segment (Q&A-data), not a query separator.
179
+ path: "/application/open-insight/Q&A-data/getList",
180
+ kind: "json",
181
+ description: "List investor Q&A (conference/interactive/survey) for a security",
182
+ pagination: { enabled: true, maxPageSize: 500 },
183
+ },
184
+ "insight.report-image.list": {
185
+ method: "POST",
186
+ path: "/application/open-insight/report-image/getList",
187
+ kind: "json",
188
+ description: "Search research report images by keyword (returns chunkId + metadata)",
189
+ },
190
+ "insight.report-image.download": {
191
+ method: "GET",
192
+ path: "/application/open-insight/report-image/download/file",
193
+ kind: "download",
194
+ description: "Download a research report image by chunkId",
195
+ },
176
196
  // ─── reference ───
177
197
  "reference.securities-search": {
178
198
  method: "POST",
@@ -192,6 +212,12 @@ const ENDPOINT_DEFS = {
192
212
  kind: "json",
193
213
  description: "Search institution IDs by keyword (domestic broker / foreign / lead / opinion institution)",
194
214
  },
215
+ "reference.official-account-search": {
216
+ method: "POST",
217
+ path: "/application/open-reference/officialAccount/search",
218
+ kind: "json",
219
+ description: "Search official account (WeChat public account) IDs by name / institution / category",
220
+ },
195
221
  "reference.constant-category": {
196
222
  method: "GET",
197
223
  path: "/application/open-reference/constants/category",
@@ -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 错误。",
@@ -121,13 +121,31 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
121
121
  // every shard on the first rejection.
122
122
  const failedShards = [];
123
123
  let firstError = null;
124
+ let aborted = false;
124
125
  const results = await runWithConcurrency(shards, config.concurrency ?? PAGE_CONCURRENCY, async (shard) => {
126
+ // A prior shard hit a hard error (rate limit, no-perm, retries exhausted). Stop
127
+ // dispatching the rest rather than burning quota into the same failure; record them
128
+ // as failed so the merged result is flagged partial. Mirrors requestPaginated.
129
+ if (aborted) {
130
+ failedShards.push(shard);
131
+ return null;
132
+ }
125
133
  try {
126
- return await client.call(endpointKey, { ...allMarketBody, startDate: shard.startDate, endDate: shard.endDate });
134
+ const res = await client.call(endpointKey, { ...allMarketBody, startDate: shard.startDate, endDate: shard.endDate });
135
+ // A shard that resolves but carries no `list` array is shape-broken (an error
136
+ // object, a truncated envelope) — its rows are missing. Treat it exactly like a
137
+ // thrown shard so the result is marked partial, not silently short. Unlike a hard
138
+ // error this doesn't abort the fan-out: one malformed shard isn't systemic.
139
+ if (!(res && typeof res === "object" && Array.isArray(res.list))) {
140
+ failedShards.push(shard);
141
+ return null;
142
+ }
143
+ return res;
127
144
  }
128
145
  catch (error) {
129
146
  if (!firstError)
130
147
  firstError = error;
148
+ aborted = true;
131
149
  failedShards.push(shard);
132
150
  return null;
133
151
  }
@@ -1,2 +1,2 @@
1
1
  // Auto-generated — DO NOT EDIT
2
- export const CLI_VERSION = "0.24.0";
2
+ export const CLI_VERSION = "0.25.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gangtise-openapi-cli",
3
- "version": "0.24.0",
3
+ "version": "0.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",