gangtise-openapi-cli 0.26.0 → 0.27.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 +40 -15
- package/dist/src/cli.js +4 -2
- package/dist/src/core/args.js +17 -0
- package/dist/src/core/asyncContent.js +11 -2
- package/dist/src/core/client.js +19 -2
- package/dist/src/core/download.js +19 -5
- package/dist/src/core/endpoints.js +7 -0
- package/dist/src/core/errors.js +5 -2
- package/dist/src/core/indicatorMatrix.js +3 -1
- package/dist/src/core/output.js +29 -7
- package/dist/src/core/quoteSharding.js +9 -5
- package/dist/src/core/transport.js +19 -3
- package/dist/src/version.js +1 -1
- package/gangtise-openapi/SKILL.md +326 -0
- package/gangtise-openapi/references/commands/ai.md +149 -0
- package/gangtise-openapi/references/commands/alternative.md +102 -0
- package/gangtise-openapi/references/commands/fundamental.md +108 -0
- package/gangtise-openapi/references/commands/indicator.md +136 -0
- package/gangtise-openapi/references/commands/insight.md +187 -0
- package/gangtise-openapi/references/commands/quote.md +93 -0
- package/gangtise-openapi/references/commands/reference-and-lookup.md +229 -0
- package/gangtise-openapi/references/commands/vault.md +79 -0
- package/gangtise-openapi/references/examples.md +291 -0
- package/gangtise-openapi/references/fields.md +166 -0
- package/gangtise-openapi/references/lookup-ids.md +264 -0
- package/gangtise-openapi/references/response-schema.md +85 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -4,6 +4,33 @@
|
|
|
4
4
|
|
|
5
5
|
## Changelog
|
|
6
6
|
|
|
7
|
+
### v0.27.0 — 2026-07-11
|
|
8
|
+
|
|
9
|
+
**EDE 指标(体验修复)**
|
|
10
|
+
- `indicator` 三端点对 `999999` 不再自动重试——实测服务端用 `999999` + HTTP 500 表示「查询无数据」(节假日 / 未来日期 / 未覆盖标的),此前每次空查询白烧 3 个请求 + ~4 秒;错误提示改为指向检查查询条件而非「稍后重试」
|
|
11
|
+
|
|
12
|
+
**资金与下载加固(承接 v0.26.0)**
|
|
13
|
+
- 下载路径同样接入重试策略:50/篇 的 `summary` / `foreign-report` / `my-conference` download 改为 no-replay(与 AI Agent 同价档;下载中断重试可能重复计费),10-30/篇 的下载维持默认重试
|
|
14
|
+
- 签名 URL 下载增加整体硬截止(10× 单请求超时)——headers/body 超时是空闲型,慢滴速传输可无限续命;最终 rename 失败时清理 `.part`
|
|
15
|
+
- `GANGTISE_PAGE_CONCURRENCY` 防御性解析:非法/非正数回退默认 5、上限 32——负值此前被底层钳制成**单 worker 串行**(静默变慢),过大值可能造成过度并发触发限流
|
|
16
|
+
- `--version` 更新提示改为数值分段版本比较(不处理预发布号;本项目只发 x.y.z)——刚发版的 registry 滞后窗口不再把旧版本提示成"可更新"
|
|
17
|
+
|
|
18
|
+
**体验与正确性小修**
|
|
19
|
+
- `--wait` 异步轮询容忍瞬态错误:5xx/网络抖动只消耗一次尝试并继续等待,不再作废整段等待(积分不足等终态错误仍立即中止)
|
|
20
|
+
- table 输出单元格显示宽度上限 120(超长截断加 `…`)——一个超长字段不再把整列所有行 pad 成同宽(行数 × 宽度的空格放大)
|
|
21
|
+
- markdown 输出先转义反斜杠再转义竖线,字面 `\|` 单元格不再错位列;table/markdown 过滤 C1 控制符(U+009B 单字节 CSI 注入面)
|
|
22
|
+
- 自动文件名按码点截断,emoji 不再被截成 `�`;EDE 矩阵中与 `date`/`security`/`name` 同名的指标列自动加后缀,不再覆盖元数据列
|
|
23
|
+
- 全市场分片截断时输出 `truncatedShards`(具体日期区间,与 `failedShards` 对称),脚本/AI 消费者可定向缩窗补拉
|
|
24
|
+
- 分页端点首页形状漂移(如 `total` 变字符串)时 `--verbose` 下告警,不再完全静默退化单页
|
|
25
|
+
|
|
26
|
+
**Skill 分发**
|
|
27
|
+
- `gangtise-openapi/` 目录纳入 npm 包;README 安装命令改为从 `$(npm root -g)` 复制——此前的相对路径命令对 npm 用户不可执行
|
|
28
|
+
|
|
29
|
+
**防漂移门禁(工程,不影响 CLI 行为)**
|
|
30
|
+
- 新增 README↔ENDPOINTS 一致性测试:「自动翻页」清单与注册表 pagination 标记双向比对(此类手抄清单漂移已发生两次);insight/reference 子命令的 `--help` 覆盖改为从端点注册表派生,新命令漏接线直接测试失败
|
|
31
|
+
- `npm run prepare` 前置断言 README/CHANGELOG 含当前版本条目(写盘前检查,失败零残留);`npm run typecheck` 纳入 tests/(tsconfig.test.json)
|
|
32
|
+
- CI:`npm pack` 装包冒烟(`--help` + skill 文件存在校验)、测试矩阵 Node 下限改精确 20.18.1、CI typecheck;publish 的 `workflow_dispatch` 必须指向 `v*` tag(关闭无护栏发布通道)
|
|
33
|
+
|
|
7
34
|
### v0.26.0 — 2026-07-11
|
|
8
35
|
|
|
9
36
|
**资金安全(重要)**
|
|
@@ -68,14 +95,6 @@
|
|
|
68
95
|
- `reference institution-search` — 机构 ID 搜索,输入机构名/简称返回 `institutionId` 及适用接口参数(`usageScopes`);`--keyword`(必填)、`--category`(`domesticBroker`/`foreignInstitution`/`leadInstitution`/`opinionInstitution`/`foreignOpinionInstitution`,可重复)、`--top`(默认 10,上限 10);免费。覆盖既有 `--broker`/`--institution` 全部机构类(research/foreign-report/opinion/foreign-opinion/summary/roadshow/site-visit/strategy/my-conference)
|
|
69
96
|
- `vault my-conference-list` 新增 `--source` — 按录制来源筛选(`1`=企微会议助理 `2`=会议服务微信群,可重复;不传返回全部)
|
|
70
97
|
|
|
71
|
-
### v0.22.1 — 2026-07-03
|
|
72
|
-
|
|
73
|
-
**修复**
|
|
74
|
-
- 错误码 `410004` 提示改为中性措辞「数据未找到或无指标权限,请检查查询条件与指标权限」——此前只说"数据未找到",与 `indicator` 内层信封的"无权限"消息拼接后自相矛盾
|
|
75
|
-
|
|
76
|
-
**文档 / Skill**(随 `/sync-skill` 分发,不影响 CLI 行为)
|
|
77
|
-
- gangtise-openapi Agent Skill 经 fable5 审计 + 多轮 review 优化:官方积分计费速查表 + 高积分 pre-flight 闸门、AI 同步生成命令 `GANGTISE_TIMEOUT_MS=120000` 超时前置、大结果集 `--output` 落盘、异步 `--wait` 主路径、行业码口径收敛到单一权威、市值量纲实测(`qte_mkt_cptl` 仅 A 股 / 默认原始「元」/ `scale`+`currency`)等文档补全与消歧
|
|
78
|
-
|
|
79
98
|
> 更早版本及完整更新历史见 [CHANGELOG.md](CHANGELOG.md)。
|
|
80
99
|
|
|
81
100
|
## 首次安装
|
|
@@ -96,6 +115,8 @@ gangtise --help
|
|
|
96
115
|
npm update -g gangtise-openapi-cli
|
|
97
116
|
```
|
|
98
117
|
|
|
118
|
+
> 更新后若使用 Agent Skill:包内 skill 已随包更新,但复制到 `~/.claude/skills/` 等目录的副本是快照,**需重新执行下方「安装」段的复制命令**才能让 AI 助手拿到新版 skill。
|
|
119
|
+
|
|
99
120
|
本地开发:
|
|
100
121
|
|
|
101
122
|
```bash
|
|
@@ -116,7 +137,7 @@ export GANGTISE_BASE_URL="https://openapi.gangtise.com"
|
|
|
116
137
|
export GANGTISE_TOKEN="Bearer xxx"
|
|
117
138
|
|
|
118
139
|
# 性能/调试可选项
|
|
119
|
-
export GANGTISE_PAGE_CONCURRENCY=5 #
|
|
140
|
+
export GANGTISE_PAGE_CONCURRENCY=5 # 翻页/分片并发数(默认 5,上限 32;非法值回退默认)
|
|
120
141
|
export GANGTISE_VERBOSE=1 # 打印每个请求的耗时与字节数
|
|
121
142
|
export GANGTISE_TIMEOUT_MS=30000 # 请求超时(默认 30s)
|
|
122
143
|
export GANGTISE_TOKEN_CACHE_PATH=... # 覆盖 token 缓存路径(默认 ~/.config/gangtise/token.json)
|
|
@@ -155,22 +176,26 @@ gangtise-openapi/
|
|
|
155
176
|
└── response-schema.md # 各接口响应字段说明
|
|
156
177
|
```
|
|
157
178
|
|
|
158
|
-
|
|
179
|
+
安装(skill 目录随 npm 包分发,`npm install -g` 之后即可从全局安装位置复制):
|
|
159
180
|
|
|
160
181
|
```bash
|
|
182
|
+
SKILL_SRC="$(npm root -g)/gangtise-openapi-cli/gangtise-openapi"
|
|
183
|
+
|
|
161
184
|
# Claude Code
|
|
162
|
-
cp -r
|
|
185
|
+
cp -r "$SKILL_SRC" ~/.claude/skills/gangtise-openapi
|
|
163
186
|
|
|
164
187
|
# Codex
|
|
165
|
-
cp -r
|
|
188
|
+
cp -r "$SKILL_SRC" ~/.codex/skills/gangtise-openapi
|
|
166
189
|
|
|
167
190
|
# OpenClaw
|
|
168
|
-
cp -r
|
|
191
|
+
cp -r "$SKILL_SRC" ~/.openclaw/skills/gangtise-openapi
|
|
169
192
|
|
|
170
193
|
# Hermes
|
|
171
|
-
cp -r
|
|
194
|
+
cp -r "$SKILL_SRC" ~/.hermes/skills/gangtise-openapi
|
|
172
195
|
```
|
|
173
196
|
|
|
197
|
+
> 从仓库 clone 开发时,把 `$SKILL_SRC` 换成仓库内的 `gangtise-openapi` 目录即可。
|
|
198
|
+
|
|
174
199
|
> **版本更新**:每次 CLI 发版时,`gangtise-openapi/SKILL.md` 的 `version` 字段会自动同步。更新 CLI 后,请将项目中的 `gangtise-openapi/` 目录重新复制到对应的 skills 目录覆盖更新:
|
|
175
200
|
>
|
|
176
201
|
> ```bash
|
|
@@ -302,7 +327,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
|
|
|
302
327
|
- **HTTP keep-alive**:所有请求复用同一个 `undici.Agent`(连接池 16),避免重复 TLS 握手。
|
|
303
328
|
- **流式下载**:指定 `--output` 时,二进制响应(PDF 等)直接 `pipeline` 到磁盘,不经过内存缓冲;50MB PDF 内存占用近乎为零。
|
|
304
329
|
- **流式输出**:`jsonl`/`csv` 格式且 `--output` 指定时,超过 1000 行自动切换为逐行写盘,避免一次性构建百 MB 字符串。
|
|
305
|
-
- **自动重试**:5xx / 429 / `ECONNREFUSED` / `ECONNRESET` / `ETIMEDOUT` / `ENOTFOUND` / `EAI_AGAIN` / `UND_ERR_*`(undici 连接/超时类)/ `999999` 系统错误自动指数退避重试 2
|
|
330
|
+
- **自动重试**:5xx / 429 / `ECONNREFUSED` / `ECONNRESET` / `ETIMEDOUT` / `ENOTFOUND` / `EAI_AGAIN` / `UND_ERR_*`(undici 连接/超时类)/ `999999` 系统错误自动指数退避重试 2 次。**贵档端点例外**(one-pager 等 13 个生成/提交类 + 50/篇 的 summary/foreign-report/my-conference 下载,共 16 个):5xx/超时不重放——按次计费不幂等,重放即重复扣分;仅连接失败、429 与 token 自愈重试。**`indicator`(EDE)端点对 `999999` 不重试**——该码实为「查询无数据」(实测),重试纯浪费。
|
|
306
331
|
- **Token 自愈**:调用返回 8000014/8000015 时自动强制刷新 Token 并重试一次。
|
|
307
332
|
- **K线/资金流向自动分片**:`quote day-kline --security all`、`quote fund-flow --security aShares` 等全市场查询自动按日期切分(A股 K线/资金流向 1 天/片、美股 1 天/片、HK 2 天/片、指数 30 天/片),并发执行后合并结果;按日分片自动跳过周六日。分片时如果用户未传 `--limit`,自动注入 `limit: 10000`(API 上限)避免默认 6000 截断。
|
|
308
333
|
- **Token 内存缓存**:Token 在进程内存中缓存,避免每次请求读盘。
|
package/dist/src/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Command, Option } from "commander";
|
|
3
3
|
import { checkAsyncContent, pollAsyncContent, POLL_MAX_ATTEMPTS } from "./core/asyncContent.js";
|
|
4
4
|
import { readTokenCache, redactTokenCache } from "./core/auth.js";
|
|
5
|
-
import { collectKeyValue, collectList, collectNumberList, localDateString, maybeArray, parseChoiceList, parseFrom, parseNumberOption, parseOptionalNumberOption, parseSize, parseTimestamp13 } from "./core/args.js";
|
|
5
|
+
import { collectKeyValue, collectList, collectNumberList, isVersionNewer, localDateString, maybeArray, parseChoiceList, parseFrom, parseNumberOption, parseOptionalNumberOption, parseSize, parseTimestamp13 } from "./core/args.js";
|
|
6
6
|
import { buildIndicatorCrossSectionBody, buildIndicatorTimeSeriesBody, buildQuoteKlineBody, buildStockPoolStocksBody, buildWechatChatroomListBody, buildWechatMessageListBody } from "./core/commandBodies.js";
|
|
7
7
|
import { flattenCrossSection, flattenTimeSeries, unwrapIndicatorData } from "./core/indicatorMatrix.js";
|
|
8
8
|
import { callKlineWithSharding, isAllMarket, isFullMarket } from "./core/quoteSharding.js";
|
|
@@ -681,7 +681,9 @@ async function checkForUpdate(timeoutMs = 2000) {
|
|
|
681
681
|
try {
|
|
682
682
|
const response = await fetch("https://registry.npmjs.org/gangtise-openapi-cli/latest", { signal: AbortSignal.timeout(timeoutMs) });
|
|
683
683
|
const latest = (await response.json()).version;
|
|
684
|
-
|
|
684
|
+
// Ordered compare, not inequality: during the just-published window the
|
|
685
|
+
// registry still serves the PREVIOUS version — don't suggest a "downgrade".
|
|
686
|
+
if (latest && isVersionNewer(latest, CLI_VERSION)) {
|
|
685
687
|
process.stderr.write(`Update available: ${CLI_VERSION} → ${latest}\nRun: npm update -g gangtise-openapi-cli\n`);
|
|
686
688
|
}
|
|
687
689
|
}
|
package/dist/src/core/args.js
CHANGED
|
@@ -62,6 +62,23 @@ export function collectKeyValue(value, previous = {}) {
|
|
|
62
62
|
export function maybeArray(value) {
|
|
63
63
|
return value.length > 0 ? value : undefined;
|
|
64
64
|
}
|
|
65
|
+
/** True when `latest` is strictly newer than `current` (numeric per-segment
|
|
66
|
+
* compare). Plain inequality would nag "update available" during the
|
|
67
|
+
* just-published window while the registry still serves the previous version. */
|
|
68
|
+
export function isVersionNewer(latest, current) {
|
|
69
|
+
const parse = (v) => v.split(".").map(Number);
|
|
70
|
+
const a = parse(latest);
|
|
71
|
+
const b = parse(current);
|
|
72
|
+
if (a.some(Number.isNaN) || b.some(Number.isNaN))
|
|
73
|
+
return false;
|
|
74
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
75
|
+
const x = a[i] ?? 0;
|
|
76
|
+
const y = b[i] ?? 0;
|
|
77
|
+
if (x !== y)
|
|
78
|
+
return x > y;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
65
82
|
// Whitelist for enum-valued repeatable options. Only used where the server was
|
|
66
83
|
// probed NOT to reject bad values (it silently ignores the filter or returns
|
|
67
84
|
// empty instead) — endpoints that answer 100003 keep server-side validation.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ApiError } from "./errors.js";
|
|
2
2
|
import { printData } from "./printer.js";
|
|
3
|
+
import { isTransientError } from "./transport.js";
|
|
3
4
|
// 14 attempts with exponential backoff (5s→30s cap) ≈ 316s total wait budget.
|
|
4
5
|
export const POLL_MAX_ATTEMPTS = 14;
|
|
5
6
|
const POLL_INITIAL_DELAY_MS = 5_000;
|
|
@@ -26,8 +27,16 @@ export async function pollAsyncContent(client, getContentEndpoint, dataId, forma
|
|
|
26
27
|
process.stderr.write("Content generation failed (terminal). Do not retry.\n");
|
|
27
28
|
return "failed";
|
|
28
29
|
}
|
|
29
|
-
if (!isAsyncPending(error))
|
|
30
|
-
|
|
30
|
+
if (!isAsyncPending(error)) {
|
|
31
|
+
// AI generation windows are exactly when the server is busiest: one 5xx
|
|
32
|
+
// (after the client's own retries) must not void minutes of waiting —
|
|
33
|
+
// the dataId is still valid. Transient errors consume this attempt and
|
|
34
|
+
// polling continues; anything else (no credits, bad params) aborts.
|
|
35
|
+
if (!isTransientError(error))
|
|
36
|
+
throw error;
|
|
37
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
38
|
+
process.stderr.write(`Attempt ${attempt}/${POLL_MAX_ATTEMPTS}: transient error (${msg.slice(0, 80)}), continuing to wait...\n`);
|
|
39
|
+
}
|
|
31
40
|
}
|
|
32
41
|
if (attempt < POLL_MAX_ATTEMPTS) {
|
|
33
42
|
const delay = nextDelayMs(attempt);
|
package/dist/src/core/client.js
CHANGED
|
@@ -175,8 +175,14 @@ export class GangtiseClient {
|
|
|
175
175
|
from: startFrom,
|
|
176
176
|
size: firstPageSize,
|
|
177
177
|
});
|
|
178
|
-
if (!this.isPaginatedListResponse(firstPage))
|
|
178
|
+
if (!this.isPaginatedListResponse(firstPage)) {
|
|
179
|
+
// Shape drift (e.g. total arriving as a string) silently degrades fetch-all
|
|
180
|
+
// to a single page with no partial marker — make it visible on verbose.
|
|
181
|
+
if (isVerbose()) {
|
|
182
|
+
process.stderr.write(`[gangtise] warning: ${endpoint.key} is marked paginated but the first page has an unexpected shape (no numeric total + list); returning it as-is\n`);
|
|
183
|
+
}
|
|
179
184
|
return firstPage;
|
|
185
|
+
}
|
|
180
186
|
const total = firstPage.total;
|
|
181
187
|
const collected = [...firstPage.list];
|
|
182
188
|
const available = Math.max(total - startFrom, 0);
|
|
@@ -398,13 +404,21 @@ export class GangtiseClient {
|
|
|
398
404
|
throw error;
|
|
399
405
|
}
|
|
400
406
|
}, {
|
|
401
|
-
policy: endpoint.retry
|
|
407
|
+
policy: endpoint.retry ?? "default",
|
|
402
408
|
onRetry: (attempt, error, delay) => {
|
|
403
409
|
if (!isVerbose())
|
|
404
410
|
return;
|
|
405
411
|
const msg = error instanceof Error ? error.message : String(error);
|
|
406
412
|
process.stderr.write(`[gangtise] retry ${attempt} after ${delay.toFixed(0)}ms: ${msg.slice(0, 120)}\n`);
|
|
407
413
|
},
|
|
414
|
+
}).catch((error) => {
|
|
415
|
+
// EDE uses 999999 for "no data for this query" (probed 2026-07-11) — the
|
|
416
|
+
// generic "系统错误,请稍后重试" hint would send the user retrying a query
|
|
417
|
+
// that will never have data. Swap in a context-specific hint.
|
|
418
|
+
if (endpoint.retry === 'no-999999' && error instanceof ApiError && error.code === '999999') {
|
|
419
|
+
throw new ApiError(error.message, error.code, error.statusCode, error.details, error.retryAfterMs, 'EDE 的 999999 多为查询无数据(节假日 / 未来日期 / 未覆盖标的)——先检查查询条件,确认应有数据再重试。');
|
|
420
|
+
}
|
|
421
|
+
throw error;
|
|
408
422
|
});
|
|
409
423
|
}
|
|
410
424
|
async download(endpoint, query, options) {
|
|
@@ -544,6 +558,9 @@ export class GangtiseClient {
|
|
|
544
558
|
filename,
|
|
545
559
|
};
|
|
546
560
|
}, {
|
|
561
|
+
// Download endpoints carry per-篇 billing too (summary/foreign-report/
|
|
562
|
+
// my-conference at 50/篇) — honor the endpoint's retry policy here as well.
|
|
563
|
+
policy: endpoint.retry ?? "default",
|
|
547
564
|
onRetry: (attempt, error, delay) => {
|
|
548
565
|
if (!isVerbose())
|
|
549
566
|
return;
|
|
@@ -18,10 +18,12 @@ function truncateFilename(name, maxBytes = 200) {
|
|
|
18
18
|
if (Buffer.byteLength(name, "utf8") <= maxBytes)
|
|
19
19
|
return name;
|
|
20
20
|
const ext = extname(name);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
// Trim by code point, not UTF-16 unit: a cut inside a surrogate pair would put
|
|
22
|
+
// a lone surrogate in the name, which reaches the filesystem as U+FFFD (�).
|
|
23
|
+
const stem = [...name.slice(0, name.length - ext.length)];
|
|
24
|
+
while (stem.length > 1 && Buffer.byteLength(stem.join("") + ext, "utf8") > maxBytes)
|
|
25
|
+
stem.pop();
|
|
26
|
+
return stem.join("") + ext;
|
|
25
27
|
}
|
|
26
28
|
/** Pick a non-existing path by suffixing -1, -2, … before the extension, so batch
|
|
27
29
|
* downloads whose titles collide ("2025年第一季度报告" from several companies) don't
|
|
@@ -137,6 +139,11 @@ async function downloadUrlTo(url, outputPath) {
|
|
|
137
139
|
method: "GET",
|
|
138
140
|
headersTimeout: timeoutMs,
|
|
139
141
|
bodyTimeout: timeoutMs,
|
|
142
|
+
// headers/body timeouts are IDLE timeouts — a stream trickling one byte
|
|
143
|
+
// per interval resets them forever. A generous total deadline (10× the
|
|
144
|
+
// per-request timeout) bounds the whole transfer without killing large
|
|
145
|
+
// legitimate downloads.
|
|
146
|
+
signal: AbortSignal.timeout(timeoutMs * 10),
|
|
140
147
|
dispatcher: getDispatcher(),
|
|
141
148
|
};
|
|
142
149
|
let currentUrl = url;
|
|
@@ -171,7 +178,14 @@ async function downloadUrlTo(url, outputPath) {
|
|
|
171
178
|
}
|
|
172
179
|
logTiming(`GET ${redactUrl(currentUrl)}`, Date.now() - startedAt, String(response.statusCode));
|
|
173
180
|
});
|
|
174
|
-
|
|
181
|
+
try {
|
|
182
|
+
await fs.rename(partPath, outputPath);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
// e.g. outputPath turned out to be a directory — don't leave the .part behind.
|
|
186
|
+
await fs.unlink(partPath).catch(() => { });
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
175
189
|
}
|
|
176
190
|
export async function saveDownloadResult(result, fallbackName, output) {
|
|
177
191
|
if (!(result && typeof result === "object")) {
|
|
@@ -46,6 +46,8 @@ const ENDPOINT_DEFS = {
|
|
|
46
46
|
path: "/application/open-insight/summary/v2/download/file",
|
|
47
47
|
kind: "download",
|
|
48
48
|
description: "Download summary file",
|
|
49
|
+
// 50/篇 — same price tier as the AI Agent calls; billing probed non-idempotent.
|
|
50
|
+
retry: "no-replay",
|
|
49
51
|
},
|
|
50
52
|
"insight.roadshow.list": {
|
|
51
53
|
method: "POST",
|
|
@@ -100,6 +102,7 @@ const ENDPOINT_DEFS = {
|
|
|
100
102
|
path: "/application/open-insight/foreign-report/download/file",
|
|
101
103
|
kind: "download",
|
|
102
104
|
description: "Download foreign report",
|
|
105
|
+
retry: "no-replay",
|
|
103
106
|
},
|
|
104
107
|
"insight.announcement.list": {
|
|
105
108
|
method: "POST",
|
|
@@ -538,6 +541,7 @@ const ENDPOINT_DEFS = {
|
|
|
538
541
|
path: "/application/open-vault/my-conference/download/file",
|
|
539
542
|
kind: "download",
|
|
540
543
|
description: "Download my conference resource",
|
|
544
|
+
retry: "no-replay",
|
|
541
545
|
},
|
|
542
546
|
"vault.wechat-message.list": {
|
|
543
547
|
method: "POST",
|
|
@@ -599,18 +603,21 @@ const ENDPOINT_DEFS = {
|
|
|
599
603
|
path: "/application/open-indicator/EDE/search",
|
|
600
604
|
kind: "json",
|
|
601
605
|
description: "Search data indicators by keyword (returns indicatorCode + params)",
|
|
606
|
+
retry: "no-999999",
|
|
602
607
|
},
|
|
603
608
|
"indicator.cross-section": {
|
|
604
609
|
method: "POST",
|
|
605
610
|
path: "/application/open-indicator/EDE/cross-section",
|
|
606
611
|
kind: "json",
|
|
607
612
|
description: "Get cross-section data (multi-indicator x multi-security, single date)",
|
|
613
|
+
retry: "no-999999",
|
|
608
614
|
},
|
|
609
615
|
"indicator.time-series": {
|
|
610
616
|
method: "POST",
|
|
611
617
|
path: "/application/open-indicator/EDE/time-series",
|
|
612
618
|
kind: "json",
|
|
613
619
|
description: "Get time-series data (multi-indicator x single-security OR single-indicator x multi-security)",
|
|
620
|
+
retry: "no-999999",
|
|
614
621
|
},
|
|
615
622
|
};
|
|
616
623
|
export const ENDPOINTS = Object.fromEntries(Object.entries(ENDPOINT_DEFS).map(([key, def]) => [key, { key, ...def }]));
|
package/dist/src/core/errors.js
CHANGED
|
@@ -40,12 +40,15 @@ export class ApiError extends CliError {
|
|
|
40
40
|
constructor(message, code, statusCode, details,
|
|
41
41
|
/** Server-specified Retry-After (ms), set on 429 responses so the transport
|
|
42
42
|
* backoff can honor it instead of the default exponential schedule. */
|
|
43
|
-
retryAfterMs
|
|
43
|
+
retryAfterMs,
|
|
44
|
+
/** Context-specific hint that beats the generic per-code table — e.g. EDE's
|
|
45
|
+
* 999999 means "no data", not the table's "系统错误,请稍后重试". */
|
|
46
|
+
hintOverride) {
|
|
44
47
|
super(message);
|
|
45
48
|
this.code = code;
|
|
46
49
|
this.statusCode = statusCode;
|
|
47
50
|
this.details = details;
|
|
48
51
|
this.retryAfterMs = retryAfterMs;
|
|
49
|
-
this.hint = code ? ERROR_HINTS[code] : undefined;
|
|
52
|
+
this.hint = hintOverride ?? (code ? ERROR_HINTS[code] : undefined);
|
|
50
53
|
}
|
|
51
54
|
}
|
|
@@ -34,7 +34,9 @@ function rowOf(values, index) {
|
|
|
34
34
|
// Build one column header per series. Prefer the human-readable name; on a
|
|
35
35
|
// duplicate name append the code so a column is never silently overwritten.
|
|
36
36
|
function buildHeaders(names, codes, count) {
|
|
37
|
-
|
|
37
|
+
// Pre-seed the metadata column names: an indicator literally named "date" /
|
|
38
|
+
// "security" / "name" must get a suffixed header, not overwrite the metadata.
|
|
39
|
+
const used = new Set(["date", "security", "name"]);
|
|
38
40
|
const headers = [];
|
|
39
41
|
for (let i = 0; i < count; i++) {
|
|
40
42
|
const base = String(names?.[i] ?? codes?.[i] ?? `col${i}`);
|
package/dist/src/core/output.js
CHANGED
|
@@ -9,10 +9,11 @@ export function parseOutputFormat(value) {
|
|
|
9
9
|
throw new ConfigError(`Unsupported format: ${format}`);
|
|
10
10
|
}
|
|
11
11
|
/** Cell text for terminal-facing formats (table/markdown): newlines collapsed for
|
|
12
|
-
* alignment, remaining C0/DEL control chars stripped so server data can't inject
|
|
13
|
-
* terminal escape sequences
|
|
12
|
+
* alignment, remaining C0/DEL/C1 control chars stripped so server data can't inject
|
|
13
|
+
* terminal escape sequences into the user's terminal (U+009B is a one-byte CSI
|
|
14
|
+
* that 8-bit-control terminals treat exactly like ESC[). */
|
|
14
15
|
function sanitizeCell(value) {
|
|
15
|
-
return value.replace(/[\r\n]+/g, " ").replace(/[\u0000-\u001f\u007f]/g, "");
|
|
16
|
+
return value.replace(/[\r\n]+/g, " ").replace(/[\u0000-\u001f\u007f\u0080-\u009f]/g, "");
|
|
16
17
|
}
|
|
17
18
|
/** Terminal display width: CJK/fullwidth chars and emoji occupy 2 columns — padEnd
|
|
18
19
|
* counts UTF-16 code units and misaligns every table containing Chinese text or emoji
|
|
@@ -62,6 +63,24 @@ function toRows(value) {
|
|
|
62
63
|
}
|
|
63
64
|
return [{ value }];
|
|
64
65
|
}
|
|
66
|
+
// One huge cell (a 50KB brief/chat message) would otherwise force every row of
|
|
67
|
+
// that column to be padded to the same width — rows × width of pure spaces.
|
|
68
|
+
const MAX_CELL_DISPLAY_WIDTH = 120;
|
|
69
|
+
/** Truncate a cell to the display-width cap, ellipsis included in the budget. */
|
|
70
|
+
function clampCell(value) {
|
|
71
|
+
if (displayWidth(value) <= MAX_CELL_DISPLAY_WIDTH)
|
|
72
|
+
return value;
|
|
73
|
+
let out = "";
|
|
74
|
+
let width = 0;
|
|
75
|
+
for (const ch of value) {
|
|
76
|
+
const w = displayWidth(ch);
|
|
77
|
+
if (width + w > MAX_CELL_DISPLAY_WIDTH - 1)
|
|
78
|
+
break;
|
|
79
|
+
out += ch;
|
|
80
|
+
width += w;
|
|
81
|
+
}
|
|
82
|
+
return out + "…";
|
|
83
|
+
}
|
|
65
84
|
function renderTable(rows) {
|
|
66
85
|
if (rows.length === 0) {
|
|
67
86
|
return "(empty)";
|
|
@@ -73,8 +92,8 @@ function renderTable(rows) {
|
|
|
73
92
|
// spreading a per-row array overflows the call stack on large results (table is
|
|
74
93
|
// the default format, e.g. `quote day-kline --security all`). Widths and padding
|
|
75
94
|
// use displayWidth so CJK cells stay aligned.
|
|
76
|
-
const headerCells = columns.map(sanitizeCell);
|
|
77
|
-
const matrix = rows.map((row) => columns.map((column) => sanitizeCell(formatScalar(row[column]))));
|
|
95
|
+
const headerCells = columns.map((column) => clampCell(sanitizeCell(column)));
|
|
96
|
+
const matrix = rows.map((row) => columns.map((column) => clampCell(sanitizeCell(formatScalar(row[column])))));
|
|
78
97
|
const widths = columns.map((_, c) => matrix.reduce((max, cells) => Math.max(max, displayWidth(cells[c])), displayWidth(headerCells[c])));
|
|
79
98
|
const renderLine = (values) => values.map((value, index) => value + " ".repeat(Math.max(0, widths[index] - displayWidth(value)))).join(" ");
|
|
80
99
|
const header = renderLine(headerCells);
|
|
@@ -89,9 +108,12 @@ function renderMarkdown(rows) {
|
|
|
89
108
|
const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
|
|
90
109
|
// Column names come from server data (e.g. EDE indicator display names) — escape
|
|
91
110
|
// them like cell values or a name containing | / , breaks the whole table.
|
|
92
|
-
|
|
111
|
+
// Backslash must go first: escaping only "|" turns a literal `\|` into `\\|`,
|
|
112
|
+
// which GFM reads as an escaped backslash + BARE pipe → an extra column.
|
|
113
|
+
const mdEscape = (value) => value.replaceAll("\\", "\\\\").replaceAll("|", "\\|");
|
|
114
|
+
const header = `| ${columns.map((column) => mdEscape(sanitizeCell(column))).join(" | ")} |`;
|
|
93
115
|
const divider = `| ${columns.map(() => "---").join(" | ")} |`;
|
|
94
|
-
const body = rows.map((row) => `| ${columns.map((column) => sanitizeCell(formatScalar(row[column]))
|
|
116
|
+
const body = rows.map((row) => `| ${columns.map((column) => mdEscape(sanitizeCell(formatScalar(row[column])))).join(" | ")} |`);
|
|
95
117
|
return [header, divider, ...body].join("\n");
|
|
96
118
|
}
|
|
97
119
|
function renderCsv(rows) {
|
|
@@ -153,8 +153,11 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
|
|
|
153
153
|
let fieldList;
|
|
154
154
|
let header = null;
|
|
155
155
|
const merged = [];
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
// Record WHICH windows maxed out, not just how many: a script/agent consumer
|
|
157
|
+
// needs the concrete date ranges to re-pull narrower windows (mirrors failedShards).
|
|
158
|
+
const truncatedShards = [];
|
|
159
|
+
for (let i = 0; i < results.length; i++) {
|
|
160
|
+
const r = results[i];
|
|
158
161
|
if (!(r && typeof r === "object"))
|
|
159
162
|
continue;
|
|
160
163
|
const rec = r;
|
|
@@ -163,7 +166,7 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
|
|
|
163
166
|
if (!fieldList && Array.isArray(rec.fieldList))
|
|
164
167
|
fieldList = rec.fieldList;
|
|
165
168
|
if (isTruncated(rec))
|
|
166
|
-
truncatedShards
|
|
169
|
+
truncatedShards.push(shards[i]);
|
|
167
170
|
// Append one-by-one rather than push(...list): a future higher row cap could
|
|
168
171
|
// make a single shard's list large enough to overflow the stack via spread.
|
|
169
172
|
if (Array.isArray(rec.list))
|
|
@@ -187,9 +190,10 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
|
|
|
187
190
|
out.failedShards = failedShards;
|
|
188
191
|
process.stderr.write(`[gangtise] warning: ${failedShards.length}/${shards.length} shards failed; results are partial (see failedShards)\n`);
|
|
189
192
|
}
|
|
190
|
-
if (truncatedShards > 0) {
|
|
193
|
+
if (truncatedShards.length > 0) {
|
|
191
194
|
out.partial = true;
|
|
192
|
-
|
|
195
|
+
out.truncatedShards = truncatedShards;
|
|
196
|
+
process.stderr.write(`[gangtise] warning: ${truncatedShards.length}/${shards.length} shard(s) hit the ${perShardLimit}-row limit and were likely truncated; results are partial (see truncatedShards) — ${truncationHint}.\n`);
|
|
193
197
|
}
|
|
194
198
|
return out;
|
|
195
199
|
}
|
|
@@ -62,8 +62,18 @@ export async function runWithConcurrency(items, concurrency, fn) {
|
|
|
62
62
|
await Promise.all(Array.from({ length: limit }, () => worker()));
|
|
63
63
|
return results;
|
|
64
64
|
}
|
|
65
|
+
/** Parse GANGTISE_PAGE_CONCURRENCY defensively. runWithConcurrency clamps to
|
|
66
|
+
* ≥1 worker, so a negative/zero/NaN value silently degrades to SERIAL fetching
|
|
67
|
+
* (slow, confusing); an absurd value fans out up to items.length workers at
|
|
68
|
+
* once and can 429-storm the server. Fall back to the default and cap at 32. */
|
|
69
|
+
export function resolvePageConcurrency(raw, fallback = 5, max = 32) {
|
|
70
|
+
const parsed = Math.floor(Number(raw));
|
|
71
|
+
if (!Number.isFinite(parsed) || parsed < 1)
|
|
72
|
+
return fallback;
|
|
73
|
+
return Math.min(parsed, max);
|
|
74
|
+
}
|
|
65
75
|
/** Fan-out width for pagination and kline shards — one env knob tunes both. */
|
|
66
|
-
export const PAGE_CONCURRENCY =
|
|
76
|
+
export const PAGE_CONCURRENCY = resolvePageConcurrency(process.env.GANGTISE_PAGE_CONCURRENCY);
|
|
67
77
|
const RETRYABLE_HTTP_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
68
78
|
const RETRYABLE_NETWORK_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
|
|
69
79
|
const RETRYABLE_API_CODES = new Set(["999999"]);
|
|
@@ -79,9 +89,9 @@ function isRetryableError(error, policy) {
|
|
|
79
89
|
return true;
|
|
80
90
|
if (policy === "no-replay")
|
|
81
91
|
return false;
|
|
82
|
-
if (error.statusCode != null && RETRYABLE_HTTP_STATUS.has(error.statusCode))
|
|
83
|
-
return true;
|
|
84
92
|
if (error.code && RETRYABLE_API_CODES.has(error.code))
|
|
93
|
+
return policy !== "no-999999";
|
|
94
|
+
if (error.statusCode != null && RETRYABLE_HTTP_STATUS.has(error.statusCode))
|
|
85
95
|
return true;
|
|
86
96
|
return false;
|
|
87
97
|
}
|
|
@@ -100,6 +110,12 @@ function isRetryableError(error, policy) {
|
|
|
100
110
|
export function markRetryable(error) {
|
|
101
111
|
return Object.assign(error, { __retryable: true });
|
|
102
112
|
}
|
|
113
|
+
/** Errors worth waiting out (anything the default policy would retry): transient
|
|
114
|
+
* 5xx / network / timeout / 429 / 999999. Used by async polling to survive a
|
|
115
|
+
* blip without abandoning a multi-minute wait. */
|
|
116
|
+
export function isTransientError(error) {
|
|
117
|
+
return isRetryableError(error, "default");
|
|
118
|
+
}
|
|
103
119
|
export async function withRetry(fn, options = {}) {
|
|
104
120
|
const retries = options.retries ?? 2;
|
|
105
121
|
const baseDelay = options.baseDelayMs ?? 400;
|
package/dist/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated — DO NOT EDIT
|
|
2
|
-
export const CLI_VERSION = "0.
|
|
2
|
+
export const CLI_VERSION = "0.27.0";
|