@nemu.pm/tachiyomi-cli 0.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/app.ts +27 -0
- package/bin.ts +8 -0
- package/bun.lock +92 -0
- package/commands/build.ts +81 -0
- package/commands/explore.ts +495 -0
- package/commands/info.ts +59 -0
- package/commands/list.ts +39 -0
- package/commands/test.ts +550 -0
- package/config.ts +87 -0
- package/lib/extension-loader.ts +163 -0
- package/lib/http.ts +122 -0
- package/lib/output.ts +37 -0
- package/package.json +39 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import { buildCommand } from "@stricli/core";
|
|
2
|
+
import { select, input, confirm } from "@inquirer/prompts";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import * as fs from "fs";
|
|
5
|
+
import * as path from "path";
|
|
6
|
+
import { loadOutputConfig } from "../config";
|
|
7
|
+
import { listExtensionsWithInfo, loadExtension, unwrapResult, type MangasPage, type TachiyomiExports } from "../lib/extension-loader";
|
|
8
|
+
|
|
9
|
+
interface Manga {
|
|
10
|
+
title: string;
|
|
11
|
+
url: string;
|
|
12
|
+
thumbnailUrl?: string;
|
|
13
|
+
author?: string;
|
|
14
|
+
artist?: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
status?: number;
|
|
17
|
+
genres?: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Chapter {
|
|
21
|
+
name: string;
|
|
22
|
+
url: string;
|
|
23
|
+
dateUpload?: number;
|
|
24
|
+
chapterNumber?: number;
|
|
25
|
+
scanlator?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Page {
|
|
29
|
+
index: number;
|
|
30
|
+
imageUrl?: string;
|
|
31
|
+
url?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const explore = buildCommand({
|
|
35
|
+
docs: {
|
|
36
|
+
brief: "Interactive mode to explore manga",
|
|
37
|
+
fullDescription: "Browse manga interactively - select extensions, browse popular/latest, search, view details and chapters.",
|
|
38
|
+
},
|
|
39
|
+
parameters: {
|
|
40
|
+
positional: {
|
|
41
|
+
kind: "tuple",
|
|
42
|
+
parameters: [],
|
|
43
|
+
},
|
|
44
|
+
flags: {},
|
|
45
|
+
},
|
|
46
|
+
func: async () => {
|
|
47
|
+
const config = loadOutputConfig();
|
|
48
|
+
|
|
49
|
+
// List available extensions
|
|
50
|
+
const extensions = listExtensionsWithInfo(config.output);
|
|
51
|
+
if (extensions.length === 0) {
|
|
52
|
+
console.log(pc.red("No extensions found. Build some first with: tachiyomi build <extension>"));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log(pc.cyan("\nš Tachiyomi Extension Explorer\n"));
|
|
57
|
+
|
|
58
|
+
// Select extension
|
|
59
|
+
const extensionId = await select({
|
|
60
|
+
message: "Select an extension",
|
|
61
|
+
choices: extensions.map((ext) => ({
|
|
62
|
+
name: `${ext.name} (${ext.lang})${ext.isNsfw ? " [NSFW]" : ""}`,
|
|
63
|
+
value: ext.id,
|
|
64
|
+
})),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const { exports, sources } = loadExtension(config.output, extensionId);
|
|
68
|
+
if (sources.length === 0) {
|
|
69
|
+
console.log(pc.red("No sources found in extension"));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const source = sources[0];
|
|
74
|
+
const sourceId = source.id;
|
|
75
|
+
console.log(pc.green(`\nLoaded: ${source.name}`));
|
|
76
|
+
|
|
77
|
+
// Main loop
|
|
78
|
+
while (true) {
|
|
79
|
+
const action = await select({
|
|
80
|
+
message: "What would you like to do?",
|
|
81
|
+
choices: [
|
|
82
|
+
{ name: "š„ Browse Popular", value: "popular" },
|
|
83
|
+
{ name: "š Browse Latest", value: "latest" },
|
|
84
|
+
{ name: "š Search", value: "search" },
|
|
85
|
+
{ name: "šŖ Exit", value: "exit" },
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (action === "exit") break;
|
|
90
|
+
|
|
91
|
+
let mangas: Manga[] = [];
|
|
92
|
+
let page = 1;
|
|
93
|
+
let hasNextPage = true;
|
|
94
|
+
|
|
95
|
+
if (action === "search") {
|
|
96
|
+
const query = await input({ message: "Search query:" });
|
|
97
|
+
if (!query.trim()) continue;
|
|
98
|
+
|
|
99
|
+
console.log(pc.dim(`\nSearching for "${query}"...`));
|
|
100
|
+
const result = unwrapResult<MangasPage>(exports.searchManga(sourceId, page, query));
|
|
101
|
+
mangas = result.mangas ?? [];
|
|
102
|
+
hasNextPage = result.hasNextPage ?? false;
|
|
103
|
+
} else if (action === "popular") {
|
|
104
|
+
console.log(pc.dim("\nFetching popular manga..."));
|
|
105
|
+
const result = unwrapResult<MangasPage>(exports.getPopularManga(sourceId, page));
|
|
106
|
+
mangas = result.mangas ?? [];
|
|
107
|
+
hasNextPage = result.hasNextPage ?? false;
|
|
108
|
+
} else if (action === "latest") {
|
|
109
|
+
console.log(pc.dim("\nFetching latest updates..."));
|
|
110
|
+
const result = unwrapResult<MangasPage>(exports.getLatestUpdates(sourceId, page));
|
|
111
|
+
mangas = result.mangas ?? [];
|
|
112
|
+
hasNextPage = result.hasNextPage ?? false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (mangas.length === 0) {
|
|
116
|
+
console.log(pc.yellow("\nNo manga found."));
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Manga selection loop
|
|
121
|
+
while (true) {
|
|
122
|
+
const choices = [
|
|
123
|
+
...mangas.map((m, i) => ({
|
|
124
|
+
name: `${i + 1}. ${m.title}`,
|
|
125
|
+
value: String(i),
|
|
126
|
+
})),
|
|
127
|
+
...(hasNextPage ? [{ name: "š Load more", value: "more" }] : []),
|
|
128
|
+
{ name: "ā¬
Back", value: "back" },
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
const mangaChoice = await select({
|
|
132
|
+
message: `Found ${mangas.length} manga:`,
|
|
133
|
+
choices,
|
|
134
|
+
pageSize: 15,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (mangaChoice === "back") break;
|
|
138
|
+
if (mangaChoice === "more") {
|
|
139
|
+
page++;
|
|
140
|
+
console.log(pc.dim(`\nLoading page ${page}...`));
|
|
141
|
+
let result: MangasPage;
|
|
142
|
+
if (action === "popular") {
|
|
143
|
+
result = unwrapResult<MangasPage>(exports.getPopularManga(sourceId, page));
|
|
144
|
+
} else if (action === "latest") {
|
|
145
|
+
result = unwrapResult<MangasPage>(exports.getLatestUpdates(sourceId, page));
|
|
146
|
+
} else {
|
|
147
|
+
// search - would need to store query
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
mangas = [...mangas, ...(result.mangas ?? [])];
|
|
151
|
+
hasNextPage = result.hasNextPage ?? false;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const manga = mangas[parseInt(mangaChoice)];
|
|
156
|
+
await showMangaDetails(exports, sourceId, manga);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.log(pc.dim("\nBye! š"));
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
async function showMangaDetails(
|
|
165
|
+
exports: TachiyomiExports,
|
|
166
|
+
sourceId: string,
|
|
167
|
+
manga: Manga
|
|
168
|
+
) {
|
|
169
|
+
console.log(pc.dim("\nFetching details..."));
|
|
170
|
+
|
|
171
|
+
const details = unwrapResult<Manga>(
|
|
172
|
+
exports.getMangaDetails(sourceId, manga.url)
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
console.log("\n" + pc.bold(pc.cyan(details.title || manga.title)));
|
|
176
|
+
if (details.author) console.log(pc.dim(`Author: ${details.author}`));
|
|
177
|
+
if (details.artist && details.artist !== details.author) {
|
|
178
|
+
console.log(pc.dim(`Artist: ${details.artist}`));
|
|
179
|
+
}
|
|
180
|
+
if (details.status !== undefined) {
|
|
181
|
+
const statusMap: Record<number, string> = { 1: "Ongoing", 2: "Completed", 3: "Licensed", 4: "Publishing finished", 5: "Cancelled", 6: "On hiatus" };
|
|
182
|
+
console.log(pc.dim(`Status: ${statusMap[details.status] || "Unknown"}`));
|
|
183
|
+
}
|
|
184
|
+
if (details.genres?.length) {
|
|
185
|
+
console.log(pc.dim(`Genres: ${details.genres.join(", ")}`));
|
|
186
|
+
}
|
|
187
|
+
if (details.description) {
|
|
188
|
+
const desc = details.description.replace(/<[^>]*>/g, "").slice(0, 300);
|
|
189
|
+
console.log(pc.dim(`\n${desc}${details.description.length > 300 ? "..." : ""}`));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
while (true) {
|
|
193
|
+
const detailAction = await select({
|
|
194
|
+
message: "What next?",
|
|
195
|
+
choices: [
|
|
196
|
+
{ name: "š View Chapters", value: "chapters" },
|
|
197
|
+
{ name: "š Show URL", value: "url" },
|
|
198
|
+
{ name: "š Raw JSON", value: "json" },
|
|
199
|
+
{ name: "ā¬
Back", value: "back" },
|
|
200
|
+
],
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (detailAction === "back") break;
|
|
204
|
+
|
|
205
|
+
if (detailAction === "url") {
|
|
206
|
+
console.log(pc.cyan(`\nURL: ${manga.url}`));
|
|
207
|
+
if (manga.thumbnailUrl) console.log(pc.cyan(`Thumbnail: ${manga.thumbnailUrl}`));
|
|
208
|
+
} else if (detailAction === "json") {
|
|
209
|
+
console.log("\n" + JSON.stringify(details, null, 2));
|
|
210
|
+
} else if (detailAction === "chapters") {
|
|
211
|
+
await showChapters(exports, sourceId, manga);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function showChapters(
|
|
217
|
+
exports: TachiyomiExports,
|
|
218
|
+
sourceId: string,
|
|
219
|
+
manga: Manga
|
|
220
|
+
) {
|
|
221
|
+
console.log(pc.dim("\nFetching chapters..."));
|
|
222
|
+
|
|
223
|
+
const chapters = unwrapResult<Chapter[]>(
|
|
224
|
+
exports.getChapterList(sourceId, manga.url)
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
if (!chapters?.length) {
|
|
228
|
+
console.log(pc.yellow("No chapters found."));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
console.log(pc.green(`\nFound ${chapters.length} chapters`));
|
|
233
|
+
|
|
234
|
+
while (true) {
|
|
235
|
+
const choices = [
|
|
236
|
+
...chapters.slice(0, 20).map((c, i) => ({
|
|
237
|
+
name: `${i + 1}. ${c.name}`,
|
|
238
|
+
value: String(i),
|
|
239
|
+
})),
|
|
240
|
+
...(chapters.length > 20 ? [{ name: `... and ${chapters.length - 20} more`, value: "info" }] : []),
|
|
241
|
+
{ name: "ā¬
Back", value: "back" },
|
|
242
|
+
];
|
|
243
|
+
|
|
244
|
+
const chapterChoice = await select({
|
|
245
|
+
message: "Select a chapter to view pages:",
|
|
246
|
+
choices,
|
|
247
|
+
pageSize: 15,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (chapterChoice === "back") break;
|
|
251
|
+
if (chapterChoice === "info") {
|
|
252
|
+
console.log(pc.dim(`\nTotal: ${chapters.length} chapters`));
|
|
253
|
+
console.log(pc.dim(`First: ${chapters[0].name}`));
|
|
254
|
+
console.log(pc.dim(`Last: ${chapters[chapters.length - 1].name}`));
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const chapter = chapters[parseInt(chapterChoice)];
|
|
259
|
+
await showPages(exports, sourceId, manga, chapter);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function showPages(
|
|
264
|
+
exports: TachiyomiExports,
|
|
265
|
+
sourceId: string,
|
|
266
|
+
manga: Manga,
|
|
267
|
+
chapter: Chapter
|
|
268
|
+
) {
|
|
269
|
+
console.log(pc.dim("\nFetching pages..."));
|
|
270
|
+
|
|
271
|
+
const pages = unwrapResult<Page[]>(
|
|
272
|
+
exports.getPageList(sourceId, chapter.url)
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
if (!pages?.length) {
|
|
276
|
+
console.log(pc.yellow("No pages found."));
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
console.log(pc.green(`\n${chapter.name} - ${pages.length} pages`));
|
|
281
|
+
|
|
282
|
+
while (true) {
|
|
283
|
+
const choices = [
|
|
284
|
+
{ name: "š„ Download all pages", value: "download-all" },
|
|
285
|
+
{ name: "š¼ļø Browse pages...", value: "browse" },
|
|
286
|
+
{ name: "š Show all URLs (JSON)", value: "json" },
|
|
287
|
+
{ name: "ā¬
Back", value: "back" },
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
const action = await select({
|
|
291
|
+
message: `${pages.length} pages:`,
|
|
292
|
+
choices,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
if (action === "back") break;
|
|
296
|
+
|
|
297
|
+
if (action === "json") {
|
|
298
|
+
console.log("\n" + JSON.stringify(pages, null, 2));
|
|
299
|
+
} else if (action === "download-all") {
|
|
300
|
+
await downloadChapter(exports, sourceId, manga, chapter, pages);
|
|
301
|
+
} else if (action === "browse") {
|
|
302
|
+
await browsePagesMenu(exports, sourceId, manga, chapter, pages);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function browsePagesMenu(
|
|
308
|
+
exports: TachiyomiExports,
|
|
309
|
+
sourceId: string,
|
|
310
|
+
manga: Manga,
|
|
311
|
+
chapter: Chapter,
|
|
312
|
+
pages: Page[]
|
|
313
|
+
) {
|
|
314
|
+
let lastSelectedIdx = 0;
|
|
315
|
+
|
|
316
|
+
while (true) {
|
|
317
|
+
const choices = [
|
|
318
|
+
...pages.map((p) => ({
|
|
319
|
+
name: `Page ${String(p.index + 1).padStart(3, "0")}: ${(p.imageUrl || "").slice(0, 60)}${(p.imageUrl || "").length > 60 ? "..." : ""}`,
|
|
320
|
+
value: String(p.index),
|
|
321
|
+
})),
|
|
322
|
+
{ name: "ā¬
Back", value: "back" },
|
|
323
|
+
];
|
|
324
|
+
|
|
325
|
+
const pageChoice = await select({
|
|
326
|
+
message: "Select a page:",
|
|
327
|
+
choices,
|
|
328
|
+
pageSize: 20,
|
|
329
|
+
default: String(lastSelectedIdx),
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
if (pageChoice === "back") break;
|
|
333
|
+
|
|
334
|
+
lastSelectedIdx = parseInt(pageChoice);
|
|
335
|
+
const page = pages[lastSelectedIdx];
|
|
336
|
+
await showSinglePage(exports, sourceId, manga, chapter, page);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function showSinglePage(
|
|
341
|
+
exports: TachiyomiExports,
|
|
342
|
+
sourceId: string,
|
|
343
|
+
manga: Manga,
|
|
344
|
+
chapter: Chapter,
|
|
345
|
+
page: Page
|
|
346
|
+
) {
|
|
347
|
+
const pageNum = String(page.index + 1).padStart(3, "0");
|
|
348
|
+
const imageUrl = page.imageUrl || "";
|
|
349
|
+
|
|
350
|
+
console.log(pc.cyan(`\nPage ${pageNum}`));
|
|
351
|
+
console.log(pc.dim(`Image: ${imageUrl || "(no direct URL)"}`));
|
|
352
|
+
|
|
353
|
+
const action = await select({
|
|
354
|
+
message: "Options:",
|
|
355
|
+
choices: [
|
|
356
|
+
{ name: "š„ Download this page", value: "download" },
|
|
357
|
+
{ name: "š Copy image URL", value: "copy" },
|
|
358
|
+
{ name: "ā¬
Back", value: "back" },
|
|
359
|
+
],
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
if (action === "back") return;
|
|
363
|
+
|
|
364
|
+
if (action === "copy") {
|
|
365
|
+
if (imageUrl) {
|
|
366
|
+
console.log(pc.green(`\n${imageUrl}`));
|
|
367
|
+
} else {
|
|
368
|
+
console.log(pc.yellow("\nNo direct image URL available"));
|
|
369
|
+
}
|
|
370
|
+
} else if (action === "download") {
|
|
371
|
+
await downloadSinglePage(exports, sourceId, manga, chapter, page);
|
|
372
|
+
}
|
|
373
|
+
// Returns to page list after action
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function downloadSinglePage(
|
|
377
|
+
exports: TachiyomiExports,
|
|
378
|
+
sourceId: string,
|
|
379
|
+
manga: Manga,
|
|
380
|
+
chapter: Chapter,
|
|
381
|
+
page: Page
|
|
382
|
+
) {
|
|
383
|
+
const pageNum = String(page.index + 1).padStart(3, "0");
|
|
384
|
+
const imageUrl = page.imageUrl || page.url || "";
|
|
385
|
+
|
|
386
|
+
if (!imageUrl) {
|
|
387
|
+
console.log(pc.red("No image URL available"));
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const mangaFolder = sanitizeFilename(manga.title);
|
|
392
|
+
const chapterFolder = sanitizeFilename(chapter.name);
|
|
393
|
+
const ext = imageUrl.match(/\.(jpe?g|png|gif|webp)/i)?.[1]?.toLowerCase() || "jpg";
|
|
394
|
+
const defaultPath = path.join("downloads", mangaFolder, chapterFolder, `${pageNum}.${ext}`);
|
|
395
|
+
|
|
396
|
+
const outputPath = await input({
|
|
397
|
+
message: "Save as:",
|
|
398
|
+
default: defaultPath,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// Create directory
|
|
402
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
403
|
+
|
|
404
|
+
console.log(pc.dim("\nDownloading..."));
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
const base64 = unwrapResult<string>(
|
|
408
|
+
exports.fetchImage(sourceId, page.url || "", imageUrl)
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
const buffer = Buffer.from(base64, "base64");
|
|
412
|
+
fs.writeFileSync(outputPath, buffer);
|
|
413
|
+
|
|
414
|
+
console.log(pc.green(`ā Saved ${outputPath} (${(buffer.length / 1024).toFixed(1)}KB)`));
|
|
415
|
+
} catch (err) {
|
|
416
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
417
|
+
console.log(pc.red(`ā Failed: ${msg}`));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function sanitizeFilename(name: string): string {
|
|
422
|
+
return name
|
|
423
|
+
.replace(/[<>:"/\\|?*]/g, "_")
|
|
424
|
+
.replace(/\s+/g, " ")
|
|
425
|
+
.trim()
|
|
426
|
+
.slice(0, 100);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function downloadChapter(
|
|
430
|
+
exports: TachiyomiExports,
|
|
431
|
+
sourceId: string,
|
|
432
|
+
manga: Manga,
|
|
433
|
+
chapter: Chapter,
|
|
434
|
+
pages: Page[]
|
|
435
|
+
) {
|
|
436
|
+
const mangaFolder = sanitizeFilename(manga.title);
|
|
437
|
+
const chapterFolder = sanitizeFilename(chapter.name);
|
|
438
|
+
const defaultDir = path.join("downloads", mangaFolder, chapterFolder);
|
|
439
|
+
|
|
440
|
+
const outputDir = await input({
|
|
441
|
+
message: "Download directory:",
|
|
442
|
+
default: defaultDir,
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// Create directory
|
|
446
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
447
|
+
|
|
448
|
+
console.log(pc.cyan(`\nDownloading ${pages.length} pages to ${outputDir}...\n`));
|
|
449
|
+
|
|
450
|
+
let downloaded = 0;
|
|
451
|
+
let failed = 0;
|
|
452
|
+
|
|
453
|
+
for (const page of pages) {
|
|
454
|
+
const pageNum = String(page.index + 1).padStart(3, "0");
|
|
455
|
+
const imageUrl = page.imageUrl || page.url || "";
|
|
456
|
+
|
|
457
|
+
if (!imageUrl) {
|
|
458
|
+
console.log(pc.red(`Page ${pageNum}: No image URL`));
|
|
459
|
+
failed++;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
process.stdout.write(pc.dim(`Page ${pageNum}...`));
|
|
465
|
+
|
|
466
|
+
// Fetch image through extension (handles headers, interceptors)
|
|
467
|
+
const base64 = unwrapResult<string>(
|
|
468
|
+
exports.fetchImage(sourceId, page.url || "", imageUrl)
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
// Detect format from URL or default to jpg
|
|
472
|
+
const ext = imageUrl.match(/\.(jpe?g|png|gif|webp)/i)?.[1]?.toLowerCase() || "jpg";
|
|
473
|
+
const filename = `${pageNum}.${ext}`;
|
|
474
|
+
const filepath = path.join(outputDir, filename);
|
|
475
|
+
|
|
476
|
+
// Decode base64 and save
|
|
477
|
+
const buffer = Buffer.from(base64, "base64");
|
|
478
|
+
fs.writeFileSync(filepath, buffer);
|
|
479
|
+
|
|
480
|
+
process.stdout.write(pc.green(` ā ${filename} (${(buffer.length / 1024).toFixed(1)}KB)\n`));
|
|
481
|
+
downloaded++;
|
|
482
|
+
} catch (err) {
|
|
483
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
484
|
+
process.stdout.write(pc.red(` ā ${msg.slice(0, 50)}\n`));
|
|
485
|
+
failed++;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
console.log(pc.green(`\nā Downloaded ${downloaded}/${pages.length} pages`));
|
|
490
|
+
if (failed > 0) {
|
|
491
|
+
console.log(pc.yellow(`ā ${failed} pages failed`));
|
|
492
|
+
}
|
|
493
|
+
console.log(pc.dim(`Saved to: ${path.resolve(outputDir)}`));
|
|
494
|
+
}
|
|
495
|
+
|
package/commands/info.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { buildCommand } from "@stricli/core";
|
|
2
|
+
import { loadOutputConfig } from "../config";
|
|
3
|
+
import { loadExtension } from "../lib/extension-loader";
|
|
4
|
+
import { printField, printHeader, printJson, printListItem } from "../lib/output";
|
|
5
|
+
|
|
6
|
+
export const info = buildCommand({
|
|
7
|
+
docs: {
|
|
8
|
+
brief: "Show extension information",
|
|
9
|
+
},
|
|
10
|
+
parameters: {
|
|
11
|
+
positional: {
|
|
12
|
+
kind: "tuple",
|
|
13
|
+
parameters: [
|
|
14
|
+
{
|
|
15
|
+
brief: "Extension path (e.g., en/mangapill)",
|
|
16
|
+
parse: String,
|
|
17
|
+
placeholder: "extension",
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
flags: {
|
|
22
|
+
json: {
|
|
23
|
+
kind: "boolean",
|
|
24
|
+
brief: "Output as JSON",
|
|
25
|
+
optional: true,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
func: async (flags: { json?: boolean }, extensionId: string) => {
|
|
30
|
+
const config = loadOutputConfig();
|
|
31
|
+
const { manifest, sources } = loadExtension(config.output, extensionId);
|
|
32
|
+
|
|
33
|
+
if (flags.json) {
|
|
34
|
+
printJson({ manifest, sources });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
printHeader("Extension Info");
|
|
39
|
+
printField("Name", manifest.name);
|
|
40
|
+
printField("Package", manifest.pkg);
|
|
41
|
+
printField("Version", manifest.version);
|
|
42
|
+
printField("NSFW", manifest.nsfw);
|
|
43
|
+
|
|
44
|
+
printHeader(`Sources (${sources.length})`);
|
|
45
|
+
for (const s of sources) {
|
|
46
|
+
printListItem(`${s.name} (${s.lang}): ${s.baseUrl}`);
|
|
47
|
+
console.log(` ID: ${s.id}`);
|
|
48
|
+
console.log(` Supports Latest: ${s.supportsLatest}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (manifest.authors?.length) {
|
|
52
|
+
printHeader(`Authors (${manifest.authors.length})`);
|
|
53
|
+
for (const a of manifest.authors) {
|
|
54
|
+
printListItem(`${a.github || a.name}: ${a.commits} commits (since ${a.firstCommit})`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
package/commands/list.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { buildCommand } from "@stricli/core";
|
|
2
|
+
import { loadOutputConfig } from "../config";
|
|
3
|
+
import { listExtensions } from "../lib/extension-loader";
|
|
4
|
+
import { printHeader, printJson, printListItem } from "../lib/output";
|
|
5
|
+
|
|
6
|
+
export const list = buildCommand({
|
|
7
|
+
docs: {
|
|
8
|
+
brief: "List available built extensions",
|
|
9
|
+
},
|
|
10
|
+
parameters: {
|
|
11
|
+
flags: {
|
|
12
|
+
json: {
|
|
13
|
+
kind: "boolean",
|
|
14
|
+
brief: "Output as JSON",
|
|
15
|
+
optional: true,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
func: async (flags: { json?: boolean }) => {
|
|
20
|
+
const config = loadOutputConfig();
|
|
21
|
+
const extensions = listExtensions(config.output);
|
|
22
|
+
|
|
23
|
+
if (flags.json) {
|
|
24
|
+
printJson(extensions);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
printHeader(`Extensions (${extensions.length})`);
|
|
29
|
+
if (extensions.length === 0) {
|
|
30
|
+
console.log("No extensions found.");
|
|
31
|
+
console.log(`Looked in: ${config.output}`);
|
|
32
|
+
} else {
|
|
33
|
+
for (const ext of extensions) {
|
|
34
|
+
printListItem(ext);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|