From 4513dcb72841260f65affcd594daffc275539f21 Mon Sep 17 00:00:00 2001 From: nyne Date: Sat, 5 Apr 2025 21:20:32 +0800 Subject: [PATCH] Add manga_dex --- index.json | 13 +- manga_dex.js | 592 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 603 insertions(+), 2 deletions(-) create mode 100644 manga_dex.js diff --git a/index.json b/index.json index c44db50..9c0185a 100644 --- a/index.json +++ b/index.json @@ -33,7 +33,8 @@ "name": "紳士漫畫", "fileName": "wnacg.js", "key": "wnacg", - "version": "1.0.2" + "version": "1.0.2", + "description": "紳士漫畫漫畫源, 不能使用時請嘗試更換URL" }, { "name": "ehentai", @@ -45,6 +46,14 @@ "name": "禁漫天堂", "fileName": "jm.js", "key": "jm", - "version": "1.1.4" + "version": "1.1.4", + "description": "禁漫天堂漫畫源, 不能使用時請嘗試切換分流" + }, + { + "name": "MangaDex", + "fileName": "manga_dex.js", + "key": "manga_dex", + "version": "1.0.0", + "description": "Account feature is not supported yet." } ] diff --git a/manga_dex.js b/manga_dex.js new file mode 100644 index 0000000..be6ae1f --- /dev/null +++ b/manga_dex.js @@ -0,0 +1,592 @@ +/** @type {import('./_venera_.js')} */ +class MangaDex extends ComicSource { + // Note: The fields which are marked as [Optional] should be removed if not used + + // name of the source + name = "MangaDex" + + // unique id of the source + key = "manga_dex" + + version = "1.0.0" + + minAppVersion = "1.4.0" + + // update url + url = "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@main/manga_dex.js" + + comicsPerPage = 20 + + api = { + parseComic: (data) => { + let id = data['id'] + let titles = {} + let mainTitles = data['attributes']['title'] + for (let lang of Object.keys(mainTitles)) { + titles[lang] = mainTitles[lang] + } + for (let at of data['attributes']['altTitles']) { + for (let lang of Object.keys(at)) { + if (titles[lang] === undefined) { + titles[lang] = at[lang] + } + } + } + let locale = APP.locale + let mainTitle = '' + let firstTitle = titles[Object.keys(titles)[0]] + if (locale.startsWith('en')) { + mainTitle = titles['en'] || titles['ja'] || firstTitle + } else if (locale.startsWith('zh_CN')) { + mainTitle = titles['zh'] || titles['zh-hk'] || titles['zh-tw'] || titles['ja'] || firstTitle + } else if (locale.startsWith('zh_TW')) { + mainTitle = titles['zh-hk'] || titles['zh-tw'] || titles['zh'] || titles['ja'] || firstTitle + } + let tags = [] + for (let tag of data['attributes']['tags']) { + tags.push(tag['attributes']['name']['en']) + } + let cover = data['relationships'].find((e) => e['type'] === 'cover_art')?.['attributes']['fileName'] + if (cover) { + cover = `https://mangadex.org/covers/${id}/${cover}.256.jpg` + } else { + cover = "" + } + let description = data['attributes']['description']['en'] + let createTime = data['attributes']['createdAt'] + let updateTime = data['attributes']['updatedAt'] + let status = data['attributes']['status'] + let authors = [] + let artists = [] + for (let rel of data['relationships']) { + if (rel['type'] === 'author') { + let name = rel['attributes']['name']; + let id = rel['id'] + authors.push(name) + this.authors[name] = id + } else if (rel['type'] === 'artist') { + let name = rel['attributes']['name']; + let id = rel['id'] + artists.push(name) + this.artists[name] = id + } + } + + return { + id: id, + title: mainTitle, + subtitle: authors.at(0), + titles: titles, + cover: cover, + tags: tags, + description: description, + createTime: createTime, + updateTime: updateTime, + status: status, + authors: authors, + artists: artists, + } + }, + getPopular: async (page) => { + let time = new Date() + time = new Date(time.getTime() - 30 * 24 * 60 * 60 * 1000) + let popularUrl = `https://api.mangadex.org/manga?` + + `includes[]=cover_art&` + + `includes[]=artist&` + + `includes[]=author&` + + `order[followedCount]=desc&` + + `hasAvailableChapters=true&` + + `createdAtSince=${time.toISOString().substring(0, 19)}&` + + `limit=${this.comicsPerPage}` + if (page && page > 1) { + popularUrl += `&offset=${(page - 1) * this.comicsPerPage}` + } + let res = await fetch(popularUrl) + let data = await res.json() + let total = data['total'] + let maxPage = Math.ceil(total / this.comicsPerPage) + let comics = [] + for (let comic of data['data']) { + comics.push(this.api.parseComic(comic)) + } + return { + comics: comics, + maxPage: maxPage + } + }, + getRecent: async (page) => { + let recentUrl = `https://api.mangadex.org/manga?` + + `includes[]=cover_art&` + + `includes[]=artist&` + + `includes[]=author&` + + `order[createdAt]=desc&` + + `hasAvailableChapters=true&` + + `limit=${this.comicsPerPage}` + if (page && page > 1) { + recentUrl += `&offset=${(page - 1) * this.comicsPerPage}` + } + let res = await fetch(recentUrl) + let data = await res.json() + let total = data['total'] + let maxPage = Math.ceil(total / this.comicsPerPage) + let comics = [] + for (let comic of data['data']) { + comics.push(this.api.parseComic(comic)) + } + return { + comics: comics, + maxPage: maxPage + } + }, + getUpdated: async (page) => { + let updatedUrl = `https://api.mangadex.org/manga?` + + `includes[]=cover_art&` + + `includes[]=artist&` + + `includes[]=author&` + + `order[latestUploadedChapter]=desc&` + + `contentRating[]=safe&` + + `contentRating[]=suggestive&` + + `hasAvailableChapters=true&` + + `limit=${this.comicsPerPage}` + if (page && page > 1) { + updatedUrl += `&offset=${(page - 1) * this.comicsPerPage}` + } + let res = await fetch(updatedUrl) + let data = await res.json() + let total = data['total'] + let maxPage = Math.ceil(total / this.comicsPerPage) + let comics = [] + for (let comic of data['data']) { + comics.push(this.api.parseComic(comic)) + } + return { + comics: comics, + maxPage: maxPage + } + } + } + + // Account feature is not implemented yet + // TODO: implement account feature + // account = {} + + // explore page list + explore = [ + { + // title of the page. + // title is used to identify the page, it should be unique + title: "Manga Dex", + + /// multiPartPage or multiPageComicList or mixed + type: "multiPartPage", + + load: async (page) => { + let res = await Promise.all([ + this.api.getPopular(page), + this.api.getRecent(page), + this.api.getUpdated(page) + ]) + let titles = ["Popular", "Recent", "Updated"] + let viewMore = [ + { + page: "search", + attributes: { + options: ["popular", "any", "any"], + }, + }, + { + page: "search", + attributes: { + options: ["recent", "any", "any"], + }, + }, + { + page: "search", + attributes: { + options: ["updated", "any", "any"], + }, + } + ] + let parts = [] + for (let i = 0; i < res.length; i++) { + let part = res[i] + parts.push({ + title: titles[i], + comics: part.comics, + viewMore: viewMore[i] + }) + } + return parts + }, + } + ] + + // categories + category = { + /// title of the category page, used to identify the page, it should be unique + title: "MangaDex", + parts: [ + { + // title of the part + name: "Tags", + + // fixed or random or dynamic + // if random, need to provide `randomNumber` field, which indicates the number of comics to display at the same time + // if dynamic, need to provide `loader` field, which indicates the function to load comics + type: "dynamic", + + // number of comics to display at the same time + // randomNumber: 5, + + // load function for dynamic type + loader: () => { + let categories = [] + for (let tag of Object.keys(this.tags)) { + categories.push({ + label: tag, + target: { + page: "search", + attributes: { + keyword: `tag:${tag}`, + }, + } + }) + } + return categories + } + } + ], + // enable ranking page + enableRankingPage: false, + } + + /// search related + search = { + /** + * load search result + * @param keyword {string} + * @param options {string[]} - options from optionList + * @param page {number} + * @returns {Promise<{comics: Comic[], maxPage: number}>} + */ + load: async (keyword, options, page) => { + let order = "" + if (options[0] !== "any") { + order = { + "popular": `order[followedCount]=desc&`, + "recent": `order[createdAt]=desc&`, + "updated": `order[latestUploadedChapter]=desc&`, + "rating": `order[rating]=desc&`, + "follows": `order[followedCount]=desc&` + }[options[0]] + } + let contentRating = "" + if (options[1] !== "any") { + contentRating = `contentRating[]=${options[1]}&` + } + let status = "" + if (options[2] !== "any") { + status = `status[]=${options[2]}&` + } + let url = `https://api.mangadex.org/manga?` + + `includes[]=cover_art&` + + `includes[]=artist&` + + `includes[]=author&` + + order + + contentRating + + status + + `hasAvailableChapters=true&` + + `limit=${this.comicsPerPage}` + if (page && page > 1) { + url += `&offset=${(page - 1) * this.comicsPerPage}` + } + if (keyword) { + let splits = keyword.split(" ") + let reformated = [] + for (let s of splits) { + if (s === "") { + continue + } + if (s.startsWith('tag:')) { + let tag = s.substring(4) + tag = tag.replaceAll('_', ' ') + let id = this.tags[tag] + if (id !== undefined) { + url += `&includedTags[]=${id}` + } else { + reformated.push(s) + } + } else if (s.startsWith('author:')) { + let author = s.substring(7) + author = author.replaceAll('_', ' ') + let id = this.authors[author] + if (id !== undefined) { + url += `&authorOrArtist=${id}` + } else { + reformated.push(s) + } + } else if (s.startsWith('artist:')) { + let artist = s.substring(7) + artist = artist.replaceAll('_', ' ') + let id = this.artists[artist] + if (id !== undefined) { + url += `&authorOrArtist=${id}` + } else { + reformated.push(s) + } + } else { + reformated.push(s) + } + } + keyword = reformated.join(" ") + if (keyword !== "") + url += `&title=${keyword}` + } + let res = await fetch(url) + if (!res.ok) { + throw new Error("Network response was not ok") + } + let data = await res.json() + let total = data['total'] + let maxPage = Math.ceil(total / this.comicsPerPage) + let comics = [] + for (let comic of data['data']) { + comics.push(this.api.parseComic(comic)) + } + return { + comics: comics, + maxPage: maxPage + } + }, + + // provide options for search + optionList: [ + { + label: "Sort By", + type: "select", + options: [ + "any-Any", + "popular-Popular", + "recent-Recent", + "updated-Updated", + "rating-Rating", + "follows-Follows", + ], + }, + { + label: "Content Rating", + type: "select", + options: [ + "any-Any", + "safe-Safe", + "suggestive-Suggestive", + "erotica-Erotica", + ] + }, + { + label: "Status", + type: "select", + options: [ + "any-Any", + "ongoing-Ongoing", + "completed-Completed", + "hiatus-Hiatus", + "cancelled-Cancelled", + ] + }, + ], + + // enable tags suggestions + enableTagsSuggestions: false, + } + + /// single comic related + comic = { + getComic: async (id) => { + let res = await fetch(`https://api.mangadex.org/manga/${id}?includes[]=cover_art&includes[]=artist&includes[]=author`) + if (!res.ok) { + throw new Error("Network response was not ok") + } + let data = await res.json() + return this.api.parseComic(data['data']) + + }, + getChapters: async (id) => { + let res = await fetch(`https://api.mangadex.org/manga/${id}/feed?limit=500&translatedLanguage[]=en&order[chapter]=asc`) + if (!res.ok) { + throw new Error("Network response was not ok") + } + let data = await res.json() + let chapters = new Map() + for (let chapter of data['data']) { + let id = chapter['id'] + let chapterId = chapter['attributes']['chapter'] + let title = chapter['attributes']['title'] + if (title) { + title = `${chapterId}: ${title}` + } else { + title = chapterId + } + let volume = chapter['attributes']['volume'] + if (volume) { + volume = `Volume ${volume}` + } else { + volume = "No Volume" + } + if (chapters.get(volume) === undefined) { + chapters.set(volume, new Map()) + } + chapters.get(volume).set(id, title) + } + return chapters + }, + getStats: async (id) => { + let res = await fetch(`https://api.mangadex.org/statistics/manga/${id}`) + if (!res.ok) { + throw new Error("Network response was not ok") + } + let data = await res.json() + return { + comments: data['statistics'][id]['comments']?.['repliesCount'] || 0, + follows: data['statistics'][id]['follows'] || 0, + rating: data['statistics'][id]['rating']['average'] || 0, + } + }, + /** + * load comic info + * @param id {string} + * @returns {Promise} + */ + loadInfo: async (id) => { + let res = await Promise.all([ + this.comic.getComic(id), + this.comic.getChapters(id), + this.comic.getStats(id) + ]) + let comic = res[0] + let chapters = res[1] + let stats = res[2] + + return new ComicDetails({ + id: comic.id, + title: comic.title, + subtitle: comic.subtitle, + cover: comic.cover, + tags: { + "Tags": comic.tags, + "Status": comic.status, + "Authors": comic.authors, + "Artists": comic.artists, + }, + description: comic.description, + updateTime: comic.updateTime, + uploadTime: comic.createTime, + status: comic.status, + chapters: chapters, + stars: (stats.rating || 0) / 2, + url: `https://mangadex.org/title/${comic.id}`, + }) + }, + + /** + * rate a comic + * @param id + * @param rating {number} - [0-10] app use 5 stars, 1 rating = 0.5 stars, + * @returns {Promise} - return any value to indicate success + */ + starRating: async (id, rating) => { + // TODO: implement star rating + }, + + /** + * load images of a chapter + * @param comicId {string} + * @param epId {string?} + * @returns {Promise<{images: string[]}>} + */ + loadEp: async (comicId, epId) => { + if (!epId) { + throw new Error("No chapter id provided") + } + let res = await fetch(`https://api.mangadex.org/at-home/server/${epId}`) + if (!res.ok) { + throw new Error("Network response was not ok") + } + let data = await res.json() + let baseUrl = data['baseUrl'] + let images = [] + for (let image of data['chapter']['data']) { + images.push(`${baseUrl}/data/${data['chapter']['hash']}/${image}`) + } + return { + images: images + } + }, + /** + * [Optional] load comments + * + * Since app version 1.0.6, rich text is supported in comments. + * Following html tags are supported: ['a', 'b', 'i', 'u', 's', 'br', 'span', 'img']. + * span tag supports style attribute, but only support font-weight, font-style, text-decoration. + * All images will be placed at the end of the comment. + * Auto link detection is enabled, but only http/https links are supported. + * @param comicId {string} + * @param subId {string?} - ComicDetails.subId + * @param page {number} + * @param replyTo {string?} - commentId to reply, not null when reply to a comment + * @returns {Promise<{comments: Comment[], maxPage: number?}>} + */ + loadComments: async (comicId, subId, page, replyTo) => { + throw new Error("Not implemented") + }, + /** + * [Optional] send a comment, return any value to indicate success + * @param comicId {string} + * @param subId {string?} - ComicDetails.subId + * @param content {string} + * @param replyTo {string?} - commentId to reply, not null when reply to a comment + * @returns {Promise} + */ + sendComment: async (comicId, subId, content, replyTo) => { + throw new Error("Not implemented") + }, + /** + * [Optional] Handle tag click event + * @param namespace {string} + * @param tag {string} + * @returns {PageJumpTarget} + */ + onClickTag: (namespace, tag) => { + tag = tag.replaceAll(' ', '_') + let keyword = tag + if (namespace === "Tags") { + keyword = `tag:${tag}` + } else if (namespace === "Authors") { + keyword = `author:${tag}` + } else if (namespace === "Artists") { + keyword = `artist:${tag}` + } + return { + page: "search", + attributes: { + 'keyword': keyword, + }, + } + }, + } + + settings = {} + + // [Optional] translations for the strings in this config + translation = { + 'zh_CN': {}, + 'zh_TW': {}, + 'en': {} + } + + tags = {"Oneshot":"0234a31e-a729-4e28-9d6a-3f87c4966b9e","Thriller":"07251805-a27e-4d59-b488-f0bfbec15168","Award Winning":"0a39b5a1-b235-4886-a747-1d05d216532d","Reincarnation":"0bc90acb-ccc1-44ca-a34a-b9f3a73259d0","Sci-Fi":"256c8bd9-4904-4360-bf4f-508a76d67183","Time Travel":"292e862b-2d17-4062-90a2-0356caa4ae27","Genderswap":"2bd2e8d0-f146-434a-9b51-fc9ff2c5fe6a","Loli":"2d1f5d56-a1e5-4d0d-a961-2193588b08ec","Traditional Games":"31932a7e-5b8e-49a6-9f12-2afa39dc544c","Official Colored":"320831a8-4026-470b-94f6-8353740e6f04","Historical":"33771934-028e-4cb3-8744-691e866a923e","Monsters":"36fd93ea-e8b8-445e-b836-358f02b3d33d","Action":"391b0423-d847-456f-aff0-8b0cfc03066b","Demons":"39730448-9a5f-48a2-85b0-a70db87b1233","Psychological":"3b60b75c-a2d7-4860-ab56-05f391bb889c","Ghosts":"3bb26d85-09d5-4d2e-880c-c34b974339e9","Animals":"3de8c75d-8ee3-48ff-98ee-e20a65c86451","Long Strip":"3e2b8dae-350e-4ab8-a8ce-016e844b9f0d","Romance":"423e2eae-a7a2-4a8b-ac03-a8351462d71d","Ninja":"489dd859-9b61-4c37-af75-5b18e88daafc","Comedy":"4d32cc48-9f00-4cca-9b5a-a839f0764984","Mecha":"50880a9d-5440-4732-9afb-8f457127e836","Anthology":"51d83883-4103-437c-b4b1-731cb73d786c","Boys' Love":"5920b825-4181-4a17-beeb-9918b0ff7a30","Incest":"5bd0e105-4481-44ca-b6e7-7544da56b1a3","Crime":"5ca48985-9a9d-4bd8-be29-80dc0303db72","Survival":"5fff9cde-849c-4d78-aab0-0d52b2ee1d25","Zombies":"631ef465-9aba-4afb-b0fc-ea10efe274a8","Reverse Harem":"65761a2a-415e-47f3-bef2-a9dababba7a6","Sports":"69964a64-2f90-4d33-beeb-f3ed2875eb4c","Superhero":"7064a261-a137-4d3a-8848-2d385de3a99c","Martial Arts":"799c202e-7daa-44eb-9cf7-8a3c0441531e","Fan Colored":"7b2ce280-79ef-4c09-9b58-12b7c23a9b78","Samurai":"81183756-1453-4c81-aa9e-f6e1b63be016","Magical Girls":"81c836c9-914a-4eca-981a-560dad663e73","Mafia":"85daba54-a71c-4554-8a28-9901a8b0afad","Adventure":"87cc87cd-a395-47af-b27a-93258283bbc6","Self-Published":"891cf039-b895-47f0-9229-bef4c96eccd4","Virtual Reality":"8c86611e-fab7-4986-9dec-d1a2f44acdd5","Office Workers":"92d6d951-ca5e-429c-ac78-451071cbf064","Video Games":"9438db5a-7e2a-4ac0-b39e-e0d95a34b8a8","Post-Apocalyptic":"9467335a-1b83-4497-9231-765337a00b96","Sexual Violence":"97893a4c-12af-4dac-b6be-0dffb353568e","Crossdressing":"9ab53f92-3eed-4e9b-903a-917c86035ee3","Magic":"a1f53773-c69a-4ce5-8cab-fffcd90b1565","Girls' Love":"a3c67850-4684-404e-9b7f-c69850ee5da6","Harem":"aafb99c1-7f60-43fa-b75f-fc9502ce29c7","Military":"ac72833b-c4e9-4878-b9db-6c8a4a99444a","Wuxia":"acc803a4-c95a-4c22-86fc-eb6b582d82a2","Isekai":"ace04997-f6bd-436e-b261-779182193d3d","4-Koma":"b11fda93-8f1d-4bef-b2ed-8803d3733170","Doujinshi":"b13b2a48-c720-44a9-9c77-39c9979373fb","Philosophical":"b1e97889-25b4-4258-b28b-cd7f4d28ea9b","Gore":"b29d6a3d-1569-4e7a-8caf-7557bc92cd5d","Drama":"b9af3a63-f058-46de-a9a0-e0c13906197a","Medical":"c8cbe35b-1b2b-4a3f-9c37-db84c4514856","School Life":"caaa44eb-cd40-4177-b930-79d3ef2afe87","Horror":"cdad7e68-1419-41dd-bdce-27753074a640","Fantasy":"cdc58593-87dd-415e-bbc0-2ec27bf404cc","Villainess":"d14322ac-4d6f-4e9b-afd9-629d5f4d8a41","Vampires":"d7d1730f-6eb0-4ba6-9437-602cac38664c","Delinquents":"da2d50ca-3018-4cc0-ac7a-6b7d472a29ea","Monster Girls":"dd1f77c5-dea9-4e2b-97ae-224af09caf99","Shota":"ddefd648-5140-4e5f-ba18-4eca4071d19b","Police":"df33b754-73a3-4c54-80e6-1a74a8058539","Web Comic":"e197df38-d0e7-43b5-9b09-2842d0c326dd","Slice of Life":"e5301a23-ebd9-49dd-a0cb-2add944c7fe9","Aliens":"e64f6742-c834-471d-8d72-dd51fc02b835","Cooking":"ea2bc92d-1c26-4930-9b7c-d5c0dc1b6869","Supernatural":"eabc5b4c-6aff-42f3-b657-3e90cbd00b75","Mystery":"ee968100-4191-4968-93d3-f82d72be7e46","Adaptation":"f4122d1c-3b44-44d0-9936-ff7502c39ad3","Music":"f42fbf9e-188a-447b-9fdc-f19dc1e4d685","Full Color":"f5ba408b-0e7a-484d-8d49-4e9125ac96de","Tragedy":"f8f62932-27da-4fe4-8ee1-6779a8c5edba","Gyaru":"fad12b5e-68ba-460e-b933-9ae8318f5b65"} + + // [authors] and [artists] are dynamic map + authors = {} + artists = {} +} \ No newline at end of file