├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .travis.yml ├── LICENSE ├── README.md ├── __mock__ ├── jest.fileMock.js ├── jest.setup.js └── jest.styleMock.js ├── build ├── webpack.base.confg.js ├── webpack.dev.config.js └── webpack.prod.config.js ├── docs ├── comic.sql ├── joinUs.md ├── logo.svg └── readmeTemplate.md ├── global.d.ts ├── jest.config.js ├── package.json ├── server ├── .gitignore ├── index.ts ├── locales │ ├── __tests__ │ │ └── index.test.ts │ ├── en-US │ │ ├── index.ts │ │ └── middleware.ts │ ├── index.ts │ └── zh-CN │ │ ├── index.ts │ │ └── middleware.ts ├── middleware │ ├── __tests__ │ │ ├── header.test.ts │ │ └── onerror.test.ts │ ├── accessControl.ts │ ├── apiResponseHandler.ts │ ├── dataProcess.ts │ ├── header.ts │ └── onerror.ts ├── package.json ├── router │ └── index.ts ├── routes │ ├── __tests__ │ │ ├── kuaikanmanhua.test.ts │ │ ├── manhuagui.test.ts │ │ ├── menu.test.ts │ │ ├── qq.test.ts │ │ ├── tohomh123.test.ts │ │ └── u17.test.ts │ ├── kuaikanmanhua │ │ ├── index.ts │ │ └── utils.ts │ ├── manhuagui │ │ ├── index.ts │ │ └── utils.ts │ ├── menu │ │ └── index.ts │ ├── qq │ │ ├── index.ts │ │ └── utils.ts │ ├── sql │ │ └── index.ts │ ├── test │ │ └── index.ts │ ├── tohomh123 │ │ ├── index.ts │ │ └── utils.ts │ └── u17 │ │ ├── index.ts │ │ └── utils.ts ├── service │ └── index.ts ├── shared │ ├── __tests__ │ │ └── urlConfig.test.ts │ ├── index.ts │ ├── statusCode.ts │ ├── type.ts │ └── urlConfig.ts ├── sql │ └── mysql.ts ├── tsconfig.json ├── type │ └── index.ts └── utils │ ├── __tests__ │ ├── axios.test.ts │ ├── convertImage.test.ts │ ├── downloadImage.test.ts │ ├── generatePdf.test.ts │ ├── makeDir.test.ts │ ├── md5.test.ts │ ├── parseUrl.test.ts │ ├── puppeteer.test.ts │ ├── toNum.test.ts │ └── wait.test.ts │ ├── axios.ts │ ├── bookInfo.ts │ ├── convertImage.ts │ ├── downloadImage.ts │ ├── generateBook.ts │ ├── generateMarkdown.ts │ ├── generatePdf.ts │ ├── logger.ts │ ├── makeDir.ts │ ├── md5.ts │ ├── parseUrl.ts │ ├── puppeteer.ts │ ├── toNum.ts │ ├── type.ts │ └── wait.ts ├── src ├── components │ ├── CommonFooter │ │ └── index.tsx │ ├── CommonHeader │ │ ├── index.less │ │ └── index.tsx │ ├── DumpTable │ │ ├── index.less │ │ └── index.tsx │ ├── SearchForm │ │ └── index.tsx │ ├── SelectLang │ │ ├── index.less │ │ └── index.tsx │ └── __tests__ │ │ ├── CommonFooter.test.tsx │ │ ├── CommonHeader.test.tsx │ │ └── __snapshots__ │ │ ├── CommonFooter.test.tsx.snap │ │ └── CommonHeader.test.tsx.snap ├── global.less ├── index.html ├── index.tsx ├── locales │ ├── en-US.ts │ ├── en-US │ │ ├── component.ts │ │ ├── page.ts │ │ └── utils.ts │ ├── index.ts │ ├── zh-CN.ts │ └── zh-CN │ │ ├── component.ts │ │ ├── page.ts │ │ └── utils.ts ├── pages │ ├── Chapter │ │ └── index.tsx │ ├── Help │ │ └── index.tsx │ ├── Images │ │ └── index.tsx │ ├── Layout │ │ ├── index.less │ │ └── index.tsx │ ├── NotMatch │ │ └── index.tsx │ ├── Result │ │ ├── index.less │ │ └── index.tsx │ ├── Search │ │ └── index.tsx │ └── __tests__ │ │ ├── Chapter.test.tsx │ │ ├── Help.test.tsx │ │ ├── Images.test.tsx │ │ ├── Layout.test.tsx │ │ ├── NotMatch.test.tsx │ │ ├── Result.test.tsx │ │ ├── Search.test.tsx │ │ └── __snapshots__ │ │ ├── Chapter.test.tsx.snap │ │ ├── Help.test.tsx.snap │ │ ├── Images.test.tsx.snap │ │ ├── Layout.test.tsx.snap │ │ ├── NotMatch.test.tsx.snap │ │ ├── Result.test.tsx.snap │ │ └── Search.test.tsx.snap ├── routes.tsx ├── services │ ├── columns.tsx │ └── index.ts ├── store │ ├── base.tsx │ └── index.ts ├── type │ └── index.ts └── utils │ ├── __tests__ │ └── index.test.ts │ ├── index.ts │ └── request.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # i18n en-US | zh-CN 2 | LANGUAGE='zh-CN' 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | scripts 3 | docs 4 | dist 5 | downloadResult 6 | node_modules 7 | server/types/* 8 | src/global.ts 9 | logs 10 | tmp 11 | **/*.snap 12 | src/pages/.umi 13 | src/models 14 | **/global.d.ts 15 | build -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const intentSize = 2; 2 | module.exports = { 3 | parser: '@typescript-eslint/parser', 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/eslint-recommended', 7 | 'plugin:@typescript-eslint/recommended', 8 | ], 9 | plugins: ['@typescript-eslint', 'react'], 10 | root: true, 11 | env: { 12 | commonjs: true, 13 | browser: true, 14 | node: true, 15 | es6: true, 16 | jest: true, 17 | }, 18 | parserOptions: { 19 | ecmaVersion: 6, 20 | sourceType: 'module', 21 | ecmaFeatures: { 22 | globalReturn: false, 23 | impliedStrict: true, 24 | jsx: true, 25 | modules: true, 26 | }, 27 | requireConfigFile: false, 28 | allowImportExportEverywhere: false, 29 | }, 30 | rules: { 31 | camelcase: 'off', 32 | 'no-restricted-syntax': 'off', 33 | 'no-console': ['error', { allow: ['log'] }], 34 | 'import/prefer-default-export': 'off', 35 | 'no-await-in-loop': 'off', 36 | 'react/prop-types': 'off', 37 | 'react/jsx-uses-react': ['error'], 38 | 'react/jsx-uses-vars': ['error'], 39 | 'react/jsx-filename-extension': [ 40 | 'error', 41 | { extensions: ['.js', '.jsx', '.ts', '.tsx'] }, 42 | ], 43 | 'no-unused-vars': 'error', 44 | 'import/no-unresolved': 'off', 45 | 'import/no-extraneous-dependencies': 'off', 46 | 'react/prefer-stateless-function': 'error', 47 | 'react/no-array-index-key': 'error', 48 | 'react/jsx-indent-props': ['error', intentSize], 49 | 'react/jsx-indent': ['error', intentSize], 50 | indent: ['error', intentSize, { SwitchCase: 1 }], 51 | 'require-atomic-updates': 'off', 52 | '@typescript-eslint/indent': ['error', intentSize, { SwitchCase: 1 }], 53 | '@typescript-eslint/prefer-interface': 'off', 54 | '@typescript-eslint/explicit-function-return-type': 'off', 55 | '@typescript-eslint/camelcase': 'off', 56 | '@typescript-eslint/interface-name-prefix': 'off', 57 | '@typescript-eslint/ban-ts-ignore': 'off', 58 | '@typescript-eslint/no-empty-function': 'error', 59 | '@typescript-eslint/unified-signatures': 'error', 60 | 'prefer-destructuring': 'error', 61 | '@typescript-eslint/no-explicit-any': 'error', 62 | }, 63 | settings: { 64 | react: { 65 | version: 'detect', 66 | }, 67 | }, 68 | }; 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | release 4 | downloadResult 5 | # misc 6 | .DS_Store 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | /coverage 11 | .vscode 12 | .idea 13 | tmp 14 | yarn.lock 15 | package-lock.json 16 | log 17 | *.pdf 18 | *.epub 19 | logs 20 | **/logs 21 | 22 | .env 23 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | dist 3 | coverage 4 | downloadResult 5 | 6 | server/coverage 7 | server/dist 8 | server/logs 9 | node_modules 10 | src/node_modules 11 | server/tmp 12 | **/*.snap 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "tabWidth": 2 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 10 4 | 5 | before_install: 6 | - npm i -g codecov 7 | 8 | install: 9 | - npm i 10 | - cd server && npm i 11 | 12 | before_script: 13 | - cd .. 14 | 15 | script: 16 | - npm run lint 17 | - npm run test 18 | 19 | after_script: 20 | - codecov 21 | 22 | git: 23 | depth: 1 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Steve Xu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
Comic Downloader (漫画下载器) 27 |
28 | 29 | ## 介绍 30 | 31 | ComicHub 是一款漫画下载器。爬取漫画网站的图片,生成 PDF 文件。 32 | 33 | ## 技术栈 34 | 35 | - [Koa](https://github.com/koajs/koa) - Expressive middleware for node.js using ES2017 async functions 36 | - [MySQL](https://github.com/mysqljs/mysql) 37 | - [React](https://github.com/facebook/react) - A declarative, efficient, and flexible JavaScript library for building user interfaces. 38 | - [Typescript](https://github.com/microsoft/TypeScript) - TypeScript is a superset of JavaScript 39 | 40 | ## 🎉 功能 41 | 42 | 1. [x] 下载一集漫画 43 | 1. [x] MySQL 存储爬取链接 44 | 1. [x] 前端交互页面 45 | 1. [x] 生成 PDF 文件 46 | 1. [x] 多语言 47 | 1. [ ] 下载一部漫画 48 | 49 | ## 支持的网站 50 | 51 | 更多站点,敬请期待! 52 | 53 | 1. [看漫画 (https://www.manhuagui.com)](https://www.manhuagui.com) 54 | 1. [土豪漫画 (https://www.tohomh123.com)](https://www.tohomh123.com) 55 | 1. [有妖气漫画 (http://www.u17.com)](http://www.u17.com) 56 | 1. [快看动漫 (https://www.kuaikanmanhua.com)](https://www.kuaikanmanhua.com) 57 | 1. [~~腾讯动漫 (https://ac.qq.com)~~](https://ac.qq.com) 58 | 59 | ## 装包 60 | 61 | 配置淘宝镜像,加快下载速度 62 | 63 | **不推荐使用 cnpm 安装依赖** 64 | 65 | ```bash 66 | $ npm i -g nrm # Mac 用户加上 sudo 67 | $ nrm use taobao 68 | ``` 69 | 70 | 前端安装依赖 71 | 72 | ```bash 73 | $ npm install 74 | ``` 75 | 76 | 服务端安装依赖 77 | 78 | ```bash 79 | $ cd server 80 | $ npm install 81 | ``` 82 | 83 | ## 运行 84 | 85 | 启动前端页面 86 | 87 | ```bash 88 | $ npm run start 89 | ``` 90 | 91 | 启动服务端 92 | 93 | ```bash 94 | $ npm run start:server 95 | ``` 96 | 97 | ## 测试 98 | 99 | ```bash 100 | $ npm run test 101 | ``` 102 | 103 | ## 打包 104 | 105 | ```bash 106 | $ npm run build 107 | ``` 108 | 109 | ## 支持更多格式 110 | 111 | 目前只支持 PDF 。更多格式请使用下列工具转换。 112 | 113 | 1. GUI 转换工具 [https://calibre-ebook.com/](https://calibre-ebook.com/) 114 | 2. 命令行转换工具 [https://pandoc.org/index.html](https://pandoc.org/index.html) 115 | 116 | ## 新增漫画网站 117 | 118 | 1. 查看 [/docs/joinUs.md](https://github.com/nusr/ComicHub/blob/master/docs/joinUs.md) 开发说明。 119 | 2. 在 [/server/router/index.ts](https://github.com/nusr/ComicHub/blob/master/server/router/index.ts) 里添加路由。 120 | 3. 在 [/server/routes/](https://github.com/nusr/ComicHub/tree/master/server/routes) 中新增脚本。 121 | 122 | ## 参与项目 123 | 124 | 欢迎提交 [issue](https://github.com/nusr/ComicHub/issues) 以及 Pull Requests 。 125 | 126 | 为了避免版权纠纷,只抓取免费漫画。 127 | 128 | ## 类似项目 129 | 130 | 1. [work_crawler](https://github.com/kanasimi/work_crawler) 131 | -------------------------------------------------------------------------------- /__mock__/jest.fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'file://assets/resource'; 2 | -------------------------------------------------------------------------------- /__mock__/jest.setup.js: -------------------------------------------------------------------------------- 1 | jest.setTimeout(1000 * 60 * 10) 2 | // Runs failed tests n-times until they pass or until the max number of retries is exhausted. This only works with jest-circus! 3 | jest.retryTimes(5) 4 | -------------------------------------------------------------------------------- /__mock__/jest.styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /build/webpack.base.confg.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const webpack = require('webpack'); 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 6 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer') 7 | .BundleAnalyzerPlugin; 8 | const ProgressBarPlugin = require('progress-bar-webpack-plugin'); 9 | // 注入 .env 环境变量 10 | const isProd = process.env.NODE_ENV === 'production'; 11 | let envConfig; 12 | try { 13 | envConfig = dotEnv.parse(fs.readFileSync('../.env')); 14 | } catch (error) { 15 | envConfig = {}; 16 | } 17 | 18 | const defineEnv = { 19 | NODE_ENV: process.env.NODE_ENV, 20 | ...envConfig, 21 | }; 22 | module.exports = { 23 | resolve: { 24 | alias: { 25 | '~': path.resolve(__dirname, '../src'), 26 | }, 27 | extensions: ['.js', '.json', '.ts', '.tsx', '.jsx'], 28 | }, 29 | entry: path.resolve(__dirname, '../src') + '/index.tsx', 30 | output: { 31 | // use absolute path 32 | // publicPath: '/', 33 | path: path.join(__dirname, '../dist'), 34 | filename: 35 | process.env.NODE_ENV === 'production' 36 | ? 'bundle.[chunkhash:8].js' 37 | : 'bundle.main.js', 38 | }, 39 | module: { 40 | rules: [ 41 | { 42 | test: /\.ts(x?)$/, 43 | use: ['ts-loader'], 44 | include: [path.join(__dirname, '../src')], 45 | }, 46 | { 47 | test: /\.(jpe?g|png|svg|gif|ogg|mp3|ttf|otf|eot|woff(?:2)?)(\?[a-z0-9]+)?$/, 48 | exclude: path.resolve(__dirname, '../src/assets/icon'), 49 | use: [ 50 | { 51 | loader: 'url-loader', 52 | options: { 53 | limit: 10 * 1024, 54 | name: '[name][hash].[ext]', 55 | outputPath: 'assets', 56 | }, 57 | }, 58 | ], 59 | }, 60 | { 61 | test: /\.less$/, 62 | exclude: /node_modules/, 63 | use: [ 64 | { 65 | loader: isProd ? MiniCssExtractPlugin.loader : 'style-loader', 66 | }, 67 | { 68 | loader: 'css-loader', 69 | options: { 70 | sourceMap: !isProd, 71 | }, 72 | }, 73 | { 74 | loader: 'less-loader', 75 | options: { 76 | javascriptEnabled: true, 77 | }, 78 | }, 79 | ], 80 | }, 81 | { 82 | test: /\.css$/, 83 | include: /node_modules/, 84 | use: [ 85 | { 86 | loader: isProd ? MiniCssExtractPlugin.loader : 'style-loader', 87 | }, 88 | { 89 | loader: 'css-loader', 90 | options: { 91 | sourceMap: !isProd, 92 | }, 93 | }, 94 | ], 95 | }, 96 | ], 97 | }, 98 | plugins: [ 99 | new ProgressBarPlugin({ 100 | format: ' build [:bar] :percent (:elapsed seconds)', 101 | }), 102 | new webpack.DefinePlugin({ 103 | 'process.env': defineEnv, 104 | }), 105 | new HtmlWebpackPlugin({ 106 | template: path.resolve(__dirname, '../src') + '/index.html', 107 | }), 108 | ].concat(process.env.ANALYZE ? new BundleAnalyzerPlugin() : []), 109 | }; 110 | -------------------------------------------------------------------------------- /build/webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const merge = require('webpack-merge'); 4 | const common = require('./webpack.base.confg'); 5 | const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin'); 6 | 7 | const PORT = 3000; 8 | const _HOST = 'localhost'; 9 | const HOST = `http://${_HOST}`; 10 | const URL = `${HOST}:${PORT}`; 11 | 12 | module.exports = merge(common, { 13 | mode: 'development', 14 | devtool: 'source-map', // for best build performance when use HMR 15 | devServer: { 16 | quiet: true, 17 | hot: true, 18 | // enable HMR on the server 19 | compress: true, 20 | contentBase: path.resolve(__dirname, '../src'), 21 | // match the output path 22 | port: PORT, 23 | host: _HOST, 24 | publicPath: URL, 25 | historyApiFallback: true, 26 | proxy: { 27 | '/v1': { 28 | changeOrigin: true, 29 | pathRewrite: { '^/v1': '' }, 30 | target: 'http://localhost:1200', 31 | }, 32 | }, 33 | }, 34 | plugins: [ 35 | new FriendlyErrorsWebpackPlugin(), 36 | new webpack.HotModuleReplacementPlugin(), 37 | ], 38 | }); 39 | -------------------------------------------------------------------------------- /build/webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 2 | const merge = require('webpack-merge') 3 | const webpack = require('webpack') 4 | const cleanWebpackPlugin = require('clean-webpack-plugin').CleanWebpackPlugin 5 | const common = require('./webpack.base.confg') 6 | module.exports = merge(common, { 7 | mode: 'production', 8 | plugins: [ 9 | new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /zh|en/), 10 | new cleanWebpackPlugin(), 11 | new MiniCssExtractPlugin({ 12 | filename: 'main.[contenthash].css', 13 | chunkFilename: '[id].[hash].css' 14 | }), 15 | ] 16 | }) 17 | -------------------------------------------------------------------------------- /docs/joinUs.md: -------------------------------------------------------------------------------- 1 | # 开发说明 2 | 3 | **第一次贡献代码?** 4 | 5 | 请查看 [开源贡献指南](https://github.com/freeCodeCamp/how-to-contribute-to-open-source/blob/master/README-CN.md)。 6 | 7 | ## 不要直接修改 README.md 文件 8 | 9 | 修改 [readmeTemplate.md](readmeTemplate.md) 10 | 11 | 添加爬取站点或者修改了 [readmeTemplate.md](readmeTemplate.md),`git add` 前运行 `npm run readme`,刷新 **README.md** 。 12 | 13 | ## 使用 puppeteer 14 | 15 | 页面动态渲染生成,使用 puppeteer 爬取图片。 16 | 17 | 参见 [../server/routes/u17/index.ts](../server/routes/u17/index.ts) ,或者搜索整个项目 **puppeteer** 。 18 | 19 | ## 数据库使用 20 | 21 | 22 | 1. 从[https://dev.mysql.com/downloads/mysql/](https://dev.mysql.com/downloads/mysql/)下载应用,安装 MySQL 23 | 2. 启动本地 MySQL,使用默认端口即可 24 | 3. 建立数据库 **comic** 25 | 4. 导入数据库表到数据库 **comic** 26 | 27 | > 如何导入数据库,查看[https://www.runoob.com/mysql/mysql-database-import.html](https://www.runoob.com/mysql/mysql-database-import.html) 28 | 29 | 数据库表结构见 [comic.sql](comic.sql) 30 | 31 | 字段说明参见 [../server/type/index.ts](../server/types/index.ts) 32 | 33 | ## 开发配置 34 | 35 | 使用环境变量用作配置,绝大部分配置都可以在 [../.env](../.env) 中进行配置。前端和服务端的配置均在里面。 36 | 37 | 服务端配置文件: [../server/shared/config.ts](../server/shared/statusCode.ts) 38 | 39 | 前端 Webpack 配置文件夹: [../config](../config) 40 | -------------------------------------------------------------------------------- /docs/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/readmeTemplate.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
Comic Downloader (漫画下载器) 27 |
28 | 29 | ## 介绍 30 | 31 | ComicHub 是一款漫画下载器。爬取漫画网站的图片,生成 PDF 文件。 32 | 33 | ## 技术栈 34 | 35 | - [Koa](https://github.com/koajs/koa) - Expressive middleware for node.js using ES2017 async functions 36 | - [MySQL](https://github.com/mysqljs/mysql) 37 | - [React](https://github.com/facebook/react) - A declarative, efficient, and flexible JavaScript library for building user interfaces. 38 | - [Typescript](https://github.com/microsoft/TypeScript) - TypeScript is a superset of JavaScript 39 | 40 | ## 🎉 功能 41 | 42 | 1. [x] 下载一集漫画 43 | 1. [x] MySQL 存储爬取链接 44 | 1. [x] 前端交互页面 45 | 1. [x] 生成 PDF 文件 46 | 1. [x] 多语言 47 | 1. [ ] 下载一部漫画 48 | 49 | ## 支持的网站 50 | 51 | 更多站点,敬请期待! 52 | 53 | ---comic-site-- 54 | 55 | ## 装包 56 | 57 | 配置淘宝镜像,加快下载速度 58 | 59 | **不推荐使用 cnpm 安装依赖** 60 | 61 | ```bash 62 | $ npm i -g nrm # Mac 用户加上 sudo 63 | $ nrm use taobao 64 | ``` 65 | 66 | 前端安装依赖 67 | 68 | ```bash 69 | $ npm install 70 | ``` 71 | 72 | 服务端安装依赖 73 | 74 | ```bash 75 | $ cd server 76 | $ npm install 77 | ``` 78 | 79 | ## 运行 80 | 81 | 启动前端页面 82 | 83 | ```bash 84 | $ npm run start 85 | ``` 86 | 87 | 启动服务端 88 | 89 | ```bash 90 | $ npm run start:server 91 | ``` 92 | 93 | ## 测试 94 | 95 | ```bash 96 | $ npm run test 97 | ``` 98 | 99 | ## 打包 100 | 101 | ```bash 102 | $ npm run build 103 | ``` 104 | 105 | ## 支持更多格式 106 | 107 | 目前只支持 PDF 。更多格式请使用下列工具转换。 108 | 109 | 1. GUI 转换工具 [https://calibre-ebook.com/](https://calibre-ebook.com/) 110 | 2. 命令行转换工具 [https://pandoc.org/index.html](https://pandoc.org/index.html) 111 | 112 | ## 新增漫画网站 113 | 114 | 1. 查看 [/docs/joinUs.md](https://github.com/nusr/ComicHub/blob/master/docs/joinUs.md) 开发说明。 115 | 2. 在 [/server/router/index.ts](https://github.com/nusr/ComicHub/blob/master/server/router/index.ts) 里添加路由。 116 | 3. 在 [/server/routes/](https://github.com/nusr/ComicHub/tree/master/server/routes) 中新增脚本。 117 | 118 | ## 参与项目 119 | 120 | 欢迎提交 [issue](https://github.com/nusr/ComicHub/issues) 以及 Pull Requests 。 121 | 122 | 为了避免版权纠纷,只抓取免费漫画。 123 | 124 | ## 类似项目 125 | 126 | 1. [work_crawler](https://github.com/kanasimi/work_crawler) 127 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | declare module '*.less'; 3 | declare module '*.scss'; 4 | declare module '*.sass'; 5 | declare module '*.svg'; 6 | declare module '*.png'; 7 | declare module '*.jpg'; 8 | declare module '*.jpeg'; 9 | declare module '*.gif'; 10 | declare module '*.bmp'; 11 | declare module '*.tiff'; 12 | declare interface JsObject { 13 | [key: string]: any; 14 | } 15 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rootDir: __dirname, 3 | 'testRunner': 'jest-circus/runner', 4 | 'preset': 'ts-jest', 5 | testMatch: ['**/?(*.)+(spec|test).ts?(x)'], 6 | // testMatch: ['**/src/**/*.test.ts?(x)'], 7 | setupFilesAfterEnv: ['Error: Error test/` 18 | ) 19 | ); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /server/middleware/accessControl.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from 'koa'; 2 | import config from '../shared'; 3 | import statusCodes from '../shared/statusCode'; 4 | import { getLanguageData } from '../locales'; 5 | 6 | const FAIL_MATCH = -1; 7 | const LINK_TIME: number = 24 * 60 * 60; 8 | const reject = (ctx: Koa.BaseContext): void => { 9 | ctx.response.status = statusCodes.FORBIDDEN; 10 | ctx.body = { 11 | lastBuildDate: new Date().toUTCString(), 12 | updated: new Date().toISOString(), 13 | ttl: LINK_TIME, 14 | title: getLanguageData('middleware.accessControl.deny'), 15 | }; 16 | }; 17 | 18 | const accessControl = async ( 19 | ctx: Koa.BaseContext, 20 | next: Function, 21 | ) => { 22 | const ip = ctx.ips[0] || ctx.ip; 23 | const requestPath = ctx.request.path; 24 | 25 | if (requestPath === '/') { 26 | await next(); 27 | } else { 28 | if (config.whitelist) { 29 | if ( 30 | !( 31 | config.whitelist.indexOf(ip) !== FAIL_MATCH || 32 | config.whitelist.indexOf(requestPath) !== FAIL_MATCH 33 | ) 34 | ) { 35 | reject(ctx); 36 | } 37 | } else if (config.blacklist) { 38 | if ( 39 | config.blacklist.indexOf(ip) !== FAIL_MATCH || 40 | config.blacklist.indexOf(requestPath) !== FAIL_MATCH 41 | ) { 42 | reject(ctx); 43 | } 44 | } 45 | 46 | if (ctx.response.status !== statusCodes.FORBIDDEN) { 47 | await next(); 48 | } 49 | } 50 | }; 51 | export default accessControl; 52 | -------------------------------------------------------------------------------- /server/middleware/apiResponseHandler.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from 'koa'; 2 | 3 | import statusCodes from '../shared/statusCode'; 4 | 5 | const responseHandler = async ( 6 | ctx: Koa.BaseContext, 7 | next: Function, 8 | )=> { 9 | ctx.res.statusCodes = statusCodes; 10 | ctx.statusCodes = ctx.res.statusCodes; 11 | 12 | ctx.res.success = ({ 13 | statusCode, 14 | data, 15 | message, 16 | }: JsObject) => { 17 | const status = 0; 18 | 19 | if (Boolean(statusCode) && statusCode < statusCode.BAD_REQUEST) { 20 | ctx.status = statusCode; 21 | } else if (!(ctx.status < statusCode.BAD_REQUEST)) { 22 | ctx.status = statusCodes.OK; 23 | } 24 | 25 | ctx.body = { 26 | status, 27 | data, 28 | message, 29 | }; 30 | }; 31 | ctx.res.ok = (params: object = {}) => { 32 | ctx.res.success({ 33 | ...params, 34 | statusCode: statusCodes.OK, 35 | }); 36 | }; 37 | await next(); 38 | }; 39 | export default responseHandler; 40 | -------------------------------------------------------------------------------- /server/middleware/dataProcess.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from 'koa'; 2 | import _ from 'lodash'; 3 | import mysqlService from '../service'; 4 | import { apiType } from '../shared'; 5 | import { IChapterMysql, IRequestData, ISearchMysql } from '../type'; 6 | import statusCodes from '../shared/statusCode'; 7 | import generateBook from '../utils/generateBook'; 8 | import { getLanguageData } from '../locales'; 9 | 10 | interface EmptyData { 11 | message: string; 12 | } 13 | 14 | const { NODE_ENV } = process.env; 15 | 16 | function handleEmpty(stateType: string): EmptyData { 17 | const dataResult: EmptyData = { 18 | message: '', 19 | }; 20 | if (stateType === apiType.search) { 21 | dataResult.message = getLanguageData('middleware.dataProcess.search.empty'); 22 | } else if (stateType === apiType.chapter) { 23 | dataResult.message = getLanguageData( 24 | 'middleware.dataProcess.chapter.empty' 25 | ); 26 | } 27 | return dataResult; 28 | } 29 | 30 | function filterArray(data: T[] = []): T[] { 31 | const record: JsObject = {}; 32 | const result: T[] = []; 33 | // eslint-disable-next-line 34 | data.forEach((item: any) => { 35 | if (item.url && !record[item.url]) { 36 | record[item.url] = '1'; 37 | result.push(item); 38 | } 39 | }); 40 | return result; 41 | } 42 | 43 | const mysqlHandler = async ( 44 | ctx: Koa.BaseContext, 45 | next: Function 46 | // eslint-disable-next-line 47 | ): Promise => { 48 | const requestData: IRequestData = ctx.request.body; 49 | ctx.state.url = requestData.name; 50 | ctx.state.type = requestData.type; 51 | await next(); 52 | let dataResult = ctx.state.data; 53 | const stateType = ctx.state.type; 54 | if (!stateType || NODE_ENV === 'test') { 55 | ctx.body = dataResult || ctx.body; 56 | return; 57 | } 58 | if (dataResult) { 59 | const searchUrl = ctx.state.url; 60 | if (stateType === apiType.search) { 61 | for (const item of dataResult) { 62 | await mysqlService.addItem(item, stateType); 63 | } 64 | } 65 | if (stateType === apiType.chapter) { 66 | dataResult = filterArray(dataResult); 67 | const searchResult: ISearchMysql = await mysqlService.searchOne( 68 | searchUrl, 69 | apiType.search 70 | ); 71 | for (const item of dataResult) { 72 | await mysqlService.addItem( 73 | { 74 | search_id: _.get(searchResult, 'id'), 75 | ...item, 76 | }, 77 | stateType 78 | ); 79 | } 80 | } 81 | if (stateType === apiType.download) { 82 | dataResult = filterArray(dataResult); 83 | const chapterItem: IChapterMysql = await mysqlService.searchOne( 84 | searchUrl, 85 | apiType.chapter 86 | ); 87 | const searchItem: ISearchMysql = await mysqlService.searchOne( 88 | _.get(chapterItem, 'search_id', ''), 89 | apiType.search, 90 | 'id' 91 | ); 92 | if (!_.isEmpty(searchItem) && !_.isEmpty(chapterItem)) { 93 | for (const item of dataResult) { 94 | await mysqlService.addItem( 95 | { 96 | chapter_id: chapterItem.id, 97 | ...item, 98 | }, 99 | stateType 100 | ); 101 | } 102 | const bookPath: string = await generateBook( 103 | dataResult, 104 | searchItem, 105 | chapterItem, 106 | searchUrl 107 | ); 108 | dataResult = { 109 | message: getLanguageData('middleware.dataProcess.success'), 110 | code: statusCodes.OK, 111 | data: bookPath, 112 | }; 113 | } 114 | } 115 | } 116 | 117 | ctx.body = _.isEmpty(dataResult) ? handleEmpty(stateType) : dataResult; 118 | }; 119 | export default mysqlHandler; 120 | -------------------------------------------------------------------------------- /server/middleware/header.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from 'koa'; 2 | import logger from '../utils/logger'; 3 | 4 | const headers = { 5 | 'Access-Control-Allow-Methods': 'GET,HEAD,PUT,POST,DELETE,PATCH', 6 | 'Content-Type': 'application/json; charset=utf-8', 7 | }; 8 | 9 | const headerHandler = async ( 10 | ctx: Koa.Context, 11 | next: Function, 12 | ) => { 13 | ctx.set(headers); 14 | ctx.set({ 15 | 'Access-Control-Allow-Origin': `${ctx.host}`, 16 | }); 17 | logger.info(`current request url: ${ctx.url}`); 18 | await next(); 19 | }; 20 | export default headerHandler; 21 | -------------------------------------------------------------------------------- /server/middleware/onerror.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from 'koa'; 2 | import logger from '../utils/logger'; 3 | import statusCodes from '../shared/statusCode'; 4 | import { getLanguageData } from '../locales'; 5 | 6 | const errorHandler = async (ctx: Koa.BaseContext, next: Function) => { 7 | try { 8 | await next(); 9 | } catch (err) { 10 | logger.error( 11 | `Error in ${ctx.request.path}: ${err instanceof Error ? err.stack : err}` 12 | ); 13 | ctx.set({ 14 | 'Content-Type': 'text/html; charset=UTF-8', 15 | }); 16 | ctx.body = `${getLanguageData('middleware.onerror.error')}: ${ 17 | err instanceof Error ? err.stack : err 18 | }`; 19 | if (err.status === statusCodes.UNAUTHORIZED) { 20 | ctx.status = statusCodes.UNAUTHORIZED; 21 | } else { 22 | ctx.status = statusCodes.NOT_FOUND; 23 | } 24 | } 25 | }; 26 | export default errorHandler; 27 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "dist/index.js", 3 | "bin": "dist/index.js", 4 | "scripts": { 5 | "postinstall": "npm run build && npm run readme", 6 | "readme": "ts-node utils/generateMarkdown.ts", 7 | "build": "npm run build-ts", 8 | "start": "node dist/index.js", 9 | "build-ts": "tsc", 10 | "watch-ts": "tsc -w", 11 | "serve-debug": "nodemon --inspect dist/index.js", 12 | "dev": "concurrently -k -p \"[{name}]\" -n \"TypeScript,Node\" -c \"yellow.bold,green.bold\" \"npm run watch-ts\" \"npm run serve-debug\"" 13 | }, 14 | "dependencies": { 15 | "axios": "^0.19.0", 16 | "axios-retry": "3.1.2", 17 | "cheerio": "1.0.0-rc.3", 18 | "dotenv": "^8.0.0", 19 | "koa": "^2.8.1", 20 | "koa-bodyparser": "^4.2.1", 21 | "koa-mount": "^4.0.0", 22 | "koa-router": "7.4.0", 23 | "lodash": "^4.17.15", 24 | "mysql": "^2.17.1", 25 | "pdfkit": "^0.10.0", 26 | "puppeteer": "^1.19.0", 27 | "sharp": "^0.22.1", 28 | "winston": "3.2.1" 29 | }, 30 | "devDependencies": { 31 | "@types/cheerio": "^0.22.12", 32 | "@types/dotenv": "^6.1.1", 33 | "@types/koa": "^2.0.49", 34 | "@types/koa-bodyparser": "^4.2.2", 35 | "@types/koa-mount": "^4.0.0", 36 | "@types/koa-router": "^7.0.42", 37 | "@types/lodash": "^4.14.136", 38 | "@types/mysql": "^2.15.6", 39 | "@types/node": "^12.7.5", 40 | "@types/pdfkit": "^0.10.2", 41 | "@types/puppeteer": "^1.19.0", 42 | "@types/sharp": "^0.22.1", 43 | "@types/supertest": "^2.0.8", 44 | "axios-mock-adapter": "^1.17.0", 45 | "concurrently": "^4.1.1", 46 | "nodemon": "^1.19.2", 47 | "supertest": "4.0.2", 48 | "ts-node": "^8.4.1", 49 | "typescript": "^3.6.3" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /server/router/index.ts: -------------------------------------------------------------------------------- 1 | import Router from 'koa-router'; 2 | import * as Koa from 'koa'; 3 | // Router 4 | import testRouter from '../routes/test'; 5 | import sql from '../routes/sql'; 6 | import menuRouter from '../routes/menu'; 7 | import tohomh from '../routes/tohomh123'; 8 | import manhuagui from '../routes/manhuagui'; 9 | import u17 from '../routes/u17'; 10 | import qq from '../routes/qq'; 11 | import kuaikanmanhua from '../routes/kuaikanmanhua'; 12 | 13 | const router = new Router(); 14 | router.get('/', (ctx: Koa.Context) => { 15 | ctx.set({ 16 | 'Cache-Control': 'no-cache', 17 | }); 18 | ctx.body = { 19 | request: 'Welcome Use Comic Hub', 20 | }; 21 | }); 22 | 23 | // Test 24 | router.get('/test/:id', testRouter); 25 | // 查询 sql 数据 26 | router.post('/sql', sql); 27 | // 左侧菜单 28 | router.get('/menu', menuRouter); 29 | router.post('/manhuagui', manhuagui); 30 | router.post('/tohomh123', tohomh); 31 | router.post('/u17', u17); 32 | router.post('/qq', qq); 33 | router.post('/kuaikanmanhua', kuaikanmanhua); 34 | 35 | export default router; 36 | -------------------------------------------------------------------------------- /server/routes/__tests__/kuaikanmanhua.test.ts: -------------------------------------------------------------------------------- 1 | import superTest from 'supertest'; 2 | import koaServer from '../../index'; 3 | import { ISearchItem } from '../../type'; 4 | 5 | const { server } = koaServer; 6 | const request = superTest(server); 7 | afterAll(() => { 8 | server.close(); 9 | }); 10 | 11 | describe('test', () => { 12 | it('test /kuaikanmanhua search', async () => { 13 | const name = '火影'; 14 | const response: superTest.Response = await request.post('/kuaikanmanhua').send({ type: 'search', name }); 15 | const data: ISearchItem[] = response.body; 16 | expect(data.every((item: ISearchItem) => item.title.includes(name))).toBeTruthy(); 17 | }); 18 | it('test /kuaikanmanhua chapter', async () => { 19 | const response: superTest.Response = await request.post('/kuaikanmanhua').send({ 20 | 'type': 'chapter', 21 | 'name': 'https://www.kuaikanmanhua.com/web/topic/1342', 22 | }); 23 | expect(response.body.length).toBeGreaterThan(700); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /server/routes/__tests__/manhuagui.test.ts: -------------------------------------------------------------------------------- 1 | import superTest from 'supertest'; 2 | import koaServer from '../../index'; 3 | import { ISearchItem } from '../../type'; 4 | 5 | const { server } = koaServer; 6 | const request = superTest(server); 7 | afterAll(() => { 8 | server.close(); 9 | }); 10 | 11 | describe('test', () => { 12 | it('test /manhuagui search', async () => { 13 | const name = '火影'; 14 | const response: superTest.Response = await request.post('/manhuagui').send({ type: 'search', name }); 15 | const data: ISearchItem[] = response.body; 16 | expect(data.every((item: ISearchItem) => item.title.includes(name))).toBeTruthy(); 17 | }); 18 | 19 | it('test /manhuagui chapter', async () => { 20 | const response: superTest.Response = await request.post('/manhuagui').send({ 21 | 'type': 'chapter', 22 | 'name': 'https://www.manhuagui.com/comic/19187/', 23 | }); 24 | expect(response.body.length).toBeGreaterThanOrEqual(1); 25 | }); 26 | 27 | }); 28 | -------------------------------------------------------------------------------- /server/routes/__tests__/menu.test.ts: -------------------------------------------------------------------------------- 1 | import superTest from 'supertest'; 2 | import koaServer from '../../index'; 3 | import urlConfig from '../../shared/urlConfig'; 4 | 5 | const { server } = koaServer; 6 | const request = superTest(server); 7 | 8 | afterAll(() => { 9 | server.close(); 10 | }); 11 | 12 | describe('Test /menu Api', () => { 13 | it('/menu should result right result', async () => { 14 | const response: superTest.Response = await request.get('/menu'); 15 | expect(response.text).toBe(JSON.stringify(urlConfig)); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /server/routes/__tests__/qq.test.ts: -------------------------------------------------------------------------------- 1 | import superTest from 'supertest'; 2 | import koaServer from '../../index'; 3 | import { ISearchItem } from '../../type'; 4 | 5 | const { server } = koaServer; 6 | const request = superTest(server); 7 | afterAll(() => { 8 | server.close(); 9 | }); 10 | 11 | describe('test', () => { 12 | it('test /qq search', async () => { 13 | const name = '火影'; 14 | const response: superTest.Response = await request.post('/qq').send({ type: 'search', name }); 15 | const data: ISearchItem[] = response.body; 16 | expect(data.some((item: ISearchItem) => item.title.includes(name))).toBeTruthy(); 17 | }); 18 | it('test /qq chapter', async () => { 19 | const response: superTest.Response = await request.post('/qq').send({ 20 | 'type': 'chapter', 21 | 'name': 'https://ac.qq.com/Comic/comicInfo/id/541201', 22 | }); 23 | expect(response.body.length).toBeGreaterThanOrEqual(5) 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /server/routes/__tests__/tohomh123.test.ts: -------------------------------------------------------------------------------- 1 | import superTest from 'supertest'; 2 | import koaServer from '../../index'; 3 | import { ISearchItem } from '../../type'; 4 | 5 | const { server } = koaServer; 6 | const request = superTest(server); 7 | afterAll(() => { 8 | server.close(); 9 | }); 10 | 11 | describe('test', () => { 12 | it('test /tohomh123 search', async () => { 13 | const name = '火影'; 14 | const response: superTest.Response = await request.post('/tohomh123').send({ type: 'search', name }); 15 | const data: ISearchItem[] = response.body; 16 | expect(data.every((item: ISearchItem) => item.title.includes(name))).toBeTruthy(); 17 | }); 18 | 19 | it('test /tohomh123 chapter', async () => { 20 | const response: superTest.Response = await request.post('/tohomh123').send({ 21 | 'type': 'chapter', 22 | 'name': 'https://www.tohomh123.com/huoyingrenzhejiezhishu/', 23 | }); 24 | expect(response.body.length).toBeGreaterThan(6); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /server/routes/__tests__/u17.test.ts: -------------------------------------------------------------------------------- 1 | import superTest from 'supertest'; 2 | import koaServer from '../../index'; 3 | import { ISearchItem } from '../../type'; 4 | 5 | const { server } = koaServer; 6 | const request = superTest(server); 7 | afterAll(() => { 8 | server.close(); 9 | }); 10 | 11 | describe('test', () => { 12 | it('test /u17 search', async () => { 13 | const name = '火影'; 14 | const response: superTest.Response = await request.post('/u17').send({ type: 'search', name }); 15 | const data: ISearchItem[] = response.body; 16 | expect(data.every((item: ISearchItem) => item.title.includes(name))).toBeTruthy(); 17 | }); 18 | 19 | it('test /u17 chapter', async () => { 20 | const response: superTest.Response = await request.post('/u17').send({ 21 | 'type': 'chapter', 22 | 'name': 'http://www.u17.com/comic/8347.html', 23 | }); 24 | expect(response.body.length).toBeGreaterThanOrEqual(6) 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /server/routes/kuaikanmanhua/index.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from 'koa'; 2 | import { Browser, Page } from 'puppeteer'; 3 | import util from './utils'; 4 | import axios from '../../utils/axios'; 5 | import { apiType } from '../../shared'; 6 | import { IRequestData } from '../../type'; 7 | import puppeteer, { getHtml } from '../../utils/puppeteer'; 8 | 9 | const WAIT_TIME = 1000; 10 | let temp: object; 11 | const kuaikan = async (ctx: Koa.BaseContext) => { 12 | const { type, name }: IRequestData = ctx.request.body; 13 | if (apiType.search === type) { 14 | const url: string = util.getSearchUrl(name); 15 | const response = await axios.get(url); 16 | temp = util.getSearchList(response.data); 17 | } 18 | if (apiType.chapter === type) { 19 | const response = await axios.get(name); 20 | temp = util.getChapterList(response.data); 21 | } 22 | if (apiType.download === type) { 23 | const browser: Browser = await puppeteer(); 24 | const page: Page = await browser.newPage(); 25 | page.setViewport({ 26 | width: 1366, 27 | height: 768, 28 | }); 29 | await page.goto(name, { 30 | waitUntil: 'networkidle0', 31 | timeout: 0, 32 | }); 33 | await page.waitFor(WAIT_TIME * 2); 34 | const html = await page.evaluate(getHtml); 35 | temp = util.getDownloadList(html); 36 | await browser.close(); 37 | } 38 | ctx.state.data = temp; 39 | }; 40 | export default kuaikan; 41 | -------------------------------------------------------------------------------- /server/routes/kuaikanmanhua/utils.ts: -------------------------------------------------------------------------------- 1 | import cheerio from 'cheerio'; 2 | import _ from 'lodash'; 3 | import { IChapterItem, IImageItem, ISearchItem } from '../../type'; 4 | import urlConfig from '../../shared/urlConfig'; 5 | 6 | const baseUrl: string = urlConfig.kuaikanmanhua.base; 7 | 8 | function getSearchList(data: string): ISearchItem[] { 9 | const $ = cheerio.load(data); 10 | const result: ISearchItem[] = []; 11 | const list = $('.resultList>.TabW184'); 12 | list.each((i, item) => { 13 | const dom = $(item) 14 | .find('a') 15 | .eq(0); 16 | const title: string = $(item).find('.itemTitle').eq(0).text(); 17 | const author: string = $(item).find('.author').eq(0).text(); 18 | const url: string = baseUrl + dom.attr('href'); 19 | const cover: string = $(item) 20 | .find('.img') 21 | .eq(0) 22 | .attr('src'); 23 | const category: string = $(item).find('.tab').text(); 24 | result.push({ 25 | url, 26 | title, 27 | author: _.trim(author), 28 | cover, 29 | category, 30 | }); 31 | }); 32 | return result; 33 | } 34 | 35 | function getChapterList(data: string): IChapterItem[] { 36 | const $ = cheerio.load(data); 37 | const chapters: IChapterItem[] = []; 38 | $('.TopicList .TopicItem').each((i, item) => { 39 | const dom = $(item) 40 | .find('.title>a') 41 | .eq(0); 42 | const url: string = baseUrl + dom.attr('href'); 43 | const title: string = _.trim(dom.text()); 44 | chapters.push({ 45 | url, 46 | title, 47 | }); 48 | }); 49 | return chapters; 50 | } 51 | 52 | function getDownloadList(data: string): IImageItem[] { 53 | const result: IImageItem[] = []; 54 | const $ = cheerio.load(data); 55 | let page = 1; 56 | const others: string [] = []; 57 | $('.imgList>img').each((i, item) => { 58 | const url: string = $(item).attr('data-src'); 59 | others.push($(item).attr('src')); 60 | if (url) { 61 | result.push({ 62 | url, 63 | page, 64 | }); 65 | page += 1; 66 | } 67 | }); 68 | return result; 69 | } 70 | 71 | function getSearchUrl(name: string): string { 72 | return `${baseUrl}/s/result/${encodeURIComponent(name)}`; 73 | } 74 | 75 | export default { 76 | getChapterList, 77 | getSearchList, 78 | getSearchUrl, 79 | getDownloadList, 80 | }; 81 | -------------------------------------------------------------------------------- /server/routes/manhuagui/index.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from 'koa'; 2 | import { Browser, Page, ElementHandle } from 'puppeteer'; 3 | import { IRequestData } from '../../type'; 4 | import axios from '../../utils/axios'; 5 | import util from './utils'; 6 | import { apiType, DESKTOP_WINDOW_SIZE } from '../../shared'; 7 | import puppeteer, { getHtml } from '../../utils/puppeteer'; 8 | 9 | const DELAY_TIME = 500; 10 | let temp: JsObject[]; 11 | const manHuaGui = async (ctx: Koa.BaseContext) => { 12 | const { type, name, page_size: pageSize }: IRequestData = ctx.request.body; 13 | 14 | if (apiType.search === type) { 15 | const response = await axios.get(util.getSearchUrl(name)); 16 | temp = util.getSearchList(response.data); 17 | } 18 | if (apiType.chapter === type) { 19 | const response = await axios.get(name); 20 | temp = util.getChapterList(response.data); 21 | } 22 | if (apiType.download === type) { 23 | temp = []; 24 | let pageIndex = 1; 25 | const browser: Browser = await puppeteer(); 26 | const page: Page = await browser.newPage(); 27 | page.setViewport(DESKTOP_WINDOW_SIZE); 28 | await page.goto(name, { 29 | waitUntil: 'networkidle0', 30 | }); 31 | const html = await page.evaluate( 32 | getHtml, 33 | ); 34 | const imageSrc = util.getDownloadItem(html); 35 | temp.push({ 36 | page: pageIndex, 37 | url: imageSrc, 38 | }); 39 | pageIndex += 1; 40 | for (; pageIndex <= pageSize; pageIndex += 1) { 41 | const nextItem = (await page.$('#next')) as ElementHandle; 42 | nextItem.click(); 43 | await page.waitFor(DELAY_TIME); 44 | const otherHtml = await page.evaluate( 45 | getHtml, 46 | ); 47 | const otherImage = util.getDownloadItem(otherHtml); 48 | temp.push({ 49 | page: pageIndex, 50 | url: otherImage, 51 | }); 52 | } 53 | await browser.close(); 54 | } 55 | ctx.state.data = temp; 56 | }; 57 | export default manHuaGui; 58 | -------------------------------------------------------------------------------- /server/routes/manhuagui/utils.ts: -------------------------------------------------------------------------------- 1 | import urlModule from 'url'; 2 | import cheerio from 'cheerio'; 3 | import urlConfig from '../../shared/urlConfig'; 4 | import { IChapterItem, ISearchItem } from '../../type'; 5 | import toNum from '../../utils/toNum'; 6 | 7 | const baseUrl = urlConfig.manhuagui.base; 8 | const fixTitle = (value: string): string => { 9 | if (value) { 10 | return value.slice(3); 11 | } 12 | return ''; 13 | }; 14 | function getSearchList(data: string): ISearchItem[]{ 15 | const $ = cheerio.load(data); 16 | const result: ISearchItem[] = []; 17 | const list = $('.book-result>ul>li'); 18 | list.each((i, item) => { 19 | const dom = $(item) 20 | .find('.book-detail > dl > dt') 21 | .eq(0); 22 | const linkDom = dom.find('a').eq(0); 23 | const url = urlModule.resolve(baseUrl, linkDom.attr('href')); 24 | const title = dom.text(); 25 | const area = $(item) 26 | .find('div.book-detail > dl > dd:nth-child(3) > span:nth-child(2)') 27 | .eq(0) 28 | .text(); 29 | const author = $(item) 30 | .find('div.book-detail > dl > dd:nth-child(4) > span') 31 | .eq(0) 32 | .text(); 33 | const introduce = $(item) 34 | .find('div.book-detail > dl > dd.intro > span') 35 | .eq(0) 36 | .text(); 37 | const category = $(item) 38 | .find('div.book-detail > dl > dd:nth-child(3) > span:nth-child(3)') 39 | .eq(0) 40 | .text(); 41 | const cover = $(item) 42 | .find('div.book-cover > a > img') 43 | .eq(0) 44 | .attr('src'); 45 | result.push({ 46 | url, 47 | title, 48 | area: fixTitle(area), 49 | author: fixTitle(author), 50 | introduce: fixTitle(introduce), 51 | category: fixTitle(category), 52 | cover, 53 | }); 54 | }); 55 | return result; 56 | } 57 | 58 | const getChapterList = (data: string): IChapterItem[] => { 59 | const $ = cheerio.load(data); 60 | const chapters: IChapterItem[] = []; 61 | $('.chapter-list > ul >li').each((i, item) => { 62 | const dom = $(item) 63 | .find('a') 64 | .eq(0); 65 | const link = urlModule.resolve(baseUrl, dom.attr('href')); 66 | const page = dom 67 | .find('i') 68 | .eq(0) 69 | .text(); 70 | chapters.push({ 71 | url: link, 72 | title: dom.attr('title'), 73 | page_size: toNum(page), 74 | }); 75 | }); 76 | return chapters; 77 | }; 78 | 79 | function getDownloadItem(data: string): string { 80 | const $ = cheerio.load(data); 81 | return $('#mangaFile').attr('src'); 82 | } 83 | 84 | const getSearchUrl = (name: string): string => 85 | `${baseUrl}/s/${encodeURIComponent(name)}.html`; 86 | const getDownloadUrl = (name: string, page: number): string => { 87 | const url = name; 88 | if (page === 1) { 89 | return url; 90 | } 91 | return `${url}#p=${page}`; 92 | }; 93 | export default { 94 | getChapterList, 95 | getSearchList, 96 | getSearchUrl, 97 | getDownloadUrl, 98 | getDownloadItem, 99 | }; 100 | -------------------------------------------------------------------------------- /server/routes/menu/index.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from 'koa'; 2 | import urlConfig from '../../shared/urlConfig'; 3 | 4 | const Menu = async (ctx: Koa.BaseContext) => { 5 | ctx.state.data = urlConfig; 6 | }; 7 | export default Menu; 8 | -------------------------------------------------------------------------------- /server/routes/qq/index.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from 'koa'; 2 | import { Browser, Page } from 'puppeteer'; 3 | import util from './utils'; 4 | import axios from '../../utils/axios'; 5 | import { apiType } from '../../shared'; 6 | import { IRequestData } from '../../type'; 7 | import puppeteer, { getHtml, scrollToBottom } from '../../utils/puppeteer'; 8 | import sleep from '../../utils/wait'; 9 | 10 | const WAIT_TIME = 1000; 11 | let temp: object; 12 | const qq = async (ctx: Koa.BaseContext) => { 13 | const { type, name }: IRequestData = ctx.request.body; 14 | if (apiType.search === type) { 15 | const response = await axios.get(util.getSearchUrl(name)); 16 | temp = util.getSearchList(response.data); 17 | } 18 | if (apiType.chapter === type) { 19 | const response = await axios.get(name); 20 | temp = util.getChapterList(response.data); 21 | } 22 | if (apiType.download === type) { 23 | // TODO 没有爬取到一话的所有漫画图片 24 | const browser: Browser = await puppeteer(); 25 | const page: Page = await browser.newPage(); 26 | page.setViewport({ 27 | width: 1366, 28 | height: 768, 29 | }); 30 | await page.goto(name, { 31 | waitUntil: 'networkidle0', 32 | timeout: 0, 33 | }); 34 | await page.waitFor(WAIT_TIME); 35 | await page.evaluate(scrollToBottom); 36 | await sleep(WAIT_TIME * 5); 37 | const html = await page.evaluate(getHtml); 38 | temp = util.getDownloadList(html); 39 | await browser.close(); 40 | } 41 | ctx.state.data = temp; 42 | }; 43 | export default qq; 44 | -------------------------------------------------------------------------------- /server/routes/qq/utils.ts: -------------------------------------------------------------------------------- 1 | import cheerio from 'cheerio'; 2 | import _ from 'lodash'; 3 | import { IChapterItem, IImageItem, ISearchItem } from '../../type'; 4 | import urlConfig from '../../shared/urlConfig'; 5 | 6 | const baseUrl: string = urlConfig.qq.base; 7 | function getSearchList (data: string): ISearchItem[] { 8 | const $ = cheerio.load(data); 9 | const result: ISearchItem[] = []; 10 | const list = $('ul.mod_book_list.mod_all_works_list > li'); 11 | list.each((i, item) => { 12 | const dom = $(item) 13 | .find('a') 14 | .eq(0); 15 | const title: string = dom.attr('title'); 16 | const url: string = baseUrl + dom.attr('href'); 17 | const cover: string = $(item) 18 | .find('img') 19 | .eq(0) 20 | .attr('data-original'); 21 | result.push({ 22 | url, 23 | title, 24 | cover: cover.endsWith('blank.gif') ? '' : cover, 25 | }); 26 | }); 27 | return result; 28 | } 29 | 30 | function getChapterList(data: string): IChapterItem[] { 31 | const $ = cheerio.load(data); 32 | const chapters: IChapterItem[] = []; 33 | $('ol.works-chapter-list span.works-chapter-item').each((i, item) => { 34 | const dom = $(item) 35 | .find('a') 36 | .eq(0); 37 | const url: string = baseUrl + dom.attr('href'); 38 | const title: string = _.trim(dom.text()); 39 | chapters.push({ 40 | url, 41 | title, 42 | }); 43 | }); 44 | return chapters; 45 | } 46 | 47 | function getDownloadList(data: string): IImageItem[] { 48 | const result: IImageItem[] = []; 49 | const $ = cheerio.load(data); 50 | let page = 1; 51 | $('#comicContain > li').each((i, item) => { 52 | const dom = $(item) 53 | .children('img') 54 | .first(); 55 | const url: string = dom.attr('src'); 56 | if (!url.endsWith('pixel.gif')) { 57 | result.push({ 58 | url, 59 | page, 60 | }); 61 | page += 1; 62 | } 63 | }); 64 | return result; 65 | } 66 | 67 | function getSearchUrl(name: string): string { 68 | return `${baseUrl}/Comic/searchList?search=${encodeURIComponent(name)}`; 69 | } 70 | 71 | export default { 72 | getChapterList, 73 | getSearchList, 74 | getSearchUrl, 75 | getDownloadList, 76 | }; 77 | -------------------------------------------------------------------------------- /server/routes/sql/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import * as Koa from 'koa'; 3 | import { apiType } from '../../shared'; 4 | import mysqlService from '../../service'; 5 | import _ from 'lodash'; 6 | import { IChapterMysql, IRequestData, ISearchMysql } from '../../type'; 7 | import generateBook from '../../utils/generateBook'; 8 | import { getLanguageData } from '../../locales'; 9 | import statusCodes from '../../shared/statusCode'; 10 | let temp: any; 11 | const sqlCache = async (ctx: Koa.BaseContext) => { 12 | const { 13 | name: requestName, 14 | type: requestType, 15 | }: IRequestData = ctx.request.body; 16 | if (requestType === apiType.search) { 17 | const result: any = await mysqlService.foggySearch( 18 | `%${requestName}%`, 19 | requestType 20 | ); 21 | if (!_.isEmpty(result)) { 22 | ctx.response.set({ 23 | 'Mysql-Search-Table-Cache': 'true', 24 | }); 25 | temp = result; 26 | } 27 | } 28 | if (requestType === apiType.chapter) { 29 | const searchItem: ISearchMysql = await mysqlService.searchOne( 30 | requestName, 31 | apiType.search 32 | ); 33 | const results: any = await mysqlService.searchItem( 34 | _.get(searchItem, 'id', ''), 35 | requestType, 36 | 'search_id' 37 | ); 38 | if (!_.isEmpty(results)) { 39 | temp = results; 40 | ctx.response.set({ 41 | 'Mysql-Chapter-Table-Cache': 'true', 42 | }); 43 | } 44 | } 45 | if (requestType === apiType.download) { 46 | const chapterItem: IChapterMysql = await mysqlService.searchOne( 47 | requestName, 48 | apiType.chapter 49 | ); 50 | const results: any = await mysqlService.searchItem( 51 | _.get(chapterItem, 'id', ''), 52 | requestType, 53 | 'chapter_id' 54 | ); 55 | if (!_.isEmpty(results)) { 56 | const searchItem: ISearchMysql = await mysqlService.searchOne( 57 | _.get(chapterItem, 'search_id', ''), 58 | apiType.search, 59 | 'id' 60 | ); 61 | const bookPath: string = await generateBook( 62 | results, 63 | searchItem, 64 | chapterItem, 65 | requestName 66 | ); 67 | 68 | ctx.response.set({ 69 | 'Mysql-Download-Table-Cache': 'true', 70 | }); 71 | temp = { 72 | message: getLanguageData('middleware.dataProcess.success'), 73 | code: statusCodes.OK, 74 | data: bookPath, 75 | }; 76 | } 77 | } 78 | ctx.state.data = temp; 79 | }; 80 | export default sqlCache; 81 | -------------------------------------------------------------------------------- /server/routes/test/index.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from 'koa'; 2 | 3 | interface TestItem { 4 | title: string; 5 | description: string; 6 | pubDate: string; 7 | } 8 | 9 | const Test = async (ctx: Koa.BaseContext) => { 10 | if (ctx.params.id === '0') { 11 | throw Error('Error test'); 12 | } 13 | const item: TestItem[] = []; 14 | 15 | for (let i = 1; i < 6; i += 1) { 16 | item.push({ 17 | title: `Title${i}`, 18 | description: `Description${i}`, 19 | pubDate: new Date().toUTCString(), 20 | }); 21 | } 22 | ctx.state.data = { 23 | title: `Test ${ctx.params.id}`, 24 | item, 25 | }; 26 | }; 27 | export default Test; 28 | -------------------------------------------------------------------------------- /server/routes/tohomh123/index.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from 'koa'; 2 | import util from './utils'; 3 | import axios from '../../utils/axios'; 4 | import { apiType } from '../../shared'; 5 | import { IRequestData } from '../../type'; 6 | let temp: object; 7 | const tuHao = async (ctx: Koa.BaseContext) => { 8 | const { type, name, page_size: pageSize }: IRequestData = ctx.request.body; 9 | 10 | if (apiType.search === type) { 11 | const response = await axios.get(util.getSearchUrl(name)); 12 | temp = util.getSearchList(response.data); 13 | } 14 | if (apiType.chapter === type) { 15 | const response = await axios.get(name); 16 | temp = util.getChapterList(response.data); 17 | } 18 | if (apiType.download === type) { 19 | // TODO 下载出现问题 20 | const response = await axios.get(name); 21 | temp = util.getDownloadItem(response.data, pageSize); 22 | } 23 | ctx.state.data = temp; 24 | }; 25 | export default tuHao; 26 | -------------------------------------------------------------------------------- /server/routes/tohomh123/utils.ts: -------------------------------------------------------------------------------- 1 | import cheerio from 'cheerio'; 2 | import urlModule from 'url'; 3 | import _ from 'lodash'; 4 | import { IChapterItem, IImageItem, ISearchItem } from '../../type'; 5 | import urlConfig from '../../shared/urlConfig'; 6 | import { numToString } from '../../utils/parseUrl'; 7 | import toNum from '../../utils/toNum'; 8 | 9 | const baseUrl = urlConfig.tohomh123.base; 10 | 11 | const getCoverUrl = (style: string): string => { 12 | const temp: string = _.head(style.match(/(\([\s\S]*\))/)) || ''; 13 | return temp.slice(1, -1); 14 | }; 15 | 16 | function getSearchList(data: string): ISearchItem[] { 17 | const $ = cheerio.load(data); 18 | const result: ISearchItem[] = []; 19 | const list = $('ul.mh-list > li'); 20 | list.each((i, item) => { 21 | const dom = $(item) 22 | .find('h2.title>a') 23 | .eq(0); 24 | const title: string = dom.text(); 25 | const url: string = urlModule.resolve(baseUrl, dom.attr('href')); 26 | const cover: string = $(item) 27 | .find('.mh-cover') 28 | .eq(0) 29 | .attr('style'); 30 | const realCover: string = getCoverUrl(cover); 31 | result.push({ 32 | url, 33 | title, 34 | cover: realCover, 35 | }); 36 | }); 37 | return result; 38 | } 39 | 40 | function getChapterList(data: string): IChapterItem[] { 41 | const $ = cheerio.load(data); 42 | const chapters: IChapterItem[] = []; 43 | $('#chapterlistload li').each((i, item) => { 44 | const dom = $(item) 45 | .find('a') 46 | .eq(0); 47 | const link: string = urlModule.resolve(baseUrl, dom.attr('href')); 48 | const pageString: string = dom 49 | .find('span') 50 | .eq(0) 51 | .text(); 52 | const title: string = dom.text(); 53 | const realTitle: string = title.slice(0, title.length - pageString.length); 54 | const currentPage = toNum(_.head(pageString.match(/(\d+)/gi))); 55 | if (link) { 56 | chapters.push({ 57 | url: link, 58 | title: realTitle, 59 | page_size: currentPage, 60 | }); 61 | } 62 | }); 63 | return chapters; 64 | } 65 | 66 | function getDownloadItem(data: string, pageSize: number): IImageItem[] { 67 | const linkResult: string[] = (data.match(/var pl = '([\s\S]*)';\s*var bqimg/)) as string[]; 68 | const [, link] = linkResult; 69 | if (!link) { 70 | return []; 71 | } 72 | const result: IImageItem[] = []; 73 | const fileName: string = _.last(link.split('/')) || ''; 74 | const extName: string = _.last(fileName.split('.')) || ''; 75 | const tempUrl: string = link.slice(0, link.length - fileName.length); 76 | for (let i = 0; i < pageSize; i += 1) { 77 | result.push({ 78 | page: i + 1, 79 | url: `${tempUrl}${numToString(i)}.${extName}`, 80 | }); 81 | } 82 | return result; 83 | } 84 | 85 | function getSearchUrl(name: string): string { 86 | return `${baseUrl}/action/Search?keyword=${encodeURIComponent(name)}`; 87 | } 88 | 89 | export default { 90 | getChapterList, 91 | getSearchList, 92 | getSearchUrl, 93 | getDownloadItem, 94 | }; 95 | -------------------------------------------------------------------------------- /server/routes/u17/index.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from 'koa'; 2 | import { Browser, ElementHandle, Page } from 'puppeteer'; 3 | import util from './utils'; 4 | import axios from '../../utils/axios'; 5 | import { apiType } from '../../shared'; 6 | import { IRequestData } from '../../type'; 7 | import puppeteer, { getHtml, scrollToBottom } from '../../utils/puppeteer'; 8 | 9 | const WAIT_TIME = 1000; 10 | let temp: object; 11 | const tuHao = async (ctx: Koa.BaseContext) => { 12 | const { type, name }: IRequestData = ctx.request.body; 13 | 14 | if (apiType.search === type) { 15 | const response = await axios.get(util.getSearchUrl(name)); 16 | temp = util.getSearchList(response.data); 17 | } 18 | if (apiType.chapter === type) { 19 | const response = await axios.get(name); 20 | temp = util.getChapterList(response.data); 21 | } 22 | if (apiType.download === type) { 23 | const browser: Browser = await puppeteer(); 24 | const page: Page = await browser.newPage(); 25 | page.setViewport({ 26 | width: 1366, 27 | height: 768, 28 | }); 29 | await page.goto(name, { 30 | waitUntil: 'networkidle0', 31 | timeout: 0, 32 | }); 33 | const nextItem = (await page.$('#cr_top > div > div.right > a:nth-child(4)')) as ElementHandle; 34 | nextItem.click(); 35 | 36 | await page.waitFor(WAIT_TIME); 37 | 38 | await page.evaluate(scrollToBottom); 39 | 40 | await page.waitFor(WAIT_TIME * 2); 41 | const html = await page.evaluate(getHtml); 42 | 43 | temp = util.getDownloadList(html); 44 | await browser.close(); 45 | } 46 | ctx.state.data = temp; 47 | }; 48 | export default tuHao; 49 | -------------------------------------------------------------------------------- /server/routes/u17/utils.ts: -------------------------------------------------------------------------------- 1 | import cheerio from 'cheerio'; 2 | import _ from 'lodash'; 3 | import { IChapterItem, IImageItem, ISearchItem } from '../../type'; 4 | import toNum from '../../utils/toNum'; 5 | 6 | function getSearchList(data: string): ISearchItem[]{ 7 | const $ = cheerio.load(data); 8 | const result: ISearchItem[] = []; 9 | const list = $('#comiclist > div > div.comiclist > ul > li'); 10 | list.each((i, item) => { 11 | const dom = $(item) 12 | .find('div.info > h3 > strong > a') 13 | .eq(0); 14 | const title: string = dom.attr('title'); 15 | const url: string = dom.attr('href'); 16 | const cover: string = $(item) 17 | .find('div.cover > a > img') 18 | .eq(0) 19 | .attr('src'); 20 | 21 | const author: string = $(item) 22 | .find('div.info > h3 > a') 23 | .eq(0) 24 | .attr('title'); 25 | const introduce: string = $(item) 26 | .find('div.info > p.text') 27 | .eq(0) 28 | .text(); 29 | const category: string = $(item) 30 | .find('div.info > p.cf > i') 31 | .eq(0) 32 | .text() 33 | .replace(/\s/gi, ''); 34 | result.push({ 35 | url, 36 | title, 37 | cover, 38 | author, 39 | introduce: introduce.slice(3), 40 | category, 41 | }); 42 | }); 43 | return result; 44 | } 45 | 46 | const getChapterList = (data: string): IChapterItem[] => { 47 | const $ = cheerio.load(data); 48 | const chapters: IChapterItem[] = []; 49 | $('#chapter>li').each((i, item) => { 50 | const dom = $(item) 51 | .find('a') 52 | .eq(0); 53 | const link: string = dom.attr('href'); 54 | const title: string = dom.attr('title'); 55 | const innerText: string = $(item).text(); 56 | const pageString: string = innerText.slice(dom.text().length); 57 | const currentPage = toNum(_.head(pageString.match(/(\d+)/gi))); 58 | const titleLen: number = title.length; 59 | chapters.push({ 60 | url: link, 61 | title: title.slice(0, titleLen > 11 ? titleLen - 11 : titleLen).trim(), 62 | page_size: currentPage, 63 | }); 64 | }); 65 | return chapters; 66 | }; 67 | 68 | const getDownloadList = (data: string): IImageItem[] => { 69 | const result: IImageItem[] = []; 70 | const $ = cheerio.load(data); 71 | let page = 1; 72 | $('#readvip > .mg_auto').each((i, item) => { 73 | const dom = $(item) 74 | .find('img.cur_pic.lazyload') 75 | .eq(0); 76 | const url: string = dom.attr('src'); 77 | result.push({ 78 | url, 79 | page, 80 | }); 81 | page += 1; 82 | }); 83 | return result; 84 | }; 85 | 86 | function getSearchUrl(name: string): string { 87 | return `http://so.u17.com/all/${encodeURIComponent(name)}/m0_p1.html`; 88 | } 89 | 90 | export default { 91 | getChapterList, 92 | getSearchList, 93 | getSearchUrl, 94 | getDownloadList, 95 | }; 96 | -------------------------------------------------------------------------------- /server/service/index.ts: -------------------------------------------------------------------------------- 1 | import mysql from '../sql/mysql'; 2 | import _ from 'lodash'; 3 | 4 | function getAllData(tableName: string) { 5 | return new Promise(resolve => { 6 | const sql = `SELECT * FROM ${tableName}`; 7 | mysql(sql, null, (results: JsObject[] = []) => { 8 | resolve(results); 9 | }); 10 | }); 11 | } 12 | 13 | async function searchItem( 14 | value: string | number, 15 | tableName: string, 16 | field = 'url', 17 | ) { 18 | return new Promise(resolve => { 19 | const sql = `SELECT * FROM ${tableName} WHERE ${field}=?`; 20 | mysql(sql, [value], (results: JsObject[] = []): void => { 21 | resolve(results); 22 | }); 23 | }); 24 | } 25 | 26 | function searchOne ( 27 | value: string | number, 28 | tableName: string, 29 | field = 'url', 30 | ): Promise { 31 | return new Promise(resolve => { 32 | const sql = `SELECT * FROM ${tableName} WHERE ${field}=?`; 33 | mysql(sql, [value], (results: T[]) => { 34 | resolve(results[0]); 35 | }); 36 | }); 37 | } 38 | 39 | function addItem(data: JsObject, tableName: string) { 40 | return new Promise(resolve => { 41 | // 判断是否存在 42 | searchItem(data.url, tableName).then((results) => { 43 | if (!_.isEmpty(results)) { 44 | resolve(false); 45 | return; 46 | } 47 | const sql = `INSERT INTO ${tableName} SET ?`; 48 | const realData = { 49 | ...data, 50 | create_time: Number(new Date()), 51 | }; 52 | mysql(sql, realData, (result: JsObject = {}) => { 53 | resolve(result.insertId > 0); 54 | }); 55 | }); 56 | }); 57 | } 58 | 59 | function deleteItem(id: number, tableName: string) { 60 | return new Promise(resolve => { 61 | const sql = `DELETE FROM ${tableName} WHERE id=?`; 62 | mysql(sql, [id], (result: JsObject = {}) => { 63 | resolve(result.affectedRows === 1); 64 | }); 65 | }); 66 | } 67 | 68 | function editItem(data: JsObject, tableName: string) { 69 | return new Promise(resolve => { 70 | const sql = `UPDATE ${tableName} SET title=?,url=?,desc=? WHERE id=?`; 71 | const sqlData = [data.title, data.url, data.desc, data.id]; 72 | mysql(sql, sqlData, (result: JsObject = {}) => { 73 | resolve(result.affectedRows === 1); 74 | }); 75 | }); 76 | } 77 | 78 | function foggySearch( 79 | value: string | number, 80 | tableName: string, 81 | field = 'title', 82 | ) { 83 | return new Promise(resolve => { 84 | const sql = `SELECT * FROM ${tableName} WHERE ${field} LIKE ?`; 85 | mysql(sql, [value], (results: JsObject[] = []) => { 86 | resolve(results); 87 | }); 88 | }); 89 | } 90 | 91 | export default { 92 | getAllData, 93 | searchItem, 94 | addItem, 95 | deleteItem, 96 | editItem, 97 | foggySearch, 98 | searchOne, 99 | }; 100 | -------------------------------------------------------------------------------- /server/shared/__tests__/urlConfig.test.ts: -------------------------------------------------------------------------------- 1 | import { Browser, Page } from 'puppeteer'; 2 | import Puppeteer from '../../utils/puppeteer'; 3 | import urlConfig from '../urlConfig'; 4 | import superTest from 'supertest'; 5 | import koaServer from '../../index'; 6 | import config from '../../shared/urlConfig'; 7 | 8 | const { server } = koaServer; 9 | const request = superTest(server); 10 | 11 | describe('test routes', () => { 12 | afterAll(() => { 13 | server.close(); 14 | }); 15 | const testPage = (path: string): (() => void) => async () => { 16 | const response: superTest.Response = await request.post(`/${path}`); 17 | expect(response.text).toBe(''); 18 | }; 19 | Object.keys(config).forEach((key: string) => { 20 | it(`post /${key} should return ''`, testPage(key), 1000000); 21 | }); 22 | }); 23 | 24 | describe('Test Base Url', () => { 25 | let page: Page; 26 | let browser: Browser; 27 | beforeAll(async () => { 28 | browser = await Puppeteer(); 29 | page = await browser.newPage(); 30 | }); 31 | 32 | afterAll(async () => { 33 | await browser.close(); 34 | }); 35 | 36 | const testPage = (path: string): (() => void) => async () => { 37 | await page.goto(path, { 38 | waitUntil: 'networkidle0', 39 | }); 40 | await page.waitFor(1000); 41 | const list: string[] = await page.evaluate(() => { 42 | const arr: HTMLImageElement[] = Array.prototype.slice.apply( 43 | document.querySelectorAll('img') 44 | ); 45 | return arr.map((v: HTMLImageElement) => v.src); 46 | }); 47 | expect(list.length).toBeGreaterThan(0); 48 | }; 49 | Object.values(urlConfig).forEach(item => { 50 | it( 51 | `base url ${item.base} should include many images`, 52 | testPage(item.base), 53 | 1000000 54 | ); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /server/shared/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import dotEnv from 'dotenv'; 3 | import fs from 'fs'; 4 | import logger from '../utils/logger'; 5 | import toNum from '../utils/toNum'; 6 | import { SharedConfig } from './type'; 7 | import { getLanguageData } from '../locales'; 8 | 9 | const isTestEnv: boolean = process.env.NODE_ENV === 'test'; 10 | const basePath: string = isTestEnv ? './' : '../'; 11 | const envPath: string = path.join(process.cwd(), `${basePath}.env`); 12 | let envConfig: JsObject; 13 | try { 14 | envConfig = dotEnv.parse(fs.readFileSync(envPath)); 15 | } catch (error) { 16 | envConfig = {}; 17 | } 18 | 19 | const downloadBase: string = envConfig.DOWNLOAD_IMAGE_BASE || 20 | path.join(process.cwd(), `${basePath}downloadResult`); 21 | 22 | if (!fs.existsSync(downloadBase)) { 23 | logger.error(`${downloadBase || ''} ${getLanguageData('shared.downloadBase.notExist')}`); 24 | } 25 | 26 | const bookConfig = { 27 | author: 'Steve Xu', 28 | imageWidth: 520, 29 | imageHeight: 700, 30 | paddingTop: 50, 31 | paddingLeft: 50, 32 | }; 33 | const apiType = { 34 | search: 'search', 35 | chapter: 'chapter', 36 | download: 'images', 37 | downloadAll: 'downloadAll', 38 | }; 39 | const DESKTOP_WINDOW_SIZE = { 40 | width: 1366, 41 | height: 768, 42 | }; 43 | const pdfSupportImage: string[] = ['.jpeg', '.png']; // Pdfkit 只支持 png jpeg 44 | const sharpConvertType: string[] = ['.jpeg', '.jpg', '.png', '.webp', '.tiff', '.gif', '.svg'];// sharp input type 45 | export { bookConfig, pdfSupportImage, apiType, sharpConvertType, DESKTOP_WINDOW_SIZE }; 46 | const sharedConfig: SharedConfig = { 47 | language: envConfig.DEFAULT_LANGUAGE, 48 | serverPort: toNum(envConfig.SERVER_PORT) || 1200, // 监听端口, 49 | userAgent: 50 | envConfig.USER_AGENT || 51 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.131 Safari/537.36', 52 | requestRetry: toNum(envConfig.REQUEST_RETRY) || 2, // 请求失败重试次数 53 | // 是否显示 Debug 信息,取值 boolean 'false' 'key' ,取值为 'false' false 时永远不显示,取值为 'key' 时带上 ?debug=key 显示 54 | debugInfo: envConfig.DEBUG_INFO || true, 55 | blacklist: 56 | envConfig.SERVER_BLACKLIST && envConfig.SERVER_BLACKLIST.split(','), 57 | whitelist: 58 | envConfig.SERVER_WHITELIST && envConfig.SERVER_WHITELIST.split(','), 59 | downloadBase, // 下载根目录 60 | mysql: { 61 | host: envConfig.MYSQL_HOST || 'localhost', // 数据库服务器所在的IP或域名 62 | port: toNum(envConfig.MYSQL_PORT) || 3306, 63 | user: envConfig.MYSQL_USERNAME || 'root', // 用户名 64 | password: envConfig.MYSQL_PASSWORD || 'admin123456', // 密码 65 | database: envConfig.MYSQL_DATABASE || 'comic', // 数据库名 66 | }, 67 | }; 68 | export default sharedConfig; 69 | -------------------------------------------------------------------------------- /server/shared/statusCode.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * HTTP Status codes 3 | */ 4 | interface StatusCode { 5 | [key: string]: number; 6 | } 7 | const statusCode: StatusCode = { 8 | CONTINUE: 100, 9 | OK: 200, 10 | CREATED: 201, 11 | ACCEPTED: 202, 12 | NO_CONTENT: 204, 13 | BAD_REQUEST: 400, 14 | UNAUTHORIZED: 401, 15 | FORBIDDEN: 403, 16 | NOT_FOUND: 404, 17 | UNPROCESSABLE_ENTITY: 422, 18 | INTERNAL_SERVER_ERROR: 500, 19 | BAD_GATEWAY: 502, 20 | SERVICE_UNAVAILABLE: 503, 21 | GATEWAY_TIME_OUT: 504, 22 | 23 | NOT_IMPLEMENTED: 501, 24 | REQUEST_TIMEOUT: 408, 25 | }; 26 | export default statusCode; 27 | -------------------------------------------------------------------------------- /server/shared/type.ts: -------------------------------------------------------------------------------- 1 | export interface UrlConfigItem { 2 | readonly base: string; 3 | readonly name: string; 4 | readonly enabled: boolean; 5 | } 6 | 7 | export interface MysqlConfig { 8 | readonly host: string; 9 | readonly port: number; 10 | readonly user: string; 11 | readonly password: string; 12 | readonly database: string; 13 | } 14 | 15 | export interface SharedConfig { 16 | language: string; 17 | readonly serverPort: number; 18 | readonly userAgent: number; 19 | readonly requestRetry: number; 20 | readonly debugInfo: boolean; 21 | readonly blacklist: string[]; 22 | readonly whitelist: string[]; 23 | readonly downloadBase: string; 24 | readonly mysql: MysqlConfig; 25 | } 26 | -------------------------------------------------------------------------------- /server/shared/urlConfig.ts: -------------------------------------------------------------------------------- 1 | // Enabled 为 true 表示功能完成 2 | import { UrlConfigItem } from './type'; 3 | 4 | interface ConfigType { 5 | [key: string]: UrlConfigItem; 6 | } 7 | const configData: ConfigType = { 8 | manhuagui: { 9 | base: 'https://www.manhuagui.com', 10 | enabled: true, 11 | name: '看漫画', 12 | }, 13 | tohomh123: { 14 | base: 'https://www.tohomh123.com', 15 | enabled: true, 16 | name: '土豪漫画', 17 | }, 18 | u17: { 19 | base: 'http://www.u17.com', 20 | enabled: true, 21 | name: '有妖气漫画', 22 | }, 23 | qq: { 24 | base: 'https://ac.qq.com', 25 | name: '腾讯动漫', 26 | enabled: false, 27 | }, 28 | kuaikanmanhua: { 29 | base: 'https://www.kuaikanmanhua.com', 30 | name: '快看动漫', 31 | enabled: true, 32 | }, 33 | }; 34 | export default configData; 35 | -------------------------------------------------------------------------------- /server/sql/mysql.ts: -------------------------------------------------------------------------------- 1 | import mysql from 'mysql'; 2 | import config from '../shared'; 3 | 4 | export default function database(sql: string, data: object | null, callback: Function): void { 5 | const connection = mysql.createConnection(config.mysql); 6 | connection.connect(); 7 | connection.query(sql, data, (error, results) => { 8 | if (error) { 9 | throw error; 10 | } 11 | callback(results); 12 | }); 13 | connection.end(); 14 | } 15 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "esModuleInterop": true, 5 | "target": "ESNext", 6 | "noImplicitAny": true, 7 | "strict": true, 8 | "alwaysStrict": true, 9 | "moduleResolution": "node", 10 | "sourceMap": true, 11 | "outDir": "dist", 12 | "baseUrl": "." 13 | }, 14 | "include": [ 15 | "routes/**/*", 16 | "index.ts", 17 | "middleware/*", 18 | "sql/*", 19 | "router/*", 20 | "service/*", 21 | "shared/*", 22 | "utils/*", 23 | "type/*", 24 | "../global.d.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /server/type/index.ts: -------------------------------------------------------------------------------- 1 | interface ICommon { 2 | id?: number; // 主键 3 | create_time?: number; // 创建时间 4 | } 5 | 6 | export interface ISearchItem { 7 | url: string; // 链接 8 | title: string; // 漫画名 9 | area?: string; // 地区 10 | author?: string; // 作者 11 | introduce?: string; // 简介 12 | category?: string; // 分类 13 | cover?: string; // 漫画封面 14 | } 15 | 16 | export interface IChapterItem { 17 | url: string; // 链接 18 | title: string; // 章节名 19 | page_size?: number; // 章节图片数量 20 | } 21 | 22 | export interface IImageItem { 23 | url: string; // 链接 24 | page: number; // 章节中第几张图片 25 | } 26 | 27 | export interface ISearchMysql extends ISearchItem, ICommon {} 28 | 29 | export interface IChapterMysql extends IChapterItem, ICommon { 30 | search_id?: number; // Search 表 ID 31 | } 32 | 33 | export interface IImageMysql extends IImageItem, ICommon { 34 | chapter_id?: number; // Chapter 表 ID 35 | } 36 | 37 | export interface IRequestData { 38 | name: string; // 请求值 39 | type: string; // 请求类型 40 | page_size: number; // 章节图片数量 41 | } 42 | 43 | -------------------------------------------------------------------------------- /server/utils/__tests__/axios.test.ts: -------------------------------------------------------------------------------- 1 | import MockAdapter from 'axios-mock-adapter'; 2 | import axiosTest from '../axios'; 3 | import config from '../../shared'; 4 | import statusCodes from '../../shared/statusCode'; 5 | 6 | const mock = new MockAdapter(axiosTest); 7 | const SUCCESS_CODE = 0; 8 | describe('axios', () => { 9 | it('axios headers', async () => { 10 | mock.onGet('/test').reply(axiosConfig => { 11 | expect(axiosConfig.headers['User-Agent']).toBe(config.userAgent); 12 | expect(axiosConfig.headers['X-APP']).toBe('ComicHub'); 13 | return [ 14 | statusCodes.OK, 15 | { 16 | code: SUCCESS_CODE, 17 | }, 18 | ]; 19 | }); 20 | 21 | const response = await axiosTest.get('/test'); 22 | expect(response.status).toBe(statusCodes.OK); 23 | expect(response.data.code).toBe(SUCCESS_CODE); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /server/utils/__tests__/convertImage.test.ts: -------------------------------------------------------------------------------- 1 | import { getJpegPath } from '../convertImage'; 2 | import { pdfSupportImage } from '../../shared'; 3 | 4 | describe('getJpegPath', () => { 5 | it('getJpegPath should return right result', () => { 6 | expect(getJpegPath('test.webp')).toBe(`test${pdfSupportImage[0]}`); 7 | expect(getJpegPath('test/test.jpg')).toBe(`test/test${pdfSupportImage[0]}`); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /server/utils/__tests__/downloadImage.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import downloadImage, { getExtName, checkSharpExtName, checkExtName } from '../downloadImage'; 3 | 4 | describe('getExtName', () => { 5 | it('getExtName should return right result', () => { 6 | expect(getExtName('nusr.com.webp')).toBe('.webp'); 7 | expect(getExtName('test.png')).toBe('.png'); 8 | expect(getExtName('test/test.jpeg')).toBe('.jpeg'); 9 | expect(getExtName('test/test/test/test.jpeg')).toBe('.jpeg'); 10 | expect(getExtName('test/test/test/test.jpeg/3434/434')).toBe('.jpeg'); 11 | }); 12 | it('getExtName should return empty', () => { 13 | expect(getExtName('')).toBe(''); 14 | expect(getExtName('test')).toBe(''); 15 | expect(getExtName('test/test/test/test.')).toBe(''); 16 | }); 17 | }); 18 | 19 | 20 | describe('checkSharpExtName', () => { 21 | it('checkSharpExtName should return right result', () => { 22 | expect(checkSharpExtName('.jpeg')).toBeTruthy(); 23 | expect(checkSharpExtName('.jpg')).toBeTruthy(); 24 | expect(checkSharpExtName('.png')).toBeTruthy(); 25 | expect(checkSharpExtName('.webp')).toBeTruthy(); 26 | expect(checkSharpExtName('.tiff')).toBeTruthy(); 27 | expect(checkSharpExtName('.gif')).toBeTruthy(); 28 | expect(checkSharpExtName('.svg')).toBeTruthy(); 29 | }); 30 | 31 | }); 32 | 33 | 34 | describe('checkExtName', () => { 35 | it('checkExtName should return right result', () => { 36 | expect(checkExtName('test.jpeg')).toBeTruthy(); 37 | expect(checkExtName('/fefeaa/test.jpeg')).toBeTruthy(); 38 | expect(checkExtName('test.png')).toBeTruthy(); 39 | expect(checkExtName('fefe/test.png')).toBeTruthy(); 40 | }); 41 | 42 | }); 43 | describe('downloadImage', () => { 44 | it('downloadImage should download success', async () => { 45 | const filePath: string = await downloadImage('https://jestjs.io/img/jest.svg', 'jest', 'https://jestjs.io'); 46 | expect(fs.existsSync(filePath)).toBeTruthy(); 47 | }, 200000); 48 | 49 | }); 50 | 51 | // https://jestjs.io/img/jest.svg 52 | -------------------------------------------------------------------------------- /server/utils/__tests__/generatePdf.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import generatePdf from '../generatePdf'; 4 | 5 | describe('generatePdf', () => { 6 | it('generatePdf should create pdf success', () => { 7 | const testPath: string = path.join(process.cwd(), './server/routes'); 8 | generatePdf(testPath); 9 | expect(fs.existsSync(`${testPath}.pdf`)).toBeTruthy(); 10 | }); 11 | 12 | it('generatePdf should create pdf fail', () => { 13 | const testPath = 'test'; 14 | generatePdf(testPath); 15 | expect(fs.existsSync(`${testPath}.pdf`)).toBeFalsy() 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /server/utils/__tests__/makeDir.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import makeDir from '../makeDir'; 3 | 4 | describe('makeDir', () => { 5 | it('makeDir should make directory success', () => { 6 | expect(makeDir('')).toBeFalsy() 7 | expect(fs.existsSync('')).toBeFalsy() 8 | 9 | expect(makeDir('type')).toBeTruthy() 10 | expect(fs.existsSync('type')).toBeTruthy(); 11 | 12 | makeDir('tmp/makeDir'); 13 | expect(fs.existsSync('tmp/makeDir')).toBeTruthy(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /server/utils/__tests__/md5.test.ts: -------------------------------------------------------------------------------- 1 | import md5 from '../md5'; 2 | 3 | describe('md5', () => { 4 | it('md5 comic-downloader', () => { 5 | expect(md5('comic-downloader')).toBe('a1681e1524cb9a983ab4781ffa393036'); 6 | }); 7 | it('md5 empty content', () => { 8 | expect(md5('')).toBe('d41d8cd98f00b204e9800998ecf8427e'); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /server/utils/__tests__/parseUrl.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | filterIllegalPath, 3 | getComicSite, 4 | getReferer, 5 | numToString, 6 | } from '../parseUrl'; 7 | 8 | describe('getReferer', () => { 9 | it('getReferer should return right referer', () => { 10 | expect(getReferer('http://www.test.com')).toBe('http://www.test.com'); 11 | expect(getReferer('http://www.test.com/11')).toBe('http://www.test.com'); 12 | expect(getReferer('http://www.test.com/11/222')).toBe( 13 | 'http://www.test.com' 14 | ); 15 | expect(getReferer('https://www.test.com')).toBe('https://www.test.com'); 16 | expect(getReferer('https://www.test.com/11/222')).toBe( 17 | 'https://www.test.com' 18 | ); 19 | }); 20 | it('getReferer should return //', () => { 21 | expect(getReferer('test.com')).toBe('//'); 22 | expect(getReferer('test.com/11')).toBe('//'); 23 | expect(getReferer('11')).toBe('//'); 24 | expect(getReferer('aa')).toBe('//'); 25 | expect(getReferer('////')).toBe('//'); 26 | }); 27 | }); 28 | 29 | describe('filterIllegalPath', () => { 30 | it('filterIllegalPath should return right path', () => { 31 | expect(filterIllegalPath('http://www.test.com')).toBe('httpwwwtestcom'); 32 | expect(filterIllegalPath('http://www.test.com/测试 23')).toBe( 33 | 'httpwwwtestcom测试23' 34 | ); 35 | expect(filterIllegalPath('http://www.test.com/测试 /测试 23')).toBe( 36 | 'httpwwwtestcom测试测试23' 37 | ); 38 | expect(filterIllegalPath('http://www.test.com/测试 /(测试 )23')).toBe( 39 | 'httpwwwtestcom测试测试23' 40 | ); 41 | }); 42 | }); 43 | 44 | describe('numToString', () => { 45 | it('numToString should return right string', () => { 46 | expect(numToString(0)).toBe('0000'); 47 | expect(numToString(3)).toBe('0003'); 48 | expect(numToString(33)).toBe('0033'); 49 | expect(numToString(333)).toBe('0333'); 50 | expect(numToString(3333)).toBe('3333'); 51 | expect(numToString(33333)).toBe('33333'); 52 | expect(numToString(333333)).toBe('333333'); 53 | }); 54 | }); 55 | 56 | describe('getComicSite', () => { 57 | it('getComicSite should return right result', () => { 58 | expect(getComicSite('http://www.test.com')).toBe('test'); 59 | expect(getComicSite('http://www.666.com')).toBe('666'); 60 | expect(getComicSite('socks://www.55.net')).toBe('55'); 61 | expect(getComicSite('33.net')).toBe('33'); 62 | }); 63 | it('getComicSite should return ""', () => { 64 | expect(getComicSite('')).toBe(''); 65 | expect(getComicSite('com')).toBe(''); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /server/utils/__tests__/puppeteer.test.ts: -------------------------------------------------------------------------------- 1 | import Puppeteer from '../puppeteer'; 2 | 3 | describe('puppeteer', () => { 4 | it( 5 | 'puppeteer run success', 6 | async () => { 7 | const browser = await Puppeteer(); 8 | const page = await browser.newPage(); 9 | await page.goto('https://github.com/nusr/ComicHub', { 10 | waitUntil: 'domcontentloaded', 11 | }); 12 | const html = await page.evaluate(() => document.body.innerHTML); 13 | expect(html.length).toBeGreaterThan(0); 14 | 15 | await browser.close(); 16 | }, 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /server/utils/__tests__/toNum.test.ts: -------------------------------------------------------------------------------- 1 | import toNum from '../toNum'; 2 | 3 | describe('toNum', () => { 4 | it('toNum should return right result', async () => { 5 | expect(toNum('33.33')).toBe(33); 6 | expect(toNum('33')).toBe(33); 7 | expect(toNum('33ff')).toBe(33); 8 | expect(toNum(undefined)).toBe(0); 9 | expect(toNum('fefe')).toBe(0); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /server/utils/__tests__/wait.test.ts: -------------------------------------------------------------------------------- 1 | import wait from '../wait'; 2 | 3 | describe('wait', (): void => { 4 | it('wait 0.1 second', async () => { 5 | const startDate: number = new Date().getTime(); 6 | 7 | await wait(100); 8 | 9 | const endDate: number = new Date().getTime(); 10 | expect(endDate - startDate).toBeGreaterThan(80); 11 | expect(endDate - startDate).toBeLessThan(120); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /server/utils/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import axiosRetry from 'axios-retry'; 3 | import logger from './logger'; 4 | import config from '../shared'; 5 | 6 | axiosRetry(axios, { 7 | retries: config.requestRetry, 8 | retryDelay: (count: number, err: JsObject): number => { 9 | logger.error( 10 | `Request ${err.config.url} fail, retry attempt #${count}: ${err}`, 11 | ); 12 | return 100; 13 | }, 14 | }); 15 | 16 | axios.defaults.headers.common['User-Agent'] = config.userAgent; 17 | axios.defaults.headers.common['X-APP'] = 'ComicHub'; 18 | 19 | export default axios; 20 | -------------------------------------------------------------------------------- /server/utils/bookInfo.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import _ from 'lodash'; 4 | import { BookInfo } from './type'; 5 | import { pdfSupportImage } from '../shared'; 6 | 7 | function getBookInfo(dirName: string, extName = 'pdf'): BookInfo { 8 | const outputPath = `${dirName}.${extName}`; 9 | const files: string[] = fs.readdirSync(dirName); 10 | 11 | const bookTitle: string = (_.last(_.split(dirName, '/'))) as string; 12 | const filePathList: string[] = []; 13 | files.forEach((fileName: string): void => { 14 | const filePath = path.join(dirName, fileName); 15 | const temp = path.extname(filePath); 16 | if (pdfSupportImage.includes(temp)) { 17 | filePathList.push(filePath); 18 | } 19 | }); 20 | return { 21 | outputPath, 22 | filePathList, 23 | bookTitle, 24 | }; 25 | } 26 | 27 | export default getBookInfo; 28 | -------------------------------------------------------------------------------- /server/utils/convertImage.ts: -------------------------------------------------------------------------------- 1 | import sharp,{OutputInfo} from 'sharp'; 2 | import { pdfSupportImage } from '../shared'; 3 | 4 | export function getJpegPath(filePath: string): string { 5 | const temp: string[] = filePath.split('.'); 6 | temp.pop(); 7 | return temp.join('.') + pdfSupportImage[0]; 8 | } 9 | 10 | export default function convertImage(filePath: string): Promise { 11 | const jpegPath: string = getJpegPath(filePath); 12 | return sharp(filePath) 13 | .jpeg() 14 | .toFile(jpegPath); 15 | } 16 | -------------------------------------------------------------------------------- /server/utils/downloadImage.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import logger from './logger'; 4 | import makeDir from './makeDir'; 5 | import config, { pdfSupportImage, sharpConvertType } from '../shared'; 6 | import axios from './axios'; 7 | import { getComicSite } from './parseUrl'; 8 | 9 | export const checkSharpExtName = (extName: string): boolean => sharpConvertType.includes(extName); 10 | 11 | export function getExtName(realUrl: string): string { 12 | const [url] = realUrl.split('?'); 13 | let result = path.extname(url); 14 | if (checkSharpExtName(result)) { 15 | return result; 16 | } 17 | result = ''; 18 | const list: string[] = url.split('/'); 19 | for (let i = list.length - 1; i >= 0; i -= 1) { 20 | const extName = path.extname(list[i]); 21 | if (checkSharpExtName(extName)) { 22 | result = extName; 23 | } 24 | } 25 | return result; 26 | } 27 | 28 | export function checkExtName(filePath: string): boolean { 29 | const parseDir = path.parse(filePath); 30 | return pdfSupportImage.includes(parseDir.ext); 31 | } 32 | 33 | function downloadImage( 34 | url: string, 35 | fileName: string, 36 | referer = 'https://www.manhuagui.com', 37 | ): Promise { 38 | return new Promise (resolve => { 39 | const extName = getExtName(url); 40 | if (!extName) { 41 | resolve(''); 42 | return; 43 | } 44 | const filePath = path.join( 45 | config.downloadBase, 46 | getComicSite(referer), 47 | fileName + extName, 48 | ); 49 | const parseDir = path.parse(filePath); 50 | makeDir(parseDir.dir); 51 | const stream = fs.createWriteStream(filePath); 52 | stream.on('finish', (): void => { 53 | logger.info(`[Download Image Success] ${filePath}`); 54 | resolve(filePath); 55 | }); 56 | // 转义链接中的中文参数 57 | const realUrl = encodeURI(url); 58 | axios({ 59 | url: realUrl, 60 | responseType: 'stream', 61 | headers: { 62 | Referer: referer, 63 | 'User-Agent': config.userAgent, 64 | }, 65 | }).then((response: JsObject): void => { 66 | response.data.pipe(stream); 67 | }).catch((error: Error) => { 68 | throw error; 69 | }); 70 | }); 71 | } 72 | 73 | export default downloadImage; 74 | -------------------------------------------------------------------------------- /server/utils/generateBook.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { IChapterMysql, ISearchMysql } from '../type'; 3 | import configData from '../shared'; 4 | import generatePdf from './generatePdf'; 5 | import logger from './logger'; 6 | import { getComicSite, getReferer, numToString } from './parseUrl'; 7 | import downloadImage, { checkExtName } from './downloadImage'; 8 | import convertImage from './convertImage'; 9 | 10 | interface DownloadItem { 11 | url: string; 12 | fileName: string; 13 | } 14 | const getBookDir = ( 15 | searchItem: ISearchMysql, 16 | chapterItem: IChapterMysql, 17 | ): string => `${searchItem.title}/${chapterItem.title}`; 18 | 19 | function formatDownloadPath ( 20 | dataResult: JsObject[], 21 | searchItem: ISearchMysql, 22 | chapterItem: IChapterMysql, 23 | ): DownloadItem[]{ 24 | const dirPath: string = getBookDir(searchItem, chapterItem); 25 | return dataResult.map((item: JsObject): DownloadItem => ({ 26 | url: item.url, 27 | fileName: `${dirPath}/${numToString(item.page)}`, 28 | })); 29 | } 30 | 31 | async function makeBook( 32 | results: JsObject[], 33 | searchItem: ISearchMysql, 34 | chapterItem: IChapterMysql, 35 | requestName: string, 36 | ): Promise { 37 | const downloadList = formatDownloadPath(results, searchItem, chapterItem); 38 | const requestUrl = getReferer(requestName); 39 | for (const item of downloadList) { 40 | try { 41 | const filePath: string = await downloadImage( 42 | item.url, 43 | item.fileName, 44 | requestUrl, 45 | ); 46 | if (!checkExtName(filePath)) { 47 | logger.info(filePath); 48 | const result: JsObject = await convertImage(filePath); 49 | if (result) { 50 | await convertImage(filePath); 51 | } 52 | } 53 | } catch (error) { 54 | logger.error(error); 55 | } 56 | } 57 | const dirPath: string = getBookDir(searchItem, chapterItem); 58 | const realPath = path.join( 59 | configData.downloadBase, 60 | getComicSite(requestUrl), 61 | dirPath, 62 | ); 63 | return generatePdf(realPath); 64 | } 65 | 66 | export default makeBook; 67 | -------------------------------------------------------------------------------- /server/utils/generateMarkdown.ts: -------------------------------------------------------------------------------- 1 | import Path from 'path'; 2 | import fs from 'fs'; 3 | import urlConfig from '../shared/urlConfig'; 4 | import { UrlConfigItem } from '../shared/type'; 5 | 6 | function generateMarkdown(): void { 7 | const sourceFilePath = Path.join(process.cwd(), '../docs/readmeTemplate.md'); 8 | const template = fs.readFileSync(sourceFilePath, 'utf8'); 9 | const list: UrlConfigItem[] = Object.values(urlConfig); 10 | // 排序 完成功能的排在前面 11 | list.sort((a: UrlConfigItem, b: UrlConfigItem) => Number(b.enabled) - Number(a.enabled)); 12 | const siteList: string[] = list.map((item: UrlConfigItem) => { 13 | const flag: string = item.enabled ? '' : '~~'; 14 | return `1. [${flag}${item.name} (${item.base})${flag}](${item.base})`; 15 | }); 16 | const resultFilePath = Path.join(process.cwd(), '../README.md'); 17 | const result: string = template.replace('---comic-site--', siteList.join('\n')); 18 | fs.writeFileSync(resultFilePath, result); 19 | console.log(`[Update README.md Success] ${resultFilePath}`); 20 | } 21 | 22 | generateMarkdown(); 23 | -------------------------------------------------------------------------------- /server/utils/generatePdf.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import PdfDoc from 'pdfkit'; 3 | import logger from './logger'; 4 | import { bookConfig } from '../shared'; 5 | import getBookInfo from './bookInfo'; 6 | import { BookInfo } from './type'; 7 | 8 | function generatePdf(dirName: string): string { 9 | if (!dirName) { 10 | logger.error('下载路径为空!'); 11 | return ''; 12 | } 13 | if (!fs.existsSync(dirName)) { 14 | logger.error(`下载路径为 ${dirName}不存在!`); 15 | return ''; 16 | } 17 | 18 | const { outputPath, filePathList, bookTitle }: BookInfo = getBookInfo( 19 | dirName, 20 | 'pdf', 21 | ); 22 | 23 | if (filePathList.length === 0) { 24 | logger.error('内容为空!'); 25 | } 26 | const pdf: JsObject = new PdfDoc(); 27 | pdf.pipe(fs.createWriteStream(outputPath)); 28 | 29 | pdf.info.Title = bookTitle; 30 | pdf.info.Author = bookConfig.author; 31 | 32 | for (let i = 0; i < filePathList.length; i += 1) { 33 | const item = filePathList[i]; 34 | const temp = fs.readFileSync(item); 35 | try { 36 | pdf.image(temp, bookConfig.paddingLeft, bookConfig.paddingTop, { 37 | width: bookConfig.imageWidth, 38 | height: bookConfig.imageHeight, 39 | align: 'center', 40 | valign: 'center', 41 | }); 42 | logger.info(`[Add Image Success] ${item}`); 43 | } catch (error) { 44 | logger.error(error); 45 | } 46 | if (i !== filePathList.length - 1) { 47 | pdf.addPage(); 48 | } 49 | } 50 | pdf.end(); 51 | logger.info(`Generate Pdf Success: ${outputPath}\nDone!`); 52 | return outputPath; 53 | } 54 | 55 | export default generatePdf; 56 | -------------------------------------------------------------------------------- /server/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | 3 | const logger = winston.createLogger({ 4 | level: process.env.LOGGER_LEVEL || 'info', 5 | format: winston.format.json(), 6 | transports: [ 7 | new winston.transports.File({ 8 | filename: './logs/error.log', 9 | level: 'error', 10 | }), 11 | new winston.transports.File({ filename: './logs/combined.log' }), 12 | ], 13 | }); 14 | 15 | logger.add( 16 | new winston.transports.Console({ 17 | format: winston.format.combine( 18 | winston.format.colorize(), 19 | winston.format.simple() 20 | ), 21 | silent: process.env.NODE_ENV === 'test', 22 | }) 23 | ); 24 | 25 | export default logger; 26 | -------------------------------------------------------------------------------- /server/utils/makeDir.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | function makeDir(dirname: string): boolean { 5 | if (!dirname) { 6 | return false; 7 | } 8 | if (fs.existsSync(dirname)) { 9 | return true; 10 | } 11 | if (makeDir(path.dirname(dirname))) { 12 | fs.mkdirSync(dirname); 13 | return true; 14 | } 15 | 16 | return false; 17 | } 18 | 19 | export default makeDir; 20 | -------------------------------------------------------------------------------- /server/utils/md5.ts: -------------------------------------------------------------------------------- 1 | import crypto, { BinaryLike } from 'crypto'; 2 | 3 | export default function md5(content: BinaryLike): string { 4 | return crypto 5 | .createHash('md5') 6 | .update(content) 7 | .digest('hex'); 8 | } 9 | -------------------------------------------------------------------------------- /server/utils/parseUrl.ts: -------------------------------------------------------------------------------- 1 | import urlModule from 'url'; 2 | import _ from 'lodash'; 3 | 4 | const maxLength = 4; 5 | 6 | function getReferer(link: string): string { 7 | const result = urlModule.parse(link); 8 | return `${result.protocol || ''}//${result.host || ''}`; 9 | } 10 | 11 | function filterIllegalPath(filePath: string): string { 12 | const result = filePath.replace(/[^\da-z\u4e00-\u9fa5]/gi, ''); 13 | return result; 14 | } 15 | 16 | function numToString(num: number): string { 17 | if (!_.isNumber(num)) { 18 | return ''; 19 | } 20 | const temp: number = maxLength - num.toString().length; 21 | if (temp <= 0) { 22 | return _.toString(num); 23 | } 24 | const zero = new Array(temp).fill(0).join(''); 25 | return `${zero}${num}`; 26 | } 27 | 28 | function getComicSite(url: string): string { 29 | const temp: string[] = url.split('.'); 30 | temp.pop(); 31 | return _.last(temp) || ''; 32 | } 33 | 34 | export { getReferer, filterIllegalPath, numToString, getComicSite }; 35 | -------------------------------------------------------------------------------- /server/utils/puppeteer.ts: -------------------------------------------------------------------------------- 1 | import puppeteer, { Browser } from 'puppeteer'; 2 | import config from '../shared'; 3 | 4 | const options = { 5 | args: [ 6 | '--no-sandbox', 7 | '--disable-setuid-sandbox', 8 | '--disable-infobars', 9 | '--window-position=0,0', 10 | '--ignore-certifcate-errors', 11 | '--ignore-certifcate-errors-spki-list', 12 | `--user-agent=${config.userAgent}`, 13 | ], 14 | headless: true, 15 | ignoreHTTPSErrors: true, 16 | userDataDir: './tmp', 17 | }; 18 | 19 | const puppeteerBrowser = async (): Promise => { 20 | return await puppeteer.launch(options); 21 | }; 22 | 23 | 24 | export function getHtml(selector = 'html'): string { 25 | const dom: JsObject | null = document.querySelector(selector); 26 | if (!dom) { 27 | return ''; 28 | } 29 | return dom.innerHTML; 30 | } 31 | 32 | export function scrollToBottom(distance = 100): void { 33 | const dom: JsObject = document.scrollingElement || {}; 34 | let lastScrollTop: number = dom.scrollTop; 35 | const scroll = (): void => { 36 | dom.scrollTop += distance; 37 | if (dom.scrollTop !== lastScrollTop) { 38 | lastScrollTop = dom.scrollTop; 39 | requestAnimationFrame(scroll); 40 | } 41 | }; 42 | scroll(); 43 | } 44 | 45 | export default puppeteerBrowser; 46 | -------------------------------------------------------------------------------- /server/utils/toNum.ts: -------------------------------------------------------------------------------- 1 | export default function toNum(data: string | undefined): number { 2 | if (typeof data === 'undefined') { 3 | return 0; 4 | } 5 | const temp: number = parseInt(data, 10); 6 | return temp || 0; 7 | } 8 | -------------------------------------------------------------------------------- /server/utils/type.ts: -------------------------------------------------------------------------------- 1 | export interface BookInfo { 2 | outputPath: string; 3 | filePathList: string[]; 4 | bookTitle: string; 5 | } 6 | -------------------------------------------------------------------------------- /server/utils/wait.ts: -------------------------------------------------------------------------------- 1 | export default function wait(millisecond: number): Promise { 2 | return new Promise((resolve) => { 3 | setTimeout(resolve, millisecond); 4 | }); 5 | } 6 | -------------------------------------------------------------------------------- /src/components/CommonFooter/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | const CommonFooter: React.FunctionComponent = () => ( 4 | 11 | ); 12 | export default CommonFooter; 13 | -------------------------------------------------------------------------------- /src/components/CommonHeader/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd/lib/style/themes/default.less'; 2 | 3 | .header { 4 | &-title { 5 | color: @primary-color; 6 | } 7 | 8 | &-content { 9 | font-size: 20px; 10 | color: white; 11 | } 12 | 13 | &-help { 14 | padding-left: @form-item-margin-bottom / 2; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/components/CommonHeader/index.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, Tooltip } from 'antd'; 2 | import React from 'react'; 3 | import { getLanguageData } from '../../locales'; 4 | import { Link } from 'react-router-dom'; 5 | import './index.less' 6 | type Props = {} 7 | 8 | const CommonHeader: React.FunctionComponent = ({ children }) => ( 9 | 10 | 11 | 23 | ); 24 | export default CommonHeader; 25 | -------------------------------------------------------------------------------- /src/components/DumpTable/index.less: -------------------------------------------------------------------------------- 1 | 2 | .table { 3 | .ant-table-pagination { 4 | margin-top: 24px; 5 | } 6 | 7 | 8 | &-alert { 9 | margin-bottom: 16px; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/components/DumpTable/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import React, { Component, Fragment } from 'react'; 3 | import { Table, Alert } from 'antd'; 4 | import { getLanguageData } from '../../locales'; 5 | import './index.less'; 6 | 7 | type StandardTableProps = { 8 | columns: any; 9 | onSelectRow: (row: any) => void; 10 | data: any; 11 | rowKey?: string; 12 | selectedRows: any[]; 13 | onChange?: any; 14 | loading?: boolean; 15 | checkType: string; 16 | }; 17 | 18 | function initTotalList(columns: any) { 19 | const totalList: any = []; 20 | columns.forEach((column: any) => { 21 | if (column.needTotal) { 22 | totalList.push({ ...column, total: 0 }); 23 | } 24 | }); 25 | return totalList; 26 | } 27 | 28 | class DumpTable extends ComponentComicHub
12 | 13 |14 | {children} 15 |22 |19 | 21 |20 | { 29 | private static defaultProps = { 30 | columns: [], 31 | data: [], 32 | rowKey: (item: any) => item.id || item.url, 33 | onSelectRow: null, 34 | selectedRows: [], 35 | }; 36 | 37 | private constructor(props: StandardTableProps) { 38 | super(props); 39 | const { columns } = props; 40 | const needTotalList = initTotalList(columns); 41 | this.state = { 42 | selectedRowKeys: [], 43 | needTotalList, 44 | }; 45 | } 46 | 47 | private handleRowSelectChange = (selectedRowKeys: any, selectedRows: any) => { 48 | let { needTotalList } = this.state; 49 | needTotalList = needTotalList.map((item: any) => ({ 50 | ...item, 51 | total: selectedRows.reduce( 52 | (sum: any, val: any) => sum + parseFloat(val[item.dataIndex]), 53 | 0, 54 | ), 55 | })); 56 | const { onSelectRow } = this.props; 57 | if (onSelectRow) { 58 | onSelectRow(selectedRows); 59 | } 60 | 61 | this.setState({ selectedRowKeys, needTotalList }); 62 | }; 63 | private handleTableChange = (pagination: any) => { 64 | const { onChange } = this.props; 65 | if (onChange) { 66 | onChange(pagination); 67 | } 68 | }; 69 | private cleanSelectedKeys = () => { 70 | this.handleRowSelectChange([], []); 71 | }; 72 | 73 | public render() { 74 | const { selectedRowKeys, needTotalList } = this.state; 75 | const { data = [], rowKey, checkType = 'checkbox', ...rest } = this.props; 76 | const realData = data.map((item: JsObject, i: number) => ({ ...item, id: item.id || (i + 1) })); 77 | const rowSelection: any = { 78 | selectedRowKeys, 79 | onChange: this.handleRowSelectChange, 80 | getCheckboxProps: (record: any) => ({ 81 | disabled: record.disabled, 82 | }), 83 | type: checkType, 84 | }; 85 | 86 | return ( 87 | 88 |128 | ); 129 | } 130 | } 131 | 132 | export default DumpTable; 133 | -------------------------------------------------------------------------------- /src/components/SearchForm/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Form, Input, Select } from 'antd'; 2 | import { getLanguageData } from '../../locales'; 3 | import React, { useEffect } from 'react'; 4 | import { IFormData, IOptionData, MenuItem } from '../../type'; 5 | 6 | const FormItem = Form.Item; 7 | 8 | interface Props { 9 | menuList: MenuItem[]; 10 | form: JsObject; 11 | handleFormSubmit: (data: IFormData) => void; 12 | } 13 | 14 | const SearchForm: React.FunctionComponent89 |117 |92 | {getLanguageData('component.DumpTable.selected')} 93 | 94 | {selectedRowKeys.length} 95 | 96 | {getLanguageData('component.DumpTable.single')} 97 | 98 | {needTotalList.map((item: any) => ( 99 | 100 | {item.title} 101 | {getLanguageData('component.DumpTable.total')} 102 | 103 | 104 | {item.render ? item.render(item.total) : item.total} 105 | 106 | 107 | ))} 108 | 109 | {getLanguageData('component.DumpTable.clear')} 110 | 111 | 112 | } 113 | type="info" 114 | showIcon 115 | /> 116 | 127 |
= ({ 15 | form, 16 | menuList = [], 17 | handleFormSubmit, 18 | }) => { 19 | const currentData: IFormData = { 20 | name: '', 21 | url: '', 22 | }; 23 | 24 | function resetForm(): void { 25 | form.resetFields(); 26 | } 27 | 28 | useEffect(() => { 29 | resetForm(); 30 | }, []); 31 | 32 | function handleSubmit(event: React.SyntheticEvent): void { 33 | event.preventDefault(); 34 | form.validateFields((error: Error, fieldsValue: IFormData) => { 35 | if (error) { 36 | return; 37 | } 38 | if (handleFormSubmit) { 39 | handleFormSubmit(fieldsValue); 40 | } 41 | }); 42 | } 43 | 44 | function filterOption(value: string, record: JsObject): boolean { 45 | if (!value) { 46 | return false; 47 | } 48 | const temp: string = record.props.children; 49 | return temp.includes(value); 50 | } 51 | 52 | return ( 53 | 110 | ); 111 | }; 112 | // eslint-disable-next-line 113 | const FormWrapper: any = Form.create({ name: 'SearchForm' })(SearchForm); 114 | export default FormWrapper; 115 | -------------------------------------------------------------------------------- /src/components/SelectLang/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd/lib/style/themes/default.less'; 2 | 3 | 4 | .language { 5 | .anticon { 6 | margin-right: 8px; 7 | } 8 | 9 | .ant-dropdown-menu-item { 10 | min-width: 160px; 11 | } 12 | 13 | &-icon { 14 | color: #fff; 15 | } 16 | } 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/components/SelectLang/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { getLanguageData, getLocale, setLocale } from '../../locales'; 3 | import { Dropdown, Icon, Menu } from 'antd'; 4 | import './index.less'; 5 | 6 | type Props = {} 7 | 8 | interface ObjectType { 9 | [key: string]: string; 10 | } 11 | 12 | interface Event { 13 | key: string; 14 | } 15 | 16 | const SelectLang: React.FunctionComponent = () => { 17 | function changeLang(event: Event): void { 18 | const { key } = event; 19 | setLocale(key); 20 | location.reload() 21 | } 22 | 23 | const selectedLang = getLocale(); 24 | const locales: string[] = ['zh-CN', 'en-US']; 25 | const languageLabels: ObjectType = { 26 | 'zh-CN': '中文', 27 | 'en-US': 'English', 28 | }; 29 | const title: string = getLanguageData('component.SelectLang.language'); 30 | const languageIcons: ObjectType = { 31 | 'zh-CN': '🇨🇳', 32 | 'en-US': '🇬🇧', 33 | }; 34 | const langMenu = ( 35 | 48 | ); 49 | return ( 50 | 51 | 53 | ); 54 | }; 55 | 56 | export default SelectLang; 57 | -------------------------------------------------------------------------------- /src/components/__tests__/CommonFooter.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer, { ReactTestRenderer } from 'react-test-renderer'; 3 | import CommonFooter from '../CommonFooter'; 4 | 5 | it('Component: CommonFooter Snapshots', () => { 6 | 7 | let tree: ReactTestRenderer; 8 | renderer.act(() => { 9 | tree = renderer 10 | .create(52 | ); 11 | }); 12 | // @ts-ignore 13 | expect(tree.toJSON()).toMatchSnapshot(); 14 | }); 15 | 16 | -------------------------------------------------------------------------------- /src/components/__tests__/CommonHeader.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { Router } from 'react-router-dom'; 4 | import { history } from '../../utils'; 5 | import CommonHeader from '../CommonHeader'; 6 | 7 | it('Page: Chpter Snapshots', () => { 8 | const tree = renderer.create( ).toJSON(); 9 | expect(tree).toMatchSnapshot(); 10 | }); 11 | -------------------------------------------------------------------------------- /src/components/__tests__/__snapshots__/CommonFooter.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Component: CommonFooter Snapshots 1`] = ` 4 | 23 | `; 24 | -------------------------------------------------------------------------------- /src/components/__tests__/__snapshots__/CommonHeader.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Page: Chpter Snapshots 1`] = ` 4 | Array [ 5 | 9 | 12 | ComicHub 13 |
14 | , 15 |18 | 24 | 41 | 42 |, 43 | ] 44 | `; 45 | -------------------------------------------------------------------------------- /src/global.less: -------------------------------------------------------------------------------- 1 | @import '~antd/lib/style/themes/default.less'; 2 | 3 | body { 4 | color: #000; 5 | line-height: 1.5; 6 | background-color: #f2f4f5; 7 | text-rendering: optimizeLegibility; 8 | } 9 | 10 | .submit-button { 11 | padding-bottom: @form-item-margin-bottom; 12 | } 13 | 14 | .page-loading { 15 | position: fixed; 16 | top: 0; 17 | left: 0; 18 | bottom: 0; 19 | right: 0; 20 | background-color: #fff; 21 | z-index: 3; 22 | 23 | > .ant-spin { 24 | position: absolute; 25 | transform: translate(-50%, -50%); 26 | top: 50%; 27 | left: 50%; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |Comic Hub 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './routes'; 4 | import './global.less'; 5 | import 'antd/dist/antd.css'; 6 | import Store from './store'; 7 | 8 | ReactDOM.render( 9 |10 | 12 | , 13 | document.getElementById('root'), 14 | ); 15 | -------------------------------------------------------------------------------- /src/locales/en-US.ts: -------------------------------------------------------------------------------- 1 | import component from './en-US/component'; 2 | import page from './en-US/page'; 3 | import utils from './en-US/utils'; 4 | 5 | export default { 6 | ...component, 7 | ...page, 8 | ...utils, 9 | }; 10 | -------------------------------------------------------------------------------- /src/locales/en-US/component.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'component.button.submit': 'Submit', 3 | 'component.button.search': 'Search', 4 | 'component.CommonHeader.tooltip': 'Help', 5 | 'component.DumpTable.clear': 'Clear', 6 | 'component.DumpTable.total': 'Total', 7 | 'component.DumpTable.selected': 'Selected', 8 | 'component.DumpTable.single': "'s", 9 | 'component.SearchForm.site.label': 'Site', 10 | 'component.SearchForm.site.message': 'please select site!', 11 | 'component.SearchForm.keyword.label': 'Keyword', 12 | 'component.SearchForm.keyword.message': 'please input keyword', 13 | 'component.SearchForm.reset': 'Reset', 14 | 'component.SelectLang.language': 'Language', 15 | }; 16 | -------------------------------------------------------------------------------- /src/locales/en-US/page.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'page.HomePage.step0': 'Search Comic', 3 | 'page.HomePage.step1': 'Select Comic', 4 | 'page.HomePage.step2': 'Select Chapter', 5 | 'page.HomePage.step3': 'Download Comic', 6 | 'page.Chapter.table.title': 'Title', 7 | 'page.Chapter.table.author': 'Author', 8 | 'page.Chapter.table.url': 'Link', 9 | 'page.Chapter.table.area': 'Country', 10 | 'page.Chapter.table.category': 'Category', 11 | 'page.Chapter.table.cover': 'Cover', 12 | 'page.Chapter.table.create_time': 'Created', 13 | 'page.Chapter.select.tip': 'please select comic!', 14 | 'page.Result.download.success': 'Download Success!', 15 | 'page.Images.table.page_size': 'Count', 16 | 'page.Images.select.tip': 'please select comic chapter', 17 | }; 18 | -------------------------------------------------------------------------------- /src/locales/en-US/utils.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'utils.request.status.error': 'Request Fail', 3 | 'utils.request.100': 'Continue', 4 | 'utils.request.200': 'Request Success', 5 | 'utils.request.201': 'Created', 6 | 'utils.request.202': 'Accepted', 7 | 'utils.request.204': 'No Content', 8 | 'utils.request.400': 'Bad Request', 9 | 'utils.request.401': 'Unauthorized', 10 | 'utils.request.403': 'Forbidden', 11 | 'utils.request.404': 'Not Found', 12 | 'utils.request.406': 'Not Acceptable', 13 | 'utils.request.408': 'Request Timeout', 14 | 'utils.request.410': 'Gone', 15 | 'utils.request.422': 'Unprocessable Entity', 16 | 'utils.request.500': 'Internal Server Error', 17 | 'utils.request.501': 'Not Implemented', 18 | 'utils.request.502': 'Bad Gateway', 19 | 'utils.request.503': 'Service Unavaliable', 20 | 'utils.request.504': 'Gateway Timeout', 21 | }; 22 | -------------------------------------------------------------------------------- /src/locales/index.ts: -------------------------------------------------------------------------------- 1 | import zhCN from './zh-CN'; 2 | import enUS from './en-US'; 3 | 4 | const LANGUAGE_KEY = 'comic-hub-language'; 5 | // eslint-disable-next-line 6 | const languageMap: { [key: string]: any } = { 7 | 'zh-CN': zhCN, 8 | 'en-US': enUS, 9 | }; 10 | 11 | export function getLocale(): string { 12 | return process.env.LANGUAGE || localStorage.getItem(LANGUAGE_KEY) || 'zh-CN'; 13 | } 14 | 15 | export function setLocale(value: string) { 16 | localStorage.setItem(LANGUAGE_KEY, value); 17 | } 18 | 19 | export function getLanguageData(id: string): string { 20 | const language = getLocale(); 21 | const data = languageMap[language]; 22 | if (data && data[id]) { 23 | return data[id]; 24 | } 25 | return ''; 26 | } 27 | -------------------------------------------------------------------------------- /src/locales/zh-CN.ts: -------------------------------------------------------------------------------- 1 | import component from './zh-CN/component'; 2 | import page from './zh-CN/page'; 3 | import utils from './zh-CN/utils'; 4 | 5 | export default { 6 | ...component, 7 | ...page, 8 | ...utils, 9 | }; 10 | -------------------------------------------------------------------------------- /src/locales/zh-CN/component.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'component.button.submit': '提交', 3 | 'component.button.search': '搜索', 4 | 'component.CommonHeader.tooltip': '帮助', 5 | 'component.DumpTable.clear': '清空', 6 | 'component.DumpTable.total': '总计', 7 | 'component.DumpTable.selected': '已选择', 8 | 'component.DumpTable.single': '项', 9 | 'component.SearchForm.site.label': '站点', 10 | 'component.SearchForm.site.message': '请选择站点', 11 | 'component.SearchForm.keyword.label': '关键词', 12 | 'component.SearchForm.keyword.message': '请输入关键词', 13 | 'component.SearchForm.reset': '重置', 14 | 'component.SelectLang.language': '语言', 15 | }; 16 | -------------------------------------------------------------------------------- /src/locales/zh-CN/page.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'page.HomePage.step0': '搜索漫画', 3 | 'page.HomePage.step1': '选择漫画', 4 | 'page.HomePage.step2': '选择章节', 5 | 'page.HomePage.step3': '下载漫画', 6 | 'page.Chapter.table.title': '名称', 7 | 'page.Chapter.table.author': '作者', 8 | 'page.Chapter.table.url': '链接', 9 | 'page.Chapter.table.area': '地区', 10 | 'page.Chapter.table.category': '分类', 11 | 'page.Chapter.table.cover': '封面', 12 | 'page.Chapter.table.create_time': '爬取时间', 13 | 'page.Chapter.select.tip': '请选择漫画!', 14 | 'page.Result.download.success': '下载成功!', 15 | 'page.Images.table.page_size': '章节图片数量', 16 | 'page.Images.select.tip': '请选择漫画章节!', 17 | }; 18 | -------------------------------------------------------------------------------- /src/locales/zh-CN/utils.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'utils.request.status.error': '请求错误', 3 | 'utils.request.100': '请求头已接受成功,请求数据待发送。', 4 | 'utils.request.200': '服务器成功返回请求的数据。', 5 | 'utils.request.201': '新建或修改数据成功。', 6 | 'utils.request.202': '一个请求已经进入后台排队(异步任务)。', 7 | 'utils.request.204': '删除数据成功。', 8 | 'utils.request.400': '发出的请求有错误,服务器没有进行新建或修改数据的操作。', 9 | 'utils.request.401': '用户没有权限(令牌、用户名、密码错误)。', 10 | 'utils.request.403': '用户得到授权,但是访问是被禁止的。', 11 | 'utils.request.404': '发出的请求针对的是不存在的记录,服务器没有进行操作。', 12 | 'utils.request.406': '请求的格式不可得。', 13 | 'utils.request.408': '服务器关闭连接。', 14 | 'utils.request.410': '请求的资源被永久删除,且不会再得到的。', 15 | 'utils.request.422': '当创建一个对象时,发生一个验证错误。', 16 | 'utils.request.500': '服务器发生错误,请检查服务器。', 17 | 'utils.request.501': '服务器不支持请求方法。', 18 | 'utils.request.502': '网关错误。', 19 | 'utils.request.503': '服务不可用,服务器暂时过载或维护。', 20 | 'utils.request.504': '网关超时。', 21 | }; 22 | -------------------------------------------------------------------------------- /src/pages/Chapter/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, message } from 'antd'; 2 | import { Location } from 'history'; 3 | import { getLanguageData } from '../../locales'; 4 | import React, { Fragment, useEffect, useState } from 'react'; 5 | import { getQuery, history } from '../../utils'; 6 | import DumpTable from '../../components/DumpTable'; 7 | import { TypeConfig } from '../../type'; 8 | import { ISearchItem } from '../../../server/type'; 9 | import { postItem } from '../../services'; 10 | import Store from '../../store'; 11 | import { searchColumns } from '../../services/columns'; 12 | 13 | interface Props { 14 | location: Location; 15 | } 16 | 17 | const Chapter: React.FunctionComponent11 | = (props: Props) => { 18 | const { 19 | location, 20 | } = props; 21 | const [selectedRows, setSelectedRows] = useState ([]); 22 | const checkType = 'radio'; 23 | const [list, setList] = useState ([]); 24 | const query = getQuery(location.search); 25 | const { toggleLoading } = Store.useContainer(); 26 | useEffect(() => { 27 | postItem({ 28 | url: query.url, 29 | name: query.name, 30 | type: TypeConfig.search, 31 | }).then(data => { 32 | setList(data); 33 | toggleLoading() 34 | }); 35 | }, []); 36 | 37 | function handleSelectRows(value: ISearchItem[]): void { 38 | setSelectedRows(value); 39 | } 40 | 41 | function handleChapterSubmit(): void { 42 | if (!selectedRows || selectedRows.length === 0) { 43 | message.error(getLanguageData('page.Chapter.select.tip')); 44 | return; 45 | } 46 | const [item] = selectedRows; 47 | const link = `/${TypeConfig.download}?url=${query.url}&name=${encodeURIComponent(item.url)}`; 48 | history.push(link); 49 | } 50 | 51 | return ( 52 | 53 | 70 | ); 71 | }; 72 | 73 | 74 | export default Chapter; 75 | -------------------------------------------------------------------------------- /src/pages/Help/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import Store from '../../store'; 3 | 4 | type Props = {} 5 | const Help: React.FunctionComponent54 | 61 |62 |69 | = () => { 6 | const { toggleLoading } = Store.useContainer(); 7 | useEffect(() => { 8 | toggleLoading(); 9 | }, []); 10 | return Help; 11 | }; 12 | export default Help; 13 | -------------------------------------------------------------------------------- /src/pages/Images/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, message } from 'antd'; 2 | import { getLanguageData } from '../../locales'; 3 | import React, { Fragment, useEffect, useState } from 'react'; 4 | import { getQuery, history } from '../../utils'; 5 | import DumpTable from '../../components/DumpTable'; 6 | import { IChapterItem } from '../../../server/type'; 7 | import { TypeConfig } from '../../type'; 8 | import { postItem } from '../../services'; 9 | import { Location } from 'history'; 10 | import Store from '../../store'; 11 | import { chapterColumns } from '../../services/columns'; 12 | 13 | interface Props { 14 | location: Location; 15 | } 16 | 17 | const ChapterResult: React.FunctionComponent= ({ 18 | location, 19 | }) => { 20 | const [selectedRows, setSelectedRows] = useState ([]); 21 | const checkType = 'radio'; 22 | const [list, setList] = useState ([]); 23 | const query = getQuery(location.search); 24 | const { toggleLoading } = Store.useContainer(); 25 | useEffect(() => { 26 | postItem({ 27 | url: query.url, 28 | name: decodeURIComponent(query.name), 29 | type: TypeConfig.chapter, 30 | }).then(data => { 31 | setList(data); 32 | toggleLoading() 33 | }); 34 | }, []); 35 | 36 | function handleSelectRows(value: IChapterItem[]): void { 37 | setSelectedRows(value); 38 | } 39 | 40 | function handleChapterSubmit(): void { 41 | if (!selectedRows || selectedRows.length === 0) { 42 | message.error(getLanguageData('page.Images.select.tip')); 43 | return; 44 | } 45 | const [item] = selectedRows; 46 | const link = `/${TypeConfig.result}?url=${query.url}&name=${encodeURIComponent(item.url)}&page_size=${item.page_size}`; 47 | history.push(link); 48 | } 49 | 50 | return ( 51 | 52 | 69 | ); 70 | }; 71 | 72 | export default ChapterResult; 73 | -------------------------------------------------------------------------------- /src/pages/Layout/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd/lib/style/themes/default.less'; 2 | 3 | .home-page { 4 | .ant-steps-item-title { 5 | white-space: pre; 6 | } 7 | 8 | .ant-layout-header { 9 | display: flex; 10 | justify-content: space-between; 11 | padding: 0 @form-item-margin-bottom; 12 | } 13 | 14 | .ant-layout-footer { 15 | background-color: #fff; 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/pages/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Layout, Steps } from 'antd'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { Location } from 'history'; 4 | import { getLanguageData } from '../../locales'; 5 | import CommonFooter from '../../components/CommonFooter'; 6 | import CommonHeader from '../../components/CommonHeader'; 7 | import SelectLang from '../../components/SelectLang'; 8 | import './index.less'; 9 | import { TypeConfig } from '../../type'; 10 | 11 | const { Header, Footer, Content } = Layout; 12 | const { Step } = Steps; 13 | 14 | interface Props { 15 | children?: React.ReactChild; 16 | location: Location; 17 | } 18 | 19 | export function getCurrentStep(type = ''): number { 20 | switch (type) { 21 | case TypeConfig.search: 22 | return 0; 23 | case TypeConfig.chapter: 24 | return 1; 25 | case TypeConfig.download: 26 | return 2; 27 | case TypeConfig.result: 28 | return 3; 29 | default: 30 | return 0; 31 | } 32 | } 33 | 34 | const HomePage: React.FunctionComponent53 | 60 |61 |68 | = ({ 35 | children, 36 | location: { pathname }, 37 | }) => { 38 | const [currentType, setCurrentType] = useState (''); 39 | useEffect(() => { 40 | const [, temp] = pathname.split('/'); 41 | setCurrentType(temp); 42 | }, [pathname]); 43 | const isVertical: boolean = window.innerWidth > 800; 44 | return ( 45 | 46 | 70 | ); 71 | }; 72 | 73 | export default HomePage; 74 | -------------------------------------------------------------------------------- /src/pages/NotMatch/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { Location } from 'history'; 4 | import Store from '../../store'; 5 | 6 | type Props = { 7 | location: Location; 8 | }; 9 | 10 | const NoMatch: React.FunctionComponent47 | 51 |48 | 50 |49 | 52 | 66 | 69 |53 | 64 |58 | 63 |59 | 60 | 61 | 62 | {children} 65 |= ({ 11 | location, 12 | }) => { 13 | const { toggleLoading } = Store.useContainer(); 14 | useEffect(()=>{ 15 | toggleLoading() 16 | },[]) 17 | return ( 18 | 19 |26 | ); 27 | }; 28 | 29 | export default NoMatch; 30 | -------------------------------------------------------------------------------- /src/pages/Result/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd/lib/style/themes/default.less'; 2 | 3 | .result { 4 | display: flex; 5 | align-items: center; 6 | justify-content: center; 7 | padding: @form-item-margin-bottom * 2; 8 | 9 | &-path { 10 | font-weight: bold; 11 | } 12 | 13 | &-pdf { 14 | text-align: center; 15 | } 16 | } 17 | 18 | -------------------------------------------------------------------------------- /src/pages/Result/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Icon, Spin } from 'antd'; 3 | import { getLanguageData } from '../../locales'; 4 | import { TypeConfig } from '../../type'; 5 | import './index.less'; 6 | import { postItem } from '../../services'; 7 | import { getQuery } from '../../utils'; 8 | import { Location } from 'history'; 9 | import Store from '../../store'; 10 | 11 | interface Props { 12 | location: Location; 13 | } 14 | 15 | const Result: React.FunctionComponent20 | No match for
22 |{location.pathname}
21 |23 | Back To Home 24 |25 |= ({ 16 | location, 17 | }) => { 18 | const [result, setResult] = useState (false); 19 | const [downloadPath, setDownloadPath] = useState (''); 20 | const { toggleLoading } = Store.useContainer(); 21 | useEffect(() => { 22 | const query = getQuery(location.search); 23 | postItem({ 24 | url: query.url, 25 | name: query.name, 26 | page_size: query.page_size, 27 | type: TypeConfig.download, 28 | }).then((response) => { 29 | const checkCode: boolean = response && response.code === 200; 30 | setResult(checkCode); 31 | setDownloadPath(response && response.data); 32 | toggleLoading() 33 | }); 34 | }, []); 35 | let temp: React.ReactNode = }/>; 36 | 37 | if (result) { 38 | temp = ( 39 | 40 |45 | ); 46 | } 47 | return41 | {getLanguageData('page.Result.download.success')} 42 |43 |{downloadPath}44 |{temp}; 48 | }; 49 | export default Result; 50 | -------------------------------------------------------------------------------- /src/pages/Search/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import SearchForm from '../../components/SearchForm'; 3 | import { IFormData, MenuItem, TypeConfig } from '../../type'; 4 | import { getMenuList as fetchMenuList } from '../../services'; 5 | import { history } from '../../utils'; 6 | import Store from '../../store'; 7 | 8 | type Props = {} 9 | 10 | function getMenuList(data: JsObject = {}): MenuItem[] { 11 | return Object.keys(data).map( 12 | (key: string): MenuItem => { 13 | const item = data[key]; 14 | return { 15 | name: item.name, 16 | enabled: item.enabled, 17 | value: key, 18 | }; 19 | }, 20 | ); 21 | } 22 | 23 | const HomePage: React.FunctionComponent= () => { 24 | const [menuList, setMenuList] = useState