├── 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 |
--------------------------------------------------------------------------------