├── .github └── workflows │ ├── CI-Server.yml │ └── CI-Web.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── autodl.sh ├── docker-compose.yml ├── script ├── compress.py ├── package.json ├── pnpm-lock.yaml ├── src │ ├── CalculateWenkuEdit.ts │ ├── ExportSakuraIncorrectCase.ts │ ├── ExportWebChapter.ts │ ├── RemoveWebNovel.ts │ ├── config.ts │ ├── init │ │ ├── DbEs.ts │ │ ├── DbMongo.ts │ │ ├── EnsureMongoIndex.ts │ │ ├── GenerateEsIndexWeb.ts │ │ └── GenerateEsIndexWenku.ts │ └── main.ts └── tsconfig.json ├── server ├── .dockerignore ├── Dockerfile ├── build.gradle.kts ├── gradle.properties ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle.kts └── src │ ├── main │ ├── kotlin │ │ ├── Application.kt │ │ ├── api │ │ │ ├── HttpException.kt │ │ │ ├── RouteArticle.kt │ │ │ ├── RouteAuth.kt │ │ │ ├── RouteComment.kt │ │ │ ├── RouteOperationHistory.kt │ │ │ ├── RouteUser.kt │ │ │ ├── RouteUserFavoredWeb.kt │ │ │ ├── RouteUserFavoredWenku.kt │ │ │ ├── RouteUserReadHistoryWeb.kt │ │ │ ├── RouteWebNovel.kt │ │ │ ├── RouteWenkuNovel.kt │ │ │ ├── UtilPage.kt │ │ │ ├── model │ │ │ │ └── WebNovel.kt │ │ │ └── plugins │ │ │ │ ├── Authentication.kt │ │ │ │ ├── ContentNegotiation.kt │ │ │ │ └── RateLimit.kt │ │ ├── infra │ │ │ ├── ElasticSearchClient.kt │ │ │ ├── EmailClient.kt │ │ │ ├── MongoClient.kt │ │ │ ├── RedisClient.kt │ │ │ ├── TempFileClient.kt │ │ │ ├── article │ │ │ │ ├── Article.kt │ │ │ │ └── ArticleRepository.kt │ │ │ ├── comment │ │ │ │ ├── Comment.kt │ │ │ │ └── CommentRepository.kt │ │ │ ├── common │ │ │ │ ├── Common.kt │ │ │ │ ├── NovelFile.kt │ │ │ │ └── Translation.kt │ │ │ ├── oplog │ │ │ │ ├── OperationHistory.kt │ │ │ │ └── OperationHistoryRepository.kt │ │ │ ├── user │ │ │ │ ├── User.kt │ │ │ │ ├── UserCodeRepository.kt │ │ │ │ ├── UserFavoredRepository.kt │ │ │ │ └── UserRepository.kt │ │ │ ├── web │ │ │ │ ├── WebNovel.kt │ │ │ │ ├── datasource │ │ │ │ │ ├── WebNovelEsDataSource.kt │ │ │ │ │ ├── WebNovelHttpDataSource.kt │ │ │ │ │ └── providers │ │ │ │ │ │ ├── Alphapolis.kt │ │ │ │ │ │ ├── Base.kt │ │ │ │ │ │ ├── Hameln.kt │ │ │ │ │ │ ├── Kakuyomu.kt │ │ │ │ │ │ ├── Novelup.kt │ │ │ │ │ │ ├── Pixiv.kt │ │ │ │ │ │ └── Syosetu.kt │ │ │ │ └── repository │ │ │ │ │ ├── WebNovelChapterRepository.kt │ │ │ │ │ ├── WebNovelFavoredRepository.kt │ │ │ │ │ ├── WebNovelFileRepository.kt │ │ │ │ │ ├── WebNovelMetadataRepository.kt │ │ │ │ │ ├── WebNovelReadHistoryRepository.kt │ │ │ │ │ ├── makeEpub.kt │ │ │ │ │ └── makeTxt.kt │ │ │ └── wenku │ │ │ │ ├── WenkuNovel.kt │ │ │ │ ├── datasource │ │ │ │ ├── WenkuNovelEsDataSource.kt │ │ │ │ └── WenkuNovelVolumeDiskDataSource.kt │ │ │ │ └── repository │ │ │ │ ├── WenkuNovelFavoredRepository.kt │ │ │ │ ├── WenkuNovelMetadataRepository.kt │ │ │ │ └── WenkuNovelVolumeRepository.kt │ │ └── util │ │ │ ├── PBKDF2.kt │ │ │ ├── SerialName.kt │ │ │ ├── UnreachableException.kt │ │ │ └── epub │ │ │ ├── Epub.kt │ │ │ ├── EpubBook.kt │ │ │ └── EpubResource.kt │ └── resources │ │ └── logback.xml │ └── test │ └── kotlin │ ├── KoinExtension.kt │ ├── infra │ ├── MongoClientTest.kt │ └── provider │ │ └── providers │ │ ├── AlphapolisTest.kt │ │ ├── HamelnTest.kt │ │ ├── KakuyomuTest.kt │ │ ├── NovelupTest.kt │ │ ├── PixivTest.kt │ │ └── SyosetuTest.kt │ └── util │ └── Epub.kt ├── web-extension ├── Makefile └── src │ ├── icons │ ├── 128.png │ ├── 16.png │ ├── 19.png │ ├── 256.png │ ├── 32.png │ ├── 38.png │ ├── 48.png │ ├── 512.png │ └── 64.png │ ├── manifest.json │ ├── rulesets │ ├── allow-credentials.json │ ├── allow-headers.json │ ├── csp.json │ ├── overwrite-origin.json │ └── x-frame.json │ └── worker.js └── web ├── .dockerignore ├── .husky └── pre-commit ├── .prettierrc ├── Caddyfile ├── Dockerfile ├── eslint.config.js ├── index.html ├── package.json ├── pnpm-lock.yaml ├── public ├── favicon.ico ├── icon.svg └── robots.txt ├── src ├── App.vue ├── components │ ├── Bulletin.vue │ ├── CA.vue │ ├── CActionWrapper.vue │ ├── CButton.vue │ ├── CButtonConfirm.vue │ ├── CDrawerLeft.vue │ ├── CDrawerRight.vue │ ├── CIconButton.vue │ ├── CLayout.vue │ ├── CModal.vue │ ├── CPage.vue │ ├── CRadioGroup.vue │ ├── CResult.vue │ ├── CSelectOverlay.vue │ ├── CTaskCard.vue │ ├── CXScrollbar.vue │ ├── ChapterTocItem.vue │ ├── ChapterTocList.vue │ ├── EmailButton.vue │ ├── GlossaryButton.vue │ ├── ImageCard.vue │ ├── OrderSort.vue │ ├── RobotIcon.vue │ ├── SearchInput.vue │ ├── SectionHeader.vue │ ├── TranslateTask.vue │ ├── TranslatorCheck.vue │ ├── comment │ │ ├── CommentEditor.vue │ │ ├── CommentItem.vue │ │ ├── CommentList.vue │ │ └── CommentThread.vue │ ├── markdown │ │ ├── MarkdownEditor.vue │ │ ├── MarkdownGuideModal.vue │ │ ├── MarkdownToolbar.vue │ │ ├── MarkdownToolbarButton.vue │ │ └── MarkdownView.vue │ └── overlay │ │ └── DropZone.vue ├── data │ ├── CachedSegRepository.ts │ ├── api │ │ ├── ArticleRepository.ts │ │ ├── CommentRepository.ts │ │ ├── OperationRepository.ts │ │ ├── UserRepository.ts │ │ ├── WebNovelRepository.ts │ │ ├── WenkuNovelRepository.ts │ │ ├── client.ts │ │ └── index.ts │ ├── auth │ │ ├── Auth.ts │ │ ├── AuthApi.ts │ │ └── AuthRepository.ts │ ├── favored │ │ ├── Favored.ts │ │ ├── FavoredApi.ts │ │ └── FavoredRepository.ts │ ├── index.ts │ ├── local │ │ ├── CreateVolume.ts │ │ ├── EpubParser.ts │ │ ├── GetTranslationFile.ts │ │ ├── LocalVolumeDao.ts │ │ ├── LocalVolumeRepository.ts │ │ └── index.ts │ ├── read-history │ │ ├── ReadHistoryApi.ts │ │ └── ReadHistoryRepository.ts │ ├── setting │ │ ├── Setting.ts │ │ └── SettingRepository.ts │ ├── stores │ │ ├── BlockUserCommentRepository.ts │ │ ├── DraftRepository.ts │ │ ├── ReadPositionRepository.ts │ │ ├── RuleViewedRepository.ts │ │ ├── SearchHistoryRepository.ts │ │ ├── WorkspaceRepository.ts │ │ └── index.ts │ └── third-party │ │ ├── AmazonRepository.ts │ │ ├── BaiduRepository.ts │ │ ├── OpenAiRepository.ts │ │ ├── OpenAiWebRepository.ts │ │ ├── YoudaoRepository.ts │ │ └── index.ts ├── domain │ ├── smart-import │ │ ├── ApiGetProduct.ts │ │ ├── ApiGetSerial.ts │ │ ├── ApiSearch.ts │ │ ├── Common.ts │ │ ├── SmartImport.ts │ │ └── index.ts │ └── translate │ │ ├── Common.ts │ │ ├── Translate.ts │ │ ├── TranslateLocal.ts │ │ ├── TranslateWeb.ts │ │ ├── TranslateWenku.ts │ │ ├── Translator.ts │ │ ├── TranslatorBaidu.ts │ │ ├── TranslatorOpenAi.ts │ │ ├── TranslatorSakura.ts │ │ ├── TranslatorYoudao.ts │ │ └── index.ts ├── image │ ├── avater.jpg │ ├── banner.webp │ └── cover_placeholder.png ├── main.ts ├── model │ ├── Article.ts │ ├── Comment.ts │ ├── Common.ts │ ├── Glossary.ts │ ├── LocalVolume.ts │ ├── Operation.ts │ ├── Page.ts │ ├── Translator.ts │ ├── User.ts │ ├── WebNovel.ts │ └── WenkuNovel.ts ├── pages │ ├── MainLayout.vue │ ├── admin │ │ ├── AdminLayout.vue │ │ ├── AdminOperationHistory.vue │ │ ├── AdminUserManagement.vue │ │ ├── AdminWebTocMergeHistory.vue │ │ └── components │ │ │ ├── OperationWebEdit.vue │ │ │ ├── OperationWenkuEdit.vue │ │ │ ├── OperationWenkuEditGlossary.vue │ │ │ ├── OperationWenkuUpload.vue │ │ │ ├── TextDiff.vue │ │ │ └── UserManagementUpdateRole.vue │ ├── auth │ │ ├── AuthLayout.vue │ │ ├── ResetPassword.vue │ │ ├── SignIn.vue │ │ └── components │ │ │ ├── SignInForm.vue │ │ │ └── SignUpForm.vue │ ├── bookshelf │ │ ├── BookshelfLocal.vue │ │ ├── BookshelfLocalStore.ts │ │ ├── BookshelfWeb.vue │ │ ├── BookshelfWenku.vue │ │ └── components │ │ │ ├── BookshelfAddButton.vue │ │ │ ├── BookshelfLayout.vue │ │ │ ├── BookshelfListButton.vue │ │ │ ├── BookshelfLocalAddButton.vue │ │ │ ├── BookshelfLocalControl.vue │ │ │ ├── BookshelfLocalList.vue │ │ │ ├── BookshelfLocalListItem.vue │ │ │ ├── BookshelfMenu.vue │ │ │ ├── BookshelfMenuItem.vue │ │ │ ├── BookshelfWebControl.vue │ │ │ └── BookshelfWenkuControl.vue │ ├── forum │ │ ├── Forum.vue │ │ ├── ForumArticle.vue │ │ ├── ForumArticleEdit.vue │ │ └── ForumArticleStore.ts │ ├── home │ │ ├── Home.vue │ │ └── components │ │ │ ├── PanelWebNovel.vue │ │ │ └── PanelWenkuNovel.vue │ ├── list │ │ ├── ReadHistoryList.vue │ │ ├── WebNovelList.vue │ │ ├── WebNovelRank.vue │ │ ├── WenkuNovelList.vue │ │ └── components │ │ │ ├── InputWithSuggestion.vue │ │ │ ├── NovelListWeb.vue │ │ │ ├── NovelListWenku.vue │ │ │ └── NovelPage.vue │ ├── novel │ │ ├── WebNovel.vue │ │ ├── WebNovelEdit.vue │ │ ├── WebNovelStore.ts │ │ ├── WenkuNovel.vue │ │ ├── WenkuNovelEdit.vue │ │ ├── WenkuNovelStore.ts │ │ └── components │ │ │ ├── FavoriteButton.vue │ │ │ ├── NovelTag.vue │ │ │ ├── TagButton.vue │ │ │ ├── TranslateOptions.vue │ │ │ ├── UploadButton.vue │ │ │ ├── UseTocExpansion.ts │ │ │ ├── UseWebNovel.ts │ │ │ ├── WebNovelMetadata.vue │ │ │ ├── WebNovelNarrow.vue │ │ │ ├── WebNovelWide.vue │ │ │ ├── WebTranslate.vue │ │ │ ├── WenkuVolume.vue │ │ │ └── common.ts │ ├── other │ │ ├── NotFound.vue │ │ └── Setting.vue │ ├── reader │ │ ├── Reader.vue │ │ ├── ReaderLayout.vue │ │ ├── ReaderStore.ts │ │ └── components │ │ │ ├── BuildParagraphs.ts │ │ │ ├── CatalogModal.vue │ │ │ ├── ReaderContent.vue │ │ │ ├── ReaderLayoutDesktop.vue │ │ │ ├── ReaderLayoutMobile.vue │ │ │ ├── ReaderSettingModal.vue │ │ │ └── SideButton.vue │ ├── util.ts │ └── workspace │ │ ├── GptWorkspace.vue │ │ ├── Interactive.vue │ │ ├── SakuraWorkspace.vue │ │ ├── Toolbox.vue │ │ └── components │ │ ├── GptWorkerModal.vue │ │ ├── JobQueue.vue │ │ ├── JobRecord.vue │ │ ├── JobRecordSection.vue │ │ ├── JobTaskLink.vue │ │ ├── JobWorker.vue │ │ ├── LocalVolumeList.vue │ │ ├── LocalVolumeListKatakana.vue │ │ ├── LocalVolumeListSpecificTranslation.vue │ │ ├── SakuraWorkerModal.vue │ │ ├── Toolbox.ts │ │ ├── ToolboxFileCard.vue │ │ ├── ToolboxItemCompressImage.vue │ │ ├── ToolboxItemConvert.vue │ │ ├── ToolboxItemFixOcr.vue │ │ └── ToolboxItemGlossary.vue ├── router.ts ├── sound │ └── all_task_completed.mp3 ├── util │ ├── cc.ts │ ├── file │ │ ├── base.ts │ │ ├── epub.ts │ │ ├── index.ts │ │ ├── srt.ts │ │ ├── standard.ts │ │ └── txt.ts │ ├── index.ts │ ├── result.ts │ └── web │ │ ├── index.ts │ │ ├── keyword.ts │ │ └── url.ts └── vite-env.d.ts ├── tests └── provider.test.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.github/workflows/CI-Server.yml: -------------------------------------------------------------------------------- 1 | name: CI-Server 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - server/** 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }}-server 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | build-and-push: 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: read 19 | packages: write 20 | 21 | steps: 22 | - name: Check out the repo 23 | uses: actions/checkout@v3 24 | 25 | - name: Set up QEMU 26 | uses: docker/setup-qemu-action@v3 27 | 28 | - name: Set up Docker Buildx 29 | uses: docker/setup-buildx-action@v3 30 | 31 | - name: Log in to the Container registry 32 | uses: docker/login-action@v3 33 | with: 34 | registry: ghcr.io 35 | username: ${{ github.actor }} 36 | password: ${{ secrets.GITHUB_TOKEN }} 37 | 38 | - name: Build and push Docker image 39 | uses: docker/build-push-action@v5 40 | with: 41 | context: server 42 | push: true 43 | tags: ghcr.io/fishhawk/auto-novel-server:latest 44 | 45 | - name: Delete all containers from repository without tags 46 | uses: Chizkiyahu/delete-untagged-ghcr-action@v2 47 | with: 48 | token: ${{ secrets.DELETE_PACKAGES_TOKEN }} 49 | repository_owner: ${{ github.repository_owner }} 50 | repository: ${{ github.repository }} 51 | untagged_only: true 52 | owner_type: user 53 | except_untagged_multiplatform: true 54 | -------------------------------------------------------------------------------- /.github/workflows/CI-Web.yml: -------------------------------------------------------------------------------- 1 | name: CI-Web 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - web/** 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }}-web 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | build-and-push: 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: read 19 | packages: write 20 | 21 | steps: 22 | - name: Check out the repo 23 | uses: actions/checkout@v3 24 | 25 | - name: Set up QEMU 26 | uses: docker/setup-qemu-action@v3 27 | 28 | - name: Set up Docker Buildx 29 | uses: docker/setup-buildx-action@v3 30 | 31 | - name: Log in to the Container registry 32 | uses: docker/login-action@v3 33 | with: 34 | registry: ghcr.io 35 | username: ${{ github.actor }} 36 | password: ${{ secrets.GITHUB_TOKEN }} 37 | 38 | - name: Build and push Docker image 39 | uses: docker/build-push-action@v5 40 | with: 41 | context: web 42 | push: true 43 | tags: ghcr.io/fishhawk/auto-novel-web:latest 44 | 45 | - name: Delete all containers from repository without tags 46 | uses: Chizkiyahu/delete-untagged-ghcr-action@v2 47 | with: 48 | token: ${{ secrets.DELETE_PACKAGES_TOKEN }} 49 | repository_owner: ${{ github.repository_owner }} 50 | repository: ${{ github.repository }} 51 | untagged_only: true 52 | owner_type: user 53 | except_untagged_multiplatform: true 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .idea/ 3 | 4 | # Sakura launcher 5 | sakura-launcher/build/ 6 | 7 | # Script 8 | script/dist/ 9 | script/node_modules/ 10 | 11 | # Server 12 | server/.gradle/ 13 | server/.kotlin 14 | server/build/ 15 | server/data/ 16 | 17 | # Web 18 | web/dist/ 19 | web/node_modules/ 20 | web/src/auto-imports.d.ts 21 | web/src/components.d.ts 22 | web/vite.config.ts.timestamp* 23 | web/sonda-report.html 24 | 25 | # Web extension 26 | web-extension/src/_metadata/ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # 贡献代码 2 | 3 | 感谢您有兴趣为这个项目做出贡献!为了高效协作,请遵循以下规范。 4 | 5 | - 在编写代码前,请先通过 Issue 或群组讨论你的变更计划,确保与现有开发方向一致。 6 | - 提交 Pull Request 时,请保持内容精简,每次聚焦一个独立的修改点,以便快速检视和合入。 7 | - 如果对当前代码设计有疑问,可以在群组里 @FishHawk 提问。 8 | - 如果使用 AI 辅助编写,请务必自己检视一遍。 9 | 10 | ## 如何参与前端开发 11 | 12 | 网站基于 Vue3 + TypeScript + Vite + [Naive ui](https://www.naiveui.com/zh-CN) 开发。 13 | 14 | 首先准备开发环境: 15 | 16 | ```bash 17 | git clone git@github.com:FishHawk/auto-novel.git 18 | cd web 19 | pnpm install --frozen-lockfile # 安装依赖 20 | pnpm prepare # 设置Git钩子 21 | ``` 22 | 23 | 然后根据你的需要,选择合适的方式启动开发服务器: 24 | 25 | ```bash 26 | pnpm dev # 启动开发服务器,连接到机翻站 生产环境 后端服务器 27 | pnpm dev:local # 启动开发服务器,连接到 本地启动 的后端服务器,http://localhost:8081 28 | pnpm dev --host # 启动开发服务器,连接到机翻站 生产环境 后端服务器,同时允许局域网访问,支持使用手机访问调试 29 | ``` 30 | 31 | 注意,如果开发服务器连接到机翻站**生产环境**后端,请避免在开发过程中污染网站数据库。出于安全考虑,开发环境中屏蔽了上传章节翻译请求。 32 | 33 | ## 如何参与后端开发 34 | 35 | 后端基于 JVM17 + Kotlin + Ktor 开发,推荐使用 IntelliJ IDEA 打开项目。 36 | 37 | 如果你的修改涉及数据库,你需要自己[部署数据库](https://github.com/FishHawk/auto-novel/blob/main/README.md#部署)并设置环境变量: 38 | 39 | ```bash 40 | DB_HOST_TEST=127.0.0.1 # 数据库 IP 地址 41 | ``` 42 | 43 | 如果你的修改不涉及 Http API,可以使用 kotest 编写单元测试调试,推荐安装 kotest 插件。 44 | 45 | 如果你的修改涉及 Http API,你可以使用 `pnpm dev:local` 启动开发服务器,参考「如何参与前端开发」一节。 46 | 47 | > [!NOTE] 48 | > NixOS 开发环境配置可以参见 [flake.nix](https://gist.github.com/kurikomoe/9dd60f9613e0b8f75c137779d223da4f)。由于使用了 devenv,因此需要 `--impure`。 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 轻小说机翻机器人 2 | 3 | [![GPL-3.0](https://img.shields.io/github/license/FishHawk/auto-novel)](https://github.com/FishHawk/auto-novel#license) 4 | [![CI-Server](https://github.com/FishHawk/auto-novel/workflows/CI-Server/badge.svg)](https://github.com/FishHawk/auto-novel/actions/workflows/CI-Server.yml) 5 | [![CI-Web](https://github.com/FishHawk/auto-novel/workflows/CI-Web/badge.svg)](https://github.com/FishHawk/auto-novel/actions/workflows/CI-Web.yml) 6 | 7 | > 重建巴别塔!! 8 | 9 | [轻小说机翻机器人](https://books.fishhawk.top/)是一个自动生成轻小说机翻并分享的网站。在这里,你可以浏览日文网络小说/文库小说,或者上传你自己的 EPUB/TXT 文件,然后生成机翻版本。 10 | 11 | ## 功能 12 | 13 | - 浏览日本网络小说,支持的网站有:[Kakuyomu](https://kakuyomu.jp/)、[小説家になろう](https://syosetu.com/)、[Novelup](https://novelup.plus/)、[Hameln](https://syosetu.org/)、[Pixiv](https://www.pixiv.net/)、[Alphapolis](https://www.alphapolis.co.jp/)。 14 | - 生成多种机翻,支持的翻译器有:百度、有道、OpenAI-like API(例如 DeepSeek API)、[Sakura](https://huggingface.co/SakuraLLM/Sakura-14B-Qwen2.5-v1.0-GGUF)。 15 | - 支持术语表。 16 | - 支持多种格式,包括日文、中文以及中日对比。 17 | - 支持生成 EPUB 和 TXT 文件。 18 | - 支持翻译 EPUB 和 TXT 文件。 19 | - 支持在线阅读。 20 | 21 | ## 贡献 22 | 23 | 请参考 [CONTRIBUTING.md](https://github.com/FishHawk/auto-novel/blob/main/CONTRIBUTING.md) 24 | 25 | 26 | 27 | 28 | Top Contributors of ant-design/ant-design - Last 28 days 29 | 30 | 31 | 32 | ## 部署 33 | 34 | > [!WARNING] 35 | > 注意:本项目并不是为了个人部署设计的,不保证所有功能可用和前向兼容。 36 | 37 | 下载项目: 38 | 39 | ```bash 40 | > git clone https://github.com/FishHawk/auto-novel.git 41 | > cd auto-novel 42 | ``` 43 | 44 | 创建并编辑 `.env` 文件,内容如下: 45 | 46 | ```bash 47 | DATA_PATH=./data # 数据的存储位置 48 | HTTPS_PROXY=https://127.0.0.1:7890 # web 小说代理,可以为空 49 | PIXIV_COOKIE_PHPSESSID= # Pixiv cookies,不使用 Pixiv 可以不填 50 | ``` 51 | 52 | 打开 `docker-compose.yml` 文件,酌情修改。 53 | 54 | 运行 `docker compose up [-d]` (`-d` 为后台运行)。 55 | 56 | 访问 `http://localhost` 即可。 57 | -------------------------------------------------------------------------------- /autodl.sh: -------------------------------------------------------------------------------- 1 | # 编译 2 | rm -rf ./llama.cpp 3 | git clone -q -c advice.detachedHead=false -b b3853 --depth 1 https://github.com/ggerganov/llama.cpp.git 4 | cd llama.cpp 5 | make GGML_CUDA=1 llama-server -j 6 | 7 | # 运行 8 | MODEL=sakura-14b-qwen2beta-v0.9-iq4_xs_ver2 9 | # MODEL=sakura-32b-qwen2beta-v0.9-iq4xs 10 | llama.cpp/server -m ${MODEL}.gguf -c 4096 -ngl 999 -a ${MODEL} --port 6006 -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | networks: 2 | default: 3 | name: auto-novel 4 | driver: bridge 5 | 6 | services: 7 | web: 8 | image: ghcr.io/fishhawk/auto-novel-web 9 | ports: 10 | - 80:80 11 | volumes: 12 | - ${DATA_PATH}/files-temp:/data/files-temp 13 | - ${DATA_PATH}/files-wenku:/data/files-wenku 14 | - ${DATA_PATH}/files-extra:/data/files-extra 15 | restart: always 16 | 17 | server: 18 | image: ghcr.io/fishhawk/auto-novel-server 19 | depends_on: 20 | - mongo 21 | - elasticsearch 22 | - redis 23 | environment: 24 | - HTTPS_PROXY 25 | - MAILGUN_API_KEY 26 | - JWT_SECRET 27 | - HAMELN_TOKEN 28 | - PIXIV_COOKIE_PHPSESSID 29 | - DB_HOST_MONGO=mongo 30 | - DB_HOST_ES=elasticsearch 31 | - DB_HOST_REDIS=redis 32 | ports: 33 | - 8081:8081 34 | volumes: 35 | - ${DATA_PATH}/files-temp:/data/files-temp 36 | - ${DATA_PATH}/files-wenku:/data/files-wenku 37 | - ${DATA_PATH}/files-extra:/data/files-extra 38 | restart: always 39 | 40 | mongo: 41 | image: mongo:6.0.3 42 | environment: 43 | - MONGO_INITDB_DATABASE=auth 44 | ports: 45 | - 27017:27017 46 | volumes: 47 | - ${DATA_PATH}/db:/data/db 48 | restart: always 49 | 50 | elasticsearch: 51 | image: elasticsearch:8.18.1 52 | environment: 53 | - xpack.security.enabled=false 54 | - discovery.type=single-node 55 | ulimits: 56 | memlock: 57 | soft: -1 58 | hard: -1 59 | nofile: 60 | soft: 65536 61 | hard: 65536 62 | cap_add: 63 | - IPC_LOCK 64 | volumes: 65 | - ${DATA_PATH}/es/data:/usr/share/elasticsearch/data 66 | - ${DATA_PATH}/es/plugins:/usr/share/elasticsearch/plugins 67 | ports: 68 | - 9200:9200 69 | restart: always 70 | 71 | redis: 72 | image: redis:7.2.1 73 | ports: 74 | - 6379:6379 75 | restart: always 76 | -------------------------------------------------------------------------------- /script/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "auto-novel-script", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "main": "index.js", 7 | "scripts": { 8 | "start": "node dist/src/main.js", 9 | "build": "tsc -p tsconfig.json", 10 | "build:watch": "tsc -w -p tsconfig.json" 11 | }, 12 | "devDependencies": { 13 | "@types/node": "^20.12.7", 14 | "mongodb": "^6.5.0", 15 | "mongoose": "^8.3.2", 16 | "typescript": "^5.4.5" 17 | }, 18 | "dependencies": { 19 | "@elastic/elasticsearch": "8" 20 | } 21 | } -------------------------------------------------------------------------------- /script/src/CalculateWenkuEdit.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from "mongodb"; 2 | 3 | import { mongo } from "./config.js"; 4 | 5 | export const calculateWenkuEdit = async () => { 6 | const database = mongo.db("main"); 7 | const col = database.collection("operation-history"); 8 | const operations = col 9 | .find({ 10 | "operation.___type": "wenku-edit", 11 | }) 12 | .project({ 13 | operator: 1, 14 | }); 15 | 16 | const userIdToCount: Record = {}; 17 | 18 | for await (const op of operations) { 19 | const userId: string = op["operator"].toHexString(); 20 | if (!(userId in userIdToCount)) { 21 | userIdToCount[userId] = 0; 22 | } 23 | userIdToCount[userId] += 1; 24 | } 25 | 26 | let records = []; 27 | const userCol = database.collection("user"); 28 | for (const userId in userIdToCount) { 29 | const user = (await userCol.findOne({ _id: new ObjectId(userId) }))!!; 30 | records.push({ 31 | username: user["username"], 32 | count: userIdToCount[userId], 33 | }); 34 | } 35 | records = records.sort((a, b) => a.count - b.count); 36 | const total = records.reduce((pv, it) => pv + it.count, 0); 37 | 38 | for (const { username, count } of records) { 39 | console.log(`${(count / total).toFixed(4)}\t${count}\t${username}`); 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /script/src/ExportSakuraIncorrectCase.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'mongodb'; 2 | import fs from 'node:fs'; 3 | 4 | import { mongo } from './config.js'; 5 | 6 | const formatDuration = (durationInMs: number) => { 7 | const pad = (num: number) => `${num < 10 ? '0' : ''}${num}`; 8 | 9 | const durationInS = Math.floor(durationInMs / 1000); 10 | const hours = Math.floor(durationInS / 3600); 11 | const minutes = Math.floor((durationInS % 3600) / 60); 12 | const seconds = Math.floor(durationInS % 60); 13 | return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; 14 | }; 15 | 16 | const writeSakuraIncorrectCase = (exportDir: string, doc: Document) => { 17 | try { 18 | const dir = exportDir; 19 | if (!fs.existsSync(dir)) { 20 | fs.mkdirSync(dir, { recursive: true }); 21 | } 22 | fs.writeFileSync( 23 | `${dir}/${doc['_id']}.json`, 24 | `${JSON.stringify(doc, null, 4)}` 25 | ); 26 | return true; 27 | } catch (err) { 28 | return false; 29 | } 30 | }; 31 | 32 | export const exportSakuraIncorrectCase = async (exportDir: string) => { 33 | const database = mongo.db('main'); 34 | const cases = database.collection('sakura-incorrect-case'); 35 | 36 | const total = await cases.estimatedDocumentCount({}); 37 | 38 | const cursor = cases.find(); 39 | let index = 0; 40 | const startTimestamp = Date.now(); 41 | for await (const doc of cursor) { 42 | const success = writeSakuraIncorrectCase(exportDir, doc); 43 | if (!success) { 44 | console.log('fail'); 45 | } 46 | index += 1; 47 | 48 | if (index % 1000 === 0) { 49 | const progress = ((index * 100) / total).toFixed(2); 50 | const duration = Date.now() - startTimestamp; 51 | console.log( 52 | `${progress}% ${index}/${total} ${formatDuration(duration)} ${doc._id}` 53 | ); 54 | } 55 | } 56 | await cursor.close(); 57 | }; 58 | -------------------------------------------------------------------------------- /script/src/ExportWebChapter.ts: -------------------------------------------------------------------------------- 1 | import { Document, ObjectId } from 'mongodb'; 2 | import fs from 'node:fs'; 3 | 4 | import { mongo } from './config.js'; 5 | 6 | const formatDuration = (durationInMs: number) => { 7 | const pad = (num: number) => `${num < 10 ? '0' : ''}${num}`; 8 | 9 | const durationInS = Math.floor(durationInMs / 1000); 10 | const hours = Math.floor(durationInS / 3600); 11 | const minutes = Math.floor((durationInS % 3600) / 60); 12 | const seconds = Math.floor(durationInS % 60); 13 | return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; 14 | }; 15 | 16 | const writeFailedLog = (exportDir: string, text: string) => { 17 | const dir = exportDir; 18 | if (!fs.existsSync(dir)) { 19 | fs.mkdirSync(dir, { recursive: true }); 20 | } 21 | fs.appendFileSync(`${dir}/failed`, text + '\n'); 22 | }; 23 | 24 | const writeWebChapter = (exportDir: string, doc: Document) => { 25 | const providerId: string = doc['providerId']; 26 | const novelId: string = doc['bookId']; 27 | const chapterId: string = doc['episodeId']; 28 | const text = (doc['paragraphsJp'] as string[]).join('\n'); 29 | try { 30 | const dir = `${exportDir}/${providerId}/${novelId}`; 31 | if (!fs.existsSync(dir)) { 32 | fs.mkdirSync(dir, { recursive: true }); 33 | } 34 | fs.writeFileSync(`${dir}/${chapterId}`, text); 35 | } catch (err) { 36 | writeFailedLog(exportDir, `${providerId}/${novelId}/${chapterId} ${err}`); 37 | } 38 | }; 39 | 40 | export const exportWebChapter = async (exportDir: string) => { 41 | const database = mongo.db('main'); 42 | const chapters = database.collection('episode'); 43 | 44 | const total = await chapters.estimatedDocumentCount({}); 45 | 46 | const lastObjectId: string | undefined = undefined; 47 | 48 | const cursor = chapters 49 | .find( 50 | lastObjectId === undefined ? {} : { _id: { $gt: new ObjectId('...') } } 51 | ) 52 | .project({ 53 | providerId: 1, 54 | bookId: 1, 55 | episodeId: 1, 56 | paragraphsJp: 1, 57 | }); 58 | let index = 0; 59 | const startTimestamp = Date.now(); 60 | for await (const doc of cursor) { 61 | writeWebChapter(exportDir, doc); 62 | index += 1; 63 | 64 | if (index % 1000 === 0) { 65 | const progress = ((index * 100) / total).toFixed(2); 66 | const duration = formatDuration(Date.now() - startTimestamp); 67 | const id = doc['_id']; 68 | console.log(`${progress}% ${index}/${total} ${duration} ${id}`); 69 | } 70 | } 71 | await cursor.close(); 72 | }; 73 | -------------------------------------------------------------------------------- /script/src/RemoveWebNovel.ts: -------------------------------------------------------------------------------- 1 | import { es, mongoDb } from './config.js'; 2 | 3 | const ARTICLE = 'article'; 4 | const COMMENT = 'comment-alt'; 5 | const OPERATION_HISTORY = 'operation-history'; 6 | const SAKURA_WEB_INCORRECT_CASE = 'sakura-incorrect-case'; 7 | const USER = 'user'; 8 | 9 | const WEB_NOVEL = 'metadata'; 10 | const WEB_FAVORITE = 'web-favorite'; 11 | const WEB_READ_HISTORY = 'web-read-history'; 12 | 13 | const WENKU_NOVEL = 'wenku-metadata'; 14 | const WENKU_FAVORITE = 'wenku-favorite'; 15 | 16 | // will deprecate 17 | const WEB_CHAPTER = 'episode'; 18 | const TOC_MERGE_HISTORY = 'toc-merge-history'; 19 | 20 | export const removeWebNovel = async (providerId: string, novelId: string) => { 21 | try { 22 | await es.delete({ 23 | id: `${providerId}.${novelId}`, 24 | index: 'web.2024-06-10', 25 | }); 26 | } catch {} 27 | 28 | const novel = await mongoDb.collection(WEB_NOVEL).findOne({ 29 | providerId, 30 | bookId: novelId, 31 | }); 32 | if (novel !== null) { 33 | await mongoDb.collection(WEB_FAVORITE).deleteMany({ 34 | novelId: novel._id, 35 | }); 36 | await mongoDb.collection(WEB_READ_HISTORY).deleteMany({ 37 | novelId: novel._id, 38 | }); 39 | await mongoDb.collection(WEB_NOVEL).deleteOne({ 40 | providerId, 41 | bookId: novelId, 42 | }); 43 | } 44 | 45 | await mongoDb.collection(WEB_CHAPTER).deleteMany({ 46 | providerId, 47 | bookId: novelId, 48 | }); 49 | await mongoDb 50 | .collection(COMMENT) 51 | .deleteMany({ site: `web-${providerId}-${novelId}` }); 52 | }; 53 | -------------------------------------------------------------------------------- /script/src/config.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@elastic/elasticsearch'; 2 | import { MongoClient } from 'mongodb'; 3 | 4 | export const es = new Client({ node: 'http://localhost:9200' }); 5 | 6 | export const mongo = new MongoClient('mongodb://localhost:27017/main'); 7 | export const mongoDb = mongo.db('main'); 8 | -------------------------------------------------------------------------------- /script/src/init/DbEs.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@elastic/elasticsearch'; 2 | 3 | const client = new Client({ node: 'http://localhost:9200' }); 4 | 5 | export const ES = { 6 | client, 7 | 8 | WEB_INDEX: 'web.2024-06-10', 9 | WENKU_INDEX: 'wenku.2024-05-15', 10 | }; 11 | -------------------------------------------------------------------------------- /script/src/init/DbMongo.ts: -------------------------------------------------------------------------------- 1 | import { MongoClient } from 'mongodb'; 2 | 3 | const client = new MongoClient('mongodb://localhost:27017/main'); 4 | const db = client.db('main'); 5 | 6 | export const MONGO = { 7 | client, 8 | db, 9 | 10 | ARTICLE: 'article', 11 | COMMENT: 'comment-alt', 12 | GLOSSARY: 'glossary', 13 | OPERATION_HISTORY: 'operation-history', 14 | SAKURA_WEB_INCORRECT_CASE: 'sakura-incorrect-case', 15 | USER: 'user', 16 | 17 | WEB_NOVEL: 'metadata', 18 | WEB_FAVORITE: 'web-favorite', 19 | WEB_READ_HISTORY: 'web-read-history', 20 | 21 | WENKU_NOVEL: 'wenku-metadata', 22 | WENKU_FAVORITE: 'wenku-favorite', 23 | 24 | WEB_CHAPTER: 'episode', 25 | TOC_MERGE_HISTORY: 'toc-merge-history', 26 | 27 | col: (s: string) => db.collection(s), 28 | }; 29 | -------------------------------------------------------------------------------- /script/src/init/GenerateEsIndexWenku.ts: -------------------------------------------------------------------------------- 1 | import { ES } from './DbEs.js'; 2 | import { MONGO } from './DbMongo.js'; 3 | 4 | export const generateEsIndexWenku = async () => { 5 | const es = ES.client; 6 | const index = ES.WENKU_INDEX; 7 | 8 | try { 9 | await es.indices.delete({ index }); 10 | } catch {} 11 | 12 | await es.indices.create( 13 | { 14 | index, 15 | mappings: { 16 | properties: { 17 | title: { type: 'text', analyzer: 'icu_analyzer' }, 18 | titleZh: { type: 'text', analyzer: 'icu_analyzer' }, 19 | authors: { type: 'keyword' }, 20 | artists: { type: 'keyword' }, 21 | keywords: { type: 'keyword' }, 22 | level: { type: 'keyword' }, 23 | publisher: { type: 'keyword' }, 24 | imprint: { type: 'keyword' }, 25 | latestPublishAt: { type: 'date' }, 26 | updateAt: { type: 'date' }, 27 | }, 28 | }, 29 | }, 30 | { ignore: [400] } 31 | ); 32 | 33 | const col = MONGO.col(MONGO.WENKU_NOVEL); 34 | const total = await col.countDocuments(); 35 | const novels = col.find().project({ 36 | title: 1, 37 | titleZh: 1, 38 | cover: 1, 39 | authors: 1, 40 | artists: 1, 41 | keywords: 1, 42 | publisher: 1, 43 | imprint: 1, 44 | level: 1, 45 | latestPublishAt: 1, 46 | updateAt: 1, 47 | }); 48 | 49 | const dataset: { id: string; doc: any }[] = []; 50 | 51 | const processDataset = async () => { 52 | const operations = dataset.flatMap(({ id, doc }) => [ 53 | { index: { _index: index, _id: id } }, 54 | doc, 55 | ]); 56 | await es.bulk({ operations }); 57 | }; 58 | 59 | let i = 1; 60 | for await (const doc of novels) { 61 | const id = doc._id.toHexString(); 62 | delete doc._id; 63 | dataset.push({ id, doc }); 64 | 65 | if (dataset.length === 300) { 66 | console.log(`${i * 300}/${total}`); 67 | i += 1; 68 | await processDataset(); 69 | dataset.length = 0; 70 | } 71 | } 72 | if (dataset.length > 0) { 73 | await processDataset(); 74 | } 75 | 76 | const stat = await es.count({ index }); 77 | console.log(`完成 Mongo:${total} ES:${stat.count}`); 78 | }; 79 | -------------------------------------------------------------------------------- /script/src/main.ts: -------------------------------------------------------------------------------- 1 | import { MONGO } from './init/DbMongo.js'; 2 | import { ensureMongoIndex } from './init/EnsureMongoIndex.js'; 3 | 4 | async function run() { 5 | await ensureMongoIndex(); 6 | await MONGO.client.close(); 7 | } 8 | 9 | run(); 10 | -------------------------------------------------------------------------------- /script/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "NodeNext", 5 | "lib": [ 6 | "ESNext" 7 | ], 8 | "rootDir": ".", 9 | "outDir": "dist", 10 | "strict": true, 11 | "sourceMap": true 12 | }, 13 | "include": [ 14 | "src/**/*", 15 | "__tests__/**/*" 16 | ] 17 | } -------------------------------------------------------------------------------- /server/.dockerignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .gradle/ 3 | data/ 4 | build/ -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gradle:8-jdk17 as builder 2 | COPY . /server 3 | WORKDIR /server 4 | RUN gradle --no-daemon installDist 5 | 6 | FROM eclipse-temurin:17.0.10_7-jdk 7 | COPY --from=builder /server/build/install/auto-novel-server /server 8 | ENV LANG C.UTF-8 9 | CMD ["/server/bin/auto-novel-server"] -------------------------------------------------------------------------------- /server/gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | -------------------------------------------------------------------------------- /server/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/auto-novel/0e12ae4515d601860e4acd4d3aa3e854fdfa5c33/server/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /server/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip 4 | networkTimeout=10000 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /server/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | 2 | rootProject.name = "auto-novel-server" 3 | 4 | -------------------------------------------------------------------------------- /server/src/main/kotlin/api/HttpException.kt: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import io.ktor.http.* 4 | import io.ktor.server.application.* 5 | import io.ktor.server.request.* 6 | import io.ktor.server.response.* 7 | 8 | data class HttpException( 9 | val status: HttpStatusCode, 10 | override val message: String, 11 | ) : Exception(message) 12 | 13 | fun throwBadRequest(message: String): Nothing = 14 | throw HttpException(HttpStatusCode.BadRequest, message) 15 | 16 | fun throwUnauthorized(message: String): Nothing = 17 | throw HttpException(HttpStatusCode.Unauthorized, message) 18 | 19 | fun throwConflict(message: String): Nothing = 20 | throw HttpException(HttpStatusCode.Conflict, message) 21 | 22 | fun throwNotFound(message: String): Nothing = 23 | throw HttpException(HttpStatusCode.NotFound, message) 24 | 25 | fun throwInternalServerError(message: String): Nothing = 26 | throw HttpException(HttpStatusCode.InternalServerError, message) 27 | 28 | suspend inline fun ApplicationCall.doOrRespondError(block: () -> Unit) { 29 | try { 30 | block() 31 | } catch (e: Throwable) { 32 | val httpMethod = request.httpMethod.value 33 | val uri = request.uri 34 | application.environment.log.warn("已捕获异常 $httpMethod-$uri:", e.message) 35 | 36 | e.printStackTrace() 37 | 38 | if (e is HttpException) { 39 | respond(e.status, e.message) 40 | } else { 41 | respond(HttpStatusCode.InternalServerError, e.message ?: "未知错误") 42 | } 43 | } 44 | } 45 | 46 | suspend inline fun ApplicationCall.tryRespond(block: () -> T) = 47 | doOrRespondError { 48 | val message = block() 49 | if (message is Unit || message == null) { 50 | response.status(HttpStatusCode.OK) 51 | } else { 52 | respond(message) 53 | } 54 | } 55 | 56 | suspend inline fun ApplicationCall.tryRespondRedirect(block: () -> String) = 57 | doOrRespondError { 58 | val url = block() 59 | respondRedirect(url) 60 | } 61 | -------------------------------------------------------------------------------- /server/src/main/kotlin/api/UtilPage.kt: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | fun validatePageNumber(page: Int) { 4 | if (page < 0) { 5 | throwBadRequest("页码不应该小于0") 6 | } 7 | } 8 | 9 | fun validatePageSize(pageSize: Int, max: Int = 100) { 10 | if (pageSize < 1) { 11 | throwBadRequest("每页数据量不应该小于1") 12 | } 13 | if (pageSize > max) { 14 | throwBadRequest("每页数据量不应该大于${max}") 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /server/src/main/kotlin/api/model/WebNovel.kt: -------------------------------------------------------------------------------- 1 | package api.model 2 | 3 | import infra.web.WebNovelAttention 4 | import infra.web.WebNovelListItem 5 | import infra.web.WebNovelType 6 | import kotlinx.serialization.Serializable 7 | 8 | @Serializable 9 | data class WebNovelOutlineDto( 10 | val providerId: String, 11 | val novelId: String, 12 | val titleJp: String, 13 | val titleZh: String?, 14 | val type: WebNovelType?, 15 | val attentions: List, 16 | val keywords: List, 17 | val extra: String?, 18 | // 19 | val favored: String?, 20 | val lastReadAt: Long?, 21 | // 22 | val total: Long, 23 | val jp: Long, 24 | val baidu: Long, 25 | val youdao: Long, 26 | val gpt: Long, 27 | val sakura: Long, 28 | val updateAt: Long?, 29 | ) 30 | 31 | fun WebNovelListItem.asDto() = 32 | WebNovelOutlineDto( 33 | providerId = providerId, 34 | novelId = novelId, 35 | titleJp = titleJp, 36 | titleZh = titleZh, 37 | type = type, 38 | attentions = attentions, 39 | keywords = keywords, 40 | extra = extra, 41 | favored = favored, 42 | lastReadAt = lastReadAt?.epochSeconds, 43 | total = total, 44 | jp = jp, 45 | baidu = baidu, 46 | youdao = youdao, 47 | gpt = gpt, 48 | sakura = sakura, 49 | updateAt = updateAt?.epochSeconds, 50 | ) 51 | 52 | -------------------------------------------------------------------------------- /server/src/main/kotlin/api/plugins/ContentNegotiation.kt: -------------------------------------------------------------------------------- 1 | package api.plugins 2 | 3 | import io.ktor.serialization.kotlinx.json.* 4 | import io.ktor.server.application.* 5 | import io.ktor.server.plugins.contentnegotiation.* 6 | import kotlinx.datetime.Instant 7 | import kotlinx.serialization.ExperimentalSerializationApi 8 | import kotlinx.serialization.KSerializer 9 | import kotlinx.serialization.descriptors.PrimitiveKind 10 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 11 | import kotlinx.serialization.descriptors.SerialDescriptor 12 | import kotlinx.serialization.encoding.Decoder 13 | import kotlinx.serialization.encoding.Encoder 14 | import kotlinx.serialization.json.Json 15 | import kotlinx.serialization.modules.SerializersModule 16 | import kotlinx.serialization.modules.contextual 17 | 18 | fun Application.contentNegotiation() { 19 | install(ContentNegotiation) { 20 | json(Json { 21 | serializersModule = SerializersModule { 22 | contextual(InstantEpochSecondsSerializer) 23 | } 24 | explicitNulls = false 25 | }) 26 | } 27 | } 28 | 29 | object InstantEpochSecondsSerializer : KSerializer { 30 | override val descriptor: SerialDescriptor = 31 | PrimitiveSerialDescriptor("Instant", PrimitiveKind.LONG) 32 | 33 | override fun deserialize(decoder: Decoder): Instant = 34 | Instant.fromEpochSeconds(decoder.decodeLong()) 35 | 36 | override fun serialize(encoder: Encoder, value: Instant) { 37 | encoder.encodeLong(value.epochSeconds) 38 | } 39 | } 40 | 41 | -------------------------------------------------------------------------------- /server/src/main/kotlin/api/plugins/RateLimit.kt: -------------------------------------------------------------------------------- 1 | package api.plugins 2 | 3 | import io.ktor.server.application.* 4 | import io.ktor.server.plugins.ratelimit.* 5 | import kotlin.time.Duration.Companion.days 6 | import kotlin.time.Duration.Companion.minutes 7 | 8 | object RateLimitNames { 9 | val CreateArticle = RateLimitName("create-article") 10 | val CreateComment = RateLimitName("create-comment") 11 | val CreateSakuraJob = RateLimitName("create-sakura-job") 12 | val CreateWenkuNovel = RateLimitName("create-wenku-novel") 13 | val CreateWenkuVolume = RateLimitName("create-wenku-volume") 14 | } 15 | 16 | fun Application.rateLimit() = install(RateLimit) { 17 | register(RateLimitNames.CreateArticle) { 18 | rateLimiter(limit = 10, refillPeriod = 1.days) 19 | requestKey { call -> call.user().id } 20 | } 21 | register(RateLimitNames.CreateComment) { 22 | rateLimiter(limit = 100, refillPeriod = 1.days) 23 | requestKey { call -> call.user().id } 24 | } 25 | register(RateLimitNames.CreateSakuraJob) { 26 | rateLimiter(limit = 5, refillPeriod = 1.minutes) 27 | requestKey { call -> call.user().id } 28 | } 29 | register(RateLimitNames.CreateWenkuNovel) { 30 | rateLimiter(limit = 100, refillPeriod = 1.days) 31 | requestKey { call -> call.user().id } 32 | } 33 | register(RateLimitNames.CreateWenkuVolume) { 34 | rateLimiter(limit = 500, refillPeriod = 1.days) 35 | requestKey { call -> call.user().id } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /server/src/main/kotlin/infra/ElasticSearchClient.kt: -------------------------------------------------------------------------------- 1 | package infra 2 | 3 | import com.jillesvangurp.ktsearch.KtorRestClient 4 | import com.jillesvangurp.ktsearch.Node 5 | import com.jillesvangurp.ktsearch.SearchClient 6 | import com.jillesvangurp.ktsearch.createIndex 7 | import kotlinx.coroutines.runBlocking 8 | 9 | typealias ElasticSearchClient = SearchClient 10 | 11 | fun elasticSearchClient(host: String, port: Int?) = 12 | SearchClient( 13 | KtorRestClient( 14 | Node(host, port ?: 9200), 15 | ) 16 | ).apply { 17 | runBlocking { 18 | runCatching { 19 | createIndex(ElasticSearchIndexNames.WEB_NOVEL) { 20 | mappings(dynamicEnabled = false) { 21 | keyword("providerId") 22 | text("titleJp") { analyzer = "icu_analyzer" } 23 | text("titleZh") { analyzer = "icu_analyzer" } 24 | keyword("authors") 25 | keyword("type") 26 | keyword("attentions") 27 | keyword("keywords") 28 | number("tocSize") 29 | number("visited") 30 | bool("hasGpt") 31 | bool("hasSakura") 32 | date("updateAt") 33 | } 34 | } 35 | } 36 | 37 | runCatching { 38 | createIndex(ElasticSearchIndexNames.WENKU_NOVEL) { 39 | mappings(dynamicEnabled = false) { 40 | text("title") { analyzer = "icu_analyzer" } 41 | text("titleZh") { analyzer = "icu_analyzer" } 42 | keyword("authors") 43 | keyword("artists") 44 | keyword("keywords") 45 | keyword("level") 46 | keyword("publisher") 47 | keyword("imprint") 48 | date("latestPublishAt") 49 | date("updateAt") 50 | } 51 | } 52 | } 53 | } 54 | } 55 | 56 | object ElasticSearchIndexNames { 57 | const val WEB_NOVEL = "web.2024-06-10" 58 | const val WENKU_NOVEL = "wenku.2024-05-15" 59 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/infra/EmailClient.kt: -------------------------------------------------------------------------------- 1 | package infra 2 | 3 | import infra.web.datasource.providers.json 4 | import io.ktor.client.* 5 | import io.ktor.client.engine.java.* 6 | import io.ktor.client.plugins.contentnegotiation.* 7 | import io.ktor.client.request.* 8 | import io.ktor.serialization.kotlinx.json.* 9 | import kotlinx.serialization.json.Json 10 | 11 | class EmailClient(private val apiKey: String?) { 12 | private val client = HttpClient(Java) { 13 | install(ContentNegotiation) { 14 | json(Json { isLenient = true }) 15 | } 16 | expectSuccess = true 17 | } 18 | 19 | val enabled 20 | get() = apiKey != null 21 | 22 | private suspend fun send(to: String, subject: String, text: String) { 23 | if (apiKey == null) return 24 | 25 | client.post("https://api.eu.mailgun.net/v3/verify.fishhawk.top/messages") { 26 | basicAuth("api", apiKey) 27 | url { 28 | parameters.append("from", "轻小说机翻机器人 ") 29 | parameters.append("to", to) 30 | parameters.append("subject", subject) 31 | parameters.append("text", text) 32 | } 33 | }.json() 34 | } 35 | 36 | suspend fun sendVerifyEmail(to: String, code: String) = 37 | send( 38 | to = to, 39 | subject = "$code 日本网文机翻机器人 注册激活码", 40 | text = "您的注册激活码为 $code\n" + 41 | "激活码将会在15分钟后失效,请尽快完成注册\n" + 42 | "这是系统邮件,请勿回复" 43 | ) 44 | 45 | suspend fun sendResetPasswordTokenEmail(to: String, token: String) = 46 | send( 47 | to = to, 48 | subject = "日本网文机翻机器人 重置密码口令", 49 | text = "您的重置密码口令为 $token\n" + 50 | "口令将会在15分钟后失效,请尽快重置密码\n" + 51 | "如果发送了多个口令,请使用最新的口令,旧的口令将失效\n" + 52 | "这是系统邮件,请勿回复" 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /server/src/main/kotlin/infra/RedisClient.kt: -------------------------------------------------------------------------------- 1 | package infra 2 | 3 | import io.github.crackthecodeabhi.kreds.args.SetOption 4 | import io.github.crackthecodeabhi.kreds.connection.Endpoint 5 | import io.github.crackthecodeabhi.kreds.connection.KredsClient 6 | import io.github.crackthecodeabhi.kreds.connection.newClient 7 | import kotlin.time.Duration.Companion.hours 8 | 9 | typealias RedisClient = KredsClient 10 | 11 | fun redisClient(host: String, port: Int?): RedisClient { 12 | return newClient(Endpoint(host, port ?: 6379)) 13 | } 14 | 15 | suspend inline fun RedisClient.withRateLimit(key: String, block: () -> Unit) { 16 | val keyWithNamespace = "rl:${key}" 17 | if (exists(keyWithNamespace) == 0L) { 18 | set( 19 | key = keyWithNamespace, 20 | value = "0", 21 | setOption = SetOption.Builder() 22 | .exSeconds(3.hours.inWholeSeconds.toULong()) 23 | .build(), 24 | ) 25 | block() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /server/src/main/kotlin/infra/TempFileClient.kt: -------------------------------------------------------------------------------- 1 | package infra 2 | 3 | import kotlinx.datetime.Instant 4 | import kotlinx.datetime.toKotlinInstant 5 | import java.nio.file.Path 6 | import java.nio.file.attribute.BasicFileAttributes 7 | import kotlin.io.path.* 8 | 9 | enum class TempFileType(val value: String) { 10 | Web("web"), 11 | Wenku("wenku"), 12 | } 13 | 14 | class TempFileClient { 15 | private val path = Path("./data/files-temp") 16 | 17 | init { 18 | for (dirName in listOf( 19 | TempFileType.Web.value, 20 | TempFileType.Wenku.value, 21 | "trash", 22 | )) { 23 | val dirPath = path / dirName 24 | if (dirPath.notExists()) { 25 | dirPath.createDirectories() 26 | } 27 | } 28 | } 29 | 30 | fun isFileModifiedAfter(type: TempFileType, filename: String, instant: Instant): Boolean { 31 | val filepath = path / type.value / filename 32 | 33 | if (!filepath.exists()) { 34 | return false 35 | } 36 | 37 | val lastModifier = filepath.readAttributes() 38 | .creationTime() 39 | .toInstant() 40 | .toKotlinInstant() 41 | return lastModifier > instant 42 | } 43 | 44 | fun createFile(type: TempFileType, filename: String): Path { 45 | val filepath = path / type.value / filename 46 | if (!filepath.exists()) { 47 | filepath.createFile() 48 | } 49 | return filepath 50 | } 51 | 52 | fun trash(filepath: Path) { 53 | val trashFilepath = path / "trash" / filepath.fileName 54 | trashFilepath.deleteIfExists() 55 | filepath.moveTo(trashFilepath) 56 | } 57 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/infra/article/Article.kt: -------------------------------------------------------------------------------- 1 | package infra.article 2 | 3 | import infra.user.UserOutline 4 | import kotlinx.datetime.Instant 5 | import kotlinx.serialization.Contextual 6 | import kotlinx.serialization.SerialName 7 | import kotlinx.serialization.Serializable 8 | import org.bson.types.ObjectId 9 | 10 | @Serializable 11 | enum class ArticleCategory { 12 | @SerialName("Guide") 13 | Guide, 14 | 15 | @SerialName("General") 16 | General, 17 | 18 | @SerialName("Support") 19 | Support, 20 | } 21 | 22 | @Serializable 23 | data class ArticleListItem( 24 | val id: String, 25 | val title: String, 26 | val category: ArticleCategory, 27 | val locked: Boolean, 28 | val pinned: Boolean, 29 | val hidden: Boolean, 30 | val numViews: Int, 31 | val numComments: Int, 32 | val user: UserOutline, 33 | @Contextual val createAt: Instant, 34 | @Contextual val updateAt: Instant, 35 | ) 36 | 37 | @Serializable 38 | data class Article( 39 | val id: String, 40 | val title: String, 41 | val content: String, 42 | val category: ArticleCategory, 43 | val locked: Boolean, 44 | val pinned: Boolean, 45 | val hidden: Boolean, 46 | val numViews: Int, 47 | val numComments: Int, 48 | @Contextual val user: UserOutline, 49 | @Contextual val createAt: Instant, 50 | @Contextual val updateAt: Instant, 51 | ) 52 | 53 | // MongoDB 54 | @Serializable 55 | data class ArticleDbModel( 56 | @Contextual @SerialName("_id") val id: ObjectId, 57 | val title: String, 58 | val content: String, 59 | val category: ArticleCategory, 60 | val locked: Boolean, 61 | val pinned: Boolean, 62 | val hidden: Boolean = false, 63 | val numViews: Int, 64 | val numComments: Int, 65 | @Contextual val user: ObjectId, 66 | @Contextual val createAt: Instant, 67 | @Contextual val updateAt: Instant, 68 | @Contextual val changeAt: Instant, 69 | ) 70 | -------------------------------------------------------------------------------- /server/src/main/kotlin/infra/comment/Comment.kt: -------------------------------------------------------------------------------- 1 | package infra.comment 2 | 3 | import infra.user.UserOutline 4 | import kotlinx.datetime.Instant 5 | import kotlinx.serialization.Contextual 6 | import kotlinx.serialization.SerialName 7 | import kotlinx.serialization.Serializable 8 | import org.bson.types.ObjectId 9 | 10 | @Serializable 11 | data class Comment( 12 | val id: String, 13 | val site: String, 14 | val content: String, 15 | val hidden: Boolean, 16 | val numReplies: Int, 17 | @Contextual val user: UserOutline, 18 | @Contextual val createAt: Instant, 19 | ) 20 | 21 | // MongoDB 22 | @Serializable 23 | data class CommentDbModel( 24 | @Contextual @SerialName("_id") val id: ObjectId, 25 | val site: String, 26 | val content: String, 27 | val hidden: Boolean = false, 28 | val numReplies: Int, 29 | @Contextual val parent: ObjectId?, 30 | @Contextual val user: ObjectId, 31 | @Contextual val createAt: Instant, 32 | ) 33 | -------------------------------------------------------------------------------- /server/src/main/kotlin/infra/common/Common.kt: -------------------------------------------------------------------------------- 1 | package infra.common 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class Page( 8 | val items: List, 9 | val pageNumber: Long, 10 | ) { 11 | constructor(items: List, total: Long, pageSize: Int) : this( 12 | items = items, 13 | pageNumber = (total - 1) / pageSize + 1, 14 | ) 15 | 16 | inline fun map(transform: (T) -> R) = Page( 17 | items = items.map(transform), 18 | pageNumber = pageNumber, 19 | ) 20 | } 21 | 22 | 23 | fun emptyPage() = Page(items = emptyList(), pageNumber = 0L) 24 | 25 | @Serializable 26 | enum class FavoredNovelListSort { 27 | @SerialName("create") 28 | CreateAt, 29 | 30 | @SerialName("update") 31 | UpdateAt, 32 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/infra/common/NovelFile.kt: -------------------------------------------------------------------------------- 1 | package infra.common 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | enum class NovelFileMode { 8 | @SerialName("jp") 9 | Jp, 10 | 11 | @SerialName("zh") 12 | Zh, 13 | 14 | @SerialName("jp-zh") 15 | JpZh, 16 | 17 | @SerialName("zh-jp") 18 | ZhJp, 19 | } 20 | 21 | @Serializable 22 | enum class NovelFileTranslationsMode { 23 | @SerialName("parallel") 24 | Parallel, 25 | 26 | @SerialName("priority") 27 | Priority, 28 | } 29 | 30 | @Serializable 31 | enum class NovelFileType(val value: String) { 32 | @SerialName("epub") 33 | EPUB("epub"), 34 | 35 | @SerialName("txt") 36 | TXT("txt") 37 | } 38 | -------------------------------------------------------------------------------- /server/src/main/kotlin/infra/common/Translation.kt: -------------------------------------------------------------------------------- 1 | package infra.common 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | enum class TranslatorId { 8 | @SerialName("baidu") 9 | Baidu, 10 | 11 | @SerialName("youdao") 12 | Youdao, 13 | 14 | @SerialName("gpt") 15 | Gpt, 16 | 17 | @SerialName("sakura") 18 | Sakura, 19 | } 20 | 21 | data class Glossary( 22 | val id: String, 23 | val map: Map, 24 | ) -------------------------------------------------------------------------------- /server/src/main/kotlin/infra/user/User.kt: -------------------------------------------------------------------------------- 1 | package infra.user 2 | 3 | import kotlinx.datetime.Instant 4 | import kotlinx.serialization.Contextual 5 | import kotlinx.serialization.SerialName 6 | import kotlinx.serialization.Serializable 7 | import org.bson.types.ObjectId 8 | 9 | @Serializable 10 | enum class UserRole { 11 | @SerialName("admin") 12 | Admin, 13 | 14 | @SerialName("maintainer") 15 | Maintainer, 16 | 17 | @SerialName("trusted") 18 | Trusted, 19 | 20 | @SerialName("normal") 21 | Normal, 22 | 23 | @SerialName("banned") 24 | Banned; 25 | 26 | private fun authLevel() = when (this) { 27 | Admin -> 4 28 | Maintainer -> 3 29 | Trusted -> 2 30 | Normal -> 1 31 | Banned -> 0 32 | } 33 | 34 | infix fun atLeast(other: UserRole): Boolean = 35 | authLevel() >= other.authLevel() 36 | 37 | companion object { 38 | fun String.toUserRole(): UserRole = 39 | when (this) { 40 | "normal" -> Normal 41 | "trusted" -> Trusted 42 | "maintainer" -> Maintainer 43 | "admin" -> Admin 44 | else -> Banned 45 | } 46 | } 47 | } 48 | 49 | @Serializable 50 | data class UserOutline( 51 | val username: String, 52 | val role: UserRole, 53 | ) 54 | 55 | @Serializable 56 | data class UserFavored( 57 | val id: String, 58 | val title: String, 59 | ) 60 | 61 | @Serializable 62 | data class UserFavoredList( 63 | val favoredWeb: List, 64 | val favoredWenku: List, 65 | ) 66 | 67 | @Serializable 68 | data class User( 69 | val id: String, 70 | val email: String, 71 | val username: String, 72 | val role: UserRole, 73 | @Contextual val createdAt: Instant, 74 | ) 75 | 76 | // MongoDB 77 | @Serializable 78 | data class UserDbModel( 79 | @Contextual @SerialName("_id") val id: ObjectId, 80 | val email: String, 81 | val username: String, 82 | val salt: String, 83 | val password: String, 84 | val role: UserRole, 85 | @Contextual val createdAt: Instant, 86 | // 87 | val favoredWeb: List, 88 | val favoredWenku: List, 89 | // 90 | val readHistoryPaused: Boolean = false, 91 | ) 92 | -------------------------------------------------------------------------------- /server/src/main/kotlin/infra/user/UserCodeRepository.kt: -------------------------------------------------------------------------------- 1 | package infra.user 2 | 3 | import infra.RedisClient 4 | import io.github.crackthecodeabhi.kreds.args.SetOption 5 | import kotlin.time.Duration.Companion.minutes 6 | 7 | class UserCodeRepository( 8 | private val redis: RedisClient, 9 | ) { 10 | private fun emailCodeKey(email: String) = "ec:${email}" 11 | 12 | suspend fun verifyEmailCode(email: String, emailCode: String): Boolean { 13 | val inRedis = redis.get(key = emailCodeKey(email)) 14 | return inRedis == emailCode 15 | } 16 | 17 | suspend fun addEmailCode(email: String, emailCode: String) { 18 | redis.set( 19 | key = emailCodeKey(email), 20 | value = emailCode, 21 | setOption = SetOption.Builder() 22 | .exSeconds(15.minutes.inWholeSeconds.toULong()) 23 | .build(), 24 | ) 25 | } 26 | 27 | private fun resetPasswordCodeKey(id: String) = "rpt:${id}" 28 | 29 | suspend fun verifyResetPasswordToken(id: String, resetCode: String): Boolean { 30 | val inRedis = redis.get(key = resetPasswordCodeKey(id)) 31 | return inRedis == resetCode 32 | } 33 | 34 | suspend fun addResetPasswordCode(id: String, resetCode: String) { 35 | redis.set( 36 | key = resetPasswordCodeKey(id), 37 | value = resetCode, 38 | setOption = SetOption.Builder() 39 | .exSeconds(15.minutes.inWholeSeconds.toULong()) 40 | .build(), 41 | ) 42 | } 43 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/infra/user/UserFavoredRepository.kt: -------------------------------------------------------------------------------- 1 | package infra.user 2 | 3 | import com.mongodb.client.model.Filters.eq 4 | import com.mongodb.client.model.Updates.set 5 | import infra.MongoClient 6 | import infra.MongoCollectionNames 7 | import infra.field 8 | import kotlinx.coroutines.flow.firstOrNull 9 | import org.bson.types.ObjectId 10 | 11 | class UserFavoredRepository( 12 | mongo: MongoClient, 13 | ) { 14 | private val userCollection = 15 | mongo.database.getCollection( 16 | MongoCollectionNames.USER, 17 | ) 18 | 19 | suspend fun getFavoredList( 20 | id: String, 21 | ): UserFavoredList? { 22 | return userCollection 23 | .withDocumentClass() 24 | .find(eq(UserDbModel::id.field(), ObjectId(id))) 25 | .firstOrNull() 26 | } 27 | 28 | suspend fun updateFavoredWeb( 29 | userId: String, 30 | favored: List, 31 | ) { 32 | userCollection 33 | .updateOne( 34 | eq(UserDbModel::id.field(), ObjectId(userId)), 35 | set(UserDbModel::favoredWeb.field(), favored) 36 | ) 37 | } 38 | 39 | suspend fun updateFavoredWenku( 40 | userId: String, 41 | favored: List, 42 | ) { 43 | userCollection 44 | .updateOne( 45 | eq(UserDbModel::id.field(), ObjectId(userId)), 46 | set(UserDbModel::favoredWenku.field(), favored), 47 | ) 48 | } 49 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/util/PBKDF2.kt: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import java.util.* 4 | import javax.crypto.SecretKeyFactory 5 | import javax.crypto.spec.PBEKeySpec 6 | 7 | object PBKDF2 { 8 | fun randomSalt(): String { 9 | return UUID.randomUUID().toString() 10 | } 11 | 12 | fun hash(password: String, salt: String): String { 13 | val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512") 14 | val spec = PBEKeySpec(password.toCharArray(), salt.toByteArray(), 120_000, 256) 15 | val hashed = factory.generateSecret(spec).encoded 16 | return String(Base64.getEncoder().encode(hashed)) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /server/src/main/kotlin/util/SerialName.kt: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import kotlinx.serialization.SerialName 4 | 5 | fun Enum<*>.serialName(): String = 6 | javaClass 7 | .getDeclaredField(name) 8 | .getAnnotation(SerialName::class.java)!! 9 | .value 10 | -------------------------------------------------------------------------------- /server/src/main/kotlin/util/UnreachableException.kt: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | class UnreachableException : Exception("This exception should not be thrown.") -------------------------------------------------------------------------------- /server/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 5 | 6 | 7 | 8 | data/log/error.log 9 | true 10 | 11 | %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 12 | 13 | 14 | info 15 | 16 | 17 | 18 | data/log/error.%i.zip 19 | 1 20 | 3 21 | 22 | 23 | 10MB 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /server/src/test/kotlin/KoinExtension.kt: -------------------------------------------------------------------------------- 1 | import io.kotest.koin.KoinExtension 2 | import io.kotest.koin.KoinLifecycleMode 3 | 4 | fun koinExtensions() = listOf( 5 | KoinExtension( 6 | module = appModule, 7 | mode = KoinLifecycleMode.Root, 8 | ) 9 | ) 10 | -------------------------------------------------------------------------------- /server/src/test/kotlin/infra/MongoClientTest.kt: -------------------------------------------------------------------------------- 1 | package infra 2 | 3 | import com.mongodb.client.model.Filters.* 4 | import com.mongodb.client.model.Updates.set 5 | import infra.web.WebNovelFavoriteDbModel 6 | import io.kotest.core.spec.style.DescribeSpec 7 | import koinExtensions 8 | import kotlinx.coroutines.flow.count 9 | import kotlinx.datetime.Instant 10 | import org.bson.Document 11 | import org.koin.test.KoinTest 12 | import org.koin.test.inject 13 | 14 | class MongoClientTest : DescribeSpec(), KoinTest { 15 | override fun extensions() = koinExtensions() 16 | private val mongo by inject() 17 | 18 | init { 19 | describe("临时测试") { 20 | val userFavoredWebCollection = 21 | mongo.database.getCollection( 22 | MongoCollectionNames.WEB_FAVORITE, 23 | ) 24 | 25 | val total = userFavoredWebCollection.withDocumentClass().find( 26 | type(WebNovelFavoriteDbModel::updateAt.field(), "string"), 27 | ).count() 28 | 29 | var i = 0 30 | userFavoredWebCollection.withDocumentClass().find( 31 | type(WebNovelFavoriteDbModel::updateAt.field(), "string"), 32 | ).collect { 33 | if (i % 100 == 0) { 34 | println("${i}/${total}") 35 | } 36 | i += 1 37 | val userId = it.getObjectId("userId") 38 | val novelId = it.getObjectId("novelId") 39 | val updateAt = it.getString("updateAt") 40 | 41 | userFavoredWebCollection.updateOne( 42 | and( 43 | eq(WebNovelFavoriteDbModel::userId.field(), userId), 44 | eq(WebNovelFavoriteDbModel::novelId.field(), novelId), 45 | ), 46 | set( 47 | WebNovelFavoriteDbModel::updateAt.field(), 48 | Instant.parse(updateAt) 49 | ) 50 | ) 51 | } 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /server/src/test/kotlin/infra/provider/providers/AlphapolisTest.kt: -------------------------------------------------------------------------------- 1 | package infra.provider.providers 2 | 3 | import koinExtensions 4 | import infra.web.datasource.WebNovelHttpDataSource 5 | import infra.web.datasource.providers.Alphapolis 6 | import io.kotest.core.spec.style.DescribeSpec 7 | import io.kotest.matchers.shouldBe 8 | import io.kotest.matchers.string.shouldEndWith 9 | import io.kotest.matchers.string.shouldStartWith 10 | import org.koin.test.KoinTest 11 | import org.koin.test.inject 12 | 13 | class AlphapolisTest : DescribeSpec(), KoinTest { 14 | override fun extensions() = koinExtensions() 15 | private val dataSource by inject() 16 | private val provider get() = dataSource.providers[Alphapolis.id]!! 17 | 18 | init { 19 | describe("getMetadata") { 20 | it("常规") { 21 | // https://www.alphapolis.co.jp/novel/638978238/525733370 22 | val metadata = provider.getMetadata("638978238-525733370") 23 | metadata.title.shouldStartWith("今までの功績を改竄され") 24 | metadata.authors.first().name.shouldBe("taki210") 25 | metadata.authors.first().link.shouldBe("https://www.alphapolis.co.jp/author/detail/638978238") 26 | metadata.introduction.shouldStartWith("「今日限りでお前をこの") 27 | metadata.introduction.shouldEndWith("っていたのだった。") 28 | metadata.toc[0].title.shouldBe("第一話") 29 | metadata.toc[0].chapterId.shouldBe("6857738") 30 | } 31 | it("折叠") { 32 | // https://www.alphapolis.co.jp/novel/761693105/571330821 33 | provider.getMetadata("761693105-571330821") 34 | } 35 | it("R18") { 36 | // https://www.alphapolis.co.jp/novel/770037297/275621537 37 | provider.getMetadata("770037297-275621537") 38 | } 39 | } 40 | 41 | describe("getEpisode") { 42 | it("常规") { 43 | // https://www.alphapolis.co.jp/novel/638978238/525733370/episode/6857739 44 | val episode = provider.getChapter("638978238-525733370", "6857739") 45 | episode.paragraphs.getOrNull(1).shouldBe("「これからどうすっかなぁ…」") 46 | } 47 | it("ruby标签") { 48 | // https://www.alphapolis.co.jp/novel/606793319/240579102/episode/5375163 49 | val episode = provider.getChapter("606793319-240579102", "5375163") 50 | episode.paragraphs.first().shouldBe("海斗の携帯は瑛人の顔に当たり、地面に落ちる") 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /server/src/test/kotlin/infra/provider/providers/NovelupTest.kt: -------------------------------------------------------------------------------- 1 | package infra.provider.providers 2 | 3 | import koinExtensions 4 | import infra.web.datasource.WebNovelHttpDataSource 5 | import infra.web.datasource.providers.Novelup 6 | import io.kotest.core.spec.style.DescribeSpec 7 | import io.kotest.matchers.collections.shouldBeEmpty 8 | import io.kotest.matchers.nulls.shouldBeNull 9 | import io.kotest.matchers.shouldBe 10 | import io.kotest.matchers.string.shouldStartWith 11 | import kotlinx.datetime.Instant 12 | import org.koin.test.KoinTest 13 | import org.koin.test.inject 14 | 15 | class NovelupTest : DescribeSpec(), KoinTest { 16 | override fun extensions() = koinExtensions() 17 | private val dataSource by inject() 18 | private val provider get() = dataSource.providers[Novelup.id]!! 19 | 20 | init { 21 | describe("getMetadata") { 22 | it("常规") { 23 | // https://novelup.plus/story/206612087 24 | val metadata = provider.getMetadata("206612087") 25 | metadata.title.shouldBe("クロの戦記 異世界転移した僕が最強なのはベッドの上だけのようです") 26 | metadata.authors.first().name.shouldBe("サイトウアユム") 27 | metadata.authors.first().link.shouldBe("https://novelup.plus/user/930309375/profile") 28 | metadata.introduction.shouldStartWith("ケフェウス帝国の貴族であるクロノ・クロフォードには秘密があった。") 29 | metadata.toc[0].title.shouldBe("第1部:立志編") 30 | metadata.toc[0].chapterId.shouldBeNull() 31 | metadata.toc[0].createAt.shouldBeNull() 32 | metadata.toc[1].title.shouldBe("001 第1話:クロノ") 33 | metadata.toc[1].chapterId.shouldBe("614254159") 34 | metadata.toc[1].createAt.shouldBe(Instant.parse("2019-05-17T08:43:00Z")) 35 | } 36 | it("常规,单页") { 37 | // https://novelup.plus/story/358276052 38 | val metadata = provider.getMetadata("358276052") 39 | metadata.toc.size.shouldBe(1) 40 | } 41 | it("常规,无标签") { 42 | // https://novelup.plus/story/140197887 43 | val metadata = provider.getMetadata("140197887") 44 | metadata.keywords.shouldBeEmpty() 45 | } 46 | } 47 | 48 | describe("getChapter") { 49 | it("常规,有罗马音") { 50 | // https://novelup.plus/story/206612087/614254159 51 | val chapter = provider.getChapter("206612087", "614254159") 52 | chapter.paragraphs[6].shouldBe(" ケフェウス帝国と神聖アルゴ王国の国境に広がる原生林は昏き森と呼ばれている。") 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /server/src/test/kotlin/util/Epub.kt: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import io.kotest.core.spec.style.DescribeSpec 4 | import util.epub.EpubBook 5 | 6 | class EpubTest : DescribeSpec({ 7 | it("package") { 8 | val epub = EpubBook() 9 | val identifier = "id" 10 | epub.addIdentifier(identifier, true) 11 | 12 | epub.addTitle("title") 13 | epub.addLanguage("ja") 14 | epub.addDescription("balabala") 15 | val doc = EpubBook.Writer(epub).createPackageDocument() 16 | println(doc) 17 | } 18 | }) -------------------------------------------------------------------------------- /web-extension/Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | mv src extension 3 | zip -r extension.zip extension 4 | mv extension src -------------------------------------------------------------------------------- /web-extension/src/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/auto-novel/0e12ae4515d601860e4acd4d3aa3e854fdfa5c33/web-extension/src/icons/128.png -------------------------------------------------------------------------------- /web-extension/src/icons/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/auto-novel/0e12ae4515d601860e4acd4d3aa3e854fdfa5c33/web-extension/src/icons/16.png -------------------------------------------------------------------------------- /web-extension/src/icons/19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/auto-novel/0e12ae4515d601860e4acd4d3aa3e854fdfa5c33/web-extension/src/icons/19.png -------------------------------------------------------------------------------- /web-extension/src/icons/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/auto-novel/0e12ae4515d601860e4acd4d3aa3e854fdfa5c33/web-extension/src/icons/256.png -------------------------------------------------------------------------------- /web-extension/src/icons/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/auto-novel/0e12ae4515d601860e4acd4d3aa3e854fdfa5c33/web-extension/src/icons/32.png -------------------------------------------------------------------------------- /web-extension/src/icons/38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/auto-novel/0e12ae4515d601860e4acd4d3aa3e854fdfa5c33/web-extension/src/icons/38.png -------------------------------------------------------------------------------- /web-extension/src/icons/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/auto-novel/0e12ae4515d601860e4acd4d3aa3e854fdfa5c33/web-extension/src/icons/48.png -------------------------------------------------------------------------------- /web-extension/src/icons/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/auto-novel/0e12ae4515d601860e4acd4d3aa3e854fdfa5c33/web-extension/src/icons/512.png -------------------------------------------------------------------------------- /web-extension/src/icons/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/auto-novel/0e12ae4515d601860e4acd4d3aa3e854fdfa5c33/web-extension/src/icons/64.png -------------------------------------------------------------------------------- /web-extension/src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "version": "1.0.11", 4 | "name": "轻小说机翻机器人", 5 | "description": "解除浏览器跨域限制,以启用网页端的机翻功能", 6 | "permissions": [ 7 | "", 8 | "*://*.fishhawk.top/*", 9 | "*://*.baidu.com/*", 10 | "*://*.youdao.com/*", 11 | "*://*.openai.com/*", 12 | "*://*.amazon.co.jp/*", 13 | "*://kakuyomu.jp/*", 14 | "*://*.syosetu.com/*", 15 | "*://novelup.plus/*", 16 | "*://syosetu.org/*", 17 | "*://*.pixiv.net/*", 18 | "*://*.alphapolis.co.jp/*", 19 | "*://novelism.jp/*", 20 | "webRequest", 21 | "webRequestBlocking", 22 | "declarativeNetRequest", 23 | "cookies", 24 | "management", 25 | "contextMenus", 26 | "debugger" 27 | ], 28 | "declarative_net_request": { 29 | "rule_resources": [ 30 | { 31 | "id": "x-frame", 32 | "enabled": true, 33 | "path": "rulesets/x-frame.json" 34 | }, 35 | { 36 | "id": "overwrite-origin", 37 | "enabled": false, 38 | "path": "rulesets/overwrite-origin.json" 39 | }, 40 | { 41 | "id": "allow-credentials", 42 | "enabled": true, 43 | "path": "rulesets/allow-credentials.json" 44 | }, 45 | { 46 | "id": "allow-headers", 47 | "enabled": false, 48 | "path": "rulesets/allow-headers.json" 49 | }, 50 | { 51 | "id": "csp", 52 | "enabled": false, 53 | "path": "rulesets/csp.json" 54 | } 55 | ] 56 | }, 57 | "icons": { 58 | "16": "icons/16.png", 59 | "32": "icons/32.png", 60 | "48": "icons/48.png", 61 | "64": "icons/64.png", 62 | "128": "icons/128.png", 63 | "256": "icons/256.png", 64 | "512": "icons/512.png" 65 | }, 66 | "homepage_url": "https://books.fishhawk.top/", 67 | "browser_action": {}, 68 | "background": { 69 | "scripts": [ 70 | "worker.js" 71 | ] 72 | } 73 | } -------------------------------------------------------------------------------- /web-extension/src/rulesets/allow-credentials.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "id": 1, 3 | "priority": 1, 4 | "action": { 5 | "type": "modifyHeaders", 6 | "responseHeaders": [{ 7 | "operation": "set", 8 | "header": "Access-Control-Allow-Credentials", 9 | "value": "true" 10 | }] 11 | }, 12 | "condition": {} 13 | }] 14 | -------------------------------------------------------------------------------- /web-extension/src/rulesets/allow-headers.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "id": 1, 3 | "priority": 1, 4 | "action": { 5 | "type": "modifyHeaders", 6 | "responseHeaders": [{ 7 | "operation": "set", 8 | "header": "Access-Control-Allow-Headers", 9 | "value": "*" 10 | }, { 11 | "operation": "set", 12 | "header": "Access-Control-Expose-Headers", 13 | "value": "*" 14 | }] 15 | }, 16 | "condition": { 17 | "excludedRequestMethods": ["options"] 18 | } 19 | }] 20 | -------------------------------------------------------------------------------- /web-extension/src/rulesets/csp.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "id": 1, 3 | "priority": 1, 4 | "action": { 5 | "type": "modifyHeaders", 6 | "responseHeaders": [{ 7 | "operation": "remove", 8 | "header": "content-security-policy" 9 | }, { 10 | "operation": "remove", 11 | "header": "content-security-policy-report-only" 12 | }, { 13 | "operation": "remove", 14 | "header": "x-webkit-csp" 15 | }, { 16 | "operation": "remove", 17 | "header": "x-content-security-policy" 18 | }] 19 | }, 20 | "condition": { 21 | "resourceTypes": ["main_frame"] 22 | } 23 | }] 24 | -------------------------------------------------------------------------------- /web-extension/src/rulesets/overwrite-origin.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "id": 1, 3 | "priority": 1, 4 | "action": { 5 | "type": "modifyHeaders", 6 | "responseHeaders": [{ 7 | "operation": "set", 8 | "header": "Access-Control-Allow-Origin", 9 | "value": "*" 10 | }] 11 | }, 12 | "condition": {} 13 | }] 14 | -------------------------------------------------------------------------------- /web-extension/src/rulesets/x-frame.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "id": 1, 3 | "priority": 1, 4 | "action": { 5 | "type": "modifyHeaders", 6 | "responseHeaders": [{ 7 | "operation": "remove", 8 | "header": "x-frame-options" 9 | }] 10 | }, 11 | "condition": { 12 | "resourceTypes": ["sub_frame"] 13 | } 14 | }] 15 | -------------------------------------------------------------------------------- /web/.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/ 3 | /src/auto-imports.d.ts 4 | /src/components.d.ts 5 | -------------------------------------------------------------------------------- /web/.husky/pre-commit: -------------------------------------------------------------------------------- 1 | cd web 2 | pnpm pretty-quick --staged -------------------------------------------------------------------------------- /web/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "htmlWhitespaceSensitivity": "ignore" 4 | } 5 | -------------------------------------------------------------------------------- /web/Caddyfile: -------------------------------------------------------------------------------- 1 | :80 { 2 | encode gzip 3 | 4 | handle { 5 | reverse_proxy /monitor* grafana:3000 6 | } 7 | 8 | handle { 9 | root * /dist 10 | file_server 11 | route { 12 | try_files {path} / 13 | header / Cache-Control "no-cache,no-store,max-age=0,must-revalidate" 14 | } 15 | header /assets/* Cache-Control "public, max-age=7776000" 16 | header /*.png Cache-Control "public, max-age=7776000" 17 | header /*.svg Cache-Control "public, max-age=7776000" 18 | header /*.webp Cache-Control "public, max-age=7776000" 19 | } 20 | 21 | @filename { 22 | query filename=* 23 | } 24 | 25 | handle_path /api* { 26 | reverse_proxy server:8081 27 | } 28 | 29 | handle_path /files-temp* { 30 | root * /data/files-temp 31 | file_server 32 | header @filename Content-Disposition "attachment; filename=\"{http.request.uri.query.filename}\"" 33 | } 34 | 35 | handle_path /files-wenku* { 36 | root * /data/files-wenku 37 | file_server 38 | } 39 | 40 | handle_path /files-extra* { 41 | root * /data/files-extra 42 | file_server 43 | header /*.png Cache-Control "public, max-age=7776000" 44 | header /*.webp Cache-Control "public, max-age=7776000" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:21-slim AS builder 2 | COPY . /web 3 | WORKDIR /web 4 | RUN npm i -g pnpm; \ 5 | pnpm install --frozen-lockfile --prod; \ 6 | pnpm run build 7 | 8 | FROM caddy:2.7.4 9 | COPY Caddyfile /etc/caddy/Caddyfile 10 | COPY --from=builder /web/dist /dist -------------------------------------------------------------------------------- /web/eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import { globalIgnores } from 'eslint/config'; 3 | import tseslint from 'typescript-eslint'; 4 | import pluginVue from 'eslint-plugin-vue'; 5 | import compat from 'eslint-plugin-compat'; 6 | import eslintConfigPrettier from 'eslint-config-prettier/flat'; 7 | import { 8 | defineConfigWithVueTs, 9 | vueTsConfigs, 10 | } from '@vue/eslint-config-typescript'; 11 | 12 | export default defineConfigWithVueTs( 13 | // { files: ["**/*.{js,mjs,cjs,ts,vue}"], plugins: { js }, extends: ["js/recommended"] }, 14 | globalIgnores(['**/*.d.ts']), 15 | { 16 | files: ['**/*.{js,mjs,cjs,ts,vue}'], 17 | languageOptions: { globals: globals.browser }, 18 | }, 19 | tseslint.configs.recommended, 20 | pluginVue.configs['flat/recommended'], 21 | vueTsConfigs.recommended, 22 | { 23 | files: ['**/*.vue'], 24 | languageOptions: { 25 | parserOptions: { parser: tseslint.parser }, 26 | }, 27 | }, 28 | compat.configs['flat/recommended'], 29 | eslintConfigPrettier, 30 | { 31 | rules: { 32 | 'no-undef': 'off', 33 | 'no-unused-vars': 'off', 34 | 'prefer-const': 'warn', 35 | 36 | 'vue/attributes-order': 'off', 37 | 'vue/max-attributes-per-line': 'off', 38 | 'vue/multi-word-component-names': 'off', 39 | 'vue/no-mutating-props': ['error', { shallowOnly: true }], 40 | 'vue/no-v-text-v-html-on-component': 'off', 41 | 42 | '@typescript-eslint/no-explicit-any': 'warn', 43 | '@typescript-eslint/no-namespace': 'off', 44 | '@typescript-eslint/no-unused-vars': 'off', 45 | '@typescript-eslint/no-empty-object-type': 'off', 46 | }, 47 | }, 48 | ); 49 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "auto-novel-web", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "browserslist": [ 7 | "edge>=88", 8 | "firefox>=78", 9 | "chrome>=87", 10 | "safari>=14" 11 | ], 12 | "scripts": { 13 | "dev": "vite", 14 | "dev:local": "cross-env LOCAL=1 vite", 15 | "build": "vite build && vue-tsc --noEmit", 16 | "build:analyze": "ENABLE_SONDA=1 vite build", 17 | "lint": "eslint src", 18 | "lint:dist": "eslint dist", 19 | "preview": "vite preview", 20 | "test": "vitest run", 21 | "prepare": "cd .. && husky web/.husky" 22 | }, 23 | "devDependencies": { 24 | "@eslint/js": "^9.25.1", 25 | "@types/node": "^20.17.30", 26 | "@vue/eslint-config-typescript": "^14.5.0", 27 | "cross-env": "^7.0.3", 28 | "eslint": "^9.25.1", 29 | "eslint-config-prettier": "^10.1.2", 30 | "eslint-plugin-compat": "^6.0.2", 31 | "eslint-plugin-prettier": "^5.2.6", 32 | "eslint-plugin-vue": "^10.0.0", 33 | "globals": "^16.0.0", 34 | "husky": "^9.1.7", 35 | "prettier": "3.3.2", 36 | "pretty-quick": "^4.1.1", 37 | "typescript-eslint": "^8.31.0", 38 | "vitest": "^3.1.1" 39 | }, 40 | "dependencies": { 41 | "@img-comparison-slider/vue": "^8.0.0", 42 | "@mdit/plugin-container": "^0.16.0", 43 | "@mdit/plugin-spoiler": "^0.16.0", 44 | "@types/diff": "^5.2.3", 45 | "@types/lodash-es": "^4.17.12", 46 | "@types/markdown-it": "^14.1.2", 47 | "@types/opencc-js": "^1.0.3", 48 | "@types/uuid": "^10.0.0", 49 | "@vicons/material": "^0.12.0", 50 | "@vitejs/plugin-vue": "^5.2.3", 51 | "@vueuse/core": "^10.11.1", 52 | "@zip.js/zip.js": "^2.7.60", 53 | "crypto-es": "^2.1.0", 54 | "diff": "^5.2.0", 55 | "idb": "^8.0.2", 56 | "jwt-decode": "^4.0.0", 57 | "ky": "1.7.5", 58 | "lodash-es": "^4.17.21", 59 | "markdown-it": "^14.1.0", 60 | "markdown-it-anchor": "^9.2.0", 61 | "naive-ui": "^2.41.0", 62 | "nanoid": "^5.1.5", 63 | "opencc-js": "^1.0.5", 64 | "pinia": "^2.3.1", 65 | "sonda": "^0.7.1", 66 | "typescript": "^5.8.3", 67 | "unplugin-auto-import": "^0.17.8", 68 | "unplugin-imagemin": "^0.5.20", 69 | "unplugin-vue-components": "^0.27.5", 70 | "uuid": "^10.0.0", 71 | "vite": "^6.2.5", 72 | "vite-plugin-html": "^3.2.2", 73 | "vite-tsconfig-paths": "^4.3.2", 74 | "vue": "^3.5.13", 75 | "vue-draggable-plus": "^0.5.6", 76 | "vue-router": "^4.5.0", 77 | "vue-tsc": "^2.2.8" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/auto-novel/0e12ae4515d601860e4acd4d3aa3e854fdfa5c33/web/public/favicon.ico -------------------------------------------------------------------------------- /web/public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /web/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: /$ 3 | Allow: /icon.svg$ 4 | Disallow: / -------------------------------------------------------------------------------- /web/src/components/Bulletin.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | -------------------------------------------------------------------------------- /web/src/components/CA.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | -------------------------------------------------------------------------------- /web/src/components/CActionWrapper.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 13 | -------------------------------------------------------------------------------- /web/src/components/CButton.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 51 | -------------------------------------------------------------------------------- /web/src/components/CButtonConfirm.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 31 | -------------------------------------------------------------------------------- /web/src/components/CDrawerLeft.vue: -------------------------------------------------------------------------------- 1 | 29 | -------------------------------------------------------------------------------- /web/src/components/CDrawerRight.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 30 | -------------------------------------------------------------------------------- /web/src/components/CIconButton.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 20 | -------------------------------------------------------------------------------- /web/src/components/CLayout.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 37 | -------------------------------------------------------------------------------- /web/src/components/CModal.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 43 | -------------------------------------------------------------------------------- /web/src/components/CRadioGroup.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | -------------------------------------------------------------------------------- /web/src/components/CResult.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 23 | -------------------------------------------------------------------------------- /web/src/components/CSelectOverlay.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | 17 | 33 | -------------------------------------------------------------------------------- /web/src/components/CXScrollbar.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /web/src/components/EmailButton.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 69 | -------------------------------------------------------------------------------- /web/src/components/ImageCard.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 32 | -------------------------------------------------------------------------------- /web/src/components/OrderSort.vue: -------------------------------------------------------------------------------- 1 | 41 | 60 | -------------------------------------------------------------------------------- /web/src/components/RobotIcon.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 55 | -------------------------------------------------------------------------------- /web/src/components/SearchInput.vue: -------------------------------------------------------------------------------- 1 | 13 | 26 | -------------------------------------------------------------------------------- /web/src/components/SectionHeader.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 18 | -------------------------------------------------------------------------------- /web/src/components/TranslatorCheck.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 60 | -------------------------------------------------------------------------------- /web/src/components/comment/CommentEditor.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 68 | -------------------------------------------------------------------------------- /web/src/components/markdown/MarkdownGuideModal.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 66 | -------------------------------------------------------------------------------- /web/src/components/markdown/MarkdownToolbarButton.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 36 | -------------------------------------------------------------------------------- /web/src/components/overlay/DropZone.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 48 | 49 | 92 | -------------------------------------------------------------------------------- /web/src/data/CachedSegRepository.ts: -------------------------------------------------------------------------------- 1 | import { DBSchema, openDB } from 'idb'; 2 | 3 | interface SegCacheDBSchema extends DBSchema { 4 | 'gpt-seg-cache': { 5 | key: string; 6 | value: { hash: string; text: string[] }; 7 | }; 8 | 'sakura-seg-cache': { 9 | key: string; 10 | value: { hash: string; text: string[] }; 11 | }; 12 | } 13 | 14 | type CachedSegType = 'gpt-seg-cache' | 'sakura-seg-cache'; 15 | 16 | export const createCachedSegRepository = async () => { 17 | const db = await openDB('test', 3, { 18 | upgrade(db, _oldVersion, _newVersion, _transaction, _event) { 19 | try { 20 | db.createObjectStore('gpt-seg-cache', { keyPath: 'hash' }); 21 | } catch (e) { 22 | console.log(e); 23 | } 24 | try { 25 | db.createObjectStore('sakura-seg-cache', { keyPath: 'hash' }); 26 | } catch (e) { 27 | console.log(e); 28 | } 29 | }, 30 | }); 31 | 32 | return { 33 | clear: (type: CachedSegType) => db.clear(type), 34 | get: (type: CachedSegType, hash: string) => 35 | db.get(type, hash).then((it) => it?.text), 36 | create: (type: CachedSegType, hash: string, text: string[]) => 37 | db.put(type, { hash, text }), 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /web/src/data/api/ArticleRepository.ts: -------------------------------------------------------------------------------- 1 | import { Article, ArticleCategory, ArticleSimplified } from '@/model/Article'; 2 | import { Page } from '@/model/Page'; 3 | 4 | import { client } from './client'; 5 | 6 | const listArticle = (params: { 7 | page: number; 8 | pageSize: number; 9 | category: ArticleCategory; 10 | }) => 11 | client 12 | .get('article', { searchParams: params }) 13 | .json>(); 14 | 15 | const getArticle = (id: string) => client.get(`article/${id}`).json
(); 16 | const deleteArticle = (id: string) => client.delete(`article/${id}`); 17 | 18 | interface ArticleBody { 19 | title: string; 20 | content: string; 21 | category: ArticleCategory; 22 | } 23 | 24 | const createArticle = (json: ArticleBody) => 25 | client.post('article', { json }).text(); 26 | 27 | const updateArticle = (id: string, json: ArticleBody) => 28 | client.put(`article/${id}`, { json }).text(); 29 | 30 | const pinArticle = (id: string) => client.put(`article/${id}/pinned`); 31 | const unpinArticle = (id: string) => client.delete(`article/${id}/pinned`); 32 | 33 | const lockArticle = (id: string) => client.put(`article/${id}/locked`); 34 | const unlockArticle = (id: string) => client.delete(`article/${id}/locked`); 35 | 36 | const hideArticle = (id: string) => client.put(`article/${id}/hidden`); 37 | const unhideArticle = (id: string) => client.delete(`article/${id}/hidden`); 38 | 39 | export const ArticleRepository = { 40 | listArticle, 41 | 42 | getArticle, 43 | createArticle, 44 | updateArticle, 45 | deleteArticle, 46 | 47 | pinArticle, 48 | unpinArticle, 49 | lockArticle, 50 | unlockArticle, 51 | hideArticle, 52 | unhideArticle, 53 | }; 54 | -------------------------------------------------------------------------------- /web/src/data/api/CommentRepository.ts: -------------------------------------------------------------------------------- 1 | import { Comment1 } from '@/model/Comment'; 2 | import { Page } from '@/model/Page'; 3 | 4 | import { client } from './client'; 5 | 6 | const listComment = (params: { 7 | site: string; 8 | page: number; 9 | parentId?: string; 10 | pageSize: number; 11 | }) => client.get('comment', { searchParams: params }).json>(); 12 | 13 | const createComment = (json: { 14 | site: string; 15 | parent: string | undefined; 16 | content: string; 17 | }) => client.post('comment', { json }); 18 | 19 | const deleteComment = (id: string) => client.delete(`comment/${id}`); 20 | const hideComment = (id: string) => client.put(`comment/${id}/hidden`); 21 | const unhideComment = (id: string) => client.delete(`comment/${id}/hidden`); 22 | 23 | export const CommentRepository = { 24 | listComment, 25 | createComment, 26 | deleteComment, 27 | hideComment, 28 | unhideComment, 29 | }; 30 | -------------------------------------------------------------------------------- /web/src/data/api/OperationRepository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MergeHistoryDto, 3 | OperationHistory, 4 | OperationType, 5 | } from '@/model/Operation'; 6 | import { Page } from '@/model/Page'; 7 | 8 | import { client } from './client'; 9 | 10 | const listOperationHistory = (params: { 11 | page: number; 12 | pageSize: number; 13 | type: OperationType; 14 | }) => 15 | client 16 | .get('operation-history', { searchParams: params }) 17 | .json>(); 18 | 19 | const deleteOperationHistory = (id: string) => 20 | client.delete(`operation-history/${id}`); 21 | 22 | const listMergeHistory = (page: number) => 23 | client 24 | .get('operation-history/toc-merge/', { searchParams: { page } }) 25 | .json>(); 26 | 27 | const deleteMergeHistory = (id: string) => 28 | client.delete(`operation-history/toc-merge/${id}`); 29 | 30 | export const OperationRepository = { 31 | listOperationHistory, 32 | deleteOperationHistory, 33 | 34 | listMergeHistory, 35 | deleteMergeHistory, 36 | }; 37 | -------------------------------------------------------------------------------- /web/src/data/api/UserRepository.ts: -------------------------------------------------------------------------------- 1 | import { Page } from '@/model/Page'; 2 | import { UserOutline, UserRole } from '@/model/User'; 3 | 4 | import { client } from './client'; 5 | 6 | const listUser = (params: { page: number; pageSize: number; role: UserRole }) => 7 | client.get('user', { searchParams: params }).json>(); 8 | 9 | const updateRole = (userId: string, json: { role: UserRole }) => 10 | client.put(`user/${userId}/role`, { json }); 11 | 12 | export const UserRepository = { 13 | listUser, 14 | updateRole, 15 | }; 16 | -------------------------------------------------------------------------------- /web/src/data/api/client.ts: -------------------------------------------------------------------------------- 1 | import ky from 'ky'; 2 | 3 | let client = ky.create({ prefixUrl: '/api', timeout: 60000 }); 4 | let authToken: string | undefined = undefined; 5 | 6 | export { client }; 7 | 8 | export const updateToken = (token?: string) => { 9 | authToken = token; 10 | let headers; 11 | if (token !== undefined) { 12 | headers = { Authorization: 'Bearer ' + token }; 13 | } else { 14 | headers = {}; 15 | } 16 | client = client.extend({ 17 | headers, 18 | }); 19 | }; 20 | 21 | export const uploadFile = ( 22 | url: string, 23 | name: string, 24 | file: File, 25 | onProgress: (p: number) => void, 26 | ) => { 27 | return new Promise(function (resolve, reject) { 28 | const formData = new FormData(); 29 | formData.append(name, file); 30 | 31 | const xhr = new XMLHttpRequest(); 32 | xhr.open('POST', url); 33 | xhr.setRequestHeader('Authorization', 'Bearer ' + authToken); 34 | xhr.onload = () => { 35 | if (xhr.status === 200) { 36 | resolve(xhr.responseText); 37 | } else { 38 | reject(new Error(xhr.responseText)); 39 | } 40 | }; 41 | xhr.upload.addEventListener('progress', (e) => { 42 | const percent = e.lengthComputable ? (e.loaded / e.total) * 100 : 0; 43 | onProgress(Math.ceil(percent)); 44 | }); 45 | xhr.send(formData); 46 | }); 47 | }; 48 | -------------------------------------------------------------------------------- /web/src/data/api/index.ts: -------------------------------------------------------------------------------- 1 | import { HTTPError, TimeoutError } from 'ky'; 2 | 3 | export { ArticleRepository } from './ArticleRepository'; 4 | export { CommentRepository } from './CommentRepository'; 5 | export { OperationRepository } from './OperationRepository'; 6 | export { UserRepository } from './UserRepository'; 7 | export { WebNovelRepository } from './WebNovelRepository'; 8 | export { WenkuNovelRepository } from './WenkuNovelRepository'; 9 | 10 | export const formatError = (error: unknown) => { 11 | if (error instanceof HTTPError) { 12 | let messageOverride: string | null = null; 13 | if (error.response.status === 429) { 14 | messageOverride = '操作额度耗尽,等明天再试吧'; 15 | } 16 | return error.response 17 | .text() 18 | .then( 19 | (message) => `[${error.response.status}]${messageOverride ?? message}`, 20 | ); 21 | } else if (error instanceof TimeoutError) { 22 | return '请求超时'; 23 | } else { 24 | return `${error}`; 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /web/src/data/auth/Auth.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/auto-novel/0e12ae4515d601860e4acd4d3aa3e854fdfa5c33/web/src/data/auth/Auth.ts -------------------------------------------------------------------------------- /web/src/data/auth/AuthApi.ts: -------------------------------------------------------------------------------- 1 | import { client } from '../api/client'; 2 | 3 | export interface SignUpBody { 4 | email: string; 5 | emailCode: string; 6 | username: string; 7 | password: string; 8 | } 9 | 10 | const signUp = (json: SignUpBody) => 11 | client.post('auth/sign-up', { json }).text(); 12 | 13 | export interface SignInBody { 14 | emailOrUsername: string; 15 | password: string; 16 | } 17 | 18 | const signIn = (json: SignInBody) => 19 | client.post(`auth/sign-in`, { json }).text(); 20 | 21 | const renew = () => client.get(`auth/renew`).text(); 22 | 23 | const verifyEmail = (email: string) => 24 | client.post('auth/verify-email', { 25 | searchParams: { email }, 26 | }); 27 | 28 | const sendResetPasswordEmail = (emailOrUsername: string) => 29 | client.post('auth/reset-password-email', { 30 | searchParams: { emailOrUsername }, 31 | }); 32 | 33 | const resetPassword = ( 34 | emailOrUsername: string, 35 | token: string, 36 | password: string, 37 | ) => 38 | client.post('auth/reset-password', { 39 | searchParams: { emailOrUsername }, 40 | json: { token, password }, 41 | }); 42 | 43 | export const AuthApi = { 44 | signIn, 45 | renew, 46 | // 47 | signUp, 48 | verifyEmail, 49 | // 50 | sendResetPasswordEmail, 51 | resetPassword, 52 | }; 53 | -------------------------------------------------------------------------------- /web/src/data/favored/Favored.ts: -------------------------------------------------------------------------------- 1 | export interface Favored { 2 | id: string; 3 | title: string; 4 | } 5 | 6 | export interface FavoredList { 7 | web: Favored[]; 8 | wenku: Favored[]; 9 | local: Favored[]; 10 | } 11 | -------------------------------------------------------------------------------- /web/src/data/local/CreateVolume.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | 3 | import { parseFile, Srt } from '@/util/file'; 4 | 5 | import { EpubParserV1 } from './EpubParser'; 6 | import { LocalVolumeDao } from './LocalVolumeDao'; 7 | 8 | export const createVolume = async ( 9 | dao: LocalVolumeDao, 10 | file: File, 11 | favoredId: string, 12 | ) => { 13 | const id = file.name; 14 | if ((await dao.getMetadata(id)) !== undefined) { 15 | throw Error('小说已经存在'); 16 | } 17 | 18 | const chapters: { chapterId: string; paragraphs: string[] }[] = []; 19 | 20 | const myFile = await parseFile(file); 21 | 22 | if (myFile.type === 'txt') { 23 | const lines = myFile.text.split('\n'); 24 | const chunkSize = 1000; 25 | for (let i = 0; i < lines.length; i += chunkSize) { 26 | const paragraphs = lines.slice(i, i + chunkSize); 27 | chapters.push({ chapterId: i.toString(), paragraphs }); 28 | } 29 | } else if (myFile.type === 'epub') { 30 | for await (const item of myFile.iterDoc()) { 31 | const paragraphs = EpubParserV1.extractText(item.doc); 32 | chapters.push({ chapterId: item.href, paragraphs }); 33 | } 34 | } else if (myFile.type === 'srt') { 35 | const lines = myFile.subtitles 36 | .flatMap((it) => it.text) 37 | .map((it) => Srt.cleanFormat(it)); 38 | chapters.push({ chapterId: '0'.toString(), paragraphs: lines }); 39 | } 40 | 41 | for (const { chapterId, paragraphs } of chapters) { 42 | await dao.createChapter({ 43 | id: `${id}/${chapterId}`, 44 | volumeId: id, 45 | paragraphs, 46 | }); 47 | } 48 | await dao.createMetadata({ 49 | id, 50 | createAt: Date.now(), 51 | toc: chapters.map((it) => ({ 52 | chapterId: it.chapterId, 53 | })), 54 | glossaryId: uuidv4(), 55 | glossary: {}, 56 | favoredId, 57 | }); 58 | await dao.createFile(id, file); 59 | }; 60 | -------------------------------------------------------------------------------- /web/src/data/local/EpubParser.ts: -------------------------------------------------------------------------------- 1 | interface EpubParser { 2 | extractText: (doc: Document) => string[]; 3 | injectTranslation: ( 4 | doc: Document, 5 | mode: 'zh' | 'jp-zh' | 'zh-jp', 6 | zhLinesList: string[][], 7 | ) => Document; 8 | } 9 | 10 | export const EpubParserV1: EpubParser = { 11 | extractText: (doc: Document) => { 12 | Array.from(doc.getElementsByTagName('rt')).forEach((node) => 13 | node.parentNode!.removeChild(node), 14 | ); 15 | Array.from(doc.getElementsByTagName('rp')).forEach((node) => 16 | node.parentNode!.removeChild(node), 17 | ); 18 | return Array.from(doc.body.getElementsByTagName('p')) 19 | .map((el) => el.innerText) 20 | .filter((it) => it.trim().length !== 0); 21 | }, 22 | injectTranslation: ( 23 | doc: Document, 24 | mode: 'zh' | 'jp-zh' | 'zh-jp', 25 | zhLinesList: string[][], 26 | ) => { 27 | Array.from(doc.body.getElementsByTagName('p')) 28 | .filter((el) => el.innerText.trim().length !== 0) 29 | .forEach((el, index) => { 30 | if (mode === 'zh') { 31 | zhLinesList.forEach((lines) => { 32 | const p = document.createElement('p'); 33 | const t = document.createTextNode(lines[index]); 34 | p.appendChild(t); 35 | el.parentNode!.insertBefore(p, el); 36 | }); 37 | el.parentNode!.removeChild(el); 38 | } else if (mode === 'jp-zh') { 39 | zhLinesList.forEach((lines) => { 40 | const p = document.createElement('p'); 41 | const t = document.createTextNode(lines[index]); 42 | p.appendChild(t); 43 | el.parentNode!.insertBefore(p, el.nextSibling); 44 | }); 45 | el.setAttribute('style', 'opacity:0.4;'); 46 | } else { 47 | zhLinesList.forEach((lines) => { 48 | const p = document.createElement('p'); 49 | const t = document.createTextNode(lines[index]); 50 | p.appendChild(t); 51 | el.parentNode!.insertBefore(p, el); 52 | }); 53 | el.setAttribute('style', 'opacity:0.4;'); 54 | } 55 | }); 56 | 57 | return doc; 58 | }, 59 | }; 60 | -------------------------------------------------------------------------------- /web/src/data/local/index.ts: -------------------------------------------------------------------------------- 1 | export { createLocalVolumeRepository } from './LocalVolumeRepository'; 2 | export { createLocalVolumeDao } from './LocalVolumeDao'; 3 | -------------------------------------------------------------------------------- /web/src/data/read-history/ReadHistoryApi.ts: -------------------------------------------------------------------------------- 1 | import { Page } from '@/model/Page'; 2 | import { WebNovelOutlineDto } from '@/model/WebNovel'; 3 | 4 | import { client } from '@/data/api/client'; 5 | 6 | const listReadHistoryWeb = (searchParams: { page: number; pageSize: number }) => 7 | client 8 | .get('user/read-history', { searchParams }) 9 | .json>(); 10 | 11 | const clearReadHistoryWeb = () => client.delete('user/read-history'); 12 | 13 | const updateReadHistoryWeb = ( 14 | providerId: string, 15 | novelId: string, 16 | chapterId: string, 17 | ) => 18 | client.put(`user/read-history/${providerId}/${novelId}`, { body: chapterId }); 19 | 20 | const deleteReadHistoryWeb = (providerId: string, novelId: string) => 21 | client.delete(`user/read-history/${providerId}/${novelId}`); 22 | 23 | // 24 | 25 | const isReadHistoryPaused = () => 26 | client.get('user/read-history/paused').json(); 27 | const pauseReadHistory = () => client.put('user/read-history/paused'); 28 | const resumeReadHistory = () => client.delete('user/read-history/paused'); 29 | 30 | export const ReadHistoryApi = { 31 | listReadHistoryWeb, 32 | clearReadHistoryWeb, 33 | updateReadHistoryWeb, 34 | deleteReadHistoryWeb, 35 | // 36 | isReadHistoryPaused, 37 | pauseReadHistory, 38 | resumeReadHistory, 39 | }; 40 | -------------------------------------------------------------------------------- /web/src/data/read-history/ReadHistoryRepository.ts: -------------------------------------------------------------------------------- 1 | import { ReadHistoryApi } from './ReadHistoryApi'; 2 | 3 | export const createReadHistoryRepository = () => { 4 | const readHistoryPaused = ref(false); 5 | 6 | let remoteFetched = false; 7 | const loadReadHistoryPausedState = async () => { 8 | if (remoteFetched) return; 9 | readHistoryPaused.value = await ReadHistoryApi.isReadHistoryPaused(); 10 | remoteFetched = true; 11 | }; 12 | 13 | const pauseReadHistory = async () => { 14 | await ReadHistoryApi.pauseReadHistory(); 15 | readHistoryPaused.value = true; 16 | }; 17 | const resumeReadHistory = async () => { 18 | await ReadHistoryApi.resumeReadHistory(); 19 | readHistoryPaused.value = false; 20 | }; 21 | 22 | return { 23 | readHistoryPaused, 24 | // 25 | listReadHistoryWeb: ReadHistoryApi.listReadHistoryWeb, 26 | clearReadHistoryWeb: ReadHistoryApi.clearReadHistoryWeb, 27 | updateReadHistoryWeb: ReadHistoryApi.updateReadHistoryWeb, 28 | deleteReadHistoryWeb: ReadHistoryApi.deleteReadHistoryWeb, 29 | // 30 | loadReadHistoryPausedState, 31 | pauseReadHistory, 32 | resumeReadHistory, 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /web/src/data/setting/SettingRepository.ts: -------------------------------------------------------------------------------- 1 | import { useLocalStorage } from '@vueuse/core'; 2 | 3 | import { ReaderSetting, Setting } from '@/data/setting/Setting'; 4 | import { CCUtil } from '@/util/cc'; 5 | 6 | export const createSettingRepository = () => { 7 | const setting = useLocalStorage('setting', Setting.defaultValue, { 8 | mergeDefaults: true, 9 | }); 10 | Setting.migrate(setting.value); 11 | 12 | const cc = ref(CCUtil.defaultConverter); 13 | 14 | const activateCC = () => { 15 | watch( 16 | () => setting.value.locale, 17 | async (locale) => { 18 | cc.value = await CCUtil.createConverter(locale); 19 | }, 20 | { immediate: true }, 21 | ); 22 | }; 23 | 24 | return { setting, cc, activateCC }; 25 | }; 26 | 27 | export const createReaderSettingRepository = () => { 28 | const setting = useLocalStorage( 29 | 'readerSetting', 30 | ReaderSetting.defaultValue, 31 | { mergeDefaults: true }, 32 | ); 33 | ReaderSetting.migrate(setting.value); 34 | return { setting }; 35 | }; 36 | -------------------------------------------------------------------------------- /web/src/data/stores/BlockUserCommentRepository.ts: -------------------------------------------------------------------------------- 1 | import { useLocalStorage } from '@vueuse/core'; 2 | 3 | interface BlockUserComment { 4 | usernames: string[]; 5 | } 6 | 7 | export const createBlockUserCommentRepository = () => { 8 | const ref = useLocalStorage('blockComment', { 9 | usernames: [], 10 | }); 11 | 12 | const add = (username: string) => { 13 | if (!ref.value.usernames.includes(username)) { 14 | ref.value.usernames.push(username); 15 | } 16 | }; 17 | 18 | const remove = (username: string) => { 19 | ref.value.usernames = ref.value.usernames.filter( 20 | (name) => name !== username, 21 | ); 22 | }; 23 | 24 | return { 25 | ref, 26 | add, 27 | remove, 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /web/src/data/stores/DraftRepository.ts: -------------------------------------------------------------------------------- 1 | import { useLocalStorage } from '@vueuse/core'; 2 | import { throttle } from 'lodash-es'; 3 | 4 | export interface Draft { 5 | text: string; 6 | createdAt: Date; 7 | } 8 | interface DraftRegistry { 9 | [draftId: string]: { [createdAt: number]: string }; 10 | } 11 | 12 | export const createDraftRepository = () => { 13 | const registry = useLocalStorage('draft', {}); 14 | 15 | const getDraft = (draftId: string) => { 16 | if (!(draftId in registry.value)) return []; 17 | return Object.entries(registry.value[draftId]).map( 18 | ([key, value]) => 19 | { 20 | text: value, 21 | createdAt: new Date(Number(key)), 22 | }, 23 | ); 24 | }; 25 | 26 | const addDraft = throttle( 27 | (draftId: string, createdAt: number, text: string) => { 28 | if (!(draftId in registry.value)) { 29 | registry.value[draftId] = {}; 30 | } 31 | if (text === undefined) { 32 | delete registry.value[draftId][createdAt]; 33 | } else { 34 | registry.value[draftId][createdAt] = text; 35 | } 36 | }, 37 | 5000, 38 | ); 39 | 40 | const removeDraft = (draftId: string) => { 41 | delete registry.value[draftId]; 42 | }; 43 | 44 | const cleanupExpiredDrafts = () => { 45 | const expirationTime = 1000 * 60 * 60 * 24 * 3; // 3 day 46 | const now = Date.now(); 47 | 48 | Object.keys(registry.value).forEach((draftId) => { 49 | const draft = registry.value[draftId]; 50 | Object.keys(draft).forEach((createAt) => { 51 | if (now - Number(createAt) > expirationTime) { 52 | delete draft[Number(createAt)]; 53 | } 54 | }); 55 | if (Object.keys(draft).length === 0) { 56 | delete registry.value[draftId]; 57 | } 58 | }); 59 | }; 60 | 61 | cleanupExpiredDrafts(); 62 | 63 | return { 64 | getDraft, 65 | addDraft, 66 | removeDraft, 67 | }; 68 | }; 69 | -------------------------------------------------------------------------------- /web/src/data/stores/ReadPositionRepository.ts: -------------------------------------------------------------------------------- 1 | import { GenericNovelId } from '@/model/Common'; 2 | import { safeJson } from '@/util'; 3 | 4 | interface LocalStorage { 5 | get(): T; 6 | set(value: T): void; 7 | } 8 | 9 | const syncLocalStorage = ( 10 | key: string, 11 | initialValue: T, 12 | ): LocalStorage => { 13 | return { 14 | get: () => { 15 | const text = window.localStorage.getItem(key) ?? ''; 16 | const value = safeJson(text) ?? initialValue; 17 | return value; 18 | }, 19 | set: (value: T) => { 20 | const text = JSON.stringify(value); 21 | window.localStorage.setItem(key, text); 22 | }, 23 | }; 24 | }; 25 | 26 | interface ReadPosition { 27 | chapterId: string; 28 | scrollY: number; 29 | } 30 | 31 | type ReaderPositions = Record; 32 | 33 | export const createReadPositionRepository = () => { 34 | const syncStorage = syncLocalStorage('readPosition', {}); 35 | 36 | const addPosition = (gnid: GenericNovelId, position: ReadPosition) => { 37 | const positions = syncStorage.get(); 38 | if (position.scrollY === 0) { 39 | delete positions[GenericNovelId.toString(gnid)]; 40 | } else { 41 | positions[GenericNovelId.toString(gnid)] = position; 42 | } 43 | syncStorage.set(positions); 44 | }; 45 | 46 | const getPosition = (gnid: GenericNovelId) => { 47 | const positions = syncStorage.get(); 48 | return positions[GenericNovelId.toString(gnid)]; 49 | }; 50 | 51 | return { 52 | addPosition, 53 | getPosition, 54 | }; 55 | }; 56 | -------------------------------------------------------------------------------- /web/src/data/stores/RuleViewedRepository.ts: -------------------------------------------------------------------------------- 1 | import { useLocalStorage } from '@vueuse/core'; 2 | 3 | interface RuleViewed { 4 | wenkuUploadRule: number; 5 | } 6 | 7 | export const createRuleViewedRepository = () => { 8 | const ref = useLocalStorage('readState', { 9 | wenkuUploadRule: 0, 10 | }); 11 | return { 12 | ref, 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /web/src/data/stores/SearchHistoryRepository.ts: -------------------------------------------------------------------------------- 1 | import { useLocalStorage } from '@vueuse/core'; 2 | 3 | interface SearchHistory { 4 | queries: string[]; 5 | tags: { tag: string; used: number }[]; 6 | } 7 | 8 | const createSearchHistoryRepository = (key: string) => { 9 | const ref = useLocalStorage(key, { 10 | queries: [], 11 | tags: [], 12 | }); 13 | 14 | const addHistory = (query: string) => { 15 | query = query.trim(); 16 | const parts = query.split(' '); 17 | 18 | if (query === '' || parts.length === 0) { 19 | return; 20 | } 21 | 22 | const tags = parts.filter((it) => it.endsWith('$')); 23 | tags.forEach((part) => { 24 | const inHistory = ref.value.tags.find((it) => it.tag === part); 25 | if (inHistory === undefined) { 26 | ref.value.tags.push({ tag: part, used: 1 }); 27 | } else { 28 | inHistory.used += 1; 29 | } 30 | }); 31 | 32 | const newQueries = ref.value.queries.filter((it) => it !== query); 33 | newQueries.unshift(query); 34 | ref.value.queries = newQueries.slice(0, 8); 35 | }; 36 | 37 | const clear = () => { 38 | ref.value.queries = []; 39 | ref.value.tags = []; 40 | }; 41 | 42 | return { 43 | ref, 44 | addHistory, 45 | clear, 46 | }; 47 | }; 48 | 49 | export const createWebSearchHistoryRepository = () => 50 | createSearchHistoryRepository('webSearchHistory'); 51 | 52 | export const createWenkuSearchHistoryRepository = () => 53 | createSearchHistoryRepository('wenkuSearchHistory'); 54 | -------------------------------------------------------------------------------- /web/src/data/stores/index.ts: -------------------------------------------------------------------------------- 1 | export { createDraftRepository } from './DraftRepository'; 2 | export { createReadPositionRepository } from './ReadPositionRepository'; 3 | export { createRuleViewedRepository } from './RuleViewedRepository'; 4 | export { 5 | createWebSearchHistoryRepository, 6 | createWenkuSearchHistoryRepository, 7 | } from './SearchHistoryRepository'; 8 | export { 9 | createGptWorkspaceRepository, 10 | createSakuraWorkspaceRepository, 11 | } from './WorkspaceRepository'; 12 | export { createBlockUserCommentRepository } from './BlockUserCommentRepository'; 13 | -------------------------------------------------------------------------------- /web/src/data/third-party/AmazonRepository.ts: -------------------------------------------------------------------------------- 1 | import ky, { Options } from 'ky'; 2 | 3 | export const createAmazonRepository = () => { 4 | const getHtml = async (url: string, options?: Options) => { 5 | const response = await ky.get(url, { 6 | prefixUrl: 'https://www.amazon.co.jp', 7 | redirect: 'manual', 8 | credentials: 'include', 9 | retry: 0, 10 | ...options, 11 | }); 12 | 13 | if (response.status === 404) { 14 | throw Error('小说不存在,请删除cookie并使用日本IP重试'); 15 | } else if (response.status === 0) { 16 | throw Error('触发年龄限制,请按说明使用插件'); 17 | } else if (!response.ok) { 18 | throw Error(`未知错误,${response.status}`); 19 | } 20 | const html = await response.text(); 21 | const parser = new DOMParser(); 22 | const doc = parser.parseFromString(html, 'text/html'); 23 | return doc; 24 | }; 25 | 26 | const getProduct = (asin: string) => getHtml(`dp/${asin}`); 27 | 28 | const getSerial = (asin: string, total: string) => 29 | getHtml('kindle-dbs/productPage/ajax/seriesAsinList', { 30 | searchParams: { 31 | asin, 32 | pageNumber: 1, 33 | pageSize: total, 34 | }, 35 | }); 36 | 37 | const search = (query: string) => 38 | getHtml('s', { 39 | searchParams: { 40 | k: query, 41 | i: 'stripbooks', 42 | }, 43 | }); 44 | 45 | return { 46 | getProduct, 47 | getSerial, 48 | search, 49 | }; 50 | }; 51 | -------------------------------------------------------------------------------- /web/src/data/third-party/BaiduRepository.ts: -------------------------------------------------------------------------------- 1 | import ky, { Options } from 'ky'; 2 | 3 | import { parseEventStream } from '@/util'; 4 | 5 | export const createBaiduRepository = () => { 6 | const client = ky.create({ 7 | prefixUrl: 'https://fanyi.baidu.com', 8 | credentials: 'include', 9 | retry: 0, 10 | }); 11 | 12 | const sug = () => { 13 | const formData = new FormData(); 14 | formData.append('kw', 'test'); 15 | return client 16 | .post('sug', { 17 | body: formData, 18 | }) 19 | .text(); 20 | }; 21 | 22 | const translate = (query: string, from: string, options: Options) => { 23 | return client 24 | .post('ait/text/translate', { 25 | headers: { 26 | accept: 'text/event-stream', 27 | }, 28 | json: { 29 | from, 30 | to: 'zh', 31 | query, 32 | corpusIds: [], 33 | domain: 'common', 34 | milliTimestamp: Date.now(), 35 | needPhonetic: false, 36 | qcSettings: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'], 37 | reference: '', 38 | }, 39 | ...options, 40 | }) 41 | .text() 42 | .then(parseEventStream); 43 | }; 44 | return { 45 | sug, 46 | translate, 47 | }; 48 | }; 49 | 50 | export type TranslateChunk = { 51 | errno: number; 52 | errmsg: string; 53 | data: 54 | | { 55 | event: 'Start' | 'StartTranslation' | 'TranslationSucceed' | 'Finished'; 56 | message: string; 57 | } 58 | | { 59 | event: 'Translating'; 60 | message: string; 61 | list: { 62 | id: string; 63 | paraIdx: number; 64 | src: string; 65 | dst: string; 66 | metadata: string; 67 | }[]; 68 | }; 69 | }; 70 | -------------------------------------------------------------------------------- /web/src/data/third-party/index.ts: -------------------------------------------------------------------------------- 1 | export { createAmazonRepository } from './AmazonRepository'; 2 | export { createBaiduRepository } from './BaiduRepository'; 3 | export { createOpenAiRepository } from './OpenAiRepository'; 4 | export { createOpenAiWebRepository } from './OpenAiWebRepository'; 5 | export { createYoudaoRepository } from './YoudaoRepository'; 6 | -------------------------------------------------------------------------------- /web/src/domain/smart-import/ApiGetSerial.ts: -------------------------------------------------------------------------------- 1 | import { Locator } from '@/data'; 2 | import { extractAsin } from './Common'; 3 | 4 | const parseSerial = (doc: Document) => { 5 | const authorsSet = new Set(); 6 | const artistsSet = new Set(); 7 | doc.querySelectorAll('[data-action="a-popover"]').forEach((element) => { 8 | element 9 | .getAttribute('data-a-popover')! 10 | .split('\\n') 11 | .map((it) => it.trim()) 12 | .forEach((contribution) => { 13 | if (contribution.endsWith('(著)')) { 14 | authorsSet.add(contribution.replace(/(\(著\))$/, '').trim()); 15 | } else if (contribution.endsWith('(イラスト)')) { 16 | artistsSet.add(contribution.replace(/(\(イラスト\))$/, '').trim()); 17 | } 18 | }); 19 | }); 20 | Array.from( 21 | doc.getElementsByClassName('series-childAsin-item-details-contributor'), 22 | ).forEach((element) => { 23 | const contribution = element.textContent!.trim().replace(/(,)$/, '').trim(); 24 | if (contribution.endsWith('(著)')) { 25 | authorsSet.add(contribution.replace(/(\(著\))$/, '').trim()); 26 | } else if (contribution.endsWith('(イラスト)')) { 27 | artistsSet.add(contribution.replace(/(\(イラスト\))$/, '').trim()); 28 | } 29 | }); 30 | 31 | const authors = [...authorsSet].filter((it) => !artistsSet.has(it)); 32 | const artists = [...artistsSet]; 33 | 34 | const volumes = Array.from( 35 | doc.getElementById('series-childAsin-batch_1')!.children, 36 | ).map((it) => { 37 | const titleLink = it.getElementsByClassName( 38 | 'a-size-base-plus a-link-normal itemBookTitle a-text-bold', 39 | )[0]!; 40 | const asin = extractAsin(titleLink.getAttribute('href')!)!; 41 | const title = titleLink.textContent!; 42 | const cover = it.getElementsByTagName('img')[0].getAttribute('src')!; 43 | return { asin, title, cover }; 44 | }); 45 | 46 | return { authors, artists, volumes }; 47 | }; 48 | 49 | export const getSerial = (asin: string, total: string) => 50 | Locator.amazonRepository().getSerial(asin, total).then(parseSerial); 51 | -------------------------------------------------------------------------------- /web/src/domain/smart-import/ApiSearch.ts: -------------------------------------------------------------------------------- 1 | import { Locator } from '@/data'; 2 | import { extractAsin } from './Common'; 3 | 4 | const parseSearch = (doc: Document) => { 5 | const items = Array.from( 6 | doc.getElementsByClassName('s-search-results')[0].children, 7 | ); 8 | return items 9 | .filter((item) => { 10 | if (!item.getAttribute('data-asin')) { 11 | return false; 12 | } 13 | 14 | // 排除漫画 15 | if ( 16 | Array.from(item.getElementsByTagName('a')) 17 | .map((el) => el.text) 18 | .some((text) => text === 'コミック (紙)' || text === 'コミック') 19 | ) { 20 | return false; 21 | } 22 | 23 | return true; 24 | }) 25 | .map((it) => { 26 | const asin = it.getAttribute('data-asin')!; 27 | const title = it.getElementsByTagName('h2')[0].textContent!; 28 | const cover = it.getElementsByTagName('img')[0].getAttribute('src')!; 29 | 30 | const serialAsin = Array.from(it.getElementsByTagName('a')) 31 | .filter((it) => { 32 | const href = it.getAttribute('href')!; 33 | const child = it.firstElementChild; 34 | return ( 35 | child && 36 | child.tagName === 'SPAN' && 37 | child.classList.length === 0 && 38 | (href.startsWith('/-/zh/dp/') || href.startsWith('/dp/')) 39 | ); 40 | }) 41 | .map((it) => extractAsin(it.getAttribute('href')!)) 42 | .find((asin) => asin); 43 | return { asin, title, cover, serialAsin }; 44 | }); 45 | }; 46 | 47 | export const search = (query: string) => 48 | Locator.amazonRepository().search(query).then(parseSearch); 49 | -------------------------------------------------------------------------------- /web/src/domain/smart-import/Common.ts: -------------------------------------------------------------------------------- 1 | export const extractAsin = (url: string) => { 2 | const asinRegex = /(?:[/dp/]|$)([A-Z0-9]{10})/g; 3 | return asinRegex.exec(url)?.[1]; 4 | }; 5 | 6 | export const prettyCover = (cover: string) => 7 | cover 8 | .replace('_PJku-sticker-v7,TopRight,0,-50.', '') 9 | .replace('m.media-amazon.com', 'images-cn.ssl-images-amazon.cn') 10 | .replace(/\.[A-Z0-9_]+\.jpg$/, '.jpg'); 11 | -------------------------------------------------------------------------------- /web/src/domain/smart-import/index.ts: -------------------------------------------------------------------------------- 1 | export { prettyCover } from './Common'; 2 | export { smartImport } from './SmartImport'; 3 | -------------------------------------------------------------------------------- /web/src/domain/translate/Translate.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TranslateTaskCallback, 3 | TranslateTaskDesc, 4 | TranslateTaskParams, 5 | } from '@/model/Translator'; 6 | 7 | import { translateLocal } from './TranslateLocal'; 8 | import { translateWeb } from './TranslateWeb'; 9 | import { translateWenku } from './TranslateWenku'; 10 | import { Translator, TranslatorConfig } from './Translator'; 11 | 12 | export const translate = async ( 13 | taskDesc: TranslateTaskDesc, 14 | taskParams: TranslateTaskParams, 15 | taskCallback: TranslateTaskCallback, 16 | translatorConfig: TranslatorConfig, 17 | signal?: AbortSignal, 18 | ) => { 19 | let translator: Translator; 20 | try { 21 | translator = await Translator.create( 22 | translatorConfig, 23 | true, 24 | (message, detail) => taskCallback.log(' ' + message, detail), 25 | ); 26 | } catch (e: unknown) { 27 | taskCallback.log(`发生错误,无法创建翻译器:${e}`); 28 | return; 29 | } 30 | 31 | if (taskDesc.type === 'web' || taskDesc.type === 'wenku') { 32 | if (!translator.allowUpload()) { 33 | return; 34 | } 35 | } 36 | 37 | if (taskDesc.type === 'web') { 38 | return translateWeb(taskDesc, taskParams, taskCallback, translator, signal); 39 | } else if (taskDesc.type === 'wenku') { 40 | return translateWenku( 41 | taskDesc, 42 | taskParams, 43 | taskCallback, 44 | translator, 45 | signal, 46 | ); 47 | } else { 48 | return translateLocal( 49 | taskDesc, 50 | taskParams, 51 | taskCallback, 52 | translator, 53 | signal, 54 | ); 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /web/src/domain/translate/TranslatorBaidu.ts: -------------------------------------------------------------------------------- 1 | import { Locator } from '@/data'; 2 | import { RegexUtil } from '@/util'; 3 | 4 | import { 5 | Logger, 6 | SegmentContext, 7 | SegmentTranslator, 8 | createGlossaryWrapper, 9 | createLengthSegmentor, 10 | } from './Common'; 11 | 12 | export class BaiduTranslator implements SegmentTranslator { 13 | id = 'baidu'; 14 | log: (message: string) => void; 15 | private api = Locator.baiduRepository(); 16 | 17 | constructor(log: Logger) { 18 | this.log = log; 19 | } 20 | 21 | async init() { 22 | await this.api.sug(); 23 | return this; 24 | } 25 | 26 | segmentor = createLengthSegmentor(3500); 27 | 28 | async translate( 29 | seg: string[], 30 | { glossary, signal }: SegmentContext, 31 | ): Promise { 32 | return createGlossaryWrapper(glossary)(seg, (seg) => 33 | this.translateInner(seg, signal), 34 | ); 35 | } 36 | 37 | async translateInner(seg: string[], signal?: AbortSignal): Promise { 38 | const query = seg.join('\n'); 39 | 40 | let from = 'jp'; 41 | if (RegexUtil.hasHangulChars(query)) { 42 | from = 'kor'; 43 | } else if (RegexUtil.hasKanaChars(query) || RegexUtil.hasHanzi(query)) { 44 | from = 'jp'; 45 | } else if (RegexUtil.hasEnglishChars(query)) { 46 | from = 'en'; 47 | } 48 | const chunks = await this.api.translate(query, from, { signal }); 49 | 50 | const lineParts: { paraIdx: number; dst: string }[] = []; 51 | Array.from(chunks).forEach((chunk) => { 52 | if (chunk.data.event === 'Translating') { 53 | lineParts.push(...chunk.data.list); 54 | } 55 | }); 56 | 57 | const lines: string[] = []; 58 | let currentParaIdx = 0; 59 | let currentLine = ''; 60 | lineParts.forEach(({ paraIdx, dst }) => { 61 | if (paraIdx === currentParaIdx) { 62 | currentLine = currentLine + dst; 63 | } else { 64 | lines.push(currentLine); 65 | currentParaIdx = paraIdx; 66 | currentLine = dst; 67 | } 68 | }); 69 | lines.push(currentLine); 70 | 71 | return lines; 72 | } 73 | } 74 | 75 | export namespace BaiduTranslator { 76 | export const create = (log: Logger) => new BaiduTranslator(log).init(); 77 | } 78 | -------------------------------------------------------------------------------- /web/src/domain/translate/TranslatorYoudao.ts: -------------------------------------------------------------------------------- 1 | import { Locator } from '@/data'; 2 | import { RegexUtil, safeJson } from '@/util'; 3 | 4 | import { 5 | Logger, 6 | SegmentContext, 7 | SegmentTranslator, 8 | createGlossaryWrapper, 9 | createLengthSegmentor, 10 | } from './Common'; 11 | 12 | export class YoudaoTranslator implements SegmentTranslator { 13 | id = 'youdao'; 14 | log: Logger; 15 | private api = Locator.youdaoRepository(); 16 | 17 | constructor(log: Logger) { 18 | this.log = log; 19 | } 20 | 21 | async init() { 22 | try { 23 | await this.api.rlog(); 24 | await this.api.refreshKey(); 25 | } catch (e) { 26 | this.log('无法获得Key,使用默认值'); 27 | } 28 | return this; 29 | } 30 | 31 | segmentor = createLengthSegmentor(3500); 32 | 33 | async translate( 34 | seg: string[], 35 | { glossary, signal }: SegmentContext, 36 | ): Promise { 37 | return createGlossaryWrapper(glossary)(seg, (seg) => 38 | this.translateInner(seg, signal), 39 | ); 40 | } 41 | 42 | async translateInner(seg: string[], signal?: AbortSignal): Promise { 43 | let from = 'auto'; 44 | const segText = seg.join('\n'); 45 | if (RegexUtil.hasHangulChars(segText)) { 46 | from = 'ko'; 47 | } else if (RegexUtil.hasKanaChars(segText) || RegexUtil.hasHanzi(segText)) { 48 | from = 'ja'; 49 | } else if (RegexUtil.hasEnglishChars(segText)) { 50 | from = 'en'; 51 | } 52 | 53 | const decoded = await this.api.webtranslate(seg.join('\n'), from, { 54 | signal, 55 | }); 56 | const decodedJson = safeJson(decoded); 57 | 58 | if (decodedJson === undefined) { 59 | this.log(` 错误:${decoded}`); 60 | throw 'quit'; 61 | } else { 62 | try { 63 | const result = decodedJson['translateResult'].map((it: any) => 64 | it.map((it: any) => it.tgt.trimEnd()).join(''), 65 | ); 66 | return result; 67 | } catch (e) { 68 | this.log(` 错误:${decoded}`); 69 | throw 'quit'; 70 | } 71 | } 72 | } 73 | } 74 | 75 | export namespace YoudaoTranslator { 76 | export const create = (log: Logger) => new YoudaoTranslator(log).init(); 77 | } 78 | -------------------------------------------------------------------------------- /web/src/domain/translate/index.ts: -------------------------------------------------------------------------------- 1 | export { translate } from './Translate'; 2 | export { Translator } from './Translator'; 3 | export type { TranslatorConfig } from './Translator'; 4 | 5 | export { SakuraTranslator } from './TranslatorSakura'; 6 | -------------------------------------------------------------------------------- /web/src/image/avater.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/auto-novel/0e12ae4515d601860e4acd4d3aa3e854fdfa5c33/web/src/image/avater.jpg -------------------------------------------------------------------------------- /web/src/image/banner.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/auto-novel/0e12ae4515d601860e4acd4d3aa3e854fdfa5c33/web/src/image/banner.webp -------------------------------------------------------------------------------- /web/src/image/cover_placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/auto-novel/0e12ae4515d601860e4acd4d3aa3e854fdfa5c33/web/src/image/cover_placeholder.png -------------------------------------------------------------------------------- /web/src/main.ts: -------------------------------------------------------------------------------- 1 | import App from './App.vue'; 2 | import router from './router'; 3 | 4 | const app = createApp(App); 5 | 6 | const pinia = createPinia(); 7 | app.use(pinia); 8 | 9 | app.use(router); 10 | app.mount('#app'); 11 | -------------------------------------------------------------------------------- /web/src/model/Article.ts: -------------------------------------------------------------------------------- 1 | import { UserReference } from './User'; 2 | 3 | export type ArticleCategory = 'Guide' | 'General' | 'Support'; 4 | 5 | export interface ArticleSimplified { 6 | id: string; 7 | title: string; 8 | category: ArticleCategory; 9 | locked: boolean; 10 | pinned: boolean; 11 | hidden: boolean; 12 | numViews: number; 13 | numComments: number; 14 | user: UserReference; 15 | createAt: number; 16 | updateAt: number; 17 | } 18 | 19 | export interface Article { 20 | id: string; 21 | title: string; 22 | content: string; 23 | category: ArticleCategory; 24 | locked: boolean; 25 | pinned: boolean; 26 | hidden: boolean; 27 | numViews: number; 28 | numComments: number; 29 | user: UserReference; 30 | createAt: number; 31 | updateAt: number; 32 | } 33 | -------------------------------------------------------------------------------- /web/src/model/Comment.ts: -------------------------------------------------------------------------------- 1 | import { UserReference } from './User'; 2 | 3 | export interface Comment1 { 4 | id: string; 5 | user: UserReference; 6 | content: string; 7 | hidden: boolean; 8 | createAt: number; 9 | numReplies: number; 10 | replies: Comment1[]; 11 | } 12 | -------------------------------------------------------------------------------- /web/src/model/Common.ts: -------------------------------------------------------------------------------- 1 | export type GenericNovelId = 2 | | { type: 'web'; providerId: string; novelId: string } 3 | | { type: 'wenku'; novelId: string } 4 | | { type: 'local'; volumeId: string }; 5 | 6 | export namespace GenericNovelId { 7 | export const web = (providerId: string, novelId: string): GenericNovelId => ({ 8 | type: 'web', 9 | providerId, 10 | novelId, 11 | }); 12 | 13 | export const wenku = (novelId: string): GenericNovelId => ({ 14 | type: 'wenku', 15 | novelId, 16 | }); 17 | 18 | export const local = (volumeId: string): GenericNovelId => ({ 19 | type: 'local', 20 | volumeId, 21 | }); 22 | 23 | export const toString = (gnid: GenericNovelId) => { 24 | if (gnid.type === 'web') { 25 | return `web/${gnid.providerId}/${gnid.novelId}`; 26 | } else if (gnid.type === 'wenku') { 27 | return `wenku/${gnid.novelId}`; 28 | } else { 29 | return `local/${gnid.volumeId}`; 30 | } 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /web/src/model/Glossary.ts: -------------------------------------------------------------------------------- 1 | export type Glossary = { [key: string]: string }; 2 | 3 | export namespace Glossary { 4 | export const toJson = (glossary: Glossary) => { 5 | return JSON.stringify(glossary, null, 2); 6 | }; 7 | const fromJson = (text: string): Glossary | undefined => { 8 | try { 9 | const obj = JSON.parse(text); 10 | if (typeof obj !== 'object') return; 11 | const glossary: Glossary = {}; 12 | for (const jp in obj) { 13 | const zh = obj[jp]; 14 | if (typeof zh !== 'string') return; 15 | glossary[jp] = zh; 16 | } 17 | return glossary; 18 | } catch { 19 | return; 20 | } 21 | }; 22 | 23 | const delimiter = '=>'; 24 | const toHumanReadableFormat = (glossary: Glossary) => { 25 | const lines = []; 26 | for (const jp in glossary) { 27 | const zh = glossary[jp]; 28 | lines.push(`${jp} ${delimiter} ${zh}`); 29 | } 30 | return lines.join('\n'); 31 | }; 32 | const fromHumanReadableFormat = (text: string): Glossary | undefined => { 33 | const glossary: Glossary = {}; 34 | for (let line of text.split('\n')) { 35 | line = line.trim(); 36 | if (line === '') continue; 37 | 38 | const parts = line.split(delimiter); 39 | console.log(parts); 40 | if (parts.length !== 2) return; 41 | 42 | const [jp, zh] = parts; 43 | glossary[jp.trim()] = zh.trim(); 44 | } 45 | return glossary; 46 | }; 47 | 48 | export const toText = (glossary: Glossary) => { 49 | return toHumanReadableFormat(glossary); 50 | }; 51 | export const fromText = (text: string): Glossary | undefined => { 52 | return fromJson(text) ?? fromHumanReadableFormat(text); 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /web/src/model/LocalVolume.ts: -------------------------------------------------------------------------------- 1 | import { Glossary } from './Glossary'; 2 | 3 | export interface ChapterTranslation { 4 | glossaryId: string; 5 | glossary: Glossary; 6 | paragraphs: string[]; 7 | } 8 | 9 | export interface LocalVolumeMetadata { 10 | id: string; 11 | readAt?: number; 12 | createAt: number; 13 | toc: { 14 | chapterId: string; 15 | baidu?: string; 16 | youdao?: string; 17 | gpt?: string; 18 | sakura?: string; 19 | }[]; 20 | glossaryId: string; 21 | glossary: Glossary; 22 | favoredId: string; 23 | } 24 | 25 | export interface LocalVolumeChapter { 26 | id: string; 27 | volumeId: string; 28 | paragraphs: string[]; 29 | baidu?: ChapterTranslation; 30 | youdao?: ChapterTranslation; 31 | gpt?: ChapterTranslation; 32 | sakura?: ChapterTranslation; 33 | } 34 | -------------------------------------------------------------------------------- /web/src/model/Operation.ts: -------------------------------------------------------------------------------- 1 | import { Glossary } from './Glossary'; 2 | import { UserReference } from './User'; 3 | 4 | export type OperationType = 5 | | 'web-edit' 6 | | 'web-edit-glossary' 7 | | 'wenku-edit' 8 | | 'wenku-edit-glossary' 9 | | 'wenku-upload'; 10 | 11 | export type Operation = 12 | | OperationWebEdit 13 | | OperationWebEditGlossary 14 | | OperationWenkuEdit 15 | | OperationWenkuEditGlossary 16 | | OperationWenkuUpload; 17 | 18 | interface OperationWebEditData { 19 | titleZh: string; 20 | introductionZh: string; 21 | } 22 | 23 | export interface OperationWebEdit { 24 | type: 'web-edit'; 25 | providerId: string; 26 | novelId: string; 27 | old: OperationWebEditData; 28 | new: OperationWebEditData; 29 | toc: { jp: string; old?: string; new: string }[]; 30 | } 31 | 32 | export interface OperationWebEditGlossary { 33 | type: 'web-edit-glossary'; 34 | providerId: string; 35 | novelId: string; 36 | old: Glossary; 37 | new: Glossary; 38 | } 39 | 40 | interface OperationWenkuEditData { 41 | title: string; 42 | titleZh: string; 43 | authors: string[]; 44 | artists: string[]; 45 | introduction: string; 46 | } 47 | 48 | export interface OperationWenkuEdit { 49 | type: 'wenku-edit'; 50 | novelId: string; 51 | old?: OperationWenkuEditData; 52 | new: OperationWenkuEditData; 53 | } 54 | 55 | export interface OperationWenkuEditGlossary { 56 | type: 'wenku-edit-glossary'; 57 | novelId: string; 58 | old: Glossary; 59 | new: Glossary; 60 | } 61 | 62 | export interface OperationWenkuUpload { 63 | type: 'wenku-upload'; 64 | novelId: string; 65 | volumeId: string; 66 | } 67 | 68 | export interface OperationHistory { 69 | id: string; 70 | operator: UserReference; 71 | operation: Operation; 72 | createAt: number; 73 | } 74 | 75 | interface MergeHistoryData { 76 | titleJp: string; 77 | titleZh?: string; 78 | chapterId?: string; 79 | createAt?: number; 80 | } 81 | 82 | export interface MergeHistoryDto { 83 | id: string; 84 | providerId: string; 85 | novelId: string; 86 | reason: string; 87 | tocOld: MergeHistoryData[]; 88 | tocNew: MergeHistoryData[]; 89 | } 90 | -------------------------------------------------------------------------------- /web/src/model/Page.ts: -------------------------------------------------------------------------------- 1 | export interface Page { 2 | pageNumber: number; 3 | items: T[]; 4 | } 5 | -------------------------------------------------------------------------------- /web/src/model/User.ts: -------------------------------------------------------------------------------- 1 | export type UserRole = 'admin' | 'maintainer' | 'trusted' | 'normal'; 2 | 3 | export interface UserReference { 4 | username: string; 5 | role: UserRole; 6 | } 7 | 8 | export interface UserOutline { 9 | id: string; 10 | email: string; 11 | username: string; 12 | role: UserRole; 13 | createdAt: number; 14 | } 15 | -------------------------------------------------------------------------------- /web/src/model/WebNovel.ts: -------------------------------------------------------------------------------- 1 | export interface WebNovelOutlineDto { 2 | providerId: string; 3 | novelId: string; 4 | titleJp: string; 5 | titleZh?: string; 6 | type: string; 7 | attentions: string[]; 8 | keywords: string[]; 9 | extra?: string; 10 | // 11 | favored?: string; 12 | lastReadAt?: number; 13 | // 14 | total: number; 15 | jp: number; 16 | baidu: number; 17 | youdao: number; 18 | gpt: number; 19 | sakura: number; 20 | updateAt?: number; 21 | } 22 | 23 | export interface WebNovelTocItemDto { 24 | titleJp: string; 25 | titleZh?: string; 26 | chapterId?: string; 27 | createAt?: number; 28 | } 29 | 30 | export interface WebNovelDto { 31 | wenkuId?: string; 32 | titleJp: string; 33 | titleZh?: string; 34 | authors: { name: string; link: string }[]; 35 | type: string; 36 | attentions: string[]; 37 | keywords: string[]; 38 | points?: number; 39 | totalCharacters?: number; 40 | introductionJp: string; 41 | introductionZh?: string; 42 | glossary: { [key: string]: string }; 43 | toc: WebNovelTocItemDto[]; 44 | visited: number; 45 | syncAt: number; 46 | favored?: string; 47 | lastReadChapterId?: string; 48 | jp: number; 49 | baidu: number; 50 | youdao: number; 51 | gpt: number; 52 | sakura: number; 53 | } 54 | 55 | export interface WebNovelChapterDto { 56 | titleJp: string; 57 | titleZh?: string; 58 | prevId?: string; 59 | nextId?: string; 60 | paragraphs: string[]; 61 | baiduParagraphs?: string[]; 62 | youdaoParagraphs?: string[]; 63 | gptParagraphs?: string[]; 64 | sakuraParagraphs?: string[]; 65 | } 66 | -------------------------------------------------------------------------------- /web/src/pages/admin/AdminLayout.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 34 | -------------------------------------------------------------------------------- /web/src/pages/admin/components/OperationWebEdit.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 24 | -------------------------------------------------------------------------------- /web/src/pages/admin/components/OperationWenkuEdit.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 29 | -------------------------------------------------------------------------------- /web/src/pages/admin/components/OperationWenkuEditGlossary.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 27 | -------------------------------------------------------------------------------- /web/src/pages/admin/components/OperationWenkuUpload.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /web/src/pages/admin/components/TextDiff.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | 19 | 28 | -------------------------------------------------------------------------------- /web/src/pages/admin/components/UserManagementUpdateRole.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 63 | -------------------------------------------------------------------------------- /web/src/pages/auth/AuthLayout.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 37 | -------------------------------------------------------------------------------- /web/src/pages/auth/SignIn.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 50 | -------------------------------------------------------------------------------- /web/src/pages/auth/components/SignInForm.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 81 | -------------------------------------------------------------------------------- /web/src/pages/bookshelf/BookshelfLocal.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 51 | -------------------------------------------------------------------------------- /web/src/pages/bookshelf/components/BookshelfAddButton.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 97 | -------------------------------------------------------------------------------- /web/src/pages/bookshelf/components/BookshelfLayout.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 22 | -------------------------------------------------------------------------------- /web/src/pages/bookshelf/components/BookshelfListButton.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 22 | -------------------------------------------------------------------------------- /web/src/pages/bookshelf/components/BookshelfLocalAddButton.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 84 | -------------------------------------------------------------------------------- /web/src/pages/bookshelf/components/BookshelfLocalListItem.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 82 | -------------------------------------------------------------------------------- /web/src/pages/bookshelf/components/BookshelfMenu.vue: -------------------------------------------------------------------------------- 1 | 76 | 77 | 82 | -------------------------------------------------------------------------------- /web/src/pages/forum/ForumArticle.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 46 | -------------------------------------------------------------------------------- /web/src/pages/forum/ForumArticleStore.ts: -------------------------------------------------------------------------------- 1 | import { Locator } from '@/data'; 2 | import { Article } from '@/model/Article'; 3 | import { Result, runCatching } from '@/util/result'; 4 | 5 | const repo = Locator.articleRepository; 6 | 7 | type ArticleStore = { 8 | articleResult: Result
| undefined; 9 | }; 10 | 11 | export const useArticleStore = (articleId: string) => { 12 | return defineStore(`Article/${articleId}`, { 13 | state: () => 14 | { 15 | articleResult: undefined, 16 | }, 17 | actions: { 18 | async loadArticle(force = false) { 19 | if (!force && this.articleResult?.ok) { 20 | return this.articleResult; 21 | } 22 | 23 | this.articleResult = undefined; 24 | const result = await runCatching(repo.getArticle(articleId)); 25 | this.articleResult = result; 26 | 27 | return this.articleResult; 28 | }, 29 | 30 | async updateArticle(json: Parameters[1]) { 31 | await repo.updateArticle(articleId, json); 32 | this.loadArticle(true); 33 | }, 34 | }, 35 | })(); 36 | }; 37 | -------------------------------------------------------------------------------- /web/src/pages/home/components/PanelWebNovel.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 31 | -------------------------------------------------------------------------------- /web/src/pages/home/components/PanelWenkuNovel.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 26 | -------------------------------------------------------------------------------- /web/src/pages/list/ReadHistoryList.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 86 | -------------------------------------------------------------------------------- /web/src/pages/list/components/NovelListWenku.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 78 | -------------------------------------------------------------------------------- /web/src/pages/novel/WebNovel.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 50 | -------------------------------------------------------------------------------- /web/src/pages/novel/WebNovelStore.ts: -------------------------------------------------------------------------------- 1 | import { Locator } from '@/data'; 2 | import { WebNovelDto } from '@/model/WebNovel'; 3 | import { Result, runCatching } from '@/util/result'; 4 | 5 | const repo = Locator.webNovelRepository; 6 | 7 | type WebNovelStore = { 8 | novelResult: Result | undefined; 9 | }; 10 | 11 | export const useWebNovelStore = (providerId: string, novelId: string) => { 12 | return defineStore(`WebNovel/${providerId}/${novelId}`, { 13 | state: () => 14 | { 15 | novelResult: undefined, 16 | }, 17 | actions: { 18 | async loadNovel(force = false) { 19 | if (!force && this.novelResult?.ok) { 20 | return this.novelResult; 21 | } 22 | 23 | this.novelResult = undefined; 24 | const result = await runCatching(repo.getNovel(providerId, novelId)); 25 | this.novelResult = result; 26 | 27 | return this.novelResult; 28 | }, 29 | 30 | async updateNovel(json: Parameters[2]) { 31 | await repo.updateNovel(providerId, novelId, json); 32 | this.loadNovel(true); 33 | }, 34 | }, 35 | })(); 36 | }; 37 | -------------------------------------------------------------------------------- /web/src/pages/novel/components/NovelTag.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 18 | -------------------------------------------------------------------------------- /web/src/pages/novel/components/TagButton.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 23 | -------------------------------------------------------------------------------- /web/src/pages/novel/components/UseWebNovel.ts: -------------------------------------------------------------------------------- 1 | import { computed, ComputedRef } from 'vue'; 2 | import { WebNovelDto } from '@/model/WebNovel'; 3 | 4 | import { ReadableTocItem } from './common'; 5 | 6 | export const useToc = (novel: WebNovelDto) => { 7 | const toc = computed(() => { 8 | const novelToc = novel.toc as ReadableTocItem[]; 9 | let order = 0; 10 | for (const [index, it] of novelToc.entries()) { 11 | it.key = index; 12 | it.order = it.chapterId ? order : undefined; 13 | if (it.chapterId) order += 1; 14 | } 15 | return novelToc; 16 | }); 17 | return { toc }; 18 | }; 19 | 20 | export const useLastReadChapter = ( 21 | novel: WebNovelDto, 22 | toc: ComputedRef, 23 | ) => { 24 | const lastReadChapter = computed(() => { 25 | if (novel.lastReadChapterId) { 26 | return toc.value.find((it) => it.chapterId === novel.lastReadChapterId); 27 | } 28 | return undefined; 29 | }); 30 | return { lastReadChapter }; 31 | }; 32 | -------------------------------------------------------------------------------- /web/src/pages/novel/components/common.ts: -------------------------------------------------------------------------------- 1 | import { WebNovelTocItemDto } from '@/model/WebNovel'; 2 | 3 | export type ReadableTocItem = WebNovelTocItemDto & { 4 | key: number; 5 | order?: number; 6 | }; 7 | -------------------------------------------------------------------------------- /web/src/pages/other/NotFound.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /web/src/pages/reader/ReaderLayout.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /web/src/pages/reader/components/ReaderLayoutDesktop.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 47 | -------------------------------------------------------------------------------- /web/src/pages/reader/components/SideButton.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 17 | -------------------------------------------------------------------------------- /web/src/pages/workspace/components/JobQueue.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 82 | -------------------------------------------------------------------------------- /web/src/pages/workspace/components/JobRecord.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 58 | -------------------------------------------------------------------------------- /web/src/pages/workspace/components/JobTaskLink.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 57 | -------------------------------------------------------------------------------- /web/src/pages/workspace/components/LocalVolumeListKatakana.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 63 | -------------------------------------------------------------------------------- /web/src/pages/workspace/components/Toolbox.ts: -------------------------------------------------------------------------------- 1 | import { downloadFile } from '@/util'; 2 | import { ParsedFile } from '@/util/file'; 3 | 4 | export namespace Toolbox { 5 | const downloadFiles = async (files: ParsedFile[]) => { 6 | if (files.length === 1) { 7 | const file = files[0]; 8 | await downloadFile(file.name, await file.toBlob()); 9 | } else { 10 | const { BlobReader, BlobWriter, ZipWriter } = await import( 11 | '@zip.js/zip.js' 12 | ); 13 | const zipBlobWriter = new BlobWriter(); 14 | const writer = new ZipWriter(zipBlobWriter); 15 | await Promise.all( 16 | files.map(async (file) => { 17 | const blob = await file.toBlob(); 18 | await writer.add(file.name, new BlobReader(blob)); 19 | }), 20 | ); 21 | await writer.close(); 22 | const zipBlob = await zipBlobWriter.getData(); 23 | downloadFile(`工具箱打包下载[${files.length}].zip`, zipBlob); 24 | } 25 | }; 26 | 27 | type ModifyFn = (file: T) => Promise; 28 | type ConvertFn = (file: T) => Promise; 29 | type ErrorFn = (e: unknown) => void; 30 | 31 | export const modifyFiles = async ( 32 | files: T[], 33 | modify: ModifyFn, 34 | onError: ErrorFn, 35 | ) => { 36 | try { 37 | const newFiles = await Promise.all( 38 | files.map(async (file) => { 39 | const newFile = (await file.clone()) as T; 40 | await modify(newFile); 41 | return newFile; 42 | }), 43 | ); 44 | await downloadFiles(newFiles); 45 | } catch (e) { 46 | onError(e); 47 | } 48 | }; 49 | 50 | export const convertFiles = async ( 51 | files: T[], 52 | convert: ConvertFn, 53 | onError: ErrorFn, 54 | ) => { 55 | try { 56 | const newFiles = await Promise.all( 57 | files.map(async (file) => convert(file)), 58 | ); 59 | await downloadFiles(newFiles); 60 | } catch (e) { 61 | onError(e); 62 | } 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /web/src/pages/workspace/components/ToolboxFileCard.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 42 | -------------------------------------------------------------------------------- /web/src/pages/workspace/components/ToolboxItemConvert.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 31 | -------------------------------------------------------------------------------- /web/src/pages/workspace/components/ToolboxItemFixOcr.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 70 | -------------------------------------------------------------------------------- /web/src/sound/all_task_completed.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/auto-novel/0e12ae4515d601860e4acd4d3aa3e854fdfa5c33/web/src/sound/all_task_completed.mp3 -------------------------------------------------------------------------------- /web/src/util/cc.ts: -------------------------------------------------------------------------------- 1 | export namespace CCUtil { 2 | type Locale = 'zh-cn' | 'zh-tw'; 3 | 4 | export type Converter = { 5 | toView: (text: string) => string; 6 | toData: (text: string) => string; 7 | }; 8 | 9 | export const defaultConverter: Converter = { 10 | toView: (text: string) => text, 11 | toData: (text: string) => text, 12 | }; 13 | 14 | export const createConverter = async (locale: Locale): Promise => { 15 | if (locale === 'zh-cn') { 16 | return defaultConverter; 17 | } else if (locale === 'zh-tw') { 18 | const opencc: any = await import('opencc-js'); 19 | const ccLocale = opencc.Locale; 20 | const customDict = [ 21 | ['託', '托'], 22 | ['孃', '娘'], 23 | ]; 24 | return { 25 | toView: opencc.ConverterFactory(ccLocale.from.cn, ccLocale.to.tw, [ 26 | customDict, 27 | ]), 28 | toData: opencc.ConverterFactory(ccLocale.from.tw, ccLocale.to.cn), 29 | }; 30 | } 31 | return locale satisfies never; 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /web/src/util/file/base.ts: -------------------------------------------------------------------------------- 1 | export class BaseFile { 2 | name: string; 3 | protected rawFile?: File; 4 | 5 | protected constructor(name: string, rawFile?: File) { 6 | this.name = name; 7 | if (rawFile) this.rawFile = rawFile; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /web/src/util/file/index.ts: -------------------------------------------------------------------------------- 1 | import { Epub } from './epub'; 2 | import { Srt } from './srt'; 3 | import { Txt } from './txt'; 4 | import { StandardNovel } from './standard'; 5 | 6 | export { Epub, Srt, Txt, StandardNovel }; 7 | export type ParsedFile = Epub | Srt | Txt; 8 | 9 | export const getFullContent = async (file: File) => { 10 | if (file.name.endsWith('.txt') || file.name.endsWith('.srt')) { 11 | const txt = await Txt.fromFile(file); 12 | return txt.text; 13 | } else if (file.name.endsWith('.epub')) { 14 | const epub = await Epub.fromFile(file); 15 | return await epub.getText(); 16 | } else { 17 | return ''; 18 | } 19 | }; 20 | 21 | export const parseFile = async ( 22 | file: File, 23 | allowExts = ['epub', 'txt', 'srt'], 24 | ) => { 25 | const ext = file.name.split('.').pop()?.toLowerCase(); 26 | if (ext === undefined) throw '无法获取文件后缀名'; 27 | if (allowExts.includes(ext)) { 28 | try { 29 | if (ext === 'txt') { 30 | return await Txt.fromFile(file); 31 | } else if (ext === 'epub') { 32 | return await Epub.fromFile(file); 33 | } else if (ext === 'srt') { 34 | return await Srt.fromFile(file); 35 | } 36 | } catch (e) { 37 | throw `无法解析${ext.toUpperCase()}文件,因为:${e}`; 38 | } 39 | } 40 | throw '不支持的文件格式'; 41 | }; 42 | -------------------------------------------------------------------------------- /web/src/util/file/txt.ts: -------------------------------------------------------------------------------- 1 | import { BaseFile } from './base'; 2 | 3 | export class Txt extends BaseFile { 4 | type = 'txt' as const; 5 | text: string = ''; 6 | 7 | private async parseFile(file: File) { 8 | const buffer = await file.arrayBuffer(); 9 | 10 | const tryDecode = async (label: string) => { 11 | const decoder = new TextDecoder(label, { fatal: true }); 12 | try { 13 | const decoded = decoder.decode(buffer); 14 | return decoded; 15 | } catch (e) { 16 | if (e instanceof TypeError) return undefined; 17 | throw e; 18 | } 19 | }; 20 | 21 | let text: string | undefined; 22 | for (const label of ['utf-8', 'gbk']) { 23 | text = await tryDecode(label); 24 | if (text !== undefined) break; 25 | } 26 | if (text === undefined) { 27 | throw '未知编码'; 28 | } 29 | 30 | // 修复换行符格式 31 | text = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); 32 | 33 | this.text = text; 34 | } 35 | 36 | static async fromFile(file: File) { 37 | const txt = new Txt(file.name, file); 38 | await txt.parseFile(file); 39 | return txt; 40 | } 41 | 42 | static async fromText(name: string, text: string) { 43 | const txt = new Txt(name); 44 | txt.text = text; 45 | return txt; 46 | } 47 | 48 | async clone() { 49 | if (!this.rawFile) 50 | throw new Error('Cannot clone manually constructed file.'); 51 | return Txt.fromFile(this.rawFile); 52 | } 53 | 54 | async toBlob() { 55 | return new Blob([this.text], { 56 | type: 'text/plain', 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /web/src/util/result.ts: -------------------------------------------------------------------------------- 1 | import { HTTPError, TimeoutError } from 'ky'; 2 | 3 | export type Result = 4 | | { ok: true; value: T } 5 | | { ok: false; error: { message: string } }; 6 | 7 | export const Ok = (data: T): Result => { 8 | return { ok: true, value: data }; 9 | }; 10 | 11 | export const Err = (error: string): Result => { 12 | return { ok: false, error: { message: error } }; 13 | }; 14 | 15 | export const runCatching = (callback: Promise): Promise> => { 16 | return callback 17 | .then((it) => Ok(it)) 18 | .catch((error) => { 19 | if (error instanceof HTTPError) { 20 | let messageOverride: string | null = null; 21 | if (error.response.status === 429) { 22 | messageOverride = '操作额度耗尽,等明天再试吧'; 23 | } 24 | return error.response 25 | .text() 26 | .then((message) => 27 | Err(`[${error.response.status}]${messageOverride ?? message}`), 28 | ); 29 | } else if (error instanceof TimeoutError) { 30 | return Err('请求超时'); 31 | } else { 32 | return Err(`${error}`); 33 | } 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /web/src/util/web/index.ts: -------------------------------------------------------------------------------- 1 | import { tryTranslateKeyword } from './keyword'; 2 | import { parseUrl, buildNovelUrl, buildChapterUrl } from './url'; 3 | 4 | export const WebUtil = { 5 | tryTranslateKeyword, 6 | parseUrl, 7 | buildNovelUrl, 8 | buildChapterUrl, 9 | }; 10 | -------------------------------------------------------------------------------- /web/src/util/web/keyword.ts: -------------------------------------------------------------------------------- 1 | export const mapper = [ 2 | ['ハーレム', '后宫'], 3 | ['シリアス', '严肃'], 4 | ['ほのぼの', '温暖'], 5 | ['バトル', '战斗'], 6 | ['ラブコメ', '爱情喜剧'], 7 | ['ハッピーエンド', 'HappyEnd'], 8 | ['バッドエンド', 'BadEnd'], 9 | ['嘘コク', '假告白'], 10 | ['ギャグ', '搞笑'], 11 | ['チート', '作弊'], 12 | ['ファンタジー', '奇幻'], 13 | ['スクールラブ', '校园爱情'], 14 | ['ダーク', '黑暗'], 15 | ['ミステリー', '推理'], 16 | ['ヒーロー', '主角'], 17 | ['ヒロイン', '女主角'], 18 | ['ダンジョン', '迷宫'], 19 | ['ざまぁ', '活该'], 20 | ['ざまあ', '活该'], 21 | ['ディストピア', '反乌托邦'], 22 | ['アイドル', '偶像'], 23 | ['成り上がり', '暴发户'], 24 | ['ライトノベル', '轻小说'], 25 | ['セフレ', '性伙伴'], 26 | ['ホームドラマ', '家庭剧'], 27 | ['パラレルワールド', '平行世界'], 28 | ['ヤンデレ', '病娇'], 29 | ['ツンデレ', '傲娇'], 30 | ['ゲーム', '游戏'], 31 | ['コミカライズ', '漫画化'], 32 | ['アニメ化', '动画化'], 33 | ['スキル', '技能'], 34 | ['ボーイズラブ', 'BL'], 35 | ['ガールズラブ', 'GL'], 36 | ['いじめ', '欺凌'], 37 | ['レイプ', '强奸'], 38 | ['ロリ', '萝莉'], 39 | ['コメディ', '喜剧'], 40 | ['カクヨムオンリー', 'kakuyomu原创'], 41 | ]; 42 | 43 | export const tryTranslateKeyword = (keyword: string) => { 44 | mapper.forEach(([jp, zh]) => (keyword = keyword.replace(jp, zh))); 45 | return keyword; 46 | }; 47 | -------------------------------------------------------------------------------- /web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue'; 5 | const component: DefineComponent<{}, {}, any>; 6 | export default component; 7 | } 8 | -------------------------------------------------------------------------------- /web/tests/provider.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { parseUrl } from '../src/util/web/url'; 3 | 4 | function test_parse_url(providerId: string, benches: [string, string][]) { 5 | for (const [url, novelId] of benches) { 6 | const parseResult = parseUrl(url); 7 | expect(parseResult?.providerId).toBe(providerId); 8 | expect(parseResult?.novelId).toBe(novelId); 9 | } 10 | } 11 | 12 | describe('provider', () => { 13 | it('kakuyomu', () => { 14 | test_parse_url('kakuyomu', [ 15 | [ 16 | 'https://kakuyomu.jp/works/16817139555217983105', 17 | '16817139555217983105', 18 | ], 19 | [ 20 | 'https://kakuyomu.jp/works/16817139555217983105/episodes/16817139555286132564', 21 | '16817139555217983105', 22 | ], 23 | ]); 24 | }); 25 | 26 | it('syosetu', () => { 27 | test_parse_url('syosetu', [ 28 | ['https://ncode.syosetu.com/n9669bk', 'n9669bk'], 29 | ['https://ncode.syosetu.com/n9669BK', 'n9669bk'], 30 | ['https://novel18.syosetu.com/n9669BK', 'n9669bk'], 31 | ]); 32 | }); 33 | 34 | it('novelup', () => { 35 | test_parse_url('novelup', [ 36 | ['https://novelup.plus/story/206612087', '206612087'], 37 | ['https://novelup.plus/story/206612087?p=2', '206612087'], 38 | ]); 39 | }); 40 | 41 | it('hameln', () => { 42 | test_parse_url('hameln', [['https://syosetu.org/novel/297874/', '297874']]); 43 | }); 44 | 45 | it('pixiv', () => { 46 | test_parse_url('pixiv', [ 47 | ['https://www.pixiv.net/novel/series/870363', '870363'], 48 | ['https://www.pixiv.net/novel/series/870363?p=5', '870363'], 49 | ['https://www.pixiv.net/novel/show.php?id=18827415', 's18827415'], 50 | ]); 51 | }); 52 | 53 | it('alphapolis', () => { 54 | test_parse_url('alphapolis', [ 55 | [ 56 | 'https://www.alphapolis.co.jp/novel/638978238/525733370', 57 | '638978238-525733370', 58 | ], 59 | ]); 60 | }); 61 | 62 | it('unmatch', () => { 63 | const benches = ['https://www.google.com/', 'https://books.fishhawk.top/']; 64 | for (const url of benches) { 65 | const parseResult = parseUrl(url); 66 | expect(parseResult).toBeUndefined(); 67 | } 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "esModuleInterop": true, 13 | "lib": [ 14 | "ESNext", 15 | "DOM" 16 | ], 17 | "skipLibCheck": true, 18 | "baseUrl": ".", 19 | "paths": { 20 | "@/*": [ 21 | "src/*" 22 | ] 23 | }, 24 | }, 25 | "include": [ 26 | "auto-imports.d.ts", 27 | "components.d.ts", 28 | "src/**/*.ts", 29 | "src/**/*.d.ts", 30 | "src/**/*.tsx", 31 | "src/**/*.vue" 32 | ], 33 | "references": [ 34 | { 35 | "path": "./tsconfig.node.json" 36 | } 37 | ] 38 | } -------------------------------------------------------------------------------- /web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "lib": ["ESNext"], 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "types": ["node"], 10 | "include": ["vite.config.ts"] 11 | } 12 | --------------------------------------------------------------------------------