div-wang commited on
Commit
95d29a5
·
verified ·
1 Parent(s): 85e0f8b

上传文件

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +61 -0
  2. LICENSE +21 -0
  3. apps/server/.env.local.example +34 -0
  4. apps/server/.eslintrc.js +25 -0
  5. apps/server/.gitignore +6 -0
  6. apps/server/.prettierrc.json +5 -0
  7. apps/server/README.md +73 -0
  8. apps/server/docker-bootstrap.sh +8 -0
  9. apps/server/nest-cli.json +8 -0
  10. apps/server/package.json +93 -0
  11. apps/server/prisma-sqlite/migrations/20240301104100_init/migration.sql +33 -0
  12. apps/server/prisma-sqlite/migrations/20241214172323_has_history/migration.sql +2 -0
  13. apps/server/prisma-sqlite/migrations/migration_lock.toml +3 -0
  14. apps/server/prisma-sqlite/schema.prisma +59 -0
  15. apps/server/prisma/migrations/20240227153512_init/migration.sql +39 -0
  16. apps/server/prisma/migrations/20241212153618_has_history/migration.sql +2 -0
  17. apps/server/prisma/migrations/migration_lock.toml +3 -0
  18. apps/server/prisma/schema.prisma +59 -0
  19. apps/server/src/app.controller.spec.ts +22 -0
  20. apps/server/src/app.controller.ts +48 -0
  21. apps/server/src/app.module.ts +39 -0
  22. apps/server/src/app.service.ts +14 -0
  23. apps/server/src/configuration.ts +40 -0
  24. apps/server/src/constants.ts +16 -0
  25. apps/server/src/feeds/feeds.controller.spec.ts +18 -0
  26. apps/server/src/feeds/feeds.controller.ts +83 -0
  27. apps/server/src/feeds/feeds.module.ts +12 -0
  28. apps/server/src/feeds/feeds.service.spec.ts +18 -0
  29. apps/server/src/feeds/feeds.service.ts +343 -0
  30. apps/server/src/main.ts +49 -0
  31. apps/server/src/prisma/prisma.module.ts +8 -0
  32. apps/server/src/prisma/prisma.service.ts +9 -0
  33. apps/server/src/trpc/trpc.module.ts +12 -0
  34. apps/server/src/trpc/trpc.router.ts +453 -0
  35. apps/server/src/trpc/trpc.service.ts +366 -0
  36. apps/server/test/app.e2e-spec.ts +24 -0
  37. apps/server/test/jest-e2e.json +9 -0
  38. apps/server/tsconfig.build.json +4 -0
  39. apps/server/tsconfig.json +13 -0
  40. apps/web/.env.local.example +2 -0
  41. apps/web/.eslintrc.cjs +19 -0
  42. apps/web/.gitignore +24 -0
  43. apps/web/README.md +30 -0
  44. apps/web/index.html +18 -0
  45. apps/web/package.json +43 -0
  46. apps/web/postcss.config.js +6 -0
  47. apps/web/src/App.tsx +28 -0
  48. apps/web/src/components/GitHubIcon.tsx +26 -0
  49. apps/web/src/components/Nav.tsx +116 -0
  50. apps/web/src/components/PlusIcon.tsx +30 -0
