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