gangtise-openapi-cli 0.11.1 → 0.13.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,63 @@
2
2
 
3
3
  一个可直接调用 Gangtise OpenAPI 获取金融数据的命令行工具,同时提供Agent Skill。
4
4
 
5
+ ## Changelog
6
+
7
+ ### v0.13.0 — 2026-05-15
8
+
9
+ **新增接口**
10
+ - `fundamental income-statement-hk / balance-sheet-hk / cash-flow-hk` — 港股三大报表(中国会计准则)
11
+ - `alternative edb-search` — 行业指标列表搜索(按关键词匹配指标名称,返回 indicatorId 等元信息)
12
+ - `alternative edb-data` — 行业指标时序数据(批量按 indicatorId 拉取时间序列,最多 10 个指标)
13
+ - `vault stock-pool-list` — 查询用户自选股股票池列表(poolId / poolName)
14
+ - `vault stock-pool-stocks` — 查询股票池证券明细(支持 `--pool-id all` 全量查询)
15
+
16
+ **接口变更**
17
+ - `fundamental income-statement / balance-sheet / cash-flow / income-statement-quarterly / cash-flow-quarterly` 名称调整为 A股报表(路径不变)
18
+ - `ai management-discuss-announcement` `--dimension` 新增 `all` 选项,返回报告中完整的管理层讨论内容(内容可能较长)
19
+ - `vault wechat-message-list` 新增 `--security <code>` 参数(按证券代码过滤),返回结果增加 `securityList` 字段
20
+
21
+ ### v0.12.0 — 2026-05-10
22
+
23
+ **性能 / 架构**
24
+ - 翻页并行化:自动翻页接口拉到首页 `total` 后,剩余页通过 `Promise.all` 并发请求(默认并发 5,`GANGTISE_PAGE_CONCURRENCY` 可调)
25
+ - 共享 `undici.Agent`:所有请求复用连接池(keep-alive 60s,max 16 连接),避免重复 TLS 握手
26
+ - 流式下载:`--output` 指定时二进制响应直接 `pipeline` 到磁盘,不再走内存 `Uint8Array`
27
+ - 流式输出:`--format jsonl/csv --output xxx` 且 ≥1000 行时逐行写盘
28
+ - Token 内存缓存:Token 在进程内不再每次读盘
29
+ - 自动重试:5xx / `ECONNRESET` / `ETIMEDOUT` / `999999` 自动指数退避重试 2 次
30
+ - Token 自愈:8000014/8000015 自动重新登录并重试一次
31
+ - 异步轮询退避:`earnings-review` / `viewpoint-debate` 轮询从固定 15s 改为 5→8→13→20→30s 指数退避
32
+ - K线自动分片:`quote day-kline --security all` 等全市场查询自动按日期切分并发执行
33
+ - 标题缓存:原"读全文→改→写全文"改为内存快照 + 原子写入(temp+rename)
34
+
35
+ **调试 / 可观测性**
36
+ - 新增 `--verbose` / `GANGTISE_VERBOSE=1`:打印每个请求的耗时、状态码、响应字节数到 stderr
37
+
38
+ ### v0.11.1 — 2026-05-10
39
+
40
+ **新增接口**
41
+ - `insight announcement-hk list/download` — 查询/下载港股公告
42
+ - `insight foreign-opinion list` — 查询外资机构观点(外资券商)
43
+ - `insight independent-opinion list/download` — 查询/下载外资独立分析师观点
44
+ - `reference securities-search` — GTS Code 搜索(按名称/代码/拼音多维度匹配证券)
45
+
46
+ **接口变更**
47
+ - `insight summary download` 新增可选 `--file-type`(`1`=原始内容 / `2`=HTML),仅影响来源为会议平台的纪要
48
+ - `insight announcement list/download` 名称调整为"查询A股公告列表/下载A股公告文件"(路径不变)
49
+ - `insight opinion list` 名称调整为"查询内资机构观点列表"(路径不变)
50
+
51
+ ### v0.11.0 — 2026-04-17
52
+
53
+ - 新增 `ai viewpoint-debate` / `viewpoint-debate-check` — 观点PK(异步)
54
+ - 新增 `ai management-discuss-announcement` / `management-discuss-earnings-call` — 管理层讨论
55
+
56
+ ### v0.10.9 — 2026-04-10
57
+
58
+ - 修复信封检测、版本更新检查、端点去重
59
+ - 新增 `quote index-day-kline` 指数日K线
60
+ - 新增 `vault wechat-message-list` / `wechat-chatroom-list` 群消息
61
+
5
62
  ## 首次安装