Dockerfile ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20.16.0-alpine AS base
2
+ ENV PNPM_HOME="/pnpm"
3
+ ENV PATH="$PNPM_HOME:$PATH"
4
+
5
+ RUN npm i -g pnpm
6
+
7
+ FROM base AS build
8
+ COPY . /usr/src/app
9
+ WORKDIR /usr/src/app
10
+
11
+ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
12
+
13
+ RUN pnpm run -r build
14
+
15
+ RUN pnpm deploy --filter=server --prod /app
16
+ RUN pnpm deploy --filter=server --prod /app-sqlite
17
+
18
+ RUN cd /app && pnpm exec prisma generate
19
+
20
+ RUN cd /app-sqlite && \
21
+ rm -rf ./prisma && \
22
+ mv prisma-sqlite prisma && \
23
+ pnpm exec prisma generate
24
+
25
+ FROM base AS app-sqlite
26
+ COPY --from=build /app-sqlite /app
27
+
28
+ WORKDIR /app
29
+
30
+ EXPOSE 4000
31
+
32
+ ENV NODE_ENV=production
33
+ ENV HOST="0.0.0.0"
34
+ ENV SERVER_ORIGIN_URL=""
35
+ ENV MAX_REQUEST_PER_MINUTE=60
36
+ ENV AUTH_CODE=""
37
+ ENV DATABASE_URL="file:../data/wewe-rss.db"
38
+ ENV DATABASE_TYPE="sqlite"
39
+
40
+ RUN chmod +x ./docker-bootstrap.sh
41
+
42
+ CMD ["./docker-bootstrap.sh"]
43
+
44
+
45
+ FROM base AS app
46
+ COPY --from=build /app /app
47
+
48
+ WORKDIR /app
49
+
50
+ EXPOSE 4000
51
+
52
+ ENV NODE_ENV=production
53
+ ENV HOST="0.0.0.0"
54
+ ENV SERVER_ORIGIN_URL=""
55
+ ENV MAX_REQUEST_PER_MINUTE=60
56
+ ENV AUTH_CODE=""
57
+ ENV DATABASE_URL=""
58
+
59
+ RUN chmod +x ./docker-bootstrap.sh
60
+
61
+ CMD ["./docker-bootstrap.sh"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2024 cooderl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
apps/server/.env.local.example ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ HOST=0.0.0.0
2
+ PORT=4000
3
+
4
+ # Prisma
5
+ # https://www.prisma.io/docs/reference/database-reference/connection-urls#env
6
+ DATABASE_URL="mysql://root:[email protected]:3306/wewe-rss"
7
+
8
+ # 使用Sqlite
9
+ # DATABASE_URL="file:../data/wewe-rss.db"
10
+ # DATABASE_TYPE="sqlite"
11
+
12
+ # 访问授权码
13
+ AUTH_CODE=123567
14
+
15
+ # 每分钟最大请求次数
16
+ MAX_REQUEST_PER_MINUTE=60
17
+
18
+ # 自动提取全文内容
19
+ FEED_MODE="fulltext"
20
+
21
+ # nginx 转发后的服务端地址
22
+ SERVER_ORIGIN_URL=http://localhost:4000
23
+
24
+ # 定时更新订阅源Cron表达式
25
+ CRON_EXPRESSION="35 5,17 * * *"
26
+
27
+ # 是否开启正文html清理
28
+ ENABLE_CLEAN_HTML=false
29
+
30
+ # 连续更新延迟时间(秒)
31
+ UPDATE_DELAY_TIME=60
32
+
33
+ # 读书转发服务,不需要修改
34
+ PLATFORM_URL="https://weread.111965.xyz"
apps/server/.eslintrc.js ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ module.exports = {
2
+ parser: '@typescript-eslint/parser',
3
+ parserOptions: {
4
+ project: 'tsconfig.json',
5
+ tsconfigRootDir: __dirname,
6
+ sourceType: 'module',
7
+ },
8
+ plugins: ['@typescript-eslint/eslint-plugin'],
9
+ extends: [
10
+ 'plugin:@typescript-eslint/recommended',
11
+ 'plugin:prettier/recommended',
12
+ ],
13
+ root: true,
14
+ env: {
15
+ node: true,
16
+ jest: true,
17
+ },
18
+ ignorePatterns: ['.eslintrc.js'],
19
+ rules: {
20
+ '@typescript-eslint/interface-name-prefix': 'off',
21
+ '@typescript-eslint/explicit-function-return-type': 'off',
22
+ '@typescript-eslint/explicit-module-boundary-types': 'off',
23
+ '@typescript-eslint/no-explicit-any': 'off',
24
+ },
25
+ };
apps/server/.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ node_modules
2
+ # Keep environment variables out of version control
3
+ .env
4
+
5
+ client
6
+ data
apps/server/.prettierrc.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "tabWidth": 2,
3
+ "singleQuote": true,
4
+ "trailingComma": "all"
5
+ }
apps/server/README.md ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <p align="center">
2
+ <a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
3
+ </p>
4
+
5
+ [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
6
+ [circleci-url]: https://circleci.com/gh/nestjs/nest
7
+
8
+ <p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
9
+ <p align="center">
10
+ <a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
11
+ <a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
12
+ <a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
13
+ <a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
14
+ <a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
15
+ <a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
16
+ <a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
17
+ <a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
18
+ <a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
19
+ <a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
20
+ <a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
21
+ </p>
22
+ <!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
23
+ [![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
24
+
25
+ ## Description
26
+
27
+ [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ $ pnpm install
33
+ ```
34
+
35
+ ## Running the app
36
+
37
+ ```bash
38
+ # development
39
+ $ pnpm run start
40
+
41
+ # watch mode
42
+ $ pnpm run start:dev
43
+
44
+ # production mode
45
+ $ pnpm run start:prod
46
+ ```
47
+
48
+ ## Test
49
+
50
+ ```bash
51
+ # unit tests
52
+ $ pnpm run test
53
+
54
+ # e2e tests
55
+ $ pnpm run test:e2e
56
+
57
+ # test coverage
58
+ $ pnpm run test:cov
59
+ ```
60
+
61
+ ## Support
62
+
63
+ 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).
64
+
65
+ ## Stay in touch
66
+
67
+ - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
68
+ - Website - [https://nestjs.com](https://nestjs.com/)
69
+ - Twitter - [@nestframework](https://twitter.com/nestframework)
70
+
71
+ ## License
72
+
73
+ Nest is [MIT licensed](LICENSE).
apps/server/docker-bootstrap.sh ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+
2
+ #!/bin/sh
3
+ # ENVIRONEMTN from docker-compose.yaml doesn't get through to subprocesses
4
+ # Need to explicit pass DATABASE_URL here, otherwise migration doesn't work
5
+ # Run migrations
6
+ DATABASE_URL=${DATABASE_URL} npx prisma migrate deploy
7
+ # start app
8
+ DATABASE_URL=${DATABASE_URL} node dist/main
apps/server/nest-cli.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://json.schemastore.org/nest-cli",
3
+ "collection": "@nestjs/schematics",
4
+ "sourceRoot": "src",
5
+ "compilerOptions": {
6
+ "deleteOutDir": true
7
+ }
8
+ }
apps/server/package.json ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "server",
3
+ "version": "2.6.1",
4
+ "description": "",
5
+ "author": "",
6
+ "private": true,
7
+ "license": "UNLICENSED",
8
+ "scripts": {
9
+ "build": "nest build",
10
+ "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
11
+ "start": "nest start",
12
+ "dev": "nest start --watch",
13
+ "start:debug": "nest start --debug --watch",
14
+ "start:prod": "node dist/main",
15
+ "start:migrate:prod": "prisma migrate deploy && npm run start:prod",
16
+ "postinstall": "npx prisma generate",
17
+ "migrate": "pnpm prisma migrate dev",
18
+ "studio": "pnpm prisma studio",
19
+ "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
20
+ "test": "jest",
21
+ "test:watch": "jest --watch",
22
+ "test:cov": "jest --coverage",
23
+ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
24
+ "test:e2e": "jest --config ./test/jest-e2e.json"
25
+ },
26
+ "dependencies": {
27
+ "@cjs-exporter/p-map": "^5.5.0",
28
+ "@nestjs/common": "^10.3.3",
29
+ "@nestjs/config": "^3.2.0",
30
+ "@nestjs/core": "^10.3.3",
31
+ "@nestjs/platform-express": "^10.3.3",
32
+ "@nestjs/schedule": "^4.0.1",
33
+ "@nestjs/throttler": "^5.1.2",
34
+ "@prisma/client": "5.10.1",
35
+ "@trpc/server": "^10.45.1",
36
+ "axios": "^1.6.7",
37
+ "cheerio": "1.0.0-rc.12",
38
+ "class-transformer": "^0.5.1",
39
+ "class-validator": "^0.14.1",
40
+ "dayjs": "^1.11.10",
41
+ "express": "^4.18.2",
42
+ "feed": "^4.2.2",
43
+ "got": "11.8.6",
44
+ "hbs": "^4.2.0",
45
+ "html-minifier": "^4.0.0",
46
+ "lru-cache": "^10.2.2",
47
+ "prisma": "^5.10.2",
48
+ "reflect-metadata": "^0.2.1",
49
+ "rxjs": "^7.8.1",
50
+ "zod": "^3.22.4"
51
+ },
52
+ "devDependencies": {
53
+ "@nestjs/cli": "^10.3.2",
54
+ "@nestjs/schematics": "^10.1.1",
55
+ "@nestjs/testing": "^10.3.3",
56
+ "@types/express": "^4.17.21",
57
+ "@types/html-minifier": "^4.0.5",
58
+ "@types/jest": "^29.5.12",
59
+ "@types/node": "^20.11.19",
60
+ "@types/supertest": "^6.0.2",
61
+ "@typescript-eslint/eslint-plugin": "^7.0.2",
62
+ "@typescript-eslint/parser": "^7.0.2",
63
+ "eslint": "^8.56.0",
64
+ "eslint-config-prettier": "^9.1.0",
65
+ "eslint-plugin-prettier": "^5.1.3",
66
+ "jest": "^29.7.0",
67
+ "prettier": "^3.2.5",
68
+ "source-map-support": "^0.5.21",
69
+ "supertest": "^6.3.4",
70
+ "ts-jest": "^29.1.2",
71
+ "ts-loader": "^9.5.1",
72
+ "ts-node": "^10.9.2",
73
+ "tsconfig-paths": "^4.2.0",
74
+ "typescript": "^5.3.3"
75
+ },
76
+ "jest": {
77
+ "moduleFileExtensions": [
78
+ "js",
79
+ "json",
80
+ "ts"
81
+ ],
82
+ "rootDir": "src",
83
+ "testRegex": ".*\\.spec\\.ts$",
84
+ "transform": {
85
+ "^.+\\.(t|j)s$": "ts-jest"
86
+ },
87
+ "collectCoverageFrom": [
88
+ "**/*.(t|j)s"
89
+ ],
90
+ "coverageDirectory": "../coverage",
91
+ "testEnvironment": "node"
92
+ }
93
+ }
apps/server/prisma-sqlite/migrations/20240301104100_init/migration.sql ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- CreateTable
2
+ CREATE TABLE "accounts" (
3
+ "id" TEXT NOT NULL PRIMARY KEY,
4
+ "token" TEXT NOT NULL,
5
+ "name" TEXT NOT NULL,
6
+ "status" INTEGER NOT NULL DEFAULT 1,
7
+ "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
8
+ "updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP
9
+ );
10
+
11
+ -- CreateTable
12
+ CREATE TABLE "feeds" (
13
+ "id" TEXT NOT NULL PRIMARY KEY,
14
+ "mp_name" TEXT NOT NULL,
15
+ "mp_cover" TEXT NOT NULL,
16
+ "mp_intro" TEXT NOT NULL,
17
+ "status" INTEGER NOT NULL DEFAULT 1,
18
+ "sync_time" INTEGER NOT NULL DEFAULT 0,
19
+ "update_time" INTEGER NOT NULL,
20
+ "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
21
+ "updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP
22
+ );
23
+
24
+ -- CreateTable
25
+ CREATE TABLE "articles" (
26
+ "id" TEXT NOT NULL PRIMARY KEY,
27
+ "mp_id" TEXT NOT NULL,
28
+ "title" TEXT NOT NULL,
29
+ "pic_url" TEXT NOT NULL,
30
+ "publish_time" INTEGER NOT NULL,
31
+ "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
32
+ "updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP
33
+ );
apps/server/prisma-sqlite/migrations/20241214172323_has_history/migration.sql ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ -- AlterTable
2
+ ALTER TABLE "feeds" ADD COLUMN "has_history" INTEGER DEFAULT 1;
apps/server/prisma-sqlite/migrations/migration_lock.toml ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Please do not edit this file manually
2
+ # It should be added in your version-control system (i.e. Git)
3
+ provider = "sqlite"
apps/server/prisma-sqlite/schema.prisma ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ datasource db {
2
+ provider = "sqlite"
3
+ url = env("DATABASE_URL")
4
+ }
5
+
6
+ generator client {
7
+ provider = "prisma-client-js"
8
+ binaryTargets = ["native", "linux-musl"] // 生成linux可执行文件
9
+ }
10
+
11
+ // 读书账号
12
+ model Account {
13
+ id String @id
14
+ token String @map("token")
15
+ name String @map("name")
16
+ // 状态 0:失效 1:启用 2:禁用
17
+ status Int @default(1) @map("status")
18
+ createdAt DateTime @default(now()) @map("created_at")
19
+ updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
20
+
21
+ @@map("accounts")
22
+ }
23
+
24
+ // 订阅源
25
+ model Feed {
26
+ id String @id
27
+ mpName String @map("mp_name")
28
+ mpCover String @map("mp_cover")
29
+ mpIntro String @map("mp_intro")
30
+ // 状态 0:失效 1:启用 2:禁用
31
+ status Int @default(1) @map("status")
32
+
33
+ // article最后同步时间
34
+ syncTime Int @default(0) @map("sync_time")
35
+
36
+ // 信息更新时间
37
+ updateTime Int @map("update_time")
38
+
39
+ createdAt DateTime @default(now()) @map("created_at")
40
+ updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
41
+
42
+ // 是否有历史文章 1 是 0 否
43
+ hasHistory Int? @default(1) @map("has_history")
44
+
45
+ @@map("feeds")
46
+ }
47
+
48
+ model Article {
49
+ id String @id
50
+ mpId String @map("mp_id")
51
+ title String @map("title")
52
+ picUrl String @map("pic_url")
53
+ publishTime Int @map("publish_time")
54
+
55
+ createdAt DateTime @default(now()) @map("created_at")
56
+ updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
57
+
58
+ @@map("articles")
59
+ }
apps/server/prisma/migrations/20240227153512_init/migration.sql ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- CreateTable
2
+ CREATE TABLE `accounts` (
3
+ `id` VARCHAR(255) NOT NULL,
4
+ `token` VARCHAR(2048) NOT NULL,
5
+ `name` VARCHAR(1024) NOT NULL,
6
+ `status` INTEGER NOT NULL DEFAULT 1,
7
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
8
+ `updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
9
+
10
+ PRIMARY KEY (`id`)
11
+ ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
12
+
13
+ -- CreateTable
14
+ CREATE TABLE `feeds` (
15
+ `id` VARCHAR(255) NOT NULL,
16
+ `mp_name` VARCHAR(512) NOT NULL,
17
+ `mp_cover` VARCHAR(1024) NOT NULL,
18
+ `mp_intro` TEXT NOT NULL,
19
+ `status` INTEGER NOT NULL DEFAULT 1,
20
+ `sync_time` INTEGER NOT NULL DEFAULT 0,
21
+ `update_time` INTEGER NOT NULL,
22
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
23
+ `updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
24
+
25
+ PRIMARY KEY (`id`)
26
+ ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
27
+
28
+ -- CreateTable
29
+ CREATE TABLE `articles` (
30
+ `id` VARCHAR(255) NOT NULL,
31
+ `mp_id` VARCHAR(255) NOT NULL,
32
+ `title` VARCHAR(255) NOT NULL,
33
+ `pic_url` VARCHAR(255) NOT NULL,
34
+ `publish_time` INTEGER NOT NULL,
35
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
36
+ `updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
37
+
38
+ PRIMARY KEY (`id`)
39
+ ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
apps/server/prisma/migrations/20241212153618_has_history/migration.sql ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ -- AlterTable
2
+ ALTER TABLE `feeds` ADD COLUMN `has_history` INTEGER NULL DEFAULT 1;
apps/server/prisma/migrations/migration_lock.toml ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Please do not edit this file manually
2
+ # It should be added in your version-control system (i.e. Git)
3
+ provider = "mysql"
apps/server/prisma/schema.prisma ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ datasource db {
2
+ provider = "mysql"
3
+ url = env("DATABASE_URL")
4
+ }
5
+
6
+ generator client {
7
+ provider = "prisma-client-js"
8
+ binaryTargets = ["native", "linux-musl"] // 生成linux可执行文件
9
+ }
10
+
11
+ // 读书账号
12
+ model Account {
13
+ id String @id @db.VarChar(255)
14
+ token String @map("token") @db.VarChar(2048)
15
+ name String @map("name") @db.VarChar(1024)
16
+ // 状态 0:失效 1:启用 2:禁用
17
+ status Int @default(1) @map("status") @db.Int()
18
+ createdAt DateTime @default(now()) @map("created_at")
19
+ updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
20
+
21
+ @@map("accounts")
22
+ }
23
+
24
+ // 订阅源
25
+ model Feed {
26
+ id String @id @db.VarChar(255)
27
+ mpName String @map("mp_name") @db.VarChar(512)
28
+ mpCover String @map("mp_cover") @db.VarChar(1024)
29
+ mpIntro String @map("mp_intro") @db.Text()
30
+ // 状态 0:失效 1:启用 2:禁用
31
+ status Int @default(1) @map("status") @db.Int()
32
+
33
+ // article最后同步时间
34
+ syncTime Int @default(0) @map("sync_time")
35
+
36
+ // 信息更新时间
37
+ updateTime Int @map("update_time")
38
+
39
+ createdAt DateTime @default(now()) @map("created_at")
40
+ updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
41
+
42
+ // 是否有历史文章 1 是 0 否
43
+ hasHistory Int? @default(1) @map("has_history")
44
+
45
+ @@map("feeds")
46
+ }
47
+
48
+ model Article {
49
+ id String @id @db.VarChar(255)
50
+ mpId String @map("mp_id") @db.VarChar(255)
51
+ title String @map("title") @db.VarChar(255)
52
+ picUrl String @map("pic_url") @db.VarChar(255)
53
+ publishTime Int @map("publish_time")
54
+
55
+ createdAt DateTime @default(now()) @map("created_at")
56
+ updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
57
+
58
+ @@map("articles")
59
+ }
apps/server/src/app.controller.spec.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Test, TestingModule } from '@nestjs/testing';
2
+ import { AppController } from './app.controller';
3
+ import { AppService } from './app.service';
4
+
5
+ describe('AppController', () => {
6
+ let appController: AppController;
7
+
8
+ beforeEach(async () => {
9
+ const app: TestingModule = await Test.createTestingModule({
10
+ controllers: [AppController],
11
+ providers: [AppService],
12
+ }).compile();
13
+
14
+ appController = app.get<AppController>(AppController);
15
+ });
16
+
17
+ describe('root', () => {
18
+ it('should return "Hello World!"', () => {
19
+ expect(appController.getHello()).toBe('Hello World!');
20
+ });
21
+ });
22
+ });
apps/server/src/app.controller.ts ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Controller, Get, Response, Render } from '@nestjs/common';
2
+ import { AppService } from './app.service';
3
+ import { ConfigService } from '@nestjs/config';
4
+ import { ConfigurationType } from './configuration';
5
+ import { Response as Res } from 'express';
6
+
7
+ @Controller()
8
+ export class AppController {
9
+ constructor(
10
+ private readonly appService: AppService,
11
+ private readonly configService: ConfigService,
12
+ ) {}
13
+
14
+ @Get()
15
+ getHello(): string {
16
+ return this.appService.getHello();
17
+ }
18
+
19
+ @Get('/robots.txt')
20
+ forRobot(): string {
21
+ return 'User-agent: *\nDisallow: /';
22
+ }
23
+
24
+ @Get('favicon.ico')
25
+ getFavicon(@Response() res: Res) {
26
+ const imgContent =
27
+ '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=';
28
+ const imgBuffer = Buffer.from(imgContent, 'base64');
29
+ res.setHeader('Content-Type', 'image/png');
30
+ res.send(imgBuffer);
31
+ }
32
+
33
+ @Get('/dash*')
34
+ @Render('index.hbs')
35
+ dashRender() {
36
+ const { originUrl: weweRssServerOriginUrl } =
37
+ this.configService.get<ConfigurationType['feed']>('feed')!;
38
+ const { code } = this.configService.get<ConfigurationType['auth']>('auth')!;
39
+
40
+ return {
41
+ weweRssServerOriginUrl,
42
+ enabledAuthCode: !!code,
43
+ iconUrl: weweRssServerOriginUrl
44
+ ? `${weweRssServerOriginUrl}/favicon.ico`
45
+ : 'https://r2-assets.111965.xyz/wewe-rss.png',
46
+ };
47
+ }
48
+ }
apps/server/src/app.module.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Module } from '@nestjs/common';
2
+ import { AppController } from './app.controller';
3
+ import { AppService } from './app.service';
4
+ import { TrpcModule } from '@server/trpc/trpc.module';
5
+ import { ConfigModule, ConfigService } from '@nestjs/config';
6
+ import configuration, { ConfigurationType } from './configuration';
7
+ import { ThrottlerModule } from '@nestjs/throttler';
8
+ import { ScheduleModule } from '@nestjs/schedule';
9
+ import { FeedsModule } from './feeds/feeds.module';
10
+
11
+ @Module({
12
+ imports: [
13
+ TrpcModule,
14
+ FeedsModule,
15
+ ScheduleModule.forRoot(),
16
+ ConfigModule.forRoot({
17
+ isGlobal: true,
18
+ envFilePath: ['.env.local', '.env'],
19
+ load: [configuration],
20
+ }),
21
+ ThrottlerModule.forRootAsync({
22
+ imports: [ConfigModule],
23
+ inject: [ConfigService],
24
+ useFactory(config: ConfigService) {
25
+ const throttler =
26
+ config.get<ConfigurationType['throttler']>('throttler');
27
+ return [
28
+ {
29
+ ttl: 60,
30
+ limit: throttler?.maxRequestPerMinute || 60,
31
+ },
32
+ ];
33
+ },
34
+ }),
35
+ ],
36
+ controllers: [AppController],
37
+ providers: [AppService],
38
+ })
39
+ export class AppModule {}
apps/server/src/app.service.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Injectable } from '@nestjs/common';
2
+ import { ConfigService } from '@nestjs/config';
3
+
4
+ @Injectable()
5
+ export class AppService {
6
+ constructor(private readonly configService: ConfigService) {}
7
+ getHello(): string {
8
+ return `
9
+ <div style="display:flex;justify-content: center;height: 100%;align-items: center;font-size: 30px;">
10
+ <div>>> <a href="/dash">WeWe RSS</a> <<</div>
11
+ </div>
12
+ `;
13
+ }
14
+ }
apps/server/src/configuration.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const configuration = () => {
2
+ const isProd = process.env.NODE_ENV === 'production';
3
+ const port = process.env.PORT || 4000;
4
+ const host = process.env.HOST || '0.0.0.0';
5
+
6
+ const maxRequestPerMinute = parseInt(
7
+ `${process.env.MAX_REQUEST_PER_MINUTE}|| 60`,
8
+ );
9
+
10
+ const authCode = process.env.AUTH_CODE;
11
+ const platformUrl = process.env.PLATFORM_URL || 'https://weread.111965.xyz';
12
+ const originUrl = process.env.SERVER_ORIGIN_URL || '';
13
+
14
+ const feedMode = process.env.FEED_MODE as 'fulltext' | '';
15
+
16
+ const databaseType = process.env.DATABASE_TYPE || 'mysql';
17
+
18
+ const updateDelayTime = parseInt(`${process.env.UPDATE_DELAY_TIME} || 60`);
19
+
20
+ const enableCleanHtml = process.env.ENABLE_CLEAN_HTML === 'true';
21
+ return {
22
+ server: { isProd, port, host },
23
+ throttler: { maxRequestPerMinute },
24
+ auth: { code: authCode },
25
+ platform: { url: platformUrl },
26
+ feed: {
27
+ originUrl,
28
+ mode: feedMode,
29
+ updateDelayTime,
30
+ enableCleanHtml,
31
+ },
32
+ database: {
33
+ type: databaseType,
34
+ },
35
+ };
36
+ };
37
+
38
+ export default configuration;
39
+
40
+ export type ConfigurationType = ReturnType<typeof configuration>;
apps/server/src/constants.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const statusMap = {
2
+ // 0:失效 1:启用 2:禁用
3
+ INVALID: 0,
4
+ ENABLE: 1,
5
+ DISABLE: 2,
6
+ };
7
+
8
+ export const feedTypes = ['rss', 'atom', 'json'] as const;
9
+
10
+ export const feedMimeTypeMap = {
11
+ rss: 'application/rss+xml; charset=utf-8',
12
+ atom: 'application/atom+xml; charset=utf-8',
13
+ json: 'application/feed+json; charset=utf-8',
14
+ } as const;
15
+
16
+ export const defaultCount = 20;
apps/server/src/feeds/feeds.controller.spec.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Test, TestingModule } from '@nestjs/testing';
2
+ import { FeedsController } from './feeds.controller';
3
+
4
+ describe('FeedsController', () => {
5
+ let controller: FeedsController;
6
+
7
+ beforeEach(async () => {
8
+ const module: TestingModule = await Test.createTestingModule({
9
+ controllers: [FeedsController],
10
+ }).compile();
11
+
12
+ controller = module.get<FeedsController>(FeedsController);
13
+ });
14
+
15
+ it('should be defined', () => {
16
+ expect(controller).toBeDefined();
17
+ });
18
+ });
apps/server/src/feeds/feeds.controller.ts ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Controller,
3
+ DefaultValuePipe,
4
+ Get,
5
+ Logger,
6
+ Param,
7
+ ParseIntPipe,
8
+ Query,
9
+ Request,
10
+ Response,
11
+ } from '@nestjs/common';
12
+ import { FeedsService } from './feeds.service';
13
+ import { Response as Res, Request as Req } from 'express';
14
+
15
+ @Controller('feeds')
16
+ export class FeedsController {
17
+ private readonly logger = new Logger(this.constructor.name);
18
+
19
+ constructor(private readonly feedsService: FeedsService) {}
20
+
21
+ @Get('/')
22
+ async getFeedList() {
23
+ return this.feedsService.getFeedList();
24
+ }
25
+
26
+ @Get('/all.(json|rss|atom)')
27
+ async getFeeds(
28
+ @Request() req: Req,
29
+ @Response() res: Res,
30
+ @Query('limit', new DefaultValuePipe(30), ParseIntPipe) limit: number = 30,
31
+ @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number = 1,
32
+ @Query('mode') mode: string,
33
+ @Query('title_include') title_include: string,
34
+ @Query('title_exclude') title_exclude: string,
35
+ ) {
36
+ const path = req.path;
37
+ const type = path.split('.').pop() || '';
38
+
39
+ const { content, mimeType } = await this.feedsService.handleGenerateFeed({
40
+ type,
41
+ limit,
42
+ page,
43
+ mode,
44
+ title_include,
45
+ title_exclude,
46
+ });
47
+
48
+ res.setHeader('Content-Type', mimeType);
49
+ res.send(content);
50
+ }
51
+
52
+ @Get('/:feed')
53
+ async getFeed(
54
+ @Response() res: Res,
55
+ @Param('feed') feed: string,
56
+ @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number = 10,
57
+ @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number = 1,
58
+ @Query('mode') mode: string,
59
+ @Query('title_include') title_include: string,
60
+ @Query('title_exclude') title_exclude: string,
61
+ @Query('update') update: boolean = false,
62
+ ) {
63
+ const [id, type] = feed.split('.');
64
+ this.logger.log('getFeed: ', id);
65
+
66
+ if (update) {
67
+ this.feedsService.updateFeed(id);
68
+ }
69
+
70
+ const { content, mimeType } = await this.feedsService.handleGenerateFeed({
71
+ id,
72
+ type,
73
+ limit,
74
+ page,
75
+ mode,
76
+ title_include,
77
+ title_exclude,
78
+ });
79
+
80
+ res.setHeader('Content-Type', mimeType);
81
+ res.send(content);
82
+ }
83
+ }
apps/server/src/feeds/feeds.module.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Module } from '@nestjs/common';
2
+ import { FeedsController } from './feeds.controller';
3
+ import { FeedsService } from './feeds.service';
4
+ import { PrismaModule } from '@server/prisma/prisma.module';
5
+ import { TrpcModule } from '@server/trpc/trpc.module';
6
+
7
+ @Module({
8
+ imports: [PrismaModule, TrpcModule],
9
+ controllers: [FeedsController],
10
+ providers: [FeedsService],
11
+ })
12
+ export class FeedsModule {}
apps/server/src/feeds/feeds.service.spec.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Test, TestingModule } from '@nestjs/testing';
2
+ import { FeedsService } from './feeds.service';
3
+
4
+ describe('FeedsService', () => {
5
+ let service: FeedsService;
6
+
7
+ beforeEach(async () => {
8
+ const module: TestingModule = await Test.createTestingModule({
9
+ providers: [FeedsService],
10
+ }).compile();
11
+
12
+ service = module.get<FeedsService>(FeedsService);
13
+ });
14
+
15
+ it('should be defined', () => {
16
+ expect(service).toBeDefined();
17
+ });
18
+ });
apps/server/src/feeds/feeds.service.ts ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
2
+ import { PrismaService } from '@server/prisma/prisma.service';
3
+ import { Cron } from '@nestjs/schedule';
4
+ import { TrpcService } from '@server/trpc/trpc.service';
5
+ import { feedMimeTypeMap, feedTypes } from '@server/constants';
6
+ import { ConfigService } from '@nestjs/config';
7
+ import { Article, Feed as FeedInfo } from '@prisma/client';
8
+ import { ConfigurationType } from '@server/configuration';
9
+ import { Feed, Item } from 'feed';
10
+ import got, { Got } from 'got';
11
+ import { load } from 'cheerio';
12
+ import { minify } from 'html-minifier';
13
+ import { LRUCache } from 'lru-cache';
14
+ import pMap from '@cjs-exporter/p-map';
15
+
16
+ console.log('CRON_EXPRESSION: ', process.env.CRON_EXPRESSION);
17
+
18
+ const mpCache = new LRUCache<string, string>({
19
+ max: 5000,
20
+ });
21
+
22
+ @Injectable()
23
+ export class FeedsService {
24
+ private readonly logger = new Logger(this.constructor.name);
25
+
26
+ private request: Got;
27
+ constructor(
28
+ private readonly prismaService: PrismaService,
29
+ private readonly trpcService: TrpcService,
30
+ private readonly configService: ConfigService,
31
+ ) {
32
+ this.request = got.extend({
33
+ retry: {
34
+ limit: 3,
35
+ methods: ['GET'],
36
+ },
37
+ timeout: 8 * 1e3,
38
+ headers: {
39
+ accept:
40
+ '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',
41
+ 'accept-encoding': 'gzip, deflate, br',
42
+ 'accept-language': 'en-US,en;q=0.9',
43
+ 'cache-control': 'max-age=0',
44
+ 'sec-ch-ua':
45
+ '" Not A;Brand";v="99", "Chromium";v="101", "Google Chrome";v="101"',
46
+ 'sec-ch-ua-mobile': '?0',
47
+ 'sec-ch-ua-platform': '"macOS"',
48
+ 'sec-fetch-dest': 'document',
49
+ 'sec-fetch-mode': 'navigate',
50
+ 'sec-fetch-site': 'none',
51
+ 'sec-fetch-user': '?1',
52
+ 'upgrade-insecure-requests': '1',
53
+ 'user-agent':
54
+ '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',
55
+ },
56
+ hooks: {
57
+ beforeRetry: [
58
+ async (options, error, retryCount) => {
59
+ this.logger.warn(`retrying ${options.url}...`);
60
+ return new Promise((resolve) =>
61
+ setTimeout(resolve, 2e3 * (retryCount || 1)),
62
+ );
63
+ },
64
+ ],
65
+ },
66
+ });
67
+ }
68
+
69
+ @Cron(process.env.CRON_EXPRESSION || '35 5,17 * * *', {
70
+ name: 'updateFeeds',
71
+ timeZone: 'Asia/Shanghai',
72
+ })
73
+ async handleUpdateFeedsCron() {
74
+ this.logger.debug('Called handleUpdateFeedsCron');
75
+
76
+ const feeds = await this.prismaService.feed.findMany({
77
+ where: { status: 1 },
78
+ });
79
+ this.logger.debug('feeds length:' + feeds.length);
80
+
81
+ const updateDelayTime =
82
+ this.configService.get<ConfigurationType['feed']>(
83
+ 'feed',
84
+ )!.updateDelayTime;
85
+
86
+ for (const feed of feeds) {
87
+ this.logger.debug('feed', feed.id);
88
+ try {
89
+ await this.trpcService.refreshMpArticlesAndUpdateFeed(feed.id);
90
+
91
+ await new Promise((resolve) =>
92
+ setTimeout(resolve, updateDelayTime * 1e3),
93
+ );
94
+ } catch (err) {
95
+ this.logger.error('handleUpdateFeedsCron error', err);
96
+ } finally {
97
+ // wait 30s for next feed
98
+ await new Promise((resolve) => setTimeout(resolve, 30 * 1e3));
99
+ }
100
+ }
101
+ }
102
+
103
+ async cleanHtml(source: string) {
104
+ const $ = load(source, { decodeEntities: false });
105
+
106
+ const dirtyHtml = $.html($('.rich_media_content'));
107
+
108
+ const html = dirtyHtml
109
+ .replace(/data-src=/g, 'src=')
110
+ .replace(/opacity: 0( !important)?;/g, '')
111
+ .replace(/visibility: hidden;/g, '');
112
+
113
+ const content =
114
+ '<style> .rich_media_content {overflow: hidden;color: #222;font-size: 17px;word-wrap: break-word;-webkit-hyphens: auto;-ms-hyphens: auto;hyphens: auto;text-align: justify;position: relative;z-index: 0;}.rich_media_content {font-size: 18px;}</style>' +
115
+ html;
116
+
117
+ const result = minify(content, {
118
+ removeAttributeQuotes: true,
119
+ collapseWhitespace: true,
120
+ });
121
+
122
+ return result;
123
+ }
124
+
125
+ async getHtmlByUrl(url: string) {
126
+ const html = await this.request(url, { responseType: 'text' }).text();
127
+ if (
128
+ this.configService.get<ConfigurationType['feed']>('feed')!.enableCleanHtml
129
+ ) {
130
+ const result = await this.cleanHtml(html);
131
+ return result;
132
+ }
133
+
134
+ return html;
135
+ }
136
+
137
+ async tryGetContent(id: string) {
138
+ let content = mpCache.get(id);
139
+ if (content) {
140
+ return content;
141
+ }
142
+ const url = `https://mp.weixin.qq.com/s/${id}`;
143
+ content = await this.getHtmlByUrl(url).catch((e) => {
144
+ this.logger.error(`getHtmlByUrl(${url}) error: ${e.message}`);
145
+
146
+ return '获取全文失败,请重试~';
147
+ });
148
+ mpCache.set(id, content);
149
+ return content;
150
+ }
151
+
152
+ async renderFeed({
153
+ type,
154
+ feedInfo,
155
+ articles,
156
+ mode,
157
+ }: {
158
+ type: string;
159
+ feedInfo: FeedInfo;
160
+ articles: Article[];
161
+ mode?: string;
162
+ }) {
163
+ const { originUrl, mode: globalMode } =
164
+ this.configService.get<ConfigurationType['feed']>('feed')!;
165
+
166
+ const link = `${originUrl}/feeds/${feedInfo.id}.${type}`;
167
+
168
+ const feed = new Feed({
169
+ title: feedInfo.mpName,
170
+ description: feedInfo.mpIntro,
171
+ id: link,
172
+ link: link,
173
+ language: 'zh-cn', // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes
174
+ image: feedInfo.mpCover,
175
+ favicon: feedInfo.mpCover,
176
+ copyright: '',
177
+ updated: new Date(feedInfo.updateTime * 1e3),
178
+ generator: 'WeWe-RSS',
179
+ author: { name: feedInfo.mpName },
180
+ });
181
+
182
+ feed.addExtension({
183
+ name: 'generator',
184
+ objects: `WeWe-RSS`,
185
+ });
186
+
187
+ const feeds = await this.prismaService.feed.findMany({
188
+ select: { id: true, mpName: true },
189
+ });
190
+
191
+ /**mode 高于 globalMode。如果 mode 值存在,取 mode 值*/
192
+ const enableFullText =
193
+ typeof mode === 'string'
194
+ ? mode === 'fulltext'
195
+ : globalMode === 'fulltext';
196
+
197
+ const showAuthor = feedInfo.id === 'all';
198
+
199
+ const mapper = async (item) => {
200
+ const { title, id, publishTime, picUrl, mpId } = item;
201
+ const link = `https://mp.weixin.qq.com/s/${id}`;
202
+
203
+ const mpName = feeds.find((item) => item.id === mpId)?.mpName || '-';
204
+ const published = new Date(publishTime * 1e3);
205
+
206
+ let content = '';
207
+ if (enableFullText) {
208
+ content = await this.tryGetContent(id);
209
+ }
210
+
211
+ feed.addItem({
212
+ id,
213
+ title,
214
+ link: link,
215
+ guid: link,
216
+ content,
217
+ date: published,
218
+ image: picUrl,
219
+ author: showAuthor ? [{ name: mpName }] : undefined,
220
+ });
221
+ };
222
+
223
+ await pMap(articles, mapper, { concurrency: 2, stopOnError: false });
224
+
225
+ return feed;
226
+ }
227
+
228
+ async handleGenerateFeed({
229
+ id,
230
+ type,
231
+ limit,
232
+ page,
233
+ mode,
234
+ title_include,
235
+ title_exclude,
236
+ }: {
237
+ id?: string;
238
+ type: string;
239
+ limit: number;
240
+ page: number;
241
+ mode?: string;
242
+ title_include?: string;
243
+ title_exclude?: string;
244
+ }) {
245
+ if (!feedTypes.includes(type as any)) {
246
+ type = 'atom';
247
+ }
248
+
249
+ let articles: Article[];
250
+ let feedInfo: FeedInfo;
251
+ if (id) {
252
+ feedInfo = (await this.prismaService.feed.findFirst({
253
+ where: { id },
254
+ }))!;
255
+
256
+ if (!feedInfo) {
257
+ throw new HttpException('不存在该feed!', HttpStatus.BAD_REQUEST);
258
+ }
259
+
260
+ articles = await this.prismaService.article.findMany({
261
+ where: { mpId: id },
262
+ orderBy: { publishTime: 'desc' },
263
+ take: limit,
264
+ skip: (page - 1) * limit,
265
+ });
266
+ } else {
267
+ articles = await this.prismaService.article.findMany({
268
+ orderBy: { publishTime: 'desc' },
269
+ take: limit,
270
+ skip: (page - 1) * limit,
271
+ });
272
+
273
+ const { originUrl } =
274
+ this.configService.get<ConfigurationType['feed']>('feed')!;
275
+ feedInfo = {
276
+ id: 'all',
277
+ mpName: 'WeWe-RSS All',
278
+ mpIntro: 'WeWe-RSS 全部文章',
279
+ mpCover: originUrl
280
+ ? `${originUrl}/favicon.ico`
281
+ : 'https://r2-assets.111965.xyz/wewe-rss.png',
282
+ status: 1,
283
+ syncTime: 0,
284
+ updateTime: Math.floor(Date.now() / 1e3),
285
+ hasHistory: -1,
286
+ createdAt: new Date(),
287
+ updatedAt: new Date(),
288
+ };
289
+ }
290
+
291
+ this.logger.log('handleGenerateFeed articles: ' + articles.length);
292
+ const feed = await this.renderFeed({ feedInfo, articles, type, mode });
293
+
294
+ if (title_include) {
295
+ const includes = title_include.split('|');
296
+ feed.items = feed.items.filter((i: Item) =>
297
+ includes.some((k) => i.title.includes(k)),
298
+ );
299
+ }
300
+ if (title_exclude) {
301
+ const excludes = title_exclude.split('|');
302
+ feed.items = feed.items.filter(
303
+ (i: Item) => !excludes.some((k) => i.title.includes(k)),
304
+ );
305
+ }
306
+
307
+ switch (type) {
308
+ case 'rss':
309
+ return { content: feed.rss2(), mimeType: feedMimeTypeMap[type] };
310
+ case 'json':
311
+ return { content: feed.json1(), mimeType: feedMimeTypeMap[type] };
312
+ case 'atom':
313
+ default:
314
+ return { content: feed.atom1(), mimeType: feedMimeTypeMap[type] };
315
+ }
316
+ }
317
+
318
+ async getFeedList() {
319
+ const data = await this.prismaService.feed.findMany();
320
+
321
+ return data.map((item) => {
322
+ return {
323
+ id: item.id,
324
+ name: item.mpName,
325
+ intro: item.mpIntro,
326
+ cover: item.mpCover,
327
+ syncTime: item.syncTime,
328
+ updateTime: item.updateTime,
329
+ };
330
+ });
331
+ }
332
+
333
+ async updateFeed(id: string) {
334
+ try {
335
+ await this.trpcService.refreshMpArticlesAndUpdateFeed(id);
336
+ } catch (err) {
337
+ this.logger.error('updateFeed error', err);
338
+ } finally {
339
+ // wait 30s for next feed
340
+ await new Promise((resolve) => setTimeout(resolve, 30 * 1e3));
341
+ }
342
+ }
343
+ }
apps/server/src/main.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NestFactory } from '@nestjs/core';
2
+ import { AppModule } from './app.module';
3
+ import { TrpcRouter } from '@server/trpc/trpc.router';
4
+ import { ConfigService } from '@nestjs/config';
5
+ import { json, urlencoded } from 'express';
6
+ import { NestExpressApplication } from '@nestjs/platform-express';
7
+ import { ConfigurationType } from './configuration';
8
+ import { join, resolve } from 'path';
9
+ import { readFileSync } from 'fs';
10
+
11
+ const packageJson = JSON.parse(
12
+ readFileSync(resolve(__dirname, '..', './package.json'), 'utf-8'),
13
+ );
14
+
15
+ const appVersion = packageJson.version;
16
+ console.log('appVersion: v' + appVersion);
17
+
18
+ async function bootstrap() {
19
+ const app = await NestFactory.create<NestExpressApplication>(AppModule);
20
+ const configService = app.get(ConfigService);
21
+
22
+ const { host, isProd, port } =
23
+ configService.get<ConfigurationType['server']>('server')!;
24
+
25
+ app.use(json({ limit: '10mb' }));
26
+ app.use(urlencoded({ extended: true, limit: '10mb' }));
27
+
28
+ app.useStaticAssets(join(__dirname, '..', 'client', 'assets'), {
29
+ prefix: '/dash/assets/',
30
+ });
31
+ app.setBaseViewsDir(join(__dirname, '..', 'client'));
32
+ app.setViewEngine('hbs');
33
+
34
+ if (isProd) {
35
+ app.enable('trust proxy');
36
+ }
37
+
38
+ app.enableCors({
39
+ exposedHeaders: ['authorization'],
40
+ });
41
+
42
+ const trpc = app.get(TrpcRouter);
43
+ trpc.applyMiddleware(app);
44
+
45
+ await app.listen(port, host);
46
+
47
+ console.log(`Server is running at http://${host}:${port}`);
48
+ }
49
+ bootstrap();
apps/server/src/prisma/prisma.module.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import { Module } from '@nestjs/common';
2
+ import { PrismaService } from './prisma.service';
3
+
4
+ @Module({
5
+ providers: [PrismaService],
6
+ exports: [PrismaService],
7
+ })
8
+ export class PrismaModule {}
apps/server/src/prisma/prisma.service.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import { Injectable, OnModuleInit } from '@nestjs/common';
2
+ import { PrismaClient } from '@prisma/client';
3
+
4
+ @Injectable()
5
+ export class PrismaService extends PrismaClient implements OnModuleInit {
6
+ async onModuleInit() {
7
+ await this.$connect();
8
+ }
9
+ }
apps/server/src/trpc/trpc.module.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Module } from '@nestjs/common';
2
+ import { TrpcService } from '@server/trpc/trpc.service';
3
+ import { TrpcRouter } from '@server/trpc/trpc.router';
4
+ import { PrismaModule } from '@server/prisma/prisma.module';
5
+
6
+ @Module({
7
+ imports: [PrismaModule],
8
+ controllers: [],
9
+ providers: [TrpcService, TrpcRouter],
10
+ exports: [TrpcService, TrpcRouter],
11
+ })
12
+ export class TrpcModule {}
apps/server/src/trpc/trpc.router.ts ADDED
@@ -0,0 +1,453 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { INestApplication, Injectable, Logger } from '@nestjs/common';
2
+ import { z } from 'zod';
3
+ import { TrpcService } from '@server/trpc/trpc.service';
4
+ import * as trpcExpress from '@trpc/server/adapters/express';
5
+ import { TRPCError } from '@trpc/server';
6
+ import { PrismaService } from '@server/prisma/prisma.service';
7
+ import { statusMap } from '@server/constants';
8
+ import { ConfigService } from '@nestjs/config';
9
+ import { ConfigurationType } from '@server/configuration';
10
+
11
+ @Injectable()
12
+ export class TrpcRouter {
13
+ constructor(
14
+ private readonly trpcService: TrpcService,
15
+ private readonly prismaService: PrismaService,
16
+ private readonly configService: ConfigService,
17
+ ) {}
18
+
19
+ private readonly logger = new Logger(this.constructor.name);
20
+
21
+ accountRouter = this.trpcService.router({
22
+ list: this.trpcService.protectedProcedure
23
+ .input(
24
+ z.object({
25
+ limit: z.number().min(1).max(1000).nullish(),
26
+ cursor: z.string().nullish(),
27
+ }),
28
+ )
29
+ .query(async ({ input }) => {
30
+ const limit = input.limit ?? 1000;
31
+ const { cursor } = input;
32
+
33
+ const items = await this.prismaService.account.findMany({
34
+ take: limit + 1,
35
+ where: {},
36
+ select: {
37
+ id: true,
38
+ name: true,
39
+ status: true,
40
+ createdAt: true,
41
+ updatedAt: true,
42
+ token: false,
43
+ },
44
+ cursor: cursor
45
+ ? {
46
+ id: cursor,
47
+ }
48
+ : undefined,
49
+ orderBy: {
50
+ createdAt: 'asc',
51
+ },
52
+ });
53
+ let nextCursor: typeof cursor | undefined = undefined;
54
+ if (items.length > limit) {
55
+ // Remove the last item and use it as next cursor
56
+
57
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
58
+ const nextItem = items.pop()!;
59
+ nextCursor = nextItem.id;
60
+ }
61
+
62
+ const disabledAccounts = this.trpcService.getBlockedAccountIds();
63
+ return {
64
+ blocks: disabledAccounts,
65
+ items,
66
+ nextCursor,
67
+ };
68
+ }),
69
+ byId: this.trpcService.protectedProcedure
70
+ .input(z.string())
71
+ .query(async ({ input: id }) => {
72
+ const account = await this.prismaService.account.findUnique({
73
+ where: { id },
74
+ });
75
+ if (!account) {
76
+ throw new TRPCError({
77
+ code: 'BAD_REQUEST',
78
+ message: `No account with id '${id}'`,
79
+ });
80
+ }
81
+ return account;
82
+ }),
83
+ add: this.trpcService.protectedProcedure
84
+ .input(
85
+ z.object({
86
+ id: z.string().min(1).max(32),
87
+ token: z.string().min(1),
88
+ name: z.string().min(1),
89
+ status: z.number().default(statusMap.ENABLE),
90
+ }),
91
+ )
92
+ .mutation(async ({ input }) => {
93
+ const { id, ...data } = input;
94
+ const account = await this.prismaService.account.upsert({
95
+ where: {
96
+ id,
97
+ },
98
+ update: data,
99
+ create: input,
100
+ });
101
+ this.trpcService.removeBlockedAccount(id);
102
+
103
+ return account;
104
+ }),
105
+ edit: this.trpcService.protectedProcedure
106
+ .input(
107
+ z.object({
108
+ id: z.string(),
109
+ data: z.object({
110
+ token: z.string().min(1).optional(),
111
+ name: z.string().min(1).optional(),
112
+ status: z.number().optional(),
113
+ }),
114
+ }),
115
+ )
116
+ .mutation(async ({ input }) => {
117
+ const { id, data } = input;
118
+ const account = await this.prismaService.account.update({
119
+ where: { id },
120
+ data,
121
+ });
122
+ this.trpcService.removeBlockedAccount(id);
123
+ return account;
124
+ }),
125
+ delete: this.trpcService.protectedProcedure
126
+ .input(z.string())
127
+ .mutation(async ({ input: id }) => {
128
+ await this.prismaService.account.delete({ where: { id } });
129
+ this.trpcService.removeBlockedAccount(id);
130
+
131
+ return id;
132
+ }),
133
+ });
134
+
135
+ feedRouter = this.trpcService.router({
136
+ list: this.trpcService.protectedProcedure
137
+ .input(
138
+ z.object({
139
+ limit: z.number().min(1).max(1000).nullish(),
140
+ cursor: z.string().nullish(),
141
+ }),
142
+ )
143
+ .query(async ({ input }) => {
144
+ const limit = input.limit ?? 1000;
145
+ const { cursor } = input;
146
+
147
+ const items = await this.prismaService.feed.findMany({
148
+ take: limit + 1,
149
+ where: {},
150
+ cursor: cursor
151
+ ? {
152
+ id: cursor,
153
+ }
154
+ : undefined,
155
+ orderBy: {
156
+ createdAt: 'asc',
157
+ },
158
+ });
159
+ let nextCursor: typeof cursor | undefined = undefined;
160
+ if (items.length > limit) {
161
+ // Remove the last item and use it as next cursor
162
+
163
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
164
+ const nextItem = items.pop()!;
165
+ nextCursor = nextItem.id;
166
+ }
167
+
168
+ return {
169
+ items: items,
170
+ nextCursor,
171
+ };
172
+ }),
173
+ byId: this.trpcService.protectedProcedure
174
+ .input(z.string())
175
+ .query(async ({ input: id }) => {
176
+ const feed = await this.prismaService.feed.findUnique({
177
+ where: { id },
178
+ });
179
+ if (!feed) {
180
+ throw new TRPCError({
181
+ code: 'BAD_REQUEST',
182
+ message: `No feed with id '${id}'`,
183
+ });
184
+ }
185
+ return feed;
186
+ }),
187
+ add: this.trpcService.protectedProcedure
188
+ .input(
189
+ z.object({
190
+ id: z.string(),
191
+ mpName: z.string(),
192
+ mpCover: z.string(),
193
+ mpIntro: z.string(),
194
+ syncTime: z
195
+ .number()
196
+ .optional()
197
+ .default(Math.floor(Date.now() / 1e3)),
198
+ updateTime: z.number(),
199
+ status: z.number().default(statusMap.ENABLE),
200
+ }),
201
+ )
202
+ .mutation(async ({ input }) => {
203
+ const { id, ...data } = input;
204
+ const feed = await this.prismaService.feed.upsert({
205
+ where: {
206
+ id,
207
+ },
208
+ update: data,
209
+ create: input,
210
+ });
211
+
212
+ return feed;
213
+ }),
214
+ edit: this.trpcService.protectedProcedure
215
+ .input(
216
+ z.object({
217
+ id: z.string(),
218
+ data: z.object({
219
+ mpName: z.string().optional(),
220
+ mpCover: z.string().optional(),
221
+ mpIntro: z.string().optional(),
222
+ syncTime: z.number().optional(),
223
+ updateTime: z.number().optional(),
224
+ status: z.number().optional(),
225
+ }),
226
+ }),
227
+ )
228
+ .mutation(async ({ input }) => {
229
+ const { id, data } = input;
230
+ const feed = await this.prismaService.feed.update({
231
+ where: { id },
232
+ data,
233
+ });
234
+ return feed;
235
+ }),
236
+ delete: this.trpcService.protectedProcedure
237
+ .input(z.string())
238
+ .mutation(async ({ input: id }) => {
239
+ await this.prismaService.feed.delete({ where: { id } });
240
+ return id;
241
+ }),
242
+
243
+ refreshArticles: this.trpcService.protectedProcedure
244
+ .input(
245
+ z.object({
246
+ mpId: z.string().optional(),
247
+ }),
248
+ )
249
+ .mutation(async ({ input: { mpId } }) => {
250
+ if (mpId) {
251
+ await this.trpcService.refreshMpArticlesAndUpdateFeed(mpId);
252
+ } else {
253
+ await this.trpcService.refreshAllMpArticlesAndUpdateFeed();
254
+ }
255
+ }),
256
+
257
+ isRefreshAllMpArticlesRunning: this.trpcService.protectedProcedure.query(
258
+ async () => {
259
+ return this.trpcService.isRefreshAllMpArticlesRunning;
260
+ },
261
+ ),
262
+ getHistoryArticles: this.trpcService.protectedProcedure
263
+ .input(
264
+ z.object({
265
+ mpId: z.string().optional(),
266
+ }),
267
+ )
268
+ .mutation(async ({ input: { mpId = '' } }) => {
269
+ this.trpcService.getHistoryMpArticles(mpId);
270
+ }),
271
+ getInProgressHistoryMp: this.trpcService.protectedProcedure.query(
272
+ async () => {
273
+ return this.trpcService.inProgressHistoryMp;
274
+ },
275
+ ),
276
+ });
277
+
278
+ articleRouter = this.trpcService.router({
279
+ list: this.trpcService.protectedProcedure
280
+ .input(
281
+ z.object({
282
+ limit: z.number().min(1).max(1000).nullish(),
283
+ cursor: z.string().nullish(),
284
+ mpId: z.string().nullish(),
285
+ }),
286
+ )
287
+ .query(async ({ input }) => {
288
+ const limit = input.limit ?? 1000;
289
+ const { cursor, mpId } = input;
290
+
291
+ const items = await this.prismaService.article.findMany({
292
+ orderBy: [
293
+ {
294
+ publishTime: 'desc',
295
+ },
296
+ ],
297
+ take: limit + 1,
298
+ where: mpId ? { mpId } : undefined,
299
+ cursor: cursor
300
+ ? {
301
+ id: cursor,
302
+ }
303
+ : undefined,
304
+ });
305
+ let nextCursor: typeof cursor | undefined = undefined;
306
+ if (items.length > limit) {
307
+ // Remove the last item and use it as next cursor
308
+
309
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
310
+ const nextItem = items.pop()!;
311
+ nextCursor = nextItem.id;
312
+ }
313
+
314
+ return {
315
+ items,
316
+ nextCursor,
317
+ };
318
+ }),
319
+ byId: this.trpcService.protectedProcedure
320
+ .input(z.string())
321
+ .query(async ({ input: id }) => {
322
+ const article = await this.prismaService.article.findUnique({
323
+ where: { id },
324
+ });
325
+ if (!article) {
326
+ throw new TRPCError({
327
+ code: 'BAD_REQUEST',
328
+ message: `No article with id '${id}'`,
329
+ });
330
+ }
331
+ return article;
332
+ }),
333
+
334
+ add: this.trpcService.protectedProcedure
335
+ .input(
336
+ z.object({
337
+ id: z.string(),
338
+ mpId: z.string(),
339
+ title: z.string(),
340
+ picUrl: z.string().optional().default(''),
341
+ publishTime: z.number(),
342
+ }),
343
+ )
344
+ .mutation(async ({ input }) => {
345
+ const { id, ...data } = input;
346
+ const article = await this.prismaService.article.upsert({
347
+ where: {
348
+ id,
349
+ },
350
+ update: data,
351
+ create: input,
352
+ });
353
+
354
+ return article;
355
+ }),
356
+ delete: this.trpcService.protectedProcedure
357
+ .input(z.string())
358
+ .mutation(async ({ input: id }) => {
359
+ await this.prismaService.article.delete({ where: { id } });
360
+ return id;
361
+ }),
362
+ });
363
+
364
+ platformRouter = this.trpcService.router({
365
+ getMpArticles: this.trpcService.protectedProcedure
366
+ .input(
367
+ z.object({
368
+ mpId: z.string(),
369
+ }),
370
+ )
371
+ .mutation(async ({ input: { mpId } }) => {
372
+ try {
373
+ const results = await this.trpcService.getMpArticles(mpId);
374
+ return results;
375
+ } catch (err: any) {
376
+ this.logger.log('getMpArticles err: ', err);
377
+ throw new TRPCError({
378
+ code: 'INTERNAL_SERVER_ERROR',
379
+ message: err.response?.data?.message || err.message,
380
+ cause: err.stack,
381
+ });
382
+ }
383
+ }),
384
+ getMpInfo: this.trpcService.protectedProcedure
385
+ .input(
386
+ z.object({
387
+ wxsLink: z
388
+ .string()
389
+ .refine((v) => v.startsWith('https://mp.weixin.qq.com/s/')),
390
+ }),
391
+ )
392
+ .mutation(async ({ input: { wxsLink: url } }) => {
393
+ try {
394
+ const results = await this.trpcService.getMpInfo(url);
395
+ return results;
396
+ } catch (err: any) {
397
+ this.logger.log('getMpInfo err: ', err);
398
+ throw new TRPCError({
399
+ code: 'INTERNAL_SERVER_ERROR',
400
+ message: err.response?.data?.message || err.message,
401
+ cause: err.stack,
402
+ });
403
+ }
404
+ }),
405
+
406
+ createLoginUrl: this.trpcService.protectedProcedure.mutation(async () => {
407
+ return this.trpcService.createLoginUrl();
408
+ }),
409
+ getLoginResult: this.trpcService.protectedProcedure
410
+ .input(
411
+ z.object({
412
+ id: z.string(),
413
+ }),
414
+ )
415
+ .query(async ({ input }) => {
416
+ return this.trpcService.getLoginResult(input.id);
417
+ }),
418
+ });
419
+
420
+ appRouter = this.trpcService.router({
421
+ feed: this.feedRouter,
422
+ account: this.accountRouter,
423
+ article: this.articleRouter,
424
+ platform: this.platformRouter,
425
+ });
426
+
427
+ async applyMiddleware(app: INestApplication) {
428
+ app.use(
429
+ `/trpc`,
430
+ trpcExpress.createExpressMiddleware({
431
+ router: this.appRouter,
432
+ createContext: ({ req }) => {
433
+ const authCode =
434
+ this.configService.get<ConfigurationType['auth']>('auth')!.code;
435
+
436
+ if (authCode && req.headers.authorization !== authCode) {
437
+ return {
438
+ errorMsg: 'authCode不正确!',
439
+ };
440
+ }
441
+ return {
442
+ errorMsg: null,
443
+ };
444
+ },
445
+ middleware: (req, res, next) => {
446
+ next();
447
+ },
448
+ }),
449
+ );
450
+ }
451
+ }
452
+
453
+ export type AppRouter = TrpcRouter[`appRouter`];
apps/server/src/trpc/trpc.service.ts ADDED
@@ -0,0 +1,366 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Injectable, Logger } from '@nestjs/common';
2
+ import { ConfigService } from '@nestjs/config';
3
+ import { ConfigurationType } from '@server/configuration';
4
+ import { defaultCount, statusMap } from '@server/constants';
5
+ import { PrismaService } from '@server/prisma/prisma.service';
6
+ import { TRPCError, initTRPC } from '@trpc/server';
7
+ import Axios, { AxiosInstance } from 'axios';
8
+ import dayjs from 'dayjs';
9
+ import timezone from 'dayjs/plugin/timezone';
10
+ import utc from 'dayjs/plugin/utc';
11
+
12
+ dayjs.extend(utc);
13
+ dayjs.extend(timezone);
14
+
15
+ /**
16
+ * 读书账号每日小黑屋
17
+ */
18
+ const blockedAccountsMap = new Map<string, string[]>();
19
+
20
+ @Injectable()
21
+ export class TrpcService {
22
+ trpc = initTRPC.create();
23
+ publicProcedure = this.trpc.procedure;
24
+ protectedProcedure = this.trpc.procedure.use(({ ctx, next }) => {
25
+ const errorMsg = (ctx as any).errorMsg;
26
+ if (errorMsg) {
27
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: errorMsg });
28
+ }
29
+ return next({ ctx });
30
+ });
31
+ router = this.trpc.router;
32
+ mergeRouters = this.trpc.mergeRouters;
33
+ request: AxiosInstance;
34
+ updateDelayTime = 60;
35
+
36
+ private readonly logger = new Logger(this.constructor.name);
37
+
38
+ constructor(
39
+ private readonly prismaService: PrismaService,
40
+ private readonly configService: ConfigService,
41
+ ) {
42
+ const { url } =
43
+ this.configService.get<ConfigurationType['platform']>('platform')!;
44
+ this.updateDelayTime =
45
+ this.configService.get<ConfigurationType['feed']>(
46
+ 'feed',
47
+ )!.updateDelayTime;
48
+
49
+ this.request = Axios.create({ baseURL: url, timeout: 15 * 1e3 });
50
+
51
+ this.request.interceptors.response.use(
52
+ (response) => {
53
+ return response;
54
+ },
55
+ async (error) => {
56
+ this.logger.log('error: ', error);
57
+ const errMsg = error.response?.data?.message || '';
58
+
59
+ const id = (error.config.headers as any).xid;
60
+ if (errMsg.includes('WeReadError401')) {
61
+ // 账号失效
62
+ await this.prismaService.account.update({
63
+ where: { id },
64
+ data: { status: statusMap.INVALID },
65
+ });
66
+ this.logger.error(`账号(${id})登录失效,已禁用`);
67
+ } else if (errMsg.includes('WeReadError429')) {
68
+ //TODO 处理请求频繁
69
+ this.logger.error(`账号(${id})请求频繁,打入小黑屋`);
70
+ }
71
+
72
+ const today = this.getTodayDate();
73
+
74
+ const blockedAccounts = blockedAccountsMap.get(today);
75
+
76
+ if (Array.isArray(blockedAccounts)) {
77
+ if (id) {
78
+ blockedAccounts.push(id);
79
+ }
80
+ blockedAccountsMap.set(today, blockedAccounts);
81
+ } else if (errMsg.includes('WeReadError400')) {
82
+ this.logger.error(`账号(${id})处理请求参数出错`);
83
+ this.logger.error('WeReadError400: ', errMsg);
84
+ // 10s 后重试
85
+ await new Promise((resolve) => setTimeout(resolve, 10 * 1e3));
86
+ } else {
87
+ this.logger.error("Can't handle this error: ", errMsg);
88
+ }
89
+
90
+ return Promise.reject(error);
91
+ },
92
+ );
93
+ }
94
+
95
+ removeBlockedAccount = (vid: string) => {
96
+ const today = this.getTodayDate();
97
+
98
+ const blockedAccounts = blockedAccountsMap.get(today);
99
+ if (Array.isArray(blockedAccounts)) {
100
+ const newBlockedAccounts = blockedAccounts.filter((id) => id !== vid);
101
+ blockedAccountsMap.set(today, newBlockedAccounts);
102
+ }
103
+ };
104
+
105
+ private getTodayDate() {
106
+ return dayjs.tz(new Date(), 'Asia/Shanghai').format('YYYY-MM-DD');
107
+ }
108
+
109
+ getBlockedAccountIds() {
110
+ const today = this.getTodayDate();
111
+ const disabledAccounts = blockedAccountsMap.get(today) || [];
112
+ this.logger.debug('disabledAccounts: ', disabledAccounts);
113
+ return disabledAccounts.filter(Boolean);
114
+ }
115
+
116
+ private async getAvailableAccount() {
117
+ const disabledAccounts = this.getBlockedAccountIds();
118
+ const account = await this.prismaService.account.findMany({
119
+ where: {
120
+ status: statusMap.ENABLE,
121
+ NOT: {
122
+ id: { in: disabledAccounts },
123
+ },
124
+ },
125
+ take: 10,
126
+ });
127
+
128
+ if (!account || account.length === 0) {
129
+ throw new Error('暂无可用读书账号!');
130
+ }
131
+
132
+ return account[Math.floor(Math.random() * account.length)];
133
+ }
134
+
135
+ async getMpArticles(mpId: string, page = 1, retryCount = 3) {
136
+ const account = await this.getAvailableAccount();
137
+
138
+ try {
139
+ const res = await this.request
140
+ .get<
141
+ {
142
+ id: string;
143
+ title: string;
144
+ picUrl: string;
145
+ publishTime: number;
146
+ }[]
147
+ >(`/api/v2/platform/mps/${mpId}/articles`, {
148
+ headers: {
149
+ xid: account.id,
150
+ Authorization: `Bearer ${account.token}`,
151
+ },
152
+ params: {
153
+ page,
154
+ },
155
+ })
156
+ .then((res) => res.data)
157
+ .then((res) => {
158
+ this.logger.log(
159
+ `getMpArticles(${mpId}) page: ${page} articles: ${res.length}`,
160
+ );
161
+ return res;
162
+ });
163
+ return res;
164
+ } catch (err) {
165
+ this.logger.error(`retry(${4 - retryCount}) getMpArticles error: `, err);
166
+ if (retryCount > 0) {
167
+ return this.getMpArticles(mpId, page, retryCount - 1);
168
+ } else {
169
+ throw err;
170
+ }
171
+ }
172
+ }
173
+
174
+ async refreshMpArticlesAndUpdateFeed(mpId: string, page = 1) {
175
+ const articles = await this.getMpArticles(mpId, page);
176
+
177
+ if (articles.length > 0) {
178
+ let results;
179
+ const { type } =
180
+ this.configService.get<ConfigurationType['database']>('database')!;
181
+ if (type === 'sqlite') {
182
+ // sqlite3 不支持 createMany
183
+ const inserts = articles.map(({ id, picUrl, publishTime, title }) =>
184
+ this.prismaService.article.upsert({
185
+ create: { id, mpId, picUrl, publishTime, title },
186
+ update: {
187
+ publishTime,
188
+ title,
189
+ },
190
+ where: { id },
191
+ }),
192
+ );
193
+ results = await this.prismaService.$transaction(inserts);
194
+ } else {
195
+ results = await (this.prismaService.article as any).createMany({
196
+ data: articles.map(({ id, picUrl, publishTime, title }) => ({
197
+ id,
198
+ mpId,
199
+ picUrl,
200
+ publishTime,
201
+ title,
202
+ })),
203
+ skipDuplicates: true,
204
+ });
205
+ }
206
+
207
+ this.logger.debug(
208
+ `refreshMpArticlesAndUpdateFeed create results: ${JSON.stringify(results)}`,
209
+ );
210
+ }
211
+
212
+ // 如果文章数量小于 defaultCount,则认为没有更多历史文章
213
+ const hasHistory = articles.length < defaultCount ? 0 : 1;
214
+
215
+ await this.prismaService.feed.update({
216
+ where: { id: mpId },
217
+ data: {
218
+ syncTime: Math.floor(Date.now() / 1e3),
219
+ hasHistory,
220
+ },
221
+ });
222
+
223
+ return { hasHistory };
224
+ }
225
+
226
+ inProgressHistoryMp = {
227
+ id: '',
228
+ page: 1,
229
+ };
230
+
231
+ async getHistoryMpArticles(mpId: string) {
232
+ if (this.inProgressHistoryMp.id === mpId) {
233
+ this.logger.log(`getHistoryMpArticles(${mpId}) is running`);
234
+ return;
235
+ }
236
+
237
+ this.inProgressHistoryMp = {
238
+ id: mpId,
239
+ page: 1,
240
+ };
241
+
242
+ if (!this.inProgressHistoryMp.id) {
243
+ return;
244
+ }
245
+
246
+ try {
247
+ const feed = await this.prismaService.feed.findFirstOrThrow({
248
+ where: {
249
+ id: mpId,
250
+ },
251
+ });
252
+
253
+ // 如果完整同步过历史文章,则直接返回
254
+ if (feed.hasHistory === 0) {
255
+ this.logger.log(`getHistoryMpArticles(${mpId}) has no history`);
256
+ return;
257
+ }
258
+
259
+ const total = await this.prismaService.article.count({
260
+ where: {
261
+ mpId,
262
+ },
263
+ });
264
+ this.inProgressHistoryMp.page = Math.ceil(total / defaultCount);
265
+
266
+ // 最多尝试一千次
267
+ let i = 1e3;
268
+ while (i-- > 0) {
269
+ if (this.inProgressHistoryMp.id !== mpId) {
270
+ this.logger.log(
271
+ `getHistoryMpArticles(${mpId}) is not running, break`,
272
+ );
273
+ break;
274
+ }
275
+ const { hasHistory } = await this.refreshMpArticlesAndUpdateFeed(
276
+ mpId,
277
+ this.inProgressHistoryMp.page,
278
+ );
279
+ if (hasHistory < 1) {
280
+ this.logger.log(
281
+ `getHistoryMpArticles(${mpId}) has no history, break`,
282
+ );
283
+ break;
284
+ }
285
+ this.inProgressHistoryMp.page++;
286
+
287
+ await new Promise((resolve) =>
288
+ setTimeout(resolve, this.updateDelayTime * 1e3),
289
+ );
290
+ }
291
+ } finally {
292
+ this.inProgressHistoryMp = {
293
+ id: '',
294
+ page: 1,
295
+ };
296
+ }
297
+ }
298
+
299
+ isRefreshAllMpArticlesRunning = false;
300
+
301
+ async refreshAllMpArticlesAndUpdateFeed() {
302
+ if (this.isRefreshAllMpArticlesRunning) {
303
+ this.logger.log('refreshAllMpArticlesAndUpdateFeed is running');
304
+ return;
305
+ }
306
+ const mps = await this.prismaService.feed.findMany();
307
+ this.isRefreshAllMpArticlesRunning = true;
308
+ try {
309
+ for (const { id } of mps) {
310
+ await this.refreshMpArticlesAndUpdateFeed(id);
311
+
312
+ await new Promise((resolve) =>
313
+ setTimeout(resolve, this.updateDelayTime * 1e3),
314
+ );
315
+ }
316
+ } finally {
317
+ this.isRefreshAllMpArticlesRunning = false;
318
+ }
319
+ }
320
+
321
+ async getMpInfo(url: string) {
322
+ url = url.trim();
323
+ const account = await this.getAvailableAccount();
324
+
325
+ return this.request
326
+ .post<
327
+ {
328
+ id: string;
329
+ cover: string;
330
+ name: string;
331
+ intro: string;
332
+ updateTime: number;
333
+ }[]
334
+ >(
335
+ `/api/v2/platform/wxs2mp`,
336
+ { url },
337
+ {
338
+ headers: {
339
+ xid: account.id,
340
+ Authorization: `Bearer ${account.token}`,
341
+ },
342
+ },
343
+ )
344
+ .then((res) => res.data);
345
+ }
346
+
347
+ async createLoginUrl() {
348
+ return this.request
349
+ .get<{
350
+ uuid: string;
351
+ scanUrl: string;
352
+ }>(`/api/v2/login/platform`)
353
+ .then((res) => res.data);
354
+ }
355
+
356
+ async getLoginResult(id: string) {
357
+ return this.request
358
+ .get<{
359
+ message: string;
360
+ vid?: number;
361
+ token?: string;
362
+ username?: string;
363
+ }>(`/api/v2/login/platform/${id}`, { timeout: 120 * 1e3 })
364
+ .then((res) => res.data);
365
+ }
366
+ }
apps/server/test/app.e2e-spec.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Test, TestingModule } from '@nestjs/testing';
2
+ import { INestApplication } from '@nestjs/common';
3
+ import * as request from 'supertest';
4
+ import { AppModule } from './../src/app.module';
5
+
6
+ describe('AppController (e2e)', () => {
7
+ let app: INestApplication;
8
+
9
+ beforeEach(async () => {
10
+ const moduleFixture: TestingModule = await Test.createTestingModule({
11
+ imports: [AppModule],
12
+ }).compile();
13
+
14
+ app = moduleFixture.createNestApplication();
15
+ await app.init();
16
+ });
17
+
18
+ it('/ (GET)', () => {
19
+ return request(app.getHttpServer())
20
+ .get('/')
21
+ .expect(200)
22
+ .expect('Hello World!');
23
+ });
24
+ });
apps/server/test/jest-e2e.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "moduleFileExtensions": ["js", "json", "ts"],
3
+ "rootDir": ".",
4
+ "testEnvironment": "node",
5
+ "testRegex": ".e2e-spec.ts$",
6
+ "transform": {
7
+ "^.+\\.(t|j)s$": "ts-jest"
8
+ }
9
+ }
apps/server/tsconfig.build.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4
+ }
apps/server/tsconfig.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "module": "commonjs",
5
+ "declaration": true,
6
+ "removeComments": true,
7
+ "allowSyntheticDefaultImports": true,
8
+ "target": "ES2021",
9
+ "sourceMap": true,
10
+ "outDir": "./dist",
11
+ "esModuleInterop":true
12
+ }
13
+ }
apps/web/.env.local.example ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # 同SERVER_ORIGIN_URL
2
+ VITE_SERVER_ORIGIN_URL=http://localhost:4000
apps/web/.eslintrc.cjs ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ module.exports = {
2
+ root: true,
3
+ env: { browser: true, es2020: true },
4
+ extends: [
5
+ 'eslint:recommended',
6
+ 'plugin:@typescript-eslint/recommended',
7
+ 'plugin:react-hooks/recommended',
8
+ ],
9
+ ignorePatterns: ['dist', '.eslintrc.cjs'],
10
+ parser: '@typescript-eslint/parser',
11
+ plugins: ['react-refresh'],
12
+ rules: {
13
+ 'react-refresh/only-export-components': [
14
+ 'warn',
15
+ { allowConstantExport: true },
16
+ ],
17
+ '@typescript-eslint/no-explicit-any': 'warn',
18
+ },
19
+ };
apps/web/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
apps/web/README.md ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + TypeScript + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@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
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9
+
10
+ ## Expanding the ESLint configuration
11
+
12
+ If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
13
+
14
+ - Configure the top-level `parserOptions` property like this:
15
+
16
+ ```js
17
+ export default {
18
+ // other rules...
19
+ parserOptions: {
20
+ ecmaVersion: 'latest',
21
+ sourceType: 'module',
22
+ project: ['./tsconfig.json', './tsconfig.node.json'],
23
+ tsconfigRootDir: __dirname,
24
+ },
25
+ };
26
+ ```
27
+
28
+ - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
29
+ - Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
30
+ - 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
apps/web/index.html ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" href="{{ iconUrl }}" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>WeWe RSS</title>
8
+ <meta name="description" content="更好的公众号订阅方式" />
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ <script>
13
+ window.__WEWE_RSS_SERVER_ORIGIN_URL__ = '{{ weweRssServerOriginUrl }}';
14
+ window.__WEWE_RSS_ENABLED_AUTH_CODE__ = '{{ enabledAuthCode }}';
15
+ </script>
16
+ <script type="module" src="/src/main.tsx"></script>
17
+ </body>
18
+ </html>
apps/web/package.json ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "web",
3
+ "private": true,
4
+ "version": "2.6.1",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc && vite build",
9
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "@nextui-org/react": "^2.2.9",
14
+ "@tanstack/react-query": "^4.35.3",
15
+ "@trpc/client": "^10.45.1",
16
+ "@trpc/next": "^10.45.1",
17
+ "@trpc/react-query": "^10.45.1",
18
+ "autoprefixer": "^10.0.1",
19
+ "dayjs": "^1.11.10",
20
+ "framer-motion": "^11.0.5",
21
+ "next-themes": "^0.2.1",
22
+ "postcss": "^8",
23
+ "qrcode.react": "^3.1.0",
24
+ "react": "^18.2.0",
25
+ "react-dom": "^18.2.0",
26
+ "react-router-dom": "^6.22.2",
27
+ "sonner": "^1.4.0",
28
+ "tailwindcss": "^3.3.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^20.11.24",
32
+ "@types/react": "^18.2.56",
33
+ "@types/react-dom": "^18.2.19",
34
+ "@typescript-eslint/eslint-plugin": "^7.0.2",
35
+ "@typescript-eslint/parser": "^7.0.2",
36
+ "@vitejs/plugin-react": "^4.2.1",
37
+ "eslint": "^8.56.0",
38
+ "eslint-plugin-react-hooks": "^4.6.0",
39
+ "eslint-plugin-react-refresh": "^0.4.5",
40
+ "typescript": "^5.2.2",
41
+ "vite": "^5.1.4"
42
+ }
43
+ }
apps/web/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
apps/web/src/App.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BrowserRouter, Route, Routes } from 'react-router-dom';
2
+ import Feeds from './pages/feeds';
3
+ import Login from './pages/login';
4
+ import Accounts from './pages/accounts';
5
+ import { BaseLayout } from './layouts/base';
6
+ import { TrpcProvider } from './provider/trpc';
7
+ import ThemeProvider from './provider/theme';
8
+
9
+ function App() {
10
+ return (
11
+ <BrowserRouter basename="/dash">
12
+ <ThemeProvider>
13
+ <TrpcProvider>
14
+ <Routes>
15
+ <Route path="/" element={<BaseLayout />}>
16
+ <Route index element={<Feeds />} />
17
+ <Route path="/feeds/:id?" element={<Feeds />} />
18
+ <Route path="/accounts" element={<Accounts />} />
19
+ <Route path="/login" element={<Login />} />
20
+ </Route>
21
+ </Routes>
22
+ </TrpcProvider>
23
+ </ThemeProvider>
24
+ </BrowserRouter>
25
+ );
26
+ }
27
+
28
+ export default App;
apps/web/src/components/GitHubIcon.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { IconSvgProps } from '../types';
2
+
3
+ export const GitHubIcon = ({
4
+ size = 24,
5
+ width,
6
+ height,
7
+ ...props
8
+ }: IconSvgProps) => (
9
+ <svg
10
+ aria-hidden="true"
11
+ fill="none"
12
+ focusable="false"
13
+ height={size || height}
14
+ role="presentation"
15
+ viewBox="0 0 24 24"
16
+ width={size || width}
17
+ {...props}
18
+ >
19
+ <path
20
+ clipRule="evenodd"
21
+ d="M12.026 2c-5.509 0-9.974 4.465-9.974 9.974 0 4.406 2.857 8.145 6.821 9.465.499.09.679-.217.679-.481 0-.237-.008-.865-.011-1.696-2.775.602-3.361-1.338-3.361-1.338-.452-1.152-1.107-1.459-1.107-1.459-.905-.619.069-.605.069-.605 1.002.07 1.527 1.028 1.527 1.028.89 1.524 2.336 1.084 2.902.829.091-.645.351-1.085.635-1.334-2.214-.251-4.542-1.107-4.542-4.93 0-1.087.389-1.979 1.024-2.675-.101-.253-.446-1.268.099-2.64 0 0 .837-.269 2.742 1.021a9.582 9.582 0 0 1 2.496-.336 9.554 9.554 0 0 1 2.496.336c1.906-1.291 2.742-1.021 2.742-1.021.545 1.372.203 2.387.099 2.64.64.696 1.024 1.587 1.024 2.675 0 3.833-2.33 4.675-4.552 4.922.355.308.675.916.675 1.846 0 1.334-.012 2.41-.012 2.737 0 .267.178.577.687.479C19.146 20.115 22 16.379 22 11.974 22 6.465 17.535 2 12.026 2z"
22
+ fill="currentColor"
23
+ fillRule="evenodd"
24
+ ></path>
25
+ </svg>
26
+ );
apps/web/src/components/Nav.tsx ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Badge,
3
+ Image,
4
+ Link,
5
+ Navbar,
6
+ NavbarBrand,
7
+ NavbarContent,
8
+ NavbarItem,
9
+ Tooltip,
10
+ } from '@nextui-org/react';
11
+ import { ThemeSwitcher } from './ThemeSwitcher';
12
+ import { GitHubIcon } from './GitHubIcon';
13
+ import { useLocation } from 'react-router-dom';
14
+ import { appVersion, serverOriginUrl } from '@web/utils/env';
15
+ import { useEffect, useState } from 'react';
16
+
17
+ const navbarItemLink = [
18
+ {
19
+ href: '/feeds',
20
+ name: '公众号源',
21
+ },
22
+ {
23
+ href: '/accounts',
24
+ name: '账号管理',
25
+ },
26
+ // {
27
+ // href: '/settings',
28
+ // name: '设置',
29
+ // },
30
+ ];
31
+
32
+ const Nav = () => {
33
+ const { pathname } = useLocation();
34
+ const [releaseVersion, setReleaseVersion] = useState(appVersion);
35
+
36
+ useEffect(() => {
37
+ fetch('https://api.github.com/repos/cooderl/wewe-rss/releases/latest')
38
+ .then((res) => res.json())
39
+ .then((data) => {
40
+ setReleaseVersion(data.name.replace('v', ''));
41
+ });
42
+ }, []);
43
+
44
+ const isFoundNewVersion = releaseVersion > appVersion;
45
+ console.log('isFoundNewVersion: ', isFoundNewVersion);
46
+
47
+ return (
48
+ <div>
49
+ <Navbar isBordered>
50
+ <Tooltip
51
+ content={
52
+ <div className="p-1">
53
+ {isFoundNewVersion && (
54
+ <Link
55
+ href={`https://github.com/cooderl/wewe-rss/releases/latest`}
56
+ target="_blank"
57
+ className="mb-1 block text-medium"
58
+ >
59
+ 发现新版本:v{releaseVersion}
60
+ </Link>
61
+ )}
62
+ 当前版本: v{appVersion}
63
+ </div>
64
+ }
65
+ placement="left"
66
+ >
67
+ <NavbarBrand className="cursor-default">
68
+ <Badge
69
+ content={isFoundNewVersion ? '' : null}
70
+ color="danger"
71
+ size="sm"
72
+ >
73
+ <Image
74
+ width={28}
75
+ alt="WeWe RSS"
76
+ className="mr-2"
77
+ src={
78
+ serverOriginUrl
79
+ ? `${serverOriginUrl}/favicon.ico`
80
+ : 'https://r2-assets.111965.xyz/wewe-rss.png'
81
+ }
82
+ ></Image>
83
+ </Badge>
84
+ <p className="font-bold text-inherit">WeWe RSS</p>
85
+ </NavbarBrand>
86
+ </Tooltip>
87
+ <NavbarContent className="hidden sm:flex gap-4" justify="center">
88
+ {navbarItemLink.map((item) => {
89
+ return (
90
+ <NavbarItem
91
+ isActive={pathname.startsWith(item.href)}
92
+ key={item.href}
93
+ >
94
+ <Link color="foreground" href={item.href}>
95
+ {item.name}
96
+ </Link>
97
+ </NavbarItem>
98
+ );
99
+ })}
100
+ </NavbarContent>
101
+ <NavbarContent justify="end">
102
+ <ThemeSwitcher></ThemeSwitcher>
103
+ <Link
104
+ href="https://github.com/cooderl/wewe-rss"
105
+ target="_blank"
106
+ color="foreground"
107
+ >
108
+ <GitHubIcon />
109
+ </Link>
110
+ </NavbarContent>
111
+ </Navbar>
112
+ </div>
113
+ );
114
+ };
115
+
116
+ export default Nav;
apps/web/src/components/PlusIcon.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { IconSvgProps } from '../types';
2
+
3
+ export const PlusIcon = ({
4
+ size = 24,
5
+ width,
6
+ height,
7
+ ...props
8
+ }: IconSvgProps) => (
9
+ <svg
10
+ aria-hidden="true"
11
+ fill="none"
12
+ focusable="false"
13
+ height={size || height}
14
+ role="presentation"
15
+ viewBox="0 0 24 24"
16
+ width={size || width}
17
+ {...props}
18
+ >
19
+ <g
20
+ fill="none"
21
+ stroke="currentColor"
22
+ strokeLinecap="round"
23
+ strokeLinejoin="round"
24
+ strokeWidth={1.5}
25
+ >
26
+ <path d="M6 12h12" />
27
+ <path d="M12 18V6" />
28
+ </g>
29
+ </svg>
30
+ );