coomer-downloader 3.0.10 → 3.1.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/dist/index.js +137 -71
- package/package.json +1 -1
- package/src/api/coomer-api.ts +3 -3
- package/src/index.ts +2 -1
- package/src/utils/downloader.ts +55 -54
- package/src/utils/filters.ts +21 -7
- package/src/utils/index.ts +6 -1
- package/src/utils/multibar.ts +17 -18
- package/src/utils/promise.ts +53 -0
- package/src/utils/requests.ts +2 -16
- package/src/utils/strings.ts +1 -1
- package/src/utils/timer.ts +19 -1
- package/src/utils/file.ts +0 -26
package/dist/index.js
CHANGED
|
@@ -32,12 +32,12 @@ function tryFixCoomerUrl(url, attempts) {
|
|
|
32
32
|
}
|
|
33
33
|
async function getUserProfileAPI(user) {
|
|
34
34
|
const url = `${user.domain}/api/v1/${user.service}/user/${user.id}/profile`;
|
|
35
|
-
const result = await
|
|
35
|
+
const result = await fetchWithGlobalHeader(url).then((r) => r.json());
|
|
36
36
|
return result;
|
|
37
37
|
}
|
|
38
38
|
async function getUserPostsAPI(user, offset) {
|
|
39
39
|
const url = `${user.domain}/api/v1/${user.service}/user/${user.id}/posts?o=${offset}`;
|
|
40
|
-
const posts = await
|
|
40
|
+
const posts = await fetchWithGlobalHeader(url).then((r) => r.json());
|
|
41
41
|
return posts;
|
|
42
42
|
}
|
|
43
43
|
async function getUserFiles(user, mediaType) {
|
|
@@ -95,10 +95,50 @@ function mkdir(filepath) {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// src/utils/promise.ts
|
|
99
|
+
async function sleep(time) {
|
|
100
|
+
return new Promise((resolve) => setTimeout(resolve, time));
|
|
101
|
+
}
|
|
102
|
+
var PromiseRetry = class _PromiseRetry {
|
|
103
|
+
retries;
|
|
104
|
+
delay;
|
|
105
|
+
callback;
|
|
106
|
+
constructor(options) {
|
|
107
|
+
this.retries = options.retries || 3;
|
|
108
|
+
this.delay = options.delay || 1e3;
|
|
109
|
+
this.callback = options.callback;
|
|
110
|
+
}
|
|
111
|
+
async execute(fn) {
|
|
112
|
+
let retries = this.retries;
|
|
113
|
+
while (true) {
|
|
114
|
+
try {
|
|
115
|
+
return await fn();
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (retries <= 0) {
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
if (this.callback) {
|
|
121
|
+
const res = this.callback(retries, error);
|
|
122
|
+
if (res) {
|
|
123
|
+
const { newRetries } = res;
|
|
124
|
+
if (newRetries === 0) throw error;
|
|
125
|
+
this.retries = newRetries || retries;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
await sleep(this.delay);
|
|
129
|
+
retries--;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
static create(options) {
|
|
134
|
+
return new _PromiseRetry(options);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
98
138
|
// src/utils/requests.ts
|
|
99
139
|
import { CookieAgent } from "http-cookie-agent/undici";
|
|
100
140
|
import { CookieJar } from "tough-cookie";
|
|
101
|
-
import { fetch,
|
|
141
|
+
import { fetch, interceptors, setGlobalDispatcher } from "undici";
|
|
102
142
|
function setCookieJarDispatcher() {
|
|
103
143
|
const jar = new CookieJar();
|
|
104
144
|
const agent = new CookieAgent({ cookies: { jar } }).compose(interceptors.retry()).compose(interceptors.redirect({ maxRedirections: 3 }));
|
|
@@ -114,7 +154,7 @@ function setGlobalHeaders(headers) {
|
|
|
114
154
|
HeadersDefault.set(k, headers[k]);
|
|
115
155
|
});
|
|
116
156
|
}
|
|
117
|
-
function
|
|
157
|
+
function fetchWithGlobalHeader(url) {
|
|
118
158
|
const requestHeaders = new Headers(HeadersDefault);
|
|
119
159
|
return fetch(url, { headers: requestHeaders });
|
|
120
160
|
}
|
|
@@ -125,7 +165,7 @@ function fetchByteRange(url, downloadedSize) {
|
|
|
125
165
|
}
|
|
126
166
|
|
|
127
167
|
// src/utils/timer.ts
|
|
128
|
-
var Timer = class {
|
|
168
|
+
var Timer = class _Timer {
|
|
129
169
|
constructor(timeout = 1e4, timeoutCallback) {
|
|
130
170
|
this.timeout = timeout;
|
|
131
171
|
this.timeoutCallback = timeoutCallback;
|
|
@@ -133,7 +173,10 @@ var Timer = class {
|
|
|
133
173
|
}
|
|
134
174
|
timer = void 0;
|
|
135
175
|
start() {
|
|
136
|
-
this.timer = setTimeout(
|
|
176
|
+
this.timer = setTimeout(() => {
|
|
177
|
+
this.stop();
|
|
178
|
+
this.timeoutCallback();
|
|
179
|
+
}, this.timeout);
|
|
137
180
|
return this;
|
|
138
181
|
}
|
|
139
182
|
stop() {
|
|
@@ -148,19 +191,27 @@ var Timer = class {
|
|
|
148
191
|
this.start();
|
|
149
192
|
return this;
|
|
150
193
|
}
|
|
194
|
+
static withSignal(timeout, message) {
|
|
195
|
+
const controller = new AbortController();
|
|
196
|
+
const callback = () => {
|
|
197
|
+
controller.abort(message);
|
|
198
|
+
};
|
|
199
|
+
const timer = new _Timer(timeout, callback).start();
|
|
200
|
+
return {
|
|
201
|
+
timer,
|
|
202
|
+
signal: controller.signal
|
|
203
|
+
};
|
|
204
|
+
}
|
|
151
205
|
};
|
|
152
206
|
|
|
153
207
|
// src/utils/downloader.ts
|
|
154
208
|
var subject = new Subject();
|
|
155
209
|
var CHUNK_TIMEOUT = 3e4;
|
|
156
|
-
var
|
|
157
|
-
|
|
158
|
-
|
|
210
|
+
var CHUNK_FETCH_RETRIES = 5;
|
|
211
|
+
var FETCH_RETRIES = 7;
|
|
212
|
+
async function fetchStream(file, stream) {
|
|
213
|
+
const { timer, signal } = Timer.withSignal(CHUNK_TIMEOUT, "CHUNK_TIMEOUT");
|
|
159
214
|
const fileStream = fs2.createWriteStream(file.filepath, { flags: "a" });
|
|
160
|
-
const controller = new AbortController();
|
|
161
|
-
const timer = new Timer(CHUNK_TIMEOUT, () => {
|
|
162
|
-
controller.abort("Stream is stuck.");
|
|
163
|
-
}).start();
|
|
164
215
|
const progressStream = new Transform({
|
|
165
216
|
transform(chunk, _encoding, callback) {
|
|
166
217
|
this.push(chunk);
|
|
@@ -170,71 +221,82 @@ async function downloadStream(file, stream) {
|
|
|
170
221
|
callback();
|
|
171
222
|
}
|
|
172
223
|
});
|
|
173
|
-
await pipeline(stream, progressStream, fileStream, { signal: controller.signal });
|
|
174
|
-
timer.stop();
|
|
175
|
-
subject.next({ type: "FILE_DOWNLOADING_FINISHED" });
|
|
176
|
-
}
|
|
177
|
-
function handleFetchError(error, file, attempts) {
|
|
178
|
-
const url = file?.url;
|
|
179
|
-
if (/coomer|kemono/.test(url)) {
|
|
180
|
-
file.url = tryFixCoomerUrl(url, attempts);
|
|
181
|
-
}
|
|
182
|
-
throw error;
|
|
183
|
-
}
|
|
184
|
-
async function downloadFile(file, attempts = DOWNLOAD_ATTEMPTS) {
|
|
185
|
-
const downloadedOld = file.downloaded || 0;
|
|
186
224
|
try {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
(error) => handleFetchError(error, file, --attempts)
|
|
190
|
-
);
|
|
191
|
-
if (!response?.ok && response?.status !== 416) {
|
|
192
|
-
throw new Error(`HTTP error! status: ${response?.status}`);
|
|
193
|
-
}
|
|
194
|
-
const contentLength = response.headers.get("Content-Length");
|
|
195
|
-
if (!contentLength && file.downloaded > 0) {
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
const restFileSize = parseInt(contentLength);
|
|
199
|
-
file.size = restFileSize + file.downloaded;
|
|
200
|
-
if (file.size > file.downloaded && response.body) {
|
|
201
|
-
const stream = Readable.fromWeb(response.body);
|
|
202
|
-
await downloadStream(file, stream);
|
|
203
|
-
}
|
|
225
|
+
subject.next({ type: "CHUNK_DOWNLOADING_START", file });
|
|
226
|
+
await pipeline(stream, progressStream, fileStream, { signal });
|
|
204
227
|
} catch (error) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
if (attempts < 1) {
|
|
209
|
-
console.error(file.url);
|
|
210
|
-
console.error(error);
|
|
211
|
-
} else {
|
|
212
|
-
await downloadFile(file, attempts - 1);
|
|
213
|
-
}
|
|
228
|
+
console.error(error.name === "AbortError" ? signal.reason : error);
|
|
229
|
+
} finally {
|
|
230
|
+
subject.next({ type: "CHUNK_DOWNLOADING_END", file });
|
|
214
231
|
}
|
|
215
232
|
}
|
|
233
|
+
async function downloadFile(file) {
|
|
234
|
+
file.downloaded = await getFileSize(file.filepath);
|
|
235
|
+
const response = await fetchByteRange(file.url, file.downloaded);
|
|
236
|
+
if (!response?.ok && response?.status !== 416) {
|
|
237
|
+
throw new Error(`HTTP error! status: ${response?.status}`);
|
|
238
|
+
}
|
|
239
|
+
const contentLength = response.headers.get("Content-Length");
|
|
240
|
+
if (!contentLength && file.downloaded > 0) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const restFileSize = parseInt(contentLength);
|
|
244
|
+
file.size = restFileSize + file.downloaded;
|
|
245
|
+
if (file.size > file.downloaded && response.body) {
|
|
246
|
+
const stream = Readable.fromWeb(response.body);
|
|
247
|
+
const sizeOld = file.downloaded;
|
|
248
|
+
await PromiseRetry.create({
|
|
249
|
+
retries: CHUNK_FETCH_RETRIES,
|
|
250
|
+
callback: () => {
|
|
251
|
+
if (sizeOld !== file.downloaded) {
|
|
252
|
+
return { newRetries: 5 };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}).execute(async () => await fetchStream(file, stream));
|
|
256
|
+
}
|
|
257
|
+
subject.next({ type: "FILE_DOWNLOADING_END" });
|
|
258
|
+
}
|
|
216
259
|
async function downloadFiles(data, downloadDir) {
|
|
217
260
|
mkdir(downloadDir);
|
|
218
|
-
subject.next({ type: "
|
|
219
|
-
for (const [
|
|
261
|
+
subject.next({ type: "FILES_DOWNLOADING_START", filesCount: data.length });
|
|
262
|
+
for (const [_, file] of data.entries()) {
|
|
220
263
|
file.filepath = path.join(downloadDir, file.name);
|
|
221
|
-
subject.next({ type: "
|
|
222
|
-
await
|
|
264
|
+
subject.next({ type: "FILE_DOWNLOADING_START" });
|
|
265
|
+
await PromiseRetry.create({
|
|
266
|
+
retries: FETCH_RETRIES,
|
|
267
|
+
callback: (retries) => {
|
|
268
|
+
if (/coomer|kemono/.test(file.url)) {
|
|
269
|
+
file.url = tryFixCoomerUrl(file.url, retries);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}).execute(async () => await downloadFile(file));
|
|
273
|
+
subject.next({ type: "FILE_DOWNLOADING_END" });
|
|
223
274
|
}
|
|
224
|
-
subject.next({ type: "
|
|
275
|
+
subject.next({ type: "FILES_DOWNLOADING_END" });
|
|
225
276
|
}
|
|
226
277
|
|
|
227
278
|
// src/utils/filters.ts
|
|
228
279
|
var isImage = (name) => /\.(jpg|jpeg|png|gif|bmp|tiff|webp|avif)$/i.test(name);
|
|
229
280
|
var isVideo = (name) => /\.(mp4|m4v|avi|mov|mkv|webm|flv|wmv|mpeg|mpg|3gp)$/i.test(name);
|
|
230
281
|
var testMediaType = (name, type) => type === "all" ? true : type === "image" ? isImage(name) : isVideo(name);
|
|
282
|
+
function includesAllWords(str, words) {
|
|
283
|
+
if (!words.length) return true;
|
|
284
|
+
return words.every((w) => str.includes(w));
|
|
285
|
+
}
|
|
286
|
+
function includesNoWords(str, words) {
|
|
287
|
+
if (!words.length) return true;
|
|
288
|
+
return words.every((w) => !str.includes(w));
|
|
289
|
+
}
|
|
290
|
+
function parseQuery(query) {
|
|
291
|
+
return query.split(",").map((x) => x.toLowerCase().trim()).filter((_) => _);
|
|
292
|
+
}
|
|
293
|
+
function filterString(text, include, exclude) {
|
|
294
|
+
return includesAllWords(text, parseQuery(include)) && includesNoWords(text, parseQuery(exclude));
|
|
295
|
+
}
|
|
231
296
|
function filterKeywords(files, include, exclude) {
|
|
232
|
-
const incl = include.split(",").map((x) => x.toLowerCase().trim());
|
|
233
|
-
const excl = exclude.split(",").map((x) => x.toLowerCase().trim());
|
|
234
|
-
const isValid = (text) => incl.some((e) => text.includes(e)) && (!exclude.trim().length || excl.every((e) => !text.includes(e)));
|
|
235
297
|
return files.filter((f) => {
|
|
236
298
|
const text = `${f.name || ""} ${f.content || ""}`.toLowerCase();
|
|
237
|
-
return
|
|
299
|
+
return filterString(text, include, exclude);
|
|
238
300
|
});
|
|
239
301
|
}
|
|
240
302
|
|
|
@@ -267,33 +329,37 @@ function createMultibar() {
|
|
|
267
329
|
let bar;
|
|
268
330
|
let minibar;
|
|
269
331
|
let filename;
|
|
332
|
+
let index = 0;
|
|
270
333
|
subject.subscribe({
|
|
271
|
-
next: ({ type, filesCount,
|
|
334
|
+
next: ({ type, filesCount, file }) => {
|
|
272
335
|
switch (type) {
|
|
273
|
-
case "
|
|
336
|
+
case "FILES_DOWNLOADING_START":
|
|
274
337
|
bar?.stop();
|
|
275
338
|
bar = multibar.create(filesCount, 0);
|
|
276
339
|
break;
|
|
277
|
-
case "
|
|
340
|
+
case "FILES_DOWNLOADING_END":
|
|
278
341
|
bar?.stop();
|
|
279
342
|
break;
|
|
280
|
-
case "
|
|
281
|
-
bar?.update(index
|
|
343
|
+
case "FILE_DOWNLOADING_START":
|
|
344
|
+
bar?.update(++index, { filename: "Downloaded files", size: "" });
|
|
345
|
+
break;
|
|
346
|
+
case "FILE_DOWNLOADING_END":
|
|
347
|
+
multibar.remove(minibar);
|
|
282
348
|
break;
|
|
283
|
-
case "
|
|
349
|
+
case "CHUNK_DOWNLOADING_START":
|
|
284
350
|
multibar?.remove(minibar);
|
|
285
351
|
filename = formatNameStdout(file?.filepath);
|
|
286
352
|
minibar = multibar.create(b2mb(file?.size), b2mb(file?.downloaded));
|
|
287
353
|
break;
|
|
288
|
-
case "FILE_DOWNLOADING_FINISHED":
|
|
289
|
-
multibar.remove(minibar);
|
|
290
|
-
break;
|
|
291
354
|
case "CHUNK_DOWNLOADING_UPDATE":
|
|
292
355
|
minibar?.update(b2mb(file?.downloaded), {
|
|
293
356
|
filename,
|
|
294
357
|
size: "mb"
|
|
295
358
|
});
|
|
296
359
|
break;
|
|
360
|
+
case "CHUNK_DOWNLOADING_END":
|
|
361
|
+
multibar?.remove(minibar);
|
|
362
|
+
break;
|
|
297
363
|
default:
|
|
298
364
|
break;
|
|
299
365
|
}
|
|
@@ -515,7 +581,7 @@ async function run() {
|
|
|
515
581
|
const { url, dir, media, include, exclude, skip } = argumentHander();
|
|
516
582
|
const { dirName, files } = await apiHandler(url, media);
|
|
517
583
|
const downloadDir = dir === "./" ? path2.resolve(dir, dirName) : path2.join(os.homedir(), path2.join(dir, dirName));
|
|
518
|
-
const filteredFiles = filterKeywords(files, include, exclude)
|
|
584
|
+
const filteredFiles = filterKeywords(files.slice(skip), include, exclude);
|
|
519
585
|
console.table([
|
|
520
586
|
{
|
|
521
587
|
found: files.length,
|
package/package.json
CHANGED
package/src/api/coomer-api.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ApiResult, File, MediaType } from '../types/index.js';
|
|
2
|
-
import {
|
|
2
|
+
import { fetchWithGlobalHeader, isImage, setGlobalHeaders, testMediaType } from '../utils/index.js';
|
|
3
3
|
|
|
4
4
|
type CoomerUser = { domain: string; service: string; id: string; name?: string };
|
|
5
5
|
type CoomerUserApi = { name: string };
|
|
@@ -29,13 +29,13 @@ export function tryFixCoomerUrl(url: string, attempts: number) {
|
|
|
29
29
|
|
|
30
30
|
async function getUserProfileAPI(user: CoomerUser): Promise<CoomerUserApi> {
|
|
31
31
|
const url = `${user.domain}/api/v1/${user.service}/user/${user.id}/profile`;
|
|
32
|
-
const result = await
|
|
32
|
+
const result = await fetchWithGlobalHeader(url).then((r) => r.json());
|
|
33
33
|
return result as CoomerUserApi;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
async function getUserPostsAPI(user: CoomerUser, offset: number): Promise<CoomerPost[]> {
|
|
37
37
|
const url = `${user.domain}/api/v1/${user.service}/user/${user.id}/posts?o=${offset}`;
|
|
38
|
-
const posts = await
|
|
38
|
+
const posts = await fetchWithGlobalHeader(url).then((r) => r.json());
|
|
39
39
|
return posts as CoomerPost[];
|
|
40
40
|
}
|
|
41
41
|
|
package/src/index.ts
CHANGED
|
@@ -15,7 +15,7 @@ async function run() {
|
|
|
15
15
|
const downloadDir =
|
|
16
16
|
dir === './' ? path.resolve(dir, dirName) : path.join(os.homedir(), path.join(dir, dirName));
|
|
17
17
|
|
|
18
|
-
const filteredFiles = filterKeywords(files, include, exclude)
|
|
18
|
+
const filteredFiles = filterKeywords(files.slice(skip), include, exclude);
|
|
19
19
|
|
|
20
20
|
console.table([
|
|
21
21
|
{
|
|
@@ -29,6 +29,7 @@ async function run() {
|
|
|
29
29
|
setGlobalHeaders({ Referer: url });
|
|
30
30
|
|
|
31
31
|
createMultibar();
|
|
32
|
+
|
|
32
33
|
await downloadFiles(filteredFiles, downloadDir);
|
|
33
34
|
|
|
34
35
|
process.kill(process.pid, 'SIGINT');
|
package/src/utils/downloader.ts
CHANGED
|
@@ -6,24 +6,21 @@ import { Subject } from 'rxjs';
|
|
|
6
6
|
import { tryFixCoomerUrl } from '../api/coomer-api';
|
|
7
7
|
import type { DownloaderSubject, File } from '../types';
|
|
8
8
|
import { getFileSize, mkdir } from './files';
|
|
9
|
+
import { PromiseRetry } from './promise';
|
|
9
10
|
import { fetchByteRange } from './requests';
|
|
10
11
|
import { Timer } from './timer';
|
|
11
12
|
|
|
12
13
|
export const subject = new Subject<DownloaderSubject>();
|
|
13
14
|
|
|
14
15
|
const CHUNK_TIMEOUT = 30_000;
|
|
15
|
-
const
|
|
16
|
+
const CHUNK_FETCH_RETRIES = 5;
|
|
17
|
+
const FETCH_RETRIES = 7;
|
|
16
18
|
|
|
17
|
-
async function
|
|
18
|
-
|
|
19
|
+
async function fetchStream(file: File, stream: Readable): Promise<void> {
|
|
20
|
+
const { timer, signal } = Timer.withSignal(CHUNK_TIMEOUT, 'CHUNK_TIMEOUT');
|
|
19
21
|
|
|
20
22
|
const fileStream = fs.createWriteStream(file.filepath as string, { flags: 'a' });
|
|
21
23
|
|
|
22
|
-
const controller = new AbortController();
|
|
23
|
-
const timer = new Timer(CHUNK_TIMEOUT, () => {
|
|
24
|
-
controller.abort('Stream is stuck.');
|
|
25
|
-
}).start();
|
|
26
|
-
|
|
27
24
|
const progressStream = new Transform({
|
|
28
25
|
transform(chunk, _encoding, callback) {
|
|
29
26
|
this.push(chunk);
|
|
@@ -34,68 +31,72 @@ async function downloadStream(file: File, stream: Readable): Promise<void> {
|
|
|
34
31
|
},
|
|
35
32
|
});
|
|
36
33
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (/coomer|kemono/.test(url)) {
|
|
45
|
-
file.url = tryFixCoomerUrl(url, attempts);
|
|
34
|
+
try {
|
|
35
|
+
subject.next({ type: 'CHUNK_DOWNLOADING_START', file });
|
|
36
|
+
await pipeline(stream, progressStream, fileStream, { signal });
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error((error as Error).name === 'AbortError' ? signal.reason : error);
|
|
39
|
+
} finally {
|
|
40
|
+
subject.next({ type: 'CHUNK_DOWNLOADING_END', file });
|
|
46
41
|
}
|
|
47
|
-
throw error;
|
|
48
42
|
}
|
|
49
43
|
|
|
50
|
-
async function downloadFile(file: File
|
|
51
|
-
|
|
52
|
-
try {
|
|
53
|
-
file.downloaded = await getFileSize(file.filepath as string);
|
|
54
|
-
|
|
55
|
-
const response = await fetchByteRange(file.url, file.downloaded).catch((error) =>
|
|
56
|
-
handleFetchError(error, file, --attempts),
|
|
57
|
-
);
|
|
44
|
+
async function downloadFile(file: File): Promise<void> {
|
|
45
|
+
file.downloaded = await getFileSize(file.filepath as string);
|
|
58
46
|
|
|
59
|
-
|
|
60
|
-
throw new Error(`HTTP error! status: ${response?.status}`);
|
|
61
|
-
}
|
|
47
|
+
const response = await fetchByteRange(file.url, file.downloaded);
|
|
62
48
|
|
|
63
|
-
|
|
49
|
+
if (!response?.ok && response?.status !== 416) {
|
|
50
|
+
throw new Error(`HTTP error! status: ${response?.status}`);
|
|
51
|
+
}
|
|
64
52
|
|
|
65
|
-
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
53
|
+
const contentLength = response.headers.get('Content-Length') as string;
|
|
68
54
|
|
|
69
|
-
|
|
70
|
-
|
|
55
|
+
if (!contentLength && file.downloaded > 0) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
71
58
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
59
|
+
const restFileSize = parseInt(contentLength);
|
|
60
|
+
file.size = restFileSize + file.downloaded;
|
|
61
|
+
|
|
62
|
+
if (file.size > file.downloaded && response.body) {
|
|
63
|
+
const stream = Readable.fromWeb(response.body);
|
|
64
|
+
const sizeOld = file.downloaded;
|
|
65
|
+
|
|
66
|
+
await PromiseRetry.create({
|
|
67
|
+
retries: CHUNK_FETCH_RETRIES,
|
|
68
|
+
callback: () => {
|
|
69
|
+
if (sizeOld !== file.downloaded) {
|
|
70
|
+
return { newRetries: 5 };
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
}).execute(async () => await fetchStream(file, stream));
|
|
86
74
|
}
|
|
75
|
+
|
|
76
|
+
subject.next({ type: 'FILE_DOWNLOADING_END' });
|
|
87
77
|
}
|
|
88
78
|
|
|
89
79
|
export async function downloadFiles(data: File[], downloadDir: string): Promise<void> {
|
|
90
80
|
mkdir(downloadDir);
|
|
91
81
|
|
|
92
|
-
subject.next({ type: '
|
|
82
|
+
subject.next({ type: 'FILES_DOWNLOADING_START', filesCount: data.length });
|
|
93
83
|
|
|
94
|
-
for (const [
|
|
84
|
+
for (const [_, file] of data.entries()) {
|
|
95
85
|
file.filepath = path.join(downloadDir, file.name);
|
|
96
|
-
|
|
97
|
-
|
|
86
|
+
|
|
87
|
+
subject.next({ type: 'FILE_DOWNLOADING_START' });
|
|
88
|
+
|
|
89
|
+
await PromiseRetry.create({
|
|
90
|
+
retries: FETCH_RETRIES,
|
|
91
|
+
callback: (retries) => {
|
|
92
|
+
if (/coomer|kemono/.test(file.url)) {
|
|
93
|
+
file.url = tryFixCoomerUrl(file.url, retries);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
}).execute(async () => await downloadFile(file));
|
|
97
|
+
|
|
98
|
+
subject.next({ type: 'FILE_DOWNLOADING_END' });
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
subject.next({ type: '
|
|
101
|
+
subject.next({ type: 'FILES_DOWNLOADING_END' });
|
|
101
102
|
}
|
package/src/utils/filters.ts
CHANGED
|
@@ -8,16 +8,30 @@ export const isVideo = (name: string) =>
|
|
|
8
8
|
export const testMediaType = (name: string, type: MediaType) =>
|
|
9
9
|
type === 'all' ? true : type === 'image' ? isImage(name) : isVideo(name);
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
function includesAllWords(str: string, words: string[]) {
|
|
12
|
+
if (!words.length) return true;
|
|
13
|
+
return words.every((w) => str.includes(w));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function includesNoWords(str: string, words: string[]) {
|
|
17
|
+
if (!words.length) return true;
|
|
18
|
+
return words.every((w) => !str.includes(w));
|
|
19
|
+
}
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
function parseQuery(query: string) {
|
|
22
|
+
return query
|
|
23
|
+
.split(',')
|
|
24
|
+
.map((x) => x.toLowerCase().trim())
|
|
25
|
+
.filter((_) => _);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function filterString(text: string, include: string, exclude: string): boolean {
|
|
29
|
+
return includesAllWords(text, parseQuery(include)) && includesNoWords(text, parseQuery(exclude));
|
|
30
|
+
}
|
|
18
31
|
|
|
32
|
+
export function filterKeywords(files: File[], include: string, exclude: string) {
|
|
19
33
|
return files.filter((f) => {
|
|
20
34
|
const text = `${f.name || ''} ${f.content || ''}`.toLowerCase();
|
|
21
|
-
return
|
|
35
|
+
return filterString(text, include, exclude);
|
|
22
36
|
});
|
|
23
37
|
}
|
package/src/utils/index.ts
CHANGED
|
@@ -2,5 +2,10 @@ export { downloadFiles } from './downloader';
|
|
|
2
2
|
export { getFileSize, mkdir } from './files';
|
|
3
3
|
export { filterKeywords, isImage, isVideo, testMediaType } from './filters';
|
|
4
4
|
export { createMultibar } from './multibar';
|
|
5
|
-
export {
|
|
5
|
+
export {
|
|
6
|
+
fetchByteRange,
|
|
7
|
+
fetchWithGlobalHeader,
|
|
8
|
+
HeadersDefault,
|
|
9
|
+
setGlobalHeaders,
|
|
10
|
+
} from './requests';
|
|
6
11
|
export { b2mb } from './strings';
|
package/src/utils/multibar.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { MultiBar, type SingleBar } from 'cli-progress';
|
|
1
|
+
import { MultiBar, type Options, type SingleBar } from 'cli-progress';
|
|
2
2
|
import { subject } from './downloader';
|
|
3
3
|
import { b2mb, formatNameStdout } from './strings';
|
|
4
4
|
|
|
5
|
-
const config = {
|
|
5
|
+
const config: Options = {
|
|
6
6
|
clearOnComplete: true,
|
|
7
7
|
gracefulExit: true,
|
|
8
8
|
autopadding: true,
|
|
@@ -10,44 +10,39 @@ const config = {
|
|
|
10
10
|
format: '{percentage}% | {filename} | {value}/{total}{size}',
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
-
// interface IBarState {
|
|
14
|
-
// totalFiles: number;
|
|
15
|
-
// totalDownloadedFiles: number;
|
|
16
|
-
// filesInProcess: File[];
|
|
17
|
-
// }
|
|
18
|
-
|
|
19
13
|
export function createMultibar() {
|
|
20
14
|
const multibar = new MultiBar(config);
|
|
21
15
|
let bar: SingleBar;
|
|
22
16
|
let minibar: SingleBar;
|
|
23
17
|
let filename: string;
|
|
18
|
+
let index = 0;
|
|
24
19
|
|
|
25
20
|
subject.subscribe({
|
|
26
|
-
next: ({ type, filesCount,
|
|
21
|
+
next: ({ type, filesCount, file }) => {
|
|
27
22
|
switch (type) {
|
|
28
|
-
case '
|
|
23
|
+
case 'FILES_DOWNLOADING_START':
|
|
29
24
|
bar?.stop();
|
|
30
25
|
bar = multibar.create(filesCount as number, 0);
|
|
31
26
|
break;
|
|
32
27
|
|
|
33
|
-
case '
|
|
28
|
+
case 'FILES_DOWNLOADING_END':
|
|
34
29
|
bar?.stop();
|
|
35
30
|
break;
|
|
36
31
|
|
|
37
|
-
case '
|
|
38
|
-
bar?.update(
|
|
32
|
+
case 'FILE_DOWNLOADING_START':
|
|
33
|
+
bar?.update(++index, { filename: 'Downloaded files', size: '' });
|
|
39
34
|
break;
|
|
40
35
|
|
|
41
|
-
case '
|
|
36
|
+
case 'FILE_DOWNLOADING_END':
|
|
37
|
+
multibar.remove(minibar);
|
|
38
|
+
break;
|
|
39
|
+
|
|
40
|
+
case 'CHUNK_DOWNLOADING_START':
|
|
42
41
|
multibar?.remove(minibar);
|
|
43
42
|
filename = formatNameStdout(file?.filepath as string);
|
|
44
43
|
minibar = multibar.create(b2mb(file?.size as number), b2mb(file?.downloaded as number));
|
|
45
44
|
break;
|
|
46
45
|
|
|
47
|
-
case 'FILE_DOWNLOADING_FINISHED':
|
|
48
|
-
multibar.remove(minibar);
|
|
49
|
-
break;
|
|
50
|
-
|
|
51
46
|
case 'CHUNK_DOWNLOADING_UPDATE':
|
|
52
47
|
minibar?.update(b2mb(file?.downloaded as number), {
|
|
53
48
|
filename: filename as string,
|
|
@@ -55,6 +50,10 @@ export function createMultibar() {
|
|
|
55
50
|
});
|
|
56
51
|
break;
|
|
57
52
|
|
|
53
|
+
case 'CHUNK_DOWNLOADING_END':
|
|
54
|
+
multibar?.remove(minibar);
|
|
55
|
+
break;
|
|
56
|
+
|
|
58
57
|
default:
|
|
59
58
|
break;
|
|
60
59
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export async function sleep(time: number) {
|
|
2
|
+
return new Promise((resolve) => setTimeout(resolve, time));
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
type PromiseRetryCallback = (retries: number, error: Error) => void | { newRetries?: number };
|
|
6
|
+
|
|
7
|
+
interface PromiseRetryOptions {
|
|
8
|
+
retries?: number;
|
|
9
|
+
callback?: PromiseRetryCallback;
|
|
10
|
+
delay?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class PromiseRetry {
|
|
14
|
+
private retries: number;
|
|
15
|
+
private delay: number;
|
|
16
|
+
private callback?: PromiseRetryCallback;
|
|
17
|
+
|
|
18
|
+
constructor(options: PromiseRetryOptions) {
|
|
19
|
+
this.retries = options.retries || 3;
|
|
20
|
+
this.delay = options.delay || 1000;
|
|
21
|
+
this.callback = options.callback;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async execute(fn: () => Promise<void>) {
|
|
25
|
+
let retries = this.retries;
|
|
26
|
+
|
|
27
|
+
while (true) {
|
|
28
|
+
try {
|
|
29
|
+
return await fn();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (retries <= 0) {
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (this.callback) {
|
|
36
|
+
const res = this.callback(retries, error as Error);
|
|
37
|
+
if (res) {
|
|
38
|
+
const { newRetries } = res;
|
|
39
|
+
if (newRetries === 0) throw error;
|
|
40
|
+
this.retries = newRetries || retries;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await sleep(this.delay);
|
|
45
|
+
retries--;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static create(options: PromiseRetryOptions) {
|
|
51
|
+
return new PromiseRetry(options);
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/utils/requests.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CookieAgent } from 'http-cookie-agent/undici';
|
|
2
2
|
import { CookieJar } from 'tough-cookie';
|
|
3
|
-
import { fetch,
|
|
3
|
+
import { fetch, interceptors, setGlobalDispatcher } from 'undici';
|
|
4
4
|
|
|
5
5
|
function setCookieJarDispatcher() {
|
|
6
6
|
const jar = new CookieJar();
|
|
@@ -12,20 +12,6 @@ function setCookieJarDispatcher() {
|
|
|
12
12
|
|
|
13
13
|
setCookieJarDispatcher();
|
|
14
14
|
|
|
15
|
-
export function setRetryDispatcher(maxRetries = 3) {
|
|
16
|
-
setGlobalDispatcher(
|
|
17
|
-
getGlobalDispatcher().compose(
|
|
18
|
-
interceptors.retry({
|
|
19
|
-
maxRetries,
|
|
20
|
-
// minTimeout: 1000,
|
|
21
|
-
// maxTimeout: 10000,
|
|
22
|
-
timeoutFactor: 2,
|
|
23
|
-
retryAfter: true,
|
|
24
|
-
}),
|
|
25
|
-
),
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
15
|
export const HeadersDefault = new Headers({
|
|
30
16
|
accept: 'application/json, text/css',
|
|
31
17
|
'User-Agent':
|
|
@@ -38,7 +24,7 @@ export function setGlobalHeaders(headers: Record<string, string>) {
|
|
|
38
24
|
});
|
|
39
25
|
}
|
|
40
26
|
|
|
41
|
-
export function
|
|
27
|
+
export function fetchWithGlobalHeader(url: string) {
|
|
42
28
|
const requestHeaders = new Headers(HeadersDefault);
|
|
43
29
|
return fetch(url, { headers: requestHeaders });
|
|
44
30
|
}
|
package/src/utils/strings.ts
CHANGED
|
@@ -16,6 +16,6 @@ export function formatNameStdout(pathname: string) {
|
|
|
16
16
|
const consoleWidth = process.stdout.columns;
|
|
17
17
|
const width = Math.max((consoleWidth / 2) | 0, 40);
|
|
18
18
|
if (name.length < width) return name.trim();
|
|
19
|
-
const result = `${name.slice(0,width-15)} ... ${name.slice(-10)}`.replace(/ +/g, ' ');
|
|
19
|
+
const result = `${name.slice(0, width - 15)} ... ${name.slice(-10)}`.replace(/ +/g, ' ');
|
|
20
20
|
return result;
|
|
21
21
|
}
|
package/src/utils/timer.ts
CHANGED
|
@@ -9,7 +9,10 @@ export class Timer {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
start() {
|
|
12
|
-
this.timer = setTimeout(
|
|
12
|
+
this.timer = setTimeout(() => {
|
|
13
|
+
this.stop();
|
|
14
|
+
this.timeoutCallback();
|
|
15
|
+
}, this.timeout);
|
|
13
16
|
return this;
|
|
14
17
|
}
|
|
15
18
|
|
|
@@ -26,4 +29,19 @@ export class Timer {
|
|
|
26
29
|
this.start();
|
|
27
30
|
return this;
|
|
28
31
|
}
|
|
32
|
+
|
|
33
|
+
static withSignal(timeout?: number, message?: string) {
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
|
|
36
|
+
const callback = () => {
|
|
37
|
+
controller.abort(message);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const timer = new Timer(timeout, callback).start();
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
timer,
|
|
44
|
+
signal: controller.signal,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
29
47
|
}
|
package/src/utils/file.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
// import path from 'node:path';
|
|
2
|
-
|
|
3
|
-
// class CFileList {
|
|
4
|
-
// files: CFile[];
|
|
5
|
-
// active: CFile[];
|
|
6
|
-
// }
|
|
7
|
-
|
|
8
|
-
// export class CFile {
|
|
9
|
-
// constructor(
|
|
10
|
-
// public name = '',
|
|
11
|
-
// public url = '',
|
|
12
|
-
// public filepath = '',
|
|
13
|
-
// public content = '',
|
|
14
|
-
// public size = 0,
|
|
15
|
-
// public downloaded = 0,
|
|
16
|
-
// public maybeFixURL?: undefined | ((url: string) => string),
|
|
17
|
-
// ) {}
|
|
18
|
-
|
|
19
|
-
// get text() {
|
|
20
|
-
// return `${this.content} ${this.name}`.toLowerCase();
|
|
21
|
-
// }
|
|
22
|
-
|
|
23
|
-
// setDir(dir: string) {
|
|
24
|
-
// this.filepath = path.join(dir, this.name);
|
|
25
|
-
// }
|
|
26
|
-
// }
|