Full Code of cooderl/wewe-rss for AI

main e751c6429408 cached
92 files
126.6 KB
37.5k tokens
74 symbols
1 requests
Download .txt
Repository: cooderl/wewe-rss
Branch: main
Commit: e751c6429408
Files: 92
Total size: 126.6 KB

Directory structure:
gitextract_odxctc3w/

├── .dockerignore
├── .github/
│   └── workflows/
│       └── docker-release.yml
├── .gitignore
├── .markdownlint.yaml
├── .npmrc
├── .prettierignore
├── .prettierrc.json
├── .vscode/
│   ├── extensions.json
│   └── settings.json
├── Dockerfile
├── LICENSE
├── README.md
├── apps/
│   ├── server/
│   │   ├── .eslintrc.js
│   │   ├── .gitignore
│   │   ├── .prettierrc.json
│   │   ├── README.md
│   │   ├── docker-bootstrap.sh
│   │   ├── nest-cli.json
│   │   ├── package.json
│   │   ├── prisma/
│   │   │   ├── migrations/
│   │   │   │   ├── 20240227153512_init/
│   │   │   │   │   └── migration.sql
│   │   │   │   ├── 20241212153618_has_history/
│   │   │   │   │   └── migration.sql
│   │   │   │   └── migration_lock.toml
│   │   │   └── schema.prisma
│   │   ├── prisma-sqlite/
│   │   │   ├── migrations/
│   │   │   │   ├── 20240301104100_init/
│   │   │   │   │   └── migration.sql
│   │   │   │   ├── 20241214172323_has_history/
│   │   │   │   │   └── migration.sql
│   │   │   │   └── migration_lock.toml
│   │   │   └── schema.prisma
│   │   ├── src/
│   │   │   ├── app.controller.spec.ts
│   │   │   ├── app.controller.ts
│   │   │   ├── app.module.ts
│   │   │   ├── app.service.ts
│   │   │   ├── configuration.ts
│   │   │   ├── constants.ts
│   │   │   ├── feeds/
│   │   │   │   ├── feeds.controller.spec.ts
│   │   │   │   ├── feeds.controller.ts
│   │   │   │   ├── feeds.module.ts
│   │   │   │   ├── feeds.service.spec.ts
│   │   │   │   └── feeds.service.ts
│   │   │   ├── main.ts
│   │   │   ├── prisma/
│   │   │   │   ├── prisma.module.ts
│   │   │   │   └── prisma.service.ts
│   │   │   └── trpc/
│   │   │       ├── trpc.module.ts
│   │   │       ├── trpc.router.ts
│   │   │       └── trpc.service.ts
│   │   ├── test/
│   │   │   ├── app.e2e-spec.ts
│   │   │   └── jest-e2e.json
│   │   ├── tsconfig.build.json
│   │   └── tsconfig.json
│   └── web/
│       ├── .eslintrc.cjs
│       ├── .gitignore
│       ├── README.md
│       ├── index.html
│       ├── package.json
│       ├── postcss.config.js
│       ├── src/
│       │   ├── App.tsx
│       │   ├── components/
│       │   │   ├── GitHubIcon.tsx
│       │   │   ├── Nav.tsx
│       │   │   ├── PlusIcon.tsx
│       │   │   ├── StatusDropdown.tsx
│       │   │   └── ThemeSwitcher.tsx
│       │   ├── constants.ts
│       │   ├── index.css
│       │   ├── layouts/
│       │   │   └── base.tsx
│       │   ├── main.tsx
│       │   ├── pages/
│       │   │   ├── accounts/
│       │   │   │   └── index.tsx
│       │   │   ├── feeds/
│       │   │   │   ├── index.tsx
│       │   │   │   └── list.tsx
│       │   │   └── login/
│       │   │       └── index.tsx
│       │   ├── provider/
│       │   │   ├── theme.tsx
│       │   │   └── trpc.tsx
│       │   ├── types.ts
│       │   ├── utils/
│       │   │   ├── auth.ts
│       │   │   ├── env.ts
│       │   │   └── trpc.ts
│       │   └── vite-env.d.ts
│       ├── tailwind.config.ts
│       ├── tsconfig.json
│       ├── tsconfig.node.json
│       └── vite.config.ts
├── assets/
│   └── nginx.example.conf
├── docker-compose.dev.yml
├── docker-compose.sqlite.yml
├── docker-compose.yml
├── package.json
├── pnpm-workspace.yaml
├── release.sh
├── tsconfig.json
└── wewe-rss-dingtalk/
    ├── Dockerfile
    ├── README.md
    ├── docker-compose.yml
    ├── main.py
    └── requirements.txt

================================================
FILE CONTENTS
================================================

================================================
FILE: .dockerignore
================================================
node_modules
.git
.gitignore
*.md
dist
.env
.next
.DS_Store
./wewe-rss-dingtalk

================================================
FILE: .github/workflows/docker-release.yml
================================================
name: Build WeWeRSS images and push image to docker hub
on:
  workflow_dispatch:
  push:
    # paths:
    #   - "apps/**"
    #   - "Dockerfile"
    tags:
      - 'v*.*.*'

concurrency:
  group: docker-release
  cancel-in-progress: true

jobs:
  check-env:
    permissions:
      contents: none
    runs-on: ubuntu-latest
    timeout-minutes: 5
    outputs:
      check-docker: ${{ steps.check-docker.outputs.defined }}
    steps:
      - id: check-docker
        env:
          DOCKER_HUB_NAME: ${{ secrets.DOCKER_HUB_NAME }}
        if: ${{ env.DOCKER_HUB_NAME != '' }}
        run: echo "defined=true" >> $GITHUB_OUTPUT

  release-images:
    runs-on: ubuntu-latest
    timeout-minutes: 120
    permissions:
      packages: write
      contents: read
      id-token: write
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 1

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKER_HUB_NAME }}
          password: ${{ secrets.DOCKER_HUB_PASSWORD }}

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract Docker metadata (sqlite)
        id: meta-sqlite
        uses: docker/metadata-action@v5
        with:
          images: |
            ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss-sqlite
            ghcr.io/cooderl/wewe-rss-sqlite
          tags: |
            type=raw,value=latest,enable=true
            type=raw,value=${{ github.ref_name }},enable=true
          flavor: latest=false

      - name: Build and push Docker image (sqlite)
        id: build-and-push-sqlite
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta-sqlite.outputs.tags }}
          labels: ${{ steps.meta-sqlite.outputs.labels }}
          target: app-sqlite
          platforms: linux/amd64,linux/arm64
          cache-from: type=gha,scope=docker-release
          cache-to: type=gha,mode=max,scope=docker-release

      - name: Extract Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: |
            ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss
            ghcr.io/cooderl/wewe-rss
          tags: |
            type=raw,value=latest,enable=true
            type=raw,value=${{ github.ref_name }},enable=true
          flavor: latest=false

      - name: Build and push Docker image
        id: build-and-push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          target: app
          platforms: linux/amd64,linux/arm64
          cache-from: type=gha,scope=docker-release
          cache-to: type=gha,mode=max,scope=docker-release

      - name: Set env
        run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV

      - name: Create a Release
        uses: elgohr/Github-Release-Action@v5
        env:
          GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
        with:
          title: ${{ env.RELEASE_VERSION }}

  description:
    runs-on: ubuntu-latest
    needs: check-env
    if: needs.check-env.outputs.check-docker == 'true'
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v4

      - name: Docker Hub Description(sqlite)
        uses: peter-evans/dockerhub-description@v4
        with:
          username: ${{ secrets.DOCKER_HUB_NAME }}
          password: ${{ secrets.DOCKER_HUB_PASSWORD }}
          repository: ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss-sqlite

      - name: Docker Hub Description
        uses: peter-evans/dockerhub-description@v4
        with:
          username: ${{ secrets.DOCKER_HUB_NAME }}
          password: ${{ secrets.DOCKER_HUB_PASSWORD }}
          repository: ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss


================================================
FILE: .gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*

# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage
*.lcov

# nyc test coverage
.nyc_output

# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# Bower dependency directory (https://bower.io/)
bower_components

# node-waf configuration
.lock-wscript

# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release

# Dependency directories
node_modules/
jspm_packages/

# Snowpack dependency directory (https://snowpack.dev/)
web_modules/

# TypeScript cache
*.tsbuildinfo

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Optional stylelint cache
.stylelintcache

# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity

# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local

# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache

# Next.js build output
.next
out

# Nuxt.js build / generate output
.nuxt
dist

# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public

# vuepress build output
.vuepress/dist

# vuepress v2.x temp and cache directory
.temp
.cache

# Docusaurus cache and generated files
.docusaurus

# Serverless directories
.serverless/

# FuseBox cache
.fusebox/

# DynamoDB Local files
.dynamodb/

# TernJS port file
.tern-port

# Stores VSCode versions used for testing VSCode extensions
.vscode-test

# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

.DS_Store


================================================
FILE: .markdownlint.yaml
================================================
# Default state for all rules
default: true

line-length: false

# MD033/no-inline-html - Inline HTML
MD033:
  # Allowed elements
  allowed_elements: ['style']


================================================
FILE: .npmrc
================================================
public-hoist-pattern[]=*@nextui-org/*
engine-strict=true
deploy-all-files=true


================================================
FILE: .prettierignore
================================================
**/*.log
**/.DS_Store
*.
*.json
apps/web/.next
dist
node_modules
pnpm-lock.yaml

================================================
FILE: .prettierrc.json
================================================
{
  "tabWidth": 2,
  "singleQuote": true,
  "trailingComma": "all"
}

================================================
FILE: .vscode/extensions.json
================================================
{
  "recommendations": [
    "esbenp.prettier-vscode",
    "dbaeumer.vscode-eslint",
    "stylelint.vscode-stylelint",
    "streetsidesoftware.code-spell-checker",
    "DavidAnson.vscode-markdownlint",
    "Gruntfuggly.todo-tree",
    "mikestead.dotenv",
    "foxundermoon.next-js",
    "Prisma.prisma",
    "planbcoding.vscode-react-refactor",
    "yoavbls.pretty-ts-errors",
    "usernamehw.errorlens"
  ]
}


================================================
FILE: .vscode/settings.json
================================================
{
  "typescript.tsdk": "node_modules/.pnpm/typescript@4.9.4/node_modules/typescript/lib",
  "typescript.enablePromptUseWorkspaceTsdk": true,
  "[javascript]": {
    "editor.formatOnSave": true,
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescript]": {
    "editor.formatOnSave": true,
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[html]": {
    "editor.formatOnSave": true,
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[scss]": {
    "editor.formatOnSave": true,
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[css]": {
    "editor.formatOnSave": true,
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[yaml]": {
    "editor.formatOnSave": true,
    "editor.defaultFormatter": "redhat.vscode-yaml"
  },
  "[json]": {
    "editor.formatOnSave": true,
    "editor.defaultFormatter": "vscode.json-language-features"
  },
  "cSpell.words": [
    "callout",
    "checkstyle",
    "commitlint",
    "daisyui",
    "nestjs",
    "nextui",
    "tailwindcss",
    "Trpc",
    "wewe"
  ]
}

================================================
FILE: Dockerfile
================================================
FROM node:20.16.0-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"

RUN npm i -g pnpm

FROM base AS build
COPY . /usr/src/app
WORKDIR /usr/src/app

RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile

RUN pnpm run -r build

RUN pnpm deploy --filter=server --prod /app
RUN pnpm deploy --filter=server --prod /app-sqlite

RUN cd /app && pnpm exec prisma generate

RUN cd /app-sqlite && \
    rm -rf ./prisma && \
    mv prisma-sqlite prisma && \
    pnpm exec prisma generate

FROM base AS app-sqlite
COPY --from=build /app-sqlite /app

WORKDIR /app

EXPOSE 4000

ENV NODE_ENV=production
ENV HOST="0.0.0.0"
ENV SERVER_ORIGIN_URL=""
ENV MAX_REQUEST_PER_MINUTE=60
ENV AUTH_CODE=""
ENV DATABASE_URL="file:../data/wewe-rss.db"
ENV DATABASE_TYPE="sqlite"

RUN chmod +x ./docker-bootstrap.sh

CMD ["./docker-bootstrap.sh"]


FROM base AS app
COPY --from=build /app /app

WORKDIR /app

EXPOSE 4000

ENV NODE_ENV=production
ENV HOST="0.0.0.0"
ENV SERVER_ORIGIN_URL=""
ENV MAX_REQUEST_PER_MINUTE=60
ENV AUTH_CODE=""
ENV DATABASE_URL=""

RUN chmod +x ./docker-bootstrap.sh

CMD ["./docker-bootstrap.sh"]

================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2024 cooderl

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

================================================
FILE: README.md
================================================
<div align="center">
<img src="https://raw.githubusercontent.com/cooderl/wewe-rss/main/assets/logo.png" width="80" alt="预览"/>

# [WeWe RSS](https://github.com/cooderl/wewe-rss)

更优雅的微信公众号订阅方式。