6
63
 
7
64
  ```bash
@@ -46,9 +103,14 @@ export GANGTISE_ACCESS_KEY="your-ak"
46
103
  export GANGTISE_SECRET_KEY="your-sk"
47
104
  export GANGTISE_BASE_URL="https://open.gangtise.com"
48
105
  export GANGTISE_TOKEN="Bearer xxx"
106
+
107
+ # 性能/调试可选项
108
+ export GANGTISE_PAGE_CONCURRENCY=5 # 翻页并发数(默认 5)
109
+ export GANGTISE_VERBOSE=1 # 打印每个请求的耗时与字节数
110
+ export GANGTISE_TIMEOUT_MS=30000 # 请求超时(默认 30s)
49
111
  ```
50
112
 
51
- 如果没有 `GANGTISE_TOKEN`,CLI 会自动调用 token 接口并缓存到本地(`~/.config/gangtise/token.json`,权限 0600)。
113
+ 如果没有 `GANGTISE_TOKEN`,CLI 会自动调用 token 接口并缓存到本地(`~/.config/gangtise/token.json`,权限 0600)。Token 失效(8000014/8000015)时会自动重新登录并重试一次。
52
114
 
53
115
 
54
116
  ## AI Agent Skill
@@ -63,10 +125,19 @@ Skill 目录结构:
63
125
 
64
126
  ```
65
127
  gangtise-openapi/
66
- ├── SKILL.md # 主 skill 文件(命令参考、参数枚举、使用规则)
128
+ ├── SKILL.md # 主 skill 文件(必备规则、速查表、按需引用 references)
67
129
  └── references/
68
- ├── fields.md # 字段中英文对照速查表
69
- └── lookup-ids.md # 常用 ID 速查表(行业/券商/机构/公告分类等)
130
+ ├── commands/ # 按命令组拆分的详细参数文档(agent 按需 Read)
131
+ │ ├── ai.md # AI 能力命令(one-pager / earnings-review / viewpoint-debate 等)
132
+ │ ├── fundamental.md # 财务数据命令(三大报表 / 估值 / 盈利预测 / 股东)
133
+ │ ├── insight.md # 投研内容命令(研报 / 观点 / 纪要 / 公告 / 外资)
134
+ │ ├── quote.md # 行情命令(A股/港股/指数 K 线)
135
+ │ ├── reference-and-lookup.md # GTS Code 搜索与枚举速查
136
+ │ └── vault.md # 云盘/录音/会议/群消息
137
+ ├── examples.md # 典型场景的端到端示例
138
+ ├── fields.md # K线/财务字段中英文对照速查表
139
+ ├── lookup-ids.md # 常用 ID 速查表(行业/券商/机构/公告分类等)
140
+ └── response-schema.md # 各接口响应字段说明
70
141
  ```
71
142
 
72
143
  安装:
@@ -102,15 +173,19 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
102
173
  |------|--------|------|
103
174
  | **Auth** | `login` / `status` | 认证登录、状态查询 |
