gangtise-openapi-cli 0.11.1 → 0.12.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
@@ -2,6 +2,49 @@
2
2
 
3
3
  一个可直接调用 Gangtise OpenAPI 获取金融数据的命令行工具,同时提供Agent Skill。
4
4
 
5
+ ## Changelog
6
+
7
+ ### v0.12.0 — 2026-05-10
8
+
9
+ **性能 / 架构**
10
+ - 翻页并行化:自动翻页接口拉到首页 `total` 后,剩余页通过 `Promise.all` 并发请求(默认并发 5,`GANGTISE_PAGE_CONCURRENCY` 可调)
11
+ - 共享 `undici.Agent`:所有请求复用连接池(keep-alive 60s,max 16 连接),避免重复 TLS 握手
12
+ - 流式下载:`--output` 指定时二进制响应直接 `pipeline` 到磁盘,不再走内存 `Uint8Array`
13
+ - 流式输出:`--format jsonl/csv --output xxx` 且 ≥1000 行时逐行写盘
14
+ - Token 内存缓存:Token 在进程内不再每次读盘
15
+ - 自动重试:5xx / `ECONNRESET` / `ETIMEDOUT` / `999999` 自动指数退避重试 2 次
16
+ - Token 自愈:8000014/8000015 自动重新登录并重试一次
17
+ - 异步轮询退避:`earnings-review` / `viewpoint-debate` 轮询从固定 15s 改为 5→8→13→20→30s 指数退避
18
+ - K线自动分片:`quote day-kline --security all` 等全市场查询自动按日期切分并发执行
19
+ - 标题缓存:原"读全文→改→写全文"改为内存快照 + 原子写入(temp+rename)
20
+
21
+ **调试 / 可观测性**
22
+ - 新增 `--verbose` / `GANGTISE_VERBOSE=1`:打印每个请求的耗时、状态码、响应字节数到 stderr
23
+
24
+ ### v0.11.1 — 2026-05-10
25
+
26
+ **新增接口**
27
+ - `insight announcement-hk list/download` — 查询/下载港股公告
28
+ - `insight foreign-opinion list` — 查询外资机构观点(外资券商)
29
+ - `insight independent-opinion list/download` — 查询/下载外资独立分析师观点
30
+ - `reference securities-search` — GTS Code 搜索(按名称/代码/拼音多维度匹配证券)
31
+
32
+ **接口变更**
33
+ - `insight summary download` 新增可选 `--file-type`(`1`=原始内容 / `2`=HTML),仅影响来源为会议平台的纪要
34
+ - `insight announcement list/download` 名称调整为"查询A股公告列表/下载A股公告文件"(路径不变)
35
+ - `insight opinion list` 名称调整为"查询内资机构观点列表"(路径不变)
36
+
37
+ ### v0.11.0 — 2026-04-17
38
+
39
+ - 新增 `ai viewpoint-debate` / `viewpoint-debate-check` — 观点PK(异步)
40
+ - 新增 `ai management-discuss-announcement` / `management-discuss-earnings-call` — 管理层讨论
41
+
42
+ ### v0.10.9 — 2026-04-10
43
+
44
+ - 修复信封检测、版本更新检查、端点去重
45
+ - 新增 `quote index-day-kline` 指数日K线
46
+ - 新增 `vault wechat-message-list` / `wechat-chatroom-list` 群消息
47
+
5
48
  ## 首次安装
6
49
 
7
50
  ```bash
@@ -46,9 +89,14 @@ export GANGTISE_ACCESS_KEY="your-ak"
46
89
  export GANGTISE_SECRET_KEY="your-sk"
47
90
  export GANGTISE_BASE_URL="https://open.gangtise.com"
48
91
  export GANGTISE_TOKEN="Bearer xxx"
92
+
93
+ # 性能/调试可选项
94
+ export GANGTISE_PAGE_CONCURRENCY=5 # 翻页并发数(默认 5)
95
+ export GANGTISE_VERBOSE=1 # 打印每个请求的耗时与字节数
96
+ export GANGTISE_TIMEOUT_MS=30000 # 请求超时(默认 30s)
49
97
  ```
50
98
 
51
- 如果没有 `GANGTISE_TOKEN`,CLI 会自动调用 token 接口并缓存到本地(`~/.config/gangtise/token.json`,权限 0600)。
99
+ 如果没有 `GANGTISE_TOKEN`,CLI 会自动调用 token 接口并缓存到本地(`~/.config/gangtise/token.json`,权限 0600)。Token 失效(8000014/8000015)时会自动重新登录并重试一次。
52
100
 
