diff --git a/web/src/engine/websites/LuaScans.ts b/web/src/engine/websites/LuaScans.ts new file mode 100644 index 0000000000..947d97ddec --- /dev/null +++ b/web/src/engine/websites/LuaScans.ts @@ -0,0 +1,13 @@ +import { Tags } from '../Tags'; +import icon from './LuaScans.webp'; +import { HeanCMS } from './templates/HeanCMS'; + +export default class extends HeanCMS { + public constructor() { + super('luascans', 'Lua Scans', 'https://luacomic.org', Tags.Media.Manhwa, Tags.Media.Manhua, Tags.Language.English, Tags.Source.Scanlator); + } + + public override get Icon() { + return icon; + } +} \ No newline at end of file diff --git a/web/src/engine/websites/LuaScans.webp b/web/src/engine/websites/LuaScans.webp new file mode 100644 index 0000000000..fbc0a35ac0 Binary files /dev/null and b/web/src/engine/websites/LuaScans.webp differ diff --git a/web/src/engine/websites/LuaScans_e2e.ts b/web/src/engine/websites/LuaScans_e2e.ts new file mode 100644 index 0000000000..9d362ec229 --- /dev/null +++ b/web/src/engine/websites/LuaScans_e2e.ts @@ -0,0 +1,22 @@ +import { TestFixture } from '../../../test/WebsitesFixture'; + +new TestFixture({ + plugin: { + id: 'luascans', + title: 'Lua Scans' + }, + container: { + url: 'https://luacomic.org/series/truck-driver-tag-team-match', + id: JSON.stringify({ id: '107', slug: 'truck-driver-tag-team-match'}), + title: 'Truck Driver Tag Team Match' + }, + child: { + id: JSON.stringify({ id: '5613', slug: 'chapter-7' }), + title: 'Chapter 7' + }, + entry: { + index: 0, + size: 465_292, + type: 'image/webp' + } +}).AssertWebsite(); \ No newline at end of file diff --git a/web/src/engine/websites/OmegaScans.ts b/web/src/engine/websites/OmegaScans.ts new file mode 100644 index 0000000000..8049fb5153 --- /dev/null +++ b/web/src/engine/websites/OmegaScans.ts @@ -0,0 +1,14 @@ +import { Tags } from '../Tags'; +import icon from './OmegaScans.webp'; +import { HeanCMS } from './templates/HeanCMS'; + +export default class extends HeanCMS { + + public constructor() { + super('omegascans', 'OmegaScans', 'https://omegascans.org', Tags.Media.Manga, Tags.Media.Manhwa, Tags.Media.Manhua, Tags.Media.Novel, Tags.Language.English, Tags.Source.Scanlator, Tags.Rating.Pornographic); + } + + public override get Icon() { + return icon; + } +} \ No newline at end of file diff --git a/web/src/engine/websites/OmegaScans.webp b/web/src/engine/websites/OmegaScans.webp new file mode 100644 index 0000000000..4a9338b490 Binary files /dev/null and b/web/src/engine/websites/OmegaScans.webp differ diff --git a/web/src/engine/websites/OmegaScans_e2e.ts b/web/src/engine/websites/OmegaScans_e2e.ts new file mode 100644 index 0000000000..5c4b3bf52e --- /dev/null +++ b/web/src/engine/websites/OmegaScans_e2e.ts @@ -0,0 +1,22 @@ +import { TestFixture } from '../../../test/WebsitesFixture'; + +new TestFixture({ + plugin: { + id: 'omegascans', + title: 'OmegaScans' + }, + container: { + url: 'https://omegascans.org/series/trapped-in-the-academys-eroge', + id: JSON.stringify({ id: '8', slug: 'trapped-in-the-academys-eroge' }), + title: `Trapped in the Academy's Eroge` + }, + child: { + id: JSON.stringify({ id: '3245', slug: 'chapter-76' }), + title: 'Chapter 76' + }, + entry: { + index: 1, + size: 1_577_008, + type: 'image/jpeg' + } +}).AssertWebsite(); diff --git a/web/src/engine/websites/QuantumScans.ts b/web/src/engine/websites/QuantumScans.ts index 0ebbc9c5c0..febbe37122 100644 --- a/web/src/engine/websites/QuantumScans.ts +++ b/web/src/engine/websites/QuantumScans.ts @@ -1,20 +1,11 @@ import { Tags } from '../Tags'; import icon from './QuantumScans.webp'; -import { DecoratableMangaScraper } from '../providers/MangaPlugin'; -import * as Common from './decorators/Common'; -import * as KeyoApp from './templates/KeyoApp'; - -// TODO : Change to HeanCMS template - -@Common.MangaCSS(/^{origin}\/series\/[^/]+\/$/, KeyoApp.queryMangaTitle) -@Common.MangasSinglePagesCSS([KeyoApp.queryMangaPath], KeyoApp.queryManga, Common.AnchorInfoExtractor(true)) -@Common.ChaptersSinglePageCSS(KeyoApp.queryChapters, Common.AnchorInfoExtractor(true)) -@Common.PagesSinglePageJS(KeyoApp.pagesScript, 500) -@Common.ImageAjax(true) -export default class extends DecoratableMangaScraper { +import { HeanCMS } from './templates/HeanCMS'; +export default class extends HeanCMS { public constructor() { super('quantumscans', 'Quantum Scans', 'https://quantumscans.org', Tags.Media.Manhwa, Tags.Media.Manhua, Tags.Language.English, Tags.Source.Scanlator); + this.mediaUrl = 'https://cdn.meowing.org'; } public override get Icon() { diff --git a/web/src/engine/websites/QuantumScans_e2e.ts b/web/src/engine/websites/QuantumScans_e2e.ts index 5726042dbd..6c62daf5e1 100644 --- a/web/src/engine/websites/QuantumScans_e2e.ts +++ b/web/src/engine/websites/QuantumScans_e2e.ts @@ -1,5 +1,4 @@ -/* NW.js crash on website initialize => CloudFlare -import { TestFixture } from '../../../test/WebsitesFixture'; +import { TestFixture } from '../../../test/WebsitesFixture'; new TestFixture({ plugin: { @@ -7,18 +6,17 @@ new TestFixture({ title: 'Quantum Scans' }, container: { - url: 'https://quantumscans.org/series/628e2319b4b/', - id: '/series/628e2319b4b/', - title: 'Celestial Phase' + url: 'https://quantumscans.org/series/celestial-phenomenon', + id: JSON.stringify({ id: '5', slug: 'celestial-phenomenon' }), + title: 'Celestial Phenomenon' }, child: { - id: '/chapter/628e2319b4b-628e242ce6f/', + id: JSON.stringify({ id: '435', slug: 'chapter-47' }), title: 'Chapter 47' }, entry: { - index: 0, + index: 1, size: 775_821, type: 'image/jpeg' } -}).AssertWebsite(); -*/ \ No newline at end of file +}).AssertWebsite(); \ No newline at end of file diff --git a/web/src/engine/websites/ReaperScans.ts b/web/src/engine/websites/ReaperScans.ts index 3cbffb7b64..1de5a9f7e5 100644 --- a/web/src/engine/websites/ReaperScans.ts +++ b/web/src/engine/websites/ReaperScans.ts @@ -1,87 +1,22 @@ import { Tags } from '../Tags'; -import icon from './ReaperScans.webp'; -import { type MangaPlugin, DecoratableMangaScraper, Manga, Chapter, type Page, type MangaScraper } from '../providers/MangaPlugin'; -import * as Common from './decorators/Common'; -import { Priority, TaskPool } from '../taskpool/TaskPool'; -import { RateLimit } from '../taskpool/RateLimit'; -import { Numeric } from '../SettingsManager'; -import { WebsiteResourceKey as R } from '../../i18n/ILocale'; import { FetchJSON } from '../platform/FetchProvider'; - -type APIResult = { - data: T -} - -type APIManga = { - id: number, - title: string -} - -type APIChapter = { - chapter_slug: string, - chapter_name: string, - chapter_title: string | null, - series: { - series_slug: string - } -} - -function PageLinkExtractor(this: MangaScraper, element: HTMLImageElement): string { - const url = new URL(element.getAttribute('src'), this.URI); - return url.searchParams.get('url') ? decodeURIComponent(url.searchParams.get('url')) : url.href; -} - -@Common.ImageAjax(true) -export default class extends DecoratableMangaScraper { - - private readonly interactionTaskPool = new TaskPool(1, RateLimit.PerMinute(15)); - private readonly apiUrl = 'https://api.reaperscans.com'; +import { Chapter, type Manga } from '../providers/MangaPlugin'; +import icon from './ReaperScans.webp'; +import { HeanCMS, type APIChapter, type APIResult, type APIMediaID } from './templates/HeanCMS'; +export default class extends HeanCMS { public constructor() { super('reaperscans', 'Reaper Scans', 'https://reaperscans.com', Tags.Media.Manhwa, Tags.Media.Manhua, Tags.Language.English); - this.Settings.throttle = new Numeric('throttle.interactive', R.Plugin_Settings_ThrottlingInteraction, R.Plugin_Settings_ThrottlingInteractionInfo, 15, 1, 60).Subscribe(value => this.interactionTaskPool.RateLimit = RateLimit.PerMinute(value)); - } - - public override get Icon() { - return icon; - } - - public override ValidateMangaURL(url: string): boolean { - return new RegExpSafe(`^${this.URI.origin}/series/[^/]+$`).test(url); - } - - public override async FetchManga(provider: MangaPlugin, url: string): Promise { - const slug = url.split('/').pop(); - const manga = await this.interactionTaskPool.Add(async () => FetchJSON(new Request(new URL(`/series/${slug}`, this.apiUrl))), Priority.Normal); - return new Manga(this, provider, manga.id.toString(), manga.title.trim()); - } - - public override async FetchMangas(provider: MangaPlugin): Promise { - const mangaList: Manga[] = []; - const uri = new URL(`/query`, this.apiUrl); - uri.searchParams.set('perPage', '100'); - uri.searchParams.set('series_type', 'Comic'); - uri.searchParams.set('adult', 'true'); - for (let page = 1, run = true; run; page++) { - const mangas = await this.interactionTaskPool.Add(async () => this.GetMangasFromPage(uri, page, provider), Priority.Normal); - mangas.length > 0 ? mangaList.push(...mangas) : run = false; - } - return mangaList; - } - - private async GetMangasFromPage(uri: URL, page: number, provider: MangaPlugin): Promise { - uri.searchParams.set('page', page.toString()); - const { data } = await FetchJSON>(new Request(uri)); - return data.map(item => new Manga(this, provider, item.id.toString(), item.title.trim())); + this.mediaUrl = 'https://media.reaperscans.com/file/4SRBHm'; } public override async FetchChapters(manga: Manga): Promise { - const { data } = await FetchJSON>(new Request(new URL(`/chapters/${manga.Identifier}?perPage=9999`, this.apiUrl))); - return data.map(item => new Chapter(this, manga, `/series/${item.series.series_slug}/${item.chapter_slug}`, [item.chapter_name, item.chapter_title || ''].join(' ').trim())); + const { id } = JSON.parse(manga.Identifier) as APIMediaID; + const { data } = await FetchJSON>(new Request(new URL(`/chapters/${id}?perPage=9999`, this.apiUrl))); + return data.map(item => new Chapter(this, manga, JSON.stringify({ id: item.id.toString(), slug: item.chapter_slug }), [item.chapter_name, item.chapter_title || ''].join(' ').trim())); } - public override async FetchPages(chapter: Chapter): Promise { - const pages = await Common.FetchPagesSinglePageCSS.call(this, chapter, 'div#content div.container > div img:not([alt *= "thumb"])', PageLinkExtractor); - return pages.filter(page => !page.Link.href.match(/\._000_slr\.jpg$/));//incorrect image in Solo Leveling Ragnarok chapter 1 + public override get Icon() { + return icon; } } diff --git a/web/src/engine/websites/ReaperScans_e2e.ts b/web/src/engine/websites/ReaperScans_e2e.ts index e3e529f56e..d218858797 100755 --- a/web/src/engine/websites/ReaperScans_e2e.ts +++ b/web/src/engine/websites/ReaperScans_e2e.ts @@ -1,5 +1,6 @@ import { TestFixture } from '../../../test/WebsitesFixture'; +//test with local cdn new TestFixture({ plugin: { id: 'reaperscans', @@ -7,19 +8,38 @@ new TestFixture({ }, container: { url: 'https://reaperscans.com/series/990k-ex-life-hunter', - id: '49', + id: JSON.stringify({ id: '49', slug: '990k-ex-life-hunter' }), title: '990k Ex-Life Hunter', - timeout: 15000 }, child: { - id: '/series/990k-ex-life-hunter/chapter-74', + id: JSON.stringify({ id: '6145', slug: 'chapter-74' }), title: 'Chapter 74', - timeout: 10000 - }, entry: { index: 0, size: 432_585, type: 'image/jpeg' } +}).AssertWebsite(); + +//test with s3 cdn +new TestFixture({ + plugin: { + id: 'reaperscans', + title: 'Reaper Scans' + }, + container: { + url: 'https://reaperscans.com/series/regressing-as-the-reincarnated-bastard-of-the-sword-clan-811', + id: JSON.stringify({ id: '228', slug: 'regressing-as-the-reincarnated-bastard-of-the-sword-clan-811' }), + title: 'Regressing as the Reincarnated Bastard of the Sword Clan', + }, + child: { + id: JSON.stringify({ id: '20499', slug: 'chapter-34' }), + title: 'Chapter 34', + }, + entry: { + index: 0, + size: 367_069, + type: 'image/jpeg' + } }).AssertWebsite(); \ No newline at end of file diff --git a/web/src/engine/websites/_index.ts b/web/src/engine/websites/_index.ts index 8d38481b85..a95166390d 100755 --- a/web/src/engine/websites/_index.ts +++ b/web/src/engine/websites/_index.ts @@ -281,6 +281,7 @@ export { default as LineWebtoon } from './LineWebtoon'; export { default as LineWebtoonTranslate } from './LineWebtoonTranslate'; export { default as LittleGarden } from './LittleGarden'; export { default as LowerWorld } from './LowerWorld'; +export { default as LuaScans } from './LuaScans'; export { default as LumosKomik } from './LumosKomik'; export { default as LunarScans } from './LunarScans'; export { default as LunaScans } from './LunaScans'; @@ -507,6 +508,7 @@ export { default as NoxScans } from './NoxScans'; export { default as NyxScans } from './NyxScans'; export { default as OlimpoScans } from './OlimpoScans'; export { default as OlympusScanlation } from './OlympusScanlation'; +export { default as OmegaScans } from './OmegaScans'; export { default as OnMangaMe } from './OnMangaMe'; export { default as Opiatoon } from './Opiatoon'; export { default as Oremanga } from './Oremanga'; diff --git a/web/src/engine/websites/templates/HeanCMS.ts b/web/src/engine/websites/templates/HeanCMS.ts new file mode 100644 index 0000000000..17c72cc3cf --- /dev/null +++ b/web/src/engine/websites/templates/HeanCMS.ts @@ -0,0 +1,136 @@ +import { Exception } from '../../Error'; +import { FetchJSON } from '../../platform/FetchProvider'; +import {type MangaPlugin, Manga, Chapter, Page, DecoratableMangaScraper } from '../../providers/MangaPlugin'; +import type { Priority } from '../../taskpool/TaskPool'; +import * as Common from '../decorators/Common'; +import { WebsiteResourceKey as R } from '../../../i18n/ILocale'; + +// TODO: Add Novel support + +type APIManga = { + title: string + id: number, + series_type: string, + series_slug: string, +} + +export type APIResult = { + data: T +} + +export type APIChapter = { + index: string, + id: number, + chapter_name: string, + chapter_title: string, + chapter_slug: string, +} + +type APIPages = { + chapter_type: string, + paywall: boolean, + data: string[] | string + chapter: { + chapter_type: string, + storage: string, + chapter_data?: { + images?: string[], + files: { + url: string + }[] + + } + } +} + +export type APIMediaID = { + id: string, + slug: string +} + +export type PageType = { + type: string; +} + +export class HeanCMS extends DecoratableMangaScraper { + + protected apiUrl = this.URI.origin.replace('://', '://api.'); + protected mediaUrl = this.apiUrl; + + public override ValidateMangaURL(url: string): boolean { + return new RegExpSafe(`^${this.URI.origin}/series/[^/]+$`).test(url); + } + + public override async FetchManga(provider: MangaPlugin, url: string): Promise { + const slug = new URL(url).pathname.split('/').at(-1); + const { title, series_slug, id } = await FetchJSON(this.CreateRequest(new URL(`${this.apiUrl}/series/${slug}`))); + return new Manga(this, provider, JSON.stringify({ + id: id.toString(), + slug: series_slug + }), title); + } + + public override async FetchMangas(provider: MangaPlugin): Promise { + const mangaList: Manga[] = []; + for (const adult of [true, false]) { //adult flag mean ONLY adult, false mean ONLY all ages... Talk about stupid. Old api ignore that flag + for (let page = 1, run = true; run; page++) { + const mangas = await this.GetMangaFromPage(provider, page, adult); + mangas.length > 0 ? mangaList.push(...mangas) : run = false; + } + } + return mangaList.distinct();//filter in case of old api + } + + private async GetMangaFromPage(provider: MangaPlugin, page: number, adult: boolean): Promise { + const request = this.CreateRequest(new URL(`${this.apiUrl}/query?perPage=100&page=${page}&adult=${adult}`)); + const { data } = await FetchJSON>(request); + return !data.length ? [] : data.map((manga) => new Manga(this, provider, JSON.stringify({ id: manga.id.toString(), slug: manga.series_slug }), manga.title)); + } + + public override async FetchChapters(manga: Manga): Promise { + const mangaid: APIMediaID = JSON.parse(manga.Identifier); + const { data } = await FetchJSON>(this.CreateRequest(new URL(`${this.apiUrl}/chapter/query?series_id=${mangaid.id}&perPage=9999&page=1`))); + return data.map(chapter => new Chapter(this, manga, JSON.stringify({ + id: chapter.id.toString(), + slug: chapter.chapter_slug, + }), `${chapter.chapter_name} ${chapter.chapter_title || ''}`.trim())); + } + + public override async FetchPages(chapter: Chapter): Promise[]> { + const chapterid: APIMediaID = JSON.parse(chapter.Identifier); + const mangaid: APIMediaID = JSON.parse(chapter.Parent.Identifier); + const data = await FetchJSON(this.CreateRequest(new URL(`${this.apiUrl}/chapter/${mangaid.slug}/${chapterid.slug}`))); + + if (data.paywall) { + throw new Exception(R.Plugin_Common_Chapter_UnavailableError); + } + + if (data.chapter.chapter_type.toLowerCase() === 'novel') { + throw new Exception(R.Plugin_HeanCMS_ErrorNovelsNotSupported); + } + //old API vs new + const listImages = Array.isArray(data.data) ? data.data as string[] : data.chapter.chapter_data.images ? data.chapter.chapter_data.images : data.chapter.chapter_data.files.map(file => file.url); + return listImages.map(image => new Page(this, chapter, this.ComputePageUrl(image, data.chapter.storage), { type: data.chapter.chapter_type })); + } + + protected ComputePageUrl(image: string, storage: string): URL { + if (/^http(s)?:\/\//.test(image)) return new URL(image); + switch (storage) { + case "s3": return new URL(image); + case "local": return new URL(`${this.mediaUrl}/${image}`); + } + } + + public override async FetchImage(page: Page, priority: Priority, signal: AbortSignal, detectMimeType = true, deProxifyLink = true): Promise { + if (page.Parameters?.type === 'Comic') { + return Common.FetchImageAjax.call(this, page, priority, signal, detectMimeType, deProxifyLink); + } else throw new Exception(R.Plugin_HeanCMS_ErrorNovelsNotSupported); + } + + private CreateRequest(endpoint: URL): Request { + return new Request(endpoint, {headers: { + Referer: this.URI.href + }}); + } + +} diff --git a/web/src/engine/websites/templates/HeanCMS_e2e.ts b/web/src/engine/websites/templates/HeanCMS_e2e.ts new file mode 100644 index 0000000000..8def00b8c0 --- /dev/null +++ b/web/src/engine/websites/templates/HeanCMS_e2e.ts @@ -0,0 +1,4 @@ +import '../LuaScans_e2e'; +import '../OmegaScans_e2e'; +import '../QuantumScans_e2e'; +import '../ReaperScans_e2e'; diff --git a/web/src/i18n/ILocale.ts b/web/src/i18n/ILocale.ts index 67b565113b..24619b0ced 100644 --- a/web/src/i18n/ILocale.ts +++ b/web/src/i18n/ILocale.ts @@ -358,6 +358,11 @@ export enum WebsiteResourceKey { Plugin_SheepScanlations_Settings_PasswordInfo = 'Plugin_SheepScanlations_Settings_PasswordInfo', } +// [SECTION]: Template : HEANCMS +export enum WebsiteResourceKey { + Plugin_HeanCMS_ErrorNovelsNotSupported = 'Plugin_HeanCMS_ErrorNovelsNotSupported' +} + export const VariantResourceKey = { ...TagCategoryResourceKey, ...TagResourceKey, diff --git a/web/src/i18n/locales/en_US.ts b/web/src/i18n/locales/en_US.ts index cb8e629599..e1b27f0995 100644 --- a/web/src/i18n/locales/en_US.ts +++ b/web/src/i18n/locales/en_US.ts @@ -315,6 +315,8 @@ const translations: VariantResource = { Plugin_CuuTruyen_Error_NotProcessed: 'This chapter is still processing, please try again later.', + Plugin_HeanCMS_ErrorNovelsNotSupported: 'Novels are not (yet) supported in Hakuneko !', + Plugin_PocketComics_LanguageMismatchError: 'Unable to find manga {0} for selected language {1}', Plugin_SheepScanlations_Settings_Username: 'Username',