fukeke commited on
Commit
880d67e
1 Parent(s): 32695d6

Upload 91 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. .dockerignore +8 -0
  2. .github/workflows/docker-release.yml +142 -0
  3. .gitignore +132 -0
  4. .markdownlint.yaml +9 -0
  5. .npmrc +3 -0
  6. .prettierignore +8 -0
  7. .prettierrc.json +5 -0
  8. .vscode/extensions.json +16 -0
  9. .vscode/settings.json +43 -0
  10. LICENSE +21 -0
  11. apps/server/.env.local.example +28 -0
  12. apps/server/.eslintrc.js +25 -0
  13. apps/server/.gitignore +6 -0
  14. apps/server/.prettierrc.json +5 -0
  15. apps/server/README.md +73 -0
  16. apps/server/docker-bootstrap.sh +8 -0
  17. apps/server/nest-cli.json +8 -0
  18. apps/server/package.json +93 -0
  19. apps/server/prisma-sqlite/migrations/20240301104100_init/migration.sql +33 -0
  20. apps/server/prisma-sqlite/migrations/migration_lock.toml +3 -0
  21. apps/server/prisma-sqlite/schema.prisma +56 -0
  22. apps/server/prisma/migrations/20240227153512_init/migration.sql +39 -0
  23. apps/server/prisma/migrations/migration_lock.toml +3 -0
  24. apps/server/prisma/schema.prisma +56 -0
  25. apps/server/src/app.controller.spec.ts +22 -0
  26. apps/server/src/app.controller.ts +39 -0
  27. apps/server/src/app.module.ts +39 -0
  28. apps/server/src/app.service.ts +14 -0
  29. apps/server/src/configuration.ts +35 -0
  30. apps/server/src/constants.ts +14 -0
  31. apps/server/src/feeds/feeds.controller.spec.ts +18 -0
  32. apps/server/src/feeds/feeds.controller.ts +64 -0
  33. apps/server/src/feeds/feeds.module.ts +12 -0
  34. apps/server/src/feeds/feeds.service.spec.ts +18 -0
  35. apps/server/src/feeds/feeds.service.ts +291 -0
  36. apps/server/src/main.ts +49 -0
  37. apps/server/src/prisma/prisma.module.ts +8 -0
  38. apps/server/src/prisma/prisma.service.ts +9 -0
  39. apps/server/src/trpc/trpc.module.ts +12 -0
  40. apps/server/src/trpc/trpc.router.ts +439 -0
  41. apps/server/src/trpc/trpc.service.ts +270 -0
  42. apps/server/test/app.e2e-spec.ts +24 -0
  43. apps/server/test/jest-e2e.json +9 -0
  44. apps/server/tsconfig.build.json +4 -0
  45. apps/server/tsconfig.json +13 -0
  46. apps/web/.env.local.example +2 -0
  47. apps/web/.eslintrc.cjs +19 -0
  48. apps/web/.gitignore +24 -0
  49. apps/web/README.md +30 -0
  50. apps/web/index.html +18 -0
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ .git
3
+ .gitignore
4
+ *.md
5
+ dist
6
+ .env
7
+ .next
8
+ .DS_Store
.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
.gitignore ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ lerna-debug.log*
8
+ .pnpm-debug.log*
9
+
10
+ # Diagnostic reports (https://nodejs.org/api/report.html)
11
+ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12
+
13
+ # Runtime data
14
+ pids
15
+ *.pid
16
+ *.seed
17
+ *.pid.lock
18
+
19
+ # Directory for instrumented libs generated by jscoverage/JSCover
20
+ lib-cov
21
+
22
+ # Coverage directory used by tools like istanbul
23
+ coverage
24
+ *.lcov
25
+
26
+ # nyc test coverage
27
+ .nyc_output
28
+
29
+ # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30
+ .grunt
31
+
32
+ # Bower dependency directory (https://bower.io/)
33
+ bower_components
34
+
35
+ # node-waf configuration
36
+ .lock-wscript
37
+
38
+ # Compiled binary addons (https://nodejs.org/api/addons.html)
39
+ build/Release
40
+
41
+ # Dependency directories
42
+ node_modules/
43
+ jspm_packages/
44
+
45
+ # Snowpack dependency directory (https://snowpack.dev/)
46
+ web_modules/
47
+
48
+ # TypeScript cache
49
+ *.tsbuildinfo
50
+
51
+ # Optional npm cache directory
52
+ .npm
53
+
54
+ # Optional eslint cache
55
+ .eslintcache
56
+
57
+ # Optional stylelint cache
58
+ .stylelintcache
59
+
60
+ # Microbundle cache
61
+ .rpt2_cache/
62
+ .rts2_cache_cjs/
63
+ .rts2_cache_es/
64
+ .rts2_cache_umd/
65
+
66
+ # Optional REPL history
67
+ .node_repl_history
68
+
69
+ # Output of 'npm pack'
70
+ *.tgz
71
+
72
+ # Yarn Integrity file
73
+ .yarn-integrity
74
+
75
+ # dotenv environment variable files
76
+ .env
77
+ .env.development.local
78
+ .env.test.local
79
+ .env.production.local
80
+ .env.local
81
+
82
+ # parcel-bundler cache (https://parceljs.org/)
83
+ .cache
84
+ .parcel-cache
85
+
86
+ # Next.js build output
87
+ .next
88
+ out
89
+
90
+ # Nuxt.js build / generate output
91
+ .nuxt
92
+ dist
93
+
94
+ # Gatsby files
95
+ .cache/
96
+ # Comment in the public line in if your project uses Gatsby and not Next.js
97
+ # https://nextjs.org/blog/next-9-1#public-directory-support
98
+ # public
99
+
100
+ # vuepress build output
101
+ .vuepress/dist
102
+
103
+ # vuepress v2.x temp and cache directory
104
+ .temp
105
+ .cache
106
+
107
+ # Docusaurus cache and generated files
108
+ .docusaurus
109
+
110
+ # Serverless directories
111
+ .serverless/
112
+
113
+ # FuseBox cache
114
+ .fusebox/
115
+
116
+ # DynamoDB Local files
117
+ .dynamodb/
118
+
119
+ # TernJS port file
120
+ .tern-port
121
+
122
+ # Stores VSCode versions used for testing VSCode extensions
123
+ .vscode-test
124
+
125
+ # yarn v2
126
+ .yarn/cache
127
+ .yarn/unplugged
128
+ .yarn/build-state.yml
129
+ .yarn/install-state.gz
130
+ .pnp.*
131
+
132
+ .DS_Store
.markdownlint.yaml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Default state for all rules
2
+ default: true
3
+
4
+ line-length: false
5
+
6
+ # MD033/no-inline-html - Inline HTML
7
+ MD033:
8
+ # Allowed elements
9
+ allowed_elements: ["style"]
.npmrc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ public-hoist-pattern[]=*@nextui-org/*
2
+ engine-strict=true
3
+ deploy-all-files=true
.prettierignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ **/*.log
2
+ **/.DS_Store
3
+ *.
4
+ *.json
5
+ apps/web/.next
6
+ dist
7
+ node_modules
8
+ pnpm-lock.yaml
.prettierrc.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "tabWidth": 2,
3
+ "singleQuote": true,
4
+ "trailingComma": "all"
5
+ }
.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/typescript@4.9.4/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
+ }
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,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:123456@127.0.0.1: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>