diff --git a/web/src/engine/websites/MangaLib.ts b/web/src/engine/websites/MangaLib.ts index 0db8bbcb56..5596532c72 100644 --- a/web/src/engine/websites/MangaLib.ts +++ b/web/src/engine/websites/MangaLib.ts @@ -1,46 +1,125 @@ import { Tags } from '../Tags'; import icon from './MangaLib.webp'; -import { DecoratableMangaScraper } from '../providers/MangaPlugin'; +import { Chapter, DecoratableMangaScraper, Manga, type MangaPlugin, Page } from '../providers/MangaPlugin'; import * as Common from './decorators/Common'; +import { FetchJSON } from '../platform/FetchProvider'; +import { Priority, TaskPool } from '../taskpool/TaskPool'; +import { RateLimit } from '../taskpool/RateLimit'; -function MangaExtractor(anchor: HTMLAnchorElement) { - return { - id: anchor.pathname, - title: anchor.querySelector('.media-card__title').textContent.trim() - }; -} - -const chapterScript = ` - new Promise(resolve => { - const chapters = window.__DATA__.chapters.list.map(entry => { - return { - id: '/' + window.__DATA__.manga.slug + '/v' + entry.chapter_volume + '/c' + entry.chapter_number, - title: entry.chapter_number + (entry.chapter_name ? ' - ' + entry.chapter_name : '') - }; - }); - resolve(chapters); - }); -`; - -const pageScript = ` - new Promise(resolve => { - resolve(window.__pg.map(page => window.__info.servers.main + window.__info.img.url + page.u)); - }); -`; - -@Common.MangaCSS(/^{origin}\/[^/]+(\?section=info)?$/, 'div.media-name__body div.media-name__main') -@Common.MangasMultiPageCSS('/manga-list?page={page}', 'div.media-card-wrap a.media-card', 1, 1, 0, MangaExtractor) -@Common.ChaptersSinglePageJS(chapterScript, 500) -@Common.PagesSinglePageJS(pageScript, 500) -@Common.ImageAjax() +type ImageServers = { + imageServers: ImageServer[] +} + +type ImageServer = { + id: string, + url: string, + site_ids: number[] +} + +type APIResult = { + data: T, + meta: { + has_next_page: boolean, + } +} + +type APIManga = { + id: number, + rus_name: string, + slug_url: string +} + +type APIChapter = { + volume: string, + number: string, + name: string, + branches_count: number, + branches: { + id: number, + branch_id: number | null, + teams: { + name: string + }[] + }[] +} + +type APIPages = { + pages: { + url: string + }[] +} + +type ChapterID = { + branch_id: string, + number: string, + volume: string +} + +@Common.ImageAjax(true) export default class extends DecoratableMangaScraper { + private readonly apiUrl = 'https://api.mangalib.me/api/'; + private readonly siteId = 1; + private imageServer: ImageServer = undefined; + private readonly mangasTaskPool = new TaskPool(1, new RateLimit(2, 1)); public constructor() { - super('mangalib', 'MangaLib', 'https://mangalib.org', Tags.Media.Manhwa, Tags.Media.Manhua, Tags.Language.Russian, Tags.Source.Aggregator); + super('mangalib', 'MangaLib', 'https://mangalib.org', Tags.Media.Manhwa, Tags.Media.Manhua, Tags.Media.Manga, Tags.Language.Russian, Tags.Source.Aggregator); } public override get Icon() { return icon; } + public override async Initialize(): Promise { + const { data: { imageServers } } = await FetchJSON>(new Request(new URL('constants?fields[]=imageServers', this.apiUrl))); + this.imageServer = imageServers.find(server => server.id === 'main' && server.site_ids.includes(this.siteId)); + } + + public override ValidateMangaURL(url: string): boolean { + return new RegExpSafe(`^${this.URI.origin}/ru/manga/[^/]`).test(url); + } + + public override async FetchManga(provider: MangaPlugin, url: string): Promise { + const mangaSlug = new URL(url).pathname.split('/').at(-1); + const { data: { slug_url, rus_name } } = await FetchJSON>(new Request(new URL(`manga/${mangaSlug}`, this.apiUrl))); + return new Manga(this, provider, slug_url, rus_name); + } + + public override async FetchMangas(provider: MangaPlugin): Promise { + const mangaList: Manga[] = []; + for (let page = 1, run = true; run; page++) { + const url = new URL(`manga?page=${page}&site_id[]=${this.siteId}`, this.apiUrl); + const { data, meta: { has_next_page } } = await this.mangasTaskPool.Add(() => FetchJSON>(new Request(url)), Priority.Low); + mangaList.push(...data.map(manga => new Manga(this, provider, manga.slug_url, manga.rus_name.trim()))); + run = has_next_page; + } + return mangaList; + } + + public override async FetchChapters(manga: Manga): Promise { + const { data } = await FetchJSON>(new Request(new URL(`manga/${manga.Identifier}/chapters`, this.apiUrl))); + return data.reduce((accumulator: Chapter[], chapter) => { + let baseTitle = `Том ${chapter.volume} Глава ${chapter.number}`; + baseTitle += chapter.name ? ` - ${chapter.name}` : ''; + const chapters = chapter.branches.map(branch => { + const teamName = chapter.branches_count > 1 ? `[${branch.teams.at(0).name}]` : ''; + const chapterId = JSON.stringify({ + branch_id: branch.branch_id?.toString() ?? '', + number: chapter.number, + volume: chapter.volume + }); + return new Chapter(this, manga, chapterId, [baseTitle, teamName].join(' ').trim()); + }); + accumulator.push(...chapters); + return accumulator; + }, []); + } + + public override async FetchPages(chapter: Chapter): Promise { + const { branch_id, number, volume } = JSON.parse(chapter.Identifier) as ChapterID; + const url = new URL(`manga/${chapter.Parent.Identifier}/chapter?number=${number}&volume=${volume}`, this.apiUrl); + if (branch_id) url.searchParams.set('branch_id', branch_id); + const { data: { pages } } = await FetchJSON>(new Request(url)); + return pages.map(page => new Page(this, chapter, new URL(this.imageServer.url + page.url))); + } } diff --git a/web/src/engine/websites/MangaLib_e2e.ts b/web/src/engine/websites/MangaLib_e2e.ts index e7966cdad9..2730edbafe 100644 --- a/web/src/engine/websites/MangaLib_e2e.ts +++ b/web/src/engine/websites/MangaLib_e2e.ts @@ -1,24 +1,47 @@ import { TestFixture } from '../../../test/WebsitesFixture'; -const config = { +const configWithBranches = { plugin: { id: 'mangalib', title: 'MangaLib' }, container: { - url: 'https://mangalib.org/toukyou-revengers', - id: '/toukyou-revengers', - title: 'Токийские мстители' + url: 'https://mangalib.org/ru/manga/7965--chainsaw-man', + id: '7965--chainsaw-man', + title: 'Человек-бензопила' }, child: { - id: '/toukyou-revengers/v1/c1', - title: '1 - Reborn' + id: JSON.stringify({ branch_id: '4667', number: '1', volume: '1' }), + title: 'Том 1 Глава 1 - Пёс и бензопила [Nippa Team]' }, entry: { - index: 1, - size: 100_108, - type: 'image/jpeg' + index: 4, + size: 314_272, + type: 'image/webp' } }; -new TestFixture(config).AssertWebsite(); \ No newline at end of file +new TestFixture(configWithBranches).AssertWebsite(); + +const configWithoutBranches = { + plugin: { + id: 'mangalib', + title: 'MangaLib' + }, + container: { + url: 'https://mangalib.org/ru/manga/210908--arlokk-the-atrocious', + id: '210908--arlokk-the-atrocious', + title: 'Злыдень Арлокк' + }, + child: { + id: JSON.stringify({ branch_id: '', number: '1', volume: '1' }), + title: 'Том 1 Глава 1 - Встреча со злодеем' + }, + entry: { + index: 0, + size: 126_508, + type: 'image/webp' + } +}; + +new TestFixture(configWithoutBranches).AssertWebsite(); \ No newline at end of file