Repository: cooderl/wewe-rss Branch: main Commit: e751c6429408 Files: 92 Total size: 126.6 KB Directory structure: gitextract_odxctc3w/ ├── .dockerignore ├── .github/ │ └── workflows/ │ └── docker-release.yml ├── .gitignore ├── .markdownlint.yaml ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── apps/ │ ├── server/ │ │ ├── .eslintrc.js │ │ ├── .gitignore │ │ ├── .prettierrc.json │ │ ├── README.md │ │ ├── docker-bootstrap.sh │ │ ├── nest-cli.json │ │ ├── package.json │ │ ├── prisma/ │ │ │ ├── migrations/ │ │ │ │ ├── 20240227153512_init/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20241212153618_has_history/ │ │ │ │ │ └── migration.sql │ │ │ │ └── migration_lock.toml │ │ │ └── schema.prisma │ │ ├── prisma-sqlite/ │ │ │ ├── migrations/ │ │ │ │ ├── 20240301104100_init/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20241214172323_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/ │ ├── .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/ │ └── nginx.example.conf ├── docker-compose.dev.yml ├── docker-compose.sqlite.yml ├── docker-compose.yml ├── package.json ├── pnpm-workspace.yaml ├── release.sh ├── tsconfig.json └── wewe-rss-dingtalk/ ├── Dockerfile ├── README.md ├── docker-compose.yml ├── main.py └── requirements.txt ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ node_modules .git .gitignore *.md dist .env .next .DS_Store ./wewe-rss-dingtalk ================================================ FILE: .github/workflows/docker-release.yml ================================================ name: Build WeWeRSS images and push image to docker hub on: workflow_dispatch: push: # paths: # - "apps/**" # - "Dockerfile" tags: - 'v*.*.*' concurrency: group: docker-release cancel-in-progress: true jobs: check-env: permissions: contents: none runs-on: ubuntu-latest timeout-minutes: 5 outputs: check-docker: ${{ steps.check-docker.outputs.defined }} steps: - id: check-docker env: DOCKER_HUB_NAME: ${{ secrets.DOCKER_HUB_NAME }} if: ${{ env.DOCKER_HUB_NAME != '' }} run: echo "defined=true" >> $GITHUB_OUTPUT release-images: runs-on: ubuntu-latest timeout-minutes: 120 permissions: packages: write contents: read id-token: write steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 1 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_HUB_NAME }} password: ${{ secrets.DOCKER_HUB_PASSWORD }} - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract Docker metadata (sqlite) id: meta-sqlite uses: docker/metadata-action@v5 with: images: | ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss-sqlite ghcr.io/cooderl/wewe-rss-sqlite tags: | type=raw,value=latest,enable=true type=raw,value=${{ github.ref_name }},enable=true flavor: latest=false - name: Build and push Docker image (sqlite) id: build-and-push-sqlite uses: docker/build-push-action@v5 with: context: . push: true tags: ${{ steps.meta-sqlite.outputs.tags }} labels: ${{ steps.meta-sqlite.outputs.labels }} target: app-sqlite platforms: linux/amd64,linux/arm64 cache-from: type=gha,scope=docker-release cache-to: type=gha,mode=max,scope=docker-release - name: Extract Docker metadata id: meta uses: docker/metadata-action@v5 with: images: | ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss ghcr.io/cooderl/wewe-rss tags: | type=raw,value=latest,enable=true type=raw,value=${{ github.ref_name }},enable=true flavor: latest=false - name: Build and push Docker image id: build-and-push uses: docker/build-push-action@v5 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} target: app platforms: linux/amd64,linux/arm64 cache-from: type=gha,scope=docker-release cache-to: type=gha,mode=max,scope=docker-release - name: Set env run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV - name: Create a Release uses: elgohr/Github-Release-Action@v5 env: GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} with: title: ${{ env.RELEASE_VERSION }} description: runs-on: ubuntu-latest needs: check-env if: needs.check-env.outputs.check-docker == 'true' timeout-minutes: 5 steps: - uses: actions/checkout@v4 - name: Docker Hub Description(sqlite) uses: peter-evans/dockerhub-description@v4 with: username: ${{ secrets.DOCKER_HUB_NAME }} password: ${{ secrets.DOCKER_HUB_PASSWORD }} repository: ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss-sqlite - name: Docker Hub Description uses: peter-evans/dockerhub-description@v4 with: username: ${{ secrets.DOCKER_HUB_NAME }} password: ${{ secrets.DOCKER_HUB_PASSWORD }} repository: ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* .pnpm-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional stylelint cache .stylelintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variable files .env .env.development.local .env.test.local .env.production.local .env.local # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache # Next.js build output .next out # Nuxt.js build / generate output .nuxt dist # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and not Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # vuepress v2.x temp and cache directory .temp .cache # Docusaurus cache and generated files .docusaurus # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # yarn v2 .yarn/cache .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz .pnp.* .DS_Store ================================================ FILE: .markdownlint.yaml ================================================ # Default state for all rules default: true line-length: false # MD033/no-inline-html - Inline HTML MD033: # Allowed elements allowed_elements: ['style'] ================================================ FILE: .npmrc ================================================ public-hoist-pattern[]=*@nextui-org/* engine-strict=true deploy-all-files=true ================================================ FILE: .prettierignore ================================================ **/*.log **/.DS_Store *. *.json apps/web/.next dist node_modules pnpm-lock.yaml ================================================ FILE: .prettierrc.json ================================================ { "tabWidth": 2, "singleQuote": true, "trailingComma": "all" } ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", "stylelint.vscode-stylelint", "streetsidesoftware.code-spell-checker", "DavidAnson.vscode-markdownlint", "Gruntfuggly.todo-tree", "mikestead.dotenv", "foxundermoon.next-js", "Prisma.prisma", "planbcoding.vscode-react-refactor", "yoavbls.pretty-ts-errors", "usernamehw.errorlens" ] } ================================================ FILE: .vscode/settings.json ================================================ { "typescript.tsdk": "node_modules/.pnpm/typescript@4.9.4/node_modules/typescript/lib", "typescript.enablePromptUseWorkspaceTsdk": true, "[javascript]": { "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescript]": { "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[html]": { "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[scss]": { "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[css]": { "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[yaml]": { "editor.formatOnSave": true, "editor.defaultFormatter": "redhat.vscode-yaml" }, "[json]": { "editor.formatOnSave": true, "editor.defaultFormatter": "vscode.json-language-features" }, "cSpell.words": [ "callout", "checkstyle", "commitlint", "daisyui", "nestjs", "nextui", "tailwindcss", "Trpc", "wewe" ] } ================================================ FILE: Dockerfile ================================================ FROM node:20.16.0-alpine AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN npm i -g pnpm FROM base AS build COPY . /usr/src/app WORKDIR /usr/src/app RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile RUN pnpm run -r build RUN pnpm deploy --filter=server --prod /app RUN pnpm deploy --filter=server --prod /app-sqlite RUN cd /app && pnpm exec prisma generate RUN cd /app-sqlite && \ rm -rf ./prisma && \ mv prisma-sqlite prisma && \ pnpm exec prisma generate FROM base AS app-sqlite COPY --from=build /app-sqlite /app WORKDIR /app EXPOSE 4000 ENV NODE_ENV=production ENV HOST="0.0.0.0" ENV SERVER_ORIGIN_URL="" ENV MAX_REQUEST_PER_MINUTE=60 ENV AUTH_CODE="" ENV DATABASE_URL="file:../data/wewe-rss.db" ENV DATABASE_TYPE="sqlite" RUN chmod +x ./docker-bootstrap.sh CMD ["./docker-bootstrap.sh"] FROM base AS app COPY --from=build /app /app WORKDIR /app EXPOSE 4000 ENV NODE_ENV=production ENV HOST="0.0.0.0" ENV SERVER_ORIGIN_URL="" ENV MAX_REQUEST_PER_MINUTE=60 ENV AUTH_CODE="" ENV DATABASE_URL="" RUN chmod +x ./docker-bootstrap.sh CMD ["./docker-bootstrap.sh"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 cooderl Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================
预览 # [WeWe RSS](https://github.com/cooderl/wewe-rss) 更优雅的微信公众号订阅方式。 ![主界面](https://raw.githubusercontent.com/cooderl/wewe-rss/main/assets/preview1.png)
## ✨ 功能 - v2.x版本使用全新接口,更加稳定 - 支持微信公众号订阅(基于微信读书) - 获取公众号历史发布文章 - 后台自动定时更新内容 - 微信公众号RSS生成(支持`.atom`、`.rss`、`.json`格式) - 支持全文内容输出,让阅读无障碍 - 所有订阅源导出OPML ### 高级功能 - **标题过滤**:支持通过`/feeds/all.(json|rss|atom)`接口和`/feeds/:feed`对标题进行过滤 ``` {{ORIGIN_URL}}/feeds/all.atom?title_include=张三 {{ORIGIN_URL}}/feeds/MP_WXS_123.json?limit=30&title_include=张三|李四|王五&title_exclude=张三丰|赵六 ``` - **手动更新**:支持通过`/feeds/:feed`接口触发单个feedid更新 ``` {{ORIGIN_URL}}/feeds/MP_WXS_123.rss?update=true ``` ## 🚀 部署 ### 一键部署 - [Deploy on Zeabur](https://zeabur.com/templates/DI9BBD) - [Railway](https://railway.app/) - [Hugging Face部署参考](https://github.com/cooderl/wewe-rss/issues/32) ### Docker Compose 部署 参考 [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) ### Docker 命令启动 #### MySQL (推荐) 1. 创建docker网络 ```sh docker network create wewe-rss ``` 2. 启动 MySQL 数据库 ```sh docker run -d \ --name db \ -e MYSQL_ROOT_PASSWORD=123456 \ -e TZ='Asia/Shanghai' \ -e MYSQL_DATABASE='wewe-rss' \ -v db_data:/var/lib/mysql \ --network wewe-rss \ mysql:8.3.0 --mysql-native-password=ON ``` 3. 启动 Server ```sh docker run -d \ --name wewe-rss \ -p 4000:4000 \ -e DATABASE_URL='mysql://root:123456@db:3306/wewe-rss?schema=public&connect_timeout=30&pool_timeout=30&socket_timeout=30' \ -e AUTH_CODE=123567 \ --network wewe-rss \ cooderl/wewe-rss:latest ``` [Nginx配置参考](https://raw.githubusercontent.com/cooderl/wewe-rss/main/assets/nginx.example.conf) #### SQLite (不推荐) ```sh docker run -d \ --name wewe-rss \ -p 4000:4000 \ -e DATABASE_TYPE=sqlite \ -e AUTH_CODE=123567 \ -v $(pwd)/data:/app/data \ cooderl/wewe-rss-sqlite:latest ``` ### 本地部署 使用 `pnpm install && pnpm run -r build && pnpm run start:server` 命令 (可配合 pm2 守护进程) **详细步骤** (SQLite示例): ```shell # 需要提前声明环境变量,因为prisma会根据环境变量生成对应的数据库连接 export DATABASE_URL="file:../data/wewe-rss.db" export DATABASE_TYPE="sqlite" # 删除mysql相关文件,避免prisma生成mysql连接 rm -rf apps/server/prisma mv apps/server/prisma-sqlite apps/server/prisma # 生成prisma client npx prisma generate --schema apps/server/prisma/schema.prisma # 生成数据库表 npx prisma migrate deploy --schema apps/server/prisma/schema.prisma # 构建并运行 pnpm run -r build pnpm run start:server ``` ## ⚙️ 环境变量 | 变量名 | 说明 | 默认值 | | ------------------------ | ----------------------------------------------------------------------- | --------------------------- | | `DATABASE_URL` | **必填** 数据库地址,例如 `mysql://root:123456@127.0.0.1:3306/wewe-rss` | - | | `DATABASE_TYPE` | 数据库类型,使用 SQLite 时需填写 `sqlite` | - | | `AUTH_CODE` | 服务端接口请求授权码,空字符或不设置将不启用 (`/feeds`路径不需要) | - | | `SERVER_ORIGIN_URL` | 服务端访问地址,用于生成RSS完整路径 | - | | `MAX_REQUEST_PER_MINUTE` | 每分钟最大请求次数 | 60 | | `FEED_MODE` | 输出模式,可选值 `fulltext` (会使接口响应变慢,占用更多内存) | - | | `CRON_EXPRESSION` | 定时更新订阅源Cron表达式 | `35 5,17 * * *` | | `UPDATE_DELAY_TIME` | 连续更新延迟时间,减少被关小黑屋 | `60s` | | `ENABLE_CLEAN_HTML` | 是否开启正文html清理 | `false` | | `PLATFORM_URL` | 基础服务URL | `https://weread.111965.xyz` | > **注意**: 国内DNS解析问题可使用 `https://weread.965111.xyz` 加速访问 ## 🔔 钉钉通知 进入 wewe-rss-dingtalk 目录按照 README.md 指引部署 ## 📱 使用方式 1. 进入账号管理,点击添加账号,微信扫码登录微信读书账号。 **注意不要勾选24小时后自动退出** 2. 进入公众号源,点击添加,通过提交微信公众号分享链接,订阅微信公众号。 **添加频率过高容易被封控,等24小时解封** ## 🔑 账号状态说明 | 状态 | 说明 | | ---------- | ------------------------------------------------------------------- | | 今日小黑屋 | 账号被封控,等一天恢复。账号正常时可通过重启服务/容器清除小黑屋记录 | | 禁用 | 不使用该账号 | | 失效 | 账号登录状态失效,需要重新登录 | ## 💻 本地开发 1. 安装 nodejs 20 和 pnpm 2. 修改环境变量: ``` cp ./apps/web/.env.local.example ./apps/web/.env cp ./apps/server/.env.local.example ./apps/server/.env ``` 3. 执行 `pnpm install && pnpm run build:web && pnpm dev` ⚠️ **注意:此命令仅用于本地开发,不要用于部署!** 4. 前端访问 `http://localhost:5173`,后端访问 `http://localhost:4000` ## ⚠️ 风险声明 为了确保本项目的持久运行,某些接口请求将通过 `weread.111965.xyz` 进行转发。请放心,该转发服务不会保存任何数据。 ## ❤️ 赞助 如果觉得 WeWe RSS 项目对你有帮助,可以给我来一杯啤酒! **PayPal**: [paypal.me/cooderl](https://paypal.me/cooderl) ## 👨‍💻 贡献者 ## 📄 License [MIT](https://raw.githubusercontent.com/cooderl/wewe-rss/main/LICENSE) @cooderl ================================================ FILE: apps/server/.eslintrc.js ================================================ module.exports = { parser: '@typescript-eslint/parser', parserOptions: { project: 'tsconfig.json', tsconfigRootDir: __dirname, sourceType: 'module', }, plugins: ['@typescript-eslint/eslint-plugin'], extends: [ 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', ], root: true, env: { node: true, jest: true, }, ignorePatterns: ['.eslintrc.js'], rules: { '@typescript-eslint/interface-name-prefix': 'off', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', }, }; ================================================ FILE: apps/server/.gitignore ================================================ node_modules # Keep environment variables out of version control .env client data ================================================ FILE: apps/server/.prettierrc.json ================================================ { "tabWidth": 2, "singleQuote": true, "trailingComma": "all" } ================================================ FILE: apps/server/README.md ================================================