![主界面](https://raw.githubusercontent.com/cooderl/wewe-rss/main/assets/preview1.png)
</div>

## ✨ 功能

- v2.x版本使用全新接口,更加稳定
- 支持微信公众号订阅(基于微信读书)
- 获取公众号历史发布文章
- 后台自动定时更新内容
- 微信公众号RSS生成(支持`.atom`、`.rss`、`.json`格式)
- 支持全文内容输出,让阅读无障碍
- 所有订阅源导出OPML

### 高级功能

- **标题过滤**:支持通过`/feeds/all.(json|rss|atom)`接口和`/feeds/:feed`对标题进行过滤
  ```
  {{ORIGIN_URL}}/feeds/all.atom?title_include=张三
  {{ORIGIN_URL}}/feeds/MP_WXS_123.json?limit=30&title_include=张三|李四|王五&title_exclude=张三丰|赵六
  ```

- **手动更新**:支持通过`/feeds/:feed`接口触发单个feedid更新
  ```
  {{ORIGIN_URL}}/feeds/MP_WXS_123.rss?update=true
  ```

## 🚀 部署

### 一键部署

- [Deploy on Zeabur](https://zeabur.com/templates/DI9BBD)
- [Railway](https://railway.app/)
- [Hugging Face部署参考](https://github.com/cooderl/wewe-rss/issues/32)

### Docker Compose 部署

参考 [docker-compose.yml](https://github.com/cooderl/wewe-rss/blob/main/docker-compose.yml) 和 [docker-compose.sqlite.yml](https://github.com/cooderl/wewe-rss/blob/main/docker-compose.sqlite.yml)

### Docker 命令启动

#### MySQL (推荐)

1. 创建docker网络
   ```sh
   docker network create wewe-rss
   ```

2. 启动 MySQL 数据库
   ```sh
   docker run -d \
     --name db \
     -e MYSQL_ROOT_PASSWORD=123456 \
     -e TZ='Asia/Shanghai' \
     -e MYSQL_DATABASE='wewe-rss' \
     -v db_data:/var/lib/mysql \
     --network wewe-rss \
     mysql:8.3.0 --mysql-native-password=ON
   ```

3. 启动 Server
   ```sh
   docker run -d \
     --name wewe-rss \
     -p 4000:4000 \
     -e DATABASE_URL='mysql://root:123456@db:3306/wewe-rss?schema=public&connect_timeout=30&pool_timeout=30&socket_timeout=30' \
     -e AUTH_CODE=123567 \
     --network wewe-rss \
     cooderl/wewe-rss:latest
   ```

[Nginx配置参考](https://raw.githubusercontent.com/cooderl/wewe-rss/main/assets/nginx.example.conf)

#### SQLite (不推荐)

```sh
docker run -d \
  --name wewe-rss \
  -p 4000:4000 \
  -e DATABASE_TYPE=sqlite \
  -e AUTH_CODE=123567 \
  -v $(pwd)/data:/app/data \
  cooderl/wewe-rss-sqlite:latest
```

### 本地部署

使用 `pnpm install && pnpm run -r build && pnpm run start:server` 命令 (可配合 pm2 守护进程)

**详细步骤** (SQLite示例):

```shell
# 需要提前声明环境变量,因为prisma会根据环境变量生成对应的数据库连接
export DATABASE_URL="file:../data/wewe-rss.db"
export DATABASE_TYPE="sqlite"
# 删除mysql相关文件,避免prisma生成mysql连接
rm -rf apps/server/prisma
mv apps/server/prisma-sqlite apps/server/prisma
# 生成prisma client
npx prisma generate --schema apps/server/prisma/schema.prisma
# 生成数据库表
npx prisma migrate deploy --schema apps/server/prisma/schema.prisma
# 构建并运行
pnpm run -r build
pnpm run start:server
```

## ⚙️ 环境变量

| 变量名                   | 说明                                                                    | 默认值                      |
| ------------------------ | ----------------------------------------------------------------------- | --------------------------- |
| `DATABASE_URL`           | **必填** 数据库地址,例如 `mysql://root:123456@127.0.0.1:3306/wewe-rss` | -                           |
| `DATABASE_TYPE`          | 数据库类型,使用 SQLite 时需填写 `sqlite`                               | -                           |
| `AUTH_CODE`              | 服务端接口请求授权码,空字符或不设置将不启用 (`/feeds`路径不需要)       | -                           |
| `SERVER_ORIGIN_URL`      | 服务端访问地址,用于生成RSS完整路径                                     | -                           |
| `MAX_REQUEST_PER_MINUTE` | 每分钟最大请求次数                                                      | 60                          |
| `FEED_MODE`              | 输出模式,可选值 `fulltext` (会使接口响应变慢,占用更多内存)            | -                           |
| `CRON_EXPRESSION`        | 定时更新订阅源Cron表达式                                                | `35 5,17 * * *`             |
| `UPDATE_DELAY_TIME`      | 连续更新延迟时间,减少被关小黑屋                                        | `60s`                       |
| `ENABLE_CLEAN_HTML`      | 是否开启正文html清理                                                    | `false`                     |
| `PLATFORM_URL`           | 基础服务URL                                                             | `https://weread.111965.xyz` |

> **注意**: 国内DNS解析问题可使用 `https://weread.965111.xyz` 加速访问

## 🔔 钉钉通知

进入 wewe-rss-dingtalk 目录按照 README.md 指引部署

## 📱 使用方式

1. 进入账号管理,点击添加账号,微信扫码登录微信读书账号。
  
   **注意不要勾选24小时后自动退出**
   
   <img width="400" src="./assets/preview2.png"/>


2. 进入公众号源,点击添加,通过提交微信公众号分享链接,订阅微信公众号。
   **添加频率过高容易被封控,等24小时解封**

   <img width="400" src="./assets/preview3.png"/>

## 🔑 账号状态说明

| 状态       | 说明                                                                |
| ---------- | ------------------------------------------------------------------- |
| 今日小黑屋 | 账号被封控,等一天恢复。账号正常时可通过重启服务/容器清除小黑屋记录 |
| 禁用       | 不使用该账号                                                        |
| 失效       | 账号登录状态失效,需要重新登录                                      |

## 💻 本地开发

1. 安装 nodejs 20 和 pnpm
2. 修改环境变量:
   ```
   cp ./apps/web/.env.local.example ./apps/web/.env
   cp ./apps/server/.env.local.example ./apps/server/.env
   ```
3. 执行 `pnpm install && pnpm run build:web && pnpm dev` 
   
   ⚠️ **注意:此命令仅用于本地开发,不要用于部署!**
4. 前端访问 `http://localhost:5173`,后端访问 `http://localhost:4000`

## ⚠️ 风险声明

为了确保本项目的持久运行,某些接口请求将通过 `weread.111965.xyz` 进行转发。请放心,该转发服务不会保存任何数据。

## ❤️ 赞助

如果觉得 WeWe RSS 项目对你有帮助,可以给我来一杯啤酒!

**PayPal**: [paypal.me/cooderl](https://paypal.me/cooderl)

## 👨‍💻 贡献者

<a href="https://github.com/cooderl/wewe-rss/graphs/contributors">
  <img src="https://contrib.rocks/image?repo=cooderl/wewe-rss" />
</a>

## 📄 License

[MIT](https://raw.githubusercontent.com/cooderl/wewe-rss/main/LICENSE) @cooderl


================================================
FILE: apps/server/.eslintrc.js
================================================
module.exports = {
  parser: '@typescript-eslint/parser',
  parserOptions: {
    project: 'tsconfig.json',
    tsconfigRootDir: __dirname,
    sourceType: 'module',
  },
  plugins: ['@typescript-eslint/eslint-plugin'],
  extends: [
    'plugin:@typescript-eslint/recommended',
    'plugin:prettier/recommended',
  ],
  root: true,
  env: {
    node: true,
    jest: true,
  },
  ignorePatterns: ['.eslintrc.js'],
  rules: {
    '@typescript-eslint/interface-name-prefix': 'off',
    '@typescript-eslint/explicit-function-return-type': 'off',
    '@typescript-eslint/explicit-module-boundary-types': 'off',
    '@typescript-eslint/no-explicit-any': 'off',
  },
};


================================================
FILE: apps/server/.gitignore
================================================
node_modules
# Keep environment variables out of version control
.env

client
data

================================================
FILE: apps/server/.prettierrc.json
================================================
{
  "tabWidth": 2,
  "singleQuote": true,
  "trailingComma": "all"
}

================================================
FILE: apps/server/README.md
================================================
<p align="center">
  <a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
</p>

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

  <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>
    <p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<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>
<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>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
  <a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
    <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>
  <a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
</p>
  <!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
  [![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->

## Description

[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.

## Installation

```bash
$ pnpm install
```

## Running the app

```bash
# development
$ pnpm run start

# watch mode
$ pnpm run start:dev

# production mode
$ pnpm run start:prod
```

## Test

```bash
# unit tests
$ pnpm run test

# e2e tests
$ pnpm run test:e2e

# test coverage
$ pnpm run test:cov
```

## Support

Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).

## Stay in touch

- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)

## License

Nest is [MIT licensed](LICENSE).


================================================
FILE: apps/server/docker-bootstrap.sh
================================================

#!/bin/sh
# ENVIRONEMTN from docker-compose.yaml doesn't get through to subprocesses
# Need to explicit pass DATABASE_URL here, otherwise migration doesn't work
# Run migrations
DATABASE_URL=${DATABASE_URL} npx prisma migrate deploy
# start app
DATABASE_URL=${DATABASE_URL} node dist/main

================================================
FILE: apps/server/nest-cli.json
================================================
{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "deleteOutDir": true
  }
}


================================================
FILE: apps/server/package.json
================================================
{
  "name": "server",
  "version": "2.6.1",
  "description": "",
  "author": "",
  "private": true,
  "license": "UNLICENSED",
  "scripts": {
    "build": "nest build",
    "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
    "start": "nest start",
    "dev": "nest start --watch",
    "start:debug": "nest start --debug --watch",
    "start:prod": "node dist/main",
    "start:migrate:prod": "prisma migrate deploy && npm run start:prod",
    "postinstall": "npx prisma generate",
    "migrate": "pnpm prisma migrate dev",
    "studio": "pnpm prisma studio",
    "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage",
    "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
    "test:e2e": "jest --config ./test/jest-e2e.json"
  },
  "dependencies": {
    "@cjs-exporter/p-map": "^5.5.0",
    "@nestjs/common": "^10.3.3",
    "@nestjs/config": "^3.2.0",
    "@nestjs/core": "^10.3.3",
    "@nestjs/platform-express": "^10.3.3",
    "@nestjs/schedule": "^4.0.1",
    "@nestjs/throttler": "^5.1.2",
    "@prisma/client": "5.10.1",
    "@trpc/server": "^10.45.1",
    "axios": "^1.6.7",
    "cheerio": "1.0.0-rc.12",
    "class-transformer": "^0.5.1",
    "class-validator": "^0.14.1",
    "dayjs": "^1.11.10",
    "express": "^4.18.2",
    "feed": "^4.2.2",
    "got": "11.8.6",
    "hbs": "^4.2.0",
    "html-minifier": "^4.0.0",
    "lru-cache": "^10.2.2",
    "prisma": "^5.10.2",
    "reflect-metadata": "^0.2.1",
    "rxjs": "^7.8.1",
    "zod": "^3.22.4"
  },
  "devDependencies": {
    "@nestjs/cli": "^10.3.2",
    "@nestjs/schematics": "^10.1.1",
    "@nestjs/testing": "^10.3.3",
    "@types/express": "^4.17.21",
    "@types/html-minifier": "^4.0.5",
    "@types/jest": "^29.5.12",
    "@types/node": "^20.11.19",
    "@types/supertest": "^6.0.2",
    "@typescript-eslint/eslint-plugin": "^7.0.2",
    "@typescript-eslint/parser": "^7.0.2",
    "eslint": "^8.56.0",
    "eslint-config-prettier": "^9.1.0",
    "eslint-plugin-prettier": "^5.1.3",
    "jest": "^29.7.0",
    "prettier": "^3.2.5",
    "source-map-support": "^0.5.21",
    "supertest": "^6.3.4",
    "ts-jest": "^29.1.2",
    "ts-loader": "^9.5.1",
    "ts-node": "^10.9.2",
    "tsconfig-paths": "^4.2.0",
    "typescript": "^5.3.3"
  },
  "jest": {
    "moduleFileExtensions": [
      "js",
      "json",
      "ts"
    ],
    "rootDir": "src",
    "testRegex": ".*\\.spec\\.ts$",
    "transform": {
      "^.+\\.(t|j)s$": "ts-jest"
    },
    "collectCoverageFrom": [
      "**/*.(t|j)s"
    ],
    "coverageDirectory": "../coverage",
    "testEnvironment": "node"
  }
}

================================================
FILE: apps/server/prisma/migrations/20240227153512_init/migration.sql
================================================
-- CreateTable
CREATE TABLE `accounts` (
    `id` VARCHAR(255) NOT NULL,
    `token` VARCHAR(2048) NOT NULL,
    `name` VARCHAR(1024) NOT NULL,
    `status` INTEGER NOT NULL DEFAULT 1,
    `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    `updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3),

    PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- CreateTable
CREATE TABLE `feeds` (
    `id` VARCHAR(255) NOT NULL,
    `mp_name` VARCHAR(512) NOT NULL,
    `mp_cover` VARCHAR(1024) NOT NULL,
    `mp_intro` TEXT NOT NULL,
    `status` INTEGER NOT NULL DEFAULT 1,
    `sync_time` INTEGER NOT NULL DEFAULT 0,
    `update_time` INTEGER NOT NULL,
    `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    `updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3),

    PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- CreateTable
CREATE TABLE `articles` (
    `id` VARCHAR(255) NOT NULL,
    `mp_id` VARCHAR(255) NOT NULL,
    `title` VARCHAR(255) NOT NULL,
    `pic_url` VARCHAR(255) NOT NULL,
    `publish_time` INTEGER NOT NULL,
    `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    `updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3),

    PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;


================================================
FILE: apps/server/prisma/migrations/20241212153618_has_history/migration.sql
================================================
-- AlterTable
ALTER TABLE `feeds` ADD COLUMN `has_history` INTEGER NULL DEFAULT 1;


================================================
FILE: apps/server/prisma/migrations/migration_lock.toml
================================================
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "mysql"

================================================
FILE: apps/server/prisma/schema.prisma
================================================
datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

generator client {
  provider      = "prisma-client-js"
  binaryTargets = ["native", "linux-musl"] // 生成linux可执行文件
}

// 读书账号
model Account {
  id        String    @id @db.VarChar(255)
  token     String    @map("token") @db.VarChar(2048)
  name      String    @map("name") @db.VarChar(1024)
  // 状态 0:失效 1:启用 2:禁用
  status    Int       @default(1) @map("status") @db.Int()
  createdAt DateTime  @default(now()) @map("created_at")
  updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")

  @@map("accounts")
}

// 订阅源
model Feed {
  id      String @id @db.VarChar(255)
  mpName  String @map("mp_name") @db.VarChar(512)
  mpCover String @map("mp_cover") @db.VarChar(1024)
  mpIntro String @map("mp_intro") @db.Text()
  // 状态 0:失效 1:启用 2:禁用
  status  Int    @default(1) @map("status") @db.Int()

  // article最后同步时间
  syncTime Int @default(0) @map("sync_time")

  // 信息更新时间
  updateTime Int @map("update_time")

  createdAt DateTime  @default(now()) @map("created_at")
  updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")

  // 是否有历史文章 1 是  0 否
  hasHistory Int? @default(1) @map("has_history")

  @@map("feeds")
}

model Article {
  id          String @id @db.VarChar(255)
  mpId        String @map("mp_id") @db.VarChar(255)
  title       String @map("title") @db.VarChar(255)
  picUrl      String @map("pic_url") @db.VarChar(255)
  publishTime Int    @map("publish_time")

  createdAt DateTime  @default(now()) @map("created_at")
  updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")

  @@map("articles")
}


================================================
FILE: apps/server/prisma-sqlite/migrations/20240301104100_init/migration.sql
================================================
-- CreateTable
CREATE TABLE "accounts" (
    "id" TEXT NOT NULL PRIMARY KEY,
    "token" TEXT NOT NULL,
    "name" TEXT NOT NULL,
    "status" INTEGER NOT NULL DEFAULT 1,
    "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- CreateTable
CREATE TABLE "feeds" (
    "id" TEXT NOT NULL PRIMARY KEY,
    "mp_name" TEXT NOT NULL,
    "mp_cover" TEXT NOT NULL,
    "mp_intro" TEXT NOT NULL,
    "status" INTEGER NOT NULL DEFAULT 1,
    "sync_time" INTEGER NOT NULL DEFAULT 0,
    "update_time" INTEGER NOT NULL,
    "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- CreateTable
CREATE TABLE "articles" (
    "id" TEXT NOT NULL PRIMARY KEY,
    "mp_id" TEXT NOT NULL,
    "title" TEXT NOT NULL,
    "pic_url" TEXT NOT NULL,
    "publish_time" INTEGER NOT NULL,
    "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP
);


================================================
FILE: apps/server/prisma-sqlite/migrations/20241214172323_has_history/migration.sql
================================================
-- AlterTable
ALTER TABLE "feeds" ADD COLUMN "has_history" INTEGER DEFAULT 1;


================================================
FILE: apps/server/prisma-sqlite/migrations/migration_lock.toml
================================================
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

================================================
FILE: apps/server/prisma-sqlite/schema.prisma
================================================
datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

generator client {
  provider      = "prisma-client-js"
  binaryTargets = ["native", "linux-musl"] // 生成linux可执行文件
}

// 读书账号
model Account {
  id        String    @id
  token     String    @map("token")
  name      String    @map("name")
  // 状态 0:失效 1:启用 2:禁用
  status    Int       @default(1) @map("status")
  createdAt DateTime  @default(now()) @map("created_at")
  updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")

  @@map("accounts")
}

// 订阅源
model Feed {
  id      String @id
  mpName  String @map("mp_name")
  mpCover String @map("mp_cover")
  mpIntro String @map("mp_intro")
  // 状态 0:失效 1:启用 2:禁用
  status  Int    @default(1) @map("status")

  // article最后同步时间
  syncTime Int @default(0) @map("sync_time")

  // 信息更新时间
  updateTime Int @map("update_time")

  createdAt DateTime  @default(now()) @map("created_at")
  updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")

  // 是否有历史文章 1 是  0 否
  hasHistory Int? @default(1) @map("has_history")

  @@map("feeds")
}

model Article {
  id          String @id
  mpId        String @map("mp_id")
  title       String @map("title")
  picUrl      String @map("pic_url")
  publishTime Int    @map("publish_time")

  createdAt DateTime  @default(now()) @map("created_at")
  updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")

  @@map("articles")
}


================================================
FILE: apps/server/src/app.controller.spec.ts
================================================
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';

describe('AppController', () => {
  let appController: AppController;

  beforeEach(async () => {
    const app: TestingModule = await Test.createTestingModule({
      controllers: [AppController],
      providers: [AppService],
    }).compile();

    appController = app.get<AppController>(AppController);
  });

  describe('root', () => {
    it('should return "Hello World!"', () => {
      expect(appController.getHello()).toBe('Hello World!');
    });
  });
});


================================================
FILE: apps/server/src/app.controller.ts
================================================
import { Controller, Get, Response, Render } from '@nestjs/common';
import { AppService } from './app.service';
import { ConfigService } from '@nestjs/config';
import { ConfigurationType } from './configuration';
import { Response as Res } from 'express';

@Controller()
export class AppController {
  constructor(
    private readonly appService: AppService,
    private readonly configService: ConfigService,
  ) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }

  @Get('/robots.txt')
  forRobot(): string {
    return 'User-agent:  *\nDisallow:  /';
  }

  @Get('favicon.ico')
  getFavicon(@Response() res: Res) {
    const imgContent =
      'iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAMAAABrrFhUAAAAAXNSR0IArs4c6QAAACRQTFRFR3BMsN2eke1itNumku5htNulm+l0ke1hc91PVc09OL0rGq0Z17o6fwAAAAV0Uk5TAGyAv79qLUngAAAFdUlEQVR42u3cQWPbIAyGYQlDkOT//3/X9bBLF3/gkgQJ3uuSA4+Ftxp3tNvtdrvdbrfb7Xa76zjNGjG9Ns65zl5O6WWrr15K0ZePS0xjSxUUewq4Oixz8MuPSw7W70EgVb+lMetfWiBV36Xg68cx/arqvhx8AHBpwPqX3QQ1RHnAACw6AjVI+f4ArD0CNUz57gCsPQI1UHl1gBp8B+B4A3RXQ/Uo3GnANVallD6DFA3gO14ZABBEB3j0CuRg6/8HUI6YAHgCgEB8gE6BGhigHKsDFF4doPDqAIVXBzhWByi8OsCxOkDh1QGO1QEKb4DFAY7VAcryAPxKADE7v7KvVFVkRoDjhQB6/shUZRkAPZ9kKvMAlJcB6HmVqkwCwK8CsBOlsQHOhkyjA+BUgwLI2ZxGnwCcRr8J4jQ6AE6jAdSzNw0GIP0CGgqg6tmdugLAieh3ZtZM4BUAJ6pqDQKuAXANCOoeACMAgeAA2MCiA2ADjQCAUyAQGAATaHAATGDBATCBSXAATCDBAbCABgfABLIMQBUDAh4B/p0NqqrcHAJxDACOg9oELNgDEdXebWBuAcCTr2Y0cwAA1gIM0LfUJYCe12nH9yT66TAWCHo0pq0CFgygX0DjHo83Ckjcs0FtEwgG0C9grgD635DAfhL5cFQbBCz04ag2+OlsADi1DgHsNy0APiE2GyFgDgCGngj+UBPPANhA4W3AXANgA4WbQHwD4OMwtAks+vsBijaB+AbAQyBoBHwDYAKDI+AbAP+0ZADKnAPgIVDwXEGcA2ABuf6Qhn9Fxq5HwLwD4B+Z9VpJvAPgW6GAEXAOgGfArkfAPQAWkMtPiHOA/nMQA3vAA4B8BwRaR8AbgJhdnwobGoEfPJ4AxG49Awd7wA2AWNMTYDAC4hZA7jz9wyPgAAC8/4ih7ApAnADozad/eA/MB4DnH1xD8AmXAHoBYEAL7AEXAHpeJfA+CG4C3n93GI+AXPyp+n8/AI+AXXBagPcErQ/A3AHY+ds94BzgRAn6hlwMVAgANDN6MR8SAQDtAXMNIP0AteOvAQ0xAWgPRAeAUyPPdSzAm6J1AyAAdQ0gN96PDQVQBwOoLwC8Bxq+Ys8BTvcvS2tsADwCNTQAFpD6v/QCQBwCSMcGwM99/PxLEAtovQFgXgCwgNRnXX1OZ3wegFP0f6O0X2Vz8FAUvxhs0jwxTzDnPRrDBibSPjDy5FdwzHy+IiONWA2T4gqgP1UzlVpDA+A2wAbYABtgA2yADbABNsAG2ACfA8jB1t8PsCdg8QlINVZlA3QC8OoAFPweiAHy6gAcewdgAFoeIMfeARiA1wGIPwIFAEQfgQcACD8C5SYAxx4ADEA59gAUggUbgH4ADr3+QrgUeAMUphUEHgAAlsKuv1BbKer6meILPMoIAOKQ6y/UUQq4fqaeUoq2/kKdpVjLL0zdpRx9/biUfB2EYYD+0lc5+7v4eP39cSll2DUbVGmKaUzHKIDy3phomMCYmX1zNCwuDtd/MI2L/V3+g4bmbv1MMwE8ivf1k7PxZxpd8OXjfO3+mQBcXf3xAA9Xqx8PkI+Wfrnq7/grIpoLIDM1xceYLT8bQKLmOCBAZuqIwwEk6oxjATB1x3MD5NpRplsdUQCYbsYhADLT7TgAQKJfxbMCpDGXH8eTAvCoy4/jKQFo2OXHsVOARKPiY0KAXEFMA+P5ABiMP42NpwMgMP7D49kAMrj7DY8nA2B0+cd3TAVAGVz+Dw0BvS0Gl/9DAvS+GFz+jxAc9MYSuPyfEGD6nECi98QA4DMEOTPRBAL09tLf3uzOBxiA+DEYgFUFmGhtAqK1BZgWi8H61yI4mJaM+SjlOJhpt9vtdrvdbrfbNfcHKaL2IynIYcEAAAAASUVORK5CYII=';
    const imgBuffer = Buffer.from(imgContent, 'base64');
    res.setHeader('Content-Type', 'image/png');
    res.send(imgBuffer);
  }

  @Get('/dash*')
  @Render('index.hbs')
  dashRender() {
    const { originUrl: weweRssServerOriginUrl } =
      this.configService.get<ConfigurationType['feed']>('feed')!;
    const { code } = this.configService.get<ConfigurationType['auth']>('auth')!;

    return {
      weweRssServerOriginUrl,
      enabledAuthCode: !!code,
      iconUrl: weweRssServerOriginUrl
        ? `${weweRssServerOriginUrl}/favicon.ico`
        : 'https://r2-assets.111965.xyz/wewe-rss.png',
    };
  }
}


================================================
FILE: apps/server/src/app.module.ts
================================================
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TrpcModule } from '@server/trpc/trpc.module';
import { ConfigModule, ConfigService } from '@nestjs/config';
import configuration, { ConfigurationType } from './configuration';
import { ThrottlerModule } from '@nestjs/throttler';
import { ScheduleModule } from '@nestjs/schedule';
import { FeedsModule } from './feeds/feeds.module';

@Module({
  imports: [
    TrpcModule,
    FeedsModule,
    ScheduleModule.forRoot(),
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: ['.env.local', '.env'],
      load: [configuration],
    }),
    ThrottlerModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory(config: ConfigService) {
        const throttler =
          config.get<ConfigurationType['throttler']>('throttler');
        return [
          {
            ttl: 60,
            limit: throttler?.maxRequestPerMinute || 60,
          },
        ];
      },
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}


================================================
FILE: apps/server/src/app.service.ts
================================================
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class AppService {
  constructor(private readonly configService: ConfigService) {}
  getHello(): string {
    return `
    <div style="display:flex;justify-content: center;height: 100%;align-items: center;font-size: 30px;">
    <div>>> <a href="/dash">WeWe RSS</a> <<</div>
    </div>
    `;
  }
}


================================================
FILE: apps/server/src/configuration.ts
================================================
const configuration = () => {
  const isProd = process.env.NODE_ENV === 'production';
  const port = process.env.PORT || 4000;
  const host = process.env.HOST || '0.0.0.0';

  const maxRequestPerMinute = parseInt(
    `${process.env.MAX_REQUEST_PER_MINUTE}|| 60`,
  );

  const authCode = process.env.AUTH_CODE;
  const platformUrl = process.env.PLATFORM_URL || 'https://weread.111965.xyz';
  const originUrl = process.env.SERVER_ORIGIN_URL || '';

  const feedMode = process.env.FEED_MODE as 'fulltext' | '';

  const databaseType = process.env.DATABASE_TYPE || 'mysql';

  const updateDelayTime = parseInt(`${process.env.UPDATE_DELAY_TIME} || 60`);

  const enableCleanHtml = process.env.ENABLE_CLEAN_HTML === 'true';
  return {
    server: { isProd, port, host },
    throttler: { maxRequestPerMinute },
    auth: { code: authCode },
    platform: { url: platformUrl },
    feed: {
      originUrl,
      mode: feedMode,
      updateDelayTime,
      enableCleanHtml,
    },
    database: {
      type: databaseType,
    },
  };
};

export default configuration;

export type ConfigurationType = ReturnType<typeof configuration>;


================================================
FILE: apps/server/src/constants.ts
================================================
export const statusMap = {
  // 0:失效 1:启用 2:禁用
  INVALID: 0,
  ENABLE: 1,
  DISABLE: 2,
};

export const feedTypes = ['rss', 'atom', 'json'] as const;

export const feedMimeTypeMap = {
  rss: 'application/rss+xml; charset=utf-8',
  atom: 'application/atom+xml; charset=utf-8',
  json: 'application/feed+json; charset=utf-8',
} as const;

export const defaultCount = 20;


================================================
FILE: apps/server/src/feeds/feeds.controller.spec.ts
================================================
import { Test, TestingModule } from '@nestjs/testing';
import { FeedsController } from './feeds.controller';

describe('FeedsController', () => {
  let controller: FeedsController;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [FeedsController],
    }).compile();

    controller = module.get<FeedsController>(FeedsController);
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });
});


================================================
FILE: apps/server/src/feeds/feeds.controller.ts
================================================
import {
  Controller,
  DefaultValuePipe,
  Get,
  Logger,
  Param,
  ParseIntPipe,
  Query,
  Request,
  Response,
} from '@nestjs/common';
import { FeedsService } from './feeds.service';
import { Response as Res, Request as Req } from 'express';

@Controller('feeds')
export class FeedsController {
  private readonly logger = new Logger(this.constructor.name);

  constructor(private readonly feedsService: FeedsService) {}

  @Get('/')
  async getFeedList() {
    return this.feedsService.getFeedList();
  }

  @Get('/all.(json|rss|atom)')
  async getFeeds(
    @Request() req: Req,
    @Response() res: Res,
    @Query('limit', new DefaultValuePipe(30), ParseIntPipe) limit: number = 30,
    @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number = 1,
    @Query('mode') mode: string,
    @Query('title_include') title_include: string,
    @Query('title_exclude') title_exclude: string,
  ) {
    const path = req.path;
    const type = path.split('.').pop() || '';

    const { content, mimeType } = await this.feedsService.handleGenerateFeed({
      type,
      limit,
      page,
      mode,
      title_include,
      title_exclude,
    });

    res.setHeader('Content-Type', mimeType);
    res.send(content);
  }

  @Get('/:feed')
  async getFeed(
    @Response() res: Res,
    @Param('feed') feed: string,
    @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number = 10,
    @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number = 1,
    @Query('mode') mode: string,
    @Query('title_include') title_include: string,
    @Query('title_exclude') title_exclude: string,
    @Query('update') update: boolean = false,
  ) {
    const [id, type] = feed.split('.');
    this.logger.log('getFeed: ', id);

    if (update) {
      this.feedsService.updateFeed(id);
    }

    const { content, mimeType } = await this.feedsService.handleGenerateFeed({
      id,
      type,
      limit,
      page,
      mode,
      title_include,
      title_exclude,
    });

    res.setHeader('Content-Type', mimeType);
    res.send(content);
  }
}


================================================
FILE: apps/server/src/feeds/feeds.module.ts
================================================
import { Module } from '@nestjs/common';
import { FeedsController } from './feeds.controller';
import { FeedsService } from './feeds.service';
import { PrismaModule } from '@server/prisma/prisma.module';
import { TrpcModule } from '@server/trpc/trpc.module';

@Module({
  imports: [PrismaModule, TrpcModule],
  controllers: [FeedsController],
  providers: [FeedsService],
})
export class FeedsModule {}


================================================
FILE: apps/server/src/feeds/feeds.service.spec.ts
================================================
import { Test, TestingModule } from '@nestjs/testing';
import { FeedsService } from './feeds.service';

describe('FeedsService', () => {
  let service: FeedsService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [FeedsService],
    }).compile();

    service = module.get<FeedsService>(FeedsService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });
});


================================================
FILE: apps/server/src/feeds/feeds.service.ts
================================================
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '@server/prisma/prisma.service';
import { Cron } from '@nestjs/schedule';
import { TrpcService } from '@server/trpc/trpc.service';
import { feedMimeTypeMap, feedTypes } from '@server/constants';
import { ConfigService } from '@nestjs/config';
import { Article, Feed as FeedInfo } from '@prisma/client';
import { ConfigurationType } from '@server/configuration';
import { Feed, Item } from 'feed';
import got, { Got } from 'got';
import { load } from 'cheerio';
import { minify } from 'html-minifier';
import { LRUCache } from 'lru-cache';
import pMap from '@cjs-exporter/p-map';

console.log('CRON_EXPRESSION: ', process.env.CRON_EXPRESSION);

const mpCache = new LRUCache<string, string>({
  max: 5000,
});

@Injectable()
export class FeedsService {
  private readonly logger = new Logger(this.constructor.name);

  private request: Got;
  constructor(
    private readonly prismaService: PrismaService,
    private readonly trpcService: TrpcService,
    private readonly configService: ConfigService,
  ) {
    this.request = got.extend({
      retry: {
        limit: 3,
        methods: ['GET'],
      },
      timeout: 8 * 1e3,
      headers: {
        accept:
          'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
        'accept-encoding': 'gzip, deflate, br',
        'accept-language': 'en-US,en;q=0.9',
        'cache-control': 'max-age=0',
        'sec-ch-ua':
          '" Not A;Brand";v="99", "Chromium";v="101", "Google Chrome";v="101"',
        'sec-ch-ua-mobile': '?0',
        'sec-ch-ua-platform': '"macOS"',
        'sec-fetch-dest': 'document',
        'sec-fetch-mode': 'navigate',
        'sec-fetch-site': 'none',
        'sec-fetch-user': '?1',
        'upgrade-insecure-requests': '1',
        'user-agent':
          'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36',
      },
      hooks: {
        beforeRetry: [
          async (options, error, retryCount) => {
            this.logger.warn(`retrying ${options.url}...`);
            return new Promise((resolve) =>
              setTimeout(resolve, 2e3 * (retryCount || 1)),
            );
          },
        ],
      },
    });
  }

  @Cron(process.env.CRON_EXPRESSION || '35 5,17 * * *', {
    name: 'updateFeeds',
    timeZone: 'Asia/Shanghai',
  })
  async handleUpdateFeedsCron() {
    this.logger.debug('Called handleUpdateFeedsCron');

    const feeds = await this.prismaService.feed.findMany({
      where: { status: 1 },
    });
    this.logger.debug('feeds length:' + feeds.length);

    const updateDelayTime =
      this.configService.get<ConfigurationType['feed']>(
        'feed',
      )!.updateDelayTime;

    for (const feed of feeds) {
      this.logger.debug('feed', feed.id);
      try {
        await this.trpcService.refreshMpArticlesAndUpdateFeed(feed.id);

        await new Promise((resolve) =>
          setTimeout(resolve, updateDelayTime * 1e3),
        );
      } catch (err) {
        this.logger.error('handleUpdateFeedsCron error', err);
      } finally {
        // wait 30s for next feed
        await new Promise((resolve) => setTimeout(resolve, 30 * 1e3));
      }
    }
  }

  async cleanHtml(source: string) {
    const $ = load(source, { decodeEntities: false });

    const dirtyHtml = $.html($('.rich_media_content'));

    const html = dirtyHtml
      .replace(/data-src=/g, 'src=')
      .replace(/opacity: 0( !important)?;/g, '')
      .replace(/visibility: hidden;/g, '');

    const content =
      '<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>' +
      html;

    const result = minify(content, {
      removeAttributeQuotes: true,
      collapseWhitespace: true,
    });

    return result;
  }

  async getHtmlByUrl(url: string) {
    const html = await this.request(url, { responseType: 'text' }).text();
    if (
      this.configService.get<ConfigurationType['feed']>('feed')!.enableCleanHtml
    ) {
      const result = await this.cleanHtml(html);
      return result;
    }

    return html;
  }

  async tryGetContent(id: string) {
    let content = mpCache.get(id);
    if (content) {
      return content;
    }
    const url = `https://mp.weixin.qq.com/s/${id}`;
    content = await this.getHtmlByUrl(url).catch((e) => {
      this.logger.error(`getHtmlByUrl(${url}) error: ${e.message}`);

      return '获取全文失败,请重试~';
    });
    mpCache.set(id, content);
    return content;
  }

  async renderFeed({
    type,
    feedInfo,
    articles,
    mode,
  }: {
    type: string;
    feedInfo: FeedInfo;
    articles: Article[];
    mode?: string;
  }) {
    const { originUrl, mode: globalMode } =
      this.configService.get<ConfigurationType['feed']>('feed')!;

    const link = `${originUrl}/feeds/${feedInfo.id}.${type}`;

    const feed = new Feed({
      title: feedInfo.mpName,
      description: feedInfo.mpIntro,
      id: link,
      link: link,
      language: 'zh-cn', // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes
      image: feedInfo.mpCover,
      favicon: feedInfo.mpCover,
      copyright: '',
      updated: new Date(feedInfo.updateTime * 1e3),
      generator: 'WeWe-RSS',
      author: { name: feedInfo.mpName },
    });

    feed.addExtension({
      name: 'generator',
      objects: `WeWe-RSS`,
    });

    const feeds = await this.prismaService.feed.findMany({
      select: { id: true, mpName: true },
    });

    /**mode 高于 globalMode。如果 mode 值存在,取 mode 值*/
    const enableFullText =
      typeof mode === 'string'
        ? mode === 'fulltext'
        : globalMode === 'fulltext';

    const showAuthor = feedInfo.id === 'all';

    const mapper = async (item) => {
      const { title, id, publishTime, picUrl, mpId } = item;
      const link = `https://mp.weixin.qq.com/s/${id}`;

      const mpName = feeds.find((item) => item.id === mpId)?.mpName || '-';
      const published = new Date(publishTime * 1e3);

      let content = '';
      if (enableFullText) {
        content = await this.tryGetContent(id);
      }

      feed.addItem({
        id,
        title,
        link: link,
        guid: link,
        content,
        date: published,
        image: picUrl,
        author: showAuthor ? [{ name: mpName }] : undefined,
      });
    };

    await pMap(articles, mapper, { concurrency: 2, stopOnError: false });

    return feed;
  }

  async handleGenerateFeed({
    id,
    type,
    limit,
    page,
    mode,
    title_include,
    title_exclude,
  }: {
    id?: string;
    type: string;
    limit: number;
    page: number;
    mode?: string;
    title_include?: string;
    title_exclude?: string;
  }) {
    if (!feedTypes.includes(type as any)) {
      type = 'atom';
    }

    let articles: Article[];
    let feedInfo: FeedInfo;
    if (id) {
      feedInfo = (await this.prismaService.feed.findFirst({
        where: { id },
      }))!;

      if (!feedInfo) {
        throw new HttpException('不存在该feed!', HttpStatus.BAD_REQUEST);
      }

      articles = await this.prismaService.article.findMany({
        where: { mpId: id },
        orderBy: { publishTime: 'desc' },
        take: limit,
        skip: (page - 1) * limit,
      });
    } else {
      articles = await this.prismaService.article.findMany({
        orderBy: { publishTime: 'desc' },
        take: limit,
        skip: (page - 1) * limit,
      });

      const { originUrl } =
        this.configService.get<ConfigurationType['feed']>('feed')!;
      feedInfo = {
        id: 'all',
        mpName: 'WeWe-RSS All',
        mpIntro: 'WeWe-RSS 全部文章',
        mpCover: originUrl
          ? `${originUrl}/favicon.ico`
          : 'https://r2-assets.111965.xyz/wewe-rss.png',
        status: 1,
        syncTime: 0,
        updateTime: Math.floor(Date.now() / 1e3),
        hasHistory: -1,
        createdAt: new Date(),
        updatedAt: new Date(),
      };
    }

    this.logger.log('handleGenerateFeed articles: ' + articles.length);
    const feed = await this.renderFeed({ feedInfo, articles, type, mode });

    if (title_include) {
      const includes = title_include.split('|');
      feed.items = feed.items.filter((i: Item) =>
        includes.some((k) => i.title.includes(k)),
      );
    }
    if (title_exclude) {
      const excludes = title_exclude.split('|');
      feed.items = feed.items.filter(
        (i: Item) => !excludes.some((k) => i.title.includes(k)),
      );
    }

    switch (type) {
      case 'rss':
        return { content: feed.rss2(), mimeType: feedMimeTypeMap[type] };
      case 'json':
        return { content: feed.json1(), mimeType: feedMimeTypeMap[type] };
      case 'atom':
      default:
        return { content: feed.atom1(), mimeType: feedMimeTypeMap[type] };
    }
  }

  async getFeedList() {
    const data = await this.prismaService.feed.findMany();

    return data.map((item) => {
      return {
        id: item.id,
        name: item.mpName,
        intro: item.mpIntro,
        cover: item.mpCover,
        syncTime: item.syncTime,
        updateTime: item.updateTime,
      };
    });
  }

  async updateFeed(id: string) {
    try {
      await this.trpcService.refreshMpArticlesAndUpdateFeed(id);
    } catch (err) {
      this.logger.error('updateFeed error', err);
    } finally {
      // wait 30s for next feed
      await new Promise((resolve) => setTimeout(resolve, 30 * 1e3));
    }
  }
}


================================================
FILE: apps/server/src/main.ts
================================================
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { TrpcRouter } from '@server/trpc/trpc.router';
import { ConfigService } from '@nestjs/config';
import { json, urlencoded } from 'express';
import { NestExpressApplication } from '@nestjs/platform-express';
import { ConfigurationType } from './configuration';
import { join, resolve } from 'path';
import { readFileSync } from 'fs';

const packageJson = JSON.parse(
  readFileSync(resolve(__dirname, '..', './package.json'), 'utf-8'),
);

const appVersion = packageJson.version;
console.log('appVersion: v' + appVersion);

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  const configService = app.get(ConfigService);

  const { host, isProd, port } =
    configService.get<ConfigurationType['server']>('server')!;

  app.use(json({ limit: '10mb' }));
  app.use(urlencoded({ extended: true, limit: '10mb' }));

  app.useStaticAssets(join(__dirname, '..', 'client', 'assets'), {
    prefix: '/dash/assets/',
  });
  app.setBaseViewsDir(join(__dirname, '..', 'client'));
  app.setViewEngine('hbs');

  if (isProd) {
    app.enable('trust proxy');
  }

  app.enableCors({
    exposedHeaders: ['authorization'],
  });

  const trpc = app.get(TrpcRouter);
  trpc.applyMiddleware(app);

  await app.listen(port, host);

  console.log(`Server is running at http://${host}:${port}`);
}
bootstrap();


