├── .dockerignore
├── .github
└── workflows
│ └── docker-release.yml
├── .gitignore
├── .markdownlint.yaml
├── .npmrc
├── .prettierignore
├── .prettierrc.json
├── .vscode
├── extensions.json
└── settings.json
├── Dockerfile
├── LICENSE
├── README.md
├── apps
├── server
│ ├── .env.local.example
│ ├── .eslintrc.js
│ ├── .gitignore
│ ├── .prettierrc.json
│ ├── README.md
│ ├── docker-bootstrap.sh
│ ├── nest-cli.json
│ ├── package.json
│ ├── prisma-sqlite
│ │ ├── migrations
│ │ │ ├── 20240301104100_init
│ │ │ │ └── migration.sql
│ │ │ ├── 20241214172323_has_history
│ │ │ │ └── migration.sql
│ │ │ └── migration_lock.toml
│ │ └── schema.prisma
│ ├── prisma
│ │ ├── migrations
│ │ │ ├── 20240227153512_init
│ │ │ │ └── migration.sql
│ │ │ ├── 20241212153618_has_history
│ │ │ │ └── migration.sql
│ │ │ └── migration_lock.toml
│ │ └── schema.prisma
│ ├── src
│ │ ├── app.controller.spec.ts
│ │ ├── app.controller.ts
│ │ ├── app.module.ts
│ │ ├── app.service.ts
│ │ ├── configuration.ts
│ │ ├── constants.ts
│ │ ├── feeds
│ │ │ ├── feeds.controller.spec.ts
│ │ │ ├── feeds.controller.ts
│ │ │ ├── feeds.module.ts
│ │ │ ├── feeds.service.spec.ts
│ │ │ └── feeds.service.ts
│ │ ├── main.ts
│ │ ├── prisma
│ │ │ ├── prisma.module.ts
│ │ │ └── prisma.service.ts
│ │ └── trpc
│ │ │ ├── trpc.module.ts
│ │ │ ├── trpc.router.ts
│ │ │ └── trpc.service.ts
│ ├── test
│ │ ├── app.e2e-spec.ts
│ │ └── jest-e2e.json
│ ├── tsconfig.build.json
│ └── tsconfig.json
└── web
│ ├── .env.local.example
│ ├── .eslintrc.cjs
│ ├── .gitignore
│ ├── README.md
│ ├── index.html
│ ├── package.json
│ ├── postcss.config.js
│ ├── src
│ ├── App.tsx
│ ├── components
│ │ ├── GitHubIcon.tsx
│ │ ├── Nav.tsx
│ │ ├── PlusIcon.tsx
│ │ ├── StatusDropdown.tsx
│ │ └── ThemeSwitcher.tsx
│ ├── constants.ts
│ ├── index.css
│ ├── layouts
│ │ └── base.tsx
│ ├── main.tsx
│ ├── pages
│ │ ├── accounts
│ │ │ └── index.tsx
│ │ ├── feeds
│ │ │ ├── index.tsx
│ │ │ └── list.tsx
│ │ └── login
│ │ │ └── index.tsx
│ ├── provider
│ │ ├── theme.tsx
│ │ └── trpc.tsx
│ ├── types.ts
│ ├── utils
│ │ ├── auth.ts
│ │ ├── env.ts
│ │ └── trpc.ts
│ └── vite-env.d.ts
│ ├── tailwind.config.ts
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── assets
├── logo.png
├── nginx.example.conf
├── preview1.png
├── preview2.png
└── preview3.png
├── docker-compose.dev.yml
├── docker-compose.sqlite.yml
├── docker-compose.yml
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── release.sh
├── tsconfig.json
└── wewe-rss-dingtalk
├── Dockerfile
├── README.md
├── docker-compose.yml
├── main.py
└── requirements.txt
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .git
3 | .gitignore
4 | *.md
5 | dist
6 | .env
7 | .next
8 | .DS_Store
9 | ./wewe-rss-dingtalk
--------------------------------------------------------------------------------
/.github/workflows/docker-release.yml:
--------------------------------------------------------------------------------
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
143 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
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
133 |
--------------------------------------------------------------------------------
/.markdownlint.yaml:
--------------------------------------------------------------------------------
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']
10 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | public-hoist-pattern[]=*@nextui-org/*
2 | engine-strict=true
3 | deploy-all-files=true
4 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | **/*.log
2 | **/.DS_Store
3 | *.
4 | *.json
5 | apps/web/.next
6 | dist
7 | node_modules
8 | pnpm-lock.yaml
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 2,
3 | "singleQuote": true,
4 | "trailingComma": "all"
5 | }
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
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 | }
17 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
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 | }
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20.16.0-alpine AS base
2 | ENV PNPM_HOME="/pnpm"
3 | ENV PATH="$PNPM_HOME:$PATH"
4 |
5 | RUN npm i -g pnpm
6 |
7 | FROM base AS build
8 | COPY . /usr/src/app
9 | WORKDIR /usr/src/app
10 |
11 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
12 |
13 | RUN pnpm run -r build
14 |
15 | RUN pnpm deploy --filter=server --prod /app
16 | RUN pnpm deploy --filter=server --prod /app-sqlite
17 |
18 | RUN cd /app && pnpm exec prisma generate
19 |
20 | RUN cd /app-sqlite && \
21 | rm -rf ./prisma && \
22 | mv prisma-sqlite prisma && \
23 | pnpm exec prisma generate
24 |
25 | FROM base AS app-sqlite
26 | COPY --from=build /app-sqlite /app
27 |
28 | WORKDIR /app
29 |
30 | EXPOSE 4000
31 |
32 | ENV NODE_ENV=production
33 | ENV HOST="0.0.0.0"
34 | ENV SERVER_ORIGIN_URL=""
35 | ENV MAX_REQUEST_PER_MINUTE=60
36 | ENV AUTH_CODE=""
37 | ENV DATABASE_URL="file:../data/wewe-rss.db"
38 | ENV DATABASE_TYPE="sqlite"
39 |
40 | RUN chmod +x ./docker-bootstrap.sh
41 |
42 | CMD ["./docker-bootstrap.sh"]
43 |
44 |
45 | FROM base AS app
46 | COPY --from=build /app /app
47 |
48 | WORKDIR /app
49 |
50 | EXPOSE 4000
51 |
52 | ENV NODE_ENV=production
53 | ENV HOST="0.0.0.0"
54 | ENV SERVER_ORIGIN_URL=""
55 | ENV MAX_REQUEST_PER_MINUTE=60
56 | ENV AUTH_CODE=""
57 | ENV DATABASE_URL=""
58 |
59 | RUN chmod +x ./docker-bootstrap.sh
60 |
61 | CMD ["./docker-bootstrap.sh"]
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
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.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 | # [WeWe RSS](https://github.com/cooderl/wewe-rss)
5 |
6 | 更优雅的微信公众号订阅方式。
7 |
8 | 
9 |
10 |
11 | ## ✨ 功能
12 |
13 | - v2.x版本使用全新接口,更加稳定
14 | - 支持微信公众号订阅(基于微信读书)
15 | - 获取公众号历史发布文章
16 | - 后台自动定时更新内容
17 | - 微信公众号RSS生成(支持`.atom`、`.rss`、`.json`格式)
18 | - 支持全文内容输出,让阅读无障碍
19 | - 所有订阅源导出OPML
20 |
21 | ### 高级功能
22 |
23 | - **标题过滤**:支持通过`/feeds/all.(json|rss|atom)`接口和`/feeds/:feed`对标题进行过滤
24 | ```
25 | {{ORIGIN_URL}}/feeds/all.atom?title_include=张三
26 | {{ORIGIN_URL}}/feeds/MP_WXS_123.json?limit=30&title_include=张三|李四|王五&title_exclude=张三丰|赵六
27 | ```
28 |
29 | - **手动更新**:支持通过`/feeds/:feed`接口触发单个feedid更新
30 | ```
31 | {{ORIGIN_URL}}/feeds/MP_WXS_123.rss?update=true
32 | ```
33 |
34 | ## 🚀 部署
35 |
36 | ### 一键部署
37 |
38 | - [Deploy on Zeabur](https://zeabur.com/templates/DI9BBD)
39 | - [Railway](https://railway.app/)
40 | - [Hugging Face部署参考](https://github.com/cooderl/wewe-rss/issues/32)
41 |
42 | ### Docker Compose 部署
43 |
44 | 参考 [docker-compose.yml](https://github.com/cooderl/wewe-rss/blob/main/docker-compose.yml) 和 [docker-compose.sqlite.yml](https://github.com/cooderl/wewe-rss/blob/main/docker-compose.sqlite.yml)
45 |
46 | ### Docker 命令启动
47 |
48 | #### MySQL (推荐)
49 |
50 | 1. 创建docker网络
51 | ```sh
52 | docker network create wewe-rss
53 | ```
54 |
55 | 2. 启动 MySQL 数据库
56 | ```sh
57 | docker run -d \
58 | --name db \
59 | -e MYSQL_ROOT_PASSWORD=123456 \
60 | -e TZ='Asia/Shanghai' \
61 | -e MYSQL_DATABASE='wewe-rss' \
62 | -v db_data:/var/lib/mysql \
63 | --network wewe-rss \
64 | mysql:8.3.0 --mysql-native-password=ON
65 | ```
66 |
67 | 3. 启动 Server
68 | ```sh
69 | docker run -d \
70 | --name wewe-rss \
71 | -p 4000:4000 \
72 | -e DATABASE_URL='mysql://root:123456@db:3306/wewe-rss?schema=public&connect_timeout=30&pool_timeout=30&socket_timeout=30' \
73 | -e AUTH_CODE=123567 \
74 | --network wewe-rss \
75 | cooderl/wewe-rss:latest
76 | ```
77 |
78 | [Nginx配置参考](https://raw.githubusercontent.com/cooderl/wewe-rss/main/assets/nginx.example.conf)
79 |
80 | #### SQLite (不推荐)
81 |
82 | ```sh
83 | docker run -d \
84 | --name wewe-rss \
85 | -p 4000:4000 \
86 | -e DATABASE_TYPE=sqlite \
87 | -e AUTH_CODE=123567 \
88 | -v $(pwd)/data:/app/data \
89 | cooderl/wewe-rss-sqlite:latest
90 | ```
91 |
92 | ### 本地部署
93 |
94 | 使用 `pnpm install && pnpm run -r build && pnpm run start:server` 命令 (可配合 pm2 守护进程)
95 |
96 | **详细步骤** (SQLite示例):
97 |
98 | ```shell
99 | # 需要提前声明环境变量,因为prisma会根据环境变量生成对应的数据库连接
100 | export DATABASE_URL="file:../data/wewe-rss.db"
101 | export DATABASE_TYPE="sqlite"
102 | # 删除mysql相关文件,避免prisma生成mysql连接
103 | rm -rf apps/server/prisma
104 | mv apps/server/prisma-sqlite apps/server/prisma
105 | # 生成prisma client
106 | npx prisma generate --schema apps/server/prisma/schema.prisma
107 | # 生成数据库表
108 | npx prisma migrate deploy --schema apps/server/prisma/schema.prisma
109 | # 构建并运行
110 | pnpm run -r build
111 | pnpm run start:server
112 | ```
113 |
114 | ## ⚙️ 环境变量
115 |
116 | | 变量名 | 说明 | 默认值 |
117 | | ------------------------ | ----------------------------------------------------------------------- | --------------------------- |
118 | | `DATABASE_URL` | **必填** 数据库地址,例如 `mysql://root:123456@127.0.0.1:3306/wewe-rss` | - |
119 | | `DATABASE_TYPE` | 数据库类型,使用 SQLite 时需填写 `sqlite` | - |
120 | | `AUTH_CODE` | 服务端接口请求授权码,空字符或不设置将不启用 (`/feeds`路径不需要) | - |
121 | | `SERVER_ORIGIN_URL` | 服务端访问地址,用于生成RSS完整路径 | - |
122 | | `MAX_REQUEST_PER_MINUTE` | 每分钟最大请求次数 | 60 |
123 | | `FEED_MODE` | 输出模式,可选值 `fulltext` (会使接口响应变慢,占用更多内存) | - |
124 | | `CRON_EXPRESSION` | 定时更新订阅源Cron表达式 | `35 5,17 * * *` |
125 | | `UPDATE_DELAY_TIME` | 连续更新延迟时间,减少被关小黑屋 | `60s` |
126 | | `ENABLE_CLEAN_HTML` | 是否开启正文html清理 | `false` |
127 | | `PLATFORM_URL` | 基础服务URL | `https://weread.111965.xyz` |
128 |
129 | > **注意**: 国内DNS解析问题可使用 `https://weread.965111.xyz` 加速访问
130 |
131 | ## 🔔 钉钉通知
132 |
133 | 进入 wewe-rss-dingtalk 目录按照 README.md 指引部署
134 |
135 | ## 📱 使用方式
136 |
137 | 1. 进入账号管理,点击添加账号,微信扫码登录微信读书账号。
138 |
139 | **注意不要勾选24小时后自动退出**
140 |
141 |
142 |
143 |
144 | 2. 进入公众号源,点击添加,通过提交微信公众号分享链接,订阅微信公众号。
145 | **添加频率过高容易被封控,等24小时解封**
146 |
147 |
148 |
149 | ## 🔑 账号状态说明
150 |
151 | | 状态 | 说明 |
152 | | ---------- | ------------------------------------------------------------------- |
153 | | 今日小黑屋 | 账号被封控,等一天恢复。账号正常时可通过重启服务/容器清除小黑屋记录 |
154 | | 禁用 | 不使用该账号 |
155 | | 失效 | 账号登录状态失效,需要重新登录 |
156 |
157 | ## 💻 本地开发
158 |
159 | 1. 安装 nodejs 20 和 pnpm
160 | 2. 修改环境变量:
161 | ```
162 | cp ./apps/web/.env.local.example ./apps/web/.env
163 | cp ./apps/server/.env.local.example ./apps/server/.env
164 | ```
165 | 3. 执行 `pnpm install && pnpm run build:web && pnpm dev`
166 |
167 | ⚠️ **注意:此命令仅用于本地开发,不要用于部署!**
168 | 4. 前端访问 `http://localhost:5173`,后端访问 `http://localhost:4000`
169 |
170 | ## ⚠️ 风险声明
171 |
172 | 为了确保本项目的持久运行,某些接口请求将通过 `weread.111965.xyz` 进行转发。请放心,该转发服务不会保存任何数据。
173 |
174 | ## ❤️ 赞助
175 |
176 | 如果觉得 WeWe RSS 项目对你有帮助,可以给我来一杯啤酒!
177 |
178 | **PayPal**: [paypal.me/cooderl](https://paypal.me/cooderl)
179 |
180 | **微信**:
181 |
182 |
183 | ## 👨💻 贡献者
184 |
185 |
186 |
187 |
188 |
189 | ## 📄 License
190 |
191 | [MIT](https://raw.githubusercontent.com/cooderl/wewe-rss/main/LICENSE) @cooderl
192 |
--------------------------------------------------------------------------------
/apps/server/.env.local.example:
--------------------------------------------------------------------------------
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 | # 是否开启正文html清理
28 | ENABLE_CLEAN_HTML=false
29 |
30 | # 连续更新延迟时间(秒)
31 | UPDATE_DELAY_TIME=60
32 |
33 | # 读书转发服务,不需要修改
34 | PLATFORM_URL="https://weread.111965.xyz"
--------------------------------------------------------------------------------
/apps/server/.eslintrc.js:
--------------------------------------------------------------------------------
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 | };
26 |
--------------------------------------------------------------------------------
/apps/server/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | # Keep environment variables out of version control
3 | .env
4 |
5 | client
6 | data
--------------------------------------------------------------------------------
/apps/server/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 2,
3 | "singleQuote": true,
4 | "trailingComma": "all"
5 | }
--------------------------------------------------------------------------------
/apps/server/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
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 | A progressive Node.js framework for building efficient and scalable server-side applications.
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
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).
74 |
--------------------------------------------------------------------------------
/apps/server/docker-bootstrap.sh:
--------------------------------------------------------------------------------
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:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/nest-cli",
3 | "collection": "@nestjs/schematics",
4 | "sourceRoot": "src",
5 | "compilerOptions": {
6 | "deleteOutDir": true
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/apps/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server",
3 | "version": "2.6.1",
4 | "description": "",
5 | "author": "",
6 | "private": true,
7 | "license": "UNLICENSED",
8 | "scripts": {
9 | "build": "nest build",
10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
11 | "start": "nest start",
12 | "dev": "nest start --watch",
13 | "start:debug": "nest start --debug --watch",
14 | "start:prod": "node dist/main",
15 | "start:migrate:prod": "prisma migrate deploy && npm run start:prod",
16 | "postinstall": "npx prisma generate",
17 | "migrate": "pnpm prisma migrate dev",
18 | "studio": "pnpm prisma studio",
19 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
20 | "test": "jest",
21 | "test:watch": "jest --watch",
22 | "test:cov": "jest --coverage",
23 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
24 | "test:e2e": "jest --config ./test/jest-e2e.json"
25 | },
26 | "dependencies": {
27 | "@cjs-exporter/p-map": "^5.5.0",
28 | "@nestjs/common": "^10.3.3",
29 | "@nestjs/config": "^3.2.0",
30 | "@nestjs/core": "^10.3.3",
31 | "@nestjs/platform-express": "^10.3.3",
32 | "@nestjs/schedule": "^4.0.1",
33 | "@nestjs/throttler": "^5.1.2",
34 | "@prisma/client": "5.10.1",
35 | "@trpc/server": "^10.45.1",
36 | "axios": "^1.6.7",
37 | "cheerio": "1.0.0-rc.12",
38 | "class-transformer": "^0.5.1",
39 | "class-validator": "^0.14.1",
40 | "dayjs": "^1.11.10",
41 | "express": "^4.18.2",
42 | "feed": "^4.2.2",
43 | "got": "11.8.6",
44 | "hbs": "^4.2.0",
45 | "html-minifier": "^4.0.0",
46 | "lru-cache": "^10.2.2",
47 | "prisma": "^5.10.2",
48 | "reflect-metadata": "^0.2.1",
49 | "rxjs": "^7.8.1",
50 | "zod": "^3.22.4"
51 | },
52 | "devDependencies": {
53 | "@nestjs/cli": "^10.3.2",
54 | "@nestjs/schematics": "^10.1.1",
55 | "@nestjs/testing": "^10.3.3",
56 | "@types/express": "^4.17.21",
57 | "@types/html-minifier": "^4.0.5",
58 | "@types/jest": "^29.5.12",
59 | "@types/node": "^20.11.19",
60 | "@types/supertest": "^6.0.2",
61 | "@typescript-eslint/eslint-plugin": "^7.0.2",
62 | "@typescript-eslint/parser": "^7.0.2",
63 | "eslint": "^8.56.0",
64 | "eslint-config-prettier": "^9.1.0",
65 | "eslint-plugin-prettier": "^5.1.3",
66 | "jest": "^29.7.0",
67 | "prettier": "^3.2.5",
68 | "source-map-support": "^0.5.21",
69 | "supertest": "^6.3.4",
70 | "ts-jest": "^29.1.2",
71 | "ts-loader": "^9.5.1",
72 | "ts-node": "^10.9.2",
73 | "tsconfig-paths": "^4.2.0",
74 | "typescript": "^5.3.3"
75 | },
76 | "jest": {
77 | "moduleFileExtensions": [
78 | "js",
79 | "json",
80 | "ts"
81 | ],
82 | "rootDir": "src",
83 | "testRegex": ".*\\.spec\\.ts$",
84 | "transform": {
85 | "^.+\\.(t|j)s$": "ts-jest"
86 | },
87 | "collectCoverageFrom": [
88 | "**/*.(t|j)s"
89 | ],
90 | "coverageDirectory": "../coverage",
91 | "testEnvironment": "node"
92 | }
93 | }
--------------------------------------------------------------------------------
/apps/server/prisma-sqlite/migrations/20240301104100_init/migration.sql:
--------------------------------------------------------------------------------
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 | );
34 |
--------------------------------------------------------------------------------
/apps/server/prisma-sqlite/migrations/20241214172323_has_history/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "feeds" ADD COLUMN "has_history" INTEGER DEFAULT 1;
3 |
--------------------------------------------------------------------------------
/apps/server/prisma-sqlite/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
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:
--------------------------------------------------------------------------------
1 | datasource db {
2 | provider = "sqlite"
3 | url = env("DATABASE_URL")
4 | }
5 |
6 | generator client {
7 | provider = "prisma-client-js"
8 | binaryTargets = ["native", "linux-musl"] // 生成linux可执行文件
9 | }
10 |
11 | // 读书账号
12 | model Account {
13 | id String @id
14 | token String @map("token")
15 | name String @map("name")
16 | // 状态 0:失效 1:启用 2:禁用
17 | status Int @default(1) @map("status")
18 | createdAt DateTime @default(now()) @map("created_at")
19 | updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
20 |
21 | @@map("accounts")
22 | }
23 |
24 | // 订阅源
25 | model Feed {
26 | id String @id
27 | mpName String @map("mp_name")
28 | mpCover String @map("mp_cover")
29 | mpIntro String @map("mp_intro")
30 | // 状态 0:失效 1:启用 2:禁用
31 | status Int @default(1) @map("status")
32 |
33 | // article最后同步时间
34 | syncTime Int @default(0) @map("sync_time")
35 |
36 | // 信息更新时间
37 | updateTime Int @map("update_time")
38 |
39 | createdAt DateTime @default(now()) @map("created_at")
40 | updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
41 |
42 | // 是否有历史文章 1 是 0 否
43 | hasHistory Int? @default(1) @map("has_history")
44 |
45 | @@map("feeds")
46 | }
47 |
48 | model Article {
49 | id String @id
50 | mpId String @map("mp_id")
51 | title String @map("title")
52 | picUrl String @map("pic_url")
53 | publishTime Int @map("publish_time")
54 |
55 | createdAt DateTime @default(now()) @map("created_at")
56 | updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
57 |
58 | @@map("articles")
59 | }
60 |
--------------------------------------------------------------------------------
/apps/server/prisma/migrations/20240227153512_init/migration.sql:
--------------------------------------------------------------------------------
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;
40 |
--------------------------------------------------------------------------------
/apps/server/prisma/migrations/20241212153618_has_history/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE `feeds` ADD COLUMN `has_history` INTEGER NULL DEFAULT 1;
3 |
--------------------------------------------------------------------------------
/apps/server/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
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:
--------------------------------------------------------------------------------
1 | datasource db {
2 | provider = "mysql"
3 | url = env("DATABASE_URL")
4 | }
5 |
6 | generator client {
7 | provider = "prisma-client-js"
8 | binaryTargets = ["native", "linux-musl"] // 生成linux可执行文件
9 | }
10 |
11 | // 读书账号
12 | model Account {
13 | id String @id @db.VarChar(255)
14 | token String @map("token") @db.VarChar(2048)
15 | name String @map("name") @db.VarChar(1024)
16 | // 状态 0:失效 1:启用 2:禁用
17 | status Int @default(1) @map("status") @db.Int()
18 | createdAt DateTime @default(now()) @map("created_at")
19 | updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
20 |
21 | @@map("accounts")
22 | }
23 |
24 | // 订阅源
25 | model Feed {
26 | id String @id @db.VarChar(255)
27 | mpName String @map("mp_name") @db.VarChar(512)
28 | mpCover String @map("mp_cover") @db.VarChar(1024)
29 | mpIntro String @map("mp_intro") @db.Text()
30 | // 状态 0:失效 1:启用 2:禁用
31 | status Int @default(1) @map("status") @db.Int()
32 |
33 | // article最后同步时间
34 | syncTime Int @default(0) @map("sync_time")
35 |
36 | // 信息更新时间
37 | updateTime Int @map("update_time")
38 |
39 | createdAt DateTime @default(now()) @map("created_at")
40 | updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
41 |
42 | // 是否有历史文章 1 是 0 否
43 | hasHistory Int? @default(1) @map("has_history")
44 |
45 | @@map("feeds")
46 | }
47 |
48 | model Article {
49 | id String @id @db.VarChar(255)
50 | mpId String @map("mp_id") @db.VarChar(255)
51 | title String @map("title") @db.VarChar(255)
52 | picUrl String @map("pic_url") @db.VarChar(255)
53 | publishTime Int @map("publish_time")
54 |
55 | createdAt DateTime @default(now()) @map("created_at")
56 | updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
57 |
58 | @@map("articles")
59 | }
60 |
--------------------------------------------------------------------------------
/apps/server/src/app.controller.spec.ts:
--------------------------------------------------------------------------------
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);
15 | });
16 |
17 | describe('root', () => {
18 | it('should return "Hello World!"', () => {
19 | expect(appController.getHello()).toBe('Hello World!');
20 | });
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/apps/server/src/app.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get, Response, Render } from '@nestjs/common';
2 | import { AppService } from './app.service';
3 | import { ConfigService } from '@nestjs/config';
4 | import { ConfigurationType } from './configuration';
5 | import { Response as Res } from 'express';
6 |
7 | @Controller()
8 | export class AppController {
9 | constructor(
10 | private readonly appService: AppService,
11 | private readonly configService: ConfigService,
12 | ) {}
13 |
14 | @Get()
15 | getHello(): string {
16 | return this.appService.getHello();
17 | }
18 |
19 | @Get('/robots.txt')
20 | forRobot(): string {
21 | return 'User-agent: *\nDisallow: /';
22 | }
23 |
24 | @Get('favicon.ico')
25 | getFavicon(@Response() res: Res) {
26 | const imgContent =
27 | 'iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAMAAABrrFhUAAAAAXNSR0IArs4c6QAAACRQTFRFR3BMsN2eke1itNumku5htNulm+l0ke1hc91PVc09OL0rGq0Z17o6fwAAAAV0Uk5TAGyAv79qLUngAAAFdUlEQVR42u3cQWPbIAyGYQlDkOT//3/X9bBLF3/gkgQJ3uuSA4+Ftxp3tNvtdrvdbrfb7Xa76zjNGjG9Ns65zl5O6WWrr15K0ZePS0xjSxUUewq4Oixz8MuPSw7W70EgVb+lMetfWiBV36Xg68cx/arqvhx8AHBpwPqX3QQ1RHnAACw6AjVI+f4ArD0CNUz57gCsPQI1UHl1gBp8B+B4A3RXQ/Uo3GnANVallD6DFA3gO14ZABBEB3j0CuRg6/8HUI6YAHgCgEB8gE6BGhigHKsDFF4doPDqAIVXBzhWByi8OsCxOkDh1QGO1QEKb4DFAY7VAcryAPxKADE7v7KvVFVkRoDjhQB6/shUZRkAPZ9kKvMAlJcB6HmVqkwCwK8CsBOlsQHOhkyjA+BUgwLI2ZxGnwCcRr8J4jQ6AE6jAdSzNw0GIP0CGgqg6tmdugLAieh3ZtZM4BUAJ6pqDQKuAXANCOoeACMAgeAA2MCiA2ADjQCAUyAQGAATaHAATGDBATCBSXAATCDBAbCABgfABLIMQBUDAh4B/p0NqqrcHAJxDACOg9oELNgDEdXebWBuAcCTr2Y0cwAA1gIM0LfUJYCe12nH9yT66TAWCHo0pq0CFgygX0DjHo83Ckjcs0FtEwgG0C9grgD635DAfhL5cFQbBCz04ag2+OlsADi1DgHsNy0APiE2GyFgDgCGngj+UBPPANhA4W3AXANgA4WbQHwD4OMwtAks+vsBijaB+AbAQyBoBHwDYAKDI+AbAP+0ZADKnAPgIVDwXEGcA2ABuf6Qhn9Fxq5HwLwD4B+Z9VpJvAPgW6GAEXAOgGfArkfAPQAWkMtPiHOA/nMQA3vAA4B8BwRaR8AbgJhdnwobGoEfPJ4AxG49Awd7wA2AWNMTYDAC4hZA7jz9wyPgAAC8/4ih7ApAnADozad/eA/MB4DnH1xD8AmXAHoBYEAL7AEXAHpeJfA+CG4C3n93GI+AXPyp+n8/AI+AXXBagPcErQ/A3AHY+ds94BzgRAn6hlwMVAgANDN6MR8SAQDtAXMNIP0AteOvAQ0xAWgPRAeAUyPPdSzAm6J1AyAAdQ0gN96PDQVQBwOoLwC8Bxq+Ys8BTvcvS2tsADwCNTQAFpD6v/QCQBwCSMcGwM99/PxLEAtovQFgXgCwgNRnXX1OZ3wegFP0f6O0X2Vz8FAUvxhs0jwxTzDnPRrDBibSPjDy5FdwzHy+IiONWA2T4gqgP1UzlVpDA+A2wAbYABtgA2yADbABNsAG2ACfA8jB1t8PsCdg8QlINVZlA3QC8OoAFPweiAHy6gAcewdgAFoeIMfeARiA1wGIPwIFAEQfgQcACD8C5SYAxx4ADEA59gAUggUbgH4ADr3+QrgUeAMUphUEHgAAlsKuv1BbKer6meILPMoIAOKQ6y/UUQq4fqaeUoq2/kKdpVjLL0zdpRx9/biUfB2EYYD+0lc5+7v4eP39cSll2DUbVGmKaUzHKIDy3phomMCYmX1zNCwuDtd/MI2L/V3+g4bmbv1MMwE8ivf1k7PxZxpd8OXjfO3+mQBcXf3xAA9Xqx8PkI+Wfrnq7/grIpoLIDM1xceYLT8bQKLmOCBAZuqIwwEk6oxjATB1x3MD5NpRplsdUQCYbsYhADLT7TgAQKJfxbMCpDGXH8eTAvCoy4/jKQFo2OXHsVOARKPiY0KAXEFMA+P5ABiMP42NpwMgMP7D49kAMrj7DY8nA2B0+cd3TAVAGVz+Dw0BvS0Gl/9DAvS+GFz+jxAc9MYSuPyfEGD6nECi98QA4DMEOTPRBAL09tLf3uzOBxiA+DEYgFUFmGhtAqK1BZgWi8H61yI4mJaM+SjlOJhpt9vtdrvdbrfbNfcHKaL2IynIYcEAAAAASUVORK5CYII=';
28 | const imgBuffer = Buffer.from(imgContent, 'base64');
29 | res.setHeader('Content-Type', 'image/png');
30 | res.send(imgBuffer);
31 | }
32 |
33 | @Get('/dash*')
34 | @Render('index.hbs')
35 | dashRender() {
36 | const { originUrl: weweRssServerOriginUrl } =
37 | this.configService.get('feed')!;
38 | const { code } = this.configService.get('auth')!;
39 |
40 | return {
41 | weweRssServerOriginUrl,
42 | enabledAuthCode: !!code,
43 | iconUrl: weweRssServerOriginUrl
44 | ? `${weweRssServerOriginUrl}/favicon.ico`
45 | : 'https://r2-assets.111965.xyz/wewe-rss.png',
46 | };
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/apps/server/src/app.module.ts:
--------------------------------------------------------------------------------
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('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 {}
40 |
--------------------------------------------------------------------------------
/apps/server/src/app.service.ts:
--------------------------------------------------------------------------------
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 |
12 | `;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/apps/server/src/configuration.ts:
--------------------------------------------------------------------------------
1 | const configuration = () => {
2 | const isProd = process.env.NODE_ENV === 'production';
3 | const port = process.env.PORT || 4000;
4 | const host = process.env.HOST || '0.0.0.0';
5 |
6 | const maxRequestPerMinute = parseInt(
7 | `${process.env.MAX_REQUEST_PER_MINUTE}|| 60`,
8 | );
9 |
10 | const authCode = process.env.AUTH_CODE;
11 | const platformUrl = process.env.PLATFORM_URL || 'https://weread.111965.xyz';
12 | const originUrl = process.env.SERVER_ORIGIN_URL || '';
13 |
14 | const feedMode = process.env.FEED_MODE as 'fulltext' | '';
15 |
16 | const databaseType = process.env.DATABASE_TYPE || 'mysql';
17 |
18 | const updateDelayTime = parseInt(`${process.env.UPDATE_DELAY_TIME} || 60`);
19 |
20 | const enableCleanHtml = process.env.ENABLE_CLEAN_HTML === 'true';
21 | return {
22 | server: { isProd, port, host },
23 | throttler: { maxRequestPerMinute },
24 | auth: { code: authCode },
25 | platform: { url: platformUrl },
26 | feed: {
27 | originUrl,
28 | mode: feedMode,
29 | updateDelayTime,
30 | enableCleanHtml,
31 | },
32 | database: {
33 | type: databaseType,
34 | },
35 | };
36 | };
37 |
38 | export default configuration;
39 |
40 | export type ConfigurationType = ReturnType;
41 |
--------------------------------------------------------------------------------
/apps/server/src/constants.ts:
--------------------------------------------------------------------------------
1 | export const statusMap = {
2 | // 0:失效 1:启用 2:禁用
3 | INVALID: 0,
4 | ENABLE: 1,
5 | DISABLE: 2,
6 | };
7 |
8 | export const feedTypes = ['rss', 'atom', 'json'] as const;
9 |
10 | export const feedMimeTypeMap = {
11 | rss: 'application/rss+xml; charset=utf-8',
12 | atom: 'application/atom+xml; charset=utf-8',
13 | json: 'application/feed+json; charset=utf-8',
14 | } as const;
15 |
16 | export const defaultCount = 20;
17 |
--------------------------------------------------------------------------------
/apps/server/src/feeds/feeds.controller.spec.ts:
--------------------------------------------------------------------------------
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);
13 | });
14 |
15 | it('should be defined', () => {
16 | expect(controller).toBeDefined();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/apps/server/src/feeds/feeds.controller.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Controller,
3 | DefaultValuePipe,
4 | Get,
5 | Logger,
6 | Param,
7 | ParseIntPipe,
8 | Query,
9 | Request,
10 | Response,
11 | } from '@nestjs/common';
12 | import { FeedsService } from './feeds.service';
13 | import { Response as Res, Request as Req } from 'express';
14 |
15 | @Controller('feeds')
16 | export class FeedsController {
17 | private readonly logger = new Logger(this.constructor.name);
18 |
19 | constructor(private readonly feedsService: FeedsService) {}
20 |
21 | @Get('/')
22 | async getFeedList() {
23 | return this.feedsService.getFeedList();
24 | }
25 |
26 | @Get('/all.(json|rss|atom)')
27 | async getFeeds(
28 | @Request() req: Req,
29 | @Response() res: Res,
30 | @Query('limit', new DefaultValuePipe(30), ParseIntPipe) limit: number = 30,
31 | @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number = 1,
32 | @Query('mode') mode: string,
33 | @Query('title_include') title_include: string,
34 | @Query('title_exclude') title_exclude: string,
35 | ) {
36 | const path = req.path;
37 | const type = path.split('.').pop() || '';
38 |
39 | const { content, mimeType } = await this.feedsService.handleGenerateFeed({
40 | type,
41 | limit,
42 | page,
43 | mode,
44 | title_include,
45 | title_exclude,
46 | });
47 |
48 | res.setHeader('Content-Type', mimeType);
49 | res.send(content);
50 | }
51 |
52 | @Get('/:feed')
53 | async getFeed(
54 | @Response() res: Res,
55 | @Param('feed') feed: string,
56 | @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number = 10,
57 | @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number = 1,
58 | @Query('mode') mode: string,
59 | @Query('title_include') title_include: string,
60 | @Query('title_exclude') title_exclude: string,
61 | @Query('update') update: boolean = false,
62 | ) {
63 | const [id, type] = feed.split('.');
64 | this.logger.log('getFeed: ', id);
65 |
66 | if (update) {
67 | this.feedsService.updateFeed(id);
68 | }
69 |
70 | const { content, mimeType } = await this.feedsService.handleGenerateFeed({
71 | id,
72 | type,
73 | limit,
74 | page,
75 | mode,
76 | title_include,
77 | title_exclude,
78 | });
79 |
80 | res.setHeader('Content-Type', mimeType);
81 | res.send(content);
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/apps/server/src/feeds/feeds.module.ts:
--------------------------------------------------------------------------------
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 {}
13 |
--------------------------------------------------------------------------------
/apps/server/src/feeds/feeds.service.spec.ts:
--------------------------------------------------------------------------------
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);
13 | });
14 |
15 | it('should be defined', () => {
16 | expect(service).toBeDefined();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/apps/server/src/feeds/feeds.service.ts:
--------------------------------------------------------------------------------
1 | import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
2 | import { PrismaService } from '@server/prisma/prisma.service';
3 | import { Cron } from '@nestjs/schedule';
4 | import { TrpcService } from '@server/trpc/trpc.service';
5 | import { feedMimeTypeMap, feedTypes } from '@server/constants';
6 | import { ConfigService } from '@nestjs/config';
7 | import { Article, Feed as FeedInfo } from '@prisma/client';
8 | import { ConfigurationType } from '@server/configuration';
9 | import { Feed, Item } from 'feed';
10 | import got, { Got } from 'got';
11 | import { load } from 'cheerio';
12 | import { minify } from 'html-minifier';
13 | import { LRUCache } from 'lru-cache';
14 | import pMap from '@cjs-exporter/p-map';
15 |
16 | console.log('CRON_EXPRESSION: ', process.env.CRON_EXPRESSION);
17 |
18 | const mpCache = new LRUCache({
19 | max: 5000,
20 | });
21 |
22 | @Injectable()
23 | export class FeedsService {
24 | private readonly logger = new Logger(this.constructor.name);
25 |
26 | private request: Got;
27 | constructor(
28 | private readonly prismaService: PrismaService,
29 | private readonly trpcService: TrpcService,
30 | private readonly configService: ConfigService,
31 | ) {
32 | this.request = got.extend({
33 | retry: {
34 | limit: 3,
35 | methods: ['GET'],
36 | },
37 | timeout: 8 * 1e3,
38 | headers: {
39 | accept:
40 | 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
41 | 'accept-encoding': 'gzip, deflate, br',
42 | 'accept-language': 'en-US,en;q=0.9',
43 | 'cache-control': 'max-age=0',
44 | 'sec-ch-ua':
45 | '" Not A;Brand";v="99", "Chromium";v="101", "Google Chrome";v="101"',
46 | 'sec-ch-ua-mobile': '?0',
47 | 'sec-ch-ua-platform': '"macOS"',
48 | 'sec-fetch-dest': 'document',
49 | 'sec-fetch-mode': 'navigate',
50 | 'sec-fetch-site': 'none',
51 | 'sec-fetch-user': '?1',
52 | 'upgrade-insecure-requests': '1',
53 | 'user-agent':
54 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36',
55 | },
56 | hooks: {
57 | beforeRetry: [
58 | async (options, error, retryCount) => {
59 | this.logger.warn(`retrying ${options.url}...`);
60 | return new Promise((resolve) =>
61 | setTimeout(resolve, 2e3 * (retryCount || 1)),
62 | );
63 | },
64 | ],
65 | },
66 | });
67 | }
68 |
69 | @Cron(process.env.CRON_EXPRESSION || '35 5,17 * * *', {
70 | name: 'updateFeeds',
71 | timeZone: 'Asia/Shanghai',
72 | })
73 | async handleUpdateFeedsCron() {
74 | this.logger.debug('Called handleUpdateFeedsCron');
75 |
76 | const feeds = await this.prismaService.feed.findMany({
77 | where: { status: 1 },
78 | });
79 | this.logger.debug('feeds length:' + feeds.length);
80 |
81 | const updateDelayTime =
82 | this.configService.get(
83 | 'feed',
84 | )!.updateDelayTime;
85 |
86 | for (const feed of feeds) {
87 | this.logger.debug('feed', feed.id);
88 | try {
89 | await this.trpcService.refreshMpArticlesAndUpdateFeed(feed.id);
90 |
91 | await new Promise((resolve) =>
92 | setTimeout(resolve, updateDelayTime * 1e3),
93 | );
94 | } catch (err) {
95 | this.logger.error('handleUpdateFeedsCron error', err);
96 | } finally {
97 | // wait 30s for next feed
98 | await new Promise((resolve) => setTimeout(resolve, 30 * 1e3));
99 | }
100 | }
101 | }
102 |
103 | async cleanHtml(source: string) {
104 | const $ = load(source, { decodeEntities: false });
105 |
106 | const dirtyHtml = $.html($('.rich_media_content'));
107 |
108 | const html = dirtyHtml
109 | .replace(/data-src=/g, 'src=')
110 | .replace(/opacity: 0( !important)?;/g, '')
111 | .replace(/visibility: hidden;/g, '');
112 |
113 | const content =
114 | '' +
115 | html;
116 |
117 | const result = minify(content, {
118 | removeAttributeQuotes: true,
119 | collapseWhitespace: true,
120 | });
121 |
122 | return result;
123 | }
124 |
125 | async getHtmlByUrl(url: string) {
126 | const html = await this.request(url, { responseType: 'text' }).text();
127 | if (
128 | this.configService.get('feed')!.enableCleanHtml
129 | ) {
130 | const result = await this.cleanHtml(html);
131 | return result;
132 | }
133 |
134 | return html;
135 | }
136 |
137 | async tryGetContent(id: string) {
138 | let content = mpCache.get(id);
139 | if (content) {
140 | return content;
141 | }
142 | const url = `https://mp.weixin.qq.com/s/${id}`;
143 | content = await this.getHtmlByUrl(url).catch((e) => {
144 | this.logger.error(`getHtmlByUrl(${url}) error: ${e.message}`);
145 |
146 | return '获取全文失败,请重试~';
147 | });
148 | mpCache.set(id, content);
149 | return content;
150 | }
151 |
152 | async renderFeed({
153 | type,
154 | feedInfo,
155 | articles,
156 | mode,
157 | }: {
158 | type: string;
159 | feedInfo: FeedInfo;
160 | articles: Article[];
161 | mode?: string;
162 | }) {
163 | const { originUrl, mode: globalMode } =
164 | this.configService.get('feed')!;
165 |
166 | const link = `${originUrl}/feeds/${feedInfo.id}.${type}`;
167 |
168 | const feed = new Feed({
169 | title: feedInfo.mpName,
170 | description: feedInfo.mpIntro,
171 | id: link,
172 | link: link,
173 | language: 'zh-cn', // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes
174 | image: feedInfo.mpCover,
175 | favicon: feedInfo.mpCover,
176 | copyright: '',
177 | updated: new Date(feedInfo.updateTime * 1e3),
178 | generator: 'WeWe-RSS',
179 | author: { name: feedInfo.mpName },
180 | });
181 |
182 | feed.addExtension({
183 | name: 'generator',
184 | objects: `WeWe-RSS`,
185 | });
186 |
187 | const feeds = await this.prismaService.feed.findMany({
188 | select: { id: true, mpName: true },
189 | });
190 |
191 | /**mode 高于 globalMode。如果 mode 值存在,取 mode 值*/
192 | const enableFullText =
193 | typeof mode === 'string'
194 | ? mode === 'fulltext'
195 | : globalMode === 'fulltext';
196 |
197 | const showAuthor = feedInfo.id === 'all';
198 |
199 | const mapper = async (item) => {
200 | const { title, id, publishTime, picUrl, mpId } = item;
201 | const link = `https://mp.weixin.qq.com/s/${id}`;
202 |
203 | const mpName = feeds.find((item) => item.id === mpId)?.mpName || '-';
204 | const published = new Date(publishTime * 1e3);
205 |
206 | let content = '';
207 | if (enableFullText) {
208 | content = await this.tryGetContent(id);
209 | }
210 |
211 | feed.addItem({
212 | id,
213 | title,
214 | link: link,
215 | guid: link,
216 | content,
217 | date: published,
218 | image: picUrl,
219 | author: showAuthor ? [{ name: mpName }] : undefined,
220 | });
221 | };
222 |
223 | await pMap(articles, mapper, { concurrency: 2, stopOnError: false });
224 |
225 | return feed;
226 | }
227 |
228 | async handleGenerateFeed({
229 | id,
230 | type,
231 | limit,
232 | page,
233 | mode,
234 | title_include,
235 | title_exclude,
236 | }: {
237 | id?: string;
238 | type: string;
239 | limit: number;
240 | page: number;
241 | mode?: string;
242 | title_include?: string;
243 | title_exclude?: string;
244 | }) {
245 | if (!feedTypes.includes(type as any)) {
246 | type = 'atom';
247 | }
248 |
249 | let articles: Article[];
250 | let feedInfo: FeedInfo;
251 | if (id) {
252 | feedInfo = (await this.prismaService.feed.findFirst({
253 | where: { id },
254 | }))!;
255 |
256 | if (!feedInfo) {
257 | throw new HttpException('不存在该feed!', HttpStatus.BAD_REQUEST);
258 | }
259 |
260 | articles = await this.prismaService.article.findMany({
261 | where: { mpId: id },
262 | orderBy: { publishTime: 'desc' },
263 | take: limit,
264 | skip: (page - 1) * limit,
265 | });
266 | } else {
267 | articles = await this.prismaService.article.findMany({
268 | orderBy: { publishTime: 'desc' },
269 | take: limit,
270 | skip: (page - 1) * limit,
271 | });
272 |
273 | const { originUrl } =
274 | this.configService.get('feed')!;
275 | feedInfo = {
276 | id: 'all',
277 | mpName: 'WeWe-RSS All',
278 | mpIntro: 'WeWe-RSS 全部文章',
279 | mpCover: originUrl
280 | ? `${originUrl}/favicon.ico`
281 | : 'https://r2-assets.111965.xyz/wewe-rss.png',
282 | status: 1,
283 | syncTime: 0,
284 | updateTime: Math.floor(Date.now() / 1e3),
285 | hasHistory: -1,
286 | createdAt: new Date(),
287 | updatedAt: new Date(),
288 | };
289 | }
290 |
291 | this.logger.log('handleGenerateFeed articles: ' + articles.length);
292 | const feed = await this.renderFeed({ feedInfo, articles, type, mode });
293 |
294 | if (title_include) {
295 | const includes = title_include.split('|');
296 | feed.items = feed.items.filter((i: Item) =>
297 | includes.some((k) => i.title.includes(k)),
298 | );
299 | }
300 | if (title_exclude) {
301 | const excludes = title_exclude.split('|');
302 | feed.items = feed.items.filter(
303 | (i: Item) => !excludes.some((k) => i.title.includes(k)),
304 | );
305 | }
306 |
307 | switch (type) {
308 | case 'rss':
309 | return { content: feed.rss2(), mimeType: feedMimeTypeMap[type] };
310 | case 'json':
311 | return { content: feed.json1(), mimeType: feedMimeTypeMap[type] };
312 | case 'atom':
313 | default:
314 | return { content: feed.atom1(), mimeType: feedMimeTypeMap[type] };
315 | }
316 | }
317 |
318 | async getFeedList() {
319 | const data = await this.prismaService.feed.findMany();
320 |
321 | return data.map((item) => {
322 | return {
323 | id: item.id,
324 | name: item.mpName,
325 | intro: item.mpIntro,
326 | cover: item.mpCover,
327 | syncTime: item.syncTime,
328 | updateTime: item.updateTime,
329 | };
330 | });
331 | }
332 |
333 | async updateFeed(id: string) {
334 | try {
335 | await this.trpcService.refreshMpArticlesAndUpdateFeed(id);
336 | } catch (err) {
337 | this.logger.error('updateFeed error', err);
338 | } finally {
339 | // wait 30s for next feed
340 | await new Promise((resolve) => setTimeout(resolve, 30 * 1e3));
341 | }
342 | }
343 | }
344 |
--------------------------------------------------------------------------------
/apps/server/src/main.ts:
--------------------------------------------------------------------------------
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(AppModule);
20 | const configService = app.get(ConfigService);
21 |
22 | const { host, isProd, port } =
23 | configService.get('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();
50 |
--------------------------------------------------------------------------------
/apps/server/src/prisma/prisma.module.ts:
--------------------------------------------------------------------------------
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 {}
9 |
--------------------------------------------------------------------------------
/apps/server/src/prisma/prisma.service.ts:
--------------------------------------------------------------------------------
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 | }
10 |
--------------------------------------------------------------------------------
/apps/server/src/trpc/trpc.module.ts:
--------------------------------------------------------------------------------
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 {}
13 |
--------------------------------------------------------------------------------
/apps/server/src/trpc/trpc.router.ts:
--------------------------------------------------------------------------------
1 | import { INestApplication, Injectable, Logger } from '@nestjs/common';
2 | import { z } from 'zod';
3 | import { TrpcService } from '@server/trpc/trpc.service';
4 | import * as trpcExpress from '@trpc/server/adapters/express';
5 | import { TRPCError } from '@trpc/server';
6 | import { PrismaService } from '@server/prisma/prisma.service';
7 | import { statusMap } from '@server/constants';
8 | import { ConfigService } from '@nestjs/config';
9 | import { ConfigurationType } from '@server/configuration';
10 |
11 | @Injectable()
12 | export class TrpcRouter {
13 | constructor(
14 | private readonly trpcService: TrpcService,
15 | private readonly prismaService: PrismaService,
16 | private readonly configService: ConfigService,
17 | ) {}
18 |
19 | private readonly logger = new Logger(this.constructor.name);
20 |
21 | accountRouter = this.trpcService.router({
22 | list: this.trpcService.protectedProcedure
23 | .input(
24 | z.object({
25 | limit: z.number().min(1).max(1000).nullish(),
26 | cursor: z.string().nullish(),
27 | }),
28 | )
29 | .query(async ({ input }) => {
30 | const limit = input.limit ?? 1000;
31 | const { cursor } = input;
32 |
33 | const items = await this.prismaService.account.findMany({
34 | take: limit + 1,
35 | where: {},
36 | select: {
37 | id: true,
38 | name: true,
39 | status: true,
40 | createdAt: true,
41 | updatedAt: true,
42 | token: false,
43 | },
44 | cursor: cursor
45 | ? {
46 | id: cursor,
47 | }
48 | : undefined,
49 | orderBy: {
50 | createdAt: 'asc',
51 | },
52 | });
53 | let nextCursor: typeof cursor | undefined = undefined;
54 | if (items.length > limit) {
55 | // Remove the last item and use it as next cursor
56 |
57 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
58 | const nextItem = items.pop()!;
59 | nextCursor = nextItem.id;
60 | }
61 |
62 | const disabledAccounts = this.trpcService.getBlockedAccountIds();
63 | return {
64 | blocks: disabledAccounts,
65 | items,
66 | nextCursor,
67 | };
68 | }),
69 | byId: this.trpcService.protectedProcedure
70 | .input(z.string())
71 | .query(async ({ input: id }) => {
72 | const account = await this.prismaService.account.findUnique({
73 | where: { id },
74 | });
75 | if (!account) {
76 | throw new TRPCError({
77 | code: 'BAD_REQUEST',
78 | message: `No account with id '${id}'`,
79 | });
80 | }
81 | return account;
82 | }),
83 | add: this.trpcService.protectedProcedure
84 | .input(
85 | z.object({
86 | id: z.string().min(1).max(32),
87 | token: z.string().min(1),
88 | name: z.string().min(1),
89 | status: z.number().default(statusMap.ENABLE),
90 | }),
91 | )
92 | .mutation(async ({ input }) => {
93 | const { id, ...data } = input;
94 | const account = await this.prismaService.account.upsert({
95 | where: {
96 | id,
97 | },
98 | update: data,
99 | create: input,
100 | });
101 | this.trpcService.removeBlockedAccount(id);
102 |
103 | return account;
104 | }),
105 | edit: this.trpcService.protectedProcedure
106 | .input(
107 | z.object({
108 | id: z.string(),
109 | data: z.object({
110 | token: z.string().min(1).optional(),
111 | name: z.string().min(1).optional(),
112 | status: z.number().optional(),
113 | }),
114 | }),
115 | )
116 | .mutation(async ({ input }) => {
117 | const { id, data } = input;
118 | const account = await this.prismaService.account.update({
119 | where: { id },
120 | data,
121 | });
122 | this.trpcService.removeBlockedAccount(id);
123 | return account;
124 | }),
125 | delete: this.trpcService.protectedProcedure
126 | .input(z.string())
127 | .mutation(async ({ input: id }) => {
128 | await this.prismaService.account.delete({ where: { id } });
129 | this.trpcService.removeBlockedAccount(id);
130 |
131 | return id;
132 | }),
133 | });
134 |
135 | feedRouter = this.trpcService.router({
136 | list: this.trpcService.protectedProcedure
137 | .input(
138 | z.object({
139 | limit: z.number().min(1).max(1000).nullish(),
140 | cursor: z.string().nullish(),
141 | }),
142 | )
143 | .query(async ({ input }) => {
144 | const limit = input.limit ?? 1000;
145 | const { cursor } = input;
146 |
147 | const items = await this.prismaService.feed.findMany({
148 | take: limit + 1,
149 | where: {},
150 | cursor: cursor
151 | ? {
152 | id: cursor,
153 | }
154 | : undefined,
155 | orderBy: {
156 | createdAt: 'asc',
157 | },
158 | });
159 | let nextCursor: typeof cursor | undefined = undefined;
160 | if (items.length > limit) {
161 | // Remove the last item and use it as next cursor
162 |
163 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
164 | const nextItem = items.pop()!;
165 | nextCursor = nextItem.id;
166 | }
167 |
168 | return {
169 | items: items,
170 | nextCursor,
171 | };
172 | }),
173 | byId: this.trpcService.protectedProcedure
174 | .input(z.string())
175 | .query(async ({ input: id }) => {
176 | const feed = await this.prismaService.feed.findUnique({
177 | where: { id },
178 | });
179 | if (!feed) {
180 | throw new TRPCError({
181 | code: 'BAD_REQUEST',
182 | message: `No feed with id '${id}'`,
183 | });
184 | }
185 | return feed;
186 | }),
187 | add: this.trpcService.protectedProcedure
188 | .input(
189 | z.object({
190 | id: z.string(),
191 | mpName: z.string(),
192 | mpCover: z.string(),
193 | mpIntro: z.string(),
194 | syncTime: z
195 | .number()
196 | .optional()
197 | .default(Math.floor(Date.now() / 1e3)),
198 | updateTime: z.number(),
199 | status: z.number().default(statusMap.ENABLE),
200 | }),
201 | )
202 | .mutation(async ({ input }) => {
203 | const { id, ...data } = input;
204 | const feed = await this.prismaService.feed.upsert({
205 | where: {
206 | id,
207 | },
208 | update: data,
209 | create: input,
210 | });
211 |
212 | return feed;
213 | }),
214 | edit: this.trpcService.protectedProcedure
215 | .input(
216 | z.object({
217 | id: z.string(),
218 | data: z.object({
219 | mpName: z.string().optional(),
220 | mpCover: z.string().optional(),
221 | mpIntro: z.string().optional(),
222 | syncTime: z.number().optional(),
223 | updateTime: z.number().optional(),
224 | status: z.number().optional(),
225 | }),
226 | }),
227 | )
228 | .mutation(async ({ input }) => {
229 | const { id, data } = input;
230 | const feed = await this.prismaService.feed.update({
231 | where: { id },
232 | data,
233 | });
234 | return feed;
235 | }),
236 | delete: this.trpcService.protectedProcedure
237 | .input(z.string())
238 | .mutation(async ({ input: id }) => {
239 | await this.prismaService.feed.delete({ where: { id } });
240 | return id;
241 | }),
242 |
243 | refreshArticles: this.trpcService.protectedProcedure
244 | .input(
245 | z.object({
246 | mpId: z.string().optional(),
247 | }),
248 | )
249 | .mutation(async ({ input: { mpId } }) => {
250 | if (mpId) {
251 | await this.trpcService.refreshMpArticlesAndUpdateFeed(mpId);
252 | } else {
253 | await this.trpcService.refreshAllMpArticlesAndUpdateFeed();
254 | }
255 | }),
256 |
257 | isRefreshAllMpArticlesRunning: this.trpcService.protectedProcedure.query(
258 | async () => {
259 | return this.trpcService.isRefreshAllMpArticlesRunning;
260 | },
261 | ),
262 | getHistoryArticles: this.trpcService.protectedProcedure
263 | .input(
264 | z.object({
265 | mpId: z.string().optional(),
266 | }),
267 | )
268 | .mutation(async ({ input: { mpId = '' } }) => {
269 | this.trpcService.getHistoryMpArticles(mpId);
270 | }),
271 | getInProgressHistoryMp: this.trpcService.protectedProcedure.query(
272 | async () => {
273 | return this.trpcService.inProgressHistoryMp;
274 | },
275 | ),
276 | });
277 |
278 | articleRouter = this.trpcService.router({
279 | list: this.trpcService.protectedProcedure
280 | .input(
281 | z.object({
282 | limit: z.number().min(1).max(1000).nullish(),
283 | cursor: z.string().nullish(),
284 | mpId: z.string().nullish(),
285 | }),
286 | )
287 | .query(async ({ input }) => {
288 | const limit = input.limit ?? 1000;
289 | const { cursor, mpId } = input;
290 |
291 | const items = await this.prismaService.article.findMany({
292 | orderBy: [
293 | {
294 | publishTime: 'desc',
295 | },
296 | ],
297 | take: limit + 1,
298 | where: mpId ? { mpId } : undefined,
299 | cursor: cursor
300 | ? {
301 | id: cursor,
302 | }
303 | : undefined,
304 | });
305 | let nextCursor: typeof cursor | undefined = undefined;
306 | if (items.length > limit) {
307 | // Remove the last item and use it as next cursor
308 |
309 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
310 | const nextItem = items.pop()!;
311 | nextCursor = nextItem.id;
312 | }
313 |
314 | return {
315 | items,
316 | nextCursor,
317 | };
318 | }),
319 | byId: this.trpcService.protectedProcedure
320 | .input(z.string())
321 | .query(async ({ input: id }) => {
322 | const article = await this.prismaService.article.findUnique({
323 | where: { id },
324 | });
325 | if (!article) {
326 | throw new TRPCError({
327 | code: 'BAD_REQUEST',
328 | message: `No article with id '${id}'`,
329 | });
330 | }
331 | return article;
332 | }),
333 |
334 | add: this.trpcService.protectedProcedure
335 | .input(
336 | z.object({
337 | id: z.string(),
338 | mpId: z.string(),
339 | title: z.string(),
340 | picUrl: z.string().optional().default(''),
341 | publishTime: z.number(),
342 | }),
343 | )
344 | .mutation(async ({ input }) => {
345 | const { id, ...data } = input;
346 | const article = await this.prismaService.article.upsert({
347 | where: {
348 | id,
349 | },
350 | update: data,
351 | create: input,
352 | });
353 |
354 | return article;
355 | }),
356 | delete: this.trpcService.protectedProcedure
357 | .input(z.string())
358 | .mutation(async ({ input: id }) => {
359 | await this.prismaService.article.delete({ where: { id } });
360 | return id;
361 | }),
362 | });
363 |
364 | platformRouter = this.trpcService.router({
365 | getMpArticles: this.trpcService.protectedProcedure
366 | .input(
367 | z.object({
368 | mpId: z.string(),
369 | }),
370 | )
371 | .mutation(async ({ input: { mpId } }) => {
372 | try {
373 | const results = await this.trpcService.getMpArticles(mpId);
374 | return results;
375 | } catch (err: any) {
376 | this.logger.log('getMpArticles err: ', err);
377 | throw new TRPCError({
378 | code: 'INTERNAL_SERVER_ERROR',
379 | message: err.response?.data?.message || err.message,
380 | cause: err.stack,
381 | });
382 | }
383 | }),
384 | getMpInfo: this.trpcService.protectedProcedure
385 | .input(
386 | z.object({
387 | wxsLink: z
388 | .string()
389 | .refine((v) => v.startsWith('https://mp.weixin.qq.com/s/')),
390 | }),
391 | )
392 | .mutation(async ({ input: { wxsLink: url } }) => {
393 | try {
394 | const results = await this.trpcService.getMpInfo(url);
395 | return results;
396 | } catch (err: any) {
397 | this.logger.log('getMpInfo err: ', err);
398 | throw new TRPCError({
399 | code: 'INTERNAL_SERVER_ERROR',
400 | message: err.response?.data?.message || err.message,
401 | cause: err.stack,
402 | });
403 | }
404 | }),
405 |
406 | createLoginUrl: this.trpcService.protectedProcedure.mutation(async () => {
407 | return this.trpcService.createLoginUrl();
408 | }),
409 | getLoginResult: this.trpcService.protectedProcedure
410 | .input(
411 | z.object({
412 | id: z.string(),
413 | }),
414 | )
415 | .query(async ({ input }) => {
416 | return this.trpcService.getLoginResult(input.id);
417 | }),
418 | });
419 |
420 | appRouter = this.trpcService.router({
421 | feed: this.feedRouter,
422 | account: this.accountRouter,
423 | article: this.articleRouter,
424 | platform: this.platformRouter,
425 | });
426 |
427 | async applyMiddleware(app: INestApplication) {
428 | app.use(
429 | `/trpc`,
430 | trpcExpress.createExpressMiddleware({
431 | router: this.appRouter,
432 | createContext: ({ req }) => {
433 | const authCode =
434 | this.configService.get('auth')!.code;
435 |
436 | if (authCode && req.headers.authorization !== authCode) {
437 | return {
438 | errorMsg: 'authCode不正确!',
439 | };
440 | }
441 | return {
442 | errorMsg: null,
443 | };
444 | },
445 | middleware: (req, res, next) => {
446 | next();
447 | },
448 | }),
449 | );
450 | }
451 | }
452 |
453 | export type AppRouter = TrpcRouter[`appRouter`];
454 |
--------------------------------------------------------------------------------
/apps/server/src/trpc/trpc.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, Logger } from '@nestjs/common';
2 | import { ConfigService } from '@nestjs/config';
3 | import { ConfigurationType } from '@server/configuration';
4 | import { defaultCount, statusMap } from '@server/constants';
5 | import { PrismaService } from '@server/prisma/prisma.service';
6 | import { TRPCError, initTRPC } from '@trpc/server';
7 | import Axios, { AxiosInstance } from 'axios';
8 | import dayjs from 'dayjs';
9 | import timezone from 'dayjs/plugin/timezone';
10 | import utc from 'dayjs/plugin/utc';
11 |
12 | dayjs.extend(utc);
13 | dayjs.extend(timezone);
14 |
15 | /**
16 | * 读书账号每日小黑屋
17 | */
18 | const blockedAccountsMap = new Map();
19 |
20 | @Injectable()
21 | export class TrpcService {
22 | trpc = initTRPC.create();
23 | publicProcedure = this.trpc.procedure;
24 | protectedProcedure = this.trpc.procedure.use(({ ctx, next }) => {
25 | const errorMsg = (ctx as any).errorMsg;
26 | if (errorMsg) {
27 | throw new TRPCError({ code: 'UNAUTHORIZED', message: errorMsg });
28 | }
29 | return next({ ctx });
30 | });
31 | router = this.trpc.router;
32 | mergeRouters = this.trpc.mergeRouters;
33 | request: AxiosInstance;
34 | updateDelayTime = 60;
35 |
36 | private readonly logger = new Logger(this.constructor.name);
37 |
38 | constructor(
39 | private readonly prismaService: PrismaService,
40 | private readonly configService: ConfigService,
41 | ) {
42 | const { url } =
43 | this.configService.get('platform')!;
44 | this.updateDelayTime =
45 | this.configService.get(
46 | 'feed',
47 | )!.updateDelayTime;
48 |
49 | this.request = Axios.create({ baseURL: url, timeout: 15 * 1e3 });
50 |
51 | this.request.interceptors.response.use(
52 | (response) => {
53 | return response;
54 | },
55 | async (error) => {
56 | this.logger.log('error: ', error);
57 | const errMsg = error.response?.data?.message || '';
58 |
59 | const id = (error.config.headers as any).xid;
60 | if (errMsg.includes('WeReadError401')) {
61 | // 账号失效
62 | await this.prismaService.account.update({
63 | where: { id },
64 | data: { status: statusMap.INVALID },
65 | });
66 | this.logger.error(`账号(${id})登录失效,已禁用`);
67 | } else if (errMsg.includes('WeReadError429')) {
68 | //TODO 处理请求频繁
69 | this.logger.error(`账号(${id})请求频繁,打入小黑屋`);
70 | }
71 |
72 | const today = this.getTodayDate();
73 |
74 | const blockedAccounts = blockedAccountsMap.get(today);
75 |
76 | if (Array.isArray(blockedAccounts)) {
77 | if (id) {
78 | blockedAccounts.push(id);
79 | }
80 | blockedAccountsMap.set(today, blockedAccounts);
81 | } else if (errMsg.includes('WeReadError400')) {
82 | this.logger.error(`账号(${id})处理请求参数出错`);
83 | this.logger.error('WeReadError400: ', errMsg);
84 | // 10s 后重试
85 | await new Promise((resolve) => setTimeout(resolve, 10 * 1e3));
86 | } else {
87 | this.logger.error("Can't handle this error: ", errMsg);
88 | }
89 |
90 | return Promise.reject(error);
91 | },
92 | );
93 | }
94 |
95 | removeBlockedAccount = (vid: string) => {
96 | const today = this.getTodayDate();
97 |
98 | const blockedAccounts = blockedAccountsMap.get(today);
99 | if (Array.isArray(blockedAccounts)) {
100 | const newBlockedAccounts = blockedAccounts.filter((id) => id !== vid);
101 | blockedAccountsMap.set(today, newBlockedAccounts);
102 | }
103 | };
104 |
105 | private getTodayDate() {
106 | return dayjs.tz(new Date(), 'Asia/Shanghai').format('YYYY-MM-DD');
107 | }
108 |
109 | getBlockedAccountIds() {
110 | const today = this.getTodayDate();
111 | const disabledAccounts = blockedAccountsMap.get(today) || [];
112 | this.logger.debug('disabledAccounts: ', disabledAccounts);
113 | return disabledAccounts.filter(Boolean);
114 | }
115 |
116 | private async getAvailableAccount() {
117 | const disabledAccounts = this.getBlockedAccountIds();
118 | const account = await this.prismaService.account.findMany({
119 | where: {
120 | status: statusMap.ENABLE,
121 | NOT: {
122 | id: { in: disabledAccounts },
123 | },
124 | },
125 | take: 10,
126 | });
127 |
128 | if (!account || account.length === 0) {
129 | throw new Error('暂无可用读书账号!');
130 | }
131 |
132 | return account[Math.floor(Math.random() * account.length)];
133 | }
134 |
135 | async getMpArticles(mpId: string, page = 1, retryCount = 3) {
136 | const account = await this.getAvailableAccount();
137 |
138 | try {
139 | const res = await this.request
140 | .get<
141 | {
142 | id: string;
143 | title: string;
144 | picUrl: string;
145 | publishTime: number;
146 | }[]
147 | >(`/api/v2/platform/mps/${mpId}/articles`, {
148 | headers: {
149 | xid: account.id,
150 | Authorization: `Bearer ${account.token}`,
151 | },
152 | params: {
153 | page,
154 | },
155 | })
156 | .then((res) => res.data)
157 | .then((res) => {
158 | this.logger.log(
159 | `getMpArticles(${mpId}) page: ${page} articles: ${res.length}`,
160 | );
161 | return res;
162 | });
163 | return res;
164 | } catch (err) {
165 | this.logger.error(`retry(${4 - retryCount}) getMpArticles error: `, err);
166 | if (retryCount > 0) {
167 | return this.getMpArticles(mpId, page, retryCount - 1);
168 | } else {
169 | throw err;
170 | }
171 | }
172 | }
173 |
174 | async refreshMpArticlesAndUpdateFeed(mpId: string, page = 1) {
175 | const articles = await this.getMpArticles(mpId, page);
176 |
177 | if (articles.length > 0) {
178 | let results;
179 | const { type } =
180 | this.configService.get('database')!;
181 | if (type === 'sqlite') {
182 | // sqlite3 不支持 createMany
183 | const inserts = articles.map(({ id, picUrl, publishTime, title }) =>
184 | this.prismaService.article.upsert({
185 | create: { id, mpId, picUrl, publishTime, title },
186 | update: {
187 | publishTime,
188 | title,
189 | },
190 | where: { id },
191 | }),
192 | );
193 | results = await this.prismaService.$transaction(inserts);
194 | } else {
195 | results = await (this.prismaService.article as any).createMany({
196 | data: articles.map(({ id, picUrl, publishTime, title }) => ({
197 | id,
198 | mpId,
199 | picUrl,
200 | publishTime,
201 | title,
202 | })),
203 | skipDuplicates: true,
204 | });
205 | }
206 |
207 | this.logger.debug(
208 | `refreshMpArticlesAndUpdateFeed create results: ${JSON.stringify(results)}`,
209 | );
210 | }
211 |
212 | // 如果文章数量小于 defaultCount,则认为没有更多历史文章
213 | const hasHistory = articles.length < defaultCount ? 0 : 1;
214 |
215 | await this.prismaService.feed.update({
216 | where: { id: mpId },
217 | data: {
218 | syncTime: Math.floor(Date.now() / 1e3),
219 | hasHistory,
220 | },
221 | });
222 |
223 | return { hasHistory };
224 | }
225 |
226 | inProgressHistoryMp = {
227 | id: '',
228 | page: 1,
229 | };
230 |
231 | async getHistoryMpArticles(mpId: string) {
232 | if (this.inProgressHistoryMp.id === mpId) {
233 | this.logger.log(`getHistoryMpArticles(${mpId}) is running`);
234 | return;
235 | }
236 |
237 | this.inProgressHistoryMp = {
238 | id: mpId,
239 | page: 1,
240 | };
241 |
242 | if (!this.inProgressHistoryMp.id) {
243 | return;
244 | }
245 |
246 | try {
247 | const feed = await this.prismaService.feed.findFirstOrThrow({
248 | where: {
249 | id: mpId,
250 | },
251 | });
252 |
253 | // 如果完整同步过历史文章,则直接返回
254 | if (feed.hasHistory === 0) {
255 | this.logger.log(`getHistoryMpArticles(${mpId}) has no history`);
256 | return;
257 | }
258 |
259 | const total = await this.prismaService.article.count({
260 | where: {
261 | mpId,
262 | },
263 | });
264 | this.inProgressHistoryMp.page = Math.ceil(total / defaultCount);
265 |
266 | // 最多尝试一千次
267 | let i = 1e3;
268 | while (i-- > 0) {
269 | if (this.inProgressHistoryMp.id !== mpId) {
270 | this.logger.log(
271 | `getHistoryMpArticles(${mpId}) is not running, break`,
272 | );
273 | break;
274 | }
275 | const { hasHistory } = await this.refreshMpArticlesAndUpdateFeed(
276 | mpId,
277 | this.inProgressHistoryMp.page,
278 | );
279 | if (hasHistory < 1) {
280 | this.logger.log(
281 | `getHistoryMpArticles(${mpId}) has no history, break`,
282 | );
283 | break;
284 | }
285 | this.inProgressHistoryMp.page++;
286 |
287 | await new Promise((resolve) =>
288 | setTimeout(resolve, this.updateDelayTime * 1e3),
289 | );
290 | }
291 | } finally {
292 | this.inProgressHistoryMp = {
293 | id: '',
294 | page: 1,
295 | };
296 | }
297 | }
298 |
299 | isRefreshAllMpArticlesRunning = false;
300 |
301 | async refreshAllMpArticlesAndUpdateFeed() {
302 | if (this.isRefreshAllMpArticlesRunning) {
303 | this.logger.log('refreshAllMpArticlesAndUpdateFeed is running');
304 | return;
305 | }
306 | const mps = await this.prismaService.feed.findMany();
307 | this.isRefreshAllMpArticlesRunning = true;
308 | try {
309 | for (const { id } of mps) {
310 | await this.refreshMpArticlesAndUpdateFeed(id);
311 |
312 | await new Promise((resolve) =>
313 | setTimeout(resolve, this.updateDelayTime * 1e3),
314 | );
315 | }
316 | } finally {
317 | this.isRefreshAllMpArticlesRunning = false;
318 | }
319 | }
320 |
321 | async getMpInfo(url: string) {
322 | url = url.trim();
323 | const account = await this.getAvailableAccount();
324 |
325 | return this.request
326 | .post<
327 | {
328 | id: string;
329 | cover: string;
330 | name: string;
331 | intro: string;
332 | updateTime: number;
333 | }[]
334 | >(
335 | `/api/v2/platform/wxs2mp`,
336 | { url },
337 | {
338 | headers: {
339 | xid: account.id,
340 | Authorization: `Bearer ${account.token}`,
341 | },
342 | },
343 | )
344 | .then((res) => res.data);
345 | }
346 |
347 | async createLoginUrl() {
348 | return this.request
349 | .get<{
350 | uuid: string;
351 | scanUrl: string;
352 | }>(`/api/v2/login/platform`)
353 | .then((res) => res.data);
354 | }
355 |
356 | async getLoginResult(id: string) {
357 | return this.request
358 | .get<{
359 | message: string;
360 | vid?: number;
361 | token?: string;
362 | username?: string;
363 | }>(`/api/v2/login/platform/${id}`, { timeout: 120 * 1e3 })
364 | .then((res) => res.data);
365 | }
366 | }
367 |
--------------------------------------------------------------------------------
/apps/server/test/app.e2e-spec.ts:
--------------------------------------------------------------------------------
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 | });
25 |
--------------------------------------------------------------------------------
/apps/server/test/jest-e2e.json:
--------------------------------------------------------------------------------
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 | }
10 |
--------------------------------------------------------------------------------
/apps/server/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/apps/server/tsconfig.json:
--------------------------------------------------------------------------------
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:
--------------------------------------------------------------------------------
1 | # 同SERVER_ORIGIN_URL
2 | VITE_SERVER_ORIGIN_URL=http://localhost:4000
3 |
--------------------------------------------------------------------------------
/apps/web/.eslintrc.cjs:
--------------------------------------------------------------------------------
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 | };
20 |
--------------------------------------------------------------------------------
/apps/web/.gitignore:
--------------------------------------------------------------------------------
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?
25 |
--------------------------------------------------------------------------------
/apps/web/README.md:
--------------------------------------------------------------------------------
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
31 |
--------------------------------------------------------------------------------
/apps/web/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | WeWe RSS
8 |
9 |
10 |
11 |
12 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/apps/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web",
3 | "private": true,
4 | "version": "2.6.1",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@nextui-org/react": "^2.2.9",
14 | "@tanstack/react-query": "^4.35.3",
15 | "@trpc/client": "^10.45.1",
16 | "@trpc/next": "^10.45.1",
17 | "@trpc/react-query": "^10.45.1",
18 | "autoprefixer": "^10.0.1",
19 | "dayjs": "^1.11.10",
20 | "framer-motion": "^11.0.5",
21 | "next-themes": "^0.2.1",
22 | "postcss": "^8",
23 | "qrcode.react": "^3.1.0",
24 | "react": "^18.2.0",
25 | "react-dom": "^18.2.0",
26 | "react-router-dom": "^6.22.2",
27 | "sonner": "^1.4.0",
28 | "tailwindcss": "^3.3.0"
29 | },
30 | "devDependencies": {
31 | "@types/node": "^20.11.24",
32 | "@types/react": "^18.2.56",
33 | "@types/react-dom": "^18.2.19",
34 | "@typescript-eslint/eslint-plugin": "^7.0.2",
35 | "@typescript-eslint/parser": "^7.0.2",
36 | "@vitejs/plugin-react": "^4.2.1",
37 | "eslint": "^8.56.0",
38 | "eslint-plugin-react-hooks": "^4.6.0",
39 | "eslint-plugin-react-refresh": "^0.4.5",
40 | "typescript": "^5.2.2",
41 | "vite": "^5.1.4"
42 | }
43 | }
--------------------------------------------------------------------------------
/apps/web/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/apps/web/src/App.tsx:
--------------------------------------------------------------------------------
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 |
12 |
13 |
14 |
15 | }>
16 | } />
17 | } />
18 | } />
19 | } />
20 |
21 |
22 |
23 |
24 |
25 | );
26 | }
27 |
28 | export default App;
29 |
--------------------------------------------------------------------------------
/apps/web/src/components/GitHubIcon.tsx:
--------------------------------------------------------------------------------
1 | import { IconSvgProps } from '../types';
2 |
3 | export const GitHubIcon = ({
4 | size = 24,
5 | width,
6 | height,
7 | ...props
8 | }: IconSvgProps) => (
9 |
26 | );
27 |
--------------------------------------------------------------------------------
/apps/web/src/components/Nav.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Badge,
3 | Image,
4 | Link,
5 | Navbar,
6 | NavbarBrand,
7 | NavbarContent,
8 | NavbarItem,
9 | Tooltip,
10 | } from '@nextui-org/react';
11 | import { ThemeSwitcher } from './ThemeSwitcher';
12 | import { GitHubIcon } from './GitHubIcon';
13 | import { useLocation } from 'react-router-dom';
14 | import { appVersion, serverOriginUrl } from '@web/utils/env';
15 | import { useEffect, useState } from 'react';
16 |
17 | const navbarItemLink = [
18 | {
19 | href: '/feeds',
20 | name: '公众号源',
21 | },
22 | {
23 | href: '/accounts',
24 | name: '账号管理',
25 | },
26 | // {
27 | // href: '/settings',
28 | // name: '设置',
29 | // },
30 | ];
31 |
32 | const Nav = () => {
33 | const { pathname } = useLocation();
34 | const [releaseVersion, setReleaseVersion] = useState(appVersion);
35 |
36 | useEffect(() => {
37 | fetch('https://api.github.com/repos/cooderl/wewe-rss/releases/latest')
38 | .then((res) => res.json())
39 | .then((data) => {
40 | setReleaseVersion(data.name.replace('v', ''));
41 | });
42 | }, []);
43 |
44 | const isFoundNewVersion = releaseVersion > appVersion;
45 | console.log('isFoundNewVersion: ', isFoundNewVersion);
46 |
47 | return (
48 |
49 |
50 |
53 | {isFoundNewVersion && (
54 |
59 | 发现新版本:v{releaseVersion}
60 |
61 | )}
62 | 当前版本: v{appVersion}
63 |
64 | }
65 | placement="left"
66 | >
67 |
68 |
73 |
83 |
84 | WeWe RSS
85 |
86 |
87 |
88 | {navbarItemLink.map((item) => {
89 | return (
90 |
94 |
95 | {item.name}
96 |
97 |
98 | );
99 | })}
100 |
101 |
102 |
103 |
108 |
109 |
110 |
111 |
112 |
113 | );
114 | };
115 |
116 | export default Nav;
117 |
--------------------------------------------------------------------------------
/apps/web/src/components/PlusIcon.tsx:
--------------------------------------------------------------------------------
1 | import { IconSvgProps } from '../types';
2 |
3 | export const PlusIcon = ({
4 | size = 24,
5 | width,
6 | height,
7 | ...props
8 | }: IconSvgProps) => (
9 |
30 | );
31 |
--------------------------------------------------------------------------------
/apps/web/src/components/StatusDropdown.tsx:
--------------------------------------------------------------------------------
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 |
20 |
21 |
24 |
25 | {
33 | onChange(+Array.from(keys)[0]);
34 | }}
35 | >
36 | {Object.entries(statusMap).map(([key, value]) => {
37 | return (
38 |
39 | {value.label}
40 |
41 | );
42 | })}
43 |
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/apps/web/src/components/ThemeSwitcher.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { VisuallyHidden, useSwitch } from '@nextui-org/react';
4 | import { useTheme } from 'next-themes';
5 |
6 | export const MoonIcon = (props) => (
7 |
21 | );
22 |
23 | export const SunIcon = (props) => (
24 |
38 | );
39 |
40 | export function ThemeSwitcher(props) {
41 | const { setTheme, theme } = useTheme();
42 | const {
43 | Component,
44 | slots,
45 | isSelected,
46 | getBaseProps,
47 | getInputProps,
48 | getWrapperProps,
49 | } = useSwitch({
50 | onClick: () => setTheme(theme === 'dark' ? 'light' : 'dark'),
51 | isSelected: theme === 'dark',
52 | });
53 |
54 | return (
55 |
56 |
57 |
58 |
59 |
60 |
70 | {isSelected ? : }
71 |
72 |
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/apps/web/src/constants.ts:
--------------------------------------------------------------------------------
1 | export const statusMap = {
2 | 0: { label: '失效', color: 'danger' },
3 | 1: { label: '启用', color: 'success' },
4 | 2: { label: '禁用', color: 'warning' },
5 | } as const;
6 |
--------------------------------------------------------------------------------
/apps/web/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/apps/web/src/layouts/base.tsx:
--------------------------------------------------------------------------------
1 | import { Toaster } from 'sonner';
2 | import { Outlet } from 'react-router-dom';
3 |
4 | import Nav from '../components/Nav';
5 |
6 | export function BaseLayout() {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/apps/web/src/main.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom/client';
2 | import App from './App.tsx';
3 | import './index.css';
4 |
5 | ReactDOM.createRoot(document.getElementById('root')!).render();
6 |
--------------------------------------------------------------------------------
/apps/web/src/pages/accounts/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Modal,
3 | ModalContent,
4 | ModalHeader,
5 | ModalBody,
6 | Button,
7 | useDisclosure,
8 | Spinner,
9 | Table,
10 | TableBody,
11 | TableCell,
12 | TableColumn,
13 | TableHeader,
14 | TableRow,
15 | Chip,
16 | } from '@nextui-org/react';
17 | import { QRCodeSVG } from 'qrcode.react';
18 | import { toast } from 'sonner';
19 | import { PlusIcon } from '@web/components/PlusIcon';
20 | import dayjs from 'dayjs';
21 | import { StatusDropdown } from '@web/components/StatusDropdown';
22 | import { trpc } from '@web/utils/trpc';
23 | import { statusMap } from '@web/constants';
24 | import { useEffect, useState } from 'react';
25 |
26 | const AccountPage = () => {
27 | const { isOpen, onOpen, onClose, onOpenChange } = useDisclosure();
28 | const [count, setCount] = useState(0);
29 |
30 | const { refetch, data, isFetching } = trpc.account.list.useQuery({});
31 |
32 | const queryUtils = trpc.useUtils();
33 |
34 | const { mutateAsync: updateAccount } = trpc.account.edit.useMutation({});
35 |
36 | const { mutateAsync: deleteAccount } = trpc.account.delete.useMutation({});
37 |
38 | const { mutateAsync: addAccount } = trpc.account.add.useMutation({});
39 |
40 | const { mutateAsync, data: loginData } =
41 | trpc.platform.createLoginUrl.useMutation({
42 | onSuccess(data) {
43 | if (data.uuid) {
44 | setCount(60);
45 | }
46 | },
47 | });
48 |
49 | const { data: loginResult } = trpc.platform.getLoginResult.useQuery(
50 | {
51 | id: loginData?.uuid ?? '',
52 | },
53 | {
54 | refetchIntervalInBackground: false,
55 | enabled: !!loginData?.uuid,
56 | async onSuccess(data) {
57 | if (data.vid && data.token) {
58 | const name = data.username!;
59 | await addAccount({ id: `${data.vid}`, name, token: data.token });
60 |
61 | onClose();
62 | toast.success('添加成功', {
63 | description: `用户名:${name}(${data.vid})`,
64 | });
65 | refetch();
66 | } else if (data.message) {
67 | toast.error(`登录失败: ${data.message}`);
68 | }
69 | },
70 | },
71 | );
72 |
73 | useEffect(() => {
74 | let timerId;
75 | if (count > 0 && isOpen) {
76 | timerId = setTimeout(() => {
77 | setCount(count - 1);
78 | }, 1000);
79 | }
80 | return () => timerId && clearTimeout(timerId);
81 | }, [count, isOpen]);
82 |
83 | return (
84 |
85 |
86 |
共{data?.items.length || 0}个账号
87 |
98 |
99 |
100 |
101 | ID
102 | 用户名
103 | 状态
104 | 更新时间
105 | 操作
106 |
107 | 暂无数据}
109 | isLoading={isFetching}
110 | loadingContent={}
111 | >
112 | {data?.items.map((item) => {
113 | const isBlocked = data?.blocks.includes(item.id);
114 |
115 | return (
116 |
117 | {item.id}
118 | {item.name}
119 |
120 | {isBlocked ? (
121 |
122 | 今日小黑屋
123 |
124 | ) : (
125 |
131 | {statusMap[item.status].label}
132 |
133 | )}
134 |
135 |
136 | {dayjs(item.updatedAt).format('YYYY-MM-DD')}
137 |
138 |
139 | {
142 | updateAccount({
143 | id: item.id,
144 | data: { status: value },
145 | }).then(() => {
146 | toast.success('更新成功!');
147 | refetch();
148 | });
149 | }}
150 | >
151 |
152 |
164 |
165 |
166 | );
167 | }) || []}
168 |
169 |
170 |
171 |
{
174 | onOpenChange();
175 | await queryUtils.platform.getLoginResult.cancel();
176 | }}
177 | >
178 |
179 | {() => (
180 | <>
181 |
182 | 添加读书账号
183 |
184 |
185 |
186 | {loginData ? (
187 |
188 |
189 | {loginResult?.message && (
190 |
191 |
192 | {loginResult?.message}
193 |
194 |
195 | )}
196 |
197 |
198 |
199 | 微信扫码登录{' '}
200 | {!loginResult?.message && count > 0 && (
201 | ({count}s)
202 | )}
203 |
204 |
205 | ) : (
206 |
207 |
208 | 二维码加载中
209 |
210 | )}
211 |
212 |
213 | >
214 | )}
215 |
216 |
217 |
218 | );
219 | };
220 |
221 | export default AccountPage;
222 |
--------------------------------------------------------------------------------
/apps/web/src/pages/feeds/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Avatar,
3 | Button,
4 | Divider,
5 | Listbox,
6 | ListboxItem,
7 | ListboxSection,
8 | Modal,
9 | ModalBody,
10 | ModalContent,
11 | ModalFooter,
12 | ModalHeader,
13 | Switch,
14 | Textarea,
15 | Tooltip,
16 | useDisclosure,
17 | Link,
18 | } from '@nextui-org/react';
19 | import { PlusIcon } from '@web/components/PlusIcon';
20 | import { trpc } from '@web/utils/trpc';
21 | import { useMemo, useState } from 'react';
22 | import { useNavigate, useParams } from 'react-router-dom';
23 | import { toast } from 'sonner';
24 | import dayjs from 'dayjs';
25 | import { serverOriginUrl } from '@web/utils/env';
26 | import ArticleList from './list';
27 |
28 | const Feeds = () => {
29 | const { id } = useParams();
30 |
31 | const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure();
32 | const { refetch: refetchFeedList, data: feedData } = trpc.feed.list.useQuery(
33 | {},
34 | {
35 | refetchOnWindowFocus: true,
36 | },
37 | );
38 |
39 | const navigate = useNavigate();
40 |
41 | const queryUtils = trpc.useUtils();
42 |
43 | const { mutateAsync: getMpInfo, isLoading: isGetMpInfoLoading } =
44 | trpc.platform.getMpInfo.useMutation({});
45 | const { mutateAsync: updateMpInfo } = trpc.feed.edit.useMutation({});
46 |
47 | const { mutateAsync: addFeed, isLoading: isAddFeedLoading } =
48 | trpc.feed.add.useMutation({});
49 | const { mutateAsync: refreshMpArticles, isLoading: isGetArticlesLoading } =
50 | trpc.feed.refreshArticles.useMutation();
51 | const {
52 | mutateAsync: getHistoryArticles,
53 | isLoading: isGetHistoryArticlesLoading,
54 | } = trpc.feed.getHistoryArticles.useMutation();
55 |
56 | const { data: inProgressHistoryMp, refetch: refetchInProgressHistoryMp } =
57 | trpc.feed.getInProgressHistoryMp.useQuery(undefined, {
58 | refetchOnWindowFocus: true,
59 | refetchInterval: 10 * 1e3,
60 | refetchOnMount: true,
61 | refetchOnReconnect: true,
62 | });
63 |
64 | const { data: isRefreshAllMpArticlesRunning } =
65 | trpc.feed.isRefreshAllMpArticlesRunning.useQuery();
66 |
67 | const { mutateAsync: deleteFeed, isLoading: isDeleteFeedLoading } =
68 | trpc.feed.delete.useMutation({});
69 |
70 | const [wxsLink, setWxsLink] = useState('');
71 |
72 | const [currentMpId, setCurrentMpId] = useState(id || '');
73 |
74 | const handleConfirm = async () => {
75 | console.log('wxsLink', wxsLink);
76 | // TODO show operation in progress
77 | const wxsLinks = wxsLink.split('\n').filter((link) => link.trim() !== '');
78 | for (const link of wxsLinks) {
79 | console.log('add wxsLink', link);
80 | const res = await getMpInfo({ wxsLink: link });
81 | if (res[0]) {
82 | const item = res[0];
83 | await addFeed({
84 | id: item.id,
85 | mpName: item.name,
86 | mpCover: item.cover,
87 | mpIntro: item.intro,
88 | updateTime: item.updateTime,
89 | status: 1,
90 | });
91 | await refreshMpArticles({ mpId: item.id });
92 | toast.success('添加成功', {
93 | description: `公众号 ${item.name}`,
94 | });
95 | await queryUtils.article.list.reset();
96 | } else {
97 | toast.error('添加失败', { description: '请检查链接是否正确' });
98 | }
99 | }
100 | refetchFeedList();
101 | setWxsLink('');
102 | onClose();
103 | };
104 |
105 | const isActive = (key: string) => {
106 | return currentMpId === key;
107 | };
108 |
109 | const currentMpInfo = useMemo(() => {
110 | return feedData?.items.find((item) => item.id === currentMpId);
111 | }, [currentMpId, feedData?.items]);
112 |
113 | const handleExportOpml = async (ev) => {
114 | ev.preventDefault();
115 | ev.stopPropagation();
116 | if (!feedData?.items?.length) {
117 | console.warn('没有订阅源');
118 | return;
119 | }
120 |
121 | let opmlContent = `
122 |
123 |
124 | WeWeRSS 所有订阅源
125 |
126 |
127 | `;
128 |
129 | feedData?.items.forEach((sub) => {
130 | opmlContent += ` \n`;
131 | });
132 |
133 | opmlContent += `
134 | `;
135 |
136 | const blob = new Blob([opmlContent], { type: 'text/xml;charset=utf-8;' });
137 | const link = document.createElement('a');
138 | link.href = URL.createObjectURL(blob);
139 | link.download = 'WeWeRSS-All.opml';
140 | document.body.appendChild(link);
141 | link.click();
142 | document.body.removeChild(link);
143 | };
144 |
145 | return (
146 | <>
147 |
148 |
149 |
150 |
}
155 | >
156 | 添加
157 |
158 |
159 | 共{feedData?.items.length || 0}个订阅
160 |
161 |
162 |
163 | {feedData?.items ? (
164 |
setCurrentMpId(key as string)}
168 | >
169 |
170 | }
175 | >
176 | 全部
177 |
178 |
179 |
180 |
181 | {feedData?.items.map((item) => {
182 | return (
183 | }
190 | >
191 | {item.mpName}
192 |
193 | );
194 | }) || []}
195 |
196 |
197 | ) : (
198 | ''
199 | )}
200 |
201 |
202 |
203 |
204 | {currentMpInfo?.mpName || '全部'}
205 |
206 | {currentMpInfo ? (
207 |
208 |
209 | 最后更新时间:
210 | {dayjs(currentMpInfo.syncTime * 1e3).format(
211 | 'YYYY-MM-DD HH:mm:ss',
212 | )}
213 |
214 |
215 |
219 | {
224 | ev.preventDefault();
225 | ev.stopPropagation();
226 | await refreshMpArticles({ mpId: currentMpInfo.id });
227 | await refetchFeedList();
228 | await queryUtils.article.list.reset();
229 | }}
230 | >
231 | {isGetArticlesLoading ? '更新中...' : '立即更新'}
232 |
233 |
234 |
235 | {currentMpInfo.hasHistory === 1 && (
236 | <>
237 |
249 | {
260 | ev.preventDefault();
261 | ev.stopPropagation();
262 |
263 | if (inProgressHistoryMp?.id === currentMpInfo.id) {
264 | await getHistoryArticles({
265 | mpId: '',
266 | });
267 | } else {
268 | await getHistoryArticles({
269 | mpId: currentMpInfo.id,
270 | });
271 | }
272 |
273 | await refetchInProgressHistoryMp();
274 | }}
275 | >
276 | {inProgressHistoryMp?.id === currentMpInfo.id
277 | ? `停止获取历史文章`
278 | : `获取历史文章`}
279 |
280 |
281 |
282 | >
283 | )}
284 |
285 |
286 |
287 | {
290 | await updateMpInfo({
291 | id: currentMpInfo.id,
292 | data: {
293 | status: value ? 1 : 0,
294 | },
295 | });
296 |
297 | await refetchFeedList();
298 | }}
299 | isSelected={currentMpInfo?.status === 1}
300 | >
301 |
302 |
303 |
304 |
305 | {
311 | ev.preventDefault();
312 | ev.stopPropagation();
313 |
314 | if (window.confirm('确定删除吗?')) {
315 | await deleteFeed(currentMpInfo.id);
316 | navigate('/feeds');
317 | await refetchFeedList();
318 | }
319 | }}
320 | >
321 | 删除
322 |
323 |
324 |
325 |
326 |
329 | 可添加.atom/.rss/.json格式输出, limit=20&page=1控制分页
330 |
331 | }
332 | >
333 |
340 | RSS
341 |
342 |
343 |
344 | ) : (
345 |
346 |
350 | {
357 | ev.preventDefault();
358 | ev.stopPropagation();
359 | await refreshMpArticles({});
360 | await refetchFeedList();
361 | await queryUtils.article.list.reset();
362 | }}
363 | >
364 | {isRefreshAllMpArticlesRunning || isGetArticlesLoading
365 | ? '更新中...'
366 | : '更新全部'}
367 |
368 |
369 |
375 | 导出OPML
376 |
377 |
378 |
385 | RSS
386 |
387 |
388 | )}
389 |
390 |
393 |
394 |
395 |
396 |
397 | {(onClose) => (
398 | <>
399 |
400 | 添加公众号源
401 |
402 |
403 |
411 |
412 |
413 |
416 |
430 |
431 | >
432 | )}
433 |
434 |
435 | >
436 | );
437 | };
438 |
439 | export default Feeds;
440 |
--------------------------------------------------------------------------------
/apps/web/src/pages/feeds/list.tsx:
--------------------------------------------------------------------------------
1 | import { FC, useMemo } from 'react';
2 | import {
3 | Table,
4 | TableHeader,
5 | TableColumn,
6 | TableBody,
7 | TableRow,
8 | TableCell,
9 | getKeyValue,
10 | Button,
11 | Spinner,
12 | Link,
13 | } from '@nextui-org/react';
14 | import { trpc } from '@web/utils/trpc';
15 | import dayjs from 'dayjs';
16 | import { useParams } from 'react-router-dom';
17 |
18 | const ArticleList: FC = () => {
19 | const { id } = useParams();
20 |
21 | const mpId = id || '';
22 |
23 | const { data, fetchNextPage, isLoading, hasNextPage } =
24 | trpc.article.list.useInfiniteQuery(
25 | {
26 | limit: 20,
27 | mpId: mpId,
28 | },
29 | {
30 | getNextPageParam: (lastPage) => lastPage.nextCursor,
31 | },
32 | );
33 |
34 | const items = useMemo(() => {
35 | const items = data
36 | ? data.pages.reduce((acc, page) => [...acc, ...page.items], [] as any[])
37 | : [];
38 |
39 | return items;
40 | }, [data]);
41 |
42 | return (
43 |
44 |
53 |
63 |
64 | ) : null
65 | }
66 | >
67 |
68 | 标题
69 |
70 | 发布时间
71 |
72 |
73 | }
78 | >
79 | {(item) => (
80 |
81 | {(columnKey) => {
82 | let value = getKeyValue(item, columnKey);
83 |
84 | if (columnKey === 'publishTime') {
85 | value = dayjs(value * 1e3).format('YYYY-MM-DD HH:mm:ss');
86 | return {value};
87 | }
88 |
89 | if (columnKey === 'title') {
90 | return (
91 |
92 |
100 | {value}
101 |
102 |
103 | );
104 | }
105 | return {value};
106 | }}
107 |
108 | )}
109 |
110 |
111 |
112 | );
113 | };
114 |
115 | export default ArticleList;
116 |
--------------------------------------------------------------------------------
/apps/web/src/pages/login/index.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Input } from '@nextui-org/react';
2 | import { setAuthCode } from '@web/utils/auth';
3 | import { useState } from 'react';
4 | import { useNavigate } from 'react-router-dom';
5 |
6 | const LoginPage = () => {
7 | const [codeValue, setCodeValue] = useState('');
8 |
9 | const navigate = useNavigate();
10 |
11 | return (
12 |
13 |
19 |
28 |
29 | );
30 | };
31 |
32 | export default LoginPage;
33 |
--------------------------------------------------------------------------------
/apps/web/src/provider/theme.tsx:
--------------------------------------------------------------------------------
1 | import { NextUIProvider } from '@nextui-org/react';
2 | import { ThemeProvider as NextThemesProvider } from 'next-themes';
3 | import { useNavigate } from 'react-router-dom';
4 |
5 | function ThemeProvider({ children }: { children: React.ReactNode }) {
6 | const navigate = useNavigate();
7 |
8 | return (
9 |
10 |
11 | {children}
12 |
13 |
14 | );
15 | }
16 |
17 | export default ThemeProvider;
18 |
--------------------------------------------------------------------------------
/apps/web/src/provider/trpc.tsx:
--------------------------------------------------------------------------------
1 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2 | import { httpBatchLink, loggerLink } from '@trpc/client';
3 | import { useNavigate } from 'react-router-dom';
4 | import { useState } from 'react';
5 | import { toast } from 'sonner';
6 | import { isTRPCClientError, trpc } from '../utils/trpc';
7 | import { getAuthCode, setAuthCode } from '../utils/auth';
8 | import { enabledAuthCode, serverOriginUrl } from '../utils/env';
9 |
10 | export const TrpcProvider: React.FC<{ children: React.ReactNode }> = ({
11 | children,
12 | }) => {
13 | const navigate = useNavigate();
14 |
15 | const handleNoAuth = () => {
16 | if (enabledAuthCode) {
17 | setAuthCode('');
18 | navigate('/login');
19 | }
20 | };
21 | const [queryClient] = useState(
22 | () =>
23 | new QueryClient({
24 | defaultOptions: {
25 | queries: {
26 | refetchOnWindowFocus: false,
27 | refetchOnReconnect: true,
28 | refetchIntervalInBackground: false,
29 | retryDelay: (retryCount) => Math.min(retryCount * 1000, 60 * 1000),
30 | retry(failureCount, error) {
31 | console.log('failureCount: ', failureCount);
32 | if (isTRPCClientError(error)) {
33 | if (error.data?.httpStatus === 401) {
34 | return false;
35 | }
36 | }
37 | return failureCount < 3;
38 | },
39 | onError(error) {
40 | console.error('queries onError: ', error);
41 | if (isTRPCClientError(error)) {
42 | if (error.data?.httpStatus === 401) {
43 | toast.error('无权限', {
44 | description: error.message,
45 | });
46 |
47 | handleNoAuth();
48 | } else {
49 | toast.error('请求失败!', {
50 | description: error.message,
51 | });
52 | }
53 | }
54 | },
55 | },
56 | mutations: {
57 | onError(error) {
58 | console.error('mutations onError: ', error);
59 | if (isTRPCClientError(error)) {
60 | if (error.data?.httpStatus === 401) {
61 | toast.error('无权限', {
62 | description: error.message,
63 | });
64 | handleNoAuth();
65 | } else {
66 | toast.error('请求失败!', {
67 | description: error.message,
68 | });
69 | }
70 | }
71 | },
72 | },
73 | },
74 | }),
75 | );
76 |
77 | const [trpcClient] = useState(() =>
78 | trpc.createClient({
79 | links: [
80 | loggerLink({
81 | enabled: () => true,
82 | }),
83 | httpBatchLink({
84 | url: serverOriginUrl + '/trpc',
85 | async headers() {
86 | const token = getAuthCode();
87 |
88 | if (!token) {
89 | handleNoAuth();
90 | return {};
91 | }
92 |
93 | return token
94 | ? {
95 | Authorization: `${token}`,
96 | }
97 | : {};
98 | },
99 | }),
100 | ],
101 | }),
102 | );
103 | return (
104 |
105 | {children}
106 |
107 | );
108 | };
109 |
--------------------------------------------------------------------------------
/apps/web/src/types.ts:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react';
2 |
3 | export type IconSvgProps = SVGProps & {
4 | size?: number;
5 | };
6 |
--------------------------------------------------------------------------------
/apps/web/src/utils/auth.ts:
--------------------------------------------------------------------------------
1 | let token: string | null = null;
2 |
3 | export const getAuthCode = () => {
4 | if (token !== null) {
5 | return token;
6 | }
7 |
8 | token = window.localStorage.getItem('authCode');
9 | return token;
10 | };
11 |
12 | export const setAuthCode = (authCode: string | null) => {
13 | token = authCode;
14 | if (!authCode) {
15 | window.localStorage.removeItem('authCode');
16 | return;
17 | }
18 | window.localStorage.setItem('authCode', authCode);
19 | };
20 |
--------------------------------------------------------------------------------
/apps/web/src/utils/env.ts:
--------------------------------------------------------------------------------
1 | export const isProd = import.meta.env.PROD;
2 |
3 | export const serverOriginUrl = isProd
4 | ? window.__WEWE_RSS_SERVER_ORIGIN_URL__
5 | : import.meta.env.VITE_SERVER_ORIGIN_URL;
6 |
7 | export const appVersion = __APP_VERSION__;
8 |
9 | export const enabledAuthCode =
10 | window.__WEWE_RSS_ENABLED_AUTH_CODE__ === false ? false : true;
11 |
--------------------------------------------------------------------------------
/apps/web/src/utils/trpc.ts:
--------------------------------------------------------------------------------
1 | import { AppRouter } from '@server/trpc/trpc.router';
2 | import { TRPCClientError, createTRPCReact } from '@trpc/react-query';
3 |
4 | export const trpc = createTRPCReact();
5 |
6 | export function isTRPCClientError(
7 | cause: unknown,
8 | ): cause is TRPCClientError {
9 | return cause instanceof TRPCClientError;
10 | }
11 |
--------------------------------------------------------------------------------
/apps/web/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | interface ImportMetaEnv {
4 | readonly VITE_SERVER_ORIGIN_URL: string;
5 | readonly VITE_ENV: string;
6 | }
7 |
8 | interface Window {
9 | __WEWE_RSS_SERVER_ORIGIN_URL__?: string;
10 | __WEWE_RSS_ENABLED_AUTH_CODE__?: boolean;
11 | }
12 |
13 | declare const __APP_VERSION__: string;
14 |
--------------------------------------------------------------------------------
/apps/web/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss';
2 | import { nextui } from '@nextui-org/react';
3 |
4 | const config: Config = {
5 | content: [
6 | './index.html',
7 | './src/**/*.{js,ts,jsx,tsx}',
8 | '../../node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}',
9 | ],
10 | theme: {
11 | extend: {},
12 | },
13 | darkMode: 'class',
14 | plugins: [nextui()],
15 | };
16 | export default config;
17 |
--------------------------------------------------------------------------------
/apps/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": false,
21 | "noUnusedParameters": false,
22 | "noFallthroughCasesInSwitch": true,
23 | "noImplicitAny": false
24 | },
25 | "include": ["src"],
26 | "references": [{ "path": "./tsconfig.node.json" }]
27 | }
28 |
--------------------------------------------------------------------------------
/apps/web/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true,
8 | "strict": true
9 | },
10 | "include": ["vite.config.ts"]
11 | }
12 |
--------------------------------------------------------------------------------
/apps/web/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import react from '@vitejs/plugin-react';
3 | import { resolve } from 'path';
4 | import { readFileSync } from 'fs';
5 |
6 | const projectRootDir = resolve(__dirname);
7 |
8 | const isProd = process.env.NODE_ENV === 'production';
9 |
10 | console.log('process.env.NODE_ENV: ', process.env.NODE_ENV);
11 |
12 | const packageJson = JSON.parse(
13 | readFileSync(resolve(__dirname, './package.json'), 'utf-8'),
14 | );
15 |
16 | // https://vitejs.dev/config/
17 | export default defineConfig({
18 | base: '/dash',
19 | define: {
20 | __APP_VERSION__: JSON.stringify(packageJson.version),
21 | },
22 | plugins: [
23 | react(),
24 | !isProd
25 | ? null
26 | : {
27 | name: 'renameIndex',
28 | enforce: 'post',
29 | generateBundle(options, bundle) {
30 | const indexHtml = bundle['index.html'];
31 | indexHtml.fileName = 'index.hbs';
32 | },
33 | },
34 | ],
35 | resolve: {
36 | alias: [
37 | {
38 | find: '@server',
39 | replacement: resolve(projectRootDir, '../apps/server/src'),
40 | },
41 | {
42 | find: '@web',
43 | replacement: resolve(projectRootDir, './src'),
44 | },
45 | ],
46 | },
47 | build: {
48 | emptyOutDir: true,
49 | outDir: resolve(projectRootDir, '..', 'server', 'client'),
50 | },
51 | });
52 |
--------------------------------------------------------------------------------
/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cooderl/wewe-rss/253fa078262040bf7bc9196b30c2120d56709e90/assets/logo.png
--------------------------------------------------------------------------------
/assets/nginx.example.conf:
--------------------------------------------------------------------------------
1 | server {
2 |
3 | listen 80;
4 |
5 | server_name yourdomain;
6 |
7 | location / {
8 |
9 | proxy_pass http://127.0.0.1:4000;
10 | proxy_http_version 1.1;
11 | proxy_set_header Connection "";
12 | proxy_set_header Host $http_host;
13 | proxy_set_header X-Forwarded-Proto $scheme;
14 | proxy_set_header X-Real-IP $remote_addr;
15 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
16 | proxy_set_header Accept-Encoding gzip;
17 |
18 | proxy_buffering off;
19 | proxy_cache off;
20 |
21 | send_timeout 300;
22 | proxy_connect_timeout 300;
23 | proxy_send_timeout 300;
24 | proxy_read_timeout 300;
25 | }
26 |
27 | }
28 |
29 |
30 |
--------------------------------------------------------------------------------
/assets/preview1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cooderl/wewe-rss/253fa078262040bf7bc9196b30c2120d56709e90/assets/preview1.png
--------------------------------------------------------------------------------
/assets/preview2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cooderl/wewe-rss/253fa078262040bf7bc9196b30c2120d56709e90/assets/preview2.png
--------------------------------------------------------------------------------
/assets/preview3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cooderl/wewe-rss/253fa078262040bf7bc9196b30c2120d56709e90/assets/preview3.png
--------------------------------------------------------------------------------
/docker-compose.dev.yml:
--------------------------------------------------------------------------------
1 | services:
2 | mysql:
3 | image: mysql:8.3.0
4 | command: --default-authentication-plugin=mysql_native_password
5 | environment:
6 | MYSQL_ROOT_PASSWORD: 123456
7 | TZ: 'Asia/Shanghai'
8 | ports:
9 | - 3306:3306
10 | volumes:
11 | mysql:
12 |
--------------------------------------------------------------------------------
/docker-compose.sqlite.yml:
--------------------------------------------------------------------------------
1 | version: '3.9'
2 |
3 | services:
4 | app:
5 | image: cooderl/wewe-rss-sqlite:latest
6 | ports:
7 | - 4000:4000
8 | environment:
9 | # 数据库连接地址
10 | # - DATABASE_URL=file:../data/wewe-rss.db
11 | - DATABASE_TYPE=sqlite
12 | # 服务接口请求授权码
13 | - AUTH_CODE=123567
14 | # 提取全文内容模式
15 | # - FEED_MODE=fulltext
16 | # 定时更新订阅源Cron表达式
17 | # - CRON_EXPRESSION=35 5,17 * * *
18 | # 服务接口请求限制,每分钟请求次数
19 | # - MAX_REQUEST_PER_MINUTE=60
20 | # 外网访问时,需设置为服务器的公网 IP 或者域名地址
21 | # - SERVER_ORIGIN_URL=http://localhost:4000
22 |
23 | volumes:
24 | # 映射数据库文件存储位置,容器重启后不丢失
25 | - ./data:/app/data
26 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.9'
2 |
3 | services:
4 | db:
5 | image: mysql:8.3.0
6 | command: --mysql-native-password=ON
7 | environment:
8 | # 请修改为自己的密码
9 | MYSQL_ROOT_PASSWORD: 123456
10 | TZ: 'Asia/Shanghai'
11 | MYSQL_DATABASE: 'wewe-rss'
12 | # ports:
13 | # - 13306:3306
14 | volumes:
15 | - db_data:/var/lib/mysql
16 | healthcheck:
17 | test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost']
18 | timeout: 45s
19 | interval: 10s
20 | retries: 10
21 |
22 | app:
23 | image: cooderl/wewe-rss:latest
24 | ports:
25 | - 4000:4000
26 | depends_on:
27 | db:
28 | condition: service_healthy
29 | environment:
30 | # 数据库连接地址
31 | - DATABASE_URL=mysql://root:123456@db:3306/wewe-rss?schema=public&connect_timeout=30&pool_timeout=30&socket_timeout=30
32 | # 服务接口请求授权码
33 | - AUTH_CODE=123567
34 | # 提取全文内容模式
35 | # - FEED_MODE=fulltext
36 | # 定时更新订阅源Cron表达式
37 | # - CRON_EXPRESSION=35 5,17 * * *
38 | # 服务接口请求限制,每分钟请求次数
39 | # - MAX_REQUEST_PER_MINUTE=60
40 | # 外网访问时,需设置为服务器的公网 IP 或者域名地址
41 | # - SERVER_ORIGIN_URL=http://localhost:4000
42 |
43 | networks:
44 | wewe-rss:
45 |
46 | volumes:
47 | db_data:
48 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "wewe-rss",
3 | "version": "2.6.1",
4 | "private": true,
5 | "author": "cooderl ",
6 | "description": "",
7 | "main": "index.js",
8 | "engines": {
9 | "node": ">=20.9.0",
10 | "pnpm": ">=8.6.1",
11 | "vscode": ">=1.79"
12 | },
13 | "scripts": {
14 | "dev": "pnpm run --parallel dev",
15 | "build:server": "pnpm --filter server build",
16 | "build:web": "pnpm --filter web build",
17 | "start:server": "pnpm --filter server start:prod",
18 | "start:web": "pnpm --filter web start",
19 | "fmt": "prettier --write .",
20 | "fmt.check": "prettier --check ."
21 | },
22 | "devDependencies": {
23 | "prettier": "^3.2.5"
24 | }
25 | }
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - 'apps/*'
3 |
--------------------------------------------------------------------------------
/release.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # 检查是否提供了版本号
4 | if [ "$#" -ne 1 ]; then
5 | echo "Usage: $0 "
6 | exit 1
7 | fi
8 |
9 | # 新版本号
10 | NEW_VERSION=$1
11 |
12 | # 更新根目录下的 package.json
13 | sed -i '' "s/\"version\": \".*\"/\"version\": \"$NEW_VERSION\"/" package.json
14 |
15 | # 更新 apps 目录下所有子包的 package.json
16 | for d in apps/*; do
17 | if [ -d "$d" ] && [ -f "$d/package.json" ]; then
18 | sed -i '' "s/\"version\": \".*\"/\"version\": \"$NEW_VERSION\"/" "$d/package.json"
19 | fi
20 | done
21 |
22 | echo "All packages updated to version $NEW_VERSION"
23 |
24 | # 创建 Git 提交(可选)
25 | git add .
26 | git commit -m "Release version $NEW_VERSION"
27 |
28 | # 创建 Git 标签
29 | git tag "v$NEW_VERSION"
30 |
31 | # 推送更改和标签到远程仓库
32 | git push && git push origin --tags
33 |
34 | echo "Git tag v$NEW_VERSION has been created and pushed"
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "experimentalDecorators": true,
5 | "emitDecoratorMetadata": true,
6 | "incremental": true,
7 | "skipLibCheck": true,
8 | "strictNullChecks": true,
9 | "noImplicitAny": false,
10 | "strictBindCallApply": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "noFallthroughCasesInSwitch": true,
13 | "paths": {
14 | "@server/*": [
15 | "./apps/server/src/*"
16 | ],
17 | "@web/*": [
18 | "./apps/web/src/*"
19 | ]
20 | }
21 | }
22 | }
--------------------------------------------------------------------------------
/wewe-rss-dingtalk/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.8.12-slim
2 |
3 | WORKDIR /app
4 | COPY . .
5 |
6 | RUN pip install -r requirements.txt
7 | ENV TZ=Asia/Shanghai \
8 | DEBIAN_FRONTEND=noninteractive
9 |
10 | CMD python3 /app/main.py
11 |
--------------------------------------------------------------------------------
/wewe-rss-dingtalk/README.md:
--------------------------------------------------------------------------------
1 | ### 修改main.py,输入dingtalk的access_token和secret
2 |
3 | ```
4 | access_token = ''
5 | secret = '' # 创建机器人时钉钉设置页面有提供
6 | ```
7 |
8 | ### 修改根目录下的docker-compose.yaml文件,去掉以下字段的注释
9 |
10 | ```
11 | # ports:
12 | # - 13306:3306
13 | ```
14 |
15 | ### python3 main.py就可以运行
16 |
17 | ### 或者部署成docker,运行
18 |
19 | ```
20 | sudo docker-compose up -d
21 | ```
22 |
--------------------------------------------------------------------------------
/wewe-rss-dingtalk/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.9'
2 |
3 | services:
4 | wewe-rss-dingtalk:
5 | build: .
6 | container_name: wewe-rss-dingtalk
7 |
--------------------------------------------------------------------------------
/wewe-rss-dingtalk/main.py:
--------------------------------------------------------------------------------
1 | import mysql.connector
2 | import requests
3 | import json
4 | import os
5 | import time
6 | from datetime import datetime, timedelta
7 | import pytz
8 | from dingtalkchatbot.chatbot import DingtalkChatbot, ActionCard, FeedLink, CardItem
9 |
10 | def get_subjects_json():
11 | # 连接MySQL数据库
12 | mydb = mysql.connector.connect(
13 | host="localhost",
14 | port="13306",
15 | user="root",
16 | password="123456",
17 | database="wewe-rss"
18 | )
19 | # 查询符合条件的数据, 用created_at来判断,因为publish_time是发文时间,rss更新时间会滞后
20 | mycursor = mydb.cursor()
21 | query = """SELECT a.id, a.title, a.pic_url, a.publish_time, b.mp_name
22 | FROM articles AS a, feeds AS b
23 | WHERE a.mp_id = b.id
24 | AND a.created_at >= NOW() - INTERVAL 12 HOUR
25 | ORDER BY a.publish_time DESC"""
26 | # 4hour +8 to fix created time is UTC time.
27 | mycursor.execute(query)
28 | results = mycursor.fetchall()
29 |
30 | # 组装数据为JSON格式
31 | data = []
32 | for result in results:
33 | subject = {
34 | "id": result[0],
35 | "title": result[1],
36 | "pic_url": result[2],
37 | "publish_time": result[3],
38 | "mp_name": result[4]
39 | }
40 | data.append(subject)
41 |
42 | json_data = json.dumps(data, indent=4)
43 | print(json_data)
44 | return json_data
45 |
46 | def dingbot_markdown(access_token, secret, rss_list):
47 | new_webhook = f'https://oapi.dingtalk.com/robot/send?access_token={access_token}'
48 | xiaoding = DingtalkChatbot(new_webhook, secret=secret, pc_slide=True, fail_notice=False)
49 |
50 | text = []
51 | for data in rss_list:
52 | # 创建CardItem对象
53 | mp_name = data['mp_name']
54 | url = 'https://mp.weixin.qq.com/s/' + str(data["id"])
55 | unix_timestamp = data['publish_time']
56 | # 将 Unix 时间戳转换为北京时间
57 | #转换成localtime
58 | time_local = time.localtime(unix_timestamp)
59 | #转换成新的时间格式(2016-05-05 20:28:54)
60 | beijing_time = time.strftime("%Y-%m-%d %H:%M:%S",time_local)
61 | text_content = f'> **{mp_name}** [' + data["title"] + '](' + url + ') ' + str(beijing_time) + '\n'
62 | # Markdown消息@指定用户
63 | text.append(text_content)
64 |
65 | title = '## 微信公众号<最近4小时更新> \n\n'
66 | markdown_text = title + '\n'.join(text)
67 | print(markdown_text)
68 | res = xiaoding.send_markdown(title=title, text=markdown_text)
69 | print(f"send sucess, res: {res}")
70 |
71 |
72 | def send_dingtalk_msg(access_token, secret):
73 | data = get_subjects_json()
74 | rss_list = json.loads(data)
75 | if len(rss_list) != 0:
76 | dingbot_markdown(access_token, secret, rss_list)
77 |
78 | if __name__ == '__main__':
79 |
80 | access_token = ''
81 | secret = '' # 创建机器人时钉钉设置页面有提供
82 |
83 | while True:
84 | send_dingtalk_msg(access_token, secret)
85 | time.sleep( 4 * 60 * 60 ) # run every 4 hours
86 |
--------------------------------------------------------------------------------
/wewe-rss-dingtalk/requirements.txt:
--------------------------------------------------------------------------------
1 | DingtalkChatbot==1.5.3
2 | mysql-connector-python
3 | jason
4 | pytz
--------------------------------------------------------------------------------