gangtise-openapi-cli 0.23.0 → 0.24.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,26 @@
4
4
 
5
5
  ## Changelog
6
6
 
7
+ ### v0.24.0 — 2026-07-07
8
+
9
+ **新增**
10
+ - `raw list` — 列出所有已注册的 endpoint key(含 method / path / description),配合 `raw call <key>` 使用,不必再翻文档记 key;支持 `--format`(默认 table)/ `--output`
11
+ - AI 同步生成端点内置 120s 超时下限(`one-pager` / `investment-logic` / `peer-comparison` / `theme-tracking` / `research-outline` / `management-discuss-announcement` / `management-discuss-earnings-call`)——生成耗时长不再撞 30s 默认超时触发重试,**不必再手动前缀 `GANGTISE_TIMEOUT_MS`**;显式设更大值仍生效(取 max)
12
+ - 429 响应尊重 `Retry-After`(秒或 HTTP-date;覆盖 JSON、非 JSON、下载三类错误路径),优先于默认指数退避,封顶 60s 防挂死
13
+ - 超大结果(≥5 万行且走非流式渲染:table/json/markdown,或 jsonl/csv 未带 `--output`)在 stderr 提示改用 `--format jsonl --output <path>` 流式落盘
14
+
15
+ **性能**
16
+ - JSON 请求启用 gzip(`accept-encoding: gzip` + 本地解压)——实测 `reference constant-list` 2110B→586B(3.6x),K 线类高重复大 JSON 收益更高;下载二进制路径不变
17
+ - 全市场按日分片(`quote fund-flow` / `day-kline` / `day-kline-us`,均 1 天/片)自动跳过周六日(A/港/美股周末闭市必空),省 ~28% 请求与每日调用配额;多日分片(`day-kline-hk` 2 天、`index-day-kline` 30 天)不受影响
18
+
19
+ **修复**
20
+ - 表格(table/markdown)显示宽度纳入 emoji 码位区(0x1F000–0x1FAFF),含 emoji 的微信群名/消息不再错位
21
+ - `fundamental earning-forecast` 默认 `--end-date`("today")改用运行机器本地日期;此前用 UTC 日期,CST 凌晨 0–8 点会算成"昨天"
22
+
23
+ **文档 / 工程**(不影响已发布 CLI 行为)
24
+ - `insight announcement`(A 股公告)时间过滤时区说明:`--start-time`/`--end-time` 按运行机器时区换算,跨机器精确边界改传 13 位毫秒时间戳
25
+ - CI 测试矩阵增加 Node 24(此前仅 20;发布用 24)
26
+
7
27
  ### v0.23.0 — 2026-07-05
8
28
 
9
29
  **行为变更(注意)**
@@ -93,38 +113,6 @@
93
113
  **修复(indicator 适配 EDE 后台新结构)**
94
114
  - `indicator cross-section` / `time-series` 适配后台改版的返回结构(字段名加 `List` 后缀 `securityCodeList/indicatorCodeList/…`、截面 `values` 改二维 `[指标][证券]`):此前后台改结构后 CLI 拍平失配、退化成原始矩阵,现恢复 `{date, security, name, 指标:值}` 宽表。配合后台同步变化——无数据从 `999999` 报错改为返回 `null`(截面不再 500、不丢行),缺必填参数从笼统 `410106` 改为直接指明缺哪个参数
95
115
 
