gangtise-openapi-cli 0.21.0 → 0.22.1

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,39 @@
4
4
 
5
5
  ## Changelog
6
6
 
7
+ ### v0.22.1 — 2026-07-03
8
+
9
+ **修复**
10
+ - 错误码 `410004` 提示改为中性措辞「数据未找到或无指标权限,请检查查询条件与指标权限」——此前只说"数据未找到",与 `indicator` 内层信封的"无权限"消息拼接后自相矛盾
11
+
12
+ **文档 / Skill**(随 `/sync-skill` 分发,不影响 CLI 行为)
13
+ - gangtise-openapi Agent Skill 经 fable5 审计 + 多轮 review 优化:官方积分计费速查表 + 高积分 pre-flight 闸门、AI 同步生成命令 `GANGTISE_TIMEOUT_MS=120000` 超时前置、大结果集 `--output` 落盘、异步 `--wait` 主路径、行业码口径收敛到单一权威、市值量纲实测(`qte_mkt_cptl` 仅 A 股 / 默认原始「元」/ `scale`+`currency`)等文档补全与消歧
14
+
15
+ ### v0.22.0 — 2026-07-02
16
+
17
+ **行为变更(注意)**
18
+ - ⚠️ 自动翻页接口省略 `--size` 现在一律拉全量(不再区分是否传时间范围);需要只取前 N 条时请显式传 `--size N`。数据量未知时可先用 `--size 1` 从 stderr 的 `Total: N` 探明量级
19
+ - 部分结果可机器识别:翻页页失败、K 线分片失败、或服务端提前短页但仍报告更大 `total` 时,结果会带 `partial: true`(页失败另有 `failedPages`,分片为 `failedShards`),非 json 行式输出仍只输出数据行,但进程退出码为 3
20
+
21
+ **修复(鉴权 / 请求可靠性)**
22
+ - Token 自愈覆盖服务端 `0000001008` 踢线失效,并能处理 HTTP 4xx 错误信封;`GANGTISE_TOKEN` + AK/SK 场景下环境 token 失效后不再反复回放旧 token
23
+ - 并发请求同时遇到旧 token 失效时复用一次刷新结果;若刚拿到的新 token 本身被踢掉,则强制再次登录,避免"刚登录窗口期"误跳过刷新
24
+ - 自动重试范围扩展到 429、DNS/网络临时错误与 undici 超时类错误;`GANGTISE_BASE_URL` 带路径前缀时 URL 拼接不再丢前缀
25
+
26
+ **修复(下载 / 输出 / 数据正确性)**
27
+ - 下载接口跟随最多 3 次 30x 跳转;跨域跳到对象存储签名 URL 时不携带 Authorization;服务端返回 `{url}` 且用户传 `--output` 时会真正下载文件,而不是把 URL 字符串写进文件
28
+ - 自动文件名补齐清洗、截断与去重:服务端文件名、标题缓存名和 fallback 名都不会把 `/`、控制字符、过长中文名或重复标题变成路径/覆盖问题
29
+ - `table`/`markdown` 输出清理控制字符、正确按 CJK 宽字符对齐,并转义 markdown 表头中的 `|`;CSV 输出转义表头、文件输出带 UTF-8 BOM,流式 CSV 遇全标量列表时回退到正常渲染而不是只写 BOM
30
+ - `indicator search` / `cross-section` / `time-series` 的内层失败信封即使没有 `data` 字段也会抛出 `ApiError`,不再把"无权限/参数错误"渲染成成功结果
31
+ - `--indicator-param` 等逗号列表支持全角逗号 `,`;日期型时间参数按本地零点解析,避免 `yyyy-MM-dd` 被当作 UTC 造成查询窗口偏移
32
+ - `fundamental earning-forecast` 省略 `--start-date` 时按传入的 `--end-date` 往前一年计算,不再总是按今天往前一年
33
+ - AI 异步 `--wait` 对 `410111` 终态失败只提示"不要重试",超时才提示稍后用 check 命令查询;等待说明同步为最长约 5 分钟
34
+
35
+ **CLI / 工程**
36
+ - `raw call` 会在本地拒绝 JSON endpoint 的 `--query` 和 download endpoint 的 `--body`,避免静默丢参数;`--format` 在发请求前校验,格式拼错不再先消耗接口调用
37
+ - `gangtise ... | head` 遇 stdout `EPIPE` 时安静退出;只有首个参数是 `--version` / `-V` 时才触发版本快捷路径
38
+ - Endpoint registry 的 `key` 改为由记录键自动派生,减少映射漂移;新增真实 CLI 选项到请求体的 stub 测试;测试 272 → 323
39
+
7
40
  ### v0.21.0 — 2026-06-29