================================================
FILE: apps/server/src/prisma/prisma.module.ts
================================================
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}


================================================
FILE: apps/server/src/prisma/prisma.service.ts
================================================
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }
}


================================================
FILE: apps/server/src/trpc/trpc.module.ts
================================================
import { Module } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { TrpcRouter } from '@server/trpc/trpc.router';
import { PrismaModule } from '@server/prisma/prisma.module';

@Module({
  imports: [PrismaModule],
  controllers: [],
  providers: [TrpcService, TrpcRouter],
  exports: [TrpcService, TrpcRouter],
})
export class TrpcModule {}


================================================
FILE: apps/server/src/trpc/trpc.router.ts
================================================
import { INestApplication, Injectable, Logger } from '@nestjs/common';
import { z } from 'zod';
import { TrpcService } from '@server/trpc/trpc.service';
import * as trpcExpress from '@trpc/server/adapters/express';
import { TRPCError } from '@trpc/server';
import { PrismaService } from '@server/prisma/prisma.service';
import { statusMap } from '@server/constants';
import { ConfigService } from '@nestjs/config';
import { ConfigurationType } from '@server/configuration';

@Injectable()
export class TrpcRouter {
  constructor(
    private readonly trpcService: TrpcService,
    private readonly prismaService: PrismaService,
    private readonly configService: ConfigService,
  ) {}

  private readonly logger = new Logger(this.constructor.name);

  accountRouter = this.trpcService.router({
    list: this.trpcService.protectedProcedure
      .input(
        z.object({
          limit: z.number().min(1).max(1000).nullish(),
          cursor: z.string().nullish(),
        }),
      )
      .query(async ({ input }) => {
        const limit = input.limit ?? 1000;
        const { cursor } = input;

        const items = await this.prismaService.account.findMany({
          take: limit + 1,
          where: {},
          select: {
            id: true,
            name: true,
            status: true,
            createdAt: true,
            updatedAt: true,
            token: false,
          },
          cursor: cursor
            ? {
                id: cursor,
              }
            : undefined,
          orderBy: {
            createdAt: 'asc',
          },
        });
        let nextCursor: typeof cursor | undefined = undefined;
        if (items.length > limit) {
          // Remove the last item and use it as next cursor

          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
          const nextItem = items.pop()!;
          nextCursor = nextItem.id;
        }

        const disabledAccounts = this.trpcService.getBlockedAccountIds();
        return {
          blocks: disabledAccounts,
          items,
          nextCursor,
        };
      }),
    byId: this.trpcService.protectedProcedure
      .input(z.string())
      .query(async ({ input: id }) => {
        const account = await this.prismaService.account.findUnique({
          where: { id },
        });
        if (!account) {
          throw new TRPCError({
            code: 'BAD_REQUEST',
            message: `No account with id '${id}'`,
          });
        }
        return account;
      }),
    add: this.trpcService.protectedProcedure
      .input(
        z.object({
          id: z.string().min(1).max(32),
          token: z.string().min(1),
          name: z.string().min(1),
          status: z.number().default(statusMap.ENABLE),
        }),
      )
      .mutation(async ({ input }) => {
        const { id, ...data } = input;
        const account = await this.prismaService.account.upsert({
          where: {
            id,
          },
          update: data,
          create: input,
        });
        this.trpcService.removeBlockedAccount(id);

        return account;
      }),
    edit: this.trpcService.protectedProcedure
      .input(
        z.object({
          id: z.string(),
          data: z.object({
            token: z.string().min(1).optional(),
            name: z.string().min(1).optional(),
            status: z.number().optional(),
          }),
        }),
      )
      .mutation(async ({ input }) => {
        const { id, data } = input;
        const account = await this.prismaService.account.update({
          where: { id },
          data,
        });
        this.trpcService.removeBlockedAccount(id);
        return account;
      }),
    delete: this.trpcService.protectedProcedure
      .input(z.string())
      .mutation(async ({ input: id }) => {
        await this.prismaService.account.delete({ where: { id } });
        this.trpcService.removeBlockedAccount(id);

        return id;
      }),
  });

