[
  {
    "path": ".dockerignore",
    "content": "__pycache__\n*.pyc\n.env\n.venv\nvenv\n.idea\n.claude\n.serena\n.pytest_cache\nlogs/\njsonl/\nweb-ui/node_modules\nweb-ui/dist\ndist/\n.git\n.DS_Store\ntask_images/\nimages/\narchive/\ntests/\ndata/\nprice_history/\n*.md\n!README.md\n"
  },
  {
    "path": ".github/workflows/claude.yml",
    "content": "name: Claude Code\n\non:\n  issue_comment:\n    types: [created]\n  pull_request_review_comment:\n    types: [created]\n  issues:\n    types: [opened, assigned]\n  pull_request_review:\n    types: [submitted]\n\njobs:\n  claude:\n    if: |\n      (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||\n      (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||\n      (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||\n      (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      pull-requests: read\n      issues: read\n      id-token: write\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 1\n\n      - name: Prepare Environment\n        run: |\n          curl -fsSL https://bun.sh/install | bash\n          mkdir -p $HOME/.claude-code-router\n          cat << 'EOF' > $HOME/.claude-code-router/config.json\n          {\n            \"log\": true,\n            \"NON_INTERACTIVE_MODE\": true,\n            \"OPENAI_API_KEY\": \"${{ secrets.OPENAI_API_KEY }}\",\n            \"OPENAI_BASE_URL\": \"https://api-inference.modelscope.cn/v1\",\n            \"OPENAI_MODEL\": \"MiniMax/MiniMax-M2.5\"\n          }\n          EOF\n        shell: bash\n\n      - name: Start Claude Code Router\n        run: |\n          nohup ~/.bun/bin/bunx @musistudio/claude-code-router@1.0.8 start &\n        shell: bash\n\n      - name: Run Claude Code\n        id: claude\n        uses: anthropics/claude-code-action@beta\n        env:\n          ANTHROPIC_BASE_URL: http://localhost:3456\n        with:\n          anthropic_api_key: \"Any-string-is-ok\"\n"
  },
  {
    "path": ".github/workflows/docker-image.yml",
    "content": "name: Docker Image CI on merge to master\n\non:\n  workflow_dispatch:\n  pull_request:\n    types: [closed]\n    branches: [\"master\"]\n\nenv:\n  IMAGE_NAME: ai-goofish\n  BASE_IMAGE_NAME: ai-goofish-base\n\njobs:\n  build-base:\n    if: |\n      github.event_name == 'workflow_dispatch' ||\n      (github.event_name == 'pull_request' && github.event.pull_request.merged == true)\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n    outputs:\n      base_image: ${{ steps.prepare.outputs.base_image }}\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Prepare image tags\n        id: prepare\n        env:\n          REPO_OWNER: ${{ github.repository_owner }}\n          BASE_IMAGE_NAME: ${{ env.BASE_IMAGE_NAME }}\n        run: |\n          set -euo pipefail\n\n          owner_lower=$(echo \"$REPO_OWNER\" | tr '[:upper:]' '[:lower:]')\n          base_image=\"ghcr.io/${owner_lower}/${BASE_IMAGE_NAME}:latest\"\n\n          base_tags=(\n            \"${base_image}\"\n            \"ghcr.io/${owner_lower}/${BASE_IMAGE_NAME}:${GITHUB_SHA}\"\n          )\n\n          {\n            echo \"base_image=${base_image}\"\n            echo 'base_tags<<EOF'\n            printf '%s\\n' \"${base_tags[@]}\"\n            echo EOF\n          } >> \"$GITHUB_OUTPUT\"\n\n      - name: Log in to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Build and push base Docker image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./Dockerfile.base\n          platforms: linux/amd64,linux/arm64\n          pull: true\n          push: true\n          tags: ${{ steps.prepare.outputs.base_tags }}\n          cache-from: type=gha,scope=ai-goofish-base-docker\n          cache-to: type=gha,scope=ai-goofish-base-docker,mode=max\n\n  build-and-push:\n    needs: build-base\n    if: ${{ needs.build-base.result == 'success' }}\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Prepare release tags\n        id: prepare\n        env:\n          REPO_OWNER: ${{ github.repository_owner }}\n          IMAGE_NAME: ${{ env.IMAGE_NAME }}\n        run: |\n          set -euo pipefail\n\n          owner_lower=$(echo \"$REPO_OWNER\" | tr '[:upper:]' '[:lower:]')\n\n          app_tags=(\n            \"ghcr.io/${owner_lower}/${IMAGE_NAME}:latest\"\n            \"ghcr.io/${owner_lower}/${IMAGE_NAME}:${GITHUB_SHA}\"\n          )\n\n          {\n            echo 'app_tags<<EOF'\n            printf '%s\\n' \"${app_tags[@]}\"\n            echo EOF\n          } >> \"$GITHUB_OUTPUT\"\n\n      - name: Log in to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Build and push multi-arch Docker images\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./Dockerfile.release\n          platforms: linux/amd64,linux/arm64\n          pull: true\n          push: true\n          build-args: |\n            BASE_IMAGE=${{ needs.build-base.outputs.base_image }}\n          tags: ${{ steps.prepare.outputs.app_tags }}\n          cache-from: type=gha,scope=ai-goofish-release-docker\n          cache-to: type=gha,scope=ai-goofish-release-docker,mode=max\n"
  },
  {
    "path": ".gitignore",
    "content": ".idea\n*.iml\nxianyu_state.json\n.env\n.aider*\nimages/\nlogs/\njsonl/\n__pycache__/\nsrc/__pycache__/\ndist/\nstate/\nconfig.json\nprompts/\n.serena/\nprice_history/\ndata/\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# Repository Guidelines\n\n## 项目结构与模块组织\n- 后端位于 `src/`，入口 `src/app.py`，API 路由在 `src/api/routes/`，服务层在 `src/services/`，领域模型在 `src/domain/`，基础设施在 `src/infrastructure/`。\n- 前端在 `web-ui/`（Vue 3 + Vite），视图放于 `web-ui/src/views/`，组件在 `web-ui/src/components/`，构建产物会复制到根目录 `dist/`。\n- 测试位于 `tests/`，命名遵循 `test_*.py` 或 `tests/*/test_*.py`。\n- 运行数据与资源：`prompts/`、`jsonl/`、`logs/`、`images/`、`static/`、`state/`，配置文件 `config.json` 与 `.env` 位于仓库根目录。\n\n## 构建、测试与本地开发\n- 后端开发：`python -m src.app` 或 `uvicorn src.app:app --host 0.0.0.0 --port 8000 --reload`。\n- 爬虫任务：`python spider_v2.py --task-name \"MacBook Air M1\" --debug-limit 3`（可用 `--config` 指定自定义配置）。\n- 前端开发：`cd web-ui && npm install && npm run dev`；构建：`cd web-ui && npm run build`（产物复制到根目录 `dist/`）。\n- 一键本地启动：`bash start.sh`（自动安装依赖、前端构建并启动后端）。\n- Docker：`docker compose up --build -d`，查看日志 `docker compose logs -f app`，停止 `docker compose down`。\n\n## 编码风格与命名约定\n- 保持分层：API → services → domain → infrastructure，避免跨层耦合，模块保持精简。\n- Python 测试函数命名为 `test_*`，文件与路径遵循上述测试目录规范。\n- 使用描述性、任务导向的命名（如爬虫任务名、配置键），与业务含义对应。\n\n## 架构与运行时\n- 后端使用 FastAPI 提供 API 与静态资源，爬虫与 AI 推理在独立任务进程中协作，前后端通过 HTTP/Web UI 交互。\n- 任务运行会在 `jsonl/` 写入结果、在 `logs/` 留存运行日志、在 `images/` 下载图片，前端监控页面依赖这些数据。\n- 默认监听 8000 端口，前端构建后静态文件可由后端或 Docker 镜像直接提供。\n\n## 测试指南\n- 测试框架：`pytest`（默认同步测试，无需 `pytest-asyncio`）。\n- 运行全部测试：`pytest`；覆盖率：`pytest --cov=src` 或 `coverage run -m pytest`；定向测试：`pytest tests/test_utils.py::test_safe_get`。\n- 优先覆盖核心服务、爬虫管道的异常分支与重试逻辑，避免回归。\n- PR 前请运行相关测试，新增逻辑补充针对性用例。\n\n## 提交与 PR 规范\n- Commit 采用类 Conventional Commits：`feat(...)`、`fix(...)`、`refactor(...)`、`chore(...)`、`docs(...)` 等。\n- PR 需说明变更范围与影响模块；UI 变更在 `web-ui/` 提供截图；关联相关 Issue；提及配置或迁移步骤。\n\n## 安全与配置提示\n- 复制 `.env.example` 为 `.env`，设置必填项 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL_NAME` 等。\n- 不要提交真实凭据或 cookies（如 `state.json`）；Playwright 需本地浏览器，Docker 镜像已预装 Chromium。\n- Web 认证默认 `admin/admin123`，生产环境务必修改，推荐启用 HTTPS 并限制访问来源。\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## 项目概述\n\n基于 Playwright + AI 的闲鱼智能监控机器人。FastAPI 后端 + Vue 3 前端，支持多任务并发监控、多模态 AI 商品分析、多渠道通知推送。\n\n## 核心架构\n\n```\nAPI层 (src/api/routes/)\n    ↓\n服务层 (src/services/)\n    ↓\n领域层 (src/domain/)\n    ↓\n基础设施层 (src/infrastructure/)\n```\n\n关键入口：\n- `src/app.py` - FastAPI 应用主入口\n- `spider_v2.py` - 爬虫 CLI 入口\n- `src/scraper.py` - Playwright 爬虫核心逻辑\n\n服务层：\n- `TaskService` - 任务 CRUD\n- `ProcessService` - 爬虫子进程管理\n- `SchedulerService` - APScheduler 定时调度\n- `AIAnalysisService` - 多模态 AI 分析\n- `NotificationService` - 多渠道通知（ntfy/Bark/企业微信/Telegram/Webhook）\n\n前端 (`web-ui/`)：Vue 3 + Vite + shadcn-vue + Tailwind CSS\n\n## 开发命令\n\n```bash\n# 后端开发\npython -m src.app\n# 或\nuvicorn src.app:app --host 0.0.0.0 --port 8000 --reload\n\n# 前端开发\ncd web-ui && npm install && npm run dev\n\n# 前端构建\ncd web-ui && npm run build\n\n# 一键本地启动（构建前端 + 启动后端）\nbash start.sh\n\n# Docker 部署\ndocker compose up --build -d\n```\n\n## 爬虫命令\n\n```bash\npython spider_v2.py                          # 运行所有启用任务\npython spider_v2.py --task-name \"MacBook\"    # 运行指定任务\npython spider_v2.py --debug-limit 3          # 调试模式，限制商品数\npython spider_v2.py --config custom.json     # 自定义配置文件\n```\n\n## 测试\n\n```bash\npytest                              # 运行所有测试\npytest --cov=src                    # 覆盖率报告\npytest tests/unit/test_utils.py    # 运行单个测试文件\npytest tests/unit/test_utils.py::test_safe_get  # 运行单个测试函数\n```\n\n测试规范：文件 `tests/**/test_*.py`，函数 `test_*`\n\n## 配置\n\n环境变量 (`.env`)：\n- AI 模型：`OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL_NAME`\n- 通知：`NTFY_TOPIC_URL`, `BARK_URL`, `WX_BOT_URL`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`\n- 爬虫：`RUN_HEADLESS`, `LOGIN_IS_EDGE`\n- Web 认证：`WEB_USERNAME`, `WEB_PASSWORD`\n- 端口：`SERVER_PORT`\n\n任务配置 (`config.json`)：定义监控任务（关键词、价格范围、cron 表达式、AI prompt 文件等）\n\n## 数据流\n\n1. Web UI / config.json 创建任务\n2. SchedulerService 按 cron 触发或手动启动\n3. ProcessService 启动 spider_v2.py 子进程\n4. scraper.py 使用 Playwright 抓取商品\n5. AIAnalysisService 调用多模态模型分析\n6. NotificationService 推送符合条件的商品\n7. 结果存储：`jsonl/`（数据）、`images/`（图片）、`logs/`（日志）\n\n## 注意事项\n\n- AI 模型必须支持图片上传（多模态）\n- Docker 部署需通过 Web UI 手动更新登录状态（`state.json`）\n- 遇到滑动验证码时设置 `RUN_HEADLESS=false` 手动处理\n- 生产环境务必修改默认 Web 认证密码\n"
  },
  {
    "path": "DISCLAIMER.md",
    "content": "# 免责声明 / Disclaimer\n\n## 中文版本\n\n本项目是一个开源软件，仅供学习和研究目的使用。使用者在使用本软件时，必须遵守所在国家/地区的所有相关法律法规。\n\n项目作者及贡献者明确声明：\n\n1. 本项目仅用于技术学习和研究目的，不得用于任何违法或不道德的活动。\n2. 使用者对本软件的使用行为承担全部责任，包括但不限于任何修改、分发或商业应用。\n3. 项目作者及贡献者不对因使用本软件而导致的任何直接、间接、附带或特殊的损害或损失承担责任，即使已被告知可能发生此类损害。\n4. 如果您的使用行为违反了所在司法管辖区的法律，请立即停止使用并删除本软件。\n5. 本项目按\"现状\"提供，不提供任何形式的担保，包括但不限于适销性、特定用途适用性和非侵权性担保。\n\n本项目采用 MIT 许可证发布。根据该许可证，您可以自由使用、复制、修改、分发本软件，但必须保留原始版权声明和本免责声明。\n\n项目作者保留随时更改本免责声明的权利，恕不另行通知。使用本软件即表示您同意受本免责声明条款的约束。\n\n## English Version\n\nThis project is open source software provided for learning and research purposes only. Users must comply with all relevant laws and regulations in their jurisdiction when using this software.\n\nThe project owner and contributors explicitly state:\n\n1. This project is for technical learning and research purposes only and must not be used for any illegal or unethical activities.\n2. Users assume full responsibility for their use of the software, including but not limited to any modifications, distributions, or commercial applications.\n3. The project owner and contributors are not liable for any direct, indirect, incidental, or special damages or losses resulting from the use of this software, even if advised of the possibility of such damages.\n4. If your use violates the laws of your jurisdiction, please stop using and delete this software immediately.\n5. This project is provided \"as is\" without warranty of any kind, either express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, or non-infringement.\n\nThis project is released under the MIT License. Under this license, you are free to use, copy, modify, and distribute this software, but you must retain the original copyright notice and this disclaimer.\n\nThe project owner reserves the right to change this disclaimer at any time without notice. Your use of the software indicates your acceptance of the terms of this disclaimer."
  },
  {
    "path": "Dockerfile",
    "content": "# Stage 1: Build the Vue application\nFROM node:22-alpine AS frontend-builder\nWORKDIR /web-ui\nCOPY web-ui/package*.json ./\nRUN npm ci\nCOPY web-ui/ .\nRUN npm run build\n\n# Stage 2: Build the python environment with dependencies\nFROM python:3.11-slim-bookworm AS builder\n\n# 设置环境变量以防止交互式提示\nENV DEBIAN_FRONTEND=noninteractive \\\n    VIRTUAL_ENV=/opt/venv \\\n    PATH=\"/opt/venv/bin:$PATH\"\n\n# 创建虚拟环境并安装 Python 运行时依赖\nRUN python3 -m venv $VIRTUAL_ENV\nCOPY requirements-runtime.txt .\nRUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements-runtime.txt\n\n# Stage 3: Create the final, lean image\nFROM python:3.11-slim-bookworm\n\nWORKDIR /app\nENV DEBIAN_FRONTEND=noninteractive \\\n    VIRTUAL_ENV=/opt/venv \\\n    PATH=\"/opt/venv/bin:$PATH\" \\\n    PYTHONUNBUFFERED=1 \\\n    RUNNING_IN_DOCKER=true \\\n    PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \\\n    TZ=Asia/Shanghai\n\nCOPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}\n\nRUN apt-get update \\\n    && apt-get install -y --no-install-recommends \\\n        tzdata \\\n        tini \\\n        libzbar0 \\\n    && playwright install --with-deps --no-shell chromium \\\n    && apt-get clean \\\n    && rm -rf /var/lib/apt/lists/*\n\nCOPY --from=frontend-builder /dist /app/dist\n\nCOPY src /app/src\nCOPY spider_v2.py /app/spider_v2.py\nCOPY prompts /app/prompts\nCOPY static /app/static\nCOPY config.json.example /app/config.json.example\n\nRUN mkdir -p /app/data /app/state /app/logs /app/images /app/jsonl /app/price_history\n\nEXPOSE 8000\n\nUSER root\n\nENTRYPOINT [\"tini\", \"--\"]\n\nCMD [\"python\", \"-m\", \"src.app\"]\n"
  },
  {
    "path": "Dockerfile.base",
    "content": "# syntax=docker/dockerfile:1.7\n\nFROM python:3.11-slim-bookworm\n\nWORKDIR /app\n\nENV DEBIAN_FRONTEND=noninteractive \\\n    VIRTUAL_ENV=/opt/venv \\\n    PATH=\"/opt/venv/bin:$PATH\" \\\n    PYTHONUNBUFFERED=1 \\\n    RUNNING_IN_DOCKER=true \\\n    PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \\\n    TZ=Asia/Shanghai\n\nCOPY requirements-runtime.txt /tmp/requirements-runtime.txt\n\nRUN python3 -m venv \"$VIRTUAL_ENV\"\n\nRUN --mount=type=cache,target=/root/.cache/pip \\\n    pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r /tmp/requirements-runtime.txt\n\nRUN apt-get update \\\n    && apt-get install -y --no-install-recommends \\\n        tzdata \\\n        tini \\\n        libzbar0 \\\n    && playwright install --with-deps --no-shell chromium \\\n    && apt-get clean \\\n    && rm -rf /var/lib/apt/lists/* /tmp/requirements-runtime.txt\n\nRUN mkdir -p /app/data /app/state /app/logs /app/images /app/jsonl /app/price_history\n\nENTRYPOINT [\"tini\", \"--\"]\n"
  },
  {
    "path": "Dockerfile.release",
    "content": "# syntax=docker/dockerfile:1.7\n\nARG BASE_IMAGE=ghcr.io/usagi-org/ai-goofish-base:latest\n\nFROM node:22-alpine AS frontend-builder\n\nWORKDIR /web-ui\n\nCOPY web-ui/package*.json ./\n\nRUN --mount=type=cache,target=/root/.npm npm ci\n\nCOPY web-ui/ .\n\nRUN npm run build\n\nFROM ${BASE_IMAGE}\n\nWORKDIR /app\n\nCOPY --from=frontend-builder /dist /app/dist\n\nCOPY src /app/src\nCOPY spider_v2.py /app/spider_v2.py\nCOPY prompts /app/prompts\nCOPY static /app/static\nCOPY config.json.example /app/config.json.example\n\nEXPOSE 8000\n\nCMD [\"python\", \"-m\", \"src.app\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 dingyufei615\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE."
  },
  {
    "path": "README.md",
    "content": "# 闲鱼智能监控机器人\n\n[English README](README_EN.md)\n\n基于 Playwright 和 AI 的闲鱼多任务实时监控工具，提供完整的 Web 管理界面。\n\n## 核心特性\n\n- **Web 可视化管理**: 任务管理、账号管理、AI 标准编辑、运行日志、结果浏览\n- **AI 驱动**: 自然语言创建任务，多模态模型深度分析商品\n- **多任务并发**: 独立配置关键词、价格、筛选条件和 AI Prompt\n- **高级筛选**: 包邮、新发布时间范围、省/市/区三级区域筛选\n- **即时通知**: 支持 ntfy.sh、企业微信、Bark、Telegram、Webhook\n- **定时调度**: Cron 表达式配置周期性任务\n- **账号与代理轮换**: 多账号管理、任务绑定账号、代理池轮换与失败重试\n- **Docker 部署**: 一键容器化部署\n\n## 截图\n\n![监控概览](static/img.png)\n![任务管理](static/img_1.png)\n![结果查看](static/img_2.png)\n![通知推送](static/img_3.png)\n\n## 🐳 Docker 部署（推荐）\n\n```bash\ngit clone https://github.com/Usagi-org/ai-goofish-monitor && cd ai-goofish-monitor\ncp .env.example .env\nvim .env # 填写相关配置项\ndocker compose up -d\ndocker compose logs -f app\ndocker compose down\n```\n\n如果镜像无法访问或下载速度慢，可尝试使用加速：\n```bash\n\ndocker pull ghcr.nju.edu.cn/usagi-org/ai-goofish:latest\ndocker tag ghcr.nju.edu.cn/usagi-org/ai-goofish:latest ghcr.io/usagi-org/ai-goofish:latest\ndocker compose up -d\n\n```\n\n- 默认 Web UI 地址：`http://127.0.0.1:8000`\n- Docker 镜像已内置 Chromium，无需宿主机额外安装浏览器。\n- 官方镜像地址：`ghcr.io/usagi-org/ai-goofish:latest`\n- 更新镜像：`docker compose pull && docker compose up -d`\n- 如果你修改了 `.env` 中的 `SERVER_PORT`，请同步更新 `docker-compose.yaml` 里的端口映射。\n- `docker-compose.yaml` 默认会把 SQLite 主库挂载到 `./data:/app/data`，数据库文件默认为 `data/app.sqlite3`\n- 目前默认持久化这些目录：\n    - `data/`  SQLite 主存储（任务、结果、价格历史）\n    - `state/`  登录状态 cookie 文件\n    - `prompts/`  任务提示词\n    - `logs/`  运行日志\n    - `images/`  商品图片与任务临时图片目录\n    - `config.json`、`jsonl/`、`price_history/`  首次升级到 SQLite 时用于兼容导入的旧数据源\n\n### 数据存储与迁移\n\n- 当前在线主存储为 SQLite，默认路径 `data/app.sqlite3`\n- 可通过环境变量 `APP_DATABASE_FILE` 自定义数据库路径；Docker 默认设置为 `/app/data/app.sqlite3`\n- 应用启动时会自动建库建表，并尝试从旧的 `config.json`、`jsonl/`、`price_history/` 导入一次历史数据\n- `state/`、`prompts/`、`logs/`、`images/` 仍然是文件系统目录，不在 SQLite 中\n- 商品图片会临时落到 `images/task_images_<task_name>/`，任务结束后默认会清理\n- 首次升级完成并确认 `data/app.sqlite3` 中数据正确后，可视部署方式决定是否继续保留旧的 `config.json`、`jsonl/`、`price_history/` 挂载\n\n### 最少配置\n\n| 变量 | 说明 | 必填 |\n|------|------|------|\n| `OPENAI_API_KEY` | AI 模型 API Key | 是 |\n| `OPENAI_BASE_URL` | OpenAI 兼容接口地址 | 是 |\n| `OPENAI_MODEL_NAME` | 支持图片输入的模型名称 | 是 |\n| `WEB_USERNAME` / `WEB_PASSWORD` | Web UI 登录账号密码，默认 `admin/admin123` | 否 |\n\n其余配置见下方“配置说明”。\n\n\n### 第一次使用\n\n1. 打开默认 Web UI `http://127.0.0.1:8000` 并登录。\n2. 进入“闲鱼账号管理”，使用 [Chrome 扩展](https://chromewebstore.google.com/detail/xianyu-login-state-extrac/eidlpfjiodpigmfcahkmlenhppfklcoa) 导出并粘贴闲鱼登录态 JSON。\n3. 登录态文件会保存到 `state/` 目录，例如 `state/acc_1.json`。\n4. 回到“任务管理”，创建任务并绑定账号后即可运行。\n\n### 创建第一个任务\n\n- `AI判断`：填写“详细需求”，提交后会弹出独立进度弹窗，后台异步生成分析标准。\n- `关键词判断`：填写关键词规则，任务会直接创建，不经过 AI 生成流程。\n- `区域筛选`：已改为省 / 市 / 区三级选择器，数据基于闲鱼页面抓取快照内置。\n\n\n\n## 用户使用说明\n\n<details>\n<summary>点击展开 Web UI 功能说明</summary>\n\n### 任务管理\n\n- 支持 AI 创建、关键词规则、价格范围、新发布范围、区域筛选、账号绑定、定时规则。\n- AI 任务创建是后台 job 流程，提交后会打开单独的进度弹窗。\n- 区域筛选会显著缩小结果集，默认留空。\n\n### 账号管理\n\n- 支持导入、更新、删除闲鱼账号登录态。\n- 每个任务可指定账号，也可不绑定并交给系统自动选择。\n\n### 结果查看与运行日志\n\n- 结果页和导出功能现在从 SQLite 查询，不再直接扫描 `jsonl` 文件。\n- 日志页按任务展示运行过程，便于排查登录态失效、风控和 AI 调用问题。\n\n### 系统设置\n\n- 可查看系统状态、编辑 Prompt、调整代理与轮换相关配置。\n\n</details>\n\n\n\n## 开发者开发\n\n### 环境要求\n\n- Python 3.10+\n- Node.js + npm（本地验证 `Node v20.18.3` 可完成前端构建）\n- Playwright CLI 与 Chromium，首次运行前建议执行 `python3 -m pip install playwright && python3 -m playwright install chromium`\n- Chrome / Edge 浏览器（Linux 环境也可使用 Chromium；`start.sh` 会先检查浏览器是否存在）\n\n```bash\ngit clone https://github.com/Usagi-org/ai-goofish-monitor\ncd ai-goofish-monitor\ncp .env.example .env\n```\n\n### 一键启动\n\n```bash\nchmod +x start.sh\n./start.sh\n```\n\n`start.sh` 会先检查 Playwright CLI 和浏览器前置条件；在前置条件满足后自动安装项目依赖、构建前端、复制构建产物并启动后端。\n\n### 手动启动\n\n```bash\n# 后端\npython -m src.app\n# 或\nuvicorn src.app:app --host 0.0.0.0 --port 8000 --reload\n\n# 前端\ncd web-ui\nnpm install\nnpm run dev\n```\n\n- FastAPI 启动时会自动初始化 SQLite，并在首次启动时尝试导入旧的 `config.json/jsonl/price_history`\n- `spider_v2.py` 默认从 SQLite 读取任务；只有显式传入 `--config <path>` 时才会走 JSON 配置兼容模式\n- 默认数据库路径为 `data/app.sqlite3`\n- Vite 开发服务器会将 `/api`、`/auth`、`/ws` 代理到 `http://127.0.0.1:8000`。\n- `npm run build` 先生成 `web-ui/dist/`，`start.sh` 再复制到仓库根目录 `dist/`。\n- FastAPI 负责提供根目录 `dist/index.html` 和 `dist/assets/`。\n- `./start.sh` 默认输出访问地址 `http://localhost:8000` 和 API 文档 `http://localhost:8000/docs`。\n\n### 测试与校验\n\n```bash\nPYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest\ncd web-ui && npm run build\n```\n\n### 任务创建 API\n\n<details>\n<summary>点击展开 API 行为说明</summary>\n\n- `POST /api/tasks/generate`\n  - `decision_mode=ai`：返回 `202` 和 `job`，需要继续轮询进度。\n  - `decision_mode=keyword`：直接返回已创建任务。\n- `GET /api/tasks/generate-jobs/{job_id}`：查询 AI 任务生成进度。\n- `POST /auth/status`：校验 Web UI 登录凭据。\n\n</details>\n\n## 配置说明\n\n<details>\n<summary>点击展开常用配置项</summary>\n\n### AI 与运行时\n\n- `OPENAI_API_KEY` / `OPENAI_BASE_URL` / `OPENAI_MODEL_NAME`：AI 模型接入必填项。\n- `PROXY_URL`：为 AI 请求单独指定 HTTP/SOCKS5 代理。\n- `RUN_HEADLESS`：是否以无头模式运行爬虫；Docker 中应保持 `true`。\n- `SERVER_PORT`：后端监听端口，默认 `8000`。\n- `LOGIN_IS_EDGE`：本地环境可切换为 Edge 内核；Docker 镜像未内置 Edge，容器内会固定使用 Chromium。\n- `PCURL_TO_MOBILE`：是否将 PC 商品链接转换为移动端链接。\n\n### 通知\n\n- `NTFY_TOPIC_URL`\n- `GOTIFY_URL` / `GOTIFY_TOKEN`\n- `BARK_URL`\n- `WX_BOT_URL`\n- `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` / `TELEGRAM_API_BASE_URL`\n- `WEBHOOK_*`\n\n### 代理轮换与失败保护\n\n- `PROXY_ROTATION_ENABLED`\n- `PROXY_ROTATION_MODE`\n- `PROXY_POOL`\n- `PROXY_ROTATION_RETRY_LIMIT`\n- `PROXY_BLACKLIST_TTL`\n- `TASK_FAILURE_THRESHOLD`\n- `TASK_FAILURE_PAUSE_SECONDS`\n- `TASK_FAILURE_GUARD_PATH`\n\n完整示例见 `.env.example`。\n\n</details>\n\n## Web 界面认证\n\n<details>\n<summary>点击展开认证说明</summary>\n\n- Web UI 当前使用登录页收集账号密码，并通过 `POST /auth/status` 校验。\n- 登录成功后，前端会在浏览器本地保存登录状态，用于路由守卫和 WebSocket 初始化。\n- 默认账号密码为 `admin/admin123`，生产环境请务必修改。\n\n</details>\n\n## 🚀 工作流程\n\n下图描述了单个监控任务从启动到完成的核心处理逻辑。主服务运行于 `src.app`，按用户操作或定时调度启动一个或多个任务进程。\n\n```mermaid\ngraph TD\n    A[启动监控任务] --> B[选择账号/代理配置];\n    B --> C[任务: 搜索商品];\n    C --> D{发现新商品?};\n    D -- 是 --> E[抓取商品详情 & 卖家信息];\n    E --> F[下载商品图片];\n    F --> G[调用AI进行分析];\n    G --> H{AI是否推荐?};\n    H -- 是 --> I[发送通知];\n    H -- 否 --> J[保存记录到 SQLite];\n    I --> J;\n    D -- 否 --> K[翻页/等待];\n    K --> C;\n    J --> C;\n    C --> L{触发风控/异常?};\n    L -- 是 --> M[账号/代理轮换并重试];\n    M --> C;\n```\n\n## 常见问题\n\n<details>\n<summary>点击展开常见问题</summary>\n\n### AI 任务创建为什么不是立即完成？\n\nAI 模式会先生成分析标准，再创建任务。现在该流程已改为后台 job，提交后会显示独立进度弹窗，避免表单长时间卡住。\n\n### 区域筛选为什么默认建议留空？\n\n区域筛选会显著减少搜索结果，适合明确只看某个区域的场景。若你先验证整体市场，建议先不填。\n\n### 本地页面打开后提示前端构建产物不存在？\n\n说明根目录 `dist/` 缺失。可直接执行 `./start.sh`，或先在 `web-ui/` 里执行 `npm run build`，再确认构建产物已复制到仓库根目录。\n\n### `./start.sh` 为什么提示缺少 Playwright 或浏览器？\n\n这是脚本的前置检查。请先安装 Playwright CLI 与 Chromium，并确保系统中可用 Chrome / Edge（Linux 环境也可用 Chromium），然后重新执行 `./start.sh`。\n\n</details>\n\n\n\n## 致谢\n\n<details>\n<summary>点击展开致谢内容</summary>\n\n本项目在开发过程中参考了以下优秀项目，特此感谢：\n\n- [superboyyy/xianyu_spider](https://github.com/superboyyy/xianyu_spider)\n\n以及感谢LinuxDo相关人员的脚本贡献\n\n- [@jooooody](https://linux.do/u/jooooody/summary)\n\n以及感谢 [LinuxDo](https://linux.do/) 社区。\n\n以及感谢 ClaudeCode/Gemini/Codex 等模型工具，解放双手 体验Vibe Coding的快乐。\n\n</details>\n\n\n## 注意事项\n\n<details>\n<summary>点击展开注意事项详情</summary>\n\n- 请遵守闲鱼的用户协议和robots.txt规则，不要进行过于频繁的请求，以免对服务器造成负担或导致账号被限制。\n- 本项目仅供学习和技术研究使用，请勿用于非法用途。\n- 本项目采用 [MIT 许可证](LICENSE) 发布，按\"现状\"提供，不提供任何形式的担保。\n- 项目作者及贡献者不对因使用本软件而导致的任何直接、间接、附带或特殊的损害或损失承担责任。\n- 如需了解更多详细信息，请查看 [免责声明](DISCLAIMER.md) 文件。\n\n</details>\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=Usagi-org/ai-goofish-monitor&type=Date)](https://www.star-history.com/#Usagi-org/ai-goofish-monitor&Date)\n\n![Alt](https://repobeats.axiom.co/api/embed/b40d8a112271b4bddabadd8fe2635be3c1aa28a3.svg \"Repobeats analytics image\")\n"
  },
  {
    "path": "README_EN.md",
    "content": "# Xianyu Intelligent Monitor Bot\n\n[中文说明](README.md)\n\nA Playwright and AI-powered multi-task real-time monitoring tool for Xianyu (闲鱼), featuring a complete web management interface.\n\n## Core Features\n\n- **Web Visual Management**: Task management, account management, AI criteria editing, run logs, results browsing\n- **AI-Driven**: Natural language task creation, multimodal model for in-depth product analysis\n- **Multi-Task Concurrency**: Independent configuration for keywords, prices, filters, and AI prompts\n- **SQLite as Primary Storage**: Tasks, results, and price history are persisted in one embedded database instead of repeatedly scanning `jsonl`\n- **Advanced Filtering**: Free shipping, new listing time range, province/city/district filtering\n- **Instant Notifications**: Supports ntfy.sh, WeChat Work (企业微信), Bark, Telegram, Webhook\n- **Scheduled Tasks**: Cron expression configuration for periodic tasks\n- **Account & Proxy Rotation**: Multi-account management, task-account binding, proxy pool rotation with failure retry\n- **Docker Deployment**: One-click containerized deployment\n\n## Screenshots\n\n![Monitoring Overview](static/img.png)\n![Task Management](static/img_1.png)\n![Result Viewer](static/img_2.png)\n![Notification Settings](static/img_3.png)\n\n## Quick Start\n\n### Requirements\n\n- Python 3.10+\n- Node.js + npm (`Node v20.18.3` has been verified to complete the frontend build)\n- Playwright CLI and Chromium. Before the first local run, install them with `python3 -m pip install playwright && python3 -m playwright install chromium`\n- Chrome or Edge on desktop systems. On Linux, Chromium also works. `start.sh` checks this prerequisite before continuing\n\n```bash\ngit clone https://github.com/Usagi-org/ai-goofish-monitor\ncd ai-goofish-monitor\ncp .env.example .env\n```\n\n### Minimum Configuration\n\n| Variable | Description | Required |\n|----------|-------------|----------|\n| `OPENAI_API_KEY` | AI model API key | Yes |\n| `OPENAI_BASE_URL` | OpenAI-compatible API base URL | Yes |\n| `OPENAI_MODEL_NAME` | Model name with image input support | Yes |\n| `WEB_USERNAME` / `WEB_PASSWORD` | Web UI login credentials, default `admin/admin123` | No |\n\nSee \"Configuration\" below for the rest.\n\n### Start Locally\n\n```bash\nchmod +x start.sh\n./start.sh\n```\n\n`start.sh` first validates the Playwright CLI and browser prerequisites. Once they are available, it installs project dependencies, builds the frontend, copies the artifacts, and starts the backend.\n\n### First-Time Setup\n\n1. Open the default Web UI at `http://127.0.0.1:8000` and sign in.\n2. Go to \"Xianyu Account Management\" and use the [Chrome Extension](https://chromewebstore.google.com/detail/xianyu-login-state-extrac/eidlpfjiodpigmfcahkmlenhppfklcoa) to export and paste the Xianyu login-state JSON.\n3. Login-state files are stored in `state/`, for example `state/acc_1.json`.\n4. Go back to \"Task Management\", create a task, bind an account if needed, and run it.\n\n### Create Your First Task\n\n- `AI mode`: fill in the requirement description. Submission opens a separate progress dialog while the criteria are generated asynchronously.\n- `Keyword mode`: provide keyword rules and the task is created immediately.\n- `Region filter`: now uses a province / city / district selector backed by an embedded Xianyu page snapshot instead of manual text input.\n\n## 🐳 Docker Deployment (Recommended)\n\n```bash\ngit clone https://github.com/Usagi-org/ai-goofish-monitor && cd ai-goofish-monitor\ncp .env.example .env\nvim .env # fill in the required values\ndocker compose up -d\ndocker compose logs -f app\ndocker compose down\n```\n\n- Default Web UI: `http://127.0.0.1:8000`\n- The published Docker image already includes Chromium, so no extra browser install is required on the host.\n- Update image: `docker compose pull && docker compose up -d`\n- If you change `SERVER_PORT` in `.env`, update the `ports` mapping in `docker-compose.yaml` as well.\n- `docker-compose.yaml` now mounts the primary SQLite database directory as `./data:/app/data`, with the default database file at `data/app.sqlite3`\n- These paths are persisted by default:\n  - `data/` for the SQLite primary store (tasks, results, price history)\n  - `state/` for login-state cookie files\n  - `prompts/` for task prompt files\n  - `logs/` for runtime logs\n  - `images/` for downloaded product images and per-task temporary image folders\n  - `config.json`, `jsonl/`, and `price_history/` as legacy sources for the first SQLite migration\n\n### Storage and Migration\n\n- SQLite is now the online primary storage, with the default path `data/app.sqlite3`\n- You can override the database path with `APP_DATABASE_FILE`; Docker sets it to `/app/data/app.sqlite3`\n- On startup, the app initializes the schema and tries to import existing data once from legacy `config.json`, `jsonl/`, and `price_history/`\n- `state/`, `prompts/`, `logs/`, and `images/` remain filesystem-based and are not stored in SQLite\n- Product images are temporarily downloaded to `images/task_images_<task_name>/` and are normally cleaned up when the task finishes\n- After the first upgrade and after verifying the database contents in `data/app.sqlite3`, you can decide whether to keep the legacy `config.json`, `jsonl/`, and `price_history/` mounts\n\n## User Guide\n\n<details>\n<summary>Click to expand Web UI usage notes</summary>\n\n### Task Management\n\n- Supports AI creation, keyword rules, price range, new listing filters, region filters, account binding, and cron scheduling.\n- AI task creation runs as a background job and shows a dedicated progress dialog after submission.\n- Region filtering can greatly reduce results, so leaving it empty is the safer default.\n\n### Account Management\n\n- Import, update, and delete Xianyu login states.\n- Each task can bind a specific account or leave account selection to the system.\n\n### Results and Logs\n\n- The results page and export endpoints now query SQLite instead of directly scanning `jsonl` files.\n- The logs page is the first place to inspect login-state expiry, anti-bot issues, or AI call failures.\n\n### System Settings\n\n- View system status, edit prompts, and adjust proxy / rotation-related settings.\n\n</details>\n\n## Developer Guide\n\n### Local Development\n\n```bash\n# backend\npython -m src.app\n# or\nuvicorn src.app:app --host 0.0.0.0 --port 8000 --reload\n\n# frontend\ncd web-ui\nnpm install\nnpm run dev\n```\n\n- FastAPI initializes SQLite on startup and performs the one-time legacy import from `config.json/jsonl/price_history` when needed\n- `spider_v2.py` now loads tasks from SQLite by default; JSON config is only used when `--config <path>` is passed explicitly\n- The default local database path is `data/app.sqlite3`\n- The Vite dev server proxies `/api`, `/auth`, and `/ws` to `http://127.0.0.1:8000`.\n- `npm run build` writes `web-ui/dist/`, and `start.sh` copies it to the repository root `dist/`.\n- FastAPI serves `dist/index.html` and `dist/assets/` from the repository root.\n- `./start.sh` prints the default app URL `http://localhost:8000` and API docs URL `http://localhost:8000/docs`.\n\n### Validation\n\n```bash\nPYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest\ncd web-ui && npm run build\n```\n\n### Task Creation API\n\n<details>\n<summary>Click to expand API behavior</summary>\n\n- `POST /api/tasks/generate`\n  - `decision_mode=ai`: returns `202` with a `job`; the client should poll for progress.\n  - `decision_mode=keyword`: returns the created task directly.\n- `GET /api/tasks/generate-jobs/{job_id}`: fetch AI task-generation progress.\n- `POST /auth/status`: validate Web UI credentials.\n\n</details>\n\n## Configuration\n\n<details>\n<summary>Click to expand common configuration items</summary>\n\n### AI and Runtime\n\n- `OPENAI_API_KEY` / `OPENAI_BASE_URL` / `OPENAI_MODEL_NAME`: required AI model settings.\n- `PROXY_URL`: dedicated HTTP/SOCKS5 proxy for AI requests.\n- `RUN_HEADLESS`: whether the scraper runs headless; keep it `true` in Docker.\n- `SERVER_PORT`: backend port, default `8000`.\n- `LOGIN_IS_EDGE`: use Edge instead of Chrome locally; Docker images do not bundle Edge and always run with Chromium.\n- `PCURL_TO_MOBILE`: convert desktop item URLs to mobile URLs.\n\n### Notifications\n\n- `NTFY_TOPIC_URL`\n- `GOTIFY_URL` / `GOTIFY_TOKEN`\n- `BARK_URL`\n- `WX_BOT_URL`\n- `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` / `TELEGRAM_API_BASE_URL`\n- `WEBHOOK_*`\n\n### Proxy Rotation and Failure Guard\n\n- `PROXY_ROTATION_ENABLED`\n- `PROXY_ROTATION_MODE`\n- `PROXY_POOL`\n- `PROXY_ROTATION_RETRY_LIMIT`\n- `PROXY_BLACKLIST_TTL`\n- `TASK_FAILURE_THRESHOLD`\n- `TASK_FAILURE_PAUSE_SECONDS`\n- `TASK_FAILURE_GUARD_PATH`\n\nSee `.env.example` for the full list.\n\n</details>\n\n## Web Authentication\n\n<details>\n<summary>Click to expand authentication notes</summary>\n\n- The Web UI uses a login page and validates credentials through `POST /auth/status`.\n- After login, the frontend stores local auth state for route guards and WebSocket startup.\n- The default credentials are `admin/admin123`; change them in production.\n\n</details>\n\n## 🚀 Workflow\n\nThe diagram below shows the core processing flow of a monitoring task. The main service runs in `src.app` and launches one or more task processes based on user actions or schedule triggers.\n\n```mermaid\ngraph TD\n    A[Start Monitoring Task] --> B[Select Account/Proxy Configuration];\n    B --> C[Task: Search Products];\n    C --> D{Found New Products?};\n    D -- Yes --> E[Scrape Product Details & Seller Info];\n    E --> F[Download Product Images];\n    F --> G[Call AI for Analysis];\n    G --> H{AI Recommended?};\n    H -- Yes --> I[Send Notification];\n    H -- No --> J[Save Record to SQLite];\n    I --> J;\n    D -- No --> K[Next Page/Wait];\n    K --> C;\n    J --> C;\n    C --> L{Risk Control/Exception?};\n    L -- Yes --> M[Account/Proxy Rotation and Retry];\n    M --> C;\n```\n\n## FAQ\n\n<details>\n<summary>Click to expand FAQ</summary>\n\n### Why does AI task creation take time?\n\nIn AI mode, the system generates analysis criteria before the task itself is created. This now runs as a background job with a separate progress dialog instead of blocking the task form.\n\n### Why is the region filter optional by default?\n\nRegion filtering can sharply reduce result volume. Leave it empty if you want a broader market scan first.\n\n### Why does the app say the frontend build artifacts are missing?\n\nIt means the repository root `dist/` directory is missing. Run `./start.sh`, or build the frontend in `web-ui/` and make sure the artifacts are copied to the root `dist/`.\n\n### Why does `./start.sh` complain about missing Playwright or a browser?\n\nThe script performs a prerequisite check before installing project dependencies. Install the Playwright CLI and Chromium first, then make sure Chrome, Edge, or Chromium is available on the system and rerun `./start.sh`.\n\n</details>\n\n## Acknowledgments\n\n<details>\n<summary>Click to expand acknowledgments</summary>\n\nThis project referenced the following excellent projects during development. Special thanks to:\n\n- [superboyyy/xianyu_spider](https://github.com/superboyyy/xianyu_spider)\n\nAlso thanks to LinuxDo contributors for script contributions:\n\n- [@jooooody](https://linux.do/u/jooooody/summary)\n\nAnd thanks to the [LinuxDo](https://linux.do/) community.\n\nAlso thanks to ClaudeCode/Gemini/Codex and other model tools for freeing our hands and experiencing the joy of Vibe Coding.\n\n</details>\n\n\n## Notices\n\n<details>\n<summary>Click to expand notice details</summary>\n\n- Please comply with Xianyu's user agreement and robots.txt rules. Do not make frequent requests to avoid burdening the server or having your account restricted.\n- This project is for learning and technical research purposes only. Do not use it for illegal purposes.\n- This project is released under the [MIT License](LICENSE), provided \"as is\", without any form of warranty.\n- The project authors and contributors are not responsible for any direct, indirect, incidental, or special damages or losses caused by the use of this software.\n- For more details, please refer to the [Disclaimer](DISCLAIMER.md) file.\n\n</details>\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=Usagi-org/ai-goofish-monitor&type=Date)](https://www.star-history.com/#Usagi-org/ai-goofish-monitor&Date)\n"
  },
  {
    "path": "chrome-extension/README.md",
    "content": "# Xianyu Login State Extractor Chrome Extension\n\nThis Chrome extension helps extract complete login state information from Xianyu (Goofish) for use with the monitoring robot. It also records browser environment hints and request headers to better mimic a real session.\n\n## Installation\n\n1. Open Chrome and navigate to `chrome://extensions`\n2. Enable \"Developer mode\" in the top right corner\n3. Click \"Load unpacked\" and select the `chrome-extension` directory\n4. The extension icon should now appear in your toolbar\n\n## Usage\n\n1. Navigate to [https://www.goofish.com](https://www.goofish.com)\n2. Log in to your account\n3. Click the extension icon in the toolbar\n4. Click \"Extract Login State\" (collects cookies + environment + headers，自动过滤无用/超大字段)\n5. The complete JSON will be displayed - click \"Copy to Clipboard\"\n6. Save the JSON文本到 `xianyu_state.json`（或自定义文件名）即可\n\n## Features\n\n- Extracts all cookies including HttpOnly cookies\n- Captures browser environment (UA, locale, timezone, screen size, device memory, hardware concurrency)\n- Captures observed request headers for the current tab\n- Captures localStorage/sessionStorage snapshot for the current domain（会自动丢弃超大或无用字段）\n- Outputs a single JSON payload, ready for the monitoring robot\n- Copy to clipboard with real-time status feedback\n\n## How It Works\n\nThe extension uses the `chrome.cookies` API to access all cookies for the `.goofish.com` domain, including those with the HttpOnly flag set. This bypasses the normal JavaScript security restrictions that prevent access to these cookies.\n"
  },
  {
    "path": "chrome-extension/background.js",
    "content": "// Service worker for capturing browser environment, headers, storage, and cookies\n\nconst GOOFISH_HOST_PATTERN = \"*://*.goofish.com/*\";\nconst MAX_STORAGE_ENTRY_LENGTH = 4096; // bytes limit to drop oversized values\n\nfunction mapSameSiteValue(chromeSameSite) {\n  if (chromeSameSite === undefined || chromeSameSite === null) return \"Lax\";\n  const sameSiteMap = {\n    no_restriction: \"None\",\n    lax: \"Lax\",\n    strict: \"Strict\",\n    unspecified: \"Lax\",\n  };\n  return sameSiteMap[chromeSameSite] || \"Lax\";\n}\n\nfunction headersArrayToObject(headers = []) {\n  const result = {};\n  headers.forEach((item) => {\n    if (item && item.name) {\n      result[item.name] = item.value || \"\";\n    }\n  });\n  return result;\n}\n\nasync function getActiveGoofishTab() {\n  const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n  const tab = tabs?.[0];\n  if (!tab || !tab.id || !tab.url) {\n    throw new Error(\"未找到活动标签页\");\n  }\n  if (!tab.url.includes(\"goofish.com\")) {\n    throw new Error(\"请先打开 goofish.com 页面\");\n  }\n  return tab;\n}\n\nasync function capturePageData(tabId) {\n  const [result] = await chrome.scripting.executeScript({\n    target: { tabId },\n    func: () => {\n      const safeEntries = (storage) => {\n        try {\n          const obj = {};\n          for (let i = 0; i < storage.length; i += 1) {\n            const key = storage.key(i);\n            if (key !== null) {\n              obj[key] = storage.getItem(key);\n            }\n          }\n          return obj;\n        } catch (e) {\n          return {};\n        }\n      };\n\n      const intl = (() => {\n        try {\n          return Intl.DateTimeFormat().resolvedOptions();\n        } catch (e) {\n          return {};\n        }\n      })();\n\n      const uaData = (() => {\n        try {\n          return navigator.userAgentData ? navigator.userAgentData.toJSON() : null;\n        } catch (e) {\n          return null;\n        }\n      })();\n\n      return {\n        page: {\n          pageUrl: location.href,\n          referrer: document.referrer || null,\n          visibilityState: document.visibilityState,\n        },\n        env: {\n          navigator: {\n            userAgent: navigator.userAgent,\n            platform: navigator.platform,\n            vendor: navigator.vendor,\n            language: navigator.language,\n            languages: navigator.languages,\n            hardwareConcurrency: navigator.hardwareConcurrency,\n            deviceMemory: navigator.deviceMemory,\n            webdriver: navigator.webdriver,\n            doNotTrack: navigator.doNotTrack,\n            maxTouchPoints: navigator.maxTouchPoints,\n            userAgentData: uaData,\n          },\n          screen: {\n            width: screen.width,\n            height: screen.height,\n            availWidth: screen.availWidth,\n            availHeight: screen.availHeight,\n            colorDepth: screen.colorDepth,\n            pixelDepth: screen.pixelDepth,\n            devicePixelRatio: window.devicePixelRatio,\n          },\n          intl,\n        },\n        storage: {\n          local: safeEntries(localStorage),\n          session: safeEntries(sessionStorage),\n        },\n      };\n    },\n  });\n\n  if (!result || !result.result) {\n    throw new Error(\"无法获取页面信息\");\n  }\n\n  return result.result;\n}\n\nfunction filterEnvData(env = {}) {\n  const nav = env.navigator || {};\n  const screen = env.screen || {};\n  const intl = env.intl || {};\n\n  return {\n    navigator: {\n      userAgent: nav.userAgent,\n      platform: nav.platform,\n      language: nav.language,\n      languages: nav.languages,\n      hardwareConcurrency: nav.hardwareConcurrency,\n      deviceMemory: nav.deviceMemory,\n      maxTouchPoints: nav.maxTouchPoints,\n      webdriver: nav.webdriver,\n      doNotTrack: nav.doNotTrack,\n      userAgentData: nav.userAgentData || null,\n    },\n    screen: {\n      width: screen.width,\n      height: screen.height,\n      devicePixelRatio: screen.devicePixelRatio,\n      colorDepth: screen.colorDepth,\n    },\n    intl: {\n      timeZone: intl.timeZone,\n      locale: intl.locale,\n    },\n  };\n}\n\nfunction pruneStorageEntries(entries = {}) {\n  const data = {};\n  const dropped = [];\n  Object.entries(entries).forEach(([key, value]) => {\n    const str = value == null ? \"\" : String(value);\n    if (str.length <= MAX_STORAGE_ENTRY_LENGTH) {\n      data[key] = value;\n    } else {\n      dropped.push(key);\n    }\n  });\n  return { data, dropped };\n}\n\nfunction filterHeaders(rawHeaders = {}) {\n  const allowList = [\n    \"user-agent\",\n    \"accept\",\n    \"accept-language\",\n    \"accept-encoding\",\n    \"referer\",\n    \"sec-ch-ua\",\n    \"sec-ch-ua-mobile\",\n    \"sec-ch-ua-platform\",\n    \"sec-fetch-site\",\n    \"sec-fetch-mode\",\n    \"sec-fetch-dest\",\n    \"sec-fetch-user\",\n    \"origin\",\n    \"cache-control\",\n    \"pragma\",\n    \"upgrade-insecure-requests\",\n    \"content-type\",\n  ];\n  const normalized = {};\n  Object.entries(rawHeaders).forEach(([k, v]) => {\n    const lower = k.toLowerCase();\n    if (allowList.includes(lower)) {\n      normalized[k] = v;\n    }\n  });\n  return normalized;\n}\n\nasync function captureHeaders(tabId) {\n  return new Promise((resolve) => {\n    let resolved = false;\n\n    const cleanup = (headers) => {\n      if (resolved) return;\n      resolved = true;\n      chrome.webRequest.onBeforeSendHeaders.removeListener(listener);\n      clearTimeout(timer);\n      resolve(headersArrayToObject(headers || []));\n    };\n\n    const listener = (details) => {\n      if (details.tabId !== tabId) return;\n      cleanup(details.requestHeaders || []);\n    };\n\n    const extraInfo = [\"requestHeaders\"];\n    // extraHeaders 提供更完整的 header 视图，在新版本 Chrome 需要显式声明\n    extraInfo.push(\"extraHeaders\");\n\n    chrome.webRequest.onBeforeSendHeaders.addListener(\n      listener,\n      { urls: [GOOFISH_HOST_PATTERN], tabId },\n      extraInfo,\n    );\n\n    const timer = setTimeout(() => cleanup(null), 2000);\n\n    // 触发一次轻量请求以获取真实请求头\n    chrome.scripting\n      .executeScript({\n        target: { tabId },\n        func: () => {\n          try {\n            fetch(`${window.location.origin}/__codex_probe?ts=${Date.now()}`, {\n              credentials: \"include\",\n              cache: \"no-store\",\n              redirect: \"follow\",\n            }).catch(() => {});\n          } catch (e) {\n            /* ignore */\n          }\n        },\n      })\n      .catch(() => {\n        // 如果注入失败，继续等待可能已有的请求\n      });\n  });\n}\n\nasync function captureCookies(url) {\n  const cookies = await chrome.cookies.getAll({ url });\n  return cookies.map((cookie) => ({\n    name: cookie.name,\n    value: cookie.value,\n    domain: cookie.domain,\n    path: cookie.path,\n    expires: cookie.expirationDate,\n    httpOnly: cookie.httpOnly,\n    secure: cookie.secure,\n    sameSite: mapSameSiteValue(cookie.sameSite),\n  }));\n}\n\nasync function buildSnapshot() {\n  const tab = await getActiveGoofishTab();\n  const pageData = await capturePageData(tab.id);\n  const headers = await captureHeaders(tab.id);\n  const cookies = await captureCookies(new URL(tab.url).origin);\n\n  const filteredEnv = filterEnvData(pageData.env);\n  const localPruned = pruneStorageEntries(pageData.storage.local);\n  const sessionPruned = pruneStorageEntries(pageData.storage.session);\n  const filteredStorage = {\n    local: localPruned.data,\n    session: sessionPruned.data,\n  };\n\n  return {\n    capturedAt: new Date().toISOString(),\n    pageUrl: tab.url,\n    page: pageData.page,\n    env: filteredEnv,\n    storage: filteredStorage,\n    meta: {\n      droppedStorageKeys: {\n        local: localPruned.dropped,\n        session: sessionPruned.dropped,\n      },\n    },\n    headers: filterHeaders(headers),\n    cookies,\n  };\n}\n\nchrome.runtime.onMessage.addListener((message, sender, sendResponse) => {\n  if (!message || message.type !== \"captureSnapshot\") {\n    return false;\n  }\n\n  buildSnapshot()\n    .then((data) => sendResponse({ ok: true, data }))\n    .catch((error) => sendResponse({ ok: false, error: error.message }));\n\n  return true;\n});\n"
  },
  {
    "path": "chrome-extension/manifest.json",
    "content": "{\n  \"manifest_version\": 3,\n  \"name\": \"Xianyu Login State Extractor\",\n  \"version\": \"1.1\",\n  \"description\": \"Extract login state and browser environment for Xianyu monitoring robot\",\n  \"permissions\": [\n    \"activeTab\",\n    \"cookies\",\n    \"scripting\",\n    \"storage\",\n    \"tabs\",\n    \"webRequest\"\n  ],\n  \"host_permissions\": [\n    \"*://*.goofish.com/*\"\n  ],\n  \"background\": {\n    \"service_worker\": \"background.js\"\n  },\n  \"action\": {\n    \"default_popup\": \"popup.html\",\n    \"default_title\": \"Extract Xianyu Login State\"\n  }\n}\n"
  },
  {
    "path": "chrome-extension/popup.html",
    "content": "<!DOCTYPE html>\n<html>\n<meta charset=\"UTF-8\">\n<head>\n  <style>\n    body {\n      width: 400px;\n      padding: 20px;\n      font-family: Arial, sans-serif;\n    }\n    #stateOutput {\n      width: 100%;\n      height: 300px;\n      font-family: monospace;\n      font-size: 12px;\n      white-space: pre-wrap;\n      overflow-y: auto;\n    }\n    button {\n      margin: 10px 0;\n      padding: 10px;\n      background-color: #4CAF50;\n      color: white;\n      border: none;\n      cursor: pointer;\n      width: 100%;\n    }\n    button:hover {\n      background-color: #45a049;\n    }\n    .status {\n      margin: 10px 0;\n      padding: 10px;\n      border-radius: 4px;\n    }\n    .success {\n      background-color: #dff0d8;\n      color: #3c763d;\n    }\n    .error {\n      background-color: #f2dede;\n      color: #a94442;\n    }\n  </style>\n</head>\n<body>\n  <h2>Xianyu Login State Extractor</h2>\n  <button id=\"extractBtn\">1.点击获取环境+登录状态</button>\n  <div id=\"status\"></div>\n  <textarea id=\"stateOutput\" readonly></textarea>\n  <button id=\"copyBtn\">2.点击复制</button>\n\n  <script src=\"popup.js\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "chrome-extension/popup.js",
    "content": "// Popup script for the Chrome extension\ndocument.addEventListener('DOMContentLoaded', function() {\n  const extractBtn = document.getElementById('extractBtn');\n  const copyBtn = document.getElementById('copyBtn');\n  const stateOutput = document.getElementById('stateOutput');\n  const statusDiv = document.getElementById('status');\n\n  let latestSnapshot = null;\n\n  function setLoading(isLoading) {\n    extractBtn.disabled = isLoading;\n    extractBtn.textContent = isLoading ? '采集中，请稍候...' : '1.点击获取环境+登录状态';\n  }\n\n  function updateStatus(message, isSuccess = false) {\n    statusDiv.textContent = message;\n    statusDiv.className = 'status ' + (isSuccess ? 'success' : 'error');\n    setTimeout(() => {\n      statusDiv.textContent = '';\n      statusDiv.className = 'status';\n    }, 4000);\n  }\n\n  function renderSnapshot(snapshot) {\n    latestSnapshot = snapshot;\n    stateOutput.value = JSON.stringify(snapshot, null, 2);\n  }\n\n  async function captureSnapshot() {\n    setLoading(true);\n    updateStatus('正在采集浏览器环境与登录状态...');\n    stateOutput.value = '';\n\n    chrome.runtime.sendMessage({ type: 'captureSnapshot' }, (response) => {\n      setLoading(false);\n\n      if (chrome.runtime.lastError) {\n        updateStatus('通信失败: ' + chrome.runtime.lastError.message);\n        return;\n      }\n      if (!response || !response.ok) {\n        updateStatus('采集失败: ' + (response?.error || '未知错误'));\n        return;\n      }\n\n      renderSnapshot(response.data);\n      updateStatus('采集完成，已生成JSON', true);\n    });\n  }\n\n  function copySnapshot() {\n    if (!stateOutput.value) {\n      updateStatus('没有可复制的数据');\n      return;\n    }\n    navigator.clipboard.writeText(stateOutput.value)\n      .then(() => updateStatus('已复制到剪贴板', true))\n      .catch(err => updateStatus('复制失败: ' + err));\n  }\n\n  extractBtn.addEventListener('click', captureSnapshot);\n  copyBtn.addEventListener('click', copySnapshot);\n});\n"
  },
  {
    "path": "config.json.example",
    "content": "[\n  {\n    \"task_name\": \"苹果watch S10\",\n    \"enabled\": true,\n    \"keyword\": \"苹果watch S10\",\n    \"description\": \"九成新，充电线包装盒齐全，无明显磕碰，卖家信用优秀\",\n    \"max_pages\": 10,\n    \"personal_only\": true,\n    \"min_price\": \"8000\",\n    \"max_price\": \"2000\",\n    \"cron\": null,\n    \"ai_prompt_base_file\": \"prompts/base_prompt.txt\",\n    \"ai_prompt_criteria_file\": \"prompts/苹果watch_s10_criteria.txt\",\n    \"account_state_file\": \"state/acc1.json\",\n    \"free_shipping\": true,\n    \"new_publish_option\": \"14天内\",\n    \"region\": \"江苏/南京/全南京\",\n    \"is_running\": false\n  }\n]\n"
  },
  {
    "path": "desktop_launcher.py",
    "content": "\"\"\"\n桌面启动入口\n使用 PyInstaller 打包后作为单一可执行文件的入口，自动启动 FastAPI 服务并打开浏览器。\n\"\"\"\nimport os\nimport sys\nimport time\nimport webbrowser\nfrom pathlib import Path\n\nimport uvicorn\n\n# PyInstaller 运行时资源目录：_MEIPASS；未打包时则为当前文件所在目录\nBASE_DIR = Path(getattr(sys, \"_MEIPASS\", Path(__file__).resolve().parent))\n\n\ndef _prepare_environment() -> None:\n    \"\"\"确保工作目录和模块路径正确\"\"\"\n    os.chdir(BASE_DIR)\n    if str(BASE_DIR) not in sys.path:\n        sys.path.insert(0, str(BASE_DIR))\n\n\ndef run_app() -> None:\n    \"\"\"启动 FastAPI 应用并自动打开浏览器\"\"\"\n    _prepare_environment()\n\n    from src.app import app\n    from src.infrastructure.config.settings import settings\n\n    # 先尝试打开浏览器，稍等服务起来\n    url = f\"http://127.0.0.1:{settings.server_port}\"\n    webbrowser.open(url)\n    time.sleep(0.5)\n\n    uvicorn.run(\n        app,\n        host=\"127.0.0.1\",\n        port=settings.server_port,\n        log_level=\"info\",\n        reload=False,\n    )\n\n\nif __name__ == \"__main__\":\n    run_app()\n"
  },
  {
    "path": "docker-compose.dev.yaml",
    "content": "services:\n  app:\n    build: .\n    container_name: ai-goofish-monitor-app\n    init: true\n    ports:\n      - \"8000:8000\"\n    env_file:\n      - .env\n    volumes:\n      - .:/app\n      - /app/dist\n    restart: unless-stopped\n"
  },
  {
    "path": "docker-compose.yaml",
    "content": "services:\n  app:\n    image: ${APP_IMAGE:-ghcr.io/usagi-org/ai-goofish:latest}\n    container_name: ai-goofish-monitor-app\n    pull_policy: always\n    init: true\n    ports:\n      - \"8000:8000\"\n    env_file:\n      - .env\n    environment:\n      APP_DATABASE_FILE: /app/data/app.sqlite3\n    volumes:\n      - ./.env:/app/.env\n      - ./data:/app/data\n      - ./state:/app/state\n      - ./config.json:/app/config.json\n      - ./prompts:/app/prompts\n      - ./jsonl:/app/jsonl\n      - ./logs:/app/logs\n      - ./images:/app/images\n      - ./price_history:/app/price_history\n    restart: unless-stopped\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[tool.pytest.ini_options]\naddopts = \"-v --tb=short\"\ntestpaths = [\"tests\"]\npython_files = [\"test_*.py\"]\npython_classes = [\"Test*\"]\npython_functions = [\"test_*\"]\nmarkers = [\n    \"live: real traffic smoke tests that require real credentials and external services\",\n    \"live_slow: slower optional live smoke tests such as AI task generation\",\n]\n\n[tool.coverage.run]\nsource = [\"src\"]\n\n[tool.coverage.report]\nexclude_lines = [\n    \"pragma: no cover\",\n    \"def __repr__\",\n    \"raise AssertionError\",\n    \"raise NotImplementedError\",\n    \"if __name__ == .__main__.:\",\n]\n"
  },
  {
    "path": "requirements-runtime.txt",
    "content": "python-dotenv\nplaywright\nrequests\nopenai\nfastapi\nuvicorn[standard]\npydantic-settings\njinja2\naiofiles\npython-socks\napscheduler\nhttpx[socks]\nPillow\npyzbar\nqrcode\n"
  },
  {
    "path": "requirements.txt",
    "content": "python-dotenv\nplaywright\nrequests\nopenai\nfastapi\nuvicorn[standard]\npydantic-settings\njinja2\naiofiles\npython-socks\napscheduler\nhttpx[socks]\nPillow\npyzbar\nqrcode\npytest\npytest-asyncio\ncoverage\n"
  },
  {
    "path": "run_live_smoke.sh",
    "content": "#!/bin/bash\n\nset -euo pipefail\n\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nNC='\\033[0m'\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\ncd \"$SCRIPT_DIR\"\n\nPYTHON_CMD=\"${PYTHON_CMD:-python3}\"\nMARK_EXPRESSION=\"\"\nDRY_RUN=false\nWITH_GENERATION=true\nPYTEST_ARGS=()\nTASK_CREATE_TEST=\"tests/integration/test_api_tasks.py::test_create_list_update_delete_task\"\nTEST_TARGETS=(\n    \"$TASK_CREATE_TEST\"\n    \"tests/live\"\n)\n\nusage() {\n    cat <<'EOF'\n用法:\n  ./run_live_smoke.sh [选项] [-- pytest额外参数]\n\n选项:\n  --keyword <关键词>           覆盖 LIVE_TEST_KEYWORD\n  --account-file <路径>        覆盖 LIVE_TEST_ACCOUNT_STATE_FILE\n  --task-name <名称>           覆盖 LIVE_TEST_TASK_NAME\n  --timeout <秒>               覆盖 LIVE_TIMEOUT_SECONDS\n  --min-items <数量>           覆盖 LIVE_EXPECT_MIN_ITEMS\n  --debug-limit <数量>         覆盖 LIVE_TEST_DEBUG_LIMIT（默认 1，仅分析前 N 个新商品）\n  --with-generation            显式开启 live_slow（默认已开启）\n  --without-generation         关闭 live_slow，只执行主 smoke\n  --dry-run                    只打印配置和将执行的命令，不真正运行\n  --help                       显示帮助\n\n示例:\n  ./run_live_smoke.sh\n  ./run_live_smoke.sh --keyword \"MacBook Air M1\" --min-items 2\n  ./run_live_smoke.sh --without-generation\n  ./run_live_smoke.sh -- -k live_real_traffic\n\n说明:\n  0. 默认先执行任务创建 CRUD 集成测试，再执行 tests/live 真实流量 smoke\n  1. 脚本会自动设置 RUN_LIVE_TESTS=1\n  2. 若未设置 LIVE_TEST_ACCOUNT_STATE_FILE，会自动尝试使用 state/ 下第一个 *.json\n  3. 默认使用 PYTEST_DISABLE_PLUGIN_AUTOLOAD=1，避免本机第三方 pytest 插件干扰\n  4. 默认设置 LIVE_TEST_DEBUG_LIMIT=1，使冒烟测试只抓取并分析 1 个新商品\nEOF\n}\n\nrequire_value() {\n    local option=\"$1\"\n    local value=\"${2:-}\"\n    if [[ -z \"$value\" ]]; then\n        echo -e \"${RED}错误:${NC} ${option} 需要一个值\"\n        exit 1\n    fi\n}\n\nresolve_default_account_file() {\n    local first_match=\"\"\n    while IFS= read -r file; do\n        first_match=\"$file\"\n        break\n    done < <(find \"$SCRIPT_DIR/state\" -maxdepth 1 -type f -name '*.json' | sort)\n    printf '%s' \"$first_match\"\n}\n\nwhile [[ $# -gt 0 ]]; do\n    case \"$1\" in\n        --keyword)\n            require_value \"$1\" \"${2:-}\"\n            export LIVE_TEST_KEYWORD=\"$2\"\n            shift 2\n            ;;\n        --account-file)\n            require_value \"$1\" \"${2:-}\"\n            export LIVE_TEST_ACCOUNT_STATE_FILE=\"$2\"\n            shift 2\n            ;;\n        --task-name)\n            require_value \"$1\" \"${2:-}\"\n            export LIVE_TEST_TASK_NAME=\"$2\"\n            shift 2\n            ;;\n        --timeout)\n            require_value \"$1\" \"${2:-}\"\n            export LIVE_TIMEOUT_SECONDS=\"$2\"\n            shift 2\n            ;;\n        --min-items)\n            require_value \"$1\" \"${2:-}\"\n            export LIVE_EXPECT_MIN_ITEMS=\"$2\"\n            shift 2\n            ;;\n        --debug-limit)\n            require_value \"$1\" \"${2:-}\"\n            export LIVE_TEST_DEBUG_LIMIT=\"$2\"\n            shift 2\n            ;;\n        --with-generation)\n            WITH_GENERATION=true\n            shift\n            ;;\n        --without-generation)\n            WITH_GENERATION=false\n            shift\n            ;;\n        --dry-run)\n            DRY_RUN=true\n            shift\n            ;;\n        --help|-h)\n            usage\n            exit 0\n            ;;\n        --)\n            shift\n            PYTEST_ARGS+=(\"$@\")\n            break\n            ;;\n        *)\n            PYTEST_ARGS+=(\"$1\")\n            shift\n            ;;\n    esac\ndone\n\nif ! command -v \"$PYTHON_CMD\" >/dev/null 2>&1; then\n    echo -e \"${RED}错误:${NC} 未找到 Python 命令: $PYTHON_CMD\"\n    exit 1\nfi\n\nif ! \"$PYTHON_CMD\" -m pytest --version >/dev/null 2>&1; then\n    echo -e \"${RED}错误:${NC} 当前 Python 环境缺少 pytest\"\n    exit 1\nfi\n\nif ! \"$PYTHON_CMD\" -m playwright --version >/dev/null 2>&1; then\n    echo -e \"${RED}错误:${NC} 当前 Python 环境缺少 Playwright，请先安装浏览器依赖\"\n    exit 1\nfi\n\nexport RUN_LIVE_TESTS=1\nexport PYTEST_DISABLE_PLUGIN_AUTOLOAD=\"${PYTEST_DISABLE_PLUGIN_AUTOLOAD:-1}\"\nexport LIVE_TEST_KEYWORD=\"${LIVE_TEST_KEYWORD:-MacBook Pro M2}\"\nexport LIVE_TEST_TASK_NAME=\"${LIVE_TEST_TASK_NAME:-Live Smoke Task}\"\nexport LIVE_EXPECT_MIN_ITEMS=\"${LIVE_EXPECT_MIN_ITEMS:-1}\"\nexport LIVE_TEST_DEBUG_LIMIT=\"${LIVE_TEST_DEBUG_LIMIT:-1}\"\nexport LIVE_TIMEOUT_SECONDS=\"${LIVE_TIMEOUT_SECONDS:-180}\"\n\nif [[ -z \"${LIVE_TEST_ACCOUNT_STATE_FILE:-}\" ]]; then\n    DEFAULT_ACCOUNT_FILE=\"$(resolve_default_account_file)\"\n    if [[ -n \"$DEFAULT_ACCOUNT_FILE\" ]]; then\n        export LIVE_TEST_ACCOUNT_STATE_FILE=\"$DEFAULT_ACCOUNT_FILE\"\n    fi\nfi\n\nif [[ -z \"${LIVE_TEST_ACCOUNT_STATE_FILE:-}\" ]]; then\n    echo -e \"${RED}错误:${NC} 未找到 live 登录态文件。请使用 --account-file 指定，或在 state/ 下放置 *.json\"\n    exit 1\nfi\n\nif [[ ! -f \"${LIVE_TEST_ACCOUNT_STATE_FILE}\" ]]; then\n    echo -e \"${RED}错误:${NC} 登录态文件不存在: ${LIVE_TEST_ACCOUNT_STATE_FILE}\"\n    exit 1\nfi\n\nif [[ \"$WITH_GENERATION\" == \"true\" ]]; then\n    export LIVE_ENABLE_TASK_GENERATION=1\n    MARK_EXPRESSION=\"\"\nelse\n    export LIVE_ENABLE_TASK_GENERATION=0\n    MARK_EXPRESSION=\"not live_slow\"\nfi\n\necho -e \"${GREEN}========================================${NC}\"\necho -e \"${GREEN}闲鱼真实流量 Live Smoke 一键测试${NC}\"\necho -e \"${GREEN}========================================${NC}\"\necho -e \"${YELLOW}Python:${NC} $PYTHON_CMD\"\necho -e \"${YELLOW}关键词:${NC} ${LIVE_TEST_KEYWORD}\"\necho -e \"${YELLOW}任务名:${NC} ${LIVE_TEST_TASK_NAME}\"\necho -e \"${YELLOW}登录态:${NC} ${LIVE_TEST_ACCOUNT_STATE_FILE}\"\necho -e \"${YELLOW}最少结果数:${NC} ${LIVE_EXPECT_MIN_ITEMS}\"\necho -e \"${YELLOW}抓取/分析商品上限:${NC} ${LIVE_TEST_DEBUG_LIMIT}\"\necho -e \"${YELLOW}超时(秒):${NC} ${LIVE_TIMEOUT_SECONDS}\"\necho -e \"${YELLOW}任务生成慢用例:${NC} ${LIVE_ENABLE_TASK_GENERATION}\"\necho -e \"${YELLOW}任务创建前置用例:${NC} ${TASK_CREATE_TEST}\"\nif [[ -n \"$MARK_EXPRESSION\" ]]; then\n    echo -e \"${YELLOW}Pytest Marker:${NC} ${MARK_EXPRESSION}\"\nelse\n    echo -e \"${YELLOW}Pytest Marker:${NC} <none>\"\nfi\necho -e \"${YELLOW}禁用插件自动加载:${NC} ${PYTEST_DISABLE_PLUGIN_AUTOLOAD}\"\n\nCMD=(\n    \"$PYTHON_CMD\" -m pytest\n    \"${TEST_TARGETS[@]}\"\n    -v\n)\n\nif [[ -n \"$MARK_EXPRESSION\" ]]; then\n    CMD+=(-m \"$MARK_EXPRESSION\")\nfi\n\nif [[ ${#PYTEST_ARGS[@]} -gt 0 ]]; then\n    CMD+=(\"${PYTEST_ARGS[@]}\")\nfi\n\necho -e \"${YELLOW}执行命令:${NC} ${CMD[*]}\"\n\nif [[ \"$DRY_RUN\" == \"true\" ]]; then\n    echo -e \"${GREEN}Dry run 完成，未实际执行测试。${NC}\"\n    exit 0\nfi\n\n\"${CMD[@]}\"\n"
  },
  {
    "path": "spider_v2.py",
    "content": "import asyncio\nimport sys\nimport os\nimport argparse\nimport json\nimport signal\nimport contextlib\nimport re\n\nfrom src.config import STATE_FILE\nfrom src.infrastructure.persistence.sqlite_task_repository import SqliteTaskRepository\nfrom src.scraper import scrape_xianyu\n\n\nasync def main():\n    parser = argparse.ArgumentParser(\n        description=\"闲鱼商品监控脚本，支持多任务配置和实时AI分析。\",\n        epilog=\"\"\"\n使用示例:\n  # 运行 config.json 中定义的所有任务\n  python spider_v2.py\n\n  # 只运行名为 \"Sony A7M4\" 的任务 (通常由调度器调用)\n  python spider_v2.py --task-name \"Sony A7M4\"\n\n  # 调试模式: 运行所有任务，但每个任务只处理前3个新发现的商品\n  python spider_v2.py --debug-limit 3\n\"\"\",\n        formatter_class=argparse.RawDescriptionHelpFormatter\n    )\n    parser.add_argument(\"--debug-limit\", type=int, default=0, help=\"调试模式：每个任务仅处理前 N 个新商品（0 表示无限制）\")\n    parser.add_argument(\"--config\", type=str, help=\"指定任务配置文件路径（传入时优先读取 JSON）\")\n    parser.add_argument(\"--task-name\", type=str, help=\"只运行指定名称的单个任务 (用于定时任务调度)\")\n    args = parser.parse_args()\n\n    if args.config:\n        if not os.path.exists(args.config):\n            sys.exit(f\"错误: 配置文件 '{args.config}' 不存在。\")\n        try:\n            with open(args.config, 'r', encoding='utf-8') as f:\n                tasks_config = json.load(f)\n        except (json.JSONDecodeError, IOError) as e:\n            sys.exit(f\"错误: 读取或解析配置文件 '{args.config}' 失败: {e}\")\n    else:\n        repository = SqliteTaskRepository()\n        tasks = await repository.find_all()\n        tasks_config = [task.dict() for task in tasks]\n\n    def normalize_keywords(value):\n        if value is None:\n            return []\n        if isinstance(value, str):\n            raw_values = re.split(r\"[\\n,]+\", value)\n        elif isinstance(value, (list, tuple, set)):\n            raw_values = list(value)\n        else:\n            raw_values = [value]\n\n        normalized = []\n        seen = set()\n        for item in raw_values:\n            text = str(item).strip()\n            if not text:\n                continue\n            key = text.lower()\n            if key in seen:\n                continue\n            seen.add(key)\n            normalized.append(text)\n        return normalized\n\n    def flatten_legacy_groups(groups):\n        merged = []\n        for group in groups or []:\n            if isinstance(group, dict):\n                merged.extend(normalize_keywords(group.get(\"include_keywords\")))\n        return normalize_keywords(merged)\n\n    def has_bound_account(tasks: list) -> bool:\n        for task in tasks:\n            account = task.get(\"account_state_file\")\n            if isinstance(account, str) and account.strip():\n                return True\n        return False\n\n    def has_any_state_file() -> bool:\n        state_dir = os.getenv(\"ACCOUNT_STATE_DIR\", \"state\").strip().strip('\"').strip(\"'\")\n        if os.path.isdir(state_dir):\n            for name in os.listdir(state_dir):\n                if name.endswith(\".json\"):\n                    return True\n        return False\n\n    if not os.path.exists(STATE_FILE) and not has_bound_account(tasks_config) and not has_any_state_file():\n        sys.exit(\n            f\"错误: 未找到登录状态文件。请在 state/ 中添加账号或配置 account_state_file。\"\n        )\n\n    # 读取所有prompt文件内容（关键词模式不需要加载prompt）\n    for task in tasks_config:\n        decision_mode = str(task.get(\"decision_mode\", \"ai\")).strip().lower()\n        if decision_mode not in {\"ai\", \"keyword\"}:\n            decision_mode = \"ai\"\n        task[\"decision_mode\"] = decision_mode\n        keyword_rules = task.get(\"keyword_rules\")\n        if keyword_rules is None and task.get(\"keyword_rule_groups\") is not None:\n            task[\"keyword_rules\"] = flatten_legacy_groups(task.get(\"keyword_rule_groups\") or [])\n        else:\n            task[\"keyword_rules\"] = normalize_keywords(keyword_rules)\n\n        if decision_mode == \"keyword\":\n            task[\"ai_prompt_text\"] = \"\"\n            continue\n\n        if task.get(\"enabled\", False) and task.get(\"ai_prompt_base_file\") and task.get(\"ai_prompt_criteria_file\"):\n            try:\n                with open(task[\"ai_prompt_base_file\"], 'r', encoding='utf-8') as f_base:\n                    base_prompt = f_base.read()\n                with open(task[\"ai_prompt_criteria_file\"], 'r', encoding='utf-8') as f_criteria:\n                    criteria_text = f_criteria.read()\n                \n                # 动态组合成最终的Prompt\n                task['ai_prompt_text'] = base_prompt.replace(\"{{CRITERIA_SECTION}}\", criteria_text)\n                \n                # 验证生成的prompt是否有效\n                if len(task['ai_prompt_text']) < 100:\n                    print(f\"警告: 任务 '{task['task_name']}' 生成的prompt过短 ({len(task['ai_prompt_text'])} 字符)，可能存在问题。\")\n                elif \"{{CRITERIA_SECTION}}\" in task['ai_prompt_text']:\n                    print(f\"警告: 任务 '{task['task_name']}' 的prompt中仍包含占位符，替换可能失败。\")\n                else:\n                    print(f\"✅ 任务 '{task['task_name']}' 的prompt生成成功，长度: {len(task['ai_prompt_text'])} 字符\")\n\n            except FileNotFoundError as e:\n                print(f\"警告: 任务 '{task['task_name']}' 的prompt文件缺失: {e}，该任务的AI分析将被跳过。\")\n                task['ai_prompt_text'] = \"\"\n            except Exception as e:\n                print(f\"错误: 任务 '{task['task_name']}' 处理prompt文件时发生异常: {e}，该任务的AI分析将被跳过。\")\n                task['ai_prompt_text'] = \"\"\n        elif task.get(\"enabled\", False) and task.get(\"ai_prompt_file\"):\n            try:\n                with open(task[\"ai_prompt_file\"], 'r', encoding='utf-8') as f:\n                    task['ai_prompt_text'] = f.read()\n                print(f\"✅ 任务 '{task['task_name']}' 的prompt文件读取成功，长度: {len(task['ai_prompt_text'])} 字符\")\n            except FileNotFoundError:\n                print(f\"警告: 任务 '{task['task_name']}' 的prompt文件 '{task['ai_prompt_file']}' 未找到，该任务的AI分析将被跳过。\")\n                task['ai_prompt_text'] = \"\"\n            except Exception as e:\n                print(f\"错误: 任务 '{task['task_name']}' 读取prompt文件时发生异常: {e}，该任务的AI分析将被跳过。\")\n                task['ai_prompt_text'] = \"\"\n\n    print(\"\\n--- 开始执行监控任务 ---\")\n    if args.debug_limit > 0:\n        print(f\"** 调试模式已激活，每个任务最多处理 {args.debug_limit} 个新商品 **\")\n    \n    if args.task_name:\n        print(f\"** 定时任务模式：只执行任务 '{args.task_name}' **\")\n\n    print(\"--------------------\")\n\n    active_task_configs = []\n    if args.task_name:\n        # 如果指定了任务名称，只查找该任务\n        task_found = next((task for task in tasks_config if task.get('task_name') == args.task_name), None)\n        if task_found:\n            if task_found.get(\"enabled\", False):\n                active_task_configs.append(task_found)\n            else:\n                print(f\"任务 '{args.task_name}' 已被禁用，跳过执行。\")\n        else:\n            print(f\"错误：在配置文件中未找到名为 '{args.task_name}' 的任务。\")\n            return\n    else:\n        # 否则，按原计划加载所有启用的任务\n        active_task_configs = [task for task in tasks_config if task.get(\"enabled\", False)]\n\n    if not active_task_configs:\n        print(\"没有需要执行的任务，程序退出。\")\n        return\n\n    # 为每个启用的任务创建一个异步执行协程\n    stop_event = asyncio.Event()\n    loop = asyncio.get_running_loop()\n    for sig in (signal.SIGTERM, signal.SIGINT):\n        try:\n            loop.add_signal_handler(sig, stop_event.set)\n        except NotImplementedError:\n            pass\n\n    tasks = []\n    for task_conf in active_task_configs:\n        print(f\"-> 任务 '{task_conf['task_name']}' 已加入执行队列。\")\n        tasks.append(asyncio.create_task(scrape_xianyu(task_config=task_conf, debug_limit=args.debug_limit)))\n\n    async def _shutdown_watcher():\n        await stop_event.wait()\n        print(\"\\n收到终止信号，正在优雅退出，取消所有爬虫任务...\")\n        for t in tasks:\n            if not t.done():\n                t.cancel()\n\n    shutdown_task = asyncio.create_task(_shutdown_watcher())\n\n    try:\n        # 并发执行所有任务\n        results = await asyncio.gather(*tasks, return_exceptions=True)\n    finally:\n        shutdown_task.cancel()\n        with contextlib.suppress(asyncio.CancelledError):\n            await shutdown_task\n\n    print(\"\\n--- 所有任务执行完毕 ---\")\n    for i, result in enumerate(results):\n        task_name = active_task_configs[i]['task_name']\n        if isinstance(result, Exception):\n            print(f\"任务 '{task_name}' 因异常而终止: {result}\")\n        else:\n            print(f\"任务 '{task_name}' 正常结束，本次运行共处理了 {result} 个新商品。\")\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "src/__init__.py",
    "content": "# This file makes src a Python package\n"
  },
  {
    "path": "src/ai_handler.py",
    "content": "import asyncio\nimport base64\nimport json\nimport os\nimport re\nimport sys\nimport shutil\nimport traceback\nfrom datetime import datetime, timedelta\nfrom urllib.parse import urlencode, urlparse, urlunparse, parse_qsl\n\nimport requests\n\n# 设置标准输出编码为UTF-8，解决Windows控制台编码问题\nif sys.platform.startswith('win'):\n    import codecs\n    sys.stdout = codecs.getwriter('utf-8')(sys.stdout.detach())\n    sys.stderr = codecs.getwriter('utf-8')(sys.stderr.detach())\n\nfrom src.config import (\n    AI_DEBUG_MODE,\n    IMAGE_DOWNLOAD_HEADERS,\n    IMAGE_SAVE_DIR,\n    TASK_IMAGE_DIR_PREFIX,\n    MODEL_NAME,\n    ENABLE_RESPONSE_FORMAT,\n    client,\n)\nfrom src.ai_message_builder import (\n    build_analysis_text_prompt,\n    build_user_message_content,\n)\nfrom src.services.ai_response_parser import (\n    EmptyAIResponseError,\n    extract_ai_response_content,\n    parse_ai_response_json,\n)\nfrom src.services.ai_request_compat import (\n    CHAT_COMPLETIONS_API_MODE,\n    RESPONSES_API_MODE,\n    build_ai_request_params,\n    create_ai_response_async,\n    is_chat_completions_api_unsupported_error,\n    is_json_output_unsupported_error,\n    is_responses_api_unsupported_error,\n    is_temperature_unsupported_error,\n    remove_temperature_param,\n)\nfrom src.services.notification_service import build_notification_service\nfrom src.utils import convert_goofish_link, retry_on_failure\n\n\ndef _positive_int(value, default: int) -> int:\n    try:\n        return max(1, int(value))\n    except (TypeError, ValueError):\n        return default\n\n\nDEFAULT_IMAGE_DOWNLOAD_CONCURRENCY = max(\n    1,\n    _positive_int(os.getenv(\"IMAGE_DOWNLOAD_CONCURRENCY\", \"3\"), 3),\n)\n\n\ndef safe_print(text):\n    \"\"\"安全的打印函数，处理编码错误\"\"\"\n    try:\n        print(text)\n    except UnicodeEncodeError:\n        # 如果遇到编码错误，尝试用ASCII编码并忽略无法编码的字符\n        try:\n            print(text.encode('ascii', errors='ignore').decode('ascii'))\n        except:\n            # 如果还是失败，打印一个简化的消息\n            print(\"[输出包含无法显示的字符]\")\n\n\ndef _build_debug_request_summary(api_mode: str, request_params: dict) -> dict:\n    summary = {\n        \"api_mode\": api_mode,\n        \"model\": request_params.get(\"model\"),\n    }\n    if \"temperature\" in request_params:\n        summary[\"temperature\"] = request_params[\"temperature\"]\n    if \"max_output_tokens\" in request_params:\n        summary[\"max_output_tokens\"] = request_params[\"max_output_tokens\"]\n    if \"max_tokens\" in request_params:\n        summary[\"max_tokens\"] = request_params[\"max_tokens\"]\n    if \"text\" in request_params:\n        summary[\"text\"] = request_params[\"text\"]\n    if \"response_format\" in request_params:\n        summary[\"response_format\"] = request_params[\"response_format\"]\n    if \"input\" in request_params:\n        summary[\"input_content_types\"] = [\n            [item.get(\"type\") for item in message.get(\"content\", [])]\n            for message in request_params[\"input\"]\n        ]\n    if \"messages\" in request_params:\n        summary[\"message_content_types\"] = [\n            _extract_message_content_types(message)\n            for message in request_params[\"messages\"]\n        ]\n    return summary\n\n\ndef _extract_message_content_types(message: dict) -> list[str]:\n    content = message.get(\"content\")\n    if isinstance(content, str):\n        return [\"text\"]\n    if not isinstance(content, list):\n        return [type(content).__name__]\n    return [str(item.get(\"type\")) for item in content if isinstance(item, dict)]\n\n\n@retry_on_failure(retries=2, delay=3)\nasync def _download_single_image(url, save_path):\n    \"\"\"一个带重试的内部函数，用于异步下载单个图片。\"\"\"\n    loop = asyncio.get_running_loop()\n    # 使用 run_in_executor 运行同步的 requests 代码，避免阻塞事件循环\n    response = await loop.run_in_executor(\n        None,\n        lambda: requests.get(url, headers=IMAGE_DOWNLOAD_HEADERS, timeout=20, stream=True)\n    )\n    response.raise_for_status()\n    with open(save_path, 'wb') as f:\n        for chunk in response.iter_content(chunk_size=8192):\n            f.write(chunk)\n    return save_path\n\n\ndef _build_image_save_path(\n    product_id: str,\n    index: int,\n    url: str,\n    task_image_dir: str,\n) -> str:\n    clean_url = url.split('.heic')[0] if '.heic' in url else url\n    file_name_base = os.path.basename(clean_url).split('?')[0]\n    file_name = f\"product_{product_id}_{index}_{file_name_base}\"\n    file_name = re.sub(r'[\\\\/*?:\"<>|]', \"\", file_name)\n    if not os.path.splitext(file_name)[1]:\n        file_name += \".jpg\"\n    return os.path.join(task_image_dir, file_name)\n\n\nasync def download_all_images(product_id, image_urls, task_name=\"default\", concurrency=None):\n    \"\"\"异步下载一个商品的所有图片。如果图片已存在则跳过。支持任务隔离。\"\"\"\n    if not image_urls:\n        return []\n\n    # 为每个任务创建独立的图片目录\n    task_image_dir = os.path.join(IMAGE_SAVE_DIR, f\"{TASK_IMAGE_DIR_PREFIX}{task_name}\")\n    os.makedirs(task_image_dir, exist_ok=True)\n\n    urls = [url.strip() for url in image_urls if url.strip().startswith('http')]\n    if not urls:\n        return []\n\n    max_concurrency = _positive_int(concurrency, DEFAULT_IMAGE_DOWNLOAD_CONCURRENCY)\n    semaphore = asyncio.Semaphore(max_concurrency)\n    total_images = len(urls)\n\n    async def _download_one(index: int, url: str):\n        save_path = _build_image_save_path(product_id, index, url, task_image_dir)\n        if os.path.exists(save_path):\n            safe_print(\n                f\"   [图片] 图片 {index}/{total_images} 已存在，跳过下载: {os.path.basename(save_path)}\"\n            )\n            return save_path\n        async with semaphore:\n            safe_print(f\"   [图片] 正在下载图片 {index}/{total_images}: {url}\")\n            if await _download_single_image(url, save_path):\n                safe_print(\n                    f\"   [图片] 图片 {index}/{total_images} 已成功下载到: {os.path.basename(save_path)}\"\n                )\n                return save_path\n        return None\n\n    tasks = [\n        asyncio.create_task(_download_one(index, url))\n        for index, url in enumerate(urls, start=1)\n    ]\n    results = await asyncio.gather(*tasks, return_exceptions=True)\n\n    saved_paths = []\n    for url, result in zip(urls, results):\n        try:\n            if isinstance(result, Exception):\n                raise result\n            if result:\n                saved_paths.append(result)\n        except Exception as e:\n            safe_print(f\"   [图片] 处理图片 {url} 时发生错误，已跳过此图: {e}\")\n\n    return saved_paths\n\n\ndef cleanup_task_images(task_name):\n    \"\"\"清理指定任务的图片目录\"\"\"\n    task_image_dir = os.path.join(IMAGE_SAVE_DIR, f\"{TASK_IMAGE_DIR_PREFIX}{task_name}\")\n    if os.path.exists(task_image_dir):\n        try:\n            shutil.rmtree(task_image_dir)\n            safe_print(f\"   [清理] 已删除任务 '{task_name}' 的临时图片目录: {task_image_dir}\")\n        except Exception as e:\n            safe_print(f\"   [清理] 删除任务 '{task_name}' 的临时图片目录时出错: {e}\")\n    else:\n        safe_print(f\"   [清理] 任务 '{task_name}' 的临时图片目录不存在: {task_image_dir}\")\n\n\ndef cleanup_ai_logs(logs_dir: str, keep_days: int = 1) -> None:\n    try:\n        cutoff = datetime.now() - timedelta(days=keep_days)\n        for filename in os.listdir(logs_dir):\n            if not filename.endswith(\".log\"):\n                continue\n            try:\n                timestamp = datetime.strptime(filename[:15], \"%Y%m%d_%H%M%S\")\n            except ValueError:\n                continue\n            if timestamp < cutoff:\n                os.remove(os.path.join(logs_dir, filename))\n    except Exception as e:\n        safe_print(f\"   [日志] 清理AI日志时出错: {e}\")\n\n\ndef encode_image_to_base64(image_path):\n    \"\"\"将本地图片文件编码为 Base64 字符串。\"\"\"\n    if not image_path or not os.path.exists(image_path):\n        return None\n    try:\n        with open(image_path, \"rb\") as image_file:\n            return base64.b64encode(image_file.read()).decode('utf-8')\n    except Exception as e:\n        safe_print(f\"编码图片时出错: {e}\")\n        return None\n\n\ndef validate_ai_response_format(parsed_response):\n    \"\"\"验证AI响应的格式是否符合预期结构\"\"\"\n    required_fields = [\n        \"prompt_version\",\n        \"is_recommended\",\n        \"reason\",\n        \"risk_tags\",\n        \"criteria_analysis\"\n    ]\n\n    # 检查顶层字段\n    for field in required_fields:\n        if field not in parsed_response:\n            safe_print(f\"   [AI分析] 警告：响应缺少必需字段 '{field}'\")\n            return False\n\n    # 检查criteria_analysis是否为字典且不为空\n    criteria_analysis = parsed_response.get(\"criteria_analysis\", {})\n    if not isinstance(criteria_analysis, dict) or not criteria_analysis:\n        safe_print(\"   [AI分析] 警告：criteria_analysis必须是非空字典\")\n        return False\n\n    # 检查seller_type字段（所有商品都需要）\n    if \"seller_type\" not in criteria_analysis:\n        safe_print(\"   [AI分析] 警告：criteria_analysis缺少必需字段 'seller_type'\")\n        return False\n\n    # 检查数据类型\n    if not isinstance(parsed_response.get(\"is_recommended\"), bool):\n        safe_print(\"   [AI分析] 警告：is_recommended字段不是布尔类型\")\n        return False\n\n    if not isinstance(parsed_response.get(\"risk_tags\"), list):\n        safe_print(\"   [AI分析] 警告：risk_tags字段不是列表类型\")\n        return False\n\n    return True\n\n\n@retry_on_failure(retries=3, delay=5)\nasync def send_ntfy_notification(product_data, reason):\n    \"\"\"兼容旧调用名，内部统一走 NotificationService。\"\"\"\n    service = build_notification_service()\n    if not service.clients:\n        safe_print(\n            \"警告：未在 .env 文件中配置任何通知服务，跳过通知。\"\n        )\n        return {}\n\n    results = await service.send_notification(product_data, reason)\n    for channel, result in results.items():\n        if result[\"success\"]:\n            safe_print(f\"   -> {channel} 通知发送成功。\")\n            continue\n        safe_print(f\"   -> {channel} 通知发送失败: {result['message']}\")\n    return results\n\n\nasync def get_ai_analysis(product_data, image_paths=None, prompt_text=\"\"):\n    \"\"\"将完整的商品JSON数据和所有图片发送给 AI 进行分析（异步）。\"\"\"\n    if not client:\n        safe_print(\"   [AI分析] 错误：AI客户端未初始化，跳过分析。\")\n        return None\n\n    item_info = product_data.get('商品信息', {})\n    product_id = item_info.get('商品ID', 'N/A')\n\n    safe_print(f\"\\n   [AI分析] 开始分析商品 #{product_id} (含 {len(image_paths or [])} 张图片)...\")\n    safe_print(f\"   [AI分析] 标题: {item_info.get('商品标题', '无')}\")\n\n    if not prompt_text:\n        safe_print(\"   [AI分析] 错误：未提供AI分析所需的prompt文本。\")\n        return None\n\n    product_details_json = json.dumps(product_data, ensure_ascii=False, indent=2)\n    system_prompt = prompt_text\n\n    if AI_DEBUG_MODE:\n        safe_print(\"\\n--- [AI DEBUG] ---\")\n        safe_print(\"--- PRODUCT DATA (JSON) ---\")\n        safe_print(product_details_json)\n        safe_print(\"--- PROMPT TEXT (完整内容) ---\")\n        safe_print(prompt_text)\n        safe_print(\"-------------------\\n\")\n\n    image_data_urls = []\n    if image_paths:\n        for path in image_paths:\n            base64_image = encode_image_to_base64(path)\n            if base64_image:\n                image_data_urls.append(f\"data:image/jpeg;base64,{base64_image}\")\n\n    combined_text_prompt = build_analysis_text_prompt(\n        product_details_json,\n        system_prompt,\n        include_images=bool(image_data_urls),\n    )\n    user_content = build_user_message_content(combined_text_prompt, image_data_urls)\n    messages = [{\"role\": \"user\", \"content\": user_content}]\n\n    # 保存最终传输内容到日志文件\n    try:\n        # 创建logs文件夹\n        logs_dir = os.path.join(\"logs\", \"ai\")\n        os.makedirs(logs_dir, exist_ok=True)\n        cleanup_ai_logs(logs_dir, keep_days=1)\n\n        # 生成日志文件名（当前时间）\n        current_time = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n        log_filename = f\"{current_time}.log\"\n        log_filepath = os.path.join(logs_dir, log_filename)\n\n        task_name = product_data.get(\"任务名称\") or product_data.get(\"任务名\") or \"unknown\"\n        log_payload = {\n            \"timestamp\": current_time,\n            \"task_name\": task_name,\n            \"product_id\": product_id,\n            \"title\": item_info.get(\"商品标题\", \"无\"),\n            \"image_count\": len(image_data_urls),\n        }\n        log_content = json.dumps(log_payload, ensure_ascii=False)\n\n        # 写入日志文件\n        with open(log_filepath, 'w', encoding='utf-8') as f:\n            f.write(log_content)\n\n        safe_print(f\"   [日志] AI分析请求已保存到: {log_filepath}\")\n\n    except Exception as e:\n        safe_print(f\"   [日志] 保存AI分析日志时出错: {e}\")\n\n    # 增强的AI调用，包含更严格的结构化输出控制和重试机制\n    max_retries = 4\n    api_mode = CHAT_COMPLETIONS_API_MODE\n    use_response_format = ENABLE_RESPONSE_FORMAT\n    use_temperature = True\n    for attempt in range(max_retries):\n        try:\n            # 根据重试次数调整参数\n            current_temperature = 0.1 if attempt == 0 else 0.05  # 重试时使用更低的温度\n\n            from src.config import get_ai_request_params\n\n            request_params = build_ai_request_params(\n                api_mode,\n                model=MODEL_NAME,\n                messages=messages,\n                temperature=current_temperature,\n                max_output_tokens=4000,\n                enable_json_output=use_response_format,\n            )\n            if not use_temperature:\n                request_params = remove_temperature_param(request_params)\n\n            request_params = get_ai_request_params(**request_params)\n\n            if AI_DEBUG_MODE:\n                safe_print(f\"\\n--- [AI DEBUG] 第{attempt + 1}次尝试 REQUEST ---\")\n                safe_print(\n                    json.dumps(\n                        _build_debug_request_summary(api_mode, request_params),\n                        ensure_ascii=False,\n                        indent=2,\n                    )\n                )\n                safe_print(\"-----------------------------------\\n\")\n\n            response = await create_ai_response_async(\n                client,\n                api_mode,\n                request_params,\n            )\n            ai_response_content = extract_ai_response_content(response)\n\n            if AI_DEBUG_MODE:\n                safe_print(f\"\\n--- [AI DEBUG] 第{attempt + 1}次尝试 ---\")\n                safe_print(\"--- RAW AI RESPONSE ---\")\n                safe_print(ai_response_content)\n                safe_print(\"---------------------\\n\")\n\n            try:\n                parsed_response = parse_ai_response_json(ai_response_content)\n\n                # 验证响应格式\n                if validate_ai_response_format(parsed_response):\n                    safe_print(f\"   [AI分析] 第{attempt + 1}次尝试成功，响应格式验证通过\")\n                    return parsed_response\n                safe_print(f\"   [AI分析] 第{attempt + 1}次尝试格式验证失败\")\n                if attempt < max_retries - 1:\n                    safe_print(f\"   [AI分析] 准备第{attempt + 2}次重试...\")\n                    continue\n                raise ValueError(\"AI响应格式缺少必需字段或字段类型不正确。\")\n            except json.JSONDecodeError as e:\n                safe_print(f\"   [AI分析] 第{attempt + 1}次尝试JSON解析失败: {e}\")\n                if attempt < max_retries - 1:\n                    safe_print(f\"   [AI分析] 准备第{attempt + 2}次重试...\")\n                    continue\n                raise e\n            except EmptyAIResponseError as e:\n                safe_print(f\"   [AI分析] 第{attempt + 1}次尝试返回空响应: {e}\")\n                if attempt < max_retries - 1:\n                    safe_print(f\"   [AI分析] 准备第{attempt + 2}次重试...\")\n                    continue\n                raise e\n\n        except Exception as e:\n            if (\n                api_mode == CHAT_COMPLETIONS_API_MODE\n                and is_chat_completions_api_unsupported_error(e)\n            ):\n                api_mode = RESPONSES_API_MODE\n                safe_print(\n                    \"   [AI分析] 当前服务未实现 Chat Completions API，后续重试将自动回退到 Responses API。\"\n                )\n            elif api_mode == RESPONSES_API_MODE and is_responses_api_unsupported_error(e):\n                api_mode = CHAT_COMPLETIONS_API_MODE\n                safe_print(\n                    \"   [AI分析] 当前服务未实现 Responses API，后续重试将自动回退到 Chat Completions API。\"\n                )\n            if use_response_format and is_json_output_unsupported_error(e):\n                use_response_format = False\n                safe_print(\n                    \"   [AI分析] 当前模型不支持结构化 JSON 输出，后续重试将自动禁用该参数。\"\n                )\n            if use_temperature and is_temperature_unsupported_error(e):\n                use_temperature = False\n                safe_print(\n                    \"   [AI分析] 当前模型不支持 temperature 参数，后续重试将自动禁用该参数。\"\n                )\n            if AI_DEBUG_MODE:\n                safe_print(f\"\\n--- [AI DEBUG] 第{attempt + 1}次尝试 EXCEPTION ---\")\n                safe_print(repr(e))\n                safe_print(traceback.format_exc())\n                safe_print(\"-------------------------------------\\n\")\n            safe_print(f\"   [AI分析] 第{attempt + 1}次尝试AI调用失败: {e}\")\n            if attempt < max_retries - 1:\n                safe_print(f\"   [AI分析] 准备第{attempt + 2}次重试...\")\n                continue\n            else:\n                raise e\n"
  },
  {
    "path": "src/ai_message_builder.py",
    "content": "\"\"\"\nAI 请求消息构造辅助函数\n\"\"\"\nfrom typing import Dict, List, Union\n\n\nTEXT_ONLY_ANALYSIS_NOTE = (\n    \"补充说明：本次未提供商品图片，请仅根据商品文字字段和卖家信息判断，不要推断图片内容。\"\n)\n\n\ndef build_analysis_text_prompt(\n    product_json: str,\n    prompt_text: str,\n    *,\n    include_images: bool,\n) -> str:\n    note = \"\" if include_images else f\"\\n{TEXT_ONLY_ANALYSIS_NOTE}\\n\"\n    value_note = (\n        \"\\n如果商品 JSON 中包含“价格参考”或 price_insight，请结合价格位置、历史走势、\"\n        \"配置、成色、附件、卖家信息综合判断性价比。\"\n        \"你可以额外输出可选字段 value_score(0-100) 和 value_summary，\"\n        \"但必须保留原有 is_recommended/reason 等字段。\\n\"\n    )\n    return f\"\"\"请基于你的专业知识和我的要求，分析以下完整的商品JSON数据：\n\n```json\n{product_json}\n```\n\n    {prompt_text}\n    {value_note}\n    {note}\"\"\"\n\n\ndef build_user_message_content(\n    text_prompt: str,\n    image_data_urls: List[str],\n) -> Union[str, List[Dict[str, object]]]:\n    if not image_data_urls:\n        return text_prompt\n\n    user_content: List[Dict[str, object]] = [\n        {\"type\": \"image_url\", \"image_url\": {\"url\": url}}\n        for url in image_data_urls\n    ]\n    user_content.append({\"type\": \"text\", \"text\": text_prompt})\n    return user_content\n"
  },
  {
    "path": "src/api/__init__.py",
    "content": ""
  },
  {
    "path": "src/api/dependencies.py",
    "content": "\"\"\"\nFastAPI 依赖注入\n提供服务实例的创建和管理\n\"\"\"\nfrom fastapi import Depends\nfrom src.services.task_service import TaskService\nfrom src.services.notification_service import NotificationService, build_notification_service\nfrom src.services.ai_service import AIAnalysisService\nfrom src.services.process_service import ProcessService\nfrom src.services.scheduler_service import SchedulerService\nfrom src.services.task_generation_service import TaskGenerationService\nfrom src.infrastructure.persistence.sqlite_task_repository import SqliteTaskRepository\nfrom src.infrastructure.external.ai_client import AIClient\n\n\n# 全局 ProcessService 实例（将在 app.py 中设置）\n_process_service_instance = None\n_scheduler_service_instance = None\n_task_generation_service_instance = None\n\n\ndef set_process_service(service: ProcessService):\n    \"\"\"设置全局 ProcessService 实例\"\"\"\n    global _process_service_instance\n    _process_service_instance = service\n\n\ndef set_scheduler_service(service: SchedulerService):\n    \"\"\"设置全局 SchedulerService 实例\"\"\"\n    global _scheduler_service_instance\n    _scheduler_service_instance = service\n\n\ndef set_task_generation_service(service: TaskGenerationService):\n    \"\"\"设置全局 TaskGenerationService 实例\"\"\"\n    global _task_generation_service_instance\n    _task_generation_service_instance = service\n\n\n# 服务依赖注入\ndef get_task_service() -> TaskService:\n    \"\"\"获取任务管理服务实例\"\"\"\n    repository = SqliteTaskRepository()\n    return TaskService(repository)\n\n\ndef get_notification_service() -> NotificationService:\n    \"\"\"获取通知服务实例\"\"\"\n    return build_notification_service()\n\n\ndef get_ai_service() -> AIAnalysisService:\n    \"\"\"获取AI分析服务实例\"\"\"\n    ai_client = AIClient()\n    return AIAnalysisService(ai_client)\n\n\ndef get_process_service() -> ProcessService:\n    \"\"\"获取进程管理服务实例\"\"\"\n    if _process_service_instance is None:\n        raise RuntimeError(\"ProcessService 未初始化\")\n    return _process_service_instance\n\n\ndef get_scheduler_service() -> SchedulerService:\n    \"\"\"获取调度服务实例\"\"\"\n    if _scheduler_service_instance is None:\n        raise RuntimeError(\"SchedulerService 未初始化\")\n    return _scheduler_service_instance\n\n\ndef get_task_generation_service() -> TaskGenerationService:\n    \"\"\"获取任务生成作业服务实例\"\"\"\n    if _task_generation_service_instance is None:\n        raise RuntimeError(\"TaskGenerationService 未初始化\")\n    return _task_generation_service_instance\n"
  },
  {
    "path": "src/api/routes/__init__.py",
    "content": ""
  },
  {
    "path": "src/api/routes/accounts.py",
    "content": "\"\"\"\n闲鱼账号管理路由\n\"\"\"\nimport json\nimport os\nimport re\nimport aiofiles\nfrom fastapi import APIRouter, HTTPException\nfrom pydantic import BaseModel\nfrom typing import List\nfrom src.infrastructure.config.env_manager import env_manager\n\n\nrouter = APIRouter(prefix=\"/api/accounts\", tags=[\"accounts\"])\n\nACCOUNT_NAME_RE = re.compile(r\"^[a-zA-Z0-9_-]{1,50}$\")\n\n\nclass AccountCreate(BaseModel):\n    name: str\n    content: str\n\n\nclass AccountUpdate(BaseModel):\n    content: str\n\n\ndef _strip_quotes(value: str) -> str:\n    if not value:\n        return value\n    if value.startswith((\"\\\"\", \"'\")) and value.endswith((\"\\\"\", \"'\")):\n        return value[1:-1]\n    return value\n\n\ndef _state_dir() -> str:\n    raw = env_manager.get_value(\"ACCOUNT_STATE_DIR\", \"state\") or \"state\"\n    return _strip_quotes(raw.strip())\n\n\ndef _ensure_state_dir(path: str) -> None:\n    os.makedirs(path, exist_ok=True)\n\n\ndef _validate_name(name: str) -> str:\n    trimmed = name.strip()\n    if not trimmed or not ACCOUNT_NAME_RE.match(trimmed):\n        raise HTTPException(status_code=400, detail=\"账号名称只能包含字母、数字、下划线或短横线。\")\n    return trimmed\n\n\ndef _account_path(name: str) -> str:\n    filename = f\"{name}.json\"\n    return os.path.join(_state_dir(), filename)\n\n\ndef _validate_json(content: str) -> None:\n    try:\n        json.loads(content)\n    except json.JSONDecodeError:\n        raise HTTPException(status_code=400, detail=\"提供的内容不是有效的JSON格式。\")\n\n\n@router.get(\"\", response_model=List[dict])\nasync def list_accounts():\n    state_dir = _state_dir()\n    if not os.path.isdir(state_dir):\n        return []\n    files = [f for f in os.listdir(state_dir) if f.endswith(\".json\")]\n    accounts = []\n    for filename in sorted(files):\n        name = filename[:-5]\n        accounts.append({\n            \"name\": name,\n            \"path\": os.path.join(state_dir, filename),\n        })\n    return accounts\n\n\n@router.get(\"/{name}\", response_model=dict)\nasync def get_account(name: str):\n    account_name = _validate_name(name)\n    path = _account_path(account_name)\n    if not os.path.exists(path):\n        raise HTTPException(status_code=404, detail=\"账号不存在\")\n    async with aiofiles.open(path, \"r\", encoding=\"utf-8\") as f:\n        content = await f.read()\n    return {\"name\": account_name, \"path\": path, \"content\": content}\n\n\n@router.post(\"\", response_model=dict)\nasync def create_account(data: AccountCreate):\n    account_name = _validate_name(data.name)\n    _validate_json(data.content)\n    state_dir = _state_dir()\n    _ensure_state_dir(state_dir)\n    path = _account_path(account_name)\n    if os.path.exists(path):\n        raise HTTPException(status_code=409, detail=\"账号已存在\")\n    async with aiofiles.open(path, \"w\", encoding=\"utf-8\") as f:\n        await f.write(data.content)\n    return {\"message\": \"账号已添加\", \"name\": account_name, \"path\": path}\n\n\n@router.put(\"/{name}\", response_model=dict)\nasync def update_account(name: str, data: AccountUpdate):\n    account_name = _validate_name(name)\n    _validate_json(data.content)\n    state_dir = _state_dir()\n    _ensure_state_dir(state_dir)\n    path = _account_path(account_name)\n    if not os.path.exists(path):\n        raise HTTPException(status_code=404, detail=\"账号不存在\")\n    async with aiofiles.open(path, \"w\", encoding=\"utf-8\") as f:\n        await f.write(data.content)\n    return {\"message\": \"账号已更新\", \"name\": account_name, \"path\": path}\n\n\n@router.delete(\"/{name}\", response_model=dict)\nasync def delete_account(name: str):\n    account_name = _validate_name(name)\n    path = _account_path(account_name)\n    if not os.path.exists(path):\n        raise HTTPException(status_code=404, detail=\"账号不存在\")\n    os.remove(path)\n    return {\"message\": \"账号已删除\"}\n"
  },
  {
    "path": "src/api/routes/dashboard.py",
    "content": "\"\"\"\nDashboard 概览路由\n\"\"\"\nfrom fastapi import APIRouter, Depends, HTTPException\n\nfrom src.api.dependencies import get_task_service\nfrom src.services.dashboard_service import build_dashboard_snapshot\nfrom src.services.task_service import TaskService\n\n\nrouter = APIRouter(prefix=\"/api/dashboard\", tags=[\"dashboard\"])\n\n\n@router.get(\"/summary\")\nasync def get_dashboard_summary(\n    task_service: TaskService = Depends(get_task_service),\n):\n    try:\n        tasks = await task_service.get_all_tasks()\n        return await build_dashboard_snapshot(tasks)\n    except Exception as exc:\n        raise HTTPException(status_code=500, detail=f\"加载 dashboard 数据失败: {exc}\")\n"
  },
  {
    "path": "src/api/routes/login_state.py",
    "content": "\"\"\"\n登录状态管理路由\n\"\"\"\nimport os\nimport json\nimport aiofiles\nfrom fastapi import APIRouter, HTTPException\nfrom pydantic import BaseModel\n\n\nrouter = APIRouter(prefix=\"/api/login-state\", tags=[\"login-state\"])\n\n\nclass LoginStateUpdate(BaseModel):\n    \"\"\"登录状态更新模型\"\"\"\n    content: str\n\n\n@router.post(\"\", response_model=dict)\nasync def update_login_state(\n    data: LoginStateUpdate,\n):\n    \"\"\"接收前端发送的登录状态JSON字符串，并保存到 xianyu_state.json\"\"\"\n    state_file = \"xianyu_state.json\"\n\n    try:\n        # 验证是否是有效的JSON\n        json.loads(data.content)\n    except json.JSONDecodeError:\n        raise HTTPException(status_code=400, detail=\"提供的内容不是有效的JSON格式。\")\n\n    try:\n        async with aiofiles.open(state_file, 'w', encoding='utf-8') as f:\n            await f.write(data.content)\n        return {\"message\": f\"登录状态文件 '{state_file}' 已成功更新。\"}\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"写入登录状态文件时出错: {e}\")\n\n\n@router.delete(\"\", response_model=dict)\nasync def delete_login_state():\n    \"\"\"删除 xianyu_state.json 文件\"\"\"\n    state_file = \"xianyu_state.json\"\n\n    if os.path.exists(state_file):\n        try:\n            os.remove(state_file)\n            return {\"message\": \"登录状态文件已成功删除。\"}\n        except OSError as e:\n            raise HTTPException(status_code=500, detail=f\"删除登录状态文件时出错: {e}\")\n\n    return {\"message\": \"登录状态文件不存在，无需删除。\"}\n"
  },
  {
    "path": "src/api/routes/logs.py",
    "content": "\"\"\"\n日志管理路由\n\"\"\"\nimport os\nfrom typing import Optional, Tuple, List\nimport aiofiles\nfrom fastapi import APIRouter, Depends, Query\nfrom fastapi.responses import JSONResponse\nfrom src.api.dependencies import get_task_service\nfrom src.services.task_service import TaskService\nfrom src.utils import resolve_task_log_path\n\n\nrouter = APIRouter(prefix=\"/api/logs\", tags=[\"logs\"])\n\n\nasync def _read_tail_lines(\n    log_file_path: str,\n    offset_lines: int,\n    limit_lines: int,\n    chunk_size: int = 8192\n) -> Tuple[List[str], bool, int]:\n    async with aiofiles.open(log_file_path, 'rb') as f:\n        await f.seek(0, os.SEEK_END)\n        file_size = await f.tell()\n\n        if file_size == 0 or limit_lines <= 0:\n            return [], False, file_size\n\n        offset_lines = max(0, int(offset_lines))\n        limit_lines = max(0, int(limit_lines))\n        lines_needed = offset_lines + limit_lines\n\n        pos = file_size\n        buffer = b\"\"\n        lines: List[bytes] = []\n\n        while pos > 0 and len(lines) < lines_needed:\n            read_size = min(chunk_size, pos)\n            pos -= read_size\n            await f.seek(pos)\n            chunk = await f.read(read_size)\n            buffer = chunk + buffer\n            lines = buffer.splitlines()\n\n        start = max(0, len(lines) - lines_needed)\n        end = max(0, len(lines) - offset_lines)\n        selected = lines[start:end] if end > start else []\n\n        has_more = pos > 0 or len(lines) > lines_needed\n        decoded = [line.decode('utf-8', errors='replace') for line in selected]\n        return decoded, has_more, file_size\n\n\n@router.get(\"\")\nasync def get_logs(\n    from_pos: int = 0,\n    task_id: Optional[int] = Query(default=None, ge=0),\n    task_service: TaskService = Depends(get_task_service),\n):\n    \"\"\"获取日志内容（增量读取）\"\"\"\n    if task_id is None:\n        return JSONResponse(content={\n            \"new_content\": \"请选择任务后查看日志。\",\n            \"new_pos\": 0\n        })\n\n    task = await task_service.get_task(task_id)\n    if not task:\n        return JSONResponse(status_code=404, content={\n            \"new_content\": \"任务不存在或已删除。\",\n            \"new_pos\": 0\n        })\n\n    log_file_path = resolve_task_log_path(task_id, task.task_name)\n\n    if not os.path.exists(log_file_path):\n        return JSONResponse(content={\n            \"new_content\": \"\",\n            \"new_pos\": 0\n        })\n\n    try:\n        async with aiofiles.open(log_file_path, 'rb') as f:\n            await f.seek(0, os.SEEK_END)\n            file_size = await f.tell()\n\n            if from_pos >= file_size:\n                return {\"new_content\": \"\", \"new_pos\": file_size}\n\n            await f.seek(from_pos)\n            new_bytes = await f.read()\n\n        new_content = new_bytes.decode('utf-8', errors='replace')\n        return {\"new_content\": new_content, \"new_pos\": file_size}\n\n    except Exception as e:\n        return JSONResponse(\n            status_code=500,\n            content={\"new_content\": f\"\\n读取日志文件时出错: {e}\", \"new_pos\": from_pos}\n        )  \n\n\n@router.get(\"/tail\")\nasync def get_logs_tail(\n    task_id: Optional[int] = Query(default=None, ge=0),\n    offset_lines: int = Query(default=0, ge=0),\n    limit_lines: int = Query(default=50, ge=1, le=1000),\n    task_service: TaskService = Depends(get_task_service),\n):\n    \"\"\"获取日志尾部内容（按行分页）\"\"\"\n    if task_id is None:\n        return JSONResponse(content={\n            \"content\": \"\",\n            \"has_more\": False,\n            \"next_offset\": 0,\n            \"new_pos\": 0\n        })\n\n    task = await task_service.get_task(task_id)\n    if not task:\n        return JSONResponse(status_code=404, content={\n            \"content\": \"\",\n            \"has_more\": False,\n            \"next_offset\": 0,\n            \"new_pos\": 0\n        })\n\n    log_file_path = resolve_task_log_path(task_id, task.task_name)\n\n    if not os.path.exists(log_file_path):\n        return JSONResponse(content={\n            \"content\": \"\",\n            \"has_more\": False,\n            \"next_offset\": 0,\n            \"new_pos\": 0\n        })\n\n    try:\n        lines, has_more, file_size = await _read_tail_lines(\n            log_file_path,\n            offset_lines=offset_lines,\n            limit_lines=limit_lines\n        )\n        next_offset = offset_lines + len(lines)\n        return {\n            \"content\": \"\\n\".join(lines),\n            \"has_more\": has_more,\n            \"next_offset\": next_offset,\n            \"new_pos\": file_size\n        }\n    except Exception as e:\n        return JSONResponse(\n            status_code=500,\n            content={\n                \"content\": f\"读取日志文件时出错: {e}\",\n                \"has_more\": False,\n                \"next_offset\": offset_lines,\n                \"new_pos\": 0\n            }\n        )\n\n\n@router.delete(\"\", response_model=dict)\nasync def clear_logs(\n    task_id: Optional[int] = Query(default=None, ge=0),\n    task_service: TaskService = Depends(get_task_service),\n):\n    \"\"\"清空日志文件\"\"\"\n    if task_id is None:\n        return {\"message\": \"未指定任务，无法清空日志。\"}\n\n    task = await task_service.get_task(task_id)\n    if not task:\n        return {\"message\": \"任务不存在或已删除。\"}\n\n    log_file_path = resolve_task_log_path(task_id, task.task_name)\n\n    if not os.path.exists(log_file_path):\n        return {\"message\": \"日志文件不存在，无需清空。\"}\n\n    try:\n        async with aiofiles.open(log_file_path, 'w', encoding='utf-8') as f:\n            await f.write(\"\")\n        return {\"message\": \"日志已成功清空。\"}\n    except Exception as e:\n        return JSONResponse(\n            status_code=500,\n            content={\"message\": f\"清空日志文件时出错: {e}\"}\n        )\n\n    if not os.path.exists(log_file_path):\n        return {\"message\": \"日志文件不存在，无需清空。\"}\n\n    try:\n        async with aiofiles.open(log_file_path, 'w', encoding='utf-8') as f:\n            await f.write(\"\")\n        return {\"message\": \"日志已成功清空。\"}\n    except Exception as e:\n        return JSONResponse(\n            status_code=500,\n            content={\"message\": f\"清空日志文件时出错: {e}\"}\n        )\n"
  },
  {
    "path": "src/api/routes/prompts.py",
    "content": "\"\"\"\nPrompt 管理路由\n\"\"\"\nimport os\nimport aiofiles\nfrom fastapi import APIRouter, HTTPException\nfrom pydantic import BaseModel\n\n\nrouter = APIRouter(prefix=\"/api/prompts\", tags=[\"prompts\"])\n\n\nclass PromptUpdate(BaseModel):\n    \"\"\"Prompt 更新模型\"\"\"\n    content: str\n\n\n@router.get(\"\")\nasync def list_prompts():\n    \"\"\"列出所有 prompt 文件\"\"\"\n    prompts_dir = \"prompts\"\n    if not os.path.isdir(prompts_dir):\n        return []\n    return [f for f in os.listdir(prompts_dir) if f.endswith(\".txt\")]\n\n\n@router.get(\"/{filename}\")\nasync def get_prompt(filename: str):\n    \"\"\"获取 prompt 文件内容\"\"\"\n    if \"/\" in filename or \"..\" in filename:\n        raise HTTPException(status_code=400, detail=\"无效的文件名\")\n\n    filepath = os.path.join(\"prompts\", filename)\n    if not os.path.exists(filepath):\n        raise HTTPException(status_code=404, detail=\"Prompt 文件未找到\")\n\n    async with aiofiles.open(filepath, 'r', encoding='utf-8') as f:\n        content = await f.read()\n    return {\"filename\": filename, \"content\": content}\n\n\n@router.put(\"/{filename}\")\nasync def update_prompt(\n    filename: str,\n    prompt_update: PromptUpdate,\n):\n    \"\"\"更新 prompt 文件内容\"\"\"\n    if \"/\" in filename or \"..\" in filename:\n        raise HTTPException(status_code=400, detail=\"无效的文件名\")\n\n    filepath = os.path.join(\"prompts\", filename)\n    if not os.path.exists(filepath):\n        raise HTTPException(status_code=404, detail=\"Prompt 文件未找到\")\n\n    try:\n        async with aiofiles.open(filepath, 'w', encoding='utf-8') as f:\n            await f.write(prompt_update.content)\n        return {\"message\": f\"Prompt 文件 '{filename}' 更新成功\"}\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"写入文件时出错: {e}\")\n"
  },
  {
    "path": "src/api/routes/results.py",
    "content": "\"\"\"\n结果文件管理路由\n\"\"\"\nfrom fastapi import APIRouter, HTTPException, Query\nfrom fastapi.responses import Response\nfrom urllib.parse import quote\n\nfrom src.services.price_history_service import build_price_history_insights\nfrom src.services.result_export_service import build_results_csv\nfrom src.services.result_file_service import (\n    enrich_records_with_price_insight,\n    validate_result_filename,\n)\nfrom src.services.result_storage_service import (\n    build_result_ndjson,\n    delete_result_file_records,\n    list_result_filenames,\n    load_all_result_records,\n    query_result_records,\n    result_file_exists,\n)\n\n\nrouter = APIRouter(prefix=\"/api/results\", tags=[\"results\"])\n\nDEFAULT_EXPORT_FILENAME = \"export.csv\"\n\n\ndef _build_download_headers(export_name: str) -> dict[str, str]:\n    ascii_name = export_name.encode(\"ascii\", \"ignore\").decode(\"ascii\")\n    if ascii_name != export_name or not ascii_name:\n        ascii_name = DEFAULT_EXPORT_FILENAME\n    encoded_name = quote(export_name, safe=\"\")\n    return {\n        \"Content-Disposition\": (\n            f'attachment; filename=\"{ascii_name}\"; '\n            f\"filename*=UTF-8''{encoded_name}\"\n        )\n    }\n\n\n@router.get(\"/files\")\nasync def get_result_files():\n    \"\"\"获取所有结果文件列表\"\"\"\n    return {\"files\": await list_result_filenames()}\n\n\n@router.get(\"/files/{filename:path}\")\nasync def download_result_file(filename: str):\n    \"\"\"下载指定的结果文件\"\"\"\n    if \"..\" in filename or filename.startswith(\"/\"):\n        return {\"error\": \"非法的文件路径\"}\n    if not filename.endswith(\".jsonl\") or not await result_file_exists(filename):\n        return {\"error\": \"文件不存在\"}\n    return Response(\n        content=await build_result_ndjson(filename),\n        media_type=\"application/x-ndjson\",\n        headers={\"Content-Disposition\": f'attachment; filename=\"{filename}\"'},\n    )\n\n\n@router.delete(\"/files/{filename:path}\")\nasync def delete_result_file(filename: str):\n    \"\"\"删除指定的结果文件\"\"\"\n    if \"..\" in filename or filename.startswith(\"/\"):\n        raise HTTPException(status_code=400, detail=\"非法的文件路径\")\n    if not filename.endswith(\".jsonl\"):\n        raise HTTPException(status_code=400, detail=\"只能删除 .jsonl 文件\")\n    deleted_rows = await delete_result_file_records(filename)\n    if deleted_rows <= 0:\n        raise HTTPException(status_code=404, detail=\"文件不存在\")\n    return {\"message\": f\"文件 {filename} 已成功删除\"}\n\n\n@router.get(\"/{filename}\")\nasync def get_result_file_content(\n    filename: str,\n    page: int = Query(1, ge=1),\n    limit: int = Query(20, ge=1, le=100),\n    recommended_only: bool = Query(False),  # 兼容旧参数，等价于 ai_recommended_only\n    ai_recommended_only: bool = Query(False),\n    keyword_recommended_only: bool = Query(False),\n    sort_by: str = Query(\"crawl_time\"),\n    sort_order: str = Query(\"desc\"),\n):\n    \"\"\"读取指定的 .jsonl 文件内容，支持分页、筛选和排序\"\"\"\n    if ai_recommended_only and keyword_recommended_only:\n        raise HTTPException(status_code=400, detail=\"AI推荐筛选与关键词推荐筛选不能同时开启。\")\n\n    if recommended_only and not ai_recommended_only and not keyword_recommended_only:\n        ai_recommended_only = True\n\n    try:\n        validate_result_filename(filename)\n        total_items, items = await query_result_records(\n            filename,\n            ai_recommended_only=ai_recommended_only,\n            keyword_recommended_only=keyword_recommended_only,\n            sort_by=sort_by,\n            sort_order=sort_order,\n            page=page,\n            limit=limit,\n        )\n    except ValueError as exc:\n        raise HTTPException(status_code=400, detail=str(exc))\n    except Exception as exc:\n        raise HTTPException(status_code=500, detail=f\"读取结果文件时出错: {exc}\")\n    if total_items <= 0 and not await result_file_exists(filename):\n        raise HTTPException(status_code=404, detail=\"结果文件未找到\")\n    paginated_results = enrich_records_with_price_insight(items, filename)\n\n    return {\n        \"total_items\": total_items,\n        \"page\": page,\n        \"limit\": limit,\n        \"items\": paginated_results\n    }\n\n\n@router.get(\"/{filename}/insights\")\nasync def get_result_file_insights(filename: str):\n    try:\n        validate_result_filename(filename)\n        keyword = filename.replace(\"_full_data.jsonl\", \"\")\n        return build_price_history_insights(keyword)\n    except ValueError as exc:\n        raise HTTPException(status_code=400, detail=str(exc))\n\n\n@router.get(\"/{filename}/export\")\nasync def export_result_file_content(\n    filename: str,\n    recommended_only: bool = Query(False),\n    ai_recommended_only: bool = Query(False),\n    keyword_recommended_only: bool = Query(False),\n    sort_by: str = Query(\"crawl_time\"),\n    sort_order: str = Query(\"desc\"),\n):\n    if ai_recommended_only and keyword_recommended_only:\n        raise HTTPException(status_code=400, detail=\"AI推荐筛选与关键词推荐筛选不能同时开启。\")\n    if recommended_only and not ai_recommended_only and not keyword_recommended_only:\n        ai_recommended_only = True\n\n    try:\n        validate_result_filename(filename)\n        results = await load_all_result_records(\n            filename,\n            ai_recommended_only=ai_recommended_only,\n            keyword_recommended_only=keyword_recommended_only,\n            sort_by=sort_by,\n            sort_order=sort_order,\n        )\n        csv_text = build_results_csv(\n            enrich_records_with_price_insight(results, filename)\n        )\n    except ValueError as exc:\n        raise HTTPException(status_code=400, detail=str(exc))\n    except Exception as exc:\n        raise HTTPException(status_code=500, detail=f\"导出结果文件时出错: {exc}\")\n    if not results and not await result_file_exists(filename):\n        raise HTTPException(status_code=404, detail=\"结果文件未找到\")\n\n    export_name = filename.replace(\".jsonl\", \".csv\")\n    headers = _build_download_headers(export_name)\n    return Response(content=csv_text, media_type=\"text/csv; charset=utf-8\", headers=headers)\n"
  },
  {
    "path": "src/api/routes/settings.py",
    "content": "\"\"\"\n设置管理路由\n\"\"\"\nimport os\nfrom typing import Optional\n\nfrom dotenv import load_dotenv\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom pydantic import BaseModel, Field\n\nfrom src.api.dependencies import get_process_service\nfrom src.infrastructure.config.env_manager import env_manager\nfrom src.infrastructure.config.settings import (\n    AISettings,\n    reload_settings,\n    scraper_settings,\n)\nfrom src.services.ai_request_compat import (\n    CHAT_COMPLETIONS_API_MODE,\n    RESPONSES_API_MODE,\n    build_ai_request_params,\n    create_ai_response_sync,\n    is_chat_completions_api_unsupported_error,\n    is_responses_api_unsupported_error,\n)\nfrom src.services.ai_response_parser import extract_ai_response_content\nfrom src.services.notification_config_service import (\n    NotificationSettingsValidationError,\n    build_configured_channels,\n    build_notification_settings_response,\n    build_notification_status_flags,\n    load_notification_settings,\n    model_dump,\n    prepare_notification_settings_update,\n)\nfrom src.services.notification_service import build_notification_service\nfrom src.services.process_service import ProcessService\n\n\nrouter = APIRouter(prefix=\"/api/settings\", tags=[\"settings\"])\nAI_TEST_PROMPT = \"Reply with OK only.\"\nAI_TEST_MAX_OUTPUT_TOKENS = 32\n\n\ndef _reload_env() -> None:\n    load_dotenv(dotenv_path=env_manager.env_file, override=True)\n    reload_settings()\n\n\ndef _env_bool(key: str, default: bool = False) -> bool:\n    value = env_manager.get_value(key)\n    if value is None:\n        return default\n    return str(value).strip().lower() in {\"1\", \"true\", \"yes\", \"y\", \"on\"}\n\n\ndef _env_int(key: str, default: int) -> int:\n    value = env_manager.get_value(key)\n    if value is None:\n        return default\n    try:\n        return int(value)\n    except ValueError:\n        return default\n\n\ndef _normalize_bool_value(value: bool) -> str:\n    return \"true\" if value else \"false\"\n\n\nclass NotificationSettingsModel(BaseModel):\n    \"\"\"通知设置模型\"\"\"\n\n    NTFY_TOPIC_URL: Optional[str] = None\n    GOTIFY_URL: Optional[str] = None\n    GOTIFY_TOKEN: Optional[str] = None\n    BARK_URL: Optional[str] = None\n    WX_BOT_URL: Optional[str] = None\n    TELEGRAM_BOT_TOKEN: Optional[str] = None\n    TELEGRAM_CHAT_ID: Optional[str] = None\n    TELEGRAM_API_BASE_URL: Optional[str] = None\n    WEBHOOK_URL: Optional[str] = None\n    WEBHOOK_METHOD: Optional[str] = None\n    WEBHOOK_HEADERS: Optional[str] = None\n    WEBHOOK_CONTENT_TYPE: Optional[str] = None\n    WEBHOOK_QUERY_PARAMETERS: Optional[str] = None\n    WEBHOOK_BODY: Optional[str] = None\n    PCURL_TO_MOBILE: Optional[bool] = None\n\n\nclass NotificationTestRequest(BaseModel):\n    \"\"\"通知测试请求\"\"\"\n\n    channel: Optional[str] = None\n    settings: NotificationSettingsModel = Field(default_factory=NotificationSettingsModel)\n\n\nclass AISettingsModel(BaseModel):\n    \"\"\"AI设置模型\"\"\"\n\n    OPENAI_API_KEY: Optional[str] = None\n    OPENAI_BASE_URL: Optional[str] = None\n    OPENAI_MODEL_NAME: Optional[str] = None\n    SKIP_AI_ANALYSIS: Optional[bool] = None\n    PROXY_URL: Optional[str] = None\n\n\nclass RotationSettingsModel(BaseModel):\n    ACCOUNT_ROTATION_ENABLED: Optional[bool] = None\n    ACCOUNT_ROTATION_MODE: Optional[str] = None\n    ACCOUNT_ROTATION_RETRY_LIMIT: Optional[int] = None\n    ACCOUNT_BLACKLIST_TTL: Optional[int] = None\n    ACCOUNT_STATE_DIR: Optional[str] = None\n    PROXY_ROTATION_ENABLED: Optional[bool] = None\n    PROXY_ROTATION_MODE: Optional[str] = None\n    PROXY_POOL: Optional[str] = None\n    PROXY_ROTATION_RETRY_LIMIT: Optional[int] = None\n    PROXY_BLACKLIST_TTL: Optional[int] = None\n\n\n@router.get(\"/notifications\")\nasync def get_notification_settings():\n    return build_notification_settings_response(load_notification_settings())\n\n\n@router.put(\"/notifications\")\nasync def update_notification_settings(settings: NotificationSettingsModel):\n    try:\n        updates, deletions, merged_settings = prepare_notification_settings_update(\n            model_dump(settings, exclude_unset=True),\n            load_notification_settings(),\n        )\n    except NotificationSettingsValidationError as exc:\n        raise HTTPException(status_code=422, detail=str(exc)) from exc\n\n    success = env_manager.apply_changes(updates=updates, deletions=deletions)\n    if not success:\n        raise HTTPException(status_code=500, detail=\"更新通知设置失败\")\n\n    _reload_env()\n    return {\n        \"message\": \"通知设置已成功更新\",\n        \"configured_channels\": build_configured_channels(merged_settings),\n    }\n\n\n@router.post(\"/notifications/test\")\nasync def test_notification_settings(payload: NotificationTestRequest):\n    try:\n        _, _, merged_settings = prepare_notification_settings_update(\n            model_dump(payload.settings, exclude_unset=True),\n            load_notification_settings(),\n        )\n    except NotificationSettingsValidationError as exc:\n        raise HTTPException(status_code=422, detail=str(exc)) from exc\n\n    service = build_notification_service(merged_settings)\n    if not service.clients:\n        raise HTTPException(status_code=422, detail=\"请至少配置一个可用的通知渠道\")\n\n    results = await service.send_test_notification()\n    if payload.channel:\n        if payload.channel not in results:\n            raise HTTPException(\n                status_code=422,\n                detail=f\"渠道 {payload.channel} 未配置或不受支持\",\n            )\n        results = {payload.channel: results[payload.channel]}\n\n    return {\n        \"message\": \"测试通知已执行\",\n        \"results\": results,\n    }\n\n\n@router.get(\"/rotation\")\nasync def get_rotation_settings():\n    return {\n        \"ACCOUNT_ROTATION_ENABLED\": _env_bool(\"ACCOUNT_ROTATION_ENABLED\", False),\n        \"ACCOUNT_ROTATION_MODE\": env_manager.get_value(\"ACCOUNT_ROTATION_MODE\", \"per_task\"),\n        \"ACCOUNT_ROTATION_RETRY_LIMIT\": _env_int(\"ACCOUNT_ROTATION_RETRY_LIMIT\", 2),\n        \"ACCOUNT_BLACKLIST_TTL\": _env_int(\"ACCOUNT_BLACKLIST_TTL\", 300),\n        \"ACCOUNT_STATE_DIR\": env_manager.get_value(\"ACCOUNT_STATE_DIR\", \"state\"),\n        \"PROXY_ROTATION_ENABLED\": _env_bool(\"PROXY_ROTATION_ENABLED\", False),\n        \"PROXY_ROTATION_MODE\": env_manager.get_value(\"PROXY_ROTATION_MODE\", \"per_task\"),\n        \"PROXY_POOL\": env_manager.get_value(\"PROXY_POOL\", \"\"),\n        \"PROXY_ROTATION_RETRY_LIMIT\": _env_int(\"PROXY_ROTATION_RETRY_LIMIT\", 2),\n        \"PROXY_BLACKLIST_TTL\": _env_int(\"PROXY_BLACKLIST_TTL\", 300),\n    }\n\n\n@router.put(\"/rotation\")\nasync def update_rotation_settings(settings: RotationSettingsModel):\n    updates = {}\n    payload = model_dump(settings, exclude_unset=True)\n    for key, value in payload.items():\n        if isinstance(value, bool):\n            updates[key] = _normalize_bool_value(value)\n        else:\n            updates[key] = str(value)\n    success = env_manager.update_values(updates)\n    if not success:\n        raise HTTPException(status_code=500, detail=\"更新轮换设置失败\")\n    _reload_env()\n    return {\"message\": \"轮换设置已成功更新\"}\n\n\n@router.get(\"/status\")\nasync def get_system_status(\n    process_service: ProcessService = Depends(get_process_service),\n):\n    state_file = \"xianyu_state.json\"\n    login_state_exists = os.path.exists(state_file)\n    env_file_exists = os.path.exists(env_manager.env_file)\n    openai_api_key = env_manager.get_value(\"OPENAI_API_KEY\", \"\")\n    openai_base_url = env_manager.get_value(\"OPENAI_BASE_URL\", \"\")\n    openai_model_name = env_manager.get_value(\"OPENAI_MODEL_NAME\", \"\")\n    ai_settings = AISettings()\n    notification_settings = load_notification_settings()\n    running_task_ids = [\n        task_id\n        for task_id, process in process_service.processes.items()\n        if process and process.returncode is None\n    ]\n\n    return {\n        \"ai_configured\": ai_settings.is_configured(),\n        \"notification_configured\": notification_settings.has_any_notification_enabled(),\n        \"headless_mode\": scraper_settings.run_headless,\n        \"running_in_docker\": scraper_settings.running_in_docker,\n        \"scraper_running\": len(running_task_ids) > 0,\n        \"running_task_ids\": running_task_ids,\n        \"login_state_file\": {\n            \"exists\": login_state_exists,\n            \"path\": state_file,\n        },\n        \"env_file\": {\n            \"exists\": env_file_exists,\n            \"openai_api_key_set\": bool(openai_api_key),\n            \"openai_base_url_set\": bool(openai_base_url),\n            \"openai_model_name_set\": bool(openai_model_name),\n            **build_notification_status_flags(notification_settings),\n        },\n        \"configured_notification_channels\": build_configured_channels(notification_settings),\n    }\n\n\n@router.get(\"/ai\")\nasync def get_ai_settings():\n    return {\n        \"OPENAI_BASE_URL\": env_manager.get_value(\"OPENAI_BASE_URL\", \"\"),\n        \"OPENAI_MODEL_NAME\": env_manager.get_value(\"OPENAI_MODEL_NAME\", \"\"),\n        \"SKIP_AI_ANALYSIS\": env_manager.get_value(\"SKIP_AI_ANALYSIS\", \"false\").lower() == \"true\",\n        \"PROXY_URL\": env_manager.get_value(\"PROXY_URL\", \"\"),\n    }\n\n\n@router.put(\"/ai\")\nasync def update_ai_settings(settings: AISettingsModel):\n    updates = {}\n    if settings.OPENAI_API_KEY is not None:\n        updates[\"OPENAI_API_KEY\"] = settings.OPENAI_API_KEY\n    if settings.OPENAI_BASE_URL is not None:\n        updates[\"OPENAI_BASE_URL\"] = settings.OPENAI_BASE_URL\n    if settings.OPENAI_MODEL_NAME is not None:\n        updates[\"OPENAI_MODEL_NAME\"] = settings.OPENAI_MODEL_NAME\n    if settings.SKIP_AI_ANALYSIS is not None:\n        updates[\"SKIP_AI_ANALYSIS\"] = str(settings.SKIP_AI_ANALYSIS).lower()\n    if settings.PROXY_URL is not None:\n        updates[\"PROXY_URL\"] = settings.PROXY_URL\n\n    success = env_manager.update_values(updates)\n    if not success:\n        raise HTTPException(status_code=500, detail=\"更新AI设置失败\")\n    _reload_env()\n    return {\"message\": \"AI设置已成功更新\"}\n\n\n@router.post(\"/ai/test\")\nasync def test_ai_settings(settings: dict):\n    \"\"\"测试AI模型设置是否有效\"\"\"\n    try:\n        from openai import OpenAI\n        import httpx\n\n        stored_api_key = env_manager.get_value(\"OPENAI_API_KEY\", \"\")\n        submitted_api_key = settings.get(\"OPENAI_API_KEY\", \"\")\n        api_key = submitted_api_key or stored_api_key\n\n        client_params = {\n            \"api_key\": api_key,\n            \"base_url\": settings.get(\"OPENAI_BASE_URL\", \"\"),\n            \"timeout\": httpx.Timeout(30.0),\n        }\n\n        proxy_url = settings.get(\"PROXY_URL\", \"\")\n        if proxy_url:\n            client_params[\"http_client\"] = httpx.Client(proxy=proxy_url)\n\n        model_name = settings.get(\"OPENAI_MODEL_NAME\", \"\")\n        client = OpenAI(**client_params)\n        messages = [{\"role\": \"user\", \"content\": AI_TEST_PROMPT}]\n        api_mode = CHAT_COMPLETIONS_API_MODE\n\n        try:\n            response = create_ai_response_sync(\n                client,\n                api_mode,\n                build_ai_request_params(\n                    api_mode,\n                    model=model_name,\n                    messages=messages,\n                    max_output_tokens=AI_TEST_MAX_OUTPUT_TOKENS,\n                ),\n            )\n        except Exception as exc:\n            if not is_chat_completions_api_unsupported_error(exc):\n                raise\n            api_mode = RESPONSES_API_MODE\n            response = create_ai_response_sync(\n                client,\n                api_mode,\n                build_ai_request_params(\n                    api_mode,\n                    model=model_name,\n                    messages=messages,\n                    max_output_tokens=AI_TEST_MAX_OUTPUT_TOKENS,\n                ),\n            )\n\n        return {\n            \"success\": True,\n            \"message\": \"AI模型连接测试成功！\",\n            \"response\": extract_ai_response_content(response),\n        }\n    except Exception as exc:\n        return {\n            \"success\": False,\n            \"message\": f\"AI模型连接测试失败: {exc}\",\n        }\n"
  },
  {
    "path": "src/api/routes/tasks.py",
    "content": "\"\"\"\n任务管理路由\n\"\"\"\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom fastapi.responses import JSONResponse\nfrom typing import List\nimport os\nimport aiofiles\nfrom src.api.dependencies import (\n    get_process_service,\n    get_scheduler_service,\n    get_task_generation_service,\n    get_task_service,\n)\nfrom src.services.task_service import TaskService\nfrom src.services.process_service import ProcessService\nfrom src.services.scheduler_service import SchedulerService\nfrom src.services.task_generation_service import TaskGenerationService\nfrom src.services.task_generation_runner import (\n    build_task_create,\n    run_ai_generation_job,\n)\nfrom src.services.task_payloads import serialize_task, serialize_tasks\nfrom src.domain.models.task import TaskCreate, TaskUpdate, TaskGenerateRequest\nfrom src.prompt_utils import generate_criteria\nfrom src.utils import resolve_task_log_path\nfrom src.services.account_strategy_service import normalize_account_strategy\nfrom src.infrastructure.persistence.storage_names import build_result_filename\nfrom src.services.price_history_service import delete_price_snapshots\nfrom src.services.result_storage_service import delete_result_file_records\nrouter = APIRouter(prefix=\"/api/tasks\", tags=[\"tasks\"])\n\nasync def _reload_scheduler_if_needed(\n    task_service: TaskService,\n    scheduler_service: SchedulerService,\n):\n    tasks = await task_service.get_all_tasks()\n    await scheduler_service.reload_jobs(tasks)\n\n\ndef _has_keyword_rules(rules) -> bool:\n    return bool(rules and len(rules) > 0)\n\n\ndef _validate_final_account_strategy(existing_task, task_update: TaskUpdate) -> None:\n    account_state_file = (\n        task_update.account_state_file\n        if task_update.account_state_file is not None\n        else existing_task.account_state_file\n    )\n    account_strategy = normalize_account_strategy(\n        task_update.account_strategy,\n        account_state_file,\n    )\n    task_update.account_strategy = account_strategy\n    if account_strategy == \"fixed\" and not account_state_file:\n        raise HTTPException(status_code=400, detail=\"固定账号模式下必须选择账号。\")\n@router.get(\"\", response_model=List[dict])\nasync def get_tasks(\n    service: TaskService = Depends(get_task_service),\n    scheduler_service: SchedulerService = Depends(get_scheduler_service),\n):\n    \"\"\"获取所有任务\"\"\"\n    tasks = await service.get_all_tasks()\n    return serialize_tasks(tasks, scheduler_service)\n@router.get(\"/{task_id}\", response_model=dict)\nasync def get_task(\n    task_id: int,\n    service: TaskService = Depends(get_task_service),\n    scheduler_service: SchedulerService = Depends(get_scheduler_service),\n):\n    \"\"\"获取单个任务\"\"\"\n    task = await service.get_task(task_id)\n    if not task:\n        raise HTTPException(status_code=404, detail=\"任务未找到\")\n    return serialize_task(task, scheduler_service)\n@router.post(\"/\", response_model=dict)\nasync def create_task(\n    task_create: TaskCreate,\n    service: TaskService = Depends(get_task_service),\n    scheduler_service: SchedulerService = Depends(get_scheduler_service),\n):\n    \"\"\"创建新任务\"\"\"\n    task = await service.create_task(task_create)\n    await _reload_scheduler_if_needed(service, scheduler_service)\n    return {\"message\": \"任务创建成功\", \"task\": serialize_task(task, scheduler_service)}\n@router.post(\"/generate\", response_model=dict)\nasync def generate_task(\n    req: TaskGenerateRequest,\n    service: TaskService = Depends(get_task_service),\n    scheduler_service: SchedulerService = Depends(get_scheduler_service),\n    generation_service: TaskGenerationService = Depends(get_task_generation_service),\n):\n    \"\"\"创建任务。AI模式会生成分析标准，关键词模式直接保存规则。\"\"\"\n    print(f\"收到任务生成请求: {req.task_name}，模式: {req.decision_mode}\")\n\n    try:\n        mode = req.decision_mode or \"ai\"\n        if mode == \"ai\":\n            job = await generation_service.create_job(req.task_name)\n            generation_service.track(\n                run_ai_generation_job(\n                    job_id=job.job_id,\n                    req=req,\n                    task_service=service,\n                    scheduler_service=scheduler_service,\n                    generation_service=generation_service,\n                )\n            )\n            return JSONResponse(\n                status_code=202,\n                content={\n                    \"message\": \"AI 任务生成已开始。\",\n                    \"job\": job.model_dump(mode=\"json\"),\n                },\n            )\n\n        task = await service.create_task(build_task_create(req, \"\"))\n        await _reload_scheduler_if_needed(service, scheduler_service)\n        return {\"message\": \"任务创建成功。\", \"task\": serialize_task(task, scheduler_service)}\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        error_msg = f\"AI任务生成API发生未知错误: {str(e)}\"\n        print(error_msg)\n        import traceback\n        print(traceback.format_exc())\n        raise HTTPException(status_code=500, detail=error_msg)\n@router.get(\"/generate-jobs/{job_id}\", response_model=dict)\nasync def get_task_generation_job(\n    job_id: str,\n    generation_service: TaskGenerationService = Depends(get_task_generation_service),\n):\n    \"\"\"获取任务生成作业状态\"\"\"\n    job = await generation_service.get_job(job_id)\n    if not job:\n        raise HTTPException(status_code=404, detail=\"任务生成作业未找到\")\n    return {\"job\": job.model_dump(mode=\"json\")}\n@router.patch(\"/{task_id}\", response_model=dict)\nasync def update_task(\n    task_id: int,\n    task_update: TaskUpdate,\n    service: TaskService = Depends(get_task_service),\n    scheduler_service: SchedulerService = Depends(get_scheduler_service),\n):\n    \"\"\"更新任务\"\"\"\n    try:\n        existing_task = await service.get_task(task_id)\n        if not existing_task:\n            raise HTTPException(status_code=404, detail=\"任务未找到\")\n        _validate_final_account_strategy(existing_task, task_update)\n\n        current_mode = getattr(existing_task, \"decision_mode\", \"ai\") or \"ai\"\n        target_mode = task_update.decision_mode or current_mode\n        description_changed = (\n            task_update.description is not None\n            and task_update.description != existing_task.description\n        )\n        switched_to_ai = current_mode != \"ai\" and target_mode == \"ai\"\n\n        if target_mode == \"keyword\":\n            final_rules = (\n                task_update.keyword_rules\n                if task_update.keyword_rules is not None\n                else getattr(existing_task, \"keyword_rules\", [])\n            )\n            if not _has_keyword_rules(final_rules):\n                raise HTTPException(status_code=400, detail=\"关键词模式下至少需要一个关键词。\")\n        if target_mode == \"ai\" and (description_changed or switched_to_ai):\n            print(f\"检测到任务 {task_id} 需要刷新 AI 标准文件，开始重新生成...\")\n            try:\n                description_for_ai = (\n                    task_update.description\n                    if task_update.description is not None\n                    else existing_task.description\n                )\n                if not str(description_for_ai or \"\").strip():\n                    raise HTTPException(status_code=400, detail=\"AI 模式下详细需求不能为空。\")\n                safe_keyword = \"\".join(\n                    c for c in existing_task.keyword.lower().replace(' ', '_')\n                    if c.isalnum() or c in \"_-\"\n                ).rstrip()\n                output_filename = f\"prompts/{safe_keyword}_criteria.txt\"\n                print(f\"目标文件路径: {output_filename}\")\n                print(\"开始调用 AI 生成新的分析标准...\")\n                generated_criteria = await generate_criteria(\n                    user_description=description_for_ai,\n                    reference_file_path=\"prompts/macbook_criteria.txt\"\n                )\n                if not generated_criteria or len(generated_criteria.strip()) == 0:\n                    print(\"AI 返回的内容为空\")\n                    raise HTTPException(status_code=500, detail=\"AI 未能生成分析标准，返回内容为空。\")\n                print(f\"保存新的分析标准到: {output_filename}\")\n                os.makedirs(\"prompts\", exist_ok=True)\n                async with aiofiles.open(output_filename, 'w', encoding='utf-8') as f:\n                    await f.write(generated_criteria)\n                print(f\"新的分析标准已保存\")\n                task_update.ai_prompt_criteria_file = output_filename\n                print(f\"已更新 ai_prompt_criteria_file 字段为: {output_filename}\")\n            except HTTPException:\n                raise\n            except Exception as e:\n                error_msg = f\"重新生成 criteria 文件时出错: {str(e)}\"\n                print(error_msg)\n                import traceback\n                print(traceback.format_exc())\n                raise HTTPException(status_code=500, detail=error_msg)\n        task = await service.update_task(task_id, task_update)\n        await _reload_scheduler_if_needed(service, scheduler_service)\n        return {\"message\": \"任务更新成功\", \"task\": serialize_task(task, scheduler_service)}\n    except ValueError as e:\n        raise HTTPException(status_code=404, detail=str(e))\n@router.delete(\"/{task_id}\", response_model=dict)\nasync def delete_task(\n    task_id: int,\n    service: TaskService = Depends(get_task_service),\n    process_service: ProcessService = Depends(get_process_service),\n    scheduler_service: SchedulerService = Depends(get_scheduler_service),\n):\n    \"\"\"删除任务\"\"\"\n    task = await service.get_task(task_id)\n    if not task:\n        raise HTTPException(status_code=404, detail=\"任务未找到\")\n\n    await process_service.stop_task(task_id)\n    success = await service.delete_task(task_id)\n    if not success:\n        raise HTTPException(status_code=404, detail=\"任务未找到\")\n    await _reload_scheduler_if_needed(service, scheduler_service)\n    try:\n        keyword = (task.keyword or \"\").strip()\n        if keyword:\n            remaining_tasks = await service.get_all_tasks()\n            keyword_still_in_use = any(\n                (remaining_task.keyword or \"\").strip() == keyword\n                for remaining_task in remaining_tasks\n            )\n            if not keyword_still_in_use:\n                await delete_result_file_records(build_result_filename(keyword))\n                delete_price_snapshots(keyword)\n    except Exception as e:\n        print(f\"删除任务结果文件时出错: {e}\")\n\n    try:\n        log_file_path = resolve_task_log_path(task_id, task.task_name)\n        if os.path.exists(log_file_path):\n            os.remove(log_file_path)\n    except Exception as e:\n        print(f\"删除任务日志文件时出错: {e}\")\n    return {\"message\": \"任务删除成功\"}\n@router.post(\"/start/{task_id}\", response_model=dict)\nasync def start_task(\n    task_id: int,\n    task_service: TaskService = Depends(get_task_service),\n    process_service: ProcessService = Depends(get_process_service),\n):\n    \"\"\"启动单个任务\"\"\"\n    task = await task_service.get_task(task_id)\n    if not task:\n        raise HTTPException(status_code=404, detail=\"任务未找到\")\n    if not task.enabled:\n        raise HTTPException(status_code=400, detail=\"任务已被禁用，无法启动\")\n    if task.is_running:\n        raise HTTPException(status_code=400, detail=\"任务已在运行中\")\n    success = await process_service.start_task(task_id, task.task_name)\n    if not success:\n        raise HTTPException(status_code=500, detail=\"启动任务失败\")\n    return {\"message\": f\"任务 '{task.task_name}' 已启动\"}\n@router.post(\"/stop/{task_id}\", response_model=dict)\nasync def stop_task(\n    task_id: int,\n    task_service: TaskService = Depends(get_task_service),\n    process_service: ProcessService = Depends(get_process_service),\n):\n    \"\"\"停止单个任务\"\"\"\n    task = await task_service.get_task(task_id)\n    if not task:\n        raise HTTPException(status_code=404, detail=\"任务未找到\")\n    await process_service.stop_task(task_id)\n    return {\"message\": f\"任务ID {task_id} 已发送停止信号\"}\n"
  },
  {
    "path": "src/api/routes/websocket.py",
    "content": "\"\"\"\nWebSocket 路由\n提供实时通信功能\n\"\"\"\nfrom fastapi import APIRouter, WebSocket, WebSocketDisconnect\nfrom typing import Set\n\n\nrouter = APIRouter()\n\n# 全局 WebSocket 连接管理\nactive_connections: Set[WebSocket] = set()\n\n\n@router.websocket(\"/ws\")\nasync def websocket_endpoint(\n    websocket: WebSocket,\n):\n    \"\"\"WebSocket 端点\"\"\"\n    # 接受连接\n    await websocket.accept()\n    active_connections.add(websocket)\n\n    try:\n        # 保持连接并接收消息\n        while True:\n            # 接收客户端消息（如果有的话）\n            data = await websocket.receive_text()\n            # 这里可以处理客户端发送的消息\n            # 目前我们主要用于服务端推送，所以暂时不处理\n    except WebSocketDisconnect:\n        active_connections.remove(websocket)\n    except Exception as e:\n        print(f\"WebSocket 错误: {e}\")\n        if websocket in active_connections:\n            active_connections.remove(websocket)\n\n\nasync def broadcast_message(message_type: str, data: dict):\n    \"\"\"向所有连接的客户端广播消息\"\"\"\n    message = {\n        \"type\": message_type,\n        \"data\": data\n    }\n\n    # 移除已断开的连接\n    disconnected = set()\n\n    for connection in active_connections:\n        try:\n            await connection.send_json(message)\n        except Exception:\n            disconnected.add(connection)\n\n    # 清理断开的连接\n    for connection in disconnected:\n        active_connections.discard(connection)\n"
  },
  {
    "path": "src/app.py",
    "content": "\"\"\"\n新架构的主应用入口\n整合所有路由和服务\n\"\"\"\nfrom contextlib import asynccontextmanager\nfrom fastapi import FastAPI\nfrom fastapi.staticfiles import StaticFiles\nfrom fastapi.templating import Jinja2Templates\n\nfrom src.api.routes import (\n    dashboard,\n    tasks,\n    logs,\n    settings,\n    prompts,\n    results,\n    login_state,\n    websocket,\n    accounts,\n)\nfrom src.api.dependencies import (\n    set_process_service,\n    set_scheduler_service,\n    set_task_generation_service,\n)\nfrom src.services.task_service import TaskService\nfrom src.services.process_service import ProcessService\nfrom src.services.scheduler_service import SchedulerService\nfrom src.services.task_log_cleanup_service import cleanup_task_logs\nfrom src.services.task_generation_service import TaskGenerationService\nfrom src.infrastructure.persistence.sqlite_bootstrap import bootstrap_sqlite_storage\nfrom src.infrastructure.persistence.sqlite_task_repository import SqliteTaskRepository\nfrom src.infrastructure.config.settings import settings as app_settings\n\n\n# 全局服务实例\nprocess_service = ProcessService()\nscheduler_service = SchedulerService(process_service)\ntask_generation_service = TaskGenerationService()\n\n\nasync def _sync_task_runtime_status(task_id: int, is_running: bool) -> None:\n    task_service = TaskService(SqliteTaskRepository())\n    task = await task_service.get_task(task_id)\n    if not task or task.is_running == is_running:\n        return\n    await task_service.update_task_status(task_id, is_running)\n    await websocket.broadcast_message(\n        \"task_status_changed\",\n        {\"id\": task_id, \"is_running\": is_running},\n    )\n\n\nprocess_service.set_lifecycle_hooks(\n    on_started=lambda task_id: _sync_task_runtime_status(task_id, True),\n    on_stopped=lambda task_id: _sync_task_runtime_status(task_id, False),\n)\n\n# 设置全局 ProcessService 实例供依赖注入使用\nset_process_service(process_service)\nset_scheduler_service(scheduler_service)\nset_task_generation_service(task_generation_service)\n\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n    \"\"\"应用生命周期管理\"\"\"\n    # 启动时\n    print(\"正在启动应用...\")\n    bootstrap_sqlite_storage()\n    cleanup_task_logs(keep_days=app_settings.task_log_retention_days)\n\n    # 重置所有任务状态为停止\n    task_repo = SqliteTaskRepository()\n    task_service = TaskService(task_repo)\n    tasks_list = await task_service.get_all_tasks()\n\n    for task in tasks_list:\n        if task.is_running:\n            await task_service.update_task_status(task.id, False)\n\n    # 加载定时任务\n    await scheduler_service.reload_jobs(tasks_list)\n    scheduler_service.start()\n\n    print(\"应用启动完成\")\n\n    yield\n\n    # 关闭时\n    print(\"正在关闭应用...\")\n    scheduler_service.stop()\n    await process_service.stop_all()\n    print(\"应用已关闭\")\n\n\n# 创建 FastAPI 应用\napp = FastAPI(\n    title=\"闲鱼智能监控机器人\",\n    description=\"基于AI的闲鱼商品监控系统\",\n    version=\"2.0.0\",\n    lifespan=lifespan\n)\n\n# 注册路由\napp.include_router(tasks.router)\napp.include_router(dashboard.router)\napp.include_router(logs.router)\napp.include_router(settings.router)\napp.include_router(prompts.router)\napp.include_router(results.router)\napp.include_router(login_state.router)\napp.include_router(websocket.router)\napp.include_router(accounts.router)\n\n# 挂载静态文件\n# 旧的静态文件目录（用于截图等）\napp.mount(\"/static\", StaticFiles(directory=\"static\"), name=\"static\")\n\n# 挂载 Vue 3 前端构建产物\n# 注意：需要在所有 API 路由之后挂载，以避免覆盖 API 路由\nimport os\nif os.path.exists(\"dist\"):\n    app.mount(\"/assets\", StaticFiles(directory=\"dist/assets\"), name=\"assets\")\n\n\n# 健康检查端点\n@app.get(\"/health\")\nasync def health_check():\n    \"\"\"健康检查（无需认证）\"\"\"\n    return {\"status\": \"healthy\", \"message\": \"服务正常运行\"}\n\n\n# 认证状态检查端点\nfrom fastapi import Request, HTTPException\nfrom fastapi.responses import FileResponse\nfrom pydantic import BaseModel\n\nclass LoginRequest(BaseModel):\n    username: str\n    password: str\n\n\n@app.post(\"/auth/status\")\nasync def auth_status(payload: LoginRequest):\n    \"\"\"检查认证状态\"\"\"\n    if payload.username == app_settings.web_username and payload.password == app_settings.web_password:\n        return {\"authenticated\": True, \"username\": payload.username}\n    raise HTTPException(status_code=401, detail=\"认证失败\")\n\n\n# 主页路由 - 服务 Vue 3 SPA\nfrom fastapi.responses import JSONResponse\n\n@app.get(\"/\")\nasync def read_root(request: Request):\n    \"\"\"提供 Vue 3 SPA 的主页面\"\"\"\n    if os.path.exists(\"dist/index.html\"):\n        return FileResponse(\"dist/index.html\")\n    else:\n        return JSONResponse(\n            status_code=500,\n            content={\"error\": \"前端构建产物不存在，请先运行 cd web-ui && npm run build\"}\n        )\n\n\n# Catch-all 路由 - 处理所有前端路由（必须放在最后）\n@app.get(\"/{full_path:path}\")\nasync def serve_spa(request: Request, full_path: str):\n    \"\"\"\n    Catch-all 路由，将所有非 API 请求重定向到 index.html\n    这样可以支持 Vue Router 的 HTML5 History 模式\n    \"\"\"\n    # 如果请求的是静态资源（如 favicon.ico），返回 404\n    if full_path.endswith(('.ico', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.css', '.js', '.json')):\n        return JSONResponse(status_code=404, content={\"error\": \"资源未找到\"})\n\n    # 其他所有路径都返回 index.html，让前端路由处理\n    if os.path.exists(\"dist/index.html\"):\n        return FileResponse(\"dist/index.html\")\n    else:\n        return JSONResponse(\n            status_code=500,\n            content={\"error\": \"前端构建产物不存在，请先运行 cd web-ui && npm run build\"}\n        )\n\n\nif __name__ == \"__main__\":\n    import uvicorn\n    from src.infrastructure.config.settings import settings\n\n    print(f\"启动新架构应用，端口: {app_settings.server_port}\")\n    uvicorn.run(app, host=\"0.0.0.0\", port=app_settings.server_port)\n"
  },
  {
    "path": "src/config.py",
    "content": "import os\nimport sys\n\nfrom dotenv import load_dotenv\nfrom openai import AsyncOpenAI\n\n# --- AI & Notification Configuration ---\nload_dotenv()\n\n# --- File Paths & Directories ---\nSTATE_FILE = \"xianyu_state.json\"\nIMAGE_SAVE_DIR = \"images\"\nCONFIG_FILE = \"config.json\"\nos.makedirs(IMAGE_SAVE_DIR, exist_ok=True)\n\n# 任务隔离的临时图片目录前缀\nTASK_IMAGE_DIR_PREFIX = \"task_images_\"\n\n# --- API URL Patterns ---\nAPI_URL_PATTERN = \"h5api.m.goofish.com/h5/mtop.taobao.idlemtopsearch.pc.search\"\nDETAIL_API_URL_PATTERN = \"h5api.m.goofish.com/h5/mtop.taobao.idle.pc.detail\"\n\n# --- Environment Variables ---\nAPI_KEY = os.getenv(\"OPENAI_API_KEY\")\nBASE_URL = os.getenv(\"OPENAI_BASE_URL\")\nMODEL_NAME = os.getenv(\"OPENAI_MODEL_NAME\")\nPROXY_URL = os.getenv(\"PROXY_URL\")\nNTFY_TOPIC_URL = os.getenv(\"NTFY_TOPIC_URL\")\nGOTIFY_URL = os.getenv(\"GOTIFY_URL\")\nGOTIFY_TOKEN = os.getenv(\"GOTIFY_TOKEN\")\nBARK_URL = os.getenv(\"BARK_URL\")\nWX_BOT_URL = os.getenv(\"WX_BOT_URL\")\nTELEGRAM_BOT_TOKEN = os.getenv(\"TELEGRAM_BOT_TOKEN\")\nTELEGRAM_CHAT_ID = os.getenv(\"TELEGRAM_CHAT_ID\")\nWEBHOOK_URL = os.getenv(\"WEBHOOK_URL\")\nWEBHOOK_METHOD = os.getenv(\"WEBHOOK_METHOD\", \"POST\").upper()\nWEBHOOK_HEADERS = os.getenv(\"WEBHOOK_HEADERS\")\nWEBHOOK_CONTENT_TYPE = os.getenv(\"WEBHOOK_CONTENT_TYPE\", \"JSON\").upper()\nWEBHOOK_QUERY_PARAMETERS = os.getenv(\"WEBHOOK_QUERY_PARAMETERS\")\nWEBHOOK_BODY = os.getenv(\"WEBHOOK_BODY\")\nPCURL_TO_MOBILE = os.getenv(\"PCURL_TO_MOBILE\", \"false\").lower() == \"true\"\nRUN_HEADLESS = os.getenv(\"RUN_HEADLESS\", \"true\").lower() != \"false\"\nLOGIN_IS_EDGE = os.getenv(\"LOGIN_IS_EDGE\", \"false\").lower() == \"true\"\nRUNNING_IN_DOCKER = os.getenv(\"RUNNING_IN_DOCKER\", \"false\").lower() == \"true\"\nAI_DEBUG_MODE = os.getenv(\"AI_DEBUG_MODE\", \"false\").lower() == \"true\"\nSKIP_AI_ANALYSIS = os.getenv(\"SKIP_AI_ANALYSIS\", \"false\").lower() == \"true\"\nENABLE_THINKING = os.getenv(\"ENABLE_THINKING\", \"false\").lower() == \"true\"\nENABLE_RESPONSE_FORMAT = os.getenv(\"ENABLE_RESPONSE_FORMAT\", \"true\").lower() == \"true\"\n\n# --- Headers ---\nIMAGE_DOWNLOAD_HEADERS = {\n    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0',\n    'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',\n    'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',\n    'Connection': 'keep-alive',\n    'Upgrade-Insecure-Requests': '1',\n}\n\n# --- Client Initialization ---\n# 检查配置是否齐全\nif not all([BASE_URL, MODEL_NAME]):\n    print(\"警告：未在 .env 文件中完整设置 OPENAI_BASE_URL 和 OPENAI_MODEL_NAME。AI相关功能可能无法使用。\")\n    client = None\nelse:\n    try:\n        if PROXY_URL:\n            print(f\"正在为AI请求使用HTTP/S代理: {PROXY_URL}\")\n            # httpx 会自动从环境变量中读取代理设置\n            os.environ['HTTP_PROXY'] = PROXY_URL\n            os.environ['HTTPS_PROXY'] = PROXY_URL\n\n        # openai 客户端内部的 httpx 会自动从环境变量中获取代理配置\n        client = AsyncOpenAI(api_key=API_KEY, base_url=BASE_URL)\n    except Exception as e:\n        print(f\"初始化 OpenAI 客户端时出错: {e}\")\n        client = None\n\n# 检查AI客户端是否成功初始化\nif not client:\n    # 在 prompt_generator.py 中，如果 client 为 None，会直接报错退出\n    # 在 spider_v2.py 中，AI分析会跳过\n    # 为了保持一致性，这里只打印警告，具体逻辑由调用方处理\n    pass\n\n# 检查关键配置\nif not all([BASE_URL, MODEL_NAME]) and 'prompt_generator.py' in sys.argv[0]:\n    sys.exit(\"错误：请确保在 .env 文件中完整设置了 OPENAI_BASE_URL 和 OPENAI_MODEL_NAME。(OPENAI_API_KEY 对于某些服务是可选的)\")\n\ndef get_ai_request_params(**kwargs):\n    \"\"\"\n    构建AI请求参数，根据ENABLE_THINKING和ENABLE_RESPONSE_FORMAT环境变量决定是否添加相应参数\n    \"\"\"\n    if ENABLE_THINKING:\n        kwargs[\"extra_body\"] = {\"enable_thinking\": False}\n    \n    # 如果禁用结构化输出，则移除 text.format 配置\n    if not ENABLE_RESPONSE_FORMAT and \"text\" in kwargs:\n        text_config = kwargs.get(\"text\")\n        if isinstance(text_config, dict):\n            text_config = dict(text_config)\n            text_config.pop(\"format\", None)\n            if text_config:\n                kwargs[\"text\"] = text_config\n            else:\n                del kwargs[\"text\"]\n    \n    return kwargs\n"
  },
  {
    "path": "src/core/cron_utils.py",
    "content": "\"\"\"\nCron 解析与校验工具。\n\"\"\"\nfrom __future__ import annotations\n\nfrom typing import Optional\n\nfrom apscheduler.triggers.cron import CronTrigger\n\nCRON_ALIASES = {\n    \"@yearly\": \"0 0 1 1 *\",\n    \"@annually\": \"0 0 1 1 *\",\n    \"@monthly\": \"0 0 1 * *\",\n    \"@weekly\": \"0 0 * * 0\",\n    \"@daily\": \"0 0 * * *\",\n    \"@midnight\": \"0 0 * * *\",\n    \"@hourly\": \"0 * * * *\",\n}\n\nCRON_FORMAT_HINT = (\n    \"Cron 表达式无效。支持 5 段（分 时 日 月 周）、\"\n    \"6 段（秒 分 时 日 月 周）和常见别名（@hourly/@daily/@weekly/@monthly/@yearly）。\"\n    \"示例：*/15 * * * *、0 8 * * *、0 0 8 * * *、@daily。\"\n)\n\n\ndef normalize_cron_expression(value: Optional[str]) -> Optional[str]:\n    if value is None:\n        return None\n\n    normalized = \" \".join(str(value).strip().split())\n    if not normalized:\n        return None\n\n    return CRON_ALIASES.get(normalized.lower(), normalized)\n\n\ndef build_cron_trigger(\n    expression: str,\n    *,\n    timezone=None,\n) -> CronTrigger:\n    normalized = normalize_cron_expression(expression)\n    if normalized is None:\n        raise ValueError(CRON_FORMAT_HINT)\n\n    parts = normalized.split()\n    try:\n        if len(parts) == 5:\n            return CronTrigger.from_crontab(normalized, timezone=timezone)\n\n        if len(parts) == 6:\n            second, minute, hour, day, month, day_of_week = parts\n            return CronTrigger(\n                second=second,\n                minute=minute,\n                hour=hour,\n                day=day,\n                month=month,\n                day_of_week=day_of_week,\n                timezone=timezone,\n            )\n    except ValueError as exc:\n        raise ValueError(CRON_FORMAT_HINT) from exc\n\n    raise ValueError(CRON_FORMAT_HINT)\n\n\ndef validate_cron_expression(value: Optional[str]) -> Optional[str]:\n    normalized = normalize_cron_expression(value)\n    if normalized is None:\n        return None\n\n    build_cron_trigger(normalized)\n    return normalized\n"
  },
  {
    "path": "src/domain/__init__.py",
    "content": ""
  },
  {
    "path": "src/domain/models/__init__.py",
    "content": "from .task import Task, TaskCreate, TaskUpdate, TaskStatus\n\n__all__ = [\"Task\", \"TaskCreate\", \"TaskUpdate\", \"TaskStatus\"]\n"
  },
  {
    "path": "src/domain/models/task.py",
    "content": "\"\"\"\n任务领域模型\n定义任务实体及其业务逻辑\n\"\"\"\nimport re\nfrom enum import Enum\nfrom typing import Any, List, Literal, Optional\n\nfrom pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator\n\nfrom src.core.cron_utils import validate_cron_expression\nfrom src.services.account_strategy_service import (\n    clean_account_state_file,\n    normalize_account_strategy,\n)\n\n\nclass TaskStatus(str, Enum):\n    \"\"\"任务状态枚举\"\"\"\n\n    STOPPED = \"stopped\"\n    RUNNING = \"running\"\n    SCHEDULED = \"scheduled\"\n\n\ndef _normalize_keyword_values(value) -> List[str]:\n    if value is None:\n        return []\n\n    raw_values = []\n    if isinstance(value, (list, tuple, set)):\n        raw_values = list(value)\n    elif isinstance(value, str):\n        raw_values = re.split(r\"[\\n,]+\", value)\n    else:\n        raw_values = [value]\n\n    normalized: List[str] = []\n    seen = set()\n    for item in raw_values:\n        text = str(item).strip()\n        if not text:\n            continue\n        dedup_key = text.lower()\n        if dedup_key in seen:\n            continue\n        seen.add(dedup_key)\n        normalized.append(text)\n    return normalized\n\n\ndef _extract_keywords_from_legacy_groups(groups) -> List[str]:\n    if not groups:\n        return []\n\n    merged: List[str] = []\n    for group in groups:\n        include_keywords = []\n        if isinstance(group, dict):\n            include_keywords = group.get(\"include_keywords\") or []\n        else:\n            include_keywords = getattr(group, \"include_keywords\", []) or []\n        merged.extend(_normalize_keyword_values(include_keywords))\n    return _normalize_keyword_values(merged)\n\n\ndef _normalize_payload_keywords(payload: Any) -> Any:\n    if payload is None or not isinstance(payload, dict):\n        return payload\n    values = dict(payload)\n    values[\"account_state_file\"] = clean_account_state_file(values.get(\"account_state_file\"))\n    values[\"account_strategy\"] = normalize_account_strategy(\n        values.get(\"account_strategy\"),\n        values.get(\"account_state_file\"),\n    )\n    if \"keyword_rules\" in values:\n        values[\"keyword_rules\"] = _normalize_keyword_values(values.get(\"keyword_rules\"))\n    elif \"keyword_rule_groups\" in values:\n        values[\"keyword_rules\"] = _extract_keywords_from_legacy_groups(\n            values.get(\"keyword_rule_groups\")\n        )\n    return values\n\n\ndef _has_keyword_rules(keyword_rules: List[str]) -> bool:\n    return bool(keyword_rules and len(keyword_rules) > 0)\n\n\ndef _normalize_optional_string(value):\n    if value == \"\" or value == \"null\" or value == \"undefined\" or value is None:\n        return None\n    return value\n\n\ndef _validate_cron_expression(value: Optional[str]) -> Optional[str]:\n    return validate_cron_expression(value)\n\n\ndef _normalize_price_value(value):\n    if _normalize_optional_string(value) is None:\n        return None\n    if isinstance(value, (int, float)):\n        return str(value)\n    return value\n\n\nclass Task(BaseModel):\n    \"\"\"任务实体\"\"\"\n\n    model_config = ConfigDict(use_enum_values=True, extra=\"ignore\")\n\n    id: Optional[int] = None\n    task_name: str\n    enabled: bool\n    keyword: str\n    description: Optional[str] = \"\"\n    analyze_images: bool = True\n    max_pages: int\n    personal_only: bool\n    min_price: Optional[str] = None\n    max_price: Optional[str] = None\n    cron: Optional[str] = None\n    ai_prompt_base_file: str\n    ai_prompt_criteria_file: str\n    account_state_file: Optional[str] = None\n    account_strategy: Literal[\"auto\", \"fixed\", \"rotate\"] = \"auto\"\n    free_shipping: bool = True\n    new_publish_option: Optional[str] = None\n    region: Optional[str] = None\n    decision_mode: Literal[\"ai\", \"keyword\"] = \"ai\"\n    keyword_rules: List[str] = Field(default_factory=list)\n    is_running: bool = False\n\n    @model_validator(mode=\"before\")\n    @classmethod\n    def normalize_legacy_keyword_payload(cls, values):\n        return _normalize_payload_keywords(values)\n\n    @field_validator(\"keyword_rules\", mode=\"before\")\n    @classmethod\n    def normalize_keyword_rules(cls, value):\n        return _normalize_keyword_values(value)\n\n    def can_start(self) -> bool:\n        \"\"\"检查任务是否可以启动\"\"\"\n        return self.enabled and not self.is_running\n\n    def can_stop(self) -> bool:\n        \"\"\"检查任务是否可以停止\"\"\"\n        return self.is_running\n\n    def apply_update(self, update: \"TaskUpdate\") -> \"Task\":\n        \"\"\"应用更新并返回新的任务实例\"\"\"\n        update_data = update.model_dump(exclude_unset=True)\n        return self.model_copy(update=update_data)\n\n\nclass TaskCreate(BaseModel):\n    \"\"\"创建任务的DTO\"\"\"\n\n    model_config = ConfigDict(extra=\"ignore\")\n\n    task_name: str\n    enabled: bool = True\n    keyword: str\n    description: Optional[str] = \"\"\n    analyze_images: bool = True\n    max_pages: int = 3\n    personal_only: bool = True\n    min_price: Optional[str] = None\n    max_price: Optional[str] = None\n    cron: Optional[str] = None\n    ai_prompt_base_file: str = \"prompts/base_prompt.txt\"\n    ai_prompt_criteria_file: str = \"\"\n    account_state_file: Optional[str] = None\n    account_strategy: Literal[\"auto\", \"fixed\", \"rotate\"] = \"auto\"\n    free_shipping: bool = True\n    new_publish_option: Optional[str] = None\n    region: Optional[str] = None\n    decision_mode: Literal[\"ai\", \"keyword\"] = \"ai\"\n    keyword_rules: List[str] = Field(default_factory=list)\n\n    @model_validator(mode=\"before\")\n    @classmethod\n    def normalize_legacy_keyword_payload(cls, values):\n        return _normalize_payload_keywords(values)\n\n    @field_validator(\"min_price\", \"max_price\", mode=\"before\")\n    @classmethod\n    def convert_price_to_str(cls, value):\n        return _normalize_price_value(value)\n\n    @field_validator(\"cron\", mode=\"before\")\n    @classmethod\n    def normalize_cron(cls, value):\n        return _normalize_optional_string(value)\n\n    @field_validator(\"account_state_file\", mode=\"before\")\n    @classmethod\n    def normalize_account_state_file(cls, value):\n        return clean_account_state_file(value)\n\n    @field_validator(\"cron\")\n    @classmethod\n    def validate_cron(cls, value):\n        return _validate_cron_expression(value)\n\n    @field_validator(\"keyword_rules\", mode=\"before\")\n    @classmethod\n    def normalize_keyword_rules(cls, value):\n        return _normalize_keyword_values(value)\n\n    @model_validator(mode=\"after\")\n    def validate_decision_mode_payload(self):\n        description = str(self.description or \"\").strip()\n        if self.decision_mode == \"ai\" and not description:\n            raise ValueError(\"AI 判断模式下，详细需求(description)不能为空。\")\n        if self.decision_mode == \"keyword\" and not _has_keyword_rules(self.keyword_rules):\n            raise ValueError(\"关键词判断模式下，至少需要一个关键词。\")\n        if self.account_strategy == \"fixed\" and not self.account_state_file:\n            raise ValueError(\"固定账号模式下必须选择账号。\")\n        return self\n\n\nclass TaskUpdate(BaseModel):\n    \"\"\"更新任务的DTO\"\"\"\n\n    model_config = ConfigDict(extra=\"ignore\")\n\n    task_name: Optional[str] = None\n    enabled: Optional[bool] = None\n    keyword: Optional[str] = None\n    description: Optional[str] = None\n    analyze_images: Optional[bool] = None\n    max_pages: Optional[int] = None\n    personal_only: Optional[bool] = None\n    min_price: Optional[str] = None\n    max_price: Optional[str] = None\n    cron: Optional[str] = None\n    ai_prompt_base_file: Optional[str] = None\n    ai_prompt_criteria_file: Optional[str] = None\n    account_state_file: Optional[str] = None\n    account_strategy: Optional[Literal[\"auto\", \"fixed\", \"rotate\"]] = None\n    free_shipping: Optional[bool] = None\n    new_publish_option: Optional[str] = None\n    region: Optional[str] = None\n    decision_mode: Optional[Literal[\"ai\", \"keyword\"]] = None\n    keyword_rules: Optional[List[str]] = None\n    is_running: Optional[bool] = None\n\n    @model_validator(mode=\"before\")\n    @classmethod\n    def normalize_legacy_keyword_payload(cls, values):\n        return _normalize_payload_keywords(values)\n\n    @field_validator(\"min_price\", \"max_price\", mode=\"before\")\n    @classmethod\n    def convert_price_to_str(cls, value):\n        return _normalize_price_value(value)\n\n    @field_validator(\"cron\", mode=\"before\")\n    @classmethod\n    def normalize_cron(cls, value):\n        return _normalize_optional_string(value)\n\n    @field_validator(\"account_state_file\", mode=\"before\")\n    @classmethod\n    def normalize_account_state_file(cls, value):\n        return clean_account_state_file(value)\n\n    @field_validator(\"cron\")\n    @classmethod\n    def validate_cron(cls, value):\n        return _validate_cron_expression(value)\n\n    @field_validator(\"keyword_rules\", mode=\"before\")\n    @classmethod\n    def normalize_keyword_rules(cls, value):\n        return _normalize_keyword_values(value)\n\n    @model_validator(mode=\"after\")\n    def validate_partial_keyword_payload(self):\n        if self.decision_mode == \"keyword\" and self.keyword_rules is not None:\n            if not _has_keyword_rules(self.keyword_rules):\n                raise ValueError(\"关键词判断模式下，至少需要一个关键词。\")\n        if self.decision_mode == \"ai\" and self.description is not None:\n            if not str(self.description).strip():\n                raise ValueError(\"AI 判断模式下，详细需求(description)不能为空。\")\n        return self\n\n\nclass TaskGenerateRequest(BaseModel):\n    \"\"\"任务创建请求DTO（AI模式支持自动生成标准）\"\"\"\n\n    model_config = ConfigDict(extra=\"ignore\")\n\n    task_name: str\n    keyword: str\n    description: Optional[str] = \"\"\n    analyze_images: bool = True\n    personal_only: bool = True\n    min_price: Optional[str] = None\n    max_price: Optional[str] = None\n    max_pages: int = 3\n    cron: Optional[str] = None\n    account_state_file: Optional[str] = None\n    account_strategy: Literal[\"auto\", \"fixed\", \"rotate\"] = \"auto\"\n    free_shipping: bool = True\n    new_publish_option: Optional[str] = None\n    region: Optional[str] = None\n    decision_mode: Literal[\"ai\", \"keyword\"] = \"ai\"\n    keyword_rules: List[str] = Field(default_factory=list)\n\n    @model_validator(mode=\"before\")\n    @classmethod\n    def normalize_legacy_keyword_payload(cls, values):\n        return _normalize_payload_keywords(values)\n\n    @field_validator(\"min_price\", \"max_price\", mode=\"before\")\n    @classmethod\n    def convert_price_to_str(cls, value):\n        return _normalize_price_value(value)\n\n    @field_validator(\"cron\", mode=\"before\")\n    @classmethod\n    def empty_str_to_none(cls, value):\n        return _normalize_optional_string(value)\n\n    @field_validator(\"cron\")\n    @classmethod\n    def validate_cron(cls, value):\n        return _validate_cron_expression(value)\n\n    @field_validator(\"account_state_file\", mode=\"before\")\n    @classmethod\n    def empty_account_to_none(cls, value):\n        return _normalize_optional_string(value)\n\n    @field_validator(\"new_publish_option\", \"region\", mode=\"before\")\n    @classmethod\n    def empty_str_to_none_for_strings(cls, value):\n        return _normalize_optional_string(value)\n\n    @field_validator(\"keyword_rules\", mode=\"before\")\n    @classmethod\n    def normalize_keyword_rules(cls, value):\n        return _normalize_keyword_values(value)\n\n    @model_validator(mode=\"after\")\n    def validate_decision_mode_payload(self):\n        description = str(self.description or \"\").strip()\n        if self.decision_mode == \"ai\" and not description:\n            raise ValueError(\"AI 判断模式下，详细需求(description)不能为空。\")\n        if self.decision_mode == \"keyword\" and not _has_keyword_rules(self.keyword_rules):\n            raise ValueError(\"关键词判断模式下，至少需要一个关键词。\")\n        if self.account_strategy == \"fixed\" and not self.account_state_file:\n            raise ValueError(\"固定账号模式下必须选择账号。\")\n        return self\n"
  },
  {
    "path": "src/domain/models/task_generation.py",
    "content": "\"\"\"\n任务生成作业模型\n\"\"\"\nfrom typing import List, Literal, Optional\n\nfrom pydantic import BaseModel, Field\n\nfrom src.domain.models.task import Task\n\n\nTaskGenerationStatus = Literal[\"queued\", \"running\", \"completed\", \"failed\"]\nTaskGenerationStepStatus = Literal[\"pending\", \"running\", \"completed\", \"failed\"]\n\n\nclass TaskGenerationStep(BaseModel):\n    \"\"\"单个任务生成步骤\"\"\"\n\n    key: str\n    label: str\n    status: TaskGenerationStepStatus = \"pending\"\n    message: str = \"\"\n\n\nclass TaskGenerationJob(BaseModel):\n    \"\"\"任务生成作业\"\"\"\n\n    job_id: str\n    task_name: str\n    status: TaskGenerationStatus = \"queued\"\n    message: str = \"任务已排队，等待开始。\"\n    current_step: Optional[str] = None\n    steps: List[TaskGenerationStep] = Field(default_factory=list)\n    task: Optional[Task] = None\n    error: Optional[str] = None\n"
  },
  {
    "path": "src/domain/repositories/__init__.py",
    "content": ""
  },
  {
    "path": "src/domain/repositories/task_repository.py",
    "content": "\"\"\"\n任务仓储层\n负责任务数据的持久化操作\n\"\"\"\nfrom typing import List, Optional\nfrom abc import ABC, abstractmethod\nimport json\nimport aiofiles\nfrom src.domain.models.task import Task\n\n\nclass TaskRepository(ABC):\n    \"\"\"任务仓储接口\"\"\"\n\n    @abstractmethod\n    async def find_all(self) -> List[Task]:\n        \"\"\"获取所有任务\"\"\"\n        pass\n\n    @abstractmethod\n    async def find_by_id(self, task_id: int) -> Optional[Task]:\n        \"\"\"根据ID获取任务\"\"\"\n        pass\n\n    @abstractmethod\n    async def save(self, task: Task) -> Task:\n        \"\"\"保存任务（创建或更新）\"\"\"\n        pass\n\n    @abstractmethod\n    async def delete(self, task_id: int) -> bool:\n        \"\"\"删除任务\"\"\"\n        pass\n"
  },
  {
    "path": "src/failure_guard.py",
    "content": "\"\"\"Task-level failure circuit breaker.\n\n目标:\n- 当登录态失效/风控导致任务持续失败时，避免无限重试、避免高频请求。\n- 失败达到阈值后暂停任务一段时间。\n- 暂停期间最多每天通知一次，直到用户更新 cookies / 登录态文件后自动恢复。\n\n说明:\n- 仅使用标准库，既可被 API 主进程使用，也可被爬虫子进程使用。\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport os\nimport time\nfrom dataclasses import dataclass\nfrom datetime import datetime, timedelta\nfrom typing import Any, Optional\n\n\ntry:\n    from zoneinfo import ZoneInfo  # py3.9+\n\n    def _load_tz(name: str):\n        return ZoneInfo(name)\n\n\nexcept Exception:  # pragma: no cover\n\n    def _load_tz(name: str):\n        return None\n\n\ndef _as_int(value: Any, default: int) -> int:\n    try:\n        return int(value)\n    except (TypeError, ValueError):\n        return default\n\n\ndef _now(tz_name: str, now: Optional[datetime] = None) -> datetime:\n    if now is not None:\n        return now\n    tz = _load_tz(tz_name)\n    if tz is None:\n        return datetime.now()\n    return datetime.now(tz)\n\n\ndef _today_str(tz_name: str, now: Optional[datetime] = None) -> str:\n    return _now(tz_name, now=now).date().isoformat()\n\n\ndef _dt_to_str(dt: Optional[datetime]) -> Optional[str]:\n    if dt is None:\n        return None\n    return dt.isoformat()\n\n\ndef _str_to_dt(value: Optional[str]) -> Optional[datetime]:\n    if not value:\n        return None\n    try:\n        return datetime.fromisoformat(value)\n    except ValueError:\n        return None\n\n\ndef _get_mtime(path: Optional[str]) -> Optional[float]:\n    if not path:\n        return None\n    try:\n        return os.path.getmtime(path)\n    except OSError:\n        return None\n\n\ndef _cookie_changed(\n    cookie_path: Optional[str], previous_mtime: Optional[float]\n) -> bool:\n    if not cookie_path:\n        return False\n    current = _get_mtime(cookie_path)\n    if current is None or previous_mtime is None:\n        return False\n    return current > (previous_mtime + 1e-6)\n\n\nclass _FileLock:\n    def __init__(self, fh):\n        self._fh = fh\n\n    def __enter__(self):\n        try:\n            import fcntl\n\n            fcntl.flock(self._fh.fileno(), fcntl.LOCK_EX)\n        except Exception:\n            pass\n        return self\n\n    def __exit__(self, exc_type, exc, tb):\n        try:\n            import fcntl\n\n            fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN)\n        except Exception:\n            pass\n        return False\n\n\ndef _ensure_parent_dir(path: str) -> None:\n    parent = os.path.dirname(path)\n    if parent:\n        os.makedirs(parent, exist_ok=True)\n\n\ndef _read_json_file(path: str) -> dict:\n    try:\n        with open(path, \"r\", encoding=\"utf-8\") as f:\n            data = json.load(f)\n        return data if isinstance(data, dict) else {}\n    except FileNotFoundError:\n        return {}\n    except Exception:\n        # 文件损坏时保留现场，避免无限解析失败。\n        try:\n            ts = str(int(time.time()))\n            os.replace(path, f\"{path}.corrupt.{ts}\")\n        except Exception:\n            pass\n        return {}\n\n\ndef _atomic_write_json(path: str, data: dict) -> None:\n    _ensure_parent_dir(path)\n    tmp = f\"{path}.tmp\"\n    with open(tmp, \"w\", encoding=\"utf-8\") as f:\n        json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True)\n        f.flush()\n        os.fsync(f.fileno())\n    os.replace(tmp, path)\n\n\n@dataclass(frozen=True)\nclass SkipDecision:\n    skip: bool\n    should_notify: bool\n    reason: str\n    paused_until: Optional[datetime]\n    consecutive_failures: int\n\n\nclass FailureGuard:\n    def __init__(\n        self,\n        path: Optional[str] = None,\n        *,\n        threshold: Optional[int] = None,\n        pause_seconds: Optional[int] = None,\n        tz_name: Optional[str] = None,\n    ):\n        self.path = (\n            path\n            or os.getenv(\"TASK_FAILURE_GUARD_PATH\")\n            or \"logs/task-failure-guard.json\"\n        )\n        self.threshold = max(\n            1, threshold or _as_int(os.getenv(\"TASK_FAILURE_THRESHOLD\"), 3)\n        )\n        self.pause_seconds = max(\n            60,\n            pause_seconds\n            or _as_int(os.getenv(\"TASK_FAILURE_PAUSE_SECONDS\"), 24 * 60 * 60),\n        )\n        self.tz_name = tz_name or os.getenv(\"TASK_FAILURE_TZ\") or \"Asia/Shanghai\"\n\n    def _load(self) -> dict:\n        data = _read_json_file(self.path)\n        if \"tasks\" not in data or not isinstance(data.get(\"tasks\"), dict):\n            data = {\"version\": 1, \"tasks\": {}}\n        data.setdefault(\"version\", 1)\n        return data\n\n    def _save(self, data: dict) -> None:\n        _atomic_write_json(self.path, data)\n\n    def _update_task(self, task_key: str, updater) -> dict:\n        _ensure_parent_dir(self.path)\n        with open(self.path, \"a+\", encoding=\"utf-8\") as fh:\n            with _FileLock(fh):\n                fh.seek(0)\n                data = self._load()\n                tasks = data.setdefault(\"tasks\", {})\n                entry = tasks.get(task_key) or {}\n                if not isinstance(entry, dict):\n                    entry = {}\n                entry = updater(entry) or entry\n                tasks[task_key] = entry\n                self._save(data)\n                return entry\n\n    def record_success(self, task_key: str, *, now: Optional[datetime] = None) -> None:\n        def _reset(_: dict) -> dict:\n            current = _now(self.tz_name, now=now)\n            return {\n                \"consecutive_failures\": 0,\n                \"paused_until\": None,\n                \"last_notified_date\": None,\n                \"last_failure_reason\": None,\n                \"last_failure_at\": None,\n                \"last_success_at\": _dt_to_str(current),\n                \"cookie_path\": None,\n                \"cookie_mtime\": None,\n            }\n\n        self._update_task(task_key, _reset)\n\n    def should_skip_start(\n        self,\n        task_key: str,\n        *,\n        cookie_path: Optional[str] = None,\n        now: Optional[datetime] = None,\n    ) -> SkipDecision:\n        current = _now(self.tz_name, now=now)\n        today = _today_str(self.tz_name, now=current)\n\n        data = self._load()\n        entry = (data.get(\"tasks\") or {}).get(task_key) or {}\n        if not isinstance(entry, dict):\n            entry = {}\n\n        paused_until = _str_to_dt(entry.get(\"paused_until\"))\n        consecutive = _as_int(entry.get(\"consecutive_failures\"), 0)\n        last_reason = (entry.get(\"last_failure_reason\") or \"\").strip() or \"未知错误\"\n        last_notified_date = entry.get(\"last_notified_date\")\n\n        previous_cookie_mtime = entry.get(\"cookie_mtime\")\n        if cookie_path and previous_cookie_mtime is not None:\n            try:\n                previous_cookie_mtime = float(previous_cookie_mtime)\n            except (TypeError, ValueError):\n                previous_cookie_mtime = None\n\n        if (\n            paused_until\n            and paused_until > current\n            and cookie_path\n            and _cookie_changed(cookie_path, previous_cookie_mtime)\n        ):\n            # cookies / 登录态更新 => 自动恢复\n            self.record_success(task_key, now=current)\n            return SkipDecision(\n                skip=False,\n                should_notify=False,\n                reason=\"cookie_updated\",\n                paused_until=None,\n                consecutive_failures=0,\n            )\n\n        if paused_until and current < paused_until:\n            should_notify = last_notified_date != today\n\n            if should_notify:\n\n                def _touch(e: dict) -> dict:\n                    e = dict(e or {})\n                    e[\"last_notified_date\"] = today\n                    return e\n\n                self._update_task(task_key, _touch)\n\n            return SkipDecision(\n                skip=True,\n                should_notify=should_notify,\n                reason=last_reason,\n                paused_until=paused_until,\n                consecutive_failures=consecutive,\n            )\n\n        return SkipDecision(\n            skip=False,\n            should_notify=False,\n            reason=\"not_paused\",\n            paused_until=None,\n            consecutive_failures=consecutive,\n        )\n\n    def record_failure(\n        self,\n        task_key: str,\n        reason: str,\n        *,\n        cookie_path: Optional[str] = None,\n        min_failures_to_pause: Optional[int] = None,\n        now: Optional[datetime] = None,\n    ) -> dict:\n        current = _now(self.tz_name, now=now)\n        today = _today_str(self.tz_name, now=current)\n        cookie_mtime = _get_mtime(cookie_path)\n\n        effective_threshold = max(1, int(min_failures_to_pause or self.threshold))\n\n        result = {\n            \"should_notify\": False,\n            \"opened_circuit\": False,\n            \"paused_until\": None,\n            \"consecutive_failures\": 0,\n        }\n\n        def _apply(entry: dict) -> dict:\n            entry = dict(entry or {})\n            previous_paused_until = _str_to_dt(entry.get(\"paused_until\"))\n            was_paused = bool(previous_paused_until and current < previous_paused_until)\n\n            prev_mtime = entry.get(\"cookie_mtime\")\n            try:\n                prev_mtime = float(prev_mtime) if prev_mtime is not None else None\n            except (TypeError, ValueError):\n                prev_mtime = None\n\n            if cookie_path and _cookie_changed(cookie_path, prev_mtime):\n                entry[\"consecutive_failures\"] = 0\n                entry[\"paused_until\"] = None\n                entry[\"last_notified_date\"] = None\n\n            consecutive = _as_int(entry.get(\"consecutive_failures\"), 0) + 1\n            entry[\"consecutive_failures\"] = consecutive\n            entry[\"last_failure_reason\"] = (reason or \"未知错误\")[:1000]\n            entry[\"last_failure_at\"] = _dt_to_str(current)\n            if cookie_path:\n                entry[\"cookie_path\"] = cookie_path\n                if cookie_mtime is not None:\n                    entry[\"cookie_mtime\"] = cookie_mtime\n\n            opened = False\n            if consecutive >= effective_threshold:\n                paused_until = current + timedelta(seconds=self.pause_seconds)\n                entry[\"paused_until\"] = _dt_to_str(paused_until)\n                opened = not was_paused\n\n                if entry.get(\"last_notified_date\") != today:\n                    entry[\"last_notified_date\"] = today\n                    result[\"should_notify\"] = True\n\n                result[\"paused_until\"] = paused_until\n            else:\n                entry[\"paused_until\"] = None\n\n            result[\"opened_circuit\"] = opened\n            result[\"consecutive_failures\"] = consecutive\n            return entry\n\n        self._update_task(task_key, _apply)\n        return result\n"
  },
  {
    "path": "src/infrastructure/__init__.py",
    "content": ""
  },
  {
    "path": "src/infrastructure/config/__init__.py",
    "content": "from .settings import settings, AppSettings, AISettings, NotificationSettings\n\n__all__ = [\"settings\", \"AppSettings\", \"AISettings\", \"NotificationSettings\"]\n"
  },
  {
    "path": "src/infrastructure/config/env_manager.py",
    "content": "\"\"\"\n环境变量管理器\n负责读取和更新 .env 文件，并在读取时回退到运行时环境变量\n\"\"\"\nimport os\nimport re\nfrom typing import Dict, List, Optional\nfrom pathlib import Path\n\nfrom dotenv import dotenv_values\n\n\n_PLAIN_ENV_VALUE_PATTERN = re.compile(r\"^[A-Za-z0-9_./:-]+$\")\n\n\nclass EnvManager:\n    \"\"\"环境变量管理器\"\"\"\n\n    def __init__(self, env_file: str = \".env\"):\n        self.env_file = Path(env_file)\n        self._ensure_env_file_exists()\n\n    def _ensure_env_file_exists(self):\n        \"\"\"确保 .env 文件存在\"\"\"\n        if not self.env_file.exists():\n            self.env_file.touch()\n\n    def read_env(self) -> Dict[str, str]:\n        \"\"\"读取所有环境变量\"\"\"\n        if not self.env_file.exists():\n            return {}\n\n        loaded = dotenv_values(self.env_file, encoding=\"utf-8\")\n        return {\n            key: value\n            for key, value in loaded.items()\n            if key and value is not None\n        }\n\n    def get_value(self, key: str, default: Optional[str] = None) -> Optional[str]:\n        \"\"\"获取单个环境变量的值，优先返回运行时环境变量\"\"\"\n        runtime_value = os.getenv(key)\n        if runtime_value is not None:\n            return runtime_value\n\n        env_vars = self.read_env()\n        return env_vars.get(key, default)\n\n    def update_values(self, updates: Dict[str, str]) -> bool:\n        \"\"\"批量更新环境变量\"\"\"\n        return self.apply_changes(updates=updates)\n\n    def apply_changes(\n        self,\n        updates: Dict[str, str],\n        deletions: List[str] | None = None,\n    ) -> bool:\n        \"\"\"批量更新并删除环境变量\"\"\"\n        try:\n            existing_vars = self.read_env()\n            existing_vars.update(updates)\n            for key in deletions or []:\n                existing_vars.pop(key, None)\n            return self._write_env(existing_vars)\n        except Exception as e:\n            print(f\"更新环境变量失败: {e}\")\n            return False\n\n    def set_value(self, key: str, value: str) -> bool:\n        \"\"\"设置单个环境变量\"\"\"\n        return self.update_values({key: value})\n\n    def delete_keys(self, keys: List[str]) -> bool:\n        \"\"\"删除指定的环境变量\"\"\"\n        try:\n            existing_vars = self.read_env()\n            for key in keys:\n                existing_vars.pop(key, None)\n            return self._write_env(existing_vars)\n        except Exception as e:\n            print(f\"删除环境变量失败: {e}\")\n            return False\n\n    def _write_env(self, env_vars: Dict[str, str]) -> bool:\n        \"\"\"写入环境变量到文件\"\"\"\n        try:\n            with open(self.env_file, 'w', encoding='utf-8') as f:\n                for key, value in env_vars.items():\n                    f.write(f\"{key}={self._serialize_value(value)}\\n\")\n            return True\n        except Exception as e:\n            print(f\"写入 .env 文件失败: {e}\")\n            return False\n\n    def _serialize_value(self, value: str) -> str:\n        text = str(value)\n        if text == \"\":\n            return \"\"\n        if _PLAIN_ENV_VALUE_PATTERN.fullmatch(text):\n            return text\n        escaped = text.replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"').replace(\"\\n\", \"\\\\n\")\n        return f'\"{escaped}\"'\n\n\n# 全局实例\nenv_manager = EnvManager()\n"
  },
  {
    "path": "src/infrastructure/config/settings.py",
    "content": "\"\"\"\n统一配置管理模块\n使用 Pydantic 进行类型安全的配置管理\n\"\"\"\ntry:\n    from pydantic_settings import BaseSettings, SettingsConfigDict\n    _USING_PYDANTIC_SETTINGS = True\nexcept ImportError:\n    from pydantic import BaseSettings\n    _USING_PYDANTIC_SETTINGS = False\nfrom pydantic import Field\nfrom typing import Optional\nimport os\n\nDEFAULT_TELEGRAM_API_BASE_URL = \"https://api.telegram.org\"\n\n\ndef _env_field(default, env_name: str, **kwargs):\n    if _USING_PYDANTIC_SETTINGS:\n        return Field(default, validation_alias=env_name, **kwargs)\n    return Field(default, env=env_name, **kwargs)\n\n\nif _USING_PYDANTIC_SETTINGS:\n    class _EnvSettings(BaseSettings):\n        model_config = SettingsConfigDict(\n            env_file=\".env\",\n            env_file_encoding=\"utf-8\",\n            extra=\"ignore\",\n            protected_namespaces=(),\n        )\nelse:\n    class _EnvSettings(BaseSettings):\n        class Config:\n            env_file = \".env\"\n            env_file_encoding = \"utf-8\"\n            extra = \"ignore\"\n            protected_namespaces = ()\n\n\nclass AISettings(_EnvSettings):\n    \"\"\"AI模型配置\"\"\"\n    api_key: Optional[str] = _env_field(None, \"OPENAI_API_KEY\")\n    base_url: str = _env_field(\"\", \"OPENAI_BASE_URL\")\n    model_name: str = _env_field(\"\", \"OPENAI_MODEL_NAME\")\n    proxy_url: Optional[str] = _env_field(None, \"PROXY_URL\")\n    debug_mode: bool = _env_field(False, \"AI_DEBUG_MODE\")\n    enable_response_format: bool = _env_field(True, \"ENABLE_RESPONSE_FORMAT\")\n    enable_thinking: bool = _env_field(False, \"ENABLE_THINKING\")\n    skip_analysis: bool = _env_field(False, \"SKIP_AI_ANALYSIS\")\n\n    def is_configured(self) -> bool:\n        \"\"\"检查AI是否已正确配置\"\"\"\n        return bool(self.base_url and self.model_name)\n\n\nclass NotificationSettings(_EnvSettings):\n    \"\"\"通知服务配置\"\"\"\n    ntfy_topic_url: Optional[str] = _env_field(None, \"NTFY_TOPIC_URL\")\n    gotify_url: Optional[str] = _env_field(None, \"GOTIFY_URL\")\n    gotify_token: Optional[str] = _env_field(None, \"GOTIFY_TOKEN\")\n    bark_url: Optional[str] = _env_field(None, \"BARK_URL\")\n    wx_bot_url: Optional[str] = _env_field(None, \"WX_BOT_URL\")\n    telegram_bot_token: Optional[str] = _env_field(None, \"TELEGRAM_BOT_TOKEN\")\n    telegram_chat_id: Optional[str] = _env_field(None, \"TELEGRAM_CHAT_ID\")\n    telegram_api_base_url: Optional[str] = _env_field(\n        DEFAULT_TELEGRAM_API_BASE_URL,\n        \"TELEGRAM_API_BASE_URL\",\n    )\n    webhook_url: Optional[str] = _env_field(None, \"WEBHOOK_URL\")\n    webhook_method: str = _env_field(\"POST\", \"WEBHOOK_METHOD\")\n    webhook_headers: Optional[str] = _env_field(None, \"WEBHOOK_HEADERS\")\n    webhook_content_type: str = _env_field(\"JSON\", \"WEBHOOK_CONTENT_TYPE\")\n    webhook_query_parameters: Optional[str] = _env_field(None, \"WEBHOOK_QUERY_PARAMETERS\")\n    webhook_body: Optional[str] = _env_field(None, \"WEBHOOK_BODY\")\n    pcurl_to_mobile: bool = _env_field(True, \"PCURL_TO_MOBILE\")\n\n    def has_any_notification_enabled(self) -> bool:\n        \"\"\"检查是否配置了任何通知服务\"\"\"\n        return any([\n            self.ntfy_topic_url,\n            self.wx_bot_url,\n            self.gotify_url and self.gotify_token,\n            self.bark_url,\n            self.telegram_bot_token and self.telegram_chat_id,\n            self.webhook_url\n        ])\n\n\nclass ScraperSettings(_EnvSettings):\n    \"\"\"爬虫相关配置\"\"\"\n    run_headless: bool = _env_field(True, \"RUN_HEADLESS\")\n    login_is_edge: bool = _env_field(False, \"LOGIN_IS_EDGE\")\n    running_in_docker: bool = _env_field(False, \"RUNNING_IN_DOCKER\")\n    state_file: str = _env_field(\"xianyu_state.json\", \"STATE_FILE\")\n\n\nclass AppSettings(_EnvSettings):\n    \"\"\"应用主配置\"\"\"\n    server_port: int = _env_field(8000, \"SERVER_PORT\")\n    web_username: str = _env_field(\"admin\", \"WEB_USERNAME\")\n    web_password: str = _env_field(\"admin123\", \"WEB_PASSWORD\")\n    task_log_retention_days: int = _env_field(7, \"TASK_LOG_RETENTION_DAYS\", ge=1)\n\n    # 文件路径配置\n    config_file: str = \"config.json\"\n    image_save_dir: str = \"images\"\n    task_image_dir_prefix: str = \"task_images_\"\n\n    def __init__(self, **kwargs):\n        super().__init__(**kwargs)\n        # 创建必要的目录\n        os.makedirs(self.image_save_dir, exist_ok=True)\n\n\n# 全局配置实例（单例模式）\n_settings_instance = None\n\ndef get_settings() -> AppSettings:\n    \"\"\"获取全局配置实例\"\"\"\n    global _settings_instance\n    if _settings_instance is None:\n        _settings_instance = AppSettings()\n    return _settings_instance\n\n\ndef reload_settings() -> None:\n    \"\"\"重新加载全局配置实例\"\"\"\n    global _settings_instance, settings, ai_settings, notification_settings, scraper_settings\n    from dotenv import load_dotenv\n    from src.infrastructure.config.env_manager import env_manager\n\n    load_dotenv(dotenv_path=env_manager.env_file, override=True)\n    _settings_instance = None\n    settings = get_settings()\n    ai_settings = AISettings()\n    notification_settings = NotificationSettings()\n    scraper_settings = ScraperSettings()\n\n\n# 导出便捷访问的配置实例\nsettings = get_settings()\nai_settings = AISettings()\nnotification_settings = NotificationSettings()\nscraper_settings = ScraperSettings()\n"
  },
  {
    "path": "src/infrastructure/external/__init__.py",
    "content": ""
  },
  {
    "path": "src/infrastructure/external/ai_client.py",
    "content": "\"\"\"\nAI 客户端封装\n提供统一的 AI 调用接口\n\"\"\"\nimport os\nimport json\nimport base64\nfrom typing import Dict, List, Optional\nfrom datetime import datetime\nfrom dotenv import load_dotenv\nfrom openai import AsyncOpenAI\nfrom src.ai_message_builder import (\n    build_analysis_text_prompt,\n    build_user_message_content,\n)\nfrom src.infrastructure.config.settings import AISettings\nfrom src.infrastructure.config.env_manager import env_manager\nfrom src.services.ai_request_compat import (\n    CHAT_COMPLETIONS_API_MODE,\n    RESPONSES_API_MODE,\n    build_ai_request_params,\n    create_ai_response_async,\n    is_chat_completions_api_unsupported_error,\n    is_json_output_unsupported_error,\n    is_responses_api_unsupported_error,\n    is_temperature_unsupported_error,\n    remove_temperature_param,\n)\nfrom src.services.ai_response_parser import (\n    EmptyAIResponseError,\n    extract_ai_response_content,\n    parse_ai_response_json,\n)\n\n\nclass AIClient:\n    \"\"\"AI 客户端封装\"\"\"\n\n    def __init__(self):\n        self.settings: Optional[AISettings] = None\n        self.client: Optional[AsyncOpenAI] = None\n        self.refresh()\n\n    def _load_settings(self) -> None:\n        load_dotenv(dotenv_path=env_manager.env_file, override=True)\n        self.settings = AISettings()\n\n    def refresh(self) -> None:\n        self._load_settings()\n        self.client = self._initialize_client()\n\n    def _initialize_client(self) -> Optional[AsyncOpenAI]:\n        \"\"\"初始化 OpenAI 客户端\"\"\"\n        if not self.settings or not self.settings.is_configured():\n            print(\"警告：AI 配置不完整，AI 功能将不可用\")\n            return None\n\n        try:\n            if self.settings.proxy_url:\n                print(f\"正在为 AI 请求使用代理: {self.settings.proxy_url}\")\n                os.environ['HTTP_PROXY'] = self.settings.proxy_url\n                os.environ['HTTPS_PROXY'] = self.settings.proxy_url\n\n            return AsyncOpenAI(\n                api_key=self.settings.api_key,\n                base_url=self.settings.base_url\n            )\n        except Exception as e:\n            print(f\"初始化 AI 客户端失败: {e}\")\n            return None\n\n    def is_available(self) -> bool:\n        \"\"\"检查 AI 客户端是否可用\"\"\"\n        return self.client is not None\n\n    async def close(self) -> None:\n        \"\"\"关闭底层异步客户端，避免事件循环结束后再触发清理。\"\"\"\n        client = self.client\n        self.client = None\n        if client is None:\n            return\n\n        close = getattr(client, \"close\", None)\n        if close is None:\n            return\n        await close()\n\n    @staticmethod\n    def encode_image(image_path: str) -> Optional[str]:\n        \"\"\"将图片编码为 Base64\"\"\"\n        if not image_path or not os.path.exists(image_path):\n            return None\n        try:\n            with open(image_path, \"rb\") as f:\n                return base64.b64encode(f.read()).decode('utf-8')\n        except Exception as e:\n            print(f\"编码图片失败: {e}\")\n            return None\n\n    async def analyze(\n        self,\n        product_data: Dict,\n        image_paths: List[str],\n        prompt_text: str\n    ) -> Optional[Dict]:\n        \"\"\"\n        分析商品数据\n\n        Args:\n            product_data: 商品数据\n            image_paths: 图片路径列表\n            prompt_text: 分析提示词\n\n        Returns:\n            分析结果\n        \"\"\"\n        if not self.is_available():\n            print(\"AI 客户端不可用\")\n            return None\n\n        try:\n            messages = self._build_messages(product_data, image_paths, prompt_text)\n            response = await self._call_ai(messages)\n            return self._parse_response(response)\n        except Exception as e:\n            print(f\"AI 分析失败: {e}\")\n            return None\n\n    def _build_messages(self, product_data: Dict, image_paths: List[str], prompt_text: str) -> List[Dict]:\n        \"\"\"构建 AI 消息\"\"\"\n        product_json = json.dumps(product_data, ensure_ascii=False, indent=2)\n        image_data_urls: List[str] = []\n        for path in image_paths:\n            base64_img = self.encode_image(path)\n            if base64_img:\n                image_data_urls.append(f\"data:image/jpeg;base64,{base64_img}\")\n\n        text_prompt = build_analysis_text_prompt(\n            product_json,\n            prompt_text,\n            include_images=bool(image_data_urls),\n        )\n        user_content = build_user_message_content(text_prompt, image_data_urls)\n        return [{\"role\": \"user\", \"content\": user_content}]\n\n    async def _call_ai(\n        self,\n        messages: List[Dict],\n        *,\n        temperature: float = 0.1,\n        max_output_tokens: int = 4000,\n        enable_json_output: Optional[bool] = None,\n    ) -> str:\n        \"\"\"调用 AI API\"\"\"\n        api_mode = CHAT_COMPLETIONS_API_MODE\n        use_response_format = (\n            self.settings.enable_response_format\n            if enable_json_output is None\n            else enable_json_output\n        )\n        use_temperature = True\n        max_attempts = 4\n\n        for attempt in range(max_attempts):\n            request_params = build_ai_request_params(\n                api_mode,\n                model=self.settings.model_name,\n                messages=messages,\n                temperature=temperature,\n                max_output_tokens=max_output_tokens,\n                enable_json_output=use_response_format,\n            )\n            if not use_temperature:\n                request_params = remove_temperature_param(request_params)\n\n            if self.settings.enable_thinking:\n                request_params[\"extra_body\"] = {\"enable_thinking\": False}\n\n            try:\n                response = await create_ai_response_async(\n                    self.client,\n                    api_mode,\n                    request_params,\n                )\n                return extract_ai_response_content(response)\n            except EmptyAIResponseError as exc:\n                if attempt < max_attempts - 1:\n                    print(\n                        f\"AI响应为空，正在自动重试 ({attempt + 2}/{max_attempts})\"\n                    )\n                    continue\n                raise exc\n            except Exception as exc:\n                changed = False\n                if (\n                    api_mode == CHAT_COMPLETIONS_API_MODE\n                    and is_chat_completions_api_unsupported_error(exc)\n                ):\n                    api_mode = RESPONSES_API_MODE\n                    changed = True\n                    print(\"当前服务未实现 Chat Completions API，正在自动回退到 Responses API\")\n                elif (\n                    api_mode == RESPONSES_API_MODE\n                    and is_responses_api_unsupported_error(exc)\n                ):\n                    api_mode = CHAT_COMPLETIONS_API_MODE\n                    changed = True\n                    print(\"当前服务未实现 Responses API，正在自动回退到 Chat Completions API\")\n                if use_response_format and is_json_output_unsupported_error(exc):\n                    use_response_format = False\n                    changed = True\n                    print(\"当前模型不支持结构化 JSON 输出，正在自动重试并移除该参数\")\n                if use_temperature and is_temperature_unsupported_error(exc):\n                    use_temperature = False\n                    changed = True\n                    print(\"当前模型不支持 temperature 参数，正在自动重试并移除该参数\")\n                if changed and attempt < max_attempts - 1:\n                    continue\n                raise\n\n        raise RuntimeError(\"AI 调用在兼容性重试后仍未返回结果\")\n\n    def _parse_response(self, response_text: str) -> Optional[Dict]:\n        \"\"\"解析 AI 响应\"\"\"\n        try:\n            return parse_ai_response_json(response_text)\n        except json.JSONDecodeError:\n            print(f\"无法解析 AI 响应: {response_text[:100]}\")\n            return None\n"
  },
  {
    "path": "src/infrastructure/external/notification_clients/__init__.py",
    "content": "from .base import NotificationClient, NotificationMessage\nfrom .bark_client import BarkClient\nfrom .gotify_client import GotifyClient\nfrom .ntfy_client import NtfyClient\nfrom .telegram_client import TelegramClient\nfrom .wecom_bot_client import WeComBotClient\nfrom .webhook_client import WebhookClient\n\n__all__ = [\n    \"NotificationClient\",\n    \"NotificationMessage\",\n    \"BarkClient\",\n    \"GotifyClient\",\n    \"NtfyClient\",\n    \"TelegramClient\",\n    \"WeComBotClient\",\n    \"WebhookClient\",\n]\n"
  },
  {
    "path": "src/infrastructure/external/notification_clients/bark_client.py",
    "content": "\"\"\"\nBark 通知客户端\n\"\"\"\nimport asyncio\nimport requests\nfrom typing import Dict\nfrom .base import NotificationClient\n\n\nclass BarkClient(NotificationClient):\n    \"\"\"Bark 通知客户端\"\"\"\n\n    channel_key = \"bark\"\n    display_name = \"Bark\"\n\n    def __init__(self, bark_url: str = None, pcurl_to_mobile: bool = True):\n        super().__init__(enabled=bool(bark_url), pcurl_to_mobile=pcurl_to_mobile)\n        self.bark_url = bark_url\n\n    async def send(self, product_data: Dict, reason: str) -> None:\n        \"\"\"发送 Bark 通知\"\"\"\n        if not self.is_enabled():\n            raise RuntimeError(\"Bark 未启用\")\n\n        message = self._build_message(product_data, reason)\n        bark_payload = {\n            \"title\": message.notification_title,\n            \"body\": message.content,\n            \"url\": message.mobile_link or message.desktop_link,\n            \"level\": \"timeSensitive\",\n            \"group\": \"闲鱼监控\"\n        }\n\n        if message.image_url:\n            bark_payload[\"icon\"] = message.image_url\n\n        headers = {\"Content-Type\": \"application/json; charset=utf-8\"}\n        loop = asyncio.get_running_loop()\n        response = await loop.run_in_executor(\n            None,\n            lambda: requests.post(\n                self.bark_url,\n                json=bark_payload,\n                headers=headers,\n                timeout=10\n            )\n        )\n        response.raise_for_status()\n"
  },
  {
    "path": "src/infrastructure/external/notification_clients/base.py",
    "content": "\"\"\"\n通知客户端基类\n定义通知客户端的统一接口\n\"\"\"\nfrom abc import ABC, abstractmethod\nfrom dataclasses import dataclass\nfrom typing import Dict\n\nfrom src.utils import convert_goofish_link\n\n\n@dataclass(frozen=True)\nclass NotificationMessage:\n    title: str\n    price: str\n    reason: str\n    desktop_link: str\n    mobile_link: str | None\n    notification_title: str\n    content: str\n    image_url: str | None\n\n\nclass NotificationClient(ABC):\n    \"\"\"通知客户端抽象基类\"\"\"\n\n    channel_key = \"unknown\"\n    display_name = \"未知渠道\"\n\n    def __init__(self, enabled: bool = False, pcurl_to_mobile: bool = True):\n        self._enabled = enabled\n        self._pcurl_to_mobile = pcurl_to_mobile\n\n    def is_enabled(self) -> bool:\n        \"\"\"检查客户端是否启用\"\"\"\n        return self._enabled\n\n    @abstractmethod\n    async def send(self, product_data: Dict, reason: str) -> bool:\n        \"\"\"\n        发送通知\n\n        Args:\n            product_data: 商品数据\n            reason: 推荐原因\n\n        Returns:\n            是否发送成功\n        \"\"\"\n        raise NotImplementedError\n\n    def _build_message(self, product_data: Dict, reason: str) -> NotificationMessage:\n        \"\"\"格式化消息内容\"\"\"\n        title = product_data.get('商品标题', 'N/A')\n        price = product_data.get('当前售价', 'N/A')\n        desktop_link = product_data.get('商品链接', '#')\n        mobile_link = None\n\n        if self._pcurl_to_mobile and desktop_link and desktop_link != \"#\":\n            mobile_link = convert_goofish_link(desktop_link)\n\n        content_lines = [\n            f\"价格: {price}\",\n            f\"原因: {reason}\",\n        ]\n        if mobile_link:\n            content_lines.append(f\"手机端链接: {mobile_link}\")\n            content_lines.append(f\"电脑端链接: {desktop_link}\")\n        else:\n            content_lines.append(f\"链接: {desktop_link}\")\n\n        short_title = title[:30]\n        suffix = \"...\" if len(title) > 30 else \"\"\n        notification_title = f\"🚨 新推荐! {short_title}{suffix}\"\n\n        main_image = product_data.get('商品主图链接')\n        if not main_image:\n            image_list = product_data.get('商品图片列表', [])\n            if image_list:\n                main_image = image_list[0]\n\n        return NotificationMessage(\n            title=title,\n            price=price,\n            reason=reason,\n            desktop_link=desktop_link,\n            mobile_link=mobile_link,\n            notification_title=notification_title,\n            content=\"\\n\".join(content_lines),\n            image_url=main_image,\n        )\n"
  },
  {
    "path": "src/infrastructure/external/notification_clients/factory.py",
    "content": "\"\"\"\n通知客户端工厂\n\"\"\"\nfrom src.infrastructure.config.settings import NotificationSettings\n\nfrom .bark_client import BarkClient\nfrom .gotify_client import GotifyClient\nfrom .ntfy_client import NtfyClient\nfrom .telegram_client import TelegramClient\nfrom .wecom_bot_client import WeComBotClient\nfrom .webhook_client import WebhookClient\n\n\ndef build_notification_clients(settings: NotificationSettings):\n    pcurl_to_mobile = settings.pcurl_to_mobile\n    return [\n        NtfyClient(settings.ntfy_topic_url, pcurl_to_mobile=pcurl_to_mobile),\n        BarkClient(settings.bark_url, pcurl_to_mobile=pcurl_to_mobile),\n        GotifyClient(\n            settings.gotify_url,\n            settings.gotify_token,\n            pcurl_to_mobile=pcurl_to_mobile,\n        ),\n        WeComBotClient(settings.wx_bot_url, pcurl_to_mobile=pcurl_to_mobile),\n        TelegramClient(\n            settings.telegram_bot_token,\n            settings.telegram_chat_id,\n            settings.telegram_api_base_url,\n            pcurl_to_mobile=pcurl_to_mobile,\n        ),\n        WebhookClient(\n            settings.webhook_url,\n            webhook_method=settings.webhook_method,\n            webhook_headers=settings.webhook_headers,\n            webhook_content_type=settings.webhook_content_type,\n            webhook_query_parameters=settings.webhook_query_parameters,\n            webhook_body=settings.webhook_body,\n            pcurl_to_mobile=pcurl_to_mobile,\n        ),\n    ]\n"
  },
  {
    "path": "src/infrastructure/external/notification_clients/gotify_client.py",
    "content": "\"\"\"\nGotify 通知客户端\n\"\"\"\nimport asyncio\nfrom typing import Dict\n\nimport requests\n\nfrom .base import NotificationClient\n\n\nclass GotifyClient(NotificationClient):\n    \"\"\"Gotify 通知客户端\"\"\"\n\n    channel_key = \"gotify\"\n    display_name = \"Gotify\"\n\n    def __init__(\n        self,\n        gotify_url: str | None = None,\n        gotify_token: str | None = None,\n        pcurl_to_mobile: bool = True,\n    ):\n        super().__init__(\n            enabled=bool(gotify_url and gotify_token),\n            pcurl_to_mobile=pcurl_to_mobile,\n        )\n        self.gotify_url = (gotify_url or \"\").rstrip(\"/\")\n        self.gotify_token = gotify_token\n\n    async def send(self, product_data: Dict, reason: str) -> None:\n        if not self.is_enabled():\n            raise RuntimeError(\"Gotify 未启用\")\n\n        message = self._build_message(product_data, reason)\n        payload = {\n            \"title\": (None, message.notification_title),\n            \"message\": (None, message.content),\n            \"priority\": (None, \"5\"),\n        }\n        final_url = f\"{self.gotify_url}/message?token={self.gotify_token}\"\n        loop = asyncio.get_running_loop()\n        response = await loop.run_in_executor(\n            None,\n            lambda: requests.post(final_url, files=payload, timeout=10),\n        )\n        response.raise_for_status()\n"
  },
  {
    "path": "src/infrastructure/external/notification_clients/ntfy_client.py",
    "content": "\"\"\"\nNtfy 通知客户端\n\"\"\"\nimport asyncio\nimport requests\nfrom typing import Dict\nfrom .base import NotificationClient\n\n\nclass NtfyClient(NotificationClient):\n    \"\"\"Ntfy 通知客户端\"\"\"\n\n    channel_key = \"ntfy\"\n    display_name = \"Ntfy\"\n\n    def __init__(self, topic_url: str = None, pcurl_to_mobile: bool = True):\n        super().__init__(enabled=bool(topic_url), pcurl_to_mobile=pcurl_to_mobile)\n        self.topic_url = topic_url\n\n    async def send(self, product_data: Dict, reason: str) -> None:\n        \"\"\"发送 Ntfy 通知\"\"\"\n        if not self.is_enabled():\n            raise RuntimeError(\"Ntfy 未启用\")\n\n        message = self._build_message(product_data, reason)\n        loop = asyncio.get_running_loop()\n        response = await loop.run_in_executor(\n            None,\n            lambda: requests.post(\n                self.topic_url,\n                data=message.content.encode('utf-8'),\n                headers={\n                    \"Title\": message.notification_title.encode('utf-8'),\n                    \"Priority\": \"urgent\",\n                    \"Tags\": \"bell,vibration\"\n                },\n                timeout=10\n            )\n        )\n        response.raise_for_status()\n"
  },
  {
    "path": "src/infrastructure/external/notification_clients/telegram_client.py",
    "content": "\"\"\"\nTelegram 通知客户端\n\"\"\"\nimport asyncio\nfrom typing import Dict\n\nimport requests\n\nfrom src.infrastructure.config.settings import DEFAULT_TELEGRAM_API_BASE_URL\n\nfrom .base import NotificationClient\n\n\nclass TelegramClient(NotificationClient):\n    \"\"\"Telegram 通知客户端\"\"\"\n\n    channel_key = \"telegram\"\n    display_name = \"Telegram\"\n\n    def __init__(\n        self,\n        bot_token: str = None,\n        chat_id: str = None,\n        api_base_url: str = DEFAULT_TELEGRAM_API_BASE_URL,\n        pcurl_to_mobile: bool = True,\n    ):\n        super().__init__(enabled=bool(bot_token and chat_id), pcurl_to_mobile=pcurl_to_mobile)\n        self.bot_token = bot_token\n        self.chat_id = chat_id\n        self.api_base_url = (\n            (api_base_url or DEFAULT_TELEGRAM_API_BASE_URL).rstrip(\"/\")\n        )\n\n    async def send(self, product_data: Dict, reason: str) -> None:\n        \"\"\"发送 Telegram 通知\"\"\"\n        if not self.is_enabled():\n            raise RuntimeError(\"Telegram 未启用\")\n\n        message = self._build_message(product_data, reason)\n        telegram_message = [\n            \"🚨 <b>新推荐!</b>\",\n            \"\",\n            f\"<b>{message.title[:50]}{'...' if len(message.title) > 50 else ''}</b>\",\n            \"\",\n            f\"💰 价格: {message.price}\",\n            f\"📝 原因: {message.reason}\",\n        ]\n        if message.mobile_link:\n            telegram_message.append(f\"📱 <a href='{message.mobile_link}'>手机端链接</a>\")\n        telegram_message.append(f\"💻 <a href='{message.desktop_link}'>电脑端链接</a>\")\n\n        telegram_api_url = f\"{self.api_base_url}/bot{self.bot_token}/sendMessage\"\n        telegram_payload = {\n            \"chat_id\": self.chat_id,\n            \"text\": \"\\n\".join(telegram_message),\n            \"parse_mode\": \"HTML\",\n            \"disable_web_page_preview\": False\n        }\n\n        headers = {\"Content-Type\": \"application/json\"}\n        loop = asyncio.get_running_loop()\n        response = await loop.run_in_executor(\n            None,\n            lambda: requests.post(\n                telegram_api_url,\n                json=telegram_payload,\n                headers=headers,\n                timeout=10\n            )\n        )\n        response.raise_for_status()\n        result = response.json()\n        if not result.get(\"ok\"):\n            raise RuntimeError(result.get(\"description\", \"Telegram 返回未知错误\"))\n"
  },
  {
    "path": "src/infrastructure/external/notification_clients/webhook_client.py",
    "content": "\"\"\"\n通用 Webhook 通知客户端\n\"\"\"\nimport asyncio\nimport json\nfrom typing import Any, Dict\nfrom urllib.parse import parse_qsl, urlencode, urlparse, urlunparse\n\nimport requests\n\nfrom .base import NotificationClient, NotificationMessage\n\n\nclass WebhookClient(NotificationClient):\n    \"\"\"通用 Webhook 通知客户端\"\"\"\n\n    channel_key = \"webhook\"\n    display_name = \"Webhook\"\n\n    def __init__(\n        self,\n        webhook_url: str | None = None,\n        webhook_method: str = \"POST\",\n        webhook_headers: str | None = None,\n        webhook_content_type: str = \"JSON\",\n        webhook_query_parameters: str | None = None,\n        webhook_body: str | None = None,\n        pcurl_to_mobile: bool = True,\n    ):\n        super().__init__(enabled=bool(webhook_url), pcurl_to_mobile=pcurl_to_mobile)\n        self.webhook_url = webhook_url\n        self.webhook_method = (webhook_method or \"POST\").upper()\n        self.webhook_headers = webhook_headers\n        self.webhook_content_type = (webhook_content_type or \"JSON\").upper()\n        self.webhook_query_parameters = webhook_query_parameters\n        self.webhook_body = webhook_body\n\n    async def send(self, product_data: Dict, reason: str) -> None:\n        if not self.is_enabled():\n            raise RuntimeError(\"Webhook 未启用\")\n\n        message = self._build_message(product_data, reason)\n        headers = self._parse_json(self.webhook_headers, \"WEBHOOK_HEADERS\", expect_dict=True) or {}\n        final_url = self._build_url(message)\n        loop = asyncio.get_running_loop()\n\n        if self.webhook_method == \"GET\":\n            response = await loop.run_in_executor(\n                None,\n                lambda: requests.get(final_url, headers=headers, timeout=15),\n            )\n            response.raise_for_status()\n            return\n\n        json_payload, form_payload = self._build_body(message, headers)\n        response = await loop.run_in_executor(\n            None,\n            lambda: requests.post(\n                final_url,\n                headers=headers,\n                json=json_payload,\n                data=form_payload,\n                timeout=15,\n            ),\n        )\n        response.raise_for_status()\n\n    def _build_url(self, message: NotificationMessage) -> str:\n        params = self._parse_json(\n            self.webhook_query_parameters,\n            \"WEBHOOK_QUERY_PARAMETERS\",\n            expect_dict=True,\n        ) or {}\n        rendered = self._render_template(params, message)\n        parsed_url = list(urlparse(self.webhook_url))\n        query = dict(parse_qsl(parsed_url[4]))\n        query.update(rendered)\n        parsed_url[4] = urlencode(query)\n        return urlunparse(parsed_url)\n\n    def _build_body(\n        self,\n        message: NotificationMessage,\n        headers: Dict[str, str],\n    ) -> tuple[Any | None, Any | None]:\n        if not self.webhook_body:\n            return None, None\n\n        body_template = self._parse_json(self.webhook_body, \"WEBHOOK_BODY\")\n        rendered_body = self._render_template(body_template, message)\n\n        if self.webhook_content_type == \"JSON\":\n            if \"Content-Type\" not in headers and \"content-type\" not in headers:\n                headers[\"Content-Type\"] = \"application/json; charset=utf-8\"\n            return rendered_body, None\n\n        if self.webhook_content_type == \"FORM\":\n            if not isinstance(rendered_body, dict):\n                raise ValueError(\"WEBHOOK_BODY 在 FORM 模式下必须是 JSON 对象\")\n            if \"Content-Type\" not in headers and \"content-type\" not in headers:\n                headers[\"Content-Type\"] = \"application/x-www-form-urlencoded\"\n            return None, rendered_body\n\n        raise ValueError(f\"不支持的 WEBHOOK_CONTENT_TYPE: {self.webhook_content_type}\")\n\n    def _parse_json(\n        self,\n        raw_value: str | None,\n        field_name: str,\n        expect_dict: bool = False,\n    ) -> Any | None:\n        if not raw_value:\n            return None\n        try:\n            parsed = json.loads(raw_value)\n        except json.JSONDecodeError as exc:\n            raise ValueError(f\"{field_name} 不是合法 JSON: {exc.msg}\") from exc\n        if expect_dict and not isinstance(parsed, dict):\n            raise ValueError(f\"{field_name} 必须是 JSON 对象\")\n        return parsed\n\n    def _render_template(self, value: Any, message: NotificationMessage) -> Any:\n        if isinstance(value, str):\n            return self._replace_placeholders(value, message)\n        if isinstance(value, list):\n            return [self._render_template(item, message) for item in value]\n        if isinstance(value, dict):\n            return {\n                key: self._render_template(item, message)\n                for key, item in value.items()\n            }\n        return value\n\n    def _replace_placeholders(self, value: str, message: NotificationMessage) -> str:\n        replacements = {\n            \"title\": message.notification_title,\n            \"content\": message.content,\n            \"price\": message.price,\n            \"reason\": message.reason,\n            \"desktop_link\": message.desktop_link,\n            \"mobile_link\": message.mobile_link or message.desktop_link,\n        }\n        rendered = value\n        for key, replacement in replacements.items():\n            rendered = rendered.replace(f\"${{{key}}}\", replacement)\n            rendered = rendered.replace(f\"{{{{{key}}}}}\", replacement)\n        return rendered\n"
  },
  {
    "path": "src/infrastructure/external/notification_clients/wecom_bot_client.py",
    "content": "\"\"\"\n企业微信机器人通知客户端\n\"\"\"\nimport asyncio\nfrom typing import Dict\n\nimport requests\n\nfrom .base import NotificationClient\n\n\nclass WeComBotClient(NotificationClient):\n    \"\"\"企业微信机器人通知客户端\"\"\"\n\n    channel_key = \"wecom\"\n    display_name = \"企业微信\"\n\n    def __init__(self, bot_url: str | None = None, pcurl_to_mobile: bool = True):\n        super().__init__(enabled=bool(bot_url), pcurl_to_mobile=pcurl_to_mobile)\n        self.bot_url = bot_url\n\n    async def send(self, product_data: Dict, reason: str) -> None:\n        if not self.is_enabled():\n            raise RuntimeError(\"企业微信 未启用\")\n\n        message = self._build_message(product_data, reason)\n        markdown_lines = [f\"## {message.notification_title}\", \"\"]\n        markdown_lines.append(f\"- 价格: {message.price}\")\n        markdown_lines.append(f\"- 原因: {message.reason}\")\n        if message.mobile_link:\n            markdown_lines.append(f\"- 手机端链接: [{message.mobile_link}]({message.mobile_link})\")\n        markdown_lines.append(f\"- 电脑端链接: [{message.desktop_link}]({message.desktop_link})\")\n        payload = {\n            \"msgtype\": \"markdown\",\n            \"markdown\": {\"content\": \"\\n\".join(markdown_lines)},\n        }\n        headers = {\"Content-Type\": \"application/json\"}\n        loop = asyncio.get_running_loop()\n        response = await loop.run_in_executor(\n            None,\n            lambda: requests.post(\n                self.bot_url,\n                json=payload,\n                headers=headers,\n                timeout=10,\n            ),\n        )\n        response.raise_for_status()\n        result = response.json()\n        if result.get(\"errcode\", 0) != 0:\n            raise RuntimeError(result.get(\"errmsg\", \"企业微信返回未知错误\"))\n"
  },
  {
    "path": "src/infrastructure/persistence/__init__.py",
    "content": ""
  },
  {
    "path": "src/infrastructure/persistence/json_task_repository.py",
    "content": "\"\"\"\n基于JSON文件的任务仓储实现\n\"\"\"\nfrom typing import List, Optional\nimport json\nimport aiofiles\nfrom src.domain.models.task import Task\nfrom src.domain.repositories.task_repository import TaskRepository\n\n\nclass JsonTaskRepository(TaskRepository):\n    \"\"\"基于JSON文件的任务仓储\"\"\"\n\n    def __init__(self, config_file: str = \"config.json\"):\n        self.config_file = config_file\n\n    async def find_all(self) -> List[Task]:\n        \"\"\"获取所有任务\"\"\"\n        try:\n            async with aiofiles.open(self.config_file, 'r', encoding='utf-8') as f:\n                content = await f.read()\n                if not content.strip():\n                    return []\n\n                tasks_data = json.loads(content)\n                tasks = []\n                for i, task_data in enumerate(tasks_data):\n                    task_data['id'] = i\n                    tasks.append(Task(**task_data))\n                return tasks\n        except FileNotFoundError:\n            return []\n        except json.JSONDecodeError:\n            print(f\"配置文件 {self.config_file} 格式错误\")\n            return []\n\n    async def find_by_id(self, task_id: int) -> Optional[Task]:\n        \"\"\"根据ID获取任务\"\"\"\n        tasks = await self.find_all()\n        if 0 <= task_id < len(tasks):\n            return tasks[task_id]\n        return None\n\n    async def save(self, task: Task) -> Task:\n        \"\"\"保存任务（创建或更新）\"\"\"\n        tasks = await self.find_all()\n\n        if task.id is not None and 0 <= task.id < len(tasks):\n            # 更新现有任务\n            tasks[task.id] = task\n        else:\n            # 创建新任务\n            task.id = len(tasks)\n            tasks.append(task)\n\n        await self._write_tasks(tasks)\n        return task\n\n    async def delete(self, task_id: int) -> bool:\n        \"\"\"删除任务\"\"\"\n        tasks = await self.find_all()\n        if 0 <= task_id < len(tasks):\n            tasks.pop(task_id)\n            await self._write_tasks(tasks)\n            return True\n        return False\n\n    async def _write_tasks(self, tasks: List[Task]):\n        \"\"\"写入任务列表到文件\"\"\"\n        tasks_data = [task.model_dump(exclude={'id'}) for task in tasks]\n        async with aiofiles.open(self.config_file, 'w', encoding='utf-8') as f:\n            await f.write(json.dumps(tasks_data, ensure_ascii=False, indent=2))\n"
  },
  {
    "path": "src/infrastructure/persistence/sqlite_bootstrap.py",
    "content": "\"\"\"\nSQLite 启动初始化与旧文件迁移。\n\"\"\"\nfrom __future__ import annotations\n\nimport hashlib\nimport json\nimport threading\nfrom pathlib import Path\n\nfrom src.infrastructure.persistence.sqlite_connection import init_schema, sqlite_connection\nfrom src.infrastructure.persistence.storage_names import (\n    build_result_filename,\n    normalize_keyword_from_filename,\n    normalize_keyword_slug,\n)\n\n\nBOOTSTRAP_LOCK = threading.Lock()\nLEGACY_CONFIG_FILE = \"config.json\"\nLEGACY_RESULT_DIR = \"jsonl\"\nLEGACY_PRICE_HISTORY_DIR = \"price_history\"\nTASKS_BOOTSTRAP_KEY = \"bootstrap:legacy_tasks\"\nRESULTS_BOOTSTRAP_KEY = \"bootstrap:legacy_results\"\nSNAPSHOTS_BOOTSTRAP_KEY = \"bootstrap:legacy_price_snapshots\"\n\n\ndef bootstrap_sqlite_storage(\n    db_path: str | None = None,\n    *,\n    legacy_config_file: str | None = LEGACY_CONFIG_FILE,\n    legacy_result_dir: str = LEGACY_RESULT_DIR,\n    legacy_price_history_dir: str = LEGACY_PRICE_HISTORY_DIR,\n) -> None:\n    with BOOTSTRAP_LOCK:\n        with sqlite_connection(db_path) as conn:\n            init_schema(conn)\n            _import_tasks_if_needed(conn, legacy_config_file)\n            _import_results_if_needed(conn, legacy_result_dir)\n            _import_price_snapshots_if_needed(conn, legacy_price_history_dir)\n\n\ndef _table_is_empty(conn, table_name: str) -> bool:\n    row = conn.execute(f\"SELECT COUNT(1) AS total FROM {table_name}\").fetchone()\n    return row is None or int(row[\"total\"]) == 0\n\n\ndef _load_json_file(path: Path):\n    if not path.exists():\n        return None\n    content = path.read_text(encoding=\"utf-8\").strip()\n    if not content:\n        return None\n    return json.loads(content)\n\n\ndef _import_tasks_if_needed(conn, legacy_config_file: str | None) -> None:\n    if _bootstrap_completed(conn, TASKS_BOOTSTRAP_KEY):\n        return\n    if not _table_is_empty(conn, \"tasks\"):\n        _mark_bootstrap_completed(conn, TASKS_BOOTSTRAP_KEY)\n        conn.commit()\n        return\n    if legacy_config_file is None:\n        _mark_bootstrap_completed(conn, TASKS_BOOTSTRAP_KEY)\n        conn.commit()\n        return\n    path = Path(legacy_config_file)\n    tasks = _load_json_file(path)\n    if not isinstance(tasks, list):\n        _mark_bootstrap_completed(conn, TASKS_BOOTSTRAP_KEY)\n        conn.commit()\n        return\n\n    for index, raw_task in enumerate(tasks):\n        if not isinstance(raw_task, dict):\n            continue\n        conn.execute(\n            \"\"\"\n            INSERT INTO tasks (\n                id, task_name, enabled, keyword, description, analyze_images,\n                max_pages, personal_only, min_price, max_price, cron,\n                ai_prompt_base_file, ai_prompt_criteria_file, account_state_file,\n                account_strategy, free_shipping, new_publish_option, region,\n                decision_mode, keyword_rules_json, is_running\n            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n            \"\"\",\n            (\n                index,\n                raw_task.get(\"task_name\", \"\"),\n                _as_int(raw_task.get(\"enabled\", True)),\n                raw_task.get(\"keyword\", \"\"),\n                raw_task.get(\"description\", \"\"),\n                _as_int(raw_task.get(\"analyze_images\", True)),\n                int(raw_task.get(\"max_pages\", 1) or 1),\n                _as_int(raw_task.get(\"personal_only\", False)),\n                raw_task.get(\"min_price\"),\n                raw_task.get(\"max_price\"),\n                raw_task.get(\"cron\"),\n                raw_task.get(\"ai_prompt_base_file\", \"prompts/base_prompt.txt\"),\n                raw_task.get(\"ai_prompt_criteria_file\", \"\"),\n                raw_task.get(\"account_state_file\"),\n                raw_task.get(\"account_strategy\", \"auto\"),\n                _as_int(raw_task.get(\"free_shipping\", True)),\n                raw_task.get(\"new_publish_option\"),\n                raw_task.get(\"region\"),\n                raw_task.get(\"decision_mode\", \"ai\"),\n                json.dumps(raw_task.get(\"keyword_rules\") or [], ensure_ascii=False),\n                _as_int(raw_task.get(\"is_running\", False)),\n            ),\n        )\n    _mark_bootstrap_completed(conn, TASKS_BOOTSTRAP_KEY)\n    conn.commit()\n\n\ndef _import_results_if_needed(conn, legacy_result_dir: str) -> None:\n    if _bootstrap_completed(conn, RESULTS_BOOTSTRAP_KEY):\n        return\n    if not _table_is_empty(conn, \"result_items\"):\n        _mark_bootstrap_completed(conn, RESULTS_BOOTSTRAP_KEY)\n        conn.commit()\n        return\n    result_dir = Path(legacy_result_dir)\n    if not result_dir.exists():\n        _mark_bootstrap_completed(conn, RESULTS_BOOTSTRAP_KEY)\n        conn.commit()\n        return\n\n    for path in sorted(result_dir.glob(\"*.jsonl\")):\n        filename = path.name\n        keyword = normalize_keyword_from_filename(filename)\n        with path.open(\"r\", encoding=\"utf-8\") as handle:\n            for line in handle:\n                text = line.strip()\n                if not text:\n                    continue\n                try:\n                    record = json.loads(text)\n                except json.JSONDecodeError:\n                    continue\n                _insert_result_record(conn, record, keyword=keyword, filename=filename)\n    _mark_bootstrap_completed(conn, RESULTS_BOOTSTRAP_KEY)\n    conn.commit()\n\n\ndef _import_price_snapshots_if_needed(conn, legacy_price_history_dir: str) -> None:\n    if _bootstrap_completed(conn, SNAPSHOTS_BOOTSTRAP_KEY):\n        return\n    if not _table_is_empty(conn, \"price_snapshots\"):\n        _mark_bootstrap_completed(conn, SNAPSHOTS_BOOTSTRAP_KEY)\n        conn.commit()\n        return\n    history_dir = Path(legacy_price_history_dir)\n    if not history_dir.exists():\n        _mark_bootstrap_completed(conn, SNAPSHOTS_BOOTSTRAP_KEY)\n        conn.commit()\n        return\n\n    for path in sorted(history_dir.glob(\"*_history.jsonl\")):\n        with path.open(\"r\", encoding=\"utf-8\") as handle:\n            for line in handle:\n                text = line.strip()\n                if not text:\n                    continue\n                try:\n                    record = json.loads(text)\n                except json.JSONDecodeError:\n                    continue\n                _insert_price_snapshot(conn, record)\n    _mark_bootstrap_completed(conn, SNAPSHOTS_BOOTSTRAP_KEY)\n    conn.commit()\n\n\ndef _insert_result_record(conn, record: dict, *, keyword: str, filename: str) -> None:\n    item = record.get(\"商品信息\", {}) or {}\n    analysis = record.get(\"ai_analysis\", {}) or {}\n    link = str(item.get(\"商品链接\") or \"\")\n    if link:\n        link_unique_key = link.split(\"&\", 1)[0]\n    else:\n        item_id = str(item.get(\"商品ID\") or \"\").strip()\n        if item_id:\n            link_unique_key = f\"item:{item_id}\"\n        else:\n            link_unique_key = \"hash:\" + hashlib.sha1(\n                json.dumps(record, ensure_ascii=False, sort_keys=True).encode(\"utf-8\")\n            ).hexdigest()\n    final_keyword = str(record.get(\"搜索关键字\") or keyword)\n    result_filename = filename or build_result_filename(final_keyword)\n    keyword_hit_count = analysis.get(\"keyword_hit_count\", 0)\n    try:\n        keyword_hit_count = int(keyword_hit_count)\n    except (TypeError, ValueError):\n        keyword_hit_count = 0\n\n    conn.execute(\n        \"\"\"\n        INSERT OR IGNORE INTO result_items (\n            result_filename, keyword, task_name, crawl_time, publish_time, price,\n            price_display, item_id, title, link, link_unique_key, seller_nickname,\n            is_recommended, analysis_source, keyword_hit_count, raw_json\n        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n        \"\"\",\n        (\n            result_filename,\n            final_keyword,\n            record.get(\"任务名称\", \"\"),\n            record.get(\"爬取时间\", \"\"),\n            item.get(\"发布时间\"),\n            _parse_price(item.get(\"当前售价\")),\n            item.get(\"当前售价\"),\n            item.get(\"商品ID\"),\n            item.get(\"商品标题\"),\n            link,\n            link_unique_key,\n            (record.get(\"卖家信息\", {}) or {}).get(\"卖家昵称\") or item.get(\"卖家昵称\"),\n            _as_int(analysis.get(\"is_recommended\", False)),\n            analysis.get(\"analysis_source\"),\n            keyword_hit_count,\n            json.dumps(record, ensure_ascii=False),\n        ),\n    )\n\n\ndef _insert_price_snapshot(conn, record: dict) -> None:\n    keyword = str(record.get(\"keyword\") or \"\")\n    slug = str(record.get(\"keyword_slug\") or normalize_keyword_slug(keyword))\n    conn.execute(\n        \"\"\"\n        INSERT OR IGNORE INTO price_snapshots (\n            keyword_slug, keyword, task_name, snapshot_time, snapshot_day, run_id,\n            item_id, title, price, price_display, tags_json, region, seller,\n            publish_time, link\n        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n        \"\"\",\n        (\n            slug,\n            keyword,\n            record.get(\"task_name\", \"\"),\n            record.get(\"snapshot_time\", \"\"),\n            record.get(\"snapshot_day\", \"\"),\n            record.get(\"run_id\", \"\"),\n            record.get(\"item_id\", \"\"),\n            record.get(\"title\", \"\"),\n            _parse_price(record.get(\"price\")),\n            record.get(\"price_display\"),\n            json.dumps(record.get(\"tags\") or [], ensure_ascii=False),\n            record.get(\"region\"),\n            record.get(\"seller\"),\n            record.get(\"publish_time\"),\n            record.get(\"link\"),\n        ),\n    )\n\n\ndef _as_int(value) -> int:\n    if isinstance(value, bool):\n        return 1 if value else 0\n    if value is None:\n        return 0\n    return 1 if str(value).strip().lower() in {\"1\", \"true\", \"yes\", \"on\"} else 0\n\n\ndef _parse_price(value):\n    if value is None:\n        return None\n    if isinstance(value, (int, float)):\n        return round(float(value), 2)\n\n    text = str(value).strip().replace(\"¥\", \"\").replace(\",\", \"\")\n    if not text or text in {\"价格异常\", \"暂无\", \"-\", \"N/A\"}:\n        return None\n    if text.endswith(\"万\"):\n        text = str(float(text[:-1]) * 10000)\n    try:\n        return round(float(text), 2)\n    except (TypeError, ValueError):\n        return None\n\n\ndef _bootstrap_completed(conn, key: str) -> bool:\n    row = conn.execute(\n        \"SELECT value FROM app_metadata WHERE key = ?\",\n        (key,),\n    ).fetchone()\n    return row is not None\n\n\ndef _mark_bootstrap_completed(conn, key: str) -> None:\n    conn.execute(\n        \"\"\"\n        INSERT OR REPLACE INTO app_metadata(key, value)\n        VALUES (?, 'done')\n        \"\"\",\n        (key,),\n    )\n"
  },
  {
    "path": "src/infrastructure/persistence/sqlite_connection.py",
    "content": "\"\"\"\nSQLite 连接与 schema 初始化。\n\"\"\"\nfrom __future__ import annotations\n\nimport os\nimport sqlite3\nfrom contextlib import contextmanager\nfrom pathlib import Path\nfrom typing import Iterator\n\nfrom src.infrastructure.persistence.storage_names import DEFAULT_DATABASE_PATH\n\n\nBUSY_TIMEOUT_MS = 5000\n\nSCHEMA_STATEMENTS = (\n    \"\"\"\n    CREATE TABLE IF NOT EXISTS app_metadata (\n        key TEXT PRIMARY KEY,\n        value TEXT NOT NULL\n    )\n    \"\"\",\n    \"\"\"\n    CREATE TABLE IF NOT EXISTS tasks (\n        id INTEGER PRIMARY KEY,\n        task_name TEXT NOT NULL,\n        enabled INTEGER NOT NULL,\n        keyword TEXT NOT NULL,\n        description TEXT,\n        analyze_images INTEGER NOT NULL,\n        max_pages INTEGER NOT NULL,\n        personal_only INTEGER NOT NULL,\n        min_price TEXT,\n        max_price TEXT,\n        cron TEXT,\n        ai_prompt_base_file TEXT NOT NULL,\n        ai_prompt_criteria_file TEXT NOT NULL,\n        account_state_file TEXT,\n        account_strategy TEXT NOT NULL,\n        free_shipping INTEGER NOT NULL,\n        new_publish_option TEXT,\n        region TEXT,\n        decision_mode TEXT NOT NULL,\n        keyword_rules_json TEXT NOT NULL,\n        is_running INTEGER NOT NULL\n    )\n    \"\"\",\n    \"\"\"\n    CREATE TABLE IF NOT EXISTS result_items (\n        id INTEGER PRIMARY KEY AUTOINCREMENT,\n        result_filename TEXT NOT NULL,\n        keyword TEXT NOT NULL,\n        task_name TEXT NOT NULL,\n        crawl_time TEXT NOT NULL,\n        publish_time TEXT,\n        price REAL,\n        price_display TEXT,\n        item_id TEXT,\n        title TEXT,\n        link TEXT,\n        link_unique_key TEXT NOT NULL,\n        seller_nickname TEXT,\n        is_recommended INTEGER NOT NULL,\n        analysis_source TEXT,\n        keyword_hit_count INTEGER NOT NULL,\n        raw_json TEXT NOT NULL,\n        UNIQUE(result_filename, link_unique_key)\n    )\n    \"\"\",\n    \"\"\"\n    CREATE TABLE IF NOT EXISTS price_snapshots (\n        id INTEGER PRIMARY KEY AUTOINCREMENT,\n        keyword_slug TEXT NOT NULL,\n        keyword TEXT NOT NULL,\n        task_name TEXT NOT NULL,\n        snapshot_time TEXT NOT NULL,\n        snapshot_day TEXT NOT NULL,\n        run_id TEXT NOT NULL,\n        item_id TEXT NOT NULL,\n        title TEXT,\n        price REAL NOT NULL,\n        price_display TEXT,\n        tags_json TEXT NOT NULL,\n        region TEXT,\n        seller TEXT,\n        publish_time TEXT,\n        link TEXT,\n        UNIQUE(keyword_slug, run_id, item_id)\n    )\n    \"\"\",\n    \"CREATE INDEX IF NOT EXISTS idx_tasks_name ON tasks(task_name)\",\n    \"\"\"\n    CREATE INDEX IF NOT EXISTS idx_results_filename_crawl\n    ON result_items(result_filename, crawl_time DESC)\n    \"\"\",\n    \"\"\"\n    CREATE INDEX IF NOT EXISTS idx_results_filename_publish\n    ON result_items(result_filename, publish_time DESC)\n    \"\"\",\n    \"\"\"\n    CREATE INDEX IF NOT EXISTS idx_results_filename_price\n    ON result_items(result_filename, price DESC)\n    \"\"\",\n    \"\"\"\n    CREATE INDEX IF NOT EXISTS idx_results_filename_recommended\n    ON result_items(result_filename, is_recommended, analysis_source, crawl_time DESC)\n    \"\"\",\n    \"\"\"\n    CREATE INDEX IF NOT EXISTS idx_snapshots_keyword_time\n    ON price_snapshots(keyword_slug, snapshot_time DESC)\n    \"\"\",\n    \"\"\"\n    CREATE INDEX IF NOT EXISTS idx_snapshots_keyword_item_time\n    ON price_snapshots(keyword_slug, item_id, snapshot_time DESC)\n    \"\"\",\n)\n\n\ndef get_database_path() -> str:\n    return os.getenv(\"APP_DATABASE_FILE\", DEFAULT_DATABASE_PATH)\n\n\ndef _prepare_database_file(path: str) -> None:\n    Path(path).parent.mkdir(parents=True, exist_ok=True)\n\n\ndef _apply_pragmas(conn: sqlite3.Connection) -> None:\n    conn.execute(\"PRAGMA journal_mode=WAL\")\n    conn.execute(\"PRAGMA foreign_keys=ON\")\n    conn.execute(f\"PRAGMA busy_timeout={BUSY_TIMEOUT_MS}\")\n\n\ndef init_schema(conn: sqlite3.Connection) -> None:\n    for statement in SCHEMA_STATEMENTS:\n        conn.execute(statement)\n    conn.commit()\n\n\n@contextmanager\ndef sqlite_connection(\n    db_path: str | None = None,\n) -> Iterator[sqlite3.Connection]:\n    path = db_path or get_database_path()\n    _prepare_database_file(path)\n    conn = sqlite3.connect(path)\n    conn.row_factory = sqlite3.Row\n    try:\n        _apply_pragmas(conn)\n        yield conn\n    finally:\n        conn.close()\n"
  },
  {
    "path": "src/infrastructure/persistence/sqlite_task_repository.py",
    "content": "\"\"\"\n基于 SQLite 的任务仓储实现。\n\"\"\"\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nfrom typing import List, Optional\n\nfrom src.domain.models.task import Task\nfrom src.domain.repositories.task_repository import TaskRepository\nfrom src.infrastructure.persistence.sqlite_bootstrap import bootstrap_sqlite_storage\nfrom src.infrastructure.persistence.sqlite_connection import sqlite_connection\n\n\ndef _row_to_task(row) -> Task:\n    payload = dict(row)\n    payload[\"enabled\"] = bool(payload[\"enabled\"])\n    payload[\"analyze_images\"] = bool(payload[\"analyze_images\"])\n    payload[\"personal_only\"] = bool(payload[\"personal_only\"])\n    payload[\"free_shipping\"] = bool(payload[\"free_shipping\"])\n    payload[\"is_running\"] = bool(payload[\"is_running\"])\n    payload[\"keyword_rules\"] = json.loads(payload.pop(\"keyword_rules_json\") or \"[]\")\n    return Task(**payload)\n\n\ndef find_task_by_name_sync(task_name: str) -> Task | None:\n    bootstrap_sqlite_storage()\n    with sqlite_connection() as conn:\n        row = conn.execute(\n            \"SELECT * FROM tasks WHERE task_name = ? ORDER BY id ASC LIMIT 1\",\n            (task_name,),\n        ).fetchone()\n    return _row_to_task(row) if row else None\n\n\nclass SqliteTaskRepository(TaskRepository):\n    \"\"\"基于 SQLite 的任务仓储\"\"\"\n\n    def __init__(\n        self,\n        db_path: str | None = None,\n        legacy_config_file: str | None = \"config.json\",\n    ):\n        self.db_path = db_path\n        self.legacy_config_file = legacy_config_file\n\n    async def find_all(self) -> List[Task]:\n        return await asyncio.to_thread(self._find_all_sync)\n\n    async def find_by_id(self, task_id: int) -> Optional[Task]:\n        return await asyncio.to_thread(self._find_by_id_sync, task_id)\n\n    async def save(self, task: Task) -> Task:\n        return await asyncio.to_thread(self._save_sync, task)\n\n    async def delete(self, task_id: int) -> bool:\n        return await asyncio.to_thread(self._delete_sync, task_id)\n\n    def _find_all_sync(self) -> List[Task]:\n        bootstrap_sqlite_storage(\n            self.db_path,\n            legacy_config_file=self.legacy_config_file,\n        )\n        with sqlite_connection(self.db_path) as conn:\n            rows = conn.execute(\"SELECT * FROM tasks ORDER BY id ASC\").fetchall()\n        return [_row_to_task(row) for row in rows]\n\n    def _find_by_id_sync(self, task_id: int) -> Optional[Task]:\n        bootstrap_sqlite_storage(\n            self.db_path,\n            legacy_config_file=self.legacy_config_file,\n        )\n        with sqlite_connection(self.db_path) as conn:\n            row = conn.execute(\"SELECT * FROM tasks WHERE id = ?\", (task_id,)).fetchone()\n        return _row_to_task(row) if row else None\n\n    def _save_sync(self, task: Task) -> Task:\n        bootstrap_sqlite_storage(\n            self.db_path,\n            legacy_config_file=self.legacy_config_file,\n        )\n        with sqlite_connection(self.db_path) as conn:\n            task_id = task.id\n            if task_id is None:\n                task_id = self._next_task_id(conn)\n            payload = self._task_values(task.model_copy(update={\"id\": task_id}))\n            conn.execute(\n                \"\"\"\n                INSERT OR REPLACE INTO tasks (\n                    id, task_name, enabled, keyword, description, analyze_images,\n                    max_pages, personal_only, min_price, max_price, cron,\n                    ai_prompt_base_file, ai_prompt_criteria_file, account_state_file,\n                    account_strategy, free_shipping, new_publish_option, region,\n                    decision_mode, keyword_rules_json, is_running\n                ) VALUES (\n                    :id, :task_name, :enabled, :keyword, :description, :analyze_images,\n                    :max_pages, :personal_only, :min_price, :max_price, :cron,\n                    :ai_prompt_base_file, :ai_prompt_criteria_file, :account_state_file,\n                    :account_strategy, :free_shipping, :new_publish_option, :region,\n                    :decision_mode, :keyword_rules_json, :is_running\n                )\n                \"\"\",\n                payload,\n            )\n            conn.commit()\n        return task.model_copy(update={\"id\": task_id})\n\n    def _delete_sync(self, task_id: int) -> bool:\n        bootstrap_sqlite_storage(\n            self.db_path,\n            legacy_config_file=self.legacy_config_file,\n        )\n        with sqlite_connection(self.db_path) as conn:\n            cursor = conn.execute(\"DELETE FROM tasks WHERE id = ?\", (task_id,))\n            conn.commit()\n        return cursor.rowcount > 0\n\n    def _next_task_id(self, conn) -> int:\n        row = conn.execute(\"SELECT COALESCE(MAX(id), -1) AS max_id FROM tasks\").fetchone()\n        return int(row[\"max_id\"]) + 1\n\n    def _task_values(self, task: Task) -> dict:\n        values = task.model_dump()\n        values[\"enabled\"] = int(task.enabled)\n        values[\"analyze_images\"] = int(task.analyze_images)\n        values[\"personal_only\"] = int(task.personal_only)\n        values[\"free_shipping\"] = int(task.free_shipping)\n        values[\"is_running\"] = int(task.is_running)\n        values[\"keyword_rules_json\"] = json.dumps(task.keyword_rules or [], ensure_ascii=False)\n        values.pop(\"keyword_rules\", None)\n        return values\n"
  },
  {
    "path": "src/infrastructure/persistence/storage_names.py",
    "content": "\"\"\"\nSQLite 持久化相关的统一命名规则。\n\"\"\"\nfrom __future__ import annotations\n\n\nDEFAULT_DATABASE_PATH = \"data/app.sqlite3\"\nRESULT_FILE_SUFFIX = \"_full_data.jsonl\"\n\n\ndef build_result_filename(keyword: str) -> str:\n    return f\"{str(keyword or '').replace(' ', '_')}{RESULT_FILE_SUFFIX}\"\n\n\ndef normalize_keyword_from_filename(filename: str) -> str:\n    return str(filename or \"\").replace(RESULT_FILE_SUFFIX, \"\")\n\n\ndef normalize_keyword_slug(keyword: str) -> str:\n    text = \"\".join(\n        char for char in str(keyword or \"\").lower().replace(\" \", \"_\")\n        if char.isalnum() or char in \"_-\"\n    ).rstrip(\"_\")\n    return text or \"unknown\"\n"
  },
  {
    "path": "src/keyword_rule_engine.py",
    "content": "\"\"\"\n关键词判断引擎：单组 OR 逻辑，命中任意关键词即推荐。\n纯英数字关键词按完整词匹配，避免 Q1 误命中 Q1R5。\n\"\"\"\nimport re\nfrom typing import Any, Dict, Iterable, List\n\n\n_ASCII_TOKEN_KEYWORD_PATTERN = re.compile(r\"^[a-z0-9 ]+$\")\n_ASCII_TOKEN_BOUNDARY = r\"[a-z0-9]\"\n\n\ndef normalize_text(value: str) -> str:\n    return \" \".join((value or \"\").lower().split())\n\n\ndef _collect_text_fragments(value: Any, bucket: List[str]) -> None:\n    if value is None:\n        return\n    if isinstance(value, str):\n        text = value.strip()\n        if text:\n            bucket.append(text)\n        return\n    if isinstance(value, (int, float, bool)):\n        bucket.append(str(value))\n        return\n    if isinstance(value, dict):\n        for item in value.values():\n            _collect_text_fragments(item, bucket)\n        return\n    if isinstance(value, list):\n        for item in value:\n            _collect_text_fragments(item, bucket)\n\n\ndef build_search_text(record: Dict[str, Any]) -> str:\n    fragments: List[str] = []\n    product_info = record.get(\"商品信息\", {})\n    seller_info = record.get(\"卖家信息\", {})\n\n    _collect_text_fragments(product_info.get(\"商品标题\"), fragments)\n    _collect_text_fragments(product_info, fragments)\n    _collect_text_fragments(seller_info, fragments)\n\n    return normalize_text(\" \".join(fragments))\n\n\ndef _normalize_keywords(values: Iterable[str]) -> List[str]:\n    normalized: List[str] = []\n    seen = set()\n    for raw in values or []:\n        text = normalize_text(str(raw).strip())\n        if not text or text in seen:\n            continue\n        seen.add(text)\n        normalized.append(text)\n    return normalized\n\n\ndef _uses_ascii_token_match(keyword: str) -> bool:\n    return bool(keyword) and _ASCII_TOKEN_KEYWORD_PATTERN.fullmatch(keyword) is not None\n\n\ndef _keyword_matches(keyword: str, normalized_text: str) -> bool:\n    if not _uses_ascii_token_match(keyword):\n        return keyword in normalized_text\n    pattern = rf\"(?<!{_ASCII_TOKEN_BOUNDARY}){re.escape(keyword)}(?!{_ASCII_TOKEN_BOUNDARY})\"\n    return re.search(pattern, normalized_text) is not None\n\n\ndef evaluate_keyword_rules(keywords: List[str], search_text: str) -> Dict[str, Any]:\n    normalized_text = normalize_text(search_text)\n    normalized_keywords = _normalize_keywords(keywords)\n\n    if not normalized_text:\n        return {\n            \"analysis_source\": \"keyword\",\n            \"is_recommended\": False,\n            \"reason\": \"可匹配文本为空，关键词规则无法执行。\",\n            \"matched_keywords\": [],\n            \"keyword_hit_count\": 0,\n        }\n\n    if not normalized_keywords:\n        return {\n            \"analysis_source\": \"keyword\",\n            \"is_recommended\": False,\n            \"reason\": \"未配置关键词规则。\",\n            \"matched_keywords\": [],\n            \"keyword_hit_count\": 0,\n        }\n\n    matched_keywords = [kw for kw in normalized_keywords if _keyword_matches(kw, normalized_text)]\n    hit_count = len(matched_keywords)\n    is_recommended = hit_count > 0\n\n    if is_recommended:\n        reason = f\"命中 {hit_count} 个关键词：{', '.join(matched_keywords)}\"\n    else:\n        reason = \"未命中任何关键词。\"\n\n    return {\n        \"analysis_source\": \"keyword\",\n        \"is_recommended\": is_recommended,\n        \"reason\": reason,\n        \"matched_keywords\": matched_keywords,\n        \"keyword_hit_count\": hit_count,\n    }\n"
  },
  {
    "path": "src/parsers.py",
    "content": "import json\nfrom datetime import datetime\n\nfrom src.config import AI_DEBUG_MODE\nfrom src.utils import safe_get\n\n\nasync def _parse_search_results_json(json_data: dict, source: str) -> list:\n    \"\"\"解析搜索API的JSON数据，返回基础商品信息列表。\"\"\"\n    page_data = []\n    try:\n        items = await safe_get(json_data, \"data\", \"resultList\", default=[])\n        if not items:\n            print(f\"LOG: ({source}) API响应中未找到商品列表 (resultList)。\")\n            if AI_DEBUG_MODE:\n                print(f\"--- [SEARCH DEBUG] RAW JSON RESPONSE from {source} ---\")\n                print(json.dumps(json_data, ensure_ascii=False, indent=2))\n                print(\"----------------------------------------------------\")\n            return []\n\n        for item in items:\n            main_data = await safe_get(item, \"data\", \"item\", \"main\", \"exContent\", default={})\n            click_params = await safe_get(item, \"data\", \"item\", \"main\", \"clickParam\", \"args\", default={})\n\n            title = await safe_get(main_data, \"title\", default=\"未知标题\")\n            price_parts = await safe_get(main_data, \"price\", default=[])\n            price = \"\".join([str(p.get(\"text\", \"\")) for p in price_parts if isinstance(p, dict)]).replace(\"当前价\", \"\").strip() if isinstance(price_parts, list) else \"价格异常\"\n            if \"万\" in price: price = f\"¥{float(price.replace('¥', '').replace('万', '')) * 10000:.0f}\"\n            area = await safe_get(main_data, \"area\", default=\"地区未知\")\n            seller = await safe_get(main_data, \"userNickName\", default=\"匿名卖家\")\n            raw_link = await safe_get(item, \"data\", \"item\", \"main\", \"targetUrl\", default=\"\")\n            image_url = await safe_get(main_data, \"picUrl\", default=\"\")\n            pub_time_ts = click_params.get(\"publishTime\", \"\")\n            item_id = await safe_get(main_data, \"itemId\", default=\"未知ID\")\n            original_price = await safe_get(main_data, \"oriPrice\", default=\"暂无\")\n            wants_count = await safe_get(click_params, \"wantNum\", default='NaN')\n\n\n            tags = []\n            if await safe_get(click_params, \"tag\") == \"freeship\":\n                tags.append(\"包邮\")\n            r1_tags = await safe_get(main_data, \"fishTags\", \"r1\", \"tagList\", default=[])\n            for tag_item in r1_tags:\n                content = await safe_get(tag_item, \"data\", \"content\", default=\"\")\n                if \"验货宝\" in content:\n                    tags.append(\"验货宝\")\n\n            page_data.append({\n                \"商品标题\": title,\n                \"当前售价\": price,\n                \"商品原价\": original_price,\n                \"“想要”人数\": wants_count,\n                \"商品标签\": tags,\n                \"发货地区\": area,\n                \"卖家昵称\": seller,\n                \"商品链接\": raw_link.replace(\"fleamarket://\", \"https://www.goofish.com/\"),\n                \"发布时间\": datetime.fromtimestamp(int(pub_time_ts)/1000).strftime(\"%Y-%m-%d %H:%M\") if pub_time_ts.isdigit() else \"未知时间\",\n                \"商品ID\": item_id\n            })\n        print(f\"LOG: ({source}) 成功解析到 {len(page_data)} 条商品基础信息。\")\n        return page_data\n    except Exception as e:\n        print(f\"LOG: ({source}) JSON数据处理异常: {str(e)}\")\n        return []\n\n\nasync def calculate_reputation_from_ratings(ratings_json: list) -> dict:\n    \"\"\"从原始评价API数据列表中，计算作为卖家和买家的好评数与好评率。\"\"\"\n    seller_total = 0\n    seller_positive = 0\n    buyer_total = 0\n    buyer_positive = 0\n\n    for card in ratings_json:\n        # 使用 safe_get 保证安全访问\n        data = await safe_get(card, 'cardData', default={})\n        role_tag = await safe_get(data, 'rateTagList', 0, 'text', default='')\n        rate_type = await safe_get(data, 'rate') # 1=好评, 0=中评, -1=差评\n\n        if \"卖家\" in role_tag:\n            seller_total += 1\n            if rate_type == 1:\n                seller_positive += 1\n        elif \"买家\" in role_tag:\n            buyer_total += 1\n            if rate_type == 1:\n                buyer_positive += 1\n\n    # 计算比率，并处理除以零的情况\n    seller_rate = f\"{(seller_positive / seller_total * 100):.2f}%\" if seller_total > 0 else \"N/A\"\n    buyer_rate = f\"{(buyer_positive / buyer_total * 100):.2f}%\" if buyer_total > 0 else \"N/A\"\n\n    return {\n        \"作为卖家的好评数\": f\"{seller_positive}/{seller_total}\",\n        \"作为卖家的好评率\": seller_rate,\n        \"作为买家的好评数\": f\"{buyer_positive}/{buyer_total}\",\n        \"作为买家的好评率\": buyer_rate\n    }\n\n\nasync def _parse_user_items_data(items_json: list) -> list:\n    \"\"\"解析用户主页的商品列表API的JSON数据。\"\"\"\n    parsed_list = []\n    for card in items_json:\n        data = card.get('cardData', {})\n        status_code = data.get('itemStatus')\n        if status_code == 0:\n            status_text = \"在售\"\n        elif status_code == 1:\n            status_text = \"已售\"\n        else:\n            status_text = f\"未知状态 ({status_code})\"\n\n        parsed_list.append({\n            \"商品ID\": data.get('id'),\n            \"商品标题\": data.get('title'),\n            \"商品价格\": data.get('priceInfo', {}).get('price'),\n            \"商品主图\": data.get('picInfo', {}).get('picUrl'),\n            \"商品状态\": status_text\n        })\n    return parsed_list\n\n\nasync def parse_user_head_data(head_json: dict) -> dict:\n    \"\"\"解析用户头部API的JSON数据。\"\"\"\n    data = head_json.get('data', {})\n    ylz_tags = await safe_get(data, 'module', 'base', 'ylzTags', default=[])\n    seller_credit, buyer_credit = {}, {}\n    for tag in ylz_tags:\n        if await safe_get(tag, 'attributes', 'role') == 'seller':\n            seller_credit = {'level': await safe_get(tag, 'attributes', 'level'), 'text': tag.get('text')}\n        elif await safe_get(tag, 'attributes', 'role') == 'buyer':\n            buyer_credit = {'level': await safe_get(tag, 'attributes', 'level'), 'text': tag.get('text')}\n    return {\n        \"卖家昵称\": await safe_get(data, 'module', 'base', 'displayName'),\n        \"卖家头像链接\": await safe_get(data, 'module', 'base', 'avatar', 'avatar'),\n        \"卖家个性签名\": await safe_get(data, 'module', 'base', 'introduction', default=''),\n        \"卖家在售/已售商品数\": await safe_get(data, 'module', 'tabs', 'item', 'number'),\n        \"卖家收到的评价总数\": await safe_get(data, 'module', 'tabs', 'rate', 'number'),\n        \"卖家信用等级\": seller_credit.get('text', '暂无'),\n        \"买家信用等级\": buyer_credit.get('text', '暂无')\n    }\n\n\nasync def parse_ratings_data(ratings_json: list) -> list:\n    \"\"\"解析评价列表API的JSON数据。\"\"\"\n    parsed_list = []\n    for card in ratings_json:\n        data = await safe_get(card, 'cardData', default={})\n        rate_tag = await safe_get(data, 'rateTagList', 0, 'text', default='未知角色')\n        rate_type = await safe_get(data, 'rate')\n        if rate_type == 1: rate_text = \"好评\"\n        elif rate_type == 0: rate_text = \"中评\"\n        elif rate_type == -1: rate_text = \"差评\"\n        else: rate_text = \"未知\"\n        parsed_list.append({\n            \"评价ID\": data.get('rateId'),\n            \"评价内容\": data.get('feedback'),\n            \"评价类型\": rate_text,\n            \"评价来源角色\": rate_tag,\n            \"评价者昵称\": data.get('raterUserNick'),\n            \"评价时间\": data.get('gmtCreate'),\n            \"评价图片\": await safe_get(data, 'pictCdnUrlList', default=[])\n        })\n    return parsed_list\n"
  },
  {
    "path": "src/prompt_utils.py",
    "content": "import json\nimport os\nimport sys\nfrom typing import Awaitable, Callable, Optional\n\nimport aiofiles\n\nfrom src.infrastructure.external.ai_client import AIClient\n\n# The meta-prompt to instruct the AI\nMETA_PROMPT_TEMPLATE = \"\"\"\n你是一位世界级的AI提示词工程大师。你的任务是根据用户提供的【购买需求】，模仿一个【参考范例】，为闲鱼监控机器人的AI分析模块（代号 EagleEye）生成一份全新的【分析标准】文本。\n\n你的输出必须严格遵循【参考范例】的结构、语气和核心原则，但内容要完全针对用户的【购买需求】进行定制。最终生成的文本将作为AI分析模块的思考指南。\n\n---\n这是【参考范例】（`macbook_criteria.txt`）：\n```text\n{reference_text}\n```\n---\n\n这是用户的【购买需求】：\n```text\n{user_description}\n```\n---\n\n请现在开始生成全新的【分析标准】文本。请注意：\n1.  **只输出新生成的文本内容**，不要包含任何额外的解释、标题或代码块标记。\n2.  保留范例中的 `[V6.3 核心升级]`、`[V6.4 逻辑修正]` 等版本标记，这有助于保持格式一致性。\n3.  将范例中所有与 \"MacBook\" 相关的内容，替换为与用户需求商品相关的内容。\n4.  思考并生成针对新商品类型的“一票否决硬性原则”和“危险信号清单”。\n\"\"\"\n\nProgressCallback = Callable[[str, str], Awaitable[None]]\n\n\nasync def _report_progress(\n    progress_callback: Optional[ProgressCallback],\n    step_key: str,\n    message: str,\n) -> None:\n    if progress_callback:\n        await progress_callback(step_key, message)\n\n\ndef _read_reference_text(reference_file_path: str) -> str:\n    try:\n        with open(reference_file_path, \"r\", encoding=\"utf-8\") as file:\n            return file.read()\n    except FileNotFoundError:\n        raise FileNotFoundError(f\"参考文件未找到: {reference_file_path}\")\n    except IOError as exc:\n        raise IOError(f\"读取参考文件失败: {exc}\")\n\n\nasync def _request_generated_text(ai_client: AIClient, prompt: str) -> str:\n    print(\"正在调用AI生成新的分析标准，请稍候...\")\n    try:\n        generated_text = await ai_client._call_ai(\n            [{\"role\": \"user\", \"content\": prompt}],\n            temperature=0.5,\n            max_output_tokens=800,\n            enable_json_output=False,\n        )\n    except Exception as exc:\n        print(f\"调用 OpenAI API 时出错: {exc}\")\n        raise\n\n    print(\"AI已成功生成内容。\")\n    return generated_text.strip()\n\n\nasync def _close_ai_client(\n    ai_client: AIClient,\n    active_error: BaseException | None,\n) -> None:\n    try:\n        await ai_client.close()\n    except Exception as close_error:\n        print(f\"关闭 AI 客户端时出错: {close_error}\")\n        if active_error is None:\n            raise\n\n\nasync def generate_criteria(\n    user_description: str,\n    reference_file_path: str,\n    progress_callback: Optional[ProgressCallback] = None,\n) -> str:\n    \"\"\"\n    Generates a new criteria file content using AI.\n    \"\"\"\n    ai_client = AIClient()\n    active_error: BaseException | None = None\n    try:\n        if not ai_client.is_available():\n            ai_client.refresh()\n        if not ai_client.is_available():\n            raise RuntimeError(\"AI客户端未初始化，无法生成分析标准。请检查.env配置。\")\n\n        await _report_progress(progress_callback, \"reference\", \"正在读取参考文件。\")\n        print(f\"正在读取参考文件: {reference_file_path}\")\n        reference_text = _read_reference_text(reference_file_path)\n\n        await _report_progress(progress_callback, \"prompt\", \"正在构建发送给 AI 的指令。\")\n        print(\"正在构建发送给AI的指令...\")\n        prompt = META_PROMPT_TEMPLATE.format(\n            reference_text=reference_text,\n            user_description=user_description,\n        )\n\n        await _report_progress(progress_callback, \"llm\", \"正在调用 AI 生成分析标准。\")\n        return await _request_generated_text(ai_client, prompt)\n    except Exception as exc:\n        active_error = exc\n        raise\n    finally:\n        await _close_ai_client(ai_client, active_error)\n\n\nasync def update_config_with_new_task(new_task: dict, config_file: str = \"config.json\"):\n    \"\"\"\n    将一个新任务添加到指定的JSON配置文件中。\n    \"\"\"\n    print(f\"正在更新配置文件: {config_file}\")\n    try:\n        # 读取现有配置\n        config_data = []\n        if os.path.exists(config_file):\n            async with aiofiles.open(config_file, 'r', encoding='utf-8') as f:\n                content = await f.read()\n                # 处理空文件的情况\n                if content.strip():\n                    try:\n                        config_data = json.loads(content)\n                        print(f\"成功读取现有配置，当前任务数量: {len(config_data)}\")\n                    except json.JSONDecodeError as e:\n                        print(f\"解析配置文件失败，将创建新配置: {e}\")\n                        config_data = []\n        else:\n            print(f\"配置文件不存在，将创建新文件: {config_file}\")\n\n        # 追加新任务\n        config_data.append(new_task)\n\n        # 写回配置文件\n        async with aiofiles.open(config_file, 'w', encoding='utf-8') as f:\n            await f.write(json.dumps(config_data, ensure_ascii=False, indent=2))\n            print(f\"配置文件写入完成\")\n\n        print(f\"成功！新任务 '{new_task.get('task_name')}' 已添加到 {config_file} 并已启用。\")\n        return True\n    except json.JSONDecodeError as e:\n        error_msg = f\"错误: 配置文件 {config_file} 格式错误，无法解析: {e}\"\n        sys.stderr.write(error_msg + \"\\n\")\n        print(error_msg)\n        return False\n    except IOError as e:\n        error_msg = f\"错误: 读写配置文件失败: {e}\"\n        sys.stderr.write(error_msg + \"\\n\")\n        print(error_msg)\n        return False\n    except Exception as e:\n        error_msg = f\"错误: 更新配置文件时发生未知错误: {e}\"\n        sys.stderr.write(error_msg + \"\\n\")\n        print(error_msg)\n        import traceback\n        print(traceback.format_exc())\n        return False\n"
  },
  {
    "path": "src/rotation.py",
    "content": "import os\nimport random\nimport time\nfrom dataclasses import dataclass\nfrom typing import Dict, List, Optional\n\n\n@dataclass\nclass RotationItem:\n    value: str\n    last_error: Optional[str] = None\n\n\nclass RotationPool:\n    def __init__(self, items: List[str], blacklist_ttl: int = 300, name: str = \"\"):\n        self.items = [RotationItem(value=item) for item in items if item]\n        self.blacklist_ttl = max(0, int(blacklist_ttl))\n        self.name = name or \"rotation\"\n        self._blacklist: Dict[str, float] = {}\n\n    def _cleanup_blacklist(self) -> None:\n        now = time.time()\n        expired = [key for key, ts in self._blacklist.items() if ts <= now]\n        for key in expired:\n            self._blacklist.pop(key, None)\n\n    def available_items(self) -> List[RotationItem]:\n        self._cleanup_blacklist()\n        return [item for item in self.items if item.value not in self._blacklist]\n\n    def pick_random(self) -> Optional[RotationItem]:\n        candidates = self.available_items()\n        if not candidates:\n            return None\n        return random.choice(candidates)\n\n    def mark_bad(self, item: Optional[RotationItem], reason: str = \"\") -> None:\n        if not item:\n            return\n        item.last_error = reason\n        if self.blacklist_ttl <= 0:\n            return\n        self._blacklist[item.value] = time.time() + self.blacklist_ttl\n\n\ndef parse_proxy_pool(value: Optional[str]) -> List[str]:\n    if not value:\n        return []\n    if isinstance(value, list):\n        return [str(item).strip() for item in value if str(item).strip()]\n    return [entry.strip() for entry in str(value).split(\",\") if entry.strip()]\n\n\ndef load_state_files(state_dir: str) -> List[str]:\n    if not state_dir:\n        return []\n    if not os.path.isdir(state_dir):\n        return []\n    files = []\n    for name in os.listdir(state_dir):\n        if name.endswith(\".json\"):\n            files.append(os.path.join(state_dir, name))\n    return sorted(files)\n"
  },
  {
    "path": "src/scraper.py",
    "content": "import asyncio\nimport json\nimport os\nimport random\nfrom datetime import datetime\nfrom typing import Optional\nfrom urllib.parse import urlencode\n\nfrom playwright.async_api import (\n    Response,\n    TimeoutError as PlaywrightTimeoutError,\n    async_playwright,\n)\n\nfrom src.ai_handler import (\n    download_all_images,\n    get_ai_analysis,\n    send_ntfy_notification,\n    cleanup_task_images,\n)\nfrom src.config import (\n    AI_DEBUG_MODE,\n    DETAIL_API_URL_PATTERN,\n    LOGIN_IS_EDGE,\n    RUN_HEADLESS,\n    RUNNING_IN_DOCKER,\n    SKIP_AI_ANALYSIS,\n    STATE_FILE,\n)\nfrom src.parsers import (\n    _parse_search_results_json,\n    _parse_user_items_data,\n    calculate_reputation_from_ratings,\n    parse_ratings_data,\n    parse_user_head_data,\n)\nfrom src.utils import (\n    format_registration_days,\n    get_link_unique_key,\n    log_time,\n    random_sleep,\n    safe_get,\n    save_to_jsonl,\n)\nfrom src.rotation import RotationPool, load_state_files, parse_proxy_pool, RotationItem\nfrom src.failure_guard import FailureGuard\nfrom src.services.account_strategy_service import resolve_account_runtime_plan\nfrom src.infrastructure.persistence.storage_names import build_result_filename\nfrom src.services.item_analysis_dispatcher import (\n    ItemAnalysisDispatcher,\n    ItemAnalysisJob,\n)\nfrom src.services.price_history_service import (\n    build_market_reference,\n    load_price_snapshots,\n    record_market_snapshots,\n)\nfrom src.services.result_storage_service import load_processed_link_keys\nfrom src.services.seller_profile_cache import SellerProfileCache\nfrom src.services.search_pagination import (\n    advance_search_page,\n    is_search_results_response,\n)\n\n\nclass RiskControlError(Exception):\n    pass\n\n\nclass LoginRequiredError(Exception):\n    \"\"\"Raised when Goofish redirects to the passport/mini_login flow.\"\"\"\n\n\nFAILURE_GUARD = FailureGuard()\nEDGE_DOCKER_WARNING_PRINTED = False\n\n\ndef _is_login_url(url: str) -> bool:\n    if not url:\n        return False\n    lowered = url.lower()\n    return \"passport.goofish.com\" in lowered or \"mini_login\" in lowered\n\n\ndef _resolve_browser_channel() -> str:\n    global EDGE_DOCKER_WARNING_PRINTED\n    if RUNNING_IN_DOCKER:\n        if LOGIN_IS_EDGE and not EDGE_DOCKER_WARNING_PRINTED:\n            print(\n                \"检测到 LOGIN_IS_EDGE=true，但 Docker 镜像未内置 Edge，\"\n                \"任务运行时将改用 Chromium。\"\n            )\n            EDGE_DOCKER_WARNING_PRINTED = True\n        return \"chromium\"\n    return \"msedge\" if LOGIN_IS_EDGE else \"chrome\"\n\n\ndef _should_analyze_images(task_config: dict) -> bool:\n    raw_value = task_config.get(\"analyze_images\", True)\n    if isinstance(raw_value, bool):\n        return raw_value\n    return str(raw_value).strip().lower() not in {\"false\", \"0\", \"no\", \"off\"}\n\n\ndef _format_failure_reason(reason: str, limit: int = 500) -> str:\n    if not reason:\n        return \"未知错误\"\n    cleaned = \" \".join(str(reason).split())\n    if len(cleaned) <= limit:\n        return cleaned\n    return cleaned[: limit - 3] + \"...\"\n\n\nasync def _notify_task_failure(\n    task_config: dict, reason: str, *, cookie_path: Optional[str]\n) -> None:\n    task_name = task_config.get(\"task_name\", \"未命名任务\")\n    keyword = task_config.get(\"keyword\", \"\")\n    formatted_reason = _format_failure_reason(reason)\n\n    # Some failures are deterministic misconfiguration and should pause/notify immediately.\n    pause_immediately = any(\n        marker in formatted_reason\n        for marker in (\n            \"未找到可用的代理地址\",\n            \"未找到可用的登录状态文件\",\n        )\n    )\n\n    guard_result = FAILURE_GUARD.record_failure(\n        task_name,\n        formatted_reason,\n        cookie_path=cookie_path,\n        min_failures_to_pause=1 if pause_immediately else None,\n    )\n\n    if not guard_result.get(\"should_notify\"):\n        print(\n            f\"[FailureGuard] 任务 '{task_name}' 失败计数 {guard_result.get('consecutive_failures')}/{FAILURE_GUARD.threshold}，暂不通知。\"\n        )\n        return\n\n    paused_until = guard_result.get(\"paused_until\")\n    paused_until_str = (\n        paused_until.strftime(\"%Y-%m-%d %H:%M:%S\") if paused_until else \"N/A\"\n    )\n\n    product_data = {\n        \"商品标题\": f\"[任务异常] {task_name}\",\n        \"当前售价\": \"N/A\",\n        \"商品链接\": \"#\",\n    }\n    notify_reason = (\n        f\"任务运行失败(已连续 {guard_result.get('consecutive_failures')}/{FAILURE_GUARD.threshold} 次): {formatted_reason}\"\n        f\"\\n任务: {task_name}\"\n        f\"\\n关键词: {keyword or 'N/A'}\"\n        f\"\\n已自动暂停重试，暂停到: {paused_until_str}\"\n        f\"\\n修复后(更新登录态/cookies文件)将自动恢复。\"\n    )\n\n    try:\n        await send_ntfy_notification(product_data, notify_reason)\n    except Exception as e:\n        print(f\"发送任务异常通知失败: {e}\")\n\n\ndef _as_bool(value, default: bool = False) -> bool:\n    if value is None:\n        return default\n    if isinstance(value, bool):\n        return value\n    return str(value).strip().lower() in {\"1\", \"true\", \"yes\", \"y\", \"on\"}\n\n\ndef _as_int(value, default: int) -> int:\n    if value is None:\n        return default\n    try:\n        return int(value)\n    except (TypeError, ValueError):\n        return default\n\n\ndef _get_rotation_settings(task_config: dict) -> dict:\n    account_cfg = task_config.get(\"account_rotation\") or {}\n    proxy_cfg = task_config.get(\"proxy_rotation\") or {}\n\n    account_enabled = _as_bool(\n        account_cfg.get(\"enabled\"),\n        _as_bool(os.getenv(\"ACCOUNT_ROTATION_ENABLED\"), False),\n    )\n    account_mode = (\n        account_cfg.get(\"mode\") or os.getenv(\"ACCOUNT_ROTATION_MODE\", \"per_task\")\n    ).lower()\n    account_state_dir = account_cfg.get(\"state_dir\") or os.getenv(\n        \"ACCOUNT_STATE_DIR\", \"state\"\n    )\n    account_retry_limit = _as_int(\n        account_cfg.get(\"retry_limit\"),\n        _as_int(os.getenv(\"ACCOUNT_ROTATION_RETRY_LIMIT\"), 2),\n    )\n    account_blacklist_ttl = _as_int(\n        account_cfg.get(\"blacklist_ttl_sec\"),\n        _as_int(os.getenv(\"ACCOUNT_BLACKLIST_TTL\"), 300),\n    )\n\n    proxy_enabled = _as_bool(\n        proxy_cfg.get(\"enabled\"), _as_bool(os.getenv(\"PROXY_ROTATION_ENABLED\"), False)\n    )\n    proxy_mode = (\n        proxy_cfg.get(\"mode\") or os.getenv(\"PROXY_ROTATION_MODE\", \"per_task\")\n    ).lower()\n    proxy_pool = proxy_cfg.get(\"proxy_pool\") or os.getenv(\"PROXY_POOL\", \"\")\n    proxy_retry_limit = _as_int(\n        proxy_cfg.get(\"retry_limit\"),\n        _as_int(os.getenv(\"PROXY_ROTATION_RETRY_LIMIT\"), 2),\n    )\n    proxy_blacklist_ttl = _as_int(\n        proxy_cfg.get(\"blacklist_ttl_sec\"),\n        _as_int(os.getenv(\"PROXY_BLACKLIST_TTL\"), 300),\n    )\n\n    return {\n        \"account_enabled\": account_enabled,\n        \"account_mode\": account_mode,\n        \"account_state_dir\": account_state_dir,\n        \"account_retry_limit\": max(1, account_retry_limit),\n        \"account_blacklist_ttl\": max(0, account_blacklist_ttl),\n        \"proxy_enabled\": proxy_enabled,\n        \"proxy_mode\": proxy_mode,\n        \"proxy_pool\": proxy_pool,\n        \"proxy_retry_limit\": max(1, proxy_retry_limit),\n        \"proxy_blacklist_ttl\": max(0, proxy_blacklist_ttl),\n    }\n\n\ndef _get_ai_analysis_concurrency(task_config: dict) -> int:\n    configured = task_config.get(\"ai_analysis_concurrency\")\n    default = _as_int(os.getenv(\"AI_ANALYSIS_CONCURRENCY\"), 2)\n    return max(1, _as_int(configured, default))\n\n\ndef _get_seller_profile_cache_ttl(task_config: dict) -> int:\n    configured = task_config.get(\"seller_profile_cache_ttl\")\n    default = _as_int(os.getenv(\"SELLER_PROFILE_CACHE_TTL\"), 1800)\n    return max(0, _as_int(configured, default))\n\n\ndef _default_context_options() -> dict:\n    return {\n        \"user_agent\": \"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36\",\n        \"viewport\": {\"width\": 412, \"height\": 915},\n        \"device_scale_factor\": 2.625,\n        \"is_mobile\": True,\n        \"has_touch\": True,\n        \"locale\": \"zh-CN\",\n        \"timezone_id\": \"Asia/Shanghai\",\n        \"permissions\": [\"geolocation\"],\n        \"geolocation\": {\"longitude\": 121.4737, \"latitude\": 31.2304},\n        \"color_scheme\": \"light\",\n    }\n\n\ndef _clean_kwargs(options: dict) -> dict:\n    return {k: v for k, v in options.items() if v is not None}\n\n\ndef _looks_like_mobile(ua: str) -> Optional[bool]:\n    if not ua:\n        return None\n    ua_lower = ua.lower()\n    if \"mobile\" in ua_lower or \"android\" in ua_lower or \"iphone\" in ua_lower:\n        return True\n    if \"windows\" in ua_lower or \"macintosh\" in ua_lower:\n        return False\n    return None\n\n\ndef _build_context_overrides(snapshot: dict) -> dict:\n    env = snapshot.get(\"env\") or {}\n    headers = snapshot.get(\"headers\") or {}\n    navigator = env.get(\"navigator\") or {}\n    screen = env.get(\"screen\") or {}\n    intl = env.get(\"intl\") or {}\n\n    overrides = {}\n\n    ua = (\n        headers.get(\"User-Agent\")\n        or headers.get(\"user-agent\")\n        or navigator.get(\"userAgent\")\n    )\n    if ua:\n        overrides[\"user_agent\"] = ua\n\n    accept_language = headers.get(\"Accept-Language\") or headers.get(\"accept-language\")\n    locale = None\n    if accept_language:\n        locale = accept_language.split(\",\")[0].strip()\n    elif navigator.get(\"language\"):\n        locale = navigator[\"language\"]\n    if locale:\n        overrides[\"locale\"] = locale\n\n    tz = intl.get(\"timeZone\")\n    if tz:\n        overrides[\"timezone_id\"] = tz\n\n    width = screen.get(\"width\")\n    height = screen.get(\"height\")\n    if isinstance(width, (int, float)) and isinstance(height, (int, float)):\n        overrides[\"viewport\"] = {\"width\": int(width), \"height\": int(height)}\n\n    dpr = screen.get(\"devicePixelRatio\")\n    if isinstance(dpr, (int, float)):\n        overrides[\"device_scale_factor\"] = float(dpr)\n\n    touch_points = navigator.get(\"maxTouchPoints\")\n    if isinstance(touch_points, (int, float)):\n        overrides[\"has_touch\"] = touch_points > 0\n\n    mobile_flag = _looks_like_mobile(ua or \"\")\n    if mobile_flag is not None:\n        overrides[\"is_mobile\"] = mobile_flag\n\n    return _clean_kwargs(overrides)\n\n\ndef _build_extra_headers(raw_headers: Optional[dict]) -> dict:\n    if not raw_headers:\n        return {}\n    excluded = {\"cookie\", \"content-length\"}\n    headers = {}\n    for key, value in raw_headers.items():\n        if not key or key.lower() in excluded or value is None:\n            continue\n        headers[key] = value\n    return headers\n\n\nasync def scrape_user_profile(context, user_id: str) -> dict:\n    \"\"\"\n    【新版】访问指定用户的个人主页，按顺序采集其摘要信息、完整的商品列表和完整的评价列表。\n    \"\"\"\n    print(f\"   -> 开始采集用户ID: {user_id} 的完整信息...\")\n    profile_data = {}\n    page = await context.new_page()\n\n    # 为各项异步任务准备Future和数据容器\n    head_api_future = asyncio.get_event_loop().create_future()\n\n    all_items, all_ratings = [], []\n    stop_item_scrolling, stop_rating_scrolling = asyncio.Event(), asyncio.Event()\n\n    async def handle_response(response: Response):\n        # 捕获头部摘要API\n        if (\n            \"mtop.idle.web.user.page.head\" in response.url\n            and not head_api_future.done()\n        ):\n            try:\n                head_api_future.set_result(await response.json())\n                print(f\"      [API捕获] 用户头部信息... 成功\")\n            except Exception as e:\n                if not head_api_future.done():\n                    head_api_future.set_exception(e)\n\n        # 捕获商品列表API\n        elif \"mtop.idle.web.xyh.item.list\" in response.url:\n            try:\n                data = await response.json()\n                all_items.extend(data.get(\"data\", {}).get(\"cardList\", []))\n                print(f\"      [API捕获] 商品列表... 当前已捕获 {len(all_items)} 件\")\n                if not data.get(\"data\", {}).get(\"nextPage\", True):\n                    stop_item_scrolling.set()\n            except Exception as e:\n                stop_item_scrolling.set()\n\n        # 捕获评价列表API\n        elif \"mtop.idle.web.trade.rate.list\" in response.url:\n            try:\n                data = await response.json()\n                all_ratings.extend(data.get(\"data\", {}).get(\"cardList\", []))\n                print(f\"      [API捕获] 评价列表... 当前已捕获 {len(all_ratings)} 条\")\n                if not data.get(\"data\", {}).get(\"nextPage\", True):\n                    stop_rating_scrolling.set()\n            except Exception as e:\n                stop_rating_scrolling.set()\n\n    page.on(\"response\", handle_response)\n\n    try:\n        # --- 任务1: 导航并采集头部信息 ---\n        await page.goto(\n            f\"https://www.goofish.com/personal?userId={user_id}\",\n            wait_until=\"domcontentloaded\",\n            timeout=20000,\n        )\n        head_data = await asyncio.wait_for(head_api_future, timeout=15)\n        profile_data = await parse_user_head_data(head_data)\n\n        # --- 任务2: 滚动加载所有商品 (默认页面) ---\n        print(\"      [采集阶段] 开始采集该用户的商品列表...\")\n        await random_sleep(2, 4)  # 等待第一页商品API完成\n        while not stop_item_scrolling.is_set():\n            await page.evaluate(\"window.scrollTo(0, document.body.scrollHeight)\")\n            try:\n                await asyncio.wait_for(stop_item_scrolling.wait(), timeout=8)\n            except asyncio.TimeoutError:\n                print(\"      [滚动超时] 商品列表可能已加载完毕。\")\n                break\n        profile_data[\"卖家发布的商品列表\"] = await _parse_user_items_data(all_items)\n\n        # --- 任务3: 点击并采集所有评价 ---\n        print(\"      [采集阶段] 开始采集该用户的评价列表...\")\n        rating_tab_locator = page.locator(\"//div[text()='信用及评价']/ancestor::li\")\n        if await rating_tab_locator.count() > 0:\n            await rating_tab_locator.click()\n            await random_sleep(3, 5)  # 等待第一页评价API完成\n\n            while not stop_rating_scrolling.is_set():\n                await page.evaluate(\"window.scrollTo(0, document.body.scrollHeight)\")\n                try:\n                    await asyncio.wait_for(stop_rating_scrolling.wait(), timeout=8)\n                except asyncio.TimeoutError:\n                    print(\"      [滚动超时] 评价列表可能已加载完毕。\")\n                    break\n\n            profile_data[\"卖家收到的评价列表\"] = await parse_ratings_data(all_ratings)\n            reputation_stats = await calculate_reputation_from_ratings(all_ratings)\n            profile_data.update(reputation_stats)\n        else:\n            print(\"      [警告] 未找到评价选项卡，跳过评价采集。\")\n\n    except Exception as e:\n        print(f\"   [错误] 采集用户 {user_id} 信息时发生错误: {e}\")\n    finally:\n        page.remove_listener(\"response\", handle_response)\n        await page.close()\n        print(f\"   -> 用户 {user_id} 信息采集完成。\")\n\n    return profile_data\n\n\nasync def scrape_xianyu(task_config: dict, debug_limit: int = 0):\n    \"\"\"\n    【核心执行器】\n    根据单个任务配置，异步爬取闲鱼商品数据，并对每个新发现的商品进行实时的、独立的AI分析和通知。\n    \"\"\"\n    keyword = task_config[\"keyword\"]\n    max_pages = task_config.get(\"max_pages\", 1)\n    personal_only = task_config.get(\"personal_only\", False)\n    min_price = task_config.get(\"min_price\")\n    max_price = task_config.get(\"max_price\")\n    ai_prompt_text = task_config.get(\"ai_prompt_text\", \"\")\n    analyze_images = _should_analyze_images(task_config)\n    decision_mode = str(task_config.get(\"decision_mode\", \"ai\")).strip().lower()\n    if decision_mode not in {\"ai\", \"keyword\"}:\n        decision_mode = \"ai\"\n    keyword_rules = task_config.get(\"keyword_rules\") or []\n    free_shipping = task_config.get(\"free_shipping\", False)\n    raw_new_publish = task_config.get(\"new_publish_option\") or \"\"\n    new_publish_option = raw_new_publish.strip()\n    if new_publish_option == \"__none__\":\n        new_publish_option = \"\"\n    region_filter = (task_config.get(\"region\") or \"\").strip()\n\n    processed_links = set()\n    history_run_id = datetime.now().strftime(\"%Y%m%d%H%M%S\")\n    history_seen_item_ids: set[str] = set()\n    historical_snapshots = load_price_snapshots(keyword)\n    result_filename = build_result_filename(keyword)\n    processed_links = load_processed_link_keys(keyword)\n    if processed_links:\n        print(f\"LOG: 发现已存在结果集 {result_filename}，已加载 {len(processed_links)} 个历史商品用于去重。\")\n    else:\n        print(f\"LOG: 结果集 {result_filename} 当前为空，将写入新记录。\")\n\n    rotation_settings = _get_rotation_settings(task_config)\n    account_items = load_state_files(rotation_settings[\"account_state_dir\"])\n    runtime_plan = resolve_account_runtime_plan(\n        strategy=task_config.get(\"account_strategy\"),\n        account_state_file=task_config.get(\"account_state_file\"),\n        has_root_state_file=os.path.exists(STATE_FILE),\n        available_account_files=account_items,\n    )\n    forced_account = runtime_plan[\"forced_account\"]\n    if runtime_plan[\"prefer_root_state\"]:\n        account_items = [STATE_FILE]\n        rotation_settings[\"account_enabled\"] = False\n    elif runtime_plan[\"use_account_pool\"]:\n        rotation_settings[\"account_enabled\"] = True\n    else:\n        rotation_settings[\"account_enabled\"] = False\n\n    account_pool = RotationPool(\n        account_items, rotation_settings[\"account_blacklist_ttl\"], \"account\"\n    )\n    proxy_pool = RotationPool(\n        parse_proxy_pool(rotation_settings[\"proxy_pool\"]),\n        rotation_settings[\"proxy_blacklist_ttl\"],\n        \"proxy\",\n    )\n\n    selected_account: Optional[RotationItem] = None\n    selected_proxy: Optional[RotationItem] = None\n\n    def _select_account(force_new: bool = False) -> Optional[RotationItem]:\n        nonlocal selected_account\n        if forced_account:\n            return RotationItem(value=forced_account)\n        if not rotation_settings[\"account_enabled\"]:\n            if os.path.exists(STATE_FILE):\n                return RotationItem(value=STATE_FILE)\n            return None\n        if (\n            rotation_settings[\"account_mode\"] == \"per_task\"\n            and selected_account\n            and not force_new\n        ):\n            return selected_account\n        picked = account_pool.pick_random()\n        return picked or selected_account\n\n    def _select_proxy(force_new: bool = False) -> Optional[RotationItem]:\n        nonlocal selected_proxy\n        if not rotation_settings[\"proxy_enabled\"]:\n            return None\n        if (\n            rotation_settings[\"proxy_mode\"] == \"per_task\"\n            and selected_proxy\n            and not force_new\n        ):\n            return selected_proxy\n        picked = proxy_pool.pick_random()\n        return picked or selected_proxy\n\n    async def _run_scrape_attempt(state_file: str, proxy_server: Optional[str]) -> int:\n        processed_item_count = 0\n        stop_scraping = False\n\n        if not os.path.exists(state_file):\n            raise FileNotFoundError(f\"登录状态文件不存在: {state_file}\")\n\n        snapshot_data = None\n        try:\n            with open(state_file, \"r\", encoding=\"utf-8\") as f:\n                snapshot_data = json.load(f)\n        except Exception as e:\n            print(f\"警告：读取登录状态文件失败，将直接按路径使用: {e}\")\n\n        async with async_playwright() as p:\n            # 反检测启动参数\n            launch_args = [\n                \"--disable-blink-features=AutomationControlled\",\n                \"--disable-dev-shm-usage\",\n                \"--no-sandbox\",\n                \"--disable-setuid-sandbox\",\n                \"--disable-web-security\",\n                \"--disable-features=IsolateOrigins,site-per-process\",\n            ]\n\n            launch_kwargs = {\"headless\": RUN_HEADLESS, \"args\": launch_args}\n            if proxy_server:\n                launch_kwargs[\"proxy\"] = {\"server\": proxy_server}\n\n            launch_kwargs[\"channel\"] = _resolve_browser_channel()\n\n            browser = await p.chromium.launch(**launch_kwargs)\n\n            context_kwargs = _default_context_options()\n            storage_state_arg = state_file\n            analysis_dispatcher: Optional[ItemAnalysisDispatcher] = None\n\n            if isinstance(snapshot_data, dict):\n                # 新版扩展导出的增强快照，包含环境和Header\n                if any(\n                    key in snapshot_data\n                    for key in (\"env\", \"headers\", \"page\", \"storage\")\n                ):\n                    print(f\"检测到增强浏览器快照，应用环境参数: {state_file}\")\n                    storage_state_arg = {\"cookies\": snapshot_data.get(\"cookies\", [])}\n                    context_kwargs.update(_build_context_overrides(snapshot_data))\n                    extra_headers = _build_extra_headers(snapshot_data.get(\"headers\"))\n                    if extra_headers:\n                        context_kwargs[\"extra_http_headers\"] = extra_headers\n                else:\n                    storage_state_arg = snapshot_data\n\n            context_kwargs = _clean_kwargs(context_kwargs)\n            context = await browser.new_context(\n                storage_state=storage_state_arg, **context_kwargs\n            )\n            seller_profile_cache = SellerProfileCache(\n                ttl_seconds=_get_seller_profile_cache_ttl(task_config)\n            )\n            analysis_dispatcher = ItemAnalysisDispatcher(\n                concurrency=_get_ai_analysis_concurrency(task_config),\n                skip_ai_analysis=SKIP_AI_ANALYSIS,\n                seller_loader=lambda user_id: seller_profile_cache.get_or_load(\n                    str(user_id),\n                    lambda seller_key: scrape_user_profile(context, seller_key),\n                ),\n                image_downloader=download_all_images,\n                ai_analyzer=get_ai_analysis,\n                notifier=send_ntfy_notification,\n                saver=save_to_jsonl,\n            )\n\n            # 增强反检测脚本（模拟真实移动设备）\n            await context.add_init_script(\"\"\"\n                // 移除webdriver标识\n                Object.defineProperty(navigator, 'webdriver', {get: () => undefined});\n\n                // 模拟真实移动设备的navigator属性\n                Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3, 4, 5]});\n                Object.defineProperty(navigator, 'languages', {get: () => ['zh-CN', 'zh', 'en-US', 'en']});\n\n                // 添加chrome对象\n                window.chrome = {runtime: {}, loadTimes: function() {}, csi: function() {}};\n\n                // 模拟触摸支持\n                Object.defineProperty(navigator, 'maxTouchPoints', {get: () => 5});\n\n                // 覆盖permissions查询（避免暴露自动化）\n                const originalQuery = window.navigator.permissions.query;\n                window.navigator.permissions.query = (parameters) => (\n                    parameters.name === 'notifications' ?\n                        Promise.resolve({state: Notification.permission}) :\n                        originalQuery(parameters)\n                );\n            \"\"\")\n\n            page = await context.new_page()\n\n            try:\n                # 步骤 0 - 模拟真实用户：先访问首页（重要的反检测措施）\n                log_time(\"步骤 0 - 模拟真实用户访问首页...\")\n                await page.goto(\n                    \"https://www.goofish.com/\",\n                    wait_until=\"domcontentloaded\",\n                    timeout=30000,\n                )\n                log_time(\"[反爬] 在首页停留，模拟浏览...\")\n                await random_sleep(1, 2)\n\n                # 模拟随机滚动（移动设备的触摸滚动）\n                await page.evaluate(\"window.scrollBy(0, Math.random() * 500 + 200)\")\n                await random_sleep(1, 2)\n\n                log_time(\"步骤 1 - 导航到搜索结果页...\")\n                # 使用 'q' 参数构建正确的搜索URL，并进行URL编码\n                params = {\"q\": keyword}\n                search_url = f\"https://www.goofish.com/search?{urlencode(params)}\"\n                log_time(f\"目标URL: {search_url}\")\n\n                # 先监听搜索接口响应，再执行导航，避免错过首次请求\n                async with page.expect_response(\n                    is_search_results_response, timeout=30000\n                ) as initial_response_info:\n                    await page.goto(\n                        search_url, wait_until=\"domcontentloaded\", timeout=60000\n                    )\n                if _is_login_url(page.url):\n                    raise LoginRequiredError(\n                        f\"Login required: redirected to {page.url} (cookies/state likely expired)\"\n                    )\n\n                # 捕获初始搜索的API数据\n                initial_response = await initial_response_info.value\n\n                # 等待页面加载出关键筛选元素，以确认已成功进入搜索结果页\n                try:\n                    await page.wait_for_selector(\"text=新发布\", timeout=15000)\n                except PlaywrightTimeoutError as e:\n                    if _is_login_url(page.url):\n                        raise LoginRequiredError(\n                            f\"Login required: redirected to {page.url} (cookies/state likely expired)\"\n                        ) from e\n                    raise\n\n                # 模拟真实用户行为：页面加载后的初始停留和浏览\n                log_time(\"[反爬] 模拟用户查看页面...\")\n                await random_sleep(1, 3)\n\n                # --- 新增：检查是否存在验证弹窗 ---\n                baxia_dialog = page.locator(\"div.baxia-dialog-mask\")\n                middleware_widget = page.locator(\"div.J_MIDDLEWARE_FRAME_WIDGET\")\n                try:\n                    # 等待弹窗在2秒内出现。如果出现，则执行块内代码。\n                    await baxia_dialog.wait_for(state=\"visible\", timeout=2000)\n                    print(\n                        \"\\n==================== CRITICAL BLOCK DETECTED ====================\"\n                    )\n                    print(\"检测到闲鱼反爬虫验证弹窗 (baxia-dialog)，无法继续操作。\")\n                    print(\"这通常是因为操作过于频繁或被识别为机器人。\")\n                    print(\"建议：\")\n                    print(\"1. 停止脚本一段时间再试。\")\n                    print(\n                        \"2. (推荐) 在 .env 文件中设置 RUN_HEADLESS=false，以非无头模式运行，这有助于绕过检测。\"\n                    )\n                    print(f\"任务 '{keyword}' 将在此处中止。\")\n                    print(\n                        \"===================================================================\"\n                    )\n                    raise RiskControlError(\"baxia-dialog\")\n                except PlaywrightTimeoutError:\n                    # 2秒内弹窗未出现，这是正常情况，继续执行\n                    pass\n\n                # 检查是否有J_MIDDLEWARE_FRAME_WIDGET覆盖层\n                try:\n                    await middleware_widget.wait_for(state=\"visible\", timeout=2000)\n                    print(\n                        \"\\n==================== CRITICAL BLOCK DETECTED ====================\"\n                    )\n                    print(\n                        \"检测到闲鱼反爬虫验证弹窗 (J_MIDDLEWARE_FRAME_WIDGET)，无法继续操作。\"\n                    )\n                    print(\"这通常是因为操作过于频繁或被识别为机器人。\")\n                    print(\"建议：\")\n                    print(\"1. 停止脚本一段时间再试。\")\n                    print(\"2. (推荐) 更新登录状态文件，确保登录状态有效。\")\n                    print(\"3. 降低任务执行频率，避免被识别为机器人。\")\n                    print(f\"任务 '{keyword}' 将在此处中止。\")\n                    print(\n                        \"===================================================================\"\n                    )\n                    raise RiskControlError(\"J_MIDDLEWARE_FRAME_WIDGET\")\n                except PlaywrightTimeoutError:\n                    # 2秒内弹窗未出现，这是正常情况，继续执行\n                    pass\n                # --- 结束新增 ---\n\n                try:\n                    await page.click(\"div[class*='closeIconBg']\", timeout=3000)\n                    print(\"LOG: 已关闭广告弹窗。\")\n                except PlaywrightTimeoutError:\n                    print(\"LOG: 未检测到广告弹窗。\")\n\n                final_response = None\n                log_time(\"步骤 2 - 应用筛选条件...\")\n                if new_publish_option:\n                    try:\n                        await page.click(\"text=新发布\")\n                        await random_sleep(1, 2)  # 原来是 (1.5, 2.5)\n                        async with page.expect_response(\n                            is_search_results_response, timeout=20000\n                        ) as response_info:\n                            await page.click(f\"text={new_publish_option}\")\n                            # --- 修改: 增加排序后的等待时间 ---\n                            await random_sleep(2, 4)  # 原来是 (3, 5)\n                        final_response = await response_info.value\n                    except PlaywrightTimeoutError:\n                        log_time(\n                            f\"新发布筛选 '{new_publish_option}' 请求超时，继续执行。\"\n                        )\n                    except Exception as e:\n                        print(f\"LOG: 应用新发布筛选失败: {e}\")\n\n                if personal_only:\n                    async with page.expect_response(\n                        is_search_results_response, timeout=20000\n                    ) as response_info:\n                        await page.click(\"text=个人闲置\")\n                        # --- 修改: 将固定等待改为随机等待，并加长 ---\n                        await random_sleep(2, 4)  # 原来是 asyncio.sleep(5)\n                    final_response = await response_info.value\n\n                if free_shipping:\n                    try:\n                        async with page.expect_response(\n                            is_search_results_response, timeout=20000\n                        ) as response_info:\n                            await page.click(\"text=包邮\")\n                            await random_sleep(2, 4)\n                        final_response = await response_info.value\n                    except PlaywrightTimeoutError:\n                        log_time(\"包邮筛选请求超时，继续执行。\")\n                    except Exception as e:\n                        print(f\"LOG: 应用包邮筛选失败: {e}\")\n\n                if region_filter:\n                    try:\n                        area_trigger = page.get_by_text(\"区域\", exact=True)\n                        if await area_trigger.count():\n                            await area_trigger.first.click()\n                            await random_sleep(1.5, 2)\n                            popover_candidates = page.locator(\"div.ant-popover\")\n                            popover = popover_candidates.filter(\n                                has=page.locator(\n                                    \".areaWrap--FaZHsn8E, [class*='areaWrap']\"\n                                )\n                            ).last\n                            if not await popover.count():\n                                popover = popover_candidates.filter(\n                                    has=page.get_by_text(\"重新定位\")\n                                ).last\n                            if not await popover.count():\n                                popover = popover_candidates.filter(\n                                    has=page.get_by_text(\"查看\")\n                                ).last\n                            if not await popover.count():\n                                print(\"LOG: 未找到区域弹窗，跳过区域筛选。\")\n                                raise PlaywrightTimeoutError(\"region-popover-not-found\")\n                            await popover.wait_for(state=\"visible\", timeout=5000)\n\n                            # 列表容器：第一层 children 即省/市/区三列，不再强依赖具体类名，提升鲁棒性\n                            area_wrap = popover.locator(\n                                \".areaWrap--FaZHsn8E, [class*='areaWrap']\"\n                            ).first\n                            await area_wrap.wait_for(state=\"visible\", timeout=3000)\n                            columns = area_wrap.locator(\":scope > div\")\n                            col_prov = columns.nth(0)\n                            col_city = columns.nth(1)\n                            col_dist = columns.nth(2)\n\n                            region_parts = [\n                                p.strip() for p in region_filter.split(\"/\") if p.strip()\n                            ]\n\n                            async def _click_in_column(\n                                column_locator, text_value: str, desc: str\n                            ) -> None:\n                                option = column_locator.locator(\n                                    \".provItem--QAdOx8nD\", has_text=text_value\n                                ).first\n                                if await option.count():\n                                    await option.click()\n                                    await random_sleep(1.5, 2)\n                                    try:\n                                        await option.wait_for(\n                                            state=\"attached\", timeout=1500\n                                        )\n                                        await option.wait_for(\n                                            state=\"visible\", timeout=1500\n                                        )\n                                    except PlaywrightTimeoutError:\n                                        pass\n                                else:\n                                    print(f\"LOG: 未找到{desc} '{text_value}'，跳过。\")\n\n                            if len(region_parts) >= 1:\n                                await _click_in_column(\n                                    col_prov, region_parts[0], \"省份\"\n                                )\n                                await random_sleep(1, 2)\n                            if len(region_parts) >= 2:\n                                await _click_in_column(\n                                    col_city, region_parts[1], \"城市\"\n                                )\n                                await random_sleep(1, 2)\n                            if len(region_parts) >= 3:\n                                await _click_in_column(\n                                    col_dist, region_parts[2], \"区/县\"\n                                )\n                                await random_sleep(1, 2)\n\n                            search_btn = popover.locator(\n                                \"div.searchBtn--Ic6RKcAb\"\n                            ).first\n                            if await search_btn.count():\n                                try:\n                                    async with page.expect_response(\n                                        is_search_results_response,\n                                        timeout=20000,\n                                    ) as response_info:\n                                        await search_btn.click()\n                                        await random_sleep(2, 3)\n                                    final_response = await response_info.value\n                                except PlaywrightTimeoutError:\n                                    log_time(\"区域筛选提交超时，继续执行。\")\n                            else:\n                                print(\n                                    \"LOG: 未找到区域弹窗的“查看XX件宝贝”按钮，跳过提交。\"\n                                )\n                        else:\n                            print(\"LOG: 未找到区域筛选触发器。\")\n                    except PlaywrightTimeoutError:\n                        log_time(f\"区域筛选 '{region_filter}' 请求超时，继续执行。\")\n                    except Exception as e:\n                        print(f\"LOG: 应用区域筛选 '{region_filter}' 失败: {e}\")\n\n                if min_price or max_price:\n                    price_container = page.locator(\n                        'div[class*=\"search-price-input-container\"]'\n                    ).first\n                    if await price_container.is_visible():\n                        if min_price:\n                            await price_container.get_by_placeholder(\"¥\").first.fill(\n                                min_price\n                            )\n                            # --- 修改: 将固定等待改为随机等待 ---\n                            await random_sleep(1, 2.5)  # 原来是 asyncio.sleep(5)\n                        if max_price:\n                            await (\n                                price_container.get_by_placeholder(\"¥\")\n                                .nth(1)\n                                .fill(max_price)\n                            )\n                            # --- 修改: 将固定等待改为随机等待 ---\n                            await random_sleep(1, 2.5)  # 原来是 asyncio.sleep(5)\n\n                        async with page.expect_response(\n                            is_search_results_response, timeout=20000\n                        ) as response_info:\n                            await page.keyboard.press(\"Tab\")\n                            # --- 修改: 增加确认价格后的等待时间 ---\n                            await random_sleep(2, 4)  # 原来是 asyncio.sleep(5)\n                        final_response = await response_info.value\n                    else:\n                        print(\"LOG: 警告 - 未找到价格输入容器。\")\n\n                log_time(\"所有筛选已完成，开始处理商品列表...\")\n\n                current_response = (\n                    final_response\n                    if final_response and final_response.ok\n                    else initial_response\n                )\n                for page_num in range(1, max_pages + 1):\n                    if stop_scraping:\n                        break\n                    log_time(f\"开始处理第 {page_num}/{max_pages} 页 ...\")\n\n                    if page_num > 1:\n                        page_advance_result = await advance_search_page(\n                            page=page,\n                            page_num=page_num,\n                        )\n                        if not page_advance_result.advanced:\n                            break\n                        current_response = page_advance_result.response\n\n                    if not (current_response and current_response.ok):\n                        log_time(f\"第 {page_num} 页响应无效，跳过。\")\n                        continue\n\n                    basic_items = await _parse_search_results_json(\n                        await current_response.json(), f\"第 {page_num} 页\"\n                    )\n                    if not basic_items:\n                        break\n                    historical_snapshots.extend(\n                        record_market_snapshots(\n                            keyword=keyword,\n                            task_name=task_config.get(\"task_name\", \"Untitled Task\"),\n                            items=basic_items,\n                            run_id=history_run_id,\n                            snapshot_time=datetime.now().isoformat(),\n                            seen_item_ids=history_seen_item_ids,\n                        )\n                    )\n\n                    total_items_on_page = len(basic_items)\n                    for i, item_data in enumerate(basic_items, 1):\n                        if debug_limit > 0 and processed_item_count >= debug_limit:\n                            log_time(\n                                f\"已达到调试上限 ({debug_limit})，停止获取新商品。\"\n                            )\n                            stop_scraping = True\n                            break\n\n                        unique_key = get_link_unique_key(item_data[\"商品链接\"])\n                        if unique_key in processed_links:\n                            log_time(\n                                f\"[页内进度 {i}/{total_items_on_page}] 商品 '{item_data['商品标题'][:20]}...' 已存在，跳过。\"\n                            )\n                            continue\n\n                        log_time(\n                            f\"[页内进度 {i}/{total_items_on_page}] 发现新商品，获取详情: {item_data['商品标题'][:30]}...\"\n                        )\n                        # --- 修改: 访问详情页前的等待时间，模拟用户在列表页上看了一会儿 ---\n                        await random_sleep(2, 4)  # 原来是 (2, 4)\n\n                        detail_page = await context.new_page()\n                        try:\n                            async with detail_page.expect_response(\n                                lambda r: DETAIL_API_URL_PATTERN in r.url, timeout=25000\n                            ) as detail_info:\n                                await detail_page.goto(\n                                    item_data[\"商品链接\"],\n                                    wait_until=\"domcontentloaded\",\n                                    timeout=25000,\n                                )\n\n                            detail_response = await detail_info.value\n                            if detail_response.ok:\n                                detail_json = await detail_response.json()\n\n                                ret_string = str(\n                                    await safe_get(detail_json, \"ret\", default=[])\n                                )\n                                if \"FAIL_SYS_USER_VALIDATE\" in ret_string:\n                                    print(\n                                        \"\\n==================== CRITICAL BLOCK DETECTED ====================\"\n                                    )\n                                    print(\n                                        \"检测到闲鱼反爬虫验证 (FAIL_SYS_USER_VALIDATE)，程序将终止。\"\n                                    )\n                                    long_sleep_duration = random.randint(3, 60)\n                                    print(\n                                        f\"为避免账户风险，将执行一次长时间休眠 ({long_sleep_duration} 秒) 后再退出...\"\n                                    )\n                                    await asyncio.sleep(long_sleep_duration)\n                                    print(\"长时间休眠结束，现在将安全退出。\")\n                                    print(\n                                        \"===================================================================\"\n                                    )\n                                    raise RiskControlError(\"FAIL_SYS_USER_VALIDATE\")\n\n                                # 解析商品详情数据并更新 item_data\n                                item_do = await safe_get(\n                                    detail_json, \"data\", \"itemDO\", default={}\n                                )\n                                seller_do = await safe_get(\n                                    detail_json, \"data\", \"sellerDO\", default={}\n                                )\n\n                                reg_days_raw = await safe_get(\n                                    seller_do, \"userRegDay\", default=0\n                                )\n                                registration_duration_text = format_registration_days(\n                                    reg_days_raw\n                                )\n\n                                # --- START: 新增代码块 ---\n\n                                # 1. 提取卖家的芝麻信用信息\n                                zhima_credit_text = await safe_get(\n                                    seller_do, \"zhimaLevelInfo\", \"levelName\"\n                                )\n\n                                # 2. 提取该商品的完整图片列表\n                                image_infos = await safe_get(\n                                    item_do, \"imageInfos\", default=[]\n                                )\n                                if image_infos:\n                                    # 使用列表推导式获取所有有效的图片URL\n                                    all_image_urls = [\n                                        img.get(\"url\")\n                                        for img in image_infos\n                                        if img.get(\"url\")\n                                    ]\n                                    if all_image_urls:\n                                        # 用新的字段存储图片列表，替换掉旧的单个链接\n                                        item_data[\"商品图片列表\"] = all_image_urls\n                                        # (可选) 仍然保留主图链接，以防万一\n                                        item_data[\"商品主图链接\"] = all_image_urls[0]\n\n                                # --- END: 新增代码块 ---\n                                item_data[\"“想要”人数\"] = await safe_get(\n                                    item_do,\n                                    \"wantCnt\",\n                                    default=item_data.get(\"“想要”人数\", \"NaN\"),\n                                )\n                                item_data[\"浏览量\"] = await safe_get(\n                                    item_do, \"browseCnt\", default=\"-\"\n                                )\n                                # ...[此处可添加更多从详情页解析出的商品信息]...\n\n                                user_id = await safe_get(seller_do, \"sellerId\")\n\n                                # 构建基础记录\n                                final_record = {\n                                    \"爬取时间\": datetime.now().isoformat(),\n                                    \"搜索关键字\": keyword,\n                                    \"任务名称\": task_config.get(\n                                        \"task_name\", \"Untitled Task\"\n                                    ),\n                                    \"商品信息\": item_data,\n                                    \"卖家信息\": {},\n                                }\n                                price_reference = build_market_reference(\n                                    keyword=keyword,\n                                    item=item_data,\n                                    current_market_items=basic_items,\n                                    historical_snapshots=historical_snapshots,\n                                )\n                                final_record[\"价格参考\"] = price_reference\n                                final_record[\"price_insight\"] = price_reference.get(\n                                    \"本商品价格位置\", {}\n                                )\n\n                                analysis_dispatcher.submit(\n                                    ItemAnalysisJob(\n                                        keyword=keyword,\n                                        task_name=task_config.get(\n                                            \"task_name\", \"Untitled Task\"\n                                        ),\n                                        decision_mode=decision_mode,\n                                        analyze_images=analyze_images,\n                                        prompt_text=ai_prompt_text,\n                                        keyword_rules=tuple(keyword_rules or []),\n                                        final_record=final_record,\n                                        seller_id=str(user_id) if user_id else None,\n                                        zhima_credit_text=zhima_credit_text,\n                                        registration_duration_text=registration_duration_text,\n                                    )\n                                )\n\n                                processed_links.add(unique_key)\n                                processed_item_count += 1\n                                log_time(\n                                    f\"商品已提交后台分析。累计处理 {processed_item_count} 个新商品。\"\n                                )\n\n                                # --- 修改: 增加单个商品处理后的主要延迟 ---\n                                log_time(\n                                    \"[反爬] 执行一次主要的随机延迟以模拟用户浏览间隔...\"\n                                )\n                                await random_sleep(5, 10)\n                            else:\n                                print(\n                                    f\"   错误: 获取商品详情API响应失败，状态码: {detail_response.status}\"\n                                )\n                                if AI_DEBUG_MODE:\n                                    print(\n                                        f\"--- [DETAIL DEBUG] FAILED RESPONSE from {item_data['商品链接']} ---\"\n                                    )\n                                    try:\n                                        print(await detail_response.text())\n                                    except Exception as e:\n                                        print(f\"无法读取响应内容: {e}\")\n                                    print(\n                                        \"----------------------------------------------------\"\n                                    )\n\n                        except PlaywrightTimeoutError:\n                            print(f\"   错误: 访问商品详情页或等待API响应超时。\")\n                        except Exception as e:\n                            print(f\"   错误: 处理商品详情时发生未知错误: {e}\")\n                        finally:\n                            await detail_page.close()\n                            # --- 修改: 增加关闭页面后的短暂整理时间 ---\n                            await random_sleep(2, 4)  # 原来是 (1, 2.5)\n\n                    # --- 新增: 在处理完一页所有商品后，翻页前，增加一个更长的“休息”时间 ---\n                    if not stop_scraping and page_num < max_pages:\n                        print(\n                            f\"--- 第 {page_num} 页处理完毕，准备翻页。执行一次页面间的长时休息... ---\"\n                        )\n                        await random_sleep(10, 15)\n\n            except PlaywrightTimeoutError as e:\n                if _is_login_url(page.url):\n                    raise LoginRequiredError(\n                        f\"Login required: redirected to {page.url} (cookies/state likely expired)\"\n                    ) from e\n                print(f\"\\n操作超时错误: 页面元素或网络响应未在规定时间内出现。\\n{e}\")\n                raise\n            except asyncio.CancelledError:\n                log_time(\"收到取消信号，正在终止当前爬虫任务...\")\n                raise\n            except Exception as e:\n                if type(e).__name__ == \"TargetClosedError\":\n                    log_time(\"浏览器已关闭，忽略后续异常（可能是任务被停止）。\")\n                    return processed_item_count\n                if \"passport.goofish.com\" in str(e):\n                    raise LoginRequiredError(\n                        f\"Login required: redirected to passport flow ({e})\"\n                    ) from e\n                print(f\"\\n爬取过程中发生未知错误: {e}\")\n                raise\n            finally:\n                if analysis_dispatcher is not None:\n                    log_time(\"等待后台分析任务完成...\")\n                    await analysis_dispatcher.join()\n                log_time(\"任务执行完毕，浏览器将在5秒后自动关闭...\")\n                await asyncio.sleep(5)\n                if debug_limit:\n                    input(\"按回车键关闭浏览器...\")\n                await browser.close()\n\n        return processed_item_count\n\n    processed_item_count = 0\n    attempt_limit = max(\n        rotation_settings[\"account_retry_limit\"],\n        rotation_settings[\"proxy_retry_limit\"],\n        1,\n    )\n    last_error = \"\"\n    last_state_path: Optional[str] = None\n\n    # If this task is already in a paused state, skip immediately.\n    task_name_for_guard = task_config.get(\"task_name\", \"未命名任务\")\n    pause_cookie_path = None\n    if (\n        isinstance(task_config.get(\"account_state_file\"), str)\n        and task_config.get(\"account_state_file\").strip()\n    ):\n        pause_cookie_path = task_config.get(\"account_state_file\").strip()\n    elif os.path.exists(STATE_FILE):\n        pause_cookie_path = STATE_FILE\n\n    decision = FAILURE_GUARD.should_skip_start(\n        task_name_for_guard, cookie_path=pause_cookie_path\n    )\n    if decision.skip:\n        print(\n            f\"[FailureGuard] 任务 '{task_name_for_guard}' 已暂停重试 (连续失败 {decision.consecutive_failures}/{FAILURE_GUARD.threshold})\"\n        )\n        if decision.should_notify:\n            try:\n                await send_ntfy_notification(\n                    {\n                        \"商品标题\": f\"[任务暂停] {task_name_for_guard}\",\n                        \"当前售价\": \"N/A\",\n                        \"商品链接\": \"#\",\n                    },\n                    \"任务处于暂停状态，将跳过执行。\\n\"\n                    f\"原因: {decision.reason}\\n\"\n                    f\"连续失败: {decision.consecutive_failures}/{FAILURE_GUARD.threshold}\\n\"\n                    f\"暂停到: {decision.paused_until.strftime('%Y-%m-%d %H:%M:%S') if decision.paused_until else 'N/A'}\\n\"\n                    \"修复方法: 更新登录态/cookies文件后会自动恢复。\",\n                )\n            except Exception as e:\n                print(f\"发送任务暂停通知失败: {e}\")\n\n        cleanup_task_images(task_config.get(\"task_name\", \"default\"))\n        return 0\n\n    for attempt in range(1, attempt_limit + 1):\n        if attempt == 1:\n            selected_account = _select_account()\n            selected_proxy = _select_proxy()\n        else:\n            if (\n                rotation_settings[\"account_enabled\"]\n                and rotation_settings[\"account_mode\"] == \"on_failure\"\n            ):\n                account_pool.mark_bad(selected_account, last_error)\n                selected_account = _select_account(force_new=True)\n            if (\n                rotation_settings[\"proxy_enabled\"]\n                and rotation_settings[\"proxy_mode\"] == \"on_failure\"\n            ):\n                proxy_pool.mark_bad(selected_proxy, last_error)\n                selected_proxy = _select_proxy(force_new=True)\n\n        if rotation_settings[\"account_enabled\"] and not selected_account:\n            last_error = \"未找到可用的登录状态文件，无法继续执行任务。\"\n            print(last_error)\n            break\n        if not rotation_settings[\"account_enabled\"] and not selected_account:\n            last_error = \"未找到可用的登录状态文件，无法继续执行任务。\"\n            print(last_error)\n            break\n        if rotation_settings[\"proxy_enabled\"] and not selected_proxy:\n            last_error = \"未找到可用的代理地址，无法继续执行任务。\"\n            print(last_error)\n            break\n\n        state_path = selected_account.value if selected_account else STATE_FILE\n        last_state_path = state_path\n        proxy_server = selected_proxy.value if selected_proxy else None\n        if rotation_settings[\"account_enabled\"]:\n            print(f\"账号轮换：使用登录状态 {state_path}\")\n        if rotation_settings[\"proxy_enabled\"] and proxy_server:\n            print(f\"IP 轮换：使用代理 {proxy_server}\")\n\n        try:\n            processed_item_count += await _run_scrape_attempt(state_path, proxy_server)\n            last_error = \"\"\n            FAILURE_GUARD.record_success(task_name_for_guard)\n            break\n        except LoginRequiredError as e:\n            last_error = str(e)\n            print(f\"检测到登录失效/重定向: {e}\")\n            break\n        except RiskControlError as e:\n            last_error = str(e)\n            print(f\"检测到风控或验证触发: {e}\")\n            # 风控验证通常不是简单轮换能解决的，避免无意义重试。\n            break\n        except Exception as e:\n            last_error = f\"{type(e).__name__}: {e}\"\n            print(f\"本次尝试失败: {last_error}\")\n            if attempt < attempt_limit:\n                print(\"将尝试轮换账号/IP 后重试...\")\n\n    if last_error:\n        await _notify_task_failure(task_config, last_error, cookie_path=last_state_path)\n\n    # 清理任务图片目录\n    cleanup_task_images(task_config.get(\"task_name\", \"default\"))\n\n    return processed_item_count\n"
  },
  {
    "path": "src/services/__init__.py",
    "content": ""
  },
  {
    "path": "src/services/account_strategy_service.py",
    "content": "\"\"\"\n账号策略辅助函数\n\"\"\"\nfrom typing import Optional\n\n\nACCOUNT_STRATEGIES = {\"auto\", \"fixed\", \"rotate\"}\n\n\ndef clean_account_state_file(value: Optional[str]) -> Optional[str]:\n    if value is None:\n        return None\n    text = str(value).strip()\n    if not text or text in {\"null\", \"undefined\"}:\n        return None\n    return text\n\n\ndef normalize_account_strategy(\n    strategy: Optional[str],\n    account_state_file: Optional[str] = None,\n) -> str:\n    raw = str(strategy or \"\").strip().lower()\n    if raw in ACCOUNT_STRATEGIES:\n        return raw\n    if clean_account_state_file(account_state_file):\n        return \"fixed\"\n    return \"auto\"\n\n\ndef resolve_account_runtime_plan(\n    *,\n    strategy: Optional[str],\n    account_state_file: Optional[str],\n    has_root_state_file: bool,\n    available_account_files: list[str],\n) -> dict:\n    normalized_strategy = normalize_account_strategy(strategy, account_state_file)\n    cleaned_account = clean_account_state_file(account_state_file)\n    has_account_pool = len(available_account_files) > 0\n\n    if normalized_strategy == \"fixed\":\n        return {\n            \"strategy\": normalized_strategy,\n            \"forced_account\": cleaned_account,\n            \"use_account_pool\": False,\n            \"prefer_root_state\": False,\n        }\n\n    if normalized_strategy == \"rotate\":\n        return {\n            \"strategy\": normalized_strategy,\n            \"forced_account\": None,\n            \"use_account_pool\": has_account_pool,\n            \"prefer_root_state\": False,\n        }\n\n    return {\n        \"strategy\": normalized_strategy,\n        \"forced_account\": None,\n        \"use_account_pool\": (not has_root_state_file) and has_account_pool,\n        \"prefer_root_state\": has_root_state_file,\n    }\n"
  },
  {
    "path": "src/services/ai_request_compat.py",
    "content": "\"\"\"AI 请求兼容性辅助逻辑。\"\"\"\n\nimport copy\nfrom typing import Any, Dict, Iterable, List\n\n\nRESPONSES_API_MODE = \"responses\"\nCHAT_COMPLETIONS_API_MODE = \"chat_completions\"\nINPUT_TEXT_TYPE = \"input_text\"\nINPUT_IMAGE_TYPE = \"input_image\"\nIMAGE_DETAIL_AUTO = \"auto\"\nJSON_OUTPUT_TYPE = \"json_object\"\nUNSUPPORTED_JSON_OUTPUT_MARKERS = (\n    \"not supported by this model\",\n    \"json_object\",\n    \"json_schema\",\n    \"text.format\",\n    \"response_format.type\",\n)\nRESPONSES_API_UNSUPPORTED_MARKERS = (\n    \"404 page not found\",\n    \"page not found\",\n    \"/responses\",\n    \"/v1/responses\",\n)\nCHAT_COMPLETIONS_API_UNSUPPORTED_MARKERS = (\n    \"404 page not found\",\n    \"page not found\",\n    \"/chat/completions\",\n    \"/v1/chat/completions\",\n)\nUNSUPPORTED_TEMPERATURE_MARKERS = (\n    \"temperature\",\n    \"sampling temperature\",\n)\n\n\ndef build_responses_input(messages: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:\n    \"\"\"将 Chat Completions 风格的消息转换为 Responses API 输入。\"\"\"\n    input_items: List[Dict[str, Any]] = []\n    for message in messages:\n        role = str(message.get(\"role\") or \"user\")\n        input_items.append(\n            {\n                \"role\": role,\n                \"content\": _build_input_content(message.get(\"content\")),\n            }\n        )\n    return input_items\n\n\ndef add_json_text_format(\n    request_params: Dict[str, Any],\n    enabled: bool,\n) -> Dict[str, Any]:\n    \"\"\"按需附加 Responses API 的结构化 JSON 输出参数。\"\"\"\n    next_params = dict(request_params)\n    if not enabled:\n        return next_params\n\n    text_config = dict(next_params.get(\"text\") or {})\n    text_config[\"format\"] = {\"type\": JSON_OUTPUT_TYPE}\n    next_params[\"text\"] = text_config\n    return next_params\n\n\ndef add_json_response_format(\n    request_params: Dict[str, Any],\n    enabled: bool,\n) -> Dict[str, Any]:\n    \"\"\"按需附加 Chat Completions 的 JSON 输出参数。\"\"\"\n    next_params = dict(request_params)\n    if enabled:\n        next_params[\"response_format\"] = {\"type\": JSON_OUTPUT_TYPE}\n    return next_params\n\n\ndef is_json_output_unsupported_error(error: Exception) -> bool:\n    \"\"\"识别模型不支持结构化 JSON 输出参数的错误。\"\"\"\n    message = str(error)\n    return (\n        \"not supported\" in message.lower()\n        and any(marker in message for marker in UNSUPPORTED_JSON_OUTPUT_MARKERS)\n    )\n\n\ndef is_responses_api_unsupported_error(error: Exception) -> bool:\n    \"\"\"识别 OpenAI 兼容服务未实现 Responses API 的错误。\"\"\"\n    return _is_api_unsupported_error(error, RESPONSES_API_UNSUPPORTED_MARKERS)\n\n\ndef is_chat_completions_api_unsupported_error(error: Exception) -> bool:\n    \"\"\"识别 OpenAI 兼容服务未实现 Chat Completions API 的错误。\"\"\"\n    return _is_api_unsupported_error(error, CHAT_COMPLETIONS_API_UNSUPPORTED_MARKERS)\n\n\ndef build_ai_request_params(\n    api_mode: str,\n    *,\n    model: str,\n    messages: Iterable[Dict[str, Any]],\n    temperature: float | None = None,\n    max_output_tokens: int | None = None,\n    enable_json_output: bool = False,\n) -> Dict[str, Any]:\n    \"\"\"根据 API 模式构建请求参数。\"\"\"\n    request_params = {\"model\": model}\n    if api_mode == RESPONSES_API_MODE:\n        request_params[\"input\"] = build_responses_input(messages)\n        if max_output_tokens is not None:\n            request_params[\"max_output_tokens\"] = max_output_tokens\n        if temperature is not None:\n            request_params[\"temperature\"] = temperature\n        return add_json_text_format(request_params, enable_json_output)\n\n    if api_mode == CHAT_COMPLETIONS_API_MODE:\n        request_params[\"messages\"] = copy.deepcopy(list(messages))\n        if max_output_tokens is not None:\n            request_params[\"max_tokens\"] = max_output_tokens\n        if temperature is not None:\n            request_params[\"temperature\"] = temperature\n        return add_json_response_format(request_params, enable_json_output)\n\n    raise ValueError(f\"不支持的 AI API 模式: {api_mode}\")\n\n\nasync def create_ai_response_async(\n    client: Any,\n    api_mode: str,\n    request_params: Dict[str, Any],\n) -> Any:\n    \"\"\"根据 API 模式发起异步请求。\"\"\"\n    if api_mode == RESPONSES_API_MODE:\n        return await client.responses.create(**request_params)\n    if api_mode == CHAT_COMPLETIONS_API_MODE:\n        return await client.chat.completions.create(**request_params)\n    raise ValueError(f\"不支持的 AI API 模式: {api_mode}\")\n\n\ndef create_ai_response_sync(\n    client: Any,\n    api_mode: str,\n    request_params: Dict[str, Any],\n) -> Any:\n    \"\"\"根据 API 模式发起同步请求。\"\"\"\n    if api_mode == RESPONSES_API_MODE:\n        return client.responses.create(**request_params)\n    if api_mode == CHAT_COMPLETIONS_API_MODE:\n        return client.chat.completions.create(**request_params)\n    raise ValueError(f\"不支持的 AI API 模式: {api_mode}\")\n\n\ndef is_temperature_unsupported_error(error: Exception) -> bool:\n    \"\"\"识别模型或中转站不支持 temperature 参数的错误。\"\"\"\n    message = str(error).lower()\n    return (\n        \"not supported\" in message\n        or \"unsupported\" in message\n        or \"invalid\" in message\n        or \"参数错误\" in message\n    ) and any(marker in message for marker in UNSUPPORTED_TEMPERATURE_MARKERS)\n\n\ndef remove_temperature_param(request_params: Dict[str, Any]) -> Dict[str, Any]:\n    \"\"\"移除 temperature 参数，适配不支持采样温度的模型网关。\"\"\"\n    next_params = dict(request_params)\n    next_params.pop(\"temperature\", None)\n    return next_params\n\n\ndef _is_api_unsupported_error(\n    error: Exception,\n    markers: tuple[str, ...],\n) -> bool:\n    message = str(error).lower()\n    if any(marker in message for marker in markers):\n        return True\n\n    status_code = getattr(error, \"status_code\", None)\n    body = getattr(error, \"body\", None)\n    response = getattr(error, \"response\", None)\n    response_text = getattr(response, \"text\", None) if response else None\n    return (\n        status_code == 404\n        and message.strip() == \"error code: 404\"\n        and not body\n        and not response_text\n    )\n\n\ndef _build_input_content(content: Any) -> List[Dict[str, Any]]:\n    if isinstance(content, str):\n        return [{\"type\": INPUT_TEXT_TYPE, \"text\": content}]\n    if not isinstance(content, list):\n        raise ValueError(f\"AI消息内容类型不受支持: {type(content).__name__}\")\n\n    return [_coerce_content_item(item) for item in content]\n\n\ndef _coerce_content_item(item: Any) -> Dict[str, Any]:\n    if not isinstance(item, dict):\n        raise ValueError(f\"AI消息片段类型不受支持: {type(item).__name__}\")\n\n    item_type = item.get(\"type\")\n    if item_type in {\"text\", INPUT_TEXT_TYPE}:\n        text = item.get(\"text\")\n        if not isinstance(text, str):\n            raise ValueError(\"文本消息片段缺少 text 字段。\")\n        return {\"type\": INPUT_TEXT_TYPE, \"text\": text}\n\n    if item_type in {\"image_url\", INPUT_IMAGE_TYPE}:\n        return _build_image_input_item(item)\n\n    raise ValueError(f\"不支持的 AI 消息片段类型: {item_type}\")\n\n\ndef _build_image_input_item(item: Dict[str, Any]) -> Dict[str, Any]:\n    raw_image = item.get(\"image_url\")\n    if isinstance(raw_image, dict):\n        image_url = raw_image.get(\"url\")\n    else:\n        image_url = raw_image\n\n    if not isinstance(image_url, str) or not image_url.strip():\n        raise ValueError(\"图片消息片段缺少有效的 image_url。\")\n\n    return {\n        \"type\": INPUT_IMAGE_TYPE,\n        \"image_url\": image_url,\n        \"detail\": item.get(\"detail\", IMAGE_DETAIL_AUTO),\n    }\n"
  },
  {
    "path": "src/services/ai_response_parser.py",
    "content": "\"\"\"\nAI 响应解析工具\n\"\"\"\nimport json\nfrom typing import Any\n\n\nclass EmptyAIResponseError(ValueError):\n    \"\"\"AI 返回了空内容。\"\"\"\n\n\ndef extract_ai_response_content(response: Any) -> str:\n    \"\"\"从不同形态的 AI 响应中提取文本内容。\"\"\"\n    if response is None:\n        raise EmptyAIResponseError(\"AI响应对象为空。\")\n\n    if isinstance(response, (bytes, bytearray)):\n        text = response.decode(\"utf-8\", errors=\"replace\")\n        return _normalize_text_content(text)\n\n    if isinstance(response, str):\n        return _normalize_text_content(response)\n\n    output_text = getattr(response, \"output_text\", None)\n    if isinstance(output_text, str):\n        return _normalize_text_content(output_text)\n\n    choices = getattr(response, \"choices\", None)\n    if choices:\n        message = getattr(choices[0], \"message\", None)\n        if message is None:\n            raise EmptyAIResponseError(\"AI响应缺少 message。\")\n        content = getattr(message, \"content\", None)\n        return _normalize_text_content(_coerce_content_parts(content))\n\n    raise ValueError(f\"无法识别的AI响应类型: {type(response).__name__}\")\n\n\ndef parse_ai_response_json(content: str) -> dict:\n    \"\"\"解析 AI 文本响应中的 JSON。\"\"\"\n    cleaned = _strip_code_fences(content)\n    try:\n        return json.loads(cleaned)\n    except json.JSONDecodeError as exc:\n        return _extract_first_json_value(cleaned, exc)\n\n\ndef _coerce_content_parts(content: Any) -> str:\n    if content is None:\n        return \"\"\n    if isinstance(content, str):\n        return content\n    if isinstance(content, (bytes, bytearray)):\n        return content.decode(\"utf-8\", errors=\"replace\")\n    if not isinstance(content, list):\n        raise ValueError(f\"AI响应内容类型不受支持: {type(content).__name__}\")\n\n    parts: list[str] = []\n    for item in content:\n        if isinstance(item, str):\n            parts.append(item)\n            continue\n        if isinstance(item, dict):\n            text = item.get(\"text\")\n            if isinstance(text, str):\n                parts.append(text)\n            continue\n        text = getattr(item, \"text\", None)\n        if isinstance(text, str):\n            parts.append(text)\n    return \"\".join(parts)\n\n\ndef _normalize_text_content(content: str) -> str:\n    text = str(content).strip()\n    if not text:\n        raise EmptyAIResponseError(\"AI响应内容为空。\")\n    return text\n\n\ndef _strip_code_fences(content: str) -> str:\n    cleaned = content.strip()\n    if cleaned.startswith(\"```json\"):\n        cleaned = cleaned[7:]\n    if cleaned.startswith(\"```\"):\n        cleaned = cleaned[3:]\n    if cleaned.endswith(\"```\"):\n        cleaned = cleaned[:-3]\n    return cleaned.strip()\n\n\ndef _extract_first_json_value(\n    content: str,\n    fallback_error: json.JSONDecodeError,\n):\n    decoder = json.JSONDecoder()\n    last_error: json.JSONDecodeError | None = None\n\n    for start_index, char in enumerate(content):\n        if char not in \"{[\":\n            continue\n        try:\n            parsed, _ = decoder.raw_decode(content[start_index:])\n            return parsed\n        except json.JSONDecodeError as exc:\n            last_error = exc\n\n    if last_error is not None:\n        raise last_error\n    raise fallback_error\n"
  },
  {
    "path": "src/services/ai_service.py",
    "content": "\"\"\"\nAI 分析服务\n封装 AI 分析相关的业务逻辑\n\"\"\"\nfrom typing import Dict, List, Optional\nfrom src.infrastructure.external.ai_client import AIClient\n\n\nclass AIAnalysisService:\n    \"\"\"AI 分析服务\"\"\"\n\n    def __init__(self, ai_client: AIClient):\n        self.ai_client = ai_client\n\n    async def analyze_product(\n        self,\n        product_data: Dict,\n        image_paths: List[str],\n        prompt_text: str\n    ) -> Optional[Dict]:\n        \"\"\"\n        分析商品\n\n        Args:\n            product_data: 商品数据\n            image_paths: 图片路径列表\n            prompt_text: 分析提示词\n\n        Returns:\n            分析结果\n        \"\"\"\n        if not self.ai_client.is_available():\n            print(\"AI 客户端不可用，跳过分析\")\n            return None\n\n        try:\n            result = await self.ai_client.analyze(product_data, image_paths, prompt_text)\n\n            if result and self._validate_result(result):\n                return result\n            else:\n                print(\"AI 分析结果验证失败\")\n                return None\n        except Exception as e:\n            print(f\"AI 分析服务出错: {e}\")\n            return None\n\n    def _validate_result(self, result: Dict) -> bool:\n        \"\"\"验证 AI 分析结果的格式\"\"\"\n        required_fields = [\n            \"prompt_version\",\n            \"is_recommended\",\n            \"reason\",\n            \"risk_tags\",\n            \"criteria_analysis\"\n        ]\n\n        # 检查必需字段\n        for field in required_fields:\n            if field not in result:\n                print(f\"AI 响应缺少必需字段: {field}\")\n                return False\n\n        # 检查数据类型\n        if not isinstance(result.get(\"is_recommended\"), bool):\n            print(\"is_recommended 字段不是布尔类型\")\n            return False\n\n        if not isinstance(result.get(\"risk_tags\"), list):\n            print(\"risk_tags 字段不是列表类型\")\n            return False\n\n        criteria_analysis = result.get(\"criteria_analysis\", {})\n        if not isinstance(criteria_analysis, dict) or not criteria_analysis:\n            print(\"criteria_analysis 必须是非空字典\")\n            return False\n\n        return True\n"
  },
  {
    "path": "src/services/dashboard_payloads.py",
    "content": "\"\"\"\nDashboard 数据拼装辅助函数\n\"\"\"\nfrom __future__ import annotations\n\nfrom datetime import datetime\nfrom typing import Any\n\nfrom src.domain.models.task import Task\nfrom src.services.price_history_service import parse_price_value\nfrom src.services.result_file_service import (\n    normalize_keyword_from_filename,\n)\nfrom src.services.result_storage_service import load_result_summary\n\n\ndef normalize_text(value: str | None) -> str:\n    return (value or \"\").strip().lower()\n\n\ndef parse_timestamp(value: str | None) -> datetime | None:\n    if not value:\n      return None\n    normalized = value.strip()\n    for candidate in (normalized, normalized.replace(\"Z\", \"+00:00\"), normalized.replace(\" \", \"T\")):\n      try:\n        return datetime.fromisoformat(candidate)\n      except ValueError:\n        continue\n    return None\n\n\ndef serialize_timestamp(value: datetime | None) -> str | None:\n    return value.isoformat() if value else None\n\n\ndef build_empty_summary(task: Task) -> dict[str, Any]:\n    return {\n        \"task_id\": task.id,\n        \"task_name\": task.task_name,\n        \"keyword\": task.keyword,\n        \"filename\": None,\n        \"enabled\": task.enabled,\n        \"is_running\": task.is_running,\n        \"account_strategy\": task.account_strategy,\n        \"cron\": task.cron,\n        \"region\": task.region,\n        \"total_items\": 0,\n        \"recommended_items\": 0,\n        \"ai_recommended_items\": 0,\n        \"keyword_recommended_items\": 0,\n        \"latest_crawl_time\": None,\n        \"latest_recommended_title\": None,\n        \"latest_recommended_price\": None,\n    }\n\n\ndef build_activity(\n    *,\n    activity_id: str,\n    activity_type: str,\n    task_name: str,\n    keyword: str,\n    title: str,\n    status: str,\n    timestamp: datetime | None,\n    detail: str | None = None,\n    filename: str | None = None,\n) -> dict[str, Any]:\n    return {\n        \"id\": activity_id,\n        \"type\": activity_type,\n        \"task_name\": task_name,\n        \"keyword\": keyword,\n        \"title\": title,\n        \"status\": status,\n        \"detail\": detail,\n        \"filename\": filename,\n        \"timestamp\": serialize_timestamp(timestamp),\n    }\n\n\ndef sort_key_by_latest_time(item: dict[str, Any]) -> tuple[float, str]:\n    timestamp = parse_timestamp(item.get(\"latest_crawl_time\"))\n    return (timestamp.timestamp() if timestamp else 0.0, item.get(\"task_name\", \"\"))\n\n\ndef sort_key_by_activity_time(item: dict[str, Any]) -> tuple[float, str]:\n    timestamp = parse_timestamp(item.get(\"timestamp\"))\n    return (timestamp.timestamp() if timestamp else 0.0, item.get(\"id\", \"\"))\n\n\ndef _build_fallback_summary(task_name: str, keyword: str) -> dict[str, Any]:\n    return {\n        \"task_id\": None,\n        \"task_name\": task_name,\n        \"keyword\": keyword,\n        \"filename\": None,\n        \"enabled\": False,\n        \"is_running\": False,\n        \"account_strategy\": \"auto\",\n        \"cron\": None,\n        \"region\": None,\n        \"total_items\": 0,\n        \"recommended_items\": 0,\n        \"ai_recommended_items\": 0,\n        \"keyword_recommended_items\": 0,\n        \"latest_crawl_time\": None,\n        \"latest_recommended_title\": None,\n        \"latest_recommended_price\": None,\n    }\n\n\ndef _resolve_task(\n    task_lookup: dict[str, Task],\n    latest_record: dict[str, Any] | None,\n    keyword: str,\n) -> Task | None:\n    task = task_lookup.get(normalize_text(keyword))\n    if task is not None or latest_record is None:\n        return task\n    fallback_name = str(latest_record.get(\"任务名称\") or \"\")\n    return next(\n        (candidate for candidate in task_lookup.values() if candidate.task_name == fallback_name),\n        None,\n    )\n\n\ndef _collect_record_metrics(records: list[dict[str, Any]]) -> dict[str, Any]:\n    latest_crawl_time: datetime | None = None\n    latest_record: dict[str, Any] | None = None\n    latest_recommendation: dict[str, Any] | None = None\n    recommended_items = 0\n    ai_recommended_items = 0\n    keyword_recommended_items = 0\n\n    for record in records:\n        crawl_time = parse_timestamp(record.get(\"爬取时间\"))\n        if crawl_time and (latest_crawl_time is None or crawl_time > latest_crawl_time):\n            latest_crawl_time = crawl_time\n            latest_record = record\n\n        analysis = record.get(\"ai_analysis\", {}) or {}\n        if analysis.get(\"is_recommended\") is not True:\n            continue\n\n        recommended_items += 1\n        source = analysis.get(\"analysis_source\")\n        if source == \"ai\":\n            ai_recommended_items += 1\n        elif source == \"keyword\":\n            keyword_recommended_items += 1\n\n        recommendation_time = parse_timestamp(\n            latest_recommendation.get(\"爬取时间\") if latest_recommendation else None\n        )\n        if latest_recommendation is None or (crawl_time and recommendation_time and crawl_time > recommendation_time):\n            latest_recommendation = record\n        elif latest_recommendation is None and crawl_time:\n            latest_recommendation = record\n\n    return {\n        \"latest_crawl_time\": latest_crawl_time,\n        \"latest_record\": latest_record,\n        \"latest_recommendation\": latest_recommendation,\n        \"recommended_items\": recommended_items,\n        \"ai_recommended_items\": ai_recommended_items,\n        \"keyword_recommended_items\": keyword_recommended_items,\n    }\n\n\ndef _build_recommendation_activity(\n    *,\n    filename: str,\n    task_name: str,\n    keyword: str,\n    latest_recommendation: dict[str, Any] | None,\n) -> tuple[dict[str, Any] | None, str | None, float | None]:\n    if not latest_recommendation:\n        return None, None, None\n\n    product = latest_recommendation.get(\"商品信息\", {}) or {}\n    analysis = latest_recommendation.get(\"ai_analysis\", {}) or {}\n    title = str(product.get(\"商品标题\") or \"发现推荐商品\")\n    price = parse_price_value(product.get(\"当前售价\"))\n    status = \"AI 推荐\" if analysis.get(\"analysis_source\") == \"ai\" else \"关键词命中\"\n    detail = f\"当前价 ¥{price:.0f}\" if isinstance(price, (int, float)) else None\n    activity = build_activity(\n        activity_id=f\"{filename}:recommended\",\n        activity_type=\"recommendation\",\n        task_name=task_name,\n        keyword=keyword,\n        title=title,\n        status=status,\n        timestamp=parse_timestamp(latest_recommendation.get(\"爬取时间\")),\n        detail=detail,\n        filename=filename,\n    )\n    return activity, title, price\n\n\ndef _build_scan_activity(\n    *,\n    filename: str,\n    task_name: str,\n    keyword: str,\n    latest_record: dict[str, Any] | None,\n    total_items: int,\n) -> dict[str, Any] | None:\n    if not latest_record:\n        return None\n    product = latest_record.get(\"商品信息\", {}) or {}\n    title = str(product.get(\"商品标题\") or task_name)\n    return build_activity(\n        activity_id=f\"{filename}:scan\",\n        activity_type=\"scan\",\n        task_name=task_name,\n        keyword=keyword,\n        title=title,\n        status=\"结果已更新\",\n        timestamp=parse_timestamp(latest_record.get(\"爬取时间\")),\n        detail=f\"已累计 {total_items} 条样本\",\n        filename=filename,\n    )\n\n\nasync def summarize_result_file(\n    filename: str,\n    task_lookup: dict[str, Task],\n) -> tuple[dict[str, Any] | None, list[dict[str, Any]], datetime | None]:\n    metrics = await load_result_summary(filename)\n    if not metrics:\n        return None, [], None\n\n    latest_record = metrics[\"latest_record\"]\n    latest_crawl_time = parse_timestamp(metrics[\"latest_crawl_time\"])\n    keyword = str((latest_record or {}).get(\"搜索关键字\") or \"\") or normalize_keyword_from_filename(filename)\n    task = _resolve_task(task_lookup, latest_record, keyword)\n    task_name = task.task_name if task else str((latest_record or {}).get(\"任务名称\") or keyword)\n    summary = build_empty_summary(task) if task else _build_fallback_summary(task_name, keyword)\n\n    activities: list[dict[str, Any]] = []\n    recommendation, title, price = _build_recommendation_activity(\n        filename=filename,\n        task_name=task_name,\n        keyword=keyword,\n        latest_recommendation=metrics[\"latest_recommendation\"],\n    )\n    if recommendation:\n        activities.append(recommendation)\n\n    scan_activity = _build_scan_activity(\n        filename=filename,\n        task_name=task_name,\n        keyword=keyword,\n        latest_record=latest_record,\n        total_items=metrics[\"total_items\"],\n    )\n    if scan_activity:\n        activities.append(scan_activity)\n\n    summary.update(\n        {\n            \"filename\": filename,\n            \"total_items\": metrics[\"total_items\"],\n            \"recommended_items\": metrics[\"recommended_items\"],\n            \"ai_recommended_items\": metrics[\"ai_recommended_items\"],\n            \"keyword_recommended_items\": metrics[\"keyword_recommended_items\"],\n            \"latest_crawl_time\": serialize_timestamp(latest_crawl_time),\n            \"latest_recommended_title\": title,\n            \"latest_recommended_price\": price,\n        }\n    )\n    return summary, activities, latest_crawl_time\n\n\ndef build_task_state_activities(tasks: list[Task]) -> list[dict[str, Any]]:\n    activities: list[dict[str, Any]] = []\n    for task in tasks:\n        status = \"运行中\" if task.is_running else \"已启用\"\n        detail = \"任务正在轮询闲鱼结果\" if task.is_running else \"等待下一次调度执行\"\n        if not task.is_running and not task.enabled:\n            continue\n        activities.append(\n            build_activity(\n                activity_id=f\"task:{task.id}:{'running' if task.is_running else 'ready'}\",\n                activity_type=\"task\",\n                task_name=task.task_name,\n                keyword=task.keyword,\n                title=task.task_name,\n                status=status,\n                timestamp=None,\n                detail=detail,\n            )\n        )\n    return activities\n"
  },
  {
    "path": "src/services/dashboard_service.py",
    "content": "\"\"\"\nDashboard 聚合服务\n统一汇总任务、结果文件和最近活动，供首页概览使用。\n\"\"\"\nfrom __future__ import annotations\n\nfrom typing import Any\n\nfrom src.domain.models.task import Task\nfrom src.services.dashboard_payloads import (\n    build_empty_summary,\n    build_task_state_activities,\n    normalize_text,\n    serialize_timestamp,\n    sort_key_by_activity_time,\n    sort_key_by_latest_time,\n    summarize_result_file,\n)\nfrom src.services.result_storage_service import list_result_filenames\n\nMAX_RECENT_ACTIVITIES = 8\n\n\ndef _build_summary_metrics(tasks: list[Task], summary_list: list[dict[str, Any]], last_updated_at: Any) -> dict[str, Any]:\n    return {\n        \"enabled_tasks\": sum(1 for task in tasks if task.enabled),\n        \"running_tasks\": sum(1 for task in tasks if task.is_running),\n        \"result_files\": sum(1 for item in summary_list if item.get(\"filename\")),\n        \"scanned_items\": sum(int(item[\"total_items\"]) for item in summary_list),\n        \"recommended_items\": sum(int(item[\"recommended_items\"]) for item in summary_list),\n        \"ai_recommended_items\": sum(int(item[\"ai_recommended_items\"]) for item in summary_list),\n        \"keyword_recommended_items\": sum(int(item[\"keyword_recommended_items\"]) for item in summary_list),\n        \"last_updated_at\": serialize_timestamp(last_updated_at),\n    }\n\n\nasync def build_dashboard_snapshot(tasks: list[Task]) -> dict[str, Any]:\n    task_lookup = {normalize_text(task.keyword): task for task in tasks}\n    task_summaries: dict[str, dict[str, Any]] = {\n        task.task_name: build_empty_summary(task) for task in tasks\n    }\n    recent_activities = build_task_state_activities(tasks)\n    latest_updated_at = None\n\n    for filename in await list_result_filenames():\n        summary, activities, file_latest_time = await summarize_result_file(filename, task_lookup)\n        if summary:\n            task_summaries[summary[\"task_name\"]] = summary\n        recent_activities.extend(activities)\n        if file_latest_time and (latest_updated_at is None or file_latest_time > latest_updated_at):\n            latest_updated_at = file_latest_time\n\n    summary_list = sorted(task_summaries.values(), key=sort_key_by_latest_time, reverse=True)\n    focus_file = next((item[\"filename\"] for item in summary_list if item.get(\"filename\")), None)\n    return {\n        \"summary\": _build_summary_metrics(tasks, summary_list, latest_updated_at),\n        \"task_summaries\": summary_list,\n        \"recent_activities\": sorted(\n            recent_activities,\n            key=sort_key_by_activity_time,\n            reverse=True,\n        )[:MAX_RECENT_ACTIVITIES],\n        \"focus_file\": focus_file,\n    }\n"
  },
  {
    "path": "src/services/item_analysis_dispatcher.py",
    "content": "\"\"\"\n商品分析分发器\n将卖家资料采集、图片下载、AI 分析和结果保存移出主抓取链路。\n\"\"\"\nimport asyncio\nimport copy\nimport os\nfrom dataclasses import dataclass\nfrom typing import Awaitable, Callable, Optional\n\nfrom src.keyword_rule_engine import build_search_text, evaluate_keyword_rules\n\n\nSellerLoader = Callable[[str], Awaitable[dict]]\nImageDownloader = Callable[[str, list[str], str], Awaitable[list[str]]]\nAIAnalyzer = Callable[[dict, list[str], str], Awaitable[Optional[dict]]]\nNotifier = Callable[[dict, str], Awaitable[None]]\nSaver = Callable[[dict, str], Awaitable[bool]]\n\n\n@dataclass(frozen=True)\nclass ItemAnalysisJob:\n    keyword: str\n    task_name: str\n    decision_mode: str\n    analyze_images: bool\n    prompt_text: str\n    keyword_rules: tuple[str, ...]\n    final_record: dict\n    seller_id: Optional[str]\n    zhima_credit_text: Optional[str]\n    registration_duration_text: str\n\n\nclass ItemAnalysisDispatcher:\n    \"\"\"用受控并发处理商品分析和落盘。\"\"\"\n\n    def __init__(\n        self,\n        *,\n        concurrency: int,\n        skip_ai_analysis: bool,\n        seller_loader: SellerLoader,\n        image_downloader: ImageDownloader,\n        ai_analyzer: AIAnalyzer,\n        notifier: Notifier,\n        saver: Saver,\n    ) -> None:\n        self._semaphore = asyncio.Semaphore(max(1, concurrency))\n        self._skip_ai_analysis = skip_ai_analysis\n        self._seller_loader = seller_loader\n        self._image_downloader = image_downloader\n        self._ai_analyzer = ai_analyzer\n        self._notifier = notifier\n        self._saver = saver\n        self._tasks: set[asyncio.Task] = set()\n        self.completed_count = 0\n\n    def submit(self, job: ItemAnalysisJob) -> None:\n        task = asyncio.create_task(self._process_with_limit(job))\n        self._tasks.add(task)\n        task.add_done_callback(self._tasks.discard)\n\n    async def join(self) -> None:\n        while self._tasks:\n            await asyncio.gather(*tuple(self._tasks))\n\n    async def _process_with_limit(self, job: ItemAnalysisJob) -> None:\n        async with self._semaphore:\n            await self._process_job(job)\n\n    async def _process_job(self, job: ItemAnalysisJob) -> None:\n        record = copy.deepcopy(job.final_record)\n        item_data = record.get(\"商品信息\", {}) or {}\n        record[\"卖家信息\"] = await self._load_seller_info(job)\n        record[\"ai_analysis\"] = await self._build_analysis_result(job, record)\n        if await self._saver(record, job.keyword):\n            self.completed_count += 1\n        await self._notify_if_recommended(item_data, record[\"ai_analysis\"])\n\n    async def _load_seller_info(self, job: ItemAnalysisJob) -> dict:\n        seller_info = {}\n        if job.seller_id:\n            try:\n                seller_info = await self._seller_loader(job.seller_id)\n            except Exception as exc:\n                print(f\"   [卖家] 采集卖家 {job.seller_id} 信息失败: {exc}\")\n        merged = copy.deepcopy(seller_info or {})\n        merged[\"卖家芝麻信用\"] = job.zhima_credit_text\n        merged[\"卖家注册时长\"] = job.registration_duration_text\n        return merged\n\n    async def _build_analysis_result(self, job: ItemAnalysisJob, record: dict) -> dict:\n        if job.decision_mode == \"keyword\":\n            return self._build_keyword_result(job, record)\n        if self._skip_ai_analysis:\n            return self._build_skip_ai_result()\n        return await self._run_ai_analysis(job, record)\n\n    def _build_keyword_result(self, job: ItemAnalysisJob, record: dict) -> dict:\n        search_text = build_search_text(record)\n        return evaluate_keyword_rules(list(job.keyword_rules), search_text)\n\n    def _build_skip_ai_result(self) -> dict:\n        return {\n            \"analysis_source\": \"ai\",\n            \"is_recommended\": True,\n            \"reason\": \"商品已跳过AI分析，直接通知\",\n            \"keyword_hit_count\": 0,\n        }\n\n    def _build_ai_error_result(self, reason: str, *, error: str = \"\") -> dict:\n        payload = {\n            \"analysis_source\": \"ai\",\n            \"is_recommended\": False,\n            \"reason\": reason,\n            \"keyword_hit_count\": 0,\n        }\n        if error:\n            payload[\"error\"] = error\n        return payload\n\n    async def _run_ai_analysis(self, job: ItemAnalysisJob, record: dict) -> dict:\n        image_paths: list[str] = []\n        try:\n            image_paths = await self._download_images(job, record)\n            if not job.prompt_text:\n                return self._build_ai_error_result(\"任务未配置AI prompt，跳过分析。\")\n            ai_result = await self._ai_analyzer(record, image_paths, job.prompt_text)\n            if not ai_result:\n                return self._build_ai_error_result(\n                    \"AI analysis returned None after retries.\",\n                    error=\"AI analysis returned None after retries.\",\n                )\n            ai_result.setdefault(\"analysis_source\", \"ai\")\n            ai_result.setdefault(\"keyword_hit_count\", 0)\n            return ai_result\n        except Exception as exc:\n            return self._build_ai_error_result(\n                f\"AI分析异常: {exc}\",\n                error=str(exc),\n            )\n        finally:\n            self._cleanup_images(image_paths)\n\n    async def _download_images(self, job: ItemAnalysisJob, record: dict) -> list[str]:\n        if not job.analyze_images:\n            return []\n        item_data = record.get(\"商品信息\", {}) or {}\n        image_urls = item_data.get(\"商品图片列表\", [])\n        if not image_urls:\n            return []\n        return await self._image_downloader(\n            item_data[\"商品ID\"],\n            image_urls,\n            job.task_name,\n        )\n\n    def _cleanup_images(self, image_paths: list[str]) -> None:\n        for img_path in image_paths:\n            try:\n                if os.path.exists(img_path):\n                    os.remove(img_path)\n            except Exception as exc:\n                print(f\"   [图片] 删除图片文件时出错: {exc}\")\n\n    async def _notify_if_recommended(self, item_data: dict, analysis_result: dict) -> None:\n        if not analysis_result.get(\"is_recommended\"):\n            return\n        try:\n            await self._notifier(item_data, analysis_result.get(\"reason\", \"无\"))\n        except Exception as exc:\n            print(f\"   [通知] 发送推荐通知失败: {exc}\")\n"
  },
  {
    "path": "src/services/notification_config_service.py",
    "content": "\"\"\"\n通知配置读写与校验服务\n\"\"\"\nimport json\nfrom urllib.parse import urlparse\n\nfrom src.infrastructure.config.env_manager import env_manager\nfrom src.infrastructure.config.settings import (\n    DEFAULT_TELEGRAM_API_BASE_URL,\n    NotificationSettings,\n)\n\n\nNOTIFICATION_FIELD_MAP = {\n    \"NTFY_TOPIC_URL\": \"ntfy_topic_url\",\n    \"GOTIFY_URL\": \"gotify_url\",\n    \"GOTIFY_TOKEN\": \"gotify_token\",\n    \"BARK_URL\": \"bark_url\",\n    \"WX_BOT_URL\": \"wx_bot_url\",\n    \"TELEGRAM_BOT_TOKEN\": \"telegram_bot_token\",\n    \"TELEGRAM_CHAT_ID\": \"telegram_chat_id\",\n    \"TELEGRAM_API_BASE_URL\": \"telegram_api_base_url\",\n    \"WEBHOOK_URL\": \"webhook_url\",\n    \"WEBHOOK_METHOD\": \"webhook_method\",\n    \"WEBHOOK_HEADERS\": \"webhook_headers\",\n    \"WEBHOOK_CONTENT_TYPE\": \"webhook_content_type\",\n    \"WEBHOOK_QUERY_PARAMETERS\": \"webhook_query_parameters\",\n    \"WEBHOOK_BODY\": \"webhook_body\",\n    \"PCURL_TO_MOBILE\": \"pcurl_to_mobile\",\n}\n\nSECRET_NOTIFICATION_FIELDS = {\n    \"BARK_URL\",\n    \"GOTIFY_TOKEN\",\n    \"WX_BOT_URL\",\n    \"TELEGRAM_BOT_TOKEN\",\n    \"WEBHOOK_URL\",\n    \"WEBHOOK_HEADERS\",\n}\n\nJSON_NOTIFICATION_FIELDS = {\n    \"WEBHOOK_HEADERS\": True,\n    \"WEBHOOK_QUERY_PARAMETERS\": True,\n    \"WEBHOOK_BODY\": False,\n}\n\nURL_FIELDS = {\n    \"NTFY_TOPIC_URL\",\n    \"GOTIFY_URL\",\n    \"BARK_URL\",\n    \"WX_BOT_URL\",\n    \"TELEGRAM_API_BASE_URL\",\n    \"WEBHOOK_URL\",\n}\n\nALLOWED_WEBHOOK_METHODS = {\"GET\", \"POST\"}\nALLOWED_WEBHOOK_CONTENT_TYPES = {\"JSON\", \"FORM\"}\n\n\nclass NotificationSettingsValidationError(ValueError):\n    \"\"\"通知配置校验错误\"\"\"\n\n\ndef model_dump(model, *, exclude_unset: bool = False) -> dict:\n    if hasattr(model, \"model_dump\"):\n        return model.model_dump(exclude_unset=exclude_unset)\n    return model.dict(exclude_unset=exclude_unset)\n\n\ndef build_notification_settings_response(\n    settings: NotificationSettings | None = None,\n) -> dict:\n    notification_settings = settings or load_notification_settings()\n    response = {\n        \"NTFY_TOPIC_URL\": notification_settings.ntfy_topic_url or \"\",\n        \"GOTIFY_URL\": notification_settings.gotify_url or \"\",\n        \"GOTIFY_TOKEN\": \"\",\n        \"BARK_URL\": \"\",\n        \"WX_BOT_URL\": \"\",\n        \"TELEGRAM_BOT_TOKEN\": \"\",\n        \"TELEGRAM_CHAT_ID\": notification_settings.telegram_chat_id or \"\",\n        \"TELEGRAM_API_BASE_URL\": (\n            notification_settings.telegram_api_base_url\n            or DEFAULT_TELEGRAM_API_BASE_URL\n        ),\n        \"WEBHOOK_URL\": \"\",\n        \"WEBHOOK_METHOD\": notification_settings.webhook_method,\n        \"WEBHOOK_HEADERS\": \"\",\n        \"WEBHOOK_CONTENT_TYPE\": notification_settings.webhook_content_type,\n        \"WEBHOOK_QUERY_PARAMETERS\": notification_settings.webhook_query_parameters or \"\",\n        \"WEBHOOK_BODY\": notification_settings.webhook_body or \"\",\n        \"PCURL_TO_MOBILE\": notification_settings.pcurl_to_mobile,\n    }\n    for field in SECRET_NOTIFICATION_FIELDS:\n        attr_name = NOTIFICATION_FIELD_MAP[field]\n        response[f\"{field}_SET\"] = bool(getattr(notification_settings, attr_name))\n    response[\"CONFIGURED_CHANNELS\"] = build_configured_channels(notification_settings)\n    return response\n\n\ndef build_notification_status_flags(\n    settings: NotificationSettings | None = None,\n) -> dict:\n    notification_settings = settings or load_notification_settings()\n    return {\n        \"ntfy_topic_url_set\": bool(notification_settings.ntfy_topic_url),\n        \"gotify_url_set\": bool(notification_settings.gotify_url),\n        \"gotify_token_set\": bool(notification_settings.gotify_token),\n        \"bark_url_set\": bool(notification_settings.bark_url),\n        \"wx_bot_url_set\": bool(notification_settings.wx_bot_url),\n        \"telegram_bot_token_set\": bool(notification_settings.telegram_bot_token),\n        \"telegram_chat_id_set\": bool(notification_settings.telegram_chat_id),\n        \"webhook_url_set\": bool(notification_settings.webhook_url),\n        \"webhook_headers_set\": bool(notification_settings.webhook_headers),\n    }\n\n\ndef build_configured_channels(\n    settings: NotificationSettings | None = None,\n) -> list[str]:\n    notification_settings = settings or load_notification_settings()\n    channels = []\n    if notification_settings.ntfy_topic_url:\n        channels.append(\"ntfy\")\n    if notification_settings.bark_url:\n        channels.append(\"bark\")\n    if notification_settings.gotify_url and notification_settings.gotify_token:\n        channels.append(\"gotify\")\n    if notification_settings.wx_bot_url:\n        channels.append(\"wecom\")\n    if notification_settings.telegram_bot_token and notification_settings.telegram_chat_id:\n        channels.append(\"telegram\")\n    if notification_settings.webhook_url:\n        channels.append(\"webhook\")\n    return channels\n\n\ndef prepare_notification_settings_update(\n    patch_payload: dict,\n    existing_settings: NotificationSettings | None = None,\n) -> tuple[dict[str, str], list[str], NotificationSettings]:\n    current_settings = existing_settings or load_notification_settings()\n    merged_values = _notification_settings_to_values(current_settings)\n\n    for env_name, raw_value in patch_payload.items():\n        attr_name = NOTIFICATION_FIELD_MAP.get(env_name)\n        if attr_name is None:\n            continue\n        merged_values[attr_name] = _normalize_patch_value(env_name, raw_value)\n\n    normalized_values = _normalize_notification_values(merged_values)\n    candidate_settings = _build_notification_settings_model(normalized_values)\n    _validate_notification_settings(candidate_settings)\n\n    updates = {}\n    deletions = []\n    for env_name, raw_value in patch_payload.items():\n        attr_name = NOTIFICATION_FIELD_MAP.get(env_name)\n        if attr_name is None:\n            continue\n        value = normalized_values[attr_name]\n        if isinstance(value, bool):\n            updates[env_name] = \"true\" if value else \"false\"\n            continue\n        if value is None:\n            deletions.append(env_name)\n            continue\n        updates[env_name] = value\n    return updates, deletions, candidate_settings\n\n\ndef _notification_settings_to_values(settings: NotificationSettings) -> dict:\n    return {\n        attr_name: getattr(settings, attr_name)\n        for attr_name in NOTIFICATION_FIELD_MAP.values()\n    }\n\n\ndef load_notification_settings() -> NotificationSettings:\n    return _build_notification_settings_model(\n        {\n            \"ntfy_topic_url\": _normalize_existing_text(env_manager.get_value(\"NTFY_TOPIC_URL\")),\n            \"gotify_url\": _normalize_existing_text(env_manager.get_value(\"GOTIFY_URL\")),\n            \"gotify_token\": _normalize_existing_text(env_manager.get_value(\"GOTIFY_TOKEN\")),\n            \"bark_url\": _normalize_existing_text(env_manager.get_value(\"BARK_URL\")),\n            \"wx_bot_url\": _normalize_existing_text(env_manager.get_value(\"WX_BOT_URL\")),\n            \"telegram_bot_token\": _normalize_existing_text(env_manager.get_value(\"TELEGRAM_BOT_TOKEN\")),\n            \"telegram_chat_id\": _normalize_existing_text(env_manager.get_value(\"TELEGRAM_CHAT_ID\")),\n            \"telegram_api_base_url\": (\n                _normalize_existing_text(env_manager.get_value(\"TELEGRAM_API_BASE_URL\"))\n                or DEFAULT_TELEGRAM_API_BASE_URL\n            ),\n            \"webhook_url\": _normalize_existing_text(env_manager.get_value(\"WEBHOOK_URL\")),\n            \"webhook_method\": _normalize_existing_text(env_manager.get_value(\"WEBHOOK_METHOD\")) or \"POST\",\n            \"webhook_headers\": _normalize_existing_text(env_manager.get_value(\"WEBHOOK_HEADERS\")),\n            \"webhook_content_type\": _normalize_existing_text(env_manager.get_value(\"WEBHOOK_CONTENT_TYPE\")) or \"JSON\",\n            \"webhook_query_parameters\": _normalize_existing_text(env_manager.get_value(\"WEBHOOK_QUERY_PARAMETERS\")),\n            \"webhook_body\": _normalize_existing_text(env_manager.get_value(\"WEBHOOK_BODY\")),\n            \"pcurl_to_mobile\": _env_bool(env_manager.get_value(\"PCURL_TO_MOBILE\"), True),\n        }\n    )\n\n\ndef _build_notification_settings_model(values: dict) -> NotificationSettings:\n    if hasattr(NotificationSettings, \"model_construct\"):\n        return NotificationSettings.model_construct(**values)\n    return NotificationSettings.construct(**values)\n\n\ndef _normalize_patch_value(env_name: str, value):\n    if env_name == \"PCURL_TO_MOBILE\":\n        return bool(value)\n    if value is None:\n        return None\n    text = str(value).strip()\n    return text or None\n\n\ndef _normalize_existing_text(value: str | None) -> str | None:\n    if value is None:\n        return None\n    text = str(value).strip()\n    return text or None\n\n\ndef _env_bool(value: str | None, default: bool) -> bool:\n    if value is None:\n        return default\n    return str(value).strip().lower() in {\"1\", \"true\", \"yes\", \"y\", \"on\"}\n\n\ndef _normalize_notification_values(values: dict) -> dict:\n    normalized = dict(values)\n    normalized[\"webhook_method\"] = (\n        (normalized.get(\"webhook_method\") or \"POST\").strip().upper()\n    )\n    normalized[\"webhook_content_type\"] = (\n        (normalized.get(\"webhook_content_type\") or \"JSON\").strip().upper()\n    )\n\n    for env_name, expect_dict in JSON_NOTIFICATION_FIELDS.items():\n        attr_name = NOTIFICATION_FIELD_MAP[env_name]\n        raw_value = normalized.get(attr_name)\n        if raw_value is None:\n            continue\n        parsed = _parse_json_field(env_name, raw_value, expect_dict=expect_dict)\n        normalized[attr_name] = json.dumps(\n            parsed,\n            ensure_ascii=False,\n            separators=(\",\", \":\"),\n        )\n    return normalized\n\n\ndef _validate_notification_settings(settings: NotificationSettings) -> None:\n    for field_name in URL_FIELDS:\n        value = getattr(settings, NOTIFICATION_FIELD_MAP[field_name])\n        if value is not None:\n            _validate_http_url(field_name, value)\n\n    _validate_pair(\n        \"GOTIFY_URL\",\n        settings.gotify_url,\n        \"GOTIFY_TOKEN\",\n        settings.gotify_token,\n    )\n    _validate_pair(\n        \"TELEGRAM_BOT_TOKEN\",\n        settings.telegram_bot_token,\n        \"TELEGRAM_CHAT_ID\",\n        settings.telegram_chat_id,\n    )\n\n    if settings.webhook_method not in ALLOWED_WEBHOOK_METHODS:\n        allowed = \", \".join(sorted(ALLOWED_WEBHOOK_METHODS))\n        raise NotificationSettingsValidationError(\n            f\"WEBHOOK_METHOD 仅支持: {allowed}\"\n        )\n    if settings.webhook_content_type not in ALLOWED_WEBHOOK_CONTENT_TYPES:\n        allowed = \", \".join(sorted(ALLOWED_WEBHOOK_CONTENT_TYPES))\n        raise NotificationSettingsValidationError(\n            f\"WEBHOOK_CONTENT_TYPE 仅支持: {allowed}\"\n        )\n\n    has_webhook_extras = any(\n        [\n            settings.webhook_headers,\n            settings.webhook_query_parameters,\n            settings.webhook_body,\n        ]\n    )\n    if has_webhook_extras and not settings.webhook_url:\n        raise NotificationSettingsValidationError(\n            \"配置 Webhook 高级参数前必须先填写 WEBHOOK_URL\"\n        )\n\n    if settings.webhook_content_type == \"FORM\" and settings.webhook_body:\n        parsed_body = json.loads(settings.webhook_body)\n        if not isinstance(parsed_body, dict):\n            raise NotificationSettingsValidationError(\n                \"WEBHOOK_BODY 在 FORM 模式下必须是 JSON 对象\"\n            )\n\n\ndef _validate_http_url(field_name: str, value: str) -> None:\n    parsed = urlparse(value)\n    if parsed.scheme not in {\"http\", \"https\"} or not parsed.netloc:\n        raise NotificationSettingsValidationError(\n            f\"{field_name} 必须是合法的 HTTP/HTTPS URL\"\n        )\n\n\ndef _validate_pair(\n    left_name: str,\n    left_value: str | None,\n    right_name: str,\n    right_value: str | None,\n) -> None:\n    if bool(left_value) == bool(right_value):\n        return\n    raise NotificationSettingsValidationError(\n        f\"{left_name} 与 {right_name} 必须成对配置\"\n    )\n\n\ndef _parse_json_field(\n    field_name: str,\n    raw_value: str,\n    expect_dict: bool,\n):\n    try:\n        parsed = json.loads(raw_value)\n    except json.JSONDecodeError as exc:\n        raise NotificationSettingsValidationError(\n            f\"{field_name} 不是合法 JSON: {exc.msg}\"\n        ) from exc\n    if expect_dict and not isinstance(parsed, dict):\n        raise NotificationSettingsValidationError(\n            f\"{field_name} 必须是 JSON 对象\"\n        )\n    return parsed\n"
  },
  {
    "path": "src/services/notification_service.py",
    "content": "\"\"\"\n通知服务\n统一管理所有通知渠道\n\"\"\"\nimport asyncio\nfrom typing import Dict, List\n\nfrom src.infrastructure.external.notification_clients.base import NotificationClient\nfrom src.infrastructure.external.notification_clients.factory import build_notification_clients\nfrom src.services.notification_config_service import load_notification_settings\nfrom src.infrastructure.config.settings import NotificationSettings\n\n\nclass NotificationService:\n    \"\"\"通知服务\"\"\"\n\n    def __init__(self, clients: List[NotificationClient]):\n        self.clients = [client for client in clients if client.is_enabled()]\n\n    async def send_notification(\n        self,\n        product_data: Dict,\n        reason: str,\n    ) -> Dict[str, Dict[str, str | bool]]:\n        \"\"\"\n        发送通知到所有启用的渠道\n\n        Returns:\n            各渠道发送结果，包含成功状态和消息\n        \"\"\"\n        if not self.clients:\n            return {}\n\n        tasks = [\n            self._send_with_result(client, product_data, reason)\n            for client in self.clients\n        ]\n        results = await asyncio.gather(*tasks)\n        return {result[\"channel\"]: result for result in results}\n\n    async def send_test_notification(self) -> Dict[str, Dict[str, str | bool]]:\n        test_product = {\n            \"商品标题\": \"[测试通知] 闲鱼智能监控\",\n            \"当前售价\": \"0\",\n            \"商品链接\": \"https://www.goofish.com/\",\n        }\n        return await self.send_notification(\n            test_product,\n            \"这是一条测试通知，用于验证推送渠道是否可用。\",\n        )\n\n    async def _send_with_result(\n        self,\n        client: NotificationClient,\n        product_data: Dict,\n        reason: str,\n    ) -> Dict[str, str | bool]:\n        try:\n            await client.send(product_data, reason)\n            return {\n                \"channel\": client.channel_key,\n                \"label\": client.display_name,\n                \"success\": True,\n                \"message\": \"发送成功\",\n            }\n        except Exception as exc:\n            return {\n                \"channel\": client.channel_key,\n                \"label\": client.display_name,\n                \"success\": False,\n                \"message\": str(exc),\n            }\n\n\ndef build_notification_service(\n    settings: NotificationSettings | None = None,\n) -> NotificationService:\n    notification_settings = settings or load_notification_settings()\n    return NotificationService(build_notification_clients(notification_settings))\n"
  },
  {
    "path": "src/services/price_history_service.py",
    "content": "\"\"\"\n价格历史记录与聚合服务\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport math\nimport os\nfrom collections import defaultdict\nfrom datetime import datetime\nfrom statistics import median\nfrom typing import Any, Iterable, Optional\n\nfrom src.infrastructure.persistence.sqlite_bootstrap import bootstrap_sqlite_storage\nfrom src.infrastructure.persistence.sqlite_connection import sqlite_connection\n\nPRICE_HISTORY_DIR = \"price_history\"\nDEFAULT_HISTORY_WINDOW_DAYS = 30\n\n\ndef normalize_keyword_slug(keyword: str) -> str:\n    text = \"\".join(\n        char for char in str(keyword or \"\").lower().replace(\" \", \"_\")\n        if char.isalnum() or char in \"_-\"\n    ).rstrip(\"_\")\n    return text or \"unknown\"\n\n\ndef build_price_history_path(keyword: str) -> str:\n    return os.path.join(\n        PRICE_HISTORY_DIR,\n        f\"{normalize_keyword_slug(keyword)}_history.jsonl\",\n    )\n\n\ndef parse_price_value(value: Any) -> Optional[float]:\n    if value is None:\n        return None\n    if isinstance(value, (int, float)):\n        return round(float(value), 2)\n\n    text = str(value).strip().replace(\"¥\", \"\").replace(\",\", \"\")\n    if not text or text in {\"价格异常\", \"暂无\", \"-\", \"N/A\"}:\n        return None\n    if text.endswith(\"万\"):\n        text = str(float(text[:-1]) * 10000)\n    try:\n        return round(float(text), 2)\n    except (TypeError, ValueError):\n        return None\n\n\ndef _safe_iso_datetime(value: Optional[str]) -> str:\n    if value:\n        return value\n    return datetime.now().isoformat()\n\n\ndef _to_day(iso_text: str) -> str:\n    return iso_text[:10]\n\n\ndef _build_snapshot_record(\n    *,\n    keyword: str,\n    task_name: str,\n    item: dict,\n    run_id: str,\n    snapshot_time: str,\n) -> Optional[dict]:\n    item_id = str(item.get(\"商品ID\") or \"\").strip()\n    link = str(item.get(\"商品链接\") or \"\").strip()\n    unique_id = item_id or link\n    price_value = parse_price_value(item.get(\"当前售价\"))\n    if not unique_id or price_value is None:\n        return None\n\n    return {\n        \"snapshot_time\": snapshot_time,\n        \"snapshot_day\": _to_day(snapshot_time),\n        \"run_id\": run_id,\n        \"task_name\": task_name,\n        \"keyword\": keyword,\n        \"item_id\": unique_id,\n        \"title\": item.get(\"商品标题\") or \"\",\n        \"price\": price_value,\n        \"price_display\": item.get(\"当前售价\") or \"\",\n        \"tags\": item.get(\"商品标签\") or [],\n        \"region\": item.get(\"发货地区\") or \"\",\n        \"seller\": item.get(\"卖家昵称\") or \"\",\n        \"publish_time\": item.get(\"发布时间\") or \"\",\n        \"link\": link,\n    }\n\n\ndef record_market_snapshots(\n    *,\n    keyword: str,\n    task_name: str,\n    items: Iterable[dict],\n    run_id: str,\n    snapshot_time: Optional[str] = None,\n    seen_item_ids: Optional[set[str]] = None,\n) -> list[dict]:\n    snapshot_time = _safe_iso_datetime(snapshot_time)\n    seen = seen_item_ids if seen_item_ids is not None else set()\n    records: list[dict] = []\n\n    for item in items:\n        record = _build_snapshot_record(\n            keyword=keyword,\n            task_name=task_name,\n            item=item,\n            run_id=run_id,\n            snapshot_time=snapshot_time,\n        )\n        if record is None or record[\"item_id\"] in seen:\n            continue\n        seen.add(record[\"item_id\"])\n        records.append(record)\n\n    if not records:\n        return []\n\n    bootstrap_sqlite_storage()\n    keyword_slug = normalize_keyword_slug(keyword)\n    with sqlite_connection() as conn:\n        for record in records:\n            conn.execute(\n                \"\"\"\n                INSERT OR IGNORE INTO price_snapshots (\n                    keyword_slug, keyword, task_name, snapshot_time, snapshot_day,\n                    run_id, item_id, title, price, price_display, tags_json, region,\n                    seller, publish_time, link\n                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n                \"\"\",\n                (\n                    keyword_slug,\n                    record.get(\"keyword\", keyword),\n                    record.get(\"task_name\", task_name),\n                    record.get(\"snapshot_time\", snapshot_time),\n                    record.get(\"snapshot_day\", _to_day(snapshot_time)),\n                    record.get(\"run_id\", run_id),\n                    record.get(\"item_id\", \"\"),\n                    record.get(\"title\", \"\"),\n                    record.get(\"price\"),\n                    record.get(\"price_display\", \"\"),\n                    json.dumps(record.get(\"tags\") or [], ensure_ascii=False),\n                    record.get(\"region\", \"\"),\n                    record.get(\"seller\", \"\"),\n                    record.get(\"publish_time\", \"\"),\n                    record.get(\"link\", \"\"),\n                ),\n            )\n        conn.commit()\n    return records\n\n\ndef load_price_snapshots(keyword: str) -> list[dict]:\n    bootstrap_sqlite_storage()\n    with sqlite_connection() as conn:\n        rows = conn.execute(\n            \"\"\"\n            SELECT *\n            FROM price_snapshots\n            WHERE keyword_slug = ?\n            ORDER BY snapshot_time ASC, id ASC\n            \"\"\",\n            (normalize_keyword_slug(keyword),),\n        ).fetchall()\n    snapshots: list[dict] = []\n    for row in rows:\n        snapshots.append(\n            {\n                \"snapshot_time\": row[\"snapshot_time\"],\n                \"snapshot_day\": row[\"snapshot_day\"],\n                \"run_id\": row[\"run_id\"],\n                \"task_name\": row[\"task_name\"],\n                \"keyword\": row[\"keyword\"],\n                \"item_id\": row[\"item_id\"],\n                \"title\": row[\"title\"],\n                \"price\": row[\"price\"],\n                \"price_display\": row[\"price_display\"],\n                \"tags\": json.loads(row[\"tags_json\"] or \"[]\"),\n                \"region\": row[\"region\"],\n                \"seller\": row[\"seller\"],\n                \"publish_time\": row[\"publish_time\"],\n                \"link\": row[\"link\"],\n            }\n        )\n    return snapshots\n\n\ndef delete_price_snapshots(keyword: str) -> int:\n    bootstrap_sqlite_storage()\n    with sqlite_connection() as conn:\n        cursor = conn.execute(\n            \"DELETE FROM price_snapshots WHERE keyword_slug = ?\",\n            (normalize_keyword_slug(keyword),),\n        )\n        conn.commit()\n    return int(cursor.rowcount or 0)\n\n\ndef _dedupe_latest(records: Iterable[dict], group_key: str) -> list[dict]:\n    latest_by_key: dict[str, dict] = {}\n    for record in records:\n        key = str(record.get(group_key) or \"\").strip()\n        if not key:\n            continue\n        latest_by_key[key] = record\n    return list(latest_by_key.values())\n\n\ndef _summarize_prices(records: Iterable[dict]) -> dict:\n    entries = [record for record in records if parse_price_value(record.get(\"price\")) is not None]\n    prices = [float(record[\"price\"]) for record in entries]\n    if not prices:\n        return {\n            \"sample_count\": 0,\n            \"avg_price\": None,\n            \"median_price\": None,\n            \"min_price\": None,\n            \"max_price\": None,\n        }\n\n    return {\n        \"sample_count\": len(prices),\n        \"avg_price\": round(sum(prices) / len(prices), 2),\n        \"median_price\": round(float(median(prices)), 2),\n        \"min_price\": round(min(prices), 2),\n        \"max_price\": round(max(prices), 2),\n    }\n\n\ndef _build_daily_trend(snapshots: list[dict]) -> list[dict]:\n    grouped: dict[str, list[dict]] = defaultdict(list)\n    for snapshot in snapshots:\n        grouped[str(snapshot.get(\"snapshot_day\") or \"\")].append(snapshot)\n\n    points: list[dict] = []\n    for day in sorted(grouped.keys()):\n        day_records = _dedupe_latest(grouped[day], \"item_id\")\n        summary = _summarize_prices(day_records)\n        summary[\"day\"] = day\n        points.append(summary)\n    return points\n\n\ndef _recent_window_snapshots(snapshots: list[dict], window_days: int) -> list[dict]:\n    if not snapshots:\n        return []\n    latest_time = max(str(record.get(\"snapshot_time\") or \"\") for record in snapshots)\n    latest_dt = datetime.fromisoformat(latest_time)\n    filtered = []\n    for record in snapshots:\n        current_time = datetime.fromisoformat(str(record.get(\"snapshot_time\") or latest_time))\n        if (latest_dt - current_time).days <= max(0, window_days):\n            filtered.append(record)\n    return filtered\n\n\ndef _resolve_deal_label(score: int) -> str:\n    if score >= 65:\n        return \"高性价比\"\n    if score >= 50:\n        return \"值得关注\"\n    if score >= 40:\n        return \"价格正常\"\n    return \"价格偏高\"\n\n\ndef build_item_price_context(\n    snapshots: list[dict],\n    *,\n    item_id: str,\n    current_price: Optional[float],\n) -> dict:\n    if not item_id:\n        return {\"observation_count\": 0, \"deal_score\": None, \"deal_label\": \"暂无数据\"}\n\n    item_snapshots = [record for record in snapshots if str(record.get(\"item_id\")) == str(item_id)]\n    if not item_snapshots:\n        return {\"observation_count\": 0, \"deal_score\": None, \"deal_label\": \"暂无数据\"}\n\n    latest_item_snapshot = item_snapshots[-1]\n    price_now = current_price if current_price is not None else parse_price_value(latest_item_snapshot.get(\"price\"))\n    historical_prices = [float(record[\"price\"]) for record in item_snapshots if parse_price_value(record.get(\"price\")) is not None]\n    latest_run_id = str(snapshots[-1].get(\"run_id\") or \"\")\n    latest_market = _dedupe_latest(\n        [record for record in snapshots if str(record.get(\"run_id\") or \"\") == latest_run_id],\n        \"item_id\",\n    )\n    market_summary = _summarize_prices(latest_market)\n    market_avg = market_summary.get(\"avg_price\")\n    market_median = market_summary.get(\"median_price\")\n\n    score = 50\n    if price_now is not None and market_avg:\n        score += int(((market_avg - price_now) / market_avg) * 60)\n    if price_now is not None and historical_prices:\n        historical_max = max(historical_prices)\n        if historical_max > 0:\n            score += int(((historical_max - price_now) / historical_max) * 20)\n        if math.isclose(price_now, min(historical_prices), rel_tol=0.001):\n            score += 8\n    score = max(0, min(100, score))\n\n    previous_price = historical_prices[-2] if len(historical_prices) >= 2 else None\n    change_amount = None if previous_price is None or price_now is None else round(price_now - previous_price, 2)\n    change_percent = None\n    if change_amount is not None and previous_price:\n        change_percent = round(change_amount / previous_price * 100, 2)\n\n    return {\n        \"observation_count\": len(historical_prices),\n        \"current_price\": price_now,\n        \"avg_price\": round(sum(historical_prices) / len(historical_prices), 2),\n        \"median_price\": round(float(median(historical_prices)), 2),\n        \"min_price\": round(min(historical_prices), 2),\n        \"max_price\": round(max(historical_prices), 2),\n        \"first_seen_at\": item_snapshots[0].get(\"snapshot_time\"),\n        \"last_seen_at\": latest_item_snapshot.get(\"snapshot_time\"),\n        \"market_avg_price\": market_avg,\n        \"market_median_price\": market_median,\n        \"price_change_amount\": change_amount,\n        \"price_change_percent\": change_percent,\n        \"deal_score\": score,\n        \"deal_label\": _resolve_deal_label(score),\n    }\n\n\ndef build_market_reference(\n    *,\n    keyword: str,\n    item: dict,\n    current_market_items: list[dict],\n    historical_snapshots: list[dict],\n) -> dict:\n    current_market_records = []\n    for market_item in current_market_items:\n        price = parse_price_value(market_item.get(\"当前售价\"))\n        if price is None:\n            continue\n        current_market_records.append({\"price\": price})\n\n    market_snapshot = _summarize_prices(current_market_records)\n    history_summary = _summarize_prices(_dedupe_latest(historical_snapshots, \"item_id\"))\n    item_context = build_item_price_context(\n        historical_snapshots,\n        item_id=str(item.get(\"商品ID\") or \"\"),\n        current_price=parse_price_value(item.get(\"当前售价\")),\n    )\n    return {\n        \"当前搜索样本\": market_snapshot,\n        \"历史价格概览\": history_summary,\n        \"本商品价格位置\": item_context,\n        \"关键词\": keyword,\n    }\n\n\ndef build_price_history_insights(\n    keyword: str,\n    *,\n    window_days: int = DEFAULT_HISTORY_WINDOW_DAYS,\n) -> dict:\n    snapshots = load_price_snapshots(keyword)\n    if not snapshots:\n        return {\n            \"market_summary\": _summarize_prices([]),\n            \"history_summary\": {\"unique_items\": 0, **_summarize_prices([])},\n            \"daily_trend\": [],\n            \"latest_snapshot_at\": None,\n        }\n\n    recent_snapshots = _recent_window_snapshots(snapshots, window_days)\n    latest_run_id = str(snapshots[-1].get(\"run_id\") or \"\")\n    latest_run_snapshots = _dedupe_latest(\n        [record for record in snapshots if str(record.get(\"run_id\") or \"\") == latest_run_id],\n        \"item_id\",\n    )\n    latest_records_by_item = _dedupe_latest(recent_snapshots, \"item_id\")\n\n    return {\n        \"market_summary\": {\n            **_summarize_prices(latest_run_snapshots),\n            \"snapshot_time\": snapshots[-1].get(\"snapshot_time\"),\n        },\n        \"history_summary\": {\n            \"unique_items\": len(latest_records_by_item),\n            **_summarize_prices(latest_records_by_item),\n        },\n        \"daily_trend\": _build_daily_trend(recent_snapshots),\n        \"latest_snapshot_at\": snapshots[-1].get(\"snapshot_time\"),\n    }\n"
  },
  {
    "path": "src/services/process_service.py",
    "content": "\"\"\"\n进程管理服务\n负责管理爬虫进程的启动和停止\n\"\"\"\n\nimport asyncio\nimport contextlib\nimport os\nimport signal\nimport sys\nfrom datetime import datetime\nfrom typing import Awaitable, Callable, Dict, TextIO\n\nfrom src.ai_handler import send_ntfy_notification\nfrom src.config import STATE_FILE\nfrom src.failure_guard import FailureGuard\nfrom src.infrastructure.persistence.sqlite_task_repository import find_task_by_name_sync\nfrom src.utils import build_task_log_path\n\nSTOP_TIMEOUT_SECONDS = 20\nSPIDER_DEBUG_LIMIT_ENV = \"SPIDER_DEBUG_LIMIT\"\nLifecycleHook = Callable[[int], Awaitable[None] | None]\n\n\nclass ProcessService:\n    \"\"\"进程管理服务\"\"\"\n\n    def __init__(self):\n        self.processes: Dict[int, asyncio.subprocess.Process] = {}\n        self.log_paths: Dict[int, str] = {}\n        self.log_handles: Dict[int, TextIO] = {}\n        self.task_names: Dict[int, str] = {}\n        self.exit_watchers: Dict[int, asyncio.Task] = {}\n        self.failure_guard = FailureGuard()\n        self._on_started: LifecycleHook | None = None\n        self._on_stopped: LifecycleHook | None = None\n\n    def set_lifecycle_hooks(\n        self,\n        *,\n        on_started: LifecycleHook | None = None,\n        on_stopped: LifecycleHook | None = None,\n    ) -> None:\n        self._on_started = on_started\n        self._on_stopped = on_stopped\n\n    async def _invoke_hook(self, hook: LifecycleHook | None, task_id: int) -> None:\n        if hook is None:\n            return\n        result = hook(task_id)\n        if asyncio.iscoroutine(result):\n            await result\n\n    def _resolve_cookie_path(self, task_name: str) -> str | None:\n        \"\"\"Best-effort cookie/state path for a task.\"\"\"\n        try:\n            task = find_task_by_name_sync(task_name)\n            if task and isinstance(task.account_state_file, str) and task.account_state_file.strip():\n                return task.account_state_file.strip()\n        except Exception:\n            pass\n\n        return STATE_FILE if os.path.exists(STATE_FILE) else None\n\n    def is_running(self, task_id: int) -> bool:\n        \"\"\"检查任务是否正在运行\"\"\"\n        process = self.processes.get(task_id)\n        return process is not None and process.returncode is None\n\n    async def _drain_finished_process(self, task_id: int) -> None:\n        process = self.processes.get(task_id)\n        if process is None or process.returncode is None:\n            return\n\n        watcher = self.exit_watchers.get(task_id)\n        if watcher is not None:\n            await asyncio.shield(watcher)\n            return\n\n        self._cleanup_runtime(task_id, process)\n        await self._invoke_hook(self._on_stopped, task_id)\n\n    def _open_log_file(self, task_id: int, task_name: str) -> tuple[str, TextIO]:\n        os.makedirs(\"logs\", exist_ok=True)\n        log_file_path = build_task_log_path(task_id, task_name)\n        log_file_handle = open(log_file_path, \"a\", encoding=\"utf-8\")\n        return log_file_path, log_file_handle\n\n    def _build_spawn_command(self, task_name: str) -> list[str]:\n        command = [\n            sys.executable,\n            \"-u\",\n            \"spider_v2.py\",\n            \"--task-name\",\n            task_name,\n        ]\n        debug_limit = str(os.getenv(SPIDER_DEBUG_LIMIT_ENV, \"\")).strip()\n        if debug_limit.isdigit() and int(debug_limit) > 0:\n            command.extend([\"--debug-limit\", debug_limit])\n        return command\n\n    async def _spawn_process(\n        self,\n        task_name: str,\n        log_file_handle: TextIO,\n    ) -> asyncio.subprocess.Process:\n        preexec_fn = os.setsid if sys.platform != \"win32\" else None\n        child_env = os.environ.copy()\n        child_env[\"PYTHONIOENCODING\"] = \"utf-8\"\n        child_env[\"PYTHONUTF8\"] = \"1\"\n        return await asyncio.create_subprocess_exec(\n            *self._build_spawn_command(task_name),\n            stdout=log_file_handle,\n            stderr=log_file_handle,\n            preexec_fn=preexec_fn,\n            env=child_env,\n        )\n\n    def _register_runtime(\n        self,\n        task_id: int,\n        task_name: str,\n        process: asyncio.subprocess.Process,\n        log_file_path: str,\n        log_file_handle: TextIO,\n    ) -> None:\n        self.processes[task_id] = process\n        self.log_paths[task_id] = log_file_path\n        self.log_handles[task_id] = log_file_handle\n        self.task_names[task_id] = task_name\n        self.exit_watchers[task_id] = asyncio.create_task(self._watch_process_exit(process))\n\n    async def start_task(self, task_id: int, task_name: str) -> bool:\n        \"\"\"启动任务进程\"\"\"\n        await self._drain_finished_process(task_id)\n        if self.is_running(task_id):\n            print(f\"任务 '{task_name}' (ID: {task_id}) 已在运行中\")\n            return False\n\n        decision = self.failure_guard.should_skip_start(\n            task_name,\n            cookie_path=self._resolve_cookie_path(task_name),\n        )\n        if decision.skip:\n            await self._notify_skip(task_name, decision)\n            return False\n\n        log_file_path = \"\"\n        log_file_handle = None\n        try:\n            log_file_path, log_file_handle = self._open_log_file(task_id, task_name)\n            process = await self._spawn_process(task_name, log_file_handle)\n        except Exception as exc:\n            self._close_log_handle(log_file_handle)\n            print(f\"启动任务 '{task_name}' 失败: {exc}\")\n            return False\n\n        self._register_runtime(task_id, task_name, process, log_file_path, log_file_handle)\n        print(f\"启动任务 '{task_name}' (PID: {process.pid})\")\n        await self._invoke_hook(self._on_started, task_id)\n        return True\n\n    async def _notify_skip(self, task_name: str, decision) -> None:\n        print(\n            f\"[FailureGuard] 跳过启动任务 '{task_name}'，已暂停重试 \"\n            f\"(连续失败 {decision.consecutive_failures}/{self.failure_guard.threshold})\"\n        )\n        if not decision.should_notify:\n            return\n        try:\n            await send_ntfy_notification(\n                {\n                    \"商品标题\": f\"[任务暂停] {task_name}\",\n                    \"当前售价\": \"N/A\",\n                    \"商品链接\": \"#\",\n                },\n                \"任务处于暂停状态，将跳过执行。\\n\"\n                f\"原因: {decision.reason}\\n\"\n                f\"连续失败: {decision.consecutive_failures}/{self.failure_guard.threshold}\\n\"\n                f\"暂停到: {decision.paused_until.strftime('%Y-%m-%d %H:%M:%S') if decision.paused_until else 'N/A'}\\n\"\n                \"修复方法: 更新登录态/cookies文件后会自动恢复。\",\n            )\n        except Exception as exc:\n            print(f\"发送任务暂停通知失败: {exc}\")\n\n    async def _watch_process_exit(self, process: asyncio.subprocess.Process) -> None:\n        await process.wait()\n        task_id = self._find_task_id_by_process(process)\n        if task_id is None:\n            return\n        self._cleanup_runtime(task_id, process)\n        await self._invoke_hook(self._on_stopped, task_id)\n\n    def _find_task_id_by_process(self, process: asyncio.subprocess.Process) -> int | None:\n        for task_id, current_process in self.processes.items():\n            if current_process is process:\n                return task_id\n        return None\n\n    def _cleanup_runtime(\n        self,\n        task_id: int,\n        process: asyncio.subprocess.Process,\n    ) -> None:\n        if self.processes.get(task_id) is not process:\n            return\n        self.processes.pop(task_id, None)\n        self.log_paths.pop(task_id, None)\n        self.task_names.pop(task_id, None)\n        self._close_log_handle(self.log_handles.pop(task_id, None))\n        self.exit_watchers.pop(task_id, None)\n\n    def _close_log_handle(self, log_handle: TextIO | None) -> None:\n        if log_handle is None:\n            return\n        with contextlib.suppress(Exception):\n            log_handle.close()\n\n    def _append_stop_marker(self, log_path: str | None) -> None:\n        if not log_path:\n            return\n        try:\n            timestamp = datetime.now().strftime(\" %Y-%m-%d %H:%M:%S\")\n            with open(log_path, \"a\", encoding=\"utf-8\") as log_file:\n                log_file.write(f\"[{timestamp}] !!! 任务已被终止 !!!\\n\")\n        except Exception as exc:\n            print(f\"写入任务终止标记失败: {exc}\")\n\n    async def stop_task(self, task_id: int) -> bool:\n        \"\"\"停止任务进程\"\"\"\n        await self._drain_finished_process(task_id)\n        process = self.processes.get(task_id)\n        if process is None:\n            print(f\"任务 ID {task_id} 没有正在运行的进程\")\n            return False\n        if process.returncode is not None:\n            await self._await_exit_watcher(task_id)\n            print(f\"任务进程 {process.pid} (ID: {task_id}) 已退出，略过停止\")\n            return False\n\n        try:\n            await self._terminate_process(process, task_id)\n            self._append_stop_marker(self.log_paths.get(task_id))\n            await self._await_exit_watcher(task_id)\n            print(f\"任务进程 {process.pid} (ID: {task_id}) 已终止\")\n            return True\n        except ProcessLookupError:\n            print(f\"进程 (ID: {task_id}) 已不存在\")\n            return False\n        except Exception as exc:\n            print(f\"停止任务进程 (ID: {task_id}) 时出错: {exc}\")\n            return False\n\n    async def _terminate_process(\n        self,\n        process: asyncio.subprocess.Process,\n        task_id: int,\n    ) -> None:\n        if sys.platform != \"win32\":\n            os.killpg(os.getpgid(process.pid), signal.SIGTERM)\n        else:\n            process.terminate()\n\n        try:\n            await asyncio.wait_for(process.wait(), timeout=STOP_TIMEOUT_SECONDS)\n            return\n        except asyncio.TimeoutError:\n            print(\n                f\"任务进程 {process.pid} (ID: {task_id}) 未在 \"\n                f\"{STOP_TIMEOUT_SECONDS} 秒内退出，准备强制终止...\"\n            )\n\n        if sys.platform != \"win32\":\n            with contextlib.suppress(ProcessLookupError):\n                os.killpg(os.getpgid(process.pid), signal.SIGKILL)\n        else:\n            process.kill()\n        await process.wait()\n\n    async def _await_exit_watcher(self, task_id: int) -> None:\n        watcher = self.exit_watchers.get(task_id)\n        if watcher is None:\n            return\n        await asyncio.shield(watcher)\n\n    def reindex_after_delete(self, deleted_task_id: int) -> None:\n        \"\"\"删除任务后同步重排运行时索引，避免任务下标漂移。\"\"\"\n        self.processes = self._reindex_mapping(self.processes, deleted_task_id)\n        self.log_paths = self._reindex_mapping(self.log_paths, deleted_task_id)\n        self.log_handles = self._reindex_mapping(self.log_handles, deleted_task_id)\n        self.task_names = self._reindex_mapping(self.task_names, deleted_task_id)\n        self.exit_watchers = self._reindex_mapping(self.exit_watchers, deleted_task_id)\n\n    def _reindex_mapping(self, mapping: Dict[int, object], deleted_task_id: int) -> Dict[int, object]:\n        reindexed: Dict[int, object] = {}\n        for task_id, value in mapping.items():\n            if task_id == deleted_task_id:\n                continue\n            next_task_id = task_id - 1 if task_id > deleted_task_id else task_id\n            reindexed[next_task_id] = value\n        return reindexed\n\n    async def stop_all(self) -> None:\n        \"\"\"停止所有任务进程\"\"\"\n        task_ids = list(self.processes.keys())\n        for task_id in task_ids:\n            await self.stop_task(task_id)\n"
  },
  {
    "path": "src/services/result_export_service.py",
    "content": "\"\"\"\n结果导出服务\n\"\"\"\nimport csv\nfrom io import StringIO\n\n\nEXPORT_HEADERS = [\n    \"任务名称\",\n    \"搜索关键字\",\n    \"商品ID\",\n    \"商品标题\",\n    \"当前售价\",\n    \"发布时间\",\n    \"卖家昵称\",\n    \"AI是否推荐\",\n    \"分析来源\",\n    \"原因\",\n    \"价格观察次数\",\n    \"价格最低值\",\n    \"价格最高值\",\n    \"市场均价\",\n    \"性价比分数\",\n    \"性价比标签\",\n    \"商品链接\",\n]\n\n\ndef build_results_csv(records: list[dict]) -> str:\n    buffer = StringIO()\n    writer = csv.DictWriter(buffer, fieldnames=EXPORT_HEADERS)\n    writer.writeheader()\n\n    for record in records:\n        item = record.get(\"商品信息\", {}) or {}\n        seller = record.get(\"卖家信息\", {}) or {}\n        ai_analysis = record.get(\"ai_analysis\", {}) or {}\n        price_insight = record.get(\"price_insight\", {}) or {}\n        writer.writerow(\n            {\n                \"任务名称\": record.get(\"任务名称\", \"\"),\n                \"搜索关键字\": record.get(\"搜索关键字\", \"\"),\n                \"商品ID\": item.get(\"商品ID\", \"\"),\n                \"商品标题\": item.get(\"商品标题\", \"\"),\n                \"当前售价\": item.get(\"当前售价\", \"\"),\n                \"发布时间\": item.get(\"发布时间\", \"\"),\n                \"卖家昵称\": seller.get(\"卖家昵称\") or item.get(\"卖家昵称\", \"\"),\n                \"AI是否推荐\": \"是\" if ai_analysis.get(\"is_recommended\") else \"否\",\n                \"分析来源\": ai_analysis.get(\"analysis_source\", \"\"),\n                \"原因\": ai_analysis.get(\"reason\", \"\"),\n                \"价格观察次数\": price_insight.get(\"observation_count\", \"\"),\n                \"价格最低值\": price_insight.get(\"min_price\", \"\"),\n                \"价格最高值\": price_insight.get(\"max_price\", \"\"),\n                \"市场均价\": price_insight.get(\"market_avg_price\", \"\"),\n                \"性价比分数\": ai_analysis.get(\"value_score\", price_insight.get(\"deal_score\", \"\")),\n                \"性价比标签\": ai_analysis.get(\"value_summary\", price_insight.get(\"deal_label\", \"\")),\n                \"商品链接\": item.get(\"商品链接\", \"\"),\n            }\n        )\n\n    return buffer.getvalue()\n"
  },
  {
    "path": "src/services/result_file_service.py",
    "content": "\"\"\"\n结果记录富化与文件名校验服务\n\"\"\"\n\nfrom src.infrastructure.persistence.storage_names import normalize_keyword_from_filename\nfrom src.services.price_history_service import (\n    build_item_price_context,\n    load_price_snapshots,\n    parse_price_value,\n)\n\n\ndef validate_result_filename(filename: str) -> None:\n    if not filename.endswith(\".jsonl\") or \"/\" in filename or \"..\" in filename:\n        raise ValueError(\"无效的文件名\")\n\n\ndef enrich_records_with_price_insight(records: list[dict], filename: str) -> list[dict]:\n    snapshots = load_price_snapshots(normalize_keyword_from_filename(filename))\n    if not snapshots:\n        return records\n\n    enriched = []\n    for record in records:\n        info = record.get(\"商品信息\", {}) or {}\n        clone = dict(record)\n        clone[\"price_insight\"] = build_item_price_context(\n            snapshots,\n            item_id=str(info.get(\"商品ID\") or \"\"),\n            current_price=parse_price_value(info.get(\"当前售价\")),\n        )\n        enriched.append(clone)\n    return enriched\n"
  },
  {
    "path": "src/services/result_storage_service.py",
    "content": "\"\"\"\n结果数据的 SQLite 读写服务。\n\"\"\"\nfrom __future__ import annotations\n\nimport asyncio\nimport hashlib\nimport json\n\nfrom src.infrastructure.persistence.sqlite_bootstrap import bootstrap_sqlite_storage\nfrom src.infrastructure.persistence.sqlite_connection import sqlite_connection\nfrom src.infrastructure.persistence.storage_names import build_result_filename\nfrom src.services.price_history_service import parse_price_value\n\n\nSORT_COLUMN_MAP = {\n    \"crawl_time\": \"crawl_time\",\n    \"publish_time\": \"COALESCE(publish_time, '')\",\n    \"price\": \"COALESCE(price, 0)\",\n    \"keyword_hit_count\": \"keyword_hit_count\",\n}\n\n\ndef _get_link_unique_key(link: str) -> str:\n    return link.split(\"&\", 1)[0]\n\n\ndef _fallback_unique_key(record: dict, item: dict) -> str:\n    item_id = str(item.get(\"商品ID\") or \"\").strip()\n    if item_id:\n        return f\"item:{item_id}\"\n    digest = hashlib.sha1(\n        json.dumps(record, ensure_ascii=False, sort_keys=True).encode(\"utf-8\")\n    ).hexdigest()\n    return f\"hash:{digest}\"\n\n\ndef _parse_raw_record(raw_json: str) -> dict:\n    return json.loads(raw_json)\n\n\ndef _build_query_conditions(\n    *,\n    filename: str,\n    ai_recommended_only: bool,\n    keyword_recommended_only: bool,\n) -> tuple[str, list]:\n    conditions = [\"result_filename = ?\"]\n    params: list = [filename]\n    if ai_recommended_only:\n        conditions.append(\"is_recommended = 1\")\n        conditions.append(\"analysis_source = ?\")\n        params.append(\"ai\")\n    if keyword_recommended_only:\n        conditions.append(\"is_recommended = 1\")\n        conditions.append(\"analysis_source = ?\")\n        params.append(\"keyword\")\n    return \" AND \".join(conditions), params\n\n\ndef _sort_expression(sort_by: str, sort_order: str) -> str:\n    column = SORT_COLUMN_MAP.get(sort_by, SORT_COLUMN_MAP[\"crawl_time\"])\n    direction = \"ASC\" if sort_order == \"asc\" else \"DESC\"\n    return f\"{column} {direction}, id {direction}\"\n\n\nasync def save_result_record(record: dict, keyword: str) -> bool:\n    return await asyncio.to_thread(_save_result_record_sync, record, keyword)\n\n\ndef _save_result_record_sync(record: dict, keyword: str) -> bool:\n    bootstrap_sqlite_storage()\n    item = record.get(\"商品信息\", {}) or {}\n    analysis = record.get(\"ai_analysis\", {}) or {}\n    link = str(item.get(\"商品链接\") or \"\")\n    link_unique_key = _get_link_unique_key(link) if link else _fallback_unique_key(record, item)\n    keyword_hit_count = analysis.get(\"keyword_hit_count\", 0)\n    try:\n        keyword_hit_count = int(keyword_hit_count)\n    except (TypeError, ValueError):\n        keyword_hit_count = 0\n\n    with sqlite_connection() as conn:\n        conn.execute(\n            \"\"\"\n            INSERT OR IGNORE INTO result_items (\n                result_filename, keyword, task_name, crawl_time, publish_time, price,\n                price_display, item_id, title, link, link_unique_key, seller_nickname,\n                is_recommended, analysis_source, keyword_hit_count, raw_json\n            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n            \"\"\",\n            (\n                build_result_filename(keyword),\n                record.get(\"搜索关键字\", keyword),\n                record.get(\"任务名称\", \"\"),\n                record.get(\"爬取时间\", \"\"),\n                item.get(\"发布时间\"),\n                parse_price_value(item.get(\"当前售价\")),\n                item.get(\"当前售价\"),\n                item.get(\"商品ID\"),\n                item.get(\"商品标题\"),\n                link,\n                link_unique_key,\n                (record.get(\"卖家信息\", {}) or {}).get(\"卖家昵称\") or item.get(\"卖家昵称\"),\n                1 if analysis.get(\"is_recommended\") else 0,\n                analysis.get(\"analysis_source\"),\n                keyword_hit_count,\n                json.dumps(record, ensure_ascii=False),\n            ),\n        )\n        conn.commit()\n    return True\n\n\ndef load_processed_link_keys(keyword: str) -> set[str]:\n    bootstrap_sqlite_storage()\n    filename = build_result_filename(keyword)\n    with sqlite_connection() as conn:\n        rows = conn.execute(\n            \"SELECT link_unique_key FROM result_items WHERE result_filename = ?\",\n            (filename,),\n        ).fetchall()\n    return {str(row[\"link_unique_key\"]) for row in rows if row[\"link_unique_key\"]}\n\n\nasync def list_result_filenames() -> list[str]:\n    return await asyncio.to_thread(_list_result_filenames_sync)\n\n\ndef _list_result_filenames_sync() -> list[str]:\n    bootstrap_sqlite_storage()\n    with sqlite_connection() as conn:\n        rows = conn.execute(\n            \"\"\"\n            SELECT result_filename, MAX(crawl_time) AS latest_crawl_time\n            FROM result_items\n            GROUP BY result_filename\n            ORDER BY latest_crawl_time DESC, result_filename DESC\n            \"\"\"\n        ).fetchall()\n    return [str(row[\"result_filename\"]) for row in rows]\n\n\nasync def result_file_exists(filename: str) -> bool:\n    return await asyncio.to_thread(_result_file_exists_sync, filename)\n\n\ndef _result_file_exists_sync(filename: str) -> bool:\n    bootstrap_sqlite_storage()\n    with sqlite_connection() as conn:\n        row = conn.execute(\n            \"SELECT 1 FROM result_items WHERE result_filename = ? LIMIT 1\",\n            (filename,),\n        ).fetchone()\n    return row is not None\n\n\nasync def delete_result_file_records(filename: str) -> int:\n    return await asyncio.to_thread(_delete_result_file_records_sync, filename)\n\n\ndef _delete_result_file_records_sync(filename: str) -> int:\n    bootstrap_sqlite_storage()\n    with sqlite_connection() as conn:\n        cursor = conn.execute(\n            \"DELETE FROM result_items WHERE result_filename = ?\",\n            (filename,),\n        )\n        conn.commit()\n    return int(cursor.rowcount or 0)\n\n\nasync def query_result_records(\n    filename: str,\n    *,\n    ai_recommended_only: bool,\n    keyword_recommended_only: bool,\n    sort_by: str,\n    sort_order: str,\n    page: int,\n    limit: int,\n) -> tuple[int, list[dict]]:\n    return await asyncio.to_thread(\n        _query_result_records_sync,\n        filename,\n        ai_recommended_only,\n        keyword_recommended_only,\n        sort_by,\n        sort_order,\n        page,\n        limit,\n    )\n\n\ndef _query_result_records_sync(\n    filename: str,\n    ai_recommended_only: bool,\n    keyword_recommended_only: bool,\n    sort_by: str,\n    sort_order: str,\n    page: int,\n    limit: int,\n) -> tuple[int, list[dict]]:\n    bootstrap_sqlite_storage()\n    where_clause, params = _build_query_conditions(\n        filename=filename,\n        ai_recommended_only=ai_recommended_only,\n        keyword_recommended_only=keyword_recommended_only,\n    )\n    offset = max(page - 1, 0) * limit\n    order_clause = _sort_expression(sort_by, sort_order)\n    with sqlite_connection() as conn:\n        total_row = conn.execute(\n            f\"SELECT COUNT(1) AS total FROM result_items WHERE {where_clause}\",\n            tuple(params),\n        ).fetchone()\n        rows = conn.execute(\n            f\"\"\"\n            SELECT raw_json\n            FROM result_items\n            WHERE {where_clause}\n            ORDER BY {order_clause}\n            LIMIT ? OFFSET ?\n            \"\"\",\n            tuple(params + [limit, offset]),\n        ).fetchall()\n    total = int(total_row[\"total\"]) if total_row else 0\n    return total, [_parse_raw_record(str(row[\"raw_json\"])) for row in rows]\n\n\nasync def load_all_result_records(\n    filename: str,\n    *,\n    ai_recommended_only: bool,\n    keyword_recommended_only: bool,\n    sort_by: str,\n    sort_order: str,\n) -> list[dict]:\n    return await asyncio.to_thread(\n        _load_all_result_records_sync,\n        filename,\n        ai_recommended_only,\n        keyword_recommended_only,\n        sort_by,\n        sort_order,\n    )\n\n\ndef _load_all_result_records_sync(\n    filename: str,\n    ai_recommended_only: bool,\n    keyword_recommended_only: bool,\n    sort_by: str,\n    sort_order: str,\n) -> list[dict]:\n    bootstrap_sqlite_storage()\n    where_clause, params = _build_query_conditions(\n        filename=filename,\n        ai_recommended_only=ai_recommended_only,\n        keyword_recommended_only=keyword_recommended_only,\n    )\n    order_clause = _sort_expression(sort_by, sort_order)\n    with sqlite_connection() as conn:\n        rows = conn.execute(\n            f\"\"\"\n            SELECT raw_json\n            FROM result_items\n            WHERE {where_clause}\n            ORDER BY {order_clause}\n            \"\"\",\n            tuple(params),\n        ).fetchall()\n    return [_parse_raw_record(str(row[\"raw_json\"])) for row in rows]\n\n\nasync def build_result_ndjson(filename: str) -> str:\n    records = await load_all_result_records(\n        filename,\n        ai_recommended_only=False,\n        keyword_recommended_only=False,\n        sort_by=\"crawl_time\",\n        sort_order=\"asc\",\n    )\n    return \"\\n\".join(json.dumps(record, ensure_ascii=False) for record in records)\n\n\nasync def load_result_summary(filename: str) -> dict | None:\n    return await asyncio.to_thread(_load_result_summary_sync, filename)\n\n\ndef _load_result_summary_sync(filename: str) -> dict | None:\n    bootstrap_sqlite_storage()\n    with sqlite_connection() as conn:\n        aggregate_row = conn.execute(\n            \"\"\"\n            SELECT\n                COUNT(1) AS total_items,\n                SUM(CASE WHEN is_recommended = 1 THEN 1 ELSE 0 END) AS recommended_items,\n                SUM(CASE WHEN is_recommended = 1 AND analysis_source = 'ai' THEN 1 ELSE 0 END) AS ai_recommended_items,\n                SUM(CASE WHEN is_recommended = 1 AND analysis_source = 'keyword' THEN 1 ELSE 0 END) AS keyword_recommended_items,\n                MAX(crawl_time) AS latest_crawl_time\n            FROM result_items\n            WHERE result_filename = ?\n            \"\"\",\n            (filename,),\n        ).fetchone()\n        if aggregate_row is None or int(aggregate_row[\"total_items\"] or 0) == 0:\n            return None\n\n        latest_record = conn.execute(\n            \"\"\"\n            SELECT raw_json FROM result_items\n            WHERE result_filename = ?\n            ORDER BY crawl_time DESC, id DESC\n            LIMIT 1\n            \"\"\",\n            (filename,),\n        ).fetchone()\n        latest_recommendation = conn.execute(\n            \"\"\"\n            SELECT raw_json FROM result_items\n            WHERE result_filename = ? AND is_recommended = 1\n            ORDER BY crawl_time DESC, id DESC\n            LIMIT 1\n            \"\"\",\n            (filename,),\n        ).fetchone()\n    return {\n        \"total_items\": int(aggregate_row[\"total_items\"] or 0),\n        \"recommended_items\": int(aggregate_row[\"recommended_items\"] or 0),\n        \"ai_recommended_items\": int(aggregate_row[\"ai_recommended_items\"] or 0),\n        \"keyword_recommended_items\": int(aggregate_row[\"keyword_recommended_items\"] or 0),\n        \"latest_crawl_time\": aggregate_row[\"latest_crawl_time\"],\n        \"latest_record\": (\n            _parse_raw_record(str(latest_record[\"raw_json\"])) if latest_record else None\n        ),\n        \"latest_recommendation\": (\n            _parse_raw_record(str(latest_recommendation[\"raw_json\"]))\n            if latest_recommendation\n            else None\n        ),\n    }\n"
  },
  {
    "path": "src/services/scheduler_service.py",
    "content": "\"\"\"\n调度服务\n负责管理定时任务的调度\n\"\"\"\nfrom datetime import datetime\nfrom apscheduler.schedulers.asyncio import AsyncIOScheduler\nfrom typing import List\n\nfrom src.core.cron_utils import build_cron_trigger\nfrom src.domain.models.task import Task\nfrom src.services.process_service import ProcessService\n\n\nclass SchedulerService:\n    \"\"\"调度服务\"\"\"\n\n    def __init__(self, process_service: ProcessService):\n        self.scheduler = AsyncIOScheduler(timezone=\"Asia/Shanghai\")\n        self.process_service = process_service\n\n    def start(self):\n        \"\"\"启动调度器\"\"\"\n        if not self.scheduler.running:\n            self.scheduler.start()\n            print(\"调度器已启动\")\n\n    def stop(self):\n        \"\"\"停止调度器\"\"\"\n        if self.scheduler.running:\n            self.scheduler.shutdown()\n            print(\"调度器已停止\")\n\n    def get_next_run_time(self, task_id: int):\n        job = self.scheduler.get_job(f\"task_{task_id}\")\n        if job is None:\n            return None\n\n        next_run_time = getattr(job, \"next_run_time\", None)\n        if next_run_time is not None:\n            return next_run_time\n\n        trigger = getattr(job, \"trigger\", None)\n        if trigger is None or not hasattr(trigger, \"get_next_fire_time\"):\n            return None\n\n        try:\n            now = datetime.now(self.scheduler.timezone)\n            return trigger.get_next_fire_time(None, now)\n        except Exception:\n            return None\n\n    async def reload_jobs(self, tasks: List[Task]):\n        \"\"\"重新加载所有定时任务\"\"\"\n        print(\"正在重新加载定时任务...\")\n        self.scheduler.remove_all_jobs()\n\n        for task in tasks:\n            if task.enabled and task.cron:\n                try:\n                    trigger = build_cron_trigger(\n                        task.cron,\n                        timezone=self.scheduler.timezone,\n                    )\n                    self.scheduler.add_job(\n                        self._run_task,\n                        trigger=trigger,\n                        args=[task.id, task.task_name],\n                        id=f\"task_{task.id}\",\n                        name=f\"Scheduled: {task.task_name}\",\n                        replace_existing=True\n                    )\n                    print(f\"  -> 已为任务 '{task.task_name}' 添加定时规则: '{task.cron}'\")\n                except ValueError as e:\n                    print(f\"  -> [警告] 任务 '{task.task_name}' 的 Cron 表达式无效: {e}\")\n\n        print(\"定时任务加载完成\")\n\n    async def _run_task(self, task_id: int, task_name: str):\n        \"\"\"执行定时任务\"\"\"\n        print(f\"定时任务触发: 正在为任务 '{task_name}' 启动爬虫...\")\n        await self.process_service.start_task(task_id, task_name)\n"
  },
  {
    "path": "src/services/search_pagination.py",
    "content": "import asyncio\nfrom dataclasses import dataclass\nfrom typing import Any, Awaitable, Callable, Optional\n\nfrom playwright.async_api import TimeoutError as PlaywrightTimeoutError\n\nfrom src.utils import log_time, random_sleep\n\nNEXT_PAGE_SELECTOR = (\n    \"button[class*='search-pagination-arrow-container']\"\n    \":has([class*='search-pagination-arrow-right'])\"\n    \":not([disabled])\"\n)\nSEARCH_RESULTS_API_FRAGMENT = \"/h5/mtop.taobao.idlemtopsearch.pc.search/1.0/\"\nPAGE_REQUEST_TIMEOUT_MS = 20_000\nPAGE_CLICK_TIMEOUT_MS = 10_000\nPAGE_RETRY_DELAY_SECONDS = 5\nPAGE_RETRY_COUNT = 2\nPAGE_CLICK_SLEEP_MIN_SECONDS = 2\nPAGE_CLICK_SLEEP_MAX_SECONDS = 5\n\n\n@dataclass(frozen=True)\nclass PageAdvanceResult:\n    advanced: bool\n    response: Optional[Any] = None\n    stop_reason: Optional[str] = None\n\n\ndef is_search_results_response(\n    response: Any,\n    api_url_fragment: str = SEARCH_RESULTS_API_FRAGMENT,\n) -> bool:\n    request = getattr(response, \"request\", None)\n    request_method = getattr(request, \"method\", None)\n    response_url = getattr(response, \"url\", \"\")\n    return api_url_fragment in response_url and request_method == \"POST\"\n\n\nasync def advance_search_page(\n    *,\n    page: Any,\n    page_num: int,\n    logger: Callable[[str], None] = log_time,\n    wait_after_click: Callable[[float, float], Awaitable[None]] = random_sleep,\n    retry_sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,\n    max_retries: int = PAGE_RETRY_COUNT,\n) -> PageAdvanceResult:\n    next_button = page.locator(NEXT_PAGE_SELECTOR).first\n    if not await next_button.count():\n        logger(\"已到达最后一页，未找到可用的'下一页'按钮，停止翻页。\")\n        return PageAdvanceResult(advanced=False, stop_reason=\"no_next_button\")\n\n    for retry_index in range(max_retries):\n        try:\n            await next_button.scroll_into_view_if_needed()\n            async with page.expect_response(\n                is_search_results_response,\n                timeout=PAGE_REQUEST_TIMEOUT_MS,\n            ) as response_info:\n                try:\n                    await next_button.click(timeout=PAGE_CLICK_TIMEOUT_MS)\n                except PlaywrightTimeoutError:\n                    logger(f\"第 {page_num} 页下一页按钮点击超时，停止翻页。\")\n                    return PageAdvanceResult(\n                        advanced=False,\n                        stop_reason=\"click_timeout\",\n                    )\n            await wait_after_click(\n                PAGE_CLICK_SLEEP_MIN_SECONDS,\n                PAGE_CLICK_SLEEP_MAX_SECONDS,\n            )\n            return PageAdvanceResult(\n                advanced=True,\n                response=await response_info.value,\n            )\n        except PlaywrightTimeoutError:\n            if retry_index < max_retries - 1:\n                logger(\n                    f\"等待第 {page_num} 页搜索响应超时，\"\n                    f\"{PAGE_RETRY_DELAY_SECONDS}秒后重试...\"\n                )\n                await retry_sleep(PAGE_RETRY_DELAY_SECONDS)\n                continue\n\n            logger(f\"等待第 {page_num} 页搜索响应超时 {max_retries} 次，停止翻页。\")\n            return PageAdvanceResult(advanced=False, stop_reason=\"response_timeout\")\n\n    return PageAdvanceResult(advanced=False, stop_reason=\"unknown\")\n"
  },
  {
    "path": "src/services/seller_profile_cache.py",
    "content": "\"\"\"\n卖家资料缓存服务\n\"\"\"\nimport asyncio\nimport copy\nimport time\nfrom dataclasses import dataclass\nfrom typing import Awaitable, Callable, Optional\n\n\nSellerProfileLoader = Callable[[str], Awaitable[dict]]\n\n\n@dataclass(frozen=True)\nclass _CacheEntry:\n    value: dict\n    expires_at: float\n\n\nclass SellerProfileCache:\n    \"\"\"带 TTL 和并发合并的卖家资料缓存。\"\"\"\n\n    def __init__(\n        self,\n        ttl_seconds: int = 1800,\n        time_source: Optional[Callable[[], float]] = None,\n    ) -> None:\n        self._ttl_seconds = max(0, ttl_seconds)\n        self._time_source = time_source or time.monotonic\n        self._entries: dict[str, _CacheEntry] = {}\n        self._inflight: dict[str, asyncio.Task] = {}\n        self._lock = asyncio.Lock()\n\n    def _now(self) -> float:\n        return float(self._time_source())\n\n    def _clone(self, value: dict) -> dict:\n        return copy.deepcopy(value)\n\n    def _get_entry_value(self, user_id: str) -> Optional[dict]:\n        entry = self._entries.get(user_id)\n        if entry is None:\n            return None\n        if entry.expires_at < self._now():\n            self._entries.pop(user_id, None)\n            return None\n        return self._clone(entry.value)\n\n    async def get_or_load(self, user_id: str, loader: SellerProfileLoader) -> dict:\n        async with self._lock:\n            cached_value = self._get_entry_value(user_id)\n            if cached_value is not None:\n                return cached_value\n            task = self._inflight.get(user_id)\n            if task is None:\n                task = asyncio.create_task(self._load_and_store(user_id, loader))\n                self._inflight[user_id] = task\n        return self._clone(await task)\n\n    async def _load_and_store(self, user_id: str, loader: SellerProfileLoader) -> dict:\n        try:\n            value = self._clone(await loader(user_id))\n            expires_at = self._now() + self._ttl_seconds\n            async with self._lock:\n                self._entries[user_id] = _CacheEntry(value=value, expires_at=expires_at)\n            return value\n        finally:\n            async with self._lock:\n                self._inflight.pop(user_id, None)\n"
  },
  {
    "path": "src/services/task_generation_runner.py",
    "content": "\"\"\"\n任务生成作业执行器\n\"\"\"\nimport os\n\nimport aiofiles\n\nfrom src.domain.models.task import TaskCreate, TaskGenerateRequest\nfrom src.prompt_utils import generate_criteria\nfrom src.services.scheduler_service import SchedulerService\nfrom src.services.task_generation_service import TaskGenerationService\nfrom src.services.task_service import TaskService\n\ndef build_criteria_filename(keyword: str) -> str:\n    safe_keyword = \"\".join(\n        char for char in keyword.lower().replace(\" \", \"_\")\n        if char.isalnum() or char in \"_-\"\n    ).rstrip()\n    return f\"prompts/{safe_keyword}_criteria.txt\"\n\n\ndef build_task_create(req: TaskGenerateRequest, criteria_file: str) -> TaskCreate:\n    return TaskCreate(\n        task_name=req.task_name,\n        enabled=True,\n        keyword=req.keyword,\n        description=req.description or \"\",\n        analyze_images=req.analyze_images,\n        max_pages=req.max_pages,\n        personal_only=req.personal_only,\n        min_price=req.min_price,\n        max_price=req.max_price,\n        cron=req.cron,\n        ai_prompt_base_file=\"prompts/base_prompt.txt\",\n        ai_prompt_criteria_file=criteria_file,\n        account_state_file=req.account_state_file,\n        account_strategy=req.account_strategy,\n        free_shipping=req.free_shipping,\n        new_publish_option=req.new_publish_option,\n        region=req.region,\n        decision_mode=req.decision_mode or \"ai\",\n        keyword_rules=req.keyword_rules,\n    )\n\n\nasync def save_generated_criteria(output_filename: str, generated_criteria: str) -> None:\n    if not generated_criteria or not generated_criteria.strip():\n        raise RuntimeError(\"AI 未能生成分析标准，返回内容为空。\")\n\n    os.makedirs(\"prompts\", exist_ok=True)\n    async with aiofiles.open(output_filename, \"w\", encoding=\"utf-8\") as file:\n        await file.write(generated_criteria)\n\n\nasync def reload_scheduler(\n    task_service: TaskService,\n    scheduler_service: SchedulerService,\n) -> None:\n    tasks = await task_service.get_all_tasks()\n    await scheduler_service.reload_jobs(tasks)\n\n\nasync def advance_job(\n    generation_service: TaskGenerationService,\n    job_id: str,\n    step_key: str,\n    message: str,\n) -> None:\n    await generation_service.advance(job_id, step_key, message)\n\n\nasync def run_ai_generation_job(\n    *,\n    job_id: str,\n    req: TaskGenerateRequest,\n    task_service: TaskService,\n    scheduler_service: SchedulerService,\n    generation_service: TaskGenerationService,\n) -> None:\n    output_filename = build_criteria_filename(req.keyword)\n    try:\n        await advance_job(\n            generation_service,\n            job_id,\n            \"prepare\",\n            \"已接收请求，开始准备分析标准。\",\n        )\n\n        async def report_progress(step_key: str, message: str) -> None:\n            await advance_job(generation_service, job_id, step_key, message)\n\n        generated_criteria = await generate_criteria(\n            user_description=req.description or \"\",\n            reference_file_path=\"prompts/macbook_criteria.txt\",\n            progress_callback=report_progress,\n        )\n\n        await advance_job(\n            generation_service,\n            job_id,\n            \"persist\",\n            f\"正在保存分析标准到 {output_filename}。\",\n        )\n        await save_generated_criteria(output_filename, generated_criteria)\n\n        await advance_job(\n            generation_service,\n            job_id,\n            \"task\",\n            \"分析标准已生成，正在创建任务记录。\",\n        )\n        task = await task_service.create_task(build_task_create(req, output_filename))\n        await reload_scheduler(task_service, scheduler_service)\n        await generation_service.complete(job_id, task, f\"任务“{req.task_name}”创建完成。\")\n    except Exception as exc:\n        if os.path.exists(output_filename):\n            os.remove(output_filename)\n        await generation_service.fail(job_id, f\"AI 任务生成失败: {exc}\")\n"
  },
  {
    "path": "src/services/task_generation_service.py",
    "content": "\"\"\"\n任务生成作业服务\n\"\"\"\nimport asyncio\nfrom copy import deepcopy\nimport threading\nfrom typing import Awaitable, Dict, Iterable, Optional\nfrom uuid import uuid4\n\nfrom src.domain.models.task import Task\nfrom src.domain.models.task_generation import TaskGenerationJob, TaskGenerationStep\n\nDEFAULT_GENERATION_STEPS: tuple[tuple[str, str], ...] = (\n    (\"prepare\", \"接收创建请求\"),\n    (\"reference\", \"读取参考文件\"),\n    (\"prompt\", \"构建提示词\"),\n    (\"llm\", \"调用 AI 生成标准\"),\n    (\"persist\", \"保存分析标准\"),\n    (\"task\", \"创建任务记录\"),\n)\n\n\nclass TaskGenerationService:\n    \"\"\"管理 AI 任务生成的后台作业状态\"\"\"\n\n    def __init__(self, step_specs: Iterable[tuple[str, str]] = DEFAULT_GENERATION_STEPS):\n        self._step_specs = tuple(step_specs)\n        self._jobs: Dict[str, TaskGenerationJob] = {}\n        self._lock = threading.Lock()\n        self._workers: set[threading.Thread] = set()\n\n    async def create_job(self, task_name: str) -> TaskGenerationJob:\n        job = TaskGenerationJob(\n            job_id=uuid4().hex,\n            task_name=task_name,\n            steps=[\n                TaskGenerationStep(key=key, label=label)\n                for key, label in self._step_specs\n            ],\n        )\n        with self._lock:\n            self._jobs[job.job_id] = job\n            return deepcopy(job)\n\n    async def get_job(self, job_id: str) -> Optional[TaskGenerationJob]:\n        with self._lock:\n            job = self._jobs.get(job_id)\n            if not job:\n                return None\n            return deepcopy(job)\n\n    def track(self, coroutine: Awaitable[None]) -> None:\n        thread: Optional[threading.Thread] = None\n\n        def runner() -> None:\n            try:\n                asyncio.run(coroutine)\n            finally:\n                if thread is None:\n                    return\n                with self._lock:\n                    self._workers.discard(thread)\n\n        thread = threading.Thread(target=runner, daemon=True)\n        with self._lock:\n            self._workers.add(thread)\n        thread.start()\n\n    async def advance(self, job_id: str, step_key: str, message: str) -> TaskGenerationJob:\n        with self._lock:\n            job = self._require_job(job_id)\n            target_index = self._find_step_index(job, step_key)\n            job.status = \"running\"\n            job.current_step = step_key\n            job.message = message\n            for index, step in enumerate(job.steps):\n                if step.status == \"failed\":\n                    continue\n                if index < target_index:\n                    step.status = \"completed\"\n                elif index == target_index:\n                    step.status = \"running\"\n                    step.message = message\n                elif step.status != \"pending\":\n                    step.status = \"pending\"\n                    step.message = \"\"\n            return deepcopy(job)\n\n    async def complete(self, job_id: str, task: Task, message: str) -> TaskGenerationJob:\n        with self._lock:\n            job = self._require_job(job_id)\n            job.status = \"completed\"\n            job.current_step = None\n            job.message = message\n            job.error = None\n            job.task = task\n            for step in job.steps:\n                if step.status != \"failed\":\n                    step.status = \"completed\"\n            return deepcopy(job)\n\n    async def fail(\n        self,\n        job_id: str,\n        error: str,\n        step_key: Optional[str] = None,\n    ) -> TaskGenerationJob:\n        with self._lock:\n            job = self._require_job(job_id)\n            failed_step = step_key or job.current_step\n            job.status = \"failed\"\n            job.error = error\n            job.message = error\n            job.current_step = failed_step\n            if failed_step:\n                step = self._find_step(job, failed_step)\n                if step:\n                    step.status = \"failed\"\n                    step.message = error\n            return deepcopy(job)\n\n    def _require_job(self, job_id: str) -> TaskGenerationJob:\n        job = self._jobs.get(job_id)\n        if not job:\n            raise KeyError(f\"任务生成作业不存在: {job_id}\")\n        return job\n\n    def _find_step(self, job: TaskGenerationJob, step_key: str) -> Optional[TaskGenerationStep]:\n        for step in job.steps:\n            if step.key == step_key:\n                return step\n        return None\n\n    def _find_step_index(self, job: TaskGenerationJob, step_key: str) -> int:\n        for index, step in enumerate(job.steps):\n            if step.key == step_key:\n                return index\n        raise KeyError(f\"未知的任务生成步骤: {step_key}\")\n"
  },
  {
    "path": "src/services/task_log_cleanup_service.py",
    "content": "\"\"\"\n任务运行日志清理服务。\n\"\"\"\nfrom __future__ import annotations\n\nfrom datetime import datetime, timedelta\nfrom pathlib import Path\n\n\ndef cleanup_task_logs(\n    logs_dir: str = \"logs\",\n    *,\n    keep_days: int = 7,\n    now: datetime | None = None,\n) -> list[str]:\n    if keep_days < 1:\n        print(f\"任务日志清理已跳过：保留天数配置无效 ({keep_days})\")\n        return []\n\n    root = Path(logs_dir)\n    if not root.exists():\n        return []\n\n    current_time = now or datetime.now()\n    cutoff = current_time - timedelta(days=keep_days)\n    removed_files: list[str] = []\n\n    for path in root.glob(\"*.log\"):\n        if not path.is_file():\n            continue\n        try:\n            modified_at = datetime.fromtimestamp(path.stat().st_mtime)\n        except OSError as exc:\n            print(f\"读取任务日志时间失败，已跳过: {path} ({exc})\")\n            continue\n\n        if modified_at >= cutoff:\n            continue\n\n        try:\n            path.unlink()\n            removed_files.append(str(path))\n        except OSError as exc:\n            print(f\"删除历史任务日志失败，已跳过: {path} ({exc})\")\n\n    if removed_files:\n        print(\n            f\"任务日志清理完成：已删除 {len(removed_files)} 个超过 {keep_days} 天的历史日志文件。\"\n        )\n\n    return removed_files\n"
  },
  {
    "path": "src/services/task_payloads.py",
    "content": "\"\"\"\n任务接口响应序列化辅助。\n\"\"\"\nfrom __future__ import annotations\n\nfrom datetime import datetime\nfrom typing import Any\n\nfrom src.domain.models.task import Task\n\n\ndef serialize_timestamp(value: datetime | None) -> str | None:\n    return value.isoformat() if value else None\n\n\ndef serialize_task(task: Task, scheduler_service) -> dict[str, Any]:\n    payload = task.model_dump()\n    next_run_time = None\n    if task.id is not None and scheduler_service is not None:\n        next_run_time = scheduler_service.get_next_run_time(task.id)\n    payload[\"next_run_at\"] = serialize_timestamp(next_run_time)\n    return payload\n\n\ndef serialize_tasks(tasks: list[Task], scheduler_service) -> list[dict[str, Any]]:\n    return [serialize_task(task, scheduler_service) for task in tasks]\n"
  },
  {
    "path": "src/services/task_service.py",
    "content": "\"\"\"\n任务管理服务\n封装任务相关的业务逻辑\n\"\"\"\nfrom typing import List, Optional\nfrom src.domain.models.task import Task, TaskCreate, TaskUpdate\nfrom src.domain.repositories.task_repository import TaskRepository\n\n\nclass TaskService:\n    \"\"\"任务管理服务\"\"\"\n\n    def __init__(self, repository: TaskRepository):\n        self.repository = repository\n\n    async def get_all_tasks(self) -> List[Task]:\n        \"\"\"获取所有任务\"\"\"\n        return await self.repository.find_all()\n\n    async def get_task(self, task_id: int) -> Optional[Task]:\n        \"\"\"获取单个任务\"\"\"\n        return await self.repository.find_by_id(task_id)\n\n    async def create_task(self, task_create: TaskCreate) -> Task:\n        \"\"\"创建新任务\"\"\"\n        task = Task(**task_create.model_dump(), is_running=False)\n        return await self.repository.save(task)\n\n    async def update_task(self, task_id: int, task_update: TaskUpdate) -> Task:\n        \"\"\"更新任务\"\"\"\n        task = await self.repository.find_by_id(task_id)\n        if not task:\n            raise ValueError(f\"任务 {task_id} 不存在\")\n\n        updated_task = task.apply_update(task_update)\n        return await self.repository.save(updated_task)\n\n    async def delete_task(self, task_id: int) -> bool:\n        \"\"\"删除任务\"\"\"\n        return await self.repository.delete(task_id)\n\n    async def update_task_status(self, task_id: int, is_running: bool) -> Task:\n        \"\"\"更新任务运行状态\"\"\"\n        task_update = TaskUpdate(is_running=is_running)\n        return await self.update_task(task_id, task_update)\n"
  },
  {
    "path": "src/utils.py",
    "content": "import asyncio\nimport json\nimport math\nimport os\nimport random\nimport re\nimport glob\nfrom datetime import datetime\nfrom functools import wraps\nfrom urllib.parse import quote\n\nfrom openai import APIStatusError\nfrom requests.exceptions import HTTPError\n\nfrom src.services.result_storage_service import save_result_record\n\n\ndef retry_on_failure(retries=3, delay=5):\n    \"\"\"\n    一个通用的异步重试装饰器，增加了对HTTP错误的详细日志记录。\n    \"\"\"\n    def decorator(func):\n        @wraps(func)\n        async def wrapper(*args, **kwargs):\n            for i in range(retries):\n                try:\n                    return await func(*args, **kwargs)\n                except (APIStatusError, HTTPError) as e:\n                    print(f\"函数 {func.__name__} 第 {i + 1}/{retries} 次尝试失败，发生HTTP错误。\")\n                    if hasattr(e, 'status_code'):\n                        print(f\"  - 状态码 (Status Code): {e.status_code}\")\n                    if hasattr(e, 'response') and hasattr(e.response, 'text'):\n                        response_text = e.response.text\n                        print(\n                            f\"  - 返回值 (Response): {response_text[:300]}{'...' if len(response_text) > 300 else ''}\")\n                except json.JSONDecodeError as e:\n                    print(f\"函数 {func.__name__} 第 {i + 1}/{retries} 次尝试失败: JSON解析错误 - {e}\")\n                except Exception as e:\n                    print(f\"函数 {func.__name__} 第 {i + 1}/{retries} 次尝试失败: {type(e).__name__} - {e}\")\n\n                if i < retries - 1:\n                    print(f\"将在 {delay} 秒后重试...\")\n                    await asyncio.sleep(delay)\n\n            print(f\"函数 {func.__name__} 在 {retries} 次尝试后彻底失败。\")\n            return None\n        return wrapper\n    return decorator\n\n\nasync def safe_get(data, *keys, default=\"暂无\"):\n    \"\"\"安全获取嵌套字典值\"\"\"\n    for key in keys:\n        try:\n            data = data[key]\n        except (KeyError, TypeError, IndexError):\n            return default\n    return data\n\n\nasync def random_sleep(min_seconds: float, max_seconds: float):\n    \"\"\"异步等待一个在指定范围内的随机时间。\"\"\"\n    delay = random.uniform(min_seconds, max_seconds)\n    print(f\"   [延迟] 等待 {delay:.2f} 秒... (范围: {min_seconds}-{max_seconds}s)\")\n    await asyncio.sleep(delay)\n\n\ndef log_time(message: str, prefix: str = \"\") -> None:\n    \"\"\"在日志前加上 YY-MM-DD HH:MM:SS 时间戳的简单打印。\"\"\"\n    try:\n        ts = datetime.now().strftime(' %Y-%m-%d %H:%M:%S')\n    except Exception:\n        ts = \"--:--:--\"\n    print(f\"[{ts}] {prefix}{message}\")\n\n\ndef sanitize_filename(value: str) -> str:\n    \"\"\"生成安全的文件名片段。\"\"\"\n    if not value:\n        return \"task\"\n    cleaned = re.sub(r\"[^a-zA-Z0-9_-]+\", \"_\", value.strip())\n    cleaned = re.sub(r\"_+\", \"_\", cleaned).strip(\"_\")\n    return cleaned or \"task\"\n\n\ndef build_task_log_path(task_id: int, task_name: str) -> str:\n    \"\"\"生成任务日志路径（包含任务名）。\"\"\"\n    safe_name = sanitize_filename(task_name)\n    filename = f\"{safe_name}_{task_id}.log\"\n    return os.path.join(\"logs\", filename)\n\n\ndef resolve_task_log_path(task_id: int, task_name: str) -> str:\n    \"\"\"优先使用任务名生成日志路径，不存在时回退为按 ID 匹配。\"\"\"\n    primary_path = build_task_log_path(task_id, task_name)\n    if os.path.exists(primary_path):\n        return primary_path\n    pattern = os.path.join(\"logs\", f\"*_{task_id}.log\")\n    matches = glob.glob(pattern)\n    if matches:\n        return matches[0]\n    return primary_path\n\n\ndef convert_goofish_link(url: str) -> str:\n    \"\"\"\n    将Goofish商品链接转换为只包含商品ID的手机端格式。\n    \"\"\"\n    match_first_link = re.search(r'item\\?id=(\\d+)', url)\n    if match_first_link:\n        item_id = match_first_link.group(1)\n        bfp_json = f'{{\"id\":{item_id}}}'\n        return f\"https://pages.goofish.com/sharexy?loadingVisible=false&bft=item&bfs=idlepc.item&spm=a21ybx.item.0.0&bfp={quote(bfp_json)}\"\n    return url\n\n\ndef get_link_unique_key(link: str) -> str:\n    \"\"\"截取链接中第一个\"&\"之前的内容作为唯一标识依据。\"\"\"\n    return link.split('&', 1)[0]\n\n\nasync def save_to_jsonl(data_record: dict, keyword: str):\n    \"\"\"兼容旧调用名，实际将结果写入 SQLite。\"\"\"\n    try:\n        return await save_result_record(data_record, keyword)\n    except Exception as e:\n        print(f\"写入 SQLite 结果记录出错: {e}\")\n        return False\n\n\ndef format_registration_days(total_days: int) -> str:\n    \"\"\"\n    将总天数格式化为“X年Y个月”的字符串。\n    \"\"\"\n    if not isinstance(total_days, int) or total_days <= 0:\n        return '未知'\n\n    DAYS_IN_YEAR = 365.25\n    DAYS_IN_MONTH = DAYS_IN_YEAR / 12\n\n    years = math.floor(total_days / DAYS_IN_YEAR)\n    remaining_days = total_days - (years * DAYS_IN_YEAR)\n    months = round(remaining_days / DAYS_IN_MONTH)\n\n    if months == 12:\n        years += 1\n        months = 0\n\n    if years > 0 and months > 0:\n        return f\"来闲鱼{years}年{months}个月\"\n    elif years > 0 and months == 0:\n        return f\"来闲鱼{years}年整\"\n    elif years == 0 and months > 0:\n        return f\"来闲鱼{months}个月\"\n    else:\n        return \"来闲鱼不足一个月\"\n"
  },
  {
    "path": "start.sh",
    "content": "#!/bin/bash\n\n# 闲鱼监控系统本地启动脚本\n# 功能：清理旧构建、安装依赖、构建前端、启动服务\n\nset -e  # 遇到错误立即退出\n\n# 颜色输出\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nNC='\\033[0m' # No Color\n\n# 获取脚本所在目录\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\ncd \"$SCRIPT_DIR\"\n\necho -e \"${GREEN}========================================${NC}\"\necho -e \"${GREEN}闲鱼监控系统 - 本地启动脚本${NC}\"\necho -e \"${GREEN}========================================${NC}\"\n\n# 0. 环境与依赖检查\necho -e \"\\n${YELLOW}[1/6] 检查环境与依赖...${NC}\"\n\nOS_FAMILY=\"unknown\"\nLINUX_ID=\"\"\nLINUX_LIKE=\"\"\nPYTHON_CMD=\"python3\"\nPIP_CMD=\"python3 -m pip\"\n\nif [ -f /etc/os-release ]; then\n    . /etc/os-release\n    LINUX_ID=\"$ID\"\n    LINUX_LIKE=\"$ID_LIKE\"\nfi\n\ncase \"$(uname -s 2>/dev/null || echo unknown)\" in\n    Darwin)\n        OS_FAMILY=\"macos\"\n        ;;\n    Linux)\n        if grep -qi microsoft /proc/version 2>/dev/null; then\n            OS_FAMILY=\"wsl\"\n        else\n            OS_FAMILY=\"linux\"\n        fi\n        ;;\n    MINGW*|MSYS*|CYGWIN*)\n        OS_FAMILY=\"windows\"\n        ;;\n    *)\n        OS_FAMILY=\"unknown\"\n        ;;\nesac\n\nMISSING_ITEMS=()\n\nif ! command -v python3 >/dev/null 2>&1; then\n    MISSING_ITEMS+=(\"python3(>=3.10)\")\nelse\n    if ! python3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)' >/dev/null 2>&1; then\n        MISSING_ITEMS+=(\"python3(>=3.10)\")\n    fi\nfi\n\nif ! python3 -m pip --version >/dev/null 2>&1; then\n    MISSING_ITEMS+=(\"pip\")\nfi\n\nif ! command -v node >/dev/null 2>&1; then\n    MISSING_ITEMS+=(\"node\")\nfi\n\nif ! command -v npm >/dev/null 2>&1; then\n    MISSING_ITEMS+=(\"npm\")\nfi\n\nif ! python3 -m playwright --version >/dev/null 2>&1; then\n    MISSING_ITEMS+=(\"playwright\")\nfi\n\nhas_browser=false\ncase \"$OS_FAMILY\" in\n    macos)\n        if [ -d \"/Applications/Google Chrome.app\" ] || [ -d \"/Applications/Microsoft Edge.app\" ]; then\n            has_browser=true\n        fi\n        ;;\n    linux|wsl)\n        if command -v google-chrome >/dev/null 2>&1 \\\n            || command -v google-chrome-stable >/dev/null 2>&1 \\\n            || command -v chromium >/dev/null 2>&1 \\\n            || command -v chromium-browser >/dev/null 2>&1 \\\n            || command -v microsoft-edge >/dev/null 2>&1 \\\n            || command -v microsoft-edge-stable >/dev/null 2>&1; then\n            has_browser=true\n        fi\n        ;;\n    windows)\n        if [ -d \"/c/Program Files/Google/Chrome/Application\" ] \\\n            || [ -d \"/c/Program Files (x86)/Google/Chrome/Application\" ] \\\n            || [ -d \"/c/Program Files (x86)/Microsoft/Edge/Application\" ] \\\n            || [ -d \"/c/Program Files/Microsoft/Edge/Application\" ]; then\n            has_browser=true\n        fi\n        ;;\nesac\n\nif [ \"$has_browser\" = false ]; then\n    MISSING_ITEMS+=(\"浏览器(Chrome 或 Edge)\")\nfi\n\n\nprint_solution_macos() {\n    cat <<'EOF'\nmacOS 解决办法:\n1) 安装 Homebrew:\n   /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"\n2) 安装 Python 与 Node:\n   brew install python@3.11 node\n3) 安装 Playwright:\n   python3 -m pip install playwright\n   python3 -m playwright install chromium\n4) 安装浏览器:\n   brew install --cask google-chrome\n   # 或\n   brew install --cask microsoft-edge\n5) 配置文件（可选）:\n   cp .env.example .env\n   cp config.json.example config.json\nEOF\n}\n\nprint_solution_linux_deb() {\n    cat <<'EOF'\nLinux (Debian/Ubuntu) 解决办法:\n1) 安装 Python 与 pip:\n   sudo apt-get update\n   sudo apt-get install -y python3 python3-pip python3-venv\n2) 安装 Node.js 与 npm (LTS):\n   curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -\n   sudo apt-get install -y nodejs\n3) 安装 Playwright:\n   python3 -m pip install playwright\n   python3 -m playwright install chromium\n   python3 -m playwright install-deps chromium\n4) 安装浏览器:\n   sudo apt-get install -y chromium-browser || sudo apt-get install -y chromium\n   # 或安装 Edge:\n   sudo apt-get install -y microsoft-edge-stable\n5) 配置文件（可选）:\n   cp .env.example .env\n   cp config.json.example config.json\nEOF\n}\n\nprint_solution_linux_rpm() {\n    cat <<'EOF'\nLinux (RHEL/CentOS/Fedora) 解决办法:\n1) 安装 Python 与 pip:\n   sudo dnf install -y python3 python3-pip\n2) 安装 Node.js 与 npm (LTS):\n   sudo dnf install -y nodejs\n3) 安装 Playwright:\n   python3 -m pip install playwright\n   python3 -m playwright install chromium\n   python3 -m playwright install-deps chromium\n4) 安装浏览器:\n   sudo dnf install -y chromium\n   # 或安装 Edge:\n   sudo dnf install -y microsoft-edge-stable\n5) 配置文件（可选）:\n   cp .env.example .env\n   cp config.json.example config.json\nEOF\n}\n\nprint_solution_linux_arch() {\n    cat <<'EOF'\nLinux (Arch) 解决办法:\n1) 安装 Python 与 pip:\n   sudo pacman -S --noconfirm python python-pip\n2) 安装 Node.js 与 npm:\n   sudo pacman -S --noconfirm nodejs npm\n3) 安装 Playwright:\n   python3 -m pip install playwright\n   python3 -m playwright install chromium\n   python3 -m playwright install-deps chromium\n4) 安装浏览器:\n   sudo pacman -S --noconfirm chromium\n   # 或安装 Edge:\n   yay -S microsoft-edge-stable\n5) 配置文件:\n   cp .env.example .env\n   cp config.json.example config.json\nEOF\n}\n\nprint_solution_wsl() {\n    cat <<'EOF'\nWSL 解决办法:\n1) 安装 Python 与 pip:\n   sudo apt-get update\n   sudo apt-get install -y python3 python3-pip python3-venv\n2) 安装 Node.js 与 npm (LTS):\n   curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -\n   sudo apt-get install -y nodejs\n3) 安装 Playwright:\n   python3 -m pip install playwright\n   python3 -m playwright install chromium\n   python3 -m playwright install-deps chromium\n4) 安装浏览器:\n   sudo apt-get install -y chromium-browser || sudo apt-get install -y chromium\n   # 或在 Windows 安装 Chrome/Edge 并在 WSL 使用 Linux 版本浏览器\n5) 配置文件:\n   cp .env.example .env\n   cp config.json.example config.json\nEOF\n}\n\nprint_solution_windows() {\n    cat <<'EOF'\nWindows (PowerShell) 解决办法:\n1) 安装 Python 与 Node:\n   winget install Python.Python.3.11\n   winget install OpenJS.NodeJS.LTS\n2) 安装 Playwright:\n   py -m pip install playwright\n   py -m playwright install chromium\n3) 安装浏览器:\n   winget install Google.Chrome\n   # 或\n   winget install Microsoft.Edge\n4) 配置文件（可选）:\n   Copy-Item .env.example .env\n   Copy-Item config.json.example config.json\nEOF\n}\n\nprint_solution_generic() {\n    cat <<'EOF'\n通用解决办法:\n1) 安装 Python 3.10+ 与 pip\n2) 安装 Node.js 与 npm\n3) 安装 Playwright:\n   python3 -m pip install playwright\n   python3 -m playwright install chromium\n4) 安装浏览器 Chrome 或 Edge\n5) 配置文件（可选）:\n   cp .env.example .env\n   cp config.json.example config.json\nEOF\n}\n\nif [ \"${#MISSING_ITEMS[@]}\" -ne 0 ]; then\n    echo -e \"${RED}✗ 检测到缺失的环境/依赖:${NC}\"\n    for item in \"${MISSING_ITEMS[@]}\"; do\n        echo \"  - $item\"\n    done\n    echo \"\"\n    case \"$OS_FAMILY\" in\n        macos)\n            print_solution_macos\n            ;;\n        linux)\n            if [ \"$LINUX_ID\" = \"arch\" ] || echo \"$LINUX_LIKE\" | grep -qi \"arch\"; then\n                print_solution_linux_arch\n            elif [ \"$LINUX_ID\" = \"fedora\" ] || [ \"$LINUX_ID\" = \"rhel\" ] || [ \"$LINUX_ID\" = \"centos\" ] || echo \"$LINUX_LIKE\" | grep -qi \"rhel\\|fedora\"; then\n                print_solution_linux_rpm\n            else\n                print_solution_linux_deb\n            fi\n            ;;\n        wsl)\n            print_solution_wsl\n            ;;\n        windows)\n            print_solution_windows\n            ;;\n        *)\n            print_solution_generic\n            ;;\n    esac\n    exit 1\nfi\n\necho -e \"${GREEN}✓ 环境与依赖检查通过${NC}\"\n\n# 1. 清理旧的 dist 目录\necho -e \"\\n${YELLOW}[2/6] 清理旧的构建产物...${NC}\"\nif [ -d \"dist\" ]; then\n    rm -rf dist\n    echo -e \"${GREEN}✓ 已删除旧的 dist 目录${NC}\"\nelse\n    echo -e \"${GREEN}✓ dist 目录不存在，跳过清理${NC}\"\nfi\n\n# 2. 检查并安装 Python 依赖\necho -e \"\\n${YELLOW}[3/6] 检查 Python 依赖...${NC}\"\nif [ ! -f \"requirements.txt\" ]; then\n    echo -e \"${RED}✗ 错误: requirements.txt 文件不存在${NC}\"\n    exit 1\nfi\n\necho \"正在安装 Python 依赖...\"\npython3 -m pip install -r requirements.txt --quiet\necho -e \"${GREEN}✓ Python 依赖安装完成${NC}\"\n\n# 3. 构建前端\necho -e \"\\n${YELLOW}[4/6] 构建前端项目...${NC}\"\nif [ ! -d \"web-ui\" ]; then\n    echo -e \"${RED}✗ 错误: web-ui 目录不存在${NC}\"\n    exit 1\nfi\n\ncd web-ui\n\n# 检查 node_modules 是否存在\nif [ ! -d \"node_modules\" ]; then\n    echo \"首次运行，正在安装前端依赖...\"\n    npm install\nfi\n\necho \"正在构建前端...\"\nnpm run build\n\ncd \"$SCRIPT_DIR\"\n\nif [ ! -d \"dist\" ]; then\n    echo -e \"${RED}✗ 错误: 前端构建失败，dist 目录未生成${NC}\"\n    exit 1\nfi\n\necho -e \"${GREEN}✓ 前端构建完成，产物已输出到项目根目录 dist/${NC}\"\n\n# 4. 校验构建产物\necho -e \"\\n${YELLOW}[5/6] 校验构建产物...${NC}\"\necho -e \"${GREEN}✓ 已确认构建产物位于项目根目录 dist/${NC}\"\n\n# 5. 启动后端服务\necho -e \"\\n${YELLOW}[6/6] 启动后端服务...${NC}\"\necho -e \"${GREEN}========================================${NC}\"\necho -e \"${GREEN}服务启动中...${NC}\"\necho -e \"${GREEN}访问地址: http://localhost:8000${NC}\"\necho -e \"${GREEN}API 文档: http://localhost:8000/docs${NC}\"\necho -e \"${GREEN}========================================${NC}\\n\"\n\npython3 -m src.app\n"
  },
  {
    "path": "tests/README.md",
    "content": "# 测试指南\n\n本项目使用 pytest 作为测试框架。以下是运行测试的指南。\n\n## 安装依赖\n\n在运行测试之前，请确保已安装所有开发依赖项：\n\n```bash\npip install -r requirements.txt\n```\n\n## 运行测试\n\n### 运行所有测试\n\n```bash\npytest\n```\n\n### 运行特定测试文件\n\n```bash\npytest tests/integration/test_api_tasks.py\n```\n\n### 运行特定测试函数\n\n```bash\npytest tests/unit/test_utils.py::test_safe_get_nested_and_default\n```\n\n### 生成覆盖率报告\n\n```bash\ncoverage run -m pytest\ncoverage report\ncoverage html  # 生成 HTML 报告\n```\n\n## 测试文件结构\n\n```\ntests/\n├── __init__.py\n├── conftest.py              # 共享 fixtures（API/CLI/样例数据）\n├── fixtures/                # 贴近真实的样例数据（搜索/用户/评价/任务配置）\n│   ├── config.sample.json\n│   ├── ratings.json\n│   ├── search_results.json\n│   ├── state.sample.json\n│   ├── user_head.json\n│   └── user_items.json\n├── integration/             # 关键链路集成测试（API/CLI/解析器）\n│   ├── test_api_tasks.py\n│   ├── test_cli_spider.py\n│   └── test_pipeline_parse.py\n└── unit/                    # 核心纯函数单元测试\n    ├── test_domain_task.py\n    └── test_utils.py\n```\n\n## 编写新测试\n\n1. 新增测试放在 `tests/integration/` 或 `tests/unit/`\n2. 文件名以 `test_` 开头，函数名以 `test_` 开头\n3. 测试采用同步执行（不依赖 pytest-asyncio）\n4. 外部依赖（Playwright/AI/通知/网络）统一 mock\n5. 使用 `tests/fixtures/` 的样例数据，避免依赖真实网络\n\n## 注意事项\n\n1. 目标是离线可跑且稳定复现\n2. 集成测试优先覆盖真实运行链路（API、CLI、解析器）\n3. 如需新增真实场景样例，统一补充到 `tests/fixtures/`\n\n## Live smoke（真实冒烟测试）\n\n- 目录：`tests/live/`\n- 默认关闭；仅当显式设置 `RUN_LIVE_TESTS=1` 时才会执行\n- 推荐命令：\n\n```bash\nRUN_LIVE_TESTS=1 \\\nLIVE_TEST_ACCOUNT_STATE_FILE=/absolute/path/to/account.json \\\nLIVE_TEST_KEYWORD=\"MacBook Pro M2\" \\\npytest tests/live -m live -v\n```\n\n- 一键脚本：\n\n```bash\n./run_live_smoke.sh\n./run_live_smoke.sh --without-generation\n```\n\n- 可选环境变量：\n  - `LIVE_TEST_TASK_NAME`\n  - `LIVE_EXPECT_MIN_ITEMS`（默认 `1`）\n  - `LIVE_TEST_DEBUG_LIMIT`（默认 `1`，只抓取/分析前 N 个新商品）\n  - `LIVE_TIMEOUT_SECONDS`（默认 `180`）\n  - `LIVE_ENABLE_TASK_GENERATION`（脚本默认 `1`；设为 `0` 或使用 `--without-generation` 可关闭真实 AI 任务生成慢用例）\n- live 套件会在临时工作目录中启动真实 `uvicorn`，并清空通知相关 env，避免污染仓库根目录或向真实通知通道发消息。\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/conftest.py",
    "content": "import json\nimport os\nimport sys\nfrom datetime import datetime, timedelta\nfrom pathlib import Path\nfrom zoneinfo import ZoneInfo\n\nimport pytest\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\n\n# Add repository root to the path so package imports work consistently\nrepo_root = Path(__file__).resolve().parents[1]\nsys.path.insert(0, str(repo_root))\n\nfrom src.api import dependencies as deps\nfrom src.api.routes import tasks\nfrom src.infrastructure.persistence.sqlite_task_repository import SqliteTaskRepository\nfrom src.services.task_service import TaskService\nfrom src.services.task_generation_service import TaskGenerationService\n\n\n@pytest.fixture()\ndef fixtures_dir() -> Path:\n    return Path(__file__).parent / \"fixtures\"\n\n\n@pytest.fixture()\ndef load_json_fixture(fixtures_dir):\n    def _load(name: str):\n        return json.loads((fixtures_dir / name).read_text(encoding=\"utf-8\"))\n\n    return _load\n\n\n@pytest.fixture()\ndef sample_task_payload():\n    return {\n        \"task_name\": \"Sony A7M4\",\n        \"enabled\": True,\n        \"keyword\": \"sony a7m4\",\n        \"description\": \"Good condition body with accessories\",\n        \"analyze_images\": True,\n        \"max_pages\": 2,\n        \"personal_only\": True,\n        \"min_price\": \"8000\",\n        \"max_price\": \"16000\",\n        \"cron\": \"*/15 * * * *\",\n        \"ai_prompt_base_file\": \"prompts/base_prompt.txt\",\n        \"ai_prompt_criteria_file\": \"prompts/sony_a7m4_criteria.txt\",\n        \"decision_mode\": \"ai\",\n        \"keyword_rules\": [],\n    }\n\n\nclass FakeProcessService:\n    def __init__(self):\n        self.started = []\n        self.stopped = []\n        self.reindexed = []\n        self._on_started = None\n        self._on_stopped = None\n\n    def set_lifecycle_hooks(self, *, on_started=None, on_stopped=None):\n        self._on_started = on_started\n        self._on_stopped = on_stopped\n\n    async def start_task(self, task_id: int, task_name: str) -> bool:\n        self.started.append((task_id, task_name))\n        if self._on_started:\n            await self._on_started(task_id)\n        return True\n\n    async def stop_task(self, task_id: int):\n        self.stopped.append(task_id)\n        if self._on_stopped:\n            await self._on_stopped(task_id)\n\n    def reindex_after_delete(self, deleted_task_id: int):\n        self.reindexed.append(deleted_task_id)\n\n\nclass FakeSchedulerService:\n    def __init__(self):\n        self.reload_calls = 0\n        self.next_run_times = {}\n\n    async def reload_jobs(self, tasks):\n        self.reload_calls += 1\n        base = datetime(2026, 3, 19, 8, 0, tzinfo=ZoneInfo(\"Asia/Shanghai\"))\n        self.next_run_times = {\n            task.id: base + timedelta(minutes=(index + 1) * 15)\n            for index, task in enumerate(tasks)\n            if task.id is not None and task.enabled and task.cron\n        }\n\n    def get_next_run_time(self, task_id: int):\n        return self.next_run_times.get(task_id)\n\n\n@pytest.fixture()\ndef api_context(tmp_path):\n    config_file = tmp_path / \"config.json\"\n    config_file.write_text(\"[]\", encoding=\"utf-8\")\n    db_path = tmp_path / \"app.sqlite3\"\n\n    repository = SqliteTaskRepository(\n        db_path=str(db_path),\n        legacy_config_file=None,\n    )\n    task_service = TaskService(repository)\n    process_service = FakeProcessService()\n    scheduler_service = FakeSchedulerService()\n    task_generation_service = TaskGenerationService()\n\n    app = FastAPI()\n    app.include_router(tasks.router)\n\n    def override_get_task_service():\n        return task_service\n\n    def override_get_process_service():\n        return process_service\n\n    def override_get_scheduler_service():\n        return scheduler_service\n\n    def override_get_task_generation_service():\n        return task_generation_service\n\n    async def mark_started(task_id: int):\n        await task_service.update_task_status(task_id, True)\n\n    async def mark_stopped(task_id: int):\n        task = await task_service.get_task(task_id)\n        if task:\n            await task_service.update_task_status(task_id, False)\n\n    process_service.set_lifecycle_hooks(on_started=mark_started, on_stopped=mark_stopped)\n\n    app.dependency_overrides[deps.get_task_service] = override_get_task_service\n    app.dependency_overrides[deps.get_process_service] = override_get_process_service\n    app.dependency_overrides[deps.get_scheduler_service] = override_get_scheduler_service\n    app.dependency_overrides[deps.get_task_generation_service] = override_get_task_generation_service\n\n    return {\n        \"app\": app,\n        \"config_file\": config_file,\n        \"db_path\": db_path,\n        \"process_service\": process_service,\n        \"scheduler_service\": scheduler_service,\n        \"task_generation_service\": task_generation_service,\n    }\n\n\n@pytest.fixture()\ndef api_client(api_context):\n    return TestClient(api_context[\"app\"])\n"
  },
  {
    "path": "tests/fixtures/config.sample.json",
    "content": "[\n  {\n    \"task_name\": \"Sony A7M4\",\n    \"enabled\": true,\n    \"keyword\": \"sony a7m4\",\n    \"description\": \"body only\",\n    \"max_pages\": 2,\n    \"personal_only\": true,\n    \"min_price\": \"8000\",\n    \"max_price\": \"16000\",\n    \"cron\": \"*/15 * * * *\",\n    \"ai_prompt_base_file\": \"prompts/base_prompt.txt\",\n    \"ai_prompt_criteria_file\": \"prompts/sony_a7m4_criteria.txt\",\n    \"decision_mode\": \"ai\",\n    \"keyword_rules\": []\n  },\n  {\n    \"task_name\": \"Canon R6\",\n    \"enabled\": false,\n    \"keyword\": \"canon r6\",\n    \"description\": \"body only\",\n    \"max_pages\": 1,\n    \"personal_only\": true,\n    \"min_price\": \"5000\",\n    \"max_price\": \"12000\",\n    \"cron\": \"*/30 * * * *\",\n    \"ai_prompt_base_file\": \"prompts/base_prompt.txt\",\n    \"ai_prompt_criteria_file\": \"prompts/canon_r6_criteria.txt\",\n    \"decision_mode\": \"ai\",\n    \"keyword_rules\": []\n  }\n]\n"
  },
  {
    "path": "tests/fixtures/ratings.json",
    "content": "[\n  {\n    \"cardData\": {\n      \"rateId\": \"r1\",\n      \"feedback\": \"Great seller\",\n      \"rate\": 1,\n      \"rateTagList\": [{\"text\": \"\\u5356\\u5bb6\"}],\n      \"raterUserNick\": \"buyer_01\",\n      \"gmtCreate\": \"2024-01-01\",\n      \"pictCdnUrlList\": []\n    }\n  },\n  {\n    \"cardData\": {\n      \"rateId\": \"r2\",\n      \"feedback\": \"Nice buyer\",\n      \"rate\": 1,\n      \"rateTagList\": [{\"text\": \"\\u4e70\\u5bb6\"}],\n      \"raterUserNick\": \"seller_02\",\n      \"gmtCreate\": \"2024-02-01\",\n      \"pictCdnUrlList\": [\"https://img.example.com/pic.jpg\"]\n    }\n  },\n  {\n    \"cardData\": {\n      \"rateId\": \"r3\",\n      \"feedback\": \"Average\",\n      \"rate\": 0,\n      \"rateTagList\": [{\"text\": \"\\u5356\\u5bb6\"}],\n      \"raterUserNick\": \"buyer_02\",\n      \"gmtCreate\": \"2024-03-01\",\n      \"pictCdnUrlList\": []\n    }\n  }\n]\n"
  },
  {
    "path": "tests/fixtures/search_results.json",
    "content": "{\n  \"data\": {\n    \"resultList\": [\n      {\n        \"data\": {\n          \"item\": {\n            \"main\": {\n              \"exContent\": {\n                \"title\": \"Sony A7M4 Body\",\n                \"price\": [\n                  {\"text\": \"\\u00a5\"},\n                  {\"text\": \"13999\"}\n                ],\n                \"area\": \"Shanghai\",\n                \"userNickName\": \"seller_01\",\n                \"picUrl\": \"https://img.example.com/a7m4.jpg\",\n                \"itemId\": \"123456\",\n                \"oriPrice\": \"\\u00a516999\",\n                \"fishTags\": {\n                  \"r1\": {\n                    \"tagList\": [\n                      {\"data\": {\"content\": \"\\u9a8c\\u8d27\\u5b9d\"}}\n                    ]\n                  }\n                }\n              },\n              \"clickParam\": {\n                \"args\": {\n                  \"publishTime\": \"1710000000000\",\n                  \"wantNum\": 12,\n                  \"tag\": \"freeship\"\n                }\n              },\n              \"targetUrl\": \"fleamarket://item?id=123456\"\n            }\n          }\n        }\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "tests/fixtures/state.sample.json",
    "content": "{\n  \"session\": \"dummy\"\n}\n"
  },
  {
    "path": "tests/fixtures/user_head.json",
    "content": "{\n  \"data\": {\n    \"module\": {\n      \"base\": {\n        \"displayName\": \"seller_01\",\n        \"avatar\": {\"avatar\": \"https://img.example.com/avatar.jpg\"},\n        \"introduction\": \"Trusted seller\",\n        \"ylzTags\": [\n          {\"attributes\": {\"role\": \"seller\", \"level\": \"3\"}, \"text\": \"S3\"},\n          {\"attributes\": {\"role\": \"buyer\", \"level\": \"2\"}, \"text\": \"B2\"}\n        ]\n      },\n      \"tabs\": {\n        \"item\": {\"number\": 12},\n        \"rate\": {\"number\": 88}\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "tests/fixtures/user_items.json",
    "content": "[\n  {\n    \"cardData\": {\n      \"itemStatus\": 0,\n      \"id\": \"10001\",\n      \"title\": \"Lens 24-70\",\n      \"priceInfo\": {\"price\": \"3500\"},\n      \"picInfo\": {\"picUrl\": \"https://img.example.com/lens.jpg\"}\n    }\n  },\n  {\n    \"cardData\": {\n      \"itemStatus\": 1,\n      \"id\": \"10002\",\n      \"title\": \"Battery Grip\",\n      \"priceInfo\": {\"price\": \"600\"},\n      \"picInfo\": {\"picUrl\": \"https://img.example.com/grip.jpg\"}\n    }\n  }\n]\n"
  },
  {
    "path": "tests/integration/test_api_dashboard.py",
    "content": "import json\n\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\nfrom src.api import dependencies as deps\nfrom src.api.routes import dashboard\nfrom src.domain.models.task import TaskCreate\nfrom src.infrastructure.persistence.sqlite_task_repository import SqliteTaskRepository\nfrom src.services.task_service import TaskService\n\n\ndef _write_jsonl(path, records):\n    with open(path, \"w\", encoding=\"utf-8\") as file:\n        for record in records:\n            file.write(json.dumps(record, ensure_ascii=False) + \"\\n\")\n\n\ndef test_dashboard_summary_aggregates_tasks_and_results(tmp_path, monkeypatch):\n    monkeypatch.chdir(tmp_path)\n\n    jsonl_dir = tmp_path / \"jsonl\"\n    jsonl_dir.mkdir(parents=True, exist_ok=True)\n\n    repository = SqliteTaskRepository(\n        db_path=str(tmp_path / \"app.sqlite3\"),\n        legacy_config_file=None,\n    )\n    task_service = TaskService(repository)\n    app = FastAPI()\n    app.include_router(dashboard.router)\n    app.dependency_overrides[deps.get_task_service] = lambda: task_service\n\n    client = TestClient(app)\n\n    first = TaskCreate(\n      task_name=\"Apple Watch 任务\",\n      keyword=\"apple watch\",\n      description=\"只关注价格合适且成色好的 Apple Watch。\",\n      max_pages=3,\n      personal_only=True,\n    )\n    second = TaskCreate(\n      task_name=\"iPad 任务\",\n      keyword=\"ipad pro\",\n      description=\"关注 2024 款 iPad Pro。\",\n      max_pages=2,\n      personal_only=True,\n    )\n\n    created_first = task_service.create_task(first)\n    created_second = task_service.create_task(second)\n    import asyncio\n    created_first = asyncio.run(created_first)\n    created_second = asyncio.run(created_second)\n    asyncio.run(task_service.update_task_status(created_second.id, True))\n\n    records = [\n        {\n            \"爬取时间\": \"2026-03-10T10:00:00\",\n            \"搜索关键字\": \"apple watch\",\n            \"任务名称\": \"Apple Watch 任务\",\n            \"商品信息\": {\n                \"商品ID\": \"watch-1\",\n                \"商品标题\": \"Apple Watch S10\",\n                \"商品链接\": \"https://www.goofish.com/item?id=watch-1\",\n                \"当前售价\": \"¥1800\",\n            },\n            \"ai_analysis\": {\n                \"analysis_source\": \"ai\",\n                \"is_recommended\": True,\n                \"reason\": \"价格低于均价\",\n            },\n        },\n        {\n            \"爬取时间\": \"2026-03-10T11:00:00\",\n            \"搜索关键字\": \"apple watch\",\n            \"任务名称\": \"Apple Watch 任务\",\n            \"商品信息\": {\n                \"商品ID\": \"watch-2\",\n                \"商品标题\": \"Apple Watch S10 蜂窝版\",\n                \"商品链接\": \"https://www.goofish.com/item?id=watch-2\",\n                \"当前售价\": \"¥2100\",\n            },\n            \"ai_analysis\": {\n                \"analysis_source\": \"keyword\",\n                \"is_recommended\": False,\n                \"reason\": \"未命中规则\",\n            },\n        },\n    ]\n    _write_jsonl(jsonl_dir / \"apple_watch_full_data.jsonl\", records)\n\n    response = client.get(\"/api/dashboard/summary\")\n    assert response.status_code == 200\n    payload = response.json()\n\n    assert payload[\"summary\"][\"enabled_tasks\"] == 2\n    assert payload[\"summary\"][\"running_tasks\"] == 1\n    assert payload[\"summary\"][\"result_files\"] == 1\n    assert payload[\"summary\"][\"scanned_items\"] == 2\n    assert payload[\"summary\"][\"recommended_items\"] == 1\n    assert payload[\"summary\"][\"ai_recommended_items\"] == 1\n    assert payload[\"summary\"][\"keyword_recommended_items\"] == 0\n    assert payload[\"focus_file\"] == \"apple_watch_full_data.jsonl\"\n\n    watch_summary = next(\n        item for item in payload[\"task_summaries\"] if item[\"task_name\"] == \"Apple Watch 任务\"\n    )\n    assert watch_summary[\"filename\"] == \"apple_watch_full_data.jsonl\"\n    assert watch_summary[\"total_items\"] == 2\n    assert watch_summary[\"latest_recommended_title\"] == \"Apple Watch S10\"\n\n    ipad_summary = next(\n        item for item in payload[\"task_summaries\"] if item[\"task_name\"] == \"iPad 任务\"\n    )\n    assert ipad_summary[\"filename\"] is None\n    assert ipad_summary[\"is_running\"] is True\n\n    statuses = {item[\"status\"] for item in payload[\"recent_activities\"]}\n    assert \"AI 推荐\" in statuses\n    assert \"结果已更新\" in statuses\n    assert \"运行中\" in statuses\n"
  },
  {
    "path": "tests/integration/test_api_results.py",
    "content": "import json\n\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\nfrom src.api.routes import results\nfrom src.services.price_history_service import record_market_snapshots\n\n\ndef _write_jsonl(path, records):\n    with open(path, \"w\", encoding=\"utf-8\") as f:\n        for record in records:\n            f.write(json.dumps(record, ensure_ascii=False) + \"\\n\")\n\n\ndef test_results_filter_and_sort_for_keyword_recommendations(tmp_path, monkeypatch):\n    monkeypatch.chdir(tmp_path)\n    jsonl_dir = tmp_path / \"jsonl\"\n    jsonl_dir.mkdir(parents=True, exist_ok=True)\n    target_file = jsonl_dir / \"demo_full_data.jsonl\"\n\n    records = [\n        {\n            \"爬取时间\": \"2026-01-01T01:00:00\",\n            \"商品信息\": {\"当前售价\": \"¥1000\", \"发布时间\": \"2026-01-01 10:00\"},\n            \"ai_analysis\": {\n                \"analysis_source\": \"keyword\",\n                \"is_recommended\": True,\n                \"keyword_hit_count\": 3,\n                \"reason\": \"命中 3 个关键词\",\n            },\n        },\n        {\n            \"爬取时间\": \"2026-01-01T02:00:00\",\n            \"商品信息\": {\"当前售价\": \"¥2000\", \"发布时间\": \"2026-01-01 11:00\"},\n            \"ai_analysis\": {\n                \"analysis_source\": \"keyword\",\n                \"is_recommended\": True,\n                \"keyword_hit_count\": 1,\n                \"reason\": \"命中 1 个关键词\",\n            },\n        },\n        {\n            \"爬取时间\": \"2026-01-01T03:00:00\",\n            \"商品信息\": {\"当前售价\": \"¥3000\", \"发布时间\": \"2026-01-01 12:00\"},\n            \"ai_analysis\": {\n                \"analysis_source\": \"ai\",\n                \"is_recommended\": True,\n                \"reason\": \"AI推荐\",\n            },\n        },\n    ]\n    _write_jsonl(target_file, records)\n\n    app = FastAPI()\n    app.include_router(results.router)\n    client = TestClient(app)\n\n    resp = client.get(\n        \"/api/results/demo_full_data.jsonl\",\n        params={\"keyword_recommended_only\": True, \"sort_by\": \"keyword_hit_count\", \"sort_order\": \"desc\"},\n    )\n    assert resp.status_code == 200\n    data = resp.json()\n    assert data[\"total_items\"] == 2\n    assert data[\"items\"][0][\"ai_analysis\"][\"keyword_hit_count\"] == 3\n    assert data[\"items\"][1][\"ai_analysis\"][\"keyword_hit_count\"] == 1\n\n    resp = client.get(\n        \"/api/results/demo_full_data.jsonl\",\n        params={\"ai_recommended_only\": True},\n    )\n    assert resp.status_code == 200\n    data = resp.json()\n    assert data[\"total_items\"] == 1\n    assert data[\"items\"][0][\"ai_analysis\"][\"analysis_source\"] == \"ai\"\n\n    resp = client.get(\n        \"/api/results/demo_full_data.jsonl\",\n        params={\"ai_recommended_only\": True, \"keyword_recommended_only\": True},\n    )\n    assert resp.status_code == 400\n\n\ndef test_results_insights_and_export_csv(tmp_path, monkeypatch):\n    monkeypatch.chdir(tmp_path)\n    jsonl_dir = tmp_path / \"jsonl\"\n    jsonl_dir.mkdir(parents=True, exist_ok=True)\n    target_file = jsonl_dir / \"demo_full_data.jsonl\"\n\n    records = [\n        {\n            \"爬取时间\": \"2026-01-02T09:00:00\",\n            \"搜索关键字\": \"demo\",\n            \"任务名称\": \"Demo 任务\",\n            \"商品信息\": {\n                \"商品ID\": \"1001\",\n                \"商品标题\": \"Demo One\",\n                \"商品链接\": \"https://www.goofish.com/item?id=1001\",\n                \"当前售价\": \"¥950\",\n                \"发布时间\": \"2026-01-02 08:30\",\n            },\n            \"卖家信息\": {\"卖家昵称\": \"卖家A\"},\n            \"ai_analysis\": {\n                \"analysis_source\": \"ai\",\n                \"is_recommended\": True,\n                \"reason\": \"价格低于近期均价\",\n            },\n        },\n        {\n            \"爬取时间\": \"2026-01-02T09:05:00\",\n            \"搜索关键字\": \"demo\",\n            \"任务名称\": \"Demo 任务\",\n            \"商品信息\": {\n                \"商品ID\": \"1002\",\n                \"商品标题\": \"Demo Two\",\n                \"商品链接\": \"https://www.goofish.com/item?id=1002\",\n                \"当前售价\": \"¥1200\",\n                \"发布时间\": \"2026-01-02 08:45\",\n            },\n            \"卖家信息\": {\"卖家昵称\": \"卖家B\"},\n            \"ai_analysis\": {\n                \"analysis_source\": \"keyword\",\n                \"is_recommended\": False,\n                \"reason\": \"未命中\",\n                \"keyword_hit_count\": 0,\n            },\n        },\n    ]\n    _write_jsonl(target_file, records)\n\n    record_market_snapshots(\n        keyword=\"demo\",\n        task_name=\"Demo 任务\",\n        items=[\n            {\n                \"商品ID\": \"1001\",\n                \"商品标题\": \"Demo One\",\n                \"当前售价\": \"¥1000\",\n                \"商品链接\": \"https://www.goofish.com/item?id=1001\",\n            },\n            {\n                \"商品ID\": \"1002\",\n                \"商品标题\": \"Demo Two\",\n                \"当前售价\": \"¥1200\",\n                \"商品链接\": \"https://www.goofish.com/item?id=1002\",\n            },\n        ],\n        run_id=\"run-1\",\n        snapshot_time=\"2026-01-01T10:00:00\",\n        seen_item_ids=set(),\n    )\n    record_market_snapshots(\n        keyword=\"demo\",\n        task_name=\"Demo 任务\",\n        items=[\n            {\n                \"商品ID\": \"1001\",\n                \"商品标题\": \"Demo One\",\n                \"当前售价\": \"¥950\",\n                \"商品链接\": \"https://www.goofish.com/item?id=1001\",\n            },\n            {\n                \"商品ID\": \"1002\",\n                \"商品标题\": \"Demo Two\",\n                \"当前售价\": \"¥1180\",\n                \"商品链接\": \"https://www.goofish.com/item?id=1002\",\n            },\n        ],\n        run_id=\"run-2\",\n        snapshot_time=\"2026-01-02T10:00:00\",\n        seen_item_ids=set(),\n    )\n\n    app = FastAPI()\n    app.include_router(results.router)\n    client = TestClient(app)\n\n    insights_resp = client.get(\"/api/results/demo_full_data.jsonl/insights\")\n    assert insights_resp.status_code == 200\n    insights = insights_resp.json()\n    assert insights[\"market_summary\"][\"sample_count\"] == 2\n    assert len(insights[\"daily_trend\"]) == 2\n\n    list_resp = client.get(\"/api/results/demo_full_data.jsonl\")\n    assert list_resp.status_code == 200\n    items = list_resp.json()[\"items\"]\n    assert items[0][\"price_insight\"][\"observation_count\"] >= 1\n\n    export_resp = client.get(\n        \"/api/results/demo_full_data.jsonl/export\",\n        params={\"sort_by\": \"price\", \"sort_order\": \"asc\"},\n    )\n    assert export_resp.status_code == 200\n    assert \"text/csv\" in export_resp.headers[\"content-type\"]\n    text = export_resp.text\n    assert \"任务名称,搜索关键字,商品ID,商品标题\" in text\n    assert \"Demo One\" in text\n\n\ndef test_results_export_csv_supports_unicode_filename(tmp_path, monkeypatch):\n    monkeypatch.chdir(tmp_path)\n    jsonl_dir = tmp_path / \"jsonl\"\n    jsonl_dir.mkdir(parents=True, exist_ok=True)\n    target_file = jsonl_dir / \"演示_full_data.jsonl\"\n\n    records = [\n        {\n            \"爬取时间\": \"2026-01-02T09:00:00\",\n            \"搜索关键字\": \"演示\",\n            \"任务名称\": \"演示任务\",\n            \"商品信息\": {\n                \"商品ID\": \"1001\",\n                \"商品标题\": \"演示商品\",\n                \"商品链接\": \"https://www.goofish.com/item?id=1001\",\n                \"当前售价\": \"¥950\",\n                \"发布时间\": \"2026-01-02 08:30\",\n            },\n            \"卖家信息\": {\"卖家昵称\": \"卖家A\"},\n            \"ai_analysis\": {\n                \"analysis_source\": \"ai\",\n                \"is_recommended\": True,\n                \"reason\": \"价格合理\",\n            },\n        }\n    ]\n    _write_jsonl(target_file, records)\n\n    app = FastAPI()\n    app.include_router(results.router)\n    client = TestClient(app)\n\n    export_resp = client.get(\"/api/results/演示_full_data.jsonl/export\")\n    assert export_resp.status_code == 200\n    assert \"text/csv\" in export_resp.headers[\"content-type\"]\n    disposition = export_resp.headers[\"content-disposition\"]\n    assert 'filename=\"export.csv\"' in disposition\n    assert \"filename*=UTF-8''%E6%BC%94%E7%A4%BA_full_data.csv\" in disposition\n"
  },
  {
    "path": "tests/integration/test_api_settings.py",
    "content": "from fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\nfrom src.api import dependencies as deps\nfrom src.api.routes import settings\nfrom src.infrastructure.config.env_manager import env_manager\n\n\n_SETTINGS_ENV_KEYS = [\n    \"ACCOUNT_ROTATION_ENABLED\",\n    \"ACCOUNT_ROTATION_MODE\",\n    \"ACCOUNT_ROTATION_RETRY_LIMIT\",\n    \"ACCOUNT_BLACKLIST_TTL\",\n    \"ACCOUNT_STATE_DIR\",\n    \"PROXY_ROTATION_ENABLED\",\n    \"PROXY_ROTATION_MODE\",\n    \"PROXY_POOL\",\n    \"PROXY_ROTATION_RETRY_LIMIT\",\n    \"PROXY_BLACKLIST_TTL\",\n    \"OPENAI_API_KEY\",\n    \"OPENAI_BASE_URL\",\n    \"OPENAI_MODEL_NAME\",\n    \"SKIP_AI_ANALYSIS\",\n    \"PROXY_URL\",\n    \"NTFY_TOPIC_URL\",\n    \"GOTIFY_URL\",\n    \"GOTIFY_TOKEN\",\n    \"BARK_URL\",\n    \"WX_BOT_URL\",\n    \"TELEGRAM_BOT_TOKEN\",\n    \"TELEGRAM_CHAT_ID\",\n    \"TELEGRAM_API_BASE_URL\",\n    \"WEBHOOK_URL\",\n    \"WEBHOOK_METHOD\",\n    \"WEBHOOK_HEADERS\",\n    \"WEBHOOK_CONTENT_TYPE\",\n    \"WEBHOOK_QUERY_PARAMETERS\",\n    \"WEBHOOK_BODY\",\n    \"PCURL_TO_MOBILE\",\n]\n\n\nclass _IdleProcessService:\n    def __init__(self) -> None:\n        self.processes = {}\n\n\ndef _build_settings_client() -> TestClient:\n    app = FastAPI()\n    app.include_router(settings.router)\n    app.dependency_overrides[deps.get_process_service] = _IdleProcessService\n    return TestClient(app)\n\n\ndef _clear_settings_env(monkeypatch) -> None:\n    for key in _SETTINGS_ENV_KEYS:\n        monkeypatch.delenv(key, raising=False)\n\n\ndef test_rotation_settings_include_account_rotation_fields(tmp_path, monkeypatch):\n    _clear_settings_env(monkeypatch)\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"ACCOUNT_ROTATION_ENABLED=false\",\n                \"ACCOUNT_ROTATION_MODE=per_task\",\n                \"ACCOUNT_ROTATION_RETRY_LIMIT=2\",\n                \"ACCOUNT_BLACKLIST_TTL=300\",\n                \"ACCOUNT_STATE_DIR=state\",\n                \"PROXY_ROTATION_ENABLED=false\",\n                \"PROXY_ROTATION_MODE=per_task\",\n                \"PROXY_ROTATION_RETRY_LIMIT=2\",\n                \"PROXY_BLACKLIST_TTL=300\",\n            ]\n        ),\n        encoding=\"utf-8\",\n    )\n    monkeypatch.setattr(env_manager, \"env_file\", env_file)\n\n    client = _build_settings_client()\n\n    response = client.get(\"/api/settings/rotation\")\n    assert response.status_code == 200\n    payload = response.json()\n    assert payload[\"ACCOUNT_ROTATION_ENABLED\"] is False\n    assert payload[\"ACCOUNT_ROTATION_MODE\"] == \"per_task\"\n    assert payload[\"ACCOUNT_STATE_DIR\"] == \"state\"\n\n    update_response = client.put(\n        \"/api/settings/rotation\",\n        json={\n            \"ACCOUNT_ROTATION_ENABLED\": True,\n            \"ACCOUNT_ROTATION_MODE\": \"on_failure\",\n            \"ACCOUNT_ROTATION_RETRY_LIMIT\": 4,\n            \"ACCOUNT_BLACKLIST_TTL\": 900,\n            \"ACCOUNT_STATE_DIR\": \"accounts\",\n        },\n    )\n    assert update_response.status_code == 200\n\n    latest = env_file.read_text(encoding=\"utf-8\")\n    assert \"ACCOUNT_ROTATION_ENABLED=true\" in latest\n    assert \"ACCOUNT_ROTATION_MODE=on_failure\" in latest\n    assert \"ACCOUNT_ROTATION_RETRY_LIMIT=4\" in latest\n    assert \"ACCOUNT_BLACKLIST_TTL=900\" in latest\n    assert \"ACCOUNT_STATE_DIR=accounts\" in latest\n\n\ndef test_notification_settings_redact_sensitive_values_and_expose_flags(tmp_path, monkeypatch):\n    _clear_settings_env(monkeypatch)\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"NTFY_TOPIC_URL=https://ntfy.sh/demo-topic\",\n                \"GOTIFY_URL=https://gotify.example.com\",\n                \"GOTIFY_TOKEN=secret-token\",\n                \"BARK_URL=https://api.day.app/private-key/\",\n                \"WX_BOT_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret\",\n                \"TELEGRAM_BOT_TOKEN=telegram-secret\",\n                \"TELEGRAM_CHAT_ID=123456\",\n                \"TELEGRAM_API_BASE_URL=https://tg.example.com/proxy\",\n                \"WEBHOOK_URL=https://hooks.example.com/notify?token=secret\",\n                'WEBHOOK_HEADERS={\"Authorization\":\"Bearer secret\"}',\n                'WEBHOOK_BODY={\"message\":\"{{content}}\"}',\n            ]\n        ),\n        encoding=\"utf-8\",\n    )\n    monkeypatch.setattr(env_manager, \"env_file\", env_file)\n    client = _build_settings_client()\n\n    response = client.get(\"/api/settings/notifications\")\n\n    assert response.status_code == 200\n    payload = response.json()\n    assert payload[\"NTFY_TOPIC_URL\"] == \"https://ntfy.sh/demo-topic\"\n    assert payload[\"GOTIFY_URL\"] == \"https://gotify.example.com\"\n    assert payload[\"TELEGRAM_CHAT_ID\"] == \"123456\"\n    assert payload[\"TELEGRAM_API_BASE_URL\"] == \"https://tg.example.com/proxy\"\n    assert payload[\"BARK_URL\"] == \"\"\n    assert payload[\"WX_BOT_URL\"] == \"\"\n    assert payload[\"GOTIFY_TOKEN\"] == \"\"\n    assert payload[\"TELEGRAM_BOT_TOKEN\"] == \"\"\n    assert payload[\"WEBHOOK_URL\"] == \"\"\n    assert payload[\"WEBHOOK_HEADERS\"] == \"\"\n    assert payload[\"BARK_URL_SET\"] is True\n    assert payload[\"WX_BOT_URL_SET\"] is True\n    assert payload[\"GOTIFY_TOKEN_SET\"] is True\n    assert payload[\"TELEGRAM_BOT_TOKEN_SET\"] is True\n    assert payload[\"WEBHOOK_URL_SET\"] is True\n    assert payload[\"WEBHOOK_HEADERS_SET\"] is True\n    assert payload[\"WEBHOOK_BODY\"] == '{\"message\":\"{{content}}\"}'\n\n\ndef test_update_notification_settings_rejects_invalid_channel_config(tmp_path, monkeypatch):\n    _clear_settings_env(monkeypatch)\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\"\", encoding=\"utf-8\")\n    monkeypatch.setattr(env_manager, \"env_file\", env_file)\n    client = _build_settings_client()\n\n    gotify_response = client.put(\n        \"/api/settings/notifications\",\n        json={\"GOTIFY_URL\": \"https://gotify.example.com\"},\n    )\n    assert gotify_response.status_code == 422\n    assert \"GOTIFY_TOKEN\" in gotify_response.text\n\n    telegram_proxy_response = client.put(\n        \"/api/settings/notifications\",\n        json={\"TELEGRAM_API_BASE_URL\": \"not-a-url\"},\n    )\n    assert telegram_proxy_response.status_code == 422\n    assert \"TELEGRAM_API_BASE_URL\" in telegram_proxy_response.text\n\n    webhook_response = client.put(\n        \"/api/settings/notifications\",\n        json={\n            \"WEBHOOK_URL\": \"https://hooks.example.com/notify\",\n            \"WEBHOOK_METHOD\": \"POST\",\n            \"WEBHOOK_CONTENT_TYPE\": \"JSON\",\n            \"WEBHOOK_HEADERS\": '{\"Authorization\": \"Bearer secret\"',\n        },\n    )\n    assert webhook_response.status_code == 422\n    assert \"WEBHOOK_HEADERS\" in webhook_response.text\n\n\ndef test_system_status_includes_notification_channel_flags(tmp_path, monkeypatch):\n    _clear_settings_env(monkeypatch)\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"NTFY_TOPIC_URL=https://ntfy.sh/demo-topic\",\n                \"GOTIFY_URL=https://gotify.example.com\",\n                \"GOTIFY_TOKEN=secret-token\",\n                \"BARK_URL=https://api.day.app/private-key/\",\n                \"WX_BOT_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret\",\n                \"TELEGRAM_BOT_TOKEN=telegram-secret\",\n                \"TELEGRAM_CHAT_ID=123456\",\n                \"WEBHOOK_URL=https://hooks.example.com/notify\",\n            ]\n        ),\n        encoding=\"utf-8\",\n    )\n    monkeypatch.setattr(env_manager, \"env_file\", env_file)\n    client = _build_settings_client()\n\n    response = client.get(\"/api/settings/status\")\n\n    assert response.status_code == 200\n    env_payload = response.json()[\"env_file\"]\n    assert env_payload[\"ntfy_topic_url_set\"] is True\n    assert env_payload[\"gotify_url_set\"] is True\n    assert env_payload[\"gotify_token_set\"] is True\n    assert env_payload[\"bark_url_set\"] is True\n    assert env_payload[\"wx_bot_url_set\"] is True\n    assert env_payload[\"telegram_bot_token_set\"] is True\n    assert env_payload[\"telegram_chat_id_set\"] is True\n    assert env_payload[\"webhook_url_set\"] is True\n\n\ndef test_notification_test_endpoint_merges_stored_secret_values(tmp_path, monkeypatch):\n    _clear_settings_env(monkeypatch)\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"TELEGRAM_BOT_TOKEN=stored-token\",\n                \"TELEGRAM_CHAT_ID=10001\",\n                \"TELEGRAM_API_BASE_URL=https://tg-proxy.example.com/base\",\n            ]\n        ),\n        encoding=\"utf-8\",\n    )\n    monkeypatch.setattr(env_manager, \"env_file\", env_file)\n    client = _build_settings_client()\n\n    captured = {}\n\n    class _FakeResponse:\n        status_code = 200\n\n        def raise_for_status(self):\n            return None\n\n        def json(self):\n            return {\"ok\": True}\n\n    def _fake_post(url, json=None, headers=None, timeout=None):\n        captured[\"url\"] = url\n        captured[\"json\"] = json\n        return _FakeResponse()\n\n    monkeypatch.setattr(\"requests.post\", _fake_post)\n\n    response = client.post(\n        \"/api/settings/notifications/test\",\n        json={\n            \"channel\": \"telegram\",\n            \"settings\": {\n                \"TELEGRAM_CHAT_ID\": \"20002\",\n            },\n        },\n    )\n\n    assert response.status_code == 200\n    payload = response.json()\n    assert payload[\"results\"][\"telegram\"][\"success\"] is True\n    assert captured[\"url\"] == \"https://tg-proxy.example.com/base/botstored-token/sendMessage\"\n    assert captured[\"json\"][\"chat_id\"] == \"20002\"\n\n\ndef test_ai_settings_fall_back_to_runtime_environment_when_env_file_missing(tmp_path, monkeypatch):\n    _clear_settings_env(monkeypatch)\n    env_file = tmp_path / \".env\"\n    monkeypatch.setattr(env_manager, \"env_file\", env_file)\n    monkeypatch.setenv(\"OPENAI_API_KEY\", \"runtime-key\")\n    monkeypatch.setenv(\"OPENAI_BASE_URL\", \"https://runtime.example.com/v1\")\n    monkeypatch.setenv(\"OPENAI_MODEL_NAME\", \"runtime-model\")\n    monkeypatch.setenv(\"PROXY_URL\", \"http://127.0.0.1:7890\")\n    client = _build_settings_client()\n\n    ai_response = client.get(\"/api/settings/ai\")\n    assert ai_response.status_code == 200\n    assert ai_response.json() == {\n        \"OPENAI_BASE_URL\": \"https://runtime.example.com/v1\",\n        \"OPENAI_MODEL_NAME\": \"runtime-model\",\n        \"SKIP_AI_ANALYSIS\": False,\n        \"PROXY_URL\": \"http://127.0.0.1:7890\",\n    }\n\n    status_response = client.get(\"/api/settings/status\")\n    assert status_response.status_code == 200\n    env_payload = status_response.json()[\"env_file\"]\n    assert env_payload[\"exists\"] is False\n    assert env_payload[\"openai_api_key_set\"] is True\n    assert env_payload[\"openai_base_url_set\"] is True\n    assert env_payload[\"openai_model_name_set\"] is True\n\n\ndef test_notification_settings_fall_back_to_runtime_environment_when_env_file_missing(\n    tmp_path, monkeypatch\n):\n    _clear_settings_env(monkeypatch)\n    env_file = tmp_path / \".env\"\n    monkeypatch.setattr(env_manager, \"env_file\", env_file)\n    monkeypatch.setenv(\"NTFY_TOPIC_URL\", \"https://ntfy.sh/runtime-topic\")\n    monkeypatch.setenv(\"TELEGRAM_BOT_TOKEN\", \"runtime-telegram-token\")\n    monkeypatch.setenv(\"TELEGRAM_CHAT_ID\", \"20001\")\n    monkeypatch.setenv(\"TELEGRAM_API_BASE_URL\", \"https://runtime-tg-proxy.example.com\")\n    monkeypatch.setenv(\"BARK_URL\", \"https://api.day.app/runtime-secret/\")\n    client = _build_settings_client()\n\n    response = client.get(\"/api/settings/notifications\")\n\n    assert response.status_code == 200\n    payload = response.json()\n    assert payload[\"NTFY_TOPIC_URL\"] == \"https://ntfy.sh/runtime-topic\"\n    assert payload[\"TELEGRAM_CHAT_ID\"] == \"20001\"\n    assert payload[\"TELEGRAM_API_BASE_URL\"] == \"https://runtime-tg-proxy.example.com\"\n    assert payload[\"BARK_URL\"] == \"\"\n    assert payload[\"BARK_URL_SET\"] is True\n    assert payload[\"TELEGRAM_BOT_TOKEN_SET\"] is True\n    assert sorted(payload[\"CONFIGURED_CHANNELS\"]) == [\"bark\", \"ntfy\", \"telegram\"]\n\n\ndef test_ai_test_endpoint_falls_back_to_responses_when_chat_completions_api_404(\n    tmp_path, monkeypatch\n):\n    _clear_settings_env(monkeypatch)\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\"\", encoding=\"utf-8\")\n    monkeypatch.setattr(env_manager, \"env_file\", env_file)\n    client = _build_settings_client()\n    request_history = []\n\n    class _FakeOpenAI:\n        def __init__(self, **_kwargs):\n            self.responses = type(\n                \"_Responses\",\n                (),\n                {\"create\": self._responses_create},\n            )()\n            self.chat = type(\n                \"_Chat\",\n                (),\n                {\n                    \"completions\": type(\n                        \"_Completions\",\n                        (),\n                        {\"create\": self._chat_create},\n                    )()\n                },\n            )()\n\n        def _responses_create(self, **kwargs):\n            request_history.append((\"responses\", kwargs))\n            return type(\n                \"_Response\",\n                (),\n                {\"output_text\": \"OK\"},\n            )()\n\n        def _chat_create(self, **kwargs):\n            request_history.append((\"chat\", kwargs))\n            raise Exception(\"Error code: 404 - page not found\")\n\n    import openai\n\n    monkeypatch.setattr(openai, \"OpenAI\", _FakeOpenAI)\n\n    response = client.post(\n        \"/api/settings/ai/test\",\n        json={\n            \"OPENAI_API_KEY\": \"demo\",\n            \"OPENAI_BASE_URL\": \"https://example.com/v1/\",\n            \"OPENAI_MODEL_NAME\": \"demo-model\",\n        },\n    )\n\n    assert response.status_code == 200\n    payload = response.json()\n    assert payload[\"success\"] is True\n    assert payload[\"response\"] == \"OK\"\n    assert request_history[0][0] == \"chat\"\n    assert request_history[0][1][\"messages\"][0][\"content\"] == settings.AI_TEST_PROMPT\n    assert request_history[1][0] == \"responses\"\n    assert request_history[1][1][\"input\"][0][\"content\"][0][\"text\"] == settings.AI_TEST_PROMPT\n"
  },
  {
    "path": "tests/integration/test_api_tasks.py",
    "content": "import asyncio\nimport time\n\n\ndef test_create_list_update_delete_task(api_client, api_context, sample_task_payload):\n    response = api_client.post(\"/api/tasks/\", json=sample_task_payload)\n    assert response.status_code == 200\n    created = response.json()[\"task\"]\n    assert created[\"task_name\"] == sample_task_payload[\"task_name\"]\n    assert created[\"analyze_images\"] is True\n    assert created[\"next_run_at\"] == \"2026-03-19T08:15:00+08:00\"\n\n    response = api_client.get(\"/api/tasks\")\n    assert response.status_code == 200\n    tasks = response.json()\n    assert len(tasks) == 1\n    assert tasks[0][\"keyword\"] == sample_task_payload[\"keyword\"]\n    assert tasks[0][\"analyze_images\"] is True\n    assert tasks[0][\"next_run_at\"] == \"2026-03-19T08:15:00+08:00\"\n\n    response = api_client.patch(\"/api/tasks/0\", json={\"enabled\": False, \"analyze_images\": False})\n    assert response.status_code == 200\n    updated = response.json()[\"task\"]\n    assert updated[\"enabled\"] is False\n    assert updated[\"analyze_images\"] is False\n    assert updated[\"next_run_at\"] is None\n\n    response = api_client.delete(\"/api/tasks/0\")\n    assert response.status_code == 200\n\n    response = api_client.get(\"/api/tasks\")\n    assert response.status_code == 200\n    assert response.json() == []\n\n\ndef test_start_stop_task_updates_status(api_client, api_context, sample_task_payload):\n    response = api_client.post(\"/api/tasks/\", json=sample_task_payload)\n    assert response.status_code == 200\n\n    response = api_client.post(\"/api/tasks/start/0\")\n    assert response.status_code == 200\n\n    response = api_client.get(\"/api/tasks/0\")\n    assert response.status_code == 200\n    assert response.json()[\"is_running\"] is True\n\n    response = api_client.post(\"/api/tasks/stop/0\")\n    assert response.status_code == 200\n\n    response = api_client.get(\"/api/tasks/0\")\n    assert response.status_code == 200\n    assert response.json()[\"is_running\"] is False\n\n    process_service = api_context[\"process_service\"]\n    assert process_service.started == [(0, sample_task_payload[\"task_name\"])]\n    assert process_service.stopped == [0]\n\n\ndef test_generate_keyword_mode_task_without_ai_criteria(api_client):\n    payload = {\n        \"task_name\": \"A7M4 关键词筛选\",\n        \"keyword\": \"sony a7m4\",\n        \"description\": \"\",\n        \"decision_mode\": \"keyword\",\n        \"keyword_rules\": [\"a7m4\", \"验货宝\"],\n        \"max_pages\": 2,\n        \"personal_only\": True,\n    }\n\n    response = api_client.post(\"/api/tasks/generate\", json=payload)\n    assert response.status_code == 200\n    created = response.json()[\"task\"]\n    assert created[\"decision_mode\"] == \"keyword\"\n    assert created[\"ai_prompt_criteria_file\"] == \"\"\n    assert created[\"keyword_rules\"] == [\"a7m4\", \"验货宝\"]\n\n\ndef test_generate_ai_task_returns_job_and_completes_async(api_client, api_context, monkeypatch):\n    payload = {\n        \"task_name\": \"Apple Watch S10\",\n        \"keyword\": \"apple watch s10\",\n        \"description\": \"只看国行蜂窝版，电池健康高于 95%，拒绝维修机。\",\n        \"analyze_images\": False,\n        \"decision_mode\": \"ai\",\n        \"max_pages\": 2,\n        \"personal_only\": True,\n    }\n\n    async def fake_generate_criteria(*_args, **_kwargs):\n        await asyncio.sleep(0.05)\n        return \"[V6.3 核心升级]\\\\nApple Watch criteria\"\n\n    monkeypatch.setattr(\n        \"src.services.task_generation_runner.generate_criteria\",\n        fake_generate_criteria,\n    )\n\n    response = api_client.post(\"/api/tasks/generate\", json=payload)\n\n    assert response.status_code == 202\n    job = response.json()[\"job\"]\n    assert isinstance(job[\"job_id\"], str)\n    assert job[\"status\"] in {\"queued\", \"running\"}\n    assert job[\"task\"] is None\n\n    status_response = api_client.get(f\"/api/tasks/generate-jobs/{job['job_id']}\")\n    assert status_response.status_code == 200\n\n    for _ in range(50):\n        status_response = api_client.get(f\"/api/tasks/generate-jobs/{job['job_id']}\")\n        latest_job = status_response.json()[\"job\"]\n        if latest_job[\"status\"] == \"completed\":\n            break\n        time.sleep(0.02)\n    else:\n        raise AssertionError(\"任务生成作业未在预期时间内完成\")\n\n    assert latest_job[\"task\"][\"task_name\"] == payload[\"task_name\"]\n    assert latest_job[\"task\"][\"ai_prompt_criteria_file\"].endswith(\"_criteria.txt\")\n    assert latest_job[\"task\"][\"analyze_images\"] is False\n    assert api_context[\"scheduler_service\"].reload_calls == 1\n\n\ndef test_create_task_accepts_cron_alias(api_client, sample_task_payload):\n    payload = dict(sample_task_payload)\n    payload[\"cron\"] = \"@daily\"\n\n    response = api_client.post(\"/api/tasks/\", json=payload)\n\n    assert response.status_code == 200\n    assert response.json()[\"task\"][\"cron\"] == \"0 0 * * *\"\n\n\ndef test_create_task_rejects_fixed_account_strategy_without_state_file(api_client, sample_task_payload):\n    payload = dict(sample_task_payload)\n    payload[\"account_strategy\"] = \"fixed\"\n\n    response = api_client.post(\"/api/tasks/\", json=payload)\n\n    assert response.status_code == 422\n\n\ndef test_create_task_accepts_rotate_account_strategy(api_client, sample_task_payload):\n    payload = dict(sample_task_payload)\n    payload[\"account_strategy\"] = \"rotate\"\n\n    response = api_client.post(\"/api/tasks/\", json=payload)\n\n    assert response.status_code == 200\n    task = response.json()[\"task\"]\n    assert task[\"account_strategy\"] == \"rotate\"\n\n\ndef test_update_task_accepts_six_field_cron_expression(api_client, sample_task_payload):\n    create_response = api_client.post(\"/api/tasks/\", json=sample_task_payload)\n    assert create_response.status_code == 200\n\n    response = api_client.patch(\"/api/tasks/0\", json={\"cron\": \"0 0 8 * * *\"})\n\n    assert response.status_code == 200\n\n    task_response = api_client.get(\"/api/tasks/0\")\n    assert task_response.status_code == 200\n    assert task_response.json()[\"cron\"] == \"0 0 8 * * *\"\n\n\ndef test_create_task_rejects_invalid_cron_expression(api_client, sample_task_payload):\n    payload = dict(sample_task_payload)\n    payload[\"cron\"] = \"every day at 8\"\n\n    response = api_client.post(\"/api/tasks/\", json=payload)\n\n    assert response.status_code == 422\n\n\ndef test_delete_task_stops_runtime_and_reindexes_process_state(\n    api_client,\n    api_context,\n    sample_task_payload,\n):\n    second_payload = dict(sample_task_payload)\n    second_payload[\"task_name\"] = \"Sony A7CR\"\n    second_payload[\"keyword\"] = \"sony a7cr\"\n    second_payload[\"ai_prompt_criteria_file\"] = \"prompts/sony_a7cr_criteria.txt\"\n\n    assert api_client.post(\"/api/tasks/\", json=sample_task_payload).status_code == 200\n    assert api_client.post(\"/api/tasks/\", json=second_payload).status_code == 200\n    assert api_client.post(\"/api/tasks/start/0\").status_code == 200\n\n    response = api_client.delete(\"/api/tasks/0\")\n\n    assert response.status_code == 200\n    process_service = api_context[\"process_service\"]\n    assert process_service.stopped == [0]\n    assert process_service.reindexed == []\n"
  },
  {
    "path": "tests/integration/test_cli_spider.py",
    "content": "import asyncio\nimport importlib\nimport json\nimport sys\nimport types\n\n\ndef test_cli_runs_single_task_with_prompt(tmp_path, load_json_fixture, monkeypatch):\n    fake_scraper = types.ModuleType(\"src.scraper\")\n\n    async def placeholder_scrape(task_config, debug_limit):\n        return 0\n\n    fake_scraper.scrape_xianyu = placeholder_scrape\n    monkeypatch.setitem(sys.modules, \"src.scraper\", fake_scraper)\n    sys.modules.pop(\"spider_v2\", None)\n\n    spider_v2 = importlib.import_module(\"spider_v2\")\n    config_data = load_json_fixture(\"config.sample.json\")\n\n    base_prompt = \"Base prompt. \" + (\"x\" * 120) + \" {{CRITERIA_SECTION}}\"\n    criteria_prompt = \"Criteria text for A7M4.\"\n\n    base_path = tmp_path / \"base_prompt.txt\"\n    criteria_path = tmp_path / \"criteria_prompt.txt\"\n    base_path.write_text(base_prompt, encoding=\"utf-8\")\n    criteria_path.write_text(criteria_prompt, encoding=\"utf-8\")\n\n    config_data[0][\"ai_prompt_base_file\"] = str(base_path)\n    config_data[0][\"ai_prompt_criteria_file\"] = str(criteria_path)\n\n    config_data[1][\"ai_prompt_base_file\"] = str(base_path)\n    config_data[1][\"ai_prompt_criteria_file\"] = str(criteria_path)\n\n    config_path = tmp_path / \"config.json\"\n    config_path.write_text(json.dumps(config_data, ensure_ascii=False), encoding=\"utf-8\")\n\n    state_path = tmp_path / \"state.json\"\n    state_path.write_text(\"{}\", encoding=\"utf-8\")\n\n    monkeypatch.setattr(spider_v2, \"STATE_FILE\", str(state_path))\n\n    called = []\n\n    async def fake_scrape_xianyu(task_config, debug_limit):\n        called.append(task_config[\"task_name\"])\n        assert \"{{CRITERIA_SECTION}}\" not in task_config[\"ai_prompt_text\"]\n        assert \"Criteria text for A7M4.\" in task_config[\"ai_prompt_text\"]\n        return 1\n\n    monkeypatch.setattr(spider_v2, \"scrape_xianyu\", fake_scrape_xianyu)\n    monkeypatch.setattr(sys, \"argv\", [\"spider_v2.py\", \"--config\", str(config_path), \"--task-name\", \"Sony A7M4\"])\n\n    asyncio.run(spider_v2.main())\n\n    assert called == [\"Sony A7M4\"]\n\n\ndef test_cli_runs_keyword_mode_without_prompt_files(tmp_path, load_json_fixture, monkeypatch):\n    fake_scraper = types.ModuleType(\"src.scraper\")\n\n    async def placeholder_scrape(task_config, debug_limit):\n        return 0\n\n    fake_scraper.scrape_xianyu = placeholder_scrape\n    monkeypatch.setitem(sys.modules, \"src.scraper\", fake_scraper)\n    sys.modules.pop(\"spider_v2\", None)\n\n    spider_v2 = importlib.import_module(\"spider_v2\")\n    config_data = load_json_fixture(\"config.sample.json\")\n    config_data[0][\"enabled\"] = True\n    config_data[0][\"decision_mode\"] = \"keyword\"\n    config_data[0][\"keyword_rules\"] = [\"a7m4\", \"验货宝\"]\n    config_data[0][\"ai_prompt_base_file\"] = \"missing_base.txt\"\n    config_data[0][\"ai_prompt_criteria_file\"] = \"missing_criteria.txt\"\n\n    config_path = tmp_path / \"config.json\"\n    config_path.write_text(json.dumps(config_data, ensure_ascii=False), encoding=\"utf-8\")\n\n    state_path = tmp_path / \"state.json\"\n    state_path.write_text(\"{}\", encoding=\"utf-8\")\n    monkeypatch.setattr(spider_v2, \"STATE_FILE\", str(state_path))\n\n    captured = []\n\n    async def fake_scrape_xianyu(task_config, debug_limit):\n        captured.append(task_config)\n        return 1\n\n    monkeypatch.setattr(spider_v2, \"scrape_xianyu\", fake_scrape_xianyu)\n    monkeypatch.setattr(sys, \"argv\", [\"spider_v2.py\", \"--config\", str(config_path), \"--task-name\", \"Sony A7M4\"])\n\n    asyncio.run(spider_v2.main())\n\n    assert len(captured) == 1\n    assert captured[0][\"decision_mode\"] == \"keyword\"\n    assert captured[0][\"ai_prompt_text\"] == \"\"\n"
  },
  {
    "path": "tests/integration/test_pipeline_parse.py",
    "content": "import asyncio\n\nfrom src.parsers import (\n    _parse_search_results_json,\n    _parse_user_items_data,\n    calculate_reputation_from_ratings,\n    parse_ratings_data,\n    parse_user_head_data,\n)\n\n\ndef test_parse_search_results(load_json_fixture):\n    raw = load_json_fixture(\"search_results.json\")\n    items = asyncio.run(_parse_search_results_json(raw, source=\"search\"))\n    assert len(items) == 1\n    item = items[0]\n    assert item[\"商品标题\"] == \"Sony A7M4 Body\"\n    assert item[\"当前售价\"].startswith(\"¥\")\n    assert \"包邮\" in item[\"商品标签\"]\n    assert \"验货宝\" in item[\"商品标签\"]\n    assert item[\"商品链接\"].startswith(\"https://www.goofish.com/\")\n\n\ndef test_parse_user_head_and_items(load_json_fixture):\n    head_json = load_json_fixture(\"user_head.json\")\n    items_json = load_json_fixture(\"user_items.json\")\n\n    head = asyncio.run(parse_user_head_data(head_json))\n    assert head[\"卖家昵称\"] == \"seller_01\"\n    assert head[\"卖家收到的评价总数\"] == 88\n\n    items = asyncio.run(_parse_user_items_data(items_json))\n    assert items[0][\"商品状态\"] == \"在售\"\n    assert items[1][\"商品状态\"] == \"已售\"\n\n\ndef test_parse_ratings_and_reputation(load_json_fixture):\n    ratings_json = load_json_fixture(\"ratings.json\")\n    ratings = asyncio.run(parse_ratings_data(ratings_json))\n    assert ratings[0][\"评价类型\"] == \"好评\"\n\n    reputation = asyncio.run(calculate_reputation_from_ratings(ratings_json))\n    assert reputation[\"作为卖家的好评数\"].startswith(\"1/\")\n    assert reputation[\"作为买家的好评数\"].startswith(\"1/\")\n"
  },
  {
    "path": "tests/live/_support.py",
    "content": "from __future__ import annotations\n\nimport os\nimport shutil\nimport socket\nimport subprocess\nimport time\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\nimport requests\nfrom dotenv import dotenv_values\n\n\nDEFAULT_EXPECT_MIN_ITEMS = 1\nDEFAULT_LIVE_DEBUG_LIMIT = 1\nDEFAULT_LIVE_KEYWORD = \"MacBook Pro M2\"\nDEFAULT_LIVE_TIMEOUT_SECONDS = 180\nHEALTH_TIMEOUT_SECONDS = 60\nNOTIFICATION_ENV_KEYS = (\n    \"NTFY_TOPIC_URL\",\n    \"GOTIFY_URL\",\n    \"GOTIFY_TOKEN\",\n    \"BARK_URL\",\n    \"WX_BOT_URL\",\n    \"TELEGRAM_BOT_TOKEN\",\n    \"TELEGRAM_CHAT_ID\",\n    \"TELEGRAM_API_BASE_URL\",\n    \"WEBHOOK_URL\",\n    \"WEBHOOK_METHOD\",\n    \"WEBHOOK_HEADERS\",\n    \"WEBHOOK_CONTENT_TYPE\",\n    \"WEBHOOK_QUERY_PARAMETERS\",\n    \"WEBHOOK_BODY\",\n)\n\n\n@dataclass(frozen=True)\nclass LiveTestSettings:\n    repo_root: Path\n    keyword: str\n    task_name: str\n    expect_min_items: int\n    debug_limit: int\n    timeout_seconds: int\n    enable_task_generation: bool\n    account_source_path: Path\n    ai_test_payload: dict[str, str]\n\n\n@dataclass(frozen=True)\nclass LiveServer:\n    base_url: str\n    workspace: Path\n    server_log_path: Path\n    account_state_file: Path\n    settings: LiveTestSettings\n\n\ndef env_flag(name: str, default: bool = False) -> bool:\n    value = os.getenv(name)\n    if value is None:\n        return default\n    return str(value).strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\n\n\ndef env_int(name: str, default: int) -> int:\n    value = os.getenv(name)\n    if value is None:\n        return default\n    return int(value)\n\n\ndef load_runtime_env(repo_root: Path) -> dict[str, str]:\n    runtime_env = os.environ.copy()\n    env_file = repo_root / \".env\"\n    if not env_file.exists():\n        return runtime_env\n    file_values = {\n        key: value\n        for key, value in dotenv_values(env_file, encoding=\"utf-8\").items()\n        if key and value is not None\n    }\n    file_values.update(runtime_env)\n    return file_values\n\n\ndef build_ai_test_payload(runtime_env: dict[str, str]) -> dict[str, str]:\n    payload = {\n        \"OPENAI_BASE_URL\": runtime_env.get(\"OPENAI_BASE_URL\", \"\"),\n        \"OPENAI_MODEL_NAME\": runtime_env.get(\"OPENAI_MODEL_NAME\", \"\"),\n    }\n    api_key = runtime_env.get(\"OPENAI_API_KEY\")\n    if api_key:\n        payload[\"OPENAI_API_KEY\"] = api_key\n    proxy_url = runtime_env.get(\"PROXY_URL\")\n    if proxy_url:\n        payload[\"PROXY_URL\"] = proxy_url\n    return payload\n\n\ndef resolve_account_source(repo_root: Path) -> Path:\n    configured = os.getenv(\"LIVE_TEST_ACCOUNT_STATE_FILE\")\n    if configured:\n        return Path(configured).expanduser().resolve()\n    state_dir = repo_root / \"state\"\n    candidates = sorted(state_dir.glob(\"*.json\"))\n    if not candidates:\n        raise FileNotFoundError(\n            \"LIVE_TEST_ACCOUNT_STATE_FILE 未设置，且 state/ 下没有可用 JSON 登录态文件。\"\n        )\n    return candidates[0]\n\n\ndef load_live_settings(repo_root: Path) -> LiveTestSettings:\n    runtime_env = load_runtime_env(repo_root)\n    return LiveTestSettings(\n        repo_root=repo_root,\n        keyword=os.getenv(\"LIVE_TEST_KEYWORD\", DEFAULT_LIVE_KEYWORD).strip(),\n        task_name=(os.getenv(\"LIVE_TEST_TASK_NAME\", \"Live Smoke Task\").strip() or \"Live Smoke Task\"),\n        expect_min_items=env_int(\"LIVE_EXPECT_MIN_ITEMS\", DEFAULT_EXPECT_MIN_ITEMS),\n        debug_limit=env_int(\"LIVE_TEST_DEBUG_LIMIT\", DEFAULT_LIVE_DEBUG_LIMIT),\n        timeout_seconds=env_int(\"LIVE_TIMEOUT_SECONDS\", DEFAULT_LIVE_TIMEOUT_SECONDS),\n        enable_task_generation=env_flag(\"LIVE_ENABLE_TASK_GENERATION\"),\n        account_source_path=resolve_account_source(repo_root),\n        ai_test_payload=build_ai_test_payload(runtime_env),\n    )\n\n\ndef mirror_path(source: Path, destination: Path) -> None:\n    if destination.exists() or destination.is_symlink():\n        return\n    try:\n        destination.symlink_to(source, target_is_directory=source.is_dir())\n    except OSError:\n        if source.is_dir():\n            shutil.copytree(source, destination)\n            return\n        shutil.copy2(source, destination)\n\n\ndef prepare_workspace(workspace: Path, settings: LiveTestSettings) -> Path:\n    for name in (\"src\", \"spider_v2.py\", \"static\", \"dist\"):\n        mirror_path(settings.repo_root / name, workspace / name)\n    shutil.copytree(settings.repo_root / \"prompts\", workspace / \"prompts\", dirs_exist_ok=True)\n    state_dir = workspace / \"state\"\n    state_dir.mkdir(parents=True, exist_ok=True)\n    account_target = state_dir / settings.account_source_path.name\n    shutil.copy2(settings.account_source_path, account_target)\n    for name in (\"logs\", \"images\", \"data\"):\n        (workspace / name).mkdir(parents=True, exist_ok=True)\n    return account_target\n\n\ndef build_server_env(workspace: Path, repo_root: Path, port: int) -> dict[str, str]:\n    env = load_runtime_env(repo_root)\n    python_path_parts = [str(repo_root)]\n    if env.get(\"PYTHONPATH\"):\n        python_path_parts.append(env[\"PYTHONPATH\"])\n    debug_limit = str(os.getenv(\"LIVE_TEST_DEBUG_LIMIT\", DEFAULT_LIVE_DEBUG_LIMIT)).strip()\n    env.update(\n        {\n            \"APP_DATABASE_FILE\": str(workspace / \"data\" / \"live.sqlite3\"),\n            \"ACCOUNT_STATE_DIR\": str(workspace / \"state\"),\n            \"RUN_HEADLESS\": \"true\",\n            \"SKIP_AI_ANALYSIS\": \"false\",\n            \"AI_DEBUG_MODE\": \"true\",\n            \"PYTHONUNBUFFERED\": \"1\",\n            \"SERVER_PORT\": str(port),\n            \"SPIDER_DEBUG_LIMIT\": debug_limit,\n            \"PYTHONPATH\": os.pathsep.join(python_path_parts),\n        }\n    )\n    for key in NOTIFICATION_ENV_KEYS:\n        env[key] = \"\"\n    return env\n\n\ndef find_free_port() -> int:\n    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:\n        sock.bind((\"127.0.0.1\", 0))\n        sock.listen(1)\n        return int(sock.getsockname()[1])\n\n\ndef wait_for_server_ready(base_url: str, process: subprocess.Popen, log_path: Path) -> None:\n    deadline = time.monotonic() + HEALTH_TIMEOUT_SECONDS\n    last_error = \"unknown\"\n    while time.monotonic() < deadline:\n        if process.poll() is not None:\n            break\n        try:\n            response = requests.get(f\"{base_url}/health\", timeout=2)\n            if response.status_code == 200:\n                return\n            last_error = f\"health status={response.status_code} body={response.text[:200]}\"\n        except requests.RequestException as exc:\n            last_error = str(exc)\n        time.sleep(1)\n\n    log_excerpt = \"\"\n    if log_path.exists():\n        log_excerpt = log_path.read_text(encoding=\"utf-8\", errors=\"ignore\")[-4000:]\n    raise RuntimeError(\n        \"Live app 未在预期时间内启动。\"\n        f\" last_error={last_error}\\nserver_log={log_path}\\n{log_excerpt}\"\n    )\n\n\ndef terminate_process(process: subprocess.Popen, timeout_seconds: int = 20) -> None:\n    if process.poll() is not None:\n        return\n    process.terminate()\n    try:\n        process.wait(timeout=timeout_seconds)\n    except subprocess.TimeoutExpired:\n        process.kill()\n        process.wait(timeout=5)\n"
  },
  {
    "path": "tests/live/conftest.py",
    "content": "from __future__ import annotations\n\nimport os\nimport shutil\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nimport pytest\n\nfrom tests.live._support import (\n    LiveServer,\n    build_server_env,\n    find_free_port,\n    load_live_settings,\n    prepare_workspace,\n    terminate_process,\n    wait_for_server_ready,\n)\n\n\nLIVE_SKIP_REASON = \"真实流量 live smoke 默认关闭；请显式设置 RUN_LIVE_TESTS=1。\"\n\n\ndef pytest_collection_modifyitems(config, items):\n    if os.environ.get(\"RUN_LIVE_TESTS\") == \"1\":\n        return\n    skip_marker = pytest.mark.skip(reason=LIVE_SKIP_REASON)\n    for item in items:\n        if \"live\" not in item.keywords and \"live_slow\" not in item.keywords:\n            continue\n        item.add_marker(skip_marker)\n\n\n@pytest.fixture(scope=\"session\")\ndef live_settings():\n    if os.environ.get(\"RUN_LIVE_TESTS\") != \"1\":\n        pytest.skip(LIVE_SKIP_REASON)\n    repo_root = Path(__file__).resolve().parents[2]\n    settings = load_live_settings(repo_root)\n    if not settings.account_source_path.exists():\n        pytest.fail(f\"live 登录态文件不存在: {settings.account_source_path}\")\n    if not settings.ai_test_payload.get(\"OPENAI_BASE_URL\") or not settings.ai_test_payload.get(\n        \"OPENAI_MODEL_NAME\"\n    ):\n        pytest.fail(\"live 测试需要 OPENAI_BASE_URL 与 OPENAI_MODEL_NAME。\")\n    return settings\n\n\n@pytest.fixture(scope=\"session\")\ndef live_server(live_settings, request, tmp_path_factory):\n    workspace = tmp_path_factory.mktemp(\"live-smoke\")\n    account_state_file = prepare_workspace(workspace, live_settings)\n    port = find_free_port()\n    base_url = f\"http://127.0.0.1:{port}\"\n    log_path = workspace / \"live-app.log\"\n    env = build_server_env(workspace, live_settings.repo_root, port)\n    log_handle = log_path.open(\"w\", encoding=\"utf-8\")\n    process = subprocess.Popen(\n        [\n            sys.executable,\n            \"-m\",\n            \"uvicorn\",\n            \"src.app:app\",\n            \"--host\",\n            \"127.0.0.1\",\n            \"--port\",\n            str(port),\n        ],\n        cwd=workspace,\n        env=env,\n        stdout=log_handle,\n        stderr=subprocess.STDOUT,\n        text=True,\n    )\n    try:\n        wait_for_server_ready(base_url, process, log_path)\n    except Exception:\n        terminate_process(process)\n        log_handle.close()\n        raise\n\n    server = LiveServer(\n        base_url=base_url,\n        workspace=workspace,\n        server_log_path=log_path,\n        account_state_file=account_state_file,\n        settings=live_settings,\n    )\n    try:\n        yield server\n    finally:\n        terminate_process(process)\n        log_handle.close()\n        if request.session.testsfailed:\n            print(f\"live smoke 失败，保留工作目录供排查: {workspace}\")\n            return\n        shutil.rmtree(workspace, ignore_errors=True)\n"
  },
  {
    "path": "tests/live/test_live_smoke.py",
    "content": "from __future__ import annotations\n\nimport time\nfrom pathlib import Path\n\nimport pytest\nimport requests\n\nfrom src.infrastructure.persistence.storage_names import build_result_filename\n\n\npytestmark = pytest.mark.live\n\nREQUEST_TIMEOUT_SECONDS = 60\nTASK_POLL_INTERVAL_SECONDS = 2\nFORBIDDEN_LOG_MARKERS = (\n    \"Login required\",\n    \"passport.goofish.com\",\n    \"FAIL_SYS_USER_VALIDATE\",\n    \"AI客户端未初始化\",\n    \"未找到可用的登录状态文件\",\n    \"检测到登录失效/重定向\",\n)\n\ndef api_request(session: requests.Session, method: str, url: str, **kwargs) -> requests.Response:\n    kwargs.setdefault(\"timeout\", REQUEST_TIMEOUT_SECONDS)\n    return session.request(method=method, url=url, **kwargs)\n\ndef fetch_task(session: requests.Session, base_url: str, task_id: int) -> dict:\n    response = api_request(session, \"get\", f\"{base_url}/api/tasks/{task_id}\")\n    assert response.status_code == 200, response.text\n    return response.json()\n\ndef fetch_results_or_none(\n    session: requests.Session,\n    base_url: str,\n    filename: str,\n    *,\n    limit: int = 5,\n) -> dict | None:\n    response = api_request(\n        session,\n        \"get\",\n        f\"{base_url}/api/results/{filename}\",\n        params={\"page\": 1, \"limit\": limit},\n    )\n    if response.status_code == 404:\n        return None\n    assert response.status_code == 200, response.text\n    return response.json()\n\ndef find_task_log(workspace: Path, task_id: int) -> Path | None:\n    log_dir = workspace / \"logs\"\n    matches = sorted(log_dir.glob(f\"*_{task_id}.log\"))\n    return matches[0] if matches else None\n\ndef read_task_log(workspace: Path, task_id: int) -> tuple[Path | None, str]:\n    log_path = find_task_log(workspace, task_id)\n    if log_path is None:\n        return None, \"\"\n    return log_path, log_path.read_text(encoding=\"utf-8\", errors=\"ignore\")\n\ndef assert_log_is_clean(log_text: str, log_path: Path | None) -> None:\n    assert log_path is not None, \"live 任务日志不存在。\"\n    for marker in FORBIDDEN_LOG_MARKERS:\n        assert marker not in log_text, f\"日志包含失败标记 {marker}，请检查 {log_path}\"\n\ndef wait_for_task_running(\n    session: requests.Session,\n    base_url: str,\n    task_id: int,\n    timeout_seconds: int,\n) -> dict:\n    deadline = time.monotonic() + timeout_seconds\n    last_task = {}\n    while time.monotonic() < deadline:\n        last_task = fetch_task(session, base_url, task_id)\n        if last_task.get(\"is_running\"):\n            return last_task\n        time.sleep(TASK_POLL_INTERVAL_SECONDS)\n    pytest.fail(f\"任务 {task_id} 未在预期时间内进入运行态: {last_task}\")\n\ndef wait_for_task_completion(\n    session: requests.Session,\n    base_url: str,\n    task_id: int,\n    filename: str,\n    expect_min_items: int,\n    timeout_seconds: int,\n    workspace: Path,\n) -> tuple[dict, dict | None]:\n    deadline = time.monotonic() + timeout_seconds\n    last_task = {}\n    last_results = None\n    stop_sent = False\n    while time.monotonic() < deadline:\n        last_task = fetch_task(session, base_url, task_id)\n        last_results = fetch_results_or_none(session, base_url, filename)\n        if (\n            last_results\n            and last_results.get(\"total_items\", 0) >= expect_min_items\n            and last_task.get(\"is_running\")\n            and not stop_sent\n        ):\n            stop_response = api_request(session, \"post\", f\"{base_url}/api/tasks/stop/{task_id}\")\n            assert stop_response.status_code == 200, stop_response.text\n            stop_sent = True\n        if not last_task.get(\"is_running\"):\n            return last_task, last_results\n        time.sleep(TASK_POLL_INTERVAL_SECONDS)\n\n    log_path, log_text = read_task_log(workspace, task_id)\n    pytest.fail(\n        f\"任务 {task_id} 在 {timeout_seconds}s 内未结束。log={log_path}\\n{log_text[-4000:]}\"\n    )\n\ndef delete_task_safely(session: requests.Session, base_url: str, task_id: int) -> None:\n    response = api_request(session, \"delete\", f\"{base_url}/api/tasks/{task_id}\")\n    assert response.status_code in {200, 404}, response.text\n\ndef build_live_task_payload(account_state_file: Path, task_name: str, keyword: str) -> dict:\n    return {\n        \"task_name\": task_name,\n        \"enabled\": True,\n        \"keyword\": keyword,\n        \"description\": \"Live smoke task for real Goofish traffic and real AI response validation.\",\n        \"analyze_images\": False,\n        \"max_pages\": 1,\n        \"personal_only\": True,\n        \"ai_prompt_base_file\": \"prompts/base_prompt.txt\",\n        \"ai_prompt_criteria_file\": \"prompts/macbook_criteria.txt\",\n        \"account_state_file\": str(account_state_file),\n        \"account_strategy\": \"fixed\",\n        \"decision_mode\": \"ai\",\n    }\n\ndef test_live_preflight_smoke(live_server):\n    with requests.Session() as session:\n        health_response = api_request(session, \"get\", f\"{live_server.base_url}/health\")\n        assert health_response.status_code == 200, health_response.text\n        assert health_response.json()[\"status\"] == \"healthy\"\n\n        ai_response = api_request(\n            session,\n            \"post\",\n            f\"{live_server.base_url}/api/settings/ai/test\",\n            json=live_server.settings.ai_test_payload,\n        )\n        assert ai_response.status_code == 200, ai_response.text\n        ai_result = ai_response.json()\n        assert ai_result[\"success\"] is True, ai_result\n        assert live_server.account_state_file.exists()\n\ndef test_live_real_traffic_task_smoke(live_server):\n    task_name = live_server.settings.task_name\n    keyword = live_server.settings.keyword\n    filename = build_result_filename(keyword)\n    payload = build_live_task_payload(live_server.account_state_file, task_name, keyword)\n\n    with requests.Session() as session:\n        create_response = api_request(\n            session,\n            \"post\",\n            f\"{live_server.base_url}/api/tasks/\",\n            json=payload,\n        )\n        assert create_response.status_code == 200, create_response.text\n        created_task = create_response.json()[\"task\"]\n        task_id = created_task[\"id\"]\n\n        try:\n            start_response = api_request(\n                session,\n                \"post\",\n                f\"{live_server.base_url}/api/tasks/start/{task_id}\",\n            )\n            assert start_response.status_code == 200, start_response.text\n\n            final_task, result_data = wait_for_task_completion(\n                session,\n                live_server.base_url,\n                task_id,\n                filename,\n                live_server.settings.expect_min_items,\n                live_server.settings.timeout_seconds,\n                live_server.workspace,\n            )\n            assert final_task[\"is_running\"] is False\n\n            files_response = api_request(session, \"get\", f\"{live_server.base_url}/api/results/files\")\n            assert files_response.status_code == 200, files_response.text\n            assert filename in files_response.json()[\"files\"]\n\n            if result_data is None:\n                result_data = fetch_results_or_none(session, live_server.base_url, filename)\n            assert result_data is not None, f\"结果文件 {filename} 未生成。\"\n            assert result_data[\"total_items\"] >= live_server.settings.expect_min_items\n\n            item = result_data[\"items\"][0]\n            product = item.get(\"商品信息\", {})\n            analysis = item.get(\"ai_analysis\", {})\n            assert product.get(\"商品标题\"), item\n            assert product.get(\"商品链接\"), item\n            assert product.get(\"当前售价\"), item\n            assert analysis, item\n            assert analysis.get(\"analysis_source\") == \"ai\", item\n\n            log_path, log_text = read_task_log(live_server.workspace, task_id)\n            assert_log_is_clean(log_text, log_path)\n        finally:\n            delete_task_safely(session, live_server.base_url, task_id)\n\n\n@pytest.mark.live_slow\ndef test_live_ai_task_generation_job(live_server):\n    if not live_server.settings.enable_task_generation:\n        pytest.skip(\"未设置 LIVE_ENABLE_TASK_GENERATION=1，跳过真实 AI 任务生成测试。\")\n\n    payload = {\n        \"task_name\": f\"{live_server.settings.task_name} Generated\",\n        \"keyword\": live_server.settings.keyword,\n        \"description\": \"Generate a practical second-hand inspection criteria for live smoke validation.\",\n        \"analyze_images\": False,\n        \"max_pages\": 1,\n        \"personal_only\": True,\n        \"account_state_file\": str(live_server.account_state_file),\n        \"account_strategy\": \"fixed\",\n        \"decision_mode\": \"ai\",\n    }\n\n    with requests.Session() as session:\n        response = api_request(\n            session,\n            \"post\",\n            f\"{live_server.base_url}/api/tasks/generate\",\n            json=payload,\n        )\n        assert response.status_code == 202, response.text\n        job = response.json()[\"job\"]\n        job_id = job[\"job_id\"]\n\n        deadline = time.monotonic() + live_server.settings.timeout_seconds\n        latest_job = job\n        while time.monotonic() < deadline:\n            status_response = api_request(\n                session,\n                \"get\",\n                f\"{live_server.base_url}/api/tasks/generate-jobs/{job_id}\",\n            )\n            assert status_response.status_code == 200, status_response.text\n            latest_job = status_response.json()[\"job\"]\n            if latest_job[\"status\"] == \"completed\":\n                break\n            if latest_job[\"status\"] == \"failed\":\n                pytest.fail(f\"真实 AI 任务生成失败: {latest_job}\")\n            time.sleep(TASK_POLL_INTERVAL_SECONDS)\n        else:\n            pytest.fail(f\"真实 AI 任务生成超时: {latest_job}\")\n\n        task = latest_job[\"task\"]\n        assert task[\"ai_prompt_criteria_file\"]\n        task_id = task[\"id\"]\n\n        try:\n            start_response = api_request(\n                session,\n                \"post\",\n                f\"{live_server.base_url}/api/tasks/start/{task_id}\",\n            )\n            assert start_response.status_code == 200, start_response.text\n            wait_for_task_running(\n                session,\n                live_server.base_url,\n                task_id,\n                timeout_seconds=min(live_server.settings.timeout_seconds, 30),\n            )\n            stop_response = api_request(\n                session,\n                \"post\",\n                f\"{live_server.base_url}/api/tasks/stop/{task_id}\",\n            )\n            assert stop_response.status_code == 200, stop_response.text\n            final_task, _ = wait_for_task_completion(\n                session,\n                live_server.base_url,\n                task_id,\n                build_result_filename(live_server.settings.keyword),\n                expect_min_items=0,\n                timeout_seconds=min(live_server.settings.timeout_seconds, 60),\n                workspace=live_server.workspace,\n            )\n            assert final_task[\"is_running\"] is False\n        finally:\n            delete_task_safely(session, live_server.base_url, task_id)\n"
  },
  {
    "path": "tests/test_failure_guard.py",
    "content": "from __future__ import annotations\n\nfrom datetime import datetime, timedelta\n\nfrom src.failure_guard import FailureGuard\n\n\ndef test_failure_guard_opens_circuit_after_threshold_and_rate_limits(tmp_path):\n    guard_path = tmp_path / \"guard.json\"\n    cookie_path = tmp_path / \"xianyu_state.json\"\n    cookie_path.write_text(\"{}\", encoding=\"utf-8\")\n\n    guard = FailureGuard(\n        path=str(guard_path),\n        threshold=3,\n        pause_seconds=3 * 24 * 60 * 60,\n        tz_name=\"Asia/Shanghai\",\n    )\n\n    base = datetime(2026, 3, 4, 12, 0, 0)\n\n    r1 = guard.record_failure(\"task-a\", \"err-1\", cookie_path=str(cookie_path), now=base)\n    assert r1[\"should_notify\"] is False\n    assert r1[\"opened_circuit\"] is False\n\n    r2 = guard.record_failure(\"task-a\", \"err-2\", cookie_path=str(cookie_path), now=base)\n    assert r2[\"should_notify\"] is False\n    assert r2[\"opened_circuit\"] is False\n\n    r3 = guard.record_failure(\"task-a\", \"err-3\", cookie_path=str(cookie_path), now=base)\n    assert r3[\"should_notify\"] is True\n    assert r3[\"opened_circuit\"] is True\n    assert r3[\"paused_until\"] is not None\n\n    d0 = guard.should_skip_start(\"task-a\", cookie_path=str(cookie_path), now=base)\n    assert d0.skip is True\n    assert d0.should_notify is False\n\n    next_day = base + timedelta(days=1, minutes=1)\n    d1 = guard.should_skip_start(\"task-a\", cookie_path=str(cookie_path), now=next_day)\n    assert d1.skip is True\n    assert d1.should_notify is True\n\n    d1b = guard.should_skip_start(\"task-a\", cookie_path=str(cookie_path), now=next_day)\n    assert d1b.skip is True\n    assert d1b.should_notify is False\n\n\ndef test_failure_guard_auto_recovers_on_cookie_change(tmp_path):\n    guard_path = tmp_path / \"guard.json\"\n    cookie_path = tmp_path / \"xianyu_state.json\"\n    cookie_path.write_text(\"{}\", encoding=\"utf-8\")\n\n    guard = FailureGuard(\n        path=str(guard_path),\n        threshold=2,\n        pause_seconds=3 * 24 * 60 * 60,\n        tz_name=\"Asia/Shanghai\",\n    )\n\n    base = datetime(2026, 3, 4, 12, 0, 0)\n\n    guard.record_failure(\"task-a\", \"err-1\", cookie_path=str(cookie_path), now=base)\n    guard.record_failure(\"task-a\", \"err-2\", cookie_path=str(cookie_path), now=base)\n\n    paused = guard.should_skip_start(\"task-a\", cookie_path=str(cookie_path), now=base)\n    assert paused.skip is True\n\n    cookie_path.write_text('{\"updated\": true}', encoding=\"utf-8\")\n\n    recovered = guard.should_skip_start(\n        \"task-a\",\n        cookie_path=str(cookie_path),\n        now=base + timedelta(minutes=1),\n    )\n    assert recovered.skip is False\n"
  },
  {
    "path": "tests/test_frontend_build_paths.py",
    "content": "from __future__ import annotations\n\nfrom pathlib import Path\n\n\nREPO_ROOT = Path(__file__).resolve().parents[1]\nROOT_DIST = \"/dist\"\n\n\ndef read_repo_file(relative_path: str) -> str:\n    return (REPO_ROOT / relative_path).read_text(encoding=\"utf-8\")\n\n\ndef test_frontend_build_output_path_is_consistent_across_configs():\n    vite_config = read_repo_file(\"web-ui/vite.config.ts\")\n    dockerfile = read_repo_file(\"Dockerfile\")\n    frontend_dockerfile = read_repo_file(\"web-ui/Dockerfile\")\n    dockerignore = read_repo_file(\".dockerignore\")\n    start_script = read_repo_file(\"start.sh\")\n    dockerignore_lines = dockerignore.splitlines()\n\n    assert \"path.resolve(__dirname, '../dist')\" in vite_config\n    assert (\n        f\"COPY --from=frontend-builder {ROOT_DIST} /app/dist\" in dockerfile\n    ), \"Docker multi-stage copy must use the Vite build output path.\"\n    assert (\n        f\"COPY --from=builder {ROOT_DIST} /usr/share/nginx/html\"\n        in frontend_dockerfile\n    ), \"Frontend-only Docker build must use the Vite build output path.\"\n    assert \"dist/\" in dockerignore_lines\n    assert \"web-ui/dist\" not in dockerignore_lines\n    assert '[ ! -d \"dist\" ]' in start_script\n    assert \"cp -r web-ui/dist ./\" not in start_script\n"
  },
  {
    "path": "tests/unit/test_ai_client.py",
    "content": "import asyncio\nfrom types import SimpleNamespace\n\nimport pytest\n\nfrom src.infrastructure.external.ai_client import AIClient\nfrom src.services.ai_request_compat import build_responses_input\n\n\ndef _build_fake_client(responses_create_impl, chat_create_impl=None):\n    responses = SimpleNamespace(create=responses_create_impl)\n    chat = SimpleNamespace(\n        completions=SimpleNamespace(create=chat_create_impl or responses_create_impl)\n    )\n    return SimpleNamespace(responses=responses, chat=chat)\n\n\ndef test_build_messages_without_images_uses_text_only_content():\n    client = AIClient.__new__(AIClient)\n\n    messages = client._build_messages(\n        {\"商品信息\": {\"商品标题\": \"MacBook Pro M2\"}, \"卖家信息\": {\"卖家信用等级\": \"优秀\"}},\n        [],\n        \"只分析文字描述和卖家资质。\",\n    )\n\n    content = messages[0][\"content\"]\n    assert isinstance(content, str)\n    assert \"MacBook Pro M2\" in content\n    assert \"未提供商品图片\" in content\n\n\ndef test_build_messages_with_images_uses_multimodal_content(monkeypatch):\n    client = AIClient.__new__(AIClient)\n    monkeypatch.setattr(AIClient, \"encode_image\", staticmethod(lambda _path: \"ZmFrZQ==\"))\n\n    messages = client._build_messages(\n        {\"商品信息\": {\"商品标题\": \"MacBook Pro M2\"}},\n        [\"fake-image.jpg\"],\n        \"结合图片和文字综合判断。\",\n    )\n\n    content = messages[0][\"content\"]\n    assert isinstance(content, list)\n    assert content[0][\"type\"] == \"image_url\"\n    assert content[-1][\"type\"] == \"text\"\n\n\ndef test_build_responses_input_converts_multimodal_messages():\n    result = build_responses_input(\n        [\n            {\n                \"role\": \"user\",\n                \"content\": [\n                    {\"type\": \"image_url\", \"image_url\": {\"url\": \"data:image/jpeg;base64,ZmFrZQ==\"}},\n                    {\"type\": \"text\", \"text\": \"hello\"},\n                ],\n            }\n        ]\n    )\n\n    assert result == [\n        {\n            \"role\": \"user\",\n            \"content\": [\n                {\n                    \"type\": \"input_image\",\n                    \"image_url\": \"data:image/jpeg;base64,ZmFrZQ==\",\n                    \"detail\": \"auto\",\n                },\n                {\"type\": \"input_text\", \"text\": \"hello\"},\n            ],\n        }\n    ]\n\n\ndef test_call_ai_retries_without_structured_output_when_model_rejects_it():\n    client = AIClient.__new__(AIClient)\n    client.settings = SimpleNamespace(\n        model_name=\"fake-model\",\n        enable_response_format=True,\n        enable_thinking=False,\n    )\n    request_history = []\n\n    async def fake_create(**kwargs):\n        request_history.append(kwargs)\n        if len(request_history) == 1:\n            raise Exception(\n                \"Error code: 400 - {'error': {'code': 'InvalidParameter', \"\n                \"'message': 'The parameter `response_format.type` specified in \"\n                \"the request are not valid: `json_object` is not supported by \"\n                \"this model.', 'param': 'response_format.type'}}\"\n            )\n        return SimpleNamespace(\n            choices=[\n                SimpleNamespace(\n                    message=SimpleNamespace(content='{\"ok\":true}')\n                )\n            ]\n        )\n\n    client.client = _build_fake_client(fake_create)\n\n    response = asyncio.run(client._call_ai([{\"role\": \"user\", \"content\": \"hi\"}]))\n\n    assert response == '{\"ok\":true}'\n    assert request_history[0][\"messages\"][0][\"content\"] == \"hi\"\n    assert request_history[0][\"response_format\"][\"type\"] == \"json_object\"\n    assert \"response_format\" not in request_history[1]\n\n\ndef test_call_ai_falls_back_to_responses_when_chat_completions_api_is_missing():\n    client = AIClient.__new__(AIClient)\n    client.settings = SimpleNamespace(\n        model_name=\"fake-model\",\n        enable_response_format=True,\n        enable_thinking=False,\n    )\n    request_history = []\n\n    async def fake_chat_create(**kwargs):\n        request_history.append((\"chat\", kwargs))\n        raise Exception(\"Error code: 404 - page not found\")\n\n    async def fake_responses_create(**kwargs):\n        request_history.append((\"responses\", kwargs))\n        if len([item for item in request_history if item[0] == \"responses\"]) == 1:\n            raise Exception(\n                \"Error code: 400 - {'error': {'code': 'InvalidParameter', \"\n                \"'message': 'The parameter `text.format.type` specified in \"\n                \"the request are not valid: `json_object` is not supported by \"\n                \"this model.', 'param': 'text.format.type'}}\"\n            )\n        return SimpleNamespace(output_text='{\"ok\":true}')\n\n    client.client = _build_fake_client(fake_responses_create, fake_chat_create)\n\n    response = asyncio.run(client._call_ai([{\"role\": \"user\", \"content\": \"hi\"}]))\n\n    assert response == '{\"ok\":true}'\n    assert request_history[0][0] == \"chat\"\n    assert request_history[1][0] == \"responses\"\n    assert request_history[1][1][\"text\"][\"format\"][\"type\"] == \"json_object\"\n    assert request_history[2][0] == \"responses\"\n    assert \"text\" not in request_history[2][1]\n\n\ndef test_call_ai_retries_without_temperature_when_gateway_rejects_it():\n    client = AIClient.__new__(AIClient)\n    client.settings = SimpleNamespace(\n        model_name=\"fake-model\",\n        enable_response_format=False,\n        enable_thinking=False,\n    )\n    request_history = []\n\n    async def fake_create(**kwargs):\n        request_history.append(kwargs)\n        if len(request_history) == 1:\n            raise Exception(\"temperature is not supported by this gateway\")\n        return SimpleNamespace(\n            choices=[\n                SimpleNamespace(\n                    message=SimpleNamespace(content='{\"ok\":true}')\n                )\n            ]\n        )\n\n    client.client = _build_fake_client(fake_create)\n\n    response = asyncio.run(client._call_ai([{\"role\": \"user\", \"content\": \"hi\"}]))\n\n    assert response == '{\"ok\":true}'\n    assert request_history[0][\"temperature\"] == 0.1\n    assert \"temperature\" not in request_history[1]\n\n\ndef test_call_ai_retries_when_response_content_is_empty():\n    client = AIClient.__new__(AIClient)\n    client.settings = SimpleNamespace(\n        model_name=\"fake-model\",\n        enable_response_format=False,\n        enable_thinking=False,\n    )\n    request_history = []\n\n    async def fake_create(**kwargs):\n        request_history.append(kwargs)\n        if len(request_history) < 4:\n            return SimpleNamespace(output_text=\"\")\n        return SimpleNamespace(output_text='{\"ok\":true}')\n\n    client.client = _build_fake_client(fake_create)\n\n    response = asyncio.run(client._call_ai([{\"role\": \"user\", \"content\": \"hi\"}]))\n\n    assert response == '{\"ok\":true}'\n    assert len(request_history) == 4\n\n\ndef test_call_ai_raises_after_all_empty_response_retries_are_exhausted():\n    client = AIClient.__new__(AIClient)\n    client.settings = SimpleNamespace(\n        model_name=\"fake-model\",\n        enable_response_format=False,\n        enable_thinking=False,\n    )\n    request_history = []\n\n    async def fake_create(**kwargs):\n        request_history.append(kwargs)\n        return SimpleNamespace(output_text=\"\")\n\n    client.client = _build_fake_client(fake_create)\n\n    with pytest.raises(ValueError, match=\"AI响应内容为空\"):\n        asyncio.run(client._call_ai([{\"role\": \"user\", \"content\": \"hi\"}]))\n\n    assert len(request_history) == 4\n\n\ndef test_close_closes_underlying_async_client_and_clears_reference():\n    client = AIClient.__new__(AIClient)\n    close_state = {\"closed\": False}\n\n    async def fake_close():\n        close_state[\"closed\"] = True\n\n    client.client = SimpleNamespace(close=fake_close)\n\n    asyncio.run(client.close())\n\n    assert close_state[\"closed\"] is True\n    assert client.client is None\n\n\ndef test_parse_response_uses_first_json_object_when_response_contains_multiple_objects():\n    client = AIClient.__new__(AIClient)\n\n    result = client._parse_response(\"\"\"```json\n{\"ok\": true, \"reason\": \"first\"}\n{\"ok\": false, \"reason\": \"second\"}\n```\"\"\")\n\n    assert result == {\"ok\": True, \"reason\": \"first\"}\n"
  },
  {
    "path": "tests/unit/test_ai_handler_analysis.py",
    "content": "import asyncio\nfrom types import SimpleNamespace\n\nimport pytest\n\nimport src.ai_handler as ai_handler\nimport src.config as app_config\n\n\ndef _build_fake_client(responses_create_impl, chat_create_impl=None):\n    responses = SimpleNamespace(create=responses_create_impl)\n    chat = SimpleNamespace(\n        completions=SimpleNamespace(create=chat_create_impl or responses_create_impl)\n    )\n    return SimpleNamespace(responses=responses, chat=chat)\n\n\ndef test_get_ai_analysis_stops_after_internal_retries_when_content_is_none(\n    monkeypatch, tmp_path\n):\n    monkeypatch.chdir(tmp_path)\n    call_count = {\"value\": 0}\n\n    async def fake_create(**_kwargs):\n        call_count[\"value\"] += 1\n        return SimpleNamespace(output_text=\"\")\n\n    monkeypatch.setattr(ai_handler, \"client\", _build_fake_client(fake_create))\n    monkeypatch.setattr(ai_handler, \"MODEL_NAME\", \"fake-model\")\n    monkeypatch.setattr(ai_handler, \"ENABLE_RESPONSE_FORMAT\", True)\n    monkeypatch.setattr(app_config, \"ENABLE_RESPONSE_FORMAT\", True)\n\n    with pytest.raises(ValueError, match=\"AI响应内容为空\"):\n        asyncio.run(\n            ai_handler.get_ai_analysis(\n                {\"商品信息\": {\"商品ID\": \"1\", \"商品标题\": \"测试商品\"}},\n                image_paths=[],\n                prompt_text=\"请输出 JSON\",\n            )\n        )\n\n    assert call_count[\"value\"] == 4\n\n\ndef test_get_ai_analysis_returns_parsed_json(monkeypatch, tmp_path):\n    monkeypatch.chdir(tmp_path)\n    call_count = {\"value\": 0}\n\n    async def fake_create(**_kwargs):\n        call_count[\"value\"] += 1\n        return SimpleNamespace(\n            output_text=(\n                '{\"prompt_version\":\"v1\",\"is_recommended\":true,'\n                '\"reason\":\"ok\",\"risk_tags\":[],\"criteria_analysis\":{\"seller_type\":\"个人\"}}'\n            )\n        )\n\n    monkeypatch.setattr(ai_handler, \"client\", _build_fake_client(fake_create))\n    monkeypatch.setattr(ai_handler, \"MODEL_NAME\", \"fake-model\")\n    monkeypatch.setattr(ai_handler, \"ENABLE_RESPONSE_FORMAT\", True)\n    monkeypatch.setattr(app_config, \"ENABLE_RESPONSE_FORMAT\", True)\n\n    result = asyncio.run(\n        ai_handler.get_ai_analysis(\n            {\"商品信息\": {\"商品ID\": \"2\", \"商品标题\": \"测试商品2\"}},\n            image_paths=[],\n            prompt_text=\"请输出 JSON\",\n        )\n    )\n\n    assert result[\"is_recommended\"] is True\n    assert call_count[\"value\"] == 1\n\n\ndef test_get_ai_analysis_retries_without_structured_output_when_model_rejects_it(\n    monkeypatch, tmp_path\n):\n    monkeypatch.chdir(tmp_path)\n    request_history = []\n\n    async def fake_create(**kwargs):\n        request_history.append(kwargs)\n        if len(request_history) == 1:\n            raise Exception(\n                \"Error code: 400 - {'error': {'code': 'InvalidParameter', \"\n                \"'message': 'The parameter `response_format.type` specified in \"\n                \"the request are not valid: `json_object` is not supported by \"\n                \"this model.', 'param': 'response_format.type'}}\"\n            )\n        return SimpleNamespace(\n            choices=[\n                SimpleNamespace(\n                    message=SimpleNamespace(\n                        content=(\n                            '{\"prompt_version\":\"v1\",\"is_recommended\":true,'\n                            '\"reason\":\"ok\",\"risk_tags\":[],\"criteria_analysis\":{\"seller_type\":\"个人\"}}'\n                        )\n                    )\n                )\n            ]\n        )\n\n    monkeypatch.setattr(ai_handler, \"client\", _build_fake_client(fake_create))\n    monkeypatch.setattr(ai_handler, \"MODEL_NAME\", \"fake-model\")\n    monkeypatch.setattr(ai_handler, \"ENABLE_RESPONSE_FORMAT\", True)\n    monkeypatch.setattr(app_config, \"ENABLE_RESPONSE_FORMAT\", True)\n\n    result = asyncio.run(\n        ai_handler.get_ai_analysis(\n            {\"商品信息\": {\"商品ID\": \"3\", \"商品标题\": \"测试商品3\"}},\n            image_paths=[],\n            prompt_text=\"请输出 JSON\",\n        )\n    )\n\n    assert result[\"reason\"] == \"ok\"\n    assert request_history[0][\"messages\"][0][\"role\"] == \"user\"\n    assert request_history[0][\"response_format\"][\"type\"] == \"json_object\"\n    assert \"response_format\" not in request_history[1]\n    assert ai_handler.ENABLE_RESPONSE_FORMAT is True\n\n\ndef test_get_ai_analysis_falls_back_to_responses_when_chat_completions_api_is_missing(\n    monkeypatch, tmp_path\n):\n    monkeypatch.chdir(tmp_path)\n    request_history = []\n\n    async def fake_chat_create(**kwargs):\n        request_history.append((\"chat\", kwargs))\n        raise Exception(\"Error code: 404 - page not found\")\n\n    async def fake_responses_create(**kwargs):\n        request_history.append((\"responses\", kwargs))\n        if len([item for item in request_history if item[0] == \"responses\"]) == 1:\n            raise Exception(\n                \"Error code: 400 - {'error': {'code': 'InvalidParameter', \"\n                \"'message': 'The parameter `text.format.type` specified in \"\n                \"the request are not valid: `json_object` is not supported by \"\n                \"this model.', 'param': 'text.format.type'}}\"\n            )\n        return SimpleNamespace(\n            output_text=(\n                '{\"prompt_version\":\"v1\",\"is_recommended\":true,'\n                '\"reason\":\"ok\",\"risk_tags\":[],\"criteria_analysis\":{\"seller_type\":\"个人\"}}'\n            )\n        )\n\n    monkeypatch.setattr(\n        ai_handler,\n        \"client\",\n        _build_fake_client(fake_responses_create, fake_chat_create),\n    )\n    monkeypatch.setattr(ai_handler, \"MODEL_NAME\", \"fake-model\")\n    monkeypatch.setattr(ai_handler, \"ENABLE_RESPONSE_FORMAT\", True)\n    monkeypatch.setattr(app_config, \"ENABLE_RESPONSE_FORMAT\", True)\n\n    result = asyncio.run(\n        ai_handler.get_ai_analysis(\n            {\"商品信息\": {\"商品ID\": \"4\", \"商品标题\": \"测试商品4\"}},\n            image_paths=[],\n            prompt_text=\"请输出 JSON\",\n        )\n    )\n\n    assert result[\"reason\"] == \"ok\"\n    assert request_history[0][0] == \"chat\"\n    assert request_history[0][1][\"messages\"][0][\"role\"] == \"user\"\n    assert request_history[1][0] == \"responses\"\n    assert request_history[1][1][\"text\"][\"format\"][\"type\"] == \"json_object\"\n    assert request_history[2][0] == \"responses\"\n    assert \"text\" not in request_history[2][1]\n\n\ndef test_get_ai_analysis_retries_without_temperature_when_gateway_rejects_it(\n    monkeypatch, tmp_path\n):\n    monkeypatch.chdir(tmp_path)\n    request_history = []\n\n    async def fake_create(**kwargs):\n        request_history.append(kwargs)\n        if len(request_history) == 1:\n            raise Exception(\"temperature is unsupported for this model\")\n        return SimpleNamespace(\n            choices=[\n                SimpleNamespace(\n                    message=SimpleNamespace(\n                        content=(\n                            '{\"prompt_version\":\"v1\",\"is_recommended\":true,'\n                            '\"reason\":\"ok\",\"risk_tags\":[],\"criteria_analysis\":{\"seller_type\":\"个人\"}}'\n                        )\n                    )\n                )\n            ]\n        )\n\n    monkeypatch.setattr(ai_handler, \"client\", _build_fake_client(fake_create))\n    monkeypatch.setattr(ai_handler, \"MODEL_NAME\", \"fake-model\")\n    monkeypatch.setattr(ai_handler, \"ENABLE_RESPONSE_FORMAT\", True)\n    monkeypatch.setattr(app_config, \"ENABLE_RESPONSE_FORMAT\", True)\n\n    result = asyncio.run(\n        ai_handler.get_ai_analysis(\n            {\"商品信息\": {\"商品ID\": \"4\", \"商品标题\": \"测试商品4\"}},\n            image_paths=[],\n            prompt_text=\"请输出 JSON\",\n        )\n    )\n\n    assert result[\"reason\"] == \"ok\"\n    assert request_history[0][\"temperature\"] == 0.1\n    assert \"temperature\" not in request_history[1]\n\n\ndef test_get_ai_analysis_uses_first_json_object_when_model_returns_multiple_objects(\n    monkeypatch, tmp_path\n):\n    monkeypatch.chdir(tmp_path)\n\n    async def fake_create(**_kwargs):\n        return SimpleNamespace(\n            output_text=\"\"\"```json\n{\"prompt_version\":\"v1\",\"is_recommended\":true,\"reason\":\"first\",\"risk_tags\":[],\"criteria_analysis\":{\"seller_type\":\"个人\"}}\n{\"prompt_version\":\"v1\",\"is_recommended\":false,\"reason\":\"second\",\"risk_tags\":[],\"criteria_analysis\":{\"seller_type\":\"商家\"}}\n```\"\"\"\n        )\n\n    monkeypatch.setattr(ai_handler, \"client\", _build_fake_client(fake_create))\n    monkeypatch.setattr(ai_handler, \"MODEL_NAME\", \"fake-model\")\n    monkeypatch.setattr(ai_handler, \"ENABLE_RESPONSE_FORMAT\", True)\n    monkeypatch.setattr(app_config, \"ENABLE_RESPONSE_FORMAT\", True)\n\n    result = asyncio.run(\n        ai_handler.get_ai_analysis(\n            {\"商品信息\": {\"商品ID\": \"5\", \"商品标题\": \"测试商品5\"}},\n            image_paths=[],\n            prompt_text=\"请输出 JSON\",\n        )\n    )\n\n    assert result[\"is_recommended\"] is True\n    assert result[\"reason\"] == \"first\"\n"
  },
  {
    "path": "tests/unit/test_ai_handler_downloads.py",
    "content": "import asyncio\nfrom pathlib import Path\n\nimport src.ai_handler as ai_handler\n\n\ndef test_download_all_images_runs_with_concurrency(tmp_path, monkeypatch):\n    monkeypatch.setattr(ai_handler, \"IMAGE_SAVE_DIR\", str(tmp_path / \"images\"))\n\n    active_downloads = 0\n    max_active_downloads = 0\n\n    async def fake_download(url, save_path):\n        nonlocal active_downloads, max_active_downloads\n        active_downloads += 1\n        max_active_downloads = max(max_active_downloads, active_downloads)\n        await asyncio.sleep(0.02)\n        Path(save_path).parent.mkdir(parents=True, exist_ok=True)\n        Path(save_path).write_text(\"ok\", encoding=\"utf-8\")\n        active_downloads -= 1\n        return save_path\n\n    monkeypatch.setattr(ai_handler, \"_download_single_image\", fake_download)\n\n    async def run():\n        return await ai_handler.download_all_images(\n            \"product-1\",\n            [\n                \"https://example.com/1.jpg\",\n                \"https://example.com/2.jpg\",\n                \"https://example.com/3.jpg\",\n            ],\n            task_name=\"demo\",\n            concurrency=3,\n        )\n\n    paths = asyncio.run(run())\n    assert len(paths) == 3\n    assert max_active_downloads == 3\n"
  },
  {
    "path": "tests/unit/test_ai_request_compat.py",
    "content": "from src.services.ai_request_compat import (\n    is_responses_api_unsupported_error,\n    is_temperature_unsupported_error,\n    remove_temperature_param,\n)\n\n\ndef test_is_temperature_unsupported_error_detects_unsupported_message():\n    err = Exception(\"temperature is not supported by this gateway\")\n    assert is_temperature_unsupported_error(err) is True\n\n\ndef test_remove_temperature_param_removes_only_temperature():\n    params = {\"model\": \"x\", \"temperature\": 0.5, \"max_output_tokens\": 128}\n    result = remove_temperature_param(params)\n\n    assert \"temperature\" not in result\n    assert result[\"model\"] == \"x\"\n    assert result[\"max_output_tokens\"] == 128\n\n\ndef test_is_responses_api_unsupported_error_detects_gemini_plain_404():\n    class _Resp:\n        text = \"\"\n\n    class _Err(Exception):\n        status_code = 404\n        body = \"\"\n        response = _Resp()\n\n        def __str__(self):\n            return \"Error code: 404\"\n\n    assert is_responses_api_unsupported_error(_Err()) is True\n"
  },
  {
    "path": "tests/unit/test_ai_response_parser.py",
    "content": "import pytest\n\nfrom src.services.ai_response_parser import parse_ai_response_json\n\n\ndef test_parse_ai_response_json_uses_first_object_when_multiple_json_objects_are_concatenated():\n    content = \"\"\"```json\n{\"is_recommended\": true, \"reason\": \"first\"}\n{\"is_recommended\": false, \"reason\": \"second\"}\n```\"\"\"\n\n    result = parse_ai_response_json(content)\n\n    assert result == {\"is_recommended\": True, \"reason\": \"first\"}\n\n\ndef test_parse_ai_response_json_extracts_json_from_wrapped_text():\n    content = \"\"\"分析结果如下：\n\n```json\n{\"is_recommended\": true, \"reason\": \"wrapped\"}\n```\n\n请按第一份结果处理。\"\"\"\n\n    result = parse_ai_response_json(content)\n\n    assert result == {\"is_recommended\": True, \"reason\": \"wrapped\"}\n\n\ndef test_parse_ai_response_json_raises_when_no_json_exists():\n    with pytest.raises(ValueError):\n        parse_ai_response_json(\"没有任何 JSON 内容\")\n"
  },
  {
    "path": "tests/unit/test_app_lifespan.py",
    "content": "import asyncio\n\nimport src.app as app_module\n\n\nclass _FakeTaskService:\n    def __init__(self, _repo):\n        self.updated = []\n\n    async def get_all_tasks(self):\n        return []\n\n    async def update_task_status(self, task_id, is_running):\n        self.updated.append((task_id, is_running))\n\n\nclass _FakeSchedulerService:\n    def __init__(self):\n        self.started = False\n        self.stopped = False\n        self.reload_payload = None\n\n    async def reload_jobs(self, tasks):\n        self.reload_payload = list(tasks)\n\n    def start(self):\n        self.started = True\n\n    def stop(self):\n        self.stopped = True\n\n\nclass _FakeProcessService:\n    def __init__(self):\n        self.stop_all_called = False\n\n    async def stop_all(self):\n        self.stop_all_called = True\n\n\ndef test_lifespan_cleans_task_logs_on_startup(monkeypatch):\n    called = {}\n    fake_scheduler = _FakeSchedulerService()\n    fake_process = _FakeProcessService()\n\n    monkeypatch.setattr(app_module, \"scheduler_service\", fake_scheduler)\n    monkeypatch.setattr(app_module, \"process_service\", fake_process)\n    monkeypatch.setattr(app_module, \"TaskService\", _FakeTaskService)\n    monkeypatch.setattr(app_module, \"SqliteTaskRepository\", lambda: object())\n    monkeypatch.setattr(app_module, \"bootstrap_sqlite_storage\", lambda: called.setdefault(\"bootstrapped\", True))\n    monkeypatch.setattr(\n        app_module,\n        \"cleanup_task_logs\",\n        lambda *args, **kwargs: called.setdefault(\"keep_days\", kwargs.get(\"keep_days\")),\n    )\n    monkeypatch.setattr(app_module.app_settings, \"task_log_retention_days\", 9)\n\n    async def _run():\n        async with app_module.lifespan(None):\n            assert fake_scheduler.started is True\n            assert fake_scheduler.reload_payload == []\n\n    asyncio.run(_run())\n\n    assert called[\"bootstrapped\"] is True\n    assert called[\"keep_days\"] == 9\n    assert fake_scheduler.stopped is True\n    assert fake_process.stop_all_called is True\n"
  },
  {
    "path": "tests/unit/test_cron_utils.py",
    "content": "from src.core.cron_utils import build_cron_trigger, validate_cron_expression\n\n\ndef test_validate_cron_expression_normalizes_alias():\n    assert validate_cron_expression(\"@daily\") == \"0 0 * * *\"\n\n\ndef test_validate_cron_expression_accepts_six_fields():\n    assert validate_cron_expression(\"0 0 8 * * *\") == \"0 0 8 * * *\"\n\n\ndef test_build_cron_trigger_accepts_alias_and_timezone():\n    trigger = build_cron_trigger(\"@hourly\", timezone=\"Asia/Shanghai\")\n\n    assert trigger is not None\n    assert str(trigger.timezone) == \"Asia/Shanghai\"\n\n\ndef test_validate_cron_expression_rejects_invalid_value():\n    try:\n        validate_cron_expression(\"not-a-cron\")\n    except ValueError as exc:\n        assert \"支持 5 段\" in str(exc)\n        return\n\n    raise AssertionError(\"非法 cron 应该抛出 ValueError\")\n"
  },
  {
    "path": "tests/unit/test_domain_task.py",
    "content": "from src.domain.models.task import Task, TaskGenerateRequest, TaskUpdate\n\n\ndef test_task_can_start_and_stop():\n    task = Task(\n        id=1,\n        task_name=\"Sony A7M4\",\n        enabled=True,\n        keyword=\"sony a7m4\",\n        description=\"body\",\n        max_pages=2,\n        personal_only=True,\n        min_price=None,\n        max_price=None,\n        cron=None,\n        ai_prompt_base_file=\"prompts/base_prompt.txt\",\n        ai_prompt_criteria_file=\"prompts/sony_a7m4_criteria.txt\",\n        is_running=False,\n    )\n\n    assert task.can_start() is True\n    assert task.can_stop() is False\n\n    running = task.model_copy(update={\"is_running\": True})\n    assert running.can_start() is False\n    assert running.can_stop() is True\n\n\ndef test_task_apply_update():\n    task = Task(\n        id=1,\n        task_name=\"Sony A7M4\",\n        enabled=True,\n        keyword=\"sony a7m4\",\n        description=\"body\",\n        max_pages=2,\n        personal_only=True,\n        min_price=None,\n        max_price=None,\n        cron=None,\n        ai_prompt_base_file=\"prompts/base_prompt.txt\",\n        ai_prompt_criteria_file=\"prompts/sony_a7m4_criteria.txt\",\n        is_running=False,\n    )\n\n    update = TaskUpdate(enabled=False, max_pages=5)\n    updated = task.apply_update(update)\n\n    assert updated.enabled is False\n    assert updated.max_pages == 5\n    assert updated.task_name == task.task_name\n\n\ndef test_legacy_keyword_groups_are_flattened_to_keyword_rules():\n    task = Task(\n        id=1,\n        task_name=\"Sony A7M4\",\n        enabled=True,\n        keyword=\"sony a7m4\",\n        description=\"body\",\n        max_pages=2,\n        personal_only=True,\n        min_price=None,\n        max_price=None,\n        cron=None,\n        ai_prompt_base_file=\"prompts/base_prompt.txt\",\n        ai_prompt_criteria_file=\"prompts/sony_a7m4_criteria.txt\",\n        decision_mode=\"keyword\",\n        keyword_rule_groups=[\n            {\"name\": \"组1\", \"include_keywords\": [\"a7m4\", \"验货宝\"], \"exclude_keywords\": [\"瑕疵\"]},\n            {\"name\": \"组2\", \"include_keywords\": [\"全画幅\", \"a7m4\"], \"exclude_keywords\": [\"拆修\"]},\n        ],\n        is_running=False,\n    )\n\n    assert task.keyword_rules == [\"a7m4\", \"验货宝\", \"全画幅\"]\n\n\ndef test_generate_request_accepts_legacy_group_payload():\n    req = TaskGenerateRequest(\n        task_name=\"legacy\",\n        keyword=\"sony a7m4\",\n        description=\"\",\n        decision_mode=\"keyword\",\n        keyword_rule_groups=[{\"include_keywords\": [\"a7m4\", \"验货宝\"], \"exclude_keywords\": [\"瑕疵\"]}],\n    )\n    assert req.keyword_rules == [\"a7m4\", \"验货宝\"]\n\n\ndef test_generate_request_enables_image_analysis_by_default():\n    req = TaskGenerateRequest(\n        task_name=\"Sony A7M4\",\n        keyword=\"sony a7m4\",\n        description=\"只看机身成色和卖家信用。\",\n        decision_mode=\"ai\",\n    )\n    assert req.analyze_images is True\n\n\ndef test_generate_request_infers_fixed_account_strategy_from_state_file():\n    req = TaskGenerateRequest(\n        task_name=\"Sony A7M4\",\n        keyword=\"sony a7m4\",\n        description=\"只看机身成色和卖家信用。\",\n        decision_mode=\"ai\",\n        account_state_file=\"state/acc_1.json\",\n    )\n\n    assert req.account_strategy == \"fixed\"\n\n\ndef test_generate_request_requires_state_file_for_fixed_account_strategy():\n    try:\n        TaskGenerateRequest(\n            task_name=\"Sony A7M4\",\n            keyword=\"sony a7m4\",\n            description=\"只看机身成色和卖家信用。\",\n            decision_mode=\"ai\",\n            account_strategy=\"fixed\",\n        )\n    except ValueError as exc:\n        assert \"固定账号模式下必须选择账号\" in str(exc)\n        return\n\n    raise AssertionError(\"固定账号模式应要求 account_state_file\")\n"
  },
  {
    "path": "tests/unit/test_item_analysis_dispatcher.py",
    "content": "import asyncio\n\nfrom src.services.item_analysis_dispatcher import (\n    ItemAnalysisDispatcher,\n    ItemAnalysisJob,\n)\n\n\ndef test_item_analysis_dispatcher_uses_bounded_concurrency():\n    active_ai_calls = 0\n    max_active_ai_calls = 0\n    saved_records = []\n    notifications = []\n\n    async def seller_loader(user_id: str):\n        await asyncio.sleep(0.005)\n        return {\"卖家ID\": user_id}\n\n    async def image_downloader(product_id: str, image_urls: list[str], task_name: str):\n        return []\n\n    async def ai_analyzer(record: dict, image_paths: list[str], prompt_text: str):\n        nonlocal active_ai_calls, max_active_ai_calls\n        active_ai_calls += 1\n        max_active_ai_calls = max(max_active_ai_calls, active_ai_calls)\n        await asyncio.sleep(0.03)\n        active_ai_calls -= 1\n        return {\n            \"analysis_source\": \"ai\",\n            \"is_recommended\": True,\n            \"reason\": f\"推荐 {record['商品信息']['商品ID']}\",\n            \"keyword_hit_count\": 0,\n        }\n\n    async def notifier(item_data: dict, reason: str):\n        notifications.append((item_data[\"商品ID\"], reason))\n\n    async def saver(record: dict, keyword: str):\n        saved_records.append((keyword, record))\n        return True\n\n    async def run():\n        dispatcher = ItemAnalysisDispatcher(\n            concurrency=2,\n            skip_ai_analysis=False,\n            seller_loader=seller_loader,\n            image_downloader=image_downloader,\n            ai_analyzer=ai_analyzer,\n            notifier=notifier,\n            saver=saver,\n        )\n        for index in range(3):\n            dispatcher.submit(\n                ItemAnalysisJob(\n                    keyword=\"demo\",\n                    task_name=\"Demo\",\n                    decision_mode=\"ai\",\n                    analyze_images=False,\n                    prompt_text=\"prompt\",\n                    keyword_rules=(),\n                    final_record={\n                        \"商品信息\": {\"商品ID\": str(index), \"商品图片列表\": []},\n                        \"卖家信息\": {},\n                    },\n                    seller_id=f\"seller-{index}\",\n                    zhima_credit_text=\"优秀\",\n                    registration_duration_text=\"来闲鱼1年\",\n                )\n            )\n        await dispatcher.join()\n        return dispatcher\n\n    dispatcher = asyncio.run(run())\n    assert dispatcher.completed_count == 3\n    assert len(saved_records) == 3\n    assert len(notifications) == 3\n    assert max_active_ai_calls == 2\n    assert saved_records[0][1][\"卖家信息\"][\"卖家ID\"].startswith(\"seller-\")\n\n\ndef test_item_analysis_dispatcher_supports_keyword_mode_without_ai():\n    saved_records = []\n\n    async def seller_loader(user_id: str):\n        return {\"卖家标签\": \"个人闲置\"}\n\n    async def image_downloader(product_id: str, image_urls: list[str], task_name: str):\n        raise AssertionError(\"关键词模式不应下载图片\")\n\n    async def ai_analyzer(record: dict, image_paths: list[str], prompt_text: str):\n        raise AssertionError(\"关键词模式不应调用 AI\")\n\n    async def notifier(item_data: dict, reason: str):\n        return None\n\n    async def saver(record: dict, keyword: str):\n        saved_records.append(record)\n        return True\n\n    async def run():\n        dispatcher = ItemAnalysisDispatcher(\n            concurrency=1,\n            skip_ai_analysis=False,\n            seller_loader=seller_loader,\n            image_downloader=image_downloader,\n            ai_analyzer=ai_analyzer,\n            notifier=notifier,\n            saver=saver,\n        )\n        dispatcher.submit(\n            ItemAnalysisJob(\n                keyword=\"demo\",\n                task_name=\"Demo\",\n                decision_mode=\"keyword\",\n                analyze_images=False,\n                prompt_text=\"\",\n                keyword_rules=(\"个人闲置\",),\n                final_record={\n                    \"商品信息\": {\"商品ID\": \"1\", \"商品标题\": \"演示商品\"},\n                    \"卖家信息\": {},\n                },\n                seller_id=\"seller-1\",\n                zhima_credit_text=\"优秀\",\n                registration_duration_text=\"来闲鱼1年\",\n            )\n        )\n        await dispatcher.join()\n\n    asyncio.run(run())\n    assert saved_records[0][\"ai_analysis\"][\"analysis_source\"] == \"keyword\"\n    assert saved_records[0][\"ai_analysis\"][\"is_recommended\"] is True\n"
  },
  {
    "path": "tests/unit/test_keyword_rule_engine.py",
    "content": "from src.keyword_rule_engine import build_search_text, evaluate_keyword_rules\n\n\ndef _sample_record():\n    return {\n        \"商品信息\": {\n            \"商品标题\": \"Sony A7M4 全画幅相机\",\n            \"当前售价\": \"10000\",\n            \"商品标签\": [\"验货宝\", \"包邮\"],\n        },\n        \"卖家信息\": {\n            \"卖家昵称\": \"摄影器材店\",\n            \"卖家个性签名\": \"可验机，支持同城面交\",\n        },\n    }\n\n\ndef test_build_search_text_contains_product_and_seller_fields():\n    text = build_search_text(_sample_record())\n    assert \"sony a7m4\" in text\n    assert \"摄影器材店\" in text\n    assert \"支持同城面交\" in text\n\n\ndef test_keyword_rules_or_match_any_keyword():\n    text = build_search_text(_sample_record())\n    result = evaluate_keyword_rules([\"a7m4\", \"佳能\"], text)\n    assert result[\"is_recommended\"] is True\n    assert result[\"analysis_source\"] == \"keyword\"\n    assert result[\"keyword_hit_count\"] == 1\n    assert result[\"matched_keywords\"] == [\"a7m4\"]\n\n\ndef test_keyword_rules_count_multiple_hits():\n    text = build_search_text(_sample_record())\n    result = evaluate_keyword_rules([\"a7m4\", \"验货宝\", \"摄影器材店\"], text)\n    assert result[\"is_recommended\"] is True\n    assert result[\"keyword_hit_count\"] == 3\n\n\ndef test_keyword_rules_case_insensitive_contains():\n    text = build_search_text(_sample_record())\n    result = evaluate_keyword_rules([\"SONY\", \"A7M4\"], text)\n    assert result[\"is_recommended\"] is True\n    assert result[\"keyword_hit_count\"] == 2\n\n\ndef test_keyword_rules_no_match():\n    text = build_search_text(_sample_record())\n    result = evaluate_keyword_rules([\"佳能\", \"单反\"], text)\n    assert result[\"is_recommended\"] is False\n    assert result[\"keyword_hit_count\"] == 0\n\n\ndef test_keyword_rules_do_not_partially_match_alphanumeric_prefixes():\n    result = evaluate_keyword_rules([\"q1\"], \"富士 q1r5 旗舰相机\")\n    assert result[\"is_recommended\"] is False\n    assert result[\"keyword_hit_count\"] == 0\n\n\ndef test_keyword_rules_still_match_full_alphanumeric_token():\n    result = evaluate_keyword_rules([\"q1r5\"], \"富士 q1r5 旗舰相机\")\n    assert result[\"is_recommended\"] is True\n    assert result[\"keyword_hit_count\"] == 1\n"
  },
  {
    "path": "tests/unit/test_notification_service.py",
    "content": "import asyncio\n\nfrom src.infrastructure.external.notification_clients.base import NotificationClient\nfrom src.infrastructure.external.notification_clients.webhook_client import WebhookClient\nfrom src.services.notification_service import NotificationService\n\n\nclass _OkClient(NotificationClient):\n    channel_key = \"ok\"\n    display_name = \"OK\"\n\n    async def send(self, product_data, reason):\n        return None\n\n\nclass _FailClient(NotificationClient):\n    channel_key = \"fail\"\n    display_name = \"FAIL\"\n\n    async def send(self, product_data, reason):\n        raise RuntimeError(\"boom\")\n\n\ndef test_notification_service_collects_success_and_failure_results():\n    service = NotificationService([_OkClient(enabled=True), _FailClient(enabled=True)])\n\n    results = asyncio.run(\n        service.send_notification({\"商品标题\": \"Sony A7M4\"}, \"价格合适\")\n    )\n\n    assert results[\"ok\"][\"success\"] is True\n    assert results[\"ok\"][\"message\"] == \"发送成功\"\n    assert results[\"fail\"][\"success\"] is False\n    assert results[\"fail\"][\"message\"] == \"boom\"\n\n\ndef test_webhook_client_renders_json_templates(monkeypatch):\n    captured = {}\n\n    class _FakeResponse:\n        def raise_for_status(self):\n            return None\n\n    def _fake_post(url, headers=None, json=None, data=None, timeout=None):\n        captured[\"url\"] = url\n        captured[\"headers\"] = headers\n        captured[\"json\"] = json\n        captured[\"data\"] = data\n        return _FakeResponse()\n\n    monkeypatch.setattr(\"requests.post\", _fake_post)\n\n    client = WebhookClient(\n        webhook_url=\"https://hooks.example.com/notify\",\n        webhook_method=\"POST\",\n        webhook_headers='{\"Authorization\":\"Bearer token\"}',\n        webhook_content_type=\"JSON\",\n        webhook_query_parameters='{\"task\":\"{{title}}\"}',\n        webhook_body='{\"message\":\"{{content}}\",\"link\":\"{{desktop_link}}\"}',\n        pcurl_to_mobile=False,\n    )\n\n    asyncio.run(\n        client.send(\n            {\n                \"商品标题\": \"Sony A7M4\",\n                \"当前售价\": \"9999\",\n                \"商品链接\": \"https://www.goofish.com/item/123\",\n            },\n            \"价格合适\",\n        )\n    )\n\n    assert \"task=%F0%9F%9A%A8+%E6%96%B0%E6%8E%A8%E8%8D%90%21+Sony+A7M4\" in captured[\"url\"]\n    assert captured[\"headers\"][\"Authorization\"] == \"Bearer token\"\n    assert captured[\"json\"][\"message\"].startswith(\"价格: 9999\")\n    assert captured[\"json\"][\"link\"] == \"https://www.goofish.com/item/123\"\n    assert captured[\"data\"] is None\n"
  },
  {
    "path": "tests/unit/test_price_history_service.py",
    "content": "from src.services.price_history_service import (\n    build_item_price_context,\n    build_price_history_insights,\n    load_price_snapshots,\n    record_market_snapshots,\n)\n\n\ndef test_record_market_snapshots_and_build_price_history_insights(tmp_path, monkeypatch):\n    monkeypatch.chdir(tmp_path)\n    seen_item_ids = set()\n\n    run1_items = [\n        {\n            \"商品ID\": \"1001\",\n            \"商品标题\": \"Sony A7M4 单机\",\n            \"当前售价\": \"¥10000\",\n            \"商品标签\": [\"验货宝\"],\n            \"发货地区\": \"上海\",\n            \"卖家昵称\": \"卖家A\",\n            \"商品链接\": \"https://www.goofish.com/item?id=1001\",\n            \"发布时间\": \"2026-01-01 09:00\",\n        },\n        {\n            \"商品ID\": \"1002\",\n            \"商品标题\": \"Sony A7M4 套机\",\n            \"当前售价\": \"¥12000\",\n            \"商品标签\": [\"包邮\"],\n            \"发货地区\": \"杭州\",\n            \"卖家昵称\": \"卖家B\",\n            \"商品链接\": \"https://www.goofish.com/item?id=1002\",\n            \"发布时间\": \"2026-01-01 10:00\",\n        },\n    ]\n    run2_items = [\n        {\n            \"商品ID\": \"1001\",\n            \"商品标题\": \"Sony A7M4 单机\",\n            \"当前售价\": \"¥9500\",\n            \"商品标签\": [\"验货宝\"],\n            \"发货地区\": \"上海\",\n            \"卖家昵称\": \"卖家A\",\n            \"商品链接\": \"https://www.goofish.com/item?id=1001\",\n            \"发布时间\": \"2026-01-02 09:00\",\n        },\n        {\n            \"商品ID\": \"1003\",\n            \"商品标题\": \"Sony A7M4 全套\",\n            \"当前售价\": \"¥13000\",\n            \"商品标签\": [\"同城\"],\n            \"发货地区\": \"南京\",\n            \"卖家昵称\": \"卖家C\",\n            \"商品链接\": \"https://www.goofish.com/item?id=1003\",\n            \"发布时间\": \"2026-01-02 11:00\",\n        },\n    ]\n\n    inserted_run1 = record_market_snapshots(\n        keyword=\"sony a7m4\",\n        task_name=\"Sony A7M4 监控\",\n        items=run1_items,\n        run_id=\"run-1\",\n        snapshot_time=\"2026-01-01T12:00:00\",\n        seen_item_ids=seen_item_ids,\n    )\n    assert len(inserted_run1) == 2\n\n    inserted_run2 = record_market_snapshots(\n        keyword=\"sony a7m4\",\n        task_name=\"Sony A7M4 监控\",\n        items=run2_items,\n        run_id=\"run-2\",\n        snapshot_time=\"2026-01-02T12:00:00\",\n        seen_item_ids=set(),\n    )\n    assert len(inserted_run2) == 2\n\n    snapshots = load_price_snapshots(\"sony a7m4\")\n    assert len(snapshots) == 4\n\n    insights = build_price_history_insights(\"sony a7m4\")\n    assert insights[\"market_summary\"][\"sample_count\"] == 2\n    assert insights[\"market_summary\"][\"avg_price\"] == 11250.0\n    assert insights[\"market_summary\"][\"min_price\"] == 9500.0\n    assert insights[\"history_summary\"][\"unique_items\"] == 3\n    assert len(insights[\"daily_trend\"]) == 2\n    assert insights[\"daily_trend\"][0][\"day\"] == \"2026-01-01\"\n    assert insights[\"daily_trend\"][1][\"day\"] == \"2026-01-02\"\n\n    item_context = build_item_price_context(\n        snapshots,\n        item_id=\"1001\",\n        current_price=9500.0,\n    )\n    assert item_context[\"observation_count\"] == 2\n    assert item_context[\"min_price\"] == 9500.0\n    assert item_context[\"max_price\"] == 10000.0\n    assert item_context[\"price_change_amount\"] == -500.0\n    assert item_context[\"deal_label\"] == \"高性价比\"\n"
  },
  {
    "path": "tests/unit/test_process_service.py",
    "content": "import asyncio\nimport sys\nfrom types import SimpleNamespace\n\nfrom src.services.process_service import ProcessService\n\n\nclass FakeProcess:\n    def __init__(self, pid: int):\n        self.pid = pid\n        self.returncode = None\n        self._done = asyncio.Event()\n\n    async def wait(self):\n        await self._done.wait()\n        return self.returncode\n\n    def finish(self, returncode: int = 0):\n        self.returncode = returncode\n        self._done.set()\n\n    def terminate(self):\n        self.finish(-15)\n\n    def kill(self):\n        self.finish(-9)\n\n\ndef test_process_service_marks_task_stopped_when_process_exits(monkeypatch, tmp_path):\n    fake_process = FakeProcess(pid=4321)\n    events = []\n\n    async def run_scenario():\n        service = ProcessService()\n        service.failure_guard.should_skip_start = lambda *args, **kwargs: SimpleNamespace(\n            skip=False,\n            should_notify=False,\n            reason=\"\",\n            consecutive_failures=0,\n            paused_until=None,\n        )\n\n        stopped = asyncio.Event()\n\n        async def on_started(task_id: int):\n            events.append((\"started\", task_id))\n\n        async def on_stopped(task_id: int):\n            events.append((\"stopped\", task_id))\n            stopped.set()\n\n        service.set_lifecycle_hooks(on_started=on_started, on_stopped=on_stopped)\n\n        async def fake_create_subprocess_exec(*_args, **_kwargs):\n            return fake_process\n\n        monkeypatch.setattr(\n            \"src.services.process_service.build_task_log_path\",\n            lambda task_id, _task_name: str(tmp_path / f\"task-{task_id}.log\"),\n        )\n        monkeypatch.setattr(asyncio, \"create_subprocess_exec\", fake_create_subprocess_exec)\n\n        started = await service.start_task(0, \"task-a\")\n        assert started is True\n        assert events == [(\"started\", 0)]\n        assert service.is_running(0) is True\n\n        fake_process.finish(0)\n        await asyncio.wait_for(stopped.wait(), timeout=1)\n\n        assert (\"stopped\", 0) in events\n        assert service.is_running(0) is False\n\n    asyncio.run(run_scenario())\n\n\ndef test_process_service_reindexes_runtime_maps_after_delete():\n    service = ProcessService()\n    proc_a = object()\n    proc_c = object()\n    watcher_a = object()\n    watcher_c = object()\n\n    service.processes = {0: proc_a, 2: proc_c}\n    service.log_paths = {0: \"a.log\", 2: \"c.log\"}\n    service.task_names = {0: \"A\", 2: \"C\"}\n    service.exit_watchers = {0: watcher_a, 2: watcher_c}\n\n    service.reindex_after_delete(1)\n\n    assert service.processes == {0: proc_a, 1: proc_c}\n    assert service.log_paths == {0: \"a.log\", 1: \"c.log\"}\n    assert service.task_names == {0: \"A\", 1: \"C\"}\n    assert service.exit_watchers == {0: watcher_a, 1: watcher_c}\n\n\ndef test_process_service_adds_debug_limit_arg_when_env_enabled(monkeypatch):\n    monkeypatch.setenv(\"SPIDER_DEBUG_LIMIT\", \"1\")\n    service = ProcessService()\n\n    command = service._build_spawn_command(\"task-a\")\n\n    assert command == [\n        sys.executable,\n        \"-u\",\n        \"spider_v2.py\",\n        \"--task-name\",\n        \"task-a\",\n        \"--debug-limit\",\n        \"1\",\n    ]\n"
  },
  {
    "path": "tests/unit/test_prompt_utils.py",
    "content": "import asyncio\n\nimport pytest\n\nimport src.prompt_utils as prompt_utils\nfrom src.services.ai_response_parser import EmptyAIResponseError\n\n\ndef test_generate_criteria_closes_ai_client_after_success(monkeypatch, tmp_path):\n    close_state = {\"closed\": False}\n    reference_file = tmp_path / \"reference.txt\"\n    reference_file.write_text(\"reference\", encoding=\"utf-8\")\n\n    class FakeAIClient:\n        def is_available(self):\n            return True\n\n        def refresh(self):\n            raise AssertionError(\"refresh should not be called\")\n\n        async def _call_ai(self, *_args, **_kwargs):\n            return \"generated criteria\"\n\n        async def close(self):\n            close_state[\"closed\"] = True\n\n    monkeypatch.setattr(prompt_utils, \"AIClient\", FakeAIClient)\n\n    result = asyncio.run(\n        prompt_utils.generate_criteria(\"need a gpu\", str(reference_file))\n    )\n\n    assert result == \"generated criteria\"\n    assert close_state[\"closed\"] is True\n\n\ndef test_generate_criteria_closes_ai_client_after_ai_failure(monkeypatch, tmp_path):\n    close_state = {\"closed\": False}\n    reference_file = tmp_path / \"reference.txt\"\n    reference_file.write_text(\"reference\", encoding=\"utf-8\")\n\n    class FakeAIClient:\n        def is_available(self):\n            return True\n\n        def refresh(self):\n            raise AssertionError(\"refresh should not be called\")\n\n        async def _call_ai(self, *_args, **_kwargs):\n            raise EmptyAIResponseError(\"AI响应内容为空。\")\n\n        async def close(self):\n            close_state[\"closed\"] = True\n\n    monkeypatch.setattr(prompt_utils, \"AIClient\", FakeAIClient)\n\n    with pytest.raises(EmptyAIResponseError, match=\"AI响应内容为空\"):\n        asyncio.run(prompt_utils.generate_criteria(\"need a gpu\", str(reference_file)))\n\n    assert close_state[\"closed\"] is True\n"
  },
  {
    "path": "tests/unit/test_scraper_browser_channel.py",
    "content": "import importlib\n\n\ndef _load_scraper(monkeypatch, *, login_is_edge: bool, running_in_docker: bool):\n    monkeypatch.setenv(\"LOGIN_IS_EDGE\", \"true\" if login_is_edge else \"false\")\n    monkeypatch.setenv(\"RUNNING_IN_DOCKER\", \"true\" if running_in_docker else \"false\")\n\n    import src.config as config_module\n    import src.scraper as scraper_module\n\n    importlib.reload(config_module)\n    reloaded_scraper = importlib.reload(scraper_module)\n    reloaded_scraper.EDGE_DOCKER_WARNING_PRINTED = False\n    return reloaded_scraper\n\n\ndef test_resolve_browser_channel_uses_chromium_in_docker_even_when_edge_requested(monkeypatch, capsys):\n    scraper = _load_scraper(monkeypatch, login_is_edge=True, running_in_docker=True)\n\n    assert scraper._resolve_browser_channel() == \"chromium\"\n    assert \"Docker 镜像未内置 Edge\" in capsys.readouterr().out\n\n\ndef test_resolve_browser_channel_uses_msedge_locally_when_requested(monkeypatch):\n    scraper = _load_scraper(monkeypatch, login_is_edge=True, running_in_docker=False)\n\n    assert scraper._resolve_browser_channel() == \"msedge\"\n"
  },
  {
    "path": "tests/unit/test_search_pagination.py",
    "content": "import asyncio\n\nfrom playwright.async_api import TimeoutError as PlaywrightTimeoutError\n\nfrom src.services.search_pagination import advance_search_page\nfrom src.services.search_pagination import is_search_results_response\n\n\nclass FakeRequest:\n    def __init__(self, method: str = \"POST\"):\n        self.method = method\n\n\nclass FakeResponse:\n    def __init__(self, url: str, ok: bool = True, method: str = \"POST\"):\n        self.url = url\n        self.ok = ok\n        self.request = FakeRequest(method)\n\n\nclass FakeLocator:\n    def __init__(self, count: int, click_error: Exception | None = None):\n        self._count = count\n        self.clicks = 0\n        self.scrolls = 0\n        self.click_timeout = None\n        self._click_error = click_error\n\n    @property\n    def first(self):\n        return self\n\n    async def count(self) -> int:\n        return self._count\n\n    async def scroll_into_view_if_needed(self) -> None:\n        self.scrolls += 1\n\n    async def click(self, timeout: int | None = None) -> None:\n        self.clicks += 1\n        self.click_timeout = timeout\n        if self._click_error is not None:\n            raise self._click_error\n\n\nclass FakeResponseContext:\n    def __init__(self, outcome):\n        self._outcome = outcome\n\n    async def __aenter__(self):\n        return self\n\n    async def __aexit__(self, exc_type, exc, tb):\n        return False\n\n    @property\n    def value(self):\n        return self._resolve()\n\n    async def _resolve(self):\n        if isinstance(self._outcome, Exception):\n            raise self._outcome\n        return self._outcome\n\n\nclass FakePage:\n    def __init__(\n        self,\n        next_button_count: int,\n        outcomes: list[object],\n        click_error: Exception | None = None,\n    ):\n        self.locator_stub = FakeLocator(next_button_count, click_error=click_error)\n        self._outcomes = list(outcomes)\n\n    def locator(self, _selector: str) -> FakeLocator:\n        return self.locator_stub\n\n    def expect_response(self, _predicate, timeout: int):\n        assert timeout == 20000\n        if not self._outcomes:\n            raise AssertionError(\"missing fake response outcome\")\n        return FakeResponseContext(self._outcomes.pop(0))\n\n\nasync def _noop_random_sleep(_min_seconds: float, _max_seconds: float) -> None:\n    return None\n\n\nasync def _noop_sleep(_seconds: float) -> None:\n    return None\n\n\ndef test_advance_search_page_stops_when_no_next_button() -> None:\n    page = FakePage(next_button_count=0, outcomes=[])\n    logs: list[str] = []\n\n    result = asyncio.run(\n        advance_search_page(\n            page=page,\n            page_num=2,\n            logger=logs.append,\n            wait_after_click=_noop_random_sleep,\n            retry_sleep=_noop_sleep,\n        )\n    )\n\n    assert result.advanced is False\n    assert result.response is None\n    assert result.stop_reason == \"no_next_button\"\n    assert page.locator_stub.clicks == 0\n    assert logs == [\"已到达最后一页，未找到可用的'下一页'按钮，停止翻页。\"]\n\n\ndef test_advance_search_page_stops_after_timeout_retries() -> None:\n    page = FakePage(\n        next_button_count=1,\n        outcomes=[\n            PlaywrightTimeoutError(\"page 2 timeout\"),\n            PlaywrightTimeoutError(\"page 2 timeout\"),\n        ],\n    )\n    logs: list[str] = []\n\n    result = asyncio.run(\n        advance_search_page(\n            page=page,\n            page_num=2,\n            logger=logs.append,\n            wait_after_click=_noop_random_sleep,\n            retry_sleep=_noop_sleep,\n        )\n    )\n\n    assert result.advanced is False\n    assert result.response is None\n    assert result.stop_reason == \"response_timeout\"\n    assert page.locator_stub.clicks == 2\n    assert page.locator_stub.scrolls == 2\n    assert logs == [\n        \"等待第 2 页搜索响应超时，5秒后重试...\",\n        \"等待第 2 页搜索响应超时 2 次，停止翻页。\",\n    ]\n\n\ndef test_advance_search_page_returns_new_response_on_success() -> None:\n    response = FakeResponse(\n        url=\"https://example.com/h5/mtop.taobao.idlemtopsearch.pc.search/1.0/?page=2\"\n    )\n    page = FakePage(next_button_count=1, outcomes=[response])\n\n    result = asyncio.run(\n        advance_search_page(\n            page=page,\n            page_num=2,\n            logger=lambda _message: None,\n            wait_after_click=_noop_random_sleep,\n            retry_sleep=_noop_sleep,\n        )\n    )\n\n    assert result.advanced is True\n    assert result.response is response\n    assert result.stop_reason is None\n    assert page.locator_stub.clicks == 1\n    assert page.locator_stub.scrolls == 1\n    assert page.locator_stub.click_timeout == 10000\n\n\ndef test_advance_search_page_stops_when_click_times_out() -> None:\n    page = FakePage(\n        next_button_count=1,\n        outcomes=[FakeResponse(url=\"https://example.com/unused\")],\n        click_error=PlaywrightTimeoutError(\"click timeout\"),\n    )\n    logs: list[str] = []\n\n    result = asyncio.run(\n        advance_search_page(\n            page=page,\n            page_num=2,\n            logger=logs.append,\n            wait_after_click=_noop_random_sleep,\n            retry_sleep=_noop_sleep,\n        )\n    )\n\n    assert result.advanced is False\n    assert result.response is None\n    assert result.stop_reason == \"click_timeout\"\n    assert page.locator_stub.clicks == 1\n    assert logs == [\"第 2 页下一页按钮点击超时，停止翻页。\"]\n\n\ndef test_is_search_results_response_matches_exact_search_api() -> None:\n    response = FakeResponse(\n        url=\"https://h5api.m.goofish.com/h5/mtop.taobao.idlemtopsearch.pc.search/1.0/?foo=bar\",\n        method=\"POST\",\n    )\n\n    assert is_search_results_response(response) is True\n\n\ndef test_is_search_results_response_rejects_search_shade_api() -> None:\n    response = FakeResponse(\n        url=\"https://h5api.m.goofish.com/h5/mtop.taobao.idlemtopsearch.pc.search.shade/1.0/?foo=bar\",\n        method=\"POST\",\n    )\n\n    assert is_search_results_response(response) is False\n\n\ndef test_is_search_results_response_rejects_non_post_request() -> None:\n    response = FakeResponse(\n        url=\"https://h5api.m.goofish.com/h5/mtop.taobao.idlemtopsearch.pc.search/1.0/?foo=bar\",\n        method=\"GET\",\n    )\n\n    assert is_search_results_response(response) is False\n"
  },
  {
    "path": "tests/unit/test_seller_profile_cache.py",
    "content": "import asyncio\n\nfrom src.services.seller_profile_cache import SellerProfileCache\n\n\ndef test_seller_profile_cache_reuses_value_and_returns_copy():\n    clock = {\"value\": 100.0}\n    cache = SellerProfileCache(ttl_seconds=60, time_source=lambda: clock[\"value\"])\n    loader_calls = 0\n\n    async def loader(user_id: str):\n        nonlocal loader_calls\n        loader_calls += 1\n        return {\"user_id\": user_id, \"items\": []}\n\n    async def run():\n        first = await cache.get_or_load(\"seller-1\", loader)\n        first[\"items\"].append(\"mutated\")\n        second = await cache.get_or_load(\"seller-1\", loader)\n        return second\n\n    second = asyncio.run(run())\n    assert loader_calls == 1\n    assert second == {\"user_id\": \"seller-1\", \"items\": []}\n\n\ndef test_seller_profile_cache_coalesces_inflight_requests():\n    cache = SellerProfileCache(ttl_seconds=60)\n    loader_calls = 0\n\n    async def loader(user_id: str):\n        nonlocal loader_calls\n        loader_calls += 1\n        await asyncio.sleep(0.02)\n        return {\"user_id\": user_id}\n\n    async def run():\n        return await asyncio.gather(\n            cache.get_or_load(\"seller-2\", loader),\n            cache.get_or_load(\"seller-2\", loader),\n        )\n\n    results = asyncio.run(run())\n    assert loader_calls == 1\n    assert results == [{\"user_id\": \"seller-2\"}, {\"user_id\": \"seller-2\"}]\n"
  },
  {
    "path": "tests/unit/test_task_log_cleanup_service.py",
    "content": "from __future__ import annotations\n\nfrom datetime import datetime\nfrom pathlib import Path\n\nfrom src.services.task_log_cleanup_service import cleanup_task_logs\n\n\ndef _write_file(path: Path, content: str = \"log\") -> None:\n    path.parent.mkdir(parents=True, exist_ok=True)\n    path.write_text(content, encoding=\"utf-8\")\n\n\ndef _set_mtime(path: Path, when: datetime) -> None:\n    timestamp = when.timestamp()\n    path.touch()\n    path.chmod(0o644)\n    import os\n\n    os.utime(path, (timestamp, timestamp))\n\n\ndef test_cleanup_task_logs_removes_only_expired_top_level_logs(tmp_path):\n    logs_dir = tmp_path / \"logs\"\n    old_log = logs_dir / \"old_task.log\"\n    recent_log = logs_dir / \"recent_task.log\"\n    nested_ai_log = logs_dir / \"ai\" / \"old_ai.log\"\n    state_file = logs_dir / \"task-failure-guard.json\"\n\n    _write_file(old_log)\n    _write_file(recent_log)\n    _write_file(nested_ai_log)\n    _write_file(state_file, \"{}\")\n\n    now = datetime(2026, 3, 19, 12, 0, 0)\n    _set_mtime(old_log, datetime(2026, 3, 1, 0, 0, 0))\n    _set_mtime(recent_log, datetime(2026, 3, 18, 23, 0, 0))\n    _set_mtime(nested_ai_log, datetime(2026, 3, 1, 0, 0, 0))\n    _set_mtime(state_file, datetime(2026, 3, 1, 0, 0, 0))\n\n    removed = cleanup_task_logs(str(logs_dir), keep_days=7, now=now)\n\n    assert removed == [str(old_log)]\n    assert not old_log.exists()\n    assert recent_log.exists()\n    assert nested_ai_log.exists()\n    assert state_file.exists()\n\n\ndef test_cleanup_task_logs_skips_when_retention_is_invalid(tmp_path):\n    logs_dir = tmp_path / \"logs\"\n    task_log = logs_dir / \"task.log\"\n    _write_file(task_log)\n\n    removed = cleanup_task_logs(str(logs_dir), keep_days=0, now=datetime(2026, 3, 19, 12, 0, 0))\n\n    assert removed == []\n    assert task_log.exists()\n"
  },
  {
    "path": "tests/unit/test_utils.py",
    "content": "import asyncio\n\nfrom src.services.result_storage_service import load_all_result_records\nfrom src.utils import (\n    format_registration_days,\n    get_link_unique_key,\n    safe_get,\n    save_to_jsonl,\n)\n\n\ndef test_safe_get_nested_and_default():\n    data = {\"a\": {\"b\": [{\"c\": \"value\"}]}}\n    assert asyncio.run(safe_get(data, \"a\", \"b\", 0, \"c\")) == \"value\"\n    assert asyncio.run(safe_get(data, \"a\", \"b\", 1, \"c\", default=\"missing\")) == \"missing\"\n\n\ndef test_format_registration_days():\n    assert format_registration_days(400).startswith(\"\\u6765\\u95f2\\u9c7c\")\n    assert format_registration_days(-1) == \"\\u672a\\u77e5\"\n\n\ndef test_get_link_unique_key():\n    link = \"https://www.goofish.com/item?id=123&foo=bar\"\n    assert get_link_unique_key(link) == \"https://www.goofish.com/item?id=123\"\n\n\ndef test_save_to_jsonl(tmp_path, monkeypatch):\n    monkeypatch.chdir(tmp_path)\n    record = {\n        \"爬取时间\": \"2026-01-01T10:00:00\",\n        \"搜索关键字\": \"sony a7m4\",\n        \"任务名称\": \"Sony A7M4\",\n        \"商品信息\": {\n            \"商品ID\": \"1\",\n            \"商品标题\": \"Sony A7M4\",\n            \"商品链接\": \"https://www.goofish.com/item?id=1\",\n            \"当前售价\": \"¥10000\",\n        },\n    }\n\n    ok = asyncio.run(save_to_jsonl(record, keyword=\"sony a7m4\"))\n    assert ok is True\n\n    records = asyncio.run(\n        load_all_result_records(\n            \"sony_a7m4_full_data.jsonl\",\n            ai_recommended_only=False,\n            keyword_recommended_only=False,\n            sort_by=\"crawl_time\",\n            sort_order=\"asc\",\n        )\n    )\n    assert records == [record]\n"
  },
  {
    "path": "web-ui/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "web-ui/.vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\"Vue.volar\"]\n}\n"
  },
  {
    "path": "web-ui/Dockerfile",
    "content": "# Stage 1: Build the Vue application\nFROM node:22-alpine AS builder\n\nWORKDIR /app\n\n# Copy package files and install dependencies\nCOPY package*.json ./\nRUN npm install\n\n# Copy source code and build\nCOPY . .\nRUN npm run build\n\n# Stage 2: Serve with Nginx\nFROM nginx:alpine\n\n# Remove default nginx static assets\nRUN rm -rf /usr/share/nginx/html/*\n\n# Vite 输出到容器根目录 /dist，而不是 /app/dist\nCOPY --from=builder /dist /usr/share/nginx/html\n\n# Copy custom nginx configuration\nCOPY nginx.conf /etc/nginx/conf.d/default.conf\n\nEXPOSE 80\n\nCMD [\"nginx\", \"-g\", \"daemon off;\"]\n"
  },
  {
    "path": "web-ui/README.md",
    "content": "# Vue 3 + TypeScript + Vite\n\nThis template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.\n\nLearn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).\n"
  },
  {
    "path": "web-ui/components.json",
    "content": "{\n  \"$schema\": \"https://shadcn-vue.com/schema.json\",\n  \"style\": \"default\",\n  \"tailwind\": {\n    \"config\": \"tailwind.config.js\",\n    \"css\": \"src/assets/main.css\",\n    \"baseColor\": \"slate\",\n    \"cssVariables\": true\n  },\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\"\n  }\n}\n"
  },
  {
    "path": "web-ui/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>web-ui</title>\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"/src/main.ts\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "web-ui/nginx.conf",
    "content": "server {\n    listen 80;\n    server_name localhost;\n\n    root /usr/share/nginx/html;\n    index index.html;\n\n    # Serve static frontend files\n    location / {\n        try_files $uri $uri/ /index.html;\n    }\n\n    # Proxy API requests to the backend service\n    location /api/ {\n        proxy_pass http://app:8000/api/;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n    }\n\n    # Proxy Auth requests\n    location /auth/ {\n        proxy_pass http://app:8000/auth/;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n    }\n\n    # Proxy static files from backend if needed (though we serve frontend here)\n    # But backend might serve user images or other static assets via /static\n    location /static/ {\n        proxy_pass http://app:8000/static/;\n    }\n\n    # Proxy WebSocket requests\n    location /ws {\n        proxy_pass http://app:8000/ws;\n        proxy_http_version 1.1;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"Upgrade\";\n        proxy_set_header Host $host;\n    }\n}\n"
  },
  {
    "path": "web-ui/package.json",
    "content": "{\n  \"name\": \"web-ui\",\n  \"private\": true,\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vue-tsc -b && vite build\",\n    \"preview\": \"vite preview\"\n  },\n  \"dependencies\": {\n    \"@vueuse/core\": \"^14.1.0\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"lucide-vue-next\": \"^0.562.0\",\n    \"reka-ui\": \"^2.7.0\",\n    \"tailwind-merge\": \"^3.4.0\",\n    \"vue\": \"^3.5.24\",\n    \"vue-i18n\": \"^10.0.7\",\n    \"vue-router\": \"^4.6.4\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"^24.10.4\",\n    \"@vitejs/plugin-vue\": \"^6.0.1\",\n    \"@vue/tsconfig\": \"^0.8.1\",\n    \"autoprefixer\": \"^10.4.23\",\n    \"postcss\": \"^8.5.6\",\n    \"tailwindcss\": \"^3.4.17\",\n    \"tailwindcss-animate\": \"^1.0.7\",\n    \"typescript\": \"~5.9.3\",\n    \"vite\": \"^7.2.4\",\n    \"vue-tsc\": \"^3.1.4\"\n  }\n}\n"
  },
  {
    "path": "web-ui/postcss.config.cjs",
    "content": "module.exports = {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n}\n"
  },
  {
    "path": "web-ui/src/App.vue",
    "content": "<script setup lang=\"ts\">\nimport { RouterView } from 'vue-router'\nimport Toaster from '@/components/ui/toast/Toaster.vue'\n</script>\n\n<template>\n  <RouterView />\n  <Toaster />\n</template>"
  },
  {
    "path": "web-ui/src/api/accounts.ts",
    "content": "import { http } from '@/lib/http'\n\nexport interface AccountItem {\n  name: string\n  path: string\n}\n\nexport interface AccountDetail extends AccountItem {\n  content: string\n}\n\nexport async function listAccounts(): Promise<AccountItem[]> {\n  return await http('/api/accounts')\n}\n\nexport async function getAccount(name: string): Promise<AccountDetail> {\n  return await http(`/api/accounts/${encodeURIComponent(name)}`)\n}\n\nexport async function createAccount(payload: { name: string; content: string }): Promise<AccountDetail> {\n  return await http('/api/accounts', {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify(payload),\n  })\n}\n\nexport async function updateAccount(name: string, content: string): Promise<AccountDetail> {\n  return await http(`/api/accounts/${encodeURIComponent(name)}`, {\n    method: 'PUT',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ content }),\n  })\n}\n\nexport async function deleteAccount(name: string): Promise<{ message: string }> {\n  return await http(`/api/accounts/${encodeURIComponent(name)}`, { method: 'DELETE' })\n}\n"
  },
  {
    "path": "web-ui/src/api/dashboard.ts",
    "content": "import { http } from '@/lib/http'\nimport type { DashboardSnapshot } from '@/types/dashboard.d.ts'\n\nexport async function getDashboardSummary(): Promise<DashboardSnapshot> {\n  return await http('/api/dashboard/summary')\n}\n"
  },
  {
    "path": "web-ui/src/api/logs.ts",
    "content": "import { http } from '@/lib/http'\n\nexport async function getLogs(fromPos: number = 0, taskId?: number | null): Promise<{ new_content: string; new_pos: number }> {\n  const params: Record<string, number> = { from_pos: fromPos }\n  if (taskId !== null && taskId !== undefined) {\n    params.task_id = taskId\n  }\n  return await http('/api/logs', { params })\n}\n\nexport async function clearLogs(taskId?: number | null): Promise<void> {\n  const params: Record<string, number> = {}\n  if (taskId !== null && taskId !== undefined) {\n    params.task_id = taskId\n  }\n  await http('/api/logs', { method: 'DELETE', params })\n}\n\nexport async function getLogTail(\n  taskId: number,\n  offsetLines: number = 0,\n  limitLines: number = 50\n): Promise<{ content: string; has_more: boolean; next_offset: number; new_pos: number }> {\n  return await http('/api/logs/tail', {\n    params: {\n      task_id: taskId,\n      offset_lines: offsetLines,\n      limit_lines: limitLines,\n    },\n  })\n}\n"
  },
  {
    "path": "web-ui/src/api/prompts.ts",
    "content": "import { http } from '@/lib/http'\n\nexport interface PromptContent {\n  filename: string\n  content: string\n}\n\nexport async function listPrompts(): Promise<string[]> {\n  return await http('/api/prompts')\n}\n\nexport async function getPromptContent(filename: string): Promise<PromptContent> {\n  return await http(`/api/prompts/${filename}`)\n}\n\nexport async function updatePrompt(filename: string, content: string): Promise<{ message: string }> {\n  return await http(`/api/prompts/${filename}`, {\n    method: 'PUT',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ content }),\n  })\n}\n"
  },
  {
    "path": "web-ui/src/api/results.ts",
    "content": "import type { ResultInsights, ResultItem } from '@/types/result.d.ts'\nimport { http } from '@/lib/http'\n\nexport interface GetResultContentParams {\n  recommended_only?: boolean;\n  ai_recommended_only?: boolean;\n  keyword_recommended_only?: boolean;\n  sort_by?: 'crawl_time' | 'publish_time' | 'price' | 'keyword_hit_count';\n  sort_order?: 'asc' | 'desc';\n  page?: number;\n  limit?: number;\n}\n\nexport async function getResultFiles(): Promise<string[]> {\n  const data = await http('/api/results/files')\n  return data.files || []\n}\n\nexport async function deleteResultFile(filename: string): Promise<{ message: string }> {\n  return await http(`/api/results/files/${filename}`, { method: 'DELETE' })\n}\n\nexport async function getResultContent(\n  filename: string,\n  params: GetResultContentParams = {}\n): Promise<{ total_items: number; items: ResultItem[] }> {\n  return await http(`/api/results/${filename}`, { params: params as Record<string, any> })\n}\n\nexport async function getResultInsights(filename: string): Promise<ResultInsights> {\n  return await http(`/api/results/${filename}/insights`)\n}\n\nexport function buildResultExportUrl(filename: string, params: GetResultContentParams = {}): string {\n  const searchParams = new URLSearchParams()\n  Object.entries(params).forEach(([key, value]) => {\n    if (value !== undefined && value !== null) {\n      searchParams.set(key, String(value))\n    }\n  })\n  const queryString = searchParams.toString()\n  return `/api/results/${encodeURIComponent(filename)}/export${queryString ? `?${queryString}` : ''}`\n}\n\nexport function downloadResultExport(filename: string, params: GetResultContentParams = {}) {\n  const url = buildResultExportUrl(filename, params)\n  const link = document.createElement('a')\n  link.href = url\n  link.download = ''\n  document.body.appendChild(link)\n  link.click()\n  document.body.removeChild(link)\n}\n"
  },
  {
    "path": "web-ui/src/api/settings.ts",
    "content": "import { http } from '@/lib/http'\n\nexport interface NotificationSettings {\n  NTFY_TOPIC_URL?: string\n  GOTIFY_URL?: string\n  GOTIFY_TOKEN?: string\n  BARK_URL?: string\n  WX_BOT_URL?: string\n  TELEGRAM_BOT_TOKEN?: string\n  TELEGRAM_CHAT_ID?: string\n  TELEGRAM_API_BASE_URL?: string\n  WEBHOOK_URL?: string\n  WEBHOOK_METHOD?: string\n  WEBHOOK_HEADERS?: string\n  WEBHOOK_CONTENT_TYPE?: string\n  WEBHOOK_QUERY_PARAMETERS?: string\n  WEBHOOK_BODY?: string\n  PCURL_TO_MOBILE?: boolean\n  BARK_URL_SET?: boolean\n  GOTIFY_TOKEN_SET?: boolean\n  WX_BOT_URL_SET?: boolean\n  TELEGRAM_BOT_TOKEN_SET?: boolean\n  WEBHOOK_URL_SET?: boolean\n  WEBHOOK_HEADERS_SET?: boolean\n  CONFIGURED_CHANNELS?: string[]\n}\n\nexport interface NotificationSettingsUpdate {\n  NTFY_TOPIC_URL?: string | null\n  GOTIFY_URL?: string | null\n  GOTIFY_TOKEN?: string | null\n  BARK_URL?: string | null\n  WX_BOT_URL?: string | null\n  TELEGRAM_BOT_TOKEN?: string | null\n  TELEGRAM_CHAT_ID?: string | null\n  TELEGRAM_API_BASE_URL?: string | null\n  WEBHOOK_URL?: string | null\n  WEBHOOK_METHOD?: string | null\n  WEBHOOK_HEADERS?: string | null\n  WEBHOOK_CONTENT_TYPE?: string | null\n  WEBHOOK_QUERY_PARAMETERS?: string | null\n  WEBHOOK_BODY?: string | null\n  PCURL_TO_MOBILE?: boolean\n}\n\nexport interface NotificationTestResponse {\n  message: string\n  results: Record<string, {\n    label: string\n    success: boolean\n    message: string\n  }>\n}\n\nexport interface AiSettings {\n  OPENAI_API_KEY?: string\n  OPENAI_BASE_URL?: string\n  OPENAI_MODEL_NAME?: string\n  PROXY_URL?: string\n}\n\nexport interface RotationSettings {\n  ACCOUNT_ROTATION_ENABLED?: boolean\n  ACCOUNT_ROTATION_MODE?: string\n  ACCOUNT_ROTATION_RETRY_LIMIT?: number\n  ACCOUNT_BLACKLIST_TTL?: number\n  ACCOUNT_STATE_DIR?: string\n  PROXY_ROTATION_ENABLED?: boolean\n  PROXY_ROTATION_MODE?: string\n  PROXY_POOL?: string\n  PROXY_ROTATION_RETRY_LIMIT?: number\n  PROXY_BLACKLIST_TTL?: number\n}\n\nexport interface SystemStatus {\n  scraper_running: boolean\n  running_task_ids?: number[]\n  ai_configured?: boolean\n  notification_configured?: boolean\n  headless_mode?: boolean\n  running_in_docker?: boolean\n  login_state_file: {\n    exists: boolean\n    path: string\n  }\n  env_file: {\n    exists: boolean\n    openai_api_key_set: boolean\n    openai_base_url_set: boolean\n    openai_model_name_set: boolean\n    ntfy_topic_url_set: boolean\n    gotify_url_set: boolean\n    gotify_token_set: boolean\n    bark_url_set: boolean\n    wx_bot_url_set: boolean\n    telegram_bot_token_set: boolean\n    telegram_chat_id_set: boolean\n    webhook_url_set: boolean\n    webhook_headers_set: boolean\n  }\n  configured_notification_channels?: string[]\n}\n\nexport async function getNotificationSettings(): Promise<NotificationSettings> {\n  return await http('/api/settings/notifications')\n}\n\nexport async function updateNotificationSettings(settings: NotificationSettingsUpdate): Promise<{ message: string; configured_channels: string[] }> {\n  return await http('/api/settings/notifications', {\n    method: 'PUT',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify(settings)\n  })\n}\n\nexport async function testNotificationSettings(\n  payload: { channel?: string; settings: NotificationSettingsUpdate }\n): Promise<NotificationTestResponse> {\n  return await http('/api/settings/notifications/test', {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify(payload)\n  })\n}\n\nexport async function getAiSettings(): Promise<AiSettings> {\n  return await http('/api/settings/ai')\n}\n\nexport async function updateAiSettings(settings: AiSettings): Promise<void> {\n  await http('/api/settings/ai', {\n    method: 'PUT',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify(settings)\n  })\n}\n\nexport async function getRotationSettings(): Promise<RotationSettings> {\n  return await http('/api/settings/rotation')\n}\n\nexport async function updateRotationSettings(settings: RotationSettings): Promise<void> {\n  await http('/api/settings/rotation', {\n    method: 'PUT',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify(settings)\n  })\n}\n\nexport async function testAiSettings(settings: AiSettings): Promise<{ success: boolean; message: string; response?: string }> {\n  return await http('/api/settings/ai/test', {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify(settings)\n  })\n}\n\nexport async function getSystemStatus(): Promise<SystemStatus> {\n  return await http('/api/settings/status')\n}\n\nexport async function updateLoginState(content: string): Promise<{ message: string }> {\n  return await http('/api/login-state', {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ content })\n  })\n}\n\nexport async function deleteLoginState(): Promise<{ message: string }> {\n  return await http('/api/login-state', { method: 'DELETE' })\n}\n"
  },
  {
    "path": "web-ui/src/api/tasks.ts",
    "content": "import type {\n  Task,\n  TaskCreateResponse,\n  TaskGenerateRequest,\n  TaskGenerationJob,\n  TaskUpdate,\n} from '@/types/task.d.ts'\nimport { http } from '@/lib/http'\n\nexport async function getAllTasks(): Promise<Task[]> {\n  return await http('/api/tasks')\n}\n\nexport async function createTaskWithAI(data: TaskGenerateRequest): Promise<TaskCreateResponse> {\n  return await http('/api/tasks/generate', {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n    },\n    body: JSON.stringify(data),\n  })\n}\n\nexport async function getTaskGenerationJob(jobId: string): Promise<TaskGenerationJob> {\n  const result = await http(`/api/tasks/generate-jobs/${jobId}`)\n  return result.job\n}\n\nexport async function updateTask(taskId: number, data: TaskUpdate): Promise<Task> {\n  const result = await http(`/api/tasks/${taskId}`, {\n    method: 'PATCH',\n    headers: {\n      'Content-Type': 'application/json',\n    },\n    body: JSON.stringify(data),\n  })\n  return result.task\n}\n\nexport async function startTask(taskId: number): Promise<void> {\n  await http(`/api/tasks/start/${taskId}`, { method: 'POST' })\n}\n\nexport async function stopTask(taskId: number): Promise<void> {\n  await http(`/api/tasks/stop/${taskId}`, { method: 'POST' })\n}\n\nexport async function deleteTask(taskId: number): Promise<void> {\n  await http(`/api/tasks/${taskId}`, { method: 'DELETE' })\n}\n"
  },
  {
    "path": "web-ui/src/assets/main.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@layer base {\n  :root {\n    --background: 0 0% 100%;\n    --foreground: 222.2 84% 4.9%;\n\n    --card: 0 0% 100%;\n    --card-foreground: 222.2 84% 4.9%;\n\n    --popover: 0 0% 100%;\n    --popover-foreground: 222.2 84% 4.9%;\n\n    --primary: 222.2 47.4% 11.2%;\n    --primary-foreground: 210 40% 98%;\n\n    --secondary: 210 40% 96.1%;\n    --secondary-foreground: 222.2 47.4% 11.2%;\n\n    --muted: 210 40% 96.1%;\n    --muted-foreground: 215.4 16.3% 46.9%;\n\n    --accent: 210 40% 96.1%;\n    --accent-foreground: 222.2 47.4% 11.2%;\n\n    --destructive: 0 84.2% 60.2%;\n    --destructive-foreground: 210 40% 98%;\n\n    --border: 214.3 31.8% 91.4%;\n    --input: 214.3 31.8% 91.4%;\n    --ring: 222.2 84% 4.9%;\n\n    --radius: 0.5rem;\n  }\n\n  .dark {\n    --background: 222.2 84% 4.9%;\n    --foreground: 210 40% 98%;\n\n    --card: 222.2 84% 4.9%;\n    --card-foreground: 210 40% 98%;\n\n    --popover: 222.2 84% 4.9%;\n    --popover-foreground: 210 40% 98%;\n\n    --primary: 210 40% 98%;\n    --primary-foreground: 222.2 47.4% 11.2%;\n\n    --secondary: 217.2 32.6% 17.5%;\n    --secondary-foreground: 210 40% 98%;\n\n    --muted: 217.2 32.6% 17.5%;\n    --muted-foreground: 215 20.2% 65.1%;\n\n    --accent: 217.2 32.6% 17.5%;\n    --accent-foreground: 210 40% 98%;\n\n    --destructive: 0 62.8% 30.6%;\n    --destructive-foreground: 210 40% 98%;\n\n    --border: 217.2 32.6% 17.5%;\n    --input: 217.2 32.6% 17.5%;\n    --ring: 212.7 26.8% 83.9%;\n  }\n}\n\n@layer base {\n  * {\n    @apply border-border;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n}"
  },
  {
    "path": "web-ui/src/components/HelloWorld.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref } from 'vue'\n\ndefineProps<{ msg: string }>()\n\nconst count = ref(0)\n</script>\n\n<template>\n  <h1>{{ msg }}</h1>\n\n  <div class=\"card\">\n    <button type=\"button\" @click=\"count++\">count is {{ count }}</button>\n    <p>\n      Edit\n      <code>components/HelloWorld.vue</code> to test HMR\n    </p>\n  </div>\n\n  <p>\n    Check out\n    <a href=\"https://vuejs.org/guide/quick-start.html#local\" target=\"_blank\"\n      >create-vue</a\n    >, the official Vue + Vite starter\n  </p>\n  <p>\n    Learn more about IDE Support for Vue in the\n    <a\n      href=\"https://vuejs.org/guide/scaling-up/tooling.html#ide-support\"\n      target=\"_blank\"\n      >Vue Docs Scaling up Guide</a\n    >.\n  </p>\n  <p class=\"read-the-docs\">Click on the Vite and Vue logos to learn more</p>\n</template>\n\n<style scoped>\n.read-the-docs {\n  color: #888;\n}\n</style>\n"
  },
  {
    "path": "web-ui/src/components/layout/DashboardTaskSearch.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'\nimport { useRouter } from 'vue-router'\nimport { useI18n } from 'vue-i18n'\nimport { LoaderCircle, Search, Sparkles } from 'lucide-vue-next'\nimport Badge from '@/components/ui/badge/Badge.vue'\nimport * as taskApi from '@/api/tasks'\nimport { useWebSocket } from '@/composables/useWebSocket'\nimport type { Task } from '@/types/task.d.ts'\n\nconst MAX_RESULTS = 6\n\nconst router = useRouter()\nconst { t } = useI18n()\nconst { on } = useWebSocket()\nconst rootRef = ref<HTMLElement | null>(null)\nconst query = ref('')\nconst tasks = ref<Task[]>([])\nconst isLoading = ref(false)\nconst error = ref('')\nconst isOpen = ref(false)\nconst highlightedIndex = ref(0)\n\nconst normalizedQuery = computed(() => query.value.trim().toLowerCase())\n\nconst visibleTasks = computed(() => {\n  const list = normalizedQuery.value\n    ? tasks.value.filter((task) => matchesTask(task, normalizedQuery.value))\n    : tasks.value\n  return list.slice(0, MAX_RESULTS)\n})\n\nconst panelTitle = computed(() => (\n  normalizedQuery.value ? t('tasks.search.matchingTasks') : t('tasks.search.recentTasks')\n))\n\nconst shouldShowPanel = computed(() => (\n  isOpen.value && (\n    isLoading.value ||\n    Boolean(error.value) ||\n    visibleTasks.value.length > 0 ||\n    Boolean(normalizedQuery.value)\n  )\n))\n\nfunction matchesTask(task: Task, value: string) {\n  const fields = [\n    task.task_name,\n    task.keyword,\n    task.description || '',\n    task.region || '',\n  ]\n  return fields.some((field) => field.toLowerCase().includes(value))\n}\n\nfunction getTaskStatus(task: Task) {\n  if (task.is_running) return t('common.running')\n  if (task.enabled) return t('common.enabled')\n  return t('common.disabled')\n}\n\nfunction getTaskMeta(task: Task) {\n  const parts = [task.keyword]\n  if (task.region) parts.push(task.region)\n  parts.push(t('tasks.search.maxPages', { count: task.max_pages }))\n  return parts.join(' · ')\n}\n\nasync function fetchTasks() {\n  isLoading.value = true\n  error.value = ''\n  try {\n    tasks.value = await taskApi.getAllTasks()\n  } catch (e) {\n    error.value = e instanceof Error ? e.message : t('tasks.search.loadFailed')\n  } finally {\n    isLoading.value = false\n  }\n}\n\nfunction ensureLoaded() {\n  if (tasks.value.length || isLoading.value) return\n  fetchTasks()\n}\n\nfunction openPanel() {\n  ensureLoaded()\n  isOpen.value = true\n}\n\nfunction closePanel() {\n  isOpen.value = false\n  highlightedIndex.value = 0\n}\n\nfunction selectTask(task: Task) {\n  closePanel()\n  router.push({\n    name: 'Tasks',\n    query: { edit: String(task.id) },\n  })\n}\n\nfunction moveHighlight(direction: 1 | -1) {\n  if (!visibleTasks.value.length) return\n  const lastIndex = visibleTasks.value.length - 1\n  const nextIndex = highlightedIndex.value + direction\n  if (nextIndex < 0) {\n    highlightedIndex.value = lastIndex\n    return\n  }\n  if (nextIndex > lastIndex) {\n    highlightedIndex.value = 0\n    return\n  }\n  highlightedIndex.value = nextIndex\n}\n\nfunction handleKeydown(event: KeyboardEvent) {\n  if (event.key === 'Escape') {\n    closePanel()\n    return\n  }\n  if (event.key === 'ArrowDown') {\n    event.preventDefault()\n    openPanel()\n    moveHighlight(1)\n    return\n  }\n  if (event.key === 'ArrowUp') {\n    event.preventDefault()\n    openPanel()\n    moveHighlight(-1)\n    return\n  }\n  if (event.key === 'Enter' && visibleTasks.value.length) {\n    event.preventDefault()\n    const selectedTask = visibleTasks.value[highlightedIndex.value]\n    if (selectedTask) {\n      selectTask(selectedTask)\n    }\n  }\n}\n\nfunction handlePointerDown(event: MouseEvent) {\n  if (!rootRef.value) return\n  const target = event.target\n  if (target instanceof Node && !rootRef.value.contains(target)) {\n    closePanel()\n  }\n}\n\nwatch(normalizedQuery, () => {\n  highlightedIndex.value = 0\n  if (!query.value.trim()) return\n  openPanel()\n})\n\non('tasks_updated', fetchTasks)\non('task_status_changed', fetchTasks)\n\nonMounted(() => {\n  document.addEventListener('mousedown', handlePointerDown)\n})\n\nonBeforeUnmount(() => {\n  document.removeEventListener('mousedown', handlePointerDown)\n})\n</script>\n\n<template>\n  <div ref=\"rootRef\" class=\"relative w-full\">\n    <Search class=\"absolute left-3 top-1/2 z-10 -translate-y-1/2 w-4 h-4 text-slate-400 transition-colors\" />\n    <input\n      v-model=\"query\"\n      type=\"text\"\n      :placeholder=\"t('tasks.search.placeholder')\"\n      class=\"w-full h-10 rounded-xl border border-slate-200/60 bg-slate-100/60 pl-10 pr-16 text-sm text-slate-700 transition-all outline-none focus:border-primary/40 focus:bg-white focus:ring-2 focus:ring-primary/15\"\n      @focus=\"openPanel\"\n      @keydown=\"handleKeydown\"\n    />\n    <kbd class=\"absolute right-3 top-1/2 -translate-y-1/2 rounded border border-slate-300 bg-white px-1.5 py-0.5 text-[10px] text-slate-400 shadow-sm\">\n      ESC\n    </kbd>\n\n    <transition name=\"search-panel\">\n      <div\n        v-if=\"shouldShowPanel\"\n        class=\"absolute inset-x-0 top-[calc(100%+0.75rem)] overflow-hidden rounded-[28px] border border-slate-200/80 bg-white/95 shadow-[0_28px_70px_rgba(15,23,42,0.16)] backdrop-blur-xl\"\n      >\n        <div class=\"flex items-center justify-between border-b border-slate-100 px-4 py-3\">\n          <div>\n            <p class=\"text-xs font-black uppercase tracking-[0.24em] text-slate-400\">{{ panelTitle }}</p>\n            <p class=\"mt-1 text-xs text-slate-500\">\n              {{ normalizedQuery ? t('tasks.search.resultCount', { count: visibleTasks.length }) : t('tasks.search.enterHint') }}\n            </p>\n          </div>\n          <Badge variant=\"outline\" class=\"border-slate-200 bg-slate-50 text-[10px] text-slate-500\">\n            {{ t('routes.tasks') }}\n          </Badge>\n        </div>\n\n        <div v-if=\"isLoading\" class=\"flex items-center gap-2 px-4 py-6 text-sm text-slate-500\">\n          <LoaderCircle class=\"h-4 w-4 animate-spin text-primary\" />\n          {{ t('tasks.search.loading') }}\n        </div>\n\n        <div v-else-if=\"error\" class=\"px-4 py-6 text-sm text-rose-600\">\n          {{ error }}\n        </div>\n\n        <div v-else-if=\"visibleTasks.length === 0\" class=\"px-4 py-6\">\n          <p class=\"text-sm font-semibold text-slate-700\">{{ t('tasks.search.emptyTitle') }}</p>\n          <p class=\"mt-1 text-xs text-slate-500\">{{ t('tasks.search.emptyDescription') }}</p>\n        </div>\n\n        <div v-else class=\"divide-y divide-slate-100\">\n          <button\n            v-for=\"(task, index) in visibleTasks\"\n            :key=\"task.id\"\n            class=\"flex w-full items-start gap-3 px-4 py-3 text-left transition-colors\"\n            :class=\"index === highlightedIndex ? 'bg-slate-900 text-white' : 'hover:bg-slate-50 text-slate-800'\"\n            @mouseenter=\"highlightedIndex = index\"\n            @click=\"selectTask(task)\"\n          >\n            <div\n              class=\"mt-1 h-2.5 w-2.5 shrink-0 rounded-full\"\n              :class=\"task.is_running ? 'bg-emerald-400' : task.enabled ? 'bg-sky-400' : 'bg-slate-300'\"\n            />\n            <div class=\"min-w-0 flex-1\">\n              <div class=\"flex items-center justify-between gap-3\">\n                <p class=\"truncate text-sm font-bold\">{{ task.task_name }}</p>\n                <Badge\n                  variant=\"outline\"\n                  class=\"shrink-0 text-[10px]\"\n                  :class=\"index === highlightedIndex ? 'border-white/20 bg-white/10 text-white' : 'border-slate-200 text-slate-500'\"\n                >\n                  {{ getTaskStatus(task) }}\n                </Badge>\n              </div>\n              <p\n                class=\"mt-1 truncate text-xs\"\n                :class=\"index === highlightedIndex ? 'text-white/70' : 'text-slate-500'\"\n              >\n                {{ getTaskMeta(task) }}\n              </p>\n              <p\n                v-if=\"task.description\"\n                class=\"mt-1 truncate text-xs\"\n                :class=\"index === highlightedIndex ? 'text-white/70' : 'text-slate-400'\"\n              >\n                {{ task.description }}\n              </p>\n            </div>\n          </button>\n        </div>\n\n        <div class=\"flex items-center justify-between border-t border-slate-100 bg-slate-50/80 px-4 py-3 text-[11px] text-slate-500\">\n          <div class=\"flex items-center gap-2\">\n            <Sparkles class=\"h-3.5 w-3.5 text-primary\" />\n            {{ t('tasks.search.footerHint') }}\n          </div>\n          <div class=\"hidden items-center gap-2 md:flex\">\n            <span>{{ t('tasks.search.keyboardUpDown') }}</span>\n            <span>{{ t('tasks.search.keyboardEnter') }}</span>\n          </div>\n        </div>\n      </div>\n    </transition>\n  </div>\n</template>\n\n<style scoped>\n.search-panel-enter-active,\n.search-panel-leave-active {\n  transition: opacity 0.18s ease, transform 0.18s ease;\n}\n\n.search-panel-enter-from,\n.search-panel-leave-to {\n  opacity: 0;\n  transform: translateY(-8px);\n}\n</style>\n"
  },
  {
    "path": "web-ui/src/components/layout/LocaleToggle.vue",
    "content": "<script setup lang=\"ts\">\nimport { Globe } from 'lucide-vue-next'\nimport { useLocale } from '@/i18n'\nimport { useI18n } from 'vue-i18n'\n\nconst { locale, toggleLocale } = useLocale()\nconst { t } = useI18n()\n\nconst localeOptions = [\n  { value: 'zh-CN', label: '中文', shortLabel: '中' },\n  { value: 'en-US', label: 'English', shortLabel: 'EN' },\n] as const\n</script>\n\n<template>\n  <div\n    class=\"inline-flex items-center gap-1 rounded-full border border-slate-200/80 bg-white/80 p-1 shadow-sm backdrop-blur\"\n    :aria-label=\"t('locale.switchLabel')\"\n    role=\"group\"\n  >\n    <span class=\"hidden pl-2 text-slate-400 sm:inline-flex\">\n      <Globe class=\"h-4 w-4\" />\n    </span>\n    <button\n      v-for=\"option in localeOptions\"\n      :key=\"option.value\"\n      type=\"button\"\n      class=\"rounded-full px-2.5 py-1 text-[11px] font-bold transition-colors sm:px-3\"\n      :class=\"locale === option.value ? 'bg-primary text-white shadow-sm' : 'text-slate-500 hover:bg-slate-100'\"\n      @click=\"toggleLocale(option.value)\"\n    >\n      <span class=\"sm:hidden\">{{ option.shortLabel }}</span>\n      <span class=\"hidden sm:inline\">{{ option.label }}</span>\n    </button>\n  </div>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/layout/TheHeader.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport { Button } from '@/components/ui/button'\nimport DashboardTaskSearch from '@/components/layout/DashboardTaskSearch.vue'\nimport LocaleToggle from '@/components/layout/LocaleToggle.vue'\nimport { \n  Zap, \n  Bell, \n  Search, \n  UserCircle,\n  HelpCircle,\n  Menu\n} from 'lucide-vue-next'\nimport Badge from '@/components/ui/badge/Badge.vue'\nimport { useMobileNav } from '@/composables/useMobileNav'\nimport { useI18n } from 'vue-i18n'\n\nconst router = useRouter()\nconst route = useRoute()\nconst { toggleMobileNav } = useMobileNav()\nconst inactiveSearchValue = ref('')\nconst { t } = useI18n()\n\nconst isDashboard = computed(() => route.name === 'Dashboard')\n\nfunction goAccounts() {\n  router.push('/accounts')\n}\n\nfunction goNotifications() {\n  router.push({ name: 'Settings', query: { tab: 'notifications' } })\n}\n\nfunction goPrompts() {\n  router.push({ name: 'Settings', query: { tab: 'prompts' } })\n}\n</script>\n\n<template>\n  <header class=\"flex items-center justify-between px-6 h-16 bg-white/60 backdrop-blur-md border-b border-slate-200/60 sticky top-0 z-[100]\">\n    <!-- Brand Logo -->\n    <div class=\"flex items-center gap-2 group cursor-pointer\" @click=\"router.push('/')\">\n      <div class=\"w-8 h-8 rounded-lg bg-primary flex items-center justify-center shadow-lg shadow-primary/20 transition-transform group-hover:rotate-12\">\n        <Zap class=\"w-5 h-5 text-white fill-white\" />\n      </div>\n      <h1 class=\"text-lg font-black text-slate-800 tracking-tighter\">\n        AI <span class=\"text-primary\">Xianyu</span> Hunter\n      </h1>\n      <Badge variant=\"outline\" class=\"ml-2 text-[10px] font-bold border-primary/20 text-primary bg-primary/5 uppercase tracking-widest hidden sm:flex\">\n        PRO\n      </Badge>\n    </div>\n\n    <!-- Search & Navigation -->\n    <div class=\"hidden md:flex flex-grow max-w-md mx-8\">\n      <DashboardTaskSearch v-if=\"isDashboard\" />\n      <div v-else class=\"relative w-full group\">\n        <Search class=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 transition-colors\" />\n        <input \n          type=\"text\" \n          v-model=\"inactiveSearchValue\"\n          :placeholder=\"t('header.searchUnavailable')\"\n          class=\"w-full h-10 pl-10 pr-4 bg-slate-100/50 border border-slate-200/50 rounded-xl text-sm transition-all focus:outline-none focus:ring-2 focus:ring-primary/20 focus:bg-white focus:border-primary/50\"\n        />\n        <kbd class=\"absolute right-3 top-1/2 -translate-y-1/2 px-1.5 py-0.5 rounded border border-slate-300 bg-white text-[10px] text-slate-400 font-sans shadow-sm pointer-events-none\">\n          /\n        </kbd>\n      </div>\n    </div>\n\n    <!-- Actions -->\n    <div class=\"flex items-center gap-3\">\n      <div class=\"flex items-center gap-2\">\n        <LocaleToggle />\n      </div>\n\n      <div class=\"flex items-center gap-1 sm:gap-2\">\n         <Button variant=\"ghost\" size=\"icon\" class=\"rounded-full text-slate-500 hover:text-primary hover:bg-primary/10\" @click=\"goNotifications\">\n            <Bell class=\"w-5 h-5\" />\n         </Button>\n         <Button variant=\"ghost\" size=\"icon\" class=\"rounded-full text-slate-500 hover:text-primary hover:bg-primary/10\" @click=\"goPrompts\">\n            <HelpCircle class=\"w-5 h-5\" />\n         </Button>\n      </div>\n      \n      <div class=\"h-6 w-px bg-slate-200 mx-1 hidden sm:block\"></div>\n\n      <Button \n        variant=\"ghost\" \n        class=\"hidden sm:flex items-center gap-2 pl-2 pr-4 rounded-full hover:bg-slate-100 transition-all active:scale-95\"\n        @click=\"goAccounts\"\n      >\n        <div class=\"w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center overflow-hidden border border-slate-300 shadow-sm\">\n           <UserCircle class=\"w-6 h-6 text-slate-500\" />\n        </div>\n        <div class=\"text-left hidden lg:block\">\n           <p class=\"text-xs font-black text-slate-700 leading-none mb-0.5\">Xianyu Admin</p>\n           <p class=\"text-[10px] text-slate-400 font-medium\">{{ t('header.accountManagement') }}</p>\n        </div>\n      </Button>\n\n      <Button variant=\"ghost\" size=\"icon\" class=\"md:hidden\" @click=\"toggleMobileNav\">\n         <Menu class=\"w-6 h-6 text-slate-700\" />\n      </Button>\n    </div>\n  </header>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/layout/TheSidebar.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { RouterLink } from 'vue-router'\nimport { \n  LayoutDashboard, \n  ListTodo, \n  Users, \n  Layers, \n  Terminal, \n  Settings2,\n  ChevronRight\n} from 'lucide-vue-next'\nimport { useWebSocket } from '@/composables/useWebSocket'\nimport { useI18n } from 'vue-i18n'\n\nconst emit = defineEmits<{\n  (event: 'navigate'): void\n}>()\nconst { isConnected } = useWebSocket()\nconst { t } = useI18n()\n\nconst navItems = computed(() => [\n  { to: '/dashboard', label: t('sidebar.dashboard'), icon: LayoutDashboard },\n  { to: '/tasks', label: t('sidebar.tasks'), icon: ListTodo },\n  { to: '/accounts', label: t('sidebar.accounts'), icon: Users },\n  { to: '/results', label: t('sidebar.results'), icon: Layers },\n  { to: '/logs', label: t('sidebar.logs'), icon: Terminal },\n  { to: '/settings', label: t('sidebar.settings'), icon: Settings2 },\n])\n\nconst connectionLabel = computed(() => (\n  isConnected.value ? t('sidebar.backendConnected') : t('sidebar.backendConnecting')\n))\nconst connectionTone = computed(() =>\n  isConnected.value\n    ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]'\n    : 'bg-amber-400 shadow-[0_0_8px_rgba(251,191,36,0.45)]'\n)\n</script>\n\n<template>\n  <nav class=\"space-y-1\">\n    <RouterLink\n      v-for=\"item in navItems\"\n      :key=\"item.to\"\n      :to=\"item.to\"\n      v-slot=\"{ isActive }\"\n      class=\"group relative flex items-center px-4 py-3 rounded-xl transition-all duration-200 overflow-hidden\"\n      @click=\"emit('navigate')\"\n    >\n      <!-- Active Background Effect -->\n      <div \n        v-if=\"isActive\" \n        class=\"absolute inset-0 bg-gradient-to-r from-primary/10 to-transparent z-0\"\n      ></div>\n      <div \n        v-if=\"isActive\" \n        class=\"absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-primary rounded-r-full\"\n      ></div>\n\n      <div class=\"relative z-10 flex items-center w-full\">\n        <component \n          :is=\"item.icon\" \n          class=\"w-5 h-5 mr-3 transition-colors\"\n          :class=\"isActive ? 'text-primary' : 'text-slate-400 group-hover:text-slate-600'\"\n        />\n        <span \n          class=\"text-sm font-bold transition-colors flex-grow\"\n          :class=\"isActive ? 'text-slate-900' : 'text-slate-500 group-hover:text-slate-700'\"\n        >\n          {{ item.label }}\n        </span>\n        <ChevronRight \n          v-if=\"isActive\"\n          class=\"w-4 h-4 text-primary animate-in fade-in slide-in-from-left-2\"\n        />\n      </div>\n    </RouterLink>\n\n    <!-- Support Section -->\n    <div class=\"mt-12 px-4\">\n      <div class=\"rounded-2xl p-4 bg-slate-50/50 border border-slate-100 border-dashed\">\n         <p class=\"text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2\">{{ t('sidebar.systemStatus') }}</p>\n         <div class=\"flex items-center gap-2\">\n            <div class=\"w-2 h-2 rounded-full\" :class=\"connectionTone\"></div>\n            <span class=\"text-xs font-bold text-slate-600\">{{ connectionLabel }}</span>\n         </div>\n      </div>\n    </div>\n  </nav>\n</template>\n\n<style scoped>\n.router-link-active {\n  background-color: transparent !important;\n}\n</style>\n"
  },
  {
    "path": "web-ui/src/components/results/PriceTrendChart.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { useI18n } from 'vue-i18n'\n\ninterface TrendPoint {\n  day: string\n  avg_price: number | null\n  median_price: number | null\n}\n\nconst props = defineProps<{\n  points: TrendPoint[]\n}>()\nconst { t } = useI18n()\n\nconst chartWidth = 720\nconst chartHeight = 220\nconst padding = 24\n\nconst validPoints = computed(() =>\n  props.points.filter((point) => point.avg_price !== null && point.avg_price !== undefined)\n)\n\nconst valueRange = computed(() => {\n  const values = validPoints.value\n    .flatMap((point) => [point.avg_price, point.median_price])\n    .filter((value): value is number => typeof value === 'number')\n  if (values.length === 0) {\n    return { min: 0, max: 1 }\n  }\n  const min = Math.min(...values)\n  const max = Math.max(...values)\n  if (min === max) {\n    return { min: min - 1, max: max + 1 }\n  }\n  return { min, max }\n})\n\nfunction resolveX(index: number) {\n  if (validPoints.value.length <= 1) return chartWidth / 2\n  const usableWidth = chartWidth - padding * 2\n  return padding + (usableWidth / (validPoints.value.length - 1)) * index\n}\n\nfunction resolveY(value: number) {\n  const usableHeight = chartHeight - padding * 2\n  const ratio = (value - valueRange.value.min) / (valueRange.value.max - valueRange.value.min)\n  return chartHeight - padding - ratio * usableHeight\n}\n\nfunction buildPath(values: Array<number | null>) {\n  const commands = values\n    .map((value, index) => {\n      if (value === null || value === undefined) return null\n      const prefix = index === 0 ? 'M' : 'L'\n      return `${prefix} ${resolveX(index)} ${resolveY(value)}`\n    })\n    .filter(Boolean)\n  return commands.join(' ')\n}\n\nconst avgPath = computed(() => buildPath(validPoints.value.map((point) => point.avg_price)))\nconst medianPath = computed(() => buildPath(validPoints.value.map((point) => point.median_price)))\nconst areaPath = computed(() => {\n  if (!avgPath.value || validPoints.value.length === 0) return ''\n  const firstX = resolveX(0)\n  const lastX = resolveX(validPoints.value.length - 1)\n  const baseline = chartHeight - padding\n  return `${avgPath.value} L ${lastX} ${baseline} L ${firstX} ${baseline} Z`\n})\n</script>\n\n<template>\n  <div class=\"rounded-[28px] border border-[#d6d1c8] bg-[#f8f3e9] p-4 shadow-[0_16px_40px_rgba(77,59,34,0.08)]\">\n    <div class=\"mb-3 flex items-center justify-between text-xs uppercase tracking-[0.28em] text-[#8d7258]\">\n      <span>Daily Price Curve</span>\n      <div class=\"flex items-center gap-3\">\n        <span class=\"inline-flex items-center gap-1\">\n          <span class=\"h-2.5 w-2.5 rounded-full bg-[#1f6f78]\" />\n          {{ t('results.chart.avgPrice') }}\n        </span>\n        <span class=\"inline-flex items-center gap-1\">\n          <span class=\"h-2.5 w-2.5 rounded-full bg-[#c1683c]\" />\n          {{ t('results.chart.medianPrice') }}\n        </span>\n      </div>\n    </div>\n\n    <div v-if=\"validPoints.length === 0\" class=\"rounded-2xl border border-dashed border-[#d0c3af] bg-white/70 px-4 py-10 text-center text-sm text-[#8a7660]\">\n      {{ t('results.chart.noTrend') }}\n    </div>\n\n    <div v-else>\n      <svg :viewBox=\"`0 0 ${chartWidth} ${chartHeight}`\" class=\"h-[220px] w-full\">\n        <defs>\n          <linearGradient id=\"avg-area-fill\" x1=\"0%\" y1=\"0%\" x2=\"0%\" y2=\"100%\">\n            <stop offset=\"0%\" stop-color=\"#1f6f78\" stop-opacity=\"0.28\" />\n            <stop offset=\"100%\" stop-color=\"#1f6f78\" stop-opacity=\"0\" />\n          </linearGradient>\n        </defs>\n\n        <g>\n          <line\n            v-for=\"index in 4\"\n            :key=\"index\"\n            :x1=\"padding\"\n            :x2=\"chartWidth - padding\"\n            :y1=\"padding + ((chartHeight - padding * 2) / 4) * (index - 1)\"\n            :y2=\"padding + ((chartHeight - padding * 2) / 4) * (index - 1)\"\n            stroke=\"#dbcdb8\"\n            stroke-dasharray=\"4 6\"\n          />\n        </g>\n\n        <path :d=\"areaPath\" fill=\"url(#avg-area-fill)\" />\n        <path :d=\"avgPath\" fill=\"none\" stroke=\"#1f6f78\" stroke-width=\"4\" stroke-linecap=\"round\" />\n        <path :d=\"medianPath\" fill=\"none\" stroke=\"#c1683c\" stroke-width=\"3\" stroke-dasharray=\"8 6\" stroke-linecap=\"round\" />\n\n        <g v-for=\"(point, index) in validPoints\" :key=\"point.day\">\n          <circle :cx=\"resolveX(index)\" :cy=\"resolveY(point.avg_price as number)\" r=\"5\" fill=\"#1f6f78\" />\n          <circle :cx=\"resolveX(index)\" :cy=\"resolveY(point.median_price as number)\" r=\"4\" fill=\"#c1683c\" />\n          <text\n            :x=\"resolveX(index)\"\n            :y=\"chartHeight - 6\"\n            text-anchor=\"middle\"\n            fill=\"#8d7258\"\n            font-size=\"12\"\n          >\n            {{ point.day.slice(5) }}\n          </text>\n        </g>\n      </svg>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/results/ResultCard.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, computed } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport type { ResultItem } from '@/types/result.d.ts'\nimport {\n  Card,\n  CardContent,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from '@/components/ui/card'\nimport Badge from '@/components/ui/badge/Badge.vue'\nimport { ExternalLink, TrendingUp, TrendingDown, Info, User, Clock, CheckCircle2, XCircle, AlertCircle } from 'lucide-vue-next'\nimport { formatDateTime } from '@/i18n'\n\ninterface Props {\n  item: ResultItem\n}\n\nconst props = defineProps<Props>()\nconst { t } = useI18n()\n\nconst info = props.item.商品信息\nconst seller = props.item.卖家信息\nconst ai = props.item.ai_analysis\nconst priceInsight = props.item.price_insight\n\nconst isRecommended = ai?.is_recommended === true\nconst recommendationStatus = computed(() => {\n  if (ai?.is_recommended === true) return { label: t('results.card.strongRecommend'), color: 'bg-emerald-500', icon: CheckCircle2, text: 'text-emerald-600', bg: 'bg-emerald-50' }\n  if (ai?.is_recommended === false) return { label: t('results.card.notRecommended'), color: 'bg-rose-500', icon: XCircle, text: 'text-rose-600', bg: 'bg-rose-50' }\n  return { label: t('results.card.pending'), color: 'bg-amber-500', icon: AlertCircle, text: 'text-amber-600', bg: 'bg-amber-50' }\n})\n\nconst imageUrl = info.商品图片列表?.[0] || info.商品主图链接 || ''\nconst crawlTime = props.item.爬取时间\n  ? formatDateTime(props.item.爬取时间, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })\n  : t('common.unknown')\nconst matchScore = ai?.value_score ?? 0\n\nconst expanded = ref(false)\n</script>\n\n<template>\n  <Card class=\"group flex flex-col h-full border-none shadow-glass hover:shadow-card-hover transition-all duration-300 rounded-2xl overflow-hidden bg-white/80 backdrop-blur-sm\">\n    <!-- Image Header -->\n    <div class=\"relative aspect-[4/3] overflow-hidden\">\n      <div class=\"absolute inset-0 bg-slate-200 animate-pulse\" v-if=\"!imageUrl\"></div>\n      <img\n        v-else\n        :src=\"imageUrl\"\n        :alt=\"info.商品标题\"\n        class=\"w-full h-full object-cover transition-transform duration-500 group-hover:scale-110\"\n        loading=\"lazy\"\n      />\n      <!-- Overlays -->\n      <div class=\"absolute top-3 left-3 flex gap-2\">\n        <Badge v-if=\"isRecommended\" variant=\"default\" class=\"bg-emerald-500/90 backdrop-blur-md border-none shadow-sm\">\n          {{ t('results.card.curated') }}\n        </Badge>\n      </div>\n      <div class=\"absolute top-3 right-3\">\n         <div class=\"p-1.5 rounded-full bg-white/20 backdrop-blur-md border border-white/30 text-white opacity-0 group-hover:opacity-100 transition-opacity\">\n            <ExternalLink class=\"w-4 h-4\" />\n         </div>\n      </div>\n    </div>\n\n    <CardHeader class=\"p-4 pb-2\">\n      <div class=\"flex justify-between items-start gap-3\">\n        <CardTitle class=\"text-base font-semibold text-slate-800 line-clamp-2 leading-snug flex-grow h-10\">\n          <a :href=\"info.商品链接\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"hover:text-primary transition-colors\">\n            {{ info.商品标题 }}\n          </a>\n        </CardTitle>\n      </div>\n      <div class=\"flex items-baseline gap-1 mt-2\">\n        <span class=\"text-2xl font-bold text-rose-600 tracking-tight\">{{ info.当前售价 }}</span>\n        <span v-if=\"info['商品原价']\" class=\"text-xs text-slate-400 line-through mb-1\">{{ info['商品原价'] }}</span>\n      </div>\n    </CardHeader>\n\n    <CardContent class=\"p-4 pt-2 flex-grow\">\n      <!-- AI Insight Section -->\n      <div class=\"rounded-xl p-3 border border-slate-100\" :class=\"recommendationStatus.bg\">\n        <div class=\"flex items-center justify-between mb-2\">\n          <div class=\"flex items-center gap-2\">\n            <component :is=\"recommendationStatus.icon\" class=\"w-4 h-4\" :class=\"recommendationStatus.text\" />\n            <span class=\"text-sm font-bold\" :class=\"recommendationStatus.text\">{{ recommendationStatus.label }}</span>\n          </div>\n          <div class=\"flex items-center gap-1\">\n             <span class=\"text-[10px] font-medium uppercase tracking-wider text-slate-400\">AI Match</span>\n             <span class=\"text-sm font-black\" :class=\"recommendationStatus.text\">{{ matchScore }}%</span>\n          </div>\n        </div>\n        \n        <div class=\"w-full h-1.5 bg-white/50 rounded-full overflow-hidden mb-3\">\n          <div \n            class=\"h-full transition-all duration-1000 ease-out rounded-full\" \n            :class=\"recommendationStatus.color\"\n            :style=\"{ width: `${matchScore}%` }\"\n          ></div>\n        </div>\n\n        <p class=\"text-xs leading-relaxed text-slate-600\" :class=\"{ 'line-clamp-2': !expanded }\">\n           {{ ai?.reason || t('results.card.analyzing') }}\n        </p>\n        \n        <button \n          v-if=\"ai?.reason && ai.reason.length > 50\"\n          @click=\"expanded = !expanded\" \n          class=\"mt-1 text-[10px] font-bold uppercase text-primary/70 hover:text-primary transition-colors flex items-center gap-1\"\n        >\n          {{ expanded ? t('results.card.collapse') : t('results.card.expand') }}\n          <Info class=\"w-3 h-3\" />\n        </button>\n      </div>\n\n      <!-- Price Stats Grid -->\n      <div v-if=\"priceInsight?.observation_count\" class=\"mt-4 grid grid-cols-2 gap-3\">\n        <div class=\"bg-slate-50/50 p-2.5 rounded-xl border border-slate-100/50 group/stat\">\n          <div class=\"flex items-center gap-1.5 text-[10px] font-medium text-slate-400 mb-1\">\n            <TrendingUp class=\"w-3 h-3\" /> {{ t('results.card.marketAvg') }}\n          </div>\n          <div class=\"text-sm font-bold text-slate-700\">\n            {{ priceInsight.market_avg_price ? `¥${priceInsight.market_avg_price}` : '—' }}\n          </div>\n        </div>\n        <div class=\"bg-slate-50/50 p-2.5 rounded-xl border border-slate-100/50\">\n          <div class=\"flex items-center gap-1.5 text-[10px] font-medium text-slate-400 mb-1\">\n            <TrendingDown class=\"w-3 h-3\" /> {{ t('results.card.historicalLow') }}\n          </div>\n          <div class=\"text-sm font-bold text-slate-700\">\n            {{ priceInsight.min_price ? `¥${priceInsight.min_price}` : '—' }}\n          </div>\n        </div>\n      </div>\n    </CardContent>\n\n    <CardFooter class=\"px-4 py-3 bg-slate-50/30 border-t border-slate-100/60 flex items-center justify-between text-[10px]\">\n      <div class=\"flex items-center gap-3 text-slate-400\">\n        <div class=\"flex items-center gap-1\">\n          <User class=\"w-3 h-3\" />\n          <span class=\"truncate max-w-[60px]\">{{ seller.卖家昵称 || info.卖家昵称 || t('results.card.anonymous') }}</span>\n        </div>\n        <div class=\"flex items-center gap-1\">\n          <Clock class=\"w-3 h-3\" />\n          <span>{{ crawlTime }}</span>\n        </div>\n      </div>\n      <a :href=\"info.商品链接\" target=\"_blank\" class=\"flex items-center gap-1 text-primary font-bold hover:gap-1.5 transition-all\">\n        {{ t('results.card.detail') }} <ExternalLink class=\"w-3 h-3\" />\n      </a>\n    </CardFooter>\n  </Card>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/results/ResultsFilterBar.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { Label } from '@/components/ui/label'\nimport { Button } from '@/components/ui/button'\n\ninterface FileOption {\n  value: string\n  label: string\n  taskName?: string\n}\n\ninterface Props {\n  files: string[]\n  fileOptions?: FileOption[]\n  selectedFile: string | null\n  aiRecommendedOnly: boolean\n  keywordRecommendedOnly: boolean\n  sortBy: 'crawl_time' | 'publish_time' | 'price' | 'keyword_hit_count'\n  sortOrder: 'asc' | 'desc'\n  isLoading: boolean\n  isReady: boolean\n}\n\nconst props = defineProps<Props>()\nconst { t } = useI18n()\n\nconst options = computed(() => {\n  if (!props.isReady) {\n    return []\n  }\n  if (props.fileOptions && props.fileOptions.length > 0) {\n    return props.fileOptions\n  }\n  return props.files.map((file) => ({ value: file, label: file }))\n})\n\nconst selectedLabel = computed(() => {\n  if (!props.isReady) return t('results.filters.loadingTaskNames')\n  if (options.value.length === 0) return t('results.filters.noResults')\n  if (!props.selectedFile) return t('results.filters.chooseResult')\n  const match = options.value.find((option) => option.value === props.selectedFile)\n  return match ? match.label : t('results.filters.taskNameLabel', { task: t('common.unnamed') })\n})\n\nconst labelClass = computed(() => {\n  const classes = ['transition-opacity', 'duration-200']\n  if (!props.isReady || !props.selectedFile || options.value.length === 0) {\n    classes.push('text-muted-foreground')\n  }\n  classes.push(props.isReady ? 'opacity-100' : 'opacity-70')\n  return classes.join(' ')\n})\n\nconst isSelectDisabled = computed(() => !props.isReady || options.value.length === 0)\n\nconst emit = defineEmits<{\n  (e: 'update:selectedFile', value: string): void\n  (e: 'update:aiRecommendedOnly', value: boolean): void\n  (e: 'update:keywordRecommendedOnly', value: boolean): void\n  (e: 'update:sortBy', value: 'crawl_time' | 'publish_time' | 'price' | 'keyword_hit_count'): void\n  (e: 'update:sortOrder', value: 'asc' | 'desc'): void\n  (e: 'refresh'): void\n  (e: 'export'): void\n  (e: 'delete'): void\n}>()\n\nfunction handleToggleAiRecommended(value: boolean) {\n  emit('update:aiRecommendedOnly', value)\n  if (value) {\n    emit('update:keywordRecommendedOnly', false)\n  }\n}\n\nfunction handleToggleKeywordRecommended(value: boolean) {\n  emit('update:keywordRecommendedOnly', value)\n  if (value) {\n    emit('update:aiRecommendedOnly', false)\n  }\n}\n</script>\n\n<template>\n  <div class=\"flex flex-wrap gap-4 items-center mb-6 p-4 bg-gray-50 rounded-lg border\">\n    <Select\n      :model-value=\"props.selectedFile || undefined\"\n      @update:model-value=\"(value) => emit('update:selectedFile', value as string)\"\n    >\n      <SelectTrigger class=\"w-[280px]\" :disabled=\"isSelectDisabled\">\n        <span :class=\"labelClass\">\n          {{ selectedLabel }}\n        </span>\n      </SelectTrigger>\n      <SelectContent>\n        <SelectItem v-for=\"option in options\" :key=\"option.value\" :value=\"option.value\">\n          {{ option.label }}\n        </SelectItem>\n      </SelectContent>\n    </Select>\n\n    <Select\n      :model-value=\"props.sortBy\"\n      @update:model-value=\"(value) => emit('update:sortBy', value as any)\"\n    >\n      <SelectTrigger class=\"w-[180px]\">\n        <SelectValue />\n      </SelectTrigger>\n      <SelectContent>\n        <SelectItem value=\"crawl_time\">{{ t('results.filters.sortByCrawlTime') }}</SelectItem>\n        <SelectItem value=\"publish_time\">{{ t('results.filters.sortByPublishTime') }}</SelectItem>\n        <SelectItem value=\"price\">{{ t('results.filters.sortByPrice') }}</SelectItem>\n        <SelectItem value=\"keyword_hit_count\">{{ t('results.filters.sortByKeywordHits') }}</SelectItem>\n      </SelectContent>\n    </Select>\n\n    <Select\n      :model-value=\"props.sortOrder\"\n      @update:model-value=\"(value) => emit('update:sortOrder', value as any)\"\n    >\n      <SelectTrigger class=\"w-[120px]\">\n        <SelectValue />\n      </SelectTrigger>\n      <SelectContent>\n        <SelectItem value=\"desc\">{{ t('results.filters.desc') }}</SelectItem>\n        <SelectItem value=\"asc\">{{ t('results.filters.asc') }}</SelectItem>\n      </SelectContent>\n    </Select>\n\n    <div class=\"flex items-center space-x-2\">\n      <Checkbox\n        id=\"ai-recommended-only\"\n        :model-value=\"props.aiRecommendedOnly\"\n        @update:modelValue=\"(value) => handleToggleAiRecommended(value === true)\"\n      />\n      <Label for=\"ai-recommended-only\" class=\"cursor-pointer\">{{ t('results.filters.aiOnly') }}</Label>\n    </div>\n\n    <div class=\"flex items-center space-x-2\">\n      <Checkbox\n        id=\"keyword-recommended-only\"\n        :model-value=\"props.keywordRecommendedOnly\"\n        @update:modelValue=\"(value) => handleToggleKeywordRecommended(value === true)\"\n      />\n      <Label for=\"keyword-recommended-only\" class=\"cursor-pointer\">{{ t('results.filters.keywordOnly') }}</Label>\n    </div>\n\n    <Button @click=\"emit('refresh')\" :disabled=\"props.isLoading\">\n      {{ t('common.refresh') }}\n    </Button>\n\n    <Button\n      variant=\"outline\"\n      @click=\"emit('export')\"\n      :disabled=\"props.isLoading || !props.selectedFile\"\n    >\n      {{ t('results.filters.exportCsv') }}\n    </Button>\n\n    <Button\n      variant=\"destructive\"\n      @click=\"emit('delete')\"\n      :disabled=\"props.isLoading || !props.selectedFile\"\n    >\n      {{ t('results.filters.deleteResult') }}\n    </Button>\n  </div>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/results/ResultsGrid.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ResultItem } from '@/types/result.d.ts'\nimport { useI18n } from 'vue-i18n'\nimport ResultCard from './ResultCard.vue'\n\ninterface Props {\n  results: ResultItem[]\n  isLoading: boolean\n}\n\ndefineProps<Props>()\nconst { t } = useI18n()\n</script>\n\n<template>\n  <div>\n    <div v-if=\"isLoading\" class=\"text-center py-12 text-gray-500\">\n      {{ t('results.grid.loading') }}\n    </div>\n    <div v-else-if=\"results.length === 0\" class=\"text-center py-12 text-gray-500\">\n      {{ t('results.grid.empty') }}\n    </div>\n    <div v-else class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6\">\n      <ResultCard v-for=\"item in results\" :key=\"item.商品信息.商品ID\" :item=\"item\" />\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/results/ResultsInsightsPanel.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport type { ResultInsights } from '@/types/result.d.ts'\nimport PriceTrendChart from './PriceTrendChart.vue'\nimport { formatDateTime } from '@/i18n'\n\nconst props = defineProps<{\n  insights: ResultInsights | null\n  selectedTaskLabel?: string | null\n}>()\nconst { t } = useI18n()\n\nconst summaryCards = computed(() => {\n  if (!props.insights) return []\n  const market = props.insights.market_summary\n  const history = props.insights.history_summary\n  return [\n    {\n      label: t('results.insights.currentAvg'),\n      value: market.avg_price ? `¥${market.avg_price}` : '—',\n      hint: t('results.insights.sampleCount', { count: market.sample_count || 0 }),\n    },\n    {\n      label: t('results.insights.historyAvg'),\n      value: history.avg_price ? `¥${history.avg_price}` : '—',\n      hint: t('results.insights.uniqueItems', { count: history.unique_items || 0 }),\n    },\n    {\n      label: t('results.insights.currentMin'),\n      value: market.min_price ? `¥${market.min_price}` : '—',\n      hint: market.max_price\n        ? t('results.insights.highestPrice', { price: market.max_price })\n        : t('results.insights.noRange'),\n    },\n  ]\n})\n\nconst latestSnapshotText = computed(() => {\n  if (!props.insights?.latest_snapshot_at) return t('results.insights.noSnapshot')\n  return t('results.insights.latestSnapshot', {\n    time: formatDateTime(props.insights.latest_snapshot_at, {\n      dateStyle: 'medium',\n      timeStyle: 'short',\n    }),\n  })\n})\n</script>\n\n<template>\n  <section class=\"mb-6 overflow-hidden rounded-[32px] border border-[#dac9b2] bg-[linear-gradient(135deg,#faf5eb_0%,#f2ebe0_55%,#f8f4ef_100%)] shadow-[0_24px_70px_rgba(92,68,36,0.08)]\">\n    <div class=\"grid gap-8 px-6 py-6 lg:grid-cols-[1.15fr_0.85fr] lg:px-8\">\n      <div class=\"space-y-5\">\n        <div class=\"space-y-2\">\n          <p class=\"text-xs uppercase tracking-[0.34em] text-[#9b7a5b]\">Market Intelligence</p>\n          <h2 class=\"font-serif text-3xl text-[#2d261f]\">\n            {{ selectedTaskLabel || t('results.insights.defaultTitle') }}\n          </h2>\n          <p class=\"max-w-2xl text-sm leading-6 text-[#6d5b49]\">\n            {{ t('results.insights.subtitle') }}\n          </p>\n        </div>\n\n        <div class=\"grid gap-4 md:grid-cols-3\">\n          <article\n            v-for=\"card in summaryCards\"\n            :key=\"card.label\"\n            class=\"rounded-[24px] border border-white/70 bg-white/70 p-4 shadow-[0_12px_30px_rgba(92,68,36,0.06)] backdrop-blur\"\n          >\n            <p class=\"text-xs uppercase tracking-[0.22em] text-[#9b7a5b]\">{{ card.label }}</p>\n            <p class=\"mt-3 text-2xl font-semibold text-[#231d18]\">{{ card.value }}</p>\n            <p class=\"mt-2 text-xs text-[#7a6855]\">{{ card.hint }}</p>\n          </article>\n        </div>\n\n        <PriceTrendChart :points=\"insights?.daily_trend || []\" />\n      </div>\n\n      <div class=\"space-y-4\">\n        <div class=\"rounded-[28px] border border-[#d8c7b5] bg-[#2a4c53] p-6 text-[#f7f1e7] shadow-[0_16px_40px_rgba(24,48,52,0.24)]\">\n          <p class=\"text-xs uppercase tracking-[0.3em] text-[#c7ddd7]\">Trend Reading</p>\n          <p class=\"mt-4 text-3xl font-semibold\">\n            {{ t('results.insights.snapshotCount', { count: insights?.market_summary.sample_count || 0 }) }}\n          </p>\n          <p class=\"mt-2 text-sm leading-6 text-[#d5e8e2]\">\n            {{ t('results.insights.trendReading') }}\n          </p>\n        </div>\n\n        <div class=\"rounded-[28px] border border-[#d8c7b5] bg-white/80 p-5 shadow-[0_12px_30px_rgba(92,68,36,0.06)]\">\n          <p class=\"text-xs uppercase tracking-[0.24em] text-[#9b7a5b]\">Snapshot Note</p>\n          <p class=\"mt-4 text-sm leading-6 text-[#5e5043]\">\n            {{ latestSnapshotText }}\n          </p>\n          <div class=\"mt-4 grid gap-3 text-sm text-[#6d5b49]\">\n            <div class=\"rounded-2xl bg-[#f8f1e5] px-4 py-3\">\n              {{ t('results.insights.currentMedian') }}\n              <span class=\"font-semibold text-[#2d261f]\">\n                {{ insights?.market_summary.median_price ? `¥${insights.market_summary.median_price}` : '—' }}\n              </span>\n            </div>\n            <div class=\"rounded-2xl bg-[#f4ece2] px-4 py-3\">\n              {{ t('results.insights.historyMin') }}\n              <span class=\"font-semibold text-[#2d261f]\">\n                {{ insights?.history_summary.min_price ? `¥${insights.history_summary.min_price}` : '—' }}\n              </span>\n            </div>\n            <div class=\"rounded-2xl bg-[#eee4d7] px-4 py-3\">\n              {{ t('results.insights.historyMax') }}\n              <span class=\"font-semibold text-[#2d261f]\">\n                {{ insights?.history_summary.max_price ? `¥${insights.history_summary.max_price}` : '—' }}\n              </span>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </section>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/settings/NotificationSettingsPanel.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, reactive, ref, watch } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport { BellRing, Radio, ShieldCheck, Send, TestTube2, Trash2, Webhook } from 'lucide-vue-next'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\nimport { Switch } from '@/components/ui/switch'\nimport { Textarea } from '@/components/ui/textarea'\nimport type { NotificationSettings, NotificationSettingsUpdate, NotificationTestResponse } from '@/api/settings'\n\ntype ChannelKey = 'ntfy' | 'bark' | 'gotify' | 'wecom' | 'telegram' | 'webhook'\n\nconst props = defineProps<{\n  settings: NotificationSettings\n  isReady: boolean\n  isSaving: boolean\n  saveSettings: (payload: NotificationSettingsUpdate) => Promise<void>\n  testSettings: (payload: { channel?: string; settings: NotificationSettingsUpdate }) => Promise<NotificationTestResponse>\n}>()\nconst { t } = useI18n()\n\nconst initialValues = reactive<NotificationSettingsUpdate>({})\nconst form = reactive<NotificationSettingsUpdate>({})\nconst secretConfigured = reactive<Record<string, boolean>>({})\nconst clearedFields = reactive<Record<string, boolean>>({})\nconst testResults = reactive<Record<string, { success: boolean; message: string; label: string }>>({})\nconst testingChannel = ref<string | null>(null)\nconst mutableInitialValues = initialValues as Record<string, string | boolean | null | undefined>\nconst mutableForm = form as Record<string, string | boolean | null | undefined>\nconst mutableClearedFields = clearedFields as Record<string, boolean>\n\nconst secretFields = ['BARK_URL', 'GOTIFY_TOKEN', 'WX_BOT_URL', 'TELEGRAM_BOT_TOKEN', 'WEBHOOK_URL', 'WEBHOOK_HEADERS'] as const\nconst channelFields: Record<ChannelKey, (keyof NotificationSettingsUpdate)[]> = {\n  ntfy: ['NTFY_TOPIC_URL'],\n  bark: ['BARK_URL'],\n  gotify: ['GOTIFY_URL', 'GOTIFY_TOKEN'],\n  wecom: ['WX_BOT_URL'],\n  telegram: ['TELEGRAM_BOT_TOKEN', 'TELEGRAM_CHAT_ID', 'TELEGRAM_API_BASE_URL'],\n  webhook: ['WEBHOOK_URL', 'WEBHOOK_METHOD', 'WEBHOOK_CONTENT_TYPE', 'WEBHOOK_HEADERS', 'WEBHOOK_QUERY_PARAMETERS', 'WEBHOOK_BODY'],\n}\n\nfunction syncFromSettings(settings: NotificationSettings) {\n  initialValues.NTFY_TOPIC_URL = settings.NTFY_TOPIC_URL ?? ''\n  initialValues.GOTIFY_URL = settings.GOTIFY_URL ?? ''\n  initialValues.TELEGRAM_CHAT_ID = settings.TELEGRAM_CHAT_ID ?? ''\n  initialValues.TELEGRAM_API_BASE_URL = settings.TELEGRAM_API_BASE_URL ?? 'https://api.telegram.org'\n  initialValues.WEBHOOK_METHOD = settings.WEBHOOK_METHOD ?? 'POST'\n  initialValues.WEBHOOK_CONTENT_TYPE = settings.WEBHOOK_CONTENT_TYPE ?? 'JSON'\n  initialValues.WEBHOOK_QUERY_PARAMETERS = settings.WEBHOOK_QUERY_PARAMETERS ?? ''\n  initialValues.WEBHOOK_BODY = settings.WEBHOOK_BODY ?? ''\n  initialValues.PCURL_TO_MOBILE = settings.PCURL_TO_MOBILE ?? true\n\n  Object.assign(form, initialValues, {\n    BARK_URL: '',\n    GOTIFY_TOKEN: '',\n    WX_BOT_URL: '',\n    TELEGRAM_BOT_TOKEN: '',\n    WEBHOOK_URL: '',\n    WEBHOOK_HEADERS: '',\n  })\n\n  secretConfigured.BARK_URL = !!settings.BARK_URL_SET\n  secretConfigured.GOTIFY_TOKEN = !!settings.GOTIFY_TOKEN_SET\n  secretConfigured.WX_BOT_URL = !!settings.WX_BOT_URL_SET\n  secretConfigured.TELEGRAM_BOT_TOKEN = !!settings.TELEGRAM_BOT_TOKEN_SET\n  secretConfigured.WEBHOOK_URL = !!settings.WEBHOOK_URL_SET\n  secretConfigured.WEBHOOK_HEADERS = !!settings.WEBHOOK_HEADERS_SET\n\n  for (const field of Object.keys(clearedFields)) {\n    clearedFields[field] = false\n  }\n}\n\nwatch(() => props.settings, syncFromSettings, { immediate: true, deep: true })\n\nconst activeChannels = computed(() => props.settings.CONFIGURED_CHANNELS ?? [])\nconst summaryText = computed(() => (\n  activeChannels.value.length ? activeChannels.value.join(' / ') : t('notifyPanel.noActiveChannels')\n))\n\nfunction updateSecretField(field: keyof NotificationSettingsUpdate, value: string) {\n  mutableForm[field as string] = value\n  mutableClearedFields[field as string] = false\n}\n\nfunction updateField(field: keyof NotificationSettingsUpdate, value: string) {\n  mutableForm[field as string] = value\n  mutableClearedFields[field as string] = false\n}\n\nfunction clearChannel(channel: ChannelKey) {\n  for (const field of channelFields[channel]) {\n    const key = field as string\n    mutableForm[key] = typeof mutableForm[key] === 'boolean' ? false : ''\n    mutableClearedFields[key] = true\n  }\n  if (channel === 'webhook') {\n    form.WEBHOOK_METHOD = 'POST'\n    form.WEBHOOK_CONTENT_TYPE = 'JSON'\n  }\n}\n\nfunction buildPayload(): NotificationSettingsUpdate {\n  const payload: NotificationSettingsUpdate = {}\n  const mutablePayload = payload as Record<string, string | boolean | null | undefined>\n  const textFields: (keyof NotificationSettingsUpdate)[] = [\n    'NTFY_TOPIC_URL', 'GOTIFY_URL', 'TELEGRAM_CHAT_ID', 'TELEGRAM_API_BASE_URL', 'WEBHOOK_METHOD',\n    'WEBHOOK_CONTENT_TYPE', 'WEBHOOK_QUERY_PARAMETERS', 'WEBHOOK_BODY',\n  ]\n\n  for (const field of textFields) {\n    if (mutableClearedFields[field as string]) {\n      mutablePayload[field as string] = null\n      continue\n    }\n    const current = String(mutableForm[field as string] ?? '').trim()\n    const initial = String(mutableInitialValues[field as string] ?? '').trim()\n    if (current !== initial) {\n      mutablePayload[field as string] = current || null\n    }\n  }\n\n  for (const field of secretFields) {\n    if (mutableClearedFields[field as string]) {\n      mutablePayload[field as string] = null\n      continue\n    }\n    const value = String(mutableForm[field as string] ?? '').trim()\n    if (value) {\n      mutablePayload[field as string] = value\n    }\n  }\n\n  if (form.PCURL_TO_MOBILE !== initialValues.PCURL_TO_MOBILE) {\n    payload.PCURL_TO_MOBILE = !!form.PCURL_TO_MOBILE\n  }\n  return payload\n}\n\nfunction isChannelConfigured(channel: ChannelKey) {\n  return activeChannels.value.includes(channel)\n}\n\nasync function handleSave() {\n  await props.saveSettings(buildPayload())\n}\n\nasync function handleTest(channel?: ChannelKey) {\n  testingChannel.value = channel ?? 'all'\n  try {\n    const response = await props.testSettings({ channel, settings: buildPayload() })\n    Object.assign(testResults, response.results)\n  } finally {\n    testingChannel.value = null\n  }\n}\n\nfunction resultClass(channel: ChannelKey) {\n  return testResults[channel]?.success\n    ? 'border-emerald-200 bg-emerald-50 text-emerald-700'\n    : 'border-red-200 bg-red-50 text-red-700'\n}\n\nfunction resolveChannelBadge(channel: ChannelKey) {\n  return isChannelConfigured(channel) ? t('common.active') : t('common.inactive')\n}\n</script>\n\n<template>\n  <div class=\"space-y-4\">\n    <Card class=\"overflow-hidden border-slate-200 bg-[radial-gradient(circle_at_top_left,_rgba(14,165,233,0.12),_transparent_35%),linear-gradient(135deg,_#ffffff,_#f8fafc)]\">\n      <CardHeader>\n        <div class=\"flex flex-col gap-4 md:flex-row md:items-start md:justify-between\">\n          <div class=\"space-y-2\">\n            <div class=\"flex items-center gap-2 text-slate-800\">\n              <BellRing class=\"h-5 w-5 text-sky-600\" />\n              <CardTitle>{{ t('notifyPanel.title') }}</CardTitle>\n            </div>\n            <CardDescription>{{ t('notifyPanel.description') }}</CardDescription>\n          </div>\n          <div class=\"flex flex-wrap gap-2\">\n            <Badge variant=\"outline\" class=\"border-sky-200 bg-sky-50 text-sky-700\">{{ t('notifyPanel.enabledChannels', { channels: summaryText }) }}</Badge>\n            <Badge variant=\"outline\" class=\"border-slate-200 bg-white text-slate-600\">{{ t('notifyPanel.supportedVariables') }}</Badge>\n          </div>\n        </div>\n      </CardHeader>\n      <CardContent class=\"grid gap-4 md:grid-cols-[1.2fr_0.8fr]\">\n        <div class=\"rounded-2xl border border-slate-200 bg-white/80 p-4\">\n          <div class=\"flex items-center justify-between gap-3\">\n            <div>\n              <p class=\"text-sm font-semibold text-slate-900\">{{ t('notifyPanel.globalBehavior') }}</p>\n              <p class=\"text-sm text-slate-500\">{{ t('notifyPanel.globalBehaviorDescription') }}</p>\n            </div>\n            <div class=\"flex items-center gap-3 rounded-full border border-slate-200 bg-slate-50 px-3 py-2\">\n              <Switch id=\"pcurl\" :model-value=\"!!form.PCURL_TO_MOBILE\" @update:model-value=\"(value) => form.PCURL_TO_MOBILE = !!value\" />\n              <Label for=\"pcurl\" class=\"text-sm text-slate-700\">{{ t('notifyPanel.preferMobileLink') }}</Label>\n            </div>\n          </div>\n        </div>\n        <div class=\"rounded-2xl border border-slate-200 bg-slate-900 p-4 text-slate-100\">\n          <div class=\"flex items-center gap-2 text-sm font-semibold\">\n            <ShieldCheck class=\"h-4 w-4 text-emerald-300\" />\n            {{ t('notifyPanel.configurationNotes') }}\n          </div>\n          <p class=\"mt-2 text-sm leading-6 text-slate-300\">{{ t('notifyPanel.configurationNotesDescription') }}</p>\n        </div>\n      </CardContent>\n    </Card>\n\n    <div v-if=\"!isReady\" class=\"rounded-2xl border border-slate-200 bg-white px-4 py-10 text-center text-sm text-slate-500\">\n      {{ t('notifyPanel.loading') }}\n    </div>\n\n    <div v-else class=\"grid gap-4\">\n      <Card class=\"border-l-4 border-l-sky-500\">\n        <CardHeader><CardTitle class=\"flex items-center gap-2\"><Radio class=\"h-4 w-4 text-sky-600\" /> Ntfy</CardTitle><CardDescription>{{ t('notifyPanel.ntfy.description') }}</CardDescription></CardHeader>\n        <CardContent><Label>Ntfy Topic URL</Label><Input :model-value=\"form.NTFY_TOPIC_URL ?? ''\" placeholder=\"https://ntfy.sh/topic\" @update:model-value=\"(value) => updateField('NTFY_TOPIC_URL', String(value))\" /></CardContent>\n        <CardFooter class=\"justify-between\"><Badge :variant=\"isChannelConfigured('ntfy') ? 'default' : 'outline'\">{{ resolveChannelBadge('ntfy') }}</Badge><Button variant=\"outline\" size=\"sm\" :disabled=\"props.isSaving\" @click=\"handleTest('ntfy')\"><TestTube2 class=\"h-4 w-4\" />{{ t('notifyPanel.testThisChannel') }}</Button></CardFooter>\n      </Card>\n\n      <div class=\"grid gap-4 xl:grid-cols-2\">\n        <Card class=\"border-l-4 border-l-amber-500\">\n          <CardHeader><CardTitle>Bark</CardTitle><CardDescription>{{ t('notifyPanel.bark.description') }}</CardDescription></CardHeader>\n          <CardContent class=\"space-y-2\"><Label>Bark URL</Label><Input :model-value=\"form.BARK_URL ?? ''\" :placeholder=\"t('notifyPanel.secretPlaceholder')\" @update:model-value=\"(value) => updateSecretField('BARK_URL', String(value))\" /><p class=\"text-xs text-slate-500\">{{ secretConfigured.BARK_URL ? t('notifyPanel.bark.configuredHint') : t('notifyPanel.notConfigured') }}</p></CardContent>\n          <CardFooter class=\"justify-between\"><Badge :variant=\"isChannelConfigured('bark') ? 'default' : 'outline'\">{{ resolveChannelBadge('bark') }}</Badge><div class=\"flex gap-2\"><Button variant=\"ghost\" size=\"sm\" :disabled=\"props.isSaving\" @click=\"clearChannel('bark')\"><Trash2 class=\"h-4 w-4\" />{{ t('notifyPanel.clear') }}</Button><Button variant=\"outline\" size=\"sm\" :disabled=\"props.isSaving\" @click=\"handleTest('bark')\"><TestTube2 class=\"h-4 w-4\" />{{ t('notifyPanel.test') }}</Button></div></CardFooter>\n        </Card>\n\n        <Card class=\"border-l-4 border-l-violet-500\">\n          <CardHeader><CardTitle>Gotify</CardTitle><CardDescription>{{ t('notifyPanel.gotify.description') }}</CardDescription></CardHeader>\n          <CardContent class=\"grid gap-4 md:grid-cols-2\">\n            <div class=\"grid gap-2\"><Label>Gotify URL</Label><Input :model-value=\"form.GOTIFY_URL ?? ''\" placeholder=\"https://gotify.example.com\" @update:model-value=\"(value) => updateField('GOTIFY_URL', String(value))\" /></div>\n            <div class=\"grid gap-2\"><Label>Gotify Token</Label><Input type=\"password\" :model-value=\"form.GOTIFY_TOKEN ?? ''\" :placeholder=\"t('notifyPanel.secretKeepPlaceholder')\" @update:model-value=\"(value) => updateSecretField('GOTIFY_TOKEN', String(value))\" /></div>\n          </CardContent>\n          <CardFooter class=\"justify-between\"><Badge :variant=\"isChannelConfigured('gotify') ? 'default' : 'outline'\">{{ resolveChannelBadge('gotify') }}</Badge><div class=\"flex gap-2\"><Button variant=\"ghost\" size=\"sm\" :disabled=\"props.isSaving\" @click=\"clearChannel('gotify')\"><Trash2 class=\"h-4 w-4\" />{{ t('notifyPanel.clear') }}</Button><Button variant=\"outline\" size=\"sm\" :disabled=\"props.isSaving\" @click=\"handleTest('gotify')\"><TestTube2 class=\"h-4 w-4\" />{{ t('notifyPanel.test') }}</Button></div></CardFooter>\n        </Card>\n      </div>\n\n      <div class=\"grid gap-4 xl:grid-cols-2\">\n        <Card class=\"border-l-4 border-l-emerald-500\">\n          <CardHeader><CardTitle>{{ t('notifyPanel.wecom.title') }}</CardTitle><CardDescription>{{ t('notifyPanel.wecom.description') }}</CardDescription></CardHeader>\n          <CardContent class=\"space-y-2\"><Label>{{ t('notifyPanel.wecom.urlLabel') }}</Label><Input :model-value=\"form.WX_BOT_URL ?? ''\" :placeholder=\"t('notifyPanel.secretPlaceholder')\" @update:model-value=\"(value) => updateSecretField('WX_BOT_URL', String(value))\" /><p class=\"text-xs text-slate-500\">{{ secretConfigured.WX_BOT_URL ? t('notifyPanel.wecom.configuredHint') : t('notifyPanel.notConfigured') }}</p></CardContent>\n          <CardFooter class=\"justify-between\"><Badge :variant=\"isChannelConfigured('wecom') ? 'default' : 'outline'\">{{ resolveChannelBadge('wecom') }}</Badge><div class=\"flex gap-2\"><Button variant=\"ghost\" size=\"sm\" :disabled=\"props.isSaving\" @click=\"clearChannel('wecom')\"><Trash2 class=\"h-4 w-4\" />{{ t('notifyPanel.clear') }}</Button><Button variant=\"outline\" size=\"sm\" :disabled=\"props.isSaving\" @click=\"handleTest('wecom')\"><TestTube2 class=\"h-4 w-4\" />{{ t('notifyPanel.test') }}</Button></div></CardFooter>\n        </Card>\n\n        <Card class=\"border-l-4 border-l-cyan-500\">\n          <CardHeader><CardTitle>Telegram</CardTitle><CardDescription>{{ t('notifyPanel.telegram.description') }}</CardDescription></CardHeader>\n          <CardContent class=\"grid gap-4 md:grid-cols-3\">\n            <div class=\"grid gap-2\"><Label>Bot Token</Label><Input type=\"password\" :model-value=\"form.TELEGRAM_BOT_TOKEN ?? ''\" :placeholder=\"t('notifyPanel.secretKeepPlaceholder')\" @update:model-value=\"(value) => updateSecretField('TELEGRAM_BOT_TOKEN', String(value))\" /></div>\n            <div class=\"grid gap-2\"><Label>Chat ID</Label><Input :model-value=\"form.TELEGRAM_CHAT_ID ?? ''\" :placeholder=\"t('notifyPanel.telegram.chatIdPlaceholder')\" @update:model-value=\"(value) => updateField('TELEGRAM_CHAT_ID', String(value))\" /></div>\n            <div class=\"grid gap-2\"><Label>{{ t('notifyPanel.telegram.apiBaseUrl') }}</Label><Input :model-value=\"form.TELEGRAM_API_BASE_URL ?? ''\" placeholder=\"https://api.telegram.org\" @update:model-value=\"(value) => updateField('TELEGRAM_API_BASE_URL', String(value))\" /></div>\n          </CardContent>\n          <CardFooter class=\"justify-between\"><Badge :variant=\"isChannelConfigured('telegram') ? 'default' : 'outline'\">{{ resolveChannelBadge('telegram') }}</Badge><div class=\"flex gap-2\"><Button variant=\"ghost\" size=\"sm\" :disabled=\"props.isSaving\" @click=\"clearChannel('telegram')\"><Trash2 class=\"h-4 w-4\" />{{ t('notifyPanel.clear') }}</Button><Button variant=\"outline\" size=\"sm\" :disabled=\"props.isSaving\" @click=\"handleTest('telegram')\"><TestTube2 class=\"h-4 w-4\" />{{ t('notifyPanel.test') }}</Button></div></CardFooter>\n        </Card>\n      </div>\n\n      <Card class=\"border-l-4 border-l-rose-500\">\n        <CardHeader><CardTitle class=\"flex items-center gap-2\"><Webhook class=\"h-4 w-4 text-rose-500\" /> {{ t('notifyPanel.webhook.title') }}</CardTitle><CardDescription>{{ t('notifyPanel.webhook.description') }}</CardDescription></CardHeader>\n        <CardContent class=\"grid gap-4\">\n          <div class=\"grid gap-4 md:grid-cols-2\">\n            <div class=\"grid gap-2\"><Label>{{ t('notifyPanel.webhook.urlLabel') }}</Label><Input :model-value=\"form.WEBHOOK_URL ?? ''\" :placeholder=\"t('notifyPanel.secretPlaceholder')\" @update:model-value=\"(value) => updateSecretField('WEBHOOK_URL', String(value))\" /></div>\n            <div class=\"grid gap-2\"><Label>{{ t('notifyPanel.webhook.headersLabel') }}</Label><Textarea :model-value=\"form.WEBHOOK_HEADERS ?? ''\" :placeholder=\"t('notifyPanel.webhook.headersPlaceholder')\" @update:model-value=\"(value) => updateSecretField('WEBHOOK_HEADERS', String(value))\" /></div>\n          </div>\n          <div class=\"grid gap-4 md:grid-cols-2\">\n            <div class=\"grid gap-2\"><Label>{{ t('notifyPanel.webhook.methodLabel') }}</Label><Select :model-value=\"form.WEBHOOK_METHOD || 'POST'\" @update:model-value=\"(value) => updateField('WEBHOOK_METHOD', String(value))\"><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value=\"POST\">POST</SelectItem><SelectItem value=\"GET\">GET</SelectItem></SelectContent></Select></div>\n            <div class=\"grid gap-2\"><Label>{{ t('notifyPanel.webhook.contentTypeLabel') }}</Label><Select :model-value=\"form.WEBHOOK_CONTENT_TYPE || 'JSON'\" @update:model-value=\"(value) => updateField('WEBHOOK_CONTENT_TYPE', String(value))\"><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value=\"JSON\">JSON</SelectItem><SelectItem value=\"FORM\">FORM</SelectItem></SelectContent></Select></div>\n          </div>\n          <div class=\"grid gap-4 md:grid-cols-2\">\n            <div class=\"grid gap-2\"><Label>{{ t('notifyPanel.webhook.queryLabel') }}</Label><Textarea :model-value=\"form.WEBHOOK_QUERY_PARAMETERS ?? ''\" :placeholder=\"t('notifyPanel.webhook.queryPlaceholder')\" @update:model-value=\"(value) => updateField('WEBHOOK_QUERY_PARAMETERS', String(value))\" /></div>\n            <div class=\"grid gap-2\"><Label>{{ t('notifyPanel.webhook.bodyLabel') }}</Label><Textarea :model-value=\"form.WEBHOOK_BODY ?? ''\" :placeholder=\"t('notifyPanel.webhook.bodyPlaceholder')\" @update:model-value=\"(value) => updateField('WEBHOOK_BODY', String(value))\" /></div>\n          </div>\n          <div v-pre class=\"rounded-2xl border border-dashed border-rose-200 bg-rose-50/70 px-4 py-3 text-sm text-rose-700\">{{ t('notifyPanel.webhook.variablesHelp') }}</div>\n        </CardContent>\n        <CardFooter class=\"justify-between\"><Badge :variant=\"isChannelConfigured('webhook') ? 'default' : 'outline'\">{{ resolveChannelBadge('webhook') }}</Badge><div class=\"flex gap-2\"><Button variant=\"ghost\" size=\"sm\" :disabled=\"props.isSaving\" @click=\"clearChannel('webhook')\"><Trash2 class=\"h-4 w-4\" />{{ t('notifyPanel.clear') }}</Button><Button variant=\"outline\" size=\"sm\" :disabled=\"props.isSaving\" @click=\"handleTest('webhook')\"><TestTube2 class=\"h-4 w-4\" />{{ t('notifyPanel.test') }}</Button></div></CardFooter>\n      </Card>\n\n      <div v-for=\"channel in ['ntfy', 'bark', 'gotify', 'wecom', 'telegram', 'webhook']\" :key=\"channel\">\n        <div v-if=\"testResults[channel]\" class=\"rounded-2xl border px-4 py-3 text-sm\" :class=\"resultClass(channel as ChannelKey)\">\n          {{ testResults[channel].label }}：{{ testResults[channel].message }}\n        </div>\n      </div>\n    </div>\n\n    <div class=\"sticky bottom-0 z-10 flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white/95 p-4 shadow-lg backdrop-blur md:flex-row md:items-center md:justify-between\">\n      <div class=\"flex items-center gap-2 text-sm text-slate-600\"><Send class=\"h-4 w-4 text-slate-400\" />{{ t('notifyPanel.footerHint') }}</div>\n      <div class=\"flex flex-col gap-2 sm:flex-row\">\n        <Button variant=\"outline\" :disabled=\"props.isSaving\" @click=\"handleTest()\"><TestTube2 class=\"h-4 w-4\" />{{ testingChannel === 'all' ? t('common.testing') : t('notifyPanel.testAll') }}</Button>\n        <Button :disabled=\"props.isSaving\" @click=\"handleSave\"><Send class=\"h-4 w-4\" />{{ t('notifyPanel.save') }}</Button>\n      </div>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/settings/RotationSettingsPanel.vue",
    "content": "<script setup lang=\"ts\">\nimport { useI18n } from 'vue-i18n'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\nimport { Switch } from '@/components/ui/switch'\nimport { Textarea } from '@/components/ui/textarea'\nimport type { RotationSettings } from '@/api/settings'\n\ndefineProps<{\n  settings: RotationSettings\n  isReady: boolean\n  isSaving: boolean\n}>()\nconst { t } = useI18n()\n\nconst emit = defineEmits<{\n  (e: 'save'): void\n}>()\n</script>\n\n<template>\n  <Card class=\"overflow-hidden border-slate-200\">\n    <CardHeader>\n      <CardTitle>{{ t('rotation.title') }}</CardTitle>\n      <CardDescription>{{ t('rotation.description') }}</CardDescription>\n    </CardHeader>\n    <CardContent v-if=\"isReady\" class=\"grid gap-6 lg:grid-cols-2\">\n      <section class=\"rounded-[28px] border border-[#d7d0c6] bg-[linear-gradient(145deg,#f6f2e9_0%,#efe8db_100%)] p-5 shadow-[0_14px_30px_rgba(69,52,29,0.08)]\">\n        <div class=\"mb-5 flex items-center justify-between\">\n          <div>\n            <h3 class=\"font-semibold text-[#2d261f]\">{{ t('rotation.account.title') }}</h3>\n            <p class=\"text-sm text-[#786858]\">{{ t('rotation.account.description') }}</p>\n          </div>\n          <Switch v-model:checked=\"settings.ACCOUNT_ROTATION_ENABLED\" />\n        </div>\n\n        <div class=\"grid gap-4\">\n          <div class=\"grid gap-2\">\n            <Label>{{ t('rotation.account.stateDir') }}</Label>\n            <Input v-model=\"settings.ACCOUNT_STATE_DIR\" placeholder=\"state\" />\n          </div>\n          <div class=\"grid gap-2\">\n            <Label>{{ t('rotation.mode') }}</Label>\n            <Select v-model=\"settings.ACCOUNT_ROTATION_MODE\">\n              <SelectTrigger>\n                <SelectValue :placeholder=\"t('rotation.modePlaceholder')\" />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"per_task\">{{ t('rotation.perTask') }}</SelectItem>\n                <SelectItem value=\"on_failure\">{{ t('rotation.onFailure') }}</SelectItem>\n              </SelectContent>\n            </Select>\n          </div>\n          <div class=\"grid grid-cols-2 gap-4\">\n            <div class=\"grid gap-2\">\n              <Label>{{ t('rotation.retryLimit') }}</Label>\n              <Input v-model.number=\"settings.ACCOUNT_ROTATION_RETRY_LIMIT\" type=\"number\" min=\"1\" />\n            </div>\n            <div class=\"grid gap-2\">\n              <Label>{{ t('rotation.blacklistTtl') }}</Label>\n              <Input v-model.number=\"settings.ACCOUNT_BLACKLIST_TTL\" type=\"number\" min=\"0\" />\n            </div>\n          </div>\n        </div>\n      </section>\n\n      <section class=\"rounded-[28px] border border-slate-200 bg-white p-5 shadow-sm\">\n        <div class=\"mb-5 flex items-center justify-between\">\n          <div>\n            <h3 class=\"font-semibold text-slate-900\">{{ t('rotation.proxy.title') }}</h3>\n            <p class=\"text-sm text-slate-500\">{{ t('rotation.proxy.description') }}</p>\n          </div>\n          <Switch v-model:checked=\"settings.PROXY_ROTATION_ENABLED\" />\n        </div>\n\n        <div class=\"grid gap-4\">\n          <div class=\"grid gap-2\">\n            <Label>{{ t('rotation.mode') }}</Label>\n            <Select v-model=\"settings.PROXY_ROTATION_MODE\">\n              <SelectTrigger>\n                <SelectValue :placeholder=\"t('rotation.modePlaceholder')\" />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"per_task\">{{ t('rotation.perTask') }}</SelectItem>\n                <SelectItem value=\"on_failure\">{{ t('rotation.onFailure') }}</SelectItem>\n              </SelectContent>\n            </Select>\n          </div>\n          <div class=\"grid gap-2\">\n            <Label>{{ t('rotation.proxy.pool') }}</Label>\n            <Textarea\n              v-model=\"settings.PROXY_POOL\"\n              class=\"min-h-[120px]\"\n              placeholder=\"http://127.0.0.1:7890,socks5://127.0.0.1:1080\"\n            />\n          </div>\n          <div class=\"grid grid-cols-2 gap-4\">\n            <div class=\"grid gap-2\">\n              <Label>{{ t('rotation.retryLimit') }}</Label>\n              <Input v-model.number=\"settings.PROXY_ROTATION_RETRY_LIMIT\" type=\"number\" min=\"1\" />\n            </div>\n            <div class=\"grid gap-2\">\n              <Label>{{ t('rotation.blacklistTtl') }}</Label>\n              <Input v-model.number=\"settings.PROXY_BLACKLIST_TTL\" type=\"number\" min=\"0\" />\n            </div>\n          </div>\n        </div>\n      </section>\n    </CardContent>\n    <CardContent v-else class=\"py-8 text-sm text-gray-500\">\n      {{ t('rotation.loading') }}\n    </CardContent>\n    <CardFooter v-if=\"isReady\" class=\"flex gap-2\">\n      <Button @click=\"emit('save')\" :disabled=\"isSaving\">{{ t('rotation.save') }}</Button>\n    </CardFooter>\n  </Card>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/tasks/TaskCreateDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, watch } from 'vue'\nimport { useRoute } from 'vue-router'\nimport { useI18n } from 'vue-i18n'\nimport { createTaskWithAI } from '@/api/tasks'\nimport { useTaskGenerationJob } from '@/composables/useTaskGenerationJob'\nimport type { TaskGenerateRequest } from '@/types/task.d.ts'\nimport { parseTaskFormDefaults } from '@/lib/taskFormQuery'\nimport TaskForm from '@/components/tasks/TaskForm.vue'\nimport TaskGenerationDialog from '@/components/tasks/TaskGenerationDialog.vue'\nimport { Button } from '@/components/ui/button'\nimport { toast } from '@/components/ui/toast'\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from '@/components/ui/dialog'\nconst { t } = useI18n()\n\nconst props = defineProps<{\n  accountOptions?: { name: string; path: string }[]\n}>()\n\nconst emit = defineEmits<{\n  (event: 'created'): void\n}>()\n\nconst route = useRoute()\nconst isFormOpen = ref(false)\nconst isProgressOpen = ref(false)\nconst isSubmitting = ref(false)\nconst defaultAccountPath = ref('')\nconst defaultValues = ref({})\nconst {\n  activeJob,\n  pollingError,\n  beginPolling,\n  clearJob,\n} = useTaskGenerationJob()\n\nfunction resolveAccountPath(accountName: string) {\n  const match = (props.accountOptions || []).find((account) => account.name === accountName)\n  return match ? match.path : ''\n}\n\nasync function handleCreateTask(data: TaskGenerateRequest) {\n  isSubmitting.value = true\n  clearJob()\n  try {\n    const result = await createTaskWithAI(data)\n    if (result.job) {\n      isFormOpen.value = false\n      isProgressOpen.value = true\n      beginPolling(result.job)\n      isSubmitting.value = false\n      return\n    }\n    emit('created')\n    toast({ title: t('tasks.toasts.created') })\n    isFormOpen.value = false\n  } catch (error) {\n    toast({\n      title: t('tasks.toasts.createFailed'),\n      description: (error as Error).message,\n      variant: 'destructive',\n    })\n  } finally {\n    if (!isProgressOpen.value) {\n      isSubmitting.value = false\n    }\n  }\n}\n\nwatch(\n  () => [route.query, props.accountOptions],\n  () => {\n    const accountName = typeof route.query.account === 'string' ? route.query.account : ''\n    defaultAccountPath.value = accountName ? resolveAccountPath(accountName) : ''\n    defaultValues.value = parseTaskFormDefaults(route.query)\n    if (route.query.create === '1') {\n      isFormOpen.value = true\n    }\n  },\n  { immediate: true }\n)\n\nwatch(\n  () => activeJob.value?.status,\n  (status, previousStatus) => {\n    if (!status || status === previousStatus) return\n    if (status === 'completed') {\n      isSubmitting.value = false\n      emit('created')\n      toast({ title: t('tasks.toasts.created') })\n      isProgressOpen.value = false\n      clearJob()\n      return\n    }\n    if (status === 'failed') {\n      isSubmitting.value = false\n      toast({\n        title: t('tasks.toasts.createFailed'),\n        description: activeJob.value?.error || activeJob.value?.message,\n        variant: 'destructive',\n      })\n    }\n  }\n)\n\nwatch(pollingError, (value) => {\n  if (!value) return\n  isSubmitting.value = false\n  toast({\n    title: t('tasks.toasts.progressFailed'),\n    description: value.message,\n    variant: 'destructive',\n  })\n})\n</script>\n\n<template>\n  <Dialog v-model:open=\"isFormOpen\">\n    <DialogTrigger as-child>\n      <Button>{{ t('tasks.createDialog.trigger') }}</Button>\n    </DialogTrigger>\n    <DialogContent class=\"sm:max-w-[640px] max-h-[85vh] overflow-y-auto\">\n      <DialogHeader>\n        <DialogTitle>{{ t('tasks.createDialog.title') }}</DialogTitle>\n      </DialogHeader>\n      <TaskForm\n        mode=\"create\"\n        :account-options=\"accountOptions\"\n        :default-account=\"defaultAccountPath\"\n        :default-values=\"defaultValues\"\n        @submit=\"(data) => handleCreateTask(data as TaskGenerateRequest)\"\n      />\n      <DialogFooter>\n        <Button type=\"submit\" form=\"task-form\" :disabled=\"isSubmitting\">\n          {{ isSubmitting ? t('tasks.createDialog.submitting') : t('tasks.createDialog.submit') }}\n        </Button>\n      </DialogFooter>\n    </DialogContent>\n  </Dialog>\n  <TaskGenerationDialog\n    v-model:open=\"isProgressOpen\"\n    :job=\"activeJob\"\n  />\n</template>\n"
  },
  {
    "path": "web-ui/src/components/tasks/TaskForm.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, watch, computed } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport type { Task, TaskGenerateRequest } from '@/types/task.d.ts'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { Switch } from '@/components/ui/switch'\nimport { Textarea } from '@/components/ui/textarea'\nimport { toast } from '@/components/ui/toast'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'\nimport TaskRegionSelector from '@/components/tasks/TaskRegionSelector.vue'\n\ntype FormMode = 'create' | 'edit'\ntype EmittedData = TaskGenerateRequest | Partial<Task>\nconst AUTO_ACCOUNT_VALUE = '__auto__'\nconst EMPTY_CRON_VALUE = '__manual__'\n\nconst props = defineProps<{\n  mode: FormMode\n  initialData?: Task | null\n  accountOptions?: { name: string; path: string }[]\n  defaultAccount?: string\n  defaultValues?: Partial<TaskGenerateRequest & Partial<Task>>\n}>()\n\nconst emit = defineEmits<{\n  (e: 'submit', data: EmittedData): void\n}>()\nconst { t } = useI18n()\n\nconst form = ref<any>({})\nconst accountStrategy = ref<'auto' | 'fixed' | 'rotate'>('auto')\nconst selectedAccountStateFile = ref(AUTO_ACCOUNT_VALUE)\nconst keywordRulesInput = ref('')\nconst cronMode = ref<'preset' | 'custom'>('preset')\n\n// 常用 cron 预设选项\nconst cronPresets = computed(() => [\n  { value: EMPTY_CRON_VALUE, label: t('tasks.form.cron.manual') },\n  { value: '*/5 * * * *', label: t('tasks.form.cron.every5Minutes') },\n  { value: '*/15 * * * *', label: t('tasks.form.cron.every15Minutes') },\n  { value: '*/30 * * * *', label: t('tasks.form.cron.every30Minutes') },\n  { value: '0 * * * *', label: t('tasks.form.cron.hourly') },\n  { value: '0 */2 * * *', label: t('tasks.form.cron.every2Hours') },\n  { value: '0 */6 * * *', label: t('tasks.form.cron.every6Hours') },\n  { value: '0 8 * * *', label: t('tasks.form.cron.daily8') },\n  { value: '0 12 * * *', label: t('tasks.form.cron.daily12') },\n  { value: '0 18 * * *', label: t('tasks.form.cron.daily18') },\n  { value: '0 20 * * *', label: t('tasks.form.cron.daily20') },\n  { value: '0 8,12,18 * * *', label: t('tasks.form.cron.daily81218') },\n  { value: '0 9 * * 1-5', label: t('tasks.form.cron.weekday9') },\n  { value: '0 10 * * 6,0', label: t('tasks.form.cron.weekend10') },\n])\n\n// 判断 cron 值是否为预设值\nfunction isPresetCronValue(value: string): boolean {\n  if (!value) return true\n  return cronPresets.value.some((preset) => preset.value === value)\n}\n\n// 判断当前 cron 是否为预设值\nconst isPresetCron = computed(() => isPresetCronValue(form.value.cron))\n\n// 预设选择的值\nconst presetCronValue = computed({\n  get: () => {\n    if (!isPresetCron.value) return EMPTY_CRON_VALUE\n    return form.value.cron || EMPTY_CRON_VALUE\n  },\n  set: (val: string) => { form.value.cron = val === EMPTY_CRON_VALUE ? '' : val },\n})\nconst accountStrategyOptions = computed(() => [\n  { value: 'auto', label: t('tasks.form.accountStrategy.auto'), description: t('tasks.form.accountStrategy.autoDescription') },\n  { value: 'fixed', label: t('tasks.form.accountStrategy.fixed'), description: t('tasks.form.accountStrategy.fixedDescription') },\n  { value: 'rotate', label: t('tasks.form.accountStrategy.rotate'), description: t('tasks.form.accountStrategy.rotateDescription') },\n])\n\nfunction parseKeywordText(text: string): string[] {\n  const values = String(text || '')\n    .split(/[\\n,]+/)\n    .map((item) => item.trim())\n    .filter((item) => item.length > 0)\n\n  const seen = new Set<string>()\n  const deduped: string[] = []\n  for (const value of values) {\n    const key = value.toLowerCase()\n    if (seen.has(key)) continue\n    seen.add(key)\n    deduped.push(value)\n  }\n  return deduped\n}\n\nwatch(() => [props.mode, props.initialData, props.defaultValues, props.defaultAccount], () => {\n  const defaultValues = props.defaultValues || {}\n  if (props.mode === 'edit' && props.initialData) {\n    form.value = {\n      ...props.initialData,\n      ...defaultValues,\n      account_strategy:\n        defaultValues.account_strategy ||\n        props.initialData.account_strategy ||\n        (props.initialData.account_state_file ? 'fixed' : 'auto'),\n      account_state_file:\n        defaultValues.account_state_file ||\n        props.initialData.account_state_file ||\n        AUTO_ACCOUNT_VALUE,\n      analyze_images: defaultValues.analyze_images ?? props.initialData.analyze_images ?? true,\n      free_shipping: defaultValues.free_shipping ?? props.initialData.free_shipping ?? true,\n      new_publish_option:\n        defaultValues.new_publish_option || props.initialData.new_publish_option || '__none__',\n      region: defaultValues.region || props.initialData.region || '',\n      decision_mode: defaultValues.decision_mode || props.initialData.decision_mode || 'ai',\n    }\n    keywordRulesInput.value = (defaultValues.keyword_rules || props.initialData.keyword_rules || []).join('\\n')\n    // 编辑模式下，根据 cron 值判断模式\n    const cronVal = defaultValues.cron ?? props.initialData.cron ?? ''\n    cronMode.value = isPresetCronValue(cronVal) ? 'preset' : 'custom'\n  } else {\n    form.value = {\n      task_name: '',\n      keyword: '',\n      description: '',\n      analyze_images: true,\n      max_pages: 3,\n      personal_only: true,\n      min_price: undefined,\n      max_price: undefined,\n      cron: '',\n      account_strategy: props.defaultAccount ? 'fixed' : 'auto',\n      account_state_file: props.defaultAccount || AUTO_ACCOUNT_VALUE,\n      free_shipping: true,\n      new_publish_option: '__none__',\n      region: '',\n      decision_mode: 'ai',\n      ...defaultValues,\n    }\n    if (!form.value.account_strategy) {\n      form.value.account_strategy = props.defaultAccount ? 'fixed' : 'auto'\n    }\n    if (!form.value.account_state_file) {\n      form.value.account_state_file = props.defaultAccount || AUTO_ACCOUNT_VALUE\n    }\n    if (!form.value.new_publish_option) {\n      form.value.new_publish_option = '__none__'\n    }\n    keywordRulesInput.value = ''\n    if (defaultValues.keyword_rules && defaultValues.keyword_rules.length > 0) {\n      keywordRulesInput.value = defaultValues.keyword_rules.join('\\n')\n    }\n    // 创建模式下，根据默认值判断模式\n    const cronVal = defaultValues.cron ?? ''\n    cronMode.value = isPresetCronValue(cronVal) ? 'preset' : 'custom'\n  }\n\n  accountStrategy.value = form.value.account_strategy || (props.defaultAccount ? 'fixed' : 'auto')\n  selectedAccountStateFile.value =\n    form.value.account_state_file || props.defaultAccount || AUTO_ACCOUNT_VALUE\n}, { immediate: true, deep: true })\n\nwatch(accountStrategy, (value) => {\n  form.value.account_strategy = value\n  if (value === 'fixed') {\n    form.value.account_state_file = selectedAccountStateFile.value || props.defaultAccount || AUTO_ACCOUNT_VALUE\n    return\n  }\n  form.value.account_state_file = null\n})\n\nwatch(selectedAccountStateFile, (value) => {\n  if (accountStrategy.value !== 'fixed') return\n  form.value.account_state_file = value || props.defaultAccount || AUTO_ACCOUNT_VALUE\n})\n\nfunction handleAccountStrategyChange(event: Event) {\n  const value = (event.target as HTMLSelectElement).value as 'auto' | 'fixed' | 'rotate'\n  accountStrategy.value = value\n}\n\nfunction handleAccountStateFileChange(event: Event) {\n  selectedAccountStateFile.value = (event.target as HTMLSelectElement).value || AUTO_ACCOUNT_VALUE\n}\n\nfunction handleSubmit() {\n  if (!form.value.task_name || !form.value.keyword) {\n    toast({\n      title: t('tasks.form.validation.incomplete'),\n      description: t('tasks.form.validation.nameAndKeywordRequired'),\n      variant: 'destructive',\n    })\n    return\n  }\n\n  const decisionMode = form.value.decision_mode || 'ai'\n  if (decisionMode === 'ai' && !String(form.value.description || '').trim()) {\n    toast({\n      title: t('tasks.form.validation.incomplete'),\n      description: t('tasks.form.validation.aiDescriptionRequired'),\n      variant: 'destructive',\n    })\n    return\n  }\n\n  const keywordRules = parseKeywordText(keywordRulesInput.value)\n  if (decisionMode === 'keyword' && keywordRules.length === 0) {\n    toast({\n      title: t('tasks.form.validation.keywordRuleIncomplete'),\n      description: t('tasks.form.validation.keywordRuleRequired'),\n      variant: 'destructive',\n    })\n    return\n  }\n\n  // Filter out fields that shouldn't be sent in update requests\n  const { id, is_running, next_run_at, ...submitData } = form.value as any\n  const currentAccountStrategy = accountStrategy.value || 'auto'\n  if (currentAccountStrategy === 'fixed') {\n    const currentAccountStateFile = selectedAccountStateFile.value || AUTO_ACCOUNT_VALUE\n    if (currentAccountStateFile === AUTO_ACCOUNT_VALUE) {\n      toast({\n        title: t('tasks.form.validation.accountStrategyIncomplete'),\n        description: t('tasks.form.validation.fixedAccountRequired'),\n        variant: 'destructive',\n      })\n      return\n    }\n    submitData.account_state_file = currentAccountStateFile\n  } else {\n    submitData.account_state_file = null\n  }\n\n  if (typeof submitData.region === 'string') {\n    const normalized = submitData.region\n      .trim()\n      .split('/')\n      .map((part: string) => part.trim().replace(/(省|市)$/u, ''))\n      .filter((part: string) => part.length > 0)\n      .join('/')\n    submitData.region = normalized\n  }\n\n  if (submitData.new_publish_option === '__none__') {\n    submitData.new_publish_option = ''\n  }\n\n  submitData.decision_mode = decisionMode\n  submitData.account_strategy = currentAccountStrategy\n  submitData.analyze_images = submitData.analyze_images !== false\n  submitData.keyword_rules = decisionMode === 'keyword' ? keywordRules : []\n  if (decisionMode === 'keyword' && !submitData.description) {\n    submitData.description = ''\n  }\n\n  emit('submit', submitData)\n}\n</script>\n\n<template>\n  <form id=\"task-form\" @submit.prevent=\"handleSubmit\">\n    <div class=\"grid gap-6 py-4\">\n      <div class=\"grid grid-cols-4 items-center gap-4\">\n        <Label for=\"task-name\" class=\"text-right\">{{ t('tasks.form.taskName') }}</Label>\n        <Input id=\"task-name\" v-model=\"form.task_name\" class=\"col-span-3\" :placeholder=\"t('tasks.form.taskNamePlaceholder')\" required />\n      </div>\n      <div class=\"grid grid-cols-4 items-center gap-4\">\n        <Label for=\"keyword\" class=\"text-right\">{{ t('tasks.form.keyword') }}</Label>\n        <Input id=\"keyword\" v-model=\"form.keyword\" class=\"col-span-3\" :placeholder=\"t('tasks.form.keywordPlaceholder')\" required />\n      </div>\n      <div class=\"grid grid-cols-4 items-center gap-4\">\n        <Label class=\"text-right\">{{ t('tasks.form.decisionMode') }}</Label>\n        <div class=\"col-span-3\">\n          <Select v-model=\"form.decision_mode\">\n            <SelectTrigger>\n              <SelectValue :placeholder=\"t('tasks.form.decisionModePlaceholder')\" />\n            </SelectTrigger>\n            <SelectContent>\n              <SelectItem value=\"ai\">{{ t('tasks.form.aiMode') }}</SelectItem>\n              <SelectItem value=\"keyword\">{{ t('tasks.form.keywordMode') }}</SelectItem>\n            </SelectContent>\n          </Select>\n        </div>\n      </div>\n      <div class=\"grid grid-cols-4 items-center gap-4\">\n        <Label for=\"description\" class=\"text-right\">{{ t('tasks.form.description') }}</Label>\n        <div class=\"col-span-3 space-y-1\">\n          <Textarea\n            id=\"description\"\n            v-model=\"form.description\"\n            :placeholder=\"t('tasks.form.descriptionPlaceholder')\"\n          />\n          <p v-if=\"form.decision_mode === 'keyword'\" class=\"text-xs text-gray-500\">\n            {{ t('tasks.form.keywordDescriptionHint') }}\n          </p>\n        </div>\n      </div>\n      <div v-if=\"form.decision_mode === 'ai'\" class=\"grid grid-cols-4 items-center gap-4\">\n        <Label for=\"analyze-images\" class=\"text-right\">{{ t('tasks.form.analyzeImages') }}</Label>\n        <div class=\"col-span-3 space-y-1\">\n          <Switch id=\"analyze-images\" v-model=\"form.analyze_images\" />\n          <p class=\"text-xs text-gray-500\">\n            {{ t('tasks.form.analyzeImagesHint') }}\n          </p>\n        </div>\n      </div>\n\n      <div v-if=\"form.decision_mode === 'keyword'\" class=\"grid grid-cols-4 gap-4\">\n        <Label class=\"text-right pt-2\">{{ t('tasks.form.keywordRules') }}</Label>\n        <div class=\"col-span-3 space-y-2\">\n          <p class=\"text-xs text-gray-500\">\n            {{ t('tasks.form.keywordRulesHint') }}\n          </p>\n          <Textarea\n            v-model=\"keywordRulesInput\"\n            class=\"min-h-[120px]\"\n            :placeholder=\"t('tasks.form.keywordRulesPlaceholder')\"\n          />\n        </div>\n      </div>\n\n      <div class=\"grid grid-cols-4 items-center gap-4\">\n        <Label class=\"text-right\">{{ t('tasks.form.priceRange') }}</Label>\n        <div class=\"col-span-3 flex items-center gap-2\">\n          <Input type=\"number\" v-model=\"form.min_price as any\" :placeholder=\"t('tasks.form.minPrice')\" />\n          <span>-</span>\n          <Input type=\"number\" v-model=\"form.max_price as any\" :placeholder=\"t('tasks.form.maxPrice')\" />\n        </div>\n      </div>\n      <div class=\"grid grid-cols-4 items-center gap-4\">\n        <Label for=\"max-pages\" class=\"text-right\">{{ t('tasks.form.maxPages') }}</Label>\n        <Input id=\"max-pages\" v-model.number=\"form.max_pages\" type=\"number\" class=\"col-span-3\" />\n      </div>\n      <div class=\"grid grid-cols-4 items-center gap-4\">\n        <Label for=\"cron\" class=\"text-right\">{{ t('tasks.form.schedule') }}</Label>\n        <div class=\"col-span-3 space-y-2\">\n          <Tabs v-model=\"cronMode\" class=\"w-full\">\n            <TabsList class=\"grid w-full grid-cols-2\">\n              <TabsTrigger value=\"preset\">{{ t('tasks.form.cronPresetTab') }}</TabsTrigger>\n              <TabsTrigger value=\"custom\">{{ t('tasks.form.cronCustomTab') }}</TabsTrigger>\n            </TabsList>\n            <TabsContent value=\"preset\">\n              <Select v-model=\"presetCronValue\">\n                <SelectTrigger>\n                  <SelectValue :placeholder=\"t('tasks.form.cronPlaceholder')\" />\n                </SelectTrigger>\n                <SelectContent>\n                  <SelectItem v-for=\"preset in cronPresets\" :key=\"preset.value\" :value=\"preset.value\">\n                    {{ preset.label }}\n                  </SelectItem>\n                </SelectContent>\n              </Select>\n            </TabsContent>\n            <TabsContent value=\"custom\">\n              <Input\n                id=\"cron\"\n                v-model=\"form.cron\"\n                :placeholder=\"t('tasks.form.cronCustomPlaceholder')\"\n              />\n              <p class=\"text-xs text-gray-500 mt-1\">\n                {{ t('tasks.form.cronCustomHintLine1') }}\n              </p>\n              <p class=\"text-xs text-gray-500\">\n                {{ t('tasks.form.cronCustomHintLine2') }}\n              </p>\n            </TabsContent>\n          </Tabs>\n        </div>\n      </div>\n      <div class=\"grid grid-cols-4 items-center gap-4\">\n        <Label class=\"text-right\">{{ t('tasks.form.accountStrategyLabel') }}</Label>\n        <div class=\"col-span-3 space-y-2\">\n          <select\n            :value=\"accountStrategy\"\n            class=\"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2\"\n            @change=\"handleAccountStrategyChange\"\n          >\n            <option v-for=\"option in accountStrategyOptions\" :key=\"option.value\" :value=\"option.value\">\n              {{ option.label }}\n            </option>\n          </select>\n          <p class=\"text-xs text-gray-500\">\n            {{ accountStrategyOptions.find((option) => option.value === accountStrategy)?.description }}\n          </p>\n        </div>\n      </div>\n      <div v-if=\"accountStrategy === 'fixed'\" class=\"grid grid-cols-4 items-center gap-4\">\n        <Label class=\"text-right\">{{ t('tasks.form.fixedAccount') }}</Label>\n        <div class=\"col-span-3\">\n          <select\n            :value=\"selectedAccountStateFile\"\n            class=\"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2\"\n            @change=\"handleAccountStateFileChange\"\n          >\n            <option :value=\"AUTO_ACCOUNT_VALUE\">{{ t('tasks.form.selectAccount') }}</option>\n            <option v-for=\"account in accountOptions || []\" :key=\"account.path\" :value=\"account.path\">\n              {{ account.name }}\n            </option>\n          </select>\n        </div>\n      </div>\n      <div class=\"grid grid-cols-4 items-center gap-4\">\n        <Label for=\"personal-only\" class=\"text-right\">{{ t('tasks.form.personalOnly') }}</Label>\n        <Switch id=\"personal-only\" v-model=\"form.personal_only\" />\n      </div>\n      <div class=\"grid grid-cols-4 items-center gap-4\">\n        <Label class=\"text-right\">{{ t('tasks.form.freeShipping') }}</Label>\n        <Switch v-model=\"form.free_shipping\" />\n      </div>\n      <div class=\"grid grid-cols-4 items-center gap-4\">\n        <Label class=\"text-right\">{{ t('tasks.form.newPublish') }}</Label>\n        <div class=\"col-span-3\">\n          <Select v-model=\"form.new_publish_option as any\">\n            <SelectTrigger>\n              <SelectValue :placeholder=\"t('tasks.form.publishOptions.none')\" />\n            </SelectTrigger>\n            <SelectContent>\n              <SelectItem value=\"__none__\">{{ t('tasks.form.publishOptions.none') }}</SelectItem>\n              <SelectItem value=\"最新\">{{ t('tasks.form.publishOptions.latest') }}</SelectItem>\n              <SelectItem value=\"1天内\">{{ t('tasks.form.publishOptions.oneDay') }}</SelectItem>\n              <SelectItem value=\"3天内\">{{ t('tasks.form.publishOptions.threeDays') }}</SelectItem>\n              <SelectItem value=\"7天内\">{{ t('tasks.form.publishOptions.sevenDays') }}</SelectItem>\n              <SelectItem value=\"14天内\">{{ t('tasks.form.publishOptions.fourteenDays') }}</SelectItem>\n            </SelectContent>\n          </Select>\n        </div>\n      </div>\n      <div class=\"grid grid-cols-4 items-center gap-4\">\n        <Label class=\"text-right\">{{ t('tasks.form.region') }}</Label>\n        <div class=\"col-span-3 space-y-1\">\n          <TaskRegionSelector v-model=\"form.region as any\" />\n          <p class=\"text-xs text-gray-500\">{{ t('tasks.form.regionHint') }}</p>\n        </div>\n      </div>\n    </div>\n  </form>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/tasks/TaskGenerationDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport type { TaskGenerationJob } from '@/types/task.d.ts'\nimport TaskGenerationProgress from '@/components/tasks/TaskGenerationProgress.vue'\nimport { Button } from '@/components/ui/button'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog'\nconst { t } = useI18n()\n\nconst props = defineProps<{\n  job: TaskGenerationJob | null\n  open: boolean\n}>()\n\nconst emit = defineEmits<{\n  (event: 'update:open', value: boolean): void\n}>()\n\nconst isRunning = computed(() => {\n  if (!props.job) return false\n  return props.job.status === 'queued' || props.job.status === 'running'\n})\n\nconst helperText = computed(() => {\n  if (!props.job) return ''\n  if (props.job.status === 'failed') {\n    return t('tasks.generation.helperFailed')\n  }\n  return t('tasks.generation.helperRunning')\n})\n</script>\n\n<template>\n  <Dialog :open=\"open\" @update:open=\"(value) => emit('update:open', value)\">\n    <DialogContent class=\"sm:max-w-[560px]\">\n      <DialogHeader>\n        <DialogTitle>{{ t('tasks.generation.title') }}</DialogTitle>\n        <DialogDescription>\n          {{ t('tasks.generation.description') }}\n        </DialogDescription>\n      </DialogHeader>\n\n      <TaskGenerationProgress v-if=\"job\" :job=\"job\" />\n      <p v-if=\"job\" class=\"text-xs text-slate-500\">\n        {{ helperText }}\n      </p>\n\n      <DialogFooter>\n        <Button variant=\"outline\" @click=\"emit('update:open', false)\">\n          {{ isRunning ? t('tasks.generation.closeWindow') : t('common.close') }}\n        </Button>\n      </DialogFooter>\n    </DialogContent>\n  </Dialog>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/tasks/TaskGenerationProgress.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport { Badge } from '@/components/ui/badge'\nimport type { TaskGenerationJob, TaskGenerationStep } from '@/types/task.d.ts'\n\nconst props = defineProps<{\n  job: TaskGenerationJob\n}>()\nconst { t } = useI18n()\n\nconst statusMeta = computed(() => {\n  if (props.job.status === 'completed') {\n    return { label: t('tasks.generation.status.completed'), variant: 'default' as const }\n  }\n  if (props.job.status === 'failed') {\n    return { label: t('tasks.generation.status.failed'), variant: 'destructive' as const }\n  }\n  if (props.job.status === 'running') {\n    return { label: t('tasks.generation.status.running'), variant: 'secondary' as const }\n  }\n  return { label: t('tasks.generation.status.queued'), variant: 'outline' as const }\n})\n\nfunction resolveStepDotClass(step: TaskGenerationStep) {\n  if (step.status === 'completed') return 'border-emerald-500 bg-emerald-500'\n  if (step.status === 'running') return 'border-amber-500 bg-amber-500 shadow-[0_0_0_4px_rgba(245,158,11,0.18)]'\n  if (step.status === 'failed') return 'border-red-500 bg-red-500'\n  return 'border-slate-300 bg-white'\n}\n\nfunction resolveStepTextClass(step: TaskGenerationStep) {\n  if (step.status === 'completed') return 'text-slate-700'\n  if (step.status === 'running') return 'text-slate-900'\n  if (step.status === 'failed') return 'text-red-600'\n  return 'text-slate-400'\n}\n</script>\n\n<template>\n  <section class=\"rounded-2xl border border-slate-200 bg-slate-50/80 p-4 shadow-sm\">\n    <div class=\"flex items-start justify-between gap-4\">\n      <div class=\"space-y-1\">\n        <p class=\"text-sm font-semibold text-slate-900\">\n          {{ job.task_name }}\n        </p>\n        <p class=\"text-sm text-slate-600\">\n          {{ job.message }}\n        </p>\n      </div>\n      <Badge :variant=\"statusMeta.variant\">\n        {{ statusMeta.label }}\n      </Badge>\n    </div>\n\n    <div class=\"mt-4 grid gap-3\">\n      <div\n        v-for=\"step in job.steps\"\n        :key=\"step.key\"\n        class=\"flex items-start gap-3 rounded-xl border border-white/70 bg-white px-3 py-2\"\n      >\n        <span\n          class=\"mt-1 h-3 w-3 shrink-0 rounded-full border-2 transition-colors\"\n          :class=\"resolveStepDotClass(step)\"\n        />\n        <div class=\"min-w-0 space-y-1\">\n          <p class=\"text-sm font-medium\" :class=\"resolveStepTextClass(step)\">\n            {{ step.label }}\n          </p>\n          <p\n            v-if=\"step.message\"\n            class=\"text-xs\"\n            :class=\"step.status === 'failed' ? 'text-red-500' : 'text-slate-500'\"\n          >\n            {{ step.message }}\n          </p>\n        </div>\n      </div>\n    </div>\n\n    <p\n      v-if=\"job.error\"\n      class=\"mt-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600\"\n    >\n      {{ job.error }}\n    </p>\n  </section>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/tasks/TaskRegionSelector.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref, watch } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport { Button } from '@/components/ui/button'\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select'\nimport regionTree from '@/data/goofishRegions.json'\n\ntype RegionTree = Record<string, Record<string, string[]>>\n\nconst props = defineProps<{\n  modelValue?: string | null\n}>()\n\nconst emit = defineEmits<{\n  (e: 'update:modelValue', value: string): void\n}>()\nconst { t } = useI18n()\n\nconst tree = regionTree as RegionTree\nconst provinceKeys = Object.keys(tree)\nconst selectedProvince = ref('')\nconst selectedCity = ref('')\nconst selectedDistrict = ref('')\nconst lastSyncedPath = ref('')\n\nconst provinceOptions = computed(() => provinceKeys)\nconst cityOptions = computed(() => {\n  return selectedProvince.value ? Object.keys(tree[selectedProvince.value] || {}) : []\n})\nconst districtOptions = computed(() => {\n  if (!selectedProvince.value || !selectedCity.value) return []\n  return tree[selectedProvince.value]?.[selectedCity.value] || []\n})\nconst currentPath = computed(() => {\n  return [selectedProvince.value, selectedCity.value, selectedDistrict.value]\n    .filter((item) => item.length > 0)\n    .join('/')\n})\n\nfunction normalizePath(value: string | null | undefined): string {\n  return String(value || '')\n    .split('/')\n    .map((item) => item.trim())\n    .filter((item) => item.length > 0)\n    .join('/')\n}\n\nfunction isFullOption(value: string): boolean {\n  return value.startsWith('全') || value === '全国'\n}\n\nfunction syncFromModel() {\n  const normalized = normalizePath(props.modelValue)\n  if (normalized === lastSyncedPath.value) return\n  lastSyncedPath.value = normalized\n\n  const parts = normalized.split('/').filter(Boolean)\n  selectedProvince.value = parts[0] || ''\n  selectedCity.value = parts[1] || ''\n  selectedDistrict.value = parts[2] || ''\n}\n\nfunction emitCurrentPath() {\n  emit('update:modelValue', currentPath.value)\n}\n\nfunction clearSelection() {\n  selectedProvince.value = ''\n  selectedCity.value = ''\n  selectedDistrict.value = ''\n  emit('update:modelValue', '')\n}\n\nfunction handleProvinceChange(value: string) {\n  selectedProvince.value = value\n  selectedCity.value = ''\n  selectedDistrict.value = ''\n\n  const firstCity = cityOptions.value[0] || ''\n  if (value && value !== '全国' && isFullOption(firstCity)) {\n    selectedCity.value = firstCity\n  }\n  emitCurrentPath()\n}\n\nfunction handleCityChange(value: string) {\n  selectedCity.value = value\n  selectedDistrict.value = ''\n\n  const firstDistrict = districtOptions.value[0] || ''\n  if (isFullOption(firstDistrict)) {\n    selectedDistrict.value = firstDistrict\n  }\n  emitCurrentPath()\n}\n\nfunction handleDistrictChange(value: string) {\n  selectedDistrict.value = value\n  emitCurrentPath()\n}\n\nwatch(() => props.modelValue, syncFromModel, { immediate: true })\n</script>\n\n<template>\n  <div class=\"space-y-3\">\n    <div class=\"grid gap-2 md:grid-cols-3\">\n      <Select\n        :model-value=\"selectedProvince\"\n        @update:model-value=\"(value) => handleProvinceChange(String(value || ''))\"\n      >\n        <SelectTrigger>\n          <SelectValue :placeholder=\"t('tasks.region.provincePlaceholder')\" />\n        </SelectTrigger>\n        <SelectContent>\n          <SelectItem v-for=\"option in provinceOptions\" :key=\"option\" :value=\"option\">\n            {{ option }}\n          </SelectItem>\n        </SelectContent>\n      </Select>\n\n      <Select\n        :disabled=\"!selectedProvince || cityOptions.length === 0\"\n        :model-value=\"selectedCity\"\n        @update:model-value=\"(value) => handleCityChange(String(value || ''))\"\n      >\n        <SelectTrigger>\n          <SelectValue :placeholder=\"t('tasks.region.cityPlaceholder')\" />\n        </SelectTrigger>\n        <SelectContent>\n          <SelectItem v-for=\"option in cityOptions\" :key=\"option\" :value=\"option\">\n            {{ option }}\n          </SelectItem>\n        </SelectContent>\n      </Select>\n\n      <Select\n        :disabled=\"!selectedCity || districtOptions.length === 0\"\n        :model-value=\"selectedDistrict\"\n        @update:model-value=\"(value) => handleDistrictChange(String(value || ''))\"\n      >\n        <SelectTrigger>\n          <SelectValue :placeholder=\"t('tasks.region.districtPlaceholder')\" />\n        </SelectTrigger>\n        <SelectContent>\n          <SelectItem v-for=\"option in districtOptions\" :key=\"option\" :value=\"option\">\n            {{ option }}\n          </SelectItem>\n        </SelectContent>\n      </Select>\n    </div>\n\n    <div class=\"flex flex-wrap items-center gap-2 text-xs text-slate-500\">\n      <span>{{ t('tasks.region.helper') }}</span>\n    </div>\n\n    <div class=\"flex flex-wrap gap-2\">\n      <Button type=\"button\" variant=\"ghost\" size=\"sm\" @click=\"clearSelection\">\n        {{ t('tasks.region.clear') }}\n      </Button>\n      <span v-if=\"currentPath\" class=\"rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-600\">\n        {{ t('tasks.region.current', { path: currentPath }) }}\n      </span>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/tasks/TasksTable.vue",
    "content": "<script setup lang=\"ts\">\nimport { onBeforeUnmount, onMounted, ref } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport type { Task } from '@/types/task.d.ts'\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from '@/components/ui/table'\nimport { Button } from '@/components/ui/button'\nimport { Switch } from '@/components/ui/switch'\nimport { Badge } from '@/components/ui/badge'\nimport { \n  Play, \n  Square, \n  Pencil, \n  Trash2, \n  User, \n  BrainCircuit, \n  Keyboard,\n  Clock,\n  Layers,\n  MapPin,\n  RefreshCcw,\n  Search\n} from 'lucide-vue-next'\nimport { formatCountdown, formatNextRunAbsolute } from '@/lib/taskSchedule'\n\ninterface Props {\n  tasks: Task[]\n  isLoading: boolean\n  stoppingIds?: Set<number>\n}\n\nconst props = defineProps<Props>()\nconst { t } = useI18n()\nconst isStopping = (id: number) => props.stoppingIds?.has(id) ?? false\nconst isKeywordMode = (task: Task) => task.decision_mode === 'keyword'\nconst nowMs = ref(Date.now())\nlet timer: number | null = null\n\nonMounted(() => {\n  timer = window.setInterval(() => {\n    nowMs.value = Date.now()\n  }, 1000)\n})\n\nonBeforeUnmount(() => {\n  if (timer !== null) {\n    window.clearInterval(timer)\n  }\n})\n\nconst resolveAccountStrategyLabel = (task: Task) => {\n  if (task.account_strategy === 'rotate') return t('tasks.table.accountRotate')\n  if (task.account_strategy === 'fixed') return t('tasks.table.accountFixed')\n  return t('tasks.table.accountAuto')\n}\n\nconst resolveAccountName = (task: Task) => {\n  if (!task.account_state_file) return t('tasks.table.systemSelected')\n  const segments = task.account_state_file.split('/')\n  const filename = segments[segments.length - 1] || task.account_state_file\n  return filename.replace('.json', '')\n}\n\nconst resolveCountdownText = (task: Task) => {\n  if (!task.cron) return t('tasks.table.manualTrigger')\n  if (!task.enabled) return t('tasks.table.disabled')\n  return formatCountdown(task.next_run_at, nowMs.value) || t('tasks.table.waitingSchedule')\n}\n\nconst resolveCountdownTone = (task: Task) => {\n  if (!task.cron) return 'text-slate-400'\n  if (!task.enabled) return 'text-slate-400'\n  return 'text-amber-600'\n}\n\nconst resolveNextRunLabel = (task: Task) => {\n  if (!task.cron || !task.enabled || !task.next_run_at) return null\n  return formatNextRunAbsolute(task.next_run_at)\n}\n\nconst emit = defineEmits<{\n  (e: 'delete-task', taskId: number): void\n  (e: 'run-task', taskId: number): void\n  (e: 'stop-task', taskId: number): void\n  (e: 'edit-task', task: Task): void\n  (e: 'refresh-criteria', task: Task): void\n  (e: 'toggle-enabled', task: Task, enabled: boolean): void\n}>()\n</script>\n\n<template>\n  <div class=\"border-none shadow-glass rounded-2xl bg-white/60 backdrop-blur-md overflow-hidden animate-fade-in\">\n    <Table>\n      <TableHeader class=\"bg-slate-50/50 border-b border-slate-100\">\n        <TableRow>\n          <TableHead class=\"w-[80px] px-6 text-slate-500 font-bold uppercase text-[10px] tracking-wider text-center\">{{ t('tasks.table.headers.status') }}</TableHead>\n          <TableHead class=\"min-w-[300px] text-slate-500 font-bold uppercase text-[10px] tracking-wider text-left\">{{ t('tasks.table.headers.details') }}</TableHead>\n          <TableHead class=\"w-[180px] text-slate-500 font-bold uppercase text-[10px] tracking-wider text-left\">{{ t('tasks.table.headers.crawl') }}</TableHead>\n          <TableHead class=\"w-[180px] text-slate-500 font-bold uppercase text-[10px] tracking-wider text-center\">{{ t('tasks.table.headers.mode') }}</TableHead>\n          <TableHead class=\"w-[140px] text-slate-500 font-bold uppercase text-[10px] tracking-wider text-center\">{{ t('tasks.table.headers.schedule') }}</TableHead>\n          <TableHead class=\"w-[160px] px-6 text-slate-500 font-bold uppercase text-[10px] tracking-wider text-right\">{{ t('tasks.table.headers.actions') }}</TableHead>\n        </TableRow>\n      </TableHeader>\n      <TableBody>\n        <template v-if=\"isLoading && tasks.length === 0\">\n          <TableRow>\n            <TableCell :colspan=\"6\" class=\"h-32 text-center\">\n              <div class=\"flex flex-col items-center justify-center gap-2 text-slate-400\">\n                <RefreshCcw class=\"w-6 h-6 animate-spin\" />\n                <span class=\"text-sm font-medium italic\">{{ t('tasks.table.syncing') }}</span>\n              </div>\n            </TableCell>\n          </TableRow>\n        </template>\n        <template v-else-if=\"tasks.length === 0\">\n          <TableRow>\n            <TableCell :colspan=\"6\" class=\"h-40 text-center\">\n               <div class=\"flex flex-col items-center justify-center gap-2 text-slate-300\">\n                  <Layers class=\"w-12 h-12 opacity-20\" />\n                  <p class=\"text-sm font-bold\">{{ t('tasks.table.empty') }}</p>\n               </div>\n            </TableCell>\n          </TableRow>\n        </template>\n        <template v-else>\n          <TableRow\n            v-for=\"task in tasks\"\n            :key=\"task.id\"\n            class=\"group hover:bg-white/80 transition-all duration-300 border-b border-slate-100/50 last:border-0\"\n          >\n            <!-- Column 1: Status -->\n            <TableCell class=\"px-6 align-middle\">\n              <div class=\"flex flex-col items-center gap-2.5\">\n                <Switch\n                  :model-value=\"task.enabled\"\n                  class=\"data-[state=checked]:bg-primary scale-90\"\n                  @update:model-value=\"(val: boolean) => emit('toggle-enabled', task, val)\"\n                />\n                <div class=\"flex items-center gap-1.5\">\n                  <div :class=\"[ 'w-1.5 h-1.5 rounded-full shadow-sm', task.is_running ? 'bg-emerald-500 animate-pulse' : 'bg-slate-300' ]\"></div>\n                  <span :class=\"[ 'text-[9px] font-black tracking-widest uppercase', task.is_running ? 'text-emerald-600' : 'text-slate-400' ]\">\n                    {{ task.is_running ? 'ACTIVE' : 'IDLE' }}\n                  </span>\n                </div>\n              </div>\n            </TableCell>\n\n            <!-- Column 2: Task Info -->\n            <TableCell class=\"align-middle\">\n              <div class=\"flex flex-col gap-1.5 py-1\">\n                <div class=\"flex items-center gap-2\">\n                  <span class=\"text-base font-black text-slate-800 tracking-tight group-hover:text-primary transition-colors\">{{ task.task_name }}</span>\n                  <Badge \n                    variant=\"outline\" \n                    :class=\"[\n                      'h-4 px-1.5 text-[9px] font-black border-none tracking-tighter', \n                      isKeywordMode(task) ? 'bg-blue-50 text-blue-500' : 'bg-emerald-50 text-emerald-600'\n                    ]\"\n                  >\n                    <component :is=\"isKeywordMode(task) ? Keyboard : BrainCircuit\" class=\"w-2.5 h-2.5 mr-1\" />\n                    {{ isKeywordMode(task) ? 'KEYWORD' : 'AI ENGINE' }}\n                  </Badge>\n                </div>\n                \n                <div class=\"flex items-center gap-2\">\n                   <div class=\"flex items-center gap-1.5 bg-slate-100/80 text-slate-600 px-2 py-0.5 rounded-md text-[11px] font-bold border border-slate-200/50\">\n                      <Search class=\"w-3 h-3 text-slate-400\" /> {{ task.keyword }}\n                   </div>\n                   <div v-if=\"task.description\" class=\"text-[11px] text-slate-400 italic line-clamp-1 max-w-[180px]\" :title=\"task.description\">\n                      {{ task.description }}\n                   </div>\n                </div>\n\n                <div class=\"flex items-center gap-2 mt-0.5\">\n                   <div class=\"flex items-center gap-1 text-[10px] font-bold text-slate-400 uppercase tracking-tight\">\n                      <User class=\"w-3 h-3\" /> {{ resolveAccountStrategyLabel(task) }}\n                   </div>\n                   <div class=\"h-1 w-1 rounded-full bg-slate-200\"></div>\n                   <div class=\"text-[10px] font-medium text-slate-400 truncate max-w-[120px]\">\n                      {{ resolveAccountName(task) }}\n                   </div>\n                </div>\n              </div>\n            </TableCell>\n\n            <!-- Column 3: Crawl Config -->\n            <TableCell class=\"align-middle text-left\">\n              <div class=\"space-y-2\">\n                <div class=\"flex items-baseline gap-0.5\">\n                  <span class=\"text-[10px] font-bold text-slate-400 mr-1 italic\">¥</span>\n                  <span class=\"text-sm font-black text-slate-700 tracking-tighter\">\n                    {{ task.min_price || 0 }} <span class=\"text-slate-300 font-normal mx-0.5\">-</span> {{ task.max_price || 'MAX' }}\n                  </span>\n                </div>\n                <div class=\"flex flex-wrap gap-1.5\">\n                  <Badge variant=\"outline\" class=\"text-[9px] h-4 border-slate-100 text-slate-400 px-1.5 font-bold bg-white/40\">\n                    {{ task.personal_only ? t('tasks.table.personalOnly') : t('common.all') }}\n                  </Badge>\n                  <Badge variant=\"outline\" class=\"text-[9px] h-4 border-slate-100 text-slate-400 px-1.5 font-bold bg-white/40\">\n                    {{ task.free_shipping ? t('tasks.table.freeShipping') : t('common.all') }}\n                  </Badge>\n                  <div v-if=\"task.region\" class=\"flex items-center gap-0.5 text-[9px] font-bold text-slate-400 px-1.5 h-4 bg-slate-50/50 rounded border border-slate-100 truncate max-w-[80px]\">\n                    <MapPin class=\"w-2.5 h-2.5\" /> {{ task.region }}\n                  </div>\n                </div>\n              </div>\n            </TableCell>\n\n            <!-- Column 4: AI/Keyword Mode Details -->\n            <TableCell class=\"align-middle text-center\">\n              <div class=\"inline-flex flex-col items-center gap-2\">\n                <div v-if=\"isKeywordMode(task)\" class=\"bg-blue-50/30 p-2 rounded-xl border border-blue-100/50\">\n                  <div class=\"text-xs font-black text-blue-600\">{{ t('tasks.table.keywordStrategies', { count: task.keyword_rules?.length || 0 }) }}</div>\n                  <div class=\"text-[9px] font-bold text-blue-400/70 uppercase mt-0.5 tracking-tighter\">OR Logic</div>\n                </div>\n                <div v-else class=\"flex flex-col items-center gap-1.5\">\n                  <div \n                    class=\"px-2 py-1 rounded bg-emerald-50/50 border border-emerald-100/50 text-[9px] font-mono font-black text-emerald-600 truncate max-w-[140px]\"\n                    :title=\"task.ai_prompt_criteria_file\"\n                  >\n                    {{ (task.ai_prompt_criteria_file || 'STANDARD').split('/').pop() }}\n                  </div>\n                  <Button \n                    size=\"sm\" \n                    variant=\"ghost\" \n                    class=\"h-6 text-[9px] font-black text-emerald-600 hover:bg-emerald-50 uppercase tracking-widest px-2\" \n                    @click=\"emit('refresh-criteria', task)\"\n                  >\n                    <RefreshCcw class=\"w-2.5 h-2.5 mr-1\" /> {{ t('tasks.table.refreshCriteria') }}\n                  </Button>\n                </div>\n              </div>\n            </TableCell>\n\n            <!-- Column 5: Cron & Pages -->\n            <TableCell class=\"align-middle text-center\">\n              <div class=\"inline-flex flex-col items-center gap-1.5\">\n                <div class=\"flex items-center gap-1.5 bg-slate-100/50 border border-slate-200/30 px-2 py-1 rounded-lg\">\n                  <Clock class=\"w-3 h-3 text-slate-400\" />\n                  <span class=\"text-[11px] font-black text-slate-600 tracking-tight\">{{ task.cron || 'MANUAL' }}</span>\n                </div>\n                <div\n                  class=\"px-2 py-1 rounded-md bg-amber-50/60 border border-amber-100/80 min-w-[112px]\"\n                  :class=\"!task.cron || !task.enabled ? 'bg-slate-50 border-slate-100' : ''\"\n                  :title=\"resolveNextRunLabel(task) || undefined\"\n                >\n                  <div\n                    class=\"text-[10px] font-black tracking-tight\"\n                    :class=\"resolveCountdownTone(task)\"\n                  >\n                    {{ resolveCountdownText(task) }}\n                  </div>\n                  <div\n                    v-if=\"resolveNextRunLabel(task)\"\n                    class=\"text-[9px] font-medium text-slate-400 mt-0.5\"\n                  >\n                    {{ resolveNextRunLabel(task) }}\n                  </div>\n                </div>\n                <div class=\"flex items-center gap-1 text-[9px] font-black text-slate-400 uppercase tracking-widest\">\n                  <Layers class=\"w-3 h-3 opacity-50\" /> {{ task.max_pages || 3 }}P\n                </div>\n              </div>\n            </TableCell>\n\n            <!-- Column 6: Actions -->\n            <TableCell class=\"px-6 align-middle text-right\">\n              <div class=\"flex justify-end items-center gap-2\">\n                <Button\n                  v-if=\"!task.is_running\"\n                  size=\"sm\" \n                  variant=\"default\"\n                  class=\"h-8 px-3 rounded-lg shadow-sm transition-all active:scale-95 text-white border-none\"\n                  :class=\"task.enabled ? 'bg-primary hover:bg-primary/90' : 'bg-slate-200 text-slate-400 pointer-events-none opacity-50'\"\n                  @click=\"emit('run-task', task.id)\"\n                >\n                  <Play class=\"w-3 h-3 mr-1.5 fill-current\" />\n                  <span class=\"font-bold text-[11px]\">{{ t('tasks.table.start') }}</span>\n                </Button>\n                <Button\n                  v-else\n                  size=\"sm\"\n                  variant=\"destructive\"\n                  class=\"h-8 px-3 rounded-lg shadow-sm active:scale-95 border-none\"\n                  :disabled=\"isStopping(task.id)\"\n                  @click=\"emit('stop-task', task.id)\"\n                >\n                  <Square v-if=\"!isStopping(task.id)\" class=\"w-3 h-3 mr-1.5 fill-current\" />\n                  <RefreshCcw v-else class=\"w-3 h-3 mr-1.5 animate-spin\" />\n                  <span class=\"font-bold text-[11px]\">{{ isStopping(task.id) ? t('tasks.table.stopping') : t('tasks.table.stop') }}</span>\n                </Button>\n\n                <div class=\"flex items-center gap-0.5 ml-1\">\n                  <Button \n                    size=\"icon\" \n                    variant=\"ghost\" \n                    class=\"w-8 h-8 rounded-full text-slate-400 hover:text-primary hover:bg-primary/5 transition-colors\" \n                    @click=\"emit('edit-task', task)\"\n                  >\n                    <Pencil class=\"w-3.5 h-3.5\" />\n                  </Button>\n                  <Button \n                    size=\"icon\" \n                    variant=\"ghost\" \n                    class=\"w-8 h-8 rounded-full text-slate-400 hover:text-rose-500 hover:bg-rose-50 transition-colors\" \n                    @click=\"emit('delete-task', task.id)\"\n                  >\n                    <Trash2 class=\"w-3.5 h-3.5\" />\n                  </Button>\n                </div>\n              </div>\n            </TableCell>\n          </TableRow>\n        </template>\n      </TableBody>\n    </Table>\n  </div>\n</template>\n\n<style scoped>\n:deep(td) {\n  @apply py-3 px-4;\n}\n:deep(th) {\n  @apply h-11 px-4;\n}\n</style>\n"
  },
  {
    "path": "web-ui/src/components/ui/badge/Badge.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport type { BadgeVariants } from \".\"\nimport { cn } from \"@/lib/utils\"\nimport { badgeVariants } from \".\"\n\nconst props = defineProps<{\n  variant?: BadgeVariants[\"variant\"]\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div :class=\"cn(badgeVariants({ variant }), props.class)\">\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/badge/index.ts",
    "content": "import type { VariantProps } from \"class-variance-authority\"\nimport { cva } from \"class-variance-authority\"\n\nexport { default as Badge } from \"./Badge.vue\"\n\nexport const badgeVariants = cva(\n  \"inline-flex gap-1 items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"border-transparent bg-primary text-primary-foreground hover:bg-primary/80\",\n        secondary:\n          \"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n        destructive:\n          \"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80\",\n        outline: \"text-foreground\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n)\n\nexport type BadgeVariants = VariantProps<typeof badgeVariants>\n"
  },
  {
    "path": "web-ui/src/components/ui/button/Button.vue",
    "content": "<script setup lang=\"ts\">\nimport type { PrimitiveProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport type { ButtonVariants } from \".\"\nimport { Primitive } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\nimport { buttonVariants } from \".\"\n\ninterface Props extends PrimitiveProps {\n  variant?: ButtonVariants[\"variant\"]\n  size?: ButtonVariants[\"size\"]\n  class?: HTMLAttributes[\"class\"]\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  as: \"button\",\n})\n</script>\n\n<template>\n  <Primitive\n    :as=\"as\"\n    :as-child=\"asChild\"\n    :class=\"cn(buttonVariants({ variant, size }), props.class)\"\n  >\n    <slot />\n  </Primitive>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/button/index.ts",
    "content": "import type { VariantProps } from \"class-variance-authority\"\nimport { cva } from \"class-variance-authority\"\n\nexport { default as Button } from \"./Button.vue\"\n\nexport const buttonVariants = cva(\n  \"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n        destructive:\n          \"bg-destructive text-destructive-foreground hover:bg-destructive/90\",\n        outline:\n          \"border border-input bg-background hover:bg-accent hover:text-accent-foreground\",\n        secondary:\n          \"bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n        ghost: \"hover:bg-accent hover:text-accent-foreground\",\n        link: \"text-primary underline-offset-4 hover:underline\",\n      },\n      size: {\n        \"default\": \"h-10 px-4 py-2\",\n        \"sm\": \"h-9 rounded-md px-3\",\n        \"lg\": \"h-11 rounded-md px-8\",\n        \"icon\": \"h-10 w-10\",\n        \"icon-sm\": \"size-9\",\n        \"icon-lg\": \"size-11\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  },\n)\n\nexport type ButtonVariants = VariantProps<typeof buttonVariants>\n"
  },
  {
    "path": "web-ui/src/components/ui/card/Card.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div\n    :class=\"\n      cn(\n        'rounded-lg border bg-card text-card-foreground shadow-sm',\n        props.class,\n      )\n    \"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/card/CardContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div :class=\"cn('p-6 pt-0', props.class)\">\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/card/CardDescription.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <p :class=\"cn('text-sm text-muted-foreground', props.class)\">\n    <slot />\n  </p>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/card/CardFooter.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div :class=\"cn('flex items-center p-6 pt-0', props.class)\">\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/card/CardHeader.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div :class=\"cn('flex flex-col gap-y-1.5 p-6', props.class)\">\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/card/CardTitle.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <h3\n    :class=\"\n      cn('text-2xl font-semibold leading-none tracking-tight', props.class)\n    \"\n  >\n    <slot />\n  </h3>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/card/index.ts",
    "content": "export { default as Card } from \"./Card.vue\"\nexport { default as CardContent } from \"./CardContent.vue\"\nexport { default as CardDescription } from \"./CardDescription.vue\"\nexport { default as CardFooter } from \"./CardFooter.vue\"\nexport { default as CardHeader } from \"./CardHeader.vue\"\nexport { default as CardTitle } from \"./CardTitle.vue\"\n"
  },
  {
    "path": "web-ui/src/components/ui/checkbox/Checkbox.vue",
    "content": "<script setup lang=\"ts\">\nimport type { CheckboxRootEmits, CheckboxRootProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { Check } from \"lucide-vue-next\"\nimport { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<CheckboxRootProps & { class?: HTMLAttributes[\"class\"] }>()\nconst emits = defineEmits<CheckboxRootEmits>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <CheckboxRoot\n    v-bind=\"forwarded\"\n    :class=\"\n      cn('grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',\n         props.class)\"\n  >\n    <CheckboxIndicator class=\"grid place-content-center text-current\">\n      <slot>\n        <Check class=\"h-4 w-4\" />\n      </slot>\n    </CheckboxIndicator>\n  </CheckboxRoot>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/checkbox/index.ts",
    "content": "export { default as Checkbox } from \"./Checkbox.vue\"\n"
  },
  {
    "path": "web-ui/src/components/ui/dialog/Dialog.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogRootEmits, DialogRootProps } from \"reka-ui\"\nimport { DialogRoot, useForwardPropsEmits } from \"reka-ui\"\n\nconst props = defineProps<DialogRootProps>()\nconst emits = defineEmits<DialogRootEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <DialogRoot v-bind=\"forwarded\">\n    <slot />\n  </DialogRoot>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/dialog/DialogClose.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogCloseProps } from \"reka-ui\"\nimport { DialogClose } from \"reka-ui\"\n\nconst props = defineProps<DialogCloseProps>()\n</script>\n\n<template>\n  <DialogClose v-bind=\"props\">\n    <slot />\n  </DialogClose>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/dialog/DialogContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogContentEmits, DialogContentProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { X } from \"lucide-vue-next\"\nimport {\n  DialogClose,\n  DialogContent,\n  DialogOverlay,\n  DialogPortal,\n  useForwardPropsEmits,\n} from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<DialogContentProps & { class?: HTMLAttributes[\"class\"] }>()\nconst emits = defineEmits<DialogContentEmits>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <DialogPortal>\n    <DialogOverlay\n      class=\"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\"\n    />\n    <DialogContent\n      v-bind=\"forwarded\"\n      :class=\"\n        cn(\n          'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',\n          props.class,\n        )\"\n    >\n      <slot />\n\n      <DialogClose\n        class=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground\"\n      >\n        <X class=\"w-4 h-4\" />\n        <span class=\"sr-only\">Close</span>\n      </DialogClose>\n    </DialogContent>\n  </DialogPortal>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/dialog/DialogDescription.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogDescriptionProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { DialogDescription, useForwardProps } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <DialogDescription\n    v-bind=\"forwardedProps\"\n    :class=\"cn('text-sm text-muted-foreground', props.class)\"\n  >\n    <slot />\n  </DialogDescription>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/dialog/DialogFooter.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{ class?: HTMLAttributes[\"class\"] }>()\n</script>\n\n<template>\n  <div\n    :class=\"\n      cn(\n        'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',\n        props.class,\n      )\n    \"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/dialog/DialogHeader.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n</script>\n\n<template>\n  <div\n    :class=\"cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/dialog/DialogScrollContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogContentEmits, DialogContentProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { X } from \"lucide-vue-next\"\nimport {\n  DialogClose,\n  DialogContent,\n  DialogOverlay,\n  DialogPortal,\n  useForwardPropsEmits,\n} from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<DialogContentProps & { class?: HTMLAttributes[\"class\"] }>()\nconst emits = defineEmits<DialogContentEmits>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <DialogPortal>\n    <DialogOverlay\n      class=\"fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\"\n    >\n      <DialogContent\n        :class=\"\n          cn(\n            'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',\n            props.class,\n          )\n        \"\n        v-bind=\"forwarded\"\n        @pointer-down-outside=\"(event) => {\n          const originalEvent = event.detail.originalEvent;\n          const target = originalEvent.target as HTMLElement;\n          if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {\n            event.preventDefault();\n          }\n        }\"\n      >\n        <slot />\n\n        <DialogClose\n          class=\"absolute top-3 right-3 p-0.5 transition-colors rounded-md hover:bg-secondary\"\n        >\n          <X class=\"w-4 h-4\" />\n          <span class=\"sr-only\">Close</span>\n        </DialogClose>\n      </DialogContent>\n    </DialogOverlay>\n  </DialogPortal>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/dialog/DialogTitle.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogTitleProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { DialogTitle, useForwardProps } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<DialogTitleProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <DialogTitle\n    v-bind=\"forwardedProps\"\n    :class=\"\n      cn(\n        'text-lg font-semibold leading-none tracking-tight',\n        props.class,\n      )\n    \"\n  >\n    <slot />\n  </DialogTitle>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/dialog/DialogTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogTriggerProps } from \"reka-ui\"\nimport { DialogTrigger } from \"reka-ui\"\n\nconst props = defineProps<DialogTriggerProps>()\n</script>\n\n<template>\n  <DialogTrigger v-bind=\"props\">\n    <slot />\n  </DialogTrigger>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/dialog/index.ts",
    "content": "export { default as Dialog } from \"./Dialog.vue\"\nexport { default as DialogClose } from \"./DialogClose.vue\"\nexport { default as DialogContent } from \"./DialogContent.vue\"\nexport { default as DialogDescription } from \"./DialogDescription.vue\"\nexport { default as DialogFooter } from \"./DialogFooter.vue\"\nexport { default as DialogHeader } from \"./DialogHeader.vue\"\nexport { default as DialogScrollContent } from \"./DialogScrollContent.vue\"\nexport { default as DialogTitle } from \"./DialogTitle.vue\"\nexport { default as DialogTrigger } from \"./DialogTrigger.vue\"\n"
  },
  {
    "path": "web-ui/src/components/ui/input/Input.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { useVModel } from \"@vueuse/core\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  defaultValue?: string | number\n  modelValue?: string | number\n  class?: HTMLAttributes[\"class\"]\n}>()\n\nconst emits = defineEmits<{\n  (e: \"update:modelValue\", payload: string | number): void\n}>()\n\nconst modelValue = useVModel(props, \"modelValue\", emits, {\n  passive: true,\n  defaultValue: props.defaultValue,\n})\n</script>\n\n<template>\n  <input v-model=\"modelValue\" :class=\"cn('flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-foreground file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)\">\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/input/index.ts",
    "content": "export { default as Input } from \"./Input.vue\"\n"
  },
  {
    "path": "web-ui/src/components/ui/label/Label.vue",
    "content": "<script setup lang=\"ts\">\nimport type { LabelProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { Label } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<LabelProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <Label\n    v-bind=\"delegatedProps\"\n    :class=\"\n      cn(\n        'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',\n        props.class,\n      )\n    \"\n  >\n    <slot />\n  </Label>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/label/index.ts",
    "content": "export { default as Label } from \"./Label.vue\"\n"
  },
  {
    "path": "web-ui/src/components/ui/select/Select.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectRootEmits, SelectRootProps } from \"reka-ui\"\nimport { SelectRoot, useForwardPropsEmits } from \"reka-ui\"\n\nconst props = defineProps<SelectRootProps>()\nconst emits = defineEmits<SelectRootEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <SelectRoot v-bind=\"forwarded\">\n    <slot />\n  </SelectRoot>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/select/SelectContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectContentEmits, SelectContentProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport {\n  SelectContent,\n  SelectPortal,\n  SelectViewport,\n  useForwardPropsEmits,\n} from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\nimport { SelectScrollDownButton, SelectScrollUpButton } from \".\"\n\ndefineOptions({\n  inheritAttrs: false,\n})\n\nconst props = withDefaults(\n  defineProps<SelectContentProps & { class?: HTMLAttributes[\"class\"] }>(),\n  {\n    position: \"popper\",\n  },\n)\nconst emits = defineEmits<SelectContentEmits>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <SelectPortal>\n    <SelectContent\n      v-bind=\"{ ...forwarded, ...$attrs }\" :class=\"cn(\n        'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n        position === 'popper'\n          && 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',\n        props.class,\n      )\n      \"\n    >\n      <SelectScrollUpButton />\n      <SelectViewport :class=\"cn('p-1', position === 'popper' && 'h-[--reka-select-trigger-height] w-full min-w-[--reka-select-trigger-width]')\">\n        <slot />\n      </SelectViewport>\n      <SelectScrollDownButton />\n    </SelectContent>\n  </SelectPortal>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/select/SelectGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectGroupProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { SelectGroup } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<SelectGroupProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <SelectGroup :class=\"cn('p-1 w-full', props.class)\" v-bind=\"delegatedProps\">\n    <slot />\n  </SelectGroup>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/select/SelectItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectItemProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { Check } from \"lucide-vue-next\"\nimport {\n  SelectItem,\n  SelectItemIndicator,\n  SelectItemText,\n  useForwardProps,\n} from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<SelectItemProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <SelectItem\n    v-bind=\"forwardedProps\"\n    :class=\"\n      cn(\n        'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n        props.class,\n      )\n    \"\n  >\n    <span class=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <SelectItemIndicator>\n        <Check class=\"h-4 w-4\" />\n      </SelectItemIndicator>\n    </span>\n\n    <SelectItemText>\n      <slot />\n    </SelectItemText>\n  </SelectItem>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/select/SelectItemText.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectItemTextProps } from \"reka-ui\"\nimport { SelectItemText } from \"reka-ui\"\n\nconst props = defineProps<SelectItemTextProps>()\n</script>\n\n<template>\n  <SelectItemText v-bind=\"props\">\n    <slot />\n  </SelectItemText>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/select/SelectLabel.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectLabelProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { SelectLabel } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<SelectLabelProps & { class?: HTMLAttributes[\"class\"] }>()\n</script>\n\n<template>\n  <SelectLabel :class=\"cn('py-1.5 pl-8 pr-2 text-sm font-semibold', props.class)\">\n    <slot />\n  </SelectLabel>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/select/SelectScrollDownButton.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectScrollDownButtonProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { ChevronDown } from \"lucide-vue-next\"\nimport { SelectScrollDownButton, useForwardProps } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <SelectScrollDownButton v-bind=\"forwardedProps\" :class=\"cn('flex cursor-default items-center justify-center py-1', props.class)\">\n    <slot>\n      <ChevronDown class=\"h-4 w-4\" />\n    </slot>\n  </SelectScrollDownButton>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/select/SelectScrollUpButton.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectScrollUpButtonProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { ChevronUp } from \"lucide-vue-next\"\nimport { SelectScrollUpButton, useForwardProps } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <SelectScrollUpButton v-bind=\"forwardedProps\" :class=\"cn('flex cursor-default items-center justify-center py-1', props.class)\">\n    <slot>\n      <ChevronUp class=\"h-4 w-4\" />\n    </slot>\n  </SelectScrollUpButton>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/select/SelectSeparator.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectSeparatorProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { SelectSeparator } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <SelectSeparator v-bind=\"delegatedProps\" :class=\"cn('-mx-1 my-1 h-px bg-muted', props.class)\" />\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/select/SelectTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectTriggerProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { ChevronDown } from \"lucide-vue-next\"\nimport { SelectIcon, SelectTrigger, useForwardProps } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<SelectTriggerProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <SelectTrigger\n    v-bind=\"forwardedProps\"\n    :class=\"cn(\n      'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',\n      props.class,\n    )\"\n  >\n    <slot />\n    <SelectIcon as-child>\n      <ChevronDown class=\"w-4 h-4 opacity-50 shrink-0\" />\n    </SelectIcon>\n  </SelectTrigger>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/select/SelectValue.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectValueProps } from \"reka-ui\"\nimport { SelectValue } from \"reka-ui\"\n\nconst props = defineProps<SelectValueProps>()\n</script>\n\n<template>\n  <SelectValue v-bind=\"props\">\n    <slot />\n  </SelectValue>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/select/index.ts",
    "content": "export { default as Select } from \"./Select.vue\"\nexport { default as SelectContent } from \"./SelectContent.vue\"\nexport { default as SelectGroup } from \"./SelectGroup.vue\"\nexport { default as SelectItem } from \"./SelectItem.vue\"\nexport { default as SelectItemText } from \"./SelectItemText.vue\"\nexport { default as SelectLabel } from \"./SelectLabel.vue\"\nexport { default as SelectScrollDownButton } from \"./SelectScrollDownButton.vue\"\nexport { default as SelectScrollUpButton } from \"./SelectScrollUpButton.vue\"\nexport { default as SelectSeparator } from \"./SelectSeparator.vue\"\nexport { default as SelectTrigger } from \"./SelectTrigger.vue\"\nexport { default as SelectValue } from \"./SelectValue.vue\"\n"
  },
  {
    "path": "web-ui/src/components/ui/switch/Switch.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SwitchRootEmits, SwitchRootProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport {\n  SwitchRoot,\n  SwitchThumb,\n  useForwardPropsEmits,\n} from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<SwitchRootProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst emits = defineEmits<SwitchRootEmits>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <SwitchRoot\n    v-bind=\"forwarded\"\n    :class=\"cn(\n      'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',\n      props.class,\n    )\"\n  >\n    <SwitchThumb\n      :class=\"cn('pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5')\"\n    >\n      <slot name=\"thumb\" />\n    </SwitchThumb>\n  </SwitchRoot>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/switch/index.ts",
    "content": "export { default as Switch } from \"./Switch.vue\"\n"
  },
  {
    "path": "web-ui/src/components/ui/table/Table.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  class?: string\n}>()\n</script>\n\n<template>\n  <div class=\"relative w-full overflow-auto\">\n    <table :class=\"cn('w-full caption-bottom text-sm', props.class)\">\n      <slot />\n    </table>\n  </div>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/table/TableBody.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  class?: string\n}>()\n</script>\n\n<template>\n  <tbody :class=\"cn('[&_tr:last-child]:border-0', props.class)\">\n    <slot />\n  </tbody>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/table/TableCaption.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  class?: string\n}>()\n</script>\n\n<template>\n  <caption\n    :class=\"cn('mt-4 text-sm text-muted-foreground', props.class)\"\n  >\n    <slot />\n  </caption>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/table/TableCell.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  class?: string\n}>()\n</script>\n\n<template>\n  <td :class=\"cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', props.class)\">\n    <slot />\n  </td>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/table/TableHead.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  class?: string\n}>()\n</script>\n\n<template>\n  <th\n    :class=\"\n      cn(\n        'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',\n        props.class,\n      )\n    \"\n  >\n    <slot />\n  </th>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/table/TableHeader.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  class?: string\n}>()\n</script>\n\n<template>\n  <thead :class=\"cn('[&_tr]:border-b', props.class)\">\n    <slot />\n  </thead>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/table/TableRow.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  class?: string\n}>()\n</script>\n\n<template>\n  <tr\n    :class=\"\n      cn(\n        'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',\n        props.class,\n      )\n    \"\n  >\n    <slot />\n  </tr>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/table/index.ts",
    "content": "export { default as Table } from './Table.vue'\nexport { default as TableBody } from './TableBody.vue'\nexport { default as TableCaption } from './TableCaption.vue'\nexport { default as TableCell } from './TableCell.vue'\nexport { default as TableHead } from './TableHead.vue'\nexport { default as TableHeader } from './TableHeader.vue'\nexport { default as TableRow } from './TableRow.vue'\n"
  },
  {
    "path": "web-ui/src/components/ui/tabs/Tabs.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TabsRootEmits, TabsRootProps } from \"reka-ui\"\nimport { TabsRoot, useForwardPropsEmits } from \"reka-ui\"\n\nconst props = defineProps<TabsRootProps>()\nconst emits = defineEmits<TabsRootEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <TabsRoot v-bind=\"forwarded\">\n    <slot />\n  </TabsRoot>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/tabs/TabsContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TabsContentProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { TabsContent } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<TabsContentProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <TabsContent\n    :class=\"cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', props.class)\"\n    v-bind=\"delegatedProps\"\n  >\n    <slot />\n  </TabsContent>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/tabs/TabsList.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TabsListProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { TabsList } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<TabsListProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <TabsList\n    v-bind=\"delegatedProps\"\n    :class=\"cn(\n      'inline-flex items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',\n      props.class,\n    )\"\n  >\n    <slot />\n  </TabsList>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/tabs/TabsTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TabsTriggerProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { TabsTrigger, useForwardProps } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<TabsTriggerProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <TabsTrigger\n    v-bind=\"forwardedProps\"\n    :class=\"cn(\n      'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',\n      props.class,\n    )\"\n  >\n    <span class=\"truncate\">\n      <slot />\n    </span>\n  </TabsTrigger>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/tabs/index.ts",
    "content": "export { default as Tabs } from \"./Tabs.vue\"\nexport { default as TabsContent } from \"./TabsContent.vue\"\nexport { default as TabsList } from \"./TabsList.vue\"\nexport { default as TabsTrigger } from \"./TabsTrigger.vue\"\n"
  },
  {
    "path": "web-ui/src/components/ui/textarea/Textarea.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { useVModel } from \"@vueuse/core\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n  defaultValue?: string | number\n  modelValue?: string | number\n}>()\n\nconst emits = defineEmits<{\n  (e: \"update:modelValue\", payload: string | number): void\n}>()\n\nconst modelValue = useVModel(props, \"modelValue\", emits, {\n  passive: true,\n  defaultValue: props.defaultValue,\n})\n</script>\n\n<template>\n  <textarea v-model=\"modelValue\" :class=\"cn('flex min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)\" />\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/textarea/index.ts",
    "content": "export { default as Textarea } from \"./Textarea.vue\"\n"
  },
  {
    "path": "web-ui/src/components/ui/toast/Toast.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ToastRootEmits } from \"reka-ui\"\nimport type { ToastProps } from \".\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { ToastRoot, useForwardPropsEmits } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\nimport { toastVariants } from \".\"\n\nconst props = defineProps<ToastProps>()\n\nconst emits = defineEmits<ToastRootEmits>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <ToastRoot\n    v-bind=\"forwarded\"\n    :class=\"cn(toastVariants({ variant }), props.class)\"\n    @update:open=\"onOpenChange\"\n  >\n    <slot />\n  </ToastRoot>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/toast/ToastAction.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ToastActionProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { ToastAction } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<ToastActionProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <ToastAction v-bind=\"delegatedProps\" :class=\"cn('inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive', props.class)\">\n    <slot />\n  </ToastAction>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/toast/ToastClose.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ToastCloseProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { X } from \"lucide-vue-next\"\nimport { ToastClose } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<ToastCloseProps & {\n  class?: HTMLAttributes[\"class\"]\n}>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <ToastClose v-bind=\"delegatedProps\" :class=\"cn('absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600', props.class)\">\n    <X class=\"h-4 w-4\" />\n  </ToastClose>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/toast/ToastDescription.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ToastDescriptionProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { ToastDescription } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<ToastDescriptionProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <ToastDescription :class=\"cn('text-sm opacity-90', props.class)\" v-bind=\"delegatedProps\">\n    <slot />\n  </ToastDescription>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/toast/ToastProvider.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ToastProviderProps } from \"reka-ui\"\nimport { ToastProvider } from \"reka-ui\"\n\nconst props = defineProps<ToastProviderProps>()\n</script>\n\n<template>\n  <ToastProvider v-bind=\"props\">\n    <slot />\n  </ToastProvider>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/toast/ToastTitle.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ToastTitleProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { ToastTitle } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<ToastTitleProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <ToastTitle v-bind=\"delegatedProps\" :class=\"cn('text-sm font-semibold', props.class)\">\n    <slot />\n  </ToastTitle>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/toast/ToastViewport.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ToastViewportProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport { reactiveOmit } from \"@vueuse/core\"\nimport { ToastViewport } from \"reka-ui\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defineProps<ToastViewportProps & { class?: HTMLAttributes[\"class\"] }>()\n\nconst delegatedProps = reactiveOmit(props, \"class\")\n</script>\n\n<template>\n  <ToastViewport\n    v-bind=\"delegatedProps\"\n    :class=\"cn('fixed top-4 left-1/2 z-[100] flex max-h-screen w-full -translate-x-1/2 flex-col-reverse p-4 sm:flex-col md:max-w-[420px]', props.class)\"\n  />\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/toast/Toaster.vue",
    "content": "<script setup lang=\"ts\">\nimport { isVNode } from \"vue\"\nimport { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from \".\"\nimport { useToast } from \"./use-toast\"\n\nconst { toasts } = useToast()\n</script>\n\n<template>\n  <ToastProvider>\n    <Toast v-for=\"toast in toasts\" :key=\"toast.id\" v-bind=\"toast\">\n      <div class=\"grid gap-1\">\n        <ToastTitle v-if=\"toast.title\">\n          {{ toast.title }}\n        </ToastTitle>\n        <template v-if=\"toast.description\">\n          <ToastDescription v-if=\"isVNode(toast.description)\">\n            <component :is=\"toast.description\" />\n          </ToastDescription>\n          <ToastDescription v-else>\n            {{ toast.description }}\n          </ToastDescription>\n        </template>\n        <ToastClose />\n      </div>\n      <component :is=\"toast.action\" />\n    </Toast>\n    <ToastViewport />\n  </ToastProvider>\n</template>\n"
  },
  {
    "path": "web-ui/src/components/ui/toast/index.ts",
    "content": "import type { ToastRootProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\n\nexport { default as Toast } from \"./Toast.vue\"\nexport { default as ToastAction } from \"./ToastAction.vue\"\nexport { default as ToastClose } from \"./ToastClose.vue\"\nexport { default as ToastDescription } from \"./ToastDescription.vue\"\nexport { default as Toaster } from \"./Toaster.vue\"\nexport { default as ToastProvider } from \"./ToastProvider.vue\"\nexport { default as ToastTitle } from \"./ToastTitle.vue\"\nexport { default as ToastViewport } from \"./ToastViewport.vue\"\nexport { toast, useToast } from \"./use-toast\"\n\nimport type { VariantProps } from \"class-variance-authority\"\nimport { cva } from \"class-variance-authority\"\n\nexport const toastVariants = cva(\n  \"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[--reka-toast-swipe-end-x] data-[swipe=move]:translate-x-[--reka-toast-swipe-move-x] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full\",\n  {\n    variants: {\n      variant: {\n        default: \"border bg-background text-foreground\",\n        destructive:\n                    \"destructive group border-destructive bg-destructive text-destructive-foreground\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n)\n\ntype ToastVariants = VariantProps<typeof toastVariants>\n\nexport interface ToastProps extends ToastRootProps {\n  class?: HTMLAttributes[\"class\"]\n  variant?: ToastVariants[\"variant\"]\n  onOpenChange?: ((value: boolean) => void) | undefined\n}\n"
  },
  {
    "path": "web-ui/src/components/ui/toast/use-toast.ts",
    "content": "import type { Component, VNode } from \"vue\"\nimport type { ToastProps } from \".\"\nimport { computed, ref } from \"vue\"\n\nconst TOAST_LIMIT = 1\nconst TOAST_REMOVE_DELAY = 1000000\n\nexport type StringOrVNode\n  = | string\n    | VNode\n    | (() => VNode)\n\ntype ToasterToast = ToastProps & {\n  id: string\n  title?: string\n  description?: StringOrVNode\n  action?: Component\n}\n\nconst actionTypes = {\n  ADD_TOAST: \"ADD_TOAST\",\n  UPDATE_TOAST: \"UPDATE_TOAST\",\n  DISMISS_TOAST: \"DISMISS_TOAST\",\n  REMOVE_TOAST: \"REMOVE_TOAST\",\n} as const\n\nlet count = 0\n\nfunction genId() {\n  count = (count + 1) % Number.MAX_VALUE\n  return count.toString()\n}\n\ntype ActionType = typeof actionTypes\n\ntype Action\n  = | {\n    type: ActionType[\"ADD_TOAST\"]\n    toast: ToasterToast\n  }\n  | {\n    type: ActionType[\"UPDATE_TOAST\"]\n    toast: Partial<ToasterToast>\n  }\n  | {\n    type: ActionType[\"DISMISS_TOAST\"]\n    toastId?: ToasterToast[\"id\"]\n  }\n  | {\n    type: ActionType[\"REMOVE_TOAST\"]\n    toastId?: ToasterToast[\"id\"]\n  }\n\ninterface State {\n  toasts: ToasterToast[]\n}\n\nconst toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()\n\nfunction addToRemoveQueue(toastId: string) {\n  if (toastTimeouts.has(toastId))\n    return\n\n  const timeout = setTimeout(() => {\n    toastTimeouts.delete(toastId)\n    dispatch({\n      type: actionTypes.REMOVE_TOAST,\n      toastId,\n    })\n  }, TOAST_REMOVE_DELAY)\n\n  toastTimeouts.set(toastId, timeout)\n}\n\nconst state = ref<State>({\n  toasts: [],\n})\n\nfunction dispatch(action: Action) {\n  switch (action.type) {\n    case actionTypes.ADD_TOAST:\n      state.value.toasts = [action.toast, ...state.value.toasts].slice(0, TOAST_LIMIT)\n      break\n\n    case actionTypes.UPDATE_TOAST:\n      state.value.toasts = state.value.toasts.map(t =>\n        t.id === action.toast.id ? { ...t, ...action.toast } : t,\n      )\n      break\n\n    case actionTypes.DISMISS_TOAST: {\n      const { toastId } = action\n\n      if (toastId) {\n        addToRemoveQueue(toastId)\n      }\n      else {\n        state.value.toasts.forEach((toast) => {\n          addToRemoveQueue(toast.id)\n        })\n      }\n\n      state.value.toasts = state.value.toasts.map(t =>\n        t.id === toastId || toastId === undefined\n          ? {\n              ...t,\n              open: false,\n            }\n          : t,\n      )\n      break\n    }\n\n    case actionTypes.REMOVE_TOAST:\n      if (action.toastId === undefined)\n        state.value.toasts = []\n      else\n        state.value.toasts = state.value.toasts.filter(t => t.id !== action.toastId)\n\n      break\n  }\n}\n\nfunction useToast() {\n  return {\n    toasts: computed(() => state.value.toasts),\n    toast,\n    dismiss: (toastId?: string) => dispatch({ type: actionTypes.DISMISS_TOAST, toastId }),\n  }\n}\n\ntype Toast = Omit<ToasterToast, \"id\">\n\nfunction toast(props: Toast) {\n  const id = genId()\n\n  const update = (props: ToasterToast) =>\n    dispatch({\n      type: actionTypes.UPDATE_TOAST,\n      toast: { ...props, id },\n    })\n\n  const dismiss = () => dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id })\n\n  dispatch({\n    type: actionTypes.ADD_TOAST,\n    toast: {\n      ...props,\n      id,\n      open: true,\n      onOpenChange: (open: boolean) => {\n        if (!open)\n          dismiss()\n      },\n    },\n  })\n\n  return {\n    id,\n    dismiss,\n    update,\n  }\n}\n\nexport { toast, useToast }\n"
  },
  {
    "path": "web-ui/src/composables/useAuth.ts",
    "content": "import { ref, computed } from 'vue'\nimport { useRouter } from 'vue-router'\nimport { wsService } from '@/services/websocket'\n\n// Global State\nconst username = ref<string | null>(localStorage.getItem('auth_username'))\nconst isLoggedIn = ref(localStorage.getItem('auth_logged_in') === 'true')\n\nexport function useAuth() {\n  const router = useRouter()\n\n  const isAuthenticated = computed(() => isLoggedIn.value)\n\n  function setAuthenticated(user: string) {\n    username.value = user\n    isLoggedIn.value = true\n\n    localStorage.setItem('auth_username', user)\n    localStorage.setItem('auth_logged_in', 'true')\n\n    // 启动 WebSocket 连接\n    wsService.start()\n  }\n\n  function logout() {\n    username.value = null\n    isLoggedIn.value = false\n    localStorage.removeItem('auth_username')\n    localStorage.removeItem('auth_logged_in')\n\n    // 停止 WebSocket 连接\n    wsService.stop()\n\n    // Redirect to login if using router\n    if (router) {\n      router.push('/login')\n    } else {\n      window.location.href = '/login'\n    }\n  }\n\n  async function login(user: string, pass: string): Promise<boolean> {\n    try {\n      const response = await fetch('/auth/status', {\n        method: 'POST',\n        headers: {\n          'Content-Type': 'application/json',\n        },\n        body: JSON.stringify({ username: user, password: pass }),\n      })\n\n      if (response.ok) {\n        setAuthenticated(user)\n        return true\n      } else {\n        return false\n      }\n    } catch (e) {\n      console.error('Login error', e)\n      return false\n    }\n  }\n\n  return {\n    username,\n    isAuthenticated,\n    login,\n    logout\n  }\n}\n"
  },
  {
    "path": "web-ui/src/composables/useDashboard.ts",
    "content": "import { computed, ref, watch } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport * as dashboardApi from '@/api/dashboard'\nimport * as resultsApi from '@/api/results'\nimport { useWebSocket } from '@/composables/useWebSocket'\nimport type {\n  DashboardSnapshot,\n  DashboardSuggestion,\n  DashboardTaskSummary,\n} from '@/types/dashboard.d.ts'\nimport type { ResultInsights } from '@/types/result.d.ts'\n\nfunction buildSuggestion(\n  focusTask: DashboardTaskSummary | undefined,\n  t: (key: string, params?: Record<string, unknown>) => string,\n): DashboardSuggestion {\n  if (!focusTask || focusTask.task_id === null) {\n    return {\n      title: t('dashboard.suggestion.firstTaskTitle'),\n      description: t('dashboard.suggestion.firstTaskDescription'),\n      actionLabel: t('dashboard.suggestion.firstTaskAction'),\n      routeName: 'Tasks',\n      query: { create: '1' },\n    }\n  }\n\n  const query: Record<string, string> = {\n    edit: String(focusTask.task_id),\n    taskName: focusTask.task_name,\n    keyword: focusTask.keyword,\n    maxPages: String(Math.max(3, focusTask.total_items > 80 ? 5 : 4)),\n    newPublishOption: focusTask.recommended_items > 0 ? '1天内' : '最新',\n    freeShipping: 'true',\n    personalOnly: 'true',\n  }\n\n  return {\n    title: focusTask.recommended_items > 0\n      ? t('dashboard.suggestion.improveValueTitle')\n      : t('dashboard.suggestion.improveHitRateTitle'),\n    description: focusTask.recommended_items > 0\n      ? t('dashboard.suggestion.improveValueDescription', { task: focusTask.task_name })\n      : t('dashboard.suggestion.improveHitRateDescription', { task: focusTask.task_name }),\n    actionLabel: t('dashboard.suggestion.openTaskAction'),\n    routeName: 'Tasks',\n    query,\n  }\n}\n\nexport function useDashboard() {\n  const { t } = useI18n()\n  const { on } = useWebSocket()\n  const snapshot = ref<DashboardSnapshot | null>(null)\n  const focusInsights = ref<ResultInsights | null>(null)\n  const isLoading = ref(false)\n  const error = ref<Error | null>(null)\n\n  async function fetchSummary() {\n    isLoading.value = true\n    error.value = null\n    try {\n      snapshot.value = await dashboardApi.getDashboardSummary()\n    } catch (e) {\n      if (e instanceof Error) error.value = e\n    } finally {\n      isLoading.value = false\n    }\n  }\n\n  const taskSummaries = computed(() => snapshot.value?.task_summaries || [])\n  const activities = computed(() => snapshot.value?.recent_activities || [])\n\n  const stats = computed(() => {\n    const summary = snapshot.value?.summary\n    return {\n      totalTasks: taskSummaries.value.length,\n      enabledTasks: summary?.enabled_tasks || 0,\n      runningTasks: summary?.running_tasks || 0,\n      scannedItems: summary?.scanned_items || 0,\n      recommendedItems: summary?.recommended_items || 0,\n      aiRecommendedItems: summary?.ai_recommended_items || 0,\n      keywordRecommendedItems: summary?.keyword_recommended_items || 0,\n      resultFiles: summary?.result_files || 0,\n    }\n  })\n\n  const focusTask = computed(() =>\n    taskSummaries.value.find((item) => item.filename === snapshot.value?.focus_file) ||\n    taskSummaries.value.find((item) => item.filename) ||\n    taskSummaries.value[0]\n  )\n\n  async function fetchFocusInsights(filename: string | null | undefined) {\n    if (!filename) {\n      focusInsights.value = null\n      return\n    }\n    try {\n      focusInsights.value = await resultsApi.getResultInsights(filename)\n    } catch {\n      focusInsights.value = null\n    }\n  }\n\n  watch(\n    () => focusTask.value?.filename,\n    (filename) => {\n      fetchFocusInsights(filename)\n    },\n    { immediate: true }\n  )\n\n  const suggestion = computed(() => buildSuggestion(focusTask.value, t))\n\n  on('tasks_updated', fetchSummary)\n  on('results_updated', fetchSummary)\n  on('task_status_changed', fetchSummary)\n\n  fetchSummary()\n\n  return {\n    snapshot,\n    focusInsights,\n    stats,\n    taskSummaries,\n    activities,\n    focusTask,\n    suggestion,\n    isLoading,\n    error,\n    fetchSummary,\n  }\n}\n"
  },
  {
    "path": "web-ui/src/composables/useLogs.ts",
    "content": "import { ref, onMounted, onUnmounted } from 'vue'\nimport * as logsApi from '@/api/logs'\nimport { t } from '@/i18n'\n\nexport function useLogs() {\n  const logs = ref('')\n  const currentPos = ref(0)\n  const currentTaskId = ref<number | null>(null)\n  const historyOffset = ref(0)\n  const hasMoreHistory = ref(false)\n  const isFetchingHistory = ref(false)\n  const isAutoRefresh = ref(true)\n  const isLoading = ref(false)\n  const error = ref<Error | null>(null)\n  \n  let refreshInterval: number | null = null\n  const MAX_LOG_CHARS = 200_000\n  const TRIM_LOG_CHARS = 150_000\n  const TRIM_NOTICE = t('logs.trimmedNotice')\n\n  function appendLogs(content: string) {\n    if (!content) return\n    logs.value += content\n    // Prevent unbounded growth that can freeze the UI.\n    if (logs.value.length > MAX_LOG_CHARS) {\n      const tail = logs.value.slice(-TRIM_LOG_CHARS)\n      logs.value = `${TRIM_NOTICE}\\n${tail}`\n    }\n  }\n\n  async function fetchLogs() {\n    if (isLoading.value) return\n    if (currentTaskId.value === null) return\n    isLoading.value = true\n    try {\n      const data = await logsApi.getLogs(currentPos.value, currentTaskId.value)\n      if (data.new_pos < currentPos.value) {\n        // Log file rotated or cleared.\n        logs.value = ''\n      }\n      if (data.new_content) {\n        appendLogs(data.new_content)\n      }\n      currentPos.value = data.new_pos\n    } catch (e) {\n      if (e instanceof Error) error.value = e\n    } finally {\n      isLoading.value = false\n    }\n  }\n\n  async function loadLatest(limitLines: number = 50) {\n    if (isFetchingHistory.value) return\n    if (currentTaskId.value === null) return\n    isFetchingHistory.value = true\n    try {\n      const data = await logsApi.getLogTail(currentTaskId.value, 0, limitLines)\n      logs.value = data.content || ''\n      historyOffset.value = data.next_offset\n      hasMoreHistory.value = data.has_more\n      currentPos.value = data.new_pos\n    } catch (e) {\n      if (e instanceof Error) error.value = e\n    } finally {\n      isFetchingHistory.value = false\n    }\n  }\n\n  async function loadPrevious(limitLines: number = 50) {\n    if (isFetchingHistory.value) return\n    if (!hasMoreHistory.value) return\n    if (currentTaskId.value === null) return\n    isFetchingHistory.value = true\n    try {\n      const data = await logsApi.getLogTail(currentTaskId.value, historyOffset.value, limitLines)\n      if (data.content) {\n        logs.value = logs.value ? `${data.content}\\n${logs.value}` : data.content\n      }\n      historyOffset.value = data.next_offset\n      hasMoreHistory.value = data.has_more\n      currentPos.value = data.new_pos\n    } catch (e) {\n      if (e instanceof Error) error.value = e\n    } finally {\n      isFetchingHistory.value = false\n    }\n  }\n\n  async function clearLogs() {\n    try {\n      if (currentTaskId.value === null) return\n      await logsApi.clearLogs(currentTaskId.value)\n      logs.value = ''\n      currentPos.value = 0\n      historyOffset.value = 0\n      hasMoreHistory.value = false\n    } catch (e) {\n      if (e instanceof Error) error.value = e\n      throw e\n    }\n  }\n\n  function startAutoRefresh() {\n    if (refreshInterval) return\n    fetchLogs() // Fetch immediately\n    refreshInterval = window.setInterval(fetchLogs, 2000)\n    isAutoRefresh.value = true\n  }\n\n  function stopAutoRefresh() {\n    if (refreshInterval) {\n      clearInterval(refreshInterval)\n      refreshInterval = null\n    }\n    isAutoRefresh.value = false\n  }\n\n  function toggleAutoRefresh() {\n    if (isAutoRefresh.value) {\n      stopAutoRefresh()\n    } else {\n      startAutoRefresh()\n    }\n  }\n\n  function setTaskId(taskId: number | null) {\n    if (currentTaskId.value === taskId) return\n    currentTaskId.value = taskId\n    logs.value = ''\n    currentPos.value = 0\n    historyOffset.value = 0\n    hasMoreHistory.value = false\n  }\n\n  onMounted(() => {\n    startAutoRefresh()\n  })\n\n  onUnmounted(() => {\n    stopAutoRefresh()\n  })\n\n  return {\n    logs,\n    isAutoRefresh,\n    isLoading, // Not strictly used for polling to avoid flickering\n    isFetchingHistory,\n    hasMoreHistory,\n    error,\n    fetchLogs,\n    clearLogs,\n    toggleAutoRefresh,\n    setTaskId,\n    loadLatest,\n    loadPrevious\n  }\n}\n"
  },
  {
    "path": "web-ui/src/composables/useMobileNav.ts",
    "content": "import { ref } from 'vue'\n\nconst isMobileNavOpen = ref(false)\n\nexport function useMobileNav() {\n  function openMobileNav() {\n    isMobileNavOpen.value = true\n  }\n\n  function closeMobileNav() {\n    isMobileNavOpen.value = false\n  }\n\n  function toggleMobileNav() {\n    isMobileNavOpen.value = !isMobileNavOpen.value\n  }\n\n  return {\n    isMobileNavOpen,\n    openMobileNav,\n    closeMobileNav,\n    toggleMobileNav,\n  }\n}\n"
  },
  {
    "path": "web-ui/src/composables/useResults.ts",
    "content": "import { ref, reactive, watch, onMounted, computed } from 'vue'\nimport { useRoute } from 'vue-router'\nimport { useI18n } from 'vue-i18n'\nimport type { ResultInsights, ResultItem } from '@/types/result.d.ts'\nimport * as resultsApi from '@/api/results'\nimport type { GetResultContentParams } from '@/api/results'\nimport { useWebSocket } from '@/composables/useWebSocket'\nimport * as tasksApi from '@/api/tasks'\n\nexport function useResults() {\n  const { t } = useI18n()\n  const route = useRoute()\n  // State\n  const files = ref<string[]>([])\n  const selectedFile = ref<string | null>(null)\n  const results = ref<ResultItem[]>([])\n  const insights = ref<ResultInsights | null>(null)\n  const totalItems = ref(0)\n  const page = ref(1)\n  const limit = ref(100)\n  const taskNameByKeyword = ref<Record<string, string>>({})\n  const isFileOptionsReady = ref(false)\n  const hasFetchedFiles = ref(false)\n  const hasFetchedTasks = ref(false)\n  const readyDelayMs = 200\n  let readyTimer: ReturnType<typeof setTimeout> | null = null\n  \n  const filters = reactive<Required<Omit<GetResultContentParams, 'page' | 'limit'>>>({\n    recommended_only: false,\n    ai_recommended_only: false,\n    keyword_recommended_only: false,\n    sort_by: 'crawl_time',\n    sort_order: 'desc',\n  })\n\n  const isLoading = ref(false)\n  const error = ref<Error | null>(null)\n  const { on } = useWebSocket()\n\n  function normalizeKeyword(value: string) {\n    return value.trim().toLowerCase().replace(/\\s+/g, '_')\n  }\n\n  function getKeywordFromFilename(filename: string) {\n    return filename.replace(/_full_data\\.jsonl$/i, '').toLowerCase()\n  }\n\n  // Methods\n  async function fetchFiles() {\n    try {\n      const fileList = await resultsApi.getResultFiles()\n      files.value = fileList\n      // If a file is selected that no longer exists, reset it.\n      // Otherwise, if nothing is selected, select the first file by default.\n      if (selectedFile.value && fileList.includes(selectedFile.value)) {\n        return\n      }\n\n      const lastSelected = localStorage.getItem('lastSelectedResultFile')\n      if (lastSelected && fileList.includes(lastSelected)) {\n        selectedFile.value = lastSelected\n        return\n      }\n\n      selectedFile.value = fileList[0] || null\n    } catch (e) {\n      if (e instanceof Error) error.value = e\n    } finally {\n      hasFetchedFiles.value = true\n      scheduleFileOptionsReady()\n    }\n  }\n\n  async function fetchResults() {\n    if (!selectedFile.value) {\n      results.value = []\n      totalItems.value = 0\n      return\n    }\n\n    isLoading.value = true\n    error.value = null\n    try {\n      const data = await resultsApi.getResultContent(selectedFile.value, {\n        ...filters,\n        page: page.value,\n        limit: limit.value,\n      })\n      results.value = data.items\n      totalItems.value = data.total_items\n    } catch (e) {\n      if (e instanceof Error) error.value = e\n      results.value = []\n      totalItems.value = 0\n    } finally {\n      isLoading.value = false\n    }\n  }\n\n  async function fetchInsights() {\n    if (!selectedFile.value) {\n      insights.value = null\n      return\n    }\n\n    try {\n      insights.value = await resultsApi.getResultInsights(selectedFile.value)\n    } catch (e) {\n      if (e instanceof Error) error.value = e\n      insights.value = null\n    }\n  }\n\n  async function fetchTaskNameMap() {\n    try {\n      const tasks = await tasksApi.getAllTasks()\n      const mapping: Record<string, string> = {}\n      tasks.forEach((task) => {\n        if (task.keyword) {\n          mapping[normalizeKeyword(task.keyword)] = task.task_name\n        }\n      })\n      taskNameByKeyword.value = mapping\n    } catch (e) {\n      if (e instanceof Error) error.value = e\n    } finally {\n      hasFetchedTasks.value = true\n      scheduleFileOptionsReady()\n    }\n  }\n\n  function scheduleFileOptionsReady() {\n    if (isFileOptionsReady.value || !hasFetchedFiles.value || !hasFetchedTasks.value) return\n    if (readyTimer) return\n    readyTimer = setTimeout(() => {\n      isFileOptionsReady.value = true\n      readyTimer = null\n    }, readyDelayMs)\n  }\n\n  // Real-time updates\n  on('results_updated', async () => {\n    const oldFile = selectedFile.value\n    await fetchFiles()\n    // If the selected file remains the same, refresh its content (in case of append)\n    // If it changed (e.g. from null to new file), the watcher will handle it.\n    if (selectedFile.value && selectedFile.value === oldFile) {\n      fetchResults()\n      fetchInsights()\n    }\n  })\n\n  on('tasks_updated', () => {\n    fetchTaskNameMap()\n  })\n\n  async function refreshResults() {\n    const current = selectedFile.value\n    await fetchFiles()\n    if (selectedFile.value && selectedFile.value === current) {\n      await fetchResults()\n      await fetchInsights()\n    }\n  }\n\n  function exportSelectedResults() {\n    if (!selectedFile.value) return\n    resultsApi.downloadResultExport(selectedFile.value, { ...filters })\n  }\n\n  async function deleteSelectedFile(filename?: string) {\n    const target = filename || selectedFile.value\n    if (!target) return\n    isLoading.value = true\n    error.value = null\n    try {\n      await resultsApi.deleteResultFile(target)\n      if (selectedFile.value === target) {\n        const lastSelected = localStorage.getItem('lastSelectedResultFile')\n        if (lastSelected === target) {\n          localStorage.removeItem('lastSelectedResultFile')\n        }\n      }\n      await fetchFiles()\n    } catch (e) {\n      if (e instanceof Error) error.value = e\n      throw e\n    } finally {\n      isLoading.value = false\n    }\n  }\n\n  // Watchers\n  watch([selectedFile, filters], fetchResults, { deep: true })\n  watch(selectedFile, () => {\n    fetchInsights()\n  })\n  watch(selectedFile, (value) => {\n    if (value) localStorage.setItem('lastSelectedResultFile', value)\n  })\n  watch(\n    [() => route.query.file, files],\n    ([routeFile, currentFiles]) => {\n      if (typeof routeFile !== 'string') return\n      if (currentFiles.includes(routeFile)) {\n        selectedFile.value = routeFile\n      }\n    },\n    { immediate: true }\n  )\n\n  const fileOptions = computed(() =>\n    files.value.map((file) => {\n      const keyword = getKeywordFromFilename(file)\n      const taskName = taskNameByKeyword.value[keyword]\n      return {\n        value: file,\n        taskName: taskName || t('common.unnamed'),\n        label: t('results.filters.taskNameLabel', {\n          task: taskName || t('common.unnamed'),\n        }),\n      }\n    })\n  )\n\n  // Lifecycle\n  onMounted(() => {\n    fetchFiles()\n    fetchTaskNameMap()\n  })\n\n  return {\n    files,\n    selectedFile,\n    results,\n    insights,\n    totalItems,\n    filters,\n    isLoading,\n    error,\n    fetchFiles, // Expose to allow manual refresh\n    refreshResults,\n    exportSelectedResults,\n    deleteSelectedFile,\n    fileOptions,\n    isFileOptionsReady,\n  }\n}\n"
  },
  {
    "path": "web-ui/src/composables/useSettings.ts",
    "content": "import { ref, onMounted } from 'vue'\nimport * as settingsApi from '@/api/settings'\nimport type {\n  NotificationSettings,\n  NotificationSettingsUpdate,\n  NotificationTestResponse,\n  AiSettings,\n  RotationSettings,\n  SystemStatus\n} from '@/api/settings'\n\nexport function useSettings() {\n  const notificationSettings = ref<NotificationSettings>({})\n  const aiSettings = ref<AiSettings>({})\n  const rotationSettings = ref<RotationSettings>({})\n  const systemStatus = ref<SystemStatus | null>(null)\n  const isReady = ref(false)\n  \n  const isLoading = ref(false)\n  const isSaving = ref(false)\n  const error = ref<Error | null>(null)\n\n  async function fetchAll() {\n    isLoading.value = true\n    error.value = null\n    try {\n      const [notif, ai, rotation, status] = await Promise.all([\n        settingsApi.getNotificationSettings(),\n        settingsApi.getAiSettings(),\n        settingsApi.getRotationSettings(),\n        settingsApi.getSystemStatus()\n      ])\n      notificationSettings.value = notif\n      aiSettings.value = ai\n      rotationSettings.value = rotation\n      systemStatus.value = status\n    } catch (e) {\n      if (e instanceof Error) error.value = e\n    } finally {\n      isLoading.value = false\n      isReady.value = true\n    }\n  }\n\n  async function refreshStatus() {\n    isLoading.value = true\n    error.value = null\n    try {\n      systemStatus.value = await settingsApi.getSystemStatus()\n    } catch (e) {\n      if (e instanceof Error) error.value = e\n      throw e\n    } finally {\n      isLoading.value = false\n    }\n  }\n\n  async function saveNotificationSettings(payload: NotificationSettingsUpdate) {\n    isSaving.value = true\n    try {\n      await settingsApi.updateNotificationSettings(payload)\n      const [notif, status] = await Promise.all([\n        settingsApi.getNotificationSettings(),\n        settingsApi.getSystemStatus()\n      ])\n      notificationSettings.value = notif\n      systemStatus.value = status\n    } catch (e) {\n      if (e instanceof Error) error.value = e\n      throw e\n    } finally {\n      isSaving.value = false\n    }\n  }\n\n  async function testNotification(payload: {\n    channel?: string\n    settings: NotificationSettingsUpdate\n  }): Promise<NotificationTestResponse> {\n    isSaving.value = true\n    try {\n      return await settingsApi.testNotificationSettings(payload)\n    } catch (e) {\n      if (e instanceof Error) error.value = e\n      throw e\n    } finally {\n      isSaving.value = false\n    }\n  }\n\n  async function saveAiSettings() {\n    isSaving.value = true\n    try {\n      const payload = { ...aiSettings.value }\n      const apiKey = (payload.OPENAI_API_KEY || '').trim()\n      if (apiKey) {\n        payload.OPENAI_API_KEY = apiKey\n      } else {\n        delete payload.OPENAI_API_KEY\n      }\n      await settingsApi.updateAiSettings(payload)\n      if (aiSettings.value.OPENAI_API_KEY) {\n        aiSettings.value.OPENAI_API_KEY = ''\n      }\n      // Refresh status\n      systemStatus.value = await settingsApi.getSystemStatus()\n    } catch (e) {\n      if (e instanceof Error) error.value = e\n      throw e\n    } finally {\n      isSaving.value = false\n    }\n  }\n\n  async function saveRotationSettings() {\n    isSaving.value = true\n    try {\n      await settingsApi.updateRotationSettings(rotationSettings.value)\n    } catch (e) {\n      if (e instanceof Error) error.value = e\n      throw e\n    } finally {\n      isSaving.value = false\n    }\n  }\n\n  async function testAiConnection() {\n    isSaving.value = true\n    try {\n      const payload = { ...aiSettings.value }\n      const apiKey = (payload.OPENAI_API_KEY || '').trim()\n      if (apiKey) {\n        payload.OPENAI_API_KEY = apiKey\n      } else {\n        delete payload.OPENAI_API_KEY\n      }\n      const res = await settingsApi.testAiSettings(payload)\n      return res\n    } catch (e) {\n      if (e instanceof Error) error.value = e\n      throw e\n    } finally {\n      isSaving.value = false\n    }\n  }\n\n  onMounted(fetchAll)\n\n  return {\n    notificationSettings,\n    aiSettings,\n    rotationSettings,\n    systemStatus,\n    isLoading,\n    isSaving,\n    isReady,\n    error,\n    fetchAll,\n    saveNotificationSettings,\n    testNotification,\n    saveAiSettings,\n    saveRotationSettings,\n    testAiConnection,\n    refreshStatus,\n  }\n}\n"
  },
  {
    "path": "web-ui/src/composables/useTaskGenerationJob.ts",
    "content": "import { computed, onScopeDispose, ref } from 'vue'\nimport { getTaskGenerationJob } from '@/api/tasks'\nimport type { TaskGenerationJob } from '@/types/task.d.ts'\n\nconst POLL_INTERVAL_MS = 800\n\nfunction isTerminalStatus(status: TaskGenerationJob['status']) {\n  return status === 'completed' || status === 'failed'\n}\n\nexport function useTaskGenerationJob() {\n  const activeJob = ref<TaskGenerationJob | null>(null)\n  const pollingError = ref<Error | null>(null)\n  const isPolling = ref(false)\n  let pollTimer: ReturnType<typeof window.setTimeout> | null = null\n\n  function clearTimer() {\n    if (pollTimer === null) return\n    window.clearTimeout(pollTimer)\n    pollTimer = null\n  }\n\n  async function refreshJob() {\n    if (!activeJob.value) return\n    try {\n      const nextJob = await getTaskGenerationJob(activeJob.value.job_id)\n      activeJob.value = nextJob\n      pollingError.value = null\n      if (isTerminalStatus(nextJob.status)) {\n        isPolling.value = false\n        clearTimer()\n        return\n      }\n      scheduleNextPoll()\n    } catch (error) {\n      isPolling.value = false\n      clearTimer()\n      pollingError.value = error as Error\n    }\n  }\n\n  function scheduleNextPoll() {\n    clearTimer()\n    pollTimer = window.setTimeout(() => {\n      void refreshJob()\n    }, POLL_INTERVAL_MS)\n  }\n\n  function beginPolling(job: TaskGenerationJob) {\n    activeJob.value = job\n    pollingError.value = null\n    if (isTerminalStatus(job.status)) {\n      isPolling.value = false\n      clearTimer()\n      return\n    }\n    isPolling.value = true\n    scheduleNextPoll()\n  }\n\n  function clearJob() {\n    activeJob.value = null\n    pollingError.value = null\n    isPolling.value = false\n    clearTimer()\n  }\n\n  onScopeDispose(clearJob)\n\n  return {\n    activeJob,\n    pollingError,\n    isGenerating: computed(() => isPolling.value),\n    beginPolling,\n    clearJob,\n    refreshJob,\n  }\n}\n"
  },
  {
    "path": "web-ui/src/composables/useTasks.ts",
    "content": "import { ref, onMounted } from 'vue'\nimport type {\n  Task,\n  TaskCreateResponse,\n  TaskGenerateRequest,\n  TaskUpdate,\n} from '@/types/task.d.ts'\nimport * as taskApi from '@/api/tasks'\nimport { useWebSocket } from '@/composables/useWebSocket'\n\nexport function useTasks() {\n  const tasks = ref<Task[]>([])\n  const isLoading = ref(false)\n  const error = ref<Error | null>(null)\n  const stoppingTaskIds = ref<Set<number>>(new Set())\n  const { on } = useWebSocket()\n\n  async function fetchTasks(options?: { silent?: boolean }) {\n    if (!options?.silent) {\n      isLoading.value = true\n    }\n    error.value = null\n    try {\n      tasks.value = await taskApi.getAllTasks()\n    } catch (e) {\n      if (e instanceof Error) {\n        error.value = e\n      }\n      console.error(e)\n    } finally {\n      if (!options?.silent) {\n        isLoading.value = false\n      }\n    }\n  }\n\n  // Real-time updates\n  on('tasks_updated', () => {\n    fetchTasks({ silent: true })\n  })\n\n  on('task_status_changed', (data: { id: number; is_running: boolean }) => {\n    const task = tasks.value.find((t) => t.id === data.id)\n    if (task) {\n      task.is_running = data.is_running\n    }\n    fetchTasks({ silent: true })\n  })\n\n  async function createTask(data: TaskGenerateRequest): Promise<TaskCreateResponse> {\n    isLoading.value = true\n    error.value = null\n    try {\n      return await taskApi.createTaskWithAI(data)\n    } catch (e) {\n      if (e instanceof Error) {\n        error.value = e\n      }\n      console.error(e)\n      throw e\n    } finally {\n      isLoading.value = false\n    }\n  }\n\n  async function updateTask(taskId: number, data: TaskUpdate) {\n    error.value = null\n    try {\n      const updatedTask = await taskApi.updateTask(taskId, data)\n      const index = tasks.value.findIndex((task) => task.id === updatedTask.id)\n      if (index >= 0) {\n        tasks.value[index] = { ...tasks.value[index], ...updatedTask }\n      } else {\n        tasks.value.push(updatedTask)\n      }\n    } catch (e) {\n      if (e instanceof Error) {\n        error.value = e\n      }\n      console.error(e)\n      throw e\n    }\n  }\n\n  async function removeTask(taskId: number) {\n    try {\n      await taskApi.deleteTask(taskId)\n      // Refresh the list after deleting\n      await fetchTasks()\n    } catch (e) {\n      console.error(e)\n      // Optionally, set the error ref to display it in the UI\n      if (e instanceof Error) {\n        error.value = e\n      }\n      throw e\n    }\n  }\n\n  async function startTask(taskId: number) {\n    isLoading.value = true\n    const task = tasks.value.find((t) => t.id === taskId)\n    const previous = task?.is_running\n    if (task) {\n      task.is_running = true // 乐观更新：点击后立刻显示运行中\n    }\n    try {\n      await taskApi.startTask(taskId)\n      // The websocket will update the status, but we can also optimistically update\n    } catch (e) {\n      if (task && previous !== undefined) {\n        task.is_running = previous\n      }\n      if (e instanceof Error) error.value = e\n      throw e\n    } finally {\n      isLoading.value = false\n    }\n  }\n\n  async function stopTask(taskId: number) {\n    isLoading.value = true\n    const next = new Set(stoppingTaskIds.value)\n    next.add(taskId)\n    stoppingTaskIds.value = next\n    try {\n      await taskApi.stopTask(taskId)\n    } catch (e) {\n      if (e instanceof Error) error.value = e\n      throw e\n    } finally {\n      const cleaned = new Set(stoppingTaskIds.value)\n      cleaned.delete(taskId)\n      stoppingTaskIds.value = cleaned\n      isLoading.value = false\n    }\n  }\n  \n  // Load tasks when the composable is first used in a component\n  onMounted(fetchTasks)\n\n  return {\n    tasks,\n    isLoading,\n    error,\n    fetchTasks,\n    createTask,\n    updateTask,\n    removeTask,\n    startTask,\n    stopTask,\n    stoppingTaskIds,\n  }\n}\n"
  },
  {
    "path": "web-ui/src/composables/useWebSocket.ts",
    "content": "import { ref, onScopeDispose } from 'vue'\nimport { wsService } from '@/services/websocket'\n\n// Global state for connection status\nconst isConnected = ref(wsService.isConnected)\n\n// Update global state on events\nwsService.on('connected', () => { isConnected.value = true })\nwsService.on('disconnected', () => { isConnected.value = false })\n\nexport function useWebSocket() {\n  /**\n   * Register a callback for a specific WebSocket event.\n   * Automatically removes the listener when the component is unmounted or scope disposed.\n   * \n   * @param event The event name (e.g., 'tasks_updated')\n   * @param callback The function to call when the event is received\n   */\n  function on(event: string, callback: (data: any) => void) {\n    wsService.on(event, callback)\n    \n    // Clean up when the scope (component) is disposed\n    onScopeDispose(() => {\n      wsService.off(event, callback)\n    })\n  }\n\n  return {\n    isConnected,\n    on,\n  }\n}\n"
  },
  {
    "path": "web-ui/src/i18n/index.ts",
    "content": "import { computed, watch } from 'vue'\nimport { createI18n, useI18n } from 'vue-i18n'\nimport zhCN from '@/i18n/messages/zh-CN'\nimport enUS from '@/i18n/messages/en-US'\n\nexport type AppLocale = 'zh-CN' | 'en-US'\n\nconst LOCALE_STORAGE_KEY = 'app_locale'\nconst DEFAULT_LOCALE: AppLocale = 'zh-CN'\nconst SUPPORTED_LOCALES: AppLocale[] = ['zh-CN', 'en-US']\n\nfunction resolveInitialLocale(): AppLocale {\n  const saved = localStorage.getItem(LOCALE_STORAGE_KEY)\n  if (saved && SUPPORTED_LOCALES.includes(saved as AppLocale)) {\n    return saved as AppLocale\n  }\n  const browserLocale = navigator.language.toLowerCase()\n  return browserLocale.startsWith('zh') ? 'zh-CN' : 'en-US'\n}\n\nexport const i18n = createI18n({\n  legacy: false,\n  locale: resolveInitialLocale(),\n  fallbackLocale: DEFAULT_LOCALE,\n  messages: {\n    'zh-CN': zhCN,\n    'en-US': enUS,\n  },\n})\n\nexport function setLocale(locale: AppLocale) {\n  i18n.global.locale.value = locale\n}\n\nexport function useLocale() {\n  const { locale } = useI18n()\n  const currentLocale = computed(() => locale.value as AppLocale)\n\n  function toggleLocale(nextLocale: AppLocale) {\n    locale.value = nextLocale\n  }\n\n  return {\n    locale: currentLocale,\n    toggleLocale,\n    supportedLocales: SUPPORTED_LOCALES,\n  }\n}\n\nexport function t(key: string, params?: Record<string, unknown>) {\n  return params ? i18n.global.t(key, params) : i18n.global.t(key)\n}\n\nexport function formatNumber(value: number, options?: Intl.NumberFormatOptions) {\n  return new Intl.NumberFormat(i18n.global.locale.value, options).format(value)\n}\n\nexport function formatDateTime(\n  value: string | Date,\n  options: Intl.DateTimeFormatOptions = {},\n) {\n  const date = value instanceof Date ? value : new Date(value)\n  return new Intl.DateTimeFormat(i18n.global.locale.value, options).format(date)\n}\n\nexport function formatRelativeTimeFromNow(value: string | null | undefined) {\n  if (!value) return t('time.justNow')\n  const timestamp = new Date(value).getTime()\n  if (Number.isNaN(timestamp)) return t('time.justNow')\n  const diffMinutes = Math.max(1, Math.round((Date.now() - timestamp) / 60000))\n  if (diffMinutes < 60) return t('time.minutesAgo', { count: diffMinutes })\n  const diffHours = Math.round(diffMinutes / 60)\n  if (diffHours < 24) return t('time.hoursAgo', { count: diffHours })\n  const diffDays = Math.round(diffHours / 24)\n  return t('time.daysAgo', { count: diffDays })\n}\n\nwatch(\n  () => i18n.global.locale.value,\n  (locale) => {\n    localStorage.setItem(LOCALE_STORAGE_KEY, locale)\n    document.documentElement.lang = locale\n  },\n  { immediate: true },\n)\n"
  },
  {
    "path": "web-ui/src/i18n/messages/en-US-extra.ts",
    "content": "const enUSExtra = {\n  tasks: {\n    title: 'Tasks',\n    toasts: {\n      created: 'Task created successfully.',\n      createFailed: 'Failed to create task.',\n      deleted: 'Task deleted.',\n      deleteFailed: 'Failed to delete task.',\n      updateFailed: 'Failed to update task.',\n      descriptionRequired: 'Detailed requirement is required.',\n      regenerateFailed: 'Failed to regenerate criteria.',\n      startFailed: 'Failed to start task.',\n      stopFailed: 'Failed to stop task.',\n      toggleFailed: 'Failed to update status.',\n      loadAccountsFailed: 'Failed to load account list.',\n      notFound: 'Task to delete was not found.',\n      progressFailed: 'Failed to fetch task progress.',\n    },\n    createDialog: { trigger: '+ New Task', title: 'Create Monitoring Task (AI or Keyword)', submit: 'Create Task', submitting: 'Submitting...' },\n    editDialog: { title: 'Edit Task: {task}', save: 'Save Changes' },\n    criteria: { title: 'Regenerate AI Criteria', description: 'Update the detailed requirement to regenerate AI analysis criteria.', descriptionRequired: 'Please provide a new detailed requirement.', action: 'Regenerate', generating: 'Generating...' },\n    deleteDialog: { title: 'Delete Task', descriptionWithTask: 'Delete task \"{task}\"? This action cannot be undone.', descriptionFallback: 'Delete this task? This action cannot be undone.', confirm: 'Delete' },\n    generation: {\n      title: 'Task Generation Progress',\n      description: 'AI is generating analysis criteria and creating the task.',\n      helperFailed: 'The dialog stays open after a failure so you can inspect the error.',\n      helperRunning: 'The task is generating in the background. You can watch progress here or close this dialog.',\n      closeWindow: 'Close Window',\n      status: { completed: 'Completed', failed: 'Failed', running: 'Running', queued: 'Queued' },\n    },\n    search: {\n      matchingTasks: 'Matching Tasks',\n      recentTasks: 'Recent Tasks',\n      placeholder: 'Search by task name, keyword, or region...',\n      resultCount: '{count} candidate tasks found',\n      enterHint: 'Press Enter to open the highlighted task',\n      loading: 'Loading task list...',\n      emptyTitle: 'No matching task found',\n      emptyDescription: 'Try task names, keywords, or regions such as MacBook, watch, or Shanghai.',\n      footerHint: 'Search results only come from the task list and do not change dashboard content.',\n      keyboardUpDown: '↑↓ Navigate',\n      keyboardEnter: 'Enter Open',\n      loadFailed: 'Failed to load tasks.',\n      maxPages: 'Up to {count} pages',\n    },\n    form: {\n      taskName: 'Task Name',\n      taskNamePlaceholder: 'Example: Sony A7M4 Camera',\n      keyword: 'Search Keyword',\n      keywordPlaceholder: 'Example: a7m4',\n      decisionMode: 'Decision Mode',\n      decisionModePlaceholder: 'Select a decision mode',\n      aiMode: 'AI Decision',\n      keywordMode: 'Keyword Rules',\n      description: 'Detailed Requirement',\n      descriptionPlaceholder: 'Describe your purchase requirement in natural language so AI can generate the analysis criteria...',\n      keywordDescriptionHint: 'Optional in keyword mode; required in AI mode.',\n      analyzeImages: 'Analyze Images',\n      analyzeImagesHint: 'Disable this to analyze only text and seller quality, useful for text-only models or lower token usage.',\n      keywordRules: 'Keyword Rules',\n      keywordRulesHint: 'Single OR group: any matching keyword recommends the item. One keyword per line or comma-separated; alphanumeric keywords match whole words.',\n      keywordRulesPlaceholder: 'Example: a7m4\\nVerified Authentic\\nFull Frame',\n      priceRange: 'Price Range',\n      minPrice: 'Min Price',\n      maxPrice: 'Max Price',\n      maxPages: 'Search Pages',\n      schedule: 'Schedule',\n      cronPresetTab: 'Preset',\n      cronCustomTab: 'Custom',\n      cronPlaceholder: 'Select a schedule',\n      cronCustomPlaceholder: 'Example: 0 8 * * * / 0 0 8 * * * / @daily',\n      cronCustomHintLine1: 'Supports 5-part cron: minute hour day month weekday; 6-part cron with seconds; also supports @hourly / @daily / @weekly / @monthly / @yearly. Server timezone: Asia/Shanghai.',\n      cronCustomHintLine2: 'Examples: every 15 minutes */15 * * * *; every day at 8:00 0 8 * * *; every day at 8:00 with seconds 0 0 8 * * *.',\n      cron: {\n        manual: 'Manual Only',\n        every5Minutes: 'Every 5 Minutes',\n        every15Minutes: 'Every 15 Minutes',\n        every30Minutes: 'Every 30 Minutes',\n        hourly: 'Hourly',\n        every2Hours: 'Every 2 Hours',\n        every6Hours: 'Every 6 Hours',\n        daily8: 'Daily 08:00',\n        daily12: 'Daily 12:00',\n        daily18: 'Daily 18:00',\n        daily20: 'Daily 20:00',\n        daily81218: 'Daily 08:00 / 12:00 / 18:00',\n        weekday9: 'Weekdays 09:00',\n        weekend10: 'Weekends 10:00',\n      },\n      accountStrategyLabel: 'Account Strategy',\n      fixedAccount: 'Fixed Account',\n      selectAccount: 'Select an account',\n      personalOnly: 'Personal Sellers Only',\n      freeShipping: 'Free Shipping Only',\n      newPublish: 'Fresh Listing Window',\n      region: 'Region Filter (optional)',\n      regionHint: 'Region filters can significantly reduce the number of matching items.',\n      accountStrategy: {\n        auto: 'Auto',\n        autoDescription: 'Prefer the default login state; fallback to the account pool.',\n        fixed: 'Fixed',\n        fixedDescription: 'Always bind this task to one specific account.',\n        rotate: 'Rotate',\n        rotateDescription: 'Force this task to rotate through the account pool.',\n      },\n      publishOptions: { none: 'No Filter (default)', latest: 'Latest', oneDay: 'Within 1 Day', threeDays: 'Within 3 Days', sevenDays: 'Within 7 Days', fourteenDays: 'Within 14 Days' },\n      validation: {\n        incomplete: 'Incomplete information',\n        nameAndKeywordRequired: 'Task name and keyword are required.',\n        aiDescriptionRequired: 'Detailed requirement is required in AI mode.',\n        keywordRuleIncomplete: 'Incomplete keyword rules',\n        keywordRuleRequired: 'At least one keyword is required in keyword mode.',\n        accountStrategyIncomplete: 'Incomplete account strategy',\n        fixedAccountRequired: 'A fixed account must be selected in fixed mode.',\n      },\n    },\n    region: {\n      provincePlaceholder: 'Select province / nationwide',\n      cityPlaceholder: 'Select city / group',\n      districtPlaceholder: 'Select district / county',\n      helper: 'Region options come from a built-in Goofish snapshot and do not rely on live third-party requests.',\n      clear: 'Clear Region',\n      current: 'Current: {path}',\n    },\n    table: {\n      headers: { status: 'Status', details: 'Task Details', crawl: 'Crawl Rules', mode: 'AI / Keyword', schedule: 'Schedule', actions: 'Actions' },\n      syncing: 'Syncing data...',\n      empty: 'No monitoring tasks yet',\n      accountRotate: 'Rotate',\n      accountFixed: 'Fixed',\n      accountAuto: 'Auto',\n      systemSelected: 'System selected',\n      manualTrigger: 'Manual trigger',\n      disabled: 'Disabled',\n      waitingSchedule: 'Waiting for scheduler',\n      personalOnly: 'Personal',\n      freeShipping: 'Free Shipping',\n      keywordStrategies: '{count} strategies',\n      refreshCriteria: 'Refresh',\n      start: 'Start',\n      stop: 'Stop',\n      stopping: 'Stopping...',\n    },\n  },\n  rotation: {\n    title: 'Account and Proxy Rotation',\n    description: 'Configure multi-account rotation explicitly while preserving proxy-pool control.',\n    mode: 'Rotation Mode',\n    modePlaceholder: 'Select a rotation mode',\n    perTask: 'Sticky per Task',\n    onFailure: 'Rotate on Failure',\n    retryLimit: 'Retry Limit',\n    blacklistTtl: 'Blacklist TTL (seconds)',\n    loading: 'Loading rotation settings...',\n    save: 'Save Rotation Settings',\n    account: { title: 'Account Rotation', description: 'Provide automatic account-pool switching for tasks.', stateDir: 'Account State Directory' },\n    proxy: { title: 'Proxy Rotation', description: 'The proxy pool stays independently controlled and can stack with account rotation.', pool: 'Proxy Pool (comma separated)' },\n  },\n  accounts: {\n    title: 'Goofish Accounts',\n    description: 'Use the Chrome extension to extract login-state JSON and add accounts here.',\n    add: '+ Add Account',\n    cookieGuide: {\n      title: 'How to Get Goofish Cookies',\n      step1Prefix: 'Install the',\n      extension: 'Goofish Login State Extractor',\n      step2Prefix: 'Open and sign in to',\n      website: 'Goofish',\n      step3: 'Click the extension icon, choose \"Extract Login State\", then click \"Copy to Clipboard\".',\n      step4: 'Come back here, click \"Add Account\", paste the JSON content, and save it.',\n      step5: 'If you manage multiple accounts, do not log out in the current window; use an incognito window to capture another account cookie.',\n    },\n    list: {\n      title: 'Account List',\n      description: 'Account files are stored under state/ and can be assigned to tasks.',\n      name: 'Account Name',\n      file: 'State File',\n      actions: 'Actions',\n      empty: 'No accounts yet',\n      createTask: 'Create Task',\n      update: 'Update',\n      delete: 'Delete',\n    },\n    createDialog: {\n      title: 'Add Goofish Account',\n      description: 'Paste the JSON extracted by the Chrome extension.',\n      name: 'Account Name',\n      namePlaceholder: 'Example: acc_1',\n      jsonContent: 'JSON Content',\n      jsonPlaceholder: 'Paste login-state JSON...',\n    },\n    editDialog: { title: 'Update Account: {name}', description: 'Replace the account login-state JSON.' },\n    deleteDialog: { title: 'Delete Account', description: 'Delete account {name}? This action cannot be undone.', deleting: 'Deleting...' },\n    toasts: {\n      loadFailed: 'Failed to load accounts.',\n      loadContentFailed: 'Failed to load account content.',\n      incomplete: 'Incomplete information',\n      createDescriptionRequired: 'Please provide an account name and paste the JSON content.',\n      created: 'Account added.',\n      createFailed: 'Failed to add account.',\n      contentRequired: 'Content is required.',\n      updateDescriptionRequired: 'Please paste the JSON content.',\n      updated: 'Account updated.',\n      updateFailed: 'Failed to update account.',\n      deleted: 'Account deleted.',\n      deleteFailed: 'Failed to delete account.',\n    },\n  },\n  notifyPanel: {\n    title: 'Notification Delivery Settings',\n    description: 'Configure, test, and clear each channel independently. Sensitive fields are never echoed back; leaving them blank preserves the current value.',\n    noActiveChannels: 'No notification channel configured yet',\n    enabledChannels: 'Enabled: {channels}',\n    supportedVariables: 'Supported variables: title / content / price / reason / desktop_link / mobile_link',\n    globalBehavior: 'Global Behavior',\n    globalBehaviorDescription: 'Control how item links are rendered across all channels.',\n    preferMobileLink: 'Prefer mobile links',\n    configurationNotes: 'Configuration Notes',\n    configurationNotesDescription: 'Webhook Query and Body support JSON templates. The test button calls the real backend delivery flow so token, URL, and JSON issues show up early.',\n    loading: 'Loading notification settings...',\n    clear: 'Clear',\n    test: 'Test',\n    testThisChannel: 'Test This Channel',\n    testAll: 'Test All Enabled Channels',\n    save: 'Save Notification Settings',\n    footerHint: 'Saving submits only changed fields. Clear actions explicitly remove the related channel configuration.',\n    secretPlaceholder: 'Leave blank to keep the current value, or enter a new value to overwrite it',\n    secretKeepPlaceholder: 'Leave blank to keep the current value',\n    notConfigured: 'Not configured yet.',\n    ntfy: {\n      description: 'Best for lightweight delivery. The topic URL is not sensitive and can be displayed directly.',\n    },\n    bark: {\n      description: 'The URL contains a device key and is intentionally hidden.',\n      configuredHint: 'A sensitive value is configured and hidden on this page.',\n    },\n    gotify: {\n      description: 'URL and token must be configured together.',\n    },\n    wecom: {\n      title: 'WeCom Bot',\n      description: 'The bot URL contains a key, so it is hidden and can only be updated or cleared.',\n      urlLabel: 'WeCom Bot URL',\n      configuredHint: 'Bot URL is already stored.',\n    },\n    telegram: {\n      description: 'The bot token is sensitive, while Chat ID and API base URL can be viewed and updated directly.',\n      chatIdPlaceholder: 'Example: 123456789',\n      apiBaseUrl: 'API / Proxy Base URL',\n    },\n    webhook: {\n      title: 'Generic Webhook',\n      description: 'Supports JSON template variables. URL and headers are treated as sensitive and are not echoed back.',\n      urlLabel: 'Webhook URL',\n      headersLabel: 'Webhook Headers (JSON)',\n      headersPlaceholder: 'Leave blank to keep current value, e.g. {\"Authorization\":\"Bearer token\"}',\n      methodLabel: 'Webhook Method',\n      contentTypeLabel: 'Webhook Content Type',\n      queryLabel: 'Webhook Query Parameters (JSON)',\n      queryPlaceholder: 'Example: {\"task\":\"{{title}}\"}',\n      bodyLabel: 'Webhook Body (JSON Template)',\n      bodyPlaceholder: 'Example: {\"message\":\"{{content}}\",\"price\":\"{{price}}\"}',\n      variablesHelp: 'Template variables: {{title}} is the notification title, {{content}} is the full body, and {{price}}, {{reason}}, {{desktop_link}}, {{mobile_link}} are also available.',\n    },\n  },\n}\n\nexport default enUSExtra\n"
  },
  {
    "path": "web-ui/src/i18n/messages/en-US.ts",
    "content": "import enUSExtra from '@/i18n/messages/en-US-extra'\n\nconst enUS = {\n  app: {\n    name: 'Goofish Monitor',\n  },\n  locale: {\n    switchLabel: 'Switch language',\n    zh: '中文',\n    en: 'English',\n  },\n  routes: {\n    login: 'Login',\n    dashboard: 'Dashboard',\n    tasks: 'Tasks',\n    accounts: 'Accounts',\n    results: 'Results',\n    logs: 'Logs',\n    settings: 'Settings',\n  },\n  common: {\n    cancel: 'Cancel',\n    close: 'Close',\n    save: 'Save',\n    saving: 'Saving...',\n    refresh: 'Refresh',\n    loading: 'Loading...',\n    error: 'Something went wrong!',\n    enabled: 'Enabled',\n    disabled: 'Disabled',\n    running: 'Running',\n    idle: 'Idle',\n    testing: 'Testing...',\n    empty: 'None',\n    unnamed: 'Unnamed',\n    unknown: 'Unknown',\n    yes: 'Yes',\n    no: 'No',\n    all: 'Any',\n    ai: 'AI',\n    keyword: 'Keyword',\n    active: 'Active',\n    inactive: 'Inactive',\n  },\n  time: {\n    justNow: 'Just now',\n    minutesAgo: '{count} minutes ago',\n    hoursAgo: '{count} hours ago',\n    daysAgo: '{count} days ago',\n    scheduled: 'Not scheduled',\n    upcoming: 'Starting soon',\n    countdownDays: '{days}d {hours}h {minutes}m',\n    countdownHours: '{hours}h {minutes}m {seconds}s',\n    countdownMinutes: '{minutes}m {seconds}s',\n    countdownSeconds: '{seconds}s',\n  },\n  header: {\n    searchUnavailable: 'Task search is only available on the dashboard...',\n    accountManagement: 'Account Management',\n  },\n  sidebar: {\n    dashboard: 'Dashboard',\n    tasks: 'Tasks',\n    accounts: 'Accounts',\n    results: 'Results',\n    logs: 'Logs',\n    settings: 'Settings',\n    systemStatus: 'System Status',\n    backendConnected: 'Realtime backend connected',\n    backendConnecting: 'Connecting to backend',\n  },\n  login: {\n    title: 'Sign In',\n    description: 'Enter your admin credentials to continue',\n    username: 'Username',\n    password: 'Password',\n    submit: 'Sign In',\n    submitting: 'Signing in...',\n    errors: {\n      missingCredentials: 'Please enter both username and password.',\n      invalidCredentials: 'Login failed: invalid username or password.',\n      unexpected: 'An unexpected error occurred during login.',\n    },\n  },\n  dashboard: {\n    title: 'Dashboard',\n    description: 'A live summary of tasks, results, and recent activity.',\n    createTask: 'Create Monitoring Task',\n    stats: {\n      activeTasks: 'Active Tasks',\n      scannedItems: 'Scanned Items',\n      recommendedItems: 'Recommendations',\n      monitoredTasks: 'Monitored Tasks',\n      runningCount: '{count} running',\n      resultFiles: '{count} result files',\n      recommendedBreakdown: 'AI {ai} / Keyword {keyword}',\n      showAllTasks: 'Showing all tasks',\n    },\n    focus: {\n      defaultTitle: 'Price Trend Insight',\n      empty: 'No result file is available yet. Create or run a task first.',\n      missingKeyword: 'No keyword set',\n      meta: 'Keyword: {keyword}, with {count} sampled results.',\n      latestUpdate: 'Updated {time}',\n      waiting: 'Waiting for real results',\n      loading: 'Loading dashboard data...',\n      noResults: 'No result files yet. Create a task or run an existing one first.',\n      currentMedian: 'Current median:',\n      historyMin: 'Historical low:',\n      historyMax: 'Historical high:',\n    },\n    activity: {\n      title: 'Live Activity',\n      empty: 'No activity to display right now.',\n      viewAllLogs: 'View All Logs',\n    },\n    suggestion: {\n      sectionTitle: 'AI Strategy',\n      firstTaskTitle: 'Create your first real monitoring task',\n      firstTaskDescription: 'There is nothing to optimize yet. Create a real monitoring task first, then come back for trends and suggestions.',\n      firstTaskAction: 'Go to Tasks',\n      improveValueTitle: 'Squeeze more from a high-value task',\n      improveValueDescription: '\"{task}\" already has real recommendations. Increase pages and focus on newly listed items.',\n      improveHitRateTitle: 'Improve hit rate first',\n      improveHitRateDescription: '\"{task}\" has no recommendations yet. Expand search depth and prioritize fresh listings.',\n      openTaskAction: 'Open Task with Suggestions',\n    },\n  },\n  results: {\n    title: 'Results',\n    filters: {\n      loadingTaskNames: 'Loading task names...',\n      noResults: 'No results yet. Run a task first.',\n      chooseResult: 'Select a task result',\n      taskNameLabel: 'Task: {task}',\n      sortByCrawlTime: 'Crawl Time',\n      sortByPublishTime: 'Publish Time',\n      sortByPrice: 'Price',\n      sortByKeywordHits: 'Keyword Hits',\n      desc: 'Descending',\n      asc: 'Ascending',\n      aiOnly: 'AI recommendations only',\n      keywordOnly: 'Keyword recommendations only',\n      exportCsv: 'Export CSV',\n      deleteResult: 'Delete Result',\n      noResultToDelete: 'No result file to delete.',\n      noResultToExport: 'No result file to export.',\n      resultDeleted: 'Result deleted.',\n      deleteFailed: 'Failed to delete result.',\n      deleteDialogTitle: 'Delete Task Result',\n      deleteDialogWithTask: 'Delete result set \"{task}\"? This action cannot be undone.',\n      deleteDialogFallback: 'Delete this result set? This action cannot be undone.',\n      confirmDelete: 'Delete',\n    },\n    grid: {\n      loading: 'Loading results...',\n      empty: 'No items matched the current filters.',\n    },\n    insights: {\n      defaultTitle: 'Price Trend Insight',\n      subtitle: 'Historical snapshots of the same keyword, including average price, short-term movement, and price band.',\n      currentAvg: 'Current Avg',\n      historyAvg: 'Historical Avg',\n      currentMin: 'Current Lowest',\n      sampleCount: '{count} samples',\n      uniqueItems: '{count} unique items',\n      highestPrice: 'High ¥{price}',\n      noRange: 'No range yet',\n      noSnapshot: 'No market snapshot yet',\n      latestSnapshot: 'Updated at {time}',\n      trendReading: 'Current market samples are already part of the historical analysis layer and can be used directly as AI pricing references.',\n      snapshotCount: '{count} records',\n      currentMedian: 'Current median:',\n      historyMin: 'Historical low:',\n      historyMax: 'Historical high:',\n    },\n    chart: {\n      avgPrice: 'Average',\n      medianPrice: 'Median',\n      noTrend: 'No trend data to render yet',\n    },\n    card: {\n      curated: 'Curated',\n      strongRecommend: 'Strong Buy',\n      notRecommended: 'Skip',\n      pending: 'Watch',\n      analyzing: 'AI is still analyzing the potential value of this item...',\n      collapse: 'Hide details',\n      expand: 'Read full reason',\n      marketAvg: 'Market Avg',\n      historicalLow: 'Historical Low',\n      anonymous: 'Anonymous',\n      detail: 'Open',\n    },\n  },\n  logs: {\n    title: 'Logs',\n    task: 'Task',\n    selectTask: 'Select a task',\n    taskRunningSuffix: ' (running)',\n    autoRefresh: 'Auto Refresh',\n    autoScroll: 'Auto Scroll',\n    clearLogs: 'Clear Logs',\n    logsCleared: 'Logs cleared.',\n    clearFailed: 'Failed to clear logs.',\n    dialogTitle: 'Clear Task Logs',\n    dialogDescription: 'This action cannot be undone. Clear the current task log?',\n    confirmClear: 'Clear',\n    trimmedNotice: '...log output was truncated to keep only the newest content...',\n  },\n  settings: {\n    title: 'Settings',\n    tabs: {\n      ai: 'AI Model',\n      rotation: 'Rotation',\n      notifications: 'Notifications',\n      status: 'System Status',\n      prompts: 'Prompts',\n    },\n    notifications: { saved: 'Notification settings saved.', saveFailed: 'Failed to save notification settings.', testFailed: 'Notification test failed.' },\n    ai: {\n      title: 'AI Model Settings',\n      description: 'Configure the LLM used for item analysis.',\n      keyPlaceholder: 'Leave blank to keep current value',\n      keyConfigured: 'Configured and intentionally not echoed back.',\n      keyMissing: 'Not configured and intentionally not echoed back.',\n      modelName: 'Model Name',\n      proxy: 'Proxy URL (optional)',\n      loading: 'Loading AI settings...',\n      testConnection: 'Test Connection',\n      save: 'Save AI Settings',\n      saved: 'AI settings saved.',\n      saveFailed: 'Failed to save AI settings.',\n      testSuccess: 'AI connection test completed.',\n      testFailed: 'AI connection test failed.',\n    },\n    rotation: { saved: 'Rotation settings saved.', saveFailed: 'Failed to save rotation settings.' },\n    status: {\n      title: 'System Status',\n      refresh: 'Refresh Status',\n      scraper: 'Scraper Process',\n      scraperDescription: 'Whether any task is actively scraping right now',\n      env: 'Environment Configuration',\n      envDescription: 'Check the critical keys in the .env file',\n      loaded: 'Loaded',\n      missing: 'Missing',\n      channels: 'Notification Channels',\n      none: 'None',\n      fetching: 'Loading system status...',\n    },\n    prompts: {\n      title: 'Prompt Management',\n      description: 'Edit prompt files under the prompts directory.',\n      selectFile: 'Select Prompt File',\n      placeholder: 'Select a prompt file...',\n      none: 'No prompt files found.',\n      content: 'Prompt Content',\n      contentPlaceholder: 'Select a prompt file to edit...',\n      save: 'Save Changes',\n      promptListFailed: 'Failed to load prompt list.',\n      selectPromptFile: 'Please select a prompt file.',\n      saveSuccess: 'Prompt saved successfully.',\n      saveFailed: 'Failed to save prompt.',\n      promptContentFailed: 'Failed to load prompt content.',\n    },\n  },\n  ...enUSExtra,\n}\n\nexport default enUS\n"
  },
  {
    "path": "web-ui/src/i18n/messages/zh-CN-extra.ts",
    "content": "const zhCNExtra = {\n  tasks: {\n    title: '任务管理',\n    toasts: {\n      created: '任务创建成功',\n      createFailed: '创建任务失败',\n      deleted: '任务已删除',\n      deleteFailed: '删除任务失败',\n      updateFailed: '更新任务失败',\n      descriptionRequired: '详细需求不能为空',\n      regenerateFailed: '重新生成失败',\n      startFailed: '启动任务失败',\n      stopFailed: '停止任务失败',\n      toggleFailed: '更新状态失败',\n      loadAccountsFailed: '加载账号列表失败',\n      notFound: '未找到要删除的任务',\n      progressFailed: '任务进度获取失败',\n    },\n    createDialog: { trigger: '+ 创建新任务', title: '创建新监控任务（AI 或关键词）', submit: '创建任务', submitting: '提交中...' },\n    editDialog: { title: '编辑任务: {task}', save: '保存更改' },\n    criteria: { title: '重新生成 AI 标准', description: '修改详细需求后将重新生成 AI 分析标准。', descriptionRequired: '请填写新的详细需求。', action: '重新生成', generating: '生成中...' },\n    deleteDialog: { title: '删除任务', descriptionWithTask: '确定删除任务「{task}」吗？此操作不可恢复。', descriptionFallback: '确定删除该任务吗？此操作不可恢复。', confirm: '确认删除' },\n    generation: {\n      title: '任务生成进度',\n      description: 'AI 正在生成分析标准并创建任务。',\n      helperFailed: '生成失败后会保留这个弹窗，方便你查看错误原因。',\n      helperRunning: '任务已转入后台生成，你可以停留在这里查看步骤，也可以先关闭弹窗。',\n      closeWindow: '关闭窗口',\n      status: { completed: '已完成', failed: '失败', running: '生成中', queued: '排队中' },\n    },\n    search: {\n      matchingTasks: '匹配任务',\n      recentTasks: '最近任务',\n      placeholder: '搜索任务名、关键词或地区...',\n      resultCount: '共找到 {count} 条候选任务',\n      enterHint: '直接回车可打开当前高亮任务',\n      loading: '正在加载任务列表...',\n      emptyTitle: '没有找到匹配任务',\n      emptyDescription: '试试任务名、关键词或地区，比如 MacBook、watch、上海。',\n      footerHint: '结果只来自任务管理，不影响 dashboard 展示内容',\n      keyboardUpDown: '↑↓ 切换',\n      keyboardEnter: 'Enter 打开',\n      loadFailed: '任务加载失败',\n      maxPages: '最多 {count} 页',\n    },\n    form: {\n      taskName: '任务名称',\n      taskNamePlaceholder: '例如：索尼 A7M4 相机',\n      keyword: '搜索关键词',\n      keywordPlaceholder: '例如：a7m4',\n      decisionMode: '判断模式',\n      decisionModePlaceholder: '请选择判断模式',\n      aiMode: 'AI判断',\n      keywordMode: '关键词判断',\n      description: '详细需求',\n      descriptionPlaceholder: '请用自然语言详细描述你的购买需求，AI 将根据此描述生成分析标准...',\n      keywordDescriptionHint: '关键词模式下可留空；AI 模式下必填。',\n      analyzeImages: '分析商品图片',\n      analyzeImagesHint: '关闭后只分析商品文字描述和卖家资质，适合纯文本模型或节省 token。',\n      keywordRules: '关键词规则',\n      keywordRulesHint: '单组 OR 逻辑：命中任一关键词即推荐。每行一个关键词，或使用逗号分隔；纯英数字关键词按完整词匹配。',\n      keywordRulesPlaceholder: '示例：a7m4\\n验货宝\\n全画幅',\n      priceRange: '价格范围',\n      minPrice: '最低价',\n      maxPrice: '最高价',\n      maxPages: '搜索页数',\n      schedule: '定时规则',\n      cronPresetTab: '预设',\n      cronCustomTab: '自定义',\n      cronPlaceholder: '选择定时规则',\n      cronCustomPlaceholder: '例如：0 8 * * * / 0 0 8 * * * / @daily',\n      cronCustomHintLine1: '支持 5 段：分 时 日 月 周；6 段：秒 分 时 日 月 周；也支持 @hourly / @daily / @weekly / @monthly / @yearly。服务端时区为 Asia/Shanghai。',\n      cronCustomHintLine2: '示例：每 15 分钟 */15 * * * *；每天 8 点 0 8 * * *；每天 8 点（带秒）0 0 8 * * *。',\n      cron: {\n        manual: '不定时（手动运行）',\n        every5Minutes: '每 5 分钟',\n        every15Minutes: '每 15 分钟',\n        every30Minutes: '每 30 分钟',\n        hourly: '每小时',\n        every2Hours: '每 2 小时',\n        every6Hours: '每 6 小时',\n        daily8: '每天 8:00',\n        daily12: '每天 12:00',\n        daily18: '每天 18:00',\n        daily20: '每天 20:00',\n        daily81218: '每天 8:00 / 12:00 / 18:00',\n        weekday9: '工作日 9:00',\n        weekend10: '周末 10:00',\n      },\n      accountStrategyLabel: '账号策略',\n      fixedAccount: '指定账号',\n      selectAccount: '请选择账号',\n      personalOnly: '仅个人卖家',\n      freeShipping: '是否包邮',\n      newPublish: '新发布范围',\n      region: '区域筛选（默认不填）',\n      regionHint: '区域筛选会导致满足条件的商品数量很少。',\n      accountStrategy: {\n        auto: '自动选择',\n        autoDescription: '优先使用默认登录态；无默认时使用账号池。',\n        fixed: '固定账号',\n        fixedDescription: '当前任务始终绑定一个指定账号。',\n        rotate: '轮换账号',\n        rotateDescription: '当前任务强制使用账号池轮换。',\n      },\n      publishOptions: { none: '不筛选（默认）', latest: '最新', oneDay: '1天内', threeDays: '3天内', sevenDays: '7天内', fourteenDays: '14天内' },\n      validation: {\n        incomplete: '信息不完整',\n        nameAndKeywordRequired: '任务名称和关键词不能为空。',\n        aiDescriptionRequired: 'AI 模式下详细需求不能为空。',\n        keywordRuleIncomplete: '关键词规则不完整',\n        keywordRuleRequired: '关键词模式下至少需要一个关键词。',\n        accountStrategyIncomplete: '账号策略不完整',\n        fixedAccountRequired: '固定账号模式下必须选择一个账号。',\n      },\n    },\n    region: {\n      provincePlaceholder: '选择省份 / 全国',\n      cityPlaceholder: '选择城市 / 区域组',\n      districtPlaceholder: '选择区 / 县',\n      helper: '区域选项基于闲鱼页面抓取快照内置，不再依赖实时外站请求。',\n      clear: '清空区域',\n      current: '当前：{path}',\n    },\n    table: {\n      headers: { status: '状态', details: '任务详情', crawl: '采集规则', mode: 'AI/关键配置', schedule: '触发规则', actions: '操作' },\n      syncing: '数据同步中...',\n      empty: '暂无监测任务',\n      accountRotate: '轮换',\n      accountFixed: '固定',\n      accountAuto: '自动',\n      systemSelected: '系统选择',\n      manualTrigger: '手动触发',\n      disabled: '已禁用',\n      waitingSchedule: '等待调度',\n      personalOnly: '个人',\n      freeShipping: '包邮',\n      keywordStrategies: '{count} 组策略',\n      refreshCriteria: '重刷',\n      start: '启动',\n      stop: '停止',\n      stopping: '停止中',\n    },\n  },\n  rotation: {\n    title: '账号与代理轮换',\n    description: '显式配置多账号切换策略，并保留代理池控制。',\n    mode: '轮换模式',\n    modePlaceholder: '请选择轮换模式',\n    perTask: '按任务固定',\n    onFailure: '失败后轮换',\n    retryLimit: '重试上限',\n    blacklistTtl: '黑名单 TTL（秒）',\n    loading: '正在加载轮换配置...',\n    save: '保存轮换设置',\n    account: { title: '账号轮换', description: '为任务提供自动账号池切换。', stateDir: '账号目录' },\n    proxy: { title: '代理轮换', description: '代理池保持独立控制，和账号轮换可以叠加。', pool: '代理池（逗号分隔）' },\n  },\n  accounts: {\n    title: '闲鱼账号管理',\n    description: '使用 Chrome 扩展提取登录状态 JSON，并在此添加账号。',\n    add: '+ 添加账号',\n    cookieGuide: {\n      title: '获取闲鱼 Cookie',\n      step1Prefix: '安装',\n      extension: '闲鱼登录状态提取扩展',\n      step2Prefix: '打开并登录',\n      website: '闲鱼官网',\n      step3: '点击扩展图标，选择“提取登录状态”，再点击“复制到剪贴板”。',\n      step4: '回到本页，点击“添加账号”，粘贴 JSON 内容并保存。',\n      step5: '如果配置多账号，不要在当前窗口退出闲鱼账号，可以新开无痕窗口提取其他账号 Cookie。',\n    },\n    list: {\n      title: '账号列表',\n      description: '账号文件保存在 state/ 目录下，可绑定到任务。',\n      name: '账号名称',\n      file: '状态文件',\n      actions: '操作',\n      empty: '暂无账号',\n      createTask: '创建任务',\n      update: '更新',\n      delete: '删除',\n    },\n    createDialog: {\n      title: '添加闲鱼账号',\n      description: '粘贴通过 Chrome 插件提取的 JSON 内容。',\n      name: '账号名称',\n      namePlaceholder: '例如：acc_1',\n      jsonContent: 'JSON 内容',\n      jsonPlaceholder: '请粘贴登录状态 JSON...',\n    },\n    editDialog: { title: '更新账号：{name}', description: '替换账号的登录状态 JSON。' },\n    deleteDialog: { title: '删除账号', description: '确认删除账号 {name} 吗？该操作不可恢复。', deleting: '删除中...' },\n    toasts: {\n      loadFailed: '加载账号失败',\n      loadContentFailed: '加载账号内容失败',\n      incomplete: '信息不完整',\n      createDescriptionRequired: '请填写账号名称并粘贴 JSON 内容。',\n      created: '账号已添加',\n      createFailed: '添加账号失败',\n      contentRequired: '内容不能为空',\n      updateDescriptionRequired: '请粘贴 JSON 内容。',\n      updated: '账号已更新',\n      updateFailed: '更新账号失败',\n      deleted: '账号已删除',\n      deleteFailed: '删除账号失败',\n    },\n  },\n  notifyPanel: {\n    title: '通知推送设置',\n    description: '按渠道单独配置、测试和清空。敏感字段不会回显，留空表示保留现有值。',\n    noActiveChannels: '尚未配置可用通知渠道',\n    enabledChannels: '已启用：{channels}',\n    supportedVariables: '支持变量：title / content / price / reason / desktop_link / mobile_link',\n    globalBehavior: '全局行为',\n    globalBehaviorDescription: '统一控制所有渠道中的商品链接展示方式。',\n    preferMobileLink: '优先附带手机端链接',\n    configurationNotes: '配置说明',\n    configurationNotesDescription: 'Webhook 的 Query / Body 支持 JSON 模板，测试按钮会直接调用后端真实发送逻辑，能提前发现 token、URL、JSON 格式问题。',\n    loading: '正在加载通知配置...',\n    clear: '清空',\n    test: '测试',\n    testThisChannel: '测试此渠道',\n    testAll: '测试全部已启用渠道',\n    save: '保存通知设置',\n    footerHint: '保存会只提交改动字段；清空操作会显式删除对应渠道配置。',\n    secretPlaceholder: '已配置则留空保留，输入新值覆盖',\n    secretKeepPlaceholder: '已配置则留空保留',\n    notConfigured: '尚未配置。',\n    ntfy: {\n      description: '适合轻量推送，URL 非敏感，可直接回显和修改。',\n    },\n    bark: {\n      description: 'URL 含设备 key，已改为不回显模式。',\n      configuredHint: '已配置敏感值，当前页面不回显。',\n    },\n    gotify: {\n      description: 'URL 与 Token 必须成对配置。',\n    },\n    wecom: {\n      title: '企业微信机器人',\n      description: 'Bot URL 含 key，不回显，仅支持更新或清空。',\n      urlLabel: '企业微信 Bot URL',\n      configuredHint: '已保存机器人地址。',\n    },\n    telegram: {\n      description: 'Bot Token 属于敏感字段，Chat ID 与反代地址可直接查看和修改。',\n      chatIdPlaceholder: '例如：123456789',\n      apiBaseUrl: 'API / 反代地址',\n    },\n    webhook: {\n      title: '通用 Webhook',\n      description: '支持 JSON 模板变量；URL 和 Headers 作为敏感字段不回显。',\n      urlLabel: 'Webhook URL',\n      headersLabel: 'Webhook Headers (JSON)',\n      headersPlaceholder: '已配置则留空保留，例如：{\"Authorization\":\"Bearer token\"}',\n      methodLabel: 'Webhook 方法',\n      contentTypeLabel: 'Webhook 内容类型',\n      queryLabel: 'Webhook Query 参数 (JSON)',\n      queryPlaceholder: '例如：{\"task\":\"{{title}}\"}',\n      bodyLabel: 'Webhook Body (JSON 模板)',\n      bodyPlaceholder: '例如：{\"message\":\"{{content}}\",\"price\":\"{{price}}\"}',\n      variablesHelp: '变量说明：{{title}} 是通知标题，{{content}} 是完整正文，另外支持 {{price}}、{{reason}}、{{desktop_link}}、{{mobile_link}}。',\n    },\n  },\n}\n\nexport default zhCNExtra\n"
  },
  {
    "path": "web-ui/src/i18n/messages/zh-CN.ts",
    "content": "import zhCNExtra from '@/i18n/messages/zh-CN-extra'\n\nconst zhCN = {\n  app: {\n    name: '闲鱼智能监控',\n  },\n  locale: {\n    switchLabel: '切换语言',\n    zh: '中文',\n    en: 'English',\n  },\n  routes: {\n    login: '登录',\n    dashboard: '监控概览',\n    tasks: '任务管理',\n    accounts: '账号管理',\n    results: '结果查看',\n    logs: '运行日志',\n    settings: '系统设置',\n  },\n  common: {\n    cancel: '取消',\n    close: '关闭',\n    save: '保存',\n    saving: '保存中...',\n    refresh: '刷新',\n    loading: '加载中...',\n    error: '出错了!',\n    enabled: '已启用',\n    disabled: '已停用',\n    running: '运行中',\n    idle: '空闲',\n    testing: '测试中...',\n    empty: '暂无',\n    unnamed: '未命名',\n    unknown: '未知',\n    yes: '是',\n    no: '否',\n    all: '不限',\n    ai: 'AI',\n    keyword: '关键词',\n    active: '已启用',\n    inactive: '未启用',\n  },\n  time: {\n    justNow: '刚刚',\n    minutesAgo: '{count} 分钟前',\n    hoursAgo: '{count} 小时前',\n    daysAgo: '{count} 天前',\n    scheduled: '未计划',\n    upcoming: '即将触发',\n    countdownDays: '{days}天 {hours}时 {minutes}分',\n    countdownHours: '{hours}时 {minutes}分 {seconds}秒',\n    countdownMinutes: '{minutes}分 {seconds}秒',\n    countdownSeconds: '{seconds}秒',\n  },\n  header: {\n    searchUnavailable: '任务搜索仅在监控概览页可用...',\n    accountManagement: '账号管理',\n  },\n  sidebar: {\n    dashboard: '监控概览',\n    tasks: '任务管理',\n    accounts: '账号管理',\n    results: '结果查看',\n    logs: '运行日志',\n    settings: '系统设置',\n    systemStatus: '系统状态',\n    backendConnected: '后端实时已连接',\n    backendConnecting: '后端连接中',\n  },\n  login: {\n    title: '系统登录',\n    description: '请输入您的管理员凭证以继续',\n    username: '用户名',\n    password: '密码',\n    submit: '登录',\n    submitting: '登录中...',\n    errors: {\n      missingCredentials: '请输入用户名和密码',\n      invalidCredentials: '登录失败：用户名或密码错误',\n      unexpected: '登录过程中发生错误',\n    },\n  },\n  dashboard: {\n    title: '监控概览',\n    description: '这里展示任务、结果与最近活动的真实汇总。',\n    createTask: '开始新监测',\n    stats: {\n      activeTasks: '活动任务',\n      scannedItems: '已扫描商品',\n      recommendedItems: '已发现推荐',\n      monitoredTasks: '监测任务',\n      runningCount: '运行中 {count} 个',\n      resultFiles: '结果文件 {count} 个',\n      recommendedBreakdown: 'AI {ai} / 关键词 {keyword}',\n      showAllTasks: '当前展示全部任务',\n    },\n    focus: {\n      defaultTitle: '价格走势洞察',\n      empty: '当前没有可展示的结果文件，先创建或运行任务。',\n      missingKeyword: '未配置关键词',\n      meta: '关键词：{keyword}，当前累计 {count} 条结果样本。',\n      latestUpdate: '最近更新 {time}',\n      waiting: '等待真实结果',\n      loading: 'Dashboard 数据加载中...',\n      noResults: '当前还没有结果文件，先去创建任务或运行已有任务。',\n      currentMedian: '当前中位数：',\n      historyMin: '历史最低价：',\n      historyMax: '历史最高价：',\n    },\n    activity: {\n      title: '实时动态',\n      empty: '当前没有可展示的动态。',\n      viewAllLogs: '查看全部日志',\n    },\n    suggestion: {\n      sectionTitle: 'AI 智能策略',\n      firstTaskTitle: '开始首个真实监测任务',\n      firstTaskDescription: '当前没有可优化的任务，先创建一个真实监测任务，再回来查看趋势和推荐。',\n      firstTaskAction: '去任务页创建',\n      improveValueTitle: '把高价值任务再压榨一点',\n      improveValueDescription: '“{task}”已经出现真实推荐，建议提高页数并聚焦新发布商品。',\n      improveHitRateTitle: '先提高监测命中率',\n      improveHitRateDescription: '“{task}”还没看到推荐结果，建议扩大搜索页数并优先抓最新发布。',\n      openTaskAction: '带推荐参数去任务页',\n    },\n  },\n  results: {\n    title: '结果查看',\n    filters: {\n      loadingTaskNames: '加载任务名称...',\n      noResults: '暂无结果，请先运行任务',\n      chooseResult: '请选择任务结果',\n      taskNameLabel: '任务名称：{task}',\n      sortByCrawlTime: '按爬取时间',\n      sortByPublishTime: '按发布时间',\n      sortByPrice: '按价格',\n      sortByKeywordHits: '按命中数',\n      desc: '降序',\n      asc: '升序',\n      aiOnly: '仅看AI推荐',\n      keywordOnly: '仅看关键词推荐',\n      exportCsv: '导出 CSV',\n      deleteResult: '删除结果',\n      noResultToDelete: '暂无可删除的结果',\n      noResultToExport: '暂无可导出的结果',\n      resultDeleted: '结果已删除',\n      deleteFailed: '删除结果失败',\n      deleteDialogTitle: '删除任务结果',\n      deleteDialogWithTask: '确定删除任务结果「{task}」吗？此操作不可恢复。',\n      deleteDialogFallback: '确定删除该任务结果吗？此操作不可恢复。',\n      confirmDelete: '确认删除',\n    },\n    grid: {\n      loading: '正在加载结果...',\n      empty: '没有找到符合条件的商品记录。',\n    },\n    insights: {\n      defaultTitle: '价格走势洞察',\n      subtitle: '用历史快照回看同关键词市场，当前均价、近期波动和价格带都集中在这里。',\n      currentAvg: '当前样本均价',\n      historyAvg: '历史均价',\n      currentMin: '当前最低价',\n      sampleCount: '样本 {count} 条',\n      uniqueItems: '唯一商品 {count} 个',\n      highestPrice: '最高 ¥{price}',\n      noRange: '暂无范围',\n      noSnapshot: '尚未形成市场快照',\n      latestSnapshot: '最近更新于 {time}',\n      trendReading: '当前市场样本已进入历史分析层，可以直接给 AI 价格参照，不再只是新商品列表。',\n      snapshotCount: '{count} 条',\n      currentMedian: '当前中位数：',\n      historyMin: '历史最低价：',\n      historyMax: '历史最高价：',\n    },\n    chart: {\n      avgPrice: '均价',\n      medianPrice: '中位数',\n      noTrend: '暂无可绘制的趋势数据',\n    },\n    card: {\n      curated: '精选',\n      strongRecommend: '强烈推荐',\n      notRecommended: '不建议购买',\n      pending: '待观察',\n      analyzing: 'AI 正在分析该商品的潜在价值...',\n      collapse: '收起详情',\n      expand: '阅读完整理由',\n      marketAvg: '市场均价',\n      historicalLow: '历史低位',\n      anonymous: '匿名',\n      detail: '详情',\n    },\n  },\n  logs: {\n    title: '运行日志',\n    task: '任务',\n    selectTask: '请选择任务',\n    taskRunningSuffix: '（运行中）',\n    autoRefresh: '自动刷新',\n    autoScroll: '自动滚动',\n    clearLogs: '清空日志',\n    logsCleared: '日志已清空',\n    clearFailed: '清空日志失败',\n    dialogTitle: '清空任务日志',\n    dialogDescription: '此操作不可恢复，确定要清空当前任务日志吗？',\n    confirmClear: '确认清空',\n    trimmedNotice: '...日志过长已截断，仅保留最新内容...',\n  },\n  settings: {\n    title: '系统设置',\n    tabs: {\n      ai: 'AI 模型',\n      rotation: 'IP 轮换',\n      notifications: '通知推送',\n      status: '系统状态',\n      prompts: 'Prompt 管理',\n    },\n    notifications: { saved: '通知设置已保存', saveFailed: '通知设置保存失败', testFailed: '通知测试失败' },\n    ai: {\n      title: 'AI 模型设置',\n      description: '配置用于商品分析的大语言模型。',\n      keyPlaceholder: '留空表示不修改',\n      keyConfigured: '已配置，为安全起见不回显。',\n      keyMissing: '未配置，为安全起见不回显。',\n      modelName: '模型名称',\n      proxy: '代理地址 (可选)',\n      loading: '正在加载 AI 配置...',\n      testConnection: '测试连接',\n      save: '保存 AI 设置',\n      saved: 'AI 设置已保存',\n      saveFailed: 'AI 设置保存失败',\n      testSuccess: 'AI 连接测试完成',\n      testFailed: 'AI 连接测试失败',\n    },\n    rotation: { saved: '轮换设置已保存', saveFailed: '轮换设置保存失败' },\n    status: {\n      title: '系统运行状态',\n      refresh: '刷新状态',\n      scraper: '爬虫进程',\n      scraperDescription: '当前是否有任务正在执行抓取',\n      env: '环境变量配置',\n      envDescription: '检查 .env 配置文件中的关键项',\n      loaded: '已加载',\n      missing: '缺失',\n      channels: '通知渠道',\n      none: '无',\n      fetching: '正在获取系统状态...',\n    },\n    prompts: {\n      title: 'Prompt 管理',\n      description: '在线编辑 prompts 目录下的 Prompt 文件。',\n      selectFile: '选择 Prompt 文件',\n      placeholder: '请选择一个 Prompt 文件...',\n      none: '没有找到 Prompt 文件。',\n      content: 'Prompt 内容',\n      contentPlaceholder: '请选择一个 Prompt 文件进行编辑...',\n      save: '保存更改',\n      promptListFailed: '加载 Prompt 列表失败',\n      selectPromptFile: '请选择 Prompt 文件',\n      saveSuccess: 'Prompt 保存成功',\n      saveFailed: 'Prompt 保存失败',\n      promptContentFailed: '加载 Prompt 内容失败',\n    },\n  },\n  ...zhCNExtra,\n}\n\nexport default zhCN\n"
  },
  {
    "path": "web-ui/src/layouts/MainLayout.vue",
    "content": "<script setup lang=\"ts\">\nimport { useI18n } from 'vue-i18n'\nimport TheHeader from '@/components/layout/TheHeader.vue'\nimport TheSidebar from '@/components/layout/TheSidebar.vue'\nimport Toaster from '@/components/ui/toast/Toaster.vue'\nimport { useMobileNav } from '@/composables/useMobileNav'\n\nconst { isMobileNavOpen, closeMobileNav } = useMobileNav()\nconst { t } = useI18n()\n</script>\n\n<template>\n  <div class=\"relative min-h-screen w-full flex flex-col bg-slate-50 selection:bg-primary/20\">\n    <!-- 背景装饰渐变 -->\n    <div class=\"fixed inset-0 pointer-events-none overflow-hidden\">\n      <div class=\"absolute -top-[10%] -left-[10%] w-[40%] h-[40%] rounded-full bg-primary/5 blur-[120px] animate-pulse\"></div>\n      <div class=\"absolute top-[20%] -right-[5%] w-[30%] h-[35%] rounded-full bg-blue-400/5 blur-[100px]\"></div>\n      <div class=\"absolute -bottom-[10%] left-[20%] w-[35%] h-[35%] rounded-full bg-emerald-400/5 blur-[100px]\"></div>\n    </div>\n\n    <!-- Header -->\n    <TheHeader class=\"sticky top-0 z-50 glass\" />\n\n    <transition name=\"mobile-nav\">\n      <div v-if=\"isMobileNavOpen\" class=\"fixed inset-0 z-[90] md:hidden\">\n        <button\n          class=\"absolute inset-0 bg-slate-950/25 backdrop-blur-[2px]\"\n          :aria-label=\"t('common.close')\"\n          @click=\"closeMobileNav\"\n        />\n        <aside class=\"relative h-full w-72 border-r border-slate-200/60 bg-white/90 p-4 shadow-2xl backdrop-blur-xl\">\n          <TheSidebar class=\"pt-16\" @navigate=\"closeMobileNav\" />\n        </aside>\n      </div>\n    </transition>\n\n    <div class=\"flex flex-grow relative z-10\">\n      <!-- Sidebar -->\n      <aside class=\"hidden md:block w-64 flex-shrink-0 border-r border-slate-200/60 bg-white/40 backdrop-blur-sm\">\n        <TheSidebar class=\"sticky top-16 h-[calc(100vh-4rem)] p-4\" />\n      </aside>\n\n      <!-- Main Content Area -->\n      <main class=\"flex-grow p-4 md:p-8 overflow-x-hidden\">\n        <div class=\"max-w-7xl mx-auto animate-fade-in\">\n          <RouterView v-slot=\"{ Component }\">\n            <transition name=\"page\" mode=\"out-in\">\n              <component :is=\"Component\" />\n            </transition>\n          </RouterView>\n        </div>\n      </main>\n    </div>\n\n    <Toaster />\n  </div>\n</template>\n\n<style scoped>\n.page-enter-active,\n.page-leave-active {\n  transition: opacity 0.2s ease, transform 0.2s ease;\n}\n\n.page-enter-from {\n  opacity: 0;\n  transform: translateY(10px);\n}\n\n.page-leave-to {\n  opacity: 0;\n  transform: translateY(-10px);\n}\n\n.mobile-nav-enter-active,\n.mobile-nav-leave-active {\n  transition: opacity 0.2s ease, transform 0.2s ease;\n}\n\n.mobile-nav-enter-from,\n.mobile-nav-leave-to {\n  opacity: 0;\n  transform: translateX(-12px);\n}\n</style>\n"
  },
  {
    "path": "web-ui/src/lib/http.ts",
    "content": "import { useAuth } from '@/composables/useAuth'\n\ninterface FetchOptions extends RequestInit {\n  params?: Record<string, string | number | boolean | undefined>;\n}\n\nexport async function http(url: string, options: FetchOptions = {}) {\n  const { logout } = useAuth()\n  \n  const headers = new Headers(options.headers)\n\n  // Handle Query Params\n  let fullUrl = url\n  if (options.params) {\n    const searchParams = new URLSearchParams()\n    Object.entries(options.params).forEach(([key, value]) => {\n      if (value !== undefined && value !== null) {\n        searchParams.append(key, String(value))\n      }\n    })\n    const queryString = searchParams.toString()\n    if (queryString) {\n      fullUrl += (url.includes('?') ? '&' : '?') + queryString\n    }\n  }\n\n  const config: RequestInit = {\n    ...options,\n    headers,\n  }\n\n  const response = await fetch(fullUrl, config)\n\n  if (response.status === 401) {\n    // Basic Auth failed or session expired\n    logout()\n    // Optional: Redirect to login handled by router or state change\n    throw new Error('Unauthorized')\n  }\n\n  if (!response.ok) {\n    const errorData = await response.json().catch(() => ({}))\n    throw new Error(errorData.detail || `HTTP error! status: ${response.status}`)\n  }\n\n  // Handle 204 No Content\n  if (response.status === 204) {\n    return null\n  }\n\n  return response.json()\n}\n"
  },
  {
    "path": "web-ui/src/lib/taskFormQuery.ts",
    "content": "import type { LocationQuery } from 'vue-router'\nimport type { TaskGenerateRequest, TaskUpdate } from '@/types/task.d.ts'\n\nexport type TaskFormDefaults = Partial<TaskGenerateRequest & TaskUpdate>\ntype QueryValue = LocationQuery[string] | undefined\n\nconst TRUE_VALUES = new Set(['1', 'true', 'yes', 'on'])\nconst FALSE_VALUES = new Set(['0', 'false', 'no', 'off'])\n\nfunction readString(value: QueryValue): string | undefined {\n  if (typeof value !== 'string') return undefined\n  const trimmed = value.trim()\n  return trimmed.length > 0 ? trimmed : undefined\n}\n\nfunction readBoolean(value: QueryValue): boolean | undefined {\n  const raw = readString(value)?.toLowerCase()\n  if (!raw) return undefined\n  if (TRUE_VALUES.has(raw)) return true\n  if (FALSE_VALUES.has(raw)) return false\n  return undefined\n}\n\nfunction readNumber(value: QueryValue): number | undefined {\n  const raw = readString(value)\n  if (!raw) return undefined\n  const parsed = Number(raw)\n  return Number.isFinite(parsed) ? parsed : undefined\n}\n\nfunction readKeywordRules(value: QueryValue): string[] | undefined {\n  const raw = readString(value)\n  if (!raw) return undefined\n  const rules = raw\n    .split(/[,\\n]+/)\n    .map((item) => item.trim())\n    .filter((item) => item.length > 0)\n  return rules.length > 0 ? rules : undefined\n}\n\nexport function parseTaskFormDefaults(query: LocationQuery): TaskFormDefaults {\n  const defaults: TaskFormDefaults = {}\n  const taskName = readString(query.taskName)\n  const keyword = readString(query.keyword)\n  const description = readString(query.description)\n  const cron = readString(query.cron)\n  const accountStateFile = readString(query.account)\n  const accountStrategy = readString(query.accountStrategy)\n  const region = readString(query.region)\n  const newPublishOption = readString(query.newPublishOption)\n  const minPrice = readString(query.minPrice)\n  const maxPrice = readString(query.maxPrice)\n  const decisionMode = readString(query.decisionMode)\n  const keywordRules = readKeywordRules(query.keywordRules)\n  const maxPages = readNumber(query.maxPages)\n  const freeShipping = readBoolean(query.freeShipping)\n  const personalOnly = readBoolean(query.personalOnly)\n  const analyzeImages = readBoolean(query.analyzeImages)\n\n  if (taskName) defaults.task_name = taskName\n  if (keyword) defaults.keyword = keyword\n  if (description !== undefined) defaults.description = description\n  if (cron !== undefined) defaults.cron = cron\n  if (accountStateFile) defaults.account_state_file = accountStateFile\n  if (accountStrategy === 'auto' || accountStrategy === 'fixed' || accountStrategy === 'rotate') {\n    defaults.account_strategy = accountStrategy\n  }\n  if (region !== undefined) defaults.region = region\n  if (newPublishOption !== undefined) defaults.new_publish_option = newPublishOption\n  if (minPrice !== undefined) defaults.min_price = minPrice\n  if (maxPrice !== undefined) defaults.max_price = maxPrice\n  if (decisionMode === 'ai' || decisionMode === 'keyword') defaults.decision_mode = decisionMode\n  if (keywordRules) defaults.keyword_rules = keywordRules\n  if (maxPages !== undefined) defaults.max_pages = maxPages\n  if (freeShipping !== undefined) defaults.free_shipping = freeShipping\n  if (personalOnly !== undefined) defaults.personal_only = personalOnly\n  if (analyzeImages !== undefined) defaults.analyze_images = analyzeImages\n\n  return defaults\n}\n"
  },
  {
    "path": "web-ui/src/lib/taskSchedule.ts",
    "content": "import { t } from '@/i18n'\n\nconst SECOND_MS = 1000\nconst MINUTE_MS = 60 * SECOND_MS\nconst HOUR_MS = 60 * MINUTE_MS\nconst DAY_MS = 24 * HOUR_MS\n\nfunction parseDate(value: string | null | undefined): Date | null {\n  if (!value) return null\n  const date = new Date(value)\n  return Number.isNaN(date.getTime()) ? null : date\n}\n\nfunction padNumber(value: number): string {\n  return String(value).padStart(2, '0')\n}\n\nexport function formatNextRunAbsolute(value: string | null | undefined): string {\n  const date = parseDate(value)\n  if (!date) return t('time.scheduled')\n  return `${padNumber(date.getMonth() + 1)}-${padNumber(date.getDate())} ${padNumber(date.getHours())}:${padNumber(date.getMinutes())}:${padNumber(date.getSeconds())}`\n}\n\nexport function formatCountdown(\n  value: string | null | undefined,\n  nowMs: number,\n): string | null {\n  const date = parseDate(value)\n  if (!date) return null\n\n  const diff = date.getTime() - nowMs\n  if (diff <= 0) return t('time.upcoming')\n\n  const days = Math.floor(diff / DAY_MS)\n  const hours = Math.floor((diff % DAY_MS) / HOUR_MS)\n  const minutes = Math.floor((diff % HOUR_MS) / MINUTE_MS)\n  const seconds = Math.floor((diff % MINUTE_MS) / SECOND_MS)\n\n  if (days > 0) return t('time.countdownDays', { days, hours, minutes })\n  if (hours > 0) return t('time.countdownHours', { hours, minutes, seconds })\n  if (minutes > 0) return t('time.countdownMinutes', { minutes, seconds })\n  return t('time.countdownSeconds', { seconds })\n}\n"
  },
  {
    "path": "web-ui/src/lib/utils.ts",
    "content": "import { type ClassValue, clsx } from 'clsx'\nimport { twMerge } from 'tailwind-merge'\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"
  },
  {
    "path": "web-ui/src/main.ts",
    "content": "import { createApp } from 'vue'\nimport App from './App.vue'\nimport router from './router'\nimport { i18n } from './i18n'\nimport './assets/main.css'\n\nconst app = createApp(App)\n\napp.use(router)\napp.use(i18n)\n\napp.mount('#app')\n"
  },
  {
    "path": "web-ui/src/router/index.ts",
    "content": "import { watch } from 'vue'\nimport { createRouter, createWebHistory } from 'vue-router'\nimport MainLayout from '@/layouts/MainLayout.vue'\nimport { useAuth } from '@/composables/useAuth'\nimport { i18n, t } from '@/i18n'\n\nconst routes = [\n  {\n    path: '/login',\n    name: 'Login',\n    component: () => import('@/views/LoginView.vue'),\n    meta: { titleKey: 'routes.login' },\n  },\n  {\n    path: '/',\n    component: MainLayout,\n    redirect: '/dashboard',\n    children: [\n      {\n        path: 'dashboard',\n        name: 'Dashboard',\n        component: () => import('@/views/DashboardView.vue'),\n        meta: { titleKey: 'routes.dashboard', requiresAuth: true },\n      },\n      {\n        path: 'tasks',\n        name: 'Tasks',\n        component: () => import('@/views/TasksView.vue'),\n        meta: { titleKey: 'routes.tasks', requiresAuth: true },\n      },\n      {\n        path: 'accounts',\n        name: 'Accounts',\n        component: () => import('@/views/AccountsView.vue'),\n        meta: { titleKey: 'routes.accounts', requiresAuth: true },\n      },\n      {\n        path: 'results',\n        name: 'Results',\n        component: () => import('@/views/ResultsView.vue'),\n        meta: { titleKey: 'routes.results', requiresAuth: true },\n      },\n      {\n        path: 'logs',\n        name: 'Logs',\n        component: () => import('@/views/LogsView.vue'),\n        meta: { titleKey: 'routes.logs', requiresAuth: true },\n      },\n      {\n        path: 'settings',\n        name: 'Settings',\n        component: () => import('@/views/SettingsView.vue'),\n        meta: { titleKey: 'routes.settings', requiresAuth: true },\n      },\n    ],\n  },\n  {\n    path: '/:pathMatch(.*)*',\n    name: 'NotFound',\n    redirect: '/',\n  },\n]\n\nconst router = createRouter({\n  history: createWebHistory(),\n  routes,\n})\n\nfunction updateDocumentTitle() {\n  const currentRoute = router.currentRoute.value\n  const titleKey = typeof currentRoute.meta.titleKey === 'string'\n    ? currentRoute.meta.titleKey\n    : null\n  const appName = t('app.name')\n  document.title = titleKey ? `${t(titleKey)} - ${appName}` : appName\n}\n\nrouter.beforeEach((to, _from, next) => {\n  const { isAuthenticated } = useAuth()\n\n  if (to.meta.requiresAuth && !isAuthenticated.value) {\n    next({ name: 'Login', query: { redirect: to.fullPath } })\n  } else if (to.name === 'Login' && isAuthenticated.value) {\n    next({ name: 'Dashboard' })\n  } else {\n    next()\n  }\n})\n\nrouter.afterEach(() => {\n  updateDocumentTitle()\n})\n\nwatch(\n  () => i18n.global.locale.value,\n  () => {\n    updateDocumentTitle()\n  },\n)\n\nexport default router\n"
  },
  {
    "path": "web-ui/src/services/websocket.ts",
    "content": "type WebSocketEventHandler = (data: any) => void;\n\nclass WebSocketService {\n  private ws: WebSocket | null = null;\n  private reconnectInterval = 3000;\n  private listeners: Map<string, WebSocketEventHandler[]> = new Map();\n  public isConnected = false;\n  private shouldConnect = false;\n\n  constructor() {\n    // 延迟连接，等待认证完成\n    // 只有在已登录时才尝试连接\n    if (localStorage.getItem('auth_logged_in') === 'true') {\n      this.connect();\n    }\n  }\n\n  public start() {\n    // 手动启动 WebSocket 连接\n    this.shouldConnect = true;\n    if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {\n      this.connect();\n    }\n  }\n\n  public stop() {\n    // 停止 WebSocket 连接\n    this.shouldConnect = false;\n    if (this.ws) {\n      this.ws.close();\n      this.ws = null;\n    }\n  }\n\n  private connect() {\n    // Determine the protocol (ws or wss) based on the current page protocol\n    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n    const host = window.location.host; // This includes port if present\n\n    const url = `${protocol}//${host}/ws`;\n\n    console.log(`Connecting to WebSocket at ${url}`);\n    this.ws = new WebSocket(url);\n\n    this.ws.onopen = () => {\n      console.log('WebSocket connected');\n      this.isConnected = true;\n      this.emit('connected', { isConnected: true });\n    };\n\n    this.ws.onmessage = (event) => {\n      try {\n        const message = JSON.parse(event.data);\n        // Expecting message format: { type: 'event_type', data: ... }\n        if (message.type) {\n          this.emit(message.type, message.data);\n        }\n      } catch (e) {\n        console.error('Failed to parse WebSocket message', e);\n      }\n    };\n\n    this.ws.onclose = () => {\n      if (this.isConnected) {\n        console.log('WebSocket disconnected');\n        this.isConnected = false;\n        this.emit('disconnected', { isConnected: false });\n      }\n      // 只有在 shouldConnect 为 true 或已登录时才重连\n      if (this.shouldConnect || localStorage.getItem('auth_logged_in') === 'true') {\n        setTimeout(() => this.connect(), this.reconnectInterval);\n      }\n    };\n\n    this.ws.onerror = (error) => {\n      console.error('WebSocket error:', error);\n      // Close will trigger onclose which handles reconnect\n      this.ws?.close();\n    };\n  }\n\n  public on(event: string, handler: WebSocketEventHandler) {\n    if (!this.listeners.has(event)) {\n      this.listeners.set(event, []);\n    }\n    this.listeners.get(event)?.push(handler);\n  }\n\n  public off(event: string, handler: WebSocketEventHandler) {\n    const handlers = this.listeners.get(event);\n    if (handlers) {\n      const index = handlers.indexOf(handler);\n      if (index !== -1) {\n        handlers.splice(index, 1);\n      }\n    }\n  }\n\n  private emit(event: string, data: any) {\n    const handlers = this.listeners.get(event);\n    if (handlers) {\n      handlers.forEach((handler) => handler(data));\n    }\n  }\n}\n\n// Export a singleton instance\nexport const wsService = new WebSocketService();\n"
  },
  {
    "path": "web-ui/src/style.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@layer base {\n  :root {\n    --background: 210 40% 98%;\n    --foreground: 222.2 84% 4.9%;\n\n    --card: 0 0% 100%;\n    --card-foreground: 222.2 84% 4.9%;\n\n    --popover: 0 0% 100%;\n    --popover-foreground: 222.2 84% 4.9%;\n\n    --primary: 221.2 83.2% 53.3%;\n    --primary-foreground: 210 40% 98%;\n\n    --secondary: 210 40% 96.1%;\n    --secondary-foreground: 222.2 47.4% 11.2%;\n\n    --muted: 210 40% 96.1%;\n    --muted-foreground: 215.4 16.3% 46.9%;\n\n    --accent: 210 40% 96.1%;\n    --accent-foreground: 222.2 47.4% 11.2%;\n\n    --destructive: 0 84.2% 60.2%;\n    --destructive-foreground: 210 40% 98%;\n\n    --border: 214.3 31.8% 91.4%;\n    --input: 214.3 31.8% 91.4%;\n    --ring: 221.2 83.2% 53.3%;\n\n    --radius: 0.75rem;\n  }\n\n  .dark {\n    --background: 222.2 84% 4.9%;\n    --foreground: 210 40% 98%;\n\n    --card: 222.2 84% 4.9%;\n    --card-foreground: 210 40% 98%;\n\n    --popover: 222.2 84% 4.9%;\n    --popover-foreground: 210 40% 98%;\n\n    --primary: 217.2 91.2% 59.8%;\n    --primary-foreground: 222.2 47.4% 11.2%;\n\n    --secondary: 217.2 32.6% 17.5%;\n    --secondary-foreground: 210 40% 98%;\n\n    --muted: 217.2 32.6% 17.5%;\n    --muted-foreground: 215 20.2% 65.1%;\n\n    --accent: 217.2 32.6% 17.5%;\n    --accent-foreground: 210 40% 98%;\n\n    --destructive: 0 62.8% 30.6%;\n    --destructive-foreground: 210 40% 98%;\n\n    --border: 217.2 32.6% 17.5%;\n    --input: 217.2 32.6% 17.5%;\n    --ring: 224.3 76.3% 48%;\n  }\n}\n\n@layer base {\n  * {\n    @apply border-border;\n  }\n  body {\n    @apply bg-background text-foreground;\n    font-feature-settings: \"rlig\" 1, \"calt\" 1;\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n    text-rendering: optimizeLegibility;\n  }\n}\n\n/* 玻璃拟态效果 */\n.glass {\n  @apply bg-white/70 backdrop-blur-md border border-white/20;\n}\n\n.dark .glass {\n  @apply bg-black/40 backdrop-blur-md border border-white/10;\n}\n\n/* 平滑滚动 */\nhtml {\n  scroll-behavior: smooth;\n}\n\n/* 自定义滚动条 */\n::-webkit-scrollbar {\n  width: 6px;\n  height: 6px;\n}\n\n::-webkit-scrollbar-track {\n  @apply bg-transparent;\n}\n\n::-webkit-scrollbar-thumb {\n  @apply bg-muted-foreground/20 rounded-full hover:bg-muted-foreground/40 transition-colors;\n}"
  },
  {
    "path": "web-ui/src/types/dashboard.d.ts",
    "content": "import type { ResultInsights } from '@/types/result.d.ts'\n\nexport interface DashboardSummary {\n  enabled_tasks: number\n  running_tasks: number\n  result_files: number\n  scanned_items: number\n  recommended_items: number\n  ai_recommended_items: number\n  keyword_recommended_items: number\n  last_updated_at: string | null\n}\n\nexport interface DashboardTaskSummary {\n  task_id: number | null\n  task_name: string\n  keyword: string\n  filename: string | null\n  enabled: boolean\n  is_running: boolean\n  account_strategy: 'auto' | 'fixed' | 'rotate'\n  cron: string | null\n  region: string | null\n  total_items: number\n  recommended_items: number\n  ai_recommended_items: number\n  keyword_recommended_items: number\n  latest_crawl_time: string | null\n  latest_recommended_title: string | null\n  latest_recommended_price: number | null\n}\n\nexport interface DashboardActivity {\n  id: string\n  type: 'recommendation' | 'scan' | 'task'\n  task_name: string\n  keyword: string\n  title: string\n  status: string\n  detail: string | null\n  filename: string | null\n  timestamp: string | null\n}\n\nexport interface DashboardSnapshot {\n  summary: DashboardSummary\n  task_summaries: DashboardTaskSummary[]\n  recent_activities: DashboardActivity[]\n  focus_file: string | null\n}\n\nexport interface DashboardSuggestion {\n  title: string\n  description: string\n  actionLabel: string\n  routeName: 'Tasks' | 'Settings'\n  query: Record<string, string>\n}\n\nexport interface DashboardState {\n  snapshot: DashboardSnapshot | null\n  focusInsights: ResultInsights | null\n}\n"
  },
  {
    "path": "web-ui/src/types/result.d.ts",
    "content": "// Based on the data structure from web_server.py and scraper.py\n\nexport interface ProductInfo {\n  \"商品标题\": string;\n  \"当前售价\": string;\n  \"商品原价\"?: string;\n  \"“想要”人数\"?: string | number;\n  \"商品标签\"?: string[];\n  \"发货地区\"?: string;\n  \"卖家昵称\"?: string;\n  \"商品链接\": string;\n  \"发布时间\"?: string;\n  \"商品ID\": string;\n  \"商品图片列表\"?: string[];\n  \"商品主图链接\"?: string;\n  \"浏览量\"?: string | number;\n}\n\nexport interface SellerInfo {\n  \"卖家昵称\"?: string;\n  \"卖家头像链接\"?: string;\n  \"卖家个性签名\"?: string;\n  \"卖家在售/已售商品数\"?: string;\n  \"卖家收到的评价总数\"?: string;\n  \"卖家信用等级\"?: string;\n  \"买家信用等级\"?: string;\n  \"卖家芝麻信用\"?: string;\n  \"卖家注册时长\"?: string;\n  \"作为卖家的好评数\"?: string;\n  \"作为卖家的好评率\"?: string;\n  \"作为买家的好评数\"?: string;\n  \"作为买家的好评率\"?: string;\n  \"卖家发布的商品列表\"?: any[]; // Define more strictly if needed\n  \"卖家收到的评价列表\"?: any[]; // Define more strictly if needed\n}\n\nexport interface AiAnalysis {\n  is_recommended: boolean;\n  reason: string;\n  analysis_source?: 'ai' | 'keyword';\n  keyword_hit_count?: number;\n  value_score?: number;\n  value_summary?: string;\n  prompt_version?: string;\n  risk_tags?: string[];\n  criteria_analysis?: Record<string, any>;\n  matched_keywords?: string[];\n  error?: string;\n}\n\nexport interface PriceInsight {\n  observation_count: number;\n  current_price?: number | null;\n  avg_price?: number | null;\n  median_price?: number | null;\n  min_price?: number | null;\n  max_price?: number | null;\n  market_avg_price?: number | null;\n  market_median_price?: number | null;\n  price_change_amount?: number | null;\n  price_change_percent?: number | null;\n  deal_score?: number | null;\n  deal_label?: string;\n  first_seen_at?: string | null;\n  last_seen_at?: string | null;\n}\n\nexport interface ResultInsights {\n  market_summary: {\n    sample_count: number;\n    avg_price: number | null;\n    median_price: number | null;\n    min_price: number | null;\n    max_price: number | null;\n    snapshot_time?: string | null;\n  };\n  history_summary: {\n    unique_items: number;\n    sample_count: number;\n    avg_price: number | null;\n    median_price: number | null;\n    min_price: number | null;\n    max_price: number | null;\n  };\n  daily_trend: Array<{\n    day: string;\n    sample_count: number;\n    avg_price: number | null;\n    median_price: number | null;\n    min_price: number | null;\n    max_price: number | null;\n  }>;\n  latest_snapshot_at?: string | null;\n}\n\nexport interface ResultItem {\n  \"爬取时间\": string;\n  \"搜索关键字\": string;\n  \"任务名称\": string;\n  \"商品信息\": ProductInfo;\n  \"卖家信息\": SellerInfo;\n  ai_analysis: AiAnalysis;\n  price_insight?: PriceInsight;\n}\n"
  },
  {
    "path": "web-ui/src/types/task.d.ts",
    "content": "// Based on the Pydantic model in the backend\n\nexport interface Task {\n  id: number;\n  task_name: string;\n  enabled: boolean;\n  keyword: string;\n  description: string;\n  analyze_images: boolean;\n  max_pages: number;\n  personal_only: boolean;\n  min_price: string | null;\n  max_price: string | null;\n  cron: string | null;\n  next_run_at?: string | null;\n  ai_prompt_base_file: string;\n  ai_prompt_criteria_file: string;\n  account_state_file?: string | null;\n  account_strategy: 'auto' | 'fixed' | 'rotate';\n  free_shipping?: boolean;\n  new_publish_option?: string | null;\n  region?: string | null;\n  decision_mode: 'ai' | 'keyword';\n  keyword_rules: string[];\n  is_running: boolean;\n}\n\nexport type TaskGenerationStatus = 'queued' | 'running' | 'completed' | 'failed';\nexport type TaskGenerationStepStatus = 'pending' | 'running' | 'completed' | 'failed';\n\nexport interface TaskGenerationStep {\n  key: string;\n  label: string;\n  status: TaskGenerationStepStatus;\n  message: string;\n}\n\nexport interface TaskGenerationJob {\n  job_id: string;\n  task_name: string;\n  status: TaskGenerationStatus;\n  message: string;\n  current_step: string | null;\n  steps: TaskGenerationStep[];\n  task: Task | null;\n  error: string | null;\n}\n\nexport interface TaskCreateResponse {\n  message: string;\n  task?: Task;\n  job?: TaskGenerationJob;\n}\n\n// For PATCH requests, all fields are optional\nexport type TaskUpdate = Partial<Omit<Task, 'id' | 'next_run_at'>>;\n\n// For task creation\nexport interface TaskGenerateRequest {\n  task_name: string;\n  keyword: string;\n  description?: string;\n  analyze_images?: boolean;\n  personal_only?: boolean;\n  min_price?: string | null;\n  max_price?: string | null;\n  max_pages?: number;\n  cron?: string | null;\n  account_state_file?: string | null;\n  account_strategy?: 'auto' | 'fixed' | 'rotate';\n  free_shipping?: boolean;\n  new_publish_option?: string | null;\n  region?: string | null;\n  decision_mode?: 'ai' | 'keyword';\n  keyword_rules?: string[];\n}\n"
  },
  {
    "path": "web-ui/src/views/AccountsView.vue",
    "content": "<script setup lang=\"ts\">\nimport { onMounted, ref } from 'vue'\nimport { useRouter } from 'vue-router'\nimport { useI18n } from 'vue-i18n'\nimport { listAccounts, getAccount, createAccount, updateAccount, deleteAccount, type AccountItem } from '@/api/accounts'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Textarea } from '@/components/ui/textarea'\nimport { Label } from '@/components/ui/label'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'\nimport { toast } from '@/components/ui/toast'\nconst { t } = useI18n()\n\nconst accounts = ref<AccountItem[]>([])\nconst isLoading = ref(false)\nconst isSaving = ref(false)\nconst router = useRouter()\n\nconst isCreateDialogOpen = ref(false)\nconst isEditDialogOpen = ref(false)\nconst isDeleteDialogOpen = ref(false)\n\nconst newName = ref('')\nconst newContent = ref('')\nconst editName = ref('')\nconst editContent = ref('')\nconst deleteName = ref('')\n\nasync function fetchAccounts() {\n  isLoading.value = true\n  try {\n    accounts.value = await listAccounts()\n  } catch (e) {\n    toast({ title: t('accounts.toasts.loadFailed'), description: (e as Error).message, variant: 'destructive' })\n  } finally {\n    isLoading.value = false\n  }\n}\n\nfunction openCreateDialog() {\n  newName.value = ''\n  newContent.value = ''\n  isCreateDialogOpen.value = true\n}\n\nasync function openEditDialog(name: string) {\n  isSaving.value = true\n  try {\n    const detail = await getAccount(name)\n    editName.value = detail.name\n    editContent.value = detail.content\n    isEditDialogOpen.value = true\n  } catch (e) {\n    toast({ title: t('accounts.toasts.loadContentFailed'), description: (e as Error).message, variant: 'destructive' })\n  } finally {\n    isSaving.value = false\n  }\n}\n\nfunction openDeleteDialog(name: string) {\n  deleteName.value = name\n  isDeleteDialogOpen.value = true\n}\n\nfunction goCreateTask(name: string) {\n  router.push({ path: '/tasks', query: { account: name, create: '1' } })\n}\n\nasync function handleCreateAccount() {\n  if (!newName.value.trim() || !newContent.value.trim()) {\n    toast({ title: t('accounts.toasts.incomplete'), description: t('accounts.toasts.createDescriptionRequired'), variant: 'destructive' })\n    return\n  }\n  isSaving.value = true\n  try {\n    await createAccount({ name: newName.value.trim(), content: newContent.value.trim() })\n    toast({ title: t('accounts.toasts.created') })\n    isCreateDialogOpen.value = false\n    await fetchAccounts()\n  } catch (e) {\n    toast({ title: t('accounts.toasts.createFailed'), description: (e as Error).message, variant: 'destructive' })\n  } finally {\n    isSaving.value = false\n  }\n}\n\nasync function handleUpdateAccount() {\n  if (!editContent.value.trim()) {\n    toast({ title: t('accounts.toasts.contentRequired'), description: t('accounts.toasts.updateDescriptionRequired'), variant: 'destructive' })\n    return\n  }\n  isSaving.value = true\n  try {\n    await updateAccount(editName.value, editContent.value.trim())\n    toast({ title: t('accounts.toasts.updated') })\n    isEditDialogOpen.value = false\n    await fetchAccounts()\n  } catch (e) {\n    toast({ title: t('accounts.toasts.updateFailed'), description: (e as Error).message, variant: 'destructive' })\n  } finally {\n    isSaving.value = false\n  }\n}\n\nasync function handleDeleteAccount() {\n  isSaving.value = true\n  try {\n    await deleteAccount(deleteName.value)\n    toast({ title: t('accounts.toasts.deleted') })\n    isDeleteDialogOpen.value = false\n    await fetchAccounts()\n  } catch (e) {\n    toast({ title: t('accounts.toasts.deleteFailed'), description: (e as Error).message, variant: 'destructive' })\n  } finally {\n    isSaving.value = false\n  }\n}\n\nonMounted(fetchAccounts)\n</script>\n\n<template>\n  <div>\n    <div class=\"flex items-center justify-between mb-6\">\n      <div>\n        <h1 class=\"text-2xl font-bold text-gray-800\">{{ t('accounts.title') }}</h1>\n        <p class=\"text-sm text-gray-500 mt-1\">{{ t('accounts.description') }}</p>\n      </div>\n      <Button @click=\"openCreateDialog\">{{ t('accounts.add') }}</Button>\n    </div>\n\n    <Card class=\"mb-6\">\n      <CardHeader>\n        <CardTitle>{{ t('accounts.cookieGuide.title') }}</CardTitle>\n      </CardHeader>\n      <CardContent class=\"text-sm text-gray-600\">\n        <ol class=\"list-decimal list-inside space-y-1\">\n          <li>\n            {{ t('accounts.cookieGuide.step1Prefix') }}\n            <a\n              class=\"text-blue-600 hover:underline\"\n              href=\"https://chromewebstore.google.com/detail/xianyu-login-state-extrac/eidlpfjiodpigmfcahkmlenhppfklcoa\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n            >{{ t('accounts.cookieGuide.extension') }}</a>\n          </li>\n          <li>\n            {{ t('accounts.cookieGuide.step2Prefix') }}\n            <a\n              class=\"text-blue-600 hover:underline\"\n              href=\"https://www.goofish.com\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n            >{{ t('accounts.cookieGuide.website') }}</a>\n          </li>\n          <li>{{ t('accounts.cookieGuide.step3') }}</li>\n          <li>{{ t('accounts.cookieGuide.step4') }}</li>\n          <li>{{ t('accounts.cookieGuide.step5') }}</li>\n        </ol>\n      </CardContent>\n    </Card>\n\n    <Card>\n      <CardHeader>\n        <CardTitle>{{ t('accounts.list.title') }}</CardTitle>\n        <CardDescription>{{ t('accounts.list.description') }}</CardDescription>\n      </CardHeader>\n      <CardContent>\n        <Table>\n          <TableHeader>\n            <TableRow>\n              <TableHead>{{ t('accounts.list.name') }}</TableHead>\n              <TableHead>{{ t('accounts.list.file') }}</TableHead>\n              <TableHead class=\"text-right\">{{ t('accounts.list.actions') }}</TableHead>\n            </TableRow>\n          </TableHeader>\n          <TableBody>\n            <TableRow v-if=\"isLoading\">\n              <TableCell colspan=\"3\" class=\"h-20 text-center text-muted-foreground\">{{ t('common.loading') }}</TableCell>\n            </TableRow>\n            <TableRow v-else-if=\"accounts.length === 0\">\n              <TableCell colspan=\"3\" class=\"h-20 text-center text-muted-foreground\">{{ t('accounts.list.empty') }}</TableCell>\n            </TableRow>\n            <TableRow v-else v-for=\"account in accounts\" :key=\"account.name\">\n              <TableCell class=\"font-medium\">{{ account.name }}</TableCell>\n              <TableCell class=\"text-sm text-gray-500\">{{ account.path }}</TableCell>\n              <TableCell class=\"text-right\">\n                <div class=\"flex justify-end gap-2\">\n                  <Button size=\"sm\" variant=\"outline\" @click=\"goCreateTask(account.name)\">{{ t('accounts.list.createTask') }}</Button>\n                  <Button size=\"sm\" variant=\"outline\" @click=\"openEditDialog(account.name)\">{{ t('accounts.list.update') }}</Button>\n                  <Button size=\"sm\" variant=\"destructive\" @click=\"openDeleteDialog(account.name)\">{{ t('accounts.list.delete') }}</Button>\n                </div>\n              </TableCell>\n            </TableRow>\n          </TableBody>\n        </Table>\n      </CardContent>\n    </Card>\n\n    <Dialog v-model:open=\"isCreateDialogOpen\">\n      <DialogContent class=\"sm:max-w-[700px]\">\n        <DialogHeader>\n          <DialogTitle>{{ t('accounts.createDialog.title') }}</DialogTitle>\n          <DialogDescription>{{ t('accounts.createDialog.description') }}</DialogDescription>\n        </DialogHeader>\n        <div class=\"space-y-4\">\n          <div class=\"grid gap-2\">\n            <Label>{{ t('accounts.createDialog.name') }}</Label>\n            <Input v-model=\"newName\" :placeholder=\"t('accounts.createDialog.namePlaceholder')\" />\n          </div>\n          <div class=\"grid gap-2\">\n            <Label>{{ t('accounts.createDialog.jsonContent') }}</Label>\n            <Textarea v-model=\"newContent\" class=\"min-h-[200px]\" :placeholder=\"t('accounts.createDialog.jsonPlaceholder')\" />\n          </div>\n        </div>\n        <DialogFooter>\n          <Button variant=\"outline\" @click=\"isCreateDialogOpen = false\">{{ t('common.cancel') }}</Button>\n          <Button :disabled=\"isSaving\" @click=\"handleCreateAccount\">\n            {{ isSaving ? t('common.saving') : t('common.save') }}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n\n    <Dialog v-model:open=\"isEditDialogOpen\">\n      <DialogContent class=\"sm:max-w-[700px]\">\n        <DialogHeader>\n          <DialogTitle>{{ t('accounts.editDialog.title', { name: editName }) }}</DialogTitle>\n          <DialogDescription>{{ t('accounts.editDialog.description') }}</DialogDescription>\n        </DialogHeader>\n        <div class=\"space-y-4\">\n          <div class=\"grid gap-2\">\n            <Label>{{ t('accounts.createDialog.jsonContent') }}</Label>\n            <Textarea v-model=\"editContent\" class=\"min-h-[200px]\" />\n          </div>\n        </div>\n        <DialogFooter>\n          <Button variant=\"outline\" @click=\"isEditDialogOpen = false\">{{ t('common.cancel') }}</Button>\n          <Button :disabled=\"isSaving\" @click=\"handleUpdateAccount\">\n            {{ isSaving ? t('common.saving') : t('common.save') }}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n\n    <Dialog v-model:open=\"isDeleteDialogOpen\">\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>{{ t('accounts.deleteDialog.title') }}</DialogTitle>\n          <DialogDescription>{{ t('accounts.deleteDialog.description', { name: deleteName }) }}</DialogDescription>\n        </DialogHeader>\n        <DialogFooter>\n          <Button variant=\"outline\" @click=\"isDeleteDialogOpen = false\">{{ t('common.cancel') }}</Button>\n          <Button variant=\"destructive\" :disabled=\"isSaving\" @click=\"handleDeleteAccount\">\n            {{ isSaving ? t('accounts.deleteDialog.deleting') : t('accounts.list.delete') }}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  </div>\n</template>\n"
  },
  {
    "path": "web-ui/src/views/DashboardView.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { useRouter } from 'vue-router'\nimport { useI18n } from 'vue-i18n'\nimport { useDashboard } from '@/composables/useDashboard'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'\nimport Badge from '@/components/ui/badge/Badge.vue'\nimport PriceTrendChart from '@/components/results/PriceTrendChart.vue'\nimport { formatNumber, formatRelativeTimeFromNow } from '@/i18n'\nimport {\n  Activity,\n  ArrowRight,\n  Compass,\n  LayoutDashboard,\n  Search,\n  Sparkles,\n  Target,\n  Zap,\n} from 'lucide-vue-next'\n\nconst router = useRouter()\nconst { t } = useI18n()\nconst {\n  focusInsights,\n  focusTask,\n  suggestion,\n  stats,\n  activities,\n  isLoading,\n  error,\n} = useDashboard()\n\nconst statCards = computed(() => [\n  {\n    label: t('dashboard.stats.activeTasks'),\n    value: String(stats.value.enabledTasks),\n    detail: t('dashboard.stats.runningCount', { count: stats.value.runningTasks }),\n    icon: Activity,\n    color: 'text-blue-500',\n    bg: 'bg-blue-500/10',\n  },\n  {\n    label: t('dashboard.stats.scannedItems'),\n    value: formatNumber(stats.value.scannedItems),\n    detail: t('dashboard.stats.resultFiles', { count: stats.value.resultFiles }),\n    icon: Search,\n    color: 'text-emerald-500',\n    bg: 'bg-emerald-500/10',\n  },\n  {\n    label: t('dashboard.stats.recommendedItems'),\n    value: String(stats.value.recommendedItems),\n    detail: t('dashboard.stats.recommendedBreakdown', {\n      ai: stats.value.aiRecommendedItems,\n      keyword: stats.value.keywordRecommendedItems,\n    }),\n    icon: Target,\n    color: 'text-amber-500',\n    bg: 'bg-amber-500/10',\n  },\n  {\n    label: t('dashboard.stats.monitoredTasks'),\n    value: String(stats.value.totalTasks),\n    detail: t('dashboard.stats.showAllTasks'),\n    icon: Compass,\n    color: 'text-purple-500',\n    bg: 'bg-purple-500/10',\n  },\n])\n\nconst focusTitle = computed(() => focusTask.value?.task_name || t('dashboard.focus.defaultTitle'))\nconst focusMeta = computed(() => {\n  if (!focusTask.value) return t('dashboard.focus.empty')\n  const keyword = focusTask.value.keyword || t('dashboard.focus.missingKeyword')\n  const count = focusTask.value.total_items\n  return t('dashboard.focus.meta', { keyword, count })\n})\n\nconst insightCards = computed(() => {\n  const market = focusInsights.value?.market_summary\n  const history = focusInsights.value?.history_summary\n  return [\n    {\n      label: t('results.insights.currentAvg'),\n      value: market?.avg_price ? `¥${market.avg_price}` : '—',\n      hint: market\n        ? t('results.insights.sampleCount', { count: market.sample_count })\n        : t('results.grid.empty'),\n    },\n    {\n      label: t('results.insights.historyAvg'),\n      value: history?.avg_price ? `¥${history.avg_price}` : '—',\n      hint: history\n        ? t('results.insights.uniqueItems', { count: history.unique_items })\n        : t('results.insights.noSnapshot'),\n    },\n    {\n      label: t('results.card.marketAvg'),\n      value: market?.min_price ? `¥${market.min_price}` : '—',\n      hint: market?.max_price\n        ? t('results.insights.highestPrice', { price: market.max_price })\n        : t('results.insights.noRange'),\n    },\n  ]\n})\n\nfunction goCreateTask() {\n  router.push({\n    name: 'Tasks',\n    query: { create: '1' },\n  })\n}\n\nfunction openSuggestion() {\n  router.push({\n    name: suggestion.value.routeName,\n    query: suggestion.value.query,\n  })\n}\n\nfunction openActivity(activity: { filename: string | null; type: string }) {\n  if (activity.filename) {\n    router.push({ name: 'Results', query: { file: activity.filename } })\n    return\n  }\n  if (activity.type === 'task') {\n    router.push({ name: 'Tasks' })\n    return\n  }\n  router.push({ name: 'Dashboard' })\n}\n</script>\n\n<template>\n  <div class=\"space-y-8 animate-fade-in\">\n    <div class=\"flex flex-col md:flex-row md:items-center justify-between gap-4\">\n      <div>\n        <h1 class=\"text-3xl font-black text-slate-800 tracking-tight flex items-center gap-3\">\n          <LayoutDashboard class=\"w-8 h-8 text-primary\" />\n          {{ t('dashboard.title') }}\n        </h1>\n        <p class=\"text-slate-500 mt-1 font-medium\">\n          {{ t('dashboard.description') }}\n        </p>\n      </div>\n      <div class=\"flex items-center gap-3\">\n        <Button class=\"shadow-md shadow-primary/20\" @click=\"goCreateTask\">\n          {{ t('dashboard.createTask') }}\n        </Button>\n      </div>\n    </div>\n    <div v-if=\"error\" class=\"rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700\">\n      {{ error.message }}\n    </div>\n    <div class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6\">\n      <Card\n        v-for=\"stat in statCards\"\n        :key=\"stat.label\"\n        class=\"border-none shadow-glass bg-white/60 backdrop-blur-md transition-all hover:scale-[1.02]\"\n      >\n        <CardContent class=\"p-6\">\n          <div class=\"flex items-center justify-between\">\n            <div>\n              <p class=\"text-sm font-bold text-slate-400 uppercase tracking-wider\">{{ stat.label }}</p>\n              <h3 class=\"text-2xl font-black text-slate-800 mt-1\">{{ stat.value }}</h3>\n            </div>\n            <div :class=\"[stat.bg, 'p-3 rounded-2xl']\">\n              <component :is=\"stat.icon\" :class=\"['w-6 h-6', stat.color]\" />\n            </div>\n          </div>\n          <div class=\"mt-4 text-xs font-bold text-slate-500\">\n            {{ stat.detail }}\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n    <div class=\"grid grid-cols-1 lg:grid-cols-3 gap-8\">\n      <Card class=\"lg:col-span-2 border-none shadow-glass bg-white/60 backdrop-blur-md\">\n        <CardHeader class=\"flex flex-col gap-4 border-b border-slate-100/60 pb-5 md:flex-row md:items-start md:justify-between\">\n          <div class=\"space-y-2\">\n            <CardTitle class=\"text-lg font-bold text-slate-800\">\n              {{ focusTitle }}\n            </CardTitle>\n            <p class=\"text-sm text-slate-500\">{{ focusMeta }}</p>\n          </div>\n          <Badge variant=\"secondary\" class=\"w-fit bg-blue-100 text-blue-600\">\n            {{ focusTask?.latest_crawl_time ? t('dashboard.focus.latestUpdate', { time: formatRelativeTimeFromNow(focusTask.latest_crawl_time) }) : t('dashboard.focus.waiting') }}\n          </Badge>\n        </CardHeader>\n        <CardContent class=\"space-y-6 p-6\">\n          <div v-if=\"isLoading\" class=\"rounded-2xl border border-dashed border-slate-200 bg-white/60 px-4 py-10 text-center text-sm text-slate-500\">\n            {{ t('dashboard.focus.loading') }}\n          </div>\n          <div v-else-if=\"!focusTask?.filename\" class=\"rounded-2xl border border-dashed border-slate-200 bg-white/60 px-4 py-10 text-center text-sm text-slate-500\">\n            {{ t('dashboard.focus.noResults') }}\n          </div>\n          <template v-else>\n            <div class=\"grid gap-4 md:grid-cols-3\">\n              <article\n                v-for=\"card in insightCards\"\n                :key=\"card.label\"\n                class=\"rounded-[24px] border border-white/70 bg-white/70 p-4 shadow-[0_12px_30px_rgba(92,68,36,0.06)] backdrop-blur\"\n              >\n                <p class=\"text-xs uppercase tracking-[0.22em] text-[#9b7a5b]\">{{ card.label }}</p>\n                <p class=\"mt-3 text-2xl font-semibold text-[#231d18]\">{{ card.value }}</p>\n                <p class=\"mt-2 text-xs text-[#7a6855]\">{{ card.hint }}</p>\n              </article>\n            </div>\n            <PriceTrendChart :points=\"focusInsights?.daily_trend || []\" />\n            <div class=\"grid gap-3 rounded-[28px] border border-[#d8c7b5] bg-white/80 p-5 shadow-[0_12px_30px_rgba(92,68,36,0.06)] md:grid-cols-3\">\n              <div class=\"rounded-2xl bg-[#f8f1e5] px-4 py-3 text-sm text-[#5e5043]\">\n                {{ t('dashboard.focus.currentMedian') }}\n                <span class=\"font-semibold text-[#2d261f]\">\n                  {{ focusInsights?.market_summary.median_price ? `¥${focusInsights.market_summary.median_price}` : '—' }}\n                </span>\n              </div>\n              <div class=\"rounded-2xl bg-[#f4ece2] px-4 py-3 text-sm text-[#5e5043]\">\n                {{ t('dashboard.focus.historyMin') }}\n                <span class=\"font-semibold text-[#2d261f]\">\n                  {{ focusInsights?.history_summary.min_price ? `¥${focusInsights.history_summary.min_price}` : '—' }}\n                </span>\n              </div>\n              <div class=\"rounded-2xl bg-[#eee4d7] px-4 py-3 text-sm text-[#5e5043]\">\n                {{ t('dashboard.focus.historyMax') }}\n                <span class=\"font-semibold text-[#2d261f]\">\n                  {{ focusInsights?.history_summary.max_price ? `¥${focusInsights.history_summary.max_price}` : '—' }}\n                </span>\n              </div>\n            </div>\n          </template>\n        </CardContent>\n      </Card>\n      <div class=\"space-y-8\">\n        <Card class=\"border-none shadow-glass bg-white/60 backdrop-blur-md\">\n          <CardHeader>\n            <CardTitle class=\"text-lg font-bold text-slate-800 flex items-center gap-2\">\n              <Activity class=\"w-5 h-5 text-rose-500\" />\n              {{ t('dashboard.activity.title') }}\n            </CardTitle>\n          </CardHeader>\n          <CardContent class=\"p-0\">\n            <div v-if=\"activities.length === 0\" class=\"px-4 pb-4 text-sm text-slate-500\">\n              {{ t('dashboard.activity.empty') }}\n            </div>\n            <div v-else class=\"divide-y divide-slate-100/50\">\n              <button\n                v-for=\"activity in activities\"\n                :key=\"activity.id\"\n                class=\"w-full p-4 text-left hover:bg-slate-50/50 transition-colors\"\n                @click=\"openActivity(activity)\"\n              >\n                <div class=\"flex items-center justify-between gap-3\">\n                  <div class=\"flex items-center gap-3 min-w-0\">\n                    <div class=\"w-2 h-2 rounded-full bg-emerald-500 animate-pulse shrink-0\"></div>\n                    <div class=\"min-w-0\">\n                      <p class=\"text-sm font-bold text-slate-700 truncate\">{{ activity.title }}</p>\n                      <p class=\"text-[11px] text-slate-400\">\n                        {{ activity.task_name }} · {{ formatRelativeTimeFromNow(activity.timestamp) }}\n                      </p>\n                      <p v-if=\"activity.detail\" class=\"mt-1 text-xs text-slate-500 truncate\">{{ activity.detail }}</p>\n                    </div>\n                  </div>\n                  <Badge variant=\"outline\" class=\"text-[10px] border-slate-200 shrink-0\">\n                    {{ activity.status }}\n                  </Badge>\n                </div>\n              </button>\n            </div>\n            <button\n              class=\"w-full py-3 text-xs font-bold text-primary hover:bg-slate-50 transition-colors flex items-center justify-center gap-2 border-t border-slate-100/50\"\n              @click=\"router.push({ name: 'Logs' })\"\n            >\n              {{ t('dashboard.activity.viewAllLogs') }}\n              <ArrowRight class=\"w-3 h-3\" />\n            </button>\n          </CardContent>\n        </Card>\n        <div class=\"bg-gradient-to-br from-indigo-600 to-blue-700 rounded-2xl p-6 text-white shadow-lg shadow-indigo-200\">\n          <div class=\"flex items-center gap-2 mb-4\">\n            <Zap class=\"w-6 h-6 text-amber-300\" />\n            <h4 class=\"font-bold text-lg\">{{ t('dashboard.suggestion.sectionTitle') }}</h4>\n          </div>\n          <p class=\"text-indigo-100 text-sm leading-relaxed mb-2\">{{ suggestion.title }}</p>\n          <p class=\"text-indigo-100/90 text-sm leading-relaxed mb-6\">{{ suggestion.description }}</p>\n          <Button variant=\"secondary\" class=\"w-full bg-white text-indigo-700 font-bold hover:bg-indigo-50\" @click=\"openSuggestion\">\n            <Sparkles class=\"mr-2 h-4 w-4\" />\n            {{ suggestion.actionLabel }}\n          </Button>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "web-ui/src/views/LoginView.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref } from 'vue'\nimport { useRouter, useRoute } from 'vue-router'\nimport { useAuth } from '@/composables/useAuth'\nimport LocaleToggle from '@/components/layout/LocaleToggle.vue'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'\nimport { useI18n } from 'vue-i18n'\n\nconst username = ref('')\nconst password = ref('')\nconst isLoading = ref(false)\nconst error = ref('')\n\nconst { login } = useAuth()\nconst router = useRouter()\nconst route = useRoute()\nconst { t } = useI18n()\n\nasync function handleLogin() {\n  if (!username.value || !password.value) {\n    error.value = t('login.errors.missingCredentials')\n    return\n  }\n\n  isLoading.value = true\n  error.value = ''\n\n  try {\n    const success = await login(username.value, password.value)\n    if (success) {\n      const redirectPath = (route.query.redirect as string) || '/'\n      router.push(redirectPath)\n    } else {\n      error.value = t('login.errors.invalidCredentials')\n    }\n  } catch (e) {\n    error.value = t('login.errors.unexpected')\n  } finally {\n    isLoading.value = false\n  }\n}\n</script>\n\n<template>\n  <div class=\"flex items-center justify-center min-h-screen bg-gray-100\">\n    <div class=\"absolute right-6 top-6\">\n      <LocaleToggle />\n    </div>\n    <Card class=\"w-full max-w-md\">\n      <CardHeader>\n        <CardTitle class=\"text-2xl text-center\">{{ t('login.title') }}</CardTitle>\n        <CardDescription class=\"text-center\">\n          {{ t('login.description') }}\n        </CardDescription>\n      </CardHeader>\n      <form @submit.prevent=\"handleLogin\">\n        <CardContent class=\"grid gap-4\">\n          <div class=\"grid gap-2\">\n            <Label for=\"username\">{{ t('login.username') }}</Label>\n            <Input id=\"username\" type=\"text\" v-model=\"username\" placeholder=\"admin\" required />\n          </div>\n          <div class=\"grid gap-2\">\n            <Label for=\"password\">{{ t('login.password') }}</Label>\n            <Input id=\"password\" type=\"password\" v-model=\"password\" required />\n          </div>\n          <div v-if=\"error\" class=\"text-sm text-red-500 font-medium\">\n            {{ error }}\n          </div>\n        </CardContent>\n        <CardFooter>\n          <Button class=\"w-full\" type=\"submit\" :disabled=\"isLoading\">\n            {{ isLoading ? t('login.submitting') : t('login.submit') }}\n          </Button>\n        </CardFooter>\n      </form>\n    </Card>\n  </div>\n</template>\n"
  },
  {
    "path": "web-ui/src/views/LogsView.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, watch, nextTick } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport { useLogs } from '@/composables/useLogs'\nimport { useTasks } from '@/composables/useTasks'\nimport { Button } from '@/components/ui/button'\nimport { Switch } from '@/components/ui/switch'\nimport { Label } from '@/components/ui/label'\nimport { Card, CardContent } from '@/components/ui/card'\nimport { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\nimport { toast } from '@/components/ui/toast'\n\nconst { t } = useI18n()\nconst { tasks } = useTasks()\nconst { logs, isAutoRefresh, clearLogs, toggleAutoRefresh, fetchLogs, setTaskId, loadLatest, loadPrevious, isFetchingHistory, hasMoreHistory } = useLogs()\nconst logContainer = ref<HTMLElement | null>(null)\nconst autoScroll = ref(true)\nconst isClearDialogOpen = ref(false)\nconst selectedTaskId = ref('')\nconst isPrepending = ref(false)\nconst lastScrollTop = ref(0)\nconst lastScrollHeight = ref(0)\n\n// Auto-scroll logic\nwatch(logs, async () => {\n  if (isPrepending.value) {\n    await nextTick()\n    if (logContainer.value) {\n      const delta = logContainer.value.scrollHeight - lastScrollHeight.value\n      logContainer.value.scrollTop = lastScrollTop.value + delta\n    }\n    isPrepending.value = false\n    return\n  }\n  if (autoScroll.value) {\n    await nextTick()\n    scrollToBottom()\n  }\n})\n\nwatch(tasks, (list) => {\n  if (!list.length) {\n    selectedTaskId.value = ''\n    setTaskId(null)\n    return\n  }\n  if (selectedTaskId.value && list.some((task) => String(task.id) === selectedTaskId.value)) {\n    return\n  }\n  const running = list.find((task) => task.is_running)\n  const fallback = list[0]\n  if (!fallback) {\n    selectedTaskId.value = ''\n    setTaskId(null)\n    return\n  }\n  selectedTaskId.value = String(running ? running.id : fallback.id)\n}, { immediate: true })\n\nwatch(selectedTaskId, (taskId) => {\n  const resolvedTaskId = taskId ? Number(taskId) : null\n  setTaskId(resolvedTaskId)\n  if (resolvedTaskId) {\n    loadLatest(50)\n  }\n})\n\nfunction scrollToBottom() {\n  if (logContainer.value) {\n    logContainer.value.scrollTop = logContainer.value.scrollHeight\n  }\n}\n\nasync function handleScroll() {\n  if (!logContainer.value) return\n  if (!hasMoreHistory.value || isFetchingHistory.value) return\n  if (logContainer.value.scrollTop > 120) return\n  lastScrollTop.value = logContainer.value.scrollTop\n  lastScrollHeight.value = logContainer.value.scrollHeight\n  isPrepending.value = true\n  await loadPrevious(50)\n}\n\nfunction openClearDialog() {\n  isClearDialogOpen.value = true\n}\n\nasync function handleClearLogs() {\n  try {\n    await clearLogs()\n    toast({ title: t('logs.logsCleared') })\n  } catch (e) {\n    toast({\n      title: t('logs.clearFailed'),\n      description: (e as Error).message,\n      variant: 'destructive',\n    })\n  } finally {\n    isClearDialogOpen.value = false\n  }\n}\n</script>\n\n<template>\n  <div class=\"h-[calc(100vh-100px)] flex flex-col\">\n    <div class=\"flex justify-between items-center mb-4\">\n      <div class=\"flex items-center gap-4\">\n        <h1 class=\"text-2xl font-bold text-gray-800\">{{ t('logs.title') }}</h1>\n        <div class=\"flex items-center gap-2\">\n          <Label class=\"text-sm text-gray-600\">{{ t('logs.task') }}</Label>\n          <Select v-model=\"selectedTaskId\">\n            <SelectTrigger class=\"w-[240px]\">\n              <SelectValue :placeholder=\"t('logs.selectTask')\" />\n            </SelectTrigger>\n            <SelectContent>\n              <SelectItem v-for=\"task in tasks\" :key=\"task.id\" :value=\"String(task.id)\">\n                {{ task.task_name }}{{ task.is_running ? t('logs.taskRunningSuffix') : '' }}\n              </SelectItem>\n            </SelectContent>\n          </Select>\n        </div>\n      </div>\n      \n      <div class=\"flex items-center gap-4\">\n        <Button variant=\"outline\" size=\"sm\" :disabled=\"!selectedTaskId\" @click=\"fetchLogs\">\n          {{ t('common.refresh') }}\n        </Button>\n\n        <div class=\"flex items-center space-x-2\">\n          <Switch id=\"auto-refresh\" :model-value=\"isAutoRefresh\" @update:model-value=\"toggleAutoRefresh\" />\n          <Label for=\"auto-refresh\">{{ t('logs.autoRefresh') }}</Label>\n        </div>\n\n        <div class=\"flex items-center space-x-2\">\n          <Switch id=\"auto-scroll\" v-model=\"autoScroll\" />\n          <Label for=\"auto-scroll\">{{ t('logs.autoScroll') }}</Label>\n        </div>\n\n        <Button variant=\"destructive\" size=\"sm\" :disabled=\"!selectedTaskId\" @click=\"openClearDialog\">\n          {{ t('logs.clearLogs') }}\n        </Button>\n      </div>\n    </div>\n\n    <Card class=\"flex-1 overflow-hidden flex flex-col\">\n      <CardContent class=\"flex-1 p-0 relative\">\n        <pre\n          ref=\"logContainer\"\n          @scroll=\"handleScroll\"\n          class=\"absolute inset-0 p-4 bg-gray-950 text-gray-100 font-mono text-sm overflow-auto whitespace-pre-wrap break-all\"\n        >{{ logs }}</pre>\n      </CardContent>\n    </Card>\n\n    <Dialog v-model:open=\"isClearDialogOpen\">\n      <DialogContent class=\"sm:max-w-[420px]\">\n        <DialogHeader>\n          <DialogTitle>{{ t('logs.dialogTitle') }}</DialogTitle>\n          <DialogDescription>\n            {{ t('logs.dialogDescription') }}\n          </DialogDescription>\n        </DialogHeader>\n        <DialogFooter>\n          <Button variant=\"outline\" @click=\"isClearDialogOpen = false\">{{ t('common.cancel') }}</Button>\n          <Button variant=\"destructive\" @click=\"handleClearLogs\">{{ t('logs.confirmClear') }}</Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  </div>\n</template>\n"
  },
  {
    "path": "web-ui/src/views/ResultsView.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport { useResults } from '@/composables/useResults'\nimport ResultsFilterBar from '@/components/results/ResultsFilterBar.vue'\nimport ResultsGrid from '@/components/results/ResultsGrid.vue'\nimport ResultsInsightsPanel from '@/components/results/ResultsInsightsPanel.vue'\nimport { Button } from '@/components/ui/button'\nimport { toast } from '@/components/ui/toast'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog'\n\nconst { t } = useI18n()\n\nconst {\n  files,\n  selectedFile,\n  results,\n  insights,\n  filters,\n  isLoading,\n  error,\n  refreshResults,\n  exportSelectedResults,\n  deleteSelectedFile,\n  fileOptions,\n  isFileOptionsReady,\n} = useResults()\n\nconst isDeleteDialogOpen = ref(false)\n\nconst selectedTaskLabel = computed(() => {\n  if (!selectedFile.value || fileOptions.value.length === 0) return null\n  const match = fileOptions.value.find((option) => option.value === selectedFile.value)\n  if (!match) return null\n  return match.taskName || null\n})\n\nconst deleteConfirmText = computed(() => {\n  return selectedTaskLabel.value\n    ? t('results.filters.deleteDialogWithTask', { task: selectedTaskLabel.value })\n    : t('results.filters.deleteDialogFallback')\n})\n\nfunction openDeleteDialog() {\n  if (!selectedFile.value) {\n    toast({\n      title: t('results.filters.noResultToDelete'),\n      variant: 'destructive',\n    })\n    return\n  }\n  isDeleteDialogOpen.value = true\n}\n\nfunction handleExportResults() {\n  if (!selectedFile.value) {\n    toast({\n      title: t('results.filters.noResultToExport'),\n      variant: 'destructive',\n    })\n    return\n  }\n  exportSelectedResults()\n}\n\nasync function handleDeleteResults() {\n  if (!selectedFile.value) return\n  try {\n    await deleteSelectedFile(selectedFile.value)\n    toast({ title: t('results.filters.resultDeleted') })\n  } catch (e) {\n    toast({\n      title: t('results.filters.deleteFailed'),\n      description: (e as Error).message,\n      variant: 'destructive',\n    })\n  } finally {\n    isDeleteDialogOpen.value = false\n  }\n}\n</script>\n\n<template>\n  <div>\n    <h1 class=\"text-2xl font-bold text-gray-800 mb-6\">\n      {{ t('results.title') }}\n    </h1>\n\n    <div v-if=\"error\" class=\"bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4\" role=\"alert\">\n      <strong class=\"font-bold\">{{ t('common.error') }}</strong>\n      <span class=\"block sm:inline\">{{ error.message }}</span>\n    </div>\n\n    <ResultsFilterBar\n      :files=\"files\"\n      :file-options=\"fileOptions\"\n      :is-ready=\"isFileOptionsReady\"\n      v-model:selectedFile=\"selectedFile\"\n      v-model:aiRecommendedOnly=\"filters.ai_recommended_only\"\n      v-model:keywordRecommendedOnly=\"filters.keyword_recommended_only\"\n      v-model:sortBy=\"filters.sort_by\"\n      v-model:sortOrder=\"filters.sort_order\"\n      :is-loading=\"isLoading\"\n      @refresh=\"refreshResults\"\n      @export=\"handleExportResults\"\n      @delete=\"openDeleteDialog\"\n    />\n\n    <ResultsInsightsPanel :insights=\"insights\" :selected-task-label=\"selectedTaskLabel\" />\n\n    <ResultsGrid :results=\"results\" :is-loading=\"isLoading\" />\n\n    <Dialog v-model:open=\"isDeleteDialogOpen\">\n      <DialogContent class=\"sm:max-w-[420px]\">\n        <DialogHeader>\n          <DialogTitle>{{ t('results.filters.deleteDialogTitle') }}</DialogTitle>\n          <DialogDescription>\n            {{ deleteConfirmText }}\n          </DialogDescription>\n        </DialogHeader>\n        <DialogFooter>\n          <Button variant=\"outline\" @click=\"isDeleteDialogOpen = false\">{{ t('common.cancel') }}</Button>\n          <Button variant=\"destructive\" :disabled=\"isLoading\" @click=\"handleDeleteResults\">\n            {{ t('results.filters.confirmDelete') }}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  </div>\n</template>\n"
  },
  {
    "path": "web-ui/src/views/SettingsView.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, watch } from 'vue'\nimport { useRoute } from 'vue-router'\nimport { useI18n } from 'vue-i18n'\nimport { useSettings } from '@/composables/useSettings'\nimport type { NotificationSettingsUpdate, NotificationTestResponse } from '@/api/settings'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { Textarea } from '@/components/ui/textarea'\nimport { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\nimport { toast } from '@/components/ui/toast'\nimport { getPromptContent, listPrompts, updatePrompt } from '@/api/prompts'\nimport NotificationSettingsPanel from '@/components/settings/NotificationSettingsPanel.vue'\nimport RotationSettingsPanel from '@/components/settings/RotationSettingsPanel.vue'\nconst { t } = useI18n()\n\nconst {\n  notificationSettings,\n  aiSettings,\n  rotationSettings,\n  systemStatus,\n  isLoading,\n  isSaving,\n  isReady,\n  error,\n  refreshStatus,\n  saveNotificationSettings,\n  testNotification,\n  saveAiSettings,\n  saveRotationSettings,\n  testAiConnection\n} = useSettings()\n\nconst activeTab = ref('ai')\nconst route = useRoute()\nconst validTabs = new Set(['notifications', 'ai', 'rotation', 'status', 'prompts'])\n\nconst promptFiles = ref<string[]>([])\nconst selectedPrompt = ref<string | null>(null)\nconst promptContent = ref('')\nconst isPromptLoading = ref(false)\nconst isPromptSaving = ref(false)\nconst promptError = ref<string | null>(null)\n\nfunction notifySuccess(title: string, description?: string) {\n  toast({ title, description })\n}\n\nfunction notifyError(title: string, description?: string) {\n  toast({ title, description, variant: 'destructive' })\n}\n\nasync function handleSaveNotifications(payload: NotificationSettingsUpdate) {\n  try {\n    await saveNotificationSettings(payload)\n    notifySuccess(t('settings.notifications.saved'))\n  } catch (e) {\n    notifyError(t('settings.notifications.saveFailed'), (e as Error).message)\n  }\n}\n\nasync function handleTestNotification(payload: {\n  channel?: string\n  settings: NotificationSettingsUpdate\n}): Promise<NotificationTestResponse> {\n  try {\n    const result = await testNotification(payload)\n    return result\n  } catch (e) {\n    notifyError(t('settings.notifications.testFailed'), (e as Error).message)\n    throw e\n  }\n}\n\nasync function handleSaveAi() {\n  try {\n    await saveAiSettings()\n    notifySuccess(t('settings.ai.saved'))\n  } catch (e) {\n    notifyError(t('settings.ai.saveFailed'), (e as Error).message)\n  }\n}\n\nasync function handleSaveRotation() {\n  try {\n    await saveRotationSettings()\n    notifySuccess(t('settings.rotation.saved'))\n  } catch (e) {\n    notifyError(t('settings.rotation.saveFailed'), (e as Error).message)\n  }\n}\n\nasync function handleTestAi() {\n  try {\n    const res = await testAiConnection()\n    notifySuccess(t('settings.ai.testSuccess'), res.message)\n  } catch (e) {\n    notifyError(t('settings.ai.testFailed'), (e as Error).message)\n  }\n}\n\nasync function fetchPrompts() {\n  isPromptLoading.value = true\n  promptError.value = null\n  try {\n    const files = await listPrompts()\n    promptFiles.value = files\n\n    if (selectedPrompt.value && files.includes(selectedPrompt.value)) {\n      return\n    }\n\n    const lastSelected = localStorage.getItem('lastSelectedPrompt')\n    if (lastSelected && files.includes(lastSelected)) {\n      selectedPrompt.value = lastSelected\n      return\n    }\n\n    selectedPrompt.value = files[0] || null\n  } catch (e) {\n    promptError.value = (e as Error).message || t('settings.prompts.promptListFailed')\n  } finally {\n    isPromptLoading.value = false\n  }\n}\n\nasync function handleSavePrompt() {\n  if (!selectedPrompt.value) {\n    notifyError(t('settings.prompts.selectPromptFile'))\n    return\n  }\n  isPromptSaving.value = true\n  try {\n    const res = await updatePrompt(selectedPrompt.value, promptContent.value)\n    notifySuccess(t('settings.prompts.saveSuccess'), res.message)\n  } catch (e) {\n    notifyError(t('settings.prompts.saveFailed'), (e as Error).message)\n  } finally {\n    isPromptSaving.value = false\n  }\n}\n\nwatch(activeTab, (tab) => {\n  if (tab === 'prompts') {\n    fetchPrompts()\n  }\n})\n\nwatch(\n  () => route.query.tab,\n  (tab) => {\n    if (typeof tab === 'string' && validTabs.has(tab)) {\n      activeTab.value = tab\n    }\n  },\n  { immediate: true }\n)\n\nwatch(selectedPrompt, async (value) => {\n  if (!value) {\n    promptContent.value = ''\n    return\n  }\n  localStorage.setItem('lastSelectedPrompt', value)\n  isPromptLoading.value = true\n  promptError.value = null\n  try {\n    const data = await getPromptContent(value)\n    promptContent.value = data.content\n  } catch (e) {\n    promptError.value = (e as Error).message || t('settings.prompts.promptContentFailed')\n  } finally {\n    isPromptLoading.value = false\n  }\n})\n</script>\n\n<template>\n  <div>\n    <h1 class=\"text-2xl font-bold text-gray-800 mb-6\">{{ t('settings.title') }}</h1>\n    \n    <div v-if=\"error\" class=\"bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4\">\n      {{ error.message }}\n    </div>\n\n    <Tabs v-model=\"activeTab\" class=\"w-full\">\n      <TabsList class=\"mb-4\">\n        <TabsTrigger value=\"ai\">{{ t('settings.tabs.ai') }}</TabsTrigger>\n        <TabsTrigger value=\"rotation\">{{ t('settings.tabs.rotation') }}</TabsTrigger>\n        <TabsTrigger value=\"notifications\">{{ t('settings.tabs.notifications') }}</TabsTrigger>\n        <TabsTrigger value=\"status\">{{ t('settings.tabs.status') }}</TabsTrigger>\n        <TabsTrigger value=\"prompts\">{{ t('settings.tabs.prompts') }}</TabsTrigger>\n      </TabsList>\n\n      <!-- AI Tab -->\n      <TabsContent value=\"ai\">\n        <Card>\n          <CardHeader>\n            <CardTitle>{{ t('settings.ai.title') }}</CardTitle>\n            <CardDescription>{{ t('settings.ai.description') }}</CardDescription>\n          </CardHeader>\n          <CardContent v-if=\"isReady\" class=\"space-y-4\">\n            <div class=\"grid gap-2\">\n              <Label>API Base URL</Label>\n              <Input v-model=\"aiSettings.OPENAI_BASE_URL\" placeholder=\"https://api.openai.com/v1\" />\n            </div>\n            <div class=\"grid gap-2\">\n              <Label>API Key</Label>\n              <Input\n                v-model=\"aiSettings.OPENAI_API_KEY\"\n                type=\"password\"\n                :placeholder=\"t('settings.ai.keyPlaceholder')\"\n              />\n              <p class=\"text-xs text-gray-500\">\n                {{ systemStatus?.env_file.openai_api_key_set ? t('settings.ai.keyConfigured') : t('settings.ai.keyMissing') }}\n              </p>\n            </div>\n            <div class=\"grid gap-2\">\n              <Label>{{ t('settings.ai.modelName') }}</Label>\n              <Input v-model=\"aiSettings.OPENAI_MODEL_NAME\" placeholder=\"gpt-3.5-turbo\" />\n            </div>\n            <div class=\"grid gap-2\">\n              <Label>{{ t('settings.ai.proxy') }}</Label>\n              <Input v-model=\"aiSettings.PROXY_URL\" placeholder=\"http://127.0.0.1:7890\" />\n            </div>\n          </CardContent>\n          <CardContent v-else class=\"py-8 text-sm text-gray-500\">\n            {{ t('settings.ai.loading') }}\n          </CardContent>\n          <CardFooter v-if=\"isReady\" class=\"flex gap-2\">\n            <Button variant=\"outline\" @click=\"handleTestAi\" :disabled=\"isSaving\">{{ t('settings.ai.testConnection') }}</Button>\n            <Button @click=\"handleSaveAi\" :disabled=\"isSaving\">{{ t('settings.ai.save') }}</Button>\n          </CardFooter>\n        </Card>\n      </TabsContent>\n\n      <!-- Rotation Tab -->\n      <TabsContent value=\"rotation\">\n        <RotationSettingsPanel\n          :settings=\"rotationSettings\"\n          :is-ready=\"isReady\"\n          :is-saving=\"isSaving\"\n          @save=\"handleSaveRotation\"\n        />\n      </TabsContent>\n\n      <!-- Notifications Tab -->\n      <TabsContent value=\"notifications\">\n        <NotificationSettingsPanel\n          :settings=\"notificationSettings\"\n          :is-ready=\"isReady\"\n          :is-saving=\"isSaving\"\n          :save-settings=\"handleSaveNotifications\"\n          :test-settings=\"handleTestNotification\"\n        />\n      </TabsContent>\n\n      <!-- Status Tab -->\n      <TabsContent value=\"status\">\n        <Card>\n          <CardHeader>\n            <CardTitle>{{ t('settings.status.title') }}</CardTitle>\n            <div class=\"flex justify-end\">\n                <Button variant=\"outline\" size=\"sm\" @click=\"refreshStatus\" :disabled=\"isLoading\">{{ t('settings.status.refresh') }}</Button>\n            </div>\n          </CardHeader>\n          <CardContent>\n            <div v-if=\"systemStatus\" class=\"space-y-6\">\n              <!-- Scraper Process Status -->\n              <div class=\"flex items-center justify-between border-b pb-4\">\n                <div>\n                  <h3 class=\"font-medium\">{{ t('settings.status.scraper') }}</h3>\n                  <p class=\"text-sm text-gray-500\">{{ t('settings.status.scraperDescription') }}</p>\n                </div>\n                <span :class=\"systemStatus.scraper_running ? 'text-green-600 font-bold bg-green-50 px-3 py-1 rounded-full' : 'text-gray-500 bg-gray-100 px-3 py-1 rounded-full'\">\n                  {{ systemStatus.scraper_running ? t('common.running') : t('common.idle') }}\n                </span>\n              </div>\n\n              <!-- Env Config Status -->\n              <div>\n                <div class=\"flex items-center justify-between mb-4\">\n                    <div>\n                        <h3 class=\"font-medium\">{{ t('settings.status.env') }}</h3>\n                        <p class=\"text-sm text-gray-500\">{{ t('settings.status.envDescription') }}</p>\n                    </div>\n                    <span :class=\"systemStatus.env_file.exists ? 'text-green-600 font-bold bg-green-50 px-3 py-1 rounded-full' : 'text-red-600 font-bold bg-red-50 px-3 py-1 rounded-full'\">\n                        {{ systemStatus.env_file.exists ? t('settings.status.loaded') : t('settings.status.missing') }}\n                    </span>\n                </div>\n                \n                <div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n                    <div class=\"p-3 border rounded-lg\" :class=\"systemStatus.env_file.openai_api_key_set ? 'bg-green-50 border-green-200' : 'bg-yellow-50 border-yellow-200'\">\n                        <div class=\"flex justify-between items-center\">\n                            <span class=\"font-medium text-sm\">OpenAI API Key</span>\n                            <span class=\"text-xs font-bold\" :class=\"systemStatus.env_file.openai_api_key_set ? 'text-green-700' : 'text-yellow-700'\">\n                                {{ systemStatus.env_file.openai_api_key_set ? t('common.active') : t('common.inactive') }}\n                            </span>\n                        </div>\n                    </div>\n                    \n                    <div class=\"p-3 border rounded-lg\" :class=\"systemStatus.configured_notification_channels?.length ? 'bg-green-50 border-green-200' : 'bg-gray-50 border-gray-200'\">\n                         <div class=\"flex justify-between items-center\">\n                            <span class=\"font-medium text-sm\">{{ t('settings.status.channels') }}</span>\n                             <span class=\"text-xs font-bold\" :class=\"systemStatus.configured_notification_channels?.length ? 'text-green-700' : 'text-gray-500'\">\n                                {{ systemStatus.configured_notification_channels?.length ? t('common.active') : t('common.inactive') }}\n                            </span>\n                        </div>\n                         <div class=\"text-xs text-gray-500 mt-1\">\n                            {{ systemStatus.configured_notification_channels?.join(', ') || t('settings.status.none') }}\n                        </div>\n                    </div>\n                </div>\n              </div>\n            </div>\n            <div v-else class=\"text-center py-8 text-gray-500\">\n                {{ t('settings.status.fetching') }}\n            </div>\n          </CardContent>\n        </Card>\n      </TabsContent>\n\n      <!-- Prompt Tab -->\n      <TabsContent value=\"prompts\">\n        <Card>\n          <CardHeader>\n            <CardTitle>{{ t('settings.prompts.title') }}</CardTitle>\n            <CardDescription>{{ t('settings.prompts.description') }}</CardDescription>\n          </CardHeader>\n          <CardContent class=\"space-y-4\">\n            <div v-if=\"promptError\" class=\"bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded\">\n              {{ promptError }}\n            </div>\n\n            <div class=\"grid gap-2\">\n              <Label>{{ t('settings.prompts.selectFile') }}</Label>\n              <Select\n                :model-value=\"selectedPrompt || undefined\"\n                @update:model-value=\"(value) => selectedPrompt = value as string\"\n              >\n                <SelectTrigger>\n                  <SelectValue :placeholder=\"t('settings.prompts.placeholder')\" />\n                </SelectTrigger>\n                <SelectContent>\n                  <SelectItem v-for=\"file in promptFiles\" :key=\"file\" :value=\"file\">\n                    {{ file }}\n                  </SelectItem>\n                </SelectContent>\n              </Select>\n              <p v-if=\"!promptFiles.length && !isPromptLoading\" class=\"text-sm text-gray-500\">\n                {{ t('settings.prompts.none') }}\n              </p>\n            </div>\n\n            <div class=\"grid gap-2\">\n              <Label>{{ t('settings.prompts.content') }}</Label>\n              <Textarea\n                v-model=\"promptContent\"\n                class=\"min-h-[240px]\"\n                :disabled=\"!selectedPrompt || isPromptLoading\"\n                :placeholder=\"t('settings.prompts.contentPlaceholder')\"\n              />\n            </div>\n          </CardContent>\n          <CardFooter>\n            <Button :disabled=\"isPromptSaving || !selectedPrompt\" @click=\"handleSavePrompt\">\n              {{ isPromptSaving ? t('common.saving') : t('settings.prompts.save') }}\n            </Button>\n          </CardFooter>\n        </Card>\n      </TabsContent>\n    </Tabs>\n  </div>\n</template>\n"
  },
  {
    "path": "web-ui/src/views/TasksView.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref, onMounted, watch } from 'vue'\nimport { useRoute } from 'vue-router'\nimport { useI18n } from 'vue-i18n'\nimport { useTasks } from '@/composables/useTasks'\nimport type { Task, TaskUpdate } from '@/types/task.d.ts'\nimport { parseTaskFormDefaults } from '@/lib/taskFormQuery'\nimport TaskCreateDialog from '@/components/tasks/TaskCreateDialog.vue'\nimport TasksTable from '@/components/tasks/TasksTable.vue'\nimport TaskForm from '@/components/tasks/TaskForm.vue'\nimport { listAccounts, type AccountItem } from '@/api/accounts'\nimport { Button } from '@/components/ui/button'\nimport { Textarea } from '@/components/ui/textarea'\nimport { toast } from '@/components/ui/toast'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog'\nconst { t } = useI18n()\n\nconst {\n  tasks,\n  isLoading,\n  error,\n  fetchTasks,\n  removeTask,\n  updateTask,\n  startTask,\n  stopTask,\n  stoppingTaskIds,\n} = useTasks()\nconst route = useRoute()\n\n// State for dialogs\nconst isEditDialogOpen = ref(false)\nconst isCriteriaDialogOpen = ref(false)\nconst isEditSubmitting = ref(false)\nconst selectedTask = ref<Task | null>(null)\nconst criteriaTask = ref<Task | null>(null)\nconst criteriaDescription = ref('')\nconst isCriteriaSubmitting = ref(false)\nconst isDeleteDialogOpen = ref(false)\nconst taskToDeleteId = ref<number | null>(null)\nconst accountOptions = ref<AccountItem[]>([])\n\nconst taskToDelete = computed(() => {\n  if (taskToDeleteId.value === null) return null\n  return tasks.value.find((task) => task.id === taskToDeleteId.value) || null\n})\nconst editDefaults = computed(() => parseTaskFormDefaults(route.query))\n\nfunction handleDeleteTask(taskId: number) {\n  taskToDeleteId.value = taskId\n  isDeleteDialogOpen.value = true\n}\n\nasync function handleConfirmDeleteTask() {\n  if (!taskToDelete.value) {\n    toast({ title: t('tasks.toasts.notFound'), variant: 'destructive' })\n    isDeleteDialogOpen.value = false\n    return\n  }\n  try {\n    await removeTask(taskToDelete.value.id)\n    toast({ title: t('tasks.toasts.deleted') })\n  } catch (e) {\n    toast({\n      title: t('tasks.toasts.deleteFailed'),\n      description: (e as Error).message,\n      variant: 'destructive',\n    })\n  } finally {\n    isDeleteDialogOpen.value = false\n    taskToDeleteId.value = null\n  }\n}\n\nfunction handleEditTask(task: Task) {\n  selectedTask.value = task\n  isEditDialogOpen.value = true\n}\n\nwatch(\n  () => [route.query.edit, tasks.value],\n  () => {\n    const editTaskId = typeof route.query.edit === 'string' ? Number(route.query.edit) : NaN\n    if (!Number.isFinite(editTaskId)) return\n    const match = tasks.value.find((task) => task.id === editTaskId)\n    if (!match) return\n    selectedTask.value = match\n    isEditDialogOpen.value = true\n  },\n  { immediate: true }\n)\n\nasync function handleUpdateTask(data: TaskUpdate) {\n  if (!selectedTask.value) return\n  isEditSubmitting.value = true\n  try {\n    await updateTask(selectedTask.value.id, data)\n    isEditDialogOpen.value = false\n  }\n  catch (e) {\n    toast({\n      title: t('tasks.toasts.updateFailed'),\n      description: (e as Error).message,\n      variant: 'destructive',\n    })\n  }\n  finally {\n    isEditSubmitting.value = false\n  }\n}\n\nfunction handleOpenCriteriaDialog(task: Task) {\n  criteriaTask.value = task\n  criteriaDescription.value = task.description || ''\n  isCriteriaDialogOpen.value = true\n}\n\nasync function handleRefreshCriteria() {\n  if (!criteriaTask.value) return\n  if (!criteriaDescription.value.trim()) {\n    toast({\n      title: t('tasks.toasts.descriptionRequired'),\n      description: t('tasks.criteria.descriptionRequired'),\n      variant: 'destructive',\n    })\n    return\n  }\n\n  isCriteriaSubmitting.value = true\n  try {\n    await updateTask(criteriaTask.value.id, { description: criteriaDescription.value })\n    isCriteriaDialogOpen.value = false\n  } catch (e) {\n    toast({\n      title: t('tasks.toasts.regenerateFailed'),\n      description: (e as Error).message,\n      variant: 'destructive',\n    })\n  } finally {\n    isCriteriaSubmitting.value = false\n  }\n}\n\nasync function handleStartTask(taskId: number) {\n  try {\n    await startTask(taskId)\n  } catch (e) {\n    toast({\n      title: t('tasks.toasts.startFailed'),\n      description: (e as Error).message,\n      variant: 'destructive',\n    })\n  }\n}\n\nasync function handleStopTask(taskId: number) {\n  try {\n    await stopTask(taskId)\n  } catch (e) {\n    toast({\n      title: t('tasks.toasts.stopFailed'),\n      description: (e as Error).message,\n      variant: 'destructive',\n    })\n  }\n}\n\nasync function handleToggleEnabled(task: Task, enabled: boolean) {\n  const previous = task.enabled\n  task.enabled = enabled\n  try {\n    await updateTask(task.id, { enabled })\n  } catch (e) {\n    task.enabled = previous\n    toast({\n      title: t('tasks.toasts.toggleFailed'),\n      description: (e as Error).message,\n      variant: 'destructive',\n    })\n  }\n}\n\nasync function fetchAccountOptions() {\n  try {\n    accountOptions.value = await listAccounts()\n  } catch (e) {\n    toast({\n      title: t('tasks.toasts.loadAccountsFailed'),\n      description: (e as Error).message,\n      variant: 'destructive',\n    })\n  }\n}\n\nonMounted(fetchAccountOptions)\n</script>\n\n<template>\n  <div>\n    <div class=\"flex justify-between items-center mb-6\">\n      <h1 class=\"text-2xl font-bold text-gray-800\">\n        {{ t('tasks.title') }}\n      </h1>\n      <TaskCreateDialog :account-options=\"accountOptions\" @created=\"fetchTasks\" />\n    </div>\n\n    <!-- Edit Task Dialog -->\n    <Dialog v-model:open=\"isEditDialogOpen\">\n      <DialogContent class=\"sm:max-w-[640px] max-h-[85vh] overflow-y-auto\">\n        <DialogHeader>\n          <DialogTitle>{{ t('tasks.editDialog.title', { task: selectedTask?.task_name || \"\" }) }}</DialogTitle>\n        </DialogHeader>\n        <TaskForm\n          v-if=\"selectedTask\"\n          mode=\"edit\"\n          :initial-data=\"selectedTask\"\n          :account-options=\"accountOptions\"\n          :default-values=\"editDefaults\"\n          @submit=\"(data) => handleUpdateTask(data as TaskUpdate)\"\n        />\n        <DialogFooter>\n          <Button type=\"submit\" form=\"task-form\" :disabled=\"isEditSubmitting\">\n            {{ isEditSubmitting ? t('common.saving') : t('tasks.editDialog.save') }}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n\n    <!-- Refresh Criteria Dialog -->\n    <Dialog v-model:open=\"isCriteriaDialogOpen\">\n      <DialogContent class=\"sm:max-w-[600px]\">\n        <DialogHeader>\n          <DialogTitle>{{ t('tasks.criteria.title') }}</DialogTitle>\n          <DialogDescription>\n            {{ t('tasks.criteria.description') }}\n          </DialogDescription>\n        </DialogHeader>\n        <div class=\"grid gap-3\">\n          <label class=\"text-sm font-medium text-gray-700\">{{ t('tasks.form.description') }}</label>\n          <Textarea\n            v-model=\"criteriaDescription\"\n            class=\"min-h-[140px]\"\n            :placeholder=\"t('tasks.form.descriptionPlaceholder')\"\n          />\n        </div>\n        <DialogFooter>\n          <Button variant=\"outline\" @click=\"isCriteriaDialogOpen = false\">\n            {{ t('common.cancel') }}\n          </Button>\n          <Button :disabled=\"isCriteriaSubmitting\" @click=\"handleRefreshCriteria\">\n            {{ isCriteriaSubmitting ? t('tasks.criteria.generating') : t('tasks.criteria.action') }}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n\n    <div v-if=\"error\" class=\"bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4\" role=\"alert\">\n      <strong class=\"font-bold\">{{ t('common.error') }}</strong>\n      <span class=\"block sm:inline\">{{ error.message }}</span>\n    </div>\n\n    <TasksTable\n      :tasks=\"tasks\"\n      :is-loading=\"isLoading\"\n      :stopping-ids=\"stoppingTaskIds\"\n      @delete-task=\"handleDeleteTask\"\n      @edit-task=\"handleEditTask\"\n      @run-task=\"handleStartTask\"\n      @stop-task=\"handleStopTask\"\n      @refresh-criteria=\"handleOpenCriteriaDialog\"\n      @toggle-enabled=\"handleToggleEnabled\"\n    />\n\n    <Dialog v-model:open=\"isDeleteDialogOpen\">\n      <DialogContent class=\"sm:max-w-[420px]\">\n        <DialogHeader>\n          <DialogTitle>{{ t('tasks.deleteDialog.title') }}</DialogTitle>\n          <DialogDescription>\n            {{ taskToDelete ? t('tasks.deleteDialog.descriptionWithTask', { task: taskToDelete.task_name }) : t('tasks.deleteDialog.descriptionFallback') }}\n          </DialogDescription>\n        </DialogHeader>\n        <DialogFooter>\n          <Button variant=\"outline\" @click=\"isDeleteDialogOpen = false\">{{ t('common.cancel') }}</Button>\n          <Button variant=\"destructive\" @click=\"handleConfirmDeleteTask\">{{ t('tasks.deleteDialog.confirm') }}</Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  </div>\n</template>\n"
  },
  {
    "path": "web-ui/tailwind.config.cjs",
    "content": "const animate = require(\"tailwindcss-animate\")\n\nmodule.exports = {\n  darkMode: [\"class\"],\n  content: [\n    './pages/**/*.{ts,tsx,vue}',\n    './components/**/*.{ts,tsx,vue}',\n    './app/**/*.{ts,tsx,vue}',\n    './src/**/*.{ts,tsx,vue}',\n  ],\n  prefix: \"\",\n  theme: {\n    container: {\n      center: true,\n      padding: \"2rem\",\n      screens: {\n        \"2xl\": \"1400px\",\n      },\n    },\n    extend: {\n      colors: {\n        border: \"hsl(var(--border))\",\n        input: \"hsl(var(--input))\",\n        ring: \"hsl(var(--ring))\",\n        background: \"hsl(var(--background))\",\n        foreground: \"hsl(var(--foreground))\",\n        primary: {\n          DEFAULT: \"hsl(var(--primary))\",\n          foreground: \"hsl(var(--primary-foreground))\",\n        },\n        secondary: {\n          DEFAULT: \"hsl(var(--secondary))\",\n          foreground: \"hsl(var(--secondary-foreground))\",\n        },\n        destructive: {\n          DEFAULT: \"hsl(var(--destructive))\",\n          foreground: \"hsl(var(--destructive-foreground))\",\n        },\n        muted: {\n          DEFAULT: \"hsl(var(--muted))\",\n          foreground: \"hsl(var(--muted-foreground))\",\n        },\n        accent: {\n          DEFAULT: \"hsl(var(--accent))\",\n          foreground: \"hsl(var(--accent-foreground))\",\n        },\n        popover: {\n          DEFAULT: \"hsl(var(--popover))\",\n          foreground: \"hsl(var(--popover-foreground))\",\n        },\n        card: {\n          DEFAULT: \"hsl(var(--card))\",\n          foreground: \"hsl(var(--card-foreground))\",\n        },\n        // 自定义 AI 风格色\n        ai: {\n          primary: \"#3b82f6\",\n          success: \"#10b981\",\n          warning: \"#f59e0b\",\n          danger: \"#ef4444\",\n          dark: \"#0f172a\",\n        }\n      },\n      boxShadow: {\n        'glass': '0 8px 32px 0 rgba(31, 38, 135, 0.07)',\n        'card-hover': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)',\n      },\n      borderRadius: {\n        lg: \"var(--radius)\",\n        md: \"calc(var(--radius) - 2px)\",\n        sm: \"calc(var(--radius) - 4px)\",\n      },\n      keyframes: {\n        \"accordion-down\": {\n          from: { height: 0 },\n          to: { height: \"var(--radix-accordion-content-height)\" },\n        },\n        \"accordion-up\": {\n          from: { height: \"var(--radix-accordion-content-height)\" },\n          to: { height: 0 },\n        },\n        \"fade-in\": {\n          \"0%\": { opacity: 0, transform: \"translateY(10px)\" },\n          \"100%\": { opacity: 1, transform: \"translateY(0)\" },\n        }\n      },\n      animation: {\n        \"accordion-down\": \"accordion-down 0.2s ease-out\",\n        \"accordion-up\": \"accordion-up 0.2s ease-out\",\n        \"fade-in\": \"fade-in 0.4s ease-out forwards\",\n      },\n    },\n  },\n  plugins: [animate],\n}"
  },
  {
    "path": "web-ui/tsconfig.app.json",
    "content": "{\n  \"extends\": \"@vue/tsconfig/tsconfig.dom.json\",\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\",\n    \"types\": [\"vite/client\"],\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\n        \"./src/*\"\n      ]\n    },\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"erasableSyntaxOnly\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"src/**/*.ts\", \"src/**/*.tsx\", \"src/**/*.vue\"]\n}\n"
  },
  {
    "path": "web-ui/tsconfig.json",
    "content": "{\n  \"files\": [],\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\n        \"./src/*\"\n      ]\n    }\n  },\n  \"references\": [\n    { \"path\": \"./tsconfig.app.json\" },\n    { \"path\": \"./tsconfig.node.json\" }\n  ]\n}\n\n"
  },
  {
    "path": "web-ui/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\",\n    \"target\": \"ES2023\",\n    \"lib\": [\"ES2023\"],\n    \"module\": \"ESNext\",\n    \"types\": [\"node\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"erasableSyntaxOnly\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "web-ui/vite.config.ts",
    "content": "import { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'\nimport path from 'path'\n\n// https://vite.dev/config/\nexport default defineConfig({\n  plugins: [vue()],\n  build: {\n    outDir: path.resolve(__dirname, '../dist'),\n    emptyOutDir: true,\n  },\n  resolve: {\n    alias: {\n      '@': path.resolve(__dirname, './src'),\n    },\n  },\n  server: {\n    proxy: {\n      '/api': {\n        target: 'http://127.0.0.1:8000',\n        changeOrigin: true,\n      },\n      '/auth': {\n        target: 'http://127.0.0.1:8000',\n        changeOrigin: true,\n      },\n      '/ws': {\n        target: 'ws://127.0.0.1:8000',\n        ws: true,\n      },\n    },\n  },\n})\n"
  },
  {
    "path": "xianyu-login-state-privacy.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n  <meta charset=\"UTF-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n  <title>闲鱼登录态采集扩展隐私权政策</title>\n  <style>\n    body { max-width: 880px; margin: 40px auto; padding: 0 16px; font-family: -apple-system,BlinkMacSystemFont,\"Segoe UI\",Helvetica,Arial,sans-serif; line-height: 1.6; color: #222; }\n    h1, h2, h3 { color: #111; }\n    code { background: #f6f8fa; padding: 2px 4px; border-radius: 4px; }\n    ul { padding-left: 20px; }\n  </style>\n</head>\n<body>\n  <h1>闲鱼登录态采集扩展隐私权政策</h1>\n  <p>版本：2026-01-13 · 本文档说明浏览器扩展“Xianyu Login State Extractor”如何收集、使用和存储数据。</p>\n\n  <h2>单一用途</h2>\n  <p>扩展唯一用途：在用户点击“提取”时，读取当前页面的登录态 Cookie、常用请求头和浏览器环境信息，生成本地 JSON 供用户复制到闲鱼监控爬虫使用。</p>\n\n  <h2>收集的数据类型（仅在用户点击“提取”时触发）</h2>\n  <ul>\n    <li>身份验证信息：当前页面的登录 Cookie/令牌。</li>\n    <li>请求头：User-Agent、Accept、Accept-Language、Accept-Encoding、Referer、Sec-CH UA 系列字段、Origin、Cache-Control/Pragma、Upgrade-Insecure-Requests、Content-Type（如有）。</li>\n    <li>浏览器环境信息：语言、时区、平台、hardwareConcurrency、deviceMemory、maxTouchPoints、webdriver 状态、屏幕尺寸/分辨率/色深、devicePixelRatio、User-Agent。</li>\n    <li>当前页面 URL，用于生成 JSON 描述当前站点。</li>\n    <li>存储数据：localStorage/sessionStorage，仅为当前页面读取，长度超过 4096 字节的键会被丢弃并标记。</li>\n  </ul>\n\n  <h2>数据使用方式</h2>\n  <ul>\n    <li>数据仅在本地内存中拼装为 JSON，供用户手动复制；不会上传到任何服务器。</li>\n    <li>不用于广告、画像、再营销或任何与上述单一用途无关的目的。</li>\n  </ul>\n\n  <h2>存储与传输</h2>\n  <ul>\n    <li>不做持久化存储；不写入远程服务。</li>\n    <li>剪贴板复制由用户主动点击触发。</li>\n  </ul>\n\n  <h2>权限说明</h2>\n  <ul>\n    <li><code>tabs</code> / <code>activeTab</code>：仅在用户点击“提取”时访问当前标签页 URL 并注入采集脚本。</li>\n    <li><code>scripting</code>：在当前页面注入脚本读取 navigator/screen 信息、localStorage/sessionStorage。</li>\n    <li><code>webRequest</code>：在当前域名发起一次 fetch 读取请求头，生成 JSON，不拦截或修改流量。</li>\n    <li><code>storage</code>：保存用户同意状态和功能开关，不存储敏感数据。</li>\n    <li><code>cookies</code>（如使用）：读取当前页面 Cookie 以生成登录态 JSON。</li>\n  </ul>\n\n  <h2>用户同意与控制</h2>\n  <ul>\n    <li>只有在弹窗中勾选同意并点击“提取”时才会收集上述数据；不使用则不收集。</li>\n    <li>可随时关闭或卸载扩展，即停止一切收集行为。</li>\n  </ul>\n\n  <h2>数据共享</h2>\n  <p>不与任何第三方共享或出售数据。</p>\n\n  <h2>联系</h2>\n  <p>如有疑问或删除请求，请联系维护者：<a href=\"mailto:privacy@your-domain.com\">privacy@your-domain.com</a>。</p>\n</body>\n</html>\n"
  }
]