96
- ### v0.19.0 — 2026-06-24
97
-
98
- **新增接口(Indicator · 证券级数据指标 EDE)**
99
- - `indicator search` — 按名称搜索证券级数据指标,返回 `indicatorCode` 及可传参数 `parameterList`(含 `required` 必填标记与枚举);取数前必先 search 拿 code,绝不猜编码
100
- - `indicator cross-section` — 指标截面数据(多指标 × 多证券,单日快照):`--indicator` / `--security`(均可重复)/ `--date` / `--currency` / `--scale` / `--indicator-param`
101
- - `indicator time-series` — 指标时间序列(多指标 × 单证券 或 单指标 × 多证券,按区间):另有 `--start-date` / `--end-date` / `--calendar-type`(`ND`/`TD`/`WD`)
102
- - 复权等指标专属参数用 `--indicator-param "code:key=value"`,参数 key 与取值以 search 的 `parameterList` 为准(行情复权键为 `adjustmentType`:`1` 不复权 / `2` 前复权 / `3` 后复权)
103
- - 很多指标有必填参数,默认调用会报 `410106`(缺必填参数):N 期统计补 `periodNum`、区间/周期类补 `startDate`、年度/分红类补 `fiscalYear`;`999999` 多为「该证券公司类型/报告期无数据」而非系统故障。详见 `gangtise-openapi/references/commands/indicator.md`
104
-
105
- **修复**
106
- - `vault stock-pool-stocks --pool-id <id>` 过滤失效:此前因选项默认值 `["all"]` 泄漏,传具体 pool id 仍返回全部股票池证券;现已修复——传 id 精确过滤,省略则默认全量
107
- - `auth` 缺凭证报错补充跨 shell(bash/zsh/fish)的 `export` 提示
108
-
109
- **文档**
110
- - README / SKILL 补充 indicator 命令组与取数最佳实践;`official-account` 命令文档补全
111
-
112
- ### v0.18.0 — 2026-06-17
113
-
114
- **新增接口(Insight · 产业公众号资讯)**
115
- - `insight official-account list` — 查询公众号资讯列表:支持 `--keyword`(需用数据中的具体词,非整句白话)/ `--account-id`(公众号 ID)/ `--security` / `--category`(文章类型枚举:`news`/`law`/`report`/`view`/`data`/`event`/`meeting`/`notice`/`recruit`/`investEdu`/`brand`/`notes`/`other`)/ `--industry`(`citicIndustry`/`swIndustry` 行业 ID)/ `--search-type`(`1` 标题 / `2` 全文)/ `--rank-type`(`1` 综合 / `2` 时间倒序);返回含模型生成摘要 `summary` 及关联行业/题材/证券列表
116
- - `insight official-account download --article-id <id>` — 下载公众号文章:`--file-type 1` txt(默认)/ `2` HTML
117
-
118
- ### v0.17.0 — 2026-06-15
119
-
120
- **接口变更(Breaking)**
121
- - 日程类命令(`roadshow` / `site-visit` / `strategy` / `forum` list)改为各自只暴露 API spec 支持的筛选选项,移除原先一刀切多出的无效选项:`strategy` 仅保留 `--institution` / `--location`;`forum` 仅保留 `--research-area` / `--location`;`site-visit` 移除 `--participant-role` / `--broker-type`;`roadshow` 移除 `--object`。传不支持的选项现由 commander 直接报 `unknown option`(此前会静默发送、服务端返回空结果)
122
- - `insight announcement list` 移除无效的 `--announcement-type`(服务端忽略、恒返全量);A 股公告分类筛选用 `--category`(`aShareAnnouncementCategory` 常量 ID)
123
-
124
- **说明 / 修正**
125
- - `--industry` 用 `citicIndustry` 码(`1008001xx`,全命令通用);`--research-area` 用 `gangtiseIndustry` 码(行业 `1008001xx` + 宏观/策略/固收/金工/海外等方向 `122000xxx`)。详见 `gangtise-openapi/references/commands/reference-and-lookup.md`
126
- - 日程类 `--location`(domesticCity)服务端过滤已生效(v0.16.0 时曾未生效)
127
-
128
116
  > 更早版本及完整更新历史见 [CHANGELOG.md](CHANGELOG.md)。
129
117
 
130
118
  ## 首次安装
@@ -315,7 +303,7 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
315
303
  - `gangtise indicator ...`
316
304
  - `gangtise alternative ...`
317
305
  - `gangtise reference ...`
318
- - `gangtise raw call ...`
306
+ - `gangtise raw call ...` / `gangtise raw list`
319
307
 
320
308
  ## 推荐工作流
321
309
 
@@ -696,6 +684,10 @@ gangtise alternative concept-securities --concept-id 121000130 --format json
696
684
  ### Raw
697
685
 
698
686
  ```bash
687
+ # 先列出所有 endpoint key(配合 raw call,不必翻文档记 key)
688
+ gangtise raw list
689
+ gangtise raw list --format json # key / method / path / description
690
+
699
691
  gangtise raw call insight.opinion.list --body '{"from":0,"size":120}'
700
692
  ```
701
693
 