  feedRouter = this.trpcService.router({
    list: this.trpcService.protectedProcedure
      .input(
        z.object({
          limit: z.number().min(1).max(1000).nullish(),
          cursor: z.string().nullish(),
        }),
      )
      .query(async ({ input }) => {
        const limit = input.limit ?? 1000;
        const { cursor } = input;

        const items = await this.prismaService.feed.findMany({
          take: limit + 1,
          where: {},
          cursor: cursor
            ? {
                id: cursor,
              }
            : undefined,
          orderBy: {
            createdAt: 'asc',
          },
        });
        let nextCursor: typeof cursor | undefined = undefined;
        if (items.length > limit) {
          // Remove the last item and use it as next cursor

          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
          const nextItem = items.pop()!;
          nextCursor = nextItem.id;
        }

        return {
          items: items,
          nextCursor,
        };
      }),
    byId: this.trpcService.protectedProcedure
      .input(z.string())
      .query(async ({ input: id }) => {
        const feed = await this.prismaService.feed.findUnique({
          where: { id },
        });
        if (!feed) {
          throw new TRPCError({
            code: 'BAD_REQUEST',
            message: `No feed with id '${id}'`,
          });
        }
        return feed;
      }),
    add: this.trpcService.protectedProcedure
      .input(
        z.object({
          id: z.string(),
          mpName: z.string(),
          mpCover: z.string(),
          mpIntro: z.string(),
          syncTime: z
            .number()
            .optional()
            .default(Math.floor(Date.now() / 1e3)),
          updateTime: z.number(),
          status: z.number().default(statusMap.ENABLE),
        }),
      )
      .mutation(async ({ input }) => {
        const { id, ...data } = input;
        const feed = await this.prismaService.feed.upsert({
          where: {
            id,
          },
          update: data,
          create: input,
        });

        return feed;
      }),
    edit: this.trpcService.protectedProcedure
      .input(
        z.object({
          id: z.string(),
          data: z.object({
            mpName: z.string().optional(),
            mpCover: z.string().optional(),
            mpIntro: z.string().optional(),
            syncTime: z.number().optional(),
            updateTime: z.number().optional(),
            status: z.number().optional(),
          }),
        }),
      )
      .mutation(async ({ input }) => {
        const { id, data } = input;
        const feed = await this.prismaService.feed.update({
          where: { id },
          data,
        });
        return feed;
      }),
    delete: this.trpcService.protectedProcedure
      .input(z.string())
      .mutation(async ({ input: id }) => {
        await this.prismaService.feed.delete({ where: { id } });
        return id;
      }),

    refreshArticles: this.trpcService.protectedProcedure
      .input(
        z.object({
          mpId: z.string().optional(),
        }),
      )
      .mutation(async ({ input: { mpId } }) => {
        if (mpId) {
          await this.trpcService.refreshMpArticlesAndUpdateFeed(mpId);
        } else {
          await this.trpcService.refreshAllMpArticlesAndUpdateFeed();
        }
      }),

    isRefreshAllMpArticlesRunning: this.trpcService.protectedProcedure.query(
      async () => {
        return this.trpcService.isRefreshAllMpArticlesRunning;
      },
    ),
    getHistoryArticles: this.trpcService.protectedProcedure
      .input(
        z.object({
          mpId: z.string().optional(),
        }),
      )
      .mutation(async ({ input: { mpId = '' } }) => {
        this.trpcService.getHistoryMpArticles(mpId);
      }),
    getInProgressHistoryMp: this.trpcService.protectedProcedure.query(
      async () => {
        return this.trpcService.inProgressHistoryMp;
      },
    ),
  });

  articleRouter = this.trpcService.router({
    list: this.trpcService.protectedProcedure
      .input(
        z.object({
          limit: z.number().min(1).max(1000).nullish(),
          cursor: z.string().nullish(),
          mpId: z.string().nullish(),
        }),
      )
      .query(async ({ input }) => {
        const limit = input.limit ?? 1000;
        const { cursor, mpId } = input;

        const items = await this.prismaService.article.findMany({
          orderBy: [
            {
              publishTime: 'desc',
            },
          ],
          take: limit + 1,
          where: mpId ? { mpId } : undefined,
          cursor: cursor
            ? {
                id: cursor,
              }
            : undefined,
        });
        let nextCursor: typeof cursor | undefined = undefined;
        if (items.length > limit) {
          // Remove the last item and use it as next cursor

          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
          const nextItem = items.pop()!;
          nextCursor = nextItem.id;
        }

        return {
          items,
          nextCursor,
        };
      }),
    byId: this.trpcService.protectedProcedure
      .input(z.string())
      .query(async ({ input: id }) => {
        const article = await this.prismaService.article.findUnique({
          where: { id },
        });
        if (!article) {
          throw new TRPCError({
            code: 'BAD_REQUEST',
            message: `No article with id '${id}'`,
          });
        }
        return article;
      }),

    add: this.trpcService.protectedProcedure
      .input(
        z.object({
          id: z.string(),
          mpId: z.string(),
          title: z.string(),
          picUrl: z.string().optional().default(''),
          publishTime: z.number(),
        }),
      )
      .mutation(async ({ input }) => {
        const { id, ...data } = input;
        const article = await this.prismaService.article.upsert({
          where: {
            id,
          },
          update: data,
          create: input,
        });

        return article;
      }),
    delete: this.trpcService.protectedProcedure
      .input(z.string())
      .mutation(async ({ input: id }) => {
        await this.prismaService.article.delete({ where: { id } });
        return id;
      }),
  });

  platformRouter = this.trpcService.router({
    getMpArticles: this.trpcService.protectedProcedure
      .input(
        z.object({
          mpId: z.string(),
        }),
      )
      .mutation(async ({ input: { mpId } }) => {
        try {
          const results = await this.trpcService.getMpArticles(mpId);
          return results;
        } catch (err: any) {
          this.logger.log('getMpArticles err: ', err);
          throw new TRPCError({
            code: 'INTERNAL_SERVER_ERROR',
            message: err.response?.data?.message || err.message,
            cause: err.stack,
          });
        }
      }),
    getMpInfo: this.trpcService.protectedProcedure
      .input(
        z.object({
          wxsLink: z
            .string()
            .refine((v) => v.startsWith('https://mp.weixin.qq.com/s/')),
        }),
      )
      .mutation(async ({ input: { wxsLink: url } }) => {
        try {
          const results = await this.trpcService.getMpInfo(url);
          return results;
        } catch (err: any) {
          this.logger.log('getMpInfo err: ', err);
          throw new TRPCError({
            code: 'INTERNAL_SERVER_ERROR',
            message: err.response?.data?.message || err.message,
            cause: err.stack,
          });
        }
      }),

    createLoginUrl: this.trpcService.protectedProcedure.mutation(async () => {
      return this.trpcService.createLoginUrl();
    }),
    getLoginResult: this.trpcService.protectedProcedure
      .input(
        z.object({
          id: z.string(),
        }),
      )
      .query(async ({ input }) => {
        return this.trpcService.getLoginResult(input.id);
      }),
  });

  appRouter = this.trpcService.router({
    feed: this.feedRouter,
    account: this.accountRouter,
    article: this.articleRouter,
    platform: this.platformRouter,
  });

  async applyMiddleware(app: INestApplication) {
    app.use(
      `/trpc`,
      trpcExpress.createExpressMiddleware({
        router: this.appRouter,
        createContext: ({ req }) => {
          const authCode =
            this.configService.get<ConfigurationType['auth']>('auth')!.code;

          if (authCode && req.headers.authorization !== authCode) {
            return {
              errorMsg: 'authCode不正确!',
            };
          }
          return {
            errorMsg: null,
          };
        },
        middleware: (req, res, next) => {
          next();
        },
      }),
    );
  }
}

export type AppRouter = TrpcRouter[`appRouter`];


================================================
FILE: apps/server/src/trpc/trpc.service.ts
================================================
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ConfigurationType } from '@server/configuration';
import { defaultCount, statusMap } from '@server/constants';
import { PrismaService } from '@server/prisma/prisma.service';
import { TRPCError, initTRPC } from '@trpc/server';
import Axios, { AxiosInstance } from 'axios';
import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';

dayjs.extend(utc);
dayjs.extend(timezone);

/**
 * 读书账号每日小黑屋
 */
const blockedAccountsMap = new Map<string, string[]>();

@Injectable()
export class TrpcService {
  trpc = initTRPC.create();
  publicProcedure = this.trpc.procedure;
  protectedProcedure = this.trpc.procedure.use(({ ctx, next }) => {
    const errorMsg = (ctx as any).errorMsg;
    if (errorMsg) {
      throw new TRPCError({ code: 'UNAUTHORIZED', message: errorMsg });
    }
    return next({ ctx });
  });
  router = this.trpc.router;
  mergeRouters = this.trpc.mergeRouters;
  request: AxiosInstance;
  updateDelayTime = 60;

  private readonly logger = new Logger(this.constructor.name);

  constructor(
    private readonly prismaService: PrismaService,
    private readonly configService: ConfigService,
  ) {
    const { url } =
      this.configService.get<ConfigurationType['platform']>('platform')!;
    this.updateDelayTime =
      this.configService.get<ConfigurationType['feed']>(
        'feed',
      )!.updateDelayTime;

    this.request = Axios.create({ baseURL: url, timeout: 15 * 1e3 });

    this.request.interceptors.response.use(
      (response) => {
        return response;
      },
      async (error) => {
        this.logger.log('error: ', error);
        const errMsg = error.response?.data?.message || '';

        const id = (error.config.headers as any).xid;
        if (errMsg.includes('WeReadError401')) {
          // 账号失效
          await this.prismaService.account.update({
            where: { id },
            data: { status: statusMap.INVALID },
          });
          this.logger.error(`账号(${id})登录失效,已禁用`);
        } else if (errMsg.includes('WeReadError429')) {
          //TODO 处理请求频繁
          this.logger.error(`账号(${id})请求频繁,打入小黑屋`);
        }

        const today = this.getTodayDate();

        const blockedAccounts = blockedAccountsMap.get(today);

        if (Array.isArray(blockedAccounts)) {
          if (id) {
            blockedAccounts.push(id);
          }
          blockedAccountsMap.set(today, blockedAccounts);
        } else if (errMsg.includes('WeReadError400')) {
          this.logger.error(`账号(${id})处理请求参数出错`);
          this.logger.error('WeReadError400: ', errMsg);
          // 10s 后重试
          await new Promise((resolve) => setTimeout(resolve, 10 * 1e3));
        } else {
          this.logger.error("Can't handle this error: ", errMsg);
        }

        return Promise.reject(error);
      },
    );
  }

  removeBlockedAccount = (vid: string) => {
    const today = this.getTodayDate();

    const blockedAccounts = blockedAccountsMap.get(today);
    if (Array.isArray(blockedAccounts)) {
      const newBlockedAccounts = blockedAccounts.filter((id) => id !== vid);
      blockedAccountsMap.set(today, newBlockedAccounts);
    }
  };

  private getTodayDate() {
    return dayjs.tz(new Date(), 'Asia/Shanghai').format('YYYY-MM-DD');
  }

  getBlockedAccountIds() {
    const today = this.getTodayDate();
    const disabledAccounts = blockedAccountsMap.get(today) || [];
    this.logger.debug('disabledAccounts: ', disabledAccounts);
    return disabledAccounts.filter(Boolean);
  }

  private async getAvailableAccount() {
    const disabledAccounts = this.getBlockedAccountIds();
    const account = await this.prismaService.account.findMany({
      where: {
        status: statusMap.ENABLE,
        NOT: {
          id: { in: disabledAccounts },
        },
      },
      take: 10,
    });

    if (!account || account.length === 0) {
      throw new Error('暂无可用读书账号!');
    }

    return account[Math.floor(Math.random() * account.length)];
  }

  async getMpArticles(mpId: string, page = 1, retryCount = 3) {
    const account = await this.getAvailableAccount();

    try {
      const res = await this.request
        .get<
          {
            id: string;
            title: string;
            picUrl: string;
            publishTime: number;
          }[]
        >(`/api/v2/platform/mps/${mpId}/articles`, {
          headers: {
            xid: account.id,
            Authorization: `Bearer ${account.token}`,
          },
          params: {
            page,
          },
        })
        .then((res) => res.data)
        .then((res) => {
          this.logger.log(
            `getMpArticles(${mpId}) page: ${page} articles: ${res.length}`,
          );
          return res;
        });
      return res;
    } catch (err) {
      this.logger.error(`retry(${4 - retryCount}) getMpArticles  error: `, err);
      if (retryCount > 0) {
        return this.getMpArticles(mpId, page, retryCount - 1);
      } else {
        throw err;
      }
    }
  }

  async refreshMpArticlesAndUpdateFeed(mpId: string, page = 1) {
    const articles = await this.getMpArticles(mpId, page);

    if (articles.length > 0) {
      let results;
      const { type } =
        this.configService.get<ConfigurationType['database']>('database')!;
      if (type === 'sqlite') {
        // sqlite3 不支持 createMany
        const inserts = articles.map(({ id, picUrl, publishTime, title }) =>
          this.prismaService.article.upsert({
            create: { id, mpId, picUrl, publishTime, title },
            update: {
              publishTime,
              title,
            },
            where: { id },
          }),
        );
        results = await this.prismaService.$transaction(inserts);
      } else {
        results = await (this.prismaService.article as any).createMany({
          data: articles.map(({ id, picUrl, publishTime, title }) => ({
            id,
            mpId,
            picUrl,
            publishTime,
            title,
          })),
          skipDuplicates: true,
        });
      }

      this.logger.debug(
        `refreshMpArticlesAndUpdateFeed create results: ${JSON.stringify(results)}`,
      );
    }

    // 如果文章数量小于 defaultCount,则认为没有更多历史文章
    const hasHistory = articles.length < defaultCount ? 0 : 1;

    await this.prismaService.feed.update({
      where: { id: mpId },
      data: {
        syncTime: Math.floor(Date.now() / 1e3),
        hasHistory,
      },
    });

    return { hasHistory };
  }

  inProgressHistoryMp = {
    id: '',
    page: 1,
  };

  async getHistoryMpArticles(mpId: string) {
    if (this.inProgressHistoryMp.id === mpId) {
      this.logger.log(`getHistoryMpArticles(${mpId}) is running`);
      return;
    }

    this.inProgressHistoryMp = {
      id: mpId,
      page: 1,
    };

    if (!this.inProgressHistoryMp.id) {
      return;
    }

    try {
      const feed = await this.prismaService.feed.findFirstOrThrow({
        where: {
          id: mpId,
        },
      });

      // 如果完整同步过历史文章,则直接返回
      if (feed.hasHistory === 0) {
        this.logger.log(`getHistoryMpArticles(${mpId}) has no history`);
        return;
      }

      const total = await this.prismaService.article.count({
        where: {
          mpId,
        },
      });
      this.inProgressHistoryMp.page = Math.ceil(total / defaultCount);

      // 最多尝试一千次
      let i = 1e3;
      while (i-- > 0) {
        if (this.inProgressHistoryMp.id !== mpId) {
          this.logger.log(
            `getHistoryMpArticles(${mpId}) is not running, break`,
          );
          break;
        }
        const { hasHistory } = await this.refreshMpArticlesAndUpdateFeed(
          mpId,
          this.inProgressHistoryMp.page,
        );
        if (hasHistory < 1) {
          this.logger.log(
            `getHistoryMpArticles(${mpId}) has no history, break`,
          );
          break;
        }
        this.inProgressHistoryMp.page++;

        await new Promise((resolve) =>
          setTimeout(resolve, this.updateDelayTime * 1e3),
        );
      }
    } finally {
      this.inProgressHistoryMp = {
        id: '',
        page: 1,
      };
    }
  }

  isRefreshAllMpArticlesRunning = false;

  async refreshAllMpArticlesAndUpdateFeed() {
    if (this.isRefreshAllMpArticlesRunning) {
      this.logger.log('refreshAllMpArticlesAndUpdateFeed is running');
      return;
    }
    const mps = await this.prismaService.feed.findMany();
    this.isRefreshAllMpArticlesRunning = true;
    try {
      for (const { id } of mps) {
        await this.refreshMpArticlesAndUpdateFeed(id);

        await new Promise((resolve) =>
          setTimeout(resolve, this.updateDelayTime * 1e3),
        );
      }
    } finally {
      this.isRefreshAllMpArticlesRunning = false;
    }
  }

  async getMpInfo(url: string) {
    url = url.trim();
    const account = await this.getAvailableAccount();

    return this.request
      .post<
        {
          id: string;
          cover: string;
          name: string;
          intro: string;
          updateTime: number;
        }[]
      >(
        `/api/v2/platform/wxs2mp`,
        { url },
        {
          headers: {
            xid: account.id,
            Authorization: `Bearer ${account.token}`,
          },
        },
      )
      .then((res) => res.data);
  }

  async createLoginUrl() {
    return this.request
      .get<{
        uuid: string;
        scanUrl: string;
      }>(`/api/v2/login/platform`)
      .then((res) => res.data);
  }

  async getLoginResult(id: string) {
    return this.request
      .get<{
        message: string;
        vid?: number;
        token?: string;
        username?: string;
      }>(`/api/v2/login/platform/${id}`, { timeout: 120 * 1e3 })
      .then((res) => res.data);
  }
}


================================================
FILE: apps/server/test/app.e2e-spec.ts
================================================
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';

describe('AppController (e2e)', () => {
  let app: INestApplication;

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it('/ (GET)', () => {
    return request(app.getHttpServer())
      .get('/')
      .expect(200)
      .expect('Hello World!');
  });
});


================================================
FILE: apps/server/test/jest-e2e.json
================================================
{
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": ".",
  "testEnvironment": "node",
  "testRegex": ".e2e-spec.ts$",
  "transform": {
    "^.+\\.(t|j)s$": "ts-jest"
  }
}


================================================
FILE: apps/server/tsconfig.build.json
================================================
{
  "extends": "./tsconfig.json",
  "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}


================================================
FILE: apps/server/tsconfig.json
================================================
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "module": "commonjs",
    "declaration": true,
    "removeComments": true,
    "allowSyntheticDefaultImports": true,
    "target": "ES2021",
    "sourceMap": true,
    "outDir": "./dist",
    "esModuleInterop":true
  }
}

================================================
FILE: apps/web/.eslintrc.cjs
================================================
module.exports = {
  root: true,
  env: { browser: true, es2020: true },
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:react-hooks/recommended',
  ],
  ignorePatterns: ['dist', '.eslintrc.cjs'],
  parser: '@typescript-eslint/parser',
  plugins: ['react-refresh'],
  rules: {
    'react-refresh/only-export-components': [
      'warn',
      { allowConstantExport: true },
    ],
    '@typescript-eslint/no-explicit-any': 'warn',
  },
};


================================================
FILE: apps/web/.gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?


================================================
FILE: apps/web/README.md
================================================
# React + TypeScript + Vite

This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.

Currently, two official plugins are available:

- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh

## Expanding the ESLint configuration

If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:

- Configure the top-level `parserOptions` property like this:

```js
export default {
  // other rules...
  parserOptions: {
    ecmaVersion: 'latest',
    sourceType: 'module',
    project: ['./tsconfig.json', './tsconfig.node.json'],
    tsconfigRootDir: __dirname,
  },
};
```

- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list


================================================
FILE: apps/web/index.html
================================================
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="{{ iconUrl }}" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>WeWe RSS</title>
    <meta name="description" content="更好的公众号订阅方式" />
  </head>
  <body>
    <div id="root"></div>
    <script>
      window.__WEWE_RSS_SERVER_ORIGIN_URL__ = '{{ weweRssServerOriginUrl }}';
      window.__WEWE_RSS_ENABLED_AUTH_CODE__ = '{{ enabledAuthCode }}';
    </script>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>


