sfiledl 2.1.1 → 2.2.1
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/build/lib.cjs +680 -116
- package/build/lib.cjs.map +1 -1
- package/build/lib.d.ts +113 -13
- package/build/lib.mjs +662 -116
- package/build/lib.mjs.map +1 -1
- package/changelog +108 -0
- package/package.json +10 -8
- package/readme.md +161 -54
package/build/lib.mjs
CHANGED
|
@@ -1,62 +1,96 @@
|
|
|
1
|
-
import path from 'path';
|
|
2
|
-
import
|
|
3
|
-
import
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import path__default from 'path';
|
|
3
|
+
import { createWriteStream, promises } from 'fs';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
4
5
|
import { chromium } from 'playwright';
|
|
6
|
+
import * as fs from 'fs/promises';
|
|
5
7
|
import os from 'os';
|
|
6
8
|
|
|
7
9
|
class AppError extends Error {
|
|
8
|
-
code;
|
|
9
|
-
retryable;
|
|
10
10
|
timestamp;
|
|
11
11
|
context;
|
|
12
|
+
code;
|
|
13
|
+
retryable;
|
|
12
14
|
constructor(message, code, retryable = false, context) {
|
|
13
15
|
super(message);
|
|
16
|
+
this.name = this.constructor.name;
|
|
14
17
|
this.code = code;
|
|
15
18
|
this.retryable = retryable;
|
|
16
|
-
this.name = this.constructor.name;
|
|
17
19
|
this.timestamp = new Date().toISOString();
|
|
18
|
-
this.context = context ? { ...context } :
|
|
19
|
-
Error.captureStackTrace
|
|
20
|
+
this.context = context ? Object.freeze({ ...context }) : Object.freeze({});
|
|
21
|
+
if (Error.captureStackTrace) {
|
|
22
|
+
Error.captureStackTrace(this, this.constructor);
|
|
23
|
+
}
|
|
24
|
+
Object.freeze(this);
|
|
20
25
|
}
|
|
21
26
|
toJSON() {
|
|
22
27
|
return {
|
|
23
28
|
name: this.name,
|
|
24
|
-
message: this.message,
|
|
25
29
|
code: this.code,
|
|
30
|
+
message: this.message,
|
|
26
31
|
retryable: this.retryable,
|
|
27
32
|
timestamp: this.timestamp,
|
|
28
33
|
context: this.context,
|
|
34
|
+
stack: process.env['NODE_ENV'] === 'development' ? this.stack : undefined,
|
|
29
35
|
};
|
|
30
36
|
}
|
|
37
|
+
toString() {
|
|
38
|
+
return `${this.name} [${this.code}]: ${this.message}`;
|
|
39
|
+
}
|
|
40
|
+
isCode(code) {
|
|
41
|
+
return this.code === code;
|
|
42
|
+
}
|
|
43
|
+
isRetryable() {
|
|
44
|
+
return this.retryable;
|
|
45
|
+
}
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
class ValidationError extends AppError {
|
|
34
49
|
constructor(message, context) {
|
|
35
50
|
super(message, 'VALIDATION_ERROR', false, context);
|
|
51
|
+
Object.freeze(this);
|
|
36
52
|
}
|
|
37
53
|
}
|
|
38
54
|
class NetworkError extends AppError {
|
|
39
55
|
constructor(message, context) {
|
|
40
56
|
super(message, 'NETWORK_ERROR', true, context);
|
|
57
|
+
Object.freeze(this);
|
|
41
58
|
}
|
|
42
59
|
}
|
|
43
60
|
class FileError extends AppError {
|
|
44
61
|
constructor(message, context) {
|
|
45
62
|
super(message, 'FILE_ERROR', false, context);
|
|
63
|
+
Object.freeze(this);
|
|
46
64
|
}
|
|
47
65
|
}
|
|
48
66
|
class BrowserError extends AppError {
|
|
49
67
|
constructor(message, context) {
|
|
50
68
|
super(message, 'BROWSER_ERROR', true, context);
|
|
69
|
+
Object.freeze(this);
|
|
51
70
|
}
|
|
52
71
|
}
|
|
72
|
+
function isAppError(err) {
|
|
73
|
+
return err instanceof AppError;
|
|
74
|
+
}
|
|
75
|
+
function isRetryableError(err) {
|
|
76
|
+
return isAppError(err) && err.retryable;
|
|
77
|
+
}
|
|
78
|
+
function isErrorWithCode(err, code) {
|
|
79
|
+
return isAppError(err) && err.code === code;
|
|
80
|
+
}
|
|
53
81
|
|
|
54
82
|
const DEFAULTS = {
|
|
55
83
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
|
56
84
|
headless: true,
|
|
57
|
-
timeout:
|
|
58
|
-
downloadButtonTimeout:
|
|
59
|
-
fallbackWaitMs:
|
|
85
|
+
timeout: 60000,
|
|
86
|
+
downloadButtonTimeout: 30000,
|
|
87
|
+
fallbackWaitMs: 3000,
|
|
88
|
+
retries: 3,
|
|
89
|
+
retryDelay: 1000,
|
|
90
|
+
maxRetryDelay: 30000,
|
|
91
|
+
saveDebugArtifacts: true,
|
|
92
|
+
maxFilenameLength: 255,
|
|
93
|
+
sanitizeReplacement: '_',
|
|
60
94
|
};
|
|
61
95
|
|
|
62
96
|
class SfilePageInteractions {
|
|
@@ -67,46 +101,110 @@ class SfilePageInteractions {
|
|
|
67
101
|
this.logger = logger;
|
|
68
102
|
}
|
|
69
103
|
async waitForDownloadButton(timeout) {
|
|
70
|
-
this.logger.debug('Waiting for #download button
|
|
104
|
+
this.logger.debug('Waiting for #download button', { timeout });
|
|
71
105
|
const button = this.page.locator('#download');
|
|
72
106
|
await button.waitFor({ state: 'visible', timeout });
|
|
73
|
-
this.logger.debug('
|
|
107
|
+
this.logger.debug('Button is visible, checking if active');
|
|
74
108
|
await this.page.waitForFunction(() => {
|
|
75
109
|
const btn = document.querySelector('#download');
|
|
76
110
|
if (!btn)
|
|
77
111
|
return false;
|
|
78
112
|
const href = btn.getAttribute('href');
|
|
79
113
|
const style = window.getComputedStyle(btn);
|
|
80
|
-
|
|
114
|
+
const isDisabled = btn.hasAttribute('disabled') ||
|
|
115
|
+
btn.getAttribute('aria-disabled') === 'true' ||
|
|
116
|
+
btn.classList.contains('disabled');
|
|
117
|
+
return !!(href &&
|
|
118
|
+
href !== '#' &&
|
|
119
|
+
href.trim() !== '' &&
|
|
120
|
+
style.pointerEvents !== 'none' &&
|
|
121
|
+
!isDisabled);
|
|
81
122
|
}, { timeout });
|
|
123
|
+
this.logger.debug('Download button is active and ready');
|
|
82
124
|
}
|
|
83
125
|
async extractIntermediateUrl() {
|
|
84
|
-
this.logger.debug('Extracting href from #download');
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
126
|
+
this.logger.debug('Extracting href from #download button');
|
|
127
|
+
try {
|
|
128
|
+
const href = await this.page.$eval('#download', (el) => {
|
|
129
|
+
const anchor = el;
|
|
130
|
+
return anchor.href;
|
|
131
|
+
});
|
|
132
|
+
if (!href || href === '#' || href.trim() === '') {
|
|
133
|
+
throw new NetworkError('Download button href is empty or invalid', { href });
|
|
134
|
+
}
|
|
135
|
+
return href;
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
if (err instanceof Error && err.message?.includes('Element not found')) {
|
|
139
|
+
throw new NetworkError('Download button (#download) not found in page', {
|
|
140
|
+
selector: '#download',
|
|
141
|
+
originalError: err.message,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
throw err;
|
|
88
145
|
}
|
|
89
|
-
return href;
|
|
90
146
|
}
|
|
91
147
|
}
|
|
92
148
|
|
|
93
149
|
function sanitizeFilename(name, replacement = '_') {
|
|
94
|
-
const
|
|
150
|
+
const safeName = name.replace(/\.\.[\\/]/g, '');
|
|
151
|
+
const sanitized = safeName
|
|
95
152
|
.replace(/[<>:"/\\|?*\x00-\x1F]/g, replacement)
|
|
96
153
|
.replace(new RegExp(`${replacement}+`, 'g'), replacement)
|
|
97
|
-
.trim()
|
|
98
|
-
|
|
99
|
-
|
|
154
|
+
.trim();
|
|
155
|
+
const maxLength = 255;
|
|
156
|
+
const trimmed = sanitized.slice(0, maxLength);
|
|
157
|
+
return trimmed || `file${replacement}bin`;
|
|
100
158
|
}
|
|
101
159
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
102
|
-
function safeStringify(value) {
|
|
160
|
+
function safeStringify(value, indent = 0) {
|
|
103
161
|
try {
|
|
104
|
-
|
|
162
|
+
const seen = new WeakSet();
|
|
163
|
+
return JSON.stringify(value, (_key, val) => {
|
|
164
|
+
if (typeof val === 'object' && val !== null) {
|
|
165
|
+
if (seen.has(val))
|
|
166
|
+
return '[Circular]';
|
|
167
|
+
seen.add(val);
|
|
168
|
+
}
|
|
169
|
+
if (typeof val === 'bigint')
|
|
170
|
+
return val.toString() + 'n';
|
|
171
|
+
if (val instanceof Error) {
|
|
172
|
+
return { name: val.name, message: val.message, stack: val.stack };
|
|
173
|
+
}
|
|
174
|
+
return val;
|
|
175
|
+
}, indent);
|
|
105
176
|
}
|
|
106
177
|
catch {
|
|
107
178
|
return '[unserializable]';
|
|
108
179
|
}
|
|
109
180
|
}
|
|
181
|
+
function extractFilenameFromContentDisposition(header) {
|
|
182
|
+
if (!header)
|
|
183
|
+
return null;
|
|
184
|
+
const encodedMatch = header.match(/filename\*=UTF-8''([^;\n"]+)/i);
|
|
185
|
+
if (encodedMatch?.[1]) {
|
|
186
|
+
try {
|
|
187
|
+
return decodeURIComponent(encodedMatch[1]);
|
|
188
|
+
}
|
|
189
|
+
catch { }
|
|
190
|
+
}
|
|
191
|
+
const quotedMatch = header.match(/filename\s*=\s*"([^"]+)"/i);
|
|
192
|
+
if (quotedMatch?.[1])
|
|
193
|
+
return quotedMatch[1];
|
|
194
|
+
const plainMatch = header.match(/filename\s*=\s*([^;,\n"]+)/i);
|
|
195
|
+
if (plainMatch?.[1])
|
|
196
|
+
return plainMatch[1].trim();
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
function calculateRetryDelay(attempt, baseDelayMs, maxDelayMs = 30000) {
|
|
200
|
+
const exponential = baseDelayMs * Math.pow(2, attempt - 1);
|
|
201
|
+
const jitter = Math.random() * 0.3 * exponential;
|
|
202
|
+
return Math.min(exponential + jitter, maxDelayMs);
|
|
203
|
+
}
|
|
204
|
+
function isError(value) {
|
|
205
|
+
return (value instanceof Error ||
|
|
206
|
+
(typeof value === 'object' && value !== null && 'message' in value));
|
|
207
|
+
}
|
|
110
208
|
|
|
111
209
|
class BrowserManager {
|
|
112
210
|
logger;
|
|
@@ -116,21 +214,36 @@ class BrowserManager {
|
|
|
116
214
|
page = null;
|
|
117
215
|
interactions = null;
|
|
118
216
|
debugDir = null;
|
|
217
|
+
stageHistory = [];
|
|
119
218
|
constructor(logger, opts) {
|
|
120
219
|
this.logger = logger;
|
|
121
220
|
this.opts = opts;
|
|
122
221
|
}
|
|
222
|
+
trackStage(stage) {
|
|
223
|
+
this.stageHistory.push({ stage, ts: Date.now() });
|
|
224
|
+
}
|
|
123
225
|
async launch() {
|
|
226
|
+
const stage = 'launch';
|
|
227
|
+
this.trackStage(stage);
|
|
124
228
|
try {
|
|
125
229
|
this.logger.info('Launching browser', { headless: this.opts.headless });
|
|
126
230
|
this.browser = await chromium.launch({
|
|
127
231
|
headless: this.opts.headless,
|
|
128
|
-
args: [
|
|
232
|
+
args: [
|
|
233
|
+
'--no-sandbox',
|
|
234
|
+
'--disable-setuid-sandbox',
|
|
235
|
+
'--disable-dev-shm-usage',
|
|
236
|
+
'--disable-accelerated-2d-canvas',
|
|
237
|
+
'--no-first-run',
|
|
238
|
+
'--no-zygote',
|
|
239
|
+
],
|
|
129
240
|
});
|
|
130
241
|
this.context = await this.browser.newContext({
|
|
131
242
|
userAgent: this.opts.userAgent,
|
|
132
243
|
acceptDownloads: true,
|
|
133
244
|
locale: 'en-US',
|
|
245
|
+
timezoneId: 'UTC',
|
|
246
|
+
viewport: { width: 1280, height: 720 },
|
|
134
247
|
});
|
|
135
248
|
await this.context.addCookies([
|
|
136
249
|
{
|
|
@@ -144,211 +257,644 @@ class BrowserManager {
|
|
|
144
257
|
this.page = await this.context.newPage();
|
|
145
258
|
this.interactions = new SfilePageInteractions(this.page, this.logger);
|
|
146
259
|
if (this.opts.debug) {
|
|
147
|
-
this.
|
|
148
|
-
if (msg.type() === 'error')
|
|
149
|
-
this.logger.error(`[console] ${msg.text()}`);
|
|
150
|
-
else if (msg.type() === 'warning')
|
|
151
|
-
this.logger.warn(`[console] ${msg.text()}`);
|
|
152
|
-
});
|
|
153
|
-
this.page.on('pageerror', (err) => this.logger.error(`[pageerror] ${err.message}`));
|
|
260
|
+
this.setupDebugListeners();
|
|
154
261
|
}
|
|
155
262
|
this.logger.info('Browser ready');
|
|
156
263
|
}
|
|
157
264
|
catch (err) {
|
|
158
|
-
|
|
265
|
+
await this.handleStageError(stage, err);
|
|
266
|
+
throw new BrowserError(`Failed to launch browser: ${err.message}`, {
|
|
267
|
+
stage,
|
|
268
|
+
originalError: err.message,
|
|
269
|
+
userAgent: this.opts.userAgent,
|
|
270
|
+
});
|
|
159
271
|
}
|
|
160
272
|
}
|
|
161
|
-
|
|
273
|
+
setupDebugListeners() {
|
|
162
274
|
if (!this.page)
|
|
163
|
-
|
|
275
|
+
return;
|
|
276
|
+
this.page.on('console', (msg) => {
|
|
277
|
+
const type = msg.type();
|
|
278
|
+
const text = msg.text();
|
|
279
|
+
if (type === 'error') {
|
|
280
|
+
this.logger.error(`[console] ${text}`, { location: msg.location() });
|
|
281
|
+
}
|
|
282
|
+
else if (type === 'warning') {
|
|
283
|
+
this.logger.warn(`[console] ${text}`);
|
|
284
|
+
}
|
|
285
|
+
else if (type === 'debug') {
|
|
286
|
+
this.logger.debug(`[console] ${text}`);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
this.page.on('pageerror', (err) => {
|
|
290
|
+
this.logger.error('[pageerror]', undefined, err);
|
|
291
|
+
});
|
|
292
|
+
this.page.on('requestfailed', (req) => {
|
|
293
|
+
const failure = req.failure();
|
|
294
|
+
this.logger.warn('[requestfailed]', {
|
|
295
|
+
url: req.url(),
|
|
296
|
+
method: req.method(),
|
|
297
|
+
error: failure?.errorText,
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
async goto(url, waitUntil = 'networkidle') {
|
|
302
|
+
const stage = 'navigation';
|
|
303
|
+
this.trackStage(stage);
|
|
304
|
+
if (!this.page) {
|
|
305
|
+
throw new BrowserError('Page not initialized', { stage });
|
|
306
|
+
}
|
|
164
307
|
try {
|
|
165
|
-
this.logger.debug(`Navigating to ${url}
|
|
308
|
+
this.logger.debug(`Navigating to ${url}`, { waitUntil, timeout: this.opts.timeout });
|
|
166
309
|
await this.page.goto(url, { waitUntil, timeout: this.opts.timeout });
|
|
167
310
|
}
|
|
168
311
|
catch (err) {
|
|
169
|
-
|
|
312
|
+
await this.handleStageError(stage, err);
|
|
313
|
+
throw new NetworkError(`Navigation failed: ${err.message}`, {
|
|
314
|
+
url,
|
|
315
|
+
stage,
|
|
316
|
+
waitUntil,
|
|
317
|
+
timeout: this.opts.timeout,
|
|
318
|
+
originalError: err.message,
|
|
319
|
+
});
|
|
170
320
|
}
|
|
171
321
|
}
|
|
172
322
|
async waitForDownloadButton() {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
323
|
+
const stage = 'waitForButton';
|
|
324
|
+
this.trackStage(stage);
|
|
325
|
+
if (!this.interactions) {
|
|
326
|
+
throw new BrowserError('Interactions not ready', { stage });
|
|
327
|
+
}
|
|
328
|
+
const timeout = this.opts.downloadButtonTimeout ?? DEFAULTS.downloadButtonTimeout;
|
|
329
|
+
try {
|
|
330
|
+
await this.interactions.waitForDownloadButton(timeout);
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
await this.handleStageError(stage, err);
|
|
334
|
+
throw new NetworkError(`Failed to wait for download button: ${err.message}`, {
|
|
335
|
+
stage,
|
|
336
|
+
timeout,
|
|
337
|
+
originalError: err.message,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
176
340
|
}
|
|
177
341
|
async getIntermediateUrl() {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
342
|
+
const stage = 'extractUrl';
|
|
343
|
+
this.trackStage(stage);
|
|
344
|
+
if (!this.interactions) {
|
|
345
|
+
throw new BrowserError('Interactions not ready', { stage });
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
const url = await this.interactions.extractIntermediateUrl();
|
|
349
|
+
this.logger.debug('Intermediate URL extracted', { url });
|
|
350
|
+
return url;
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
await this.handleStageError(stage, err);
|
|
354
|
+
throw new NetworkError(`Failed to extract intermediate URL: ${err.message}`, {
|
|
355
|
+
stage,
|
|
356
|
+
originalError: err.message,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
181
359
|
}
|
|
182
360
|
async startDownloadAndWait(autoUrl) {
|
|
183
|
-
|
|
184
|
-
|
|
361
|
+
const stage = 'downloadWait';
|
|
362
|
+
this.trackStage(stage);
|
|
363
|
+
if (!this.page) {
|
|
364
|
+
throw new BrowserError('Page not initialized', { stage });
|
|
365
|
+
}
|
|
185
366
|
const downloadPromise = this.page
|
|
186
367
|
.waitForEvent('download', { timeout: this.opts.timeout })
|
|
187
368
|
.catch((err) => {
|
|
188
|
-
this.logger.
|
|
369
|
+
this.logger.debug('Download event timeout', {
|
|
370
|
+
timeout: this.opts.timeout,
|
|
371
|
+
error: err.message,
|
|
372
|
+
});
|
|
189
373
|
return null;
|
|
190
374
|
});
|
|
191
|
-
this.logger.debug(
|
|
192
|
-
await this.page
|
|
375
|
+
this.logger.debug('Navigating to auto download URL', { url: autoUrl });
|
|
376
|
+
await this.page
|
|
377
|
+
.goto(autoUrl, { waitUntil: 'commit', timeout: this.opts.timeout })
|
|
378
|
+
.catch((err) => {
|
|
379
|
+
this.logger.warn('Navigation to auto URL failed, continuing anyway', {
|
|
380
|
+
error: err.message,
|
|
381
|
+
});
|
|
382
|
+
});
|
|
193
383
|
const download = await downloadPromise;
|
|
194
384
|
if (download) {
|
|
195
385
|
this.logger.info('Download event captured');
|
|
196
386
|
return download;
|
|
197
387
|
}
|
|
388
|
+
this.logger.debug('No download event captured within timeout');
|
|
198
389
|
return null;
|
|
199
390
|
}
|
|
200
391
|
async fallbackCollectFileResponse() {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
this.
|
|
392
|
+
const stage = 'fallbackIntercept';
|
|
393
|
+
this.trackStage(stage);
|
|
394
|
+
if (!this.page) {
|
|
395
|
+
throw new BrowserError('Page not initialized', { stage });
|
|
396
|
+
}
|
|
397
|
+
this.logger.info('Falling back to response interception');
|
|
204
398
|
const responses = [];
|
|
205
399
|
const handler = (res) => responses.push(res);
|
|
206
400
|
this.page.on('response', handler);
|
|
207
401
|
await sleep(DEFAULTS.fallbackWaitMs);
|
|
208
402
|
this.page.off('response', handler);
|
|
209
403
|
const fileResponse = [...responses].reverse().find((r) => {
|
|
210
|
-
const
|
|
404
|
+
const headers = r.headers();
|
|
405
|
+
const disposition = headers['content-disposition'];
|
|
406
|
+
const contentType = headers['content-type'];
|
|
407
|
+
const url = r.url();
|
|
211
408
|
return ((disposition && disposition.includes('attachment')) ||
|
|
212
|
-
|
|
409
|
+
url.includes('/downloadfile/') ||
|
|
410
|
+
(contentType &&
|
|
411
|
+
!contentType.startsWith('text/html') &&
|
|
412
|
+
!contentType.startsWith('application/json')));
|
|
213
413
|
});
|
|
214
|
-
if (!fileResponse)
|
|
414
|
+
if (!fileResponse) {
|
|
415
|
+
this.logger.debug('No suitable file response found in fallback', {
|
|
416
|
+
responseCount: responses.length,
|
|
417
|
+
sampleUrls: responses.slice(0, 3).map((r) => r.url()),
|
|
418
|
+
});
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
const buffer = await fileResponse.body();
|
|
423
|
+
let filename = extractFilenameFromContentDisposition(fileResponse.headers()['content-disposition']);
|
|
424
|
+
if (!filename) {
|
|
425
|
+
const urlParts = fileResponse.url().split('/');
|
|
426
|
+
filename = urlParts[urlParts.length - 1]?.split('?')[0] || 'file.bin';
|
|
427
|
+
}
|
|
428
|
+
filename = filename.replace(/[<>:"/\\|?*]/g, '_');
|
|
429
|
+
this.logger.info('Fallback response captured', {
|
|
430
|
+
filename,
|
|
431
|
+
size: buffer.length,
|
|
432
|
+
contentType: fileResponse.headers()['content-type'],
|
|
433
|
+
});
|
|
434
|
+
return { buffer, filename };
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
this.logger.error('Failed to read fallback response body', { error: err.message });
|
|
215
438
|
return null;
|
|
216
|
-
|
|
217
|
-
let filename = fileResponse.url().split('/').pop()?.split('?')[0] || 'file.bin';
|
|
218
|
-
filename = filename.replace(/[<>:"/\\|?*]/g, '_');
|
|
219
|
-
return { buffer, filename };
|
|
439
|
+
}
|
|
220
440
|
}
|
|
221
441
|
async saveDebugArtifacts(errorMessage) {
|
|
222
|
-
if (!this.page)
|
|
223
|
-
return;
|
|
442
|
+
if (!this.opts.debug || !this.page)
|
|
443
|
+
return null;
|
|
224
444
|
try {
|
|
225
|
-
this.debugDir =
|
|
445
|
+
this.debugDir = path__default.join(os.tmpdir(), `sfile_debug_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`);
|
|
226
446
|
await fs.mkdir(this.debugDir, { recursive: true });
|
|
227
|
-
|
|
228
|
-
this.page
|
|
229
|
-
|
|
447
|
+
const tasks = [
|
|
448
|
+
this.page
|
|
449
|
+
.screenshot({
|
|
450
|
+
path: path__default.join(this.debugDir, 'error.png'),
|
|
230
451
|
fullPage: true,
|
|
231
|
-
})
|
|
232
|
-
|
|
233
|
-
fs.writeFile(
|
|
234
|
-
|
|
235
|
-
|
|
452
|
+
})
|
|
453
|
+
.then(() => { }),
|
|
454
|
+
fs.writeFile(path__default.join(this.debugDir, 'error.html'), await this.page.content()),
|
|
455
|
+
fs.writeFile(path__default.join(this.debugDir, 'error.txt'), errorMessage),
|
|
456
|
+
fs.writeFile(path__default.join(this.debugDir, 'stages.json'), JSON.stringify(this.stageHistory, null, 2)),
|
|
457
|
+
];
|
|
458
|
+
await Promise.all(tasks);
|
|
459
|
+
this.logger.info('Debug artifacts saved', { debugDir: this.debugDir });
|
|
460
|
+
return this.debugDir;
|
|
236
461
|
}
|
|
237
462
|
catch (e) {
|
|
238
|
-
this.logger.error(
|
|
463
|
+
this.logger.error('Failed to save debug artifacts', { error: e.message });
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
async handleStageError(stage, error) {
|
|
468
|
+
if (this.opts.debug) {
|
|
469
|
+
await this.saveDebugArtifacts(`Error at stage "${stage}": ${error?.message || 'Unknown error'}`);
|
|
239
470
|
}
|
|
240
471
|
}
|
|
241
472
|
async close() {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
this.logger.debug('Browser closed');
|
|
473
|
+
const closePromises = [
|
|
474
|
+
this.page?.close().catch(() => { }),
|
|
475
|
+
this.context?.close().catch(() => { }),
|
|
476
|
+
this.browser?.close().catch(() => { }),
|
|
477
|
+
].filter(Boolean);
|
|
478
|
+
await Promise.all(closePromises);
|
|
479
|
+
this.logger.debug('Browser closed', { stages: this.stageHistory.length });
|
|
480
|
+
}
|
|
481
|
+
async getPage() {
|
|
482
|
+
return this.page;
|
|
483
|
+
}
|
|
484
|
+
getDebugDir() {
|
|
485
|
+
return this.debugDir;
|
|
486
|
+
}
|
|
487
|
+
getStageHistory() {
|
|
488
|
+
return [...this.stageHistory];
|
|
249
489
|
}
|
|
250
490
|
}
|
|
251
491
|
|
|
252
492
|
class Logger {
|
|
253
493
|
minLevel;
|
|
254
|
-
|
|
255
|
-
|
|
494
|
+
correlationId;
|
|
495
|
+
fileStream = null;
|
|
496
|
+
prefix;
|
|
497
|
+
constructor(options = {}) {
|
|
498
|
+
this.minLevel = options.debugMode ? 'debug' : 'info';
|
|
499
|
+
this.correlationId = options.correlationId;
|
|
500
|
+
this.prefix = options.prefix || '';
|
|
501
|
+
if (options.logFile) {
|
|
502
|
+
this.fileStream = createWriteStream(options.logFile, { flags: 'a' });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
setCorrelationId(id) {
|
|
506
|
+
this.correlationId = id;
|
|
256
507
|
}
|
|
257
508
|
setDebug(enabled) {
|
|
258
509
|
this.minLevel = enabled ? 'debug' : 'info';
|
|
259
510
|
}
|
|
511
|
+
levelPriority(level) {
|
|
512
|
+
return { debug: 0, info: 1, warn: 2, error: 3 }[level];
|
|
513
|
+
}
|
|
260
514
|
shouldLog(level) {
|
|
261
|
-
|
|
262
|
-
return levels[level] >= levels[this.minLevel];
|
|
515
|
+
return this.levelPriority(level) >= this.levelPriority(this.minLevel);
|
|
263
516
|
}
|
|
264
|
-
|
|
517
|
+
format(level, message, context) {
|
|
518
|
+
const ts = new Date().toISOString();
|
|
519
|
+
const corr = this.correlationId ? ` [${this.correlationId}]` : '';
|
|
520
|
+
const prefix = this.prefix ? ` [${this.prefix}]` : '';
|
|
521
|
+
const ctx = context ? ` | ${safeStringify(context)}` : '';
|
|
522
|
+
return `[${ts}]${corr}${prefix} ${level.toUpperCase()}: ${message}${ctx}`;
|
|
523
|
+
}
|
|
524
|
+
write(level, message, context) {
|
|
265
525
|
if (!this.shouldLog(level))
|
|
266
526
|
return;
|
|
267
|
-
const
|
|
268
|
-
const
|
|
269
|
-
console
|
|
527
|
+
const formatted = this.format(level, message, context);
|
|
528
|
+
const consoleMethod = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'log';
|
|
529
|
+
console[consoleMethod](formatted);
|
|
530
|
+
if (this.fileStream?.writable) {
|
|
531
|
+
this.fileStream.write(formatted + '\n');
|
|
532
|
+
}
|
|
270
533
|
}
|
|
271
534
|
debug(msg, ctx) {
|
|
272
|
-
this.
|
|
535
|
+
this.write('debug', msg, ctx);
|
|
273
536
|
}
|
|
274
537
|
info(msg, ctx) {
|
|
275
|
-
this.
|
|
538
|
+
this.write('info', msg, ctx);
|
|
276
539
|
}
|
|
277
540
|
warn(msg, ctx) {
|
|
278
|
-
this.
|
|
541
|
+
this.write('warn', msg, ctx);
|
|
542
|
+
}
|
|
543
|
+
error(msg, ctx, err) {
|
|
544
|
+
let errorCtx;
|
|
545
|
+
if (err) {
|
|
546
|
+
const baseObj = ctx && typeof ctx === 'object' && ctx !== null
|
|
547
|
+
? { ...ctx }
|
|
548
|
+
: {};
|
|
549
|
+
errorCtx = {
|
|
550
|
+
...baseObj,
|
|
551
|
+
error: { name: err.name, message: err.message },
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
errorCtx = ctx;
|
|
556
|
+
}
|
|
557
|
+
this.write('error', msg, errorCtx);
|
|
558
|
+
}
|
|
559
|
+
child(prefix) {
|
|
560
|
+
const childOptions = {
|
|
561
|
+
debugMode: this.minLevel === 'debug',
|
|
562
|
+
...(this.correlationId !== undefined && { correlationId: this.correlationId }),
|
|
563
|
+
prefix: this.prefix ? `${this.prefix}:${prefix}` : prefix,
|
|
564
|
+
};
|
|
565
|
+
const child = new Logger(childOptions);
|
|
566
|
+
if (this.fileStream) {
|
|
567
|
+
child.fileStream = this.fileStream;
|
|
568
|
+
}
|
|
569
|
+
return child;
|
|
279
570
|
}
|
|
280
|
-
|
|
281
|
-
this.
|
|
571
|
+
async close() {
|
|
572
|
+
if (this.fileStream && !this.fileStream.closed) {
|
|
573
|
+
return new Promise((resolve) => this.fileStream?.end(resolve));
|
|
574
|
+
}
|
|
282
575
|
}
|
|
283
576
|
}
|
|
284
577
|
|
|
285
578
|
function normalizeOptions(opts) {
|
|
286
|
-
|
|
579
|
+
const base = {
|
|
287
580
|
headless: opts?.headless ?? DEFAULTS.headless,
|
|
288
|
-
debug: opts?.debug ?? false,
|
|
289
581
|
userAgent: opts?.userAgent ?? DEFAULTS.userAgent,
|
|
290
582
|
timeout: opts?.timeout ?? DEFAULTS.timeout,
|
|
583
|
+
downloadButtonTimeout: opts?.downloadButtonTimeout ?? DEFAULTS.downloadButtonTimeout,
|
|
584
|
+
retries: opts?.retries ?? DEFAULTS.retries,
|
|
585
|
+
retryDelay: opts?.retryDelay ?? DEFAULTS.retryDelay,
|
|
586
|
+
debug: opts?.debug ?? false,
|
|
587
|
+
saveDebugArtifacts: opts?.saveDebugArtifacts ?? DEFAULTS.saveDebugArtifacts,
|
|
588
|
+
};
|
|
589
|
+
const optional = {
|
|
590
|
+
...(opts?.onProgress !== undefined && { onProgress: opts.onProgress }),
|
|
591
|
+
...(opts?.correlationId !== undefined && { correlationId: opts.correlationId }),
|
|
592
|
+
...(opts?._internal !== undefined && { _internal: opts._internal }),
|
|
593
|
+
...(opts?.logFile !== undefined && { logFile: opts.logFile }),
|
|
291
594
|
};
|
|
595
|
+
return { ...base, ...optional };
|
|
292
596
|
}
|
|
293
597
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
598
|
+
class InputValidator {
|
|
599
|
+
static validateUrl(url, allowedDomains = ['sfile.co', 'sfile.mobi']) {
|
|
600
|
+
if (!url || typeof url !== 'string') {
|
|
601
|
+
throw new ValidationError('URL must be a non-empty string', {
|
|
602
|
+
received: typeof url,
|
|
603
|
+
url,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
const hasAllowedDomain = allowedDomains.some((domain) => url.includes(domain));
|
|
607
|
+
if (!hasAllowedDomain) {
|
|
608
|
+
throw new ValidationError(`URL must contain one of: ${allowedDomains.join(', ')}`, {
|
|
609
|
+
url,
|
|
610
|
+
allowedDomains,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
try {
|
|
614
|
+
const parsed = new URL(url);
|
|
615
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
616
|
+
throw new ValidationError('URL must use http or https protocol', {
|
|
617
|
+
url,
|
|
618
|
+
protocol: parsed.protocol,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
catch (parseErr) {
|
|
623
|
+
throw new ValidationError('Invalid URL format', {
|
|
624
|
+
url,
|
|
625
|
+
parseError: parseErr instanceof Error ? parseErr.message : String(parseErr),
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
static validateSaveDir(dir) {
|
|
630
|
+
if (!dir || typeof dir !== 'string') {
|
|
631
|
+
throw new ValidationError('Save directory must be a non-empty string', {
|
|
632
|
+
received: typeof dir,
|
|
633
|
+
dir,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
static sanitizeFilename(name) {
|
|
638
|
+
const replacement = DEFAULTS.sanitizeReplacement;
|
|
639
|
+
const maxLength = DEFAULTS.maxFilenameLength;
|
|
640
|
+
const sanitized = sanitizeFilename(name, replacement);
|
|
641
|
+
if (!sanitized || sanitized.length === 0) {
|
|
642
|
+
const ext = name.includes('.') ? name.split('.').pop() : 'bin';
|
|
643
|
+
return `downloaded_file${ext ? '.' + ext : ''}`.slice(0, maxLength);
|
|
644
|
+
}
|
|
645
|
+
return sanitized.slice(0, maxLength);
|
|
646
|
+
}
|
|
647
|
+
static validateOptions(options) {
|
|
648
|
+
if (options && typeof options !== 'object') {
|
|
649
|
+
throw new ValidationError('Options must be an object or undefined', {
|
|
650
|
+
received: typeof options,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
const opts = options;
|
|
654
|
+
const numericFields = ['timeout', 'retries', 'retryDelay', 'downloadButtonTimeout'];
|
|
655
|
+
for (const field of numericFields) {
|
|
656
|
+
if (field in opts && typeof opts[field] !== 'number') {
|
|
657
|
+
throw new ValidationError(`Option '${field}' must be a number`, {
|
|
658
|
+
field,
|
|
659
|
+
received: typeof opts[field],
|
|
660
|
+
value: opts[field],
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
const booleanFields = ['headless', 'debug', 'saveDebugArtifacts'];
|
|
665
|
+
for (const field of booleanFields) {
|
|
666
|
+
if (field in opts && typeof opts[field] !== 'boolean') {
|
|
667
|
+
throw new ValidationError(`Option '${field}' must be a boolean`, {
|
|
668
|
+
field,
|
|
669
|
+
received: typeof opts[field],
|
|
670
|
+
value: opts[field],
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
299
674
|
}
|
|
300
|
-
|
|
301
|
-
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function stripUndefined(obj) {
|
|
678
|
+
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
|
|
679
|
+
}
|
|
680
|
+
function safeProgress(onProgress, percent, total, meta) {
|
|
681
|
+
if (typeof onProgress === 'function') {
|
|
682
|
+
try {
|
|
683
|
+
onProgress(percent, total, meta);
|
|
684
|
+
}
|
|
685
|
+
catch (err) {
|
|
686
|
+
console.warn('Progress callback error:', err);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
async function executeDownload(url, saveDir, opts, logger) {
|
|
691
|
+
const startTime = Date.now();
|
|
692
|
+
const browserMgr = new BrowserManager(logger.child('BrowserManager'), {
|
|
302
693
|
headless: opts.headless,
|
|
303
694
|
userAgent: opts.userAgent,
|
|
304
695
|
timeout: opts.timeout,
|
|
305
696
|
debug: opts.debug,
|
|
697
|
+
downloadButtonTimeout: opts.downloadButtonTimeout,
|
|
306
698
|
});
|
|
699
|
+
let finalPath;
|
|
700
|
+
let fileSize;
|
|
701
|
+
let method;
|
|
307
702
|
try {
|
|
703
|
+
logger.info('Starting download workflow', { url });
|
|
704
|
+
safeProgress(opts.onProgress, 10, 100, {
|
|
705
|
+
stage: 'launch',
|
|
706
|
+
message: 'Launching browser',
|
|
707
|
+
attempt: 1,
|
|
708
|
+
});
|
|
308
709
|
await browserMgr.launch();
|
|
309
710
|
await browserMgr.goto(url, 'networkidle');
|
|
711
|
+
safeProgress(opts.onProgress, 30, 100, { stage: 'navigation', message: 'Page loaded' });
|
|
310
712
|
await browserMgr.waitForDownloadButton();
|
|
713
|
+
safeProgress(opts.onProgress, 50, 100, {
|
|
714
|
+
stage: 'button',
|
|
715
|
+
message: 'Download button ready',
|
|
716
|
+
});
|
|
311
717
|
const intermediateUrl = await browserMgr.getIntermediateUrl();
|
|
312
718
|
const autoUrl = intermediateUrl.includes('?')
|
|
313
719
|
? `${intermediateUrl}&auto=1`
|
|
314
720
|
: `${intermediateUrl}?auto=1`;
|
|
721
|
+
logger.debug('Triggering download', { autoUrl });
|
|
722
|
+
safeProgress(opts.onProgress, 70, 100, { stage: 'trigger', message: 'Download triggered' });
|
|
315
723
|
let download = await browserMgr.startDownloadAndWait(autoUrl);
|
|
316
|
-
let finalPath;
|
|
317
|
-
let fileSize;
|
|
318
|
-
let method;
|
|
319
724
|
if (download) {
|
|
320
725
|
const suggested = download.suggestedFilename() || 'file.bin';
|
|
321
|
-
const filename = sanitizeFilename(suggested);
|
|
726
|
+
const filename = InputValidator.sanitizeFilename(suggested);
|
|
322
727
|
finalPath = path.join(saveDir, filename);
|
|
323
728
|
await download.saveAs(finalPath);
|
|
324
|
-
const stat = await
|
|
729
|
+
const stat = await promises.stat(finalPath);
|
|
325
730
|
fileSize = stat.size;
|
|
326
731
|
method = 'direct';
|
|
327
|
-
logger.info(
|
|
732
|
+
logger.info('Saved via direct download', { path: finalPath, size: fileSize });
|
|
328
733
|
}
|
|
329
734
|
else {
|
|
735
|
+
logger.warn('Direct download not captured, trying fallback');
|
|
330
736
|
const fallback = await browserMgr.fallbackCollectFileResponse();
|
|
331
737
|
if (!fallback) {
|
|
332
|
-
throw new NetworkError('No download event and no file response found'
|
|
738
|
+
throw new NetworkError('No download event and no file response found', {
|
|
739
|
+
url,
|
|
740
|
+
intermediateUrl,
|
|
741
|
+
autoUrl,
|
|
742
|
+
stage: 'fallbackFailed',
|
|
743
|
+
});
|
|
333
744
|
}
|
|
334
745
|
const { buffer, filename: rawName } = fallback;
|
|
335
|
-
const filename = sanitizeFilename(rawName);
|
|
746
|
+
const filename = InputValidator.sanitizeFilename(rawName);
|
|
336
747
|
finalPath = path.join(saveDir, filename);
|
|
337
|
-
await
|
|
748
|
+
await promises.writeFile(finalPath, buffer);
|
|
338
749
|
fileSize = buffer.length;
|
|
339
750
|
method = 'fallback';
|
|
340
|
-
logger.info(
|
|
751
|
+
logger.info('Saved via fallback', { path: finalPath, size: fileSize });
|
|
752
|
+
}
|
|
753
|
+
safeProgress(opts.onProgress, 100, 100, { stage: 'complete', message: 'Download finished' });
|
|
754
|
+
const result = {
|
|
755
|
+
filePath: finalPath,
|
|
756
|
+
size: fileSize,
|
|
757
|
+
method,
|
|
758
|
+
durationMs: Date.now() - startTime,
|
|
759
|
+
attempts: 1,
|
|
760
|
+
};
|
|
761
|
+
if (opts.correlationId !== undefined) {
|
|
762
|
+
result.correlationId = opts.correlationId;
|
|
341
763
|
}
|
|
342
|
-
return
|
|
764
|
+
return result;
|
|
343
765
|
}
|
|
344
766
|
catch (err) {
|
|
345
|
-
|
|
346
|
-
|
|
767
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
768
|
+
const appError = error instanceof AppError
|
|
769
|
+
? error
|
|
770
|
+
: new NetworkError(error.message, {
|
|
771
|
+
url,
|
|
772
|
+
saveDir,
|
|
773
|
+
correlationId: opts.correlationId,
|
|
774
|
+
originalError: error.message,
|
|
775
|
+
errorName: error.name,
|
|
776
|
+
});
|
|
777
|
+
if (opts.saveDebugArtifacts) {
|
|
778
|
+
const debugPath = await browserMgr.saveDebugArtifacts(appError.message);
|
|
779
|
+
if (debugPath) {
|
|
780
|
+
appError.debugPath = debugPath;
|
|
781
|
+
if (appError.context && typeof appError.context === 'object') {
|
|
782
|
+
appError.context.debugPath = debugPath;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
throw appError;
|
|
347
787
|
}
|
|
348
788
|
finally {
|
|
349
|
-
await browserMgr
|
|
789
|
+
await browserMgr
|
|
790
|
+
.close()
|
|
791
|
+
.catch((e) => logger.error('Failed to close browser', { error: e.message }));
|
|
792
|
+
await logger.close().catch((e) => console.error('Failed to close logger', e));
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
async function downloadSfile(url, saveDir, options) {
|
|
796
|
+
const opts = normalizeOptions(options);
|
|
797
|
+
const correlationId = opts.correlationId || randomUUID();
|
|
798
|
+
const loggerOptions = {
|
|
799
|
+
debugMode: opts.debug,
|
|
800
|
+
correlationId,
|
|
801
|
+
prefix: 'downloadSfile',
|
|
802
|
+
};
|
|
803
|
+
if (opts.logFile !== undefined) {
|
|
804
|
+
loggerOptions.logFile = opts.logFile;
|
|
805
|
+
}
|
|
806
|
+
const logger = new Logger(loggerOptions);
|
|
807
|
+
InputValidator.validateUrl(url);
|
|
808
|
+
InputValidator.validateSaveDir(saveDir);
|
|
809
|
+
InputValidator.validateOptions(options);
|
|
810
|
+
await promises.mkdir(saveDir, { recursive: true });
|
|
811
|
+
let lastError;
|
|
812
|
+
let attempt = 0;
|
|
813
|
+
while (attempt < opts.retries) {
|
|
814
|
+
attempt++;
|
|
815
|
+
try {
|
|
816
|
+
logger.info(`Download attempt ${attempt}/${opts.retries}`, { url });
|
|
817
|
+
const result = await executeDownload(url, saveDir, opts, logger);
|
|
818
|
+
logger.info(`Download succeeded`, {
|
|
819
|
+
duration: result.durationMs,
|
|
820
|
+
attempts: attempt,
|
|
821
|
+
method: result.method,
|
|
822
|
+
});
|
|
823
|
+
return {
|
|
824
|
+
...result,
|
|
825
|
+
attempts: attempt,
|
|
826
|
+
correlationId,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
catch (err) {
|
|
830
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
831
|
+
const isRetryable = lastError instanceof AppError && lastError.retryable;
|
|
832
|
+
const isLastAttempt = attempt >= opts.retries;
|
|
833
|
+
if (!isRetryable || isLastAttempt) {
|
|
834
|
+
logger.error(`Download failed permanently`, {
|
|
835
|
+
error: lastError.message,
|
|
836
|
+
attempt,
|
|
837
|
+
retryable: isRetryable,
|
|
838
|
+
lastAttempt: isLastAttempt,
|
|
839
|
+
});
|
|
840
|
+
throw lastError;
|
|
841
|
+
}
|
|
842
|
+
const delay = calculateRetryDelay(attempt, opts.retryDelay, DEFAULTS.maxRetryDelay);
|
|
843
|
+
logger.warn(`Retrying download`, {
|
|
844
|
+
attempt,
|
|
845
|
+
maxAttempts: opts.retries,
|
|
846
|
+
delayMs: Math.round(delay),
|
|
847
|
+
error: lastError.message,
|
|
848
|
+
});
|
|
849
|
+
safeProgress(opts.onProgress, 0, 100, {
|
|
850
|
+
stage: 'retry',
|
|
851
|
+
message: `Retry ${attempt}/${opts.retries}`,
|
|
852
|
+
attempt,
|
|
853
|
+
});
|
|
854
|
+
await sleep(delay);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
throw lastError || new Error('Download failed after all retries');
|
|
858
|
+
}
|
|
859
|
+
async function downloadSfileSafe(url, saveDir, options) {
|
|
860
|
+
try {
|
|
861
|
+
const result = await downloadSfile(url, saveDir, options);
|
|
862
|
+
return { success: true, value: result };
|
|
863
|
+
}
|
|
864
|
+
catch (error) {
|
|
865
|
+
return { success: false, error: error };
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
function createDownloader(defaultOptions) {
|
|
869
|
+
const defaults = normalizeOptions(defaultOptions);
|
|
870
|
+
return {
|
|
871
|
+
async download(url, saveDir, callOptions) {
|
|
872
|
+
const merged = stripUndefined({ ...defaults, ...callOptions });
|
|
873
|
+
return downloadSfile(url, saveDir, merged);
|
|
874
|
+
},
|
|
875
|
+
async downloadSafe(url, saveDir, callOptions) {
|
|
876
|
+
const merged = stripUndefined({ ...defaults, ...callOptions });
|
|
877
|
+
return downloadSfileSafe(url, saveDir, merged);
|
|
878
|
+
},
|
|
879
|
+
withOptions(newDefaults) {
|
|
880
|
+
const merged = stripUndefined({ ...defaults, ...newDefaults });
|
|
881
|
+
return createDownloader(merged);
|
|
882
|
+
},
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function isSuccess(result) {
|
|
887
|
+
return result.success === true;
|
|
888
|
+
}
|
|
889
|
+
function isFailure(result) {
|
|
890
|
+
return result.success === false;
|
|
891
|
+
}
|
|
892
|
+
function mapError(result, fn) {
|
|
893
|
+
if (!result.success) {
|
|
894
|
+
return { success: false, error: fn(result.error) };
|
|
350
895
|
}
|
|
896
|
+
return result;
|
|
351
897
|
}
|
|
352
898
|
|
|
353
|
-
export { AppError, BrowserError, FileError, NetworkError, ValidationError, downloadSfile };
|
|
899
|
+
export { AppError, BrowserError, DEFAULTS, FileError, InputValidator, Logger, NetworkError, ValidationError, calculateRetryDelay, createDownloader, downloadSfile, downloadSfileSafe, extractFilenameFromContentDisposition, isAppError, isFailure as isErr, isError, isErrorWithCode, isSuccess as isOk, isRetryableError, mapError as mapErr, normalizeOptions, safeStringify, sanitizeFilename, sleep };
|
|
354
900
|
//# sourceMappingURL=lib.mjs.map
|