gangtise-openapi-cli 0.26.0 → 0.28.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 +109 -32
- package/dist/src/cli.js +35 -30
- package/dist/src/core/args.js +152 -19
- package/dist/src/core/asyncContent.js +43 -7
- package/dist/src/core/client.js +38 -12
- package/dist/src/core/download.js +19 -5
- package/dist/src/core/endpoints.js +7 -0
- package/dist/src/core/errors.js +121 -20
- package/dist/src/core/indicatorMatrix.js +8 -2
- package/dist/src/core/output.js +29 -7
- package/dist/src/core/quoteSharding.js +9 -5
- package/dist/src/core/transport.js +34 -3
- package/dist/src/version.js +1 -1
- package/gangtise-openapi/SKILL.md +369 -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 +188 -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/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.
|
|
@@ -73,38 +90,154 @@ export function parseChoiceList(values, optionName, allowed) {
|
|
|
73
90
|
}
|
|
74
91
|
return maybeArray(values);
|
|
75
92
|
}
|
|
93
|
+
const YYYY_MM_DD = /^\d{4}-\d{2}-\d{2}$/;
|
|
94
|
+
/**
|
|
95
|
+
* Strict `YYYY-MM-DD` guard for Quote/Fundamental date options.
|
|
96
|
+
*
|
|
97
|
+
* Beyond the documented shape the server accepts two extra year-last formats
|
|
98
|
+
* whose day/month order is *opposite* to each other (probed 2026-07-20 on
|
|
99
|
+
* quote/kline/daily and fundamental/balance-sheet; other groups sharing these
|
|
100
|
+
* flags were not probed, so the guard is applied uniformly as the safe default):
|
|
101
|
+
*
|
|
102
|
+
* "07/01/2026" -> 2026-01-07 slash reads DD/MM/YYYY
|
|
103
|
+
* "07-01-2026" -> 2026-07-01 hyphen reads MM-DD-YYYY
|
|
104
|
+
*
|
|
105
|
+
* Same three digits, six months apart, both HTTP 200, and nothing in the
|
|
106
|
+
* response echoes which date the server actually used. Confirmed by the
|
|
107
|
+
* complement: "25/12/2026" parses while "12/25/2026" errors, and the hyphen
|
|
108
|
+
* forms behave exactly the other way round. Since the CLI cannot know which
|
|
109
|
+
* reading was meant, it forwards only the unambiguous form.
|
|
110
|
+
*
|
|
111
|
+
* Other unambiguous shapes (`20260701`, `2026/07/01`) are rejected too, even
|
|
112
|
+
* though the server handles them — one accepted form beats a per-shape allowlist
|
|
113
|
+
* that has to be re-probed per endpoint group. The message says which form is
|
|
114
|
+
* wanted rather than claiming the input itself was ambiguous.
|
|
115
|
+
*
|
|
116
|
+
* Datetime options (`--start-time`) are guarded separately: pass-through ones by
|
|
117
|
+
* `parseDatetimeOption` (a timezone-free field check that returns the string as-is),
|
|
118
|
+
* and the two conversion endpoints (A-share announcement / knowledge-batch) by
|
|
119
|
+
* `parseTimestamp13`.
|
|
120
|
+
*/
|
|
121
|
+
export function parseDateOption(value, optionName) {
|
|
122
|
+
if (!YYYY_MM_DD.test(value)) {
|
|
123
|
+
throw new ValidationError(`Invalid ${optionName}: expected YYYY-MM-DD, got "${value}" — only that form is forwarded; some endpoints silently misread other layouts (e.g. "07/01/2026") as a different day`);
|
|
124
|
+
}
|
|
125
|
+
// Shape alone lets 2026-02-30 / 2026-13-01 through; round-trip to reject those.
|
|
126
|
+
// Built from the ISO string, not Date.UTC(y,...), whose two-digit-year mapping
|
|
127
|
+
// would turn a valid year 0050 into 1950 and report it as a non-existent date.
|
|
128
|
+
const [year, month, day] = value.split("-").map(Number);
|
|
129
|
+
const parsed = new Date(`${value}T00:00:00Z`);
|
|
130
|
+
if (parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1 || parsed.getUTCDate() !== day) {
|
|
131
|
+
throw new ValidationError(`Invalid ${optionName}: "${value}" is not a real calendar date`);
|
|
132
|
+
}
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
/** Commander argParser factory — `.option("--start-date <date>", desc, dateArg("--start-date"))`. */
|
|
136
|
+
export function dateArg(optionName) {
|
|
137
|
+
return (value) => parseDateOption(value, optionName);
|
|
138
|
+
}
|
|
139
|
+
/** `yyyy-MM-dd` with an optional ` HH:mm[:ss]` / `THH:mm[:ss]` tail. Anything else
|
|
140
|
+
* is rejected rather than handed to `new Date()`: V8's fallback parser accepts the
|
|
141
|
+
* same year-last shapes the server does but reads them the OTHER way round —
|
|
142
|
+
* `07/01/2026` is July 1 to V8 and January 7 to the server, `25/12/2026` is invalid
|
|
143
|
+
* to V8 and valid to the server (both probed 2026-07-20). Since `announcement list`
|
|
144
|
+
* converts locally while its HK/US siblings pass the string through, an open
|
|
145
|
+
* fallback made the same flag mean two dates six months apart across sibling
|
|
146
|
+
* commands, silently and with exit 0. */
|
|
147
|
+
const LOCAL_DATETIME = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
|
|
148
|
+
/** Field-level datetime validation, timezone-free — a real calendar day plus a
|
|
149
|
+
* valid clock time, judged by arithmetic alone (no Date construction, so no
|
|
150
|
+
* dependence on the client's timezone or DST). This is what the pass-through guard
|
|
151
|
+
* needs: a string the CLI forwards verbatim must be judged on its fields, not on
|
|
152
|
+
* whether the local zone happens to contain that wall-clock instant. */
|
|
153
|
+
function datetimeFieldsValid(value) {
|
|
154
|
+
const parts = LOCAL_DATETIME.exec(value);
|
|
155
|
+
if (!parts)
|
|
156
|
+
return false;
|
|
157
|
+
const [, y, mo, d, hh = "0", mi = "0", ss = "0"] = parts;
|
|
158
|
+
const year = Number(y), month = Number(mo), day = Number(d);
|
|
159
|
+
if (month < 1 || month > 12)
|
|
160
|
+
return false;
|
|
161
|
+
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
162
|
+
const dim = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
|
|
163
|
+
if (day < 1 || day > dim)
|
|
164
|
+
return false;
|
|
165
|
+
return Number(hh) <= 23 && Number(mi) <= 59 && Number(ss) <= 59;
|
|
166
|
+
}
|
|
167
|
+
/** A 10-digit seconds or 13-digit millis epoch, normalized to millis. Judged by
|
|
168
|
+
* digit count, NOT magnitude: a `> 1e12` test sends a real 13-digit `1000000000000`
|
|
169
|
+
* (which equals 1e12) down the seconds branch, and lets Number() coerce scientific
|
|
170
|
+
* / hex / whitespace-padded / 11–12–14-digit inputs through as a "timestamp" — all
|
|
171
|
+
* of which then convert wrong or get rejected by the server. Exactly 10 or 13
|
|
172
|
+
* digits, nothing else. */
|
|
173
|
+
function epochMillis(value) {
|
|
174
|
+
if (/^\d{13}$/.test(value))
|
|
175
|
+
return Number(value);
|
|
176
|
+
if (/^\d{10}$/.test(value))
|
|
177
|
+
return Number(value) * 1000;
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
76
180
|
export function toTimestamp13(value) {
|
|
77
181
|
if (value === undefined)
|
|
78
182
|
return undefined;
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
81
|
-
return
|
|
82
|
-
if (!
|
|
83
|
-
return num * 1000;
|
|
84
|
-
// `new Date("yyyy-MM-dd")` parses as UTC midnight while `new Date("yyyy-MM-dd HH:mm:ss")`
|
|
85
|
-
// parses as local time — for CST users the two forms would differ by 8 hours and
|
|
86
|
-
// silently shift the query window. Anchor date-only input to local midnight so both
|
|
87
|
-
// forms mean the same wall-clock day.
|
|
88
|
-
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
89
|
-
if (dateOnly) {
|
|
90
|
-
const d = new Date(Number(dateOnly[1]), Number(dateOnly[2]) - 1, Number(dateOnly[3]));
|
|
91
|
-
const valid = d.getMonth() === Number(dateOnly[2]) - 1 && d.getDate() === Number(dateOnly[3]);
|
|
92
|
-
return valid ? d.getTime() : undefined;
|
|
93
|
-
}
|
|
94
|
-
const ms = new Date(value).getTime();
|
|
95
|
-
if (Number.isNaN(ms))
|
|
183
|
+
const ts = epochMillis(value);
|
|
184
|
+
if (ts !== undefined)
|
|
185
|
+
return ts;
|
|
186
|
+
if (!datetimeFieldsValid(value))
|
|
96
187
|
return undefined;
|
|
97
|
-
|
|
188
|
+
// `new Date("yyyy-MM-dd")` parses as UTC midnight while `new Date("yyyy-MM-dd
|
|
189
|
+
// HH:mm:ss")` parses as local time — for CST users the two forms would differ by 8
|
|
190
|
+
// hours. Build from local components so both mean the same wall-clock day.
|
|
191
|
+
const parts = LOCAL_DATETIME.exec(value);
|
|
192
|
+
const [, y, mo, d, hh = "0", mi = "0", ss = "0"] = parts;
|
|
193
|
+
const year = Number(y);
|
|
194
|
+
const dt = new Date(year, Number(mo) - 1, Number(d), Number(hh), Number(mi), Number(ss));
|
|
195
|
+
// Full round-trip on every field: rejects the two inputs that cannot convert
|
|
196
|
+
// faithfully — the two-digit-year remap (new Date(50,…) → 1950) and a DST gap (a
|
|
197
|
+
// wall-clock time the local zone skips: 02:30 on a US spring-forward morning, or
|
|
198
|
+
// 02:15 in Lord Howe's 30-minute gap, which only a minute-level check catches).
|
|
199
|
+
const faithful = dt.getFullYear() === year && dt.getMonth() === Number(mo) - 1
|
|
200
|
+
&& dt.getDate() === Number(d) && dt.getHours() === Number(hh)
|
|
201
|
+
&& dt.getMinutes() === Number(mi) && dt.getSeconds() === Number(ss);
|
|
202
|
+
return faithful ? dt.getTime() : undefined;
|
|
98
203
|
}
|
|
99
204
|
export function parseTimestamp13(value, optionName) {
|
|
100
205
|
if (value === undefined)
|
|
101
206
|
return undefined;
|
|
102
207
|
const parsed = toTimestamp13(value);
|
|
103
208
|
if (parsed === undefined) {
|
|
104
|
-
throw new ValidationError(`Invalid ${optionName}: expected a Unix timestamp or
|
|
209
|
+
throw new ValidationError(`Invalid ${optionName}: expected a Unix timestamp or "YYYY-MM-DD" optionally with " HH:mm[:ss]" (space or T separator), got "${value}" — year-last forms are refused because Node and the API read their day/month order the opposite way round`);
|
|
105
210
|
}
|
|
106
211
|
return parsed;
|
|
107
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* Guard for datetime options forwarded to the server AS A STRING (never converted
|
|
215
|
+
* to a timestamp): the pass-through Insight/Vault list endpoints echo the string
|
|
216
|
+
* verbatim, and probing 2026-07-21 showed they misread year-last separators exactly
|
|
217
|
+
* like the date endpoints — `insight research list` read `07/01/2026` as 2026-01-07
|
|
218
|
+
* but `07-01-2026` as 2026-07-01, a half-year apart, both HTTP 200 with nothing in
|
|
219
|
+
* the response flagging it. Accept a finite epoch or a well-formed `YYYY-MM-DD
|
|
220
|
+
* [ HH:mm[:ss]]` and return the ORIGINAL string unchanged.
|
|
221
|
+
*
|
|
222
|
+
* Validated with `datetimeFieldsValid`, NOT `toTimestamp13`: the latter's local Date
|
|
223
|
+
* round-trip would reject a DST-gap string (e.g. `2026-03-08 02:30:00` under
|
|
224
|
+
* America/New_York) that the server accepts — the CLI forwards this string as-is and
|
|
225
|
+
* the server resolves it in its own zone, so the client's timezone must not decide
|
|
226
|
+
* validity. Distinct from `parseTimestamp13`, which DOES convert (A-share
|
|
227
|
+
* announcement / knowledge-batch want epoch millis, where an unrepresentable local
|
|
228
|
+
* instant genuinely cannot convert).
|
|
229
|
+
*/
|
|
230
|
+
export function parseDatetimeOption(value, optionName) {
|
|
231
|
+
if (epochMillis(value) === undefined && !datetimeFieldsValid(value)) {
|
|
232
|
+
throw new ValidationError(`Invalid ${optionName}: expected a Unix timestamp or "YYYY-MM-DD" optionally with " HH:mm[:ss]" (space or T separator), got "${value}" — year-last forms are refused because the API reads their day/month order differently per separator`);
|
|
233
|
+
}
|
|
234
|
+
return value;
|
|
235
|
+
}
|
|
236
|
+
/** Commander argParser factory for pass-through datetime options — same role as
|
|
237
|
+
* `dateArg`, but allows an optional time part and keeps the string as-is. */
|
|
238
|
+
export function datetimeArg(optionName) {
|
|
239
|
+
return (value) => parseDatetimeOption(value, optionName);
|
|
240
|
+
}
|
|
108
241
|
/** Machine-local calendar date as `yyyy-MM-dd`, for CLI "default: today" options.
|
|
109
242
|
* `new Date().toISOString().slice(0,10)` renders the UTC day — for CST users a
|
|
110
243
|
* pre-08:00 "today" resolves to yesterday. Anchoring to local components matches
|
|
@@ -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;
|
|
@@ -9,8 +10,35 @@ function nextDelayMs(attempt) {
|
|
|
9
10
|
const grown = POLL_INITIAL_DELAY_MS * 1.6 ** (attempt - 1);
|
|
10
11
|
return Math.min(POLL_MAX_DELAY_MS, Math.round(grown));
|
|
11
12
|
}
|
|
13
|
+
// Probed 2026-07-20 against a real viewpoint-debate job: the async endpoints are
|
|
14
|
+
// still entirely on the legacy codes — 410110 "正在生成中" and 410111 "生成失败",
|
|
15
|
+
// both string-typed, both HTTP 400, neither carrying the new `errorType` field.
|
|
16
|
+
// The 2026-07-17 spec renumbers them to 140001 RESULT_GENERATING (409) and
|
|
17
|
+
// 140002 PROCESSING_FAILED (500); those are listed ahead of the switchover
|
|
18
|
+
// because the failure mode is expensive and silent — a `--wait` that does not
|
|
19
|
+
// recognize the pending code aborts the poll on a job already billed 50 credits.
|
|
20
|
+
const PENDING_CODES = new Set(["410110", "140001"]);
|
|
21
|
+
const FAILED_CODES = new Set(["410111", "140002"]);
|
|
12
22
|
function isAsyncPending(error) {
|
|
13
|
-
return error instanceof ApiError && error.code
|
|
23
|
+
return error instanceof ApiError && error.code !== undefined && PENDING_CODES.has(error.code);
|
|
24
|
+
}
|
|
25
|
+
function isAsyncFailed(error) {
|
|
26
|
+
return error instanceof ApiError && error.code !== undefined && FAILED_CODES.has(error.code);
|
|
27
|
+
}
|
|
28
|
+
/** Terminal failures are swallowed here rather than rethrown, so this line is the
|
|
29
|
+
* user's only record of them — it has to carry what the global handler in cli.ts
|
|
30
|
+
* would have printed (code, msg, traceId). Without the traceId the failure is
|
|
31
|
+
* unreportable to Gangtise support, and README promises every error line has one. */
|
|
32
|
+
function terminalFailureLine(error) {
|
|
33
|
+
const api = error instanceof ApiError ? error : undefined;
|
|
34
|
+
const code = api?.code ? ` ${api.code}` : "";
|
|
35
|
+
const trace = api?.traceId ? ` [trace ${api.traceId}]` : "";
|
|
36
|
+
const detail = api?.message ? `: ${api.message}` : "";
|
|
37
|
+
// Name the *submit* endpoint explicitly: re-running a `*-check` is a free
|
|
38
|
+
// lookup, it is resubmitting the generation job that re-bills 50 credits for a
|
|
39
|
+
// verdict that will not change (probed 2026-07-20 on sensitive content).
|
|
40
|
+
return `Content generation failed (terminal${code})${trace}${detail}\n`
|
|
41
|
+
+ "This dataId is final — re-checking it will not change. Resubmitting the generation task bills again for the same result; change the parameters first.\n";
|
|
14
42
|
}
|
|
15
43
|
export async function pollAsyncContent(client, getContentEndpoint, dataId, format, output) {
|
|
16
44
|
for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
|
|
@@ -22,12 +50,20 @@ export async function pollAsyncContent(client, getContentEndpoint, dataId, forma
|
|
|
22
50
|
}
|
|
23
51
|
}
|
|
24
52
|
catch (error) {
|
|
25
|
-
if (error
|
|
26
|
-
process.stderr.write(
|
|
53
|
+
if (isAsyncFailed(error)) {
|
|
54
|
+
process.stderr.write(terminalFailureLine(error));
|
|
27
55
|
return "failed";
|
|
28
56
|
}
|
|
29
|
-
if (!isAsyncPending(error))
|
|
30
|
-
|
|
57
|
+
if (!isAsyncPending(error)) {
|
|
58
|
+
// AI generation windows are exactly when the server is busiest: one 5xx
|
|
59
|
+
// (after the client's own retries) must not void minutes of waiting —
|
|
60
|
+
// the dataId is still valid. Transient errors consume this attempt and
|
|
61
|
+
// polling continues; anything else (no credits, bad params) aborts.
|
|
62
|
+
if (!isTransientError(error))
|
|
63
|
+
throw error;
|
|
64
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
65
|
+
process.stderr.write(`Attempt ${attempt}/${POLL_MAX_ATTEMPTS}: transient error (${msg.slice(0, 80)}), continuing to wait...\n`);
|
|
66
|
+
}
|
|
31
67
|
}
|
|
32
68
|
if (attempt < POLL_MAX_ATTEMPTS) {
|
|
33
69
|
const delay = nextDelayMs(attempt);
|
|
@@ -46,8 +82,8 @@ export async function checkAsyncContent(client, getContentEndpoint, dataId, form
|
|
|
46
82
|
}
|
|
47
83
|
}
|
|
48
84
|
catch (error) {
|
|
49
|
-
if (error
|
|
50
|
-
process.stderr.write(
|
|
85
|
+
if (isAsyncFailed(error)) {
|
|
86
|
+
process.stderr.write(terminalFailureLine(error));
|
|
51
87
|
process.exitCode = 1;
|
|
52
88
|
return;
|
|
53
89
|
}
|
package/dist/src/core/client.js
CHANGED
|
@@ -4,14 +4,20 @@ import path from "node:path";
|
|
|
4
4
|
import { pipeline } from "node:stream/promises";
|
|
5
5
|
import { request } from "undici";
|
|
6
6
|
import { isTokenCacheValid, normalizeToken, readTokenCache, requireAccessCredentials, writeTokenCache } from "./auth.js";
|
|
7
|
-
import { ApiError, ValidationError } from "./errors.js";
|
|
7
|
+
import { ApiError, attachEnvelopeTraceId, ValidationError } from "./errors.js";
|
|
8
8
|
import { ENDPOINTS, resolveTimeoutMs } from "./endpoints.js";
|
|
9
9
|
import { getLookupData } from "./lookupData/index.js";
|
|
10
10
|
import { decodeResponseBody, getDispatcher, isVerbose, logTiming, markRetryable, PAGE_CONCURRENCY, parseRetryAfterMs, runWithConcurrency, withRetry } from "./transport.js";
|
|
11
|
-
// Auth errors that warrant a forced re-login + one replay
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
11
|
+
// Auth errors that warrant a forced re-login + one replay: the token was rejected
|
|
12
|
+
// server-side while still looking valid by local expiry, so only a forced refresh
|
|
13
|
+
// recovers it. 0000001008 is the legacy code (probed 2026-07-20: still what the
|
|
14
|
+
// token filter emits); 999002 TOKEN_INVALID is its 2026-07-17 replacement, listed
|
|
15
|
+
// ahead of the rollout so self-heal does not silently die when the filter switches.
|
|
16
|
+
// 8000014/8000015 are the retired AK/SK codes, kept for older server builds.
|
|
17
|
+
// 999011 CREDENTIAL_INVALID is not here and could not act if it were — it comes from
|
|
18
|
+
// auth.login, which runs useAuth=false and so never reaches this check. Its "never
|
|
19
|
+
// replay" guarantee lives in transport's TERMINAL_API_CODES instead.
|
|
20
|
+
const AUTH_RETRY_CODES = new Set(["8000014", "8000015", "0000001008", "999002"]);
|
|
15
21
|
export class GangtiseClient {
|
|
16
22
|
config;
|
|
17
23
|
refreshPromise = null;
|
|
@@ -126,17 +132,20 @@ export class GangtiseClient {
|
|
|
126
132
|
}
|
|
127
133
|
throw new ApiError(`API request failed (HTTP ${statusCode})`, undefined, statusCode, parsed, retryAfterMs);
|
|
128
134
|
}
|
|
129
|
-
unwrapEnvelope(parsed, statusCode) {
|
|
135
|
+
unwrapEnvelope(parsed, statusCode, retryAfterMs) {
|
|
130
136
|
if (!this.isEnvelope(parsed)) {
|
|
131
137
|
return parsed;
|
|
132
138
|
}
|
|
133
139
|
const code = parsed.code === undefined ? undefined : String(parsed.code);
|
|
134
140
|
const ok = parsed.status === true || parsed.success === true || code === "000000" || code === "0";
|
|
135
141
|
if (!ok) {
|
|
136
|
-
throw new ApiError(parsed.msg || "API request failed", code, statusCode, parsed);
|
|
142
|
+
throw new ApiError(parsed.msg || "API request failed", code, statusCode, parsed, retryAfterMs);
|
|
137
143
|
}
|
|
138
144
|
if ('data' in parsed) {
|
|
139
|
-
|
|
145
|
+
// Carry the envelope's traceId onto the payload: the EDE endpoints wrap a
|
|
146
|
+
// second envelope inside `data` and raise their own failures from it, by
|
|
147
|
+
// which point this is the only traceId in reach.
|
|
148
|
+
return attachEnvelopeTraceId(parsed.data, parsed.traceId);
|
|
140
149
|
}
|
|
141
150
|
return parsed;
|
|
142
151
|
}
|
|
@@ -175,8 +184,14 @@ export class GangtiseClient {
|
|
|
175
184
|
from: startFrom,
|
|
176
185
|
size: firstPageSize,
|
|
177
186
|
});
|
|
178
|
-
if (!this.isPaginatedListResponse(firstPage))
|
|
187
|
+
if (!this.isPaginatedListResponse(firstPage)) {
|
|
188
|
+
// Shape drift (e.g. total arriving as a string) silently degrades fetch-all
|
|
189
|
+
// to a single page with no partial marker — make it visible on verbose.
|
|
190
|
+
if (isVerbose()) {
|
|
191
|
+
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`);
|
|
192
|
+
}
|
|
179
193
|
return firstPage;
|
|
194
|
+
}
|
|
180
195
|
const total = firstPage.total;
|
|
181
196
|
const collected = [...firstPage.list];
|
|
182
197
|
const available = Math.max(total - startFrom, 0);
|
|
@@ -391,20 +406,28 @@ export class GangtiseClient {
|
|
|
391
406
|
if (response.statusCode >= 400) {
|
|
392
407
|
this.throwHttpError(parsed, response.statusCode, retryAfterMs);
|
|
393
408
|
}
|
|
394
|
-
return this.unwrapEnvelope(parsed, response.statusCode);
|
|
409
|
+
return this.unwrapEnvelope(parsed, response.statusCode, retryAfterMs);
|
|
395
410
|
}
|
|
396
411
|
catch (error) {
|
|
397
412
|
await this.refreshAuthIfRecoverable(error, useAuth, authState, usedAuthorization);
|
|
398
413
|
throw error;
|
|
399
414
|
}
|
|
400
415
|
}, {
|
|
401
|
-
policy: endpoint.retry
|
|
416
|
+
policy: endpoint.retry ?? "default",
|
|
402
417
|
onRetry: (attempt, error, delay) => {
|
|
403
418
|
if (!isVerbose())
|
|
404
419
|
return;
|
|
405
420
|
const msg = error instanceof Error ? error.message : String(error);
|
|
406
421
|
process.stderr.write(`[gangtise] retry ${attempt} after ${delay.toFixed(0)}ms: ${msg.slice(0, 120)}\n`);
|
|
407
422
|
},
|
|
423
|
+
}).catch((error) => {
|
|
424
|
+
// EDE uses 999999 for "no data for this query" (probed 2026-07-11) — the
|
|
425
|
+
// generic "系统错误,请稍后重试" hint would send the user retrying a query
|
|
426
|
+
// that will never have data. Swap in a context-specific hint.
|
|
427
|
+
if (endpoint.retry === 'no-999999' && error instanceof ApiError && error.code === '999999') {
|
|
428
|
+
throw new ApiError(error.message, error.code, error.statusCode, error.details, error.retryAfterMs, 'EDE 的 999999 多为查询无数据(节假日 / 未来日期 / 未覆盖标的)——先检查查询条件,确认应有数据再重试。');
|
|
429
|
+
}
|
|
430
|
+
throw error;
|
|
408
431
|
});
|
|
409
432
|
}
|
|
410
433
|
async download(endpoint, query, options) {
|
|
@@ -477,7 +500,7 @@ export class GangtiseClient {
|
|
|
477
500
|
if (response.statusCode >= 400) {
|
|
478
501
|
this.throwHttpError(parsed, response.statusCode, retryAfterMs);
|
|
479
502
|
}
|
|
480
|
-
data = this.unwrapEnvelope(parsed, response.statusCode);
|
|
503
|
+
data = this.unwrapEnvelope(parsed, response.statusCode, retryAfterMs);
|
|
481
504
|
}
|
|
482
505
|
catch (error) {
|
|
483
506
|
await this.refreshAuthIfRecoverable(error, true, authState, authorization);
|
|
@@ -544,6 +567,9 @@ export class GangtiseClient {
|
|
|
544
567
|
filename,
|
|
545
568
|
};
|
|
546
569
|
}, {
|
|
570
|
+
// Download endpoints carry per-篇 billing too (summary/foreign-report/
|
|
571
|
+
// my-conference at 50/篇) — honor the endpoint's retry policy here as well.
|
|
572
|
+
policy: endpoint.retry ?? "default",
|
|
547
573
|
onRetry: (attempt, error, delay) => {
|
|
548
574
|
if (!isVerbose())
|
|
549
575
|
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 }]));
|