diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..d579531bfd86e47afd1b56bf24a77f6746f5a3c9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,61 @@ +FROM node:20.16.0-alpine AS base +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" + +RUN npm i -g pnpm + +FROM base AS build +COPY . /usr/src/app +WORKDIR /usr/src/app + +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile + +RUN pnpm run -r build + +RUN pnpm deploy --filter=server --prod /app +RUN pnpm deploy --filter=server --prod /app-sqlite + +RUN cd /app && pnpm exec prisma generate + +RUN cd /app-sqlite && \ + rm -rf ./prisma && \ + mv prisma-sqlite prisma && \ + pnpm exec prisma generate + +FROM base AS app-sqlite +COPY --from=build /app-sqlite /app + +WORKDIR /app + +EXPOSE 4000 + +ENV NODE_ENV=production +ENV HOST="0.0.0.0" +ENV SERVER_ORIGIN_URL="" +ENV MAX_REQUEST_PER_MINUTE=60 +ENV AUTH_CODE="" +ENV DATABASE_URL="file:../data/wewe-rss.db" +ENV DATABASE_TYPE="sqlite" + +RUN chmod +x ./docker-bootstrap.sh + +CMD ["./docker-bootstrap.sh"] + + +FROM base AS app +COPY --from=build /app /app + +WORKDIR /app + +EXPOSE 4000 + +ENV NODE_ENV=production +ENV HOST="0.0.0.0" +ENV SERVER_ORIGIN_URL="" +ENV MAX_REQUEST_PER_MINUTE=60 +ENV AUTH_CODE="" +ENV DATABASE_URL="" + +RUN chmod +x ./docker-bootstrap.sh + +CMD ["./docker-bootstrap.sh"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..66ad5c00a5c1d7ad475c16bb1bb014d33169a70c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 cooderl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/apps/server/.env.local.example b/apps/server/.env.local.example new file mode 100644 index 0000000000000000000000000000000000000000..cb9691cd8925264e8b03b4d6521d8241b443bccb --- /dev/null +++ b/apps/server/.env.local.example @@ -0,0 +1,34 @@ +HOST=0.0.0.0 +PORT=4000 + +# Prisma +# https://www.prisma.io/docs/reference/database-reference/connection-urls#env +DATABASE_URL="mysql://root:123456@127.0.0.1:3306/wewe-rss" + +# 使用Sqlite +# DATABASE_URL="file:../data/wewe-rss.db" +# DATABASE_TYPE="sqlite" + +# 访问授权码 +AUTH_CODE=123567 + +# 每分钟最大请求次数 +MAX_REQUEST_PER_MINUTE=60 + +# 自动提取全文内容 +FEED_MODE="fulltext" + +# nginx 转发后的服务端地址 +SERVER_ORIGIN_URL=http://localhost:4000 + +# 定时更新订阅源Cron表达式 +CRON_EXPRESSION="35 5,17 * * *" + +# 是否开启正文html清理 +ENABLE_CLEAN_HTML=false + +# 连续更新延迟时间(秒) +UPDATE_DELAY_TIME=60 + +# 读书转发服务,不需要修改 +PLATFORM_URL="https://weread.111965.xyz" \ No newline at end of file diff --git a/apps/server/.eslintrc.js b/apps/server/.eslintrc.js new file mode 100644 index 0000000000000000000000000000000000000000..259de13c733a7284a352a5cba1e9fd57e97e431d --- /dev/null +++ b/apps/server/.eslintrc.js @@ -0,0 +1,25 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + plugins: ['@typescript-eslint/eslint-plugin'], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: ['.eslintrc.js'], + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, +}; diff --git a/apps/server/.gitignore b/apps/server/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..271305e69cbee07cf6b5ed562019dc3a9d090b97 --- /dev/null +++ b/apps/server/.gitignore @@ -0,0 +1,6 @@ +node_modules +# Keep environment variables out of version control +.env + +client +data \ No newline at end of file diff --git a/apps/server/.prettierrc.json b/apps/server/.prettierrc.json new file mode 100644 index 0000000000000000000000000000000000000000..aff308a815c099dd789001e218a0c3b8ddff994b --- /dev/null +++ b/apps/server/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "tabWidth": 2, + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/apps/server/README.md b/apps/server/README.md new file mode 100644 index 0000000000000000000000000000000000000000..f5aa86c5dc479cee604999f8faa2451b99a56c2b --- /dev/null +++ b/apps/server/README.md @@ -0,0 +1,73 @@ +

+ Nest Logo +