53
101
 
54
102
  ## AI Agent Skill
@@ -102,15 +150,19 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
102
150
  |------|--------|------|
103
151
  | **Auth** | `login` / `status` | 认证登录、状态查询 |
104
152
  | **Lookup** | `research-area list` / `broker-org list` / `meeting-org list` / `industry list` / `industry-code list` / `region list` / `announcement-category list` / `theme-id list` | 枚举速查(内置,无需额外文档) |
105
- | **Insight** | `opinion list` | 首席观点 |
106
- | | `summary list` / `download` | 纪要(含下载) |
153
+ | **Insight** | `opinion list` | 内资机构观点 |
154
+ | | `summary list` / `download` | 纪要(含下载,支持 `--file-type` 选原始/HTML) |
107
155
  | | `roadshow list` | 路演 |
108
156
  | | `site-visit list` | 调研 |
109
157
  | | `strategy list` | 策略 |
110
158
  | | `forum list` | 论坛 |
111
159
  | | `research list` / `download` | 研报(含 Markdown 下载) |
112
160
  | | `foreign-report list` / `download` | 外资研报(含中文翻译下载) |
113
- | | `announcement list` / `download` | 公告(含 Markdown 下载) |
161
+ | | `announcement list` / `download` | A股公告(含 Markdown 下载) |
162
+ | | `announcement-hk list` / `download` | 港股公告(含下载) |
163
+ | | `foreign-opinion list` | 外资机构观点 |
164
+ | | `independent-opinion list` / `download` | 外资独立分析师观点(含原文/翻译HTML下载) |
165
+ | **Reference** | `securities-search` | GTS Code 搜索(按名称/代码/拼音匹配) |
114
166
  | **Quote** | `day-kline` / `day-kline-hk` | A股/港股日K线 |
115
167
  | | `index-day-kline` | 沪深京指数日K线 |
116
168
  | | `minute-kline` | A股分钟K线 |
@@ -148,6 +200,7 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
148
200
  - `gangtise fundamental ...`
149
201
  - `gangtise ai ...`
150
202
  - `gangtise vault ...`
203
+ - `gangtise reference ...`
151
204
  - `gangtise raw call ...`
152
205
 
153
206
  ## 推荐工作流
@@ -173,6 +226,18 @@ gangtise quote day-kline --security 600519.SH --start-date 2025-03-01 --end-date
173
226
  gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
174
227
  ```
175
228
 
229
+ ## 性能特性
230
+
231
+ - **并发翻页**:自动翻页接口的首页拿到 `total` 后,剩余页用 `Promise.all` 并发拉取(默认并发数 5,可通过 `GANGTISE_PAGE_CONCURRENCY` 调整)。20 页查询从串行 ~10s 降到 ~2s。
232
+ - **HTTP keep-alive**:所有请求复用同一个 `undici.Agent`(连接池 16),避免重复 TLS 握手。
233
+ - **流式下载**:指定 `--output` 时,二进制响应(PDF 等)直接 `pipeline` 到磁盘,不经过内存缓冲;50MB PDF 内存占用近乎为零。
234
+ - **流式输出**:`jsonl`/`csv` 格式且 `--output` 指定时,超过 1000 行自动切换为逐行写盘,避免一次性构建百 MB 字符串。
235
+ - **自动重试**:5xx / `ECONNRESET` / `ETIMEDOUT` / `999999` 系统错误自动指数退避重试 2 次。
236
+ - **Token 自愈**:调用返回 8000014/8000015 时自动强制刷新 Token 并重试一次。
237
+ - **K线自动分片**:`quote day-kline --security all` 等全市场查询自动按日期切分(A股 2 天/片、HK 3 天/片、指数 30 天/片),并发执行后合并结果。
238
+ - **Token 内存缓存**:Token 在进程内存中缓存,避免每次请求读盘。
239
+ - **`--verbose`**:打印每个请求的方法、路径、状态码、耗时和响应大小到 stderr,方便定位慢查询。
240
+
176
241
  ## 自动翻页
177
242
 
178
243
  以下列表接口会自动翻页:
@@ -185,6 +250,9 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
185
250
  - `insight research list`
186
251
  - `insight foreign-report list`
187
252
  - `insight announcement list`
253
+ - `insight announcement-hk list`
254
+ - `insight foreign-opinion list`
255
+ - `insight independent-opinion list`
188
256
  - `ai security-clue`
189
257
  - `vault drive-list`
190
258
  - `vault record-list`
@@ -249,6 +317,31 @@ gangtise insight announcement download --announcement-id 123456 --file-type 2
249
317
  gangtise insight research download --report-id 12345 --output ./report.pdf
250
318
 
251
319
  gangtise insight roadshow list --institution C100000017
320
+
321
+ # 港股公告
322
+ gangtise insight announcement-hk list --security 01913.HK --rank-type 2 --size 20 --format json
323
+ gangtise insight announcement-hk download --announcement-id ANN2026040200012345
324
+
325
+ # 外资机构观点
326
+ gangtise insight foreign-opinion list --keyword "自动驾驶" --region us --rank-type 2 --format json
327
+ gangtise insight foreign-opinion list --security APP.O --rating buy --format json
328
+
329
+ # 外资独立观点
330
+ gangtise insight independent-opinion list --keyword "肿瘤" --industry 104370000 --format json
331
+ gangtise insight independent-opinion download --independent-opinion-id 207051900018372 --file-type 2
332
+
333
+ # 纪要下载(会议平台来源可选 HTML 格式)
334
+ gangtise insight summary download --summary-id 4906813 --file-type 2
335
+ ```
336
+
337
+ ### Reference
338
+
339
+ ```bash
340
+ # GTS Code 搜索:按公司名/代码/拼音查证券代码
341
+ gangtise reference securities-search --keyword "贵州茅台" --category stock
342
+ gangtise reference securities-search --keyword "600519" --category stock
343
+ gangtise reference securities-search --keyword gzmt --top 5
344
+ gangtise reference securities-search --keyword "银行" --category stock --category index
252
345
  ```
