diff --git a/.gitignore b/.gitignore index d48c759..231a0fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea -.vscode \ No newline at end of file +.vscode +test/ \ No newline at end of file diff --git a/zaimanhua.js b/zaimanhua.js new file mode 100644 index 0000000..9e88a3c --- /dev/null +++ b/zaimanhua.js @@ -0,0 +1,583 @@ +/** @type {import('./_venera_.js')} */ +class ZaiManHua extends ComicSource { + // Note: The fields which are marked as [Optional] should be removed if not used + + // name of the source + name = "再漫画"; + + // unique id of the source + key = "zaimanhua"; + + version = "1.0.0"; + + minAppVersion = "1.4.0"; + + // update url + url = "https://git.nyne.dev/nyne/venera-configs/raw/branch/main/zaimanhua.js"; + + /** + * fetch html content + * @param url {string} + * @param headers {object?} + * @returns {Promise<{body: string, status: number, headers: object}>} + */ + async fetchHtml(url, headers = {}) { + let res = await Network.get(url, headers); + return res; + } + + /** + * fetch json content + * @param url {string} + * @param headers {object?} + * @returns {Promise<{errno:number,errmsg:string,data:object}>} + */ + async fetchJson(url, headers = {}) { + let res = await Network.get(url, headers); + return JSON.parse(res.body); + } + + /** + * parse comic from html element + * @param comic {HtmlElement} + * @returns {Comic} + */ + parseCoverComic(comic) { + let title = comic.querySelector("p > a").text.trim(); + let url = comic.querySelector("p > a").attributes["href"]; + let id = url.split("/").pop().split(".")[0]; + let cover = comic.querySelector("img").attributes["src"]; + let subtitle = comic.querySelector(".auth")?.text.trim(); + if (!subtitle) { + subtitle = comic + .querySelector(".con_author") + ?.text.replace("作者:", "") + .trim(); + } + let description = comic.querySelector(".tip")?.text.trim(); + + return new Comic({ title, id, subtitle, url, cover, description }); + } + /** + * parse comic from html element + * @param comic {HtmlElement} + * @returns {Comic} + */ + parseListComic(comic) { + let cover = comic.querySelector("img").attributes["src"]; + let title = comic.querySelector("h3 > a").text.trim(); + let url = comic.querySelector("h3 > a").attributes["href"]; + let id = url.split("/").pop().split(".")[0]; + + let infos = comic.querySelectorAll("p"); + + let subtitle = infos[0]?.text.replace("作者:", "").trim(); + let classify = infos[1]?.text.replace("类型:", "").trim().split("/"); + let status = infos[2]?.text.replace("状态:", "").trim(); + let description = infos[3]?.text.replace("最新:", "").trim(); + let tags = { + 类型: classify, + 状态: status, + }; + + return new Comic({ + title, + id, + subtitle, + tags, + url, + cover, + description, + }); + } + + /** + * parse json content + * @param e object + * @returns {Comic} + */ + parseJsonComic(e) { + let cover = e.cover; + let title = e.name; + let id = e.comic_py; + let url = `https://www.zaimanhua.com/info/${e.id}.html`; + + let subtitle = e.authors; + + let classify = e.types; + let status = e.status; + let description = e.last_update_chapter_name; + let tags = { + 类型: classify, + 状态: status, + }; + + return new Comic({ + title, + id, + subtitle, + tags, + url, + cover, + description, + }); + } + + /** + * [Optional] init function + */ + init() { + this.domain = "https://www.zaimanhua.com"; + this.imgBase = "https://images.zaimanhua.com"; + this.baseUrl = "https://manhua.zaimanhua.com"; + } + + // explore page list + explore = [ + { + // title of the page. + // title is used to identify the page, it should be unique + title: this.name, + + /// TODO multiPartPage + type: "singlePageWithMultiPart", + + /** + * load function + * @param page {number | null} - page number, null for `singlePageWithMultiPart` type + * @returns {{}} + * - for `multiPartPage` type, return [{title: string, comics: Comic[], viewMore: PageJumpTarget}] + * - for `multiPageComicList` type, for each page(1-based), return {comics: Comic[], maxPage: number} + * - for `mixed` type, use param `page` as index. for each index(0-based), return {data: [], maxPage: number?}, data is an array contains Comic[] or {title: string, comics: Comic[], viewMore: string?} + */ + load: async (page) => { + let result = {}; + let res = await this.fetchHtml(this.domain); + if (res.status !== 200) { + throw `Invalid status code: ${res.status}`; + } + let document = new HtmlDocument(res.body); + // 推荐 + let recommend_title = document.querySelector( + ".new_recommend_l h2" + )?.text; + let recommend_comics = document + .querySelectorAll(".new_recommend_l li") + .map(this.parseCoverComic); + result[recommend_title] = recommend_comics; + // 更新 + let update_title = document.querySelector(".new_update_l h2")?.text; + let update_comics = document + .querySelectorAll(".new_update_l li") + .map(this.parseCoverComic); + result[update_title] = update_comics; + // 少男漫画 + // 少女漫画 + // 冒险,搞笑,奇幻 + return result; + }, + }, + ]; + + // categories + category = { + /// title of the category page, used to identify the page, it should be unique + title: "", + parts: [ + { + // title of the part + name: "Theme", + + // 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: "fixed", + + // Remove this if type is dynamic + categories: [ + { + label: "Category1", + /** + * @type {PageJumpTarget} + */ + target: { + page: "category", + attributes: { + category: "category1", + param: null, + }, + }, + }, + ] + + // number of comics to display at the same time + // randomNumber: 5, + + // load function for dynamic type + // loader: async () => { + // return [ + // // ... + // ] + // } + } + ], + // enable ranking page + enableRankingPage: false, + } + + /// category comic loading related + categoryComics = { + /** + * load comics of a category + * @param category {string} - category name + * @param param {string?} - category param + * @param options {string[]} - options from optionList + * @param page {number} - page number + * @returns {Promise<{comics: Comic[], maxPage: number}>} + */ + load: async (category, param, options, page) => { + /* + ``` + let data = JSON.parse((await Network.get('...')).body) + let maxPage = data.maxPage + + function parseComic(comic) { + // ... + + return new Comic({ + id: id, + title: title, + subTitle: author, + cover: cover, + tags: tags, + description: description + }) + } + + return { + comics: data.list.map(parseComic), + maxPage: maxPage + } + ``` + */ + }, + // provide options for category comic loading + optionList: [ + { + // For a single option, use `-` to separate the value and text, left for value, right for text + options: [ + "newToOld-New to Old", + "oldToNew-Old to New" + ], + // [Optional] {string[]} - show this option only when the value not in the list + notShowWhen: null, + // [Optional] {string[]} - show this option only when the value in the list + showWhen: null + } + ], + ranking: { + // For a single option, use `-` to separate the value and text, left for value, right for text + options: [ + "day-Day", + "week-Week" + ], + /** + * load ranking comics + * @param option {string} - option from optionList + * @param page {number} - page number + * @returns {Promise<{comics: Comic[], maxPage: number}>} + */ + load: async (option, page) => { + /* + ``` + let data = JSON.parse((await Network.get('...')).body) + let maxPage = data.maxPage + + function parseComic(comic) { + // ... + + return new Comic({ + id: id, + title: title, + subTitle: author, + cover: cover, + tags: tags, + description: description + }) + } + + return { + comics: data.list.map(parseComic), + maxPage: maxPage + } + ``` + */ + } + } + } + + /// 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 data = JSON.parse((await Network.get('...')).body) + let maxPage = data.maxPage + + function parseComic(comic) { + // ... + + return new Comic({ + id: id, + title: title, + subTitle: author, + cover: cover, + tags: tags, + description: description + }) + } + + return { + comics: data.list.map(parseComic), + maxPage: maxPage + } + ``` + */ + }, + + /** + * load search result with next page token. + * The field will be ignored if `load` function is implemented. + * @param keyword {string} + * @param options {(string)[]} - options from optionList + * @param next {string | null} + * @returns {Promise<{comics: Comic[], maxPage: number}>} + */ + loadNext: async (keyword, options, next) => {}, + + // provide options for search + optionList: [ + { + // [Optional] default is `select` + // type: select, multi-select, dropdown + // For select, there is only one selected value + // For multi-select, there are multiple selected values or none. The `load` function will receive a json string which is an array of selected values + // For dropdown, there is one selected value at most. If no selected value, the `load` function will receive a null + type: "select", + // For a single option, use `-` to separate the value and text, left for value, right for text + options: ["0-time", "1-popular"], + // option label + label: "sort", + // default selected options. If not set, use the first option as default + default: null, + }, + ], + + // enable tags suggestions + enableTagsSuggestions: false, + }; + + // favorite related + favorites = { + // whether support multi folders + multiFolder: false, + /** + * add or delete favorite. + * throw `Login expired` to indicate login expired, App will automatically re-login and re-add/delete favorite + * @param comicId {string} + * @param folderId {string} + * @param isAdding {boolean} - true for add, false for delete + * @param favoriteId {string?} - [Comic.favoriteId] + * @returns {Promise} - return any value to indicate success + */ + addOrDelFavorite: async (comicId, folderId, isAdding, favoriteId) => { + /* + ``` + let res = await Network.post('...') + if (res.status === 401) { + throw `Login expired`; + } + return 'ok' + ``` + */ + }, + /** + * load favorite folders. + * throw `Login expired` to indicate login expired, App will automatically re-login retry. + * if comicId is not null, return favorite folders which contains the comic. + * @param comicId {string?} + * @returns {Promise<{folders: {[p: string]: string}, favorited: string[]}>} - `folders` is a map of folder id to folder name, `favorited` is a list of folder id which contains the comic + */ + loadFolders: async (comicId) => { + /* + ``` + let data = JSON.parse((await Network.get('...')).body) + + let folders = {} + + data.folders.forEach((f) => { + folders[f.id] = f.name + }) + + return { + folders: folders, + favorited: data.favorited + } + ``` + */ + }, + /** + * add a folder + * @param name {string} + * @returns {Promise} - return any value to indicate success + */ + addFolder: async (name) => { + /* + ``` + let res = await Network.post('...') + if (res.status === 401) { + throw `Login expired`; + } + return 'ok' + ``` + */ + }, + /** + * delete a folder + * @param folderId {string} + * @returns {Promise} - return any value to indicate success + */ + deleteFolder: async (folderId) => { + /* + ``` + let res = await Network.delete('...') + if (res.status === 401) { + throw `Login expired`; + } + return 'ok' + ``` + */ + }, + /** + * load comics in a folder + * throw `Login expired` to indicate login expired, App will automatically re-login retry. + * @param page {number} + * @param folder {string?} - folder id, null for non-multi-folder + * @returns {Promise<{comics: Comic[], maxPage: number}>} + */ + loadComics: async (page, folder) => { + /* + ``` + let data = JSON.parse((await Network.get('...')).body) + let maxPage = data.maxPage + + function parseComic(comic) { + // ... + + return new Comic{ + id: id, + title: title, + subTitle: author, + cover: cover, + tags: tags, + description: description + } + } + + return { + comics: data.list.map(parseComic), + maxPage: maxPage + } + ``` + */ + }, + /** + * load comics with next page token + * @param next {string | null} - next page token, null for first page + * @param folder {string} + * @returns {Promise<{comics: Comic[], next: string?}>} + */ + loadNext: async (next, folder) => {}, + /** + * If the comic source only allows one comic in one folder, set this to true. + */ + singleFolderForSingleComic: false, + }; + + /// single comic related + comic = { + /** + * load comic info + * @param id {string} + * @returns {Promise} + */ + loadInfo: async (id) => {}, + /** + * [Optional] load thumbnails of a comic + * + * To render a part of an image as thumbnail, return `${url}@x=${start}-${end}&y=${start}-${end}` + * - If width is not provided, use full width + * - If height is not provided, use full height + * @param id {string} + * @param next {string?} - next page token, null for first page + * @returns {Promise<{thumbnails: string[], next: string?}>} - `next` is next page token, null for no more + */ + loadThumbnails: async (id, next) => { + /* + ``` + let data = JSON.parse((await Network.get('...')).body) + + return { + thumbnails: data.list, + next: next, + } + ``` + */ + }, + + /** + * load images of a chapter + * @param comicId {string} + * @param epId {string?} + * @returns {Promise<{images: string[]}>} + */ + loadEp: async (comicId, epId) => { + /* + ``` + return { + // string[] + images: images + } + ``` + */ + }, + /** + * [Optional] provide configs for an image loading + * @param url + * @param comicId + * @param epId + * @returns {ImageLoadingConfig | Promise} + */ + onImageLoad: (url, comicId, epId) => { + return {}; + }, + /** + * [Optional] provide configs for a thumbnail loading + * @param url {string} + * @returns {ImageLoadingConfig | Promise} + * + * `ImageLoadingConfig.modifyImage` and `ImageLoadingConfig.onLoadFailed` will be ignored. + * They are not supported for thumbnails. + */ + onThumbnailLoad: (url) => { + return {}; + }, + }; +}