================================================
FILE: apps/web/package.json
================================================
{
  "name": "web",
  "private": true,
  "version": "2.6.1",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview"
  },
  "dependencies": {
    "@nextui-org/react": "^2.2.9",
    "@tanstack/react-query": "^4.35.3",
    "@trpc/client": "^10.45.1",
    "@trpc/next": "^10.45.1",
    "@trpc/react-query": "^10.45.1",
    "autoprefixer": "^10.0.1",
    "dayjs": "^1.11.10",
    "framer-motion": "^11.0.5",
    "next-themes": "^0.2.1",
    "postcss": "^8",
    "qrcode.react": "^3.1.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-dom": "^6.22.2",
    "sonner": "^1.4.0",
    "tailwindcss": "^3.3.0"
  },
  "devDependencies": {
    "@types/node": "^20.11.24",
    "@types/react": "^18.2.56",
    "@types/react-dom": "^18.2.19",
    "@typescript-eslint/eslint-plugin": "^7.0.2",
    "@typescript-eslint/parser": "^7.0.2",
    "@vitejs/plugin-react": "^4.2.1",
    "eslint": "^8.56.0",
    "eslint-plugin-react-hooks": "^4.6.0",
    "eslint-plugin-react-refresh": "^0.4.5",
    "typescript": "^5.2.2",
    "vite": "^5.1.4"
  }
}

================================================
FILE: apps/web/postcss.config.js
================================================
export default {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};


================================================
FILE: apps/web/src/App.tsx
================================================
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import Feeds from './pages/feeds';
import Login from './pages/login';
import Accounts from './pages/accounts';
import { BaseLayout } from './layouts/base';
import { TrpcProvider } from './provider/trpc';
import ThemeProvider from './provider/theme';

function App() {
  return (
    <BrowserRouter basename="/dash">
      <ThemeProvider>
        <TrpcProvider>
          <Routes>
            <Route path="/" element={<BaseLayout />}>
              <Route index element={<Feeds />} />
              <Route path="/feeds/:id?" element={<Feeds />} />
              <Route path="/accounts" element={<Accounts />} />
              <Route path="/login" element={<Login />} />
            </Route>
          </Routes>
        </TrpcProvider>
      </ThemeProvider>
    </BrowserRouter>
  );
}

export default App;


================================================
FILE: apps/web/src/components/GitHubIcon.tsx
================================================
import { IconSvgProps } from '../types';

export const GitHubIcon = ({
  size = 24,
  width,
  height,
  ...props
}: IconSvgProps) => (
  <svg
    aria-hidden="true"
    fill="none"
    focusable="false"
    height={size || height}
    role="presentation"
    viewBox="0 0 24 24"
    width={size || width}
    {...props}
  >
    <path
      clipRule="evenodd"
      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"
      fill="currentColor"
      fillRule="evenodd"
    ></path>
  </svg>
);


================================================
FILE: apps/web/src/components/Nav.tsx
================================================
import {
  Badge,
  Image,
  Link,
  Navbar,
  NavbarBrand,
  NavbarContent,
  NavbarItem,
  Tooltip,
} from '@nextui-org/react';
import { ThemeSwitcher } from './ThemeSwitcher';
import { GitHubIcon } from './GitHubIcon';
import { useLocation } from 'react-router-dom';
import { appVersion, serverOriginUrl } from '@web/utils/env';
import { useEffect, useState } from 'react';

const navbarItemLink = [
  {
    href: '/feeds',
    name: '公众号源',
  },
  {
    href: '/accounts',
    name: '账号管理',
  },
  // {
  //   href: '/settings',
  //   name: '设置',
  // },
];

const Nav = () => {
  const { pathname } = useLocation();
  const [releaseVersion, setReleaseVersion] = useState(appVersion);

  useEffect(() => {
    fetch('https://api.github.com/repos/cooderl/wewe-rss/releases/latest')
      .then((res) => res.json())
      .then((data) => {
        setReleaseVersion(data.name.replace('v', ''));
      });
  }, []);

  const isFoundNewVersion = releaseVersion > appVersion;
  console.log('isFoundNewVersion: ', isFoundNewVersion);

  return (
    <div>
      <Navbar isBordered>
        <Tooltip
          content={
            <div className="p-1">
              {isFoundNewVersion && (
                <Link
                  href={`https://github.com/cooderl/wewe-rss/releases/latest`}
                  target="_blank"
                  className="mb-1 block text-medium"
                >
                  发现新版本:v{releaseVersion}
                </Link>
              )}
              当前版本: v{appVersion}
            </div>
          }
          placement="left"
        >
          <NavbarBrand className="cursor-default">
            <Badge
              content={isFoundNewVersion ? '' : null}
              color="danger"
              size="sm"
            >
              <Image
                width={28}
                alt="WeWe RSS"
                className="mr-2"
                src={
                  serverOriginUrl
                    ? `${serverOriginUrl}/favicon.ico`
                    : 'https://r2-assets.111965.xyz/wewe-rss.png'
                }
              ></Image>
            </Badge>
            <p className="font-bold text-inherit">WeWe RSS</p>
          </NavbarBrand>
        </Tooltip>
        <NavbarContent className="hidden sm:flex gap-4" justify="center">
          {navbarItemLink.map((item) => {
            return (
              <NavbarItem
                isActive={pathname.startsWith(item.href)}
                key={item.href}
              >
                <Link color="foreground" href={item.href}>
                  {item.name}
                </Link>
              </NavbarItem>
            );
          })}
        </NavbarContent>
        <NavbarContent justify="end">
          <ThemeSwitcher></ThemeSwitcher>
          <Link
            href="https://github.com/cooderl/wewe-rss"
            target="_blank"
            color="foreground"
          >
            <GitHubIcon />
          </Link>
        </NavbarContent>
      </Navbar>
    </div>
  );
};

export default Nav;


================================================
FILE: apps/web/src/components/PlusIcon.tsx
================================================
import { IconSvgProps } from '../types';

export const PlusIcon = ({
  size = 24,
  width,
  height,
  ...props
}: IconSvgProps) => (
  <svg
    aria-hidden="true"
    fill="none"
    focusable="false"
    height={size || height}
    role="presentation"
    viewBox="0 0 24 24"
    width={size || width}
    {...props}
  >
    <g
      fill="none"
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth={1.5}
    >
      <path d="M6 12h12" />
      <path d="M12 18V6" />
    </g>
  </svg>
);


================================================
FILE: apps/web/src/components/StatusDropdown.tsx
================================================
import React from 'react';
import {
  Dropdown,
  DropdownTrigger,
  DropdownMenu,
  DropdownItem,
  Button,
} from '@nextui-org/react';
import { statusMap } from '@web/constants';

export function StatusDropdown({
  value = 1,
  onChange,
}: {
  value: number;
  onChange: (value: number) => void;
}) {
  return (
    <Dropdown>
      <DropdownTrigger>
        <Button size="sm" variant="bordered" className="capitalize">
          {statusMap[value].label}
        </Button>
      </DropdownTrigger>
      <DropdownMenu
        disabledKeys={['0']}
        aria-label="状态设置"
        variant="flat"
        disallowEmptySelection
        selectionMode="single"
        selectedKeys={[`${value}`]}
        onSelectionChange={(keys) => {
          onChange(+Array.from(keys)[0]);
        }}
      >
        {Object.entries(statusMap).map(([key, value]) => {
          return (
            <DropdownItem color={value.color} key={`${key}`} value={`${key}`}>
              {value.label}
            </DropdownItem>
          );
        })}
      </DropdownMenu>
    </Dropdown>
  );
}


================================================
FILE: apps/web/src/components/ThemeSwitcher.tsx
================================================
'use client';

import { VisuallyHidden, useSwitch } from '@nextui-org/react';
import { useTheme } from 'next-themes';

export const MoonIcon = (props) => (
  <svg
    aria-hidden="true"
    focusable="false"
    height="1em"
    role="presentation"
    viewBox="0 0 24 24"
    width="1em"
    {...props}
  >
    <path
      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"
      fill="currentColor"
    />
  </svg>
);

export const SunIcon = (props) => (
  <svg
    aria-hidden="true"
    focusable="false"
    height="1em"
    role="presentation"
    viewBox="0 0 24 24"
    width="1em"
    {...props}
  >
    <g fill="currentColor">
      <path d="M19 12a7 7 0 11-7-7 7 7 0 017 7z" />
      <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" />
    </g>
  </svg>
);

export function ThemeSwitcher(props) {
  const { setTheme, theme } = useTheme();
  const {
    Component,
    slots,
    isSelected,
    getBaseProps,
    getInputProps,
    getWrapperProps,
  } = useSwitch({
    onClick: () => setTheme(theme === 'dark' ? 'light' : 'dark'),
    isSelected: theme === 'dark',
  });

  return (
    <div className="flex flex-col gap-2">
      <Component {...getBaseProps()}>
        <VisuallyHidden>
          <input {...getInputProps()} />
        </VisuallyHidden>
        <div
          {...getWrapperProps()}
          className={slots.wrapper({
            class: [
              'w-8 h-8',
              'flex items-center justify-center',
              'rounded-lg bg-default-100 hover:bg-default-200',
            ],
          })}
        >
          {isSelected ? <SunIcon /> : <MoonIcon />}
        </div>
      </Component>
    </div>
  );
}


================================================
FILE: apps/web/src/constants.ts
================================================
export const statusMap = {
  0: { label: '失效', color: 'danger' },
  1: { label: '启用', color: 'success' },
  2: { label: '禁用', color: 'warning' },
} as const;


================================================
FILE: apps/web/src/index.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;


================================================
FILE: apps/web/src/layouts/base.tsx
================================================
import { Toaster } from 'sonner';
import { Outlet } from 'react-router-dom';

import Nav from '../components/Nav';

export function BaseLayout() {
  return (
    <div>
      <main className="h-screen overflow-hidden">
        <Nav></Nav>
        <div className="h-[calc(100vh-64px)] max-w-[1280px] mx-auto pb-6">
          <Outlet />
        </div>
      </main>
      <Toaster richColors position="top-right" />
    </div>
  );
}


================================================
FILE: apps/web/src/main.tsx
================================================
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './index.css';

ReactDOM.createRoot(document.getElementById('root')!).render(<App />);


================================================
FILE: apps/web/src/pages/accounts/index.tsx
================================================
import {
  Modal,
  ModalContent,
  ModalHeader,
  ModalBody,
  Button,
  useDisclosure,
  Spinner,
  Table,
  TableBody,
  TableCell,
  TableColumn,
  TableHeader,
  TableRow,
  Chip,
} from '@nextui-org/react';
import { QRCodeSVG } from 'qrcode.react';
import { toast } from 'sonner';
import { PlusIcon } from '@web/components/PlusIcon';
import dayjs from 'dayjs';
import { StatusDropdown } from '@web/components/StatusDropdown';
import { trpc } from '@web/utils/trpc';
import { statusMap } from '@web/constants';
import { useEffect, useState } from 'react';

const AccountPage = () => {
  const { isOpen, onOpen, onClose, onOpenChange } = useDisclosure();
  const [count, setCount] = useState(0);

  const { refetch, data, isFetching } = trpc.account.list.useQuery({});

  const queryUtils = trpc.useUtils();

  const { mutateAsync: updateAccount } = trpc.account.edit.useMutation({});

  const { mutateAsync: deleteAccount } = trpc.account.delete.useMutation({});

  const { mutateAsync: addAccount } = trpc.account.add.useMutation({});

  const { mutateAsync, data: loginData } =
    trpc.platform.createLoginUrl.useMutation({
      onSuccess(data) {
        if (data.uuid) {
          setCount(60);
        }
      },
    });

  const { data: loginResult } = trpc.platform.getLoginResult.useQuery(
    {
      id: loginData?.uuid ?? '',
    },
    {
      refetchIntervalInBackground: false,
      enabled: !!loginData?.uuid,
      async onSuccess(data) {
        if (data.vid && data.token) {
          const name = data.username!;
          await addAccount({ id: `${data.vid}`, name, token: data.token });

          onClose();
          toast.success('添加成功', {
            description: `用户名:${name}(${data.vid})`,
          });
          refetch();
        } else if (data.message) {
          toast.error(`登录失败: ${data.message}`);
        }
      },
    },
  );

  useEffect(() => {
    let timerId;
    if (count > 0 && isOpen) {
      timerId = setTimeout(() => {
        setCount(count - 1);
      }, 1000);
    }
    return () => timerId && clearTimeout(timerId);
  }, [count, isOpen]);

  return (
    <div>
      <div className="flex justify-between m-4">
        <div className="font-bold">共{data?.items.length || 0}个账号</div>
        <Button
          onPress={() => {
            onOpen();
            mutateAsync();
          }}
          size="sm"
          color="primary"
          endContent={<PlusIcon />}
        >
          添加读书账号
        </Button>
      </div>
      <Table aria-label="Example static collection table">
        <TableHeader>
          <TableColumn>ID</TableColumn>
          <TableColumn>用户名</TableColumn>
          <TableColumn>状态</TableColumn>
          <TableColumn>更新时间</TableColumn>
          <TableColumn>操作</TableColumn>
        </TableHeader>
        <TableBody
          emptyContent={<div className="m-auto text-center">暂无数据</div>}
          isLoading={isFetching}
          loadingContent={<Spinner />}
        >
          {data?.items.map((item) => {
            const isBlocked = data?.blocks.includes(item.id);

            return (
              <TableRow key={item.id}>
                <TableCell>{item.id}</TableCell>
                <TableCell>{item.name}</TableCell>
                <TableCell>
                  {isBlocked ? (
                    <Chip className="capitalize" size="sm" variant="flat">
                      今日小黑屋
                    </Chip>
                  ) : (
                    <Chip
                      className="capitalize"
                      color={statusMap[item.status].color}
                      size="sm"
                      variant="flat"
                    >
                      {statusMap[item.status].label}
                    </Chip>
                  )}
                </TableCell>
                <TableCell>
                  {dayjs(item.updatedAt).format('YYYY-MM-DD')}
                </TableCell>
                <TableCell className="flex gap-2">
                  <StatusDropdown
                    value={item.status}
                    onChange={(value) => {
                      updateAccount({
                        id: item.id,
                        data: { status: value },
                      }).then(() => {
                        toast.success('更新成功!');
                        refetch();
                      });
                    }}
                  ></StatusDropdown>

                  <Button
                    size="sm"
                    color="danger"
                    onPress={() => {
                      deleteAccount(item.id).then(() => {
                        toast.success('删除成功!');
                        refetch();
                      });
                    }}
                  >
                    删除
                  </Button>
                </TableCell>
              </TableRow>
            );
          }) || []}
        </TableBody>
      </Table>

      <Modal
        isOpen={isOpen}
        onOpenChange={async () => {
          onOpenChange();
          await queryUtils.platform.getLoginResult.cancel();
        }}
      >
        <ModalContent>
          {() => (
            <>
              <ModalHeader className="flex flex-col gap-1">
                添加读书账号
              </ModalHeader>
              <ModalBody>
                <div className="m-auto pb-8 text-center">
                  {loginData ? (
                    <div>
                      <div className="relative">
                        {loginResult?.message && (
                          <div className="absolute top-0 left-0 bottom-0 right-0 bg-white bg-opacity-75 flex justify-center items-center">
                            <div className="text-xl">
                              {loginResult?.message}
                            </div>
                          </div>
                        )}
                        <QRCodeSVG size={150} value={loginData?.scanUrl} />
                      </div>
                      <div className="mt-4">
                        微信扫码登录{' '}
                        {!loginResult?.message && count > 0 && (
                          <span className="text-red-400">({count}s)</span>
                        )}
                      </div>
                    </div>
                  ) : (
                    <div className="m-auto flex justify-center align-middle items-center">
                      <Spinner />
                      二维码加载中
                    </div>
                  )}
                </div>
              </ModalBody>
            </>
          )}
        </ModalContent>
      </Modal>
    </div>
  );
};

export default AccountPage;


