class Wnacg 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 = "wnacg" version = "1.0.2" minAppVersion = "1.0.0" // update url url = "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@main/wnacg.js" get baseUrl() { return `https://${this.loadSetting('domain')}` } // [Optional] account related account = { /** * login, return any value to indicate success * @param account {string} * @param pwd {string} * @returns {Promise} */ login: async (account, pwd) => { let res = await Network.post( `${this.baseUrl}/users-check_login.html`, { 'content-type': 'application/x-www-form-urlencoded' }, `login_name=${encodeURIComponent(account)}&login_pass=${encodeURIComponent(pwd)}` ) if(res.status !== 200) { throw 'Login failed' } let json = JSON.parse(res.body) if(json['html'].includes('登錄成功')) { return 'ok' } throw 'Login failed' }, /** * logout function, clear account related data */ logout: () => { Network.deleteCookies(this.baseUrl) }, // {string?} - register url registerWebsite: null } parseComic(c) { let link = c.querySelector("div.pic_box > a").attributes["href"]; let id = RegExp("(?<=-aid-)[0-9]+").exec(link)[0]; let image = c.querySelector("div.pic_box > a > img").attributes["src"]; image = `https:${image}`; let name = c.querySelector("div.info > div.title > a").text; let info = c.querySelector("div.info > div.info_col").text.trim(); info = info.replaceAll('\n', ''); info = info.replaceAll('\t', ''); return new Comic({ id: id, title: name, cover: image, description: info, }) } // explore page list explore = [ { // title of the page. // title is used to identify the page, it should be unique title: "紳士漫畫", /// multiPartPage or multiPageComicList or mixed type: "multiPartPage", /** * load function * @param page {number | null} - page number, null for `singlePageWithMultiPart` type * @returns {{}} * - for `multiPartPage` type, return [{title: string, comics: Comic[], viewMore: string?}] * - 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 res = await Network.get(this.baseUrl, {}) if(res.status !== 200) { throw `Invalid Status Code ${res.status}` } let document = new HtmlDocument(res.body) let titleBlocks = document.querySelectorAll("div.title_sort"); let comicBlocks = document.querySelectorAll("div.bodywrap"); if (titleBlocks.length !== comicBlocks.length) { throw "Invalid Page" } let result = [] for (let i = 0; i < titleBlocks.length; i++) { let title = titleBlocks[i].querySelector("div.title_h2").text.replaceAll(/\s+/g, '') let link = titleBlocks[i].querySelector("div.r > a").attributes["href"] let comics = [] let comicBlock = comicBlocks[i] let comicElements = comicBlock.querySelectorAll("div.gallary_wrap > ul.cc > li") for (let comicElement of comicElements) { comics.push(this.parseComic(comicElement)) } result.push({ title: title, comics: comics, viewMore: `category:${title}@${link}` }) } document.dispose() 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: "最新", // fixed or random // if random, need to provide `randomNumber` field, which indicates the number of comics to display at the same time type: "fixed", // number of comics to display at the same time // randomNumber: 5, categories: ["最新"], // category or search // if `category`, use categoryComics.load to load comics // if `search`, use search.load to load comics itemType: "category", // [Optional] {string[]?} must have same length as categories, used to provide loading param for each category categoryParams: ["/albums.html"], // [Optional] {string} cannot be used with `categoryParams`, set all category params to this value groupParam: null, }, { // title of the part name: "同人誌", // fixed or random // if random, need to provide `randomNumber` field, which indicates the number of comics to display at the same time type: "fixed", // number of comics to display at the same time // randomNumber: 5, categories: ["同人誌", "漢化", "日語", "English", "CG畫集", "3D漫畫", "寫真Cosplay"], // category or search // if `category`, use categoryComics.load to load comics // if `search`, use search.load to load comics itemType: "category", // [Optional] {string[]?} must have same length as categories, used to provide loading param for each category categoryParams: [ "/albums-index-cate-5.html", "/albums-index-cate-1.html", "/albums-index-cate-12.html", "/albums-index-cate-16.html", "/albums-index-cate-2.html", "/albums-index-cate-22.html", "/albums-index-cate-3.html", ], // [Optional] {string} cannot be used with `categoryParams`, set all category params to this value groupParam: null, }, { // title of the part name: "單行本", // fixed or random // if random, need to provide `randomNumber` field, which indicates the number of comics to display at the same time type: "fixed", // number of comics to display at the same time // randomNumber: 5, categories: ["單行本", "漢化", "日語", "English",], // category or search // if `category`, use categoryComics.load to load comics // if `search`, use search.load to load comics itemType: "category", // [Optional] {string[]?} must have same length as categories, used to provide loading param for each category categoryParams: [ "/albums-index-cate-6.html", "/albums-index-cate-9.html", "/albums-index-cate-13.html", "/albums-index-cate-17.html", ], // [Optional] {string} cannot be used with `categoryParams`, set all category params to this value groupParam: null, }, { // title of the part name: "雜誌短篇", // fixed or random // if random, need to provide `randomNumber` field, which indicates the number of comics to display at the same time type: "fixed", // number of comics to display at the same time // randomNumber: 5, categories: ["雜誌短篇", "漢化", "日語", "English",], // category or search // if `category`, use categoryComics.load to load comics // if `search`, use search.load to load comics itemType: "category", // [Optional] {string[]?} must have same length as categories, used to provide loading param for each category categoryParams: [ "/albums-index-cate-7.html", "/albums-index-cate-10.html", "/albums-index-cate-14.html", "/albums-index-cate-18.html", ], // [Optional] {string} cannot be used with `categoryParams`, set all category params to this value groupParam: null, }, { // title of the part name: "韓漫", // fixed or random // if random, need to provide `randomNumber` field, which indicates the number of comics to display at the same time type: "fixed", // number of comics to display at the same time // randomNumber: 5, categories: ["韓漫", "漢化", "生肉",], // category or search // if `category`, use categoryComics.load to load comics // if `search`, use search.load to load comics itemType: "category", // [Optional] {string[]?} must have same length as categories, used to provide loading param for each category categoryParams: [ "/albums-index-cate-19.html", "/albums-index-cate-20.html", "/albums-index-cate-21.html", ], // [Optional] {string} cannot be used with `categoryParams`, set all category params to this value groupParam: null, }, ], // 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 url = this.baseUrl + param if(page !== 0) { if (!url.includes("-")) { url = url.replaceAll(".html", "-.html"); } url = url.replaceAll("index", ""); let lr = url.split("albums-"); lr[1] = `index-page-${page}${lr[1]}`; url = `${lr[0]}albums-${lr[1]}`; } let res = await Network.get(url, {}) if(res.status !== 200) { throw `Invalid Status Code ${res.status}` } let document = new HtmlDocument(res.body) let comicElements = document.querySelectorAll("div.grid div.gallary_wrap > ul.cc > li") let comics = [] for (let comicElement of comicElements) { comics.push(this.parseComic(comicElement)) } let pagesLink = document.querySelectorAll("div.f_left.paginator > a"); let pages = Number(pagesLink[pagesLink.length-1].text) document.dispose() return { comics: comics, maxPage: pages, } }, } /// 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 url = `${this.baseUrl}/search/?q=${encodeURIComponent(keyword)}&f=_all&s=create_time_DESC&syn=yes` if(page !== 0) { url += `&p=${page}` } let res = await Network.get(url, {}) if(res.status !== 200) { throw `Invalid Status Code ${res.status}` } let document = new HtmlDocument(res.body) let comicElements = document.querySelectorAll("div.grid div.gallary_wrap > ul.cc > li") let comics = [] for (let comicElement of comicElements) { comics.push(this.parseComic(comicElement)) } let total = document.querySelectorAll("p.result > b")[0].text.replaceAll(',', '') const comicsPerPage = 24 let pages = Math.ceil(Number(total) / comicsPerPage) document.dispose() return { comics: comics, maxPage: pages, } }, } // favorite related favorites = { // whether support multi folders multiFolder: true, /** * 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) => { if(!isAdding) { let res = await Network.get(`${this.baseUrl}/users-fav_del-id-${favoriteId}.html?ajax=true&_t=${randomDouble(0, 1)}`, {}) if(res.status !== 200) { throw 'Delete failed' } } else { let res = await Network.post(`${this.baseUrl}/users-save_fav-id-${comicId}.html`, { 'content-type': 'application/x-www-form-urlencoded' }, `favc_id=${folderId}`) if(res.status !== 200) { throw 'Delete failed' } } 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 res = await Network.get(`${this.baseUrl}/users-addfav-id-210814.html`, {}) if(res.status !== 200) { throw 'Load failed' } let document = new HtmlDocument(res.body) let data = {} document.querySelectorAll("option").forEach((option => { if (option.attributes["value"] === "") return data[option.attributes["value"]] = option.text })) return { folders: data, favorited: [] } }, /** * add a folder * @param name {string} * @returns {Promise} - return any value to indicate success */ addFolder: async (name) => { let res = await Network.post(`${this.baseUrl}/users-favc_save-id.html`, { 'content-type': 'application/x-www-form-urlencoded' }, `favc_name=${encodeURIComponent(name)}`) if(res.status !== 200) { throw 'Add failed' } return 'ok' }, /** * delete a folder * @param folderId {string} * @returns {Promise} - return any value to indicate success */ deleteFolder: async (folderId) => { let res = await Network.get(`${this.baseUrl}/users-favclass_del-id-${folderId}.html?ajax=true&_t=${randomDouble()}`, {}) if(res.status !== 200) { throw 'Delete failed' } 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 url = `${this.baseUrl}/users-users_fav-page-${page}-c-${folder}.html.html` let res = await Network.get(url, {}) if(res.status !== 200) { throw `Invalid Status Code ${res.status}` } let document = new HtmlDocument(res.body) let comicBlocks = document.querySelectorAll("div.asTB") let comics = comicBlocks.map((comic) => { let cover = comic.querySelector("div.asTBcell.thumb > div > img").attributes["src"] cover = 'https:' + cover let time = comic.querySelector("div.box_cel.u_listcon > p.l_catg > span").text.replaceAll("創建時間:", "") let name = comic.querySelector("div.box_cel.u_listcon > p.l_title > a").text; let link = comic.querySelector("div.box_cel.u_listcon > p.l_title > a").attributes["href"]; let id = RegExp("(?<=-aid-)[0-9]+").exec(link)[0]; let info = comic.querySelector("div.box_cel.u_listcon > p.l_detla").text; let pages = Number(RegExp("(?<=頁數:)[0-9]+").exec(info)[0]) let delUrl = comic.querySelector("div.box_cel.u_listcon > p.alopt > a").attributes["onclick"]; let favoriteId = RegExp("(?<=del-id-)[0-9]+").exec(delUrl)[0]; return new Comic({ id: id, title: name, subtitle: time, cover: cover, pages: pages, favoriteId: favoriteId, }) }) let pages = 1 let pagesLink = document.querySelectorAll("div.f_left.paginator > a") if(pagesLink.length > 0) { pages = Number(pagesLink[pagesLink.length-1].text) } document.dispose() return { comics: comics, maxPage: pages, } } } /// single comic related comic = { /** * load comic info * @param id {string} * @returns {Promise} */ loadInfo: async (id) => { let res = await Network.get(`${this.baseUrl}/photos-index-page-1-aid-${id}.html`, {}) if(res.status !== 200) { throw `Invalid Status Code ${res.status}` } let document = new HtmlDocument(res.body) let title = document.querySelector("div.userwrap > h2").text let cover = document.querySelector("div.userwrap > div.asTB > div.asTBcell.uwthumb > img").attributes["src"] cover = 'https:' + cover cover = cover.substring(0, 6) + cover.substring(8) let labels = document.querySelectorAll("div.asTBcell.uwconn > label") let category = labels[0].text.split(":")[1] let pages = labels[1].text.split(":")[1]; let tagsDom = document.querySelectorAll("a.tagshow"); let tags = new Map() tags.set("頁數", [pages]) tags.set("分類", [category]) if(tagsDom.length > 0) { tags.set("標籤", tagsDom.map((e) => e.text)) } let description = document.querySelector("div.asTBcell.uwconn > p").text; let uploader = document.querySelector("div.asTBcell.uwuinfo > a > p").text; return new ComicDetails({ id: id, title: title, cover: cover, pages: pages, tags: tags, description: description, uploader: uploader, }) }, /** * [Optional] load thumbnails of a comic * @param id {string} * @param next {string | null | undefined} - 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) => { next = next || '1' let res = await Network.get(`${this.baseUrl}/photos-index-page-${next}-aid-${id}.html`, {}); if(res.status !== 200) { throw `Invalid Status Code ${res.status}` } let document = new HtmlDocument(res.body) let thumbnails = document.querySelectorAll("div.pic_box.tb > a > img").map((e) => { return 'https:' + e.attributes["src"] }) next = (Number(next)+1).toString() let pagesLink = document.querySelector("div.f_left.paginator").children if(pagesLink[pagesLink.length-1].classNames.includes("thispage")) { next = null } return { thumbnails: thumbnails, next: next } }, /** * load images of a chapter * @param comicId {string} * @param epId {string?} * @returns {Promise<{images: string[]}>} */ loadEp: async (comicId, epId) => { let res = await Network.get(`${this.baseUrl}/photos-gallery-aid-${comicId}.html`, {}) if(res.status !== 200) { throw `Invalid Status Code ${res.status}` } const regex = RegExp(String.raw`//[^"]+/[^"]+\.[^"]+`, 'g'); const matches = Array.from(res.body.matchAll(regex)); return { images: matches.map((e) => 'https:' + e[0].substring(0, e[0].length-1)) } }, /** * [Optional] Handle tag click event * @param namespace {string} * @param tag {string} * @returns {{action: string, keyword: string, param: string?}} */ onClickTag: (namespace, tag) => { return { action: 'search', keyword: tag, } }, } settings = { domain: { title: "Domain", type: "input", validator: '^(?!:\\/\\/)(?=.{1,253})([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,}$', default: 'www.wnacg.com', }, } }