gangtise-openapi-cli 0.43.0 → 0.44.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 +93 -19
- package/dist/src/cli.js +29 -18
- package/dist/src/commands/ai.js +20 -29
- package/dist/src/commands/auth.js +5 -4
- package/dist/src/commands/bond.js +5 -4
- package/dist/src/commands/fund.js +100 -0
- package/dist/src/commands/fundamental.js +2 -0
- package/dist/src/commands/groups.js +14 -0
- package/dist/src/commands/indicator.js +5 -5
- package/dist/src/commands/insight.js +3 -4
- package/dist/src/commands/raw.js +4 -3
- package/dist/src/commands/shared.js +35 -6
- package/dist/src/commands/tool.js +2 -2
- package/dist/src/commands/vault.js +30 -30
- package/dist/src/core/args.js +23 -7
- package/dist/src/core/asyncContent.js +1 -1
- package/dist/src/core/auth.js +2 -10
- package/dist/src/core/client.js +96 -35
- package/dist/src/core/commandBodies.js +1 -27
- package/dist/src/core/config.js +9 -5
- package/dist/src/core/download.js +47 -10
- package/dist/src/core/endpoints.js +198 -3
- package/dist/src/core/errors.js +1 -1
- package/dist/src/core/opinionDetail.js +16 -6
- package/dist/src/core/output.js +103 -72
- package/dist/src/core/printer.js +10 -5
- package/dist/src/core/quoteSharding.js +28 -4
- package/dist/src/core/rowSink.js +24 -10
- package/dist/src/core/titleCache.js +2 -10
- package/dist/src/core/transport.js +25 -7
- package/dist/src/core/updateCheck.js +3 -9
- package/gangtise-openapi/SKILL.md +53 -17
- package/gangtise-openapi/references/commands/fund.md +192 -0
- package/gangtise-openapi/references/commands/fundamental.md +2 -2
- package/gangtise-openapi/references/commands/indicator.md +7 -7
- package/gangtise-openapi/references/commands/insight.md +3 -3
- package/gangtise-openapi/references/commands/quote.md +1 -1
- package/gangtise-openapi/references/commands/tool.md +1 -1
- package/gangtise-openapi/references/commands/vault.md +1 -1
- package/gangtise-openapi/references/errors.md +5 -5
- package/gangtise-openapi/references/response-schema.md +10 -0
- package/package.json +1 -1
package/dist/src/core/client.js
CHANGED
|
@@ -6,10 +6,10 @@ import { pipeline } from "node:stream/promises";
|
|
|
6
6
|
import { FormData, request } from "undici";
|
|
7
7
|
import { credentialFingerprint, isTokenCacheValid, normalizeToken, readTokenCache, requireAccessCredentials, writeTokenCache } from "./auth.js";
|
|
8
8
|
import { ApiError, attachEnvelopeTraceId, markStructural, ValidationError } from "./errors.js";
|
|
9
|
-
import { ENDPOINTS, resolveTimeoutMs } from "./endpoints.js";
|
|
9
|
+
import { COSTLY_FETCH_CREDITS, ENDPOINTS, resolveTimeoutMs } from "./endpoints.js";
|
|
10
10
|
import { getLookupData } from "./lookupData/index.js";
|
|
11
11
|
import { stagingPath } from "./output.js";
|
|
12
|
-
import { decodeResponseBody, getDispatcher, isVerbose, logTiming,
|
|
12
|
+
import { decodeResponseBody, getDispatcher, isVerbose, JSON_TOTAL_FACTOR, logTiming, markAuthReplay, PAGE_CONCURRENCY, parseRetryAfterMs, quoteBigIntFields, runInOrder, TRANSFER_TOTAL_FACTOR, withRetry } from "./transport.js";
|
|
13
13
|
import { attachRowSink } from "./rowSink.js";
|
|
14
14
|
import { markIncomplete } from "./exitStatus.js";
|
|
15
15
|
const AUTH_RETRY_CODES = new Set(["8000014", "8000015", "0000001008", "999002"]);
|
|
@@ -18,7 +18,6 @@ function hasExpectedShape(expects, payload) {
|
|
|
18
18
|
return Array.isArray(payload);
|
|
19
19
|
return Boolean(payload && typeof payload === "object" && Array.isArray(payload.list));
|
|
20
20
|
}
|
|
21
|
-
export const COSTLY_FETCH_CREDITS = 1000;
|
|
22
21
|
const PROBE_ABOVE_CREDITS = 50;
|
|
23
22
|
const OFFSET_REFUSAL_CODES = new Set(["140002", "100006"]);
|
|
24
23
|
function readEmptyListAsArray(page) {
|
|
@@ -31,12 +30,22 @@ function markTotalCapped(out, why, collected, keepBelow) {
|
|
|
31
30
|
out.partial = true;
|
|
32
31
|
out.totalCapped = true;
|
|
33
32
|
}
|
|
33
|
+
function stableStringify(value) {
|
|
34
|
+
if (Array.isArray(value))
|
|
35
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
36
|
+
if (value && typeof value === "object") {
|
|
37
|
+
const obj = value;
|
|
38
|
+
return `{${Object.keys(obj).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`).join(",")}}`;
|
|
39
|
+
}
|
|
40
|
+
return JSON.stringify(value);
|
|
41
|
+
}
|
|
34
42
|
export class GangtiseClient {
|
|
35
43
|
config;
|
|
36
44
|
rowSink;
|
|
37
45
|
allowCostlyFetch = false;
|
|
38
46
|
refreshPromise = null;
|
|
39
47
|
memoCache = null;
|
|
48
|
+
loginToken = null;
|
|
40
49
|
envTokenInvalidated = false;
|
|
41
50
|
rowSinkClaimed = false;
|
|
42
51
|
constructor(config, rowSink) {
|
|
@@ -89,6 +98,7 @@ export class GangtiseClient {
|
|
|
89
98
|
issuedFor: credentialFingerprint(credentials.accessKey, this.config.baseUrl),
|
|
90
99
|
};
|
|
91
100
|
this.memoCache = cache;
|
|
101
|
+
this.loginToken = accessToken;
|
|
92
102
|
try {
|
|
93
103
|
await writeTokenCache(this.config.tokenCachePath, cache);
|
|
94
104
|
}
|
|
@@ -99,23 +109,45 @@ export class GangtiseClient {
|
|
|
99
109
|
return accessToken;
|
|
100
110
|
}
|
|
101
111
|
async refreshAuthIfRecoverable(error, useAuth, authState, usedAuthorization) {
|
|
102
|
-
if (useAuth
|
|
103
|
-
&& !authState.retried
|
|
112
|
+
if (!(useAuth
|
|
104
113
|
&& error instanceof ApiError
|
|
105
114
|
&& error.code
|
|
106
115
|
&& AUTH_RETRY_CODES.has(error.code)
|
|
107
116
|
&& this.config.accessKey
|
|
108
|
-
&& this.config.secretKey)
|
|
109
|
-
|
|
110
|
-
|
|
117
|
+
&& this.config.secretKey))
|
|
118
|
+
return;
|
|
119
|
+
if (authState.usedLoginToken)
|
|
120
|
+
return;
|
|
121
|
+
this.envTokenInvalidated = true;
|
|
122
|
+
const replay = () => markAuthReplay(new ApiError(error.message, error.code, error.statusCode, error.details));
|
|
123
|
+
const tryMemory = () => {
|
|
111
124
|
const memoToken = this.memoCache && isTokenCacheValid(this.memoCache) ? normalizeToken(this.memoCache.accessToken) : null;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
125
|
+
if (memoToken === null || usedAuthorization === undefined || memoToken === usedAuthorization)
|
|
126
|
+
return;
|
|
127
|
+
if (memoToken === this.loginToken) {
|
|
128
|
+
authState.usedLoginToken = true;
|
|
129
|
+
throw replay();
|
|
130
|
+
}
|
|
131
|
+
if (!authState.usedUnprovenToken) {
|
|
132
|
+
authState.usedUnprovenToken = true;
|
|
133
|
+
throw replay();
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
tryMemory();
|
|
137
|
+
if (!authState.usedUnprovenToken) {
|
|
138
|
+
const expected = credentialFingerprint(this.config.accessKey, this.config.baseUrl);
|
|
139
|
+
const disk = await readTokenCache(this.config.tokenCachePath);
|
|
140
|
+
tryMemory();
|
|
141
|
+
if (disk && isTokenCacheValid(disk, undefined, expected) && normalizeToken(disk.accessToken) !== usedAuthorization) {
|
|
142
|
+
this.memoCache = disk;
|
|
143
|
+
authState.usedUnprovenToken = true;
|
|
144
|
+
throw replay();
|
|
116
145
|
}
|
|
117
|
-
throw markRetryable(new ApiError(error.message, error.code, error.statusCode, error.details));
|
|
118
146
|
}
|
|
147
|
+
this.memoCache = null;
|
|
148
|
+
await this.getAuthorizationHeader(true);
|
|
149
|
+
authState.usedLoginToken = true;
|
|
150
|
+
throw replay();
|
|
119
151
|
}
|
|
120
152
|
buildUrl(path) {
|
|
121
153
|
const base = this.config.baseUrl.endsWith("/") ? this.config.baseUrl : `${this.config.baseUrl}/`;
|
|
@@ -184,15 +216,18 @@ export class GangtiseClient {
|
|
|
184
216
|
}
|
|
185
217
|
const windowRoom = maxWindow === undefined ? Infinity : maxWindow - startFrom;
|
|
186
218
|
const billing = endpoint.billing;
|
|
187
|
-
const guarded =
|
|
219
|
+
const guarded = billing?.per === "row" && !this.allowCostlyFetch
|
|
220
|
+
&& (requestedSize === undefined || requestedSize * billing.price > COSTLY_FETCH_CREDITS);
|
|
188
221
|
const refuseIfCostly = (reportedTotal, fetched) => {
|
|
189
222
|
if (!guarded || !billing)
|
|
190
223
|
return;
|
|
191
|
-
const rows = Math.min(Math.max(reportedTotal - startFrom, 0), windowRoom);
|
|
224
|
+
const rows = Math.min(Math.max(reportedTotal - startFrom, 0), windowRoom, requestedSize ?? Infinity);
|
|
192
225
|
const estimatedCredits = Math.round(rows * billing.price * 100) / 100;
|
|
193
226
|
if (estimatedCredits <= COSTLY_FETCH_CREDITS)
|
|
194
227
|
return;
|
|
195
|
-
|
|
228
|
+
const what = requestedSize === undefined ? `all ${rows} rows` : `${rows} rows (--size ${requestedSize})`;
|
|
229
|
+
const escape = requestedSize === undefined ? "Pass --size N for a bounded subset" : "Pass a smaller --size";
|
|
230
|
+
throw new ValidationError(`fetching ${what} of ${endpoint.key} would cost about ${estimatedCredits} credits (${billing.price} per row), above the ${COSTLY_FETCH_CREDITS}-credit guard. Only ${fetched} row(s) were fetched, to learn the total. ${escape}, or --yes to fetch them all.`);
|
|
196
231
|
};
|
|
197
232
|
let probe;
|
|
198
233
|
let probed = false;
|
|
@@ -206,7 +241,7 @@ export class GangtiseClient {
|
|
|
206
241
|
else {
|
|
207
242
|
refuseIfCostly(probe.total, probe.list.length);
|
|
208
243
|
probeRows = probe.list.length;
|
|
209
|
-
probed = probe.list.length === Math.min(Math.max(probe.total - startFrom, 0), windowRoom);
|
|
244
|
+
probed = probe.list.length === Math.min(Math.max(probe.total - startFrom, 0), windowRoom, requestedSize ?? Infinity);
|
|
210
245
|
}
|
|
211
246
|
}
|
|
212
247
|
const firstPageSize = probed ? 1 : Math.min(requestedSize === undefined ? maxPageSize : Math.min(maxPageSize, requestedSize), windowRoom);
|
|
@@ -245,29 +280,33 @@ export class GangtiseClient {
|
|
|
245
280
|
const dropRepeats = (rows) => {
|
|
246
281
|
if (!seen || !rowId)
|
|
247
282
|
return rows;
|
|
248
|
-
const onThisPage = new
|
|
283
|
+
const onThisPage = new Map();
|
|
249
284
|
return rows.filter((row) => {
|
|
250
285
|
const id = row && typeof row === "object" ? row[rowId] : undefined;
|
|
251
286
|
if (id === undefined || id === null)
|
|
252
287
|
return true;
|
|
253
|
-
const digest = createHash("sha1").update(
|
|
288
|
+
const digest = createHash("sha1").update(stableStringify(row)).digest("base64");
|
|
254
289
|
const key = String(id);
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (
|
|
259
|
-
|
|
290
|
+
const pageVersions = onThisPage.get(key);
|
|
291
|
+
if (pageVersions && !pageVersions.has(digest))
|
|
292
|
+
idIsRowKey = false;
|
|
293
|
+
if (pageVersions)
|
|
294
|
+
pageVersions.add(digest);
|
|
295
|
+
else
|
|
296
|
+
onThisPage.set(key, new Set([digest]));
|
|
297
|
+
const versions = seen.get(key);
|
|
298
|
+
if (versions === undefined) {
|
|
299
|
+
seen.set(key, new Set([digest]));
|
|
260
300
|
return true;
|
|
261
301
|
}
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
else
|
|
266
|
-
changedRows++;
|
|
267
|
-
return true;
|
|
302
|
+
if (versions.has(digest)) {
|
|
303
|
+
duplicateRows++;
|
|
304
|
+
return false;
|
|
268
305
|
}
|
|
269
|
-
|
|
270
|
-
|
|
306
|
+
versions.add(digest);
|
|
307
|
+
if (!pageVersions)
|
|
308
|
+
changedRows++;
|
|
309
|
+
return true;
|
|
271
310
|
});
|
|
272
311
|
};
|
|
273
312
|
const keep = async (fetched) => {
|
|
@@ -433,7 +472,7 @@ export class GangtiseClient {
|
|
|
433
472
|
}
|
|
434
473
|
const dispatcher = getDispatcher();
|
|
435
474
|
const url = this.buildUrl(endpoint.path);
|
|
436
|
-
const authState = {
|
|
475
|
+
const authState = {};
|
|
437
476
|
const timeoutMs = resolveTimeoutMs(this.config.timeoutMs, endpoint);
|
|
438
477
|
return withRetry(async () => {
|
|
439
478
|
const isUpload = endpoint.kind === 'upload';
|
|
@@ -454,6 +493,7 @@ export class GangtiseClient {
|
|
|
454
493
|
body: endpoint.method === 'GET' ? undefined : (isUpload ? body : JSON.stringify(body ?? {})),
|
|
455
494
|
headersTimeout: timeoutMs,
|
|
456
495
|
bodyTimeout: timeoutMs,
|
|
496
|
+
signal: AbortSignal.timeout(timeoutMs * (isUpload ? TRANSFER_TOTAL_FACTOR : JSON_TOTAL_FACTOR)),
|
|
457
497
|
dispatcher,
|
|
458
498
|
});
|
|
459
499
|
const encoding = response.headers['content-encoding'];
|
|
@@ -510,7 +550,7 @@ export class GangtiseClient {
|
|
|
510
550
|
}).catch((error) => {
|
|
511
551
|
const isIndicatorFetch = endpoint.key === 'indicator.cross-section' || endpoint.key === 'indicator.time-series' || endpoint.key === 'indicator.screener';
|
|
512
552
|
if (isIndicatorFetch && error instanceof ApiError && error.code === '999999') {
|
|
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
|
|
553
|
+
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,都会指名(fiscalYear 例外:漏传不报错,返回 null 或默认年度的值)。');
|
|
514
554
|
}
|
|
515
555
|
throw error;
|
|
516
556
|
});
|
|
@@ -534,11 +574,12 @@ export class GangtiseClient {
|
|
|
534
574
|
Object.entries(query).forEach(([key, value]) => {
|
|
535
575
|
url.searchParams.set(key, String(value));
|
|
536
576
|
});
|
|
537
|
-
const authState = {
|
|
577
|
+
const authState = {};
|
|
538
578
|
const timeoutMs = resolveTimeoutMs(this.config.timeoutMs, endpoint);
|
|
539
579
|
return withRetry(async () => {
|
|
540
580
|
const authorization = await this.getAuthorizationHeader();
|
|
541
581
|
const startedAt = Date.now();
|
|
582
|
+
const signal = AbortSignal.timeout(timeoutMs * TRANSFER_TOTAL_FACTOR);
|
|
542
583
|
let currentUrl = url;
|
|
543
584
|
let auth = authorization;
|
|
544
585
|
const isPost = endpoint.method === 'POST';
|
|
@@ -550,6 +591,7 @@ export class GangtiseClient {
|
|
|
550
591
|
body: isPost ? JSON.stringify(body ?? {}) : undefined,
|
|
551
592
|
headersTimeout: timeoutMs,
|
|
552
593
|
bodyTimeout: timeoutMs,
|
|
594
|
+
signal,
|
|
553
595
|
dispatcher,
|
|
554
596
|
});
|
|
555
597
|
for (let hops = 0; hops < 3 && response.statusCode >= 300 && response.statusCode < 400; hops++) {
|
|
@@ -567,6 +609,7 @@ export class GangtiseClient {
|
|
|
567
609
|
headers: auth ? { Authorization: auth } : {},
|
|
568
610
|
headersTimeout: timeoutMs,
|
|
569
611
|
bodyTimeout: timeoutMs,
|
|
612
|
+
signal,
|
|
570
613
|
dispatcher,
|
|
571
614
|
});
|
|
572
615
|
}
|
|
@@ -662,6 +705,23 @@ export class GangtiseClient {
|
|
|
662
705
|
},
|
|
663
706
|
});
|
|
664
707
|
}
|
|
708
|
+
refuseIfEstimatedCostly(endpoint, body) {
|
|
709
|
+
const billing = endpoint.billing;
|
|
710
|
+
if (!endpoint.estimateBilledUnits || !billing || this.allowCostlyFetch)
|
|
711
|
+
return;
|
|
712
|
+
let units;
|
|
713
|
+
try {
|
|
714
|
+
units = endpoint.estimateBilledUnits(body && typeof body === "object" ? body : {});
|
|
715
|
+
}
|
|
716
|
+
catch (error) {
|
|
717
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
718
|
+
throw new ValidationError(`${endpoint.key} could not be priced before sending (${why}), and it is billed per ${billing.per} with no upper bound. Fix the request, or pass --yes to send it as it is.`);
|
|
719
|
+
}
|
|
720
|
+
const credits = Math.ceil(units * billing.price);
|
|
721
|
+
if (credits <= COSTLY_FETCH_CREDITS)
|
|
722
|
+
return;
|
|
723
|
+
throw new ValidationError(`${endpoint.key} would cost about ${credits} credits (${billing.price} per ${billing.per}, about ${units} estimated from the requested date range), above the ${COSTLY_FETCH_CREDITS}-credit guard. Narrow the date range, or pass --yes to send it.`);
|
|
724
|
+
}
|
|
665
725
|
async call(endpointKey, body, query, options) {
|
|
666
726
|
const endpoint = ENDPOINTS[endpointKey];
|
|
667
727
|
if (!endpoint) {
|
|
@@ -673,6 +733,7 @@ export class GangtiseClient {
|
|
|
673
733
|
if (endpoint.kind === 'download') {
|
|
674
734
|
return this.download(endpoint, query ?? {}, options, body);
|
|
675
735
|
}
|
|
736
|
+
this.refuseIfEstimatedCostly(endpoint, body);
|
|
676
737
|
if (endpoint.kind === 'json' && endpoint.pagination?.enabled) {
|
|
677
738
|
return this.requestPaginated(endpoint, body);
|
|
678
739
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { maybeArray,
|
|
1
|
+
import { maybeArray, parseIndicatorParams, parseOptionalNumberOption, parseScreenerIndicators } from "./args.js";
|
|
2
2
|
import { ValidationError } from "./errors.js";
|
|
3
3
|
import { QUOTE_MAX_LIMIT } from "./quoteSharding.js";
|
|
4
4
|
export function buildQuoteKlineBody(options) {
|
|
@@ -10,32 +10,6 @@ export function buildQuoteKlineBody(options) {
|
|
|
10
10
|
fieldList: maybeArray(options.field),
|
|
11
11
|
};
|
|
12
12
|
}
|
|
13
|
-
export function buildWechatMessageListBody(options) {
|
|
14
|
-
return {
|
|
15
|
-
from: parseFrom(options.from),
|
|
16
|
-
size: parseSize(options.size),
|
|
17
|
-
startTime: options.startTime,
|
|
18
|
-
endTime: options.endTime,
|
|
19
|
-
keyword: options.keyword,
|
|
20
|
-
securityList: maybeArray(options.security),
|
|
21
|
-
wechatGroupIdList: maybeArray(options.wechatGroupId),
|
|
22
|
-
industryIdList: maybeArray(options.industry),
|
|
23
|
-
categoryList: maybeArray(options.category),
|
|
24
|
-
tagList: maybeArray(options.tag),
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
export function buildWechatChatroomListBody(options) {
|
|
28
|
-
return {
|
|
29
|
-
from: parseFrom(options.from),
|
|
30
|
-
size: parseSize(options.size),
|
|
31
|
-
roomName: options.roomName.length > 0 ? options.roomName.join(",") : undefined,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
export function buildStockPoolStocksBody(options) {
|
|
35
|
-
return {
|
|
36
|
-
poolIdList: options.poolId?.length ? options.poolId : ["all"],
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
13
|
const DATE_PARAM_KEYS = new Set(["tradeDate", "reportDate"]);
|
|
40
14
|
function assertParamCodesBound(groups, codes) {
|
|
41
15
|
if (!groups?.length)
|
package/dist/src/core/config.js
CHANGED
|
@@ -12,14 +12,18 @@ export function loadConfig() {
|
|
|
12
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
13
|
}
|
|
14
14
|
return {
|
|
15
|
-
baseUrl:
|
|
15
|
+
baseUrl: envValue("GANGTISE_BASE_URL") ?? DEFAULT_BASE_URL,
|
|
16
16
|
timeoutMs,
|
|
17
|
-
accessKey:
|
|
18
|
-
secretKey:
|
|
19
|
-
token:
|
|
20
|
-
tokenCachePath:
|
|
17
|
+
accessKey: envValue("GANGTISE_ACCESS_KEY"),
|
|
18
|
+
secretKey: envValue("GANGTISE_SECRET_KEY"),
|
|
19
|
+
token: envValue("GANGTISE_TOKEN"),
|
|
20
|
+
tokenCachePath: envValue("GANGTISE_TOKEN_CACHE_PATH") ?? DEFAULT_TOKEN_CACHE_PATH,
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
|
+
function envValue(name) {
|
|
24
|
+
const value = process.env[name]?.trim();
|
|
25
|
+
return value ? value : undefined;
|
|
26
|
+
}
|
|
23
27
|
export const MIN_TIMEOUT_MS = 1_000;
|
|
24
28
|
export const MAX_TIMEOUT_MS = 3_600_000;
|
|
25
29
|
export function resolveTimeoutEnv(raw) {
|
|
@@ -84,10 +84,27 @@ export function extFromContentType(contentType) {
|
|
|
84
84
|
const mime = contentType.split(";")[0].trim().toLowerCase();
|
|
85
85
|
return MIME_EXT[mime] ?? "";
|
|
86
86
|
}
|
|
87
|
+
const KNOWN_EXT = new Set(Object.values(MIME_EXT).concat([".md", ".jpeg", ".htm"]));
|
|
88
|
+
export function extFromUrl(url) {
|
|
89
|
+
try {
|
|
90
|
+
const ext = extname(decodeURIComponent(new URL(url).pathname)).toLowerCase();
|
|
91
|
+
return KNOWN_EXT.has(ext) ? ext : "";
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return "";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function resultExt(file) {
|
|
98
|
+
if (file.filename)
|
|
99
|
+
return extname(file.filename);
|
|
100
|
+
if (typeof file.url === "string")
|
|
101
|
+
return extFromUrl(file.url);
|
|
102
|
+
return extFromContentType(file.contentType);
|
|
103
|
+
}
|
|
87
104
|
export async function resolveTitle(client, result, listEndpoint, idField, idValue, options = {}) {
|
|
88
105
|
const titleField = options.titleField ?? "title";
|
|
89
106
|
const file = result;
|
|
90
|
-
const serverExt =
|
|
107
|
+
const serverExt = resultExt(file);
|
|
91
108
|
function buildFilename(rawTitle) {
|
|
92
109
|
let title = sanitizeFilename(rawTitle).trim();
|
|
93
110
|
if (serverExt && !title.toLowerCase().endsWith(serverExt.toLowerCase())) {
|
|
@@ -130,7 +147,7 @@ async function downloadUrlTo(url, outputPath) {
|
|
|
130
147
|
const { pipeline } = await import("node:stream/promises");
|
|
131
148
|
const { dirname } = await import("node:path");
|
|
132
149
|
const { request } = await import("undici");
|
|
133
|
-
const { getDispatcher, logTiming, withRetry } = await import("./transport.js");
|
|
150
|
+
const { getDispatcher, logTiming, TRANSFER_TOTAL_FACTOR, withRetry } = await import("./transport.js");
|
|
134
151
|
const { loadConfig } = await import("./config.js");
|
|
135
152
|
const timeoutMs = loadConfig().timeoutMs;
|
|
136
153
|
await fs.mkdir(dirname(outputPath), { recursive: true });
|
|
@@ -150,7 +167,7 @@ async function downloadUrlTo(url, outputPath) {
|
|
|
150
167
|
method: "GET",
|
|
151
168
|
headersTimeout: timeoutMs,
|
|
152
169
|
bodyTimeout: timeoutMs,
|
|
153
|
-
signal: AbortSignal.timeout(timeoutMs *
|
|
170
|
+
signal: AbortSignal.timeout(timeoutMs * TRANSFER_TOTAL_FACTOR),
|
|
154
171
|
dispatcher: getDispatcher(),
|
|
155
172
|
};
|
|
156
173
|
let currentUrl = url;
|
|
@@ -189,6 +206,23 @@ async function downloadUrlTo(url, outputPath) {
|
|
|
189
206
|
throw error;
|
|
190
207
|
}
|
|
191
208
|
}
|
|
209
|
+
export function autoDownloadName(file, fallbackName) {
|
|
210
|
+
return truncateFilename(file.filename ? sanitizeFilename(file.filename) : sanitizeFilename(fallbackName) + extFromContentType(file.contentType));
|
|
211
|
+
}
|
|
212
|
+
export async function placeStreamedDownload(file, fallbackName, named) {
|
|
213
|
+
let target;
|
|
214
|
+
try {
|
|
215
|
+
target = await uniquePath(truncateFilename(named ?? autoDownloadName(file, fallbackName)));
|
|
216
|
+
await fs.rename(file.savedPath, target);
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
if (target)
|
|
220
|
+
await releaseClaim(target);
|
|
221
|
+
await fs.unlink(file.savedPath).catch(() => { });
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
return target;
|
|
225
|
+
}
|
|
192
226
|
export async function saveDownloadResult(result, fallbackName, output) {
|
|
193
227
|
if (!(result && typeof result === "object")) {
|
|
194
228
|
throw new DownloadError("Unexpected download response");
|
|
@@ -200,8 +234,7 @@ export async function saveDownloadResult(result, fallbackName, output) {
|
|
|
200
234
|
return;
|
|
201
235
|
}
|
|
202
236
|
if (file.data instanceof Uint8Array) {
|
|
203
|
-
const
|
|
204
|
-
const outputPath = output ?? await uniquePath(truncateFilename(autoName));
|
|
237
|
+
const outputPath = output ?? await uniquePath(autoDownloadName(file, fallbackName));
|
|
205
238
|
try {
|
|
206
239
|
await saveOutputIfNeeded(file.data, outputPath, false);
|
|
207
240
|
}
|
|
@@ -227,12 +260,16 @@ export async function saveDownloadResult(result, fallbackName, output) {
|
|
|
227
260
|
return;
|
|
228
261
|
}
|
|
229
262
|
if (typeof file.url === "string") {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
263
|
+
const outputPath = output ?? await uniquePath(truncateFilename(safeFallback + extFromUrl(file.url)));
|
|
264
|
+
try {
|
|
265
|
+
await downloadUrlTo(file.url, outputPath);
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
if (!output)
|
|
269
|
+
await releaseClaim(outputPath);
|
|
270
|
+
throw error;
|
|
234
271
|
}
|
|
235
|
-
process.stdout.write(`${
|
|
272
|
+
process.stdout.write(`${outputPath}\n`);
|
|
236
273
|
return;
|
|
237
274
|
}
|
|
238
275
|
throw new DownloadError("Unexpected download response");
|