================================================
FILE: apps/web/src/pages/feeds/index.tsx
================================================
import {
  Avatar,
  Button,
  Divider,
  Listbox,
  ListboxItem,
  ListboxSection,
  Modal,
  ModalBody,
  ModalContent,
  ModalFooter,
  ModalHeader,
  Switch,
  Textarea,
  Tooltip,
  useDisclosure,
  Link,
} from '@nextui-org/react';
import { PlusIcon } from '@web/components/PlusIcon';
import { trpc } from '@web/utils/trpc';
import { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { toast } from 'sonner';
import dayjs from 'dayjs';
import { serverOriginUrl } from '@web/utils/env';
import ArticleList from './list';

const Feeds = () => {
  const { id } = useParams();

  const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure();
  const { refetch: refetchFeedList, data: feedData } = trpc.feed.list.useQuery(
    {},
    {
      refetchOnWindowFocus: true,
    },
  );

  const navigate = useNavigate();

  const queryUtils = trpc.useUtils();

  const { mutateAsync: getMpInfo, isLoading: isGetMpInfoLoading } =
    trpc.platform.getMpInfo.useMutation({});
  const { mutateAsync: updateMpInfo } = trpc.feed.edit.useMutation({});

  const { mutateAsync: addFeed, isLoading: isAddFeedLoading } =
    trpc.feed.add.useMutation({});
  const { mutateAsync: refreshMpArticles, isLoading: isGetArticlesLoading } =
    trpc.feed.refreshArticles.useMutation();
  const {
    mutateAsync: getHistoryArticles,
    isLoading: isGetHistoryArticlesLoading,
  } = trpc.feed.getHistoryArticles.useMutation();

  const { data: inProgressHistoryMp, refetch: refetchInProgressHistoryMp } =
    trpc.feed.getInProgressHistoryMp.useQuery(undefined, {
      refetchOnWindowFocus: true,
      refetchInterval: 10 * 1e3,
      refetchOnMount: true,
      refetchOnReconnect: true,
    });

  const { data: isRefreshAllMpArticlesRunning } =
    trpc.feed.isRefreshAllMpArticlesRunning.useQuery();

  const { mutateAsync: deleteFeed, isLoading: isDeleteFeedLoading } =
    trpc.feed.delete.useMutation({});

  const [wxsLink, setWxsLink] = useState('');

  const [currentMpId, setCurrentMpId] = useState(id || '');

  const handleConfirm = async () => {
    console.log('wxsLink', wxsLink);
    // TODO show operation in progress
    const wxsLinks = wxsLink.split('\n').filter((link) => link.trim() !== '');
    for (const link of wxsLinks) {
      console.log('add wxsLink', link);
      const res = await getMpInfo({ wxsLink: link });
      if (res[0]) {
        const item = res[0];
        await addFeed({
          id: item.id,
          mpName: item.name,
          mpCover: item.cover,
          mpIntro: item.intro,
          updateTime: item.updateTime,
          status: 1,
        });
        await refreshMpArticles({ mpId: item.id });
        toast.success('添加成功', {
          description: `公众号 ${item.name}`,
        });
        await queryUtils.article.list.reset();
      } else {
        toast.error('添加失败', { description: '请检查链接是否正确' });
      }
    }
    refetchFeedList();
    setWxsLink('');
    onClose();
  };

  const isActive = (key: string) => {
    return currentMpId === key;
  };

  const currentMpInfo = useMemo(() => {
    return feedData?.items.find((item) => item.id === currentMpId);
  }, [currentMpId, feedData?.items]);

  const handleExportOpml = async (ev) => {
    ev.preventDefault();
    ev.stopPropagation();
    if (!feedData?.items?.length) {
      console.warn('没有订阅源');
      return;
    }

    let opmlContent = `<?xml version="1.0" encoding="UTF-8"?>
    <opml version="2.0">
      <head>
        <title>WeWeRSS 所有订阅源</title>
      </head>
      <body>
    `;

    feedData?.items.forEach((sub) => {
      opmlContent += `    <outline text="${sub.mpName}" type="rss" xmlUrl="${window.location.origin}/feeds/${sub.id}.atom" htmlUrl="${window.location.origin}/feeds/${sub.id}.atom"/>\n`;
    });

    opmlContent += `    </body>
    </opml>`;

    const blob = new Blob([opmlContent], { type: 'text/xml;charset=utf-8;' });
    const link = document.createElement('a');
    link.href = URL.createObjectURL(blob);
    link.download = 'WeWeRSS-All.opml';
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
  };

  return (
    <>
      <div className="h-full flex justify-between">
        <div className="w-64 p-4 h-full">
          <div className="pb-4 flex justify-between align-middle items-center">
            <Button
              color="primary"
              size="sm"
              onPress={onOpen}
              endContent={<PlusIcon />}
            >
              添加
            </Button>
            <div className="font-normal text-sm">
              共{feedData?.items.length || 0}个订阅
            </div>
          </div>

          {feedData?.items ? (
            <Listbox
              aria-label="订阅源"
              emptyContent="暂无订阅"
              onAction={(key) => setCurrentMpId(key as string)}
            >
              <ListboxSection showDivider>
                <ListboxItem
                  key={''}
                  href={`/feeds`}
                  className={isActive('') ? 'bg-primary-50 text-primary' : ''}
                  startContent={<Avatar name="ALL"></Avatar>}
                >
                  全部
                </ListboxItem>
              </ListboxSection>

              <ListboxSection className="overflow-y-auto h-[calc(100vh-260px)]">
                {feedData?.items.map((item) => {
                  return (
                    <ListboxItem
                      href={`/feeds/${item.id}`}
                      className={
                        isActive(item.id) ? 'bg-primary-50 text-primary' : ''
                      }
                      key={item.id}
                      startContent={<Avatar src={item.mpCover}></Avatar>}
                    >
                      {item.mpName}
                    </ListboxItem>
                  );
                }) || []}
              </ListboxSection>
            </Listbox>
          ) : (
            ''
          )}
        </div>
        <div className="flex-1 h-full flex flex-col">
          <div className="p-4 pb-0 flex justify-between">
            <h3 className="text-medium font-mono flex-1 overflow-hidden text-ellipsis break-keep text-nowrap pr-1">
              {currentMpInfo?.mpName || '全部'}
            </h3>
            {currentMpInfo ? (
              <div className="flex h-5 items-center space-x-4 text-small">
                <div className="font-light">
                  最后更新时间:
                  {dayjs(currentMpInfo.syncTime * 1e3).format(
                    'YYYY-MM-DD HH:mm:ss',
                  )}
                </div>
                <Divider orientation="vertical" />
                <Tooltip
                  content="频繁调用可能会导致一段时间内不可用"
                  color="danger"
                >
                  <Link
                    size="sm"
                    href="#"
                    isDisabled={isGetArticlesLoading}
                    onClick={async (ev) => {
                      ev.preventDefault();
                      ev.stopPropagation();
                      await refreshMpArticles({ mpId: currentMpInfo.id });
                      await refetchFeedList();
                      await queryUtils.article.list.reset();
                    }}
                  >
                    {isGetArticlesLoading ? '更新中...' : '立即更新'}
                  </Link>
                </Tooltip>
                <Divider orientation="vertical" />
                {currentMpInfo.hasHistory === 1 && (
                  <>
                    <Tooltip
                      content={
                        inProgressHistoryMp?.id === currentMpInfo.id
                          ? `正在获取第${inProgressHistoryMp.page}页...`
                          : `历史文章需要分批次拉取,请耐心等候,频繁调用可能会导致一段时间内不可用`
                      }
                      color={
                        inProgressHistoryMp?.id === currentMpInfo.id
                          ? 'primary'
                          : 'danger'
                      }
                    >
                      <Link
                        size="sm"
                        href="#"
                        isDisabled={
                          (inProgressHistoryMp?.id
                            ? inProgressHistoryMp?.id !== currentMpInfo.id
                            : false) ||
                          isGetHistoryArticlesLoading ||
                          isGetArticlesLoading
                        }
                        onClick={async (ev) => {
                          ev.preventDefault();
                          ev.stopPropagation();

                          if (inProgressHistoryMp?.id === currentMpInfo.id) {
                            await getHistoryArticles({
                              mpId: '',
                            });
                          } else {
                            await getHistoryArticles({
                              mpId: currentMpInfo.id,
                            });
                          }

                          await refetchInProgressHistoryMp();
                        }}
                      >
                        {inProgressHistoryMp?.id === currentMpInfo.id
                          ? `停止获取历史文章`
                          : `获取历史文章`}
                      </Link>
                    </Tooltip>
                    <Divider orientation="vertical" />
                  </>
                )}

                <Tooltip content="启用服务端定时更新">
                  <div>
                    <Switch
                      size="sm"
                      onValueChange={async (value) => {
                        await updateMpInfo({
                          id: currentMpInfo.id,
                          data: {
                            status: value ? 1 : 0,
                          },
                        });

                        await refetchFeedList();
                      }}
                      isSelected={currentMpInfo?.status === 1}
                    ></Switch>
                  </div>
                </Tooltip>
                <Divider orientation="vertical" />
                <Tooltip content="仅删除订阅源,已获取的文章不会被删除">
                  <Link
                    href="#"
                    color="danger"
                    size="sm"
                    isDisabled={isDeleteFeedLoading}
                    onClick={async (ev) => {
                      ev.preventDefault();
                      ev.stopPropagation();

                      if (window.confirm('确定删除吗?')) {
                        await deleteFeed(currentMpInfo.id);
                        navigate('/feeds');
                        await refetchFeedList();
                      }
                    }}
                  >
                    删除
                  </Link>
                </Tooltip>

                <Divider orientation="vertical" />
                <Tooltip
                  content={
                    <div>
                      可添加.atom/.rss/.json格式输出, limit=20&page=1控制分页
                    </div>
                  }
                >
                  <Link
                    size="sm"
                    showAnchorIcon
                    target="_blank"
                    href={`${serverOriginUrl}/feeds/${currentMpInfo.id}.atom`}
                    color="foreground"
                  >
                    RSS
                  </Link>
                </Tooltip>
              </div>
            ) : (
              <div className="flex gap-2">
                <Tooltip
                  content="频繁调用可能会导致一段时间内不可用"
                  color="danger"
                >
                  <Link
                    size="sm"
                    href="#"
                    isDisabled={
                      isRefreshAllMpArticlesRunning || isGetArticlesLoading
                    }
                    onClick={async (ev) => {
                      ev.preventDefault();
                      ev.stopPropagation();
                      await refreshMpArticles({});
                      await refetchFeedList();
                      await queryUtils.article.list.reset();
                    }}
                  >
                    {isRefreshAllMpArticlesRunning || isGetArticlesLoading
                      ? '更新中...'
                      : '更新全部'}
                  </Link>
                </Tooltip>
                <Link
                  href="#"
                  color="foreground"
                  onClick={handleExportOpml}
                  size="sm"
                >
                  导出OPML
                </Link>
                <Divider orientation="vertical" />
                <Link
                  size="sm"
                  showAnchorIcon
                  target="_blank"
                  href={`${serverOriginUrl}/feeds/all.atom`}
                  color="foreground"
                >
                  RSS
                </Link>
              </div>
            )}
          </div>
          <div className="p-2 overflow-y-auto">
            <ArticleList></ArticleList>
          </div>
        </div>
      </div>
      <Modal isOpen={isOpen} onOpenChange={onOpenChange}>
        <ModalContent>
          {(onClose) => (
            <>
              <ModalHeader className="flex flex-col gap-1">
                添加公众号源
              </ModalHeader>
              <ModalBody>
                <Textarea
                  value={wxsLink}
                  onValueChange={setWxsLink}
                  autoFocus
                  label="分享链接"
                  placeholder="输入公众号文章分享链接,一行一条,如 https://mp.weixin.qq.com/s/xxxxxx https://mp.weixin.qq.com/s/xxxxxx"
                  variant="bordered"
                />
              </ModalBody>
              <ModalFooter>
                <Button color="danger" variant="flat" onPress={onClose}>
                  取消
                </Button>
                <Button
                  color="primary"
                  isDisabled={
                    !wxsLink.startsWith('https://mp.weixin.qq.com/s/')
                  }
                  onPress={handleConfirm}
                  isLoading={
                    isAddFeedLoading ||
                    isGetMpInfoLoading ||
                    isGetArticlesLoading
                  }
                >
                  确定
                </Button>
              </ModalFooter>
            </>
          )}
        </ModalContent>
      </Modal>
    </>
  );
};

export default Feeds;


================================================
FILE: apps/web/src/pages/feeds/list.tsx
================================================
import { FC, useMemo } from 'react';
import {
  Table,
  TableHeader,
  TableColumn,
  TableBody,
  TableRow,
  TableCell,
  getKeyValue,
  Button,
  Spinner,
  Link,
} from '@nextui-org/react';
import { trpc } from '@web/utils/trpc';
import dayjs from 'dayjs';
import { useParams } from 'react-router-dom';

const ArticleList: FC = () => {
  const { id } = useParams();

  const mpId = id || '';

  const { data, fetchNextPage, isLoading, hasNextPage } =
    trpc.article.list.useInfiniteQuery(
      {
        limit: 20,
        mpId: mpId,
      },
      {
        getNextPageParam: (lastPage) => lastPage.nextCursor,
      },
    );

  const items = useMemo(() => {
    const items = data
      ? data.pages.reduce((acc, page) => [...acc, ...page.items], [] as any[])
      : [];

    return items;
  }, [data]);

  return (
    <div>
      <Table
        classNames={{
          base: 'h-full',
          table: 'min-h-[420px]',
        }}
        aria-label="文章列表"
        bottomContent={
          hasNextPage && !isLoading ? (
            <div className="flex w-full justify-center">
              <Button
                isDisabled={isLoading}
                variant="flat"
                onPress={() => {
                  fetchNextPage();
                }}
              >
                {isLoading && <Spinner color="white" size="sm" />}
                加载更多
              </Button>
            </div>
          ) : null
        }
      >
        <TableHeader>
          <TableColumn key="title">标题</TableColumn>
          <TableColumn width={180} key="publishTime">
            发布时间
          </TableColumn>
        </TableHeader>
        <TableBody
          isLoading={isLoading}
          emptyContent={'暂无数据'}
          items={items || []}
          loadingContent={<Spinner />}
        >
          {(item) => (
            <TableRow key={item.id}>
              {(columnKey) => {
                let value = getKeyValue(item, columnKey);

                if (columnKey === 'publishTime') {
                  value = dayjs(value * 1e3).format('YYYY-MM-DD HH:mm:ss');
                  return <TableCell>{value}</TableCell>;
                }

                if (columnKey === 'title') {
                  return (
                    <TableCell>
                      <Link
                        className="visited:text-neutral-400"
                        isBlock
                        showAnchorIcon
                        color="foreground"
                        target="_blank"
                        href={`https://mp.weixin.qq.com/s/${item.id}`}
                      >
                        {value}
                      </Link>
                    </TableCell>
                  );
                }
                return <TableCell>{value}</TableCell>;
              }}
            </TableRow>
          )}
        </TableBody>
      </Table>
    </div>
  );
};

export default ArticleList;


================================================
FILE: apps/web/src/pages/login/index.tsx
================================================
import { Button, Input } from '@nextui-org/react';
import { setAuthCode } from '@web/utils/auth';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';

const LoginPage = () => {
  const [codeValue, setCodeValue] = useState('');

  const navigate = useNavigate();

  return (
    <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">
      <Input
        value={codeValue}
        onValueChange={setCodeValue}
        label="AuthCode"
        placeholder="请输入auth code"
      />
      <Button
        color="primary"
        onPress={() => {
          setAuthCode(codeValue);
          navigate('/');
        }}
      >
        确认
      </Button>
    </div>
  );
};

export default LoginPage;


================================================
FILE: apps/web/src/provider/theme.tsx
================================================
import { NextUIProvider } from '@nextui-org/react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { useNavigate } from 'react-router-dom';

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const navigate = useNavigate();

  return (
    <NextUIProvider navigate={navigate}>
      <NextThemesProvider attribute="class" enableSystem>
        {children}
      </NextThemesProvider>
    </NextUIProvider>
  );
}

export default ThemeProvider;


================================================
FILE: apps/web/src/provider/trpc.tsx
================================================
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink, loggerLink } from '@trpc/client';
import { useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { toast } from 'sonner';
import { isTRPCClientError, trpc } from '../utils/trpc';
import { getAuthCode, setAuthCode } from '../utils/auth';
import { enabledAuthCode, serverOriginUrl } from '../utils/env';

export const TrpcProvider: React.FC<{ children: React.ReactNode }> = ({
  children,
}) => {
  const navigate = useNavigate();

  const handleNoAuth = () => {
    if (enabledAuthCode) {
      setAuthCode('');
      navigate('/login');
    }
  };
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            refetchOnWindowFocus: false,
            refetchOnReconnect: true,
            refetchIntervalInBackground: false,
            retryDelay: (retryCount) => Math.min(retryCount * 1000, 60 * 1000),
            retry(failureCount, error) {
              console.log('failureCount: ', failureCount);
              if (isTRPCClientError(error)) {
                if (error.data?.httpStatus === 401) {
                  return false;
                }
              }
              return failureCount < 3;
            },
            onError(error) {
              console.error('queries onError: ', error);
              if (isTRPCClientError(error)) {
                if (error.data?.httpStatus === 401) {
                  toast.error('无权限', {
                    description: error.message,
                  });

                  handleNoAuth();
                } else {
                  toast.error('请求失败!', {
                    description: error.message,
                  });
                }
              }
            },
          },
          mutations: {
            onError(error) {
              console.error('mutations onError: ', error);
              if (isTRPCClientError(error)) {
                if (error.data?.httpStatus === 401) {
                  toast.error('无权限', {
                    description: error.message,
                  });
                  handleNoAuth();
                } else {
                  toast.error('请求失败!', {
                    description: error.message,
                  });
                }
              }
            },
          },
        },
      }),
  );

  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        loggerLink({
          enabled: () => true,
        }),
        httpBatchLink({
          url: serverOriginUrl + '/trpc',
          async headers() {
            const token = getAuthCode();

            if (!token) {
              handleNoAuth();
              return {};
            }

            return token
              ? {
                  Authorization: `${token}`,
                }
              : {};
          },
        }),
      ],
    }),
  );
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </trpc.Provider>
  );
};


================================================
FILE: apps/web/src/types.ts
================================================
import { SVGProps } from 'react';

export type IconSvgProps = SVGProps<SVGSVGElement> & {
  size?: number;
};


================================================
FILE: apps/web/src/utils/auth.ts
================================================
let token: string | null = null;

export const getAuthCode = () => {
  if (token !== null) {
    return token;
  }

  token = window.localStorage.getItem('authCode');
  return token;
};

export const setAuthCode = (authCode: string | null) => {
  token = authCode;
  if (!authCode) {
    window.localStorage.removeItem('authCode');
    return;
  }
  window.localStorage.setItem('authCode', authCode);
};


================================================
FILE: apps/web/src/utils/env.ts
================================================
export const isProd = import.meta.env.PROD;

export const serverOriginUrl = isProd
  ? window.__WEWE_RSS_SERVER_ORIGIN_URL__
  : import.meta.env.VITE_SERVER_ORIGIN_URL;

export const appVersion = __APP_VERSION__;

export const enabledAuthCode =
  window.__WEWE_RSS_ENABLED_AUTH_CODE__ === false ? false : true;


================================================
FILE: apps/web/src/utils/trpc.ts
================================================
import { AppRouter } from '@server/trpc/trpc.router';
import { TRPCClientError, createTRPCReact } from '@trpc/react-query';

export const trpc = createTRPCReact<AppRouter>();

export function isTRPCClientError(
  cause: unknown,
): cause is TRPCClientError<AppRouter> {
  return cause instanceof TRPCClientError;
}


================================================
FILE: apps/web/src/vite-env.d.ts
================================================
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_SERVER_ORIGIN_URL: string;
  readonly VITE_ENV: string;
}

interface Window {
  __WEWE_RSS_SERVER_ORIGIN_URL__?: string;
  __WEWE_RSS_ENABLED_AUTH_CODE__?: boolean;
}

declare const __APP_VERSION__: string;


================================================
FILE: apps/web/tailwind.config.ts
================================================
import type { Config } from 'tailwindcss';
import { nextui } from '@nextui-org/react';

const config: Config = {
  content: [
    './index.html',
    './src/**/*.{js,ts,jsx,tsx}',
    '../../node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}',
  ],
  theme: {
    extend: {},
  },
  darkMode: 'class',
  plugins: [nextui()],
};
export default config;


================================================
FILE: apps/web/tsconfig.json
================================================
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",

    /* Linting */
    "strict": true,
    "noUnusedLocals": false,
    "noUnusedParameters": false,
    "noFallthroughCasesInSwitch": true,
    "noImplicitAny": false
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}


================================================
FILE: apps/web/tsconfig.node.json
================================================
{
  "compilerOptions": {
    "composite": true,
    "skipLibCheck": true,
    "module": "ESNext",
    "moduleResolution": "bundler",
    "allowSyntheticDefaultImports": true,
    "strict": true
  },
  "include": ["vite.config.ts"]
}


================================================
FILE: apps/web/vite.config.ts
================================================
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
import { readFileSync } from 'fs';

const projectRootDir = resolve(__dirname);

const isProd = process.env.NODE_ENV === 'production';

console.log('process.env.NODE_ENV: ', process.env.NODE_ENV);

const packageJson = JSON.parse(
  readFileSync(resolve(__dirname, './package.json'), 'utf-8'),
);

// https://vitejs.dev/config/
export default defineConfig({
  base: '/dash',
  define: {
    __APP_VERSION__: JSON.stringify(packageJson.version),
  },
  plugins: [
    react(),
    !isProd
      ? null
      : {
          name: 'renameIndex',
          enforce: 'post',
          generateBundle(options, bundle) {
            const indexHtml = bundle['index.html'];
            indexHtml.fileName = 'index.hbs';
          },
        },
  ],
  resolve: {
    alias: [
      {
        find: '@server',
        replacement: resolve(projectRootDir, '../apps/server/src'),
      },
      {
        find: '@web',
        replacement: resolve(projectRootDir, './src'),
      },
    ],
  },
  build: {
    emptyOutDir: true,
    outDir: resolve(projectRootDir, '..', 'server', 'client'),
  },
});


================================================
FILE: assets/nginx.example.conf
================================================
server {

  listen 80;

  server_name yourdomain;
  
  location / {

    proxy_pass http://127.0.0.1:4000;
    proxy_http_version 	1.1;
    proxy_set_header	Connection		"";
    proxy_set_header   	Host			$http_host;
    proxy_set_header 	X-Forwarded-Proto 	$scheme;
    proxy_set_header   	X-Real-IP          	$remote_addr;
    proxy_set_header   	X-Forwarded-For    	$proxy_add_x_forwarded_for;
    proxy_set_header    Accept-Encoding gzip;

    proxy_buffering off;
    proxy_cache off;
    
    send_timeout 300;
    proxy_connect_timeout 300;
    proxy_send_timeout 300;
    proxy_read_timeout 300;
  }

}




================================================
FILE: docker-compose.dev.yml
================================================
services:
  mysql:
    image: mysql:8.3.0
    command: --default-authentication-plugin=mysql_native_password
    environment:
      MYSQL_ROOT_PASSWORD: 123456
      TZ: 'Asia/Shanghai'
    ports:
      - 3306:3306
volumes:
  mysql:


================================================
FILE: docker-compose.sqlite.yml
================================================
version: '3.9'

services:
  app:
    image: cooderl/wewe-rss-sqlite:latest
    ports:
      - 4000:4000
    environment:
      # 数据库连接地址
      # - DATABASE_URL=file:../data/wewe-rss.db
      - DATABASE_TYPE=sqlite
      # 服务接口请求授权码
      - AUTH_CODE=123567
      # 提取全文内容模式
      # - FEED_MODE=fulltext
      # 定时更新订阅源Cron表达式
      # - CRON_EXPRESSION=35 5,17 * * *
      # 服务接口请求限制,每分钟请求次数
      # - MAX_REQUEST_PER_MINUTE=60
      # 外网访问时,需设置为服务器的公网 IP 或者域名地址
      # - SERVER_ORIGIN_URL=http://localhost:4000

    volumes:
      # 映射数据库文件存储位置,容器重启后不丢失
      - ./data:/app/data


