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 +25 -33
- package/dist/src/cli.js +4 -4
- package/dist/src/core/args.js +9 -0
- package/dist/src/core/client.js +31 -14
- package/dist/src/core/endpoints.js +17 -0
- package/dist/src/core/errors.js +6 -1
- package/dist/src/core/output.js +5 -3
- package/dist/src/core/printer.js +16 -0
- package/dist/src/core/quoteSharding.js +21 -4
- package/dist/src/core/transport.js +39 -2
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
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()
|
|
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) });
|
package/dist/src/core/args.js
CHANGED
|
@@ -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.
|
package/dist/src/core/client.js
CHANGED
|
@@ -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:
|
|
329
|
-
bodyTimeout:
|
|
332
|
+
headersTimeout: timeoutMs,
|
|
333
|
+
bodyTimeout: timeoutMs,
|
|
330
334
|
dispatcher,
|
|
331
335
|
});
|
|
332
|
-
|
|
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
|
+
}
|
package/dist/src/core/errors.js
CHANGED
|
@@ -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
|
}
|
package/dist/src/core/output.js
CHANGED
|
@@ -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
|
|
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 >=
|
|
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;
|
package/dist/src/core/printer.js
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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++;
|
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.24.0";
|