Nest Logo

[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 [circleci-url]: https://circleci.com/gh/nestjs/nest

A progressive Node.js framework for building efficient and scalable server-side applications.

NPM Version Package License NPM Downloads CircleCI Coverage Discord Backers on Open Collective Sponsors on Open Collective Support us

## Description [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. ## Installation ```bash $ pnpm install ``` ## Running the app ```bash # development $ pnpm run start # watch mode $ pnpm run start:dev # production mode $ pnpm run start:prod ``` ## Test ```bash # unit tests $ pnpm run test # e2e tests $ pnpm run test:e2e # test coverage $ pnpm run test:cov ``` ## Support 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). ## Stay in touch - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) - Website - [https://nestjs.com](https://nestjs.com/) - Twitter - [@nestframework](https://twitter.com/nestframework) ## License Nest is [MIT licensed](LICENSE). ================================================ FILE: apps/server/docker-bootstrap.sh ================================================ #!/bin/sh # ENVIRONEMTN from docker-compose.yaml doesn't get through to subprocesses # Need to explicit pass DATABASE_URL here, otherwise migration doesn't work # Run migrations DATABASE_URL=${DATABASE_URL} npx prisma migrate deploy # start app DATABASE_URL=${DATABASE_URL} node dist/main ================================================ FILE: apps/server/nest-cli.json ================================================ { "$schema": "https://json.schemastore.org/nest-cli", "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { "deleteOutDir": true } } ================================================ FILE: apps/server/package.json ================================================ { "name": "server", "version": "2.6.1", "description": "", "author": "", "private": true, "license": "UNLICENSED", "scripts": { "build": "nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "nest start", "dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", "start:migrate:prod": "prisma migrate deploy && npm run start:prod", "postinstall": "npx prisma generate", "migrate": "pnpm prisma migrate dev", "studio": "pnpm prisma studio", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { "@cjs-exporter/p-map": "^5.5.0", "@nestjs/common": "^10.3.3", "@nestjs/config": "^3.2.0", "@nestjs/core": "^10.3.3", "@nestjs/platform-express": "^10.3.3", "@nestjs/schedule": "^4.0.1", "@nestjs/throttler": "^5.1.2", "@prisma/client": "5.10.1", "@trpc/server": "^10.45.1", "axios": "^1.6.7", "cheerio": "1.0.0-rc.12", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dayjs": "^1.11.10", "express": "^4.18.2", "feed": "^4.2.2", "got": "11.8.6", "hbs": "^4.2.0", "html-minifier": "^4.0.0", "lru-cache": "^10.2.2", "prisma": "^5.10.2", "reflect-metadata": "^0.2.1", "rxjs": "^7.8.1", "zod": "^3.22.4" }, "devDependencies": { "@nestjs/cli": "^10.3.2", "@nestjs/schematics": "^10.1.1", "@nestjs/testing": "^10.3.3", "@types/express": "^4.17.21", "@types/html-minifier": "^4.0.5", "@types/jest": "^29.5.12", "@types/node": "^20.11.19", "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^7.0.2", "@typescript-eslint/parser": "^7.0.2", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "jest": "^29.7.0", "prettier": "^3.2.5", "source-map-support": "^0.5.21", "supertest": "^6.3.4", "ts-jest": "^29.1.2", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.3.3" }, "jest": { "moduleFileExtensions": [ "js", "json", "ts" ], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, "collectCoverageFrom": [ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", "testEnvironment": "node" } } ================================================ FILE: apps/server/prisma/migrations/20240227153512_init/migration.sql ================================================ -- CreateTable CREATE TABLE `accounts` ( `id` VARCHAR(255) NOT NULL, `token` VARCHAR(2048) NOT NULL, `name` VARCHAR(1024) NOT NULL, `status` INTEGER NOT NULL DEFAULT 1, `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), `updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (`id`) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- CreateTable CREATE TABLE `feeds` ( `id` VARCHAR(255) NOT NULL, `mp_name` VARCHAR(512) NOT NULL, `mp_cover` VARCHAR(1024) NOT NULL, `mp_intro` TEXT NOT NULL, `status` INTEGER NOT NULL DEFAULT 1, `sync_time` INTEGER NOT NULL DEFAULT 0, `update_time` INTEGER NOT NULL, `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), `updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (`id`) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- CreateTable CREATE TABLE `articles` ( `id` VARCHAR(255) NOT NULL, `mp_id` VARCHAR(255) NOT NULL, `title` VARCHAR(255) NOT NULL, `pic_url` VARCHAR(255) NOT NULL, `publish_time` INTEGER NOT NULL, `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), `updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (`id`) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ================================================ FILE: apps/server/prisma/migrations/20241212153618_has_history/migration.sql ================================================ -- AlterTable ALTER TABLE `feeds` ADD COLUMN `has_history` INTEGER NULL DEFAULT 1; ================================================ FILE: apps/server/prisma/migrations/migration_lock.toml ================================================ # Please do not edit this file manually # It should be added in your version-control system (i.e. Git) provider = "mysql" ================================================ FILE: apps/server/prisma/schema.prisma ================================================ datasource db { provider = "mysql" url = env("DATABASE_URL") } generator client { provider = "prisma-client-js" binaryTargets = ["native", "linux-musl"] // 生成linux可执行文件 } // 读书账号 model Account { id String @id @db.VarChar(255) token String @map("token") @db.VarChar(2048) name String @map("name") @db.VarChar(1024) // 状态 0:失效 1:启用 2:禁用 status Int @default(1) @map("status") @db.Int() createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") @@map("accounts") } // 订阅源 model Feed { id String @id @db.VarChar(255) mpName String @map("mp_name") @db.VarChar(512) mpCover String @map("mp_cover") @db.VarChar(1024) mpIntro String @map("mp_intro") @db.Text() // 状态 0:失效 1:启用 2:禁用 status Int @default(1) @map("status") @db.Int() // article最后同步时间 syncTime Int @default(0) @map("sync_time") // 信息更新时间 updateTime Int @map("update_time") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") // 是否有历史文章 1 是 0 否 hasHistory Int? @default(1) @map("has_history") @@map("feeds") } model Article { id String @id @db.VarChar(255) mpId String @map("mp_id") @db.VarChar(255) title String @map("title") @db.VarChar(255) picUrl String @map("pic_url") @db.VarChar(255) publishTime Int @map("publish_time") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") @@map("articles") } ================================================ FILE: apps/server/prisma-sqlite/migrations/20240301104100_init/migration.sql ================================================ -- CreateTable CREATE TABLE "accounts" ( "id" TEXT NOT NULL PRIMARY KEY, "token" TEXT NOT NULL, "name" TEXT NOT NULL, "status" INTEGER NOT NULL DEFAULT 1, "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP ); -- CreateTable CREATE TABLE "feeds" ( "id" TEXT NOT NULL PRIMARY KEY, "mp_name" TEXT NOT NULL, "mp_cover" TEXT NOT NULL, "mp_intro" TEXT NOT NULL, "status" INTEGER NOT NULL DEFAULT 1, "sync_time" INTEGER NOT NULL DEFAULT 0, "update_time" INTEGER NOT NULL, "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP ); -- CreateTable CREATE TABLE "articles" ( "id" TEXT NOT NULL PRIMARY KEY, "mp_id" TEXT NOT NULL, "title" TEXT NOT NULL, "pic_url" TEXT NOT NULL, "publish_time" INTEGER NOT NULL, "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP ); ================================================ FILE: apps/server/prisma-sqlite/migrations/20241214172323_has_history/migration.sql ================================================ -- AlterTable ALTER TABLE "feeds" ADD COLUMN "has_history" INTEGER DEFAULT 1; ================================================ FILE: apps/server/prisma-sqlite/migrations/migration_lock.toml ================================================ # Please do not edit this file manually # It should be added in your version-control system (i.e. Git) provider = "sqlite" ================================================ FILE: apps/server/prisma-sqlite/schema.prisma ================================================ datasource db { provider = "sqlite" url = env("DATABASE_URL") } generator client { provider = "prisma-client-js" binaryTargets = ["native", "linux-musl"] // 生成linux可执行文件 } // 读书账号 model Account { id String @id token String @map("token") name String @map("name") // 状态 0:失效 1:启用 2:禁用 status Int @default(1) @map("status") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") @@map("accounts") } // 订阅源 model Feed { id String @id mpName String @map("mp_name") mpCover String @map("mp_cover") mpIntro String @map("mp_intro") // 状态 0:失效 1:启用 2:禁用 status Int @default(1) @map("status") // article最后同步时间 syncTime Int @default(0) @map("sync_time") // 信息更新时间 updateTime Int @map("update_time") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") // 是否有历史文章 1 是 0 否 hasHistory Int? @default(1) @map("has_history") @@map("feeds") } model Article { id String @id mpId String @map("mp_id") title String @map("title") picUrl String @map("pic_url") publishTime Int @map("publish_time") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") @@map("articles") } ================================================ FILE: apps/server/src/app.controller.spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { AppController } from './app.controller'; import { AppService } from './app.service'; describe('AppController', () => { let appController: AppController; beforeEach(async () => { const app: TestingModule = await Test.createTestingModule({ controllers: [AppController], providers: [AppService], }).compile(); appController = app.get(AppController); }); describe('root', () => { it('should return "Hello World!"', () => { expect(appController.getHello()).toBe('Hello World!'); }); }); }); ================================================ FILE: apps/server/src/app.controller.ts ================================================ import { Controller, Get, Response, Render } from '@nestjs/common'; import { AppService } from './app.service'; import { ConfigService } from '@nestjs/config'; import { ConfigurationType } from './configuration'; import { Response as Res } from 'express'; @Controller() export class AppController { constructor( private readonly appService: AppService, private readonly configService: ConfigService, ) {} @Get() getHello(): string { return this.appService.getHello(); } @Get('/robots.txt') forRobot(): string { return 'User-agent: *\nDisallow: /'; } @Get('favicon.ico') getFavicon(@Response() res: Res) { const imgContent = '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='; const imgBuffer = Buffer.from(imgContent, 'base64'); res.setHeader('Content-Type', 'image/png'); res.send(imgBuffer); } @Get('/dash*') @Render('index.hbs') dashRender() { const { originUrl: weweRssServerOriginUrl } = this.configService.get('feed')!; const { code } = this.configService.get('auth')!; return { weweRssServerOriginUrl, enabledAuthCode: !!code, iconUrl: weweRssServerOriginUrl ? `${weweRssServerOriginUrl}/favicon.ico` : 'https://r2-assets.111965.xyz/wewe-rss.png', }; } } ================================================ FILE: apps/server/src/app.module.ts ================================================ import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { TrpcModule } from '@server/trpc/trpc.module'; import { ConfigModule, ConfigService } from '@nestjs/config'; import configuration, { ConfigurationType } from './configuration'; import { ThrottlerModule } from '@nestjs/throttler'; import { ScheduleModule } from '@nestjs/schedule'; import { FeedsModule } from './feeds/feeds.module'; @Module({ imports: [ TrpcModule, FeedsModule, ScheduleModule.forRoot(), ConfigModule.forRoot({ isGlobal: true, envFilePath: ['.env.local', '.env'], load: [configuration], }), ThrottlerModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory(config: ConfigService) { const throttler = config.get('throttler'); return [ { ttl: 60, limit: throttler?.maxRequestPerMinute || 60, }, ]; }, }), ], controllers: [AppController], providers: [AppService], }) export class AppModule {} ================================================ FILE: apps/server/src/app.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @Injectable() export class AppService { constructor(private readonly configService: ConfigService) {} getHello(): string { return `
>> WeWe RSS <<
`; } } ================================================ FILE: apps/server/src/configuration.ts ================================================ const configuration = () => { const isProd = process.env.NODE_ENV === 'production'; const port = process.env.PORT || 4000; const host = process.env.HOST || '0.0.0.0'; const maxRequestPerMinute = parseInt( `${process.env.MAX_REQUEST_PER_MINUTE}|| 60`, ); const authCode = process.env.AUTH_CODE; const platformUrl = process.env.PLATFORM_URL || 'https://weread.111965.xyz'; const originUrl = process.env.SERVER_ORIGIN_URL || ''; const feedMode = process.env.FEED_MODE as 'fulltext' | ''; const databaseType = process.env.DATABASE_TYPE || 'mysql'; const updateDelayTime = parseInt(`${process.env.UPDATE_DELAY_TIME} || 60`); const enableCleanHtml = process.env.ENABLE_CLEAN_HTML === 'true'; return { server: { isProd, port, host }, throttler: { maxRequestPerMinute }, auth: { code: authCode }, platform: { url: platformUrl }, feed: { originUrl, mode: feedMode, updateDelayTime, enableCleanHtml, }, database: { type: databaseType, }, }; }; export default configuration; export type ConfigurationType = ReturnType; ================================================ FILE: apps/server/src/constants.ts ================================================ export const statusMap = { // 0:失效 1:启用 2:禁用 INVALID: 0, ENABLE: 1, DISABLE: 2, }; export const feedTypes = ['rss', 'atom', 'json'] as const; export const feedMimeTypeMap = { rss: 'application/rss+xml; charset=utf-8', atom: 'application/atom+xml; charset=utf-8', json: 'application/feed+json; charset=utf-8', } as const; export const defaultCount = 20; ================================================ FILE: apps/server/src/feeds/feeds.controller.spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { FeedsController } from './feeds.controller'; describe('FeedsController', () => { let controller: FeedsController; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [FeedsController], }).compile(); controller = module.get(FeedsController); }); it('should be defined', () => { expect(controller).toBeDefined(); }); }); ================================================ FILE: apps/server/src/feeds/feeds.controller.ts ================================================ import { Controller, DefaultValuePipe, Get, Logger, Param, ParseIntPipe, Query, Request, Response, } from '@nestjs/common'; import { FeedsService } from './feeds.service'; import { Response as Res, Request as Req } from 'express'; @Controller('feeds') export class FeedsController { private readonly logger = new Logger(this.constructor.name); constructor(private readonly feedsService: FeedsService) {} @Get('/') async getFeedList() { return this.feedsService.getFeedList(); } @Get('/all.(json|rss|atom)') async getFeeds( @Request() req: Req, @Response() res: Res, @Query('limit', new DefaultValuePipe(30), ParseIntPipe) limit: number = 30, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number = 1, @Query('mode') mode: string, @Query('title_include') title_include: string, @Query('title_exclude') title_exclude: string, ) { const path = req.path; const type = path.split('.').pop() || ''; const { content, mimeType } = await this.feedsService.handleGenerateFeed({ type, limit, page, mode, title_include, title_exclude, }); res.setHeader('Content-Type', mimeType); res.send(content); } @Get('/:feed') async getFeed( @Response() res: Res, @Param('feed') feed: string, @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number = 10, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number = 1, @Query('mode') mode: string, @Query('title_include') title_include: string, @Query('title_exclude') title_exclude: string, @Query('update') update: boolean = false, ) { const [id, type] = feed.split('.'); this.logger.log('getFeed: ', id); if (update) { this.feedsService.updateFeed(id); } const { content, mimeType } = await this.feedsService.handleGenerateFeed({ id, type, limit, page, mode, title_include, title_exclude, }); res.setHeader('Content-Type', mimeType); res.send(content); } } ================================================ FILE: apps/server/src/feeds/feeds.module.ts ================================================ import { Module } from '@nestjs/common'; import { FeedsController } from './feeds.controller'; import { FeedsService } from './feeds.service'; import { PrismaModule } from '@server/prisma/prisma.module'; import { TrpcModule } from '@server/trpc/trpc.module'; @Module({ imports: [PrismaModule, TrpcModule], controllers: [FeedsController], providers: [FeedsService], }) export class FeedsModule {} ================================================ FILE: apps/server/src/feeds/feeds.service.spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { FeedsService } from './feeds.service'; describe('FeedsService', () => { let service: FeedsService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [FeedsService], }).compile(); service = module.get(FeedsService); }); it('should be defined', () => { expect(service).toBeDefined(); }); }); ================================================ FILE: apps/server/src/feeds/feeds.service.ts ================================================ import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '@server/prisma/prisma.service'; import { Cron } from '@nestjs/schedule'; import { TrpcService } from '@server/trpc/trpc.service'; import { feedMimeTypeMap, feedTypes } from '@server/constants'; import { ConfigService } from '@nestjs/config'; import { Article, Feed as FeedInfo } from '@prisma/client'; import { ConfigurationType } from '@server/configuration'; import { Feed, Item } from 'feed'; import got, { Got } from 'got'; import { load } from 'cheerio'; import { minify } from 'html-minifier'; import { LRUCache } from 'lru-cache'; import pMap from '@cjs-exporter/p-map'; console.log('CRON_EXPRESSION: ', process.env.CRON_EXPRESSION); const mpCache = new LRUCache({ max: 5000, }); @Injectable() export class FeedsService { private readonly logger = new Logger(this.constructor.name); private request: Got; constructor( private readonly prismaService: PrismaService, private readonly trpcService: TrpcService, private readonly configService: ConfigService, ) { this.request = got.extend({ retry: { limit: 3, methods: ['GET'], }, timeout: 8 * 1e3, headers: { accept: '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', 'accept-encoding': 'gzip, deflate, br', 'accept-language': 'en-US,en;q=0.9', 'cache-control': 'max-age=0', 'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="101", "Google Chrome";v="101"', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"macOS"', 'sec-fetch-dest': 'document', 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'none', 'sec-fetch-user': '?1', 'upgrade-insecure-requests': '1', 'user-agent': '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', }, hooks: { beforeRetry: [ async (options, error, retryCount) => { this.logger.warn(`retrying ${options.url}...`); return new Promise((resolve) => setTimeout(resolve, 2e3 * (retryCount || 1)), ); }, ], }, }); } @Cron(process.env.CRON_EXPRESSION || '35 5,17 * * *', { name: 'updateFeeds', timeZone: 'Asia/Shanghai', }) async handleUpdateFeedsCron() { this.logger.debug('Called handleUpdateFeedsCron'); const feeds = await this.prismaService.feed.findMany({ where: { status: 1 }, }); this.logger.debug('feeds length:' + feeds.length); const updateDelayTime = this.configService.get( 'feed', )!.updateDelayTime; for (const feed of feeds) { this.logger.debug('feed', feed.id); try { await this.trpcService.refreshMpArticlesAndUpdateFeed(feed.id); await new Promise((resolve) => setTimeout(resolve, updateDelayTime * 1e3), ); } catch (err) { this.logger.error('handleUpdateFeedsCron error', err); } finally { // wait 30s for next feed await new Promise((resolve) => setTimeout(resolve, 30 * 1e3)); } } } async cleanHtml(source: string) { const $ = load(source, { decodeEntities: false }); const dirtyHtml = $.html($('.rich_media_content')); const html = dirtyHtml .replace(/data-src=/g, 'src=') .replace(/opacity: 0( !important)?;/g, '') .replace(/visibility: hidden;/g, ''); const content = '' + html; const result = minify(content, { removeAttributeQuotes: true, collapseWhitespace: true, }); return result; } async getHtmlByUrl(url: string) { const html = await this.request(url, { responseType: 'text' }).text(); if ( this.configService.get('feed')!.enableCleanHtml ) { const result = await this.cleanHtml(html); return result; } return html; } async tryGetContent(id: string) { let content = mpCache.get(id); if (content) { return content; } const url = `https://mp.weixin.qq.com/s/${id}`; content = await this.getHtmlByUrl(url).catch((e) => { this.logger.error(`getHtmlByUrl(${url}) error: ${e.message}`); return '获取全文失败,请重试~'; }); mpCache.set(id, content); return content; } async renderFeed({ type, feedInfo, articles, mode, }: { type: string; feedInfo: FeedInfo; articles: Article[]; mode?: string; }) { const { originUrl, mode: globalMode } = this.configService.get('feed')!; const link = `${originUrl}/feeds/${feedInfo.id}.${type}`; const feed = new Feed({ title: feedInfo.mpName, description: feedInfo.mpIntro, id: link, link: link, language: 'zh-cn', // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes image: feedInfo.mpCover, favicon: feedInfo.mpCover, copyright: '', updated: new Date(feedInfo.updateTime * 1e3), generator: 'WeWe-RSS', author: { name: feedInfo.mpName }, }); feed.addExtension({ name: 'generator', objects: `WeWe-RSS`, }); const feeds = await this.prismaService.feed.findMany({ select: { id: true, mpName: true }, }); /**mode 高于 globalMode。如果 mode 值存在,取 mode 值*/ const enableFullText = typeof mode === 'string' ? mode === 'fulltext' : globalMode === 'fulltext'; const showAuthor = feedInfo.id === 'all'; const mapper = async (item) => { const { title, id, publishTime, picUrl, mpId } = item; const link = `https://mp.weixin.qq.com/s/${id}`; const mpName = feeds.find((item) => item.id === mpId)?.mpName || '-'; const published = new Date(publishTime * 1e3); let content = ''; if (enableFullText) { content = await this.tryGetContent(id); } feed.addItem({ id, title, link: link, guid: link, content, date: published, image: picUrl, author: showAuthor ? [{ name: mpName }] : undefined, }); }; await pMap(articles, mapper, { concurrency: 2, stopOnError: false }); return feed; } async handleGenerateFeed({ id, type, limit, page, mode, title_include, title_exclude, }: { id?: string; type: string; limit: number; page: number; mode?: string; title_include?: string; title_exclude?: string; }) { if (!feedTypes.includes(type as any)) { type = 'atom'; } let articles: Article[]; let feedInfo: FeedInfo; if (id) { feedInfo = (await this.prismaService.feed.findFirst({ where: { id }, }))!; if (!feedInfo) { throw new HttpException('不存在该feed!', HttpStatus.BAD_REQUEST); } articles = await this.prismaService.article.findMany({ where: { mpId: id }, orderBy: { publishTime: 'desc' }, take: limit, skip: (page - 1) * limit, }); } else { articles = await this.prismaService.article.findMany({ orderBy: { publishTime: 'desc' }, take: limit, skip: (page - 1) * limit, }); const { originUrl } = this.configService.get('feed')!; feedInfo = { id: 'all', mpName: 'WeWe-RSS All', mpIntro: 'WeWe-RSS 全部文章', mpCover: originUrl ? `${originUrl}/favicon.ico` : 'https://r2-assets.111965.xyz/wewe-rss.png', status: 1, syncTime: 0, updateTime: Math.floor(Date.now() / 1e3), hasHistory: -1, createdAt: new Date(), updatedAt: new Date(), }; } this.logger.log('handleGenerateFeed articles: ' + articles.length); const feed = await this.renderFeed({ feedInfo, articles, type, mode }); if (title_include) { const includes = title_include.split('|'); feed.items = feed.items.filter((i: Item) => includes.some((k) => i.title.includes(k)), ); } if (title_exclude) { const excludes = title_exclude.split('|'); feed.items = feed.items.filter( (i: Item) => !excludes.some((k) => i.title.includes(k)), ); } switch (type) { case 'rss': return { content: feed.rss2(), mimeType: feedMimeTypeMap[type] }; case 'json': return { content: feed.json1(), mimeType: feedMimeTypeMap[type] }; case 'atom': default: return { content: feed.atom1(), mimeType: feedMimeTypeMap[type] }; } } async getFeedList() { const data = await this.prismaService.feed.findMany(); return data.map((item) => { return { id: item.id, name: item.mpName, intro: item.mpIntro, cover: item.mpCover, syncTime: item.syncTime, updateTime: item.updateTime, }; }); } async updateFeed(id: string) { try { await this.trpcService.refreshMpArticlesAndUpdateFeed(id); } catch (err) { this.logger.error('updateFeed error', err); } finally { // wait 30s for next feed await new Promise((resolve) => setTimeout(resolve, 30 * 1e3)); } } } ================================================ FILE: apps/server/src/main.ts ================================================ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { TrpcRouter } from '@server/trpc/trpc.router'; import { ConfigService } from '@nestjs/config'; import { json, urlencoded } from 'express'; import { NestExpressApplication } from '@nestjs/platform-express'; import { ConfigurationType } from './configuration'; import { join, resolve } from 'path'; import { readFileSync } from 'fs'; const packageJson = JSON.parse( readFileSync(resolve(__dirname, '..', './package.json'), 'utf-8'), ); const appVersion = packageJson.version; console.log('appVersion: v' + appVersion); async function bootstrap() { const app = await NestFactory.create(AppModule); const configService = app.get(ConfigService); const { host, isProd, port } = configService.get('server')!; app.use(json({ limit: '10mb' })); app.use(urlencoded({ extended: true, limit: '10mb' })); app.useStaticAssets(join(__dirname, '..', 'client', 'assets'), { prefix: '/dash/assets/', }); app.setBaseViewsDir(join(__dirname, '..', 'client')); app.setViewEngine('hbs'); if (isProd) { app.enable('trust proxy'); } app.enableCors({ exposedHeaders: ['authorization'], }); const trpc = app.get(TrpcRouter); trpc.applyMiddleware(app); await app.listen(port, host); console.log(`Server is running at http://${host}:${port}`); } bootstrap(); ================================================ FILE: apps/server/src/prisma/prisma.module.ts ================================================ import { Module } from '@nestjs/common'; import { PrismaService } from './prisma.service'; @Module({ providers: [PrismaService], exports: [PrismaService], }) export class PrismaModule {} ================================================ FILE: apps/server/src/prisma/prisma.service.ts ================================================ import { Injectable, OnModuleInit } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit { async onModuleInit() { await this.$connect(); } } ================================================ FILE: apps/server/src/trpc/trpc.module.ts ================================================ import { Module } from '@nestjs/common'; import { TrpcService } from '@server/trpc/trpc.service'; import { TrpcRouter } from '@server/trpc/trpc.router'; import { PrismaModule } from '@server/prisma/prisma.module'; @Module({ imports: [PrismaModule], controllers: [], providers: [TrpcService, TrpcRouter], exports: [TrpcService, TrpcRouter], }) export class TrpcModule {} ================================================ FILE: apps/server/src/trpc/trpc.router.ts ================================================ import { INestApplication, Injectable, Logger } from '@nestjs/common'; import { z } from 'zod'; import { TrpcService } from '@server/trpc/trpc.service'; import * as trpcExpress from '@trpc/server/adapters/express'; import { TRPCError } from '@trpc/server'; import { PrismaService } from '@server/prisma/prisma.service'; import { statusMap } from '@server/constants'; import { ConfigService } from '@nestjs/config'; import { ConfigurationType } from '@server/configuration'; @Injectable() export class TrpcRouter { constructor( private readonly trpcService: TrpcService, private readonly prismaService: PrismaService, private readonly configService: ConfigService, ) {} private readonly logger = new Logger(this.constructor.name); accountRouter = this.trpcService.router({ list: this.trpcService.protectedProcedure .input( z.object({ limit: z.number().min(1).max(1000).nullish(), cursor: z.string().nullish(), }), ) .query(async ({ input }) => { const limit = input.limit ?? 1000; const { cursor } = input; const items = await this.prismaService.account.findMany({ take: limit + 1, where: {}, select: { id: true, name: true, status: true, createdAt: true, updatedAt: true, token: false, }, cursor: cursor ? { id: cursor, } : undefined, orderBy: { createdAt: 'asc', }, }); let nextCursor: typeof cursor | undefined = undefined; if (items.length > limit) { // Remove the last item and use it as next cursor // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const nextItem = items.pop()!; nextCursor = nextItem.id; } const disabledAccounts = this.trpcService.getBlockedAccountIds(); return { blocks: disabledAccounts, items, nextCursor, }; }), byId: this.trpcService.protectedProcedure .input(z.string()) .query(async ({ input: id }) => { const account = await this.prismaService.account.findUnique({ where: { id }, }); if (!account) { throw new TRPCError({ code: 'BAD_REQUEST', message: `No account with id '${id}'`, }); } return account; }), add: this.trpcService.protectedProcedure .input( z.object({ id: z.string().min(1).max(32), token: z.string().min(1), name: z.string().min(1), status: z.number().default(statusMap.ENABLE), }), ) .mutation(async ({ input }) => { const { id, ...data } = input; const account = await this.prismaService.account.upsert({ where: { id, }, update: data, create: input, }); this.trpcService.removeBlockedAccount(id); return account; }), edit: this.trpcService.protectedProcedure .input( z.object({ id: z.string(), data: z.object({ token: z.string().min(1).optional(), name: z.string().min(1).optional(), status: z.number().optional(), }), }), ) .mutation(async ({ input }) => { const { id, data } = input; const account = await this.prismaService.account.update({ where: { id }, data, }); this.trpcService.removeBlockedAccount(id); return account; }), delete: this.trpcService.protectedProcedure .input(z.string()) .mutation(async ({ input: id }) => { await this.prismaService.account.delete({ where: { id } }); this.trpcService.removeBlockedAccount(id); return id; }), }); feedRouter = this.trpcService.router({ list: this.trpcService.protectedProcedure .input( z.object({ limit: z.number().min(1).max(1000).nullish(), cursor: z.string().nullish(), }), ) .query(async ({ input }) => { const limit = input.limit ?? 1000; const { cursor } = input; const items = await this.prismaService.feed.findMany({ take: limit + 1, where: {}, cursor: cursor ? { id: cursor, } : undefined, orderBy: { createdAt: 'asc', }, }); let nextCursor: typeof cursor | undefined = undefined; if (items.length > limit) { // Remove the last item and use it as next cursor // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const nextItem = items.pop()!; nextCursor = nextItem.id; } return { items: items, nextCursor, }; }), byId: this.trpcService.protectedProcedure .input(z.string()) .query(async ({ input: id }) => { const feed = await this.prismaService.feed.findUnique({ where: { id }, }); if (!feed) { throw new TRPCError({ code: 'BAD_REQUEST', message: `No feed with id '${id}'`, }); } return feed; }), add: this.trpcService.protectedProcedure .input( z.object({ id: z.string(), mpName: z.string(), mpCover: z.string(), mpIntro: z.string(), syncTime: z .number() .optional() .default(Math.floor(Date.now() / 1e3)), updateTime: z.number(), status: z.number().default(statusMap.ENABLE), }), ) .mutation(async ({ input }) => { const { id, ...data } = input; const feed = await this.prismaService.feed.upsert({ where: { id, }, update: data, create: input, }); return feed; }), edit: this.trpcService.protectedProcedure .input( z.object({ id: z.string(), data: z.object({ mpName: z.string().optional(), mpCover: z.string().optional(), mpIntro: z.string().optional(), syncTime: z.number().optional(), updateTime: z.number().optional(), status: z.number().optional(), }), }), ) .mutation(async ({ input }) => { const { id, data } = input; const feed = await this.prismaService.feed.update({ where: { id }, data, }); return feed; }), delete: this.trpcService.protectedProcedure .input(z.string()) .mutation(async ({ input: id }) => { await this.prismaService.feed.delete({ where: { id } }); return id; }), refreshArticles: this.trpcService.protectedProcedure .input( z.object({ mpId: z.string().optional(), }), ) .mutation(async ({ input: { mpId } }) => { if (mpId) { await this.trpcService.refreshMpArticlesAndUpdateFeed(mpId); } else { await this.trpcService.refreshAllMpArticlesAndUpdateFeed(); } }), isRefreshAllMpArticlesRunning: this.trpcService.protectedProcedure.query( async () => { return this.trpcService.isRefreshAllMpArticlesRunning; }, ), getHistoryArticles: this.trpcService.protectedProcedure .input( z.object({ mpId: z.string().optional(), }), ) .mutation(async ({ input: { mpId = '' } }) => { this.trpcService.getHistoryMpArticles(mpId); }), getInProgressHistoryMp: this.trpcService.protectedProcedure.query( async () => { return this.trpcService.inProgressHistoryMp; }, ), }); articleRouter = this.trpcService.router({ list: this.trpcService.protectedProcedure .input( z.object({ limit: z.number().min(1).max(1000).nullish(), cursor: z.string().nullish(), mpId: z.string().nullish(), }), ) .query(async ({ input }) => { const limit = input.limit ?? 1000; const { cursor, mpId } = input; const items = await this.prismaService.article.findMany({ orderBy: [ { publishTime: 'desc', }, ], take: limit + 1, where: mpId ? { mpId } : undefined, cursor: cursor ? { id: cursor, } : undefined, }); let nextCursor: typeof cursor | undefined = undefined; if (items.length > limit) { // Remove the last item and use it as next cursor // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const nextItem = items.pop()!; nextCursor = nextItem.id; } return { items, nextCursor, }; }), byId: this.trpcService.protectedProcedure .input(z.string()) .query(async ({ input: id }) => { const article = await this.prismaService.article.findUnique({ where: { id }, }); if (!article) { throw new TRPCError({ code: 'BAD_REQUEST', message: `No article with id '${id}'`, }); } return article; }), add: this.trpcService.protectedProcedure .input( z.object({ id: z.string(), mpId: z.string(), title: z.string(), picUrl: z.string().optional().default(''), publishTime: z.number(), }), ) .mutation(async ({ input }) => { const { id, ...data } = input; const article = await this.prismaService.article.upsert({ where: { id, }, update: data, create: input, }); return article; }), delete: this.trpcService.protectedProcedure .input(z.string()) .mutation(async ({ input: id }) => { await this.prismaService.article.delete({ where: { id } }); return id; }), }); platformRouter = this.trpcService.router({ getMpArticles: this.trpcService.protectedProcedure .input( z.object({ mpId: z.string(), }), ) .mutation(async ({ input: { mpId } }) => { try { const results = await this.trpcService.getMpArticles(mpId); return results; } catch (err: any) { this.logger.log('getMpArticles err: ', err); throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: err.response?.data?.message || err.message, cause: err.stack, }); } }), getMpInfo: this.trpcService.protectedProcedure .input( z.object({ wxsLink: z .string() .refine((v) => v.startsWith('https://mp.weixin.qq.com/s/')), }), ) .mutation(async ({ input: { wxsLink: url } }) => { try { const results = await this.trpcService.getMpInfo(url); return results; } catch (err: any) { this.logger.log('getMpInfo err: ', err); throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: err.response?.data?.message || err.message, cause: err.stack, }); } }), createLoginUrl: this.trpcService.protectedProcedure.mutation(async () => { return this.trpcService.createLoginUrl(); }), getLoginResult: this.trpcService.protectedProcedure .input( z.object({ id: z.string(), }), ) .query(async ({ input }) => { return this.trpcService.getLoginResult(input.id); }), }); appRouter = this.trpcService.router({ feed: this.feedRouter, account: this.accountRouter, article: this.articleRouter, platform: this.platformRouter, }); async applyMiddleware(app: INestApplication) { app.use( `/trpc`, trpcExpress.createExpressMiddleware({ router: this.appRouter, createContext: ({ req }) => { const authCode = this.configService.get('auth')!.code; if (authCode && req.headers.authorization !== authCode) { return { errorMsg: 'authCode不正确!', }; } return { errorMsg: null, }; }, middleware: (req, res, next) => { next(); }, }), ); } } export type AppRouter = TrpcRouter[`appRouter`]; ================================================ FILE: apps/server/src/trpc/trpc.service.ts ================================================ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ConfigurationType } from '@server/configuration'; import { defaultCount, statusMap } from '@server/constants'; import { PrismaService } from '@server/prisma/prisma.service'; import { TRPCError, initTRPC } from '@trpc/server'; import Axios, { AxiosInstance } from 'axios'; import dayjs from 'dayjs'; import timezone from 'dayjs/plugin/timezone'; import utc from 'dayjs/plugin/utc'; dayjs.extend(utc); dayjs.extend(timezone); /** * 读书账号每日小黑屋 */ const blockedAccountsMap = new Map(); @Injectable() export class TrpcService { trpc = initTRPC.create(); publicProcedure = this.trpc.procedure; protectedProcedure = this.trpc.procedure.use(({ ctx, next }) => { const errorMsg = (ctx as any).errorMsg; if (errorMsg) { throw new TRPCError({ code: 'UNAUTHORIZED', message: errorMsg }); } return next({ ctx }); }); router = this.trpc.router; mergeRouters = this.trpc.mergeRouters; request: AxiosInstance; updateDelayTime = 60; private readonly logger = new Logger(this.constructor.name); constructor( private readonly prismaService: PrismaService, private readonly configService: ConfigService, ) { const { url } = this.configService.get('platform')!; this.updateDelayTime = this.configService.get( 'feed', )!.updateDelayTime; this.request = Axios.create({ baseURL: url, timeout: 15 * 1e3 }); this.request.interceptors.response.use( (response) => { return response; }, async (error) => { this.logger.log('error: ', error); const errMsg = error.response?.data?.message || ''; const id = (error.config.headers as any).xid; if (errMsg.includes('WeReadError401')) { // 账号失效 await this.prismaService.account.update({ where: { id }, data: { status: statusMap.INVALID }, }); this.logger.error(`账号(${id})登录失效,已禁用`); } else if (errMsg.includes('WeReadError429')) { //TODO 处理请求频繁 this.logger.error(`账号(${id})请求频繁,打入小黑屋`); } const today = this.getTodayDate(); const blockedAccounts = blockedAccountsMap.get(today); if (Array.isArray(blockedAccounts)) { if (id) { blockedAccounts.push(id); } blockedAccountsMap.set(today, blockedAccounts); } else if (errMsg.includes('WeReadError400')) { this.logger.error(`账号(${id})处理请求参数出错`); this.logger.error('WeReadError400: ', errMsg); // 10s 后重试 await new Promise((resolve) => setTimeout(resolve, 10 * 1e3)); } else { this.logger.error("Can't handle this error: ", errMsg); } return Promise.reject(error); }, ); } removeBlockedAccount = (vid: string) => { const today = this.getTodayDate(); const blockedAccounts = blockedAccountsMap.get(today); if (Array.isArray(blockedAccounts)) { const newBlockedAccounts = blockedAccounts.filter((id) => id !== vid); blockedAccountsMap.set(today, newBlockedAccounts); } }; private getTodayDate() { return dayjs.tz(new Date(), 'Asia/Shanghai').format('YYYY-MM-DD'); } getBlockedAccountIds() { const today = this.getTodayDate(); const disabledAccounts = blockedAccountsMap.get(today) || []; this.logger.debug('disabledAccounts: ', disabledAccounts); return disabledAccounts.filter(Boolean); } private async getAvailableAccount() { const disabledAccounts = this.getBlockedAccountIds(); const account = await this.prismaService.account.findMany({ where: { status: statusMap.ENABLE, NOT: { id: { in: disabledAccounts }, }, }, take: 10, }); if (!account || account.length === 0) { throw new Error('暂无可用读书账号!'); } return account[Math.floor(Math.random() * account.length)]; } async getMpArticles(mpId: string, page = 1, retryCount = 3) { const account = await this.getAvailableAccount(); try { const res = await this.request .get< { id: string; title: string; picUrl: string; publishTime: number; }[] >(`/api/v2/platform/mps/${mpId}/articles`, { headers: { xid: account.id, Authorization: `Bearer ${account.token}`, }, params: { page, }, }) .then((res) => res.data) .then((res) => { this.logger.log( `getMpArticles(${mpId}) page: ${page} articles: ${res.length}`, ); return res; }); return res; } catch (err) { this.logger.error(`retry(${4 - retryCount}) getMpArticles error: `, err); if (retryCount > 0) { return this.getMpArticles(mpId, page, retryCount - 1); } else { throw err; } } } async refreshMpArticlesAndUpdateFeed(mpId: string, page = 1) { const articles = await this.getMpArticles(mpId, page); if (articles.length > 0) { let results; const { type } = this.configService.get('database')!; if (type === 'sqlite') { // sqlite3 不支持 createMany const inserts = articles.map(({ id, picUrl, publishTime, title }) => this.prismaService.article.upsert({ create: { id, mpId, picUrl, publishTime, title }, update: { publishTime, title, }, where: { id }, }), ); results = await this.prismaService.$transaction(inserts); } else { results = await (this.prismaService.article as any).createMany({ data: articles.map(({ id, picUrl, publishTime, title }) => ({ id, mpId, picUrl, publishTime, title, })), skipDuplicates: true, }); } this.logger.debug( `refreshMpArticlesAndUpdateFeed create results: ${JSON.stringify(results)}`, ); } // 如果文章数量小于 defaultCount,则认为没有更多历史文章 const hasHistory = articles.length < defaultCount ? 0 : 1; await this.prismaService.feed.update({ where: { id: mpId }, data: { syncTime: Math.floor(Date.now() / 1e3), hasHistory, }, }); return { hasHistory }; } inProgressHistoryMp = { id: '', page: 1, }; async getHistoryMpArticles(mpId: string) { if (this.inProgressHistoryMp.id === mpId) { this.logger.log(`getHistoryMpArticles(${mpId}) is running`); return; } this.inProgressHistoryMp = { id: mpId, page: 1, }; if (!this.inProgressHistoryMp.id) { return; } try { const feed = await this.prismaService.feed.findFirstOrThrow({ where: { id: mpId, }, }); // 如果完整同步过历史文章,则直接返回 if (feed.hasHistory === 0) { this.logger.log(`getHistoryMpArticles(${mpId}) has no history`); return; } const total = await this.prismaService.article.count({ where: { mpId, }, }); this.inProgressHistoryMp.page = Math.ceil(total / defaultCount); // 最多尝试一千次 let i = 1e3; while (i-- > 0) { if (this.inProgressHistoryMp.id !== mpId) { this.logger.log( `getHistoryMpArticles(${mpId}) is not running, break`, ); break; } const { hasHistory } = await this.refreshMpArticlesAndUpdateFeed( mpId, this.inProgressHistoryMp.page, ); if (hasHistory < 1) { this.logger.log( `getHistoryMpArticles(${mpId}) has no history, break`, ); break; } this.inProgressHistoryMp.page++; await new Promise((resolve) => setTimeout(resolve, this.updateDelayTime * 1e3), ); } } finally { this.inProgressHistoryMp = { id: '', page: 1, }; } } isRefreshAllMpArticlesRunning = false; async refreshAllMpArticlesAndUpdateFeed() { if (this.isRefreshAllMpArticlesRunning) { this.logger.log('refreshAllMpArticlesAndUpdateFeed is running'); return; } const mps = await this.prismaService.feed.findMany(); this.isRefreshAllMpArticlesRunning = true; try { for (const { id } of mps) { await this.refreshMpArticlesAndUpdateFeed(id); await new Promise((resolve) => setTimeout(resolve, this.updateDelayTime * 1e3), ); } } finally { this.isRefreshAllMpArticlesRunning = false; } } async getMpInfo(url: string) { url = url.trim(); const account = await this.getAvailableAccount(); return this.request .post< { id: string; cover: string; name: string; intro: string; updateTime: number; }[] >( `/api/v2/platform/wxs2mp`, { url }, { headers: { xid: account.id, Authorization: `Bearer ${account.token}`, }, }, ) .then((res) => res.data); } async createLoginUrl() { return this.request .get<{ uuid: string; scanUrl: string; }>(`/api/v2/login/platform`) .then((res) => res.data); } async getLoginResult(id: string) { return this.request .get<{ message: string; vid?: number; token?: string; username?: string; }>(`/api/v2/login/platform/${id}`, { timeout: 120 * 1e3 }) .then((res) => res.data); } } ================================================ FILE: apps/server/test/app.e2e-spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import * as request from 'supertest'; import { AppModule } from './../src/app.module'; describe('AppController (e2e)', () => { let app: INestApplication; beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleFixture.createNestApplication(); await app.init(); }); it('/ (GET)', () => { return request(app.getHttpServer()) .get('/') .expect(200) .expect('Hello World!'); }); }); ================================================ FILE: apps/server/test/jest-e2e.json ================================================ { "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" } } ================================================ FILE: apps/server/tsconfig.build.json ================================================ { "extends": "./tsconfig.json", "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] } ================================================ FILE: apps/server/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "module": "commonjs", "declaration": true, "removeComments": true, "allowSyntheticDefaultImports": true, "target": "ES2021", "sourceMap": true, "outDir": "./dist", "esModuleInterop":true } } ================================================ FILE: apps/web/.eslintrc.cjs ================================================ module.exports = { root: true, env: { browser: true, es2020: true }, extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended', ], ignorePatterns: ['dist', '.eslintrc.cjs'], parser: '@typescript-eslint/parser', plugins: ['react-refresh'], rules: { 'react-refresh/only-export-components': [ 'warn', { allowConstantExport: true }, ], '@typescript-eslint/no-explicit-any': 'warn', }, }; ================================================ FILE: apps/web/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? ================================================ FILE: apps/web/README.md ================================================ # React + TypeScript + Vite This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. Currently, two official plugins are available: - [@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 - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh ## Expanding the ESLint configuration If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: - Configure the top-level `parserOptions` property like this: ```js export default { // other rules... parserOptions: { ecmaVersion: 'latest', sourceType: 'module', project: ['./tsconfig.json', './tsconfig.node.json'], tsconfigRootDir: __dirname, }, }; ``` - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` - 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 ================================================ FILE: apps/web/index.html ================================================ WeWe RSS
================================================ FILE: apps/web/package.json ================================================ { "name": "web", "private": true, "version": "2.6.1", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview" }, "dependencies": { "@nextui-org/react": "^2.2.9", "@tanstack/react-query": "^4.35.3", "@trpc/client": "^10.45.1", "@trpc/next": "^10.45.1", "@trpc/react-query": "^10.45.1", "autoprefixer": "^10.0.1", "dayjs": "^1.11.10", "framer-motion": "^11.0.5", "next-themes": "^0.2.1", "postcss": "^8", "qrcode.react": "^3.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.22.2", "sonner": "^1.4.0", "tailwindcss": "^3.3.0" }, "devDependencies": { "@types/node": "^20.11.24", "@types/react": "^18.2.56", "@types/react-dom": "^18.2.19", "@typescript-eslint/eslint-plugin": "^7.0.2", "@typescript-eslint/parser": "^7.0.2", "@vitejs/plugin-react": "^4.2.1", "eslint": "^8.56.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", "typescript": "^5.2.2", "vite": "^5.1.4" } } ================================================ FILE: apps/web/postcss.config.js ================================================ export default { plugins: { tailwindcss: {}, autoprefixer: {}, }, }; ================================================ FILE: apps/web/src/App.tsx ================================================ import { BrowserRouter, Route, Routes } from 'react-router-dom'; import Feeds from './pages/feeds'; import Login from './pages/login'; import Accounts from './pages/accounts'; import { BaseLayout } from './layouts/base'; import { TrpcProvider } from './provider/trpc'; import ThemeProvider from './provider/theme'; function App() { return ( }> } /> } /> } /> } /> ); } export default App; ================================================ FILE: apps/web/src/components/GitHubIcon.tsx ================================================ import { IconSvgProps } from '../types'; export const GitHubIcon = ({ size = 24, width, height, ...props }: IconSvgProps) => ( ); ================================================ FILE: apps/web/src/components/Nav.tsx ================================================ import { Badge, Image, Link, Navbar, NavbarBrand, NavbarContent, NavbarItem, Tooltip, } from '@nextui-org/react'; import { ThemeSwitcher } from './ThemeSwitcher'; import { GitHubIcon } from './GitHubIcon'; import { useLocation } from 'react-router-dom'; import { appVersion, serverOriginUrl } from '@web/utils/env'; import { useEffect, useState } from 'react'; const navbarItemLink = [ { href: '/feeds', name: '公众号源', }, { href: '/accounts', name: '账号管理', }, // { // href: '/settings', // name: '设置', // }, ]; const Nav = () => { const { pathname } = useLocation(); const [releaseVersion, setReleaseVersion] = useState(appVersion); useEffect(() => { fetch('https://api.github.com/repos/cooderl/wewe-rss/releases/latest') .then((res) => res.json()) .then((data) => { setReleaseVersion(data.name.replace('v', '')); }); }, []); const isFoundNewVersion = releaseVersion > appVersion; console.log('isFoundNewVersion: ', isFoundNewVersion); return (
{isFoundNewVersion && ( 发现新版本:v{releaseVersion} )} 当前版本: v{appVersion}
} placement="left" > WeWe RSS

WeWe RSS

{navbarItemLink.map((item) => { return ( {item.name} ); })} ); }; export default Nav; ================================================ FILE: apps/web/src/components/PlusIcon.tsx ================================================ import { IconSvgProps } from '../types'; export const PlusIcon = ({ size = 24, width, height, ...props }: IconSvgProps) => ( ); ================================================ FILE: apps/web/src/components/StatusDropdown.tsx ================================================ import React from 'react'; import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Button, } from '@nextui-org/react'; import { statusMap } from '@web/constants'; export function StatusDropdown({ value = 1, onChange, }: { value: number; onChange: (value: number) => void; }) { return ( { onChange(+Array.from(keys)[0]); }} > {Object.entries(statusMap).map(([key, value]) => { return ( {value.label} ); })} ); } ================================================ FILE: apps/web/src/components/ThemeSwitcher.tsx ================================================ 'use client'; import { VisuallyHidden, useSwitch } from '@nextui-org/react'; import { useTheme } from 'next-themes'; export const MoonIcon = (props) => ( ); export const SunIcon = (props) => ( ); export function ThemeSwitcher(props) { const { setTheme, theme } = useTheme(); const { Component, slots, isSelected, getBaseProps, getInputProps, getWrapperProps, } = useSwitch({ onClick: () => setTheme(theme === 'dark' ? 'light' : 'dark'), isSelected: theme === 'dark', }); return (
{isSelected ? : }
); } ================================================ FILE: apps/web/src/constants.ts ================================================ export const statusMap = { 0: { label: '失效', color: 'danger' }, 1: { label: '启用', color: 'success' }, 2: { label: '禁用', color: 'warning' }, } as const; ================================================ FILE: apps/web/src/index.css ================================================ @tailwind base; @tailwind components; @tailwind utilities; ================================================ FILE: apps/web/src/layouts/base.tsx ================================================ import { Toaster } from 'sonner'; import { Outlet } from 'react-router-dom'; import Nav from '../components/Nav'; export function BaseLayout() { return (
); } ================================================ FILE: apps/web/src/main.tsx ================================================ import ReactDOM from 'react-dom/client'; import App from './App.tsx'; import './index.css'; ReactDOM.createRoot(document.getElementById('root')!).render(); ================================================ FILE: apps/web/src/pages/accounts/index.tsx ================================================ import { Modal, ModalContent, ModalHeader, ModalBody, Button, useDisclosure, Spinner, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow, Chip, } from '@nextui-org/react'; import { QRCodeSVG } from 'qrcode.react'; import { toast } from 'sonner'; import { PlusIcon } from '@web/components/PlusIcon'; import dayjs from 'dayjs'; import { StatusDropdown } from '@web/components/StatusDropdown'; import { trpc } from '@web/utils/trpc'; import { statusMap } from '@web/constants'; import { useEffect, useState } from 'react'; const AccountPage = () => { const { isOpen, onOpen, onClose, onOpenChange } = useDisclosure(); const [count, setCount] = useState(0); const { refetch, data, isFetching } = trpc.account.list.useQuery({}); const queryUtils = trpc.useUtils(); const { mutateAsync: updateAccount } = trpc.account.edit.useMutation({}); const { mutateAsync: deleteAccount } = trpc.account.delete.useMutation({}); const { mutateAsync: addAccount } = trpc.account.add.useMutation({}); const { mutateAsync, data: loginData } = trpc.platform.createLoginUrl.useMutation({ onSuccess(data) { if (data.uuid) { setCount(60); } }, }); const { data: loginResult } = trpc.platform.getLoginResult.useQuery( { id: loginData?.uuid ?? '', }, { refetchIntervalInBackground: false, enabled: !!loginData?.uuid, async onSuccess(data) { if (data.vid && data.token) { const name = data.username!; await addAccount({ id: `${data.vid}`, name, token: data.token }); onClose(); toast.success('添加成功', { description: `用户名:${name}(${data.vid})`, }); refetch(); } else if (data.message) { toast.error(`登录失败: ${data.message}`); } }, }, ); useEffect(() => { let timerId; if (count > 0 && isOpen) { timerId = setTimeout(() => { setCount(count - 1); }, 1000); } return () => timerId && clearTimeout(timerId); }, [count, isOpen]); return (
共{data?.items.length || 0}个账号
ID 用户名 状态 更新时间 操作 暂无数据} isLoading={isFetching} loadingContent={} > {data?.items.map((item) => { const isBlocked = data?.blocks.includes(item.id); return ( {item.id} {item.name} {isBlocked ? ( 今日小黑屋 ) : ( {statusMap[item.status].label} )} {dayjs(item.updatedAt).format('YYYY-MM-DD')} { updateAccount({ id: item.id, data: { status: value }, }).then(() => { toast.success('更新成功!'); refetch(); }); }} > ); }) || []}
{ onOpenChange(); await queryUtils.platform.getLoginResult.cancel(); }} > {() => ( <> 添加读书账号
{loginData ? (
{loginResult?.message && (
{loginResult?.message}
)}
微信扫码登录{' '} {!loginResult?.message && count > 0 && ( ({count}s) )}
) : (
二维码加载中
)}
)}
); }; export default AccountPage; ================================================ FILE: apps/web/src/pages/feeds/index.tsx ================================================ import { Avatar, Button, Divider, Listbox, ListboxItem, ListboxSection, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Switch, Textarea, Tooltip, useDisclosure, Link, } from '@nextui-org/react'; import { PlusIcon } from '@web/components/PlusIcon'; import { trpc } from '@web/utils/trpc'; import { useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { toast } from 'sonner'; import dayjs from 'dayjs'; import { serverOriginUrl } from '@web/utils/env'; import ArticleList from './list'; const Feeds = () => { const { id } = useParams(); const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure(); const { refetch: refetchFeedList, data: feedData } = trpc.feed.list.useQuery( {}, { refetchOnWindowFocus: true, }, ); const navigate = useNavigate(); const queryUtils = trpc.useUtils(); const { mutateAsync: getMpInfo, isLoading: isGetMpInfoLoading } = trpc.platform.getMpInfo.useMutation({}); const { mutateAsync: updateMpInfo } = trpc.feed.edit.useMutation({}); const { mutateAsync: addFeed, isLoading: isAddFeedLoading } = trpc.feed.add.useMutation({}); const { mutateAsync: refreshMpArticles, isLoading: isGetArticlesLoading } = trpc.feed.refreshArticles.useMutation(); const { mutateAsync: getHistoryArticles, isLoading: isGetHistoryArticlesLoading, } = trpc.feed.getHistoryArticles.useMutation(); const { data: inProgressHistoryMp, refetch: refetchInProgressHistoryMp } = trpc.feed.getInProgressHistoryMp.useQuery(undefined, { refetchOnWindowFocus: true, refetchInterval: 10 * 1e3, refetchOnMount: true, refetchOnReconnect: true, }); const { data: isRefreshAllMpArticlesRunning } = trpc.feed.isRefreshAllMpArticlesRunning.useQuery(); const { mutateAsync: deleteFeed, isLoading: isDeleteFeedLoading } = trpc.feed.delete.useMutation({}); const [wxsLink, setWxsLink] = useState(''); const [currentMpId, setCurrentMpId] = useState(id || ''); const handleConfirm = async () => { console.log('wxsLink', wxsLink); // TODO show operation in progress const wxsLinks = wxsLink.split('\n').filter((link) => link.trim() !== ''); for (const link of wxsLinks) { console.log('add wxsLink', link); const res = await getMpInfo({ wxsLink: link }); if (res[0]) { const item = res[0]; await addFeed({ id: item.id, mpName: item.name, mpCover: item.cover, mpIntro: item.intro, updateTime: item.updateTime, status: 1, }); await refreshMpArticles({ mpId: item.id }); toast.success('添加成功', { description: `公众号 ${item.name}`, }); await queryUtils.article.list.reset(); } else { toast.error('添加失败', { description: '请检查链接是否正确' }); } } refetchFeedList(); setWxsLink(''); onClose(); }; const isActive = (key: string) => { return currentMpId === key; }; const currentMpInfo = useMemo(() => { return feedData?.items.find((item) => item.id === currentMpId); }, [currentMpId, feedData?.items]); const handleExportOpml = async (ev) => { ev.preventDefault(); ev.stopPropagation(); if (!feedData?.items?.length) { console.warn('没有订阅源'); return; } let opmlContent = ` WeWeRSS 所有订阅源 `; feedData?.items.forEach((sub) => { opmlContent += ` \n`; }); opmlContent += ` `; const blob = new Blob([opmlContent], { type: 'text/xml;charset=utf-8;' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = 'WeWeRSS-All.opml'; document.body.appendChild(link); link.click(); document.body.removeChild(link); }; return ( <>
共{feedData?.items.length || 0}个订阅
{feedData?.items ? ( setCurrentMpId(key as string)} > } > 全部 {feedData?.items.map((item) => { return ( } > {item.mpName} ); }) || []} ) : ( '' )}

