coomer-downloader 2.6.5 → 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/README.md +3 -0
- package/biome.json +18 -9
- package/build.js +15 -0
- package/dist/index.js +598 -0
- package/package.json +21 -10
- package/src/api/{bunkr.js → bunkr.ts} +19 -19
- package/src/api/coomer-api.ts +93 -0
- package/src/api/{gofile.js → gofile.ts} +19 -14
- package/src/api/index.ts +28 -0
- package/src/api/{nsfw.xxx.js → nsfw.xxx.ts} +14 -13
- package/src/api/plain-curl.ts +13 -0
- package/src/args-handler.ts +55 -0
- package/src/index.ts +38 -0
- package/src/types/index.ts +23 -0
- package/src/utils/downloader.ts +102 -0
- package/src/utils/files.ts +15 -0
- package/src/utils/filters.ts +37 -0
- package/src/utils/index.ts +11 -0
- package/src/utils/multibar.ts +62 -0
- package/src/utils/promise.ts +53 -0
- package/src/utils/requests.ts +36 -0
- package/src/utils/strings.ts +21 -0
- package/src/utils/timer.ts +47 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +24 -0
- package/index.js +0 -34
- package/src/api/coomer-api.js +0 -78
- package/src/api/index.js +0 -24
- package/src/api/plain-curl.js +0 -11
- package/src/args-handler.js +0 -42
- package/src/downloader.js +0 -91
- package/src/utils/index.js +0 -62
- package/src/utils/streams.js +0 -40
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coomer-downloader",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"author": "smartacephal",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"description": "Downloads images/videos from Coomer/Kemono
|
|
6
|
+
"description": "Downloads images/videos from Coomer/Kemono, Bunkr, GoFile, Reddit-NSFW user posts",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"downloader",
|
|
9
9
|
"cli",
|
|
@@ -15,30 +15,41 @@
|
|
|
15
15
|
"kemono",
|
|
16
16
|
"reddit",
|
|
17
17
|
"nsfw.xxx",
|
|
18
|
-
"bunkr"
|
|
18
|
+
"bunkr",
|
|
19
|
+
"gofile"
|
|
19
20
|
],
|
|
20
21
|
"repository": {
|
|
21
22
|
"type": "git",
|
|
22
23
|
"url": "git+https://github.com/smartacephale/coomer-downloader.git"
|
|
23
24
|
},
|
|
24
|
-
"main": "index.
|
|
25
|
+
"main": "./src/index.ts",
|
|
25
26
|
"type": "module",
|
|
26
27
|
"scripts": {
|
|
27
|
-
"start": "
|
|
28
|
-
"
|
|
28
|
+
"start": "tsx ./src/index.ts",
|
|
29
|
+
"build": "node build.js",
|
|
30
|
+
"start-build": "npm run build && node ./dist/index.js"
|
|
29
31
|
},
|
|
30
32
|
"bin": {
|
|
31
|
-
"coomer-downloader": "index.js"
|
|
33
|
+
"coomer-downloader": "./dist/index.js"
|
|
32
34
|
},
|
|
33
35
|
"bugs": {
|
|
34
36
|
"url": "https://github.com/smartacephale/coomer-downloader",
|
|
35
37
|
"email": "atm.mormon@protonmail.com"
|
|
36
38
|
},
|
|
37
39
|
"dependencies": {
|
|
38
|
-
"cheerio": "
|
|
40
|
+
"cheerio": "1.1.0",
|
|
39
41
|
"cli-progress": "^3.12.0",
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
+
"http-cookie-agent": "^6.0.0",
|
|
43
|
+
"rxjs": "^7.8.2",
|
|
44
|
+
"tough-cookie": "5.1.2",
|
|
45
|
+
"undici": "^6.22.0",
|
|
42
46
|
"yargs": "^17.7.2"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/cli-progress": "^3.11.6",
|
|
50
|
+
"@types/node": "^24.10.0",
|
|
51
|
+
"@types/yargs": "^17.0.34",
|
|
52
|
+
"esbuild": "^0.27.0",
|
|
53
|
+
"tsx": "^4.20.6"
|
|
43
54
|
}
|
|
44
55
|
}
|
|
@@ -1,34 +1,36 @@
|
|
|
1
1
|
import * as cheerio from 'cheerio';
|
|
2
|
-
import {
|
|
2
|
+
import { fetch } from 'undici';
|
|
3
|
+
import type { ApiResult, MediaType } from '../types/index.js';
|
|
4
|
+
import { testMediaType } from '../utils/index.js';
|
|
3
5
|
|
|
4
|
-
|
|
6
|
+
type EncData = { url: string; timestamp: number };
|
|
7
|
+
|
|
8
|
+
async function getEncryptionData(slug: string): Promise<EncData> {
|
|
5
9
|
const response = await fetch('https://bunkr.cr/api/vs', {
|
|
6
10
|
method: 'POST',
|
|
7
11
|
headers: { 'Content-Type': 'application/json' },
|
|
8
12
|
body: JSON.stringify({ slug: slug }),
|
|
9
13
|
});
|
|
10
|
-
return await response.json();
|
|
14
|
+
return (await response.json()) as EncData;
|
|
11
15
|
}
|
|
12
16
|
|
|
13
|
-
function decryptEncryptedUrl(encryptionData) {
|
|
17
|
+
function decryptEncryptedUrl(encryptionData: EncData) {
|
|
14
18
|
const secretKey = `SECRET_KEY_${Math.floor(encryptionData.timestamp / 3600)}`;
|
|
15
19
|
const encryptedUrlBuffer = Buffer.from(encryptionData.url, 'base64');
|
|
16
20
|
const secretKeyBuffer = Buffer.from(secretKey, 'utf-8');
|
|
17
21
|
return Array.from(encryptedUrlBuffer)
|
|
18
|
-
.map((byte, i) =>
|
|
19
|
-
String.fromCharCode(byte ^ secretKeyBuffer[i % secretKeyBuffer.length]),
|
|
20
|
-
)
|
|
22
|
+
.map((byte, i) => String.fromCharCode(byte ^ secretKeyBuffer[i % secretKeyBuffer.length]))
|
|
21
23
|
.join('');
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
async function getFileData(url, name) {
|
|
25
|
-
const slug = url.split('/').pop();
|
|
26
|
+
async function getFileData(url: string, name: string) {
|
|
27
|
+
const slug = url.split('/').pop() as string;
|
|
26
28
|
const encryptionData = await getEncryptionData(slug);
|
|
27
29
|
const src = decryptEncryptedUrl(encryptionData);
|
|
28
|
-
return { name, src };
|
|
30
|
+
return { name, url: src };
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
async function getGalleryFiles(url, mediaType) {
|
|
33
|
+
async function getGalleryFiles(url: string, mediaType: MediaType) {
|
|
32
34
|
const data = [];
|
|
33
35
|
const page = await fetch(url).then((r) => r.text());
|
|
34
36
|
const $ = cheerio.load(page);
|
|
@@ -42,26 +44,24 @@ async function getGalleryFiles(url, mediaType) {
|
|
|
42
44
|
return { title, files: data.filter((f) => testMediaType(f.name, mediaType)) };
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
const fileNames = Array.from(
|
|
46
|
-
$('div[title]').map((_, e) => $(e).attr('title')),
|
|
47
|
-
);
|
|
47
|
+
const fileNames = Array.from($('div[title]').map((_, e) => $(e).attr('title')));
|
|
48
48
|
|
|
49
49
|
const files = Array.from($('a').map((_, e) => $(e).attr('href')))
|
|
50
50
|
.filter((a) => /\/f\/\w+/.test(a))
|
|
51
51
|
.map((a, i) => ({
|
|
52
|
-
|
|
53
|
-
name: fileNames[i] ||
|
|
52
|
+
url: `${url_.origin}${a}`,
|
|
53
|
+
name: fileNames[i] || (url.split('/').pop() as string),
|
|
54
54
|
}));
|
|
55
55
|
|
|
56
|
-
for (const { name,
|
|
57
|
-
const res = await getFileData(
|
|
56
|
+
for (const { name, url } of files) {
|
|
57
|
+
const res = await getFileData(url, name);
|
|
58
58
|
data.push(res);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
return { title, files: data.filter((f) => testMediaType(f.name, mediaType)) };
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
export async function getBunkrData(url, mediaType) {
|
|
64
|
+
export async function getBunkrData(url: string, mediaType: MediaType): Promise<ApiResult> {
|
|
65
65
|
const { files, title } = await getGalleryFiles(url, mediaType);
|
|
66
66
|
const dirName = `${title.split('|')[0].trim()}-bunkr`;
|
|
67
67
|
return { dirName, files };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { ApiResult, File, MediaType } from '../types/index.js';
|
|
2
|
+
import { fetchWithGlobalHeader, isImage, setGlobalHeaders, testMediaType } from '../utils/index.js';
|
|
3
|
+
|
|
4
|
+
type CoomerUser = { domain: string; service: string; id: string; name?: string };
|
|
5
|
+
type CoomerUserApi = { name: string };
|
|
6
|
+
type CoomerFile = { path: string; name: string };
|
|
7
|
+
type CoomerPost = {
|
|
8
|
+
title: string;
|
|
9
|
+
content: string;
|
|
10
|
+
published: string;
|
|
11
|
+
attachments: CoomerFile[];
|
|
12
|
+
file: CoomerFile;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const SERVERS = ['n1', 'n2', 'n3', 'n4'];
|
|
16
|
+
|
|
17
|
+
export function tryFixCoomerUrl(url: string, attempts: number) {
|
|
18
|
+
if (attempts < 2 && isImage(url)) {
|
|
19
|
+
return url.replace(/\/data\//, '/thumbnail/data/').replace(/n\d\./, 'img.');
|
|
20
|
+
}
|
|
21
|
+
const server = url.match(/n\d\./)?.[0].slice(0, 2) as string;
|
|
22
|
+
const i = SERVERS.indexOf(server);
|
|
23
|
+
if (i !== -1) {
|
|
24
|
+
const newServer = SERVERS[(i + 1) % SERVERS.length];
|
|
25
|
+
return url.replace(/n\d./, `${newServer}.`);
|
|
26
|
+
}
|
|
27
|
+
return url;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function getUserProfileAPI(user: CoomerUser): Promise<CoomerUserApi> {
|
|
31
|
+
const url = `${user.domain}/api/v1/${user.service}/user/${user.id}/profile`;
|
|
32
|
+
const result = await fetchWithGlobalHeader(url).then((r) => r.json());
|
|
33
|
+
return result as CoomerUserApi;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function getUserPostsAPI(user: CoomerUser, offset: number): Promise<CoomerPost[]> {
|
|
37
|
+
const url = `${user.domain}/api/v1/${user.service}/user/${user.id}/posts?o=${offset}`;
|
|
38
|
+
const posts = await fetchWithGlobalHeader(url).then((r) => r.json());
|
|
39
|
+
return posts as CoomerPost[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function getUserFiles(user: CoomerUser, mediaType: MediaType): Promise<File[]> {
|
|
43
|
+
const userPosts = [];
|
|
44
|
+
|
|
45
|
+
const offset = 50;
|
|
46
|
+
for (let i = 0; i < 1000; i++) {
|
|
47
|
+
const posts = await getUserPostsAPI(user, i * offset);
|
|
48
|
+
userPosts.push(...posts);
|
|
49
|
+
if (posts.length < 50) break;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const files: File[] = [];
|
|
53
|
+
|
|
54
|
+
for (const p of userPosts) {
|
|
55
|
+
const title = p.title.match(/\w+/g)?.join(' ') || '';
|
|
56
|
+
const content = p.content;
|
|
57
|
+
const date = p.published.replace(/T/, ' ');
|
|
58
|
+
const datentitle = `${date} ${title}`.trim();
|
|
59
|
+
|
|
60
|
+
const postFiles = [...p.attachments, p.file]
|
|
61
|
+
.filter((f) => f.path)
|
|
62
|
+
.filter((f) => testMediaType(f.name, mediaType))
|
|
63
|
+
.map((f, i) => {
|
|
64
|
+
const ext = f.name.split('.').pop();
|
|
65
|
+
const name = `${datentitle} ${i + 1}.${ext}`;
|
|
66
|
+
const url = `${user.domain}/${f.path}`;
|
|
67
|
+
return { name, url, content };
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
files.push(...postFiles);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return files;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function parseUser(url: string): Promise<CoomerUser> {
|
|
77
|
+
const [_, domain, service, id] = url.match(
|
|
78
|
+
/(https:\/\/\w+\.\w+)\/(\w+)\/user\/([\w|.|-]+)/,
|
|
79
|
+
) as RegExpMatchArray;
|
|
80
|
+
if (!domain || !service || !id) console.error('Invalid URL', url);
|
|
81
|
+
|
|
82
|
+
const { name } = await getUserProfileAPI({ domain, service, id });
|
|
83
|
+
|
|
84
|
+
return { domain, service, id, name };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function getCoomerData(url: string, mediaType: MediaType): Promise<ApiResult> {
|
|
88
|
+
setGlobalHeaders({ accept: 'text/css' });
|
|
89
|
+
const user = await parseUser(url);
|
|
90
|
+
const dirName = `${user.name}-${user.service}`;
|
|
91
|
+
const files = await getUserFiles(user, mediaType);
|
|
92
|
+
return { dirName, files };
|
|
93
|
+
}
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import { fetch
|
|
1
|
+
import { fetch } from 'undici';
|
|
2
|
+
import type { ApiResult, File, MediaType } from '../types/index.js';
|
|
3
|
+
import { setGlobalHeaders, testMediaType } from '../utils/index.js';
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
type GoFileAPIToken = { status: string; data: { token: string } };
|
|
6
|
+
type GoFileAPIFilelist = { data: { children: { link: string; name: string }[] } };
|
|
7
|
+
|
|
8
|
+
async function getToken(): Promise<string> {
|
|
4
9
|
const response = await fetch('https://api.gofile.io/accounts', {
|
|
5
10
|
method: 'POST',
|
|
6
11
|
});
|
|
7
|
-
const data = await response.json();
|
|
12
|
+
const data = (await response.json()) as GoFileAPIToken;
|
|
8
13
|
if (data.status === 'ok') {
|
|
9
14
|
return data.data.token;
|
|
10
15
|
}
|
|
@@ -21,7 +26,7 @@ async function getWebsiteToken() {
|
|
|
21
26
|
throw new Error('cannot get wt');
|
|
22
27
|
}
|
|
23
28
|
|
|
24
|
-
async function getFolderFiles(id, token, websiteToken) {
|
|
29
|
+
async function getFolderFiles(id: string, token: string, websiteToken: string): Promise<File[]> {
|
|
25
30
|
const url = `https://api.gofile.io/contents/${id}?wt=${websiteToken}&cache=true}`;
|
|
26
31
|
const response = await fetch(url, {
|
|
27
32
|
headers: {
|
|
@@ -33,27 +38,27 @@ async function getFolderFiles(id, token, websiteToken) {
|
|
|
33
38
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
34
39
|
}
|
|
35
40
|
|
|
36
|
-
const data = await response.json();
|
|
41
|
+
const data = (await response.json()) as GoFileAPIFilelist;
|
|
37
42
|
const files = Object.values(data.data.children).map((f) => ({
|
|
38
|
-
|
|
43
|
+
url: f.link,
|
|
39
44
|
name: f.name,
|
|
40
45
|
}));
|
|
41
46
|
|
|
42
47
|
return files;
|
|
43
48
|
}
|
|
44
49
|
|
|
45
|
-
export async function getGofileData(url, mediaType) {
|
|
46
|
-
const id = url.match(/gofile.io\/d\/(\w+)/)[1];
|
|
50
|
+
export async function getGofileData(url: string, mediaType: MediaType): Promise<ApiResult> {
|
|
51
|
+
const id = url.match(/gofile.io\/d\/(\w+)/)?.[1] as string;
|
|
47
52
|
const dirName = `gofile-${id}`;
|
|
48
53
|
|
|
49
54
|
const token = await getToken();
|
|
50
55
|
const websiteToken = await getWebsiteToken();
|
|
51
56
|
|
|
52
|
-
const files = await getFolderFiles(id, token, websiteToken)
|
|
57
|
+
const files = (await getFolderFiles(id, token, websiteToken)).filter((f) =>
|
|
58
|
+
testMediaType(f.name, mediaType),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
setGlobalHeaders({ Cookie: `accountToken=${token}` });
|
|
53
62
|
|
|
54
|
-
return {
|
|
55
|
-
dirName,
|
|
56
|
-
files: files.filter((f) => testMediaType(f.name, mediaType)),
|
|
57
|
-
headerData: { Cookie: `accountToken=${token}` },
|
|
58
|
-
};
|
|
63
|
+
return { dirName, files };
|
|
59
64
|
}
|
package/src/api/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ApiResult, MediaType } from '../types';
|
|
2
|
+
import { getBunkrData } from './bunkr';
|
|
3
|
+
import { getCoomerData } from './coomer-api';
|
|
4
|
+
import { getGofileData } from './gofile';
|
|
5
|
+
import { getRedditData } from './nsfw.xxx';
|
|
6
|
+
import { getPlainFileData } from './plain-curl';
|
|
7
|
+
|
|
8
|
+
export async function apiHandler(
|
|
9
|
+
url: string,
|
|
10
|
+
mediaType: MediaType,
|
|
11
|
+
): Promise<ApiResult | undefined> {
|
|
12
|
+
if (/^u\/\w+$/.test(url.trim())) {
|
|
13
|
+
return getRedditData(url, mediaType);
|
|
14
|
+
}
|
|
15
|
+
if (/coomer|kemono/.test(url)) {
|
|
16
|
+
return getCoomerData(url, mediaType);
|
|
17
|
+
}
|
|
18
|
+
if (/bunkr/.test(url)) {
|
|
19
|
+
return getBunkrData(url, mediaType);
|
|
20
|
+
}
|
|
21
|
+
if (/gofile\.io/.test(url)) {
|
|
22
|
+
return getGofileData(url, mediaType);
|
|
23
|
+
}
|
|
24
|
+
if (/\.\w+/.test(url.split('/').pop() as string)) {
|
|
25
|
+
return getPlainFileData(url);
|
|
26
|
+
}
|
|
27
|
+
console.error('Wrong URL.');
|
|
28
|
+
}
|
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
import fetch from 'node-fetch';
|
|
2
1
|
import * as cheerio from 'cheerio';
|
|
3
|
-
import {
|
|
2
|
+
import { fetch } from 'undici';
|
|
3
|
+
import type { ApiResult, File, MediaType } from '../types/index.js';
|
|
4
|
+
import { testMediaType } from '../utils/index.js';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
`https://nsfw.xxx/page/${
|
|
6
|
+
async function getUserPage(user: string, offset: number) {
|
|
7
|
+
const url = `https://nsfw.xxx/page/${offset}?nsfw[]=0&types[]=image&types[]=video&types[]=gallery&slider=1&jsload=1&user=${user}&_=${Date.now()}`;
|
|
8
|
+
return fetch(url).then((r) => r.text());
|
|
9
|
+
}
|
|
7
10
|
|
|
8
|
-
async function getUserPosts(user) {
|
|
11
|
+
async function getUserPosts(user: string): Promise<string[]> {
|
|
9
12
|
console.log('Fetching user posts...');
|
|
10
13
|
const posts = [];
|
|
11
14
|
for (let i = 1; i < 100000; i++) {
|
|
12
|
-
const page = await
|
|
15
|
+
const page = await getUserPage(user, i);
|
|
13
16
|
if (page.length < 1) break;
|
|
14
17
|
|
|
15
18
|
const $ = cheerio.load(page);
|
|
@@ -23,7 +26,7 @@ async function getUserPosts(user) {
|
|
|
23
26
|
return posts;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
async function getPostsData(posts, mediaType) {
|
|
29
|
+
async function getPostsData(posts: string[], mediaType: MediaType): Promise<File[]> {
|
|
27
30
|
console.log('Fetching posts data...');
|
|
28
31
|
const data = [];
|
|
29
32
|
for (const post of posts) {
|
|
@@ -38,21 +41,19 @@ async function getPostsData(posts, mediaType) {
|
|
|
38
41
|
if (!src) continue;
|
|
39
42
|
|
|
40
43
|
const slug = post.split('post/')[1].split('?')[0];
|
|
41
|
-
const date =
|
|
42
|
-
$('.sh-section .sh-section__passed').first().text().replace(/ /g, '-') ||
|
|
43
|
-
'';
|
|
44
|
+
const date = $('.sh-section .sh-section__passed').first().text().replace(/ /g, '-') || '';
|
|
44
45
|
|
|
45
46
|
const ext = src.split('.').pop();
|
|
46
47
|
const name = `${slug}-${date}.${ext}`;
|
|
47
48
|
|
|
48
|
-
data.push({ name, src });
|
|
49
|
+
data.push({ name, url: src });
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
return data.filter((f) => testMediaType(f.name, mediaType));
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
export async function getRedditData(url, mediaType) {
|
|
55
|
-
const user = url.match(/u\/(\w+)/)[1];
|
|
55
|
+
export async function getRedditData(url: string, mediaType: MediaType): Promise<ApiResult> {
|
|
56
|
+
const user = url.match(/u\/(\w+)/)?.[1] as string;
|
|
56
57
|
const posts = await getUserPosts(user);
|
|
57
58
|
const files = await getPostsData(posts, mediaType);
|
|
58
59
|
const dirName = `${user}-reddit`;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import yargs from 'yargs';
|
|
2
|
+
import { hideBin } from 'yargs/helpers';
|
|
3
|
+
|
|
4
|
+
export type ArgumentHandlerResult = {
|
|
5
|
+
[x: string]: unknown;
|
|
6
|
+
url: string;
|
|
7
|
+
dir: string;
|
|
8
|
+
media: string;
|
|
9
|
+
include: string;
|
|
10
|
+
exclude: string;
|
|
11
|
+
skip: number;
|
|
12
|
+
_: (string | number)[];
|
|
13
|
+
$0: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function argumentHander(): ArgumentHandlerResult {
|
|
17
|
+
return yargs(hideBin(process.argv))
|
|
18
|
+
.option('url', {
|
|
19
|
+
alias: 'u',
|
|
20
|
+
type: 'string',
|
|
21
|
+
description:
|
|
22
|
+
'A URL from Coomer/Kemono/Bunkr/GoFile, a Reddit user (u/<username>), or a direct file link',
|
|
23
|
+
demandOption: true,
|
|
24
|
+
})
|
|
25
|
+
.option('dir', {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'The directory where files will be downloaded',
|
|
28
|
+
default: './',
|
|
29
|
+
})
|
|
30
|
+
.option('media', {
|
|
31
|
+
type: 'string',
|
|
32
|
+
choices: ['video', 'image', 'all'],
|
|
33
|
+
default: 'all',
|
|
34
|
+
description:
|
|
35
|
+
"The type of media to download: 'video', 'image', or 'all'. 'all' is the default.",
|
|
36
|
+
})
|
|
37
|
+
.option('include', {
|
|
38
|
+
type: 'string',
|
|
39
|
+
default: '',
|
|
40
|
+
description: 'Filter file names by a comma-separated list of keywords to include',
|
|
41
|
+
})
|
|
42
|
+
.option('exclude', {
|
|
43
|
+
type: 'string',
|
|
44
|
+
default: '',
|
|
45
|
+
description: 'Filter file names by a comma-separated list of keywords to exclude',
|
|
46
|
+
})
|
|
47
|
+
.option('skip', {
|
|
48
|
+
type: 'number',
|
|
49
|
+
default: 0,
|
|
50
|
+
description: 'Skips the first N files in the download queue',
|
|
51
|
+
})
|
|
52
|
+
.help()
|
|
53
|
+
.alias('help', 'h')
|
|
54
|
+
.parseSync();
|
|
55
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { apiHandler } from './api';
|
|
6
|
+
import { argumentHander } from './args-handler';
|
|
7
|
+
import type { ApiResult, MediaType } from './types';
|
|
8
|
+
import { createMultibar, downloadFiles, filterKeywords, setGlobalHeaders } from './utils';
|
|
9
|
+
|
|
10
|
+
async function run() {
|
|
11
|
+
const { url, dir, media, include, exclude, skip } = argumentHander();
|
|
12
|
+
|
|
13
|
+
const { dirName, files } = (await apiHandler(url, media as MediaType)) as ApiResult;
|
|
14
|
+
|
|
15
|
+
const downloadDir =
|
|
16
|
+
dir === './' ? path.resolve(dir, dirName) : path.join(os.homedir(), path.join(dir, dirName));
|
|
17
|
+
|
|
18
|
+
const filteredFiles = filterKeywords(files.slice(skip), include, exclude);
|
|
19
|
+
|
|
20
|
+
console.table([
|
|
21
|
+
{
|
|
22
|
+
found: files.length,
|
|
23
|
+
skip,
|
|
24
|
+
filtered: files.length - filteredFiles.length - skip,
|
|
25
|
+
folder: downloadDir,
|
|
26
|
+
},
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
setGlobalHeaders({ Referer: url });
|
|
30
|
+
|
|
31
|
+
createMultibar();
|
|
32
|
+
|
|
33
|
+
await downloadFiles(filteredFiles, downloadDir);
|
|
34
|
+
|
|
35
|
+
process.kill(process.pid, 'SIGINT');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
run();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type File = {
|
|
2
|
+
name: string;
|
|
3
|
+
url: string;
|
|
4
|
+
filepath?: string;
|
|
5
|
+
size?: number;
|
|
6
|
+
downloaded?: number;
|
|
7
|
+
content?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type DownloaderSubject = {
|
|
11
|
+
type: string;
|
|
12
|
+
file?: File;
|
|
13
|
+
index?: number;
|
|
14
|
+
filesCount?: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ApiResult = {
|
|
18
|
+
files: File[];
|
|
19
|
+
// should merge into filepaths?
|
|
20
|
+
dirName: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type MediaType = 'video' | 'image' | 'all';
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Readable, Transform } from 'node:stream';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
5
|
+
import { Subject } from 'rxjs';
|
|
6
|
+
import { tryFixCoomerUrl } from '../api/coomer-api';
|
|
7
|
+
import type { DownloaderSubject, File } from '../types';
|
|
8
|
+
import { getFileSize, mkdir } from './files';
|
|
9
|
+
import { PromiseRetry } from './promise';
|
|
10
|
+
import { fetchByteRange } from './requests';
|
|
11
|
+
import { Timer } from './timer';
|
|
12
|
+
|
|
13
|
+
export const subject = new Subject<DownloaderSubject>();
|
|
14
|
+
|
|
15
|
+
const CHUNK_TIMEOUT = 30_000;
|
|
16
|
+
const CHUNK_FETCH_RETRIES = 5;
|
|
17
|
+
const FETCH_RETRIES = 7;
|
|
18
|
+
|
|
19
|
+
async function fetchStream(file: File, stream: Readable): Promise<void> {
|
|
20
|
+
const { timer, signal } = Timer.withSignal(CHUNK_TIMEOUT, 'CHUNK_TIMEOUT');
|
|
21
|
+
|
|
22
|
+
const fileStream = fs.createWriteStream(file.filepath as string, { flags: 'a' });
|
|
23
|
+
|
|
24
|
+
const progressStream = new Transform({
|
|
25
|
+
transform(chunk, _encoding, callback) {
|
|
26
|
+
this.push(chunk);
|
|
27
|
+
file.downloaded += chunk.length;
|
|
28
|
+
timer.reset();
|
|
29
|
+
subject.next({ type: 'CHUNK_DOWNLOADING_UPDATE', file });
|
|
30
|
+
callback();
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
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 });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function downloadFile(file: File): Promise<void> {
|
|
45
|
+
file.downloaded = await getFileSize(file.filepath as string);
|
|
46
|
+
|
|
47
|
+
const response = await fetchByteRange(file.url, file.downloaded);
|
|
48
|
+
|
|
49
|
+
if (!response?.ok && response?.status !== 416) {
|
|
50
|
+
throw new Error(`HTTP error! status: ${response?.status}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const contentLength = response.headers.get('Content-Length') as string;
|
|
54
|
+
|
|
55
|
+
if (!contentLength && file.downloaded > 0) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
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));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
subject.next({ type: 'FILE_DOWNLOADING_END' });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function downloadFiles(data: File[], downloadDir: string): Promise<void> {
|
|
80
|
+
mkdir(downloadDir);
|
|
81
|
+
|
|
82
|
+
subject.next({ type: 'FILES_DOWNLOADING_START', filesCount: data.length });
|
|
83
|
+
|
|
84
|
+
for (const [_, file] of data.entries()) {
|
|
85
|
+
file.filepath = path.join(downloadDir, file.name);
|
|
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' });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
subject.next({ type: 'FILES_DOWNLOADING_END' });
|
|
102
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
export async function getFileSize(filepath: string) {
|
|
4
|
+
let size = 0;
|
|
5
|
+
if (fs.existsSync(filepath)) {
|
|
6
|
+
size = (await fs.promises.stat(filepath)).size || 0;
|
|
7
|
+
}
|
|
8
|
+
return size;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function mkdir(filepath: string) {
|
|
12
|
+
if (!fs.existsSync(filepath)) {
|
|
13
|
+
fs.mkdirSync(filepath, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { File, MediaType } from '../types';
|
|
2
|
+
|
|
3
|
+
export const isImage = (name: string) => /\.(jpg|jpeg|png|gif|bmp|tiff|webp|avif)$/i.test(name);
|
|
4
|
+
|
|
5
|
+
export const isVideo = (name: string) =>
|
|
6
|
+
/\.(mp4|m4v|avi|mov|mkv|webm|flv|wmv|mpeg|mpg|3gp)$/i.test(name);
|
|
7
|
+
|
|
8
|
+
export const testMediaType = (name: string, type: MediaType) =>
|
|
9
|
+
type === 'all' ? true : type === 'image' ? isImage(name) : isVideo(name);
|
|
10
|
+
|
|
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
|
+
}
|
|
20
|
+
|
|
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
|
+
}
|
|
31
|
+
|
|
32
|
+
export function filterKeywords(files: File[], include: string, exclude: string) {
|
|
33
|
+
return files.filter((f) => {
|
|
34
|
+
const text = `${f.name || ''} ${f.content || ''}`.toLowerCase();
|
|
35
|
+
return filterString(text, include, exclude);
|
|
36
|
+
});
|
|
37
|
+
}
|