diff --git a/.env b/.env index c062d70..48a9af3 100644 --- a/.env +++ b/.env @@ -3,3 +3,4 @@ API_URL= API_MODEL= ANNOUNCEMENT_TEXT= API_TIMEOUT=60 +LOGIN_PASSWORD= diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d11bcbf --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# API配置 +API_KEY= +API_URL= +API_MODEL= +API_TIMEOUT=60 +# 公告文本 +ANNOUNCEMENT_TEXT=欢迎使用! +# 登录配置(为空时不需要登录,否则需要经过登录接口验证) +LOGIN_PASSWORD= diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 4260c42..a2523bb 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,38 +1,190 @@ -name: Docker Build and Push +name: Docker Build and Deploy on: push: - branches: [ main ] # 只在 main 分支推送时触发 + branches: [ main ] + paths-ignore: + - '**.md' + - 'docs/**' + workflow_dispatch: + inputs: + deploy: + description: '是否部署' + type: boolean + default: true + +env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + TIME: ${{ github.run_number }}-${{ github.run_attempt }} jobs: - build: + prepare: runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Generate version + id: version + run: | + echo "version=$(date +'%Y%m%d%H%M')" >> $GITHUB_OUTPUT + + build: + needs: prepare + runs-on: ubuntu-latest + strategy: + matrix: + service: [backend, frontend] + include: + - service: backend + context: . + - service: frontend + context: ./frontend + # 允许其中一个任务失败时,其他任务仍继续执行 + fail-fast: false steps: - - uses: actions/checkout@v2 + - name: Checkout code + uses: actions/checkout@v4 - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - - name: Get current time - id: time - run: echo "TIME=$(date +'%Y%m%d%H%M')" >> $GITHUB_ENV + uses: docker/setup-qemu-action@v3 - name: Login to Docker Hub - uses: docker/login-action@v1 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v3 + with: + platforms: linux/amd64,linux/arm64 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKERHUB_USERNAME }}/stock-scanner-${{ matrix.service }} + tags: | + type=raw,value=latest + type=raw,value=${{ needs.prepare.outputs.version }} - name: Build and push - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v5 with: - context: . + context: ${{ matrix.context }} platforms: linux/amd64,linux/arm64 push: true - tags: | - ${{ secrets.DOCKERHUB_USERNAME }}/stock-scanner:latest - ${{ secrets.DOCKERHUB_USERNAME }}/stock-scanner:${{ env.TIME }} + tags: ${{ steps.meta.outputs.tags }} + cache-from: type=gha,scope=${{ matrix.service }} + cache-to: type=gha,scope=${{ matrix.service }},mode=max + + deploy: + needs: [prepare, build] + runs-on: ubuntu-latest + if: | + success() && + (github.ref == 'refs/heads/main' && github.event_name == 'push') || + (github.event_name == 'workflow_dispatch' && inputs.deploy) + + steps: + - name: Checkout code for prod compose file + uses: actions/checkout@v4 + with: + sparse-checkout: | + docker-compose.prod.yml + + - name: Create .env file for deployment + run: | + cat > .env << EOL + DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }} + TAG=${{ needs.prepare.outputs.version }} + API_KEY=${{ secrets.API_KEY }} + API_URL=${{ secrets.API_URL }} + API_MODEL=${{ secrets.API_MODEL }} + API_TIMEOUT=${{ secrets.API_TIMEOUT }} + LOGIN_PASSWORD=${{ secrets.LOGIN_PASSWORD }} + ANNOUNCEMENT_TEXT=${{ secrets.ANNOUNCEMENT_TEXT }} + EOL + + - name: Deploy to server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USERNAME }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script_stop: true # 遇到错误时停止执行 + envs: DEPLOY_PATH + script: | + # 创建备份目录(如果不存在) + mkdir -p ${DEPLOY_PATH}/backups + + # 如果存在旧容器,备份当前的配置和数据 + if [ -f ${DEPLOY_PATH}/docker-compose.prod.yml ]; then + cp ${DEPLOY_PATH}/docker-compose.prod.yml ${DEPLOY_PATH}/backups/docker-compose.prod.$(date +%Y%m%d%H%M%S).yml + if [ -f ${DEPLOY_PATH}/.env ]; then + cp ${DEPLOY_PATH}/.env ${DEPLOY_PATH}/backups/.env.$(date +%Y%m%d%H%M%S) + fi + fi + + - name: Copy files to server + uses: appleboy/scp-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USERNAME }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + source: "docker-compose.prod.yml,.env" + target: ${{ secrets.DEPLOY_PATH }} + overwrite: true + + - name: Start services + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USERNAME }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script_stop: true + script: | + cd ${{ secrets.DEPLOY_PATH }} + + # 拉取最新镜像并启动服务 + docker compose -f docker-compose.prod.yml pull + docker compose -f docker-compose.prod.yml up -d + + # 等待服务启动完成 + echo "等待服务启动..." + sleep 10 + + # 验证服务是否正常运行 + if ! curl -s http://localhost:80 > /dev/null; then + echo "前端服务未正常运行!" + exit 1 + fi + + if ! curl -s http://localhost:8888/config > /dev/null; then + echo "后端服务未正常运行!" + exit 1 + fi + + # 清理未使用的镜像和容器 + docker system prune -af --volumes + + echo "部署完成并验证成功!" + + notify: + needs: [prepare, build, deploy] + runs-on: ubuntu-latest + if: always() + steps: + - name: Notify deployment status + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_CHANNEL: deployments + SLACK_COLOR: ${{ needs.deploy.result == 'success' && 'good' || needs.deploy.result == 'skipped' && 'warning' || 'danger' }} + SLACK_TITLE: Stock Scanner Deployment Status + SLACK_MESSAGE: | + Build: ${{ needs.build.result == 'success' && '✅' || '❌' }} + Deploy: ${{ needs.deploy.result == 'success' && '✅' || needs.deploy.result == 'skipped' && '⏭️' || '❌' }} + Version: ${{ needs.prepare.outputs.version }} + SLACK_FOOTER: GitHub Actions \ No newline at end of file diff --git a/.gitignore b/.gitignore index db3d017..9759f13 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,23 @@ build_upload.log *.spec *.zip +# frontend +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +dist/ +dist-ssr +*.local +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/Dockerfile b/Dockerfile index 1c331fb..f9a3cd5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,52 @@ # 使用 Python 3.10 作为基础镜像 +FROM python:3.10-slim as builder + +# 设置工作目录 +WORKDIR /app + +# 安装系统依赖和构建依赖 +RUN apt-get update && apt-get install -y \ + libgl1-mesa-glx \ + ca-certificates \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# 复制项目文件 +COPY requirements.txt /app/ + +# 安装 Python 依赖 +RUN pip install --no-cache-dir --user -r requirements.txt + +# 第二阶段:运行阶段 FROM python:3.10-slim # 设置工作目录 WORKDIR /app -# 安装系统依赖 +# 安装运行时依赖 RUN apt-get update && apt-get install -y \ libgl1-mesa-glx \ - ca-certificates \ + ca-certificates \ && rm -rf /var/lib/apt/lists/* -# 复制项目文件 -COPY . /app/ +# 从构建阶段复制Python依赖 +COPY --from=builder /root/.local /root/.local -# 安装 Python 依赖 -RUN pip install --no-cache-dir -r requirements.txt +# 确保脚本路径在PATH中 +ENV PATH=/root/.local/bin:$PATH # 设置环境变量 ENV PYTHONPATH=/app -# 暴露端口(如果需要) +# 复制应用代码 +COPY . /app/ + +# 暴露端口 EXPOSE 8888 +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8888/config || exit 1 + # 启动命令 CMD ["python", "web_server.py"] \ No newline at end of file diff --git a/README.md b/README.md index d72310e..88e87fd 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ 3. 完善Dockerfile、GitHub Actions 支持docker一键部署使用。 4. 支持x86_64 和 ARM64架构镜像 5. 支持流式输出,支持前端传入Key(仅作为本地用户使用,日志等内容不会输出) 感谢@Cassianvale +6. 重构为Vue3+Vite+TS+Naive UI,支持响应式布局 +7. 支持GitHub Actions 一键部署 ## docker一键部署 ``` @@ -19,9 +21,11 @@ docker run -d \ -e API_URL=替换为你的api地址 \ -e API_MODEL=替换为你的模型 \ -e API_TIMEOUT=60 \ + -e LOGIN_PASSWORD=替换为你的密码 \ lanzhihong/stock-scanner:latest API_TIMEOUT=60 202503040712版本开始 (AI分析发生错误,查看日志是否有timed out类似错误,需要增加你的API超时时间) +LOGIN_PASSWORD 为空时,表示不需要登录,否则需要经过登录接口验证 注意⚠️: 环境变量名变更,更新版本后需要调整!!! @@ -44,6 +48,18 @@ API_URL 处理逻辑说明: ``` 默认8888端口,部署完成后访问 http://127.0.0.1:8888 即可使用。 +## Github Actions 部署 + +| 环境变量 | 说明 | +| --- | --- | +| DOCKERHUB_USERNAME | Docker Hub用户名 | +| DOCKERHUB_TOKEN | Docker Hub访问令牌 | +| SERVER_HOST | 部署服务器地址 | +| SERVER_USERNAME | 服务器用户名 | +| SSH_PRIVATE_KEY | SSH私钥 | +| DEPLOY_PATH | 部署路径 | +| SLACK_WEBHOOK | Slack通知Webhook(可选) | + ## 注意事项 (Notes) - 股票分析仅供参考,不构成投资建议 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..5c18530 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,48 @@ +version: '3.8' + +services: + backend: + image: ${DOCKERHUB_USERNAME}/stock-scanner-backend:${TAG:-latest} + container_name: stock-scanner-backend + ports: + - "8888:8888" + environment: + - API_KEY=${API_KEY} + - API_URL=${API_URL} + - API_MODEL=${API_MODEL} + - API_TIMEOUT=${API_TIMEOUT} + - LOGIN_PASSWORD=${LOGIN_PASSWORD} + - ANNOUNCEMENT_TEXT=${ANNOUNCEMENT_TEXT} + volumes: + - ./logs:/app/logs + - ./.env:/app/.env + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8888/config"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + networks: + - stock-scanner-network + + frontend: + image: ${DOCKERHUB_USERNAME}/stock-scanner-frontend:${TAG:-latest} + container_name: stock-scanner-frontend + ports: + - "80:80" + depends_on: + - backend + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + networks: + - stock-scanner-network + +networks: + stock-scanner-network: + driver: bridge \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 4d0da35..57ddb0b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,11 @@ version: '3.8' services: - stock-analyzer: - build: . + backend: + build: + context: . + dockerfile: Dockerfile + container_name: stock-scanner-backend ports: - "8888:8888" environment: @@ -10,6 +13,39 @@ services: - API_URL=${API_URL} - API_MODEL=${API_MODEL} - API_TIMEOUT=${API_TIMEOUT} + - LOGIN_PASSWORD=${LOGIN_PASSWORD} + - ANNOUNCEMENT_TEXT=${ANNOUNCEMENT_TEXT} volumes: - - .:/app + - ./logs:/app/logs restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8888/config"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + networks: + - stock-scanner-network + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: stock-scanner-frontend + ports: + - "80:80" + depends_on: + - backend + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + networks: + - stock-scanner-network + +networks: + stock-scanner-network: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..1b8d3b4 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,36 @@ +# 构建阶段 +FROM node:18-alpine as build-stage + +# 设置工作目录 +WORKDIR /app + +# 复制 package.json 和 package-lock.json(如果有) +COPY package*.json ./ + +# 安装依赖 +RUN npm ci + +# 复制项目文件 +COPY . . + +# 构建应用 +RUN npm run build + +# 生产阶段 +FROM nginx:stable-alpine as production-stage + +# 复制自定义nginx配置 +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# 从构建阶段复制构建结果到nginx服务目录 +COPY --from=build-stage /app/dist /usr/share/nginx/html + +# 暴露80端口 +EXPOSE 80 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://localhost:80 || exit 1 + +# 设定启动命令 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..33895ab --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,5 @@ +# Vue 3 + TypeScript + Vite + +This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + + \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..d07268f --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,85 @@ +server { + listen 80; + server_name localhost; + + #access_log /var/log/nginx/host.access.log main; + + root /usr/share/nginx/html; + index index.html; + + # 缓存静态资源 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + # API请求代理到后端服务 - 使用相对路径 + location /api/ { + proxy_pass http://backend:8888/api/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300s; + } + + location /login { + proxy_pass http://backend:8888/login; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_cache_bypass $http_upgrade; + } + + location /check_auth { + proxy_pass http://backend:8888/check_auth; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_cache_bypass $http_upgrade; + } + + location /need_login { + proxy_pass http://backend:8888/need_login; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_cache_bypass $http_upgrade; + } + + location /config { + proxy_pass http://backend:8888/config; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_cache_bypass $http_upgrade; + } + + location /analyze { + proxy_pass http://backend:8888/analyze; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300s; + } + + # 所有其他路由返回index.html(SPA应用需要) + location / { + try_files $uri $uri/ /index.html; + } + + # 错误页面 + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..fed2cdf --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4314 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@types/node": "^22.13.9", + "@vicons/ionicons5": "^0.13.0", + "@vueuse/core": "^12.8.2", + "axios": "^1.8.1", + "marked": "^15.0.7", + "naive-ui": "^2.41.0", + "npm": "^10.8.2", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/marked": "^5.0.2", + "@vitejs/plugin-vue": "^5.2.1", + "@vue/tsconfig": "^0.7.0", + "typescript": "~5.7.2", + "vite": "^6.2.0", + "vue-tsc": "^2.2.4" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.9", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.9" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.26.9", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@css-render/plugin-bem": { + "version": "0.15.14", + "license": "MIT", + "peerDependencies": { + "css-render": "~0.15.14" + } + }, + "node_modules/@css-render/vue3-ssr": { + "version": "0.15.14", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "license": "MIT" + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "license": "MIT" + }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "license": "Apache-2.0" + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.34.9", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/katex": { + "version": "0.16.7", + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.16", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/marked": { + "version": "5.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.13.9", + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "license": "MIT" + }, + "node_modules/@vicons/ionicons5": { + "version": "0.13.0", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.12", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.12" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.12", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.12", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.12", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.13", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/shared": "3.5.13", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.13", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.13", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/compiler-core": "3.5.13", + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.11", + "postcss": "^8.4.48", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.13", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "~2.4.11", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.13", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.13", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.13", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.13", + "@vue/runtime-core": "3.5.13", + "@vue/shared": "3.5.13", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.13", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13" + }, + "peerDependencies": { + "vue": "3.5.13" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.13", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.7.0", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.8.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.8.2", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/alien-signals": { + "version": "1.0.4", + "dev": true, + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.8.1", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/css-render": { + "version": "0.15.14", + "license": "MIT", + "dependencies": { + "@emotion/hash": "~0.8.0", + "csstype": "~3.0.5" + } + }, + "node_modules/css-render/node_modules/csstype": { + "version": "3.0.11", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "3.6.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/de-indent": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.0", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" + } + }, + "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/darwin-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-loong64": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-s390x": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/sunos-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "license": "MIT" + }, + "node_modules/evtd": { + "version": "0.2.4", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.2", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.17", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/marked": { + "version": "15.0.7", + "resolved": "https://registry.npmmirror.com/marked/-/marked-15.0.7.tgz", + "integrity": "sha512-dgLIeKGLx5FwziAnsk4ONoGwHwGPJzselimvlVskE9XLN4Orv9u2VA3GWw/lYUqjfA0rUT/6fqKwfZJapP9BEg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/naive-ui": { + "version": "2.41.0", + "resolved": "https://registry.npmmirror.com/naive-ui/-/naive-ui-2.41.0.tgz", + "integrity": "sha512-KnmLg+xPLwXV8QVR7ZZ69eCjvel7R5vru8+eFe4VoAJHEgqAJgVph6Zno9K2IVQRpSF3GBGea3tjavslOR4FAA==", + "license": "MIT", + "dependencies": { + "@css-render/plugin-bem": "^0.15.14", + "@css-render/vue3-ssr": "^0.15.14", + "@types/katex": "^0.16.2", + "@types/lodash": "^4.14.198", + "@types/lodash-es": "^4.17.9", + "async-validator": "^4.2.5", + "css-render": "^0.15.14", + "csstype": "^3.1.3", + "date-fns": "^3.6.0", + "date-fns-tz": "^3.1.3", + "evtd": "^0.2.4", + "highlight.js": "^11.8.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "seemly": "^0.3.8", + "treemate": "^0.3.11", + "vdirs": "^0.1.8", + "vooks": "^0.2.12", + "vueuc": "^0.4.63" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.8", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/npm": { + "version": "10.9.2", + "bundleDependencies": [ + "@isaacs/string-locale-compare", + "@npmcli/arborist", + "@npmcli/config", + "@npmcli/fs", + "@npmcli/map-workspaces", + "@npmcli/package-json", + "@npmcli/promise-spawn", + "@npmcli/redact", + "@npmcli/run-script", + "@sigstore/tuf", + "abbrev", + "archy", + "cacache", + "chalk", + "ci-info", + "cli-columns", + "fastest-levenshtein", + "fs-minipass", + "glob", + "graceful-fs", + "hosted-git-info", + "ini", + "init-package-json", + "is-cidr", + "json-parse-even-better-errors", + "libnpmaccess", + "libnpmdiff", + "libnpmexec", + "libnpmfund", + "libnpmhook", + "libnpmorg", + "libnpmpack", + "libnpmpublish", + "libnpmsearch", + "libnpmteam", + "libnpmversion", + "make-fetch-happen", + "minimatch", + "minipass", + "minipass-pipeline", + "ms", + "node-gyp", + "nopt", + "normalize-package-data", + "npm-audit-report", + "npm-install-checks", + "npm-package-arg", + "npm-pick-manifest", + "npm-profile", + "npm-registry-fetch", + "npm-user-validate", + "p-map", + "pacote", + "parse-conflict-json", + "proc-log", + "qrcode-terminal", + "read", + "semver", + "spdx-expression-parse", + "ssri", + "supports-color", + "tar", + "text-table", + "tiny-relative-date", + "treeverse", + "validate-npm-package-name", + "which", + "write-file-atomic" + ], + "license": "Artistic-2.0", + "workspaces": [ + "docs", + "smoke-tests", + "mock-globals", + "mock-registry", + "workspaces/*" + ], + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/arborist": "^8.0.0", + "@npmcli/config": "^9.0.0", + "@npmcli/fs": "^4.0.0", + "@npmcli/map-workspaces": "^4.0.2", + "@npmcli/package-json": "^6.1.0", + "@npmcli/promise-spawn": "^8.0.2", + "@npmcli/redact": "^3.0.0", + "@npmcli/run-script": "^9.0.1", + "@sigstore/tuf": "^3.0.0", + "abbrev": "^3.0.0", + "archy": "~1.0.0", + "cacache": "^19.0.1", + "chalk": "^5.3.0", + "ci-info": "^4.1.0", + "cli-columns": "^4.0.0", + "fastest-levenshtein": "^1.0.16", + "fs-minipass": "^3.0.3", + "glob": "^10.4.5", + "graceful-fs": "^4.2.11", + "hosted-git-info": "^8.0.2", + "ini": "^5.0.0", + "init-package-json": "^7.0.2", + "is-cidr": "^5.1.0", + "json-parse-even-better-errors": "^4.0.0", + "libnpmaccess": "^9.0.0", + "libnpmdiff": "^7.0.0", + "libnpmexec": "^9.0.0", + "libnpmfund": "^6.0.0", + "libnpmhook": "^11.0.0", + "libnpmorg": "^7.0.0", + "libnpmpack": "^8.0.0", + "libnpmpublish": "^10.0.1", + "libnpmsearch": "^8.0.0", + "libnpmteam": "^7.0.0", + "libnpmversion": "^7.0.0", + "make-fetch-happen": "^14.0.3", + "minimatch": "^9.0.5", + "minipass": "^7.1.1", + "minipass-pipeline": "^1.2.4", + "ms": "^2.1.2", + "node-gyp": "^11.0.0", + "nopt": "^8.0.0", + "normalize-package-data": "^7.0.0", + "npm-audit-report": "^6.0.0", + "npm-install-checks": "^7.1.1", + "npm-package-arg": "^12.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-profile": "^11.0.1", + "npm-registry-fetch": "^18.0.2", + "npm-user-validate": "^3.0.0", + "p-map": "^4.0.0", + "pacote": "^19.0.1", + "parse-conflict-json": "^4.0.0", + "proc-log": "^5.0.0", + "qrcode-terminal": "^0.12.0", + "read": "^4.0.0", + "semver": "^7.6.3", + "spdx-expression-parse": "^4.0.0", + "ssri": "^12.0.0", + "supports-color": "^9.4.0", + "tar": "^6.2.1", + "text-table": "~0.2.0", + "tiny-relative-date": "^1.3.0", + "treeverse": "^3.0.0", + "validate-npm-package-name": "^6.0.0", + "which": "^5.0.0", + "write-file-atomic": "^6.0.0" + }, + "bin": { + "npm": "bin/npm-cli.js", + "npx": "bin/npx-cli.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui": { + "version": "8.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/npm/node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/@npmcli/agent": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/arborist": { + "version": "8.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^4.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/map-workspaces": "^4.0.1", + "@npmcli/metavuln-calculator": "^8.0.0", + "@npmcli/name-from-folder": "^3.0.0", + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.1", + "@npmcli/query": "^4.0.0", + "@npmcli/redact": "^3.0.0", + "@npmcli/run-script": "^9.0.1", + "bin-links": "^5.0.0", + "cacache": "^19.0.1", + "common-ancestor-path": "^1.0.1", + "hosted-git-info": "^8.0.0", + "json-parse-even-better-errors": "^4.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^10.2.2", + "minimatch": "^9.0.4", + "nopt": "^8.0.0", + "npm-install-checks": "^7.1.0", + "npm-package-arg": "^12.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.1", + "pacote": "^19.0.0", + "parse-conflict-json": "^4.0.0", + "proc-log": "^5.0.0", + "proggy": "^3.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "read-package-json-fast": "^4.0.0", + "semver": "^7.3.7", + "ssri": "^12.0.0", + "treeverse": "^3.0.0", + "walk-up-path": "^3.0.1" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/config": { + "version": "9.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/map-workspaces": "^4.0.1", + "@npmcli/package-json": "^6.0.1", + "ci-info": "^4.0.0", + "ini": "^5.0.0", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "walk-up-path": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/fs": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/git": { + "version": "6.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/promise-spawn": "^8.0.0", + "ini": "^5.0.0", + "lru-cache": "^10.0.1", + "npm-pick-manifest": "^10.0.0", + "proc-log": "^5.0.0", + "promise-inflight": "^1.0.1", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/installed-package-contents": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/map-workspaces": { + "version": "4.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "glob": "^10.2.2", + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { + "version": "8.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "cacache": "^19.0.0", + "json-parse-even-better-errors": "^4.0.0", + "pacote": "^20.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/metavuln-calculator/node_modules/pacote": { + "version": "20.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "@npmcli/run-script": "^9.0.0", + "cacache": "^19.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^12.0.0", + "npm-packlist": "^9.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^3.0.0", + "ssri": "^12.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/name-from-folder": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/node-gyp": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/package-json": { + "version": "6.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "glob": "^10.2.2", + "hosted-git-info": "^8.0.0", + "json-parse-even-better-errors": "^4.0.0", + "normalize-package-data": "^7.0.0", + "proc-log": "^5.0.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/promise-spawn": { + "version": "8.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/query": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^6.1.2" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/redact": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/run-script": { + "version": "9.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "node-gyp": "^11.0.0", + "proc-log": "^5.0.0", + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/npm/node_modules/@sigstore/protobuf-specs": { + "version": "0.3.2", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@sigstore/tuf": { + "version": "3.0.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.3.2", + "tuf-js": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/abbrev": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/agent-base": { + "version": "7.1.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/aggregate-error": { + "version": "3.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/ansi-regex": { + "version": "5.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/ansi-styles": { + "version": "6.2.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/npm/node_modules/aproba": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/archy": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/balanced-match": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/bin-links": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^7.0.0", + "npm-normalize-package-bin": "^4.0.0", + "proc-log": "^5.0.0", + "read-cmd-shim": "^5.0.0", + "write-file-atomic": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/binary-extensions": { + "version": "2.3.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/brace-expansion": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/npm/node_modules/cacache": { + "version": "19.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^4.0.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/chownr": { + "version": "3.0.0", + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/minizlib": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.4", + "rimraf": "^5.0.5" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/mkdirp": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/p-map": { + "version": "7.0.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/tar": { + "version": "7.4.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/yallist": { + "version": "5.0.0", + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/chalk": { + "version": "5.3.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/npm/node_modules/chownr": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/ci-info": { + "version": "4.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/cidr-regex": { + "version": "4.1.1", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "ip-regex": "^5.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/npm/node_modules/clean-stack": { + "version": "2.2.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/npm/node_modules/cli-columns": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/npm/node_modules/cmd-shim": { + "version": "7.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/color-convert": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/npm/node_modules/color-name": { + "version": "1.1.4", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/common-ancestor-path": { + "version": "1.0.1", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/cross-spawn": { + "version": "7.0.6", + "inBundle": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/cssesc": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/debug": { + "version": "4.3.7", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/npm/node_modules/diff": { + "version": "5.2.0", + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/npm/node_modules/eastasianwidth": { + "version": "0.2.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/emoji-regex": { + "version": "8.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/encoding": { + "version": "0.1.13", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/npm/node_modules/env-paths": { + "version": "2.2.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/npm/node_modules/err-code": { + "version": "2.0.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/exponential-backoff": { + "version": "3.1.1", + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/npm/node_modules/fastest-levenshtein": { + "version": "1.0.16", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/npm/node_modules/foreground-child": { + "version": "3.3.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/fs-minipass": { + "version": "3.0.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/glob": { + "version": "10.4.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/graceful-fs": { + "version": "4.2.11", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/hosted-git-info": { + "version": "8.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/http-cache-semantics": { + "version": "4.1.1", + "inBundle": true, + "license": "BSD-2-Clause" + }, + "node_modules/npm/node_modules/http-proxy-agent": { + "version": "7.0.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/https-proxy-agent": { + "version": "7.0.5", + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/iconv-lite": { + "version": "0.6.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm/node_modules/ignore-walk": { + "version": "7.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/imurmurhash": { + "version": "0.1.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/npm/node_modules/indent-string": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/ini": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/init-package-json": { + "version": "7.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^6.0.0", + "npm-package-arg": "^12.0.0", + "promzard": "^2.0.0", + "read": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4", + "validate-npm-package-name": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/ip-address": { + "version": "9.0.5", + "inBundle": true, + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/npm/node_modules/ip-regex": { + "version": "5.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/is-cidr": { + "version": "5.1.0", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "cidr-regex": "^4.1.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/npm/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/isexe": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/jackspeak": { + "version": "3.4.3", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/npm/node_modules/jsbn": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/json-parse-even-better-errors": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/json-stringify-nice": { + "version": "1.1.4", + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/jsonparse": { + "version": "1.3.1", + "engines": [ + "node >= 0.2.0" + ], + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff": { + "version": "6.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff-apply": { + "version": "5.5.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/libnpmaccess": { + "version": "9.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-package-arg": "^12.0.0", + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmdiff": { + "version": "7.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^8.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "binary-extensions": "^2.3.0", + "diff": "^5.1.0", + "minimatch": "^9.0.4", + "npm-package-arg": "^12.0.0", + "pacote": "^19.0.0", + "tar": "^6.2.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmexec": { + "version": "9.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^8.0.0", + "@npmcli/run-script": "^9.0.1", + "ci-info": "^4.0.0", + "npm-package-arg": "^12.0.0", + "pacote": "^19.0.0", + "proc-log": "^5.0.0", + "read": "^4.0.0", + "read-package-json-fast": "^4.0.0", + "semver": "^7.3.7", + "walk-up-path": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmfund": { + "version": "6.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^8.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmhook": { + "version": "11.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmorg": { + "version": "7.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmpack": { + "version": "8.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^8.0.0", + "@npmcli/run-script": "^9.0.1", + "npm-package-arg": "^12.0.0", + "pacote": "^19.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmpublish": { + "version": "10.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "ci-info": "^4.0.0", + "normalize-package-data": "^7.0.0", + "npm-package-arg": "^12.0.0", + "npm-registry-fetch": "^18.0.1", + "proc-log": "^5.0.0", + "semver": "^7.3.7", + "sigstore": "^3.0.0", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmsearch": { + "version": "8.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmteam": { + "version": "7.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmversion": { + "version": "7.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.1", + "@npmcli/run-script": "^9.0.1", + "json-parse-even-better-errors": "^4.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.7" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/lru-cache": { + "version": "10.4.3", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/make-fetch-happen": { + "version": "14.0.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/make-fetch-happen/node_modules/negotiator": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/npm/node_modules/minimatch": { + "version": "9.0.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/minipass": { + "version": "7.1.2", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-collect": { + "version": "2.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-fetch": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/npm/node_modules/minipass-fetch/node_modules/minizlib": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.4", + "rimraf": "^5.0.5" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/minipass-flush": { + "version": "1.0.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline": { + "version": "1.2.4", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-sized": { + "version": "1.0.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minizlib": { + "version": "2.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/mkdirp": { + "version": "1.0.4", + "inBundle": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/ms": { + "version": "2.1.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/mute-stream": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/node-gyp": { + "version": "11.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "tar": "^7.4.3", + "which": "^5.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/chownr": { + "version": "3.0.0", + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/minizlib": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.4", + "rimraf": "^5.0.5" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/mkdirp": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/tar": { + "version": "7.4.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/yallist": { + "version": "5.0.0", + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/nopt": { + "version": "8.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/nopt/node_modules/abbrev": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/normalize-package-data": { + "version": "7.0.0", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^8.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-audit-report": { + "version": "6.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-bundled": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-install-checks": { + "version": "7.1.1", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-package-arg": { + "version": "12.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-packlist": { + "version": "9.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^7.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-pick-manifest": { + "version": "10.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^7.1.0", + "npm-normalize-package-bin": "^4.0.0", + "npm-package-arg": "^12.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-profile": { + "version": "11.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-registry-fetch": { + "version": "18.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^3.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^14.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^12.0.0", + "proc-log": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-registry-fetch/node_modules/minizlib": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.4", + "rimraf": "^5.0.5" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/npm-user-validate": { + "version": "3.0.0", + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/p-map": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/package-json-from-dist": { + "version": "1.0.1", + "inBundle": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/npm/node_modules/pacote": { + "version": "19.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "@npmcli/run-script": "^9.0.0", + "cacache": "^19.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^12.0.0", + "npm-packlist": "^9.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^3.0.0", + "ssri": "^12.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/parse-conflict-json": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/path-key": { + "version": "3.1.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/path-scurry": { + "version": "1.11.1", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/proc-log": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/proggy": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/promise-all-reject-late": { + "version": "1.0.1", + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promise-call-limit": { + "version": "3.0.2", + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promise-inflight": { + "version": "1.0.1", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/promise-retry": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/promzard": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "read": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/qrcode-terminal": { + "version": "0.12.0", + "inBundle": true, + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/npm/node_modules/read": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "mute-stream": "^2.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/read-cmd-shim": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/read-package-json-fast": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/retry": { + "version": "0.12.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/npm/node_modules/rimraf": { + "version": "5.0.10", + "inBundle": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/safer-buffer": { + "version": "2.1.2", + "inBundle": true, + "license": "MIT", + "optional": true + }, + "node_modules/npm/node_modules/semver": { + "version": "7.6.3", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/shebang-command": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/shebang-regex": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/signal-exit": { + "version": "4.1.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/sigstore": { + "version": "3.0.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.0.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.3.2", + "@sigstore/sign": "^3.0.0", + "@sigstore/tuf": "^3.0.0", + "@sigstore/verify": "^2.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/sigstore/node_modules/@sigstore/bundle": { + "version": "3.0.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.3.2" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/sigstore/node_modules/@sigstore/core": { + "version": "2.0.0", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/sigstore/node_modules/@sigstore/sign": { + "version": "3.0.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.0.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.3.2", + "make-fetch-happen": "^14.0.1", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/sigstore/node_modules/@sigstore/verify": { + "version": "2.0.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.0.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.3.2" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/smart-buffer": { + "version": "4.2.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks": { + "version": "2.8.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks-proxy-agent": { + "version": "8.0.4", + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.1", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/spdx-correct": { + "version": "3.2.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-correct/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-exceptions": { + "version": "2.5.0", + "inBundle": true, + "license": "CC-BY-3.0" + }, + "node_modules/npm/node_modules/spdx-expression-parse": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-license-ids": { + "version": "3.0.20", + "inBundle": true, + "license": "CC0-1.0" + }, + "node_modules/npm/node_modules/sprintf-js": { + "version": "1.1.3", + "inBundle": true, + "license": "BSD-3-Clause" + }, + "node_modules/npm/node_modules/ssri": { + "version": "12.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/string-width": { + "version": "4.2.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/strip-ansi": { + "version": "6.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/supports-color": { + "version": "9.4.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/npm/node_modules/tar": { + "version": "6.2.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/text-table": { + "version": "0.2.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/tiny-relative-date": { + "version": "1.3.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/treeverse": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/tuf-js": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "3.0.1", + "debug": "^4.3.6", + "make-fetch-happen": "^14.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/tuf-js/node_modules/@tufjs/models": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^9.0.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/unique-filename": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/unique-slug": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/util-deprecate": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/validate-npm-package-license": { + "version": "3.0.4", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/npm/node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/validate-npm-package-name": { + "version": "6.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/walk-up-path": { + "version": "3.0.1", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/which": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/which/node_modules/isexe": { + "version": "3.1.1", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/npm/node_modules/wrap-ansi": { + "version": "8.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.1.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "9.2.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/write-file-atomic": { + "version": "6.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/yallist": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.3", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.34.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.34.9", + "@rollup/rollup-android-arm64": "4.34.9", + "@rollup/rollup-darwin-arm64": "4.34.9", + "@rollup/rollup-darwin-x64": "4.34.9", + "@rollup/rollup-freebsd-arm64": "4.34.9", + "@rollup/rollup-freebsd-x64": "4.34.9", + "@rollup/rollup-linux-arm-gnueabihf": "4.34.9", + "@rollup/rollup-linux-arm-musleabihf": "4.34.9", + "@rollup/rollup-linux-arm64-gnu": "4.34.9", + "@rollup/rollup-linux-arm64-musl": "4.34.9", + "@rollup/rollup-linux-loongarch64-gnu": "4.34.9", + "@rollup/rollup-linux-powerpc64le-gnu": "4.34.9", + "@rollup/rollup-linux-riscv64-gnu": "4.34.9", + "@rollup/rollup-linux-s390x-gnu": "4.34.9", + "@rollup/rollup-linux-x64-gnu": "4.34.9", + "@rollup/rollup-linux-x64-musl": "4.34.9", + "@rollup/rollup-win32-arm64-msvc": "4.34.9", + "@rollup/rollup-win32-ia32-msvc": "4.34.9", + "@rollup/rollup-win32-x64-msvc": "4.34.9", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.34.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.9.tgz", + "integrity": "sha512-qZdlImWXur0CFakn2BJ2znJOdqYZKiedEPEVNTBrpfPjc/YuTGcaYZcdmNFTkUj3DU0ZM/AElcM8Ybww3xVLzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-android-arm64": { + "version": "4.34.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.9.tgz", + "integrity": "sha512-4KW7P53h6HtJf5Y608T1ISKvNIYLWRKMvfnG0c44M6In4DQVU58HZFEVhWINDZKp7FZps98G3gxwC1sb0wXUUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.34.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.9.tgz", + "integrity": "sha512-0CY3/K54slrzLDjOA7TOjN1NuLKERBgk9nY5V34mhmuu673YNb+7ghaDUs6N0ujXR7fz5XaS5Aa6d2TNxZd0OQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-darwin-x64": { + "version": "4.34.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.9.tgz", + "integrity": "sha512-eOojSEAi/acnsJVYRxnMkPFqcxSMFfrw7r2iD9Q32SGkb/Q9FpUY1UlAu1DH9T7j++gZ0lHjnm4OyH2vCI7l7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.34.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.9.tgz", + "integrity": "sha512-2lzjQPJbN5UnHm7bHIUKFMulGTQwdvOkouJDpPysJS+QFBGDJqcfh+CxxtG23Ik/9tEvnebQiylYoazFMAgrYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.34.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.9.tgz", + "integrity": "sha512-SLl0hi2Ah2H7xQYd6Qaiu01kFPzQ+hqvdYSoOtHYg/zCIFs6t8sV95kaoqjzjFwuYQLtOI0RZre/Ke0nPaQV+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.34.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.9.tgz", + "integrity": "sha512-88I+D3TeKItrw+Y/2ud4Tw0+3CxQ2kLgu3QvrogZ0OfkmX/DEppehus7L3TS2Q4lpB+hYyxhkQiYPJ6Mf5/dPg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.34.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.9.tgz", + "integrity": "sha512-3qyfWljSFHi9zH0KgtEPG4cBXHDFhwD8kwg6xLfHQ0IWuH9crp005GfoUUh/6w9/FWGBwEHg3lxK1iHRN1MFlA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.34.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.9.tgz", + "integrity": "sha512-6TZjPHjKZUQKmVKMUowF3ewHxctrRR09eYyvT5eFv8w/fXarEra83A2mHTVJLA5xU91aCNOUnM+DWFMSbQ0Nxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.34.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.9.tgz", + "integrity": "sha512-LD2fytxZJZ6xzOKnMbIpgzFOuIKlxVOpiMAXawsAZ2mHBPEYOnLRK5TTEsID6z4eM23DuO88X0Tq1mErHMVq0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.34.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.9.tgz", + "integrity": "sha512-dRAgTfDsn0TE0HI6cmo13hemKpVHOEyeciGtvlBTkpx/F65kTvShtY/EVyZEIfxFkV5JJTuQ9tP5HGBS0hfxIg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.34.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.9.tgz", + "integrity": "sha512-PHcNOAEhkoMSQtMf+rJofwisZqaU8iQ8EaSps58f5HYll9EAY5BSErCZ8qBDMVbq88h4UxaNPlbrKqfWP8RfJA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.34.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.9.tgz", + "integrity": "sha512-Z2i0Uy5G96KBYKjeQFKbbsB54xFOL5/y1P5wNBsbXB8yE+At3oh0DVMjQVzCJRJSfReiB2tX8T6HUFZ2k8iaKg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.34.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.9.tgz", + "integrity": "sha512-U+5SwTMoeYXoDzJX5dhDTxRltSrIax8KWwfaaYcynuJw8mT33W7oOgz0a+AaXtGuvhzTr2tVKh5UO8GVANTxyQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.34.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.9.tgz", + "integrity": "sha512-FwBHNSOjUTQLP4MG7y6rR6qbGw4MFeQnIBrMe161QGaQoBQLqSUEKlHIiVgF3g/mb3lxlxzJOpIBhaP+C+KP2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.34.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.9.tgz", + "integrity": "sha512-cYRpV4650z2I3/s6+5/LONkjIz8MBeqrk+vPXV10ORBnshpn8S32bPqQ2Utv39jCiDcO2eJTuSlPXpnvmaIgRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.34.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.9.tgz", + "integrity": "sha512-z4mQK9dAN6byRA/vsSgQiPeuO63wdiDxZ9yg9iyX2QTzKuQM7T4xlBoeUP/J8uiFkqxkcWndWi+W7bXdPbt27Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.34.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.9.tgz", + "integrity": "sha512-KB48mPtaoHy1AwDNkAJfHXvHp24H0ryZog28spEs0V48l3H1fr4i37tiyHsgKZJnCmvxsbATdZGBpbmxTE3a9w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/rollup/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/seemly": { + "version": "0.3.10", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/treemate": { + "version": "0.3.11", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.7.3", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "license": "MIT" + }, + "node_modules/vdirs": { + "version": "0.1.8", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/vite": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "postcss": "^8.5.3", + "rollup": "^4.30.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vooks": { + "version": "0.2.12", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.13", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-sfc": "3.5.13", + "@vue/runtime-dom": "3.5.13", + "@vue/server-renderer": "3.5.13", + "@vue/shared": "3.5.13" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.5.0.tgz", + "integrity": "sha512-HDuk+PuH5monfNuY+ct49mNmkCRK4xJAV9Ts4z9UFc4rzdDnxQLyCMGGc8pKhZhHTVzfanpNwB/lwqevcBwI4w==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "~2.4.11", + "@vue/language-core": "2.2.8" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vueuc": { + "version": "0.4.64", + "license": "MIT", + "dependencies": { + "@css-render/vue3-ssr": "^0.15.10", + "@juggle/resize-observer": "^3.3.1", + "css-render": "^0.15.10", + "evtd": "^0.2.4", + "seemly": "^0.3.6", + "vdirs": "^0.1.4", + "vooks": "^0.2.4" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2acefe4 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@types/node": "^22.13.9", + "@vicons/ionicons5": "^0.13.0", + "@vueuse/core": "^12.8.2", + "axios": "^1.8.1", + "marked": "^15.0.7", + "naive-ui": "^2.41.0", + "npm": "^10.8.2", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/marked": "^5.0.2", + "@vitejs/plugin-vue": "^5.2.1", + "@vue/tsconfig": "^0.7.0", + "typescript": "~5.7.2", + "vite": "^6.2.0", + "vue-tsc": "^2.2.4" + } +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..07a97be --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/frontend/src/assets/vue.svg b/frontend/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/frontend/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/AnnouncementBanner.vue b/frontend/src/components/AnnouncementBanner.vue new file mode 100644 index 0000000..ab872cb --- /dev/null +++ b/frontend/src/components/AnnouncementBanner.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/frontend/src/components/ApiConfigPanel.vue b/frontend/src/components/ApiConfigPanel.vue new file mode 100644 index 0000000..2886359 --- /dev/null +++ b/frontend/src/components/ApiConfigPanel.vue @@ -0,0 +1,642 @@ + + + + + diff --git a/frontend/src/components/LoginPage.vue b/frontend/src/components/LoginPage.vue new file mode 100644 index 0000000..f175e35 --- /dev/null +++ b/frontend/src/components/LoginPage.vue @@ -0,0 +1,536 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/MarketTimeDisplay.vue b/frontend/src/components/MarketTimeDisplay.vue new file mode 100644 index 0000000..3acda4a --- /dev/null +++ b/frontend/src/components/MarketTimeDisplay.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/frontend/src/components/StockAnalysisApp.vue b/frontend/src/components/StockAnalysisApp.vue new file mode 100644 index 0000000..c3ec27e --- /dev/null +++ b/frontend/src/components/StockAnalysisApp.vue @@ -0,0 +1,994 @@ + + + + + diff --git a/frontend/src/components/StockCard.vue b/frontend/src/components/StockCard.vue new file mode 100644 index 0000000..c7c4a4d --- /dev/null +++ b/frontend/src/components/StockCard.vue @@ -0,0 +1,1025 @@ + + + + + diff --git a/frontend/src/components/StockSearch.vue b/frontend/src/components/StockSearch.vue new file mode 100644 index 0000000..44abb46 --- /dev/null +++ b/frontend/src/components/StockSearch.vue @@ -0,0 +1,222 @@ + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..e3a485c --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,8 @@ +import { createApp } from 'vue' +import './style.css' +import App from './App.vue' +import router from './router' + +const app = createApp(App) +app.use(router) +app.mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..e2bd7f0 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,89 @@ +import { createRouter, createWebHistory } from 'vue-router'; +import type { RouteRecordRaw } from 'vue-router'; +import { apiService } from '@/services/api'; +import StockAnalysisApp from '@/components/StockAnalysisApp.vue'; +import LoginPage from '@/components/LoginPage.vue'; + +const routes: Array = [ + { + path: '/', + name: 'Home', + component: StockAnalysisApp, + meta: { requiresAuth: true } + }, + { + path: '/login', + name: 'Login', + component: LoginPage, + meta: { requiresAuth: false } + }, + { + path: '/:pathMatch(.*)*', + redirect: '/' + } +]; + +const router = createRouter({ + history: createWebHistory(), + routes +}); + +// 全局前置守卫 +router.beforeEach(async (to, from, next) => { + console.log(`路由跳转: 从 ${from.path} 到 ${to.path}`); + + // 如果已经在登录页面,直接通过 + if (to.path === '/login') { + next(); + return; + } + + // 检查路由是否需要认证 + if (to.matched.some(record => record.meta.requiresAuth)) { + console.log('当前路由需要认证'); + + try { + // 先检查系统是否需要登录 + const requireLogin = await apiService.checkNeedLogin(); + console.log('系统是否需要登录:', requireLogin); + + if (!requireLogin) { + // 系统不需要登录,直接通过 + console.log('系统不需要登录,允许访问'); + next(); + return; + } + + // 系统需要登录,检查本地是否有token + const token = localStorage.getItem('token'); + if (!token) { + console.log('本地没有token,跳转到登录页'); + next({ name: 'Login' }); + return; + } + + const isAuthenticated = await apiService.checkAuth(); + console.log('认证检查结果:', isAuthenticated); + + if (!isAuthenticated) { + // 未登录,重定向到登录页 + console.log('认证失败,跳转到登录页'); + next({ name: 'Login' }); + } else { + // 已登录,允许访问 + console.log('认证成功,允许访问'); + next(); + } + } catch (error) { + console.error('认证检查失败:', error); + // 认证检查失败,重定向到登录页 + next({ name: 'Login' }); + } + } else { + // 不需要认证的路由,直接访问 + console.log('当前路由不需要认证,直接访问'); + next(); + } +}); + +export default router; \ No newline at end of file diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..082f02f --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,146 @@ +import axios from 'axios'; +import type { AnalyzeRequest, TestApiRequest, TestApiResponse, SearchResult, LoginRequest, LoginResponse } from '@/types'; + +// 在开发环境中使用完整URL +const API_PREFIX = ''; + +// 创建axios实例 +const axiosInstance = axios.create({ + baseURL: API_PREFIX +}); + +// 请求拦截器,添加token +axiosInstance.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +// 响应拦截器,处理401错误 +axiosInstance.interceptors.response.use( + (response) => { + return response; + }, + (error) => { + if (error.response && error.response.status === 401) { + // 清除token + localStorage.removeItem('token'); + // 不要在这里跳转,避免循环重定向 + } + return Promise.reject(error); + } +); + +export const apiService = { + // 用户登录 + login: async (request: LoginRequest): Promise => { + try { + const response = await axios.post(`${API_PREFIX}/login`, request); + if (response.data.access_token) { + localStorage.setItem('token', response.data.access_token); + } + return response.data; + } catch (error: any) { + if (error.response) { + return { + success: false, + message: error.response.data.detail || '登录失败', + }; + } + return { + success: false, + message: error.message || '登录失败' + }; + } + }, + + // 检查认证状态 + checkAuth: async (): Promise => { + try { + const response = await axiosInstance.get(`${API_PREFIX}/check_auth`); + return response.data.authenticated === true; + } catch (error) { + // 认证失败,清除token + localStorage.removeItem('token'); + return false; + } + }, + + // 登出 + logout: () => { + localStorage.removeItem('token'); + // 简化登出逻辑 + window.location.href = '/login'; + }, + + // 分析股票 + analyzeStocks: async (request: AnalyzeRequest) => { + return axiosInstance.post(`${API_PREFIX}/analyze`, request, { + responseType: 'stream' + }); + }, + + // 测试API连接 + testApiConnection: async (request: TestApiRequest): Promise => { + try { + const response = await axiosInstance.post(`${API_PREFIX}/test_api_connection`, request); + return response.data; + } catch (error: any) { + if (error.response) { + return error.response.data; + } + return { + success: false, + message: error.message || '连接失败' + }; + } + }, + + // 搜索美股 + searchUsStocks: async (keyword: string): Promise => { + try { + const response = await axiosInstance.get(`${API_PREFIX}/search_us_stocks`, { + params: { keyword } + }); + return response.data.results || []; + } catch (error) { + console.error('搜索美股时出错:', error); + return []; + } + }, + + // 获取配置 + getConfig: async () => { + try { + const response = await axios.get(`${API_PREFIX}/config`); + return response.data; + } catch (error) { + console.error('获取配置时出错:', error); + return { + announcement: '', + default_api_url: '', + default_api_model: 'gpt-3.5-turbo', + default_api_timeout: '60' + }; + } + }, + + // 检查是否需要登录 + checkNeedLogin: async (): Promise => { + try { + const response = await axios.get(`${API_PREFIX}/need_login`); + return response.data.require_login; + } catch (error) { + console.error('检查是否需要登录时出错:', error); + // 默认为需要登录,确保安全 + return true; + } + } +}; diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..f691315 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,79 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +.card { + padding: 2em; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..a6f75ff --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,109 @@ +// API接口相关类型 +export interface ApiConfig { + apiUrl: string; + apiKey: string; + apiModel: string; + apiTimeout: string; + saveApiConfig: boolean; +} + +// 登录相关类型 +export interface LoginRequest { + password: string; +} + +export interface LoginResponse { + access_token?: string; + token_type?: string; + success?: boolean; + message?: string; +} + +export interface StockInfo { + code: string; + name: string; + marketType: string; + price?: number; + changePercent?: number; + marketValue?: number; + analysis?: string; + analysisStatus: 'waiting' | 'analyzing' | 'completed' | 'error'; + error?: string; + score?: number; + recommendation?: string; + price_change?: number; + rsi?: number; + ma_trend?: string; + macd_signal?: string; + volume_status?: string; + analysis_date?: string; +} + +export interface SearchResult { + symbol: string; + name: string; + market: string; + marketValue?: number; +} + +export interface MarketStatus { + isOpen: boolean; + nextTime: string; +} + +export interface MarketTimeInfo { + currentTime: string; + cnMarket: MarketStatus; + hkMarket: MarketStatus; + usMarket: MarketStatus; +} + +// 分析请求和响应 +export interface AnalyzeRequest { + stock_codes: string[]; + market_type: string; + api_url?: string; + api_key?: string; + api_model?: string; + api_timeout?: string; +} + +export interface TestApiRequest { + api_url: string; + api_key: string; + api_model: string; + api_timeout: string; +} + +export interface TestApiResponse { + success: boolean; + message: string; + status_code?: number; +} + +// 流式响应类型 +export interface StreamInitMessage { + stream_type: 'single' | 'batch'; + stock_code?: string; + stock_codes?: string[]; +} + +export interface StreamAnalysisUpdate { + stock_code: string; + analysis?: string; + status: 'analyzing' | 'completed' | 'error'; + error?: string; + name?: string; + price?: number; + change_percent?: number; + market_value?: number; + score?: number; + recommendation?: string; + price_change?: number; + rsi?: number; + ma_trend?: string; + macd_signal?: string; + volume_status?: string; + analysis_date?: string; + ai_analysis_chunk?: string; +} diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts new file mode 100644 index 0000000..9877d89 --- /dev/null +++ b/frontend/src/utils/index.ts @@ -0,0 +1,201 @@ +import type { MarketTimeInfo } from '@/types'; +import { marked } from 'marked'; + +// 防抖函数 +export function debounce any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: number | null = null; + + return function(...args: Parameters): void { + const later = () => { + timeout = null; + func(...args); + }; + + if (timeout !== null) { + clearTimeout(timeout); + } + timeout = window.setTimeout(later, wait); + }; +} + +// 格式化市值 +export function formatMarketValue(value: number): string { + if (!value) return '未知'; + + if (value >= 1000000000000) { + return (value / 1000000000000).toFixed(2) + '万亿'; + } else if (value >= 100000000) { + return (value / 100000000).toFixed(2) + '亿'; + } else if (value >= 10000) { + return (value / 10000).toFixed(2) + '万'; + } else { + return value.toFixed(2); + } +} + +// 解析Markdown +export function parseMarkdown(text: string): string { + try { + const result = marked(text); + if (typeof result === 'string') { + return result; + } + return ''; + } catch (e) { + console.error('解析Markdown出错:', e); + return text; + } +} + +// 更新市场时间信息 +export function updateMarketTimeInfo(): MarketTimeInfo { + const now = new Date(); + + // 当前时间 + const currentTime = now.toLocaleTimeString('zh-CN', { hour12: false }); + + // 中国时间 + const cnOptions = { timeZone: 'Asia/Shanghai', hour12: false } as Intl.DateTimeFormatOptions; + const cnTime = now.toLocaleString('zh-CN', cnOptions); + const cnHour = new Date(cnTime).getHours(); + const cnMinute = new Date(cnTime).getMinutes(); + + // A股市场状态 + const cnMarketOpen = (cnHour === 9 && cnMinute >= 30) || (cnHour === 10) || + (cnHour === 11 && cnMinute <= 30) || + (cnHour >= 13 && cnHour < 15); + + const cnNextTime = getNextTimeText(cnMarketOpen, cnHour, cnMinute, 9, 30, 15, 0); + + // 港股市场状态(与A股相同时区) + const hkMarketOpen = (cnHour === 9 && cnMinute >= 30) || + (cnHour === 10) || (cnHour === 11) || + (cnHour >= 13 && cnHour < 16); + + const hkNextTime = getNextTimeText(hkMarketOpen, cnHour, cnMinute, 9, 30, 16, 0); + + // 获取美国东部时间 + const usOptions = { timeZone: 'America/New_York', hour12: false } as Intl.DateTimeFormatOptions; + const usTime = now.toLocaleString('zh-CN', usOptions); + const usHour = new Date(usTime).getHours(); + const usMinute = new Date(usTime).getMinutes(); + + // 美股市场状态 + const usMarketOpen = (usHour >= 9 && usHour < 16) || + (usHour === 16 && usMinute === 0); + + const usNextTime = getNextTimeText(usMarketOpen, usHour, usMinute, 9, 30, 16, 0); + + return { + currentTime, + cnMarket: { isOpen: cnMarketOpen, nextTime: cnNextTime }, + hkMarket: { isOpen: hkMarketOpen, nextTime: hkNextTime }, + usMarket: { isOpen: usMarketOpen, nextTime: usNextTime } + }; +} + +// 辅助函数:获取距离下一次开/闭市的时间文本 +function getNextTimeText( + isOpen: boolean, + currentHour: number, + currentMinute: number, + openHour: number, + openMinute: number, + closeHour: number, + closeMinute: number +): string { + if (isOpen) { + // 计算距离收盘时间 + let timeToCloseMinutes = (closeHour - currentHour) * 60 + (closeMinute - currentMinute); + + if (timeToCloseMinutes <= 0) { + return '即将收盘'; + } + + const hours = Math.floor(timeToCloseMinutes / 60); + const minutes = timeToCloseMinutes % 60; + + return `距离收盘还有 ${hours}小时${minutes}分钟`; + } else { + // 计算距离开盘时间 + let nextOpenHour = openHour; + let nextOpenMinute = openMinute; + let isNextDay = false; + + if (currentHour >= closeHour) { + // 已经过了今天的收盘时间,下一个开盘是明天 + isNextDay = true; + } else if (currentHour < openHour || (currentHour === openHour && currentMinute < openMinute)) { + // 还没到今天的开盘时间 + isNextDay = false; + } else { + // 当前处于盘中休息时间,下一个开盘时间是当天下午 + nextOpenHour = 13; + nextOpenMinute = 0; + } + + let timeToOpenMinutes; + + if (isNextDay) { + timeToOpenMinutes = (24 - currentHour + nextOpenHour) * 60 + (nextOpenMinute - currentMinute); + } else { + timeToOpenMinutes = (nextOpenHour - currentHour) * 60 + (nextOpenMinute - currentMinute); + } + + if (timeToOpenMinutes <= 0) { + return '即将开盘'; + } + + const hours = Math.floor(timeToOpenMinutes / 60); + const minutes = timeToOpenMinutes % 60; + + return `距离开盘还有 ${hours}小时${minutes}分钟`; + } +} + +// 保存API配置到localStorage +export function saveApiConfigToLocalStorage(config: Partial>): void { + if (window.localStorage) { + localStorage.setItem('apiConfig', JSON.stringify(config)); + } +} + +// 从localStorage加载API配置 +export function loadApiConfig(): Partial<{ + apiUrl: string, + apiKey: string, + apiModel: string, + apiTimeout: string, + saveApiConfig: boolean +}> { + if (window.localStorage) { + const saved = localStorage.getItem('apiConfig'); + if (saved) { + try { + return JSON.parse(saved); + } catch (e) { + console.error('解析保存的API配置出错:', e); + } + } + } + return { + apiUrl: '', + apiKey: '', + apiModel: '', + apiTimeout: '', + saveApiConfig: false + }; +} + +// 清除API配置 +export function clearApiConfig(): void { + if (window.localStorage) { + localStorage.removeItem('apiConfig'); + } +} diff --git a/frontend/src/utils/stockValidator.ts b/frontend/src/utils/stockValidator.ts new file mode 100644 index 0000000..edbbc58 --- /dev/null +++ b/frontend/src/utils/stockValidator.ts @@ -0,0 +1,172 @@ + /** + * 股票代码验证工具 + * 用于验证不同市场类型的股票代码格式 + */ + +/** + * 市场类型枚举 + */ +export enum MarketType { + A = 'A', // A股 + HK = 'HK', // 港股 + US = 'US', // 美股 + ETF = 'ETF', // ETF基金 + LOF = 'LOF' // LOF基金 +} + +/** + * 验证A股股票代码 + * @param code 股票代码 + * @returns 是否为有效的A股代码 + */ +export const validateAStock = (code: string): boolean => { + // 上海证券交易所股票代码以6开头,6位数字 + // 深圳证券交易所股票代码以0或3开头,6位数字 + // 科创板股票代码以688开头,6位数字 + // 北京证券交易所股票代码以8开头,一般为5位数字(如80XXX) + // 注意:中小板、创业板代码格式已合并处理 + + // 验证上海证券交易所(以6开头的6位数字) + if (code.startsWith('6') && /^\d{6}$/.test(code)) { + return true; + } + + // 验证深圳证券交易所(以0或3开头的6位数字) + if ((code.startsWith('0') || code.startsWith('3')) && /^\d{6}$/.test(code)) { + return true; + } + + // 验证科创板(以688开头的6位数字) + if (code.startsWith('688') && /^\d{6}$/.test(code)) { + return true; + } + + // 验证北京证券交易所(以8开头的股票) + // 北交所股票一般是5位数字,格式为8xxxx + if (code.startsWith('8') && /^\d{5}$/.test(code)) { + return true; + } + + return false; +}; + +/** + * 验证港股股票代码 + * @param code 股票代码 + * @returns 是否为有效的港股代码 + */ +export const validateHKStock = (code: string): boolean => { + // 港股通常是5位数字 + return /^\d{5}$/.test(code); +}; + +/** + * 验证美股股票代码 + * @param code 股票代码 + * @returns 是否为有效的美股代码 + */ +export const validateUSStock = (code: string): boolean => { + // 美股代码通常由字母组成,长度在1-5之间 + return /^[A-Za-z]{1,5}$/.test(code); +}; + +/** + * 验证ETF/LOF基金代码 + * @param code 基金代码 + * @returns 是否为有效的基金代码 + */ +export const validateFund = (code: string): boolean => { + // 基金代码通常为6位数字 + return /^\d{6}$/.test(code); +}; + +/** + * 根据市场类型验证股票代码 + * @param code 股票代码 + * @param marketType 市场类型 + * @returns 包含验证结果和错误信息的对象 + */ +export const validateStockCode = ( + code: string, + marketType: MarketType +): { valid: boolean; errorMessage?: string } => { + + if (!code || code.trim() === '') { + return { + valid: false, + errorMessage: '股票代码不能为空' + }; + } + + switch (marketType) { + case MarketType.A: + if (!validateAStock(code)) { + return { + valid: false, + errorMessage: `无效的A股股票代码格式: ${code}。A股代码应以0、3、6、688或8开头,且为6位数字或5位数字` + }; + } + break; + + case MarketType.HK: + if (!validateHKStock(code)) { + return { + valid: false, + errorMessage: `无效的港股代码格式: ${code}。港股代码应为5位数字` + }; + } + break; + + case MarketType.US: + if (!validateUSStock(code)) { + return { + valid: false, + errorMessage: `无效的美股代码格式: ${code}。美股代码应为1-5位字母` + }; + } + break; + + case MarketType.ETF: + case MarketType.LOF: + if (!validateFund(code)) { + return { + valid: false, + errorMessage: `无效的${marketType}基金代码格式: ${code}。基金代码应为6位数字` + }; + } + break; + + default: + return { + valid: false, + errorMessage: `不支持的市场类型: ${marketType}` + }; + } + + return { valid: true }; +}; + +/** + * 批量验证多个股票代码 + * @param codes 股票代码数组 + * @param marketType 市场类型 + * @returns 包含所有无效代码及其错误信息的数组 + */ +export const validateMultipleStockCodes = ( + codes: string[], + marketType: MarketType +): { code: string; errorMessage: string }[] => { + const invalidCodes: { code: string; errorMessage: string }[] = []; + + for (const code of codes) { + const result = validateStockCode(code, marketType); + if (!result.valid && result.errorMessage) { + invalidCodes.push({ + code, + errorMessage: result.errorMessage + }); + } + } + + return invalidCodes; +}; \ No newline at end of file diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..c86af31 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,18 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "incremental": true, // 启用增量编译 + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..69fc25c --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "incremental": true, // 启用增量编译 + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..8009f22 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,68 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath } from 'url' +import { dirname, resolve } from 'path' + +// 获取当前文件的目录路径(在ESM中替代__dirname) +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + server: { + cors: true, + proxy: { + '/api': { + target: 'http://127.0.0.1:8888', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ''), + }, + '/analyze': { + target: 'http://127.0.0.1:8888', + changeOrigin: true, + }, + '/test_api_connection': { + target: 'http://127.0.0.1:8888', + changeOrigin: true, + }, + '/search_us_stocks': { + target: 'http://127.0.0.1:8888', + changeOrigin: true, + }, + '/config': { + target: 'http://127.0.0.1:8888', + changeOrigin: true, + }, + '/login': { + target: 'http://127.0.0.1:8888', + changeOrigin: true, + }, + '/check_auth': { + target: 'http://127.0.0.1:8888', + changeOrigin: true, + }, + '/need_login': { + target: 'http://127.0.0.1:8888', + changeOrigin: true, + }, + '/us_stock_detail': { + target: 'http://127.0.0.1:8888', + changeOrigin: true, + }, + '/fund_detail': { + target: 'http://127.0.0.1:8888', + changeOrigin: true, + }, + '/search_funds': { + target: 'http://127.0.0.1:8888', + changeOrigin: true, + }, + }, + }, +}) diff --git a/fund_service.py b/fund_service.py deleted file mode 100644 index fa4218b..0000000 --- a/fund_service.py +++ /dev/null @@ -1,51 +0,0 @@ -import akshare as ak -import pandas as pd - -class FundService: - def search_funds(self, keyword, market_type='ETF'): - """ - 搜索基金代码 - :param keyword: 搜索关键词 - :return: 匹配的基金列表 - """ - try: - # 获取ETF和LOF数据 - if market_type == 'ETF': - df = ak.fund_etf_spot_em() - else: - df = ak.fund_lof_spot_em() - - # 转换列名 - df = df.rename(columns={ - "代码": "symbol", - "名称": "name", - "最新价": "price", - "涨跌额": "price_change", - "涨跌幅": "price_change_percent", - "成交量": "volume", - "流通市值": "market_value", - "总市值": "total_value", - "基金折价率": "discount_rate", - }) - - # 模糊匹配搜索(同时匹配代码和名称) - mask = (df['name'].str.contains(keyword, case=False, na=False) | - df['symbol'].str.contains(keyword, case=False, na=False)) - results = df[mask] - - # 格式化返回结果并处理 NaN 值 - formatted_results = [] - for _, row in results.iterrows(): - formatted_results.append({ - 'name': row['name'] if pd.notna(row['name']) else '', - 'symbol': str(row['symbol']) if pd.notna(row['symbol']) else '', - 'price': float(row['price']) if pd.notna(row['price']) else 0.0, - 'volume': float(row['volume']) if pd.notna(row['volume']) else 0.0, - 'market_value': float(row['market_value']) if pd.notna(row['market_value']) else 0.0, - 'total_value': float(row['total_value']) if pd.notna(row['total_value']) else 0.0, - }) - - return formatted_results - - except Exception as e: - raise Exception(f"搜索基金代码失败: {str(e)}") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index c77a82b..988cf92 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,11 +10,14 @@ scipy==1.15.1 akshare==1.16.22 tqdm==4.67.1 +# Web框架与异步处理 +fastapi==0.115.11 +uvicorn[standard]==0.34.0 +pydantic==2.10.6 +httpx==0.28.1 -# 网络和API请求 -requests==2.32.3 +# 环境配置 python-dotenv==1.0.1 -flask==3.1.0 # 日志和系统工具 loguru==0.7.2 @@ -32,3 +35,5 @@ html5lib==1.1 lxml==4.9.4 jsonpath==0.82.2 openpyxl==3.1.5 +python-jose[cryptography]==3.4.0 +passlib==1.7.4 diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..7267b70 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,2 @@ +# services包初始化文件 +# 用于组织股票分析服务的各个模块 \ No newline at end of file diff --git a/services/ai_analyzer.py b/services/ai_analyzer.py new file mode 100644 index 0000000..eed82ff --- /dev/null +++ b/services/ai_analyzer.py @@ -0,0 +1,456 @@ +import pandas as pd +import numpy as np +import os +import json +import asyncio +import httpx +import re +from typing import Dict, List, Optional, Any, Generator, AsyncGenerator +from dotenv import load_dotenv +from logger import get_logger +from utils.api_utils import APIUtils +from datetime import datetime + +# 获取日志器 +logger = get_logger() + +class AIAnalyzer: + """ + 异步AI分析服务 + 负责调用AI API对股票数据进行分析 + """ + + def __init__(self, custom_api_url=None, custom_api_key=None, custom_api_model=None, custom_api_timeout=None): + """ + 初始化AI分析服务 + + Args: + custom_api_url: 自定义API URL + custom_api_key: 自定义API密钥 + custom_api_model: 自定义API模型 + custom_api_timeout: 自定义API超时时间 + """ + # 加载环境变量 + load_dotenv() + + # 设置API配置 + self.API_URL = custom_api_url or os.getenv('API_URL') + self.API_KEY = custom_api_key or os.getenv('API_KEY') + self.API_MODEL = custom_api_model or os.getenv('API_MODEL', 'gpt-3.5-turbo') + self.API_TIMEOUT = int(custom_api_timeout or os.getenv('API_TIMEOUT', 60)) + + logger.debug(f"初始化AIAnalyzer: API_URL={self.API_URL}, API_MODEL={self.API_MODEL}, API_KEY={'已提供' if self.API_KEY else '未提供'}, API_TIMEOUT={self.API_TIMEOUT}") + + async def get_ai_analysis(self, df: pd.DataFrame, stock_code: str, market_type: str = 'A', stream: bool = False) -> AsyncGenerator[str, None]: + """ + 对股票数据进行AI分析 + + Args: + df: 包含技术指标的DataFrame + stock_code: 股票代码 + market_type: 市场类型,默认为'A'股 + stream: 是否使用流式响应 + + Returns: + 异步生成器,生成分析结果字符串 + """ + try: + logger.info(f"开始AI分析 {stock_code}, 流式模式: {stream}") + + # 提取关键技术指标 + latest_data = df.iloc[-1] + + # 计算技术指标 + rsi = latest_data.get('RSI') + price = latest_data.get('Close') + price_change = latest_data.get('Change') + + # 确定MA趋势 + ma_trend = 'UP' if latest_data.get('MA5', 0) > latest_data.get('MA20', 0) else 'DOWN' + + # 确定MACD信号 + macd = latest_data.get('MACD', 0) + macd_signal = latest_data.get('MACD_Signal', 0) + macd_signal_type = 'BUY' if macd > macd_signal else 'SELL' + + # 确定成交量状态 + volume_ratio = latest_data.get('Volume_Ratio', 1) + volume_status = 'HIGH' if volume_ratio > 1.5 else ('LOW' if volume_ratio < 0.5 else 'NORMAL') + + # AI 分析内容 + # 最近14天的股票数据记录 + recent_data = df.tail(14).to_dict('records') + + # 包含trend, volatility, volume_trend, rsi_level的字典 + technical_summary = { + 'trend': 'upward' if df.iloc[-1]['MA5'] > df.iloc[-1]['MA20'] else 'downward', + 'volatility': f"{df.iloc[-1]['Volatility']:.2f}%", + 'volume_trend': 'increasing' if df.iloc[-1]['Volume_Ratio'] > 1 else 'decreasing', + 'rsi_level': df.iloc[-1]['RSI'] + } + + # 根据市场类型调整分析提示 + if market_type in ['ETF', 'LOF']: + prompt = f""" + 分析基金 {stock_code}: + + 技术指标概要: + {technical_summary} + + 近14日交易数据: + {recent_data} + + 请提供: + 1. 净值走势分析(包含支撑位和压力位) + 2. 成交量分析及其对净值的影响 + 3. 风险评估(包含波动率和折溢价分析) + 4. 短期和中期净值预测 + 5. 关键价格位分析 + 6. 申购赎回建议(包含止损位) + + 请基于技术指标和市场表现进行分析,给出具体数据支持。 + """ + elif market_type == 'US': + prompt = f""" + 分析美股 {stock_code}: + + 技术指标概要: + {technical_summary} + + 近14日交易数据: + {recent_data} + + 请提供: + 1. 趋势分析(包含支撑位和压力位,美元计价) + 2. 成交量分析及其含义 + 3. 风险评估(包含波动率和美股市场特有风险) + 4. 短期和中期目标价位(美元) + 5. 关键技术位分析 + 6. 具体交易建议(包含止损位) + + 请基于技术指标和美股市场特点进行分析,给出具体数据支持。 + """ + elif market_type == 'HK': + prompt = f""" + 分析港股 {stock_code}: + + 技术指标概要: + {technical_summary} + + 近14日交易数据: + {recent_data} + + 请提供: + 1. 趋势分析(包含支撑位和压力位,港币计价) + 2. 成交量分析及其含义 + 3. 风险评估(包含波动率和港股市场特有风险) + 4. 短期和中期目标价位(港币) + 5. 关键技术位分析 + 6. 具体交易建议(包含止损位) + + 请基于技术指标和港股市场特点进行分析,给出具体数据支持。 + """ + else: # A股 + prompt = f""" + 分析A股 {stock_code}: + + 技术指标概要: + {technical_summary} + + 近14日交易数据: + {recent_data} + + 请提供: + 1. 趋势分析(包含支撑位和压力位) + 2. 成交量分析及其含义 + 3. 风险评估(包含波动率分析) + 4. 短期和中期目标价位 + 5. 关键技术位分析 + 6. 具体交易建议(包含止损位) + + 请基于技术指标和A股市场特点进行分析,给出具体数据支持。 + """ + + # 格式化API URL + api_url = APIUtils.format_api_url(self.API_URL) + + # 准备请求数据 + request_data = { + "model": self.API_MODEL, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.7, + "stream": stream + } + + # 准备请求头 + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.API_KEY}" + } + + # 获取当前日期作为分析日期 + analysis_date = datetime.now().strftime("%Y-%m-%d") + + # 异步请求API + async with httpx.AsyncClient(timeout=self.API_TIMEOUT) as client: + # 记录请求 + logger.debug(f"发送AI请求: URL={api_url}, MODEL={self.API_MODEL}, STREAM={stream}") + + # 先发送技术指标数据 + yield json.dumps({ + "stock_code": stock_code, + "status": "analyzing", + "rsi": rsi, + "price": price, + "price_change": price_change, + "ma_trend": ma_trend, + "macd_signal": macd_signal_type, + "volume_status": volume_status, + "analysis_date": analysis_date + }) + + if stream: + # 流式响应处理 + async with client.stream("POST", api_url, json=request_data, headers=headers) as response: + if response.status_code != 200: + error_text = await response.aread() + error_data = json.loads(error_text) + error_message = error_data.get('error', {}).get('message', '未知错误') + logger.error(f"AI API请求失败: {response.status_code} - {error_message}") + yield json.dumps({ + "stock_code": stock_code, + "error": f"API请求失败: {error_message}", + "status": "error" + }) + return + + # 处理流式响应 + buffer = "" + collected_messages = [] + chunk_count = 0 + + async for chunk in response.aiter_text(): + if chunk: + # 分割多行响应(处理某些API可能在一个chunk中返回多行) + lines = chunk.strip().split('\n') + for line in lines: + line = line.strip() + if not line: + continue + + # 处理以data:开头的行 + if line.startswith("data: "): + line = line[6:] # 去除"data: "前缀 + + if line == "[DONE]": + logger.debug("收到流结束标记 [DONE]") + continue + + try: + # 处理特殊错误情况 + if "error" in line.lower(): + error_msg = line + try: + error_data = json.loads(line) + error_msg = error_data.get("error", line) + except: + pass + + logger.error(f"流式响应中收到错误: {error_msg}") + yield json.dumps({ + "stock_code": stock_code, + "error": f"流式响应错误: {error_msg}", + "status": "error" + }) + continue + + # 尝试解析JSON + chunk_data = json.loads(line) + + # 检查是否有finish_reason + finish_reason = chunk_data.get("choices", [{}])[0].get("finish_reason") + if finish_reason == "stop": + logger.debug("收到finish_reason=stop,流结束") + continue + + # 获取delta内容 + delta = chunk_data.get("choices", [{}])[0].get("delta", {}) + + # 检查delta是否为空对象 + if not delta or delta == {}: + logger.debug("收到空的delta对象,跳过") + continue + + content = delta.get("content", "") + + if content: + chunk_count += 1 + buffer += content + collected_messages.append(content) + + # 直接发送每个内容片段,不累积 + yield json.dumps({ + "stock_code": stock_code, + "ai_analysis_chunk": content, + "status": "analyzing" + }) + except json.JSONDecodeError: + # 记录解析错误并尝试恢复 + logger.error(f"JSON解析错误,块内容: {line}") + + # 如果是特定错误模式,处理它 + if "streaming failed after retries" in line.lower(): + logger.error("检测到流式传输失败") + yield json.dumps({ + "stock_code": stock_code, + "error": "流式传输失败,请稍后重试", + "status": "error" + }) + return + continue + + logger.info(f"AI流式处理完成,共收到 {chunk_count} 个内容片段,总长度: {len(buffer)}") + + # 如果buffer不为空且不以换行符结束,发送一个换行符 + if buffer and not buffer.endswith('\n'): + logger.debug("发送换行符") + yield json.dumps({ + "stock_code": stock_code, + "ai_analysis_chunk": "\n", + "status": "analyzing" + }) + + # 完整的分析内容 + full_content = buffer + + # 尝试从分析内容中提取投资建议 + recommendation = self._extract_recommendation(full_content) + + # 计算分析评分 + score = self._calculate_analysis_score(full_content, technical_summary) + + # 发送完成状态和评分、建议 + yield json.dumps({ + "stock_code": stock_code, + "status": "completed", + "score": score, + "recommendation": recommendation + }) + else: + # 非流式响应处理 + response = await client.post(api_url, json=request_data, headers=headers) + + if response.status_code != 200: + error_data = response.json() + error_message = error_data.get('error', {}).get('message', '未知错误') + logger.error(f"AI API请求失败: {response.status_code} - {error_message}") + yield json.dumps({ + "stock_code": stock_code, + "error": f"API请求失败: {error_message}", + "status": "error" + }) + return + + response_data = response.json() + analysis_text = response_data.get("choices", [{}])[0].get("message", {}).get("content", "") + + # 尝试从分析内容中提取投资建议 + recommendation = self._extract_recommendation(analysis_text) + + # 计算分析评分 + score = self._calculate_analysis_score(analysis_text, technical_summary) + + # 发送完整的分析结果 + yield json.dumps({ + "stock_code": stock_code, + "status": "completed", + "analysis": analysis_text, + "score": score, + "recommendation": recommendation, + "rsi": rsi, + "price": price, + "price_change": price_change, + "ma_trend": ma_trend, + "macd_signal": macd_signal_type, + "volume_status": volume_status, + "analysis_date": analysis_date + }) + + except Exception as e: + logger.error(f"AI分析出错: {str(e)}", exc_info=True) + yield json.dumps({ + "stock_code": stock_code, + "error": f"分析出错: {str(e)}", + "status": "error" + }) + + def _extract_recommendation(self, analysis_text: str) -> str: + """从分析文本中提取投资建议""" + # 查找投资建议部分 + investment_advice_pattern = r"##\s*投资建议\s*\n(.*?)(?:\n##|\Z)" + match = re.search(investment_advice_pattern, analysis_text, re.DOTALL) + + if match: + advice_text = match.group(1).strip() + + # 提取关键建议 + if "买入" in advice_text or "增持" in advice_text: + return "买入" + elif "卖出" in advice_text or "减持" in advice_text: + return "卖出" + elif "持有" in advice_text: + return "持有" + else: + return "观望" + + return "观望" # 默认建议 + + def _calculate_analysis_score(self, analysis_text: str, technical_summary: dict) -> int: + """计算分析评分""" + score = 50 # 基础分数 + + # 根据技术指标调整分数 + if technical_summary['trend'] == 'upward': + score += 10 + else: + score -= 10 + + if technical_summary['volume_trend'] == 'increasing': + score += 5 + else: + score -= 5 + + rsi = technical_summary['rsi_level'] + if rsi < 30: # 超卖 + score += 15 + elif rsi > 70: # 超买 + score -= 15 + + # 根据分析文本中的关键词调整分数 + if "强烈买入" in analysis_text or "显著上涨" in analysis_text: + score += 20 + elif "买入" in analysis_text or "看涨" in analysis_text: + score += 10 + elif "强烈卖出" in analysis_text or "显著下跌" in analysis_text: + score -= 20 + elif "卖出" in analysis_text or "看跌" in analysis_text: + score -= 10 + + # 确保分数在0-100范围内 + return max(0, min(100, score)) + + def _truncate_json_for_logging(self, json_obj, max_length=500): + """ + 截断JSON对象以便记录日志 + + Args: + json_obj: JSON对象 + max_length: 最大长度 + + Returns: + 截断后的字符串 + """ + json_str = json.dumps(json_obj, ensure_ascii=False) + if len(json_str) <= max_length: + return json_str + return json_str[:max_length] + "..." \ No newline at end of file diff --git a/services/fund_service_async.py b/services/fund_service_async.py new file mode 100644 index 0000000..e232188 --- /dev/null +++ b/services/fund_service_async.py @@ -0,0 +1,228 @@ +import asyncio +import pandas as pd +from typing import List, Dict, Any, Optional +from logger import get_logger +from datetime import datetime, timedelta + +# 获取日志器 +logger = get_logger() + +class FundServiceAsync: + """ + 异步基金服务 + 提供基金数据的异步搜索和获取功能 + """ + + def __init__(self): + """初始化异步基金服务""" + logger.debug("初始化FundServiceAsync") + + # 添加缓存 + self._etf_cache = None + self._lof_cache = None + self._cache_timestamp = None + self._cache_duration = timedelta(minutes=30) # 缓存30分钟 + + async def search_funds(self, keyword: str, market_type: str = 'ETF') -> List[Dict[str, Any]]: + """ + 异步搜索基金代码 + + Args: + keyword: 搜索关键词 + market_type: 市场类型,'ETF'或'LOF' + + Returns: + 匹配的基金列表 + """ + try: + logger.info(f"异步搜索基金: {keyword}, 类型: {market_type}") + + # 获取基金数据 + df = await self._get_funds_data(market_type) + + # 模糊匹配搜索(同时匹配代码和名称) + mask = (df['name'].str.contains(keyword, case=False, na=False) | + df['symbol'].str.contains(keyword, case=False, na=False)) + results = df[mask] + + # 格式化返回结果并处理 NaN 值 + formatted_results = [] + for _, row in results.iterrows(): + formatted_results.append({ + 'name': row['name'] if pd.notna(row['name']) else '', + 'symbol': str(row['symbol']) if pd.notna(row['symbol']) else '', + 'price': float(row['price']) if pd.notna(row['price']) else 0.0, + 'volume': float(row['volume']) if pd.notna(row['volume']) else 0.0, + 'market_value': float(row['market_value']) if pd.notna(row['market_value']) else 0.0, + 'total_value': float(row['total_value']) if pd.notna(row['total_value']) else 0.0, + }) + # 限制只返回前10个结果 + if len(formatted_results) >= 10: + break + + logger.info(f"基金搜索完成,找到 {len(formatted_results)} 个匹配项(限制显示前10个)") + return formatted_results + + except Exception as e: + error_msg = f"搜索基金代码失败: {str(e)}" + logger.error(error_msg) + logger.exception(e) + raise Exception(error_msg) + + async def _get_funds_data(self, market_type: str = 'ETF') -> pd.DataFrame: + """ + 异步获取基金数据,支持缓存 + + Args: + market_type: 市场类型,'ETF'或'LOF' + + Returns: + 包含基金数据的DataFrame + """ + # 检查缓存是否有效 + now = datetime.now() + cache_valid = ( + self._cache_timestamp is not None and + (now - self._cache_timestamp) < self._cache_duration + ) + + if market_type == 'ETF' and cache_valid and self._etf_cache is not None: + logger.debug("使用ETF缓存数据") + return self._etf_cache + elif market_type == 'LOF' and cache_valid and self._lof_cache is not None: + logger.debug("使用LOF缓存数据") + return self._lof_cache + + # 缓存无效,重新获取数据 + try: + logger.debug(f"从API获取{market_type}数据") + + # 使用线程池执行同步的akshare调用 + if market_type == 'ETF': + df = await asyncio.to_thread(self._get_etf_data) + self._etf_cache = df + else: + df = await asyncio.to_thread(self._get_lof_data) + self._lof_cache = df + + self._cache_timestamp = now + return df + + except Exception as e: + logger.error(f"获取{market_type}数据失败: {str(e)}") + logger.exception(e) + raise + + def _get_etf_data(self) -> pd.DataFrame: + """ + 获取ETF数据(同步方法,将被异步方法调用) + + Returns: + 包含ETF数据的DataFrame + """ + import akshare as ak + + try: + # 获取ETF基金数据 + df = ak.fund_etf_spot_em() + + # 转换列名 + df = df.rename(columns={ + "代码": "symbol", + "名称": "name", + "最新价": "price", + "涨跌额": "price_change", + "涨跌幅": "price_change_percent", + "成交量": "volume", + "流通市值": "market_value", + "总市值": "total_value", + "基金折价率": "discount_rate", + }) + + return df + + except Exception as e: + logger.error(f"获取ETF数据失败: {str(e)}") + logger.exception(e) + raise Exception(f"获取ETF数据失败: {str(e)}") + + def _get_lof_data(self) -> pd.DataFrame: + """ + 获取LOF数据(同步方法,将被异步方法调用) + + Returns: + 包含LOF数据的DataFrame + """ + import akshare as ak + + try: + # 获取LOF基金数据 + df = ak.fund_lof_spot_em() + + # 转换列名 + df = df.rename(columns={ + "代码": "symbol", + "名称": "name", + "最新价": "price", + "涨跌额": "price_change", + "涨跌幅": "price_change_percent", + "成交量": "volume", + "流通市值": "market_value", + "总市值": "total_value", + "基金折价率": "discount_rate", + }) + + return df + + except Exception as e: + logger.error(f"获取LOF数据失败: {str(e)}") + logger.exception(e) + raise Exception(f"获取LOF数据失败: {str(e)}") + + async def get_fund_detail(self, symbol: str, market_type: str = 'ETF') -> Dict[str, Any]: + """ + 异步获取单个基金详细信息 + + Args: + symbol: 基金代码 + market_type: 市场类型,'ETF'或'LOF' + + Returns: + 基金详细信息 + """ + try: + logger.info(f"获取{market_type}基金详情: {symbol}") + + # 获取基金数据 + df = await self._get_funds_data(market_type) + + # 精确匹配基金代码 + result = df[df['symbol'] == symbol] + + if len(result) == 0: + raise Exception(f"未找到基金代码: {symbol}") + + # 获取第一行数据 + row = result.iloc[0] + + # 格式化为字典 + fund_detail = { + 'name': row['name'] if pd.notna(row['name']) else '', + 'symbol': str(row['symbol']) if pd.notna(row['symbol']) else '', + 'price': float(row['price']) if pd.notna(row['price']) else 0.0, + 'price_change': float(row['price_change']) if pd.notna(row['price_change']) else 0.0, + 'price_change_percent': float(row['price_change_percent'].strip('%'))/100 if pd.notna(row['price_change_percent']) else 0.0, + 'volume': float(row['volume']) if pd.notna(row['volume']) else 0.0, + 'market_value': float(row['market_value']) if pd.notna(row['market_value']) else 0.0, + 'total_value': float(row['total_value']) if pd.notna(row['total_value']) else 0.0, + 'discount_rate': float(row['discount_rate'].strip('%'))/100 if pd.notna(row['discount_rate']) else 0.0 + } + + logger.info(f"获取基金详情成功: {symbol}") + return fund_detail + + except Exception as e: + error_msg = f"获取基金详情失败: {str(e)}" + logger.error(error_msg) + logger.exception(e) + raise Exception(error_msg) \ No newline at end of file diff --git a/services/stock_analyzer_service.py b/services/stock_analyzer_service.py new file mode 100644 index 0000000..d73738f --- /dev/null +++ b/services/stock_analyzer_service.py @@ -0,0 +1,269 @@ +import pandas as pd +import numpy as np +import asyncio +import json +from datetime import datetime +from typing import Dict, List, Optional, Tuple, Any, AsyncGenerator +from logger import get_logger +from services.stock_data_provider import StockDataProvider +from services.technical_indicator import TechnicalIndicator +from services.stock_scorer import StockScorer +from services.ai_analyzer import AIAnalyzer + +# 获取日志器 +logger = get_logger() + +class StockAnalyzerService: + """ + 股票分析服务 + 作为门面类协调数据提供、指标计算、评分和AI分析等组件 + """ + + def __init__(self, custom_api_url=None, custom_api_key=None, custom_api_model=None, custom_api_timeout=None): + """ + 初始化股票分析服务 + + Args: + custom_api_url: 自定义API URL + custom_api_key: 自定义API密钥 + custom_api_model: 自定义API模型 + custom_api_timeout: 自定义API超时时间 + """ + # 初始化各个组件 + self.data_provider = StockDataProvider() + self.indicator = TechnicalIndicator() + self.scorer = StockScorer() + self.ai_analyzer = AIAnalyzer( + custom_api_url=custom_api_url, + custom_api_key=custom_api_key, + custom_api_model=custom_api_model, + custom_api_timeout=custom_api_timeout + ) + + logger.info("初始化StockAnalyzerService完成") + + async def analyze_stock(self, stock_code: str, market_type: str = 'A', stream: bool = False) -> AsyncGenerator[str, None]: + """ + 分析单只股票 + + Args: + stock_code: 股票代码 + market_type: 市场类型,默认为'A'股 + stream: 是否使用流式响应 + + Returns: + 异步生成器,生成分析结果的JSON字符串 + """ + try: + logger.info(f"开始分析股票: {stock_code}, 市场: {market_type}") + + # 获取股票数据 + df = await self.data_provider.get_stock_data(stock_code, market_type) + + # 检查是否有错误 + if hasattr(df, 'error'): + error_msg = df.error + logger.error(f"获取股票数据时出错: {error_msg}") + yield json.dumps({ + "stock_code": stock_code, + "market_type": market_type, + "error": error_msg, + "status": "error" + }) + return + + # 检查数据是否为空 + if df.empty: + error_msg = f"获取到的股票 {stock_code} 数据为空" + logger.error(error_msg) + yield json.dumps({ + "stock_code": stock_code, + "market_type": market_type, + "error": error_msg, + "status": "error" + }) + return + + # 计算技术指标 + df_with_indicators = self.indicator.calculate_indicators(df) + + # 计算评分 + score = self.scorer.calculate_score(df_with_indicators) + recommendation = self.scorer.get_recommendation(score) + + # 获取最新数据 + latest_data = df_with_indicators.iloc[-1] + previous_data = df_with_indicators.iloc[-2] if len(df_with_indicators) > 1 else latest_data + + # 计算价格变化百分比 + price_change = ((latest_data['Close'] - previous_data['Close']) / previous_data['Close']) * 100 + + # 确定MA趋势 + ma_short = latest_data.get('MA5', 0) + ma_medium = latest_data.get('MA20', 0) + ma_long = latest_data.get('MA60', 0) + + if ma_short > ma_medium > ma_long: + ma_trend = "UP" + elif ma_short < ma_medium < ma_long: + ma_trend = "DOWN" + else: + ma_trend = "FLAT" + + # 确定MACD信号 + macd = latest_data.get('MACD', 0) + signal = latest_data.get('Signal', 0) + + if macd > signal: + macd_signal = "BUY" + elif macd < signal: + macd_signal = "SELL" + else: + macd_signal = "HOLD" + + # 确定成交量状态 + volume = latest_data.get('Volume', 0) + volume_ma = latest_data.get('Volume_MA', 0) + + if volume > volume_ma * 1.5: + volume_status = "HIGH" + elif volume < volume_ma * 0.5: + volume_status = "LOW" + else: + volume_status = "NORMAL" + + # 当前分析日期 + analysis_date = datetime.now().strftime('%Y-%m-%d') + + # 生成基本分析结果 + basic_result = { + "stock_code": stock_code, + "market_type": market_type, + "analysis_date": analysis_date, + "score": score, + "price": latest_data['Close'], + "price_change": price_change, + "ma_trend": ma_trend, + "rsi": latest_data.get('RSI', 0), + "macd_signal": macd_signal, + "volume_status": volume_status, + "recommendation": recommendation, + "ai_analysis": "" + } + + # 输出基本分析结果 + logger.info(f"基本分析结果: {json.dumps(basic_result)}") + yield json.dumps(basic_result) + + # 使用AI进行深入分析 + async for analysis_chunk in self.ai_analyzer.get_ai_analysis(df_with_indicators, stock_code, market_type, stream): + yield analysis_chunk + + logger.info(f"完成股票分析: {stock_code}") + + except Exception as e: + error_msg = f"分析股票 {stock_code} 时出错: {str(e)}" + logger.error(error_msg) + logger.exception(e) + yield json.dumps({"error": error_msg}) + + async def scan_stocks(self, stock_codes: List[str], market_type: str = 'A', min_score: int = 0, stream: bool = False) -> AsyncGenerator[str, None]: + """ + 批量扫描股票 + + Args: + stock_codes: 股票代码列表 + market_type: 市场类型 + min_score: 最低评分阈值 + stream: 是否使用流式响应 + + Returns: + 异步生成器,生成扫描结果的JSON字符串 + """ + try: + logger.info(f"开始批量扫描 {len(stock_codes)} 只股票, 市场: {market_type}") + + # 输出初始状态 - 发送批量分析初始化消息 + yield json.dumps({ + "stream_type": "batch", + "stock_codes": stock_codes, + "market_type": market_type, + "min_score": min_score + }) + + # 批量获取股票数据 + stock_data_dict = await self.data_provider.get_multiple_stocks_data(stock_codes, market_type) + + # 计算技术指标 + stock_with_indicators = {} + for code, df in stock_data_dict.items(): + try: + stock_with_indicators[code] = self.indicator.calculate_indicators(df) + except Exception as e: + logger.error(f"计算 {code} 技术指标时出错: {str(e)}") + # 发送错误状态 + yield json.dumps({ + "stock_code": code, + "error": f"计算技术指标时出错: {str(e)}", + "status": "error" + }) + + # 评分股票 + results = self.scorer.batch_score_stocks(stock_with_indicators) + + # 过滤低于最低评分的股票 + filtered_results = [r for r in results if r[1] >= min_score] + + # 为每只股票发送基本评分和推荐信息 + for code, score, rec in results: + df = stock_with_indicators.get(code) + if df is not None and len(df) > 0: + # 获取最新数据 + latest_data = df.iloc[-1] + + # 发送股票基本信息和评分 + yield json.dumps({ + "stock_code": code, + "score": score, + "recommendation": rec, + "price": float(latest_data.get('Close', 0)), + "price_change": float(latest_data.get('Change', 0)), + "rsi": float(latest_data.get('RSI', 0)) if 'RSI' in latest_data else None, + "ma_trend": "UP" if latest_data.get('MA5', 0) > latest_data.get('MA20', 0) else "DOWN", + "macd_signal": "BUY" if latest_data.get('MACD', 0) > latest_data.get('MACD_Signal', 0) else "SELL", + "volume_status": "HIGH" if latest_data.get('Volume_Ratio', 1) > 1.5 else ("LOW" if latest_data.get('Volume_Ratio', 1) < 0.5 else "NORMAL"), + "status": "completed" if score < min_score else "waiting" + }) + + # 如果需要进一步分析,对评分较高的股票进行AI分析 + if stream and filtered_results: + # 只分析前5只评分最高的股票,避免分析过多导致前端卡顿 + top_stocks = filtered_results[:5] + + for stock_code, score, _ in top_stocks: + df = stock_with_indicators.get(stock_code) + if df is not None: + # 输出正在分析的股票信息 + yield json.dumps({ + "stock_code": stock_code, + "status": "analyzing" + }) + + # AI分析 + async for analysis_chunk in self.ai_analyzer.get_ai_analysis(df, stock_code, market_type, stream): + yield analysis_chunk + + # 输出扫描完成信息 + yield json.dumps({ + "scan_completed": True, + "total_scanned": len(results), + "total_matched": len(filtered_results) + }) + + logger.info(f"完成批量扫描 {len(stock_codes)} 只股票, 符合条件: {len(filtered_results)}") + + except Exception as e: + error_msg = f"批量扫描股票时出错: {str(e)}" + logger.error(error_msg) + logger.exception(e) + yield json.dumps({"error": error_msg}) diff --git a/services/stock_data_provider.py b/services/stock_data_provider.py new file mode 100644 index 0000000..af2afa0 --- /dev/null +++ b/services/stock_data_provider.py @@ -0,0 +1,260 @@ +import pandas as pd +import numpy as np +from datetime import datetime, timedelta +import asyncio +import os +from typing import Dict, List, Optional, Tuple, Any +from logger import get_logger +import re + +# 获取日志器 +logger = get_logger() + +class StockDataProvider: + """ + 异步股票数据提供服务 + 负责获取股票、基金等金融产品的历史数据 + """ + + def __init__(self): + """初始化数据提供者服务""" + logger.debug("初始化StockDataProvider") + + async def get_stock_data(self, stock_code: str, market_type: str = 'A', + start_date: Optional[str] = None, + end_date: Optional[str] = None) -> pd.DataFrame: + """ + 异步获取股票或基金数据 + + Args: + stock_code: 股票代码 + market_type: 市场类型,默认为'A'股 + start_date: 开始日期,格式YYYYMMDD,默认为一年前 + end_date: 结束日期,格式YYYYMMDD,默认为今天 + + Returns: + 包含历史数据的DataFrame + """ + # 使用线程池执行同步的akshare调用 + return await asyncio.to_thread( + self._get_stock_data_sync, + stock_code, + market_type, + start_date, + end_date + ) + + def _get_stock_data_sync(self, stock_code: str, market_type: str = 'A', + start_date: Optional[str] = None, + end_date: Optional[str] = None) -> pd.DataFrame: + """ + 同步获取股票数据的实现 + 将被异步方法调用 + """ + import akshare as ak + + if start_date is None: + start_date = (datetime.now() - timedelta(days=365)).strftime('%Y%m%d') + if end_date is None: + end_date = datetime.now().strftime('%Y%m%d') + + # 确保日期格式统一(移除可能的'-'符号) + if isinstance(start_date, str) and '-' in start_date: + start_date = start_date.replace('-', '') + if isinstance(end_date, str) and '-' in end_date: + end_date = end_date.replace('-', '') + + try: + if market_type == 'A': + logger.debug(f"获取A股数据: {stock_code}") + + df = ak.stock_zh_a_hist( + symbol=stock_code, + start_date=start_date, + end_date=end_date, + adjust="qfq" + ) + + elif market_type in ['HK']: + logger.debug(f"获取港股数据: {stock_code}") + df = ak.stock_hk_daily( + symbol=stock_code, + start_date=start_date, + end_date=end_date, + adjust="qfq" + ) + + elif market_type in ['US']: + logger.debug(f"获取美股数据: {stock_code}") + try: + df = ak.stock_us_daily( + symbol=stock_code, + adjust="qfq" + ) + logger.debug(f"美股数据原始列: {df.columns.tolist()}") + logger.debug(f"美股数据形状: {df.shape}") + + # 确保索引是日期时间类型 + if not isinstance(df.index, pd.DatetimeIndex): + # 如果存在命名为'date'的列,将其设为索引 + if 'date' in df.columns: + df['date'] = pd.to_datetime(df['date']) + df.set_index('date', inplace=True) + logger.debug("已将'date'列设置为索引") + else: + # 否则将当前索引转换为日期类型 + df.index = pd.to_datetime(df.index) + logger.debug("已将索引转换为DatetimeIndex") + + # 计算美股的成交额(Amount)= 成交量(Volume)× 收盘价(Close) + volume_col = next((col for col in df.columns if col.lower() == 'volume'), None) + close_col = next((col for col in df.columns if col.lower() == 'close'), None) + + if volume_col and close_col: + df['amount'] = df[volume_col] * df[close_col] + logger.debug("已为美股数据计算成交额(amount)字段") + else: + logger.warning(f"美股数据缺少volume或close列,无法计算amount。当前列: {df.columns.tolist()}") + # 添加空的amount列,避免后续处理错误 + df['amount'] = 0.0 + + # 将所有列名转为小写以进行统一处理 + df.columns = [col.lower() for col in df.columns] + + except Exception as e: + logger.error(f"获取美股数据失败 {stock_code}: {str(e)}") + raise ValueError(f"获取美股数据失败 {stock_code}: {str(e)}") + + # 将字符串日期转换为日期时间对象进行比较 + try: + # 尝试多种格式解析日期 + # 如果日期是数字格式(20220101),使用适当的格式 + if start_date.isdigit() and len(start_date) == 8: + start_date_dt = pd.to_datetime(start_date, format='%Y%m%d') + else: + # 否则让pandas自动推断格式 + start_date_dt = pd.to_datetime(start_date) + + if end_date.isdigit() and len(end_date) == 8: + end_date_dt = pd.to_datetime(end_date, format='%Y%m%d') + else: + end_date_dt = pd.to_datetime(end_date) + except Exception as e: + logger.warning(f"日期转换出错: {str(e)},使用默认值") + # 如果转换失败,使用合理的默认值 + start_date_dt = pd.to_datetime('20000101', format='%Y%m%d') + end_date_dt = pd.to_datetime(datetime.now().strftime('%Y%m%d'), format='%Y%m%d') + + # 过滤日期 + try: + df = df[(df.index >= start_date_dt) & (df.index <= end_date_dt)] + logger.debug(f"日期过滤后数据点数: {len(df)}") + except Exception as e: + logger.warning(f"日期过滤出错: {str(e)},返回原始数据") + + elif market_type in ['ETF', 'LOF']: + logger.debug(f"获取{market_type}基金数据: {stock_code}") + df = ak.fund_etf_hist_sina( + symbol=stock_code, + start_date=start_date.replace('-', ''), + end_date=end_date.replace('-', '') + ) + + else: + error_msg = f"不支持的市场类型: {market_type}" + logger.error(f"[市场类型错误] {error_msg}") + raise ValueError(error_msg) + + # 标准化列名 + if market_type == 'A': + # 根据实际数据结构调整列名映射 + # 实际数据列:['日期', '股票代码', '开盘', '收盘', '最高', '最低', '成交量', '成交额', '振幅', '涨跌幅', '涨跌额', '换手率'] + df.columns = ['Date', 'Code', 'Open', 'Close', 'High', 'Low', 'Volume', 'Amount', 'Amplitude', 'Change_pct', 'Change', 'Turnover'] + elif market_type in ['HK', 'US']: + # 美股数据列可能不同,需要通过映射处理 + columns_mapping = { + 'open': 'Open', + 'high': 'High', + 'low': 'Low', + 'close': 'Close', + 'volume': 'Volume', + 'amount': 'Amount' + } + + # 创建新的DataFrame以确保列顺序和存在性 + new_df = pd.DataFrame(index=df.index) + + # 遍历映射,填充新DataFrame + for orig_col, new_col in columns_mapping.items(): + if orig_col in df.columns: + new_df[new_col] = df[orig_col] + else: + # 如果原始列不存在,创建一个填充0的列 + logger.warning(f"数据中缺少{orig_col}列,使用0值填充") + new_df[new_col] = 0.0 + + # 替换原始df + df = new_df + + elif market_type in ['ETF', 'LOF']: + # 基金数据可能有不同的列 + df.columns = ['Date', 'Open', 'High', 'Low', 'Close', 'Volume', 'Amount'] + + # 确保日期列是日期类型 + if 'Date' in df.columns: + df['Date'] = pd.to_datetime(df['Date']) + df.set_index('Date', inplace=True) + + # 确保按日期升序排序 + df.sort_index(inplace=True) + + logger.info(f"成功获取{market_type}数据 {stock_code}, 数据点数: {len(df)}") + return df + + except Exception as e: + error_msg = f"获取{market_type}数据失败 {stock_code}: {str(e)}" + logger.error(error_msg) + logger.exception(e) + # 使用空的DataFrame并添加错误信息,而不是抛出异常 + # 这样上层调用者可以检查是否有错误并适当处理 + df = pd.DataFrame() + df.error = error_msg # 添加错误属性 + return df + + async def get_multiple_stocks_data(self, stock_codes: List[str], + market_type: str = 'A', + start_date: Optional[str] = None, + end_date: Optional[str] = None, + max_concurrency: int = 5) -> Dict[str, pd.DataFrame]: + """ + 异步批量获取多只股票数据 + + Args: + stock_codes: 股票代码列表 + market_type: 市场类型,默认为'A'股 + start_date: 开始日期,格式YYYYMMDD + end_date: 结束日期,格式YYYYMMDD + max_concurrency: 最大并发数,默认为5 + + Returns: + 字典,键为股票代码,值为对应的DataFrame + """ + # 使用信号量控制并发数 + semaphore = asyncio.Semaphore(max_concurrency) + + async def get_with_semaphore(code): + async with semaphore: + try: + return code, await self.get_stock_data(code, market_type, start_date, end_date) + except Exception as e: + logger.error(f"获取股票 {code} 数据时出错: {str(e)}") + return code, None + + # 创建异步任务 + tasks = [get_with_semaphore(code) for code in stock_codes] + + # 等待所有任务完成 + results = await asyncio.gather(*tasks) + + # 构建结果字典,过滤掉失败的请求 + return {code: df for code, df in results if df is not None} \ No newline at end of file diff --git a/services/stock_scorer.py b/services/stock_scorer.py new file mode 100644 index 0000000..c631f14 --- /dev/null +++ b/services/stock_scorer.py @@ -0,0 +1,128 @@ +import pandas as pd +import numpy as np +from typing import Dict, Optional, Any, List, Tuple +from logger import get_logger + +# 获取日志器 +logger = get_logger() + +class StockScorer: + """ + 股票评分服务 + 负责根据技术指标计算股票的综合评分 + """ + + def __init__(self): + """初始化股票评分服务""" + logger.debug("初始化StockScorer") + + def calculate_score(self, df: pd.DataFrame) -> int: + """ + 计算股票评分(满分100分) + + Args: + df: 包含技术指标的DataFrame + + Returns: + 股票评分(0-100的整数) + """ + try: + # 使用最新的数据点进行评分 + latest = df.iloc[-1] + + # 初始得分为0 + score = 0 + + # 移动平均线评分(25分) + if latest['MA5'] > latest['MA20'] > latest['MA60']: + # 短期、中期和长期均线呈多头排列 + score += 25 + elif latest['MA5'] > latest['MA20']: + # 短期均线在中期均线之上 + score += 15 + elif latest['Close'] > latest['MA20']: + # 股价在中期均线之上 + score += 10 + + # RSI评分(25分) + rsi = latest['RSI'] + if 45 <= rsi <= 55: + # RSI在中间区域,可能即将爆发 + score += 15 + elif 55 < rsi < 70: + # RSI在强势区域但未超买 + score += 25 + elif 30 < rsi < 45: + # RSI在弱势区域但未超卖 + score += 10 + elif rsi >= 70: + # RSI超买 + score += 5 + elif rsi <= 30: + # RSI超卖 + score += 15 + + # MACD得分(20分) + if latest['MACD'] > latest['Signal']: + score += 20 + + # 成交量得分(30分) + if latest['Volume_Ratio'] > 1.5: + score += 30 + elif latest['Volume_Ratio'] > 1: + score += 15 + + return score + + except Exception as e: + logger.error(f"计算评分时出错: {str(e)}") + logger.exception(e) + raise + + def get_recommendation(self, score: int) -> str: + """ + 根据评分获取投资建议 + + Args: + score: 股票评分(0-100) + + Returns: + 投资建议文本 + """ + if score >= 80: + return "强烈推荐" + elif score >= 70: + return "推荐" + elif score >= 60: + return "谨慎推荐" + elif score >= 40: + return "观望" + elif score >= 20: + return "不推荐" + else: + return "强烈不推荐" + + def batch_score_stocks(self, stock_dfs: Dict[str, pd.DataFrame]) -> List[Tuple[str, int, str]]: + """ + 批量评分多只股票 + + Args: + stock_dfs: 字典,键为股票代码,值为DataFrame + + Returns: + 评分结果列表,每项为(股票代码, 评分, 推荐)的三元组 + """ + results = [] + + for stock_code, df in stock_dfs.items(): + try: + score = self.calculate_score(df) + recommendation = self.get_recommendation(score) + results.append((stock_code, score, recommendation)) + except Exception as e: + logger.error(f"评分股票 {stock_code} 时出错: {str(e)}") + + # 按评分降序排序 + results.sort(key=lambda x: x[1], reverse=True) + + return results \ No newline at end of file diff --git a/services/technical_indicator.py b/services/technical_indicator.py new file mode 100644 index 0000000..0f8aacb --- /dev/null +++ b/services/technical_indicator.py @@ -0,0 +1,187 @@ +import pandas as pd +import numpy as np +from typing import Dict, Optional, Any +from logger import get_logger + +# 获取日志器 +logger = get_logger() + +class TechnicalIndicator: + """ + 技术指标计算服务 + 负责计算常见的股票技术指标 + """ + + def __init__(self, params: Optional[Dict[str, Any]] = None): + """ + 初始化技术指标计算服务 + + Args: + params: 技术指标参数配置 + """ + # 默认参数设置 + self.params = params or { + 'ma_periods': {'short': 5, 'medium': 20, 'long': 60}, + 'rsi_period': 14, + 'bollinger_period': 20, + 'bollinger_std': 2, + 'volume_ma_period': 20, + 'atr_period': 14 + } + + logger.debug(f"初始化TechnicalIndicator,参数: {self.params}") + + def calculate_ema(self, series: pd.Series, period: int) -> pd.Series: + """ + 计算指数移动平均线 + + Args: + series: 价格序列 + period: 周期 + + Returns: + EMA序列 + """ + return series.ewm(span=period, adjust=False).mean() + + def calculate_rsi(self, series: pd.Series, period: int) -> pd.Series: + """ + 计算相对强弱指标(RSI) + + Args: + series: 价格序列 + period: 周期 + + Returns: + RSI序列 + """ + delta = series.diff() + gain = delta.where(delta > 0, 0) + loss = -delta.where(delta < 0, 0) + + avg_gain = gain.rolling(window=period).mean() + avg_loss = loss.rolling(window=period).mean() + + rs = avg_gain / avg_loss + rsi = 100 - (100 / (1 + rs)) + + return rsi + + def calculate_macd(self, series: pd.Series) -> tuple: + """ + 计算MACD指标 + + Args: + series: 价格序列 + + Returns: + (MACD线, 信号线, 柱状图)的元组 + """ + ema12 = self.calculate_ema(series, 12) + ema26 = self.calculate_ema(series, 26) + + macd = ema12 - ema26 + signal = self.calculate_ema(macd, 9) + histogram = macd - signal + + return macd, signal, histogram + + def calculate_bollinger_bands(self, series: pd.Series, period: int, std_dev: float) -> tuple: + """ + 计算布林带 + + Args: + series: 价格序列 + period: 周期 + std_dev: 标准差倍数 + + Returns: + (中轨, 上轨, 下轨)的元组 + """ + middle = series.rolling(window=period).mean() + std = series.rolling(window=period).std() + + upper = middle + std_dev * std + lower = middle - std_dev * std + + return middle, upper, lower + + def calculate_atr(self, df: pd.DataFrame, period: int) -> pd.Series: + """ + 计算平均真实波幅(ATR) + + Args: + df: 包含High, Low, Close列的DataFrame + period: 周期 + + Returns: + ATR序列 + """ + high = df['High'] + low = df['Low'] + close = df['Close'] + + tr1 = high - low + tr2 = abs(high - close.shift()) + tr3 = abs(low - close.shift()) + + tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) + atr = tr.rolling(window=period).mean() + + return atr + + def calculate_indicators(self, df: pd.DataFrame) -> pd.DataFrame: + """ + 计算所有技术指标 + + Args: + df: 原始价格数据,包含Open, High, Low, Close, Volume列 + + Returns: + 添加了技术指标的DataFrame + """ + try: + # 复制数据框 + result_df = df.copy() + + # 移动平均线 + for name, period in self.params['ma_periods'].items(): + result_df[f'MA{period}'] = result_df['Close'].rolling(window=period).mean() + + # RSI + result_df['RSI'] = self.calculate_rsi(result_df['Close'], self.params['rsi_period']) + + # MACD + macd, signal, histogram = self.calculate_macd(result_df['Close']) + result_df['MACD'] = macd + result_df['Signal'] = signal + result_df['Histogram'] = histogram + + # 布林带 + middle, upper, lower = self.calculate_bollinger_bands( + result_df['Close'], + self.params['bollinger_period'], + self.params['bollinger_std'] + ) + result_df['BB_Middle'] = middle + result_df['BB_Upper'] = upper + result_df['BB_Lower'] = lower + + # 成交量移动平均 + result_df['Volume_MA'] = result_df['Volume'].rolling(window=self.params['volume_ma_period']).mean() + + # 成交量比率 + result_df['Volume_Ratio'] = result_df['Volume'] / result_df['Volume_MA'] + + # ATR + result_df['ATR'] = self.calculate_atr(result_df, self.params['atr_period']) + + # 波动率 (过去20天收盘价的标准差/均值) + result_df['Volatility'] = result_df['Close'].rolling(window=20).std() / result_df['Close'].rolling(window=20).mean() * 100 + + return result_df + + except Exception as e: + logger.error(f"计算技术指标时出错: {str(e)}") + logger.exception(e) + raise \ No newline at end of file diff --git a/services/us_stock_service_async.py b/services/us_stock_service_async.py new file mode 100644 index 0000000..97c94b1 --- /dev/null +++ b/services/us_stock_service_async.py @@ -0,0 +1,154 @@ +import asyncio +import pandas as pd +from typing import List, Dict, Any, Optional +from logger import get_logger + +# 获取日志器 +logger = get_logger() + +class USStockServiceAsync: + """ + 异步美股服务 + 提供美股数据的异步搜索和获取功能 + """ + + def __init__(self): + """初始化异步美股服务""" + logger.debug("初始化USStockServiceAsync") + + # 可选:添加缓存以减少频繁请求 + self._cache = None + self._cache_timestamp = None + + async def search_us_stocks(self, keyword: str) -> List[Dict[str, Any]]: + """ + 异步搜索美股代码 + + Args: + keyword: 搜索关键词 + + Returns: + 匹配的股票列表 + """ + try: + logger.info(f"异步搜索美股: {keyword}") + + # 使用线程池执行同步的akshare调用 + df = await asyncio.to_thread(self._get_us_stocks_data) + + # 模糊匹配搜索 + mask = df['name'].str.contains(keyword, case=False, na=False) + results = df[mask] + + # 格式化返回结果并处理 NaN 值 + formatted_results = [] + for _, row in results.iterrows(): + formatted_results.append({ + 'name': row['name'] if pd.notna(row['name']) else '', + 'symbol': str(row['symbol']) if pd.notna(row['symbol']) else '', + 'price': float(row['price']) if pd.notna(row['price']) else 0.0, + 'market_value': float(row['market_value']) if pd.notna(row['market_value']) else 0.0 + }) + # 限制只返回前10个结果 + if len(formatted_results) >= 10: + break + + logger.info(f"美股搜索完成,找到 {len(formatted_results)} 个匹配项(限制显示前10个)") + return formatted_results + + except Exception as e: + error_msg = f"搜索美股代码失败: {str(e)}" + logger.error(error_msg) + logger.exception(e) + raise Exception(error_msg) + + def _get_us_stocks_data(self) -> pd.DataFrame: + """ + 获取美股数据(同步方法,将被异步方法调用) + + Returns: + 包含美股数据的DataFrame + """ + import akshare as ak + + try: + # 获取美股数据 + df = ak.stock_us_spot_em() + + # 转换列名 + df = df.rename(columns={ + "序号": "index", + "名称": "name", + "最新价": "price", + "涨跌额": "price_change", + "涨跌幅": "price_change_percent", + "开盘价": "open", + "最高价": "high", + "最低价": "low", + "昨收价": "pre_close", + "总市值": "market_value", + "市盈率": "pe_ratio", + "成交量": "volume", + "成交额": "turnover", + "振幅": "amplitude", + "换手率": "turnover_rate", + "代码": "symbol" + }) + + return df + + except Exception as e: + logger.error(f"获取美股数据失败: {str(e)}") + logger.exception(e) + raise Exception(f"获取美股数据失败: {str(e)}") + + async def get_us_stock_detail(self, symbol: str) -> Dict[str, Any]: + """ + 异步获取单个美股详细信息 + + Args: + symbol: 股票代码 + + Returns: + 股票详细信息 + """ + try: + logger.info(f"获取美股详情: {symbol}") + + # 使用线程池执行同步的akshare调用 + df = await asyncio.to_thread(self._get_us_stocks_data) + + # 精确匹配股票代码 + result = df[df['symbol'] == symbol] + + if len(result) == 0: + raise Exception(f"未找到股票代码: {symbol}") + + # 获取第一行数据 + row = result.iloc[0] + + # 格式化为字典 + stock_detail = { + 'name': row['name'] if pd.notna(row['name']) else '', + 'symbol': str(row['symbol']) if pd.notna(row['symbol']) else '', + 'price': float(row['price']) if pd.notna(row['price']) else 0.0, + 'price_change': float(row['price_change']) if pd.notna(row['price_change']) else 0.0, + 'price_change_percent': float(row['price_change_percent'].strip('%'))/100 if pd.notna(row['price_change_percent']) else 0.0, + 'open': float(row['open']) if pd.notna(row['open']) else 0.0, + 'high': float(row['high']) if pd.notna(row['high']) else 0.0, + 'low': float(row['low']) if pd.notna(row['low']) else 0.0, + 'pre_close': float(row['pre_close']) if pd.notna(row['pre_close']) else 0.0, + 'market_value': float(row['market_value']) if pd.notna(row['market_value']) else 0.0, + 'pe_ratio': float(row['pe_ratio']) if pd.notna(row['pe_ratio']) else 0.0, + 'volume': float(row['volume']) if pd.notna(row['volume']) else 0.0, + 'turnover': float(row['turnover']) if pd.notna(row['turnover']) else 0.0 + } + + logger.info(f"获取美股详情成功: {symbol}") + return stock_detail + + except Exception as e: + error_msg = f"获取美股详情失败: {str(e)}" + logger.error(error_msg) + logger.exception(e) + raise Exception(error_msg) \ No newline at end of file diff --git a/stock_analyzer.py b/stock_analyzer.py deleted file mode 100644 index 9e11788..0000000 --- a/stock_analyzer.py +++ /dev/null @@ -1,760 +0,0 @@ -import pandas as pd -import numpy as np -from datetime import datetime, timedelta -import os -import requests -from typing import Dict, List, Optional, Tuple, Generator -from dotenv import load_dotenv -import json -from logger import get_logger -from utils.api_utils import APIUtils - -# 获取日志器 -logger = get_logger() - -class StockAnalyzer: - def __init__(self, initial_cash=1000000, custom_api_url=None, custom_api_key=None, custom_api_model=None, custom_api_timeout=None): - - # 加载环境变量 - load_dotenv() - - # 设置 API 配置,优先使用自定义配置,否则使用环境变量 - self.API_URL = custom_api_url or os.getenv('API_URL') - self.API_KEY = custom_api_key or os.getenv('API_KEY') - self.API_MODEL = custom_api_model or os.getenv('API_MODEL', 'gpt-3.5-turbo') - self.API_TIMEOUT = int(custom_api_timeout or os.getenv('API_TIMEOUT', 60)) - - logger.debug(f"初始化StockAnalyzer: API_URL={self.API_URL}, API_MODEL={self.API_MODEL}, API_KEY={'已提供' if self.API_KEY else '未提供'}, API_TIMEOUT={self.API_TIMEOUT}") - - # 配置参数 - self.params = { - 'ma_periods': {'short': 5, 'medium': 20, 'long': 60}, - 'rsi_period': 14, - 'bollinger_period': 20, - 'bollinger_std': 2, - 'volume_ma_period': 20, - 'atr_period': 14 - } - - - def get_stock_data(self, stock_code, market_type='A', start_date=None, end_date=None): - """获取股票或基金数据""" - import akshare as ak - - if start_date is None: - start_date = (datetime.now() - timedelta(days=365)).strftime('%Y%m%d') - if end_date is None: - end_date = datetime.now().strftime('%Y%m%d') - - try: - # 验证股票代码格式 - if market_type == 'A': - # 上海证券交易所股票代码以6开头 - # 深圳证券交易所股票代码以0或3开头 - # 科创板股票代码以688开头 - # 北京证券交易所股票代码以8开头 - valid_prefixes = ['0', '3', '6', '688', '8'] - valid_format = False - - for prefix in valid_prefixes: - if stock_code.startswith(prefix): - valid_format = True - break - - if not valid_format: - error_msg = f"无效的A股股票代码格式: {stock_code}。A股代码应以0、3、6、688或8开头" - logger.error(f"[股票代码格式错误] {error_msg}") - raise ValueError(error_msg) - - df = ak.stock_zh_a_hist( - symbol=stock_code, - start_date=start_date, - end_date=end_date, - adjust="qfq" - ) - elif market_type == 'HK': - df = ak.stock_hk_daily( - symbol=stock_code, - adjust="qfq" - ) - elif market_type == 'US': - df = ak.stock_us_hist( - symbol=stock_code, - start_date=start_date, - end_date=end_date, - adjust="qfq" - ) - elif market_type == 'ETF': - df = ak.fund_etf_hist_em( - symbol=stock_code, - period="daily", - start_date=start_date, - end_date=end_date, - adjust="qfq" - ) - elif market_type == 'LOF': - df = ak.fund_lof_hist_em( - symbol=stock_code, - period="daily", - start_date=start_date, - end_date=end_date, - adjust="qfq" - ) - else: - raise ValueError(f"不支持的市场类型: {market_type}") - - # 重命名列名以匹配分析需求 - df = df.rename(columns={ - "日期": "date", - "开盘": "open", - "收盘": "close", - "最高": "high", - "最低": "low", - "成交量": "volume" - }) - - # 确保日期格式正确 - df['date'] = pd.to_datetime(df['date']) - - # 数据类型转换 - numeric_columns = ['open', 'close', 'high', 'low', 'volume'] - df[numeric_columns] = df[numeric_columns].apply(pd.to_numeric, errors='coerce') - - # 删除空值 - df = df.dropna() - - return df.sort_values('date') - - # except ValueError as ve: - # # 捕获格式验证错误 - # logger.error(f"[股票代码格式错误] {str(ve)}") - # raise Exception(f"股票代码格式错误: {str(ve)}") - except Exception as e: - logger.error(f"[获取数据失败] {str(e)}") - raise Exception(f"获取数据失败: {str(e)}") - - def calculate_ema(self, series, period): - """计算指数移动平均线""" - return series.ewm(span=period, adjust=False).mean() - - def calculate_rsi(self, series, period): - """计算RSI指标""" - delta = series.diff() - gain = (delta.where(delta > 0, 0)).rolling(window=period).mean() - loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean() - rs = gain / loss - return 100 - (100 / (1 + rs)) - - def calculate_macd(self, series): - """计算MACD指标""" - exp1 = series.ewm(span=12, adjust=False).mean() - exp2 = series.ewm(span=26, adjust=False).mean() - macd = exp1 - exp2 - signal = macd.ewm(span=9, adjust=False).mean() - hist = macd - signal - return macd, signal, hist - - def calculate_bollinger_bands(self, series, period, std_dev): - """计算布林带""" - middle = series.rolling(window=period).mean() - std = series.rolling(window=period).std() - upper = middle + (std * std_dev) - lower = middle - (std * std_dev) - return upper, middle, lower - - def calculate_atr(self, df, period): - """计算ATR指标""" - high = df['high'] - low = df['low'] - close = df['close'].shift(1) - - tr1 = high - low - tr2 = abs(high - close) - tr3 = abs(low - close) - - tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) - return tr.rolling(window=period).mean() - - def calculate_indicators(self, df): - """计算技术指标""" - try: - # 计算移动平均线 - df['MA5'] = self.calculate_ema(df['close'], self.params['ma_periods']['short']) - df['MA20'] = self.calculate_ema(df['close'], self.params['ma_periods']['medium']) - df['MA60'] = self.calculate_ema(df['close'], self.params['ma_periods']['long']) - - # 计算RSI - df['RSI'] = self.calculate_rsi(df['close'], self.params['rsi_period']) - - # 计算MACD - df['MACD'], df['Signal'], df['MACD_hist'] = self.calculate_macd(df['close']) - - # 计算布林带 - df['BB_upper'], df['BB_middle'], df['BB_lower'] = self.calculate_bollinger_bands( - df['close'], - self.params['bollinger_period'], - self.params['bollinger_std'] - ) - - # 成交量分析 - df['Volume_MA'] = df['volume'].rolling(window=self.params['volume_ma_period']).mean() - df['Volume_Ratio'] = df['volume'] / df['Volume_MA'] - - # 计算ATR和波动率 - df['ATR'] = self.calculate_atr(df, self.params['atr_period']) - df['Volatility'] = df['ATR'] / df['close'] * 100 - - # 动量指标 - df['ROC'] = df['close'].pct_change(periods=10) * 100 - - return df - - except Exception as e: - print(f"计算技术指标时出错: {str(e)}") - raise - - def calculate_score(self, df): - """计算评分""" - try: - score = 0 - latest = df.iloc[-1] - - # 趋势得分 (30分) - if latest['MA5'] > latest['MA20']: - score += 15 - if latest['MA20'] > latest['MA60']: - score += 15 - - # RSI得分 (20分) - if 30 <= latest['RSI'] <= 70: - score += 20 - elif latest['RSI'] < 30: # 超卖 - score += 15 - - # MACD得分 (20分) - if latest['MACD'] > latest['Signal']: - score += 20 - - # 成交量得分 (30分) - if latest['Volume_Ratio'] > 1.5: - score += 30 - elif latest['Volume_Ratio'] > 1: - score += 15 - - return score - - except Exception as e: - print(f"计算评分时出错: {str(e)}") - raise - - def get_ai_analysis(self, df, stock_code, market_type='A', stream=False): - """使用 OpenAI 进行 AI 分析""" - try: - logger.info(f"开始AI分析 {stock_code}, 流式模式: {stream}") - recent_data = df.tail(14).to_dict('records') - - technical_summary = { - 'trend': 'upward' if df.iloc[-1]['MA5'] > df.iloc[-1]['MA20'] else 'downward', - 'volatility': f"{df.iloc[-1]['Volatility']:.2f}%", - 'volume_trend': 'increasing' if df.iloc[-1]['Volume_Ratio'] > 1 else 'decreasing', - 'rsi_level': df.iloc[-1]['RSI'] - } - - # 根据市场类型调整分析提示 - if market_type in ['ETF', 'LOF']: - prompt = f""" - 分析基金 {stock_code}: - - 技术指标概要: - {technical_summary} - - 近14日交易数据: - {recent_data} - - 请提供: - 1. 净值走势分析(包含支撑位和压力位) - 2. 成交量分析及其对净值的影响 - 3. 风险评估(包含波动率和折溢价分析) - 4. 短期和中期净值预测 - 5. 关键价格位分析 - 6. 申购赎回建议(包含止损位) - - 请基于技术指标和市场表现进行分析,给出具体数据支持。 - """ - elif market_type == 'US': - prompt = f""" - 分析美股 {stock_code}: - - 技术指标概要: - {technical_summary} - - 近14日交易数据: - {recent_data} - - 请提供: - 1. 趋势分析(包含支撑位和压力位,美元计价) - 2. 成交量分析及其含义 - 3. 风险评估(包含波动率和美股市场特有风险) - 4. 短期和中期目标价位(美元) - 5. 关键技术位分析 - 6. 具体交易建议(包含止损位) - - 请基于技术指标和美股市场特点进行分析,给出具体数据支持。 - """ - elif market_type == 'HK': - prompt = f""" - 分析港股 {stock_code}: - - 技术指标概要: - {technical_summary} - - 近14日交易数据: - {recent_data} - - 请提供: - 1. 趋势分析(包含支撑位和压力位,港币计价) - 2. 成交量分析及其含义 - 3. 风险评估(包含波动率和港股市场特有风险) - 4. 短期和中期目标价位(港币) - 5. 关键技术位分析 - 6. 具体交易建议(包含止损位) - - 请基于技术指标和港股市场特点进行分析,给出具体数据支持。 - """ - else: # A股 - prompt = f""" - 分析A股 {stock_code}: - - 技术指标概要: - {technical_summary} - - 近14日交易数据: - {recent_data} - - 请提供: - 1. 趋势分析(包含支撑位和压力位) - 2. 成交量分析及其含义 - 3. 风险评估(包含波动率分析) - 4. 短期和中期目标价位 - 5. 关键技术位分析 - 6. 具体交易建议(包含止损位) - - 请基于技术指标和A股市场特点进行分析,给出具体数据支持。 - """ - - logger.debug(f"生成的AI分析提示词: {self._truncate_json_for_logging(prompt, 100)}...") - - # 检查API配置 - if not self.API_URL: - error_msg = "API URL未配置,无法进行AI分析" - logger.error(f"[API配置错误] {error_msg}") - return error_msg if not stream else (yield json.dumps({"error": error_msg})) - - if not self.API_KEY: - error_msg = "API Key未配置,无法进行AI分析" - logger.error(f"[API配置错误] {error_msg}") - return error_msg if not stream else (yield json.dumps({"error": error_msg})) - - # 标准化API URL - api_url = APIUtils.format_api_url(self.API_URL) - - logger.debug(f"标准化后的API URL: {api_url}") - - # 构建请求头和请求体 - headers = { - "Authorization": f"Bearer {self.API_KEY}", - "Content-Type": "application/json" - } - - payload = { - "model": self.API_MODEL, - "messages": [{"role": "user", "content": prompt}] - } - - # 流式处理设置 - if stream: - logger.debug(f"配置流式参数,使用API URL: {api_url}") - payload["stream"] = True # 明确设置stream参数为True - - try: - logger.debug(f"发起流式API请求: {api_url}") - logger.debug(f"请求载荷: {self._truncate_json_for_logging(payload)}") - - response = requests.post( - api_url, - headers=headers, - json=payload, - timeout=self.API_TIMEOUT, # 增加超时时间 - stream=True - ) - - logger.debug(f"API流式响应状态码: {response.status_code}") - - if response.status_code == 200: - logger.info(f"成功获取API流式响应,开始处理") - yield from self._process_ai_stream(response, stock_code) - else: - try: - error_response = response.json() - error_text = self._truncate_json_for_logging(error_response) - except: - error_text = response.text[:500] if response.text else "无响应内容" - - error_msg = f"API请求失败: 状态码 {response.status_code}, 响应: {error_text}" - logger.error(f"[API请求失败] {error_msg}") - yield json.dumps({"stock_code": stock_code, "error": error_msg}) - - except Exception as e: - error_msg = f"流式API请求异常: {str(e)}" - logger.error(f"[流式API异常] {error_msg}") - logger.exception(e) - yield json.dumps({"stock_code": stock_code, "error": error_msg}) - else: - # 非流式处理 - logger.debug(f"发起非流式API请求: {api_url}") - - try: - response = requests.post( - api_url, - headers=headers, - json=payload, - timeout=self.API_TIMEOUT - ) - - logger.debug(f"API非流式响应状态码: {response.status_code}") - - if response.status_code == 200: - api_response = response.json() - content = api_response['choices'][0]['message']['content'] - logger.info(f"成功获取AI分析结果,长度: {len(content)}") - logger.debug(f"AI分析结果前100字符: {content[:100]}...") - return content - else: - try: - error_response = response.json() - error_text = self._truncate_json_for_logging(error_response) - except: - error_text = response.text[:500] if response.text else "无响应内容" - - error_msg = f"API请求失败: 状态码 {response.status_code}, 响应: {error_text}" - logger.error(f"[API请求失败] {error_msg}") - return error_msg - - except Exception as e: - error_msg = f"非流式API请求异常: {str(e)}" - logger.error(f"[非流式API异常] {error_msg}") - logger.exception(e) - return error_msg - - except Exception as e: - error_msg = f"AI 分析过程中发生错误: {str(e)}" - logger.error(f"[AI分析异常] {error_msg}") - logger.exception(e) - - if stream: - logger.debug("在流式模式下返回异常信息") - error_json = json.dumps({"stock_code": stock_code, "error": error_msg}) - logger.info(f"流式异常输出: {error_json}") - yield error_json - else: - return error_msg - - def _truncate_json_for_logging(self, json_obj, max_length=500): - """截断JSON对象用于日志记录,避免日志过大 - - Args: - json_obj: 要截断的JSON对象 - max_length: 最大字符长度,默认500 - - Returns: - str: 截断后的JSON字符串 - """ - json_str = json.dumps(json_obj, ensure_ascii=False) - if len(json_str) <= max_length: - return json_str - return json_str[:max_length] + f"... [截断,总长度: {len(json_str)}字符]" - - def _process_ai_stream(self, response, stock_code) -> Generator[str, None, None]: - """处理AI流式响应""" - logger.info(f"开始处理 {stock_code} 的AI流式响应") - buffer = "" - chunk_count = 0 - - try: - for line in response.iter_lines(): - if line: - line = line.decode('utf-8') - - # 跳过保持连接的空行 - if line.strip() == '': - logger.debug("跳过空行") - continue - - # 数据行通常以"data: "开头 - if line.startswith('data: '): - data_content = line[6:] # 移除 "data: " 前缀 - - # 检查是否为流的结束 - if data_content.strip() == '[DONE]': - logger.debug("收到流结束标记 [DONE]") - break - - try: - json_data = json.loads(data_content) - - # 检查 choices 列表是否为空 - if 'choices' in json_data and json_data['choices']: - delta = json_data['choices'][0].get('delta', {}) - content = delta.get('content', '') - - if content: - chunk_count += 1 - buffer += content - - # 创建包含AI分析片段的JSON - chunk_json = json.dumps({ - "stock_code": stock_code, - "ai_analysis_chunk": content - }) - yield chunk_json - else: - logger.warning(f"收到空的 choices 列表: {data_content}") - except json.JSONDecodeError as e: - logger.error(f"[JSON解析错误] {str(e)}, 行内容: {data_content}") - # 忽略无法解析的JSON - pass - else: - logger.warning(f"收到非'data:'开头的行: {line}") - - logger.info(f"AI流式处理完成,共收到 {chunk_count} 个内容片段,总长度: {len(buffer)}") - - # 如果buffer不为空,最后一次发送完整内容 - if buffer and not buffer.endswith('\n'): - logger.debug("发送换行符") - yield json.dumps({"stock_code": stock_code, "ai_analysis_chunk": "\n"}) - - except Exception as e: - error_msg = f"处理AI流式响应时出错: {str(e)}" - logger.error(f"[流式响应异常] {error_msg}") - logger.exception(e) - yield json.dumps({"stock_code": stock_code, "error": error_msg}) - - - def get_recommendation(self, score): - """根据得分给出建议""" - logger.debug(f"根据评分 {score} 生成投资建议") - if score >= 80: - return '强烈推荐买入' - elif score >= 60: - return '建议买入' - elif score >= 40: - return '观望' - elif score >= 20: - return '建议卖出' - else: - return '强烈建议卖出' - - def analyze_stock(self, stock_code, market_type='A', stream=False): - """分析单只""" - logger.info(f"开始分析 {stock_code}, 市场类型: {market_type}, 流式模式: {stream}") - - try: - # 获取股票数据 - try: - df = self.get_stock_data(stock_code, market_type) - except Exception as e: - # 捕获股票数据获取异常 - error_msg = str(e) - logger.error(f"[数据获取异常] {error_msg}") - - # 格式化错误响应 - error_response = { - 'stock_code': stock_code, - 'error': error_msg, - 'status': 'error', - 'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S') - } - - if stream: - return (yield json.dumps(error_response)) - else: - return error_response - - # 检查数据是否为空 - if df.empty: - error_msg = f" {stock_code} 数据为空" - logger.error(f"[空数据] {error_msg}") - - # 格式化错误响应 - error_response = { - 'stock_code': stock_code, - 'error': error_msg, - 'status': 'error', - 'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S') - } - - if stream: - return (yield json.dumps(error_response)) - else: - return error_response - - # 计算技术指标 - logger.debug(f"计算 {stock_code} 技术指标") - df = self.calculate_indicators(df) - - # 评分系统 - logger.debug(f"计算 {stock_code} 评分") - score = self.calculate_score(df) - logger.info(f"{stock_code} 评分结果: {score}") - - # 获取最新数据 - latest = df.iloc[-1] - prev = df.iloc[-2] - - # 生成报告 - report = { - 'stock_code': stock_code, - 'market_type': market_type, # 添加市场类型 - 'analysis_date': datetime.now().strftime('%Y-%m-%d'), - 'score': score, - 'price': latest['close'], - 'price_change': (latest['close'] - prev['close']) / prev['close'] * 100, - 'ma_trend': 'UP' if latest['MA5'] > latest['MA20'] else 'DOWN', - 'rsi': latest['RSI'] if not pd.isna(latest['RSI']) else None, - 'macd_signal': 'BUY' if latest['MACD'] > latest['Signal'] else 'SELL', - 'volume_status': 'HIGH' if latest['Volume_Ratio'] > 1.5 else 'NORMAL', - 'recommendation': self.get_recommendation(score) - } - logger.debug(f"生成 {stock_code} 基础报告: {self._truncate_json_for_logging(report, 100)}...") - - if stream: - logger.info(f"以流式模式返回 {stock_code} 分析结果") - # 先返回基本报告结构 - base_report = dict(report) - base_report['ai_analysis'] = '' - base_report_json = json.dumps(base_report) - logger.debug(f"基础报告JSON: {self._truncate_json_for_logging(base_report_json, 100)}...") - logger.info(f"发送基础报告: {base_report_json}") - yield base_report_json - - # 然后流式返回AI分析部分 - logger.debug(f"开始获取 {stock_code} 的流式AI分析") - ai_chunks_count = 0 - for ai_chunk in self.get_ai_analysis(df, stock_code, market_type, stream=True): - ai_chunks_count += 1 - yield ai_chunk - logger.info(f" {stock_code} 流式AI分析完成,共发送 {ai_chunks_count} 个块") - else: - logger.info(f"以非流式模式返回 {stock_code} 分析结果") - logger.debug(f"开始获取 {stock_code} 的AI分析") - report['ai_analysis'] = self.get_ai_analysis(df, stock_code, market_type) - logger.debug(f"AI分析结果长度: {len(report['ai_analysis'])}") - return report - - except Exception as e: - error_msg = f"分析 {stock_code} 时出错: {str(e)}\n" - logger.error(f"[分析异常] {error_msg}") - logger.exception(e) - - # 格式化错误响应 - error_response = { - 'stock_code': stock_code, - 'error': error_msg, - 'status': 'error', - 'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S') - } - - if stream: - return (yield json.dumps(error_response)) - else: - return error_response - - def scan_stocks(self, stock_codes, market_type='A', min_score=60, stream=False): - """扫描多只""" - logger.info(f"开始扫描 {len(stock_codes)} 只, 市场类型: {market_type}, 最低评分: {min_score}, 流式模式: {stream}") - - if not stream: - # 非流式模式 - recommended_stocks = [] - stock_count = 0 - error_count = 0 - - for stock_code in stock_codes: - stock_count += 1 - logger.info(f"扫描进度: {stock_count}/{len(stock_codes)}, 当前: {stock_code}") - - try: - logger.debug(f"分析: {stock_code}") - report = self.analyze_stock(stock_code, market_type) - - # 检查是否有错误 - if isinstance(report, dict) and 'error' in report: - error_count += 1 - logger.warning(f"[扫描错误] {stock_code}: {report['error']}") - continue - - # 检查评分是否达到最低要求 - if report['score'] >= min_score: - logger.info(f" {stock_code} 评分 {report['score']} >= {min_score},添加到推荐列表") - recommended_stocks.append(report) - else: - logger.debug(f" {stock_code} 评分 {report['score']} < {min_score},不添加到推荐列表") - except Exception as e: - error_count += 1 - error_msg = f"分析 {stock_code} 时出错: {str(e)}" - logger.error(f"[扫描异常] {error_msg}") - logger.exception(e) - - # 添加错误信息到推荐列表,确保前端能看到错误 - error_response = { - 'stock_code': stock_code, - 'error': error_msg, - 'status': 'error', - 'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S') - } - recommended_stocks.append(error_response) - continue - - logger.info(f"扫描完成,共 {stock_count} 只,{error_count} 只出错,{len(recommended_stocks)} 只推荐") - return recommended_stocks - else: - # 流式模式 - stock_count = 0 - error_count = 0 - - for stock_code in stock_codes: - stock_count += 1 - logger.info(f"流式扫描进度: {stock_count}/{len(stock_codes)}, 当前: {stock_code}") - - try: - chunk_count = 0 - for chunk in self.analyze_stock(stock_code, market_type, stream=True): - chunk_count += 1 - # 检查是否有错误信息 - try: - chunk_data = json.loads(chunk) - if 'error' in chunk_data: - error_count += 1 - logger.warning(f"[流式扫描错误] {stock_code}: {chunk_data['error']}") - except: - pass - yield chunk - logger.debug(f" {stock_code} 流式分析完成,共 {chunk_count} 个块") - except Exception as e: - error_count += 1 - error_msg = f"分析 {stock_code} 时出错: {str(e)}" - logger.error(f"[流式扫描异常] {error_msg}") - logger.exception(e) - - # 格式化错误响应 - error_response = { - 'stock_code': stock_code, - 'error': error_msg, - 'status': 'error', - 'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S') - } - error_json = json.dumps(error_response) - logger.info(f"流式错误输出: {error_json}") - yield error_json - - logger.info(f"流式扫描完成,共处理 {stock_count} ,{error_count} 只出错") diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index 0f159c7..0000000 --- a/templates/index.html +++ /dev/null @@ -1,1287 +0,0 @@ - - - - - - 股票分析系统 - - - - - - {% if announcement %} -
-
-
-
- - - -
-
-

