diff --git a/web/src/engine/websites/Zebrack.ts b/web/src/engine/websites/Zebrack.ts new file mode 100644 index 0000000000..f97d394c84 --- /dev/null +++ b/web/src/engine/websites/Zebrack.ts @@ -0,0 +1,347 @@ +import { Tags } from '../Tags'; +import icon from './Zebrack.webp'; +import { Chapter, DecoratableMangaScraper, Manga, Page, type MangaPlugin } from '../providers/MangaPlugin'; +import * as Common from './decorators/Common'; +import { protoTypes } from './Zebrack_proto'; +import { FetchProto, FetchRequest, FetchWindowScript } from '../FetchProvider'; +import type { Priority } from '../taskpool/TaskPool'; + +type ZebrackResponse = { + titleDetailView: TitleDetailView, + magazineViewerView: MagazineViewerView, + volumeListView: VolumeListView +} + +type VolumeListView = { + volumes: Volume[] +} + +type Volume = { + titleId: number, + volumeId: number, + titleName: string, + volumeName: string +} + +type TitleDetailView = { + titleId: number, + titleName: string +} + +type TitleChapterListViewV3 = { + titleId: number, + groups: ChapterGroupV3[], + titleName: string +} + +type ChapterGroupV3 = { + volumeId: number, + chapters: ChapterV3[] +} + +type ChapterV3 = { + id: number, + titleId: number, + mainName: string +} + +type MagazineViewerView = { + images: ZebrackImage[]; +} + +type ZebrackImage = { + imageUrl: string, + encryptionKey: string +} + +type ChapterViewerViewV3 = { + pages: ChapterPageV3[] +} +type ChapterPageV3 = { + image: ImageV3; +} + +type ImageV3 = { + imageUrl: string, + encryptionKey: string +} + +type GravureDetailViewV3 = { + gravure: GravureV3 +} + +type GravureV3 = { + name: string +} + +type GravureViewerViewV3 = { + images: ImageV3[] +} + +type MagazineDetailViewV3 = { + magazine: MagazineIssue; +} +type MagazineIssue = { + magazineName: string, + issueName: string +} + +type VolumeViewerViewV3 = { + pages: VolumePageV3[]; +} + +type VolumePageV3 = { + image: ImageV3; +} + +@Common.MangasNotSupported() + +export default class extends DecoratableMangaScraper { + + private readonly apiURL = 'https://api.zebrack-comic.com'; + private readonly responseRootType = 'Zebrack.Response'; + + public constructor() { + super('zebrack', 'Zebrack(ゼブラック)', 'https://zebrack-comic.shueisha.co.jp', Tags.Media.Manga, Tags.Language.Japanese, Tags.Source.Official); + } + + public override get Icon() { + return icon; + } + + public override ValidateMangaURL(url: string): boolean { + return /https?:\/\/zebrack-comic\.shueisha\.co\.jp\/(title|gravure|magazine)\/\d+(\/(issue|volume)\/\d+)?/.test(url); + } + + //title : https://zebrack-comic.shueisha.co.jp/title/5123 + ///gravure : https://zebrack-comic.shueisha.co.jp/gravure/2188 + //Magazine : https://zebrack-comic.shueisha.co.jp/magazine/1/issue/14486/detail + //Volume : https://zebrack-comic.shueisha.co.jp/title/46119/volume/178046 + + public override async FetchManga(provider: MangaPlugin, url: string): Promise { + const uri = new URL(url); + if (/^\/magazine\//.test(uri.pathname)) { + const magazineId = uri.pathname.match(/\/magazine\/(\d+)/)[1]; + const magazineIssueId = uri.pathname.match(/\/issue\/(\d+)/)[1]; + const data = await this.fetchMagazineDetail(magazineId, magazineIssueId); + return new Manga(this, provider, uri.pathname, `${data.magazine.magazineName} ${data.magazine.issueName}`); + + } else if (/^\/gravure\//.test(uri.pathname)) { + const gravureId = uri.pathname.match(/\/gravure\/(\d+)$/)[1]; + const data = await this.fetchGravureDetail(gravureId); + return new Manga(this, provider, uri.pathname, data.gravure.name.trim()); + } + + const titleId = uri.pathname.match(/\/title\/(\d+)/)[1]; + const data = await this.fetchTitleDetail(titleId); + return new Manga(this, provider, uri.pathname, data.titleDetailView.titleName.trim()); + + } + async fetchMagazineDetail(magazineId: string, magazineIssueId: string): Promise { + const uri = new URL('/api/v3/magazine_issue_detail', this.apiURL); + uri.searchParams.set('os', 'browser'); + uri.searchParams.set('magazine_id', magazineId); + uri.searchParams.set('magazine_issue_id', magazineIssueId); + const request = new FetchRequest(uri.href); + return FetchProto(request, protoTypes, 'Zebrack.MagazineDetailViewV3'); + } + + async fetchGravureDetail(gravureId: string): Promise { + const uri = new URL('/api/v3/gravure_detail', this.apiURL); + uri.searchParams.set('os', 'browser'); + uri.searchParams.set('gravure_id', gravureId); + const request = new FetchRequest(uri.href); + return FetchProto(request, protoTypes, 'Zebrack.GravureDetailViewV3'); + } + + async fetchTitleDetail(titleId: string): Promise { + const uri = new URL('/api/browser/title_detail', this.apiURL); + uri.searchParams.set('os', 'browser'); + uri.searchParams.set('title_id', titleId); + const request = new FetchRequest(uri.href); + return FetchProto(request, protoTypes, this.responseRootType); + } + + public override async FetchChapters(manga: Manga): Promise { + const parts = manga.Identifier.split('/'); + let type = parts[3] || 'chapter'; + if (['magazine', 'gravure'].includes(parts[1])) { + type = parts[1]; + } + if (type === 'chapter') { + const id = parts[2]; + const data = await this.fetchChapterList(id); + const chapters: ChapterV3[] = []; + data.groups.forEach(group => { + chapters.push(...group.chapters); + }); + return chapters.map(chapter => new Chapter(this, manga, `chapter/${chapter.titleId}/${chapter.id}`, chapter.mainName)); + } + + if (type === 'gravure') { + return [new Chapter(this, manga, manga.Identifier.slice(1), manga.Title)]; + } + + if (type === 'magazine') { + const magazineId = parts[2]; + const magazineIssueId = parts[4]; + return [new Chapter(this, manga, `magazine/${magazineId}/${magazineIssueId}`, manga.Title)]; + } + + if (type === 'volume_list' || type === 'volume') { + const id = parts[2]; + const data = await this.fetchVolumeList(id); + const volumes = data.volumeListView.volumes; + return volumes.map(volume => new Chapter(this, manga, `volume/${volume.titleId}/${volume.volumeId}`, volume.volumeName)); + } + return []; + } + + async fetchVolumeList(id: string): Promise { + const uri = new URL('/api/browser/title_volume_list', this.apiURL); + uri.searchParams.set('os', 'browser'); + uri.searchParams.set('title_id', id); + const request = new FetchRequest(uri.href); + return FetchProto(request, protoTypes, this.responseRootType); + } + + async fetchChapterList(id: string): Promise { + const uri = new URL('/api/v3/title_chapter_list', this.apiURL); + uri.searchParams.set('os', 'browser'); + uri.searchParams.set('title_id', id); + const request = new FetchRequest(uri.href); + return FetchProto(request, protoTypes, 'Zebrack.TitleChapterListViewV3'); + } + + public override async FetchPages(chapter: Chapter): Promise { + const [type, titleId, chapterId] = chapter.Identifier.split('/'); + const request = new FetchRequest(this.URI.href); + const secretKey = await FetchWindowScript(request, 'localStorage.getItem("device_secret_key") || ""'); + if (type === 'chapter') { + const data = await this.fetchChapterViewer(titleId, chapterId, secretKey); + if (data.pages) { + return data.pages + .filter(page => page.image && page.image.imageUrl) + .map(page => new Page(this, chapter, new URL(page.image.imageUrl), { encryptionKey: page.image.encryptionKey })); + } + } + + if (type === 'gravure') { + const data = await this.fetchGravureViewer(titleId, secretKey); + if (data.images) { + return data.images.map(image => new Page(this, chapter, new URL(image.imageUrl), { encryptionKey: image.encryptionKey })); + } + } + + if (type === 'magazine') { + const data = await this.fetchMagazineViewer(titleId, chapterId, secretKey); + if (data.magazineViewerView) { + return data.magazineViewerView.images + .filter(image => image && image.imageUrl) + .map(image => new Page(this, chapter, new URL(image.imageUrl), { encryptionKey: image.encryptionKey })); + } + } + + if (type === 'volume') { + const data = await this.fetchVolumeViewer(titleId, chapterId, secretKey); + if (data.pages) { + return data.pages + .filter(page => page.image && page.image.imageUrl) + .map(page => new Page(this, chapter, new URL(page.image.imageUrl), { encryptionKey: page.image.encryptionKey })); + } + } + + throw new Error('No image data available, make sure your account is logged in and the chapter is purchased!'); + } + + async fetchVolumeViewer(titleId: string, volumeId: string, secretKey : string) { + const uri = new URL('/api/v3/manga_volume_viewer', this.apiURL); + uri.searchParams.set('secret', secretKey); + uri.searchParams.set('is_trial', '0'); + uri.searchParams.set('os', 'browser'); + uri.searchParams.set('title_id', titleId); + uri.searchParams.set('volume_id', volumeId); + let request = new FetchRequest(uri.href); + let data = await FetchProto(request, protoTypes, 'Zebrack.VolumeViewerViewV3'); + if (!data.pages) { + uri.searchParams.set('is_trial', '1'); + request = new FetchRequest(uri.href); + data = await FetchProto(request, protoTypes, 'Zebrack.VolumeViewerViewV3'); + } + return data; + } + + async fetchMagazineViewer(magazineId: string, magazineIssueId: string, secretKey: string): Promise { + const uri = new URL('/api/browser/magazine_viewer', this.apiURL); + uri.searchParams.set('secret', secretKey); + uri.searchParams.set('is_trial', '0'); + uri.searchParams.set('os', 'browser'); + uri.searchParams.set('magazine_id', magazineId); + uri.searchParams.set('magazine_issue_id', magazineIssueId); + let request = new FetchRequest(uri.href); + let data = await FetchProto(request, protoTypes, this.responseRootType); + if (!data.magazineViewerView) { + uri.searchParams.set('is_trial', '1'); + request = new FetchRequest(uri.href); + data = await FetchProto(request, protoTypes, this.responseRootType); + } + return data; + } + + async fetchGravureViewer(gravureId: string, secretKey: string): Promise { + const uri = new URL('/api/v3/gravure_viewer', this.apiURL); + uri.searchParams.set('secret', secretKey); + uri.searchParams.set('is_trial', '0'); + uri.searchParams.set('os', 'browser'); + uri.searchParams.set('gravure_id', gravureId); + let request = new FetchRequest(uri.href); + let data = await FetchProto(request, protoTypes, 'Zebrack.GravureViewerViewV3'); + if (!data.images) { + uri.searchParams.set('is_trial', '1'); + request = new FetchRequest(uri.href); + data = await FetchProto(request, protoTypes, 'Zebrack.GravureViewerViewV3'); + } + return data; + } + + async fetchChapterViewer(titleId: string, chapterId: string, secretKey: string): Promise { + const uri = new URL('/api/v3/chapter_viewer', this.apiURL); + const params = new URLSearchParams(); + params.set('secret', secretKey); + params.set('os', 'browser'); + params.set('title_id', titleId); + params.set('chapter_id', chapterId); + params.set('type', 'normal'); + const request = new FetchRequest(uri.href, { + method: 'POST', + body: params.toString(), + headers: { + 'content-type': 'application/x-www-form-urlencoded' + } + }); + return FetchProto(request, protoTypes, 'Zebrack.ChapterViewerViewV3'); + } + + public override async FetchImage(page: Page, priority: Priority, signal: AbortSignal): Promise { + const data = await Common.FetchImageAjax.call(this, page, priority, signal); + const key: string = page.Parameters ? page.Parameters['encryptionKey'] as string : undefined; + if (!key) return data; + const encrypted = await new Response(data).arrayBuffer(); + const decrypted = XORDecrypt(new Uint8Array(encrypted), key); + return new Blob([decrypted], { type: data.type }); + } + +} + +function XORDecrypt(encrypted: Uint8Array, key: string) { + if (key) { + const t = new Uint8Array(key.match(/.{1,2}/g).map(e => parseInt(e, 16))); + const s = new Uint8Array(encrypted); + for (let n = 0; n < s.length; n++) { + s[n] ^= t[n % t.length]; + } + return s; + } else { + return encrypted; + } +} diff --git a/web/src/engine/websites/Zebrack.webp b/web/src/engine/websites/Zebrack.webp new file mode 100644 index 0000000000..61c588fee2 Binary files /dev/null and b/web/src/engine/websites/Zebrack.webp differ diff --git a/web/src/engine/websites/Zebrack_e2e.ts b/web/src/engine/websites/Zebrack_e2e.ts new file mode 100644 index 0000000000..0c07c00f25 --- /dev/null +++ b/web/src/engine/websites/Zebrack_e2e.ts @@ -0,0 +1,25 @@ +import { TestFixture, type Config } from '../../../test/WebsitesFixture'; + +const config: Config = { + plugin: { + id: 'zebrack', + title: 'Zebrack(ゼブラック)' + }, + container: { + url: 'https://zebrack-comic.shueisha.co.jp/magazine/1/issue/14486/detail', + id: '/magazine/1/issue/14486/detail', + title: '週刊少年ジャンプ 2023年44号' + }, + child: { + id: 'magazine/1/14486', + title: '週刊少年ジャンプ 2023年44号' + }, + entry: { + index: 0, + size: 485_738, + type: 'image/jpeg' + } +}; + +const fixture = new TestFixture(config); +describe(fixture.Name, () => fixture.AssertWebsite()); \ No newline at end of file diff --git a/web/src/engine/websites/Zebrack_proto.ts b/web/src/engine/websites/Zebrack_proto.ts new file mode 100644 index 0000000000..815738c4af --- /dev/null +++ b/web/src/engine/websites/Zebrack_proto.ts @@ -0,0 +1,105 @@ +export const protoTypes = ` +package Zebrack; +syntax = "proto3"; + +message Response { + optional TitleDetailView titleDetailView = 21; + optional MagazineViewerView magazineViewerView = 32; + optional VolumeListView volumeListView = 100; +} + +message TitleDetailView { + optional uint32 titleId = 1; + optional string titleName = 2; +} + +message ImageV3 { + optional string imageUrl = 1; + optional string encryptionKey = 2; +} + +message Image { + optional string imageUrl = 1; + optional string encryptionKey = 3; +} + +// Chapter + +message TitleChapterListViewV3 { + optional uint32 titleId = 1; + repeated ChapterGroupV3 groups = 4; + optional string titleName = 7; +} + +message ChapterViewerViewV3 { + repeated ChapterPageV3 pages = 1; +} + +message ChapterGroupV3 { + optional uint32 volumeId = 2; + repeated ChapterV3 chapters = 3; +} + +message ChapterPageV3 { + optional ImageV3 image = 1; +} + +message ChapterV3 { + optional uint32 id = 1; + optional uint32 titleId = 2; + optional string mainName = 3; +} + +// Volume + +message VolumeListView { + repeated Volume volumes = 2; +} + +message VolumeViewerViewV3 { + repeated VolumePageV3 pages = 1; +} + +message VolumePageV3 { + optional ImageV3 image = 1; +} + +message Volume { + optional uint32 titleId = 2; + optional uint32 volumeId = 3; + optional string titleName = 4; + optional string volumeName = 5; +} + + +// Magazine + +message MagazineDetailViewV3 { + optional MagazineIssue magazine = 3; +} + +message MagazineViewerView { + repeated Image images = 1; +} + +message MagazineIssue { + optional string magazineName = 3; + optional string issueName = 4; +} + +// Gravure + +message GravureDetailViewV3 { + optional GravureV3 gravure = 1; +} + +message GravureViewerViewV3 { + repeated ImageV3 images = 2; +} + +message GravureV3 { + optional string name = 2; +} + + +`; \ No newline at end of file diff --git a/web/src/engine/websites/_index.ts b/web/src/engine/websites/_index.ts index b6f723fc1c..185150337a 100755 --- a/web/src/engine/websites/_index.ts +++ b/web/src/engine/websites/_index.ts @@ -413,6 +413,7 @@ export { default as YawarakaSpirits } from './YawarakaSpirits'; export { default as YugenMangasES } from './YugenMangasES'; export { default as YugenMangasPT } from './YugenMangasPT'; export { default as YuriVerso } from './YuriVerso'; +export { default as Zebrack } from './Zebrack'; export { default as ZinManga } from './ZinManga'; // Legacy Websites export { default as AkaiYuhiMun } from './legacy/AkaiYuhiMun';