gangtise-openapi-cli 0.21.0 → 0.22.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 +12 -9
- package/dist/src/cli.js +66 -37
- package/dist/src/core/args.js +13 -1
- package/dist/src/core/asyncContent.js +3 -3
- package/dist/src/core/auth.js +4 -1
- package/dist/src/core/client.js +135 -38
- package/dist/src/core/download.js +60 -4
- package/dist/src/core/endpoints.js +4 -87
- package/dist/src/core/indicatorMatrix.js +11 -6
- package/dist/src/core/output.js +80 -30
- package/dist/src/core/printer.js +9 -1
- package/dist/src/core/quoteSharding.js +12 -8
- package/dist/src/core/titleCache.js +5 -3
- package/dist/src/core/transport.js +2 -0
- package/dist/src/version.js +1 -1
- package/package.json +2 -2
package/dist/src/core/output.js
CHANGED
|
@@ -8,6 +8,26 @@ export function parseOutputFormat(value) {
|
|
|
8
8
|
}
|
|
9
9
|
throw new ConfigError(`Unsupported format: ${format}`);
|
|
10
10
|
}
|
|
11
|
+
/** Cell text for terminal-facing formats (table/markdown): newlines collapsed for
|
|
12
|
+
* alignment, remaining C0/DEL control chars stripped so server data can't inject
|
|
13
|
+
* terminal escape sequences (ESC[31m etc.) into the user's terminal. */
|
|
14
|
+
function sanitizeCell(value) {
|
|
15
|
+
return value.replace(/[\r\n]+/g, " ").replace(/[\u0000-\u001f\u007f]/g, "");
|
|
16
|
+
}
|
|
17
|
+
/** Terminal display width: CJK/fullwidth chars occupy 2 columns — padEnd counts
|
|
18
|
+
* UTF-16 code units and misaligns every table containing Chinese text. */
|
|
19
|
+
function displayWidth(value) {
|
|
20
|
+
let width = 0;
|
|
21
|
+
for (const ch of value) {
|
|
22
|
+
const cp = ch.codePointAt(0);
|
|
23
|
+
const wide = (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf)
|
|
24
|
+
|| (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff)
|
|
25
|
+
|| (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60)
|
|
26
|
+
|| (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x20000 && cp <= 0x3fffd);
|
|
27
|
+
width += wide ? 2 : 1;
|
|
28
|
+
}
|
|
29
|
+
return width;
|
|
30
|
+
}
|
|
11
31
|
function formatScalar(value) {
|
|
12
32
|
if (value === null || value === undefined) {
|
|
13
33
|
return "";
|
|
@@ -17,21 +37,24 @@ function formatScalar(value) {
|
|
|
17
37
|
}
|
|
18
38
|
return String(value);
|
|
19
39
|
}
|
|
40
|
+
/** Rows for a list: object rows as-is with stray null/scalar rows dropped (one bad
|
|
41
|
+
* row must not degrade the whole table to index/value pairs — and the streaming CSV
|
|
42
|
+
* path already skips them, so both paths agree). A list with NO object rows at all
|
|
43
|
+
* (e.g. plain string codes) still renders as index/value pairs. */
|
|
44
|
+
function rowsFromList(list) {
|
|
45
|
+
const objectRows = list.filter((item) => Boolean(item && typeof item === "object" && !Array.isArray(item)));
|
|
46
|
+
if (objectRows.length > 0)
|
|
47
|
+
return objectRows;
|
|
48
|
+
return list.map((item, index) => ({ index, value: item }));
|
|
49
|
+
}
|
|
20
50
|
function toRows(value) {
|
|
21
51
|
if (Array.isArray(value)) {
|
|
22
|
-
|
|
23
|
-
return value;
|
|
24
|
-
}
|
|
25
|
-
return value.map((item, index) => ({ index, value: item }));
|
|
52
|
+
return rowsFromList(value);
|
|
26
53
|
}
|
|
27
54
|
if (value && typeof value === "object") {
|
|
28
55
|
const record = value;
|
|
29
56
|
if (Array.isArray(record.list)) {
|
|
30
|
-
|
|
31
|
-
if (list.every((item) => item && typeof item === "object" && !Array.isArray(item))) {
|
|
32
|
-
return list;
|
|
33
|
-
}
|
|
34
|
-
return list.map((item, index) => ({ index, value: item }));
|
|
57
|
+
return rowsFromList(record.list);
|
|
35
58
|
}
|
|
36
59
|
return [record];
|
|
37
60
|
}
|
|
@@ -42,15 +65,17 @@ function renderTable(rows) {
|
|
|
42
65
|
return "(empty)";
|
|
43
66
|
}
|
|
44
67
|
const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
|
|
45
|
-
// Format every cell once (formatScalar may JSON.stringify objects),
|
|
46
|
-
//
|
|
47
|
-
// width and rendering — and compute widths with reduce, NOT Math.max(...arr):
|
|
68
|
+
// Format every cell once (formatScalar may JSON.stringify objects), sanitizing
|
|
69
|
+
// control chars so multi-line fields don't break alignment. Reuse the matrix for
|
|
70
|
+
// both width and rendering — and compute widths with reduce, NOT Math.max(...arr):
|
|
48
71
|
// spreading a per-row array overflows the call stack on large results (table is
|
|
49
|
-
// the default format, e.g. `quote day-kline --security all`).
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const
|
|
72
|
+
// the default format, e.g. `quote day-kline --security all`). Widths and padding
|
|
73
|
+
// use displayWidth so CJK cells stay aligned.
|
|
74
|
+
const headerCells = columns.map(sanitizeCell);
|
|
75
|
+
const matrix = rows.map((row) => columns.map((column) => sanitizeCell(formatScalar(row[column]))));
|
|
76
|
+
const widths = columns.map((_, c) => matrix.reduce((max, cells) => Math.max(max, displayWidth(cells[c])), displayWidth(headerCells[c])));
|
|
77
|
+
const renderLine = (values) => values.map((value, index) => value + " ".repeat(Math.max(0, widths[index] - displayWidth(value)))).join(" ");
|
|
78
|
+
const header = renderLine(headerCells);
|
|
54
79
|
const divider = renderLine(widths.map((width) => "-".repeat(width)));
|
|
55
80
|
const body = matrix.map((cells) => renderLine(cells));
|
|
56
81
|
return [header, divider, ...body].join("\n");
|
|
@@ -60,9 +85,11 @@ function renderMarkdown(rows) {
|
|
|
60
85
|
return "(empty)";
|
|
61
86
|
}
|
|
62
87
|
const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
|
|
63
|
-
|
|
88
|
+
// Column names come from server data (e.g. EDE indicator display names) — escape
|
|
89
|
+
// them like cell values or a name containing | / , breaks the whole table.
|
|
90
|
+
const header = `| ${columns.map((column) => sanitizeCell(column).replaceAll("|", "\\|")).join(" | ")} |`;
|
|
64
91
|
const divider = `| ${columns.map(() => "---").join(" | ")} |`;
|
|
65
|
-
const body = rows.map((row) => `| ${columns.map((column) => formatScalar(row[column])
|
|
92
|
+
const body = rows.map((row) => `| ${columns.map((column) => sanitizeCell(formatScalar(row[column])).replaceAll("|", "\\|")).join(" | ")} |`);
|
|
66
93
|
return [header, divider, ...body].join("\n");
|
|
67
94
|
}
|
|
68
95
|
function renderCsv(rows) {
|
|
@@ -70,7 +97,7 @@ function renderCsv(rows) {
|
|
|
70
97
|
return "";
|
|
71
98
|
}
|
|
72
99
|
const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
|
|
73
|
-
const header = columns.join(",");
|
|
100
|
+
const header = columns.map(csvEscape).join(",");
|
|
74
101
|
const body = rows.map((row) => columns.map((column) => csvEscape(formatScalar(row[column]))).join(","));
|
|
75
102
|
return [header, ...body].join("\n");
|
|
76
103
|
}
|
|
@@ -105,10 +132,24 @@ export async function streamOutputToFile(value, format, outputPath) {
|
|
|
105
132
|
// Below this row count the join() approach is cheaper than per-row writes.
|
|
106
133
|
if (list.length < 1000)
|
|
107
134
|
return false;
|
|
135
|
+
// csv can only stream object rows; an all-scalar list has no columns — fall back
|
|
136
|
+
// to renderOutput's index/value shaping instead of writing a BOM-only file.
|
|
137
|
+
let csvRows = [];
|
|
138
|
+
let csvColumns = [];
|
|
139
|
+
if (format === "csv") {
|
|
140
|
+
csvRows = list.filter((row) => Boolean(row && typeof row === "object" && !Array.isArray(row)));
|
|
141
|
+
if (csvRows.length === 0)
|
|
142
|
+
return false;
|
|
143
|
+
csvColumns = Array.from(new Set(csvRows.flatMap((row) => Object.keys(row))));
|
|
144
|
+
}
|
|
108
145
|
const { dirname } = await import("node:path");
|
|
109
146
|
const { createWriteStream } = await import("node:fs");
|
|
110
147
|
await fs.mkdir(dirname(outputPath), { recursive: true });
|
|
111
148
|
const stream = createWriteStream(outputPath, { encoding: "utf8" });
|
|
149
|
+
// A stream 'error' with no listener (EACCES on open, ENOSPC mid-write) crashes the
|
|
150
|
+
// process before any write callback fires. Swallow the event here — the failure
|
|
151
|
+
// still surfaces through the write/end callbacks below.
|
|
152
|
+
stream.on("error", () => { });
|
|
112
153
|
try {
|
|
113
154
|
if (format === "jsonl") {
|
|
114
155
|
for (const item of list) {
|
|
@@ -116,20 +157,23 @@ export async function streamOutputToFile(value, format, outputPath) {
|
|
|
116
157
|
}
|
|
117
158
|
}
|
|
118
159
|
else {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const cells = columns.map((column) => csvEscape(formatScalar(row[column])));
|
|
160
|
+
// BOM so Excel double-click decodes Chinese as UTF-8 instead of ANSI/GBK.
|
|
161
|
+
await writeLine(stream, "\ufeff" + csvColumns.map(csvEscape).join(","));
|
|
162
|
+
for (const row of csvRows) {
|
|
163
|
+
const cells = csvColumns.map((column) => csvEscape(formatScalar(row[column])));
|
|
124
164
|
await writeLine(stream, cells.join(","));
|
|
125
165
|
}
|
|
126
166
|
}
|
|
127
|
-
}
|
|
128
|
-
finally {
|
|
129
167
|
await new Promise((resolve, reject) => {
|
|
130
168
|
stream.end((err) => err ? reject(err) : resolve());
|
|
131
169
|
});
|
|
132
170
|
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
// Mirror the download path: never leave a truncated file that looks complete.
|
|
173
|
+
stream.destroy();
|
|
174
|
+
await fs.unlink(outputPath).catch(() => { });
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
133
177
|
return true;
|
|
134
178
|
}
|
|
135
179
|
/** Extract a row array from a value: the array itself, or its `.list` property,
|
|
@@ -158,10 +202,16 @@ function csvEscape(value) {
|
|
|
158
202
|
function writeLine(stream, line) {
|
|
159
203
|
return new Promise((resolve, reject) => {
|
|
160
204
|
const ok = stream.write(line + "\n", (err) => err ? reject(err) : undefined);
|
|
161
|
-
if (ok)
|
|
205
|
+
if (ok) {
|
|
162
206
|
resolve();
|
|
163
|
-
|
|
164
|
-
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
// Waiting only for 'drain' would hang forever if the stream errors instead;
|
|
210
|
+
// race the two and detach the loser so listeners don't pile up per write.
|
|
211
|
+
const onDrain = () => { stream.off("error", onError); resolve(); };
|
|
212
|
+
const onError = (err) => { stream.off("drain", onDrain); reject(err); };
|
|
213
|
+
stream.once("drain", onDrain);
|
|
214
|
+
stream.once("error", onError);
|
|
165
215
|
});
|
|
166
216
|
}
|
|
167
217
|
export async function saveOutputIfNeeded(content, outputPath) {
|
package/dist/src/core/printer.js
CHANGED
|
@@ -16,6 +16,12 @@ export async function printData(data, format, output, cache) {
|
|
|
16
16
|
const listLen = Array.isArray(meta.list) ? meta.list.length : 0;
|
|
17
17
|
process.stderr.write(`Total: ${meta.total}, showing: ${listLen}\n`);
|
|
18
18
|
}
|
|
19
|
+
// Partial results (failed pages/shards) exit with code 3: the table/csv/jsonl
|
|
20
|
+
// renderers only emit the rows, so without a distinct exit code a script or AI
|
|
21
|
+
// consumer cannot tell a partial export from a complete one.
|
|
22
|
+
if (meta.partial === true) {
|
|
23
|
+
process.exitCode = 3;
|
|
24
|
+
}
|
|
19
25
|
}
|
|
20
26
|
if (output) {
|
|
21
27
|
if (await streamOutputToFile(normalized, format, output)) {
|
|
@@ -23,7 +29,9 @@ export async function printData(data, format, output, cache) {
|
|
|
23
29
|
return;
|
|
24
30
|
}
|
|
25
31
|
const content = renderOutput(normalized, format);
|
|
26
|
-
|
|
32
|
+
// CSV files get a BOM so Excel double-click decodes Chinese as UTF-8 (stdout
|
|
33
|
+
// stays BOM-free for pipes).
|
|
34
|
+
await saveOutputIfNeeded(format === "csv" ? `\ufeff${content}` : content, output);
|
|
27
35
|
process.stdout.write(`${output}\n`);
|
|
28
36
|
return;
|
|
29
37
|
}
|
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
import { runWithConcurrency } from "./transport.js";
|
|
1
|
+
import { isVerbose, PAGE_CONCURRENCY, runWithConcurrency } from "./transport.js";
|
|
2
2
|
const DAY_MS = 86_400_000;
|
|
3
3
|
/** API-side row cap (per docs). Used to lift the default 6000-row cap on
|
|
4
4
|
* `--security all` queries so a 2-day A-share shard (~11K rows) isn't
|
|
5
5
|
* silently truncated. Single-security queries are untouched. */
|
|
6
6
|
const ALL_MARKET_LIMIT = 10_000;
|
|
7
|
-
/** Shard fan-out concurrency. Shares the GANGTISE_PAGE_CONCURRENCY knob with
|
|
8
|
-
* pagination (see transport/client) so one env var tunes all request fan-out. */
|
|
9
|
-
const SHARD_CONCURRENCY = Number(process.env.GANGTISE_PAGE_CONCURRENCY ?? 5) || 5;
|
|
10
7
|
function parseDate(value) {
|
|
11
8
|
// Accept yyyy-MM-dd; reject anything else so we can fall back to a single request.
|
|
12
9
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
@@ -46,12 +43,17 @@ function buildShards(start, end, shardDays) {
|
|
|
46
43
|
* single-security queries this is a no-op.
|
|
47
44
|
*/
|
|
48
45
|
export async function callKlineWithSharding(client, endpointKey, body, config) {
|
|
49
|
-
if (!isAllMarket(body)
|
|
46
|
+
if (!isAllMarket(body)) {
|
|
50
47
|
return client.call(endpointKey, body);
|
|
51
48
|
}
|
|
52
49
|
// `--security all` returns thousands of rows per day; lift the default 6000-row
|
|
53
|
-
// cap to the API max so single-shard requests aren't silently truncated.
|
|
50
|
+
// cap to the API max so single-shard requests aren't silently truncated. This
|
|
51
|
+
// must apply even when a date is missing (no sharding possible then, but the
|
|
52
|
+
// single request still needs the lifted cap).
|
|
54
53
|
const allMarketBody = { ...body, limit: body.limit ?? ALL_MARKET_LIMIT };
|
|
54
|
+
if (!body.startDate || !body.endDate) {
|
|
55
|
+
return client.call(endpointKey, allMarketBody);
|
|
56
|
+
}
|
|
55
57
|
const start = parseDate(body.startDate);
|
|
56
58
|
const end = parseDate(body.endDate);
|
|
57
59
|
if (!start || !end || end < start) {
|
|
@@ -62,7 +64,9 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
|
|
|
62
64
|
return client.call(endpointKey, allMarketBody);
|
|
63
65
|
}
|
|
64
66
|
const shards = buildShards(start, end, config.shardDays);
|
|
65
|
-
|
|
67
|
+
// isVerbose() (not a direct env read) so the global --verbose flag reaches
|
|
68
|
+
// shard logging too — cli.ts enables it via setVerbose in a preAction hook.
|
|
69
|
+
if (isVerbose()) {
|
|
66
70
|
process.stderr.write(`[gangtise] sharding ${endpointKey} into ${shards.length} requests (${config.shardDays} day(s) each)\n`);
|
|
67
71
|
}
|
|
68
72
|
// Per-shard fault tolerance: a failing shard is recorded and skipped (returns a
|
|
@@ -71,7 +75,7 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
|
|
|
71
75
|
// every shard on the first rejection.
|
|
72
76
|
const failedShards = [];
|
|
73
77
|
let firstError = null;
|
|
74
|
-
const results = await runWithConcurrency(shards, config.concurrency ??
|
|
78
|
+
const results = await runWithConcurrency(shards, config.concurrency ?? PAGE_CONCURRENCY, async (shard) => {
|
|
75
79
|
try {
|
|
76
80
|
return await client.call(endpointKey, { ...allMarketBody, startDate: shard.startDate, endDate: shard.endDate });
|
|
77
81
|
}
|
|
@@ -75,9 +75,11 @@ function capTitles(merged, freshKeys, cap) {
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
if (n < cap) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
// Newest-inserted keys sit at the END of the merged object — fill in reverse so
|
|
79
|
+
// eviction drops the oldest entries, not the batch the user just listed yesterday.
|
|
80
|
+
const rest = Object.keys(merged);
|
|
81
|
+
for (let i = rest.length - 1; i >= 0 && n < cap; i--) {
|
|
82
|
+
const k = rest[i];
|
|
81
83
|
if (k in out)
|
|
82
84
|
continue;
|
|
83
85
|
out[k] = merged[k];
|
|
@@ -29,6 +29,8 @@ export async function runWithConcurrency(items, concurrency, fn) {
|
|
|
29
29
|
await Promise.all(Array.from({ length: limit }, () => worker()));
|
|
30
30
|
return results;
|
|
31
31
|
}
|
|
32
|
+
/** Fan-out width for pagination and kline shards — one env knob tunes both. */
|
|
33
|
+
export const PAGE_CONCURRENCY = Number(process.env.GANGTISE_PAGE_CONCURRENCY ?? 5) || 5;
|
|
32
34
|
const RETRYABLE_HTTP_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
33
35
|
const RETRYABLE_NETWORK_CODES = new Set(["ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_SOCKET", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
|
|
34
36
|
const RETRYABLE_API_CODES = new Set(["999999"]);
|
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.22.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gangtise-openapi-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "CLI for Gangtise OpenAPI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"undici": "^7.16.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@types/node": "^
|
|
40
|
+
"@types/node": "^20.19.43",
|
|
41
41
|
"tsx": "^4.20.5",
|
|
42
42
|
"typescript": "^5.9.2",
|
|
43
43
|
"vitest": "^3.2.6"
|