================================================
FILE: docker-compose.yml
================================================
version: '3.9'

services:
  db:
    image: mysql:8.3.0
    command: --mysql-native-password=ON
    environment:
      # 请修改为自己的密码
      MYSQL_ROOT_PASSWORD: 123456
      TZ: 'Asia/Shanghai'
      MYSQL_DATABASE: 'wewe-rss'
    # ports:
    #   - 13306:3306
    volumes:
      - db_data:/var/lib/mysql
    healthcheck:
      test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost']
      timeout: 45s
      interval: 10s
      retries: 10

  app:
    image: cooderl/wewe-rss:latest
    ports:
      - 4000:4000
    depends_on:
      db:
        condition: service_healthy
    environment:
      # 数据库连接地址
      - DATABASE_URL=mysql://root:123456@db:3306/wewe-rss?schema=public&connect_timeout=30&pool_timeout=30&socket_timeout=30
      # 服务接口请求授权码
      - AUTH_CODE=123567
      # 提取全文内容模式
      # - FEED_MODE=fulltext
      # 定时更新订阅源Cron表达式
      # - CRON_EXPRESSION=35 5,17 * * *
      # 服务接口请求限制,每分钟请求次数
      # - MAX_REQUEST_PER_MINUTE=60
      # 外网访问时,需设置为服务器的公网 IP 或者域名地址
      # - SERVER_ORIGIN_URL=http://localhost:4000

networks:
  wewe-rss:

volumes:
  db_data:


================================================
FILE: package.json
================================================
{
  "name": "wewe-rss",
  "version": "2.6.1",
  "private": true,
  "author": "cooderl <cooder@111965.xyz>",
  "description": "",
  "main": "index.js",
  "engines": {
    "node": ">=20.9.0",
    "pnpm": ">=8.6.1",
    "vscode": ">=1.79"
  },
  "scripts": {
    "dev": "pnpm run --parallel dev",
    "build:server": "pnpm --filter server build",
    "build:web": "pnpm --filter web build",
    "start:server": "pnpm --filter server start:prod",
    "start:web": "pnpm --filter web start",
    "fmt": "prettier --write .",
    "fmt.check": "prettier --check ."
  },
  "devDependencies": {
    "prettier": "^3.2.5"
  }
}

================================================
FILE: pnpm-workspace.yaml
================================================
packages:
  - 'apps/*'


================================================
FILE: release.sh
================================================
#!/bin/bash

# 检查是否提供了版本号
if [ "$#" -ne 1 ]; then
    echo "Usage: $0 <new-version>"
    exit 1
fi

# 新版本号
NEW_VERSION=$1

# 更新根目录下的 package.json
sed -i '' "s/\"version\": \".*\"/\"version\": \"$NEW_VERSION\"/" package.json

