├── .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 | ![主界面](https://raw.githubusercontent.com/cooderl/wewe-rss/main/assets/preview1.png) 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 | Donate_WeChat.jpg 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 | Nest Logo 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 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 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 |
10 |
>> WeWe RSS <<
11 |
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 | WeWe RSS 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 | 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 |
391 | 392 |
393 |
394 | 395 | 396 | 397 | {(onClose) => ( 398 | <> 399 | 400 | 添加公众号源 401 | 402 | 403 |