634 |
635 | This program is free software: you can redistribute it and/or modify
636 | it under the terms of the GNU Affero General Public License as published
637 | by the Free Software Foundation, either version 3 of the License, or
638 | (at your option) any later version.
639 |
640 | This program is distributed in the hope that it will be useful,
641 | but WITHOUT ANY WARRANTY; without even the implied warranty of
642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643 | GNU Affero General Public License for more details.
644 |
645 | You should have received a copy of the GNU Affero General Public License
646 | along with this program. If not, see .
647 |
648 | Also add information on how to contact you by electronic and paper mail.
649 |
650 | If your software can interact with users remotely through a computer
651 | network, you should also make sure that it provides a way for users to
652 | get its source. For example, if your program is a web application, its
653 | interface could display a "Source" link that leads users to an archive
654 | of the code. There are many ways you could offer source, and different
655 | solutions will be better for different programs; see section 13 for the
656 | specific requirements.
657 |
658 | You should also get your employer (if you work as a programmer) or school,
659 | if any, to sign a "copyright disclaimer" for the program, if necessary.
660 | For more information on this, and how to apply and follow the GNU AGPL, see
661 | .
662 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # comic-book-dl
2 |
3 | 
4 |
5 |
6 |
7 |
8 | 一款漫画下载器(搭配comic-book-browser使用)
9 | 开源 | 高效 | 易用
10 |
11 |
12 |
13 |
14 |
15 |
16 | ## 安装
17 |
18 | ```bash
19 | npm i -g comic-book-dl
20 | ```
21 |
22 | ## 用法
23 |
24 | ```bash
25 | $ comic-book-dl --help
26 |
27 | Usage:
28 | $ comic-book-dl
29 |
30 | Commands:
31 | update 更新已下载的漫画
32 | 漫画目录页url
33 |
34 | For more info, run any command with the `--help` flag:
35 | $ comic-book-dl update --help
36 | $ comic-book-dl --help
37 |
38 | Options:
39 | -d, --distPath 下载的目录 eg: -d comic-book (default: comic-book)
40 | -h, --help Display this message
41 | -v, --version Display version number
42 | ```
43 |
44 | ## Start
45 |
46 | 开始新下载一部漫画到本地,会在当前目录创建 comic-book目录存放漫画的图片
47 |
48 | > PS: 目前支持的站点[查看](./docs/site.md), 后续尝试其他站点
49 |
50 | ```bash
51 | # url 为对应想下载漫画目录
52 | comic-book-dl "https://cn.baozimh.com/comic/mengoushia-feiniaocheng"
53 | ```
54 |
55 | 
56 |
57 | 下载仅是下载漫画的图片,安装 [`comic-book-browser`](https://github.com/gxr404/comic-book-browser) 开始沉浸式的阅读体验
58 |
59 | ## 更新
60 |
61 | 如果漫画后续有更新,可使用 `update` 命令更新
62 |
63 | ```bash
64 | comic-book-dl update
65 | ```
66 |
67 | 
68 |
69 | ## 功能与建议
70 |
71 | - [x] 支持下载中断继续
72 | - [x] 支持漫画更新
73 | - [ ] 更多站点支持🤔
74 |
75 | 目前项目处于开发初期, 如果你对该项目有任何功能与建议,欢迎在 Issues 中提出
76 |
--------------------------------------------------------------------------------
/bin/index.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | function run() {
4 | return import('../dist/cli.js')
5 | }
6 | run()
--------------------------------------------------------------------------------
/docs/example.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gxr404/comic-book-dl/a04bb2ad4441b8405088a27c96c1eb5d4ec1fc8e/docs/example.gif
--------------------------------------------------------------------------------
/docs/example_2.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gxr404/comic-book-dl/a04bb2ad4441b8405088a27c96c1eb5d4ec1fc8e/docs/example_2.gif
--------------------------------------------------------------------------------
/docs/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gxr404/comic-book-dl/a04bb2ad4441b8405088a27c96c1eb5d4ec1fc8e/docs/logo.png
--------------------------------------------------------------------------------
/docs/site.md:
--------------------------------------------------------------------------------
1 | # 支持的网站
2 |
3 | - [包子漫画](https://cn.baozimh.com/)-- [其他域名](https://cn.fzmanga.com)
4 | - 其他备用域名:
5 | - "cn.baozimh.com"
6 | - "tw.baozimh.com"
7 | - "www.baozimh.com"
8 | - "cn.webmota.com"
9 | - "tw.webmota.com"
10 | - "www.webmota.com"
11 | - "cn.kukuc.co"
12 | - "tw.kukuc.co"
13 | - "www.kukuc.co"
14 | - "cn.czmanga.com"
15 | - "tw.czmanga.com"
16 | - "www.czmanga.com"
17 | - "cn.dinnerku.com"
18 | - "tw.dinnerku.com"
19 | - "www.dinnerku.com"
20 |
21 | ```bash
22 | comic-book-dl "https://cn.baozimh.com/comic/mengoushia-feiniaocheng"
23 | ```
24 |
25 | > ⚠️ cloudflare 403报错, 改用其他域名试试
26 |
27 | - 动漫之家([mobile](https://m.idmzj.com/)/[pc](https://cn.baozimh.com/))
28 |
29 | ```bash
30 | comic-book-dl "https://m.idmzj.com/info/qishishiyimeizuijinchuxiandeyilididiguoyuqinmile.html"
31 | ```
32 |
33 | - [百漫谷](https://www.darpou.com)
34 |
35 | ```bash
36 | comic-book-dl "https://www.darpou.com/book/116645.html"
37 | ```
38 |
39 | - [GoDa](https://cn.godamanga.com)(需科学上网)
40 | - [另一个域名](https://cn.baozimh.one/)(仿包子漫画实际是goda)
41 |
42 | ```bash
43 | comic-book-dl "https://cn.baozimh.one/manga/bianfuxiaqunyinghuiv3"
44 | ```
45 |
46 | - [Ikuku](https://m.ikuku.cc)
47 |
48 |
49 | ```bash
50 | comic-book-dl "https://m.ikuku.cc/comiclist/2262/"
51 | ```
52 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "comic-book-dl",
3 | "version": "0.0.43",
4 | "description": "漫画下载器",
5 | "keywords": [
6 | "manga",
7 | "comic",
8 | "nodejs",
9 | "download",
10 | "comic-dl",
11 | "comic-book-dl",
12 | "comic-downloader",
13 | "cli"
14 | ],
15 | "repository": {
16 | "type": "git",
17 | "url": "git+https://github.com/gxr404/comic-book-dl.git"
18 | },
19 | "license": "ISC",
20 | "author": "gxr404",
21 | "type": "module",
22 | "main": "dist/index.js",
23 | "types": "types/index.d.ts",
24 | "bin": {
25 | "comic-book-dl": "bin/index.js"
26 | },
27 | "scripts": {
28 | "dev": "tsc -p tsconfig.build.json && (concurrently \"tsc -p tsconfig.build.json -w\" \"tsc-alias -p tsconfig.build.json -w\")",
29 | "build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
30 | "test": "run-s test:clean test:run",
31 | "test:run": "vitest run",
32 | "test:clean": "rimraf test/.temp",
33 | "eslintLog": "eslint . > eslint.log",
34 | "clean": "rimraf dist types",
35 | "np": "np",
36 | "release": "run-s clean build np",
37 | "postinstall": "patch-package",
38 | "sort-package-json": "npx sort-package-json"
39 | },
40 | "dependencies": {
41 | "@inquirer/prompts": "^7.4.0",
42 | "cac": "^6.7.14",
43 | "cheerio": "^1.0.0",
44 | "cli-progress": "3.12.0",
45 | "got": "^14.4.6",
46 | "log4js": "^6.9.1",
47 | "node-rsa": "^1.1.1",
48 | "p-limit": "^6.2.0",
49 | "patch-package": "^8.0.0",
50 | "protobufjs": "^7.4.0",
51 | "rand-user-agent": "2.0.81",
52 | "rimraf": "^6.0.1"
53 | },
54 | "devDependencies": {
55 | "@types/cli-progress": "^3.11.6",
56 | "@types/node-rsa": "^1.1.4",
57 | "@typescript-eslint/eslint-plugin": "^8.27.0",
58 | "@typescript-eslint/parser": "^8.27.0",
59 | "concurrently": "^9.1.2",
60 | "np": "^10.2.0",
61 | "npm-run-all": "^4.1.5",
62 | "tsc-alias": "^1.8.11",
63 | "typescript": "^5.8.2",
64 | "vitest": "^3.0.9"
65 | },
66 | "engines": {
67 | "node": ">=16.14.0"
68 | },
69 | "np": {
70 | "tests": true,
71 | "2fa": false
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/patches/cli-progress+3.12.0.patch:
--------------------------------------------------------------------------------
1 | diff --git a/node_modules/cli-progress/lib/multi-bar.js b/node_modules/cli-progress/lib/multi-bar.js
2 | index d40ccfc..39db839 100644
3 | --- a/node_modules/cli-progress/lib/multi-bar.js
4 | +++ b/node_modules/cli-progress/lib/multi-bar.js
5 | @@ -114,7 +114,7 @@ module.exports = class MultiBar extends _EventEmitter{
6 | this.bars.splice(index, 1);
7 |
8 | // force update
9 | - this.update();
10 | + this.update(true);
11 |
12 | // clear bottom
13 | this.terminal.newline();
14 | @@ -124,7 +124,7 @@ module.exports = class MultiBar extends _EventEmitter{
15 | }
16 |
17 | // internal update routine
18 | - update(){
19 | + update(forceRendering=false){
20 | // stop timer
21 | if (this.timer){
22 | clearTimeout(this.timer);
23 | @@ -158,7 +158,7 @@ module.exports = class MultiBar extends _EventEmitter{
24 | }
25 |
26 | // render
27 | - this.bars[i].render();
28 | + this.bars[i].render(forceRendering);
29 | }
30 |
31 | // trigger event
32 |
--------------------------------------------------------------------------------
/src/cli.ts:
--------------------------------------------------------------------------------
1 | import { readFileSync } from 'node:fs'
2 | import { cac } from 'cac'
3 | import { main } from '@/index'
4 | import { logger } from '@/utils'
5 | import { update } from '@/update'
6 |
7 | const cli = cac('comic-book-dl')
8 |
9 | export interface IOptions {
10 | distPath: string;
11 | }
12 |
13 | // 不能直接使用 import {version} from '../package.json'
14 | // 否则declaration 生成的d.ts 会多一层src目录
15 | const { version } = JSON.parse(
16 | readFileSync(new URL('../package.json', import.meta.url)).toString(),
17 | )
18 |
19 | cli.command('update', '更新已下载的漫画')
20 | .option('-d, --distPath ', '下载的目录 eg: -d /xx/comic-book', {
21 | default: 'comic-book',
22 | })
23 | .action(async (options: IOptions) => {
24 | try {
25 | await update({
26 | bookPath: options.distPath
27 | })
28 | } catch (err) {
29 | console.log(err)
30 | logger.error(err.message || 'unknown exception')
31 | }
32 | })
33 |
34 | cli
35 | .command('', '漫画目录页url')
36 | .option('-d, --distPath ', '下载的目录 eg: -d comic-book', {
37 | default: 'comic-book',
38 | })
39 | .action(async (url, options: IOptions) => {
40 | try {
41 | await main({
42 | targetUrl: url,
43 | bookPath: options.distPath,
44 | ignoreConsole: false
45 | })
46 | } catch (err) {
47 | console.log(err)
48 | logger.error(err.message || 'unknown exception')
49 | }
50 | })
51 |
52 | cli.help()
53 | cli.version(version)
54 |
55 | try {
56 | cli.parse()
57 | } catch (err) {
58 | logger.error(err.message || 'unknown exception')
59 | process.exit(1)
60 | }
61 |
--------------------------------------------------------------------------------
/src/core.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path'
2 | import pLimit from 'p-limit'
3 | import ProgressBar from './lib/ProgressBar'
4 | import type { IProgressItem } from './lib/ProgressBar'
5 | import { existsMkdir } from './utils'
6 | import { matchParse } from './lib/parse/index'
7 | import { writeBookInfoFile } from './lib/download'
8 | import type { ChaptersItem } from './lib/parse/base'
9 |
10 | interface RunHooks {
11 | // 漫画url解析错误
12 | parseErr?: () => void,
13 | // 下载中断
14 | downloadInterrupted?: () => void,
15 | // 开始下载
16 | start?: (bookName: string) => void,
17 | // 下载结束存在错误
18 | error?: (bookName: string, chaptersList: ChaptersItem[], errorList: ErrorChapterItem[]) => void,
19 | // 下载结束 成功未有错误
20 | success?: (bookName: string, distPath: string, chaptersList: ChaptersItem[] | null) => void,
21 | }
22 |
23 | export interface ErrorChapterItem {
24 | bookName: string,
25 | /** 图片获取失败则所有图片都算失败 无指定的图片 */
26 | imgUrl?: string,
27 | chapter: ChaptersItem
28 | }
29 |
30 | interface IgnoreBook {
31 | name: string,
32 | chapter?: string[]
33 | }
34 |
35 | export interface UserConfig {
36 | ignore?: IgnoreBook[]
37 | }
38 | export interface Config {
39 | bookPath: string,
40 | targetUrl: string,
41 | ignoreConsole?: boolean,
42 | userConfig?: UserConfig
43 | }
44 |
45 | // process.on('warning', e => {
46 | // writeFile('./warn.log', JSON.stringify(e.stack, null, 2))
47 | // console.warn(e.stack?.at(-1))
48 | // })
49 |
50 | export async function run(config: Config, hooks: RunHooks) {
51 | const match = matchParse(config.targetUrl)
52 | if (!match) {
53 | if (hooks.parseErr) hooks.parseErr()
54 | return
55 | }
56 | const { preHandleUrl, getInstance } = match
57 | if (typeof preHandleUrl === 'function') {
58 | config.targetUrl = preHandleUrl(config.targetUrl)
59 | }
60 | const parseInstance = getInstance(config.targetUrl)
61 | const bookInfo = await parseInstance.parseBookInfo()
62 | if (!bookInfo) {
63 | if (hooks.parseErr) hooks.parseErr()
64 | return
65 | }
66 |
67 | const bookName = bookInfo.name
68 | const bookDistPath = path.resolve(config.bookPath, bookInfo.pathName)
69 | existsMkdir(bookDistPath)
70 |
71 | const total = bookInfo.chapters.length
72 | const progressBar = new ProgressBar(bookDistPath, total, config.ignoreConsole)
73 | await progressBar.init()
74 |
75 | // 已完成 无需再继续
76 | if (progressBar.curr === total) {
77 | if (hooks.success) hooks.success(bookName, bookDistPath, null)
78 | return
79 | }
80 |
81 | let chaptersList = bookInfo.chapters
82 |
83 | // 存在用户配置 忽略本漫画的某些章节
84 | if (config.userConfig?.ignore) {
85 | const ignoreInfo = config.userConfig?.ignore.find(item => item.name === bookName)
86 | if (ignoreInfo) {
87 | const ignoreChapter = ignoreInfo.chapter ?? []
88 | chaptersList = chaptersList.filter(chaptersItem => {
89 | return !ignoreChapter.includes(chaptersItem.name)
90 | })
91 | // 已完成 无需再继续
92 | if (progressBar.curr === total - ignoreChapter.length) {
93 | if (hooks.success) {
94 | progressBar.bar?.stop()
95 | hooks.success(bookName, bookDistPath, null)
96 | }
97 | return
98 | }
99 | }
100 | }
101 |
102 | if (hooks.start) hooks.start(bookName)
103 |
104 |
105 | // 下载中断 重新获取下载进度数据
106 | if (progressBar.isDownloadInterrupted) {
107 | if (hooks.downloadInterrupted) hooks?.downloadInterrupted()
108 | // 根据匹配chaptersList 重新更新 progressInfo 仅保留符合chaptersList
109 | // 因为有种情况 漫画更新 url一致 但对应的内容由于更新变化了
110 | const updateProgressInfo: IProgressItem[] = []
111 |
112 | // 从process.json中读取已下载的数据 对 chaptersList 回填 已下载的数据
113 | // 并过滤出 chaptersList中未下载的
114 | chaptersList = chaptersList.filter((chaptersItem) => {
115 | return !progressBar.progressInfo.some(item => {
116 | try {
117 | let isSameHref = item.href === chaptersItem.href
118 | const isUrlReg = /(http|https):\/\//
119 | if (!isSameHref && isUrlReg.test(item.href) && isUrlReg.test(chaptersItem.href)) {
120 | isSameHref = new URL(item.href).pathname === new URL(chaptersItem.href).pathname
121 | }
122 | const isSameName = item.rawName == chaptersItem.rawName
123 | if (isSameHref && isSameName) {
124 | chaptersItem.imageList = item.imageList
125 | chaptersItem.imageListPath = item.imageListPath
126 | updateProgressInfo.push(item)
127 | }
128 | return isSameHref && isSameName
129 | } catch(e) {
130 | return false
131 | }
132 | })
133 | })
134 | // ! 漫画更新 url一致 但对应的内容由于更新变化了 重新更新符合的progressInfo
135 | progressBar.resetProgressInfo(updateProgressInfo)
136 | }
137 |
138 | const LIMIT_MAX = 6
139 |
140 | const limit = pLimit(LIMIT_MAX)
141 |
142 | const errorList: ErrorChapterItem[] = []
143 |
144 | const promiseList = chaptersList.map(item => {
145 | return limit(async () => {
146 | const chaptersItemPath = `${bookDistPath}/chapters/${item.name}`
147 | existsMkdir(chaptersItemPath)
148 | let getImageListSuccess = true
149 | const imageList = await parseInstance.getImgList(item.href)
150 | .catch(() => {
151 | getImageListSuccess = false
152 | errorList.push({
153 | bookName,
154 | chapter: item
155 | })
156 | return [] as string[]
157 | })
158 | let imageListPath: string[] = []
159 | const curBar = progressBar.multiBarCreate({
160 | total: imageList.length,
161 | file: `下载「${item.name}」中的图片...`
162 | })
163 |
164 | let isAllSuccess = true
165 | imageListPath = await parseInstance.saveImgList(
166 | chaptersItemPath,
167 | imageList,
168 | (imgUrl: string, isSuccess: boolean) => {
169 | if (!isSuccess) {
170 | isAllSuccess = false
171 | errorList.push({
172 | bookName,
173 | imgUrl,
174 | chapter: item
175 | })
176 | }
177 | progressBar.multiBarUpdate(curBar)
178 | }
179 | )
180 | imageListPath = imageListPath.map((itemPath) => {
181 | return `chapters/${item.name}/${itemPath}`
182 | })
183 | item.imageList = imageList
184 | item.imageListPath = imageListPath
185 | progressBar.multiBarRemove(curBar)
186 | await progressBar.updateProgress({
187 | name: item.name,
188 | rawName: item.rawName,
189 | path: chaptersItemPath,
190 | href: item.href,
191 | index: item.index,
192 | imageList,
193 | imageListPath
194 | }, isAllSuccess && getImageListSuccess)
195 | return isAllSuccess && getImageListSuccess
196 | })
197 | })
198 |
199 | const isAllSuccess = await Promise.all(promiseList)
200 |
201 | await writeBookInfoFile(bookInfo, bookDistPath, parseInstance)
202 |
203 | if (errorList.length > 0) {
204 | if (hooks.error) hooks.error(bookName, chaptersList, errorList)
205 | } else if (progressBar.curr === total && isAllSuccess) {
206 | if (hooks.success) hooks.success(bookName, bookDistPath, chaptersList)
207 | }
208 | }
209 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { logger } from '@/utils'
2 | import { run } from '@/core'
3 | import { scanFolder } from '@/lib/download'
4 | import type { Config, ErrorChapterItem } from '@/core'
5 | import type { ChaptersItem } from '@/lib/parse/base'
6 |
7 | interface ChapterErrorMsgItem {
8 | chapterName: string,
9 | imgList: string[]
10 | }
11 |
12 | export function echoErrorMsg(
13 | bookName: string,
14 | chaptersList: ChaptersItem[],
15 | errorList: ErrorChapterItem[],
16 | isShowDetails: boolean = false
17 | ) {
18 | const errChaptersMsg: ChapterErrorMsgItem[] = []
19 | errorList.forEach((item)=>{
20 | const errChapter = errChaptersMsg.find(msg => {
21 | return msg.chapterName === item.chapter.name
22 | })
23 | if (errChapter && item.imgUrl) {
24 | errChapter.imgList.push(item.imgUrl)
25 | } else {
26 | errChaptersMsg.push({
27 | chapterName: item.chapter.name,
28 | imgList: item.imgUrl ? [item.imgUrl] : []
29 | })
30 | }
31 | })
32 |
33 | logger.error(`《${bookName}》本次执行总数${chaptersList.length}话,✕ 失败${errChaptersMsg.length}话`)
34 | for (const errInfo of errChaptersMsg) {
35 | logger.error(` └── ✕ ${errInfo.chapterName}`)
36 | if (isShowDetails) {
37 | errInfo.imgList.forEach(imgUrl => {
38 | logger.error(` └── ${imgUrl}`)
39 | })
40 | }
41 | }
42 | }
43 |
44 | export async function main(config: Config) {
45 | const bookInfoList = await scanFolder(config.bookPath)
46 | const existedBookInfo = bookInfoList.find(bookInfo => {
47 | return bookInfo.rawUrl === config.targetUrl
48 | })
49 | if (existedBookInfo) config.targetUrl = existedBookInfo.url
50 | const {ignoreConsole} = config
51 | await run(config, {
52 | parseErr() {
53 | if (ignoreConsole) return
54 | logger.error('× 请输入正确的url... o(╥﹏╥)o')
55 | },
56 | start(bookName) {
57 | if (ignoreConsole) return
58 | logger.info(`开始下载 《${bookName}》`)
59 | },
60 | downloadInterrupted() {
61 | if (ignoreConsole) return
62 | logger.info('根据上次数据继续断点下载')
63 | },
64 | error(...args) {
65 | if (ignoreConsole) return
66 | echoErrorMsg(...args, true)
67 | logger.error('o(╥﹏╥)o 由于网络波动或链接失效以上下载失败,可重新执行命令重试(PS:不会影响已下载成功的数据)')
68 | },
69 | success(bookName, bookDistPath) {
70 | if (ignoreConsole) return
71 | logger.info(`√ 已完成: ${bookDistPath}`)
72 | logger.info('(つ•̀ω•́)つ 欢迎star: https://github.com/gxr404/comic-book-dl')
73 | }
74 | })
75 | process.exit(0)
76 | }
77 |
--------------------------------------------------------------------------------
/src/lib.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'rand-user-agent'
--------------------------------------------------------------------------------
/src/lib/ProgressBar.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs/promises'
2 | import cliProgress, { SingleBar } from 'cli-progress'
3 | import { rimraf } from 'rimraf'
4 |
5 | export interface IProgressItem {
6 | name: string,
7 | rawName: string,
8 | index: number,
9 | path: string,
10 | href: string,
11 | imageList: string[],
12 | imageListPath: string[]
13 | }
14 | export type IProgress = IProgressItem[]
15 |
16 | export default class ProgressBar {
17 | bookPath: string = ''
18 | progressFilePath: string = ''
19 | progressInfo: IProgress = []
20 | curr: number = 0
21 | total: number = 0
22 | /** 是否中断下载 */
23 | isDownloadInterrupted: boolean = false
24 | multiBar: cliProgress.MultiBar | null = null
25 | bar: cliProgress.SingleBar | null = null
26 | completePromise: Promise | null = null
27 | /** 忽略打印(如果忽略打印则 bar multiBar 一直为null) */
28 | ignoreConsole: boolean = false
29 |
30 | constructor (bookPath: string, total: number, ignoreConsole: boolean = false) {
31 | this.bookPath = bookPath
32 | this.progressFilePath = `${bookPath}/progress.json`
33 | this.total = total
34 | this.ignoreConsole = ignoreConsole
35 | }
36 |
37 | async init() {
38 | this.progressInfo = await this.getProgress()
39 | this.curr = this.progressInfo.length
40 |
41 | if (this.curr === this.total) return
42 |
43 | this.isDownloadInterrupted = this.curr > 0 && this.curr !== this.total
44 |
45 | if (this.ignoreConsole) return
46 |
47 | this.multiBar = new cliProgress.MultiBar({
48 | format: ' {bar} | {file} | {value}/{total}',
49 | hideCursor: true,
50 | barCompleteChar: '\u2588',
51 | barIncompleteChar: '\u2591',
52 | clearOnComplete: true,
53 | stopOnComplete: true,
54 | noTTYOutput: true
55 | })
56 |
57 | this.bar = this.multiBar.create(this.total, this.curr, {}, {
58 | ...cliProgress.Presets.legacy,
59 | format: 'Download [{bar}] {percentage}% | {value}/{total}',
60 | })
61 | }
62 |
63 | async getProgress(): Promise {
64 | let progressInfo = []
65 | try {
66 | const progressInfoStr = await fs.readFile(this.progressFilePath, {encoding: 'utf8'})
67 | progressInfo = JSON.parse(progressInfoStr)
68 | } catch (err) {
69 | if (err && err.code === 'ENOENT') {
70 | await fs.writeFile(
71 | this.progressFilePath,
72 | JSON.stringify(progressInfo),
73 | {encoding: 'utf8'}
74 | )
75 | }
76 | }
77 | return progressInfo
78 | }
79 |
80 | async updateProgress(progressItem: IProgressItem, isSuccess: boolean) {
81 | this.curr = this.curr + 1
82 | // 成功才写入 progress.json 以便重新执行时重新下载
83 | if (isSuccess) {
84 | this.progressInfo.push(progressItem)
85 | await fs.writeFile(
86 | this.progressFilePath,
87 | JSON.stringify(this.progressInfo, null, 2),
88 | {encoding: 'utf8'}
89 | )
90 | }
91 | if (this.bar) {
92 | this.bar.update(this.curr > this.total ? this.total : this.curr)
93 | if (this.curr >= this.total) {
94 | this.clearLine(1)
95 | this.bar.render()
96 | this.bar.stop()
97 | console.log('')
98 | }
99 | }
100 | }
101 |
102 | async resetProgressInfo(updateProgressInfo: IProgressItem[]) {
103 | if (updateProgressInfo.length < this.progressInfo.length) {
104 | const needDeleteList = this.progressInfo.filter((oldData) => {
105 | return !updateProgressInfo.some(item => {
106 | return item.href == oldData.href &&
107 | item.name === oldData.name &&
108 | item.rawName === oldData.rawName
109 | })
110 | })
111 | this.progressInfo = updateProgressInfo
112 | if (this.bar) this.bar.update(updateProgressInfo.length)
113 | this.curr = updateProgressInfo.length
114 | // 删除已下载但已经不符合最新漫画目录的文件夹
115 | const promiseList = needDeleteList.map(needDel => {
116 | return rimraf(needDel.path, {preserveRoot: true})
117 | })
118 | await Promise.all(promiseList)
119 | }
120 |
121 | }
122 |
123 | multiBarCreate(params: {total: number, file: string}) {
124 | if (this.ignoreConsole) return
125 | return this.multiBar?.create(params.total, 0, {
126 | file: params.file
127 | })
128 | }
129 |
130 | multiBarUpdate(curBar: SingleBar | undefined) {
131 | if (curBar) curBar.increment()
132 | }
133 |
134 | multiBarRemove(curBar: SingleBar| undefined) {
135 | if (curBar) this.multiBar?.remove(curBar)
136 | }
137 |
138 | // 暂停进度条的打印
139 | pause () {
140 | if (this.bar) this.bar.stop()
141 | }
142 | // 继续进度条的打印
143 | continue(line: number) {
144 | this.clearLine(line)
145 | this.bar?.start(this.total, this.curr)
146 | }
147 | // 清理n行终端显示
148 | clearLine(line: number) {
149 | if (line <= 0) return
150 | process.stderr.cursorTo(0)
151 | for (let i = 0; i< line;i++){
152 | process.stderr.moveCursor(0, -1)
153 | process.stderr.clearLine(1)
154 | }
155 | }
156 | }
--------------------------------------------------------------------------------
/src/lib/download.ts:
--------------------------------------------------------------------------------
1 | import { writeFileSync } from 'node:fs'
2 | import { readdir, stat, readFile } from 'node:fs/promises'
3 | import { join } from 'node:path'
4 | import pLimit from 'p-limit'
5 | import { logger, notEmpty } from '@/utils'
6 | import { Base } from '@/lib/parse/base'
7 | import type { BookInfo } from '@/lib/parse/base'
8 | import { UserConfig } from '@/core'
9 |
10 | export async function writeBookInfoFile(bookInfo: BookInfo, bookDistPath: string, parseInstance: Base) {
11 | const coverPicPath = await parseInstance.saveImg(bookDistPath, bookInfo.coverUrl, 'cover').catch(e => {
12 | logger.error('封面图下载失败', e.message)
13 | return ''
14 | })
15 | bookInfo.coverPath = coverPicPath
16 | writeFileSync(`${bookDistPath}/bookInfo.json`, JSON.stringify(bookInfo, null, 2))
17 | }
18 |
19 | export async function scanFolder(distPath: string) {
20 | const LIMIT_MAX = 6
21 | let folderList: string[]
22 | try {
23 | folderList = await readdir(distPath)
24 | } catch (e) {
25 | folderList = []
26 | }
27 | const limit = pLimit(LIMIT_MAX)
28 | const promiseList = folderList.map((folder) => {
29 | return limit(async () => {
30 | const curBookPath = join(distPath, folder)
31 | const itemStat = await stat(curBookPath)
32 | if (!itemStat.isDirectory()) return null
33 | try {
34 | const bookInfoStr = await readFile(`${curBookPath}/bookInfo.json`, {encoding: 'utf-8'})
35 | const bookInfo: BookInfo = JSON.parse(bookInfoStr)
36 | return bookInfo
37 | } catch (e) {
38 | return null
39 | }
40 | })
41 | })
42 | const bookInfoList = await Promise.all(promiseList)
43 | return bookInfoList.filter(notEmpty)
44 | }
45 |
46 | export async function readConfig(distPath: string) {
47 | try {
48 | const configStr = await readFile(`${distPath}/config.json`, {encoding: 'utf-8'})
49 | const config: UserConfig = JSON.parse(configStr)
50 | return config
51 | } catch (e) {
52 | return {}
53 | }
54 | }
--------------------------------------------------------------------------------
/src/lib/parse/baimangu/index.ts:
--------------------------------------------------------------------------------
1 | import vm from 'node:vm'
2 | import got, { Response } from 'got'
3 | import pLimit from 'p-limit'
4 | import { load } from 'cheerio'
5 | import { Base } from '@/lib/parse/base'
6 | import type { BookInfo, ChaptersItem, TSaveImgCallback } from '@/lib/parse/base'
7 | import { UA, fixPathName } from '@/utils'
8 |
9 | export class Baimangu extends Base {
10 | override readonly type = 'Baimangu'
11 |
12 | async parseBookInfo(): Promise {
13 | const url = this.bookUrl
14 | const rawUrl = url
15 | let response: Response
16 | try {
17 | response = await got.get(url, this.genReqOptions())
18 | } catch (e) {
19 | return false
20 | }
21 | if (!response || response.statusCode !== 200) {
22 | return false
23 | }
24 | const $ = load(response.body)
25 | const name = $('.fed-deta-content .fed-part-eone.fed-font-xvi').text().trim()
26 | const desc = $('.fed-deta-content li.fed-col-xs12:nth-child(6) .fed-part-esan').text().trim()
27 | const author = $('.fed-deta-content li.fed-col-xs12:nth-child(1) a').text().trim()
28 | const coverUrl = $('.fed-main-info .fed-deta-info .fed-list-pics').attr('data-original')?.trim() ?? ''
29 | // 全部章节
30 | const chaptersEl = $('.fed-drop-boxs.fed-drop-btms.fed-matp-v ul:nth-child(2) li')
31 |
32 | let chapters: ChaptersItem[] = []
33 | chaptersEl.toArray().forEach((el: any, index: number) => {
34 | const target = $(el)
35 | const name = target.find('a').text().trim()
36 | const href = target.find('a').attr('href')?.trim() ?? ''
37 | chapters.push({
38 | name: `${index}_${fixPathName(name)}`,
39 | rawName: name,
40 | href,
41 | imageList: [],
42 | imageListPath: [],
43 | index
44 | })
45 | })
46 | if (!name || chapters.length === 0) {
47 | return false
48 | }
49 |
50 | // 生成上一话/下一话信息
51 | chapters = chapters.map((item, index) => {
52 | const newItem = {...item}
53 | if (index !== 0) {
54 | newItem.preChapter = {
55 | name: chapters[index - 1].name,
56 | rawName: chapters[index - 1].rawName,
57 | href: chapters[index - 1].href,
58 | index: chapters[index - 1].index
59 | }
60 | }
61 | if (index !== chapters.length - 1){
62 | newItem.nextChapter = {
63 | name: chapters[index + 1].name,
64 | rawName: chapters[index + 1].rawName,
65 | href: chapters[index + 1].href,
66 | index: chapters[index + 1].index
67 | }
68 | }
69 | return newItem
70 | })
71 |
72 | return {
73 | name,
74 | pathName: fixPathName(name),
75 | author,
76 | desc,
77 | coverUrl,
78 | coverPath: '',
79 | chapters,
80 | url,
81 | language: '简体',
82 | rawUrl
83 | }
84 | }
85 |
86 | async getImgList(chapterUrl: string): Promise {
87 | const response = await got(chapterUrl, this.genReqOptions())
88 |
89 | const reg = /var oScript=document\.createElement\('script'\);(.*)oScript\.src=txt_url;/s
90 | const jsStr = reg.exec(response.body)?.[1] ?? ''
91 | if (!jsStr) return []
92 | const context: any = {}
93 |
94 | try {
95 | vm.createContext(context)
96 | vm.runInContext(jsStr, context)
97 | } catch (e) {
98 | return []
99 | }
100 |
101 | const txtUrl = context['txt_url'] || ''
102 | if (!txtUrl) return []
103 |
104 | const resImgTxt = await got.get(txtUrl, this.genReqOptions())
105 | const imgReg = /.*?src="(.*?)"/mg
106 | let imgList: string[] = []
107 | let res = imgReg.exec(resImgTxt.body)
108 | while(res) {
109 | if (res[1]) {
110 | imgList.push(res[1])
111 | }
112 | res = imgReg.exec(resImgTxt.body)
113 | }
114 | imgList = imgList.map(img =>{
115 | return img
116 | .replace(/(.*)img.manga8.xyz(.*)/g, '$1img3.manga8.xyz$2')
117 | .replace(/(.*)img2.manga8.xyz(.*)/g, '$1img4.manga8.xyz$2')
118 | })
119 |
120 | return [...new Set(imgList)]
121 | }
122 |
123 | override genReqOptions() {
124 | return {
125 | headers: {
126 | 'referrer': 'https://www.darpou.com/',
127 | 'user-agent': UA
128 | }
129 | }
130 | }
131 |
132 | override async saveImgList(
133 | path: string,
134 | imgList: string[],
135 | saveImgCallback?: TSaveImgCallback) {
136 | const limit = pLimit(6)
137 |
138 | const promiseList = imgList.map((imgUrl, index) => limit(async () => {
139 | let isSuccess = true
140 | // let imgPath = ''
141 | let imgFileName = ''
142 | try {
143 | // baimangu 特殊的 保存文件名 非顺序的数字 自定义index 去命名
144 | imgFileName = await this.saveImg(path, imgUrl, String(index+1))
145 | } catch(err) {
146 | isSuccess = false
147 | }
148 | if (typeof saveImgCallback === 'function') saveImgCallback(imgUrl, isSuccess)
149 | return imgFileName
150 | }))
151 | return await Promise.all(promiseList)
152 | }
153 | }
--------------------------------------------------------------------------------
/src/lib/parse/baozi/index.ts:
--------------------------------------------------------------------------------
1 | import got, {Response} from 'got'
2 | import { load } from 'cheerio'
3 |
4 | import { Base } from '@/lib/parse/base'
5 | import type { BookInfo, ChaptersItem } from '@/lib/parse/base'
6 | import { fixPathName } from '@/utils'
7 |
8 | export class Baozi extends Base {
9 | override readonly type = 'baozi'
10 | async getImgList(chapterUrl: string): Promise {
11 | const response = await got(chapterUrl, this.genReqOptions())
12 | const $ = load(response.body)
13 | const ampState = $('.comic-contain amp-state')
14 |
15 | let imgList = ampState.toArray().map((el: any) => {
16 | const scriptText = $(el).find('script').text()
17 | const jsonData = JSON.parse(scriptText)
18 | return jsonData?.url as string ?? ''
19 | })
20 |
21 | const nextChapterList = $('.comic-chapter .next_chapter').toArray()
22 | const findIndex = nextChapterList.length > 1 ? 1 : 0
23 | const nextChapterEl = $(nextChapterList[findIndex]).find('a')
24 | const nextChapterHref = nextChapterEl.attr('href')
25 | const nextChapterText = nextChapterEl.text()
26 | // baozi 页数超过50则会有下一页, 递归执行直到没有下一页
27 | if (/下一頁|下一页/g.test(nextChapterText) && nextChapterHref) {
28 | const nextImgList = await this.getImgList(nextChapterHref)
29 | imgList = imgList.concat(nextImgList)
30 | }
31 | return [...new Set(imgList)]
32 | }
33 |
34 | async parseBookInfo(): Promise {
35 | let url = this.bookUrl
36 | const rawUrl = url
37 | let response: Response
38 | try {
39 | response = await got.get(url, this.genReqOptions())
40 | } catch (e) {
41 | return false
42 | }
43 | if (!response || response.statusCode !== 200) {
44 | return false
45 | }
46 | const $ = load(response.body)
47 | const name = $('.comics-detail__info .comics-detail__title').text().trim()
48 | const desc = $('.comics-detail__info .comics-detail__desc').text().trim()
49 | const author = $('.comics-detail__info .comics-detail__author').text().trim()
50 | const coverUrl = $('.l-content .pure-g.de-info__box amp-img').attr('src')?.trim() ?? ''
51 | // 全部章节
52 | const chaptersEl = $('#chapter-items a.comics-chapters__item, #chapters_other_list a.comics-chapters__item')
53 |
54 | let language = $('.header .home-menu .pure-menu-list:nth-of-type(2) .pure-menu-item:nth-of-type(2) > a').text()?.trim() ?? ''
55 | const realLanguage = language === '繁體' ? '简体' : '繁體'
56 | language = language && realLanguage
57 |
58 | if (language) {
59 | const hostnameMap = new Map([
60 | ['繁體', 'tw'],
61 | ['简体', 'cn']
62 | ])
63 | const newHostName = hostnameMap.get(language)
64 | if (newHostName) {
65 | // xxx.aaa.com -> cn.aaa.com
66 | // aaa.com -> cn.aaa.com
67 | url = url.replace(/(http|https):\/\/(www\.)?([^.\s]+)\.(com)/, `$1://${newHostName}.$3.$4`)
68 | // 不管繁体简体都用 cn.xx.com
69 | // 因为墙内也没法访问tw域名也会转cn域名
70 | // tw.xx.com -> cn.xxx.com
71 | url = url.replace(/tw\./, 'cn.')
72 | }
73 | }
74 |
75 | let chapters: ChaptersItem[] = []
76 | const {origin} = new URL(url)
77 |
78 | chaptersEl.toArray().forEach((el: any, index: number) => {
79 | const target = $(el)
80 | const name = target.find('span').text().trim()
81 | const href = target.attr('href')?.trim() ?? ''
82 | chapters.push({
83 | name: `${index}_${fixPathName(name)}`,
84 | rawName: name,
85 | href: `${origin}${href}`,
86 | imageList: [],
87 | imageListPath: [],
88 | index
89 | })
90 | })
91 |
92 | // 没有全部章节 尝试取最新章节(新上架的漫画仅有 最新章节, 没有全部章节)
93 | if (chapters.length === 0) {
94 | let chaptersEl = $('#layout > div.comics-detail > div:nth-child(3) > div > div:nth-child(4) a.comics-chapters__item')
95 | if (chaptersEl.length === 0) {
96 | chaptersEl = $('#layout > div.comics-detail > div:nth-child(3) > div .comics-chapters > a.comics-chapters__item')
97 | }
98 | chaptersEl.toArray().forEach((el: any, index: number) => {
99 | const target = $(el)
100 | const name = target.find('span').text().trim()
101 | const href = target.attr('href')?.trim() ?? ''
102 | chapters.unshift({
103 | name: `${index}_${fixPathName(name)}`,
104 | rawName: name,
105 | href: `${origin}${href}`,
106 | imageList: [],
107 | imageListPath: [],
108 | index
109 | })
110 | })
111 | // fix index name
112 | chapters = chapters.map((item, index) => {
113 | return {
114 | ...item,
115 | name: `${index}_${fixPathName(item.rawName)}`,
116 | index,
117 | }
118 | })
119 | }
120 |
121 | if (!name || chapters.length === 0) {
122 | return false
123 | }
124 |
125 | // 生成上一话/下一话信息
126 | chapters = chapters.map((item, index) => {
127 | const newItem = {...item}
128 | if (index !== 0) {
129 | newItem.preChapter = {
130 | name: chapters[index - 1].name,
131 | rawName: chapters[index - 1].rawName,
132 | href: chapters[index - 1].href,
133 | index: chapters[index - 1].index
134 | }
135 | }
136 | if (index !== chapters.length - 1){
137 | newItem.nextChapter = {
138 | name: chapters[index + 1].name,
139 | rawName: chapters[index + 1].rawName,
140 | href: chapters[index + 1].href,
141 | index: chapters[index + 1].index
142 | }
143 | }
144 | return newItem
145 | })
146 |
147 | return {
148 | name,
149 | pathName: fixPathName(name),
150 | author,
151 | desc,
152 | coverUrl,
153 | coverPath: '',
154 | chapters,
155 | url,
156 | language,
157 | rawUrl
158 | }
159 | }
160 | }
--------------------------------------------------------------------------------
/src/lib/parse/base/index.ts:
--------------------------------------------------------------------------------
1 | import { pipeline } from 'node:stream/promises'
2 | import { createWriteStream } from 'node:fs'
3 | import got from 'got'
4 | import pLimit from 'p-limit'
5 | import { UA, getUrlFileName } from '@/utils'
6 |
7 | export type TSaveImgCallback = (imgUrl: string, isSuccess: boolean) => void
8 |
9 | export interface ChaptersItem {
10 | name: string,
11 | rawName: string,
12 | index: number,
13 | href: string,
14 | imageList: string[],
15 | imageListPath: string[]
16 | preChapter?: {
17 | name: string,
18 | href: string,
19 | rawName: string,
20 | index: number
21 | },
22 | nextChapter?: {
23 | name: string,
24 | href: string,
25 | rawName: string,
26 | index: number
27 | },
28 | other?: {
29 | [key: string]: any
30 | }
31 | }
32 |
33 | export interface BookInfo {
34 | name: string,
35 | pathName: string,
36 | author: string,
37 | desc: string,
38 | coverUrl: string,
39 | coverPath: string,
40 | chapters: ChaptersItem[],
41 | url: string,
42 | language: string,
43 | rawUrl: string,
44 | /** 是否完结 */
45 | isEnd?: boolean
46 | }
47 |
48 | export abstract class Base {
49 | readonly type: string = 'base'
50 | /* 漫画目录url */
51 | bookUrl: string
52 | constructor(bookUrl?: string) {
53 | this.bookUrl = bookUrl ?? ''
54 | }
55 | /** got请求配置 */
56 | genReqOptions() {
57 | return {
58 | headers: {
59 | 'user-agent': UA
60 | }
61 | }
62 | }
63 | /** 通用保存图片列表方法 */
64 | async saveImgList(
65 | path: string,
66 | imgList: string[],
67 | saveImgCallback?: TSaveImgCallback) {
68 | const limit = pLimit(6)
69 |
70 | const promiseList = imgList.map(imgUrl => limit(async () => {
71 | let isSuccess = true
72 | // let imgPath = ''
73 | let imgFileName = ''
74 | try {
75 | imgFileName = await this.saveImg(path, imgUrl)
76 | } catch(err) {
77 | // console.error(`save img Error: ${imgUrl}`)
78 | // console.error(err)
79 | isSuccess = false
80 | }
81 | if (typeof saveImgCallback === 'function') saveImgCallback(imgUrl, isSuccess)
82 | return imgFileName
83 | }))
84 | return await Promise.all(promiseList)
85 | }
86 | /** 通用保存图片方法 */
87 | async saveImg(path: string, imgUrl: string, fixFileName?: string, fixSuffix?: string) {
88 | if (!imgUrl) return ''
89 | let imgName = getUrlFileName(imgUrl) ?? ''
90 | imgName = decodeURIComponent(imgName)
91 | if (fixFileName) {
92 | const suffix = imgName?.split('.')?.[1] ?? 'jpg'
93 | imgName = `${fixFileName}.${fixSuffix ?? suffix}`
94 | }
95 | await pipeline(
96 | got.stream(imgUrl, this.genReqOptions()),
97 | createWriteStream(`${path}/${imgName}`)
98 | )
99 | return imgName
100 | }
101 | /**
102 | * 抽象方法需有继承类实现
103 | * 获取图片列表
104 | */
105 | abstract getImgList(chapterUrl: string): Promise
106 | /**
107 | * 抽象方法需有继承类实现
108 | * 解析漫画信息
109 | */
110 | abstract parseBookInfo(): Promise
111 | }
--------------------------------------------------------------------------------
/src/lib/parse/dmzj/crypto.ts:
--------------------------------------------------------------------------------
1 | import protobuf from 'protobufjs'
2 | import NodeRSA from 'node-rsa'
3 |
4 | const key = 'MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBAK8nNR1lTnIfIes6oRWJNj3mB6OssDGx0uGMpgpbVCpf6+VwnuI2stmhZNoQcM417Iz7WqlPzbUmu9R4dEKmLGEEqOhOdVaeh9Xk2IPPjqIu5TbkLZRxkY3dJM1htbz57d/roesJLkZXqssfG5EJauNc+RcABTfLb4IiFjSMlTsnAgMBAAECgYEAiz/pi2hKOJKlvcTL4jpHJGjn8+lL3wZX+LeAHkXDoTjHa47g0knYYQteCbv+YwMeAGupBWiLy5RyyhXFoGNKbbnvftMYK56hH+iqxjtDLnjSDKWnhcB7089sNKaEM9Ilil6uxWMrMMBH9v2PLdYsqMBHqPutKu/SigeGPeiB7VECQQDizVlNv67go99QAIv2n/ga4e0wLizVuaNBXE88AdOnaZ0LOTeniVEqvPtgUk63zbjl0P/pzQzyjitwe6HoCAIpAkEAxbOtnCm1uKEp5HsNaXEJTwE7WQf7PrLD4+BpGtNKkgja6f6F4ld4QZ2TQ6qvsCizSGJrjOpNdjVGJ7bgYMcczwJBALvJWPLmDi7ToFfGTB0EsNHZVKE66kZ/8Stx+ezueke4S556XplqOflQBjbnj2PigwBN/0afT+QZUOBOjWzoDJkCQClzo+oDQMvGVs9GEajS/32mJ3hiWQZrWvEzgzYRqSf3XVcEe7PaXSd8z3y3lACeeACsShqQoc8wGlaHXIJOHTcCQQCZw5127ZGs8ZDTSrogrH73Kw/HvX55wGAeirKYcv28eauveCG7iyFR0PFB/P/EDZnyb+ifvyEFlucPUI0+Y87F'
5 |
6 | const ChapterImageProtoDefinition = `
7 | // https://github.com/tachiyomiorg/tachiyomi-extensions/blob/master/src/zh/dmzj/API.md
8 |
9 | syntax = "proto3";
10 |
11 | package dmzj.chapter_images;
12 |
13 | message ResponseDto {
14 | int32 Errno = 1;
15 | string Errmsg = 2;
16 | ChapterImagesDto Data= 3;
17 | }
18 |
19 | message ChapterImagesDto {
20 | int32 Id = 1;
21 | int32 MangaId = 2;
22 | string Name= 3;
23 | int32 Order= 4;
24 | int32 Direction= 5;
25 | repeated string LowResImages= 6;
26 | int32 PageCount= 7;
27 | repeated string Images= 8;
28 | int32 CommentCount= 9;
29 | }
30 | `
31 |
32 | const ChapterListProtoDefinition = `
33 | syntax = "proto3";
34 |
35 | package dmzj.comic;
36 |
37 |
38 | message ComicDetailResponse {
39 | int32 Errno = 1;
40 | string Errmsg = 2;
41 | ComicDetailInfoResponse Data= 3;
42 | }
43 |
44 | message ComicDetailInfoResponse {
45 | int32 Id = 1;
46 | string Title = 2;
47 | int32 Direction=3;
48 | int32 Islong=4;
49 | int32 IsDmzj=5;
50 | string Cover=6;
51 | string Description=7;
52 | int64 LastUpdatetime=8;
53 | string LastUpdateChapterName=9;
54 | int32 Copyright=10;
55 | string FirstLetter=11;
56 | string ComicPy=12;
57 | int32 Hidden=13;
58 | int32 HotNum=14;
59 | int32 HitNum=15;
60 | int32 Uid=16;
61 | int32 IsLock=17;
62 | int32 LastUpdateChapterId=18;
63 | repeated ComicDetailTypeItemResponse Types=19;
64 | repeated ComicDetailTypeItemResponse Status=20;
65 | repeated ComicDetailTypeItemResponse Authors=21;
66 | int32 SubscribeNum=22;
67 | repeated ComicDetailChapterResponse Chapters=23;
68 | int32 IsNeedLogin=24;
69 | //object UrlLinks=25;
70 | int32 IsHideChapter=26;
71 | //object DhUrlLinks=27;
72 | }
73 |
74 | message ComicDetailTypeItemResponse {
75 | int32 TagId = 1;
76 | string TagName = 2;
77 | }
78 |
79 | message ComicDetailChapterResponse {
80 | string Title = 1;
81 | repeated ComicDetailChapterInfoResponse Data=2;
82 | }
83 | message ComicDetailChapterInfoResponse {
84 | int32 ChapterId = 1;
85 | string ChapterTitle = 2;
86 | int64 Updatetime=3;
87 | int32 Filesize=4;
88 | int32 ChapterOrder=5;
89 | }
90 | `
91 |
92 | // 需要注意buffer的长度 不要有为空为0的 否则 protobuf 解码会失败
93 | export function ApiV4Decrypt(data: string): Buffer {
94 | const keyByte = Buffer.from(key, 'base64')
95 | // const privateKey = crypto.createPrivateKey({
96 | // key: keyByte,
97 | // format: 'der',
98 | // type: 'pkcs8'
99 | // })
100 | // const decryptedRequestData = privateKey
101 | // .decrypt(encryptedBody)
102 | // .toString("utf8");
103 | const tempData = Buffer.from(data, 'base64')
104 | const privateKey = new NodeRSA(keyByte, 'pkcs8-der', {})
105 | privateKey.setOptions({ encryptionScheme: 'pkcs1', environment: 'browser' })
106 | const MAX_DECRYPT_BLOCK = 128
107 | const inputLen = tempData.length
108 | let result = new Uint8Array(inputLen)
109 | let chunk = 0
110 | // 该循环是为了分段解码 每128位解码一次
111 | for (let offset = 0; offset < inputLen; offset += MAX_DECRYPT_BLOCK) {
112 | // min 是为了结尾时不多取buffer位数
113 | const blockLen = Math.min(MAX_DECRYPT_BLOCK, inputLen - offset)
114 | const encryptedData = tempData.subarray(offset, offset + blockLen)
115 | // console.log(encryptedData.length)
116 | // const decryptData = crypto.privateDecrypt({
117 | // key: privateKey,
118 | // padding: crypto.constants.RSA_PKCS1_PADDING,
119 | // }, encryptedData)
120 | const decryptData = privateKey.decrypt(encryptedData)
121 | // 注意这里不能使用 offset 而需使用计算的chunk,
122 | // 因为 128位进行解码后不一定是 128 是不定长度的
123 | // 所以手动计算chunk位数
124 | result.set(decryptData, chunk)
125 | chunk = chunk + decryptData.length
126 | }
127 | result = result.subarray(0, chunk)
128 | return Buffer.from(result)
129 | }
130 |
131 | interface ApiV4ChapterImageData {
132 | Id: number,
133 | MangaId: number,
134 | Name: string,
135 | Order: number,
136 | Direction: number,
137 | LowResImages: string[]
138 | PageCount: number,
139 | Images: string[],
140 | CommentCount: number
141 | }
142 |
143 | export function ApiV4ChapterImageParse(data: Buffer): ApiV4ChapterImageData | null{
144 | const root = protobuf.parse(ChapterImageProtoDefinition, {
145 | keepCase: true
146 | }).root
147 |
148 | const ResponseDto = root.lookupType('ResponseDto')
149 | const decodedRes = ResponseDto.decode(data)
150 | const decodedObject = ResponseDto.toObject(decodedRes, {
151 | longs: String,
152 | enums: String,
153 | bytes: String
154 | })
155 | if (!decodedObject) {
156 | return null
157 | }
158 | return decodedObject?.Data as ApiV4ChapterImageData
159 | }
160 |
161 | interface ApiV4ChapterItem {
162 | ChapterId: number,
163 | ChapterOrder: number,
164 | ChapterTitle: string,
165 | Filesize: number
166 | }
167 |
168 | interface ApiV4ChapterList {
169 | Chapters: {
170 | Title: string
171 | Data: ApiV4ChapterItem[]
172 | }[]
173 | [key: string]: any
174 | }
175 | export function ApiV4ChapterListParse(data: Buffer): ApiV4ChapterList | null{
176 | const root = protobuf.parse(ChapterListProtoDefinition, {
177 | keepCase: true
178 | }).root
179 |
180 | const ResponseDto = root.lookupType('ComicDetailResponse')
181 | const decodedRes = ResponseDto.decode(data)
182 | const decodedObject = ResponseDto.toObject(decodedRes, {
183 | longs: String,
184 | enums: String,
185 | bytes: String
186 | })
187 | if (!decodedObject) {
188 | return null
189 | }
190 | return decodedObject?.Data as ApiV4ChapterList
191 | }
--------------------------------------------------------------------------------
/src/lib/parse/dmzj/index.ts:
--------------------------------------------------------------------------------
1 | import got, {Response} from 'got'
2 | import { load } from 'cheerio'
3 |
4 | import { UA, fixPathName } from '@/utils'
5 | import { Base } from '@/lib/parse/base'
6 | import type { BookInfo, ChaptersItem } from '@/lib/parse/base'
7 | import { ApiV4ChapterImageParse, ApiV4ChapterListParse, ApiV4Decrypt } from './crypto'
8 |
9 | const api = {
10 | v4Chapter: 'https://nnv4api.dmzj.com/comic/chapter/',
11 | v3Chapter: 'https://m.idmzj.com/chapinfo/',
12 | v3Api: 'https://api.dmzj.com',
13 | v4Api: 'https://nnv4api.dmzj.com'
14 | }
15 |
16 | interface MobileTempChapterItem {
17 | chapter_name: string,
18 | chapter_order: number,
19 | chaptertype: number,
20 | comic_id: number,
21 | id: number,
22 | sort: number,
23 | title: string
24 | }
25 |
26 | interface PcTempChapterItem {
27 | chapter_id: number,
28 | chapter_title: string,
29 | updatetime: number,
30 | filesize: number,
31 | chapter_order: number,
32 | is_fee: boolean
33 | }
34 |
35 | export class Dmzj extends Base {
36 | override readonly type = 'Dmzj'
37 | async parseBookInfo(): Promise {
38 | const url = this.bookUrl
39 | const rawUrl = url
40 |
41 | let response: Response
42 | try {
43 | response = await got.get(url, this.genReqOptions())
44 | } catch (e) {
45 | // console.log(e)
46 | return false
47 | }
48 | if (!response || response.statusCode !== 200) {
49 | return false
50 | }
51 |
52 | const isMobile = /m\.idmzj/.test(url)
53 | let parseResult
54 | if (isMobile) {
55 | parseResult = this.mSite(url, response.body)
56 | } else {
57 | parseResult = await this.pcSite(url, response.body)
58 | }
59 | if (!parseResult) return false
60 | const name = parseResult?.name
61 | const author = parseResult?.author
62 | const desc = parseResult?.desc
63 | const coverUrl = parseResult?.coverUrl
64 | let chapters: ChaptersItem[] = parseResult?.chapters || []
65 |
66 | if (!name || chapters.length === 0) {
67 | return false
68 | }
69 | // 章节默认是升序改为降序
70 | chapters = chapters.reverse()
71 |
72 | // 生成上一话/下一话信息
73 | chapters = chapters.map((item, index) => {
74 | const newItem = {...item}
75 | if (index !== 0) {
76 | newItem.preChapter = {
77 | name: chapters[index - 1].name,
78 | rawName: chapters[index - 1].rawName,
79 | href: chapters[index - 1].href,
80 | index: chapters[index - 1].index
81 | }
82 | }
83 | if (index !== chapters.length - 1){
84 | newItem.nextChapter = {
85 | name: chapters[index + 1].name,
86 | rawName: chapters[index + 1].rawName,
87 | href: chapters[index + 1].href,
88 | index: chapters[index + 1].index
89 | }
90 | }
91 | return newItem
92 | })
93 |
94 | return {
95 | name,
96 | pathName: fixPathName(name),
97 | author,
98 | desc,
99 | coverUrl,
100 | coverPath: '',
101 | chapters,
102 | url,
103 | language: '简体',
104 | rawUrl
105 | }
106 | }
107 | async getImgList(url: string): Promise {
108 | // urlPath = comic_id/id
109 | const urlPath = /.*view\/(.*).html/g.exec(url)?.[1]
110 | if (!urlPath) return []
111 | // https://m.idmzj.com/view/62324/140451.html
112 | const response = await got(`${api.v4Chapter}${urlPath}`, this.genReqOptions())
113 | let imgList: string[] = []
114 | try {
115 | const data = ApiV4ChapterImageParse(ApiV4Decrypt(response.body))
116 | imgList = data?.Images ?? []
117 | imgList = imgList.map(img => decodeURIComponent(img))
118 | } catch(e) {
119 | const response = await got(`${api.v3Chapter}${urlPath}.html`,this.genReqOptions())
120 | let data: any = {}
121 | try {
122 | data = JSON.parse(response.body)
123 | } catch (e) {
124 | console.log(e)
125 | }
126 | imgList = data['page_url']
127 | }
128 |
129 | return imgList
130 | }
131 | // mobile https://m.idmzj.com/index.html
132 | mSite(url: string, body: string) {
133 | const $ = load(body)
134 | const name = $('#comicName').text().trim()
135 | const desc = $('.txtDesc.autoHeight').text().trim()
136 | const author = $('.introName').toArray().map(el => $(el).text()).join('/').trim()
137 | const coverUrl = $('#Cover img').attr('src')?.trim() ?? ''
138 | // 全部章节
139 | const chaptersReg = /initIntroData\((.*?)\)/gm
140 | const chaptersJSONstr = chaptersReg.exec(body)?.[1] ?? ''
141 | let tempChapters: MobileTempChapterItem[] = []
142 | try {
143 | tempChapters = JSON.parse(chaptersJSONstr)
144 | } catch (e) {
145 | // console.log(e)
146 | return null
147 | }
148 | if (!Array.isArray(tempChapters)) tempChapters = []
149 | tempChapters = tempChapters.map((item: any) => item?.data ?? null)
150 | tempChapters = tempChapters.flat()
151 |
152 | const chapters: ChaptersItem[] = []
153 | const {origin} = new URL(url)
154 |
155 | tempChapters.forEach((item, index) => {
156 | const chapterIndex = item['chapter_order'] ?? index
157 | // /view/comic_id/id.html
158 | chapters.push({
159 | name: `${chapterIndex}_${fixPathName(item['chapter_name'])}`,
160 | rawName: item['chapter_name'],
161 | href: `${origin}/view/${item['comic_id']}/${item.id}.html`,
162 | imageList: [],
163 | imageListPath: [],
164 | index: chapterIndex,
165 | other: item
166 | })
167 | })
168 | return {
169 | name,
170 | author,
171 | desc,
172 | coverUrl,
173 | chapters,
174 | }
175 | }
176 | // pc https://www.idmzj.com/info/yaoshenji.html
177 | async pcSite(url: string, body: string) {
178 | const reg = /.*\/(.*?)$/
179 | const params: any = {}
180 | let comicName = url.match(reg)?.[1] ?? ''
181 | comicName = comicName.replace(/\.html/, '')
182 | params['comic_py'] = comicName
183 | const api = 'https://www.idmzj.com/api/v1/comic1/comic/detail'
184 | const nuxtDataStr = body.match(/window\.__NUXT__=\((.*)\)/gm)?.[0] ?? ''
185 | const fieldGroup = ['channel', 'app_name', 'version', 'timestamp']
186 | fieldGroup.forEach((field, i) => {
187 | const fieldReg = new RegExp(`${field}:(.*?),`)
188 | const key = fieldGroup[i]
189 | params[key] = nuxtDataStr.match(fieldReg)?.[1] ?? ''
190 | })
191 | const queryStr = new URLSearchParams(params).toString()
192 | const response = await got.get(`${api}?${queryStr}`, this.genReqOptions())
193 | let resJSON: any = null
194 | try {
195 | resJSON = JSON.parse(response.body)
196 | resJSON = resJSON?.data?.comicInfo || {}
197 | } catch (e) {
198 | console.log(e)
199 | return null
200 | }
201 | // const $ = load(body)
202 | // const name = $('.comic_deCon > h1 > a').text().trim()
203 | // const desc = $('.comic_deCon .comic_deCon_d').text().trim()
204 | // const author = $('.comic_deCon .comic_deCon_liO > li:nth-child(1)').text().trim()
205 | // const coverUrl = $('#Cover img').attr('src')?.trim() ?? ''
206 | const author = resJSON?.authorInfo?.authorName || ''
207 | const name = resJSON?.title || ''
208 | const desc = resJSON?.description || ''
209 | const coverUrl = resJSON?.cover || ''
210 | const chapters: ChaptersItem[] = []
211 | let tempChapters: PcTempChapterItem[] = resJSON?.chapterList || []
212 | if (tempChapters.length <= 0) {
213 | const comicId = resJSON?.id
214 | tempChapters = await this.commonFetchChaptersList(comicId)
215 | } else {
216 | tempChapters = tempChapters.map((item: any) => item?.data ?? null)
217 | tempChapters = tempChapters.flat()
218 | }
219 |
220 | // const {origin} = new URL(url)
221 | tempChapters.forEach((item, index) => {
222 | const chapterIndex = item['chapter_order'] ?? index
223 | // /view/comic_id/id.html
224 | chapters.push({
225 | name: `${chapterIndex}_${fixPathName(item['chapter_title'])}`,
226 | rawName: item['chapter_title'],
227 | href: `https://m.idmzj.com/view/${resJSON?.id}/${item['chapter_id']}.html`,
228 | imageList: [],
229 | imageListPath: [],
230 | index: chapterIndex,
231 | other: item
232 | })
233 | })
234 | return {
235 | name,
236 | author,
237 | desc,
238 | coverUrl,
239 | chapters,
240 | }
241 |
242 | }
243 |
244 | /** 可公用的 获取章节 前提是需要 comic id */
245 | async commonFetchChaptersList(id: string) {
246 | if (!id) return []
247 | const url = `${api.v4Api}/comic/detail/${id}?uid=2665531`
248 | const response = await got.get(url, this.genReqOptions())
249 | let chapterList: any[] = []
250 | try {
251 | const data = ApiV4ChapterListParse(ApiV4Decrypt(response.body))
252 | const tempChapterList = data?.Chapters ?? []
253 | const dataFieldList = tempChapterList.map(item => item.Data)
254 | chapterList = dataFieldList.flat().map(item => {
255 | return {
256 | ['chapter_order']: item.ChapterOrder,
257 | ['chapter_title']: item.ChapterTitle,
258 | ['chapter_id']: item.ChapterId
259 | }
260 | })
261 | } catch (e) {
262 | // v3接口一直为空 暂时忽略
263 | // const v3Url = `${api.v3Api}/dynamic/comicinfo/${id}.json`
264 | return []
265 | }
266 | return chapterList.reverse()
267 | }
268 |
269 | override genReqOptions() {
270 | return {
271 | headers: {
272 | 'user-agent': UA
273 | },
274 | http2: true
275 | }
276 | }
277 | }
--------------------------------------------------------------------------------
/src/lib/parse/godamanga/index.ts:
--------------------------------------------------------------------------------
1 | import got, {Response} from 'got'
2 | import { load } from 'cheerio'
3 | import pLimit from 'p-limit'
4 | import { Base } from '@/lib/parse/base'
5 | import { fixPathName, sleep } from '@/utils'
6 | import { UA } from '@/utils'
7 | import type { BookInfo, ChaptersItem, TSaveImgCallback } from '@/lib/parse/base'
8 |
9 | export class Godamanga extends Base {
10 | override readonly type = 'Godamanga'
11 |
12 | async parseBookInfo(): Promise {
13 | const url = this.bookUrl
14 | const rawUrl = url
15 | let response: Response
16 | try {
17 | response = await got.get(url, this.genReqOptions())
18 | } catch (e) {
19 | return false
20 | }
21 | if (!response || response.statusCode !== 200) {
22 | return false
23 | }
24 | let $ = load(response.body)
25 | let name = $('#info .gap-unit-xs .text-xl').text().trim()
26 | const _name = $('#info .gap-unit-xs .text-xl .text-xs').text().trim()
27 | name = name.replace(_name, '').trim()
28 | const desc = $('#info .block .text-medium').text().trim()
29 | const author = $('#info .block div:nth-child(2) a span').text().trim()
30 | const coverUrl = $('#MangaCard > div > div:nth-child(1) img').attr('src')?.trim() ?? ''
31 | // 全部章节需 点击全部章节按钮 请求另一个页面
32 | const chaptersAllUrl = $('.my-unit-sm a').attr('href')
33 | let chapters: ChaptersItem[] = []
34 | const {origin} = new URL(url)
35 |
36 | // 如果有全部章节则 点击,没有则直接在当前页取 因为章节太少的可能会没有全部章节页
37 | if (chaptersAllUrl) {
38 | const chaptersAllHref = new URL(chaptersAllUrl, origin).href
39 | const res = await got.get(chaptersAllHref, this.genReqOptions()).catch(() => ({body: ''}))
40 | $ = load(res.body)
41 | }
42 | const chaptersElSelector = chaptersAllUrl ?
43 | '#allchapters' :
44 | '.peer-checked\\:block #chapterlists .chapteritem'
45 | const chaptersEl = $(chaptersElSelector)
46 | const mid = chaptersEl.data('mid')
47 | if (!mid) return false
48 | let chaptersList = [] as any
49 | let chaptersHrefPrefix = ''
50 | try {
51 | // 2024-10-10接口变更
52 | // const chaptersAPI = `https://api-get.mgsearcher.com/api/manga/get?mid=${mid}&mode=all`
53 | const chaptersAPI = `https://api-get-v2.mgsearcher.com/api/manga/get?mid=${mid}&mode=all`
54 | const response = await fetch(chaptersAPI, {
55 | headers: this.genReqOptions().headers,
56 | method: 'GET'
57 | })
58 | const bodyText = await response.text()
59 | const data = JSON.parse(bodyText)
60 | // const response = await got.get(chaptersAPI, this.genReqOptions())
61 | // const data = JSON.parse(response.body)
62 | if (data.status && Array.isArray(data?.data?.chapters)) {
63 | chaptersList = data?.data?.chapters
64 | chaptersHrefPrefix = `/manga/${data?.data?.slug}`
65 | }
66 | } catch (e) {
67 | // console.log(e)
68 | return false
69 | }
70 |
71 | chaptersList.forEach((data: any, index: number) => {
72 | const name = data?.attributes?.title?.trim() || ''
73 | const slug = data?.attributes?.slug?.trim() || ''
74 | const href = `${chaptersHrefPrefix}/${slug}`
75 | chapters.push({
76 | name: `${index}_${fixPathName(name)}`,
77 | rawName: name,
78 | href: `${origin}${href}`,
79 | imageList: [],
80 | imageListPath: [],
81 | index
82 | })
83 | })
84 | if (!name || chapters.length === 0) {
85 | return false
86 | }
87 |
88 | // 生成上一话/下一话信息
89 | chapters = chapters.map((item, index) => {
90 | const newItem = {...item}
91 | if (index !== 0) {
92 | newItem.preChapter = {
93 | name: chapters[index - 1].name,
94 | rawName: chapters[index - 1].rawName,
95 | href: chapters[index - 1].href,
96 | index: chapters[index - 1].index
97 | }
98 | }
99 | if (index !== chapters.length - 1){
100 | newItem.nextChapter = {
101 | name: chapters[index + 1].name,
102 | rawName: chapters[index + 1].rawName,
103 | href: chapters[index + 1].href,
104 | index: chapters[index + 1].index
105 | }
106 | }
107 | return newItem
108 | })
109 | return {
110 | name,
111 | pathName: fixPathName(name),
112 | author,
113 | desc,
114 | coverUrl,
115 | coverPath: '',
116 | chapters,
117 | url,
118 | language: '简体',
119 | rawUrl
120 | }
121 | }
122 | async getImgList(chapterUrl: string): Promise {
123 | const response = await got(chapterUrl, this.genReqOptions())
124 | const $ = load(response.body)
125 | const domInfo = $('#chapterContent')
126 | const mid = domInfo.data('ms')
127 | const cid = domInfo.data('cs')
128 | let imgList: string[] = []
129 | try {
130 | // 2024-10-10接口变更
131 | // const chaptersAPI = `https://api-get.mgsearcher.com/api/chapter/getinfo?m=${mid}&c=${cid}`
132 | const chaptersAPI = `https://api-get-v2.mgsearcher.com/api/chapter/getinfo?m=${mid}&c=${cid}`
133 | // const response = await got.get(chaptersAPI, this.genReqOptions())
134 | // const data = JSON.parse(response.body)
135 | const response = await fetch(chaptersAPI, {
136 | headers: this.genReqOptions().headers,
137 | method: 'GET'
138 | })
139 | const bodyText = await response.text()
140 | const data = JSON.parse(bodyText)
141 | if (data?.status && Array.isArray(data?.data?.info?.images?.images)) {
142 | imgList = data?.data?.info?.images?.images.map((item: any) => {
143 | const imgHost = data?.data?.info?.images?.line === 2 ? 'https://f40-1-4.g-mh.online' : 'https://t40-1-4.g-mh.online'
144 | return `${imgHost}${item?.url}` || ''
145 | })
146 | }
147 | } catch (e) {
148 | console.log(e)
149 | return []
150 | }
151 | return [...new Set(imgList)]
152 | }
153 |
154 | override async saveImgList(
155 | path: string,
156 | imgList: string[],
157 | saveImgCallback?: TSaveImgCallback) {
158 | const limit = pLimit(6)
159 |
160 | const promiseList = imgList.map((imgUrl, index) => limit(async () => {
161 | let isSuccess = true
162 | // let imgPath = ''
163 | let imgFileName = ''
164 | try {
165 | // baimangu 特殊的 保存文件名 非顺序的数字 自定义index 去命名
166 | imgFileName = await this.saveImg(path, imgUrl, String(index+1), 'jpg')
167 | } catch(err) {
168 | isSuccess = false
169 | }
170 | if (typeof saveImgCallback === 'function') saveImgCallback(imgUrl, isSuccess)
171 | return imgFileName
172 | }))
173 | return await Promise.all(promiseList)
174 | }
175 | // ! 有访问限制 不能太快
176 | override async saveImg(path: string, imgUrl: string, fixFileName?: string | undefined, fixSuffix?: string | undefined): Promise {
177 | await sleep(600)
178 | const res = await super.saveImg(path, imgUrl, fixFileName, fixSuffix)
179 | await sleep(600)
180 | return res
181 | }
182 |
183 | override genReqOptions() {
184 | return {
185 | headers: {
186 | 'user-agent': UA,
187 | referer: 'https://m.baozimh.one/'
188 | },
189 | http2: true
190 | }
191 | }
192 | }
--------------------------------------------------------------------------------
/src/lib/parse/ikuku/index.ts:
--------------------------------------------------------------------------------
1 | import got, {Response} from 'got'
2 | import { load } from 'cheerio'
3 | import pLimit from 'p-limit'
4 | import { Base } from '@/lib/parse/base'
5 | import { fixPathName, isHasHost, sleep, toReversed } from '@/utils'
6 | import type { BookInfo, ChaptersItem, TSaveImgCallback } from '@/lib/parse/base'
7 |
8 | export class Ikuku extends Base {
9 | override readonly type = 'Ikuku'
10 |
11 | async parseBookInfo(): Promise {
12 | const url = this.bookUrl
13 | const rawUrl = url
14 | let response: Response
15 | try {
16 | response = await got.get(url, this.genReqOptions())
17 | } catch (e) {
18 | return false
19 | }
20 | if (!response || response.statusCode !== 200) {
21 | return false
22 | }
23 | const decoder = new TextDecoder('gbk')
24 | const $ = load(decoder.decode(response.rawBody))
25 | const name = $('#comicName').text().trim()
26 | const desc =$('.txtDesc').text().trim()
27 | const author = $('.Introduct_Sub .txtItme:nth-child(1)').text().trim()
28 | const coverUrl = $('#Cover img').attr('src')?.trim() ?? ''
29 |
30 | let chapters: ChaptersItem[] = []
31 | // const {origin} = new URL(url)
32 | const chaptersEl = $('#list li')
33 | toReversed(chaptersEl.toArray()).forEach((el: any, index: number) => {
34 | const target = $(el)
35 | const aEl = target.find('a')
36 | const name = aEl.text().trim()
37 | const href = aEl.attr('href')?.trim() ?? ''
38 | chapters.push({
39 | name: `${index}_${fixPathName(name)}`,
40 | rawName: name,
41 | href,
42 | imageList: [],
43 | imageListPath: [],
44 | index
45 | })
46 | })
47 |
48 | if (!name || chapters.length === 0) {
49 | return false
50 | }
51 |
52 | // 生成上一话/下一话信息
53 | chapters = chapters.map((item, index) => {
54 | const newItem = {...item}
55 | if (index !== 0) {
56 | newItem.preChapter = {
57 | name: chapters[index - 1].name,
58 | rawName: chapters[index - 1].rawName,
59 | href: chapters[index - 1].href,
60 | index: chapters[index - 1].index
61 | }
62 | }
63 | if (index !== chapters.length - 1){
64 | newItem.nextChapter = {
65 | name: chapters[index + 1].name,
66 | rawName: chapters[index + 1].rawName,
67 | href: chapters[index + 1].href,
68 | index: chapters[index + 1].index
69 | }
70 | }
71 | return newItem
72 | })
73 |
74 | return {
75 | name,
76 | pathName: fixPathName(name),
77 | author,
78 | desc,
79 | coverUrl,
80 | coverPath: '',
81 | chapters,
82 | url,
83 | language: '简体',
84 | rawUrl
85 | }
86 | }
87 |
88 | async getImgList(chapterUrl: string): Promise {
89 | const {origin} = new URL(this.bookUrl)
90 | const reqUrl = isHasHost(chapterUrl) ? chapterUrl : `${origin}${chapterUrl}`
91 | const response = await got(reqUrl, this.genReqOptions())
92 | const decoder = new TextDecoder('gbk')
93 | const bodyStr = decoder.decode(response.rawBody)
94 | const reg = /document\.write\("
/g
95 | const [, nextImgUrlPath, tempCurImgPath ] = reg.exec(bodyStr) ?? []
96 | if (!nextImgUrlPath || !tempCurImgPath) return []
97 | const nextImgUrl = `${origin}${nextImgUrlPath}`
98 | const isEnd = nextImgUrlPath.includes('exit')
99 | const imgHostMap = {} as {[key: string]: string}
100 | const scriptReg = /