253
346
 
254
347
  ### Quote
package/dist/src/cli.js CHANGED
@@ -4,6 +4,7 @@ import { checkAsyncContent, pollAsyncContent, POLL_MAX_ATTEMPTS } from "./core/a
4
4
  import { readTokenCache } from "./core/auth.js";
5
5
  import { collectKeyValue, collectList, collectNumberList, maybeArray, parseFrom, parseNumberOption, parseOptionalNumberOption, parseSize, parseTimestamp13 } from "./core/args.js";
6
6
  import { buildQuoteKlineBody, buildWechatChatroomListBody, buildWechatMessageListBody } from "./core/commandBodies.js";
7
+ import { callKlineWithSharding } from "./core/quoteSharding.js";
7
8
  import { loadConfig } from "./core/config.js";
8
9
  import { resolveTitle, saveDownloadResult } from "./core/download.js";
9
10
  import { ENDPOINTS } from "./core/endpoints.js";
@@ -16,6 +17,21 @@ async function createClient() {
16
17
  const { GangtiseClient } = await import("./core/client.js");
17
18
  return new GangtiseClient(loadConfig());
18
19
  }
20
+ /**
21
+ * Run a download. If `output` is set we already know the destination, so the
22
+ * client streams the body straight to disk (no in-memory Uint8Array copy);
23
+ * otherwise we buffer and let the caller resolve a friendly title.
24
+ */
25
+ async function runDownload(client, endpointKey, query, options) {
26
+ if (options.output) {
27
+ const result = await client.call(endpointKey, undefined, query, { streamTo: options.output });
28
+ await saveDownloadResult(result, options.fallbackName, options.output);
29
+ return;
30
+ }
31
+ const result = await client.call(endpointKey, undefined, query);
32
+ const resolved = options.resolveOutputPath ? await options.resolveOutputPath(result) : undefined;
33
+ await saveDownloadResult(result, options.fallbackName, resolved);
34
+ }
19
35
  function addTimeFilters(command) {
20
36
  return command
21
37
  .option("--from <number>", "Starting offset", "0")
@@ -24,9 +40,18 @@ function addTimeFilters(command) {
24
40
  .option("--end-time <datetime>", "End time")
25
41
  .option("--keyword <keyword>", "Keyword");
26
42
  }
43
+ import { setVerbose } from "./core/transport.js";
27
44
  import { CLI_VERSION } from "./version.js";
28
45
  const program = new Command();
29
- program.name("gangtise").description("Gangtise OpenAPI CLI").version(CLI_VERSION);
46
+ program
47
+ .name("gangtise")
48
+ .description("Gangtise OpenAPI CLI")
49
+ .version(CLI_VERSION)
50
+ .option("--verbose", "Print per-request timings to stderr (also: GANGTISE_VERBOSE=1)")
51
+ .hook("preAction", (thisCommand) => {
52
+ if (thisCommand.opts().verbose)
53
+ setVerbose(true);
54
+ });
30
55
  program
31
56
  .command("auth")
32
57
  .description("Authentication commands")
@@ -88,6 +113,9 @@ const forum = new Command("forum");
88
113
  const research = new Command("research");
89
114
  const foreignReport = new Command("foreign-report");
90
115
  const announcement = new Command("announcement");
116
+ const announcementHk = new Command("announcement-hk");
117
+ const foreignOpinion = new Command("foreign-opinion");
118
+ const independentOpinion = new Command("independent-opinion");
91
119
  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(async (options) => {
92
120
  const client = await createClient();
93
121
  await printData(await client.call("insight.opinion.list", {
@@ -106,11 +134,16 @@ addTimeFilters(summary.command("list").option("--search-type <number>", "Search
106
134
  categoryList: maybeArray(options.category), marketList: maybeArray(options.market), participantRoleList: maybeArray(options.participantRole),
107
135
  }), parseOutputFormat(options.format), options.output, { endpointKey: "insight.summary.list", idField: "summaryId" });
108
136
  });
109
- summary.command("download").requiredOption("--summary-id <id>").option("--output <path>").action(async (options) => {
137
+ summary.command("download").requiredOption("--summary-id <id>").option("--file-type <number>", "File type: 1=original(default) 2=HTML; only affects meeting platform summaries").option("--output <path>").action(async (options) => {
110
138
  const client = await createClient();
111
- const result = await client.call("insight.summary.download", undefined, { summaryId: options.summaryId });
112
- const title = options.output ? undefined : await resolveTitle(client, result, "insight.summary.list", "summaryId", options.summaryId);
113
- await saveDownloadResult(result, `summary-${options.summaryId}`, options.output ?? title);
139
+ const qp = { summaryId: options.summaryId };
140
+ if (options.fileType)
141
+ qp.fileType = parseNumberOption(options.fileType, "--file-type", { integer: true, min: 1 });
142
+ await runDownload(client, "insight.summary.download", qp, {
143
+ output: options.output,
144
+ fallbackName: `summary-${options.summaryId}`,
145
+ resolveOutputPath: (result) => resolveTitle(client, result, "insight.summary.list", "summaryId", options.summaryId),
146
+ });
114
147
  });
115
148
  const addScheduleList = (command, endpointKey) => addTimeFilters(command.command("list").option("--research-area <id>", "Research area", collectList, []).option("--institution <id>", "Institution ID", collectList, []).option("--security <code>", "Security code", collectList, []).option("--category <name>", "Category", collectList, []).option("--market <name>", "Market", collectList, []).option("--participant-role <name>", "Participant role", collectList, []).option("--broker-type <name>", "Broker type", collectList, []).option("--object <type>", "Object type: company/industry", collectList, []).option("--permission <number>", "Permission", collectNumberList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action(async (options) => {
116
149
  const client = await createClient();
@@ -138,9 +171,11 @@ addTimeFilters(research.command("list").option("--search-type <number>", "Search
138
171
  });
139
172
  research.command("download").requiredOption("--report-id <id>").option("--file-type <number>", "File type: 1=PDF 2=Markdown", "1").option("--output <path>").action(async (options) => {
140
173
  const client = await createClient();
141
- const result = await client.call("insight.research.download", undefined, { reportId: options.reportId, fileType: parseNumberOption(options.fileType, "--file-type", { integer: true, min: 1 }) });
142
- const title = options.output ? undefined : await resolveTitle(client, result, "insight.research.list", "reportId", options.reportId);
143
- await saveDownloadResult(result, `research-${options.reportId}`, options.output ?? title);
174
+ await runDownload(client, "insight.research.download", { reportId: options.reportId, fileType: parseNumberOption(options.fileType, "--file-type", { integer: true, min: 1 }) }, {
175
+ output: options.output,
176
+ fallbackName: `research-${options.reportId}`,
177
+ resolveOutputPath: (result) => resolveTitle(client, result, "insight.research.list", "reportId", options.reportId),
178
+ });
144
179
  });
145
180
  addTimeFilters(foreignReport.command("list").option("--search-type <number>", "Search type: 1=title 2=fulltext", "1").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "1").option("--security <code>", "Security code", collectList, []).option("--region <id>", "Region ID", collectList, []).option("--category <name>", "Report category", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--llm-tag <tag>", "Semantic tag", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--min-pages <number>", "Min report pages").option("--max-pages <number>", "Max report pages").option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action(async (options) => {
146
181
  const client = await createClient();
@@ -155,9 +190,11 @@ addTimeFilters(foreignReport.command("list").option("--search-type <number>", "S
155
190
  });
156
191
  foreignReport.command("download").requiredOption("--report-id <id>").option("--file-type <number>", "File type: 1=PDF 2=Markdown 3=CN-PDF 4=CN-Markdown", "1").option("--output <path>").action(async (options) => {
157
192
  const client = await createClient();
158
- const result = await client.call("insight.foreign-report.download", undefined, { reportId: options.reportId, fileType: parseNumberOption(options.fileType, "--file-type", { integer: true, min: 1 }) });
159
- const title = options.output ? undefined : await resolveTitle(client, result, "insight.foreign-report.list", "reportId", options.reportId);
160
- await saveDownloadResult(result, `foreign-report-${options.reportId}`, options.output ?? title);
193
+ await runDownload(client, "insight.foreign-report.download", { reportId: options.reportId, fileType: parseNumberOption(options.fileType, "--file-type", { integer: true, min: 1 }) }, {
194
+ output: options.output,
195
+ fallbackName: `foreign-report-${options.reportId}`,
196
+ resolveOutputPath: (result) => resolveTitle(client, result, "insight.foreign-report.list", "reportId", options.reportId),
197
+ });
161
198
  });
162
199
  addTimeFilters(announcement.command("list").option("--search-type <number>", "Search type: 1=title 2=fulltext", "1").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "1").option("--security <code>", "Security code", collectList, []).option("--announcement-type <type>", "Announcement type", collectList, []).option("--category <id>", "Category ID", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action(async (options) => {
163
200
  const client = await createClient();
@@ -170,9 +207,60 @@ addTimeFilters(announcement.command("list").option("--search-type <number>", "Se
170
207
  });
171
208
  announcement.command("download").requiredOption("--announcement-id <id>").option("--file-type <number>", "File type: 1=PDF 2=Markdown", "1").option("--output <path>").action(async (options) => {
172
209
  const client = await createClient();
173
- const result = await client.call("insight.announcement.download", undefined, { announcementId: options.announcementId, fileType: parseNumberOption(options.fileType, "--file-type", { integer: true, min: 1 }) });
174
- const title = options.output ? undefined : await resolveTitle(client, result, "insight.announcement.list", "announcementId", options.announcementId);
175
- await saveDownloadResult(result, `announcement-${options.announcementId}`, options.output ?? title);
210
+ await runDownload(client, "insight.announcement.download", { announcementId: options.announcementId, fileType: parseNumberOption(options.fileType, "--file-type", { integer: true, min: 1 }) }, {
211
+ output: options.output,
212
+ fallbackName: `announcement-${options.announcementId}`,
213
+ resolveOutputPath: (result) => resolveTitle(client, result, "insight.announcement.list", "announcementId", options.announcementId),
214
+ });
215
+ });
216
+ addTimeFilters(announcementHk.command("list").option("--search-type <number>", "Search type: 1=title 2=fulltext", "1").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "1").option("--security <code>", "Security code (e.g. 01913.HK)", collectList, []).option("--category <id>", "Category ID", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action(async (options) => {
217
+ const client = await createClient();
218
+ await printData(await client.call("insight.announcement-hk.list", {
219
+ from: parseFrom(options.from), size: parseSize(options.size),
220
+ startTime: options.startTime, endTime: options.endTime,
221
+ searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }),
222
+ rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
223
+ keyword: options.keyword,
224
+ securityList: maybeArray(options.security), categoryList: maybeArray(options.category),
225
+ }), parseOutputFormat(options.format), options.output, { endpointKey: "insight.announcement-hk.list", idField: "announcementId" });
226
+ });
227
+ announcementHk.command("download").requiredOption("--announcement-id <id>").option("--output <path>").action(async (options) => {
228
+ const client = await createClient();
229
+ await runDownload(client, "insight.announcement-hk.download", { announcementId: options.announcementId }, {
230
+ output: options.output,
231
+ fallbackName: `announcement-hk-${options.announcementId}`,
232
+ resolveOutputPath: (result) => resolveTitle(client, result, "insight.announcement-hk.list", "announcementId", options.announcementId),
233
+ });
234
+ });
235
+ addTimeFilters(foreignOpinion.command("list").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "1").option("--security <code>", "Security code (e.g. UBER.N)", collectList, []).option("--region <code>", "Region code", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action(async (options) => {
236
+ const client = await createClient();
237
+ await printData(await client.call("insight.foreign-opinion.list", {
238
+ from: parseFrom(options.from), size: parseSize(options.size),
239
+ startTime: options.startTime, endTime: options.endTime,
240
+ rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
241
+ keyword: options.keyword,
242
+ regionList: maybeArray(options.region), industryList: maybeArray(options.industry),
243
+ securityList: maybeArray(options.security), brokerList: maybeArray(options.broker),
244
+ ratingList: maybeArray(options.rating), ratingChangeList: maybeArray(options.ratingChange),
245
+ }), parseOutputFormat(options.format), options.output);
246
+ });
247
+ addTimeFilters(independentOpinion.command("list").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "1").option("--security <code>", "Security code (e.g. GSK.N)", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action(async (options) => {
248
+ const client = await createClient();
249
+ await printData(await client.call("insight.independent-opinion.list", {
250
+ from: parseFrom(options.from), size: parseSize(options.size),
251
+ startTime: options.startTime, endTime: options.endTime,
252
+ rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
253
+ keyword: options.keyword,
254
+ industryList: maybeArray(options.industry), securityList: maybeArray(options.security),
255
+ ratingList: maybeArray(options.rating), ratingChangeList: maybeArray(options.ratingChange),
256
+ }), parseOutputFormat(options.format), options.output);
257
+ });
258
+ independentOpinion.command("download").requiredOption("--independent-opinion-id <id>").requiredOption("--file-type <number>", "File type: 1=original HTML 2=CN-translated HTML").option("--output <path>").action(async (options) => {
259
+ const client = await createClient();
260
+ await runDownload(client, "insight.independent-opinion.download", { independentOpinionId: options.independentOpinionId, fileType: parseNumberOption(options.fileType, "--file-type", { integer: true, min: 1 }) }, {
261
+ output: options.output,
262
+ fallbackName: `independent-opinion-${options.independentOpinionId}`,
263
+ });
176
264
  });
177
265
  insight.addCommand(opinion);
178
266
  insight.addCommand(summary);
@@ -183,19 +271,22 @@ insight.addCommand(forum);
183
271
  insight.addCommand(research);
184
272
  insight.addCommand(foreignReport);
185
273
  insight.addCommand(announcement);
274
+ insight.addCommand(announcementHk);
275
+ insight.addCommand(foreignOpinion);
276
+ insight.addCommand(independentOpinion);
186
277
  program.addCommand(insight);
187
278
  const quote = new Command("quote").description("Quote APIs");
188
279
  quote.command("day-kline").option("--security <code>", "Security code (A-share: .SH/.SZ/.BJ, or 'all' for full market)", collectList, []).option("--start-date <date>", "Start date (default: 1 year before end-date)").option("--end-date <date>", "End date (default: latest)").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(async (options) => {
189
280
  const client = await createClient();
190
- await printData(await client.call("quote.day-kline", buildQuoteKlineBody(options)), parseOutputFormat(options.format), options.output);
281
+ await printData(await callKlineWithSharding(client, "quote.day-kline", buildQuoteKlineBody(options), { shardDays: 2 }), parseOutputFormat(options.format), options.output);
191
282
  });
192
283
  quote.command("day-kline-hk").option("--security <code>", "Security code (HK stock: .HK, or 'all' for full market)", collectList, []).option("--start-date <date>", "Start date (default: 1 year before end-date)").option("--end-date <date>", "End date (default: latest)").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(async (options) => {
193
284
  const client = await createClient();
194
- await printData(await client.call("quote.day-kline-hk", buildQuoteKlineBody(options)), parseOutputFormat(options.format), options.output);
285
+ await printData(await callKlineWithSharding(client, "quote.day-kline-hk", buildQuoteKlineBody(options), { shardDays: 3 }), parseOutputFormat(options.format), options.output);
195
286
  });
196
287
  quote.command("index-day-kline").option("--security <code>", "Index code (.SH/.SZ/.BJ, or 'all' for full market)", collectList, []).option("--start-date <date>", "Start date (default: 1 year before end-date)").option("--end-date <date>", "End date (default: latest)").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(async (options) => {
197
288
  const client = await createClient();
198
- await printData(await client.call("quote.index-day-kline", buildQuoteKlineBody(options)), parseOutputFormat(options.format), options.output);
289
+ await printData(await callKlineWithSharding(client, "quote.index-day-kline", buildQuoteKlineBody(options), { shardDays: 30 }), parseOutputFormat(options.format), options.output);
199
290
  });
200
291
  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(async (options) => {
201
292
  const client = await createClient();
@@ -254,7 +345,10 @@ ai.command("knowledge-batch").requiredOption("--query <text>", "Query", collectL
254
345
  });
255
346
  ai.command("knowledge-resource-download").requiredOption("--resource-type <number>").requiredOption("--source-id <id>").option("--output <path>").action(async (options) => {
256
347
  const client = await createClient();
257
- await saveDownloadResult(await client.call("ai.knowledge-resource.download", undefined, { resourceType: parseNumberOption(options.resourceType, "--resource-type", { integer: true, min: 0 }), sourceId: options.sourceId }), `resource-${options.sourceId}`, options.output);
348
+ await runDownload(client, "ai.knowledge-resource.download", { resourceType: parseNumberOption(options.resourceType, "--resource-type", { integer: true, min: 0 }), sourceId: options.sourceId }, {
349
+ output: options.output,
350
+ fallbackName: `resource-${options.sourceId}`,
351
+ });
258
352
  });
259
353
  ai.command("security-clue").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").requiredOption("--start-time <datetime>").requiredOption("--end-time <datetime>").addOption(new Option("--query-mode <mode>").choices(["bySecurity", "byIndustry"]).makeOptionMandatory()).option("--gts-code <code>", "GTS code", collectList, []).option("--source <name>", "Source", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
260
354
  const client = await createClient();
@@ -358,6 +452,16 @@ ai.command("viewpoint-debate-check").requiredOption("--data-id <id>", "dataId fr
358
452
  const client = await createClient();
359
453
  await checkAsyncContent(client, "ai.viewpoint-debate.get-content", options.dataId, parseOutputFormat(options.format), options.output);
360
454
  });
455
+ const reference = new Command("reference").description("Reference data APIs");
456
+ 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(async (options) => {
457
+ const client = await createClient();
458
+ await printData(await client.call("reference.securities-search", {
459
+ keyword: options.keyword,
460
+ category: options.category.length ? options.category : undefined,
461
+ top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
462
+ }), parseOutputFormat(options.format), options.output);
463
+ });
464
+ program.addCommand(reference);
361
465
  const vault = new Command("vault").description("Vault APIs");
362
466
  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(async (options) => {
363
467
  const client = await createClient();
@@ -365,9 +469,11 @@ vault.command("drive-list").option("--from <number>", "Starting offset", "0").op
365
469
  });
366
470
  vault.command("drive-download").requiredOption("--file-id <id>").option("--output <path>").action(async (options) => {
367
471
  const client = await createClient();
368
- const result = await client.call("vault.drive.download", undefined, { fileId: options.fileId });
369
- const title = options.output ? undefined : await resolveTitle(client, result, "vault.drive.list", "fileId", options.fileId);
370
- await saveDownloadResult(result, `file-${options.fileId}`, options.output ?? title);
472
+ await runDownload(client, "vault.drive.download", { fileId: options.fileId }, {
473
+ output: options.output,
474
+ fallbackName: `file-${options.fileId}`,
475
+ resolveOutputPath: (result) => resolveTitle(client, result, "vault.drive.list", "fileId", options.fileId),
476
+ });
371
477
  });
372
478
  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(async (options) => {
373
479
  const client = await createClient();
@@ -375,9 +481,11 @@ vault.command("record-list").option("--from <number>", "Starting offset", "0").o
375
481
  });
376
482
  vault.command("record-download").requiredOption("--record-id <id>").requiredOption("--content-type <type>", "Content type: original/asr/summary").option("--output <path>").action(async (options) => {
377
483
  const client = await createClient();
378
- const result = await client.call("vault.record.download", undefined, { recordId: options.recordId, contentType: options.contentType });
379
- const title = options.output ? undefined : await resolveTitle(client, result, "vault.record.list", "recordId", options.recordId);
380
- await saveDownloadResult(result, `record-${options.recordId}`, options.output ?? title);
484
+ await runDownload(client, "vault.record.download", { recordId: options.recordId, contentType: options.contentType }, {
485
+ output: options.output,
486
+ fallbackName: `record-${options.recordId}`,
487
+ resolveOutputPath: (result) => resolveTitle(client, result, "vault.record.list", "recordId", options.recordId),
488
+ });
381
489
  });
382
490
  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(async (options) => {
383
491
  const client = await createClient();
@@ -385,9 +493,11 @@ vault.command("my-conference-list").option("--from <number>", "Starting offset",
385
493
  });
386
494
  vault.command("my-conference-download").requiredOption("--conference-id <id>").requiredOption("--content-type <type>", "Content type: asr/summary").option("--output <path>").action(async (options) => {
387
495
  const client = await createClient();
388
- const result = await client.call("vault.my-conference.download", undefined, { conferenceId: options.conferenceId, contentType: options.contentType });
389
- const title = options.output ? undefined : await resolveTitle(client, result, "vault.my-conference.list", "conferenceId", options.conferenceId);
390
- await saveDownloadResult(result, `conference-${options.conferenceId}`, options.output ?? title);
496
+ await runDownload(client, "vault.my-conference.download", { conferenceId: options.conferenceId, contentType: options.contentType }, {
497
+ output: options.output,
498
+ fallbackName: `conference-${options.conferenceId}`,
499
+ resolveOutputPath: (result) => resolveTitle(client, result, "vault.my-conference.list", "conferenceId", options.conferenceId),
500
+ });
391
501
  });
392
502
  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("--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(async (options) => {
393
503
  const client = await createClient();
@@ -414,11 +524,14 @@ program.command("raw").description("Raw API calls").addCommand(new Command("call
414
524
  throw new ConfigError(`Invalid JSON in --body: ${options.body}`);
415
525
  }
416
526
  }
417
- const data = await client.call(endpointKey, body, options.query);
418
527
  if (endpoint.kind === "download") {
419
- await saveDownloadResult(data, "download.bin", options.output);
528
+ await runDownload(client, endpointKey, options.query, {
529
+ output: options.output,
530
+ fallbackName: "download.bin",
531
+ });
420
532
  return;
421
533
  }
534
+ const data = await client.call(endpointKey, body, options.query);
422
535
  await printData(data, parseOutputFormat(options.format), options.output);
423
536
  }));
424
537
  async function checkForUpdate(timeoutMs = 2000) {
@@ -1,7 +1,15 @@
1
1
  import { ApiError } from "./errors.js";
2
2
  import { printData } from "./printer.js";
3
- export const POLL_MAX_ATTEMPTS = 12;
4
- export const POLL_DELAY_MS = 15_000;
3
+ export const POLL_MAX_ATTEMPTS = 14;
4
+ const POLL_INITIAL_DELAY_MS = 5_000;
5
+ const POLL_MAX_DELAY_MS = 30_000;
6
+ /** Total wait time stays close to the previous 12*15s=180s budget. */
7
+ export const POLL_DELAY_MS = POLL_INITIAL_DELAY_MS;
8
+ function nextDelayMs(attempt) {
9
+ // 5s, 8s, 13s, 20s, 30s, 30s, ...
10
+ const grown = POLL_INITIAL_DELAY_MS * 1.6 ** (attempt - 1);
11
+ return Math.min(POLL_MAX_DELAY_MS, Math.round(grown));
12
+ }
5
13
  function isAsyncPending(error) {
6
14
  return error instanceof ApiError && error.code === "410110";
7
15
  }
@@ -23,8 +31,9 @@ export async function pollAsyncContent(client, getContentEndpoint, dataId, forma
23
31
  throw error;
24
32
  }
25
33
  if (attempt < POLL_MAX_ATTEMPTS) {
26
- process.stderr.write(`Attempt ${attempt}/${POLL_MAX_ATTEMPTS}: content not ready, retrying in 15s...\n`);
27
- await new Promise(resolve => setTimeout(resolve, POLL_DELAY_MS));
34
+ const delay = nextDelayMs(attempt);
35
+ process.stderr.write(`Attempt ${attempt}/${POLL_MAX_ATTEMPTS}: content not ready, retrying in ${Math.round(delay / 1000)}s...\n`);
36
+ await new Promise(resolve => setTimeout(resolve, delay));
28
37
  }
29
38
  }
30
39
  return false;