-

-
- -
-
-
- - - {% endif %} - -
-

股票分析系统

- - -
-
-
- -
-

当前时间

-

-
- - -
-

A股市场

-

-

-
- - -
-

港股市场

-

-

-
- - -
-

美股市场

-

-

-
-
-
-
- - - -
- -
-

股票批量分析

- - -
-
-

API配置

- -
- - -
- - -
- - -
- - - -
- - - -
- - -
- - - - -
- - -
-
-

分析结果

- -
-
-
- - - - - - - - - - - - - \ No newline at end of file diff --git a/us_stock_service.py b/us_stock_service.py deleted file mode 100644 index 852d565..0000000 --- a/us_stock_service.py +++ /dev/null @@ -1,53 +0,0 @@ -import akshare as ak -import pandas as pd - -class USStockService: - - def search_us_stocks(self, keyword): - """ - 搜索美股代码 - :param keyword: 搜索关键词 - :return: 匹配的股票列表 - """ - try: - # 获取美股数据 - df = ak.stock_us_spot_em() - - # 转换列名 - df = df.rename(columns={ - "序号": "index", - "名称": "name", - "最新价": "price", - "涨跌额": "price_change", - "涨跌幅": "price_change_percent", - "开盘价": "open", - "最高价": "high", - "最低价": "low", - "昨收价": "pre_close", - "总市值": "market_value", - "市盈率": "pe_ratio", - "成交量": "volume", - "成交额": "turnover", - "振幅": "amplitude", - "换手率": "turnover_rate", - "代码": "symbol" - }) - - # 模糊匹配搜索 - mask = df['name'].str.contains(keyword, case=False, na=False) - results = df[mask] - - # 格式化返回结果并处理 NaN 值 - formatted_results = [] - for _, row in results.iterrows(): - formatted_results.append({ - 'name': row['name'] if pd.notna(row['name']) else '', - 'symbol': str(row['symbol']) if pd.notna(row['symbol']) else '', - 'price': float(row['price']) if pd.notna(row['price']) else 0.0, - 'market_value': float(row['market_value']) if pd.notna(row['market_value']) else 0.0 - }) - - return formatted_results - - except Exception as e: - raise Exception(f"搜索美股代码失败: {str(e)}") \ No newline at end of file diff --git a/web_server.py b/web_server.py index 2e13be7..4682dae 100644 --- a/web_server.py +++ b/web_server.py @@ -1,60 +1,208 @@ -from flask import Flask, render_template, request, jsonify, Response, stream_with_context -from stock_analyzer import StockAnalyzer -from us_stock_service import USStockService -from fund_service import FundService # 新增导入 -import threading +from fastapi import FastAPI, Request, Response, Depends, HTTPException, BackgroundTasks +from fastapi.responses import JSONResponse, StreamingResponse, FileResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.middleware.cors import CORSMiddleware +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from pydantic import BaseModel, Field +from typing import List, Optional, Dict, Any, Generator +from services.stock_analyzer_service import StockAnalyzerService +from services.us_stock_service_async import USStockServiceAsync +from services.fund_service_async import FundServiceAsync import os -import traceback -import requests +import httpx from logger import get_logger from utils.api_utils import APIUtils -# 加载环境变量 -from dotenv import load_dotenv +from dotenv import load_dotenv, dotenv_values +import uvicorn +import json +import secrets +from datetime import datetime, timedelta +from jose import JWTError, jwt load_dotenv() # 获取日志器 logger = get_logger() -app = Flask(__name__) -analyzer = StockAnalyzer() -us_stock_service = USStockService() -fund_service = FundService() # 新增服务实例 +# JWT相关配置 +SECRET_KEY = os.getenv("JWT_SECRET_KEY", secrets.token_hex(32)) +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 10080 # Token过期时间一周 -@app.route('/') -def index(): - announcement = os.getenv('ANNOUNCEMENT_TEXT') or None - # 获取默认API配置信息 - default_api_url = os.getenv('API_URL', '') - default_api_model = os.getenv('API_MODEL', 'gpt-3.5-turbo') - default_api_timeout = os.getenv('API_TIMEOUT', '60') - # 不传递API_KEY到前端,出于安全考虑 - return render_template('index.html', - announcement=announcement, - default_api_url=default_api_url, - default_api_model=default_api_model, - default_api_timeout=default_api_timeout) +LOGIN_PASSWORD = os.getenv("LOGIN_PASSWORD", "") +print(LOGIN_PASSWORD) -@app.route('/analyze', methods=['POST']) -def analyze(): +# 是否需要登录 +REQUIRE_LOGIN = bool(LOGIN_PASSWORD.strip()) + + +app = FastAPI( + title="Stock Scanner API", + description="异步股票分析API", + version="1.0.0" +) + +# 添加CORS中间件 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # 开发环境允许所有来源,生产环境应该限制 + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 设置静态文件 +frontend_dist = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'frontend', 'dist') +if os.path.exists(frontend_dist): + app.mount("/assets", StaticFiles(directory=os.path.join(frontend_dist, "assets")), name="assets") + +# 初始化异步服务 +# StockAnalyzerService 不需要全局初始化,在 /analyze 接口中按需创建 +us_stock_service = USStockServiceAsync() +fund_service = FundServiceAsync() + +# 定义请求和响应模型 +class AnalyzeRequest(BaseModel): + stock_codes: List[str] + market_type: str = "A" + api_url: Optional[str] = None + api_key: Optional[str] = None + api_model: Optional[str] = None + api_timeout: Optional[str] = None + +class TestAPIRequest(BaseModel): + api_url: str + api_key: str + api_model: Optional[str] = None + api_timeout: Optional[int] = 10 + +class LoginRequest(BaseModel): + password: str + +class Token(BaseModel): + access_token: str + token_type: str + +# 自定义依赖项,在REQUIRE_LOGIN=False时不要求token +class OptionalOAuth2PasswordBearer(OAuth2PasswordBearer): + async def __call__(self, request: Request) -> Optional[str]: + if not REQUIRE_LOGIN: + return None + try: + return await super().__call__(request) + except HTTPException: + if not REQUIRE_LOGIN: + return None + raise + +# 使用自定义的依赖项 +optional_oauth2_scheme = OptionalOAuth2PasswordBearer(tokenUrl="login") + +# 创建访问令牌 +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +# 验证令牌 +async def verify_token(token: Optional[str] = Depends(optional_oauth2_scheme)): + # 如果未设置密码,则不需要验证 + if not REQUIRE_LOGIN: + return "guest" + + # 如果没有token且不需要登录,返回guest + if token is None and not REQUIRE_LOGIN: + return "guest" + + credentials_exception = HTTPException( + status_code=401, + detail="无效的认证凭据", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # 如果需要登录但没有token,抛出异常 + if token is None: + raise credentials_exception + + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + return username + except JWTError: + raise credentials_exception + +# 用户登录接口 +@app.post("/login") +async def login(request: LoginRequest): + """用户登录接口""" + # 如果未设置密码,表示不需要登录 + if not REQUIRE_LOGIN: + access_token = create_access_token(data={"sub": "guest"}) + return {"access_token": access_token, "token_type": "bearer"} + + if request.password != LOGIN_PASSWORD: + logger.warning("登录失败:密码错误") + raise HTTPException(status_code=401, detail="密码错误") + + # 创建访问令牌 + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": "user"}, expires_delta=access_token_expires + ) + logger.info("用户登录成功") + return {"access_token": access_token, "token_type": "bearer"} + +# 检查用户认证状态 +@app.get("/check_auth") +async def check_auth(username: str = Depends(verify_token)): + """检查用户认证状态""" + return {"authenticated": True, "username": username} + +# 获取系统配置 +@app.get("/config") +async def get_config(): + """返回系统配置信息""" + config = { + 'announcement': os.getenv('ANNOUNCEMENT_TEXT') or '', + 'default_api_url': os.getenv('API_URL', ''), + 'default_api_model': os.getenv('API_MODEL', ''), + 'default_api_timeout': os.getenv('API_TIMEOUT', '60') + } + return config + +# AI分析股票 +@app.post("/analyze") +async def analyze(request: AnalyzeRequest, username: str = Depends(verify_token)): try: logger.info("开始处理分析请求") - data = request.json - stock_codes = data.get('stock_codes', []) - market_type = data.get('market_type', 'A') + stock_codes = request.stock_codes + market_type = request.market_type + + # 后端再次去重,确保安全 + original_count = len(stock_codes) + stock_codes = list(dict.fromkeys(stock_codes)) # 保持原有顺序的去重方法 + if len(stock_codes) < original_count: + logger.info(f"后端去重: 从{original_count}个代码中移除了{original_count - len(stock_codes)}个重复项") logger.debug(f"接收到分析请求: stock_codes={stock_codes}, market_type={market_type}") # 获取自定义API配置 - custom_api_url = data.get('api_url') - custom_api_key = data.get('api_key') - custom_api_model = data.get('api_model') - custom_api_timeout = data.get('api_timeout') + custom_api_url = request.api_url + custom_api_key = request.api_key + custom_api_model = request.api_model + custom_api_timeout = request.api_timeout logger.debug(f"自定义API配置: URL={custom_api_url}, 模型={custom_api_model}, API Key={'已提供' if custom_api_key else '未提供'}, Timeout={custom_api_timeout}") # 创建新的分析器实例,使用自定义配置 - custom_analyzer = StockAnalyzer( + custom_analyzer = StockAnalyzerService( custom_api_url=custom_api_url, custom_api_key=custom_api_key, custom_api_model=custom_api_model, @@ -63,34 +211,41 @@ def analyze(): if not stock_codes: logger.warning("未提供股票代码") - return jsonify({'error': '请输入代码'}), 400 + raise HTTPException(status_code=400, detail="请输入代码") - # 使用流式响应 - def generate(): + # 定义流式生成器 + async def generate_stream(): if len(stock_codes) == 1: # 单个股票分析流式处理 stock_code = stock_codes[0].strip() logger.info(f"开始单股流式分析: {stock_code}") - init_message = f'{{"stream_type": "single", "stock_code": "{stock_code}"}}\n' + stock_code_json = json.dumps(stock_code) + init_message = f'{{"stream_type": "single", "stock_code": {stock_code_json}}}\n' yield init_message logger.debug(f"开始处理股票 {stock_code} 的流式响应") chunk_count = 0 - for chunk in custom_analyzer.analyze_stock(stock_code, market_type, stream=True): + + # 使用异步生成器 + async for chunk in custom_analyzer.analyze_stock(stock_code, market_type, stream=True): chunk_count += 1 yield chunk + '\n' + logger.info(f"股票 {stock_code} 流式分析完成,共发送 {chunk_count} 个块") else: # 批量分析流式处理 logger.info(f"开始批量流式分析: {stock_codes}") - init_message = f'{{"stream_type": "batch", "stock_codes": {stock_codes}}}\n' + stock_codes_json = json.dumps(stock_codes) + init_message = f'{{"stream_type": "batch", "stock_codes": {stock_codes_json}}}\n' yield init_message logger.debug(f"开始处理批量股票的流式响应") chunk_count = 0 - for chunk in custom_analyzer.scan_stocks( + + # 使用异步生成器 + async for chunk in custom_analyzer.scan_stocks( [code.strip() for code in stock_codes], min_score=0, market_type=market_type, @@ -98,106 +253,175 @@ def analyze(): ): chunk_count += 1 yield chunk + '\n' + logger.info(f"批量流式分析完成,共发送 {chunk_count} 个块") logger.info("成功创建流式响应生成器") - return Response(stream_with_context(generate()), mimetype='application/json') + return StreamingResponse(generate_stream(), media_type='application/json') except Exception as e: error_msg = f"分析时出错: {str(e)}" logger.error(error_msg) logger.exception(e) - return jsonify({'error': error_msg}), 500 + raise HTTPException(status_code=500, detail=error_msg) -@app.route('/search_us_stocks', methods=['GET']) -def search_us_stocks(): +# 搜索美股代码 +@app.get("/search_us_stocks") +async def search_us_stocks(keyword: str = "", username: str = Depends(verify_token)): try: - keyword = request.args.get('keyword', '') if not keyword: - return jsonify({'error': '请输入搜索关键词'}), 400 - - results = us_stock_service.search_us_stocks(keyword) - return jsonify({'results': results}) + raise HTTPException(status_code=400, detail="请输入搜索关键词") + + # 直接使用异步服务的异步方法 + results = await us_stock_service.search_us_stocks(keyword) + return {"results": results} except Exception as e: - print(f"搜索美股代码时出错: {str(e)}") - return jsonify({'error': str(e)}), 500 + logger.error(f"搜索美股代码时出错: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) -# 添加基金搜索路由 -@app.route('/search_funds', methods=['GET']) -def search_funds(): +# 搜索基金代码 +@app.get("/search_funds") +async def search_funds(keyword: str = "", market_type: str = "", username: str = Depends(verify_token)): try: - keyword = request.args.get('keyword', '') - market_type = request.args.get('market_type', '') if not keyword: - return jsonify({'error': '请输入搜索关键词'}), 400 - - results = fund_service.search_funds(keyword, market_type) - return jsonify({'results': results}) + raise HTTPException(status_code=400, detail="请输入搜索关键词") + + # 直接使用异步服务的异步方法 + results = await fund_service.search_funds(keyword, market_type) + return {"results": results} except Exception as e: logger.error(f"搜索基金代码时出错: {str(e)}") - return jsonify({'error': str(e)}), 500 + raise HTTPException(status_code=500, detail=str(e)) -@app.route('/test_api_connection', methods=['POST']) -def test_api_connection(): +# 获取美股详情 +@app.get("/us_stock_detail/{symbol}") +async def get_us_stock_detail(symbol: str, username: str = Depends(verify_token)): + try: + if not symbol: + raise HTTPException(status_code=400, detail="请提供股票代码") + + # 使用异步服务获取详情 + detail = await us_stock_service.get_us_stock_detail(symbol) + return detail + + except Exception as e: + logger.error(f"获取美股详情时出错: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + +# 获取基金详情 +@app.get("/fund_detail/{symbol}") +async def get_fund_detail(symbol: str, market_type: str = "ETF", username: str = Depends(verify_token)): + try: + if not symbol: + raise HTTPException(status_code=400, detail="请提供基金代码") + + # 使用异步服务获取详情 + detail = await fund_service.get_fund_detail(symbol, market_type) + return detail + + except Exception as e: + logger.error(f"获取基金详情时出错: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + +# 测试API连接 +@app.post("/test_api_connection") +async def test_api_connection(request: TestAPIRequest, username: str = Depends(verify_token)): """测试API连接""" try: logger.info("开始测试API连接") - data = request.json - api_url = data.get('api_url') - api_key = data.get('api_key') - api_model = data.get('api_model') - api_timeout = data.get('api_timeout', 10) # 默认测试连接超时为10秒 + api_url = request.api_url + api_key = request.api_key + api_model = request.api_model + api_timeout = request.api_timeout logger.debug(f"测试API连接: URL={api_url}, 模型={api_model}, API Key={'已提供' if api_key else '未提供'}, Timeout={api_timeout}") if not api_url: logger.warning("未提供API URL") - return jsonify({'error': '请提供API URL'}), 400 + raise HTTPException(status_code=400, detail="请提供API URL") if not api_key: logger.warning("未提供API Key") - return jsonify({'error': '请提供API Key'}), 400 + raise HTTPException(status_code=400, detail="请提供API Key") # 构建API URL test_url = APIUtils.format_api_url(api_url) logger.debug(f"完整API测试URL: {test_url}") - # 发送测试请求 - response = requests.post( - test_url, - headers={ - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json" - }, - json={ - "model": api_model or "gpt-3.5-turbo", - "messages": [ - {"role": "user", "content": "Hello, this is a test message. Please respond with 'API connection successful'."} - ], - "max_tokens": 20 - }, - timeout=int(api_timeout) - ) + # 使用异步HTTP客户端发送测试请求 + async with httpx.AsyncClient(timeout=float(api_timeout)) as client: + response = await client.post( + test_url, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + }, + json={ + "model": api_model or "", + "messages": [ + {"role": "user", "content": "Hello, this is a test message. Please respond with 'API connection successful'."} + ], + "max_tokens": 20 + } + ) # 检查响应 if response.status_code == 200: logger.info(f"API 连接测试成功: {response.status_code}") - return jsonify({'success': True, 'message': 'API 连接测试成功'}) + return {"success": True, "message": "API 连接测试成功"} else: - error_message = response.json().get('error', {}).get('message', '未知错误') + error_data = response.json() + error_message = error_data.get('error', {}).get('message', '未知错误') logger.warning(f"API连接测试失败: {response.status_code} - {error_message}") - return jsonify({'success': False, 'message': f'API 连接测试失败: {error_message}', 'status_code': response.status_code}), 400 + return JSONResponse( + status_code=400, + content={"success": False, "message": f"API 连接测试失败: {error_message}", "status_code": response.status_code} + ) - except requests.exceptions.RequestException as e: + except httpx.RequestError as e: logger.error(f"API 连接请求错误: {str(e)}") - return jsonify({'success': False, 'message': f'请求错误: {str(e)}'}), 400 + return JSONResponse( + status_code=400, + content={"success": False, "message": f"请求错误: {str(e)}"} + ) except Exception as e: logger.error(f"测试 API 连接时出错: {str(e)}") logger.exception(e) - return jsonify({'success': False, 'message': f'API 测试连接时出错: {str(e)}'}), 500 + return JSONResponse( + status_code=500, + content={"success": False, "message": f"API 测试连接时出错: {str(e)}"} + ) + +# 检查是否需要登录 +@app.get("/need_login") +async def need_login(): + """检查是否需要登录""" + return {"require_login": REQUIRE_LOGIN} + +# 前端路由处理,必须放在所有API路由之后 +@app.get("/{full_path:path}") +async def serve_frontend(full_path: str, request: Request): + """处理所有前端路由请求,返回index.html""" + # 排除API路径和静态资源 + if full_path.startswith(("api/", "assets/", "docs", "openapi.json")) or \ + full_path in ["check_auth", "config", "analyze", + "search_us_stocks", "search_funds", + "test_api_connection", "us_stock_detail", + "fund_detail"]: + # 对于API路径,让FastAPI继续处理 + raise HTTPException(status_code=404, detail="API路径不存在") + + # 检查是否使用前端构建版本 + if os.path.exists(frontend_dist): + index_file = os.path.join(frontend_dist, 'index.html') + return FileResponse(index_file) + else: + # 不再使用模板渲染,而是重定向到API文档页面 + logger.warning("前端构建目录不存在,重定向到API文档页面") + return RedirectResponse(url="/docs") if __name__ == '__main__': - logger.info("股票分析系统启动") - app.run(host='0.0.0.0', port=8888, debug=True) \ No newline at end of file + logger.info("股票AI分析系统启动") + uvicorn.run("web_server:app", host="0.0.0.0", port=8888, reload=True) \ No newline at end of file