{currentMpInfo?.mpName || '全部'}

{currentMpInfo ? (
最后更新时间: {dayjs(currentMpInfo.syncTime * 1e3).format( 'YYYY-MM-DD HH:mm:ss', )}
{ ev.preventDefault(); ev.stopPropagation(); await refreshMpArticles({ mpId: currentMpInfo.id }); await refetchFeedList(); await queryUtils.article.list.reset(); }} > {isGetArticlesLoading ? '更新中...' : '立即更新'} {currentMpInfo.hasHistory === 1 && ( <> { ev.preventDefault(); ev.stopPropagation(); if (inProgressHistoryMp?.id === currentMpInfo.id) { await getHistoryArticles({ mpId: '', }); } else { await getHistoryArticles({ mpId: currentMpInfo.id, }); } await refetchInProgressHistoryMp(); }} > {inProgressHistoryMp?.id === currentMpInfo.id ? `停止获取历史文章` : `获取历史文章`} )}
{ await updateMpInfo({ id: currentMpInfo.id, data: { status: value ? 1 : 0, }, }); await refetchFeedList(); }} isSelected={currentMpInfo?.status === 1} >
{ ev.preventDefault(); ev.stopPropagation(); if (window.confirm('确定删除吗?')) { await deleteFeed(currentMpInfo.id); navigate('/feeds'); await refetchFeedList(); } }} > 删除 可添加.atom/.rss/.json格式输出, limit=20&page=1控制分页
} > RSS
) : (
{ ev.preventDefault(); ev.stopPropagation(); await refreshMpArticles({}); await refetchFeedList(); await queryUtils.article.list.reset(); }} > {isRefreshAllMpArticlesRunning || isGetArticlesLoading ? '更新中...' : '更新全部'} 导出OPML RSS
)}
{(onClose) => ( <> 添加公众号源