hp1234 commited on
Commit
a1db4f2
·
verified ·
1 Parent(s): cfc9d6e

Upload 75 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .github/workflows/docker-release.yml +142 -0
  2. .vscode/extensions.json +16 -0
  3. .vscode/settings.json +43 -0
  4. apps/server/.env.local.example +28 -0
  5. apps/server/.eslintrc.js +25 -0
  6. apps/server/.gitignore +6 -0
  7. apps/server/.prettierrc.json +5 -0
  8. apps/server/README.md +73 -0
  9. apps/server/docker-bootstrap.sh +8 -0
  10. apps/server/nest-cli.json +8 -0
  11. apps/server/package.json +93 -0
  12. apps/server/prisma-sqlite/migrations/20240301104100_init/migration.sql +33 -0
  13. apps/server/prisma-sqlite/migrations/migration_lock.toml +3 -0
  14. apps/server/prisma-sqlite/schema.prisma +56 -0
  15. apps/server/prisma/migrations/20240227153512_init/migration.sql +39 -0
  16. apps/server/prisma/migrations/migration_lock.toml +3 -0
  17. apps/server/prisma/schema.prisma +56 -0
  18. apps/server/src/app.controller.spec.ts +22 -0
  19. apps/server/src/app.controller.ts +39 -0
  20. apps/server/src/app.module.ts +39 -0
  21. apps/server/src/app.service.ts +14 -0
  22. apps/server/src/configuration.ts +35 -0
  23. apps/server/src/constants.ts +14 -0
  24. apps/server/src/feeds/feeds.controller.spec.ts +18 -0
  25. apps/server/src/feeds/feeds.controller.ts +64 -0
  26. apps/server/src/feeds/feeds.module.ts +12 -0
  27. apps/server/src/feeds/feeds.service.spec.ts +18 -0
  28. apps/server/src/feeds/feeds.service.ts +291 -0
  29. apps/server/src/main.ts +49 -0
  30. apps/server/src/prisma/prisma.module.ts +8 -0
  31. apps/server/src/prisma/prisma.service.ts +9 -0
  32. apps/server/src/trpc/trpc.module.ts +12 -0
  33. apps/server/src/trpc/trpc.router.ts +439 -0
  34. apps/server/src/trpc/trpc.service.ts +270 -0
  35. apps/server/test/app.e2e-spec.ts +24 -0
  36. apps/server/test/jest-e2e.json +9 -0
  37. apps/server/tsconfig.build.json +4 -0
  38. apps/server/tsconfig.json +13 -0
  39. apps/web/.env.local.example +2 -0
  40. apps/web/.eslintrc.cjs +19 -0
  41. apps/web/.gitignore +24 -0
  42. apps/web/README.md +30 -0
  43. apps/web/index.html +18 -0
  44. apps/web/package.json +43 -0
  45. apps/web/postcss.config.js +6 -0
  46. apps/web/src/App.tsx +28 -0
  47. apps/web/src/components/GitHubIcon.tsx +26 -0
  48. apps/web/src/components/Nav.tsx +112 -0
  49. apps/web/src/components/PlusIcon.tsx +30 -0
  50. apps/web/src/components/StatusDropdown.tsx +46 -0
