gangtise-openapi-cli 0.25.0 → 0.26.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 +17 -26
- package/dist/src/cli.js +2 -2
- package/dist/src/core/client.js +32 -8
- package/dist/src/core/download.js +65 -14
- package/dist/src/core/endpoints.js +13 -0
- package/dist/src/core/output.js +29 -6
- package/dist/src/core/transport.js +13 -4
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,6 +4,22 @@
|
|
|
4
4
|
|
|
5
5
|
## Changelog
|
|
6
6
|
|
|
7
|
+
### v0.26.0 — 2026-07-11
|
|
8
|
+
|
|
9
|
+
**资金安全(重要)**
|
|
10
|
+
- 13 个贵档端点(`one-pager` / `investment-logic` / `peer-comparison` / `research-outline` / `theme-tracking` / `management-discuss-*`×2 / `hot-topic` / `knowledge-batch` / `earnings-review get-id` / `viewpoint-debate get-id` / `concept-info` / `concept-securities`)改为 **no-replay 重试策略**:5xx / 超时 / `999999` 不再自动重放——实测(2026-07-11)平台按次计费且**缓存命中不豁免**,同参数重放每次都扣分;仅连接期错误(`ECONNREFUSED`/DNS,请求未发出)、429 限流和 token 自愈仍重试。便宜按条计费的 list 类维持原全量重试(失败响应没有数据行、不计费)
|
|
11
|
+
- 连接失败 `ECONNREFUSED` / `UND_ERR_CONNECT_TIMEOUT` 纳入默认重试范围(此前这两类不重试)
|
|
12
|
+
|
|
13
|
+
**文件安全**
|
|
14
|
+
- 所有 `--output` 落盘(导出、流式下载主路径、签名 URL 跟随下载)改为原子写:先写同目录 `.part` 成功后 rename——重跑失败不再毁掉已有旧文件;顺带修掉中止路径上 `.part` 因流懒打开竞态残留的问题
|
|
15
|
+
- 签名 URL 跟随下载改走 transport 层:遵守 `GANGTISE_TIMEOUT_MS`(此前裸 `fetch` 无超时,慢滴速 CDN 可无限挂起)、网络错误自动重试、跟随最多 3 跳重定向(undici 不自动跟随,超限/缺 `Location` 报错而非把跳转页存成文件)、`--verbose` 日志剥离签名 query 只留 origin+path
|
|
16
|
+
- 自动命名去重后缀试尽 `-1`…`-99` 仍冲突时报错,不再静默覆盖最早的文件
|
|
17
|
+
|
|
18
|
+
**修复 / 加固**
|
|
19
|
+
- 下载重定向超过 3 跳或缺 `Location` 时报错,不再把跳转页 HTML 当文件内容保存
|
|
20
|
+
- 损坏的 gzip 响应包装为带请求上下文的 `ApiError`(此前抛裸 zlib `Z_DATA_ERROR`,与请求无关联且不可定位)
|
|
21
|
+
- `alternative edb-search --limit` ≤200、`indicator search --limit` ≤100 本地上限校验——实测服务端对超限值静默截断(201→200、101→100),与 v0.25.0 的 `--top` 同类同修法
|
|
22
|
+
|
|
7
23
|
### v0.25.0 — 2026-07-10
|
|
8
24
|
|
|
9
25
|
**新增接口(4)**
|
|
@@ -60,31 +76,6 @@
|
|
|
60
76
|
**文档 / Skill**(随 `/sync-skill` 分发,不影响 CLI 行为)
|
|
61
77
|
- gangtise-openapi Agent Skill 经 fable5 审计 + 多轮 review 优化:官方积分计费速查表 + 高积分 pre-flight 闸门、AI 同步生成命令 `GANGTISE_TIMEOUT_MS=120000` 超时前置、大结果集 `--output` 落盘、异步 `--wait` 主路径、行业码口径收敛到单一权威、市值量纲实测(`qte_mkt_cptl` 仅 A 股 / 默认原始「元」/ `scale`+`currency`)等文档补全与消歧
|
|
62
78
|
|
|
63
|
-
### v0.22.0 — 2026-07-02
|
|
64
|
-
|
|
65
|
-
**行为变更(注意)**
|
|
66
|
-
- ⚠️ 自动翻页接口省略 `--size` 现在一律拉全量(不再区分是否传时间范围);需要只取前 N 条时请显式传 `--size N`。数据量未知时可先用 `--size 1` 从 stderr 的 `Total: N` 探明量级
|
|
67
|
-
- 部分结果可机器识别:翻页页失败、K 线分片失败、或服务端提前短页但仍报告更大 `total` 时,结果会带 `partial: true`(页失败另有 `failedPages`,分片为 `failedShards`),非 json 行式输出仍只输出数据行,但进程退出码为 3
|
|
68
|
-
|
|
69
|
-
**修复(鉴权 / 请求可靠性)**
|
|
70
|
-
- Token 自愈覆盖服务端 `0000001008` 踢线失效,并能处理 HTTP 4xx 错误信封;`GANGTISE_TOKEN` + AK/SK 场景下环境 token 失效后不再反复回放旧 token
|
|
71
|
-
- 并发请求同时遇到旧 token 失效时复用一次刷新结果;若刚拿到的新 token 本身被踢掉,则强制再次登录,避免"刚登录窗口期"误跳过刷新
|
|
72
|
-
- 自动重试范围扩展到 429、DNS/网络临时错误与 undici 超时类错误;`GANGTISE_BASE_URL` 带路径前缀时 URL 拼接不再丢前缀
|
|
73
|
-
|
|
74
|
-
**修复(下载 / 输出 / 数据正确性)**
|
|
75
|
-
- 下载接口跟随最多 3 次 30x 跳转;跨域跳到对象存储签名 URL 时不携带 Authorization;服务端返回 `{url}` 且用户传 `--output` 时会真正下载文件,而不是把 URL 字符串写进文件
|
|
76
|
-
- 自动文件名补齐清洗、截断与去重:服务端文件名、标题缓存名和 fallback 名都不会把 `/`、控制字符、过长中文名或重复标题变成路径/覆盖问题
|
|
77
|
-
- `table`/`markdown` 输出清理控制字符、正确按 CJK 宽字符对齐,并转义 markdown 表头中的 `|`;CSV 输出转义表头、文件输出带 UTF-8 BOM,流式 CSV 遇全标量列表时回退到正常渲染而不是只写 BOM
|
|
78
|
-
- `indicator search` / `cross-section` / `time-series` 的内层失败信封即使没有 `data` 字段也会抛出 `ApiError`,不再把"无权限/参数错误"渲染成成功结果
|
|
79
|
-
- `--indicator-param` 等逗号列表支持全角逗号 `,`;日期型时间参数按本地零点解析,避免 `yyyy-MM-dd` 被当作 UTC 造成查询窗口偏移
|
|
80
|
-
- `fundamental earning-forecast` 省略 `--start-date` 时按传入的 `--end-date` 往前一年计算,不再总是按今天往前一年
|
|
81
|
-
- AI 异步 `--wait` 对 `410111` 终态失败只提示"不要重试",超时才提示稍后用 check 命令查询;等待说明同步为最长约 5 分钟
|
|
82
|
-
|
|
83
|
-
**CLI / 工程**
|
|
84
|
-
- `raw call` 会在本地拒绝 JSON endpoint 的 `--query` 和 download endpoint 的 `--body`,避免静默丢参数;`--format` 在发请求前校验,格式拼错不再先消耗接口调用
|
|
85
|
-
- `gangtise ... | head` 遇 stdout `EPIPE` 时安静退出;只有首个参数是 `--version` / `-V` 时才触发版本快捷路径
|
|
86
|
-
- Endpoint registry 的 `key` 改为由记录键自动派生,减少映射漂移;新增真实 CLI 选项到请求体的 stub 测试;测试 272 → 323
|
|
87
|
-
|
|
88
79
|
> 更早版本及完整更新历史见 [CHANGELOG.md](CHANGELOG.md)。
|
|
89
80
|
|
|
90
81
|
## 首次安装
|
|
@@ -311,7 +302,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
|
|
|
311
302
|
- **HTTP keep-alive**:所有请求复用同一个 `undici.Agent`(连接池 16),避免重复 TLS 握手。
|
|
312
303
|
- **流式下载**:指定 `--output` 时,二进制响应(PDF 等)直接 `pipeline` 到磁盘,不经过内存缓冲;50MB PDF 内存占用近乎为零。
|
|
313
304
|
- **流式输出**:`jsonl`/`csv` 格式且 `--output` 指定时,超过 1000 行自动切换为逐行写盘,避免一次性构建百 MB 字符串。
|
|
314
|
-
- **自动重试**:5xx / 429 / `ECONNRESET` / `ETIMEDOUT` / `ENOTFOUND` / `EAI_AGAIN` / `UND_ERR_*`(undici
|
|
305
|
+
- **自动重试**:5xx / 429 / `ECONNREFUSED` / `ECONNRESET` / `ETIMEDOUT` / `ENOTFOUND` / `EAI_AGAIN` / `UND_ERR_*`(undici 连接/超时类)/ `999999` 系统错误自动指数退避重试 2 次。**贵档生成类端点例外**(one-pager 等 13 个):5xx/超时不重放——按次计费不幂等,重放即重复扣分;仅连接失败、429 与 token 自愈重试。
|
|
315
306
|
- **Token 自愈**:调用返回 8000014/8000015 时自动强制刷新 Token 并重试一次。
|
|
316
307
|
- **K线/资金流向自动分片**:`quote day-kline --security all`、`quote fund-flow --security aShares` 等全市场查询自动按日期切分(A股 K线/资金流向 1 天/片、美股 1 天/片、HK 2 天/片、指数 30 天/片),并发执行后合并结果;按日分片自动跳过周六日。分片时如果用户未传 `--limit`,自动注入 `limit: 10000`(API 上限)避免默认 6000 截断。
|
|
317
308
|
- **Token 内存缓存**:Token 在进程内存中缓存,避免每次请求读盘。
|
package/dist/src/cli.js
CHANGED
|
@@ -601,7 +601,7 @@ program.addCommand(ai);
|
|
|
601
601
|
const alternative = new Command("alternative").description("Alternative data APIs");
|
|
602
602
|
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((options) => emit(options, (client) => client.call("alternative.edb-search", {
|
|
603
603
|
keyword: options.keyword,
|
|
604
|
-
limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1 }),
|
|
604
|
+
limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 200 }),
|
|
605
605
|
})));
|
|
606
606
|
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) => {
|
|
607
607
|
const format = parseOutputFormat(options.format);
|
|
@@ -628,7 +628,7 @@ indicator.command("search").requiredOption("--keyword <text>", "Search keyword,
|
|
|
628
628
|
const format = parseOutputFormat(options.format);
|
|
629
629
|
const raw = await client.call("indicator.search", {
|
|
630
630
|
keyword: options.keyword,
|
|
631
|
-
limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1 }),
|
|
631
|
+
limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 100 }),
|
|
632
632
|
});
|
|
633
633
|
await printData(unwrapIndicatorData(raw), format, options.output);
|
|
634
634
|
}));
|
package/dist/src/core/client.js
CHANGED
|
@@ -354,9 +354,22 @@ export class GangtiseClient {
|
|
|
354
354
|
// response reads as text directly (and keeps existing behavior on that path).
|
|
355
355
|
const encoding = response.headers['content-encoding'];
|
|
356
356
|
const gzipped = (Array.isArray(encoding) ? encoding[0] : encoding)?.toLowerCase().trim() === 'gzip';
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
357
|
+
let text;
|
|
358
|
+
if (gzipped) {
|
|
359
|
+
// A proxy/middlebox can declare gzip and deliver garbage — surface that as
|
|
360
|
+
// an ApiError with request context instead of a bare zlib Z_DATA_ERROR.
|
|
361
|
+
const bytes = new Uint8Array(await response.body.arrayBuffer());
|
|
362
|
+
try {
|
|
363
|
+
text = decodeResponseBody(bytes, encoding);
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
367
|
+
throw new ApiError(`Failed to decode gzip response for ${endpoint.method} ${endpoint.path}: ${detail}`, undefined, response.statusCode);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
text = await response.body.text();
|
|
372
|
+
}
|
|
360
373
|
logTiming(`${endpoint.method} ${endpoint.path}`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
|
|
361
374
|
// Parse Retry-After once so every error path below (JSON parse failure AND the
|
|
362
375
|
// envelope/HTTP-error throw) carries it — a non-JSON 429/503 must still honor
|
|
@@ -385,6 +398,7 @@ export class GangtiseClient {
|
|
|
385
398
|
throw error;
|
|
386
399
|
}
|
|
387
400
|
}, {
|
|
401
|
+
policy: endpoint.retry === "no-replay" ? "no-replay" : "default",
|
|
388
402
|
onRetry: (attempt, error, delay) => {
|
|
389
403
|
if (!isVerbose())
|
|
390
404
|
return;
|
|
@@ -434,6 +448,13 @@ export class GangtiseClient {
|
|
|
434
448
|
dispatcher,
|
|
435
449
|
});
|
|
436
450
|
}
|
|
451
|
+
// The loop above can exit with a 3xx still in hand (hop limit exceeded, or a
|
|
452
|
+
// redirect without Location) — that response must never be treated as file
|
|
453
|
+
// content: its HTML placeholder body would be saved as the "downloaded file".
|
|
454
|
+
if (response.statusCode >= 300 && response.statusCode < 400) {
|
|
455
|
+
await response.body.text().catch(() => { });
|
|
456
|
+
throw new ApiError(`Download failed: unresolved redirect (HTTP ${response.statusCode})`, undefined, response.statusCode);
|
|
457
|
+
}
|
|
437
458
|
const contentType = Array.isArray(response.headers['content-type']) ? response.headers['content-type'][0] : response.headers['content-type'];
|
|
438
459
|
// From the final (post-redirect) response, so a rate-limited download honors
|
|
439
460
|
// Retry-After too — every error branch below passes it into the ApiError.
|
|
@@ -499,14 +520,17 @@ export class GangtiseClient {
|
|
|
499
520
|
// Stream directly to disk when caller already knows the destination
|
|
500
521
|
if (options?.streamTo) {
|
|
501
522
|
await fs.mkdir(path.dirname(options.streamTo), { recursive: true });
|
|
523
|
+
// Stream into a .part sibling and rename over the target only on success:
|
|
524
|
+
// writing to the target directly would truncate an existing file on the
|
|
525
|
+
// FIRST byte and delete it on failure — a failed re-download (or each
|
|
526
|
+
// withRetry attempt) must never destroy the user's previous good file.
|
|
527
|
+
const partPath = `${options.streamTo}.part`;
|
|
502
528
|
try {
|
|
503
|
-
await pipeline(response.body, createWriteStream(
|
|
529
|
+
await pipeline(response.body, createWriteStream(partPath));
|
|
530
|
+
await fs.rename(partPath, options.streamTo);
|
|
504
531
|
}
|
|
505
532
|
catch (error) {
|
|
506
|
-
|
|
507
|
-
// failed download never looks like a complete one. withRetry may still
|
|
508
|
-
// replay the request (the next attempt re-creates the file).
|
|
509
|
-
await fs.unlink(options.streamTo).catch(() => { });
|
|
533
|
+
await fs.unlink(partPath).catch(() => { });
|
|
510
534
|
throw error;
|
|
511
535
|
}
|
|
512
536
|
logTiming(`GET ${endpoint.path} (stream)`, Date.now() - startedAt, `${response.statusCode}`);
|
|
@@ -26,7 +26,9 @@ function truncateFilename(name, maxBytes = 200) {
|
|
|
26
26
|
/** Pick a non-existing path by suffixing -1, -2, … before the extension, so batch
|
|
27
27
|
* downloads whose titles collide ("2025年第一季度报告" from several companies) don't
|
|
28
28
|
* silently overwrite each other. Only auto-derived names go through this — an
|
|
29
|
-
* explicit --output path keeps plain overwrite semantics.
|
|
29
|
+
* explicit --output path keeps plain overwrite semantics. Throws instead of
|
|
30
|
+
* falling back to the original path once the suffixes run out: returning `p`
|
|
31
|
+
* there would silently overwrite the very first file. */
|
|
30
32
|
export async function uniquePath(p) {
|
|
31
33
|
const exists = (f) => fs.access(f).then(() => true, () => false);
|
|
32
34
|
if (!(await exists(p)))
|
|
@@ -38,7 +40,7 @@ export async function uniquePath(p) {
|
|
|
38
40
|
if (!(await exists(candidate)))
|
|
39
41
|
return candidate;
|
|
40
42
|
}
|
|
41
|
-
|
|
43
|
+
throw new DownloadError(`Refusing to overwrite: 100 files already share the name "${p}" — pass --output or clean up the directory`);
|
|
42
44
|
}
|
|
43
45
|
const MIME_EXT = {
|
|
44
46
|
"application/pdf": ".pdf",
|
|
@@ -106,21 +108,70 @@ export async function resolveTitle(client, result, listEndpoint, idField, idValu
|
|
|
106
108
|
}
|
|
107
109
|
async function downloadUrlTo(url, outputPath) {
|
|
108
110
|
const { createWriteStream } = await import("node:fs");
|
|
109
|
-
const { Readable } = await import("node:stream");
|
|
110
111
|
const { pipeline } = await import("node:stream/promises");
|
|
111
112
|
const { dirname } = await import("node:path");
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
const { request } = await import("undici");
|
|
114
|
+
const { getDispatcher, logTiming, withRetry } = await import("./transport.js");
|
|
115
|
+
const { loadConfig } = await import("./config.js");
|
|
116
|
+
// Through the transport layer instead of a bare global fetch: the configured
|
|
117
|
+
// timeout applies (a slow-drip CDN can no longer hang the CLI indefinitely),
|
|
118
|
+
// network-level failures retry, and --verbose logs the request.
|
|
119
|
+
const timeoutMs = loadConfig().timeoutMs;
|
|
116
120
|
await fs.mkdir(dirname(outputPath), { recursive: true });
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
// Signed URLs carry credentials in the query string — verbose logs must strip
|
|
122
|
+
// search/hash so signatures never land in terminal/CI logs.
|
|
123
|
+
const redactUrl = (u) => {
|
|
124
|
+
try {
|
|
125
|
+
const parsed = new URL(u);
|
|
126
|
+
return parsed.origin + parsed.pathname;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return "signed-url";
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
// .part + rename so a failed follow-download never destroys an existing file.
|
|
133
|
+
const partPath = `${outputPath}.part`;
|
|
134
|
+
await withRetry(async () => {
|
|
135
|
+
const startedAt = Date.now();
|
|
136
|
+
const requestOptions = {
|
|
137
|
+
method: "GET",
|
|
138
|
+
headersTimeout: timeoutMs,
|
|
139
|
+
bodyTimeout: timeoutMs,
|
|
140
|
+
dispatcher: getDispatcher(),
|
|
141
|
+
};
|
|
142
|
+
let currentUrl = url;
|
|
143
|
+
let response = await request(currentUrl, requestOptions);
|
|
144
|
+
// undici does not follow redirects (the old global fetch did): a signed URL
|
|
145
|
+
// may 302 to the real CDN object. Follow a bounded number of hops; no
|
|
146
|
+
// Authorization header is involved on this path, so nothing leaks cross-origin.
|
|
147
|
+
for (let hops = 0; hops < 3 && response.statusCode >= 300 && response.statusCode < 400; hops++) {
|
|
148
|
+
const locationHeader = response.headers.location;
|
|
149
|
+
const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader;
|
|
150
|
+
if (!location)
|
|
151
|
+
break;
|
|
152
|
+
await response.body.text().catch(() => { });
|
|
153
|
+
currentUrl = new URL(location, currentUrl).toString();
|
|
154
|
+
response = await request(currentUrl, requestOptions);
|
|
155
|
+
}
|
|
156
|
+
if (response.statusCode >= 300 && response.statusCode < 400) {
|
|
157
|
+
await response.body.text().catch(() => { });
|
|
158
|
+
throw new DownloadError(`Failed to fetch download URL: unresolved redirect (HTTP ${response.statusCode})`);
|
|
159
|
+
}
|
|
160
|
+
if (response.statusCode >= 400) {
|
|
161
|
+
await response.body.text().catch(() => { });
|
|
162
|
+
// DownloadError (not retried): signed URLs expire — replaying a 403 is useless.
|
|
163
|
+
throw new DownloadError(`Failed to fetch download URL (HTTP ${response.statusCode})`);
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
await pipeline(response.body, createWriteStream(partPath));
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
await fs.unlink(partPath).catch(() => { });
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
logTiming(`GET ${redactUrl(currentUrl)}`, Date.now() - startedAt, String(response.statusCode));
|
|
173
|
+
});
|
|
174
|
+
await fs.rename(partPath, outputPath);
|
|
124
175
|
}
|
|
125
176
|
export async function saveDownloadResult(result, fallbackName, output) {
|
|
126
177
|
if (!(result && typeof result === "object")) {
|
|
@@ -394,6 +394,7 @@ const ENDPOINT_DEFS = {
|
|
|
394
394
|
path: "/application/open-data/ai/search/knowledge/batch",
|
|
395
395
|
kind: "json",
|
|
396
396
|
description: "Batch knowledge search",
|
|
397
|
+
retry: "no-replay",
|
|
397
398
|
},
|
|
398
399
|
"ai.knowledge-resource.download": {
|
|
399
400
|
method: "GET",
|
|
@@ -414,6 +415,7 @@ const ENDPOINT_DEFS = {
|
|
|
414
415
|
kind: "json",
|
|
415
416
|
description: "Generate one pager",
|
|
416
417
|
timeoutMs: 120_000,
|
|
418
|
+
retry: "no-replay",
|
|
417
419
|
},
|
|
418
420
|
"ai.investment-logic": {
|
|
419
421
|
method: "POST",
|
|
@@ -421,6 +423,7 @@ const ENDPOINT_DEFS = {
|
|
|
421
423
|
kind: "json",
|
|
422
424
|
description: "Generate investment logic",
|
|
423
425
|
timeoutMs: 120_000,
|
|
426
|
+
retry: "no-replay",
|
|
424
427
|
},
|
|
425
428
|
"ai.peer-comparison": {
|
|
426
429
|
method: "POST",
|
|
@@ -428,12 +431,14 @@ const ENDPOINT_DEFS = {
|
|
|
428
431
|
kind: "json",
|
|
429
432
|
description: "Generate peer comparison",
|
|
430
433
|
timeoutMs: 120_000,
|
|
434
|
+
retry: "no-replay",
|
|
431
435
|
},
|
|
432
436
|
"ai.earnings-review.get-id": {
|
|
433
437
|
method: "POST",
|
|
434
438
|
path: "/application/open-ai/agent/earnings-review-getid",
|
|
435
439
|
kind: "json",
|
|
436
440
|
description: "Get earnings review ID",
|
|
441
|
+
retry: "no-replay",
|
|
437
442
|
},
|
|
438
443
|
"ai.earnings-review.get-content": {
|
|
439
444
|
method: "POST",
|
|
@@ -447,6 +452,7 @@ const ENDPOINT_DEFS = {
|
|
|
447
452
|
kind: "json",
|
|
448
453
|
description: "Get theme tracking daily report",
|
|
449
454
|
timeoutMs: 120_000,
|
|
455
|
+
retry: "no-replay",
|
|
450
456
|
},
|
|
451
457
|
"ai.research-outline": {
|
|
452
458
|
method: "POST",
|
|
@@ -454,6 +460,7 @@ const ENDPOINT_DEFS = {
|
|
|
454
460
|
kind: "json",
|
|
455
461
|
description: "Get company research outline",
|
|
456
462
|
timeoutMs: 120_000,
|
|
463
|
+
retry: "no-replay",
|
|
457
464
|
},
|
|
458
465
|
"ai.hot-topic": {
|
|
459
466
|
method: "POST",
|
|
@@ -461,6 +468,7 @@ const ENDPOINT_DEFS = {
|
|
|
461
468
|
kind: "json",
|
|
462
469
|
description: "List hot topic reports",
|
|
463
470
|
pagination: { enabled: true, maxPageSize: 20 },
|
|
471
|
+
retry: "no-replay",
|
|
464
472
|
},
|
|
465
473
|
"ai.management-discuss-announcement": {
|
|
466
474
|
method: "POST",
|
|
@@ -468,6 +476,7 @@ const ENDPOINT_DEFS = {
|
|
|
468
476
|
kind: "json",
|
|
469
477
|
description: "Management discussion from financial reports (half-year/annual)",
|
|
470
478
|
timeoutMs: 120_000,
|
|
479
|
+
retry: "no-replay",
|
|
471
480
|
},
|
|
472
481
|
"ai.management-discuss-earnings-call": {
|
|
473
482
|
method: "POST",
|
|
@@ -475,12 +484,14 @@ const ENDPOINT_DEFS = {
|
|
|
475
484
|
kind: "json",
|
|
476
485
|
description: "Management discussion from earnings calls",
|
|
477
486
|
timeoutMs: 120_000,
|
|
487
|
+
retry: "no-replay",
|
|
478
488
|
},
|
|
479
489
|
"ai.viewpoint-debate.get-id": {
|
|
480
490
|
method: "POST",
|
|
481
491
|
path: "/application/open-ai/agent/viewpoint-debate-getid",
|
|
482
492
|
kind: "json",
|
|
483
493
|
description: "Get viewpoint debate ID",
|
|
494
|
+
retry: "no-replay",
|
|
484
495
|
},
|
|
485
496
|
"ai.viewpoint-debate.get-content": {
|
|
486
497
|
method: "POST",
|
|
@@ -573,12 +584,14 @@ const ENDPOINT_DEFS = {
|
|
|
573
584
|
path: "/application/open-alternative/concept/info",
|
|
574
585
|
kind: "json",
|
|
575
586
|
description: "Query latest concept (theme index) profile by conceptId",
|
|
587
|
+
retry: "no-replay",
|
|
576
588
|
},
|
|
577
589
|
"alternative.concept-securities": {
|
|
578
590
|
method: "POST",
|
|
579
591
|
path: "/application/open-alternative/concept/securities",
|
|
580
592
|
kind: "json",
|
|
581
593
|
description: "Query concept (theme index) constituent securities, grouped",
|
|
594
|
+
retry: "no-replay",
|
|
582
595
|
},
|
|
583
596
|
// ─── indicator (EDE: security-level data indicators) ───
|
|
584
597
|
"indicator.search": {
|
package/dist/src/core/output.js
CHANGED
|
@@ -147,7 +147,10 @@ export async function streamOutputToFile(value, format, outputPath) {
|
|
|
147
147
|
const { dirname } = await import("node:path");
|
|
148
148
|
const { createWriteStream } = await import("node:fs");
|
|
149
149
|
await fs.mkdir(dirname(outputPath), { recursive: true });
|
|
150
|
-
|
|
150
|
+
// Stream into a .part sibling and rename over the target only on success, so a
|
|
151
|
+
// failed re-export never destroys the previous good file.
|
|
152
|
+
const partPath = `${outputPath}.part`;
|
|
153
|
+
const stream = createWriteStream(partPath, { encoding: "utf8" });
|
|
151
154
|
// A stream 'error' with no listener (EACCES on open, ENOSPC mid-write) crashes the
|
|
152
155
|
// process before any write callback fires. Swallow the event here — the failure
|
|
153
156
|
// still surfaces through the write/end callbacks below.
|
|
@@ -169,11 +172,20 @@ export async function streamOutputToFile(value, format, outputPath) {
|
|
|
169
172
|
await new Promise((resolve, reject) => {
|
|
170
173
|
stream.end((err) => err ? reject(err) : resolve());
|
|
171
174
|
});
|
|
175
|
+
await fs.rename(partPath, outputPath);
|
|
172
176
|
}
|
|
173
177
|
catch (error) {
|
|
174
178
|
// Mirror the download path: never leave a truncated file that looks complete.
|
|
179
|
+
// createWriteStream opens lazily — an early abort can reach unlink BEFORE the
|
|
180
|
+
// open() creates the file, so wait for 'close' (fd released or open aborted)
|
|
181
|
+
// or the .part would reappear right after being "removed".
|
|
175
182
|
stream.destroy();
|
|
176
|
-
|
|
183
|
+
// Already-closed happens when the failure was the rename (stream ended fine);
|
|
184
|
+
// then 'close' has fired and waiting for it again would hang forever.
|
|
185
|
+
if (!stream.closed) {
|
|
186
|
+
await new Promise((resolve) => stream.once("close", resolve));
|
|
187
|
+
}
|
|
188
|
+
await fs.unlink(partPath).catch(() => { });
|
|
177
189
|
throw error;
|
|
178
190
|
}
|
|
179
191
|
return true;
|
|
@@ -222,9 +234,20 @@ export async function saveOutputIfNeeded(content, outputPath) {
|
|
|
222
234
|
}
|
|
223
235
|
const { dirname } = await import("node:path");
|
|
224
236
|
await fs.mkdir(dirname(outputPath), { recursive: true });
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
237
|
+
// Write to a .part sibling and rename over the target only on success, so a
|
|
238
|
+
// failed re-export/download never destroys the previous good file.
|
|
239
|
+
const partPath = `${outputPath}.part`;
|
|
240
|
+
try {
|
|
241
|
+
if (typeof content === "string") {
|
|
242
|
+
await fs.writeFile(partPath, content, "utf8");
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
await fs.writeFile(partPath, content);
|
|
246
|
+
}
|
|
247
|
+
await fs.rename(partPath, outputPath);
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
await fs.unlink(partPath).catch(() => { });
|
|
251
|
+
throw error;
|
|
228
252
|
}
|
|
229
|
-
await fs.writeFile(outputPath, content);
|
|
230
253
|
}
|
|
@@ -65,13 +65,20 @@ export async function runWithConcurrency(items, concurrency, fn) {
|
|
|
65
65
|
/** Fan-out width for pagination and kline shards — one env knob tunes both. */
|
|
66
66
|
export const PAGE_CONCURRENCY = Number(process.env.GANGTISE_PAGE_CONCURRENCY ?? 5) || 5;
|
|
67
67
|
const RETRYABLE_HTTP_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
68
|
-
const RETRYABLE_NETWORK_CODES = new Set(["ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_SOCKET", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
|
|
68
|
+
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
69
|
const RETRYABLE_API_CODES = new Set(["999999"]);
|
|
70
|
-
|
|
70
|
+
// Connect-phase / DNS failures: the request provably never reached the server, so a
|
|
71
|
+
// replay cannot double-execute (or double-bill) anything even under "no-replay".
|
|
72
|
+
const NO_REPLAY_NETWORK_CODES = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT"]);
|
|
73
|
+
function isRetryableError(error, policy) {
|
|
71
74
|
if (error && typeof error === "object" && error.__retryable === true) {
|
|
72
75
|
return true;
|
|
73
76
|
}
|
|
74
77
|
if (error instanceof ApiError) {
|
|
78
|
+
if (error.statusCode === 429)
|
|
79
|
+
return true;
|
|
80
|
+
if (policy === "no-replay")
|
|
81
|
+
return false;
|
|
75
82
|
if (error.statusCode != null && RETRYABLE_HTTP_STATUS.has(error.statusCode))
|
|
76
83
|
return true;
|
|
77
84
|
if (error.code && RETRYABLE_API_CODES.has(error.code))
|
|
@@ -80,9 +87,11 @@ function isRetryableError(error) {
|
|
|
80
87
|
}
|
|
81
88
|
if (error && typeof error === "object" && "code" in error) {
|
|
82
89
|
const code = String(error.code);
|
|
83
|
-
if (RETRYABLE_NETWORK_CODES.has(code))
|
|
90
|
+
if ((policy === "no-replay" ? NO_REPLAY_NETWORK_CODES : RETRYABLE_NETWORK_CODES).has(code))
|
|
84
91
|
return true;
|
|
85
92
|
}
|
|
93
|
+
if (policy === "no-replay")
|
|
94
|
+
return false;
|
|
86
95
|
if (error instanceof Error && /timeout|ETIMEDOUT|ECONNRESET|socket hang up/i.test(error.message)) {
|
|
87
96
|
return true;
|
|
88
97
|
}
|
|
@@ -101,7 +110,7 @@ export async function withRetry(fn, options = {}) {
|
|
|
101
110
|
return await fn();
|
|
102
111
|
}
|
|
103
112
|
catch (error) {
|
|
104
|
-
if (attempt >= retries || !isRetryableError(error))
|
|
113
|
+
if (attempt >= retries || !isRetryableError(error, options.policy ?? "default"))
|
|
105
114
|
throw error;
|
|
106
115
|
// A server-sent Retry-After (429) wins over exponential backoff, but is capped
|
|
107
116
|
// so it can't stall the CLI; otherwise fall back to jittered exponential backoff.
|
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.26.0";
|