├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .gitignore ├── .jsbeautifyrc ├── .prettierrc.js ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── README_CN.md ├── babel.config.js ├── build ├── webpack.base.conf.js ├── webpack.dev.conf.js ├── webpack.prod.conf.js └── webpack.test.conf.js ├── cSpell.json ├── core ├── app.vue ├── assets │ └── value │ │ ├── String.js │ │ ├── bookInstruction.js │ │ ├── instruction.js │ │ ├── tags.js │ │ └── version.js ├── bean │ ├── DialogBean.ts │ ├── DialogOperation.ts │ ├── ImgPageInfo.ts │ ├── ServerMessage.ts │ └── ThumbInfo.ts ├── components │ ├── AlbumBookView.vue │ ├── AlbumScrollView.vue │ ├── LoadingView.vue │ ├── ModalManager.vue │ ├── PageView.vue │ ├── ReaderView.vue │ ├── ThumbScrollView.vue │ ├── TopBar.vue │ ├── base │ │ └── AwesomeScrollView.vue │ └── widget │ │ ├── CircleIconButton.vue │ │ ├── DropOption.vue │ │ ├── FlatButton.vue │ │ ├── Pagination.vue │ │ ├── PopSlider.vue │ │ ├── Popover.vue │ │ ├── SimpleDialog.vue │ │ ├── SimpleSwitch.vue │ │ └── Slider.vue ├── index.js ├── launcher.js ├── service │ ├── AlbumService.ts │ ├── InfoService.ts │ ├── PlatformService.js │ ├── SettingService.ts │ ├── StringService.ts │ ├── request │ │ ├── MultiAsyncReq.ts │ │ ├── ReqQueue.ts │ │ └── TextReq.ts │ └── storage │ │ ├── LocalStorage.ts │ │ ├── SyncStorage.ts │ │ └── base │ │ └── Storage.js ├── store │ ├── index.js │ ├── modules │ │ ├── AlbumView.js │ │ ├── Modal.js │ │ └── String.js │ └── mutation-types.js ├── style │ ├── _markdown.scss │ ├── _normalize.scss │ ├── _responsive.scss │ └── _variables.scss └── utils │ ├── DateUtil.js │ ├── DateWrapper.js │ ├── Logger.js │ ├── MdRenderer.js │ ├── Utils.ts │ ├── VueUtil.js │ ├── bezier-easing.js │ └── formatter.js ├── github_image ├── github_preview_1.jpg ├── github_preview_2.jpg ├── github_preview_3.jpg ├── github_preview_4.png ├── github_preview_5_1.png ├── keyboard_arrow.jpg ├── language.jpg ├── open_ehunter.jpg └── topbar_close.jpg ├── ipad_cn.md ├── ipad_en.md ├── package-lock.json ├── package.json ├── src ├── assets │ ├── img │ │ └── ehunter_icon.png │ └── unused │ │ ├── ehunter_icon_draft1.psd │ │ ├── ehunter_icon_draft2.png │ │ ├── ehunter_icon_draft2.psd │ │ ├── ehunter_icon_v1.png │ │ ├── ehunter_icon_v2_128px.psd │ │ └── ehunter_icon_v2_origin.psd ├── config.js ├── legacy │ ├── app.popup.vue │ ├── background.js │ ├── components │ │ └── Notification.vue │ ├── index.popup.html │ ├── service │ │ ├── NotificationService.js │ │ ├── PlatformService.js │ │ ├── parser │ │ │ └── SearchHtmlParser.js │ │ ├── request │ │ │ ├── MultiAsyncReqService.js │ │ │ ├── ReqQueueService.js │ │ │ └── TextReqService.js │ │ ├── storage │ │ │ ├── NotiStorage.js │ │ │ ├── SubsStorage.js │ │ │ └── base.js │ │ └── type │ │ │ ├── GalleryType.js │ │ │ └── UpdateInterval.js │ ├── style │ │ ├── _responsive.scss │ │ ├── _variables.scss │ │ └── muse-ui │ │ │ ├── colors.less │ │ │ ├── index.less │ │ │ ├── muse-ui.less │ │ │ ├── theme-color.less │ │ │ ├── theme-vars.less │ │ │ └── theme.less │ └── utils │ │ ├── DateUtil.js │ │ ├── DateWrapper.js │ │ ├── Logger.js │ │ ├── Utils.js │ │ ├── VueUtil.js │ │ └── lang.js ├── main.inject.js ├── main.popup.js ├── manifest.js └── platform │ ├── base │ ├── index.ts │ ├── request │ │ ├── MultiAsyncReq.ts │ │ ├── ReqQueue.ts │ │ └── TextReq.ts │ └── service │ │ └── PlatformService.js │ ├── eh │ ├── api.ts │ ├── index.ts │ ├── parser │ │ ├── ImgHtmlParser.ts │ │ ├── ImgUrlListParser.ts │ │ └── IntroHtmlParser.ts │ └── service │ │ ├── AlbumCacheService.ts │ │ └── AlbumServiceImpl.ts │ └── nh │ ├── index.ts │ ├── parser │ ├── ImgHtmlParser.ts │ └── IntroHtmlParser.ts │ └── service │ └── AlbumServiceImpl.ts ├── test └── unit │ ├── .eslintrc │ ├── index.js │ ├── karma.conf.js │ ├── specs │ ├── service.AlbumService.spec.js │ └── widget.CircleIconButton.spec.js │ └── util.js ├── tsconfig.json ├── update.json ├── update.md ├── yarn-error.log └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | build/*.js 2 | config/*.js 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: 'babel-eslint', 4 | parserOptions: { 5 | sourceType: 'module' 6 | }, 7 | // https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style 8 | extends: 'standard', 9 | // required to lint *.vue files 10 | plugins: ['html'], 11 | // add your custom rules here 12 | rules: { 13 | 'space-before-function-paren': 0, 14 | semi: 0, 15 | indent: ['error', 4, { SwitchCase: 1, MemberExpression: 1 }], 16 | // allow paren-less arrow functions 17 | 'arrow-parens': 0, 18 | // allow async-await 19 | 'generator-star-spacing': 0, 20 | // allow debugger during development 21 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0, 22 | outerIIFEBody: 0 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | src/* linguist-vendored 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/ 4 | npm-debug.log 5 | test/unit/coverage 6 | test/e2e/reports 7 | .vscode 8 | *.zip 9 | web-ext-artifacts 10 | .idea 11 | dist.pem 12 | *.crx 13 | *.xpi -------------------------------------------------------------------------------- /.jsbeautifyrc: -------------------------------------------------------------------------------- 1 | { 2 | "indent_with_tabs": false, 3 | "max_preserve_newlines": 2, 4 | "preserve_newlines": true, 5 | "keep_array_indentation": false, 6 | "break_chained_methods": false, 7 | "wrap_line_length": 200, 8 | "end_with_newline": true, 9 | "brace_style": "collapse,preserve-inline", 10 | "unformatted": ["a", "abbr", "area", "audio", "b", "bdi", "bdo", "br", "button", "canvas", "cite", "code", "data", 11 | "datalist", "del", "dfn", "em", "embed", "i", "iframe", "img", "input", "ins", "kbd", "keygen", "label", "map", 12 | "mark", "math", "meter", "noscript", "object", "output", "progress", "q", "ruby", "s", "samp", "select", "small", 13 | "span", "strong", "sub", "sup", "template", "textarea", "time", "u", "var", "video", "wbr", "text", "acronym", 14 | "address", "big", "dt", "ins", "small", "strike", "tt", "pre", "h1", "h2", "h3", "h4", "h5", "h6" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | parser: 'flow', 4 | tabWidth: 4, 5 | singleQuote: true 6 | }; 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible Node.js debug attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "launch", 10 | "name": "Launch Chrome", 11 | "url": "https://exhentai.org/s/7d2bc55781/1038240-1", 12 | "webRoot": "${workspaceRoot}" 13 | }, 14 | { 15 | "type": "node", 16 | "request": "launch", 17 | "name": "启动程序", 18 | "program": "${file}" 19 | }, 20 | { 21 | "type": "node", 22 | "request": "attach", 23 | "name": "附加到进程", 24 | "address": "localhost", 25 | "port": 5858 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "vsicons.presets.angular": false, 3 | "eslint.enable": true 4 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Alex 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 | [中文版](https://github.com/hanFengSan/eHunter/blob/master/README_CN.md) 2 | # eHunter 3 | Provide a scroll mode and book mode, for a better reading experience. 4 | 5 | # Preview 6 | ![avatar](https://github.com/hanFengSan/eHunter/blob/master/github_image/github_preview_4.png?raw=true) 7 | 8 | ![avatar](https://github.com/hanFengSan/eHunter/blob/master/github_image/github_preview_5_1.png?raw=true) 9 | 10 | ![avatar](https://raw.githubusercontent.com/hanFengSan/eHunter/master/github_image/github_preview_3.jpg) 11 | 12 | # Use in iPad 13 | You can follow the guide to use eHunter in iPad: 14 | CN: [Link](https://github.com/hanFengSan/eHunter/blob/master/ipad_cn.md) 15 | EN: [Link](https://github.com/hanFengSan/eHunter/blob/master/ipad_en.md) 16 | 17 | ## Implementation 18 | It creates a new element in the Eh page, and inject Vue components to provide a scroll mode and book mode. 19 | 20 | ## Install 21 | Tampermonkey: [openuserjs](https://openuserjs.org/scripts/alexchen/eHunter) 22 | Chrome: coming soon 23 | Firefox: Coming soon 24 | 25 | You also can get it from the 'release' of this project. 26 | 27 | ## Run 28 | 1. In a Node environment, run `npm install`, and `npm run dev`, then you will in dev mode. 29 | 2. In the top of `chrome://extensions`, open the develop mode, and select the `/dist`. 30 | 3. Run `npm run publish` to package a zip file in `/publish_output` for the web store of Chrome and Firefox. 31 | 4. Tampermonkey: run `npm run build`, and the `/dist/inject.js` is target, just use it. 32 | 5. Run `npm run test` to test. 33 | 34 | ## Structure 35 | ``` 36 | |-eHunter 37 | |-build 38 | |-gulpfile.js // gulp file for packaging 39 | |-webpack.dev.conf.js // webpack file for dev 40 | |-webpack.prod.conf.js // webpack file for prod 41 | |-dist // the directory of release 42 | |-src 43 | |-assets // resources 44 | |-img // images 45 | |-value 46 | |-String.js // for i18n 47 | |-tags.js // tags 48 | |-version.js // the informations of update in this version 49 | |-bean // bean classes 50 | |-components // Vue components 51 | |-widget // button, pagination, switch etc.. 52 | |-AlbumBookView.vue // the component of book mode 53 | |-AlbumScrollView.vue // the component of scorll mode 54 | |-ModalManager.vue // manage dialogs 55 | |-PageView.vue // the component of page, loading in AlbumBookView and AlbumScrollView 56 | |-ReaderView.vue // the component of reader,including of AlbumBookView, AlbumScrollView,ThumbScrollview and TopBar 57 | |-ThumbScrollview.vue // the component of thumbnail column 58 | |-TopBar.vue // top bar 59 | |-service 60 | |-parser // the parseres of Eh pages 61 | |-request // the request classes. 62 | |-storage 63 | |-Base 64 | |-Stroage.js // extend from react-native-storage, supporting chrome.storage. 65 | |-AlbumCacheService.js // cache the urls of images, the size of images. 66 | |-LocalStorage.js // wrap Storage.js,basing on the window.localStorage 67 | |-SyncStorage.js // wrap Storage.js,basing on the chrome.storage.sync. It can sync the datas with Cloud of Google. 68 | |-api.js // the api of Eh 69 | |-InfoService.js // show the dialog of instructions, the dialog of update, etc.. 70 | |-SettingServie.js // save settings and get 71 | |-PlatformService.js // some apis, for cross platfroms 72 | |-StringService.js // provide strings of i18n 73 | |-store // Vuex 74 | |-style // the variables of sass, and the style of Markdown 75 | |-utils 76 | |-bezier-easing.js // using Cubic Bezier in the scroll of scroll mode 77 | |-MdRenderer.js // the renderer of Markdown 78 | |-VueUtil.js // add some frequently-used functions in Vue 79 | |-app.inject.vue // the main components of Vue 80 | |-app.popup.vue // the main components of Vue in popup window 81 | |-main.inject.js // the entry of webpeck and some earlier stage processing before injecting view of Vue. 82 | |-main.popup.js // the entry of webpeck. in popup window. 83 | |-config.js // version and update server 84 | |-mainifest.json // the mainifest for chrome and firefox extension 85 | ``` -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 | # eHunter 2 | 提供卷轴式/书本式阅读 3 | 4 | # 预览 5 | 6 | 7 | 8 | 9 | # 在iPad上使用 10 | 现在可以在iPad上使用eHunter了!可参考以下指南: 11 | CN: [Link](https://github.com/hanFengSan/eHunter/blob/master/ipad_cn.md) 12 | EN: [Link](https://github.com/hanFengSan/eHunter/blob/master/ipad_en.md) 13 | 14 | ## 实现方式概要 15 | 在在原页面上新创建一个节点, 将vue注入到此节点上. 爬虫是利用fetch实现的. 16 | 实现上基本隔离了具体环境, 可很容易得移植到其他漫画网站/平台等. 17 | 18 | ## 获取 19 | 油猴版本: [openuserjs](https://openuserjs.org/scripts/alexchen/eHunter) 20 | Chrome版本: 新版本即将上架 21 | Firefox版本: 新版本即将上架 22 | 23 | 24 | ## 运行 25 | 1. `npm install`后, 再`npm run dev`就可以进入dev模式了(当然,我个人喜好用yarn). 26 | 2. 在`chrome://extensions`页面顶部打开开发者模式, 选择项目的`/dist`文件夹就OK了. 27 | 3. `npm run publish`可以直接生成chrome&firefox用的zip压缩文件到`publish_output`文件夹. 28 | 4. 油猴的话, `npm run build`后, `/dist/inject.js`就是目标文件. 29 | 5. 运行`npm run test`执行单元测试. 30 | 31 | 32 | ## 项目结构 33 | 由于v1.0升级到v2.0时,有些弃用的功能以及相关文件,比如订阅通知等,所以有些杂物并未投入使用,以下不会说明。 34 | ``` 35 | |-eHunter 36 | |-build 37 | |-gulpfile.js // 部署用的gulp脚本 38 | |-webpack.dev.conf.js // 开发中打包用的webpack脚本 39 | |-webpack.prod.conf.js // 生产中打包用的webpack脚本 40 | |-dist // release文件夹 41 | |-src 42 | |-assets // 资源文件夹 43 | |-img // image files 44 | |-value 45 | |-String.js // 多语言化 46 | |-tags.js // 标志 47 | |-version.js // 新版本更新信息 48 | |-bean // bean类 49 | |-components // vue组件 50 | |-widget // 按钮、分页组件、开关等小组件 51 | |-AlbumBookView.vue // 书本模式组件 52 | |-AlbumScrollView.vue // 滚动模式组件 53 | |-ModalManager.vue // 弹窗管理组件 54 | |-PageView.vue // 图片页组件, 装载于AlbumBookView和AlbumScrollView之中 55 | |-ReaderView.vue // 阅读器组件,载入AlbumBookView、AlbumScrollView、ThumbScrollview和TopBar 56 | |-ThumbScrollview.vue // 滚动缩略图栏组件 57 | |-TopBar.vue // 顶栏组件 58 | |-service // 业务类 59 | |-parser // 解析页面用的各种praser类 60 | |-request // 异步请求队列序列化/请求失败自动重试等功能的请求服务类 61 | |-storage 62 | |-Base 63 | |-Stroage.js // 继承于react-native-storage,使得支持chrome.storage. 目前弃用了chrome.storage作为底层,而是使用window.localStorage 64 | |-AlbumCacheService.js // 实现画廊中图片地址、图片数量、图片高宽等的缓存,加速浏览。队列化存储,支持10个画廊的缓存。 65 | |-LocalStorage.js // 封装Storage.js,使用window.localStorage为底层 66 | |-SyncStorage.js // 封装Storage.js,使用chrome.storage.sync为底层, 可在chrome上实现云端同步数据 67 | |-api.js // 请求url的封装 68 | |-InfoService.js // 消息服务类,实现弹窗用户引导等 69 | |-SettingServie.js // 设置服务类 70 | |-PlatformService.js // 平台接口的隔离层, 用于屏蔽各平台api上的差异 71 | |-StringService.js // 提供多语言化 72 | |-store // vuex相关 73 | |-style // sass的变量以及markdown样式 74 | |-utils // 工具类 75 | |-bezier-easing.js // 二次贝塞尔曲线生成,用于滚动模式的滚动 76 | |-MdRenderer.js // md解析类 77 | |-VueUtil.js // vue的一些常用操作封装 78 | |-app.inject.vue // vue主组件 79 | |-app.popup.vue // 弹出框的vue主组件 80 | |-background.js // chrome的后台任务, 目前并未使用 81 | |-main.inject.js // webpack入口; vue注入前的前期处理 82 | |-main.popup.js // webpack入口 83 | |-config.js // 定义版本和更新查询接口 84 | |-mainifest.json // chrome&firefox extension的文件/权限/说明用的清单 85 | ``` 86 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | chrome: '42', 8 | firefox: '42' 9 | } 10 | } 11 | ] 12 | ], 13 | plugins: ['@babel/transform-runtime'] 14 | } 15 | -------------------------------------------------------------------------------- /build/webpack.base.conf.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const CleanWebpackPlugin = require('clean-webpack-plugin') 4 | const CopyWebpackPlugin = require('copy-webpack-plugin') 5 | const GenerateJsonPlugin = require('generate-json-webpack-plugin'); 6 | const VueLoaderPlugin = require('vue-loader/lib/plugin') 7 | 8 | function resolve(dir) { 9 | return path.join(__dirname, '..', dir) 10 | } 11 | 12 | module.exports = { 13 | entry: { 14 | popup: resolve('src/main.popup.js'), 15 | inject: resolve('src/main.inject.js'), 16 | background: resolve('src/legacy/background.js') 17 | }, 18 | output: { 19 | path: resolve('dist'), 20 | filename: '[name].js' 21 | }, 22 | module: { 23 | rules: [{ 24 | test: /\.vue$/, 25 | use: 'vue-loader' 26 | }, 27 | { 28 | test: /\.js$/, 29 | loader: 'babel-loader', 30 | include: [ 31 | resolve('node_modules/react-native-storage'), 32 | resolve('src'), 33 | resolve('core'), 34 | resolve('test') 35 | ] 36 | }, 37 | { 38 | test: /\.tsx?$/, 39 | loader: 'ts-loader', 40 | exclude: /node_modules/, 41 | options: { 42 | appendTsSuffixTo: [/\.vue$/] 43 | } 44 | }, 45 | { 46 | test: /\.(png|jpg|gif|svg)$/, 47 | loader: 'file-loader', 48 | options: { 49 | name: '[name].[ext]?[hash]' 50 | } 51 | }, 52 | { 53 | test: /\.less$/, 54 | use: [ 55 | 'vue-style-loader', 56 | 'css-loader', 57 | 'less-loader' 58 | ] 59 | }, 60 | { 61 | test: /\.scss$/, 62 | use: [ 63 | 'style-loader', 64 | 'css-loader', 65 | 'sass-loader' 66 | ] 67 | }, 68 | { 69 | test: /\.css$/, 70 | use: [{ 71 | loader: 'vue-style-loader' 72 | }, 73 | { 74 | loader: 'css-loader', 75 | options: { 76 | modules: true, 77 | localIdentName: '[local]_[hash:base64:8]' 78 | } 79 | } 80 | ] 81 | }, 82 | { 83 | test: /\.html$/, 84 | loader: 'html-loader' 85 | } 86 | ] 87 | }, 88 | resolve: { 89 | extensions: ['.ts', '.js', '.vue', '.json'], 90 | alias: { 91 | 'vue$': 'vue/dist/vue.esm.js', 92 | 'src': resolve('src'), 93 | 'core': resolve('core') 94 | } 95 | }, 96 | plugins: [ 97 | new VueLoaderPlugin(), 98 | // new CleanWebpackPlugin({ 99 | // verbose: true, 100 | // cleanOnceBeforeBuildPatterns: ['*'] 101 | // }), 102 | new CopyWebpackPlugin([ 103 | { from: resolve('src/assets/img'), to: resolve('dist/img') } 104 | ]), 105 | new GenerateJsonPlugin('manifest.json', require(resolve('src/manifest')).chrome) 106 | ] 107 | } 108 | -------------------------------------------------------------------------------- /build/webpack.dev.conf.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var webpack = require('webpack') 3 | var HtmlWebpackPlugin = require('html-webpack-plugin') 4 | var merge = require('webpack-merge') 5 | let baseWebpackConfig = require('./webpack.base.conf') 6 | 7 | function resolve(dir) { 8 | return path.join(__dirname, '..', dir) 9 | } 10 | 11 | module.exports = merge(baseWebpackConfig, { 12 | watch: true, 13 | mode: 'development', 14 | plugins: [ 15 | new HtmlWebpackPlugin({ 16 | filename: 'popup.html', 17 | template: 'src/legacy/index.popup.html', 18 | inject: true, 19 | chunks: ['popup'] 20 | }), 21 | new webpack.BannerPlugin({ 22 | banner: require(resolve('src/manifest')).tampermonkey, 23 | raw: true, 24 | entryOnly: true, 25 | include: /inject\.js/ 26 | }) 27 | ] 28 | }) 29 | -------------------------------------------------------------------------------- /build/webpack.prod.conf.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const HtmlWebpackPlugin = require('html-webpack-plugin') 4 | const merge = require('webpack-merge') 5 | const baseWebpackConfig = require('./webpack.base.conf') 6 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin') 7 | 8 | function resolve(dir) { 9 | return path.join(__dirname, '..', dir) 10 | } 11 | 12 | module.exports = merge(baseWebpackConfig, { 13 | mode: 'production', 14 | plugins: [ 15 | new UglifyJsPlugin({ 16 | uglifyOptions: { 17 | compress: { 18 | warnings: false 19 | } 20 | }, 21 | sourceMap: false, 22 | parallel: true 23 | }), 24 | new HtmlWebpackPlugin({ 25 | filename: 'popup.html', 26 | template: 'src/legacy/index.popup.html', 27 | inject: true, 28 | chunks: ['popup'], 29 | minify: { 30 | removeComments: true, 31 | collapseWhitespace: true, 32 | removeAttributeQuotes: true 33 | } 34 | }) 35 | // new webpack.BannerPlugin({ 36 | // banner: require(resolve('src/manifest')).tampermonkey, 37 | // raw: false, 38 | // entryOnly: true, 39 | // include: /inject\.js/ 40 | // }) 41 | ] 42 | }) 43 | 44 | -------------------------------------------------------------------------------- /build/webpack.test.conf.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const merge = require('webpack-merge') 3 | const baseWebpackConfig = require('./webpack.base.conf') 4 | 5 | const webpackConfig = merge(baseWebpackConfig, { 6 | mode: 'testing', 7 | // use inline sourcemap for karma-sourcemap-loader 8 | devtool: '#inline-source-map' 9 | }); 10 | // no need for app entry during tests 11 | delete webpackConfig.entry; 12 | module.exports = webpackConfig; 13 | -------------------------------------------------------------------------------- /cSpell.json: -------------------------------------------------------------------------------- 1 | // cSpell Settings 2 | { 3 | // Version of the setting file. Always 0.1 4 | "version": "0.1", 5 | // language - current active spelling language 6 | "language": "en", 7 | // words - list of words to be always considered correct 8 | "words": [ 9 | "uconfig", 10 | "ehentai's", 11 | "WenQuanYi", 12 | "\"./img/ehunter_icon.png\"", 13 | "Doujishi", 14 | "ehunter", 15 | "isActivedPopView", 16 | "Actived" 17 | ], 18 | // flagWords - list of words to be always considered incorrect 19 | // This is useful for offensive words and common spelling errors. 20 | // For example "hte" should be "the" 21 | "flagWords": [ 22 | "hte" 23 | ] 24 | } -------------------------------------------------------------------------------- /core/assets/value/bookInstruction.js: -------------------------------------------------------------------------------- 1 | export default { 2 | cn: ` 3 | 支持\`A\`. \`D\`, \`Left(左)\`, \`Right(右)\`和\`Space(空格)\`键或者鼠标滚轮翻页. 4 | `, 5 | en: ` 6 | You can use the keyboard's \`A\`, \`D\`, \`Left\`, \`Right\` and \`Space\` keys or mouse wheel to page. 7 | `, 8 | jp: ` 9 | You can use the keyboard's \`A\`, \`D\`, \`Left\`, \`Right\` and \`Space\` keys or mouse wheel to page. 10 | ` 11 | } 12 | -------------------------------------------------------------------------------- /core/assets/value/instruction.js: -------------------------------------------------------------------------------- 1 | export default { 2 | cn: ` 3 | 1.Change language/切换语言/言語を変更 4 | ![image-language](https://raw.githubusercontent.com/hanFengSan/eHunter/master/github_image/language.jpg) 5 | 6 | 1.显示/隐藏顶栏和关闭eHunter 7 | ![image-topbar_close](https://raw.githubusercontent.com/hanFengSan/eHunter/master/github_image/topbar_close.jpg) 8 | 9 | 2.在页面右上角点击打开eHunter 10 | ![image-open_ehunter](https://raw.githubusercontent.com/hanFengSan/eHunter/master/github_image/open_ehunter.jpg) 11 | 12 | 3.\`滚动\`模式下, 支持\`A\`. \`D\`, \`Left(左)\`和\`Right(右)\`键翻页. 13 | 14 | 4.\`书页\`模式下, 支持\`A\`. \`D\`, \`Left(左)\`, \`Right(右)\`和\`Space(空格)\`键翻页. 你也可以用鼠标滚轮翻页. 15 | 16 | 5.\`分卷页数\`对性能要求较高,请不要设置过高,可能会导致卡顿. 17 | 18 | 6.在前页采用\`Normal\`模式查看缩略图可加速加载, 使用\`Large\`模式会慢一些. 19 | 20 | 7.有更多想要的功能, 可以反馈给我, 如果该功能可以有的话, 我有空的时候会支持的. 21 | 22 | ### eHunter-local 23 | eHunter-local是eHunter的本地版本, 支持Windows和MacOS. [项目主页](https://github.com/hanFengSan/eHunter_local) 24 | 25 | [Github下载](https://github.com/hanFengSan/eHunter_local/releases) [百度网盘](https://pan.baidu.com/s/1wEnBe9uGoBKzNd4DCfbuAg) 提取码: czft 26 | 27 | 28 | ### 反馈和建议 29 | * 可在[Github]($$HOME_PAGE$$)上开issue给我. 30 | * 可发邮件到我邮箱: c360785655@gmail.com 31 | 32 | ### 关于 33 | * 版本: $$VERSION$$ 34 | * 作者: Alex Chen (hanFeng) 35 | * 项目开源地址: [Github]($$HOME_PAGE$$) 36 | 37 | 如果你喜欢此插件的话,希望能在应用商店上给个好评 8-) 38 | `, 39 | en: ` 40 | 1.Change language/切换语言/言語を変更 41 | ![image-language](https://raw.githubusercontent.com/hanFengSan/eHunter/master/github_image/language.jpg) 42 | 43 | 1.Show/hide top bar and close the eHunter 44 | ![image-topbar_close](https://raw.githubusercontent.com/hanFengSan/eHunter/master/github_image/topbar_close.jpg) 45 | 46 | 2.Click the button at the upper right corner of this page to open the eHunter 47 | ![image-open_ehunter](https://raw.githubusercontent.com/hanFengSan/eHunter/master/github_image/open_ehunter.jpg) 48 | 49 | 3.When the \`Mode\` is \`Scroll\`, you can use the keyboard's \`A\`, \`D\`, \`Left\` and \`Right\` keys to page. 50 | 51 | 4.When the \`Mode\` is \`Book\`, you can use the keyboard's \`A\`, \`D\`, \`Left\`, \`Right\` and \`Space\` keys to page. You also can use mouse wheel to page. 52 | 53 | 5.This is a high performance requirements on \`Volume size\`. If too big, the program will be slow. 54 | 55 | 6.You can use \`Normal\` mode of thumbnail in previous page to accelerate the load. If it's \`Large\` mode, the loading will be slow a bit. 56 | 57 | 7.If you want EHunter to support more features, you can give me feedback. 58 | 59 | ### eHunter-local 60 | The eHunter-local is local version of eHunter, supporting Windows and MacOS. [Home Page](https://github.com/hanFengSan/eHunter_local) 61 | 62 | [Github releases](https://github.com/hanFengSan/eHunter_local/releases) 63 | 64 | ### Feedback & Suggestion 65 | * Create issue on [Github]($$HOME_PAGE$$) to me. 66 | * Send email to c360785655@gmail.com 67 | 68 | ### About 69 | * Version: $$VERSION$$ 70 | * Author: Alex Chen (hanFeng) 71 | * Home page of this project: [Github]($$HOME_PAGE$$) 72 | 73 | If you like this extension, I hope you can give a five-star rating in store. 8-) 74 | `, 75 | jp: ` 76 | 1.Change language/切换语言/言語を変更 77 | ![image-language](https://raw.githubusercontent.com/hanFengSan/eHunter/master/github_image/language.jpg) 78 | 79 | 1.トップバーを表示/非表示にしてeHunterを閉じる 80 | ![image-topbar_close](https://raw.githubusercontent.com/hanFengSan/eHunter/master/github_image/topbar_close.jpg) 81 | 82 | 2.このページの右上隅にあるボタンをクリックしてeHunterを開きます 83 | ![image-open_ehunter](https://raw.githubusercontent.com/hanFengSan/eHunter/master/github_image/open_ehunter.jpg) 84 | 85 | 3.When the \`Mode\` is \`Scroll\`, you can use the keyboard's \`A\`, \`D\`, \`Left\` and \`Right\` keys to page. 86 | 87 | 4.When the \`Mode\` is \`Book\`, you can use the keyboard's \`A\`, \`D\`, \`Left\`, \`Right\` and \`Space\` keys to page. マウスホイールを使用してページを移動することもできます。 88 | 89 | 5.これは\`ボリュームサイズ\`の高性能要件です。 大きすぎるとプログラムが遅くなります。 90 | 91 | 6.前のページのサムネイルの「Normal」モードを使用して負荷を高速化することができます。「Large」モードの場合は、読み込みが少し遅くなります。 92 | 93 | 7.あなたがEHunterにもっと多くの機能をサポートさせたいならば、あなたは私にフィードバックを与えることができます。 94 | 95 | ### eHunter-local 96 | eHunter-localはeHunterのローカル版で、WindowsとMacOSをサポートしています。[Home Page](https://github.com/hanFengSan/eHunter_local) 97 | 98 | [Github releases](https://github.com/hanFengSan/eHunter_local/releases) 99 | 100 | ### フィードバックと提案 101 | * 私にGITHUBのオープンな問題 [Github]($$HOME_PAGE$$) 102 | * c360785655@gmail.comにメールを送信する 103 | 104 | ### 〜について 105 | * バージョン: $$VERSION$$ 106 | * 著者: Alex Chen (hanFeng) 107 | * このプロジェクトのホームページ: [Github]($$HOME_PAGE$$) 108 | 109 | この拡張機能が気に入ったら、お店で5つ星の評価をつけてください。 8-) 110 | ` 111 | } 112 | -------------------------------------------------------------------------------- /core/assets/value/tags.js: -------------------------------------------------------------------------------- 1 | export const SCROLL_VIEW = 'SCROLL_VIEW'; 2 | export const SCROLL_VIEW_VOL = 'SCROLL_VIEW_VOL'; 3 | export const BOOK_VIEW = 'BOOK_VIEW'; 4 | export const THUMB_VIEW = 'THUMB_VIEW'; 5 | export const READER_VIEW = 'READER_VIEW'; 6 | export const TOP_BAR = 'TOP_BAR'; 7 | 8 | export const MODE_FAST = 'MODE_FAST'; 9 | export const MODE_ORIGIN = 'MODE_ORIGIN'; 10 | export const MODE_CHANGE_SOURCE = 'MODE_CHANGE_SOURCE'; 11 | 12 | export const ERROR_NO_ORIGIN = 'ERROR_NO_ORIGIN'; 13 | 14 | export const ID_START = 'ID_START'; 15 | export const ID_END = 'ID_END'; 16 | export const TYPE_NORMAL = 'TYPE_NORMAL'; 17 | export const TYPE_START = 'TYPE_START'; 18 | export const TYPE_END = 'TYPE_END'; 19 | 20 | export const LANG_EN = 'LANG_EN'; 21 | export const LANG_CN = 'LANG_CN'; 22 | export const LANG_JP = 'LANG_JP'; 23 | 24 | export const STATE_WAITING = 'STATE_WAITING'; 25 | export const STATE_LOADING = 'STATE_LOADING'; 26 | export const STATE_ERROR = 'STATE_ERROR'; 27 | export const STATE_LOADED = 'STATE_LOADED'; 28 | 29 | export const DIALOG_NORMAL = 'DIALOG_NORMAL'; 30 | export const DIALOG_COMPULSIVE = 'DIALOG_COMPULSIVE'; 31 | export const DIALOG_OPERATION_TYPE_PLAIN = 'DIALOG_OPERATION_PLAIN'; 32 | export const DIALOG_OPERATION_TYPE_NEGATIVE = 'DIALOG_OPERATION_TYPE_NEGATIVE'; 33 | export const DIALOG_OPERATION_TYPE_POSITIVE = 'DIALOG_OPERATION_TYPE_POSITIVE'; 34 | export const DIALOG_OPERATION_TYPE_WARNING = 'DIALOG_OPERATION_TYPE_WARNING'; 35 | 36 | export const TYPE_PROXY = 'TYPE_PROXY'; 37 | 38 | export const KEYBOARD = 'KEYBOARD'; 39 | -------------------------------------------------------------------------------- /core/assets/value/version.js: -------------------------------------------------------------------------------- 1 | export default { 2 | cn: ` 3 | * 修复exhentai/ehentai的页数解析问题 4 | * 现已支持大图缩略图 5 | 6 | ### iPad支持 7 | * 目前在iOS 15/iPadOS 15上可以运行油猴脚本了,因此eHunter也能成功在iPad上使用 8 | * 目前没有在UI上做移动端适配,因此比较适合iPad使用,看看大家的需求再决定要不要对iPhone做UI优化 9 | * 使用指南: [链接](https://github.com/hanFengSan/eHunter/blob/master/ipad_cn.md) 10 | 11 | ### eHunter-local 12 | eHunter-local是eHunter的本地版本, 支持Windows和MacOS. [项目主页](https://github.com/hanFengSan/eHunter_local) 13 | 14 | [Github下载](https://github.com/hanFengSan/eHunter_local/releases) [百度网盘](https://pan.baidu.com/s/1wEnBe9uGoBKzNd4DCfbuAg) 提取码: czft 15 | 16 | `, 17 | en: ` 18 | * Fixed the support issue of exhentai/ehentai 19 | * Support for large thumbnail mode 20 | 21 | ### Use in iPad 22 | * The greasymonkey/tampermonkey script can run on iOS 15/iPadOS 15, so the eHunter also can run on iPad now 23 | * I don't optimize the UX on mobile platform yet, so it may be have a bad UX on iPhone. 24 | * Guide: [Link](https://github.com/hanFengSan/eHunter/blob/master/ipad_en.md) 25 | 26 | ### eHunter-local 27 | The eHunter-local is local version of eHunter, supporting Windows and MacOS. [Home Page](https://github.com/hanFengSan/eHunter_local) 28 | 29 | [Github releases](https://github.com/hanFengSan/eHunter_local/releases) 30 | 31 | `, 32 | jp: ` 33 | * exhentai/ehentaiの問題を修正しました。 34 | * Support for large thumbnail mode 35 | 36 | ### Use in iPad 37 | * The greasymonkey/tampermonkey script can run on iOS 15/iPadOS 15, so the eHunter also can run on iPad now. 38 | * I don't optimize the UX on mobile platform yet, so it may be have a bad UX on iPhone. 39 | * Guide: [Link](https://github.com/hanFengSan/eHunter/blob/master/ipad_en.md) 40 | 41 | ### eHunter-local 42 | eHunter-localはeHunterのローカル版で、WindowsとMacOSをサポートしています。[Home Page](https://github.com/hanFengSan/eHunter_local) 43 | 44 | [Github releases](https://github.com/hanFengSan/eHunter_local/releases) 45 | ` 46 | } 47 | -------------------------------------------------------------------------------- /core/bean/DialogBean.ts: -------------------------------------------------------------------------------- 1 | import { DialogOperation } from './DialogOperation' 2 | 3 | export default class DialogBean { 4 | readonly id: number; 5 | type: string; 6 | title: string; 7 | text: string; 8 | operations: Array; 9 | constructor(type: string, title: string, text: string, ...operations: Array) { 10 | this.id = new Date().getTime(); 11 | this.type = type; 12 | this.title = title; 13 | this.text = text; 14 | this.operations = operations; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /core/bean/DialogOperation.ts: -------------------------------------------------------------------------------- 1 | export interface DOClick { (): void }; 2 | export class DialogOperation { 3 | name: string; 4 | type: string; 5 | onClick: DOClick; 6 | constructor(name: string, type: string, onClick: DOClick) { 7 | this.name = name; 8 | this.type = type; 9 | this.onClick = onClick; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /core/bean/ImgPageInfo.ts: -------------------------------------------------------------------------------- 1 | export interface ImgPageInfo { 2 | id: number | string, 3 | index: number, 4 | pageUrl: string, 5 | src: string, 6 | thumbStyle: string, 7 | heightOfWidth: number, 8 | thumbHeight?: number, 9 | thumbWidth?: number, 10 | preciseHeightOfWidth?: number 11 | } -------------------------------------------------------------------------------- /core/bean/ServerMessage.ts: -------------------------------------------------------------------------------- 1 | import store from '../store/index' 2 | 3 | interface MsgOperation { 4 | name: string; 5 | url: string; 6 | } 7 | 8 | interface UpdateMsg { 9 | title: string; 10 | version: string; 11 | text: string; 12 | operations: Array; 13 | time: number; 14 | always: boolean; 15 | duration: number; 16 | } 17 | 18 | interface I18nUpdateMsg { 19 | cn: UpdateMsg; 20 | en: UpdateMsg; 21 | jp: UpdateMsg; 22 | } 23 | 24 | export default class ServerMessage { 25 | title: string; 26 | version: string; 27 | text: string; 28 | operations: Array; 29 | time: number; 30 | always: boolean; 31 | duration: number; 32 | 33 | constructor(data: I18nUpdateMsg) { 34 | let message; 35 | switch (store.getters.string.lang) { 36 | case 'CN': 37 | message = data.cn; 38 | break; 39 | case 'JP': 40 | message = data.jp; 41 | break; 42 | case 'EN': 43 | default: 44 | message = data.en; 45 | } 46 | this.title = message.title; 47 | this.version = message.version; 48 | this.text = message.text; 49 | this.operations = message.operations; 50 | this.time = message.time; 51 | this.always = message.always; 52 | this.duration = message.duration; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /core/bean/ThumbInfo.ts: -------------------------------------------------------------------------------- 1 | export enum ThumbMode { 2 | SPIRIT = 0, 3 | IMG 4 | } 5 | 6 | export interface ThumbInfo { 7 | id: string | number, 8 | src: string; 9 | mode: ThumbMode, 10 | offset?: number, 11 | style: string, 12 | height: number, 13 | width: number, 14 | } -------------------------------------------------------------------------------- /core/components/LoadingView.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 32 | 33 | -------------------------------------------------------------------------------- /core/components/ModalManager.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 43 | 44 | -------------------------------------------------------------------------------- /core/components/base/AwesomeScrollView.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 102 | 103 | -------------------------------------------------------------------------------- /core/components/widget/CircleIconButton.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 40 | 41 | -------------------------------------------------------------------------------- /core/components/widget/DropOption.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 53 | 54 | -------------------------------------------------------------------------------- /core/components/widget/FlatButton.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 38 | 39 | -------------------------------------------------------------------------------- /core/components/widget/Pagination.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 97 | 98 | -------------------------------------------------------------------------------- /core/components/widget/PopSlider.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 97 | 98 | -------------------------------------------------------------------------------- /core/components/widget/Popover.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 55 | 56 | -------------------------------------------------------------------------------- /core/components/widget/SimpleDialog.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 87 | 88 | -------------------------------------------------------------------------------- /core/components/widget/SimpleSwitch.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 34 | 35 | -------------------------------------------------------------------------------- /core/components/widget/Slider.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 104 | 105 | -------------------------------------------------------------------------------- /core/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars,no-undef,indent */ 2 | import "@babel/polyfill"; 3 | import Vue from 'vue' 4 | import launcher from './launcher' 5 | import store from './store' 6 | import VueUtil from './utils/VueUtil.js' 7 | import SettingService from './service/SettingService.ts' 8 | import { setTimeout } from 'timers' 9 | 10 | Vue.mixin(VueUtil); 11 | 12 | function createAppView(containerClass, vueRootId, vueInstance) { 13 | if (document.getElementsByClassName(containerClass) 14 | .length > 0) { 15 | let app = new Vue({ 16 | store, 17 | render: (h) => h(vueInstance) 18 | }) 19 | .$mount(vueRootId); 20 | SettingService.initSettings(); 21 | return app; 22 | } 23 | } 24 | 25 | export default { 26 | launcher, 27 | createAppView, 28 | SettingService 29 | } -------------------------------------------------------------------------------- /core/launcher.js: -------------------------------------------------------------------------------- 1 | import app from './App.vue' 2 | 3 | let service = {} 4 | let config = {} 5 | let disableLoading = false; 6 | 7 | export default { 8 | setAlbumService(obj) { 9 | service.album = obj; 10 | return this; 11 | }, 12 | setEHunterService(obj) { 13 | service.eHunter = obj; 14 | return this; 15 | }, 16 | setConfig(obj) { 17 | config = obj; 18 | return this; 19 | }, 20 | disableLoading(disable) { 21 | disableLoading = disable; 22 | return this; 23 | }, 24 | instance() { 25 | return { 26 | components: { app }, 27 | provide: { 28 | service, 29 | config, 30 | disableLoading 31 | }, 32 | data() { 33 | return {} 34 | }, 35 | template: '' 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /core/service/AlbumService.ts: -------------------------------------------------------------------------------- 1 | import { ImgPageInfo } from "../bean/ImgPageInfo"; 2 | import { ThumbInfo } from "../bean/ThumbInfo"; 3 | 4 | export interface PreviewThumbnailStyle { 5 | 'background-image': string; 6 | 'background-position': string; 7 | 'background-size': string; 8 | } 9 | 10 | export interface IndexInfo { 11 | val: number; 12 | updater: string; 13 | } 14 | 15 | export abstract class AlbumService { 16 | abstract getPageCount(): Promise; 17 | abstract getCurPageNum(): Promise; 18 | abstract getTitle(): Promise; 19 | abstract getImgPageInfos(): Promise>; 20 | abstract getImgPageInfo(index: number): Promise; 21 | abstract getImgSrc(index: number, mode): Promise; 22 | abstract getNewImgSrc(index: number, mode): Promise; 23 | abstract getThumbInfos(noCache?: boolean): Promise>; 24 | abstract getThumbInfo(index: number): Promise; 25 | abstract getAlbumId(): Promise; 26 | abstract getPreviewThumbnailStyle(index: number, imgPageInfo: ImgPageInfo, thumbInfo: ThumbInfo, width: number, height: number): Promise | Promise; 27 | abstract supportOriginImg(): boolean; 28 | abstract supportImgChangeSource(): boolean; 29 | abstract supportThumbView(): boolean; 30 | 31 | getBookScreenCount(pageCount: number, screenSize: number): number { 32 | // 2 is start page and end page 33 | return Math.ceil((pageCount + 2) / screenSize); 34 | } 35 | 36 | getRealCurIndexInfo(pageCount: number, curIndex: IndexInfo): IndexInfo { 37 | let index = curIndex.val; 38 | index = index >= pageCount ? pageCount - 1 : index; 39 | return { val: index, updater: curIndex.updater }; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /core/service/InfoService.ts: -------------------------------------------------------------------------------- 1 | import store from '../store' 2 | import DialogBean from '../bean/DialogBean' 3 | import { DialogOperation, DOClick } from '../bean/DialogOperation' 4 | import * as tags from '../assets/value/tags' 5 | import { TextReq } from '../service/request/TextReq' 6 | import ServerMessage from '../bean/ServerMessage' 7 | import SettingService from '../service/SettingService' 8 | import Logger from '../utils/Logger' 9 | import Formatter from '../utils/formatter' 10 | 11 | class InfoService { 12 | async showInstruction(config, isCompulsive) { 13 | let dialog = new DialogBean( 14 | isCompulsive ? tags.DIALOG_COMPULSIVE : tags.DIALOG_NORMAL, 15 | store.getters.string.instructionsAndAbouts, 16 | Formatter.replaceKey(store.getters.string.p_instruction, { 17 | HOME_PAGE: config.homePage, 18 | VERSION: config.version 19 | }), 20 | new DialogOperation(store.getters.string.confirm, tags.DIALOG_OPERATION_TYPE_PLAIN, () => { 21 | return true; 22 | }) 23 | ) 24 | store.dispatch('addDialog', dialog); 25 | } 26 | 27 | async showBookInstruction(isCompulsive): Promise { 28 | let dialog = new DialogBean( 29 | isCompulsive ? tags.DIALOG_COMPULSIVE : tags.DIALOG_NORMAL, 30 | store.getters.string.instructions, 31 | store.getters.string.p_bookInstruction, 32 | new DialogOperation(store.getters.string.confirm, tags.DIALOG_OPERATION_TYPE_PLAIN, () => { 33 | return true; 34 | }) 35 | ); 36 | store.dispatch('addDialog', dialog); 37 | } 38 | 39 | async checkUpdate(config): Promise { 40 | let message; 41 | let lastShowDialogTime = await SettingService.getUpdateTime(); 42 | Promise 43 | .race([ 44 | new TextReq(config.updateServer1, true, false).setCredentials('omit').request(), 45 | new TextReq(config.updateServer2, true, false).setCredentials('omit').request() 46 | ]) 47 | .then(data => { 48 | message = new ServerMessage(JSON.parse(data)); 49 | let isNewVersion = message.version !== config.version; 50 | let isReleaseTime = new Date().getTime() > message.time; 51 | let isOverDuration = (new Date().getTime() - lastShowDialogTime) > message.duration; 52 | if (isNewVersion && isReleaseTime && isOverDuration) { 53 | SettingService.setUpdateTime(new Date().getTime()); 54 | this.showUpdateInfo(message); 55 | } 56 | }) 57 | .catch(e => { 58 | Logger.logObj('InfoService', e); 59 | }); 60 | } 61 | 62 | showUpdateInfo(message): void { 63 | let operations: Array = []; 64 | operations.push(new DialogOperation(store.getters.string.later, tags.DIALOG_OPERATION_TYPE_PLAIN, () => { 65 | return true; 66 | })); 67 | message.operations.forEach(i => { 68 | operations.push(new DialogOperation(i.name, tags.DIALOG_OPERATION_TYPE_PLAIN, () => { 69 | window.open(i.url, '_blank'); 70 | return true; 71 | })); 72 | }); 73 | let dialog = new DialogBean( 74 | tags.DIALOG_COMPULSIVE, 75 | message.title, 76 | message.text, 77 | ...operations 78 | ); 79 | store.dispatch('addDialog', dialog); 80 | } 81 | 82 | showReloadError(text): void { 83 | let dialog = new DialogBean( 84 | tags.DIALOG_COMPULSIVE, 85 | store.getters.string.loadingFailed, 86 | text, 87 | new DialogOperation(store.getters.string.reload, tags.DIALOG_OPERATION_TYPE_PLAIN, () => { 88 | window.location.reload(); 89 | return true; 90 | }) 91 | ); 92 | store.dispatch('addDialog', dialog); 93 | } 94 | 95 | // if updated a new version, shows messages 96 | async checkNewVersion(config): Promise { 97 | if (await SettingService.getVersion() !== config.version) { 98 | let dialog = new DialogBean( 99 | tags.DIALOG_COMPULSIVE, 100 | `${store.getters.string.versionUpdate} v${config.version}`, 101 | store.getters.string.p_version, 102 | new DialogOperation(store.getters.string.confirm, tags.DIALOG_OPERATION_TYPE_PLAIN, () => { 103 | SettingService.setVersion(config.version); 104 | return true; 105 | }) 106 | ); 107 | store.dispatch('addDialog', dialog); 108 | } 109 | } 110 | } 111 | 112 | let instance = new InfoService(); 113 | export default instance; 114 | -------------------------------------------------------------------------------- /core/service/PlatformService.js: -------------------------------------------------------------------------------- 1 | // a service for crossing platform 2 | /* eslint-disable no-undef */ 3 | 4 | // hack for test 5 | if (typeof chrome === 'undefined') { 6 | var chrome = { extension: null }; 7 | } 8 | 9 | export default { 10 | storage: { 11 | get sync() { 12 | if (chrome && chrome.storage) { 13 | return chrome.storage.sync.QUOTA_BYTES ? chrome.storage.sync : chrome.storage.local; 14 | } else { 15 | return window.localStorage; 16 | } 17 | }, 18 | local: window.localStorage 19 | }, 20 | getExtension() { 21 | return chrome.extension; 22 | }, 23 | fetch(url, option) { 24 | /* eslint-disable camelcase */ 25 | if (typeof GM_info !== 'undefined' && GM_info.version) { // the ENV is Tampermonkey 26 | return new Promise((resolve, reject) => { 27 | const httpRequest = (typeof GM_xmlhttpRequest === 'undefined') ? GM.xmlHttpRequest : GM_xmlhttpRequest; 28 | httpRequest({ 29 | method: option.method, 30 | url, 31 | onload: x => { 32 | let responseText = x.responseText; 33 | x.text = async function() { 34 | return responseText; 35 | } 36 | resolve(x); 37 | }, 38 | onerror: e => { 39 | reject(`GM_xhr error, ${e.status}`); 40 | } 41 | }); 42 | }); 43 | } else { // the ENV is Chrome or Firefox 44 | return window.fetch(url, option); 45 | } 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /core/service/StringService.ts: -------------------------------------------------------------------------------- 1 | import string from '../assets/value/string' 2 | import instruction from '../assets/value/instruction' 3 | import bookInstruction from '../assets/value/bookInstruction' 4 | import version from '../assets/value/version' 5 | 6 | class StringService { 7 | cn = {}; 8 | en = {}; 9 | jp = {}; 10 | 11 | constructor() { 12 | this.initString(); 13 | } 14 | 15 | initString() { 16 | for (let key in string) { 17 | this.cn[key] = string[key].cn; 18 | this.en[key] = string[key].en; 19 | this.jp[key] = string[key].jp; 20 | } 21 | this.cn['p_instruction'] = instruction.cn; 22 | this.en['p_instruction'] = instruction.en; 23 | this.jp['p_instruction'] = instruction.jp; 24 | this.cn['p_bookInstruction'] = bookInstruction.cn; 25 | this.en['p_bookInstruction'] = bookInstruction.en; 26 | this.jp['p_bookInstruction'] = bookInstruction.jp; 27 | this.cn['p_version'] = version.cn; 28 | this.en['p_version'] = version.en; 29 | this.jp['p_version'] = version.jp; 30 | } 31 | } 32 | 33 | let instance = new StringService(); 34 | export default instance; 35 | -------------------------------------------------------------------------------- /core/service/request/MultiAsyncReq.ts: -------------------------------------------------------------------------------- 1 | // a service for sync multi asynchronous text requests 2 | import { TextReq } from './TextReq' 3 | 4 | export class MultiAsyncReq { 5 | private urls: Array = []; 6 | private resultMap: Map = new Map(); 7 | private fetchSetting = null; 8 | private gen; 9 | 10 | constructor(urls) { 11 | this.urls = urls; 12 | this.fetchSetting = null; 13 | } 14 | 15 | request(): Promise> { 16 | return new Promise((resolve, reject) => { 17 | this._initGenerator(resolve, reject); 18 | this._request(); 19 | }); 20 | } 21 | 22 | setFetchSetting(setting) { 23 | this.fetchSetting = setting; 24 | return this; 25 | } 26 | 27 | _initGenerator(resolve, reject) { 28 | let self = this; 29 | this.gen = (function* () { 30 | try { 31 | for (let url of self.urls) { 32 | let item = yield url; 33 | self.resultMap.set(item.url, item.html); 34 | } 35 | resolve(self.resultMap); 36 | } catch (err) { 37 | reject(err); 38 | } 39 | })(); 40 | this.gen.next(); // run to first yield 41 | } 42 | 43 | _request() { 44 | for (let url of this.urls) { 45 | (new TextReq(url)) 46 | .setFetchSetting(this.fetchSetting) 47 | .request() 48 | .then(html => this.gen.next({ url: url, html: html }, 49 | err => this.gen.throw(err))); 50 | } 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /core/service/request/ReqQueue.ts: -------------------------------------------------------------------------------- 1 | // a service for limiting num of async requests, avoiding too many concurrent requests 2 | import {MultiAsyncReq} from './MultiAsyncReq' 3 | 4 | export class ReqQueue { 5 | private urls: Array = []; 6 | private maxConcurrentedNum = 5; 7 | private resultMap: Map = new Map(); 8 | private fetchSetting = null; 9 | 10 | constructor(urls) { 11 | this.urls = urls; 12 | } 13 | 14 | setNumOfConcurrented(num: number) { 15 | this.maxConcurrentedNum = num; 16 | return this; 17 | } 18 | 19 | setFetchSetting(setting) { 20 | this.fetchSetting = setting; 21 | return this; 22 | } 23 | 24 | request(): Promise> { 25 | return new Promise((resolve, reject) => { 26 | let reqList = this._splitReqs(); 27 | this._request(reqList, resolve, reject); 28 | }); 29 | } 30 | 31 | _splitReqs() { 32 | if (this.urls.length < this.maxConcurrentedNum) { 33 | return [this.urls]; 34 | } 35 | let results: Array = []; 36 | let urls = JSON.parse(JSON.stringify(this.urls)); 37 | while (true) { 38 | let list = urls.splice(0, this.maxConcurrentedNum); 39 | if (list.length > 0) { 40 | results.push(list); 41 | } else { 42 | return results; 43 | } 44 | } 45 | } 46 | 47 | _addMap(destMap, srcMap) { 48 | for (let item of srcMap) { 49 | destMap.set(item[0], item[1]); 50 | } 51 | return destMap; 52 | } 53 | 54 | _request(reqList, resolve, reject) { 55 | if (reqList.length > 0) { 56 | (new MultiAsyncReq(reqList[0])) 57 | .setFetchSetting(this.fetchSetting) 58 | .request() 59 | .then(map => { 60 | this._addMap(this.resultMap, map); 61 | reqList.splice(0, 1); 62 | this._request(reqList, resolve, reject); 63 | }, err => { reject(err) }); 64 | } else { 65 | resolve(this.resultMap); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /core/service/request/TextReq.ts: -------------------------------------------------------------------------------- 1 | // a good resolution for poor network 2 | import PlatformService from '../PlatformService' 3 | 4 | export class TextReq { 5 | private url: string; 6 | private method = 'GET'; 7 | private credentials = 'include'; 8 | private retryTimes = 3; 9 | private timeoutTime = 15; // secs 10 | private curRetryTimes = 0; 11 | private retryInterval = 3; // secs 12 | private enabledLog = true; 13 | private fetchSetting = null; 14 | private noCache = false; 15 | private rejectError = true; 16 | 17 | constructor(url: string, noCache = false, rejectError = true) { 18 | this.url = url; 19 | this.noCache = noCache; 20 | this.rejectError = rejectError; 21 | } 22 | 23 | setMethod(method) { 24 | this.method = method; 25 | return this; 26 | } 27 | 28 | setCredentials(credential: string) { 29 | this.credentials = credential; 30 | return this; 31 | } 32 | 33 | setFetchSetting(setting: any) { 34 | this.fetchSetting = setting; 35 | return this; 36 | } 37 | 38 | setRetryTimes(times: number) { 39 | this.retryTimes = times; 40 | } 41 | 42 | setRetryInterval(secs: number) { 43 | this.retryInterval = secs; 44 | } 45 | 46 | setTimeOutTime(secs: number) { 47 | this.timeoutTime = secs; 48 | } 49 | 50 | request(): Promise { 51 | return new Promise((resolve, reject) => { 52 | this._request(res => { 53 | res.text().then(text => resolve(text)); 54 | }, err => { 55 | if (this.rejectError) { 56 | reject(err); 57 | } else { 58 | console.error(err); 59 | } 60 | }); 61 | }); 62 | } 63 | 64 | private printErrorLog(err) { 65 | console.error(`TextReq: request error in ${this.url}, retry:(${this.curRetryTimes}/${this.retryTimes}), error: ${err}`); 66 | } 67 | 68 | _request(successCallback, failureCallback) { 69 | this.curRetryTimes++; 70 | let url = this.url.includes('http') ? this.url : `${window.location.protocol}//${window.location.host}${this.url}`; 71 | if (this.noCache) { 72 | url = `${url}?_t=${new Date().getTime()}`; 73 | } 74 | let timeout = new Promise((resolve, reject) => { 75 | setTimeout(reject, this.timeoutTime * 1000 * this.curRetryTimes, 'request timed out'); 76 | }); 77 | let req = PlatformService.fetch(url, this.fetchSetting ? this.fetchSetting : { 78 | method: this.method, 79 | credentials: this.credentials 80 | }); 81 | Promise 82 | .race([timeout, req]) 83 | .then(res => { 84 | if (res.status === 200) { 85 | successCallback(res); 86 | } else { 87 | throw new Error(`${url}: ${res.status}`); 88 | } 89 | }) 90 | .catch(err => { 91 | this.printErrorLog(err); 92 | if (this.curRetryTimes < this.retryTimes) { 93 | setTimeout(() => { 94 | this._request(successCallback, failureCallback); 95 | }, this.retryInterval * 1000); 96 | } else { 97 | failureCallback(err); 98 | } 99 | }); 100 | } 101 | } -------------------------------------------------------------------------------- /core/service/storage/LocalStorage.ts: -------------------------------------------------------------------------------- 1 | import Storage from './base/Storage' 2 | import Platform from '../PlatformService' 3 | 4 | let storage = new Storage({ 5 | size: 10, 6 | storageBackend: Platform.storage.local, 7 | defaultExpires: null, 8 | enableCache: true, 9 | sync: {} 10 | }); 11 | 12 | export default storage; 13 | -------------------------------------------------------------------------------- /core/service/storage/SyncStorage.ts: -------------------------------------------------------------------------------- 1 | import Storage from './base/Storage' 2 | import Platform from '../PlatformService' 3 | 4 | let storage = new Storage({ 5 | size: 10, 6 | storageBackend: Platform.storage.sync, 7 | defaultExpires: null, 8 | enableCache: true, 9 | sync: {} 10 | }); 11 | 12 | export default storage; 13 | -------------------------------------------------------------------------------- /core/service/storage/base/Storage.js: -------------------------------------------------------------------------------- 1 | import Storage from 'react-native-storage'; 2 | import Logger from '../../../utils/Logger'; 3 | 4 | function wrapStorageArea(storageArea) { 5 | return { 6 | async getItem(key) { 7 | return new Promise((resolve, reject) => { 8 | Logger.logText('Storage', `get ${key}`); 9 | storageArea.get(key, (val) => { 10 | if (typeof val[key] !== 'undefined') { 11 | resolve(val[key]); 12 | } else { 13 | Logger.logText('Storage', `This key--${key} doesn't exist`); 14 | resolve(null); 15 | } 16 | }) 17 | }); 18 | }, 19 | async setItem(key, val) { 20 | return new Promise((resolve, reject) => { 21 | if (key) { 22 | storageArea.set({ 23 | [key]: val 24 | }, () => { 25 | Logger.logText('Storage', `chrome saved ${key}`); 26 | resolve(); 27 | }); 28 | } else { 29 | Logger.logText('Storage', `ERROR: setItem, key is null, ${val}`); 30 | } 31 | }); 32 | }, 33 | async removeItem(key) { 34 | return new Promise((resolve, reject) => { 35 | storageArea.remove(key, () => { 36 | Logger.logText('Storage', `chrome removed ${key}`); 37 | resolve(); 38 | }) 39 | }); 40 | } 41 | } 42 | } 43 | 44 | export default class UniStorage extends Storage { 45 | constructor(options = {}) { 46 | if (options.storageBackend.constructor.name === 'StorageArea') { 47 | options.storageBackend = wrapStorageArea(options.storageBackend); 48 | } 49 | super(options); 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /core/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import String from './modules/String' 4 | import AlbumView from './modules/AlbumView' 5 | import Modal from './modules/Modal' 6 | 7 | Vue.use(Vuex) 8 | 9 | const debug = process.env.NODE_ENV !== 'production' 10 | 11 | export default new Vuex.Store({ 12 | modules: { 13 | String, 14 | AlbumView, 15 | Modal 16 | }, 17 | strict: debug 18 | }) 19 | -------------------------------------------------------------------------------- /core/store/modules/Modal.js: -------------------------------------------------------------------------------- 1 | import * as types from '../mutation-types' 2 | // import Logger from '../../utils/Logger' 3 | 4 | // initial state 5 | const state = { 6 | dialogs: [] 7 | } 8 | 9 | // getters 10 | const getters = { 11 | dialogs: state => state.dialogs 12 | } 13 | 14 | // actions 15 | const actions = { 16 | addDialog: ({ commit }, dialogBean) => commit(types.ADD_DIALOG, { dialogBean }), 17 | removeDialog: ({ commit }, dialogBean) => commit(types.REMOVE_DIALOG, { dialogBean }) 18 | } 19 | 20 | // mutations 21 | const mutations = { 22 | [types.ADD_DIALOG](state, { dialogBean }) { 23 | state.dialogs.push(dialogBean); 24 | }, 25 | [types.REMOVE_DIALOG](state, { dialogBean }) { 26 | state.dialogs.splice(state.dialogs.indexOf(dialogBean), 1); 27 | } 28 | } 29 | 30 | export default { 31 | state, 32 | getters, 33 | actions, 34 | mutations 35 | } 36 | -------------------------------------------------------------------------------- /core/store/modules/String.js: -------------------------------------------------------------------------------- 1 | import StringService from '../../service/StringService.ts' 2 | import * as types from '../mutation-types' 3 | import * as tags from '../../assets/value/tags' 4 | // import Logger from '../../utils/Logger' 5 | 6 | // initial state 7 | const state = { 8 | string: StringService.en 9 | } 10 | 11 | // getters 12 | const getters = { 13 | string: state => { 14 | return state.string 15 | } 16 | } 17 | 18 | // actions 19 | const actions = { 20 | setString({ commit }, langCode) { 21 | commit(types.SET_STRING, { langCode }); 22 | } 23 | } 24 | 25 | // mutations 26 | const mutations = { 27 | [types.SET_STRING](state, { langCode }) { 28 | /* eslint-disable indent */ 29 | switch (langCode) { 30 | case tags.LANG_CN: 31 | state.string = StringService.cn; 32 | break; 33 | case tags.LANG_EN: 34 | state.string = StringService.en; 35 | break; 36 | case tags.LANG_JP: 37 | state.string = StringService.jp; 38 | break; 39 | } 40 | } 41 | } 42 | 43 | export default { 44 | state, 45 | getters, 46 | actions, 47 | mutations 48 | } 49 | -------------------------------------------------------------------------------- /core/store/mutation-types.js: -------------------------------------------------------------------------------- 1 | export const SET_LANG = 'SET_LANG' 2 | export const SET_SELECTOR = 'SET_SELECTOR' 3 | export const SET_RANK_LIST = 'SET_RANK_LIST' 4 | export const SET_CUR_RANK = 'SET_CUR_RANK' 5 | export const SET_CUR_SUB_RANK = 'SET_CUR_SUB_RANK' 6 | export const SET_CUR_TAB = 'SET_CUR_TAB' 7 | export const SHOW_SELECTOR = 'SHOW_SELECTOR' 8 | export const SHOW_TABLE_PICKER = 'SHOW_TABLE_PICKER' 9 | export const SHOW_ITEM_INFO = 'SHOW_ITEM_INFO' 10 | export const CLOSE_POPUPS = 'CLOSE_POPUPS' 11 | export const UPDATE_RANK = 'UPDATE_RANK' 12 | export const SET_ERROR = 'SET_ERROR' 13 | export const SWITCH_TABLE_ITEMS = 'SWITCH_TABLE_ITEMS' 14 | 15 | export const SET_INDEX = 'SET_INDEX' 16 | export const SET_ALBUM_WIDTH = 'SET_ALBUM_WIDTH' 17 | export const TOGGLE_THUMB_VIEW = 'TOGGLE_THUMB_VIEW' 18 | export const TOGGLE_SYNC_SCROLL = 'TOGGLE_SYNC_SCROLL' 19 | export const TOGGLE_SHOW_TOP_BAR = 'TOGGLE_SHOW_TOP_BAR' 20 | export const SET_LOAD_NUM = 'SET_LOAD_NUM' 21 | export const SET_VOLUME_SIZE = 'SET_VOLUME_SIZE' 22 | export const SET_BOOK_INDEX = 'SET_BOOK_INDEX' 23 | export const SET_READING_MODE = 'SET_READING_MODE' 24 | export const SET_BOOK_SCREEN_ANIMATION = 'SET_BOOK_SCREEN_ANIMATION' 25 | export const SET_BOOK_PAGINATION = 'SET_BOOK_PAGINATION' 26 | export const SET_BOOK_DIRECTION = 'SET_BOOK_DIRECTION' 27 | export const SET_BOOK_SCREEN_SIZE = 'SET_BOOK_SCREEN_SIZE' 28 | export const TOGGLE_MORE_SETTINGS = 'TOGGLE_MORE_SETTINGS' 29 | export const SET_REVERSE_FLIP = 'SET_REVERSE_FLIP' 30 | export const SET_AUTO_FLIP = 'SET_AUTO_FLIP' 31 | export const SET_AUTO_FLIP_FREQUENCY = 'SET_AUTO_FLIP_FREQUENCY' 32 | export const TOGGLE_THUMB_VIEW_IN_BOOK = 'TOGGLE_THUMB_VIEW_IN_BOOK' 33 | export const SET_WHEEL_SENSITIVITY = 'SET_WHEEL_SENSITIVITY' 34 | export const SET_WHEEL_DIRECTION = 'SET_WHEEL_DIRECTION' 35 | export const SET_SCROLLED_PAGE_MARGIN = 'SET_SCROLLED_PAGE_MARGIN' 36 | export const SET_ODD_EVEN = 'SET_ODD_EVEN' 37 | 38 | export const SET_STRING = 'SET_STRING' 39 | 40 | export const ADD_DIALOG = 'ADD_DIALOG' 41 | export const REMOVE_DIALOG = 'REMOVE_DIALOG' 42 | -------------------------------------------------------------------------------- /core/style/_markdown.scss: -------------------------------------------------------------------------------- 1 | p.markdown { 2 | font-size: 14px !important; 3 | line-height: 1.42857143 !important; 4 | color: #333 !important; 5 | * { 6 | box-sizing: border-box; 7 | } 8 | 9 | *:before, 10 | *:after { 11 | box-sizing: border-box; 12 | } 13 | 14 | hr { 15 | margin-top: 20px; 16 | margin-bottom: 20px; 17 | border: 0; 18 | border-top: 1px solid #eee; 19 | height: 0; 20 | } 21 | 22 | input, 23 | button, 24 | select, 25 | textarea { 26 | font-family: inherit; 27 | font-size: inherit; 28 | line-height: inherit; 29 | } 30 | 31 | a { 32 | color: #428bca; 33 | text-decoration: none; 34 | background: transparent; 35 | &:hover, 36 | &:focus { 37 | color: #2a6496; 38 | outline: none; 39 | text-decoration: underline; 40 | } 41 | } 42 | 43 | p { 44 | margin: 0 0 10px !important; 45 | } 46 | 47 | b, 48 | strong { 49 | font-weight: bold; 50 | } 51 | 52 | h1 { 53 | font-size: 36px; 54 | margin: .67em 0; 55 | } 56 | 57 | h2 { 58 | font-size: 30px; 59 | } 60 | 61 | h4 { 62 | font-size: 18px; 63 | } 64 | 65 | h5 { 66 | font-size: 14px; 67 | } 68 | 69 | h6 { 70 | font-size: 12px; 71 | } 72 | 73 | h1, 74 | h2, 75 | h3 { 76 | margin-top: 20px !important; 77 | margin-bottom: 10px !important; 78 | } 79 | 80 | h4, 81 | h5, 82 | h6 { 83 | margin-top: 10px !important; 84 | margin-bottom: 10px !important; 85 | } 86 | 87 | h1, 88 | h2, 89 | h3, 90 | h4, 91 | h5, 92 | h6 { 93 | font-family: inherit; 94 | font-weight: 500; 95 | line-height: 1.1; 96 | color: inherit; 97 | } 98 | 99 | blockquote { 100 | padding: 10px 20px; 101 | margin: 0 0 20px; 102 | font-size: 17.5px; 103 | border-left: 5px solid #eee; 104 | &:before { 105 | content: ''; 106 | } 107 | &:after { 108 | content: ''; 109 | } 110 | } 111 | 112 | ul, 113 | ol { 114 | margin-top: 0; 115 | margin-bottom: 10px; 116 | } 117 | 118 | code, 119 | kbd, 120 | pre, 121 | samp { 122 | font-family: Menlo, Monaco, Consolas, "Courier New", monospace; 123 | } 124 | 125 | code { 126 | padding: 2px 4px; 127 | font-size: 90%; 128 | color: #c7254e; 129 | background-color: #f9f2f4; 130 | border-radius: 4px; 131 | } 132 | 133 | ul { 134 | padding-left: 20px; 135 | } 136 | 137 | ul ul, 138 | ol ul, 139 | ul ol, 140 | ol ol { 141 | margin-bottom: 0; 142 | } 143 | 144 | pre { 145 | display: block; 146 | padding: 9.5px; 147 | margin: 0 0 10px; 148 | font-size: 13px; 149 | line-height: 1.42857143; 150 | color: #333; 151 | word-break: break-all; 152 | word-wrap: break-word; 153 | background-color: #f5f5f5; 154 | border: 1px solid #ccc; 155 | border-radius: 4px; 156 | overflow: auto; 157 | code { 158 | padding: 0; 159 | font-size: inherit; 160 | color: inherit; 161 | white-space: pre-wrap; 162 | background-color: transparent; 163 | border-radius: 0; 164 | } 165 | } 166 | 167 | table { 168 | width: 100%; 169 | max-width: 100%; 170 | margin-bottom: 20px; 171 | background-color: transparent; 172 | border-spacing: 0; 173 | border-collapse: collapse; 174 | } 175 | 176 | table>caption+thead>tr:first-child>th, 177 | table>colgroup+thead>tr:first-child>th, 178 | table>thead:first-child>tr:first-child>th, 179 | table>caption+thead>tr:first-child>td, 180 | table>colgroup+thead>tr:first-child>td, 181 | table>thead:first-child>tr:first-child>td { 182 | border-top: 0; 183 | } 184 | 185 | table>thead>tr>th { 186 | vertical-align: bottom; 187 | border-bottom: 2px solid #ddd; 188 | } 189 | 190 | table>thead>tr>th, 191 | table>tbody>tr>th, 192 | table>tfoot>tr>th, 193 | table>thead>tr>td, 194 | table>tbody>tr>td, 195 | table>tfoot>tr>td { 196 | padding: 8px; 197 | line-height: 1.42857143; 198 | vertical-align: top; 199 | border-top: 1px solid #ddd; 200 | } 201 | 202 | th { 203 | text-align: left; 204 | } 205 | 206 | td, 207 | th { 208 | padding: 0; 209 | } 210 | 211 | tbody>tr:nth-child(odd)>td, 212 | tbody>tr:nth-child(odd)>th { 213 | background-color: #f9f9f9; 214 | } 215 | 216 | img { 217 | max-width: 35%; 218 | vertical-align: middle; 219 | border: 0; 220 | } 221 | 222 | sub, 223 | sup { 224 | position: relative; 225 | font-size: 75%; 226 | line-height: 0; 227 | vertical-align: baseline; 228 | } 229 | 230 | sup { 231 | top: -.5em; 232 | } 233 | 234 | .emoji { 235 | height: 1.2em; 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /core/style/_normalize.scss: -------------------------------------------------------------------------------- 1 | .normalize { 2 | line-height: 1.15; 3 | -webkit-text-size-adjust: 100%; 4 | margin: 0; 5 | -webkit-font-smoothing: auto; 6 | 7 | main { 8 | display: block; 9 | } 10 | 11 | h1 { 12 | font-size: 2em; 13 | margin: 0.67em 0; 14 | } 15 | 16 | hr { 17 | box-sizing: content-box; 18 | height: 0; 19 | overflow: visible; 20 | } 21 | 22 | pre { 23 | font-family: monospace, monospace; 24 | font-size: 1em; 25 | } 26 | 27 | a { 28 | background-color: transparent; 29 | } 30 | 31 | abbr[title] { 32 | border-bottom: none; 33 | text-decoration: underline; 34 | text-decoration: underline dotted; 35 | } 36 | 37 | b, 38 | strong { 39 | font-weight: bolder; 40 | } 41 | 42 | code, 43 | kbd, 44 | samp { 45 | font-family: monospace, monospace; 46 | font-size: 1em; 47 | } 48 | 49 | small { 50 | font-size: 80%; 51 | } 52 | 53 | sub, 54 | sup { 55 | font-size: 75%; 56 | line-height: 0; 57 | position: relative; 58 | vertical-align: baseline; 59 | } 60 | 61 | sub { 62 | bottom: -0.25em; 63 | } 64 | 65 | sup { 66 | top: -0.5em; 67 | } 68 | 69 | 70 | img { 71 | border-style: none; 72 | } 73 | 74 | button, 75 | input, 76 | optgroup, 77 | select, 78 | textarea { 79 | font-family: inherit; 80 | /* 1 */ 81 | font-size: 100%; 82 | /* 1 */ 83 | line-height: 1.15; 84 | margin: 0; 85 | } 86 | 87 | button, 88 | input { 89 | overflow: visible; 90 | } 91 | 92 | button, 93 | select { 94 | text-transform: none; 95 | } 96 | 97 | button, 98 | [type="button"], 99 | [type="reset"], 100 | [type="submit"] { 101 | -webkit-appearance: button; 102 | } 103 | 104 | button::-moz-focus-inner, 105 | [type="button"]::-moz-focus-inner, 106 | [type="reset"]::-moz-focus-inner, 107 | [type="submit"]::-moz-focus-inner { 108 | border-style: none; 109 | padding: 0; 110 | } 111 | 112 | 113 | button:-moz-focusring, 114 | [type="button"]:-moz-focusring, 115 | [type="reset"]:-moz-focusring, 116 | [type="submit"]:-moz-focusring { 117 | outline: 1px dotted ButtonText; 118 | } 119 | 120 | fieldset { 121 | padding: 0.35em 0.75em 0.625em; 122 | } 123 | 124 | 125 | legend { 126 | box-sizing: border-box; 127 | color: inherit; 128 | display: table; 129 | max-width: 100%; 130 | padding: 0; 131 | white-space: normal; 132 | } 133 | 134 | progress { 135 | vertical-align: baseline; 136 | } 137 | 138 | 139 | textarea { 140 | overflow: auto; 141 | } 142 | 143 | 144 | [type="checkbox"], 145 | [type="radio"] { 146 | box-sizing: border-box; 147 | padding: 0; 148 | } 149 | 150 | 151 | 152 | [type="number"]::-webkit-inner-spin-button, 153 | [type="number"]::-webkit-outer-spin-button { 154 | height: auto; 155 | } 156 | 157 | 158 | 159 | [type="search"] { 160 | -webkit-appearance: textfield; 161 | outline-offset: -2px; 162 | } 163 | 164 | 165 | [type="search"]::-webkit-search-decoration { 166 | -webkit-appearance: none; 167 | } 168 | 169 | 170 | ::-webkit-file-upload-button { 171 | -webkit-appearance: button; 172 | font: inherit; 173 | } 174 | 175 | details { 176 | display: block; 177 | } 178 | 179 | 180 | summary { 181 | display: list-item; 182 | } 183 | 184 | 185 | 186 | template { 187 | display: none; 188 | } 189 | 190 | [hidden] { 191 | display: none; 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /core/style/_variables.scss: -------------------------------------------------------------------------------- 1 | // color 2 | $primary_color: hsl(145, 63%, 49%); 3 | $light_primary_color: hsl(145, 63%, 60%); 4 | $split_grey: #DDDDDD; 5 | $accent_color: hsl(145, 63%, 42%); 6 | $background_grey: #EEEEEE; 7 | $contrast_color: #f1c40f; 8 | $table_grey: #f7f7f7; 9 | $text_grey: #777777; 10 | 11 | $slider_track_bg: #bdbdbd; 12 | $slider_track_fill_color: hsl(145, 63%, 42%); 13 | $slider_thumb_color: hsl(145, 63%, 49%); 14 | 15 | $flat_button_positive_color: hsl(145, 63%, 49%); 16 | $flat_button_positive_light_color: lighten($flat_button_positive_color, 10%); 17 | $flat_button_positive_dark_color: darken($flat_button_positive_color, 10%); 18 | $flat_button_negative_color: #AAAAAA; 19 | $flat_button_negative_light_color: lighten($flat_button_negative_color, 10%); 20 | $flat_button_negative_dark_color: darken($flat_button_negative_color, 10%); 21 | $flat_button_plain_color: hsl(145, 63%, 42%); 22 | $flat_button_plain_light_color: lighten($flat_button_plain_color, 10%); 23 | $flat_button_plain_dark_color: darken($flat_button_plain_color, 10%); 24 | $flat_button_warning_color: #e74c3c; 25 | $flat_button_warning_light_color: lighten($flat_button_warning_color, 10%); 26 | $flat_button_warning_dark_color: darken($flat_button_warning_color, 10%); 27 | 28 | $switch_track_disabled_color: #bdbdbd; 29 | $switch_track_enabled_color: #71ca96; 30 | $switch_thumb_disabled_color: #f5f5f5; 31 | $switch_thumb_enabled_color: #006548; 32 | 33 | $top_bar_float_btn_bg: rgba(0, 0, 0, 0.5); 34 | $top_bar_float_btn_icon_color: rgba(255, 255, 255, 0.9); 35 | $top_bar_float_btn_hover_bg: rgba(255, 255, 255, 0.9); 36 | $top_bar_float_btn_hover_icon_color: rgba(0, 0, 0, 0.5); 37 | $top_bar_float_btn_active_bg: rgba(255, 255, 255, 0.2); 38 | $top_bar_float_btn_active_icon_color: rgba(0, 0, 0, 0.5); 39 | 40 | $pagination_icon_active_color: #c9cacf; 41 | $pagination_icon_disabled_color: rgba(#c9cacf, 0.6); 42 | $pagination_icon_hovered_color: white; 43 | $pagination_item_text_normal_color: #c9cacf; 44 | $pagination_item_text_actived__color: white; 45 | $pagination_item_text_hovered__color: white; 46 | $pagination_item_background_actived__color: hsl(145, 63%, 49%); 47 | $pagination_item_background_hovered__color: #777777; 48 | 49 | $page_view_thumb_mask_color: rgba(0, 0, 0, 0.5); 50 | $page_view_index_color: rgba(255, 255, 255, 0.5); 51 | $page_view_border_color: hsl(231, 6%, 36%); 52 | $page_view_info_color: white; 53 | $page_view_loading_btn_color: rgba(255, 255, 255, 0.8); 54 | $page_view_loading_btn_hovered_color: hsl(145, 63%, 60%); 55 | $page_view_loading_btn_actived_color: hsl(145, 63%, 30%); 56 | 57 | $reader_view_location_color: hsl(145, 63%, 42%); 58 | $reader_view_full_screen_color: hsl(145, 63%, 42%); 59 | $reader_view_full_screen_hovered_color: hsl(145, 63%, 60%); 60 | $reader_view_loading_color: rgba(255,255,255,0.1); 61 | 62 | $book_view_title_color: rgba(0, 0, 0, 0.8); 63 | $book_view_ehunter_tag_bg_color: hsl(145, 63%, 42%); 64 | $book_view_page_bg: white; 65 | $book_view_ehunter_tag_bg: hsl(145, 63%, 42%); 66 | $book_view_ehunter_tag_text_color: white; 67 | $book_view_end_page_text_color: rgba(0, 0, 0, 0.7); 68 | $book_view_pagination_bg: rgb(51, 51, 51); 69 | 70 | $modal_view_bg: rgba(0, 0, 0, 0.6); 71 | 72 | 73 | 74 | 75 | /* mussy */ 76 | $body_bg: #333333; // directly use in app.inject.js 77 | $img_container_color:hsl(235, 16%, 13%); 78 | $title_color:hsl(231, 6%, 80%); 79 | 80 | // thumb-view 81 | $thumb-view-width: 150px; 82 | $thumb-view-height: 160px; 83 | $indicator_color: white; 84 | $thumb-width: 100px; 85 | $thumb_scroll_view_bg: #444444; 86 | $thumb-view-margin: 4px; 87 | $header-bg: #2ecc71; 88 | $header-height: 40px; 89 | 90 | // popup view 91 | $popup_primary_color: hsl(145, 63%, 49%); 92 | $popup_alternate_text_color: white; 93 | $popup_text_color: hsla(0, 0%, 0%, .67); 94 | $popup_secondary_text_color: hsla(0, 0%, 0%, .54); 95 | $popup_addition_bg: hsla(0, 0%, 97%, 1); 96 | -------------------------------------------------------------------------------- /core/utils/DateUtil.js: -------------------------------------------------------------------------------- 1 | export default { 2 | getIntervalFromNow(date) { 3 | let now = new Date().getTime(); 4 | let start = date instanceof Date ? date.getTime() : date; 5 | let interval = now - start; 6 | if (interval < 60 * 1000) { // sec level 7 | return `${(interval / 1000).toFixed(0)}秒前`; 8 | } 9 | if (60 * 1000 <= interval && interval < 60 * 60 * 1000) { // min level 10 | return `${(interval / (60 * 1000)).toFixed(0)}分钟前`; 11 | } 12 | if (60 * 60 * 1000 <= interval && interval < 24 * 60 * 60 * 1000) { // hour level 13 | return `${(interval / (60 * 60 * 1000)).toFixed(0)}小时前`; 14 | } 15 | if (24 * 60 * 60 * 1000 && interval < 365 * 24 * 60 * 60 * 1000) { // day level 16 | return `${(interval / (24 * 60 * 60 * 1000)).toFixed(0)}天前`; 17 | } 18 | return `${(interval / (365 * 24 * 60 * 60 * 1000)).toFixed(0)}年前`; // year level 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /core/utils/DateWrapper.js: -------------------------------------------------------------------------------- 1 | export default class DateWrapper { 2 | constructor(date) { 3 | if (date) { 4 | this.date = date; 5 | } else { 6 | this.date = new Date(); 7 | } 8 | } 9 | 10 | _paddy(n, p, c) { 11 | let padChar = typeof c !== 'undefined' ? c : '0'; 12 | let pad = new Array(1 + p).join(padChar); 13 | return (pad + n).slice(-pad.length); 14 | } 15 | 16 | addDays(days) { 17 | this.date.setDate(this.date.getDate() + days); 18 | return this; 19 | } 20 | 21 | addMonths(month) { 22 | this.date.setMonth(this.date.getMonth() + month); 23 | return this; 24 | } 25 | 26 | addYears(Years) { 27 | this.date.setFullYear(this.date.getFullYear() + Years); 28 | return this; 29 | } 30 | 31 | getDate() { 32 | return this.date; 33 | } 34 | 35 | toString(pattern) { 36 | pattern = pattern || 'yyyy/MM/dd HH:mm:ss'; 37 | let month = this.date.getMonth() + 1 // begin from 0 38 | let day = this.date.getDate() // not getDay(), it's wrong 39 | let year = this.date.getFullYear(); 40 | let hour = this.date.getHours(); 41 | let min = this.date.getMinutes(); 42 | let sec = this.date.getSeconds(); 43 | pattern = pattern 44 | .replace('MM', this._paddy(month, 2)) 45 | .replace('dd', this._paddy(day, 2)) 46 | .replace('HH', this._paddy(hour, 2)) 47 | .replace('mm', this._paddy(min, 2)) 48 | .replace('ss', this._paddy(sec, 2)); 49 | if (pattern.includes('yyyy')) { 50 | pattern = pattern.replace('yyyy', year); 51 | } else if (pattern.includes('yy')) { 52 | pattern = pattern.replace('yy', year % 100); 53 | } 54 | return pattern; 55 | } 56 | 57 | toGMTString() { 58 | return this.date.toGMTString(); 59 | } 60 | 61 | setTimeFromDate(date) { 62 | this.date.setHours(date.getHours()); 63 | this.date.setMinutes(date.getMinutes()); 64 | this.date.setSeconds(date.getSeconds()); 65 | return this; 66 | } 67 | 68 | setDateFromDate(date) { 69 | this.date.setMonth(date.getMonth()); 70 | this.date.setDate(date.getDate()); 71 | this.date.setFullYear(date.getFullYear()); 72 | return this; 73 | } 74 | 75 | clearTime() { 76 | this.date.setHours(0); 77 | this.date.setMinutes(0); 78 | this.date.setSeconds(0); 79 | return this; 80 | } 81 | 82 | clearDay() { 83 | this.date.setDate(1); 84 | this.clearTime(); 85 | return this; 86 | } 87 | 88 | clearMonth() { 89 | this.date.setMonth(0); 90 | this.clearDay(); 91 | return this; 92 | } 93 | } -------------------------------------------------------------------------------- /core/utils/Logger.js: -------------------------------------------------------------------------------- 1 | class Logger { 2 | logText(tag, text) { 3 | console.log(`%c[${tag}] %c${text}`, 'color:red', 'color:black'); 4 | } 5 | 6 | logObj(tag, obj, str = false) { 7 | this.logText(tag, ':'); 8 | console.log(str ? JSON.parse(JSON.stringify(obj)) : obj); 9 | this.logText(tag, '----------'); 10 | } 11 | } 12 | 13 | let instance = new Logger(); 14 | export default instance; 15 | -------------------------------------------------------------------------------- /core/utils/MdRenderer.js: -------------------------------------------------------------------------------- 1 | import MarkdownIt from 'markdown-it'; 2 | import emoji from 'markdown-it-emoji'; 3 | import twemoji from 'twemoji'; 4 | 5 | class MdRenderer { 6 | constructor() { 7 | this.md = new MarkdownIt(); 8 | this.md.use(emoji, []); 9 | let defaultRender = 10 | this.md.renderer.rules.link_open || 11 | function(tokens, idx, options, env, self) { 12 | return self.renderToken(tokens, idx, options); 13 | }; 14 | this.md.renderer.rules.link_open = (tokens, idx, options, env, self) => { 15 | // If you are sure other plugins can't add `target` - drop check below 16 | var aIndex = tokens[idx].attrIndex('target'); 17 | 18 | if (aIndex < 0) { 19 | tokens[idx].attrPush(['target', '_blank']); // add new attribute 20 | } else { 21 | tokens[idx].attrs[aIndex][1] = '_blank'; // replace value of existing attr 22 | } 23 | // pass token to default renderer. 24 | return defaultRender(tokens, idx, options, env, self); 25 | }; 26 | this.md.renderer.rules.emoji = (token, idx) => twemoji.parse(token[idx].content); 27 | } 28 | 29 | render(text) { 30 | return this.md.render(text); 31 | } 32 | } 33 | 34 | let instance = new MdRenderer(); 35 | export default instance; 36 | -------------------------------------------------------------------------------- /core/utils/Utils.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | timeout(ms): Promise { 3 | return new Promise(resolve => setTimeout(resolve, ms)); 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /core/utils/VueUtil.js: -------------------------------------------------------------------------------- 1 | export default { 2 | methods: { 3 | px(num) { 4 | return `${num}px`; 5 | }, 6 | range(start, count) { 7 | return Array.apply(0, Array(count)) 8 | .map(function(element, index) { 9 | return index + start; 10 | }); 11 | } 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /core/utils/bezier-easing.js: -------------------------------------------------------------------------------- 1 | /** 2 | * https://github.com/gre/bezier-easing 3 | * BezierEasing - use bezier curve for transition easing function 4 | * by Gaëtan Renaudeau 2014 - 2015 – MIT License 5 | */ 6 | 7 | // These values are established by empiricism with tests (tradeoff: performance VS precision) 8 | var NEWTON_ITERATIONS = 4; 9 | var NEWTON_MIN_SLOPE = 0.001; 10 | var SUBDIVISION_PRECISION = 0.0000001; 11 | var SUBDIVISION_MAX_ITERATIONS = 10; 12 | 13 | var kSplineTableSize = 11; 14 | var kSampleStepSize = 1.0 / (kSplineTableSize - 1.0); 15 | 16 | var float32ArraySupported = typeof Float32Array === 'function'; 17 | 18 | function A(aA1, aA2) { 19 | return 1.0 - 3.0 * aA2 + 3.0 * aA1; 20 | } 21 | 22 | function B(aA1, aA2) { 23 | return 3.0 * aA2 - 6.0 * aA1; 24 | } 25 | 26 | function C(aA1) { 27 | return 3.0 * aA1; 28 | } 29 | 30 | // Returns x(t) given t, x1, and x2, or y(t) given t, y1, and y2. 31 | function calcBezier(aT, aA1, aA2) { 32 | return ((A(aA1, aA2) * aT + B(aA1, aA2)) * aT + C(aA1)) * aT; 33 | } 34 | 35 | // Returns dx/dt given t, x1, and x2, or dy/dt given t, y1, and y2. 36 | function getSlope(aT, aA1, aA2) { 37 | return 3.0 * A(aA1, aA2) * aT * aT + 2.0 * B(aA1, aA2) * aT + C(aA1); 38 | } 39 | 40 | function binarySubdivide(aX, aA, aB, mX1, mX2) { 41 | var currentX, currentT, i = 0; 42 | do { 43 | currentT = aA + (aB - aA) / 2.0; 44 | currentX = calcBezier(currentT, mX1, mX2) - aX; 45 | if (currentX > 0.0) { 46 | aB = currentT; 47 | } else { 48 | aA = currentT; 49 | } 50 | } while (Math.abs(currentX) > SUBDIVISION_PRECISION && ++i < SUBDIVISION_MAX_ITERATIONS); 51 | return currentT; 52 | } 53 | 54 | function newtonRaphsonIterate(aX, aGuessT, mX1, mX2) { 55 | for (var i = 0; i < NEWTON_ITERATIONS; ++i) { 56 | var currentSlope = getSlope(aGuessT, mX1, mX2); 57 | if (currentSlope === 0.0) { 58 | return aGuessT; 59 | } 60 | var currentX = calcBezier(aGuessT, mX1, mX2) - aX; 61 | aGuessT -= currentX / currentSlope; 62 | } 63 | return aGuessT; 64 | } 65 | 66 | export default function bezier(mX1, mY1, mX2, mY2) { 67 | if (!(0 <= mX1 && mX1 <= 1 && 0 <= mX2 && mX2 <= 1)) { 68 | throw new Error('bezier x values must be in [0, 1] range'); 69 | } 70 | 71 | // Precompute samples table 72 | var sampleValues = float32ArraySupported ? new Float32Array(kSplineTableSize) : new Array(kSplineTableSize); 73 | if (mX1 !== mY1 || mX2 !== mY2) { 74 | for (var i = 0; i < kSplineTableSize; ++i) { 75 | sampleValues[i] = calcBezier(i * kSampleStepSize, mX1, mX2); 76 | } 77 | } 78 | 79 | function getTForX(aX) { 80 | var intervalStart = 0.0; 81 | var currentSample = 1; 82 | var lastSample = kSplineTableSize - 1; 83 | 84 | for (; currentSample !== lastSample && sampleValues[currentSample] <= aX; ++currentSample) { 85 | intervalStart += kSampleStepSize; 86 | } 87 | --currentSample; 88 | 89 | // Interpolate to provide an initial guess for t 90 | var dist = (aX - sampleValues[currentSample]) / (sampleValues[currentSample + 1] - sampleValues[currentSample]); 91 | var guessForT = intervalStart + dist * kSampleStepSize; 92 | 93 | var initialSlope = getSlope(guessForT, mX1, mX2); 94 | if (initialSlope >= NEWTON_MIN_SLOPE) { 95 | return newtonRaphsonIterate(aX, guessForT, mX1, mX2); 96 | } else if (initialSlope === 0.0) { 97 | return guessForT; 98 | } else { 99 | return binarySubdivide(aX, intervalStart, intervalStart + kSampleStepSize, mX1, mX2); 100 | } 101 | } 102 | 103 | return function BezierEasing(x) { 104 | if (mX1 === mY1 && mX2 === mY2) { 105 | return x; // linear 106 | } 107 | // Because JavaScript number are imprecise, we should guarantee the extremes are right. 108 | if (x === 0) { 109 | return 0; 110 | } 111 | if (x === 1) { 112 | return 1; 113 | } 114 | return calcBezier(getTForX(x), mY1, mY2); 115 | }; 116 | }; 117 | -------------------------------------------------------------------------------- /core/utils/formatter.js: -------------------------------------------------------------------------------- 1 | export default { 2 | replaceKey(str, options) { 3 | for (let key in options) { 4 | let re = new RegExp("\\$\\$" + key + "\\$\\$", "g"); 5 | str = str.replace(re, options[key]); 6 | } 7 | return str; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /github_image/github_preview_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hanFengSan/eHunter/3ea92b674cd81d5b8648ca9e2fb641c1f77f7b31/github_image/github_preview_1.jpg -------------------------------------------------------------------------------- /github_image/github_preview_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hanFengSan/eHunter/3ea92b674cd81d5b8648ca9e2fb641c1f77f7b31/github_image/github_preview_2.jpg -------------------------------------------------------------------------------- /github_image/github_preview_3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hanFengSan/eHunter/3ea92b674cd81d5b8648ca9e2fb641c1f77f7b31/github_image/github_preview_3.jpg -------------------------------------------------------------------------------- /github_image/github_preview_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hanFengSan/eHunter/3ea92b674cd81d5b8648ca9e2fb641c1f77f7b31/github_image/github_preview_4.png -------------------------------------------------------------------------------- /github_image/github_preview_5_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hanFengSan/eHunter/3ea92b674cd81d5b8648ca9e2fb641c1f77f7b31/github_image/github_preview_5_1.png -------------------------------------------------------------------------------- /github_image/keyboard_arrow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hanFengSan/eHunter/3ea92b674cd81d5b8648ca9e2fb641c1f77f7b31/github_image/keyboard_arrow.jpg -------------------------------------------------------------------------------- /github_image/language.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hanFengSan/eHunter/3ea92b674cd81d5b8648ca9e2fb641c1f77f7b31/github_image/language.jpg -------------------------------------------------------------------------------- /github_image/open_ehunter.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hanFengSan/eHunter/3ea92b674cd81d5b8648ca9e2fb641c1f77f7b31/github_image/open_ehunter.jpg -------------------------------------------------------------------------------- /github_image/topbar_close.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hanFengSan/eHunter/3ea92b674cd81d5b8648ca9e2fb641c1f77f7b31/github_image/topbar_close.jpg -------------------------------------------------------------------------------- /ipad_cn.md: -------------------------------------------------------------------------------- 1 | ## iPad/iOS使用说明 2 | 1. 系统需要为iPadOS 15/iOS 15或以上 3 | 2. 在App Store安装userscripts, 说明:https://github.com/quoid/userscripts#installation 4 | 3. 在`设置->safari->扩展`中打开userscripts 5 | 4. 在设备上的文件App上创建一个子目录,并将eHunter执行文件放入其中(eHunter执行文件下载:https://github.com/hanFengSan/eHunter/releases) 6 | 5. 打开userscripts app,选择刚才创建的子目录 7 | 6. 在Safari打开e-hentai(非exhentai),浏览画廊 8 | 7. 在浏览画廊页面的地址栏刷新按钮左侧,有个扩展图标,点击允许userscripts访问该网站。然后刷新按钮左侧会有一个新增按钮,点击并确保eHunter是启用的 9 | 8. 刷新页面即可使用eHunter (nhentai可能需要刷新两次) 10 | 9. 目前还未针对移动端做用户体验优化,因此比较适合iPad使用,iPhone可能用起来不是很舒服。这个优化看大家的需求再决定是否需要做 -------------------------------------------------------------------------------- /ipad_en.md: -------------------------------------------------------------------------------- 1 | ## iPad/iOS Guide 2 | 1. The system needs to be iPadOS 15/iOS 15 or above 3 | 2. Install userscripts in App Store, Guide:https://github.com/quoid/userscripts#installation 4 | 3. Go to `Settings->safari->Extensions`, turn `userscripts` on 5 | 4. Create a subdirectory on the file App on the device and put the eHunter executable file into it (download the eHunter executable file:https://github.com/hanFengSan/eHunter/releases) 6 | 5. Open userscripts App,and select the subdirectory 7 | 6. Open e-hentai on Safari (non-exhentai),and view a gallery 8 | 7. On the left side of the refresh button in the address bar of the browse gallery page, there is an extension icon, click Allow userscripts to access the site. Then there will be a new button to the left of the refresh button, click and make sure eHunter is enabled 9 | 8. Refresh the page, then you can enjoy it (maybe need to refresh twice on nhentai) 10 | 9. I don't optimize the UX on mobile platform yet, so it may be have a bad UX on iPhone. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ehunter", 3 | "version": "2.8.0", 4 | "description": "A Vue.js project", 5 | "author": "Alex Chen ", 6 | "private": true, 7 | "scripts": { 8 | "dev": "webpack --config build/webpack.dev.conf.js", 9 | "build": "webpack --config build/webpack.prod.conf.js", 10 | "publish": "npm run test && npm run build && web-ext build -s dist -a publish_output", 11 | "publish_no_test": "npm run build && web-ext build -s dist -a publish_output --overwrite-dest", 12 | "test": "npm run unit", 13 | "unit": "cross-env BABEL_ENV=test karma start test/unit/karma.conf.js --single-run" 14 | }, 15 | "dependencies": { 16 | "@babel/plugin-transform-runtime": "^7.4.3", 17 | "@babel/polyfill": "^7.4.3", 18 | "clean-webpack-plugin": "^2.0.1", 19 | "copy-webpack-plugin": "^5.0.2", 20 | "generate-json-webpack-plugin": "^0.3.1", 21 | "html-loader": "^0.5.5", 22 | "html-res-webpack-plugin": "^4.0.4", 23 | "html-webpack-plugin": "^3.2.0", 24 | "json": "^9.0.6", 25 | "karma-babel-preprocessor": "^8.0.0", 26 | "karma-chrome-launcher": "^2.2.0", 27 | "less": "^2.7.2", 28 | "less-loader": "^4.0.0", 29 | "markdown-it": "^8.4.2", 30 | "markdown-it-emoji": "^1.4.0", 31 | "muse-ui": "^2.0.0-rc.5", 32 | "react-native-storage": "0.2.2", 33 | "style-loader": "^0.23.1", 34 | "ts-loader": "^5.3.3", 35 | "twemoji": "^12.0.1", 36 | "typescript": "^3.4.3", 37 | "uglifyjs-webpack-plugin": "^1.2.4", 38 | "vue": "^2.6.10", 39 | "vuex": "^3.1.0", 40 | "web-ext": "^1.8.1" 41 | }, 42 | "devDependencies": { 43 | "@babel/core": "^7.4.3", 44 | "@babel/preset-env": "^7.4.3", 45 | "@babel/preset-stage-0": "^7.0.0", 46 | "@babel/preset-stage-2": "^7.0.0", 47 | "@babel/runtime": "^7.4.3", 48 | "autoprefixer": "^9.5.1", 49 | "babel-eslint": "^10.0.1", 50 | "babel-loader": "^8.0.5", 51 | "chai": "^4.2.0", 52 | "chalk": "^2.4.2", 53 | "chromedriver": "^2.46.0", 54 | "connect-history-api-fallback": "^1.6.0", 55 | "cross-env": "^5.2.0", 56 | "cross-spawn": "^6.0.5", 57 | "css-loader": "^2.1.1", 58 | "eslint": "^5.16.0", 59 | "eslint-config-standard": "^12.0.0", 60 | "eslint-friendly-formatter": "^4.0.1", 61 | "eslint-loader": "^2.1.2", 62 | "eslint-plugin-html": "^5.0.3", 63 | "eslint-plugin-promise": "^4.1.1", 64 | "eslint-plugin-standard": "^4.0.0", 65 | "extract-text-webpack-plugin": "^3.0.2", 66 | "file-loader": "^3.0.1", 67 | "inject-loader": "^4.0.1", 68 | "karma": "^4.1.0", 69 | "karma-coverage": "^1.1.2", 70 | "karma-mocha": "^1.3.0", 71 | "karma-phantomjs-launcher": "^1.0.4", 72 | "karma-phantomjs-shim": "^1.5.0", 73 | "karma-sinon-chai": "^2.0.2", 74 | "karma-sourcemap-loader": "^0.3.7", 75 | "karma-spec-reporter": "0.0.32", 76 | "karma-webpack": "^3.0.5", 77 | "lolex": "^3.1.0", 78 | "mocha": "^6.1.3", 79 | "node-sass": "^4.12.0", 80 | "opn": "^4.0.1", 81 | "ora": "^3.4.0", 82 | "phantomjs-prebuilt": "^2.1.16", 83 | "sass-loader": "^7.1.0", 84 | "selenium-server": "^3.141.59", 85 | "semver": "^6.0.0", 86 | "shelljs": "^0.8.3", 87 | "sinon": "^7.3.2", 88 | "sinon-chai": "^3.3.0", 89 | "url-loader": "^1.1.2", 90 | "vue-loader": "^15.7.0", 91 | "vue-style-loader": "^4.1.2", 92 | "vue-template-compiler": "^2.6.10", 93 | "webpack": "^4.30.0", 94 | "webpack-bundle-analyzer": "^3.3.2", 95 | "webpack-cli": "^3.3.0", 96 | "webpack-dev-server": "^3.3.1", 97 | "webpack-merge": "^4.2.1" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/assets/img/ehunter_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hanFengSan/eHunter/3ea92b674cd81d5b8648ca9e2fb641c1f77f7b31/src/assets/img/ehunter_icon.png -------------------------------------------------------------------------------- /src/assets/unused/ehunter_icon_draft1.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hanFengSan/eHunter/3ea92b674cd81d5b8648ca9e2fb641c1f77f7b31/src/assets/unused/ehunter_icon_draft1.psd -------------------------------------------------------------------------------- /src/assets/unused/ehunter_icon_draft2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hanFengSan/eHunter/3ea92b674cd81d5b8648ca9e2fb641c1f77f7b31/src/assets/unused/ehunter_icon_draft2.png -------------------------------------------------------------------------------- /src/assets/unused/ehunter_icon_draft2.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hanFengSan/eHunter/3ea92b674cd81d5b8648ca9e2fb641c1f77f7b31/src/assets/unused/ehunter_icon_draft2.psd -------------------------------------------------------------------------------- /src/assets/unused/ehunter_icon_v1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hanFengSan/eHunter/3ea92b674cd81d5b8648ca9e2fb641c1f77f7b31/src/assets/unused/ehunter_icon_v1.png -------------------------------------------------------------------------------- /src/assets/unused/ehunter_icon_v2_128px.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hanFengSan/eHunter/3ea92b674cd81d5b8648ca9e2fb641c1f77f7b31/src/assets/unused/ehunter_icon_v2_128px.psd -------------------------------------------------------------------------------- /src/assets/unused/ehunter_icon_v2_origin.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hanFengSan/eHunter/3ea92b674cd81d5b8648ca9e2fb641c1f77f7b31/src/assets/unused/ehunter_icon_v2_origin.psd -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | version: '2.8.0', 3 | homePage: 'https://github.com/hanFengSan/eHunter', 4 | email: 'c360785655@gmail.com', 5 | updateServer1: 'https://jp.animesales.xyz/ehunter/update.json', 6 | updateServer2: 'https://jp.animesales.xyz/ehunter/update.json' 7 | }; 8 | -------------------------------------------------------------------------------- /src/legacy/app.popup.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 65 | 66 | 132 | -------------------------------------------------------------------------------- /src/legacy/background.js: -------------------------------------------------------------------------------- 1 | // a background script for background tasks 2 | import NotificationService from './service/NotificationService' 3 | 4 | // hack for test 5 | if (typeof chrome === 'undefined') { 6 | var chrome = { extension: { getViews: () => { return [] } } }; 7 | } 8 | 9 | /* eslint-disable no-undef */ 10 | const isActivedPopView = chrome ? (chrome.extension.getViews().length === 2) : false; 11 | 12 | // If user open popupView, Chrome will run two background.js, so we need to avoid this. 13 | if (!isActivedPopView) { 14 | let notificationService = new NotificationService(); 15 | notificationService.run(); 16 | } 17 | -------------------------------------------------------------------------------- /src/legacy/index.popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | EHUNTER 8 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/legacy/service/NotificationService.js: -------------------------------------------------------------------------------- 1 | // a background service for notifying tag's update 2 | import SubsStorage from './storage/SubsStorage' 3 | import NotiStorage from './storage/NotiStorage' 4 | import ReqQueueService from './request/ReqQueueService' 5 | import SearchHtmlParser from './parser/SearchHtmlParser' 6 | import Utils from '../utils/Utils' 7 | import lang from '../utils/lang' 8 | 9 | class NotificationService { 10 | constructor() { 11 | this.time = 0; // record the duration of running 12 | } 13 | 14 | static getTagUrl(item) { 15 | let url = ''; 16 | if (item.site.length > 0) { 17 | switch (item.site[0]) { 18 | case 'e-hentai': 19 | url += 'https://e-hentai.org/'; 20 | break; 21 | case 'exhentai': 22 | url += 'https://exhentai.org/'; 23 | break; 24 | } 25 | } 26 | let category = 1023; 27 | const value = { 28 | 'Misc': 1, 29 | 'Doujinshi': 2, 30 | 'Manga': 4, 31 | 'Artist CG': 8, 32 | 'Game CG': 16, 33 | 'Image Set': 32, 34 | 'Cosplay': 64, 35 | 'Asian Porn': 128, 36 | 'Non-H': 256, 37 | 'Western': 512, 38 | 'none': 0, 39 | } 40 | item.type.forEach(i => { 41 | category = category - value[i]; 42 | }); 43 | url += `?${category === 1023 ? '' : 'f_cats=' + category + '&'}`; 44 | const tags = item.name.replace(/,/g, ',').split(',').map(i => i.trim()); 45 | url = tags.reduce((sum, i) => { 46 | return sum + encodeURIComponent(`"${i}$"`) + '+' 47 | }, url + 'f_search=') 48 | if (item.lang.length > 0) { 49 | url += encodeURIComponent(`language:"${item.lang[0]}$"`); 50 | } 51 | url = url.replace(/\+$/g, '') 52 | return url; 53 | } 54 | 55 | log(msg) { 56 | console.log(`${new Date().toLocaleString()}: ${msg}`); 57 | } 58 | 59 | run() { 60 | /* eslint-disable no-undef */ 61 | this.log('NotiService run'); 62 | window.setInterval(async () => { 63 | console.log('run'); 64 | this.time += 10; // 叠加时间 65 | const tags = await this.getCheckedTags(); // 获取需要更新的tag 66 | const urls = tags.map(item => NotificationService.getTagUrl(item)); // 获取对应需要请求的url 67 | const htmlMaps = await this.getUrlHtmls(urls); // 获取url对应的html 68 | const notifications = []; 69 | for (let tag of tags) { 70 | const url = urls[tags.indexOf(tag)]; 71 | const html = htmlMaps.get(url); 72 | const msg = await this.compare(tag, url, html); 73 | if (msg) { 74 | notifications.push(msg); 75 | } 76 | } 77 | this.notify(notifications); 78 | }, 10 * 60 * 1000); // 10 mins 79 | // }, 10 * 1000); // debug: 5s 80 | } 81 | 82 | async getCheckedTags() { 83 | return (await SubsStorage.getSubsList()).filter(item => this.time % item.time === 0); // 获取需要检查的tag 84 | } 85 | 86 | async getUrlHtmls(urls) { 87 | return await (new ReqQueueService(urls)).setNumOfConcurrented(1).request(); 88 | } 89 | 90 | async compare(tag, url, html) { 91 | const oldResults = await NotiStorage.getResultsByName(tag.name); 92 | const newResults = new SearchHtmlParser(html).getResults(); 93 | let diffs = []; 94 | if (oldResults.length === 0) { 95 | diffs = newResults; 96 | } else { 97 | for (let item of newResults) { 98 | if (item.title !== oldResults[0].title) { 99 | diffs.push(item); 100 | } else { 101 | break; 102 | } 103 | } 104 | } 105 | if (diffs.length > 0) { 106 | await NotiStorage.putItem(tag.name, newResults); 107 | if (oldResults.length > 0) { 108 | return { 109 | name: tag.name, 110 | message: [`${tag.name} Updated: ${diffs.length >= 25 ? '>=25' : diffs.length} items`,`${tag.name}更新了${diffs.length >= 25 ? '>=25' : diffs.length}项`][lang], 111 | time: new Date().getTime(), 112 | updatedNum: diffs.length >= 25 ? '>=25' : diffs.length, 113 | url, 114 | diffs, 115 | type: tag.type 116 | }; 117 | } 118 | } 119 | } 120 | 121 | async notify(notifications) { 122 | if (notifications.length === 0) return; 123 | for (let item of notifications) { 124 | await Utils.sleep(100); 125 | chrome.notifications.create('EHUNTER_UPDATED_NOTI_' + item.time, { 126 | type: 'basic', 127 | title: ['TAG Update Notification','标签更新通知'][lang], 128 | iconUrl: './img/ehunter_icon.png', 129 | message: item.message, 130 | }, () => { 131 | NotiStorage.pushMsg(item); 132 | }); 133 | } 134 | } 135 | } 136 | 137 | export default NotificationService; 138 | -------------------------------------------------------------------------------- /src/legacy/service/PlatformService.js: -------------------------------------------------------------------------------- 1 | // a service for crossing platform 2 | /* eslint-disable no-undef */ 3 | 4 | // hack for test 5 | if (typeof chrome === 'undefined') { 6 | var chrome = { extension: null }; 7 | } 8 | 9 | export default { 10 | storage: { 11 | get sync() { 12 | if (chrome && chrome.storage) { 13 | return chrome.storage.sync.QUOTA_BYTES ? chrome.storage.sync : chrome.storage.local; 14 | } else { 15 | return window.localStorage; 16 | } 17 | }, 18 | local: window.localStorage 19 | }, 20 | getExtension() { 21 | return chrome.extension; 22 | }, 23 | fetch(url, option) { 24 | /* eslint-disable camelcase */ 25 | if (typeof GM_info !== 'undefined' && GM_info.version) { // the ENV is Tampermonkey 26 | return new Promise((resolve, reject) => { 27 | GM_xmlhttpRequest({ 28 | method: option.method, 29 | url, 30 | onload: x => { 31 | let responseText = x.responseText; 32 | x.text = async function() { 33 | return responseText; 34 | } 35 | resolve(x); 36 | }, 37 | onerror: e => { 38 | reject(`GM_xhr error, ${e.status}`); 39 | } 40 | }); 41 | }); 42 | } else { // the ENV is Chrome or Firefox 43 | return window.fetch(url, option); 44 | } 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /src/legacy/service/parser/SearchHtmlParser.js: -------------------------------------------------------------------------------- 1 | // a parser for search page 2 | class SearchHtmlParser { 3 | constructor(html) { 4 | this.htmlText = html; 5 | this.html = document.createElement('html'); 6 | this.html.innerHTML = html.replace(/src=/g, 'x-src=').replace(/stylesheet/g, 'x-stylesheet'); // avoid load assets 7 | this.document = this.html.ownerDocument; 8 | return this; 9 | } 10 | 11 | getType() { 12 | const classList = this.html.querySelector('.itg').classList; 13 | if (classList.contains('gltm')) { 14 | return 'Minimal'; 15 | } 16 | if (classList.contains('gltc')) { 17 | return 'Compact'; 18 | } 19 | if (classList.contains('glte')) { 20 | return 'Extended'; 21 | } 22 | if (classList.contains('glte')) { 23 | return 'Extended'; 24 | } 25 | if (classList.contains('gld')) { 26 | return 'Thumbnail'; 27 | } 28 | throw new Error('cannot get type'); 29 | } 30 | 31 | getResults() { 32 | const type = this.getType(); 33 | let items; 34 | switch (type) { 35 | case 'Minimal': 36 | case 'Compact': 37 | items = [...this.html.querySelectorAll('.glink')]; 38 | return items.map(i => ({ 39 | title: i.textContent, 40 | url: i.parentElement.getAttribute('href'), 41 | })); 42 | case 'Extended': 43 | case 'Thumbnail': 44 | items = [...this.html.querySelectorAll('.glname')]; 45 | return items.map(i => ({ 46 | title: i.textContent, 47 | url: i.parentElement.getAttribute('href'), 48 | })); 49 | } 50 | } 51 | } 52 | 53 | export default SearchHtmlParser; 54 | -------------------------------------------------------------------------------- /src/legacy/service/request/MultiAsyncReqService.js: -------------------------------------------------------------------------------- 1 | // a service for sync multi asynchronous text requests 2 | import TextReqService from './TextReqService.js' 3 | 4 | class MultiAsyncReqService { 5 | constructor(urls) { 6 | this.urls = urls; 7 | this.resultMap = new Map(); 8 | this.fetchSetting = null; 9 | } 10 | 11 | request() { 12 | return new Promise((resolve, reject) => { 13 | this._initGenerator(resolve, reject); 14 | this._request(); 15 | }); 16 | } 17 | 18 | setFetchSetting(setting) { 19 | this.fetchSetting = setting; 20 | return this; 21 | } 22 | 23 | _initGenerator(resolve, reject) { 24 | let self = this; 25 | this.gen = (function* () { 26 | try { 27 | for (let url of self.urls) { 28 | let item = yield url; 29 | self.resultMap.set(item.url, item.html); 30 | } 31 | resolve(self.resultMap); 32 | } catch (err) { 33 | reject(err); 34 | } 35 | })(); 36 | this.gen.next(); // run to first yield 37 | } 38 | 39 | _request() { 40 | for (let url of this.urls) { 41 | (new TextReqService(url)) 42 | .setFetchSetting(this.fetchSetting) 43 | .request() 44 | .then(html => this.gen.next({ url: url, html: html }, 45 | err => this.gen.throw(err))); 46 | } 47 | } 48 | 49 | } 50 | 51 | export default MultiAsyncReqService; 52 | -------------------------------------------------------------------------------- /src/legacy/service/request/ReqQueueService.js: -------------------------------------------------------------------------------- 1 | // a service for limiting num of async requests, avoiding too many concurrent requests 2 | import MultiAsyncReqService from './MultiAsyncReqService.js' 3 | 4 | class ReqQueueService { 5 | constructor(urls) { 6 | this.urls = urls; 7 | this.maxConcurrentedNum = 5; 8 | this.resultMap = new Map(); 9 | this.fetchSetting = null; 10 | } 11 | 12 | setNumOfConcurrented(num) { 13 | this.maxConcurrentedNum = num; 14 | return this; 15 | } 16 | 17 | setFetchSetting(setting) { 18 | this.fetchSetting = setting; 19 | return this; 20 | } 21 | 22 | request() { 23 | return new Promise((resolve, reject) => { 24 | let reqList = this._splitReqs(); 25 | this._request(reqList, resolve, reject); 26 | }); 27 | } 28 | 29 | _splitReqs() { 30 | if (this.urls.length < this.maxConcurrentedNum) { 31 | return [this.urls]; 32 | } 33 | let results = []; 34 | let urls = JSON.parse(JSON.stringify(this.urls)); 35 | while (true) { 36 | let list = urls.splice(0, this.maxConcurrentedNum); 37 | if (list.length > 0) { 38 | results.push(list); 39 | } else { 40 | return results; 41 | } 42 | } 43 | } 44 | 45 | _addMap(destMap, srcMap) { 46 | for (let item of srcMap) { 47 | destMap.set(item[0], item[1]); 48 | } 49 | return destMap; 50 | } 51 | 52 | _request(reqList, resolve, reject) { 53 | if (reqList.length > 0) { 54 | (new MultiAsyncReqService(reqList[0])) 55 | .setFetchSetting(this.fetchSetting) 56 | .request() 57 | .then(map => { 58 | this._addMap(this.resultMap, map); 59 | reqList.splice(0, 1); 60 | this._request(reqList, resolve, reject); 61 | }, err => { reject(err) }); 62 | } else { 63 | resolve(this.resultMap); 64 | } 65 | } 66 | } 67 | 68 | export default ReqQueueService; 69 | -------------------------------------------------------------------------------- /src/legacy/service/request/TextReqService.js: -------------------------------------------------------------------------------- 1 | // a good resolution for poor network 2 | import PlatformService from '../PlatformService' 3 | 4 | class TextReqService { 5 | constructor(url, noCache = false, rejectError = true) { 6 | this.url = url; 7 | this.method = 'GET'; 8 | this.credentials = 'include'; 9 | this.retryTimes = 3; 10 | this.timeoutTime = 15; // secs 11 | this.curRetryTimes = 0; 12 | this.retryInterval = 3; // secs 13 | this.enabledLog = true; 14 | this.fetchSetting = null; 15 | this.noCache = noCache; 16 | this.rejectError = rejectError; 17 | } 18 | 19 | setMethod(method) { 20 | this.method = method; 21 | return this; 22 | } 23 | 24 | setCredentials(credential) { 25 | this.credentials = credential; 26 | return this; 27 | } 28 | 29 | setFetchSetting(setting) { 30 | this.fetchSetting = setting; 31 | return this; 32 | } 33 | 34 | setRetryTimes(times) { 35 | this.retryTimes = times; 36 | } 37 | 38 | setRetryInterval(secs) { 39 | this.retryInterval = secs; 40 | } 41 | 42 | setTimeOutTime(secs) { 43 | this.timeoutTime = secs; 44 | } 45 | 46 | request() { 47 | return new Promise((resolve, reject) => { 48 | this._request(res => { 49 | res.text().then(text => resolve(text)); 50 | }, err => { 51 | if (this.rejectError) { 52 | reject(err); 53 | } else { 54 | console.error(err); 55 | } 56 | }); 57 | }); 58 | } 59 | 60 | _printErrorLog(err) { 61 | console.error(`TextReqService: request error in ${this.url}, retry:(${this.curRetryTimes}/${this.retryTimes}), error: ${err}`); 62 | } 63 | 64 | _request(successCallback, failureCallback) { 65 | this.curRetryTimes++; 66 | let url = this.url.includes('http') ? this.url : `${window.location.protocol}//${window.location.host}${this.url}`; 67 | if (this.noCache) { 68 | url = `${url}?_t=${new Date().getTime()}`; 69 | } 70 | let timeout = new Promise((resolve, reject) => { 71 | setTimeout(reject, this.timeoutTime * 1000 * this.curRetryTimes, 'request timed out'); 72 | }); 73 | let req = PlatformService.fetch(url, this.fetchSetting ? this.fetchSetting : { 74 | method: this.method, 75 | credentials: this.credentials 76 | }); 77 | Promise 78 | .race([timeout, req]) 79 | .then(res => { 80 | if (res.status === 200) { 81 | successCallback(res); 82 | } else { 83 | throw new Error(`${url}: ${res.status}`); 84 | } 85 | }) 86 | .catch(err => { 87 | this._printErrorLog(err); 88 | if (this.curRetryTimes < this.retryTimes) { 89 | setTimeout(() => { 90 | this._request(successCallback, failureCallback); 91 | }, this.retryInterval * 1000); 92 | } else { 93 | failureCallback(err); 94 | } 95 | }); 96 | } 97 | } 98 | 99 | export default TextReqService; 100 | -------------------------------------------------------------------------------- /src/legacy/service/storage/NotiStorage.js: -------------------------------------------------------------------------------- 1 | import BaseStorage from './base'; 2 | 3 | class NotiStorage extends BaseStorage { 4 | constructor() { 5 | super(); 6 | this.name = 'notiV2'; 7 | this.default = {}; 8 | } 9 | 10 | async getResultsByName(name) { 11 | const noti = await this.get(); 12 | return JSON.parse(JSON.stringify(noti[name] || [])); 13 | } 14 | 15 | async putItem(name, results) { 16 | const noti = await this.get(); 17 | noti[name] = JSON.parse(JSON.stringify(results)); 18 | await this.save(noti); 19 | } 20 | 21 | async getMsgList() { 22 | const noti = await this.get(); 23 | return JSON.parse(JSON.stringify(noti.msg || [])); 24 | } 25 | 26 | async pushMsg(item) { 27 | const noti = await this.get(); 28 | if (!noti.msg) { 29 | noti.msg = []; 30 | } 31 | // cut down size if too big 32 | if (noti.msg.length > 100) { 33 | noti.msg.splice(0, 50); 34 | console.log('cut down size of noti cache'); 35 | } 36 | noti.msg.push(item); 37 | this.save(noti); 38 | } 39 | 40 | async clearMsg() { 41 | const noti = await this.get(); 42 | noti.msg = []; 43 | this.save(noti); 44 | } 45 | } 46 | 47 | const instance = new NotiStorage(); 48 | export default instance; -------------------------------------------------------------------------------- /src/legacy/service/storage/SubsStorage.js: -------------------------------------------------------------------------------- 1 | import BaseStorage from './base'; 2 | 3 | class SubsStorage extends BaseStorage { 4 | constructor() { 5 | super(); 6 | this.name = 'subsV2'; 7 | this.storageType = 'sync'; 8 | this.default = {}; 9 | } 10 | 11 | async migrateOldData() { 12 | try { 13 | const oldData = await this.getByName('subs', 'local'); 14 | if (oldData && oldData.list && oldData.list.length > 0) { 15 | const newData = await this.get(); 16 | if (newData.list && newData.list.length > 0) { 17 | for (let item of oldData.list) { 18 | if (!newData.list.find(i => i.name === item.name)) { 19 | newData.list.push(item); 20 | } 21 | } 22 | } else { 23 | newData.list = oldData.list; 24 | } 25 | await this.save(newData); 26 | this.delByName('subs', 'local'); 27 | } 28 | } catch(e) { 29 | } 30 | } 31 | 32 | async getSubsList() { 33 | await this.migrateOldData(); 34 | const subs = await this.get(); 35 | return JSON.parse(JSON.stringify(subs.list || [])); 36 | } 37 | 38 | async addSubsItem(item) { 39 | const subs = await this.get(); 40 | if (!subs.list) { 41 | subs.list = []; 42 | } 43 | subs.list.push(item); 44 | await this.save(subs); 45 | } 46 | 47 | async delSubsItemByName(name) { 48 | const subs = await this.get(); 49 | const target = subs.list.find(i => i.name === name); 50 | if (target) { 51 | subs.list.splice(subs.list.indexOf(target), 1); 52 | } 53 | await this.save(subs); 54 | } 55 | } 56 | 57 | const instance = new SubsStorage(); 58 | export default instance; 59 | -------------------------------------------------------------------------------- /src/legacy/service/storage/base.js: -------------------------------------------------------------------------------- 1 | export default class BaseStorage { 2 | constructor() { 3 | this.name = 'fill_in_child'; 4 | this.storageType = 'local'; 5 | this.default = {}; 6 | } 7 | 8 | get() { 9 | return new Promise((resolve, reject) => { 10 | chrome.storage[this.storageType].get(this.name, async res => { 11 | let data = res[this.name]; 12 | if (typeof data === 'undefined') { 13 | data = this.default; 14 | await this.save(data); 15 | } 16 | resolve(data); 17 | }); 18 | }); 19 | } 20 | 21 | getByName(name, type) { 22 | return new Promise((resolve, reject) => { 23 | chrome.storage[type || this.storageType].get(name, async res => { 24 | let data = res[name]; 25 | resolve(data); 26 | }); 27 | }); 28 | } 29 | 30 | delByName(name, type) { 31 | chrome.storage[type || this.storageType].remove(name); 32 | } 33 | 34 | save(data) { 35 | return new Promise((resolve, reject) => { 36 | chrome.storage[this.storageType].set({ [this.name]: data }, () => resolve()); 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/legacy/service/type/GalleryType.js: -------------------------------------------------------------------------------- 1 | // types and colors of album type 2 | const types = { 3 | 'Doujinshi': '#e74c3c', 4 | 'Manga': '#e67e22', 5 | 'Artist CG': '#f1c40f', 6 | 'Game CG': '#27ae60', 7 | 'Western': '#2ecc71', 8 | 'Non-H': '#3498db', 9 | 'Image Set': '#2980b9', 10 | 'Cosplay': '#9b59b6', 11 | 'Asian Porn': '#8e44ad', 12 | 'Misc': '#bdc3c7', 13 | 'none': '#2c3e50' 14 | }; 15 | 16 | export default { 17 | getTypeColor(type) { 18 | return types[type] || types.none; 19 | }, 20 | getTypes() { 21 | return Object.keys(types); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /src/legacy/service/type/UpdateInterval.js: -------------------------------------------------------------------------------- 1 | import lang from '../../utils/lang' 2 | 3 | // types of time in subscription 4 | const intervals = { 5 | '10': ['10min', '10分钟'][lang], 6 | '30': ['0.5h', '0.5小时'][lang], 7 | '180': ['3h', '3小时'][lang], 8 | '360': ['6h', '6小时'][lang], 9 | '720': ['12h', '12小时'][lang], 10 | }; 11 | 12 | export default { 13 | getText(val) { 14 | return intervals[val]; 15 | }, 16 | getVal(text) { 17 | for (let key in intervals) { 18 | if (intervals[key].includes(text)) { 19 | return Number(key); 20 | } 21 | } 22 | }, 23 | getTypes() { 24 | let results = []; 25 | for (let key in intervals) { 26 | results.push(intervals[key]); 27 | } 28 | return results; 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/legacy/style/_variables.scss: -------------------------------------------------------------------------------- 1 | // color 2 | $primary_color: hsl(145, 63%, 49%); 3 | $light_primary_color: hsl(145, 63%, 60%); 4 | $split_grey: #DDDDDD; 5 | $accent_color: hsl(145, 63%, 42%); 6 | $background_grey: #EEEEEE; 7 | $contrast_color: #f1c40f; 8 | $table_grey: #f7f7f7; 9 | $text_grey: #777777; 10 | 11 | $slider_track_bg: #bdbdbd; 12 | $slider_track_fill_color: hsl(145, 63%, 42%); 13 | $slider_thumb_color: hsl(145, 63%, 49%); 14 | 15 | $flat_button_positive_color: hsl(145, 63%, 49%); 16 | $flat_button_positive_light_color: lighten($flat_button_positive_color, 10%); 17 | $flat_button_positive_dark_color: darken($flat_button_positive_color, 10%); 18 | $flat_button_negative_color: #AAAAAA; 19 | $flat_button_negative_light_color: lighten($flat_button_negative_color, 10%); 20 | $flat_button_negative_dark_color: darken($flat_button_negative_color, 10%); 21 | $flat_button_plain_color: hsl(145, 63%, 42%); 22 | $flat_button_plain_light_color: lighten($flat_button_plain_color, 10%); 23 | $flat_button_plain_dark_color: darken($flat_button_plain_color, 10%); 24 | $flat_button_warning_color: #e74c3c; 25 | $flat_button_warning_light_color: lighten($flat_button_warning_color, 10%); 26 | $flat_button_warning_dark_color: darken($flat_button_warning_color, 10%); 27 | 28 | $switch_track_disabled_color: #bdbdbd; 29 | $switch_track_enabled_color: #71ca96; 30 | $switch_thumb_disabled_color: #f5f5f5; 31 | $switch_thumb_enabled_color: #006548; 32 | 33 | $top_bar_float_btn_bg: rgba(0, 0, 0, 0.5); 34 | $top_bar_float_btn_icon_color: rgba(255, 255, 255, 0.9); 35 | $top_bar_float_btn_hover_bg: rgba(255, 255, 255, 0.9); 36 | $top_bar_float_btn_hover_icon_color: rgba(0, 0, 0, 0.5); 37 | $top_bar_float_btn_active_bg: rgba(255, 255, 255, 0.2); 38 | $top_bar_float_btn_active_icon_color: rgba(0, 0, 0, 0.5); 39 | 40 | $pagination_icon_active_color: #c9cacf; 41 | $pagination_icon_disabled_color: rgba(#c9cacf, 0.6); 42 | $pagination_icon_hovered_color: white; 43 | $pagination_item_text_normal_color: #c9cacf; 44 | $pagination_item_text_actived__color: white; 45 | $pagination_item_text_hovered__color: white; 46 | $pagination_item_background_actived__color: hsl(145, 63%, 49%); 47 | $pagination_item_background_hovered__color: #777777; 48 | 49 | $page_view_thumb_mask_color: rgba(0, 0, 0, 0.5); 50 | $page_view_index_color: rgba(255, 255, 255, 0.5); 51 | $page_view_border_color: hsl(231, 6%, 36%); 52 | $page_view_info_color: white; 53 | $page_view_loading_btn_color: rgba(255, 255, 255, 0.8); 54 | $page_view_loading_btn_hovered_color: hsl(145, 63%, 60%); 55 | $page_view_loading_btn_actived_color: hsl(145, 63%, 30%); 56 | 57 | $reader_view_location_color: hsl(145, 63%, 42%); 58 | $reader_view_full_screen_color: hsl(145, 63%, 42%); 59 | $reader_view_full_screen_hovered_color: hsl(145, 63%, 60%); 60 | $reader_view_loading_color: rgba(255,255,255,0.1); 61 | 62 | $book_view_title_color: rgba(0, 0, 0, 0.8); 63 | $book_view_ehunter_tag_bg_color: hsl(145, 63%, 42%); 64 | $book_view_page_bg: white; 65 | $book_view_ehunter_tag_bg: hsl(145, 63%, 42%); 66 | $book_view_ehunter_tag_text_color: white; 67 | $book_view_end_page_text_color: rgba(0, 0, 0, 0.7); 68 | $book_view_pagination_bg: rgb(51, 51, 51); 69 | 70 | $modal_view_bg: rgba(0, 0, 0, 0.6); 71 | 72 | 73 | 74 | 75 | /* mussy */ 76 | $body_bg: #333333; // directly use in app.inject.js 77 | $img_container_color:hsl(235, 16%, 13%); 78 | $title_color:hsl(231, 6%, 80%); 79 | 80 | // thumb-view 81 | $thumb-view-width: 150px; 82 | $thumb-view-height: 160px; 83 | $indicator_color: white; 84 | $thumb-width: 100px; 85 | $thumb_scroll_view_bg: #444444; 86 | $thumb-view-margin: 4px; 87 | $header-bg: #2ecc71; 88 | $header-height: 40px; 89 | 90 | // popup view 91 | $popup_primary_color: hsl(145, 63%, 49%); 92 | $popup_alternate_text_color: white; 93 | $popup_text_color: hsla(0, 0%, 0%, .67); 94 | $popup_secondary_text_color: hsla(0, 0%, 0%, .54); 95 | $popup_addition_bg: hsla(0, 0%, 97%, 1); 96 | -------------------------------------------------------------------------------- /src/legacy/style/muse-ui/index.less: -------------------------------------------------------------------------------- 1 | @import "./muse-ui.less"; 2 | @import "./theme.less"; -------------------------------------------------------------------------------- /src/legacy/style/muse-ui/theme-color.less: -------------------------------------------------------------------------------- 1 | @import "./colors.less"; 2 | 3 | @primaryColor: hsl(145, 63%, 49%); 4 | @alternateTextColor: white; 5 | @lighterPrimaryColor: hsl(0, 0%, 74%); 6 | @accentColor: hsla(0, 0%, 100%, .87); 7 | @darkerAccentColor: hsl(0, 0%, 90%); 8 | @fontFamily: 'San Francisco', 'Helvetica', Arial, "Hiragino Sans GB", "Heiti SC",//macOS & ios 9 | "Microsoft YaHei", //windows 10 | 'Droid Sans', // android default 11 | 'WenQuanYi Micro Hei', // linux 12 | sans-serif; 13 | @textColor: hsla(0, 0%, 0%,.67); 14 | @backgroundColor: white; 15 | @dialogBackgroundColor: white; 16 | @borderColor: @lightBlack; 17 | @secondaryTextColor: hsla(0 , 0%, 0%, .54); 18 | @disabledColor: @darkWhite; 19 | -------------------------------------------------------------------------------- /src/legacy/utils/DateUtil.js: -------------------------------------------------------------------------------- 1 | import lang from './lang'; 2 | 3 | export default { 4 | getIntervalFromNow(date) { 5 | let now = new Date().getTime(); 6 | let start = date instanceof Date ? date.getTime() : date; 7 | let interval = now - start; 8 | if (interval < 60 * 1000) { // sec level 9 | return `${(interval / 1000).toFixed(0)}${['s ago', '秒前'][lang]}`; 10 | } 11 | if (60 * 1000 <= interval && interval < 60 * 60 * 1000) { // min level 12 | return `${(interval / (60 * 1000)).toFixed(0)}${['m ago', '分钟前'][lang]}`; 13 | } 14 | if (60 * 60 * 1000 <= interval && interval < 24 * 60 * 60 * 1000) { // hour level 15 | return `${(interval / (60 * 60 * 1000)).toFixed(0)}${['h ago', '小时前'][lang]}`; 16 | } 17 | if (24 * 60 * 60 * 1000 && interval < 365 * 24 * 60 * 60 * 1000) { // day level 18 | return `${(interval / (24 * 60 * 60 * 1000)).toFixed(0)}${['d ago', '天前'][lang]}`; 19 | } 20 | return `${(interval / (365 * 24 * 60 * 60 * 1000)).toFixed(0)}${['y ago', '年'][lang]}`; // year level 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/legacy/utils/DateWrapper.js: -------------------------------------------------------------------------------- 1 | export default class DateWrapper { 2 | constructor(date) { 3 | if (date) { 4 | this.date = date; 5 | } else { 6 | this.date = new Date(); 7 | } 8 | } 9 | 10 | _paddy(n, p, c) { 11 | let padChar = typeof c !== 'undefined' ? c : '0'; 12 | let pad = new Array(1 + p).join(padChar); 13 | return (pad + n).slice(-pad.length); 14 | } 15 | 16 | addDays(days) { 17 | this.date.setDate(this.date.getDate() + days); 18 | return this; 19 | } 20 | 21 | addMonths(month) { 22 | this.date.setMonth(this.date.getMonth() + month); 23 | return this; 24 | } 25 | 26 | addYears(Years) { 27 | this.date.setFullYear(this.date.getFullYear() + Years); 28 | return this; 29 | } 30 | 31 | getDate() { 32 | return this.date; 33 | } 34 | 35 | toString(pattern) { 36 | pattern = pattern || 'yyyy/MM/dd HH:mm:ss'; 37 | let month = this.date.getMonth() + 1 // begin from 0 38 | let day = this.date.getDate() // not getDay(), it's wrong 39 | let year = this.date.getFullYear(); 40 | let hour = this.date.getHours(); 41 | let min = this.date.getMinutes(); 42 | let sec = this.date.getSeconds(); 43 | pattern = pattern 44 | .replace('MM', this._paddy(month, 2)) 45 | .replace('dd', this._paddy(day, 2)) 46 | .replace('HH', this._paddy(hour, 2)) 47 | .replace('mm', this._paddy(min, 2)) 48 | .replace('ss', this._paddy(sec, 2)); 49 | if (pattern.includes('yyyy')) { 50 | pattern = pattern.replace('yyyy', year); 51 | } else if (pattern.includes('yy')) { 52 | pattern = pattern.replace('yy', year % 100); 53 | } 54 | return pattern; 55 | } 56 | 57 | toGMTString() { 58 | return this.date.toGMTString(); 59 | } 60 | 61 | setTimeFromDate(date) { 62 | this.date.setHours(date.getHours()); 63 | this.date.setMinutes(date.getMinutes()); 64 | this.date.setSeconds(date.getSeconds()); 65 | return this; 66 | } 67 | 68 | setDateFromDate(date) { 69 | this.date.setMonth(date.getMonth()); 70 | this.date.setDate(date.getDate()); 71 | this.date.setFullYear(date.getFullYear()); 72 | return this; 73 | } 74 | 75 | clearTime() { 76 | this.date.setHours(0); 77 | this.date.setMinutes(0); 78 | this.date.setSeconds(0); 79 | return this; 80 | } 81 | 82 | clearDay() { 83 | this.date.setDate(1); 84 | this.clearTime(); 85 | return this; 86 | } 87 | 88 | clearMonth() { 89 | this.date.setMonth(0); 90 | this.clearDay(); 91 | return this; 92 | } 93 | } -------------------------------------------------------------------------------- /src/legacy/utils/Logger.js: -------------------------------------------------------------------------------- 1 | class Logger { 2 | logText(tag, text) { 3 | console.log(`%c[${tag}] %c${text}`, 'color:red', 'color:black'); 4 | } 5 | 6 | logObj(tag, obj, str = false) { 7 | this.logText(tag, ':'); 8 | console.log(str ? JSON.parse(JSON.stringify(obj)) : obj); 9 | this.logText(tag, '----------'); 10 | } 11 | } 12 | 13 | let instance = new Logger(); 14 | export default instance; 15 | -------------------------------------------------------------------------------- /src/legacy/utils/Utils.js: -------------------------------------------------------------------------------- 1 | export default { 2 | sleep(ms) { 3 | return new Promise(resolve => setTimeout(resolve, ms)); 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /src/legacy/utils/VueUtil.js: -------------------------------------------------------------------------------- 1 | export default { 2 | methods: { 3 | px(num) { 4 | return `${num}px`; 5 | }, 6 | range(start, count) { 7 | return Array.apply(0, Array(count)) 8 | .map(function(element, index) { 9 | return index + start; 10 | }); 11 | } 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/legacy/utils/lang.js: -------------------------------------------------------------------------------- 1 | const lang = (navigator.languages && navigator.languages[0] || '').includes('zh') ? 1 : 0; 2 | export default lang; 3 | -------------------------------------------------------------------------------- /src/main.inject.js: -------------------------------------------------------------------------------- 1 | import EHPlatform from './platform/eh' 2 | import NHPlatform from './platform/nh' 3 | 4 | switch (window.location.host) { 5 | case 'exhentai.org': 6 | case 'e-hentai.org': 7 | new EHPlatform().init(); 8 | break; 9 | case 'nhentai.net': 10 | new NHPlatform().init(); 11 | } 12 | -------------------------------------------------------------------------------- /src/main.popup.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './legacy/app.popup.vue' 3 | import MuseUI from 'muse-ui' 4 | import './legacy/style/muse-ui/index.less' 5 | 6 | Vue.use(MuseUI) 7 | 8 | /* eslint-disable no-unused-vars */ 9 | const app = new Vue({ 10 | render: (h) => h(App) 11 | }).$mount('#app') 12 | -------------------------------------------------------------------------------- /src/manifest.js: -------------------------------------------------------------------------------- 1 | let config = require('./config'); 2 | 3 | module.exports.chrome = { 4 | 'manifest_version': 2, 5 | 'name': 'eHunter - more powerful e-hentai/exhentai!', 6 | 'short_name': 'eHunter', 7 | 'description': 'More powerful e-hentai/eHentai/exhentai! Scroll and Book view', 8 | 'version': config.version, 9 | 'content_security_policy': 'script-src \'self\' \'unsafe-eval\'; object-src \'self\'', 10 | 'browser_action': { 11 | 'default_popup': 'popup.html', 12 | 'default_title': 'eHunter', 13 | 'default_icon': { 14 | '16': './img/ehunter_icon.png', 15 | '32': './img/ehunter_icon.png' 16 | } 17 | }, 18 | 'icons': { 19 | '16': './img/ehunter_icon.png', 20 | '24': './img/ehunter_icon.png', 21 | '48': './img/ehunter_icon.png', 22 | '96': './img/ehunter_icon.png', 23 | '128': './img/ehunter_icon.png' 24 | }, 25 | 'author': 'Alex Chen', 26 | 'incognito': 'spanning', 27 | 'permissions': [ 28 | 'activeTab', 29 | 'https://alexskye.info/', 30 | 'https://www.alexskye.info/', 31 | 'http://alexskye.info/', 32 | 'https://exhentai.org/', 33 | 'http://www.alexskye.info/', 34 | 'http://githubusercontent.com/', 35 | 'https://githubusercontent.com/', 36 | 'https://nhentai.net/', 37 | 'https://e-hentai.org/', 38 | 'https://raw.githubusercontent.com/', 39 | 'http://raw.githubusercontent.com/', 40 | 'https://jp.animesales.xyz/', 41 | 'http://jp.animesales.xyz/', 42 | 'storage', 43 | 'background', 44 | 'notifications', 45 | 'cookies' 46 | ], 47 | 'content_scripts': [ 48 | { 49 | 'matches': [ 50 | '*://alexskye.org/*', 51 | '*://alexskye.info/*', 52 | '*://anime-sales.com/*', 53 | '*://exhentai.org/*', 54 | '*://hanfengsan.org/*', 55 | '*://e-hentai.org/*', 56 | '*://nhentai.net/*', 57 | '*://mingzuozhibiba.cn/*' 58 | ], 59 | 'js': [ 60 | 'inject.js' 61 | ], 62 | 'run_at': 'document_end' 63 | } 64 | ], 65 | 'web_accessible_resources': [ 66 | 'img/*' 67 | ], 68 | 'background': { 69 | 'scripts': ['background.js'] 70 | } 71 | }; 72 | 73 | module.exports.tampermonkey = 74 | `// ==UserScript== 75 | // @name 76 | // @namespace http://tampermonkey.net/ 77 | // @version ${config.version} 78 | // @description This extension provides a scroll mode and book mode to e-hentai/exhentai/nhentai, for the best reading experince! 此扩展为e-hentai/exhentai/nhentai提供一个滚动模式和书本模式, 提供良好的阅读体验. 79 | // @supportURL https://github.com/hanFengSan/eHunter/issues 80 | // @author Alex Chen 81 | // @match https://exhentai.org/* 82 | // @match https://e-hentai.org/* 83 | // @match https://nhentai.net/* 84 | // @connect githubusercontent.com 85 | // @connect jp.animesales.xyz 86 | // @grant GM_xmlhttpRequest 87 | // @license MIT 88 | // ==/UserScript== 89 | ` -------------------------------------------------------------------------------- /src/platform/base/index.ts: -------------------------------------------------------------------------------- 1 | import core from '../../../core' 2 | 3 | export abstract class BasePlatform { 4 | abstract isAlbumViewPage(): boolean; 5 | 6 | // add ehunter switch etc. 7 | async init(): Promise { 8 | if (this.isAlbumViewPage()) { 9 | this.createEhunterSwitch(); 10 | if (await core.SettingService.getEHunterStatus()) { 11 | this.toggleEHunterView(true); 12 | } 13 | } 14 | } 15 | 16 | createEhunterSwitch() { 17 | let container = document.createElement('div'); 18 | container.style.display = 'flex'; 19 | container.style.flexDirection = 'column'; 20 | container.style.justifyContent = 'center'; 21 | container.style.alignItems = 'center'; 22 | container.style.position = 'absolute'; 23 | container.style.right = '100px'; 24 | container.style.top = '-150px'; 25 | container.style.zIndex = '10'; 26 | container.style.cursor = 'pointer'; 27 | container.style.transition = 'all 0.2s cubic-bezier(.46,-0.23,.37,2.38)'; 28 | container.setAttribute('title', 'open eHunter'); 29 | container.setAttribute('id', 'switch'); 30 | container.addEventListener('click', this.openEhunterBySwitch.bind(this)); 31 | 32 | let line = document.createElement('span'); 33 | line.style.width = '2px'; 34 | line.style.height = '200px'; 35 | line.style.background = '#2ecc71'; 36 | line.style.boxShadow = '0 1px 6px rgba(0,0,0,.117647), 0 1px 4px rgba(0,0,0,.117647)'; 37 | container.appendChild(line); 38 | 39 | let ring = document.createElement('span'); 40 | ring.style.border = '2px solid #2ecc71'; 41 | ring.style.borderRadius = '50%'; 42 | ring.style.width = '15px'; 43 | ring.style.height = '15px'; 44 | ring.style.boxShadow = '0 1px 6px rgba(0,0,0,.117647), 0 1px 4px rgba(0,0,0,.117647)'; 45 | container.appendChild(ring); 46 | 47 | document.body.appendChild(container); 48 | } 49 | 50 | // when user click the ehunter switch 51 | openEhunterBySwitch() { 52 | var element = document.querySelector('#switch'); 53 | if (element) { 54 | element.style.top = '-50px'; 55 | window.setTimeout(() => { 56 | if (element) { 57 | element.style.top = '-150px'; 58 | } 59 | }, 2000); 60 | core.SettingService.toggleEHunter(true); 61 | window.setTimeout(() => { 62 | this.toggleEHunterView(true); 63 | }, 300); 64 | } 65 | } 66 | 67 | createEHunterContainer() { 68 | document.body.style.overflow = 'hidden'; 69 | let element = document.createElement('div'); 70 | element.style.position = 'fixed'; 71 | element.style.height = '100%'; 72 | element.style.width = '100%'; 73 | element.style.transition = 'all 1s ease'; 74 | element.style.background = '#333333'; 75 | element.style.zIndex = '10'; 76 | element.style.top = '-100%'; 77 | element.style.left = '0px'; 78 | element.classList.add('vue-container'); 79 | 80 | let vue = document.createElement('div'); 81 | vue.setAttribute('id', 'app'); 82 | element.appendChild(vue); 83 | document.body.appendChild(element); 84 | 85 | setTimeout(() => { 86 | element.style.top = '0'; 87 | }, 0); 88 | } 89 | 90 | toggleEHunterView(open: boolean): void { 91 | if (document.getElementsByClassName('vue-container').length > 0) { 92 | this.showEHunterView(open); 93 | } else { 94 | this.initEHunter(); 95 | } 96 | } 97 | 98 | showEHunterView(show: boolean): void { 99 | document.body.style.overflow = show ? 'hidden' : ''; 100 | (document.getElementsByClassName('vue-container')[0]).style.top = show ? '0' : '-100%'; 101 | } 102 | 103 | blockHostActions(): void { } 104 | 105 | initEHunter(): void { 106 | this.blockHostActions(); 107 | this.createEHunterContainer(); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/platform/base/request/MultiAsyncReq.ts: -------------------------------------------------------------------------------- 1 | // a service for sync multi asynchronous text requests 2 | import { TextReq } from './TextReq' 3 | 4 | export class MultiAsyncReq { 5 | private urls: Array = []; 6 | private resultMap: Map = new Map(); 7 | private fetchSetting = null; 8 | private gen; 9 | 10 | constructor(urls) { 11 | this.urls = urls; 12 | this.fetchSetting = null; 13 | } 14 | 15 | request(): Promise> { 16 | return new Promise((resolve, reject) => { 17 | this._initGenerator(resolve, reject); 18 | this._request(); 19 | }); 20 | } 21 | 22 | setFetchSetting(setting) { 23 | this.fetchSetting = setting; 24 | return this; 25 | } 26 | 27 | _initGenerator(resolve, reject) { 28 | let self = this; 29 | this.gen = (function* () { 30 | try { 31 | for (let url of self.urls) { 32 | let item = yield url; 33 | self.resultMap.set(item.url, item.html); 34 | } 35 | resolve(self.resultMap); 36 | } catch (err) { 37 | reject(err); 38 | } 39 | })(); 40 | this.gen.next(); // run to first yield 41 | } 42 | 43 | _request() { 44 | for (let url of this.urls) { 45 | (new TextReq(url)) 46 | .setFetchSetting(this.fetchSetting) 47 | .request() 48 | .then(html => this.gen.next({ url: url, html: html }, 49 | err => this.gen.throw(err))); 50 | } 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /src/platform/base/request/ReqQueue.ts: -------------------------------------------------------------------------------- 1 | // a service for limiting num of async requests, avoiding too many concurrent requests 2 | import { MultiAsyncReq } from './MultiAsyncReq' 3 | 4 | export class ReqQueue { 5 | private urls: Array = []; 6 | private maxConcurrentedNum = 5; 7 | private resultMap: Map = new Map(); 8 | private fetchSetting = null; 9 | 10 | constructor(urls) { 11 | this.urls = urls; 12 | } 13 | 14 | setNumOfConcurrented(num: number) { 15 | this.maxConcurrentedNum = num; 16 | return this; 17 | } 18 | 19 | setFetchSetting(setting) { 20 | this.fetchSetting = setting; 21 | return this; 22 | } 23 | 24 | request(): Promise> { 25 | return new Promise((resolve, reject) => { 26 | let reqList = this._splitReqs(); 27 | this._request(reqList, resolve, reject); 28 | }); 29 | } 30 | 31 | _splitReqs() { 32 | if (this.urls.length < this.maxConcurrentedNum) { 33 | return [this.urls]; 34 | } 35 | let results: Array = []; 36 | let urls = JSON.parse(JSON.stringify(this.urls)); 37 | while (true) { 38 | let list = urls.splice(0, this.maxConcurrentedNum); 39 | if (list.length > 0) { 40 | results.push(list); 41 | } else { 42 | return results; 43 | } 44 | } 45 | } 46 | 47 | _addMap(destMap: Map, srcMap: Map): Map { 48 | srcMap.forEach((val, key) => { 49 | destMap.set(key, val); 50 | }); 51 | return destMap; 52 | } 53 | 54 | _request(reqList, resolve, reject) { 55 | if (reqList.length > 0) { 56 | (new MultiAsyncReq(reqList[0])) 57 | .setFetchSetting(this.fetchSetting) 58 | .request() 59 | .then(map => { 60 | this._addMap(this.resultMap, map); 61 | reqList.splice(0, 1); 62 | this._request(reqList, resolve, reject); 63 | }, err => { reject(err) }); 64 | } else { 65 | resolve(this.resultMap); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/platform/base/request/TextReq.ts: -------------------------------------------------------------------------------- 1 | // a good resolution for poor network 2 | import PlatformService from '../service/PlatformService' 3 | 4 | export class TextReq { 5 | private url: string; 6 | private method = 'GET'; 7 | private credentials = 'include'; 8 | private retryTimes = 3; 9 | private timeoutTime = 15; // secs 10 | private curRetryTimes = 0; 11 | private retryInterval = 3; // secs 12 | private enabledLog = true; 13 | private fetchSetting = null; 14 | private noCache = false; 15 | private rejectError = true; 16 | 17 | constructor(url: string, noCache = false, rejectError = true) { 18 | this.url = url; 19 | this.noCache = noCache; 20 | this.rejectError = rejectError; 21 | } 22 | 23 | setMethod(method) { 24 | this.method = method; 25 | return this; 26 | } 27 | 28 | setCredentials(credential: string) { 29 | this.credentials = credential; 30 | return this; 31 | } 32 | 33 | setFetchSetting(setting: any) { 34 | this.fetchSetting = setting; 35 | return this; 36 | } 37 | 38 | setRetryTimes(times: number) { 39 | this.retryTimes = times; 40 | } 41 | 42 | setRetryInterval(secs: number) { 43 | this.retryInterval = secs; 44 | } 45 | 46 | setTimeOutTime(secs: number) { 47 | this.timeoutTime = secs; 48 | } 49 | 50 | request(): Promise { 51 | return new Promise((resolve, reject) => { 52 | this._request(res => { 53 | res.text().then(text => resolve(text)); 54 | }, err => { 55 | if (this.rejectError) { 56 | reject(err); 57 | } else { 58 | console.error(err); 59 | } 60 | }); 61 | }); 62 | } 63 | 64 | private printErrorLog(err) { 65 | console.error(`TextReq: request error in ${this.url}, retry:(${this.curRetryTimes}/${this.retryTimes}), error: ${err}`); 66 | } 67 | 68 | _request(successCallback, failureCallback) { 69 | this.curRetryTimes++; 70 | let url = this.url.includes('http') ? this.url : `${window.location.protocol}//${window.location.host}${this.url}`; 71 | if (this.noCache) { 72 | url = `${url}?_t=${new Date().getTime()}`; 73 | } 74 | let timeout = new Promise((resolve, reject) => { 75 | setTimeout(reject, this.timeoutTime * 1000 * this.curRetryTimes, 'request timed out'); 76 | }); 77 | let req = PlatformService.fetch(url, this.fetchSetting ? this.fetchSetting : { 78 | method: this.method, 79 | credentials: this.credentials 80 | }); 81 | Promise 82 | .race([timeout, req]) 83 | .then(res => { 84 | if (res.status === 200) { 85 | successCallback(res); 86 | } else { 87 | throw new Error(`${url}: ${res.status}`); 88 | } 89 | }) 90 | .catch(err => { 91 | this.printErrorLog(err); 92 | if (this.curRetryTimes < this.retryTimes) { 93 | setTimeout(() => { 94 | this._request(successCallback, failureCallback); 95 | }, this.retryInterval * 1000); 96 | } else { 97 | failureCallback(err); 98 | } 99 | }); 100 | } 101 | } -------------------------------------------------------------------------------- /src/platform/base/service/PlatformService.js: -------------------------------------------------------------------------------- 1 | // a service for crossing platform 2 | /* eslint-disable no-undef */ 3 | 4 | // hack for test 5 | if (typeof chrome === 'undefined') { 6 | var chrome = { extension: null }; 7 | } 8 | 9 | export default { 10 | storage: { 11 | get sync() { 12 | if (chrome && chrome.storage) { 13 | return chrome.storage.sync.QUOTA_BYTES ? chrome.storage.sync : chrome.storage.local; 14 | } else { 15 | return window.localStorage; 16 | } 17 | }, 18 | local: window.localStorage 19 | }, 20 | getExtension() { 21 | return chrome.extension; 22 | }, 23 | fetch(url, option) { 24 | /* eslint-disable camelcase */ 25 | if (typeof GM_info !== 'undefined' && GM_info.version) { // the ENV is Tampermonkey 26 | return new Promise((resolve, reject) => { 27 | GM_xmlhttpRequest({ 28 | method: option.method, 29 | url, 30 | onload: x => { 31 | let responseText = x.responseText; 32 | x.text = async function() { 33 | return responseText; 34 | } 35 | resolve(x); 36 | }, 37 | onerror: e => { 38 | reject(`GM_xhr error, ${e.status}`); 39 | } 40 | }); 41 | }); 42 | } else { // the ENV is Chrome or Firefox 43 | return window.fetch(url, option); 44 | } 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /src/platform/eh/api.ts: -------------------------------------------------------------------------------- 1 | export function getIntroHtml(introUrl: string, page: number): string { 2 | const url = page > 1 ? `${introUrl}?p=${page - 1}` : introUrl; 3 | return url; 4 | } 5 | 6 | export function getImgHtml(baseUrl: string, pageNum: number): string { 7 | const url = `${baseUrl}-${pageNum}`; 8 | return url; 9 | } 10 | -------------------------------------------------------------------------------- /src/platform/eh/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars,no-undef,indent */ 2 | import core from '../../../core' 3 | import { AlbumServiceImpl } from './service/AlbumServiceImpl' 4 | import config from '../../config' 5 | import { BasePlatform } from '../base' 6 | 7 | export default class EHPlatform extends BasePlatform { 8 | isAlbumViewPage() { 9 | return document.location.pathname.includes('/s/'); 10 | } 11 | 12 | // some actions of eh will make some wired errors 13 | blockHostActions() { 14 | var elt = document.createElement('script'); 15 | elt.innerHTML = ` 16 | if (typeof timerId === 'undefined') { 17 | const timerId = window.setInterval(() => { 18 | if (document.onkeyup) { 19 | window.onpopstate = null; 20 | window.clearInterval(timerId); 21 | load_image_dispatch = () => {}; 22 | api_response = () => {}; 23 | _load_image = () => {}; 24 | nl = () => {}; 25 | hookEvent = () => { console.log('hookEvent') }; 26 | scroll_space = () => {}; 27 | document.onkeydown = () => {}; 28 | document.onkeyup = () => {}; 29 | } 30 | }, 1000); 31 | } 32 | `; 33 | document.body.appendChild(elt); 34 | } 35 | 36 | initEHunter() { 37 | super.initEHunter(); 38 | core.createAppView('vue-container', '#app', 39 | core.launcher 40 | .setAlbumService(new AlbumServiceImpl(document.documentElement.innerHTML)) 41 | .setEHunterService({ 42 | showEHunterView: this.showEHunterView 43 | }) 44 | .setConfig(config) 45 | .instance()); 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /src/platform/eh/parser/ImgHtmlParser.ts: -------------------------------------------------------------------------------- 1 | // a parser for album's img page 2 | export class ImgHtmlParser { 3 | private htmlText: string; 4 | private html: HTMLElement; 5 | private document: Document | null; 6 | private i2: HTMLElement | undefined; 7 | private imgSizeInfo: Array | undefined; 8 | 9 | constructor(html) { 10 | this.htmlText = html.replace(/src=/g, 'x-src='); // avoid load assets 11 | this.html = document.createElement('html'); 12 | this.html.innerHTML = this.htmlText; 13 | this.document = this.html.ownerDocument; 14 | this._initI2Element(); 15 | this._initImgSizeInfo(); 16 | return this; 17 | } 18 | 19 | private _initI2Element() { 20 | this.i2 = this.html.querySelector('#i2'); 21 | if (!this.i2) { 22 | throw new Error('ImgHtmlParser: i2 is undefined'); 23 | } 24 | } 25 | 26 | private _initImgSizeInfo() { 27 | this.imgSizeInfo = this.i2!.children[1]!.textContent!.split('::')[1].split('x'); 28 | } 29 | 30 | getTitle(): string { 31 | let elem = this.html.querySelector('h1'); 32 | return elem ? (elem.textContent || '') : ''; 33 | } 34 | 35 | getCurPageNum(): number { 36 | return Number(this.i2!.getElementsByTagName('span')[0]!.textContent!); 37 | } 38 | 39 | getPageCount(): number { 40 | return Number(this.i2!.getElementsByTagName('span')[1]!.textContent); 41 | } 42 | 43 | getImgHeight(): number { 44 | return Number(this.imgSizeInfo![1].trim()); 45 | } 46 | 47 | getImgWidth(): number { 48 | return Number(this.imgSizeInfo![0].trim()); 49 | } 50 | 51 | getPreciseHeightOfWidth(): number { 52 | return Number(this.getImgHeight() / this.getImgWidth()); 53 | } 54 | 55 | getIntroUrl(): string { 56 | let url = this.html!.querySelectorAll('.sb')![0].children![0].getAttribute('href')! 57 | .replace(/^.*?org/g, '').replace(/\?p=.*?$/g, ''); 58 | return url; 59 | } 60 | 61 | getAlbumId(): string { 62 | return this.getIntroUrl().match(/g\/\d+(?=\/)/)![0].replace('g/', ''); 63 | } 64 | 65 | getImgId(): string { 66 | return window.location.pathname.split('/')[2]; 67 | } 68 | 69 | getNextImgId(): string { 70 | return this.document!.getElementById('i3')!.children![0].getAttribute('href')!.split('/')[4]; 71 | } 72 | 73 | getImgUrl(): string { 74 | this.htmlText.match('id="img" x-src="(.*?)"'); 75 | return RegExp.$1; 76 | } 77 | 78 | getOriginalImgUrl(): string { 79 | let items = this.html.querySelector('#i6')!.children 80 | return items[items.length - 1].children[1]!.getAttribute('href')! 81 | } 82 | 83 | getSourceId(): string { 84 | this.html!.querySelector('#loadfail')!.attributes['onclick'].value.match(/nl\('(.*?)'\)/g); 85 | return RegExp.$1; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/platform/eh/parser/ImgUrlListParser.ts: -------------------------------------------------------------------------------- 1 | // get img page urls from album intro page 2 | import { ReqQueue } from '../../base/request/ReqQueue' 3 | import { IntroHtmlParser } from './IntroHtmlParser' 4 | import { ImgPageInfo } from '../../../../core/bean/ImgPageInfo' 5 | 6 | export class ImgUrlListParser { 7 | private introUrl: string; 8 | private sumOfIntroPage: number; 9 | private introPageUrls: string[]; 10 | 11 | constructor(introUrl, sumOfImgPage) { 12 | this.introUrl = introUrl; 13 | this.sumOfIntroPage = 0; 14 | this.introPageUrls = []; 15 | } 16 | 17 | async request(): Promise> { 18 | let introResultMap = await new ReqQueue([this.introUrl]).request(); 19 | this.sumOfIntroPage = new IntroHtmlParser(introResultMap.get(this.introUrl), this.introUrl).getMaxPageNumber(); 20 | this.introPageUrls = this._getIntroPageUrls(); 21 | let result = await this._request(); 22 | return result 23 | } 24 | 25 | 26 | _getIntroPageUrls(): string[] { 27 | let urls: string[] = []; 28 | for (let i = 0; i < this.sumOfIntroPage; i++) { 29 | urls.push(`${this.introUrl}?p=${i}`); 30 | } 31 | return urls; 32 | } 33 | 34 | async _request(): Promise> { 35 | let resultMap = await new ReqQueue(this.introPageUrls).request() 36 | let result = this.introPageUrls.reduce((imgUrls, introUrl) => { 37 | imgUrls = imgUrls.concat(new IntroHtmlParser(resultMap.get(introUrl), introUrl).getImgUrls()); 38 | return imgUrls; 39 | }, >[]); 40 | let index = 0; 41 | result.forEach(i => { 42 | i.index = index++ 43 | }); 44 | return result 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/platform/eh/service/AlbumServiceImpl.ts: -------------------------------------------------------------------------------- 1 | import { ImgHtmlParser } from '../parser/ImgHtmlParser' 2 | import { AlbumCacheService } from './AlbumCacheService' 3 | import { AlbumService } from '../../../../core/service/AlbumService' 4 | import { ThumbInfo } from '../../../../core/bean/ThumbInfo' 5 | import { ImgPageInfo } from '../../../../core/bean/ImgPageInfo' 6 | // import Logger from '../utils/Logger'; 7 | 8 | export class AlbumServiceImpl extends AlbumService { 9 | protected imgHtmlParser: ImgHtmlParser; 10 | protected cacheService: any; 11 | protected thumbInfos: Array = []; 12 | protected sumOfPage: number | undefined; 13 | protected introUrl: string = ''; 14 | protected albumId: string = ''; 15 | protected curPageNum: number | undefined; 16 | protected title: string = ''; 17 | protected imgPageInfos: Array = []; 18 | 19 | constructor(imgHtml: string) { 20 | super(); 21 | this.imgHtmlParser = new ImgHtmlParser(imgHtml); 22 | this.cacheService = new AlbumCacheService(this); 23 | } 24 | 25 | async getPageCount(): Promise { 26 | if (!this.sumOfPage) { 27 | this.sumOfPage = this.imgHtmlParser.getPageCount(); 28 | } 29 | return this.sumOfPage; 30 | } 31 | 32 | getIntroUrl() { 33 | if (!this.introUrl) { 34 | this.introUrl = this.imgHtmlParser.getIntroUrl(); 35 | } 36 | return this.introUrl; 37 | } 38 | 39 | setIntroUrl(val) { 40 | this.introUrl = val; 41 | } 42 | 43 | async getAlbumId(): Promise { 44 | if (!this.albumId) { 45 | this.albumId = this.imgHtmlParser.getAlbumId(); 46 | } 47 | return this.albumId; 48 | } 49 | 50 | async getCurPageNum(): Promise { 51 | if (!this.curPageNum) { 52 | this.curPageNum = this.imgHtmlParser.getCurPageNum(); 53 | } 54 | return this.curPageNum; 55 | } 56 | 57 | async getTitle(): Promise { 58 | if (!this.title) { 59 | this.title = this.imgHtmlParser.getTitle(); 60 | } 61 | return this.title; 62 | } 63 | 64 | getCacheService() { 65 | return this.cacheService; 66 | } 67 | 68 | async getImgPageInfos(): Promise> { 69 | return this.cacheService.getImgPageInfos(await this.getAlbumId(), await this.getIntroUrl(), await this.getPageCount()); 70 | } 71 | 72 | async getImgPageInfo(index: number): Promise { 73 | return (await this.getImgPageInfos())[index]; 74 | } 75 | 76 | getImgSrc(index, mode) { 77 | return this.cacheService.getImgSrc(this.getAlbumId(), index, mode); 78 | } 79 | 80 | getNewImgSrc(index, mode) { 81 | return this.cacheService.getNewImgSrc(this.getAlbumId(), index, mode); 82 | } 83 | 84 | async getThumbInfos(cache = true): Promise> { 85 | if (!cache || this.thumbInfos.length === 0) { 86 | this.thumbInfos = this.cacheService.getThumbInfos(await this.getAlbumId(), await this.getIntroUrl(), await this.getPageCount()); 87 | } 88 | return this.thumbInfos; 89 | } 90 | 91 | async getThumbInfo(index): Promise { 92 | return (await this.getThumbInfos())[index]; 93 | } 94 | 95 | async getPreviewThumbnailStyle(index: number, imgPageInfo: ImgPageInfo, thumbInfo: ThumbInfo, width: number, height: number) { 96 | let common = `${thumbInfo.style}; height: ${thumbInfo.height}px; width: ${thumbInfo.width}px; position: absolute; left: 50%; top: 50%;` 97 | if (thumbInfo.height > thumbInfo.width) { 98 | return `${common}; transform: translate(-50%, -50%) scale(${height / thumbInfo.height})` 99 | } else { 100 | return `${common}; transform: translate(-50%, -50%) scale(${width / thumbInfo.width})` 101 | } 102 | } 103 | 104 | supportOriginImg(): boolean { 105 | return true; 106 | } 107 | 108 | supportImgChangeSource(): boolean { 109 | return true; 110 | } 111 | 112 | supportThumbView(): boolean { 113 | return true; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/platform/nh/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars,no-undef,indent */ 2 | import core from '../../../core' 3 | import { AlbumServiceImpl } from './service/AlbumServiceImpl' 4 | import config from '../../config' 5 | import { BasePlatform } from '../base' 6 | 7 | export default class NHApp extends BasePlatform { 8 | isAlbumViewPage() { 9 | return window.location.pathname.match(/^\/g\/[0-9]*?\/[0-9]*\/$/) != null; 10 | } 11 | 12 | blockHostActions(): void { 13 | var elt = document.createElement('script'); 14 | elt.innerHTML = ` 15 | console._clear = console.clear; 16 | console.clear = function () {} 17 | `; 18 | document.body.appendChild(elt); 19 | } 20 | 21 | initEHunter(): void { 22 | super.initEHunter(); 23 | core.createAppView('vue-container', '#app', 24 | core.launcher 25 | .setAlbumService(new AlbumServiceImpl(document.documentElement.innerHTML)) 26 | .setEHunterService({ 27 | showEHunterView: this.showEHunterView 28 | }) 29 | .setConfig(config) 30 | .instance()); 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/platform/nh/parser/ImgHtmlParser.ts: -------------------------------------------------------------------------------- 1 | // a parser for album's img page 2 | export class ImgHtmlParser { 3 | private htmlText: string; 4 | private html: HTMLElement; 5 | 6 | constructor(html) { 7 | this.htmlText = html.replace(/src=/g, 'x-src='); // avoid load assets 8 | this.html = document.createElement('html'); 9 | this.html.innerHTML = this.htmlText; 10 | return this; 11 | } 12 | 13 | getCurPageNum(): number { 14 | return Number(this.html.querySelector('.current')!.textContent); 15 | } 16 | 17 | getPageCount(): number { 18 | return Number(this.html.querySelector('.num-pages')!.textContent); 19 | } 20 | 21 | getImgHeight(): number { 22 | return Number(this.html.querySelector('#image-container')!.children[0]!.children[0]!.getAttribute('height')); 23 | } 24 | 25 | getImgWidth(): number { 26 | return Number(this.html.querySelector('#image-container')!.children[0]!.children[0]!.getAttribute('width')); 27 | } 28 | 29 | getIntroUrl(): string { 30 | return this.html.querySelector('.go-back')!.getAttribute('href')!; 31 | } 32 | 33 | getAlbumId(): string { 34 | return this.getIntroUrl().replace(/(\/|g)/g, ''); 35 | } 36 | 37 | getImgUrl(): string { 38 | return this.html.querySelector('#image-container')!.children[0]!.children[0]!.getAttribute('x-src')!; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/platform/nh/parser/IntroHtmlParser.ts: -------------------------------------------------------------------------------- 1 | import { ImgPageInfo } from '../../../../core/bean/ImgPageInfo' 2 | import { ThumbInfo, ThumbMode } from '../../../../core/bean/ThumbInfo' 3 | 4 | // a parser for album's intro page 5 | export class IntroHtmlParser { 6 | private html: HTMLElement; 7 | private imgPageInfos: Array = []; 8 | private thumbInfos: Array = []; 9 | 10 | 11 | constructor(html) { 12 | this.html = document.createElement('html'); 13 | this.html.innerHTML = html.replace(/src=/g, 'x-src='); // avoid load assets 14 | this.parseData(); 15 | } 16 | 17 | getTitle(): string { 18 | return this.html.querySelector('h1')!.textContent!; 19 | } 20 | 21 | private parseData() { 22 | Array.prototype.slice.call(this.html.querySelectorAll('.gallerythumb'), 0).forEach(i => { 23 | const thumbSrc = i.children[0].getAttribute('data-x-src'); 24 | const thumbHeight = i.children[0].getAttribute('height') * 1; 25 | const thumbWidth = i.children[0].getAttribute('width') * 1; 26 | const pageUrl = i.getAttribute('href'); 27 | this.imgPageInfos.push({ 28 | id: pageUrl, 29 | index: this.imgPageInfos.length, // set id to index 30 | pageUrl, 31 | thumbHeight, 32 | thumbWidth, 33 | thumbStyle: '', 34 | src: '', 35 | heightOfWidth: thumbHeight / thumbWidth 36 | }); 37 | this.thumbInfos.push({ 38 | id: pageUrl, 39 | mode: ThumbMode.IMG, 40 | src: thumbSrc, 41 | style: '', 42 | height: 0, 43 | width: 0, 44 | }) 45 | }); 46 | } 47 | 48 | getImgPageInfos(): Array { 49 | return this.imgPageInfos; 50 | } 51 | 52 | getThumbInfos(): Array { 53 | return this.thumbInfos; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/unit/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "globals": { 6 | "expect": true, 7 | "sinon": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/unit/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import 'babel-polyfill'; 3 | 4 | Vue.config.productionTip = false; 5 | 6 | // require all test files (files that ends with .spec.js) 7 | const testsContext = require.context('./specs', true, /\.spec$/); 8 | testsContext.keys().forEach(testsContext); 9 | 10 | // require all src files except main.js for coverage. 11 | // you can also change this to match only the subset of files that 12 | // you want coverage for. 13 | // const srcContext = require.context('../../src', true, /\.(js|vue)$/); 14 | // const srcContext = require.context('../../src', true, /CircleIconButton\.(js|vue)$/); 15 | const srcContext = require.context('../../src', true, /.+\.(js|vue)$/); 16 | srcContext.keys().forEach(srcContext); 17 | -------------------------------------------------------------------------------- /test/unit/karma.conf.js: -------------------------------------------------------------------------------- 1 | // This is a karma config file. For more details see 2 | // http://karma-runner.github.io/0.13/config/configuration-file.html 3 | // we are also using it with karma-webpack 4 | // https://github.com/webpack/karma-webpack 5 | var webpackConfig = require('../../build/webpack.test.conf'); 6 | 7 | module.exports = function karmaConfig(config) { 8 | config.set({ 9 | // to run in additional browsers: 10 | // 1. install corresponding karma launcher 11 | // http://karma-runner.github.io/0.13/config/browsers.html 12 | // 2. add it to the `browsers` array below. 13 | // browsers: ['PhantomJS'], 14 | browsers: ['Chrome_without_security'], 15 | frameworks: ['mocha', 'sinon-chai', 'phantomjs-shim'], 16 | reporters: ['spec', 'coverage'], 17 | files: ['./index.js'], 18 | preprocessors: { 19 | './index.js': ['webpack', 'sourcemap'] 20 | }, 21 | webpack: webpackConfig, 22 | webpackMiddleware: { 23 | noInfo: true 24 | }, 25 | coverageReporter: { 26 | dir: './coverage', 27 | reporters: [ 28 | { type: 'lcov', subdir: '.' }, 29 | { type: 'text-summary' } 30 | ] 31 | }, 32 | customLaunchers: { 33 | Chrome_without_security: { 34 | base: 'Chrome', 35 | flags: ['--disable-web-security'] 36 | } 37 | } 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /test/unit/specs/service.AlbumService.spec.js: -------------------------------------------------------------------------------- 1 | import { AlbumService } from '../../../src/service/AlbumService'; 2 | import { AlbumCacheService } from '../../../src/service/storage/AlbumCacheService'; 3 | import * as tags from '../../../src/assets/value/tags'; 4 | 5 | describe('service.AlbumService', () => { 6 | let as; 7 | before(async function () { 8 | this.timeout(10000); 9 | let timer = setTimeout(() => console.error('Cannot connect to e-hentai'), 9500); 10 | let html = await (await window.fetch('https://e-hentai.org/s/0bb62690b8/1183176-1')).text(); 11 | window.clearTimeout(timer); 12 | as = new AlbumService(html); 13 | }); 14 | it('getPageCount', () => { 15 | expect(as.getPageCount()).to.be.equals(284); 16 | }); 17 | it('getPageCount', () => { 18 | expect(as.getBookScreenCount(2)).to.be.equals(143); 19 | }); 20 | it('getIntroUrl', () => { 21 | expect(as.getIntroUrl()).to.be.equals('https://e-hentai.org/g/1183176/e6c9e01507/'); 22 | }); 23 | it('setIntroUrl', () => { 24 | as.setIntroUrl('/123/'); 25 | expect(as.getIntroUrl()).to.be.equals('/123/'); 26 | as.setIntroUrl('https://e-hentai.org/g/1183176/e6c9e01507/'); 27 | }); 28 | it('getAlbumId', () => { 29 | expect(as.getAlbumId()).to.be.equals('1183176'); 30 | }); 31 | it('getCurPageNum', () => { 32 | expect(as.getCurPageNum()).to.be.equals(1); 33 | }); 34 | it('getTitle', () => { 35 | expect(as.getTitle()).to.be.equals('The Secret of Mobile Suit Development II U.C.0079'); 36 | }); 37 | it('getCacheService', () => { 38 | expect(as.getCacheService()).to.be.an.instanceof(AlbumCacheService); 39 | }); 40 | it('getImgInfos', async function () { 41 | this.timeout(10000); 42 | let imgInfos = await as.getImgInfos(); 43 | expect(imgInfos).to.have.lengthOf(as.getPageCount()); 44 | imgInfos.forEach(i => { 45 | expect(i).to.have.property('pageUrl').which.is.a('string').and.not.empty; 46 | expect(i).to.have.property('src').which.is.a('string'); 47 | expect(i).to.have.property('thumbHeight').which.is.a('number').and.above(0); 48 | expect(i).to.have.property('thumbWidth').which.is.a('number').and.above(0); 49 | expect(i).to.have.property('heightOfWidth').which.is.a('number').and.above(0); 50 | }) 51 | }); 52 | it('getImgInfo', async () => { 53 | for (let i = 0; i < as.getPageCount(); i++) { 54 | let imgInfo = await as.getImgInfo(i); 55 | expect(imgInfo).to.have.property('pageUrl').which.is.a('string').and.not.empty; 56 | expect(imgInfo).to.have.property('src').which.is.a('string'); 57 | expect(imgInfo).to.have.property('thumbHeight').which.is.a('number').and.above(0); 58 | expect(imgInfo).to.have.property('thumbWidth').which.is.a('number').and.above(0); 59 | expect(imgInfo).to.have.property('heightOfWidth').which.is.a('number').and.above(0); 60 | } 61 | }); 62 | it('getImgSrc', async function () { 63 | this.timeout(10000); 64 | expect(await as.getImgSrc(0)).to.match(/\.(jpg|png|gif|webp)$/); 65 | expect(await as.getImgSrc(1, tags.MODE_ORIGIN)).to.match(/^https:\/\/e-hentai.org\/fullimg\.php/); 66 | expect(await as.getImgSrc(2, tags.MODE_CHANGE_SOURCE)).to.match(/\.(jpg|png|gif|webp)$/); 67 | }); 68 | it('getNewImgSrc', async function () { 69 | this.timeout(10000); 70 | let oldSrc = await as.getImgSrc(0); 71 | expect(await as.getNewImgSrc(0, tags.MODE_CHANGE_SOURCE)).to.match(/\.(jpg|png|gif|webp)$/) 72 | .and.not.equal(oldSrc); 73 | }); 74 | it('getThumbs', async function () { 75 | this.timeout(10000); 76 | let thumbs = await as.getThumbs(); 77 | expect(thumbs).to.have.lengthOf(as.getPageCount()); 78 | thumbs.forEach(i => { 79 | expect(i).to.have.property('url').which.is.a('string').and.not.empty; 80 | expect(i).to.have.property('offset').which.is.a('number').and.least(0); 81 | }); 82 | thumbs = await as.getThumbs(false); 83 | expect(thumbs).to.have.lengthOf(as.getPageCount()); 84 | thumbs.forEach(i => { 85 | expect(i).to.have.property('url').which.is.a('string').and.not.empty; 86 | expect(i).to.have.property('offset').which.is.a('number').and.least(0); 87 | }); 88 | }); 89 | it('getThumb', async function () { 90 | this.timeout(10000); 91 | let thumb = await as.getThumb(0); 92 | expect(thumb).to.have.property('url').which.is.a('string').and.not.empty; 93 | expect(thumb).to.have.property('offset').which.is.a('number').and.least(0); 94 | }); 95 | it('getPreviewThumbnailStyle', async function () { 96 | let style = as.getPreviewThumbnailStyle(1, await as.getImgInfo(1), await as.getThumb(1)); 97 | expect(style).to.have.property('background-image').which.match(/url\(.+\.(jpg|png|gif|webp)\)$/); 98 | expect(style).to.have.property('background-position').which.match(/^(\d|\.)+% \d+/); 99 | expect(style).to.have.property('background-size').which.match(/^(\d|\.)+%/); 100 | }); 101 | it('getRealCurIndex', async function () { 102 | expect(as.getRealCurIndex({ val: 0, updater: tags.SCROLL_VIEW })) 103 | .to.have.property('val', 0); 104 | expect(as.getRealCurIndex({ val: as.getPageCount() - 2, updater: tags.SCROLL_VIEW })) 105 | .to.have.property('val', as.getPageCount() - 2); 106 | expect(as.getRealCurIndex({ val: as.getPageCount(), updater: tags.SCROLL_VIEW })) 107 | .to.have.property('val', as.getPageCount() - 1); 108 | }) 109 | }); 110 | -------------------------------------------------------------------------------- /test/unit/specs/widget.CircleIconButton.spec.js: -------------------------------------------------------------------------------- 1 | import CircleIconButton from '../../../src/components/widget/CircleIconButton' 2 | import { destroyVM, createTest, createVue } from '../util' 3 | 4 | describe('widget.CircleIconButton', () => { 5 | let vm; 6 | afterEach(() => { 7 | destroyVM(vm); 8 | }); 9 | it('create', () => { 10 | vm = createTest(CircleIconButton, { 11 | icon: 'menu' 12 | }, true); 13 | let buttonElm = vm.$el; 14 | expect(buttonElm.classList.contains('circle-icon-button')).to.be.true; 15 | }); 16 | it('menu_icon', () => { 17 | vm = createTest(CircleIconButton, { 18 | icon: 'menu' 19 | }, true); 20 | let buttonElm = vm.$el; 21 | expect(buttonElm.querySelectorAll('path')[1].attributes['d'].value).to.be.equal('M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z'); 22 | }); 23 | it('close_icon', () => { 24 | vm = createTest(CircleIconButton, { 25 | icon: 'close' 26 | }, true); 27 | let buttonElm = vm.$el; 28 | expect(buttonElm.querySelectorAll('path')[1].attributes['d'].value).to.be.equal('M0 0h24v24H0z'); 29 | }); 30 | it('rotate', () => { 31 | vm = createTest(CircleIconButton, { 32 | icon: 'close', 33 | rotate: true 34 | }, true); 35 | let buttonElm = vm.$el; 36 | expect(buttonElm.querySelector('svg').classList.contains('rotate')).to.be.true; 37 | }); 38 | it('click', done => { 39 | let result; 40 | vm = createVue({ 41 | template: ` 42 | 43 | `, 44 | methods: { 45 | handleClick(evt) { 46 | result = evt; 47 | } 48 | }, 49 | components: { CircleIconButton } 50 | }, true); 51 | vm.$el.click(); 52 | setTimeout(_ => { 53 | expect(result).to.exist; 54 | done(); 55 | }, 20); 56 | }); 57 | }); 58 | 59 | -------------------------------------------------------------------------------- /test/unit/util.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | let id = 0; 4 | 5 | const createElm = function () { 6 | const elm = document.createElement('div'); 7 | 8 | elm.id = 'app' + ++id; 9 | document.body.appendChild(elm); 10 | 11 | return elm; 12 | }; 13 | 14 | /** 15 | * 回收 vm 16 | * @param {Object} vm 17 | */ 18 | exports.destroyVM = function (vm) { 19 | vm.$destroy && vm.$destroy(); 20 | vm.$el && 21 | vm.$el.parentNode && 22 | vm.$el.parentNode.removeChild(vm.$el); 23 | }; 24 | 25 | /** 26 | * 创建一个 Vue 的实例对象 27 | * @param {Object|String} Compo 组件配置,可直接传 template 28 | * @param {Boolean=false} mounted 是否添加到 DOM 上 29 | * @return {Object} vm 30 | */ 31 | exports.createVue = function (Compo, mounted = false) { 32 | if (Object.prototype.toString.call(Compo) === '[object String]') { 33 | Compo = { template: Compo }; 34 | } 35 | return new Vue(Compo).$mount(mounted === false ? null : createElm()); 36 | }; 37 | 38 | /** 39 | * 创建一个测试组件实例 40 | * @link http://vuejs.org/guide/unit-testing.html#Writing-Testable-Components 41 | * @param {Object} Compo - 组件对象 42 | * @param {Object} propsData - props 数据 43 | * @param {Boolean=false} mounted - 是否添加到 DOM 上 44 | * @return {Object} vm 45 | */ 46 | exports.createTest = function (Compo, propsData = {}, mounted = false) { 47 | if (propsData === true || propsData === false) { 48 | mounted = propsData; 49 | propsData = {}; 50 | } 51 | const elm = createElm(); 52 | const Ctor = Vue.extend(Compo); 53 | return new Ctor({ propsData }).$mount(mounted === false ? null : elm); 54 | }; 55 | 56 | /** 57 | * 触发一个事件 58 | * mouseenter, mouseleave, mouseover, keyup, change, click 等 59 | * @param {Element} elm 60 | * @param {String} name 61 | * @param {*} opts 62 | */ 63 | exports.triggerEvent = function (elm, name, ...opts) { 64 | let eventName; 65 | 66 | if (/^mouse|click/.test(name)) { 67 | eventName = 'MouseEvents'; 68 | } else if (/^key/.test(name)) { 69 | eventName = 'KeyboardEvent'; 70 | } else { 71 | eventName = 'HTMLEvents'; 72 | } 73 | const evt = document.createEvent(eventName); 74 | 75 | evt.initEvent(name, ...opts); 76 | elm.dispatchEvent 77 | ? elm.dispatchEvent(evt) 78 | : elm.fireEvent('on' + name, evt); 79 | 80 | return elm; 81 | }; 82 | 83 | /** 84 | * 触发 “mouseup” 和 “mousedown” 事件 85 | * @param {Element} elm 86 | * @param {*} opts 87 | */ 88 | exports.triggerClick = function (elm, ...opts) { 89 | exports.triggerEvent(elm, 'mousedown', ...opts); 90 | exports.triggerEvent(elm, 'mouseup', ...opts); 91 | 92 | return elm; 93 | }; 94 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./built/", 4 | "sourceMap": true, 5 | "strict": true, 6 | "noImplicitReturns": true, 7 | "noImplicitAny": false, 8 | "module": "es2015", 9 | "moduleResolution": "node", 10 | "target": "es5", 11 | "lib": [ 12 | "es2015", 13 | "DOM" 14 | ] 15 | }, 16 | "include": [ 17 | "./core/**/*", 18 | "./src/**/*" 19 | ], 20 | "baseUrl": ".", 21 | "paths": { 22 | "src": "src", 23 | "core": "core" 24 | } 25 | } -------------------------------------------------------------------------------- /update.json: -------------------------------------------------------------------------------- 1 | { 2 | "cn":{ 3 | "title":"新版本: v2.8.0", 4 | "version":"2.8.0", 5 | "text":"1. 修复exhentai/ehentai的解析问题, 2. 支持大图缩略图", 6 | "time":1690647347098, 7 | "duration":3000, 8 | "always":true, 9 | "operations":[ 10 | { 11 | "name":"油猴", 12 | "url":"https://openuserjs.org/scripts/alexchen/eHunter" 13 | } 14 | ] 15 | }, 16 | "en":{ 17 | "title":"New version: v2.8.0", 18 | "version":"2.8.0", 19 | "text":"1. Fixed the support issue of exhentai/ehentai 2. Support for large thumbnail mode", 20 | "time":1690647347098, 21 | "duration":3000, 22 | "always":true, 23 | "operations":[ 24 | { 25 | "name":"Tampermonkey", 26 | "url":"https://openuserjs.org/scripts/alexchen/eHunter" 27 | } 28 | ] 29 | }, 30 | "jp":{ 31 | "title":"新しいバージョン: v2.8.0", 32 | "version":"2.8.0", 33 | "text":"1. exhentai/ehentaiの問題を修正しました 2. Support for large thumbnail mode", 34 | "time":1690647347098, 35 | "duration":3000, 36 | "always":true, 37 | "operations":[ 38 | { 39 | "name":"Tampermonkey", 40 | "url":"https://openuserjs.org/scripts/alexchen/eHunter" 41 | } 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /update.md: -------------------------------------------------------------------------------- 1 | 1.`书页模式`下支持奇偶页切换 2 | 3 | 2.支持回车关闭弹窗 4 | 5 | 3.迁移老的订阅数据 6 | 7 | 4.`滚动模式`下支持调整页间距 8 | 9 | 5.支持手动修改配置值 10 | 11 | ### 2.5.0 更新信息 12 | #### 滚轮翻页 13 | 在`书页模式`下可以使用鼠标滚轮翻页, 且在设置中可调整滚动灵敏度和滚动翻页方向. 14 | 使用Mac触控板的用户, 请在设置中把`滚动方向`打开, 从而翻转方向, 达到最佳体验. 15 | 16 | #### 更新订阅 17 | 在chrome应用商店下载的用户, 可以使用新版本的标签更新订阅功能. 新版本修复了订阅, 且实现了多标签混合订阅和测试标签订阅功能. 18 | 19 | #### Flip by mouse wheel 20 | In `Book mode`, you can use mouse wheel to flip pages, and can customize the sensitivity and direction. 21 | If you are using touchpad of Mac, please open the switch of `Wheel Direction` in settings to have a best experience. 22 | 23 | #### Update subscription (Chrome extension) 24 | If you are using eHunter by Chrome extension, you can use the new version of update subscription system of tag. 25 | In new version, you can subscribe multiple tags once to get more accurate results, and you also can click `TEST TAG` button to test subscription. 26 | --------------------------------------------------------------------------------