gangtise-openapi-cli 0.37.0 → 0.38.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 +57 -43
- package/dist/src/cli.js +84 -26
- package/dist/src/core/args.js +4 -7
- package/dist/src/core/client.js +78 -50
- package/dist/src/core/download.js +40 -6
- package/dist/src/core/endpoints.js +7 -0
- package/dist/src/core/errors.js +8 -0
- package/dist/src/core/normalize.js +35 -0
- package/dist/src/core/output.js +13 -10
- package/dist/src/core/perSecurity.js +87 -0
- package/dist/src/core/printer.js +110 -11
- package/dist/src/core/quoteSharding.js +98 -19
- package/dist/src/core/rowSink.js +196 -0
- package/dist/src/core/transport.js +51 -0
- package/dist/src/version.js +1 -1
- package/gangtise-openapi/SKILL.md +57 -158
- package/gangtise-openapi/references/commands/ai.md +6 -4
- package/gangtise-openapi/references/commands/fundamental.md +7 -6
- package/gangtise-openapi/references/commands/indicator.md +58 -75
- package/gangtise-openapi/references/commands/insight.md +11 -11
- package/gangtise-openapi/references/commands/quote.md +45 -19
- package/gangtise-openapi/references/commands/reference-and-lookup.md +5 -5
- package/gangtise-openapi/references/commands/tool.md +2 -2
- package/gangtise-openapi/references/commands/vault.md +3 -3
- package/gangtise-openapi/references/errors.md +129 -0
- package/gangtise-openapi/references/examples.md +14 -11
- package/gangtise-openapi/references/fields.md +18 -19
- package/gangtise-openapi/references/lookup-ids.md +3 -3
- package/gangtise-openapi/references/response-schema.md +9 -9
- package/package.json +3 -2
package/dist/src/core/client.js
CHANGED
|
@@ -4,18 +4,28 @@ import path from "node:path";
|
|
|
4
4
|
import { pipeline } from "node:stream/promises";
|
|
5
5
|
import { FormData, request } from "undici";
|
|
6
6
|
import { isTokenCacheValid, normalizeToken, readTokenCache, requireAccessCredentials, writeTokenCache } from "./auth.js";
|
|
7
|
-
import { ApiError, attachEnvelopeTraceId, ValidationError } from "./errors.js";
|
|
7
|
+
import { ApiError, attachEnvelopeTraceId, markStructural, ValidationError } from "./errors.js";
|
|
8
8
|
import { ENDPOINTS, resolveTimeoutMs } from "./endpoints.js";
|
|
9
9
|
import { getLookupData } from "./lookupData/index.js";
|
|
10
|
-
import { decodeResponseBody, getDispatcher, isVerbose, logTiming, markRetryable, PAGE_CONCURRENCY, parseRetryAfterMs, quoteBigIntFields,
|
|
10
|
+
import { decodeResponseBody, getDispatcher, isVerbose, logTiming, markRetryable, PAGE_CONCURRENCY, parseRetryAfterMs, quoteBigIntFields, runInOrder, withRetry } from "./transport.js";
|
|
11
|
+
import { attachRowSink } from "./rowSink.js";
|
|
11
12
|
const AUTH_RETRY_CODES = new Set(["8000014", "8000015", "0000001008", "999002"]);
|
|
12
13
|
export class GangtiseClient {
|
|
13
14
|
config;
|
|
15
|
+
rowSink;
|
|
14
16
|
refreshPromise = null;
|
|
15
17
|
memoCache = null;
|
|
16
18
|
envTokenInvalidated = false;
|
|
17
|
-
|
|
19
|
+
rowSinkClaimed = false;
|
|
20
|
+
constructor(config, rowSink) {
|
|
18
21
|
this.config = config;
|
|
22
|
+
this.rowSink = rowSink;
|
|
23
|
+
}
|
|
24
|
+
claimRowSink() {
|
|
25
|
+
if (!this.rowSink || this.rowSinkClaimed)
|
|
26
|
+
return undefined;
|
|
27
|
+
this.rowSinkClaimed = true;
|
|
28
|
+
return this.rowSink;
|
|
19
29
|
}
|
|
20
30
|
async getAuthorizationHeader(forceRefresh = false) {
|
|
21
31
|
if (this.config.token && !this.envTokenInvalidated && !forceRefresh) {
|
|
@@ -151,27 +161,42 @@ export class GangtiseClient {
|
|
|
151
161
|
return firstPage;
|
|
152
162
|
}
|
|
153
163
|
const total = firstPage.total;
|
|
154
|
-
const collected = [...firstPage.list];
|
|
155
164
|
const available = Math.max(total - startFrom, 0);
|
|
156
165
|
const target = requestedSize === undefined ? available : Math.min(requestedSize, available);
|
|
166
|
+
const sink = this.claimRowSink();
|
|
167
|
+
if (sink && Array.isArray(firstPage.fieldList))
|
|
168
|
+
sink.setFieldList(firstPage.fieldList);
|
|
169
|
+
const collected = [];
|
|
170
|
+
let count = 0;
|
|
171
|
+
const keep = async (rows) => {
|
|
172
|
+
const kept = requestedSize === undefined ? rows : rows.slice(0, Math.max(0, requestedSize - count));
|
|
173
|
+
if (kept.length === 0)
|
|
174
|
+
return;
|
|
175
|
+
count += kept.length;
|
|
176
|
+
if (sink)
|
|
177
|
+
await sink.push(kept);
|
|
178
|
+
else
|
|
179
|
+
for (const row of kept)
|
|
180
|
+
collected.push(row);
|
|
181
|
+
};
|
|
182
|
+
const result = () => {
|
|
183
|
+
const out = { ...firstPage, total, list: collected };
|
|
184
|
+
return sink ? attachRowSink(out, sink) : out;
|
|
185
|
+
};
|
|
186
|
+
await keep(firstPage.list);
|
|
157
187
|
if (firstPage.list.length < firstPageSize) {
|
|
158
|
-
const out =
|
|
159
|
-
|
|
160
|
-
total
|
|
161
|
-
list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
|
|
162
|
-
};
|
|
163
|
-
if (collected.length < target) {
|
|
164
|
-
process.stderr.write(`[gangtise] warning: server returned a short page (${collected.length} rows) but reported total=${total}; treating it as the end of data — results may be incomplete\n`);
|
|
188
|
+
const out = result();
|
|
189
|
+
if (count < target) {
|
|
190
|
+
process.stderr.write(`[gangtise] warning: server returned a short page (${count} rows) but reported total=${total}; treating it as the end of data — results may be incomplete\n`);
|
|
165
191
|
out.partial = true;
|
|
166
192
|
}
|
|
167
193
|
return out;
|
|
168
194
|
}
|
|
169
|
-
if (
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
total,
|
|
173
|
-
|
|
174
|
-
};
|
|
195
|
+
if (count >= target) {
|
|
196
|
+
const out = result();
|
|
197
|
+
if (requestedSize === undefined && total > firstPageSize)
|
|
198
|
+
await this.flagIfTotalCapped(endpoint, initialBody, total, out, count);
|
|
199
|
+
return out;
|
|
175
200
|
}
|
|
176
201
|
const MAX_PAGES = 1000;
|
|
177
202
|
let truncatedByPageCap = false;
|
|
@@ -190,10 +215,11 @@ export class GangtiseClient {
|
|
|
190
215
|
}
|
|
191
216
|
let unexpectedShape = false;
|
|
192
217
|
let totalDrift = false;
|
|
218
|
+
let laterPartial = false;
|
|
193
219
|
const failedPages = [];
|
|
194
220
|
let firstError = null;
|
|
195
221
|
let aborted = false;
|
|
196
|
-
|
|
222
|
+
await runInOrder(pageRequests, PAGE_CONCURRENCY, async (req) => {
|
|
197
223
|
if (aborted) {
|
|
198
224
|
failedPages.push(req);
|
|
199
225
|
return [];
|
|
@@ -211,6 +237,8 @@ export class GangtiseClient {
|
|
|
211
237
|
}
|
|
212
238
|
if (page.total !== total)
|
|
213
239
|
totalDrift = true;
|
|
240
|
+
if (page.partial === true)
|
|
241
|
+
laterPartial = true;
|
|
214
242
|
return page.list;
|
|
215
243
|
}
|
|
216
244
|
catch (error) {
|
|
@@ -220,12 +248,7 @@ export class GangtiseClient {
|
|
|
220
248
|
failedPages.push(req);
|
|
221
249
|
return [];
|
|
222
250
|
}
|
|
223
|
-
});
|
|
224
|
-
for (const list of pages) {
|
|
225
|
-
if (list.length === 0)
|
|
226
|
-
continue;
|
|
227
|
-
collected.push(...list);
|
|
228
|
-
}
|
|
251
|
+
}, (list) => keep(list));
|
|
229
252
|
if (unexpectedShape) {
|
|
230
253
|
process.stderr.write(`[gangtise] warning: a page response had unexpected shape; its rows are missing (counted in failedPages)\n`);
|
|
231
254
|
}
|
|
@@ -233,44 +256,45 @@ export class GangtiseClient {
|
|
|
233
256
|
process.stderr.write(`[gangtise] warning: 'total' changed across pages (data shifted during fetch); rows may be duplicated or missing\n`);
|
|
234
257
|
}
|
|
235
258
|
if (truncatedByPageCap) {
|
|
236
|
-
process.stderr.write(`[gangtise] warning: hit the ${MAX_PAGES}-page safety cap; fetched ${
|
|
259
|
+
process.stderr.write(`[gangtise] warning: hit the ${MAX_PAGES}-page safety cap; fetched ${count} of ${total} rows. Narrow the query (e.g. a shorter date range) or pass --size to fetch a bounded subset.\n`);
|
|
237
260
|
}
|
|
238
|
-
const short =
|
|
239
|
-
|
|
261
|
+
const short = count < target;
|
|
262
|
+
const out = result();
|
|
240
263
|
if (requestedSize === undefined && total > 0 && !short && !totalDrift && !truncatedByPageCap && failedPages.length === 0) {
|
|
241
|
-
|
|
242
|
-
const probe = await this.requestJson(endpoint, { ...initialBody, from: total, size: 1 });
|
|
243
|
-
if (this.isPaginatedListResponse(probe) && probe.list.length > 0)
|
|
244
|
-
totalCapped = true;
|
|
245
|
-
}
|
|
246
|
-
catch {
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
if (totalCapped) {
|
|
250
|
-
process.stderr.write(`[gangtise] warning: ${endpoint.key} reported total=${total} but rows exist past that offset — 'total' is a server-side cap, not the real count. This export is TRUNCATED at ${collected.length} rows. Narrow the query (date range / filters) and fetch in slices.\n`);
|
|
251
|
-
}
|
|
252
|
-
const out = {
|
|
253
|
-
...firstPage,
|
|
254
|
-
total,
|
|
255
|
-
list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
|
|
256
|
-
};
|
|
257
|
-
if (totalCapped) {
|
|
258
|
-
out.partial = true;
|
|
259
|
-
out.totalCapped = true;
|
|
264
|
+
await this.flagIfTotalCapped(endpoint, initialBody, total, out, count);
|
|
260
265
|
}
|
|
261
266
|
if (short || totalDrift || failedPages.length > 0)
|
|
262
267
|
out.partial = true;
|
|
268
|
+
if (laterPartial) {
|
|
269
|
+
out.partial = true;
|
|
270
|
+
process.stderr.write(`[gangtise] warning: a later page reported itself partial; the merged result is marked partial\n`);
|
|
271
|
+
}
|
|
263
272
|
if (failedPages.length > 0) {
|
|
264
273
|
out.failedPages = failedPages.map((p) => ({ from: p.from, size: p.size }));
|
|
265
274
|
const detail = firstError instanceof Error ? `: ${firstError.message}` : "";
|
|
266
275
|
const skippedHint = aborted ? " A page hit a non-retryable error (e.g. rate limit); remaining pages were skipped." : "";
|
|
267
|
-
process.stderr.write(`[gangtise] warning: ${failedPages.length}/${pageRequests.length} pages not fetched${detail}; results are partial — got ${
|
|
276
|
+
process.stderr.write(`[gangtise] warning: ${failedPages.length}/${pageRequests.length} pages not fetched${detail}; results are partial — got ${count}/${total} rows (see failedPages).${skippedHint}\n`);
|
|
268
277
|
}
|
|
269
278
|
else if (short && !truncatedByPageCap && !totalDrift) {
|
|
270
|
-
process.stderr.write(`[gangtise] warning: server returned ${
|
|
279
|
+
process.stderr.write(`[gangtise] warning: server returned ${count} of ${total} rows (a later page came back short); results may be incomplete\n`);
|
|
271
280
|
}
|
|
272
281
|
return out;
|
|
273
282
|
}
|
|
283
|
+
async flagIfTotalCapped(endpoint, initialBody, total, out, collectedLength) {
|
|
284
|
+
let totalCapped = false;
|
|
285
|
+
try {
|
|
286
|
+
const probe = await this.requestJson(endpoint, { ...initialBody, from: total, size: 1 });
|
|
287
|
+
if (this.isPaginatedListResponse(probe) && probe.list.length > 0)
|
|
288
|
+
totalCapped = true;
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
}
|
|
292
|
+
if (!totalCapped)
|
|
293
|
+
return;
|
|
294
|
+
process.stderr.write(`[gangtise] warning: ${endpoint.key} reported total=${total} but rows exist past that offset — 'total' is a server-side cap, not the real count. This export is TRUNCATED at ${collectedLength} rows. Narrow the query (date range / filters) and fetch in slices.\n`);
|
|
295
|
+
out.partial = true;
|
|
296
|
+
out.totalCapped = true;
|
|
297
|
+
}
|
|
274
298
|
async login() {
|
|
275
299
|
const authorization = await this.getAuthorizationHeader();
|
|
276
300
|
const cache = await readTokenCache(this.config.tokenCachePath);
|
|
@@ -340,7 +364,11 @@ export class GangtiseClient {
|
|
|
340
364
|
if (response.statusCode >= 400) {
|
|
341
365
|
this.throwHttpError(parsed, response.statusCode, retryAfterMs);
|
|
342
366
|
}
|
|
343
|
-
|
|
367
|
+
const payload = this.unwrapEnvelope(parsed, response.statusCode, retryAfterMs);
|
|
368
|
+
if (endpoint.expects === "list" && !(payload && typeof payload === "object" && Array.isArray(payload.list))) {
|
|
369
|
+
throw markStructural(new ApiError(`${endpoint.key} returned no list payload (got ${payload === null ? "null" : Array.isArray(payload) ? "an array" : typeof payload}) — the response layout may have changed`, undefined, response.statusCode, parsed));
|
|
370
|
+
}
|
|
371
|
+
return payload;
|
|
344
372
|
}
|
|
345
373
|
catch (error) {
|
|
346
374
|
await this.refreshAuthIfRecoverable(error, useAuth, authState, usedAuthorization);
|
|
@@ -519,6 +547,6 @@ export class GangtiseClient {
|
|
|
519
547
|
if (endpoint.kind === 'json' && endpoint.pagination?.enabled) {
|
|
520
548
|
return this.requestPaginated(endpoint, body);
|
|
521
549
|
}
|
|
522
|
-
return this.requestJson(endpoint, body);
|
|
550
|
+
return this.requestJson(endpoint, body, endpoint.key !== 'auth.login');
|
|
523
551
|
}
|
|
524
552
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
|
-
import { extname } from "node:path";
|
|
2
|
+
import { dirname, extname } from "node:path";
|
|
3
3
|
import { DownloadError } from "./errors.js";
|
|
4
4
|
import { saveOutputIfNeeded } from "./output.js";
|
|
5
5
|
import { extractTitles, lookupTitleCache, readTitleCache, TITLE_LOOKUP_SIZE, writeTitleCache } from "./titleCache.js";
|
|
@@ -16,18 +16,38 @@ function truncateFilename(name, maxBytes = 200) {
|
|
|
16
16
|
return stem.join("") + ext;
|
|
17
17
|
}
|
|
18
18
|
export async function uniquePath(p) {
|
|
19
|
-
const
|
|
20
|
-
|
|
19
|
+
const claim = async (f) => {
|
|
20
|
+
try {
|
|
21
|
+
const handle = await fs.open(f, "wx");
|
|
22
|
+
await handle.close();
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error.code === "EEXIST")
|
|
27
|
+
return false;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
await fs.mkdir(dirname(p) || ".", { recursive: true });
|
|
32
|
+
if (await claim(p))
|
|
21
33
|
return p;
|
|
22
34
|
const ext = extname(p);
|
|
23
35
|
const stem = p.slice(0, p.length - ext.length);
|
|
24
36
|
for (let i = 1; i <= 99; i++) {
|
|
25
37
|
const candidate = `${stem}-${i}${ext}`;
|
|
26
|
-
if (
|
|
38
|
+
if (await claim(candidate))
|
|
27
39
|
return candidate;
|
|
28
40
|
}
|
|
29
41
|
throw new DownloadError(`Refusing to overwrite: 100 files already share the name "${p}" — pass --output or clean up the directory`);
|
|
30
42
|
}
|
|
43
|
+
export async function releaseClaim(p) {
|
|
44
|
+
try {
|
|
45
|
+
if ((await fs.stat(p)).size === 0)
|
|
46
|
+
await fs.unlink(p);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
}
|
|
50
|
+
}
|
|
31
51
|
const MIME_EXT = {
|
|
32
52
|
"application/pdf": ".pdf",
|
|
33
53
|
"application/msword": ".doc",
|
|
@@ -178,13 +198,27 @@ export async function saveDownloadResult(result, fallbackName, output) {
|
|
|
178
198
|
if (file.data instanceof Uint8Array) {
|
|
179
199
|
const autoName = (file.filename ? sanitizeFilename(file.filename) : undefined) ?? (safeFallback + extFromContentType(file.contentType));
|
|
180
200
|
const outputPath = output ?? await uniquePath(truncateFilename(autoName));
|
|
181
|
-
|
|
201
|
+
try {
|
|
202
|
+
await saveOutputIfNeeded(file.data, outputPath);
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (!output)
|
|
206
|
+
await releaseClaim(outputPath);
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
182
209
|
process.stdout.write(`${outputPath}\n`);
|
|
183
210
|
return;
|
|
184
211
|
}
|
|
185
212
|
if (typeof file.text === "string") {
|
|
186
213
|
const outputPath = output ?? await uniquePath(truncateFilename(`${safeFallback}.txt`));
|
|
187
|
-
|
|
214
|
+
try {
|
|
215
|
+
await saveOutputIfNeeded(file.text, outputPath);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
if (!output)
|
|
219
|
+
await releaseClaim(outputPath);
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
188
222
|
process.stdout.write(`${outputPath}\n`);
|
|
189
223
|
return;
|
|
190
224
|
}
|
|
@@ -273,42 +273,49 @@ const ENDPOINT_DEFS = {
|
|
|
273
273
|
path: "/application/open-quote/kline/daily",
|
|
274
274
|
kind: "json",
|
|
275
275
|
description: "Query A-share historical daily kline (SH/SZ/BJ)",
|
|
276
|
+
expects: "list",
|
|
276
277
|
},
|
|
277
278
|
"quote.day-kline-hk": {
|
|
278
279
|
method: "POST",
|
|
279
280
|
path: "/application/open-quote/kline-hk/daily",
|
|
280
281
|
kind: "json",
|
|
281
282
|
description: "Query HK stock historical daily kline (HK)",
|
|
283
|
+
expects: "list",
|
|
282
284
|
},
|
|
283
285
|
"quote.day-kline-us": {
|
|
284
286
|
method: "POST",
|
|
285
287
|
path: "/application/open-quote/kline-us/daily",
|
|
286
288
|
kind: "json",
|
|
287
289
|
description: "Query US stock historical daily kline (NYSE/NASDAQ/AMEX)",
|
|
290
|
+
expects: "list",
|
|
288
291
|
},
|
|
289
292
|
"quote.index-day-kline": {
|
|
290
293
|
method: "POST",
|
|
291
294
|
path: "/application/open-quote/index/kline/daily",
|
|
292
295
|
kind: "json",
|
|
293
296
|
description: "Query SH/SZ/BJ index daily kline",
|
|
297
|
+
expects: "list",
|
|
294
298
|
},
|
|
295
299
|
"quote.minute-kline": {
|
|
296
300
|
method: "POST",
|
|
297
301
|
path: "/application/open-quote/kline/minute",
|
|
298
302
|
kind: "json",
|
|
299
303
|
description: "Query A-share minute kline (SH/SZ/BJ)",
|
|
304
|
+
expects: "list",
|
|
300
305
|
},
|
|
301
306
|
"quote.realtime": {
|
|
302
307
|
method: "POST",
|
|
303
308
|
path: "/application/open-quote/quote/realtime",
|
|
304
309
|
kind: "json",
|
|
305
310
|
description: "Query realtime quote snapshot (A-share / HK / US)",
|
|
311
|
+
expects: "list",
|
|
306
312
|
},
|
|
307
313
|
"quote.fund-flow": {
|
|
308
314
|
method: "POST",
|
|
309
315
|
path: "/application/open-quote/fund-flow/daily",
|
|
310
316
|
kind: "json",
|
|
311
317
|
description: "Query A-share daily fund flow (SH/SZ/BJ; small/medium/large/xlarge orders + main net inflow)",
|
|
318
|
+
expects: "list",
|
|
312
319
|
},
|
|
313
320
|
"fundamental.income-statement": {
|
|
314
321
|
method: "POST",
|
package/dist/src/core/errors.js
CHANGED
|
@@ -17,6 +17,14 @@ export function attachEnvelopeTraceId(payload, traceId) {
|
|
|
17
17
|
}
|
|
18
18
|
return payload;
|
|
19
19
|
}
|
|
20
|
+
const STRUCTURAL = Symbol("gangtise.structural");
|
|
21
|
+
export function markStructural(error) {
|
|
22
|
+
Object.defineProperty(error, STRUCTURAL, { value: true, enumerable: false, configurable: true });
|
|
23
|
+
return error;
|
|
24
|
+
}
|
|
25
|
+
export function isStructuralError(error) {
|
|
26
|
+
return Boolean(error && typeof error === "object" && error[STRUCTURAL] === true);
|
|
27
|
+
}
|
|
20
28
|
const ERROR_HINTS = {
|
|
21
29
|
"999001": "检查 GANGTISE_TOKEN 或 AK/SK 是否已 export。",
|
|
22
30
|
"999002": "有 AK/SK 时 CLI 会自动重新登录重试一次,否则请重新登录。",
|
|
@@ -14,6 +14,37 @@ export function zipFieldRow(fields, row, source) {
|
|
|
14
14
|
return acc;
|
|
15
15
|
}, {});
|
|
16
16
|
}
|
|
17
|
+
export function columnarSchemaValid(fields, list) {
|
|
18
|
+
if (!fields)
|
|
19
|
+
return false;
|
|
20
|
+
if (new Set(fields.map(String)).size !== fields.length)
|
|
21
|
+
return false;
|
|
22
|
+
return list.every((row) => !Array.isArray(row) || row.length === fields.length);
|
|
23
|
+
}
|
|
24
|
+
export function flagMissingFields(data, requested, label) {
|
|
25
|
+
if (!requested?.length || !data || typeof data !== "object" || Array.isArray(data))
|
|
26
|
+
return;
|
|
27
|
+
const rec = data;
|
|
28
|
+
if (!Array.isArray(rec.fieldList))
|
|
29
|
+
return;
|
|
30
|
+
const returned = new Set(rec.fieldList.map(String));
|
|
31
|
+
const missing = requested.filter((field) => !returned.has(field));
|
|
32
|
+
if (missing.length === 0)
|
|
33
|
+
return;
|
|
34
|
+
rec.partial = true;
|
|
35
|
+
rec.missingFields = missing;
|
|
36
|
+
process.stderr.write(`[gangtise] warning: ${label} returned no column for ${missing.join(", ")} — the server drops a field name it does not recognise (or no longer serves) without an error. Check the name against references/fields.md; result marked partial (exit 3).\n`);
|
|
37
|
+
}
|
|
38
|
+
export function assertColumnarHeader(fields, source) {
|
|
39
|
+
if (!fields) {
|
|
40
|
+
throw new ValidationError(`响应包含数组形式的行但没有 fieldList,无法确定各列的含义,已拒绝输出。这是上游响应结构异常,请报障${traceSuffix(source)}。`);
|
|
41
|
+
}
|
|
42
|
+
const names = fields.map(String);
|
|
43
|
+
const dupes = [...new Set(names.filter((name, i) => names.indexOf(name) !== i))];
|
|
44
|
+
if (dupes.length > 0) {
|
|
45
|
+
throw new ValidationError(`响应 fieldList 有重复列名(${dupes.join(", ")})——按位置拍平时后一列会覆盖前一列,已拒绝输出。这是上游响应结构异常,请报障${traceSuffix(source)}。`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
17
48
|
export function normalizeRows(value) {
|
|
18
49
|
if (!value || typeof value !== "object") {
|
|
19
50
|
return value;
|
|
@@ -24,12 +55,16 @@ export function normalizeRows(value) {
|
|
|
24
55
|
const record = value;
|
|
25
56
|
if (Array.isArray(record.fieldList) && Array.isArray(record.list)) {
|
|
26
57
|
const fields = record.fieldList;
|
|
58
|
+
if (record.list.some(Array.isArray))
|
|
59
|
+
assertColumnarHeader(fields, record);
|
|
27
60
|
const normalizedList = record.list.map((row) => (Array.isArray(row) ? zipFieldRow(fields, row, record) : row));
|
|
28
61
|
const { fieldList, list, ...meta } = record;
|
|
29
62
|
const hasMeta = Object.keys(meta).length > 0;
|
|
30
63
|
return hasMeta ? { ...meta, list: normalizedList } : normalizedList;
|
|
31
64
|
}
|
|
32
65
|
if (Array.isArray(record.list)) {
|
|
66
|
+
if (record.list.some(Array.isArray))
|
|
67
|
+
assertColumnarHeader(undefined, record);
|
|
33
68
|
const { list, ...meta } = record;
|
|
34
69
|
const hasMeta = Object.keys(meta).length > 0;
|
|
35
70
|
return hasMeta ? { ...meta, list } : list;
|
package/dist/src/core/output.js
CHANGED
|
@@ -24,7 +24,7 @@ function displayWidth(value) {
|
|
|
24
24
|
}
|
|
25
25
|
return width;
|
|
26
26
|
}
|
|
27
|
-
function formatScalar(value) {
|
|
27
|
+
export function formatScalar(value) {
|
|
28
28
|
if (value === null || value === undefined) {
|
|
29
29
|
return "";
|
|
30
30
|
}
|
|
@@ -104,16 +104,19 @@ function renderCsv(rows) {
|
|
|
104
104
|
const body = rows.map((row) => columns.map((column) => csvEscape(formatScalar(row[column]))).join(","));
|
|
105
105
|
return [header, ...body].join("\n");
|
|
106
106
|
}
|
|
107
|
+
export function jsonlItems(value) {
|
|
108
|
+
const list = value && typeof value === "object" && !Array.isArray(value) ? value.list : undefined;
|
|
109
|
+
return Array.isArray(list) ? list : toRows(value);
|
|
110
|
+
}
|
|
111
|
+
export function countOutputRows(value, format) {
|
|
112
|
+
return format === "jsonl" ? jsonlItems(value).length : toRows(value).length;
|
|
113
|
+
}
|
|
107
114
|
export function renderOutput(value, format) {
|
|
108
115
|
switch (format) {
|
|
109
116
|
case "json":
|
|
110
117
|
return JSON.stringify(value, null, 2);
|
|
111
|
-
case "jsonl":
|
|
112
|
-
|
|
113
|
-
? value.list
|
|
114
|
-
: null;
|
|
115
|
-
return (items ?? toRows(value)).map((item) => JSON.stringify(item)).join("\n");
|
|
116
|
-
}
|
|
118
|
+
case "jsonl":
|
|
119
|
+
return jsonlItems(value).map((item) => JSON.stringify(item)).join("\n");
|
|
117
120
|
case "csv":
|
|
118
121
|
return renderCsv(toRows(value));
|
|
119
122
|
case "markdown":
|
|
@@ -147,7 +150,7 @@ export async function streamOutputToFile(value, format, outputPath) {
|
|
|
147
150
|
stream.on("error", () => { });
|
|
148
151
|
try {
|
|
149
152
|
if (format === "jsonl") {
|
|
150
|
-
for (const item of
|
|
153
|
+
for (const item of jsonlItems(value)) {
|
|
151
154
|
await writeLine(stream, JSON.stringify(item));
|
|
152
155
|
}
|
|
153
156
|
}
|
|
@@ -183,7 +186,7 @@ export function pickList(value) {
|
|
|
183
186
|
}
|
|
184
187
|
return null;
|
|
185
188
|
}
|
|
186
|
-
function csvEscape(value) {
|
|
189
|
+
export function csvEscape(value) {
|
|
187
190
|
let out = value;
|
|
188
191
|
if (/^[=@\t\r]/.test(out) || (/^[+\-]/.test(out) && !Number.isFinite(Number(out))))
|
|
189
192
|
out = "'" + out;
|
|
@@ -191,7 +194,7 @@ function csvEscape(value) {
|
|
|
191
194
|
return `"${out.replaceAll("\"", "\"\"")}"`;
|
|
192
195
|
return out;
|
|
193
196
|
}
|
|
194
|
-
function writeLine(stream, line) {
|
|
197
|
+
export function writeLine(stream, line) {
|
|
195
198
|
return new Promise((resolve, reject) => {
|
|
196
199
|
const ok = stream.write(line + "\n", (err) => err ? reject(err) : undefined);
|
|
197
200
|
if (ok) {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { ApiError, markStructural } from "./errors.js";
|
|
2
|
+
import { columnarSchemaValid } from "./normalize.js";
|
|
3
|
+
import { attachRowSink } from "./rowSink.js";
|
|
4
|
+
import { PAGE_CONCURRENCY, runInOrder } from "./transport.js";
|
|
5
|
+
export function estimateTradingDays(startDate, endDate) {
|
|
6
|
+
if (!startDate)
|
|
7
|
+
return 262;
|
|
8
|
+
const start = Date.parse(`${startDate}T00:00:00Z`);
|
|
9
|
+
const end = endDate ? Date.parse(`${endDate}T00:00:00Z`) : Date.now() + 86_400_000;
|
|
10
|
+
if (Number.isNaN(start) || Number.isNaN(end) || end < start)
|
|
11
|
+
return 262;
|
|
12
|
+
let days = 0;
|
|
13
|
+
for (let t = start; t <= end; t += 86_400_000) {
|
|
14
|
+
const weekday = new Date(t).getUTCDay();
|
|
15
|
+
if (weekday !== 0 && weekday !== 6)
|
|
16
|
+
days++;
|
|
17
|
+
}
|
|
18
|
+
return days;
|
|
19
|
+
}
|
|
20
|
+
export async function callPerSecurity(client, endpointKey, securities, makeBody, cap, label) {
|
|
21
|
+
const sink = client.claimRowSink?.();
|
|
22
|
+
let fieldList;
|
|
23
|
+
let headerSecurity;
|
|
24
|
+
let emptyFields;
|
|
25
|
+
const merged = [];
|
|
26
|
+
let count = 0;
|
|
27
|
+
const truncated = [];
|
|
28
|
+
let partial = false;
|
|
29
|
+
const mergePart = async (part, i) => {
|
|
30
|
+
const rec = part;
|
|
31
|
+
if (!(rec && typeof rec === "object" && Array.isArray(rec.list))) {
|
|
32
|
+
throw markStructural(new ApiError(`${label}: ${securities[i]} returned no list payload — the response layout may have changed`, undefined, undefined, rec));
|
|
33
|
+
}
|
|
34
|
+
if (rec.partial === true)
|
|
35
|
+
partial = true;
|
|
36
|
+
if (rec.list.length === 0) {
|
|
37
|
+
if ((typeof rec.total === "number" && rec.total > 0) || rec.partial === true) {
|
|
38
|
+
const claim = typeof rec.total === "number" && rec.total > 0 ? `reported total=${rec.total}` : "carried a partial marker";
|
|
39
|
+
throw markStructural(new ApiError(`${label}: ${securities[i]} ${claim} but delivered no rows — the response is inconsistent`, undefined, undefined, rec));
|
|
40
|
+
}
|
|
41
|
+
if (!emptyFields && Array.isArray(rec.fieldList))
|
|
42
|
+
emptyFields = rec.fieldList;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const fields = Array.isArray(rec.fieldList) && rec.fieldList.length > 0 ? rec.fieldList : undefined;
|
|
46
|
+
if (rec.list.some(Array.isArray) && !columnarSchemaValid(fields, rec.list)) {
|
|
47
|
+
throw markStructural(new ApiError(`${label}: ${securities[i]} returned columnar rows without a usable fieldList (missing, duplicated or mis-sized) — they cannot be read by position`, undefined, undefined, rec));
|
|
48
|
+
}
|
|
49
|
+
if (fields && !fieldList) {
|
|
50
|
+
fieldList = fields;
|
|
51
|
+
headerSecurity = securities[i];
|
|
52
|
+
sink?.setFieldList(fieldList);
|
|
53
|
+
}
|
|
54
|
+
if ((fields ?? fieldList) && !sameColumns(fieldList, fields)) {
|
|
55
|
+
throw markStructural(new ApiError(`${label}: ${securities[i]} answered with columns ${JSON.stringify(fields)} while ${headerSecurity} answered ${JSON.stringify(fieldList)} — the parts cannot be merged`, undefined, undefined, rec));
|
|
56
|
+
}
|
|
57
|
+
if (rec.list.length >= cap)
|
|
58
|
+
truncated.push(securities[i]);
|
|
59
|
+
count += rec.list.length;
|
|
60
|
+
if (sink)
|
|
61
|
+
await sink.push(rec.list);
|
|
62
|
+
else
|
|
63
|
+
for (const row of rec.list)
|
|
64
|
+
merged.push(row);
|
|
65
|
+
};
|
|
66
|
+
await runInOrder(securities, PAGE_CONCURRENCY, (code) => client.call(endpointKey, makeBody(code)), mergePart);
|
|
67
|
+
const out = { total: count, list: merged };
|
|
68
|
+
if (sink)
|
|
69
|
+
attachRowSink(out, sink);
|
|
70
|
+
if (fieldList)
|
|
71
|
+
out.fieldList = fieldList;
|
|
72
|
+
else if (count === 0 && emptyFields)
|
|
73
|
+
out.fieldList = emptyFields;
|
|
74
|
+
if (partial)
|
|
75
|
+
out.partial = true;
|
|
76
|
+
if (truncated.length > 0) {
|
|
77
|
+
out.partial = true;
|
|
78
|
+
out.truncatedSecurities = truncated;
|
|
79
|
+
process.stderr.write(`[gangtise] warning: ${label}: ${truncated.join(", ")} returned ${cap} rows = the per-request limit; those securities are likely truncated (see truncatedSecurities). Narrow the date range for them or raise --limit (max 10000).\n`);
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
function sameColumns(a, b) {
|
|
84
|
+
if (!a || !b)
|
|
85
|
+
return false;
|
|
86
|
+
return a.length === b.length && a.every((field, i) => field === b[i]);
|
|
87
|
+
}
|