├── .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 | Project logo 4 |

5 | 6 |

ComicHub

7 | 8 |
9 | 10 | [![build status](https://img.shields.io/travis/nusr/ComicHub/master.svg?style=flat-square)](https://travis-ci.org/nusr/ComicHub) 11 | [![Test coverage](https://img.shields.io/codecov/c/github/nusr/ComicHub.svg?style=flat-square)](https://codecov.io/github/nusr/ComicHub?branch=master) 12 | [![codebeat badge](https://codebeat.co/badges/d9f586aa-2e0a-4999-ad9a-4f51cb6f4fae)](https://codebeat.co/projects/github-com-nusr-comichub-master) 13 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/9600f74529c7446292b20527855f6aea)](https://www.codacy.com/app/nusr/ComicHub?utm_source=github.com&utm_medium=referral&utm_content=nusr/ComicHub&utm_campaign=Badge_Grade) 14 | [![DeepScan grade](https://deepscan.io/api/teams/4611/projects/6382/branches/52943/badge/grade.svg)](https://deepscan.io/dashboard#view=project&tid=4611&pid=6382&bid=52943) 15 | ![GitHub last commit (branch)](https://img.shields.io/github/last-commit/nusr/ComicHub/master.svg) 16 | ![GitHub commit activity the past week, 4 weeks, year](https://img.shields.io/github/commit-activity/y/nusr/ComicHub.svg) 17 | ![GitHub top language](https://img.shields.io/github/languages/top/nusr/ComicHub.svg) 18 | ![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/nusr/ComicHub.svg) 19 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/nusr/ComicHub/pull/new) 20 | [![MIT License](https://img.shields.io/github/license/nusr/ComicHub.svg)](http://opensource.org/licenses/MIT) 21 | 22 |
23 | 24 | --- 25 | 26 |

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 | Project logo 4 |

5 | 6 |

ComicHub

7 | 8 |
9 | 10 | [![build status](https://img.shields.io/travis/nusr/ComicHub/master.svg?style=flat-square)](https://travis-ci.org/nusr/ComicHub) 11 | [![Test coverage](https://img.shields.io/codecov/c/github/nusr/ComicHub.svg?style=flat-square)](https://codecov.io/github/nusr/ComicHub?branch=master) 12 | [![codebeat badge](https://codebeat.co/badges/d9f586aa-2e0a-4999-ad9a-4f51cb6f4fae)](https://codebeat.co/projects/github-com-nusr-comichub-master) 13 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/9600f74529c7446292b20527855f6aea)](https://www.codacy.com/app/nusr/ComicHub?utm_source=github.com&utm_medium=referral&utm_content=nusr/ComicHub&utm_campaign=Badge_Grade) 14 | [![DeepScan grade](https://deepscan.io/api/teams/4611/projects/6382/branches/52943/badge/grade.svg)](https://deepscan.io/dashboard#view=project&tid=4611&pid=6382&bid=52943) 15 | ![GitHub last commit (branch)](https://img.shields.io/github/last-commit/nusr/ComicHub/master.svg) 16 | ![GitHub commit activity the past week, 4 weeks, year](https://img.shields.io/github/commit-activity/y/nusr/ComicHub.svg) 17 | ![GitHub top language](https://img.shields.io/github/languages/top/nusr/ComicHub.svg) 18 | ![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/nusr/ComicHub.svg) 19 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/nusr/ComicHub/pull/new) 20 | [![MIT License](https://img.shields.io/github/license/nusr/ComicHub.svg)](http://opensource.org/licenses/MIT) 21 | 22 |
23 | 24 | --- 25 | 26 |

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: ['/__mock__/jest.setup.js'], 8 | testPathIgnorePatterns: ['/node_modules/', '/server/node_modules/'], 9 | collectCoverageFrom: [ 10 | 'src/components/**/*.{ts,tsx}', 11 | 'src/pages/**/*.{ts,tsx}', 12 | '**/utils/**/*.{ts,tsx}', 13 | 'server/middleware/**/*.{ts,tsx}', 14 | 'server/routes/**/*.{ts,tsx}', 15 | '!**/type/**/*.ts', 16 | '!**/generateMarkdown.ts', 17 | ], 18 | coveragePathIgnorePatterns: ['/node_modules/', '/server/node_modules/'], 19 | moduleNameMapper: { 20 | '\\.(css|less|sass|scss|svg)$': '/__mock__/jest.styleMock.js', 21 | '^.+\\.(ico|jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 22 | '/__mock__/jest.fileMock.js', 23 | }, 24 | coverageThreshold: { 25 | global: { 26 | functions: 60, 27 | lines: 60, 28 | statements: 60, 29 | branches: 35, 30 | }, 31 | }, 32 | testURL: 'http://localhost:8080', 33 | }; 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ComicHub", 3 | "version": "1.0.0", 4 | "description": "ComicHub", 5 | "private": true, 6 | "scripts": { 7 | "start": "webpack-dev-server --config build/webpack.dev.config.js --open", 8 | "start:server": "npm run --prefix ./server dev", 9 | "analyze": "cross-env ANALYZE=true NODE_ENV=production webpack --config build/webpack.prod.config.js", 10 | "build": "npm run build:server && npm run build:react", 11 | "build:react": "cross-env NODE_ENV=production webpack --config build/webpack.prod.config.js", 12 | "build:server": "npm run --prefix ./server build", 13 | "lint:fix": "npm run lint -- --fix", 14 | "lint": "eslint --ext .js,.jsx,.ts,.tsx src server", 15 | "test": "jest --coverage", 16 | "test:watch": "jest --watchAll --coverage", 17 | "readme": "npm run --prefix ./server readme", 18 | "lint-staged": "lint-staged" 19 | }, 20 | "dependencies": { 21 | "antd": "^3.23.6", 22 | "history": "^4.10.1", 23 | "qs": "^6.9.0", 24 | "react": "^16.10.2", 25 | "react-dom": "^16.10.2", 26 | "react-router-dom": "^5.1.2" 27 | }, 28 | "devDependencies": { 29 | "@types/jest": "^24.0.16", 30 | "@types/qs": "^6.5.3", 31 | "@types/react": "^16.9.5", 32 | "@types/react-dom": "^16.8.5", 33 | "@types/react-router-dom": "^5.1.0", 34 | "@types/react-test-renderer": "^16.8.3", 35 | "@typescript-eslint/eslint-plugin": "^2.3.0", 36 | "@typescript-eslint/parser": "^2.3.0", 37 | "clean-webpack-plugin": "^3.0.0", 38 | "cross-env": "^6.0.3", 39 | "css-loader": "^3.2.0", 40 | "dotenv": "^8.1.0", 41 | "eslint": "^6.4.0", 42 | "eslint-plugin-react": "^7.16.0", 43 | "friendly-errors-webpack-plugin": "^1.7.0", 44 | "html-webpack-plugin": "^3.2.0", 45 | "husky": "^3.0.5", 46 | "jest": "^24.8.0", 47 | "jest-circus": "^24.9.0", 48 | "less": "^3.9.0", 49 | "less-loader": "^5.0.0", 50 | "lint-staged": "^9.3.0", 51 | "mini-css-extract-plugin": "^0.8.0", 52 | "prettier": "^1.18.2", 53 | "progress-bar-webpack-plugin": "^1.12.1", 54 | "react-test-renderer": "^16.10.2", 55 | "style-loader": "^1.0.0", 56 | "ts-jest": "^24.1.0", 57 | "ts-loader": "^6.1.2", 58 | "typescript": "^3.6.3", 59 | "url-loader": "^2.2.0", 60 | "webpack": "^4.40.2", 61 | "webpack-bundle-analyzer": "^3.5.1", 62 | "webpack-cli": "^3.3.9", 63 | "webpack-dev-server": "^3.8.2", 64 | "webpack-merge": "^4.2.1" 65 | }, 66 | "lint-staged": { 67 | "*.{ts,tsx}": [ 68 | "eslint --fix", 69 | "git add" 70 | ], 71 | "*": [ 72 | "npm run readme", 73 | "git add", 74 | "git status" 75 | ] 76 | }, 77 | "husky": { 78 | "hooks": { 79 | "pre-commit": "npm run lint-staged", 80 | "pre-push": "npm run test" 81 | } 82 | }, 83 | "repository": "https://github.com/nusr/ComicHub", 84 | "keywords": [ 85 | "Comic", 86 | "Downloader", 87 | "typescript" 88 | ], 89 | "author": { 90 | "name": "Steve Xu", 91 | "email": "stevexugc@gmail.com" 92 | }, 93 | "license": "MIT" 94 | } 95 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | # testing 5 | coverage 6 | # production 7 | /build 8 | /dist 9 | .idea 10 | .vscode 11 | try.js 12 | # misc 13 | .DS_Store 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | logs 18 | tmp 19 | result 20 | node_modules 21 | cache 22 | # 锁住版本会有问题 23 | yarn.lock 24 | package-lock.json 25 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import Koa from 'koa'; 2 | import _ from 'lodash'; 3 | import { Server } from 'http'; 4 | import mount from 'koa-mount' 5 | import bodyParser from 'koa-bodyparser'; 6 | import config from './shared'; 7 | import logger from './utils/logger'; 8 | import errorHandler from './middleware/onerror'; 9 | import header from './middleware/header'; 10 | import mysql from './middleware/dataProcess'; 11 | 12 | import accessControl from './middleware/accessControl'; 13 | import router from './router'; 14 | 15 | import apiResponseHandler from './middleware/apiResponseHandler'; 16 | 17 | logger.info('Comic start!'); 18 | 19 | const app: Koa = new Koa(); 20 | 21 | app.use(errorHandler); 22 | 23 | app.use(header); 24 | 25 | app.use(accessControl); 26 | 27 | app.use(apiResponseHandler); 28 | 29 | app.use(bodyParser()); 30 | 31 | app.use(mysql); 32 | app.use(mount('/', router.routes() as Koa.Middleware)).use(router.allowedMethods()); 33 | let koaPort: number = config.serverPort; 34 | if (process.env.NODE_ENV === 'test') { 35 | koaPort = _.random(5000, 8000); 36 | } 37 | const server: Server = app.listen(koaPort); 38 | logger.info(`Running in http://localhost:${koaPort}`); 39 | 40 | export default { 41 | app, 42 | server, 43 | }; 44 | -------------------------------------------------------------------------------- /server/locales/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { getLanguageData } from '../index'; 2 | 3 | describe('getLanguageData', () => { 4 | it('getLanguageData should return right result', () => { 5 | expect(getLanguageData('middleware.accessControl.deny')).toBe( 6 | '没有访问权限!', 7 | ); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /server/locales/en-US/index.ts: -------------------------------------------------------------------------------- 1 | import middleware from './middleware'; 2 | 3 | export default { 4 | ...middleware, 5 | 'shared.downloadBase.notExist':'Download path does not exist' 6 | }; 7 | -------------------------------------------------------------------------------- /server/locales/en-US/middleware.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'middleware.accessControl.deny': 'Access denied!', 3 | 'middleware.dataProcess.search.empty': '', 4 | 'middleware.dataProcess.chapter.empty': '', 5 | 'middleware.dataProcess.failure': '', 6 | 'middleware.dataProcess.paramsFail': 'params error!', 7 | }; 8 | -------------------------------------------------------------------------------- /server/locales/index.ts: -------------------------------------------------------------------------------- 1 | import zhCN from './zh-CN'; 2 | import enUS from './en-US'; 3 | import config from '../shared'; 4 | 5 | interface LanguageMap { 6 | [key: string]: JsObject; 7 | } 8 | 9 | const languageMap: LanguageMap = { 10 | 'zh-CN': zhCN, 11 | 'en-US': enUS, 12 | }; 13 | 14 | function getLocale(): string { 15 | const lang: string = config && config.language; 16 | return lang || 'zh-CN'; 17 | } 18 | 19 | export function getLanguageData( 20 | id: string) { 21 | const language: string = getLocale(); 22 | const data: JsObject = languageMap[language]; 23 | if (data && data[id]) { 24 | return data[id]; 25 | } 26 | return ''; 27 | } 28 | -------------------------------------------------------------------------------- /server/locales/zh-CN/index.ts: -------------------------------------------------------------------------------- 1 | import middleware from './middleware'; 2 | 3 | export default { 4 | ...middleware, 5 | 'shared.downloadBase.notExist':'下载路径不存在!' 6 | }; 7 | -------------------------------------------------------------------------------- /server/locales/zh-CN/middleware.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'middleware.accessControl.deny': '没有访问权限!', 3 | 'middleware.dataProcess.search.empty': '搜索不到该漫画,请更换搜索词!', 4 | 'middleware.dataProcess.chapter.empty': '爬取章节为空,请重新选择漫画!', 5 | 'middleware.dataProcess.success': '下载漫画,生成PDF成功!', 6 | 'middleware.onerror.error': 'ComicHub 发生了一些意外', 7 | 'middleware.dataProcess.paramsFail': '参数错误!', 8 | }; 9 | -------------------------------------------------------------------------------- /server/middleware/__tests__/header.test.ts: -------------------------------------------------------------------------------- 1 | import superTest from 'supertest'; 2 | import koaServer from '../../index'; 3 | 4 | const { server } = koaServer; 5 | const request = superTest(server); 6 | 7 | afterAll(() => { 8 | server.close(); 9 | }); 10 | 11 | describe('header', () => { 12 | it('header', async () => { 13 | // eslint-disable-next-line 14 | const response: any = await request.get('/test/1'); 15 | // Test generate random port 16 | expect(response.headers['access-control-allow-origin']).toContain( 17 | '127.0.0.1:' 18 | ); 19 | expect(response.headers['access-control-allow-methods']).toBe( 20 | 'GET,HEAD,PUT,POST,DELETE,PATCH' 21 | ); 22 | expect(response.headers['content-type']).toBe( 23 | 'application/json; charset=utf-8' 24 | ); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /server/middleware/__tests__/onerror.test.ts: -------------------------------------------------------------------------------- 1 | import superTest from 'supertest'; 2 | import koaServer from '../../index'; 3 | 4 | const { server } = koaServer; 5 | const request = superTest(server); 6 | 7 | afterAll(() => { 8 | server.close(); 9 | }); 10 | 11 | describe('error', () => { 12 | it('error', async () => { 13 | const response: superTest.Response = await request.get('/test/0'); 14 | // eslint-disable-next-line 15 | expect(response.text).toMatch( 16 | eval( 17 | `/ComicHub 发生了一些意外:
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 |

ComicHub

12 | 13 |
14 | {children} 15 | 19 | 20 | 21 |
22 |
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 Component { 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 |
89 | 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 |
117 | 127 | 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.FunctionComponent = ({ 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 |
54 | 55 | {form.getFieldDecorator('url', { 56 | initialValue: currentData.url, 57 | rules: [ 58 | { 59 | required: true, 60 | message: getLanguageData('component.SearchForm.site.message'), 61 | }, 62 | ], 63 | })( 64 | 80 | )} 81 | 82 | 83 | {form.getFieldDecorator('name', { 84 | initialValue: currentData.name, 85 | rules: [ 86 | { 87 | required: true, 88 | message: getLanguageData('component.SearchForm.keyword.label'), 89 | }, 90 | ], 91 | })( 92 | 95 | )} 96 | 97 | 98 | 105 | 108 | 109 | 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 | 39 | {locales.map(locale => ( 40 | 41 | 42 | {languageIcons[locale]} 43 | 44 | {languageLabels[locale]} 45 | 46 | ))} 47 | 48 | ); 49 | return ( 50 | 51 | 52 | 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(); 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 | 11 | 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.FunctionComponent = (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 |
54 | 61 |
62 | 69 |
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.FunctionComponent = () => { 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 |
53 | 60 |
61 | 68 |
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.FunctionComponent = ({ 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 |
47 | 48 | 49 | 50 |
51 | 52 | 53 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | {children} 65 | 66 |
67 | 68 |
69 |
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.FunctionComponent = ({ 11 | location, 12 | }) => { 13 | const { toggleLoading } = Store.useContainer(); 14 | useEffect(()=>{ 15 | toggleLoading() 16 | },[]) 17 | return ( 18 |
19 |

20 | No match for {location.pathname} 21 |

22 |
23 | Back To Home 24 |
25 |
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.FunctionComponent = ({ 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 |
41 | {getLanguageData('page.Result.download.success')} 42 |
43 |
{downloadPath}
44 |
45 | ); 46 | } 47 | return
{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([]); 25 | const { toggleLoading } = Store.useContainer(); 26 | useEffect(() => { 27 | fetchMenuList().then(data => { 28 | setMenuList(getMenuList(data)); 29 | toggleLoading() 30 | }); 31 | }, []); 32 | 33 | function handleSearchSubmit(value: IFormData): void { 34 | if (value.name && value.url) { 35 | const link = `/${TypeConfig.chapter}?url=${value.url}&name=${value.name}`; 36 | history.push(link); 37 | } 38 | } 39 | 40 | return ; 41 | 42 | }; 43 | 44 | export default HomePage; 45 | -------------------------------------------------------------------------------- /src/pages/__tests__/Chapter.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer, { ReactTestRenderer } from 'react-test-renderer'; 3 | import Chapter from '../Chapter'; 4 | import Store from '../../store'; 5 | 6 | it('Page: Chpter Snapshots', () => { 7 | const location = { pathname: 'test', search: '?', hash: '', state: '' }; 8 | 9 | let tree: ReactTestRenderer; 10 | renderer.act(() => { 11 | tree = renderer 12 | .create(); 13 | }); 14 | // @ts-ignore 15 | expect(tree.toJSON()).toMatchSnapshot(); 16 | }); 17 | -------------------------------------------------------------------------------- /src/pages/__tests__/Help.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer, { ReactTestRenderer } from 'react-test-renderer'; 3 | import Help from '../Help'; 4 | import Store from '../../store'; 5 | describe('Page: Help', () => { 6 | it('Render correctly', () => { 7 | const wrapper: ReactTestRenderer = renderer.create(); 8 | expect(wrapper.root.children.length).toBeGreaterThan(1); 9 | }); 10 | it('Page: Help Snapshots', () => { 11 | const tree = renderer.create().toJSON(); 12 | expect(tree).toMatchSnapshot(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/pages/__tests__/Images.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer, { ReactTestRenderer } from 'react-test-renderer'; 3 | import Images from '../Images'; 4 | import Store from '../../store'; 5 | it('Page: Images Snapshots', () => { 6 | const location = { pathname: '', search: '?', hash: '', state: '' }; 7 | 8 | 9 | let tree: ReactTestRenderer; 10 | renderer.act(() => { 11 | tree = renderer 12 | .create(); 13 | }); 14 | // @ts-ignore 15 | expect(tree.toJSON()).toMatchSnapshot(); 16 | }); 17 | -------------------------------------------------------------------------------- /src/pages/__tests__/Layout.test.tsx: -------------------------------------------------------------------------------- 1 | import Layout, { getCurrentStep } from '../Layout'; 2 | import { TypeConfig } from '../../type'; 3 | import renderer, { ReactTestRenderer } from 'react-test-renderer'; 4 | import React from 'react'; 5 | import { Router } from 'react-router-dom'; 6 | import { history } from '../../utils'; 7 | import Store from '../../store'; 8 | describe('getCurrentStep', () => { 9 | it('getCurrentStep Should Right Result', () => { 10 | expect(getCurrentStep(TypeConfig.search)).toBe(0); 11 | expect(getCurrentStep(TypeConfig.chapter)).toBe(1); 12 | expect(getCurrentStep(TypeConfig.download)).toBe(2); 13 | expect(getCurrentStep(TypeConfig.result)).toBe(3); 14 | }); 15 | it('getCurrentStep Should Handle Non String', () => { 16 | expect(getCurrentStep()).toBe(0); 17 | expect(getCurrentStep('test')).toBe(0); 18 | expect(getCurrentStep('测试')).toBe(0); 19 | }); 20 | }); 21 | 22 | 23 | it('Page: Layout Snapshots', () => { 24 | const location = { pathname: '/chapter', search: '', hash: '', state: '' }; 25 | let tree: ReactTestRenderer; 26 | renderer.act(() => { 27 | tree = renderer 28 | .create(); 29 | }); 30 | // @ts-ignore 31 | expect(tree.toJSON()).toMatchSnapshot(); 32 | }); 33 | -------------------------------------------------------------------------------- /src/pages/__tests__/NotMatch.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer, { ReactTestRenderer } from 'react-test-renderer'; 3 | import { Router } from 'react-router-dom'; 4 | import { history } from '../../utils'; 5 | import NotMatch from '../NotMatch'; 6 | import Store from '../../store'; 7 | it('Page: Chpter Snapshots', () => { 8 | const location = { pathname: 'test', search: '', hash: '', state: '' }; 9 | 10 | let tree: ReactTestRenderer; 11 | renderer.act(() => { 12 | tree = renderer 13 | .create(); 14 | }); 15 | // @ts-ignore 16 | expect(tree.toJSON()).toMatchSnapshot(); 17 | }); 18 | -------------------------------------------------------------------------------- /src/pages/__tests__/Result.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import Result from '../Result'; 4 | import Store from '../../store'; 5 | it('Page: Result Snapshots', () => { 6 | const location = { pathname: '', search: '?', hash: '', state: '' }; 7 | const tree = renderer 8 | .create() 9 | .toJSON(); 10 | expect(tree).toMatchSnapshot(); 11 | }); 12 | -------------------------------------------------------------------------------- /src/pages/__tests__/Search.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer, { ReactTestRenderer } from 'react-test-renderer'; 3 | import Search from '../Search'; 4 | import Store from '../../store'; 5 | it('Page: Chpter Snapshots', () => { 6 | let tree: ReactTestRenderer; 7 | renderer.act(() => { 8 | tree = renderer 9 | .create(); 10 | }); 11 | // @ts-ignore 12 | expect(tree.toJSON()).toMatchSnapshot(); 13 | }); 14 | -------------------------------------------------------------------------------- /src/pages/__tests__/__snapshots__/Chapter.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Page: Chpter Snapshots 1`] = ` 4 | Array [ 5 | " ", 6 |
9 | 19 |
, 20 |
23 |
26 |
30 | 34 | 48 | 49 | 52 | 已选择 53 |   54 | 61 | 0 62 | 63 |   64 | 项 65 |   66 | 74 | 清空 75 | 76 | 77 | 80 |
81 |
82 |
85 |
88 |
91 |
95 |
98 |
104 |
108 | 109 | 118 | 126 | 134 | 142 | 150 | 158 | 166 | 174 | 182 | 183 | 186 | 193 | 209 | 227 | 245 | 263 | 281 | 299 | 317 | 335 | 353 | 354 | 355 | 358 |
196 | 199 |
200 | 203 | 206 |
207 |
208 |
212 | 215 |
216 | 219 | ID 220 | 221 | 224 |
225 |
226 |
230 | 233 |
234 | 237 | 名称 238 | 239 | 242 |
243 |
244 |
248 | 251 |
252 | 255 | 作者 256 | 257 | 260 |
261 |
262 |
266 | 269 |
270 | 273 | 链接 274 | 275 | 278 |
279 |
280 |
284 | 287 |
288 | 291 | 地区 292 | 293 | 296 |
297 |
298 |
302 | 305 |
306 | 309 | 分类 310 | 311 | 314 |
315 |
316 |
320 | 323 |
324 | 327 | 封面 328 | 329 | 332 |
333 |
334 |
338 | 341 |
342 | 345 | 爬取时间 346 | 347 | 350 |
351 |
352 |
359 |
360 |
363 |
366 |
369 | 375 | 380 | 387 | 391 | 394 | 398 | 399 | 400 | 401 |
402 |

405 | No Data 406 |

407 |
408 |
409 | 410 | 411 | 412 | 413 | 414 | , 415 | " ", 416 | ] 417 | `; 418 | -------------------------------------------------------------------------------- /src/pages/__tests__/__snapshots__/Help.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Page: Help Page: Help Snapshots 1`] = ` 4 | Array [ 5 | " ", 6 |
7 | Help 8 |
, 9 | " ", 10 | ] 11 | `; 12 | -------------------------------------------------------------------------------- /src/pages/__tests__/__snapshots__/Images.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Page: Images Snapshots 1`] = ` 4 | Array [ 5 | " ", 6 |
9 | 19 |
, 20 |
23 |
26 |
30 | 34 | 48 | 49 | 52 | 已选择 53 |   54 | 61 | 0 62 | 63 |   64 | 项 65 |   66 | 74 | 清空 75 | 76 | 77 | 80 |
81 |
82 |
85 |
88 |
91 |
95 |
98 |
104 | 108 | 109 | 118 | 126 | 134 | 142 | 150 | 158 | 159 | 162 | 169 | 185 | 203 | 221 | 239 | 257 | 275 | 276 | 277 | 280 |
172 | 175 |
176 | 179 | 182 |
183 |
184 |
188 | 191 |
192 | 195 | ID 196 | 197 | 200 |
201 |
202 |
206 | 209 |
210 | 213 | 名称 214 | 215 | 218 |
219 |
220 |
224 | 227 |
228 | 231 | 链接 232 | 233 | 236 |
237 |
238 |
242 | 245 |
246 | 249 | 章节图片数量 250 | 251 | 254 |
255 |
256 |
260 | 263 |
264 | 267 | 爬取时间 268 | 269 | 272 |
273 |
274 |
281 |
282 |
285 |
288 |
291 | 297 | 302 | 309 | 313 | 316 | 320 | 321 | 322 | 323 |
324 |

327 | No Data 328 |

329 |
330 |
331 |
332 |
333 |
334 |
335 |
336 |
, 337 | " ", 338 | ] 339 | `; 340 | -------------------------------------------------------------------------------- /src/pages/__tests__/__snapshots__/Layout.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Page: Layout Snapshots 1`] = ` 4 | Array [ 5 | " ", 6 |
14 |
17 | 21 |

24 | ComicHub 25 |

26 |
27 |
30 | 37 | 51 | 52 | 58 | 75 | 76 |
77 |
78 |
81 |
84 |
88 |
92 |
96 |
99 |
102 |
105 | 108 | 112 | 126 | 127 | 128 |
129 |
132 |
135 | 搜索漫画 136 |
137 |
138 |
139 |
140 |
144 |
147 |
150 |
153 | 156 | 2 157 | 158 |
159 |
162 |
165 | 选择漫画 166 |
167 |
168 |
169 |
170 |
174 |
177 |
180 |
183 | 186 | 3 187 | 188 |
189 |
192 |
195 | 选择章节 196 |
197 |
198 |
199 |
200 |
204 |
207 |
210 |
213 | 216 | 4 217 | 218 |
219 |
222 |
225 | 下载漫画 226 |
227 |
228 |
229 |
230 |
231 |
232 |
233 |
236 |
240 |
241 |
242 | 265 |
, 266 | " ", 267 | ] 268 | `; 269 | -------------------------------------------------------------------------------- /src/pages/__tests__/__snapshots__/NotMatch.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Page: Chpter Snapshots 1`] = ` 4 | Array [ 5 | " ", 6 |
7 |

8 | No match for 9 | 10 | test 11 | 12 |

13 | 21 |
, 22 | " ", 23 | ] 24 | `; 25 | -------------------------------------------------------------------------------- /src/pages/__tests__/__snapshots__/Result.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Page: Result Snapshots 1`] = ` 4 | Array [ 5 | " ", 6 |
9 |
12 | 21 | 35 | 36 |
37 |
, 38 | " ", 39 | ] 40 | `; 41 | -------------------------------------------------------------------------------- /src/pages/__tests__/__snapshots__/Search.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Page: Chpter Snapshots 1`] = ` 4 | Array [ 5 | " ", 6 |
10 |
14 |
18 | 26 |
27 |
31 |
34 | 37 |
53 |
108 |
111 |
124 | 请选择站点 125 |
126 |
136 | 137 |
138 |
146 |
149 | 157 | 160 | 161 |   162 | 163 |
164 |
165 |
166 | 177 | 181 | 195 | 196 | 197 |
198 |
199 |
200 |
201 |
202 |
203 |
207 |
211 | 219 |
220 |
224 |
227 | 230 | 276 | 277 |
278 |
279 |
280 |
284 |
288 |
291 | 294 | 308 | 317 | 318 |
319 |
320 |
321 |
, 322 | " ", 323 | ] 324 | `; 325 | -------------------------------------------------------------------------------- /src/routes.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, Router, Switch } from 'react-router-dom'; 3 | import { Location } from 'history'; 4 | import Help from './pages/Help'; 5 | import Layout from './pages/Layout'; 6 | import Search from './pages/Search'; 7 | import Chapter from './pages/Chapter'; 8 | import Images from './pages/Images'; 9 | import Result from './pages/Result'; 10 | import NotMatch from './pages/NotMatch'; 11 | import { history } from './utils'; 12 | import { Spin } from 'antd'; 13 | import Store from './store'; 14 | 15 | type componentProps = { 16 | location: Location; 17 | }; 18 | type Props = {}; 19 | const Routes: React.FunctionComponent = () => { 20 | const { isLoading } = Store.useContainer(); 21 | return ( 22 | 23 | {isLoading && ( 24 |
25 | 26 |
27 | )} 28 | 29 | ( 33 | 34 | 35 | 36 | )} 37 | /> 38 | ( 41 | 42 | 43 | 44 | )} 45 | /> 46 | ( 49 | 50 | 51 | 52 | )} 53 | /> 54 | ( 57 | 58 | 59 | 60 | )} 61 | /> 62 | ( 65 | 66 | 67 | 68 | )} 69 | /> 70 | 71 | 72 |
73 | ); 74 | }; 75 | 76 | export default Routes; 77 | -------------------------------------------------------------------------------- /src/services/columns.tsx: -------------------------------------------------------------------------------- 1 | import { getLanguageData } from '../locales'; 2 | import { Avatar } from 'antd'; 3 | import { renderDate } from '../utils'; 4 | import React from 'react'; 5 | 6 | export const searchColumns = [ 7 | { 8 | dataIndex: 'id', 9 | title: 'ID', 10 | }, 11 | { 12 | dataIndex: 'title', 13 | title: getLanguageData('page.Chapter.table.title'), 14 | }, 15 | { 16 | dataIndex: 'author', 17 | title: getLanguageData('page.Chapter.table.author'), 18 | }, 19 | { 20 | dataIndex: 'url', 21 | title: getLanguageData('page.Chapter.table.url'), 22 | render: (text: string): JSX.Element => ( 23 | 24 | {text} 25 | 26 | ), 27 | }, 28 | 29 | { 30 | dataIndex: 'area', 31 | title: getLanguageData('page.Chapter.table.area'), 32 | }, 33 | { 34 | dataIndex: 'category', 35 | title: getLanguageData('page.Chapter.table.category'), 36 | }, 37 | { 38 | dataIndex: 'cover', 39 | title: getLanguageData('page.Chapter.table.cover'), 40 | render: (text: string): JSX.Element | null => 41 | text ? ( 42 | 43 | 44 | 45 | ) : null, 46 | }, 47 | { 48 | dataIndex: 'create_time', 49 | title: getLanguageData('page.Chapter.table.create_time'), 50 | render: renderDate, 51 | }, 52 | ]; 53 | export const chapterColumns = [ 54 | { 55 | dataIndex: 'id', 56 | title: 'ID', 57 | }, 58 | { 59 | dataIndex: 'title', 60 | title: getLanguageData('page.Chapter.table.title'), 61 | }, 62 | { 63 | dataIndex: 'url', 64 | title: getLanguageData('page.Chapter.table.url'), 65 | render: (text: string): JSX.Element => ( 66 | 67 | {text} 68 | 69 | ), 70 | }, 71 | { 72 | dataIndex: 'page_size', 73 | title: getLanguageData('page.Images.table.page_size'), 74 | }, 75 | { 76 | dataIndex: 'create_time', 77 | title: getLanguageData('page.Chapter.table.create_time'), 78 | render: renderDate, 79 | }, 80 | ]; 81 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | import request from '../utils/request'; 2 | 3 | const requestPrefix = '/v1'; 4 | 5 | interface PostData { 6 | url: string; 7 | type: string; 8 | name: string; 9 | page_size?: number; 10 | } 11 | 12 | export async function getMenuList(): Promise { 13 | return request(`${requestPrefix}/menu`); 14 | } 15 | // eslint-disable-next-line 16 | export async function postItem(params: PostData): Promise { 17 | const { url = '', ...rest } = params; 18 | if (!url) { 19 | return null; 20 | } 21 | const realUrl = `${requestPrefix}/${url}`; 22 | return request(realUrl, { 23 | method: 'POST', 24 | body: rest, 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /src/store/base.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export interface ContainerProviderProps { 4 | initialState?: State; 5 | children: React.ReactNode; 6 | } 7 | 8 | export interface Container { 9 | Provider: React.ComponentType>; 10 | useContainer: () => Value; 11 | } 12 | 13 | export function createContainer( 14 | useHook: (initialState?: State) => Value, 15 | ): Container { 16 | const Context = React.createContext(null); 17 | 18 | function Provider(props: ContainerProviderProps) { 19 | const value = useHook(props.initialState); 20 | return ( {props.children} ); 21 | } 22 | 23 | function useContainer(): Value { 24 | const value = React.useContext(Context); 25 | if (value === null) { 26 | throw new Error('Component must be wrapped with '); 27 | } 28 | return value; 29 | } 30 | 31 | return { Provider, useContainer }; 32 | } 33 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { createContainer } from './base'; 3 | 4 | function useLanguage( 5 | initLoading = true 6 | ): { 7 | toggleLoading: () => void; 8 | isLoading: boolean; 9 | } { 10 | const [isLoading, setIsLoading] = useState(initLoading); 11 | const toggleLoading = () => { 12 | setIsLoading(!isLoading); 13 | }; 14 | useEffect(() => { 15 | setTimeout(() => { 16 | setIsLoading(false); 17 | }, 5000); 18 | }, []); 19 | return { 20 | toggleLoading, 21 | isLoading, 22 | }; 23 | } 24 | 25 | const Store = createContainer(useLanguage); 26 | 27 | export default Store; 28 | -------------------------------------------------------------------------------- /src/type/index.ts: -------------------------------------------------------------------------------- 1 | interface Params { 2 | name: string; 3 | page_size?: number; 4 | } 5 | 6 | export interface SharedState { 7 | currentUrl: string; 8 | params: Params; 9 | } 10 | 11 | export interface MenuItem { 12 | value: string; 13 | enabled: boolean; 14 | name: string; 15 | } 16 | 17 | export interface IFormData { 18 | url: string; 19 | name: string; 20 | } 21 | 22 | export interface IOptionData { 23 | value: string | number; 24 | name: string; 25 | enabled: boolean; 26 | } 27 | 28 | export enum TypeConfig { 29 | chapter = 'chapter', 30 | downloadAll = 'downloadAll', 31 | download = 'images', 32 | search = 'search', 33 | result = 'result', 34 | } 35 | -------------------------------------------------------------------------------- /src/utils/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { renderDate, addZero } from '../index'; 2 | 3 | describe('addZero', () => { 4 | it('addZero should return right result', () => { 5 | expect(addZero(0)).toBe('00'); 6 | expect(addZero(9)).toBe('09'); 7 | expect(addZero(10)).toBe('10'); 8 | expect(addZero(1000)).toBe('1000'); 9 | }); 10 | }); 11 | 12 | describe('renderDate', () => { 13 | it('renderDate should return right result', () => { 14 | expect(renderDate('2019-7-10')).toBe('2019-07-10 00:00:00'); 15 | }); 16 | it('renderDate should handle error input', () => { 17 | expect(renderDate('veve')).toBe(''); 18 | expect(renderDate('444444')).toBe(''); 19 | expect(renderDate(99915626880000000)).toBe(''); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'qs'; 2 | import { createBrowserHistory, History } from 'history'; 3 | 4 | export function addZero(temp: number): string { 5 | if (temp < 10) { 6 | return `0${temp}`; 7 | } 8 | return `${temp}`; 9 | } 10 | 11 | /** 12 | * 统一格式化时间 13 | * @param temp 14 | */ 15 | export function renderDate(temp: number | string): string { 16 | let date: Date; 17 | try { 18 | date = new Date(temp); 19 | } catch (e) { 20 | return ''; 21 | } 22 | const year: number = date.getFullYear(); 23 | if (Number.isNaN(year)) { 24 | return ''; 25 | } 26 | const month: number = date.getMonth() + 1; 27 | const day: number = date.getDate(); 28 | const hour: number = date.getHours(); 29 | const minute: number = date.getMinutes(); 30 | const second: number = date.getSeconds(); 31 | return `${year}-${addZero(month)}-${addZero(day)} ${addZero(hour)}:${addZero( 32 | minute 33 | )}:${addZero(second)}`; 34 | } 35 | 36 | export function getQuery(search: string) { 37 | const [, data] = search.split('?'); 38 | return parse(data); 39 | } 40 | 41 | export const history: History = createBrowserHistory({ 42 | basename: '/', 43 | }); 44 | -------------------------------------------------------------------------------- /src/utils/request.ts: -------------------------------------------------------------------------------- 1 | import { notification } from 'antd'; 2 | import { getLanguageData } from '../locales'; 3 | 4 | function checkStatus(response: JsObject): JsObject | Error { 5 | if (response.status >= 200 && response.status < 300) { 6 | return response; 7 | } 8 | const error = new Error(); 9 | error.name = response.status; 10 | error.message = `${getLanguageData('utils.request.status.error')} ${ 11 | response.status 12 | }: ${response.url}`; 13 | throw error; 14 | } 15 | 16 | const contentType = { 17 | 'Content-Type': 'application/json; charset=utf-8', 18 | }; 19 | 20 | /** 21 | * 22 | * 请求数据,返回promise 23 | * @export 24 | * @param {请求链接 string} url 25 | * @param {请求选项 object} options 26 | * @returns 27 | */ 28 | export default function request(url: string, options = {}) { 29 | const defaultOptions = { 30 | credentials: 'include', 31 | }; 32 | const newOptions: JsObject = { 33 | ...defaultOptions, 34 | ...options, 35 | }; 36 | if ( 37 | newOptions.method === 'POST' || 38 | newOptions.method === 'PUT' || 39 | newOptions.method === 'DELETE' 40 | ) { 41 | if (!(newOptions.body instanceof FormData)) { 42 | newOptions.headers = { 43 | Accept: 'application/json', 44 | ...contentType, 45 | ...newOptions.headers, 46 | }; 47 | newOptions.body = JSON.stringify(newOptions.body); 48 | } else { 49 | newOptions.headers = { 50 | Accept: 'application/json', 51 | ...newOptions.headers, 52 | }; 53 | } 54 | } 55 | return window.fetch(url, newOptions) 56 | .then(checkStatus) 57 | .then((response: JsObject) => response.json()) 58 | .catch((error: Error) => { 59 | const title = 60 | getLanguageData(`utils.request.${error.name}`) || 61 | error.name || 62 | getLanguageData('utils.request.status.error'); 63 | if (process.env.NODE_ENV === 'development') { 64 | return; 65 | } 66 | notification.error({ 67 | description: error.message, 68 | duration: 3, 69 | message: title, 70 | }); 71 | }); 72 | } 73 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "target": "ESNext", 5 | "moduleResolution": "node", 6 | "importHelpers": true, 7 | "jsx": "react", 8 | "esModuleInterop": true, 9 | "emitDecoratorMetadata": true, 10 | "experimentalDecorators": true, 11 | "sourceMap": true, 12 | "baseUrl": ".", 13 | "strict": true, 14 | "noImplicitAny": true, 15 | "alwaysStrict": true, 16 | "allowSyntheticDefaultImports": true, 17 | "typeRoots": [ 18 | "node_modules/@types" 19 | ] 20 | }, 21 | "include": [ 22 | "src", 23 | "global.d.ts" 24 | ] 25 | } 26 | --------------------------------------------------------------------------------