[
  {
    "path": ".dockerignore",
    "content": "node_modules\n.git\n.gitignore\n*.md\ndist\n.env\n.next\n.DS_Store\n./wewe-rss-dingtalk"
  },
  {
    "path": ".github/workflows/docker-release.yml",
    "content": "name: Build WeWeRSS images and push image to docker hub\non:\n  workflow_dispatch:\n  push:\n    # paths:\n    #   - \"apps/**\"\n    #   - \"Dockerfile\"\n    tags:\n      - 'v*.*.*'\n\nconcurrency:\n  group: docker-release\n  cancel-in-progress: true\n\njobs:\n  check-env:\n    permissions:\n      contents: none\n    runs-on: ubuntu-latest\n    timeout-minutes: 5\n    outputs:\n      check-docker: ${{ steps.check-docker.outputs.defined }}\n    steps:\n      - id: check-docker\n        env:\n          DOCKER_HUB_NAME: ${{ secrets.DOCKER_HUB_NAME }}\n        if: ${{ env.DOCKER_HUB_NAME != '' }}\n        run: echo \"defined=true\" >> $GITHUB_OUTPUT\n\n  release-images:\n    runs-on: ubuntu-latest\n    timeout-minutes: 120\n    permissions:\n      packages: write\n      contents: read\n      id-token: write\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 1\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login to Docker Hub\n        uses: docker/login-action@v2\n        with:\n          username: ${{ secrets.DOCKER_HUB_NAME }}\n          password: ${{ secrets.DOCKER_HUB_PASSWORD }}\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract Docker metadata (sqlite)\n        id: meta-sqlite\n        uses: docker/metadata-action@v5\n        with:\n          images: |\n            ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss-sqlite\n            ghcr.io/cooderl/wewe-rss-sqlite\n          tags: |\n            type=raw,value=latest,enable=true\n            type=raw,value=${{ github.ref_name }},enable=true\n          flavor: latest=false\n\n      - name: Build and push Docker image (sqlite)\n        id: build-and-push-sqlite\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          push: true\n          tags: ${{ steps.meta-sqlite.outputs.tags }}\n          labels: ${{ steps.meta-sqlite.outputs.labels }}\n          target: app-sqlite\n          platforms: linux/amd64,linux/arm64\n          cache-from: type=gha,scope=docker-release\n          cache-to: type=gha,mode=max,scope=docker-release\n\n      - name: Extract Docker metadata\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: |\n            ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss\n            ghcr.io/cooderl/wewe-rss\n          tags: |\n            type=raw,value=latest,enable=true\n            type=raw,value=${{ github.ref_name }},enable=true\n          flavor: latest=false\n\n      - name: Build and push Docker image\n        id: build-and-push\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          target: app\n          platforms: linux/amd64,linux/arm64\n          cache-from: type=gha,scope=docker-release\n          cache-to: type=gha,mode=max,scope=docker-release\n\n      - name: Set env\n        run: echo \"RELEASE_VERSION=${GITHUB_REF#refs/*/}\" >> $GITHUB_ENV\n\n      - name: Create a Release\n        uses: elgohr/Github-Release-Action@v5\n        env:\n          GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}\n        with:\n          title: ${{ env.RELEASE_VERSION }}\n\n  description:\n    runs-on: ubuntu-latest\n    needs: check-env\n    if: needs.check-env.outputs.check-docker == 'true'\n    timeout-minutes: 5\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Docker Hub Description(sqlite)\n        uses: peter-evans/dockerhub-description@v4\n        with:\n          username: ${{ secrets.DOCKER_HUB_NAME }}\n          password: ${{ secrets.DOCKER_HUB_PASSWORD }}\n          repository: ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss-sqlite\n\n      - name: Docker Hub Description\n        uses: peter-evans/dockerhub-description@v4\n        with:\n          username: ${{ secrets.DOCKER_HUB_NAME }}\n          password: ${{ secrets.DOCKER_HUB_PASSWORD }}\n          repository: ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss\n"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n.pnpm-debug.log*\n\n# Diagnostic reports (https://nodejs.org/api/report.html)\nreport.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n*.lcov\n\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# Bower dependency directory (https://bower.io/)\nbower_components\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (https://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules/\njspm_packages/\n\n# Snowpack dependency directory (https://snowpack.dev/)\nweb_modules/\n\n# TypeScript cache\n*.tsbuildinfo\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Optional stylelint cache\n.stylelintcache\n\n# Microbundle cache\n.rpt2_cache/\n.rts2_cache_cjs/\n.rts2_cache_es/\n.rts2_cache_umd/\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variable files\n.env\n.env.development.local\n.env.test.local\n.env.production.local\n.env.local\n\n# parcel-bundler cache (https://parceljs.org/)\n.cache\n.parcel-cache\n\n# Next.js build output\n.next\nout\n\n# Nuxt.js build / generate output\n.nuxt\ndist\n\n# Gatsby files\n.cache/\n# Comment in the public line in if your project uses Gatsby and not Next.js\n# https://nextjs.org/blog/next-9-1#public-directory-support\n# public\n\n# vuepress build output\n.vuepress/dist\n\n# vuepress v2.x temp and cache directory\n.temp\n.cache\n\n# Docusaurus cache and generated files\n.docusaurus\n\n# Serverless directories\n.serverless/\n\n# FuseBox cache\n.fusebox/\n\n# DynamoDB Local files\n.dynamodb/\n\n# TernJS port file\n.tern-port\n\n# Stores VSCode versions used for testing VSCode extensions\n.vscode-test\n\n# yarn v2\n.yarn/cache\n.yarn/unplugged\n.yarn/build-state.yml\n.yarn/install-state.gz\n.pnp.*\n\n.DS_Store\n"
  },
  {
    "path": ".markdownlint.yaml",
    "content": "# Default state for all rules\ndefault: true\n\nline-length: false\n\n# MD033/no-inline-html - Inline HTML\nMD033:\n  # Allowed elements\n  allowed_elements: ['style']\n"
  },
  {
    "path": ".npmrc",
    "content": "public-hoist-pattern[]=*@nextui-org/*\nengine-strict=true\ndeploy-all-files=true\n"
  },
  {
    "path": ".prettierignore",
    "content": "**/*.log\n**/.DS_Store\n*.\n*.json\napps/web/.next\ndist\nnode_modules\npnpm-lock.yaml"
  },
  {
    "path": ".prettierrc.json",
    "content": "{\n  \"tabWidth\": 2,\n  \"singleQuote\": true,\n  \"trailingComma\": \"all\"\n}"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\n    \"esbenp.prettier-vscode\",\n    \"dbaeumer.vscode-eslint\",\n    \"stylelint.vscode-stylelint\",\n    \"streetsidesoftware.code-spell-checker\",\n    \"DavidAnson.vscode-markdownlint\",\n    \"Gruntfuggly.todo-tree\",\n    \"mikestead.dotenv\",\n    \"foxundermoon.next-js\",\n    \"Prisma.prisma\",\n    \"planbcoding.vscode-react-refactor\",\n    \"yoavbls.pretty-ts-errors\",\n    \"usernamehw.errorlens\"\n  ]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"typescript.tsdk\": \"node_modules/.pnpm/typescript@4.9.4/node_modules/typescript/lib\",\n  \"typescript.enablePromptUseWorkspaceTsdk\": true,\n  \"[javascript]\": {\n    \"editor.formatOnSave\": true,\n    \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n  },\n  \"[typescript]\": {\n    \"editor.formatOnSave\": true,\n    \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n  },\n  \"[html]\": {\n    \"editor.formatOnSave\": true,\n    \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n  },\n  \"[scss]\": {\n    \"editor.formatOnSave\": true,\n    \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n  },\n  \"[css]\": {\n    \"editor.formatOnSave\": true,\n    \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n  },\n  \"[yaml]\": {\n    \"editor.formatOnSave\": true,\n    \"editor.defaultFormatter\": \"redhat.vscode-yaml\"\n  },\n  \"[json]\": {\n    \"editor.formatOnSave\": true,\n    \"editor.defaultFormatter\": \"vscode.json-language-features\"\n  },\n  \"cSpell.words\": [\n    \"callout\",\n    \"checkstyle\",\n    \"commitlint\",\n    \"daisyui\",\n    \"nestjs\",\n    \"nextui\",\n    \"tailwindcss\",\n    \"Trpc\",\n    \"wewe\"\n  ]\n}"
  },
  {
    "path": "Dockerfile",
    "content": "FROM node:20.16.0-alpine AS base\nENV PNPM_HOME=\"/pnpm\"\nENV PATH=\"$PNPM_HOME:$PATH\"\n\nRUN npm i -g pnpm\n\nFROM base AS build\nCOPY . /usr/src/app\nWORKDIR /usr/src/app\n\nRUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile\n\nRUN pnpm run -r build\n\nRUN pnpm deploy --filter=server --prod /app\nRUN pnpm deploy --filter=server --prod /app-sqlite\n\nRUN cd /app && pnpm exec prisma generate\n\nRUN cd /app-sqlite && \\\n    rm -rf ./prisma && \\\n    mv prisma-sqlite prisma && \\\n    pnpm exec prisma generate\n\nFROM base AS app-sqlite\nCOPY --from=build /app-sqlite /app\n\nWORKDIR /app\n\nEXPOSE 4000\n\nENV NODE_ENV=production\nENV HOST=\"0.0.0.0\"\nENV SERVER_ORIGIN_URL=\"\"\nENV MAX_REQUEST_PER_MINUTE=60\nENV AUTH_CODE=\"\"\nENV DATABASE_URL=\"file:../data/wewe-rss.db\"\nENV DATABASE_TYPE=\"sqlite\"\n\nRUN chmod +x ./docker-bootstrap.sh\n\nCMD [\"./docker-bootstrap.sh\"]\n\n\nFROM base AS app\nCOPY --from=build /app /app\n\nWORKDIR /app\n\nEXPOSE 4000\n\nENV NODE_ENV=production\nENV HOST=\"0.0.0.0\"\nENV SERVER_ORIGIN_URL=\"\"\nENV MAX_REQUEST_PER_MINUTE=60\nENV AUTH_CODE=\"\"\nENV DATABASE_URL=\"\"\n\nRUN chmod +x ./docker-bootstrap.sh\n\nCMD [\"./docker-bootstrap.sh\"]"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 cooderl\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE."
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n<img src=\"https://raw.githubusercontent.com/cooderl/wewe-rss/main/assets/logo.png\" width=\"80\" alt=\"预览\"/>\n\n# [WeWe RSS](https://github.com/cooderl/wewe-rss)\n\n更优雅的微信公众号订阅方式。\n\n![主界面](https://raw.githubusercontent.com/cooderl/wewe-rss/main/assets/preview1.png)\n</div>\n\n## ✨ 功能\n\n- v2.x版本使用全新接口，更加稳定\n- 支持微信公众号订阅（基于微信读书）\n- 获取公众号历史发布文章\n- 后台自动定时更新内容\n- 微信公众号RSS生成（支持`.atom`、`.rss`、`.json`格式)\n- 支持全文内容输出，让阅读无障碍\n- 所有订阅源导出OPML\n\n### 高级功能\n\n- **标题过滤**：支持通过`/feeds/all.(json|rss|atom)`接口和`/feeds/:feed`对标题进行过滤\n  ```\n  {{ORIGIN_URL}}/feeds/all.atom?title_include=张三\n  {{ORIGIN_URL}}/feeds/MP_WXS_123.json?limit=30&title_include=张三|李四|王五&title_exclude=张三丰|赵六\n  ```\n\n- **手动更新**：支持通过`/feeds/:feed`接口触发单个feedid更新\n  ```\n  {{ORIGIN_URL}}/feeds/MP_WXS_123.rss?update=true\n  ```\n\n## 🚀 部署\n\n### 一键部署\n\n- [Deploy on Zeabur](https://zeabur.com/templates/DI9BBD)\n- [Railway](https://railway.app/)\n- [Hugging Face部署参考](https://github.com/cooderl/wewe-rss/issues/32)\n\n### Docker Compose 部署\n\n参考 [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)\n\n### Docker 命令启动\n\n#### MySQL (推荐)\n\n1. 创建docker网络\n   ```sh\n   docker network create wewe-rss\n   ```\n\n2. 启动 MySQL 数据库\n   ```sh\n   docker run -d \\\n     --name db \\\n     -e MYSQL_ROOT_PASSWORD=123456 \\\n     -e TZ='Asia/Shanghai' \\\n     -e MYSQL_DATABASE='wewe-rss' \\\n     -v db_data:/var/lib/mysql \\\n     --network wewe-rss \\\n     mysql:8.3.0 --mysql-native-password=ON\n   ```\n\n3. 启动 Server\n   ```sh\n   docker run -d \\\n     --name wewe-rss \\\n     -p 4000:4000 \\\n     -e DATABASE_URL='mysql://root:123456@db:3306/wewe-rss?schema=public&connect_timeout=30&pool_timeout=30&socket_timeout=30' \\\n     -e AUTH_CODE=123567 \\\n     --network wewe-rss \\\n     cooderl/wewe-rss:latest\n   ```\n\n[Nginx配置参考](https://raw.githubusercontent.com/cooderl/wewe-rss/main/assets/nginx.example.conf)\n\n#### SQLite (不推荐)\n\n```sh\ndocker run -d \\\n  --name wewe-rss \\\n  -p 4000:4000 \\\n  -e DATABASE_TYPE=sqlite \\\n  -e AUTH_CODE=123567 \\\n  -v $(pwd)/data:/app/data \\\n  cooderl/wewe-rss-sqlite:latest\n```\n\n### 本地部署\n\n使用 `pnpm install && pnpm run -r build && pnpm run start:server` 命令 (可配合 pm2 守护进程)\n\n**详细步骤** (SQLite示例)：\n\n```shell\n# 需要提前声明环境变量,因为prisma会根据环境变量生成对应的数据库连接\nexport DATABASE_URL=\"file:../data/wewe-rss.db\"\nexport DATABASE_TYPE=\"sqlite\"\n# 删除mysql相关文件,避免prisma生成mysql连接\nrm -rf apps/server/prisma\nmv apps/server/prisma-sqlite apps/server/prisma\n# 生成prisma client\nnpx prisma generate --schema apps/server/prisma/schema.prisma\n# 生成数据库表\nnpx prisma migrate deploy --schema apps/server/prisma/schema.prisma\n# 构建并运行\npnpm run -r build\npnpm run start:server\n```\n\n## ⚙️ 环境变量\n\n| 变量名                   | 说明                                                                    | 默认值                      |\n| ------------------------ | ----------------------------------------------------------------------- | --------------------------- |\n| `DATABASE_URL`           | **必填** 数据库地址，例如 `mysql://root:123456@127.0.0.1:3306/wewe-rss` | -                           |\n| `DATABASE_TYPE`          | 数据库类型，使用 SQLite 时需填写 `sqlite`                               | -                           |\n| `AUTH_CODE`              | 服务端接口请求授权码，空字符或不设置将不启用 (`/feeds`路径不需要)       | -                           |\n| `SERVER_ORIGIN_URL`      | 服务端访问地址，用于生成RSS完整路径                                     | -                           |\n| `MAX_REQUEST_PER_MINUTE` | 每分钟最大请求次数                                                      | 60                          |\n| `FEED_MODE`              | 输出模式，可选值 `fulltext` (会使接口响应变慢，占用更多内存)            | -                           |\n| `CRON_EXPRESSION`        | 定时更新订阅源Cron表达式                                                | `35 5,17 * * *`             |\n| `UPDATE_DELAY_TIME`      | 连续更新延迟时间，减少被关小黑屋                                        | `60s`                       |\n| `ENABLE_CLEAN_HTML`      | 是否开启正文html清理                                                    | `false`                     |\n| `PLATFORM_URL`           | 基础服务URL                                                             | `https://weread.111965.xyz` |\n\n> **注意**: 国内DNS解析问题可使用 `https://weread.965111.xyz` 加速访问\n\n## 🔔 钉钉通知\n\n进入 wewe-rss-dingtalk 目录按照 README.md 指引部署\n\n## 📱 使用方式\n\n1. 进入账号管理，点击添加账号，微信扫码登录微信读书账号。\n  \n   **注意不要勾选24小时后自动退出**\n   \n   <img width=\"400\" src=\"./assets/preview2.png\"/>\n\n\n2. 进入公众号源，点击添加，通过提交微信公众号分享链接，订阅微信公众号。\n   **添加频率过高容易被封控，等24小时解封**\n\n   <img width=\"400\" src=\"./assets/preview3.png\"/>\n\n## 🔑 账号状态说明\n\n| 状态       | 说明                                                                |\n| ---------- | ------------------------------------------------------------------- |\n| 今日小黑屋 | 账号被封控，等一天恢复。账号正常时可通过重启服务/容器清除小黑屋记录 |\n| 禁用       | 不使用该账号                                                        |\n| 失效       | 账号登录状态失效，需要重新登录                                      |\n\n## 💻 本地开发\n\n1. 安装 nodejs 20 和 pnpm\n2. 修改环境变量：\n   ```\n   cp ./apps/web/.env.local.example ./apps/web/.env\n   cp ./apps/server/.env.local.example ./apps/server/.env\n   ```\n3. 执行 `pnpm install && pnpm run build:web && pnpm dev` \n   \n   ⚠️ **注意：此命令仅用于本地开发，不要用于部署！**\n4. 前端访问 `http://localhost:5173`，后端访问 `http://localhost:4000`\n\n## ⚠️ 风险声明\n\n为了确保本项目的持久运行，某些接口请求将通过 `weread.111965.xyz` 进行转发。请放心，该转发服务不会保存任何数据。\n\n## ❤️ 赞助\n\n如果觉得 WeWe RSS 项目对你有帮助，可以给我来一杯啤酒！\n\n**PayPal**: [paypal.me/cooderl](https://paypal.me/cooderl)\n\n## 👨‍💻 贡献者\n\n<a href=\"https://github.com/cooderl/wewe-rss/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=cooderl/wewe-rss\" />\n</a>\n\n## 📄 License\n\n[MIT](https://raw.githubusercontent.com/cooderl/wewe-rss/main/LICENSE) @cooderl\n"
  },
  {
    "path": "apps/server/.eslintrc.js",
    "content": "module.exports = {\n  parser: '@typescript-eslint/parser',\n  parserOptions: {\n    project: 'tsconfig.json',\n    tsconfigRootDir: __dirname,\n    sourceType: 'module',\n  },\n  plugins: ['@typescript-eslint/eslint-plugin'],\n  extends: [\n    'plugin:@typescript-eslint/recommended',\n    'plugin:prettier/recommended',\n  ],\n  root: true,\n  env: {\n    node: true,\n    jest: true,\n  },\n  ignorePatterns: ['.eslintrc.js'],\n  rules: {\n    '@typescript-eslint/interface-name-prefix': 'off',\n    '@typescript-eslint/explicit-function-return-type': 'off',\n    '@typescript-eslint/explicit-module-boundary-types': 'off',\n    '@typescript-eslint/no-explicit-any': 'off',\n  },\n};\n"
  },
  {
    "path": "apps/server/.gitignore",
    "content": "node_modules\n# Keep environment variables out of version control\n.env\n\nclient\ndata"
  },
  {
    "path": "apps/server/.prettierrc.json",
    "content": "{\n  \"tabWidth\": 2,\n  \"singleQuote\": true,\n  \"trailingComma\": \"all\"\n}"
  },
  {
    "path": "apps/server/README.md",
    "content": "<p align=\"center\">\n  <a href=\"http://nestjs.com/\" target=\"blank\"><img src=\"https://nestjs.com/img/logo-small.svg\" width=\"200\" alt=\"Nest Logo\" /></a>\n</p>\n\n[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456\n[circleci-url]: https://circleci.com/gh/nestjs/nest\n\n  <p align=\"center\">A progressive <a href=\"http://nodejs.org\" target=\"_blank\">Node.js</a> framework for building efficient and scalable server-side applications.</p>\n    <p align=\"center\">\n<a href=\"https://www.npmjs.com/~nestjscore\" target=\"_blank\"><img src=\"https://img.shields.io/npm/v/@nestjs/core.svg\" alt=\"NPM Version\" /></a>\n<a href=\"https://www.npmjs.com/~nestjscore\" target=\"_blank\"><img src=\"https://img.shields.io/npm/l/@nestjs/core.svg\" alt=\"Package License\" /></a>\n<a href=\"https://www.npmjs.com/~nestjscore\" target=\"_blank\"><img src=\"https://img.shields.io/npm/dm/@nestjs/common.svg\" alt=\"NPM Downloads\" /></a>\n<a href=\"https://circleci.com/gh/nestjs/nest\" target=\"_blank\"><img src=\"https://img.shields.io/circleci/build/github/nestjs/nest/master\" alt=\"CircleCI\" /></a>\n<a href=\"https://coveralls.io/github/nestjs/nest?branch=master\" target=\"_blank\"><img src=\"https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9\" alt=\"Coverage\" /></a>\n<a href=\"https://discord.gg/G7Qnnhy\" target=\"_blank\"><img src=\"https://img.shields.io/badge/discord-online-brightgreen.svg\" alt=\"Discord\"/></a>\n<a href=\"https://opencollective.com/nest#backer\" target=\"_blank\"><img src=\"https://opencollective.com/nest/backers/badge.svg\" alt=\"Backers on Open Collective\" /></a>\n<a href=\"https://opencollective.com/nest#sponsor\" target=\"_blank\"><img src=\"https://opencollective.com/nest/sponsors/badge.svg\" alt=\"Sponsors on Open Collective\" /></a>\n  <a href=\"https://paypal.me/kamilmysliwiec\" target=\"_blank\"><img src=\"https://img.shields.io/badge/Donate-PayPal-ff3f59.svg\"/></a>\n    <a href=\"https://opencollective.com/nest#sponsor\"  target=\"_blank\"><img src=\"https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg\" alt=\"Support us\"></a>\n  <a href=\"https://twitter.com/nestframework\" target=\"_blank\"><img src=\"https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow\"></a>\n</p>\n  <!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)\n  [![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->\n\n## Description\n\n[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.\n\n## Installation\n\n```bash\n$ pnpm install\n```\n\n## Running the app\n\n```bash\n# development\n$ pnpm run start\n\n# watch mode\n$ pnpm run start:dev\n\n# production mode\n$ pnpm run start:prod\n```\n\n## Test\n\n```bash\n# unit tests\n$ pnpm run test\n\n# e2e tests\n$ pnpm run test:e2e\n\n# test coverage\n$ pnpm run test:cov\n```\n\n## Support\n\nNest 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).\n\n## Stay in touch\n\n- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)\n- Website - [https://nestjs.com](https://nestjs.com/)\n- Twitter - [@nestframework](https://twitter.com/nestframework)\n\n## License\n\nNest is [MIT licensed](LICENSE).\n"
  },
  {
    "path": "apps/server/docker-bootstrap.sh",
    "content": "\n#!/bin/sh\n# ENVIRONEMTN from docker-compose.yaml doesn't get through to subprocesses\n# Need to explicit pass DATABASE_URL here, otherwise migration doesn't work\n# Run migrations\nDATABASE_URL=${DATABASE_URL} npx prisma migrate deploy\n# start app\nDATABASE_URL=${DATABASE_URL} node dist/main"
  },
  {
    "path": "apps/server/nest-cli.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/nest-cli\",\n  \"collection\": \"@nestjs/schematics\",\n  \"sourceRoot\": \"src\",\n  \"compilerOptions\": {\n    \"deleteOutDir\": true\n  }\n}\n"
  },
  {
    "path": "apps/server/package.json",
    "content": "{\n  \"name\": \"server\",\n  \"version\": \"2.6.1\",\n  \"description\": \"\",\n  \"author\": \"\",\n  \"private\": true,\n  \"license\": \"UNLICENSED\",\n  \"scripts\": {\n    \"build\": \"nest build\",\n    \"format\": \"prettier --write \\\"src/**/*.ts\\\" \\\"test/**/*.ts\\\"\",\n    \"start\": \"nest start\",\n    \"dev\": \"nest start --watch\",\n    \"start:debug\": \"nest start --debug --watch\",\n    \"start:prod\": \"node dist/main\",\n    \"start:migrate:prod\": \"prisma migrate deploy && npm run start:prod\",\n    \"postinstall\": \"npx prisma generate\",\n    \"migrate\": \"pnpm prisma migrate dev\",\n    \"studio\": \"pnpm prisma studio\",\n    \"lint\": \"eslint \\\"{src,apps,libs,test}/**/*.ts\\\" --fix\",\n    \"test\": \"jest\",\n    \"test:watch\": \"jest --watch\",\n    \"test:cov\": \"jest --coverage\",\n    \"test:debug\": \"node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand\",\n    \"test:e2e\": \"jest --config ./test/jest-e2e.json\"\n  },\n  \"dependencies\": {\n    \"@cjs-exporter/p-map\": \"^5.5.0\",\n    \"@nestjs/common\": \"^10.3.3\",\n    \"@nestjs/config\": \"^3.2.0\",\n    \"@nestjs/core\": \"^10.3.3\",\n    \"@nestjs/platform-express\": \"^10.3.3\",\n    \"@nestjs/schedule\": \"^4.0.1\",\n    \"@nestjs/throttler\": \"^5.1.2\",\n    \"@prisma/client\": \"5.10.1\",\n    \"@trpc/server\": \"^10.45.1\",\n    \"axios\": \"^1.6.7\",\n    \"cheerio\": \"1.0.0-rc.12\",\n    \"class-transformer\": \"^0.5.1\",\n    \"class-validator\": \"^0.14.1\",\n    \"dayjs\": \"^1.11.10\",\n    \"express\": \"^4.18.2\",\n    \"feed\": \"^4.2.2\",\n    \"got\": \"11.8.6\",\n    \"hbs\": \"^4.2.0\",\n    \"html-minifier\": \"^4.0.0\",\n    \"lru-cache\": \"^10.2.2\",\n    \"prisma\": \"^5.10.2\",\n    \"reflect-metadata\": \"^0.2.1\",\n    \"rxjs\": \"^7.8.1\",\n    \"zod\": \"^3.22.4\"\n  },\n  \"devDependencies\": {\n    \"@nestjs/cli\": \"^10.3.2\",\n    \"@nestjs/schematics\": \"^10.1.1\",\n    \"@nestjs/testing\": \"^10.3.3\",\n    \"@types/express\": \"^4.17.21\",\n    \"@types/html-minifier\": \"^4.0.5\",\n    \"@types/jest\": \"^29.5.12\",\n    \"@types/node\": \"^20.11.19\",\n    \"@types/supertest\": \"^6.0.2\",\n    \"@typescript-eslint/eslint-plugin\": \"^7.0.2\",\n    \"@typescript-eslint/parser\": \"^7.0.2\",\n    \"eslint\": \"^8.56.0\",\n    \"eslint-config-prettier\": \"^9.1.0\",\n    \"eslint-plugin-prettier\": \"^5.1.3\",\n    \"jest\": \"^29.7.0\",\n    \"prettier\": \"^3.2.5\",\n    \"source-map-support\": \"^0.5.21\",\n    \"supertest\": \"^6.3.4\",\n    \"ts-jest\": \"^29.1.2\",\n    \"ts-loader\": \"^9.5.1\",\n    \"ts-node\": \"^10.9.2\",\n    \"tsconfig-paths\": \"^4.2.0\",\n    \"typescript\": \"^5.3.3\"\n  },\n  \"jest\": {\n    \"moduleFileExtensions\": [\n      \"js\",\n      \"json\",\n      \"ts\"\n    ],\n    \"rootDir\": \"src\",\n    \"testRegex\": \".*\\\\.spec\\\\.ts$\",\n    \"transform\": {\n      \"^.+\\\\.(t|j)s$\": \"ts-jest\"\n    },\n    \"collectCoverageFrom\": [\n      \"**/*.(t|j)s\"\n    ],\n    \"coverageDirectory\": \"../coverage\",\n    \"testEnvironment\": \"node\"\n  }\n}"
  },
  {
    "path": "apps/server/prisma/migrations/20240227153512_init/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE `accounts` (\n    `id` VARCHAR(255) NOT NULL,\n    `token` VARCHAR(2048) NOT NULL,\n    `name` VARCHAR(1024) NOT NULL,\n    `status` INTEGER NOT NULL DEFAULT 1,\n    `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),\n    `updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3),\n\n    PRIMARY KEY (`id`)\n) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;\n\n-- CreateTable\nCREATE TABLE `feeds` (\n    `id` VARCHAR(255) NOT NULL,\n    `mp_name` VARCHAR(512) NOT NULL,\n    `mp_cover` VARCHAR(1024) NOT NULL,\n    `mp_intro` TEXT NOT NULL,\n    `status` INTEGER NOT NULL DEFAULT 1,\n    `sync_time` INTEGER NOT NULL DEFAULT 0,\n    `update_time` INTEGER NOT NULL,\n    `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),\n    `updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3),\n\n    PRIMARY KEY (`id`)\n) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;\n\n-- CreateTable\nCREATE TABLE `articles` (\n    `id` VARCHAR(255) NOT NULL,\n    `mp_id` VARCHAR(255) NOT NULL,\n    `title` VARCHAR(255) NOT NULL,\n    `pic_url` VARCHAR(255) NOT NULL,\n    `publish_time` INTEGER NOT NULL,\n    `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),\n    `updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3),\n\n    PRIMARY KEY (`id`)\n) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;\n"
  },
  {
    "path": "apps/server/prisma/migrations/20241212153618_has_history/migration.sql",
    "content": "-- AlterTable\nALTER TABLE `feeds` ADD COLUMN `has_history` INTEGER NULL DEFAULT 1;\n"
  },
  {
    "path": "apps/server/prisma/migrations/migration_lock.toml",
    "content": "# Please do not edit this file manually\n# It should be added in your version-control system (i.e. Git)\nprovider = \"mysql\""
  },
  {
    "path": "apps/server/prisma/schema.prisma",
    "content": "datasource db {\n  provider = \"mysql\"\n  url      = env(\"DATABASE_URL\")\n}\n\ngenerator client {\n  provider      = \"prisma-client-js\"\n  binaryTargets = [\"native\", \"linux-musl\"] // 生成linux可执行文件\n}\n\n// 读书账号\nmodel Account {\n  id        String    @id @db.VarChar(255)\n  token     String    @map(\"token\") @db.VarChar(2048)\n  name      String    @map(\"name\") @db.VarChar(1024)\n  // 状态 0:失效 1:启用 2:禁用\n  status    Int       @default(1) @map(\"status\") @db.Int()\n  createdAt DateTime  @default(now()) @map(\"created_at\")\n  updatedAt DateTime? @default(now()) @updatedAt @map(\"updated_at\")\n\n  @@map(\"accounts\")\n}\n\n// 订阅源\nmodel Feed {\n  id      String @id @db.VarChar(255)\n  mpName  String @map(\"mp_name\") @db.VarChar(512)\n  mpCover String @map(\"mp_cover\") @db.VarChar(1024)\n  mpIntro String @map(\"mp_intro\") @db.Text()\n  // 状态 0:失效 1:启用 2:禁用\n  status  Int    @default(1) @map(\"status\") @db.Int()\n\n  // article最后同步时间\n  syncTime Int @default(0) @map(\"sync_time\")\n\n  // 信息更新时间\n  updateTime Int @map(\"update_time\")\n\n  createdAt DateTime  @default(now()) @map(\"created_at\")\n  updatedAt DateTime? @default(now()) @updatedAt @map(\"updated_at\")\n\n  // 是否有历史文章 1 是  0 否\n  hasHistory Int? @default(1) @map(\"has_history\")\n\n  @@map(\"feeds\")\n}\n\nmodel Article {\n  id          String @id @db.VarChar(255)\n  mpId        String @map(\"mp_id\") @db.VarChar(255)\n  title       String @map(\"title\") @db.VarChar(255)\n  picUrl      String @map(\"pic_url\") @db.VarChar(255)\n  publishTime Int    @map(\"publish_time\")\n\n  createdAt DateTime  @default(now()) @map(\"created_at\")\n  updatedAt DateTime? @default(now()) @updatedAt @map(\"updated_at\")\n\n  @@map(\"articles\")\n}\n"
  },
  {
    "path": "apps/server/prisma-sqlite/migrations/20240301104100_init/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"accounts\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"token\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"status\" INTEGER NOT NULL DEFAULT 1,\n    \"created_at\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" DATETIME DEFAULT CURRENT_TIMESTAMP\n);\n\n-- CreateTable\nCREATE TABLE \"feeds\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"mp_name\" TEXT NOT NULL,\n    \"mp_cover\" TEXT NOT NULL,\n    \"mp_intro\" TEXT NOT NULL,\n    \"status\" INTEGER NOT NULL DEFAULT 1,\n    \"sync_time\" INTEGER NOT NULL DEFAULT 0,\n    \"update_time\" INTEGER NOT NULL,\n    \"created_at\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" DATETIME DEFAULT CURRENT_TIMESTAMP\n);\n\n-- CreateTable\nCREATE TABLE \"articles\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"mp_id\" TEXT NOT NULL,\n    \"title\" TEXT NOT NULL,\n    \"pic_url\" TEXT NOT NULL,\n    \"publish_time\" INTEGER NOT NULL,\n    \"created_at\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" DATETIME DEFAULT CURRENT_TIMESTAMP\n);\n"
  },
  {
    "path": "apps/server/prisma-sqlite/migrations/20241214172323_has_history/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"feeds\" ADD COLUMN \"has_history\" INTEGER DEFAULT 1;\n"
  },
  {
    "path": "apps/server/prisma-sqlite/migrations/migration_lock.toml",
    "content": "# Please do not edit this file manually\n# It should be added in your version-control system (i.e. Git)\nprovider = \"sqlite\""
  },
  {
    "path": "apps/server/prisma-sqlite/schema.prisma",
    "content": "datasource db {\n  provider = \"sqlite\"\n  url      = env(\"DATABASE_URL\")\n}\n\ngenerator client {\n  provider      = \"prisma-client-js\"\n  binaryTargets = [\"native\", \"linux-musl\"] // 生成linux可执行文件\n}\n\n// 读书账号\nmodel Account {\n  id        String    @id\n  token     String    @map(\"token\")\n  name      String    @map(\"name\")\n  // 状态 0:失效 1:启用 2:禁用\n  status    Int       @default(1) @map(\"status\")\n  createdAt DateTime  @default(now()) @map(\"created_at\")\n  updatedAt DateTime? @default(now()) @updatedAt @map(\"updated_at\")\n\n  @@map(\"accounts\")\n}\n\n// 订阅源\nmodel Feed {\n  id      String @id\n  mpName  String @map(\"mp_name\")\n  mpCover String @map(\"mp_cover\")\n  mpIntro String @map(\"mp_intro\")\n  // 状态 0:失效 1:启用 2:禁用\n  status  Int    @default(1) @map(\"status\")\n\n  // article最后同步时间\n  syncTime Int @default(0) @map(\"sync_time\")\n\n  // 信息更新时间\n  updateTime Int @map(\"update_time\")\n\n  createdAt DateTime  @default(now()) @map(\"created_at\")\n  updatedAt DateTime? @default(now()) @updatedAt @map(\"updated_at\")\n\n  // 是否有历史文章 1 是  0 否\n  hasHistory Int? @default(1) @map(\"has_history\")\n\n  @@map(\"feeds\")\n}\n\nmodel Article {\n  id          String @id\n  mpId        String @map(\"mp_id\")\n  title       String @map(\"title\")\n  picUrl      String @map(\"pic_url\")\n  publishTime Int    @map(\"publish_time\")\n\n  createdAt DateTime  @default(now()) @map(\"created_at\")\n  updatedAt DateTime? @default(now()) @updatedAt @map(\"updated_at\")\n\n  @@map(\"articles\")\n}\n"
  },
  {
    "path": "apps/server/src/app.controller.spec.ts",
    "content": "import { Test, TestingModule } from '@nestjs/testing';\nimport { AppController } from './app.controller';\nimport { AppService } from './app.service';\n\ndescribe('AppController', () => {\n  let appController: AppController;\n\n  beforeEach(async () => {\n    const app: TestingModule = await Test.createTestingModule({\n      controllers: [AppController],\n      providers: [AppService],\n    }).compile();\n\n    appController = app.get<AppController>(AppController);\n  });\n\n  describe('root', () => {\n    it('should return \"Hello World!\"', () => {\n      expect(appController.getHello()).toBe('Hello World!');\n    });\n  });\n});\n"
  },
  {
    "path": "apps/server/src/app.controller.ts",
    "content": "import { Controller, Get, Response, Render } from '@nestjs/common';\nimport { AppService } from './app.service';\nimport { ConfigService } from '@nestjs/config';\nimport { ConfigurationType } from './configuration';\nimport { Response as Res } from 'express';\n\n@Controller()\nexport class AppController {\n  constructor(\n    private readonly appService: AppService,\n    private readonly configService: ConfigService,\n  ) {}\n\n  @Get()\n  getHello(): string {\n    return this.appService.getHello();\n  }\n\n  @Get('/robots.txt')\n  forRobot(): string {\n    return 'User-agent:  *\\nDisallow:  /';\n  }\n\n  @Get('favicon.ico')\n  getFavicon(@Response() res: Res) {\n    const imgContent =\n      '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=';\n    const imgBuffer = Buffer.from(imgContent, 'base64');\n    res.setHeader('Content-Type', 'image/png');\n    res.send(imgBuffer);\n  }\n\n  @Get('/dash*')\n  @Render('index.hbs')\n  dashRender() {\n    const { originUrl: weweRssServerOriginUrl } =\n      this.configService.get<ConfigurationType['feed']>('feed')!;\n    const { code } = this.configService.get<ConfigurationType['auth']>('auth')!;\n\n    return {\n      weweRssServerOriginUrl,\n      enabledAuthCode: !!code,\n      iconUrl: weweRssServerOriginUrl\n        ? `${weweRssServerOriginUrl}/favicon.ico`\n        : 'https://r2-assets.111965.xyz/wewe-rss.png',\n    };\n  }\n}\n"
  },
  {
    "path": "apps/server/src/app.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { AppController } from './app.controller';\nimport { AppService } from './app.service';\nimport { TrpcModule } from '@server/trpc/trpc.module';\nimport { ConfigModule, ConfigService } from '@nestjs/config';\nimport configuration, { ConfigurationType } from './configuration';\nimport { ThrottlerModule } from '@nestjs/throttler';\nimport { ScheduleModule } from '@nestjs/schedule';\nimport { FeedsModule } from './feeds/feeds.module';\n\n@Module({\n  imports: [\n    TrpcModule,\n    FeedsModule,\n    ScheduleModule.forRoot(),\n    ConfigModule.forRoot({\n      isGlobal: true,\n      envFilePath: ['.env.local', '.env'],\n      load: [configuration],\n    }),\n    ThrottlerModule.forRootAsync({\n      imports: [ConfigModule],\n      inject: [ConfigService],\n      useFactory(config: ConfigService) {\n        const throttler =\n          config.get<ConfigurationType['throttler']>('throttler');\n        return [\n          {\n            ttl: 60,\n            limit: throttler?.maxRequestPerMinute || 60,\n          },\n        ];\n      },\n    }),\n  ],\n  controllers: [AppController],\n  providers: [AppService],\n})\nexport class AppModule {}\n"
  },
  {
    "path": "apps/server/src/app.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\n\n@Injectable()\nexport class AppService {\n  constructor(private readonly configService: ConfigService) {}\n  getHello(): string {\n    return `\n    <div style=\"display:flex;justify-content: center;height: 100%;align-items: center;font-size: 30px;\">\n    <div>>> <a href=\"/dash\">WeWe RSS</a> <<</div>\n    </div>\n    `;\n  }\n}\n"
  },
  {
    "path": "apps/server/src/configuration.ts",
    "content": "const configuration = () => {\n  const isProd = process.env.NODE_ENV === 'production';\n  const port = process.env.PORT || 4000;\n  const host = process.env.HOST || '0.0.0.0';\n\n  const maxRequestPerMinute = parseInt(\n    `${process.env.MAX_REQUEST_PER_MINUTE}|| 60`,\n  );\n\n  const authCode = process.env.AUTH_CODE;\n  const platformUrl = process.env.PLATFORM_URL || 'https://weread.111965.xyz';\n  const originUrl = process.env.SERVER_ORIGIN_URL || '';\n\n  const feedMode = process.env.FEED_MODE as 'fulltext' | '';\n\n  const databaseType = process.env.DATABASE_TYPE || 'mysql';\n\n  const updateDelayTime = parseInt(`${process.env.UPDATE_DELAY_TIME} || 60`);\n\n  const enableCleanHtml = process.env.ENABLE_CLEAN_HTML === 'true';\n  return {\n    server: { isProd, port, host },\n    throttler: { maxRequestPerMinute },\n    auth: { code: authCode },\n    platform: { url: platformUrl },\n    feed: {\n      originUrl,\n      mode: feedMode,\n      updateDelayTime,\n      enableCleanHtml,\n    },\n    database: {\n      type: databaseType,\n    },\n  };\n};\n\nexport default configuration;\n\nexport type ConfigurationType = ReturnType<typeof configuration>;\n"
  },
  {
    "path": "apps/server/src/constants.ts",
    "content": "export const statusMap = {\n  // 0:失效 1:启用 2:禁用\n  INVALID: 0,\n  ENABLE: 1,\n  DISABLE: 2,\n};\n\nexport const feedTypes = ['rss', 'atom', 'json'] as const;\n\nexport const feedMimeTypeMap = {\n  rss: 'application/rss+xml; charset=utf-8',\n  atom: 'application/atom+xml; charset=utf-8',\n  json: 'application/feed+json; charset=utf-8',\n} as const;\n\nexport const defaultCount = 20;\n"
  },
  {
    "path": "apps/server/src/feeds/feeds.controller.spec.ts",
    "content": "import { Test, TestingModule } from '@nestjs/testing';\nimport { FeedsController } from './feeds.controller';\n\ndescribe('FeedsController', () => {\n  let controller: FeedsController;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      controllers: [FeedsController],\n    }).compile();\n\n    controller = module.get<FeedsController>(FeedsController);\n  });\n\n  it('should be defined', () => {\n    expect(controller).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/server/src/feeds/feeds.controller.ts",
    "content": "import {\n  Controller,\n  DefaultValuePipe,\n  Get,\n  Logger,\n  Param,\n  ParseIntPipe,\n  Query,\n  Request,\n  Response,\n} from '@nestjs/common';\nimport { FeedsService } from './feeds.service';\nimport { Response as Res, Request as Req } from 'express';\n\n@Controller('feeds')\nexport class FeedsController {\n  private readonly logger = new Logger(this.constructor.name);\n\n  constructor(private readonly feedsService: FeedsService) {}\n\n  @Get('/')\n  async getFeedList() {\n    return this.feedsService.getFeedList();\n  }\n\n  @Get('/all.(json|rss|atom)')\n  async getFeeds(\n    @Request() req: Req,\n    @Response() res: Res,\n    @Query('limit', new DefaultValuePipe(30), ParseIntPipe) limit: number = 30,\n    @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number = 1,\n    @Query('mode') mode: string,\n    @Query('title_include') title_include: string,\n    @Query('title_exclude') title_exclude: string,\n  ) {\n    const path = req.path;\n    const type = path.split('.').pop() || '';\n\n    const { content, mimeType } = await this.feedsService.handleGenerateFeed({\n      type,\n      limit,\n      page,\n      mode,\n      title_include,\n      title_exclude,\n    });\n\n    res.setHeader('Content-Type', mimeType);\n    res.send(content);\n  }\n\n  @Get('/:feed')\n  async getFeed(\n    @Response() res: Res,\n    @Param('feed') feed: string,\n    @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number = 10,\n    @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number = 1,\n    @Query('mode') mode: string,\n    @Query('title_include') title_include: string,\n    @Query('title_exclude') title_exclude: string,\n    @Query('update') update: boolean = false,\n  ) {\n    const [id, type] = feed.split('.');\n    this.logger.log('getFeed: ', id);\n\n    if (update) {\n      this.feedsService.updateFeed(id);\n    }\n\n    const { content, mimeType } = await this.feedsService.handleGenerateFeed({\n      id,\n      type,\n      limit,\n      page,\n      mode,\n      title_include,\n      title_exclude,\n    });\n\n    res.setHeader('Content-Type', mimeType);\n    res.send(content);\n  }\n}\n"
  },
  {
    "path": "apps/server/src/feeds/feeds.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { FeedsController } from './feeds.controller';\nimport { FeedsService } from './feeds.service';\nimport { PrismaModule } from '@server/prisma/prisma.module';\nimport { TrpcModule } from '@server/trpc/trpc.module';\n\n@Module({\n  imports: [PrismaModule, TrpcModule],\n  controllers: [FeedsController],\n  providers: [FeedsService],\n})\nexport class FeedsModule {}\n"
  },
  {
    "path": "apps/server/src/feeds/feeds.service.spec.ts",
    "content": "import { Test, TestingModule } from '@nestjs/testing';\nimport { FeedsService } from './feeds.service';\n\ndescribe('FeedsService', () => {\n  let service: FeedsService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [FeedsService],\n    }).compile();\n\n    service = module.get<FeedsService>(FeedsService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/server/src/feeds/feeds.service.ts",
    "content": "import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';\nimport { PrismaService } from '@server/prisma/prisma.service';\nimport { Cron } from '@nestjs/schedule';\nimport { TrpcService } from '@server/trpc/trpc.service';\nimport { feedMimeTypeMap, feedTypes } from '@server/constants';\nimport { ConfigService } from '@nestjs/config';\nimport { Article, Feed as FeedInfo } from '@prisma/client';\nimport { ConfigurationType } from '@server/configuration';\nimport { Feed, Item } from 'feed';\nimport got, { Got } from 'got';\nimport { load } from 'cheerio';\nimport { minify } from 'html-minifier';\nimport { LRUCache } from 'lru-cache';\nimport pMap from '@cjs-exporter/p-map';\n\nconsole.log('CRON_EXPRESSION: ', process.env.CRON_EXPRESSION);\n\nconst mpCache = new LRUCache<string, string>({\n  max: 5000,\n});\n\n@Injectable()\nexport class FeedsService {\n  private readonly logger = new Logger(this.constructor.name);\n\n  private request: Got;\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly trpcService: TrpcService,\n    private readonly configService: ConfigService,\n  ) {\n    this.request = got.extend({\n      retry: {\n        limit: 3,\n        methods: ['GET'],\n      },\n      timeout: 8 * 1e3,\n      headers: {\n        accept:\n          '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',\n        'accept-encoding': 'gzip, deflate, br',\n        'accept-language': 'en-US,en;q=0.9',\n        'cache-control': 'max-age=0',\n        'sec-ch-ua':\n          '\" Not A;Brand\";v=\"99\", \"Chromium\";v=\"101\", \"Google Chrome\";v=\"101\"',\n        'sec-ch-ua-mobile': '?0',\n        'sec-ch-ua-platform': '\"macOS\"',\n        'sec-fetch-dest': 'document',\n        'sec-fetch-mode': 'navigate',\n        'sec-fetch-site': 'none',\n        'sec-fetch-user': '?1',\n        'upgrade-insecure-requests': '1',\n        'user-agent':\n          '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',\n      },\n      hooks: {\n        beforeRetry: [\n          async (options, error, retryCount) => {\n            this.logger.warn(`retrying ${options.url}...`);\n            return new Promise((resolve) =>\n              setTimeout(resolve, 2e3 * (retryCount || 1)),\n            );\n          },\n        ],\n      },\n    });\n  }\n\n  @Cron(process.env.CRON_EXPRESSION || '35 5,17 * * *', {\n    name: 'updateFeeds',\n    timeZone: 'Asia/Shanghai',\n  })\n  async handleUpdateFeedsCron() {\n    this.logger.debug('Called handleUpdateFeedsCron');\n\n    const feeds = await this.prismaService.feed.findMany({\n      where: { status: 1 },\n    });\n    this.logger.debug('feeds length:' + feeds.length);\n\n    const updateDelayTime =\n      this.configService.get<ConfigurationType['feed']>(\n        'feed',\n      )!.updateDelayTime;\n\n    for (const feed of feeds) {\n      this.logger.debug('feed', feed.id);\n      try {\n        await this.trpcService.refreshMpArticlesAndUpdateFeed(feed.id);\n\n        await new Promise((resolve) =>\n          setTimeout(resolve, updateDelayTime * 1e3),\n        );\n      } catch (err) {\n        this.logger.error('handleUpdateFeedsCron error', err);\n      } finally {\n        // wait 30s for next feed\n        await new Promise((resolve) => setTimeout(resolve, 30 * 1e3));\n      }\n    }\n  }\n\n  async cleanHtml(source: string) {\n    const $ = load(source, { decodeEntities: false });\n\n    const dirtyHtml = $.html($('.rich_media_content'));\n\n    const html = dirtyHtml\n      .replace(/data-src=/g, 'src=')\n      .replace(/opacity: 0( !important)?;/g, '')\n      .replace(/visibility: hidden;/g, '');\n\n    const content =\n      '<style> .rich_media_content {overflow: hidden;color: #222;font-size: 17px;word-wrap: break-word;-webkit-hyphens: auto;-ms-hyphens: auto;hyphens: auto;text-align: justify;position: relative;z-index: 0;}.rich_media_content {font-size: 18px;}</style>' +\n      html;\n\n    const result = minify(content, {\n      removeAttributeQuotes: true,\n      collapseWhitespace: true,\n    });\n\n    return result;\n  }\n\n  async getHtmlByUrl(url: string) {\n    const html = await this.request(url, { responseType: 'text' }).text();\n    if (\n      this.configService.get<ConfigurationType['feed']>('feed')!.enableCleanHtml\n    ) {\n      const result = await this.cleanHtml(html);\n      return result;\n    }\n\n    return html;\n  }\n\n  async tryGetContent(id: string) {\n    let content = mpCache.get(id);\n    if (content) {\n      return content;\n    }\n    const url = `https://mp.weixin.qq.com/s/${id}`;\n    content = await this.getHtmlByUrl(url).catch((e) => {\n      this.logger.error(`getHtmlByUrl(${url}) error: ${e.message}`);\n\n      return '获取全文失败，请重试~';\n    });\n    mpCache.set(id, content);\n    return content;\n  }\n\n  async renderFeed({\n    type,\n    feedInfo,\n    articles,\n    mode,\n  }: {\n    type: string;\n    feedInfo: FeedInfo;\n    articles: Article[];\n    mode?: string;\n  }) {\n    const { originUrl, mode: globalMode } =\n      this.configService.get<ConfigurationType['feed']>('feed')!;\n\n    const link = `${originUrl}/feeds/${feedInfo.id}.${type}`;\n\n    const feed = new Feed({\n      title: feedInfo.mpName,\n      description: feedInfo.mpIntro,\n      id: link,\n      link: link,\n      language: 'zh-cn', // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes\n      image: feedInfo.mpCover,\n      favicon: feedInfo.mpCover,\n      copyright: '',\n      updated: new Date(feedInfo.updateTime * 1e3),\n      generator: 'WeWe-RSS',\n      author: { name: feedInfo.mpName },\n    });\n\n    feed.addExtension({\n      name: 'generator',\n      objects: `WeWe-RSS`,\n    });\n\n    const feeds = await this.prismaService.feed.findMany({\n      select: { id: true, mpName: true },\n    });\n\n    /**mode 高于 globalMode。如果 mode 值存在，取 mode 值*/\n    const enableFullText =\n      typeof mode === 'string'\n        ? mode === 'fulltext'\n        : globalMode === 'fulltext';\n\n    const showAuthor = feedInfo.id === 'all';\n\n    const mapper = async (item) => {\n      const { title, id, publishTime, picUrl, mpId } = item;\n      const link = `https://mp.weixin.qq.com/s/${id}`;\n\n      const mpName = feeds.find((item) => item.id === mpId)?.mpName || '-';\n      const published = new Date(publishTime * 1e3);\n\n      let content = '';\n      if (enableFullText) {\n        content = await this.tryGetContent(id);\n      }\n\n      feed.addItem({\n        id,\n        title,\n        link: link,\n        guid: link,\n        content,\n        date: published,\n        image: picUrl,\n        author: showAuthor ? [{ name: mpName }] : undefined,\n      });\n    };\n\n    await pMap(articles, mapper, { concurrency: 2, stopOnError: false });\n\n    return feed;\n  }\n\n  async handleGenerateFeed({\n    id,\n    type,\n    limit,\n    page,\n    mode,\n    title_include,\n    title_exclude,\n  }: {\n    id?: string;\n    type: string;\n    limit: number;\n    page: number;\n    mode?: string;\n    title_include?: string;\n    title_exclude?: string;\n  }) {\n    if (!feedTypes.includes(type as any)) {\n      type = 'atom';\n    }\n\n    let articles: Article[];\n    let feedInfo: FeedInfo;\n    if (id) {\n      feedInfo = (await this.prismaService.feed.findFirst({\n        where: { id },\n      }))!;\n\n      if (!feedInfo) {\n        throw new HttpException('不存在该feed！', HttpStatus.BAD_REQUEST);\n      }\n\n      articles = await this.prismaService.article.findMany({\n        where: { mpId: id },\n        orderBy: { publishTime: 'desc' },\n        take: limit,\n        skip: (page - 1) * limit,\n      });\n    } else {\n      articles = await this.prismaService.article.findMany({\n        orderBy: { publishTime: 'desc' },\n        take: limit,\n        skip: (page - 1) * limit,\n      });\n\n      const { originUrl } =\n        this.configService.get<ConfigurationType['feed']>('feed')!;\n      feedInfo = {\n        id: 'all',\n        mpName: 'WeWe-RSS All',\n        mpIntro: 'WeWe-RSS 全部文章',\n        mpCover: originUrl\n          ? `${originUrl}/favicon.ico`\n          : 'https://r2-assets.111965.xyz/wewe-rss.png',\n        status: 1,\n        syncTime: 0,\n        updateTime: Math.floor(Date.now() / 1e3),\n        hasHistory: -1,\n        createdAt: new Date(),\n        updatedAt: new Date(),\n      };\n    }\n\n    this.logger.log('handleGenerateFeed articles: ' + articles.length);\n    const feed = await this.renderFeed({ feedInfo, articles, type, mode });\n\n    if (title_include) {\n      const includes = title_include.split('|');\n      feed.items = feed.items.filter((i: Item) =>\n        includes.some((k) => i.title.includes(k)),\n      );\n    }\n    if (title_exclude) {\n      const excludes = title_exclude.split('|');\n      feed.items = feed.items.filter(\n        (i: Item) => !excludes.some((k) => i.title.includes(k)),\n      );\n    }\n\n    switch (type) {\n      case 'rss':\n        return { content: feed.rss2(), mimeType: feedMimeTypeMap[type] };\n      case 'json':\n        return { content: feed.json1(), mimeType: feedMimeTypeMap[type] };\n      case 'atom':\n      default:\n        return { content: feed.atom1(), mimeType: feedMimeTypeMap[type] };\n    }\n  }\n\n  async getFeedList() {\n    const data = await this.prismaService.feed.findMany();\n\n    return data.map((item) => {\n      return {\n        id: item.id,\n        name: item.mpName,\n        intro: item.mpIntro,\n        cover: item.mpCover,\n        syncTime: item.syncTime,\n        updateTime: item.updateTime,\n      };\n    });\n  }\n\n  async updateFeed(id: string) {\n    try {\n      await this.trpcService.refreshMpArticlesAndUpdateFeed(id);\n    } catch (err) {\n      this.logger.error('updateFeed error', err);\n    } finally {\n      // wait 30s for next feed\n      await new Promise((resolve) => setTimeout(resolve, 30 * 1e3));\n    }\n  }\n}\n"
  },
  {
    "path": "apps/server/src/main.ts",
    "content": "import { NestFactory } from '@nestjs/core';\nimport { AppModule } from './app.module';\nimport { TrpcRouter } from '@server/trpc/trpc.router';\nimport { ConfigService } from '@nestjs/config';\nimport { json, urlencoded } from 'express';\nimport { NestExpressApplication } from '@nestjs/platform-express';\nimport { ConfigurationType } from './configuration';\nimport { join, resolve } from 'path';\nimport { readFileSync } from 'fs';\n\nconst packageJson = JSON.parse(\n  readFileSync(resolve(__dirname, '..', './package.json'), 'utf-8'),\n);\n\nconst appVersion = packageJson.version;\nconsole.log('appVersion: v' + appVersion);\n\nasync function bootstrap() {\n  const app = await NestFactory.create<NestExpressApplication>(AppModule);\n  const configService = app.get(ConfigService);\n\n  const { host, isProd, port } =\n    configService.get<ConfigurationType['server']>('server')!;\n\n  app.use(json({ limit: '10mb' }));\n  app.use(urlencoded({ extended: true, limit: '10mb' }));\n\n  app.useStaticAssets(join(__dirname, '..', 'client', 'assets'), {\n    prefix: '/dash/assets/',\n  });\n  app.setBaseViewsDir(join(__dirname, '..', 'client'));\n  app.setViewEngine('hbs');\n\n  if (isProd) {\n    app.enable('trust proxy');\n  }\n\n  app.enableCors({\n    exposedHeaders: ['authorization'],\n  });\n\n  const trpc = app.get(TrpcRouter);\n  trpc.applyMiddleware(app);\n\n  await app.listen(port, host);\n\n  console.log(`Server is running at http://${host}:${port}`);\n}\nbootstrap();\n"
  },
  {
    "path": "apps/server/src/prisma/prisma.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { PrismaService } from './prisma.service';\n\n@Module({\n  providers: [PrismaService],\n  exports: [PrismaService],\n})\nexport class PrismaModule {}\n"
  },
  {
    "path": "apps/server/src/prisma/prisma.service.ts",
    "content": "import { Injectable, OnModuleInit } from '@nestjs/common';\nimport { PrismaClient } from '@prisma/client';\n\n@Injectable()\nexport class PrismaService extends PrismaClient implements OnModuleInit {\n  async onModuleInit() {\n    await this.$connect();\n  }\n}\n"
  },
  {
    "path": "apps/server/src/trpc/trpc.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { TrpcService } from '@server/trpc/trpc.service';\nimport { TrpcRouter } from '@server/trpc/trpc.router';\nimport { PrismaModule } from '@server/prisma/prisma.module';\n\n@Module({\n  imports: [PrismaModule],\n  controllers: [],\n  providers: [TrpcService, TrpcRouter],\n  exports: [TrpcService, TrpcRouter],\n})\nexport class TrpcModule {}\n"
  },
  {
    "path": "apps/server/src/trpc/trpc.router.ts",
    "content": "import { INestApplication, Injectable, Logger } from '@nestjs/common';\nimport { z } from 'zod';\nimport { TrpcService } from '@server/trpc/trpc.service';\nimport * as trpcExpress from '@trpc/server/adapters/express';\nimport { TRPCError } from '@trpc/server';\nimport { PrismaService } from '@server/prisma/prisma.service';\nimport { statusMap } from '@server/constants';\nimport { ConfigService } from '@nestjs/config';\nimport { ConfigurationType } from '@server/configuration';\n\n@Injectable()\nexport class TrpcRouter {\n  constructor(\n    private readonly trpcService: TrpcService,\n    private readonly prismaService: PrismaService,\n    private readonly configService: ConfigService,\n  ) {}\n\n  private readonly logger = new Logger(this.constructor.name);\n\n  accountRouter = this.trpcService.router({\n    list: this.trpcService.protectedProcedure\n      .input(\n        z.object({\n          limit: z.number().min(1).max(1000).nullish(),\n          cursor: z.string().nullish(),\n        }),\n      )\n      .query(async ({ input }) => {\n        const limit = input.limit ?? 1000;\n        const { cursor } = input;\n\n        const items = await this.prismaService.account.findMany({\n          take: limit + 1,\n          where: {},\n          select: {\n            id: true,\n            name: true,\n            status: true,\n            createdAt: true,\n            updatedAt: true,\n            token: false,\n          },\n          cursor: cursor\n            ? {\n                id: cursor,\n              }\n            : undefined,\n          orderBy: {\n            createdAt: 'asc',\n          },\n        });\n        let nextCursor: typeof cursor | undefined = undefined;\n        if (items.length > limit) {\n          // Remove the last item and use it as next cursor\n\n          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n          const nextItem = items.pop()!;\n          nextCursor = nextItem.id;\n        }\n\n        const disabledAccounts = this.trpcService.getBlockedAccountIds();\n        return {\n          blocks: disabledAccounts,\n          items,\n          nextCursor,\n        };\n      }),\n    byId: this.trpcService.protectedProcedure\n      .input(z.string())\n      .query(async ({ input: id }) => {\n        const account = await this.prismaService.account.findUnique({\n          where: { id },\n        });\n        if (!account) {\n          throw new TRPCError({\n            code: 'BAD_REQUEST',\n            message: `No account with id '${id}'`,\n          });\n        }\n        return account;\n      }),\n    add: this.trpcService.protectedProcedure\n      .input(\n        z.object({\n          id: z.string().min(1).max(32),\n          token: z.string().min(1),\n          name: z.string().min(1),\n          status: z.number().default(statusMap.ENABLE),\n        }),\n      )\n      .mutation(async ({ input }) => {\n        const { id, ...data } = input;\n        const account = await this.prismaService.account.upsert({\n          where: {\n            id,\n          },\n          update: data,\n          create: input,\n        });\n        this.trpcService.removeBlockedAccount(id);\n\n        return account;\n      }),\n    edit: this.trpcService.protectedProcedure\n      .input(\n        z.object({\n          id: z.string(),\n          data: z.object({\n            token: z.string().min(1).optional(),\n            name: z.string().min(1).optional(),\n            status: z.number().optional(),\n          }),\n        }),\n      )\n      .mutation(async ({ input }) => {\n        const { id, data } = input;\n        const account = await this.prismaService.account.update({\n          where: { id },\n          data,\n        });\n        this.trpcService.removeBlockedAccount(id);\n        return account;\n      }),\n    delete: this.trpcService.protectedProcedure\n      .input(z.string())\n      .mutation(async ({ input: id }) => {\n        await this.prismaService.account.delete({ where: { id } });\n        this.trpcService.removeBlockedAccount(id);\n\n        return id;\n      }),\n  });\n\n  feedRouter = this.trpcService.router({\n    list: this.trpcService.protectedProcedure\n      .input(\n        z.object({\n          limit: z.number().min(1).max(1000).nullish(),\n          cursor: z.string().nullish(),\n        }),\n      )\n      .query(async ({ input }) => {\n        const limit = input.limit ?? 1000;\n        const { cursor } = input;\n\n        const items = await this.prismaService.feed.findMany({\n          take: limit + 1,\n          where: {},\n          cursor: cursor\n            ? {\n                id: cursor,\n              }\n            : undefined,\n          orderBy: {\n            createdAt: 'asc',\n          },\n        });\n        let nextCursor: typeof cursor | undefined = undefined;\n        if (items.length > limit) {\n          // Remove the last item and use it as next cursor\n\n          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n          const nextItem = items.pop()!;\n          nextCursor = nextItem.id;\n        }\n\n        return {\n          items: items,\n          nextCursor,\n        };\n      }),\n    byId: this.trpcService.protectedProcedure\n      .input(z.string())\n      .query(async ({ input: id }) => {\n        const feed = await this.prismaService.feed.findUnique({\n          where: { id },\n        });\n        if (!feed) {\n          throw new TRPCError({\n            code: 'BAD_REQUEST',\n            message: `No feed with id '${id}'`,\n          });\n        }\n        return feed;\n      }),\n    add: this.trpcService.protectedProcedure\n      .input(\n        z.object({\n          id: z.string(),\n          mpName: z.string(),\n          mpCover: z.string(),\n          mpIntro: z.string(),\n          syncTime: z\n            .number()\n            .optional()\n            .default(Math.floor(Date.now() / 1e3)),\n          updateTime: z.number(),\n          status: z.number().default(statusMap.ENABLE),\n        }),\n      )\n      .mutation(async ({ input }) => {\n        const { id, ...data } = input;\n        const feed = await this.prismaService.feed.upsert({\n          where: {\n            id,\n          },\n          update: data,\n          create: input,\n        });\n\n        return feed;\n      }),\n    edit: this.trpcService.protectedProcedure\n      .input(\n        z.object({\n          id: z.string(),\n          data: z.object({\n            mpName: z.string().optional(),\n            mpCover: z.string().optional(),\n            mpIntro: z.string().optional(),\n            syncTime: z.number().optional(),\n            updateTime: z.number().optional(),\n            status: z.number().optional(),\n          }),\n        }),\n      )\n      .mutation(async ({ input }) => {\n        const { id, data } = input;\n        const feed = await this.prismaService.feed.update({\n          where: { id },\n          data,\n        });\n        return feed;\n      }),\n    delete: this.trpcService.protectedProcedure\n      .input(z.string())\n      .mutation(async ({ input: id }) => {\n        await this.prismaService.feed.delete({ where: { id } });\n        return id;\n      }),\n\n    refreshArticles: this.trpcService.protectedProcedure\n      .input(\n        z.object({\n          mpId: z.string().optional(),\n        }),\n      )\n      .mutation(async ({ input: { mpId } }) => {\n        if (mpId) {\n          await this.trpcService.refreshMpArticlesAndUpdateFeed(mpId);\n        } else {\n          await this.trpcService.refreshAllMpArticlesAndUpdateFeed();\n        }\n      }),\n\n    isRefreshAllMpArticlesRunning: this.trpcService.protectedProcedure.query(\n      async () => {\n        return this.trpcService.isRefreshAllMpArticlesRunning;\n      },\n    ),\n    getHistoryArticles: this.trpcService.protectedProcedure\n      .input(\n        z.object({\n          mpId: z.string().optional(),\n        }),\n      )\n      .mutation(async ({ input: { mpId = '' } }) => {\n        this.trpcService.getHistoryMpArticles(mpId);\n      }),\n    getInProgressHistoryMp: this.trpcService.protectedProcedure.query(\n      async () => {\n        return this.trpcService.inProgressHistoryMp;\n      },\n    ),\n  });\n\n  articleRouter = this.trpcService.router({\n    list: this.trpcService.protectedProcedure\n      .input(\n        z.object({\n          limit: z.number().min(1).max(1000).nullish(),\n          cursor: z.string().nullish(),\n          mpId: z.string().nullish(),\n        }),\n      )\n      .query(async ({ input }) => {\n        const limit = input.limit ?? 1000;\n        const { cursor, mpId } = input;\n\n        const items = await this.prismaService.article.findMany({\n          orderBy: [\n            {\n              publishTime: 'desc',\n            },\n          ],\n          take: limit + 1,\n          where: mpId ? { mpId } : undefined,\n          cursor: cursor\n            ? {\n                id: cursor,\n              }\n            : undefined,\n        });\n        let nextCursor: typeof cursor | undefined = undefined;\n        if (items.length > limit) {\n          // Remove the last item and use it as next cursor\n\n          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n          const nextItem = items.pop()!;\n          nextCursor = nextItem.id;\n        }\n\n        return {\n          items,\n          nextCursor,\n        };\n      }),\n    byId: this.trpcService.protectedProcedure\n      .input(z.string())\n      .query(async ({ input: id }) => {\n        const article = await this.prismaService.article.findUnique({\n          where: { id },\n        });\n        if (!article) {\n          throw new TRPCError({\n            code: 'BAD_REQUEST',\n            message: `No article with id '${id}'`,\n          });\n        }\n        return article;\n      }),\n\n    add: this.trpcService.protectedProcedure\n      .input(\n        z.object({\n          id: z.string(),\n          mpId: z.string(),\n          title: z.string(),\n          picUrl: z.string().optional().default(''),\n          publishTime: z.number(),\n        }),\n      )\n      .mutation(async ({ input }) => {\n        const { id, ...data } = input;\n        const article = await this.prismaService.article.upsert({\n          where: {\n            id,\n          },\n          update: data,\n          create: input,\n        });\n\n        return article;\n      }),\n    delete: this.trpcService.protectedProcedure\n      .input(z.string())\n      .mutation(async ({ input: id }) => {\n        await this.prismaService.article.delete({ where: { id } });\n        return id;\n      }),\n  });\n\n  platformRouter = this.trpcService.router({\n    getMpArticles: this.trpcService.protectedProcedure\n      .input(\n        z.object({\n          mpId: z.string(),\n        }),\n      )\n      .mutation(async ({ input: { mpId } }) => {\n        try {\n          const results = await this.trpcService.getMpArticles(mpId);\n          return results;\n        } catch (err: any) {\n          this.logger.log('getMpArticles err: ', err);\n          throw new TRPCError({\n            code: 'INTERNAL_SERVER_ERROR',\n            message: err.response?.data?.message || err.message,\n            cause: err.stack,\n          });\n        }\n      }),\n    getMpInfo: this.trpcService.protectedProcedure\n      .input(\n        z.object({\n          wxsLink: z\n            .string()\n            .refine((v) => v.startsWith('https://mp.weixin.qq.com/s/')),\n        }),\n      )\n      .mutation(async ({ input: { wxsLink: url } }) => {\n        try {\n          const results = await this.trpcService.getMpInfo(url);\n          return results;\n        } catch (err: any) {\n          this.logger.log('getMpInfo err: ', err);\n          throw new TRPCError({\n            code: 'INTERNAL_SERVER_ERROR',\n            message: err.response?.data?.message || err.message,\n            cause: err.stack,\n          });\n        }\n      }),\n\n    createLoginUrl: this.trpcService.protectedProcedure.mutation(async () => {\n      return this.trpcService.createLoginUrl();\n    }),\n    getLoginResult: this.trpcService.protectedProcedure\n      .input(\n        z.object({\n          id: z.string(),\n        }),\n      )\n      .query(async ({ input }) => {\n        return this.trpcService.getLoginResult(input.id);\n      }),\n  });\n\n  appRouter = this.trpcService.router({\n    feed: this.feedRouter,\n    account: this.accountRouter,\n    article: this.articleRouter,\n    platform: this.platformRouter,\n  });\n\n  async applyMiddleware(app: INestApplication) {\n    app.use(\n      `/trpc`,\n      trpcExpress.createExpressMiddleware({\n        router: this.appRouter,\n        createContext: ({ req }) => {\n          const authCode =\n            this.configService.get<ConfigurationType['auth']>('auth')!.code;\n\n          if (authCode && req.headers.authorization !== authCode) {\n            return {\n              errorMsg: 'authCode不正确！',\n            };\n          }\n          return {\n            errorMsg: null,\n          };\n        },\n        middleware: (req, res, next) => {\n          next();\n        },\n      }),\n    );\n  }\n}\n\nexport type AppRouter = TrpcRouter[`appRouter`];\n"
  },
  {
    "path": "apps/server/src/trpc/trpc.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport { ConfigurationType } from '@server/configuration';\nimport { defaultCount, statusMap } from '@server/constants';\nimport { PrismaService } from '@server/prisma/prisma.service';\nimport { TRPCError, initTRPC } from '@trpc/server';\nimport Axios, { AxiosInstance } from 'axios';\nimport dayjs from 'dayjs';\nimport timezone from 'dayjs/plugin/timezone';\nimport utc from 'dayjs/plugin/utc';\n\ndayjs.extend(utc);\ndayjs.extend(timezone);\n\n/**\n * 读书账号每日小黑屋\n */\nconst blockedAccountsMap = new Map<string, string[]>();\n\n@Injectable()\nexport class TrpcService {\n  trpc = initTRPC.create();\n  publicProcedure = this.trpc.procedure;\n  protectedProcedure = this.trpc.procedure.use(({ ctx, next }) => {\n    const errorMsg = (ctx as any).errorMsg;\n    if (errorMsg) {\n      throw new TRPCError({ code: 'UNAUTHORIZED', message: errorMsg });\n    }\n    return next({ ctx });\n  });\n  router = this.trpc.router;\n  mergeRouters = this.trpc.mergeRouters;\n  request: AxiosInstance;\n  updateDelayTime = 60;\n\n  private readonly logger = new Logger(this.constructor.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly configService: ConfigService,\n  ) {\n    const { url } =\n      this.configService.get<ConfigurationType['platform']>('platform')!;\n    this.updateDelayTime =\n      this.configService.get<ConfigurationType['feed']>(\n        'feed',\n      )!.updateDelayTime;\n\n    this.request = Axios.create({ baseURL: url, timeout: 15 * 1e3 });\n\n    this.request.interceptors.response.use(\n      (response) => {\n        return response;\n      },\n      async (error) => {\n        this.logger.log('error: ', error);\n        const errMsg = error.response?.data?.message || '';\n\n        const id = (error.config.headers as any).xid;\n        if (errMsg.includes('WeReadError401')) {\n          // 账号失效\n          await this.prismaService.account.update({\n            where: { id },\n            data: { status: statusMap.INVALID },\n          });\n          this.logger.error(`账号（${id}）登录失效，已禁用`);\n        } else if (errMsg.includes('WeReadError429')) {\n          //TODO 处理请求频繁\n          this.logger.error(`账号（${id}）请求频繁，打入小黑屋`);\n        }\n\n        const today = this.getTodayDate();\n\n        const blockedAccounts = blockedAccountsMap.get(today);\n\n        if (Array.isArray(blockedAccounts)) {\n          if (id) {\n            blockedAccounts.push(id);\n          }\n          blockedAccountsMap.set(today, blockedAccounts);\n        } else if (errMsg.includes('WeReadError400')) {\n          this.logger.error(`账号（${id}）处理请求参数出错`);\n          this.logger.error('WeReadError400: ', errMsg);\n          // 10s 后重试\n          await new Promise((resolve) => setTimeout(resolve, 10 * 1e3));\n        } else {\n          this.logger.error(\"Can't handle this error: \", errMsg);\n        }\n\n        return Promise.reject(error);\n      },\n    );\n  }\n\n  removeBlockedAccount = (vid: string) => {\n    const today = this.getTodayDate();\n\n    const blockedAccounts = blockedAccountsMap.get(today);\n    if (Array.isArray(blockedAccounts)) {\n      const newBlockedAccounts = blockedAccounts.filter((id) => id !== vid);\n      blockedAccountsMap.set(today, newBlockedAccounts);\n    }\n  };\n\n  private getTodayDate() {\n    return dayjs.tz(new Date(), 'Asia/Shanghai').format('YYYY-MM-DD');\n  }\n\n  getBlockedAccountIds() {\n    const today = this.getTodayDate();\n    const disabledAccounts = blockedAccountsMap.get(today) || [];\n    this.logger.debug('disabledAccounts: ', disabledAccounts);\n    return disabledAccounts.filter(Boolean);\n  }\n\n  private async getAvailableAccount() {\n    const disabledAccounts = this.getBlockedAccountIds();\n    const account = await this.prismaService.account.findMany({\n      where: {\n        status: statusMap.ENABLE,\n        NOT: {\n          id: { in: disabledAccounts },\n        },\n      },\n      take: 10,\n    });\n\n    if (!account || account.length === 0) {\n      throw new Error('暂无可用读书账号!');\n    }\n\n    return account[Math.floor(Math.random() * account.length)];\n  }\n\n  async getMpArticles(mpId: string, page = 1, retryCount = 3) {\n    const account = await this.getAvailableAccount();\n\n    try {\n      const res = await this.request\n        .get<\n          {\n            id: string;\n            title: string;\n            picUrl: string;\n            publishTime: number;\n          }[]\n        >(`/api/v2/platform/mps/${mpId}/articles`, {\n          headers: {\n            xid: account.id,\n            Authorization: `Bearer ${account.token}`,\n          },\n          params: {\n            page,\n          },\n        })\n        .then((res) => res.data)\n        .then((res) => {\n          this.logger.log(\n            `getMpArticles(${mpId}) page: ${page} articles: ${res.length}`,\n          );\n          return res;\n        });\n      return res;\n    } catch (err) {\n      this.logger.error(`retry(${4 - retryCount}) getMpArticles  error: `, err);\n      if (retryCount > 0) {\n        return this.getMpArticles(mpId, page, retryCount - 1);\n      } else {\n        throw err;\n      }\n    }\n  }\n\n  async refreshMpArticlesAndUpdateFeed(mpId: string, page = 1) {\n    const articles = await this.getMpArticles(mpId, page);\n\n    if (articles.length > 0) {\n      let results;\n      const { type } =\n        this.configService.get<ConfigurationType['database']>('database')!;\n      if (type === 'sqlite') {\n        // sqlite3 不支持 createMany\n        const inserts = articles.map(({ id, picUrl, publishTime, title }) =>\n          this.prismaService.article.upsert({\n            create: { id, mpId, picUrl, publishTime, title },\n            update: {\n              publishTime,\n              title,\n            },\n            where: { id },\n          }),\n        );\n        results = await this.prismaService.$transaction(inserts);\n      } else {\n        results = await (this.prismaService.article as any).createMany({\n          data: articles.map(({ id, picUrl, publishTime, title }) => ({\n            id,\n            mpId,\n            picUrl,\n            publishTime,\n            title,\n          })),\n          skipDuplicates: true,\n        });\n      }\n\n      this.logger.debug(\n        `refreshMpArticlesAndUpdateFeed create results: ${JSON.stringify(results)}`,\n      );\n    }\n\n    // 如果文章数量小于 defaultCount，则认为没有更多历史文章\n    const hasHistory = articles.length < defaultCount ? 0 : 1;\n\n    await this.prismaService.feed.update({\n      where: { id: mpId },\n      data: {\n        syncTime: Math.floor(Date.now() / 1e3),\n        hasHistory,\n      },\n    });\n\n    return { hasHistory };\n  }\n\n  inProgressHistoryMp = {\n    id: '',\n    page: 1,\n  };\n\n  async getHistoryMpArticles(mpId: string) {\n    if (this.inProgressHistoryMp.id === mpId) {\n      this.logger.log(`getHistoryMpArticles(${mpId}) is running`);\n      return;\n    }\n\n    this.inProgressHistoryMp = {\n      id: mpId,\n      page: 1,\n    };\n\n    if (!this.inProgressHistoryMp.id) {\n      return;\n    }\n\n    try {\n      const feed = await this.prismaService.feed.findFirstOrThrow({\n        where: {\n          id: mpId,\n        },\n      });\n\n      // 如果完整同步过历史文章，则直接返回\n      if (feed.hasHistory === 0) {\n        this.logger.log(`getHistoryMpArticles(${mpId}) has no history`);\n        return;\n      }\n\n      const total = await this.prismaService.article.count({\n        where: {\n          mpId,\n        },\n      });\n      this.inProgressHistoryMp.page = Math.ceil(total / defaultCount);\n\n      // 最多尝试一千次\n      let i = 1e3;\n      while (i-- > 0) {\n        if (this.inProgressHistoryMp.id !== mpId) {\n          this.logger.log(\n            `getHistoryMpArticles(${mpId}) is not running, break`,\n          );\n          break;\n        }\n        const { hasHistory } = await this.refreshMpArticlesAndUpdateFeed(\n          mpId,\n          this.inProgressHistoryMp.page,\n        );\n        if (hasHistory < 1) {\n          this.logger.log(\n            `getHistoryMpArticles(${mpId}) has no history, break`,\n          );\n          break;\n        }\n        this.inProgressHistoryMp.page++;\n\n        await new Promise((resolve) =>\n          setTimeout(resolve, this.updateDelayTime * 1e3),\n        );\n      }\n    } finally {\n      this.inProgressHistoryMp = {\n        id: '',\n        page: 1,\n      };\n    }\n  }\n\n  isRefreshAllMpArticlesRunning = false;\n\n  async refreshAllMpArticlesAndUpdateFeed() {\n    if (this.isRefreshAllMpArticlesRunning) {\n      this.logger.log('refreshAllMpArticlesAndUpdateFeed is running');\n      return;\n    }\n    const mps = await this.prismaService.feed.findMany();\n    this.isRefreshAllMpArticlesRunning = true;\n    try {\n      for (const { id } of mps) {\n        await this.refreshMpArticlesAndUpdateFeed(id);\n\n        await new Promise((resolve) =>\n          setTimeout(resolve, this.updateDelayTime * 1e3),\n        );\n      }\n    } finally {\n      this.isRefreshAllMpArticlesRunning = false;\n    }\n  }\n\n  async getMpInfo(url: string) {\n    url = url.trim();\n    const account = await this.getAvailableAccount();\n\n    return this.request\n      .post<\n        {\n          id: string;\n          cover: string;\n          name: string;\n          intro: string;\n          updateTime: number;\n        }[]\n      >(\n        `/api/v2/platform/wxs2mp`,\n        { url },\n        {\n          headers: {\n            xid: account.id,\n            Authorization: `Bearer ${account.token}`,\n          },\n        },\n      )\n      .then((res) => res.data);\n  }\n\n  async createLoginUrl() {\n    return this.request\n      .get<{\n        uuid: string;\n        scanUrl: string;\n      }>(`/api/v2/login/platform`)\n      .then((res) => res.data);\n  }\n\n  async getLoginResult(id: string) {\n    return this.request\n      .get<{\n        message: string;\n        vid?: number;\n        token?: string;\n        username?: string;\n      }>(`/api/v2/login/platform/${id}`, { timeout: 120 * 1e3 })\n      .then((res) => res.data);\n  }\n}\n"
  },
  {
    "path": "apps/server/test/app.e2e-spec.ts",
    "content": "import { Test, TestingModule } from '@nestjs/testing';\nimport { INestApplication } from '@nestjs/common';\nimport * as request from 'supertest';\nimport { AppModule } from './../src/app.module';\n\ndescribe('AppController (e2e)', () => {\n  let app: INestApplication;\n\n  beforeEach(async () => {\n    const moduleFixture: TestingModule = await Test.createTestingModule({\n      imports: [AppModule],\n    }).compile();\n\n    app = moduleFixture.createNestApplication();\n    await app.init();\n  });\n\n  it('/ (GET)', () => {\n    return request(app.getHttpServer())\n      .get('/')\n      .expect(200)\n      .expect('Hello World!');\n  });\n});\n"
  },
  {
    "path": "apps/server/test/jest-e2e.json",
    "content": "{\n  \"moduleFileExtensions\": [\"js\", \"json\", \"ts\"],\n  \"rootDir\": \".\",\n  \"testEnvironment\": \"node\",\n  \"testRegex\": \".e2e-spec.ts$\",\n  \"transform\": {\n    \"^.+\\\\.(t|j)s$\": \"ts-jest\"\n  }\n}\n"
  },
  {
    "path": "apps/server/tsconfig.build.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"test\", \"dist\", \"**/*spec.ts\"]\n}\n"
  },
  {
    "path": "apps/server/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"module\": \"commonjs\",\n    \"declaration\": true,\n    \"removeComments\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"target\": \"ES2021\",\n    \"sourceMap\": true,\n    \"outDir\": \"./dist\",\n    \"esModuleInterop\":true\n  }\n}"
  },
  {
    "path": "apps/web/.eslintrc.cjs",
    "content": "module.exports = {\n  root: true,\n  env: { browser: true, es2020: true },\n  extends: [\n    'eslint:recommended',\n    'plugin:@typescript-eslint/recommended',\n    'plugin:react-hooks/recommended',\n  ],\n  ignorePatterns: ['dist', '.eslintrc.cjs'],\n  parser: '@typescript-eslint/parser',\n  plugins: ['react-refresh'],\n  rules: {\n    'react-refresh/only-export-components': [\n      'warn',\n      { allowConstantExport: true },\n    ],\n    '@typescript-eslint/no-explicit-any': 'warn',\n  },\n};\n"
  },
  {
    "path": "apps/web/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "apps/web/README.md",
    "content": "# React + TypeScript + Vite\n\nThis template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.\n\nCurrently, two official plugins are available:\n\n- [@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\n- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh\n\n## Expanding the ESLint configuration\n\nIf you are developing a production application, we recommend updating the configuration to enable type aware lint rules:\n\n- Configure the top-level `parserOptions` property like this:\n\n```js\nexport default {\n  // other rules...\n  parserOptions: {\n    ecmaVersion: 'latest',\n    sourceType: 'module',\n    project: ['./tsconfig.json', './tsconfig.node.json'],\n    tsconfigRootDir: __dirname,\n  },\n};\n```\n\n- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`\n- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`\n- 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\n"
  },
  {
    "path": "apps/web/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" href=\"{{ iconUrl }}\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>WeWe RSS</title>\n    <meta name=\"description\" content=\"更好的公众号订阅方式\" />\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script>\n      window.__WEWE_RSS_SERVER_ORIGIN_URL__ = '{{ weweRssServerOriginUrl }}';\n      window.__WEWE_RSS_ENABLED_AUTH_CODE__ = '{{ enabledAuthCode }}';\n    </script>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "apps/web/package.json",
    "content": "{\n  \"name\": \"web\",\n  \"private\": true,\n  \"version\": \"2.6.1\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"tsc && vite build\",\n    \"lint\": \"eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0\",\n    \"preview\": \"vite preview\"\n  },\n  \"dependencies\": {\n    \"@nextui-org/react\": \"^2.2.9\",\n    \"@tanstack/react-query\": \"^4.35.3\",\n    \"@trpc/client\": \"^10.45.1\",\n    \"@trpc/next\": \"^10.45.1\",\n    \"@trpc/react-query\": \"^10.45.1\",\n    \"autoprefixer\": \"^10.0.1\",\n    \"dayjs\": \"^1.11.10\",\n    \"framer-motion\": \"^11.0.5\",\n    \"next-themes\": \"^0.2.1\",\n    \"postcss\": \"^8\",\n    \"qrcode.react\": \"^3.1.0\",\n    \"react\": \"^18.2.0\",\n    \"react-dom\": \"^18.2.0\",\n    \"react-router-dom\": \"^6.22.2\",\n    \"sonner\": \"^1.4.0\",\n    \"tailwindcss\": \"^3.3.0\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"^20.11.24\",\n    \"@types/react\": \"^18.2.56\",\n    \"@types/react-dom\": \"^18.2.19\",\n    \"@typescript-eslint/eslint-plugin\": \"^7.0.2\",\n    \"@typescript-eslint/parser\": \"^7.0.2\",\n    \"@vitejs/plugin-react\": \"^4.2.1\",\n    \"eslint\": \"^8.56.0\",\n    \"eslint-plugin-react-hooks\": \"^4.6.0\",\n    \"eslint-plugin-react-refresh\": \"^0.4.5\",\n    \"typescript\": \"^5.2.2\",\n    \"vite\": \"^5.1.4\"\n  }\n}"
  },
  {
    "path": "apps/web/postcss.config.js",
    "content": "export default {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n};\n"
  },
  {
    "path": "apps/web/src/App.tsx",
    "content": "import { BrowserRouter, Route, Routes } from 'react-router-dom';\nimport Feeds from './pages/feeds';\nimport Login from './pages/login';\nimport Accounts from './pages/accounts';\nimport { BaseLayout } from './layouts/base';\nimport { TrpcProvider } from './provider/trpc';\nimport ThemeProvider from './provider/theme';\n\nfunction App() {\n  return (\n    <BrowserRouter basename=\"/dash\">\n      <ThemeProvider>\n        <TrpcProvider>\n          <Routes>\n            <Route path=\"/\" element={<BaseLayout />}>\n              <Route index element={<Feeds />} />\n              <Route path=\"/feeds/:id?\" element={<Feeds />} />\n              <Route path=\"/accounts\" element={<Accounts />} />\n              <Route path=\"/login\" element={<Login />} />\n            </Route>\n          </Routes>\n        </TrpcProvider>\n      </ThemeProvider>\n    </BrowserRouter>\n  );\n}\n\nexport default App;\n"
  },
  {
    "path": "apps/web/src/components/GitHubIcon.tsx",
    "content": "import { IconSvgProps } from '../types';\n\nexport const GitHubIcon = ({\n  size = 24,\n  width,\n  height,\n  ...props\n}: IconSvgProps) => (\n  <svg\n    aria-hidden=\"true\"\n    fill=\"none\"\n    focusable=\"false\"\n    height={size || height}\n    role=\"presentation\"\n    viewBox=\"0 0 24 24\"\n    width={size || width}\n    {...props}\n  >\n    <path\n      clipRule=\"evenodd\"\n      d=\"M12.026 2c-5.509 0-9.974 4.465-9.974 9.974 0 4.406 2.857 8.145 6.821 9.465.499.09.679-.217.679-.481 0-.237-.008-.865-.011-1.696-2.775.602-3.361-1.338-3.361-1.338-.452-1.152-1.107-1.459-1.107-1.459-.905-.619.069-.605.069-.605 1.002.07 1.527 1.028 1.527 1.028.89 1.524 2.336 1.084 2.902.829.091-.645.351-1.085.635-1.334-2.214-.251-4.542-1.107-4.542-4.93 0-1.087.389-1.979 1.024-2.675-.101-.253-.446-1.268.099-2.64 0 0 .837-.269 2.742 1.021a9.582 9.582 0 0 1 2.496-.336 9.554 9.554 0 0 1 2.496.336c1.906-1.291 2.742-1.021 2.742-1.021.545 1.372.203 2.387.099 2.64.64.696 1.024 1.587 1.024 2.675 0 3.833-2.33 4.675-4.552 4.922.355.308.675.916.675 1.846 0 1.334-.012 2.41-.012 2.737 0 .267.178.577.687.479C19.146 20.115 22 16.379 22 11.974 22 6.465 17.535 2 12.026 2z\"\n      fill=\"currentColor\"\n      fillRule=\"evenodd\"\n    ></path>\n  </svg>\n);\n"
  },
  {
    "path": "apps/web/src/components/Nav.tsx",
    "content": "import {\n  Badge,\n  Image,\n  Link,\n  Navbar,\n  NavbarBrand,\n  NavbarContent,\n  NavbarItem,\n  Tooltip,\n} from '@nextui-org/react';\nimport { ThemeSwitcher } from './ThemeSwitcher';\nimport { GitHubIcon } from './GitHubIcon';\nimport { useLocation } from 'react-router-dom';\nimport { appVersion, serverOriginUrl } from '@web/utils/env';\nimport { useEffect, useState } from 'react';\n\nconst navbarItemLink = [\n  {\n    href: '/feeds',\n    name: '公众号源',\n  },\n  {\n    href: '/accounts',\n    name: '账号管理',\n  },\n  // {\n  //   href: '/settings',\n  //   name: '设置',\n  // },\n];\n\nconst Nav = () => {\n  const { pathname } = useLocation();\n  const [releaseVersion, setReleaseVersion] = useState(appVersion);\n\n  useEffect(() => {\n    fetch('https://api.github.com/repos/cooderl/wewe-rss/releases/latest')\n      .then((res) => res.json())\n      .then((data) => {\n        setReleaseVersion(data.name.replace('v', ''));\n      });\n  }, []);\n\n  const isFoundNewVersion = releaseVersion > appVersion;\n  console.log('isFoundNewVersion: ', isFoundNewVersion);\n\n  return (\n    <div>\n      <Navbar isBordered>\n        <Tooltip\n          content={\n            <div className=\"p-1\">\n              {isFoundNewVersion && (\n                <Link\n                  href={`https://github.com/cooderl/wewe-rss/releases/latest`}\n                  target=\"_blank\"\n                  className=\"mb-1 block text-medium\"\n                >\n                  发现新版本：v{releaseVersion}\n                </Link>\n              )}\n              当前版本: v{appVersion}\n            </div>\n          }\n          placement=\"left\"\n        >\n          <NavbarBrand className=\"cursor-default\">\n            <Badge\n              content={isFoundNewVersion ? '' : null}\n              color=\"danger\"\n              size=\"sm\"\n            >\n              <Image\n                width={28}\n                alt=\"WeWe RSS\"\n                className=\"mr-2\"\n                src={\n                  serverOriginUrl\n                    ? `${serverOriginUrl}/favicon.ico`\n                    : 'https://r2-assets.111965.xyz/wewe-rss.png'\n                }\n              ></Image>\n            </Badge>\n            <p className=\"font-bold text-inherit\">WeWe RSS</p>\n          </NavbarBrand>\n        </Tooltip>\n        <NavbarContent className=\"hidden sm:flex gap-4\" justify=\"center\">\n          {navbarItemLink.map((item) => {\n            return (\n              <NavbarItem\n                isActive={pathname.startsWith(item.href)}\n                key={item.href}\n              >\n                <Link color=\"foreground\" href={item.href}>\n                  {item.name}\n                </Link>\n              </NavbarItem>\n            );\n          })}\n        </NavbarContent>\n        <NavbarContent justify=\"end\">\n          <ThemeSwitcher></ThemeSwitcher>\n          <Link\n            href=\"https://github.com/cooderl/wewe-rss\"\n            target=\"_blank\"\n            color=\"foreground\"\n          >\n            <GitHubIcon />\n          </Link>\n        </NavbarContent>\n      </Navbar>\n    </div>\n  );\n};\n\nexport default Nav;\n"
  },
  {
    "path": "apps/web/src/components/PlusIcon.tsx",
    "content": "import { IconSvgProps } from '../types';\n\nexport const PlusIcon = ({\n  size = 24,\n  width,\n  height,\n  ...props\n}: IconSvgProps) => (\n  <svg\n    aria-hidden=\"true\"\n    fill=\"none\"\n    focusable=\"false\"\n    height={size || height}\n    role=\"presentation\"\n    viewBox=\"0 0 24 24\"\n    width={size || width}\n    {...props}\n  >\n    <g\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={1.5}\n    >\n      <path d=\"M6 12h12\" />\n      <path d=\"M12 18V6\" />\n    </g>\n  </svg>\n);\n"
  },
  {
    "path": "apps/web/src/components/StatusDropdown.tsx",
    "content": "import React from 'react';\nimport {\n  Dropdown,\n  DropdownTrigger,\n  DropdownMenu,\n  DropdownItem,\n  Button,\n} from '@nextui-org/react';\nimport { statusMap } from '@web/constants';\n\nexport function StatusDropdown({\n  value = 1,\n  onChange,\n}: {\n  value: number;\n  onChange: (value: number) => void;\n}) {\n  return (\n    <Dropdown>\n      <DropdownTrigger>\n        <Button size=\"sm\" variant=\"bordered\" className=\"capitalize\">\n          {statusMap[value].label}\n        </Button>\n      </DropdownTrigger>\n      <DropdownMenu\n        disabledKeys={['0']}\n        aria-label=\"状态设置\"\n        variant=\"flat\"\n        disallowEmptySelection\n        selectionMode=\"single\"\n        selectedKeys={[`${value}`]}\n        onSelectionChange={(keys) => {\n          onChange(+Array.from(keys)[0]);\n        }}\n      >\n        {Object.entries(statusMap).map(([key, value]) => {\n          return (\n            <DropdownItem color={value.color} key={`${key}`} value={`${key}`}>\n              {value.label}\n            </DropdownItem>\n          );\n        })}\n      </DropdownMenu>\n    </Dropdown>\n  );\n}\n"
  },
  {
    "path": "apps/web/src/components/ThemeSwitcher.tsx",
    "content": "'use client';\n\nimport { VisuallyHidden, useSwitch } from '@nextui-org/react';\nimport { useTheme } from 'next-themes';\n\nexport const MoonIcon = (props) => (\n  <svg\n    aria-hidden=\"true\"\n    focusable=\"false\"\n    height=\"1em\"\n    role=\"presentation\"\n    viewBox=\"0 0 24 24\"\n    width=\"1em\"\n    {...props}\n  >\n    <path\n      d=\"M21.53 15.93c-.16-.27-.61-.69-1.73-.49a8.46 8.46 0 01-1.88.13 8.409 8.409 0 01-5.91-2.82 8.068 8.068 0 01-1.44-8.66c.44-1.01.13-1.54-.09-1.76s-.77-.55-1.83-.11a10.318 10.318 0 00-6.32 10.21 10.475 10.475 0 007.04 8.99 10 10 0 002.89.55c.16.01.32.02.48.02a10.5 10.5 0 008.47-4.27c.67-.93.49-1.519.32-1.79z\"\n      fill=\"currentColor\"\n    />\n  </svg>\n);\n\nexport const SunIcon = (props) => (\n  <svg\n    aria-hidden=\"true\"\n    focusable=\"false\"\n    height=\"1em\"\n    role=\"presentation\"\n    viewBox=\"0 0 24 24\"\n    width=\"1em\"\n    {...props}\n  >\n    <g fill=\"currentColor\">\n      <path d=\"M19 12a7 7 0 11-7-7 7 7 0 017 7z\" />\n      <path d=\"M12 22.96a.969.969 0 01-1-.96v-.08a1 1 0 012 0 1.038 1.038 0 01-1 1.04zm7.14-2.82a1.024 1.024 0 01-.71-.29l-.13-.13a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.984.984 0 01-.7.29zm-14.28 0a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a1 1 0 01-.7.29zM22 13h-.08a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zM2.08 13H2a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zm16.93-7.01a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a.984.984 0 01-.7.29zm-14.02 0a1.024 1.024 0 01-.71-.29l-.13-.14a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.97.97 0 01-.7.3zM12 3.04a.969.969 0 01-1-.96V2a1 1 0 012 0 1.038 1.038 0 01-1 1.04z\" />\n    </g>\n  </svg>\n);\n\nexport function ThemeSwitcher(props) {\n  const { setTheme, theme } = useTheme();\n  const {\n    Component,\n    slots,\n    isSelected,\n    getBaseProps,\n    getInputProps,\n    getWrapperProps,\n  } = useSwitch({\n    onClick: () => setTheme(theme === 'dark' ? 'light' : 'dark'),\n    isSelected: theme === 'dark',\n  });\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <Component {...getBaseProps()}>\n        <VisuallyHidden>\n          <input {...getInputProps()} />\n        </VisuallyHidden>\n        <div\n          {...getWrapperProps()}\n          className={slots.wrapper({\n            class: [\n              'w-8 h-8',\n              'flex items-center justify-center',\n              'rounded-lg bg-default-100 hover:bg-default-200',\n            ],\n          })}\n        >\n          {isSelected ? <SunIcon /> : <MoonIcon />}\n        </div>\n      </Component>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/src/constants.ts",
    "content": "export const statusMap = {\n  0: { label: '失效', color: 'danger' },\n  1: { label: '启用', color: 'success' },\n  2: { label: '禁用', color: 'warning' },\n} as const;\n"
  },
  {
    "path": "apps/web/src/index.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n"
  },
  {
    "path": "apps/web/src/layouts/base.tsx",
    "content": "import { Toaster } from 'sonner';\nimport { Outlet } from 'react-router-dom';\n\nimport Nav from '../components/Nav';\n\nexport function BaseLayout() {\n  return (\n    <div>\n      <main className=\"h-screen overflow-hidden\">\n        <Nav></Nav>\n        <div className=\"h-[calc(100vh-64px)] max-w-[1280px] mx-auto pb-6\">\n          <Outlet />\n        </div>\n      </main>\n      <Toaster richColors position=\"top-right\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/src/main.tsx",
    "content": "import ReactDOM from 'react-dom/client';\nimport App from './App.tsx';\nimport './index.css';\n\nReactDOM.createRoot(document.getElementById('root')!).render(<App />);\n"
  },
  {
    "path": "apps/web/src/pages/accounts/index.tsx",
    "content": "import {\n  Modal,\n  ModalContent,\n  ModalHeader,\n  ModalBody,\n  Button,\n  useDisclosure,\n  Spinner,\n  Table,\n  TableBody,\n  TableCell,\n  TableColumn,\n  TableHeader,\n  TableRow,\n  Chip,\n} from '@nextui-org/react';\nimport { QRCodeSVG } from 'qrcode.react';\nimport { toast } from 'sonner';\nimport { PlusIcon } from '@web/components/PlusIcon';\nimport dayjs from 'dayjs';\nimport { StatusDropdown } from '@web/components/StatusDropdown';\nimport { trpc } from '@web/utils/trpc';\nimport { statusMap } from '@web/constants';\nimport { useEffect, useState } from 'react';\n\nconst AccountPage = () => {\n  const { isOpen, onOpen, onClose, onOpenChange } = useDisclosure();\n  const [count, setCount] = useState(0);\n\n  const { refetch, data, isFetching } = trpc.account.list.useQuery({});\n\n  const queryUtils = trpc.useUtils();\n\n  const { mutateAsync: updateAccount } = trpc.account.edit.useMutation({});\n\n  const { mutateAsync: deleteAccount } = trpc.account.delete.useMutation({});\n\n  const { mutateAsync: addAccount } = trpc.account.add.useMutation({});\n\n  const { mutateAsync, data: loginData } =\n    trpc.platform.createLoginUrl.useMutation({\n      onSuccess(data) {\n        if (data.uuid) {\n          setCount(60);\n        }\n      },\n    });\n\n  const { data: loginResult } = trpc.platform.getLoginResult.useQuery(\n    {\n      id: loginData?.uuid ?? '',\n    },\n    {\n      refetchIntervalInBackground: false,\n      enabled: !!loginData?.uuid,\n      async onSuccess(data) {\n        if (data.vid && data.token) {\n          const name = data.username!;\n          await addAccount({ id: `${data.vid}`, name, token: data.token });\n\n          onClose();\n          toast.success('添加成功', {\n            description: `用户名：${name}(${data.vid})`,\n          });\n          refetch();\n        } else if (data.message) {\n          toast.error(`登录失败: ${data.message}`);\n        }\n      },\n    },\n  );\n\n  useEffect(() => {\n    let timerId;\n    if (count > 0 && isOpen) {\n      timerId = setTimeout(() => {\n        setCount(count - 1);\n      }, 1000);\n    }\n    return () => timerId && clearTimeout(timerId);\n  }, [count, isOpen]);\n\n  return (\n    <div>\n      <div className=\"flex justify-between m-4\">\n        <div className=\"font-bold\">共{data?.items.length || 0}个账号</div>\n        <Button\n          onPress={() => {\n            onOpen();\n            mutateAsync();\n          }}\n          size=\"sm\"\n          color=\"primary\"\n          endContent={<PlusIcon />}\n        >\n          添加读书账号\n        </Button>\n      </div>\n      <Table aria-label=\"Example static collection table\">\n        <TableHeader>\n          <TableColumn>ID</TableColumn>\n          <TableColumn>用户名</TableColumn>\n          <TableColumn>状态</TableColumn>\n          <TableColumn>更新时间</TableColumn>\n          <TableColumn>操作</TableColumn>\n        </TableHeader>\n        <TableBody\n          emptyContent={<div className=\"m-auto text-center\">暂无数据</div>}\n          isLoading={isFetching}\n          loadingContent={<Spinner />}\n        >\n          {data?.items.map((item) => {\n            const isBlocked = data?.blocks.includes(item.id);\n\n            return (\n              <TableRow key={item.id}>\n                <TableCell>{item.id}</TableCell>\n                <TableCell>{item.name}</TableCell>\n                <TableCell>\n                  {isBlocked ? (\n                    <Chip className=\"capitalize\" size=\"sm\" variant=\"flat\">\n                      今日小黑屋\n                    </Chip>\n                  ) : (\n                    <Chip\n                      className=\"capitalize\"\n                      color={statusMap[item.status].color}\n                      size=\"sm\"\n                      variant=\"flat\"\n                    >\n                      {statusMap[item.status].label}\n                    </Chip>\n                  )}\n                </TableCell>\n                <TableCell>\n                  {dayjs(item.updatedAt).format('YYYY-MM-DD')}\n                </TableCell>\n                <TableCell className=\"flex gap-2\">\n                  <StatusDropdown\n                    value={item.status}\n                    onChange={(value) => {\n                      updateAccount({\n                        id: item.id,\n                        data: { status: value },\n                      }).then(() => {\n                        toast.success('更新成功!');\n                        refetch();\n                      });\n                    }}\n                  ></StatusDropdown>\n\n                  <Button\n                    size=\"sm\"\n                    color=\"danger\"\n                    onPress={() => {\n                      deleteAccount(item.id).then(() => {\n                        toast.success('删除成功!');\n                        refetch();\n                      });\n                    }}\n                  >\n                    删除\n                  </Button>\n                </TableCell>\n              </TableRow>\n            );\n          }) || []}\n        </TableBody>\n      </Table>\n\n      <Modal\n        isOpen={isOpen}\n        onOpenChange={async () => {\n          onOpenChange();\n          await queryUtils.platform.getLoginResult.cancel();\n        }}\n      >\n        <ModalContent>\n          {() => (\n            <>\n              <ModalHeader className=\"flex flex-col gap-1\">\n                添加读书账号\n              </ModalHeader>\n              <ModalBody>\n                <div className=\"m-auto pb-8 text-center\">\n                  {loginData ? (\n                    <div>\n                      <div className=\"relative\">\n                        {loginResult?.message && (\n                          <div className=\"absolute top-0 left-0 bottom-0 right-0 bg-white bg-opacity-75 flex justify-center items-center\">\n                            <div className=\"text-xl\">\n                              {loginResult?.message}\n                            </div>\n                          </div>\n                        )}\n                        <QRCodeSVG size={150} value={loginData?.scanUrl} />\n                      </div>\n                      <div className=\"mt-4\">\n                        微信扫码登录{' '}\n                        {!loginResult?.message && count > 0 && (\n                          <span className=\"text-red-400\">({count}s)</span>\n                        )}\n                      </div>\n                    </div>\n                  ) : (\n                    <div className=\"m-auto flex justify-center align-middle items-center\">\n                      <Spinner />\n                      二维码加载中\n                    </div>\n                  )}\n                </div>\n              </ModalBody>\n            </>\n          )}\n        </ModalContent>\n      </Modal>\n    </div>\n  );\n};\n\nexport default AccountPage;\n"
  },
  {
    "path": "apps/web/src/pages/feeds/index.tsx",
    "content": "import {\n  Avatar,\n  Button,\n  Divider,\n  Listbox,\n  ListboxItem,\n  ListboxSection,\n  Modal,\n  ModalBody,\n  ModalContent,\n  ModalFooter,\n  ModalHeader,\n  Switch,\n  Textarea,\n  Tooltip,\n  useDisclosure,\n  Link,\n} from '@nextui-org/react';\nimport { PlusIcon } from '@web/components/PlusIcon';\nimport { trpc } from '@web/utils/trpc';\nimport { useMemo, useState } from 'react';\nimport { useNavigate, useParams } from 'react-router-dom';\nimport { toast } from 'sonner';\nimport dayjs from 'dayjs';\nimport { serverOriginUrl } from '@web/utils/env';\nimport ArticleList from './list';\n\nconst Feeds = () => {\n  const { id } = useParams();\n\n  const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure();\n  const { refetch: refetchFeedList, data: feedData } = trpc.feed.list.useQuery(\n    {},\n    {\n      refetchOnWindowFocus: true,\n    },\n  );\n\n  const navigate = useNavigate();\n\n  const queryUtils = trpc.useUtils();\n\n  const { mutateAsync: getMpInfo, isLoading: isGetMpInfoLoading } =\n    trpc.platform.getMpInfo.useMutation({});\n  const { mutateAsync: updateMpInfo } = trpc.feed.edit.useMutation({});\n\n  const { mutateAsync: addFeed, isLoading: isAddFeedLoading } =\n    trpc.feed.add.useMutation({});\n  const { mutateAsync: refreshMpArticles, isLoading: isGetArticlesLoading } =\n    trpc.feed.refreshArticles.useMutation();\n  const {\n    mutateAsync: getHistoryArticles,\n    isLoading: isGetHistoryArticlesLoading,\n  } = trpc.feed.getHistoryArticles.useMutation();\n\n  const { data: inProgressHistoryMp, refetch: refetchInProgressHistoryMp } =\n    trpc.feed.getInProgressHistoryMp.useQuery(undefined, {\n      refetchOnWindowFocus: true,\n      refetchInterval: 10 * 1e3,\n      refetchOnMount: true,\n      refetchOnReconnect: true,\n    });\n\n  const { data: isRefreshAllMpArticlesRunning } =\n    trpc.feed.isRefreshAllMpArticlesRunning.useQuery();\n\n  const { mutateAsync: deleteFeed, isLoading: isDeleteFeedLoading } =\n    trpc.feed.delete.useMutation({});\n\n  const [wxsLink, setWxsLink] = useState('');\n\n  const [currentMpId, setCurrentMpId] = useState(id || '');\n\n  const handleConfirm = async () => {\n    console.log('wxsLink', wxsLink);\n    // TODO show operation in progress\n    const wxsLinks = wxsLink.split('\\n').filter((link) => link.trim() !== '');\n    for (const link of wxsLinks) {\n      console.log('add wxsLink', link);\n      const res = await getMpInfo({ wxsLink: link });\n      if (res[0]) {\n        const item = res[0];\n        await addFeed({\n          id: item.id,\n          mpName: item.name,\n          mpCover: item.cover,\n          mpIntro: item.intro,\n          updateTime: item.updateTime,\n          status: 1,\n        });\n        await refreshMpArticles({ mpId: item.id });\n        toast.success('添加成功', {\n          description: `公众号 ${item.name}`,\n        });\n        await queryUtils.article.list.reset();\n      } else {\n        toast.error('添加失败', { description: '请检查链接是否正确' });\n      }\n    }\n    refetchFeedList();\n    setWxsLink('');\n    onClose();\n  };\n\n  const isActive = (key: string) => {\n    return currentMpId === key;\n  };\n\n  const currentMpInfo = useMemo(() => {\n    return feedData?.items.find((item) => item.id === currentMpId);\n  }, [currentMpId, feedData?.items]);\n\n  const handleExportOpml = async (ev) => {\n    ev.preventDefault();\n    ev.stopPropagation();\n    if (!feedData?.items?.length) {\n      console.warn('没有订阅源');\n      return;\n    }\n\n    let opmlContent = `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n    <opml version=\"2.0\">\n      <head>\n        <title>WeWeRSS 所有订阅源</title>\n      </head>\n      <body>\n    `;\n\n    feedData?.items.forEach((sub) => {\n      opmlContent += `    <outline text=\"${sub.mpName}\" type=\"rss\" xmlUrl=\"${window.location.origin}/feeds/${sub.id}.atom\" htmlUrl=\"${window.location.origin}/feeds/${sub.id}.atom\"/>\\n`;\n    });\n\n    opmlContent += `    </body>\n    </opml>`;\n\n    const blob = new Blob([opmlContent], { type: 'text/xml;charset=utf-8;' });\n    const link = document.createElement('a');\n    link.href = URL.createObjectURL(blob);\n    link.download = 'WeWeRSS-All.opml';\n    document.body.appendChild(link);\n    link.click();\n    document.body.removeChild(link);\n  };\n\n  return (\n    <>\n      <div className=\"h-full flex justify-between\">\n        <div className=\"w-64 p-4 h-full\">\n          <div className=\"pb-4 flex justify-between align-middle items-center\">\n            <Button\n              color=\"primary\"\n              size=\"sm\"\n              onPress={onOpen}\n              endContent={<PlusIcon />}\n            >\n              添加\n            </Button>\n            <div className=\"font-normal text-sm\">\n              共{feedData?.items.length || 0}个订阅\n            </div>\n          </div>\n\n          {feedData?.items ? (\n            <Listbox\n              aria-label=\"订阅源\"\n              emptyContent=\"暂无订阅\"\n              onAction={(key) => setCurrentMpId(key as string)}\n            >\n              <ListboxSection showDivider>\n                <ListboxItem\n                  key={''}\n                  href={`/feeds`}\n                  className={isActive('') ? 'bg-primary-50 text-primary' : ''}\n                  startContent={<Avatar name=\"ALL\"></Avatar>}\n                >\n                  全部\n                </ListboxItem>\n              </ListboxSection>\n\n              <ListboxSection className=\"overflow-y-auto h-[calc(100vh-260px)]\">\n                {feedData?.items.map((item) => {\n                  return (\n                    <ListboxItem\n                      href={`/feeds/${item.id}`}\n                      className={\n                        isActive(item.id) ? 'bg-primary-50 text-primary' : ''\n                      }\n                      key={item.id}\n                      startContent={<Avatar src={item.mpCover}></Avatar>}\n                    >\n                      {item.mpName}\n                    </ListboxItem>\n                  );\n                }) || []}\n              </ListboxSection>\n            </Listbox>\n          ) : (\n            ''\n          )}\n        </div>\n        <div className=\"flex-1 h-full flex flex-col\">\n          <div className=\"p-4 pb-0 flex justify-between\">\n            <h3 className=\"text-medium font-mono flex-1 overflow-hidden text-ellipsis break-keep text-nowrap pr-1\">\n              {currentMpInfo?.mpName || '全部'}\n            </h3>\n            {currentMpInfo ? (\n              <div className=\"flex h-5 items-center space-x-4 text-small\">\n                <div className=\"font-light\">\n                  最后更新时间:\n                  {dayjs(currentMpInfo.syncTime * 1e3).format(\n                    'YYYY-MM-DD HH:mm:ss',\n                  )}\n                </div>\n                <Divider orientation=\"vertical\" />\n                <Tooltip\n                  content=\"频繁调用可能会导致一段时间内不可用\"\n                  color=\"danger\"\n                >\n                  <Link\n                    size=\"sm\"\n                    href=\"#\"\n                    isDisabled={isGetArticlesLoading}\n                    onClick={async (ev) => {\n                      ev.preventDefault();\n                      ev.stopPropagation();\n                      await refreshMpArticles({ mpId: currentMpInfo.id });\n                      await refetchFeedList();\n                      await queryUtils.article.list.reset();\n                    }}\n                  >\n                    {isGetArticlesLoading ? '更新中...' : '立即更新'}\n                  </Link>\n                </Tooltip>\n                <Divider orientation=\"vertical\" />\n                {currentMpInfo.hasHistory === 1 && (\n                  <>\n                    <Tooltip\n                      content={\n                        inProgressHistoryMp?.id === currentMpInfo.id\n                          ? `正在获取第${inProgressHistoryMp.page}页...`\n                          : `历史文章需要分批次拉取，请耐心等候，频繁调用可能会导致一段时间内不可用`\n                      }\n                      color={\n                        inProgressHistoryMp?.id === currentMpInfo.id\n                          ? 'primary'\n                          : 'danger'\n                      }\n                    >\n                      <Link\n                        size=\"sm\"\n                        href=\"#\"\n                        isDisabled={\n                          (inProgressHistoryMp?.id\n                            ? inProgressHistoryMp?.id !== currentMpInfo.id\n                            : false) ||\n                          isGetHistoryArticlesLoading ||\n                          isGetArticlesLoading\n                        }\n                        onClick={async (ev) => {\n                          ev.preventDefault();\n                          ev.stopPropagation();\n\n                          if (inProgressHistoryMp?.id === currentMpInfo.id) {\n                            await getHistoryArticles({\n                              mpId: '',\n                            });\n                          } else {\n                            await getHistoryArticles({\n                              mpId: currentMpInfo.id,\n                            });\n                          }\n\n                          await refetchInProgressHistoryMp();\n                        }}\n                      >\n                        {inProgressHistoryMp?.id === currentMpInfo.id\n                          ? `停止获取历史文章`\n                          : `获取历史文章`}\n                      </Link>\n                    </Tooltip>\n                    <Divider orientation=\"vertical\" />\n                  </>\n                )}\n\n                <Tooltip content=\"启用服务端定时更新\">\n                  <div>\n                    <Switch\n                      size=\"sm\"\n                      onValueChange={async (value) => {\n                        await updateMpInfo({\n                          id: currentMpInfo.id,\n                          data: {\n                            status: value ? 1 : 0,\n                          },\n                        });\n\n                        await refetchFeedList();\n                      }}\n                      isSelected={currentMpInfo?.status === 1}\n                    ></Switch>\n                  </div>\n                </Tooltip>\n                <Divider orientation=\"vertical\" />\n                <Tooltip content=\"仅删除订阅源，已获取的文章不会被删除\">\n                  <Link\n                    href=\"#\"\n                    color=\"danger\"\n                    size=\"sm\"\n                    isDisabled={isDeleteFeedLoading}\n                    onClick={async (ev) => {\n                      ev.preventDefault();\n                      ev.stopPropagation();\n\n                      if (window.confirm('确定删除吗？')) {\n                        await deleteFeed(currentMpInfo.id);\n                        navigate('/feeds');\n                        await refetchFeedList();\n                      }\n                    }}\n                  >\n                    删除\n                  </Link>\n                </Tooltip>\n\n                <Divider orientation=\"vertical\" />\n                <Tooltip\n                  content={\n                    <div>\n                      可添加.atom/.rss/.json格式输出, limit=20&page=1控制分页\n                    </div>\n                  }\n                >\n                  <Link\n                    size=\"sm\"\n                    showAnchorIcon\n                    target=\"_blank\"\n                    href={`${serverOriginUrl}/feeds/${currentMpInfo.id}.atom`}\n                    color=\"foreground\"\n                  >\n                    RSS\n                  </Link>\n                </Tooltip>\n              </div>\n            ) : (\n              <div className=\"flex gap-2\">\n                <Tooltip\n                  content=\"频繁调用可能会导致一段时间内不可用\"\n                  color=\"danger\"\n                >\n                  <Link\n                    size=\"sm\"\n                    href=\"#\"\n                    isDisabled={\n                      isRefreshAllMpArticlesRunning || isGetArticlesLoading\n                    }\n                    onClick={async (ev) => {\n                      ev.preventDefault();\n                      ev.stopPropagation();\n                      await refreshMpArticles({});\n                      await refetchFeedList();\n                      await queryUtils.article.list.reset();\n                    }}\n                  >\n                    {isRefreshAllMpArticlesRunning || isGetArticlesLoading\n                      ? '更新中...'\n                      : '更新全部'}\n                  </Link>\n                </Tooltip>\n                <Link\n                  href=\"#\"\n                  color=\"foreground\"\n                  onClick={handleExportOpml}\n                  size=\"sm\"\n                >\n                  导出OPML\n                </Link>\n                <Divider orientation=\"vertical\" />\n                <Link\n                  size=\"sm\"\n                  showAnchorIcon\n                  target=\"_blank\"\n                  href={`${serverOriginUrl}/feeds/all.atom`}\n                  color=\"foreground\"\n                >\n                  RSS\n                </Link>\n              </div>\n            )}\n          </div>\n          <div className=\"p-2 overflow-y-auto\">\n            <ArticleList></ArticleList>\n          </div>\n        </div>\n      </div>\n      <Modal isOpen={isOpen} onOpenChange={onOpenChange}>\n        <ModalContent>\n          {(onClose) => (\n            <>\n              <ModalHeader className=\"flex flex-col gap-1\">\n                添加公众号源\n              </ModalHeader>\n              <ModalBody>\n                <Textarea\n                  value={wxsLink}\n                  onValueChange={setWxsLink}\n                  autoFocus\n                  label=\"分享链接\"\n                  placeholder=\"输入公众号文章分享链接，一行一条，如 https://mp.weixin.qq.com/s/xxxxxx https://mp.weixin.qq.com/s/xxxxxx\"\n                  variant=\"bordered\"\n                />\n              </ModalBody>\n              <ModalFooter>\n                <Button color=\"danger\" variant=\"flat\" onPress={onClose}>\n                  取消\n                </Button>\n                <Button\n                  color=\"primary\"\n                  isDisabled={\n                    !wxsLink.startsWith('https://mp.weixin.qq.com/s/')\n                  }\n                  onPress={handleConfirm}\n                  isLoading={\n                    isAddFeedLoading ||\n                    isGetMpInfoLoading ||\n                    isGetArticlesLoading\n                  }\n                >\n                  确定\n                </Button>\n              </ModalFooter>\n            </>\n          )}\n        </ModalContent>\n      </Modal>\n    </>\n  );\n};\n\nexport default Feeds;\n"
  },
  {
    "path": "apps/web/src/pages/feeds/list.tsx",
    "content": "import { FC, useMemo } from 'react';\nimport {\n  Table,\n  TableHeader,\n  TableColumn,\n  TableBody,\n  TableRow,\n  TableCell,\n  getKeyValue,\n  Button,\n  Spinner,\n  Link,\n} from '@nextui-org/react';\nimport { trpc } from '@web/utils/trpc';\nimport dayjs from 'dayjs';\nimport { useParams } from 'react-router-dom';\n\nconst ArticleList: FC = () => {\n  const { id } = useParams();\n\n  const mpId = id || '';\n\n  const { data, fetchNextPage, isLoading, hasNextPage } =\n    trpc.article.list.useInfiniteQuery(\n      {\n        limit: 20,\n        mpId: mpId,\n      },\n      {\n        getNextPageParam: (lastPage) => lastPage.nextCursor,\n      },\n    );\n\n  const items = useMemo(() => {\n    const items = data\n      ? data.pages.reduce((acc, page) => [...acc, ...page.items], [] as any[])\n      : [];\n\n    return items;\n  }, [data]);\n\n  return (\n    <div>\n      <Table\n        classNames={{\n          base: 'h-full',\n          table: 'min-h-[420px]',\n        }}\n        aria-label=\"文章列表\"\n        bottomContent={\n          hasNextPage && !isLoading ? (\n            <div className=\"flex w-full justify-center\">\n              <Button\n                isDisabled={isLoading}\n                variant=\"flat\"\n                onPress={() => {\n                  fetchNextPage();\n                }}\n              >\n                {isLoading && <Spinner color=\"white\" size=\"sm\" />}\n                加载更多\n              </Button>\n            </div>\n          ) : null\n        }\n      >\n        <TableHeader>\n          <TableColumn key=\"title\">标题</TableColumn>\n          <TableColumn width={180} key=\"publishTime\">\n            发布时间\n          </TableColumn>\n        </TableHeader>\n        <TableBody\n          isLoading={isLoading}\n          emptyContent={'暂无数据'}\n          items={items || []}\n          loadingContent={<Spinner />}\n        >\n          {(item) => (\n            <TableRow key={item.id}>\n              {(columnKey) => {\n                let value = getKeyValue(item, columnKey);\n\n                if (columnKey === 'publishTime') {\n                  value = dayjs(value * 1e3).format('YYYY-MM-DD HH:mm:ss');\n                  return <TableCell>{value}</TableCell>;\n                }\n\n                if (columnKey === 'title') {\n                  return (\n                    <TableCell>\n                      <Link\n                        className=\"visited:text-neutral-400\"\n                        isBlock\n                        showAnchorIcon\n                        color=\"foreground\"\n                        target=\"_blank\"\n                        href={`https://mp.weixin.qq.com/s/${item.id}`}\n                      >\n                        {value}\n                      </Link>\n                    </TableCell>\n                  );\n                }\n                return <TableCell>{value}</TableCell>;\n              }}\n            </TableRow>\n          )}\n        </TableBody>\n      </Table>\n    </div>\n  );\n};\n\nexport default ArticleList;\n"
  },
  {
    "path": "apps/web/src/pages/login/index.tsx",
    "content": "import { Button, Input } from '@nextui-org/react';\nimport { setAuthCode } from '@web/utils/auth';\nimport { useState } from 'react';\nimport { useNavigate } from 'react-router-dom';\n\nconst LoginPage = () => {\n  const [codeValue, setCodeValue] = useState('');\n\n  const navigate = useNavigate();\n\n  return (\n    <div className=\"m-auto mt-[10vh] flex w-full max-w-sm flex-col gap-4 rounded-large bg-content1 px-8 pb-10 pt-6 shadow-small\">\n      <Input\n        value={codeValue}\n        onValueChange={setCodeValue}\n        label=\"AuthCode\"\n        placeholder=\"请输入auth code\"\n      />\n      <Button\n        color=\"primary\"\n        onPress={() => {\n          setAuthCode(codeValue);\n          navigate('/');\n        }}\n      >\n        确认\n      </Button>\n    </div>\n  );\n};\n\nexport default LoginPage;\n"
  },
  {
    "path": "apps/web/src/provider/theme.tsx",
    "content": "import { NextUIProvider } from '@nextui-org/react';\nimport { ThemeProvider as NextThemesProvider } from 'next-themes';\nimport { useNavigate } from 'react-router-dom';\n\nfunction ThemeProvider({ children }: { children: React.ReactNode }) {\n  const navigate = useNavigate();\n\n  return (\n    <NextUIProvider navigate={navigate}>\n      <NextThemesProvider attribute=\"class\" enableSystem>\n        {children}\n      </NextThemesProvider>\n    </NextUIProvider>\n  );\n}\n\nexport default ThemeProvider;\n"
  },
  {
    "path": "apps/web/src/provider/trpc.tsx",
    "content": "import { QueryClient, QueryClientProvider } from '@tanstack/react-query';\nimport { httpBatchLink, loggerLink } from '@trpc/client';\nimport { useNavigate } from 'react-router-dom';\nimport { useState } from 'react';\nimport { toast } from 'sonner';\nimport { isTRPCClientError, trpc } from '../utils/trpc';\nimport { getAuthCode, setAuthCode } from '../utils/auth';\nimport { enabledAuthCode, serverOriginUrl } from '../utils/env';\n\nexport const TrpcProvider: React.FC<{ children: React.ReactNode }> = ({\n  children,\n}) => {\n  const navigate = useNavigate();\n\n  const handleNoAuth = () => {\n    if (enabledAuthCode) {\n      setAuthCode('');\n      navigate('/login');\n    }\n  };\n  const [queryClient] = useState(\n    () =>\n      new QueryClient({\n        defaultOptions: {\n          queries: {\n            refetchOnWindowFocus: false,\n            refetchOnReconnect: true,\n            refetchIntervalInBackground: false,\n            retryDelay: (retryCount) => Math.min(retryCount * 1000, 60 * 1000),\n            retry(failureCount, error) {\n              console.log('failureCount: ', failureCount);\n              if (isTRPCClientError(error)) {\n                if (error.data?.httpStatus === 401) {\n                  return false;\n                }\n              }\n              return failureCount < 3;\n            },\n            onError(error) {\n              console.error('queries onError: ', error);\n              if (isTRPCClientError(error)) {\n                if (error.data?.httpStatus === 401) {\n                  toast.error('无权限', {\n                    description: error.message,\n                  });\n\n                  handleNoAuth();\n                } else {\n                  toast.error('请求失败!', {\n                    description: error.message,\n                  });\n                }\n              }\n            },\n          },\n          mutations: {\n            onError(error) {\n              console.error('mutations onError: ', error);\n              if (isTRPCClientError(error)) {\n                if (error.data?.httpStatus === 401) {\n                  toast.error('无权限', {\n                    description: error.message,\n                  });\n                  handleNoAuth();\n                } else {\n                  toast.error('请求失败!', {\n                    description: error.message,\n                  });\n                }\n              }\n            },\n          },\n        },\n      }),\n  );\n\n  const [trpcClient] = useState(() =>\n    trpc.createClient({\n      links: [\n        loggerLink({\n          enabled: () => true,\n        }),\n        httpBatchLink({\n          url: serverOriginUrl + '/trpc',\n          async headers() {\n            const token = getAuthCode();\n\n            if (!token) {\n              handleNoAuth();\n              return {};\n            }\n\n            return token\n              ? {\n                  Authorization: `${token}`,\n                }\n              : {};\n          },\n        }),\n      ],\n    }),\n  );\n  return (\n    <trpc.Provider client={trpcClient} queryClient={queryClient}>\n      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>\n    </trpc.Provider>\n  );\n};\n"
  },
  {
    "path": "apps/web/src/types.ts",
    "content": "import { SVGProps } from 'react';\n\nexport type IconSvgProps = SVGProps<SVGSVGElement> & {\n  size?: number;\n};\n"
  },
  {
    "path": "apps/web/src/utils/auth.ts",
    "content": "let token: string | null = null;\n\nexport const getAuthCode = () => {\n  if (token !== null) {\n    return token;\n  }\n\n  token = window.localStorage.getItem('authCode');\n  return token;\n};\n\nexport const setAuthCode = (authCode: string | null) => {\n  token = authCode;\n  if (!authCode) {\n    window.localStorage.removeItem('authCode');\n    return;\n  }\n  window.localStorage.setItem('authCode', authCode);\n};\n"
  },
  {
    "path": "apps/web/src/utils/env.ts",
    "content": "export const isProd = import.meta.env.PROD;\n\nexport const serverOriginUrl = isProd\n  ? window.__WEWE_RSS_SERVER_ORIGIN_URL__\n  : import.meta.env.VITE_SERVER_ORIGIN_URL;\n\nexport const appVersion = __APP_VERSION__;\n\nexport const enabledAuthCode =\n  window.__WEWE_RSS_ENABLED_AUTH_CODE__ === false ? false : true;\n"
  },
  {
    "path": "apps/web/src/utils/trpc.ts",
    "content": "import { AppRouter } from '@server/trpc/trpc.router';\nimport { TRPCClientError, createTRPCReact } from '@trpc/react-query';\n\nexport const trpc = createTRPCReact<AppRouter>();\n\nexport function isTRPCClientError(\n  cause: unknown,\n): cause is TRPCClientError<AppRouter> {\n  return cause instanceof TRPCClientError;\n}\n"
  },
  {
    "path": "apps/web/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n\ninterface ImportMetaEnv {\n  readonly VITE_SERVER_ORIGIN_URL: string;\n  readonly VITE_ENV: string;\n}\n\ninterface Window {\n  __WEWE_RSS_SERVER_ORIGIN_URL__?: string;\n  __WEWE_RSS_ENABLED_AUTH_CODE__?: boolean;\n}\n\ndeclare const __APP_VERSION__: string;\n"
  },
  {
    "path": "apps/web/tailwind.config.ts",
    "content": "import type { Config } from 'tailwindcss';\nimport { nextui } from '@nextui-org/react';\n\nconst config: Config = {\n  content: [\n    './index.html',\n    './src/**/*.{js,ts,jsx,tsx}',\n    '../../node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}',\n  ],\n  theme: {\n    extend: {},\n  },\n  darkMode: 'class',\n  plugins: [nextui()],\n};\nexport default config;\n"
  },
  {
    "path": "apps/web/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": false,\n    \"noUnusedParameters\": false,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noImplicitAny\": false\n  },\n  \"include\": [\"src\"],\n  \"references\": [{ \"path\": \"./tsconfig.node.json\" }]\n}\n"
  },
  {
    "path": "apps/web/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"skipLibCheck\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"allowSyntheticDefaultImports\": true,\n    \"strict\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "apps/web/vite.config.ts",
    "content": "import { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\nimport { resolve } from 'path';\nimport { readFileSync } from 'fs';\n\nconst projectRootDir = resolve(__dirname);\n\nconst isProd = process.env.NODE_ENV === 'production';\n\nconsole.log('process.env.NODE_ENV: ', process.env.NODE_ENV);\n\nconst packageJson = JSON.parse(\n  readFileSync(resolve(__dirname, './package.json'), 'utf-8'),\n);\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n  base: '/dash',\n  define: {\n    __APP_VERSION__: JSON.stringify(packageJson.version),\n  },\n  plugins: [\n    react(),\n    !isProd\n      ? null\n      : {\n          name: 'renameIndex',\n          enforce: 'post',\n          generateBundle(options, bundle) {\n            const indexHtml = bundle['index.html'];\n            indexHtml.fileName = 'index.hbs';\n          },\n        },\n  ],\n  resolve: {\n    alias: [\n      {\n        find: '@server',\n        replacement: resolve(projectRootDir, '../apps/server/src'),\n      },\n      {\n        find: '@web',\n        replacement: resolve(projectRootDir, './src'),\n      },\n    ],\n  },\n  build: {\n    emptyOutDir: true,\n    outDir: resolve(projectRootDir, '..', 'server', 'client'),\n  },\n});\n"
  },
  {
    "path": "assets/nginx.example.conf",
    "content": "server {\n\n  listen 80;\n\n  server_name yourdomain;\n  \n  location / {\n\n    proxy_pass http://127.0.0.1:4000;\n    proxy_http_version \t1.1;\n    proxy_set_header\tConnection\t\t\"\";\n    proxy_set_header   \tHost\t\t\t$http_host;\n    proxy_set_header \tX-Forwarded-Proto \t$scheme;\n    proxy_set_header   \tX-Real-IP          \t$remote_addr;\n    proxy_set_header   \tX-Forwarded-For    \t$proxy_add_x_forwarded_for;\n    proxy_set_header    Accept-Encoding gzip;\n\n    proxy_buffering off;\n    proxy_cache off;\n    \n    send_timeout 300;\n    proxy_connect_timeout 300;\n    proxy_send_timeout 300;\n    proxy_read_timeout 300;\n  }\n\n}\n\n\n"
  },
  {
    "path": "docker-compose.dev.yml",
    "content": "services:\n  mysql:\n    image: mysql:8.3.0\n    command: --default-authentication-plugin=mysql_native_password\n    environment:\n      MYSQL_ROOT_PASSWORD: 123456\n      TZ: 'Asia/Shanghai'\n    ports:\n      - 3306:3306\nvolumes:\n  mysql:\n"
  },
  {
    "path": "docker-compose.sqlite.yml",
    "content": "version: '3.9'\n\nservices:\n  app:\n    image: cooderl/wewe-rss-sqlite:latest\n    ports:\n      - 4000:4000\n    environment:\n      # 数据库连接地址\n      # - DATABASE_URL=file:../data/wewe-rss.db\n      - DATABASE_TYPE=sqlite\n      # 服务接口请求授权码\n      - AUTH_CODE=123567\n      # 提取全文内容模式\n      # - FEED_MODE=fulltext\n      # 定时更新订阅源Cron表达式\n      # - CRON_EXPRESSION=35 5,17 * * *\n      # 服务接口请求限制，每分钟请求次数\n      # - MAX_REQUEST_PER_MINUTE=60\n      # 外网访问时，需设置为服务器的公网 IP 或者域名地址\n      # - SERVER_ORIGIN_URL=http://localhost:4000\n\n    volumes:\n      # 映射数据库文件存储位置，容器重启后不丢失\n      - ./data:/app/data\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "version: '3.9'\n\nservices:\n  db:\n    image: mysql:8.3.0\n    command: --mysql-native-password=ON\n    environment:\n      # 请修改为自己的密码\n      MYSQL_ROOT_PASSWORD: 123456\n      TZ: 'Asia/Shanghai'\n      MYSQL_DATABASE: 'wewe-rss'\n    # ports:\n    #   - 13306:3306\n    volumes:\n      - db_data:/var/lib/mysql\n    healthcheck:\n      test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost']\n      timeout: 45s\n      interval: 10s\n      retries: 10\n\n  app:\n    image: cooderl/wewe-rss:latest\n    ports:\n      - 4000:4000\n    depends_on:\n      db:\n        condition: service_healthy\n    environment:\n      # 数据库连接地址\n      - DATABASE_URL=mysql://root:123456@db:3306/wewe-rss?schema=public&connect_timeout=30&pool_timeout=30&socket_timeout=30\n      # 服务接口请求授权码\n      - AUTH_CODE=123567\n      # 提取全文内容模式\n      # - FEED_MODE=fulltext\n      # 定时更新订阅源Cron表达式\n      # - CRON_EXPRESSION=35 5,17 * * *\n      # 服务接口请求限制，每分钟请求次数\n      # - MAX_REQUEST_PER_MINUTE=60\n      # 外网访问时，需设置为服务器的公网 IP 或者域名地址\n      # - SERVER_ORIGIN_URL=http://localhost:4000\n\nnetworks:\n  wewe-rss:\n\nvolumes:\n  db_data:\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"wewe-rss\",\n  \"version\": \"2.6.1\",\n  \"private\": true,\n  \"author\": \"cooderl <cooder@111965.xyz>\",\n  \"description\": \"\",\n  \"main\": \"index.js\",\n  \"engines\": {\n    \"node\": \">=20.9.0\",\n    \"pnpm\": \">=8.6.1\",\n    \"vscode\": \">=1.79\"\n  },\n  \"scripts\": {\n    \"dev\": \"pnpm run --parallel dev\",\n    \"build:server\": \"pnpm --filter server build\",\n    \"build:web\": \"pnpm --filter web build\",\n    \"start:server\": \"pnpm --filter server start:prod\",\n    \"start:web\": \"pnpm --filter web start\",\n    \"fmt\": \"prettier --write .\",\n    \"fmt.check\": \"prettier --check .\"\n  },\n  \"devDependencies\": {\n    \"prettier\": \"^3.2.5\"\n  }\n}"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "packages:\n  - 'apps/*'\n"
  },
  {
    "path": "release.sh",
    "content": "#!/bin/bash\n\n# 检查是否提供了版本号\nif [ \"$#\" -ne 1 ]; then\n    echo \"Usage: $0 <new-version>\"\n    exit 1\nfi\n\n# 新版本号\nNEW_VERSION=$1\n\n# 更新根目录下的 package.json\nsed -i '' \"s/\\\"version\\\": \\\".*\\\"/\\\"version\\\": \\\"$NEW_VERSION\\\"/\" package.json\n\n# 更新 apps 目录下所有子包的 package.json\nfor d in apps/*; do\n  if [ -d \"$d\" ] && [ -f \"$d/package.json\" ]; then\n    sed -i '' \"s/\\\"version\\\": \\\".*\\\"/\\\"version\\\": \\\"$NEW_VERSION\\\"/\" \"$d/package.json\"\n  fi\ndone\n\necho \"All packages updated to version $NEW_VERSION\"\n\n# 创建 Git 提交（可选）\ngit add .\ngit commit -m \"Release version $NEW_VERSION\"\n\n# 创建 Git 标签\ngit tag \"v$NEW_VERSION\"\n\n# 推送更改和标签到远程仓库\ngit push && git push origin --tags\n\necho \"Git tag v$NEW_VERSION has been created and pushed\""
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"experimentalDecorators\": true,\n    \"emitDecoratorMetadata\": true,\n    \"incremental\": true,\n    \"skipLibCheck\": true,\n    \"strictNullChecks\": true,\n    \"noImplicitAny\": false,\n    \"strictBindCallApply\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"paths\": {\n      \"@server/*\": [\n        \"./apps/server/src/*\"\n      ],\n      \"@web/*\": [\n        \"./apps/web/src/*\"\n      ]\n    }\n  }\n}"
  },
  {
    "path": "wewe-rss-dingtalk/Dockerfile",
    "content": "FROM python:3.8.12-slim\n\nWORKDIR /app\nCOPY . .\n\nRUN pip install -r requirements.txt\nENV TZ=Asia/Shanghai \\\n    DEBIAN_FRONTEND=noninteractive\n\nCMD python3 /app/main.py\n"
  },
  {
    "path": "wewe-rss-dingtalk/README.md",
    "content": "### 修改main.py，输入dingtalk的access_token和secret\n\n```\n   access_token = ''\n   secret = ''  # 创建机器人时钉钉设置页面有提供\n```\n\n### 修改根目录下的docker-compose.yaml文件，去掉以下字段的注释\n\n```\n    # ports:\n    #   - 13306:3306\n```\n\n### python3 main.py就可以运行\n\n### 或者部署成docker，运行\n\n```\n    sudo docker-compose up -d\n```\n"
  },
  {
    "path": "wewe-rss-dingtalk/docker-compose.yml",
    "content": "version: '3.9'\n\nservices:\n  wewe-rss-dingtalk:\n    build: .\n    container_name: wewe-rss-dingtalk\n"
  },
  {
    "path": "wewe-rss-dingtalk/main.py",
    "content": "import mysql.connector\nimport requests\nimport json\nimport os\nimport time\nfrom datetime import datetime, timedelta\nimport pytz\nfrom dingtalkchatbot.chatbot import DingtalkChatbot, ActionCard, FeedLink, CardItem\n\ndef get_subjects_json():\n    # 连接MySQL数据库\n    mydb = mysql.connector.connect(\n        host=\"localhost\",\n        port=\"13306\",\n        user=\"root\",\n        password=\"123456\",\n        database=\"wewe-rss\"\n    )\n    # 查询符合条件的数据, 用created_at来判断，因为publish_time是发文时间，rss更新时间会滞后\n    mycursor = mydb.cursor()\n    query = \"\"\"SELECT a.id, a.title, a.pic_url, a.publish_time, b.mp_name\n        FROM articles AS a, feeds AS b\n        WHERE a.mp_id = b.id\n        AND a.created_at >= NOW() - INTERVAL 12 HOUR \n        ORDER BY a.publish_time DESC\"\"\"\n        # 4hour +8 to fix created time is UTC time.\n    mycursor.execute(query)\n    results = mycursor.fetchall()\n\n    # 组装数据为JSON格式\n    data = []\n    for result in results:\n        subject = {\n            \"id\": result[0],\n            \"title\": result[1],\n            \"pic_url\": result[2],\n            \"publish_time\": result[3],\n            \"mp_name\": result[4]\n        }\n        data.append(subject)\n\n    json_data = json.dumps(data, indent=4)\n    print(json_data)\n    return json_data\n\ndef dingbot_markdown(access_token, secret, rss_list):\n    new_webhook = f'https://oapi.dingtalk.com/robot/send?access_token={access_token}'\n    xiaoding = DingtalkChatbot(new_webhook, secret=secret, pc_slide=True, fail_notice=False)\n\n    text = []\n    for data in rss_list:\n        # 创建CardItem对象\n        mp_name = data['mp_name']\n        url = 'https://mp.weixin.qq.com/s/' + str(data[\"id\"])\n        unix_timestamp = data['publish_time']\n        # 将 Unix 时间戳转换为北京时间\n        #转换成localtime\n        time_local = time.localtime(unix_timestamp)\n        #转换成新的时间格式(2016-05-05 20:28:54)\n        beijing_time = time.strftime(\"%Y-%m-%d %H:%M:%S\",time_local)\n        text_content = f'> **{mp_name}** [' + data[\"title\"] + '](' + url + ') ' + str(beijing_time) + '\\n'\n        # Markdown消息@指定用户\n        text.append(text_content)\n\n    title = '## 微信公众号<最近4小时更新> \\n\\n'\n    markdown_text = title +  '\\n'.join(text)\n    print(markdown_text)\n    res = xiaoding.send_markdown(title=title, text=markdown_text)\n    print(f\"send sucess, res: {res}\")\n\n\ndef send_dingtalk_msg(access_token, secret):\n    data = get_subjects_json()\n    rss_list = json.loads(data)\n    if len(rss_list) != 0:\n        dingbot_markdown(access_token, secret, rss_list) \n\nif __name__ == '__main__':\n\n    access_token = ''\n    secret = ''  # 创建机器人时钉钉设置页面有提供\n\n    while True:\n        send_dingtalk_msg(access_token, secret)\n        time.sleep( 4 * 60 * 60 ) # run every 4 hours\n"
  },
  {
    "path": "wewe-rss-dingtalk/requirements.txt",
    "content": "DingtalkChatbot==1.5.3\nmysql-connector-python\njason\npytz"
  }
]