104
175
  | **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` | 纪要(含下载) |
176
+ | **Insight** | `opinion list` | 内资机构观点 |
177
+ | | `summary list` / `download` | 纪要(含下载,支持 `--file-type` 选原始/HTML) |
107
178
  | | `roadshow list` | 路演 |
108
179
  | | `site-visit list` | 调研 |
109
180
  | | `strategy list` | 策略 |
110
181
  | | `forum list` | 论坛 |
111
182
  | | `research list` / `download` | 研报(含 Markdown 下载) |
112
183
  | | `foreign-report list` / `download` | 外资研报(含中文翻译下载) |
113
- | | `announcement list` / `download` | 公告(含 Markdown 下载) |
184
+ | | `announcement list` / `download` | A股公告(含 Markdown 下载) |
185
+ | | `announcement-hk list` / `download` | 港股公告(含下载) |
186
+ | | `foreign-opinion list` | 外资机构观点 |
187
+ | | `independent-opinion list` / `download` | 外资独立分析师观点(含原文/翻译HTML下载) |
188
+ | **Reference** | `securities-search` | GTS Code 搜索(按名称/代码/拼音匹配) |
114
189
  | **Quote** | `day-kline` / `day-kline-hk` | A股/港股日K线 |
115
190
  | | `index-day-kline` | 沪深京指数日K线 |
116
191
  | | `minute-kline` | A股分钟K线 |
@@ -148,6 +223,7 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
148
223
  - `gangtise fundamental ...`
149
224
  - `gangtise ai ...`
150
225
  - `gangtise vault ...`
226
+ - `gangtise reference ...`
151
227
  - `gangtise raw call ...`
152
228
 
153
229
  ## 推荐工作流
@@ -173,6 +249,18 @@ gangtise quote day-kline --security 600519.SH --start-date 2025-03-01 --end-date
173
249
  gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
174
250
  ```
175
251
 
252
+ ## 性能特性
253
+
254
+ - **并发翻页**:自动翻页接口的首页拿到 `total` 后,剩余页用 `Promise.all` 并发拉取(默认并发数 5,可通过 `GANGTISE_PAGE_CONCURRENCY` 调整)。20 页查询从串行 ~10s 降到 ~2s。
255
+ - **HTTP keep-alive**:所有请求复用同一个 `undici.Agent`(连接池 16),避免重复 TLS 握手。
256
+ - **流式下载**:指定 `--output` 时,二进制响应(PDF 等)直接 `pipeline` 到磁盘,不经过内存缓冲;50MB PDF 内存占用近乎为零。
257
+ - **流式输出**:`jsonl`/`csv` 格式且 `--output` 指定时,超过 1000 行自动切换为逐行写盘,避免一次性构建百 MB 字符串。
258
+ - **自动重试**:5xx / `ECONNRESET` / `ETIMEDOUT` / `999999` 系统错误自动指数退避重试 2 次。
259
+ - **Token 自愈**:调用返回 8000014/8000015 时自动强制刷新 Token 并重试一次。
260
+ - **K线自动分片**:`quote day-kline --security all` 等全市场查询自动按日期切分(A股 2 天/片、HK 3 天/片、指数 30 天/片),并发执行后合并结果。
261
+ - **Token 内存缓存**:Token 在进程内存中缓存,避免每次请求读盘。
262
+ - **`--verbose`**:打印每个请求的方法、路径、状态码、耗时和响应大小到 stderr,方便定位慢查询。
263
+
176
264
  ## 自动翻页
177
265
 
178
266
  以下列表接口会自动翻页:
@@ -185,6 +273,9 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
185
273
  - `insight research list`
186
274
  - `insight foreign-report list`
187
275
  - `insight announcement list`
276
+ - `insight announcement-hk list`
277
+ - `insight foreign-opinion list`
278
+ - `insight independent-opinion list`
188
279
  - `ai security-clue`
189
280
  - `vault drive-list`
190
281
  - `vault record-list`
@@ -249,6 +340,31 @@ gangtise insight announcement download --announcement-id 123456 --file-type 2
249
340
  gangtise insight research download --report-id 12345 --output ./report.pdf
250
341
 
251
342
  gangtise insight roadshow list --institution C100000017