# 更新 apps 目录下所有子包的 package.json
for d in apps/*; do
  if [ -d "$d" ] && [ -f "$d/package.json" ]; then
    sed -i '' "s/\"version\": \".*\"/\"version\": \"$NEW_VERSION\"/" "$d/package.json"
  fi
done

echo "All packages updated to version $NEW_VERSION"

# 创建 Git 提交(可选)
git add .
git commit -m "Release version $NEW_VERSION"

# 创建 Git 标签
git tag "v$NEW_VERSION"

# 推送更改和标签到远程仓库
git push && git push origin --tags

echo "Git tag v$NEW_VERSION has been created and pushed"

================================================
FILE: tsconfig.json
================================================
{
  "compilerOptions": {
    "baseUrl": ".",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "incremental": true,
    "skipLibCheck": true,
    "strictNullChecks": true,
    "noImplicitAny": false,
    "strictBindCallApply": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "paths": {
      "@server/*": [
        "./apps/server/src/*"
      ],
      "@web/*": [
        "./apps/web/src/*"
      ]
    }
  }
}

================================================
FILE: wewe-rss-dingtalk/Dockerfile
================================================
FROM python:3.8.12-slim

WORKDIR /app
COPY . .

RUN pip install -r requirements.txt
ENV TZ=Asia/Shanghai \
    DEBIAN_FRONTEND=noninteractive

CMD python3 /app/main.py


================================================
FILE: wewe-rss-dingtalk/README.md
================================================
### 修改main.py,输入dingtalk的access_token和secret

```
   access_token = ''
   secret = ''  # 创建机器人时钉钉设置页面有提供
```

### 修改根目录下的docker-compose.yaml文件,去掉以下字段的注释

```
    # ports:
    #   - 13306:3306
```

### python3 main.py就可以运行

### 或者部署成docker,运行

```
    sudo docker-compose up -d
```


================================================
FILE: wewe-rss-dingtalk/docker-compose.yml
================================================
version: '3.9'

services:
  wewe-rss-dingtalk:
    build: .
    container_name: wewe-rss-dingtalk


================================================
FILE: wewe-rss-dingtalk/main.py
================================================
import mysql.connector
import requests
import json
import os
import time
from datetime import datetime, timedelta
import pytz
from dingtalkchatbot.chatbot import DingtalkChatbot, ActionCard, FeedLink, CardItem

def get_subjects_json():
    # 连接MySQL数据库
    mydb = mysql.connector.connect(
        host="localhost",
        port="13306",
        user="root",
        password="123456",
        database="wewe-rss"
    )
    # 查询符合条件的数据, 用created_at来判断,因为publish_time是发文时间,rss更新时间会滞后
    mycursor = mydb.cursor()
    query = """SELECT a.id, a.title, a.pic_url, a.publish_time, b.mp_name
        FROM articles AS a, feeds AS b
        WHERE a.mp_id = b.id
        AND a.created_at >= NOW() - INTERVAL 12 HOUR 
        ORDER BY a.publish_time DESC"""
        # 4hour +8 to fix created time is UTC time.
    mycursor.execute(query)
    results = mycursor.fetchall()

    # 组装数据为JSON格式
    data = []
    for result in results:
        subject = {
            "id": result[0],
            "title": result[1],
            "pic_url": result[2],
            "publish_time": result[3],
            "mp_name": result[4]
        }
        data.append(subject)

    json_data = json.dumps(data, indent=4)
    print(json_data)
    return json_data

def dingbot_markdown(access_token, secret, rss_list):
    new_webhook = f'https://oapi.dingtalk.com/robot/send?access_token={access_token}'
    xiaoding = DingtalkChatbot(new_webhook, secret=secret, pc_slide=True, fail_notice=False)

    text = []
    for data in rss_list:
        # 创建CardItem对象
        mp_name = data['mp_name']
        url = 'https://mp.weixin.qq.com/s/' + str(data["id"])
        unix_timestamp = data['publish_time']
        # 将 Unix 时间戳转换为北京时间
        #转换成localtime
        time_local = time.localtime(unix_timestamp)
        #转换成新的时间格式(2016-05-05 20:28:54)
        beijing_time = time.strftime("%Y-%m-%d %H:%M:%S",time_local)
        text_content = f'> **{mp_name}** [' + data["title"] + '](' + url + ') ' + str(beijing_time) + '\n'
        # Markdown消息@指定用户
        text.append(text_content)

    title = '## 微信公众号<最近4小时更新> \n\n'
    markdown_text = title +  '\n'.join(text)
    print(markdown_text)
    res = xiaoding.send_markdown(title=title, text=markdown_text)
    print(f"send sucess, res: {res}")


def send_dingtalk_msg(access_token, secret):
    data = get_subjects_json()
    rss_list = json.loads(data)
    if len(rss_list) != 0:
        dingbot_markdown(access_token, secret, rss_list) 

if __name__ == '__main__':

    access_token = ''
    secret = ''  # 创建机器人时钉钉设置页面有提供

    while True:
        send_dingtalk_msg(access_token, secret)
        time.sleep( 4 * 60 * 60 ) # run every 4 hours


================================================
FILE: wewe-rss-dingtalk/requirements.txt
================================================
DingtalkChatbot==1.5.3
mysql-connector-python
jason
pytz
Download .txt
gitextract_odxctc3w/

├── .dockerignore
├── .github/
│   └── workflows/
│       └── docker-release.yml
├── .gitignore
├── .markdownlint.yaml
├── .npmrc
├── .prettierignore
├── .prettierrc.json
├── .vscode/
│   ├── extensions.json
│   └── settings.json
├── Dockerfile
├── LICENSE
├── README.md
├── apps/
│   ├── server/
│   │   ├── .eslintrc.js
│   │   ├── .gitignore
│   │   ├── .prettierrc.json
│   │   ├── README.md
│   │   ├── docker-bootstrap.sh
│   │   ├── nest-cli.json
│   │   ├── package.json
│   │   ├── prisma/
│   │   │   ├── migrations/
│   │   │   │   ├── 20240227153512_init/
│   │   │   │   │   └── migration.sql
│   │   │   │   ├── 20241212153618_has_history/
│   │   │   │   │   └── migration.sql
│   │   │   │   └── migration_lock.toml
│   │   │   └── schema.prisma
│   │   ├── prisma-sqlite/
│   │   │   ├── migrations/
│   │   │   │   ├── 20240301104100_init/
│   │   │   │   │   └── migration.sql
│   │   │   │   ├── 20241214172323_has_history/
│   │   │   │   │   └── migration.sql
│   │   │   │   └── migration_lock.toml
│   │   │   └── schema.prisma
│   │   ├── src/
│   │   │   ├── app.controller.spec.ts
│   │   │   ├── app.controller.ts
│   │   │   ├── app.module.ts
│   │   │   ├── app.service.ts
│   │   │   ├── configuration.ts
│   │   │   ├── constants.ts
│   │   │   ├── feeds/
│   │   │   │   ├── feeds.controller.spec.ts
│   │   │   │   ├── feeds.controller.ts
│   │   │   │   ├── feeds.module.ts
│   │   │   │   ├── feeds.service.spec.ts
│   │   │   │   └── feeds.service.ts
│   │   │   ├── main.ts
│   │   │   ├── prisma/
│   │   │   │   ├── prisma.module.ts
│   │   │   │   └── prisma.service.ts
│   │   │   └── trpc/
│   │   │       ├── trpc.module.ts
│   │   │       ├── trpc.router.ts
│   │   │       └── trpc.service.ts
│   │   ├── test/
│   │   │   ├── app.e2e-spec.ts
│   │   │   └── jest-e2e.json
│   │   ├── tsconfig.build.json
│   │   └── tsconfig.json
│   └── web/
│       ├── .eslintrc.cjs
│       ├── .gitignore
│       ├── README.md
│       ├── index.html
│       ├── package.json
│       ├── postcss.config.js
│       ├── src/
│       │   ├── App.tsx
│       │   ├── components/
│       │   │   ├── GitHubIcon.tsx
│       │   │   ├── Nav.tsx
│       │   │   ├── PlusIcon.tsx
│       │   │   ├── StatusDropdown.tsx
│       │   │   └── ThemeSwitcher.tsx
│       │   ├── constants.ts
│       │   ├── index.css
│       │   ├── layouts/
│       │   │   └── base.tsx
│       │   ├── main.tsx
│       │   ├── pages/
│       │   │   ├── accounts/
│       │   │   │   └── index.tsx
│       │   │   ├── feeds/
│       │   │   │   ├── index.tsx
│       │   │   │   └── list.tsx
│       │   │   └── login/
│       │   │       └── index.tsx
│       │   ├── provider/
│       │   │   ├── theme.tsx
│       │   │   └── trpc.tsx
│       │   ├── types.ts
│       │   ├── utils/
│       │   │   ├── auth.ts
│       │   │   ├── env.ts
│       │   │   └── trpc.ts
│       │   └── vite-env.d.ts
│       ├── tailwind.config.ts
│       ├── tsconfig.json
│       ├── tsconfig.node.json
│       └── vite.config.ts
├── assets/
│   └── nginx.example.conf
├── docker-compose.dev.yml
├── docker-compose.sqlite.yml
├── docker-compose.yml
├── package.json
├── pnpm-workspace.yaml
├── release.sh
├── tsconfig.json
└── wewe-rss-dingtalk/
    ├── Dockerfile
    ├── README.md
    ├── docker-compose.yml
    ├── main.py
    └── requirements.txt
Download .txt
SYMBOL INDEX (74 symbols across 27 files)

FILE: apps/server/prisma-sqlite/migrations/20240301104100_init/migration.sql
  type "accounts" (line 2) | CREATE TABLE "accounts" (
  type "feeds" (line 12) | CREATE TABLE "feeds" (
  type "articles" (line 25) | CREATE TABLE "articles" (

FILE: apps/server/prisma/migrations/20240227153512_init/migration.sql
  type `accounts` (line 2) | CREATE TABLE `accounts` (
  type `feeds` (line 14) | CREATE TABLE `feeds` (
  type `articles` (line 29) | CREATE TABLE `articles` (

FILE: apps/server/src/app.controller.ts
  class AppController (line 8) | class AppController {
    method constructor (line 9) | constructor(
    method getHello (line 15) | getHello(): string {
    method forRobot (line 20) | forRobot(): string {
    method getFavicon (line 25) | getFavicon(@Response() res: Res) {
    method dashRender (line 35) | dashRender() {

FILE: apps/server/src/app.module.ts
  method useFactory (line 24) | useFactory(config: ConfigService) {
  class AppModule (line 39) | class AppModule {}

FILE: apps/server/src/app.service.ts
  class AppService (line 5) | class AppService {
    method constructor (line 6) | constructor(private readonly configService: ConfigService) {}
    method getHello (line 7) | getHello(): string {

FILE: apps/server/src/configuration.ts
  type ConfigurationType (line 40) | type ConfigurationType = ReturnType<typeof configuration>;

FILE: apps/server/src/feeds/feeds.controller.ts
  class FeedsController (line 16) | class FeedsController {
    method constructor (line 19) | constructor(private readonly feedsService: FeedsService) {}
    method getFeedList (line 22) | async getFeedList() {
    method getFeeds (line 27) | async getFeeds(
    method getFeed (line 53) | async getFeed(

FILE: apps/server/src/feeds/feeds.module.ts
  class FeedsModule (line 12) | class FeedsModule {}

FILE: apps/server/src/feeds/feeds.service.ts
  class FeedsService (line 23) | class FeedsService {
    method constructor (line 27) | constructor(
    method handleUpdateFeedsCron (line 73) | async handleUpdateFeedsCron() {
    method cleanHtml (line 103) | async cleanHtml(source: string) {
    method getHtmlByUrl (line 125) | async getHtmlByUrl(url: string) {
    method tryGetContent (line 137) | async tryGetContent(id: string) {
    method renderFeed (line 152) | async renderFeed({
    method handleGenerateFeed (line 228) | async handleGenerateFeed({
    method getFeedList (line 318) | async getFeedList() {
    method updateFeed (line 333) | async updateFeed(id: string) {

FILE: apps/server/src/main.ts
  function bootstrap (line 18) | async function bootstrap() {

FILE: apps/server/src/prisma/prisma.module.ts
  class PrismaModule (line 8) | class PrismaModule {}

FILE: apps/server/src/prisma/prisma.service.ts
  class PrismaService (line 5) | class PrismaService extends PrismaClient implements OnModuleInit {
    method onModuleInit (line 6) | async onModuleInit() {

FILE: apps/server/src/trpc/trpc.module.ts
  class TrpcModule (line 12) | class TrpcModule {}

FILE: apps/server/src/trpc/trpc.router.ts
  class TrpcRouter (line 12) | class TrpcRouter {
    method constructor (line 13) | constructor(
    method applyMiddleware (line 427) | async applyMiddleware(app: INestApplication) {
  type AppRouter (line 453) | type AppRouter = TrpcRouter[`appRouter`];

FILE: apps/server/src/trpc/trpc.service.ts
  class TrpcService (line 21) | class TrpcService {
    method constructor (line 38) | constructor(
    method getTodayDate (line 105) | private getTodayDate() {
    method getBlockedAccountIds (line 109) | getBlockedAccountIds() {
    method getAvailableAccount (line 116) | private async getAvailableAccount() {
    method getMpArticles (line 135) | async getMpArticles(mpId: string, page = 1, retryCount = 3) {
    method refreshMpArticlesAndUpdateFeed (line 174) | async refreshMpArticlesAndUpdateFeed(mpId: string, page = 1) {
    method getHistoryMpArticles (line 231) | async getHistoryMpArticles(mpId: string) {
    method refreshAllMpArticlesAndUpdateFeed (line 301) | async refreshAllMpArticlesAndUpdateFeed() {
    method getMpInfo (line 321) | async getMpInfo(url: string) {
    method createLoginUrl (line 347) | async createLoginUrl() {
    method getLoginResult (line 356) | async getLoginResult(id: string) {

FILE: apps/web/src/App.tsx
  function App (line 9) | function App() {

FILE: apps/web/src/components/StatusDropdown.tsx
  function StatusDropdown (line 11) | function StatusDropdown({

FILE: apps/web/src/components/ThemeSwitcher.tsx
  function ThemeSwitcher (line 40) | function ThemeSwitcher(props) {

FILE: apps/web/src/layouts/base.tsx
  function BaseLayout (line 6) | function BaseLayout() {

FILE: apps/web/src/pages/accounts/index.tsx
  method onSuccess (line 42) | onSuccess(data) {
  method onSuccess (line 56) | async onSuccess(data) {

FILE: apps/web/src/provider/theme.tsx
  function ThemeProvider (line 5) | function ThemeProvider({ children }: { children: React.ReactNode }) {

FILE: apps/web/src/provider/trpc.tsx
  method retry (line 30) | retry(failureCount, error) {
  method onError (line 39) | onError(error) {
  method onError (line 57) | onError(error) {
  method headers (line 85) | async headers() {

FILE: apps/web/src/types.ts
  type IconSvgProps (line 3) | type IconSvgProps = SVGProps<SVGSVGElement> & {

FILE: apps/web/src/utils/trpc.ts
  function isTRPCClientError (line 6) | function isTRPCClientError(

FILE: apps/web/src/vite-env.d.ts
  type ImportMetaEnv (line 3) | interface ImportMetaEnv {
  type Window (line 8) | interface Window {

FILE: apps/web/vite.config.ts
  method generateBundle (line 29) | generateBundle(options, bundle) {

FILE: wewe-rss-dingtalk/main.py
  function get_subjects_json (line 10) | def get_subjects_json():
  function dingbot_markdown (line 46) | def dingbot_markdown(access_token, secret, rss_list):
  function send_dingtalk_msg (line 72) | def send_dingtalk_msg(access_token, secret):
Condensed preview — 92 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (141K chars).
[
  {
    "path": ".dockerignore",
    "chars": 79,
    "preview": "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",
    "chars": 4201,
    "preview": "name: Build WeWeRSS images and push image to docker hub\non:\n  workflow_dispatch:\n  push:\n    # paths:\n    #   - \"apps/**"
  },
  {
    "path": ".gitignore",
    "chars": 2058,
    "preview": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n.pnpm-debug.log*\n\n# Diagnostic reports"
  },
  {
    "path": ".markdownlint.yaml",
    "chars": 160,
    "preview": "# Default state for all rules\ndefault: true\n\nline-length: false\n\n# MD033/no-inline-html - Inline HTML\nMD033:\n  # Allowed"
  },
  {
    "path": ".npmrc",
    "chars": 79,
    "preview": "public-hoist-pattern[]=*@nextui-org/*\nengine-strict=true\ndeploy-all-files=true\n"
  },
  {
    "path": ".prettierignore",
    "chars": 79,
    "preview": "**/*.log\n**/.DS_Store\n*.\n*.json\napps/web/.next\ndist\nnode_modules\npnpm-lock.yaml"
  },
  {
    "path": ".prettierrc.json",
    "chars": 68,
    "preview": "{\n  \"tabWidth\": 2,\n  \"singleQuote\": true,\n  \"trailingComma\": \"all\"\n}"
  },
  {
    "path": ".vscode/extensions.json",
    "chars": 410,
    "preview": "{\n  \"recommendations\": [\n    \"esbenp.prettier-vscode\",\n    \"dbaeumer.vscode-eslint\",\n    \"stylelint.vscode-stylelint\",\n "
  },
  {
    "path": ".vscode/settings.json",
    "chars": 1072,
    "preview": "{\n  \"typescript.tsdk\": \"node_modules/.pnpm/typescript@4.9.4/node_modules/typescript/lib\",\n  \"typescript.enablePromptUseW"
  },
  {
    "path": "Dockerfile",
    "chars": 1136,
    "preview": "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 buil"
  },
  {
    "path": "LICENSE",
    "chars": 1063,
    "preview": "MIT License\n\nCopyright (c) 2024 cooderl\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof"
  },
  {
    "path": "README.md",
    "chars": 5638,
    "preview": "<div align=\"center\">\n<img src=\"https://raw.githubusercontent.com/cooderl/wewe-rss/main/assets/logo.png\" width=\"80\" alt=\""
  },
  {
    "path": "apps/server/.eslintrc.js",
    "chars": 663,
    "preview": "module.exports = {\n  parser: '@typescript-eslint/parser',\n  parserOptions: {\n    project: 'tsconfig.json',\n    tsconfigR"
  },
  {
    "path": "apps/server/.gitignore",
    "chars": 82,
    "preview": "node_modules\n# Keep environment variables out of version control\n.env\n\nclient\ndata"
  },
  {
    "path": "apps/server/.prettierrc.json",
    "chars": 68,
    "preview": "{\n  \"tabWidth\": 2,\n  \"singleQuote\": true,\n  \"trailingComma\": \"all\"\n}"
  },
  {
    "path": "apps/server/README.md",
    "chars": 3346,
    "preview": "<p align=\"center\">\n  <a href=\"http://nestjs.com/\" target=\"blank\"><img src=\"https://nestjs.com/img/logo-small.svg\" width="
  },
  {
    "path": "apps/server/docker-bootstrap.sh",
    "chars": 289,
    "preview": "\n#!/bin/sh\n# ENVIRONEMTN from docker-compose.yaml doesn't get through to subprocesses\n# Need to explicit pass DATABASE_U"
  },
  {
    "path": "apps/server/nest-cli.json",
    "chars": 171,
    "preview": "{\n  \"$schema\": \"https://json.schemastore.org/nest-cli\",\n  \"collection\": \"@nestjs/schematics\",\n  \"sourceRoot\": \"src\",\n  \""
  },
  {
    "path": "apps/server/package.json",
    "chars": 2717,
    "preview": "{\n  \"name\": \"server\",\n  \"version\": \"2.6.1\",\n  \"description\": \"\",\n  \"author\": \"\",\n  \"private\": true,\n  \"license\": \"UNLICE"
  },
  {
    "path": "apps/server/prisma/migrations/20240227153512_init/migration.sql",
    "chars": 1349,
    "preview": "-- CreateTable\nCREATE TABLE `accounts` (\n    `id` VARCHAR(255) NOT NULL,\n    `token` VARCHAR(2048) NOT NULL,\n    `name` "
  },
  {
    "path": "apps/server/prisma/migrations/20241212153618_has_history/migration.sql",
    "chars": 83,
    "preview": "-- AlterTable\nALTER TABLE `feeds` ADD COLUMN `has_history` INTEGER NULL DEFAULT 1;\n"
  },
  {
    "path": "apps/server/prisma/migrations/migration_lock.toml",
    "chars": 121,
    "preview": "# 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",
    "chars": 1620,
    "preview": "datasource db {\n  provider = \"mysql\"\n  url      = env(\"DATABASE_URL\")\n}\n\ngenerator client {\n  provider      = \"prisma-cl"
  },
  {
    "path": "apps/server/prisma-sqlite/migrations/20240301104100_init/migration.sql",
    "chars": 1005,
    "preview": "-- CreateTable\nCREATE TABLE \"accounts\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"token\" TEXT NOT NULL,\n    \"name\" TEXT "
  },
  {
    "path": "apps/server/prisma-sqlite/migrations/20241214172323_has_history/migration.sql",
    "chars": 78,
    "preview": "-- AlterTable\nALTER TABLE \"feeds\" ADD COLUMN \"has_history\" INTEGER DEFAULT 1;\n"
  },
  {
    "path": "apps/server/prisma-sqlite/migrations/migration_lock.toml",
    "chars": 122,
    "preview": "# Please do not edit this file manually\n# It should be added in your version-control system (i.e. Git)\nprovider = \"sqlit"
  },
  {
    "path": "apps/server/prisma-sqlite/schema.prisma",
    "chars": 1417,
    "preview": "datasource db {\n  provider = \"sqlite\"\n  url      = env(\"DATABASE_URL\")\n}\n\ngenerator client {\n  provider      = \"prisma-c"
  },
  {
    "path": "apps/server/src/app.controller.spec.ts",
    "chars": 617,
    "preview": "import { Test, TestingModule } from '@nestjs/testing';\nimport { AppController } from './app.controller';\nimport { AppSer"
  },
  {
    "path": "apps/server/src/app.controller.ts",
    "chars": 3344,
    "preview": "import { Controller, Get, Response, Render } from '@nestjs/common';\nimport { AppService } from './app.service';\nimport {"
  },
  {
    "path": "apps/server/src/app.module.ts",
    "chars": 1165,
    "preview": "import { Module } from '@nestjs/common';\nimport { AppController } from './app.controller';\nimport { AppService } from '."
  },
  {
    "path": "apps/server/src/app.service.ts",
    "chars": 413,
    "preview": "import { Injectable } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\n\n@Injectable()\nexport class"
  },
  {
    "path": "apps/server/src/configuration.ts",
    "chars": 1132,
    "preview": "const configuration = () => {\n  const isProd = process.env.NODE_ENV === 'production';\n  const port = process.env.PORT ||"
  },
  {
    "path": "apps/server/src/constants.ts",
    "chars": 370,
    "preview": "export const statusMap = {\n  // 0:失效 1:启用 2:禁用\n  INVALID: 0,\n  ENABLE: 1,\n  DISABLE: 2,\n};\n\nexport const feedTypes = ['r"
  },
  {
    "path": "apps/server/src/feeds/feeds.controller.spec.ts",
    "chars": 485,
    "preview": "import { Test, TestingModule } from '@nestjs/testing';\nimport { FeedsController } from './feeds.controller';\n\ndescribe('"
  },
  {
    "path": "apps/server/src/feeds/feeds.controller.ts",
    "chars": 2080,
    "preview": "import {\n  Controller,\n  DefaultValuePipe,\n  Get,\n  Logger,\n  Param,\n  ParseIntPipe,\n  Query,\n  Request,\n  Response,\n} f"
  },
  {
    "path": "apps/server/src/feeds/feeds.module.ts",
    "chars": 403,
    "preview": "import { Module } from '@nestjs/common';\nimport { FeedsController } from './feeds.controller';\nimport { FeedsService } f"
  },
  {
    "path": "apps/server/src/feeds/feeds.service.spec.ts",
    "chars": 453,
    "preview": "import { Test, TestingModule } from '@nestjs/testing';\nimport { FeedsService } from './feeds.service';\n\ndescribe('FeedsS"
  },
  {
    "path": "apps/server/src/feeds/feeds.service.ts",
    "chars": 9777,
    "preview": "import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';\nimport { PrismaService } from '@server/p"
  },
  {
    "path": "apps/server/src/main.ts",
    "chars": 1441,
    "preview": "import { NestFactory } from '@nestjs/core';\nimport { AppModule } from './app.module';\nimport { TrpcRouter } from '@serve"
  },
  {
    "path": "apps/server/src/prisma/prisma.module.ts",
    "chars": 192,
    "preview": "import { Module } from '@nestjs/common';\nimport { PrismaService } from './prisma.service';\n\n@Module({\n  providers: [Pris"
  },
  {
    "path": "apps/server/src/prisma/prisma.service.ts",
    "chars": 253,
    "preview": "import { Injectable, OnModuleInit } from '@nestjs/common';\nimport { PrismaClient } from '@prisma/client';\n\n@Injectable()"
  },
  {
    "path": "apps/server/src/trpc/trpc.module.ts",
    "chars": 379,
    "preview": "import { Module } from '@nestjs/common';\nimport { TrpcService } from '@server/trpc/trpc.service';\nimport { TrpcRouter } "
  },
  {
    "path": "apps/server/src/trpc/trpc.router.ts",
    "chars": 12893,
    "preview": "import { INestApplication, Injectable, Logger } from '@nestjs/common';\nimport { z } from 'zod';\nimport { TrpcService } f"
  },
  {
    "path": "apps/server/src/trpc/trpc.service.ts",
    "chars": 9887,
    "preview": "import { Injectable, Logger } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport { Configurat"
  },
  {
    "path": "apps/server/test/app.e2e-spec.ts",
    "chars": 630,
    "preview": "import { Test, TestingModule } from '@nestjs/testing';\nimport { INestApplication } from '@nestjs/common';\nimport * as re"
  },
  {
    "path": "apps/server/test/jest-e2e.json",
    "chars": 183,
    "preview": "{\n  \"moduleFileExtensions\": [\"js\", \"json\", \"ts\"],\n  \"rootDir\": \".\",\n  \"testEnvironment\": \"node\",\n  \"testRegex\": \".e2e-sp"
  },
  {
    "path": "apps/server/tsconfig.build.json",
    "chars": 97,
    "preview": "{\n  \"extends\": \"./tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"test\", \"dist\", \"**/*spec.ts\"]\n}\n"
  },
  {
    "path": "apps/server/tsconfig.json",
    "chars": 285,
    "preview": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"module\": \"commonjs\",\n    \"declaration\": true,\n    \"rem"
  },
  {
    "path": "apps/web/.eslintrc.cjs",
    "chars": 487,
    "preview": "module.exports = {\n  root: true,\n  env: { browser: true, es2020: true },\n  extends: [\n    'eslint:recommended',\n    'plu"
  },
  {
    "path": "apps/web/.gitignore",
    "chars": 253,
    "preview": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndis"
  },
  {
    "path": "apps/web/README.md",
    "chars": 1301,
    "preview": "# React + TypeScript + Vite\n\nThis template provides a minimal setup to get React working in Vite with HMR and some ESLin"
  },
  {
    "path": "apps/web/index.html",
    "chars": 569,
    "preview": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" href=\"{{ iconUrl }}\" />\n    "
  },
  {
    "path": "apps/web/package.json",
    "chars": 1199,
    "preview": "{\n  \"name\": \"web\",\n  \"private\": true,\n  \"version\": \"2.6.1\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"b"
  },
  {
    "path": "apps/web/postcss.config.js",
    "chars": 81,
    "preview": "export default {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n};\n"
  },
  {
    "path": "apps/web/src/App.tsx",
    "chars": 871,
    "preview": "import { BrowserRouter, Route, Routes } from 'react-router-dom';\nimport Feeds from './pages/feeds';\nimport Login from '."
  },
  {
    "path": "apps/web/src/components/GitHubIcon.tsx",
    "chars": 1208,
    "preview": "import { IconSvgProps } from '../types';\n\nexport const GitHubIcon = ({\n  size = 24,\n  width,\n  height,\n  ...props\n}: Ico"
  },
  {
    "path": "apps/web/src/components/Nav.tsx",
    "chars": 3043,
    "preview": "import {\n  Badge,\n  Image,\n  Link,\n  Navbar,\n  NavbarBrand,\n  NavbarContent,\n  NavbarItem,\n  Tooltip,\n} from '@nextui-or"
  },
  {
    "path": "apps/web/src/components/PlusIcon.tsx",
    "chars": 540,
    "preview": "import { IconSvgProps } from '../types';\n\nexport const PlusIcon = ({\n  size = 24,\n  width,\n  height,\n  ...props\n}: IconS"
  },
  {
    "path": "apps/web/src/components/StatusDropdown.tsx",
    "chars": 1080,
    "preview": "import React from 'react';\nimport {\n  Dropdown,\n  DropdownTrigger,\n  DropdownMenu,\n  DropdownItem,\n  Button,\n} from '@ne"
  },
  {
    "path": "apps/web/src/components/ThemeSwitcher.tsx",
    "chars": 2554,
    "preview": "'use client';\n\nimport { VisuallyHidden, useSwitch } from '@nextui-org/react';\nimport { useTheme } from 'next-themes';\n\ne"
  },
  {
    "path": "apps/web/src/constants.ts",
    "chars": 158,
    "preview": "export const statusMap = {\n  0: { label: '失效', color: 'danger' },\n  1: { label: '启用', color: 'success' },\n  2: { label: "
  },
  {
    "path": "apps/web/src/index.css",
    "chars": 59,
    "preview": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n"
  },
  {
    "path": "apps/web/src/layouts/base.tsx",
    "chars": 431,
    "preview": "import { Toaster } from 'sonner';\nimport { Outlet } from 'react-router-dom';\n\nimport Nav from '../components/Nav';\n\nexpo"
  },
  {
    "path": "apps/web/src/main.tsx",
    "chars": 164,
    "preview": "import ReactDOM from 'react-dom/client';\nimport App from './App.tsx';\nimport './index.css';\n\nReactDOM.createRoot(documen"
  },
  {
    "path": "apps/web/src/pages/accounts/index.tsx",
    "chars": 6668,
    "preview": "import {\n  Modal,\n  ModalContent,\n  ModalHeader,\n  ModalBody,\n  Button,\n  useDisclosure,\n  Spinner,\n  Table,\n  TableBody"
  },
  {
    "path": "apps/web/src/pages/feeds/index.tsx",
    "chars": 14475,
    "preview": "import {\n  Avatar,\n  Button,\n  Divider,\n  Listbox,\n  ListboxItem,\n  ListboxSection,\n  Modal,\n  ModalBody,\n  ModalContent"
  },
  {
    "path": "apps/web/src/pages/feeds/list.tsx",
    "chars": 2933,
    "preview": "import { FC, useMemo } from 'react';\nimport {\n  Table,\n  TableHeader,\n  TableColumn,\n  TableBody,\n  TableRow,\n  TableCel"
  },
  {
    "path": "apps/web/src/pages/login/index.tsx",
    "chars": 793,
    "preview": "import { Button, Input } from '@nextui-org/react';\nimport { setAuthCode } from '@web/utils/auth';\nimport { useState } fr"
  },
  {
    "path": "apps/web/src/provider/theme.tsx",
    "chars": 490,
    "preview": "import { NextUIProvider } from '@nextui-org/react';\nimport { ThemeProvider as NextThemesProvider } from 'next-themes';\ni"
  },
  {
    "path": "apps/web/src/provider/trpc.tsx",
    "chars": 3154,
    "preview": "import { QueryClient, QueryClientProvider } from '@tanstack/react-query';\nimport { httpBatchLink, loggerLink } from '@tr"
  },
  {
    "path": "apps/web/src/types.ts",
    "chars": 110,
    "preview": "import { SVGProps } from 'react';\n\nexport type IconSvgProps = SVGProps<SVGSVGElement> & {\n  size?: number;\n};\n"
  },
  {
    "path": "apps/web/src/utils/auth.ts",
    "chars": 404,
    "preview": "let token: string | null = null;\n\nexport const getAuthCode = () => {\n  if (token !== null) {\n    return token;\n  }\n\n  to"
  },
  {
    "path": "apps/web/src/utils/env.ts",
    "chars": 311,
    "preview": "export const isProd = import.meta.env.PROD;\n\nexport const serverOriginUrl = isProd\n  ? window.__WEWE_RSS_SERVER_ORIGIN_U"
  },
  {
    "path": "apps/web/src/utils/trpc.ts",
    "chars": 315,
    "preview": "import { AppRouter } from '@server/trpc/trpc.router';\nimport { TRPCClientError, createTRPCReact } from '@trpc/react-quer"
  },
  {
    "path": "apps/web/src/vite-env.d.ts",
    "chars": 288,
    "preview": "/// <reference types=\"vite/client\" />\n\ninterface ImportMetaEnv {\n  readonly VITE_SERVER_ORIGIN_URL: string;\n  readonly V"
  },
  {
    "path": "apps/web/tailwind.config.ts",
    "chars": 357,
    "preview": "import type { Config } from 'tailwindcss';\nimport { nextui } from '@nextui-org/react';\n\nconst config: Config = {\n  conte"
  },
  {
    "path": "apps/web/tsconfig.json",
    "chars": 671,
    "preview": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true"
  },
  {
    "path": "apps/web/tsconfig.node.json",
    "chars": 233,
    "preview": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"skipLibCheck\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\""
  },
  {
    "path": "apps/web/vite.config.ts",
    "chars": 1197,
    "preview": "import { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\nimport { resolve } from 'path';\nimport { "
  },
  {
    "path": "assets/nginx.example.conf",
    "chars": 612,
    "preview": "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_htt"
  },
  {
    "path": "docker-compose.dev.yml",
    "chars": 233,
    "preview": "services:\n  mysql:\n    image: mysql:8.3.0\n    command: --default-authentication-plugin=mysql_native_password\n    environ"
  },
  {
    "path": "docker-compose.sqlite.yml",
    "chars": 580,
    "preview": "version: '3.9'\n\nservices:\n  app:\n    image: cooderl/wewe-rss-sqlite:latest\n    ports:\n      - 4000:4000\n    environment:"
  },
  {
    "path": "docker-compose.yml",
    "chars": 1069,
    "preview": "version: '3.9'\n\nservices:\n  db:\n    image: mysql:8.3.0\n    command: --mysql-native-password=ON\n    environment:\n      # "
  },
  {
    "path": "package.json",
    "chars": 616,
    "preview": "{\n  \"name\": \"wewe-rss\",\n  \"version\": \"2.6.1\",\n  \"private\": true,\n  \"author\": \"cooderl <cooder@111965.xyz>\",\n  \"descripti"
  },
  {
    "path": "pnpm-workspace.yaml",
    "chars": 23,
    "preview": "packages:\n  - 'apps/*'\n"
  },
  {
    "path": "release.sh",
    "chars": 695,
    "preview": "#!/bin/bash\n\n# 检查是否提供了版本号\nif [ \"$#\" -ne 1 ]; then\n    echo \"Usage: $0 <new-version>\"\n    exit 1\nfi\n\n# 新版本号\nNEW_VERSION=$"
  },
  {
    "path": "tsconfig.json",
    "chars": 483,
    "preview": "{\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"experimentalDecorators\": true,\n    \"emitDecoratorMetadata\": true,\n    "
  },
  {
    "path": "wewe-rss-dingtalk/Dockerfile",
    "chars": 168,
    "preview": "FROM python:3.8.12-slim\n\nWORKDIR /app\nCOPY . .\n\nRUN pip install -r requirements.txt\nENV TZ=Asia/Shanghai \\\n    DEBIAN_FR"
  },
  {
    "path": "wewe-rss-dingtalk/README.md",
    "chars": 281,
    "preview": "### 修改main.py,输入dingtalk的access_token和secret\n\n```\n   access_token = ''\n   secret = ''  # 创建机器人时钉钉设置页面有提供\n```\n\n### 修改根目录下"
  },
  {
    "path": "wewe-rss-dingtalk/docker-compose.yml",
    "chars": 98,
    "preview": "version: '3.9'\n\nservices:\n  wewe-rss-dingtalk:\n    build: .\n    container_name: wewe-rss-dingtalk\n"
  },
  {
    "path": "wewe-rss-dingtalk/main.py",
    "chars": 2663,
    "preview": "import mysql.connector\nimport requests\nimport json\nimport os\nimport time\nfrom datetime import datetime, timedelta\nimport"
  },
  {
    "path": "wewe-rss-dingtalk/requirements.txt",
    "chars": 56,
    "preview": "DingtalkChatbot==1.5.3\nmysql-connector-python\njason\npytz"
  }
]

About this extraction

This page contains the full source code of the cooderl/wewe-rss GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 92 files (126.6 KB), approximately 37.5k tokens, and a symbol index with 74 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!