+ +[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 +[circleci-url]: https://circleci.com/gh/nestjs/nest + +

A progressive Node.js framework for building efficient and scalable server-side applications.

+

+NPM Version +Package License +NPM Downloads +CircleCI +Coverage +Discord +Backers on Open Collective +Sponsors on Open Collective + + Support us + +

+ + +## Description + +[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. + +## Installation + +```bash +$ pnpm install +``` + +## Running the app + +```bash +# development +$ pnpm run start + +# watch mode +$ pnpm run start:dev + +# production mode +$ pnpm run start:prod +``` + +## Test + +```bash +# unit tests +$ pnpm run test + +# e2e tests +$ pnpm run test:e2e + +# test coverage +$ pnpm run test:cov +``` + +## Support + +Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). + +## Stay in touch + +- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) +- Website - [https://nestjs.com](https://nestjs.com/) +- Twitter - [@nestframework](https://twitter.com/nestframework) + +## License + +Nest is [MIT licensed](LICENSE). diff --git a/apps/server/docker-bootstrap.sh b/apps/server/docker-bootstrap.sh new file mode 100644 index 0000000000000000000000000000000000000000..9f430454e8d82dcea3ee5e1a5709955fbd1d824c --- /dev/null +++ b/apps/server/docker-bootstrap.sh @@ -0,0 +1,8 @@ + +#!/bin/sh +# ENVIRONEMTN from docker-compose.yaml doesn't get through to subprocesses +# Need to explicit pass DATABASE_URL here, otherwise migration doesn't work +# Run migrations +DATABASE_URL=${DATABASE_URL} npx prisma migrate deploy +# start app +DATABASE_URL=${DATABASE_URL} node dist/main \ No newline at end of file diff --git a/apps/server/nest-cli.json b/apps/server/nest-cli.json new file mode 100644 index 0000000000000000000000000000000000000000..f9aa683b1ad5cffc76da9ad4b77c562ac4c2b399 --- /dev/null +++ b/apps/server/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000000000000000000000000000000000000..0f2b50a757328130a25b8e38a9edcc1bdd814eb8 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,93 @@ +{ + "name": "server", + "version": "2.6.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "start:migrate:prod": "prisma migrate deploy && npm run start:prod", + "postinstall": "npx prisma generate", + "migrate": "pnpm prisma migrate dev", + "studio": "pnpm prisma studio", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json" + }, + "dependencies": { + "@cjs-exporter/p-map": "^5.5.0", + "@nestjs/common": "^10.3.3", + "@nestjs/config": "^3.2.0", + "@nestjs/core": "^10.3.3", + "@nestjs/platform-express": "^10.3.3", + "@nestjs/schedule": "^4.0.1", + "@nestjs/throttler": "^5.1.2", + "@prisma/client": "5.10.1", + "@trpc/server": "^10.45.1", + "axios": "^1.6.7", + "cheerio": "1.0.0-rc.12", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "dayjs": "^1.11.10", + "express": "^4.18.2", + "feed": "^4.2.2", + "got": "11.8.6", + "hbs": "^4.2.0", + "html-minifier": "^4.0.0", + "lru-cache": "^10.2.2", + "prisma": "^5.10.2", + "reflect-metadata": "^0.2.1", + "rxjs": "^7.8.1", + "zod": "^3.22.4" + }, + "devDependencies": { + "@nestjs/cli": "^10.3.2", + "@nestjs/schematics": "^10.1.1", + "@nestjs/testing": "^10.3.3", + "@types/express": "^4.17.21", + "@types/html-minifier": "^4.0.5", + "@types/jest": "^29.5.12", + "@types/node": "^20.11.19", + "@types/supertest": "^6.0.2", + "@typescript-eslint/eslint-plugin": "^7.0.2", + "@typescript-eslint/parser": "^7.0.2", + "eslint": "^8.56.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.3", + "jest": "^29.7.0", + "prettier": "^3.2.5", + "source-map-support": "^0.5.21", + "supertest": "^6.3.4", + "ts-jest": "^29.1.2", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.3.3" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} \ No newline at end of file diff --git a/apps/server/prisma-sqlite/migrations/20240301104100_init/migration.sql b/apps/server/prisma-sqlite/migrations/20240301104100_init/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..6d253a84314d5613cec21b24aed64f0a20ef1baf --- /dev/null +++ b/apps/server/prisma-sqlite/migrations/20240301104100_init/migration.sql @@ -0,0 +1,33 @@ +-- CreateTable +CREATE TABLE "accounts" ( + "id" TEXT NOT NULL PRIMARY KEY, + "token" TEXT NOT NULL, + "name" TEXT NOT NULL, + "status" INTEGER NOT NULL DEFAULT 1, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "feeds" ( + "id" TEXT NOT NULL PRIMARY KEY, + "mp_name" TEXT NOT NULL, + "mp_cover" TEXT NOT NULL, + "mp_intro" TEXT NOT NULL, + "status" INTEGER NOT NULL DEFAULT 1, + "sync_time" INTEGER NOT NULL DEFAULT 0, + "update_time" INTEGER NOT NULL, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "articles" ( + "id" TEXT NOT NULL PRIMARY KEY, + "mp_id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "pic_url" TEXT NOT NULL, + "publish_time" INTEGER NOT NULL, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP +); diff --git a/apps/server/prisma-sqlite/migrations/20241214172323_has_history/migration.sql b/apps/server/prisma-sqlite/migrations/20241214172323_has_history/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..018005b424984ca4ec0f3a47e2fa14aecc1bb208 --- /dev/null +++ b/apps/server/prisma-sqlite/migrations/20241214172323_has_history/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "feeds" ADD COLUMN "has_history" INTEGER DEFAULT 1; diff --git a/apps/server/prisma-sqlite/migrations/migration_lock.toml b/apps/server/prisma-sqlite/migrations/migration_lock.toml new file mode 100644 index 0000000000000000000000000000000000000000..e5e5c4705ab084270b7de6f45d5291ba01666948 --- /dev/null +++ b/apps/server/prisma-sqlite/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "sqlite" \ No newline at end of file diff --git a/apps/server/prisma-sqlite/schema.prisma b/apps/server/prisma-sqlite/schema.prisma new file mode 100644 index 0000000000000000000000000000000000000000..b557ff039e20c96d8dd43d7c26a62f8c645076a4 --- /dev/null +++ b/apps/server/prisma-sqlite/schema.prisma @@ -0,0 +1,59 @@ +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client-js" + binaryTargets = ["native", "linux-musl"] // 生成linux可执行文件 +} + +// 读书账号 +model Account { + id String @id + token String @map("token") + name String @map("name") + // 状态 0:失效 1:启用 2:禁用 + status Int @default(1) @map("status") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") + + @@map("accounts") +} + +// 订阅源 +model Feed { + id String @id + mpName String @map("mp_name") + mpCover String @map("mp_cover") + mpIntro String @map("mp_intro") + // 状态 0:失效 1:启用 2:禁用 + status Int @default(1) @map("status") + + // article最后同步时间 + syncTime Int @default(0) @map("sync_time") + + // 信息更新时间 + updateTime Int @map("update_time") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") + + // 是否有历史文章 1 是 0 否 + hasHistory Int? @default(1) @map("has_history") + + @@map("feeds") +} + +model Article { + id String @id + mpId String @map("mp_id") + title String @map("title") + picUrl String @map("pic_url") + publishTime Int @map("publish_time") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") + + @@map("articles") +} diff --git a/apps/server/prisma/migrations/20240227153512_init/migration.sql b/apps/server/prisma/migrations/20240227153512_init/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..c655a12a9db24c44a8f7b742dfde42278c32451c --- /dev/null +++ b/apps/server/prisma/migrations/20240227153512_init/migration.sql @@ -0,0 +1,39 @@ +-- CreateTable +CREATE TABLE `accounts` ( + `id` VARCHAR(255) NOT NULL, + `token` VARCHAR(2048) NOT NULL, + `name` VARCHAR(1024) NOT NULL, + `status` INTEGER NOT NULL DEFAULT 1, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `feeds` ( + `id` VARCHAR(255) NOT NULL, + `mp_name` VARCHAR(512) NOT NULL, + `mp_cover` VARCHAR(1024) NOT NULL, + `mp_intro` TEXT NOT NULL, + `status` INTEGER NOT NULL DEFAULT 1, + `sync_time` INTEGER NOT NULL DEFAULT 0, + `update_time` INTEGER NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `articles` ( + `id` VARCHAR(255) NOT NULL, + `mp_id` VARCHAR(255) NOT NULL, + `title` VARCHAR(255) NOT NULL, + `pic_url` VARCHAR(255) NOT NULL, + `publish_time` INTEGER NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/apps/server/prisma/migrations/20241212153618_has_history/migration.sql b/apps/server/prisma/migrations/20241212153618_has_history/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..7e3aea7d70017c283faaf14e21c48319d1028fff --- /dev/null +++ b/apps/server/prisma/migrations/20241212153618_has_history/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `feeds` ADD COLUMN `has_history` INTEGER NULL DEFAULT 1; diff --git a/apps/server/prisma/migrations/migration_lock.toml b/apps/server/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000000000000000000000000000000000000..e5a788a7af8fecc0478ef418b8e95c2cf2bbffdf --- /dev/null +++ b/apps/server/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "mysql" \ No newline at end of file diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma new file mode 100644 index 0000000000000000000000000000000000000000..fa46874aebb06a7d1ed5a85fec2dc56383ff176e --- /dev/null +++ b/apps/server/prisma/schema.prisma @@ -0,0 +1,59 @@ +datasource db { + provider = "mysql" + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client-js" + binaryTargets = ["native", "linux-musl"] // 生成linux可执行文件 +} + +// 读书账号 +model Account { + id String @id @db.VarChar(255) + token String @map("token") @db.VarChar(2048) + name String @map("name") @db.VarChar(1024) + // 状态 0:失效 1:启用 2:禁用 + status Int @default(1) @map("status") @db.Int() + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") + + @@map("accounts") +} + +// 订阅源 +model Feed { + id String @id @db.VarChar(255) + mpName String @map("mp_name") @db.VarChar(512) + mpCover String @map("mp_cover") @db.VarChar(1024) + mpIntro String @map("mp_intro") @db.Text() + // 状态 0:失效 1:启用 2:禁用 + status Int @default(1) @map("status") @db.Int() + + // article最后同步时间 + syncTime Int @default(0) @map("sync_time") + + // 信息更新时间 + updateTime Int @map("update_time") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") + + // 是否有历史文章 1 是 0 否 + hasHistory Int? @default(1) @map("has_history") + + @@map("feeds") +} + +model Article { + id String @id @db.VarChar(255) + mpId String @map("mp_id") @db.VarChar(255) + title String @map("title") @db.VarChar(255) + picUrl String @map("pic_url") @db.VarChar(255) + publishTime Int @map("publish_time") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") + + @@map("articles") +} diff --git a/apps/server/src/app.controller.spec.ts b/apps/server/src/app.controller.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..d22f3890a380cea30641cfecc329b5c794ef5fb2 --- /dev/null +++ b/apps/server/src/app.controller.spec.ts @@ -0,0 +1,22 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; + +describe('AppController', () => { + let appController: AppController; + + beforeEach(async () => { + const app: TestingModule = await Test.createTestingModule({ + controllers: [AppController], + providers: [AppService], + }).compile(); + + appController = app.get(AppController); + }); + + describe('root', () => { + it('should return "Hello World!"', () => { + expect(appController.getHello()).toBe('Hello World!'); + }); + }); +}); diff --git a/apps/server/src/app.controller.ts b/apps/server/src/app.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..4516357b1d734410d3ae33bfbe27166b4ff1ebec --- /dev/null +++ b/apps/server/src/app.controller.ts @@ -0,0 +1,48 @@ +import { Controller, Get, Response, Render } from '@nestjs/common'; +import { AppService } from './app.service'; +import { ConfigService } from '@nestjs/config'; +import { ConfigurationType } from './configuration'; +import { Response as Res } from 'express'; + +@Controller() +export class AppController { + constructor( + private readonly appService: AppService, + private readonly configService: ConfigService, + ) {} + + @Get() + getHello(): string { + return this.appService.getHello(); + } + + @Get('/robots.txt') + forRobot(): string { + return 'User-agent: *\nDisallow: /'; + } + + @Get('favicon.ico') + getFavicon(@Response() res: Res) { + const imgContent = + 'iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAMAAABrrFhUAAAAAXNSR0IArs4c6QAAACRQTFRFR3BMsN2eke1itNumku5htNulm+l0ke1hc91PVc09OL0rGq0Z17o6fwAAAAV0Uk5TAGyAv79qLUngAAAFdUlEQVR42u3cQWPbIAyGYQlDkOT//3/X9bBLF3/gkgQJ3uuSA4+Ftxp3tNvtdrvdbrfb7Xa76zjNGjG9Ns65zl5O6WWrr15K0ZePS0xjSxUUewq4Oixz8MuPSw7W70EgVb+lMetfWiBV36Xg68cx/arqvhx8AHBpwPqX3QQ1RHnAACw6AjVI+f4ArD0CNUz57gCsPQI1UHl1gBp8B+B4A3RXQ/Uo3GnANVallD6DFA3gO14ZABBEB3j0CuRg6/8HUI6YAHgCgEB8gE6BGhigHKsDFF4doPDqAIVXBzhWByi8OsCxOkDh1QGO1QEKb4DFAY7VAcryAPxKADE7v7KvVFVkRoDjhQB6/shUZRkAPZ9kKvMAlJcB6HmVqkwCwK8CsBOlsQHOhkyjA+BUgwLI2ZxGnwCcRr8J4jQ6AE6jAdSzNw0GIP0CGgqg6tmdugLAieh3ZtZM4BUAJ6pqDQKuAXANCOoeACMAgeAA2MCiA2ADjQCAUyAQGAATaHAATGDBATCBSXAATCDBAbCABgfABLIMQBUDAh4B/p0NqqrcHAJxDACOg9oELNgDEdXebWBuAcCTr2Y0cwAA1gIM0LfUJYCe12nH9yT66TAWCHo0pq0CFgygX0DjHo83Ckjcs0FtEwgG0C9grgD635DAfhL5cFQbBCz04ag2+OlsADi1DgHsNy0APiE2GyFgDgCGngj+UBPPANhA4W3AXANgA4WbQHwD4OMwtAks+vsBijaB+AbAQyBoBHwDYAKDI+AbAP+0ZADKnAPgIVDwXEGcA2ABuf6Qhn9Fxq5HwLwD4B+Z9VpJvAPgW6GAEXAOgGfArkfAPQAWkMtPiHOA/nMQA3vAA4B8BwRaR8AbgJhdnwobGoEfPJ4AxG49Awd7wA2AWNMTYDAC4hZA7jz9wyPgAAC8/4ih7ApAnADozad/eA/MB4DnH1xD8AmXAHoBYEAL7AEXAHpeJfA+CG4C3n93GI+AXPyp+n8/AI+AXXBagPcErQ/A3AHY+ds94BzgRAn6hlwMVAgANDN6MR8SAQDtAXMNIP0AteOvAQ0xAWgPRAeAUyPPdSzAm6J1AyAAdQ0gN96PDQVQBwOoLwC8Bxq+Ys8BTvcvS2tsADwCNTQAFpD6v/QCQBwCSMcGwM99/PxLEAtovQFgXgCwgNRnXX1OZ3wegFP0f6O0X2Vz8FAUvxhs0jwxTzDnPRrDBibSPjDy5FdwzHy+IiONWA2T4gqgP1UzlVpDA+A2wAbYABtgA2yADbABNsAG2ACfA8jB1t8PsCdg8QlINVZlA3QC8OoAFPweiAHy6gAcewdgAFoeIMfeARiA1wGIPwIFAEQfgQcACD8C5SYAxx4ADEA59gAUggUbgH4ADr3+QrgUeAMUphUEHgAAlsKuv1BbKer6meILPMoIAOKQ6y/UUQq4fqaeUoq2/kKdpVjLL0zdpRx9/biUfB2EYYD+0lc5+7v4eP39cSll2DUbVGmKaUzHKIDy3phomMCYmX1zNCwuDtd/MI2L/V3+g4bmbv1MMwE8ivf1k7PxZxpd8OXjfO3+mQBcXf3xAA9Xqx8PkI+Wfrnq7/grIpoLIDM1xceYLT8bQKLmOCBAZuqIwwEk6oxjATB1x3MD5NpRplsdUQCYbsYhADLT7TgAQKJfxbMCpDGXH8eTAvCoy4/jKQFo2OXHsVOARKPiY0KAXEFMA+P5ABiMP42NpwMgMP7D49kAMrj7DY8nA2B0+cd3TAVAGVz+Dw0BvS0Gl/9DAvS+GFz+jxAc9MYSuPyfEGD6nECi98QA4DMEOTPRBAL09tLf3uzOBxiA+DEYgFUFmGhtAqK1BZgWi8H61yI4mJaM+SjlOJhpt9vtdrvdbrfbNfcHKaL2IynIYcEAAAAASUVORK5CYII='; + const imgBuffer = Buffer.from(imgContent, 'base64'); + res.setHeader('Content-Type', 'image/png'); + res.send(imgBuffer); + } + + @Get('/dash*') + @Render('index.hbs') + dashRender() { + const { originUrl: weweRssServerOriginUrl } = + this.configService.get('feed')!; + const { code } = this.configService.get('auth')!; + + return { + weweRssServerOriginUrl, + enabledAuthCode: !!code, + iconUrl: weweRssServerOriginUrl + ? `${weweRssServerOriginUrl}/favicon.ico` + : 'https://r2-assets.111965.xyz/wewe-rss.png', + }; + } +} diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..c18c83e21f3ed0e4be21bbfcce7f5e1c6b64019d --- /dev/null +++ b/apps/server/src/app.module.ts @@ -0,0 +1,39 @@ +import { Module } from '@nestjs/common'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { TrpcModule } from '@server/trpc/trpc.module'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import configuration, { ConfigurationType } from './configuration'; +import { ThrottlerModule } from '@nestjs/throttler'; +import { ScheduleModule } from '@nestjs/schedule'; +import { FeedsModule } from './feeds/feeds.module'; + +@Module({ + imports: [ + TrpcModule, + FeedsModule, + ScheduleModule.forRoot(), + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: ['.env.local', '.env'], + load: [configuration], + }), + ThrottlerModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory(config: ConfigService) { + const throttler = + config.get('throttler'); + return [ + { + ttl: 60, + limit: throttler?.maxRequestPerMinute || 60, + }, + ]; + }, + }), + ], + controllers: [AppController], + providers: [AppService], +}) +export class AppModule {} diff --git a/apps/server/src/app.service.ts b/apps/server/src/app.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..2530ce6ab30495cf95ec254af8afb736fa1e6ce4 --- /dev/null +++ b/apps/server/src/app.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class AppService { + constructor(private readonly configService: ConfigService) {} + getHello(): string { + return ` +
+
>> WeWe RSS <<
+
+ `; + } +} diff --git a/apps/server/src/configuration.ts b/apps/server/src/configuration.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd9715928425bba4e6a9153388ac70c3c09661d6 --- /dev/null +++ b/apps/server/src/configuration.ts @@ -0,0 +1,40 @@ +const configuration = () => { + const isProd = process.env.NODE_ENV === 'production'; + const port = process.env.PORT || 4000; + const host = process.env.HOST || '0.0.0.0'; + + const maxRequestPerMinute = parseInt( + `${process.env.MAX_REQUEST_PER_MINUTE}|| 60`, + ); + + const authCode = process.env.AUTH_CODE; + const platformUrl = process.env.PLATFORM_URL || 'https://weread.111965.xyz'; + const originUrl = process.env.SERVER_ORIGIN_URL || ''; + + const feedMode = process.env.FEED_MODE as 'fulltext' | ''; + + const databaseType = process.env.DATABASE_TYPE || 'mysql'; + + const updateDelayTime = parseInt(`${process.env.UPDATE_DELAY_TIME} || 60`); + + const enableCleanHtml = process.env.ENABLE_CLEAN_HTML === 'true'; + return { + server: { isProd, port, host }, + throttler: { maxRequestPerMinute }, + auth: { code: authCode }, + platform: { url: platformUrl }, + feed: { + originUrl, + mode: feedMode, + updateDelayTime, + enableCleanHtml, + }, + database: { + type: databaseType, + }, + }; +}; + +export default configuration; + +export type ConfigurationType = ReturnType; diff --git a/apps/server/src/constants.ts b/apps/server/src/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..3acc2d8496f3d13796731dd2dffdf1bd22b19767 --- /dev/null +++ b/apps/server/src/constants.ts @@ -0,0 +1,16 @@ +export const statusMap = { + // 0:失效 1:启用 2:禁用 + INVALID: 0, + ENABLE: 1, + DISABLE: 2, +}; + +export const feedTypes = ['rss', 'atom', 'json'] as const; + +export const feedMimeTypeMap = { + rss: 'application/rss+xml; charset=utf-8', + atom: 'application/atom+xml; charset=utf-8', + json: 'application/feed+json; charset=utf-8', +} as const; + +export const defaultCount = 20; diff --git a/apps/server/src/feeds/feeds.controller.spec.ts b/apps/server/src/feeds/feeds.controller.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..05646556c0b1d7f34d449b841349ff40e8e28965 --- /dev/null +++ b/apps/server/src/feeds/feeds.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FeedsController } from './feeds.controller'; + +describe('FeedsController', () => { + let controller: FeedsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [FeedsController], + }).compile(); + + controller = module.get(FeedsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/server/src/feeds/feeds.controller.ts b/apps/server/src/feeds/feeds.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b7bc92c8045ecc594a72120fffa88f59c7e41a4 --- /dev/null +++ b/apps/server/src/feeds/feeds.controller.ts @@ -0,0 +1,83 @@ +import { + Controller, + DefaultValuePipe, + Get, + Logger, + Param, + ParseIntPipe, + Query, + Request, + Response, +} from '@nestjs/common'; +import { FeedsService } from './feeds.service'; +import { Response as Res, Request as Req } from 'express'; + +@Controller('feeds') +export class FeedsController { + private readonly logger = new Logger(this.constructor.name); + + constructor(private readonly feedsService: FeedsService) {} + + @Get('/') + async getFeedList() { + return this.feedsService.getFeedList(); + } + + @Get('/all.(json|rss|atom)') + async getFeeds( + @Request() req: Req, + @Response() res: Res, + @Query('limit', new DefaultValuePipe(30), ParseIntPipe) limit: number = 30, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number = 1, + @Query('mode') mode: string, + @Query('title_include') title_include: string, + @Query('title_exclude') title_exclude: string, + ) { + const path = req.path; + const type = path.split('.').pop() || ''; + + const { content, mimeType } = await this.feedsService.handleGenerateFeed({ + type, + limit, + page, + mode, + title_include, + title_exclude, + }); + + res.setHeader('Content-Type', mimeType); + res.send(content); + } + + @Get('/:feed') + async getFeed( + @Response() res: Res, + @Param('feed') feed: string, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number = 10, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number = 1, + @Query('mode') mode: string, + @Query('title_include') title_include: string, + @Query('title_exclude') title_exclude: string, + @Query('update') update: boolean = false, + ) { + const [id, type] = feed.split('.'); + this.logger.log('getFeed: ', id); + + if (update) { + this.feedsService.updateFeed(id); + } + + const { content, mimeType } = await this.feedsService.handleGenerateFeed({ + id, + type, + limit, + page, + mode, + title_include, + title_exclude, + }); + + res.setHeader('Content-Type', mimeType); + res.send(content); + } +} diff --git a/apps/server/src/feeds/feeds.module.ts b/apps/server/src/feeds/feeds.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b8342d70a55983ea8f92345ff615878677b008e --- /dev/null +++ b/apps/server/src/feeds/feeds.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { FeedsController } from './feeds.controller'; +import { FeedsService } from './feeds.service'; +import { PrismaModule } from '@server/prisma/prisma.module'; +import { TrpcModule } from '@server/trpc/trpc.module'; + +@Module({ + imports: [PrismaModule, TrpcModule], + controllers: [FeedsController], + providers: [FeedsService], +}) +export class FeedsModule {} diff --git a/apps/server/src/feeds/feeds.service.spec.ts b/apps/server/src/feeds/feeds.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3b07f55b04f5d473d750c39fb8fac8df0061328 --- /dev/null +++ b/apps/server/src/feeds/feeds.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FeedsService } from './feeds.service'; + +describe('FeedsService', () => { + let service: FeedsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [FeedsService], + }).compile(); + + service = module.get(FeedsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/server/src/feeds/feeds.service.ts b/apps/server/src/feeds/feeds.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..45727cd95b0489d86b7bcd9c6ab063b00ac6e164 --- /dev/null +++ b/apps/server/src/feeds/feeds.service.ts @@ -0,0 +1,343 @@ +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '@server/prisma/prisma.service'; +import { Cron } from '@nestjs/schedule'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { feedMimeTypeMap, feedTypes } from '@server/constants'; +import { ConfigService } from '@nestjs/config'; +import { Article, Feed as FeedInfo } from '@prisma/client'; +import { ConfigurationType } from '@server/configuration'; +import { Feed, Item } from 'feed'; +import got, { Got } from 'got'; +import { load } from 'cheerio'; +import { minify } from 'html-minifier'; +import { LRUCache } from 'lru-cache'; +import pMap from '@cjs-exporter/p-map'; + +console.log('CRON_EXPRESSION: ', process.env.CRON_EXPRESSION); + +const mpCache = new LRUCache({ + max: 5000, +}); + +@Injectable() +export class FeedsService { + private readonly logger = new Logger(this.constructor.name); + + private request: Got; + constructor( + private readonly prismaService: PrismaService, + private readonly trpcService: TrpcService, + private readonly configService: ConfigService, + ) { + this.request = got.extend({ + retry: { + limit: 3, + methods: ['GET'], + }, + timeout: 8 * 1e3, + headers: { + accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + 'accept-encoding': 'gzip, deflate, br', + 'accept-language': 'en-US,en;q=0.9', + 'cache-control': 'max-age=0', + 'sec-ch-ua': + '" Not A;Brand";v="99", "Chromium";v="101", "Google Chrome";v="101"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"macOS"', + 'sec-fetch-dest': 'document', + 'sec-fetch-mode': 'navigate', + 'sec-fetch-site': 'none', + 'sec-fetch-user': '?1', + 'upgrade-insecure-requests': '1', + 'user-agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36', + }, + hooks: { + beforeRetry: [ + async (options, error, retryCount) => { + this.logger.warn(`retrying ${options.url}...`); + return new Promise((resolve) => + setTimeout(resolve, 2e3 * (retryCount || 1)), + ); + }, + ], + }, + }); + } + + @Cron(process.env.CRON_EXPRESSION || '35 5,17 * * *', { + name: 'updateFeeds', + timeZone: 'Asia/Shanghai', + }) + async handleUpdateFeedsCron() { + this.logger.debug('Called handleUpdateFeedsCron'); + + const feeds = await this.prismaService.feed.findMany({ + where: { status: 1 }, + }); + this.logger.debug('feeds length:' + feeds.length); + + const updateDelayTime = + this.configService.get( + 'feed', + )!.updateDelayTime; + + for (const feed of feeds) { + this.logger.debug('feed', feed.id); + try { + await this.trpcService.refreshMpArticlesAndUpdateFeed(feed.id); + + await new Promise((resolve) => + setTimeout(resolve, updateDelayTime * 1e3), + ); + } catch (err) { + this.logger.error('handleUpdateFeedsCron error', err); + } finally { + // wait 30s for next feed + await new Promise((resolve) => setTimeout(resolve, 30 * 1e3)); + } + } + } + + async cleanHtml(source: string) { + const $ = load(source, { decodeEntities: false }); + + const dirtyHtml = $.html($('.rich_media_content')); + + const html = dirtyHtml + .replace(/data-src=/g, 'src=') + .replace(/opacity: 0( !important)?;/g, '') + .replace(/visibility: hidden;/g, ''); + + const content = + '' + + html; + + const result = minify(content, { + removeAttributeQuotes: true, + collapseWhitespace: true, + }); + + return result; + } + + async getHtmlByUrl(url: string) { + const html = await this.request(url, { responseType: 'text' }).text(); + if ( + this.configService.get('feed')!.enableCleanHtml + ) { + const result = await this.cleanHtml(html); + return result; + } + + return html; + } + + async tryGetContent(id: string) { + let content = mpCache.get(id); + if (content) { + return content; + } + const url = `https://mp.weixin.qq.com/s/${id}`; + content = await this.getHtmlByUrl(url).catch((e) => { + this.logger.error(`getHtmlByUrl(${url}) error: ${e.message}`); + + return '获取全文失败,请重试~'; + }); + mpCache.set(id, content); + return content; + } + + async renderFeed({ + type, + feedInfo, + articles, + mode, + }: { + type: string; + feedInfo: FeedInfo; + articles: Article[]; + mode?: string; + }) { + const { originUrl, mode: globalMode } = + this.configService.get('feed')!; + + const link = `${originUrl}/feeds/${feedInfo.id}.${type}`; + + const feed = new Feed({ + title: feedInfo.mpName, + description: feedInfo.mpIntro, + id: link, + link: link, + language: 'zh-cn', // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes + image: feedInfo.mpCover, + favicon: feedInfo.mpCover, + copyright: '', + updated: new Date(feedInfo.updateTime * 1e3), + generator: 'WeWe-RSS', + author: { name: feedInfo.mpName }, + }); + + feed.addExtension({ + name: 'generator', + objects: `WeWe-RSS`, + }); + + const feeds = await this.prismaService.feed.findMany({ + select: { id: true, mpName: true }, + }); + + /**mode 高于 globalMode。如果 mode 值存在,取 mode 值*/ + const enableFullText = + typeof mode === 'string' + ? mode === 'fulltext' + : globalMode === 'fulltext'; + + const showAuthor = feedInfo.id === 'all'; + + const mapper = async (item) => { + const { title, id, publishTime, picUrl, mpId } = item; + const link = `https://mp.weixin.qq.com/s/${id}`; + + const mpName = feeds.find((item) => item.id === mpId)?.mpName || '-'; + const published = new Date(publishTime * 1e3); + + let content = ''; + if (enableFullText) { + content = await this.tryGetContent(id); + } + + feed.addItem({ + id, + title, + link: link, + guid: link, + content, + date: published, + image: picUrl, + author: showAuthor ? [{ name: mpName }] : undefined, + }); + }; + + await pMap(articles, mapper, { concurrency: 2, stopOnError: false }); + + return feed; + } + + async handleGenerateFeed({ + id, + type, + limit, + page, + mode, + title_include, + title_exclude, + }: { + id?: string; + type: string; + limit: number; + page: number; + mode?: string; + title_include?: string; + title_exclude?: string; + }) { + if (!feedTypes.includes(type as any)) { + type = 'atom'; + } + + let articles: Article[]; + let feedInfo: FeedInfo; + if (id) { + feedInfo = (await this.prismaService.feed.findFirst({ + where: { id }, + }))!; + + if (!feedInfo) { + throw new HttpException('不存在该feed!', HttpStatus.BAD_REQUEST); + } + + articles = await this.prismaService.article.findMany({ + where: { mpId: id }, + orderBy: { publishTime: 'desc' }, + take: limit, + skip: (page - 1) * limit, + }); + } else { + articles = await this.prismaService.article.findMany({ + orderBy: { publishTime: 'desc' }, + take: limit, + skip: (page - 1) * limit, + }); + + const { originUrl } = + this.configService.get('feed')!; + feedInfo = { + id: 'all', + mpName: 'WeWe-RSS All', + mpIntro: 'WeWe-RSS 全部文章', + mpCover: originUrl + ? `${originUrl}/favicon.ico` + : 'https://r2-assets.111965.xyz/wewe-rss.png', + status: 1, + syncTime: 0, + updateTime: Math.floor(Date.now() / 1e3), + hasHistory: -1, + createdAt: new Date(), + updatedAt: new Date(), + }; + } + + this.logger.log('handleGenerateFeed articles: ' + articles.length); + const feed = await this.renderFeed({ feedInfo, articles, type, mode }); + + if (title_include) { + const includes = title_include.split('|'); + feed.items = feed.items.filter((i: Item) => + includes.some((k) => i.title.includes(k)), + ); + } + if (title_exclude) { + const excludes = title_exclude.split('|'); + feed.items = feed.items.filter( + (i: Item) => !excludes.some((k) => i.title.includes(k)), + ); + } + + switch (type) { + case 'rss': + return { content: feed.rss2(), mimeType: feedMimeTypeMap[type] }; + case 'json': + return { content: feed.json1(), mimeType: feedMimeTypeMap[type] }; + case 'atom': + default: + return { content: feed.atom1(), mimeType: feedMimeTypeMap[type] }; + } + } + + async getFeedList() { + const data = await this.prismaService.feed.findMany(); + + return data.map((item) => { + return { + id: item.id, + name: item.mpName, + intro: item.mpIntro, + cover: item.mpCover, + syncTime: item.syncTime, + updateTime: item.updateTime, + }; + }); + } + + async updateFeed(id: string) { + try { + await this.trpcService.refreshMpArticlesAndUpdateFeed(id); + } catch (err) { + this.logger.error('updateFeed error', err); + } finally { + // wait 30s for next feed + await new Promise((resolve) => setTimeout(resolve, 30 * 1e3)); + } + } +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts new file mode 100644 index 0000000000000000000000000000000000000000..20959802313383dab5585a062b051f4cf51a4b5d --- /dev/null +++ b/apps/server/src/main.ts @@ -0,0 +1,49 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { TrpcRouter } from '@server/trpc/trpc.router'; +import { ConfigService } from '@nestjs/config'; +import { json, urlencoded } from 'express'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import { ConfigurationType } from './configuration'; +import { join, resolve } from 'path'; +import { readFileSync } from 'fs'; + +const packageJson = JSON.parse( + readFileSync(resolve(__dirname, '..', './package.json'), 'utf-8'), +); + +const appVersion = packageJson.version; +console.log('appVersion: v' + appVersion); + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + const configService = app.get(ConfigService); + + const { host, isProd, port } = + configService.get('server')!; + + app.use(json({ limit: '10mb' })); + app.use(urlencoded({ extended: true, limit: '10mb' })); + + app.useStaticAssets(join(__dirname, '..', 'client', 'assets'), { + prefix: '/dash/assets/', + }); + app.setBaseViewsDir(join(__dirname, '..', 'client')); + app.setViewEngine('hbs'); + + if (isProd) { + app.enable('trust proxy'); + } + + app.enableCors({ + exposedHeaders: ['authorization'], + }); + + const trpc = app.get(TrpcRouter); + trpc.applyMiddleware(app); + + await app.listen(port, host); + + console.log(`Server is running at http://${host}:${port}`); +} +bootstrap(); diff --git a/apps/server/src/prisma/prisma.module.ts b/apps/server/src/prisma/prisma.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..ec0ce3291549a316e826e073e7b028674a47a6a8 --- /dev/null +++ b/apps/server/src/prisma/prisma.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/apps/server/src/prisma/prisma.service.ts b/apps/server/src/prisma/prisma.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..359f950b75c4f7f644772315a44d1108664e697d --- /dev/null +++ b/apps/server/src/prisma/prisma.service.ts @@ -0,0 +1,9 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit { + async onModuleInit() { + await this.$connect(); + } +} diff --git a/apps/server/src/trpc/trpc.module.ts b/apps/server/src/trpc/trpc.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..14787949e9e48a938901ced6bdd96c88b9c3aca2 --- /dev/null +++ b/apps/server/src/trpc/trpc.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { TrpcRouter } from '@server/trpc/trpc.router'; +import { PrismaModule } from '@server/prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [], + providers: [TrpcService, TrpcRouter], + exports: [TrpcService, TrpcRouter], +}) +export class TrpcModule {} diff --git a/apps/server/src/trpc/trpc.router.ts b/apps/server/src/trpc/trpc.router.ts new file mode 100644 index 0000000000000000000000000000000000000000..93c4fa71d8db411ddbefa9f26b1c6488e9cba535 --- /dev/null +++ b/apps/server/src/trpc/trpc.router.ts @@ -0,0 +1,453 @@ +import { INestApplication, Injectable, Logger } from '@nestjs/common'; +import { z } from 'zod'; +import { TrpcService } from '@server/trpc/trpc.service'; +import * as trpcExpress from '@trpc/server/adapters/express'; +import { TRPCError } from '@trpc/server'; +import { PrismaService } from '@server/prisma/prisma.service'; +import { statusMap } from '@server/constants'; +import { ConfigService } from '@nestjs/config'; +import { ConfigurationType } from '@server/configuration'; + +@Injectable() +export class TrpcRouter { + constructor( + private readonly trpcService: TrpcService, + private readonly prismaService: PrismaService, + private readonly configService: ConfigService, + ) {} + + private readonly logger = new Logger(this.constructor.name); + + accountRouter = this.trpcService.router({ + list: this.trpcService.protectedProcedure + .input( + z.object({ + limit: z.number().min(1).max(1000).nullish(), + cursor: z.string().nullish(), + }), + ) + .query(async ({ input }) => { + const limit = input.limit ?? 1000; + const { cursor } = input; + + const items = await this.prismaService.account.findMany({ + take: limit + 1, + where: {}, + select: { + id: true, + name: true, + status: true, + createdAt: true, + updatedAt: true, + token: false, + }, + cursor: cursor + ? { + id: cursor, + } + : undefined, + orderBy: { + createdAt: 'asc', + }, + }); + let nextCursor: typeof cursor | undefined = undefined; + if (items.length > limit) { + // Remove the last item and use it as next cursor + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const nextItem = items.pop()!; + nextCursor = nextItem.id; + } + + const disabledAccounts = this.trpcService.getBlockedAccountIds(); + return { + blocks: disabledAccounts, + items, + nextCursor, + }; + }), + byId: this.trpcService.protectedProcedure + .input(z.string()) + .query(async ({ input: id }) => { + const account = await this.prismaService.account.findUnique({ + where: { id }, + }); + if (!account) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `No account with id '${id}'`, + }); + } + return account; + }), + add: this.trpcService.protectedProcedure + .input( + z.object({ + id: z.string().min(1).max(32), + token: z.string().min(1), + name: z.string().min(1), + status: z.number().default(statusMap.ENABLE), + }), + ) + .mutation(async ({ input }) => { + const { id, ...data } = input; + const account = await this.prismaService.account.upsert({ + where: { + id, + }, + update: data, + create: input, + }); + this.trpcService.removeBlockedAccount(id); + + return account; + }), + edit: this.trpcService.protectedProcedure + .input( + z.object({ + id: z.string(), + data: z.object({ + token: z.string().min(1).optional(), + name: z.string().min(1).optional(), + status: z.number().optional(), + }), + }), + ) + .mutation(async ({ input }) => { + const { id, data } = input; + const account = await this.prismaService.account.update({ + where: { id }, + data, + }); + this.trpcService.removeBlockedAccount(id); + return account; + }), + delete: this.trpcService.protectedProcedure + .input(z.string()) + .mutation(async ({ input: id }) => { + await this.prismaService.account.delete({ where: { id } }); + this.trpcService.removeBlockedAccount(id); + + return id; + }), + }); + + feedRouter = this.trpcService.router({ + list: this.trpcService.protectedProcedure + .input( + z.object({ + limit: z.number().min(1).max(1000).nullish(), + cursor: z.string().nullish(), + }), + ) + .query(async ({ input }) => { + const limit = input.limit ?? 1000; + const { cursor } = input; + + const items = await this.prismaService.feed.findMany({ + take: limit + 1, + where: {}, + cursor: cursor + ? { + id: cursor, + } + : undefined, + orderBy: { + createdAt: 'asc', + }, + }); + let nextCursor: typeof cursor | undefined = undefined; + if (items.length > limit) { + // Remove the last item and use it as next cursor + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const nextItem = items.pop()!; + nextCursor = nextItem.id; + } + + return { + items: items, + nextCursor, + }; + }), + byId: this.trpcService.protectedProcedure + .input(z.string()) + .query(async ({ input: id }) => { + const feed = await this.prismaService.feed.findUnique({ + where: { id }, + }); + if (!feed) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `No feed with id '${id}'`, + }); + } + return feed; + }), + add: this.trpcService.protectedProcedure + .input( + z.object({ + id: z.string(), + mpName: z.string(), + mpCover: z.string(), + mpIntro: z.string(), + syncTime: z + .number() + .optional() + .default(Math.floor(Date.now() / 1e3)), + updateTime: z.number(), + status: z.number().default(statusMap.ENABLE), + }), + ) + .mutation(async ({ input }) => { + const { id, ...data } = input; + const feed = await this.prismaService.feed.upsert({ + where: { + id, + }, + update: data, + create: input, + }); + + return feed; + }), + edit: this.trpcService.protectedProcedure + .input( + z.object({ + id: z.string(), + data: z.object({ + mpName: z.string().optional(), + mpCover: z.string().optional(), + mpIntro: z.string().optional(), + syncTime: z.number().optional(), + updateTime: z.number().optional(), + status: z.number().optional(), + }), + }), + ) + .mutation(async ({ input }) => { + const { id, data } = input; + const feed = await this.prismaService.feed.update({ + where: { id }, + data, + }); + return feed; + }), + delete: this.trpcService.protectedProcedure + .input(z.string()) + .mutation(async ({ input: id }) => { + await this.prismaService.feed.delete({ where: { id } }); + return id; + }), + + refreshArticles: this.trpcService.protectedProcedure + .input( + z.object({ + mpId: z.string().optional(), + }), + ) + .mutation(async ({ input: { mpId } }) => { + if (mpId) { + await this.trpcService.refreshMpArticlesAndUpdateFeed(mpId); + } else { + await this.trpcService.refreshAllMpArticlesAndUpdateFeed(); + } + }), + + isRefreshAllMpArticlesRunning: this.trpcService.protectedProcedure.query( + async () => { + return this.trpcService.isRefreshAllMpArticlesRunning; + }, + ), + getHistoryArticles: this.trpcService.protectedProcedure + .input( + z.object({ + mpId: z.string().optional(), + }), + ) + .mutation(async ({ input: { mpId = '' } }) => { + this.trpcService.getHistoryMpArticles(mpId); + }), + getInProgressHistoryMp: this.trpcService.protectedProcedure.query( + async () => { + return this.trpcService.inProgressHistoryMp; + }, + ), + }); + + articleRouter = this.trpcService.router({ + list: this.trpcService.protectedProcedure + .input( + z.object({ + limit: z.number().min(1).max(1000).nullish(), + cursor: z.string().nullish(), + mpId: z.string().nullish(), + }), + ) + .query(async ({ input }) => { + const limit = input.limit ?? 1000; + const { cursor, mpId } = input; + + const items = await this.prismaService.article.findMany({ + orderBy: [ + { + publishTime: 'desc', + }, + ], + take: limit + 1, + where: mpId ? { mpId } : undefined, + cursor: cursor + ? { + id: cursor, + } + : undefined, + }); + let nextCursor: typeof cursor | undefined = undefined; + if (items.length > limit) { + // Remove the last item and use it as next cursor + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const nextItem = items.pop()!; + nextCursor = nextItem.id; + } + + return { + items, + nextCursor, + }; + }), + byId: this.trpcService.protectedProcedure + .input(z.string()) + .query(async ({ input: id }) => { + const article = await this.prismaService.article.findUnique({ + where: { id }, + }); + if (!article) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `No article with id '${id}'`, + }); + } + return article; + }), + + add: this.trpcService.protectedProcedure + .input( + z.object({ + id: z.string(), + mpId: z.string(), + title: z.string(), + picUrl: z.string().optional().default(''), + publishTime: z.number(), + }), + ) + .mutation(async ({ input }) => { + const { id, ...data } = input; + const article = await this.prismaService.article.upsert({ + where: { + id, + }, + update: data, + create: input, + }); + + return article; + }), + delete: this.trpcService.protectedProcedure + .input(z.string()) + .mutation(async ({ input: id }) => { + await this.prismaService.article.delete({ where: { id } }); + return id; + }), + }); + + platformRouter = this.trpcService.router({ + getMpArticles: this.trpcService.protectedProcedure + .input( + z.object({ + mpId: z.string(), + }), + ) + .mutation(async ({ input: { mpId } }) => { + try { + const results = await this.trpcService.getMpArticles(mpId); + return results; + } catch (err: any) { + this.logger.log('getMpArticles err: ', err); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: err.response?.data?.message || err.message, + cause: err.stack, + }); + } + }), + getMpInfo: this.trpcService.protectedProcedure + .input( + z.object({ + wxsLink: z + .string() + .refine((v) => v.startsWith('https://mp.weixin.qq.com/s/')), + }), + ) + .mutation(async ({ input: { wxsLink: url } }) => { + try { + const results = await this.trpcService.getMpInfo(url); + return results; + } catch (err: any) { + this.logger.log('getMpInfo err: ', err); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: err.response?.data?.message || err.message, + cause: err.stack, + }); + } + }), + + createLoginUrl: this.trpcService.protectedProcedure.mutation(async () => { + return this.trpcService.createLoginUrl(); + }), + getLoginResult: this.trpcService.protectedProcedure + .input( + z.object({ + id: z.string(), + }), + ) + .query(async ({ input }) => { + return this.trpcService.getLoginResult(input.id); + }), + }); + + appRouter = this.trpcService.router({ + feed: this.feedRouter, + account: this.accountRouter, + article: this.articleRouter, + platform: this.platformRouter, + }); + + async applyMiddleware(app: INestApplication) { + app.use( + `/trpc`, + trpcExpress.createExpressMiddleware({ + router: this.appRouter, + createContext: ({ req }) => { + const authCode = + this.configService.get('auth')!.code; + + if (authCode && req.headers.authorization !== authCode) { + return { + errorMsg: 'authCode不正确!', + }; + } + return { + errorMsg: null, + }; + }, + middleware: (req, res, next) => { + next(); + }, + }), + ); + } +} + +export type AppRouter = TrpcRouter[`appRouter`]; diff --git a/apps/server/src/trpc/trpc.service.ts b/apps/server/src/trpc/trpc.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f20397e9e2d8d38da690ea3f400e501091abd0d --- /dev/null +++ b/apps/server/src/trpc/trpc.service.ts @@ -0,0 +1,366 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ConfigurationType } from '@server/configuration'; +import { defaultCount, statusMap } from '@server/constants'; +import { PrismaService } from '@server/prisma/prisma.service'; +import { TRPCError, initTRPC } from '@trpc/server'; +import Axios, { AxiosInstance } from 'axios'; +import dayjs from 'dayjs'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; + +dayjs.extend(utc); +dayjs.extend(timezone); + +/** + * 读书账号每日小黑屋 + */ +const blockedAccountsMap = new Map(); + +@Injectable() +export class TrpcService { + trpc = initTRPC.create(); + publicProcedure = this.trpc.procedure; + protectedProcedure = this.trpc.procedure.use(({ ctx, next }) => { + const errorMsg = (ctx as any).errorMsg; + if (errorMsg) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: errorMsg }); + } + return next({ ctx }); + }); + router = this.trpc.router; + mergeRouters = this.trpc.mergeRouters; + request: AxiosInstance; + updateDelayTime = 60; + + private readonly logger = new Logger(this.constructor.name); + + constructor( + private readonly prismaService: PrismaService, + private readonly configService: ConfigService, + ) { + const { url } = + this.configService.get('platform')!; + this.updateDelayTime = + this.configService.get( + 'feed', + )!.updateDelayTime; + + this.request = Axios.create({ baseURL: url, timeout: 15 * 1e3 }); + + this.request.interceptors.response.use( + (response) => { + return response; + }, + async (error) => { + this.logger.log('error: ', error); + const errMsg = error.response?.data?.message || ''; + + const id = (error.config.headers as any).xid; + if (errMsg.includes('WeReadError401')) { + // 账号失效 + await this.prismaService.account.update({ + where: { id }, + data: { status: statusMap.INVALID }, + }); + this.logger.error(`账号(${id})登录失效,已禁用`); + } else if (errMsg.includes('WeReadError429')) { + //TODO 处理请求频繁 + this.logger.error(`账号(${id})请求频繁,打入小黑屋`); + } + + const today = this.getTodayDate(); + + const blockedAccounts = blockedAccountsMap.get(today); + + if (Array.isArray(blockedAccounts)) { + if (id) { + blockedAccounts.push(id); + } + blockedAccountsMap.set(today, blockedAccounts); + } else if (errMsg.includes('WeReadError400')) { + this.logger.error(`账号(${id})处理请求参数出错`); + this.logger.error('WeReadError400: ', errMsg); + // 10s 后重试 + await new Promise((resolve) => setTimeout(resolve, 10 * 1e3)); + } else { + this.logger.error("Can't handle this error: ", errMsg); + } + + return Promise.reject(error); + }, + ); + } + + removeBlockedAccount = (vid: string) => { + const today = this.getTodayDate(); + + const blockedAccounts = blockedAccountsMap.get(today); + if (Array.isArray(blockedAccounts)) { + const newBlockedAccounts = blockedAccounts.filter((id) => id !== vid); + blockedAccountsMap.set(today, newBlockedAccounts); + } + }; + + private getTodayDate() { + return dayjs.tz(new Date(), 'Asia/Shanghai').format('YYYY-MM-DD'); + } + + getBlockedAccountIds() { + const today = this.getTodayDate(); + const disabledAccounts = blockedAccountsMap.get(today) || []; + this.logger.debug('disabledAccounts: ', disabledAccounts); + return disabledAccounts.filter(Boolean); + } + + private async getAvailableAccount() { + const disabledAccounts = this.getBlockedAccountIds(); + const account = await this.prismaService.account.findMany({ + where: { + status: statusMap.ENABLE, + NOT: { + id: { in: disabledAccounts }, + }, + }, + take: 10, + }); + + if (!account || account.length === 0) { + throw new Error('暂无可用读书账号!'); + } + + return account[Math.floor(Math.random() * account.length)]; + } + + async getMpArticles(mpId: string, page = 1, retryCount = 3) { + const account = await this.getAvailableAccount(); + + try { + const res = await this.request + .get< + { + id: string; + title: string; + picUrl: string; + publishTime: number; + }[] + >(`/api/v2/platform/mps/${mpId}/articles`, { + headers: { + xid: account.id, + Authorization: `Bearer ${account.token}`, + }, + params: { + page, + }, + }) + .then((res) => res.data) + .then((res) => { + this.logger.log( + `getMpArticles(${mpId}) page: ${page} articles: ${res.length}`, + ); + return res; + }); + return res; + } catch (err) { + this.logger.error(`retry(${4 - retryCount}) getMpArticles error: `, err); + if (retryCount > 0) { + return this.getMpArticles(mpId, page, retryCount - 1); + } else { + throw err; + } + } + } + + async refreshMpArticlesAndUpdateFeed(mpId: string, page = 1) { + const articles = await this.getMpArticles(mpId, page); + + if (articles.length > 0) { + let results; + const { type } = + this.configService.get('database')!; + if (type === 'sqlite') { + // sqlite3 不支持 createMany + const inserts = articles.map(({ id, picUrl, publishTime, title }) => + this.prismaService.article.upsert({ + create: { id, mpId, picUrl, publishTime, title }, + update: { + publishTime, + title, + }, + where: { id }, + }), + ); + results = await this.prismaService.$transaction(inserts); + } else { + results = await (this.prismaService.article as any).createMany({ + data: articles.map(({ id, picUrl, publishTime, title }) => ({ + id, + mpId, + picUrl, + publishTime, + title, + })), + skipDuplicates: true, + }); + } + + this.logger.debug( + `refreshMpArticlesAndUpdateFeed create results: ${JSON.stringify(results)}`, + ); + } + + // 如果文章数量小于 defaultCount,则认为没有更多历史文章 + const hasHistory = articles.length < defaultCount ? 0 : 1; + + await this.prismaService.feed.update({ + where: { id: mpId }, + data: { + syncTime: Math.floor(Date.now() / 1e3), + hasHistory, + }, + }); + + return { hasHistory }; + } + + inProgressHistoryMp = { + id: '', + page: 1, + }; + + async getHistoryMpArticles(mpId: string) { + if (this.inProgressHistoryMp.id === mpId) { + this.logger.log(`getHistoryMpArticles(${mpId}) is running`); + return; + } + + this.inProgressHistoryMp = { + id: mpId, + page: 1, + }; + + if (!this.inProgressHistoryMp.id) { + return; + } + + try { + const feed = await this.prismaService.feed.findFirstOrThrow({ + where: { + id: mpId, + }, + }); + + // 如果完整同步过历史文章,则直接返回 + if (feed.hasHistory === 0) { + this.logger.log(`getHistoryMpArticles(${mpId}) has no history`); + return; + } + + const total = await this.prismaService.article.count({ + where: { + mpId, + }, + }); + this.inProgressHistoryMp.page = Math.ceil(total / defaultCount); + + // 最多尝试一千次 + let i = 1e3; + while (i-- > 0) { + if (this.inProgressHistoryMp.id !== mpId) { + this.logger.log( + `getHistoryMpArticles(${mpId}) is not running, break`, + ); + break; + } + const { hasHistory } = await this.refreshMpArticlesAndUpdateFeed( + mpId, + this.inProgressHistoryMp.page, + ); + if (hasHistory < 1) { + this.logger.log( + `getHistoryMpArticles(${mpId}) has no history, break`, + ); + break; + } + this.inProgressHistoryMp.page++; + + await new Promise((resolve) => + setTimeout(resolve, this.updateDelayTime * 1e3), + ); + } + } finally { + this.inProgressHistoryMp = { + id: '', + page: 1, + }; + } + } + + isRefreshAllMpArticlesRunning = false; + + async refreshAllMpArticlesAndUpdateFeed() { + if (this.isRefreshAllMpArticlesRunning) { + this.logger.log('refreshAllMpArticlesAndUpdateFeed is running'); + return; + } + const mps = await this.prismaService.feed.findMany(); + this.isRefreshAllMpArticlesRunning = true; + try { + for (const { id } of mps) { + await this.refreshMpArticlesAndUpdateFeed(id); + + await new Promise((resolve) => + setTimeout(resolve, this.updateDelayTime * 1e3), + ); + } + } finally { + this.isRefreshAllMpArticlesRunning = false; + } + } + + async getMpInfo(url: string) { + url = url.trim(); + const account = await this.getAvailableAccount(); + + return this.request + .post< + { + id: string; + cover: string; + name: string; + intro: string; + updateTime: number; + }[] + >( + `/api/v2/platform/wxs2mp`, + { url }, + { + headers: { + xid: account.id, + Authorization: `Bearer ${account.token}`, + }, + }, + ) + .then((res) => res.data); + } + + async createLoginUrl() { + return this.request + .get<{ + uuid: string; + scanUrl: string; + }>(`/api/v2/login/platform`) + .then((res) => res.data); + } + + async getLoginResult(id: string) { + return this.request + .get<{ + message: string; + vid?: number; + token?: string; + username?: string; + }>(`/api/v2/login/platform/${id}`, { timeout: 120 * 1e3 }) + .then((res) => res.data); + } +} diff --git a/apps/server/test/app.e2e-spec.ts b/apps/server/test/app.e2e-spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..50cda62332e9474925e819ff946358a9c40d1bf2 --- /dev/null +++ b/apps/server/test/app.e2e-spec.ts @@ -0,0 +1,24 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from './../src/app.module'; + +describe('AppController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it('/ (GET)', () => { + return request(app.getHttpServer()) + .get('/') + .expect(200) + .expect('Hello World!'); + }); +}); diff --git a/apps/server/test/jest-e2e.json b/apps/server/test/jest-e2e.json new file mode 100644 index 0000000000000000000000000000000000000000..e9d912f3e3cefc18505d3cd19b3a5a9f567f5de0 --- /dev/null +++ b/apps/server/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/apps/server/tsconfig.build.json b/apps/server/tsconfig.build.json new file mode 100644 index 0000000000000000000000000000000000000000..64f86c6bd2bb30e3d22e752295eb7c7923fc191e --- /dev/null +++ b/apps/server/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..71be589062d2ae6c1081590afadadffe69726a03 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "esModuleInterop":true + } +} \ No newline at end of file diff --git a/apps/web/.env.local.example b/apps/web/.env.local.example new file mode 100644 index 0000000000000000000000000000000000000000..919751f19d10cedbe38fe887e8bb85df46de0816 --- /dev/null +++ b/apps/web/.env.local.example @@ -0,0 +1,2 @@ +# 同SERVER_ORIGIN_URL +VITE_SERVER_ORIGIN_URL=http://localhost:4000 diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs new file mode 100644 index 0000000000000000000000000000000000000000..c50b1397dea6b796edf65f145d23987b3b8d89c9 --- /dev/null +++ b/apps/web/.eslintrc.cjs @@ -0,0 +1,19 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + }, +}; diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..a547bf36d8d11a4f89c59c144f24795749086dd1 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000000000000000000000000000000000000..9d0b4bcdbeb4720762b2fe32304ea2671330a96a --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,30 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default { + // other rules... + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: ['./tsconfig.json', './tsconfig.node.json'], + tsconfigRootDir: __dirname, + }, +}; +``` + +- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` +- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000000000000000000000000000000000000..dc1d7b2aeeae927c15cb8095ad276c82bd7718e8 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,18 @@ + + + + + + + WeWe RSS + + + +
+ + + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000000000000000000000000000000000000..d4033ff1dd76cff0a7f55bbee8b514f8ee6228f0 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,43 @@ +{ + "name": "web", + "private": true, + "version": "2.6.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@nextui-org/react": "^2.2.9", + "@tanstack/react-query": "^4.35.3", + "@trpc/client": "^10.45.1", + "@trpc/next": "^10.45.1", + "@trpc/react-query": "^10.45.1", + "autoprefixer": "^10.0.1", + "dayjs": "^1.11.10", + "framer-motion": "^11.0.5", + "next-themes": "^0.2.1", + "postcss": "^8", + "qrcode.react": "^3.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.22.2", + "sonner": "^1.4.0", + "tailwindcss": "^3.3.0" + }, + "devDependencies": { + "@types/node": "^20.11.24", + "@types/react": "^18.2.56", + "@types/react-dom": "^18.2.19", + "@typescript-eslint/eslint-plugin": "^7.0.2", + "@typescript-eslint/parser": "^7.0.2", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^8.56.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "typescript": "^5.2.2", + "vite": "^5.1.4" + } +} \ No newline at end of file diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000000000000000000000000000000000000..2aa7205d4b402a1bdfbe07110c61df920b370066 --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d92ee1250495a9e9cba24fa273cf34aa6fbc77b9 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,28 @@ +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import Feeds from './pages/feeds'; +import Login from './pages/login'; +import Accounts from './pages/accounts'; +import { BaseLayout } from './layouts/base'; +import { TrpcProvider } from './provider/trpc'; +import ThemeProvider from './provider/theme'; + +function App() { + return ( + + + + + }> + } /> + } /> + } /> + } /> + + + + + + ); +} + +export default App; diff --git a/apps/web/src/components/GitHubIcon.tsx b/apps/web/src/components/GitHubIcon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..35ec9acd6947d955a36dcacd9fea04aac4eb0aca --- /dev/null +++ b/apps/web/src/components/GitHubIcon.tsx @@ -0,0 +1,26 @@ +import { IconSvgProps } from '../types'; + +export const GitHubIcon = ({ + size = 24, + width, + height, + ...props +}: IconSvgProps) => ( + +); diff --git a/apps/web/src/components/Nav.tsx b/apps/web/src/components/Nav.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8372e043bcdc5187bc27db31006d2bd138a3eb55 --- /dev/null +++ b/apps/web/src/components/Nav.tsx @@ -0,0 +1,116 @@ +import { + Badge, + Image, + Link, + Navbar, + NavbarBrand, + NavbarContent, + NavbarItem, + Tooltip, +} from '@nextui-org/react'; +import { ThemeSwitcher } from './ThemeSwitcher'; +import { GitHubIcon } from './GitHubIcon'; +import { useLocation } from 'react-router-dom'; +import { appVersion, serverOriginUrl } from '@web/utils/env'; +import { useEffect, useState } from 'react'; + +const navbarItemLink = [ + { + href: '/feeds', + name: '公众号源', + }, + { + href: '/accounts', + name: '账号管理', + }, + // { + // href: '/settings', + // name: '设置', + // }, +]; + +const Nav = () => { + const { pathname } = useLocation(); + const [releaseVersion, setReleaseVersion] = useState(appVersion); + + useEffect(() => { + fetch('https://api.github.com/repos/cooderl/wewe-rss/releases/latest') + .then((res) => res.json()) + .then((data) => { + setReleaseVersion(data.name.replace('v', '')); + }); + }, []); + + const isFoundNewVersion = releaseVersion > appVersion; + console.log('isFoundNewVersion: ', isFoundNewVersion); + + return ( +
+ + + {isFoundNewVersion && ( + + 发现新版本:v{releaseVersion} + + )} + 当前版本: v{appVersion} +
+ } + placement="left" + > + + + WeWe RSS + +

