From 8403695ca86a5fa0f0196d35f09b19081829e904 Mon Sep 17 00:00:00 2001 From: Matey Krastev Date: Fri, 18 Aug 2023 21:48:29 +0300 Subject: [PATCH] Added MangaPlus source. (#9) * Added MangaPlus source. * Readability. fixes and other stuff --------- Co-authored-by: TheNetsky <56271887+TheNetsky@users.noreply.github.com> --- src/MangaPlus/MangaPlus.ts | 398 +++++++++++++++++++++++++++++ src/MangaPlus/MangaPlusHelper.ts | 264 +++++++++++++++++++ src/MangaPlus/MangaPlusSettings.ts | 136 ++++++++++ src/MangaPlus/includes/icon.png | Bin 0 -> 13086 bytes 4 files changed, 798 insertions(+) create mode 100644 src/MangaPlus/MangaPlus.ts create mode 100644 src/MangaPlus/MangaPlusHelper.ts create mode 100644 src/MangaPlus/MangaPlusSettings.ts create mode 100644 src/MangaPlus/includes/icon.png diff --git a/src/MangaPlus/MangaPlus.ts b/src/MangaPlus/MangaPlus.ts new file mode 100644 index 0000000..4238891 --- /dev/null +++ b/src/MangaPlus/MangaPlus.ts @@ -0,0 +1,398 @@ +import { + SourceManga, + Chapter, + ChapterDetails, + HomeSection, + SearchRequest, + PagedResults, + SourceInfo, + ContentRating, + Request, + Response, + SourceIntents, + SearchResultsProviding, + ChapterProviding, + MangaProviding, + HomePageSectionsProviding, + HomeSectionType, + PartialSourceManga, + DUISection +} from '@paperback/types' + +import { + Language, + MangaPlusResponse, + TitleDetailView +} from './MangaPlusHelper' + +import { + contentSettings, + getLanguages, + resetSettings +} from './MangaPlusSettings' + +const BASE_URL = 'https://mangaplus.shueisha.co.jp' +const API_URL = 'https://jumpg-webapi.tokyo-cdn.com/api' + +const langCode = Language.ENGLISH + +export const MangaPlusInfo: SourceInfo = { + version: '2.0.0', + name: 'MangaPlus', + icon: 'icon.png', + author: 'Rinto-kun', + authorWebsite: 'https://github.com/Rinto-kun', + description: 'Extension that pulls manga from Manga+ by Shueisha', + contentRating: ContentRating.EVERYONE, + websiteBaseURL: BASE_URL, + sourceTags: [], + intents: SourceIntents.MANGA_CHAPTERS | SourceIntents.HOMEPAGE_SECTIONS | SourceIntents.CLOUDFLARE_BYPASS_REQUIRED | SourceIntents.SETTINGS_UI +} + +export class MangaPlus implements SearchResultsProviding, MangaProviding, ChapterProviding, HomePageSectionsProviding { + + stateManager = App.createSourceStateManager() + + requestManager = App.createRequestManager({ + requestsPerSecond: 10, + requestTimeout: 20000, + interceptor: { + interceptRequest: async (request: Request): Promise => { + request.headers = { + ...(request.headers ?? {}), + ...{ + 'Referer': `${BASE_URL}/`, + 'user-agent': await this.requestManager.getDefaultUserAgent() + } + } + return request + }, + interceptResponse: async (response: Response): Promise => { + + if (!response.request.url.includes('encryptionKey') && response.headers['Content-Type'] !== 'image/jpeg') { + return response + } + + if (response.request.url.includes('title_thumbnail_portrait_list')) { + return response + } + + const encryptionKey = response.request.url.substring(response.request.url.lastIndexOf('#') + 1) ?? '' + + // @ts-ignore + response.rawData = App.createRawData(this.decodeXoRCipher(App.createByteArray(response.rawData ?? new Uint8Array()), encryptionKey)) + + return response + } + + } + }); + + async getSourceMenu(): Promise { + return App.createDUISection( + { + + id: 'main', + header: 'Source Settings', + rows: async () => { + return [ + contentSettings(this.stateManager), + resetSettings(this.stateManager) + ] + }, + isHidden: false + } + ) + + } + + getMangaShareUrl(mangaId: string): string { return `${BASE_URL}/titles/${mangaId}` } + + async getMangaDetails(mangaId: string): Promise { + const request = App.createRequest({ + url: `${API_URL}/title_detail?title_id=${mangaId}&format=json`, + method: 'GET' + }) + + const response = await this.requestManager.schedule(request, 1) + const result = TitleDetailView.fromJson(response.data as string) + + return result.toSourceManga() + } + + async getChapters(mangaId: string): Promise { + const request = App.createRequest({ + url: `${API_URL}/title_detail?title_id=${mangaId}&format=json`, + method: 'GET' + }) + + const response = await this.requestManager.schedule(request, 1) + const result = TitleDetailView.fromJson(response.data as string) + + return [...(result.firstChapterList ?? []), ...(result.lastChapterList ?? [])].reverse().filter(chapter => !chapter.isExpired).map(chapter => chapter.toSChapter()) + } + + async getChapterDetails(mangaId: string, chapterId: string): Promise { + const request = App.createRequest({ + url: `${API_URL}/manga_viewer?chapter_id=${chapterId}&split=${(await this.stateManager.retrieve('split_images')) as string ?? 'no'}&img_quality=${(await this.stateManager.retrieve('image_resolution')) as string ?? 'high'}&format=json`, + method: 'GET' + }) + + const response = await this.requestManager.schedule(request, 1) + const result = JSON.parse(response.data as string) as MangaPlusResponse + + if (result.success === undefined) { + throw new Error(result.error?.langPopup(Language.ENGLISH)?.body ?? 'Unknown error') + } + + const pages = result.success.mangaViewer?.pages + .map(page => page.mangaPage) + .filter(page => page !== null) + .map((page) => page?.encryptionKey ? `${page?.imageUrl}#${page?.encryptionKey}` : '') + + return App.createChapterDetails({ + id: chapterId, + mangaId: mangaId, + pages: pages ?? [] + }) + + } + + async getFeaturedTitles(): Promise { + const request = App.createRequest({ + url: `${API_URL}/featured?lang=eng&format=json`, + method: 'GET' + }) + + const response = await this.requestManager.schedule(request, 1) + const result = JSON.parse(response.data as string) as MangaPlusResponse + + if (result.success === undefined) { + throw new Error(result.error?.langPopup(Language.ENGLISH)?.body ?? 'Unknown error') + } + + const languages = await getLanguages(this.stateManager) + + const results = result.success?.featuredTitlesView?.contents.find(x => x.titleList && x.titleList.listName == 'WEEKLY SHONEN JUMP')?.titleList.featuredTitles + .filter((title) => languages.includes(title.language ?? Language.ENGLISH)) + + const titles: PartialSourceManga[] = [] + const collectedIds: string[] = [] + + for (const item of results ?? []) { + const mangaId = item.titleId.toString() + const title = item.name + const author = item.author + const image = item.portraitImageUrl + + if (!mangaId || !title || collectedIds.includes(mangaId)) continue + + titles.push(App.createPartialSourceManga({ + mangaId: mangaId, + title: title, + subtitle: author, + image: image + })) + } + + return titles + } + + async getPopularTitles(): Promise { + const request = App.createRequest({ + url: `${API_URL}/title_list/ranking?format=json`, + method: 'GET' + }) + + const response = await this.requestManager.schedule(request, 1) + const result = JSON.parse(response.data as string) as MangaPlusResponse + + if (result.success === undefined) { + throw new Error(result.error?.langPopup(Language.ENGLISH)?.body ?? 'Unknown error') + } + + const languages = await getLanguages(this.stateManager) + + const results = result.success?.titleRankingView?.titles + .filter((title) => languages.includes(title.language ?? Language.ENGLISH)) + + const titles: PartialSourceManga[] = [] + const collectedIds: string[] = [] + + for (const item of results ?? []) { + const mangaId = item.titleId.toString() + const title = item.name + const author = item.author + const image = item.portraitImageUrl + + if (!mangaId || !title || collectedIds.includes(mangaId)) continue + + titles.push(App.createPartialSourceManga({ + mangaId: mangaId, + title: title, + subtitle: author, + image: image + })) + } + + return titles + } + + async getLatestUpdates(): Promise { + + function latestUpdatesRequest() { + return App.createRequest({ + url: `${API_URL}/web/web_homeV3?lang=eng&format=json`, + method: 'GET' + }) + } + + const request = latestUpdatesRequest() + const response = await this.requestManager.schedule(request, 1) + + const result: MangaPlusResponse = JSON.parse(response.data as string) + + if (result.success === undefined) { + throw new Error(result.error?.langPopup(langCode)?.body ?? 'Unknown error') + } + + const languages = await getLanguages(this.stateManager) + + const results = result.success.webHomeViewV3?.groups + .flatMap(ex => ex.titleGroups) + .flatMap(ex => ex.titles) + .map(title => title.title) + .filter(title => languages.includes(title.language ?? Language.ENGLISH)) + + const titles: PartialSourceManga[] = [] + const collectedIds: string[] = [] + + for (const item of results ?? []) { + const mangaId = item.titleId.toString() + const title = item.name + const author = item.author + const image = item.portraitImageUrl + + if (!mangaId || !title || collectedIds.includes(mangaId)) continue + + titles.push(App.createPartialSourceManga({ + mangaId: mangaId, + title: title, + subtitle: author, + image: image + })) + } + + return titles + } + + async getHomePageSections(sectionCallback: (section: HomeSection) => void): Promise { + + const featuredSection = App.createHomeSection({ + id: 'featured', + title: 'Deatured', + containsMoreItems: true, + type: HomeSectionType.featured, + items: await this.getFeaturedTitles() + }) + sectionCallback(featuredSection) + + const popularSection = App.createHomeSection({ + id: 'popular', + title: 'Popular', + containsMoreItems: true, + type: HomeSectionType.singleRowNormal, + items: await this.getPopularTitles() + }) + sectionCallback(popularSection) + + const latestUpdatesSection = App.createHomeSection({ + id: 'latest_updates', + title: 'Latest Updates', + containsMoreItems: true, + type: HomeSectionType.singleRowNormal, + items: await this.getLatestUpdates() + }) + sectionCallback(latestUpdatesSection) + } + + async getViewMoreItems(homepageSectionId: string, metadata: any): Promise { + let items: PartialSourceManga[] = [] + + switch (homepageSectionId) { + case 'featured': + items = await this.getFeaturedTitles() + break + + case 'popular': + items = await this.getPopularTitles() + break + + case 'latest_updates': + items = await this.getLatestUpdates() + break + + default: + throw new Error(`Invalid homeSectionId | ${homepageSectionId}`) + } + + return App.createPagedResults({ + results: items, + metadata + }) + } + + async getSearchResults(query: SearchRequest, metadata: any): Promise { + const title = query.title ?? '' + + const request = App.createRequest({ + url: `${API_URL}/title_list/allV2?format=JSON&${title ? 'filter=' + encodeURI(title) + '&' : ''}format=json`, + method: 'GET' + } + ) + + const response = await this.requestManager.schedule(request, 1) + const result = JSON.parse(response.data as string) as MangaPlusResponse + + if (result.success === undefined) { + throw new Error(result.error?.langPopup(Language.ENGLISH)?.body ?? 'Unknown error') + } + + const ltitle = query.title?.toLowerCase() ?? '' + const languages = await getLanguages(this.stateManager) + + const results = result.success?.allTitlesViewV2?.AllTitlesGroup.flatMap((group) => group.titles) + .filter((title) => languages.includes(title.language ?? Language.ENGLISH)) + .filter((title) => title.author?.toLowerCase().includes(ltitle) || title.name.toLowerCase().includes(ltitle)) + + const titles: PartialSourceManga[] = [] + const collectedIds: string[] = [] + + for (const item of results ?? []) { + const mangaId = item.titleId.toString() + const title = item.name + const author = item.author + const image = item.portraitImageUrl + + if (!mangaId || !title || collectedIds.includes(mangaId)) continue + + titles.push(App.createPartialSourceManga({ + mangaId: mangaId, + title: title, + subtitle: author, + image: image + })) + } + + return App.createPagedResults({ + results: titles + }) + } + + // Utility + private decodeXoRCipher(buffer: Uint8Array, encryptionKey: string) { + const key = encryptionKey.match(/../g)?.map((byte) => parseInt(byte, 16)) ?? [] + + return buffer.map((byte, index) => byte ^ (key[index % key.length] ?? 0)) + } +} \ No newline at end of file diff --git a/src/MangaPlus/MangaPlusHelper.ts b/src/MangaPlus/MangaPlusHelper.ts new file mode 100644 index 0000000..f94b6e2 --- /dev/null +++ b/src/MangaPlus/MangaPlusHelper.ts @@ -0,0 +1,264 @@ +import { SourceManga } from '@paperback/types' + + +export interface MangaPlusResponse { + success?: SuccessResult; + error?: ErrorResult; +} + +interface SuccessResult { + isFeaturedUpdated?: boolean; + titleRankingView?: TitleRankingView; + titleDetailView?: TitleDetailView; + mangaViewer?: MangaViewer; + allTitlesViewV2?: AllTitlesViewV2; + webHomeViewV3?: WebHomeViewV3; + featuredTitlesView?: { + contents: [ + { + titleList: { + listName: 'WEEKLY SHONEN JUMP' | 'JUMP PLUS' | 'OTHERS' | 'Re edition' | '"First Read Free" Eligible Titles!'; + featuredTitles: Title[]; + } + } + ] + }; +} + +interface TitleRankingView { + titles: Title[]; +} + +interface AllTitlesViewV2 { + AllTitlesGroup: AllTitlesGroup[]; +} + +interface AllTitlesGroup { + theTitle: string; + titles: Title[]; +} + +interface WebHomeViewV3 { + groups: UpdatedTitleV2Group[]; +} + +interface UpdatedTitleV2Group { + groupName: string; + titleGroups: OriginalTitleGroup[]; +} + +interface OriginalTitleGroup { + theTitle: string; + titles: UpdatedTitle[]; +} + +interface UpdatedTitle { + title: Title; +} + +class ErrorResult { + popups: Popup[] = [] + + langPopup(lang: Language): Popup | null { + return this.popups.find(popup => popup.language === lang) || null + } +} + +class Popup { + subject: string + body: string + language?: Language + + constructor(subject: string, body: string, language?: Language) { + this.subject = subject + this.body = body + if (language) this.language = language + else this.language = Language.ENGLISH + } +} + +export enum Language { + ENGLISH = 'ENGLISH', + SPANISH = 'SPANISH', + FRENCH = 'FRENCH', + INDONESIAN = 'INDONESIAN', + PORTUGUESE_BR = 'PORTUGUESE_BR', + RUSSIAN = 'RUSSIAN', + THAI = 'THAI', + VIETNAMESE = 'VIETNAMESE' +} + +export class Title { + titleId: number; + name: string; + author?: string; + portraitImageUrl: string; + landscapeImageUrl: string; + viewCount = 0; + language: Language = Language.ENGLISH; + + constructor(titleId: number, name: string, portraitImageUrl: string, landscapeImageUrl: string, author?: string) { + this.titleId = titleId + this.name = name + this.portraitImageUrl = portraitImageUrl + this.landscapeImageUrl = landscapeImageUrl + + if (author) this.author = author + } +} + +export class TitleDetailView { + title?: Title; + titleImageUrl?: string; + overview?: string; + backgroundImageUrl?: string; + nextTimeStamp = 0; + viewingPeriodDescription = ''; + nonAppearanceInfo = ''; + firstChapterList: Chapter[] = []; + lastChapterList: Chapter[] = []; + isSimulReleased = false; + chaptersDescending = true; + + private get isWebtoon(): boolean { + return this.firstChapterList.every(chapter => chapter.isVerticalOnly) && this.lastChapterList.every(chapter => chapter.isVerticalOnly) + } + + private get isOneShot(): boolean { + return this.chapterCount == 1 && this.firstChapterList.at(0)?.name?.localeCompare('one-shot', undefined, { 'sensitivity': 'base' }) == 0 + } + + private get chapterCount(): number { + return this.firstChapterList?.length + this.lastChapterList?.length + } + + private get isReEdition(): boolean { + return this.viewingPeriodDescription?.search(TitleDetailView.REEDITION_REGEX) != 0 + } + + private get isCompleted(): boolean { + return this.nonAppearanceInfo?.search(TitleDetailView.COMPLETED_REGEX) != 0 || this.isOneShot + } + + private get isOnHiatus(): boolean { + return this.nonAppearanceInfo?.search(TitleDetailView.HIATUS_REGEX) != 0 + } + + private get genres(): string[] { + const genres = [] + if (this.isSimulReleased && !this.isReEdition && !this.isOneShot) genres.push('Simulrelease') + + if (this.isOneShot) genres.push('One-shot') + + if (this.isReEdition) genres.push('Re-edition') + + if (this.isWebtoon) genres.push('Webtoon') + + return genres + } + + static fromJson(str: string): TitleDetailView { + const bopp = JSON.parse(str) as MangaPlusResponse + + if (bopp.success?.titleDetailView === undefined) throw Error('Cannot find manga') + + const json = bopp.success.titleDetailView + + const obj = new TitleDetailView() + + if (json.title === undefined) { + throw Error('Cannot find title') + } + + const title = json.title + + obj.title = new Title(title.titleId, title.name, title.portraitImageUrl, title.landscapeImageUrl, title.author) + obj.titleImageUrl = json.titleImageUrl + obj.overview = json.overview + obj.backgroundImageUrl = json.backgroundImageUrl + obj.nextTimeStamp = json.nextTimeStamp + obj.viewingPeriodDescription = json.viewingPeriodDescription + obj.nonAppearanceInfo = json.nonAppearanceInfo + obj.firstChapterList = json.firstChapterList?.map(chapter => Object.assign(new Chapter(1, 1, '', 1, 1), chapter)) + obj.lastChapterList = json.lastChapterList?.map(chapter => Object.assign(new Chapter(1, 1, '', 1, 1), chapter)) + + return obj + } + + toSourceManga(): SourceManga { + const authors = this.title?.author?.split('/') + return App.createSourceManga({ + id: this.title?.titleId.toString() ?? '', + mangaInfo: App.createMangaInfo({ + image: this.title?.portraitImageUrl ?? '', + titles: [this.title?.name ?? ''], + author: authors ? authors[0]?.trimEnd() : this.title?.author ?? '', + artist: authors ? authors[1]?.trimStart() : this.title?.author ?? '', + desc: (this.overview ?? '') + '\n\n' + (this.viewingPeriodDescription ?? ''), + tags: [ + App.createTagSection({ + id: '0', + label: 'genres', + tags: this.genres.map(genre => App.createTag({ id: genre, label: genre })) + }) + ], + status: this.isCompleted ? 'Completed' : this.isOnHiatus ? 'On hiatus' : 'Ongoing' + }) + }) + } + + private static COMPLETED_REGEX = /completado|complete|completo/ + private static HIATUS_REGEX = /on a hiatus/i + private static REEDITION_REGEX = /revival|remasterizada/ +} + +interface MangaViewer { + pages: MangaPlusPage[]; + titleId?: number; + titleName?: string; +} + +interface MangaPlusPage { + mangaPage?: MangaPage; +} + +interface MangaPage { + imageUrl: string; + width: number; + height: number; + encryptionKey?: string; +} + +class Chapter { + titleId: number; + chapterId: number; + name: string; + subTitle?: string; + startTimeStamp: number; + endTimeStamp: number; + isVerticalOnly = false; + + constructor(titleId: number, chapterId: number, name: string, startTimeStamp: number, endTimeStamp: number) { + this.titleId = titleId + this.chapterId = chapterId + this.name = name + this.startTimeStamp = startTimeStamp + this.endTimeStamp = endTimeStamp + } + + public get isExpired(): boolean { + return this.subTitle == null + } + + toSChapter() { + const chapNum = parseFloat(this.name.slice(this.name.lastIndexOf('#') + 1)) + + return App.createChapter({ + id: this.chapterId.toString(), + name: this.subTitle ? this.subTitle : '', + chapNum: isNaN(chapNum) ? 0 : chapNum, + sortingIndex: isNaN(chapNum) ? -1 : chapNum, + time: new Date(this.startTimeStamp * 1000) + }) + } +} \ No newline at end of file diff --git a/src/MangaPlus/MangaPlusSettings.ts b/src/MangaPlus/MangaPlusSettings.ts new file mode 100644 index 0000000..9033e84 --- /dev/null +++ b/src/MangaPlus/MangaPlusSettings.ts @@ -0,0 +1,136 @@ +import { + DUIButton, + DUINavigationButton, + SourceStateManager +} from '@paperback/types' + +import { Language } from './MangaPlusHelper' + +export const getLanguages = async (stateManager: SourceStateManager): Promise => { + return (await stateManager.retrieve('languages') as string[]) ?? [Language.ENGLISH] +} + +export const getSplitImages = async (stateManager: SourceStateManager): Promise => { + return (await stateManager.retrieve('split_images') as string) ?? 'yes' +} + +export const getResolution = async (stateManager: SourceStateManager): Promise => { + return ( + (await stateManager.retrieve('image_resolution') as string) ?? 'high' + ) +} + +export const contentSettings = (stateManager: SourceStateManager): DUINavigationButton => { + return App.createDUINavigationButton({ + id: 'content_settings', + label: 'Content Settings', + form: App.createDUIForm({ + sections: async () => + [ + App.createDUISection({ + isHidden: false, + id: 'content', + rows: async () => { + await Promise.all([ + getLanguages(stateManager), + getSplitImages(stateManager), + getResolution(stateManager) + ]) + + return await [ + App.createDUISelect({ + id: 'languages', + label: 'Languages', + options: [Language.ENGLISH, Language.FRENCH, Language.INDONESIAN, Language.PORTUGUESE_BR, Language.RUSSIAN, Language.SPANISH, Language.THAI, Language.VIETNAMESE], + labelResolver: async (option: string) => { + switch (option) { + case Language.ENGLISH: + return 'English' + + case Language.SPANISH: + return 'Español' + + case Language.FRENCH: + return 'Français' + + case Language.INDONESIAN: + return 'Bahasa (IND)' + + case Language.PORTUGUESE_BR: + return 'Portugûes (BR)' + + case Language.RUSSIAN: + return 'Русский' + + case Language.THAI: + return 'ภาษาไทย' + + case Language.VIETNAMESE: + return 'Tiếng Việt' + + default: + return '' + } + }, + value: App.createDUIBinding({ + get: async () => getLanguages(stateManager), + set: async (value: string[]) => { await stateManager.store('languages', value) } + }), + allowsMultiselect: true + }), + + App.createDUISwitch({ + id: 'split_images', + label: 'Split double pages', + value: App.createDUIBinding({ + get: async () => await getSplitImages(stateManager) == 'yes', + set: async (value: boolean) => { await stateManager.store('split_images', value ? 'yes' : 'no') } + }) + }), + + App.createDUISelect({ + id: 'image_resolution', + label: 'Image resolution', + options: ['low', 'high', 'super_high'], + value: App.createDUIBinding({ + get: async () => [await getResolution(stateManager)], + set: async (value: string[]) => { + await stateManager.store('image_resolution', value[0]) + } + }), + allowsMultiselect: false, + labelResolver: async (option: string) => { + switch (option) { + case 'low': + return 'Low' + case 'high': + return 'High' + case 'super_high': + return 'Super High' + default: + return '' + } + } + }) + + ] + + } + }) + ] + }) + }) + +} + +export function resetSettings(stateManager: SourceStateManager): DUIButton { + return App.createDUIButton({ + id: 'reset', + label: 'Reset to Default', + onTap: async () => { + await stateManager.store('languages', [Language.ENGLISH]), + await stateManager.store('split_images', 'yes'), + await stateManager.store('image_resolution', 'high') + } + }) +} \ No newline at end of file diff --git a/src/MangaPlus/includes/icon.png b/src/MangaPlus/includes/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9b8e34610766fcc16643d64c8272d7b7b588e026 GIT binary patch literal 13086 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 s{_F{L