8
41
 
9
42
  **行为变更(注意)**
@@ -123,9 +156,10 @@ export GANGTISE_TOKEN="Bearer xxx"
123
156
  export GANGTISE_PAGE_CONCURRENCY=5 # 翻页并发数(默认 5)
124
157
  export GANGTISE_VERBOSE=1 # 打印每个请求的耗时与字节数
125
158
  export GANGTISE_TIMEOUT_MS=30000 # 请求超时(默认 30s)
159
+ export GANGTISE_TOKEN_CACHE_PATH=... # 覆盖 token 缓存路径(默认 ~/.config/gangtise/token.json)
126
160
  ```
127
161
 
128
- 如果没有 `GANGTISE_TOKEN`,CLI 会自动调用 token 接口并缓存到本地(`~/.config/gangtise/token.json`,权限 0600)。Token 失效(8000014/8000015)时会自动重新登录并重试一次。
162
+ 如果没有 `GANGTISE_TOKEN`,CLI 会自动调用 token 接口并缓存到本地(`~/.config/gangtise/token.json`,权限 0600)。Token 失效(8000014/8000015/0000001008)时会自动重新登录并重试一次。
129
163
 
130
164
 
131
165
  ## AI Agent Skill
@@ -264,6 +298,7 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
264
298
  - `gangtise fundamental ...`
265
299
  - `gangtise ai ...`
266
300
  - `gangtise vault ...`
301
+ - `gangtise indicator ...`
267
302
  - `gangtise alternative ...`
268
303
  - `gangtise reference ...`
269
304
  - `gangtise raw call ...`
@@ -299,7 +334,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
299
334
  - **HTTP keep-alive**:所有请求复用同一个 `undici.Agent`(连接池 16),避免重复 TLS 握手。
300
335
  - **流式下载**:指定 `--output` 时,二进制响应(PDF 等)直接 `pipeline` 到磁盘,不经过内存缓冲;50MB PDF 内存占用近乎为零。
301
336
  - **流式输出**:`jsonl`/`csv` 格式且 `--output` 指定时,超过 1000 行自动切换为逐行写盘,避免一次性构建百 MB 字符串。
302
- - **自动重试**:5xx / `ECONNRESET` / `ETIMEDOUT` / `999999` 系统错误自动指数退避重试 2 次。
337
+ - **自动重试**:5xx / 429 / `ECONNRESET` / `ETIMEDOUT` / `ENOTFOUND` / `EAI_AGAIN` / `UND_ERR_*`(undici 超时类)/ `999999` 系统错误自动指数退避重试 2 次。
303
338
  - **Token 自愈**:调用返回 8000014/8000015 时自动强制刷新 Token 并重试一次。
304
339
  - **K线自动分片**:`quote day-kline --security all` 等全市场查询自动按日期切分(A股 1 天/片、美股 1 天/片、HK 2 天/片、指数 30 天/片),并发执行后合并结果。分片时如果用户未传 `--limit`,自动注入 `limit: 10000`(API 上限)避免默认 6000 截断。
305
340
  - **Token 内存缓存**:Token 在进程内存中缓存,避免每次请求读盘。
@@ -331,12 +366,13 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
331
366
  - `ai hot-topic`
332
367
 
333
368
  规则:
334
- - **有时间范围时**(传了 `--start-time/--end-time` 或 `--start-date/--end-date`):**省略 `--size`**,CLI 自动翻页查全
335
- - **无时间范围时**(未传时间参数):默认 `--size 200`,防止一次查询数据量过大
369
+ - **省略 `--size` 一律拉全量**(无论是否传时间范围),CLI 自动翻页查完
370
+ - 数据量未知时,可先 `--size 1` 从 stderr 的 `Total: N` 探明量级,再决定是否全量
336
371
  - 如果显式传了 `--size`,则按指定值翻页,直到达到 `size` 或数据取完
337
372
  - `--from` 必须是非负整数,`--size` 必须是正整数;非法数字会在本地直接报 `ValidationError`,不会继续请求 API
338
373
  - 安全上限:自动翻页最多 1000 页,防止异常循环
339
- - 分页结果中 `total` 字段会被保留(json 格式输出 `{total, list}`),同时 stderr 输出 `Total: N, showing: M`
374
+ - 部分页失败、或服务端实际返回行数与 `total` 矛盾(提前短页)时,不丢弃已取到的数据:结果带 `partial: true`(页失败时另有 `failedPages`;K线分片为 `failedShards`;`--format json` 可见),stderr 输出警告,**进程退出码为 3**(完整成功为 0)
375
+ - 分页结果中 `total` 字段会被保留(json 格式输出 `{total, list}`);其他格式下 stderr 输出 `Total: N, showing: M`(json 格式不输出该行)
340
376
  - `vault wechat-chatroom-list` 是特例:接口不返回 `total`,CLI 改为串行翻页——省略 `--size` 拉全量、传 `--size N` 取前 N 条,单页 50,无 `Total:` 提示
341
377
 
342
378
  ## 智能文件命名
@@ -361,11 +397,11 @@ gangtise auth status
361
397
  ### Insight
362
398
 
363
399
  ```bash