WeWe RSS

+
+ + + {navbarItemLink.map((item) => { + return ( + + + {item.name} + + + ); + })} + + + + + + + + + + ); +}; + +export default Nav; diff --git a/apps/web/src/components/PlusIcon.tsx b/apps/web/src/components/PlusIcon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..093f5d708f76b76268dd07b938fe2eadfdaa6eab --- /dev/null +++ b/apps/web/src/components/PlusIcon.tsx @@ -0,0 +1,30 @@ +import { IconSvgProps } from '../types'; + +export const PlusIcon = ({ + size = 24, + width, + height, + ...props +}: IconSvgProps) => ( + +); diff --git a/apps/web/src/components/StatusDropdown.tsx b/apps/web/src/components/StatusDropdown.tsx new file mode 100644 index 0000000000000000000000000000000000000000..10c0f960e995419d973e15b48fa1842a613dbb5a --- /dev/null +++ b/apps/web/src/components/StatusDropdown.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { + Dropdown, + DropdownTrigger, + DropdownMenu, + DropdownItem, + Button, +} from '@nextui-org/react'; +import { statusMap } from '@web/constants'; + +export function StatusDropdown({ + value = 1, + onChange, +}: { + value: number; + onChange: (value: number) => void; +}) { + return ( + + + + + { + onChange(+Array.from(keys)[0]); + }} + > + {Object.entries(statusMap).map(([key, value]) => { + return ( + + {value.label} + + ); + })} + + + ); +} diff --git a/apps/web/src/components/ThemeSwitcher.tsx b/apps/web/src/components/ThemeSwitcher.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3e8fb0bb7467447a79fa182f5152e45df9a94191 --- /dev/null +++ b/apps/web/src/components/ThemeSwitcher.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { VisuallyHidden, useSwitch } from '@nextui-org/react'; +import { useTheme } from 'next-themes'; + +export const MoonIcon = (props) => ( + +); + +export const SunIcon = (props) => ( + +); + +export function ThemeSwitcher(props) { + const { setTheme, theme } = useTheme(); + const { + Component, + slots, + isSelected, + getBaseProps, + getInputProps, + getWrapperProps, + } = useSwitch({ + onClick: () => setTheme(theme === 'dark' ? 'light' : 'dark'), + isSelected: theme === 'dark', + }); + + return ( +
+ + + + +
+ {isSelected ? : } +
+
+
+ ); +} diff --git a/apps/web/src/constants.ts b/apps/web/src/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca2f21e7489ef6d4f6985ea1e773990f0c85eb81 --- /dev/null +++ b/apps/web/src/constants.ts @@ -0,0 +1,5 @@ +export const statusMap = { + 0: { label: '失效', color: 'danger' }, + 1: { label: '启用', color: 'success' }, + 2: { label: '禁用', color: 'warning' }, +} as const; diff --git a/apps/web/src/index.css b/apps/web/src/index.css new file mode 100644 index 0000000000000000000000000000000000000000..b5c61c956711f981a41e95f7fcf0038436cfbb22 --- /dev/null +++ b/apps/web/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/apps/web/src/layouts/base.tsx b/apps/web/src/layouts/base.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d90152f385e23e4fced6b43051bad34bef0a810f --- /dev/null +++ b/apps/web/src/layouts/base.tsx @@ -0,0 +1,18 @@ +import { Toaster } from 'sonner'; +import { Outlet } from 'react-router-dom'; + +import Nav from '../components/Nav'; + +export function BaseLayout() { + return ( +
+
+ +
+ +
+
+ +
+ ); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0fe899540d624ab463da0aa52510f8875dd17fee --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,5 @@ +import ReactDOM from 'react-dom/client'; +import App from './App.tsx'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render(); diff --git a/apps/web/src/pages/accounts/index.tsx b/apps/web/src/pages/accounts/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..83d3cc6096500b17d4455079ea83fc19639a2740 --- /dev/null +++ b/apps/web/src/pages/accounts/index.tsx @@ -0,0 +1,221 @@ +import { + Modal, + ModalContent, + ModalHeader, + ModalBody, + Button, + useDisclosure, + Spinner, + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, + Chip, +} from '@nextui-org/react'; +import { QRCodeSVG } from 'qrcode.react'; +import { toast } from 'sonner'; +import { PlusIcon } from '@web/components/PlusIcon'; +import dayjs from 'dayjs'; +import { StatusDropdown } from '@web/components/StatusDropdown'; +import { trpc } from '@web/utils/trpc'; +import { statusMap } from '@web/constants'; +import { useEffect, useState } from 'react'; + +const AccountPage = () => { + const { isOpen, onOpen, onClose, onOpenChange } = useDisclosure(); + const [count, setCount] = useState(0); + + const { refetch, data, isFetching } = trpc.account.list.useQuery({}); + + const queryUtils = trpc.useUtils(); + + const { mutateAsync: updateAccount } = trpc.account.edit.useMutation({}); + + const { mutateAsync: deleteAccount } = trpc.account.delete.useMutation({}); + + const { mutateAsync: addAccount } = trpc.account.add.useMutation({}); + + const { mutateAsync, data: loginData } = + trpc.platform.createLoginUrl.useMutation({ + onSuccess(data) { + if (data.uuid) { + setCount(60); + } + }, + }); + + const { data: loginResult } = trpc.platform.getLoginResult.useQuery( + { + id: loginData?.uuid ?? '', + }, + { + refetchIntervalInBackground: false, + enabled: !!loginData?.uuid, + async onSuccess(data) { + if (data.vid && data.token) { + const name = data.username!; + await addAccount({ id: `${data.vid}`, name, token: data.token }); + + onClose(); + toast.success('添加成功', { + description: `用户名:${name}(${data.vid})`, + }); + refetch(); + } else if (data.message) { + toast.error(`登录失败: ${data.message}`); + } + }, + }, + ); + + useEffect(() => { + let timerId; + if (count > 0 && isOpen) { + timerId = setTimeout(() => { + setCount(count - 1); + }, 1000); + } + return () => timerId && clearTimeout(timerId); + }, [count, isOpen]); + + return ( +
+
+
共{data?.items.length || 0}个账号
+ +
+ + + ID + 用户名 + 状态 + 更新时间 + 操作 + + 暂无数据} + isLoading={isFetching} + loadingContent={} + > + {data?.items.map((item) => { + const isBlocked = data?.blocks.includes(item.id); + + return ( + + {item.id} + {item.name} + + {isBlocked ? ( + + 今日小黑屋 + + ) : ( + + {statusMap[item.status].label} + + )} + + + {dayjs(item.updatedAt).format('YYYY-MM-DD')} + + + { + updateAccount({ + id: item.id, + data: { status: value }, + }).then(() => { + toast.success('更新成功!'); + refetch(); + }); + }} + > + + + + + ); + }) || []} + +
+ + { + onOpenChange(); + await queryUtils.platform.getLoginResult.cancel(); + }} + > + + {() => ( + <> + + 添加读书账号 + + +
+ {loginData ? ( +
+
+ {loginResult?.message && ( +
+
+ {loginResult?.message} +
+
+ )} + +
+
+ 微信扫码登录{' '} + {!loginResult?.message && count > 0 && ( + ({count}s) + )} +
+
+ ) : ( +
+ + 二维码加载中 +
+ )} +
+
+ + )} +
+
+
+ ); +}; + +export default AccountPage; diff --git a/apps/web/src/pages/feeds/index.tsx b/apps/web/src/pages/feeds/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..31927490b3733165907b05b524baa7319f8f93cc --- /dev/null +++ b/apps/web/src/pages/feeds/index.tsx @@ -0,0 +1,439 @@ +import { + Avatar, + Button, + Divider, + Listbox, + ListboxItem, + ListboxSection, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Switch, + Textarea, + Tooltip, + useDisclosure, + Link, +} from '@nextui-org/react'; +import { PlusIcon } from '@web/components/PlusIcon'; +import { trpc } from '@web/utils/trpc'; +import { useMemo, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { toast } from 'sonner'; +import dayjs from 'dayjs'; +import { serverOriginUrl } from '@web/utils/env'; +import ArticleList from './list'; + +const Feeds = () => { + const { id } = useParams(); + + const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure(); + const { refetch: refetchFeedList, data: feedData } = trpc.feed.list.useQuery( + {}, + { + refetchOnWindowFocus: true, + }, + ); + + const navigate = useNavigate(); + + const queryUtils = trpc.useUtils(); + + const { mutateAsync: getMpInfo, isLoading: isGetMpInfoLoading } = + trpc.platform.getMpInfo.useMutation({}); + const { mutateAsync: updateMpInfo } = trpc.feed.edit.useMutation({}); + + const { mutateAsync: addFeed, isLoading: isAddFeedLoading } = + trpc.feed.add.useMutation({}); + const { mutateAsync: refreshMpArticles, isLoading: isGetArticlesLoading } = + trpc.feed.refreshArticles.useMutation(); + const { + mutateAsync: getHistoryArticles, + isLoading: isGetHistoryArticlesLoading, + } = trpc.feed.getHistoryArticles.useMutation(); + + const { data: inProgressHistoryMp, refetch: refetchInProgressHistoryMp } = + trpc.feed.getInProgressHistoryMp.useQuery(undefined, { + refetchOnWindowFocus: true, + refetchInterval: 10 * 1e3, + refetchOnMount: true, + refetchOnReconnect: true, + }); + + const { data: isRefreshAllMpArticlesRunning } = + trpc.feed.isRefreshAllMpArticlesRunning.useQuery(); + + const { mutateAsync: deleteFeed, isLoading: isDeleteFeedLoading } = + trpc.feed.delete.useMutation({}); + + const [wxsLink, setWxsLink] = useState(''); + + const [currentMpId, setCurrentMpId] = useState(id || ''); + + const handleConfirm = async () => { + console.log('wxsLink', wxsLink); + // TODO show operation in progress + const wxsLinks = wxsLink.split('\n').filter((link) => link.trim() !== ''); + for (const link of wxsLinks) { + console.log('add wxsLink', link); + const res = await getMpInfo({ wxsLink: link }); + if (res[0]) { + const item = res[0]; + await addFeed({ + id: item.id, + mpName: item.name, + mpCover: item.cover, + mpIntro: item.intro, + updateTime: item.updateTime, + status: 1, + }); + await refreshMpArticles({ mpId: item.id }); + toast.success('添加成功', { + description: `公众号 ${item.name}`, + }); + await queryUtils.article.list.reset(); + } else { + toast.error('添加失败', { description: '请检查链接是否正确' }); + } + } + refetchFeedList(); + setWxsLink(''); + onClose(); + }; + + const isActive = (key: string) => { + return currentMpId === key; + }; + + const currentMpInfo = useMemo(() => { + return feedData?.items.find((item) => item.id === currentMpId); + }, [currentMpId, feedData?.items]); + + const handleExportOpml = async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + if (!feedData?.items?.length) { + console.warn('没有订阅源'); + return; + } + + let opmlContent = ` + + + WeWeRSS 所有订阅源 + + + `; + + feedData?.items.forEach((sub) => { + opmlContent += ` \n`; + }); + + opmlContent += ` + `; + + const blob = new Blob([opmlContent], { type: 'text/xml;charset=utf-8;' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = 'WeWeRSS-All.opml'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + return ( + <> +
+
+
+ +
+ 共{feedData?.items.length || 0}个订阅 +
+
+ + {feedData?.items ? ( + setCurrentMpId(key as string)} + > + + } + > + 全部 + + + + + {feedData?.items.map((item) => { + return ( + } + > + {item.mpName} + + ); + }) || []} + + + ) : ( + '' + )} +
+
+
+