343
+
344
+ # 港股公告
345
+ gangtise insight announcement-hk list --security 01913.HK --rank-type 2 --size 20 --format json
346
+ gangtise insight announcement-hk download --announcement-id ANN2026040200012345
347
+
348
+ # 外资机构观点
349
+ gangtise insight foreign-opinion list --keyword "自动驾驶" --region us --rank-type 2 --format json
350
+ gangtise insight foreign-opinion list --security APP.O --rating buy --format json
351
+
352
+ # 外资独立观点
353
+ gangtise insight independent-opinion list --keyword "肿瘤" --industry 104370000 --format json
354
+ gangtise insight independent-opinion download --independent-opinion-id 207051900018372 --file-type 2
355
+
356
+ # 纪要下载(会议平台来源可选 HTML 格式)
357
+ gangtise insight summary download --summary-id 4906813 --file-type 2
358
+ ```
359
+
360
+ ### Reference
361
+
362
+ ```bash
363
+ # GTS Code 搜索:按公司名/代码/拼音查证券代码
364
+ gangtise reference securities-search --keyword "贵州茅台" --category stock
365
+ gangtise reference securities-search --keyword "600519" --category stock
366
+ gangtise reference securities-search --keyword gzmt --top 5
367
+ gangtise reference securities-search --keyword "银行" --category stock --category index
252
368
  ```
253
369
 
254
370
  ### 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();
@@ -212,6 +303,9 @@ addFinancialReport("income-statement-quarterly", "fundamental.income-statement-q
212
303
  addFinancialReport("balance-sheet", "fundamental.balance-sheet");
213
304
  addFinancialReport("cash-flow", "fundamental.cash-flow");
214
305
  addFinancialReport("cash-flow-quarterly", "fundamental.cash-flow-quarterly", "Period: q1/q2/q3/q4/latest");
306
+ addFinancialReport("income-statement-hk", "fundamental.income-statement-hk", "Period: q1/h1/q3/h2/nsd/annual/latest");
307
+ addFinancialReport("balance-sheet-hk", "fundamental.balance-sheet-hk", "Period: q1/h1/q3/h2/nsd/annual/latest");
308
+ addFinancialReport("cash-flow-hk", "fundamental.cash-flow-hk", "Period: q1/h1/q3/h2/nsd/annual/latest");
215
309
  fundamental.command("main-business").requiredOption("--security-code <code>").option("--start-date <date>").option("--end-date <date>").addOption(new Option("--breakdown <type>", "Breakdown: product/industry/region").choices(["product", "industry", "region"]).default("product")).option("--period <type>", "Period: interim/annual", collectList, []).option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
216
310
  const client = await createClient();
217
311
  await printData(await client.call("fundamental.main-business", { securityCode: options.securityCode, startDate: options.startDate, endDate: options.endDate, breakdown: options.breakdown, periodList: maybeArray(options.period), fieldList: maybeArray(options.field) }), parseOutputFormat(options.format), options.output);
@@ -254,7 +348,10 @@ ai.command("knowledge-batch").requiredOption("--query <text>", "Query", collectL
254
348
  });
255
349
  ai.command("knowledge-resource-download").requiredOption("--resource-type <number>").requiredOption("--source-id <id>").option("--output <path>").action(async (options) => {
256
350
  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);
351
+ await runDownload(client, "ai.knowledge-resource.download", { resourceType: parseNumberOption(options.resourceType, "--resource-type", { integer: true, min: 0 }), sourceId: options.sourceId }, {
352
+ output: options.output,
353
+ fallbackName: `resource-${options.sourceId}`,
354
+ });
258
355
  });
259
356
  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
357
  const client = await createClient();
@@ -318,7 +415,7 @@ ai.command("hot-topic").option("--from <number>", "Starting offset", "0").option
318
415
  withCloseReading: options.withCloseReading === false ? undefined : true,
319
416
  }), parseOutputFormat(options.format), options.output);
320
417
  });
321
- ai.command("management-discuss-announcement").requiredOption("--report-date <date>", "Report date (yyyy-MM-dd, e.g. 2025-06-30)").requiredOption("--security-code <code>", "Security code (e.g. 000001.SZ)").addOption(new Option("--dimension <name>", "Discussion dimension").choices(["businessOperation", "financialPerformance", "developmentAndRisk"]).makeOptionMandatory()).option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
418
+ ai.command("management-discuss-announcement").requiredOption("--report-date <date>", "Report date (yyyy-MM-dd, e.g. 2025-06-30)").requiredOption("--security-code <code>", "Security code (e.g. 000001.SZ)").addOption(new Option("--dimension <name>", "Discussion dimension: businessOperation/financialPerformance/developmentAndRisk/all").choices(["businessOperation", "financialPerformance", "developmentAndRisk", "all"]).makeOptionMandatory()).option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
322
419
  const client = await createClient();
323
420
  await printData(await client.call("ai.management-discuss-announcement", {
324
421
  reportDate: options.reportDate,
@@ -358,6 +455,16 @@ ai.command("viewpoint-debate-check").requiredOption("--data-id <id>", "dataId fr
358
455
  const client = await createClient();
359
456
  await checkAsyncContent(client, "ai.viewpoint-debate.get-content", options.dataId, parseOutputFormat(options.format), options.output);
360
457
  });
458
+ const reference = new Command("reference").description("Reference data APIs");
459
+ 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) => {
460
+ const client = await createClient();
461
+ await printData(await client.call("reference.securities-search", {
462
+ keyword: options.keyword,
463
+ category: options.category.length ? options.category : undefined,
464
+ top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
465
+ }), parseOutputFormat(options.format), options.output);
466
+ });
467
+ program.addCommand(reference);
361
468
  const vault = new Command("vault").description("Vault APIs");
362
469
  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
470
  const client = await createClient();
@@ -365,9 +472,11 @@ vault.command("drive-list").option("--from <number>", "Starting offset", "0").op
365
472
  });
366
473
  vault.command("drive-download").requiredOption("--file-id <id>").option("--output <path>").action(async (options) => {
367
474
  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);
475
+ await runDownload(client, "vault.drive.download", { fileId: options.fileId }, {
476
+ output: options.output,
477
+ fallbackName: `file-${options.fileId}`,
478
+ resolveOutputPath: (result) => resolveTitle(client, result, "vault.drive.list", "fileId", options.fileId),
479
+ });
371
480
  });
372
481
  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
482
  const client = await createClient();
@@ -375,9 +484,11 @@ vault.command("record-list").option("--from <number>", "Starting offset", "0").o
375
484
  });
376
485
  vault.command("record-download").requiredOption("--record-id <id>").requiredOption("--content-type <type>", "Content type: original/asr/summary").option("--output <path>").action(async (options) => {
377
486
  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);
487
+ await runDownload(client, "vault.record.download", { recordId: options.recordId, contentType: options.contentType }, {
488
+ output: options.output,
489
+ fallbackName: `record-${options.recordId}`,
490
+ resolveOutputPath: (result) => resolveTitle(client, result, "vault.record.list", "recordId", options.recordId),
491
+ });
381
492
  });
382
493
  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
494
  const client = await createClient();
@@ -385,11 +496,13 @@ vault.command("my-conference-list").option("--from <number>", "Starting offset",
385
496
  });
386
497
  vault.command("my-conference-download").requiredOption("--conference-id <id>").requiredOption("--content-type <type>", "Content type: asr/summary").option("--output <path>").action(async (options) => {
387
498
  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);
499
+ await runDownload(client, "vault.my-conference.download", { conferenceId: options.conferenceId, contentType: options.contentType }, {
500
+ output: options.output,
501
+ fallbackName: `conference-${options.conferenceId}`,
502
+ resolveOutputPath: (result) => resolveTitle(client, result, "vault.my-conference.list", "conferenceId", options.conferenceId),
503
+ });
391
504
  });
392
- 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) => {
505
+ 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(async (options) => {
393
506
  const client = await createClient();
394
507
  await printData(await client.call("vault.wechat-message.list", buildWechatMessageListBody(options)), parseOutputFormat(options.format), options.output);
395
508
  });
@@ -397,8 +510,42 @@ vault.command("wechat-chatroom-list").option("--from <number>", "Starting offset
397
510
  const client = await createClient();
398
511
  await printData(await client.call("vault.wechat-chatroom.list", buildWechatChatroomListBody(options)), parseOutputFormat(options.format), options.output);
399
512
  });
513
+ vault.command("stock-pool-list").option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
514
+ const client = await createClient();
515
+ await printData(await client.call("vault.stock-pool.list", {}), parseOutputFormat(options.format), options.output);
516
+ });
517
+ vault.command("stock-pool-stocks").option("--pool-id <id>", "Pool ID; repeat for multiple; use 'all' for all pools", collectList, ["all"]).option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
518
+ const client = await createClient();
519
+ await printData(await client.call("vault.stock-pool.stocks", { poolIdList: options.poolId }), parseOutputFormat(options.format), options.output);
520
+ });
400
521
  program.addCommand(vault);
401
522
  program.addCommand(ai);
523
+ const alternative = new Command("alternative").description("Alternative data APIs");
524
+ alternative.command("edb-search").requiredOption("--keyword <text>", "Search keyword (e.g. '空调')").option("--limit <number>", "Max results (default: 100, max: 200)", "100").option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
525
+ const client = await createClient();
526
+ await printData(await client.call("alternative.edb-search", {
527
+ keyword: options.keyword,
528
+ limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1 }),
529
+ }), parseOutputFormat(options.format), options.output);
530
+ });
531
+ alternative.command("edb-data").option("--indicator-id <id>", "Indicator ID (repeat, max 10)", collectList, []).requiredOption("--start-date <date>", "Start date (yyyy-MM-dd)").requiredOption("--end-date <date>", "End date (yyyy-MM-dd)").option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
532
+ const client = await createClient();
533
+ const raw = await client.call("alternative.edb-data", {
534
+ indicatorIdList: options.indicatorId,
535
+ startDate: options.startDate,
536
+ endDate: options.endDate,
537
+ });
538
+ let data = raw;
539
+ if (raw && Array.isArray(raw.fieldList) && Array.isArray(raw.dataList)) {
540
+ const list = raw.dataList.map((row) => raw.fieldList.reduce((acc, field, i) => {
541
+ acc[field] = row[i];
542
+ return acc;
543
+ }, {}));
544
+ data = { list, total: list.length };
545
+ }
546
+ await printData(data, parseOutputFormat(options.format), options.output);
547
+ });
548
+ program.addCommand(alternative);
402
549
  program.command("raw").description("Raw API calls").addCommand(new Command("call").argument("<endpointKey>").option("--body <json>").option("--query <key=value>", "Query string pair", collectKeyValue, {}).option("--format <format>", "Output format", "json").option("--output <path>").action(async (endpointKey, options) => {
403
550
  const endpoint = ENDPOINTS[endpointKey];
404
551
  if (!endpoint) {
@@ -414,11 +561,14 @@ program.command("raw").description("Raw API calls").addCommand(new Command("call
414
561
  throw new ConfigError(`Invalid JSON in --body: ${options.body}`);
415
562
  }
416
563
  }
417
- const data = await client.call(endpointKey, body, options.query);
418
564
  if (endpoint.kind === "download") {
419
- await saveDownloadResult(data, "download.bin", options.output);
565
+ await runDownload(client, endpointKey, options.query, {
566
+ output: options.output,
567
+ fallbackName: "download.bin",
568
+ });
420
569
  return;
421
570
  }
571
+ const data = await client.call(endpointKey, body, options.query);
422
572
  await printData(data, parseOutputFormat(options.format), options.output);
423
573
  }));
424
574
  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;