class Ehentai extends ComicSource { // Note: The fields which are marked as [Optional] should be removed if not used // name of the source name = "ehentai" // unique id of the source key = "ehentai" version = "1.0.0" minAppVersion = "1.0.0" // update url url = "https://raw.githubusercontent.com/venera-app/venera-configs/refs/heads/main/ehentai.js" /** * cached api key * @type {string | null} */ apiKey = null /** * cached uid key * @type {string | null} */ uid = null /** * @param url * @returns {{id: string, token: string}} */ parseUrl(url) { let segments = url.split("/") let id = segments[4] let token = segments[5] return { id: id, token: token } } // [Optional] account related account = { /** * [Optional] login with webview */ loginWithWebview: { url: "https://forums.e-hentai.org/index.php?act=Login&CODE=00", /** * check login status * @param url {string} - current url * @param title {string} - current title * @returns {boolean} - return true if login success */ checkStatus: (url, title) => { return title === "E-Hentai Forums"; }, onLoginSuccess: async () => { let cookies = await Network.getCookies("https://forums.e-hentai.org") cookies.forEach((cookie) => { cookie.domain = ".exhentai.org" }) Network.setCookies("https://exhentai.org", cookies) }, }, loginWithCookies: { fields: [ "ipb_member_id", "ipb_pass_hash", "igneous", "star", ], /** * Validate cookies, return false if cookies are invalid. * * Use `Network.setCookies` to set cookies before validate. * @param values {string[]} - same order as `fields` * @returns {Promise} */ validate: async (values) => { if (values.length !== 4) { return false } if (values[0].length === 0 || values[1].length === 0) { return false } let cookies = [] for (let i = 0; i < values.length; i++) { cookies.push(new Cookie({ name: this.account.loginWithCookies.fields[i], value: values[i], domain: ".e-hentai.org" })) cookies.push(new Cookie({ name: this.account.loginWithCookies.fields[i], value: values[i], domain: ".exhentai.org" })) } Network.deleteCookies('https://e-hentai.org') Network.setCookies('https://e-hentai.org', cookies) let res = await Network.get( "https://forums.e-hentai.org/", { "referer": "https://forums.e-hentai.org/index.php?", "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "accept-encoding": "gzip, deflate, br", "accept-language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7" }); if (res.status !== 200) { return false } let document = new HtmlDocument(res.body) let name = document.querySelector("div#userlinks > p.home > b > a"); document.dispose() return name != null } }, /** * logout function, clear account related data */ logout: () => { Network.deleteCookies("https://e-hentai.org"); Network.deleteCookies("https://forums.e-hentai.org"); Network.deleteCookies("https://exhentai.org"); }, // {string?} - register url registerWebsite: null } get baseUrl() { return 'https://' + this.loadSetting("domain"); } get apiUrl() { return this.baseUrl.includes("exhentai") ? "https://exhentai.org/api.php" : "https://api.e-hentai.org/api.php" } getStarsFromPosition(position) { let i = 0; while (position[i] !== ";") { i++; if (i === position.length) { break; } } switch (position.substring(0, i)) { case "background-position:0px -1px": return 5; case "background-position:0px -21px": return 4.5; case "background-position:-16px -1px": return 4; case "background-position:-16px -21px": return 3.5; case "background-position:-32px -1px": return 3; case "background-position:-32px -21px": return 2.5; case "background-position:-48px -1px": return 2; case "background-position:-48px -21px": return 1.5; case "background-position:-64px -1px": return 1; case "background-position:-64px -21px": return 0.5; } return 0.5; } /** * * @param url {string} * @param isLeaderBoard {boolean} * @returns {Promise<{comics: Comic[], next: string?}>} */ async getGalleries(url, isLeaderBoard) { let t = isLeaderBoard ? 1 : 0; let res = await Network.get(url, {}); if (res.status !== 200) { throw `Invalid status code: ${res.status}` } if(res.body.trim().length === 0) { let cookies = await Network.getCookies('https://e-hentai.org') cookies.forEach((c) => { c.domain = '.exhentai.org' }) Network.deleteCookies('https://exhentai.org') Network.setCookies('https://exhentai.org', cookies) throw `Exception: empty data\nYou may not have permission to access this page.` } if(res.body[0] !== '<') { if(res.body.includes("IP")) { throw "Your IP address has been banned" } throw "Failed to load page" } let document = new HtmlDocument(res.body); let galleries = []; // compact mode for (let item of document.querySelectorAll("table.itg.gltc > tbody > tr")) { try { let type = item.children[0 + t].children[0].text; let time = item.children[1 + t].children[2].children[0].text; let stars = this.getStarsFromPosition(item.children[1 + t].children[2].children[1].attributes["style"]) let cover = item.children[1 + t].children[1].children[0].children[0].attributes["src"]; if (cover[0] === 'd') { cover = item.children[1 + t].children[1].children[0].children[0].attributes["data-src"]; } let title = item.children[2 + t].children[0].children[0].text; let link = item.children[2 + t].children[0].attributes["href"]; let uploader = ""; let pages; try { pages = Number(item.children[3 + t].children[1].text.match(/\d+/)[0]); uploader = item.children[3 + t].children[0].children[0].text; } catch(e) {} let tags = []; let language = null for (let node of item.children[2 + t].children[0].children[1].children) { let tag = node.attributes["title"] if (tag.startsWith("language:")) { let l = tag.split(":")[1].trim() language = l === 'translated' ? language : l continue } tags.push(tag) } galleries.push(new Comic({ id: link, title: title, subTitle: uploader, cover: cover, tags: tags, description: time, stars: stars, maxPage: pages, language: language })); } catch(e) { } } // Thumbnail mode for (let item of document.querySelectorAll("div.gl1t")) { try { let title = item.querySelector("a")?.text ?? "Unknown"; let type = item.querySelector("div.gl5t > div > div.cs")?.text ?? "Unknown"; let time = item.querySelectorAll("div.gl5t > div > div").find((element) => !isNaN(Date.parse(element.text)))?.text; let coverPath = item.querySelector("img")?.attributes["src"] ?? ""; let stars = this.getStarsFromPosition(item.querySelector("div.gl5t > div > div.ir")?.attributes["style"] ?? ""); let link = item.querySelector("a")?.attributes["href"] ?? ""; let pages = Number(item.querySelectorAll("div.gl5t > div > div").find((element) => element.text.includes("pages"))?.text.match(/\d+/)[0] ?? "0"); galleries.push(new Comic({ id: link, title: title, description: time, stars: stars, maxPage: pages, })); } catch (e) { //忽视 } } // Extended mode for (let item of document.querySelectorAll("table.itg.glte > tbody > tr")) { try { let title = item.querySelector("td.gl2e > div > a > div > div.glink")?.text ?? "Unknown"; let type = item.querySelector("td.gl2e > div > div.gl3e > div.cn")?.text ?? "Unknown"; let time = item.querySelectorAll("td.gl2e > div > div.gl3e > div").find((element) => !isNaN(Date.parse(element.text)))?.text ?? "Unknown"; let uploader = item.querySelector("td.gl2e > div > div.gl3e > div > a")?.text ?? "Unknown"; let coverPath = item.querySelector("td.gl1e > div > a > img")?.attributes["src"] ?? ""; let stars = this.getStarsFromPosition(item.querySelector("td.gl2e > div > div.gl3e > div.ir")?.attributes["style"] ?? ""); let link = item.querySelector("td.gl1e > div > a")?.attributes["href"] ?? ""; let tags = item.querySelectorAll("div.gtl").map((e) => e.attributes["title"] ?? ""); let pages = Number(item.querySelectorAll("td.gl2e > div > div.gl3e > div").find((element) => element.text.includes("pages"))?.text.match(/\d+/)[0] ?? ""); let language = tags.find((e) => e.startsWith("language:") && !e.includes('translated'))?.split(":")[1].trim() ?? null; galleries.push(new Comic({ id: link, title: title, subTitle: uploader, cover: coverPath, tags: tags, description: time, stars: stars, maxPage: pages, language: language })) } catch (e) { //忽视 } } // minimal mode for (let item of document.querySelectorAll("table.itg.gltm > tbody > tr")) { try { let title = item.querySelector("td.gl3m > a > div.glink")?.text ?? "Unknown"; let type = item.querySelector("td.gl1m > div.cs")?.text ?? "Unknown"; let time = item.querySelectorAll("td.gl2m > div").find((element) => !isNaN(Date.parse(element.text)))?.text ?? "Unknown"; let uploader = item.querySelector("td.gl5m > div > a")?.text ?? "Unknown"; let coverPath = item.querySelector("td.gl2m > div > div > img")?.attributes["src"] ?? ""; let stars = this.getStarsFromPosition(item.querySelector("td.gl4m > div.ir")?.attributes["style"] ?? ""); let link = item.querySelector("td.gl3m > a")?.attributes["href"] ?? ""; galleries.push(new Comic({ id: link, title: title, subTitle: uploader, cover: coverPath, description: time, stars: stars, })) } catch (e) { //忽视 } } let nextButton = document.querySelector("a#dnext"); let next = nextButton?.attributes["href"] return { comics: galleries, next: next } } // explore page list explore = [ { // title of the page. // title is used to identify the page, it should be unique title: "eh latest", /// multiPartPage or multiPageComicList or mixed type: "multiPageComicList", loadNext: (next) => { return this.getGalleries(next ?? this.baseUrl, false); } }, { // title of the page. // title is used to identify the page, it should be unique title: "eh popular", /// multiPartPage or multiPageComicList or mixed type: "multiPageComicList", loadNext: (next) => { return this.getGalleries(next ?? `${this.baseUrl}/popular`, false); } }, ] // categories category = { /// title of the category page, used to identify the page, it should be unique title: "ehentai", parts: [], // enable ranking page enableRankingPage: true, } /// category comic loading related categoryComics = { ranking: { // For a single option, use `-` to separate the value and text, left for value, right for text options: [ "15-yesterday", "13-month", "12-year", "11-all" ], /** * 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 res = await this.getGalleries(`https://e-hentai.org/toplist.php?tl=${option}&=${page}`, true); return { comics: res.comics, maxPage: 200, } } } } /// search related search = { /** * load search result with next page token * @param keyword {string} * @param options {(string)[]} - options from optionList * @param next {string | null} * @returns {Promise<{comics: Comic[], maxPage: number}>} */ loadNext: async (keyword, options, next) => { let category = JSON.parse(options[0]); let stars = options[1]; let language = options[2]; let fcats = 1023 for(let c of category) { fcats -= 1 << Number(c) } if(language && !keyword.includes("language:")) { keyword += ` language:${language}` } let url = `${this.baseUrl}/?f_search=${encodeURIComponent(keyword)}` if(fcats) { url += `&f_cats=${fcats}` } if(stars) { url += `&f_srdd=${stars}` } return this.getGalleries(next ?? url, false); }, // provide options for search optionList: [ { // type: select, multi-select, dropdown type: "multi-select", // For a single option, use `-` to separate the value and text, left for value, right for text options: [ "0-Misc", "1-Doujinshi", "2-Manga", "3-Artist CG", "4-Game CG", "5-Image Set", "6-Cosplay", "7-Asian Porn", "8-Non-H", "9-Western", ], // option label label: "Category", // default selected options default: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"], }, { // 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: "dropdown", // For a single option, use `-` to separate the value and text, left for value, right for text options: [ "-", "0-0", "1-1", "2-2", "3-3", "4-4", "5-5", ], // option label label: "Min Stars", }, { // type: select, multi-select, dropdown type: "dropdown", // For a single option, use `-` to separate the value and text, left for value, right for text options: [ "-", "chinese-Chinese", "english-English", "japanese-Japanese", ], // option label label: "Language", }, ], // enable tags suggestions enableTagsSuggestions: true, } // 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) => { let parsed = this.parseUrl(comicId) let id = parsed.id let token = parsed.token if(isAdding) { let res = await Network.post( `${this.baseUrl}/gallerypopups.php?gid=${id}&t=${token}&act=addfav`, { "Content-Type": "application/x-www-form-urlencoded", }, `favcat=${folderId}&favnote=&apply=Add+to+Favorites&update=1` ); if (res.status !== 200 || res.body.length === 0 || res.body[0] !== "<") { throw "Failed to add favorite" } return "ok" } else { let res = await Network.post( `${this.baseUrl}/gallerypopups.php?gid=${id}&t=${token}&act=addfav`, { "Content-Type": "application/x-www-form-urlencoded", }, `favcat=favdel&favnote=&apply=Apply+Changes&update=1` ); if (res.status !== 200 || res.body.length === 0 || res.body[0] !== "<") { throw "Failed to delete favorite" } 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}/favorites.php`, {}); if (res.status !== 200) { throw `Invalid status code: ${res.status}` } let document = new HtmlDocument(res.body); let folders = new Map(); folders.set("-1", "All") for (let item of document.querySelectorAll("div.fp")) { let name = item.children[2]?.text ?? `Favorite ${folders.size}` let length = item.children[0]?.text; if(length) { name += ` (${length})` } folders.set((folders.size-1).toString(), name) } document.dispose() let favorited = [] if(comicId) { let comic = await this.comic.loadInfo(comicId) if(comic.isFavorite) { favorited.push(comic.folder) } } return { folders: folders, favorited: favorited } }, loadNext: async (next, folder) => { let url = `${this.baseUrl}/favorites.php`; if(folder !== '-1') { url += `?favcat=${folder}` } return this.getGalleries(next ?? url, false); } } /// single comic related comic = { /** * load comic info * @param id {string} * @returns {Promise} */ loadInfo: async (id) => { let res = await Network.get(id, { 'cookie': 'nw=1' }); if (res.status !== 200) { throw `Invalid status code: ${res.status}` } let document = new HtmlDocument(res.body); let tags = new Map(); for(let tr of document.querySelectorAll("div#taglist > table > tbody > tr")) { tags.set( tr.children[0].text.substring(0, tr.children[0].text.length - 1), tr.children[1].children.map((e) => e.children[0].text) ) } let maxPage = "1" for(let element of document.querySelectorAll("td.gdt2")) { if (element.text.includes("pages")) { maxPage = element.text.match(/\d+/)[0]; } } let isFavorited = true; if(document.querySelector("a#favoritelink")?.text === " Add to Favorites") { isFavorited = false; } let folder = null if(isFavorited) { let position = document .querySelector("div#fav") .children[0] .attributes["style"] .split("background-position:0px -")[1] .split("px;")[0]; folder = (Number(position-2) / 19).toString() } let coverPath = document.querySelector("div#gleft > div#gd1 > div").attributes["style"]; coverPath = RegExp("https?://([-a-zA-Z0-9.]+(/\\S*)?\\.(?:jpg|jpeg|gif|png))").exec(coverPath)[0]; let uploader = document.getElementById("gdn")?.children[0]?.text let stars = Number(document.getElementById("rating_label")?.text?.split(':')?.at(1)?.trim()); let category = document.querySelector("div.cs").text; tags.set("Category", [category]) let time = document.querySelector("div#gdd > table > tbody > tr > td.gdt2").text let script = document.querySelectorAll("script").find((e) => e.text.includes("var token")); let reg = RegExp("var\\s+(\\w+)\\s*=\\s*(.*?);", "g"); let variables = new Map(); for(let match of script.text.matchAll(reg)) { variables.set(match[1], match[2]); } let title = document.querySelector("h1#gn").text; let subtitle = document.querySelector("h1#gj")?.text; if(subtitle != null && subtitle.trim() === "") { subtitle = null; } let comic = new ComicDetails({ id: id, title: title, subTitle: subtitle, cover: coverPath, tags: tags, stars: stars, maxPage: Number(maxPage), isFavorite: isFavorited, uploader: uploader, uploadTime: time, url: id, }) comic.folder = folder comic.token = variables.get("token") this.apikey = variables.get("apikey") if(this.apikey[0] === '"') { this.apikey = this.apikey.substring(1, this.apikey.length - 1) } this.uid = variables.get("apiuid") document.dispose() return comic; }, /** * [Optional] load thumbnails of a comic * @param id {string} * @param next {string?} - next page token, null for first page * @returns {Promise<{thumbnails: string[], next: string?, urls: string[]}>} - `next` is next page token, null for no more */ loadThumbnails: async (id, next) => { let url = id if(next != null) { url += `?p=${next}` } let res = await Network.get(url, { 'cache-time': 'long', 'prevent-parallel': 'true', }); if(res.status !== 200) { throw `Invalid status code: ${res.status}` } let document = new HtmlDocument(res.body); let images = document.querySelectorAll("div.gdtm > div").map((e) => { let style = e.attributes['style']; let r = style.split("background:transparent url(")[1] let url = r.split(")")[0] let position = Number(r.split(') -')[1].split('px')[0]) return url + `@x=${position}-${position + 100}` }); images.push(...document.querySelectorAll("div.gdtl > a > img").map((e) => e.attributes["src"])) if(images.length === 0) { for(let e of document.querySelectorAll("div.gt100 > a > div") .map(e => e.children.length === 0 ? e : e.children[0])) { let style = e.attributes['style']; let r = style.split("background:transparent url(")[1] let url = r.split(")")[0] if(r.includes('px')) { let position = Number(r.split(') -')[1].split('px')[0]) url += `@x=${position}-${position + 100}` } images.push(url) } for(let e of document.querySelectorAll("div.gt200 > a > div") .map(e => e.children.length === 0 ? e : e.children[0])) { let style = e.attributes['style']; let r = style.split("background:transparent url(")[1] let url = r.split(")")[0] if(r.includes('px')) { let position = Number(r.split(') -')[1].split('px')[0]) url += `@x=${position}-${position + 100}` } images.push(url) } } let urls = document.querySelectorAll("table.ptb > tbody > tr > td > a").map((e) => e.attributes["href"]) let pageNumbers = urls.map((e) => { let n = Number(e.split("=")[1]) if(isNaN(n)) { return 0 } return n }) let maxPage = Math.max(...pageNumbers) let current = 0 if(next) { current = Number(next) } current += 1 if(current > maxPage) { current = null } else { current = current.toString() } let _urls = document.querySelectorAll("div#gdt a").map((e) => e.attributes["href"]) document.dispose() return { thumbnails: images, urls: _urls, next: current } }, /** * rate a comic * @param id * @param rating {number} - [0-10] app use 5 stars, 1 rating = 0.5 stars, * @returns {Promise} */ starRating: async (id, rating) => { let res = await Network.post(this.apiUrl, { 'Content-Type': 'application/json' }, { 'gid': this.parseUrl(id).id, 'token': this.parseUrl(id).token, 'method': 'rategallery', 'rating': rating, 'apikey': this.apikey, 'apiuid': this.uid, }) if(res.status !== 200) { throw `Invalid status code: ${res.status}` } return 'ok' }, getKey: async (url) => { let res = await Network.get(url, { 'cache-time': 'long', 'prevent-parallel': 'true', }) let document = new HtmlDocument(res.body) let script = document.querySelectorAll("script").find((e) => e.text.includes("showkey")); if(script) { let reg = RegExp("showkey=\"(.*?)\"", "g"); let match = reg.exec(script.text) return { 'showkey': match[1] } } script = document.querySelectorAll("script").find((e) => e.text.includes("mpvkey"))?.text; document.dispose() if(script) { let mpvkey = script.split(';').find((e) => e.includes("mpvkey")).replaceAll(' ', '').split('=')[1].replaceAll('"', ''); let imageList = script.split(';').find((e) => e.includes("imagelist")).replaceAll(' ', '').split('=')[1]; return { 'mpvkey': mpvkey, 'imageKeys': JSON.parse(imageList).map((e) => e["k"]) } } throw "Failed to get key" }, /** * load images of a chapter * @param comicId {string} * @param epId {string?} * @returns {Promise<{images: string[]}>} */ loadEp: async (comicId, epId) => { let comic = await this.comic.loadInfo(comicId) return { images: Array.from({length: comic.maxPage}, (_, i) => i.toString()) } }, /** * [Optional] provide configs for an image loading * @param image * @param comicId * @param epId * @returns {{}} */ onImageLoad: async (image, comicId, epId) => { let first = await this.comic.loadThumbnails(comicId) console.log(first) let key = await this.comic.getKey(first.urls[0]) let page = Number(image) console.log(key) let getImageFromApi = async (nl) => { if(key.mpvkey) { let res = await Network.post(this.apiUrl, { 'Content-Type': 'application/json', }, { 'gid': this.parseUrl(comicId).id, "imgkey": key.imageKeys[page], "method": "imagedispatch", "page": Number(image) + 1, "mpvkey": key.mpvkey, "nl": nl, }) let json = JSON.parse(res.body) return { url: json.i.toString(), nl: json.s.toString() } } else { let parseImageKeyFromUrl = (url) => { return url.split("/")[4] } let url = '' if(page < first.thumbnails.length) { url = first.urls[page] } else { let onePageLength = first.thumbnails.length let shouldLoadPage = Math.floor(page / onePageLength) let index = page % onePageLength let thumbnails = await this.comic.loadThumbnails(comicId, shouldLoadPage.toString()) url = thumbnails.urls[index] } let res = await Network.post(this.apiUrl, { 'Content-Type': 'application/json', }, { 'gid': this.parseUrl(comicId).id, "imgkey": parseImageKeyFromUrl(url), "method": "showpage", "page": page + 1, "showkey": key.showkey, "nl": nl, }) let json = JSON.parse(res.body) let i6 = json.i6 let reg = RegExp("nl\\('(.+?)'\\)").exec(i6) nl = reg[1] let image = json.i3 image = image.substring(image.indexOf("src=\"") + 5, image.indexOf("\" style")) return { url: image, nl: nl } } } let res = await getImageFromApi() return { url: res.url, headers: { 'referer': this.baseUrl, } } }, /** * [Optional] provide configs for a thumbnail loading * @param url {string} * @returns {{}} */ onThumbnailLoad: (url) => { if(url.includes('s.exhentai.org')) { url = url.replace("s.exhentai.org", "ehgt.org") } return { url: url, headers: { 'referer': this.baseUrl, } } }, /** * [Optional] load comments * @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) => { let res = await Network.get(`${comicId}?hc=1`, {}); if(res.status !== 200) { throw `Invalid status code: ${res.status}` } let document = new HtmlDocument(res.body) let comments = [] for(let c of document.querySelectorAll('div.c1')) { let name = c.querySelector('div.c3 > a').text let time = c.querySelector('div.c3')?.text?.split("Posted on")?.at(1)?.split('by')?.at(0)?.trim() ?? 'unknown' let content = c.querySelector('div.c6').text let score = Number(c.querySelector('div.c5 > span')?.text) if(isNaN(score)) { score = null } let id = c.previousElementSibling?.attributes['name']?.match(/\d+/)[0] ?? '0' let isUp = c.querySelector(`a#comment_vote_up_${id}`)?.attributes['style']?.length > 0 let isDown = c.querySelector(`a#comment_vote_down_${id}`)?.attributes['style']?.length > 0 comments.push(new Comment({ id: id, content: content, time: time, userName: name, score: score, voteStatus: isUp ? 1 : isDown ? -1 : 0, })) } document.dispose() return { comments: comments, maxPage: 1 } }, /** * [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) => { let res = await Network.post(comicId, { 'Content-Type': 'application/x-www-form-urlencoded', 'referer': comicId, }, `commenttext_new=${encodeURIComponent(content)}`) if(res.status >= 400) { throw `Invalid status code: ${res.status}` } let document = new HtmlDocument(res.body) if(document.querySelector('p.br')) { throw document.querySelector('p.br').text } return 'ok' }, /** * [Optional] vote a comment * @param id {string} - comicId * @param subId {string?} - ComicDetails.subId * @param commentId {string} - commentId * @param isUp {boolean} - true for up, false for down * @param isCancel {boolean} - true for cancel, false for vote * @returns {Promise} - new score */ voteComment: async (id, subId, commentId, isUp, isCancel) => { if(this.apikey == null || this.uid == null) { throw "Login required" } let res = await Network.post(this.apiUrl, { 'Content-Type': 'application/json' }, { 'gid': this.parseUrl(id).id, 'token': this.parseUrl(id).token, 'method': 'votecomment', 'comment_id': commentId, 'comment_vote': isUp ? 1 : -1, 'apikey': this.apikey, 'apiuid': this.uid, }) let json = JSON.parse(res.body) if(json.error) { throw json.error } return json.comment_score }, /** * [Optional] Handle tag click event * @param namespace {string} * @param tag {string} * @returns {{action: string, keyword: string, param: string?}} */ onClickTag: (namespace, tag) => { if(tag.includes(' ')) { tag = `"${tag}"` } return { // 'search' or 'category' action: 'search', keyword: `${namespace}:${tag}`, // {string?} only for category action param: null, } }, /** * [Optional] Handle links */ link: { /** * set accepted domains */ domains: [ 'e-hentai.org', 'exhentai.org' ], /** * parse url to comic id * @param url {string} * @returns {string | null} */ linkToId: (url) => { if(url.includes('?')) { url = url.split('?')[0] } let uri = new URL(url) if(uri.pathname.startsWith('/g/')) { return url } return null } }, enableTagsTranslate: true, } /* [Optional] settings related Use this.loadSetting to load setting ``` let setting1Value = this.loadSetting('setting1') console.log(setting1Value) ``` */ settings = { domain: { // title title: "domain", // type: input, select, switch type: "select", // options options: [ { value: 'e-hentai.org', }, { value: 'exhentai.org', }, ], default: 'e-hentai.org', }, } // [Optional] translations for the strings in this config translation = { 'zh_CN': { "domain": "域名", "language": "语言", "artist": "画师", "male": "男性", "female": "女性", "mixed": "混合", "other": "其它", "parody": "原作", "character": "角色", "group": "团队", "cosplayer": "Coser", "reclass": "重新分类", "Languages": "语言", "Artists": "画师", "Characters": "角色", "Groups": "团队", "Tags": "标签", "Parodies": "原作", "Categories": "分类", "Category": "分类", "Min Stars": "最少星星", "Language": "语言", }, 'zh_TW': { 'domain': '域名', "language": "語言", "artist": "畫師", "male": "男性", "female": "女性", "mixed": "混合", "other": "其他", "parody": "原作", "character": "角色", "group": "團隊", "cosplayer": "Coser", "reclass": "重新分類", "Languages": "語言", "Artists": "畫師", "Characters": "角色", "Groups": "團隊", "Tags": "標籤", "Parodies": "原作", "Categories": "分類", "Category": "分類", "Min Stars": "最少星星", "Language": "語言", }, } }