+ {currentMpInfo?.mpName || '全部'} +

+ {currentMpInfo ? ( +
+
+ 最后更新时间: + {dayjs(currentMpInfo.syncTime * 1e3).format( + 'YYYY-MM-DD HH:mm:ss', + )} +
+ + + { + ev.preventDefault(); + ev.stopPropagation(); + await refreshMpArticles({ mpId: currentMpInfo.id }); + await refetchFeedList(); + await queryUtils.article.list.reset(); + }} + > + {isGetArticlesLoading ? '更新中...' : '立即更新'} + + + + {currentMpInfo.hasHistory === 1 && ( + <> + + { + ev.preventDefault(); + ev.stopPropagation(); + + if (inProgressHistoryMp?.id === currentMpInfo.id) { + await getHistoryArticles({ + mpId: '', + }); + } else { + await getHistoryArticles({ + mpId: currentMpInfo.id, + }); + } + + await refetchInProgressHistoryMp(); + }} + > + {inProgressHistoryMp?.id === currentMpInfo.id + ? `停止获取历史文章` + : `获取历史文章`} + + + + + )} + + +
+ { + await updateMpInfo({ + id: currentMpInfo.id, + data: { + status: value ? 1 : 0, + }, + }); + + await refetchFeedList(); + }} + isSelected={currentMpInfo?.status === 1} + > +
+
+ + + { + ev.preventDefault(); + ev.stopPropagation(); + + if (window.confirm('确定删除吗?')) { + await deleteFeed(currentMpInfo.id); + navigate('/feeds'); + await refetchFeedList(); + } + }} + > + 删除 + + + + + + 可添加.atom/.rss/.json格式输出, limit=20&page=1控制分页 +
+ } + > + + RSS + + +
+ ) : ( +
+ + { + ev.preventDefault(); + ev.stopPropagation(); + await refreshMpArticles({}); + await refetchFeedList(); + await queryUtils.article.list.reset(); + }} + > + {isRefreshAllMpArticlesRunning || isGetArticlesLoading + ? '更新中...' + : '更新全部'} + + + + 导出OPML + + + + RSS + +
+ )} +
+
+ +
+
+ + + + {(onClose) => ( + <> + + 添加公众号源 + + +