gangtise-openapi-cli 0.41.1 → 0.43.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 +45 -39
- package/dist/src/cli.js +50 -1161
- package/dist/src/commands/ai.js +156 -0
- package/dist/src/commands/alternative.js +56 -0
- package/dist/src/commands/auth.js +37 -0
- package/dist/src/commands/bond.js +76 -0
- package/dist/src/commands/fundamental.js +137 -0
- package/dist/src/commands/indicator.js +134 -0
- package/dist/src/commands/insight.js +338 -0
- package/dist/src/commands/quote.js +181 -0
- package/dist/src/commands/raw.js +49 -0
- package/dist/src/commands/reference.js +58 -0
- package/dist/src/commands/shared.js +197 -0
- package/dist/src/commands/tool.js +55 -0
- package/dist/src/commands/vault.js +203 -0
- package/dist/src/core/args.js +13 -9
- package/dist/src/core/asyncContent.js +32 -19
- package/dist/src/core/auth.js +3 -2
- package/dist/src/core/client.js +112 -15
- package/dist/src/core/commandBodies.js +2 -1
- package/dist/src/core/config.js +16 -3
- package/dist/src/core/download.js +9 -5
- package/dist/src/core/endpoints.js +106 -1
- package/dist/src/core/exitStatus.js +17 -0
- package/dist/src/core/fileParse.js +3 -20
- package/dist/src/core/opinionDetail.js +39 -0
- package/dist/src/core/output.js +55 -12
- package/dist/src/core/perSecurity.js +29 -11
- package/dist/src/core/printer.js +7 -8
- package/dist/src/core/quoteSharding.js +19 -15
- package/dist/src/core/rowSink.js +40 -7
- package/dist/src/core/titleCache.js +28 -2
- package/dist/src/core/transport.js +7 -4
- package/dist/src/core/updateCheck.js +51 -0
- package/dist/src/version.js +19 -1
- package/gangtise-openapi/SKILL.md +50 -49
- package/gangtise-openapi/references/commands/ai.md +35 -16
- package/gangtise-openapi/references/commands/alternative.md +2 -2
- package/gangtise-openapi/references/commands/bond.md +1 -1
- package/gangtise-openapi/references/commands/fundamental.md +5 -5
- package/gangtise-openapi/references/commands/indicator.md +29 -27
- package/gangtise-openapi/references/commands/insight.md +39 -31
- package/gangtise-openapi/references/commands/quote.md +19 -19
- package/gangtise-openapi/references/commands/reference-and-lookup.md +18 -15
- package/gangtise-openapi/references/commands/vault.md +10 -7
- package/gangtise-openapi/references/errors.md +27 -25
- package/gangtise-openapi/references/examples.md +38 -36
- package/gangtise-openapi/references/fields.md +8 -6
- package/gangtise-openapi/references/lookup-ids.md +10 -8
- package/gangtise-openapi/references/response-schema.md +7 -7
- package/package.json +1 -1
package/dist/src/core/client.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { createWriteStream } from "node:fs";
|
|
2
3
|
import fs from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
@@ -10,10 +11,30 @@ import { getLookupData } from "./lookupData/index.js";
|
|
|
10
11
|
import { stagingPath } from "./output.js";
|
|
11
12
|
import { decodeResponseBody, getDispatcher, isVerbose, logTiming, markRetryable, PAGE_CONCURRENCY, parseRetryAfterMs, quoteBigIntFields, runInOrder, withRetry } from "./transport.js";
|
|
12
13
|
import { attachRowSink } from "./rowSink.js";
|
|
14
|
+
import { markIncomplete } from "./exitStatus.js";
|
|
13
15
|
const AUTH_RETRY_CODES = new Set(["8000014", "8000015", "0000001008", "999002"]);
|
|
16
|
+
function hasExpectedShape(expects, payload) {
|
|
17
|
+
if (expects === "array")
|
|
18
|
+
return Array.isArray(payload);
|
|
19
|
+
return Boolean(payload && typeof payload === "object" && Array.isArray(payload.list));
|
|
20
|
+
}
|
|
21
|
+
export const COSTLY_FETCH_CREDITS = 1000;
|
|
22
|
+
const PROBE_ABOVE_CREDITS = 50;
|
|
23
|
+
const OFFSET_REFUSAL_CODES = new Set(["140002", "100006"]);
|
|
24
|
+
function readEmptyListAsArray(page) {
|
|
25
|
+
const rec = page;
|
|
26
|
+
if (rec && typeof rec === "object" && rec.total === 0 && rec.list === null)
|
|
27
|
+
rec.list = [];
|
|
28
|
+
}
|
|
29
|
+
function markTotalCapped(out, why, collected, keepBelow) {
|
|
30
|
+
process.stderr.write(`[gangtise] warning: ${why}. This export is TRUNCATED at ${collected} rows. Narrow the query (e.g. a shorter time range) until total stays below ${keepBelow}, and fetch in slices.\n`);
|
|
31
|
+
out.partial = true;
|
|
32
|
+
out.totalCapped = true;
|
|
33
|
+
}
|
|
14
34
|
export class GangtiseClient {
|
|
15
35
|
config;
|
|
16
36
|
rowSink;
|
|
37
|
+
allowCostlyFetch = false;
|
|
17
38
|
refreshPromise = null;
|
|
18
39
|
memoCache = null;
|
|
19
40
|
envTokenInvalidated = false;
|
|
@@ -162,15 +183,42 @@ export class GangtiseClient {
|
|
|
162
183
|
throw new ValidationError(`${endpoint.key} serves rows only up to offset ${maxWindow} (from + size ≤ ${maxWindow}); --from ${startFrom} is past it. Narrow the query (e.g. a shorter time range) instead of paging deeper.`);
|
|
163
184
|
}
|
|
164
185
|
const windowRoom = maxWindow === undefined ? Infinity : maxWindow - startFrom;
|
|
165
|
-
const
|
|
166
|
-
const
|
|
186
|
+
const billing = endpoint.billing;
|
|
187
|
+
const guarded = requestedSize === undefined && billing?.per === "row" && !this.allowCostlyFetch;
|
|
188
|
+
const refuseIfCostly = (reportedTotal, fetched) => {
|
|
189
|
+
if (!guarded || !billing)
|
|
190
|
+
return;
|
|
191
|
+
const rows = Math.min(Math.max(reportedTotal - startFrom, 0), windowRoom);
|
|
192
|
+
const estimatedCredits = Math.round(rows * billing.price * 100) / 100;
|
|
193
|
+
if (estimatedCredits <= COSTLY_FETCH_CREDITS)
|
|
194
|
+
return;
|
|
195
|
+
throw new ValidationError(`fetching all ${rows} rows of ${endpoint.key} would cost about ${estimatedCredits} credits (${billing.price} per row), above the ${COSTLY_FETCH_CREDITS}-credit guard for a fetch without --size. Only ${fetched} row(s) were fetched, to learn the total. Pass --size N for a bounded subset, or --yes to fetch them all.`);
|
|
196
|
+
};
|
|
197
|
+
let probe;
|
|
198
|
+
let probed = false;
|
|
199
|
+
let probeRows = 0;
|
|
200
|
+
if (guarded && billing && Math.min(maxPageSize, windowRoom) * billing.price > PROBE_ABOVE_CREDITS) {
|
|
201
|
+
probe = await this.requestJson(endpoint, { ...initialBody, from: startFrom, size: 1 });
|
|
202
|
+
readEmptyListAsArray(probe);
|
|
203
|
+
if (!this.isPaginatedListResponse(probe)) {
|
|
204
|
+
probed = true;
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
refuseIfCostly(probe.total, probe.list.length);
|
|
208
|
+
probeRows = probe.list.length;
|
|
209
|
+
probed = probe.list.length === Math.min(Math.max(probe.total - startFrom, 0), windowRoom);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const firstPageSize = probed ? 1 : Math.min(requestedSize === undefined ? maxPageSize : Math.min(maxPageSize, requestedSize), windowRoom);
|
|
213
|
+
const firstPage = probed ? probe : await this.requestJson(endpoint, {
|
|
167
214
|
...initialBody,
|
|
168
215
|
from: startFrom,
|
|
169
216
|
size: firstPageSize,
|
|
170
217
|
});
|
|
218
|
+
readEmptyListAsArray(firstPage);
|
|
171
219
|
if (!this.isPaginatedListResponse(firstPage)) {
|
|
172
220
|
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`);
|
|
173
|
-
|
|
221
|
+
markIncomplete();
|
|
174
222
|
return firstPage;
|
|
175
223
|
}
|
|
176
224
|
const total = firstPage.total;
|
|
@@ -189,7 +237,41 @@ export class GangtiseClient {
|
|
|
189
237
|
sink.setFieldList(firstPage.fieldList);
|
|
190
238
|
const collected = [];
|
|
191
239
|
let count = 0;
|
|
192
|
-
const
|
|
240
|
+
const rowId = endpoint.rowId;
|
|
241
|
+
const seen = rowId ? new Map() : undefined;
|
|
242
|
+
let duplicateRows = 0;
|
|
243
|
+
let changedRows = 0;
|
|
244
|
+
let idIsRowKey = true;
|
|
245
|
+
const dropRepeats = (rows) => {
|
|
246
|
+
if (!seen || !rowId)
|
|
247
|
+
return rows;
|
|
248
|
+
const onThisPage = new Set();
|
|
249
|
+
return rows.filter((row) => {
|
|
250
|
+
const id = row && typeof row === "object" ? row[rowId] : undefined;
|
|
251
|
+
if (id === undefined || id === null)
|
|
252
|
+
return true;
|
|
253
|
+
const digest = createHash("sha1").update(JSON.stringify(row)).digest("base64");
|
|
254
|
+
const key = String(id);
|
|
255
|
+
const previous = seen.get(key);
|
|
256
|
+
const earlierOnThisPage = onThisPage.has(key);
|
|
257
|
+
onThisPage.add(key);
|
|
258
|
+
if (previous === undefined) {
|
|
259
|
+
seen.set(key, digest);
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
if (previous !== digest) {
|
|
263
|
+
if (earlierOnThisPage)
|
|
264
|
+
idIsRowKey = false;
|
|
265
|
+
else
|
|
266
|
+
changedRows++;
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
duplicateRows++;
|
|
270
|
+
return false;
|
|
271
|
+
});
|
|
272
|
+
};
|
|
273
|
+
const keep = async (fetched) => {
|
|
274
|
+
const rows = dropRepeats(fetched);
|
|
193
275
|
const kept = requestedSize === undefined ? rows : rows.slice(0, Math.max(0, requestedSize - count));
|
|
194
276
|
if (kept.length === 0)
|
|
195
277
|
return;
|
|
@@ -203,10 +285,20 @@ export class GangtiseClient {
|
|
|
203
285
|
const checkTotalCap = async (out) => {
|
|
204
286
|
if (probeFits)
|
|
205
287
|
return this.flagIfTotalCapped(endpoint, initialBody, total, out, count);
|
|
206
|
-
|
|
288
|
+
markTotalCapped(out, `${endpoint.key} reported total=${total}, which equals its ${maxWindow}-row offset window: rows past it can be neither fetched nor counted`, count, maxWindow);
|
|
207
289
|
};
|
|
208
290
|
const result = () => {
|
|
209
291
|
const out = { ...firstPage, total, list: collected };
|
|
292
|
+
if (duplicateRows > 0) {
|
|
293
|
+
process.stderr.write(`[gangtise] warning: ${endpoint.key} returned ${duplicateRows} row(s) more than once — its sort order is not unique, so paging also missed as many rows. The duplicates were dropped and the result is partial. Re-run over a shorter time range to fetch the missing rows.\n`);
|
|
294
|
+
out.duplicateRows = duplicateRows;
|
|
295
|
+
out.partial = true;
|
|
296
|
+
}
|
|
297
|
+
if (changedRows > 0 && idIsRowKey && !endpoint.rowIdUnverified) {
|
|
298
|
+
process.stderr.write(`[gangtise] warning: ${endpoint.key} returned ${changedRows} row(s) whose ${rowId} came back on a later page with different content — the list changed while it was being paged, so rows may also have been missed. Both versions were kept (deduplicating by ${rowId} keeps one) and the result is partial. Re-run to fetch a consistent result.\n`);
|
|
299
|
+
out.changedRows = changedRows;
|
|
300
|
+
out.partial = true;
|
|
301
|
+
}
|
|
210
302
|
return sink ? attachRowSink(out, sink) : out;
|
|
211
303
|
};
|
|
212
304
|
await keep(firstPage.list);
|
|
@@ -226,6 +318,7 @@ export class GangtiseClient {
|
|
|
226
318
|
await checkTotalCap(out);
|
|
227
319
|
return out;
|
|
228
320
|
}
|
|
321
|
+
refuseIfCostly(total, count + (probed ? 0 : probeRows));
|
|
229
322
|
const MAX_PAGES = 1000;
|
|
230
323
|
let truncatedByPageCap = false;
|
|
231
324
|
const pageRequests = [];
|
|
@@ -258,6 +351,7 @@ export class GangtiseClient {
|
|
|
258
351
|
from: req.from,
|
|
259
352
|
size: req.size,
|
|
260
353
|
});
|
|
354
|
+
readEmptyListAsArray(page);
|
|
261
355
|
if (!this.isPaginatedListResponse(page)) {
|
|
262
356
|
unexpectedShape = true;
|
|
263
357
|
failedPages.push(req);
|
|
@@ -276,7 +370,7 @@ export class GangtiseClient {
|
|
|
276
370
|
failedPages.push(req);
|
|
277
371
|
return [];
|
|
278
372
|
}
|
|
279
|
-
}, (list) => keep(list));
|
|
373
|
+
}, (list) => keep(list), PAGE_CONCURRENCY * 4);
|
|
280
374
|
if (unexpectedShape) {
|
|
281
375
|
process.stderr.write(`[gangtise] warning: a page response had unexpected shape; its rows are missing (counted in failedPages)\n`);
|
|
282
376
|
}
|
|
@@ -287,9 +381,10 @@ export class GangtiseClient {
|
|
|
287
381
|
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`);
|
|
288
382
|
}
|
|
289
383
|
const short = count < target;
|
|
384
|
+
const shortByRepeatsOnly = short && count + duplicateRows >= target;
|
|
290
385
|
const out = result();
|
|
291
386
|
flagWindowCut(out);
|
|
292
|
-
if (requestedSize === undefined && total > 0 && target === wanted && !short && !totalDrift && !truncatedByPageCap && failedPages.length === 0) {
|
|
387
|
+
if (requestedSize === undefined && total > 0 && target === wanted && (!short || shortByRepeatsOnly) && !totalDrift && !truncatedByPageCap && failedPages.length === 0) {
|
|
293
388
|
await checkTotalCap(out);
|
|
294
389
|
}
|
|
295
390
|
if (short || totalDrift || failedPages.length > 0)
|
|
@@ -304,7 +399,7 @@ export class GangtiseClient {
|
|
|
304
399
|
const skippedHint = aborted ? " A page hit a non-retryable error (e.g. rate limit); remaining pages were skipped." : "";
|
|
305
400
|
process.stderr.write(`[gangtise] warning: ${failedPages.length}/${pageRequests.length} pages not fetched${detail}; results are partial — got ${count}/${total} rows (see failedPages).${skippedHint}\n`);
|
|
306
401
|
}
|
|
307
|
-
else if (short && !truncatedByPageCap && !totalDrift) {
|
|
402
|
+
else if (short && !shortByRepeatsOnly && !truncatedByPageCap && !totalDrift) {
|
|
308
403
|
process.stderr.write(`[gangtise] warning: server returned ${count} of ${total} rows (a later page came back short); results may be incomplete\n`);
|
|
309
404
|
}
|
|
310
405
|
return out;
|
|
@@ -316,13 +411,14 @@ export class GangtiseClient {
|
|
|
316
411
|
if (this.isPaginatedListResponse(probe) && probe.list.length > 0)
|
|
317
412
|
totalCapped = true;
|
|
318
413
|
}
|
|
319
|
-
catch {
|
|
414
|
+
catch (error) {
|
|
415
|
+
if (error instanceof ApiError && error.code !== undefined && OFFSET_REFUSAL_CODES.has(error.code)) {
|
|
416
|
+
markTotalCapped(out, `${endpoint.key} reported total=${total} and refused the row right after it (${error.code}) — most likely a server-side offset window at total, so rows past it may exist and can be neither fetched nor counted`, collectedLength, total);
|
|
417
|
+
}
|
|
320
418
|
}
|
|
321
419
|
if (!totalCapped)
|
|
322
420
|
return;
|
|
323
|
-
|
|
324
|
-
out.partial = true;
|
|
325
|
-
out.totalCapped = true;
|
|
421
|
+
markTotalCapped(out, `${endpoint.key} reported total=${total} but rows exist past that offset — 'total' is a server-side cap, not the real count`, collectedLength, total);
|
|
326
422
|
}
|
|
327
423
|
async login() {
|
|
328
424
|
if (this.config.token && !this.envTokenInvalidated) {
|
|
@@ -393,8 +489,9 @@ export class GangtiseClient {
|
|
|
393
489
|
this.throwHttpError(parsed, response.statusCode, retryAfterMs);
|
|
394
490
|
}
|
|
395
491
|
const payload = this.unwrapEnvelope(parsed, response.statusCode, retryAfterMs);
|
|
396
|
-
if (endpoint.expects
|
|
397
|
-
|
|
492
|
+
if (endpoint.expects && !hasExpectedShape(endpoint.expects, payload)) {
|
|
493
|
+
const got = payload === null ? "null" : Array.isArray(payload) ? "an array" : typeof payload;
|
|
494
|
+
throw markStructural(new ApiError(`${endpoint.key} returned no ${endpoint.expects} payload (got ${got}) — the response layout may have changed`, undefined, response.statusCode, parsed));
|
|
398
495
|
}
|
|
399
496
|
return payload;
|
|
400
497
|
}
|
|
@@ -413,7 +510,7 @@ export class GangtiseClient {
|
|
|
413
510
|
}).catch((error) => {
|
|
414
511
|
const isIndicatorFetch = endpoint.key === 'indicator.cross-section' || endpoint.key === 'indicator.time-series' || endpoint.key === 'indicator.screener';
|
|
415
512
|
if (isIndicatorFetch && error instanceof ApiError && error.code === '999999') {
|
|
416
|
-
throw new ApiError(error.message, error.code, error.statusCode, error.details, error.retryAfterMs, 'EDE 取数故障。先核对参数再重试:参数名以 indicator search 的 parameterList
|
|
513
|
+
throw new ApiError(error.message, error.code, error.statusCode, error.details, error.retryAfterMs, 'EDE 取数故障。先核对参数再重试:参数名以 indicator search 的 parameterList 为准(传错名会报 100003 并指名)、日期匹配指标周期(财务报表类用报告期末如 2025-12-31,PE/PB 等日频估值用交易日)、标的在 scopeList 覆盖内、required 参数已补。注意此码不表示无数据——无数据返回保留行列的 null 单元格;code 或参数名写错报 100003、缺必填参数报 100001,都会指名。');
|
|
417
514
|
}
|
|
418
515
|
throw error;
|
|
419
516
|
});
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { maybeArray, parseFrom, parseIndicatorParams, parseOptionalNumberOption, parseScreenerIndicators, parseSize } from "./args.js";
|
|
2
2
|
import { ValidationError } from "./errors.js";
|
|
3
|
+
import { QUOTE_MAX_LIMIT } from "./quoteSharding.js";
|
|
3
4
|
export function buildQuoteKlineBody(options) {
|
|
4
5
|
return {
|
|
5
6
|
securityList: maybeArray(options.security),
|
|
6
7
|
startDate: options.startDate,
|
|
7
8
|
endDate: options.endDate,
|
|
8
|
-
limit: parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1, max:
|
|
9
|
+
limit: parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1, max: QUOTE_MAX_LIMIT }),
|
|
9
10
|
fieldList: maybeArray(options.field),
|
|
10
11
|
};
|
|
11
12
|
}
|
package/dist/src/core/config.js
CHANGED
|
@@ -3,15 +3,28 @@ import path from "node:path";
|
|
|
3
3
|
export const DEFAULT_BASE_URL = "https://openapi.gangtise.com";
|
|
4
4
|
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
5
5
|
export const DEFAULT_TOKEN_CACHE_PATH = path.join(os.homedir(), ".config", "gangtise", "token.json");
|
|
6
|
+
let timeoutWarned = false;
|
|
6
7
|
export function loadConfig() {
|
|
7
|
-
const
|
|
8
|
-
const timeoutMs =
|
|
8
|
+
const rawTimeout = process.env.GANGTISE_TIMEOUT_MS?.trim();
|
|
9
|
+
const timeoutMs = resolveTimeoutEnv(rawTimeout);
|
|
10
|
+
if (rawTimeout && Number(rawTimeout) !== timeoutMs && !timeoutWarned) {
|
|
11
|
+
timeoutWarned = true;
|
|
12
|
+
process.stderr.write(`[gangtise] warning: GANGTISE_TIMEOUT_MS=${rawTimeout} is not in effect, using ${timeoutMs} ms: expected whole milliseconds from ${MIN_TIMEOUT_MS} to ${MAX_TIMEOUT_MS}\n`);
|
|
13
|
+
}
|
|
9
14
|
return {
|
|
10
15
|
baseUrl: process.env.GANGTISE_BASE_URL ?? DEFAULT_BASE_URL,
|
|
11
|
-
timeoutMs
|
|
16
|
+
timeoutMs,
|
|
12
17
|
accessKey: process.env.GANGTISE_ACCESS_KEY,
|
|
13
18
|
secretKey: process.env.GANGTISE_SECRET_KEY,
|
|
14
19
|
token: process.env.GANGTISE_TOKEN,
|
|
15
20
|
tokenCachePath: process.env.GANGTISE_TOKEN_CACHE_PATH ?? DEFAULT_TOKEN_CACHE_PATH,
|
|
16
21
|
};
|
|
17
22
|
}
|
|
23
|
+
export const MIN_TIMEOUT_MS = 1_000;
|
|
24
|
+
export const MAX_TIMEOUT_MS = 3_600_000;
|
|
25
|
+
export function resolveTimeoutEnv(raw) {
|
|
26
|
+
if (!raw || !/^\d+$/.test(raw.trim()))
|
|
27
|
+
return DEFAULT_TIMEOUT_MS;
|
|
28
|
+
const ms = Number(raw);
|
|
29
|
+
return ms < MIN_TIMEOUT_MS ? DEFAULT_TIMEOUT_MS : Math.min(MAX_TIMEOUT_MS, ms);
|
|
30
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import { dirname, extname } from "node:path";
|
|
3
3
|
import { DownloadError } from "./errors.js";
|
|
4
|
-
import { saveOutputIfNeeded, stagingPath } from "./output.js";
|
|
4
|
+
import { saveOutputIfNeeded, stagingPath, trackClaim } from "./output.js";
|
|
5
5
|
import { extractTitles, lookupTitleCache, readTitleCache, TITLE_LOOKUP_SIZE, writeTitleCache } from "./titleCache.js";
|
|
6
6
|
function sanitizeFilename(name) {
|
|
7
7
|
return name.replace(/[/\\:*?"<>|\u0000-\u001f]/g, "_");
|
|
@@ -15,6 +15,10 @@ function truncateFilename(name, maxBytes = 200) {
|
|
|
15
15
|
stem.pop();
|
|
16
16
|
return stem.join("") + ext;
|
|
17
17
|
}
|
|
18
|
+
function trackedClaim(p) {
|
|
19
|
+
trackClaim(p);
|
|
20
|
+
return p;
|
|
21
|
+
}
|
|
18
22
|
export async function uniquePath(p) {
|
|
19
23
|
const claim = async (f) => {
|
|
20
24
|
try {
|
|
@@ -30,13 +34,13 @@ export async function uniquePath(p) {
|
|
|
30
34
|
};
|
|
31
35
|
await fs.mkdir(dirname(p) || ".", { recursive: true });
|
|
32
36
|
if (await claim(p))
|
|
33
|
-
return p;
|
|
37
|
+
return trackedClaim(p);
|
|
34
38
|
const ext = extname(p);
|
|
35
39
|
const stem = p.slice(0, p.length - ext.length);
|
|
36
40
|
for (let i = 1; i <= 99; i++) {
|
|
37
41
|
const candidate = `${stem}-${i}${ext}`;
|
|
38
42
|
if (await claim(candidate))
|
|
39
|
-
return candidate;
|
|
43
|
+
return trackedClaim(candidate);
|
|
40
44
|
}
|
|
41
45
|
throw new DownloadError(`Refusing to overwrite: 100 files already share the name "${p}" — pass --output or clean up the directory`);
|
|
42
46
|
}
|
|
@@ -199,7 +203,7 @@ export async function saveDownloadResult(result, fallbackName, output) {
|
|
|
199
203
|
const autoName = (file.filename ? sanitizeFilename(file.filename) : undefined) ?? (safeFallback + extFromContentType(file.contentType));
|
|
200
204
|
const outputPath = output ?? await uniquePath(truncateFilename(autoName));
|
|
201
205
|
try {
|
|
202
|
-
await saveOutputIfNeeded(file.data, outputPath);
|
|
206
|
+
await saveOutputIfNeeded(file.data, outputPath, false);
|
|
203
207
|
}
|
|
204
208
|
catch (error) {
|
|
205
209
|
if (!output)
|
|
@@ -212,7 +216,7 @@ export async function saveDownloadResult(result, fallbackName, output) {
|
|
|
212
216
|
if (typeof file.text === "string") {
|
|
213
217
|
const outputPath = output ?? await uniquePath(truncateFilename(`${safeFallback}.txt`));
|
|
214
218
|
try {
|
|
215
|
-
await saveOutputIfNeeded(file.text, outputPath);
|
|
219
|
+
await saveOutputIfNeeded(file.text, outputPath, false);
|
|
216
220
|
}
|
|
217
221
|
catch (error) {
|
|
218
222
|
if (!output)
|