├── .gitignore ├── README.md ├── index.json ├── mh18.js ├── goda.js ├── comic_walker.js ├── manwaba.js ├── lanraragi.js ├── baozi.js ├── zaimanhua.js ├── shonen_jump_plus.js ├── mh1234.js ├── ikmmh.js ├── mxs.js ├── komiic.js └── komga.js /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | test/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # venera-configs 2 | 3 | Configuration file repository for venera 4 | 5 | ## Create a new configuration 6 | 7 | 1. Download `_template_.js`, `_venera_.js`, put them in the same directory 8 | 2. Rename `_template_.js` to `your_config_name.js` 9 | 3. Edit `your_config_name.js` to your needs. 10 | - The `_template_.js` file contains comments to help you with that. 11 | - The `_venera_.js` is used for code completion in your IDE. -------------------------------------------------------------------------------- /index.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "拷贝漫画", 4 | "fileName": "copy_manga.js", 5 | "key": "copy_manga", 6 | "version": "1.4.0" 7 | }, 8 | { 9 | "name": "Komiic", 10 | "fileName": "komiic.js", 11 | "key": "Komiic", 12 | "version": "1.0.3" 13 | }, 14 | { 15 | "name": "包子漫画", 16 | "fileName": "baozi.js", 17 | "key": "baozi", 18 | "version": "1.1.3" 19 | }, 20 | { 21 | "name": "Picacg", 22 | "fileName": "picacg.js", 23 | "key": "picacg", 24 | "version": "1.0.5" 25 | }, 26 | { 27 | "name": "nhentai", 28 | "fileName": "nhentai.js", 29 | "key": "nhentai", 30 | "version": "1.0.6" 31 | }, 32 | { 33 | "name": "紳士漫畫", 34 | "fileName": "wnacg.js", 35 | "key": "wnacg", 36 | "version": "1.0.4", 37 | "description": "紳士漫畫漫畫源, 不能使用時請嘗試更換URL" 38 | }, 39 | { 40 | "name": "ehentai", 41 | "fileName": "ehentai.js", 42 | "key": "ehentai", 43 | "version": "1.1.8" 44 | }, 45 | { 46 | "name": "禁漫天堂", 47 | "fileName": "jm.js", 48 | "key": "jm", 49 | "version": "1.3.1", 50 | "description": "禁漫天堂漫畫源, 不能使用時請嘗試切換分流" 51 | }, 52 | { 53 | "name": "MangaDex", 54 | "fileName": "manga_dex.js", 55 | "key": "manga_dex", 56 | "version": "1.1.0", 57 | "description": "Account feature is not supported yet." 58 | }, 59 | { 60 | "name": "爱看漫", 61 | "fileName": "ikmmh.js", 62 | "key": "ikmmh", 63 | "version": "1.0.5" 64 | }, 65 | { 66 | "name": "少年ジャンプ+", 67 | "fileName": "shonen_jump_plus.js", 68 | "key": "shonen_jump_plus", 69 | "version": "1.1.1" 70 | }, 71 | { 72 | "name": "hitomi.la", 73 | "fileName": "hitomi.js", 74 | "key": "hitomi", 75 | "version": "1.1.2" 76 | }, 77 | { 78 | "name": "comick", 79 | "fileName": "comick.js", 80 | "key": "comick", 81 | "version": "1.2.0" 82 | }, 83 | { 84 | "name": "优酷漫画", 85 | "fileName": "ykmh.js", 86 | "key": "ykmh", 87 | "version": "1.0.0" 88 | }, 89 | { 90 | "name": "再漫画", 91 | "fileName": "zaimanhua.js", 92 | "key": "zaimanhua", 93 | "version": "1.0.2" 94 | }, 95 | { 96 | "name": "漫画柜", 97 | "fileName": "manhuagui.js", 98 | "key": "ManHuaGui", 99 | "version": "1.2.1" 100 | }, 101 | { 102 | "name": "漫蛙吧", 103 | "fileName": "manwaba.js", 104 | "key": "manwaba", 105 | "version": "1.0.2" 106 | }, 107 | { 108 | "name": "Lanraragi", 109 | "fileName": "lanraragi.js", 110 | "key": "lanraragi", 111 | "version": "1.1.0" 112 | }, 113 | { 114 | "name": "Komga", 115 | "fileName": "komga.js", 116 | "key": "komga", 117 | "version": "1.0.0" 118 | }, 119 | { 120 | "name": "カドコミ", 121 | "fileName": "comic_walker.js", 122 | "key": "comic_walker", 123 | "version": "1.0.0" 124 | }, 125 | { 126 | "name": "漫画1234", 127 | "fileName": "mh1234.js", 128 | "key": "mh1234", 129 | "version": "1.0.0" 130 | }, 131 | { 132 | "name": "CCC追漫台", 133 | "fileName": "ccc.js", 134 | "key": "ccc", 135 | "version": "1.0.1" 136 | }, 137 | { 138 | "name": "GoDa漫画", 139 | "fileName": "goda.js", 140 | "key": "goda", 141 | "version": "1.0.0" 142 | }, 143 | { 144 | "name": "18漫画", 145 | "fileName": "mh18.js", 146 | "key": "mh18", 147 | "version": "1.0.0" 148 | }, 149 | { 150 | "name": "漫小肆", 151 | "fileName": "mxs.js", 152 | "key": "mxs", 153 | "version": "1.0.0" 154 | }, 155 | { 156 | "name": "漫画人", 157 | "fileName": "manhuaren.js", 158 | "key": "manhuaren", 159 | "version": "1.0.0" 160 | } 161 | ] -------------------------------------------------------------------------------- /mh18.js: -------------------------------------------------------------------------------- 1 | /** @type {import('./_venera_.js')} */ 2 | class MH18 extends ComicSource { 3 | // Note: The fields which are marked as [Optional] should be removed if not used 4 | 5 | // name of the source 6 | name = "18漫画" 7 | 8 | // unique id of the source 9 | key = "mh18" 10 | 11 | version = "1.0.0" 12 | 13 | minAppVersion = "1.4.0" 14 | 15 | // update url 16 | url = "https://git.nyne.dev/nyne/venera-configs/raw/branch/main/mh18.js" 17 | 18 | settings = { 19 | domains: { 20 | title: "域名", 21 | type: "input", 22 | default: "18mh.org" 23 | } 24 | } 25 | 26 | get baseUrl() { 27 | return `https://${this.loadSetting("domains")}`; 28 | } 29 | 30 | get headers() { 31 | return { 32 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0", 33 | "Referer": this.baseUrl 34 | }; 35 | } 36 | 37 | parseComics(doc) { 38 | console.warn(doc) 39 | const result = []; 40 | for (let item of doc.querySelectorAll(".pb-2")) { 41 | result.push(new Comic({ 42 | id: item.querySelector("a").attributes["href"], 43 | title: item.querySelector("h3").text, 44 | cover: item.querySelector("img").attributes["src"] 45 | })) 46 | } 47 | return result; 48 | } 49 | 50 | // explore page list 51 | explore = [ 52 | { 53 | // title of the page. 54 | // title is used to identify the page, it should be unique 55 | title: this.name, 56 | 57 | /// multiPartPage or multiPageComicList or mixed 58 | type: "multiPartPage", 59 | 60 | load: async () => { 61 | const res = await Network.get(this.baseUrl, this.headers); 62 | const document = new HtmlDocument(res.body); 63 | const result = [{ title: "近期更新", comics: [], viewMore: null }]; 64 | for (let item of document.querySelector(".pb-unit-md").querySelectorAll(".slicarda")) { 65 | result[0].comics.push(new Comic({ 66 | id: item.attributes["href"], 67 | title: item.querySelector("h3").text, 68 | cover: item.querySelector("img").attributes["src"] 69 | })) 70 | } 71 | const cardlists = document.querySelectorAll(".cardlist"); 72 | const hometitles = document.querySelectorAll(".hometitle"); 73 | for (let i = 0; i < hometitles.length; i++) { 74 | result.push({ 75 | title: hometitles[i].querySelector("h2").text, 76 | comics: this.parseComics(cardlists[i]), 77 | viewMore: { 78 | page: "category", 79 | attributes: { 80 | category: hometitles[i].querySelector("h2").text, 81 | param: hometitles[i].attributes["href"] 82 | }, 83 | } 84 | }); 85 | } 86 | return result; 87 | } 88 | } 89 | ] 90 | 91 | // categories 92 | category = { 93 | /// title of the category page, used to identify the page, it should be unique 94 | title: this.name, 95 | parts: [ 96 | { 97 | name: "类型", 98 | type: "fixed", 99 | categories: [ 100 | "全部", 101 | "韓漫", 102 | "真人寫真", 103 | "日漫", 104 | "AI寫真", 105 | "熱門漫畫" 106 | ], 107 | itemType: "category", 108 | categoryParams: [ 109 | "/manga", 110 | "/manga-genre/hanman", 111 | "/manga-genre/zhenrenxiezhen", 112 | "/manga-genre/riman", 113 | "/manga-genre/aixiezhen", 114 | "/manga-genre/hots" 115 | ], 116 | }, 117 | { 118 | name: "标签", 119 | type: "fixed", 120 | categories: [ 121 | "多人", 122 | "慾望", 123 | "正妹", 124 | "同居", 125 | "女學生", 126 | "劇情", 127 | "偷情", 128 | "校园", 129 | "逆襲", 130 | "办公室", 131 | "誘惑", 132 | "反转", 133 | "熟女", 134 | "人妻", 135 | "初戀", 136 | "少妇", 137 | "刺激", 138 | "女大学生", 139 | "治疗", 140 | "超能力", 141 | "浪漫校园", 142 | "戏剧", 143 | "学姐", 144 | "大学生", 145 | "泳衣", 146 | "暧昧", 147 | "写真", 148 | "女神", 149 | "大尺度", 150 | "纯情警察" 151 | ], 152 | itemType: "category", 153 | categoryParams: [ 154 | "/manga-tag/duoren", 155 | "/manga-tag/yuwang", 156 | "/manga-tag/zhengmei", 157 | "/manga-tag/tongju", 158 | "/manga-tag/nxuesheng", 159 | "/manga-tag/juqing", 160 | "/manga-tag/touqing", 161 | "/manga-tag/xiaoyuan", 162 | "/manga-tag/nixi", 163 | "/manga-tag/bangongshi", 164 | "/manga-tag/youhuo", 165 | "/manga-tag/fanzhuan", 166 | "/manga-tag/shun", 167 | "/manga-tag/renqi", 168 | "/manga-tag/chulian", 169 | "/manga-tag/shaofu", 170 | "/manga-tag/ciji", 171 | "/manga-tag/ndaxuesheng", 172 | "/manga-tag/zhiliao", 173 | "/manga-tag/chaonengli", 174 | "/manga-tag/langmanxiaoyuan", 175 | "/manga-tag/xiju", 176 | "/manga-tag/xuejie", 177 | "/manga-tag/daxuesheng", 178 | "/manga-tag/yongyi", 179 | "/manga-tag/aimei", 180 | "/manga-tag/xiezhen", 181 | "/manga-tag/nshen", 182 | "/manga-tag/dachidu", 183 | "/manga-tag/chunqingjingcha" 184 | ], 185 | } 186 | ], 187 | // enable ranking page 188 | enableRankingPage: false, 189 | } 190 | 191 | /// category comic loading related 192 | categoryComics = { 193 | load: async (category, params, options, page) => { 194 | const res = await Network.get(`${this.baseUrl}${params}/page/${page}`, this.headers); 195 | if (res.status !== 200) { 196 | throw `Invalid status code: ${res.status}`; 197 | } 198 | const document = new HtmlDocument(res.body); 199 | let maxPage = null; 200 | try { 201 | maxPage = parseInt(document.querySelectorAll("button.text-small").pop().text.replaceAll("\n", "").replaceAll(" ", "")); 202 | } catch (_) { 203 | maxPage = 1; 204 | } 205 | return { 206 | comics: this.parseComics(document), 207 | maxPage: maxPage 208 | }; 209 | } 210 | } 211 | 212 | /// search related 213 | search = { 214 | load: async (keyword, options, page) => { 215 | const res = await Network.get(`${this.baseUrl}/s/${keyword}?page=${page}`); 216 | if (res.status !== 200) { 217 | throw `Invalid status code: ${res.status}`; 218 | } 219 | const document = new HtmlDocument(res.body); 220 | let maxPage = null; 221 | try { 222 | maxPage = parseInt(document.querySelectorAll("button.text-small").pop().text.replaceAll("\n", "").replaceAll(" ", "")); 223 | } catch (_) { 224 | maxPage = 1; 225 | } 226 | return { 227 | comics: this.parseComics(document), 228 | maxPage: maxPage 229 | }; 230 | }, 231 | // enable tags suggestions 232 | enableTagsSuggestions: false, 233 | } 234 | 235 | /// single comic related 236 | comic = { 237 | onThumbnailLoad: (url) => { 238 | return { 239 | headers: this.headers 240 | } 241 | }, 242 | loadInfo: async (id) => { 243 | if (!id.startsWith("http")) { 244 | id = this.baseUrl + id; 245 | } 246 | const res = await Network.get(id); 247 | if (res.status !== 200) { 248 | throw `Invalid status code: ${res.status}`; 249 | } 250 | const document = new HtmlDocument(res.body); 251 | const title = document.querySelector(".text-xl").text.trim().split(" ")[0] 252 | const cover = document.querySelector(".object-cover").attributes["src"]; 253 | const description = document.querySelector("p.text-medium").text; 254 | const infos = document.querySelectorAll("div.py-1"); 255 | const tags = { "作者": [], "类型": [], "标签": [] }; 256 | for (let author of infos[0].querySelectorAll("a > span")) { 257 | let author_name = author.text.trim(); 258 | if (author_name.endsWith(",")) { 259 | author_name = author_name.slice(0, -1).trim(); 260 | } 261 | tags["作者"].push(author_name); 262 | } 263 | for (let category of infos[1].querySelectorAll("a > span")) { 264 | let category_name = category.text.trim(); 265 | if (category_name.endsWith(",")) { 266 | category_name = category_name.slice(0, -1).trim(); 267 | } 268 | tags["类型"].push(category_name); 269 | } 270 | for (let tag of infos[2].querySelectorAll("a")) { 271 | tags["标签"].push(tag.text.replace("\n", "").replaceAll(" ", "").replace("#", "")); 272 | } 273 | const mangaId = document.querySelector("#mangachapters").attributes["data-mid"]; 274 | const chapterRes = await Network.get(`${this.baseUrl}/manga/get?mid=${mangaId}&mode=all&t=${Date.now()}`, this.headers); 275 | const chapterDoc = new HtmlDocument(chapterRes.body); 276 | const chapters = {}; 277 | for (let ch of chapterDoc.querySelectorAll(".chapteritem")) { 278 | const info = ch.querySelector("a"); 279 | chapters[`${info.attributes["data-ms"]}@${info.attributes["data-cs"]}`] = ch.querySelector(".chaptertitle").text; 280 | } 281 | const recommend = []; 282 | for (let item of document.querySelectorAll("div.cardlist > div.pb-2")) { 283 | recommend.push(new Comic({ 284 | id: item.querySelector("a").attributes["href"], 285 | title: item.querySelector("h3").text, 286 | cover: item.querySelector("img").attributes["src"] 287 | })); 288 | } 289 | return new ComicDetails({ 290 | title: title, 291 | cover: cover, 292 | description: description, 293 | tags: tags, 294 | chapters: chapters, 295 | recommend: recommend, 296 | }); 297 | }, 298 | 299 | loadEp: async (comicId, epId) => { 300 | const ids = epId.split("@"); 301 | const res = await Network.get(`${this.baseUrl}/chapter/getcontent?m=${ids[0]}&c=${ids[1]}`, this.headers); 302 | if (res.status !== 200) { 303 | throw `Invalid status code: ${res.status}`; 304 | } 305 | const document = new HtmlDocument(res.body); 306 | const images = []; 307 | for (let i of document.querySelector("#chapcontent").querySelectorAll("img")) { 308 | images.push(i.attributes["data-src"] ? i.attributes["data-src"] : i.attributes["src"]); 309 | } 310 | return { images }; 311 | }, 312 | 313 | // enable tags translate 314 | enableTagsTranslate: false, 315 | } 316 | } -------------------------------------------------------------------------------- /goda.js: -------------------------------------------------------------------------------- 1 | /** @type {import('./_venera_.js')} */ 2 | class Goda extends ComicSource { 3 | // Note: The fields which are marked as [Optional] should be removed if not used 4 | 5 | // name of the source 6 | name = "GoDa漫画" 7 | 8 | // unique id of the source 9 | key = "goda" 10 | 11 | version = "1.0.0" 12 | 13 | minAppVersion = "1.4.0" 14 | 15 | // update url 16 | url = "https://git.nyne.dev/nyne/venera-configs/raw/branch/main/goda.js" 17 | 18 | settings = { 19 | domains: { 20 | title: "域名", 21 | type: "input", 22 | default: "godamh.com" 23 | }, 24 | api: { 25 | title: "API域名", 26 | type: "input", 27 | default: "api-get-v3.mgsearcher.com" 28 | }, 29 | image: { 30 | title: "图片域名", 31 | type: "input", 32 | default: "t40-1-4.g-mh.online" 33 | } 34 | } 35 | 36 | get baseUrl() { 37 | return `https://${this.loadSetting("domains")}`; 38 | } 39 | 40 | get apiUrl() { 41 | return `https://${this.loadSetting("api")}/api`; 42 | } 43 | 44 | get imageUrl() { 45 | return `https://${this.loadSetting("image")}`; 46 | } 47 | 48 | get headers() { 49 | return { 50 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0", 51 | "Referer": this.baseUrl 52 | }; 53 | } 54 | 55 | parseComics(doc) { 56 | console.warn(doc) 57 | const result = []; 58 | for (let item of doc.querySelectorAll(".pb-2")) { 59 | result.push(new Comic({ 60 | id: item.querySelector("a").attributes["href"], 61 | title: item.querySelector("h3").text, 62 | cover: item.querySelector("img").attributes["src"] 63 | })) 64 | } 65 | return result; 66 | } 67 | 68 | // explore page list 69 | explore = [ 70 | { 71 | // title of the page. 72 | // title is used to identify the page, it should be unique 73 | title: this.name, 74 | 75 | /// multiPartPage or multiPageComicList or mixed 76 | type: "multiPartPage", 77 | 78 | load: async () => { 79 | const res = await Network.get(this.baseUrl, this.headers); 80 | const document = new HtmlDocument(res.body); 81 | const result = [{ title: "近期更新", comics: [], viewMore: null }]; 82 | for (let item of document.querySelector(".pb-unit-md").querySelectorAll(".slicarda")) { 83 | result[0].comics.push(new Comic({ 84 | id: item.attributes["href"], 85 | title: item.querySelector("h3").text, 86 | cover: item.querySelector("img").attributes["src"] 87 | })) 88 | } 89 | const cardlists = document.querySelectorAll(".cardlist"); 90 | const hometitles = document.querySelectorAll(".hometitle"); 91 | for (let i = 0; i < hometitles.length; i++) { 92 | result.push({ 93 | title: hometitles[i].querySelector("h2").text, 94 | comics: this.parseComics(cardlists[i]), 95 | viewMore: { 96 | page: "category", 97 | attributes: { 98 | category: hometitles[i].querySelector("h2").text, 99 | param: hometitles[i].attributes["href"] 100 | }, 101 | } 102 | }); 103 | } 104 | return result; 105 | } 106 | } 107 | ] 108 | 109 | // categories 110 | category = { 111 | /// title of the category page, used to identify the page, it should be unique 112 | title: this.name, 113 | parts: [ 114 | { 115 | name: "类型", 116 | type: "fixed", 117 | categories: [ 118 | "全部", 119 | "韩漫", 120 | "热门漫画", 121 | "国漫", 122 | "其他", 123 | "日漫", 124 | "欧美" 125 | ], 126 | itemType: "category", 127 | categoryParams: [ 128 | "/manga", 129 | "/manga-genre/kr", 130 | "/manga-genre/hots", 131 | "/manga-genre/cn", 132 | "/manga-genre/qita", 133 | "/manga-genre/jp", 134 | "/manga-genre/ou-mei" 135 | ], 136 | }, 137 | { 138 | name: "标签", 139 | type: "fixed", 140 | categories: [ 141 | "复仇", 142 | "古风", 143 | "奇幻", 144 | "逆袭", 145 | "异能", 146 | "宅向", 147 | "穿越", 148 | "热血", 149 | "纯爱", 150 | "系统", 151 | "重生", 152 | "冒险", 153 | "灵异", 154 | "大女主", 155 | "剧情", 156 | "恋爱", 157 | "玄幻", 158 | "女神", 159 | "科幻", 160 | "魔幻", 161 | "推理", 162 | "猎奇", 163 | "治愈", 164 | "都市", 165 | "异形", 166 | "青春", 167 | "末日", 168 | "悬疑", 169 | "修仙", 170 | "战斗" 171 | ], 172 | itemType: "category", 173 | categoryParams: [ 174 | "/manga-tag/fuchou", 175 | "/manga-tag/gufeng", 176 | "/manga-tag/qihuan", 177 | "/manga-tag/nixi", 178 | "/manga-tag/yineng", 179 | "/manga-tag/zhaixiang", 180 | "/manga-tag/chuanyue", 181 | "/manga-tag/rexue", 182 | "/manga-tag/chunai", 183 | "/manga-tag/xitong", 184 | "/manga-tag/zhongsheng", 185 | "/manga-tag/maoxian", 186 | "/manga-tag/lingyi", 187 | "/manga-tag/danvzhu", 188 | "/manga-tag/juqing", 189 | "/manga-tag/lianai", 190 | "/manga-tag/xuanhuan", 191 | "/manga-tag/nvshen", 192 | "/manga-tag/kehuan", 193 | "/manga-tag/mohuan", 194 | "/manga-tag/tuili", 195 | "/manga-tag/lieqi", 196 | "/manga-tag/zhiyu", 197 | "/manga-tag/doushi", 198 | "/manga-tag/yixing", 199 | "/manga-tag/qingchun", 200 | "/manga-tag/mori", 201 | "/manga-tag/xuanyi", 202 | "/manga-tag/xiuxian", 203 | "/manga-tag/zhandou" 204 | ], 205 | } 206 | ], 207 | // enable ranking page 208 | enableRankingPage: false, 209 | } 210 | 211 | /// category comic loading related 212 | categoryComics = { 213 | load: async (category, params, options, page) => { 214 | const res = await Network.get(`${this.baseUrl}${params}/page/${page}`, this.headers); 215 | if (res.status !== 200) { 216 | throw `Invalid status code: ${res.status}`; 217 | } 218 | const document = new HtmlDocument(res.body); 219 | let maxPage = null; 220 | try { 221 | maxPage = parseInt(document.querySelectorAll("button.text-small").pop().text.replaceAll("\n", "").replaceAll(" ", "")); 222 | } catch(_) { 223 | maxPage = 1; 224 | } 225 | return { 226 | comics: this.parseComics(document), 227 | maxPage: maxPage 228 | }; 229 | } 230 | } 231 | 232 | /// search related 233 | search = { 234 | load: async (keyword, options, page) => { 235 | const res = await Network.get(`${this.baseUrl}/s/${keyword}?page=${page}`); 236 | if (res.status !== 200) { 237 | throw `Invalid status code: ${res.status}`; 238 | } 239 | const document = new HtmlDocument(res.body); 240 | let maxPage = null; 241 | try { 242 | maxPage = parseInt(document.querySelectorAll("button.text-small").pop().text.replaceAll("\n", "").replaceAll(" ", "")); 243 | } catch(_) { 244 | maxPage = 1; 245 | } 246 | return { 247 | comics: this.parseComics(document), 248 | maxPage: maxPage 249 | }; 250 | }, 251 | // enable tags suggestions 252 | enableTagsSuggestions: false, 253 | } 254 | 255 | /// single comic related 256 | comic = { 257 | onThumbnailLoad: (url) => { 258 | return { 259 | headers: this.headers 260 | } 261 | }, 262 | loadInfo: async (id) => { 263 | const res = await Network.get(this.baseUrl + id); 264 | if (res.status !== 200) { 265 | throw `Invalid status code: ${res.status}`; 266 | } 267 | const document = new HtmlDocument(res.body); 268 | const title = document.querySelector(".text-xl").text.trim().split(" ")[0] 269 | const cover = document.querySelector(".object-cover").attributes["src"]; 270 | const description = document.querySelector("p.text-medium").text; 271 | const infos = document.querySelectorAll("div.py-1"); 272 | const tags = { "作者": [], "类型": [], "标签": [] }; 273 | for (let author of infos[0].querySelectorAll("a > span")) { 274 | let author_name = author.text.trim(); 275 | if (author_name.endsWith(",")) { 276 | author_name = author_name.slice(0, -1).trim(); 277 | } 278 | tags["作者"].push(author_name); 279 | } 280 | for (let category of infos[1].querySelectorAll("a > span")) { 281 | let category_name = category.text.trim(); 282 | if (category_name.endsWith(",")) { 283 | category_name = category_name.slice(0, -1).trim(); 284 | } 285 | tags["类型"].push(category_name); 286 | } 287 | for (let tag of infos[2].querySelectorAll("a")) { 288 | tags["标签"].push(tag.text.replace("\n", "").replaceAll(" ", "").replace("#", "")); 289 | } 290 | const mangaId = document.querySelector("#mangachapters").attributes["data-mid"]; 291 | const jsonRes = await Network.get(`${this.apiUrl}/manga/get?mid=${mangaId}&mode=all&t=${Date.now()}`, this.headers); 292 | const jsonData = JSON.parse(jsonRes.body); 293 | const chapters = {}; 294 | for (let ch of jsonData["data"]["chapters"]) { 295 | chapters[`${mangaId}@${ch["id"]}`] = ch["attributes"]["title"]; 296 | } 297 | const recommend = []; 298 | for (let item of document.querySelectorAll("div.cardlist > div.pb-2")) { 299 | recommend.push(new Comic({ 300 | id: item.querySelector("a").attributes["href"], 301 | title: item.querySelector("h3").text, 302 | cover: item.querySelector("img").attributes["src"] 303 | })); 304 | } 305 | return new ComicDetails({ 306 | title: title, 307 | cover: cover, 308 | description: description, 309 | tags: tags, 310 | chapters: chapters, 311 | recommend: recommend, 312 | }); 313 | }, 314 | 315 | loadEp: async (comicId, epId) => { 316 | const ids = epId.split("@"); 317 | const res = await Network.get(`${this.apiUrl}/chapter/getinfo?m=${ids[0]}&c=${ids[1]}`, this.headers); 318 | if (res.status !== 200) { 319 | throw `Invalid status code: ${res.status}`; 320 | } 321 | const jsonData = JSON.parse(res.body); 322 | const images = []; 323 | for (let i of jsonData["data"]["info"]["images"]["images"]) { 324 | images.push(this.imageUrl + i["url"]); 325 | } 326 | return { images }; 327 | }, 328 | 329 | // enable tags translate 330 | enableTagsTranslate: false, 331 | } 332 | } -------------------------------------------------------------------------------- /comic_walker.js: -------------------------------------------------------------------------------- 1 | class ComicWalker extends ComicSource { 2 | name = "カドコミ"; 3 | key = "comic_walker"; 4 | version = "1.0.0"; 5 | minAppVersion = "1.6.0"; 6 | url = 7 | "https://git.nyne.dev/nyne/venera-configs/raw/branch/main/comic_walker.js"; 8 | 9 | api_key = "ytBrdQ2ZYdRQguqEusVLxQVUgakNnVht"; 10 | 11 | latestVersion = "1.4.13"; 12 | 13 | api_base = "https://mobileapp.comic-walker.com"; 14 | 15 | get headers() { 16 | const headers = { 17 | "X-API-Environment-Key": this.api_key, 18 | "User-Agent": `BookWalkerApp/${this.latestVersion} (Android 13)`, 19 | "Host": "mobileapp.comic-walker.com", 20 | "Content-Type": "application/json" 21 | }; 22 | const token = this.loadData("token"); 23 | if (token) { 24 | headers["Authorization"] = `Bearer ${token}`; 25 | } 26 | return headers; 27 | } 28 | 29 | async refreshToken() { 30 | const res = await this.request( 31 | `${this.api_base}/v1/users`, 32 | this.headers, 33 | "POST", 34 | ); 35 | 36 | this.saveData("token", res.resources.access_token); 37 | return res.resources.access_token; 38 | } 39 | 40 | async request(url, headers, method = "GET", data) { 41 | let response; 42 | if (method === "GET") { 43 | response = await Network.get(url, headers); 44 | } else if (method === "POST") { 45 | response = await Network.post(url, headers, data); 46 | } else { 47 | throw new Error(`Unsupported method: ${method}`); 48 | } 49 | if ( 50 | response.status === 204 51 | ) { 52 | return response; 53 | } 54 | response = JSON.parse(response.body); 55 | if ( 56 | response.code === "invalid_request_parameter" || 57 | response.code === "free_daily_reward_quota_exceeded" || 58 | response.code === "unauthorized" 59 | ) { 60 | await this.refreshToken(); 61 | if (method === "GET") { 62 | response = await Network.get(url, this.headers); 63 | } else if (method === "POST") { 64 | response = await Network.post(url, this.headers, data); 65 | } else { 66 | throw new Error(`Unsupported method: ${method}`); 67 | } 68 | if ( 69 | response.status === 204 70 | ) { 71 | return response; 72 | } 73 | response = JSON.parse(response.body); 74 | } 75 | return response; 76 | } 77 | 78 | async init() { 79 | const itunes_api = "https://itunes.apple.com/lookup?bundleId=jp.co.bookwalker.cwapp.ios&country=jp"; 80 | 81 | const resp = await Network.get(itunes_api); 82 | 83 | if (resp.status == 200) { 84 | response = JSON.parse(resp.body); 85 | this.latestVersion = response.version; 86 | } 87 | 88 | await this.refreshToken(); 89 | } 90 | 91 | explore = [ 92 | { 93 | title: "カドコミ", 94 | type: "singlePageWithMultiPart", 95 | load: async () => { 96 | const res = await this.request( 97 | `${this.api_base}/v2/screens/home`, 98 | this.headers, 99 | ); 100 | 101 | const result = {}; 102 | 103 | const newArrivals = res.resources.new_arrival_comics.map((item) => 104 | new Comic({ 105 | id: item.id, 106 | title: item.title, 107 | cover: item.thumbnail_1x1 || "", 108 | tags: item.comic_labels?.map((l) => l.name) || [], 109 | }), 110 | ); 111 | result["今日の更新"] = newArrivals; 112 | 113 | const attention = res.resources.attention_comics.map((item) => 114 | new Comic({ 115 | id: item.comic_id, 116 | title: item.title, 117 | cover: item.image_url || "", 118 | tags: item.comic_labels?.map((l) => l.name) || [], 119 | }), 120 | ); 121 | result["注目作品"] = attention; 122 | 123 | for (const pickup of res.resources.pickup_comics) { 124 | const comics = pickup.comics.map((item) => 125 | new Comic({ 126 | id: item.id, 127 | title: item.title, 128 | cover: item.thumbnail_1x1 || "", 129 | tags: item.comic_labels?.map((l) => l.name) || [], 130 | }), 131 | ); 132 | result[pickup.name] = comics; 133 | } 134 | 135 | const newSerialization = res.resources.new_serialization_comics.map((item) => 136 | new Comic({ 137 | id: item.id, 138 | title: item.title, 139 | cover: item.thumbnail_1x1 || "", 140 | tags: item.comic_labels?.map((l) => l.name) || [], 141 | }), 142 | ); 143 | result["新連載"] = newSerialization; 144 | 145 | 146 | return result; 147 | }, 148 | }, 149 | ]; 150 | 151 | search = { 152 | load: async (keyword, _, page) => { 153 | const res = await this.request( 154 | `${this.api_base}/v1/search/comics?keyword=${keyword}&limit=20&offset=${ 155 | (page - 1) * 20 156 | }`, 157 | this.headers, 158 | ); 159 | 160 | const comics = res.resources.map((item) => 161 | new Comic({ 162 | id: item.id, 163 | title: item.title, 164 | cover: item.thumbnail_1x1 || "", 165 | tags: [ 166 | ...(item.authors?.map((a) => a.name) || []), 167 | ...(item.comic_labels?.map((l) => l.name) || []), 168 | ], 169 | }) 170 | ); 171 | const pageInfo = { 172 | hasNextPage: res.resources.length === 20, 173 | endCursor: null, 174 | }; 175 | 176 | return { 177 | comics, 178 | maxPage: pageInfo.hasNextPage ? (page || 1) + 1 : (page || 1), 179 | endCursor: pageInfo.endCursor, 180 | }; 181 | }, 182 | }; 183 | 184 | comic = { 185 | loadInfo: async (id) => { 186 | const res = await this.request( 187 | `${this.api_base}/v2/screens/comics/${id}`, 188 | this.headers, 189 | ); 190 | const detail = res.resources.detail; 191 | 192 | const totalCount = res.resources.episode_total_count || 0; 193 | let episodes = { resources: [] }; 194 | for (let offset = 0; offset < totalCount; offset += 100) { 195 | const chunk = await this.request( 196 | `${this.api_base}/v1/comics/${id}/episodes?offset=${offset}&limit=100&sort=asc`, 197 | this.headers, 198 | ); 199 | episodes.resources.push(...(chunk.resources || [])); 200 | } 201 | 202 | const tags = new Map(); 203 | 204 | if (detail.authors) { 205 | detail.authors.forEach((a) => { 206 | if (!tags.has(a.role)) tags.set(a.role, []); 207 | tags.get(a.role).push(a.name); 208 | }); 209 | } 210 | 211 | if (detail.comic_labels) { 212 | detail.comic_labels.forEach((l) => { 213 | if (!tags.has("Labels")) tags.set("Labels", []); 214 | tags.get("Labels").push(l.name); 215 | }); 216 | } 217 | 218 | if (detail.tags) { 219 | detail.tags.forEach((t) => { 220 | if (!tags.has(t.type)) tags.set(t.type, []); 221 | tags.get(t.type).push(t.name); 222 | }); 223 | } 224 | 225 | const chapters = new Map(); 226 | for (const ep of episodes.resources) { 227 | let canRent = false; 228 | const plans = (ep.plans || []).filter((plan) => 229 | plan.type !== "paid" 230 | ); 231 | if (Array.isArray(plans) && plans.length > 0) { 232 | canRent = true; 233 | } 234 | const title = canRent ? ep.title : `❌ ${ep.title}`; 235 | chapters.set(ep.id, title); 236 | } 237 | 238 | return new ComicDetails({ 239 | title: detail.title, 240 | subtitle: detail.authors?.map((a) => a.name).join("・") || "", 241 | cover: detail.thumbnail_1x1 || "", 242 | description: detail.story?.replace(//gi, "\n") || "", 243 | tags, 244 | chapters, 245 | updateTime: detail.next_update_at, 246 | url: detail.share_url, 247 | maxPage: totalCount, 248 | }); 249 | }, 250 | 251 | loadEp: async (comicId, epId) => { 252 | let detail = await this.request( 253 | `${this.api_base}/v1/episodes/${epId}`, 254 | this.headers, 255 | ); 256 | const plans = (detail.plans || []).filter((plan) => 257 | // plan.type !== "daily_video_free" && 258 | plan.type !== "paid" 259 | ); 260 | if ( 261 | !Array.isArray(plans) || 262 | plans.length === 0 263 | ) { 264 | throw new Error("No available rental plans after filtering"); 265 | } 266 | console.log(plans); 267 | const freePlan = plans.find((plan) => plan.type === "free"); 268 | if (!freePlan) { 269 | const plan = plans[randomInt(0, plans.length - 1)]; 270 | await this.request( 271 | `${this.api_base}/v1/users/me/rental_episodes`, 272 | this.headers, 273 | "POST", 274 | { episode_id: epId, reading_method: plan.type }, 275 | ); 276 | } 277 | let res = await this.request( 278 | `${this.api_base}/v1/screens/comics/${comicId}/episodes/${epId}/viewer`, 279 | this.headers, 280 | ); 281 | const manuscripts = res.resources.manuscripts || []; 282 | return { 283 | images: manuscripts.map((m) => 284 | `${m.drm_image_url}&drm_hash=${m.drm_hash}` 285 | ), 286 | }; 287 | }, 288 | 289 | onImageLoad: (url) => { 290 | let drm_hash = null; 291 | let cleanUrl = url; 292 | const drmHashMatch = url.match(/[?&]drm_hash=([^&]+)/); 293 | if (drmHashMatch) { 294 | drm_hash = decodeURIComponent(drmHashMatch[1]); 295 | cleanUrl = url.replace(/([?&])drm_hash=[^&]+(&)?/, (match, p1, p2) => { 296 | if (p2) return p1; 297 | return ""; 298 | }).replace(/[?&]$/, ""); 299 | } 300 | cleanUrl = cleanUrl.replace(/([?&])weight=[^&]+(&)?/, (match, p1, p2) => { 301 | if (p2) return p1; 302 | return ""; 303 | }).replace(/[?&]$/, ""); 304 | 305 | cleanUrl = cleanUrl.replace(/([?&])height=[^&]+(&)?/, (match, p1, p2) => { 306 | if (p2) return p1; 307 | return ""; 308 | }).replace(/[?&]$/, ""); 309 | 310 | if (drm_hash.length < 2) { 311 | throw new Error( 312 | "drm_hash must be at least 2 characters long", 313 | ); 314 | } 315 | var version = drm_hash.slice(0, 2); 316 | if (version !== "01") { 317 | throw new Error("Unsupported version: " + version); 318 | } 319 | var key_part = drm_hash.slice(2); 320 | if (key_part.length < 16) { 321 | throw new Error( 322 | "Key part must be 16 characters long (8 hex numbers)", 323 | ); 324 | } 325 | var key = []; 326 | for (var i = 0; i < 8; i++) { 327 | key.push(parseInt(key_part.slice(i * 2, i * 2 + 2), 16)); 328 | } 329 | 330 | const keyArray = key; 331 | const onResponseScript = ` 332 | function onResponse(buffer) { 333 | var key = [${keyArray.join(',')}]; 334 | var view = new Uint8Array(buffer); 335 | for (var i = 0; i < view.length; i++) { 336 | view[i] ^= key[i % key.length]; 337 | } 338 | return buffer; 339 | } 340 | onResponse; 341 | `; 342 | return { 343 | url: cleanUrl, 344 | headers: this.headers, 345 | onResponse: async (buffer) => { 346 | return await compute(onResponseScript, buffer); 347 | } 348 | }; 349 | }, 350 | 351 | onClickTag: (namespace, tag) => { 352 | if ( 353 | namespace === "漫画" || namespace === "原作" || 354 | namespace === "キャラクター原案" || namespace === "著者" 355 | ) { 356 | return { 357 | action: "search", 358 | keyword: tag, 359 | param: null, 360 | }; 361 | } 362 | throw "未支持此类Tag检索"; 363 | }, 364 | }; 365 | } 366 | -------------------------------------------------------------------------------- /manwaba.js: -------------------------------------------------------------------------------- 1 | /** @type {import('./_venera_.js')} */ 2 | class ManWaBa extends ComicSource { 3 | // Note: The fields which are marked as [Optional] should be removed if not used 4 | 5 | // name of the source 6 | name = "漫蛙吧"; 7 | 8 | // unique id of the source 9 | key = "manwaba"; 10 | 11 | version = "1.0.2"; 12 | 13 | minAppVersion = "1.4.0"; 14 | 15 | // update url 16 | url = "https://git.nyne.dev/nyne/venera-configs/raw/branch/main/manwaba.js"; 17 | 18 | //api = "https://www.manwaba.com/api"; //重定向之前的地址无法使用分类 19 | api = "https://www.mhtmh.org/api"; 20 | 21 | init() { 22 | /** 23 | * Sends an HTTP request. 24 | * @param {string} url - The URL to send the request to. 25 | * @param {string} method - The HTTP method (e.g., GET, POST, PUT, PATCH, DELETE). 26 | * @param {Object} params - The query parameters to include in the request. 27 | * @param {Object} headers - The headers to include in the request. 28 | * @param {string} payload - The payload to include in the request. 29 | * @returns {Promise} The response from the request. 30 | */ 31 | this.fetchJson = async ( 32 | url, 33 | { method = "GET", params, headers, payload } 34 | ) => { 35 | if (params) { 36 | let params_str = Object.keys(params) 37 | .map((key) => `${key}=${params[key]}`) 38 | .join("&"); 39 | url += `?${params_str}`; 40 | } 41 | let res = await Network.sendRequest(method, url, headers, payload); 42 | if (res.status !== 200) { 43 | throw `Invalid status code: ${res.status}, body: ${res.body}`; 44 | } 45 | let json = JSON.parse(res.body); 46 | return json; 47 | }; 48 | this.logger = { 49 | error: (msg) => { 50 | log("error", this.name, msg); 51 | }, 52 | info: (msg) => { 53 | log("info", this.name, msg); 54 | }, 55 | warn: (msg) => { 56 | log("warning", this.name, msg); 57 | }, 58 | }; 59 | } 60 | 61 | // explore page list 62 | explore = [ 63 | { 64 | // title of the page. 65 | // title is used to identify the page, it should be unique 66 | title: this.name, 67 | 68 | /// multiPartPage or multiPageComicList or mixed 69 | type: "singlePageWithMultiPart", 70 | 71 | /** 72 | * load function 73 | * @param page {number | null} - page number, null for `singlePageWithMultiPart` type 74 | * @returns {{}} 75 | * - for `multiPartPage` type, return [{title: string, comics: Comic[], viewMore: PageJumpTarget}] 76 | * - for `multiPageComicList` type, for each page(1-based), return {comics: Comic[], maxPage: number} 77 | * - 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?} 78 | */ 79 | load: async (page) => { 80 | let params = { 81 | page: 1, 82 | pageSize: 6, 83 | type: "", 84 | flag: false, 85 | }; 86 | const url = `${this.api}/home`; 87 | const data = await this.fetchJson(url, { params }).then( 88 | (res) => res.data 89 | ); 90 | let magnaList = { 91 | 热门: data.comicList, 92 | 最新完整版: data.gufengList, 93 | 最新更新: data.xuanhuanList, 94 | 热门收藏: data.xiaoyuanList, 95 | }; 96 | function parseComic(comic) { 97 | return new Comic({ 98 | id: comic.id.toString(), 99 | title: comic.title, 100 | subTitle: comic.author, 101 | cover: comic.pic, 102 | tags: comic.tags.split(","), 103 | }); 104 | } 105 | let result = {}; 106 | for (let key in magnaList) { 107 | result[key] = magnaList[key].map(parseComic); 108 | } 109 | return result; 110 | }, 111 | }, 112 | ]; 113 | 114 | // categories 115 | category = { 116 | /// title of the category page, used to identify the page, it should be unique 117 | title: this.name, 118 | parts: [ 119 | { 120 | // title of the part 121 | name: "类型", 122 | 123 | // fixed or random or dynamic 124 | // if random, need to provide `randomNumber` field, which indicates the number of comics to display at the same time 125 | // if dynamic, need to provide `loader` field, which indicates the function to load comics 126 | type: "fixed", 127 | 128 | // Remove this if type is dynamic 129 | categories: [ 130 | "全部", 131 | "热血", 132 | "玄幻", 133 | "恋爱", 134 | "冒险", 135 | "古风", 136 | "都市", 137 | "穿越", 138 | "奇幻", 139 | "其他", 140 | "搞笑", 141 | "少男", 142 | "战斗", 143 | "重生", 144 | "逆袭", 145 | "爆笑", 146 | "少年", 147 | "后宫", 148 | "系统", 149 | "BL", 150 | "韩漫", 151 | "完整版", 152 | "19r", 153 | "台版", 154 | ], 155 | 156 | itemType: "category", 157 | categoryParams: [ 158 | "", 159 | "热血", 160 | "玄幻", 161 | "恋爱", 162 | "冒险", 163 | "古风", 164 | "都市", 165 | "穿越", 166 | "奇幻", 167 | "其他", 168 | "搞笑", 169 | "少男", 170 | "战斗", 171 | "重生", 172 | "逆袭", 173 | "爆笑", 174 | "少年", 175 | "后宫", 176 | "系统", 177 | "BL", 178 | "韩漫", 179 | "完整版", 180 | "19r", 181 | "台版", 182 | ], 183 | }, 184 | ], 185 | // enable ranking page 186 | enableRankingPage: false, 187 | }; 188 | 189 | /// category comic loading related 190 | categoryComics = { 191 | /** 192 | * load comics of a category 193 | * @param category {string} - category name 194 | * @param param {string?} - category param 195 | * @param options {string[]} - options from optionList 196 | * @param page {number} - page number 197 | * @returns {Promise<{comics: Comic[], maxPage: number}>} 198 | */ 199 | load: async (category, param, options, page) => { 200 | let pathMap = { 201 | "": "/cate", 202 | "热血": "/cate/hotblooded", 203 | "玄幻": "/cate/xuanhuan", 204 | "恋爱": "/cate/romance", 205 | "冒险": "/cate/adventure", 206 | "古风": "/cate/historical", 207 | "都市": "/cate/urban", 208 | "穿越": "/cate/transmigration", 209 | "奇幻": "/cate/fantasy", 210 | "搞笑": "/cate/comedy", 211 | "少男": "/cate/shounen", 212 | "战斗": "/cate/action", 213 | "重生": "/cate/rebirth", 214 | "逆袭": "/cate/counterattack", 215 | "爆笑": "/cate/hilarious", 216 | "少年": "/cate/youth", 217 | "系统": "/cate/system", 218 | "BL": "/cate/bl", 219 | "韩漫": "/cate/manhwa", 220 | "完整版": "/cate/fullversion", 221 | "19r": "/cate/19plus", 222 | "台版": "/cate/taiwanver", 223 | }; 224 | let url = this.api + pathMap[param] || "/cate"; 225 | let payload = JSON.stringify({ 226 | page: { 227 | page: page, 228 | pageSize: 10, 229 | }, 230 | category: "comic", 231 | sort: parseInt(options[2]), 232 | comic: { 233 | status: parseInt(options[0] == "2" ? -1 : options[0]), 234 | day: parseInt(options[1]), 235 | tag: param, 236 | }, 237 | video: { 238 | year: 0, 239 | typeId: 0, 240 | typeId1: 0, 241 | area: "", 242 | lang: "", 243 | status: -1, 244 | day: 0, 245 | }, 246 | novel: { 247 | status: -1, 248 | day: 0, 249 | sortId: 0, 250 | }, 251 | }); 252 | 253 | let data = await this.fetchJson(url, { 254 | method: "POST", 255 | payload, 256 | }).then((res) => res.data.list); 257 | 258 | function parseComic(comic) { 259 | return new Comic({ 260 | id: comic.url.split("/").pop(), 261 | title: comic.title, 262 | subTitle: comic.author, 263 | cover: comic.pic, 264 | tags: comic.tags.split(","), 265 | description: comic.intro, 266 | status: comic.status == 0 ? "连载中" : "已完结", 267 | }); 268 | } 269 | return { 270 | comics: data.map(parseComic), 271 | maxPage: 100, 272 | }; 273 | }, 274 | // provide options for category comic loading 275 | optionList: [ 276 | { 277 | options: ["2-全部", "0-连载中", "1-已完结"], 278 | }, 279 | { 280 | options: [ 281 | "0-全部", 282 | "1-周一", 283 | "2-周二", 284 | "3-周三", 285 | "4-周四", 286 | "5-周五", 287 | "6-周六", 288 | "7-周日", 289 | ], 290 | }, 291 | { 292 | options: ["0-更新", "1-新作", "2-畅销", "3-热门", "4-收藏"], 293 | }, 294 | ], 295 | }; 296 | 297 | /// search related 298 | search = { 299 | /** 300 | * load search result 301 | * @param keyword {string} 302 | * @param options {string[]} - options from optionList 303 | * @param page {number} 304 | * @returns {Promise<{comics: Comic[], maxPage: number}>} 305 | */ 306 | load: async (keyword, options, page) => { 307 | const pageSize = 20; 308 | let url = `${this.api}/search`; 309 | let params = { 310 | keyword, 311 | type: "mh", 312 | page, 313 | pageSize, 314 | }; 315 | let data = await this.fetchJson(url, { params }).then((res) => res.data); 316 | let total = data.total; 317 | let comics = data.list.map((item) => { 318 | return new Comic({ 319 | id: item.id.toString(), 320 | title: item.title, 321 | subTitle: item.author, 322 | cover: item.cover, 323 | tags: item.tags.split(","), 324 | description: item.description, 325 | status: item.status == 0 ? "连载中" : "已完结", 326 | }); 327 | }); 328 | let maxPage = Math.ceil(total / pageSize); 329 | return { 330 | comics, 331 | maxPage, 332 | }; 333 | }, 334 | }; 335 | 336 | /// single comic related 337 | comic = { 338 | /** 339 | * load comic info 340 | * @param id {string} 341 | * @returns {Promise}s 342 | */ 343 | loadInfo: async (id) => { 344 | let url = `${this.api}/comic/${id}`; 345 | let data = await this.fetchJson(url, { payload: undefined }).then( 346 | (res) => res.data 347 | ); 348 | this.logger.warn(`loadInfo: ${data}`); 349 | let chapterId = data.id; 350 | let chapterApi = `${this.api}/comic/chapter`; 351 | let params = { 352 | comicId: chapterId, 353 | page: 1, 354 | pageSize: 1, 355 | }; 356 | let pageRes = await this.fetchJson(chapterApi, { params }); 357 | let total = pageRes.pagination.total; 358 | 359 | let chapterRes = await this.fetchJson(chapterApi, { 360 | params: { 361 | ...params, 362 | pageSize: total, 363 | }, 364 | }); 365 | let chapterList = chapterRes.data; 366 | let chapters = new Map(); 367 | chapterList.forEach((item) => { 368 | chapters.set(item.id.toString(), item.title.toString()); 369 | }); 370 | 371 | return new ComicDetails({ 372 | title: data.title.toString(), 373 | subTitle: data.author.toString(), 374 | cover: data.cover, 375 | tags: { 376 | 类型: data.tags.split(","), 377 | 状态: data.status == 0 ? "连载中" : "已完结", 378 | }, 379 | chapters, 380 | description: data.intro, 381 | updateTime: new Date(data.editTime * 1000).toLocaleDateString(), 382 | }); 383 | }, 384 | /** 385 | * load images of a chapter 386 | * @param comicId {string} 387 | * @param epId {string?} 388 | * @returns {Promise<{images: string[]}>} 389 | */ 390 | loadEp: async (comicId, epId) => { 391 | let imgApi = `${this.api}/comic/image/${epId}`; 392 | let params = { 393 | page: 1, 394 | pageSize: 1, 395 | imageSource: "https://tu.mhttu.cc", 396 | }; 397 | let pageNum = await this.fetchJson(imgApi, { 398 | params, 399 | }).then((res) => res.data.pagination.total); 400 | let imageRes = await this.fetchJson(imgApi, { 401 | params: { 402 | ...params, 403 | page_size: pageNum, 404 | }, 405 | }).then((res) => res.data.images); 406 | let images = imageRes.map((item) => item.url); 407 | return { 408 | images, 409 | }; 410 | }, 411 | }; 412 | } 413 | -------------------------------------------------------------------------------- /lanraragi.js: -------------------------------------------------------------------------------- 1 | /** @type {import('./_venera_.js')} */ 2 | class Lanraragi extends ComicSource { 3 | name = "Lanraragi" 4 | key = "lanraragi" 5 | version = "1.1.0" 6 | minAppVersion = "1.4.0" 7 | url = "https://git.nyne.dev/nyne/venera-configs/raw/branch/main/lanraragi.js" 8 | 9 | settings = { 10 | api: { title: "API", type: "input", default: "http://lrr.tvc-16.science" }, 11 | apiKey: { title: "APIKEY", type: "input", default: "" } 12 | } 13 | 14 | get baseUrl() { 15 | const api = this.loadSetting('api') || this.settings.api.default 16 | 17 | return api.replace(/\/$/, '') 18 | } 19 | 20 | get headers() { 21 | let apiKey = this.loadSetting('apiKey') 22 | if (apiKey) apiKey = "Bearer " + Convert.encodeBase64(Convert.encodeUtf8(apiKey)) 23 | 24 | return { 25 | "Authorization": `${apiKey}`, 26 | } 27 | } 28 | 29 | async init() { 30 | try { 31 | const url = `${this.baseUrl}/api/categories` 32 | const res = await Network.get(url, this.headers) 33 | if (res.status !== 200) { this.saveData('categories', []); return } 34 | let data = [] 35 | try { data = JSON.parse(res.body) } catch (_) { data = [] } 36 | if (!Array.isArray(data)) data = [] 37 | this.saveData('categories', data) 38 | this.saveData('categories_ts', Date.now()) 39 | } catch (_) { this.saveData('categories', []) } 40 | } 41 | 42 | // account = { 43 | // login: async (account, pwd) => {}, 44 | // loginWithWebview: { url: "", checkStatus: (url, title) => false, onLoginSuccess: () => {} }, 45 | // loginWithCookies: { fields: ["ipb_member_id","ipb_pass_hash","igneous","star"], validate: async (values) => false }, 46 | // logout: () => {}, 47 | // registerWebsite: null, 48 | // } 49 | 50 | explore = [ 51 | { title: "Lanraragi", type: "multiPageComicList", load: async (page = 1) => { 52 | const url = `${this.baseUrl}/api/archives` 53 | const res = await Network.get(url, this.headers) 54 | if (res.status !== 200) throw `Invalid status code: ${res.status}` 55 | const data = JSON.parse(res.body) 56 | const list = data.slice((page-1)*50, page*50) 57 | const parseComic = (item) => { 58 | let base = this.baseUrl.replace(/\/$/, '') 59 | if (!/^https?:\/\//.test(base)) base = 'http://' + base 60 | const cover = `${base}/api/archives/${item.arcid}/thumbnail` 61 | return new Comic({ id: item.arcid, title: item.title, subTitle: '', cover, tags: item.tags ? item.tags.split(',').map(t=>t.trim()).filter(Boolean) : [], description: `页数: ${item.pagecount} | 新: ${item.isnew} | 扩展: ${item.extension}` }) 62 | } 63 | return { comics: list.map(parseComic), maxPage: Math.ceil(data.length/50) } 64 | }} 65 | ] 66 | 67 | category = { 68 | title: "Lanraragi", 69 | parts: [ { name: "ALL", type: "dynamic", loader: () => { 70 | const data = this.loadData('categories') 71 | if (!Array.isArray(data) || data.length === 0) throw 'Please check your API settings or categories.' 72 | const items = [] 73 | for (const cat of data) { 74 | if (!cat) continue 75 | const id = cat.id ?? cat._id ?? cat.name 76 | const label = cat.name ?? String(id) 77 | try { items.push({ label, target: new PageJumpTarget({ page: 'category', attributes: { category: id, param: null } }) }) } 78 | catch (_) { items.push({ label, target: { page: 'category', attributes: { category: id, param: null } } }) } 79 | } 80 | return items 81 | } } ], 82 | enableRankingPage: false, 83 | } 84 | 85 | categoryComics = { 86 | load: async (category, param, options, page) => { 87 | // Use /search endpoint filtered by category tag value 88 | const base = (this.baseUrl || '').replace(/\/$/, '') 89 | const pageSize = 100 90 | const start = Math.max(0, (page - 1) * pageSize) 91 | 92 | const qp = [] 93 | const add = (k, v) => qp.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) 94 | add('draw', String(Date.now() % 1000)) 95 | add('columns[0][data]', '') 96 | add('columns[0][name]', 'title') 97 | add('columns[0][searchable]', 'true') 98 | add('columns[0][orderable]', 'true') 99 | add('columns[0][search][value]', '') 100 | add('columns[0][search][regex]', 'false') 101 | add('columns[1][data]', 'tags') 102 | add('columns[1][name]', 'artist') 103 | add('columns[1][searchable]', 'true') 104 | add('columns[1][orderable]', 'true') 105 | add('columns[1][search][value]', '') 106 | add('columns[1][search][regex]', 'false') 107 | add('columns[2][data]', 'tags') 108 | add('columns[2][name]', 'series') 109 | add('columns[2][searchable]', 'true') 110 | add('columns[2][orderable]', 'true') 111 | add('columns[2][search][value]', '') 112 | add('columns[2][search][regex]', 'false') 113 | add('columns[3][data]', 'tags') 114 | add('columns[3][name]', 'tags') 115 | add('columns[3][searchable]', 'true') 116 | add('columns[3][orderable]', 'false') 117 | // Filter by category identifier in tags column 118 | add('columns[3][search][value]', category || '') 119 | add('columns[3][search][regex]', 'false') 120 | add('order[0][column]', '0') 121 | add('order[0][dir]', 'asc') 122 | add('start', String(start)) 123 | add('length', String(pageSize)) 124 | add('search[value]', '') 125 | add('search[regex]', 'false') 126 | 127 | const url = `${base}/search?${qp.join('&')}` 128 | const res = await Network.get(url, this.headers) 129 | if (res.status !== 200) throw `Invalid status code: ${res.status}` 130 | const data = JSON.parse(res.body) 131 | const list = Array.isArray(data.data) ? data.data : [] 132 | const comics = list.map(item => { 133 | const cover = `${base}/api/archives/${item.arcid}/thumbnail` 134 | const tags = item.tags ? item.tags.split(',').map(t => t.trim()).filter(Boolean) : [] 135 | return new Comic({ 136 | id: item.arcid, 137 | title: item.title || item.filename || item.arcid, 138 | subTitle: '', 139 | cover, 140 | tags, 141 | description: `页数: ${item.pagecount} | 新: ${item.isnew} | 扩展: ${item.extension}` 142 | }) 143 | }) 144 | 145 | const total = typeof data.recordsFiltered === 'number' && data.recordsFiltered >= 0 146 | ? data.recordsFiltered 147 | : (list.length < pageSize ? start + list.length : start + pageSize) 148 | const maxPage = Math.max(1, Math.ceil(total / pageSize)) 149 | return { comics, maxPage } 150 | } 151 | } 152 | 153 | search = { 154 | load: async (keyword, options, page = 1) => { 155 | const base = (this.baseUrl || '').replace(/\/$/, '') 156 | 157 | // Fetch all results once (start=-1), then page locally for consistent UX across servers 158 | const qp = [] 159 | const add = (k, v) => qp.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) 160 | const pick = (key, def) => { 161 | let v = options && (options[key]) 162 | if (typeof v === 'string') { 163 | const idx = v.indexOf('-'); 164 | if (idx > 0) v = v.slice(0, idx) 165 | } 166 | return (v === undefined || v === null || v === '') ? def : v 167 | } 168 | const sortby = pick(0, 'title') 169 | const order = pick(1, 'asc') 170 | const newonly = String(pick(2, 'false')) 171 | const untaggedonly = String(pick(3, 'false')) 172 | const groupby = String(pick(4, 'true')) 173 | 174 | add('filter', (keyword || '').trim()) 175 | add('start', '-1') 176 | add('sortby', sortby) 177 | add('order', order) 178 | add('newonly', newonly) 179 | add('untaggedonly', untaggedonly) 180 | add('groupby_tanks', groupby) 181 | 182 | const url = `${base}/api/search?${qp.join('&')}` 183 | const res = await Network.get(url, this.headers) 184 | if (res.status !== 200) throw `Invalid status code: ${res.status}` 185 | const data = JSON.parse(res.body) 186 | const all = Array.isArray(data.data) ? data.data : [] 187 | 188 | const pageSize = 100 189 | const start = Math.max(0, (page - 1) * pageSize) 190 | const slice = all.slice(start, start + pageSize) 191 | 192 | const comics = slice.map(item => { 193 | const cover = `${base}/api/archives/${item.arcid}/thumbnail` 194 | const tags = item.tags ? item.tags.split(',').map(t => t.trim()).filter(Boolean) : [] 195 | return new Comic({ 196 | id: item.arcid, 197 | title: item.title || item.filename || item.arcid, 198 | subTitle: '', 199 | cover, 200 | tags, 201 | description: `页数: ${item.pagecount ?? ''} | 新: ${item.isnew ?? ''} | 扩展: ${item.extension ?? ''}` 202 | }) 203 | }) 204 | 205 | const total = (typeof data.recordsFiltered === 'number' && data.recordsFiltered >= 0) 206 | ? data.recordsFiltered 207 | : all.length 208 | const maxPage = Math.max(1, Math.ceil(total / pageSize)) 209 | return { comics, maxPage } 210 | }, 211 | loadNext: async (keyword, options, next) => { 212 | const page = (typeof next === 'number' && next > 0) ? next : 1 213 | return await this.search.load(keyword, options, page) 214 | }, 215 | optionList: [ 216 | { type: "select", options: ["title-按标题","lastread-最近阅读"], label: "sortby", default: "title" }, 217 | { type: "select", options: ["asc-升序","desc-降序"], label: "order", default: "asc" }, 218 | { type: "select", options: ["false-全部","true-仅新"], label: "newonly", default: "false" }, 219 | { type: "select", options: ["false-全部","true-仅未打标签"], label: "untaggedonly", default: "false" }, 220 | { type: "select", options: ["true-启用","false-禁用"], label: "groupby_tanks", default: "true" } 221 | ], 222 | enableTagsSuggestions: false, 223 | } 224 | 225 | // favorites = { 226 | // multiFolder: false, 227 | // addOrDelFavorite: async (comicId, folderId, isAdding, favoriteId) => {}, 228 | // loadFolders: async (comicId) => {}, 229 | // addFolder: async (name) => {}, 230 | // deleteFolder: async (folderId) => {}, 231 | // loadComics: async (page, folder) => {}, 232 | // loadNext: async (next, folder) => {}, 233 | // singleFolderForSingleComic: false, 234 | // } 235 | 236 | comic = { 237 | loadInfo: async (id) => { 238 | const url = `${this.baseUrl}/api/archives/${id}/metadata` 239 | const res = await Network.get(url, this.headers) 240 | if (res.status !== 200) throw `Invalid status code: ${res.status}` 241 | const data = JSON.parse(res.body) 242 | const cover = `${this.baseUrl}/api/archives/${id}/thumbnail` 243 | let tags = data.tags ? data.tags.split(',').map(t=>t.trim()).filter(Boolean) : [] 244 | const rating = tags.find(t=>t.startsWith('rating:')) 245 | if (rating) tags = tags.filter(t=>!t.startsWith('rating:')) 246 | const chapters = new Map(); chapters.set(id, data.title || 'Local manga') 247 | return { title: data.title || data.filename || id, cover, description: data.summary || '', tags: { "Tags": tags, "Extension": [data.extension], "Rating": rating ? [rating.replace('rating:', '')] : [], "Page": [String(data.pagecount)] }, chapters } 248 | }, 249 | loadThumbnails: async (id, next) => { 250 | const metaUrl = `${this.baseUrl}/api/archives/${id}/metadata` 251 | const res = await Network.get(metaUrl, this.headers) 252 | if (res.status !== 200) throw `Invalid status code: ${res.status}` 253 | const data = JSON.parse(res.body) 254 | const pagecount = data.pagecount || 1 255 | const thumbnails = [] 256 | for (let i = 1; i <= pagecount; i++) thumbnails.push(`${this.baseUrl}/api/archives/${id}/thumbnail?page=${i}`) 257 | return { thumbnails, next: null } 258 | }, 259 | starRating: async (id, rating) => {}, 260 | loadEp: async (comicId, epId) => { 261 | const base = (this.baseUrl || '').replace(/\/$/, '') 262 | const url = `${base}/api/archives/${comicId}/files?force=false` 263 | const res = await Network.get(url, this.headers) 264 | if (res.status !== 200) throw `Invalid status code: ${res.status}` 265 | const data = JSON.parse(res.body) 266 | const images = (data.pages || []).map(p => { 267 | if (!p) return null 268 | const s = String(p) 269 | if (/^https?:\/\//i.test(s)) return s 270 | return `${base}${s.startsWith('/') ? s : '/' + s}` 271 | }).filter(Boolean) 272 | return { images } 273 | }, 274 | onImageLoad: (url, comicId, epId) => { 275 | return { 276 | headers: this.headers 277 | } 278 | }, 279 | onThumbnailLoad: (url) => { 280 | return { 281 | headers: this.headers 282 | } 283 | }, 284 | // likeComic: async (id, isLike) => {}, 285 | // loadComments: async (comicId, subId, page, replyTo) => {}, 286 | // sendComment: async (comicId, subId, content, replyTo) => {}, 287 | // likeComment: async (comicId, subId, commentId, isLike) => {}, 288 | // voteComment: async (id, subId, commentId, isUp, isCancel) => {}, 289 | // idMatch: null, 290 | // onClickTag: (namespace, tag) => {}, 291 | // link: { domains: ['example.com'], linkToId: (url) => null }, 292 | enableTagsTranslate: false, 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /baozi.js: -------------------------------------------------------------------------------- 1 | class Baozi extends ComicSource { 2 | // 此漫画源的名称 3 | name = "包子漫画"; 4 | 5 | // 唯一标识符 6 | key = "baozi"; 7 | 8 | version = "1.1.3"; 9 | 10 | minAppVersion = "1.0.0"; 11 | 12 | // 更新链接 13 | url = "https://git.nyne.dev/nyne/venera-configs/raw/branch/main/baozi.js"; 14 | 15 | settings = { 16 | language: { 17 | title: "简繁切换", 18 | type: "select", 19 | options: [ 20 | { value: "cn", text: "简体" }, 21 | { value: "tw", text: "繁體" }, 22 | ], 23 | default: "cn", 24 | }, 25 | domains: { 26 | title: "主域名", 27 | type: "select", 28 | options: [ 29 | { value: "bzmgcn.com" }, 30 | { value: "baozimhcn.com" }, 31 | { value: "webmota.com" }, 32 | { value: "kukuc.co" }, 33 | { value: "twmanga.com" }, 34 | { value: "dinnerku.com" }, 35 | ], 36 | default: "bzmgcn.com", 37 | }, 38 | }; 39 | 40 | // 动态生成完整域名 41 | get lang() { 42 | return this.loadSetting("language") || this.settings.language.default; 43 | } 44 | get baseUrl() { 45 | let domain = this.loadSetting("domains") || this.settings.domains.default; 46 | return `https://${this.lang}.${domain}`; 47 | } 48 | 49 | /// 账号 50 | /// 设置为null禁用账号功能 51 | account = { 52 | /// 登录 53 | /// 返回任意值表示登录成功 54 | login: async (account, pwd) => { 55 | let res = await Network.post( 56 | `${this.baseUrl}/api/bui/signin`, 57 | { 58 | "content-type": 59 | "multipart/form-data; boundary=----WebKitFormBoundaryFUNUxpOwyUaDop8s", 60 | }, 61 | '------WebKitFormBoundaryFUNUxpOwyUaDop8s\r\nContent-Disposition: form-data; name="username"\r\n\r\n' + 62 | account + 63 | '\r\n------WebKitFormBoundaryFUNUxpOwyUaDop8s\r\nContent-Disposition: form-data; name="password"\r\n\r\n' + 64 | pwd + 65 | "\r\n------WebKitFormBoundaryFUNUxpOwyUaDop8s--\r\n" 66 | ); 67 | if (res.status !== 200) { 68 | throw "Invalid status code: " + res.status; 69 | } 70 | let json = JSON.parse(res.body); 71 | let token = json.data; 72 | Network.setCookies(this.baseUrl, [ 73 | new Cookie({ 74 | name: "TSID", 75 | value: token, 76 | domain: this.loadSetting("domains") || this.settings.domains.default, 77 | }), 78 | ]); 79 | return "ok"; 80 | }, 81 | 82 | // 退出登录时将会调用此函数 83 | logout: function () { 84 | Network.deleteCookies( 85 | this.loadSetting("domains") || this.settings.domains.default 86 | ); 87 | }, 88 | 89 | get registerWebsite() { 90 | return `${this.baseUrl}/user/signup`; 91 | }, 92 | }; 93 | 94 | /// 解析漫画列表 95 | parseComic(e) { 96 | let url = e.querySelector("a").attributes["href"]; 97 | let id = url.split("/").pop(); 98 | let title = e.querySelector("h3").text.trim(); 99 | let cover = e.querySelector("a > amp-img").attributes["src"]; 100 | let tags = e.querySelectorAll("div.tabs > span").map((e) => e.text.trim()); 101 | let description = e.querySelector("small").text.trim(); 102 | return { 103 | id: id, 104 | title: title, 105 | cover: cover, 106 | tags: tags, 107 | description: description, 108 | }; 109 | } 110 | 111 | parseJsonComic(e) { 112 | return { 113 | id: e.comic_id, 114 | title: e.name, 115 | subTitle: e.author, 116 | cover: `https://static-tw.baozimh.com/cover/${e.topic_img}?w=285&h=375&q=100`, 117 | tags: e.type_names, 118 | }; 119 | } 120 | 121 | /// 探索页面 122 | /// 一个漫画源可以有多个探索页面 123 | explore = [ 124 | { 125 | /// 标题 126 | /// 标题同时用作标识符, 不能重复 127 | title: "包子漫画", 128 | 129 | /// singlePageWithMultiPart 或者 multiPageComicList 130 | type: "singlePageWithMultiPart", 131 | 132 | load: async () => { 133 | var res = await Network.get(this.baseUrl); 134 | if (res.status !== 200) { 135 | throw "Invalid status code: " + res.status; 136 | } 137 | let document = new HtmlDocument(res.body); 138 | let parts = document.querySelectorAll("div.index-recommend-items"); 139 | let result = {}; 140 | for (let part of parts) { 141 | let title = part.querySelector("div.catalog-title").text.trim(); 142 | let comics = part 143 | .querySelectorAll("div.comics-card") 144 | .map((e) => this.parseComic(e)); 145 | if (comics.length > 0) { 146 | result[title] = comics; 147 | } 148 | } 149 | return result; 150 | }, 151 | }, 152 | ]; 153 | 154 | /// 分类页面 155 | /// 一个漫画源只能有一个分类页面, 也可以没有, 设置为null禁用分类页面 156 | category = { 157 | /// 标题, 同时为标识符, 不能与其他漫画源的分类页面重复 158 | title: "包子漫画", 159 | parts: [ 160 | { 161 | name: "类型", 162 | 163 | // fixed 或者 random 164 | // random用于分类数量相当多时, 随机显示其中一部分 165 | type: "fixed", 166 | 167 | // 如果类型为random, 需要提供此字段, 表示同时显示的数量 168 | // randomNumber: 5, 169 | 170 | categories: [ 171 | "全部", 172 | "恋爱", 173 | "纯爱", 174 | "古风", 175 | "异能", 176 | "悬疑", 177 | "剧情", 178 | "科幻", 179 | "奇幻", 180 | "玄幻", 181 | "穿越", 182 | "冒险", 183 | "推理", 184 | "武侠", 185 | "格斗", 186 | "战争", 187 | "热血", 188 | "搞笑", 189 | "大女主", 190 | "都市", 191 | "总裁", 192 | "后宫", 193 | "日常", 194 | "韩漫", 195 | "少年", 196 | "其它", 197 | ], 198 | 199 | // category或者search 200 | // 如果为category, 点击后将进入分类漫画页面, 使用下方的`categoryComics`加载漫画 201 | // 如果为search, 将进入搜索页面 202 | itemType: "category", 203 | 204 | // 若提供, 数量需要和`categories`一致, `categoryComics.load`方法将会收到此参数 205 | categoryParams: [ 206 | "all", 207 | "lianai", 208 | "chunai", 209 | "gufeng", 210 | "yineng", 211 | "xuanyi", 212 | "juqing", 213 | "kehuan", 214 | "qihuan", 215 | "xuanhuan", 216 | "chuanyue", 217 | "mouxian", 218 | "tuili", 219 | "wuxia", 220 | "gedou", 221 | "zhanzheng", 222 | "rexie", 223 | "gaoxiao", 224 | "danuzhu", 225 | "dushi", 226 | "zongcai", 227 | "hougong", 228 | "richang", 229 | "hanman", 230 | "shaonian", 231 | "qita", 232 | ], 233 | }, 234 | ], 235 | enableRankingPage: false, 236 | }; 237 | 238 | /// 分类漫画页面, 即点击分类标签后进入的页面 239 | categoryComics = { 240 | load: async (category, param, options, page) => { 241 | let res = await Network.get( 242 | `${this.baseUrl}/api/bzmhq/amp_comic_list?type=${param}®ion=${options[0]}&state=${options[1]}&filter=%2a&page=${page}&limit=36&language=${this.lang}&__amp_source_origin=${this.baseUrl}` 243 | ); 244 | if (res.status !== 200) { 245 | throw "Invalid status code: " + res.status; 246 | } 247 | let maxPage = null; 248 | let json = JSON.parse(res.body); 249 | if (!json.next) { 250 | maxPage = page; 251 | } 252 | return { 253 | comics: json.items.map((e) => this.parseJsonComic(e)), 254 | maxPage: maxPage, 255 | }; 256 | }, 257 | // 提供选项 258 | optionList: [ 259 | { 260 | options: ["all-全部", "cn-国漫", "jp-日本", "kr-韩国", "en-欧美"], 261 | }, 262 | { 263 | options: ["all-全部", "serial-连载中", "pub-已完结"], 264 | }, 265 | ], 266 | }; 267 | 268 | /// 搜索 269 | search = { 270 | load: async (keyword, options, page) => { 271 | let res = await Network.get(`${this.baseUrl}/search?q=${keyword}`); 272 | if (res.status !== 200) { 273 | throw "Invalid status code: " + res.status; 274 | } 275 | let document = new HtmlDocument(res.body); 276 | let comics = document 277 | .querySelectorAll("div.comics-card") 278 | .map((e) => this.parseComic(e)); 279 | return { 280 | comics: comics, 281 | maxPage: 1, 282 | }; 283 | }, 284 | 285 | // 提供选项 286 | optionList: [], 287 | }; 288 | 289 | /// 收藏 290 | favorites = { 291 | /// 是否为多收藏夹 292 | multiFolder: false, 293 | /// 添加或者删除收藏 294 | addOrDelFavorite: async (comicId, folderId, isAdding) => { 295 | if (!isAdding) { 296 | let res = await Network.post( 297 | `${this.baseUrl}/user/operation_v2?op=del_bookmark&comic_id=${comicId}` 298 | ); 299 | if (!res.status || res.status >= 400) { 300 | throw "Invalid status code: " + res.status; 301 | } 302 | return "ok"; 303 | } else { 304 | let res = await Network.post( 305 | `${this.baseUrl}/user/operation_v2?op=set_bookmark&comic_id=${comicId}&chapter_slot=0` 306 | ); 307 | if (!res.status || res.status >= 400) { 308 | throw "Invalid status code: " + res.status; 309 | } 310 | return "ok"; 311 | } 312 | }, 313 | // 加载收藏夹, 仅当multiFolder为true时有效 314 | // 当comicId不为null时, 需要同时返回包含该漫画的收藏夹 315 | loadFolders: null, 316 | /// 加载漫画 317 | loadComics: async (page, folder) => { 318 | let res = await Network.get(`${this.baseUrl}/user/my_bookshelf`); 319 | if (res.status !== 200) { 320 | throw "Invalid status code: " + res.status; 321 | } 322 | let document = new HtmlDocument(res.body); 323 | function parseComic(e) { 324 | let title = e.querySelector("h4 > a").text.trim(); 325 | let url = e.querySelector("h4 > a").attributes["href"]; 326 | let id = url.split("/").pop(); 327 | let author = e 328 | .querySelector("div.info > ul") 329 | .children[1].text.split(":")[1] 330 | .trim(); 331 | let description = e 332 | .querySelector("div.info > ul") 333 | .children[4].children[0].text.trim(); 334 | 335 | return { 336 | id: id, 337 | title: title, 338 | subTitle: author, 339 | description: description, 340 | cover: e.querySelector("amp-img").attributes["src"], 341 | }; 342 | } 343 | let comics = document 344 | .querySelectorAll("div.bookshelf-items") 345 | .map((e) => parseComic(e)); 346 | return { 347 | comics: comics, 348 | maxPage: 1, 349 | }; 350 | }, 351 | }; 352 | 353 | /// 单个漫画相关 354 | comic = { 355 | // 加载漫画信息 356 | loadInfo: async (id) => { 357 | let res = await Network.get(`${this.baseUrl}/comic/${id}`); 358 | if (res.status !== 200) { 359 | throw "Invalid status code: " + res.status; 360 | } 361 | let document = new HtmlDocument(res.body); 362 | 363 | let title = document.querySelector("h1.comics-detail__title").text.trim(); 364 | let cover = document.querySelector("div.l-content > div > div > amp-img") 365 | .attributes["src"]; 366 | let author = document 367 | .querySelector("h2.comics-detail__author") 368 | .text.trim(); 369 | let tags = document 370 | .querySelectorAll("div.tag-list > span") 371 | .map((e) => e.text.trim()); 372 | tags = [...tags.filter((e) => e !== "")]; 373 | let updateTime = document 374 | .querySelector("div.supporting-text > div > span > em") 375 | ?.text.trim() 376 | .replace("(", "") 377 | .replace(")", ""); 378 | if (!updateTime) { 379 | const getLastChapterText = () => { 380 | // 合并所有章节容器(处理可能存在多个列表的情况) 381 | const containers = [ 382 | ...document.querySelectorAll( 383 | "#chapter-items, #chapters_other_list" 384 | ), 385 | ]; 386 | let allChapters = []; 387 | containers.forEach((container) => { 388 | const chapters = container.querySelectorAll(".comics-chapters > a"); 389 | allChapters.push(...Array.from(chapters)); 390 | }); 391 | const lastChapter = allChapters[allChapters.length - 1]; 392 | return ( 393 | lastChapter?.querySelector("div > span")?.text.trim() || 394 | "暂无更新信息" 395 | ); 396 | }; 397 | updateTime = getLastChapterText(); 398 | } 399 | let description = document 400 | .querySelector("p.comics-detail__desc") 401 | .text.trim(); 402 | let chapters = new Map(); 403 | let i = 0; 404 | for (let c of document.querySelectorAll( 405 | "div#chapter-items > div.comics-chapters > a > div > span" 406 | )) { 407 | chapters.set(i.toString(), c.text.trim()); 408 | i++; 409 | } 410 | for (let c of document.querySelectorAll( 411 | "div#chapters_other_list > div.comics-chapters > a > div > span" 412 | )) { 413 | chapters.set(i.toString(), c.text.trim()); 414 | i++; 415 | } 416 | if (i === 0) { 417 | // 将倒序的最新章节反转 418 | const spans = Array.from( 419 | document.querySelectorAll("div.comics-chapters > a > div > span") 420 | ).reverse(); 421 | for (let c of spans) { 422 | chapters.set(i.toString(), c.text.trim()); 423 | i++; 424 | } 425 | } 426 | let recommend = []; 427 | for (let c of document.querySelectorAll("div.recommend--item")) { 428 | if (c.querySelectorAll("div.tag-comic").length > 0) { 429 | let title = c.querySelector("span").text.trim(); 430 | let cover = c.querySelector("amp-img").attributes["src"]; 431 | let url = c.querySelector("a").attributes["href"]; 432 | let id = url.split("/").pop(); 433 | recommend.push({ 434 | id: id, 435 | title: title, 436 | cover: cover, 437 | }); 438 | } 439 | } 440 | // updateTime 将 Y年 M月 D日 转化为 Y-M-D 441 | let updateDate = updateTime 442 | .replace(/年/g, "-") 443 | .replace(/月/g, "-") 444 | .replace(/日/g, ""); 445 | 446 | return new ComicDetails({ 447 | title: title, 448 | cover: cover, 449 | description: description, 450 | tags: { 451 | 作者: [author], 452 | 标签: tags, 453 | }, 454 | chapters: chapters, 455 | recommend: recommend, 456 | updateTime: updateDate, 457 | }); 458 | }, 459 | loadEp: async (comicId, epId) => { 460 | const images = []; 461 | 462 | // App版链接 463 | let currentPageUrl = `https://appcn.baozimh.com/baozimhapp/comic/chapter/${comicId}/0_${epId}.html`; 464 | 465 | const res = await Network.get(currentPageUrl); 466 | if (res.status !== 200) { 467 | throw `Invalid status code: ${res.status}`; 468 | } 469 | 470 | const doc = new HtmlDocument(res.body); 471 | 472 | // 解析当前页图片(App 版) 473 | const imageNodes = doc.querySelectorAll(".comic-contain > .chapter-img"); 474 | imageNodes.forEach((imgNode) => { 475 | const imgUrl = imgNode.querySelector(".comic-contain__item")?.attributes?.["data-src"]; 476 | if (imgUrl) { 477 | 478 | // 替换 /w640/ 为 /,使用原图而不是压缩图 479 | // TODO: 可以添加配置选项让用户选择使用略缩图或者原图 480 | let processedUrl = imgUrl.replace("/w640/", "/"); 481 | 482 | // 提取域名并替换 483 | const regex = /^(https?:\/\/)?([^/\s:]+)(:\d+)?/; 484 | const match = processedUrl.match(regex); 485 | if (match && match[2]) { 486 | const domain = match[2]; 487 | processedUrl = processedUrl.replace(domain, "as.baozimh.com"); 488 | } 489 | 490 | images.push(processedUrl); 491 | } 492 | }); 493 | 494 | return { images: images }; 495 | }, 496 | }; 497 | } 498 | -------------------------------------------------------------------------------- /zaimanhua.js: -------------------------------------------------------------------------------- 1 | class Zaimanhua extends ComicSource { 2 | // 基础信息 3 | name = "再漫画"; 4 | key = "zaimanhua"; 5 | version = "1.0.2"; 6 | minAppVersion = "1.0.0"; 7 | url = 8 | "https://git.nyne.dev/nyne/venera-configs/raw/branch/main/zaimanhua.js"; 9 | 10 | // 初始化请求头 11 | init() { 12 | this.headers = { 13 | "User-Agent": "Mozilla/5.0 (Linux; Android) Mobile", 14 | "authorization": `Bearer ${this.loadData("token") || ""}`, 15 | }; 16 | } 17 | // 构建 URL 18 | buildUrl(path) { 19 | this.signTask(); 20 | return `https://v4api.zaimanhua.com/app/v1/${path}`; 21 | } 22 | // 每日签到 23 | async signTask() { 24 | if (!this.isLogged) { 25 | return; 26 | } 27 | if (!this.loadSetting("signTask")) { 28 | return; 29 | } 30 | const lastSign = this.loadData("lastSign"); 31 | const newTime = new Date().toISOString().split("T")[0]; 32 | if (lastSign == newTime) { 33 | return; 34 | } 35 | const res = await Network.post("https://i.zaimanhua.com/lpi/v1/task/sign_in", this.headers); 36 | if (res.status !== 200) { 37 | return; 38 | } 39 | this.saveData("lastSign", newTime); 40 | if (JSON.parse(res.body)["errno"] == 0) { 41 | UI.showMessage("签到成功"); 42 | } 43 | } 44 | 45 | //账户管理 46 | account = { 47 | login: async (username, password) => { 48 | try { 49 | const encryptedPwd = Convert.hexEncode( 50 | Convert.md5(Convert.encodeUtf8(password)) 51 | ); 52 | const res = await Network.post( 53 | "https://account-api.zaimanhua.com/v1/login/passwd", 54 | { "Content-Type": "application/x-www-form-urlencoded;charset=utf-8" }, 55 | `username=${username}&passwd=${encryptedPwd}` 56 | ); 57 | 58 | const data = JSON.parse(res.body); 59 | if (data.errno !== 0) throw new Error(data.errmsg); 60 | 61 | this.saveData("token", data.data.user.token); 62 | this.headers.authorization = `Bearer ${data.data.user.token}`; 63 | return true; 64 | } catch (e) { 65 | UI.showMessage(`登录失败: ${e.message}`); 66 | throw e; 67 | } 68 | }, 69 | logout: () => { 70 | this.deleteData("token"); 71 | }, 72 | }; 73 | 74 | // 状态检查 75 | checkResponseStatus(res) { 76 | if (res.status === 401) { 77 | throw new Error("登录失效"); 78 | } 79 | if (res.status !== 200) { 80 | throw new Error(`请求失败: ${res.status}`); 81 | } 82 | } 83 | 84 | // 漫画解析 85 | parseComic(comic) { 86 | // const safeString = (value) => (value || "").toString().trim(); 87 | const safeString = (value) => (value != null ? value.toString() : ""); 88 | const resolveId = () => 89 | [comic.comic_id, comic.id].find((id) => id && id !== "0") || ""; 90 | const resolveTags = () => 91 | [comic.status, ...safeString(comic.types).split("/")].filter(Boolean); 92 | const resolveDescription = () => { 93 | const candidates = [ 94 | comic.description, 95 | comic.last_update_chapter_name, 96 | comic.last_name, 97 | ]; 98 | return candidates.find((text) => text) || ""; 99 | }; 100 | 101 | return { 102 | id: safeString(resolveId()), 103 | title: comic.title || comic.name, 104 | subTitle: comic.authors, 105 | cover: comic.cover, 106 | tags: resolveTags(), 107 | description: resolveDescription(), 108 | }; 109 | } 110 | 111 | //探索页面 112 | explore = [ 113 | { 114 | title: "再漫画 更新", 115 | type: "multiPageComicList", 116 | load: async (page) => { 117 | const res = await Network.get( 118 | this.buildUrl(`comic/update/list/0/${page}`), 119 | this.headers 120 | ); 121 | const data = JSON.parse(res.body).data; 122 | return { 123 | comics: data.map((item) => this.parseComic(item)), 124 | }; 125 | }, 126 | }, 127 | ]; 128 | 129 | static categoryParamMap = { 130 | "全部": "0", 131 | "冒险": "4", 132 | "欢乐向": "5", 133 | "格斗": "6", 134 | "科幻": "7", 135 | "爱情": "8", 136 | "侦探": "9", 137 | "竞技": "10", 138 | "魔法": "11", 139 | "神鬼": "12", 140 | "校园": "13", 141 | "惊悚": "14", 142 | "其他": "16", 143 | "四格": "17", 144 | "亲情": "3242", 145 | "百合": "3243", 146 | "秀吉": "3244", 147 | "悬疑": "3245", 148 | "纯爱": "3246", 149 | "热血": "3248", 150 | "泛爱": "3249", 151 | "历史": "3250", 152 | "战争": "3251", 153 | "萌系": "3252", 154 | "宅系": "3253", 155 | "治愈": "3254", 156 | "励志": "3255", 157 | "武侠": "3324", 158 | "机战": "3325", 159 | "音乐舞蹈": "3326", 160 | "美食": "3327", 161 | "职场": "3328", 162 | "西方魔幻": "3365", 163 | "高清单行": "4459", 164 | "TS": "4518", 165 | "东方": "5077", 166 | "魔幻": "5806", 167 | "奇幻": "5848", 168 | "节操": "6219", 169 | "轻小说": "6316", 170 | "颜艺": "6437", 171 | "搞笑": "7568", 172 | "仙侠": "23388", 173 | "舰娘": "7900", 174 | "动画": "13627", 175 | "AA": "17192", 176 | "福瑞": "18522", 177 | "生存": "23323", 178 | "日常": "23388", 179 | "画集": "30788", 180 | "C100": "31137", 181 | }; 182 | 183 | //分类页面 184 | category = { 185 | title: "再漫画", 186 | parts: [ 187 | { 188 | name: "排行榜", 189 | type: "fixed", 190 | categories: ["日排行", "周排行", "月排行", "总排行"], 191 | itemType: "category", 192 | categoryParams: ["0", "1", "2", "3"], 193 | }, 194 | { 195 | name: "分类", 196 | type: "fixed", 197 | categories: Object.keys(Zaimanhua.categoryParamMap), 198 | categoryParams: Object.values(Zaimanhua.categoryParamMap), 199 | itemType: "category", 200 | }, 201 | ], 202 | }; 203 | 204 | //分类漫画加载 205 | categoryComics = { 206 | load: async (category, param, options, page) => { 207 | if (category.includes("排行")) { 208 | let res = await Network.get( 209 | this.buildUrl( 210 | `comic/rank/list?page=${page}&rank_type=${options}&by_time=${param}` 211 | ), 212 | this.headers 213 | ); 214 | return { 215 | comics: JSON.parse(res.body).data.map((item) => 216 | this.parseComic(item) 217 | ), 218 | maxPage: 10, 219 | }; 220 | } else { 221 | param = Zaimanhua.categoryParamMap[category] || "0"; 222 | let res = await Network.get( 223 | this.buildUrl( 224 | `comic/filter/list?status=${options[2]}&theme=${param}&zone=${options[3]}&cate=${options[1]}&sortType=${options[0]}&page=${page}&size=20` 225 | ), 226 | this.headers 227 | ); 228 | const data = JSON.parse(res.body).data; 229 | return { 230 | comics: data.comicList.map((item) => this.parseComic(item)), 231 | maxPage: Math.ceil(data.totalNum / 20), 232 | }; 233 | } 234 | }, 235 | optionList: [ 236 | { 237 | options: ["1-更新", "2-人气"], 238 | notShowWhen: null, 239 | showWhen: Object.keys(Zaimanhua.categoryParamMap), 240 | }, 241 | { 242 | options: [ 243 | "0-全部", 244 | "3262-少年漫画", 245 | "3263-少女漫画", 246 | "3264-青年漫画", 247 | "13626-女青漫画", 248 | ], 249 | notShowWhen: null, 250 | showWhen: Object.keys(Zaimanhua.categoryParamMap), 251 | }, 252 | { 253 | options: ["0-全部", "2309-连载中", "2310-已完结", "29205-短篇"], 254 | notShowWhen: null, 255 | showWhen: Object.keys(Zaimanhua.categoryParamMap), 256 | }, 257 | { 258 | options: [ 259 | "0-全部", 260 | "2304-日本", 261 | "2305-韩国", 262 | "2306-欧美", 263 | "2307-港台", 264 | "2308-内地", 265 | "8435-其他", 266 | ], 267 | notShowWhen: null, 268 | showWhen: Object.keys(Zaimanhua.categoryParamMap), 269 | }, 270 | { 271 | options: ["0-人气", "1-吐槽", "2-订阅"], 272 | notshowWhen: null, 273 | showWhen: ["日排行", "周排行", "月排行", "总排行"], 274 | }, 275 | ], 276 | }; 277 | 278 | //搜索 279 | search = { 280 | load: async (keyword, options, page) => { 281 | const res = await Network.get( 282 | this.buildUrl( 283 | `search/index?keyword=${encodeURIComponent( 284 | keyword 285 | )}&page=${page}&sort=0&size=20` 286 | ), 287 | this.headers 288 | ); 289 | const data = JSON.parse(res.body).data.list; 290 | return { 291 | comics: data.map((item) => this.parseComic(item)), 292 | }; 293 | }, 294 | optionList: [], 295 | }; 296 | 297 | //收藏 298 | favorites = { 299 | multiFolder: false, 300 | addOrDelFavorite: async (comicId, folderId, isAdding) => { 301 | const path = isAdding ? "add" : "del"; 302 | const res = await Network.get( 303 | this.buildUrl(`comic/sub/${path}?comic_id=${comicId}`), 304 | this.headers 305 | ); 306 | const data = JSON.parse(res.body); 307 | if (data.errno !== 0) { 308 | throw new Error(data.errmsg || "操作失败"); 309 | } 310 | return "ok"; 311 | }, 312 | loadComics: async (page) => { 313 | try { 314 | const res = await Network.get( 315 | this.buildUrl(`comic/sub/list?status=0&page=${page}&size=20`), 316 | this.headers 317 | ); 318 | const data = JSON.parse(res.body).data; 319 | return { 320 | comics: data.subList.map((item) => this.parseComic(item)) ?? [], 321 | maxPage: Math.ceil(data.total / 20), 322 | }; 323 | } catch (e) { 324 | console.error("加载收藏失败:", e); 325 | return { comics: [], maxPage: null }; 326 | } 327 | }, 328 | }; 329 | 330 | // 时间戳转换 331 | formatTimestamp(ts) { 332 | const date = new Date(ts * 1000); 333 | return date.toISOString().split("T")[0]; 334 | } 335 | 336 | //漫画详情 337 | comic = { 338 | loadInfo: async (id) => { 339 | const getFavoriteStatus = async (id) => { 340 | let res = await Network.get( 341 | this.buildUrl(`comic/sub/checkIsSub?objId=${id}&source=1`), 342 | this.headers 343 | ); 344 | this.checkResponseStatus(res); 345 | return JSON.parse(res.body).data.isSub; 346 | }; 347 | let results = await Promise.all([ 348 | Network.get( 349 | this.buildUrl(`comic/detail/${id}?channel=android`), 350 | this.headers 351 | ), 352 | getFavoriteStatus.bind(this)(id), 353 | ]); 354 | const response = JSON.parse(results[0].body); 355 | if (response.errno !== 0) throw new Error(response.errmsg || "加载失败"); 356 | const data = response.data.data; 357 | 358 | function processChapters(groups) { 359 | return (groups || []).reduce((result, group) => { 360 | const groupTitle = group.title || "默认"; 361 | const chapters = (group.data || []) 362 | .reverse() 363 | .map((ch) => [ 364 | String(ch.chapter_id), 365 | `${ch.chapter_title.replace( 366 | /^(?:连载版?)?(\d+\.?\d*)([话卷])?$/, 367 | (_, n, t) => `第${n}${t || "话"}` 368 | )}`, 369 | ]); 370 | result.set(groupTitle, new Map(chapters)); 371 | return result; 372 | }, new Map()); 373 | } 374 | // 分类标签 375 | const { authors, status, types } = data; 376 | const tagMapper = (arr) => arr.map((t) => t.tag_name); 377 | return { 378 | title: data.title, 379 | cover: data.cover, 380 | description: data.description, 381 | tags: { 382 | "作者": tagMapper(authors), 383 | "状态": [...tagMapper(status), data.last_update_chapter_name], 384 | "标签": tagMapper(types), 385 | }, 386 | updateTime: this.formatTimestamp(data.last_updatetime), 387 | chapters: processChapters(data.chapters), 388 | isFavorite: results[1], 389 | subId: id, 390 | }; 391 | }, 392 | loadEp: async (comicId, epId) => { 393 | const res = await Network.get( 394 | this.buildUrl(`comic/chapter/${comicId}/${epId}`), 395 | this.headers 396 | ); 397 | const data = JSON.parse(res.body).data.data; 398 | return { images: data.page_url_hd || data.page_url }; 399 | }, 400 | 401 | loadComments: async (comicId, subId, page, replyTo) => { 402 | try { 403 | // 构建请求URL 404 | const url = this.buildUrl( 405 | `comment/list?page=${page}&size=30&type=4&objId=${ 406 | subId || comicId 407 | }&sortBy=1` 408 | ); 409 | const res = await Network.get(url, this.headers); 410 | this.checkResponseStatus(res); 411 | 412 | const response = JSON.parse(res.body); 413 | const data = response.data; 414 | 415 | /* 空数据检查 */ 416 | if (!data || !data.commentIdList || !data.commentList) { 417 | UI.showMessage("暂时没有评论,快来发表第一条吧~"); 418 | return { comments: [], maxPage: 0 }; 419 | } 420 | 421 | /* 处理评论ID列表 */ 422 | // 标准化ID数组:处理null/字符串/数组等多种情况 423 | const rawIds = Array.isArray(data.commentIdList) 424 | ? data.commentIdList 425 | : []; 426 | 427 | // 展开所有ID并过滤无效值 428 | const allCommentIds = rawIds 429 | .map((idStr) => `${idStr || ""}`.split(",")) // 转换为字符串再分割 430 | .flat() 431 | .filter((id) => id.trim() !== ""); 432 | 433 | // 最终ID处理流程 434 | const processComments = () => { 435 | // 去重并验证ID有效性 436 | const validIds = [...new Set(allCommentIds)].filter((id) => 437 | data.commentList.hasOwnProperty(id) 438 | ); 439 | 440 | // 过滤回复评论 441 | const filteredIds = replyTo 442 | ? validIds.filter( 443 | (id) => data.commentList[id]?.to_comment_id == replyTo 444 | ) 445 | : validIds; 446 | 447 | // 转换为评论对象 448 | return filteredIds.map((id) => { 449 | const comment = data.commentList[id]; 450 | return new Comment({ 451 | userName: comment.nickname || "匿名用户", 452 | avatar: comment.photo || "", 453 | content: comment.content || "[内容已删除]", 454 | time: this.formatTimestamp(comment.create_time), 455 | replyCount: comment.reply_amount || 0, 456 | score: comment.like_amount || 0, 457 | id: String(id), 458 | parentId: comment.to_comment_id || null, 459 | }); 460 | }); 461 | }; 462 | 463 | // 当没有有效评论时显示提示 464 | const comments = processComments(); 465 | if (comments.length === 0) { 466 | UI.showMessage(replyTo ? "该评论暂无回复" : "这里还没有评论哦~"); 467 | } 468 | 469 | return { 470 | comments: comments, 471 | maxPage: Math.ceil((data.total || 0) / 30), 472 | }; 473 | } catch (e) { 474 | console.error("评论加载失败:", e); 475 | UI.showMessage(`加载评论失败: ${e.message}`); 476 | return { comments: [], maxPage: 0 }; 477 | } 478 | }, 479 | 480 | // 发送评论, 返回任意值表示成功. 481 | sendComment: async (comicId, subId, content, replyTo) => { 482 | if (!replyTo) { 483 | replyTo = 0; 484 | } 485 | let res = await Network.post( 486 | this.buildUrl(`comment/add`), 487 | { 488 | ...this.headers, 489 | "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", 490 | }, 491 | `obj_id=${subId}&content=${encodeURIComponent( 492 | content 493 | )}&to_comment_id=${replyTo}&type=4` 494 | ); 495 | this.checkResponseStatus(res); 496 | let response = JSON.parse(res.body); 497 | if (response.errno !== 0) throw new Error(response.errmsg || "加载失败"); 498 | return "ok"; 499 | }, 500 | // 点赞 501 | likeComment: async (comicId, subId, commentId, isLike) => { 502 | let res = await Network.post( 503 | this.buildUrl(`comment/addLike`), 504 | { 505 | ...this.headers, 506 | "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", 507 | }, 508 | `commentId=${commentId}&type=4` 509 | ); 510 | this.checkResponseStatus(res); 511 | return "ok"; 512 | }, 513 | }; 514 | 515 | settings = { 516 | signTask: { 517 | title: "每日签到", 518 | type: "switch", 519 | default: false 520 | } 521 | }; 522 | } 523 | -------------------------------------------------------------------------------- /shonen_jump_plus.js: -------------------------------------------------------------------------------- 1 | class ShonenJumpPlus extends ComicSource { 2 | name = "少年ジャンプ+"; 3 | key = "shonen_jump_plus"; 4 | version = "1.1.1"; 5 | minAppVersion = "1.2.1"; 6 | url = 7 | "https://git.nyne.dev/nyne/venera-configs/raw/branch/main/shonen_jump_plus.js"; 8 | 9 | deviceId = this.generateDeviceId(); 10 | bearerToken = null; 11 | userAccountId = null; 12 | tokenExpiry = 0; 13 | latestVersion = "4.0.24"; 14 | 15 | get headers() { 16 | return { 17 | "Origin": "https://shonenjumpplus.com", 18 | "Referer": "https://shonenjumpplus.com/", 19 | "X-Giga-Device-Id": this.deviceId, 20 | "User-Agent": `ShonenJumpPlus-Android/${this.latestVersion}`, 21 | }; 22 | } 23 | 24 | apiBase = `https://shonenjumpplus.com/api/v1`; 25 | generateDeviceId() { 26 | let result = ""; 27 | const chars = "0123456789abcdef"; 28 | for (let i = 0; i < 16; i++) { 29 | result += chars[randomInt(0, chars.length - 1)]; 30 | } 31 | return result; 32 | } 33 | 34 | async init() { 35 | const url = "https://apps.apple.com/jp/app/id875750302"; 36 | 37 | const resp = await Network.get(url); 38 | 39 | const match = resp.body.match(/whats-new__latest__version">[^<]*?([\d.]+) { 51 | await this.ensureAuth(); 52 | 53 | const response = await this.graphqlRequest("HomeCacheable", {}); 54 | 55 | if (!response || !response.data || !response.data.homeSections) { 56 | throw "Cannot fetch home sections"; 57 | } 58 | 59 | const sections = response.data.homeSections; 60 | const dailyRankingSection = sections.find((section) => 61 | section.__typename === "DailyRankingSection" 62 | ); 63 | 64 | if (!dailyRankingSection || !dailyRankingSection.dailyRankings) { 65 | throw "Cannot fetch daily ranking data"; 66 | } 67 | 68 | const dailyRanking = dailyRankingSection.dailyRankings.find((ranking) => 69 | ranking.ranking && ranking.ranking.__typename === "DailyRanking" 70 | ); 71 | 72 | if ( 73 | !dailyRanking || !dailyRanking.ranking || 74 | !dailyRanking.ranking.items || !dailyRanking.ranking.items.edges 75 | ) { 76 | throw "Cannot fetch ranking data structure"; 77 | } 78 | 79 | const rankingItems = dailyRanking.ranking.items.edges.map((edge) => 80 | edge.node 81 | ).filter((node) => 82 | node.__typename === "DailyRankingValidItem" && node.product 83 | ); 84 | 85 | function parseComic(item) { 86 | const series = item.product.series; 87 | if (!series) return null; 88 | 89 | const cover = series.squareThumbnailUriTemplate || 90 | series.horizontalThumbnailUriTemplate; 91 | 92 | return { 93 | id: series.databaseId, 94 | title: series.title || "", 95 | cover: cover 96 | ? cover.replace("{height}", "500").replace("{width}", "500") 97 | : "", 98 | tags: [], 99 | description: `Ranking: ${item.rank} · Views: ${ 100 | item.viewCount || "Unknown" 101 | }`, 102 | }; 103 | } 104 | 105 | const comics = rankingItems.map(parseComic).filter((comic) => 106 | comic !== null 107 | ); 108 | 109 | const result = {}; 110 | result["Daily Ranking"] = comics; 111 | return result; 112 | }, 113 | }, 114 | ]; 115 | 116 | search = { 117 | load: async (keyword, _, page) => { 118 | if (!this.bearerToken || Date.now() > this.tokenExpiry) { 119 | await this.fetchBearerToken(); 120 | } 121 | 122 | const operationName = "SearchResult"; 123 | 124 | const response = await this.graphqlRequest(operationName, { 125 | keyword, 126 | }); 127 | const edges = response?.data?.search?.edges || []; 128 | const pageInfo = response?.data?.search?.pageInfo || {}; 129 | 130 | const comics = edges.map(({ node }) => { 131 | const authors = (node.author?.name || "").split(/\s*\/\s*/).filter( 132 | Boolean, 133 | ); 134 | const cover = node.latestIssue?.thumbnailUriTemplate || 135 | node.thumbnailUriTemplate; 136 | if (node.__typename === "Series") { 137 | return new Comic({ 138 | id: node.databaseId, 139 | title: node.title || "", 140 | cover: this.replaceCoverUrl(cover), 141 | description: node.description || "", 142 | tags: authors, 143 | }); 144 | } 145 | if (node.__typename === "MagazineLabel") { 146 | return new Comic({ 147 | id: node.databaseId, 148 | title: node.title || "", 149 | cover: this.replaceCoverUrl(cover), 150 | }); 151 | } 152 | return null; 153 | }).filter(Boolean); 154 | 155 | return { 156 | comics, 157 | maxPage: pageInfo.hasNextPage ? (page || 1) + 1 : (page || 1), 158 | endCursor: pageInfo.endCursor, 159 | }; 160 | }, 161 | }; 162 | 163 | comic = { 164 | loadInfo: async (id) => { 165 | await this.ensureAuth(); 166 | const seriesData = await this.fetchSeriesDetail(id); 167 | const episodes = await this.fetchEpisodes(id); 168 | 169 | const { chapters, latestPublishAt } = episodes.reduce( 170 | (acc, ep) => ({ 171 | chapters: { 172 | ...acc.chapters, 173 | [ep.databaseId]: ep.title || "", 174 | }, 175 | latestPublishAt: 176 | ep.publishedAt && ep.publishedAt > acc.latestPublishAt 177 | ? ep.publishedAt 178 | : acc.latestPublishAt, 179 | }), 180 | { chapters: {}, latestPublishAt: "" }, 181 | ); 182 | 183 | const maxDate = latestPublishAt > seriesData.openAt 184 | ? latestPublishAt 185 | : seriesData.openAt; 186 | const updateDate = new Date(new Date(maxDate) - 60 * 60 * 1000); 187 | const authors = (seriesData.author?.name || "").split(/\s*\/\s*/).filter( 188 | Boolean, 189 | ); 190 | 191 | return new ComicDetails({ 192 | title: seriesData.title || "", 193 | subtitle: authors.join(" / "), 194 | cover: this.replaceCoverUrl(seriesData.thumbnailUriTemplate), 195 | description: seriesData.description || "", 196 | tags: { 197 | "Author": authors, 198 | "Update": [updateDate.toISOString().slice(0, 10)], 199 | }, 200 | url: `https://shonenjumpplus.com/app/episode/${seriesData.publisherId}`, 201 | chapters, 202 | }); 203 | }, 204 | 205 | loadEp: async (comicId, epId) => { 206 | await this.ensureAuth(); 207 | const episodeId = this.normalizeEpisodeId(epId); 208 | const episodeData = await this.fetchEpisodePages(episodeId); 209 | 210 | if (!this.isEpisodeAccessible(episodeData)) { 211 | await this.handleEpisodePurchase(episodeData); 212 | return this.comic.loadEp(comicId, epId); 213 | } 214 | 215 | return this.buildImageUrls(episodeData); 216 | }, 217 | 218 | onImageLoad: (url) => { 219 | const [cleanUrl, token] = url.split("?token="); 220 | return { 221 | url: cleanUrl, 222 | headers: { "X-Giga-Page-Image-Auth": token }, 223 | }; 224 | }, 225 | 226 | onClickTag: (namespace, tag) => { 227 | if (namespace === "Author") { 228 | return { 229 | action: "search", 230 | keyword: `${tag}`, 231 | param: null, 232 | }; 233 | } 234 | throw "Unsupported tag namespace: " + namespace; 235 | }, 236 | }; 237 | 238 | async ensureAuth() { 239 | if (!this.bearerToken || Date.now() > this.tokenExpiry) { 240 | await this.fetchBearerToken(); 241 | } 242 | } 243 | 244 | async graphqlRequest(operationName, variables) { 245 | const payload = { 246 | operationName, 247 | variables, 248 | query: GraphQLQueries[operationName], 249 | }; 250 | const response = await Network.post( 251 | `${this.apiBase}/graphql?opname=${operationName}`, 252 | { 253 | ...this.headers, 254 | "Authorization": `Bearer ${this.bearerToken}`, 255 | "Accept": "application/json", 256 | "X-APOLLO-OPERATION-NAME": operationName, 257 | "Content-Type": "application/json", 258 | }, 259 | JSON.stringify(payload), 260 | ); 261 | 262 | if (response.status !== 200) throw `Invalid status: ${response.status}`; 263 | return JSON.parse(response.body); 264 | } 265 | 266 | normalizeEpisodeId(epId) { 267 | if (typeof epId === "object") return epId.id; 268 | if (typeof epId === "string" && epId.includes("/")) { 269 | return epId.split("/").pop(); 270 | } 271 | return epId; 272 | } 273 | 274 | replaceCoverUrl(url) { 275 | return (url || "").replace("{height}", "1500").replace( 276 | "{width}", 277 | "1500", 278 | ) || ""; 279 | } 280 | 281 | async fetchBearerToken() { 282 | const response = await Network.post( 283 | `${this.apiBase}/user_account/access_token`, 284 | this.headers, 285 | "", 286 | ); 287 | const { access_token, user_account_id } = JSON.parse( 288 | response.body, 289 | ); 290 | this.bearerToken = access_token; 291 | this.userAccountId = user_account_id; 292 | this.tokenExpiry = Date.now() + 3600000; 293 | } 294 | 295 | async fetchSeriesDetail(id) { 296 | const response = await this.graphqlRequest("SeriesDetail", { id }); 297 | return response?.data?.series || {}; 298 | } 299 | 300 | async fetchEpisodes(id) { 301 | const response = await this.graphqlRequest( 302 | "SeriesDetailEpisodeList", 303 | { id, episodeOffset: 0, episodeFirst: 1500, episodeSort: "NUMBER_ASC" }, 304 | ); 305 | const episodes = (response?.data?.series?.episodes?.edges || []).map( 306 | (edge) => edge.node 307 | ); 308 | return episodes; 309 | } 310 | 311 | async fetchEpisodePages(episodeId) { 312 | const response = await this.graphqlRequest( 313 | "EpisodeViewerConditionallyCacheable", 314 | { episodeID: episodeId }, 315 | ); 316 | return response?.data?.episode || {}; 317 | } 318 | 319 | isEpisodeAccessible({ purchaseInfo }) { 320 | return purchaseInfo?.isFree || purchaseInfo?.hasPurchased || 321 | purchaseInfo?.hasRented; 322 | } 323 | 324 | async handleEpisodePurchase(episodeData) { 325 | const { id, purchaseInfo } = episodeData; 326 | const { purchasableViaOnetimeFree, rentable, unitPrice } = purchaseInfo || 327 | {}; 328 | 329 | if (purchasableViaOnetimeFree) await this.consumeOnetimeFree(id); 330 | if (rentable) await this.rentChapter(id, unitPrice); 331 | } 332 | 333 | buildImageUrls({ pageImages, pageImageToken }) { 334 | const validImages = pageImages.edges.flatMap((edge) => edge.node?.src) 335 | .filter(Boolean); 336 | return { 337 | images: validImages.map((url) => `${url}?token=${pageImageToken}`), 338 | }; 339 | } 340 | 341 | async consumeOnetimeFree(episodeId) { 342 | const response = await this.graphqlRequest("ConsumeOnetimeFree", { 343 | input: { id: episodeId }, 344 | }); 345 | return response?.data?.consumeOnetimeFree?.isSuccess; 346 | } 347 | 348 | async rentChapter(episodeId, unitPrice, retryCount = 0) { 349 | if (retryCount > 3) { 350 | throw "Failed to rent chapter after multiple attempts."; 351 | } 352 | const response = await this.graphqlRequest("Rent", { 353 | input: { id: episodeId, unitPrice }, 354 | }); 355 | 356 | if (response.errors?.[0]?.extensions?.code === "FAILED_TO_USE_POINT") { 357 | await this.refreshAccount(); 358 | return this.rentChapter(episodeId, unitPrice, retryCount + 1); 359 | } 360 | 361 | this.userAccountId = response?.data?.rent?.userAccount?.databaseId; 362 | return true; 363 | } 364 | 365 | async refreshAccount() { 366 | this.deviceId = this.generateDeviceId(); 367 | this.bearerToken = this.userAccountId = null; 368 | this.tokenExpiry = 0; 369 | await this.fetchBearerToken(); 370 | await this.addUserDevice(); 371 | } 372 | 373 | async addUserDevice() { 374 | await this.graphqlRequest("AddUserDevice", { 375 | input: { 376 | deviceName: `Android ${21 + Math.floor(Math.random() * 14)}`, 377 | modelName: `Device-${Math.random().toString(36).slice(2, 10)}`, 378 | osName: `Android ${9 + Math.floor(Math.random() * 6)}`, 379 | }, 380 | }); 381 | this.addUserDeviceCalled = true; 382 | } 383 | } 384 | 385 | const GraphQLQueries = { 386 | "SearchResult": `query SearchResult($after: String, $keyword: String!) { 387 | search(after: $after, first: 50, keyword: $keyword, types: [SERIES,MAGAZINE_LABEL]) { 388 | pageInfo { hasNextPage endCursor } 389 | edges { 390 | node { 391 | __typename 392 | ... on Series { id databaseId title thumbnailUriTemplate author { name } description } 393 | ... on MagazineLabel { id databaseId title thumbnailUriTemplate latestIssue { thumbnailUriTemplate } } 394 | } 395 | } 396 | } 397 | }`, 398 | "SeriesDetail": `query SeriesDetail($id: String!) { 399 | series(databaseId: $id) { 400 | id databaseId title thumbnailUriTemplate 401 | author { name } 402 | description 403 | hashtags serialUpdateScheduleLabel 404 | openAt 405 | publisherId 406 | } 407 | }`, 408 | "SeriesDetailEpisodeList": 409 | `query SeriesDetailEpisodeList($id: String!, $episodeOffset: Int, $episodeFirst: Int, $episodeSort: ReadableProductSorting) { 410 | series(databaseId: $id) { 411 | episodes: readableProducts(types: [EPISODE], first: $episodeFirst, offset: $episodeOffset, sort: $episodeSort) { 412 | edges { node { databaseId title publishedAt } } 413 | } 414 | } 415 | }`, 416 | "EpisodeViewerConditionallyCacheable": 417 | `query EpisodeViewerConditionallyCacheable($episodeID: String!) { 418 | episode(databaseId: $episodeID) { 419 | id pageImages { edges { node { src } } } pageImageToken 420 | purchaseInfo { 421 | isFree hasPurchased hasRented 422 | purchasableViaOnetimeFree rentable unitPrice 423 | } 424 | } 425 | }`, 426 | "ConsumeOnetimeFree": 427 | `mutation ConsumeOnetimeFree($input: ConsumeOnetimeFreeInput!) { 428 | consumeOnetimeFree(input: $input) { isSuccess } 429 | }`, 430 | "Rent": `mutation Rent($input: RentInput!) { 431 | rent(input: $input) { 432 | userAccount { databaseId } 433 | } 434 | }`, 435 | "AddUserDevice": `mutation AddUserDevice($input: AddUserDeviceInput!) { 436 | addUserDevice(input: $input) { isSuccess } 437 | }`, 438 | "HomeCacheable": `query HomeCacheable { 439 | homeSections { 440 | __typename 441 | ...DailyRankingSection 442 | } 443 | } 444 | fragment DesignSectionImage on DesignSectionImage { 445 | imageUrl width height 446 | } 447 | fragment SerialInfoIcon on SerialInfo { 448 | isOriginal isIndies 449 | } 450 | fragment DailyRankingSeries on Series { 451 | id databaseId publisherId title 452 | horizontalThumbnailUriTemplate: subThumbnailUri(type: HORIZONTAL_WITH_LOGO) 453 | squareThumbnailUriTemplate: subThumbnailUri(type: SQUARE_WITHOUT_LOGO) 454 | isNewOngoing supportsOnetimeFree 455 | serialInfo { 456 | __typename ...SerialInfoIcon 457 | status isTrial 458 | } 459 | jamEpisodeWorkType 460 | } 461 | fragment DailyRankingItem on DailyRankingItem { 462 | __typename 463 | ... on DailyRankingValidItem { 464 | product { 465 | __typename 466 | ... on Episode { 467 | id databaseId publisherId commentCount 468 | series { 469 | __typename ...DailyRankingSeries 470 | } 471 | } 472 | ... on SpecialContent { 473 | publisherId linkUrl 474 | series { 475 | __typename ...DailyRankingSeries 476 | } 477 | } 478 | } 479 | badge { name label } 480 | label rank viewCount 481 | } 482 | ... on DailyRankingInvalidItem { 483 | publisherWorkId 484 | } 485 | } 486 | fragment DailyRanking on DailyRanking { 487 | date firstPositionSeriesId 488 | items { 489 | edges { 490 | node { 491 | __typename ...DailyRankingItem 492 | } 493 | } 494 | } 495 | } 496 | fragment DailyRankingSection on DailyRankingSection { 497 | title 498 | titleImage { 499 | __typename ...DesignSectionImage 500 | } 501 | dailyRankings { 502 | ranking { 503 | __typename ...DailyRanking 504 | } 505 | } 506 | }`, 507 | }; 508 | -------------------------------------------------------------------------------- /mh1234.js: -------------------------------------------------------------------------------- 1 | class MH1234 extends ComicSource { 2 | // name of the source 3 | name = "漫画1234" 4 | 5 | // unique id of the source 6 | key = "mh1234" 7 | 8 | version = "1.0.0" 9 | 10 | minAppVersion = "1.4.0" 11 | 12 | // update url 13 | url = "https://git.nyne.dev/nyne/venera-configs/raw/branch/main/mh1234.js" 14 | 15 | settings = { 16 | domains: { 17 | title: "域名", 18 | type: "input", 19 | default: "amh1234.com" 20 | } 21 | } 22 | 23 | get baseUrl() { 24 | return `https://b.${this.loadSetting('domains')}`; 25 | } 26 | 27 | // explore page list 28 | explore = [{ 29 | title: "漫画1234", 30 | type: "singlePageWithMultiPart", 31 | load: async () => { 32 | const result = {}; 33 | const res = await Network.get(this.baseUrl); 34 | if (res.status !== 200) { 35 | throw `Invalid status code: ${res.status}`; 36 | } 37 | const doc = new HtmlDocument(res.body); 38 | const mangaLists = doc.querySelectorAll("div.imgBox"); 39 | for (let list of mangaLists) { 40 | const tabTitle = list.querySelector(".Title").text; 41 | const items = []; 42 | for (let item of list.querySelectorAll("li.list-comic")) { 43 | const info = item.querySelectorAll("a")[1]; 44 | items.push(new Comic({ 45 | id: item.attributes["data-key"], 46 | title: item.querySelector("a.txtA").text, 47 | cover: item.querySelector("img").attributes["src"] 48 | })); 49 | } 50 | result[tabTitle] = items; 51 | } 52 | return result; 53 | } 54 | }]; 55 | 56 | // categories 57 | category = { 58 | /// title of the category page, used to identify the page, it should be unique 59 | title: "漫画1234", 60 | parts: [ 61 | { 62 | name: "题材", 63 | type: "fixed", 64 | categories: [ 65 | "全部", "少年热血", "武侠格斗", "科幻魔幻", "竞技体育", "爆笑喜剧", "侦探推理", "恐怖灵异", "耽美人生", 66 | "少女爱情", "恋爱生活", "生活漫画", "战争漫画", "故事漫画", "其他漫画", "爱情", "唯美", "武侠", "玄幻", 67 | "后宫", "治愈", "励志", "古风", "校园", "虐心", "魔幻", "冒险", "欢乐向", "节操", "悬疑", "历史", "职场", 68 | "神鬼", "明星", "穿越", "百合", "西方魔幻", "纯爱", "音乐舞蹈", "轻小说", "侦探", "伪娘", "仙侠", "四格", 69 | "剧情", "萌系", "东方", "性转换", "宅系", "美食", "脑洞", "惊险", "爆笑", "都市", "蔷薇", "恋爱", "格斗", 70 | "科幻", "魔法", "奇幻", "热血", "其他", "搞笑", "生活", "恐怖", "架空", "竞技", "战争", "搞笑喜剧", "青春", 71 | "浪漫", "爽流", "神话", "轻松", "日常", "家庭", "婚姻", "动作", "战斗", "异能", "内涵", "同人", "惊奇", 72 | "正剧", "推理", "宠物", "温馨", "异世界", "颜艺", "惊悚", "舰娘","机战", "彩虹", "耽美", "轻松搞笑", 73 | "修真恋爱架空", "复仇", "霸总", "段子", "逆袭", "烧脑", "娱乐圈", "纠结", "感动", "豪门", "体育", "机甲", 74 | "末世", "灵异", "僵尸", "宫廷", "权谋", "未来", "科技", "商战", "乡村", "震撼", "游戏", "重口味", "血腥", 75 | "逗比", "丧尸", "神魔", "修真", "社会", "召唤兽", "装逼", "新作", "漫改", "真人", "运动", "高智商", "悬疑推理", 76 | "机智", "史诗", "萝莉", "宫斗", "御姐", "恶搞", "精品", "日更", "小说改编", "防疫", "吸血", "暗黑", "总裁", 77 | "重生", "大女主", "系统", "神仙", "末日", "怪物", "妖怪", "修仙", "宅斗", "神豪", "高甜", "电竞", "豪快", 78 | "猎奇", "多世界", "性转", "少女", "改编", "女生", "乙女", "男生", "兄弟情", "智斗", "少男", "连载", "奇幻冒险", 79 | "古风穿越", "浪漫爱情", "古装", "幽默搞笑", "偶像", "小僵尸", "BL", "少年", "橘味", "情感", "经典", 80 | "腹黑", "都市大女主", "致郁", "美少女", "少儿", "暖萌", "长条", "限制级", "知音漫客", "氪金", "独家", 81 | "亲情", "现代", "武侠仙侠", "西幻", "超级英雄", "女神", "幻想", "欧风", "养成", "动作冒险", "GL", "橘调", 82 | "悬疑灵异", "古代宫廷", "欧式宫廷", "游戏竞技", "橘系", "奇幻爱情", "架空世界", "ゆり", "福瑞", "秀吉", "现代言情", 83 | "古代言情", "豪门总裁", "现言萌宝", "玄幻言情", "虐渣", "团宠", "古言萌宝", "现言甜宠", "古言脑洞", "AA", "金手指", 84 | "玄幻脑洞", "都市脑洞", "甜宠", "伦理", "生存", "TL", "悬疑脑洞", "黑暗", "独特", "成长", "幻想言情", "直播", 85 | "游戏体育", "现言脑洞", "音乐", "双男主", "迪化", "LGBTQ+", "正能量", "军事", "ABO", "悬疑恐怖", 86 | "玄幻科幻", "投稿", "种田", "经营", "反套路", "无节操", "强强", "克苏鲁", "无敌流", "冒险热血", "畅销", 87 | "大人系", "宅向", "萌娃", "宠兽", "异形", "撒糖", "诡异", "言情", "西方", "滑稽搞笑", "同居", "人外", 88 | "白切黑", "并肩作战", "救赎", "戏精", "美强惨", "非人类", "原创", "黑白漫", "无限流", 89 | "升级", "爽", "轻橘", "女帝", "偏执", "自由", "星际", "可盐可甜", "反差萌", "聪颖", "智商在线", 90 | "倔强", "狼人", "欢喜冤家", "吸血鬼", "萌宠", "学校", "台湾作品", "彩色", "武术", "短篇", "契约", "魔王", 91 | "无敌", "美女", "暧昧", "网游", "宅男", "追逐梦想", "冒险奇幻", "疯批", "中二", "召唤", "法宝", "钓系", "鬼怪", 92 | "占有欲", "阳光", "元气", "强制爱", "黑道", "马甲", "阴郁", "忧郁", "哲理", "病娇", "喜剧", "江湖恩怨", 93 | "相爱相杀", "萌", "SM", "精选", "生子", "年下", "18+限制", "日久生情", "梦想", "多攻", "竹马", "骨科", "gnbq" 94 | ], 95 | itemType: "category", 96 | categoryParams: [ 97 | "", "shaonianrexue", "wuxiagedou", "kehuanmohuan", "jingjitiyu", "baoxiaoxiju", "zhentantuili", "kongbulingyi", 98 | "danmeirensheng", "shaonvaiqing", "lianaishenghuo", "shenghuomanhua", "zhanzhengmanhua", "gushimanhua", 99 | "qitamanhua", "aiqing", "weimei", "wuxia", "xuanhuan", "hougong", "zhiyu", "lizhi", "gufeng", "xiaoyuan", "nuexin", 100 | "mohuan", "maoxian", "huanlexiang", "jiecao", "xuanyi", "lishi", "zhichang", "shengui", "mingxing", "chuanyue", 101 | "baihe", "xifangmohuan", "chunai", "yinyuewudao", "qingxiaoshuo", "zhentan", "weiniang", "xianxia", "sige", "juqing", 102 | "mengxi", "dongfang", "xingzhuanhuan", "zhaixi", "meishi", "naodong", "jingxian", "baoxiao", "dushi", "qiangwei", 103 | "lianai", "gedou", "kehuan", "mofa", "qihuan", "rexue", "qita", "gaoxiao", "shenghuo", "kongbu", "jiakong", "jingji", 104 | "zhanzheng", "gaoxiaoxiju", "qingchun", "langman", "shuangliu", "shenhua", "qingsong", "richang", "jiating", "hunyin", 105 | "dongzuo", "zhandou", "yineng", "neihan", "tongren", "jingqi", "zhengju", "tuili", "chongwu", "wenxin", "yishijie", 106 | "yanyi", "jingsong", "jianniang", "jizhan", "caihong", "danmei", "qingsonggaoxiao", "xiuzhenlianaijiakong", "fuchou", 107 | "bazong", "duanzi", "nixi", "shaonao", "yulequan", "jiujie", "gandong", "haomen", "tiyu", "jijia", "moshi", "lingyi", 108 | "jiangshi", "gongting", "quanmou", "weilai", "keji", "shangzhan", "xiangcun", "zhenhan", "youxi", 109 | "zhongkouwei", "xuexing", "doubi", "sangshi", "shenmo", "xiuzhen", "shehui", "zhaohuanshou", "zhuangbi", 110 | "xinzuo", "mangai", "zhenren", "yundong", "gaozhishang", "xuanyituili", "jizhi", "shishi", "luoli","gongdou", 111 | "yujie", "egao", "jingpin", "rigeng", "xiaoshuogaibian", "fangyi", "xixie", "anhei", "zongcai", "zhongsheng", 112 | "danvzhu", "xitong", "shenxian", "mori", "guaiwu", "yaoguai", "xiuxian", "zhaidou", "shenhao", "gaotian", 113 | "dianjing", "haokuai", "lieqi", "duoshijie", "xingzhuan", "shaonv", "gaibian", "nvsheng", "yinv", "nansheng", 114 | "xiongdiqing", "zhidou", "shaonan", "lianzai", "qihuanmaoxian", "gufengchuanyue", "langmanaiqing", "guzhuang", 115 | "youmogaoxiao", "ouxiang", "xiaojiangshi", "BL", "shaonian", "juwei", "qinggan", "jingdian", 116 | "fuhei", "dushidanvzhu", "zhiyu2", "meishaonv", "shaoer", "nuanmeng", "changtiao", "xianzhiji", "zhiyinmanke", 117 | "kejin", "dujia", "qinqing", "xiandai", "wuxiaxianxia", "xihuan", "chaojiyingxiong", "nvshen", "huanxiang", 118 | "oufeng", "yangcheng", "dongzuomaoxian", "GL", "judiao", "xuanyilingyi", "gudaigongting", "oushigongting", 119 | "youxijingji", "juxi", "qihuanaiqing", "jiakongshijie", "unknown", "furui", "xiuji", "xiandaiyanqing", "gudaiyanqing", 120 | "haomenzongcai", "xianyanmengbao", "xuanhuanyanqing", "nuezha", "tuanchong", "guyanmengbao", "xianyantianchong", 121 | "guyannaodong", "AA", "jinshouzhi", "xuanhuannaodong", "dushinaodong", "tianchong", "lunli", "shengcun", "TL", 122 | "xuanyinaodong", "heian", "dute", "chengzhang", "huanxiangyanqing", "zhibo", "youxitiyu", "xianyannaodong", 123 | "yinyue", "shuangnanzhu", "dihua", "LGBTQ", "zhengnengliang", "junshi", "ABO", "xuanyikongbu", "xuanhuankehuan", "tougao", 124 | "zhongtian", "jingying", "fantaolu", "wujiecao", "qiangqiang", "kesulu", "wudiliu", "maoxianrexue", "changxiao", 125 | "darenxi", "zhaixiang", "mengwa", "chongshou", "yixing", "satang", "guiyi", "yanqing", "xifang", "huajigaoxiao", "tongju", 126 | "renwai", "baiqiehei", "bingjianzuozhan", "jiushu", "xijing", "meiqiangcan", "feirenlei", "yuanchuang", "heibaiman", 127 | "wuxianliu", "shengji", "shuang", "qingju", "nvdi", "pianzhi", "ziyou", "xingji", "keyanketian", "fanchameng", "congying", 128 | "zhishangzaixian", "juejiang", "langren", "huanxiyuanjia", "xixiegui", "mengchong", "xuexiao", "taiwanzuopin", "caise", 129 | "wushu", "duanpian", "qiyue", "mowang", "wudi", "meinv", "aimei", "wangyou", "zhainan", "zhuizhumengxiang", "maoxianqihuan", 130 | "fengpi", "zhonger", "zhaohuan", "fabao", "diaoxi", "guiguai", "zhanyouyu", "yangguang", "yuanqi", "qiangzhiai", "heidao", 131 | "majia", "yinyu", "youyu", "zheli", "bingjiao", "xiju", "jianghuenyuan", "xiangaixiangsha", "meng", "SM", "jingxuan", "shengzi", 132 | "nianxia", "18xianzhi", "rijiushengqing", "mengxiang", "duogong", "zhuma", "guke", "gnbq" 133 | ], 134 | } 135 | ], 136 | // enable ranking page 137 | enableRankingPage: false, 138 | } 139 | 140 | parseComics(html, onePage = false) { 141 | const doc = new HtmlDocument(html); 142 | const comics = []; 143 | for (let comic of doc.querySelectorAll(".itemBox")) { 144 | comics.push(new Comic({ 145 | id: comic.attributes["data-key"], 146 | title: comic.querySelector(".title").text, 147 | cover: comic.querySelector("img").attributes["src"] 148 | })); 149 | } 150 | return {comics: comics, maxPage: onePage ? 1 : parseInt(doc.querySelector("#total-page").attributes["value"])}; 151 | } 152 | 153 | parseList(doc) { 154 | const comics = []; 155 | for (let comic of doc.querySelectorAll(".list-comic")) { 156 | comics.push(new Comic({ 157 | id: comic.attributes["data-key"], 158 | title: comic.querySelector(".txtA").text, 159 | cover: comic.querySelector("img").attributes["src"] 160 | })); 161 | } 162 | return comics; 163 | } 164 | 165 | /// category comic loading related 166 | categoryComics = { 167 | load: async (category, params, options, page) => { 168 | if (params.endsWith(".html")) { 169 | const res = await Network.get(`${this.baseUrl}${params}`); 170 | if (res.status !== 200) { 171 | throw `Invalid status code: ${res.status}`; 172 | } 173 | return this.parseComics(res.body, true); 174 | } else { 175 | const res = await Network.get(`${this.baseUrl}/list/?filter=${params}-${options[0]}-${options[1]}-${options[2]}&sort=${options[3]}&page=${page}`); 176 | console.warn(`${this.baseUrl}/list/?filter=${params}-${options[0]}-${options[1]}-${options[2]}&sort=${options[3]}&page=${page}`) 177 | if (res.status !== 200) { 178 | throw `Invalid status code: ${res.status}`; 179 | } 180 | const doc = new HtmlDocument(res.body); 181 | return {comics: this.parseList(doc), 182 | maxPage: parseInt(doc.querySelector("#total-page").attributes["value"])}; 183 | } 184 | }, 185 | optionLoader: async (category, params) => { 186 | if (!params.endsWith(".html")) { 187 | return [ 188 | { 189 | options: [ 190 | "-全部", 191 | "ertong-儿童漫画", 192 | "shaonian-少年漫画", 193 | "shaonv-少女漫画", 194 | "qingnian-青年漫画", 195 | "bailingmanhua-白领漫画", 196 | "tongrenmanhua-同人漫画" 197 | ] 198 | }, 199 | { 200 | options: [ 201 | "-全部", 202 | "wanjie-已完结", 203 | "lianzai-连载中", 204 | ] 205 | }, 206 | { 207 | options: [ 208 | "-全部", 209 | "rhmh-日韩", 210 | "dlmh-大陆", 211 | "gtmh-港台", 212 | "taiwan-台湾", 213 | "ommh-欧美", 214 | "hanguo-韩国", 215 | "qtmg-其他", 216 | ] 217 | }, 218 | { 219 | options: [ 220 | "update-更新", 221 | "post-发布", 222 | "click-点击", 223 | ] 224 | }, 225 | ]; 226 | } 227 | return []; 228 | } 229 | } 230 | 231 | /// search related 232 | search = { 233 | load: async (keyword, options, page) => { 234 | const res = await Network.get(`${this.baseUrl}/search/?keywords=${keyword}&sort=${options[0]}&page=${page}`); 235 | if (res.status !== 200) { 236 | throw `Invalid status code: ${res.status}`; 237 | } 238 | return this.parseComics(res.body); 239 | }, 240 | 241 | // provide options for search 242 | optionList: [ 243 | { 244 | options: [ 245 | "update-更新", 246 | "post-发布", 247 | "click-点击", 248 | ], 249 | label: "排序" 250 | } 251 | ], 252 | 253 | // enable tags suggestions 254 | enableTagsSuggestions: false, 255 | } 256 | 257 | /// single comic related 258 | comic = { 259 | loadInfo: async (id) => { 260 | const res = await Network.get(`${this.baseUrl}/comic/${id}.html`); 261 | if (res.status !== 200) { 262 | throw `Invalid status code: ${res.status}`; 263 | } 264 | const doc = new HtmlDocument(res.body); 265 | const title = doc.querySelector(".BarTit").text; 266 | const cover = doc.querySelector(".pic").querySelector("img").attributes["src"]; 267 | const description = doc.querySelector("#full-des")?.text; 268 | const infos = doc.querySelectorAll(".txtItme"); 269 | const tags = []; 270 | for (let tag of doc.querySelector(".sub_r").querySelectorAll("a")) { 271 | const tag_name = tag.text; 272 | if (tag_name.length > 0) { 273 | tags.push(tag_name); 274 | } 275 | } 276 | const chapters = {}; 277 | const chapterElements = doc.querySelector(".chapter-warp")?.querySelectorAll("li"); 278 | if (chapterElements) { 279 | for (let ch of chapterElements) { 280 | const id = ch.querySelector("a").attributes["href"].replace("/comic/", "").replace(".html", "").split("/").join("_"); 281 | chapters[id] = ch.querySelector("span").text; 282 | } 283 | } 284 | return { 285 | title: title, 286 | cover: cover, 287 | description: description, 288 | tags: { 289 | "作者": [infos[0].text.replaceAll("\n", "").replaceAll("\r", "").trim()], 290 | "更新": [infos[3].querySelector(".date").text], 291 | "标签": tags.slice(0,-1) 292 | }, 293 | chapters: chapters, 294 | recommend: this.parseList(doc) 295 | }; 296 | 297 | }, 298 | 299 | loadEp: async (comicId, epId) => { 300 | const ids = epId.split("_"); 301 | const res = await Network.get(`${this.baseUrl}/comic/${ids[0]}/${ids[1]}.html`); 302 | if (res.status !== 200) { 303 | throw `Invalid status code: ${res.status}`; 304 | } 305 | const html = res.body; 306 | const start = html.search(`var chapterImages = `) + 22; 307 | const end = html.search(`;var chapterPath = `) - 2; 308 | const end2 = html.search(`;var chapterPrice`) - 1; 309 | const images = html.substring(start, end).split(`","`); 310 | const cpath = html.substring(end + 22, end2); 311 | for (let i = 0; i < images.length; i++) { 312 | images[i] = "https://gmh1234.wszwhg.net/" + cpath + images[i].replaceAll("\\", ""); 313 | images[i] = images[i].replaceAll("//", "/"); 314 | } 315 | return { images }; 316 | }, 317 | 318 | // enable tags translate 319 | enableTagsTranslate: false, 320 | } 321 | } -------------------------------------------------------------------------------- /ikmmh.js: -------------------------------------------------------------------------------- 1 | /** @type {import('./_venera_.js')} */ 2 | 3 | function getValidatorCookie(htmlString) { 4 | // 正则表达式匹配 document.cookie 设置语句 5 | const cookieRegex = /document\.cookie\s*=\s*"([^"]+)"/; 6 | const match = htmlString.match(cookieRegex); 7 | 8 | if (!match) { 9 | return null; // 没有找到 cookie 设置语句 10 | } 11 | 12 | const cookieSetting = match[1]; 13 | const cookies = cookieSetting.split(';'); 14 | if (cookies.length === 0) { 15 | return null 16 | } 17 | const nameValuePart = cookies[0].trim(); 18 | const equalsIndex = nameValuePart.indexOf('='); 19 | 20 | const name = nameValuePart.substring(0, equalsIndex); 21 | const value = nameValuePart.substring(equalsIndex + 1); 22 | 23 | return new Cookie({ name, value, domain: "www.ikmmh.com" }) 24 | } 25 | 26 | function needPassValidator(htmlString) { 27 | var cookie = getValidatorCookie(htmlString) 28 | if (cookie != null) { 29 | Network.setCookies(Ikm.baseUrl, [cookie]) 30 | return true 31 | } 32 | return false 33 | } 34 | 35 | class Ikm extends ComicSource { 36 | // 基础配置 37 | name = "爱看漫"; 38 | key = "ikmmh"; 39 | version = "1.0.5"; 40 | minAppVersion = "1.0.0"; 41 | url = "https://git.nyne.dev/nyne/venera-configs/raw/branch/main/ikmmh.js"; 42 | // 常量定义 43 | static baseUrl = "https://www.ikmmh.com"; 44 | static Mobile_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1 Edg/140.0.0.0"; 45 | static webHeaders = { 46 | "User-Agent": Ikm.Mobile_UA, 47 | "Accept": 48 | "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", 49 | }; 50 | static jsonHead = { 51 | "User-Agent": Ikm.Mobile_UA, 52 | "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", 53 | "Accept": "application/json, text/javascript, */*; q=0.01", 54 | "Accept-Encoding": "gzip", 55 | "X-Requested-With": "XMLHttpRequest", 56 | }; 57 | // 统一缩略图加载配置 58 | static thumbConfig = (url) => ({ 59 | headers: { 60 | ...Ikm.webHeaders, 61 | "referer": Ikm.baseUrl, 62 | }, 63 | }); 64 | // 账号系统 65 | account = { 66 | login: async (account, pwd) => { 67 | try { 68 | let res = await Network.post( 69 | `${Ikm.baseUrl}/api/user/userarr/login`, 70 | Ikm.jsonHead, 71 | `user=${account}&pass=${pwd}` 72 | ); 73 | if (res.status !== 200) 74 | throw new Error(`登录失败,状态码:${res.status}`); 75 | 76 | if (needPassValidator(res.body)) { 77 | // rePost 78 | res = await Network.post( 79 | `${Ikm.baseUrl}/api/user/userarr/login`, 80 | Ikm.jsonHead, 81 | `user=${account}&pass=${pwd}` 82 | ); 83 | } 84 | 85 | let data = JSON.parse(res.body); 86 | if (data.code !== 0) 87 | throw new Error(data.msg || "登录异常"); 88 | 89 | return "ok"; 90 | } catch (err) { 91 | throw new Error(`登录失败:${err.message}`); 92 | } 93 | }, 94 | logout: () => Network.deleteCookies("www.ikmmh.com"), 95 | registerWebsite: `${Ikm.baseUrl}/user/register/`, 96 | }; 97 | // 探索页面 98 | explore = [ 99 | { 100 | title: this.name, 101 | type: "singlePageWithMultiPart", 102 | load: async () => { 103 | try { 104 | let res = await Network.get(`${Ikm.baseUrl}/`, Ikm.webHeaders); 105 | if (res.status !== 200) 106 | throw new Error(`加载探索页面失败,状态码:${res.status}`); 107 | 108 | if (needPassValidator(res.body)) { 109 | // rePost 110 | res = await Network.get(`${Ikm.baseUrl}/`, Ikm.webHeaders); 111 | } 112 | 113 | let document = new HtmlDocument(res.body); 114 | let parseComic = (e) => { 115 | let title = e.querySelector("div.title").text.split("~")[0]; 116 | let cover = e.querySelector("div.thumb_img").attributes["data-src"]; 117 | let link = `${Ikm.baseUrl}${ 118 | e.querySelector("a").attributes["href"] 119 | }`; 120 | return { 121 | title, 122 | cover, 123 | id: link, 124 | }; 125 | }; 126 | return { 127 | "本周推荐": document 128 | .querySelectorAll("div.module-good-fir > div.item") 129 | .map(parseComic), 130 | "今日更新": document 131 | .querySelectorAll("div.module-day-fir > div.item") 132 | .map(parseComic), 133 | }; 134 | } catch (err) { 135 | throw new Error(`探索页面加载失败:${err.message}`); 136 | } 137 | }, 138 | onThumbnailLoad: Ikm.thumbConfig, 139 | }, 140 | ]; 141 | // 分类页面 142 | category = { 143 | title: "爱看漫", 144 | parts: [ 145 | { 146 | name: "更新", 147 | type: "fixed", 148 | categories: [ 149 | "星期一", 150 | "星期二", 151 | "星期三", 152 | "星期四", 153 | "星期五", 154 | "星期六", 155 | "星期日", 156 | ], 157 | itemType: "category", 158 | categoryParams: ["1", "2", "3", "4", "5", "6", "7"], 159 | }, 160 | { 161 | name: "分类", 162 | // fixed 或者 random 163 | // random用于分类数量相当多时, 随机显示其中一部分 164 | type: "fixed", 165 | // 如果类型为random, 需要提供此字段, 表示同时显示的数量 166 | // randomNumber: 5, 167 | categories: [ 168 | "全部", 169 | "长条", 170 | "大女主", 171 | "百合", 172 | "耽美", 173 | "纯爱", 174 | "後宫", 175 | "韩漫", 176 | "奇幻", 177 | "轻小说", 178 | "生活", 179 | "悬疑", 180 | "格斗", 181 | "搞笑", 182 | "伪娘", 183 | "竞技", 184 | "职场", 185 | "萌系", 186 | "冒险", 187 | "治愈", 188 | "都市", 189 | "霸总", 190 | "神鬼", 191 | "侦探", 192 | "爱情", 193 | "古风", 194 | "欢乐向", 195 | "科幻", 196 | "穿越", 197 | "性转换", 198 | "校园", 199 | "美食", 200 | "悬疑", 201 | "剧情", 202 | "热血", 203 | "节操", 204 | "励志", 205 | "异世界", 206 | "历史", 207 | "战争", 208 | "恐怖", 209 | "霸总" 210 | ], 211 | // category或者search 212 | // 如果为category, 点击后将进入分类漫画页面, 使用下方的`categoryComics`加载漫画 213 | // 如果为search, 将进入搜索页面 214 | itemType: "category", 215 | } 216 | ], 217 | enableRankingPage: false, 218 | }; 219 | // 分类漫画加载 220 | categoryComics = { 221 | load: async (category, param, options, page) => { 222 | try { 223 | let res; 224 | if (param) { 225 | res = await Network.get( 226 | `${Ikm.baseUrl}/update/${param}.html`, 227 | Ikm.webHeaders 228 | ); 229 | if (res.status !== 200) 230 | throw new Error(`分类请求失败,状态码:${res.status}`); 231 | 232 | if (needPassValidator(res.body)) { 233 | // rePost 234 | res = await Network.get( 235 | `${Ikm.baseUrl}/update/${param}.html`, 236 | Ikm.webHeaders 237 | ); 238 | } 239 | 240 | let document = new HtmlDocument(res.body); 241 | let comics = document.querySelectorAll("li.comic-item").map((e) => ({ 242 | title: e.querySelector("p.title").text.split("~")[0], 243 | cover: e.querySelector("img").attributes["src"], 244 | id: `${Ikm.baseUrl}${e.querySelector("a").attributes["href"]}`, 245 | subTitle: e.querySelector("span.chapter").text, 246 | })); 247 | return { 248 | comics, 249 | maxPage: 1 250 | }; 251 | } else { 252 | res = await Network.post( 253 | `${Ikm.baseUrl}/api/comic/index/lists`, 254 | Ikm.jsonHead, 255 | `area=${options[1]}&tags=${encodeURIComponent(category)}&full=${ 256 | options[0] 257 | }&page=${page}` 258 | ); 259 | 260 | if (needPassValidator(res.body)) { 261 | // rePost 262 | res = await Network.post( 263 | `${Ikm.baseUrl}/api/comic/index/lists`, 264 | Ikm.jsonHead, 265 | `area=${options[1]}&tags=${encodeURIComponent(category)}&full=${options[0] 266 | }&page=${page}` 267 | ); 268 | } 269 | 270 | let resData = JSON.parse(res.body); 271 | return { 272 | comics: resData.data.map((e) => ({ 273 | id: `${Ikm.baseUrl}${e.info_url}`, 274 | title: e.name.split("~")[0], 275 | subTitle: e.author, 276 | cover: e.cover, 277 | tags: e.tags, 278 | description: e.lastchapter, 279 | })), 280 | maxPage: resData.end || 1, 281 | }; 282 | } 283 | } catch (err) { 284 | throw new Error(`分类加载失败:${err.message}`); 285 | } 286 | }, 287 | onThumbnailLoad: Ikm.thumbConfig, 288 | optionList: [ 289 | { 290 | // 对于单个选项, 使用-分割, 左侧为用于数据加载的值, 即传给load函数的options参数; 右侧为显示给用户的文本 291 | 292 | options: ["3-全部", "4-连载中", "1-已完结"], 293 | notShowWhen: [ 294 | "星期一", 295 | "星期二", 296 | "星期三", 297 | "星期四", 298 | "星期五", 299 | "星期六", 300 | "星期日", 301 | ], 302 | showWhen: null, 303 | }, 304 | { 305 | options: [ 306 | "9-全部", 307 | "1-日漫", 308 | "2-港台", 309 | "3-美漫", 310 | "4-国漫", 311 | "5-韩漫", 312 | "6-未分类", 313 | ], 314 | notShowWhen: [ 315 | "星期一", 316 | "星期二", 317 | "星期三", 318 | "星期四", 319 | "星期五", 320 | "星期六", 321 | "星期日", 322 | ], 323 | showWhen: null, 324 | }, 325 | ], 326 | }; 327 | // 搜索功能 328 | search = { 329 | load: async (keyword, options, page) => { 330 | try { 331 | let res = await Network.get( 332 | `${Ikm.baseUrl}/search?searchkey=${encodeURIComponent(keyword)}`, 333 | Ikm.webHeaders 334 | ); 335 | 336 | if (needPassValidator(res.body)) { 337 | // rePost 338 | res = await Network.get( 339 | `${Ikm.baseUrl}/search?searchkey=${encodeURIComponent(keyword)}`, 340 | Ikm.webHeaders 341 | ); 342 | } 343 | 344 | let document = new HtmlDocument(res.body); 345 | return { 346 | comics: document.querySelectorAll("li.comic-item").map((e) => ({ 347 | title: e.querySelector("p.title").text.split("~")[0], 348 | cover: e.querySelector("img").attributes["src"], 349 | id: `${Ikm.baseUrl}${e.querySelector("a").attributes["href"]}`, 350 | subTitle: e.querySelector("span.chapter").text, 351 | })), 352 | maxPage: 1, 353 | }; 354 | } catch (err) { 355 | throw new Error(`搜索失败:${err.message}`); 356 | } 357 | }, 358 | onThumbnailLoad: Ikm.thumbConfig, 359 | optionList: [], 360 | }; 361 | // 收藏功能 362 | favorites = { 363 | multiFolder: false, 364 | addOrDelFavorite: async (comicId, folderId, isAdding) => { 365 | try { 366 | let id = comicId.match(/\d+/)[0]; 367 | if (isAdding) { 368 | // 获取漫画信息 369 | let infoRes = await Network.get(comicId, Ikm.webHeaders); 370 | 371 | if (needPassValidator(infoRes.body)) { 372 | // rePost 373 | infoRes = await Network.get(comicId, Ikm.webHeaders); 374 | } 375 | 376 | let name = new HtmlDocument(infoRes.body).querySelector( 377 | "meta[property='og:title']" 378 | ).attributes["content"]; 379 | // 添加收藏 380 | let res = await Network.post( 381 | `${Ikm.baseUrl}/api/user/bookcase/add`, 382 | Ikm.jsonHead, 383 | `articleid=${id}&articlename=${encodeURIComponent(name)}` 384 | ); 385 | let data = JSON.parse(res.body); 386 | if (data.code !== "0") throw new Error(data.msg || "收藏失败"); 387 | return "ok"; 388 | } else { 389 | // 删除收藏 390 | let res = await Network.post( 391 | `${Ikm.baseUrl}/api/user/bookcase/del`, 392 | Ikm.jsonHead, 393 | `articleid=${id}` 394 | ); 395 | 396 | if (needPassValidator(res.body)) { 397 | // rePost 398 | res = await Network.post( 399 | `${Ikm.baseUrl}/api/user/bookcase/del`, 400 | Ikm.jsonHead, 401 | `articleid=${id}` 402 | ); 403 | } 404 | 405 | let data = JSON.parse(res.body); 406 | if (data.code !== "0") throw new Error(data.msg || "取消收藏失败"); 407 | return "ok"; 408 | } 409 | } catch (err) { 410 | throw new Error(`收藏操作失败:${err.message}`); 411 | } 412 | }, 413 | //加载收藏 414 | loadComics: async (page, folder) => { 415 | let res = await Network.get( 416 | `${Ikm.baseUrl}/user/bookcase`, 417 | Ikm.webHeaders 418 | ); 419 | if (res.status !== 200) { 420 | throw "加载收藏失败:" + res.status; 421 | } 422 | 423 | if (needPassValidator(res.body)) { 424 | // rePost 425 | res = await Network.get( 426 | `${Ikm.baseUrl}/user/bookcase`, 427 | Ikm.webHeaders 428 | ); 429 | } 430 | 431 | let document = new HtmlDocument(res.body); 432 | return { 433 | comics: document.querySelectorAll("div.bookrack-item").map((e) => ({ 434 | title: e.querySelector("h3").text.split("~")[0], 435 | subTitle: e.querySelector("p.desc").text, 436 | cover: e.querySelector("img").attributes["src"], 437 | id: `${Ikm.baseUrl}/book/${e.attributes["data-id"]}/`, 438 | })), 439 | maxPage: 1, 440 | }; 441 | }, 442 | onThumbnailLoad: Ikm.thumbConfig, 443 | }; 444 | // 漫画详情 445 | comic = { 446 | loadInfo: async (id) => { 447 | // 加载收藏页并判断是否收藏 448 | let isFavorite = false; 449 | try { 450 | let favorites = await this.favorites.loadComics(1, null); 451 | isFavorite = favorites.comics.some((comic) => comic.id === id); 452 | } catch (error) { 453 | console.error("加载收藏页失败:", error); 454 | } 455 | let res = await Network.get(id, Ikm.webHeaders); 456 | 457 | if (needPassValidator(res.body)) { 458 | // rePost 459 | res = await Network.get(id, Ikm.webHeaders); 460 | } 461 | 462 | let document = new HtmlDocument(res.body); 463 | let comicId = id.match(/\d+/)[0]; 464 | // 获取章节数据 465 | let epRes = await Network.get( 466 | `${Ikm.baseUrl}/api/comic/zyz/chapterlink?id=${comicId}`, 467 | { 468 | ...Ikm.jsonHead, 469 | "referer": id, 470 | } 471 | ); 472 | let epData = JSON.parse(epRes.body); 473 | let eps = new Map(); 474 | if (epData.data && epData.data.length > 0 && epData.data[0].list) { 475 | epData.data[0].list.forEach((e) => { 476 | let title = e.name; 477 | let id = `${Ikm.baseUrl}${e.url}`; 478 | eps.set(id, title); 479 | }); 480 | } else { 481 | throw new Error(`章节数据格式异常`); 482 | } 483 | 484 | let title = document.querySelector( 485 | "div.book-hero__detail > div.title" 486 | ).text; 487 | let escapedTitle = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 488 | let thumb = 489 | document 490 | .querySelector("div.coverimg") 491 | .attributes["style"].match(/\((.*?)\)/)?.[1] || ""; 492 | let desc = document 493 | .querySelector("article.book-container__detail") 494 | .text.match( 495 | new RegExp( 496 | `漫画名:${escapedTitle}(?:(?:[^。]*?(?:简介|漫画简介)\\s*[::]?\\s*)|(?:[^。]*?))([\\s\\S]+?)\\.\\.\\.。` 497 | ) 498 | ); 499 | let intro = desc?.[1]?.trim().replace(/\s+/g, " ") || ""; 500 | 501 | return { 502 | title: title.split("~")[0], 503 | cover: thumb, 504 | description: intro, 505 | tags: { 506 | "作者": [ 507 | document 508 | .querySelector("div.book-container__author") 509 | .text.split("作者:")[1], 510 | ], 511 | "更新": [document.querySelector("div.update > a > em").text], 512 | "标签": document 513 | .querySelectorAll("div.book-hero__detail > div.tags > a") 514 | .map((e) => e.text.trim()) 515 | .filter((text) => text), 516 | }, 517 | chapters: eps, 518 | recommend: document 519 | .querySelectorAll("div.module-guessu > div.item") 520 | .map((e) => ({ 521 | title: e.querySelector("div.title").text.split("~")[0], 522 | cover: e.querySelector("div.thumb_img").attributes["data-src"], 523 | id: `${Ikm.baseUrl}${e.querySelector("a").attributes["href"]}`, 524 | })), 525 | isFavorite: isFavorite, 526 | }; 527 | }, 528 | onThumbnailLoad: Ikm.thumbConfig, 529 | loadEp: async (comicId, epId) => { 530 | try { 531 | let res = await Network.get(epId, Ikm.webHeaders); 532 | 533 | if (needPassValidator(res.body)) { 534 | // rePost 535 | res = await Network.get(epId, Ikm.webHeaders); 536 | } 537 | 538 | let document = new HtmlDocument(res.body); 539 | return { 540 | images: document 541 | .querySelectorAll("img.lazy") 542 | .map((e) => e.attributes["data-src"]), 543 | }; 544 | } catch (err) { 545 | throw new Error(`加载章节失败:${err.message}`); 546 | } 547 | }, 548 | onImageLoad: (url, comicId, epId) => { 549 | return { 550 | url, 551 | headers: { 552 | ...Ikm.webHeaders, 553 | "referer": epId, 554 | }, 555 | }; 556 | }, 557 | }; 558 | } 559 | -------------------------------------------------------------------------------- /mxs.js: -------------------------------------------------------------------------------- 1 | class MXS extends ComicSource { 2 | // 漫画源基本信息 3 | name = "漫小肆"; 4 | key = "mxs"; 5 | version = "1.0.0"; 6 | minAppVersion = "1.5.0"; 7 | url = "https://git.nyne.dev/nyne/venera-configs/raw/branch/main/mxs.js"; 8 | 9 | // 漫画源设置项 10 | settings = { 11 | // 域名选择功能 12 | domains: { 13 | title: "选择域名", 14 | type: "select", 15 | options: [ 16 | { value: "https://www.mxshm.top", text: "mxshm.top" }, 17 | { value: "https://www.jjmhw1.top", text: "jjmhw1.top" }, 18 | { value: "https://www.jjmh.top", text: "jjmh.top" }, 19 | { value: "https://www.jjmh.cc", text: "jjmh.cc" }, 20 | { value: "https://www.wzd1.cc", text: "wzd1.cc" }, 21 | { value: "https://www.wzdhm1.cc", text: "wzdhm1.cc" }, 22 | { value: "https://www.ikanwzd.cc", text: "ikanwzd.cc" } 23 | ], 24 | default: "https://www.mxshm.top" 25 | }, 26 | 27 | // 域名检测功能 28 | domainCheck: { 29 | title: "检测当前域名", 30 | type: "callback", 31 | buttonText: "检测", 32 | callback: () => { 33 | const currentDomain = this.loadSetting("domains"); 34 | const startTime = Date.now(); 35 | let isCompleted = false; 36 | 37 | // 显示加载对话框 38 | const loadingId = UI.showLoading(() => { 39 | UI.showMessage("检测已取消"); 40 | isCompleted = true; 41 | }); 42 | 43 | // 10秒超时检测 44 | setTimeout(() => { 45 | if (!isCompleted) { 46 | UI.cancelLoading(loadingId); 47 | UI.showMessage("❌ 连接超时,可能需要 🚀"); 48 | isCompleted = true; 49 | } 50 | }, 10000); 51 | 52 | // 测试网络连接 53 | Network.get(currentDomain).then(res => { 54 | if (isCompleted) return; 55 | const delay = Date.now() - startTime; 56 | UI.cancelLoading(loadingId); 57 | UI.showMessage(`✅ 连接正常,延迟: ${delay}ms`); 58 | isCompleted = true; 59 | }).catch(() => { 60 | if (isCompleted) return; 61 | UI.cancelLoading(loadingId); 62 | UI.showMessage("❌ 连接失败,可能需要 🚀"); 63 | isCompleted = true; 64 | }); 65 | } 66 | } 67 | }; 68 | 69 | // 获取基础URL 70 | get baseUrl() { 71 | return this.loadSetting("domains"); 72 | } 73 | 74 | // 解析普通漫画列表 75 | parseComicList(items) { 76 | const comics = []; 77 | 78 | for (let item of items) { 79 | // 提取漫画ID 80 | const linkElem = item.querySelector("a[href^='/book/']"); 81 | const id = linkElem.attributes.href.split("/").pop(); 82 | 83 | // 提取标题和作者 84 | const title = item.querySelector(".title a")?.text?.trim(); 85 | const author = item.querySelector("span a")?.text?.trim(); 86 | 87 | // 提取描述信息 88 | const description = item.querySelector(".chapter")?.text?.replace(/^更新/, "")?.replace(/\s+/g, " ")?.trim() || item.querySelector(".zl")?.text?.trim(); 89 | 90 | // 验证必要字段并创建漫画对象 91 | if (id && title) { 92 | comics.push(new Comic({ 93 | id: id, 94 | title: title, 95 | subTitle: author, 96 | cover: `${this.baseUrl}/static/upload/book/${id}/cover.jpg`, 97 | description: description 98 | })); 99 | } 100 | } 101 | 102 | return comics; 103 | } 104 | 105 | // 解析热门漫画列表 106 | parseHotComicList(items) { 107 | const comics = []; 108 | 109 | for (let item of items) { 110 | // 提取漫画ID 111 | const linkElem = item.querySelector(".cover a[href^='/book/']"); 112 | const id = linkElem.attributes.href.split("/").pop(); 113 | 114 | // 提取标题、作者和点击量 115 | const title = item.querySelector(".info .title a")?.text?.trim(); 116 | const author = item.querySelector(".info .desc")?.text?.trim(); 117 | const clickCount = item.querySelector(".info .subtitle span a")?.text?.trim(); 118 | 119 | // 提取标签信息 120 | const tags = []; 121 | const tagElems = item.querySelectorAll(".info .tag a"); 122 | for (let tagElem of tagElems) { 123 | if (tagElem.text) tags.push(tagElem.text.trim()); 124 | } 125 | 126 | // 验证必要字段并创建漫画对象 127 | if (id && title) { 128 | comics.push(new Comic({ 129 | id: id, 130 | title: title, 131 | subTitle: author, 132 | cover: `${this.baseUrl}/static/upload/book/${id}/cover.jpg`, 133 | tags: tags, 134 | description: `热度: 🔥${clickCount}` 135 | })); 136 | } 137 | } 138 | 139 | return comics; 140 | } 141 | 142 | // 解析评论列表 143 | parseCommentList(items) { 144 | const comments = []; 145 | 146 | for (let item of items) { 147 | // 提取评论信息 148 | const userName = item.querySelector(".title")?.text?.trim(); 149 | const content = item.querySelector(".content")?.text?.trim(); 150 | const time = item.querySelector(".bottom")?.text?.match(/\d{4}-\d{2}-\d{2}/)?.[0]?.trim(); 151 | const avatar = item.querySelector(".cover img")?.attributes?.src; 152 | 153 | // 验证必要字段并创建评论对象 154 | if (userName && content) { 155 | comments.push(new Comment({ 156 | userName: userName, 157 | avatar: `${this.baseUrl}${avatar}`, 158 | content: content, 159 | time: time 160 | })); 161 | } 162 | } 163 | 164 | return comments; 165 | } 166 | 167 | // 执行网络请求并返回HTML文档对象 168 | async fetchDocument(url) { 169 | const res = await Network.get(url, { 170 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" 171 | }); 172 | 173 | if (res.status !== 200) { 174 | throw `请求失败: ${res.status}`; 175 | } 176 | 177 | return new HtmlDocument(res.body); 178 | } 179 | 180 | // === 探索页面配置 === 181 | explore = [ 182 | { 183 | title: "漫小肆", 184 | type: "multiPartPage", 185 | load: async (page) => { 186 | const doc = await this.fetchDocument(this.baseUrl); 187 | 188 | // 最近更新部分 189 | const updateSection = { 190 | title: "最近更新", 191 | comics: this.parseComicList(doc.querySelectorAll(".index-manga .mh-item")), 192 | viewMore: { 193 | page: "category", 194 | attributes: { category: "最近更新" } 195 | } 196 | }; 197 | 198 | // 热门漫画部分 199 | const hotSection = { 200 | title: "热门漫画", 201 | comics: this.parseHotComicList(doc.querySelectorAll(".index-original .index-original-list li")), 202 | viewMore: { 203 | page: "category", 204 | attributes: { category: "排行榜" } 205 | } 206 | }; 207 | 208 | // 完结优选部分 209 | const endSection = { 210 | title: "完结优选", 211 | comics: this.parseComicList(doc.querySelectorAll(".box-body .mh-item")), 212 | viewMore: { 213 | page: "category", 214 | attributes: { category: "全部漫画" } 215 | } 216 | }; 217 | 218 | doc.dispose(); 219 | return [updateSection, hotSection, endSection]; 220 | } 221 | } 222 | ]; 223 | 224 | // === 分类页面配置 === 225 | category = { 226 | title: "漫小肆", 227 | parts: [ 228 | { 229 | name: "推荐", 230 | type: "fixed", 231 | categories: ["最近更新", "排行榜", "全部漫画"], 232 | itemType: "category" 233 | }, 234 | { 235 | name: "题材", 236 | type: "fixed", 237 | categories: [ 238 | "都市", "校园", "青春", "性感", "长腿", "多人", "御姐", "巨乳", 239 | "新婚", "媳妇", "暧昧", "清纯", "调教", "少妇", "风骚", "同居", 240 | "淫乱", "好友", "女神", "诱惑", "偷情", "出轨", "正妹", "家教" 241 | ], 242 | itemType: "category" 243 | } 244 | ], 245 | enableRankingPage: false 246 | }; 247 | 248 | // === 分类漫画加载配置 === 249 | categoryComics = { 250 | // 加载分类漫画 251 | load: async (category, param, options, page) => { 252 | // 根据分类构建不同的请求URL 253 | let url; 254 | if (category === "最近更新") { 255 | url = `${this.baseUrl}/update?page=${page}`; 256 | } else if (category === "排行榜") { 257 | url = `${this.baseUrl}/rank`; 258 | } else { 259 | const tag = (category !== "全部漫画") ? category : "全部"; 260 | const area = options[0] || "-1"; 261 | const end = options[1] || "-1"; 262 | url = `${this.baseUrl}/booklist?tag=${encodeURIComponent(tag)}&area=${area}&end=${end}&page=${page}`; 263 | } 264 | 265 | const doc = await this.fetchDocument(url); 266 | let comics = []; 267 | 268 | // 排行榜特殊处理 269 | if (category === "排行榜") { 270 | const selectedRank = options[0] || "new"; 271 | const rankMapping = { 272 | "new": "新书榜", 273 | "popular": "人气榜", 274 | "end": "完结榜", 275 | "recommend": "推荐榜" 276 | }; 277 | 278 | // 查找对应的排行榜列表 279 | const rankLists = doc.querySelectorAll(".mh-list.col3.top-cat li"); 280 | let targetList = null; 281 | 282 | for (let list of rankLists) { 283 | const titleElem = list.querySelector(".title"); 284 | if (titleElem) { 285 | const title = titleElem.text.trim(); 286 | if (title === rankMapping[selectedRank]) { 287 | targetList = list; 288 | break; 289 | } 290 | } 291 | } 292 | 293 | if (!targetList) { 294 | doc.dispose(); 295 | throw "未找到对应的排行榜"; 296 | } 297 | 298 | comics = this.parseComicList(targetList.querySelectorAll(".mh-item.horizontal, .mh-itme-top")); 299 | } else { 300 | // 普通分类处理 301 | comics = this.parseComicList(doc.querySelectorAll(".mh-list.col7 .mh-item")); 302 | } 303 | 304 | // 解析最大页数(排行榜不分页) 305 | let maxPage = 1; 306 | if (category !== "排行榜") { 307 | const pageLinks = doc.querySelectorAll(".pagination a[href*='page=']"); 308 | for (let link of pageLinks) { 309 | const match = link.attributes.href.match(/page=(\d+)/); 310 | if (match) { 311 | const pageNum = parseInt(match[1]); 312 | if (!isNaN(pageNum) && pageNum > maxPage) { 313 | maxPage = pageNum; 314 | } 315 | } 316 | } 317 | } 318 | 319 | doc.dispose(); 320 | return { comics, maxPage }; 321 | }, 322 | 323 | // 动态加载分类选项 324 | optionLoader: async (category, param) => { 325 | if (category === "最近更新") { 326 | return []; 327 | } else if (category === "排行榜") { 328 | return [{ 329 | options: [ 330 | "new-新书榜", 331 | "popular-人气榜", 332 | "end-完结榜", 333 | "recommend-推荐榜" 334 | ] 335 | }]; 336 | } else { 337 | return [ 338 | { 339 | label: "地区", 340 | options: [ 341 | "-全部", 342 | "1-韩国", 343 | "2-日本", 344 | "3-台湾" 345 | ] 346 | }, 347 | { 348 | label: "状态", 349 | options: [ 350 | "-全部", 351 | "0-连载", 352 | "1-完结" 353 | ] 354 | } 355 | ]; 356 | } 357 | } 358 | }; 359 | 360 | // === 搜索功能配置 === 361 | search = { 362 | // 搜索漫画 363 | load: async (keyword, options, page) => { 364 | const url = `${this.baseUrl}/search?keyword=${encodeURIComponent(keyword)}`; 365 | const doc = await this.fetchDocument(url); 366 | const comics = this.parseComicList(doc.querySelectorAll(".mh-item")); 367 | 368 | doc.dispose(); 369 | return { 370 | comics: comics, 371 | maxPage: 1 372 | }; 373 | }, 374 | enableTagsSuggestions: false 375 | }; 376 | 377 | // === 漫画详情和阅读功能配置 === 378 | comic = { 379 | // 加载漫画详情 380 | loadInfo: async (id) => { 381 | const url = `${this.baseUrl}/book/${id}`; 382 | const doc = await this.fetchDocument(url); 383 | 384 | // 提取标题信息 385 | const title = doc.querySelector(".info h1")?.text?.trim(); 386 | 387 | // 提取副标题信息(别名和作者) 388 | let author = ""; 389 | let subTitle = ""; 390 | const subTitleElems = doc.querySelectorAll(".info .subtitle"); 391 | for (let elem of subTitleElems) { 392 | const text = elem.text; 393 | if (text.includes("别名:")) subTitle = text.replace("别名:", "").trim(); 394 | if (text.includes("作者:")) author = text.replace("作者:", "").trim(); 395 | } 396 | const authors = author ? author.split("&").map(a => a.trim()).filter(a => a) : []; 397 | 398 | // 提取其他信息(状态、地区、更新时间、点击量和描述信息) 399 | let status = ""; 400 | let area = ""; 401 | let updateTime = ""; 402 | let clickCount = ""; 403 | const tipElems = doc.querySelectorAll(".info .tip span"); 404 | for (let elem of tipElems) { 405 | const text = elem.text; 406 | if (text.includes("状态:")) status = elem.querySelector("span")?.text?.trim(); 407 | if (text.includes("地区:")) area = elem.querySelector("a")?.text?.trim(); 408 | if (text.includes("更新时间:")) updateTime = elem.text.replace("更新时间:", "").trim(); 409 | if (text.includes("点击:")) clickCount = elem.text.replace("点击:", "").trim(); 410 | } 411 | const description = doc.querySelector(".info .content")?.text?.trim(); 412 | 413 | // 提取标签信息 414 | const tagList = []; 415 | const tagElems = doc.querySelectorAll(".info .tip a[href*='tag=']"); 416 | for (let elem of tagElems) { 417 | const tagName = elem.text?.trim(); 418 | if (tagName) tagList.push(tagName); 419 | } 420 | 421 | // 提取章节列表 422 | const chapters = {}; 423 | const chapterElems = doc.querySelectorAll("#detail-list-select li a"); 424 | for (let elem of chapterElems) { 425 | const chapterUrl = elem.attributes?.href; 426 | const chapterTitle = elem.text?.trim(); 427 | if (chapterUrl && chapterTitle) { 428 | const chapterId = chapterUrl.split("/").pop(); 429 | if (chapterId) chapters[chapterId] = chapterTitle; 430 | } 431 | } 432 | 433 | // 提取评论和推荐漫画 434 | const comments = this.parseCommentList(doc.querySelectorAll(".view-comment-main .postlist li.dashed")); 435 | const recommend = this.parseComicList(doc.querySelectorAll(".index-manga .mh-item")); 436 | 437 | doc.dispose(); 438 | 439 | // 创建并返回漫画详情对象 440 | return new ComicDetails({ 441 | title: title, 442 | subTitle: subTitle, 443 | cover: `${this.baseUrl}/static/upload/book/${id}/cover.jpg`, 444 | description: description, 445 | tags: { 446 | "作者": authors, 447 | "题材": tagList, 448 | "地区": [area], 449 | "状态": [status], 450 | "热度": [`🔥${clickCount}`] 451 | }, 452 | chapters: chapters, 453 | recommend: recommend, 454 | commentCount: comments.length, 455 | updateTime: updateTime, 456 | url: url, 457 | comments: comments 458 | }); 459 | }, 460 | 461 | // 加载章节图片 462 | loadEp: async (comicId, epId) => { 463 | const url = `${this.baseUrl}/chapter/${epId}`; 464 | const doc = await this.fetchDocument(url); 465 | 466 | // 提取懒加载图片 467 | const images = []; 468 | const imageElems = doc.querySelectorAll("img.lazy"); 469 | for (let img of imageElems) { 470 | const src = img.attributes?.["data-original"]; 471 | const image = src.replace(/https?:\/\/[^\/]+/, this.baseUrl); 472 | if (image) images.push(image); 473 | } 474 | 475 | if (images.length === 0) { 476 | doc.dispose(); 477 | throw "本章中未找到图片"; 478 | } 479 | 480 | doc.dispose(); 481 | return { 482 | images: images 483 | }; 484 | }, 485 | 486 | // 加载评论列表 487 | loadComments: async (comicId, subId, page, replyTo) => { 488 | const url = `${this.baseUrl}/book/${comicId}`; 489 | const doc = await this.fetchDocument(url); 490 | 491 | const comments = this.parseCommentList(doc.querySelectorAll(".view-comment-main .postlist li.dashed")); 492 | 493 | doc.dispose(); 494 | return { 495 | comments: comments, 496 | maxPage: 1 497 | }; 498 | }, 499 | 500 | // 处理标签点击事件 501 | onClickTag: (namespace, tag) => { 502 | // 作者标签跳转到搜索页面 503 | if (namespace === "作者") { 504 | return { 505 | page: "search", 506 | attributes: { 507 | keyword: tag 508 | } 509 | }; 510 | } 511 | // 题材标签跳转到分类页面 512 | else if (namespace === "题材") { 513 | return { 514 | page: "category", 515 | attributes: { 516 | category: tag 517 | } 518 | }; 519 | } 520 | }, 521 | enableTagsTranslate: false 522 | }; 523 | } -------------------------------------------------------------------------------- /komiic.js: -------------------------------------------------------------------------------- 1 | class Komiic extends ComicSource { 2 | 3 | // 此漫画源的名称 4 | name = "Komiic" 5 | 6 | // 唯一标识符 7 | key = "Komiic" 8 | 9 | version = "1.0.3" 10 | 11 | minAppVersion = "1.0.0" 12 | 13 | // 更新链接 14 | url = "https://git.nyne.dev/nyne/venera-configs/raw/branch/main/komiic.js" 15 | 16 | get headers() { 17 | let token = this.loadData('token') 18 | let headers = { 19 | 'Referer': 'https://komiic.com/', 20 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', 21 | 'Content-Type': 'application/json' 22 | } 23 | if (token) { 24 | headers['Authorization'] = `Bearer ${token}` 25 | } 26 | return headers 27 | } 28 | 29 | async queryJson(query) { 30 | let res = await Network.post( 31 | 'https://komiic.com/api/query', 32 | this.headers, 33 | query 34 | ) 35 | 36 | if (res.status !== 200) { 37 | throw `Invalid Status Code ${res.status}` 38 | } 39 | 40 | let json = JSON.parse(res.body) 41 | 42 | if (json.errors != undefined) { 43 | const errorInfo = json.errors[0].message.toString(); 44 | if ((errorInfo.indexOf('token is expired') >= 0) || (errorInfo.indexOf('no token') >= 0)) { 45 | const accountData = this.loadData("account"); 46 | await this.account.login(accountData[0], accountData[1]); 47 | return await this.queryJson(query); 48 | } 49 | throw json.errors[0].message 50 | } 51 | 52 | return json 53 | } 54 | 55 | async queryComics(query) { 56 | let operationName = query["operationName"] 57 | let json = await this.queryJson(query) 58 | 59 | function parseComic(comic) { 60 | let author = '' 61 | if (comic.authors.length > 0) { 62 | author = comic.authors[0].name 63 | } 64 | let tags = [] 65 | comic.categories.forEach((c) => { 66 | tags.push(c.name) 67 | }) 68 | 69 | function getTimeDifference(date) { 70 | const now = new Date(); 71 | const timeDifference = now - date; 72 | 73 | const millisecondsPerHour = 1000 * 60 * 60; 74 | const millisecondsPerDay = millisecondsPerHour * 24; 75 | 76 | if (timeDifference < millisecondsPerHour) { 77 | return '剛剛更新'; 78 | } else if (timeDifference < millisecondsPerDay) { 79 | const hours = Math.floor(timeDifference / millisecondsPerHour); 80 | return `${hours}小時前更新`; 81 | } else { 82 | const days = Math.floor(timeDifference / millisecondsPerDay); 83 | return `${days}天前更新`; 84 | } 85 | } 86 | 87 | let updateTime = new Date(comic.dateUpdated) 88 | let description = getTimeDifference(updateTime) 89 | let formatedTime = `${updateTime.getFullYear()}-${updateTime.getMonth() + 1}-${updateTime.getDate()}` 90 | 91 | return { 92 | id: comic.id, 93 | title: comic.title, 94 | subTitle: author, 95 | cover: comic.imageUrl, 96 | tags: tags, 97 | description: description, 98 | updateTime: formatedTime 99 | } 100 | } 101 | 102 | return { 103 | comics: json.data[operationName].map(parseComic), 104 | // 没找到最大页数的接口 105 | maxPage: null 106 | } 107 | } 108 | 109 | /// 账号 110 | /// 设置为null禁用账号功能 111 | account = { 112 | /// 登录 113 | /// 返回任意值表示登录成功 114 | login: async (account, pwd) => { 115 | let res = await Network.post( 116 | 'https://komiic.com/api/login', 117 | this.headers, 118 | { 119 | email: account, 120 | password: pwd 121 | } 122 | ) 123 | 124 | if (res.status === 200) { 125 | this.saveData('token', JSON.parse(res.body).token) 126 | return 'ok' 127 | } 128 | 129 | throw 'Failed to login' 130 | }, 131 | 132 | // 退出登录时将会调用此函数 133 | logout: () => { 134 | this.deleteData('token') 135 | }, 136 | 137 | registerWebsite: "https://komiic.com/register" 138 | } 139 | 140 | /// 探索页面 141 | /// 一个漫画源可以有多个探索页面 142 | explore = [ 143 | { 144 | /// 标题 145 | /// 标题同时用作标识符, 不能重复 146 | title: "Komiic", 147 | 148 | /// singlePageWithMultiPart 或者 multiPageComicList 149 | type: "multiPageComicList", 150 | 151 | load: async (page) => { 152 | return await this.queryComics({ "operationName": "recentUpdate", "variables": { "pagination": { "limit": 20, "offset": (page - 1) * 20, "orderBy": "DATE_UPDATED", "status": "", "asc": true } }, "query": "query recentUpdate($pagination: Pagination!) {\n recentUpdate(pagination: $pagination) {\n id\n title\n status\n year\n imageUrl\n authors {\n id\n name\n __typename\n }\n categories {\n id\n name\n __typename\n }\n dateUpdated\n monthViews\n views\n favoriteCount\n lastBookUpdate\n lastChapterUpdate\n __typename\n }\n}" }) 153 | } 154 | } 155 | ] 156 | 157 | category = { 158 | title: "Komiic", 159 | enableRankingPage: true, 160 | parts: [ 161 | { 162 | name: "主题", 163 | 164 | type: "fixed", 165 | 166 | categories: ['全部', '愛情', '神鬼', '校園', '搞笑', '生活', '懸疑', '冒險', '職場', '魔幻', '後宮', '魔法', '格鬥', '宅男', '勵志', '耽美', '科幻', '百合', '治癒', '萌系', '熱血', '競技', '推理', '雜誌', '偵探', '偽娘', '美食', '恐怖', '四格', '社會', '歷史', '戰爭', '舞蹈', '武俠', '機戰', '音樂', '體育', '黑道'], 167 | 168 | itemType: "category", 169 | 170 | // 若提供, 数量需要和`categories`一致, `categoryComics.load`方法将会收到此参数 171 | categoryParams: ['0', '1', '3', '4', '5', '6', '7', '8', '10', '11', '2', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23', '24', '25', '26', '27', '9', '28', '31', '32', '33', '34', '35', '36', '37', '40', '42'] 172 | } 173 | ] 174 | } 175 | 176 | /// 分类漫画页面, 即点击分类标签后进入的页面 177 | categoryComics = { 178 | load: async (category, param, options, page) => { 179 | let variables = { 180 | pagination: { 181 | limit: 30, 182 | offset: (page - 1) * 30, 183 | orderBy: options[0], 184 | asc: false, 185 | status: options[1] 186 | } 187 | }; 188 | 189 | if (param !== '0') { 190 | variables.categoryId = [param]; 191 | } else { 192 | variables.categoryId = []; 193 | } 194 | 195 | return await this.queryComics({ 196 | "operationName": "comicByCategories", 197 | "variables": variables, 198 | "query": `query comicByCategories($categoryId: [ID!]!, $pagination: Pagination!) { 199 | comicByCategories(categoryId: $categoryId, pagination: $pagination) { 200 | id 201 | title 202 | status 203 | year 204 | imageUrl 205 | authors { id name __typename } 206 | categories { id name __typename } 207 | dateUpdated 208 | monthViews 209 | views 210 | favoriteCount 211 | lastBookUpdate 212 | lastChapterUpdate 213 | __typename 214 | } 215 | }` 216 | }) 217 | }, 218 | // 提供选项 219 | optionList: [ 220 | { 221 | options: [ 222 | "DATE_UPDATED-更新", 223 | "VIEWS-觀看數", 224 | "FAVORITE_COUNT-喜愛數", 225 | ], 226 | notShowWhen: null, 227 | showWhen: null 228 | }, 229 | { 230 | options: [ 231 | "-全部", 232 | "ONGOING-連載中", 233 | "END-完結", 234 | ], 235 | notShowWhen: null, 236 | showWhen: null 237 | }, 238 | ], 239 | ranking: { 240 | options: [ 241 | "MONTH_VIEWS-月", 242 | "VIEWS-綜合" 243 | ], 244 | load: async (option, page) => { 245 | return this.queryComics({ "operationName": "hotComics", "variables": { "pagination": { "limit": 20, "offset": (page - 1) * 20, "orderBy": option, "status": "", "asc": true } }, "query": "query hotComics($pagination: Pagination!) {\n hotComics(pagination: $pagination) {\n id\n title\n status\n year\n imageUrl\n authors {\n id\n name\n __typename\n }\n categories {\n id\n name\n __typename\n }\n dateUpdated\n monthViews\n views\n favoriteCount\n lastBookUpdate\n lastChapterUpdate\n __typename\n }\n}" }) 246 | } 247 | } 248 | } 249 | 250 | /// 搜索 251 | search = { 252 | load: async (keyword, options, page) => { 253 | let json = await this.queryJson({ "operationName": "searchComicAndAuthorQuery", "variables": { "keyword": keyword }, "query": "query searchComicAndAuthorQuery($keyword: String!) {\n searchComicsAndAuthors(keyword: $keyword) {\n comics {\n id\n title\n status\n year\n imageUrl\n authors {\n id\n name\n __typename\n }\n categories {\n id\n name\n __typename\n }\n dateUpdated\n monthViews\n views\n favoriteCount\n lastBookUpdate\n lastChapterUpdate\n __typename\n }\n authors {\n id\n name\n chName\n enName\n wikiLink\n comicCount\n views\n __typename\n }\n __typename\n }\n}" }) 254 | 255 | function parseComic(comic) { 256 | let author = '' 257 | if (comic.authors.length > 0) { 258 | author = comic.authors[0].name 259 | } 260 | let tags = [] 261 | comic.categories.forEach((c) => { 262 | tags.push(c.name) 263 | }) 264 | 265 | function getTimeDifference(date) { 266 | const now = new Date(); 267 | const timeDifference = now - date; 268 | 269 | const millisecondsPerHour = 1000 * 60 * 60; 270 | const millisecondsPerDay = millisecondsPerHour * 24; 271 | 272 | if (timeDifference < millisecondsPerHour) { 273 | return '剛剛更新'; 274 | } else if (timeDifference < millisecondsPerDay) { 275 | const hours = Math.floor(timeDifference / millisecondsPerHour); 276 | return `${hours}小時前更新`; 277 | } else { 278 | const days = Math.floor(timeDifference / millisecondsPerDay); 279 | return `${days}天前更新`; 280 | } 281 | } 282 | 283 | let updateTime = new Date(comic.dateUpdated) 284 | let description = getTimeDifference(updateTime) 285 | 286 | return { 287 | id: comic.id, 288 | title: comic.title, 289 | subTitle: author, 290 | cover: comic.imageUrl, 291 | tags: tags, 292 | description: description 293 | } 294 | } 295 | 296 | return { 297 | comics: json.data.searchComicsAndAuthors.comics.map(parseComic), 298 | // 没找到最大页数的接口 299 | maxPage: 1 300 | } 301 | }, 302 | 303 | optionList: [] 304 | } 305 | 306 | /// 收藏 307 | favorites = { 308 | /// 是否为多收藏夹 309 | multiFolder: true, 310 | /// 添加或者删除收藏 311 | addOrDelFavorite: async (comicId, folderId, isAdding) => { 312 | let query = {} 313 | if (isAdding) { 314 | query = { "operationName": "addComicToFolder", "variables": { "comicId": comicId, "folderId": folderId }, "query": "mutation addComicToFolder($comicId: ID!, $folderId: ID!) {\n addComicToFolder(comicId: $comicId, folderId: $folderId)\n}" } 315 | } else { 316 | query = { "operationName": "removeComicToFolder", "variables": { "comicId": comicId, "folderId": folderId }, "query": "mutation removeComicToFolder($comicId: ID!, $folderId: ID!) {\n removeComicToFolder(comicId: $comicId, folderId: $folderId)\n}" } 317 | } 318 | await this.queryJson(query) 319 | return "ok" 320 | }, 321 | // 加载收藏夹, 仅当multiFolder为true时有效 322 | // 当comicId不为null时, 需要同时返回包含该漫画的收藏夹 323 | loadFolders: async (comicId) => { 324 | let json = await this.queryJson({ "operationName": "myFolder", "variables": {}, "query": "query myFolder {\n folders {\n id\n key\n name\n views\n comicCount\n dateCreated\n dateUpdated\n __typename\n }\n}" }) 325 | let folders = {} 326 | json.data.folders.forEach((f) => { 327 | folders[f.id] = f.name 328 | }) 329 | let favorited = null 330 | if (comicId) { 331 | let json2 = await this.queryJson({ "operationName": "comicInAccountFolders", "variables": { "comicId": comicId }, "query": "query comicInAccountFolders($comicId: ID!) {\n comicInAccountFolders(comicId: $comicId)\n}" }) 332 | favorited = json2.data.comicInAccountFolders 333 | } 334 | return { 335 | folders: folders, 336 | favorited: favorited 337 | } 338 | }, 339 | /// 创建收藏夹 340 | addFolder: async (name) => { 341 | let json = await this.queryJson({ "operationName": "createFolder", "variables": { "name": name }, "query": "mutation createFolder($name: String!) {\n createFolder(name: $name) {\n id\n key\n name\n account {\n id\n nickname\n __typename\n }\n comicCount\n views\n dateCreated\n dateUpdated\n __typename\n }\n}" }) 342 | return "ok" 343 | }, 344 | deleteFolder: async (id) => { 345 | let json = await this.queryJson({ "operationName": "removeFolder", "variables": { "folderId": id }, "query": "mutation removeFolder($folderId: ID!) {\n removeFolder(folderId: $folderId)\n}" }) 346 | return "ok" 347 | }, 348 | /// 加载漫画 349 | loadComics: async (page, folder) => { 350 | let json = await this.queryJson({ "operationName": "folderComicIds", "variables": { "folderId": folder, "pagination": { "limit": 30, "offset": (page - 1) * 30, "orderBy": "DATE_UPDATED", "status": "", "asc": true } }, "query": "query folderComicIds($folderId: ID!, $pagination: Pagination!) {\n folderComicIds(folderId: $folderId, pagination: $pagination) {\n folderId\n key\n comicIds\n __typename\n }\n}" }) 351 | let ids = json.data.folderComicIds.comicIds 352 | if (ids.length == 0) { 353 | return { 354 | comics: [], 355 | maxPage: 1 356 | } 357 | } 358 | return this.queryComics({ "operationName": "comicByIds", "variables": { "comicIds": ids }, "query": "query comicByIds($comicIds: [ID]!) {\n comicByIds(comicIds: $comicIds) {\n id\n title\n status\n year\n imageUrl\n authors {\n id\n name\n __typename\n }\n categories {\n id\n name\n __typename\n }\n dateUpdated\n monthViews\n views\n favoriteCount\n lastBookUpdate\n lastChapterUpdate\n __typename\n }\n}" }) 359 | } 360 | } 361 | 362 | /// 单个漫画相关 363 | comic = { 364 | // 加载漫画信息 365 | loadInfo: async (id) => { 366 | let json1 = await this.queryJson({ "operationName": "recommendComicById", "variables": { "comicId": id }, "query": "query recommendComicById($comicId: ID!) {\n recommendComicById(comicId: $comicId)\n}" }) 367 | let recommend = json1.data.recommendComicById 368 | recommend.push(id) 369 | 370 | let getFavoriteStatus = async () => { 371 | let token = this.loadData('token') 372 | if (!token) { 373 | return false 374 | } 375 | let json = await this.queryJson({ "operationName": "comicInAccountFolders", "variables": { "comicId": id }, "query": "query comicInAccountFolders($comicId: ID!) {\n comicInAccountFolders(comicId: $comicId)\n}" }) 376 | let folders = json.data.comicInAccountFolders 377 | return folders.length !== 0 378 | } 379 | 380 | let getChapter = async () => { 381 | let json = await this.queryJson({ "operationName": "chapterByComicId", "variables": { "comicId": id }, "query": "query chapterByComicId($comicId: ID!) {\n chaptersByComicId(comicId: $comicId) {\n id\n serial\n type\n dateCreated\n dateUpdated\n size\n __typename\n }\n}" }) 382 | let all = json.data.chaptersByComicId 383 | let books = [], chapters = [] 384 | all.forEach((c) => { 385 | if(c.type === 'book') { 386 | books.push(c) 387 | } else { 388 | chapters.push(c) 389 | } 390 | }) 391 | let res = new Map() 392 | books.forEach((c) => { 393 | let name = '卷' + c.serial 394 | res.set(c.id, name) 395 | }) 396 | chapters.forEach((c) => { 397 | let name = c.serial 398 | res.set(c.id, name) 399 | }) 400 | return res 401 | } 402 | 403 | let results = await Promise.all([ 404 | this.queryComics({ "operationName": "comicByIds", "variables": { "comicIds": recommend }, "query": "query comicByIds($comicIds: [ID]!) {\n comicByIds(comicIds: $comicIds) {\n id\n title\n status\n year\n imageUrl\n authors {\n id\n name\n __typename\n }\n categories {\n id\n name\n __typename\n }\n dateUpdated\n monthViews\n views\n favoriteCount\n lastBookUpdate\n lastChapterUpdate\n __typename\n }\n}" }), 405 | getChapter.call() 406 | ]) 407 | 408 | let info = results[0].comics.pop() 409 | 410 | return { 411 | // string 标题 412 | title: info.title, 413 | // string 封面url 414 | cover: info.cover, 415 | // map 标签 416 | tags: { 417 | "作者": [info.subTitle], 418 | "标签": info.tags 419 | }, 420 | // map?, key为章节id, value为章节名称 421 | chapters: results[1], 422 | recommend: results[0].comics, 423 | updateTime: info.updateTime, 424 | } 425 | }, 426 | // 获取章节图片 427 | loadEp: async (comicId, epId) => { 428 | let json = await this.queryJson({ "operationName": "imagesByChapterId", "variables": { "chapterId": epId }, "query": "query imagesByChapterId($chapterId: ID!) {\n imagesByChapterId(chapterId: $chapterId) {\n id\n kid\n height\n width\n __typename\n }\n}" }) 429 | return { 430 | images: json.data.imagesByChapterId.map((i) => { 431 | return `https://komiic.com/api/image/${i.kid}` 432 | }) 433 | } 434 | }, 435 | // 可选, 调整图片加载的行为 436 | onImageLoad: (url, comicId, epId) => { 437 | return { 438 | headers: { 439 | 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', 440 | 'referer': `https://komiic.com/comic/${comicId}/chapter/${epId}/images/all` 441 | } 442 | } 443 | }, 444 | // 加载评论 445 | loadComments: async (comicId, subId, page, replyTo) => { 446 | let operationName = replyTo ? "messageChan" : "getMessagesByComicId" 447 | let promise = replyTo 448 | ? this.queryJson({ "operationName": "messageChan", "variables": { "messageId": replyTo }, "query": "query messageChan($messageId: ID!) {\n messageChan(messageId: $messageId) {\n id\n comicId\n account {\n id\n nickname\n profileText\n profileTextColor\n profileBackgroundColor\n profileImageUrl\n __typename\n }\n message\n replyTo {\n id\n __typename\n }\n upCount\n downCount\n dateUpdated\n dateCreated\n __typename\n }\n}" }) 449 | : this.queryJson({ "operationName": "getMessagesByComicId", "variables": { "comicId": comicId, "pagination": { "limit": 100, "offset": (page - 1) * 100, "orderBy": "DATE_UPDATED", "asc": true } }, "query": "query getMessagesByComicId($comicId: ID!, $pagination: Pagination!) {\n getMessagesByComicId(comicId: $comicId, pagination: $pagination) {\n id\n comicId\n account {\n id\n nickname\n profileText\n profileTextColor\n profileBackgroundColor\n profileImageUrl\n __typename\n }\n message\n replyTo {\n id\n message\n account {\n id\n nickname\n profileText\n profileTextColor\n profileBackgroundColor\n profileImageUrl\n __typename\n }\n __typename\n }\n upCount\n downCount\n dateUpdated\n dateCreated\n __typename\n }\n}" }) 450 | let json = await promise 451 | return { 452 | comments: json.data[operationName].map(e => { 453 | return { 454 | // string 455 | userName: e.account.nickname, 456 | // string 457 | avatar: e.account.profileImageUrl, 458 | // string 459 | content: e.message, 460 | // string? 461 | time: e.dateUpdated, 462 | // number? 463 | // TODO: 没有数量信息, 但是设为null会禁用回复功能 464 | replyCount: 0, 465 | // string 466 | id: e.id, 467 | } 468 | }), 469 | maxPage: null, 470 | } 471 | }, 472 | // 发送评论, 返回任意值表示成功 473 | sendComment: async (comicId, subId, content, replyTo) => { 474 | if (!replyTo) { 475 | replyTo = "0" 476 | } 477 | let json = await this.queryJson({ "operationName": "addMessageToComic", "variables": { "comicId": comicId, "message": content, "replyToId": replyTo }, "query": "mutation addMessageToComic($comicId: ID!, $replyToId: ID!, $message: String!) {\n addMessageToComic(message: $message, comicId: $comicId, replyToId: $replyToId) {\n id\n message\n comicId\n account {\n id\n nickname\n __typename\n }\n replyTo {\n id\n message\n account {\n id\n nickname\n profileText\n profileTextColor\n profileBackgroundColor\n profileImageUrl\n __typename\n }\n __typename\n }\n dateCreated\n dateUpdated\n __typename\n }\n}" }) 478 | return "ok" 479 | } 480 | } 481 | } 482 | -------------------------------------------------------------------------------- /komga.js: -------------------------------------------------------------------------------- 1 | /** @type {import('./_venera_.js')} */ 2 | class Komga extends ComicSource { 3 | name = "Komga" 4 | 5 | key = "komga" 6 | 7 | version = "1.0.0" 8 | 9 | minAppVersion = "1.4.0" 10 | 11 | url = "https://git.nyne.dev/nyne/venera-configs/raw/branch/main/komga.js" 12 | 13 | settings = { 14 | base_url: { 15 | title: "服务器地址", 16 | type: "input", 17 | default: "https://demo.komga.org", 18 | validator: "^(https?:\\/\\/).+$" 19 | }, 20 | // default_username: { 21 | // title: "默认账号", 22 | // type: "input", 23 | // default: "demo@komga.org" 24 | // }, 25 | // default_password: { 26 | // title: "默认密码", 27 | // type: "input", 28 | // default: "komga-demo" 29 | // } 30 | } 31 | 32 | get baseUrl() { 33 | let raw = this.loadSetting('base_url') 34 | if (typeof raw !== 'string' || !raw.trim()) { 35 | raw = this.settings.base_url.default 36 | } 37 | let value = raw.trim() 38 | if (!/^https?:\/\//i.test(value)) { 39 | value = `https://${value}` 40 | } 41 | return value.replace(/\/$/, '') 42 | } 43 | 44 | get authToken() { 45 | const stored = this.loadData('komga_auth') 46 | if (stored) { 47 | return stored 48 | } 49 | const username = this.loadSetting('default_username') 50 | const password = this.loadSetting('default_password') 51 | if (!username || !password) { 52 | return null 53 | } 54 | const encoded = Convert.encodeBase64(Convert.encodeUtf8(`${username}:${password}`)) 55 | return typeof encoded === 'string' ? encoded : Convert.decodeUtf8(encoded) 56 | } 57 | 58 | get headers() { 59 | const headers = { "Accept": "application/json" } 60 | const token = this.authToken 61 | if (token) headers["Authorization"] = `Basic ${token}` 62 | return headers 63 | } 64 | 65 | get imageHeaders() { 66 | const token = this.authToken 67 | return token ? { "Authorization": `Basic ${token}` } : {} 68 | } 69 | 70 | async init() { 71 | try { 72 | await this.refreshReferenceData(false) 73 | } catch (_) { 74 | } 75 | } 76 | 77 | account = { 78 | login: async (account, pwd) => { 79 | if (!account || !pwd) { 80 | throw '账号或密码不能为空' 81 | } 82 | const basic = Convert.encodeBase64(Convert.encodeUtf8(`${account}:${pwd}`)) 83 | const token = typeof basic === 'string' ? basic : Convert.decodeUtf8(basic) 84 | const res = await Network.get( 85 | this.buildUrl('/api/v2/users/me'), 86 | { 87 | "Accept": "application/json", 88 | "Authorization": `Basic ${token}` 89 | } 90 | ) 91 | if (res.status === 401) { 92 | throw '账号或密码错误' 93 | } 94 | if (res.status !== 200) { 95 | throw `登录失败: ${res.status}` 96 | } 97 | this.saveData('komga_auth', token) 98 | this.saveData('komga_account_email', account) 99 | await this.refreshReferenceData(true) 100 | return account 101 | }, 102 | logout: () => { 103 | this.deleteData('komga_auth') 104 | this.deleteData('komga_account_email') 105 | this.deleteData('komga_libraries') 106 | this.deleteData('komga_tags') 107 | this.deleteData('komga_genres') 108 | this.deleteData('komga_languages') 109 | this.deleteData('komga_collections') 110 | this.deleteData('komga_meta_ts') 111 | }, 112 | registerWebsite: null 113 | } 114 | 115 | explore = [ 116 | { 117 | title: "Komga", 118 | type: "singlePageWithMultiPart", 119 | load: async () => { 120 | await this.refreshReferenceData(false) 121 | const feeds = {} 122 | const latest = await this.fetchSeriesList('/api/v1/series/latest', { size: 12, page: 0 }) 123 | if (latest.comics.length) feeds["最新上架"] = latest.comics 124 | const updated = await this.fetchSeriesList('/api/v1/series/updated', { size: 12, page: 0 }) 125 | if (updated.comics.length) feeds["最近更新"] = updated.comics 126 | const libraries = this.loadData('komga_libraries') 127 | if (Array.isArray(libraries)) { 128 | for (const library of libraries.slice(0, 4)) { 129 | const list = await this.fetchSeriesList('/api/v1/series', { 130 | page: 0, 131 | size: 12, 132 | sort: ['metadata.lastModified,desc'], 133 | library_id: [library.id] 134 | }) 135 | if (list.comics.length) feeds[`书库 ${library.name}`] = list.comics 136 | } 137 | } 138 | if (!Object.keys(feeds).length) { 139 | throw '未找到可展示的数据,请确认已登录且服务器可用' 140 | } 141 | return feeds 142 | } 143 | } 144 | ] 145 | 146 | category = { 147 | title: "Komga", 148 | parts: [ 149 | { 150 | name: "常用", 151 | type: "dynamic", 152 | loader: () => ( 153 | [ 154 | { 155 | label: "all", 156 | target: { 157 | page: 'category', 158 | attributes: { 159 | category: 'all', 160 | param: null, 161 | }, 162 | }, 163 | } 164 | ] 165 | ) 166 | }, 167 | { 168 | name: "书库", 169 | type: "dynamic", 170 | loader: () => { 171 | const libraries = this.loadData('komga_libraries') 172 | if (!Array.isArray(libraries) || !libraries.length) { 173 | return [] 174 | } 175 | return libraries.map((library) => ({ 176 | label: library.name, 177 | target: { 178 | page: 'category', 179 | attributes: { 180 | category: 'library', 181 | param: library.id, 182 | }, 183 | }, 184 | })) 185 | } 186 | }, 187 | { 188 | name: "合集", 189 | type: "dynamic", 190 | loader: () => { 191 | const collections = this.loadData('komga_collections') 192 | if (!Array.isArray(collections) || !collections.length) { 193 | return [] 194 | } 195 | return collections.map((collection) => ({ 196 | label: collection.name, 197 | target: { 198 | page: 'category', 199 | attributes: { 200 | category: 'collection', 201 | param: collection.id, 202 | }, 203 | }, 204 | })) 205 | } 206 | }, 207 | { 208 | name: "标签", 209 | type: "dynamic", 210 | loader: () => { 211 | const tags = this.loadData('komga_tags') 212 | if (!Array.isArray(tags) || !tags.length) { 213 | return [] 214 | } 215 | return tags.map((tag) => ({ 216 | label: tag, 217 | target: { 218 | page: 'category', 219 | attributes: { 220 | category: 'tag', 221 | param: tag, 222 | }, 223 | }, 224 | })) 225 | } 226 | }, 227 | { 228 | name: "语言", 229 | type: "dynamic", 230 | loader: () => { 231 | const languages = this.loadData('komga_languages') 232 | if (!Array.isArray(languages) || !languages.length) { 233 | return [] 234 | } 235 | return languages.map((lang) => ({ 236 | label: lang, 237 | target: { 238 | page: 'category', 239 | attributes: { 240 | category: 'language', 241 | param: lang, 242 | }, 243 | }, 244 | })) 245 | } 246 | }, 247 | { 248 | name: "题材", 249 | type: "dynamic", 250 | loader: () => { 251 | const genres = this.loadData('komga_genres') 252 | if (!Array.isArray(genres) || !genres.length) { 253 | return [] 254 | } 255 | return genres.map((genre) => ({ 256 | label: genre, 257 | target: { 258 | page: 'category', 259 | attributes: { 260 | category: 'genre', 261 | param: genre, 262 | }, 263 | }, 264 | })) 265 | } 266 | } 267 | ], 268 | enableRankingPage: false, 269 | } 270 | 271 | categoryComics = { 272 | load: async (category, param, options, page) => { 273 | await this.refreshReferenceData(false) 274 | const pageIndex = Math.max(0, (page || 1) - 1) 275 | const defaultSort = category === 'all' ? 'created,desc' : 'metadata.lastModified,desc' 276 | const sortValue = this.extractOption(options, 0, defaultSort) 277 | const query = { 278 | page: pageIndex, 279 | size: 30, 280 | sort: [sortValue] 281 | } 282 | if (category === 'all') { 283 | // const list = await this.fetchBookList('/api/v1/books', query) 284 | // return { 285 | // comics: list.comics, 286 | // maxPage: Math.max(1, list.totalPages) 287 | // } 288 | const list = await this.fetchSeriesList('/api/v1/series', query) 289 | return { 290 | comics: list.comics, 291 | maxPage: Math.max(1, list.totalPages) 292 | } 293 | } 294 | if (category === 'library' && param) { 295 | query.library_id = [param] 296 | const list = await this.fetchSeriesList('/api/v1/series', query) 297 | return { 298 | comics: list.comics, 299 | maxPage: Math.max(1, list.totalPages) 300 | } 301 | } 302 | if (category === 'collection' && param) { 303 | const list = await this.fetchSeriesList(`/api/v1/collections/${param}/series`, query) 304 | return { 305 | comics: list.comics, 306 | maxPage: Math.max(1, list.totalPages) 307 | } 308 | } 309 | 310 | 311 | if (category === 'tag' && param) { 312 | query.tag = [param] 313 | const list = await this.fetchSeriesList('/api/v1/series', query) 314 | return { 315 | comics: list.comics, 316 | maxPage: Math.max(1, list.totalPages) 317 | } 318 | } 319 | 320 | 321 | if (category === 'language' && param){ 322 | query.language = [param] 323 | const list = await this.fetchSeriesList('/api/v1/series', query) 324 | return { 325 | comics: list.comics, 326 | maxPage: Math.max(1, list.totalPages) 327 | } 328 | } 329 | 330 | // if (category === 'genre' && param) query.genre = [param] 331 | query.genre = [param] 332 | const list = await this.fetchSeriesList('/api/v1/series', query) 333 | 334 | return { 335 | comics: list.comics, 336 | maxPage: Math.max(1, list.totalPages) 337 | } 338 | }, 339 | optionList: [ 340 | { 341 | options: [ 342 | '*created,desc-添加时间(新→旧)', 343 | 'created,asc-添加时间(旧→新)', 344 | 'metadata.lastModified,desc-更新时间(新→旧)', 345 | 'metadata.lastModified,asc-更新时间(旧→新)', 346 | 'metadata.titleSort,asc-标题(A-Z)', 347 | 'metadata.titleSort,desc-标题(Z-A)' 348 | ], 349 | notShowWhen: null, 350 | showWhen: null 351 | } 352 | ] 353 | } 354 | 355 | search = { 356 | load: async (keyword, options, page) => { 357 | const pageIndex = Math.max(0, (page || 1) - 1) 358 | const sortValue = this.extractOption(options, 0, 'metadata.lastModified,desc') 359 | const query = { 360 | page: pageIndex, 361 | size: 30, 362 | sort: [sortValue] 363 | } 364 | let term = (keyword || '').trim() 365 | const colonIdx = term.indexOf(':') 366 | if (colonIdx > 0) { 367 | const prefix = term.slice(0, colonIdx).toLowerCase() 368 | const value = term.slice(colonIdx + 1).trim() 369 | if (value) { 370 | if (prefix === 'tag') query.tag = [value] 371 | else if (prefix === 'author') query.author = [`${value},`] 372 | else if (prefix === 'language') query.language = [value] 373 | else if (prefix === 'genre') query.genre = [value] 374 | else if (prefix === 'publisher') query.publisher = [value] 375 | else query.search = value 376 | } 377 | term = '' 378 | } 379 | if (term) query.search = term 380 | const list = await this.fetchSeriesList('/api/v1/series', query) 381 | return { 382 | comics: list.comics, 383 | maxPage: Math.max(1, list.totalPages) 384 | } 385 | }, 386 | optionList: [ 387 | { 388 | type: 'select', 389 | options: [ 390 | '*metadata.lastModified,desc-更新时间(新→旧)', 391 | 'metadata.lastModified,asc-更新时间(旧→新)', 392 | 'metadata.titleSort,asc-标题(A-Z)', 393 | 'metadata.titleSort,desc-标题(Z-A)' 394 | ], 395 | label: '排序', 396 | default: null 397 | } 398 | ] 399 | } 400 | 401 | comic = { 402 | loadInfo: async (id) => { 403 | const bookId = this.extractBookId(id) 404 | if (bookId) { 405 | return await this.loadBookDetails(bookId) 406 | } 407 | const [series, booksPage] = await Promise.all([ 408 | this.getJson(`/api/v1/series/${id}`), 409 | this.getJson(`/api/v1/series/${id}/books`, { 410 | unpaged: true, 411 | sort: ['metadata.numberSort,asc'] 412 | }) 413 | ]) 414 | const books = Array.isArray(booksPage?.content) ? booksPage.content : [] 415 | const readable = books.filter((book) => this.isSupportedBook(book)) 416 | readable.sort((a, b) => this.compareBooks(a, b)) 417 | const chapters = new Map() 418 | readable.forEach((book, index) => { 419 | chapters.set(book.id, this.formatBookTitle(book, index)) 420 | }) 421 | const metadata = series?.metadata || {} 422 | const summary = series?.booksMetadata?.summary || metadata.summary || '' 423 | const authors = this.collectAuthors(series?.booksMetadata?.authors) 424 | const genres = Array.isArray(metadata.genres) ? metadata.genres : [] 425 | const tags = Array.isArray(series?.booksMetadata?.tags) ? series.booksMetadata.tags : [] 426 | const description = summary || '暂无简介' 427 | const tagSections = {} 428 | if (authors.length) tagSections['作者'] = authors 429 | if (genres.length) tagSections['类型'] = this.uniqueArray(genres) 430 | if (tags.length) tagSections['标签'] = this.uniqueArray(tags) 431 | if (!readable.length && books.length) { 432 | tagSections['提示'] = ['该系列包含的项目暂不支持阅读'] 433 | } 434 | const info = new ComicDetails({ 435 | title: metadata.title || series?.name || id, 436 | subTitle: authors.slice(0, 3).join(', '), 437 | cover: this.buildUrl(`/api/v1/series/${id}/thumbnail`), 438 | description, 439 | tags: tagSections, 440 | chapters, 441 | updateTime: this.formatDate(series?.lastModified), 442 | uploadTime: this.formatDate(series?.created), 443 | url: series?.url || this.buildUrl(`/series/${id}`) 444 | }) 445 | return info 446 | }, 447 | loadEp: async (comicId, epId) => { 448 | let bookId = epId || comicId 449 | if (typeof bookId === 'string' && bookId.startsWith('book:')) { 450 | bookId = bookId.slice(5) 451 | } 452 | if (typeof comicId === 'string' && comicId.startsWith('book:') && !epId) { 453 | bookId = comicId.slice(5) 454 | } 455 | const pages = await this.getJson(`/api/v1/books/${bookId}/pages`) 456 | const list = Array.isArray(pages) ? pages : [] 457 | list.sort((a, b) => (a?.number ?? 0) - (b?.number ?? 0)) 458 | const zeroBased = list.some((page) => (page?.number ?? 1) === 0) 459 | const images = list 460 | .filter((page) => this.isPageRenderable(page)) 461 | .map((page) => { 462 | const number = page?.number ?? 0 463 | return this.buildUrl(`/api/v1/books/${bookId}/pages/${number}`, zeroBased ? { zero_based: true } : null) 464 | }) 465 | return { images } 466 | }, 467 | onImageLoad: (url) => { 468 | return { 469 | headers: this.imageHeaders 470 | } 471 | }, 472 | onThumbnailLoad: () => { 473 | return { 474 | headers: this.imageHeaders 475 | } 476 | }, 477 | onClickTag: (namespace, tag) => { 478 | if (!tag) throw '无效的标签' 479 | const ns = (namespace || '').toLowerCase() 480 | if (ns === '作者') { 481 | return { 482 | action: 'search', 483 | keyword: `author:${tag}`, 484 | param: null, 485 | } 486 | } 487 | if (ns === '类型' || ns === '标签') { 488 | return { 489 | action: 'category', 490 | keyword: `genre:${tag}`, 491 | param: `${tag}`, 492 | } 493 | } 494 | return { 495 | action: 'search', 496 | keyword: tag, 497 | param: null, 498 | } 499 | }, 500 | enableTagsTranslate: false, 501 | } 502 | 503 | async refreshReferenceData(force) { 504 | const token = this.authToken 505 | if (!token) { 506 | this.saveData('komga_libraries', []) 507 | this.saveData('komga_tags', []) 508 | this.saveData('komga_genres', []) 509 | this.saveData('komga_languages', []) 510 | this.saveData('komga_collections', []) 511 | return 512 | } 513 | const now = Date.now() 514 | const last = this.loadData('komga_meta_ts') 515 | if (!force && last && now - last < 5 * 60 * 1000) return 516 | try { 517 | const [libraries, tags, languages, collections, genres] = await Promise.all([ 518 | this.getJson('/api/v1/libraries'), 519 | this.getJson('/api/v1/tags/series'), 520 | this.getJson('/api/v1/languages'), 521 | this.getJson('/api/v1/collections', { unpaged: true, sort: ['name,asc'] }), 522 | this.getJson('/api/v1/genres') 523 | ]) 524 | const libraryList = Array.isArray(libraries) ? libraries.filter((library) => library && library.id) : [] 525 | const collectionPage = collections && typeof collections === 'object' ? collections : null 526 | const collectionList = Array.isArray(collectionPage?.content) ? collectionPage.content : Array.isArray(collections) ? collections : [] 527 | this.saveData('komga_libraries', libraryList) 528 | this.saveData('komga_tags', Array.isArray(tags) ? tags : []) 529 | this.saveData('komga_genres', Array.isArray(genres) ? genres : []) 530 | this.saveData('komga_languages', Array.isArray(languages) ? languages : []) 531 | this.saveData('komga_collections', collectionList) 532 | this.saveData('komga_meta_ts', now) 533 | } catch (error) { 534 | this.saveData('komga_libraries', []) 535 | this.saveData('komga_tags', []) 536 | this.saveData('komga_genres', []) 537 | this.saveData('komga_languages', []) 538 | this.saveData('komga_collections', []) 539 | if (String(error) === 'Login expired') throw error 540 | } 541 | } 542 | 543 | async fetchSeriesList(path, query) { 544 | const data = await this.getJson(path, query) 545 | const content = Array.isArray(data?.content) ? data.content : [] 546 | const comics = content.map((item) => this.parseSeries(item)).filter(Boolean) 547 | return { 548 | comics, 549 | totalPages: data?.totalPages ?? 1 550 | } 551 | } 552 | 553 | async fetchBookList(path, query) { 554 | const data = await this.getJson(path, query) 555 | const content = Array.isArray(data?.content) ? data.content : [] 556 | const comics = content.map((item) => this.parseBook(item)).filter(Boolean) 557 | return { 558 | comics, 559 | totalPages: data?.totalPages ?? 1 560 | } 561 | } 562 | 563 | parseBook(book) { 564 | if (!book || !this.isSupportedBook(book)) return null 565 | const metadata = book.metadata || {} 566 | const title = metadata.title || book.name || book.id 567 | const authors = this.collectAuthors(metadata.authors) 568 | const tags = Array.isArray(metadata.tags) ? metadata.tags : [] 569 | const description = metadata.summary || '' 570 | const subtitleParts = [] 571 | if (book.seriesTitle) subtitleParts.push(book.seriesTitle) 572 | if (authors.length) subtitleParts.push(authors[0]) 573 | return new Comic({ 574 | id: `book:${book.id}`, 575 | title, 576 | subTitle: subtitleParts.join(' · '), 577 | cover: this.buildUrl(`/api/v1/books/${book.id}/thumbnail`), 578 | tags: this.uniqueArray(tags).slice(0, 12), 579 | description, 580 | }) 581 | } 582 | 583 | extractBookId(id) { 584 | if (typeof id !== 'string') return null 585 | return id.startsWith('book:') ? id.slice(5) : null 586 | } 587 | 588 | async loadBookDetails(bookId) { 589 | const book = await this.getJson(`/api/v1/books/${bookId}`) 590 | if (!book) throw '未找到该图书' 591 | const metadata = book.metadata || {} 592 | const authors = this.collectAuthors(metadata.authors) 593 | const tags = this.uniqueArray(Array.isArray(metadata.tags) ? metadata.tags : []) 594 | const description = metadata.summary || '暂无简介' 595 | const tagSections = {} 596 | if (authors.length) tagSections['作者'] = authors 597 | if (tags.length) tagSections['标签'] = tags 598 | if (book.seriesTitle) tagSections['系列'] = [book.seriesTitle] 599 | if (!this.isSupportedBook(book)) tagSections['提示'] = ['该图书暂不支持阅读'] 600 | const chapters = new Map() 601 | const chapterTitle = metadata.title || book.name || '立即阅读' 602 | chapters.set(book.id, chapterTitle) 603 | return new ComicDetails({ 604 | title: metadata.title || book.name || bookId, 605 | subTitle: book.seriesTitle || authors.slice(0, 3).join(', '), 606 | cover: this.buildUrl(`/api/v1/books/${bookId}/thumbnail`), 607 | description, 608 | tags: tagSections, 609 | chapters, 610 | updateTime: this.formatDate(book.lastModified), 611 | uploadTime: this.formatDate(book.created), 612 | url: book.url || this.buildUrl(`/books/${bookId}`) 613 | }) 614 | } 615 | 616 | parseSeries(series) { 617 | if (!series) return null 618 | const metadata = series.metadata || {} 619 | const title = metadata.title || series.name || series.id 620 | const authors = this.collectAuthors(series?.booksMetadata?.authors) 621 | const tags = [] 622 | if (Array.isArray(metadata.genres)) tags.push(...metadata.genres) 623 | if (Array.isArray(series?.booksMetadata?.tags)) tags.push(...series.booksMetadata.tags) 624 | const description = series?.booksMetadata?.summary || metadata.summary || '' 625 | return new Comic({ 626 | id: series.id, 627 | title, 628 | subTitle: authors.slice(0, 2).join(', '), 629 | cover: this.buildUrl(`/api/v1/series/${series.id}/thumbnail`), 630 | tags: this.uniqueArray(tags).slice(0, 12), 631 | description, 632 | }) 633 | } 634 | 635 | collectAuthors(authors) { 636 | if (!Array.isArray(authors)) return [] 637 | return this.uniqueArray(authors.map((author) => author?.name).filter(Boolean)) 638 | } 639 | 640 | uniqueArray(list) { 641 | if (!Array.isArray(list)) return [] 642 | const set = new Set() 643 | const result = [] 644 | for (const item of list) { 645 | const value = typeof item === 'string' ? item.trim() : '' 646 | if (!value) continue 647 | const key = value.toLowerCase() 648 | if (set.has(key)) continue 649 | set.add(key) 650 | result.push(value) 651 | } 652 | return result 653 | } 654 | 655 | isSupportedBook(book) { 656 | if (!book || !book.media) return false 657 | const status = String(book.media.status || '').toUpperCase() 658 | if (status && status !== 'READY') return false 659 | const mediaType = String(book.media.mediaType || '').toLowerCase() 660 | if (!mediaType) return false 661 | if (mediaType.includes('epub') || mediaType.includes('pdf') || mediaType.includes('mobi')) return false 662 | if ((book.media.pagesCount || 0) <= 0) return false 663 | return true 664 | } 665 | 666 | isPageRenderable(page) { 667 | if (!page) return false 668 | const mediaType = String(page.mediaType || '').toLowerCase() 669 | if (!mediaType) return true 670 | return mediaType.startsWith('image/') || mediaType.includes('jpeg') || mediaType.includes('png') || mediaType.includes('webp') 671 | } 672 | 673 | compareBooks(a, b) { 674 | const aSort = typeof a?.metadata?.numberSort === 'number' ? a.metadata.numberSort : NaN 675 | const bSort = typeof b?.metadata?.numberSort === 'number' ? b.metadata.numberSort : NaN 676 | if (!Number.isNaN(aSort) && !Number.isNaN(bSort)) return aSort - bSort 677 | const aNumber = parseFloat(a?.metadata?.number) 678 | const bNumber = parseFloat(b?.metadata?.number) 679 | if (!Number.isNaN(aNumber) && !Number.isNaN(bNumber)) return aNumber - bNumber 680 | return (a?.metadata?.title || a?.name || '').localeCompare(b?.metadata?.title || b?.name || '') 681 | } 682 | 683 | formatBookTitle(book, index) { 684 | const metadata = book?.metadata || {} 685 | if (metadata.title) return metadata.title 686 | if (metadata.number) return `第${metadata.number}卷` 687 | if (book?.number != null) return `第${book.number}卷` 688 | return `章节 ${index + 1}` 689 | } 690 | 691 | extractOption(options, index, fallback) { 692 | if (!Array.isArray(options) || options.length <= index) return fallback 693 | let value = options[index] 694 | if (typeof value !== 'string') return fallback 695 | if (value.startsWith('*')) value = value.slice(1) 696 | const idx = value.indexOf('-') 697 | return idx > -1 ? value.slice(0, idx) : value 698 | } 699 | 700 | async getJson(path, query) { 701 | const res = await Network.get(this.buildUrl(path, query), this.headers) 702 | this.ensureOk(res) 703 | const text = res.body 704 | if (!text) return null 705 | return JSON.parse(text) 706 | } 707 | 708 | ensureOk(res) { 709 | if (!res) throw '请求失败' 710 | if (res.status === 401 || res.status === 403) throw 'Login expired' 711 | if (res.status < 200 || res.status >= 300) throw `请求失败: ${res.status}` 712 | } 713 | 714 | buildUrl(path, query) { 715 | let url = path 716 | if (!/^https?:\/\//i.test(path)) { 717 | url = `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}` 718 | } 719 | const qs = this.buildQuery(query) 720 | return qs ? `${url}?${qs}` : url 721 | } 722 | 723 | buildQuery(query) { 724 | if (!query) return '' 725 | const parts = [] 726 | for (const key of Object.keys(query)) { 727 | const value = query[key] 728 | if (value === undefined || value === null) continue 729 | if (Array.isArray(value)) { 730 | for (const item of value) { 731 | if (item === undefined || item === null) continue 732 | parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(item))}`) 733 | } 734 | } else { 735 | parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) 736 | } 737 | } 738 | return parts.join('&') 739 | } 740 | 741 | formatDate(value) { 742 | if (!value) return null 743 | try { 744 | const date = new Date(value) 745 | if (Number.isNaN(date.getTime())) return null 746 | return date.toISOString().split('T')[0] 747 | } catch (_) { 748 | return null 749 | } 750 | } 751 | } 752 | 753 | --------------------------------------------------------------------------------