package/dist/src/cli.js CHANGED
@@ -2,13 +2,13 @@
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, maybeArray, parseFrom, parseNumberOption, parseOptionalNumberOption, parseSize, parseTimestamp13 } from "./core/args.js";
5
+ import { collectKeyValue, collectList, collectNumberList, localDateString, maybeArray, parseFrom, parseNumberOption, parseOptionalNumberOption, parseSize, parseTimestamp13 } from "./core/args.js";
6
6
  import { buildIndicatorCrossSectionBody, buildIndicatorTimeSeriesBody, buildQuoteKlineBody, buildStockPoolStocksBody, buildWechatChatroomListBody, buildWechatMessageListBody } from "./core/commandBodies.js";
7
7
  import { flattenCrossSection, flattenTimeSeries, unwrapIndicatorData } from "./core/indicatorMatrix.js";
8
8
  import { callKlineWithSharding, isAllMarket, isFullMarket } from "./core/quoteSharding.js";
9
9
  import { loadConfig } from "./core/config.js";
10
10
  import { resolveTitle, saveDownloadResult, uniquePath } from "./core/download.js";
11
- import { ENDPOINTS } from "./core/endpoints.js";
11
+ import { ENDPOINTS, listEndpoints } from "./core/endpoints.js";
12
12
  import { ApiError, ConfigError, ValidationError } from "./core/errors.js";
13
13
  import { normalizeRows } from "./core/normalize.js";
14
14
  import { parseOutputFormat } from "./core/output.js";
@@ -428,7 +428,7 @@ fundamental.command("valuation-analysis").requiredOption("--security-code <code>
428
428
  }));
429
429
  fundamental.command("top-holders").requiredOption("--security-code <code>").addOption(new Option("--holder-type <type>", "Holder type: top10/top10Float").choices(["top10", "top10Float"]).makeOptionMandatory()).option("--start-date <date>").option("--end-date <date>").option("--fiscal-year <year>", "Fiscal year", collectList, []).option("--period <period>", "Period: q1/interim/q3/annual/latest", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("fundamental.top-holders", { securityCode: options.securityCode, holderType: options.holderType, startDate: options.startDate, endDate: options.endDate, fiscalYear: maybeArray(options.fiscalYear), period: options.period.length ? options.period : undefined })));
