├── src ├── renderer.ts ├── utils │ ├── sleep.ts │ ├── Axios.ts │ └── processes.ts ├── store │ └── store.ts ├── reducers │ ├── safeStateSlice.ts │ ├── loadingStateSlice.ts │ └── downloadStateSlice.ts ├── app │ ├── index.tsx │ ├── components │ │ ├── CurrentDownload.tsx │ │ ├── Loading.tsx │ │ ├── ColorModeSwitch.tsx │ │ ├── SafetyModeSwitch.tsx │ │ ├── TitleBar.tsx │ │ ├── Header.tsx │ │ ├── SideDrawer.tsx │ │ ├── Footer.tsx │ │ ├── Table │ │ │ ├── Table.tsx │ │ │ └── TableItem.tsx │ │ └── Opening.tsx │ ├── hooks │ │ ├── useToastHook.tsx │ │ └── useGetSaftyModeData.tsx │ └── App.tsx ├── types │ └── types.ts ├── preload.ts ├── main.ts └── service │ ├── getListService.ts │ └── downloadService.ts ├── .gitignore ├── index.html ├── @types └── index.d.ts ├── webpack.config.js ├── README.md ├── package.json └── tsconfig.json /src/renderer.ts: -------------------------------------------------------------------------------- 1 | import './app/index.tsx' 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | dist 3 | 4 | node_modules 5 | out/ -------------------------------------------------------------------------------- /src/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | export async function sleep(ms: number) { 2 | return new Promise((r) => setTimeout(r, ms)) 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/Axios.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import https from 'https' 3 | 4 | const Axios = axios.create({ httpsAgent: new https.Agent({ timeout: 0 }) }) 5 | 6 | export default Axios 7 | -------------------------------------------------------------------------------- /src/store/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit' 2 | import loadingState from '../reducers/loadingStateSlice' 3 | import downloadState from '../reducers/downloadStateSlice' 4 | import safetyModeState from '../reducers/safeStateSlice' 5 | 6 | const store = configureStore({ 7 | reducer: { 8 | loadingState, 9 | downloadState, 10 | safetyModeState, 11 | }, 12 | }) 13 | 14 | export default store 15 | -------------------------------------------------------------------------------- /src/reducers/safeStateSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | 3 | const initialState = false 4 | 5 | const safeStateSlice = createSlice({ 6 | name: 'loadingState', 7 | initialState: initialState, 8 | reducers: { 9 | updateSafeState(state, action) { 10 | return action.payload 11 | }, 12 | }, 13 | }) 14 | 15 | const { actions, reducer } = safeStateSlice 16 | 17 | export const { updateSafeState } = actions 18 | export default reducer 19 | -------------------------------------------------------------------------------- /src/reducers/loadingStateSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | 3 | const initialState = false 4 | 5 | const loadingStateSlice = createSlice({ 6 | name: 'loadingState', 7 | initialState: initialState, 8 | reducers: { 9 | updateLoadingState(state, action) { 10 | return action.payload 11 | }, 12 | }, 13 | }) 14 | 15 | const { actions, reducer } = loadingStateSlice 16 | 17 | export const { updateLoadingState } = actions 18 | export default reducer 19 | -------------------------------------------------------------------------------- /src/app/index.tsx: -------------------------------------------------------------------------------- 1 | import { ChakraProvider } from '@chakra-ui/react' 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | import { Provider } from 'react-redux' 5 | import App from './App' 6 | import store from '../store/store' 7 | 8 | ReactDOM.render( 9 | 10 | 11 | 12 | 13 | 14 | 15 | , 16 | document.getElementById('root') 17 | ) 18 | -------------------------------------------------------------------------------- /src/reducers/downloadStateSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | 3 | const initialState = { currentDownloadName: '', videoListUrl: '', percentage: '' } 4 | 5 | const downloadStateSlice = createSlice({ 6 | name: 'downloadState', 7 | initialState: initialState, 8 | reducers: { 9 | updateDownloadState(state, action) { 10 | return { ...state, ...action.payload } 11 | }, 12 | }, 13 | }) 14 | 15 | const { actions, reducer } = downloadStateSlice 16 | 17 | export const { updateDownloadState } = actions 18 | export default reducer 19 | -------------------------------------------------------------------------------- /src/app/components/CurrentDownload.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@chakra-ui/react' 2 | import React from 'react' 3 | import { useSelector } from 'react-redux' 4 | 5 | export default function CurrentDownload() { 6 | const { currentDownloadName, percentage } = useSelector((state) => state['downloadState']) 7 | const isSafetyMode = useSelector((state) => state['safetyModeState']) 8 | return ( 9 | <> 10 | {currentDownloadName && !isSafetyMode && ( 11 | 12 | 當前下載片名 {currentDownloadName} : 下載進度 {percentage || '0.0'} % 13 | 14 | )} 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/app/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner, Box } from '@chakra-ui/react' 2 | import styled from '@emotion/styled' 3 | import React from 'react' 4 | 5 | const StyledBg = styled(Box)` 6 | position: absolute; 7 | top: 0; 8 | bottom: 0; 9 | right: 0; 10 | left: 0; 11 | display: flex; 12 | align-items: center; 13 | justify-content: center; 14 | z-index: 9999; 15 | background: rgba(255, 255, 255, 0.5); 16 | ` 17 | 18 | export default function Loading() { 19 | return ( 20 | 21 | 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Jable Downloader 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/app/components/ColorModeSwitch.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Switch, useColorMode } from '@chakra-ui/react' 2 | import React from 'react' 3 | import { FaMoon, FaSun } from 'react-icons/fa' 4 | export default function ColorModeSwitch() { 5 | const { colorMode, toggleColorMode } = useColorMode() 6 | return ( 7 | 8 | {colorMode === 'light' ? '淺色模式' : '深色模式'} 9 | 10 | {colorMode === 'light' && } 11 | 12 | {colorMode === 'dark' && } 13 | 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/app/components/SafetyModeSwitch.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Switch } from '@chakra-ui/react' 2 | import React from 'react' 3 | import { useDispatch, useSelector } from 'react-redux' 4 | import { updateSafeState } from '../../reducers/safeStateSlice' 5 | 6 | export default function SafetyModeSwitch() { 7 | const isSafetyMode = useSelector((state) => state['safetyModeState']) 8 | const dispatch = useDispatch() 9 | function handleToggle(e) { 10 | dispatch(updateSafeState(e.target.checked)) 11 | } 12 | return ( 13 | 14 | 15 | {isSafetyMode && 閱讀模式} 16 | {!isSafetyMode && 正常模式} 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /@types/index.d.ts: -------------------------------------------------------------------------------- 1 | export declare global { 2 | interface Window { 3 | electronAPI: { 4 | errorMessenger: (cb: Function) => void 5 | successMessenger: (cb: Function) => void 6 | getPercentage: (cb: Function) => void 7 | getActorListByPage: (page: number) => void 8 | infoSetter: (cb: Function) => void 9 | reloadWindow: () => void 10 | getVideoListByActorLink: (props: { url: string; page: number }) => void 11 | actorVideoListSetter: (cb: Function) => void 12 | beginDownload: ({ link, rootPath }: { link: string; rootPath: string }) => void 13 | setupRootFolder: () => void 14 | returnRootPath: (cb: Function) => void 15 | minimizeWindow: () => void 16 | closeWindow: () => void 17 | stopCurrentDownload: () => void 18 | openChrome: (url: string) => void 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | 4 | module.exports = { 5 | mode: process.env.NODE_ENV, 6 | devtool: 'source-map', 7 | entry: { renderer: './src/renderer.ts', preload: './src/preload.ts', main: './src/main.ts' }, 8 | output: { 9 | path: path.resolve(__dirname, 'dist'), 10 | filename: '[name].js', 11 | clean: true, 12 | }, 13 | resolve: { 14 | extensions: ['.ts', '.tsx', '.js'], 15 | }, 16 | module: { 17 | rules: [{ test: /\.tsx?$/, loader: 'ts-loader' }], 18 | }, 19 | target: ['electron-main', 'electron-renderer'], 20 | plugins: [ 21 | new webpack.DefinePlugin({ 22 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 23 | }), 24 | ], 25 | watch: true, 26 | watchOptions: { 27 | poll: 200, 28 | ignored: '**/node_modules', 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /src/types/types.ts: -------------------------------------------------------------------------------- 1 | export enum FetchStatus { 2 | success = 'success', 3 | error = 'error', 4 | pending = 'pending', 5 | } 6 | 7 | /** 8 | * @param tsFileUrl string 下載ts檔案的url 9 | * @param folderName string 番號名 10 | * @param tsFileArray string[] 需要下載的ts file name 11 | * @param _IV string aes-cbc 解密用的初始向量 12 | * @param keyURIContent Buffer aes-cbc解密用的key,type是arrayBuffer 13 | */ 14 | export interface InitDownloadInfo { 15 | tsFileUrl: string 16 | folderName: string 17 | tsFileArray: Array 18 | _IV: string 19 | keyURIContent: Buffer 20 | } 21 | 22 | export interface M3U8Info { 23 | status: FetchStatus.success | FetchStatus.error | FetchStatus.pending 24 | keyURIName: string 25 | _IV: string 26 | tsFileArray: string[] 27 | } 28 | 29 | export interface BeginDownloadInfo { 30 | link: string 31 | rootPath: string 32 | } 33 | 34 | export type PaginatorAction = 'plus' | 'minus' | 'toFirst' | 'toLast' 35 | -------------------------------------------------------------------------------- /src/app/hooks/useToastHook.tsx: -------------------------------------------------------------------------------- 1 | import { useToast, UseToastOptions } from '@chakra-ui/react' 2 | import { useEffect, useState } from 'react' 3 | import { useSelector } from 'react-redux' 4 | 5 | const initialState = { status: '', msg: '' } 6 | 7 | const toastSuccess: UseToastOptions = { title: 'Success', status: 'success', duration: null, isClosable: true, position: 'bottom-right' } 8 | const toastError: UseToastOptions = { title: 'Error', status: 'error', duration: null, isClosable: true, position: 'bottom-right' } 9 | 10 | export default function useToastHook() { 11 | const [tip, setTip] = useState(initialState) 12 | const isSafetyMode = useSelector((state) => state['safetyModeState']) 13 | const toast = useToast() 14 | useEffect(() => { 15 | if (tip.status === 'success') { 16 | toast({ ...toastSuccess, description: isSafetyMode ? tip.msg.replace('下載', '學習') : tip.msg }) 17 | } 18 | if (tip.status === 'error') { 19 | toast({ ...toastError, description: isSafetyMode ? tip.msg.replace('下載', '學習') : tip.msg }) 20 | } 21 | }, [tip]) 22 | return { setTip } 23 | } 24 | -------------------------------------------------------------------------------- /src/preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from 'electron' 2 | 3 | contextBridge.exposeInMainWorld('electronAPI', { 4 | errorMessenger: (cb) => ipcRenderer.on('error', cb), 5 | successMessenger: (cb) => ipcRenderer.on('success', cb), 6 | getPercentage: (cb) => ipcRenderer.on('percentage', cb), 7 | getActorListByPage: (page: number) => ipcRenderer.send('getActorListByPage', page), 8 | infoSetter: (cb) => ipcRenderer.on('returnInfo', cb), 9 | reloadWindow: () => ipcRenderer.send('reloadWindow'), 10 | actorVideoListSetter: (cb) => ipcRenderer.on('returnVideoList', cb), 11 | getVideoListByActorLink: (props: { url: string; page: number }) => ipcRenderer.send('getVideoListByActorLink', props), 12 | beginDownload: (link: string) => ipcRenderer.send('beginDownload', link), 13 | setupRootFolder: () => ipcRenderer.send('setupRootFolder'), 14 | returnRootPath: (cb) => ipcRenderer.on('returnRootPath', cb), 15 | minimizeWindow: () => ipcRenderer.send('minimizeWindow'), 16 | closeWindow: () => ipcRenderer.send('closeWindow'), 17 | stopCurrentDownload: () => ipcRenderer.send('stopCurrentDownload'), 18 | openChrome: (url: string) => ipcRenderer.send('openChrome', url), 19 | }) 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jable Downloader 2 | 3 | 靈感來源: https://github.com/hcjohn463/JableTVDownload
4 | 覺得這麼有趣的功能沒有加上 GUI 實在有點可惜。 5 | 6 | ## 更新日誌 7 | 8 | ### 4/9 9 | 10 | 1. 優化 UI 及 下載百分比顯示 11 | 2. 強化安全模式 (縮小視窗自動切換) 12 | 3. 增加下載續傳功能 13 | 4. 增加取消下載功能 14 | 15 | ## 使用工具 16 | 17 | 1. App 框架: Electron 18 | 2. UI 框架及衍生生態系: React / RTK / Styled-component / Chakra-UI 19 | 3. 爬蟲: cheerio 20 | 4. 打包工具: Webpack 21 | 22 | ## APP FEATURE 23 | 24 | 1. 即時網頁內容爬蟲 25 | 2. 下載 / 解碼 mp4 檔案 26 | 3. 新增深色模式 27 | 4. 新增安全模式 28 | 29 | ## FUTURE 30 | 31 | 1. ~~下載功能優化~~ 32 | 2. 排程下載功能 33 | 3. 專案 Refactor 34 | 35 | --- 36 | 37 | 此 APP 僅為個人 SIDE PROJECT 練習之用,目前僅有提供 WINDOW x64 線上下載,下載後解壓縮即可使用。 38 | 39 | 下載連結:
40 | 4/9 latest version 41 |
42 | https://drive.google.com/file/d/1IUUWIszCQBk8udwJPkQKheEvOhytIHyF/view?usp=sharing 43 |
44 | 4/2 45 |
46 | https://drive.google.com/file/d/1TqjNnYieXphk5gcfamVrn012LmdtLTI-/view?usp=sharing 47 | 48 | 不確定這個 APP 能夠存活或使用多久,如果有什麼建議,或是有 BUG,請開 Issue 或 PR。 49 | 50 | 如果覺得有趣或用起來還算滿意的話,請大方贈送你/妳們的星星。 51 | 52 | 合併後的影片拖拉會有卡頓的感覺的話,請參考[此專案](https://github.com/hcjohn463/JableTVDownload)提供的解法。 53 | 54 | - 個人 Email: minghang.h@gmail.com 55 | - Facebook: https://www.facebook.com/mingang.he/ 56 | - Github: https://github.com/HenryMHH 57 | 58 | --- 59 | -------------------------------------------------------------------------------- /src/app/components/TitleBar.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Square } from '@chakra-ui/react' 2 | import styled from '@emotion/styled' 3 | import React from 'react' 4 | import { useDispatch, useSelector } from 'react-redux' 5 | import { MdMinimize, MdClose } from 'react-icons/md' 6 | import { updateSafeState } from '../../reducers/safeStateSlice' 7 | 8 | const StyledTitleBar = styled(Box)` 9 | -webkit-app-region: drag; 10 | width: 100%; 11 | display: flex; 12 | justify-content: space-between; 13 | align-items: center; 14 | padding: 0.3rem 1rem; 15 | background: #fe628e; 16 | color: white; 17 | ` 18 | 19 | export default function TitleBar() { 20 | const isSafetyMode = useSelector((state) => state['safetyModeState']) 21 | const dispatch = useDispatch() 22 | 23 | function handleMinimize() { 24 | dispatch(updateSafeState(true)) 25 | // 稍微延遲一點再縮小,避免縮小以後,雖然切換成安全模式了,但縮圖還是顯示女優列表 26 | setTimeout(() => window.electronAPI.minimizeWindow(), 50) 27 | } 28 | 29 | function handleClose() { 30 | const confirmResult = confirm('確定要關閉嗎?') 31 | if (confirmResult) window.electronAPI.closeWindow() 32 | } 33 | return ( 34 | 35 | 36 | {isSafetyMode ? '前端學習軟體' : 'Jable Downloader'} 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /src/app/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Square } from '@chakra-ui/react' 2 | import React, { useEffect, useState } from 'react' 3 | import { AiOutlineReload, AiOutlineFolderOpen } from 'react-icons/ai' 4 | import SideDrawer from './SideDrawer' 5 | import { useSelector } from 'react-redux' 6 | 7 | export default function Header({ resetAndBack, videoList }) { 8 | const [rootPath, setRootPath] = useState(localStorage.getItem('rootPath')) 9 | const isSafetyMode = useSelector((state) => state['safetyModeState']) 10 | function handleRelaunch() { 11 | window.electronAPI.reloadWindow() 12 | } 13 | function setupRootFolder() { 14 | window.electronAPI.setupRootFolder() 15 | } 16 | 17 | useEffect(() => { 18 | window.electronAPI.returnRootPath(function (e, path: string) { 19 | setRootPath(path) 20 | localStorage.setItem('rootPath', path) 21 | }) 22 | }, []) 23 | return ( 24 | 25 | 26 | {!isSafetyMode && ( 27 | <> 28 | 29 | 30 | 31 | {rootPath || '未指定根目錄'} 32 | 33 | )} 34 | 35 | 36 | 37 | {videoList.length >= 1 && ( 38 | resetAndBack()} cursor="pointer"> 39 | {isSafetyMode ? '回文章列表' : '回女優列表'} 40 | 41 | )} 42 | {/* 開發時才打開此功能 */} 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jbdelectron", 3 | "version": "1.0.0", 4 | "main": "./dist/main.js", 5 | "license": "MIT", 6 | "author": "Henry Ho", 7 | "description": "只是個side project,請各位紳士慢用", 8 | "devDependencies": { 9 | "@electron-forge/cli": "^6.0.0-beta.65", 10 | "@electron-forge/maker-deb": "^6.0.0-beta.65", 11 | "@electron-forge/maker-rpm": "^6.0.0-beta.65", 12 | "@electron-forge/maker-squirrel": "^6.0.0-beta.65", 13 | "@electron-forge/maker-zip": "^6.0.0-beta.65", 14 | "cross-env": "^7.0.3", 15 | "electron": "^16.2.6", 16 | "ts-node": "^10.4.0", 17 | "typescript": "^4.5.5", 18 | "webpack": "^5.67.0", 19 | "webpack-cli": "^4.9.2" 20 | }, 21 | "scripts": { 22 | "start": "electron-forge start", 23 | "dev": "electron .", 24 | "webp:d": "cross-env NODE_ENV=development webpack", 25 | "webp:p": "cross-env NODE_ENV=production webpack", 26 | "package": "electron-forge package", 27 | "make": "electron-forge make" 28 | }, 29 | "dependencies": { 30 | "@chakra-ui/react": "^1.8.3", 31 | "@emotion/react": "^11", 32 | "@emotion/styled": "^11", 33 | "@reduxjs/toolkit": "^1.8.1", 34 | "@types/crypto-js": "^4.1.1", 35 | "@types/react": "^17.0.39", 36 | "@types/react-dom": "^17.0.11", 37 | "@types/react-redux": "^7.1.23", 38 | "axios": "^0.25.0", 39 | "cheerio": "^1.0.0-rc.10", 40 | "concurrently": "^7.0.0", 41 | "crypto-js": "^4.1.1", 42 | "electron-squirrel-startup": "^1.0.0", 43 | "framer-motion": "^6", 44 | "open": "^8.4.0", 45 | "react": "^17.0.2", 46 | "react-dom": "^17.0.2", 47 | "react-icons": "^4.3.1", 48 | "react-redux": "^7.2.8", 49 | "redux": "^4.1.2", 50 | "ts-loader": "^9.2.6" 51 | }, 52 | "config": { 53 | "forge": { 54 | "packagerConfig": {}, 55 | "makers": [ 56 | { 57 | "name": "@electron-forge/maker-squirrel", 58 | "config": { 59 | "name": "jbdelectron" 60 | } 61 | }, 62 | { 63 | "name": "@electron-forge/maker-zip", 64 | "platforms": [ 65 | "darwin" 66 | ] 67 | }, 68 | { 69 | "name": "@electron-forge/maker-deb", 70 | "config": {} 71 | }, 72 | { 73 | "name": "@electron-forge/maker-rpm", 74 | "config": {} 75 | } 76 | ] 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, ipcMain } from 'electron' 2 | import path from 'path' 3 | import fs from 'fs' 4 | import { beginDownload, closeWindow, getActorListByPage, getVideoListByActorLink, minimizeWindow, openChrome, reloadForFetch, setupRootFolder, stopCurrentDownload } from './utils/processes' 5 | 6 | const isDev: boolean = process.env.NODE_ENV === 'development' ? true : false 7 | 8 | function createWindow() { 9 | // Create the browser window. 10 | const mainWindow = new BrowserWindow({ 11 | height: 600, 12 | webPreferences: { 13 | preload: path.join(__dirname, 'preload.js'), 14 | }, 15 | width: 900, 16 | autoHideMenuBar: true, 17 | resizable: false, 18 | titleBarStyle: 'hidden', 19 | }) 20 | 21 | mainWindow.loadFile(path.join(__dirname, '../index.html')) 22 | 23 | if (isDev) { 24 | mainWindow.webContents.openDevTools({ mode: 'detach' }) 25 | fs.watch('./dist', { recursive: true }, () => { 26 | mainWindow.reload() 27 | }) 28 | } 29 | } 30 | 31 | // This method will be called when Electron has finished 32 | // initialization and is ready to create browser windows. 33 | // Some APIs can only be used after this event occurs. 34 | app.on('ready', () => { 35 | ipcMain.on('getActorListByPage', getActorListByPage) 36 | ipcMain.on('reloadWindow', reloadForFetch) 37 | ipcMain.on('getVideoListByActorLink', getVideoListByActorLink) 38 | ipcMain.on('beginDownload', beginDownload) 39 | ipcMain.on('setupRootFolder', setupRootFolder) 40 | ipcMain.on('minimizeWindow', minimizeWindow) 41 | ipcMain.on('closeWindow', closeWindow) 42 | ipcMain.on('stopCurrentDownload', stopCurrentDownload) 43 | ipcMain.on('openChrome', openChrome) 44 | createWindow() 45 | 46 | app.on('activate', function () { 47 | // On macOS it's common to re-create a window in the app when the 48 | // dock icon is clicked and there are no other windows open. 49 | if (BrowserWindow.getAllWindows().length === 0) createWindow() 50 | }) 51 | }) 52 | 53 | // Quit when all windows are closed, except on macOS. There, it's common 54 | // for applications and their menu bar to stay active until the user quits 55 | // explicitly with Cmd + Q. 56 | app.on('window-all-closed', () => { 57 | if (process.platform !== 'darwin') { 58 | app.quit() 59 | } 60 | }) 61 | 62 | // In this file you can include the rest of your app"s specific main process 63 | // code. You can also put them in separate files and require them here. 64 | -------------------------------------------------------------------------------- /src/app/components/SideDrawer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Drawer, DrawerBody, DrawerFooter, Tooltip, DrawerHeader, DrawerOverlay, DrawerContent, DrawerCloseButton, useDisclosure, Box, Tag } from '@chakra-ui/react' 3 | import { MdMenu } from 'react-icons/md' 4 | import ColorModeSwitch from './ColorModeSwitch' 5 | import { useSelector } from 'react-redux' 6 | import { CgCloseO } from 'react-icons/cg' 7 | import { BsGithub } from 'react-icons/bs' 8 | 9 | export default function SideDrawer() { 10 | const { isOpen, onOpen, onClose } = useDisclosure() 11 | const { currentDownloadName, percentage } = useSelector((state) => state['downloadState']) 12 | const isSafetyMode = useSelector((state) => state['safetyModeState']) 13 | function suspendDownload() { 14 | const confirmResult = confirm(`確定要取消當前下載嗎?`) 15 | if (confirmResult) { 16 | window.electronAPI.stopCurrentDownload() 17 | } 18 | } 19 | 20 | function handleOpenChrome() { 21 | // 聯繫作者,開啟作者的github 22 | window.electronAPI.openChrome('https://github.com/HenryMHH') 23 | } 24 | return ( 25 | <> 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 資訊列表 34 | 35 | {isSafetyMode && '學習資源'}下載佇列 36 | {currentDownloadName && ( 37 | 38 | 39 | {isSafetyMode ? '演算法教材' : currentDownloadName}: {percentage}% 40 | 41 | 42 | 43 | 44 | 45 | )} 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /src/app/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Switch, useColorMode } from '@chakra-ui/react' 2 | import React, { useEffect, useRef } from 'react' 3 | import styled from '@emotion/styled' 4 | import { Page } from '../App' 5 | import { PaginatorAction } from '../../types/types' 6 | import { useSelector } from 'react-redux' 7 | import ColorModeSwitch from './ColorModeSwitch' 8 | import SafetyModeSwitch from './SafetyModeSwitch' 9 | import CurrentDownload from './CurrentDownload' 10 | 11 | const PaginatorBox = styled(Box)` 12 | display: flex; 13 | justify-content: center; 14 | align-items: baseline; 15 | ` 16 | 17 | const PageButton = styled(Button)` 18 | width: 18px; 19 | height: 25px; 20 | display: grid; 21 | place-items: center; 22 | margin: 0 3px; 23 | cursor: pointer; 24 | font-size: 1.3rem; 25 | background: transparent; 26 | cursor: pointer; 27 | &:hover { 28 | color: #fe628e; 29 | background: transparent; 30 | } 31 | ` 32 | 33 | interface FooterProps { 34 | videosPage: Page 35 | actorsPage: Page 36 | handleChangePage: (action: PaginatorAction) => void 37 | } 38 | 39 | export default function Footer({ videosPage, actorsPage, handleChangePage }: FooterProps) { 40 | const didMount = useRef(false) 41 | const { videoListUrl } = useSelector((state) => state['downloadState']) 42 | 43 | useEffect(() => { 44 | if (didMount.current) { 45 | window.electronAPI.getActorListByPage(actorsPage.currentPage) 46 | } else { 47 | didMount.current = true 48 | } 49 | }, [actorsPage.currentPage]) 50 | 51 | useEffect(() => { 52 | if (videosPage.currentPage >= 1 && videoListUrl) { 53 | window.electronAPI.getVideoListByActorLink({ url: videoListUrl, page: videosPage.currentPage }) 54 | } 55 | }, [videosPage.currentPage, videoListUrl]) 56 | 57 | function paginator(currentPage: number, maxPage: number) { 58 | return ( 59 | <> 60 | handleChangePage('toFirst')}> 61 | {'<<'} 62 | 63 | handleChangePage('minus')}> 64 | {'<'} 65 | 66 | 67 | {currentPage} / {maxPage} 68 | 69 | handleChangePage('plus')}> 70 | {'>'} 71 | 72 | handleChangePage('toLast')}> 73 | {'>>'} 74 | 75 | 76 | ) 77 | } 78 | return ( 79 | 80 | {paginator(videosPage.currentPage || actorsPage.currentPage, videosPage.maxPage || actorsPage.maxPage)} 81 | 82 | 83 | 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /src/app/components/Table/Table.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Icon, Image } from '@chakra-ui/react' 2 | import styled from '@emotion/styled' 3 | import React from 'react' 4 | import { useSelector } from 'react-redux' 5 | import { ActorVideoItem, ListItem } from '../../../service/getListService' 6 | import useGetSaftyModeData from '../../hooks/useGetSaftyModeData' 7 | import TableItem from './TableItem' 8 | 9 | const StyledTable = styled(Box)` 10 | height: 80vh; 11 | display: grid; 12 | grid-template-columns: repeat(4, 1fr); 13 | grid-template-rows: repeat(5, 1fr); 14 | padding: 5vh 5vw; 15 | grid-column-gap: 10px; 16 | grid-row-gap: 1em; 17 | ` 18 | 19 | const StyledTableItem = styled(Box)` 20 | overflow: hidden; 21 | border-radius: 5px; 22 | display: flex; 23 | align-items: center; 24 | cursor: pointer; 25 | &:hover { 26 | border: 1px solid #fe628e; 27 | margin: -1px; 28 | } 29 | ` 30 | 31 | const StyledVideoListTable = styled(Box)` 32 | height: 78vh; 33 | overflow-y: scroll; 34 | overflow-x: hidden; 35 | padding: 0.3rem 1rem 1rem; 36 | ` 37 | 38 | const TitleBar = styled(Box)` 39 | display: flex; 40 | text-align: center; 41 | padding: 0 1.5rem; 42 | ` 43 | 44 | interface TableData { 45 | actorList: ListItem[] 46 | videoList: ActorVideoItem[] 47 | initActorVideoList: (url: string) => void 48 | } 49 | 50 | export default function Table({ actorList = [], videoList = [], initActorVideoList }: TableData) { 51 | const isSafetyMode = useSelector((state) => state['safetyModeState']) 52 | const { fakeData } = useGetSaftyModeData() 53 | return ( 54 | <> 55 | {actorList.length > 0 && videoList.length === 0 ? ( 56 | 57 | {actorList.map((item, i) => { 58 | return ( 59 | initActorVideoList(item.href)}> 60 | {isSafetyMode ? : } 61 | 62 | {isSafetyMode ? fakeData[i].title : item.name} 63 | {isSafetyMode ? `學習時數:${Math.floor(Math.random() * 100)}小時` : item.number} 64 | 65 | 66 | ) 67 | })} 68 | 69 | ) : null} 70 | 71 | {videoList.length > 0 ? ( 72 | 73 | 74 | {isSafetyMode ? '項次' : '番號'} 75 | {isSafetyMode ? '文章名' : '片名'} 76 | 長度 77 | 觀看數 78 | 收藏數 79 | 80 | 81 | {videoList.map((item, index) => ( 82 | 83 | ))} 84 | 85 | 86 | ) : null} 87 | 88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /src/app/components/Opening.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@chakra-ui/react' 2 | import React, { useEffect, useState } from 'react' 3 | import styled from '@emotion/styled' 4 | import { keyframes } from '@emotion/react' 5 | import { sleep } from '../../utils/sleep' 6 | 7 | const FadeOut = keyframes` 8 | 0%{ 9 | opacity:1; 10 | } 11 | 100%{ 12 | opacity:0; 13 | } 14 | ` 15 | 16 | const InitBox = keyframes` 17 | 0%{ 18 | opacity:0; 19 | } 20 | 10%{ 21 | opacity:1; 22 | width:0px; 23 | } 24 | 50%{ 25 | width:120px; 26 | height:0px; 27 | margin-bottom:120px; 28 | } 29 | 99%{ 30 | padding:0; 31 | } 32 | 100%{ 33 | width:120px; 34 | height:120px; 35 | margin-bottom:0px; 36 | padding: 1rem; 37 | } 38 | ` 39 | 40 | const Blink = keyframes` 41 | 0%{ 42 | opacity:0; 43 | } 44 | 100%{ 45 | opacity:1; 46 | } 47 | ` 48 | 49 | const StyledBg = styled(Box)` 50 | position: absolute; 51 | height: 100vh; 52 | width: 100vw; 53 | background: #fe628e; 54 | justify-content: center; 55 | align-items: center; 56 | display: flex; 57 | animation: ${FadeOut} 1s ease 4.5s forwards; 58 | z-index: 9998; 59 | ` 60 | 61 | const ContentBox = styled(Box)` 62 | border: 5px solid white; 63 | border-radius: 10px; 64 | width: 0px; 65 | height: 0px; 66 | font-family: 'Quicksand', sans-serif; 67 | color: white; 68 | font-weight: 600; 69 | font-size: 1.5rem; 70 | margin-bottom: 120px; 71 | animation: ${InitBox} 2s ease forwards; 72 | opacity: 1; 73 | display: flex; 74 | flex-wrap: wrap; 75 | justify-content: flex-start; 76 | ` 77 | 78 | const ContextBox = styled(Box)` 79 | height: 1.1em; 80 | line-height: 1.1em; 81 | display: flex; 82 | ` 83 | 84 | const BlinkLine = styled(Box)` 85 | width: 2px; 86 | height: 100%; 87 | background: white; 88 | margin-left: 0.3em; 89 | animation: ${Blink} 0.5s ease infinite; 90 | ` 91 | 92 | function SingleText({ word }: { word: string }) { 93 | const [context, setContext] = useState('') 94 | async function setter() { 95 | for (const char of word.split('')) { 96 | setContext((pre) => (pre += char)) 97 | await sleep(200) 98 | } 99 | } 100 | useEffect(() => { 101 | setter() 102 | }, [word]) 103 | 104 | const isMaxLength = context.length === word.length 105 | return ( 106 | 107 | {context} 108 | {!isMaxLength && } 109 | 110 | ) 111 | } 112 | 113 | export default function Opening() { 114 | const [isVanished, setIsVanished] = useState(false) 115 | const [step, setStep] = useState(0) 116 | useEffect(() => { 117 | setTimeout(() => setStep(1), 2000) 118 | setTimeout(() => setStep(2), 3200) 119 | setTimeout(() => setIsVanished(true), 5500) 120 | }, []) 121 | return ( 122 | <> 123 | {!isVanished && ( 124 | 125 | 126 | {step >= 1 && } 127 | {step >= 2 && } 128 | 129 | 130 | )} 131 | 132 | ) 133 | } 134 | -------------------------------------------------------------------------------- /src/app/hooks/useGetSaftyModeData.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { FaReact, FaAngular, FaPython, FaVuejs, FaRust, FaSass, FaBootstrap } from 'react-icons/fa' 3 | import { CgCPlusPlus } from 'react-icons/cg' 4 | import { SiNextdotjs, SiCsharp, SiPug, SiWebpack, SiElectron, SiSolidity, SiRedux, SiPhp, SiVite, SiWebassembly, SiJenkins, SiHtml5 } from 'react-icons/si' 5 | import { DiRuby } from 'react-icons/di' 6 | import { IconType } from 'react-icons' 7 | 8 | const initialData = [ 9 | { title: 'React', icon: FaReact }, 10 | { title: 'Angular', icon: FaAngular }, 11 | { title: 'Python', icon: FaPython }, 12 | { title: 'Vue', icon: FaVuejs }, 13 | { title: 'Rust', icon: FaRust }, 14 | { title: 'Sass', icon: FaSass }, 15 | { title: 'Boostrap', icon: FaBootstrap }, 16 | { title: 'C++', icon: CgCPlusPlus }, 17 | { title: 'C#', icon: SiCsharp }, 18 | { title: 'Pug', icon: SiPug }, 19 | { title: 'NextJS', icon: SiNextdotjs }, 20 | { title: 'Ruby', icon: DiRuby }, 21 | { title: 'Webpack', icon: SiWebpack }, 22 | { title: 'Electron', icon: SiElectron }, 23 | { title: 'Solidity', icon: SiSolidity }, 24 | { title: 'Redux', icon: SiRedux }, 25 | { title: 'Vite', icon: SiVite }, 26 | { title: 'Webassembly', icon: SiWebassembly }, 27 | { title: 'Php', icon: SiPhp }, 28 | { title: 'Jenkins', icon: SiJenkins }, 29 | { title: 'Html5', icon: SiHtml5 }, 30 | ] 31 | 32 | const fakeArticleList = [ 33 | 'Styling React Applications with Styled Components', 34 | 'Manage Application State with Jotai Atoms', 35 | 'Ionic Quickstart for Windows: Installing Ionic', 36 | 'Get Started with the AWS Amplify Admin UI', 37 | 'Create Contextual Video Analysis App with NextJS and Symbl.ai', 38 | 'Manage React Form State with redux-form', 39 | 'Angular Service Injection with the Dependency Injector (DI)', 40 | 'Manage Application State with Mobx-state-tree', 41 | 'Integrate IBM Domino with Node.js', 42 | 'JSON Web Token (JWT) Authentication with Node.js and Auth0', 43 | 'Test React Components with Enzyme and Jest', 44 | 'Making an HTTP server in ReasonML on top of Node.js', 45 | 'AngularJS with Webpack - Testing with Karma, Mocha, and Chai', 46 | 'AngularJS with Webpack - Production Setup', 47 | 'Install the Genymotion Android Emulator for Ionic', 48 | 'Edit Your Ionic Application Code', 49 | 'Use the fs/promises node API to Serialize a Map to the Filesystem', 50 | 'Sort and Return an Array of Objects with a Query Param in an Express API', 51 | 'Add Basic Error Handling to an Express 5 App', 52 | 'Deploy a Node.js function to AWS Lambda using the Serverless Framework', 53 | 'Deploy a DynamoDB table to AWS using the Serverless Framework', 54 | 'Deploy an AWS Lambda to retrieve a record from DynamoDB with the Serverless Framework', 55 | 'Course Overview: Develop a Serverless Backend using Node.js on AWS Lambda', 56 | 'Setup the Serverless Framework', 57 | ] 58 | 59 | export default function useGetSaftyModeData() { 60 | const [fakeData, setFakeData] = useState>(initialData) 61 | const [fakeArticle, setFakeArticle] = useState>(fakeArticleList) 62 | return { fakeData, fakeArticle } 63 | } 64 | -------------------------------------------------------------------------------- /src/utils/processes.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, dialog, IpcMainEvent } from 'electron' 2 | import { DownloadService } from '../service/downloadService' 3 | import { BeginDownloadInfo } from '../types/types' 4 | import fs from 'fs' 5 | import { GetListService, InitInfo } from '../service/getListService' 6 | import open from 'open' 7 | 8 | let stopDownload = false 9 | 10 | /** 11 | * 12 | * @param event 13 | * @param info 14 | * @returns void 15 | */ 16 | export async function beginDownload(event: IpcMainEvent, info: BeginDownloadInfo): Promise { 17 | stopDownload = false 18 | 19 | const { link, rootPath } = info 20 | const downloadService = new DownloadService() 21 | const focusedWindow = BrowserWindow.getFocusedWindow() 22 | const downloadInfo = await downloadService.initDownloadInfo(link) 23 | const { tsFileArray, folderName, keyURIContent, _IV } = downloadInfo 24 | 25 | if (tsFileArray.length === 0) { 26 | focusedWindow.webContents.send('error', '未獲取下載連結') 27 | return 28 | } 29 | 30 | const dir = rootPath + '/' + folderName 31 | const mp4Name = folderName + '.mp4' 32 | const mp4FileNameArray = tsFileArray.map((item) => item.replace('ts', 'mp4')) 33 | const mp4FullPath = dir + '/' + mp4Name 34 | 35 | if (!fs.existsSync(dir)) { 36 | fs.mkdirSync(dir) 37 | focusedWindow.webContents.send('success', `建立資料夾-${mp4Name},下載初始化!`) 38 | } else { 39 | focusedWindow.webContents.send('success', `資料夾已存在,繼續未完成下載!`) 40 | } 41 | 42 | try { 43 | for (let i = 0; i < mp4FileNameArray.length; i++) { 44 | if (stopDownload) { 45 | throw '停止下載!' 46 | } 47 | const singleMp4FullPath = dir + '/' + mp4FileNameArray[i] 48 | const fullDownloadUrl = downloadInfo.tsFileUrl + tsFileArray[i] 49 | 50 | function errorCallback(err) { 51 | console.log('err') 52 | focusedWindow.webContents.send('error', err) 53 | } 54 | await downloadService.downloadTsFile(singleMp4FullPath, fullDownloadUrl, _IV, keyURIContent, errorCallback) 55 | focusedWindow.webContents.send('percentage', downloadService.formatPercentage(i, mp4FileNameArray.length)) 56 | } 57 | downloadService.combineTsFile(dir, mp4FullPath, mp4FileNameArray) 58 | downloadService.deleteTsFile(dir, mp4FileNameArray) 59 | 60 | focusedWindow.webContents.send('success', `${mp4Name} 下載完成!`) 61 | } catch (error) { 62 | focusedWindow.webContents.send('error', error) 63 | } 64 | } 65 | 66 | export async function getActorListByPage(event: IpcMainEvent, page: number) { 67 | const focusedWindow = BrowserWindow.getFocusedWindow() 68 | const getListService = new GetListService() 69 | const result: InitInfo = await getListService.getActorListByPage(page) 70 | focusedWindow.webContents.send('returnInfo', result) 71 | } 72 | 73 | export async function getVideoListByActorLink(evnet: IpcMainEvent, { url, page }: { url: string; page: number }) { 74 | const focusedWindow = BrowserWindow.getFocusedWindow() 75 | const getListService = new GetListService() 76 | const result = await getListService.getVideoListByActorLink(url, page) 77 | focusedWindow.webContents.send('returnVideoList', result) 78 | } 79 | 80 | export async function setupRootFolder() { 81 | const focusedWindow = BrowserWindow.getFocusedWindow() 82 | const getFolderPath = await dialog.showOpenDialog({ 83 | properties: ['openDirectory'], 84 | }) 85 | 86 | if (!getFolderPath.canceled) { 87 | focusedWindow.webContents.send('returnRootPath', getFolderPath.filePaths[0]) 88 | } 89 | } 90 | 91 | export function reloadForFetch() { 92 | const focusedWindow = BrowserWindow.getFocusedWindow() 93 | focusedWindow.reload() 94 | } 95 | 96 | export function minimizeWindow() { 97 | const focusedWindow = BrowserWindow.getFocusedWindow() 98 | focusedWindow.minimize() 99 | } 100 | 101 | export function closeWindow() { 102 | const focusedWindow = BrowserWindow.getFocusedWindow() 103 | focusedWindow.close() 104 | } 105 | 106 | export function stopCurrentDownload() { 107 | stopDownload = true 108 | } 109 | 110 | export async function openChrome(e: IpcMainEvent, url: string) { 111 | await open(url, { app: { name: open.apps.chrome } }) 112 | } 113 | -------------------------------------------------------------------------------- /src/app/components/Table/TableItem.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AlertDialog, 3 | AlertDialogBody, 4 | AlertDialogCloseButton, 5 | AlertDialogContent, 6 | AlertDialogFooter, 7 | AlertDialogHeader, 8 | AlertDialogOverlay, 9 | Box, 10 | Button, 11 | Icon, 12 | Image, 13 | useDisclosure, 14 | } from '@chakra-ui/react' 15 | import styled from '@emotion/styled' 16 | import React, { useRef } from 'react' 17 | import { AiFillCheckCircle } from 'react-icons/ai' 18 | import { useDispatch, useSelector } from 'react-redux' 19 | import { updateDownloadState } from '../../../reducers/downloadStateSlice' 20 | import { ActorVideoItem } from '../../../service/getListService' 21 | import useGetSaftyModeData from '../../hooks/useGetSaftyModeData' 22 | 23 | const StyledTableItem = styled(Box)` 24 | // overflow: hidden; 25 | border-radius: 5px; 26 | display: flex; 27 | align-items: center; 28 | cursor: pointer; 29 | &:hover { 30 | border: 1px solid #fe628e; 31 | margin: -1px; 32 | } 33 | ` 34 | 35 | const Item = styled(Box)` 36 | margin-left: 5px; 37 | display: flex; 38 | position: relative; 39 | align-items: center; 40 | ` 41 | 42 | const IconBox = styled(Box)` 43 | position: absolute; 44 | left: -1rem; 45 | z-index: 500; 46 | color: ; 47 | ` 48 | 49 | export default function TableItem({ item, index }: { item: ActorVideoItem; index: number }) { 50 | const { isOpen, onClose, onOpen } = useDisclosure() 51 | const isSafetyMode = useSelector((state) => state['safetyModeState']) 52 | const { fakeData, fakeArticle } = useGetSaftyModeData() 53 | const dispatch = useDispatch() 54 | const { currentDownloadName } = useSelector((state) => state['downloadState']) 55 | const downloadHistory = localStorage.getItem('downloadHistory') ? (JSON.parse(localStorage.getItem('downloadHistory')) as Array) : [] 56 | const cancelRef = useRef() 57 | function handleDownload(link: string) { 58 | const rootPath = localStorage.getItem('rootPath') 59 | if (rootPath) { 60 | if (downloadHistory) { 61 | if (downloadHistory.includes(item.indexNO)) { 62 | const confirmResult = confirm('本片曾經下載過,確定要再次下載或繼續未完成下載?') 63 | if (confirmResult) { 64 | dispatch(updateDownloadState({ currentDownloadName: item.indexNO })) 65 | window.electronAPI.beginDownload({ link, rootPath }) 66 | } 67 | } else { 68 | dispatch(updateDownloadState({ currentDownloadName: item.indexNO })) 69 | window.electronAPI.beginDownload({ link, rootPath }) 70 | localStorage.setItem('downloadHistory', JSON.stringify([...downloadHistory, item.indexNO])) 71 | } 72 | } else { 73 | window.electronAPI.beginDownload({ link, rootPath }) 74 | localStorage.setItem('downloadHistory', JSON.stringify([item.indexNO])) 75 | } 76 | } else { 77 | alert('請指定下載根目錄!') 78 | } 79 | } 80 | return ( 81 | onOpen()}> 82 | 83 | {downloadHistory.includes(item.indexNO) && } 84 | 85 | {isSafetyMode ? index : item.indexNO} 86 | 87 | {isSafetyMode ? fakeArticle[index] : item.title} 88 | {item.time} 89 | {item.views} 90 | {item.favorite} 91 | 92 | 93 | 94 | 95 | {isSafetyMode ? `項次: ${index}` : `番號: ${item.indexNO}`} 96 | 97 | 98 | {isSafetyMode ? : } 99 | {isSafetyMode ? fakeArticle[index] : item.title} 100 | 101 | 102 | 105 | 116 | 117 | 118 | 119 | 120 | ) 121 | } 122 | -------------------------------------------------------------------------------- /src/service/getListService.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance } from 'axios' 2 | import cheerio from 'cheerio' 3 | import https from 'https' 4 | import { dialog } from 'electron' 5 | 6 | /** 7 | * * name 女優名 string 8 | * * number 影片數 string 9 | * * href 女優影片清單連結 string 10 | * * imgSrc 女優頭像 string 11 | */ 12 | export interface ListItem { 13 | name: string 14 | number: string 15 | href: string 16 | imgSrc: string 17 | } 18 | 19 | /** 20 | * * maxlistPage 最大頁面數 number 21 | * * actorList 女優清單 Array<{@link ListItem}> 22 | */ 23 | export interface InitInfo { 24 | maxListPage: number 25 | actorList: Array 26 | } 27 | 28 | /** 29 | * * indexNO 番號 string 30 | * * title 片名 string 31 | * * imgSrc 封面照 string 32 | * * views 觀看人數 string 33 | * * favorite 收藏人數 string 34 | * * link 影片連結 string 35 | */ 36 | export interface ActorVideoItem { 37 | indexNO: string 38 | title: string 39 | imgSrc: string 40 | views: string 41 | favorite: string 42 | link: string 43 | time: string 44 | } 45 | 46 | /** 47 | * * maxListPage 最大影片頁數 number 48 | * * videoList 該女優的影片清單 Array<{@link ActorVideoItem}> 49 | */ 50 | export interface ActorVideoInfo { 51 | maxListPage: number 52 | videoList: Array 53 | } 54 | 55 | export class GetListService { 56 | private initialUrl = 'https://jable.tv/models/?mode=async&function=get_block&block_id=list_models_models_list&sort_by=total_videos' 57 | private maxListPage = 0 58 | private maxVideoListPage = 0 59 | private Axios: AxiosInstance 60 | constructor() { 61 | this.Axios = axios.create({ httpsAgent: new https.Agent({ timeout: 0 }) }) 62 | } 63 | async getActorListByPage(page: number): Promise { 64 | let result 65 | const returnObj: InitInfo = { 66 | maxListPage: 0, 67 | actorList: [], 68 | } 69 | try { 70 | // Axios 的 instance只能在這邊每次打就建一次,如果建好一個重複使用都會被cloudflare擋住 71 | // 可能未來有更好的解法 72 | result = await this.Axios.get(this.initialUrl + `&from=${page}`) 73 | const $ = cheerio.load(result['data']) 74 | const linkList = $('div.horizontal-img-box').find('a').toArray() 75 | 76 | // 爬每一個page的女優名單 & 資料 77 | for (const value of linkList) { 78 | const tempHref = value.attribs['href'] 79 | returnObj.actorList.push({ 80 | href: tempHref, 81 | name: $('div.horizontal-img-box').find(`a[href=${tempHref}]`).find('h6.title').text(), 82 | imgSrc: $(`a[href=${tempHref}]`).find('div.media img').toArray()[0].attribs['src'], 83 | number: $(`a[href=${tempHref}]`).find('div.detail span').text(), 84 | }) 85 | } 86 | 87 | // 爬max page 88 | const list = $('ul.pagination').find('a') 89 | if (list.length > 0) { 90 | this.maxListPage = parseInt(list[list.length - 1].attribs.href.split('/')[2]) 91 | } 92 | } catch (error) { 93 | console.log(error) 94 | } 95 | 96 | returnObj.maxListPage = this.maxListPage 97 | 98 | return returnObj 99 | } 100 | 101 | async getVideoListByActorLink(url: string, page: number = 1) { 102 | let result 103 | const returnObj: ActorVideoInfo = { 104 | maxListPage: this.maxVideoListPage, 105 | videoList: [], 106 | } 107 | try { 108 | result = await this.Axios.get(url + `?sort_by=total_video&from=${page}`) 109 | const $ = cheerio.load(result['data']) 110 | 111 | const videos = $('h6.title > a').toArray() 112 | 113 | videos.forEach((item) => { 114 | const imgDetail = $('div.img-box').find(`a[href=${item.attribs['href']}]`).first() 115 | const detail = $('h6.title').find(`a[href=${item.attribs['href']}]`).first() 116 | returnObj.videoList.push({ 117 | title: detail.text().split(' ')[1], 118 | indexNO: detail.text().split(' ')[0], 119 | link: item.attribs['href'], 120 | views: $('div.detail').find('h6.title').has(`a[href=${item.attribs['href']}]`).next().first().text().split(' ').join('').split('\n')[1], 121 | favorite: $('div.detail').find('h6.title').has(`a[href=${item.attribs['href']}]`).next().first().text().split(' ').join('').split('\n')[2], 122 | imgSrc: imgDetail.find('img').attr('data-src'), 123 | time: imgDetail.find('span.label').text(), 124 | }) 125 | }) 126 | 127 | // 爬女優的影片頁數 128 | const paginator = $('ul.pagination').find('a') 129 | if (paginator.length > 0) { 130 | this.maxVideoListPage = parseInt(paginator[paginator.length - 1].attribs['data-parameters'].split(';from:')[1]) 131 | } else { 132 | this.maxVideoListPage = 1 133 | } 134 | } catch (error) { 135 | console.log(error) 136 | this.maxVideoListPage = 0 137 | } 138 | 139 | returnObj.maxListPage = this.maxVideoListPage 140 | return returnObj 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/app/App.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button } from '@chakra-ui/react' 2 | import { IpcRendererEvent } from 'electron' 3 | import React, { useEffect, useState } from 'react' 4 | import { ActorVideoInfo, ActorVideoItem, InitInfo, ListItem } from '../service/getListService' 5 | import Footer from './components/Footer' 6 | import TitleBar from './components/TitleBar' 7 | import Opening from './components/Opening' 8 | import Table from './components/Table/Table' 9 | import Header from './components/Header' 10 | import useToastHook from './hooks/useToastHook' 11 | import { PaginatorAction } from '../types/types' 12 | import Loading from './components/Loading' 13 | import { useDispatch, useSelector } from 'react-redux' 14 | import { updateLoadingState } from '../reducers/loadingStateSlice' 15 | import { updateDownloadState } from '../reducers/downloadStateSlice' 16 | 17 | function handleInitInfo() { 18 | window.electronAPI.getActorListByPage(1) 19 | } 20 | 21 | export interface Page { 22 | currentPage: number 23 | maxPage: number 24 | } 25 | 26 | export default function App() { 27 | const [actorsPage, setActorsPage] = useState({ maxPage: 0, currentPage: 1 }) 28 | const [videosPage, setVideosPage] = useState({ maxPage: 0, currentPage: 0 }) 29 | const [actorList, setActorList] = useState([]) 30 | const [videoList, setVideoList] = useState([]) 31 | const dispatch = useDispatch() 32 | const isLoading = useSelector((state) => state['loadingState']) 33 | 34 | const { setTip } = useToastHook() 35 | function resetAndBack() { 36 | setVideosPage({ maxPage: 0, currentPage: 0 }) 37 | setVideoList([]) 38 | } 39 | 40 | function resetDownloadState() { 41 | dispatch(updateDownloadState({ currentDownloadName: '', percentage: '' })) 42 | } 43 | 44 | // 註冊所有的electronApi事件 45 | useEffect(() => { 46 | window.electronAPI.errorMessenger((e, message: string) => { 47 | setTip({ status: 'error', msg: message }) 48 | if (message.includes('程序中斷') || message.includes('停止下載')) { 49 | resetDownloadState() 50 | } 51 | }) 52 | window.electronAPI.successMessenger((e, message: string) => { 53 | setTip({ status: 'success', msg: message }) 54 | 55 | // 下載完成以後清除當前下載檔案名稱 56 | if (message.includes('下載完成')) { 57 | resetDownloadState() 58 | } 59 | }) 60 | window.electronAPI.getPercentage((e, percentage: number) => { 61 | dispatch(updateDownloadState({ percentage })) 62 | }) 63 | window.electronAPI.actorVideoListSetter((e: IpcRendererEvent, value: ActorVideoInfo) => { 64 | setVideoList(value.videoList) 65 | // 爬蟲目標最後一頁的paginator會是最大頁數減1,避免頁數顯示異常,只要maxPage小於前面爬蟲的結果都不給複寫 66 | setVideosPage((pre) => { 67 | if (pre.maxPage > value.maxListPage) { 68 | return pre 69 | } else { 70 | return { ...pre, maxPage: value.maxListPage } 71 | } 72 | }) 73 | dispatch(updateLoadingState(false)) 74 | }) 75 | window.electronAPI.infoSetter((event: IpcRendererEvent, value: InitInfo) => { 76 | setActorList(value.actorList) 77 | // 爬蟲目標最後一頁的paginator會是最大頁數減1,避免頁數顯示異常,只要maxPage小於前面爬蟲的結果都不給複寫 78 | setActorsPage((pre) => { 79 | if (pre.maxPage > value.maxListPage) { 80 | return pre 81 | } else { 82 | return { ...pre, maxPage: value.maxListPage } 83 | } 84 | }) 85 | dispatch(updateLoadingState(false)) 86 | }) 87 | }, []) 88 | 89 | useEffect(() => { 90 | // 當App component mount的時候送出第一頁資料的fetch request 91 | handleInitInfo() 92 | }, []) 93 | 94 | function initActorVideoList(url: string) { 95 | setVideosPage((pre) => (pre = { ...pre, currentPage: 1 })) 96 | dispatch(updateDownloadState({ videoListUrl: url })) 97 | dispatch(updateLoadingState(true)) 98 | } 99 | 100 | function handleChangePage(action: PaginatorAction) { 101 | let currentPage = videosPage.currentPage || actorsPage.currentPage 102 | let maxPage = videosPage.maxPage || actorsPage.maxPage 103 | let cb = videosPage.maxPage ? setVideosPage : setActorsPage 104 | dispatch(updateLoadingState(true)) 105 | switch (action) { 106 | case 'plus': 107 | if (currentPage < maxPage) { 108 | cb((pre) => (pre = { ...pre, currentPage: currentPage + 1 })) 109 | } 110 | return 111 | case 'minus': 112 | if (currentPage > 1) { 113 | cb((pre) => (pre = { ...pre, currentPage: currentPage - 1 })) 114 | } 115 | return 116 | case 'toFirst': 117 | cb((pre) => (pre = { ...pre, currentPage: 1 })) 118 | return 119 | case 'toLast': 120 | cb((pre) => (pre = { ...pre, currentPage: maxPage })) 121 | return 122 | default: 123 | return 124 | } 125 | } 126 | return ( 127 | <> 128 | {process.env.NODE_ENV !== 'development' && } 129 | {isLoading && } 130 | 131 | 132 |
133 | 134 |
135 | 136 | 137 | ) 138 | } 139 | -------------------------------------------------------------------------------- /src/service/downloadService.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance } from 'axios' 2 | import https from 'https' 3 | import cheerio from 'cheerio' 4 | import crypto from 'crypto' 5 | import { InitDownloadInfo, FetchStatus, M3U8Info } from '../types/types' 6 | import fs from 'fs' 7 | export class DownloadService { 8 | private Axios: AxiosInstance 9 | constructor() { 10 | this.Axios = axios.create({ httpsAgent: new https.Agent({ timeout: 0 }) }) 11 | } 12 | /** 13 | * 初始化下載所有ts file所需要的資訊,包括IV / KEY的內容 14 | * @param link 15 | * @returns 16 | */ 17 | async initDownloadInfo(link: string): Promise { 18 | const downloadInfo: InitDownloadInfo = { 19 | tsFileUrl: '', 20 | folderName: '', 21 | tsFileArray: [], 22 | keyURIContent: Buffer.from(''), 23 | _IV: '', 24 | } 25 | try { 26 | const result = await this.Axios.get(link) 27 | const $ = cheerio.load(result.data) 28 | const htmlString = $.html() 29 | const m3u8_url = htmlString.match(/https:\/\/(?:(?!exclude).)*\.m3u8/g)[0] 30 | const m3u8info = await this.fetchM3U8Content(m3u8_url) 31 | const fullUriUrl = m3u8_url.split('/').slice(0, -1).join('/') + '/' + m3u8info.keyURIName 32 | const _keyURIContent = await this.fetchKeyURIContent(fullUriUrl) 33 | 34 | if (!_keyURIContent || m3u8info.status !== 'success') { 35 | throw 'error' 36 | } 37 | 38 | downloadInfo.tsFileUrl = m3u8_url.split('/').slice(0, -1).join('/') + '/' 39 | downloadInfo.folderName = link.split('videos/')[1].split('/')[0] 40 | downloadInfo.keyURIContent = _keyURIContent 41 | downloadInfo._IV = m3u8info._IV 42 | downloadInfo.tsFileArray = m3u8info.tsFileArray 43 | } catch (error) { 44 | console.log(error) 45 | } 46 | return downloadInfo 47 | } 48 | /** 49 | * 下載m3u8檔案 50 | * 檔案中包含IV / KEY的檔案 / 所有ts片段的檔案名稱 51 | * @param url 52 | * @returns 53 | */ 54 | async fetchM3U8Content(url: string): Promise { 55 | const m3u8Info = { 56 | status: FetchStatus.pending, 57 | keyURIName: '', 58 | _IV: '', 59 | tsFileArray: [], 60 | } 61 | try { 62 | const result = await this.Axios.get(url) 63 | m3u8Info.keyURIName = result.data 64 | .split(',') 65 | .filter((item) => item.includes('URI'))[0] 66 | .split('=')[1] 67 | .replace(/\"/g, '') 68 | 69 | m3u8Info._IV = result.data 70 | .split(',') 71 | .filter((item) => item.includes('IV'))[0] 72 | .split('#')[0] 73 | .split('=')[1] 74 | .replace('0x', '') 75 | .slice(0, 16) 76 | 77 | m3u8Info.tsFileArray = result.data 78 | .split(',') 79 | .map((item) => item.split('#')[0].trim()) 80 | .filter((item) => parseInt(item.split('.')[0]) > 0) 81 | 82 | m3u8Info.status = FetchStatus.success 83 | } catch (error) { 84 | m3u8Info.status = FetchStatus.error 85 | } 86 | return m3u8Info 87 | } 88 | /** 89 | * 下載key的內容,用於aes-cbc解密 90 | * @param url 91 | * @returns Buffer 92 | */ 93 | async fetchKeyURIContent(url: string): Promise { 94 | let keyURIContent = Buffer.from('') 95 | try { 96 | const result = await this.Axios.get(url, { responseType: 'arraybuffer' }) 97 | keyURIContent = result.data 98 | } catch (error) { 99 | console.log(error) 100 | } 101 | return keyURIContent 102 | } 103 | 104 | /** 105 | * 下載TS檔案,如果該檔案已經存在,則略過下載 106 | * @param singleMp4FullPath 107 | * @param fullDownloadUrl 108 | * @param IV 109 | * @param keyContent 110 | * @param errorCb 111 | */ 112 | async downloadTsFile(singleMp4FullPath: string, fullDownloadUrl: string, IV: string, keyContent: Buffer, errorCb) { 113 | if (!fs.existsSync(singleMp4FullPath)) { 114 | try { 115 | const result = await this.Axios.get(fullDownloadUrl, { responseType: 'arraybuffer' }) 116 | fs.writeFile(singleMp4FullPath, this.decrypt(result.data, IV, keyContent), function (err) { 117 | if (err) { 118 | errorCb(err) 119 | return 120 | } 121 | }) 122 | } catch (err) { 123 | errorCb(err) 124 | } 125 | } 126 | } 127 | /** 128 | * 把所有零散的mp4檔合併 129 | * @param dir 130 | * @param mp4FullPath 131 | * @param mp4FileNameArray 132 | */ 133 | combineTsFile(dir: string, mp4FullPath: string, mp4FileNameArray: string[]) { 134 | let writer = fs.createWriteStream(mp4FullPath) 135 | for (let i = 0; i < mp4FileNameArray.length; i++) { 136 | let fullPath = dir + '/' + mp4FileNameArray[i] 137 | if (fs.existsSync(fullPath)) { 138 | const content = fs.readFileSync(fullPath) 139 | writer.write(content) 140 | } else { 141 | console.log(fullPath) 142 | } 143 | } 144 | writer.end() 145 | } 146 | /** 147 | * 刪除所有零散的mp4檔 148 | * @param dir 149 | * @param mp4FileNameArray 150 | */ 151 | deleteTsFile(dir: string, mp4FileNameArray: string[]) { 152 | for (let i = 0; i < mp4FileNameArray.length; i++) { 153 | let fullPath = dir + '/' + mp4FileNameArray[i] 154 | fs.unlink(fullPath, (errMsg) => { 155 | if (errMsg) { 156 | console.log(errMsg) 157 | } 158 | }) 159 | } 160 | } 161 | 162 | /** 163 | * aes-cbc-128解密,key 跟 IV 都是16 bytes Buffer 164 | * @param data 165 | * @param IV 166 | * @param key 167 | * @returns 168 | */ 169 | decrypt(data: Uint8Array, IV: string, key: Uint8Array) { 170 | const decipher = crypto.createDecipheriv('aes-128-cbc', key, Buffer.from(IV)) 171 | let decrypted = decipher.update(data) 172 | decrypted = Buffer.concat([decrypted, decipher.final()]) 173 | return decrypted 174 | } 175 | /** 176 | * 轉換當前下載數量比 為 百分比數字(string) 177 | * @param currentIndex 178 | * @param totalFileNumber 179 | * @returns 180 | */ 181 | formatPercentage(currentIndex: number, totalFileNumber: number): string { 182 | let ratio = (currentIndex / totalFileNumber) * 100 183 | return Number.parseFloat(ratio.toString()).toFixed(1) 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | "jsx": "react" /* Specify what JSX code is generated. */, 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "commonjs" /* Specify what module code is generated. */, 28 | // "rootDir": "./", /* Specify the root folder within your source files. */ 29 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 34 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 36 | // "resolveJsonModule": true, /* Enable importing .json files */ 37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 38 | 39 | /* JavaScript Support */ 40 | "allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */, 41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 43 | /* Emit */ 44 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 45 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 46 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 47 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 48 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 49 | "outDir": "./lib" /* Specify an output folder for all emitted files. */, 50 | // "removeComments": true, /* Disable emitting comments. */ 51 | // "noEmit": true, /* Disable emitting files from a compilation. */ 52 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 53 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 54 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 55 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 56 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 57 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 58 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 59 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 60 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 61 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 62 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 63 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 64 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 65 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 66 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 67 | 68 | /* Interop Constraints */ 69 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 70 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 71 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, 72 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 73 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 74 | // "watch": true, 75 | /* Type Checking */ 76 | // "strict": true /* Enable all strict type-checking options. */, 77 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 78 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 79 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 80 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 81 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 82 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 83 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 84 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 85 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 86 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 87 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 88 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 89 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 90 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 91 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 92 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 93 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 94 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 95 | 96 | /* Completeness */ 97 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 98 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 99 | }, 100 | "include": ["src/**/*", "."], 101 | "exclude": ["lib/**/*"], 102 | "compileOnSave": true 103 | } 104 | --------------------------------------------------------------------------------