diff --git a/web/src/engine/transformers/BookmarkConverter.ts b/web/src/engine/transformers/BookmarkConverter.ts index 80debb6c28..ac1d146e14 100644 --- a/web/src/engine/transformers/BookmarkConverter.ts +++ b/web/src/engine/transformers/BookmarkConverter.ts @@ -7,9 +7,10 @@ import type { BookmarkSerialized } from '../providers/Bookmark'; * @remarks Only exported for testing */ export const legacyWebsiteIdentifierMap = new Map([ + [ 'allanimesite', 'allmangato' ], + [ 'apolltoons', 'mundomanhwa' ], [ 'aresnov', 'scarmanga' ], [ 'azoramanga', 'azoraworld' ], - [ 'apolltoons', 'mundomanhwa' ], [ 'bacamangaorg', 'bacamanga' ], [ 'bananascan', 'harmonyscan' ], [ 'blogtruyen', 'blogtruyenmoi' ], diff --git a/web/src/engine/transformers/BookmarkConverter_test.ts b/web/src/engine/transformers/BookmarkConverter_test.ts index ce8ce4159a..70fcd16bd1 100644 --- a/web/src/engine/transformers/BookmarkConverter_test.ts +++ b/web/src/engine/transformers/BookmarkConverter_test.ts @@ -9,8 +9,9 @@ import { Key } from '../SettingsGlobal'; import { GetLocale } from '../../i18n/Localization'; const legacyWebsiteIdentifierMapTestCases = [ - { sourceID: 'aresnov', targetID: 'scarmanga' }, + { sourceID: 'allanimesite', targetID: 'allmangato' }, { sourceID: 'apolltoons', targetID: 'mundomanhwa' }, + { sourceID: 'aresnov', targetID: 'scarmanga' }, { sourceID: 'azoramanga', targetID: 'azoraworld' }, { sourceID: 'bacamangaorg', targetID: 'bacamanga' }, { sourceID: 'bananascan', targetID: 'harmonyscan' }, diff --git a/web/src/engine/websites/AllMangaTo.ts b/web/src/engine/websites/AllMangaTo.ts new file mode 100644 index 0000000000..0d63fa9900 --- /dev/null +++ b/web/src/engine/websites/AllMangaTo.ts @@ -0,0 +1,148 @@ +import { Tags } from '../Tags'; +import icon from './AllMangaTo.webp'; +import { Chapter, Page } from '../providers/MangaPlugin'; +import { DecoratableMangaScraper, Manga, type MangaPlugin } from '../providers/MangaPlugin'; +import * as Common from './decorators/Common'; +import { FetchCSS, FetchJSON } from '../platform/FetchProvider'; + +type GraphQLResult<T> = { + data: T +}; + +type APIMangas = { + mangas: { + edges: APIManga[], + } +} + +type APIManga = { + _id: string, + englishName: string | null, + name: string, + availableChaptersDetail: Record<string, string[]> +} + +type APIChapters = { + manga: APIManga +} + +type ChapterID = { + id: string, + translation: string +} + +type APIPages = { + chapterPages: { + edges: { + pictureUrlHead: string, + pictureUrls: { + url: string + }[] + }[] + } +} + +@Common.ImageAjax() +export default class extends DecoratableMangaScraper { + + private readonly apiUrl = 'https://api.allanime.day/api'; + + public constructor() { + super('allmangato', `AllManga.to`, 'https://allmanga.to', Tags.Media.Manga, Tags.Media.Manhua, Tags.Media.Manhwa, Tags.Language.Multilingual, Tags.Source.Aggregator); + } + + public override get Icon() { + return icon; + } + + public override ValidateMangaURL(url: string): boolean { + return new RegExpSafe(`^${this.URI.origin}/manga/[^/]+/[^/]+$`).test(url); + } + + public override async FetchManga(provider: MangaPlugin, url: string): Promise<Manga> { + const title = (await FetchCSS(new Request(new URL(url)), 'ol.breadcrumb li:last-of-type')).shift().textContent.trim(); + return new Manga(this, provider, url.match(/\/manga\/([^/]+)\//)[1], title); + } + + public override async FetchMangas(provider: MangaPlugin): Promise<Manga[]> { + const mangaList: Manga[] = []; + for (let page = 1, run = true; run; page++) { + await new Promise(resolve => setTimeout(resolve, 200)); + const mangas = await this.GetMangasFromPage(page, provider); + mangas.length > 0 ? mangaList.push(...mangas) : run = false; + } + return mangaList; + } + + private async GetMangasFromPage(page: number, provider: MangaPlugin): Promise<Manga[]> { + const jsonVariables = { + search: { + isManga: true, + allowAdult: true, + allowUnknown: true + }, + limit: 26, //impossible to change + page: page, + translationType: 'sub', + countryOrigin: 'ALL' + }; + const jsonExtensions = { + persistedQuery: { + version: 1, + sha256Hash: 'a27e57ef5de5bae714db701fb7b5cf57e13d57938fc6256f7d5c70a975d11f3d' + } + }; + + const data = await this.FetchGraphQL<APIMangas>(jsonVariables, jsonExtensions); + return data?.mangas?.edges ? data.mangas.edges.map(manga => new Manga(this, provider, manga._id, manga.englishName ?? manga.name)) : []; + } + + public override async FetchChapters(manga: Manga): Promise<Chapter[]> { + const jsonVariables = { + _id: manga.Identifier, + }; + const jsonExtensions = { + persistedQuery: { + version: 1, + sha256Hash: 'a42e1106694628f5e4eaecd8d7ce0c73895a22a3c905c29836e2c220cf26e55f' + } + }; + const { manga: { availableChaptersDetail } } = await this.FetchGraphQL<APIChapters>(jsonVariables, jsonExtensions); + return Object.keys(availableChaptersDetail).reduce((accumulator: Chapter[], key) => { + const chapters = availableChaptersDetail[key].map(chapter => new Chapter(this, manga, JSON.stringify({ id: chapter, translation: key }), `Chapter ${chapter} [${key}]`)); + accumulator.push(...chapters); + return accumulator; + }, []); + } + + public override async FetchPages(chapter: Chapter): Promise<Page[]> { + const { id, translation }: ChapterID = JSON.parse(chapter.Identifier); + const jsonVariables = { + mangaId: chapter.Parent.Identifier, + translationType: translation, + chapterString: id, + limit: 10, + offset: 0 + }; + const jsonExtensions = { + persistedQuery: { + version: 1, + sha256Hash: '121996b57011b69386b65ca8fc9e202046fc20bf68b8c8128de0d0e92a681195' + } + }; + const { chapterPages: { edges } } = await this.FetchGraphQL<APIPages>(jsonVariables, jsonExtensions); + const source = edges.find(source => source.pictureUrlHead); + return source.pictureUrls.map(picture => new Page(this, chapter, new URL(picture.url, source.pictureUrlHead))); + } + + private async FetchGraphQL<T extends JSONElement>(variables: JSONObject, extensions: JSONObject): Promise<T> { + const url = new URL(`?variables=${JSON.stringify(variables)}&extensions=${JSON.stringify(extensions)}`, this.apiUrl); + const { data } = await FetchJSON<GraphQLResult<T>>(new Request(url, { + headers: { + Origin: this.URI.origin + } + })); + return data; + } + +} \ No newline at end of file diff --git a/web/src/engine/websites/AllMangaTo.webp b/web/src/engine/websites/AllMangaTo.webp new file mode 100644 index 0000000000..73d8c641bb Binary files /dev/null and b/web/src/engine/websites/AllMangaTo.webp differ diff --git a/web/src/engine/websites/AllMangaTo_e2e.ts b/web/src/engine/websites/AllMangaTo_e2e.ts new file mode 100644 index 0000000000..3aa395b181 --- /dev/null +++ b/web/src/engine/websites/AllMangaTo_e2e.ts @@ -0,0 +1,24 @@ +import { TestFixture } from '../../../test/WebsitesFixture'; + +const config = { + plugin: { + id: 'allmangato', + title: 'AllManga.to' + }, + container: { + url: 'https://allmanga.to/manga/kFvrdRcbubPjrhr63/yuan-zun', + id: 'kFvrdRcbubPjrhr63', + title: 'Yuan Zun' + }, + child: { + id: JSON.stringify({id: '643.5', translation: 'sub'}), + title: 'Chapter 643.5 [sub]' + }, + entry: { + index: 0, + size: 714_974, + type: 'image/jpeg' + } +}; + +new TestFixture(config).AssertWebsite(); \ No newline at end of file diff --git a/web/src/engine/websites/_index.ts b/web/src/engine/websites/_index.ts index 9f72c71025..fb069213ef 100755 --- a/web/src/engine/websites/_index.ts +++ b/web/src/engine/websites/_index.ts @@ -6,6 +6,7 @@ export { default as AGS } from './AGS'; export { default as Ainzscans } from './Ainzscans'; export { default as Akuma } from './Akuma'; export { default as AllHentai } from './AllHentai'; +export { default as AllMangaTo } from './AllMangaTo'; export { default as AllPornComic } from './AllPornComic'; export { default as Alphapolis } from './Alphapolis'; export { default as AmuyScan } from './AmuyScan';