430
430
  fundamental.command("earning-forecast").requiredOption("--security-code <code>").option("--start-date <date>", "Start date (default: 1 year before end-date)").option("--end-date <date>", "End date (default: today)").option("--consensus <name>", "Consensus indicator: netIncome/netIncomeYoy/eps/pe/bps/pb/peg/roe/ps", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => {
431
- const endDate = options.endDate ?? new Date().toISOString().slice(0, 10);
431
+ const endDate = options.endDate ?? localDateString(new Date());
432
432
  // Anchor the default window to endDate (as the help text promises), not to today —
433
433
  // a historical --end-date without --start-date should mean "the year before it".
434
434
  const startDate = options.startDate ?? new Date(new Date(`${endDate}T00:00:00Z`).getTime() - 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
@@ -653,7 +653,7 @@ program.command("raw").description("Raw API calls").addCommand(new Command("call
653
653
  }
654
654
  const data = await client.call(endpointKey, body);
655
655
  await printData(data, format, options.output);
656
- }));
656
+ })).addCommand(new Command("list").description("List all registered endpoint keys (for use with 'raw call')").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => printData(listEndpoints(), parseOutputFormat(options.format), options.output)));
657
657
  async function checkForUpdate(timeoutMs = 2000) {
658
658
  try {
659
659
  const response = await fetch("https://registry.npmjs.org/gangtise-openapi-cli/latest", { signal: AbortSignal.timeout(timeoutMs) });
@@ -94,6 +94,15 @@ export function parseTimestamp13(value, optionName) {
94
94
  }
95
95
  return parsed;
96
96
  }
97
+ /** Machine-local calendar date as `yyyy-MM-dd`, for CLI "default: today" options.
98
+ * `new Date().toISOString().slice(0,10)` renders the UTC day — for CST users a
99
+ * pre-08:00 "today" resolves to yesterday. Anchoring to local components matches
100
+ * toTimestamp13's local-midnight convention. */
101
+ export function localDateString(d) {
102
+ const month = String(d.getMonth() + 1).padStart(2, "0");
103
+ const day = String(d.getDate()).padStart(2, "0");
104
+ return `${d.getFullYear()}-${month}-${day}`;
105
+ }
97
106
  // Parse repeatable `--indicator-param "code:key=value"` specs into the nested
98
107
  // indicatorParamList the EDE cross-section / time-series endpoints expect.
99
108
  // Multiple specs for the same code accumulate into one group, first-seen order.
@@ -5,9 +5,9 @@ import { pipeline } from "node:stream/promises";
5
5
  import { request } from "undici";
6
6
  import { isTokenCacheValid, normalizeToken, readTokenCache, requireAccessCredentials, writeTokenCache } from "./auth.js";
7
7
  import { ApiError, ValidationError } from "./errors.js";
8
- import { ENDPOINTS } from "./endpoints.js";
8
+ import { ENDPOINTS, resolveTimeoutMs } from "./endpoints.js";
9
9
  import { getLookupData } from "./lookupData/index.js";
10
- import { getDispatcher, isVerbose, logTiming, markRetryable, PAGE_CONCURRENCY, runWithConcurrency, withRetry } from "./transport.js";
10
+ import { decodeResponseBody, getDispatcher, isVerbose, logTiming, markRetryable, PAGE_CONCURRENCY, parseRetryAfterMs, runWithConcurrency, withRetry } from "./transport.js";
11
11
  // Auth errors that warrant a forced re-login + one replay. 8000014/8000015 are
12
12
  // AK/SK errors; 0000001008 is a server-side token invalidation (the token still
13
13
  // looks valid by local expiry, so only a forced refresh recovers it).
@@ -119,12 +119,12 @@ export class GangtiseClient {
119
119
  return false;
120
120
  return 'msg' in obj || 'data' in obj || 'success' in obj || 'status' in obj;
121
121
  }
122
- throwHttpError(parsed, statusCode) {
122
+ throwHttpError(parsed, statusCode, retryAfterMs) {
123
123
  if (this.isEnvelope(parsed)) {
124
124
  const code = parsed.code === undefined ? undefined : String(parsed.code);
125
- throw new ApiError(parsed.msg || `API request failed (HTTP ${statusCode})`, code, statusCode, parsed);
125
+ throw new ApiError(parsed.msg || `API request failed (HTTP ${statusCode})`, code, statusCode, parsed, retryAfterMs);
126
126
  }
127
- throw new ApiError(`API request failed (HTTP ${statusCode})`, undefined, statusCode, parsed);
127
+ throw new ApiError(`API request failed (HTTP ${statusCode})`, undefined, statusCode, parsed, retryAfterMs);
128
128
  }
129
129
  unwrapEnvelope(parsed, statusCode) {
130
130
  if (!this.isEnvelope(parsed)) {
@@ -309,9 +309,13 @@ export class GangtiseClient {
309
309
  const dispatcher = getDispatcher();
310
310
  const url = this.buildUrl(endpoint.path);
311
311
  const authState = { retried: false };
312
+ const timeoutMs = resolveTimeoutMs(this.config.timeoutMs, endpoint);
312
313
  return withRetry(async () => {
313
314
  const headers = {
314
315
  'content-type': 'application/json',
316
+ // undici does not auto-decompress; decodeResponseBody gunzips below. Server
317
+ // gzip cuts JSON payloads ~3-10x (measured 3.6x on constant-list).
318
+ 'accept-encoding': 'gzip',
315
319
  };
316
320
  // Keep the header we actually sent: the self-heal check compares it against
317
321
  // the current memoCache token to tell "stale token" from "fresh token died".
@@ -325,12 +329,22 @@ export class GangtiseClient {
325
329
  method: endpoint.method,
326
330
  headers,
327
331
  body: endpoint.method === 'GET' ? undefined : JSON.stringify(body ?? {}),
328
- headersTimeout: this.config.timeoutMs,
329
- bodyTimeout: this.config.timeoutMs,
332
+ headersTimeout: timeoutMs,
333
+ bodyTimeout: timeoutMs,
330
334
  dispatcher,
331
335
  });
332
- const text = await response.body.text();
336
+ // Only buffer + gunzip when the server actually compressed; an unencoded
337
+ // response reads as text directly (and keeps existing behavior on that path).
338
+ const encoding = response.headers['content-encoding'];
339
+ const gzipped = (Array.isArray(encoding) ? encoding[0] : encoding)?.toLowerCase().trim() === 'gzip';
340
+ const text = gzipped
341
+ ? decodeResponseBody(new Uint8Array(await response.body.arrayBuffer()), encoding)
342
+ : await response.body.text();
333
343
  logTiming(`${endpoint.method} ${endpoint.path}`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
344
+ // Parse Retry-After once so every error path below (JSON parse failure AND the
345
+ // envelope/HTTP-error throw) carries it — a non-JSON 429/503 must still honor
346
+ // the server's rate window instead of falling back to default backoff.
347
+ const retryAfterMs = parseRetryAfterMs(response.headers['retry-after'], Date.now());
334
348
  let parsed;
335
349
  try {
336
350
  parsed = JSON.parse(text);
@@ -339,13 +353,13 @@ export class GangtiseClient {
339
353
  const message = response.statusCode >= 400
340
354
  ? `API request failed (HTTP ${response.statusCode})`
341
355
  : 'Failed to parse API response';
342
- throw new ApiError(message, undefined, response.statusCode, text.slice(0, 500));
356
+ throw new ApiError(message, undefined, response.statusCode, text.slice(0, 500), retryAfterMs);
343
357
  }
344
358
  try {
345
359
  // Auth errors can arrive as HTTP 4xx or as a 200-wrapped error envelope;
346
360
  // both routes must reach the self-heal check below.
347
361
  if (response.statusCode >= 400) {
348
- this.throwHttpError(parsed, response.statusCode);
362
+ this.throwHttpError(parsed, response.statusCode, retryAfterMs);
349
363
  }
350
364
  return this.unwrapEnvelope(parsed, response.statusCode);
351
365
  }
@@ -404,6 +418,9 @@ export class GangtiseClient {
404
418
  });
405
419
  }
406
420
  const contentType = Array.isArray(response.headers['content-type']) ? response.headers['content-type'][0] : response.headers['content-type'];
421
+ // From the final (post-redirect) response, so a rate-limited download honors
422
+ // Retry-After too — every error branch below passes it into the ApiError.
423
+ const retryAfterMs = parseRetryAfterMs(response.headers['retry-after'], Date.now());
407
424
  if (contentType?.includes('application/json')) {
408
425
  const text = await response.body.text();
409
426
  logTiming(`GET ${endpoint.path} (json)`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
@@ -413,14 +430,14 @@ export class GangtiseClient {
413
430
  }
414
431
  catch {
415
432
  if (response.statusCode >= 400) {
416
- throw new ApiError('Download failed', undefined, response.statusCode, text);
433
+ throw new ApiError('Download failed', undefined, response.statusCode, text, retryAfterMs);
417
434
  }
418
435
  return { text, contentType };
419
436
  }
420
437
  let data;
421
438
  try {
422
439
  if (response.statusCode >= 400) {
423
- this.throwHttpError(parsed, response.statusCode);
440
+ this.throwHttpError(parsed, response.statusCode, retryAfterMs);
424
441
  }
425
442
  data = this.unwrapEnvelope(parsed, response.statusCode);
426
443
  }
@@ -437,13 +454,13 @@ export class GangtiseClient {
437
454
  const text = await response.body.text();
438
455
  logTiming(`GET ${endpoint.path} (text)`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
439
456
  if (response.statusCode >= 400) {
440
- throw new ApiError('Download failed', undefined, response.statusCode, text);
457
+ throw new ApiError('Download failed', undefined, response.statusCode, text, retryAfterMs);
441
458
  }
442
459
  return { text, contentType };
443
460
  }
444
461
  if (response.statusCode >= 400) {
445
462
  const text = await response.body.text();
446
- throw new ApiError('Download failed', undefined, response.statusCode, text);
463
+ throw new ApiError('Download failed', undefined, response.statusCode, text, retryAfterMs);
447
464
  }
448
465
  const contentDisposition = response.headers['content-disposition'];
449
466
  const filenameMatch = Array.isArray(contentDisposition)
@@ -1,3 +1,8 @@
1
+ /** Effective request timeout: the endpoint's floor, or the config timeout if higher
2
+ * (a user who raised GANGTISE_TIMEOUT_MS keeps their value). */
3
+ export function resolveTimeoutMs(configTimeoutMs, endpoint) {
4
+ return Math.max(configTimeoutMs, endpoint.timeoutMs ?? 0);
5
+ }
1
6
  // Registry entries omit `key`: it is derived from the record key when ENDPOINTS is
2
7
  // built below, so the two can never drift.
3
8
  const ENDPOINT_DEFS = {
@@ -382,18 +387,21 @@ const ENDPOINT_DEFS = {
382
387
  path: "/application/open-ai/agent/one-pager",
383
388
  kind: "json",
384
389
  description: "Generate one pager",
390
+ timeoutMs: 120_000,
385
391
  },
386
392
  "ai.investment-logic": {
387
393
  method: "POST",
388
394
  path: "/application/open-ai/agent/investment-logic",
389
395
  kind: "json",
390
396
  description: "Generate investment logic",
397
+ timeoutMs: 120_000,
391
398
  },
392
399
  "ai.peer-comparison": {
393
400
  method: "POST",
394
401
  path: "/application/open-ai/agent/peer-comparison",
395
402
  kind: "json",
396
403
  description: "Generate peer comparison",
404
+ timeoutMs: 120_000,
397
405
  },
398
406
  "ai.earnings-review.get-id": {
399
407
  method: "POST",
@@ -412,12 +420,14 @@ const ENDPOINT_DEFS = {
412
420
  path: "/application/open-ai/agent/theme-tracking",
413
421
  kind: "json",
414
422
  description: "Get theme tracking daily report",
423
+ timeoutMs: 120_000,
415
424
  },
416
425
  "ai.research-outline": {
417
426
  method: "POST",
418
427
  path: "/application/open-ai/agent/research-outline",
419
428
  kind: "json",
420
429
  description: "Get company research outline",
430
+ timeoutMs: 120_000,
421
431
  },
422
432
  "ai.hot-topic": {
423
433
  method: "POST",
@@ -431,12 +441,14 @@ const ENDPOINT_DEFS = {
431
441
  path: "/application/open-ai/management-discuss/from-announcement",
432
442
  kind: "json",
433
443
  description: "Management discussion from financial reports (half-year/annual)",
444
+ timeoutMs: 120_000,
434
445
  },
435
446
  "ai.management-discuss-earnings-call": {
436
447
  method: "POST",
437
448
  path: "/application/open-ai/management-discuss/from-earningsCall",
438
449
  kind: "json",
439
450
  description: "Management discussion from earnings calls",
451
+ timeoutMs: 120_000,
440
452
  },
441
453
  "ai.viewpoint-debate.get-id": {
442
454
  method: "POST",
@@ -563,3 +575,8 @@ const ENDPOINT_DEFS = {
563
575
  },
564
576
  };
565
577
  export const ENDPOINTS = Object.fromEntries(Object.entries(ENDPOINT_DEFS).map(([key, def]) => [key, { key, ...def }]));
578
+ /** Flat catalog of every registered endpoint, for `raw list` discoverability
579
+ * (so `raw call` doesn't require memorizing endpoint keys). */
580
+ export function listEndpoints() {
581
+ return Object.values(ENDPOINTS).map(({ key, method, path, description }) => ({ key, method, path, description }));
582
+ }
@@ -34,12 +34,17 @@ export class ApiError extends CliError {
34
34
  code;
35
35
  statusCode;
36
36
  details;
37
+ retryAfterMs;
37
38
  hint;
38
- constructor(message, code, statusCode, details) {
39
+ constructor(message, code, statusCode, details,
40
+ /** Server-specified Retry-After (ms), set on 429 responses so the transport
41
+ * backoff can honor it instead of the default exponential schedule. */
42
+ retryAfterMs) {
39
43
  super(message);
40
44
  this.code = code;
41
45
  this.statusCode = statusCode;
42
46
  this.details = details;
47
+ this.retryAfterMs = retryAfterMs;
43
48
  this.hint = code ? ERROR_HINTS[code] : undefined;
44
49
  }
45
50
  }
@@ -14,8 +14,9 @@ export function parseOutputFormat(value) {
14
14
  function sanitizeCell(value) {
15
15
  return value.replace(/[\r\n]+/g, " ").replace(/[\u0000-\u001f\u007f]/g, "");
16
16
  }
17
- /** Terminal display width: CJK/fullwidth chars occupy 2 columns — padEnd counts
18
- * UTF-16 code units and misaligns every table containing Chinese text. */
17
+ /** Terminal display width: CJK/fullwidth chars and emoji occupy 2 columns — padEnd
18
+ * counts UTF-16 code units and misaligns every table containing Chinese text or emoji
19
+ * (e.g. WeChat group names). */
19
20
  function displayWidth(value) {
20
21
  let width = 0;
21
22
  for (const ch of value) {
@@ -23,7 +24,8 @@ function displayWidth(value) {
23
24
  const wide = (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf)
24
25
  || (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff)
25
26
  || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60)
26
- || (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x20000 && cp <= 0x3fffd);
27
+ || (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x1f000 && cp <= 0x1faff)
28
+ || (cp >= 0x20000 && cp <= 0x3fffd);
27
29
  width += wide ? 2 : 1;
28
30
  }
29
31
  return width;
@@ -1,6 +1,18 @@
1
1
  import { normalizeRows } from "./normalize.js";
2
2
  import { pickList, renderOutput, saveOutputIfNeeded, streamOutputToFile } from "./output.js";
3
3
  import { extractTitles, writeTitleCache } from "./titleCache.js";
4
+ /** Rows above which renderOutput's single in-memory string risks high memory / the V8
5
+ * max-string-length cap. Well above normal result sizes, so it only fires on huge exports. */
6
+ const LARGE_RESULT_ROWS = 50_000;
7
+ /** Warn when we're about to renderOutput a huge result. Called only on the paths that
8
+ * actually render — never after streamOutputToFile streamed — so it can't misfire on a
9
+ * genuinely streamed export, and it DOES fire on the all-scalar-csv list that streaming
10
+ * declines (which then falls back to a full in-memory string). */
11
+ function warnIfLargeInMemory(items, format) {
12
+ if (items && items.length >= LARGE_RESULT_ROWS) {
13
+ process.stderr.write(`[gangtise] note: ${items.length} rows in '${format}' is built entirely in memory; stream large exports to a file with --format jsonl --output <path> (or csv).\n`);
14
+ }
15
+ }
4
16
  export async function printData(data, format, output, cache) {
5
17
  const normalized = normalizeRows(data);
6
18
  const items = pickList(normalized);
@@ -28,6 +40,9 @@ export async function printData(data, format, output, cache) {
28
40
  process.stdout.write(`${output}\n`);
29
41
  return;
30
42
  }
43
+ // streamOutputToFile declined (non-stream format, or an all-scalar csv list) \u2192 we
44
+ // fall back to renderOutput, which builds the whole result as one string.
45
+ warnIfLargeInMemory(items, format);
31
46
  const content = renderOutput(normalized, format);
32
47
  // CSV files get a BOM so Excel double-click decodes Chinese as UTF-8 (stdout
33
48
  // stays BOM-free for pipes).
@@ -35,5 +50,6 @@ export async function printData(data, format, output, cache) {
35
50
  process.stdout.write(`${output}\n`);
36
51
  return;
37
52
  }
53
+ warnIfLargeInMemory(items, format);
38
54
  process.stdout.write(`${renderOutput(normalized, format)}\n`);
39
55
  }
@@ -27,16 +27,28 @@ export function isFullMarket(body, fullMarketValue) {
27
27
  export function isAllMarket(body) {
28
28
  return isFullMarket(body, "all");
29
29
  }
30
+ /** Sat/Sun in UTC — shard dates are formatted from UTC midnight (see parseDate). */
31
+ function isWeekendUtc(d) {
32
+ const day = d.getUTCDay();
33
+ return day === 0 || day === 6;
34
+ }
30
35
  function buildShards(start, end, shardDays) {
31
36
  const shards = [];
32
37
  let cursor = start.getTime();
33
38
  const endTime = end.getTime();
34
39
  while (cursor <= endTime) {
35
40
  const shardEnd = Math.min(cursor + (shardDays - 1) * DAY_MS, endTime);
36
- shards.push({
37
- startDate: formatDate(new Date(cursor)),
38
- endDate: formatDate(new Date(shardEnd)),
39
- });
41
+ // Per-day sharding (shardDays===1): a lone weekend day always returns empty (A/HK/US
42
+ // markets closed) — skip it to save ~28% of requests and daily quota. This covers
43
+ // every 1-day-sharded full-market query: fund-flow AND day-kline / day-kline-us
44
+ // (both shardDays 1 in cli.ts). Multi-day shards (day-kline-hk=2, index=30) always
45
+ // straddle weekdays, so they're never dropped.
46
+ if (!(shardDays === 1 && isWeekendUtc(new Date(cursor)))) {
47
+ shards.push({
48
+ startDate: formatDate(new Date(cursor)),
49
+ endDate: formatDate(new Date(shardEnd)),
50
+ });
51
+ }
40
52
  cursor = shardEnd + DAY_MS;
41
53
  }
42
54
  return shards;
@@ -93,6 +105,11 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
93
105
  return callSingle();
94
106
  }
95
107
  const shards = buildShards(start, end, config.shardDays);
108
+ // A range that's entirely weekends (per-day sharding) filters down to nothing — no
109
+ // trading days to fetch. Return empty rather than falling into the all-shards-failed
110
+ // check below, which would misread 0 shards as a total outage and throw.
111
+ if (shards.length === 0)
112
+ return { list: [] };
96
113
  // isVerbose() (not a direct env read) so the global --verbose flag reaches
97
114
  // shard logging too — cli.ts enables it via setVerbose in a preAction hook.
98
115
  if (isVerbose()) {
@@ -1,5 +1,38 @@
1
+ import { gunzipSync } from "node:zlib";
1
2
  import { Agent } from "undici";
2
3
  import { ApiError } from "./errors.js";
4
+ /** Decode an HTTP response body honoring Content-Encoding. undici does not
5
+ * auto-decompress, so requestJson advertises `accept-encoding: gzip` and gunzips
6
+ * here; an unencoded body is read as-is. */
7
+ export function decodeResponseBody(buf, contentEncoding) {
8
+ const enc = (Array.isArray(contentEncoding) ? contentEncoding[0] : contentEncoding)?.toLowerCase().trim();
9
+ const b = Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
10
+ return enc === "gzip" ? gunzipSync(b).toString("utf8") : b.toString("utf8");
11
+ }
12
+ /** A Retry-After delay we'll honor even if it exceeds maxDelay — but never past this
13
+ * ceiling, so a hostile/misconfigured header can't hang the CLI for minutes. */
14
+ const RETRY_AFTER_CEILING_MS = 60_000;
15
+ /** Parse a Retry-After header (delta-seconds or an HTTP-date) into a delay in ms.
16
+ * Returns undefined when absent or unparseable. `nowMs` is injected for testing. */
17
+ export function parseRetryAfterMs(value, nowMs) {
18
+ const raw = (Array.isArray(value) ? value[0] : value)?.trim();
19
+ if (!raw)
20
+ return undefined;
21
+ if (/^\d+$/.test(raw))
22
+ return Number(raw) * 1000;
23
+ const dateMs = Date.parse(raw);
24
+ return Number.isNaN(dateMs) ? undefined : Math.max(0, dateMs - nowMs);
25
+ }
26
+ /** A retryable error may carry a server-specified Retry-After (attached by the
27
+ * client on a 429); prefer it over the computed backoff. */
28
+ function retryAfterFromError(error) {
29
+ if (error && typeof error === "object") {
30
+ const v = error.retryAfterMs;
31
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0)
32
+ return v;
33
+ }
34
+ return undefined;
35
+ }
3
36
  let cachedDispatcher = null;
4
37
  export function getDispatcher() {
5
38
  if (!cachedDispatcher) {
@@ -70,8 +103,12 @@ export async function withRetry(fn, options = {}) {
70
103
  catch (error) {
71
104
  if (attempt >= retries || !isRetryableError(error))
72
105
  throw error;
73
- const jitter = Math.random() * baseDelay;
74
- const delay = Math.min(maxDelay, baseDelay * 2 ** attempt + jitter);
106
+ // A server-sent Retry-After (429) wins over exponential backoff, but is capped
107
+ // so it can't stall the CLI; otherwise fall back to jittered exponential backoff.
108
+ const retryAfter = retryAfterFromError(error);
109
+ const delay = retryAfter !== undefined
110
+ ? Math.min(retryAfter, RETRY_AFTER_CEILING_MS)
111
+ : Math.min(maxDelay, baseDelay * 2 ** attempt + Math.random() * baseDelay);
75
112
  options.onRetry?.(attempt + 1, error, delay);
76
113
  await new Promise(resolve => setTimeout(resolve, delay));
77
114
  attempt++;
@@ -1,2 +1,2 @@
1
1
  // Auto-generated — DO NOT EDIT
2
- export const CLI_VERSION = "0.23.0";
2
+ export const CLI_VERSION = "0.24.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gangtise-openapi-cli",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "CLI for Gangtise OpenAPI",
5
5
  "license": "MIT",
6
6
  "repository": {