├── .gitignore ├── README.md ├── _template_.js ├── _venera_.js ├── baozi.js ├── copy_manga.js ├── ehentai.js ├── ikmmh.js ├── index.json ├── jm.js ├── komiic.js ├── manga_dex.js ├── nhentai.js ├── picacg.js ├── shonenjumpplus.js └── wnacg.js /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /_template_.js: -------------------------------------------------------------------------------- 1 | /** @type {import('./_venera_.js')} */ 2 | class NewComicSource 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 = "" 10 | 11 | version = "1.0.0" 12 | 13 | minAppVersion = "1.4.0" 14 | 15 | // update url 16 | url = "" 17 | 18 | /** 19 | * [Optional] init function 20 | */ 21 | init() { 22 | 23 | } 24 | 25 | // [Optional] account related 26 | account = { 27 | /** 28 | * [Optional] login with account and password, return any value to indicate success 29 | * @param account {string} 30 | * @param pwd {string} 31 | * @returns {Promise} 32 | */ 33 | login: async (account, pwd) => { 34 | /* 35 | Use Network to send request 36 | Use this.saveData to save data 37 | `account` and `pwd` will be saved to local storage automatically if login success 38 | ``` 39 | let res = await Network.post('https://example.com/login', { 40 | 'content-type': 'application/x-www-form-urlencoded;charset=utf-8' 41 | }, `account=${account}&password=${pwd}`) 42 | 43 | if(res.status == 200) { 44 | let json = JSON.parse(res.body) 45 | this.saveData('token', json.token) 46 | return 'ok' 47 | } 48 | 49 | throw 'Failed to login' 50 | ``` 51 | */ 52 | 53 | }, 54 | 55 | /** 56 | * [Optional] login with webview 57 | */ 58 | loginWithWebview: { 59 | url: "", 60 | /** 61 | * check login status 62 | * @param url {string} - current url 63 | * @param title {string} - current title 64 | * @returns {boolean} - return true if login success 65 | */ 66 | checkStatus: (url, title) => { 67 | 68 | }, 69 | /** 70 | * [Optional] Callback when login success 71 | */ 72 | onLoginSuccess: () => { 73 | 74 | }, 75 | }, 76 | 77 | /** 78 | * [Optional] login with cookies 79 | * Note: If `this.account.login` is implemented, this will be ignored 80 | */ 81 | loginWithCookies: { 82 | fields: [ 83 | "ipb_member_id", 84 | "ipb_pass_hash", 85 | "igneous", 86 | "star", 87 | ], 88 | /** 89 | * Validate cookies, return false if cookies are invalid. 90 | * 91 | * Use `Network.setCookies` to set cookies before validate. 92 | * @param values {string[]} - same order as `fields` 93 | * @returns {Promise} 94 | */ 95 | validate: async (values) => { 96 | 97 | }, 98 | }, 99 | 100 | /** 101 | * logout function, clear account related data 102 | */ 103 | logout: () => { 104 | /* 105 | ``` 106 | this.deleteData('token') 107 | Network.deleteCookies('https://example.com') 108 | ``` 109 | */ 110 | }, 111 | 112 | // {string?} - register url 113 | registerWebsite: null 114 | } 115 | 116 | // explore page list 117 | explore = [ 118 | { 119 | // title of the page. 120 | // title is used to identify the page, it should be unique 121 | title: "", 122 | 123 | /// multiPartPage or multiPageComicList or mixed 124 | type: "multiPartPage", 125 | 126 | /** 127 | * load function 128 | * @param page {number | null} - page number, null for `singlePageWithMultiPart` type 129 | * @returns {{}} 130 | * - for `multiPartPage` type, return [{title: string, comics: Comic[], viewMore: PageJumpTarget}] 131 | * - for `multiPageComicList` type, for each page(1-based), return {comics: Comic[], maxPage: number} 132 | * - 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?} 133 | */ 134 | load: async (page) => { 135 | /* 136 | ``` 137 | let res = await Network.get("https://example.com") 138 | 139 | if (res.status !== 200) { 140 | throw `Invalid status code: ${res.status}` 141 | } 142 | 143 | let data = JSON.parse(res.body) 144 | 145 | function parseComic(comic) { 146 | // ... 147 | 148 | return new Comic({ 149 | id: id, 150 | title: title, 151 | subTitle: author, 152 | cover: cover, 153 | tags: tags, 154 | description: description 155 | }) 156 | } 157 | 158 | let comics = {} 159 | comics["hot"] = data["results"]["recComics"].map(parseComic) 160 | comics["latest"] = data["results"]["newComics"].map(parseComic) 161 | 162 | return comics 163 | ``` 164 | */ 165 | }, 166 | 167 | /** 168 | * Only use for `multiPageComicList` type. 169 | * `loadNext` would be ignored if `load` function is implemented. 170 | * @param next {string | null} - next page token, null if first page 171 | * @returns {Promise<{comics: Comic[], next: string?}>} - next is null if no next page. 172 | */ 173 | loadNext(next) {}, 174 | } 175 | ] 176 | 177 | // categories 178 | category = { 179 | /// title of the category page, used to identify the page, it should be unique 180 | title: "", 181 | parts: [ 182 | { 183 | // title of the part 184 | name: "Theme", 185 | 186 | // fixed or random or dynamic 187 | // if random, need to provide `randomNumber` field, which indicates the number of comics to display at the same time 188 | // if dynamic, need to provide `loader` field, which indicates the function to load comics 189 | type: "fixed", 190 | 191 | // Remove this if type is dynamic 192 | categories: [ 193 | { 194 | label: "Category1", 195 | /** 196 | * @type {PageJumpTarget} 197 | */ 198 | target: { 199 | page: "category", 200 | attributes: { 201 | category: "category1", 202 | param: null, 203 | }, 204 | }, 205 | }, 206 | ] 207 | 208 | // number of comics to display at the same time 209 | // randomNumber: 5, 210 | 211 | // load function for dynamic type 212 | // loader: async () => { 213 | // return [ 214 | // // ... 215 | // ] 216 | // } 217 | } 218 | ], 219 | // enable ranking page 220 | enableRankingPage: false, 221 | } 222 | 223 | /// category comic loading related 224 | categoryComics = { 225 | /** 226 | * load comics of a category 227 | * @param category {string} - category name 228 | * @param param {string?} - category param 229 | * @param options {string[]} - options from optionList 230 | * @param page {number} - page number 231 | * @returns {Promise<{comics: Comic[], maxPage: number}>} 232 | */ 233 | load: async (category, param, options, page) => { 234 | /* 235 | ``` 236 | let data = JSON.parse((await Network.get('...')).body) 237 | let maxPage = data.maxPage 238 | 239 | function parseComic(comic) { 240 | // ... 241 | 242 | return new Comic({ 243 | id: id, 244 | title: title, 245 | subTitle: author, 246 | cover: cover, 247 | tags: tags, 248 | description: description 249 | }) 250 | } 251 | 252 | return { 253 | comics: data.list.map(parseComic), 254 | maxPage: maxPage 255 | } 256 | ``` 257 | */ 258 | }, 259 | // provide options for category comic loading 260 | optionList: [ 261 | { 262 | // For a single option, use `-` to separate the value and text, left for value, right for text 263 | options: [ 264 | "newToOld-New to Old", 265 | "oldToNew-Old to New" 266 | ], 267 | // [Optional] {string[]} - show this option only when the value not in the list 268 | notShowWhen: null, 269 | // [Optional] {string[]} - show this option only when the value in the list 270 | showWhen: null 271 | } 272 | ], 273 | ranking: { 274 | // For a single option, use `-` to separate the value and text, left for value, right for text 275 | options: [ 276 | "day-Day", 277 | "week-Week" 278 | ], 279 | /** 280 | * load ranking comics 281 | * @param option {string} - option from optionList 282 | * @param page {number} - page number 283 | * @returns {Promise<{comics: Comic[], maxPage: number}>} 284 | */ 285 | load: async (option, page) => { 286 | /* 287 | ``` 288 | let data = JSON.parse((await Network.get('...')).body) 289 | let maxPage = data.maxPage 290 | 291 | function parseComic(comic) { 292 | // ... 293 | 294 | return new Comic({ 295 | id: id, 296 | title: title, 297 | subTitle: author, 298 | cover: cover, 299 | tags: tags, 300 | description: description 301 | }) 302 | } 303 | 304 | return { 305 | comics: data.list.map(parseComic), 306 | maxPage: maxPage 307 | } 308 | ``` 309 | */ 310 | } 311 | } 312 | } 313 | 314 | /// search related 315 | search = { 316 | /** 317 | * load search result 318 | * @param keyword {string} 319 | * @param options {string[]} - options from optionList 320 | * @param page {number} 321 | * @returns {Promise<{comics: Comic[], maxPage: number}>} 322 | */ 323 | load: async (keyword, options, page) => { 324 | /* 325 | ``` 326 | let data = JSON.parse((await Network.get('...')).body) 327 | let maxPage = data.maxPage 328 | 329 | function parseComic(comic) { 330 | // ... 331 | 332 | return new Comic({ 333 | id: id, 334 | title: title, 335 | subTitle: author, 336 | cover: cover, 337 | tags: tags, 338 | description: description 339 | }) 340 | } 341 | 342 | return { 343 | comics: data.list.map(parseComic), 344 | maxPage: maxPage 345 | } 346 | ``` 347 | */ 348 | }, 349 | 350 | /** 351 | * load search result with next page token. 352 | * The field will be ignored if `load` function is implemented. 353 | * @param keyword {string} 354 | * @param options {(string)[]} - options from optionList 355 | * @param next {string | null} 356 | * @returns {Promise<{comics: Comic[], maxPage: number}>} 357 | */ 358 | loadNext: async (keyword, options, next) => { 359 | 360 | }, 361 | 362 | // provide options for search 363 | optionList: [ 364 | { 365 | // [Optional] default is `select` 366 | // type: select, multi-select, dropdown 367 | // For select, there is only one selected value 368 | // For multi-select, there are multiple selected values or none. The `load` function will receive a json string which is an array of selected values 369 | // For dropdown, there is one selected value at most. If no selected value, the `load` function will receive a null 370 | type: "select", 371 | // For a single option, use `-` to separate the value and text, left for value, right for text 372 | options: [ 373 | "0-time", 374 | "1-popular" 375 | ], 376 | // option label 377 | label: "sort", 378 | // default selected options. If not set, use the first option as default 379 | default: null, 380 | } 381 | ], 382 | 383 | // enable tags suggestions 384 | enableTagsSuggestions: false, 385 | } 386 | 387 | // favorite related 388 | favorites = { 389 | // whether support multi folders 390 | multiFolder: false, 391 | /** 392 | * add or delete favorite. 393 | * throw `Login expired` to indicate login expired, App will automatically re-login and re-add/delete favorite 394 | * @param comicId {string} 395 | * @param folderId {string} 396 | * @param isAdding {boolean} - true for add, false for delete 397 | * @param favoriteId {string?} - [Comic.favoriteId] 398 | * @returns {Promise} - return any value to indicate success 399 | */ 400 | addOrDelFavorite: async (comicId, folderId, isAdding, favoriteId) => { 401 | /* 402 | ``` 403 | let res = await Network.post('...') 404 | if (res.status === 401) { 405 | throw `Login expired`; 406 | } 407 | return 'ok' 408 | ``` 409 | */ 410 | }, 411 | /** 412 | * load favorite folders. 413 | * throw `Login expired` to indicate login expired, App will automatically re-login retry. 414 | * if comicId is not null, return favorite folders which contains the comic. 415 | * @param comicId {string?} 416 | * @returns {Promise<{folders: {[p: string]: string}, favorited: string[]}>} - `folders` is a map of folder id to folder name, `favorited` is a list of folder id which contains the comic 417 | */ 418 | loadFolders: async (comicId) => { 419 | /* 420 | ``` 421 | let data = JSON.parse((await Network.get('...')).body) 422 | 423 | let folders = {} 424 | 425 | data.folders.forEach((f) => { 426 | folders[f.id] = f.name 427 | }) 428 | 429 | return { 430 | folders: folders, 431 | favorited: data.favorited 432 | } 433 | ``` 434 | */ 435 | }, 436 | /** 437 | * add a folder 438 | * @param name {string} 439 | * @returns {Promise} - return any value to indicate success 440 | */ 441 | addFolder: async (name) => { 442 | /* 443 | ``` 444 | let res = await Network.post('...') 445 | if (res.status === 401) { 446 | throw `Login expired`; 447 | } 448 | return 'ok' 449 | ``` 450 | */ 451 | }, 452 | /** 453 | * delete a folder 454 | * @param folderId {string} 455 | * @returns {Promise} - return any value to indicate success 456 | */ 457 | deleteFolder: async (folderId) => { 458 | /* 459 | ``` 460 | let res = await Network.delete('...') 461 | if (res.status === 401) { 462 | throw `Login expired`; 463 | } 464 | return 'ok' 465 | ``` 466 | */ 467 | }, 468 | /** 469 | * load comics in a folder 470 | * throw `Login expired` to indicate login expired, App will automatically re-login retry. 471 | * @param page {number} 472 | * @param folder {string?} - folder id, null for non-multi-folder 473 | * @returns {Promise<{comics: Comic[], maxPage: number}>} 474 | */ 475 | loadComics: async (page, folder) => { 476 | /* 477 | ``` 478 | let data = JSON.parse((await Network.get('...')).body) 479 | let maxPage = data.maxPage 480 | 481 | function parseComic(comic) { 482 | // ... 483 | 484 | return new Comic{ 485 | id: id, 486 | title: title, 487 | subTitle: author, 488 | cover: cover, 489 | tags: tags, 490 | description: description 491 | } 492 | } 493 | 494 | return { 495 | comics: data.list.map(parseComic), 496 | maxPage: maxPage 497 | } 498 | ``` 499 | */ 500 | }, 501 | /** 502 | * load comics with next page token 503 | * @param next {string | null} - next page token, null for first page 504 | * @param folder {string} 505 | * @returns {Promise<{comics: Comic[], next: string?}>} 506 | */ 507 | loadNext: async (next, folder) => { 508 | 509 | }, 510 | /** 511 | * If the comic source only allows one comic in one folder, set this to true. 512 | */ 513 | singleFolderForSingleComic: false, 514 | } 515 | 516 | /// single comic related 517 | comic = { 518 | /** 519 | * load comic info 520 | * @param id {string} 521 | * @returns {Promise} 522 | */ 523 | loadInfo: async (id) => { 524 | 525 | }, 526 | /** 527 | * [Optional] load thumbnails of a comic 528 | * 529 | * To render a part of an image as thumbnail, return `${url}@x=${start}-${end}&y=${start}-${end}` 530 | * - If width is not provided, use full width 531 | * - If height is not provided, use full height 532 | * @param id {string} 533 | * @param next {string?} - next page token, null for first page 534 | * @returns {Promise<{thumbnails: string[], next: string?}>} - `next` is next page token, null for no more 535 | */ 536 | loadThumbnails: async (id, next) => { 537 | /* 538 | ``` 539 | let data = JSON.parse((await Network.get('...')).body) 540 | 541 | return { 542 | thumbnails: data.list, 543 | next: next, 544 | } 545 | ``` 546 | */ 547 | }, 548 | 549 | /** 550 | * rate a comic 551 | * @param id 552 | * @param rating {number} - [0-10] app use 5 stars, 1 rating = 0.5 stars, 553 | * @returns {Promise} - return any value to indicate success 554 | */ 555 | starRating: async (id, rating) => { 556 | 557 | }, 558 | 559 | /** 560 | * load images of a chapter 561 | * @param comicId {string} 562 | * @param epId {string?} 563 | * @returns {Promise<{images: string[]}>} 564 | */ 565 | loadEp: async (comicId, epId) => { 566 | /* 567 | ``` 568 | return { 569 | // string[] 570 | images: images 571 | } 572 | ``` 573 | */ 574 | }, 575 | /** 576 | * [Optional] provide configs for an image loading 577 | * @param url 578 | * @param comicId 579 | * @param epId 580 | * @returns {ImageLoadingConfig | Promise} 581 | */ 582 | onImageLoad: (url, comicId, epId) => { 583 | return {} 584 | }, 585 | /** 586 | * [Optional] provide configs for a thumbnail loading 587 | * @param url {string} 588 | * @returns {ImageLoadingConfig | Promise} 589 | * 590 | * `ImageLoadingConfig.modifyImage` and `ImageLoadingConfig.onLoadFailed` will be ignored. 591 | * They are not supported for thumbnails. 592 | */ 593 | onThumbnailLoad: (url) => { 594 | return {} 595 | }, 596 | /** 597 | * [Optional] like or unlike a comic 598 | * @param id {string} 599 | * @param isLike {boolean} - true for like, false for unlike 600 | * @returns {Promise} 601 | */ 602 | likeComic: async (id, isLike) => { 603 | 604 | }, 605 | /** 606 | * [Optional] load comments 607 | * 608 | * Since app version 1.0.6, rich text is supported in comments. 609 | * Following html tags are supported: ['a', 'b', 'i', 'u', 's', 'br', 'span', 'img']. 610 | * span tag supports style attribute, but only support font-weight, font-style, text-decoration. 611 | * All images will be placed at the end of the comment. 612 | * Auto link detection is enabled, but only http/https links are supported. 613 | * @param comicId {string} 614 | * @param subId {string?} - ComicDetails.subId 615 | * @param page {number} 616 | * @param replyTo {string?} - commentId to reply, not null when reply to a comment 617 | * @returns {Promise<{comments: Comment[], maxPage: number?}>} 618 | */ 619 | loadComments: async (comicId, subId, page, replyTo) => { 620 | /* 621 | ``` 622 | // ... 623 | 624 | return { 625 | comments: data.results.list.map(e => { 626 | return new Comment({ 627 | // string 628 | userName: e.user_name, 629 | // string 630 | avatar: e.user_avatar, 631 | // string 632 | content: e.comment, 633 | // string? 634 | time: e.create_at, 635 | // number? 636 | replyCount: e.count, 637 | // string 638 | id: e.id, 639 | }) 640 | }), 641 | // number 642 | maxPage: data.results.maxPage, 643 | } 644 | ``` 645 | */ 646 | }, 647 | /** 648 | * [Optional] send a comment, return any value to indicate success 649 | * @param comicId {string} 650 | * @param subId {string?} - ComicDetails.subId 651 | * @param content {string} 652 | * @param replyTo {string?} - commentId to reply, not null when reply to a comment 653 | * @returns {Promise} 654 | */ 655 | sendComment: async (comicId, subId, content, replyTo) => { 656 | 657 | }, 658 | /** 659 | * [Optional] like or unlike a comment 660 | * @param comicId {string} 661 | * @param subId {string?} - ComicDetails.subId 662 | * @param commentId {string} 663 | * @param isLike {boolean} - true for like, false for unlike 664 | * @returns {Promise} 665 | */ 666 | likeComment: async (comicId, subId, commentId, isLike) => { 667 | 668 | }, 669 | /** 670 | * [Optional] vote a comment 671 | * @param id {string} - comicId 672 | * @param subId {string?} - ComicDetails.subId 673 | * @param commentId {string} - commentId 674 | * @param isUp {boolean} - true for up, false for down 675 | * @param isCancel {boolean} - true for cancel, false for vote 676 | * @returns {Promise} - new score 677 | */ 678 | voteComment: async (id, subId, commentId, isUp, isCancel) => { 679 | 680 | }, 681 | // {string?} - regex string, used to identify comic id from user input 682 | idMatch: null, 683 | /** 684 | * [Optional] Handle tag click event 685 | * @param namespace {string} 686 | * @param tag {string} 687 | * @returns {PageJumpTarget} 688 | */ 689 | onClickTag: (namespace, tag) => { 690 | /* 691 | ``` 692 | return new PageJumpTarget({ 693 | page: 'search', 694 | keyword: tag, 695 | }) 696 | ``` 697 | */ 698 | }, 699 | /** 700 | * [Optional] Handle links 701 | */ 702 | link: { 703 | /** 704 | * set accepted domains 705 | */ 706 | domains: [ 707 | 'example.com' 708 | ], 709 | /** 710 | * parse url to comic id 711 | * @param url {string} 712 | * @returns {string | null} 713 | */ 714 | linkToId: (url) => { 715 | 716 | } 717 | }, 718 | // enable tags translate 719 | enableTagsTranslate: false, 720 | } 721 | 722 | 723 | /* 724 | [Optional] settings related 725 | Use this.loadSetting to load setting 726 | ``` 727 | let setting1Value = this.loadSetting('setting1') 728 | console.log(setting1Value) 729 | ``` 730 | */ 731 | settings = { 732 | setting1: { 733 | // title 734 | title: "Setting1", 735 | // type: input, select, switch 736 | type: "select", 737 | // options 738 | options: [ 739 | { 740 | // value 741 | value: 'o1', 742 | // [Optional] text, if not set, use value as text 743 | text: 'Option 1', 744 | }, 745 | ], 746 | default: 'o1', 747 | }, 748 | setting2: { 749 | title: "Setting2", 750 | type: "switch", 751 | default: true, 752 | }, 753 | setting3: { 754 | title: "Setting3", 755 | type: "input", 756 | validator: null, // string | null, regex string 757 | default: '', 758 | }, 759 | setting4: { 760 | title: "Setting4", 761 | type: "callback", 762 | buttonText: "Click me", 763 | /** 764 | * callback function 765 | * 766 | * If the callback function returns a Promise, the button will show a loading indicator until the promise is resolved. 767 | * @returns {void | Promise} 768 | */ 769 | callback: () => { 770 | // do something 771 | } 772 | } 773 | } 774 | 775 | // [Optional] translations for the strings in this config 776 | translation = { 777 | 'zh_CN': { 778 | 'Setting1': '设置1', 779 | 'Setting2': '设置2', 780 | 'Setting3': '设置3', 781 | }, 782 | 'zh_TW': {}, 783 | 'en': {} 784 | } 785 | } -------------------------------------------------------------------------------- /baozi.js: -------------------------------------------------------------------------------- 1 | class Baozi extends ComicSource { 2 | // 此漫画源的名称 3 | name = "包子漫画" 4 | 5 | // 唯一标识符 6 | key = "baozi" 7 | 8 | version = "1.0.4" 9 | 10 | minAppVersion = "1.0.0" 11 | 12 | // 更新链接 13 | url = "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@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: "baozimhcn.com" }, 30 | { value: "webmota.com" }, 31 | { value: "kukuc.co" }, 32 | { value: "twmanga.com" }, 33 | { value: "dinnerku.com" } 34 | ], 35 | default: "baozimhcn.com" 36 | } 37 | } 38 | 39 | // 动态生成完整域名 40 | get lang() { 41 | return this.loadSetting('language') || this.settings.language.default; 42 | } 43 | get baseUrl() { 44 | let domain = this.loadSetting('domains') || this.settings.domains.default; 45 | return `https://${this.lang}.${domain}`; 46 | } 47 | 48 | /// 账号 49 | /// 设置为null禁用账号功能 50 | account = { 51 | /// 登录 52 | /// 返回任意值表示登录成功 53 | login: async (account, pwd) => { 54 | let res = await Network.post(`${this.baseUrl}/api/bui/signin`, { 55 | 'content-type': 'multipart/form-data; boundary=----WebKitFormBoundaryFUNUxpOwyUaDop8s' 56 | }, "------WebKitFormBoundaryFUNUxpOwyUaDop8s\r\nContent-Disposition: form-data; name=\"username\"\r\n\r\n" + account + "\r\n------WebKitFormBoundaryFUNUxpOwyUaDop8s\r\nContent-Disposition: form-data; name=\"password\"\r\n\r\n" + pwd + "\r\n------WebKitFormBoundaryFUNUxpOwyUaDop8s--\r\n") 57 | if (res.status !== 200) { 58 | throw "Invalid status code: " + res.status 59 | } 60 | let json = JSON.parse(res.body) 61 | let token = json.data 62 | Network.setCookies(this.baseUrl, [ 63 | new Cookie({ 64 | name: 'TSID', 65 | value: token, 66 | domain: this.loadSetting('domains') || this.settings.domains.default 67 | }), 68 | ]) 69 | return 'ok' 70 | }, 71 | 72 | // 退出登录时将会调用此函数 73 | logout: function() { 74 | Network.deleteCookies(this.loadSetting('domains') || this.settings.domains.default) 75 | }, 76 | 77 | get registerWebsite() { 78 | return `${this.baseUrl}/user/signup` 79 | } 80 | } 81 | 82 | /// 解析漫画列表 83 | parseComic(e) { 84 | let url = e.querySelector("a").attributes['href'] 85 | let id = url.split("/").pop() 86 | let title = e.querySelector("h3").text.trim() 87 | let cover = e.querySelector("a > amp-img").attributes["src"] 88 | let tags = e.querySelectorAll("div.tabs > span").map(e => e.text.trim()) 89 | let description = e.querySelector("small").text.trim() 90 | return { 91 | id: id, 92 | title: title, 93 | cover: cover, 94 | tags: tags, 95 | description: description 96 | } 97 | } 98 | 99 | parseJsonComic(e) { 100 | return { 101 | id: e.comic_id, 102 | title: e.name, 103 | subTitle: e.author, 104 | cover: `https://static-tw.baozimh.com/cover/${e.topic_img}?w=285&h=375&q=100`, 105 | tags: e.type_names, 106 | } 107 | } 108 | 109 | /// 探索页面 110 | /// 一个漫画源可以有多个探索页面 111 | explore = [{ 112 | /// 标题 113 | /// 标题同时用作标识符, 不能重复 114 | title: "包子漫画", 115 | 116 | /// singlePageWithMultiPart 或者 multiPageComicList 117 | type: "singlePageWithMultiPart", 118 | 119 | load: async () => { 120 | var res = await Network.get(this.baseUrl) 121 | if (res.status !== 200) { 122 | throw "Invalid status code: " + res.status 123 | } 124 | let document = new HtmlDocument(res.body) 125 | let parts = document.querySelectorAll("div.index-recommend-items") 126 | let result = {} 127 | for (let part of parts) { 128 | let title = part.querySelector("div.catalog-title").text.trim() 129 | let comics = part.querySelectorAll("div.comics-card").map(e => this.parseComic(e)) 130 | if (comics.length > 0) { 131 | result[title] = comics 132 | } 133 | } 134 | return result 135 | } 136 | } 137 | ] 138 | 139 | /// 分类页面 140 | /// 一个漫画源只能有一个分类页面, 也可以没有, 设置为null禁用分类页面 141 | category = { 142 | /// 标题, 同时为标识符, 不能与其他漫画源的分类页面重复 143 | title: "包子漫画", 144 | parts: [{ 145 | name: "类型", 146 | 147 | // fixed 或者 random 148 | // random用于分类数量相当多时, 随机显示其中一部分 149 | type: "fixed", 150 | 151 | // 如果类型为random, 需要提供此字段, 表示同时显示的数量 152 | // randomNumber: 5, 153 | 154 | categories: ['全部', '恋爱', '纯爱', '古风', '异能', '悬疑', '剧情', '科幻', '奇幻', '玄幻', '穿越', '冒险', '推理', '武侠', '格斗', '战争', '热血', '搞笑', '大女主', '都市', '总裁', '后宫', '日常', '韩漫', '少年', '其它'], 155 | 156 | // category或者search 157 | // 如果为category, 点击后将进入分类漫画页面, 使用下方的`categoryComics`加载漫画 158 | // 如果为search, 将进入搜索页面 159 | itemType: "category", 160 | 161 | // 若提供, 数量需要和`categories`一致, `categoryComics.load`方法将会收到此参数 162 | categoryParams: ['all', 'lianai', 'chunai', 'gufeng', 'yineng', 'xuanyi', 'juqing', 'kehuan', 'qihuan', 'xuanhuan', 'chuanyue', 'mouxian', 'tuili', 'wuxia', 'gedou', 'zhanzheng', 'rexie', 'gaoxiao', 'danuzhu', 'dushi', 'zongcai', 'hougong', 'richang', 'hanman', 'shaonian', 'qita'] 163 | } 164 | ], 165 | enableRankingPage: false, 166 | } 167 | 168 | /// 分类漫画页面, 即点击分类标签后进入的页面 169 | categoryComics = { 170 | load: async (category, param, options, page) => { 171 | let res = await Network.get(`${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}`) 172 | if (res.status !== 200) { 173 | throw "Invalid status code: " + res.status 174 | } 175 | let maxPage = null 176 | let json = JSON.parse(res.body) 177 | if (!json.next) { 178 | maxPage = page 179 | } 180 | return { 181 | comics: json.items.map(e => this.parseJsonComic(e)), 182 | maxPage: maxPage 183 | } 184 | }, 185 | // 提供选项 186 | optionList: [{ 187 | options: [ 188 | "all-全部", 189 | "cn-国漫", 190 | "jp-日本", 191 | "kr-韩国", 192 | "en-欧美", 193 | ], 194 | }, { 195 | options: [ 196 | "all-全部", 197 | "serial-连载中", 198 | "pub-已完结", 199 | ], 200 | }, 201 | ], 202 | } 203 | 204 | /// 搜索 205 | search = { 206 | load: async (keyword, options, page) => { 207 | let res = await Network.get(`${this.baseUrl}/search?q=${keyword}`) 208 | if (res.status !== 200) { 209 | throw "Invalid status code: " + res.status 210 | } 211 | let document = new HtmlDocument(res.body) 212 | let comics = document.querySelectorAll("div.comics-card").map(e => this.parseComic(e)) 213 | return { 214 | comics: comics, 215 | maxPage: 1 216 | } 217 | }, 218 | 219 | // 提供选项 220 | optionList: [] 221 | } 222 | 223 | /// 收藏 224 | favorites = { 225 | /// 是否为多收藏夹 226 | multiFolder: false, 227 | /// 添加或者删除收藏 228 | addOrDelFavorite: async (comicId, folderId, isAdding) => { 229 | if (!isAdding) { 230 | let res = await Network.post(`${this.baseUrl}/user/operation_v2?op=del_bookmark&comic_id=${comicId}`) 231 | if (!res.status || res.status >= 400) { 232 | throw "Invalid status code: " + res.status 233 | } 234 | return 'ok' 235 | } else { 236 | let res = await Network.post(`${this.baseUrl}/user/operation_v2?op=set_bookmark&comic_id=${comicId}&chapter_slot=0`) 237 | if (!res.status || res.status >= 400) { 238 | throw "Invalid status code: " + res.status 239 | } 240 | return 'ok' 241 | } 242 | }, 243 | // 加载收藏夹, 仅当multiFolder为true时有效 244 | // 当comicId不为null时, 需要同时返回包含该漫画的收藏夹 245 | loadFolders: null, 246 | /// 加载漫画 247 | loadComics: async (page, folder) => { 248 | let res = await Network.get(`${this.baseUrl}/user/my_bookshelf`) 249 | if (res.status !== 200) { 250 | throw "Invalid status code: " + res.status 251 | } 252 | let document = new HtmlDocument(res.body) 253 | function parseComic(e) { 254 | let title = e.querySelector("h4 > a").text.trim() 255 | let url = e.querySelector("h4 > a").attributes['href'] 256 | let id = url.split("/").pop() 257 | let author = e.querySelector("div.info > ul").children[1].text.split(":")[1].trim() 258 | let description = e.querySelector("div.info > ul").children[4].children[0].text.trim() 259 | 260 | return { 261 | id: id, 262 | title: title, 263 | subTitle: author, 264 | description: description, 265 | cover: e.querySelector("amp-img").attributes['src'] 266 | } 267 | } 268 | let comics = document.querySelectorAll("div.bookshelf-items").map(e => parseComic(e)) 269 | return { 270 | comics: comics, 271 | maxPage: 1 272 | } 273 | } 274 | } 275 | 276 | /// 单个漫画相关 277 | comic = { 278 | // 加载漫画信息 279 | loadInfo: async (id) => { 280 | let res = await Network.get(`${this.baseUrl}/comic/${id}`) 281 | if (res.status !== 200) { 282 | throw "Invalid status code: " + res.status 283 | } 284 | let document = new HtmlDocument(res.body) 285 | 286 | let title = document.querySelector("h1.comics-detail__title").text.trim() 287 | let cover = document.querySelector("div.l-content > div > div > amp-img").attributes['src'] 288 | let author = document.querySelector("h2.comics-detail__author").text.trim() 289 | let tags = document.querySelectorAll("div.tag-list > span").map(e => e.text.trim()) 290 | tags = [...tags.filter(e => e !== "")] 291 | let updateTime = document.querySelector("div.supporting-text > div > span > em")?.text.trim().replace('(', '').replace(')', '') 292 | if (!updateTime) { 293 | const getLastChapterText = () => { 294 | // 合并所有章节容器(处理可能存在多个列表的情况) 295 | const containers = [ 296 | ...document.querySelectorAll("#chapter-items, #chapters_other_list") 297 | ]; 298 | let allChapters = []; 299 | containers.forEach(container => { 300 | const chapters = container.querySelectorAll(".comics-chapters > a"); 301 | allChapters.push(...Array.from(chapters)); 302 | }); 303 | const lastChapter = allChapters[allChapters.length - 1]; 304 | return lastChapter?.querySelector("div > span")?.text.trim() || "暂无更新信息"; 305 | }; 306 | updateTime = getLastChapterText(); 307 | } 308 | let description = document.querySelector("p.comics-detail__desc").text.trim() 309 | let chapters = new Map() 310 | let i = 0 311 | for (let c of document.querySelectorAll("div#chapter-items > div.comics-chapters > a > div > span")) { 312 | chapters.set(i.toString(), c.text.trim()) 313 | i++ 314 | } 315 | for (let c of document.querySelectorAll("div#chapters_other_list > div.comics-chapters > a > div > span")) { 316 | chapters.set(i.toString(), c.text.trim()) 317 | i++ 318 | } 319 | if (i === 0) { 320 | // 将倒序的最新章节反转 321 | const spans = Array.from(document.querySelectorAll("div.comics-chapters > a > div > span")).reverse(); 322 | for (let c of spans) { 323 | chapters.set(i.toString(), c.text.trim()); 324 | i++; 325 | } 326 | } 327 | let recommend = [] 328 | for (let c of document.querySelectorAll("div.recommend--item")) { 329 | if (c.querySelectorAll("div.tag-comic").length > 0) { 330 | let title = c.querySelector("span").text.trim() 331 | let cover = c.querySelector("amp-img").attributes['src'] 332 | let url = c.querySelector("a").attributes['href'] 333 | let id = url.split("/").pop() 334 | recommend.push({ 335 | id: id, 336 | title: title, 337 | cover: cover 338 | }) 339 | } 340 | } 341 | 342 | return { 343 | title: title, 344 | cover: cover, 345 | description: description, 346 | tags: { 347 | "作者": [author], 348 | "更新": [updateTime], 349 | "标签": tags 350 | }, 351 | chapters: chapters, 352 | recommend: recommend 353 | } 354 | }, 355 | loadEp: async (comicId, epId) => { 356 | const images = []; 357 | let currentPageUrl = `${this.baseUrl}/comic/chapter/${comicId}/0_${epId}.html`; 358 | let maxAttempts = 100; 359 | 360 | while (maxAttempts > 0) { 361 | const res = await Network.get(currentPageUrl); 362 | if (res.status !== 200) break; 363 | 364 | // 解析当前页图片 365 | const doc = new HtmlDocument(res.body); 366 | doc.querySelectorAll("ul.comic-contain > div > amp-img").forEach(img => { 367 | const src = img?.attributes?.['src']; 368 | if (typeof src === 'string') images.push(src); 369 | }); 370 | 371 | // 查找下一页链接 372 | const nextLink = doc.querySelector("a#next-chapter"); 373 | if (nextLink?.text?.match(/下一页|下一頁/)) { 374 | currentPageUrl = nextLink.attributes['href']; 375 | } else { 376 | break; 377 | } 378 | maxAttempts--; 379 | } 380 | // 代理后图片水印更少 381 | return { images }; 382 | } 383 | } 384 | } 385 | -------------------------------------------------------------------------------- /ikmmh.js: -------------------------------------------------------------------------------- 1 | class Ikm extends ComicSource { 2 | // 基础配置 3 | name = "爱看漫"; 4 | key = "ikmmh"; 5 | version = "1.0.2"; 6 | minAppVersion = "1.0.0"; 7 | url = "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@main/ikmmh.js"; 8 | // 常量定义 9 | static baseUrl = "https://ymcdnyfqdapp.ikmmh.com"; 10 | static Mobile_UA = "Mozilla/5.0 (Linux; Android) Mobile"; 11 | static webHeaders = { 12 | "User-Agent": Ikm.Mobile_UA, 13 | Accept: 14 | "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", 15 | }; 16 | static jsonHead = { 17 | "User-Agent": Ikm.Mobile_UA, 18 | "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", 19 | Accept: "application/json, text/javascript, */*; q=0.01", 20 | "Accept-Encoding": "gzip", 21 | "X-Requested-With": "XMLHttpRequest", 22 | }; 23 | // 统一缩略图加载配置 24 | static thumbConfig = (url) => ({ 25 | headers: { 26 | ...Ikm.webHeaders, 27 | Referer: Ikm.baseUrl, 28 | }, 29 | }); 30 | // 账号系统 31 | account = { 32 | login: async (account, pwd) => { 33 | try { 34 | let res = await Network.post( 35 | `${Ikm.baseUrl}/api/user/userarr/login`, 36 | Ikm.jsonHead, 37 | `user=${account}&pass=${pwd}` 38 | ); 39 | if (res.status !== 200) 40 | throw new Error(`登录失败,状态码:${res.status}`); 41 | let data = JSON.parse(res.body); 42 | if (data.code !== 0) throw new Error(data.msg || "登录异常"); 43 | return "ok"; 44 | } catch (err) { 45 | throw new Error(`登录失败:${err.message}`); 46 | } 47 | }, 48 | logout: () => Network.deleteCookies("ymcdnyfqdapp.ikmmh.com"), 49 | registerWebsite: `${Ikm.baseUrl}/user/register/`, 50 | }; 51 | // 探索页面 52 | explore = [ 53 | { 54 | title: this.name, 55 | type: "singlePageWithMultiPart", 56 | load: async () => { 57 | try { 58 | let res = await Network.get(`${Ikm.baseUrl}/`, Ikm.webHeaders); 59 | if (res.status !== 200) 60 | throw new Error(`加载探索页面失败,状态码:${res.status}`); 61 | let document = new HtmlDocument(res.body); 62 | let parseComic = (e) => { 63 | let title = e.querySelector("div.title").text.split("~")[0]; 64 | let cover = e.querySelector("div.thumb_img").attributes["data-src"]; 65 | let link = `${Ikm.baseUrl}${ 66 | e.querySelector("a").attributes["href"] 67 | }`; 68 | return { 69 | title, 70 | cover, 71 | id: link, 72 | }; 73 | }; 74 | return { 75 | 本周推荐: document 76 | .querySelectorAll("div.module-good-fir > div.item") 77 | .map(parseComic), 78 | 今日更新: document 79 | .querySelectorAll("div.module-day-fir > div.item") 80 | .map(parseComic), 81 | }; 82 | } catch (err) { 83 | throw new Error(`探索页面加载失败:${err.message}`); 84 | } 85 | }, 86 | onThumbnailLoad: Ikm.thumbConfig, 87 | }, 88 | ]; 89 | // 分类页面 90 | category = { 91 | title: "爱看漫", 92 | parts: [ 93 | { 94 | name: "分类", 95 | // fixed 或者 random 96 | // random用于分类数量相当多时, 随机显示其中一部分 97 | type: "fixed", 98 | // 如果类型为random, 需要提供此字段, 表示同时显示的数量 99 | // randomNumber: 5, 100 | categories: [ 101 | "全部", 102 | "长条", 103 | "大女主", 104 | "百合", 105 | "耽美", 106 | "纯爱", 107 | "後宫", 108 | "韩漫", 109 | "奇幻", 110 | "轻小说", 111 | "生活", 112 | "悬疑", 113 | "格斗", 114 | "搞笑", 115 | "伪娘", 116 | "竞技", 117 | "职场", 118 | "萌系", 119 | "冒险", 120 | "治愈", 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 | "未分类", 153 | ], 154 | // category或者search 155 | // 如果为category, 点击后将进入分类漫画页面, 使用下方的`categoryComics`加载漫画 156 | // 如果为search, 将进入搜索页面 157 | itemType: "category", 158 | }, 159 | { 160 | name: "更新", 161 | type: "fixed", 162 | categories: [ 163 | "星期一", 164 | "星期二", 165 | "星期三", 166 | "星期四", 167 | "星期五", 168 | "星期六", 169 | "星期日", 170 | ], 171 | itemType: "category", 172 | categoryParams: ["1", "2", "3", "4", "5", "6", "7"], 173 | }, 174 | ], 175 | enableRankingPage: false, 176 | }; 177 | // 分类漫画加载 178 | categoryComics = { 179 | load: async (category, param, options, page) => { 180 | try { 181 | let res; 182 | if (param) { 183 | res = await Network.get( 184 | `${Ikm.baseUrl}/update/${param}.html`, 185 | Ikm.webHeaders 186 | ); 187 | if (res.status !== 200) 188 | throw new Error(`分类请求失败,状态码:${res.status}`); 189 | let document = new HtmlDocument(res.body); 190 | let comics = document.querySelectorAll("li.comic-item").map((e) => ({ 191 | title: e.querySelector("p.title").text.split("~")[0], 192 | cover: e.querySelector("img").attributes["src"], 193 | id: `${Ikm.baseUrl}${e.querySelector("a").attributes["href"]}`, 194 | subTitle: e.querySelector("span.chapter").text, 195 | })); 196 | return { 197 | comics, 198 | maxPage: 1, 199 | }; 200 | } else { 201 | res = await Network.post( 202 | `${Ikm.baseUrl}/api/comic/index/lists`, 203 | Ikm.jsonHead, 204 | `area=${options[1]}&tags=${encodeURIComponent(category)}&full=${ 205 | options[0] 206 | }&page=${page}` 207 | ); 208 | let resData = JSON.parse(res.body); 209 | return { 210 | comics: resData.data.map((e) => ({ 211 | id: `${Ikm.baseUrl}${e.info_url}`, 212 | title: e.name.split("~")[0], 213 | subTitle: e.author, 214 | cover: e.cover, 215 | tags: e.tags, 216 | description: e.lastchapter, 217 | })), 218 | maxPage: resData.end || 1, 219 | }; 220 | } 221 | } catch (err) { 222 | throw new Error(`分类加载失败:${err.message}`); 223 | } 224 | }, 225 | onThumbnailLoad: Ikm.thumbConfig, 226 | optionList: [ 227 | { 228 | // 对于单个选项, 使用-分割, 左侧为用于数据加载的值, 即传给load函数的options参数; 右侧为显示给用户的文本 229 | 230 | options: ["3-全部", "4-连载中", "1-已完结"], 231 | notShowWhen: [ 232 | "星期一", 233 | "星期二", 234 | "星期三", 235 | "星期四", 236 | "星期五", 237 | "星期六", 238 | "星期日", 239 | ], 240 | showWhen: null, 241 | }, 242 | { 243 | options: [ 244 | "9-全部", 245 | "1-日漫", 246 | "2-港台", 247 | "3-美漫", 248 | "4-国漫", 249 | "5-韩漫", 250 | "6-未分类", 251 | ], 252 | notShowWhen: [ 253 | "星期一", 254 | "星期二", 255 | "星期三", 256 | "星期四", 257 | "星期五", 258 | "星期六", 259 | "星期日", 260 | ], 261 | showWhen: null, 262 | }, 263 | ], 264 | }; 265 | // 搜索功能 266 | search = { 267 | load: async (keyword, options, page) => { 268 | try { 269 | let res = await Network.get( 270 | `${Ikm.baseUrl}/search?searchkey=${encodeURIComponent(keyword)}`, 271 | Ikm.webHeaders 272 | ); 273 | let document = new HtmlDocument(res.body); 274 | return { 275 | comics: document.querySelectorAll("li.comic-item").map((e) => ({ 276 | title: e.querySelector("p.title").text.split("~")[0], 277 | cover: e.querySelector("img").attributes["src"], 278 | id: `${Ikm.baseUrl}${e.querySelector("a").attributes["href"]}`, 279 | subTitle: e.querySelector("span.chapter").text, 280 | })), 281 | maxPage: 1, 282 | }; 283 | } catch (err) { 284 | throw new Error(`搜索失败:${err.message}`); 285 | } 286 | }, 287 | onThumbnailLoad: Ikm.thumbConfig, 288 | optionList: [], 289 | }; 290 | // 收藏功能 291 | favorites = { 292 | multiFolder: false, 293 | addOrDelFavorite: async (comicId, folderId, isAdding) => { 294 | try { 295 | let id = comicId.match(/\d+/)[0]; 296 | if (isAdding) { 297 | // 获取漫画信息 298 | let infoRes = await Network.get(comicId, Ikm.webHeaders); 299 | let name = new HtmlDocument(infoRes.body).querySelector( 300 | "meta[property='og:title']" 301 | ).attributes["content"]; 302 | // 添加收藏 303 | let res = await Network.post( 304 | `${Ikm.baseUrl}/api/user/bookcase/add`, 305 | Ikm.jsonHead, 306 | `articleid=${id}&articlename=${encodeURIComponent(name)}` 307 | ); 308 | let data = JSON.parse(res.body); 309 | if (data.code !== "0") throw new Error(data.msg || "收藏失败"); 310 | return "ok"; 311 | } else { 312 | // 删除收藏 313 | let res = await Network.post( 314 | `${Ikm.baseUrl}/api/user/bookcase/del`, 315 | Ikm.jsonHead, 316 | `articleid=${id}` 317 | ); 318 | let data = JSON.parse(res.body); 319 | if (data.code !== "0") throw new Error(data.msg || "取消收藏失败"); 320 | return "ok"; 321 | } 322 | } catch (err) { 323 | throw new Error(`收藏操作失败:${err.message}`); 324 | } 325 | }, 326 | //加载收藏 327 | loadComics: async (page, folder) => { 328 | let res = await Network.get( 329 | `${Ikm.baseUrl}/user/bookcase`, 330 | Ikm.webHeaders 331 | ); 332 | if (res.status !== 200) { 333 | throw "加载收藏失败:" + res.status; 334 | } 335 | let document = new HtmlDocument(res.body); 336 | return { 337 | comics: document.querySelectorAll("div.bookrack-item").map((e) => ({ 338 | title: e.querySelector("h3").text.split("~")[0], 339 | subTitle: e.querySelector("p.desc").text, 340 | cover: e.querySelector("img").attributes["src"], 341 | id: `${Ikm.baseUrl}/book/${e.attributes["data-id"]}/`, 342 | })), 343 | maxPage: 1, 344 | }; 345 | }, 346 | onThumbnailLoad: Ikm.thumbConfig, 347 | }; 348 | // 漫画详情 349 | comic = { 350 | loadInfo: async (id) => { 351 | let res = await Network.get(id, Ikm.webHeaders); 352 | let document = new HtmlDocument(res.body); 353 | let comicId = id.match(/\d+/)[0]; 354 | // 获取章节数据 355 | let epRes = await Network.get( 356 | `${Ikm.baseUrl}/api/comic/zyz/chapterlink?id=${comicId}`, 357 | { 358 | ...Ikm.jsonHead, 359 | Referer: id, 360 | } 361 | ); 362 | let epData = JSON.parse(epRes.body); 363 | let eps = new Map(); 364 | if (epData.data && epData.data.length > 0 && epData.data[0].list) { 365 | epData.data[0].list.forEach((e) => { 366 | let title = e.name; 367 | let id = `${Ikm.baseUrl}${e.url}`; 368 | eps.set(id, title); 369 | }); 370 | } else { 371 | throw new Error(`章节数据格式异常`); 372 | } 373 | 374 | let title = document.querySelector( 375 | "div.book-hero__detail > div.title" 376 | ).text; 377 | let escapedTitle = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 378 | let thumb = 379 | document 380 | .querySelector("div.coverimg") 381 | .attributes["style"].match(/\((.*?)\)/)?.[1] || ""; 382 | let desc = document 383 | .querySelector("article.book-container__detail") 384 | .text.match( 385 | new RegExp( 386 | `漫画名:${escapedTitle}(?:(?:[^。]*?(?:简介|漫画简介)\\s*[::]?\\s*)|(?:[^。]*?))([\\s\\S]+?)\\.\\.\\.。` 387 | ) 388 | ); 389 | let intro = desc?.[1]?.trim().replace(/\s+/g, " ") || ""; 390 | 391 | return { 392 | title: title.split("~")[0], 393 | cover: thumb, 394 | description: intro, 395 | tags: { 396 | 作者: [ 397 | document 398 | .querySelector("div.book-container__author") 399 | .text.split("作者:")[1], 400 | ], 401 | 更新: [document.querySelector("div.update > a > em").text], 402 | 标签: document 403 | .querySelectorAll("div.book-hero__detail > div.tags > a") 404 | .map((e) => e.text.trim()) 405 | .filter((text) => text), 406 | }, 407 | chapters: eps, 408 | recommend: document 409 | .querySelectorAll("div.module-guessu > div.item") 410 | .map((e) => ({ 411 | title: e.querySelector("div.title").text.split("~")[0], 412 | cover: e.querySelector("div.thumb_img").attributes["data-src"], 413 | id: `${Ikm.baseUrl}${e.querySelector("a").attributes["href"]}`, 414 | })), 415 | }; 416 | }, 417 | onThumbnailLoad: Ikm.thumbConfig, 418 | loadEp: async (comicId, epId) => { 419 | try { 420 | let res = await Network.get(epId, Ikm.webHeaders); 421 | let document = new HtmlDocument(res.body); 422 | return { 423 | images: document 424 | .querySelectorAll("img.lazy") 425 | .map((e) => e.attributes["data-src"]), 426 | }; 427 | } catch (err) { 428 | throw new Error(`加载章节失败:${err.message}`); 429 | } 430 | }, 431 | onImageLoad: (url, comicId, epId) => { 432 | return { 433 | url, 434 | headers: { 435 | ...Ikm.webHeaders, 436 | Referer: epId, 437 | }, 438 | }; 439 | }, 440 | }; 441 | } 442 | -------------------------------------------------------------------------------- /index.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "拷贝漫画", 4 | "fileName": "copy_manga.js", 5 | "key": "copy_manga", 6 | "version": "1.3.0" 7 | }, 8 | { 9 | "name": "Komiic", 10 | "fileName": "komiic.js", 11 | "key": "Komiic", 12 | "version": "1.0.2" 13 | }, 14 | { 15 | "name": "包子漫画", 16 | "fileName": "baozi.js", 17 | "key": "baozi", 18 | "version": "1.0.4" 19 | }, 20 | { 21 | "name": "Picacg", 22 | "fileName": "picacg.js", 23 | "key": "picacg", 24 | "version": "1.0.3" 25 | }, 26 | { 27 | "name": "nhentai", 28 | "fileName": "nhentai.js", 29 | "key": "nhentai", 30 | "version": "1.0.4" 31 | }, 32 | { 33 | "name": "紳士漫畫", 34 | "fileName": "wnacg.js", 35 | "key": "wnacg", 36 | "version": "1.0.2", 37 | "description": "紳士漫畫漫畫源, 不能使用時請嘗試更換URL" 38 | }, 39 | { 40 | "name": "ehentai", 41 | "fileName": "ehentai.js", 42 | "key": "ehentai", 43 | "version": "1.1.3" 44 | }, 45 | { 46 | "name": "禁漫天堂", 47 | "fileName": "jm.js", 48 | "key": "jm", 49 | "version": "1.1.4", 50 | "description": "禁漫天堂漫畫源, 不能使用時請嘗試切換分流" 51 | }, 52 | { 53 | "name": "MangaDex", 54 | "fileName": "manga_dex.js", 55 | "key": "manga_dex", 56 | "version": "1.0.0", 57 | "description": "Account feature is not supported yet." 58 | }, 59 | { 60 | "name": "爱看漫", 61 | "fileName": "ikmmh.js", 62 | "key": "ikmmh", 63 | "version": "1.0.2" 64 | }, 65 | { 66 | "name": "少年ジャンプ+", 67 | "fileName": "shonenjumpplus.js", 68 | "key": "shonen_jump_plus", 69 | "version": "1.0.0" 70 | } 71 | ] 72 | -------------------------------------------------------------------------------- /jm.js: -------------------------------------------------------------------------------- 1 | class JM extends ComicSource { 2 | // Note: The fields which are marked as [Optional] should be removed if not used 3 | 4 | // name of the source 5 | name = "禁漫天堂" 6 | 7 | // unique id of the source 8 | key = "jm" 9 | 10 | version = "1.1.4" 11 | 12 | minAppVersion = "1.2.5" 13 | 14 | // update url 15 | url = "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@main/jm.js" 16 | 17 | static apiDomains = [ 18 | "www.jmapiproxyxxx.vip", 19 | "www.cdnblackmyth.club", 20 | "www.cdnmhws.cc", 21 | "www.cdnmhwscc.org" 22 | ]; 23 | 24 | static imageUrl = "https://cdn-msp.jmapinodeudzn.net" 25 | 26 | static apiUa = "Mozilla/5.0 (Linux; Android 10; K; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/130.0.0.0 Mobile Safari/537.36" 27 | 28 | static imgUa = "okhttp/3.12.1" 29 | 30 | get baseUrl() { 31 | let index = parseInt(this.loadSetting('apiDomain')) - 1 32 | return `https://${JM.apiDomains[index]}` 33 | } 34 | 35 | get imageUrl() { 36 | return JM.imageUrl 37 | } 38 | 39 | overwriteApiDomains(domains) { 40 | if (domains.length != 0) JM.apiDomains = domains 41 | } 42 | 43 | overwriteImgUrl(url) { 44 | if (url.length != 0) JM.imageUrl = url 45 | } 46 | 47 | isNum(str) { 48 | return /^\d+$/.test(str) 49 | } 50 | 51 | get apiUa() { 52 | return JM.apiUa; 53 | } 54 | 55 | get imgUa() { 56 | return JM.imgUa; 57 | } 58 | 59 | getCoverUrl(id) { 60 | return `${this.imageUrl}/media/albums/${id}_3x4.jpg` 61 | } 62 | 63 | getImageUrl(id, imageName) { 64 | return `${this.imageUrl}/media/photos/${id}/${imageName}` 65 | } 66 | 67 | getAvatarUrl(imageName) { 68 | return `${this.imageUrl}/media/users/${imageName}` 69 | } 70 | 71 | async init() { 72 | if (this.loadSetting('refreshDomainsOnStart')) await this.refreshApiDomains(false) 73 | this.refreshImgUrl(false) 74 | } 75 | 76 | /** 77 | * 78 | * @param showConfirmDialog {boolean} 79 | */ 80 | async refreshApiDomains(showConfirmDialog) { 81 | let today = new Date(); 82 | let url = "https://jmappc01-1308024008.cos.ap-guangzhou.myqcloud.com/server-2024.txt" 83 | let domainSecret = "diosfjckwpqpdfjkvnqQjsik" 84 | let title = "" 85 | let message = "" 86 | let domains = [] 87 | let res = await fetch( 88 | `${url}?time=${today.getFullYear()}${today.getMonth() + 1}${today.getDate()}`, 89 | {headers: {"User-Agent": this.imgUa}} 90 | ) 91 | if (res.status === 200) { 92 | let data = this.convertData(await res.text(), domainSecret) 93 | let json = JSON.parse(data) 94 | if (json["Server"]) { 95 | title = "Update Success" 96 | message = "New domains:\n\n" 97 | domains = json["Server"] 98 | } 99 | } 100 | if (domains.length === 0) { 101 | title = "Update Failed" 102 | message = `Using built-in domains:\n\n` 103 | domains = JM.apiDomains 104 | } 105 | for (let i = 0; i < domains.length; i++) { 106 | message = message + `Stream ${i + 1}: ${domains[i]}\n` 107 | } 108 | if (showConfirmDialog) { 109 | UI.showDialog( 110 | title, 111 | message, 112 | [ 113 | { 114 | text: "Cancel", 115 | callback: () => {} 116 | }, 117 | { 118 | text: "Apply", 119 | callback: () => { 120 | this.overwriteApiDomains(domains) 121 | this.refreshImgUrl(true) 122 | } 123 | } 124 | ] 125 | ) 126 | } else { 127 | this.overwriteApiDomains(domains) 128 | } 129 | } 130 | 131 | /** 132 | * 133 | * @param showMessage {boolean} 134 | */ 135 | async refreshImgUrl(showMessage) { 136 | let index = this.loadSetting('imageStream') 137 | let res = await this.get( 138 | `${this.baseUrl}/setting?app_img_shunt=${index}` 139 | ) 140 | let setting = JSON.parse(res) 141 | if (setting["img_host"]) { 142 | if (showMessage) { 143 | UI.showMessage(`Image Stream ${index}:\n${setting["img_host"]}`) 144 | } 145 | this.overwriteImgUrl(setting["img_host"]) 146 | } 147 | } 148 | 149 | /** 150 | * 151 | * @param comic {object} 152 | * @returns {Comic} 153 | */ 154 | parseComic(comic) { 155 | let id = comic.id.toString() 156 | let author = comic.author 157 | let title = comic.name 158 | let description = comic.description ?? "" 159 | let cover = this.getCoverUrl(id) 160 | let tags =[] 161 | if(comic["category"]["title"]) { 162 | tags.push(comic["category"]["title"]) 163 | } 164 | if(comic["category_sub"]["title"]) { 165 | tags.push(comic["category_sub"]["title"]) 166 | } 167 | return new Comic({ 168 | id: id, 169 | title: title, 170 | subTitle: author, 171 | cover: cover, 172 | tags: tags, 173 | description: description 174 | }) 175 | } 176 | 177 | getHeaders(time) { 178 | const jmVersion = "1.7.6" 179 | const jmAuthKey = "18comicAPPContent" 180 | let token = Convert.md5(Convert.encodeUtf8(`${time}${jmAuthKey}`)) 181 | 182 | return { 183 | "token": Convert.hexEncode(token), 184 | "tokenparam": `${time},${jmVersion}`, 185 | "Accept-Encoding": "gzip", 186 | "User-Agent": this.apiUa, 187 | } 188 | } 189 | 190 | /** 191 | * 192 | * @param input {string} 193 | * @param secret {string} 194 | * @returns {string} 195 | */ 196 | convertData(input, secret) { 197 | let key = Convert.encodeUtf8(Convert.hexEncode(Convert.md5(Convert.encodeUtf8(secret)))) 198 | let data = Convert.decodeBase64(input) 199 | let decrypted = Convert.decryptAesEcb(data, key) 200 | let res = Convert.decodeUtf8(decrypted) 201 | let i = res.length - 1 202 | while(res[i] !== '}' && res[i] !== ']' && i > 0) { 203 | i-- 204 | } 205 | return res.substring(0, i + 1) 206 | } 207 | 208 | /** 209 | * 210 | * @param url {string} 211 | * @returns {Promise} 212 | */ 213 | async get(url) { 214 | let time = Math.floor(Date.now() / 1000) 215 | let kJmSecret = "185Hcomic3PAPP7R" 216 | let res = await Network.get(url, this.getHeaders(time)) 217 | if(res.status !== 200) { 218 | if(res.status === 401) { 219 | let json = JSON.parse(res.body) 220 | let message = json.errorMsg 221 | if(message === "請先登入會員" && this.isLogged) { 222 | throw 'Login expired' 223 | } 224 | throw message ?? 'Invalid Status Code: ' + res.status 225 | } 226 | throw 'Invalid Status Code: ' + res.status 227 | } 228 | let json = JSON.parse(res.body) 229 | let data = json.data 230 | if(typeof data !== 'string') { 231 | throw 'Invalid Data' 232 | } 233 | return this.convertData(data, `${time}${kJmSecret}`) 234 | } 235 | 236 | async post(url, body) { 237 | let time = Math.floor(Date.now() / 1000) 238 | let kJmSecret = "185Hcomic3PAPP7R" 239 | let res = await Network.post(url, { 240 | ...this.getHeaders(time), 241 | "Content-Type": "application/x-www-form-urlencoded" 242 | }, body) 243 | if(res.status !== 200) { 244 | if(res.status === 401) { 245 | let json = JSON.parse(res.body) 246 | let message = json.errorMsg 247 | if(message === "請先登入會員" && this.isLogged) { 248 | throw 'Login expired' 249 | } 250 | throw message ?? 'Invalid Status Code: ' + res.status 251 | } 252 | throw 'Invalid Status Code: ' + res.status 253 | } 254 | let json = JSON.parse(res.body) 255 | let data = json.data 256 | if(typeof data !== 'string') { 257 | throw 'Invalid Data' 258 | } 259 | return this.convertData(data, `${time}${kJmSecret}`) 260 | } 261 | 262 | // [Optional] account related 263 | account = { 264 | /** 265 | * [Optional] login with account and password, return any value to indicate success 266 | * @param account {string} 267 | * @param pwd {string} 268 | * @returns {Promise} 269 | */ 270 | login: async (account, pwd) => { 271 | let time = Math.floor(Date.now() / 1000) 272 | await this.post( 273 | `${this.baseUrl}/login`, 274 | `username=${encodeURIComponent(account)}&password=${encodeURIComponent(pwd)}` 275 | ) 276 | return "ok" 277 | }, 278 | 279 | /** 280 | * logout function, clear account related data 281 | */ 282 | logout: () => { 283 | for (let url of JM.apiDomains) { 284 | Network.deleteCookies(url) 285 | } 286 | }, 287 | 288 | // {string?} - register url 289 | registerWebsite: null 290 | } 291 | 292 | // explore page list 293 | explore = [ 294 | { 295 | // title of the page. 296 | // title is used to identify the page, it should be unique 297 | title: "禁漫天堂", 298 | 299 | /// multiPartPage or multiPageComicList or mixed 300 | type: "multiPartPage", 301 | 302 | /** 303 | * load function 304 | * @param page {number | null} - page number, null for `singlePageWithMultiPart` type 305 | * @returns {{}} 306 | * - for `multiPartPage` type, return [{title: string, comics: Comic[], viewMore: string?}] 307 | * - for `multiPageComicList` type, for each page(1-based), return {comics: Comic[], maxPage: number} 308 | * - 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?} 309 | */ 310 | load: async (page) => { 311 | let res = await this.get(`${this.baseUrl}/promote?$baseData&page=0`) 312 | let result = [] 313 | 314 | for(let e of JSON.parse(res)) { 315 | let title = e["title"] 316 | let type = e.type 317 | let id = e.id.toString() 318 | if (type === 'category_id') { 319 | id = e.slug 320 | } 321 | if (type === 'library') { 322 | continue 323 | } 324 | let comics = e.content.map((e) => this.parseComic(e)) 325 | result.push({ 326 | title: e.title, 327 | comics: comics, 328 | viewMore: `category:${title}@${id}` 329 | }) 330 | } 331 | 332 | return result 333 | }, 334 | } 335 | ] 336 | 337 | // categories 338 | category = { 339 | /// title of the category page, used to identify the page, it should be unique 340 | title: "禁漫天堂", 341 | parts: [ 342 | { 343 | name: "成人A漫", 344 | type: "fixed", 345 | categories: ["最新A漫", "同人", "單本", "短篇", "其他類", "韓漫", "美漫", "Cosplay", "3D", "禁漫漢化組"], 346 | itemType: "category", 347 | categoryParams: [ 348 | "0", 349 | "doujin", 350 | "single", 351 | "short", 352 | "another", 353 | "hanman", 354 | "meiman", 355 | "another_cosplay", 356 | "3D", 357 | "禁漫漢化組" 358 | ], 359 | }, 360 | { 361 | name: "主題A漫", 362 | type: "fixed", 363 | categories: [ 364 | '無修正', 365 | '劇情向', 366 | '青年漫', 367 | '校服', 368 | '純愛', 369 | '人妻', 370 | '教師', 371 | '百合', 372 | 'Yaoi', 373 | '性轉', 374 | 'NTR', 375 | '女裝', 376 | '癡女', 377 | '全彩', 378 | '女性向', 379 | '完結', 380 | '純愛', 381 | '禁漫漢化組' 382 | ], 383 | itemType: "search", 384 | }, 385 | { 386 | name: "角色扮演", 387 | type: "fixed", 388 | categories: [ 389 | '御姐', 390 | '熟女', 391 | '巨乳', 392 | '貧乳', 393 | '女性支配', 394 | '教師', 395 | '女僕', 396 | '護士', 397 | '泳裝', 398 | '眼鏡', 399 | '連褲襪', 400 | '其他制服', 401 | '兔女郎' 402 | ], 403 | itemType: "search", 404 | }, 405 | { 406 | name: "特殊PLAY", 407 | type: "fixed", 408 | categories: [ 409 | '群交', 410 | '足交', 411 | '束縛', 412 | '肛交', 413 | '阿黑顏', 414 | '藥物', 415 | '扶他', 416 | '調教', 417 | '野外露出', 418 | '催眠', 419 | '自慰', 420 | '觸手', 421 | '獸交', 422 | '亞人', 423 | '怪物女孩', 424 | '皮物', 425 | 'ryona', 426 | '騎大車' 427 | ], 428 | itemType: "search", 429 | }, 430 | { 431 | name: "特殊PLAY", 432 | type: "fixed", 433 | categories: ['CG', '重口', '獵奇', '非H', '血腥暴力', '站長推薦'], 434 | itemType: "search", 435 | }, 436 | ], 437 | // enable ranking page 438 | enableRankingPage: true, 439 | } 440 | 441 | /// category comic loading related 442 | categoryComics = { 443 | /** 444 | * load comics of a category 445 | * @param category {string} - category name 446 | * @param param {string?} - category param 447 | * @param options {string[]} - options from optionList 448 | * @param page {number} - page number 449 | * @returns {Promise<{comics: Comic[], maxPage: number}>} 450 | */ 451 | load: async (category, param, options, page) => { 452 | param ??= category 453 | param = encodeURIComponent(param) 454 | let res = await this.get(`${this.baseUrl}/categories/filter?o=${options[0]}&c=${param}&page=${page}`) 455 | let data = JSON.parse(res) 456 | let total = data.total 457 | let maxPage = Math.ceil(total / 80) 458 | let comics = data.content.map((e) => this.parseComic(e)) 459 | return { 460 | comics: comics, 461 | maxPage: maxPage 462 | } 463 | }, 464 | // provide options for category comic loading 465 | optionList: [ 466 | { 467 | // For a single option, use `-` to separate the value and text, left for value, right for text 468 | options: [ 469 | "mr-最新", 470 | "mv-總排行", 471 | "mv_m-月排行", 472 | "mv_w-周排行", 473 | "mv_t-日排行", 474 | "mp-最多圖片", 475 | "tf-最多喜歡", 476 | ], 477 | } 478 | ], 479 | ranking: { 480 | // For a single option, use `-` to separate the value and text, left for value, right for text 481 | options: [ 482 | "mv-總排行", 483 | "mv_m-月排行", 484 | "mv_w-周排行", 485 | "mv_t-日排行", 486 | ], 487 | /** 488 | * load ranking comics 489 | * @param option {string} - option from optionList 490 | * @param page {number} - page number 491 | * @returns {Promise<{comics: Comic[], maxPage: number}>} 492 | */ 493 | load: async (option, page) => { 494 | return this.categoryComics.load("總排行", "0", [option], page) 495 | } 496 | } 497 | } 498 | 499 | /// search related 500 | search = { 501 | /** 502 | * load search result 503 | * @param keyword {string} 504 | * @param options {(string | null)[]} - options from optionList 505 | * @param page {number} 506 | * @returns {Promise<{comics: Comic[], maxPage: number}>} 507 | */ 508 | load: async (keyword, options, page) => { 509 | keyword = keyword.trim() 510 | keyword = encodeURIComponent(keyword) 511 | keyword = keyword.replace(/%20/g, '+') 512 | let url = `${this.baseUrl}/search?search_query=${keyword}&o=${options[0]}` 513 | if(page > 1) { 514 | url += `&page=${page}` 515 | } 516 | let res = await this.get(url) 517 | let data = JSON.parse(res) 518 | let total = data.total 519 | let maxPage = Math.ceil(total / 80) 520 | let comics = data.content.map((e) => this.parseComic(e)) 521 | return { 522 | comics: comics, 523 | maxPage: maxPage 524 | } 525 | }, 526 | 527 | // provide options for search 528 | optionList: [ 529 | { 530 | type: "select", 531 | // For a single option, use `-` to separate the value and text, left for value, right for text 532 | options: [ 533 | "mr-最新", 534 | "mv-總排行", 535 | "mv_m-月排行", 536 | "mv_w-周排行", 537 | "mv_t-日排行", 538 | "mp-最多圖片", 539 | "tf-最多喜歡", 540 | ], 541 | // option label 542 | label: "排序", 543 | } 544 | ], 545 | } 546 | 547 | // favorite related 548 | favorites = { 549 | multiFolder: true, 550 | /** 551 | * add or delete favorite. 552 | * throw `Login expired` to indicate login expired, App will automatically re-login and re-add/delete favorite 553 | * @param comicId {string} 554 | * @param folderId {string} 555 | * @param isAdding {boolean} - true for add, false for delete 556 | * @param favoriteId {string?} - [Comic.favoriteId] 557 | * @returns {Promise} - return any value to indicate success 558 | */ 559 | addOrDelFavorite: async (comicId, folderId, isAdding, favoriteId) => { 560 | if (isAdding) { 561 | await this.post(`${this.baseUrl}/favorite`, `aid=${comicId}`) 562 | await this.post(`${this.baseUrl}/favorite_folder`, `type=move&folder_id=${folderId}&aid=${comicId}`) 563 | } else { 564 | await this.post(`${this.baseUrl}/favorite`, `aid=${comicId}`) 565 | } 566 | }, 567 | /** 568 | * load favorite folders. 569 | * throw `Login expired` to indicate login expired, App will automatically re-login retry. 570 | * if comicId is not null, return favorite folders which contains the comic. 571 | * @param comicId {string?} 572 | * @returns {Promise<{folders: {[p: string]: string}, favorited: string[]}>} - `folders` is a map of folder id to folder name, `favorited` is a list of folder id which contains the comic 573 | */ 574 | loadFolders: async (comicId) => { 575 | let res = await this.get(`${this.baseUrl}/favorite`) 576 | let folders = { 577 | "0": this.translate("All") 578 | } 579 | let json = JSON.parse(res) 580 | for (let e of json.folder_list) { 581 | folders[e.FID.toString()] = e.name 582 | } 583 | return { 584 | folders: folders, 585 | favorited: [] 586 | } 587 | }, 588 | /** 589 | * add a folder 590 | * @param name {string} 591 | * @returns {Promise} - return any value to indicate success 592 | */ 593 | addFolder: async (name) => { 594 | await this.post(`${this.baseUrl}/favorite_folder`, `type=add&folder_name=${name}`) 595 | }, 596 | /** 597 | * delete a folder 598 | * @param folderId {string} 599 | * @returns {Promise} - return any value to indicate success 600 | */ 601 | deleteFolder: async (folderId) => { 602 | await this.post(`${this.baseUrl}/favorite_folder`, `type=del&folder_id=${folderId}`) 603 | }, 604 | /** 605 | * load comics in a folder 606 | * throw `Login expired` to indicate login expired, App will automatically re-login retry. 607 | * @param page {number} 608 | * @param folder {string?} - folder id, null for non-multi-folder 609 | * @returns {Promise<{comics: Comic[], maxPage: number}>} 610 | */ 611 | loadComics: async (page, folder) => { 612 | let order = this.loadSetting('favoriteOrder') 613 | let res = await this.get(`${this.baseUrl}/favorite?folder_id=${folder}&page=${page}&o=${order}`) 614 | let json = JSON.parse(res) 615 | let total = json.total 616 | let maxPage = Math.ceil(total / 20) 617 | let comics = json.list.map((e) => this.parseComic(e)) 618 | return { 619 | comics: comics, 620 | maxPage: maxPage 621 | } 622 | }, 623 | singleFolderForSingleComic: true, 624 | } 625 | 626 | /// single comic related 627 | comic = { 628 | /** 629 | * load comic info 630 | * @param id {string} 631 | * @returns {Promise} 632 | */ 633 | loadInfo: async (id) => { 634 | if (id.startsWith('jm')) { 635 | id = id.substring(2) 636 | } 637 | let res = await this.get(`${this.baseUrl}/album?comicName=&id=${id}`); 638 | let data = JSON.parse(res) 639 | let author = data.author ?? [] 640 | let chapters = new Map() 641 | let series = (data.series ?? []).sort((a, b) => a.sort - b.sort) 642 | for(let e of series) { 643 | let title = e.name ?? '' 644 | title = title.trim() 645 | if(title.length === 0) { 646 | title = `第${e["sort"]}話` 647 | } 648 | let id = e.id.toString() 649 | chapters.set(id, title) 650 | } 651 | if(chapters.size === 0) { 652 | chapters.set(id, '第1話') 653 | } 654 | let tags = data.tags ?? [] 655 | let related = data["related_list"].map((e) => new Comic({ 656 | id: e.id.toString(), 657 | title: e.name, 658 | subtitle: e.author ?? "", 659 | cover: this.getCoverUrl(e.id), 660 | description: e.description ?? "" 661 | })) 662 | let updateTimeStamp = data["addtime"]; 663 | let date = new Date(updateTimeStamp * 1000) 664 | let updateDate = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; 665 | 666 | return new ComicDetails({ 667 | title: data.name, 668 | cover: this.getCoverUrl(id), 669 | description: data.description, 670 | likesCount: Number(data.likes), 671 | chapters: chapters, 672 | tags: { 673 | "作者": author, 674 | "標籤": tags, 675 | }, 676 | related: related, 677 | isFavorite: data.is_favorite ?? false, 678 | updateTime: updateDate, 679 | }) 680 | }, 681 | /** 682 | * load images of a chapter 683 | * @param comicId {string} 684 | * @param epId {string?} 685 | * @returns {Promise<{images: string[]}>} 686 | */ 687 | loadEp: async (comicId, epId) => { 688 | let res = await this.get(`${this.baseUrl}/chapter?&id=${epId}`); 689 | let data = JSON.parse(res) 690 | let images = data.images.map((e) => this.getImageUrl(epId, e)) 691 | return { 692 | images: images 693 | } 694 | }, 695 | /** 696 | * [Optional] provide configs for an image loading 697 | * @param url 698 | * @param comicId 699 | * @param epId 700 | * @returns {{} | Promise<{}>} 701 | */ 702 | onImageLoad: (url, comicId, epId) => { 703 | const scrambleId = 220980 704 | let pictureName = ""; 705 | for (let i = url.length - 1; i >= 0; i--) { 706 | if (url[i] === '/') { 707 | pictureName = url.substring(i + 1, url.length - 5); 708 | break; 709 | } 710 | } 711 | epId = Number(epId); 712 | let num = 0 713 | if(epId < scrambleId) { 714 | num = 0 715 | } else if (epId < 268850) { 716 | num = 10 717 | } else if (epId > 421926) { 718 | let str = epId.toString() + pictureName 719 | let bytes = Convert.encodeUtf8(str) 720 | let hash = Convert.md5(bytes) 721 | let hashStr = Convert.hexEncode(hash) 722 | let charCode = hashStr.charCodeAt(hashStr.length-1) 723 | let remainder = charCode % 8 724 | num = remainder * 2 + 2 725 | } else { 726 | let str = epId.toString() + pictureName 727 | let bytes = Convert.encodeUtf8(str) 728 | let hash = Convert.md5(bytes) 729 | let hashStr = Convert.hexEncode(hash) 730 | let charCode = hashStr.charCodeAt(hashStr.length-1) 731 | let remainder = charCode % 10 732 | num = remainder * 2 + 2 733 | } 734 | if (num <= 1) { 735 | return {} 736 | } 737 | return { 738 | headers: { 739 | "Accept-Encoding": "gzip", 740 | "User-Agent": this.imgUa, 741 | }, 742 | modifyImage: ` 743 | let modifyImage = (image) => { 744 | const num = ${num} 745 | let blockSize = Math.floor(image.height / num) 746 | let remainder = image.height % num 747 | let blocks = [] 748 | for(let i = 0; i < num; i++) { 749 | let start = i * blockSize 750 | let end = start + blockSize + (i !== num - 1 ? 0 : remainder) 751 | blocks.push({ 752 | start: start, 753 | end: end 754 | }) 755 | } 756 | let res = Image.empty(image.width, image.height) 757 | let y = 0 758 | for(let i = blocks.length - 1; i >= 0; i--) { 759 | let block = blocks[i] 760 | let currentHeight = block.end - block.start 761 | res.fillImageRangeAt(0, y, image, 0, block.start, image.width, currentHeight) 762 | y += currentHeight 763 | } 764 | return res 765 | } 766 | `, 767 | } 768 | }, 769 | /** 770 | * [Optional] provide configs for a thumbnail loading 771 | * @param url {string} 772 | * @returns {{}} 773 | */ 774 | onThumbnailLoad: (url) => { 775 | return { 776 | headers: { 777 | "Accept-Encoding": "gzip", 778 | "User-Agent": this.imgUa, 779 | } 780 | } 781 | }, 782 | /** 783 | * [Optional] load comments 784 | * @param comicId {string} 785 | * @param subId {string?} - ComicDetails.subId 786 | * @param page {number} 787 | * @param replyTo {string?} - commentId to reply, not null when reply to a comment 788 | * @returns {Promise<{comments: Comment[], maxPage: number?}>} 789 | */ 790 | loadComments: async (comicId, subId, page, replyTo) => { 791 | let res = await this.get(`${this.baseUrl}/forum?mode=manhua&aid=${comicId}&page=${page}`) 792 | let json = JSON.parse(res) 793 | const pageSize = 6 794 | return { 795 | comments: json.list.map((e) => new Comment({ 796 | avatar: this.getAvatarUrl(e.photo), 797 | userName: e.username, 798 | time: e.addtime, 799 | content: e.content.substring(e.content.indexOf('>') + 1, e.content.lastIndexOf('<')), 800 | })), 801 | maxPage: Math.floor(json.total / pageSize) + 1 802 | } 803 | }, 804 | /** 805 | * [Optional] send a comment, return any value to indicate success 806 | * @param comicId {string} 807 | * @param subId {string?} - ComicDetails.subId 808 | * @param content {string} 809 | * @param replyTo {string?} - commentId to reply, not null when reply to a comment 810 | * @returns {Promise} 811 | */ 812 | sendComment: async (comicId, subId, content, replyTo) => { 813 | let res = await this.post(`${this.baseUrl}/comment`, `aid=${comicId}&comment=${encodeURIComponent(content)}&status=undefined`) 814 | let json = JSON.parse(res) 815 | if (json.status === "fail") { 816 | throw json.msg ?? 'Failed to send comment' 817 | } 818 | return "ok" 819 | }, 820 | // {string?} - regex string, used to identify comic id from user input 821 | idMatch: "^(\\d+|jm\\d+)$", 822 | /** 823 | * [Optional] Handle tag click event 824 | * @param namespace {string} 825 | * @param tag {string} 826 | * @returns {{action: string, keyword: string, param: string?}} 827 | */ 828 | onClickTag: (namespace, tag) => { 829 | return { 830 | action: 'search', 831 | keyword: tag, 832 | } 833 | }, 834 | } 835 | 836 | 837 | /* 838 | [Optional] settings related 839 | Use this.loadSetting to load setting 840 | ``` 841 | let setting1Value = this.loadSetting('setting1') 842 | console.log(setting1Value) 843 | ``` 844 | */ 845 | settings = { 846 | refreshDomains: { 847 | title: "Refresh Domain List", 848 | type: "callback", 849 | buttonText: "Refresh", 850 | callback: () => this.refreshApiDomains(true) 851 | }, 852 | refreshDomainsOnStart: { 853 | title: "Refresh Domain List on Startup", 854 | type: "switch", 855 | default: true, 856 | }, 857 | apiDomain: { 858 | title: "Api Domain", 859 | type: "select", 860 | options: [ 861 | { 862 | value: '1', 863 | }, 864 | { 865 | value: '2', 866 | }, 867 | { 868 | value: '3', 869 | }, 870 | { 871 | value: '4', 872 | }, 873 | ], 874 | default: "1", 875 | }, 876 | imageStream: { 877 | title: "Image Stream", 878 | type: "select", 879 | options: [ 880 | { 881 | value: '1', 882 | }, 883 | { 884 | value: '2', 885 | }, 886 | { 887 | value: '3', 888 | }, 889 | { 890 | value: '4', 891 | }, 892 | ], 893 | default: "1", 894 | }, 895 | favoriteOrder: { 896 | title: "Favorite Order", 897 | type: "select", 898 | options: [ 899 | { 900 | value: 'mr', 901 | text: 'Add Time', 902 | }, 903 | { 904 | value: 'mp', 905 | text: 'Update Time', 906 | } 907 | ], 908 | default: 'mr' 909 | } 910 | } 911 | 912 | // [Optional] translations for the strings in this config 913 | translation = { 914 | 'zh_CN': { 915 | 'Refresh Domain List': '刷新域名列表', 916 | 'Refresh': '刷新', 917 | 'Refresh Domain List on Startup': '启动时刷新域名列表', 918 | 'Api Domain': 'Api域名', 919 | 'Image Stream': '图片分流', 920 | 'Favorite Order': '收藏夹排序', 921 | 'Add Time': '添加时间', 922 | 'Update Time': '更新时间', 923 | 'All': '全部', 924 | }, 925 | 'zh_TW': { 926 | 'Refresh Domain List': '刷新域名列表', 927 | 'Refresh': '刷新', 928 | 'Refresh Domain List on Startup': '啟動時刷新域名列表', 929 | 'Api Domain': 'Api域名', 930 | 'Image Stream': '圖片分流', 931 | 'Favorite Order': '收藏夾排序', 932 | 'Add Time': '添加時間', 933 | 'Update Time': '更新時間', 934 | 'All': '全部', 935 | }, 936 | } 937 | } 938 | -------------------------------------------------------------------------------- /komiic.js: -------------------------------------------------------------------------------- 1 | class Komiic extends ComicSource { 2 | 3 | // 此漫画源的名称 4 | name = "Komiic" 5 | 6 | // 唯一标识符 7 | key = "Komiic" 8 | 9 | version = "1.0.2" 10 | 11 | minAppVersion = "1.0.0" 12 | 13 | // 更新链接 14 | url = "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@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 operationName = query["operationName"] 31 | 32 | let res = await Network.post( 33 | 'https://komiic.com/api/query', 34 | this.headers, 35 | query 36 | ) 37 | 38 | if (res.status !== 200) { 39 | throw `Invalid Status Code ${res.status}` 40 | } 41 | 42 | let json = JSON.parse(res.body) 43 | 44 | if (json.errors != undefined) { 45 | if(json.errors[0].message.toString().indexOf('token is expired') >= 0){ 46 | throw 'Login expired' 47 | } 48 | throw json.errors[0].message 49 | } 50 | 51 | return json 52 | } 53 | 54 | async queryComics(query) { 55 | let operationName = query["operationName"] 56 | let json = await this.queryJson(query) 57 | 58 | function parseComic(comic) { 59 | let author = '' 60 | if (comic.authors.length > 0) { 61 | author = comic.authors[0].name 62 | } 63 | let tags = [] 64 | comic.categories.forEach((c) => { 65 | tags.push(c.name) 66 | }) 67 | 68 | function getTimeDifference(date) { 69 | const now = new Date(); 70 | const timeDifference = now - date; 71 | 72 | const millisecondsPerHour = 1000 * 60 * 60; 73 | const millisecondsPerDay = millisecondsPerHour * 24; 74 | 75 | if (timeDifference < millisecondsPerHour) { 76 | return '剛剛更新'; 77 | } else if (timeDifference < millisecondsPerDay) { 78 | const hours = Math.floor(timeDifference / millisecondsPerHour); 79 | return `${hours}小時前更新`; 80 | } else { 81 | const days = Math.floor(timeDifference / millisecondsPerDay); 82 | return `${days}天前更新`; 83 | } 84 | } 85 | 86 | let updateTime = new Date(comic.dateUpdated) 87 | let description = getTimeDifference(updateTime) 88 | let formatedTime = `${updateTime.getFullYear()}-${updateTime.getMonth() + 1}-${updateTime.getDate()}` 89 | 90 | return { 91 | id: comic.id, 92 | title: comic.title, 93 | subTitle: author, 94 | cover: comic.imageUrl, 95 | tags: tags, 96 | description: description, 97 | updateTime: formatedTime 98 | } 99 | } 100 | 101 | return { 102 | comics: json.data[operationName].map(parseComic), 103 | // 没找到最大页数的接口 104 | maxPage: null 105 | } 106 | } 107 | 108 | /// 账号 109 | /// 设置为null禁用账号功能 110 | account = { 111 | /// 登录 112 | /// 返回任意值表示登录成功 113 | login: async (account, pwd) => { 114 | let res = await Network.post( 115 | 'https://komiic.com/api/login', 116 | this.headers, 117 | { 118 | email: account, 119 | password: pwd 120 | } 121 | ) 122 | 123 | if (res.status === 200) { 124 | this.saveData('token', JSON.parse(res.body).token) 125 | return 'ok' 126 | } 127 | 128 | throw 'Failed to login' 129 | }, 130 | 131 | // 退出登录时将会调用此函数 132 | logout: () => { 133 | this.deleteData('token') 134 | }, 135 | 136 | registerWebsite: "https://komiic.com/register" 137 | } 138 | 139 | /// 探索页面 140 | /// 一个漫画源可以有多个探索页面 141 | explore = [ 142 | { 143 | /// 标题 144 | /// 标题同时用作标识符, 不能重复 145 | title: "Komiic", 146 | 147 | /// singlePageWithMultiPart 或者 multiPageComicList 148 | type: "multiPageComicList", 149 | 150 | load: async (page) => { 151 | 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}" }) 152 | } 153 | } 154 | ] 155 | 156 | category = { 157 | title: "Komiic", 158 | enableRankingPage: true, 159 | parts: [ 160 | { 161 | name: "主题", 162 | 163 | type: "fixed", 164 | 165 | categories: ['全部', '愛情', '神鬼', '校園', '搞笑', '生活', '懸疑', '冒險', '職場', '魔幻', '後宮', '魔法', '格鬥', '宅男', '勵志', '耽美', '科幻', '百合', '治癒', '萌系', '熱血', '競技', '推理', '雜誌', '偵探', '偽娘', '美食', '恐怖', '四格', '社會', '歷史', '戰爭', '舞蹈', '武俠', '機戰', '音樂', '體育', '黑道'], 166 | 167 | itemType: "category", 168 | 169 | // 若提供, 数量需要和`categories`一致, `categoryComics.load`方法将会收到此参数 170 | 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'] 171 | } 172 | ] 173 | } 174 | 175 | /// 分类漫画页面, 即点击分类标签后进入的页面 176 | categoryComics = { 177 | load: async (category, param, options, page) => { 178 | let variables = { 179 | pagination: { 180 | limit: 30, 181 | offset: (page - 1) * 30, 182 | orderBy: options[0], 183 | asc: false, 184 | status: options[1] 185 | } 186 | }; 187 | 188 | if (param !== '0') { 189 | variables.categoryId = [param]; 190 | } else { 191 | variables.categoryId = []; 192 | } 193 | 194 | return await this.queryComics({ 195 | "operationName": "comicByCategories", 196 | "variables": variables, 197 | "query": `query comicByCategories($categoryId: [ID!]!, $pagination: Pagination!) { 198 | comicByCategories(categoryId: $categoryId, pagination: $pagination) { 199 | id 200 | title 201 | status 202 | year 203 | imageUrl 204 | authors { id name __typename } 205 | categories { id name __typename } 206 | dateUpdated 207 | monthViews 208 | views 209 | favoriteCount 210 | lastBookUpdate 211 | lastChapterUpdate 212 | __typename 213 | } 214 | }` 215 | }) 216 | }, 217 | // 提供选项 218 | optionList: [ 219 | { 220 | options: [ 221 | "DATE_UPDATED-更新", 222 | "VIEWS-觀看數", 223 | "FAVORITE_COUNT-喜愛數", 224 | ], 225 | notShowWhen: null, 226 | showWhen: null 227 | }, 228 | { 229 | options: [ 230 | "-全部", 231 | "ONGOING-連載中", 232 | "END-完結", 233 | ], 234 | notShowWhen: null, 235 | showWhen: null 236 | }, 237 | ], 238 | ranking: { 239 | options: [ 240 | "MONTH_VIEWS-月", 241 | "VIEWS-綜合" 242 | ], 243 | load: async (option, page) => { 244 | 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}" }) 245 | } 246 | } 247 | } 248 | 249 | /// 搜索 250 | search = { 251 | load: async (keyword, options, page) => { 252 | 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}" }) 253 | 254 | function parseComic(comic) { 255 | let author = '' 256 | if (comic.authors.length > 0) { 257 | author = comic.authors[0].name 258 | } 259 | let tags = [] 260 | comic.categories.forEach((c) => { 261 | tags.push(c.name) 262 | }) 263 | 264 | function getTimeDifference(date) { 265 | const now = new Date(); 266 | const timeDifference = now - date; 267 | 268 | const millisecondsPerHour = 1000 * 60 * 60; 269 | const millisecondsPerDay = millisecondsPerHour * 24; 270 | 271 | if (timeDifference < millisecondsPerHour) { 272 | return '剛剛更新'; 273 | } else if (timeDifference < millisecondsPerDay) { 274 | const hours = Math.floor(timeDifference / millisecondsPerHour); 275 | return `${hours}小時前更新`; 276 | } else { 277 | const days = Math.floor(timeDifference / millisecondsPerDay); 278 | return `${days}天前更新`; 279 | } 280 | } 281 | 282 | let updateTime = new Date(comic.dateUpdated) 283 | let description = getTimeDifference(updateTime) 284 | 285 | return { 286 | id: comic.id, 287 | title: comic.title, 288 | subTitle: author, 289 | cover: comic.imageUrl, 290 | tags: tags, 291 | description: description 292 | } 293 | } 294 | 295 | return { 296 | comics: json.data.searchComicsAndAuthors.comics.map(parseComic), 297 | // 没找到最大页数的接口 298 | maxPage: 1 299 | } 300 | }, 301 | 302 | optionList: [] 303 | } 304 | 305 | /// 收藏 306 | favorites = { 307 | /// 是否为多收藏夹 308 | multiFolder: true, 309 | /// 添加或者删除收藏 310 | addOrDelFavorite: async (comicId, folderId, isAdding) => { 311 | let query = {} 312 | if (isAdding) { 313 | query = { "operationName": "addComicToFolder", "variables": { "comicId": comicId, "folderId": folderId }, "query": "mutation addComicToFolder($comicId: ID!, $folderId: ID!) {\n addComicToFolder(comicId: $comicId, folderId: $folderId)\n}" } 314 | } else { 315 | query = { "operationName": "removeComicToFolder", "variables": { "comicId": comicId, "folderId": folderId }, "query": "mutation removeComicToFolder($comicId: ID!, $folderId: ID!) {\n removeComicToFolder(comicId: $comicId, folderId: $folderId)\n}" } 316 | } 317 | await this.queryJson(query) 318 | return "ok" 319 | }, 320 | // 加载收藏夹, 仅当multiFolder为true时有效 321 | // 当comicId不为null时, 需要同时返回包含该漫画的收藏夹 322 | loadFolders: async (comicId) => { 323 | 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}" }) 324 | let folders = {} 325 | json.data.folders.forEach((f) => { 326 | folders[f.id] = f.name 327 | }) 328 | let favorited = null 329 | if (comicId) { 330 | let json2 = await this.queryJson({ "operationName": "comicInAccountFolders", "variables": { "comicId": comicId }, "query": "query comicInAccountFolders($comicId: ID!) {\n comicInAccountFolders(comicId: $comicId)\n}" }) 331 | favorited = json2.data.comicInAccountFolders 332 | } 333 | return { 334 | folders: folders, 335 | favorited: favorited 336 | } 337 | }, 338 | /// 创建收藏夹 339 | addFolder: async (name) => { 340 | 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}" }) 341 | return "ok" 342 | }, 343 | deleteFolder: async (id) => { 344 | let json = await this.queryJson({ "operationName": "removeFolder", "variables": { "folderId": id }, "query": "mutation removeFolder($folderId: ID!) {\n removeFolder(folderId: $folderId)\n}" }) 345 | return "ok" 346 | }, 347 | /// 加载漫画 348 | loadComics: async (page, folder) => { 349 | 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}" }) 350 | let ids = json.data.folderComicIds.comicIds 351 | if (ids.length == 0) { 352 | return { 353 | comics: [], 354 | maxPage: 1 355 | } 356 | } 357 | 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}" }) 358 | } 359 | } 360 | 361 | /// 单个漫画相关 362 | comic = { 363 | // 加载漫画信息 364 | loadInfo: async (id) => { 365 | let json1 = await this.queryJson({ "operationName": "recommendComicById", "variables": { "comicId": id }, "query": "query recommendComicById($comicId: ID!) {\n recommendComicById(comicId: $comicId)\n}" }) 366 | let recommend = json1.data.recommendComicById 367 | recommend.push(id) 368 | 369 | let getFavoriteStatus = async () => { 370 | let token = this.loadData('token') 371 | if (!token) { 372 | return false 373 | } 374 | let json = await this.queryJson({ "operationName": "comicInAccountFolders", "variables": { "comicId": id }, "query": "query comicInAccountFolders($comicId: ID!) {\n comicInAccountFolders(comicId: $comicId)\n}" }) 375 | let folders = json.data.comicInAccountFolders 376 | return folders.length !== 0 377 | } 378 | 379 | let getChapter = async () => { 380 | 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}" }) 381 | let all = json.data.chaptersByComicId 382 | let books = [], chapters = [] 383 | all.forEach((c) => { 384 | if(c.type === 'book') { 385 | books.push(c) 386 | } else { 387 | chapters.push(c) 388 | } 389 | }) 390 | let res = new Map() 391 | books.forEach((c) => { 392 | let name = '卷' + c.serial 393 | res.set(c.id, name) 394 | }) 395 | chapters.forEach((c) => { 396 | let name = c.serial 397 | res.set(c.id, name) 398 | }) 399 | return res 400 | } 401 | 402 | let results = await Promise.all([ 403 | 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}" }), 404 | getChapter.call() 405 | ]) 406 | 407 | let info = results[0].comics.pop() 408 | 409 | return { 410 | // string 标题 411 | title: info.title, 412 | // string 封面url 413 | cover: info.cover, 414 | // map 标签 415 | tags: { 416 | "作者": [info.subTitle], 417 | "标签": info.tags 418 | }, 419 | // map?, key为章节id, value为章节名称 420 | chapters: results[1], 421 | recommend: results[0].comics, 422 | updateTime: info.updateTime, 423 | } 424 | }, 425 | // 获取章节图片 426 | loadEp: async (comicId, epId) => { 427 | 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}" }) 428 | return { 429 | images: json.data.imagesByChapterId.map((i) => { 430 | return `https://komiic.com/api/image/${i.kid}` 431 | }) 432 | } 433 | }, 434 | // 可选, 调整图片加载的行为 435 | onImageLoad: (url, comicId, epId) => { 436 | return { 437 | headers: { 438 | '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', 439 | 'referer': `https://komiic.com/comic/${comicId}/chapter/${epId}/images/all` 440 | } 441 | } 442 | }, 443 | // 加载评论 444 | loadComments: async (comicId, subId, page, replyTo) => { 445 | let operationName = replyTo ? "messageChan" : "getMessagesByComicId" 446 | let promise = replyTo 447 | ? 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}" }) 448 | : 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}" }) 449 | let json = await promise 450 | return { 451 | comments: json.data[operationName].map(e => { 452 | return { 453 | // string 454 | userName: e.account.nickname, 455 | // string 456 | avatar: e.account.profileImageUrl, 457 | // string 458 | content: e.message, 459 | // string? 460 | time: e.dateUpdated, 461 | // number? 462 | // TODO: 没有数量信息, 但是设为null会禁用回复功能 463 | replyCount: 0, 464 | // string 465 | id: e.id, 466 | } 467 | }), 468 | maxPage: null, 469 | } 470 | }, 471 | // 发送评论, 返回任意值表示成功 472 | sendComment: async (comicId, subId, content, replyTo) => { 473 | if (!replyTo) { 474 | replyTo = "0" 475 | } 476 | 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}" }) 477 | return "ok" 478 | } 479 | } 480 | } 481 | -------------------------------------------------------------------------------- /manga_dex.js: -------------------------------------------------------------------------------- 1 | /** @type {import('./_venera_.js')} */ 2 | class MangaDex 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 = "MangaDex" 7 | 8 | // unique id of the source 9 | key = "manga_dex" 10 | 11 | version = "1.0.0" 12 | 13 | minAppVersion = "1.4.0" 14 | 15 | // update url 16 | url = "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@main/manga_dex.js" 17 | 18 | comicsPerPage = 20 19 | 20 | api = { 21 | parseComic: (data) => { 22 | let id = data['id'] 23 | let titles = {} 24 | let mainTitles = data['attributes']['title'] 25 | for (let lang of Object.keys(mainTitles)) { 26 | titles[lang] = mainTitles[lang] 27 | } 28 | for (let at of data['attributes']['altTitles']) { 29 | for (let lang of Object.keys(at)) { 30 | if (titles[lang] === undefined) { 31 | titles[lang] = at[lang] 32 | } 33 | } 34 | } 35 | let locale = APP.locale 36 | let mainTitle = '' 37 | let firstTitle = titles[Object.keys(titles)[0]] 38 | if (locale.startsWith('en')) { 39 | mainTitle = titles['en'] || titles['ja'] || firstTitle 40 | } else if (locale.startsWith('zh_CN')) { 41 | mainTitle = titles['zh'] || titles['zh-hk'] || titles['zh-tw'] || titles['ja'] || firstTitle 42 | } else if (locale.startsWith('zh_TW')) { 43 | mainTitle = titles['zh-hk'] || titles['zh-tw'] || titles['zh'] || titles['ja'] || firstTitle 44 | } 45 | let tags = [] 46 | for (let tag of data['attributes']['tags']) { 47 | tags.push(tag['attributes']['name']['en']) 48 | } 49 | let cover = data['relationships'].find((e) => e['type'] === 'cover_art')?.['attributes']['fileName'] 50 | if (cover) { 51 | cover = `https://mangadex.org/covers/${id}/${cover}.256.jpg` 52 | } else { 53 | cover = "" 54 | } 55 | let description = data['attributes']['description']['en'] 56 | let createTime = data['attributes']['createdAt'] 57 | let updateTime = data['attributes']['updatedAt'] 58 | let status = data['attributes']['status'] 59 | let authors = [] 60 | let artists = [] 61 | for (let rel of data['relationships']) { 62 | if (rel['type'] === 'author') { 63 | let name = rel['attributes']['name']; 64 | let id = rel['id'] 65 | authors.push(name) 66 | this.authors[name] = id 67 | } else if (rel['type'] === 'artist') { 68 | let name = rel['attributes']['name']; 69 | let id = rel['id'] 70 | artists.push(name) 71 | this.artists[name] = id 72 | } 73 | } 74 | 75 | return { 76 | id: id, 77 | title: mainTitle, 78 | subtitle: authors.at(0), 79 | titles: titles, 80 | cover: cover, 81 | tags: tags, 82 | description: description, 83 | createTime: createTime, 84 | updateTime: updateTime, 85 | status: status, 86 | authors: authors, 87 | artists: artists, 88 | } 89 | }, 90 | getPopular: async (page) => { 91 | let time = new Date() 92 | time = new Date(time.getTime() - 30 * 24 * 60 * 60 * 1000) 93 | let popularUrl = `https://api.mangadex.org/manga?` + 94 | `includes[]=cover_art&` + 95 | `includes[]=artist&` + 96 | `includes[]=author&` + 97 | `order[followedCount]=desc&` + 98 | `hasAvailableChapters=true&` + 99 | `createdAtSince=${time.toISOString().substring(0, 19)}&` + 100 | `limit=${this.comicsPerPage}` 101 | if (page && page > 1) { 102 | popularUrl += `&offset=${(page - 1) * this.comicsPerPage}` 103 | } 104 | let res = await fetch(popularUrl) 105 | let data = await res.json() 106 | let total = data['total'] 107 | let maxPage = Math.ceil(total / this.comicsPerPage) 108 | let comics = [] 109 | for (let comic of data['data']) { 110 | comics.push(this.api.parseComic(comic)) 111 | } 112 | return { 113 | comics: comics, 114 | maxPage: maxPage 115 | } 116 | }, 117 | getRecent: async (page) => { 118 | let recentUrl = `https://api.mangadex.org/manga?` + 119 | `includes[]=cover_art&` + 120 | `includes[]=artist&` + 121 | `includes[]=author&` + 122 | `order[createdAt]=desc&` + 123 | `hasAvailableChapters=true&` + 124 | `limit=${this.comicsPerPage}` 125 | if (page && page > 1) { 126 | recentUrl += `&offset=${(page - 1) * this.comicsPerPage}` 127 | } 128 | let res = await fetch(recentUrl) 129 | let data = await res.json() 130 | let total = data['total'] 131 | let maxPage = Math.ceil(total / this.comicsPerPage) 132 | let comics = [] 133 | for (let comic of data['data']) { 134 | comics.push(this.api.parseComic(comic)) 135 | } 136 | return { 137 | comics: comics, 138 | maxPage: maxPage 139 | } 140 | }, 141 | getUpdated: async (page) => { 142 | let updatedUrl = `https://api.mangadex.org/manga?` + 143 | `includes[]=cover_art&` + 144 | `includes[]=artist&` + 145 | `includes[]=author&` + 146 | `order[latestUploadedChapter]=desc&` + 147 | `contentRating[]=safe&` + 148 | `contentRating[]=suggestive&` + 149 | `hasAvailableChapters=true&` + 150 | `limit=${this.comicsPerPage}` 151 | if (page && page > 1) { 152 | updatedUrl += `&offset=${(page - 1) * this.comicsPerPage}` 153 | } 154 | let res = await fetch(updatedUrl) 155 | let data = await res.json() 156 | let total = data['total'] 157 | let maxPage = Math.ceil(total / this.comicsPerPage) 158 | let comics = [] 159 | for (let comic of data['data']) { 160 | comics.push(this.api.parseComic(comic)) 161 | } 162 | return { 163 | comics: comics, 164 | maxPage: maxPage 165 | } 166 | } 167 | } 168 | 169 | // Account feature is not implemented yet 170 | // TODO: implement account feature 171 | // account = {} 172 | 173 | // explore page list 174 | explore = [ 175 | { 176 | // title of the page. 177 | // title is used to identify the page, it should be unique 178 | title: "Manga Dex", 179 | 180 | /// multiPartPage or multiPageComicList or mixed 181 | type: "multiPartPage", 182 | 183 | load: async (page) => { 184 | let res = await Promise.all([ 185 | this.api.getPopular(page), 186 | this.api.getRecent(page), 187 | this.api.getUpdated(page) 188 | ]) 189 | let titles = ["Popular", "Recent", "Updated"] 190 | let viewMore = [ 191 | { 192 | page: "search", 193 | attributes: { 194 | options: ["popular", "any", "any"], 195 | }, 196 | }, 197 | { 198 | page: "search", 199 | attributes: { 200 | options: ["recent", "any", "any"], 201 | }, 202 | }, 203 | { 204 | page: "search", 205 | attributes: { 206 | options: ["updated", "any", "any"], 207 | }, 208 | } 209 | ] 210 | let parts = [] 211 | for (let i = 0; i < res.length; i++) { 212 | let part = res[i] 213 | parts.push({ 214 | title: titles[i], 215 | comics: part.comics, 216 | viewMore: viewMore[i] 217 | }) 218 | } 219 | return parts 220 | }, 221 | } 222 | ] 223 | 224 | // categories 225 | category = { 226 | /// title of the category page, used to identify the page, it should be unique 227 | title: "MangaDex", 228 | parts: [ 229 | { 230 | // title of the part 231 | name: "Tags", 232 | 233 | // fixed or random or dynamic 234 | // if random, need to provide `randomNumber` field, which indicates the number of comics to display at the same time 235 | // if dynamic, need to provide `loader` field, which indicates the function to load comics 236 | type: "dynamic", 237 | 238 | // number of comics to display at the same time 239 | // randomNumber: 5, 240 | 241 | // load function for dynamic type 242 | loader: () => { 243 | let categories = [] 244 | for (let tag of Object.keys(this.tags)) { 245 | categories.push({ 246 | label: tag, 247 | target: { 248 | page: "search", 249 | attributes: { 250 | keyword: `tag:${tag}`, 251 | }, 252 | } 253 | }) 254 | } 255 | return categories 256 | } 257 | } 258 | ], 259 | // enable ranking page 260 | enableRankingPage: false, 261 | } 262 | 263 | /// search related 264 | search = { 265 | /** 266 | * load search result 267 | * @param keyword {string} 268 | * @param options {string[]} - options from optionList 269 | * @param page {number} 270 | * @returns {Promise<{comics: Comic[], maxPage: number}>} 271 | */ 272 | load: async (keyword, options, page) => { 273 | let order = "" 274 | if (options[0] !== "any") { 275 | order = { 276 | "popular": `order[followedCount]=desc&`, 277 | "recent": `order[createdAt]=desc&`, 278 | "updated": `order[latestUploadedChapter]=desc&`, 279 | "rating": `order[rating]=desc&`, 280 | "follows": `order[followedCount]=desc&` 281 | }[options[0]] 282 | } 283 | let contentRating = "" 284 | if (options[1] !== "any") { 285 | contentRating = `contentRating[]=${options[1]}&` 286 | } 287 | let status = "" 288 | if (options[2] !== "any") { 289 | status = `status[]=${options[2]}&` 290 | } 291 | let url = `https://api.mangadex.org/manga?` + 292 | `includes[]=cover_art&` + 293 | `includes[]=artist&` + 294 | `includes[]=author&` + 295 | order + 296 | contentRating + 297 | status + 298 | `hasAvailableChapters=true&` + 299 | `limit=${this.comicsPerPage}` 300 | if (page && page > 1) { 301 | url += `&offset=${(page - 1) * this.comicsPerPage}` 302 | } 303 | if (keyword) { 304 | let splits = keyword.split(" ") 305 | let reformated = [] 306 | for (let s of splits) { 307 | if (s === "") { 308 | continue 309 | } 310 | if (s.startsWith('tag:')) { 311 | let tag = s.substring(4) 312 | tag = tag.replaceAll('_', ' ') 313 | let id = this.tags[tag] 314 | if (id !== undefined) { 315 | url += `&includedTags[]=${id}` 316 | } else { 317 | reformated.push(s) 318 | } 319 | } else if (s.startsWith('author:')) { 320 | let author = s.substring(7) 321 | author = author.replaceAll('_', ' ') 322 | let id = this.authors[author] 323 | if (id !== undefined) { 324 | url += `&authorOrArtist=${id}` 325 | } else { 326 | reformated.push(s) 327 | } 328 | } else if (s.startsWith('artist:')) { 329 | let artist = s.substring(7) 330 | artist = artist.replaceAll('_', ' ') 331 | let id = this.artists[artist] 332 | if (id !== undefined) { 333 | url += `&authorOrArtist=${id}` 334 | } else { 335 | reformated.push(s) 336 | } 337 | } else { 338 | reformated.push(s) 339 | } 340 | } 341 | keyword = reformated.join(" ") 342 | if (keyword !== "") 343 | url += `&title=${keyword}` 344 | } 345 | let res = await fetch(url) 346 | if (!res.ok) { 347 | throw new Error("Network response was not ok") 348 | } 349 | let data = await res.json() 350 | let total = data['total'] 351 | let maxPage = Math.ceil(total / this.comicsPerPage) 352 | let comics = [] 353 | for (let comic of data['data']) { 354 | comics.push(this.api.parseComic(comic)) 355 | } 356 | return { 357 | comics: comics, 358 | maxPage: maxPage 359 | } 360 | }, 361 | 362 | // provide options for search 363 | optionList: [ 364 | { 365 | label: "Sort By", 366 | type: "select", 367 | options: [ 368 | "any-Any", 369 | "popular-Popular", 370 | "recent-Recent", 371 | "updated-Updated", 372 | "rating-Rating", 373 | "follows-Follows", 374 | ], 375 | }, 376 | { 377 | label: "Content Rating", 378 | type: "select", 379 | options: [ 380 | "any-Any", 381 | "safe-Safe", 382 | "suggestive-Suggestive", 383 | "erotica-Erotica", 384 | ] 385 | }, 386 | { 387 | label: "Status", 388 | type: "select", 389 | options: [ 390 | "any-Any", 391 | "ongoing-Ongoing", 392 | "completed-Completed", 393 | "hiatus-Hiatus", 394 | "cancelled-Cancelled", 395 | ] 396 | }, 397 | ], 398 | 399 | // enable tags suggestions 400 | enableTagsSuggestions: false, 401 | } 402 | 403 | /// single comic related 404 | comic = { 405 | getComic: async (id) => { 406 | let res = await fetch(`https://api.mangadex.org/manga/${id}?includes[]=cover_art&includes[]=artist&includes[]=author`) 407 | if (!res.ok) { 408 | throw new Error("Network response was not ok") 409 | } 410 | let data = await res.json() 411 | return this.api.parseComic(data['data']) 412 | 413 | }, 414 | getChapters: async (id) => { 415 | let res = await fetch(`https://api.mangadex.org/manga/${id}/feed?limit=500&translatedLanguage[]=en&order[chapter]=asc`) 416 | if (!res.ok) { 417 | throw new Error("Network response was not ok") 418 | } 419 | let data = await res.json() 420 | let chapters = new Map() 421 | for (let chapter of data['data']) { 422 | let id = chapter['id'] 423 | let chapterId = chapter['attributes']['chapter'] 424 | let title = chapter['attributes']['title'] 425 | if (title) { 426 | title = `${chapterId}: ${title}` 427 | } else { 428 | title = chapterId 429 | } 430 | let volume = chapter['attributes']['volume'] 431 | if (volume) { 432 | volume = `Volume ${volume}` 433 | } else { 434 | volume = "No Volume" 435 | } 436 | if (chapters.get(volume) === undefined) { 437 | chapters.set(volume, new Map()) 438 | } 439 | chapters.get(volume).set(id, title) 440 | } 441 | return chapters 442 | }, 443 | getStats: async (id) => { 444 | let res = await fetch(`https://api.mangadex.org/statistics/manga/${id}`) 445 | if (!res.ok) { 446 | throw new Error("Network response was not ok") 447 | } 448 | let data = await res.json() 449 | return { 450 | comments: data['statistics'][id]['comments']?.['repliesCount'] || 0, 451 | follows: data['statistics'][id]['follows'] || 0, 452 | rating: data['statistics'][id]['rating']['average'] || 0, 453 | } 454 | }, 455 | /** 456 | * load comic info 457 | * @param id {string} 458 | * @returns {Promise} 459 | */ 460 | loadInfo: async (id) => { 461 | let res = await Promise.all([ 462 | this.comic.getComic(id), 463 | this.comic.getChapters(id), 464 | this.comic.getStats(id) 465 | ]) 466 | let comic = res[0] 467 | let chapters = res[1] 468 | let stats = res[2] 469 | 470 | return new ComicDetails({ 471 | id: comic.id, 472 | title: comic.title, 473 | subtitle: comic.subtitle, 474 | cover: comic.cover, 475 | tags: { 476 | "Tags": comic.tags, 477 | "Status": comic.status, 478 | "Authors": comic.authors, 479 | "Artists": comic.artists, 480 | }, 481 | description: comic.description, 482 | updateTime: comic.updateTime, 483 | uploadTime: comic.createTime, 484 | status: comic.status, 485 | chapters: chapters, 486 | stars: (stats.rating || 0) / 2, 487 | url: `https://mangadex.org/title/${comic.id}`, 488 | }) 489 | }, 490 | 491 | /** 492 | * rate a comic 493 | * @param id 494 | * @param rating {number} - [0-10] app use 5 stars, 1 rating = 0.5 stars, 495 | * @returns {Promise} - return any value to indicate success 496 | */ 497 | starRating: async (id, rating) => { 498 | // TODO: implement star rating 499 | }, 500 | 501 | /** 502 | * load images of a chapter 503 | * @param comicId {string} 504 | * @param epId {string?} 505 | * @returns {Promise<{images: string[]}>} 506 | */ 507 | loadEp: async (comicId, epId) => { 508 | if (!epId) { 509 | throw new Error("No chapter id provided") 510 | } 511 | let res = await fetch(`https://api.mangadex.org/at-home/server/${epId}`) 512 | if (!res.ok) { 513 | throw new Error("Network response was not ok") 514 | } 515 | let data = await res.json() 516 | let baseUrl = data['baseUrl'] 517 | let images = [] 518 | for (let image of data['chapter']['data']) { 519 | images.push(`${baseUrl}/data/${data['chapter']['hash']}/${image}`) 520 | } 521 | return { 522 | images: images 523 | } 524 | }, 525 | /** 526 | * [Optional] load comments 527 | * 528 | * Since app version 1.0.6, rich text is supported in comments. 529 | * Following html tags are supported: ['a', 'b', 'i', 'u', 's', 'br', 'span', 'img']. 530 | * span tag supports style attribute, but only support font-weight, font-style, text-decoration. 531 | * All images will be placed at the end of the comment. 532 | * Auto link detection is enabled, but only http/https links are supported. 533 | * @param comicId {string} 534 | * @param subId {string?} - ComicDetails.subId 535 | * @param page {number} 536 | * @param replyTo {string?} - commentId to reply, not null when reply to a comment 537 | * @returns {Promise<{comments: Comment[], maxPage: number?}>} 538 | */ 539 | loadComments: async (comicId, subId, page, replyTo) => { 540 | throw new Error("Not implemented") 541 | }, 542 | /** 543 | * [Optional] send a comment, return any value to indicate success 544 | * @param comicId {string} 545 | * @param subId {string?} - ComicDetails.subId 546 | * @param content {string} 547 | * @param replyTo {string?} - commentId to reply, not null when reply to a comment 548 | * @returns {Promise} 549 | */ 550 | sendComment: async (comicId, subId, content, replyTo) => { 551 | throw new Error("Not implemented") 552 | }, 553 | /** 554 | * [Optional] Handle tag click event 555 | * @param namespace {string} 556 | * @param tag {string} 557 | * @returns {PageJumpTarget} 558 | */ 559 | onClickTag: (namespace, tag) => { 560 | tag = tag.replaceAll(' ', '_') 561 | let keyword = tag 562 | if (namespace === "Tags") { 563 | keyword = `tag:${tag}` 564 | } else if (namespace === "Authors") { 565 | keyword = `author:${tag}` 566 | } else if (namespace === "Artists") { 567 | keyword = `artist:${tag}` 568 | } 569 | return { 570 | page: "search", 571 | attributes: { 572 | 'keyword': keyword, 573 | }, 574 | } 575 | }, 576 | } 577 | 578 | settings = {} 579 | 580 | // [Optional] translations for the strings in this config 581 | translation = { 582 | 'zh_CN': {}, 583 | 'zh_TW': {}, 584 | 'en': {} 585 | } 586 | 587 | tags = {"Oneshot":"0234a31e-a729-4e28-9d6a-3f87c4966b9e","Thriller":"07251805-a27e-4d59-b488-f0bfbec15168","Award Winning":"0a39b5a1-b235-4886-a747-1d05d216532d","Reincarnation":"0bc90acb-ccc1-44ca-a34a-b9f3a73259d0","Sci-Fi":"256c8bd9-4904-4360-bf4f-508a76d67183","Time Travel":"292e862b-2d17-4062-90a2-0356caa4ae27","Genderswap":"2bd2e8d0-f146-434a-9b51-fc9ff2c5fe6a","Loli":"2d1f5d56-a1e5-4d0d-a961-2193588b08ec","Traditional Games":"31932a7e-5b8e-49a6-9f12-2afa39dc544c","Official Colored":"320831a8-4026-470b-94f6-8353740e6f04","Historical":"33771934-028e-4cb3-8744-691e866a923e","Monsters":"36fd93ea-e8b8-445e-b836-358f02b3d33d","Action":"391b0423-d847-456f-aff0-8b0cfc03066b","Demons":"39730448-9a5f-48a2-85b0-a70db87b1233","Psychological":"3b60b75c-a2d7-4860-ab56-05f391bb889c","Ghosts":"3bb26d85-09d5-4d2e-880c-c34b974339e9","Animals":"3de8c75d-8ee3-48ff-98ee-e20a65c86451","Long Strip":"3e2b8dae-350e-4ab8-a8ce-016e844b9f0d","Romance":"423e2eae-a7a2-4a8b-ac03-a8351462d71d","Ninja":"489dd859-9b61-4c37-af75-5b18e88daafc","Comedy":"4d32cc48-9f00-4cca-9b5a-a839f0764984","Mecha":"50880a9d-5440-4732-9afb-8f457127e836","Anthology":"51d83883-4103-437c-b4b1-731cb73d786c","Boys' Love":"5920b825-4181-4a17-beeb-9918b0ff7a30","Incest":"5bd0e105-4481-44ca-b6e7-7544da56b1a3","Crime":"5ca48985-9a9d-4bd8-be29-80dc0303db72","Survival":"5fff9cde-849c-4d78-aab0-0d52b2ee1d25","Zombies":"631ef465-9aba-4afb-b0fc-ea10efe274a8","Reverse Harem":"65761a2a-415e-47f3-bef2-a9dababba7a6","Sports":"69964a64-2f90-4d33-beeb-f3ed2875eb4c","Superhero":"7064a261-a137-4d3a-8848-2d385de3a99c","Martial Arts":"799c202e-7daa-44eb-9cf7-8a3c0441531e","Fan Colored":"7b2ce280-79ef-4c09-9b58-12b7c23a9b78","Samurai":"81183756-1453-4c81-aa9e-f6e1b63be016","Magical Girls":"81c836c9-914a-4eca-981a-560dad663e73","Mafia":"85daba54-a71c-4554-8a28-9901a8b0afad","Adventure":"87cc87cd-a395-47af-b27a-93258283bbc6","Self-Published":"891cf039-b895-47f0-9229-bef4c96eccd4","Virtual Reality":"8c86611e-fab7-4986-9dec-d1a2f44acdd5","Office Workers":"92d6d951-ca5e-429c-ac78-451071cbf064","Video Games":"9438db5a-7e2a-4ac0-b39e-e0d95a34b8a8","Post-Apocalyptic":"9467335a-1b83-4497-9231-765337a00b96","Sexual Violence":"97893a4c-12af-4dac-b6be-0dffb353568e","Crossdressing":"9ab53f92-3eed-4e9b-903a-917c86035ee3","Magic":"a1f53773-c69a-4ce5-8cab-fffcd90b1565","Girls' Love":"a3c67850-4684-404e-9b7f-c69850ee5da6","Harem":"aafb99c1-7f60-43fa-b75f-fc9502ce29c7","Military":"ac72833b-c4e9-4878-b9db-6c8a4a99444a","Wuxia":"acc803a4-c95a-4c22-86fc-eb6b582d82a2","Isekai":"ace04997-f6bd-436e-b261-779182193d3d","4-Koma":"b11fda93-8f1d-4bef-b2ed-8803d3733170","Doujinshi":"b13b2a48-c720-44a9-9c77-39c9979373fb","Philosophical":"b1e97889-25b4-4258-b28b-cd7f4d28ea9b","Gore":"b29d6a3d-1569-4e7a-8caf-7557bc92cd5d","Drama":"b9af3a63-f058-46de-a9a0-e0c13906197a","Medical":"c8cbe35b-1b2b-4a3f-9c37-db84c4514856","School Life":"caaa44eb-cd40-4177-b930-79d3ef2afe87","Horror":"cdad7e68-1419-41dd-bdce-27753074a640","Fantasy":"cdc58593-87dd-415e-bbc0-2ec27bf404cc","Villainess":"d14322ac-4d6f-4e9b-afd9-629d5f4d8a41","Vampires":"d7d1730f-6eb0-4ba6-9437-602cac38664c","Delinquents":"da2d50ca-3018-4cc0-ac7a-6b7d472a29ea","Monster Girls":"dd1f77c5-dea9-4e2b-97ae-224af09caf99","Shota":"ddefd648-5140-4e5f-ba18-4eca4071d19b","Police":"df33b754-73a3-4c54-80e6-1a74a8058539","Web Comic":"e197df38-d0e7-43b5-9b09-2842d0c326dd","Slice of Life":"e5301a23-ebd9-49dd-a0cb-2add944c7fe9","Aliens":"e64f6742-c834-471d-8d72-dd51fc02b835","Cooking":"ea2bc92d-1c26-4930-9b7c-d5c0dc1b6869","Supernatural":"eabc5b4c-6aff-42f3-b657-3e90cbd00b75","Mystery":"ee968100-4191-4968-93d3-f82d72be7e46","Adaptation":"f4122d1c-3b44-44d0-9936-ff7502c39ad3","Music":"f42fbf9e-188a-447b-9fdc-f19dc1e4d685","Full Color":"f5ba408b-0e7a-484d-8d49-4e9125ac96de","Tragedy":"f8f62932-27da-4fe4-8ee1-6779a8c5edba","Gyaru":"fad12b5e-68ba-460e-b933-9ae8318f5b65"} 588 | 589 | // [authors] and [artists] are dynamic map 590 | authors = {} 591 | artists = {} 592 | } -------------------------------------------------------------------------------- /picacg.js: -------------------------------------------------------------------------------- 1 | class Picacg extends ComicSource { 2 | name = "Picacg" 3 | 4 | key = "picacg" 5 | 6 | version = "1.0.3" 7 | 8 | minAppVersion = "1.0.0" 9 | 10 | url = "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@main/picacg.js" 11 | 12 | api = "https://picaapi.picacomic.com" 13 | 14 | apiKey = "C69BAF41DA5ABD1FFEDC6D2FEA56B"; 15 | 16 | createSignature(path, nonce, time, method) { 17 | let data = path + time + nonce + method + this.apiKey 18 | let key = '~d}$Q7$eIni=V)9\\RK/P.RM4;9[7|@/CA}b~OW!3?EV`:<>M7pddUBL5n|0/*Cn' 19 | let s = Convert.encodeUtf8(key) 20 | let h = Convert.encodeUtf8(data.toLowerCase()) 21 | return Convert.hmacString(s, h, 'sha256') 22 | } 23 | 24 | buildHeaders(method, path, token) { 25 | let uuid = createUuid() 26 | let nonce = uuid.replace(/-/g, '') 27 | let time = (new Date().getTime() / 1000).toFixed(0) 28 | let signature = this.createSignature(path, nonce, time, method.toUpperCase()) 29 | return { 30 | "api-key": "C69BAF41DA5ABD1FFEDC6D2FEA56B", 31 | "accept": "application/vnd.picacomic.com.v1+json", 32 | "app-channel": this.loadSetting('appChannel'), 33 | "authorization": token ?? "", 34 | "time": time, 35 | "nonce": nonce, 36 | "app-version": "2.2.1.3.3.4", 37 | "app-uuid": "defaultUuid", 38 | "image-quality": this.loadSetting('imageQuality'), 39 | "app-platform": "android", 40 | "app-build-version": "45", 41 | "Content-Type": "application/json; charset=UTF-8", 42 | "user-agent": "okhttp/3.8.1", 43 | "version": "v1.4.1", 44 | "Host": "picaapi.picacomic.com", 45 | "signature": signature, 46 | } 47 | } 48 | 49 | account = { 50 | reLogin: async () => { 51 | if(!this.isLogged) { 52 | throw new Error('Not logged in'); 53 | } 54 | let account = this.loadData('account') 55 | if(!Array.isArray(account)) { 56 | throw new Error('Failed to reLogin: Invalid account data'); 57 | } 58 | let username = account[0] 59 | let password = account[1] 60 | return await this.account.login(username, password) 61 | }, 62 | login: async (account, pwd) => { 63 | let res = await Network.post( 64 | `${this.api}/auth/sign-in`, 65 | this.buildHeaders('POST', 'auth/sign-in'), 66 | { 67 | email: account, 68 | password: pwd 69 | }) 70 | 71 | if (res.status === 200) { 72 | let json = JSON.parse(res.body) 73 | if (!json.data?.token) { 74 | throw 'Failed to get token\nResponse: ' + res.body 75 | } 76 | this.saveData('token', json.data.token) 77 | return 'ok' 78 | } 79 | 80 | throw 'Failed to login' 81 | }, 82 | 83 | logout: () => { 84 | this.deleteData('token') 85 | }, 86 | 87 | registerWebsite: "https://manhuabika.com/pregister/?" 88 | } 89 | 90 | parseComic(comic) { 91 | let tags = [] 92 | tags.push(...(comic.tags ?? [])) 93 | tags.push(...(comic.categories ?? [])) 94 | return new Comic({ 95 | id: comic._id, 96 | title: comic.title, 97 | subTitle: comic.author, 98 | cover: comic.thumb.fileServer + '/static/' + comic.thumb.path, 99 | tags: tags, 100 | description: `${comic.totalLikes ?? comic.likesCount} likes`, 101 | maxPage: comic.pagesCount, 102 | }) 103 | } 104 | 105 | explore = [ 106 | { 107 | title: "Picacg Random", 108 | type: "multiPageComicList", 109 | load: async (page) => { 110 | if (!this.isLogged) { 111 | throw 'Not logged in' 112 | } 113 | let res = await Network.get( 114 | `${this.api}/comics/random`, 115 | this.buildHeaders('GET', 'comics/random', this.loadData('token')) 116 | ) 117 | if(res.status === 401) { 118 | await this.account.reLogin() 119 | res = await Network.get( 120 | `${this.api}/comics/random`, 121 | this.buildHeaders('GET', 'comics/random', this.loadData('token')) 122 | ) 123 | } 124 | if (res.status !== 200) { 125 | throw 'Invalid status code: ' + res.status 126 | } 127 | let data = JSON.parse(res.body) 128 | let comics = [] 129 | data.data.comics.forEach(c => { 130 | comics.push(this.parseComic(c)) 131 | }) 132 | return { 133 | comics: comics 134 | } 135 | } 136 | }, 137 | { 138 | title: "Picacg Latest", 139 | type: "multiPageComicList", 140 | load: async (page) => { 141 | if (!this.isLogged) { 142 | throw 'Not logged in' 143 | } 144 | let res = await Network.get( 145 | `${this.api}/comics?page=${page}&s=dd`, 146 | this.buildHeaders('GET', `comics?page=${page}&s=dd`, this.loadData('token')) 147 | ) 148 | if(res.status === 401) { 149 | await this.account.reLogin() 150 | res = await Network.get( 151 | `${this.api}/comics?page=${page}&s=dd`, 152 | this.buildHeaders('GET', `comics?page=${page}&s=dd`, this.loadData('token')) 153 | ) 154 | } 155 | if (res.status !== 200) { 156 | throw 'Invalid status code: ' + res.status 157 | } 158 | let data = JSON.parse(res.body) 159 | let comics = [] 160 | data.data.comics.docs.forEach(c => { 161 | comics.push(this.parseComic(c)) 162 | }) 163 | return { 164 | comics: comics 165 | } 166 | } 167 | } 168 | ] 169 | 170 | /// 分类页面 171 | /// 一个漫画源只能有一个分类页面, 也可以没有, 设置为null禁用分类页面 172 | category = { 173 | /// 标题, 同时为标识符, 不能与其他漫画源的分类页面重复 174 | title: "Picacg", 175 | parts: [ 176 | { 177 | name: "主题", 178 | type: "fixed", 179 | categories: [ 180 | "大家都在看", 181 | "大濕推薦", 182 | "那年今天", 183 | "官方都在看", 184 | "嗶咔漢化", 185 | "全彩", 186 | "長篇", 187 | "同人", 188 | "短篇", 189 | "圓神領域", 190 | "碧藍幻想", 191 | "CG雜圖", 192 | "英語 ENG", 193 | "生肉", 194 | "純愛", 195 | "百合花園", 196 | "耽美花園", 197 | "偽娘哲學", 198 | "後宮閃光", 199 | "扶他樂園", 200 | "單行本", 201 | "姐姐系", 202 | "妹妹系", 203 | "SM", 204 | "性轉換", 205 | "足の恋", 206 | "人妻", 207 | "NTR", 208 | "強暴", 209 | "非人類", 210 | "艦隊收藏", 211 | "Love Live", 212 | "SAO 刀劍神域", 213 | "Fate", 214 | "東方", 215 | "WEBTOON", 216 | "禁書目錄", 217 | "歐美", 218 | "Cosplay", 219 | "重口地帶" 220 | ], 221 | itemType: "category", 222 | } 223 | ], 224 | enableRankingPage: true, 225 | } 226 | 227 | /// 分类漫画页面, 即点击分类标签后进入的页面 228 | categoryComics = { 229 | load: async (category, param, options, page) => { 230 | let type = param ?? 'c' 231 | let res = await Network.get( 232 | `${this.api}/comics?page=${page}&${type}=${encodeURIComponent(category)}&s=${options[0]}`, 233 | this.buildHeaders('GET', `comics?page=${page}&${type}=${encodeURIComponent(category)}&s=${options[0]}`, this.loadData('token')) 234 | ) 235 | if(res.status === 401) { 236 | await this.account.reLogin() 237 | res = await Network.get( 238 | `${this.api}/comics?page=${page}&${type}=${encodeURIComponent(category)}&s=${options[0]}`, 239 | this.buildHeaders('GET', `comics?page=${page}&${type}=${encodeURIComponent(category)}&s=${options[0]}`, this.loadData('token')) 240 | ) 241 | } 242 | if (res.status !== 200) { 243 | throw 'Invalid status code: ' + res.status 244 | } 245 | let data = JSON.parse(res.body) 246 | let comics = [] 247 | data.data.comics.docs.forEach(c => { 248 | comics.push(this.parseComic(c)) 249 | }) 250 | return { 251 | comics: comics, 252 | maxPage: data.data.comics.pages 253 | } 254 | }, 255 | // 提供选项 256 | optionList: [ 257 | { 258 | options: [ 259 | "dd-New to old", 260 | "da-Old to new", 261 | "ld-Most likes", 262 | "vd-Most nominated", 263 | ], 264 | } 265 | ], 266 | ranking: { 267 | options: [ 268 | "H24-Day", 269 | "D7-Week", 270 | "D30-Month", 271 | ], 272 | load: async (option, page) => { 273 | let res = await Network.get( 274 | `${this.api}/comics/leaderboard?tt=${option}&ct=VC`, 275 | this.buildHeaders('GET', `comics/leaderboard?tt=${option}&ct=VC`, this.loadData('token')) 276 | ) 277 | if(res.status === 401) { 278 | await this.account.reLogin() 279 | res = await Network.get( 280 | `${this.api}/comics/leaderboard?tt=${option}&ct=VC`, 281 | this.buildHeaders('GET', `comics/leaderboard?tt=${option}&ct=VC`, this.loadData('token')) 282 | ) 283 | } 284 | if (res.status !== 200) { 285 | throw 'Invalid status code: ' + res.status 286 | } 287 | let data = JSON.parse(res.body) 288 | let comics = [] 289 | data.data.comics.forEach(c => { 290 | comics.push(this.parseComic(c)) 291 | }) 292 | return { 293 | comics: comics, 294 | maxPage: 1 295 | } 296 | } 297 | } 298 | } 299 | 300 | /// 搜索 301 | search = { 302 | load: async (keyword, options, page) => { 303 | let res = await Network.post( 304 | `${this.api}/comics/advanced-search?page=${page}`, 305 | this.buildHeaders('POST', `comics/advanced-search?page=${page}`, this.loadData('token')), 306 | JSON.stringify({ 307 | keyword: keyword, 308 | sort: options[0], 309 | }) 310 | ) 311 | if(res.status === 401) { 312 | await this.account.reLogin() 313 | res = await Network.post( 314 | `${this.api}/comics/advanced-search?page=${page}`, 315 | this.buildHeaders('POST', `comics/advanced-search?page=${page}`, this.loadData('token')), 316 | JSON.stringify({ 317 | keyword: keyword, 318 | sort: options[0], 319 | }) 320 | ) 321 | } 322 | if (res.status !== 200) { 323 | throw 'Invalid status code: ' + res.status 324 | } 325 | let data = JSON.parse(res.body) 326 | let comics = [] 327 | data.data.comics.docs.forEach(c => { 328 | comics.push(this.parseComic(c)) 329 | }) 330 | return { 331 | comics: comics, 332 | maxPage: data.data.comics.pages 333 | } 334 | }, 335 | optionList: [ 336 | { 337 | options: [ 338 | "dd-New to old", 339 | "da-Old to new", 340 | "ld-Most likes", 341 | "vd-Most nominated", 342 | ], 343 | label: "Sort" 344 | } 345 | ] 346 | } 347 | 348 | /// 收藏 349 | favorites = { 350 | multiFolder: false, 351 | /// 添加或者删除收藏 352 | addOrDelFavorite: async (comicId, folderId, isAdding) => { 353 | let res = await Network.post( 354 | `${this.api}/comics/${comicId}/favourite`, 355 | this.buildHeaders('POST', `comics/${comicId}/favourite`, this.loadData('token')), 356 | '{}' 357 | ) 358 | if(res.status === 401) { 359 | throw `Login expired` 360 | } 361 | if(res.status !== 200) { 362 | throw 'Invalid status code: ' + res.status 363 | } 364 | return 'ok' 365 | }, 366 | /// 加载漫画 367 | loadComics: async (page, folder) => { 368 | let sort = this.loadSetting('favoriteSort') 369 | let res = await Network.get( 370 | `${this.api}/users/favourite?page=${page}&s=${sort}`, 371 | this.buildHeaders('GET', `users/favourite?page=${page}&s=${sort}`, this.loadData('token')) 372 | ) 373 | if(res.status === 401) { 374 | throw `Login expired` 375 | } 376 | if (res.status !== 200) { 377 | throw 'Invalid status code: ' + res.status 378 | } 379 | let data = JSON.parse(res.body) 380 | let comics = [] 381 | data.data.comics.docs.forEach(c => { 382 | comics.push(this.parseComic(c)) 383 | }) 384 | return { 385 | comics: comics, 386 | maxPage: data.data.comics.pages 387 | } 388 | } 389 | } 390 | 391 | /// 单个漫画相关 392 | comic = { 393 | // 加载漫画信息 394 | loadInfo: async (id) => { 395 | let infoLoader = async () => { 396 | let res = await Network.get( 397 | `${this.api}/comics/${id}`, 398 | this.buildHeaders('GET', `comics/${id}`, this.loadData('token')) 399 | ) 400 | if (res.status !== 200) { 401 | throw 'Invalid status code: ' + res.status 402 | } 403 | let data = JSON.parse(res.body) 404 | return data.data.comic 405 | } 406 | let epsLoader = async () => { 407 | let eps = new Map(); 408 | let i = 1; 409 | let j = 1; 410 | let allEps = []; 411 | while (true) { 412 | let res = await Network.get( 413 | `${this.api}/comics/${id}/eps?page=${i}`, 414 | this.buildHeaders('GET', `comics/${id}/eps?page=${i}`, this.loadData('token')) 415 | ); 416 | if (res.status !== 200) { 417 | throw 'Invalid status code: ' + res.status; 418 | } 419 | let data = JSON.parse(res.body); 420 | allEps.push(...data.data.eps.docs); 421 | if (data.data.eps.pages === i) { 422 | break; 423 | } 424 | i++; 425 | } 426 | allEps.sort((a, b) => a.order - b.order); 427 | allEps.forEach(e => { 428 | eps.set(j.toString(), e.title); 429 | j++; 430 | }); 431 | return eps; 432 | } 433 | let relatedLoader = async () => { 434 | let res = await Network.get( 435 | `${this.api}/comics/${id}/recommendation`, 436 | this.buildHeaders('GET', `comics/${id}/recommendation`, this.loadData('token')) 437 | ) 438 | if (res.status !== 200) { 439 | throw 'Invalid status code: ' + res.status 440 | } 441 | let data = JSON.parse(res.body) 442 | let comics = [] 443 | data.data.comics.forEach(c => { 444 | comics.push(this.parseComic(c)) 445 | }) 446 | return comics 447 | } 448 | let info, eps, related 449 | try { 450 | [info, eps, related] = await Promise.all([infoLoader(), epsLoader(), relatedLoader()]) 451 | } 452 | catch (e) { 453 | if (e === 'Invalid status code: 401') { 454 | await this.account.reLogin(); 455 | [info, eps, related] = await Promise.all([infoLoader(), epsLoader(), relatedLoader()]); 456 | } 457 | throw e 458 | } 459 | let tags = {} 460 | if(info.author) { 461 | tags['Author'] = [info.author]; 462 | } 463 | if(info.chineseTeam) { 464 | tags['Chinese Team'] = [info.chineseTeam]; 465 | } 466 | let updateTime = new Date(info.updated_at) 467 | let formattedDate = updateTime.getFullYear() + '-' + (updateTime.getMonth() + 1) + '-' + updateTime.getDate() 468 | return new ComicDetails({ 469 | title: info.title, 470 | cover: info.thumb.fileServer + '/static/' + info.thumb.path, 471 | description: info.description, 472 | tags: { 473 | ...tags, 474 | 'Categories': info.categories, 475 | 'Tags': info.tags, 476 | }, 477 | chapters: eps, 478 | isFavorite: info.isFavourite ?? false, 479 | isLiked: info.isLiked ?? false, 480 | recommend: related, 481 | commentCount: info.commentsCount, 482 | likesCount: info.likesCount, 483 | uploader: info._creator.name, 484 | updateTime: formattedDate, 485 | maxPage: info.pagesCount, 486 | }) 487 | }, 488 | // 获取章节图片 489 | loadEp: async (comicId, epId) => { 490 | let images = [] 491 | let i = 1 492 | while(true) { 493 | let res = await Network.get( 494 | `${this.api}/comics/${comicId}/order/${epId}/pages?page=${i}`, 495 | this.buildHeaders('GET', `comics/${comicId}/order/${epId}/pages?page=${i}`, this.loadData('token')) 496 | ) 497 | if (res.status !== 200) { 498 | throw 'Invalid status code: ' + res.status 499 | } 500 | let data = JSON.parse(res.body) 501 | data.data.pages.docs.forEach(p => { 502 | images.push(p.media.fileServer + '/static/' + p.media.path) 503 | }) 504 | if(data.data.pages.pages === i) { 505 | break 506 | } 507 | i++ 508 | } 509 | return { 510 | images: images 511 | } 512 | }, 513 | likeComic: async (id, isLike) => { 514 | var res = await Network.post( 515 | `${this.api}/comics/${id}/like`, 516 | this.buildHeaders('POST', `comics/${id}/like`, this.loadData('token')), 517 | {} 518 | ); 519 | if (res.status !== 200) { 520 | throw 'Invalid status code: ' + res.status 521 | } 522 | return 'ok' 523 | }, 524 | // 加载评论 525 | loadComments: async (comicId, subId, page, replyTo) => { 526 | function parseComment(c) { 527 | return new Comment({ 528 | userName: c._user.name, 529 | avatar: c._user.avatar ? c._user.avatar.fileServer + '/static/' + c._user.avatar.path : undefined, 530 | id: c._id, 531 | content: c.content, 532 | isLiked: c.isLiked, 533 | score: c.likesCount ?? 0, 534 | replyCount: c.commentsCount, 535 | time: c.created_at, 536 | }) 537 | } 538 | let comments = [] 539 | 540 | let maxPage = 1 541 | 542 | if(replyTo) { 543 | let res = await Network.get( 544 | `${this.api}/comments/${replyTo}/childrens?page=${page}`, 545 | this.buildHeaders('GET', `comments/${replyTo}/childrens?page=${page}`, this.loadData('token')) 546 | ) 547 | if (res.status !== 200) { 548 | throw 'Invalid status code: ' + res.status 549 | } 550 | let data = JSON.parse(res.body) 551 | data.data.comments.docs.forEach(c => { 552 | comments.push(parseComment(c)) 553 | }) 554 | maxPage = data.data.comments.pages 555 | } else { 556 | let res = await Network.get( 557 | `${this.api}/comics/${comicId}/comments?page=${page}`, 558 | this.buildHeaders('GET', `comics/${comicId}/comments?page=${page}`, this.loadData('token')) 559 | ) 560 | if (res.status !== 200) { 561 | throw 'Invalid status code: ' + res.status 562 | } 563 | let data = JSON.parse(res.body) 564 | data.data.comments.docs.forEach(c => { 565 | comments.push(parseComment(c)) 566 | }) 567 | maxPage = data.data.comments.pages 568 | } 569 | return { 570 | comments: comments, 571 | maxPage: maxPage 572 | } 573 | }, 574 | // 发送评论, 返回任意值表示成功 575 | sendComment: async (comicId, subId, content, replyTo) => { 576 | if(replyTo) { 577 | let res = await Network.post( 578 | `${this.api}/comments/${replyTo}`, 579 | this.buildHeaders('POST', `/comments/${replyTo}`, this.loadData('token')), 580 | JSON.stringify({ 581 | content: content 582 | }) 583 | ) 584 | if (res.status !== 200) { 585 | throw 'Invalid status code: ' + res.status 586 | } 587 | } else { 588 | let res = await Network.post( 589 | `${this.api}/comics/${comicId}/comments`, 590 | this.buildHeaders('POST', `/comics/${comicId}/comments`, this.loadData('token')), 591 | JSON.stringify({ 592 | content: content 593 | }) 594 | ) 595 | if (res.status !== 200) { 596 | throw 'Invalid status code: ' + res.status 597 | } 598 | } 599 | return 'ok' 600 | }, 601 | likeComment: async (comicId, subId, commentId, isLike) => { 602 | let res = await Network.post( 603 | `${this.api}/comments/${commentId}/like`, 604 | this.buildHeaders('POST', `/comments/${commentId}/like`, this.loadData('token')), 605 | '{}' 606 | ) 607 | if (res.status !== 200) { 608 | throw 'Invalid status code: ' + res.status 609 | } 610 | return 'ok' 611 | }, 612 | onClickTag: (namespace, tag) => { 613 | if(namespace === 'Author') { 614 | return { 615 | action: 'category', 616 | keyword: tag, 617 | param: 'a', 618 | } 619 | } else if (namespace === 'Categories') { 620 | return { 621 | action: 'category', 622 | keyword: tag, 623 | param: 'c', 624 | } 625 | } else { 626 | return { 627 | action: 'search', 628 | keyword: tag, 629 | } 630 | } 631 | } 632 | } 633 | 634 | settings = { 635 | 'imageQuality': { 636 | type: 'select', 637 | title: 'Image quality', 638 | options: [ 639 | { 640 | value: 'original', 641 | }, 642 | { 643 | value: 'medium' 644 | }, 645 | { 646 | value: 'low' 647 | } 648 | ], 649 | default: 'original', 650 | }, 651 | 'appChannel': { 652 | type: 'select', 653 | title: 'App channel', 654 | options: [ 655 | { 656 | value: '1', 657 | }, 658 | { 659 | value: '2' 660 | }, 661 | { 662 | value: '3' 663 | } 664 | ], 665 | default: '3', 666 | }, 667 | 'favoriteSort': { 668 | type: 'select', 669 | title: 'Favorite sort', 670 | options: [ 671 | { 672 | value: 'dd', 673 | text: 'New to old' 674 | }, 675 | { 676 | value: 'da', 677 | text: 'Old to new' 678 | }, 679 | ], 680 | default: 'dd', 681 | } 682 | } 683 | 684 | translation = { 685 | 'zh_CN': { 686 | 'Picacg Random': "哔咔随机", 687 | 'Picacg Latest': "哔咔最新", 688 | 'New to old': "新到旧", 689 | 'Old to new': "旧到新", 690 | 'Most likes': "最多喜欢", 691 | 'Most nominated': "最多指名", 692 | 'Day': "日", 693 | 'Week': "周", 694 | 'Month': "月", 695 | 'Author': "作者", 696 | 'Chinese Team': "汉化组", 697 | 'Categories': "分类", 698 | 'Tags': "标签", 699 | 'Image quality': "图片质量", 700 | 'App channel': "分流", 701 | 'Favorite sort': "收藏排序", 702 | 'Sort': "排序", 703 | }, 704 | 'zh_TW': { 705 | 'Picacg Random': "哔咔隨機", 706 | 'Picacg Latest': "哔咔最新", 707 | 'New to old': "新到舊", 708 | 'Old to new': "舊到新", 709 | 'Most likes': "最多喜歡", 710 | 'Most nominated': "最多指名", 711 | 'Day': "日", 712 | 'Week': "周", 713 | 'Month': "月", 714 | 'Author': "作者", 715 | 'Chinese Team': "漢化組", 716 | 'Categories': "分類", 717 | 'Tags': "標籤", 718 | 'Image quality': "圖片質量", 719 | 'App channel': "分流", 720 | 'Favorite sort': "收藏排序", 721 | 'Sort': "排序", 722 | }, 723 | } 724 | } -------------------------------------------------------------------------------- /shonenjumpplus.js: -------------------------------------------------------------------------------- 1 | class ShonenJumpPlus extends ComicSource { 2 | name = "少年ジャンプ+"; 3 | key = "shonen_jump_plus"; 4 | version = "1.0.0"; 5 | minAppVersion = "1.2.1"; 6 | url = 7 | "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@main/shonen_jump_plus.js"; 8 | 9 | deviceId = this.generateDeviceId(); 10 | bearerToken = null; 11 | userAccountId = null; 12 | tokenExpiry = 0; 13 | 14 | get headers() { 15 | return { 16 | "Origin": "https://shonenjumpplus.com", 17 | "Referer": "https://shonenjumpplus.com/", 18 | "X-Giga-Device-Id": this.deviceId, 19 | "User-Agent": "ShonenJumpPlus-Android/4.0.18", 20 | }; 21 | } 22 | 23 | apiBase = `https://shonenjumpplus.com/api/v1`; 24 | generateDeviceId() { 25 | let result = ""; 26 | const chars = "0123456789abcdef"; 27 | for (let i = 0; i < 16; i++) { 28 | result += chars[randomInt(0, chars.length - 1)]; 29 | } 30 | return result; 31 | } 32 | 33 | init() { } 34 | 35 | explore = [ 36 | { 37 | title: "少年ジャンプ+", 38 | type: "singlePageWithMultiPart", 39 | load: async () => { 40 | await this.ensureAuth(); 41 | 42 | const response = await this.graphqlRequest("HomeCacheable", {}); 43 | 44 | if (!response || !response.data || !response.data.homeSections) { 45 | throw "Cannot fetch home sections"; 46 | } 47 | 48 | const sections = response.data.homeSections; 49 | const dailyRankingSection = sections.find((section) => 50 | section.__typename === "DailyRankingSection" 51 | ); 52 | 53 | if (!dailyRankingSection || !dailyRankingSection.dailyRankings) { 54 | throw "Cannot fetch daily ranking data"; 55 | } 56 | 57 | const dailyRanking = dailyRankingSection.dailyRankings.find((ranking) => 58 | ranking.ranking && ranking.ranking.__typename === "DailyRanking" 59 | ); 60 | 61 | if ( 62 | !dailyRanking || !dailyRanking.ranking || 63 | !dailyRanking.ranking.items || !dailyRanking.ranking.items.edges 64 | ) { 65 | throw "Cannot fetch ranking data structure"; 66 | } 67 | 68 | const rankingItems = dailyRanking.ranking.items.edges.map((edge) => 69 | edge.node 70 | ).filter((node) => 71 | node.__typename === "DailyRankingValidItem" && node.product 72 | ); 73 | 74 | function parseComic(item) { 75 | const series = item.product.series; 76 | if (!series) return null; 77 | 78 | const cover = series.squareThumbnailUriTemplate || 79 | series.horizontalThumbnailUriTemplate; 80 | 81 | return { 82 | id: series.databaseId, 83 | title: series.title || "", 84 | cover: cover 85 | ? cover.replace("{height}", "500").replace("{width}", "500") 86 | : "", 87 | tags: [], 88 | description: `Ranking: ${item.rank} · Views: ${item.viewCount || "Unknown" 89 | }`, 90 | }; 91 | } 92 | 93 | const comics = rankingItems.map(parseComic).filter((comic) => 94 | comic !== null 95 | ); 96 | 97 | const result = {}; 98 | result["Daily Ranking"] = comics; 99 | return result; 100 | }, 101 | }, 102 | ]; 103 | 104 | search = { 105 | load: async (keyword, _, page) => { 106 | if (!this.bearerToken || Date.now() > this.tokenExpiry) { 107 | await this.fetchBearerToken(); 108 | } 109 | 110 | const operationName = "SearchResult"; 111 | 112 | const response = await this.graphqlRequest(operationName, { 113 | keyword, 114 | }); 115 | const edges = response?.data?.search?.edges || []; 116 | const pageInfo = response?.data?.search?.pageInfo || {}; 117 | 118 | const comics = edges.map(({ node }) => { 119 | const cover = node.latestIssue?.thumbnailUriTemplate || 120 | node.thumbnailUriTemplate; 121 | if (node.__typename === "Series") { 122 | return new Comic({ 123 | id: node.databaseId, 124 | title: node.title || "", 125 | cover: this.replaceCoverUrl(cover), 126 | extra: { 127 | author: node.author?.name || "", 128 | }, 129 | }); 130 | } 131 | if (node.__typename === "MagazineLabel") { 132 | return new Comic({ 133 | id: node.databaseId, 134 | title: node.title || "", 135 | cover: this.replaceCoverUrl(cover), 136 | }); 137 | } 138 | return null; 139 | }).filter(Boolean); 140 | 141 | return { 142 | comics, 143 | maxPage: pageInfo.hasNextPage ? (page || 1) + 1 : (page || 1), 144 | endCursor: pageInfo.endCursor, 145 | }; 146 | }, 147 | }; 148 | 149 | comic = { 150 | loadInfo: async (id) => { 151 | await this.ensureAuth(); 152 | const seriesData = await this.fetchSeriesDetail(id); 153 | const chapters = await this.fetchEpisodes(id); 154 | 155 | return new ComicDetails({ 156 | title: seriesData.title || "", 157 | subtitle: seriesData.author?.name || "", 158 | cover: this.replaceCoverUrl(seriesData.thumbnailUriTemplate), 159 | description: seriesData.descriptionBanner?.text || "", 160 | tags: { 161 | "Author": [seriesData.author?.name || ""], 162 | }, 163 | chapters, 164 | }); 165 | }, 166 | 167 | loadEp: async (comicId, epId) => { 168 | await this.ensureAuth(); 169 | const episodeId = this.normalizeEpisodeId(epId); 170 | const episodeData = await this.fetchEpisodePages(episodeId); 171 | 172 | if (!this.isEpisodeAccessible(episodeData)) { 173 | await this.handleEpisodePurchase(episodeData); 174 | return this.comic.loadEp(comicId, epId); 175 | } 176 | 177 | return this.buildImageUrls(episodeData); 178 | }, 179 | 180 | onImageLoad: (url) => { 181 | const [cleanUrl, token] = url.split("?token="); 182 | return { 183 | url: cleanUrl, 184 | headers: { "X-Giga-Page-Image-Auth": token }, 185 | }; 186 | }, 187 | 188 | onClickTag: (namespace, tag) => { 189 | if (namespace === "Author") { 190 | return { 191 | action: "search", 192 | keyword: `${tag}`, 193 | param: null, 194 | }; 195 | } 196 | throw "Unsupported tag namespace: " + namespace; 197 | }, 198 | }; 199 | 200 | async ensureAuth() { 201 | if (!this.bearerToken || Date.now() > this.tokenExpiry) { 202 | await this.fetchBearerToken(); 203 | } 204 | } 205 | 206 | async graphqlRequest(operationName, variables) { 207 | const payload = { 208 | operationName, 209 | variables, 210 | query: GraphQLQueries[operationName], 211 | }; 212 | const response = await Network.post( 213 | `${this.apiBase}/graphql?opname=${operationName}`, 214 | { 215 | ...this.headers, 216 | "Authorization": `Bearer ${this.bearerToken}`, 217 | "Accept": "application/json", 218 | "X-APOLLO-OPERATION-NAME": operationName, 219 | "Content-Type": "application/json", 220 | }, 221 | JSON.stringify(payload), 222 | ); 223 | 224 | if (response.status !== 200) throw `Invalid status: ${response.status}`; 225 | return JSON.parse(response.body); 226 | } 227 | 228 | normalizeEpisodeId(epId) { 229 | if (typeof epId === "object") return epId.id; 230 | if (typeof epId === "string" && epId.includes("/")) { 231 | return epId.split("/").pop(); 232 | } 233 | return epId; 234 | } 235 | 236 | replaceCoverUrl(url) { 237 | return (url || "").replace("{height}", "1500").replace( 238 | "{width}", 239 | "1500", 240 | ) || ""; 241 | } 242 | 243 | async fetchBearerToken() { 244 | const response = await Network.post( 245 | `${this.apiBase}/user_account/access_token`, 246 | this.headers, 247 | "", 248 | ); 249 | const { access_token, user_account_id } = JSON.parse( 250 | response.body, 251 | ); 252 | this.bearerToken = access_token; 253 | this.userAccountId = user_account_id; 254 | this.tokenExpiry = Date.now() + 3600000; 255 | } 256 | 257 | async fetchSeriesDetail(id) { 258 | const response = await this.graphqlRequest("SeriesDetail", { id }); 259 | return response?.data?.series || {}; 260 | } 261 | 262 | async fetchEpisodes(id) { 263 | const response = await this.graphqlRequest( 264 | "SeriesDetailEpisodeList", 265 | { id, episodeOffset: 0, episodeFirst: 1500, episodeSort: "NUMBER_ASC" }, 266 | ); 267 | const episodes = response?.data?.series?.episodes?.edges || []; 268 | return episodes.reduce((chapters, { node }) => ({ 269 | ...chapters, 270 | [node.databaseId]: node.title || "", 271 | }), {}); 272 | } 273 | 274 | async fetchEpisodePages(episodeId) { 275 | const response = await this.graphqlRequest( 276 | "EpisodeViewerConditionallyCacheable", 277 | { episodeID: episodeId }, 278 | ); 279 | return response?.data?.episode || {}; 280 | } 281 | 282 | isEpisodeAccessible({ purchaseInfo }) { 283 | return purchaseInfo?.isFree || purchaseInfo?.hasPurchased || 284 | purchaseInfo?.hasRented; 285 | } 286 | 287 | async handleEpisodePurchase(episodeData) { 288 | const { id, purchaseInfo } = episodeData; 289 | const { purchasableViaOnetimeFree, rentable, unitPrice } = purchaseInfo || 290 | {}; 291 | 292 | if (purchasableViaOnetimeFree) await this.consumeOnetimeFree(id); 293 | if (rentable) await this.rentChapter(id, unitPrice); 294 | } 295 | 296 | buildImageUrls({ pageImages, pageImageToken }) { 297 | const validImages = pageImages.edges.flatMap((edge) => edge.node?.src) 298 | .filter(Boolean); 299 | return { 300 | images: validImages.map((url) => `${url}?token=${pageImageToken}`), 301 | }; 302 | } 303 | 304 | async consumeOnetimeFree(episodeId) { 305 | const response = await this.graphqlRequest("ConsumeOnetimeFree", { 306 | input: { id: episodeId }, 307 | }); 308 | return response?.data?.consumeOnetimeFree?.isSuccess; 309 | } 310 | 311 | async rentChapter(episodeId, unitPrice, retryCount = 0) { 312 | if (retryCount > 3) { 313 | throw "Failed to rent chapter after multiple attempts."; 314 | } 315 | const response = await this.graphqlRequest("Rent", { 316 | input: { id: episodeId, unitPrice }, 317 | }); 318 | 319 | if (response.errors?.[0]?.extensions?.code === "FAILED_TO_USE_POINT") { 320 | await this.refreshAccount(); 321 | return this.rentChapter(episodeId, unitPrice, retryCount + 1); 322 | } 323 | 324 | this.userAccountId = response?.data?.rent?.userAccount?.databaseId; 325 | return true; 326 | } 327 | 328 | async refreshAccount() { 329 | this.deviceId = this.generateDeviceId(); 330 | this.bearerToken = this.userAccountId = null; 331 | this.tokenExpiry = 0; 332 | await this.fetchBearerToken(); 333 | await this.addUserDevice(); 334 | } 335 | 336 | async addUserDevice() { 337 | await this.graphqlRequest("AddUserDevice", { 338 | input: { 339 | deviceName: `Android ${21 + Math.floor(Math.random() * 14)}`, 340 | modelName: `Device-${Math.random().toString(36).slice(2, 10)}`, 341 | osName: `Android ${9 + Math.floor(Math.random() * 6)}`, 342 | }, 343 | }); 344 | this.addUserDeviceCalled = true; 345 | } 346 | } 347 | 348 | const GraphQLQueries = { 349 | "SearchResult": `query SearchResult($after: String, $keyword: String!) { 350 | search(after: $after, first: 50, keyword: $keyword, types: [SERIES,MAGAZINE_LABEL]) { 351 | pageInfo { hasNextPage endCursor } 352 | edges { 353 | node { 354 | __typename 355 | ... on Series { id databaseId title thumbnailUriTemplate author { name } } 356 | ... on MagazineLabel { id databaseId title thumbnailUriTemplate latestIssue { thumbnailUriTemplate } } 357 | } 358 | } 359 | } 360 | }`, 361 | "SeriesDetail": `query SeriesDetail($id: String!) { 362 | series(databaseId: $id) { 363 | id databaseId title thumbnailUriTemplate 364 | author { name } descriptionBanner { text } 365 | hashtags serialUpdateScheduleLabel 366 | } 367 | }`, 368 | "SeriesDetailEpisodeList": 369 | `query SeriesDetailEpisodeList($id: String!, $episodeOffset: Int, $episodeFirst: Int, $episodeSort: ReadableProductSorting) { 370 | series(databaseId: $id) { 371 | episodes: readableProducts(types: [EPISODE,SPECIAL_CONTENT], first: $episodeFirst, offset: $episodeOffset, sort: $episodeSort) { 372 | edges { node { databaseId title } } 373 | } 374 | } 375 | }`, 376 | "EpisodeViewerConditionallyCacheable": 377 | `query EpisodeViewerConditionallyCacheable($episodeID: String!) { 378 | episode(databaseId: $episodeID) { 379 | id pageImages { edges { node { src } } } pageImageToken 380 | purchaseInfo { 381 | isFree hasPurchased hasRented 382 | purchasableViaOnetimeFree rentable unitPrice 383 | } 384 | } 385 | }`, 386 | "ConsumeOnetimeFree": 387 | `mutation ConsumeOnetimeFree($input: ConsumeOnetimeFreeInput!) { 388 | consumeOnetimeFree(input: $input) { isSuccess } 389 | }`, 390 | "Rent": `mutation Rent($input: RentInput!) { 391 | rent(input: $input) { 392 | userAccount { databaseId } 393 | } 394 | }`, 395 | "AddUserDevice": `mutation AddUserDevice($input: AddUserDeviceInput!) { 396 | addUserDevice(input: $input) { isSuccess } 397 | }`, 398 | "HomeCacheable": `query HomeCacheable { 399 | homeSections { 400 | __typename 401 | ...DailyRankingSection 402 | } 403 | } 404 | fragment DesignSectionImage on DesignSectionImage { 405 | imageUrl width height 406 | } 407 | fragment SerialInfoIcon on SerialInfo { 408 | isOriginal isIndies 409 | } 410 | fragment DailyRankingSeries on Series { 411 | id databaseId publisherId title 412 | horizontalThumbnailUriTemplate: subThumbnailUri(type: HORIZONTAL_WITH_LOGO) 413 | squareThumbnailUriTemplate: subThumbnailUri(type: SQUARE_WITHOUT_LOGO) 414 | isNewOngoing supportsOnetimeFree 415 | serialInfo { 416 | __typename ...SerialInfoIcon 417 | status isTrial 418 | } 419 | jamEpisodeWorkType 420 | } 421 | fragment DailyRankingItem on DailyRankingItem { 422 | __typename 423 | ... on DailyRankingValidItem { 424 | product { 425 | __typename 426 | ... on Episode { 427 | id databaseId publisherId commentCount 428 | series { 429 | __typename ...DailyRankingSeries 430 | } 431 | } 432 | ... on SpecialContent { 433 | publisherId linkUrl 434 | series { 435 | __typename ...DailyRankingSeries 436 | } 437 | } 438 | } 439 | badge { name label } 440 | label rank viewCount 441 | } 442 | ... on DailyRankingInvalidItem { 443 | publisherWorkId 444 | } 445 | } 446 | fragment DailyRanking on DailyRanking { 447 | date firstPositionSeriesId 448 | items { 449 | edges { 450 | node { 451 | __typename ...DailyRankingItem 452 | } 453 | } 454 | } 455 | } 456 | fragment DailyRankingSection on DailyRankingSection { 457 | title 458 | titleImage { 459 | __typename ...DesignSectionImage 460 | } 461 | dailyRankings { 462 | ranking { 463 | __typename ...DailyRanking 464 | } 465 | } 466 | }`, 467 | }; 468 | -------------------------------------------------------------------------------- /wnacg.js: -------------------------------------------------------------------------------- 1 | class Wnacg extends ComicSource { 2 | // Note: The fields which are marked as [Optional] should be removed if not used 3 | 4 | // name of the source 5 | name = "紳士漫畫" 6 | 7 | // unique id of the source 8 | key = "wnacg" 9 | 10 | version = "1.0.2" 11 | 12 | minAppVersion = "1.0.0" 13 | 14 | // update url 15 | url = "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@main/wnacg.js" 16 | 17 | get baseUrl() { 18 | return `https://${this.loadSetting('domain')}` 19 | } 20 | 21 | // [Optional] account related 22 | account = { 23 | /** 24 | * login, return any value to indicate success 25 | * @param account {string} 26 | * @param pwd {string} 27 | * @returns {Promise} 28 | */ 29 | login: async (account, pwd) => { 30 | let res = await Network.post( 31 | `${this.baseUrl}/users-check_login.html`, 32 | { 33 | 'content-type': 'application/x-www-form-urlencoded' 34 | }, 35 | `login_name=${encodeURIComponent(account)}&login_pass=${encodeURIComponent(pwd)}` 36 | ) 37 | if(res.status !== 200) { 38 | throw 'Login failed' 39 | } 40 | let json = JSON.parse(res.body) 41 | if(json['html'].includes('登錄成功')) { 42 | return 'ok' 43 | } 44 | throw 'Login failed' 45 | }, 46 | 47 | /** 48 | * logout function, clear account related data 49 | */ 50 | logout: () => { 51 | Network.deleteCookies(this.baseUrl) 52 | }, 53 | 54 | // {string?} - register url 55 | registerWebsite: null 56 | } 57 | 58 | parseComic(c) { 59 | let link = c.querySelector("div.pic_box > a").attributes["href"]; 60 | let id = RegExp("(?<=-aid-)[0-9]+").exec(link)[0]; 61 | let image = 62 | c.querySelector("div.pic_box > a > img").attributes["src"]; 63 | image = `https:${image}`; 64 | let name = c.querySelector("div.info > div.title > a").text; 65 | let info = c.querySelector("div.info > div.info_col").text.trim(); 66 | info = info.replaceAll('\n', ''); 67 | info = info.replaceAll('\t', ''); 68 | return new Comic({ 69 | id: id, 70 | title: name, 71 | cover: image, 72 | description: info, 73 | }) 74 | } 75 | 76 | // explore page list 77 | explore = [ 78 | { 79 | // title of the page. 80 | // title is used to identify the page, it should be unique 81 | title: "紳士漫畫", 82 | 83 | /// multiPartPage or multiPageComicList or mixed 84 | type: "multiPartPage", 85 | 86 | /** 87 | * load function 88 | * @param page {number | null} - page number, null for `singlePageWithMultiPart` type 89 | * @returns {{}} 90 | * - for `multiPartPage` type, return [{title: string, comics: Comic[], viewMore: string?}] 91 | * - for `multiPageComicList` type, for each page(1-based), return {comics: Comic[], maxPage: number} 92 | * - 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?} 93 | */ 94 | load: async (page) => { 95 | let res = await Network.get(this.baseUrl, {}) 96 | if(res.status !== 200) { 97 | throw `Invalid Status Code ${res.status}` 98 | } 99 | let document = new HtmlDocument(res.body) 100 | let titleBlocks = document.querySelectorAll("div.title_sort"); 101 | let comicBlocks = document.querySelectorAll("div.bodywrap"); 102 | if (titleBlocks.length !== comicBlocks.length) { 103 | throw "Invalid Page" 104 | } 105 | let result = [] 106 | for (let i = 0; i < titleBlocks.length; i++) { 107 | let title = titleBlocks[i].querySelector("div.title_h2").text.replaceAll(/\s+/g, '') 108 | let link = titleBlocks[i].querySelector("div.r > a").attributes["href"] 109 | let comics = [] 110 | let comicBlock = comicBlocks[i] 111 | let comicElements = comicBlock.querySelectorAll("div.gallary_wrap > ul.cc > li") 112 | for (let comicElement of comicElements) { 113 | comics.push(this.parseComic(comicElement)) 114 | } 115 | result.push({ 116 | title: title, 117 | comics: comics, 118 | viewMore: `category:${title}@${link}` 119 | }) 120 | } 121 | document.dispose() 122 | return result 123 | } 124 | } 125 | ] 126 | 127 | // categories 128 | category = { 129 | /// title of the category page, used to identify the page, it should be unique 130 | title: "紳士漫畫", 131 | parts: [ 132 | { 133 | // title of the part 134 | name: "最新", 135 | 136 | // fixed or random 137 | // if random, need to provide `randomNumber` field, which indicates the number of comics to display at the same time 138 | type: "fixed", 139 | 140 | // number of comics to display at the same time 141 | // randomNumber: 5, 142 | 143 | categories: ["最新"], 144 | 145 | // category or search 146 | // if `category`, use categoryComics.load to load comics 147 | // if `search`, use search.load to load comics 148 | itemType: "category", 149 | 150 | // [Optional] {string[]?} must have same length as categories, used to provide loading param for each category 151 | categoryParams: ["/albums.html"], 152 | 153 | // [Optional] {string} cannot be used with `categoryParams`, set all category params to this value 154 | groupParam: null, 155 | }, 156 | { 157 | // title of the part 158 | name: "同人誌", 159 | 160 | // fixed or random 161 | // if random, need to provide `randomNumber` field, which indicates the number of comics to display at the same time 162 | type: "fixed", 163 | 164 | // number of comics to display at the same time 165 | // randomNumber: 5, 166 | 167 | categories: ["同人誌", "漢化", "日語", "English", "CG畫集", "3D漫畫", "寫真Cosplay"], 168 | 169 | // category or search 170 | // if `category`, use categoryComics.load to load comics 171 | // if `search`, use search.load to load comics 172 | itemType: "category", 173 | 174 | // [Optional] {string[]?} must have same length as categories, used to provide loading param for each category 175 | categoryParams: [ 176 | "/albums-index-cate-5.html", 177 | "/albums-index-cate-1.html", 178 | "/albums-index-cate-12.html", 179 | "/albums-index-cate-16.html", 180 | "/albums-index-cate-2.html", 181 | "/albums-index-cate-22.html", 182 | "/albums-index-cate-3.html", 183 | ], 184 | 185 | // [Optional] {string} cannot be used with `categoryParams`, set all category params to this value 186 | groupParam: null, 187 | }, 188 | { 189 | // title of the part 190 | name: "單行本", 191 | 192 | // fixed or random 193 | // if random, need to provide `randomNumber` field, which indicates the number of comics to display at the same time 194 | type: "fixed", 195 | 196 | // number of comics to display at the same time 197 | // randomNumber: 5, 198 | 199 | categories: ["單行本", "漢化", "日語", "English",], 200 | 201 | // category or search 202 | // if `category`, use categoryComics.load to load comics 203 | // if `search`, use search.load to load comics 204 | itemType: "category", 205 | 206 | // [Optional] {string[]?} must have same length as categories, used to provide loading param for each category 207 | categoryParams: [ 208 | "/albums-index-cate-6.html", 209 | "/albums-index-cate-9.html", 210 | "/albums-index-cate-13.html", 211 | "/albums-index-cate-17.html", 212 | ], 213 | 214 | // [Optional] {string} cannot be used with `categoryParams`, set all category params to this value 215 | groupParam: null, 216 | }, 217 | { 218 | // title of the part 219 | name: "雜誌短篇", 220 | 221 | // fixed or random 222 | // if random, need to provide `randomNumber` field, which indicates the number of comics to display at the same time 223 | type: "fixed", 224 | 225 | // number of comics to display at the same time 226 | // randomNumber: 5, 227 | 228 | categories: ["雜誌短篇", "漢化", "日語", "English",], 229 | 230 | // category or search 231 | // if `category`, use categoryComics.load to load comics 232 | // if `search`, use search.load to load comics 233 | itemType: "category", 234 | 235 | // [Optional] {string[]?} must have same length as categories, used to provide loading param for each category 236 | categoryParams: [ 237 | "/albums-index-cate-7.html", 238 | "/albums-index-cate-10.html", 239 | "/albums-index-cate-14.html", 240 | "/albums-index-cate-18.html", 241 | ], 242 | 243 | // [Optional] {string} cannot be used with `categoryParams`, set all category params to this value 244 | groupParam: null, 245 | }, 246 | { 247 | // title of the part 248 | name: "韓漫", 249 | 250 | // fixed or random 251 | // if random, need to provide `randomNumber` field, which indicates the number of comics to display at the same time 252 | type: "fixed", 253 | 254 | // number of comics to display at the same time 255 | // randomNumber: 5, 256 | 257 | categories: ["韓漫", "漢化", "生肉",], 258 | 259 | // category or search 260 | // if `category`, use categoryComics.load to load comics 261 | // if `search`, use search.load to load comics 262 | itemType: "category", 263 | 264 | // [Optional] {string[]?} must have same length as categories, used to provide loading param for each category 265 | categoryParams: [ 266 | "/albums-index-cate-19.html", 267 | "/albums-index-cate-20.html", 268 | "/albums-index-cate-21.html", 269 | ], 270 | 271 | // [Optional] {string} cannot be used with `categoryParams`, set all category params to this value 272 | groupParam: null, 273 | }, 274 | ], 275 | // enable ranking page 276 | enableRankingPage: false, 277 | } 278 | 279 | /// category comic loading related 280 | categoryComics = { 281 | /** 282 | * load comics of a category 283 | * @param category {string} - category name 284 | * @param param {string?} - category param 285 | * @param options {string[]} - options from optionList 286 | * @param page {number} - page number 287 | * @returns {Promise<{comics: Comic[], maxPage: number}>} 288 | */ 289 | load: async (category, param, options, page) => { 290 | let url = this.baseUrl + param 291 | if(page !== 0) { 292 | if (!url.includes("-")) { 293 | url = url.replaceAll(".html", "-.html"); 294 | } 295 | url = url.replaceAll("index", ""); 296 | let lr = url.split("albums-"); 297 | lr[1] = `index-page-${page}${lr[1]}`; 298 | url = `${lr[0]}albums-${lr[1]}`; 299 | } 300 | 301 | let res = await Network.get(url, {}) 302 | if(res.status !== 200) { 303 | throw `Invalid Status Code ${res.status}` 304 | } 305 | let document = new HtmlDocument(res.body) 306 | let comicElements = document.querySelectorAll("div.grid div.gallary_wrap > ul.cc > li") 307 | let comics = [] 308 | for (let comicElement of comicElements) { 309 | comics.push(this.parseComic(comicElement)) 310 | } 311 | let pagesLink = document.querySelectorAll("div.f_left.paginator > a"); 312 | let pages = Number(pagesLink[pagesLink.length-1].text) 313 | document.dispose() 314 | return { 315 | comics: comics, 316 | maxPage: pages, 317 | } 318 | }, 319 | } 320 | 321 | /// search related 322 | search = { 323 | /** 324 | * load search result 325 | * @param keyword {string} 326 | * @param options {string[]} - options from optionList 327 | * @param page {number} 328 | * @returns {Promise<{comics: Comic[], maxPage: number}>} 329 | */ 330 | load: async (keyword, options, page) => { 331 | let url = `${this.baseUrl}/search/?q=${encodeURIComponent(keyword)}&f=_all&s=create_time_DESC&syn=yes` 332 | if(page !== 0) { 333 | url += `&p=${page}` 334 | } 335 | let res = await Network.get(url, {}) 336 | if(res.status !== 200) { 337 | throw `Invalid Status Code ${res.status}` 338 | } 339 | let document = new HtmlDocument(res.body) 340 | let comicElements = document.querySelectorAll("div.grid div.gallary_wrap > ul.cc > li") 341 | let comics = [] 342 | for (let comicElement of comicElements) { 343 | comics.push(this.parseComic(comicElement)) 344 | } 345 | let total = document.querySelectorAll("p.result > b")[0].text.replaceAll(',', '') 346 | const comicsPerPage = 24 347 | let pages = Math.ceil(Number(total) / comicsPerPage) 348 | document.dispose() 349 | return { 350 | comics: comics, 351 | maxPage: pages, 352 | } 353 | }, 354 | } 355 | 356 | // favorite related 357 | favorites = { 358 | // whether support multi folders 359 | multiFolder: true, 360 | /** 361 | * add or delete favorite. 362 | * throw `Login expired` to indicate login expired, App will automatically re-login and re-add/delete favorite 363 | * @param comicId {string} 364 | * @param folderId {string} 365 | * @param isAdding {boolean} - true for add, false for delete 366 | * @param favoriteId {string?} - [Comic.favoriteId] 367 | * @returns {Promise} - return any value to indicate success 368 | */ 369 | addOrDelFavorite: async (comicId, folderId, isAdding, favoriteId) => { 370 | if(!isAdding) { 371 | let res = await Network.get(`${this.baseUrl}/users-fav_del-id-${favoriteId}.html?ajax=true&_t=${randomDouble(0, 1)}`, {}) 372 | if(res.status !== 200) { 373 | throw 'Delete failed' 374 | } 375 | } else { 376 | let res = await Network.post(`${this.baseUrl}/users-save_fav-id-${comicId}.html`, { 377 | 'content-type': 'application/x-www-form-urlencoded' 378 | }, `favc_id=${folderId}`) 379 | if(res.status !== 200) { 380 | throw 'Delete failed' 381 | } 382 | } 383 | return 'ok' 384 | }, 385 | /** 386 | * load favorite folders. 387 | * throw `Login expired` to indicate login expired, App will automatically re-login retry. 388 | * if comicId is not null, return favorite folders which contains the comic. 389 | * @param comicId {string?} 390 | * @returns {Promise<{folders: {[p: string]: string}, favorited: string[]}>} - `folders` is a map of folder id to folder name, `favorited` is a list of folder id which contains the comic 391 | */ 392 | loadFolders: async (comicId) => { 393 | let res = await Network.get(`${this.baseUrl}/users-addfav-id-210814.html`, {}) 394 | if(res.status !== 200) { 395 | throw 'Load failed' 396 | } 397 | let document = new HtmlDocument(res.body) 398 | let data = {} 399 | document.querySelectorAll("option").forEach((option => { 400 | if (option.attributes["value"] === "") return 401 | data[option.attributes["value"]] = option.text 402 | })) 403 | return { 404 | folders: data, 405 | favorited: [] 406 | } 407 | }, 408 | /** 409 | * add a folder 410 | * @param name {string} 411 | * @returns {Promise} - return any value to indicate success 412 | */ 413 | addFolder: async (name) => { 414 | let res = await Network.post(`${this.baseUrl}/users-favc_save-id.html`, { 415 | 'content-type': 'application/x-www-form-urlencoded' 416 | }, `favc_name=${encodeURIComponent(name)}`) 417 | if(res.status !== 200) { 418 | throw 'Add failed' 419 | } 420 | return 'ok' 421 | }, 422 | /** 423 | * delete a folder 424 | * @param folderId {string} 425 | * @returns {Promise} - return any value to indicate success 426 | */ 427 | deleteFolder: async (folderId) => { 428 | let res = await Network.get(`${this.baseUrl}/users-favclass_del-id-${folderId}.html?ajax=true&_t=${randomDouble()}`, {}) 429 | if(res.status !== 200) { 430 | throw 'Delete failed' 431 | } 432 | return 'ok' 433 | }, 434 | /** 435 | * load comics in a folder 436 | * throw `Login expired` to indicate login expired, App will automatically re-login retry. 437 | * @param page {number} 438 | * @param folder {string?} - folder id, null for non-multi-folder 439 | * @returns {Promise<{comics: Comic[], maxPage: number}>} 440 | */ 441 | loadComics: async (page, folder) => { 442 | let url = `${this.baseUrl}/users-users_fav-page-${page}-c-${folder}.html.html` 443 | let res = await Network.get(url, {}) 444 | if(res.status !== 200) { 445 | throw `Invalid Status Code ${res.status}` 446 | } 447 | let document = new HtmlDocument(res.body) 448 | let comicBlocks = document.querySelectorAll("div.asTB") 449 | let comics = comicBlocks.map((comic) => { 450 | let cover = comic.querySelector("div.asTBcell.thumb > div > img").attributes["src"] 451 | cover = 'https:' + cover 452 | let time = comic.querySelector("div.box_cel.u_listcon > p.l_catg > span").text.replaceAll("創建時間:", "") 453 | let name = comic.querySelector("div.box_cel.u_listcon > p.l_title > a").text; 454 | let link = comic.querySelector("div.box_cel.u_listcon > p.l_title > a").attributes["href"]; 455 | let id = RegExp("(?<=-aid-)[0-9]+").exec(link)[0]; 456 | let info = comic.querySelector("div.box_cel.u_listcon > p.l_detla").text; 457 | let pages = Number(RegExp("(?<=頁數:)[0-9]+").exec(info)[0]) 458 | let delUrl = comic.querySelector("div.box_cel.u_listcon > p.alopt > a").attributes["onclick"]; 459 | let favoriteId = RegExp("(?<=del-id-)[0-9]+").exec(delUrl)[0]; 460 | return new Comic({ 461 | id: id, 462 | title: name, 463 | subtitle: time, 464 | cover: cover, 465 | pages: pages, 466 | favoriteId: favoriteId, 467 | }) 468 | }) 469 | let pages = 1 470 | let pagesLink = document.querySelectorAll("div.f_left.paginator > a") 471 | if(pagesLink.length > 0) { 472 | pages = Number(pagesLink[pagesLink.length-1].text) 473 | } 474 | document.dispose() 475 | return { 476 | comics: comics, 477 | maxPage: pages, 478 | } 479 | } 480 | } 481 | 482 | /// single comic related 483 | comic = { 484 | /** 485 | * load comic info 486 | * @param id {string} 487 | * @returns {Promise} 488 | */ 489 | loadInfo: async (id) => { 490 | let res = await Network.get(`${this.baseUrl}/photos-index-page-1-aid-${id}.html`, {}) 491 | if(res.status !== 200) { 492 | throw `Invalid Status Code ${res.status}` 493 | } 494 | let document = new HtmlDocument(res.body) 495 | let title = document.querySelector("div.userwrap > h2").text 496 | let cover = document.querySelector("div.userwrap > div.asTB > div.asTBcell.uwthumb > img").attributes["src"] 497 | cover = 'https:' + cover 498 | cover = cover.substring(0, 6) + cover.substring(8) 499 | let labels = document.querySelectorAll("div.asTBcell.uwconn > label") 500 | let category = labels[0].text.split(":")[1] 501 | let pages = labels[1].text.split(":")[1]; 502 | let tagsDom = document.querySelectorAll("a.tagshow"); 503 | let tags = new Map() 504 | tags.set("頁數", [pages]) 505 | tags.set("分類", [category]) 506 | if(tagsDom.length > 0) { 507 | tags.set("標籤", tagsDom.map((e) => e.text)) 508 | } 509 | let description = document.querySelector("div.asTBcell.uwconn > p").text; 510 | let uploader = document.querySelector("div.asTBcell.uwuinfo > a > p").text; 511 | 512 | return new ComicDetails({ 513 | id: id, 514 | title: title, 515 | cover: cover, 516 | pages: pages, 517 | tags: tags, 518 | description: description, 519 | uploader: uploader, 520 | }) 521 | }, 522 | /** 523 | * [Optional] load thumbnails of a comic 524 | * @param id {string} 525 | * @param next {string | null | undefined} - next page token, null for first page 526 | * @returns {Promise<{thumbnails: string[], next: string?}>} - `next` is next page token, null for no more 527 | */ 528 | loadThumbnails: async (id, next) => { 529 | next = next || '1' 530 | let res = await Network.get(`${this.baseUrl}/photos-index-page-${next}-aid-${id}.html`, {}); 531 | if(res.status !== 200) { 532 | throw `Invalid Status Code ${res.status}` 533 | } 534 | let document = new HtmlDocument(res.body) 535 | let thumbnails = document.querySelectorAll("div.pic_box.tb > a > img").map((e) => { 536 | return 'https:' + e.attributes["src"] 537 | }) 538 | next = (Number(next)+1).toString() 539 | let pagesLink = document.querySelector("div.f_left.paginator").children 540 | if(pagesLink[pagesLink.length-1].classNames.includes("thispage")) { 541 | next = null 542 | } 543 | return { 544 | thumbnails: thumbnails, 545 | next: next 546 | } 547 | }, 548 | /** 549 | * load images of a chapter 550 | * @param comicId {string} 551 | * @param epId {string?} 552 | * @returns {Promise<{images: string[]}>} 553 | */ 554 | loadEp: async (comicId, epId) => { 555 | let res = await Network.get(`${this.baseUrl}/photos-gallery-aid-${comicId}.html`, {}) 556 | if(res.status !== 200) { 557 | throw `Invalid Status Code ${res.status}` 558 | } 559 | const regex = RegExp(String.raw`//[^"]+/[^"]+\.[^"]+`, 'g'); 560 | const matches = Array.from(res.body.matchAll(regex)); 561 | return { 562 | images: matches.map((e) => 'https:' + e[0].substring(0, e[0].length-1)) 563 | } 564 | }, 565 | /** 566 | * [Optional] Handle tag click event 567 | * @param namespace {string} 568 | * @param tag {string} 569 | * @returns {{action: string, keyword: string, param: string?}} 570 | */ 571 | onClickTag: (namespace, tag) => { 572 | return { 573 | action: 'search', 574 | keyword: tag, 575 | } 576 | }, 577 | } 578 | 579 | settings = { 580 | domain: { 581 | title: "Domain", 582 | type: "input", 583 | validator: '^(?!:\\/\\/)(?=.{1,253})([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,}$', 584 | default: 'www.wnacg.com', 585 | }, 586 | } 587 | } 588 | --------------------------------------------------------------------------------