364
- # 有时间范围 → 省略 --size,自动查全
400
+ # 省略 --size → 自动翻页查全
365
401
  gangtise insight research list --start-time "2026-04-01 00:00:00" --end-time "2026-04-09 23:59:59"
366
402
 
367
- # 无时间范围 默认 --size 200
368
- gangtise insight research list --industry 100800126 --category company --llm-tag inDepth --rating buy
403
+ # 无时间范围也是拉全量;只要前 200 条就显式传 --size
404
+ gangtise insight research list --industry 100800126 --category company --llm-tag inDepth --rating buy --size 200
369
405
 
370
406
  # 多值 List 模式:一次查多家券商 + 多个行业 + 多个评级
371
407
  gangtise insight research list --broker C100000027 --broker C100000014 --industry 100800119 --industry 100800118 --rating buy --rating overweight --format json
@@ -543,7 +579,7 @@ gangtise ai management-discuss-earnings-call --report-date 2025-06-30 --security
543
579
  gangtise ai viewpoint-debate --viewpoint "飞天茅台的批价低点是1500元"
544
580
  # 等待生成完成后查询结果
545
581
  gangtise ai viewpoint-debate-check --data-id 202603310528
546
- # 也可以 --wait 同步等待结果(最长3分钟)
582
+ # 也可以 --wait 同步等待结果(最长约 5 分钟:14 次指数退避轮询,累计 ≈316s)
547
583
  gangtise ai viewpoint-debate --viewpoint "比亚迪股价将突破500元" --wait
548
584
  gangtise ai knowledge-resource-download --resource-type 60 --source-id 3052524 --output ./resource.txt
