gangtise-openapi-cli 0.37.1 → 0.39.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 +50 -39
- package/dist/src/cli.js +96 -28
- package/dist/src/core/args.js +4 -7
- package/dist/src/core/client.js +80 -51
- package/dist/src/core/download.js +42 -8
- 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 +43 -17
- package/dist/src/core/perSecurity.js +87 -0
- package/dist/src/core/printer.js +126 -11
- package/dist/src/core/quoteSharding.js +109 -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 -5
- package/gangtise-openapi/references/commands/fundamental.md +7 -6
- package/gangtise-openapi/references/commands/indicator.md +53 -85
- 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 +135 -0
- package/gangtise-openapi/references/examples.md +14 -11
- package/gangtise-openapi/references/fields.md +21 -24
- package/gangtise-openapi/references/lookup-ids.md +3 -3
- package/gangtise-openapi/references/response-schema.md +9 -9
- package/package.json +3 -2
|
@@ -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
|
+
}
|
package/dist/src/core/printer.js
CHANGED
|
@@ -1,17 +1,117 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import { normalizeRows } from "./normalize.js";
|
|
2
|
-
import { pickList, renderOutput, saveOutputIfNeeded, streamOutputToFile } from "./output.js";
|
|
4
|
+
import { countOutputRows, digestFile, pickList, renderOutput, saveOutputIfNeeded, stagingPath, streamOutputToFile } from "./output.js";
|
|
5
|
+
import { getRowSink } from "./rowSink.js";
|
|
3
6
|
import { extractTitles, writeTitleCache } from "./titleCache.js";
|
|
7
|
+
import { CLI_VERSION } from "../version.js";
|
|
4
8
|
const LARGE_RESULT_ROWS = 50_000;
|
|
9
|
+
export const SUPERSEDED_EXIT = 4;
|
|
10
|
+
export async function warnIfSuperseded(output, digest) {
|
|
11
|
+
const actual = await digestFile(output).catch(() => undefined);
|
|
12
|
+
if (!actual || actual.sha256 === digest.sha256)
|
|
13
|
+
return;
|
|
14
|
+
process.stderr.write(`[gangtise] warning: ${output} was replaced by another export to the same path while this one was finishing — the file there is not this command's output `
|
|
15
|
+
+ `(wrote sha256 ${digest.sha256.slice(0, 12)}…, found ${actual.sha256.slice(0, 12)}…). Give concurrent exports distinct --output paths.\n`);
|
|
16
|
+
if (process.exitCode !== 3)
|
|
17
|
+
process.exitCode = SUPERSEDED_EXIT;
|
|
18
|
+
}
|
|
5
19
|
function warnIfLargeInMemory(items, format) {
|
|
6
20
|
if (items && items.length >= LARGE_RESULT_ROWS) {
|
|
7
21
|
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`);
|
|
8
22
|
}
|
|
9
23
|
}
|
|
24
|
+
function localIso(d) {
|
|
25
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
26
|
+
const offset = -d.getTimezoneOffset();
|
|
27
|
+
const sign = offset >= 0 ? "+" : "-";
|
|
28
|
+
const abs = Math.abs(offset);
|
|
29
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
|
|
30
|
+
}
|
|
31
|
+
const SECRET_KEY = /^(access|secret|api|private)?key$|secret|token|password|credential/i;
|
|
32
|
+
function redactSecrets(value) {
|
|
33
|
+
if (Array.isArray(value))
|
|
34
|
+
return value.map(redactSecrets);
|
|
35
|
+
if (value && typeof value === "object") {
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const [key, inner] of Object.entries(value))
|
|
38
|
+
out[key] = SECRET_KEY.test(key) ? "[redacted]" : redactSecrets(inner);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
if (typeof value === "string") {
|
|
42
|
+
const eq = value.startsWith("--") ? value.indexOf("=") : -1;
|
|
43
|
+
const head = eq > 0 ? value.slice(0, eq + 1) : "";
|
|
44
|
+
const raw = eq > 0 ? value.slice(eq + 1) : value;
|
|
45
|
+
const trimmed = raw.trimStart();
|
|
46
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(trimmed);
|
|
49
|
+
if (parsed && typeof parsed === "object")
|
|
50
|
+
return head + JSON.stringify(redactSecrets(parsed));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
async function stageExportMeta(output, format, rows, columns, normalized, complete) {
|
|
59
|
+
const result = normalized && typeof normalized === "object" && !Array.isArray(normalized) ? { ...normalized } : {};
|
|
60
|
+
delete result.list;
|
|
61
|
+
const doc = {
|
|
62
|
+
file: path.basename(output),
|
|
63
|
+
format,
|
|
64
|
+
rows,
|
|
65
|
+
complete,
|
|
66
|
+
exitCode: complete ? 0 : 3,
|
|
67
|
+
command: redactSecrets(process.argv.slice(2)),
|
|
68
|
+
cliVersion: CLI_VERSION,
|
|
69
|
+
fetchedAt: localIso(new Date()),
|
|
70
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
71
|
+
columns,
|
|
72
|
+
result: redactSecrets(result),
|
|
73
|
+
};
|
|
74
|
+
const metaPath = `${output}.meta.json`;
|
|
75
|
+
const partPath = stagingPath(metaPath);
|
|
76
|
+
await fs.mkdir(path.dirname(output), { recursive: true });
|
|
77
|
+
try {
|
|
78
|
+
await fs.writeFile(partPath, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
await fs.unlink(partPath).catch(() => { });
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
async commit(digest) {
|
|
86
|
+
try {
|
|
87
|
+
if (digest)
|
|
88
|
+
await fs.writeFile(partPath, `${JSON.stringify({ ...doc, ...digest }, null, 2)}\n`, "utf8");
|
|
89
|
+
await fs.rename(partPath, metaPath);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
await fs.unlink(partPath).catch(() => { });
|
|
93
|
+
await fs.unlink(metaPath).catch(() => { });
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
async discard() {
|
|
98
|
+
await fs.unlink(partPath).catch(() => { });
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
10
102
|
export async function printData(data, format, output, cache) {
|
|
103
|
+
const sink = getRowSink(data);
|
|
104
|
+
if (sink && !sink.opened)
|
|
105
|
+
data.list = sink.takeBuffer();
|
|
106
|
+
const streamed = sink?.opened ? sink : undefined;
|
|
107
|
+
const columns = data && typeof data === "object" && Array.isArray(data.fieldList)
|
|
108
|
+
? data.fieldList
|
|
109
|
+
: undefined;
|
|
11
110
|
const normalized = normalizeRows(data);
|
|
12
111
|
const items = pickList(normalized);
|
|
13
|
-
|
|
14
|
-
|
|
112
|
+
const showing = streamed ? streamed.rows : (items?.length ?? 0);
|
|
113
|
+
if (cache) {
|
|
114
|
+
const titles = { ...(streamed?.titles ?? {}), ...(items ? extractTitles(items, cache) : {}) };
|
|
15
115
|
if (Object.keys(titles).length > 0) {
|
|
16
116
|
writeTitleCache(cache.endpointKey, titles).catch(() => { });
|
|
17
117
|
}
|
|
@@ -19,21 +119,36 @@ export async function printData(data, format, output, cache) {
|
|
|
19
119
|
if (normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
|
|
20
120
|
const meta = normalized;
|
|
21
121
|
if (typeof meta.total === "number" && format !== "json") {
|
|
22
|
-
|
|
23
|
-
process.stderr.write(`Total: ${meta.total}, showing: ${listLen}\n`);
|
|
122
|
+
process.stderr.write(`Total: ${meta.total}, showing: ${showing}\n`);
|
|
24
123
|
}
|
|
25
124
|
if (meta.partial === true) {
|
|
26
125
|
process.exitCode = 3;
|
|
27
126
|
}
|
|
28
127
|
}
|
|
128
|
+
const complete = process.exitCode !== 3;
|
|
29
129
|
if (output) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
130
|
+
const staged = format === "csv" || format === "jsonl"
|
|
131
|
+
? await stageExportMeta(output, format, streamed ? streamed.dataRows : countOutputRows(normalized, format), columns, normalized, complete)
|
|
132
|
+
: null;
|
|
133
|
+
let digest;
|
|
134
|
+
try {
|
|
135
|
+
if (streamed) {
|
|
136
|
+
await streamed.finish();
|
|
137
|
+
digest = streamed.digest;
|
|
138
|
+
}
|
|
139
|
+
else if (!(digest = (await streamOutputToFile(normalized, format, output)) ?? undefined)) {
|
|
140
|
+
warnIfLargeInMemory(items, format);
|
|
141
|
+
const content = renderOutput(normalized, format);
|
|
142
|
+
digest = await saveOutputIfNeeded(format === "csv" ? `\ufeff${content}` : content, output);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
await staged?.discard();
|
|
147
|
+
throw error;
|
|
33
148
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
149
|
+
await staged?.commit(digest);
|
|
150
|
+
if (digest)
|
|
151
|
+
await warnIfSuperseded(output, digest);
|
|
37
152
|
process.stdout.write(`${output}\n`);
|
|
38
153
|
return;
|
|
39
154
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isStructuralError } from "./errors.js";
|
|
2
|
+
import { columnarSchemaValid } from "./normalize.js";
|
|
3
|
+
import { attachRowSink } from "./rowSink.js";
|
|
4
|
+
import { isVerbose, PAGE_CONCURRENCY, runInOrder } from "./transport.js";
|
|
2
5
|
const DAY_MS = 86_400_000;
|
|
3
6
|
const ALL_MARKET_LIMIT = 10_000;
|
|
4
7
|
function parseDate(value) {
|
|
@@ -12,6 +15,21 @@ function parseDate(value) {
|
|
|
12
15
|
function formatDate(d) {
|
|
13
16
|
return d.toISOString().slice(0, 10);
|
|
14
17
|
}
|
|
18
|
+
function columnRemap(header, shard) {
|
|
19
|
+
const headerNames = new Set(header.map(String));
|
|
20
|
+
const extra = shard.map(String).filter((field) => !headerNames.has(field));
|
|
21
|
+
if (header.length === shard.length && header.every((field, i) => field === shard[i]))
|
|
22
|
+
return { map: null, extra };
|
|
23
|
+
const index = new Map(shard.map((field, i) => [String(field), i]));
|
|
24
|
+
const map = [];
|
|
25
|
+
for (const field of header) {
|
|
26
|
+
const i = index.get(String(field));
|
|
27
|
+
if (i === undefined)
|
|
28
|
+
return undefined;
|
|
29
|
+
map.push(i);
|
|
30
|
+
}
|
|
31
|
+
return { map, extra };
|
|
32
|
+
}
|
|
15
33
|
export function isFullMarket(body, fullMarketValue) {
|
|
16
34
|
const list = body.securityList;
|
|
17
35
|
if (!Array.isArray(list) || list.length !== 1)
|
|
@@ -79,7 +97,7 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
|
|
|
79
97
|
const failedShards = [];
|
|
80
98
|
let firstError = null;
|
|
81
99
|
let aborted = false;
|
|
82
|
-
const
|
|
100
|
+
const fetchShard = async (shard) => {
|
|
83
101
|
if (aborted) {
|
|
84
102
|
failedShards.push(shard);
|
|
85
103
|
return null;
|
|
@@ -95,43 +113,115 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
|
|
|
95
113
|
catch (error) {
|
|
96
114
|
if (!firstError)
|
|
97
115
|
firstError = error;
|
|
98
|
-
|
|
116
|
+
if (!isStructuralError(error))
|
|
117
|
+
aborted = true;
|
|
99
118
|
failedShards.push(shard);
|
|
100
119
|
return null;
|
|
101
120
|
}
|
|
102
|
-
}
|
|
121
|
+
};
|
|
122
|
+
const sink = client.claimRowSink?.();
|
|
103
123
|
let fieldList;
|
|
104
124
|
let header = null;
|
|
125
|
+
let fallback = null;
|
|
105
126
|
const merged = [];
|
|
127
|
+
let count = 0;
|
|
128
|
+
const objectKeys = new Set();
|
|
106
129
|
const truncatedShards = [];
|
|
107
|
-
|
|
108
|
-
|
|
130
|
+
const partialShards = [];
|
|
131
|
+
const droppedColumns = new Set();
|
|
132
|
+
const keep = async (rows) => {
|
|
133
|
+
count += rows.length;
|
|
134
|
+
if (!fieldList)
|
|
135
|
+
for (const row of rows)
|
|
136
|
+
if (row && typeof row === "object" && !Array.isArray(row))
|
|
137
|
+
for (const key of Object.keys(row))
|
|
138
|
+
objectKeys.add(key);
|
|
139
|
+
if (sink)
|
|
140
|
+
await sink.push(rows);
|
|
141
|
+
else
|
|
142
|
+
for (const row of rows)
|
|
143
|
+
merged.push(row);
|
|
144
|
+
};
|
|
145
|
+
const mergeShard = async (r, i) => {
|
|
109
146
|
if (!(r && typeof r === "object"))
|
|
110
|
-
|
|
147
|
+
return;
|
|
111
148
|
const rec = r;
|
|
112
|
-
if (!header)
|
|
113
|
-
header = rec;
|
|
114
|
-
if (!fieldList && Array.isArray(rec.fieldList))
|
|
115
|
-
fieldList = rec.fieldList;
|
|
116
149
|
if (isTruncated(rec))
|
|
117
150
|
truncatedShards.push(shards[i]);
|
|
118
|
-
if (Array.isArray(rec.list))
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
151
|
+
if (!Array.isArray(rec.list))
|
|
152
|
+
return;
|
|
153
|
+
if (rec.list.length === 0) {
|
|
154
|
+
if ((typeof rec.total === "number" && rec.total > 0) || rec.partial === true) {
|
|
155
|
+
failedShards.push(shards[i]);
|
|
156
|
+
const claim = typeof rec.total === "number" && rec.total > 0 ? `reported total=${rec.total}` : "carried a partial marker";
|
|
157
|
+
process.stderr.write(`[gangtise] warning: shard ${shards[i].startDate}..${shards[i].endDate} ${claim} but delivered no rows; treated as failed (see failedShards)\n`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!fallback)
|
|
161
|
+
fallback = rec;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const shardFields = Array.isArray(rec.fieldList) && rec.fieldList.length > 0 ? rec.fieldList : undefined;
|
|
165
|
+
const columnar = rec.list.some(Array.isArray);
|
|
166
|
+
if (columnar && !columnarSchemaValid(shardFields, rec.list)) {
|
|
167
|
+
failedShards.push(shards[i]);
|
|
168
|
+
process.stderr.write(`[gangtise] warning: shard ${shards[i].startDate}..${shards[i].endDate} returned columnar rows that do not match its own fieldList (missing, duplicated or mis-sized); its rows were dropped (see failedShards)\n`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (!fieldList && columnar && shardFields) {
|
|
172
|
+
fieldList = shardFields;
|
|
173
|
+
sink?.setFieldList(fieldList);
|
|
174
|
+
}
|
|
175
|
+
if (!header)
|
|
176
|
+
header = rec;
|
|
177
|
+
if (rec.partial === true)
|
|
178
|
+
partialShards.push(shards[i]);
|
|
179
|
+
const remap = columnar && fieldList && shardFields ? columnRemap(fieldList, shardFields) : null;
|
|
180
|
+
if (remap === undefined) {
|
|
181
|
+
failedShards.push(shards[i]);
|
|
182
|
+
process.stderr.write(`[gangtise] warning: shard ${shards[i].startDate}..${shards[i].endDate} returned columns that cannot be aligned with the first shard's fieldList; its rows were dropped (see failedShards)\n`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
for (const field of remap?.extra ?? [])
|
|
186
|
+
droppedColumns.add(field);
|
|
187
|
+
const rows = rec.list;
|
|
188
|
+
const map = remap?.map;
|
|
189
|
+
await keep(map ? rows.map((item) => (Array.isArray(item) ? map.map((k) => item[k]) : item)) : rows);
|
|
190
|
+
};
|
|
191
|
+
await runInOrder(shards, config.concurrency ?? PAGE_CONCURRENCY, fetchShard, mergeShard);
|
|
122
192
|
if (failedShards.length === shards.length) {
|
|
123
193
|
throw firstError ?? new Error(`All ${shards.length} kline shards failed`);
|
|
124
194
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
195
|
+
const base = header ?? fallback ?? {};
|
|
196
|
+
const out = { ...base, total: count, list: merged };
|
|
197
|
+
if (sink)
|
|
198
|
+
attachRowSink(out, sink);
|
|
199
|
+
if (fieldList) {
|
|
129
200
|
out.fieldList = fieldList;
|
|
201
|
+
}
|
|
202
|
+
else if (count > 0) {
|
|
203
|
+
out.fieldList = [...objectKeys];
|
|
204
|
+
}
|
|
205
|
+
else if (Array.isArray(base.fieldList)) {
|
|
206
|
+
out.fieldList = base.fieldList;
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
delete out.fieldList;
|
|
210
|
+
}
|
|
130
211
|
if (failedShards.length > 0) {
|
|
131
212
|
out.partial = true;
|
|
132
213
|
out.failedShards = failedShards;
|
|
133
214
|
process.stderr.write(`[gangtise] warning: ${failedShards.length}/${shards.length} shards failed; results are partial (see failedShards)\n`);
|
|
134
215
|
}
|
|
216
|
+
if (partialShards.length > 0) {
|
|
217
|
+
out.partial = true;
|
|
218
|
+
process.stderr.write(`[gangtise] warning: ${partialShards.length}/${shards.length} shard(s) reported themselves partial; the merged result is marked partial\n`);
|
|
219
|
+
}
|
|
220
|
+
if (droppedColumns.size > 0) {
|
|
221
|
+
out.partial = true;
|
|
222
|
+
out.droppedColumns = [...droppedColumns];
|
|
223
|
+
process.stderr.write(`[gangtise] warning: ${droppedColumns.size} column(s) came back only in later shards and could not be carried (see droppedColumns); the merged fieldList is the first data shard's — re-pull a narrower range to get them.\n`);
|
|
224
|
+
}
|
|
135
225
|
if (truncatedShards.length > 0) {
|
|
136
226
|
out.partial = true;
|
|
137
227
|
out.truncatedShards = truncatedShards;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { assertColumnarHeader, zipFieldRow } from "./normalize.js";
|
|
5
|
+
import { csvEscape, digestFile, formatScalar, stagingPath, writeLine } from "./output.js";
|
|
6
|
+
import { extractTitles, MAX_TITLES_PER_ENDPOINT } from "./titleCache.js";
|
|
7
|
+
export const ROW_SINK = Symbol("gangtise.rowSink");
|
|
8
|
+
const STREAM_THRESHOLD = 1000;
|
|
9
|
+
export class ExportSink {
|
|
10
|
+
outputPath;
|
|
11
|
+
format;
|
|
12
|
+
cache;
|
|
13
|
+
rows = 0;
|
|
14
|
+
digest;
|
|
15
|
+
titles = {};
|
|
16
|
+
titleCount = 0;
|
|
17
|
+
buffer = [];
|
|
18
|
+
stream = null;
|
|
19
|
+
fields;
|
|
20
|
+
headerChecked = false;
|
|
21
|
+
finished = false;
|
|
22
|
+
columns = [];
|
|
23
|
+
columnSet = new Set();
|
|
24
|
+
objectRows = 0;
|
|
25
|
+
partPath;
|
|
26
|
+
rowsPath;
|
|
27
|
+
constructor(outputPath, format = "jsonl", cache) {
|
|
28
|
+
this.outputPath = outputPath;
|
|
29
|
+
this.format = format;
|
|
30
|
+
this.cache = cache;
|
|
31
|
+
this.partPath = stagingPath(outputPath);
|
|
32
|
+
this.rowsPath = stagingPath(outputPath, "rows.part");
|
|
33
|
+
}
|
|
34
|
+
get opened() {
|
|
35
|
+
return this.stream !== null;
|
|
36
|
+
}
|
|
37
|
+
get count() {
|
|
38
|
+
return this.rows + this.buffer.length;
|
|
39
|
+
}
|
|
40
|
+
get dataRows() {
|
|
41
|
+
if (this.format === "jsonl")
|
|
42
|
+
return this.rows;
|
|
43
|
+
return this.objectRows > 0 ? this.objectRows : this.rows;
|
|
44
|
+
}
|
|
45
|
+
setFieldList(fields) {
|
|
46
|
+
this.fields = fields;
|
|
47
|
+
}
|
|
48
|
+
async push(rows) {
|
|
49
|
+
if (this.stream) {
|
|
50
|
+
await this.write(rows);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
for (const row of rows)
|
|
54
|
+
this.buffer.push(row);
|
|
55
|
+
if (this.buffer.length < STREAM_THRESHOLD)
|
|
56
|
+
return;
|
|
57
|
+
await this.open();
|
|
58
|
+
const buffered = this.buffer;
|
|
59
|
+
this.buffer = [];
|
|
60
|
+
await this.write(buffered);
|
|
61
|
+
}
|
|
62
|
+
takeBuffer() {
|
|
63
|
+
const rows = this.buffer;
|
|
64
|
+
this.buffer = [];
|
|
65
|
+
return rows;
|
|
66
|
+
}
|
|
67
|
+
async finish() {
|
|
68
|
+
const stream = this.stream;
|
|
69
|
+
if (!stream || this.finished)
|
|
70
|
+
return;
|
|
71
|
+
try {
|
|
72
|
+
await endStream(stream);
|
|
73
|
+
if (this.format === "csv")
|
|
74
|
+
await this.assembleCsv();
|
|
75
|
+
this.digest = await digestFile(this.partPath);
|
|
76
|
+
await fs.rename(this.partPath, this.outputPath);
|
|
77
|
+
this.finished = true;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
await this.abort();
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async abort() {
|
|
85
|
+
const stream = this.stream;
|
|
86
|
+
if (!stream || this.finished)
|
|
87
|
+
return;
|
|
88
|
+
this.finished = true;
|
|
89
|
+
stream.destroy();
|
|
90
|
+
if (!stream.closed)
|
|
91
|
+
await new Promise((resolve) => stream.once("close", resolve));
|
|
92
|
+
await fs.unlink(this.partPath).catch(() => { });
|
|
93
|
+
await fs.unlink(this.rowsPath).catch(() => { });
|
|
94
|
+
}
|
|
95
|
+
async open() {
|
|
96
|
+
await fs.mkdir(path.dirname(this.outputPath), { recursive: true });
|
|
97
|
+
const stream = createWriteStream(this.format === "csv" ? this.rowsPath : this.partPath, { encoding: "utf8" });
|
|
98
|
+
stream.on("error", () => { });
|
|
99
|
+
this.stream = stream;
|
|
100
|
+
}
|
|
101
|
+
async write(rows) {
|
|
102
|
+
const stream = this.stream;
|
|
103
|
+
for (const row of rows) {
|
|
104
|
+
let item = row;
|
|
105
|
+
if (Array.isArray(row)) {
|
|
106
|
+
if (!this.headerChecked) {
|
|
107
|
+
assertColumnarHeader(this.fields);
|
|
108
|
+
this.headerChecked = true;
|
|
109
|
+
}
|
|
110
|
+
item = zipFieldRow(this.fields, row);
|
|
111
|
+
}
|
|
112
|
+
if (this.format === "csv" && isObjectRow(item)) {
|
|
113
|
+
this.objectRows++;
|
|
114
|
+
for (const key of Object.keys(item)) {
|
|
115
|
+
if (!this.columnSet.has(key)) {
|
|
116
|
+
this.columnSet.add(key);
|
|
117
|
+
this.columns.push(key);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
await writeLine(stream, JSON.stringify(item));
|
|
122
|
+
this.rows++;
|
|
123
|
+
if (this.cache && this.titleCount < MAX_TITLES_PER_ENDPOINT) {
|
|
124
|
+
for (const [id, title] of Object.entries(extractTitles([item], this.cache))) {
|
|
125
|
+
if (!(id in this.titles))
|
|
126
|
+
this.titleCount++;
|
|
127
|
+
this.titles[id] = title;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async assembleCsv() {
|
|
133
|
+
const out = createWriteStream(this.partPath, { encoding: "utf8" });
|
|
134
|
+
out.on("error", () => { });
|
|
135
|
+
this.stream = out;
|
|
136
|
+
const objectMode = this.objectRows > 0;
|
|
137
|
+
const columns = objectMode ? this.columns : ["index", "value"];
|
|
138
|
+
await writeLine(out, "\ufeff" + columns.map(csvEscape).join(","));
|
|
139
|
+
const input = createReadStream(this.rowsPath, { encoding: "utf8" });
|
|
140
|
+
try {
|
|
141
|
+
let index = 0;
|
|
142
|
+
const emit = async (line) => {
|
|
143
|
+
if (!line)
|
|
144
|
+
return;
|
|
145
|
+
const item = JSON.parse(line);
|
|
146
|
+
if (objectMode) {
|
|
147
|
+
if (!isObjectRow(item))
|
|
148
|
+
return;
|
|
149
|
+
await writeLine(out, columns.map((column) => csvEscape(formatScalar(item[column]))).join(","));
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
await writeLine(out, `${csvEscape(String(index++))},${csvEscape(formatScalar(item))}`);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
let rest = "";
|
|
156
|
+
for await (const chunk of input) {
|
|
157
|
+
const lines = (rest + String(chunk)).split("\n");
|
|
158
|
+
rest = lines.pop() ?? "";
|
|
159
|
+
for (const line of lines)
|
|
160
|
+
await emit(line);
|
|
161
|
+
}
|
|
162
|
+
await emit(rest);
|
|
163
|
+
await endStream(out);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
input.destroy();
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
await fs.unlink(this.rowsPath).catch(() => { });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function isObjectRow(value) {
|
|
173
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
174
|
+
}
|
|
175
|
+
function endStream(stream) {
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
stream.end((err) => err ? reject(err) : resolve());
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
export function attachRowSink(result, sink) {
|
|
181
|
+
Object.defineProperty(result, ROW_SINK, { value: sink, enumerable: false, configurable: true });
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
export function getRowSink(value) {
|
|
185
|
+
if (!value || typeof value !== "object")
|
|
186
|
+
return undefined;
|
|
187
|
+
const sink = value[ROW_SINK];
|
|
188
|
+
return sink instanceof ExportSink ? sink : undefined;
|
|
189
|
+
}
|
|
190
|
+
export function rowCount(value) {
|
|
191
|
+
const sink = getRowSink(value);
|
|
192
|
+
if (sink)
|
|
193
|
+
return sink.count;
|
|
194
|
+
const list = value && typeof value === "object" ? value.list : undefined;
|
|
195
|
+
return Array.isArray(list) ? list.length : 0;
|
|
196
|
+
}
|
|
@@ -62,6 +62,57 @@ export async function runWithConcurrency(items, concurrency, fn) {
|
|
|
62
62
|
await Promise.all(Array.from({ length: limit }, () => worker()));
|
|
63
63
|
return results;
|
|
64
64
|
}
|
|
65
|
+
export async function runInOrder(items, concurrency, fn, consume) {
|
|
66
|
+
const width = Math.max(1, concurrency);
|
|
67
|
+
const pending = new Map();
|
|
68
|
+
let next = 0;
|
|
69
|
+
let failure = null;
|
|
70
|
+
let chain = Promise.resolve();
|
|
71
|
+
let waiters = [];
|
|
72
|
+
const wake = () => {
|
|
73
|
+
const ws = waiters;
|
|
74
|
+
waiters = [];
|
|
75
|
+
for (const w of ws)
|
|
76
|
+
w();
|
|
77
|
+
};
|
|
78
|
+
const drain = () => {
|
|
79
|
+
chain = chain.then(async () => {
|
|
80
|
+
while (!failure && pending.has(next)) {
|
|
81
|
+
const result = pending.get(next);
|
|
82
|
+
pending.delete(next);
|
|
83
|
+
await consume(result, next++);
|
|
84
|
+
wake();
|
|
85
|
+
}
|
|
86
|
+
}).catch((error) => {
|
|
87
|
+
failure ??= { error };
|
|
88
|
+
wake();
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
await runWithConcurrency(items, width, async (item, index) => {
|
|
92
|
+
if (failure)
|
|
93
|
+
return;
|
|
94
|
+
let result;
|
|
95
|
+
try {
|
|
96
|
+
result = await fn(item, index);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
failure ??= { error };
|
|
100
|
+
wake();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (failure)
|
|
104
|
+
return;
|
|
105
|
+
pending.set(index, result);
|
|
106
|
+
drain();
|
|
107
|
+
while (!failure && pending.size >= width) {
|
|
108
|
+
await new Promise((resolve) => waiters.push(resolve));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
await chain;
|
|
112
|
+
const failed = failure;
|
|
113
|
+
if (failed)
|
|
114
|
+
throw failed.error;
|
|
115
|
+
}
|
|
65
116
|
export function resolvePageConcurrency(raw, fallback = 5, max = 32) {
|
|
66
117
|
const parsed = Math.floor(Number(raw));
|
|
67
118
|
if (!Number.isFinite(parsed) || parsed < 1)
|
package/dist/src/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.
|
|
1
|
+
export const CLI_VERSION = "0.39.0";
|