├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml └── workflows │ ├── close-default-title-issue.yml │ └── publish.yml ├── src ├── vite-env.d.ts ├── types.ts ├── styles │ ├── UncompletedProgresses.module.css │ ├── ChapterPane.module.css │ └── global.css ├── main.tsx ├── App.tsx ├── components │ ├── CompletedProgresses.tsx │ ├── LoginDialog.tsx │ ├── ComicCard.tsx │ ├── AboutDialog.tsx │ ├── DownloadedComicCard.tsx │ ├── SettingsDialog.tsx │ ├── LogViewer.tsx │ └── UncompletedProgresses.tsx ├── panes │ ├── FavoritePane.tsx │ ├── SearchPane.tsx │ ├── DownloadingPane.tsx │ ├── DownloadedPane.tsx │ └── ChapterPane.tsx ├── assets │ └── react.svg ├── AppContent.tsx └── bindings.ts ├── src-tauri ├── build.rs ├── icons │ ├── icon.ico │ ├── icon.png │ ├── 32x32.png │ ├── icon.icns │ ├── 128x128.png │ ├── StoreLogo.png │ ├── 128x128@2x.png │ ├── Square30x30Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ └── Square310x310Logo.png ├── .gitignore ├── src │ ├── main.rs │ ├── types │ │ ├── mod.rs │ │ ├── log_level.rs │ │ ├── user_profile.rs │ │ ├── comic_info.rs │ │ ├── get_favorite_result.rs │ │ ├── search_result.rs │ │ └── comic.rs │ ├── utils.rs │ ├── errors.rs │ ├── extensions.rs │ ├── events.rs │ ├── config.rs │ ├── lib.rs │ ├── decrypt.rs │ ├── logger.rs │ ├── manhuagui_client.rs │ └── commands.rs ├── capabilities │ └── default.json ├── tauri.conf.json └── Cargo.toml ├── .vscode └── extensions.json ├── .prettierrc ├── tsconfig.node.json ├── .gitignore ├── index.html ├── eslint.config.js ├── uno.config.ts ├── tsconfig.json ├── vite.config.ts ├── package.json ├── LICENSE ├── public ├── vite.svg └── tauri.svg └── README.md /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/manhuagui-downloader/HEAD/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/manhuagui-downloader/HEAD/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/manhuagui-downloader/HEAD/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/manhuagui-downloader/HEAD/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/manhuagui-downloader/HEAD/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/manhuagui-downloader/HEAD/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/manhuagui-downloader/HEAD/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/manhuagui-downloader/HEAD/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/manhuagui-downloader/HEAD/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/manhuagui-downloader/HEAD/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/manhuagui-downloader/HEAD/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/manhuagui-downloader/HEAD/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/manhuagui-downloader/HEAD/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/manhuagui-downloader/HEAD/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/manhuagui-downloader/HEAD/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/manhuagui-downloader/HEAD/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "semi": false, 6 | "bracketSameLine": true, 7 | "endOfLine": "auto", 8 | "htmlWhitespaceSensitivity": "ignore" 9 | } -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Generated by Tauri 6 | # will have schema files for capabilities auto-completion 7 | /gen/schemas 8 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() { 5 | manhuagui_downloader_lib::run() 6 | } 7 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { DownloadTaskEvent } from './bindings.ts' 2 | 3 | export type CurrentTabName = 'search' | 'favorite' | 'downloaded' | 'chapter' 4 | 5 | export type ProgressData = DownloadTaskEvent & { percentage: number; indicator: string } -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/styles/UncompletedProgresses.module.css: -------------------------------------------------------------------------------- 1 | .selectionContainer { 2 | @apply select-none overflow-auto; 3 | 4 | :global(.selected) { 5 | @apply bg-[rgb(204,232,255)]; 6 | } 7 | 8 | :global(.downloaded) { 9 | @apply bg-[rgba(24,160,88,0.16)]; 10 | } 11 | } -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App' 4 | import 'virtual:uno.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /src-tauri/src/types/mod.rs: -------------------------------------------------------------------------------- 1 | mod comic; 2 | mod comic_info; 3 | mod get_favorite_result; 4 | mod log_level; 5 | mod search_result; 6 | mod user_profile; 7 | 8 | pub use comic::*; 9 | pub use comic_info::*; 10 | pub use get_favorite_result::*; 11 | pub use log_level::*; 12 | pub use search_result::*; 13 | pub use user_profile::*; 14 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "Capability for the main window", 5 | "windows": [ 6 | "main" 7 | ], 8 | "permissions": [ 9 | "core:default", 10 | "opener:default", 11 | "dialog:allow-open" 12 | ] 13 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tauri + React + Typescript 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src-tauri/src/types/log_level.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use specta::Type; 3 | 4 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 5 | pub enum LogLevel { 6 | #[serde(rename = "TRACE")] 7 | Trace, 8 | #[serde(rename = "DEBUG")] 9 | Debug, 10 | #[serde(rename = "INFO")] 11 | Info, 12 | #[serde(rename = "WARN")] 13 | Warn, 14 | #[serde(rename = "ERROR")] 15 | Error, 16 | } 17 | -------------------------------------------------------------------------------- /src-tauri/src/utils.rs: -------------------------------------------------------------------------------- 1 | pub fn filename_filter(s: &str) -> String { 2 | s.chars() 3 | .map(|c| match c { 4 | '\\' | '/' => ' ', 5 | ':' => ':', 6 | '*' => '⭐', 7 | '?' => '?', 8 | '"' => '\'', 9 | '<' => '《', 10 | '>' => '》', 11 | '|' => '丨', 12 | _ => c, 13 | }) 14 | .collect::() 15 | .trim() 16 | .to_string() 17 | } 18 | -------------------------------------------------------------------------------- /src/styles/ChapterPane.module.css: -------------------------------------------------------------------------------- 1 | .antdCheckBox { 2 | &:global(.ant-checkbox-wrapper) { 3 | @apply w-full; 4 | 5 | & > span:nth-child(2) { 6 | @apply overflow-hidden whitespace-nowrap text-ellipsis pr-0; 7 | } 8 | } 9 | } 10 | 11 | .selectionContainer { 12 | @apply select-none overflow-auto; 13 | 14 | :global(.selected) { 15 | @apply bg-[rgb(204,232,255)]; 16 | } 17 | 18 | :global(.downloaded) { 19 | @apply bg-[rgba(24,160,88,0.16)]; 20 | } 21 | } -------------------------------------------------------------------------------- /src/styles/global.css: -------------------------------------------------------------------------------- 1 | body { 2 | @apply m-0; 3 | } 4 | 5 | .ant-notification { 6 | @apply whitespace-pre-wrap; 7 | } 8 | 9 | .ant-notification-notice-description { 10 | @apply h-20 overflow-auto; 11 | } 12 | 13 | .ant-tabs-nav { 14 | @apply important-mb-0; 15 | } 16 | 17 | .ant-tabs-content, 18 | .ant-tabs-tabpane { 19 | @apply h-full; 20 | } 21 | 22 | .selectable, 23 | .ant-tabs-nav-wrap { 24 | @apply select-none; 25 | } 26 | 27 | .selection-area { 28 | @apply bg-[rgba(46,115,252,0.5)]; 29 | } -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import pluginJs from '@eslint/js' 2 | import tseslint from 'typescript-eslint' 3 | import pluginReact from 'eslint-plugin-react' 4 | import pluginReactHooks from 'eslint-plugin-react-hooks' 5 | 6 | /** @type {import('eslint').Linter.Config[]} */ 7 | export default [ 8 | { files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'] }, 9 | pluginJs.configs.recommended, 10 | ...tseslint.configs.recommended, 11 | pluginReact.configs.flat.recommended, 12 | { 13 | plugins: { 14 | 'react-hooks': pluginReactHooks, 15 | }, 16 | rules: { 17 | 'react/react-in-jsx-scope': 'off', 18 | ...pluginReactHooks.configs.recommended.rules, 19 | }, 20 | }, 21 | ] 22 | -------------------------------------------------------------------------------- /uno.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineConfig, 3 | presetAttributify, 4 | presetIcons, 5 | presetTypography, 6 | presetUno, 7 | presetWebFonts, 8 | transformerDirectives, 9 | transformerVariantGroup, 10 | } from 'unocss' 11 | 12 | export default defineConfig({ 13 | shortcuts: [ 14 | // ... 15 | ], 16 | theme: { 17 | colors: { 18 | // ... 19 | }, 20 | }, 21 | presets: [ 22 | presetUno(), 23 | presetAttributify(), 24 | presetIcons(), 25 | presetTypography(), 26 | presetWebFonts({ 27 | fonts: { 28 | // ... 29 | }, 30 | }), 31 | ], 32 | transformers: [transformerDirectives(), transformerVariantGroup()], 33 | }) 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /src-tauri/src/errors.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use specta::Type; 3 | 4 | use crate::extensions::AnyhowErrorToStringChain; 5 | 6 | pub type CommandResult = Result; 7 | 8 | #[derive(Debug, Type, Serialize)] 9 | pub struct CommandError { 10 | pub err_title: String, 11 | pub err_message: String, 12 | } 13 | 14 | impl CommandError { 15 | pub fn from(err_title: &str, err: E) -> Self 16 | where 17 | E: Into, 18 | { 19 | let string_chain = err.into().to_string_chain(); 20 | tracing::error!(err_title, message = string_chain); 21 | Self { 22 | err_title: err_title.to_string(), 23 | err_message: string_chain, 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 功能请求 2 | description: 想要请求添加某个功能 3 | labels: [enhancement] 4 | title: "[功能请求] 修改我!未修改标题的issue将被自动关闭" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | 为了使我更好地帮助你,请提供以下信息。以及上方的标题 10 | - type: textarea 11 | id: reason 12 | attributes: 13 | label: 原因 14 | description: 为什么想要这个功能 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: desc 19 | attributes: 20 | label: 功能简述 21 | description: 想要个怎样的功能 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: logic 26 | attributes: 27 | label: 功能逻辑 28 | description: 如何互交、如何使用等 29 | validations: 30 | required: true 31 | - type: textarea 32 | id: ref 33 | attributes: 34 | label: 实现参考 35 | description: 该功能可能的实现方式,或者其他已经实现该功能的应用等 -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import './styles/global.css' 3 | import { commands, Config } from './bindings.ts' 4 | import { App as AntdApp, ConfigProvider } from 'antd' 5 | import zhCN from 'antd/es/locale/zh_CN' 6 | import AppContent from './AppContent.tsx' 7 | 8 | function App() { 9 | const [config, setConfig] = useState() 10 | useEffect(() => { 11 | // 屏蔽浏览器右键菜单 12 | document.oncontextmenu = (event) => { 13 | event.preventDefault() 14 | } 15 | // 获取配置 16 | commands.getConfig().then((result) => { 17 | setConfig(result) 18 | }) 19 | }, []) 20 | 21 | return <>{config !== undefined && } 22 | } 23 | 24 | // eslint-disable-next-line react/display-name 25 | export default () => ( 26 | 27 | 28 | 29 | 30 | 31 | ) 32 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import UnoCSS from 'unocss/vite' 4 | 5 | // @ts-expect-error process is a nodejs global 6 | const host = process.env.TAURI_DEV_HOST 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig(async () => ({ 10 | plugins: [react(), UnoCSS()], 11 | 12 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 13 | // 14 | // 1. prevent vite from obscuring rust errors 15 | clearScreen: false, 16 | // 2. tauri expects a fixed port, fail if that port is not available 17 | server: { 18 | port: 5005, 19 | strictPort: true, 20 | host: host || false, 21 | hmr: host 22 | ? { 23 | protocol: 'ws', 24 | host, 25 | port: 1421, 26 | } 27 | : undefined, 28 | watch: { 29 | // 3. tell vite to ignore watching `src-tauri` 30 | ignored: ['**/src-tauri/**'], 31 | }, 32 | }, 33 | })) 34 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.tauri.app/config/2", 3 | "productName": "manhuagui-downloader", 4 | "version": "0.4.2", 5 | "identifier": "com.lanyeeee.manhuagui-downloader", 6 | "build": { 7 | "beforeDevCommand": "pnpm dev", 8 | "devUrl": "http://localhost:5005", 9 | "beforeBuildCommand": "pnpm build", 10 | "frontendDist": "../dist" 11 | }, 12 | "app": { 13 | "windows": [ 14 | { 15 | "title": "漫画柜下载器", 16 | "width": 800, 17 | "height": 600 18 | } 19 | ], 20 | "security": { 21 | "csp": null 22 | } 23 | }, 24 | "bundle": { 25 | "active": true, 26 | "targets": "all", 27 | "licenseFile": "../LICENSE", 28 | "icon": [ 29 | "icons/32x32.png", 30 | "icons/128x128.png", 31 | "icons/128x128@2x.png", 32 | "icons/icon.icns", 33 | "icons/icon.ico" 34 | ], 35 | "windows": { 36 | "nsis": { 37 | "installMode": "perMachine", 38 | "languages": [ 39 | "SimpChinese" 40 | ] 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "manhuagui-downloader", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "tauri": "tauri" 11 | }, 12 | "dependencies": { 13 | "@ant-design/icons": "^5.6.0", 14 | "@tauri-apps/api": "^2", 15 | "@tauri-apps/plugin-dialog": "~2", 16 | "@tauri-apps/plugin-opener": "^2", 17 | "@viselect/react": "^3.7.0", 18 | "antd": "^5.23.0", 19 | "prettier": "^3.4.2", 20 | "react": "^18.3.1", 21 | "react-dom": "^18.3.1", 22 | "unocss": "^0.65.4" 23 | }, 24 | "devDependencies": { 25 | "@eslint/js": "^9.17.0", 26 | "@tauri-apps/cli": "^2", 27 | "@types/react": "^18.3.1", 28 | "@types/react-dom": "^18.3.1", 29 | "@vitejs/plugin-react": "^4.3.4", 30 | "eslint": "^9.17.0", 31 | "eslint-plugin-react": "^7.37.3", 32 | "eslint-plugin-react-hooks": "^5.1.0", 33 | "globals": "^15.14.0", 34 | "typescript": "~5.6.2", 35 | "typescript-eslint": "^8.19.1", 36 | "vite": "^6.0.3" 37 | }, 38 | "packageManager": "pnpm@9.5.0" 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 lanyeeee (https://github.com/lanyeeee) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 反馈 Bug 2 | description: 反馈遇到的问题 3 | labels: [bug] 4 | title: "[Bug] 修改我!未修改标题的issue将被自动关闭" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | 为了使我更好地帮助你,请提供以下信息。以及修改上方的标题 10 | - type: textarea 11 | id: desc 12 | attributes: 13 | label: 问题描述 14 | description: 发生了什么情况?复现条件(哪部漫画、哪个章节)是什么?问题能稳定触发吗? 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: expected 19 | attributes: 20 | label: 预期行为 21 | description: 正常情况下应该发生什么 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: actual 26 | attributes: 27 | label: 实际行为 28 | description: 实际上发生了什么 29 | validations: 30 | required: true 31 | - type: textarea 32 | id: media 33 | attributes: 34 | label: 截图或录屏 35 | description: 问题复现时候的截图或录屏 36 | placeholder: 点击文本框下面小长条可以上传文件 37 | - type: input 38 | id: version 39 | attributes: 40 | label: 工具版本号 41 | placeholder: v0.1 42 | validations: 43 | required: true 44 | - type: textarea 45 | id: other 46 | attributes: 47 | label: 其他 48 | description: 其他要补充的内容 49 | placeholder: 其他要补充的内容 50 | validations: 51 | required: false -------------------------------------------------------------------------------- /src-tauri/src/types/user_profile.rs: -------------------------------------------------------------------------------- 1 | use crate::extensions::ToAnyhow; 2 | use anyhow::Context; 3 | use scraper::{Html, Selector}; 4 | use serde::{Deserialize, Serialize}; 5 | use specta::Type; 6 | 7 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 8 | #[serde(rename_all = "camelCase")] 9 | pub struct UserProfile { 10 | pub username: String, 11 | pub avatar: String, 12 | } 13 | impl UserProfile { 14 | pub fn from_html(html: &str) -> anyhow::Result { 15 | let document = Html::parse_document(html); 16 | // 获取 `.avatar-box` 的 `
` 17 | let avatar_box = document 18 | .select(&Selector::parse(".avatar-box").to_anyhow()?) 19 | .next() 20 | .context("没有找到`.avatar-box`的
")?; 21 | 22 | let username = avatar_box 23 | .select(&Selector::parse("h3").to_anyhow()?) 24 | .next() 25 | .map(|h3| h3.text().collect::().trim().to_string()) 26 | .context("没有找到用户名相关的

")?; 27 | 28 | let avatar = avatar_box 29 | .select(&Selector::parse(".img-box img").to_anyhow()?) 30 | .next() 31 | .and_then(|img| img.value().attr("src")) 32 | .map(|src| format!("https:{src}")) 33 | .context("没有找到头像相关的")?; 34 | 35 | let user_profile = UserProfile { username, avatar }; 36 | Ok(user_profile) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/CompletedProgresses.tsx: -------------------------------------------------------------------------------- 1 | import { ProgressData } from '../types.ts' 2 | import { useMemo } from 'react' 3 | 4 | interface Props { 5 | progresses: Map 6 | } 7 | 8 | function CompletedProgresses({ progresses }: Props) { 9 | const completedProgresses = useMemo<[number, ProgressData][]>( 10 | () => 11 | Array.from(progresses.entries()) 12 | .filter(([, { state }]) => state === 'Completed') 13 | .sort((a, b) => { 14 | return b[1].totalImgCount - a[1].totalImgCount 15 | }), 16 | [progresses], 17 | ) 18 | 19 | return ( 20 |
21 | {completedProgresses.map(([chapterId, { chapterInfo }]) => ( 22 |
23 | 24 | {chapterInfo.comicTitle} 25 | 26 | 27 | {chapterInfo.groupName} 28 | 29 | 30 | {chapterInfo.chapterTitle} 31 | 32 |
33 | ))} 34 |
35 | ) 36 | } 37 | 38 | export default CompletedProgresses 39 | -------------------------------------------------------------------------------- /.github/workflows/close-default-title-issue.yml: -------------------------------------------------------------------------------- 1 | name: Close Issues with Default TitleAdd commentMore actions 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | 7 | permissions: 8 | issues: write 9 | 10 | jobs: 11 | check-issue-title: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Check issue title 15 | uses: actions/github-script@v6 16 | with: 17 | github-token: ${{ secrets.GITHUB_TOKEN }} 18 | script: | 19 | const issue = context.payload.issue; 20 | const defaultTitle = '修改我!'; 21 | 22 | if (issue.title.includes(defaultTitle)) { 23 | await github.rest.issues.createComment({ 24 | owner: context.repo.owner, 25 | repo: context.repo.repo, 26 | issue_number: issue.number, 27 | body: '检测到此issue的标题未修改\n\n为了更好地管理项目,帮助维护者快速理解问题,方便其他用户检索,请您在编写issue时务必**修改标题**\n\n此issue将被自动关闭。请修改标题后重新提交一个issue。感谢您的理解与合作!' 28 | }); 29 | 30 | await github.rest.issues.update({ 31 | owner: context.repo.owner, 32 | repo: context.repo.repo, 33 | issue_number: issue.number, 34 | state: 'closed', 35 | state_reason: 'not_planned' 36 | }); 37 | 38 | await github.rest.issues.addLabels({ 39 | owner: context.repo.owner, 40 | repo: context.repo.repo, 41 | issue_number: issue.number, 42 | labels: ['invalid'] 43 | }); 44 | } -------------------------------------------------------------------------------- /src-tauri/src/extensions.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use reqwest::Response; 3 | use reqwest_middleware::RequestBuilder; 4 | use scraper::error::SelectorErrorKind; 5 | 6 | pub trait AnyhowErrorToStringChain { 7 | /// 将 `anyhow::Error` 转换为chain格式 8 | /// # Example 9 | /// 0: error message 10 | /// 1: error message 11 | /// 2: error message 12 | fn to_string_chain(&self) -> String; 13 | } 14 | 15 | impl AnyhowErrorToStringChain for anyhow::Error { 16 | fn to_string_chain(&self) -> String { 17 | use std::fmt::Write; 18 | self.chain() 19 | .enumerate() 20 | .fold(String::new(), |mut output, (i, e)| { 21 | let _ = writeln!(output, "{i}: {e}"); 22 | output 23 | }) 24 | } 25 | } 26 | 27 | pub trait ToAnyhow { 28 | fn to_anyhow(self) -> anyhow::Result; 29 | } 30 | 31 | impl ToAnyhow for Result> { 32 | fn to_anyhow(self) -> anyhow::Result { 33 | self.map_err(|e| anyhow!(e.to_string())) 34 | } 35 | } 36 | 37 | pub trait SendWithTimeoutMsg { 38 | /// 发送请求并处理超时错误 39 | /// 40 | /// - 如果遇到超时错误,返回带有用户友好信息的错误 41 | /// - 否则返回原始错误 42 | async fn send_with_timeout_msg(self) -> anyhow::Result; 43 | } 44 | 45 | impl SendWithTimeoutMsg for RequestBuilder { 46 | async fn send_with_timeout_msg(self) -> anyhow::Result { 47 | self.send().await.map_err(|e| { 48 | if e.is_timeout() || e.is_middleware() { 49 | anyhow::Error::from(e).context( 50 | "网络连接超时,可能是未使用代理或IP被封,请使用代理或切换代理线路后重试", 51 | ) 52 | } else { 53 | anyhow::Error::from(e) 54 | } 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/LoginDialog.tsx: -------------------------------------------------------------------------------- 1 | import { App as AntdApp, Input, Modal } from 'antd' 2 | import { commands, Config } from '../bindings.ts' 3 | import { KeyboardEvent, useState } from 'react' 4 | 5 | interface Props { 6 | loginDialogShowing: boolean 7 | setLoginDialogShowing: (showing: boolean) => void 8 | config: Config 9 | setConfig: (value: Config | undefined | ((prev: Config | undefined) => Config | undefined)) => void 10 | } 11 | 12 | function LoginDialog({ loginDialogShowing, setLoginDialogShowing, config, setConfig }: Props) { 13 | const { message } = AntdApp.useApp() 14 | 15 | const [username, setUsername] = useState('') 16 | const [password, setPassword] = useState('') 17 | 18 | async function login() { 19 | if (username === '') { 20 | message.error('请输入用户名') 21 | return 22 | } 23 | 24 | if (password === '') { 25 | message.error('请输入密码') 26 | return 27 | } 28 | 29 | const key = 'login' 30 | message.loading({ content: '登录中...', key, duration: 0 }) 31 | const result = await commands.login(username, password) 32 | message.destroy(key) 33 | if (result.status === 'error') { 34 | console.error(result.error) 35 | return 36 | } 37 | 38 | message.success('登录成功') 39 | setConfig({ ...config, cookie: result.data }) 40 | setLoginDialogShowing(false) 41 | } 42 | 43 | async function handleKeyDown(e: KeyboardEvent) { 44 | if (e.key === 'Enter') { 45 | await login() 46 | } 47 | } 48 | 49 | return ( 50 | setLoginDialogShowing(false)} 55 | cancelButtonProps={{ style: { display: 'none' } }} 56 | okText="登录"> 57 |
58 | setUsername(e.target.value)} /> 59 | setPassword(e.target.value)} /> 60 |
61 |
62 | ) 63 | } 64 | 65 | export default LoginDialog 66 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "manhuagui-downloader" 3 | version = "0.1.0" 4 | description = "A Tauri App" 5 | authors = ["you"] 6 | edition = "2021" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [lib] 11 | # The `_lib` suffix may seem redundant but it is necessary 12 | # to make the lib name unique and wouldn't conflict with the bin name. 13 | # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 14 | name = "manhuagui_downloader_lib" 15 | crate-type = ["staticlib", "cdylib", "rlib"] 16 | 17 | [build-dependencies] 18 | tauri-build = { version = "2", features = [] } 19 | 20 | [dependencies] 21 | tauri = { version = "2", features = [] } 22 | tauri-plugin-opener = "2" 23 | tauri-plugin-dialog = "2" 24 | 25 | serde = { version = "1", features = ["derive"] } 26 | serde_json = { version = "1" } 27 | yaserde = { version = "0.11.2", features = ["yaserde_derive"] } 28 | 29 | specta = { version = "2.0.0-rc", features = ["serde_json"] } 30 | specta-util = { version = "0.0.7", features = ["export"] } 31 | tauri-specta = { version = "2.0.0-rc", features = ["derive", "typescript"] } 32 | specta-typescript = { version = "0.0.7" } 33 | 34 | reqwest = { version = "0.12.9", default-features = false } 35 | reqwest-retry = { version = "0.7.0" } 36 | reqwest-middleware = { version = "0.4.0" } 37 | 38 | anyhow = { version = "1.0.91" } 39 | parking_lot = { version = "0.12.3", features = ["send_guard"] } 40 | scraper = { version = "0.22.0" } 41 | lz-str = { version = "0.2.1" } 42 | regex = { version = "1.11.1" } 43 | tokio = { version = "1.43.0", features = ["full"] } 44 | bytes = { version = "1.8.0" } 45 | zip = { version = "2.2.0", default-features = false } 46 | rayon = { version = "1.10.0" } 47 | uuid = { version = "1.11.0" } 48 | lopdf = { git = "https://github.com/lanyeeee/lopdf", features = ["embed_image_jpeg"] } 49 | image = { version = "0.25.2", default-features = false, features = ["jpeg"] } 50 | tracing = { version = "0.1.41" } 51 | tracing-subscriber = { version = "0.3.19", features = ["json", "time", "local-time"] } 52 | tracing-appender = { version = "0.2.3" } 53 | notify = { version = "8.0.0" } 54 | 55 | [profile.release] 56 | strip = true 57 | lto = true 58 | codegen-units = 1 59 | panic = "abort" 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # 📚 漫画柜下载器 6 | 7 | 一个用于 manhuagui.com 看漫画 漫画柜 的下载器,带图形界面,支持下载隐藏内容、支持导出cbz和pdf,免安装版(portable)解压后可以直接运行。图形界面基于[Tauri](https://v2.tauri.app/start/) 8 | 9 | 🔽 在[Release页面](https://github.com/lanyeeee/manhuagui-downloader/releases)可以直接下载 10 | 11 | **如果本项目对你有帮助,欢迎点个 Star⭐ 支持!你的支持是我持续更新维护的动力🙏** 12 | 13 | # 🖥️ 图形界面 14 | 15 | ![](https://github.com/user-attachments/assets/fff56df2-0067-4374-a6cd-90c7f63309df) 16 | 17 | # 📖 使用方法 18 | 19 | #### 🚀 不使用收藏夹 20 | 21 | 1. **不需要登录**,直接使用`漫画搜索`,选择要下载的漫画,点击后进入`章节详情` 22 | 2. 在`章节详情`勾选要下载的章节,点击`下载勾选章节`按钮开始下载 23 | 3. 下载完成后点击`下载目录`右边的`打开目录`按钮查看结果 24 | 25 | #### ⭐ 使用收藏夹 26 | 27 | 1. 点击`账号登录`按钮完成登录 28 | 2. 使用`漫画收藏`,选择要下载的漫画,点击后进入`章节详情` 29 | 3. 在`章节详情`勾选要下载的章节,点击`下载勾选章节`按钮开始下载 30 | 4. 下载完成后点击`下载目录`右边的`打开目录`按钮查看结果 31 | 32 | 📹 下面的视频是完整使用流程 33 | 34 | https://github.com/user-attachments/assets/2e0f86c6-381d-437a-8815-5cf3c2a71c60 35 | 36 | # ⚠️ 关于被杀毒软件误判为病毒 37 | 38 | 对于个人开发的项目来说,这个问题几乎是无解的(~~需要购买数字证书给软件签名,甚至给杀毒软件交保护费~~) 39 | 我能想到的解决办法只有: 40 | 41 | 1. 根据下面的**如何构建(build)**,自行编译 42 | 2. 希望你相信我的承诺,我承诺你在[Release页面](https://github.com/lanyeeee/manhuagui-downloader/releases)下载到的所有东西都是安全的 43 | 44 | # 🛠️ 如何构建(build) 45 | 46 | 构建非常简单,一共就3条命令 47 | ~~前提是你已经安装了Rust、Node、pnpm~~ 48 | 49 | #### 📋 前提 50 | 51 | - [Rust](https://www.rust-lang.org/tools/install) 52 | - [Node](https://nodejs.org/en) 53 | - [pnpm](https://pnpm.io/installation) 54 | 55 | #### 📝 步骤 56 | 57 | #### 1. 克隆本仓库 58 | 59 | ``` 60 | git clone https://github.com/lanyeeee/manhuagui-downloader.git 61 | ``` 62 | 63 | #### 2.安装依赖 64 | 65 | ``` 66 | cd manhuagui-downloader 67 | pnpm install 68 | ``` 69 | 70 | #### 3.构建(build) 71 | 72 | ``` 73 | pnpm tauri build 74 | ``` 75 | 76 | # 🤝 提交PR 77 | 78 | **PR请提交至`develop`分支** 79 | 80 | **如果想新加一个功能,请先开个`issue`或`discussion`讨论一下,避免无效工作** 81 | 82 | 其他情况的PR欢迎直接提交,比如: 83 | 84 | 1. 🔧 对原有功能的改进 85 | 2. 🐛 修复BUG 86 | 3. ⚡ 使用更轻量的库实现原有功能 87 | 4. 📝 修订文档 88 | 5. ⬆️ 升级、更新依赖的PR也会被接受 89 | 90 | # ⚠️ 免责声明 91 | 92 | - 本工具仅作学习、研究、交流使用,使用本工具的用户应自行承担风险 93 | - 作者不对使用本工具导致的任何损失、法律纠纷或其他后果负责 94 | - 作者不对用户使用本工具的行为负责,包括但不限于用户违反法律或任何第三方权益的行为 95 | 96 | # 💬 其他 97 | 98 | 任何使用中遇到的问题、任何希望添加的功能,都欢迎提交issue或开discussion交流,我会尽力解决 99 | 100 | -------------------------------------------------------------------------------- /src/panes/FavoritePane.tsx: -------------------------------------------------------------------------------- 1 | import { Comic, commands, GetFavoriteResult, UserProfile } from '../bindings.ts' 2 | import { CurrentTabName } from '../types.ts' 3 | import { Pagination } from 'antd' 4 | import { useCallback, useEffect, useState } from 'react' 5 | import ComicCard from '../components/ComicCard.tsx' 6 | 7 | interface Props { 8 | userProfile: UserProfile | undefined 9 | setPickedComic: (comic: Comic | undefined) => void 10 | setCurrentTabName: (currentTabName: CurrentTabName) => void 11 | } 12 | 13 | function FavoritePane({ userProfile, setPickedComic, setCurrentTabName }: Props) { 14 | const [favoritePageNum, setFavoritePageNum] = useState(1) 15 | const [getFavoriteResult, setGetFavoriteResult] = useState() 16 | 17 | const getFavourite = useCallback(async (pageNum: number) => { 18 | setFavoritePageNum(pageNum) 19 | const result = await commands.getFavorite(pageNum) 20 | if (result.status === 'error') { 21 | console.error(result.error) 22 | return 23 | } 24 | console.log('getFavourite') 25 | setGetFavoriteResult(result.data) 26 | console.log(result.data) 27 | }, []) 28 | 29 | useEffect(() => { 30 | getFavourite(1).then() 31 | }, [userProfile, getFavourite]) 32 | 33 | return ( 34 |
35 | {getFavoriteResult && ( 36 |
37 |
38 | {getFavoriteResult.comics.map((comic) => ( 39 | 49 | ))} 50 |
51 | getFavourite(pageNum)} 58 | /> 59 |
60 | )} 61 |
62 | ) 63 | } 64 | 65 | export default FavoritePane 66 | -------------------------------------------------------------------------------- /src/components/ComicCard.tsx: -------------------------------------------------------------------------------- 1 | import { Comic, commands } from '../bindings.ts' 2 | import { CurrentTabName } from '../types.ts' 3 | import { Card } from 'antd' 4 | 5 | interface Props { 6 | comicId: number 7 | comicTitle: string 8 | comicCover: string 9 | comicSubtitle?: string | null 10 | comicAuthors?: string[] 11 | comicGenres?: string[] 12 | comicLastUpdateTime?: string 13 | comicLastReadTime?: string 14 | setPickedComic: (comic: Comic | undefined) => void 15 | setCurrentTabName: (currentTabName: CurrentTabName) => void 16 | } 17 | 18 | function ComicCard({ 19 | comicId, 20 | comicTitle, 21 | comicCover, 22 | comicSubtitle, 23 | comicAuthors, 24 | comicGenres, 25 | comicLastUpdateTime, 26 | comicLastReadTime, 27 | setPickedComic, 28 | setCurrentTabName, 29 | }: Props) { 30 | async function pickComic(id: number) { 31 | const result = await commands.getComic(id) 32 | if (result.status === 'error') { 33 | console.error(result.error) 34 | return 35 | } 36 | console.log(result.data) 37 | setPickedComic(result.data) 38 | setCurrentTabName('chapter') 39 | } 40 | 41 | return ( 42 | 43 |
44 | pickComic(comicId)} 49 | /> 50 |
51 | pickComic(comicId)}> 54 | {comicTitle} 55 | {comicSubtitle && `(${comicSubtitle})`} 56 | 57 | {comicAuthors !== undefined && 作者:{comicAuthors.join(', ')}} 58 | {comicGenres !== undefined && 类型:{comicGenres.join(' ')}} 59 | {comicLastUpdateTime !== undefined && 上次更新:{comicLastUpdateTime}} 60 | {comicLastReadTime !== undefined && 上次阅读:{comicLastReadTime}} 61 |
62 |
63 |
64 | ) 65 | } 66 | 67 | export default ComicCard 68 | -------------------------------------------------------------------------------- /public/tauri.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src-tauri/src/types/comic_info.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use specta::Type; 3 | use yaserde::{YaDeserialize, YaSerialize}; 4 | 5 | use super::ChapterInfo; 6 | 7 | /// 主要参考了[Kavita的文档](https://wiki.kavitareader.com/guides/metadata/comics/) 8 | #[derive( 9 | Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type, YaSerialize, YaDeserialize, 10 | )] 11 | #[serde(rename_all = "camelCase")] 12 | pub struct ComicInfo { 13 | #[yaserde(rename = "Manga")] 14 | pub manga: String, 15 | /// 漫画名 16 | #[yaserde(rename = "Series")] 17 | pub series: String, 18 | /// 出版社 19 | #[yaserde(rename = "Publisher")] 20 | pub publisher: String, 21 | /// 作者 22 | #[yaserde(rename = "Writer")] 23 | pub writer: String, 24 | /// 漫画类型 25 | #[yaserde(rename = "Genre")] 26 | pub genre: String, 27 | #[yaserde(rename = "Summary")] 28 | pub summary: String, 29 | /// 章节名 30 | #[yaserde(rename = "Title")] 31 | pub title: String, 32 | /// 普通章节序号 33 | #[yaserde(rename = "Number")] 34 | pub number: Option, 35 | /// 卷序号 36 | #[yaserde(rename = "Volume")] 37 | pub volume: Option, 38 | /// 如果值为Special,则该章节会被Kavita视为特刊 39 | #[yaserde(rename = "Format")] 40 | pub format: Option, 41 | /// 该章节的有多少页 42 | #[yaserde(rename = "PageCount")] 43 | pub page_count: i64, 44 | /// 章节总数 45 | /// - `0` => Ongoing 46 | /// - `非零`且与`Number`或`Volume`一致 => Completed 47 | /// - `其他非零值` => Ended 48 | #[yaserde(rename = "Count")] 49 | pub count: i64, 50 | } 51 | impl ComicInfo { 52 | #[allow(clippy::cast_possible_wrap)] 53 | pub fn from( 54 | chapter_info: ChapterInfo, 55 | authors: &[String], 56 | genre: &[String], 57 | intro: String, 58 | ) -> ComicInfo { 59 | let order = Some(chapter_info.order.to_string()); 60 | let (number, volume, format) = match chapter_info.group_name.as_str() { 61 | "单话" => (order, None, None), 62 | "单行本" => (None, order, None), 63 | _ => (order, None, Some("Special".to_string())), 64 | }; 65 | 66 | let count = match chapter_info.comic_status.as_ref() { 67 | "连载中" => 0, 68 | _ => chapter_info.group_size, 69 | }; 70 | 71 | ComicInfo { 72 | manga: "Yes".to_string(), 73 | series: chapter_info.comic_title, 74 | publisher: "漫画柜".to_string(), 75 | writer: authors.join(", "), 76 | genre: genre.join(", "), 77 | summary: intro, 78 | title: chapter_info.chapter_title, 79 | number, 80 | volume, 81 | format, 82 | page_count: chapter_info.chapter_size, 83 | count, 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/components/AboutDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Modal, Typography } from 'antd' 2 | import { useEffect, useState } from 'react' 3 | import { getVersion } from '@tauri-apps/api/app' 4 | import icon from '../../src-tauri/icons/128x128.png' 5 | 6 | interface Props { 7 | aboutDialogShowing: boolean 8 | setAboutDialogShowing: (showing: boolean) => void 9 | } 10 | 11 | export function AboutDialog({ aboutDialogShowing, setAboutDialogShowing }: Props) { 12 | const [version, setVersion] = useState('') 13 | 14 | useEffect(() => { 15 | getVersion().then(setVersion) 16 | }, []) 17 | 18 | return ( 19 | setAboutDialogShowing(false)} footer={null}> 20 |
21 | icon 22 |
23 |
24 | 如果本项目对你有帮助,欢迎来 25 | 26 | GitHub 27 | 28 | 点个Star⭐支持! 29 |
30 |
你的支持是我持续更新维护的动力🙏
31 |
32 |
33 |
34 | 软件版本 35 |
v{version}
36 |
37 |
38 | 开源地址 39 | 40 | GitHub 41 | 42 |
43 |
44 | 问题反馈 45 | 46 | GitHub Issues 47 | 48 |
49 |
50 |
51 |
52 | Copyright © 2025{' '} 53 | 54 | lanyeeee 55 | 56 |
57 |
58 | Released under{' '} 59 | 60 | MIT License 61 | 62 |
63 |
64 |
65 |
66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /src-tauri/src/events.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use specta::Type; 5 | use tauri_specta::Event; 6 | 7 | use crate::{ 8 | download_manager::DownloadTaskState, 9 | types::{ChapterInfo, LogLevel}, 10 | }; 11 | 12 | #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] 13 | #[serde(tag = "event", content = "data")] 14 | pub enum DownloadEvent { 15 | #[serde(rename_all = "camelCase")] 16 | Speed { speed: String }, 17 | 18 | #[serde(rename_all = "camelCase")] 19 | Sleeping { chapter_id: i64, remaining_sec: u64 }, 20 | } 21 | 22 | #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] 23 | #[serde(tag = "event", content = "data")] 24 | pub enum ExportCbzEvent { 25 | #[serde(rename_all = "camelCase")] 26 | Start { 27 | uuid: String, 28 | comic_title: String, 29 | total: u32, 30 | }, 31 | 32 | #[serde(rename_all = "camelCase")] 33 | Progress { uuid: String, current: u32 }, 34 | 35 | #[serde(rename_all = "camelCase")] 36 | End { uuid: String }, 37 | } 38 | 39 | #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] 40 | #[serde(tag = "event", content = "data")] 41 | pub enum ExportPdfEvent { 42 | #[serde(rename_all = "camelCase")] 43 | CreateStart { 44 | uuid: String, 45 | comic_title: String, 46 | total: u32, 47 | }, 48 | 49 | #[serde(rename_all = "camelCase")] 50 | CreateProgress { uuid: String, current: u32 }, 51 | 52 | #[serde(rename_all = "camelCase")] 53 | CreateEnd { uuid: String }, 54 | 55 | #[serde(rename_all = "camelCase")] 56 | MergeStart { 57 | uuid: String, 58 | comic_title: String, 59 | total: u32, 60 | }, 61 | 62 | #[serde(rename_all = "camelCase")] 63 | MergeProgress { uuid: String, current: u32 }, 64 | 65 | #[serde(rename_all = "camelCase")] 66 | MergeEnd { uuid: String }, 67 | } 68 | 69 | #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] 70 | #[serde(tag = "event", content = "data")] 71 | pub enum UpdateDownloadedComicsEvent { 72 | #[serde(rename_all = "camelCase")] 73 | GettingComics { total: i64 }, 74 | 75 | #[serde(rename_all = "camelCase")] 76 | ComicGot { current: i64, total: i64 }, 77 | 78 | #[serde(rename_all = "camelCase")] 79 | DownloadTaskCreated, 80 | } 81 | 82 | #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] 83 | #[serde(rename_all = "camelCase")] 84 | pub struct LogEvent { 85 | pub timestamp: String, 86 | pub level: LogLevel, 87 | pub fields: HashMap, 88 | pub target: String, 89 | pub filename: String, 90 | #[serde(rename = "line_number")] 91 | pub line_number: i64, 92 | } 93 | 94 | #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] 95 | #[serde(rename_all = "camelCase")] 96 | pub struct DownloadTaskEvent { 97 | pub state: DownloadTaskState, 98 | pub chapter_info: ChapterInfo, 99 | pub downloaded_img_count: u32, 100 | pub total_img_count: u32, 101 | } 102 | -------------------------------------------------------------------------------- /src/components/DownloadedComicCard.tsx: -------------------------------------------------------------------------------- 1 | import { Comic, commands } from '../bindings.ts' 2 | import { CurrentTabName } from '../types.ts' 3 | import { Button, Card } from 'antd' 4 | import { useMemo } from 'react' 5 | 6 | interface GroupInfo { 7 | name: string 8 | downloaded: number 9 | total: number 10 | } 11 | 12 | interface Props { 13 | comic: Comic 14 | setPickedComic: (comic: Comic | undefined) => void 15 | setCurrentTabName: (currentTabName: CurrentTabName) => void 16 | } 17 | 18 | function DownloadedComicCard({ comic, setPickedComic, setCurrentTabName }: Props) { 19 | const groupInfos = useMemo(() => { 20 | const groups = comic.groups 21 | 22 | const infos = Object.values(groups).map((chapterInfos) => { 23 | const groupInfo: GroupInfo = { 24 | name: chapterInfos[0].groupName, 25 | downloaded: chapterInfos.filter((chapterInfo) => chapterInfo.isDownloaded).length, 26 | total: chapterInfos.length, 27 | } 28 | return groupInfo 29 | }) 30 | 31 | infos.sort((a, b) => b.total - a.total) 32 | return infos 33 | }, [comic.groups]) 34 | 35 | function pickComic() { 36 | setPickedComic(comic) 37 | setCurrentTabName('chapter') 38 | } 39 | 40 | async function exportCbz() { 41 | const result = await commands.exportCbz(comic) 42 | if (result.status === 'error') { 43 | console.error(result.error) 44 | return 45 | } 46 | } 47 | 48 | async function exportPdf() { 49 | const result = await commands.exportPdf(comic) 50 | if (result.status === 'error') { 51 | console.error(result.error) 52 | return 53 | } 54 | } 55 | 56 | return ( 57 | 58 |
59 | pickComic()} 64 | /> 65 |
66 | pickComic()}> 69 | {comic.title} 70 | {comic.subtitle && `(${comic.subtitle})`} 71 | 72 | {comic.authors !== undefined && 作者:{comic.authors.join(', ')}} 73 | {comic.genres !== undefined && 类型:{comic.genres.join(' ')}} 74 | {groupInfos.map((groupInfo) => ( 75 | 76 | {groupInfo.name}:{groupInfo.downloaded}/{groupInfo.total} 77 | 78 | ))} 79 |
80 | 83 | 86 |
87 |
88 |
89 |
90 | ) 91 | } 92 | 93 | export default DownloadedComicCard 94 | -------------------------------------------------------------------------------- /src-tauri/src/config.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use specta::Type; 5 | use tauri::{AppHandle, Manager}; 6 | 7 | #[derive(Debug, Clone, Serialize, Deserialize, Type)] 8 | #[serde(rename_all = "camelCase")] 9 | pub struct Config { 10 | pub cookie: String, 11 | pub download_dir: PathBuf, 12 | pub export_dir: PathBuf, 13 | pub enable_file_logger: bool, 14 | pub chapter_concurrency: usize, 15 | pub chapter_download_interval_sec: u64, 16 | pub img_concurrency: usize, 17 | pub img_download_interval_sec: u64, 18 | pub update_get_comic_interval_sec: u64, 19 | } 20 | 21 | impl Config { 22 | pub fn new(app: &AppHandle) -> anyhow::Result { 23 | let app_data_dir = app.path().app_data_dir()?; 24 | let config_path = app_data_dir.join("config.json"); 25 | 26 | let config = if config_path.exists() { 27 | let config_string = std::fs::read_to_string(config_path)?; 28 | match serde_json::from_str(&config_string) { 29 | // 如果能够直接解析为Config,则直接返回 30 | Ok(config) => config, 31 | // 否则,将默认配置与文件中已有的配置合并 32 | // 以免新版本添加了新的配置项,用户升级到新版本后,所有配置项都被重置 33 | Err(_) => Config::merge_config(&config_string, &app_data_dir), 34 | } 35 | } else { 36 | Config::default(&app_data_dir) 37 | }; 38 | config.save(app)?; 39 | Ok(config) 40 | } 41 | 42 | pub fn save(&self, app: &AppHandle) -> anyhow::Result<()> { 43 | let app_data_dir = app.path().app_data_dir()?; 44 | let config_path = app_data_dir.join("config.json"); 45 | let config_string = serde_json::to_string_pretty(self)?; 46 | std::fs::write(config_path, config_string)?; 47 | Ok(()) 48 | } 49 | 50 | fn merge_config(config_string: &str, app_data_dir: &Path) -> Config { 51 | let Ok(mut json_value) = serde_json::from_str::(config_string) else { 52 | return Config::default(app_data_dir); 53 | }; 54 | let serde_json::Value::Object(ref mut map) = json_value else { 55 | return Config::default(app_data_dir); 56 | }; 57 | let Ok(default_config_value) = serde_json::to_value(Config::default(app_data_dir)) else { 58 | return Config::default(app_data_dir); 59 | }; 60 | let serde_json::Value::Object(default_map) = default_config_value else { 61 | return Config::default(app_data_dir); 62 | }; 63 | for (key, value) in default_map { 64 | map.entry(key).or_insert(value); 65 | } 66 | let Ok(config) = serde_json::from_value(json_value) else { 67 | return Config::default(app_data_dir); 68 | }; 69 | config 70 | } 71 | 72 | fn default(app_data_dir: &Path) -> Config { 73 | Config { 74 | cookie: String::new(), 75 | download_dir: app_data_dir.join("漫画下载"), 76 | export_dir: app_data_dir.join("漫画导出"), 77 | enable_file_logger: true, 78 | chapter_concurrency: 1, 79 | chapter_download_interval_sec: 10, 80 | img_concurrency: 10, 81 | img_download_interval_sec: 0, 82 | update_get_comic_interval_sec: 0, 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod commands; 2 | mod config; 3 | mod decrypt; 4 | mod download_manager; 5 | mod errors; 6 | mod events; 7 | mod export; 8 | mod extensions; 9 | mod logger; 10 | mod manhuagui_client; 11 | mod types; 12 | mod utils; 13 | 14 | use anyhow::Context; 15 | use config::Config; 16 | use download_manager::DownloadManager; 17 | use events::{ 18 | DownloadEvent, DownloadTaskEvent, ExportCbzEvent, ExportPdfEvent, LogEvent, 19 | UpdateDownloadedComicsEvent, 20 | }; 21 | use manhuagui_client::ManhuaguiClient; 22 | use parking_lot::RwLock; 23 | use tauri::{Manager, Wry}; 24 | 25 | use crate::commands::*; 26 | 27 | fn generate_context() -> tauri::Context { 28 | tauri::generate_context!() 29 | } 30 | 31 | #[cfg_attr(mobile, tauri::mobile_entry_point)] 32 | pub fn run() { 33 | let builder = tauri_specta::Builder::::new() 34 | .commands(tauri_specta::collect_commands![ 35 | greet, 36 | get_config, 37 | save_config, 38 | login, 39 | get_user_profile, 40 | search, 41 | get_comic, 42 | download_chapters, 43 | get_favorite, 44 | save_metadata, 45 | get_downloaded_comics, 46 | export_cbz, 47 | export_pdf, 48 | update_downloaded_comics, 49 | get_logs_dir_size, 50 | show_path_in_file_manager, 51 | pause_download_task, 52 | resume_download_task, 53 | cancel_download_task, 54 | ]) 55 | .events(tauri_specta::collect_events![ 56 | DownloadEvent, 57 | ExportCbzEvent, 58 | ExportPdfEvent, 59 | UpdateDownloadedComicsEvent, 60 | LogEvent, 61 | DownloadTaskEvent, 62 | ]); 63 | 64 | #[cfg(debug_assertions)] 65 | builder 66 | .export( 67 | specta_typescript::Typescript::default() 68 | .bigint(specta_typescript::BigIntExportBehavior::Number) 69 | .formatter(specta_typescript::formatter::prettier) 70 | .header("// @ts-nocheck"), // 跳过检查 71 | "../src/bindings.ts", 72 | ) 73 | .expect("Failed to export typescript bindings"); 74 | 75 | tauri::Builder::default() 76 | .plugin(tauri_plugin_dialog::init()) 77 | .plugin(tauri_plugin_opener::init()) 78 | .invoke_handler(builder.invoke_handler()) 79 | .setup(move |app| { 80 | builder.mount_events(app); 81 | 82 | let app_data_dir = app 83 | .path() 84 | .app_data_dir() 85 | .context("获取app_data_dir目录失败")?; 86 | 87 | std::fs::create_dir_all(&app_data_dir) 88 | .context(format!("创建app_data_dir目录`{app_data_dir:?}`失败"))?; 89 | 90 | let config = RwLock::new(Config::new(app.handle())?); 91 | app.manage(config); 92 | 93 | let manhuagui_client = ManhuaguiClient::new(app.handle().clone()); 94 | app.manage(manhuagui_client); 95 | 96 | let download_manager = DownloadManager::new(app.handle()); 97 | app.manage(download_manager); 98 | 99 | logger::init(app.handle())?; 100 | 101 | Ok(()) 102 | }) 103 | .run(generate_context()) 104 | .expect("error while running tauri application"); 105 | } 106 | -------------------------------------------------------------------------------- /src/components/SettingsDialog.tsx: -------------------------------------------------------------------------------- 1 | import { commands, Config } from '../bindings.ts' 2 | import { App as AntdApp, Button, InputNumber, Modal } from 'antd' 3 | import { path } from '@tauri-apps/api' 4 | import { appDataDir } from '@tauri-apps/api/path' 5 | 6 | interface Props { 7 | settingsDialogShowing: boolean 8 | setSettingsDialogShowing: (showing: boolean) => void 9 | config: Config 10 | setConfig: (value: Config | undefined | ((prev: Config | undefined) => Config | undefined)) => void 11 | } 12 | 13 | function SettingsDialog({ settingsDialogShowing, setSettingsDialogShowing, config, setConfig }: Props) { 14 | const { message } = AntdApp.useApp() 15 | 16 | async function showConfigPathInFileManager() { 17 | const configPath = await path.join(await appDataDir(), 'config.json') 18 | const result = await commands.showPathInFileManager(configPath) 19 | if (result.status === 'error') { 20 | console.error(result.error) 21 | } 22 | } 23 | 24 | return ( 25 | setSettingsDialogShowing(false)} footer={null}> 26 |
27 |
28 | { 34 | if (value === null) { 35 | return 36 | } 37 | message.warning('对章节并发数的修改需要重启才能生效') 38 | setConfig({ ...config, chapterConcurrency: value }) 39 | }} 40 | /> 41 | { 48 | if (value === null) { 49 | return 50 | } 51 | setConfig({ ...config, chapterDownloadIntervalSec: value }) 52 | }} 53 | /> 54 |
55 |
56 | { 62 | if (value === null) { 63 | return 64 | } 65 | message.warning('对图片并发数的修改需要重启才能生效') 66 | setConfig({ ...config, imgConcurrency: value }) 67 | }} 68 | /> 69 | { 76 | if (value === null) { 77 | return 78 | } 79 | setConfig({ ...config, imgDownloadIntervalSec: value }) 80 | }} 81 | /> 82 |
83 | { 90 | if (value === null) { 91 | return 92 | } 93 | setConfig({ ...config, updateGetComicIntervalSec: value }) 94 | }} 95 | /> 96 |
97 | 100 |
101 |
102 |
103 | ) 104 | } 105 | 106 | export default SettingsDialog 107 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src-tauri/src/types/get_favorite_result.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use scraper::{ElementRef, Html, Selector}; 3 | use serde::{Deserialize, Serialize}; 4 | use specta::Type; 5 | 6 | use crate::extensions::ToAnyhow; 7 | 8 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 9 | #[serde(rename_all = "camelCase")] 10 | pub struct GetFavoriteResult { 11 | comics: Vec, 12 | current: i64, 13 | total: i64, 14 | } 15 | 16 | impl GetFavoriteResult { 17 | pub fn from_html(html: &str) -> anyhow::Result { 18 | let document = Html::parse_document(html); 19 | let mut comics = Vec::new(); 20 | for book_div in document.select(&Selector::parse(".dy_content_li").to_anyhow()?) { 21 | let comic = ComicInFavorite::from_div(&book_div)?; 22 | comics.push(comic); 23 | } 24 | 25 | let current = match document 26 | .select(&Selector::parse(".current").to_anyhow()?) 27 | .next() 28 | { 29 | Some(span) => span 30 | .text() 31 | .next() 32 | .context("没有在当前页码的中找到文本")? 33 | .parse::() 34 | .context("当前页码不是整数")?, 35 | None => 1, 36 | }; 37 | 38 | // 如果没有找到总页数的span,说明只有一页 39 | let total = match document 40 | .select(&Selector::parse(".flickr.right > span").to_anyhow()?) 41 | .next() 42 | { 43 | Some(span) => span 44 | .text() 45 | .next() 46 | .context("没有在总页数的中找到文本")? 47 | .trim_start_matches("共") 48 | .trim_end_matches("记录") 49 | .parse::() 50 | .context("总页数不是整数")?, 51 | None => 1, 52 | }; 53 | 54 | Ok(GetFavoriteResult { 55 | comics, 56 | current, 57 | total, 58 | }) 59 | } 60 | } 61 | 62 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 63 | #[serde(rename_all = "camelCase")] 64 | pub struct ComicInFavorite { 65 | /// 漫画id 66 | id: i64, 67 | /// 漫画标题 68 | title: String, 69 | /// 漫画封面链接 70 | cover: String, 71 | /// 最近更新时间,两种格式 72 | /// - 2024-12-13 73 | /// - x分钟前 74 | last_update: String, 75 | /// 上次阅读时间,两种格式 76 | /// - 2024-12-13 77 | /// - x分钟前 78 | last_read: String, 79 | } 80 | 81 | impl ComicInFavorite { 82 | pub fn from_div(div: &ElementRef) -> anyhow::Result { 83 | let a = div 84 | .select(&Selector::parse(".dy_content_li h3 > a").to_anyhow()?) 85 | .next() 86 | .context("没有找到标题相关的")?; 87 | 88 | let id = a 89 | .value() 90 | .attr("href") 91 | .context("没有在标题和链接的中找到href属性")? 92 | .trim_start_matches("/comic/") 93 | .trim_end_matches('/') 94 | .parse::() 95 | .context("漫画id不是整数")?; 96 | 97 | let title = a 98 | .text() 99 | .next() 100 | .context("没有在标题和链接的中找到文本")? 101 | .trim() 102 | .to_string(); 103 | 104 | let cover_src = div 105 | .select(&Selector::parse(".dy_img img").to_anyhow()?) 106 | .next() 107 | .context("没有找到封面的")? 108 | .value() 109 | .attr("src") 110 | .context("没有在封面的中找到src属性")?; 111 | let cover = format!("https:{cover_src}"); 112 | 113 | let last_update = div 114 | .select(&Selector::parse(".dy_r > p > em:nth-child(2)").to_anyhow()?) 115 | .next() 116 | .context("没有找到最近更新时间")? 117 | .text() 118 | .next() 119 | .context("没有在最近更新时间中找到文本")? 120 | .trim() 121 | .to_string(); 122 | 123 | let last_read = div 124 | .select(&Selector::parse(".dy_r > p > em:nth-child(2)").to_anyhow()?) 125 | .nth(1) 126 | .context("没有找到上次阅读时间")? 127 | .text() 128 | .next() 129 | .context("没有在上次阅读时间中找到文本")? 130 | .trim() 131 | .to_string(); 132 | 133 | Ok(ComicInFavorite { 134 | id, 135 | title, 136 | cover, 137 | last_update, 138 | last_read, 139 | }) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/panes/SearchPane.tsx: -------------------------------------------------------------------------------- 1 | import { Comic, commands, SearchResult } from '../bindings.ts' 2 | import { CurrentTabName } from '../types.ts' 3 | import { useState } from 'react' 4 | import { App as AntdApp, Button, Input, Pagination } from 'antd' 5 | import ComicCard from '../components/ComicCard.tsx' 6 | import isNumeric from 'antd/es/_util/isNumeric' 7 | 8 | interface Props { 9 | setPickedComic: (comic: Comic | undefined) => void 10 | setCurrentTabName: (currentTabName: CurrentTabName) => void 11 | } 12 | 13 | function SearchPane({ setPickedComic, setCurrentTabName }: Props) { 14 | const { message } = AntdApp.useApp() 15 | 16 | const [searchInput, setSearchInput] = useState('') 17 | const [comicIdInput, setComicIdInput] = useState('') 18 | const [searchPageNum, setSearchPageNum] = useState(1) 19 | const [searchResult, setSearchResult] = useState() 20 | 21 | async function search(keyword: string, pageNum: number) { 22 | console.log(keyword, pageNum) 23 | setSearchPageNum(pageNum) 24 | const result = await commands.search(keyword, pageNum) 25 | if (result.status === 'error') { 26 | console.error(result.error) 27 | return 28 | } 29 | setSearchResult(result.data) 30 | console.log(result.data) 31 | } 32 | 33 | function getComicIdFromComicIdInput(): number | undefined { 34 | const comicIdString = comicIdInput.trim() 35 | if (isNumeric(comicIdString)) { 36 | return parseInt(comicIdString) 37 | } 38 | 39 | const regex = /\/comic\/(\d+)/ 40 | const match = comicIdString.match(regex) 41 | if (match === null || match[1] === null) { 42 | return 43 | } 44 | return parseInt(match[1]) 45 | } 46 | 47 | async function pickComic() { 48 | const comicId = getComicIdFromComicIdInput() 49 | 50 | if (comicId === undefined) { 51 | message.error('漫画ID格式错误,请输入正确的漫画ID或漫画链接') 52 | return 53 | } 54 | 55 | const result = await commands.getComic(comicId) 56 | if (result.status === 'error') { 57 | console.error(result.error) 58 | return 59 | } 60 | console.log(result.data) 61 | setPickedComic(result.data) 62 | setCurrentTabName('chapter') 63 | } 64 | 65 | return ( 66 |
67 |
68 |
69 | setSearchInput(e.target.value)} 75 | onKeyDown={async (e) => { 76 | if (e.key === 'Enter') await search(searchInput.trim(), 1) 77 | }} 78 | /> 79 | 82 |
83 |
84 | setComicIdInput(e.target.value)} 91 | onKeyDown={async (e) => { 92 | if (e.key === 'Enter') await pickComic() 93 | }} 94 | /> 95 | 98 |
99 |
100 | 101 | {searchResult && ( 102 |
103 |
104 | {searchResult.comics.map((comic) => ( 105 | 117 | ))} 118 |
119 | search(searchInput.trim(), pageNum)} 126 | /> 127 |
128 | )} 129 |
130 | ) 131 | } 132 | 133 | export default SearchPane 134 | -------------------------------------------------------------------------------- /src/AppContent.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { Comic, commands, Config, UserProfile } from './bindings.ts' 3 | import { App as AntdApp, Avatar, Button, Input, Tabs, TabsProps } from 'antd' 4 | import LoginDialog from './components/LoginDialog.tsx' 5 | import DownloadingPane from './panes/DownloadingPane.tsx' 6 | import { CurrentTabName } from './types.ts' 7 | import SearchPane from './panes/SearchPane.tsx' 8 | import ChapterPane from './panes/ChapterPane.tsx' 9 | import FavoritePane from './panes/FavoritePane.tsx' 10 | import DownloadedPane from './panes/DownloadedPane.tsx' 11 | import LogViewer from './components/LogViewer.tsx' 12 | import { AboutDialog } from './components/AboutDialog.tsx' 13 | 14 | interface Props { 15 | config: Config 16 | setConfig: (value: Config | undefined | ((prev: Config | undefined) => Config | undefined)) => void 17 | } 18 | 19 | function AppContent({ config, setConfig }: Props) { 20 | const { message } = AntdApp.useApp() 21 | 22 | const [userProfile, setUserProfile] = useState() 23 | const [loginDialogShowing, setLoginDialogShowing] = useState(false) 24 | const [logViewerShowing, setLogViewerShowing] = useState(false) 25 | const [aboutDialogShowing, setAboutDialogShowing] = useState(false) 26 | const [pickedComic, setPickedComic] = useState() 27 | 28 | useEffect(() => { 29 | if (config === undefined) { 30 | return 31 | } 32 | 33 | commands.saveConfig(config).then(async () => { 34 | message.success('保存配置成功') 35 | }) 36 | }, [config, message]) 37 | 38 | useEffect(() => { 39 | if (config.cookie === '') { 40 | return 41 | } 42 | 43 | commands.getUserProfile().then(async (result) => { 44 | if (result.status === 'error') { 45 | console.error(result.error) 46 | setUserProfile(undefined) 47 | return 48 | } 49 | 50 | setUserProfile(result.data) 51 | message.success('获取用户信息成功') 52 | }) 53 | }, [config.cookie, message]) 54 | 55 | const [currentTabName, setCurrentTabName] = useState('search') 56 | 57 | const tabItems: TabsProps['items'] = [ 58 | { 59 | key: 'search', 60 | label: '漫画搜索', 61 | children: , 62 | }, 63 | { 64 | key: 'favorite', 65 | label: '漫画收藏', 66 | children: ( 67 | 68 | ), 69 | }, 70 | { 71 | key: 'downloaded', 72 | label: '本地库存', 73 | children: ( 74 | 81 | ), 82 | }, 83 | { 84 | key: 'chapter', 85 | label: '章节详情', 86 | children: , 87 | }, 88 | ] 89 | 90 | return ( 91 |
92 |
93 | setConfig({ ...config, cookie: e.target.value })} 97 | allowClear={true} 98 | /> 99 | 102 | 103 | 104 | {userProfile !== undefined && ( 105 |
106 | 107 | {userProfile.username} 108 |
109 | )} 110 |
111 |
112 | setCurrentTabName(key as CurrentTabName)} 118 | /> 119 | 120 |
121 | 122 | 128 | 134 | 135 | 136 |
137 | ) 138 | } 139 | 140 | export default AppContent 141 | -------------------------------------------------------------------------------- /src/panes/DownloadingPane.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Input, Tabs, TabsProps } from 'antd' 2 | import { commands, Config, events } from '../bindings.ts' 3 | import { useEffect, useMemo, useState } from 'react' 4 | import { open } from '@tauri-apps/plugin-dialog' 5 | import SettingsDialog from '../components/SettingsDialog.tsx' 6 | import { ProgressData } from '../types.ts' 7 | import UncompletedProgresses from '../components/UncompletedProgresses.tsx' 8 | import CompletedProgresses from '../components/CompletedProgresses.tsx' 9 | 10 | interface Props { 11 | className?: string 12 | config: Config 13 | setConfig: (value: Config | undefined | ((prev: Config | undefined) => Config | undefined)) => void 14 | } 15 | 16 | function DownloadingPane({ className, config, setConfig }: Props) { 17 | const [progresses, setProgresses] = useState>(new Map()) 18 | const [downloadSpeed, setDownloadSpeed] = useState() 19 | const [settingsDialogShowing, setSettingsDialogShowing] = useState(false) 20 | 21 | useEffect(() => { 22 | let mounted = true 23 | let unListenDownloadEvent: () => void | undefined 24 | let unListenDownloadTaskEvent: () => void | undefined 25 | 26 | events.downloadEvent 27 | .listen(({ payload: downloadEvent }) => { 28 | if (downloadEvent.event === 'Sleeping') { 29 | const { chapterId, remainingSec } = downloadEvent.data 30 | setProgresses((prev) => { 31 | const progressData = prev.get(chapterId) 32 | if (progressData === undefined) { 33 | return prev 34 | } 35 | const next = new Map(prev) 36 | next.set(chapterId, { ...progressData, indicator: `将在${remainingSec}秒后继续下载` }) 37 | return new Map(next) 38 | }) 39 | } else if (downloadEvent.event == 'Speed') { 40 | const { speed } = downloadEvent.data 41 | setDownloadSpeed(speed) 42 | } 43 | }) 44 | .then((unListenFn) => { 45 | if (mounted) { 46 | unListenDownloadEvent = unListenFn 47 | } else { 48 | unListenFn() 49 | } 50 | }) 51 | 52 | events.downloadTaskEvent 53 | .listen(({ payload: downloadTaskEvent }) => { 54 | setProgresses((prev) => { 55 | const { state, chapterInfo, downloadedImgCount, totalImgCount } = downloadTaskEvent 56 | 57 | const percentage = (downloadedImgCount / totalImgCount) * 100 58 | 59 | let indicator = '' 60 | if (state === 'Pending') { 61 | indicator = `排队中` 62 | } else if (state === 'Downloading') { 63 | indicator = `下载中` 64 | } else if (state === 'Paused') { 65 | indicator = `已暂停` 66 | } else if (state === 'Cancelled') { 67 | indicator = `已取消` 68 | } else if (state === 'Completed') { 69 | indicator = `下载完成` 70 | } else if (state === 'Failed') { 71 | indicator = `下载失败` 72 | } 73 | if (totalImgCount !== 0) { 74 | indicator += ` ${downloadedImgCount}/${totalImgCount}` 75 | } 76 | 77 | const next = new Map(prev) 78 | next.set(chapterInfo.chapterId, { ...downloadTaskEvent, percentage, indicator }) 79 | return new Map(next) 80 | }) 81 | }) 82 | .then((unListenFn) => { 83 | if (mounted) { 84 | unListenDownloadTaskEvent = unListenFn 85 | } else { 86 | unListenFn() 87 | } 88 | }) 89 | 90 | return () => { 91 | mounted = false 92 | unListenDownloadEvent?.() 93 | unListenDownloadTaskEvent?.() 94 | } 95 | }, []) 96 | 97 | // 通过对话框选择下载目录 98 | async function selectDownloadDir() { 99 | const selectedDirPath = await open({ directory: true }) 100 | if (selectedDirPath === null) { 101 | return 102 | } 103 | setConfig((prev) => { 104 | if (prev === undefined) { 105 | return prev 106 | } 107 | return { ...prev, downloadDir: selectedDirPath } 108 | }) 109 | } 110 | 111 | async function showDownloadDirInFileManager() { 112 | const result = await commands.showPathInFileManager(config.downloadDir) 113 | if (result.status === 'error') { 114 | console.error(result.error) 115 | } 116 | } 117 | 118 | const tabItems = useMemo( 119 | () => [ 120 | { key: 'uncompleted', label: '未完成', children: }, 121 | { key: 'completed', label: '已完成', children: }, 122 | ], 123 | [progresses], 124 | ) 125 | 126 | return ( 127 |
128 | 下载列表 129 |
130 | 131 | 134 | 137 | 143 |
144 | 下载速度: {downloadSpeed} 145 | 146 |
147 | ) 148 | } 149 | 150 | export default DownloadingPane 151 | -------------------------------------------------------------------------------- /src-tauri/src/decrypt.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::{anyhow, Context}; 4 | use regex::Regex; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 8 | #[serde(rename_all = "camelCase")] 9 | #[allow(clippy::module_name_repetitions)] 10 | pub struct DecryptResult { 11 | /// 漫画id 12 | pub bid: i64, 13 | /// 漫画名 14 | pub bname: String, 15 | /// 封面图片名称 (漫画id.jpg) 16 | pub bpic: String, 17 | /// 章节id 18 | pub cid: i64, 19 | /// 章节名 20 | pub cname: String, 21 | /// 章节图片名 (xxx.jpg.webp) 22 | pub files: Vec, 23 | /// 是否已完结 24 | pub finished: bool, 25 | /// 章节图片数量 26 | pub len: i64, 27 | /// `https://i.hamreus.com{path}{file}` 为图片url 28 | pub path: String, 29 | /// 不知道有啥用,都是1 30 | pub status: i64, 31 | /// 不知道有啥用,都为"" 32 | #[serde(rename = "block_cc")] 33 | pub block_cc: String, 34 | /// 下一个章节id,如果没有则为0 35 | pub next_id: i64, 36 | /// 上一个章节id,如果没有则为0 37 | pub prev_id: i64, 38 | /// 应该是凭证之类的东西,用不到 39 | pub sl: Sl, 40 | } 41 | 42 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 43 | #[serde(rename_all = "camelCase")] 44 | pub struct Sl { 45 | /// 看起来是时间戳 46 | e: i64, 47 | /// 不知道有啥用 (`agr1qEo-yIKXp_vfN6HbYg`) 48 | m: String, 49 | } 50 | 51 | pub fn decrypt(html: &str) -> anyhow::Result { 52 | let (function, a, c, data) = extract_decryption_data(html)?; 53 | 54 | let dict = create_dict(a, c, &data); 55 | 56 | let js = create_js(&function, &dict).context("生成js失败")?; 57 | 58 | let decrypt_result = create_decrypt_result(&js).context("生成DecryptResult失败")?; 59 | 60 | Ok(decrypt_result) 61 | } 62 | 63 | fn extract_decryption_data(html: &str) -> anyhow::Result<(String, i32, i32, Vec)> { 64 | let re = 65 | Regex::new(r"^.*}\('(.*)',(\d*),(\d*),'([\w|+/=]*)'.*$").context("正则表达式编译失败")?; 66 | 67 | let captures = re.captures(html).context("正则表达式没有匹配到内容")?; 68 | 69 | let function = captures 70 | .get(1) 71 | .context("匹配到的内容没有function部分")? 72 | .as_str() 73 | .to_string(); 74 | 75 | let a = captures 76 | .get(2) 77 | .context("匹配到的内容没有a部分")? 78 | .as_str() 79 | .parse::() 80 | .context("将a部分转换为整数失败")?; 81 | 82 | let c = captures 83 | .get(3) 84 | .context("匹配到的内容没有c部分")? 85 | .as_str() 86 | .parse::() 87 | .context("将c部分转换为整数失败")?; 88 | 89 | let compressed_data = captures 90 | .get(4) 91 | .context("匹配到的内容没有compressed_data部分")? 92 | .as_str(); 93 | 94 | let decompressed_data = 95 | lz_str::decompress_from_base64(compressed_data).ok_or(anyhow!("lzstring解压缩失败"))?; 96 | let decompressed = 97 | String::from_utf16(&decompressed_data).context("lzstring解压缩后的数据不是utf-16字符串")?; 98 | 99 | let data = decompressed 100 | .split('|') 101 | .map(str::to_string) 102 | .collect::>(); 103 | 104 | Ok((function, a, c, data)) 105 | } 106 | 107 | #[allow(clippy::cast_sign_loss)] 108 | #[allow(clippy::cast_possible_truncation)] 109 | fn create_dict(a: i32, mut c: i32, data: &[String]) -> HashMap { 110 | fn itr(value: i32, num: i32, a: i32) -> String { 111 | const D: &str = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; 112 | if value <= 0 { 113 | return String::new(); 114 | } 115 | let mut result = itr(value / num, num, a); 116 | result.push(D.chars().nth((value % a) as usize).unwrap()); 117 | result 118 | } 119 | 120 | fn tr(value: i32, num: i32, a: i32) -> String { 121 | let tmp = itr(value, num, a); 122 | if tmp.is_empty() { 123 | "0".to_string() 124 | } else { 125 | tmp 126 | } 127 | } 128 | 129 | fn e(c: i32, a: i32) -> String { 130 | let prefix = if c < a { String::new() } else { e(c / a, a) }; 131 | 132 | let suffix = if c % a > 35 { 133 | ((c % a + 29) as u8 as char).to_string() 134 | } else { 135 | tr(c % a, 36, a) 136 | }; 137 | 138 | format!("{prefix}{suffix}") 139 | } 140 | 141 | let mut dict = HashMap::new(); 142 | while c > 0 { 143 | c -= 1; 144 | let key = e(c, a); 145 | let value = if data[c as usize].is_empty() { 146 | key.clone() 147 | } else { 148 | data[c as usize].clone() 149 | }; 150 | dict.insert(key, value); 151 | } 152 | 153 | dict 154 | } 155 | 156 | fn create_js(function: &str, dict: &HashMap) -> anyhow::Result { 157 | let re = Regex::new(r"(\b\w+\b)").context("正则表达式编译失败")?; 158 | 159 | let splits = re.split(function).collect::>(); 160 | 161 | let matches = re 162 | .find_iter(function) 163 | .map(|m| m.as_str()) 164 | .collect::>(); 165 | 166 | let mut pieces = Vec::new(); 167 | for i in 0..splits.len() { 168 | pieces.push(splits[i]); 169 | if i < matches.len() { 170 | pieces.push(matches[i]); 171 | } 172 | } 173 | 174 | let mut js = String::new(); 175 | for x in pieces { 176 | if let Some(val) = dict.get(x) { 177 | js.push_str(val); 178 | } else { 179 | js.push_str(x); 180 | } 181 | } 182 | 183 | Ok(js) 184 | } 185 | 186 | fn create_decrypt_result(js: &str) -> anyhow::Result { 187 | let re = Regex::new(r"^.*\((\{.*})\).*$").context("正则表达式编译失败")?; 188 | 189 | let captures = re.captures(js).context("正则表达式没有匹配到内容")?; 190 | 191 | let json_str = captures 192 | .get(1) 193 | .context("匹配到的内容没有json部分")? 194 | .as_str(); 195 | 196 | let decrypt_result = serde_json::from_str::(json_str) 197 | .context("将解密后的数据转换为DecryptResult失败")?; 198 | 199 | Ok(decrypt_result) 200 | } 201 | -------------------------------------------------------------------------------- /src/components/LogViewer.tsx: -------------------------------------------------------------------------------- 1 | import { App as AntdApp, Modal, Input, Button, Select, Checkbox } from 'antd' 2 | import { useEffect, useState, useMemo, useRef } from 'react' 3 | import { commands, Config, events, LogEvent, LogLevel } from '../bindings.ts' 4 | import { path } from '@tauri-apps/api' 5 | import { appDataDir } from '@tauri-apps/api/path' 6 | 7 | interface Props { 8 | logViewerShowing: boolean 9 | setLogViewerShowing: (showing: boolean) => void 10 | config: Config 11 | setConfig: (value: Config | undefined | ((prev: Config | undefined) => Config | undefined)) => void 12 | } 13 | 14 | type LogRecord = LogEvent & { id: number; formatedLog: string } 15 | 16 | function LogViewer({ logViewerShowing, setLogViewerShowing, config, setConfig }: Props) { 17 | const { notification } = AntdApp.useApp() 18 | const [logRecords, setLogRecords] = useState([]) 19 | const [searchText, setSearchText] = useState('') 20 | const [selectedLevel, setSelectedLevel] = useState('INFO') 21 | const [logsDirSize, setLogsDirSize] = useState(0) 22 | const nextLogRecordId = useRef(1) 23 | 24 | useEffect(() => { 25 | let mounted = true 26 | let unListenLogEvent: () => void | undefined 27 | 28 | events.logEvent 29 | .listen(async ({ payload: logEvent }) => { 30 | setLogRecords((prev) => [ 31 | ...prev, 32 | { 33 | ...logEvent, 34 | id: nextLogRecordId.current++, 35 | formatedLog: formatLogEvent(logEvent), 36 | }, 37 | ]) 38 | const { level, fields } = logEvent 39 | if (level === 'ERROR') { 40 | notification.error({ 41 | message: fields['err_title'] as string, 42 | description: fields['message'] as string, 43 | duration: 0, 44 | }) 45 | } 46 | }) 47 | .then((unListenFn) => { 48 | if (mounted) { 49 | unListenLogEvent = unListenFn 50 | } else { 51 | unListenFn() 52 | } 53 | }) 54 | 55 | return () => { 56 | mounted = false 57 | unListenLogEvent?.() 58 | } 59 | }, [notification]) 60 | 61 | useEffect(() => { 62 | if (!logViewerShowing) { 63 | return 64 | } 65 | commands.getLogsDirSize().then((result) => { 66 | if (result.status === 'error') { 67 | console.error(result.error) 68 | return 69 | } 70 | 71 | setLogsDirSize(result.data) 72 | }) 73 | }, [logViewerShowing]) 74 | 75 | const formatedLogsDirSize = useMemo(() => { 76 | const units = ['B', 'KB', 'MB'] 77 | let size = logsDirSize 78 | let unitIndex = 0 79 | 80 | while (size >= 1024 && unitIndex < 2) { 81 | size /= 1024 82 | unitIndex++ 83 | } 84 | 85 | // 保留两位小数 86 | return `${size.toFixed(2)} ${units[unitIndex]}` 87 | }, [logsDirSize]) 88 | 89 | const filteredLogs = useMemo(() => { 90 | return logRecords.filter(({ level, formatedLog }) => { 91 | // 定义日志等级的优先级顺序 92 | const logLevelPriority = { 93 | TRACE: 0, 94 | DEBUG: 1, 95 | INFO: 2, 96 | WARN: 3, 97 | ERROR: 4, 98 | } 99 | // 首先按日志等级筛选 100 | if (logLevelPriority[level] < logLevelPriority[selectedLevel]) { 101 | return false 102 | } 103 | // 然后按搜索文本筛选 104 | if (searchText === '') { 105 | return true 106 | } 107 | 108 | return formatedLog.toLowerCase().includes(searchText.toLowerCase()) 109 | }) 110 | }, [logRecords, searchText, selectedLevel]) 111 | 112 | function getLevelStyles(level: LogLevel) { 113 | switch (level) { 114 | case 'TRACE': 115 | return 'text-gray-400' 116 | case 'DEBUG': 117 | return 'text-green-400' 118 | case 'INFO': 119 | return 'text-blue-400' 120 | case 'WARN': 121 | return 'text-yellow-400' 122 | case 'ERROR': 123 | return 'text-red-400' 124 | } 125 | } 126 | 127 | const logLevelOptions = [ 128 | { value: 'TRACE', label: 'TRACE' }, 129 | { value: 'DEBUG', label: 'DEBUG' }, 130 | { value: 'INFO', label: 'INFO' }, 131 | { value: 'WARN', label: 'WARN' }, 132 | { value: 'ERROR', label: 'ERROR' }, 133 | ] 134 | 135 | function formatLogEvent(logEvent: LogEvent): string { 136 | const { timestamp, level, fields, target, filename, line_number } = logEvent 137 | const fields_str = Object.entries(fields) 138 | .map(([key, value]) => `${key}=${value}`) 139 | .join(' ') 140 | return `${timestamp} ${level} ${target}: ${filename}:${line_number} ${fields_str}` 141 | } 142 | 143 | function clearLogRecords() { 144 | setLogRecords([]) 145 | nextLogRecordId.current = 1 146 | } 147 | 148 | async function showLogsDirInFileManager() { 149 | const logsDir = await path.join(await appDataDir(), '日志') 150 | const result = await commands.showPathInFileManager(logsDir) 151 | if (result.status === 'error') { 152 | console.error(result.error) 153 | } 154 | } 155 | 156 | return ( 157 | 日志目录总大小:{formatedLogsDirSize}

} 159 | open={logViewerShowing} 160 | onCancel={() => setLogViewerShowing(false)} 161 | width="95%" 162 | footer={null}> 163 |
164 | setSearchText(e.target.value)} 169 | style={{ width: 300 }} 170 | allowClear 171 | /> 172 | 239 | 242 | 245 |
246 |
247 |
248 | {showingDownloadedComics.map((comic) => ( 249 | 255 | ))} 256 |
257 | setDownloadedPageNum(pageNum)} 264 | /> 265 |
266 |
267 | ) 268 | } 269 | 270 | export default DownloadedPane 271 | -------------------------------------------------------------------------------- /src/panes/ChapterPane.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | App as AntdApp, 3 | Button, 4 | Card, 5 | Checkbox, 6 | CheckboxProps, 7 | Divider, 8 | Dropdown, 9 | Empty, 10 | MenuProps, 11 | Tabs, 12 | TabsProps, 13 | } from 'antd' 14 | import { ChapterInfo, Comic, commands } from '../bindings.ts' 15 | import { useEffect, useMemo, useState } from 'react' 16 | import SelectionArea, { SelectionEvent } from '@viselect/react' 17 | import styles from '../styles/ChapterPane.module.css' 18 | 19 | interface Props { 20 | pickedComic: Comic | undefined 21 | setPickedComic: (update: (prevComic: Comic | undefined) => Comic | undefined) => void 22 | } 23 | 24 | function ChapterPane({ pickedComic, setPickedComic }: Props) { 25 | const { message } = AntdApp.useApp() 26 | // 按章节数排序的分组 27 | const sortedGroups = useMemo<[string, ChapterInfo[]][] | undefined>(() => { 28 | const groups = pickedComic?.groups 29 | if (groups === undefined) { 30 | return 31 | } 32 | return Object.entries(groups).sort((a, b) => { 33 | return b[1].length - a[1].length 34 | }) 35 | }, [pickedComic?.groups]) 36 | // 第一个group的名字 37 | const firstGroupName = sortedGroups?.[0]?.[0] ?? '单话' 38 | // 当前tab的分组名 39 | const [currentGroupName, setCurrentGroupName] = useState(firstGroupName) 40 | 41 | // 所有章节 42 | const chapterInfos = useMemo(() => { 43 | const groups = pickedComic?.groups 44 | if (groups === undefined) { 45 | return 46 | } 47 | 48 | return Object.values(groups).flat() 49 | }, [pickedComic?.groups]) 50 | 51 | // 已勾选的章节id 52 | const [checkedIds, setCheckedIds] = useState>(new Set()) 53 | // 已选中(被框选选到)的章节id 54 | const [selectedIds, setSelectedIds] = useState>(new Set()) 55 | // 如果漫画变了,清空勾选和选中状态 56 | useEffect(() => { 57 | setCheckedIds(new Set()) 58 | setSelectedIds(new Set()) 59 | setCurrentGroupName(firstGroupName) 60 | }, [firstGroupName, pickedComic?.id]) 61 | 62 | // 下载勾选的章节 63 | async function downloadChapters() { 64 | if (pickedComic === undefined) { 65 | message.error('请先选择漫画') 66 | return 67 | } 68 | // 创建下载任务前,先创建元数据 69 | const saveMetadataResult = await commands.saveMetadata(pickedComic) 70 | if (saveMetadataResult.status === 'error') { 71 | console.error(saveMetadataResult.error) 72 | return 73 | } 74 | // 下载没有下载过的且已勾选的章节 75 | const chapterToDownload = chapterInfos?.filter((c) => c.isDownloaded === false && checkedIds.has(c.chapterId)) 76 | if (chapterToDownload === undefined) { 77 | return 78 | } 79 | await commands.downloadChapters(chapterToDownload) 80 | // 把已下载的章节从已勾选的章节id中移除 81 | setCheckedIds((prev) => new Set([...prev].filter((id) => !chapterToDownload.map((c) => c.chapterId).includes(id)))) 82 | // 更新pickedComic,将已下载的章节标记为已下载 83 | setPickedComic((prev) => { 84 | if (prev === undefined) { 85 | return prev 86 | } 87 | const next = { ...prev } 88 | for (const downloadedChapter of chapterToDownload) { 89 | const chapter = Object.values(next.groups) 90 | .flat() 91 | .find((c) => c.chapterId === downloadedChapter.chapterId) 92 | if (chapter !== undefined) { 93 | chapter.isDownloaded = true 94 | } 95 | } 96 | return next 97 | }) 98 | } 99 | 100 | // 重新加载选中的漫画 101 | async function reloadPickedComic() { 102 | if (pickedComic === undefined) { 103 | return 104 | } 105 | 106 | const result = await commands.getComic(pickedComic.id) 107 | if (result.status === 'error') { 108 | console.error(result.error) 109 | return 110 | } 111 | 112 | setPickedComic(() => result.data) 113 | } 114 | 115 | return ( 116 |
117 |
118 | 总章数:{chapterInfos?.length} 119 | 120 | 已下载:{chapterInfos?.filter((c) => c.isDownloaded).length} 121 | 122 | 已勾选:{checkedIds.size} 123 |
124 |
125 | 左键拖动进行框选,右键打开菜单 126 | 129 | 137 |
138 | 148 | {pickedComic !== undefined && ( 149 | 150 |
151 | 152 |
153 | 154 | {pickedComic.title} 155 | {pickedComic.subtitle && `(${pickedComic.subtitle})`} 156 | 157 | 作者:{pickedComic.authors.join(', ')} 158 | 类型:{pickedComic.genres.join(' ')} 159 |
160 |
161 |
162 | )} 163 |
164 | ) 165 | } 166 | 167 | interface ChapterTabsProps { 168 | pickedComic: Comic | undefined 169 | sortedGroups?: [string, ChapterInfo[]][] 170 | setCheckedIds: (value: ((prevState: Set) => Set) | Set) => void 171 | selectedIds: Set 172 | setSelectedIds: (value: ((prevState: Set) => Set) | Set) => void 173 | checkedIds: Set 174 | currentGroupName: string 175 | setCurrentGroupName: (value: string) => void 176 | } 177 | 178 | function ChapterTabs({ 179 | pickedComic, 180 | sortedGroups, 181 | setCheckedIds, 182 | selectedIds, 183 | setSelectedIds, 184 | checkedIds, 185 | currentGroupName, 186 | setCurrentGroupName, 187 | }: ChapterTabsProps) { 188 | // 当前分组 189 | const currentGroup = pickedComic?.groups[currentGroupName] 190 | 191 | const items = useMemo(() => { 192 | // 提取章节id 193 | function extractIds(elements: Element[]): number[] { 194 | return elements 195 | .map((element) => element.getAttribute('data-key')) 196 | .filter(Boolean) 197 | .map(Number) 198 | .filter((id) => { 199 | const chapterInfo = currentGroup?.find((chapter) => chapter.chapterId === id) 200 | return chapterInfo && chapterInfo.isDownloaded === false 201 | }) 202 | } 203 | 204 | // 取消所有已选中(被框选选到)的章节 205 | function unselectAll({ event, selection }: SelectionEvent) { 206 | if (!event?.ctrlKey && !event?.metaKey) { 207 | selection.clearSelection() 208 | setSelectedIds(new Set()) 209 | } 210 | } 211 | 212 | // 更新已选中(被框选选到)的章节id 213 | function updateSelectedIds({ 214 | store: { 215 | changed: { added, removed }, 216 | }, 217 | }: SelectionEvent) { 218 | setSelectedIds((prev) => { 219 | const next = new Set(prev) 220 | extractIds(added).forEach((id) => next.add(id)) 221 | extractIds(removed).forEach((id) => next.delete(id)) 222 | console.log(`added: ${extractIds(added)}, removed: ${extractIds(removed)}`) 223 | return next 224 | }) 225 | } 226 | 227 | if (sortedGroups === undefined) { 228 | return [] 229 | } 230 | 231 | const onCheckboxChange: CheckboxProps['onChange'] = (e) => { 232 | setCheckedIds((prev) => { 233 | const next = new Set(prev) 234 | const id = e.target.value 235 | if (e.target.checked) { 236 | next.add(id) 237 | } else { 238 | next.delete(id) 239 | } 240 | return next 241 | }) 242 | } 243 | 244 | const dropdownOptions: MenuProps['items'] = [ 245 | { 246 | label: '勾选', 247 | key: 'check', 248 | onClick: () => 249 | // 将框选选到的章节id加入已勾选的章节id中 250 | setCheckedIds((prev) => { 251 | const next = new Set(prev) 252 | selectedIds.forEach((id) => next.add(id)) 253 | return next 254 | }), 255 | }, 256 | { 257 | label: '取消勾选', 258 | key: 'uncheck', 259 | onClick: () => 260 | // 将框选选到的章节id从已勾选的章节id中移除 261 | setCheckedIds((prev) => new Set([...prev].filter((id) => !selectedIds.has(id)))), 262 | }, 263 | { 264 | label: '全选', 265 | key: 'check all', 266 | onClick: () => 267 | // 将当前分组中未下载的章节id加入已勾选的章节id中 268 | setCheckedIds((prev) => { 269 | const next = new Set(prev) 270 | currentGroup?.filter((c) => c.isDownloaded === false).forEach((c) => next.add(c.chapterId)) 271 | return next 272 | }), 273 | }, 274 | { 275 | label: '取消全选', 276 | key: 'uncheck all', 277 | onClick: () => 278 | // 将当前分组中未下载的章节id从已勾选的章节id中移除 279 | setCheckedIds( 280 | (prev) => new Set([...prev].filter((id) => !currentGroup?.map((c) => c.chapterId).includes(id))), 281 | ), 282 | }, 283 | ] 284 | 285 | return sortedGroups.map(([groupName, chapters]) => ({ 286 | key: groupName, 287 | label: groupName, 288 | children: ( 289 | 290 |
291 | 297 |
298 | {chapters.map((chapter) => ( 299 |
303 | 309 | {chapter.chapterTitle} 310 | 311 |
312 | ))} 313 |
314 |
315 |
316 |
317 | ), 318 | })) 319 | }, [sortedGroups, setSelectedIds, setCheckedIds, selectedIds, currentGroup, checkedIds]) 320 | 321 | if (pickedComic === undefined) { 322 | return 323 | } 324 | 325 | return ( 326 | 334 | ) 335 | } 336 | 337 | export default ChapterPane 338 | -------------------------------------------------------------------------------- /src/bindings.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | // This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually. 3 | 4 | /** user-defined commands **/ 5 | 6 | 7 | export const commands = { 8 | async greet(name: string) : Promise { 9 | return await TAURI_INVOKE("greet", { name }); 10 | }, 11 | async getConfig() : Promise { 12 | return await TAURI_INVOKE("get_config"); 13 | }, 14 | async saveConfig(config: Config) : Promise> { 15 | try { 16 | return { status: "ok", data: await TAURI_INVOKE("save_config", { config }) }; 17 | } catch (e) { 18 | if(e instanceof Error) throw e; 19 | else return { status: "error", error: e as any }; 20 | } 21 | }, 22 | async login(username: string, password: string) : Promise> { 23 | try { 24 | return { status: "ok", data: await TAURI_INVOKE("login", { username, password }) }; 25 | } catch (e) { 26 | if(e instanceof Error) throw e; 27 | else return { status: "error", error: e as any }; 28 | } 29 | }, 30 | async getUserProfile() : Promise> { 31 | try { 32 | return { status: "ok", data: await TAURI_INVOKE("get_user_profile") }; 33 | } catch (e) { 34 | if(e instanceof Error) throw e; 35 | else return { status: "error", error: e as any }; 36 | } 37 | }, 38 | async search(keyword: string, pageNum: number) : Promise> { 39 | try { 40 | return { status: "ok", data: await TAURI_INVOKE("search", { keyword, pageNum }) }; 41 | } catch (e) { 42 | if(e instanceof Error) throw e; 43 | else return { status: "error", error: e as any }; 44 | } 45 | }, 46 | async getComic(id: number) : Promise> { 47 | try { 48 | return { status: "ok", data: await TAURI_INVOKE("get_comic", { id }) }; 49 | } catch (e) { 50 | if(e instanceof Error) throw e; 51 | else return { status: "error", error: e as any }; 52 | } 53 | }, 54 | async downloadChapters(chapters: ChapterInfo[]) : Promise { 55 | await TAURI_INVOKE("download_chapters", { chapters }); 56 | }, 57 | async getFavorite(pageNum: number) : Promise> { 58 | try { 59 | return { status: "ok", data: await TAURI_INVOKE("get_favorite", { pageNum }) }; 60 | } catch (e) { 61 | if(e instanceof Error) throw e; 62 | else return { status: "error", error: e as any }; 63 | } 64 | }, 65 | async saveMetadata(comic: Comic) : Promise> { 66 | try { 67 | return { status: "ok", data: await TAURI_INVOKE("save_metadata", { comic }) }; 68 | } catch (e) { 69 | if(e instanceof Error) throw e; 70 | else return { status: "error", error: e as any }; 71 | } 72 | }, 73 | async getDownloadedComics() : Promise> { 74 | try { 75 | return { status: "ok", data: await TAURI_INVOKE("get_downloaded_comics") }; 76 | } catch (e) { 77 | if(e instanceof Error) throw e; 78 | else return { status: "error", error: e as any }; 79 | } 80 | }, 81 | async exportCbz(comic: Comic) : Promise> { 82 | try { 83 | return { status: "ok", data: await TAURI_INVOKE("export_cbz", { comic }) }; 84 | } catch (e) { 85 | if(e instanceof Error) throw e; 86 | else return { status: "error", error: e as any }; 87 | } 88 | }, 89 | async exportPdf(comic: Comic) : Promise> { 90 | try { 91 | return { status: "ok", data: await TAURI_INVOKE("export_pdf", { comic }) }; 92 | } catch (e) { 93 | if(e instanceof Error) throw e; 94 | else return { status: "error", error: e as any }; 95 | } 96 | }, 97 | async updateDownloadedComics() : Promise> { 98 | try { 99 | return { status: "ok", data: await TAURI_INVOKE("update_downloaded_comics") }; 100 | } catch (e) { 101 | if(e instanceof Error) throw e; 102 | else return { status: "error", error: e as any }; 103 | } 104 | }, 105 | async getLogsDirSize() : Promise> { 106 | try { 107 | return { status: "ok", data: await TAURI_INVOKE("get_logs_dir_size") }; 108 | } catch (e) { 109 | if(e instanceof Error) throw e; 110 | else return { status: "error", error: e as any }; 111 | } 112 | }, 113 | async showPathInFileManager(path: string) : Promise> { 114 | try { 115 | return { status: "ok", data: await TAURI_INVOKE("show_path_in_file_manager", { path }) }; 116 | } catch (e) { 117 | if(e instanceof Error) throw e; 118 | else return { status: "error", error: e as any }; 119 | } 120 | }, 121 | async pauseDownloadTask(chapterId: number) : Promise> { 122 | try { 123 | return { status: "ok", data: await TAURI_INVOKE("pause_download_task", { chapterId }) }; 124 | } catch (e) { 125 | if(e instanceof Error) throw e; 126 | else return { status: "error", error: e as any }; 127 | } 128 | }, 129 | async resumeDownloadTask(chapterId: number) : Promise> { 130 | try { 131 | return { status: "ok", data: await TAURI_INVOKE("resume_download_task", { chapterId }) }; 132 | } catch (e) { 133 | if(e instanceof Error) throw e; 134 | else return { status: "error", error: e as any }; 135 | } 136 | }, 137 | async cancelDownloadTask(chapterId: number) : Promise> { 138 | try { 139 | return { status: "ok", data: await TAURI_INVOKE("cancel_download_task", { chapterId }) }; 140 | } catch (e) { 141 | if(e instanceof Error) throw e; 142 | else return { status: "error", error: e as any }; 143 | } 144 | } 145 | } 146 | 147 | /** user-defined events **/ 148 | 149 | 150 | export const events = __makeEvents__<{ 151 | downloadEvent: DownloadEvent, 152 | downloadTaskEvent: DownloadTaskEvent, 153 | exportCbzEvent: ExportCbzEvent, 154 | exportPdfEvent: ExportPdfEvent, 155 | logEvent: LogEvent, 156 | updateDownloadedComicsEvent: UpdateDownloadedComicsEvent 157 | }>({ 158 | downloadEvent: "download-event", 159 | downloadTaskEvent: "download-task-event", 160 | exportCbzEvent: "export-cbz-event", 161 | exportPdfEvent: "export-pdf-event", 162 | logEvent: "log-event", 163 | updateDownloadedComicsEvent: "update-downloaded-comics-event" 164 | }) 165 | 166 | /** user-defined constants **/ 167 | 168 | 169 | 170 | /** user-defined types **/ 171 | 172 | export type ChapterInfo = { 173 | /** 174 | * 章节id 175 | */ 176 | chapterId: number; 177 | /** 178 | * 章节标题 179 | */ 180 | chapterTitle: string; 181 | /** 182 | * 此章节有多少页 183 | */ 184 | chapterSize: number; 185 | /** 186 | * 以order为前缀的章节标题 187 | */ 188 | prefixedChapterTitle: string; 189 | /** 190 | * 漫画id 191 | */ 192 | comicId: number; 193 | /** 194 | * 漫画标题 195 | */ 196 | comicTitle: string; 197 | /** 198 | * 组名(单话、单行本、番外篇) 199 | */ 200 | groupName: string; 201 | /** 202 | * 此章节对应的group有多少章节 203 | */ 204 | groupSize: number; 205 | /** 206 | * 此章节在group中的顺序 207 | */ 208 | order: number; 209 | /** 210 | * 漫画状态(连载中/已完结) 211 | */ 212 | comicStatus: string; 213 | /** 214 | * 是否已下载 215 | */ 216 | isDownloaded?: boolean | null } 217 | export type Comic = { 218 | /** 219 | * 漫画id 220 | */ 221 | id: number; 222 | /** 223 | * 漫画标题 224 | */ 225 | title: string; 226 | /** 227 | * 漫画副标题 228 | */ 229 | subtitle: string | null; 230 | /** 231 | * 封面链接 232 | */ 233 | cover: string; 234 | /** 235 | * 漫画状态(连载中/已完结) 236 | */ 237 | status: string; 238 | /** 239 | * 上次更新时间 240 | */ 241 | updateTime: string; 242 | /** 243 | * 出版年份 244 | */ 245 | year: number; 246 | /** 247 | * 地区 248 | */ 249 | region: string; 250 | /** 251 | * 类型 252 | */ 253 | genres: string[]; 254 | /** 255 | * 作者 256 | */ 257 | authors: string[]; 258 | /** 259 | * 漫画别名 260 | */ 261 | aliases: string[]; 262 | /** 263 | * 简介 264 | */ 265 | intro: string; 266 | /** 267 | * 组名(单话、单行本...)->章节信息 268 | */ 269 | groups: { [key in string]: ChapterInfo[] } } 270 | export type ComicInFavorite = { 271 | /** 272 | * 漫画id 273 | */ 274 | id: number; 275 | /** 276 | * 漫画标题 277 | */ 278 | title: string; 279 | /** 280 | * 漫画封面链接 281 | */ 282 | cover: string; 283 | /** 284 | * 最近更新时间,两种格式 285 | * - 2024-12-13 286 | * - x分钟前 287 | */ 288 | lastUpdate: string; 289 | /** 290 | * 上次阅读时间,两种格式 291 | * - 2024-12-13 292 | * - x分钟前 293 | */ 294 | lastRead: string } 295 | export type ComicInSearch = { 296 | /** 297 | * 漫画id 298 | */ 299 | id: number; 300 | /** 301 | * 漫画标题 302 | */ 303 | title: string; 304 | /** 305 | * 漫画副标题 306 | */ 307 | subtitle: string | null; 308 | /** 309 | * 封面链接 310 | */ 311 | cover: string; 312 | /** 313 | * 漫画状态(连载中/已完结) 314 | */ 315 | status: string; 316 | /** 317 | * 上次更新时间 318 | */ 319 | updateTime: string; 320 | /** 321 | * 出版年份 322 | */ 323 | year: number; 324 | /** 325 | * 地区 326 | */ 327 | region: string; 328 | /** 329 | * 类型 330 | */ 331 | genres: string[]; 332 | /** 333 | * 作者 334 | */ 335 | authors: string[]; 336 | /** 337 | * 漫画别名 338 | */ 339 | aliases: string[]; 340 | /** 341 | * 简介 342 | */ 343 | intro: string } 344 | export type CommandError = { err_title: string; err_message: string } 345 | export type Config = { cookie: string; downloadDir: string; exportDir: string; enableFileLogger: boolean; chapterConcurrency: number; chapterDownloadIntervalSec: number; imgConcurrency: number; imgDownloadIntervalSec: number; updateGetComicIntervalSec: number } 346 | export type DownloadEvent = { event: "Speed"; data: { speed: string } } | { event: "Sleeping"; data: { chapterId: number; remainingSec: number } } 347 | export type DownloadTaskEvent = { state: DownloadTaskState; chapterInfo: ChapterInfo; downloadedImgCount: number; totalImgCount: number } 348 | export type DownloadTaskState = "Pending" | "Downloading" | "Paused" | "Cancelled" | "Completed" | "Failed" 349 | export type ExportCbzEvent = { event: "Start"; data: { uuid: string; comicTitle: string; total: number } } | { event: "Progress"; data: { uuid: string; current: number } } | { event: "End"; data: { uuid: string } } 350 | export type ExportPdfEvent = { event: "CreateStart"; data: { uuid: string; comicTitle: string; total: number } } | { event: "CreateProgress"; data: { uuid: string; current: number } } | { event: "CreateEnd"; data: { uuid: string } } | { event: "MergeStart"; data: { uuid: string; comicTitle: string; total: number } } | { event: "MergeProgress"; data: { uuid: string; current: number } } | { event: "MergeEnd"; data: { uuid: string } } 351 | export type GetFavoriteResult = { comics: ComicInFavorite[]; current: number; total: number } 352 | export type JsonValue = null | boolean | number | string | JsonValue[] | { [key in string]: JsonValue } 353 | export type LogEvent = { timestamp: string; level: LogLevel; fields: { [key in string]: JsonValue }; target: string; filename: string; line_number: number } 354 | export type LogLevel = "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" 355 | export type SearchResult = { comics: ComicInSearch[]; current: number; total: number } 356 | export type UpdateDownloadedComicsEvent = { event: "GettingComics"; data: { total: number } } | { event: "ComicGot"; data: { current: number; total: number } } | { event: "DownloadTaskCreated" } 357 | export type UserProfile = { username: string; avatar: string } 358 | 359 | /** tauri-specta globals **/ 360 | 361 | import { 362 | invoke as TAURI_INVOKE, 363 | Channel as TAURI_CHANNEL, 364 | } from "@tauri-apps/api/core"; 365 | import * as TAURI_API_EVENT from "@tauri-apps/api/event"; 366 | import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; 367 | 368 | type __EventObj__ = { 369 | listen: ( 370 | cb: TAURI_API_EVENT.EventCallback, 371 | ) => ReturnType>; 372 | once: ( 373 | cb: TAURI_API_EVENT.EventCallback, 374 | ) => ReturnType>; 375 | emit: null extends T 376 | ? (payload?: T) => ReturnType 377 | : (payload: T) => ReturnType; 378 | }; 379 | 380 | export type Result = 381 | | { status: "ok"; data: T } 382 | | { status: "error"; error: E }; 383 | 384 | function __makeEvents__>( 385 | mappings: Record, 386 | ) { 387 | return new Proxy( 388 | {} as unknown as { 389 | [K in keyof T]: __EventObj__ & { 390 | (handle: __WebviewWindow__): __EventObj__; 391 | }; 392 | }, 393 | { 394 | get: (_, event) => { 395 | const name = mappings[event as keyof T]; 396 | 397 | return new Proxy((() => {}) as any, { 398 | apply: (_, __, [window]: [__WebviewWindow__]) => ({ 399 | listen: (arg: any) => window.listen(name, arg), 400 | once: (arg: any) => window.once(name, arg), 401 | emit: (arg: any) => window.emit(name, arg), 402 | }), 403 | get: (_, command: keyof __EventObj__) => { 404 | switch (command) { 405 | case "listen": 406 | return (arg: any) => TAURI_API_EVENT.listen(name, arg); 407 | case "once": 408 | return (arg: any) => TAURI_API_EVENT.once(name, arg); 409 | case "emit": 410 | return (arg: any) => TAURI_API_EVENT.emit(name, arg); 411 | } 412 | }, 413 | }); 414 | }, 415 | }, 416 | ); 417 | } 418 | -------------------------------------------------------------------------------- /src-tauri/src/commands.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, time::Duration}; 2 | 3 | use anyhow::Context; 4 | use parking_lot::RwLock; 5 | use tauri::{AppHandle, Manager, State}; 6 | use tauri_plugin_opener::OpenerExt; 7 | use tauri_specta::Event; 8 | use tokio::time::sleep; 9 | 10 | use crate::{ 11 | config::Config, 12 | download_manager::DownloadManager, 13 | errors::{CommandError, CommandResult}, 14 | events::UpdateDownloadedComicsEvent, 15 | export, 16 | extensions::AnyhowErrorToStringChain, 17 | logger, 18 | manhuagui_client::ManhuaguiClient, 19 | types::{ChapterInfo, Comic, GetFavoriteResult, SearchResult, UserProfile}, 20 | }; 21 | 22 | #[tauri::command] 23 | #[specta::specta] 24 | pub fn greet(name: &str) -> String { 25 | format!("Hello, {}! You've been greeted from Rust!", name) 26 | } 27 | 28 | #[tauri::command] 29 | #[specta::specta] 30 | #[allow(clippy::needless_pass_by_value)] 31 | pub fn get_config(config: tauri::State>) -> Config { 32 | let config = config.read().clone(); 33 | tracing::debug!("获取配置成功"); 34 | config 35 | } 36 | 37 | #[tauri::command(async)] 38 | #[specta::specta] 39 | #[allow(clippy::needless_pass_by_value)] 40 | pub fn save_config( 41 | app: AppHandle, 42 | config_state: State>, 43 | config: Config, 44 | ) -> CommandResult<()> { 45 | let enable_file_logger = config.enable_file_logger; 46 | let enable_file_logger_changed = config_state 47 | .read() 48 | .enable_file_logger 49 | .ne(&enable_file_logger); 50 | 51 | let mut config_state = config_state.write(); 52 | *config_state = config; 53 | config_state 54 | .save(&app) 55 | .map_err(|err| CommandError::from("保存配置失败", err))?; 56 | drop(config_state); 57 | tracing::debug!("保存配置成功"); 58 | 59 | if enable_file_logger_changed { 60 | if enable_file_logger { 61 | logger::reload_file_logger() 62 | .map_err(|err| CommandError::from("重新加载文件日志失败", err))?; 63 | } else { 64 | logger::disable_file_logger() 65 | .map_err(|err| CommandError::from("禁用文件日志失败", err))?; 66 | } 67 | } 68 | 69 | Ok(()) 70 | } 71 | 72 | #[tauri::command(async)] 73 | #[specta::specta] 74 | pub async fn login( 75 | manhuagui_client: State<'_, ManhuaguiClient>, 76 | username: String, 77 | password: String, 78 | ) -> CommandResult { 79 | let cookie = manhuagui_client 80 | .login(&username, &password) 81 | .await 82 | .map_err(|err| CommandError::from("使用账号密码登录失败", err))?; 83 | tracing::debug!("登录成功"); 84 | Ok(cookie) 85 | } 86 | 87 | #[tauri::command(async)] 88 | #[specta::specta] 89 | pub async fn get_user_profile( 90 | manhuagui_client: State<'_, ManhuaguiClient>, 91 | ) -> CommandResult { 92 | let user_profile = manhuagui_client 93 | .get_user_profile() 94 | .await 95 | .map_err(|err| CommandError::from("获取用户信息失败", err))?; 96 | tracing::debug!("获取用户信息成功"); 97 | Ok(user_profile) 98 | } 99 | 100 | #[tauri::command(async)] 101 | #[specta::specta] 102 | pub async fn search( 103 | manhuagui_client: State<'_, ManhuaguiClient>, 104 | keyword: String, 105 | page_num: i64, 106 | ) -> CommandResult { 107 | let search_result = manhuagui_client 108 | .search(&keyword, page_num) 109 | .await 110 | .map_err(|err| CommandError::from("搜索失败", err))?; 111 | tracing::debug!("搜索成功"); 112 | Ok(search_result) 113 | } 114 | 115 | #[tauri::command(async)] 116 | #[specta::specta] 117 | pub async fn get_comic( 118 | manhuagui_client: State<'_, ManhuaguiClient>, 119 | id: i64, 120 | ) -> CommandResult { 121 | let comic = manhuagui_client 122 | .get_comic(id) 123 | .await 124 | .map_err(|err| CommandError::from(&format!("获取漫画`{id}`的信息失败"), err))?; 125 | tracing::debug!("获取漫画信息成功"); 126 | Ok(comic) 127 | } 128 | 129 | #[allow(clippy::needless_pass_by_value)] 130 | #[tauri::command(async)] 131 | #[specta::specta] 132 | pub fn download_chapters(download_manager: State, chapters: Vec) { 133 | for chapter in chapters { 134 | download_manager.create_download_task(chapter); 135 | } 136 | tracing::debug!("下载任务创建成功"); 137 | } 138 | 139 | #[tauri::command(async)] 140 | #[specta::specta] 141 | pub async fn get_favorite( 142 | manhuagui_client: State<'_, ManhuaguiClient>, 143 | page_num: i64, 144 | ) -> CommandResult { 145 | let get_favorite_result = manhuagui_client 146 | .get_favorite(page_num) 147 | .await 148 | .map_err(|err| CommandError::from("获取收藏夹失败", err))?; 149 | tracing::debug!("获取收藏夹成功"); 150 | Ok(get_favorite_result) 151 | } 152 | 153 | #[tauri::command(async)] 154 | #[specta::specta] 155 | #[allow(clippy::needless_pass_by_value)] 156 | pub fn save_metadata(config: State>, mut comic: Comic) -> CommandResult<()> { 157 | // 将所有章节的is_downloaded字段设置为None,这样能使is_downloaded字段在序列化时被忽略 158 | for chapter_infos in comic.groups.values_mut() { 159 | for chapter_info in chapter_infos.iter_mut() { 160 | chapter_info.is_downloaded = None; 161 | } 162 | } 163 | 164 | let comic_title = &comic.title; 165 | let comic_json = serde_json::to_string_pretty(&comic) 166 | .context("将Comic序列化为json失败") 167 | .map_err(|err| CommandError::from(&format!("`{comic_title}`的元数据保存失败"), err))?; 168 | 169 | let download_dir = config.read().download_dir.clone(); 170 | let metadata_dir = download_dir.join(comic_title); 171 | let metadata_path = metadata_dir.join("元数据.json"); 172 | 173 | std::fs::create_dir_all(&metadata_dir) 174 | .context(format!("创建目录`{metadata_dir:?}`失败")) 175 | .map_err(|err| CommandError::from(&format!("`{comic_title}`的元数据保存失败"), err))?; 176 | 177 | std::fs::write(&metadata_path, comic_json) 178 | .context(format!("写入文件`{metadata_path:?}`失败")) 179 | .map_err(|err| CommandError::from(&format!("`{comic_title}`的元数据保存失败"), err))?; 180 | 181 | tracing::debug!("`{comic_title}`的元数据保存成功"); 182 | Ok(()) 183 | } 184 | 185 | #[tauri::command(async)] 186 | #[specta::specta] 187 | #[allow(clippy::needless_pass_by_value)] 188 | pub fn get_downloaded_comics( 189 | app: AppHandle, 190 | config: State>, 191 | ) -> CommandResult> { 192 | let download_dir = config.read().download_dir.clone(); 193 | // 遍历下载目录,获取所有元数据文件的路径和修改时间 194 | let mut metadata_path_with_modify_time = std::fs::read_dir(&download_dir) 195 | .context(format!("读取下载目录`{download_dir:?}`失败")) 196 | .map_err(|err| CommandError::from("获取已下载的漫画失败", err))? 197 | .filter_map(Result::ok) 198 | .filter_map(|entry| { 199 | let metadata_path = entry.path().join("元数据.json"); 200 | if !metadata_path.exists() { 201 | return None; 202 | } 203 | let modify_time = metadata_path.metadata().ok()?.modified().ok()?; 204 | Some((metadata_path, modify_time)) 205 | }) 206 | .collect::>(); 207 | // 按照文件修改时间排序,最新的排在最前面 208 | metadata_path_with_modify_time.sort_by(|(_, a), (_, b)| b.cmp(a)); 209 | // 从元数据文件中读取Comic 210 | let downloaded_comics = metadata_path_with_modify_time 211 | .iter() 212 | .filter_map(|(metadata_path, _)| { 213 | match Comic::from_metadata(&app, metadata_path).map_err(anyhow::Error::from) { 214 | Ok(comic) => Some(comic), 215 | Err(err) => { 216 | let err_title = format!("读取元数据文件`{metadata_path:?}`失败"); 217 | let string_chain = err.to_string_chain(); 218 | tracing::error!(err_title, message = string_chain); 219 | None 220 | } 221 | } 222 | }) 223 | .collect::>(); 224 | 225 | tracing::debug!("获取已下载的漫画成功"); 226 | Ok(downloaded_comics) 227 | } 228 | 229 | #[tauri::command(async)] 230 | #[specta::specta] 231 | #[allow(clippy::needless_pass_by_value)] 232 | pub fn export_cbz(app: AppHandle, comic: Comic) -> CommandResult<()> { 233 | let comic_title = comic.title.clone(); 234 | export::cbz(&app, comic) 235 | .map_err(|err| CommandError::from(&format!("漫画`{comic_title}`导出cbz失败"), err))?; 236 | tracing::debug!("漫画`{comic_title}`导出cbz成功"); 237 | Ok(()) 238 | } 239 | 240 | #[tauri::command(async)] 241 | #[specta::specta] 242 | #[allow(clippy::needless_pass_by_value)] 243 | pub fn export_pdf(app: AppHandle, comic: Comic) -> CommandResult<()> { 244 | let comic_title = comic.title.clone(); 245 | export::pdf(&app, comic) 246 | .map_err(|err| CommandError::from(&format!("漫画`{comic_title}`导出pdf失败"), err))?; 247 | tracing::debug!("漫画`{comic_title}`导出pdf成功"); 248 | Ok(()) 249 | } 250 | 251 | #[allow(clippy::cast_possible_wrap)] 252 | #[tauri::command(async)] 253 | #[specta::specta] 254 | pub async fn update_downloaded_comics( 255 | app: AppHandle, 256 | download_manager: State<'_, DownloadManager>, 257 | ) -> CommandResult<()> { 258 | // 从下载目录中获取已下载的漫画 259 | let downloaded_comics = get_downloaded_comics(app.clone(), app.state::>())?; 260 | // 用于存储最新的漫画信息 261 | let mut latest_comics = Vec::new(); 262 | // 发送正在获取漫画事件 263 | let total = downloaded_comics.len() as i64; 264 | let _ = UpdateDownloadedComicsEvent::GettingComics { total }.emit(&app); 265 | 266 | let update_get_comic_interval_sec = app 267 | .state::>() 268 | .read() 269 | .update_get_comic_interval_sec; 270 | 271 | // 获取已下载漫画的最新信息,不用并发是有意为之,防止被封IP 272 | for (i, downloaded_comic) in downloaded_comics.iter().enumerate() { 273 | // 获取最新的漫画信息 274 | let comic = get_comic(app.state::(), downloaded_comic.id).await?; 275 | // 将最新的漫画信息保存到元数据文件 276 | save_metadata(app.state::>(), comic.clone())?; 277 | 278 | latest_comics.push(comic); 279 | // 发送获取到漫画事件 280 | let current = i as i64 + 1; 281 | let _ = UpdateDownloadedComicsEvent::ComicGot { current, total }.emit(&app); 282 | sleep(Duration::from_secs(update_get_comic_interval_sec)).await; 283 | } 284 | // 至此,已下载的漫画的最新信息已获取完毕 285 | let chapters_to_download = latest_comics 286 | .into_iter() 287 | .filter_map(|comic| { 288 | // 先过滤出每个漫画中至少有一个已下载章节的组 289 | let downloaded_group = comic 290 | .groups 291 | .into_iter() 292 | .filter_map(|(group_name, chapter_infos)| { 293 | // 检查当前组是否有已下载章节,如果有,则返回组路径和章节信息,否则返回None(跳过) 294 | chapter_infos 295 | .iter() 296 | .any(|chapter_info| chapter_info.is_downloaded.unwrap_or(false)) 297 | .then_some((group_name, chapter_infos)) 298 | }) 299 | .collect::>(); 300 | // 如果所有组都没有已下载章节,则跳过 301 | if downloaded_group.is_empty() { 302 | return None; 303 | } 304 | Some(downloaded_group) 305 | }) 306 | .flat_map(|downloaded_groups| { 307 | // 从至少有一个已下载章节的组中过滤出其中未下载的章节 308 | downloaded_groups 309 | .into_values() 310 | .flat_map(|chapter_infos| { 311 | chapter_infos 312 | .into_iter() 313 | .filter(|chapter_info| !chapter_info.is_downloaded.unwrap_or(false)) 314 | }) 315 | .collect::>() 316 | }) 317 | .collect::>(); 318 | // 下载未下载章节 319 | download_chapters(download_manager, chapters_to_download); 320 | // 发送下载任务创建完成事件 321 | let _ = UpdateDownloadedComicsEvent::DownloadTaskCreated.emit(&app); 322 | 323 | tracing::debug!("为已下载的漫画创建更新任务成功"); 324 | Ok(()) 325 | } 326 | 327 | #[allow(clippy::needless_pass_by_value)] 328 | #[tauri::command(async)] 329 | #[specta::specta] 330 | pub fn get_logs_dir_size(app: AppHandle) -> CommandResult { 331 | let logs_dir = logger::logs_dir(&app) 332 | .context("获取日志目录失败") 333 | .map_err(|err| CommandError::from("获取日志目录大小失败", err))?; 334 | let logs_dir_size = std::fs::read_dir(&logs_dir) 335 | .context(format!("读取日志目录`{logs_dir:?}`失败")) 336 | .map_err(|err| CommandError::from("获取日志目录大小失败", err))? 337 | .filter_map(Result::ok) 338 | .filter_map(|entry| entry.metadata().ok()) 339 | .map(|metadata| metadata.len()) 340 | .sum::(); 341 | tracing::debug!("获取日志目录大小成功"); 342 | Ok(logs_dir_size) 343 | } 344 | 345 | #[allow(clippy::needless_pass_by_value)] 346 | #[tauri::command(async)] 347 | #[specta::specta] 348 | pub fn show_path_in_file_manager(app: AppHandle, path: &str) -> CommandResult<()> { 349 | app.opener() 350 | .reveal_item_in_dir(path) 351 | .context(format!("在文件管理器中打开`{path}`失败")) 352 | .map_err(|err| CommandError::from("在文件管理器中打开失败", err))?; 353 | tracing::debug!("在文件管理器中打开成功"); 354 | Ok(()) 355 | } 356 | 357 | #[allow(clippy::needless_pass_by_value)] 358 | #[tauri::command(async)] 359 | #[specta::specta] 360 | pub fn pause_download_task( 361 | download_manager: State, 362 | chapter_id: i64, 363 | ) -> CommandResult<()> { 364 | download_manager 365 | .pause_download_task(chapter_id) 366 | .map_err(|err| CommandError::from(&format!("暂停章节ID为`{chapter_id}`的下载任务"), err))?; 367 | tracing::debug!("暂停章节ID为`{chapter_id}`的下载任务成功"); 368 | Ok(()) 369 | } 370 | 371 | #[allow(clippy::needless_pass_by_value)] 372 | #[tauri::command(async)] 373 | #[specta::specta] 374 | pub fn resume_download_task( 375 | download_manager: State, 376 | chapter_id: i64, 377 | ) -> CommandResult<()> { 378 | download_manager 379 | .resume_download_task(chapter_id) 380 | .map_err(|err| { 381 | CommandError::from(&format!("恢复章节ID为`{chapter_id}`的下载任务失败"), err) 382 | })?; 383 | tracing::debug!("恢复章节ID为`{chapter_id}`的下载任务成功"); 384 | Ok(()) 385 | } 386 | 387 | #[allow(clippy::needless_pass_by_value)] 388 | #[tauri::command(async)] 389 | #[specta::specta] 390 | pub fn cancel_download_task( 391 | download_manager: State, 392 | chapter_id: i64, 393 | ) -> CommandResult<()> { 394 | download_manager 395 | .cancel_download_task(chapter_id) 396 | .map_err(|err| { 397 | CommandError::from(&format!("取消章节ID为`{chapter_id}`的下载任务失败"), err) 398 | })?; 399 | tracing::debug!("取消章节ID为`{chapter_id}`的下载任务成功"); 400 | Ok(()) 401 | } 402 | -------------------------------------------------------------------------------- /src-tauri/src/types/comic.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, path::Path}; 2 | 3 | use anyhow::{anyhow, Context}; 4 | use parking_lot::RwLock; 5 | use scraper::{ElementRef, Html, Selector}; 6 | use serde::{Deserialize, Serialize}; 7 | use specta::Type; 8 | use tauri::{AppHandle, Manager}; 9 | 10 | use crate::{config::Config, extensions::ToAnyhow, utils::filename_filter}; 11 | 12 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 13 | #[serde(rename_all = "camelCase")] 14 | #[allow(clippy::struct_field_names)] 15 | pub struct Comic { 16 | /// 漫画id 17 | pub id: i64, 18 | /// 漫画标题 19 | pub title: String, 20 | /// 漫画副标题 21 | pub subtitle: Option, 22 | /// 封面链接 23 | pub cover: String, 24 | /// 漫画状态(连载中/已完结) 25 | pub status: String, 26 | /// 上次更新时间 27 | pub update_time: String, 28 | /// 出版年份 29 | pub year: i64, 30 | /// 地区 31 | pub region: String, 32 | /// 类型 33 | pub genres: Vec, 34 | /// 作者 35 | pub authors: Vec, 36 | /// 漫画别名 37 | pub aliases: Vec, 38 | /// 简介 39 | pub intro: String, 40 | /// 组名(单话、单行本...)->章节信息 41 | pub groups: HashMap>, 42 | } 43 | 44 | impl Comic { 45 | pub fn from_html(app: &AppHandle, html: &str) -> anyhow::Result { 46 | let document = Html::parse_document(html); 47 | 48 | let hidden_fragment = match document 49 | .select(&Selector::parse("#__VIEWSTATE").to_anyhow()?) 50 | .next() 51 | { 52 | Some(hidden_input) => { 53 | // 有隐藏数据 54 | let compressed_data = hidden_input 55 | .value() 56 | .attr("value") 57 | .context("没有在包含隐藏数据的中找到value属性")?; 58 | 59 | let decompressed_data = lz_str::decompress_from_base64(compressed_data) 60 | .context("lzstring解压缩失败")?; 61 | 62 | let hidden_html = String::from_utf16(&decompressed_data) 63 | .context("lzstring解压缩后的数据不是utf-16字符串")?; 64 | 65 | Some(Html::parse_fragment(&hidden_html)) 66 | } 67 | None => None, 68 | }; 69 | 70 | let book_detail_div = document 71 | .select(&Selector::parse(".book-detail").to_anyhow()?) 72 | .next() 73 | .context("没有找到漫画详情的
")?; 74 | 75 | let id = document 76 | .select(&Selector::parse(".crumb > a:nth-last-child(1)").to_anyhow()?) 77 | .next() 78 | .context("没有找到漫画链接的")? 79 | .value() 80 | .attr("href") 81 | .context("没有在漫画链接的中找到href属性")? 82 | .trim_start_matches("/comic/") 83 | .trim_end_matches('/') 84 | .parse::() 85 | .context("漫画id不是整数")?; 86 | 87 | let (title, subtitle) = get_title_and_subtitle(&book_detail_div)?; 88 | 89 | let cover_src = document 90 | .select(&Selector::parse(".hcover img").to_anyhow()?) 91 | .next() 92 | .context("没有找到封面的")? 93 | .value() 94 | .attr("src") 95 | .context("没有在封面的中找到src属性")? 96 | .to_string(); 97 | let cover = format!("https:{cover_src}"); 98 | 99 | let detail_lis = book_detail_div 100 | .select(&Selector::parse(".detail-list > li").to_anyhow()?) 101 | .collect::>(); 102 | 103 | let li = detail_lis.first().context("没有找到出版年份和地区的
  • ")?; 104 | let (year, region) = get_year_and_region(li)?; 105 | 106 | let li = detail_lis.get(1).context("没有找到漫画类型和作者的
  • ")?; 107 | let (genres, authors) = get_genres_and_authors(li)?; 108 | 109 | let li = detail_lis.get(2).context("没有找到别名的
  • ")?; 110 | let aliases = li 111 | .select(&Selector::parse("span > a").to_anyhow()?) 112 | .filter_map(|a| a.text().next().map(|text| text.trim().to_string())) 113 | .collect::>(); 114 | 115 | let li = detail_lis.get(3).context("没有找到状态和更新时间的
  • ")?; 116 | let (status, update_time) = get_status_and_update_time(li)?; 117 | 118 | let intro = book_detail_div 119 | .select(&Selector::parse("#intro-cut").to_anyhow()?) 120 | .next() 121 | .context("没有找到简介的
    ")? 122 | .text() 123 | .next() 124 | .context("没有在简介的
    中找到文本")? 125 | .trim() 126 | .to_string(); 127 | 128 | let groups = if let Some(fragment) = hidden_fragment { 129 | get_groups(app, &fragment.root_element(), id, &title, &status)? 130 | } else { 131 | let chapter_div = document 132 | .select(&Selector::parse(".chapter").to_anyhow()?) 133 | .next() 134 | .context("没有找到章节列表的
    ")?; 135 | 136 | get_groups(app, &chapter_div, id, &title, &status)? 137 | }; 138 | 139 | Ok(Comic { 140 | id, 141 | title, 142 | subtitle, 143 | cover, 144 | status, 145 | update_time, 146 | year, 147 | region, 148 | genres, 149 | authors, 150 | aliases, 151 | intro, 152 | groups, 153 | }) 154 | } 155 | 156 | pub fn from_metadata(app: &AppHandle, metadata_path: &Path) -> anyhow::Result { 157 | let comic_json = std::fs::read_to_string(metadata_path).context(format!( 158 | "从元数据转为Comic失败,读取元数据文件 {metadata_path:?} 失败" 159 | ))?; 160 | let mut comic = serde_json::from_str::(&comic_json).context(format!( 161 | "从元数据转为Comic失败,将 {metadata_path:?} 反序列化为Comic失败" 162 | ))?; 163 | // 这个comic中的is_downloaded字段是None,需要重新计算 164 | for chapter_infos in comic.groups.values_mut() { 165 | for chapter_info in chapter_infos.iter_mut() { 166 | let comic_title = &comic.title; 167 | let group_name = &chapter_info.group_name; 168 | let prefixed_chapter_title = &chapter_info.prefixed_chapter_title; 169 | let is_downloaded = ChapterInfo::get_is_downloaded( 170 | app, 171 | comic_title, 172 | group_name, 173 | prefixed_chapter_title, 174 | ); 175 | chapter_info.is_downloaded = Some(is_downloaded); 176 | } 177 | } 178 | Ok(comic) 179 | } 180 | } 181 | 182 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 183 | #[serde(rename_all = "camelCase")] 184 | pub struct ChapterInfo { 185 | /// 章节id 186 | pub chapter_id: i64, 187 | /// 章节标题 188 | pub chapter_title: String, 189 | /// 此章节有多少页 190 | pub chapter_size: i64, 191 | /// 以order为前缀的章节标题 192 | pub prefixed_chapter_title: String, 193 | /// 漫画id 194 | pub comic_id: i64, 195 | /// 漫画标题 196 | pub comic_title: String, 197 | /// 组名(单话、单行本、番外篇) 198 | pub group_name: String, 199 | /// 此章节对应的group有多少章节 200 | pub group_size: i64, 201 | /// 此章节在group中的顺序 202 | pub order: f64, 203 | /// 漫画状态(连载中/已完结) 204 | pub comic_status: String, 205 | /// 是否已下载 206 | #[serde(skip_serializing_if = "Option::is_none")] 207 | pub is_downloaded: Option, 208 | } 209 | 210 | impl ChapterInfo { 211 | pub fn get_is_downloaded( 212 | app: &AppHandle, 213 | comic_title: &str, 214 | group_name: &str, 215 | prefixed_chapter_title: &str, 216 | ) -> bool { 217 | app.state::>() 218 | .read() 219 | .download_dir 220 | .join(comic_title) 221 | .join(group_name) 222 | .join(prefixed_chapter_title) 223 | .exists() 224 | } 225 | } 226 | 227 | fn get_title_and_subtitle( 228 | book_detail_div: &ElementRef, 229 | ) -> anyhow::Result<(String, Option)> { 230 | let title = book_detail_div 231 | .select(&Selector::parse(".book-title h1").to_anyhow()?) 232 | .next() 233 | .context("没有找到漫画标题的

    ")? 234 | .text() 235 | .next() 236 | .context("没有在漫画标题的

    中找到文本")? 237 | .trim() 238 | .to_string(); 239 | let title = filename_filter(&title); 240 | 241 | let subtitle = book_detail_div 242 | .select(&Selector::parse(".book-title h2").to_anyhow()?) 243 | .next() 244 | .and_then(|h2| h2.text().next()) 245 | .map(|text| text.trim().to_string()); 246 | 247 | Ok((title, subtitle)) 248 | } 249 | 250 | fn get_year_and_region(li: &ElementRef) -> anyhow::Result<(i64, String)> { 251 | let spans = li 252 | .select(&Selector::parse("span").to_anyhow()?) 253 | .collect::>(); 254 | let a_selector = Selector::parse("a").to_anyhow()?; 255 | 256 | let year = spans 257 | .first() 258 | .context("没有找到出版年份的")? 259 | .select(&a_selector) 260 | .next() 261 | .context("没有找到出版年份的")? 262 | .text() 263 | .next() 264 | .context("没有在出版年份的中找到文本")? 265 | .trim() 266 | .trim_end_matches('年') 267 | .parse::() 268 | .context("出版年份不是整数")?; 269 | 270 | let region = spans 271 | .get(1) 272 | .context("没有找到地区的")? 273 | .select(&a_selector) 274 | .next() 275 | .context("没有找到地区的")? 276 | .value() 277 | .attr("title") 278 | .context("没有在地区的中找到title属性")? 279 | .to_string(); 280 | 281 | Ok((year, region)) 282 | } 283 | 284 | fn get_genres_and_authors(li: &ElementRef) -> anyhow::Result<(Vec, Vec)> { 285 | let spans = li 286 | .select(&Selector::parse("span").to_anyhow()?) 287 | .collect::>(); 288 | let a_selector = Selector::parse("a").to_anyhow()?; 289 | 290 | let genres = spans 291 | .first() 292 | .context("没有找到漫画类型的")? 293 | .select(&a_selector) 294 | .filter_map(|a| a.text().next().map(|text| text.trim().to_string())) 295 | .collect::>(); 296 | 297 | let authors = spans 298 | .get(1) 299 | .context("没有找到作者的")? 300 | .select(&a_selector) 301 | .filter_map(|a| a.value().attr("title").map(str::to_string)) 302 | .collect::>(); 303 | 304 | Ok((genres, authors)) 305 | } 306 | 307 | fn get_status_and_update_time(li: &ElementRef) -> anyhow::Result<(String, String)> { 308 | let spans = li 309 | .select(&Selector::parse("span > span").to_anyhow()?) 310 | .collect::>(); 311 | 312 | let status = spans 313 | .first() 314 | .context("没有找到漫画状态的")? 315 | .text() 316 | .next() 317 | .context("没有在漫画状态的中找到文本")? 318 | .trim() 319 | .to_string(); 320 | 321 | let update_time = spans 322 | .get(1) 323 | .context("没有找到更新时间的")? 324 | .text() 325 | .next() 326 | .context("没有在更新时间的中找到文本")? 327 | .trim() 328 | .to_string(); 329 | 330 | Ok((status, update_time)) 331 | } 332 | 333 | #[allow(clippy::cast_possible_wrap)] 334 | fn get_groups( 335 | app: &AppHandle, 336 | chapter_div: &ElementRef, 337 | comic_id: i64, 338 | comic_title: &str, 339 | comic_status: &str, 340 | ) -> anyhow::Result>> { 341 | let h4s = chapter_div 342 | .select(&Selector::parse("h4").to_anyhow()?) 343 | .collect::>(); 344 | 345 | let chapter_divs = chapter_div 346 | .select(&Selector::parse(".chapter-list").to_anyhow()?) 347 | .collect::>(); 348 | 349 | if h4s.len() != chapter_divs.len() { 350 | return Err(anyhow!("章节组名和章节列表数量不一致")); 351 | } 352 | 353 | let mut groups = HashMap::new(); 354 | for (h4, chapter_list_div) in h4s.iter().zip(chapter_divs.iter()) { 355 | let group_name = h4 356 | .text() 357 | .next() 358 | .context("没有在章节组名的

    中找到文本")? 359 | .trim() 360 | .to_string(); 361 | let group_name = filename_filter(&group_name); 362 | 363 | let uls = chapter_list_div 364 | .select(&Selector::parse("ul").to_anyhow()?) 365 | .collect::>(); 366 | 367 | let mut order = 0.0; 368 | // 统计一共有多少个li 369 | let group_size = chapter_list_div 370 | .select(&Selector::parse("li").to_anyhow()?) 371 | .count() as i64; 372 | 373 | let mut chapter_infos = Vec::new(); 374 | for ul in uls { 375 | let mut lis = ul 376 | .select(&Selector::parse("li").to_anyhow()?) 377 | .collect::>(); 378 | lis.reverse(); 379 | 380 | for li in lis { 381 | order += 1.0; 382 | let a = li 383 | .select(&Selector::parse("a").to_anyhow()?) 384 | .next() 385 | .context("没有找到章节的")?; 386 | 387 | let chapter_id = a 388 | .value() 389 | .attr("href") 390 | .context("没有在章节的中找到href属性")? 391 | .trim_start_matches(&format!("/comic/{comic_id}/")) 392 | .trim_end_matches(".html") 393 | .parse::() 394 | .context("章节id不是整数")?; 395 | 396 | let chapter_title = a 397 | .value() 398 | .attr("title") 399 | .context("没有在章节的中找到title属性")? 400 | .to_string(); 401 | let chapter_title = filename_filter(&chapter_title); 402 | 403 | let prefixed_chapter_title = format!("{order} {chapter_title}"); 404 | 405 | let chapter_size = a 406 | .select(&Selector::parse("span > i").to_anyhow()?) 407 | .next() 408 | .context("没有找到章节的")? 409 | .text() 410 | .next() 411 | .context("没有在章节的中找到文本")? 412 | .trim() 413 | .trim_end_matches('p') 414 | .parse::() 415 | .context("章节页数不是整数")?; 416 | 417 | let is_downloaded = 418 | get_is_downloaded(app, comic_title, &group_name, &prefixed_chapter_title); 419 | 420 | chapter_infos.push(ChapterInfo { 421 | chapter_id, 422 | chapter_title, 423 | chapter_size, 424 | prefixed_chapter_title, 425 | comic_id, 426 | comic_title: comic_title.to_string(), 427 | group_name: group_name.clone(), 428 | group_size, 429 | order, 430 | comic_status: comic_status.to_string(), 431 | is_downloaded: Some(is_downloaded), 432 | }); 433 | } 434 | } 435 | 436 | groups.insert(group_name, chapter_infos); 437 | } 438 | 439 | Ok(groups) 440 | } 441 | 442 | fn get_is_downloaded( 443 | app: &AppHandle, 444 | comic_title: &str, 445 | group_name: &str, 446 | prefixed_chapter_title: &str, 447 | ) -> bool { 448 | app.state::>() 449 | .read() 450 | .download_dir 451 | .join(comic_title) 452 | .join(group_name) 453 | .join(prefixed_chapter_title) 454 | .exists() 455 | } 456 | --------------------------------------------------------------------------------