549
585
  ```
package/dist/src/cli.js CHANGED
@@ -7,7 +7,7 @@ import { buildIndicatorCrossSectionBody, buildIndicatorTimeSeriesBody, buildQuot
7
7
  import { flattenCrossSection, flattenTimeSeries, unwrapIndicatorData } from "./core/indicatorMatrix.js";
8
8
  import { callKlineWithSharding } from "./core/quoteSharding.js";
9
9
  import { loadConfig } from "./core/config.js";
10
- import { resolveTitle, saveDownloadResult } from "./core/download.js";
10
+ import { resolveTitle, saveDownloadResult, uniquePath } from "./core/download.js";
11
11
  import { ENDPOINTS } from "./core/endpoints.js";
12
12
  import { ApiError, ConfigError, ValidationError } from "./core/errors.js";
13
13
  import { normalizeRows } from "./core/normalize.js";
@@ -25,8 +25,11 @@ async function createClient() {
25
25
  * query command repeated.
26
26
  */
27
27
  async function emit(options, produce, cache) {
28
+ // Validate --format before fetching: a typo'd format must not burn a full
29
+ // (possibly credit-metered) data pull only to fail at render time.
30
+ const format = parseOutputFormat(options.format);
28
31
  const client = await createClient();
29
- await printData(await produce(client), parseOutputFormat(options.format), options.output, cache);
32
+ await printData(await produce(client), format, options.output, cache);
30
33
  }
31
34
  /** Acquire a client and run an arbitrary action (downloads, polling, custom shaping). */
32
35
  async function withClient(fn) {
@@ -45,7 +48,8 @@ async function runDownload(client, endpointKey, query, options) {
45
48
  }
46
49
  const result = await client.call(endpointKey, undefined, query);
47
50
  const resolved = options.resolveOutputPath ? await options.resolveOutputPath(result) : undefined;
48
- await saveDownloadResult(result, options.fallbackName, resolved);
51
+ // Title-derived names are auto-generated too — dedupe them like the fallback names.
52
+ await saveDownloadResult(result, options.fallbackName, resolved ? await uniquePath(resolved) : undefined);
49
53
  }
50
54
  /**
51
55
  * Register a download subcommand. All download commands share one shape: a
@@ -326,6 +330,7 @@ addFinancialReport("balance-sheet-us", "fundamental.balance-sheet-us", "Period:
326
330
  addFinancialReport("cash-flow-us", "fundamental.cash-flow-us", "Period: q1/h1/q3/nsd/annual/latest");
327
331
  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((options) => emit(options, (client) => 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) })));
328
332
  fundamental.command("valuation-analysis").requiredOption("--security-code <code>").addOption(new Option("--indicator <name>", "Indicator").choices(["peTtm", "pbMrq", "peg", "psTtm", "pcfTtm", "em"]).makeOptionMandatory()).option("--start-date <date>").option("--end-date <date>").option("--limit <number>").option("--field <field>", "Field", collectList, []).option("--skip-null", "Drop rows where value or percentileRank is null").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
333
+ const format = parseOutputFormat(options.format);
329
334
  let data = await client.call("fundamental.valuation-analysis", { securityCode: options.securityCode, indicator: options.indicator, startDate: options.startDate, endDate: options.endDate, limit: parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1 }), fieldList: maybeArray(options.field) });
330
335
  if (options.skipNull) {
331
336
  const normalized = normalizeRows(data);
@@ -342,12 +347,14 @@ fundamental.command("valuation-analysis").requiredOption("--security-code <code>
342
347
  }
343
348
  }
344
349
  }
345
- await printData(data, parseOutputFormat(options.format), options.output);
350
+ await printData(data, format, options.output);
346
351
  }));
347
352
  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 })));
348
353
  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) => {
349
354
  const endDate = options.endDate ?? new Date().toISOString().slice(0, 10);
350
- const startDate = options.startDate ?? new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
355
+ // Anchor the default window to endDate (as the help text promises), not to today
356
+ // a historical --end-date without --start-date should mean "the year before it".
357
+ const startDate = options.startDate ?? new Date(new Date(`${endDate}T00:00:00Z`).getTime() - 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
351
358
  return client.call("fundamental.earning-forecast", { securityCode: options.securityCode, startDate, endDate, consensusList: maybeArray(options.consensus) });
352
359
  }));
353
360
  program.addCommand(fundamental);
@@ -367,7 +374,8 @@ ai.command("security-clue").option("--from <number>", "Starting offset", "0").op
367
374
  ai.command("one-pager").requiredOption("--security-code <code>").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("ai.one-pager", { securityCode: options.securityCode })));
368
375
  ai.command("investment-logic").requiredOption("--security-code <code>").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("ai.investment-logic", { securityCode: options.securityCode })));
369
376
  ai.command("peer-comparison").requiredOption("--security-code <code>").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("ai.peer-comparison", { securityCode: options.securityCode })));
370
- ai.command("earnings-review").requiredOption("--security-code <code>").requiredOption("--period <period>", "Report period (e.g. 2025q3, 2025interim, 2025annual)").option("--wait", "Wait for content generation (blocking, up to 3 min)").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => withClient(async (client) => {
377
+ ai.command("earnings-review").requiredOption("--security-code <code>").requiredOption("--period <period>", "Report period (e.g. 2025q3, 2025interim, 2025annual)").option("--wait", "Wait for content generation (blocking, up to ~5 min)").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => withClient(async (client) => {
378
+ const format = parseOutputFormat(options.format);
371
379
  const idResult = await client.call("ai.earnings-review.get-id", { securityCode: options.securityCode, period: options.period });
372
380
  const dataId = idResult?.dataId;
373
381
  if (!dataId) {
@@ -381,8 +389,13 @@ ai.command("earnings-review").requiredOption("--security-code <code>").requiredO
381
389
  return;
382
390
  }
383
391
  process.stderr.write(`Got dataId: ${dataId}, waiting for content generation...\n`);
384
- if (!await pollAsyncContent(client, "ai.earnings-review.get-content", dataId, parseOutputFormat(options.format), options.output)) {
385
- process.stderr.write(`Content not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise ai earnings-review-check --data-id ${dataId}\n`);
392
+ const outcome = await pollAsyncContent(client, "ai.earnings-review.get-content", dataId, format, options.output);
393
+ if (outcome !== "ok") {
394
+ // "failed" already printed its terminal "Do not retry" line — only a timeout
395
+ // gets the retry hint.
396
+ if (outcome === "timeout") {
397
+ process.stderr.write(`Content not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise ai earnings-review-check --data-id ${dataId}\n`);
398
+ }
386
399
  process.exitCode = 1;
387
400
  }
388
401
  }));
@@ -414,7 +427,8 @@ ai.command("management-discuss-earnings-call").requiredOption("--report-date <da
414
427
  securityCode: options.securityCode,
415
428
  discussionDimension: options.dimension,
416
429
  })));
417
- ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint text (max 1000 chars)").option("--wait", "Wait for content generation (blocking, up to 3 min)").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => withClient(async (client) => {
430
+ ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint text (max 1000 chars)").option("--wait", "Wait for content generation (blocking, up to ~5 min)").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => withClient(async (client) => {
431
+ const format = parseOutputFormat(options.format);
418
432
  const idResult = await client.call("ai.viewpoint-debate.get-id", { viewpoint: options.viewpoint });
419
433
  const dataId = idResult?.dataId;
420
434
  if (!dataId) {
@@ -428,8 +442,11 @@ ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint t
428
442
  return;
429
443
  }
430
444
  process.stderr.write(`Got dataId: ${dataId}, waiting for content generation...\n`);
431
- if (!await pollAsyncContent(client, "ai.viewpoint-debate.get-content", dataId, parseOutputFormat(options.format), options.output)) {
432
- process.stderr.write(`Content not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise ai viewpoint-debate-check --data-id ${dataId}\n`);
445
+ const outcome = await pollAsyncContent(client, "ai.viewpoint-debate.get-content", dataId, format, options.output);
446
+ if (outcome !== "ok") {
447
+ if (outcome === "timeout") {
448
+ process.stderr.write(`Content not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise ai viewpoint-debate-check --data-id ${dataId}\n`);
449
+ }
433
450
  process.exitCode = 1;
434
451
  }
435
452
  }));
@@ -482,6 +499,7 @@ alternative.command("edb-search").requiredOption("--keyword <text>", "Search key
482
499
  limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1 }),
483
500
  })));
484
501
  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((options) => withClient(async (client) => {
502
+ const format = parseOutputFormat(options.format);
485
503
  const raw = await client.call("alternative.edb-data", {
486
504
  indicatorIdList: options.indicatorId,
487
505
  startDate: options.startDate,
@@ -495,26 +513,29 @@ alternative.command("edb-data").option("--indicator-id <id>", "Indicator ID (rep
495
513
  }, {}));
496
514
  data = { list, total: list.length };
497
515
  }
498
- await printData(data, parseOutputFormat(options.format), options.output);
516
+ await printData(data, format, options.output);
499
517
  }));
500
518
  alternative.command("concept-info").requiredOption("--concept-id <id>", "Concept (theme index) ID, e.g. 121000130 机器人; discover via 'gangtise reference concept-search'").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("alternative.concept-info", { conceptId: options.conceptId })));
501
519
  alternative.command("concept-securities").requiredOption("--concept-id <id>", "Concept (theme index) ID, e.g. 121000130 机器人; discover via 'gangtise reference concept-search'").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("alternative.concept-securities", { conceptId: options.conceptId })));
502
520
  program.addCommand(alternative);
503
521
  const indicator = new Command("indicator").description("Data indicator (EDE) APIs: search codes, cross-section, time-series");
504
522
  indicator.command("search").requiredOption("--keyword <text>", "Search keyword, e.g. '收盘价' '成交量' '营业收入' (not free-form questions)").option("--limit <number>", "Max results (default: 50, max: 100)", "50").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
523
+ const format = parseOutputFormat(options.format);
505
524
  const raw = await client.call("indicator.search", {
506
525
  keyword: options.keyword,
507
526
  limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1 }),
508
527
  });
509
- await printData(unwrapIndicatorData(raw), parseOutputFormat(options.format), options.output);
528
+ await printData(unwrapIndicatorData(raw), format, options.output);
510
529
  }));
511
530
  indicator.command("cross-section").option("--indicator <code>", "Indicator code, e.g. qte_close (repeat for multiple)", collectList, []).option("--security <code>", "Security code, e.g. 600519.SH (repeat for multiple)", collectList, []).requiredOption("--date <date>", "Data date (yyyy-MM-dd)").option("--currency <code>", "Currency: DFT/CNY/HKD/USD/EUR/GBP/JPY/TWD/MOP/AUD (default DFT)").option("--scale <code>", "Scale: 0=个 3=千 4=万 6=百万 8=亿 9=十亿 (default 0)").option("--indicator-param <spec>", "Per-indicator param 'code:key=value', e.g. qte_close:adjustmentType=2 for 前复权 (repeat)", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
531
+ const format = parseOutputFormat(options.format);
512
532
  const raw = await client.call("indicator.cross-section", buildIndicatorCrossSectionBody(options));
513
- await printData(flattenCrossSection(unwrapIndicatorData(raw)), parseOutputFormat(options.format), options.output);
533
+ await printData(flattenCrossSection(unwrapIndicatorData(raw)), format, options.output);
514
534
  }));
515
535
  indicator.command("time-series").option("--indicator <code>", "Indicator code, e.g. qte_close (repeat for multiple)", collectList, []).option("--security <code>", "Security code, e.g. 600519.SH (repeat for multiple)", collectList, []).requiredOption("--start-date <date>", "Start date (yyyy-MM-dd)").requiredOption("--end-date <date>", "End date (yyyy-MM-dd)").option("--calendar-type <type>", "Calendar: ND=natural TD=trading WD=weekday (default TD)").option("--currency <code>", "Currency: DFT/CNY/HKD/USD/EUR/GBP/JPY/TWD/MOP/AUD (default DFT)").option("--scale <code>", "Scale: 0=个 3=千 4=万 6=百万 8=亿 9=十亿 (default 0)").option("--indicator-param <spec>", "Per-indicator param 'code:key=value', e.g. qte_close:adjustmentType=2 for 前复权 (repeat)", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
536
+ const format = parseOutputFormat(options.format);
516
537
  const raw = await client.call("indicator.time-series", buildIndicatorTimeSeriesBody(options));
517
- await printData(flattenTimeSeries(unwrapIndicatorData(raw)), parseOutputFormat(options.format), options.output);
538
+ await printData(flattenTimeSeries(unwrapIndicatorData(raw)), format, options.output);
518
539
  }));
519
540
  program.addCommand(indicator);
520
541
  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) => {
@@ -522,6 +543,7 @@ program.command("raw").description("Raw API calls").addCommand(new Command("call
522
543
  if (!endpoint) {
523
544
  throw new ConfigError(`Unknown endpoint key: ${endpointKey}`);
524
545
  }
546
+ const format = parseOutputFormat(options.format);
525
547
  const client = await createClient();
526
548
  let body;
527
549
  if (options.body) {
@@ -532,39 +554,46 @@ program.command("raw").description("Raw API calls").addCommand(new Command("call
532
554
  throw new ConfigError(`Invalid JSON in --body: ${options.body}`);
533
555
  }
534
556
  }
557
+ // Fail loudly on arguments the endpoint kind can't use — they used to be
558
+ // silently dropped, leaving the user to puzzle over server-side errors.
535
559
  if (endpoint.kind === "download") {
560
+ if (body !== undefined) {
561
+ throw new ValidationError(`--body is not supported for download endpoints (use --query key=value); ${endpointKey} is kind=download`);
562
+ }
536
563
  await runDownload(client, endpointKey, options.query, {
537
564
  output: options.output,
538
565
  fallbackName: "download.bin",
539
566
  });
540
567
  return;
541
568
  }
542
- const data = await client.call(endpointKey, body, options.query);
543
- await printData(data, parseOutputFormat(options.format), options.output);
569
+ if (Object.keys(options.query).length > 0) {
570
+ throw new ValidationError(`--query is not supported for JSON endpoints (use --body '{...}'); ${endpointKey} is kind=json`);
571
+ }
572
+ const data = await client.call(endpointKey, body);
573
+ await printData(data, format, options.output);
544
574
  }));
545
575
  async function checkForUpdate(timeoutMs = 2000) {
546
- const https = await import("node:https");
547
- await new Promise((resolve) => {
548
- const req = https.get("https://registry.npmjs.org/gangtise-openapi-cli/latest", (res) => {
549
- let body = "";
550
- res.on("data", (chunk) => { body += chunk; });
551
- res.on("end", () => {
552
- try {
553
- const latest = JSON.parse(body).version;
554
- if (latest && latest !== CLI_VERSION) {
555
- process.stderr.write(`Update available: ${CLI_VERSION} → ${latest}\nRun: npm update -g gangtise-openapi-cli\n`);
556
- }
557
- }
558
- catch { /* ignore */ }
559
- resolve();
560
- });
561
- });
562
- req.on("error", () => resolve());
563
- req.setTimeout(timeoutMs, () => { req.destroy(); resolve(); });
564
- });
576
+ try {
577
+ const response = await fetch("https://registry.npmjs.org/gangtise-openapi-cli/latest", { signal: AbortSignal.timeout(timeoutMs) });
578
+ const latest = (await response.json()).version;
579
+ if (latest && latest !== CLI_VERSION) {
580
+ process.stderr.write(`Update available: ${CLI_VERSION} → ${latest}\nRun: npm update -g gangtise-openapi-cli\n`);
581
+ }
582
+ }
583
+ catch { /* best-effort: offline or a slow registry must not break --version */ }
565
584
  }
585
+ // `gangtise ... | head` closes stdout early; without a handler the final big write
586
+ // crashes Node with an unhandled 'error' event. Exit quietly like a normal CLI.
587
+ process.stdout.on("error", (error) => {
588
+ if (error?.code === "EPIPE")
589
+ process.exit(0);
590
+ throw error;
591
+ });
566
592
  async function main() {
567
- if (process.argv.includes("--version") || process.argv.includes("-V")) {
593
+ // Positional check, not argv.includes: "--version" appearing later (e.g. as
594
+ // another option's value) must not short-circuit the whole command.
595
+ const firstArg = process.argv[2];
596
+ if (firstArg === "--version" || firstArg === "-V") {
568
597
  process.stdout.write(`${CLI_VERSION}\n`);
569
598
  await checkForUpdate();
570
599
  return;
@@ -1,7 +1,9 @@
1
1
  import { ValidationError } from "./errors.js";
2
2
  export function splitCsv(value) {
3
+ // Also split on full-width ",": voice-input IMEs produce it, and an unsplit
4
+ // "600519,000858" goes to the API as one bogus code with no local hint.
3
5
  return value
4
- .split(",")
6
+ .split(/[,,]/)
5
7
  .map((item) => item.trim())
6
8
  .filter(Boolean);
7
9
  }
@@ -65,6 +67,16 @@ export function toTimestamp13(value) {
65
67
  return num;
66
68
  if (!Number.isNaN(num) && num > 1e9)
67
69
  return num * 1000;
70
+ // `new Date("yyyy-MM-dd")` parses as UTC midnight while `new Date("yyyy-MM-dd HH:mm:ss")`
71
+ // parses as local time — for CST users the two forms would differ by 8 hours and
72
+ // silently shift the query window. Anchor date-only input to local midnight so both
73
+ // forms mean the same wall-clock day.
74
+ const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
75
+ if (dateOnly) {
76
+ const d = new Date(Number(dateOnly[1]), Number(dateOnly[2]) - 1, Number(dateOnly[3]));
77
+ const valid = d.getMonth() === Number(dateOnly[2]) - 1 && d.getDate() === Number(dateOnly[3]);
78
+ return valid ? d.getTime() : undefined;
79
+ }
68
80
  const ms = new Date(value).getTime();
69
81
  if (Number.isNaN(ms))
70
82
  return undefined;
@@ -18,13 +18,13 @@ export async function pollAsyncContent(client, getContentEndpoint, dataId, forma
18
18
  const result = await client.call(getContentEndpoint, { dataId });
19
19
  if (result?.content != null) {
20
20
  await printData(result, format, output);
21
- return true;
21
+ return "ok";
22
22
  }
23
23
  }
24
24
  catch (error) {
25
25
  if (error instanceof ApiError && error.code === "410111") {
26
26
  process.stderr.write("Content generation failed (terminal). Do not retry.\n");
27
- return false;
27
+ return "failed";
28
28
  }
29
29
  if (!isAsyncPending(error))
30
30
  throw error;
@@ -35,7 +35,7 @@ export async function pollAsyncContent(client, getContentEndpoint, dataId, forma
35
35
  await new Promise(resolve => setTimeout(resolve, delay));
36
36
  }
37
37
  }
38
- return false;
38
+ return "timeout";
39
39
  }
40
40
  export async function checkAsyncContent(client, getContentEndpoint, dataId, format, output) {
41
41
  try {
@@ -57,7 +57,10 @@ export function isTokenCacheValid(cache, bufferSeconds = 300) {
57
57
  return cache.expiresAt - bufferSeconds > now;
58
58
  }
59
59
  export function normalizeToken(token) {
60
- return token.startsWith("Bearer ") ? token : `Bearer ${token}`;
60
+ // Case-insensitive prefix check: GANGTISE_TOKEN="bearer xxx" must become
61
+ // "Bearer xxx", not the silently-invalid "Bearer bearer xxx".
62
+ const prefix = /^bearer\s+/i.exec(token);
63
+ return `Bearer ${prefix ? token.slice(prefix[0].length) : token}`;
61
64
  }
62
65
  export function requireAccessCredentials(accessKey, secretKey) {
63
66
  if (!accessKey || !secretKey) {