.github/workflows/docker-release.yml ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Build WeWeRSS images and push image to docker hub
2
+ on:
3
+ workflow_dispatch:
4
+ push:
5
+ # paths:
6
+ # - "apps/**"
7
+ # - "Dockerfile"
8
+ tags:
9
+ - "v*.*.*"
10
+
11
+ concurrency:
12
+ group: docker-release
13
+ cancel-in-progress: true
14
+
15
+ jobs:
16
+ check-env:
17
+ permissions:
18
+ contents: none
19
+ runs-on: ubuntu-latest
20
+ timeout-minutes: 5
21
+ outputs:
22
+ check-docker: ${{ steps.check-docker.outputs.defined }}
23
+ steps:
24
+ - id: check-docker
25
+ env:
26
+ DOCKER_HUB_NAME: ${{ secrets.DOCKER_HUB_NAME }}
27
+ if: ${{ env.DOCKER_HUB_NAME != '' }}
28
+ run: echo "defined=true" >> $GITHUB_OUTPUT
29
+
30
+ release-images:
31
+ runs-on: ubuntu-latest
32
+ timeout-minutes: 120
33
+ permissions:
34
+ packages: write
35
+ contents: read
36
+ id-token: write
37
+ steps:
38
+ - name: Checkout
39
+ uses: actions/checkout@v4
40
+ with:
41
+ fetch-depth: 1
42
+
43
+ - name: Set up QEMU
44
+ uses: docker/setup-qemu-action@v3
45
+
46
+ - name: Set up Docker Buildx
47
+ uses: docker/setup-buildx-action@v3
48
+
49
+ - name: Login to Docker Hub
50
+ uses: docker/login-action@v2
51
+ with:
52
+ username: ${{ secrets.DOCKER_HUB_NAME }}
53
+ password: ${{ secrets.DOCKER_HUB_PASSWORD }}
54
+
55
+ - name: Login to GitHub Container Registry
56
+ uses: docker/login-action@v3
57
+ with:
58
+ registry: ghcr.io
59
+ username: ${{ github.repository_owner }}
60
+ password: ${{ secrets.GITHUB_TOKEN }}
61
+
62
+ - name: Extract Docker metadata (sqlite)
63
+ id: meta-sqlite
64
+ uses: docker/metadata-action@v5
65
+ with:
66
+ images: |
67
+ ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss-sqlite
68
+ ghcr.io/cooderl/wewe-rss-sqlite
69
+ tags: |
70
+ type=raw,value=latest,enable=true
71
+ type=raw,value=${{ github.ref_name }},enable=true
72
+ flavor: latest=false
73
+
74
+ - name: Build and push Docker image (sqlite)
75
+ id: build-and-push-sqlite
76
+ uses: docker/build-push-action@v5
77
+ with:
78
+ context: .
79
+ push: true
80
+ tags: ${{ steps.meta-sqlite.outputs.tags }}
81
+ labels: ${{ steps.meta-sqlite.outputs.labels }}
82
+ target: app-sqlite
83
+ platforms: linux/amd64,linux/arm64
84
+ cache-from: type=gha,scope=docker-release
85
+ cache-to: type=gha,mode=max,scope=docker-release
86
+
87
+ - name: Extract Docker metadata
88
+ id: meta
89
+ uses: docker/metadata-action@v5
90
+ with:
91
+ images: |
92
+ ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss
93
+ ghcr.io/cooderl/wewe-rss
94
+ tags: |
95
+ type=raw,value=latest,enable=true
96
+ type=raw,value=${{ github.ref_name }},enable=true
97
+ flavor: latest=false
98
+
99
+ - name: Build and push Docker image
100
+ id: build-and-push
101
+ uses: docker/build-push-action@v5
102
+ with:
103
+ context: .
104
+ push: true
105
+ tags: ${{ steps.meta.outputs.tags }}
106
+ labels: ${{ steps.meta.outputs.labels }}
107
+ target: app
108
+ platforms: linux/amd64,linux/arm64
109
+ cache-from: type=gha,scope=docker-release
110
+ cache-to: type=gha,mode=max,scope=docker-release
111
+
112
+ - name: Set env
113
+ run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
114
+
115
+ - name: Create a Release
116
+ uses: elgohr/Github-Release-Action@v5
117
+ env:
118
+ GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
119
+ with:
120
+ title: ${{ env.RELEASE_VERSION }}
121
+
122
+ description:
123
+ runs-on: ubuntu-latest
124
+ needs: check-env
125
+ if: needs.check-env.outputs.check-docker == 'true'
126
+ timeout-minutes: 5
127
+ steps:
128
+ - uses: actions/checkout@v4
129
+
130
+ - name: Docker Hub Description(sqlite)
131
+ uses: peter-evans/dockerhub-description@v4
132
+ with:
133
+ username: ${{ secrets.DOCKER_HUB_NAME }}
134
+ password: ${{ secrets.DOCKER_HUB_PASSWORD }}
135
+ repository: ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss-sqlite
136
+
137
+ - name: Docker Hub Description
138
+ uses: peter-evans/dockerhub-description@v4
139
+ with:
140
+ username: ${{ secrets.DOCKER_HUB_NAME }}
141
+ password: ${{ secrets.DOCKER_HUB_PASSWORD }}
142
+ repository: ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss
.vscode/extensions.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "recommendations": [
3
+ "esbenp.prettier-vscode",
4
+ "dbaeumer.vscode-eslint",
5
+ "stylelint.vscode-stylelint",
6
+ "streetsidesoftware.code-spell-checker",
7
+ "DavidAnson.vscode-markdownlint",
8
+ "Gruntfuggly.todo-tree",
9
+ "mikestead.dotenv",
10
+ "foxundermoon.next-js",
11
+ "Prisma.prisma",
12
+ "planbcoding.vscode-react-refactor",
13
+ "yoavbls.pretty-ts-errors",
14
+ "usernamehw.errorlens"
15
+ ]
16
+ }
.vscode/settings.json ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "typescript.tsdk": "node_modules/.pnpm/[email protected]/node_modules/typescript/lib",
3
+ "typescript.enablePromptUseWorkspaceTsdk": true,
4
+ "[javascript]": {
5
+ "editor.formatOnSave": true,
6
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
7
+ },
8
+ "[typescript]": {
9
+ "editor.formatOnSave": true,
10
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
11
+ },
12
+ "[html]": {
13
+ "editor.formatOnSave": true,
14
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
15
+ },
16
+ "[scss]": {
17
+ "editor.formatOnSave": true,
18
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
19
+ },
20
+ "[css]": {
21
+ "editor.formatOnSave": true,
22
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
23
+ },
24
+ "[yaml]": {
25
+ "editor.formatOnSave": true,
26
+ "editor.defaultFormatter": "redhat.vscode-yaml"
27
+ },
28
+ "[json]": {
29
+ "editor.formatOnSave": true,
30
+ "editor.defaultFormatter": "vscode.json-language-features"
31
+ },
32
+ "cSpell.words": [
33
+ "callout",
34
+ "checkstyle",
35
+ "commitlint",
36
+ "daisyui",
37
+ "nestjs",
38
+ "nextui",
39
+ "tailwindcss",
40
+ "Trpc",
41
+ "wewe"
42
+ ]
43
+ }
apps/server/.env.local.example ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # 读书转发服务,不需要修改
28
+ 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.2.3",
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
+ "node-cache": "^5.1.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/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,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ @@map("feeds")
43
+ }
44
+
45
+ model Article {
46
+ id String @id
47
+ mpId String @map("mp_id")
48
+ title String @map("title")
49
+ picUrl String @map("pic_url")
50
+ publishTime Int @map("publish_time")
51
+
52
+ createdAt DateTime @default(now()) @map("created_at")
53
+ updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
54
+
55
+ @@map("articles")
56
+ }
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/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,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ @@map("feeds")
43
+ }
44
+
45
+ model Article {
46
+ id String @id @db.VarChar(255)
47
+ mpId String @map("mp_id") @db.VarChar(255)
48
+ title String @map("title") @db.VarChar(255)
49
+ picUrl String @map("pic_url") @db.VarChar(255)
50
+ publishTime Int @map("publish_time")
51
+
52
+ createdAt DateTime @default(now()) @map("created_at")
53
+ updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
54
+
55
+ @@map("articles")
56
+ }
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,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Controller, Get, Redirect, Render } from '@nestjs/common';
2
+ import { AppService } from './app.service';
3
+ import { ConfigService } from '@nestjs/config';
4
+ import { ConfigurationType } from './configuration';
5
+
6
+ @Controller()
7
+ export class AppController {
8
+ constructor(
9
+ private readonly appService: AppService,
10
+ private readonly configService: ConfigService,
11
+ ) {}
12
+
13
+ @Get()
14
+ getHello(): string {
15
+ return this.appService.getHello();
16
+ }
17
+
18
+ @Get('/robots.txt')
19
+ forRobot(): string {
20
+ return 'User-agent: *\nDisallow: /';
21
+ }
22
+
23
+ @Get('favicon.ico')
24
+ @Redirect('https://r2-assets.111965.xyz/wewe-rss.png', 302)
25
+ getFavicon() {}
26
+
27
+ @Get('/dash*')
28
+ @Render('index.hbs')
29
+ dashRender() {
30
+ const { originUrl: weweRssServerOriginUrl } =
31
+ this.configService.get<ConfigurationType['feed']>('feed')!;
32
+ const { code } = this.configService.get<ConfigurationType['auth']>('auth')!;
33
+
34
+ return {
35
+ weweRssServerOriginUrl,
36
+ enabledAuthCode: !!code,
37
+ };
38
+ }
39
+ }
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,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ return {
19
+ server: { isProd, port, host },
20
+ throttler: { maxRequestPerMinute },
21
+ auth: { code: authCode },
22
+ platform: { url: platformUrl },
23
+ feed: {
24
+ originUrl,
25
+ mode: feedMode,
26
+ },
27
+ database: {
28
+ type: databaseType,
29
+ },
30
+ };
31
+ };
32
+
33
+ export default configuration;
34
+
35
+ export type ConfigurationType = ReturnType<typeof configuration>;
apps/server/src/constants.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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;
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,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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('mode') mode: string,
32
+ ) {
33
+ const path = req.path;
34
+ const type = path.split('.').pop() || '';
35
+ const { content, mimeType } = await this.feedsService.handleGenerateFeed({
36
+ type,
37
+ limit,
38
+ mode,
39
+ });
40
+
41
+ res.setHeader('Content-Type', mimeType);
42
+ res.send(content);
43
+ }
44
+
45
+ @Get('/:feed')
46
+ async getFeed(
47
+ @Response() res: Res,
48
+ @Param('feed') feed: string,
49
+ @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number = 10,
50
+ @Query('mode') mode: string,
51
+ ) {
52
+ const [id, type] = feed.split('.');
53
+ this.logger.log('getFeed: ', id);
54
+ const { content, mimeType } = await this.feedsService.handleGenerateFeed({
55
+ id,
56
+ type,
57
+ limit,
58
+ mode,
59
+ });
60
+
61
+ res.setHeader('Content-Type', mimeType);
62
+ res.send(content);
63
+ }
64
+ }
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,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 } from 'feed';
10
+ import got, { Got } from 'got';
11
+ import { load } from 'cheerio';
12
+ import { minify } from 'html-minifier';
13
+ import NodeCache from 'node-cache';
14
+ import pMap from '@cjs-exporter/p-map';
15
+
16
+ console.log('CRON_EXPRESSION: ', process.env.CRON_EXPRESSION);
17
+
18
+ const mpCache = new NodeCache({
19
+ maxKeys: 1000,
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
+ for (const feed of feeds) {
82
+ this.logger.debug('feed', feed.id);
83
+ try {
84
+ await this.trpcService.refreshMpArticlesAndUpdateFeed(feed.id);
85
+ } catch (err) {
86
+ this.logger.error('handleUpdateFeedsCron error', err);
87
+ } finally {
88
+ // wait 30s for next feed
89
+ await new Promise((resolve) => setTimeout(resolve, 30 * 1e3));
90
+ }
91
+ }
92
+ }
93
+
94
+ async cleanHtml(source: string) {
95
+ const $ = load(source, { decodeEntities: false });
96
+
97
+ const dirtyHtml = $.html($('.rich_media_content'));
98
+
99
+ const html = dirtyHtml
100
+ .replace(/data-src=/g, 'src=')
101
+ .replace(/visibility: hidden;/g, '');
102
+
103
+ const content =
104
+ '<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>' +
105
+ html;
106
+
107
+ const result = minify(content, {
108
+ removeAttributeQuotes: true,
109
+ collapseWhitespace: true,
110
+ });
111
+
112
+ return result;
113
+ }
114
+
115
+ async getHtmlByUrl(url: string) {
116
+ const html = await this.request(url, { responseType: 'text' }).text();
117
+ const result = await this.cleanHtml(html);
118
+
119
+ return result;
120
+ }
121
+
122
+ async tryGetContent(id: string) {
123
+ let content = mpCache.get(id) as string;
124
+ if (content) {
125
+ return content;
126
+ }
127
+ const url = `https://mp.weixin.qq.com/s/${id}`;
128
+ content = await this.getHtmlByUrl(url).catch((e) => {
129
+ this.logger.error(`getHtmlByUrl(${url}) error: ${e.message}`);
130
+
131
+ return '获取全文失败,请重试~';
132
+ });
133
+ mpCache.set(id, content);
134
+ return content;
135
+ }
136
+
137
+ async renderFeed({
138
+ type,
139
+ feedInfo,
140
+ articles,
141
+ mode,
142
+ }: {
143
+ type: string;
144
+ feedInfo: FeedInfo;
145
+ articles: Article[];
146
+ mode?: string;
147
+ }) {
148
+ const { originUrl, mode: globalMode } =
149
+ this.configService.get<ConfigurationType['feed']>('feed')!;
150
+
151
+ const link = `${originUrl}/feeds/${feedInfo.id}.${type}`;
152
+
153
+ const feed = new Feed({
154
+ title: feedInfo.mpName,
155
+ description: feedInfo.mpIntro,
156
+ id: link,
157
+ link: link,
158
+ language: 'zh-cn', // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes
159
+ image: feedInfo.mpCover,
160
+ favicon: feedInfo.mpCover,
161
+ copyright: '',
162
+ updated: new Date(feedInfo.updateTime * 1e3),
163
+ generator: 'WeWe-RSS',
164
+ author: { name: feedInfo.mpName },
165
+ });
166
+
167
+ feed.addExtension({
168
+ name: 'generator',
169
+ objects: `WeWe-RSS`,
170
+ });
171
+
172
+ const feeds = await this.prismaService.feed.findMany({
173
+ select: { id: true, mpName: true },
174
+ });
175
+
176
+ /**mode 高于 globalMode。如果 mode 值存在,取 mode 值*/
177
+ const enableFullText =
178
+ typeof mode === 'string'
179
+ ? mode === 'fulltext'
180
+ : globalMode === 'fulltext';
181
+
182
+ const showAuthor = feedInfo.id === 'all';
183
+
184
+ const mapper = async (item) => {
185
+ const { title, id, publishTime, picUrl, mpId } = item;
186
+ const link = `https://mp.weixin.qq.com/s/${id}`;
187
+
188
+ const mpName = feeds.find((item) => item.id === mpId)?.mpName || '-';
189
+ const published = new Date(publishTime * 1e3);
190
+
191
+ let description = '';
192
+ if (enableFullText) {
193
+ description = await this.tryGetContent(id);
194
+ }
195
+
196
+ feed.addItem({
197
+ id,
198
+ title,
199
+ link: link,
200
+ guid: link,
201
+ description,
202
+ date: published,
203
+ image: picUrl,
204
+ author: showAuthor ? [{ name: mpName }] : undefined,
205
+ });
206
+ };
207
+
208
+ await pMap(articles, mapper, { concurrency: 2, stopOnError: false });
209
+
210
+ return feed;
211
+ }
212
+
213
+ async handleGenerateFeed({
214
+ id,
215
+ type,
216
+ limit,
217
+ mode,
218
+ }: {
219
+ id?: string;
220
+ type: string;
221
+ limit: number;
222
+ mode?: string;
223
+ }) {
224
+ if (!feedTypes.includes(type as any)) {
225
+ type = 'atom';
226
+ }
227
+
228
+ let articles: Article[];
229
+ let feedInfo: FeedInfo;
230
+ if (id) {
231
+ feedInfo = (await this.prismaService.feed.findFirst({
232
+ where: { id },
233
+ }))!;
234
+
235
+ if (!feedInfo) {
236
+ throw new HttpException('不存在该feed!', HttpStatus.BAD_REQUEST);
237
+ }
238
+
239
+ articles = await this.prismaService.article.findMany({
240
+ where: { mpId: id },
241
+ orderBy: { publishTime: 'desc' },
242
+ take: limit,
243
+ });
244
+ } else {
245
+ articles = await this.prismaService.article.findMany({
246
+ orderBy: { publishTime: 'desc' },
247
+ take: limit,
248
+ });
249
+
250
+ feedInfo = {
251
+ id: 'all',
252
+ mpName: 'WeWe-RSS All',
253
+ mpIntro: 'WeWe-RSS 全部文章',
254
+ mpCover: 'https://r2-assets.111965.xyz/wewe-rss.png',
255
+ status: 1,
256
+ syncTime: 0,
257
+ updateTime: Math.floor(Date.now() / 1e3),
258
+ createdAt: new Date(),
259
+ updatedAt: new Date(),
260
+ };
261
+ }
262
+
263
+ this.logger.log('handleGenerateFeed articles: ' + articles.length);
264
+ const feed = await this.renderFeed({ feedInfo, articles, type, mode });
265
+
266
+ switch (type) {
267
+ case 'rss':
268
+ return { content: feed.rss2(), mimeType: feedMimeTypeMap[type] };
269
+ case 'json':
270
+ return { content: feed.json1(), mimeType: feedMimeTypeMap[type] };
271
+ case 'atom':
272
+ default:
273
+ return { content: feed.atom1(), mimeType: feedMimeTypeMap[type] };
274
+ }
275
+ }
276
+
277
+ async getFeedList() {
278
+ const data = await this.prismaService.feed.findMany();
279
+
280
+ return data.map((item) => {
281
+ return {
282
+ id: item.id,
283
+ name: item.mpName,
284
+ intro: item.mpIntro,
285
+ cover: item.mpCover,
286
+ syncTime: item.syncTime,
287
+ updateTime: item.updateTime,
288
+ };
289
+ });
290
+ }
291
+ }
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,439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(500).nullish(),
26
+ cursor: z.string().nullish(),
27
+ }),
28
+ )
29
+ .query(async ({ input }) => {
30
+ const limit = input.limit ?? 500;
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(500).nullish(),
140
+ cursor: z.string().nullish(),
141
+ }),
142
+ )
143
+ .query(async ({ input }) => {
144
+ const limit = input.limit ?? 500;
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
+ });
263
+
264
+ articleRouter = this.trpcService.router({
265
+ list: this.trpcService.protectedProcedure
266
+ .input(
267
+ z.object({
268
+ limit: z.number().min(1).max(500).nullish(),
269
+ cursor: z.string().nullish(),
270
+ mpId: z.string().nullish(),
271
+ }),
272
+ )
273
+ .query(async ({ input }) => {
274
+ const limit = input.limit ?? 500;
275
+ const { cursor, mpId } = input;
276
+
277
+ const items = await this.prismaService.article.findMany({
278
+ orderBy: [
279
+ {
280
+ publishTime: 'desc',
281
+ },
282
+ ],
283
+ take: limit + 1,
284
+ where: mpId ? { mpId } : undefined,
285
+ cursor: cursor
286
+ ? {
287
+ id: cursor,
288
+ }
289
+ : undefined,
290
+ });
291
+ let nextCursor: typeof cursor | undefined = undefined;
292
+ if (items.length > limit) {
293
+ // Remove the last item and use it as next cursor
294
+
295
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
296
+ const nextItem = items.pop()!;
297
+ nextCursor = nextItem.id;
298
+ }
299
+
300
+ return {
301
+ items,
302
+ nextCursor,
303
+ };
304
+ }),
305
+ byId: this.trpcService.protectedProcedure
306
+ .input(z.string())
307
+ .query(async ({ input: id }) => {
308
+ const article = await this.prismaService.article.findUnique({
309
+ where: { id },
310
+ });
311
+ if (!article) {
312
+ throw new TRPCError({
313
+ code: 'BAD_REQUEST',
314
+ message: `No article with id '${id}'`,
315
+ });
316
+ }
317
+ return article;
318
+ }),
319
+
320
+ add: this.trpcService.protectedProcedure
321
+ .input(
322
+ z.object({
323
+ id: z.string(),
324
+ mpId: z.string(),
325
+ title: z.string(),
326
+ picUrl: z.string().optional().default(''),
327
+ publishTime: z.number(),
328
+ }),
329
+ )
330
+ .mutation(async ({ input }) => {
331
+ const { id, ...data } = input;
332
+ const article = await this.prismaService.article.upsert({
333
+ where: {
334
+ id,
335
+ },
336
+ update: data,
337
+ create: input,
338
+ });
339
+
340
+ return article;
341
+ }),
342
+ delete: this.trpcService.protectedProcedure
343
+ .input(z.string())
344
+ .mutation(async ({ input: id }) => {
345
+ await this.prismaService.article.delete({ where: { id } });
346
+ return id;
347
+ }),
348
+ });
349
+
350
+ platformRouter = this.trpcService.router({
351
+ getMpArticles: this.trpcService.protectedProcedure
352
+ .input(
353
+ z.object({
354
+ mpId: z.string(),
355
+ }),
356
+ )
357
+ .mutation(async ({ input: { mpId } }) => {
358
+ try {
359
+ const results = await this.trpcService.getMpArticles(mpId);
360
+ return results;
361
+ } catch (err: any) {
362
+ this.logger.log('getMpArticles err: ', err);
363
+ throw new TRPCError({
364
+ code: 'INTERNAL_SERVER_ERROR',
365
+ message: err.response?.data?.message || err.message,
366
+ cause: err.stack,
367
+ });
368
+ }
369
+ }),
370
+ getMpInfo: this.trpcService.protectedProcedure
371
+ .input(
372
+ z.object({
373
+ wxsLink: z
374
+ .string()
375
+ .refine((v) => v.startsWith('https://mp.weixin.qq.com/s/')),
376
+ }),
377
+ )
378
+ .mutation(async ({ input: { wxsLink: url } }) => {
379
+ try {
380
+ const results = await this.trpcService.getMpInfo(url);
381
+ return results;
382
+ } catch (err: any) {
383
+ this.logger.log('getMpInfo err: ', err);
384
+ throw new TRPCError({
385
+ code: 'INTERNAL_SERVER_ERROR',
386
+ message: err.response?.data?.message || err.message,
387
+ cause: err.stack,
388
+ });
389
+ }
390
+ }),
391
+
392
+ createLoginUrl: this.trpcService.protectedProcedure.mutation(async () => {
393
+ return this.trpcService.createLoginUrl();
394
+ }),
395
+ getLoginResult: this.trpcService.protectedProcedure
396
+ .input(
397
+ z.object({
398
+ id: z.string(),
399
+ }),
400
+ )
401
+ .query(async ({ input }) => {
402
+ return this.trpcService.getLoginResult(input.id);
403
+ }),
404
+ });
405
+
406
+ appRouter = this.trpcService.router({
407
+ feed: this.feedRouter,
408
+ account: this.accountRouter,
409
+ article: this.articleRouter,
410
+ platform: this.platformRouter,
411
+ });
412
+
413
+ async applyMiddleware(app: INestApplication) {
414
+ app.use(
415
+ `/trpc`,
416
+ trpcExpress.createExpressMiddleware({
417
+ router: this.appRouter,
418
+ createContext: ({ req }) => {
419
+ const authCode =
420
+ this.configService.get<ConfigurationType['auth']>('auth')!.code;
421
+
422
+ if (authCode && req.headers.authorization !== authCode) {
423
+ return {
424
+ errorMsg: 'authCode不正确!',
425
+ };
426
+ }
427
+ return {
428
+ errorMsg: null,
429
+ };
430
+ },
431
+ middleware: (req, res, next) => {
432
+ next();
433
+ },
434
+ }),
435
+ );
436
+ }
437
+ }
438
+
439
+ export type AppRouter = TrpcRouter[`appRouter`];
apps/server/src/trpc/trpc.service.ts ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Injectable, Logger } from '@nestjs/common';
2
+ import { ConfigService } from '@nestjs/config';
3
+ import { ConfigurationType } from '@server/configuration';
4
+ import { 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
+
35
+ private readonly logger = new Logger(this.constructor.name);
36
+
37
+ constructor(
38
+ private readonly prismaService: PrismaService,
39
+ private readonly configService: ConfigService,
40
+ ) {
41
+ const { url } =
42
+ this.configService.get<ConfigurationType['platform']>('platform')!;
43
+ this.request = Axios.create({ baseURL: url, timeout: 15 * 1e3 });
44
+
45
+ this.request.interceptors.response.use(
46
+ (response) => {
47
+ return response;
48
+ },
49
+ async (error) => {
50
+ this.logger.log('error: ', error);
51
+ const errMsg = error.response?.data?.message || '';
52
+
53
+ const id = (error.config.headers as any).xid;
54
+ if (errMsg.includes('WeReadError401')) {
55
+ // 账号失效
56
+ await this.prismaService.account.update({
57
+ where: { id },
58
+ data: { status: statusMap.INVALID },
59
+ });
60
+ this.logger.error(`账号(${id})登录失效,已禁用`);
61
+ } else if (errMsg.includes('WeReadError429')) {
62
+ //TODO 处理请求频繁
63
+ this.logger.error(`账号(${id})请求频繁,打入小黑屋`);
64
+ }
65
+
66
+ const today = this.getTodayDate();
67
+
68
+ const blockedAccounts = blockedAccountsMap.get(today);
69
+
70
+ if (Array.isArray(blockedAccounts)) {
71
+ if (id) {
72
+ blockedAccounts.push(id);
73
+ }
74
+ blockedAccountsMap.set(today, blockedAccounts);
75
+ } else if (errMsg.includes('WeReadError400')) {
76
+ this.logger.error(`账号(${id})处理请求参数出错`);
77
+ this.logger.error('WeReadError400: ', errMsg);
78
+ // 10s 后重试
79
+ await new Promise((resolve) => setTimeout(resolve, 10 * 1e3));
80
+ } else {
81
+ this.logger.error("Can't handle this error: ", errMsg);
82
+ }
83
+
84
+ return Promise.reject(error);
85
+ },
86
+ );
87
+ }
88
+
89
+ removeBlockedAccount = (vid: string) => {
90
+ const today = this.getTodayDate();
91
+
92
+ const blockedAccounts = blockedAccountsMap.get(today);
93
+ if (Array.isArray(blockedAccounts)) {
94
+ const newBlockedAccounts = blockedAccounts.filter((id) => id !== vid);
95
+ blockedAccountsMap.set(today, newBlockedAccounts);
96
+ }
97
+ };
98
+
99
+ private getTodayDate() {
100
+ return dayjs.tz(new Date(), 'Asia/Shanghai').format('YYYY-MM-DD');
101
+ }
102
+
103
+ getBlockedAccountIds() {
104
+ const today = this.getTodayDate();
105
+ const disabledAccounts = blockedAccountsMap.get(today) || [];
106
+ this.logger.debug('disabledAccounts: ', disabledAccounts);
107
+ return disabledAccounts.filter(Boolean);
108
+ }
109
+
110
+ private async getAvailableAccount() {
111
+ const disabledAccounts = this.getBlockedAccountIds();
112
+ const account = await this.prismaService.account.findFirst({
113
+ where: {
114
+ status: statusMap.ENABLE,
115
+ NOT: {
116
+ id: { in: disabledAccounts },
117
+ },
118
+ },
119
+ });
120
+
121
+ if (!account) {
122
+ throw new Error('暂无可用读书账号!');
123
+ }
124
+
125
+ return account;
126
+ }
127
+
128
+ async getMpArticles(mpId: string, retryCount = 3) {
129
+ const account = await this.getAvailableAccount();
130
+
131
+ try {
132
+ const res = await this.request
133
+ .get<
134
+ {
135
+ id: string;
136
+ title: string;
137
+ picUrl: string;
138
+ publishTime: number;
139
+ }[]
140
+ >(`/api/v2/platform/mps/${mpId}/articles`, {
141
+ headers: {
142
+ xid: account.id,
143
+ Authorization: `Bearer ${account.token}`,
144
+ },
145
+ })
146
+ .then((res) => res.data)
147
+ .then((res) => {
148
+ this.logger.log(`getMpArticles(${mpId}): ${res.length} articles`);
149
+ return res;
150
+ });
151
+ return res;
152
+ } catch (err) {
153
+ this.logger.error(`retry(${4 - retryCount}) getMpArticles error: `, err);
154
+ if (retryCount > 0) {
155
+ return this.getMpArticles(mpId, retryCount - 1);
156
+ } else {
157
+ throw err;
158
+ }
159
+ }
160
+ }
161
+
162
+ async refreshMpArticlesAndUpdateFeed(mpId: string) {
163
+ const articles = await this.getMpArticles(mpId);
164
+
165
+ if (articles.length > 0) {
166
+ let results;
167
+ const { type } =
168
+ this.configService.get<ConfigurationType['database']>('database')!;
169
+ if (type === 'sqlite') {
170
+ // sqlite3 不支持 createMany
171
+ const inserts = articles.map(({ id, picUrl, publishTime, title }) =>
172
+ this.prismaService.article.upsert({
173
+ create: { id, mpId, picUrl, publishTime, title },
174
+ update: {
175
+ publishTime,
176
+ title,
177
+ },
178
+ where: { id },
179
+ }),
180
+ );
181
+ results = await this.prismaService.$transaction(inserts);
182
+ } else {
183
+ results = await (this.prismaService.article as any).createMany({
184
+ data: articles.map(({ id, picUrl, publishTime, title }) => ({
185
+ id,
186
+ mpId,
187
+ picUrl,
188
+ publishTime,
189
+ title,
190
+ })),
191
+ skipDuplicates: true,
192
+ });
193
+ }
194
+
195
+ this.logger.debug('refreshMpArticlesAndUpdateFeed results: ', results);
196
+ }
197
+
198
+ await this.prismaService.feed.update({
199
+ where: { id: mpId },
200
+ data: {
201
+ syncTime: Math.floor(Date.now() / 1e3),
202
+ },
203
+ });
204
+ }
205
+
206
+ isRefreshAllMpArticlesRunning = false;
207
+
208
+ async refreshAllMpArticlesAndUpdateFeed() {
209
+ if (this.isRefreshAllMpArticlesRunning) {
210
+ this.logger.log('refreshAllMpArticlesAndUpdateFeed is running');
211
+ return;
212
+ }
213
+ const mps = await this.prismaService.feed.findMany();
214
+ this.isRefreshAllMpArticlesRunning = true;
215
+ try {
216
+ for (const { id } of mps) {
217
+ await this.refreshMpArticlesAndUpdateFeed(id);
218
+ await new Promise((resolve) => setTimeout(resolve, 10 * 1e3));
219
+ }
220
+ } finally {
221
+ this.isRefreshAllMpArticlesRunning = false;
222
+ }
223
+ }
224
+
225
+ async getMpInfo(url: string) {
226
+ url = url.trim();
227
+ const account = await this.getAvailableAccount();
228
+
229
+ return this.request
230
+ .post<
231
+ {
232
+ id: string;
233
+ cover: string;
234
+ name: string;
235
+ intro: string;
236
+ updateTime: number;
237
+ }[]
238
+ >(
239
+ `/api/v2/platform/wxs2mp`,
240
+ { url },
241
+ {
242
+ headers: {
243
+ xid: account.id,
244
+ Authorization: `Bearer ${account.token}`,
245
+ },
246
+ },
247
+ )
248
+ .then((res) => res.data);
249
+ }
250
+
251
+ async createLoginUrl() {
252
+ return this.request
253
+ .get<{
254
+ uuid: string;
255
+ scanUrl: string;
256
+ }>(`/api/v2/login/platform`)
257
+ .then((res) => res.data);
258
+ }
259
+
260
+ async getLoginResult(id: string) {
261
+ return this.request
262
+ .get<{
263
+ message: string;
264
+ vid?: number;
265
+ token?: string;
266
+ username?: string;
267
+ }>(`/api/v2/login/platform/${id}`, { timeout: 120 * 1e3 })
268
+ .then((res) => res.data);
269
+ }
270
+ }
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="https://r2-assets.111965.xyz/wewe-rss.png" />
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.2.3",
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,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 } 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="https://r2-assets.111965.xyz/wewe-rss.png"
78
+ ></Image>
79
+ </Badge>
80
+ <p className="font-bold text-inherit">WeWe RSS</p>
81
+ </NavbarBrand>
82
+ </Tooltip>
83
+ <NavbarContent className="hidden sm:flex gap-4" justify="center">
84
+ {navbarItemLink.map((item) => {
85
+ return (
86
+ <NavbarItem
87
+ isActive={pathname.startsWith(item.href)}
88
+ key={item.href}
89
+ >
90
+ <Link color="foreground" href={item.href}>
91
+ {item.name}
92
+ </Link>
93
+ </NavbarItem>
94
+ );
95
+ })}
96
+ </NavbarContent>
97
+ <NavbarContent justify="end">
98
+ <ThemeSwitcher></ThemeSwitcher>
99
+ <Link
100
+ href="https://github.com/cooderl/wewe-rss"
101
+ target="_blank"
102
+ color="foreground"
103
+ >
104
+ <GitHubIcon />
105
+ </Link>
106
+ </NavbarContent>
107
+ </Navbar>
108
+ </div>
109
+ );
110
+ };
111
+
112
+ 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
+ );
apps/web/src/components/StatusDropdown.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import {
3
+ Dropdown,
4
+ DropdownTrigger,
5
+ DropdownMenu,
6
+ DropdownItem,
7
+ Button,
8
+ } from '@nextui-org/react';
9
+ import { statusMap } from '@web/constants';
10
+
11
+ export function StatusDropdown({
12
+ value = 1,
13
+ onChange,
14
+ }: {
15
+ value: number;
16
+ onChange: (value: number) => void;
17
+ }) {
18
+ return (
19
+ <Dropdown>
20
+ <DropdownTrigger>
21
+ <Button size="sm" variant="bordered" className="capitalize">
22
+ {statusMap[value].label}
23
+ </Button>
24
+ </DropdownTrigger>
25
+ <DropdownMenu
26
+ disabledKeys={['0']}
27
+ aria-label="状态设置"
28
+ variant="flat"
29
+ disallowEmptySelection
30
+ selectionMode="single"
31
+ selectedKeys={[`${value}`]}
32
+ onSelectionChange={(keys) => {
33
+ onChange(+Array.from(keys)[0]);
34
+ }}
35
+ >
36
+ {Object.entries(statusMap).map(([key, value]) => {
37
+ return (
38
+ <DropdownItem color={value.color} key={`${key}`} value={`${key}`}>
39
+ {value.label}
40
+ </DropdownItem>
41
+ );
42
+ })}
43
+ </DropdownMenu>
44
+ </Dropdown>
45
+ );
46
+ }