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/client.js
CHANGED
|
@@ -7,8 +7,7 @@ import { isTokenCacheValid, normalizeToken, readTokenCache, requireAccessCredent
|
|
|
7
7
|
import { ApiError, ValidationError } from "./errors.js";
|
|
8
8
|
import { ENDPOINTS } from "./endpoints.js";
|
|
9
9
|
import { getLookupData } from "./lookupData/index.js";
|
|
10
|
-
import { getDispatcher, isVerbose, logTiming, markRetryable, runWithConcurrency, withRetry } from "./transport.js";
|
|
11
|
-
const PAGINATION_CONCURRENCY = Number(process.env.GANGTISE_PAGE_CONCURRENCY ?? 5) || 5;
|
|
10
|
+
import { getDispatcher, isVerbose, logTiming, markRetryable, PAGE_CONCURRENCY, runWithConcurrency, withRetry } from "./transport.js";
|
|
12
11
|
// Auth errors that warrant a forced re-login + one replay. 8000014/8000015 are
|
|
13
12
|
// AK/SK errors; 0000001008 is a server-side token invalidation (the token still
|
|
14
13
|
// looks valid by local expiry, so only a forced refresh recovers it).
|
|
@@ -48,11 +47,28 @@ export class GangtiseClient {
|
|
|
48
47
|
accessKey: credentials.accessKey,
|
|
49
48
|
secretKey: credentials.secretKey,
|
|
50
49
|
}, false);
|
|
50
|
+
// Validate the shape before touching it: a missing accessToken used to surface
|
|
51
|
+
// as a bare TypeError from normalizeToken, hiding the real cause.
|
|
52
|
+
if (typeof envelope?.accessToken !== "string" || !envelope.accessToken) {
|
|
53
|
+
throw new ApiError("Login succeeded but the response carried no accessToken", undefined, undefined, envelope);
|
|
54
|
+
}
|
|
51
55
|
const accessToken = normalizeToken(envelope.accessToken);
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
// A non-numeric expiresIn would make expiresAt NaN — the cache would never
|
|
57
|
+
// validate and every command would silently re-login. Degrade to 0 instead
|
|
58
|
+
// (token works now, next process logs in again).
|
|
59
|
+
const expiresIn = Number.isFinite(envelope.expiresIn) ? envelope.expiresIn : 0;
|
|
60
|
+
const expiresAt = Math.floor(Date.now() / 1000) + expiresIn;
|
|
61
|
+
const cache = { ...envelope, accessToken, expiresIn, expiresAt };
|
|
54
62
|
this.memoCache = cache;
|
|
55
|
-
|
|
63
|
+
try {
|
|
64
|
+
await writeTokenCache(this.config.tokenCachePath, cache);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
// A read-only HOME or full disk must not fail the request — the in-memory
|
|
68
|
+
// token is valid; we just can't persist it for the next process.
|
|
69
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
70
|
+
process.stderr.write(`[gangtise] warning: could not persist token cache: ${msg}\n`);
|
|
71
|
+
}
|
|
56
72
|
return accessToken;
|
|
57
73
|
}
|
|
58
74
|
/**
|
|
@@ -62,7 +78,7 @@ export class GangtiseClient {
|
|
|
62
78
|
* the caller re-throws the original error. `authState` persists across the
|
|
63
79
|
* withRetry attempts so we only refresh once per logical request.
|
|
64
80
|
*/
|
|
65
|
-
async refreshAuthIfRecoverable(error, useAuth, authState) {
|
|
81
|
+
async refreshAuthIfRecoverable(error, useAuth, authState, usedAuthorization) {
|
|
66
82
|
if (useAuth
|
|
67
83
|
&& !authState.retried
|
|
68
84
|
&& error instanceof ApiError
|
|
@@ -71,12 +87,30 @@ export class GangtiseClient {
|
|
|
71
87
|
&& this.config.accessKey
|
|
72
88
|
&& this.config.secretKey) {
|
|
73
89
|
authState.retried = true;
|
|
74
|
-
this.memoCache = null;
|
|
75
90
|
this.envTokenInvalidated = true;
|
|
76
|
-
|
|
91
|
+
// If the failed request was still carrying an OLDER token than the one now in
|
|
92
|
+
// memoCache, another request already refreshed — replay with the fresh token
|
|
93
|
+
// instead of logging in again (back-to-back logins can kick each other's
|
|
94
|
+
// sessions server-side, the 0000001008 semantics). If the failed request used
|
|
95
|
+
// the CURRENT token, that token is genuinely dead: force a new login. A time
|
|
96
|
+
// window is NOT a valid proxy here — right after the initial login the window
|
|
97
|
+
// is always "recent", which would skip the refresh exactly when it's needed.
|
|
98
|
+
const memoToken = this.memoCache && isTokenCacheValid(this.memoCache) ? normalizeToken(this.memoCache.accessToken) : null;
|
|
99
|
+
const alreadyRefreshed = memoToken !== null && usedAuthorization !== undefined && usedAuthorization !== memoToken;
|
|
100
|
+
if (!alreadyRefreshed) {
|
|
101
|
+
this.memoCache = null;
|
|
102
|
+
await this.getAuthorizationHeader(true);
|
|
103
|
+
}
|
|
77
104
|
throw markRetryable(new ApiError(error.message, error.code, error.statusCode, error.details));
|
|
78
105
|
}
|
|
79
106
|
}
|
|
107
|
+
/** `new URL("/a/b", "https://proxy/prefix")` drops "/prefix" — an absolute path
|
|
108
|
+
* replaces the base's path per the URL spec. Join manually so a reverse-proxy
|
|
109
|
+
* GANGTISE_BASE_URL with a path prefix keeps working. */
|
|
110
|
+
buildUrl(path) {
|
|
111
|
+
const base = this.config.baseUrl.endsWith("/") ? this.config.baseUrl : `${this.config.baseUrl}/`;
|
|
112
|
+
return new URL(path.replace(/^\//, ""), base);
|
|
113
|
+
}
|
|
80
114
|
isEnvelope(parsed) {
|
|
81
115
|
if (!parsed || typeof parsed !== 'object')
|
|
82
116
|
return false;
|
|
@@ -145,16 +179,25 @@ export class GangtiseClient {
|
|
|
145
179
|
return firstPage;
|
|
146
180
|
const total = firstPage.total;
|
|
147
181
|
const collected = [...firstPage.list];
|
|
148
|
-
|
|
182
|
+
const available = Math.max(total - startFrom, 0);
|
|
183
|
+
const target = requestedSize === undefined ? available : Math.min(requestedSize, available);
|
|
184
|
+
// Last page reached on first request. If `total` promises more rows than the
|
|
185
|
+
// short page delivered, the server's page cap may be lower than our configured
|
|
186
|
+
// maxPageSize — say so instead of silently returning a subset as "everything".
|
|
149
187
|
if (firstPage.list.length < firstPageSize) {
|
|
150
|
-
|
|
188
|
+
const out = {
|
|
151
189
|
...firstPage,
|
|
152
190
|
total,
|
|
153
191
|
list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
|
|
154
192
|
};
|
|
193
|
+
if (collected.length < target) {
|
|
194
|
+
process.stderr.write(`[gangtise] warning: server returned a short page (${collected.length} rows) but reported total=${total}; treating it as the end of data — results may be incomplete\n`);
|
|
195
|
+
// Machine-readable counterpart of the warning: scripts key off partial /
|
|
196
|
+
// exit code 3, and must not mistake a truncated result for a complete one.
|
|
197
|
+
out.partial = true;
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
155
200
|
}
|
|
156
|
-
const available = Math.max(total - startFrom, 0);
|
|
157
|
-
const target = requestedSize === undefined ? available : Math.min(requestedSize, available);
|
|
158
201
|
if (collected.length >= target) {
|
|
159
202
|
return {
|
|
160
203
|
...firstPage,
|
|
@@ -162,21 +205,24 @@ export class GangtiseClient {
|
|
|
162
205
|
list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
|
|
163
206
|
};
|
|
164
207
|
}
|
|
208
|
+
// Build remaining page requests. The cap lives inside the loop: a corrupt
|
|
209
|
+
// server `total` (e.g. 9e15) must not materialize millions of page objects —
|
|
210
|
+
// or spin for minutes — before a post-hoc truncation applies.
|
|
211
|
+
const MAX_PAGES = 1000;
|
|
212
|
+
let truncatedByPageCap = false;
|
|
165
213
|
const pageRequests = [];
|
|
166
214
|
let nextFrom = startFrom + firstPage.list.length;
|
|
167
215
|
const endFrom = startFrom + target;
|
|
168
216
|
while (nextFrom < endFrom) {
|
|
217
|
+
if (pageRequests.length + 1 >= MAX_PAGES) {
|
|
218
|
+
truncatedByPageCap = true;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
169
221
|
const remaining = endFrom - nextFrom;
|
|
170
222
|
const size = Math.min(maxPageSize, remaining);
|
|
171
223
|
pageRequests.push({ from: nextFrom, size });
|
|
172
224
|
nextFrom += size;
|
|
173
225
|
}
|
|
174
|
-
const MAX_PAGES = 1000;
|
|
175
|
-
let truncatedByPageCap = false;
|
|
176
|
-
if (pageRequests.length + 1 > MAX_PAGES) {
|
|
177
|
-
pageRequests.length = MAX_PAGES - 1;
|
|
178
|
-
truncatedByPageCap = true;
|
|
179
|
-
}
|
|
180
226
|
let unexpectedShape = false;
|
|
181
227
|
let totalDrift = false;
|
|
182
228
|
// Fail-soft fan-out: a hard page failure (rate-limit 903301, no-perm, retries
|
|
@@ -187,7 +233,7 @@ export class GangtiseClient {
|
|
|
187
233
|
const failedPages = [];
|
|
188
234
|
let firstError = null;
|
|
189
235
|
let aborted = false;
|
|
190
|
-
const pages = await runWithConcurrency(pageRequests,
|
|
236
|
+
const pages = await runWithConcurrency(pageRequests, PAGE_CONCURRENCY, async (req) => {
|
|
191
237
|
if (aborted) {
|
|
192
238
|
failedPages.push(req);
|
|
193
239
|
return [];
|
|
@@ -199,7 +245,10 @@ export class GangtiseClient {
|
|
|
199
245
|
size: req.size,
|
|
200
246
|
});
|
|
201
247
|
if (!this.isPaginatedListResponse(page)) {
|
|
248
|
+
// Treat a shape-broken page like a failed page: its rows are missing, so
|
|
249
|
+
// the result must carry the partial marker instead of looking complete.
|
|
202
250
|
unexpectedShape = true;
|
|
251
|
+
failedPages.push(req);
|
|
203
252
|
return [];
|
|
204
253
|
}
|
|
205
254
|
if (page.total !== total)
|
|
@@ -219,11 +268,11 @@ export class GangtiseClient {
|
|
|
219
268
|
continue;
|
|
220
269
|
collected.push(...list);
|
|
221
270
|
}
|
|
222
|
-
if (unexpectedShape
|
|
223
|
-
process.stderr.write(`[gangtise] warning: a page response had unexpected shape;
|
|
271
|
+
if (unexpectedShape) {
|
|
272
|
+
process.stderr.write(`[gangtise] warning: a page response had unexpected shape; its rows are missing (counted in failedPages)\n`);
|
|
224
273
|
}
|
|
225
|
-
if (totalDrift
|
|
226
|
-
process.stderr.write(`[gangtise] warning: 'total' changed across pages (data shifted during fetch)\n`);
|
|
274
|
+
if (totalDrift) {
|
|
275
|
+
process.stderr.write(`[gangtise] warning: 'total' changed across pages (data shifted during fetch); rows may be duplicated or missing\n`);
|
|
227
276
|
}
|
|
228
277
|
// Always surface a cap-induced truncation (not gated on verbose): the user
|
|
229
278
|
// asked for everything and is silently getting a subset, mirroring the
|
|
@@ -240,7 +289,8 @@ export class GangtiseClient {
|
|
|
240
289
|
out.partial = true;
|
|
241
290
|
out.failedPages = failedPages.map((p) => ({ from: p.from, size: p.size }));
|
|
242
291
|
const detail = firstError instanceof Error ? `: ${firstError.message}` : "";
|
|
243
|
-
|
|
292
|
+
const skippedHint = aborted ? " A page hit a non-retryable error (e.g. rate limit); remaining pages were skipped." : "";
|
|
293
|
+
process.stderr.write(`[gangtise] warning: ${failedPages.length}/${pageRequests.length} pages not fetched${detail}; results are partial — got ${collected.length}/${total} rows (see failedPages).${skippedHint}\n`);
|
|
244
294
|
}
|
|
245
295
|
return out;
|
|
246
296
|
}
|
|
@@ -274,6 +324,7 @@ export class GangtiseClient {
|
|
|
274
324
|
let from = startFrom;
|
|
275
325
|
const MAX_PAGES = 1000;
|
|
276
326
|
let truncatedByPageCap = false;
|
|
327
|
+
let partialShape = false;
|
|
277
328
|
for (let page = 0;; page++) {
|
|
278
329
|
const remaining = requestedSize === undefined ? maxPageSize : requestedSize - collected.length;
|
|
279
330
|
if (requestedSize !== undefined && remaining <= 0)
|
|
@@ -289,6 +340,7 @@ export class GangtiseClient {
|
|
|
289
340
|
// requestPaginated's fail-soft fan-out): stop, keep them, and warn loudly.
|
|
290
341
|
if (page === 0)
|
|
291
342
|
return firstPage;
|
|
343
|
+
partialShape = true;
|
|
292
344
|
process.stderr.write(`[gangtise] warning: a page response had unexpected shape; results are partial — ${collected.length} rows fetched.\n`);
|
|
293
345
|
break;
|
|
294
346
|
}
|
|
@@ -306,7 +358,10 @@ export class GangtiseClient {
|
|
|
306
358
|
process.stderr.write(`[gangtise] warning: hit the ${MAX_PAGES}-page safety cap; fetched ${collected.length} rows. Pass --size to fetch a bounded subset.\n`);
|
|
307
359
|
}
|
|
308
360
|
const rows = requestedSize === undefined ? collected : collected.slice(0, requestedSize);
|
|
309
|
-
|
|
361
|
+
const out = { [listKey]: rows };
|
|
362
|
+
if (partialShape)
|
|
363
|
+
out.partial = true;
|
|
364
|
+
return out;
|
|
310
365
|
}
|
|
311
366
|
async login() {
|
|
312
367
|
const authorization = await this.getAuthorizationHeader();
|
|
@@ -321,14 +376,18 @@ export class GangtiseClient {
|
|
|
321
376
|
return this.readLocalLookup(endpoint);
|
|
322
377
|
}
|
|
323
378
|
const dispatcher = getDispatcher();
|
|
324
|
-
const url =
|
|
379
|
+
const url = this.buildUrl(endpoint.path);
|
|
325
380
|
const authState = { retried: false };
|
|
326
381
|
return withRetry(async () => {
|
|
327
382
|
const headers = {
|
|
328
383
|
'content-type': 'application/json',
|
|
329
384
|
};
|
|
385
|
+
// Keep the header we actually sent: the self-heal check compares it against
|
|
386
|
+
// the current memoCache token to tell "stale token" from "fresh token died".
|
|
387
|
+
let usedAuthorization;
|
|
330
388
|
if (useAuth) {
|
|
331
|
-
|
|
389
|
+
usedAuthorization = await this.getAuthorizationHeader();
|
|
390
|
+
headers.Authorization = usedAuthorization;
|
|
332
391
|
}
|
|
333
392
|
const startedAt = Date.now();
|
|
334
393
|
const response = await request(url, {
|
|
@@ -351,14 +410,16 @@ export class GangtiseClient {
|
|
|
351
410
|
: 'Failed to parse API response';
|
|
352
411
|
throw new ApiError(message, undefined, response.statusCode, text.slice(0, 500));
|
|
353
412
|
}
|
|
354
|
-
if (response.statusCode >= 400) {
|
|
355
|
-
this.throwHttpError(parsed, response.statusCode);
|
|
356
|
-
}
|
|
357
413
|
try {
|
|
414
|
+
// Auth errors can arrive as HTTP 4xx or as a 200-wrapped error envelope;
|
|
415
|
+
// both routes must reach the self-heal check below.
|
|
416
|
+
if (response.statusCode >= 400) {
|
|
417
|
+
this.throwHttpError(parsed, response.statusCode);
|
|
418
|
+
}
|
|
358
419
|
return this.unwrapEnvelope(parsed, response.statusCode);
|
|
359
420
|
}
|
|
360
421
|
catch (error) {
|
|
361
|
-
await this.refreshAuthIfRecoverable(error, useAuth, authState);
|
|
422
|
+
await this.refreshAuthIfRecoverable(error, useAuth, authState, usedAuthorization);
|
|
362
423
|
throw error;
|
|
363
424
|
}
|
|
364
425
|
}, {
|
|
@@ -372,7 +433,7 @@ export class GangtiseClient {
|
|
|
372
433
|
}
|
|
373
434
|
async download(endpoint, query, options) {
|
|
374
435
|
const dispatcher = getDispatcher();
|
|
375
|
-
const url =
|
|
436
|
+
const url = this.buildUrl(endpoint.path);
|
|
376
437
|
Object.entries(query).forEach(([key, value]) => {
|
|
377
438
|
url.searchParams.set(key, String(value));
|
|
378
439
|
});
|
|
@@ -380,13 +441,37 @@ export class GangtiseClient {
|
|
|
380
441
|
return withRetry(async () => {
|
|
381
442
|
const authorization = await this.getAuthorizationHeader();
|
|
382
443
|
const startedAt = Date.now();
|
|
383
|
-
|
|
444
|
+
let currentUrl = url;
|
|
445
|
+
let auth = authorization;
|
|
446
|
+
let response = await request(currentUrl, {
|
|
384
447
|
method: endpoint.method,
|
|
385
448
|
headers: { Authorization: authorization },
|
|
386
449
|
headersTimeout: this.config.timeoutMs,
|
|
387
450
|
bodyTimeout: this.config.timeoutMs,
|
|
388
451
|
dispatcher,
|
|
389
452
|
});
|
|
453
|
+
// undici does not follow redirects, and a download endpoint may 302 to a
|
|
454
|
+
// pre-signed object-store URL — without this the redirect body would be
|
|
455
|
+
// saved as the "file". Follow up to 3 hops, dropping Authorization once the
|
|
456
|
+
// redirect leaves the API origin so the bearer never reaches storage hosts.
|
|
457
|
+
for (let hops = 0; hops < 3 && response.statusCode >= 300 && response.statusCode < 400; hops++) {
|
|
458
|
+
const locationHeader = response.headers.location;
|
|
459
|
+
const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader;
|
|
460
|
+
if (!location)
|
|
461
|
+
break;
|
|
462
|
+
await response.body.text().catch(() => { });
|
|
463
|
+
const next = new URL(location, currentUrl);
|
|
464
|
+
if (next.origin !== currentUrl.origin)
|
|
465
|
+
auth = undefined;
|
|
466
|
+
currentUrl = next;
|
|
467
|
+
response = await request(currentUrl, {
|
|
468
|
+
method: 'GET',
|
|
469
|
+
headers: auth ? { Authorization: auth } : {},
|
|
470
|
+
headersTimeout: this.config.timeoutMs,
|
|
471
|
+
bodyTimeout: this.config.timeoutMs,
|
|
472
|
+
dispatcher,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
390
475
|
const contentType = Array.isArray(response.headers['content-type']) ? response.headers['content-type'][0] : response.headers['content-type'];
|
|
391
476
|
if (contentType?.includes('application/json')) {
|
|
392
477
|
const text = await response.body.text();
|
|
@@ -401,15 +486,15 @@ export class GangtiseClient {
|
|
|
401
486
|
}
|
|
402
487
|
return { text, contentType };
|
|
403
488
|
}
|
|
404
|
-
if (response.statusCode >= 400) {
|
|
405
|
-
this.throwHttpError(parsed, response.statusCode);
|
|
406
|
-
}
|
|
407
489
|
let data;
|
|
408
490
|
try {
|
|
491
|
+
if (response.statusCode >= 400) {
|
|
492
|
+
this.throwHttpError(parsed, response.statusCode);
|
|
493
|
+
}
|
|
409
494
|
data = this.unwrapEnvelope(parsed, response.statusCode);
|
|
410
495
|
}
|
|
411
496
|
catch (error) {
|
|
412
|
-
await this.refreshAuthIfRecoverable(error, true, authState);
|
|
497
|
+
await this.refreshAuthIfRecoverable(error, true, authState, authorization);
|
|
413
498
|
throw error;
|
|
414
499
|
}
|
|
415
500
|
if (data && typeof data === 'object' && 'url' in data && typeof data.url === 'string') {
|
|
@@ -433,7 +518,19 @@ export class GangtiseClient {
|
|
|
433
518
|
const filenameMatch = Array.isArray(contentDisposition)
|
|
434
519
|
? contentDisposition[0]?.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i)
|
|
435
520
|
: contentDisposition?.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i);
|
|
436
|
-
|
|
521
|
+
// A plain filename= value with a bare % ("增长100%.pdf") is not valid URI
|
|
522
|
+
// encoding — decodeURIComponent would throw and fail the whole download over
|
|
523
|
+
// a cosmetic hint. Fall back to the raw value instead.
|
|
524
|
+
let filename;
|
|
525
|
+
if (filenameMatch) {
|
|
526
|
+
const raw = filenameMatch[1] || filenameMatch[2];
|
|
527
|
+
try {
|
|
528
|
+
filename = decodeURIComponent(raw);
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
filename = raw;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
437
534
|
// Stream directly to disk when caller already knows the destination
|
|
438
535
|
if (options?.streamTo) {
|
|
439
536
|
await fs.mkdir(path.dirname(options.streamTo), { recursive: true });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
1
2
|
import { extname } from "node:path";
|
|
2
3
|
import { DownloadError } from "./errors.js";
|
|
3
4
|
import { saveOutputIfNeeded } from "./output.js";
|
|
@@ -9,6 +10,36 @@ import { lookupTitleCache, readTitleCache, TITLE_LOOKUP_SIZE } from "./titleCach
|
|
|
9
10
|
function sanitizeFilename(name) {
|
|
10
11
|
return name.replace(/[/\\:*?"<>|\u0000-\u001f]/g, "_");
|
|
11
12
|
}
|
|
13
|
+
/** Keep auto-derived filenames under 200 UTF-8 bytes, preserving the extension.
|
|
14
|
+
* ext4 caps directory entries at 255 bytes — long Chinese titles hit that at
|
|
15
|
+
* ~85 chars (3 bytes each) and fs.writeFile throws ENAMETOOLONG after the body
|
|
16
|
+
* has already been downloaded. */
|
|
17
|
+
function truncateFilename(name, maxBytes = 200) {
|
|
18
|
+
if (Buffer.byteLength(name, "utf8") <= maxBytes)
|
|
19
|
+
return name;
|
|
20
|
+
const ext = extname(name);
|
|
21
|
+
let stem = name.slice(0, name.length - ext.length);
|
|
22
|
+
while (stem.length > 1 && Buffer.byteLength(stem + ext, "utf8") > maxBytes)
|
|
23
|
+
stem = stem.slice(0, -1);
|
|
24
|
+
return stem + ext;
|
|
25
|
+
}
|
|
26
|
+
/** Pick a non-existing path by suffixing -1, -2, … before the extension, so batch
|
|
27
|
+
* downloads whose titles collide ("2025年第一季度报告" from several companies) don't
|
|
28
|
+
* silently overwrite each other. Only auto-derived names go through this — an
|
|
29
|
+
* explicit --output path keeps plain overwrite semantics. */
|
|
30
|
+
export async function uniquePath(p) {
|
|
31
|
+
const exists = (f) => fs.access(f).then(() => true, () => false);
|
|
32
|
+
if (!(await exists(p)))
|
|
33
|
+
return p;
|
|
34
|
+
const ext = extname(p);
|
|
35
|
+
const stem = p.slice(0, p.length - ext.length);
|
|
36
|
+
for (let i = 1; i <= 99; i++) {
|
|
37
|
+
const candidate = `${stem}-${i}${ext}`;
|
|
38
|
+
if (!(await exists(candidate)))
|
|
39
|
+
return candidate;
|
|
40
|
+
}
|
|
41
|
+
return p;
|
|
42
|
+
}
|
|
12
43
|
const MIME_EXT = {
|
|
13
44
|
"application/pdf": ".pdf",
|
|
14
45
|
"application/msword": ".doc",
|
|
@@ -49,7 +80,7 @@ export async function resolveTitle(client, result, listEndpoint, idField, idValu
|
|
|
49
80
|
if (serverExt && !title.toLowerCase().endsWith(serverExt.toLowerCase())) {
|
|
50
81
|
title += serverExt;
|
|
51
82
|
}
|
|
52
|
-
return title;
|
|
83
|
+
return truncateFilename(title);
|
|
53
84
|
}
|
|
54
85
|
try {
|
|
55
86
|
const cacheData = await readTitleCache();
|
|
@@ -73,11 +104,32 @@ export async function resolveTitle(client, result, listEndpoint, idField, idValu
|
|
|
73
104
|
}
|
|
74
105
|
return undefined;
|
|
75
106
|
}
|
|
107
|
+
async function downloadUrlTo(url, outputPath) {
|
|
108
|
+
const { createWriteStream } = await import("node:fs");
|
|
109
|
+
const { Readable } = await import("node:stream");
|
|
110
|
+
const { pipeline } = await import("node:stream/promises");
|
|
111
|
+
const { dirname } = await import("node:path");
|
|
112
|
+
const response = await fetch(url);
|
|
113
|
+
if (!response.ok || !response.body) {
|
|
114
|
+
throw new DownloadError(`Failed to fetch download URL (HTTP ${response.status})`);
|
|
115
|
+
}
|
|
116
|
+
await fs.mkdir(dirname(outputPath), { recursive: true });
|
|
117
|
+
try {
|
|
118
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(outputPath));
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
await fs.unlink(outputPath).catch(() => { });
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
76
125
|
export async function saveDownloadResult(result, fallbackName, output) {
|
|
77
126
|
if (!(result && typeof result === "object")) {
|
|
78
127
|
throw new DownloadError("Unexpected download response");
|
|
79
128
|
}
|
|
80
129
|
const file = result;
|
|
130
|
+
// The fallback prefix embeds a user-supplied id (e.g. --report-id): sanitize it
|
|
131
|
+
// like server-provided names so an auto filename never interprets input as a path.
|
|
132
|
+
const safeFallback = sanitizeFilename(fallbackName);
|
|
81
133
|
if (typeof file.savedPath === "string") {
|
|
82
134
|
process.stdout.write(`${file.savedPath}\n`);
|
|
83
135
|
return;
|
|
@@ -85,20 +137,24 @@ export async function saveDownloadResult(result, fallbackName, output) {
|
|
|
85
137
|
if (file.data instanceof Uint8Array) {
|
|
86
138
|
// Sanitize the server-provided filename so a Content-Disposition value with
|
|
87
139
|
// / or : can't write outside the intended path (same rule as buildFilename).
|
|
88
|
-
const
|
|
140
|
+
const autoName = (file.filename ? sanitizeFilename(file.filename) : undefined) ?? (safeFallback + extFromContentType(file.contentType));
|
|
141
|
+
const outputPath = output ?? await uniquePath(truncateFilename(autoName));
|
|
89
142
|
await saveOutputIfNeeded(file.data, outputPath);
|
|
90
143
|
process.stdout.write(`${outputPath}\n`);
|
|
91
144
|
return;
|
|
92
145
|
}
|
|
93
146
|
if (typeof file.text === "string") {
|
|
94
|
-
const outputPath = output ?? `${
|
|
147
|
+
const outputPath = output ?? await uniquePath(truncateFilename(`${safeFallback}.txt`));
|
|
95
148
|
await saveOutputIfNeeded(file.text, outputPath);
|
|
96
149
|
process.stdout.write(`${outputPath}\n`);
|
|
97
150
|
return;
|
|
98
151
|
}
|
|
99
152
|
if (typeof file.url === "string") {
|
|
100
153
|
if (output) {
|
|
101
|
-
|
|
154
|
+
// The server handed us a (typically signed, short-lived) URL instead of the
|
|
155
|
+
// bytes. The user asked for a file — follow the URL and stream the content
|
|
156
|
+
// to disk instead of writing the URL string into a fake .pdf.
|
|
157
|
+
await downloadUrlTo(file.url, output);
|
|
102
158
|
process.stdout.write(`${output}\n`);
|
|
103
159
|
return;
|
|
104
160
|
}
|