Full Code of Usagi-org/ai-goofish-monitor for AI

master 88aa5f7071ad cached
283 files
844.8 KB
224.1k tokens
886 symbols
1 requests
Download .txt
Showing preview only (916K chars total). Download the full file or copy to clipboard to get everything.
Repository: Usagi-org/ai-goofish-monitor
Branch: master
Commit: 88aa5f7071ad
Files: 283
Total size: 844.8 KB

Directory structure:
gitextract_pcspcjt1/

├── .dockerignore
├── .github/
│   └── workflows/
│       ├── claude.yml
│       └── docker-image.yml
├── .gitignore
├── AGENTS.md
├── CLAUDE.md
├── DISCLAIMER.md
├── Dockerfile
├── Dockerfile.base
├── Dockerfile.release
├── LICENSE
├── README.md
├── README_EN.md
├── chrome-extension/
│   ├── README.md
│   ├── background.js
│   ├── manifest.json
│   ├── popup.html
│   └── popup.js
├── config.json.example
├── desktop_launcher.py
├── docker-compose.dev.yaml
├── docker-compose.yaml
├── pyproject.toml
├── requirements-runtime.txt
├── requirements.txt
├── run_live_smoke.sh
├── spider_v2.py
├── src/
│   ├── __init__.py
│   ├── ai_handler.py
│   ├── ai_message_builder.py
│   ├── api/
│   │   ├── __init__.py
│   │   ├── dependencies.py
│   │   └── routes/
│   │       ├── __init__.py
│   │       ├── accounts.py
│   │       ├── dashboard.py
│   │       ├── login_state.py
│   │       ├── logs.py
│   │       ├── prompts.py
│   │       ├── results.py
│   │       ├── settings.py
│   │       ├── tasks.py
│   │       └── websocket.py
│   ├── app.py
│   ├── config.py
│   ├── core/
│   │   └── cron_utils.py
│   ├── domain/
│   │   ├── __init__.py
│   │   ├── models/
│   │   │   ├── __init__.py
│   │   │   ├── task.py
│   │   │   └── task_generation.py
│   │   └── repositories/
│   │       ├── __init__.py
│   │       └── task_repository.py
│   ├── failure_guard.py
│   ├── infrastructure/
│   │   ├── __init__.py
│   │   ├── config/
│   │   │   ├── __init__.py
│   │   │   ├── env_manager.py
│   │   │   └── settings.py
│   │   ├── external/
│   │   │   ├── __init__.py
│   │   │   ├── ai_client.py
│   │   │   └── notification_clients/
│   │   │       ├── __init__.py
│   │   │       ├── bark_client.py
│   │   │       ├── base.py
│   │   │       ├── factory.py
│   │   │       ├── gotify_client.py
│   │   │       ├── ntfy_client.py
│   │   │       ├── telegram_client.py
│   │   │       ├── webhook_client.py
│   │   │       └── wecom_bot_client.py
│   │   └── persistence/
│   │       ├── __init__.py
│   │       ├── json_task_repository.py
│   │       ├── sqlite_bootstrap.py
│   │       ├── sqlite_connection.py
│   │       ├── sqlite_task_repository.py
│   │       └── storage_names.py
│   ├── keyword_rule_engine.py
│   ├── parsers.py
│   ├── prompt_utils.py
│   ├── rotation.py
│   ├── scraper.py
│   ├── services/
│   │   ├── __init__.py
│   │   ├── account_strategy_service.py
│   │   ├── ai_request_compat.py
│   │   ├── ai_response_parser.py
│   │   ├── ai_service.py
│   │   ├── dashboard_payloads.py
│   │   ├── dashboard_service.py
│   │   ├── item_analysis_dispatcher.py
│   │   ├── notification_config_service.py
│   │   ├── notification_service.py
│   │   ├── price_history_service.py
│   │   ├── process_service.py
│   │   ├── result_export_service.py
│   │   ├── result_file_service.py
│   │   ├── result_storage_service.py
│   │   ├── scheduler_service.py
│   │   ├── search_pagination.py
│   │   ├── seller_profile_cache.py
│   │   ├── task_generation_runner.py
│   │   ├── task_generation_service.py
│   │   ├── task_log_cleanup_service.py
│   │   ├── task_payloads.py
│   │   └── task_service.py
│   └── utils.py
├── start.sh
├── tests/
│   ├── README.md
│   ├── __init__.py
│   ├── conftest.py
│   ├── fixtures/
│   │   ├── config.sample.json
│   │   ├── ratings.json
│   │   ├── search_results.json
│   │   ├── state.sample.json
│   │   ├── user_head.json
│   │   └── user_items.json
│   ├── integration/
│   │   ├── test_api_dashboard.py
│   │   ├── test_api_results.py
│   │   ├── test_api_settings.py
│   │   ├── test_api_tasks.py
│   │   ├── test_cli_spider.py
│   │   └── test_pipeline_parse.py
│   ├── live/
│   │   ├── _support.py
│   │   ├── conftest.py
│   │   └── test_live_smoke.py
│   ├── test_failure_guard.py
│   ├── test_frontend_build_paths.py
│   └── unit/
│       ├── test_ai_client.py
│       ├── test_ai_handler_analysis.py
│       ├── test_ai_handler_downloads.py
│       ├── test_ai_request_compat.py
│       ├── test_ai_response_parser.py
│       ├── test_app_lifespan.py
│       ├── test_cron_utils.py
│       ├── test_domain_task.py
│       ├── test_item_analysis_dispatcher.py
│       ├── test_keyword_rule_engine.py
│       ├── test_notification_service.py
│       ├── test_price_history_service.py
│       ├── test_process_service.py
│       ├── test_prompt_utils.py
│       ├── test_scraper_browser_channel.py
│       ├── test_search_pagination.py
│       ├── test_seller_profile_cache.py
│       ├── test_task_log_cleanup_service.py
│       └── test_utils.py
├── web-ui/
│   ├── .gitignore
│   ├── .vscode/
│   │   └── extensions.json
│   ├── Dockerfile
│   ├── README.md
│   ├── components.json
│   ├── index.html
│   ├── nginx.conf
│   ├── package.json
│   ├── postcss.config.cjs
│   ├── src/
│   │   ├── App.vue
│   │   ├── api/
│   │   │   ├── accounts.ts
│   │   │   ├── dashboard.ts
│   │   │   ├── logs.ts
│   │   │   ├── prompts.ts
│   │   │   ├── results.ts
│   │   │   ├── settings.ts
│   │   │   └── tasks.ts
│   │   ├── assets/
│   │   │   └── main.css
│   │   ├── components/
│   │   │   ├── HelloWorld.vue
│   │   │   ├── layout/
│   │   │   │   ├── DashboardTaskSearch.vue
│   │   │   │   ├── LocaleToggle.vue
│   │   │   │   ├── TheHeader.vue
│   │   │   │   └── TheSidebar.vue
│   │   │   ├── results/
│   │   │   │   ├── PriceTrendChart.vue
│   │   │   │   ├── ResultCard.vue
│   │   │   │   ├── ResultsFilterBar.vue
│   │   │   │   ├── ResultsGrid.vue
│   │   │   │   └── ResultsInsightsPanel.vue
│   │   │   ├── settings/
│   │   │   │   ├── NotificationSettingsPanel.vue
│   │   │   │   └── RotationSettingsPanel.vue
│   │   │   ├── tasks/
│   │   │   │   ├── TaskCreateDialog.vue
│   │   │   │   ├── TaskForm.vue
│   │   │   │   ├── TaskGenerationDialog.vue
│   │   │   │   ├── TaskGenerationProgress.vue
│   │   │   │   ├── TaskRegionSelector.vue
│   │   │   │   └── TasksTable.vue
│   │   │   └── ui/
│   │   │       ├── badge/
│   │   │       │   ├── Badge.vue
│   │   │       │   └── index.ts
│   │   │       ├── button/
│   │   │       │   ├── Button.vue
│   │   │       │   └── index.ts
│   │   │       ├── card/
│   │   │       │   ├── Card.vue
│   │   │       │   ├── CardContent.vue
│   │   │       │   ├── CardDescription.vue
│   │   │       │   ├── CardFooter.vue
│   │   │       │   ├── CardHeader.vue
│   │   │       │   ├── CardTitle.vue
│   │   │       │   └── index.ts
│   │   │       ├── checkbox/
│   │   │       │   ├── Checkbox.vue
│   │   │       │   └── index.ts
│   │   │       ├── dialog/
│   │   │       │   ├── Dialog.vue
│   │   │       │   ├── DialogClose.vue
│   │   │       │   ├── DialogContent.vue
│   │   │       │   ├── DialogDescription.vue
│   │   │       │   ├── DialogFooter.vue
│   │   │       │   ├── DialogHeader.vue
│   │   │       │   ├── DialogScrollContent.vue
│   │   │       │   ├── DialogTitle.vue
│   │   │       │   ├── DialogTrigger.vue
│   │   │       │   └── index.ts
│   │   │       ├── input/
│   │   │       │   ├── Input.vue
│   │   │       │   └── index.ts
│   │   │       ├── label/
│   │   │       │   ├── Label.vue
│   │   │       │   └── index.ts
│   │   │       ├── select/
│   │   │       │   ├── Select.vue
│   │   │       │   ├── SelectContent.vue
│   │   │       │   ├── SelectGroup.vue
│   │   │       │   ├── SelectItem.vue
│   │   │       │   ├── SelectItemText.vue
│   │   │       │   ├── SelectLabel.vue
│   │   │       │   ├── SelectScrollDownButton.vue
│   │   │       │   ├── SelectScrollUpButton.vue
│   │   │       │   ├── SelectSeparator.vue
│   │   │       │   ├── SelectTrigger.vue
│   │   │       │   ├── SelectValue.vue
│   │   │       │   └── index.ts
│   │   │       ├── switch/
│   │   │       │   ├── Switch.vue
│   │   │       │   └── index.ts
│   │   │       ├── table/
│   │   │       │   ├── Table.vue
│   │   │       │   ├── TableBody.vue
│   │   │       │   ├── TableCaption.vue
│   │   │       │   ├── TableCell.vue
│   │   │       │   ├── TableHead.vue
│   │   │       │   ├── TableHeader.vue
│   │   │       │   ├── TableRow.vue
│   │   │       │   └── index.ts
│   │   │       ├── tabs/
│   │   │       │   ├── Tabs.vue
│   │   │       │   ├── TabsContent.vue
│   │   │       │   ├── TabsList.vue
│   │   │       │   ├── TabsTrigger.vue
│   │   │       │   └── index.ts
│   │   │       ├── textarea/
│   │   │       │   ├── Textarea.vue
│   │   │       │   └── index.ts
│   │   │       └── toast/
│   │   │           ├── Toast.vue
│   │   │           ├── ToastAction.vue
│   │   │           ├── ToastClose.vue
│   │   │           ├── ToastDescription.vue
│   │   │           ├── ToastProvider.vue
│   │   │           ├── ToastTitle.vue
│   │   │           ├── ToastViewport.vue
│   │   │           ├── Toaster.vue
│   │   │           ├── index.ts
│   │   │           └── use-toast.ts
│   │   ├── composables/
│   │   │   ├── useAuth.ts
│   │   │   ├── useDashboard.ts
│   │   │   ├── useLogs.ts
│   │   │   ├── useMobileNav.ts
│   │   │   ├── useResults.ts
│   │   │   ├── useSettings.ts
│   │   │   ├── useTaskGenerationJob.ts
│   │   │   ├── useTasks.ts
│   │   │   └── useWebSocket.ts
│   │   ├── i18n/
│   │   │   ├── index.ts
│   │   │   └── messages/
│   │   │       ├── en-US-extra.ts
│   │   │       ├── en-US.ts
│   │   │       ├── zh-CN-extra.ts
│   │   │       └── zh-CN.ts
│   │   ├── layouts/
│   │   │   └── MainLayout.vue
│   │   ├── lib/
│   │   │   ├── http.ts
│   │   │   ├── taskFormQuery.ts
│   │   │   ├── taskSchedule.ts
│   │   │   └── utils.ts
│   │   ├── main.ts
│   │   ├── router/
│   │   │   └── index.ts
│   │   ├── services/
│   │   │   └── websocket.ts
│   │   ├── style.css
│   │   ├── types/
│   │   │   ├── dashboard.d.ts
│   │   │   ├── result.d.ts
│   │   │   └── task.d.ts
│   │   └── views/
│   │       ├── AccountsView.vue
│   │       ├── DashboardView.vue
│   │       ├── LoginView.vue
│   │       ├── LogsView.vue
│   │       ├── ResultsView.vue
│   │       ├── SettingsView.vue
│   │       └── TasksView.vue
│   ├── tailwind.config.cjs
│   ├── tsconfig.app.json
│   ├── tsconfig.json
│   ├── tsconfig.node.json
│   └── vite.config.ts
└── xianyu-login-state-privacy.html

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

================================================
FILE: .dockerignore
================================================
__pycache__
*.pyc
.env
.venv
venv
.idea
.claude
.serena
.pytest_cache
logs/
jsonl/
web-ui/node_modules
web-ui/dist
dist/
.git
.DS_Store
task_images/
images/
archive/
tests/
data/
price_history/
*.md
!README.md


================================================
FILE: .github/workflows/claude.yml
================================================
name: Claude Code

on:
  issue_comment:
    types: [created]
  pull_request_review_comment:
    types: [created]
  issues:
    types: [opened, assigned]
  pull_request_review:
    types: [submitted]

jobs:
  claude:
    if: |
      (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
      (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
      (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
      (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: read
      issues: read
      id-token: write
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 1

      - name: Prepare Environment
        run: |
          curl -fsSL https://bun.sh/install | bash
          mkdir -p $HOME/.claude-code-router
          cat << 'EOF' > $HOME/.claude-code-router/config.json
          {
            "log": true,
            "NON_INTERACTIVE_MODE": true,
            "OPENAI_API_KEY": "${{ secrets.OPENAI_API_KEY }}",
            "OPENAI_BASE_URL": "https://api-inference.modelscope.cn/v1",
            "OPENAI_MODEL": "MiniMax/MiniMax-M2.5"
          }
          EOF
        shell: bash

      - name: Start Claude Code Router
        run: |
          nohup ~/.bun/bin/bunx @musistudio/claude-code-router@1.0.8 start &
        shell: bash

      - name: Run Claude Code
        id: claude
        uses: anthropics/claude-code-action@beta
        env:
          ANTHROPIC_BASE_URL: http://localhost:3456
        with:
          anthropic_api_key: "Any-string-is-ok"


================================================
FILE: .github/workflows/docker-image.yml
================================================
name: Docker Image CI on merge to master

on:
  workflow_dispatch:
  pull_request:
    types: [closed]
    branches: ["master"]

env:
  IMAGE_NAME: ai-goofish
  BASE_IMAGE_NAME: ai-goofish-base

jobs:
  build-base:
    if: |
      github.event_name == 'workflow_dispatch' ||
      (github.event_name == 'pull_request' && github.event.pull_request.merged == true)
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      base_image: ${{ steps.prepare.outputs.base_image }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

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

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

      - name: Prepare image tags
        id: prepare
        env:
          REPO_OWNER: ${{ github.repository_owner }}
          BASE_IMAGE_NAME: ${{ env.BASE_IMAGE_NAME }}
        run: |
          set -euo pipefail

          owner_lower=$(echo "$REPO_OWNER" | tr '[:upper:]' '[:lower:]')
          base_image="ghcr.io/${owner_lower}/${BASE_IMAGE_NAME}:latest"

          base_tags=(
            "${base_image}"
            "ghcr.io/${owner_lower}/${BASE_IMAGE_NAME}:${GITHUB_SHA}"
          )

          {
            echo "base_image=${base_image}"
            echo 'base_tags<<EOF'
            printf '%s\n' "${base_tags[@]}"
            echo EOF
          } >> "$GITHUB_OUTPUT"

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

      - name: Build and push base Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile.base
          platforms: linux/amd64,linux/arm64
          pull: true
          push: true
          tags: ${{ steps.prepare.outputs.base_tags }}
          cache-from: type=gha,scope=ai-goofish-base-docker
          cache-to: type=gha,scope=ai-goofish-base-docker,mode=max

  build-and-push:
    needs: build-base
    if: ${{ needs.build-base.result == 'success' }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

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

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

      - name: Prepare release tags
        id: prepare
        env:
          REPO_OWNER: ${{ github.repository_owner }}
          IMAGE_NAME: ${{ env.IMAGE_NAME }}
        run: |
          set -euo pipefail

          owner_lower=$(echo "$REPO_OWNER" | tr '[:upper:]' '[:lower:]')

          app_tags=(
            "ghcr.io/${owner_lower}/${IMAGE_NAME}:latest"
            "ghcr.io/${owner_lower}/${IMAGE_NAME}:${GITHUB_SHA}"
          )

          {
            echo 'app_tags<<EOF'
            printf '%s\n' "${app_tags[@]}"
            echo EOF
          } >> "$GITHUB_OUTPUT"

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

      - name: Build and push multi-arch Docker images
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile.release
          platforms: linux/amd64,linux/arm64
          pull: true
          push: true
          build-args: |
            BASE_IMAGE=${{ needs.build-base.outputs.base_image }}
          tags: ${{ steps.prepare.outputs.app_tags }}
          cache-from: type=gha,scope=ai-goofish-release-docker
          cache-to: type=gha,scope=ai-goofish-release-docker,mode=max


================================================
FILE: .gitignore
================================================
.idea
*.iml
xianyu_state.json
.env
.aider*
images/
logs/
jsonl/
__pycache__/
src/__pycache__/
dist/
state/
config.json
prompts/
.serena/
price_history/
data/


================================================
FILE: AGENTS.md
================================================
# Repository Guidelines

## 项目结构与模块组织
- 后端位于 `src/`,入口 `src/app.py`,API 路由在 `src/api/routes/`,服务层在 `src/services/`,领域模型在 `src/domain/`,基础设施在 `src/infrastructure/`。
- 前端在 `web-ui/`(Vue 3 + Vite),视图放于 `web-ui/src/views/`,组件在 `web-ui/src/components/`,构建产物会复制到根目录 `dist/`。
- 测试位于 `tests/`,命名遵循 `test_*.py` 或 `tests/*/test_*.py`。
- 运行数据与资源:`prompts/`、`jsonl/`、`logs/`、`images/`、`static/`、`state/`,配置文件 `config.json` 与 `.env` 位于仓库根目录。

## 构建、测试与本地开发
- 后端开发:`python -m src.app` 或 `uvicorn src.app:app --host 0.0.0.0 --port 8000 --reload`。
- 爬虫任务:`python spider_v2.py --task-name "MacBook Air M1" --debug-limit 3`(可用 `--config` 指定自定义配置)。
- 前端开发:`cd web-ui && npm install && npm run dev`;构建:`cd web-ui && npm run build`(产物复制到根目录 `dist/`)。
- 一键本地启动:`bash start.sh`(自动安装依赖、前端构建并启动后端)。
- Docker:`docker compose up --build -d`,查看日志 `docker compose logs -f app`,停止 `docker compose down`。

## 编码风格与命名约定
- 保持分层:API → services → domain → infrastructure,避免跨层耦合,模块保持精简。
- Python 测试函数命名为 `test_*`,文件与路径遵循上述测试目录规范。
- 使用描述性、任务导向的命名(如爬虫任务名、配置键),与业务含义对应。

## 架构与运行时
- 后端使用 FastAPI 提供 API 与静态资源,爬虫与 AI 推理在独立任务进程中协作,前后端通过 HTTP/Web UI 交互。
- 任务运行会在 `jsonl/` 写入结果、在 `logs/` 留存运行日志、在 `images/` 下载图片,前端监控页面依赖这些数据。
- 默认监听 8000 端口,前端构建后静态文件可由后端或 Docker 镜像直接提供。

## 测试指南
- 测试框架:`pytest`(默认同步测试,无需 `pytest-asyncio`)。
- 运行全部测试:`pytest`;覆盖率:`pytest --cov=src` 或 `coverage run -m pytest`;定向测试:`pytest tests/test_utils.py::test_safe_get`。
- 优先覆盖核心服务、爬虫管道的异常分支与重试逻辑,避免回归。
- PR 前请运行相关测试,新增逻辑补充针对性用例。

## 提交与 PR 规范
- Commit 采用类 Conventional Commits:`feat(...)`、`fix(...)`、`refactor(...)`、`chore(...)`、`docs(...)` 等。
- PR 需说明变更范围与影响模块;UI 变更在 `web-ui/` 提供截图;关联相关 Issue;提及配置或迁移步骤。

## 安全与配置提示
- 复制 `.env.example` 为 `.env`,设置必填项 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL_NAME` 等。
- 不要提交真实凭据或 cookies(如 `state.json`);Playwright 需本地浏览器,Docker 镜像已预装 Chromium。
- Web 认证默认 `admin/admin123`,生产环境务必修改,推荐启用 HTTPS 并限制访问来源。


================================================
FILE: CLAUDE.md
================================================
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## 项目概述

基于 Playwright + AI 的闲鱼智能监控机器人。FastAPI 后端 + Vue 3 前端,支持多任务并发监控、多模态 AI 商品分析、多渠道通知推送。

## 核心架构

```
API层 (src/api/routes/)
    ↓
服务层 (src/services/)
    ↓
领域层 (src/domain/)
    ↓
基础设施层 (src/infrastructure/)
```

关键入口:
- `src/app.py` - FastAPI 应用主入口
- `spider_v2.py` - 爬虫 CLI 入口
- `src/scraper.py` - Playwright 爬虫核心逻辑

服务层:
- `TaskService` - 任务 CRUD
- `ProcessService` - 爬虫子进程管理
- `SchedulerService` - APScheduler 定时调度
- `AIAnalysisService` - 多模态 AI 分析
- `NotificationService` - 多渠道通知(ntfy/Bark/企业微信/Telegram/Webhook)

前端 (`web-ui/`):Vue 3 + Vite + shadcn-vue + Tailwind CSS

## 开发命令

```bash
# 后端开发
python -m src.app
# 或
uvicorn src.app:app --host 0.0.0.0 --port 8000 --reload

# 前端开发
cd web-ui && npm install && npm run dev

# 前端构建
cd web-ui && npm run build

# 一键本地启动(构建前端 + 启动后端)
bash start.sh

# Docker 部署
docker compose up --build -d
```

## 爬虫命令

```bash
python spider_v2.py                          # 运行所有启用任务
python spider_v2.py --task-name "MacBook"    # 运行指定任务
python spider_v2.py --debug-limit 3          # 调试模式,限制商品数
python spider_v2.py --config custom.json     # 自定义配置文件
```

## 测试

```bash
pytest                              # 运行所有测试
pytest --cov=src                    # 覆盖率报告
pytest tests/unit/test_utils.py    # 运行单个测试文件
pytest tests/unit/test_utils.py::test_safe_get  # 运行单个测试函数
```

测试规范:文件 `tests/**/test_*.py`,函数 `test_*`

## 配置

环境变量 (`.env`):
- AI 模型:`OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL_NAME`
- 通知:`NTFY_TOPIC_URL`, `BARK_URL`, `WX_BOT_URL`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`
- 爬虫:`RUN_HEADLESS`, `LOGIN_IS_EDGE`
- Web 认证:`WEB_USERNAME`, `WEB_PASSWORD`
- 端口:`SERVER_PORT`

任务配置 (`config.json`):定义监控任务(关键词、价格范围、cron 表达式、AI prompt 文件等)

## 数据流

1. Web UI / config.json 创建任务
2. SchedulerService 按 cron 触发或手动启动
3. ProcessService 启动 spider_v2.py 子进程
4. scraper.py 使用 Playwright 抓取商品
5. AIAnalysisService 调用多模态模型分析
6. NotificationService 推送符合条件的商品
7. 结果存储:`jsonl/`(数据)、`images/`(图片)、`logs/`(日志)

## 注意事项

- AI 模型必须支持图片上传(多模态)
- Docker 部署需通过 Web UI 手动更新登录状态(`state.json`)
- 遇到滑动验证码时设置 `RUN_HEADLESS=false` 手动处理
- 生产环境务必修改默认 Web 认证密码


================================================
FILE: DISCLAIMER.md
================================================
# 免责声明 / Disclaimer

## 中文版本

本项目是一个开源软件,仅供学习和研究目的使用。使用者在使用本软件时,必须遵守所在国家/地区的所有相关法律法规。

项目作者及贡献者明确声明:

1. 本项目仅用于技术学习和研究目的,不得用于任何违法或不道德的活动。
2. 使用者对本软件的使用行为承担全部责任,包括但不限于任何修改、分发或商业应用。
3. 项目作者及贡献者不对因使用本软件而导致的任何直接、间接、附带或特殊的损害或损失承担责任,即使已被告知可能发生此类损害。
4. 如果您的使用行为违反了所在司法管辖区的法律,请立即停止使用并删除本软件。
5. 本项目按"现状"提供,不提供任何形式的担保,包括但不限于适销性、特定用途适用性和非侵权性担保。

本项目采用 MIT 许可证发布。根据该许可证,您可以自由使用、复制、修改、分发本软件,但必须保留原始版权声明和本免责声明。

项目作者保留随时更改本免责声明的权利,恕不另行通知。使用本软件即表示您同意受本免责声明条款的约束。

## English Version

This 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.

The project owner and contributors explicitly state:

1. This project is for technical learning and research purposes only and must not be used for any illegal or unethical activities.
2. Users assume full responsibility for their use of the software, including but not limited to any modifications, distributions, or commercial applications.
3. 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.
4. If your use violates the laws of your jurisdiction, please stop using and delete this software immediately.
5. 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.

This 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.

The 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.

================================================
FILE: Dockerfile
================================================
# Stage 1: Build the Vue application
FROM node:22-alpine AS frontend-builder
WORKDIR /web-ui
COPY web-ui/package*.json ./
RUN npm ci
COPY web-ui/ .
RUN npm run build

# Stage 2: Build the python environment with dependencies
FROM python:3.11-slim-bookworm AS builder

# 设置环境变量以防止交互式提示
ENV DEBIAN_FRONTEND=noninteractive \
    VIRTUAL_ENV=/opt/venv \
    PATH="/opt/venv/bin:$PATH"

# 创建虚拟环境并安装 Python 运行时依赖
RUN python3 -m venv $VIRTUAL_ENV
COPY requirements-runtime.txt .
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements-runtime.txt

# Stage 3: Create the final, lean image
FROM python:3.11-slim-bookworm

WORKDIR /app
ENV DEBIAN_FRONTEND=noninteractive \
    VIRTUAL_ENV=/opt/venv \
    PATH="/opt/venv/bin:$PATH" \
    PYTHONUNBUFFERED=1 \
    RUNNING_IN_DOCKER=true \
    PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
    TZ=Asia/Shanghai

COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        tzdata \
        tini \
        libzbar0 \
    && playwright install --with-deps --no-shell chromium \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

COPY --from=frontend-builder /dist /app/dist

COPY src /app/src
COPY spider_v2.py /app/spider_v2.py
COPY prompts /app/prompts
COPY static /app/static
COPY config.json.example /app/config.json.example

RUN mkdir -p /app/data /app/state /app/logs /app/images /app/jsonl /app/price_history

EXPOSE 8000

USER root

ENTRYPOINT ["tini", "--"]

CMD ["python", "-m", "src.app"]


================================================
FILE: Dockerfile.base
================================================
# syntax=docker/dockerfile:1.7

FROM python:3.11-slim-bookworm

WORKDIR /app

ENV DEBIAN_FRONTEND=noninteractive \
    VIRTUAL_ENV=/opt/venv \
    PATH="/opt/venv/bin:$PATH" \
    PYTHONUNBUFFERED=1 \
    RUNNING_IN_DOCKER=true \
    PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
    TZ=Asia/Shanghai

COPY requirements-runtime.txt /tmp/requirements-runtime.txt

RUN python3 -m venv "$VIRTUAL_ENV"

RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r /tmp/requirements-runtime.txt

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        tzdata \
        tini \
        libzbar0 \
    && playwright install --with-deps --no-shell chromium \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/* /tmp/requirements-runtime.txt

RUN mkdir -p /app/data /app/state /app/logs /app/images /app/jsonl /app/price_history

ENTRYPOINT ["tini", "--"]


================================================
FILE: Dockerfile.release
================================================
# syntax=docker/dockerfile:1.7

ARG BASE_IMAGE=ghcr.io/usagi-org/ai-goofish-base:latest

FROM node:22-alpine AS frontend-builder

WORKDIR /web-ui

COPY web-ui/package*.json ./

RUN --mount=type=cache,target=/root/.npm npm ci

COPY web-ui/ .

RUN npm run build

FROM ${BASE_IMAGE}

WORKDIR /app

COPY --from=frontend-builder /dist /app/dist

COPY src /app/src
COPY spider_v2.py /app/spider_v2.py
COPY prompts /app/prompts
COPY static /app/static
COPY config.json.example /app/config.json.example

EXPOSE 8000

CMD ["python", "-m", "src.app"]


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

Copyright (c) 2025 dingyufei615

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

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

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

================================================
FILE: README.md
================================================
# 闲鱼智能监控机器人

[English README](README_EN.md)

基于 Playwright 和 AI 的闲鱼多任务实时监控工具,提供完整的 Web 管理界面。

## 核心特性

- **Web 可视化管理**: 任务管理、账号管理、AI 标准编辑、运行日志、结果浏览
- **AI 驱动**: 自然语言创建任务,多模态模型深度分析商品
- **多任务并发**: 独立配置关键词、价格、筛选条件和 AI Prompt
- **高级筛选**: 包邮、新发布时间范围、省/市/区三级区域筛选
- **即时通知**: 支持 ntfy.sh、企业微信、Bark、Telegram、Webhook
- **定时调度**: Cron 表达式配置周期性任务
- **账号与代理轮换**: 多账号管理、任务绑定账号、代理池轮换与失败重试
- **Docker 部署**: 一键容器化部署

## 截图

![监控概览](static/img.png)
![任务管理](static/img_1.png)
![结果查看](static/img_2.png)
![通知推送](static/img_3.png)

## 🐳 Docker 部署(推荐)

```bash
git clone https://github.com/Usagi-org/ai-goofish-monitor && cd ai-goofish-monitor
cp .env.example .env
vim .env # 填写相关配置项
docker compose up -d
docker compose logs -f app
docker compose down
```

如果镜像无法访问或下载速度慢,可尝试使用加速:
```bash

docker pull ghcr.nju.edu.cn/usagi-org/ai-goofish:latest
docker tag ghcr.nju.edu.cn/usagi-org/ai-goofish:latest ghcr.io/usagi-org/ai-goofish:latest
docker compose up -d

```

- 默认 Web UI 地址:`http://127.0.0.1:8000`
- Docker 镜像已内置 Chromium,无需宿主机额外安装浏览器。
- 官方镜像地址:`ghcr.io/usagi-org/ai-goofish:latest`
- 更新镜像:`docker compose pull && docker compose up -d`
- 如果你修改了 `.env` 中的 `SERVER_PORT`,请同步更新 `docker-compose.yaml` 里的端口映射。
- `docker-compose.yaml` 默认会把 SQLite 主库挂载到 `./data:/app/data`,数据库文件默认为 `data/app.sqlite3`
- 目前默认持久化这些目录:
    - `data/`  SQLite 主存储(任务、结果、价格历史)
    - `state/`  登录状态 cookie 文件
    - `prompts/`  任务提示词
    - `logs/`  运行日志
    - `images/`  商品图片与任务临时图片目录
    - `config.json`、`jsonl/`、`price_history/`  首次升级到 SQLite 时用于兼容导入的旧数据源

### 数据存储与迁移

- 当前在线主存储为 SQLite,默认路径 `data/app.sqlite3`
- 可通过环境变量 `APP_DATABASE_FILE` 自定义数据库路径;Docker 默认设置为 `/app/data/app.sqlite3`
- 应用启动时会自动建库建表,并尝试从旧的 `config.json`、`jsonl/`、`price_history/` 导入一次历史数据
- `state/`、`prompts/`、`logs/`、`images/` 仍然是文件系统目录,不在 SQLite 中
- 商品图片会临时落到 `images/task_images_<task_name>/`,任务结束后默认会清理
- 首次升级完成并确认 `data/app.sqlite3` 中数据正确后,可视部署方式决定是否继续保留旧的 `config.json`、`jsonl/`、`price_history/` 挂载

### 最少配置

| 变量 | 说明 | 必填 |
|------|------|------|
| `OPENAI_API_KEY` | AI 模型 API Key | 是 |
| `OPENAI_BASE_URL` | OpenAI 兼容接口地址 | 是 |
| `OPENAI_MODEL_NAME` | 支持图片输入的模型名称 | 是 |
| `WEB_USERNAME` / `WEB_PASSWORD` | Web UI 登录账号密码,默认 `admin/admin123` | 否 |

其余配置见下方“配置说明”。


### 第一次使用

1. 打开默认 Web UI `http://127.0.0.1:8000` 并登录。
2. 进入“闲鱼账号管理”,使用 [Chrome 扩展](https://chromewebstore.google.com/detail/xianyu-login-state-extrac/eidlpfjiodpigmfcahkmlenhppfklcoa) 导出并粘贴闲鱼登录态 JSON。
3. 登录态文件会保存到 `state/` 目录,例如 `state/acc_1.json`。
4. 回到“任务管理”,创建任务并绑定账号后即可运行。

### 创建第一个任务

- `AI判断`:填写“详细需求”,提交后会弹出独立进度弹窗,后台异步生成分析标准。
- `关键词判断`:填写关键词规则,任务会直接创建,不经过 AI 生成流程。
- `区域筛选`:已改为省 / 市 / 区三级选择器,数据基于闲鱼页面抓取快照内置。



## 用户使用说明

<details>
<summary>点击展开 Web UI 功能说明</summary>

### 任务管理

- 支持 AI 创建、关键词规则、价格范围、新发布范围、区域筛选、账号绑定、定时规则。
- AI 任务创建是后台 job 流程,提交后会打开单独的进度弹窗。
- 区域筛选会显著缩小结果集,默认留空。

### 账号管理

- 支持导入、更新、删除闲鱼账号登录态。
- 每个任务可指定账号,也可不绑定并交给系统自动选择。

### 结果查看与运行日志

- 结果页和导出功能现在从 SQLite 查询,不再直接扫描 `jsonl` 文件。
- 日志页按任务展示运行过程,便于排查登录态失效、风控和 AI 调用问题。

### 系统设置

- 可查看系统状态、编辑 Prompt、调整代理与轮换相关配置。

</details>



## 开发者开发

### 环境要求

- Python 3.10+
- Node.js + npm(本地验证 `Node v20.18.3` 可完成前端构建)
- Playwright CLI 与 Chromium,首次运行前建议执行 `python3 -m pip install playwright && python3 -m playwright install chromium`
- Chrome / Edge 浏览器(Linux 环境也可使用 Chromium;`start.sh` 会先检查浏览器是否存在)

```bash
git clone https://github.com/Usagi-org/ai-goofish-monitor
cd ai-goofish-monitor
cp .env.example .env
```

### 一键启动

```bash
chmod +x start.sh
./start.sh
```

`start.sh` 会先检查 Playwright CLI 和浏览器前置条件;在前置条件满足后自动安装项目依赖、构建前端、复制构建产物并启动后端。

### 手动启动

```bash
# 后端
python -m src.app
# 或
uvicorn src.app:app --host 0.0.0.0 --port 8000 --reload

# 前端
cd web-ui
npm install
npm run dev
```

- FastAPI 启动时会自动初始化 SQLite,并在首次启动时尝试导入旧的 `config.json/jsonl/price_history`
- `spider_v2.py` 默认从 SQLite 读取任务;只有显式传入 `--config <path>` 时才会走 JSON 配置兼容模式
- 默认数据库路径为 `data/app.sqlite3`
- Vite 开发服务器会将 `/api`、`/auth`、`/ws` 代理到 `http://127.0.0.1:8000`。
- `npm run build` 先生成 `web-ui/dist/`,`start.sh` 再复制到仓库根目录 `dist/`。
- FastAPI 负责提供根目录 `dist/index.html` 和 `dist/assets/`。
- `./start.sh` 默认输出访问地址 `http://localhost:8000` 和 API 文档 `http://localhost:8000/docs`。

### 测试与校验

```bash
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest
cd web-ui && npm run build
```

### 任务创建 API

<details>
<summary>点击展开 API 行为说明</summary>

- `POST /api/tasks/generate`
  - `decision_mode=ai`:返回 `202` 和 `job`,需要继续轮询进度。
  - `decision_mode=keyword`:直接返回已创建任务。
- `GET /api/tasks/generate-jobs/{job_id}`:查询 AI 任务生成进度。
- `POST /auth/status`:校验 Web UI 登录凭据。

</details>

## 配置说明

<details>
<summary>点击展开常用配置项</summary>

### AI 与运行时

- `OPENAI_API_KEY` / `OPENAI_BASE_URL` / `OPENAI_MODEL_NAME`:AI 模型接入必填项。
- `PROXY_URL`:为 AI 请求单独指定 HTTP/SOCKS5 代理。
- `RUN_HEADLESS`:是否以无头模式运行爬虫;Docker 中应保持 `true`。
- `SERVER_PORT`:后端监听端口,默认 `8000`。
- `LOGIN_IS_EDGE`:本地环境可切换为 Edge 内核;Docker 镜像未内置 Edge,容器内会固定使用 Chromium。
- `PCURL_TO_MOBILE`:是否将 PC 商品链接转换为移动端链接。

### 通知

- `NTFY_TOPIC_URL`
- `GOTIFY_URL` / `GOTIFY_TOKEN`
- `BARK_URL`
- `WX_BOT_URL`
- `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` / `TELEGRAM_API_BASE_URL`
- `WEBHOOK_*`

### 代理轮换与失败保护

- `PROXY_ROTATION_ENABLED`
- `PROXY_ROTATION_MODE`
- `PROXY_POOL`
- `PROXY_ROTATION_RETRY_LIMIT`
- `PROXY_BLACKLIST_TTL`
- `TASK_FAILURE_THRESHOLD`
- `TASK_FAILURE_PAUSE_SECONDS`
- `TASK_FAILURE_GUARD_PATH`

完整示例见 `.env.example`。

</details>

## Web 界面认证

<details>
<summary>点击展开认证说明</summary>

- Web UI 当前使用登录页收集账号密码,并通过 `POST /auth/status` 校验。
- 登录成功后,前端会在浏览器本地保存登录状态,用于路由守卫和 WebSocket 初始化。
- 默认账号密码为 `admin/admin123`,生产环境请务必修改。

</details>

## 🚀 工作流程

下图描述了单个监控任务从启动到完成的核心处理逻辑。主服务运行于 `src.app`,按用户操作或定时调度启动一个或多个任务进程。

```mermaid
graph TD
    A[启动监控任务] --> B[选择账号/代理配置];
    B --> C[任务: 搜索商品];
    C --> D{发现新商品?};
    D -- 是 --> E[抓取商品详情 & 卖家信息];
    E --> F[下载商品图片];
    F --> G[调用AI进行分析];
    G --> H{AI是否推荐?};
    H -- 是 --> I[发送通知];
    H -- 否 --> J[保存记录到 SQLite];
    I --> J;
    D -- 否 --> K[翻页/等待];
    K --> C;
    J --> C;
    C --> L{触发风控/异常?};
    L -- 是 --> M[账号/代理轮换并重试];
    M --> C;
```

## 常见问题

<details>
<summary>点击展开常见问题</summary>

### AI 任务创建为什么不是立即完成?

AI 模式会先生成分析标准,再创建任务。现在该流程已改为后台 job,提交后会显示独立进度弹窗,避免表单长时间卡住。

### 区域筛选为什么默认建议留空?

区域筛选会显著减少搜索结果,适合明确只看某个区域的场景。若你先验证整体市场,建议先不填。

### 本地页面打开后提示前端构建产物不存在?

说明根目录 `dist/` 缺失。可直接执行 `./start.sh`,或先在 `web-ui/` 里执行 `npm run build`,再确认构建产物已复制到仓库根目录。

### `./start.sh` 为什么提示缺少 Playwright 或浏览器?

这是脚本的前置检查。请先安装 Playwright CLI 与 Chromium,并确保系统中可用 Chrome / Edge(Linux 环境也可用 Chromium),然后重新执行 `./start.sh`。

</details>



## 致谢

<details>
<summary>点击展开致谢内容</summary>

本项目在开发过程中参考了以下优秀项目,特此感谢:

- [superboyyy/xianyu_spider](https://github.com/superboyyy/xianyu_spider)

以及感谢LinuxDo相关人员的脚本贡献

- [@jooooody](https://linux.do/u/jooooody/summary)

以及感谢 [LinuxDo](https://linux.do/) 社区。

以及感谢 ClaudeCode/Gemini/Codex 等模型工具,解放双手 体验Vibe Coding的快乐。

</details>


## 注意事项

<details>
<summary>点击展开注意事项详情</summary>

- 请遵守闲鱼的用户协议和robots.txt规则,不要进行过于频繁的请求,以免对服务器造成负担或导致账号被限制。
- 本项目仅供学习和技术研究使用,请勿用于非法用途。
- 本项目采用 [MIT 许可证](LICENSE) 发布,按"现状"提供,不提供任何形式的担保。
- 项目作者及贡献者不对因使用本软件而导致的任何直接、间接、附带或特殊的损害或损失承担责任。
- 如需了解更多详细信息,请查看 [免责声明](DISCLAIMER.md) 文件。

</details>

## Star History

[![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)

![Alt](https://repobeats.axiom.co/api/embed/b40d8a112271b4bddabadd8fe2635be3c1aa28a3.svg "Repobeats analytics image")


================================================
FILE: README_EN.md
================================================
# Xianyu Intelligent Monitor Bot

[中文说明](README.md)

A Playwright and AI-powered multi-task real-time monitoring tool for Xianyu (闲鱼), featuring a complete web management interface.

## Core Features

- **Web Visual Management**: Task management, account management, AI criteria editing, run logs, results browsing
- **AI-Driven**: Natural language task creation, multimodal model for in-depth product analysis
- **Multi-Task Concurrency**: Independent configuration for keywords, prices, filters, and AI prompts
- **SQLite as Primary Storage**: Tasks, results, and price history are persisted in one embedded database instead of repeatedly scanning `jsonl`
- **Advanced Filtering**: Free shipping, new listing time range, province/city/district filtering
- **Instant Notifications**: Supports ntfy.sh, WeChat Work (企业微信), Bark, Telegram, Webhook
- **Scheduled Tasks**: Cron expression configuration for periodic tasks
- **Account & Proxy Rotation**: Multi-account management, task-account binding, proxy pool rotation with failure retry
- **Docker Deployment**: One-click containerized deployment

## Screenshots

![Monitoring Overview](static/img.png)
![Task Management](static/img_1.png)
![Result Viewer](static/img_2.png)
![Notification Settings](static/img_3.png)

## Quick Start

### Requirements

- Python 3.10+
- Node.js + npm (`Node v20.18.3` has been verified to complete the frontend build)
- Playwright CLI and Chromium. Before the first local run, install them with `python3 -m pip install playwright && python3 -m playwright install chromium`
- Chrome or Edge on desktop systems. On Linux, Chromium also works. `start.sh` checks this prerequisite before continuing

```bash
git clone https://github.com/Usagi-org/ai-goofish-monitor
cd ai-goofish-monitor
cp .env.example .env
```

### Minimum Configuration

| Variable | Description | Required |
|----------|-------------|----------|
| `OPENAI_API_KEY` | AI model API key | Yes |
| `OPENAI_BASE_URL` | OpenAI-compatible API base URL | Yes |
| `OPENAI_MODEL_NAME` | Model name with image input support | Yes |
| `WEB_USERNAME` / `WEB_PASSWORD` | Web UI login credentials, default `admin/admin123` | No |

See "Configuration" below for the rest.

### Start Locally

```bash
chmod +x start.sh
./start.sh
```

`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.

### First-Time Setup

1. Open the default Web UI at `http://127.0.0.1:8000` and sign in.
2. 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.
3. Login-state files are stored in `state/`, for example `state/acc_1.json`.
4. Go back to "Task Management", create a task, bind an account if needed, and run it.

### Create Your First Task

- `AI mode`: fill in the requirement description. Submission opens a separate progress dialog while the criteria are generated asynchronously.
- `Keyword mode`: provide keyword rules and the task is created immediately.
- `Region filter`: now uses a province / city / district selector backed by an embedded Xianyu page snapshot instead of manual text input.

## 🐳 Docker Deployment (Recommended)

```bash
git clone https://github.com/Usagi-org/ai-goofish-monitor && cd ai-goofish-monitor
cp .env.example .env
vim .env # fill in the required values
docker compose up -d
docker compose logs -f app
docker compose down
```

- Default Web UI: `http://127.0.0.1:8000`
- The published Docker image already includes Chromium, so no extra browser install is required on the host.
- Update image: `docker compose pull && docker compose up -d`
- If you change `SERVER_PORT` in `.env`, update the `ports` mapping in `docker-compose.yaml` as well.
- `docker-compose.yaml` now mounts the primary SQLite database directory as `./data:/app/data`, with the default database file at `data/app.sqlite3`
- These paths are persisted by default:
  - `data/` for the SQLite primary store (tasks, results, price history)
  - `state/` for login-state cookie files
  - `prompts/` for task prompt files
  - `logs/` for runtime logs
  - `images/` for downloaded product images and per-task temporary image folders
  - `config.json`, `jsonl/`, and `price_history/` as legacy sources for the first SQLite migration

### Storage and Migration

- SQLite is now the online primary storage, with the default path `data/app.sqlite3`
- You can override the database path with `APP_DATABASE_FILE`; Docker sets it to `/app/data/app.sqlite3`
- On startup, the app initializes the schema and tries to import existing data once from legacy `config.json`, `jsonl/`, and `price_history/`
- `state/`, `prompts/`, `logs/`, and `images/` remain filesystem-based and are not stored in SQLite
- Product images are temporarily downloaded to `images/task_images_<task_name>/` and are normally cleaned up when the task finishes
- 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

## User Guide

<details>
<summary>Click to expand Web UI usage notes</summary>

### Task Management

- Supports AI creation, keyword rules, price range, new listing filters, region filters, account binding, and cron scheduling.
- AI task creation runs as a background job and shows a dedicated progress dialog after submission.
- Region filtering can greatly reduce results, so leaving it empty is the safer default.

### Account Management

- Import, update, and delete Xianyu login states.
- Each task can bind a specific account or leave account selection to the system.

### Results and Logs

- The results page and export endpoints now query SQLite instead of directly scanning `jsonl` files.
- The logs page is the first place to inspect login-state expiry, anti-bot issues, or AI call failures.

### System Settings

- View system status, edit prompts, and adjust proxy / rotation-related settings.

</details>

## Developer Guide

### Local Development

```bash
# backend
python -m src.app
# or
uvicorn src.app:app --host 0.0.0.0 --port 8000 --reload

# frontend
cd web-ui
npm install
npm run dev
```

- FastAPI initializes SQLite on startup and performs the one-time legacy import from `config.json/jsonl/price_history` when needed
- `spider_v2.py` now loads tasks from SQLite by default; JSON config is only used when `--config <path>` is passed explicitly
- The default local database path is `data/app.sqlite3`
- The Vite dev server proxies `/api`, `/auth`, and `/ws` to `http://127.0.0.1:8000`.
- `npm run build` writes `web-ui/dist/`, and `start.sh` copies it to the repository root `dist/`.
- FastAPI serves `dist/index.html` and `dist/assets/` from the repository root.
- `./start.sh` prints the default app URL `http://localhost:8000` and API docs URL `http://localhost:8000/docs`.

### Validation

```bash
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest
cd web-ui && npm run build
```

### Task Creation API

<details>
<summary>Click to expand API behavior</summary>

- `POST /api/tasks/generate`
  - `decision_mode=ai`: returns `202` with a `job`; the client should poll for progress.
  - `decision_mode=keyword`: returns the created task directly.
- `GET /api/tasks/generate-jobs/{job_id}`: fetch AI task-generation progress.
- `POST /auth/status`: validate Web UI credentials.

</details>

## Configuration

<details>
<summary>Click to expand common configuration items</summary>

### AI and Runtime

- `OPENAI_API_KEY` / `OPENAI_BASE_URL` / `OPENAI_MODEL_NAME`: required AI model settings.
- `PROXY_URL`: dedicated HTTP/SOCKS5 proxy for AI requests.
- `RUN_HEADLESS`: whether the scraper runs headless; keep it `true` in Docker.
- `SERVER_PORT`: backend port, default `8000`.
- `LOGIN_IS_EDGE`: use Edge instead of Chrome locally; Docker images do not bundle Edge and always run with Chromium.
- `PCURL_TO_MOBILE`: convert desktop item URLs to mobile URLs.

### Notifications

- `NTFY_TOPIC_URL`
- `GOTIFY_URL` / `GOTIFY_TOKEN`
- `BARK_URL`
- `WX_BOT_URL`
- `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` / `TELEGRAM_API_BASE_URL`
- `WEBHOOK_*`

### Proxy Rotation and Failure Guard

- `PROXY_ROTATION_ENABLED`
- `PROXY_ROTATION_MODE`
- `PROXY_POOL`
- `PROXY_ROTATION_RETRY_LIMIT`
- `PROXY_BLACKLIST_TTL`
- `TASK_FAILURE_THRESHOLD`
- `TASK_FAILURE_PAUSE_SECONDS`
- `TASK_FAILURE_GUARD_PATH`

See `.env.example` for the full list.

</details>

## Web Authentication

<details>
<summary>Click to expand authentication notes</summary>

- The Web UI uses a login page and validates credentials through `POST /auth/status`.
- After login, the frontend stores local auth state for route guards and WebSocket startup.
- The default credentials are `admin/admin123`; change them in production.

</details>

## 🚀 Workflow

The 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.

```mermaid
graph TD
    A[Start Monitoring Task] --> B[Select Account/Proxy Configuration];
    B --> C[Task: Search Products];
    C --> D{Found New Products?};
    D -- Yes --> E[Scrape Product Details & Seller Info];
    E --> F[Download Product Images];
    F --> G[Call AI for Analysis];
    G --> H{AI Recommended?};
    H -- Yes --> I[Send Notification];
    H -- No --> J[Save Record to SQLite];
    I --> J;
    D -- No --> K[Next Page/Wait];
    K --> C;
    J --> C;
    C --> L{Risk Control/Exception?};
    L -- Yes --> M[Account/Proxy Rotation and Retry];
    M --> C;
```

## FAQ

<details>
<summary>Click to expand FAQ</summary>

### Why does AI task creation take time?

In 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.

### Why is the region filter optional by default?

Region filtering can sharply reduce result volume. Leave it empty if you want a broader market scan first.

### Why does the app say the frontend build artifacts are missing?

It 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/`.

### Why does `./start.sh` complain about missing Playwright or a browser?

The 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`.

</details>

## Acknowledgments

<details>
<summary>Click to expand acknowledgments</summary>

This project referenced the following excellent projects during development. Special thanks to:

- [superboyyy/xianyu_spider](https://github.com/superboyyy/xianyu_spider)

Also thanks to LinuxDo contributors for script contributions:

- [@jooooody](https://linux.do/u/jooooody/summary)

And thanks to the [LinuxDo](https://linux.do/) community.

Also thanks to ClaudeCode/Gemini/Codex and other model tools for freeing our hands and experiencing the joy of Vibe Coding.

</details>


## Notices

<details>
<summary>Click to expand notice details</summary>

- 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.
- This project is for learning and technical research purposes only. Do not use it for illegal purposes.
- This project is released under the [MIT License](LICENSE), provided "as is", without any form of warranty.
- 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.
- For more details, please refer to the [Disclaimer](DISCLAIMER.md) file.

</details>

## Star History

[![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)


================================================
FILE: chrome-extension/README.md
================================================
# Xianyu Login State Extractor Chrome Extension

This 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.

## Installation

1. Open Chrome and navigate to `chrome://extensions`
2. Enable "Developer mode" in the top right corner
3. Click "Load unpacked" and select the `chrome-extension` directory
4. The extension icon should now appear in your toolbar

## Usage

1. Navigate to [https://www.goofish.com](https://www.goofish.com)
2. Log in to your account
3. Click the extension icon in the toolbar
4. Click "Extract Login State" (collects cookies + environment + headers,自动过滤无用/超大字段)
5. The complete JSON will be displayed - click "Copy to Clipboard"
6. Save the JSON文本到 `xianyu_state.json`(或自定义文件名)即可

## Features

- Extracts all cookies including HttpOnly cookies
- Captures browser environment (UA, locale, timezone, screen size, device memory, hardware concurrency)
- Captures observed request headers for the current tab
- Captures localStorage/sessionStorage snapshot for the current domain(会自动丢弃超大或无用字段)
- Outputs a single JSON payload, ready for the monitoring robot
- Copy to clipboard with real-time status feedback

## How It Works

The 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.


================================================
FILE: chrome-extension/background.js
================================================
// Service worker for capturing browser environment, headers, storage, and cookies

const GOOFISH_HOST_PATTERN = "*://*.goofish.com/*";
const MAX_STORAGE_ENTRY_LENGTH = 4096; // bytes limit to drop oversized values

function mapSameSiteValue(chromeSameSite) {
  if (chromeSameSite === undefined || chromeSameSite === null) return "Lax";
  const sameSiteMap = {
    no_restriction: "None",
    lax: "Lax",
    strict: "Strict",
    unspecified: "Lax",
  };
  return sameSiteMap[chromeSameSite] || "Lax";
}

function headersArrayToObject(headers = []) {
  const result = {};
  headers.forEach((item) => {
    if (item && item.name) {
      result[item.name] = item.value || "";
    }
  });
  return result;
}

async function getActiveGoofishTab() {
  const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
  const tab = tabs?.[0];
  if (!tab || !tab.id || !tab.url) {
    throw new Error("未找到活动标签页");
  }
  if (!tab.url.includes("goofish.com")) {
    throw new Error("请先打开 goofish.com 页面");
  }
  return tab;
}

async function capturePageData(tabId) {
  const [result] = await chrome.scripting.executeScript({
    target: { tabId },
    func: () => {
      const safeEntries = (storage) => {
        try {
          const obj = {};
          for (let i = 0; i < storage.length; i += 1) {
            const key = storage.key(i);
            if (key !== null) {
              obj[key] = storage.getItem(key);
            }
          }
          return obj;
        } catch (e) {
          return {};
        }
      };

      const intl = (() => {
        try {
          return Intl.DateTimeFormat().resolvedOptions();
        } catch (e) {
          return {};
        }
      })();

      const uaData = (() => {
        try {
          return navigator.userAgentData ? navigator.userAgentData.toJSON() : null;
        } catch (e) {
          return null;
        }
      })();

      return {
        page: {
          pageUrl: location.href,
          referrer: document.referrer || null,
          visibilityState: document.visibilityState,
        },
        env: {
          navigator: {
            userAgent: navigator.userAgent,
            platform: navigator.platform,
            vendor: navigator.vendor,
            language: navigator.language,
            languages: navigator.languages,
            hardwareConcurrency: navigator.hardwareConcurrency,
            deviceMemory: navigator.deviceMemory,
            webdriver: navigator.webdriver,
            doNotTrack: navigator.doNotTrack,
            maxTouchPoints: navigator.maxTouchPoints,
            userAgentData: uaData,
          },
          screen: {
            width: screen.width,
            height: screen.height,
            availWidth: screen.availWidth,
            availHeight: screen.availHeight,
            colorDepth: screen.colorDepth,
            pixelDepth: screen.pixelDepth,
            devicePixelRatio: window.devicePixelRatio,
          },
          intl,
        },
        storage: {
          local: safeEntries(localStorage),
          session: safeEntries(sessionStorage),
        },
      };
    },
  });

  if (!result || !result.result) {
    throw new Error("无法获取页面信息");
  }

  return result.result;
}

function filterEnvData(env = {}) {
  const nav = env.navigator || {};
  const screen = env.screen || {};
  const intl = env.intl || {};

  return {
    navigator: {
      userAgent: nav.userAgent,
      platform: nav.platform,
      language: nav.language,
      languages: nav.languages,
      hardwareConcurrency: nav.hardwareConcurrency,
      deviceMemory: nav.deviceMemory,
      maxTouchPoints: nav.maxTouchPoints,
      webdriver: nav.webdriver,
      doNotTrack: nav.doNotTrack,
      userAgentData: nav.userAgentData || null,
    },
    screen: {
      width: screen.width,
      height: screen.height,
      devicePixelRatio: screen.devicePixelRatio,
      colorDepth: screen.colorDepth,
    },
    intl: {
      timeZone: intl.timeZone,
      locale: intl.locale,
    },
  };
}

function pruneStorageEntries(entries = {}) {
  const data = {};
  const dropped = [];
  Object.entries(entries).forEach(([key, value]) => {
    const str = value == null ? "" : String(value);
    if (str.length <= MAX_STORAGE_ENTRY_LENGTH) {
      data[key] = value;
    } else {
      dropped.push(key);
    }
  });
  return { data, dropped };
}

function filterHeaders(rawHeaders = {}) {
  const allowList = [
    "user-agent",
    "accept",
    "accept-language",
    "accept-encoding",
    "referer",
    "sec-ch-ua",
    "sec-ch-ua-mobile",
    "sec-ch-ua-platform",
    "sec-fetch-site",
    "sec-fetch-mode",
    "sec-fetch-dest",
    "sec-fetch-user",
    "origin",
    "cache-control",
    "pragma",
    "upgrade-insecure-requests",
    "content-type",
  ];
  const normalized = {};
  Object.entries(rawHeaders).forEach(([k, v]) => {
    const lower = k.toLowerCase();
    if (allowList.includes(lower)) {
      normalized[k] = v;
    }
  });
  return normalized;
}

async function captureHeaders(tabId) {
  return new Promise((resolve) => {
    let resolved = false;

    const cleanup = (headers) => {
      if (resolved) return;
      resolved = true;
      chrome.webRequest.onBeforeSendHeaders.removeListener(listener);
      clearTimeout(timer);
      resolve(headersArrayToObject(headers || []));
    };

    const listener = (details) => {
      if (details.tabId !== tabId) return;
      cleanup(details.requestHeaders || []);
    };

    const extraInfo = ["requestHeaders"];
    // extraHeaders 提供更完整的 header 视图,在新版本 Chrome 需要显式声明
    extraInfo.push("extraHeaders");

    chrome.webRequest.onBeforeSendHeaders.addListener(
      listener,
      { urls: [GOOFISH_HOST_PATTERN], tabId },
      extraInfo,
    );

    const timer = setTimeout(() => cleanup(null), 2000);

    // 触发一次轻量请求以获取真实请求头
    chrome.scripting
      .executeScript({
        target: { tabId },
        func: () => {
          try {
            fetch(`${window.location.origin}/__codex_probe?ts=${Date.now()}`, {
              credentials: "include",
              cache: "no-store",
              redirect: "follow",
            }).catch(() => {});
          } catch (e) {
            /* ignore */
          }
        },
      })
      .catch(() => {
        // 如果注入失败,继续等待可能已有的请求
      });
  });
}

async function captureCookies(url) {
  const cookies = await chrome.cookies.getAll({ url });
  return cookies.map((cookie) => ({
    name: cookie.name,
    value: cookie.value,
    domain: cookie.domain,
    path: cookie.path,
    expires: cookie.expirationDate,
    httpOnly: cookie.httpOnly,
    secure: cookie.secure,
    sameSite: mapSameSiteValue(cookie.sameSite),
  }));
}

async function buildSnapshot() {
  const tab = await getActiveGoofishTab();
  const pageData = await capturePageData(tab.id);
  const headers = await captureHeaders(tab.id);
  const cookies = await captureCookies(new URL(tab.url).origin);

  const filteredEnv = filterEnvData(pageData.env);
  const localPruned = pruneStorageEntries(pageData.storage.local);
  const sessionPruned = pruneStorageEntries(pageData.storage.session);
  const filteredStorage = {
    local: localPruned.data,
    session: sessionPruned.data,
  };

  return {
    capturedAt: new Date().toISOString(),
    pageUrl: tab.url,
    page: pageData.page,
    env: filteredEnv,
    storage: filteredStorage,
    meta: {
      droppedStorageKeys: {
        local: localPruned.dropped,
        session: sessionPruned.dropped,
      },
    },
    headers: filterHeaders(headers),
    cookies,
  };
}

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (!message || message.type !== "captureSnapshot") {
    return false;
  }

  buildSnapshot()
    .then((data) => sendResponse({ ok: true, data }))
    .catch((error) => sendResponse({ ok: false, error: error.message }));

  return true;
});


================================================
FILE: chrome-extension/manifest.json
================================================
{
  "manifest_version": 3,
  "name": "Xianyu Login State Extractor",
  "version": "1.1",
  "description": "Extract login state and browser environment for Xianyu monitoring robot",
  "permissions": [
    "activeTab",
    "cookies",
    "scripting",
    "storage",
    "tabs",
    "webRequest"
  ],
  "host_permissions": [
    "*://*.goofish.com/*"
  ],
  "background": {
    "service_worker": "background.js"
  },
  "action": {
    "default_popup": "popup.html",
    "default_title": "Extract Xianyu Login State"
  }
}


================================================
FILE: chrome-extension/popup.html
================================================
<!DOCTYPE html>
<html>
<meta charset="UTF-8">
<head>
  <style>
    body {
      width: 400px;
      padding: 20px;
      font-family: Arial, sans-serif;
    }
    #stateOutput {
      width: 100%;
      height: 300px;
      font-family: monospace;
      font-size: 12px;
      white-space: pre-wrap;
      overflow-y: auto;
    }
    button {
      margin: 10px 0;
      padding: 10px;
      background-color: #4CAF50;
      color: white;
      border: none;
      cursor: pointer;
      width: 100%;
    }
    button:hover {
      background-color: #45a049;
    }
    .status {
      margin: 10px 0;
      padding: 10px;
      border-radius: 4px;
    }
    .success {
      background-color: #dff0d8;
      color: #3c763d;
    }
    .error {
      background-color: #f2dede;
      color: #a94442;
    }
  </style>
</head>
<body>
  <h2>Xianyu Login State Extractor</h2>
  <button id="extractBtn">1.点击获取环境+登录状态</button>
  <div id="status"></div>
  <textarea id="stateOutput" readonly></textarea>
  <button id="copyBtn">2.点击复制</button>

  <script src="popup.js"></script>
</body>
</html>


================================================
FILE: chrome-extension/popup.js
================================================
// Popup script for the Chrome extension
document.addEventListener('DOMContentLoaded', function() {
  const extractBtn = document.getElementById('extractBtn');
  const copyBtn = document.getElementById('copyBtn');
  const stateOutput = document.getElementById('stateOutput');
  const statusDiv = document.getElementById('status');

  let latestSnapshot = null;

  function setLoading(isLoading) {
    extractBtn.disabled = isLoading;
    extractBtn.textContent = isLoading ? '采集中,请稍候...' : '1.点击获取环境+登录状态';
  }

  function updateStatus(message, isSuccess = false) {
    statusDiv.textContent = message;
    statusDiv.className = 'status ' + (isSuccess ? 'success' : 'error');
    setTimeout(() => {
      statusDiv.textContent = '';
      statusDiv.className = 'status';
    }, 4000);
  }

  function renderSnapshot(snapshot) {
    latestSnapshot = snapshot;
    stateOutput.value = JSON.stringify(snapshot, null, 2);
  }

  async function captureSnapshot() {
    setLoading(true);
    updateStatus('正在采集浏览器环境与登录状态...');
    stateOutput.value = '';

    chrome.runtime.sendMessage({ type: 'captureSnapshot' }, (response) => {
      setLoading(false);

      if (chrome.runtime.lastError) {
        updateStatus('通信失败: ' + chrome.runtime.lastError.message);
        return;
      }
      if (!response || !response.ok) {
        updateStatus('采集失败: ' + (response?.error || '未知错误'));
        return;
      }

      renderSnapshot(response.data);
      updateStatus('采集完成,已生成JSON', true);
    });
  }

  function copySnapshot() {
    if (!stateOutput.value) {
      updateStatus('没有可复制的数据');
      return;
    }
    navigator.clipboard.writeText(stateOutput.value)
      .then(() => updateStatus('已复制到剪贴板', true))
      .catch(err => updateStatus('复制失败: ' + err));
  }

  extractBtn.addEventListener('click', captureSnapshot);
  copyBtn.addEventListener('click', copySnapshot);
});


================================================
FILE: config.json.example
================================================
[
  {
    "task_name": "苹果watch S10",
    "enabled": true,
    "keyword": "苹果watch S10",
    "description": "九成新,充电线包装盒齐全,无明显磕碰,卖家信用优秀",
    "max_pages": 10,
    "personal_only": true,
    "min_price": "8000",
    "max_price": "2000",
    "cron": null,
    "ai_prompt_base_file": "prompts/base_prompt.txt",
    "ai_prompt_criteria_file": "prompts/苹果watch_s10_criteria.txt",
    "account_state_file": "state/acc1.json",
    "free_shipping": true,
    "new_publish_option": "14天内",
    "region": "江苏/南京/全南京",
    "is_running": false
  }
]


================================================
FILE: desktop_launcher.py
================================================
"""
桌面启动入口
使用 PyInstaller 打包后作为单一可执行文件的入口,自动启动 FastAPI 服务并打开浏览器。
"""
import os
import sys
import time
import webbrowser
from pathlib import Path

import uvicorn

# PyInstaller 运行时资源目录:_MEIPASS;未打包时则为当前文件所在目录
BASE_DIR = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))


def _prepare_environment() -> None:
    """确保工作目录和模块路径正确"""
    os.chdir(BASE_DIR)
    if str(BASE_DIR) not in sys.path:
        sys.path.insert(0, str(BASE_DIR))


def run_app() -> None:
    """启动 FastAPI 应用并自动打开浏览器"""
    _prepare_environment()

    from src.app import app
    from src.infrastructure.config.settings import settings

    # 先尝试打开浏览器,稍等服务起来
    url = f"http://127.0.0.1:{settings.server_port}"
    webbrowser.open(url)
    time.sleep(0.5)

    uvicorn.run(
        app,
        host="127.0.0.1",
        port=settings.server_port,
        log_level="info",
        reload=False,
    )


if __name__ == "__main__":
    run_app()


================================================
FILE: docker-compose.dev.yaml
================================================
services:
  app:
    build: .
    container_name: ai-goofish-monitor-app
    init: true
    ports:
      - "8000:8000"
    env_file:
      - .env
    volumes:
      - .:/app
      - /app/dist
    restart: unless-stopped


================================================
FILE: docker-compose.yaml
================================================
services:
  app:
    image: ${APP_IMAGE:-ghcr.io/usagi-org/ai-goofish:latest}
    container_name: ai-goofish-monitor-app
    pull_policy: always
    init: true
    ports:
      - "8000:8000"
    env_file:
      - .env
    environment:
      APP_DATABASE_FILE: /app/data/app.sqlite3
    volumes:
      - ./.env:/app/.env
      - ./data:/app/data
      - ./state:/app/state
      - ./config.json:/app/config.json
      - ./prompts:/app/prompts
      - ./jsonl:/app/jsonl
      - ./logs:/app/logs
      - ./images:/app/images
      - ./price_history:/app/price_history
    restart: unless-stopped


================================================
FILE: pyproject.toml
================================================
[tool.pytest.ini_options]
addopts = "-v --tb=short"
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
markers = [
    "live: real traffic smoke tests that require real credentials and external services",
    "live_slow: slower optional live smoke tests such as AI task generation",
]

[tool.coverage.run]
source = ["src"]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "raise AssertionError",
    "raise NotImplementedError",
    "if __name__ == .__main__.:",
]


================================================
FILE: requirements-runtime.txt
================================================
python-dotenv
playwright
requests
openai
fastapi
uvicorn[standard]
pydantic-settings
jinja2
aiofiles
python-socks
apscheduler
httpx[socks]
Pillow
pyzbar
qrcode


================================================
FILE: requirements.txt
================================================
python-dotenv
playwright
requests
openai
fastapi
uvicorn[standard]
pydantic-settings
jinja2
aiofiles
python-socks
apscheduler
httpx[socks]
Pillow
pyzbar
qrcode
pytest
pytest-asyncio
coverage


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

set -euo pipefail

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"

PYTHON_CMD="${PYTHON_CMD:-python3}"
MARK_EXPRESSION=""
DRY_RUN=false
WITH_GENERATION=true
PYTEST_ARGS=()
TASK_CREATE_TEST="tests/integration/test_api_tasks.py::test_create_list_update_delete_task"
TEST_TARGETS=(
    "$TASK_CREATE_TEST"
    "tests/live"
)

usage() {
    cat <<'EOF'
用法:
  ./run_live_smoke.sh [选项] [-- pytest额外参数]

选项:
  --keyword <关键词>           覆盖 LIVE_TEST_KEYWORD
  --account-file <路径>        覆盖 LIVE_TEST_ACCOUNT_STATE_FILE
  --task-name <名称>           覆盖 LIVE_TEST_TASK_NAME
  --timeout <秒>               覆盖 LIVE_TIMEOUT_SECONDS
  --min-items <数量>           覆盖 LIVE_EXPECT_MIN_ITEMS
  --debug-limit <数量>         覆盖 LIVE_TEST_DEBUG_LIMIT(默认 1,仅分析前 N 个新商品)
  --with-generation            显式开启 live_slow(默认已开启)
  --without-generation         关闭 live_slow,只执行主 smoke
  --dry-run                    只打印配置和将执行的命令,不真正运行
  --help                       显示帮助

示例:
  ./run_live_smoke.sh
  ./run_live_smoke.sh --keyword "MacBook Air M1" --min-items 2
  ./run_live_smoke.sh --without-generation
  ./run_live_smoke.sh -- -k live_real_traffic

说明:
  0. 默认先执行任务创建 CRUD 集成测试,再执行 tests/live 真实流量 smoke
  1. 脚本会自动设置 RUN_LIVE_TESTS=1
  2. 若未设置 LIVE_TEST_ACCOUNT_STATE_FILE,会自动尝试使用 state/ 下第一个 *.json
  3. 默认使用 PYTEST_DISABLE_PLUGIN_AUTOLOAD=1,避免本机第三方 pytest 插件干扰
  4. 默认设置 LIVE_TEST_DEBUG_LIMIT=1,使冒烟测试只抓取并分析 1 个新商品
EOF
}

require_value() {
    local option="$1"
    local value="${2:-}"
    if [[ -z "$value" ]]; then
        echo -e "${RED}错误:${NC} ${option} 需要一个值"
        exit 1
    fi
}

resolve_default_account_file() {
    local first_match=""
    while IFS= read -r file; do
        first_match="$file"
        break
    done < <(find "$SCRIPT_DIR/state" -maxdepth 1 -type f -name '*.json' | sort)
    printf '%s' "$first_match"
}

while [[ $# -gt 0 ]]; do
    case "$1" in
        --keyword)
            require_value "$1" "${2:-}"
            export LIVE_TEST_KEYWORD="$2"
            shift 2
            ;;
        --account-file)
            require_value "$1" "${2:-}"
            export LIVE_TEST_ACCOUNT_STATE_FILE="$2"
            shift 2
            ;;
        --task-name)
            require_value "$1" "${2:-}"
            export LIVE_TEST_TASK_NAME="$2"
            shift 2
            ;;
        --timeout)
            require_value "$1" "${2:-}"
            export LIVE_TIMEOUT_SECONDS="$2"
            shift 2
            ;;
        --min-items)
            require_value "$1" "${2:-}"
            export LIVE_EXPECT_MIN_ITEMS="$2"
            shift 2
            ;;
        --debug-limit)
            require_value "$1" "${2:-}"
            export LIVE_TEST_DEBUG_LIMIT="$2"
            shift 2
            ;;
        --with-generation)
            WITH_GENERATION=true
            shift
            ;;
        --without-generation)
            WITH_GENERATION=false
            shift
            ;;
        --dry-run)
            DRY_RUN=true
            shift
            ;;
        --help|-h)
            usage
            exit 0
            ;;
        --)
            shift
            PYTEST_ARGS+=("$@")
            break
            ;;
        *)
            PYTEST_ARGS+=("$1")
            shift
            ;;
    esac
done

if ! command -v "$PYTHON_CMD" >/dev/null 2>&1; then
    echo -e "${RED}错误:${NC} 未找到 Python 命令: $PYTHON_CMD"
    exit 1
fi

if ! "$PYTHON_CMD" -m pytest --version >/dev/null 2>&1; then
    echo -e "${RED}错误:${NC} 当前 Python 环境缺少 pytest"
    exit 1
fi

if ! "$PYTHON_CMD" -m playwright --version >/dev/null 2>&1; then
    echo -e "${RED}错误:${NC} 当前 Python 环境缺少 Playwright,请先安装浏览器依赖"
    exit 1
fi

export RUN_LIVE_TESTS=1
export PYTEST_DISABLE_PLUGIN_AUTOLOAD="${PYTEST_DISABLE_PLUGIN_AUTOLOAD:-1}"
export LIVE_TEST_KEYWORD="${LIVE_TEST_KEYWORD:-MacBook Pro M2}"
export LIVE_TEST_TASK_NAME="${LIVE_TEST_TASK_NAME:-Live Smoke Task}"
export LIVE_EXPECT_MIN_ITEMS="${LIVE_EXPECT_MIN_ITEMS:-1}"
export LIVE_TEST_DEBUG_LIMIT="${LIVE_TEST_DEBUG_LIMIT:-1}"
export LIVE_TIMEOUT_SECONDS="${LIVE_TIMEOUT_SECONDS:-180}"

if [[ -z "${LIVE_TEST_ACCOUNT_STATE_FILE:-}" ]]; then
    DEFAULT_ACCOUNT_FILE="$(resolve_default_account_file)"
    if [[ -n "$DEFAULT_ACCOUNT_FILE" ]]; then
        export LIVE_TEST_ACCOUNT_STATE_FILE="$DEFAULT_ACCOUNT_FILE"
    fi
fi

if [[ -z "${LIVE_TEST_ACCOUNT_STATE_FILE:-}" ]]; then
    echo -e "${RED}错误:${NC} 未找到 live 登录态文件。请使用 --account-file 指定,或在 state/ 下放置 *.json"
    exit 1
fi

if [[ ! -f "${LIVE_TEST_ACCOUNT_STATE_FILE}" ]]; then
    echo -e "${RED}错误:${NC} 登录态文件不存在: ${LIVE_TEST_ACCOUNT_STATE_FILE}"
    exit 1
fi

if [[ "$WITH_GENERATION" == "true" ]]; then
    export LIVE_ENABLE_TASK_GENERATION=1
    MARK_EXPRESSION=""
else
    export LIVE_ENABLE_TASK_GENERATION=0
    MARK_EXPRESSION="not live_slow"
fi

echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}闲鱼真实流量 Live Smoke 一键测试${NC}"
echo -e "${GREEN}========================================${NC}"
echo -e "${YELLOW}Python:${NC} $PYTHON_CMD"
echo -e "${YELLOW}关键词:${NC} ${LIVE_TEST_KEYWORD}"
echo -e "${YELLOW}任务名:${NC} ${LIVE_TEST_TASK_NAME}"
echo -e "${YELLOW}登录态:${NC} ${LIVE_TEST_ACCOUNT_STATE_FILE}"
echo -e "${YELLOW}最少结果数:${NC} ${LIVE_EXPECT_MIN_ITEMS}"
echo -e "${YELLOW}抓取/分析商品上限:${NC} ${LIVE_TEST_DEBUG_LIMIT}"
echo -e "${YELLOW}超时(秒):${NC} ${LIVE_TIMEOUT_SECONDS}"
echo -e "${YELLOW}任务生成慢用例:${NC} ${LIVE_ENABLE_TASK_GENERATION}"
echo -e "${YELLOW}任务创建前置用例:${NC} ${TASK_CREATE_TEST}"
if [[ -n "$MARK_EXPRESSION" ]]; then
    echo -e "${YELLOW}Pytest Marker:${NC} ${MARK_EXPRESSION}"
else
    echo -e "${YELLOW}Pytest Marker:${NC} <none>"
fi
echo -e "${YELLOW}禁用插件自动加载:${NC} ${PYTEST_DISABLE_PLUGIN_AUTOLOAD}"

CMD=(
    "$PYTHON_CMD" -m pytest
    "${TEST_TARGETS[@]}"
    -v
)

if [[ -n "$MARK_EXPRESSION" ]]; then
    CMD+=(-m "$MARK_EXPRESSION")
fi

if [[ ${#PYTEST_ARGS[@]} -gt 0 ]]; then
    CMD+=("${PYTEST_ARGS[@]}")
fi

echo -e "${YELLOW}执行命令:${NC} ${CMD[*]}"

if [[ "$DRY_RUN" == "true" ]]; then
    echo -e "${GREEN}Dry run 完成,未实际执行测试。${NC}"
    exit 0
fi

"${CMD[@]}"


================================================
FILE: spider_v2.py
================================================
import asyncio
import sys
import os
import argparse
import json
import signal
import contextlib
import re

from src.config import STATE_FILE
from src.infrastructure.persistence.sqlite_task_repository import SqliteTaskRepository
from src.scraper import scrape_xianyu


async def main():
    parser = argparse.ArgumentParser(
        description="闲鱼商品监控脚本,支持多任务配置和实时AI分析。",
        epilog="""
使用示例:
  # 运行 config.json 中定义的所有任务
  python spider_v2.py

  # 只运行名为 "Sony A7M4" 的任务 (通常由调度器调用)
  python spider_v2.py --task-name "Sony A7M4"

  # 调试模式: 运行所有任务,但每个任务只处理前3个新发现的商品
  python spider_v2.py --debug-limit 3
""",
        formatter_class=argparse.RawDescriptionHelpFormatter
    )
    parser.add_argument("--debug-limit", type=int, default=0, help="调试模式:每个任务仅处理前 N 个新商品(0 表示无限制)")
    parser.add_argument("--config", type=str, help="指定任务配置文件路径(传入时优先读取 JSON)")
    parser.add_argument("--task-name", type=str, help="只运行指定名称的单个任务 (用于定时任务调度)")
    args = parser.parse_args()

    if args.config:
        if not os.path.exists(args.config):
            sys.exit(f"错误: 配置文件 '{args.config}' 不存在。")
        try:
            with open(args.config, 'r', encoding='utf-8') as f:
                tasks_config = json.load(f)
        except (json.JSONDecodeError, IOError) as e:
            sys.exit(f"错误: 读取或解析配置文件 '{args.config}' 失败: {e}")
    else:
        repository = SqliteTaskRepository()
        tasks = await repository.find_all()
        tasks_config = [task.dict() for task in tasks]

    def normalize_keywords(value):
        if value is None:
            return []
        if isinstance(value, str):
            raw_values = re.split(r"[\n,]+", value)
        elif isinstance(value, (list, tuple, set)):
            raw_values = list(value)
        else:
            raw_values = [value]

        normalized = []
        seen = set()
        for item in raw_values:
            text = str(item).strip()
            if not text:
                continue
            key = text.lower()
            if key in seen:
                continue
            seen.add(key)
            normalized.append(text)
        return normalized

    def flatten_legacy_groups(groups):
        merged = []
        for group in groups or []:
            if isinstance(group, dict):
                merged.extend(normalize_keywords(group.get("include_keywords")))
        return normalize_keywords(merged)

    def has_bound_account(tasks: list) -> bool:
        for task in tasks:
            account = task.get("account_state_file")
            if isinstance(account, str) and account.strip():
                return True
        return False

    def has_any_state_file() -> bool:
        state_dir = os.getenv("ACCOUNT_STATE_DIR", "state").strip().strip('"').strip("'")
        if os.path.isdir(state_dir):
            for name in os.listdir(state_dir):
                if name.endswith(".json"):
                    return True
        return False

    if not os.path.exists(STATE_FILE) and not has_bound_account(tasks_config) and not has_any_state_file():
        sys.exit(
            f"错误: 未找到登录状态文件。请在 state/ 中添加账号或配置 account_state_file。"
        )

    # 读取所有prompt文件内容(关键词模式不需要加载prompt)
    for task in tasks_config:
        decision_mode = str(task.get("decision_mode", "ai")).strip().lower()
        if decision_mode not in {"ai", "keyword"}:
            decision_mode = "ai"
        task["decision_mode"] = decision_mode
        keyword_rules = task.get("keyword_rules")
        if keyword_rules is None and task.get("keyword_rule_groups") is not None:
            task["keyword_rules"] = flatten_legacy_groups(task.get("keyword_rule_groups") or [])
        else:
            task["keyword_rules"] = normalize_keywords(keyword_rules)

        if decision_mode == "keyword":
            task["ai_prompt_text"] = ""
            continue

        if task.get("enabled", False) and task.get("ai_prompt_base_file") and task.get("ai_prompt_criteria_file"):
            try:
                with open(task["ai_prompt_base_file"], 'r', encoding='utf-8') as f_base:
                    base_prompt = f_base.read()
                with open(task["ai_prompt_criteria_file"], 'r', encoding='utf-8') as f_criteria:
                    criteria_text = f_criteria.read()
                
                # 动态组合成最终的Prompt
                task['ai_prompt_text'] = base_prompt.replace("{{CRITERIA_SECTION}}", criteria_text)
                
                # 验证生成的prompt是否有效
                if len(task['ai_prompt_text']) < 100:
                    print(f"警告: 任务 '{task['task_name']}' 生成的prompt过短 ({len(task['ai_prompt_text'])} 字符),可能存在问题。")
                elif "{{CRITERIA_SECTION}}" in task['ai_prompt_text']:
                    print(f"警告: 任务 '{task['task_name']}' 的prompt中仍包含占位符,替换可能失败。")
                else:
                    print(f"✅ 任务 '{task['task_name']}' 的prompt生成成功,长度: {len(task['ai_prompt_text'])} 字符")

            except FileNotFoundError as e:
                print(f"警告: 任务 '{task['task_name']}' 的prompt文件缺失: {e},该任务的AI分析将被跳过。")
                task['ai_prompt_text'] = ""
            except Exception as e:
                print(f"错误: 任务 '{task['task_name']}' 处理prompt文件时发生异常: {e},该任务的AI分析将被跳过。")
                task['ai_prompt_text'] = ""
        elif task.get("enabled", False) and task.get("ai_prompt_file"):
            try:
                with open(task["ai_prompt_file"], 'r', encoding='utf-8') as f:
                    task['ai_prompt_text'] = f.read()
                print(f"✅ 任务 '{task['task_name']}' 的prompt文件读取成功,长度: {len(task['ai_prompt_text'])} 字符")
            except FileNotFoundError:
                print(f"警告: 任务 '{task['task_name']}' 的prompt文件 '{task['ai_prompt_file']}' 未找到,该任务的AI分析将被跳过。")
                task['ai_prompt_text'] = ""
            except Exception as e:
                print(f"错误: 任务 '{task['task_name']}' 读取prompt文件时发生异常: {e},该任务的AI分析将被跳过。")
                task['ai_prompt_text'] = ""

    print("\n--- 开始执行监控任务 ---")
    if args.debug_limit > 0:
        print(f"** 调试模式已激活,每个任务最多处理 {args.debug_limit} 个新商品 **")
    
    if args.task_name:
        print(f"** 定时任务模式:只执行任务 '{args.task_name}' **")

    print("--------------------")

    active_task_configs = []
    if args.task_name:
        # 如果指定了任务名称,只查找该任务
        task_found = next((task for task in tasks_config if task.get('task_name') == args.task_name), None)
        if task_found:
            if task_found.get("enabled", False):
                active_task_configs.append(task_found)
            else:
                print(f"任务 '{args.task_name}' 已被禁用,跳过执行。")
        else:
            print(f"错误:在配置文件中未找到名为 '{args.task_name}' 的任务。")
            return
    else:
        # 否则,按原计划加载所有启用的任务
        active_task_configs = [task for task in tasks_config if task.get("enabled", False)]

    if not active_task_configs:
        print("没有需要执行的任务,程序退出。")
        return

    # 为每个启用的任务创建一个异步执行协程
    stop_event = asyncio.Event()
    loop = asyncio.get_running_loop()
    for sig in (signal.SIGTERM, signal.SIGINT):
        try:
            loop.add_signal_handler(sig, stop_event.set)
        except NotImplementedError:
            pass

    tasks = []
    for task_conf in active_task_configs:
        print(f"-> 任务 '{task_conf['task_name']}' 已加入执行队列。")
        tasks.append(asyncio.create_task(scrape_xianyu(task_config=task_conf, debug_limit=args.debug_limit)))

    async def _shutdown_watcher():
        await stop_event.wait()
        print("\n收到终止信号,正在优雅退出,取消所有爬虫任务...")
        for t in tasks:
            if not t.done():
                t.cancel()

    shutdown_task = asyncio.create_task(_shutdown_watcher())

    try:
        # 并发执行所有任务
        results = await asyncio.gather(*tasks, return_exceptions=True)
    finally:
        shutdown_task.cancel()
        with contextlib.suppress(asyncio.CancelledError):
            await shutdown_task

    print("\n--- 所有任务执行完毕 ---")
    for i, result in enumerate(results):
        task_name = active_task_configs[i]['task_name']
        if isinstance(result, Exception):
            print(f"任务 '{task_name}' 因异常而终止: {result}")
        else:
            print(f"任务 '{task_name}' 正常结束,本次运行共处理了 {result} 个新商品。")

if __name__ == "__main__":
    asyncio.run(main())


================================================
FILE: src/__init__.py
================================================
# This file makes src a Python package


================================================
FILE: src/ai_handler.py
================================================
import asyncio
import base64
import json
import os
import re
import sys
import shutil
import traceback
from datetime import datetime, timedelta
from urllib.parse import urlencode, urlparse, urlunparse, parse_qsl

import requests

# 设置标准输出编码为UTF-8,解决Windows控制台编码问题
if sys.platform.startswith('win'):
    import codecs
    sys.stdout = codecs.getwriter('utf-8')(sys.stdout.detach())
    sys.stderr = codecs.getwriter('utf-8')(sys.stderr.detach())

from src.config import (
    AI_DEBUG_MODE,
    IMAGE_DOWNLOAD_HEADERS,
    IMAGE_SAVE_DIR,
    TASK_IMAGE_DIR_PREFIX,
    MODEL_NAME,
    ENABLE_RESPONSE_FORMAT,
    client,
)
from src.ai_message_builder import (
    build_analysis_text_prompt,
    build_user_message_content,
)
from src.services.ai_response_parser import (
    EmptyAIResponseError,
    extract_ai_response_content,
    parse_ai_response_json,
)
from src.services.ai_request_compat import (
    CHAT_COMPLETIONS_API_MODE,
    RESPONSES_API_MODE,
    build_ai_request_params,
    create_ai_response_async,
    is_chat_completions_api_unsupported_error,
    is_json_output_unsupported_error,
    is_responses_api_unsupported_error,
    is_temperature_unsupported_error,
    remove_temperature_param,
)
from src.services.notification_service import build_notification_service
from src.utils import convert_goofish_link, retry_on_failure


def _positive_int(value, default: int) -> int:
    try:
        return max(1, int(value))
    except (TypeError, ValueError):
        return default


DEFAULT_IMAGE_DOWNLOAD_CONCURRENCY = max(
    1,
    _positive_int(os.getenv("IMAGE_DOWNLOAD_CONCURRENCY", "3"), 3),
)


def safe_print(text):
    """安全的打印函数,处理编码错误"""
    try:
        print(text)
    except UnicodeEncodeError:
        # 如果遇到编码错误,尝试用ASCII编码并忽略无法编码的字符
        try:
            print(text.encode('ascii', errors='ignore').decode('ascii'))
        except:
            # 如果还是失败,打印一个简化的消息
            print("[输出包含无法显示的字符]")


def _build_debug_request_summary(api_mode: str, request_params: dict) -> dict:
    summary = {
        "api_mode": api_mode,
        "model": request_params.get("model"),
    }
    if "temperature" in request_params:
        summary["temperature"] = request_params["temperature"]
    if "max_output_tokens" in request_params:
        summary["max_output_tokens"] = request_params["max_output_tokens"]
    if "max_tokens" in request_params:
        summary["max_tokens"] = request_params["max_tokens"]
    if "text" in request_params:
        summary["text"] = request_params["text"]
    if "response_format" in request_params:
        summary["response_format"] = request_params["response_format"]
    if "input" in request_params:
        summary["input_content_types"] = [
            [item.get("type") for item in message.get("content", [])]
            for message in request_params["input"]
        ]
    if "messages" in request_params:
        summary["message_content_types"] = [
            _extract_message_content_types(message)
            for message in request_params["messages"]
        ]
    return summary


def _extract_message_content_types(message: dict) -> list[str]:
    content = message.get("content")
    if isinstance(content, str):
        return ["text"]
    if not isinstance(content, list):
        return [type(content).__name__]
    return [str(item.get("type")) for item in content if isinstance(item, dict)]


@retry_on_failure(retries=2, delay=3)
async def _download_single_image(url, save_path):
    """一个带重试的内部函数,用于异步下载单个图片。"""
    loop = asyncio.get_running_loop()
    # 使用 run_in_executor 运行同步的 requests 代码,避免阻塞事件循环
    response = await loop.run_in_executor(
        None,
        lambda: requests.get(url, headers=IMAGE_DOWNLOAD_HEADERS, timeout=20, stream=True)
    )
    response.raise_for_status()
    with open(save_path, 'wb') as f:
        for chunk in response.iter_content(chunk_size=8192):
            f.write(chunk)
    return save_path


def _build_image_save_path(
    product_id: str,
    index: int,
    url: str,
    task_image_dir: str,
) -> str:
    clean_url = url.split('.heic')[0] if '.heic' in url else url
    file_name_base = os.path.basename(clean_url).split('?')[0]
    file_name = f"product_{product_id}_{index}_{file_name_base}"
    file_name = re.sub(r'[\\/*?:"<>|]', "", file_name)
    if not os.path.splitext(file_name)[1]:
        file_name += ".jpg"
    return os.path.join(task_image_dir, file_name)


async def download_all_images(product_id, image_urls, task_name="default", concurrency=None):
    """异步下载一个商品的所有图片。如果图片已存在则跳过。支持任务隔离。"""
    if not image_urls:
        return []

    # 为每个任务创建独立的图片目录
    task_image_dir = os.path.join(IMAGE_SAVE_DIR, f"{TASK_IMAGE_DIR_PREFIX}{task_name}")
    os.makedirs(task_image_dir, exist_ok=True)

    urls = [url.strip() for url in image_urls if url.strip().startswith('http')]
    if not urls:
        return []

    max_concurrency = _positive_int(concurrency, DEFAULT_IMAGE_DOWNLOAD_CONCURRENCY)
    semaphore = asyncio.Semaphore(max_concurrency)
    total_images = len(urls)

    async def _download_one(index: int, url: str):
        save_path = _build_image_save_path(product_id, index, url, task_image_dir)
        if os.path.exists(save_path):
            safe_print(
                f"   [图片] 图片 {index}/{total_images} 已存在,跳过下载: {os.path.basename(save_path)}"
            )
            return save_path
        async with semaphore:
            safe_print(f"   [图片] 正在下载图片 {index}/{total_images}: {url}")
            if await _download_single_image(url, save_path):
                safe_print(
                    f"   [图片] 图片 {index}/{total_images} 已成功下载到: {os.path.basename(save_path)}"
                )
                return save_path
        return None

    tasks = [
        asyncio.create_task(_download_one(index, url))
        for index, url in enumerate(urls, start=1)
    ]
    results = await asyncio.gather(*tasks, return_exceptions=True)

    saved_paths = []
    for url, result in zip(urls, results):
        try:
            if isinstance(result, Exception):
                raise result
            if result:
                saved_paths.append(result)
        except Exception as e:
            safe_print(f"   [图片] 处理图片 {url} 时发生错误,已跳过此图: {e}")

    return saved_paths


def cleanup_task_images(task_name):
    """清理指定任务的图片目录"""
    task_image_dir = os.path.join(IMAGE_SAVE_DIR, f"{TASK_IMAGE_DIR_PREFIX}{task_name}")
    if os.path.exists(task_image_dir):
        try:
            shutil.rmtree(task_image_dir)
            safe_print(f"   [清理] 已删除任务 '{task_name}' 的临时图片目录: {task_image_dir}")
        except Exception as e:
            safe_print(f"   [清理] 删除任务 '{task_name}' 的临时图片目录时出错: {e}")
    else:
        safe_print(f"   [清理] 任务 '{task_name}' 的临时图片目录不存在: {task_image_dir}")


def cleanup_ai_logs(logs_dir: str, keep_days: int = 1) -> None:
    try:
        cutoff = datetime.now() - timedelta(days=keep_days)
        for filename in os.listdir(logs_dir):
            if not filename.endswith(".log"):
                continue
            try:
                timestamp = datetime.strptime(filename[:15], "%Y%m%d_%H%M%S")
            except ValueError:
                continue
            if timestamp < cutoff:
                os.remove(os.path.join(logs_dir, filename))
    except Exception as e:
        safe_print(f"   [日志] 清理AI日志时出错: {e}")


def encode_image_to_base64(image_path):
    """将本地图片文件编码为 Base64 字符串。"""
    if not image_path or not os.path.exists(image_path):
        return None
    try:
        with open(image_path, "rb") as image_file:
            return base64.b64encode(image_file.read()).decode('utf-8')
    except Exception as e:
        safe_print(f"编码图片时出错: {e}")
        return None


def validate_ai_response_format(parsed_response):
    """验证AI响应的格式是否符合预期结构"""
    required_fields = [
        "prompt_version",
        "is_recommended",
        "reason",
        "risk_tags",
        "criteria_analysis"
    ]

    # 检查顶层字段
    for field in required_fields:
        if field not in parsed_response:
            safe_print(f"   [AI分析] 警告:响应缺少必需字段 '{field}'")
            return False

    # 检查criteria_analysis是否为字典且不为空
    criteria_analysis = parsed_response.get("criteria_analysis", {})
    if not isinstance(criteria_analysis, dict) or not criteria_analysis:
        safe_print("   [AI分析] 警告:criteria_analysis必须是非空字典")
        return False

    # 检查seller_type字段(所有商品都需要)
    if "seller_type" not in criteria_analysis:
        safe_print("   [AI分析] 警告:criteria_analysis缺少必需字段 'seller_type'")
        return False

    # 检查数据类型
    if not isinstance(parsed_response.get("is_recommended"), bool):
        safe_print("   [AI分析] 警告:is_recommended字段不是布尔类型")
        return False

    if not isinstance(parsed_response.get("risk_tags"), list):
        safe_print("   [AI分析] 警告:risk_tags字段不是列表类型")
        return False

    return True


@retry_on_failure(retries=3, delay=5)
async def send_ntfy_notification(product_data, reason):
    """兼容旧调用名,内部统一走 NotificationService。"""
    service = build_notification_service()
    if not service.clients:
        safe_print(
            "警告:未在 .env 文件中配置任何通知服务,跳过通知。"
        )
        return {}

    results = await service.send_notification(product_data, reason)
    for channel, result in results.items():
        if result["success"]:
            safe_print(f"   -> {channel} 通知发送成功。")
            continue
        safe_print(f"   -> {channel} 通知发送失败: {result['message']}")
    return results


async def get_ai_analysis(product_data, image_paths=None, prompt_text=""):
    """将完整的商品JSON数据和所有图片发送给 AI 进行分析(异步)。"""
    if not client:
        safe_print("   [AI分析] 错误:AI客户端未初始化,跳过分析。")
        return None

    item_info = product_data.get('商品信息', {})
    product_id = item_info.get('商品ID', 'N/A')

    safe_print(f"\n   [AI分析] 开始分析商品 #{product_id} (含 {len(image_paths or [])} 张图片)...")
    safe_print(f"   [AI分析] 标题: {item_info.get('商品标题', '无')}")

    if not prompt_text:
        safe_print("   [AI分析] 错误:未提供AI分析所需的prompt文本。")
        return None

    product_details_json = json.dumps(product_data, ensure_ascii=False, indent=2)
    system_prompt = prompt_text

    if AI_DEBUG_MODE:
        safe_print("\n--- [AI DEBUG] ---")
        safe_print("--- PRODUCT DATA (JSON) ---")
        safe_print(product_details_json)
        safe_print("--- PROMPT TEXT (完整内容) ---")
        safe_print(prompt_text)
        safe_print("-------------------\n")

    image_data_urls = []
    if image_paths:
        for path in image_paths:
            base64_image = encode_image_to_base64(path)
            if base64_image:
                image_data_urls.append(f"data:image/jpeg;base64,{base64_image}")

    combined_text_prompt = build_analysis_text_prompt(
        product_details_json,
        system_prompt,
        include_images=bool(image_data_urls),
    )
    user_content = build_user_message_content(combined_text_prompt, image_data_urls)
    messages = [{"role": "user", "content": user_content}]

    # 保存最终传输内容到日志文件
    try:
        # 创建logs文件夹
        logs_dir = os.path.join("logs", "ai")
        os.makedirs(logs_dir, exist_ok=True)
        cleanup_ai_logs(logs_dir, keep_days=1)

        # 生成日志文件名(当前时间)
        current_time = datetime.now().strftime("%Y%m%d_%H%M%S")
        log_filename = f"{current_time}.log"
        log_filepath = os.path.join(logs_dir, log_filename)

        task_name = product_data.get("任务名称") or product_data.get("任务名") or "unknown"
        log_payload = {
            "timestamp": current_time,
            "task_name": task_name,
            "product_id": product_id,
            "title": item_info.get("商品标题", "无"),
            "image_count": len(image_data_urls),
        }
        log_content = json.dumps(log_payload, ensure_ascii=False)

        # 写入日志文件
        with open(log_filepath, 'w', encoding='utf-8') as f:
            f.write(log_content)

        safe_print(f"   [日志] AI分析请求已保存到: {log_filepath}")

    except Exception as e:
        safe_print(f"   [日志] 保存AI分析日志时出错: {e}")

    # 增强的AI调用,包含更严格的结构化输出控制和重试机制
    max_retries = 4
    api_mode = CHAT_COMPLETIONS_API_MODE
    use_response_format = ENABLE_RESPONSE_FORMAT
    use_temperature = True
    for attempt in range(max_retries):
        try:
            # 根据重试次数调整参数
            current_temperature = 0.1 if attempt == 0 else 0.05  # 重试时使用更低的温度

            from src.config import get_ai_request_params

            request_params = build_ai_request_params(
                api_mode,
                model=MODEL_NAME,
                messages=messages,
                temperature=current_temperature,
                max_output_tokens=4000,
                enable_json_output=use_response_format,
            )
            if not use_temperature:
                request_params = remove_temperature_param(request_params)

            request_params = get_ai_request_params(**request_params)

            if AI_DEBUG_MODE:
                safe_print(f"\n--- [AI DEBUG] 第{attempt + 1}次尝试 REQUEST ---")
                safe_print(
                    json.dumps(
                        _build_debug_request_summary(api_mode, request_params),
                        ensure_ascii=False,
                        indent=2,
                    )
                )
                safe_print("-----------------------------------\n")

            response = await create_ai_response_async(
                client,
                api_mode,
                request_params,
            )
            ai_response_content = extract_ai_response_content(response)

            if AI_DEBUG_MODE:
                safe_print(f"\n--- [AI DEBUG] 第{attempt + 1}次尝试 ---")
                safe_print("--- RAW AI RESPONSE ---")
                safe_print(ai_response_content)
                safe_print("---------------------\n")

            try:
                parsed_response = parse_ai_response_json(ai_response_content)

                # 验证响应格式
                if validate_ai_response_format(parsed_response):
                    safe_print(f"   [AI分析] 第{attempt + 1}次尝试成功,响应格式验证通过")
                    return parsed_response
                safe_print(f"   [AI分析] 第{attempt + 1}次尝试格式验证失败")
                if attempt < max_retries - 1:
                    safe_print(f"   [AI分析] 准备第{attempt + 2}次重试...")
                    continue
                raise ValueError("AI响应格式缺少必需字段或字段类型不正确。")
            except json.JSONDecodeError as e:
                safe_print(f"   [AI分析] 第{attempt + 1}次尝试JSON解析失败: {e}")
                if attempt < max_retries - 1:
                    safe_print(f"   [AI分析] 准备第{attempt + 2}次重试...")
                    continue
                raise e
            except EmptyAIResponseError as e:
                safe_print(f"   [AI分析] 第{attempt + 1}次尝试返回空响应: {e}")
                if attempt < max_retries - 1:
                    safe_print(f"   [AI分析] 准备第{attempt + 2}次重试...")
                    continue
                raise e

        except Exception as e:
            if (
                api_mode == CHAT_COMPLETIONS_API_MODE
                and is_chat_completions_api_unsupported_error(e)
            ):
                api_mode = RESPONSES_API_MODE
                safe_print(
                    "   [AI分析] 当前服务未实现 Chat Completions API,后续重试将自动回退到 Responses API。"
                )
            elif api_mode == RESPONSES_API_MODE and is_responses_api_unsupported_error(e):
                api_mode = CHAT_COMPLETIONS_API_MODE
                safe_print(
                    "   [AI分析] 当前服务未实现 Responses API,后续重试将自动回退到 Chat Completions API。"
                )
            if use_response_format and is_json_output_unsupported_error(e):
                use_response_format = False
                safe_print(
                    "   [AI分析] 当前模型不支持结构化 JSON 输出,后续重试将自动禁用该参数。"
                )
            if use_temperature and is_temperature_unsupported_error(e):
                use_temperature = False
                safe_print(
                    "   [AI分析] 当前模型不支持 temperature 参数,后续重试将自动禁用该参数。"
                )
            if AI_DEBUG_MODE:
                safe_print(f"\n--- [AI DEBUG] 第{attempt + 1}次尝试 EXCEPTION ---")
                safe_print(repr(e))
                safe_print(traceback.format_exc())
                safe_print("-------------------------------------\n")
            safe_print(f"   [AI分析] 第{attempt + 1}次尝试AI调用失败: {e}")
            if attempt < max_retries - 1:
                safe_print(f"   [AI分析] 准备第{attempt + 2}次重试...")
                continue
            else:
                raise e


================================================
FILE: src/ai_message_builder.py
================================================
"""
AI 请求消息构造辅助函数
"""
from typing import Dict, List, Union


TEXT_ONLY_ANALYSIS_NOTE = (
    "补充说明:本次未提供商品图片,请仅根据商品文字字段和卖家信息判断,不要推断图片内容。"
)


def build_analysis_text_prompt(
    product_json: str,
    prompt_text: str,
    *,
    include_images: bool,
) -> str:
    note = "" if include_images else f"\n{TEXT_ONLY_ANALYSIS_NOTE}\n"
    value_note = (
        "\n如果商品 JSON 中包含“价格参考”或 price_insight,请结合价格位置、历史走势、"
        "配置、成色、附件、卖家信息综合判断性价比。"
        "你可以额外输出可选字段 value_score(0-100) 和 value_summary,"
        "但必须保留原有 is_recommended/reason 等字段。\n"
    )
    return f"""请基于你的专业知识和我的要求,分析以下完整的商品JSON数据:

```json
{product_json}
```

    {prompt_text}
    {value_note}
    {note}"""


def build_user_message_content(
    text_prompt: str,
    image_data_urls: List[str],
) -> Union[str, List[Dict[str, object]]]:
    if not image_data_urls:
        return text_prompt

    user_content: List[Dict[str, object]] = [
        {"type": "image_url", "image_url": {"url": url}}
        for url in image_data_urls
    ]
    user_content.append({"type": "text", "text": text_prompt})
    return user_content


================================================
FILE: src/api/__init__.py
================================================


================================================
FILE: src/api/dependencies.py
================================================
"""
FastAPI 依赖注入
提供服务实例的创建和管理
"""
from fastapi import Depends
from src.services.task_service import TaskService
from src.services.notification_service import NotificationService, build_notification_service
from src.services.ai_service import AIAnalysisService
from src.services.process_service import ProcessService
from src.services.scheduler_service import SchedulerService
from src.services.task_generation_service import TaskGenerationService
from src.infrastructure.persistence.sqlite_task_repository import SqliteTaskRepository
from src.infrastructure.external.ai_client import AIClient


# 全局 ProcessService 实例(将在 app.py 中设置)
_process_service_instance = None
_scheduler_service_instance = None
_task_generation_service_instance = None


def set_process_service(service: ProcessService):
    """设置全局 ProcessService 实例"""
    global _process_service_instance
    _process_service_instance = service


def set_scheduler_service(service: SchedulerService):
    """设置全局 SchedulerService 实例"""
    global _scheduler_service_instance
    _scheduler_service_instance = service


def set_task_generation_service(service: TaskGenerationService):
    """设置全局 TaskGenerationService 实例"""
    global _task_generation_service_instance
    _task_generation_service_instance = service


# 服务依赖注入
def get_task_service() -> TaskService:
    """获取任务管理服务实例"""
    repository = SqliteTaskRepository()
    return TaskService(repository)


def get_notification_service() -> NotificationService:
    """获取通知服务实例"""
    return build_notification_service()


def get_ai_service() -> AIAnalysisService:
    """获取AI分析服务实例"""
    ai_client = AIClient()
    return AIAnalysisService(ai_client)


def get_process_service() -> ProcessService:
    """获取进程管理服务实例"""
    if _process_service_instance is None:
        raise RuntimeError("ProcessService 未初始化")
    return _process_service_instance


def get_scheduler_service() -> SchedulerService:
    """获取调度服务实例"""
    if _scheduler_service_instance is None:
        raise RuntimeError("SchedulerService 未初始化")
    return _scheduler_service_instance


def get_task_generation_service() -> TaskGenerationService:
    """获取任务生成作业服务实例"""
    if _task_generation_service_instance is None:
        raise RuntimeError("TaskGenerationService 未初始化")
    return _task_generation_service_instance


================================================
FILE: src/api/routes/__init__.py
================================================


================================================
FILE: src/api/routes/accounts.py
================================================
"""
闲鱼账号管理路由
"""
import json
import os
import re
import aiofiles
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import List
from src.infrastructure.config.env_manager import env_manager


router = APIRouter(prefix="/api/accounts", tags=["accounts"])

ACCOUNT_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,50}$")


class AccountCreate(BaseModel):
    name: str
    content: str


class AccountUpdate(BaseModel):
    content: str


def _strip_quotes(value: str) -> str:
    if not value:
        return value
    if value.startswith(("\"", "'")) and value.endswith(("\"", "'")):
        return value[1:-1]
    return value


def _state_dir() -> str:
    raw = env_manager.get_value("ACCOUNT_STATE_DIR", "state") or "state"
    return _strip_quotes(raw.strip())


def _ensure_state_dir(path: str) -> None:
    os.makedirs(path, exist_ok=True)


def _validate_name(name: str) -> str:
    trimmed = name.strip()
    if not trimmed or not ACCOUNT_NAME_RE.match(trimmed):
        raise HTTPException(status_code=400, detail="账号名称只能包含字母、数字、下划线或短横线。")
    return trimmed


def _account_path(name: str) -> str:
    filename = f"{name}.json"
    return os.path.join(_state_dir(), filename)


def _validate_json(content: str) -> None:
    try:
        json.loads(content)
    except json.JSONDecodeError:
        raise HTTPException(status_code=400, detail="提供的内容不是有效的JSON格式。")


@router.get("", response_model=List[dict])
async def list_accounts():
    state_dir = _state_dir()
    if not os.path.isdir(state_dir):
        return []
    files = [f for f in os.listdir(state_dir) if f.endswith(".json")]
    accounts = []
    for filename in sorted(files):
        name = filename[:-5]
        accounts.append({
            "name": name,
            "path": os.path.join(state_dir, filename),
        })
    return accounts


@router.get("/{name}", response_model=dict)
async def get_account(name: str):
    account_name = _validate_name(name)
    path = _account_path(account_name)
    if not os.path.exists(path):
        raise HTTPException(status_code=404, detail="账号不存在")
    async with aiofiles.open(path, "r", encoding="utf-8") as f:
        content = await f.read()
    return {"name": account_name, "path": path, "content": content}


@router.post("", response_model=dict)
async def create_account(data: AccountCreate):
    account_name = _validate_name(data.name)
    _validate_json(data.content)
    state_dir = _state_dir()
    _ensure_state_dir(state_dir)
    path = _account_path(account_name)
    if os.path.exists(path):
        raise HTTPException(status_code=409, detail="账号已存在")
    async with aiofiles.open(path, "w", encoding="utf-8") as f:
        await f.write(data.content)
    return {"message": "账号已添加", "name": account_name, "path": path}


@router.put("/{name}", response_model=dict)
async def update_account(name: str, data: AccountUpdate):
    account_name = _validate_name(name)
    _validate_json(data.content)
    state_dir = _state_dir()
    _ensure_state_dir(state_dir)
    path = _account_path(account_name)
    if not os.path.exists(path):
        raise HTTPException(status_code=404, detail="账号不存在")
    async with aiofiles.open(path, "w", encoding="utf-8") as f:
        await f.write(data.content)
    return {"message": "账号已更新", "name": account_name, "path": path}


@router.delete("/{name}", response_model=dict)
async def delete_account(name: str):
    account_name = _validate_name(name)
    path = _account_path(account_name)
    if not os.path.exists(path):
        raise HTTPException(status_code=404, detail="账号不存在")
    os.remove(path)
    return {"message": "账号已删除"}


================================================
FILE: src/api/routes/dashboard.py
================================================
"""
Dashboard 概览路由
"""
from fastapi import APIRouter, Depends, HTTPException

from src.api.dependencies import get_task_service
from src.services.dashboard_service import build_dashboard_snapshot
from src.services.task_service import TaskService


router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])


@router.get("/summary")
async def get_dashboard_summary(
    task_service: TaskService = Depends(get_task_service),
):
    try:
        tasks = await task_service.get_all_tasks()
        return await build_dashboard_snapshot(tasks)
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"加载 dashboard 数据失败: {exc}")


================================================
FILE: src/api/routes/login_state.py
================================================
"""
登录状态管理路由
"""
import os
import json
import aiofiles
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel


router = APIRouter(prefix="/api/login-state", tags=["login-state"])


class LoginStateUpdate(BaseModel):
    """登录状态更新模型"""
    content: str


@router.post("", response_model=dict)
async def update_login_state(
    data: LoginStateUpdate,
):
    """接收前端发送的登录状态JSON字符串,并保存到 xianyu_state.json"""
    state_file = "xianyu_state.json"

    try:
        # 验证是否是有效的JSON
        json.loads(data.content)
    except json.JSONDecodeError:
        raise HTTPException(status_code=400, detail="提供的内容不是有效的JSON格式。")

    try:
        async with aiofiles.open(state_file, 'w', encoding='utf-8') as f:
            await f.write(data.content)
        return {"message": f"登录状态文件 '{state_file}' 已成功更新。"}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"写入登录状态文件时出错: {e}")


@router.delete("", response_model=dict)
async def delete_login_state():
    """删除 xianyu_state.json 文件"""
    state_file = "xianyu_state.json"

    if os.path.exists(state_file):
        try:
            os.remove(state_file)
            return {"message": "登录状态文件已成功删除。"}
        except OSError as e:
            raise HTTPException(status_code=500, detail=f"删除登录状态文件时出错: {e}")

    return {"message": "登录状态文件不存在,无需删除。"}


================================================
FILE: src/api/routes/logs.py
================================================
"""
日志管理路由
"""
import os
from typing import Optional, Tuple, List
import aiofiles
from fastapi import APIRouter, Depends, Query
from fastapi.responses import JSONResponse
from src.api.dependencies import get_task_service
from src.services.task_service import TaskService
from src.utils import resolve_task_log_path


router = APIRouter(prefix="/api/logs", tags=["logs"])


async def _read_tail_lines(
    log_file_path: str,
    offset_lines: int,
    limit_lines: int,
    chunk_size: int = 8192
) -> Tuple[List[str], bool, int]:
    async with aiofiles.open(log_file_path, 'rb') as f:
        await f.seek(0, os.SEEK_END)
        file_size = await f.tell()

        if file_size == 0 or limit_lines <= 0:
            return [], False, file_size

        offset_lines = max(0, int(offset_lines))
        limit_lines = max(0, int(limit_lines))
        lines_needed = offset_lines + limit_lines

        pos = file_size
        buffer = b""
        lines: List[bytes] = []

        while pos > 0 and len(lines) < lines_needed:
            read_size = min(chunk_size, pos)
            pos -= read_size
            await f.seek(pos)
            chunk = await f.read(read_size)
            buffer = chunk + buffer
            lines = buffer.splitlines()

        start = max(0, len(lines) - lines_needed)
        end = max(0, len(lines) - offset_lines)
        selected = lines[start:end] if end > start else []

        has_more = pos > 0 or len(lines) > lines_needed
        decoded = [line.decode('utf-8', errors='replace') for line in selected]
        return decoded, has_more, file_size


@router.get("")
async def get_logs(
    from_pos: int = 0,
    task_id: Optional[int] = Query(default=None, ge=0),
    task_service: TaskService = Depends(get_task_service),
):
    """获取日志内容(增量读取)"""
    if task_id is None:
        return JSONResponse(content={
            "new_content": "请选择任务后查看日志。",
            "new_pos": 0
        })

    task = await task_service.get_task(task_id)
    if not task:
        return JSONResponse(status_code=404, content={
            "new_content": "任务不存在或已删除。",
            "new_pos": 0
        })

    log_file_path = resolve_task_log_path(task_id, task.task_name)

    if not os.path.exists(log_file_path):
        return JSONResponse(content={
            "new_content": "",
            "new_pos": 0
        })

    try:
        async with aiofiles.open(log_file_path, 'rb') as f:
            await f.seek(0, os.SEEK_END)
            file_size = await f.tell()

            if from_pos >= file_size:
                return {"new_content": "", "new_pos": file_size}

            await f.seek(from_pos)
            new_bytes = await f.read()

        new_content = new_bytes.decode('utf-8', errors='replace')
        return {"new_content": new_content, "new_pos": file_size}

    except Exception as e:
        return JSONResponse(
            status_code=500,
            content={"new_content": f"\n读取日志文件时出错: {e}", "new_pos": from_pos}
        )  


@router.get("/tail")
async def get_logs_tail(
    task_id: Optional[int] = Query(default=None, ge=0),
    offset_lines: int = Query(default=0, ge=0),
    limit_lines: int = Query(default=50, ge=1, le=1000),
    task_service: TaskService = Depends(get_task_service),
):
    """获取日志尾部内容(按行分页)"""
    if task_id is None:
        return JSONResponse(content={
            "content": "",
            "has_more": False,
            "next_offset": 0,
            "new_pos": 0
        })

    task = await task_service.get_task(task_id)
    if not task:
        return JSONResponse(status_code=404, content={
            "content": "",
            "has_more": False,
            "next_offset": 0,
            "new_pos": 0
        })

    log_file_path = resolve_task_log_path(task_id, task.task_name)

    if not os.path.exists(log_file_path):
        return JSONResponse(content={
            "content": "",
            "has_more": False,
            "next_offset": 0,
            "new_pos": 0
        })

    try:
        lines, has_more, file_size = await _read_tail_lines(
            log_file_path,
            offset_lines=offset_lines,
            limit_lines=limit_lines
        )
        next_offset = offset_lines + len(lines)
        return {
            "content": "\n".join(lines),
            "has_more": has_more,
            "next_offset": next_offset,
            "new_pos": file_size
        }
    except Exception as e:
        return JSONResponse(
            status_code=500,
            content={
                "content": f"读取日志文件时出错: {e}",
                "has_more": False,
                "next_offset": offset_lines,
                "new_pos": 0
            }
        )


@router.delete("", response_model=dict)
async def clear_logs(
    task_id: Optional[int] = Query(default=None, ge=0),
    task_service: TaskService = Depends(get_task_service),
):
    """清空日志文件"""
    if task_id is None:
        return {"message": "未指定任务,无法清空日志。"}

    task = await task_service.get_task(task_id)
    if not task:
        return {"message": "任务不存在或已删除。"}

    log_file_path = resolve_task_log_path(task_id, task.task_name)

    if not os.path.exists(log_file_path):
        return {"message": "日志文件不存在,无需清空。"}

    try:
        async with aiofiles.open(log_file_path, 'w', encoding='utf-8') as f:
            await f.write("")
        return {"message": "日志已成功清空。"}
    except Exception as e:
        return JSONResponse(
            status_code=500,
            content={"message": f"清空日志文件时出错: {e}"}
        )

    if not os.path.exists(log_file_path):
        return {"message": "日志文件不存在,无需清空。"}

    try:
        async with aiofiles.open(log_file_path, 'w', encoding='utf-8') as f:
            await f.write("")
        return {"message": "日志已成功清空。"}
    except Exception as e:
        return JSONResponse(
            status_code=500,
            content={"message": f"清空日志文件时出错: {e}"}
        )


================================================
FILE: src/api/routes/prompts.py
================================================
"""
Prompt 管理路由
"""
import os
import aiofiles
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel


router = APIRouter(prefix="/api/prompts", tags=["prompts"])


class PromptUpdate(BaseModel):
    """Prompt 更新模型"""
    content: str


@router.get("")
async def list_prompts():
    """列出所有 prompt 文件"""
    prompts_dir = "prompts"
    if not os.path.isdir(prompts_dir):
        return []
    return [f for f in os.listdir(prompts_dir) if f.endswith(".txt")]


@router.get("/{filename}")
async def get_prompt(filename: str):
    """获取 prompt 文件内容"""
    if "/" in filename or ".." in filename:
        raise HTTPException(status_code=400, detail="无效的文件名")

    filepath = os.path.join("prompts", filename)
    if not os.path.exists(filepath):
        raise HTTPException(status_code=404, detail="Prompt 文件未找到")

    async with aiofiles.open(filepath, 'r', encoding='utf-8') as f:
        content = await f.read()
    return {"filename": filename, "content": content}


@router.put("/{filename}")
async def update_prompt(
    filename: str,
    prompt_update: PromptUpdate,
):
    """更新 prompt 文件内容"""
    if "/" in filename or ".." in filename:
        raise HTTPException(status_code=400, detail="无效的文件名")

    filepath = os.path.join("prompts", filename)
    if not os.path.exists(filepath):
        raise HTTPException(status_code=404, detail="Prompt 文件未找到")

    try:
        async with aiofiles.open(filepath, 'w', encoding='utf-8') as f:
            await f.write(prompt_update.content)
        return {"message": f"Prompt 文件 '{filename}' 更新成功"}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"写入文件时出错: {e}")


================================================
FILE: src/api/routes/results.py
================================================
"""
结果文件管理路由
"""
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import Response
from urllib.parse import quote

from src.services.price_history_service import build_price_history_insights
from src.services.result_export_service import build_results_csv
from src.services.result_file_service import (
    enrich_records_with_price_insight,
    validate_result_filename,
)
from src.services.result_storage_service import (
    build_result_ndjson,
    delete_result_file_records,
    list_result_filenames,
    load_all_result_records,
    query_result_records,
    result_file_exists,
)


router = APIRouter(prefix="/api/results", tags=["results"])

DEFAULT_EXPORT_FILENAME = "export.csv"


def _build_download_headers(export_name: str) -> dict[str, str]:
    ascii_name = export_name.encode("ascii", "ignore").decode("ascii")
    if ascii_name != export_name or not ascii_name:
        ascii_name = DEFAULT_EXPORT_FILENAME
    encoded_name = quote(export_name, safe="")
    return {
        "Content-Disposition": (
            f'attachment; filename="{ascii_name}"; '
            f"filename*=UTF-8''{encoded_name}"
        )
    }


@router.get("/files")
async def get_result_files():
    """获取所有结果文件列表"""
    return {"files": await list_result_filenames()}


@router.get("/files/{filename:path}")
async def download_result_file(filename: str):
    """下载指定的结果文件"""
    if ".." in filename or filename.startswith("/"):
        return {"error": "非法的文件路径"}
    if not filename.endswith(".jsonl") or not await result_file_exists(filename):
        return {"error": "文件不存在"}
    return Response(
        content=await build_result_ndjson(filename),
        media_type="application/x-ndjson",
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )


@router.delete("/files/{filename:path}")
async def delete_result_file(filename: str):
    """删除指定的结果文件"""
    if ".." in filename or filename.startswith("/"):
        raise HTTPException(status_code=400, detail="非法的文件路径")
    if not filename.endswith(".jsonl"):
        raise HTTPException(status_code=400, detail="只能删除 .jsonl 文件")
    deleted_rows = await delete_result_file_records(filename)
    if deleted_rows <= 0:
        raise HTTPException(status_code=404, detail="文件不存在")
    return {"message": f"文件 {filename} 已成功删除"}


@router.get("/{filename}")
async def get_result_file_content(
    filename: str,
    page: int = Query(1, ge=1),
    limit: int = Query(20, ge=1, le=100),
    recommended_only: bool = Query(False),  # 兼容旧参数,等价于 ai_recommended_only
    ai_recommended_only: bool = Query(False),
    keyword_recommended_only: bool = Query(False),
    sort_by: str = Query("crawl_time"),
    sort_order: str = Query("desc"),
):
    """读取指定的 .jsonl 文件内容,支持分页、筛选和排序"""
    if ai_recommended_only and keyword_recommended_only:
        raise HTTPException(status_code=400, detail="AI推荐筛选与关键词推荐筛选不能同时开启。")

    if recommended_only and not ai_recommended_only and not keyword_recommended_only:
        ai_recommended_only = True

    try:
        validate_result_filename(filename)
        total_items, items = await query_result_records(
            filename,
            ai_recommended_only=ai_recommended_only,
            keyword_recommended_only=keyword_recommended_only,
            sort_by=sort_by,
            sort_order=sort_order,
            page=page,
            limit=limit,
        )
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"读取结果文件时出错: {exc}")
    if total_items <= 0 and not await result_file_exists(filename):
        raise HTTPException(status_code=404, detail="结果文件未找到")
    paginated_results = enrich_records_with_price_insight(items, filename)

    return {
        "total_items": total_items,
        "page": page,
        "limit": limit,
        "items": paginated_results
    }


@router.get("/{filename}/insights")
async def get_result_file_insights(filename: str):
    try:
        validate_result_filename(filename)
        keyword = filename.replace("_full_data.jsonl", "")
        return build_price_history_insights(keyword)
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))


@router.get("/{filename}/export")
async def export_result_file_content(
    filename: str,
    recommended_only: bool = Query(False),
    ai_recommended_only: bool = Query(False),
    keyword_recommended_only: bool = Query(False),
    sort_by: str = Query("crawl_time"),
    sort_order: str = Query("desc"),
):
    if ai_recommended_only and keyword_recommended_only:
        raise HTTPException(status_code=400, detail="AI推荐筛选与关键词推荐筛选不能同时开启。")
    if recommended_only and not ai_recommended_only and not keyword_recommended_only:
        ai_recommended_only = True

    try:
        validate_result_filename(filename)
        results = await load_all_result_records(
            filename,
            ai_recommended_only=ai_recommended_only,
            keyword_recommended_only=keyword_recommended_only,
            sort_by=sort_by,
            sort_order=sort_order,
        )
        csv_text = build_results_csv(
            enrich_records_with_price_insight(results, filename)
        )
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"导出结果文件时出错: {exc}")
    if not results and not await result_file_exists(filename):
        raise HTTPException(status_code=404, detail="结果文件未找到")

    export_name = filename.replace(".jsonl", ".csv")
    headers = _build_download_headers(export_name)
    return Response(content=csv_text, media_type="text/csv; charset=utf-8", headers=headers)


================================================
FILE: src/api/routes/settings.py
================================================
"""
设置管理路由
"""
import os
from typing import Optional

from dotenv import load_dotenv
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field

from src.api.dependencies import get_process_service
from src.infrastructure.config.env_manager import env_manager
from src.infrastructure.config.settings import (
    AISettings,
    reload_settings,
    scraper_settings,
)
from src.services.ai_request_compat import (
    CHAT_COMPLETIONS_API_MODE,
    RESPONSES_API_MODE,
    build_ai_request_params,
    create_ai_response_sync,
    is_chat_completions_api_unsupported_error,
    is_responses_api_unsupported_error,
)
from src.services.ai_response_parser import extract_ai_response_content
from src.services.notification_config_service import (
    NotificationSettingsValidationError,
    build_configured_channels,
    build_notification_settings_response,
    build_notification_status_flags,
    load_notification_settings,
    model_dump,
    prepare_notification_settings_update,
)
from src.services.notification_service import build_notification_service
from src.services.process_service import ProcessService


router = APIRouter(prefix="/api/settings", tags=["settings"])
AI_TEST_PROMPT = "Reply with OK only."
AI_TEST_MAX_OUTPUT_TOKENS = 32


def _reload_env() -> None:
    load_dotenv(dotenv_path=env_manager.env_file, override=True)
    reload_settings()


def _env_bool(key: str, default: bool = False) -> bool:
    value = env_manager.get_value(key)
    if value is None:
        return default
    return str(value).strip().lower() in {"1", "true", "yes", "y", "on"}


def _env_int(key: str, default: int) -> int:
    value = env_manager.get_value(key)
    if value is None:
        return default
    try:
        return int(value)
    except ValueError:
        return default


def _normalize_bool_value(value: bool) -> str:
    return "true" if value else "false"


class NotificationSettingsModel(BaseModel):
    """通知设置模型"""

    NTFY_TOPIC_URL: Optional[str] = None
    GOTIFY_URL: Optional[str] = None
    GOTIFY_TOKEN: Optional[str] = None
    BARK_URL: Optional[str] = None
    WX_BOT_URL: Optional[str] = None
    TELEGRAM_BOT_TOKEN: Optional[str] = None
    TELEGRAM_CHAT_ID: Optional[str] = None
    TELEGRAM_API_BASE_URL: Optional[str] = None
    WEBHOOK_URL: Optional[str] = None
    WEBHOOK_METHOD: Optional[str] = None
    WEBHOOK_HEADERS: Optional[str] = None
    WEBHOOK_CONTENT_TYPE: Optional[str] = None
    WEBHOOK_QUERY_PARAMETERS: Optional[str] = None
    WEBHOOK_BODY: Optional[str] = None
    PCURL_TO_MOBILE: Optional[bool] = None


class NotificationTestRequest(BaseModel):
    """通知测试请求"""

    channel: Optional[str] = None
    settings: NotificationSettingsModel = Field(default_factory=NotificationSettingsModel)


class AISettingsModel(BaseModel):
    """AI设置模型"""

    OPENAI_API_KEY: Optional[str] = None
    OPENAI_BASE_URL: Optional[str] = None
    OPENAI_MODEL_NAME: Optional[str] = None
    SKIP_AI_ANALYSIS: Optional[bool] = None
    PROXY_URL: Optional[str] = None


class RotationSettingsModel(BaseModel):
    ACCOUNT_ROTATION_ENABLED: Optional[bool] = None
    ACCOUNT_ROTATION_MODE: Optional[str] = None
    ACCOUNT_ROTATION_RETRY_LIMIT: Optional[int] = None
    ACCOUNT_BLACKLIST_TTL: Optional[int] = None
    ACCOUNT_STATE_DIR: Optional[str] = None
    PROXY_ROTATION_ENABLED: Optional[bool] = None
    PROXY_ROTATION_MODE: Optional[str] = None
    PROXY_POOL: Optional[str] = None
    PROXY_ROTATION_RETRY_LIMIT: Optional[int] = None
    PROXY_BLACKLIST_TTL: Optional[int] = None


@router.get("/notifications")
async def get_notification_settings():
    return build_notification_settings_response(load_notification_settings())


@router.put("/notifications")
async def update_notification_settings(settings: NotificationSettingsModel):
    try:
        updates, deletions, merged_settings = prepare_notification_settings_update(
            model_dump(settings, exclude_unset=True),
            load_notification_settings(),
        )
    except NotificationSettingsValidationError as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc

    success = env_manager.apply_changes(updates=updates, deletions=deletions)
    if not success:
        raise HTTPException(status_code=500, detail="更新通知设置失败")

    _reload_env()
    return {
        "message": "通知设置已成功更新",
        "configured_channels": build_configured_channels(merged_settings),
    }


@router.post("/notifications/test")
async def test_notification_settings(payload: NotificationTestRequest):
    try:
        _, _, merged_settings = prepare_notification_settings_update(
            model_dump(payload.settings, exclude_unset=True),
            load_notification_settings(),
        )
    except NotificationSettingsValidationError as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc

    service = build_notification_service(merged_settings)
    if not service.clients:
        raise HTTPException(status_code=422, detail="请至少配置一个可用的通知渠道")

    results = await service.send_test_notification()
    if payload.channel:
        if payload.channel not in results:
            raise HTTPException(
                status_code=422,
                detail=f"渠道 {payload.channel} 未配置或不受支持",
            )
        results = {payload.channel: results[payload.channel]}

    return {
        "message": "测试通知已执行",
        "results": results,
    }


@router.get("/rotation")
async def get_rotation_settings():
    return {
        "ACCOUNT_ROTATION_ENABLED": _env_bool("ACCOUNT_ROTATION_ENABLED", False),
        "ACCOUNT_ROTATION_MODE": env_manager.get_value("ACCOUNT_ROTATION_MODE", "per_task"),
        "ACCOUNT_ROTATION_RETRY_LIMIT": _env_int("ACCOUNT_ROTATION_RETRY_LIMIT", 2),
        "ACCOUNT_BLACKLIST_TTL": _env_int("ACCOUNT_BLACKLIST_TTL", 300),
        "ACCOUNT_STATE_DIR": env_manager.get_value("ACCOUNT_STATE_DIR", "state"),
        "PROXY_ROTATION_ENABLED": _env_bool("PROXY_ROTATION_ENABLED", False),
        "PROXY_ROTATION_MODE": env_manager.get_value("PROXY_ROTATION_MODE", "per_task"),
        "PROXY_POOL": env_manager.get_value("PROXY_POOL", ""),
        "PROXY_ROTATION_RETRY_LIMIT": _env_int("PROXY_ROTATION_RETRY_LIMIT", 2),
        "PROXY_BLACKLIST_TTL": _env_int("PROXY_BLACKLIST_TTL", 300),
    }


@router.put("/rotation")
async def update_rotation_settings(settings: RotationSettingsModel):
    updates = {}
    payload = model_dump(settings, exclude_unset=True)
    for key, value in payload.items():
        if isinstance(value, bool):
            updates[key] = _normalize_bool_value(value)
        else:
            updates[key] = str(value)
    success = env_manager.update_values(updates)
    if not success:
        raise HTTPException(status_code=500, detail="更新轮换设置失败")
    _reload_env()
    return {"message": "轮换设置已成功更新"}


@router.get("/status")
async def get_system_status(
    process_service: ProcessService = Depends(get_process_service),
):
    state_file = "xianyu_state.json"
    login_state_exists = os.path.exists(state_file)
    env_file_exists = os.path.exists(env_manager.env_file)
    openai_api_key = env_manager.get_value("OPENAI_API_KEY", "")
    openai_base_url = env_manager.get_value("OPENAI_BASE_URL", "")
    openai_model_name = env_manager.get_value("OPENAI_MODEL_NAME", "")
    ai_settings = AISettings()
    notification_settings = load_notification_settings()
    running_task_ids = [
        task_id
        for task_id, process in process_service.processes.items()
        if process and process.returncode is None
    ]

    return {
        "ai_configured": ai_settings.is_configured(),
        "notification_configured": notification_settings.has_any_notification_enabled(),
        "headless_mode": scraper_settings.run_headless,
        "running_in_docker": scraper_settings.running_in_docker,
        "scraper_running": len(running_task_ids) > 0,
        "running_task_ids": running_task_ids,
        "login_state_file": {
            "exists": login_state_exists,
            "path": state_file,
        },
        "env_file": {
            "exists": env_file_exists,
            "openai_api_key_set": bool(openai_api_key),
            "openai_base_url_set": bool(openai_base_url),
            "openai_model_name_set": bool(openai_model_name),
            **build_notification_status_flags(notification_settings),
        },
        "configured_notification_channels": build_configured_channels(notification_settings),
    }


@router.get("/ai")
async def get_ai_settings():
    return {
        "OPENAI_BASE_URL": env_manager.get_value("OPENAI_BASE_URL", ""),
        "OPENAI_MODEL_NAME": env_manager.get_value("OPENAI_MODEL_NAME", ""),
        "SKIP_AI_ANALYSIS": env_manager.get_value("SKIP_AI_ANALYSIS", "false").lower() == "true",
        "PROXY_URL": env_manager.get_value("PROXY_URL", ""),
    }


@router.put("/ai")
async def update_ai_settings(settings: AISettingsModel):
    updates = {}
    if settings.OPENAI_API_KEY is not None:
        updates["OPENAI_API_KEY"] = settings.OPENAI_API_KEY
    if settings.OPENAI_BASE_URL is not None:
        updates["OPENAI_BASE_URL"] = settings.OPENAI_BASE_URL
    if settings.OPENAI_MODEL_NAME is not None:
        updates["OPENAI_MODEL_NAME"] = settings.OPENAI_MODEL_NAME
    if settings.SKIP_AI_ANALYSIS is not None:
        updates["SKIP_AI_ANALYSIS"] = str(settings.SKIP_AI_ANALYSIS).lower()
    if settings.PROXY_URL is not None:
        updates["PROXY_URL"] = settings.PROXY_URL

    success = env_manager.update_values(updates)
    if not success:
        raise HTTPException(status_code=500, detail="更新AI设置失败")
    _reload_env()
    return {"message": "AI设置已成功更新"}


@router.post("/ai/test")
async def test_ai_settings(settings: dict):
    """测试AI模型设置是否有效"""
    try:
        from openai import OpenAI
        import httpx

        stored_api_key = env_manager.get_value("OPENAI_API_KEY", "")
        submitted_api_key = settings.get("OPENAI_API_KEY", "")
        api_key = submitted_api_key or stored_api_key

        client_params = {
            "api_key": api_key,
            "base_url": settings.get("OPENAI_BASE_URL", ""),
            "timeout": httpx.Timeout(30.0),
        }

        proxy_url = settings.get("PROXY_URL", "")
        if proxy_url:
            client_params["http_client"] = httpx.Client(proxy=proxy_url)

        model_name = settings.get("OPENAI_MODEL_NAME", "")
        client = OpenAI(**client_params)
        messages = [{"role": "user", "content": AI_TEST_PROMPT}]
        api_mode = CHAT_COMPLETIONS_API_MODE

        try:
            response = create_ai_response_sync(
                client,
                api_mode,
                build_ai_request_params(
                    api_mode,
                    model=model_name,
                    messages=messages,
                    max_output_tokens=AI_TEST_MAX_OUTPUT_TOKENS,
                ),
            )
        except Exception as exc:
            if not is_chat_completions_api_unsupported_error(exc):
                raise
            api_mode = RESPONSES_API_MODE
            response = create_ai_response_sync(
                client,
                api_mode,
                build_ai_request_params(
                    api_mode,
                    model=model_name,
                    messages=messages,
                    max_output_tokens=AI_TEST_MAX_OUTPUT_TOKENS,
                ),
            )

        return {
            "success": True,
            "message": "AI模型连接测试成功!",
            "response": extract_ai_response_content(response),
        }
    except Exception as exc:
        return {
            "success": False,
            "message": f"AI模型连接测试失败: {exc}",
        }


================================================
FILE: src/api/routes/tasks.py
================================================
"""
任务管理路由
"""
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from typing import List
import os
import aiofiles
from src.api.dependencies import (
    get_process_service,
    get_scheduler_service,
    get_task_generation_service,
    get_task_service,
)
from src.services.task_service import TaskService
from src.services.process_service import ProcessService
from src.services.scheduler_service import SchedulerService
from src.services.task_generation_service import TaskGenerationService
from src.services.task_generation_runner import (
    build_task_create,
    run_ai_generation_job,
)
from src.services.task_payloads import serialize_task, serialize_tasks
from src.domain.models.task import TaskCreate, TaskUpdate, TaskGenerateRequest
from src.prompt_utils import generate_criteria
from src.utils import resolve_task_log_path
from src.services.account_strategy_service import normalize_account_strategy
from src.infrastructure.persistence.storage_names import build_result_filename
from src.services.price_history_service import delete_price_snapshots
from src.services.result_storage_service import delete_result_file_records
router = APIRouter(prefix="/api/tasks", tags=["tasks"])

async def _reload_scheduler_if_needed(
    task_service: TaskService,
    scheduler_service: SchedulerService,
):
    tasks = await task_service.get_all_tasks()
    await scheduler_service.reload_jobs(tasks)


def _has_keyword_rules(rules) -> bool:
    return bool(rules and len(rules) > 0)


def _validate_final_account_strategy(existing_task, task_update: TaskUpdate) -> None:
    account_state_file = (
        task_update.account_state_file
        if task_update.account_state_file is not None
        else existing_task.account_state_file
    )
    account_strategy = normalize_account_strategy(
        task_update.account_strategy,
        account_state_file,
    )
    task_update.account_strategy = account_strategy
    if account_strategy == "fixed" and not account_state_file:
        raise HTTPException(status_code=400, detail="固定账号模式下必须选择账号。")
@router.get("", response_model=List[dict])
async def get_tasks(
    service: TaskService = Depends(get_task_service),
    scheduler_service: SchedulerService = Depends(get_scheduler_service),
):
    """获取所有任务"""
    tasks = await service.get_all_tasks()
    return serialize_tasks(tasks, scheduler_service)
@router.get("/{task_id}", response_model=dict)
async def get_task(
    task_id: int,
    service: TaskService = Depends(get_task_service),
    scheduler_service: SchedulerService = Depends(get_scheduler_service),
):
    """获取单个任务"""
    task = await service.get_task(task_id)
    if not task:
        raise HTTPException(status_code=404, detail="任务未找到")
    return serialize_task(task, scheduler_service)
@router.post("/", response_model=dict)
async def create_task(
    task_create: TaskCreate,
    service: TaskService = Depends(get_task_service),
    scheduler_service: SchedulerService = Depends(get_scheduler_service),
):
    """创建新任务"""
    task = await service.create_task(task_create)
    await _reload_scheduler_if_needed(service, scheduler_service)
    return {"message": "任务创建成功", "task": serialize_task(task, scheduler_service)}
@router.post("/generate", response_model=dict)
async def generate_task(
    req: TaskGenerateRequest,
    service: TaskService = Depends(get_task_service),
    scheduler_service: SchedulerService = Depends(get_scheduler_service),
    generation_service: TaskGenerationService = Depends(get_task_generation_service),
):
    """创建任务。AI模式会生成分析标准,关键词模式直接保存规则。"""
    print(f"收到任务生成请求: {req.task_name},模式: {req.decision_mode}")

    try:
        mode = req.decision_mode or "ai"
        if mode == "ai":
            job = await generation_service.create_job(req.task_name)
            generation_service.track(
                run_ai_generation_job(
                    job_id=job.job_id,
                    req=req,
                    task_service=service,
                    scheduler_service=scheduler_service,
                    generation_service=generation_service,
                )
            )
            return JSONResponse(
                status_code=202,
                content={
                    "message": "AI 任务生成已开始。",
                    "job": job.model_dump(mode="json"),
                },
            )

        task = await service.create_task(build_task_create(req, ""))
        await _reload_scheduler_if_needed(service, scheduler_service)
        return {"message": "任务创建成功。", "task": serialize_task(task, scheduler_service)}

    except HTTPException:
        raise
    except Exception as e:
        error_msg = f"AI任务生成API发生未知错误: {str(e)}"
        print(error_msg)
        import traceback
        print(traceback.format_exc())
        raise HTTPException(status_code=500, detail=error_msg)
@router.get("/generate-jobs/{job_id}", response_model=dict)
async def get_task_generation_job(
    job_id: str,
    generation_service: TaskGenerationService = Depends(get_task_generation_service),
):
    """获取任务生成作业状态"""
    job = await generation_service.get_job(job_id)
    if not job:
        raise HTTPException(status_code=404, detail="任务生成作业未找到")
    return {"job": job.model_dump(mode="json")}
@router.patch("/{task_id}", response_model=dict)
async def update_task(
    task_id: int,
    task_update: TaskUpdate,
    service: TaskService = Depends(get_task_service),
    scheduler_service: SchedulerService = Depends(get_scheduler_service),
):
    """更新任务"""
    try:
        existing_task = await service.get_task(task_id)
        if not existing_task:
            raise HTTPException(status_code=404, detail="任务未找到")
        _validate_final_account_strategy(existing_task, task_update)

        current_mode = getattr(existing_task, "decision_mode", "ai") or "ai"
        target_mode = task_update.decision_mode or current_mode
        description_changed = (
            task_update.description is not None
            and task_update.description != existing_task.description
        )
        switched_to_ai = current_mode != "ai" and target_mode == "ai"

        if target_mode == "keyword":
            final_rules = (
                task_update.keyword_rules
                if task_update.keyword_rules is not None
                else getattr(existing_task, "keyword_rules", [])
            )
            if not _has_keyword_rules(final_rules):
                raise HTTPException(status_code=400, detail="关键词模式下至少需要一个关键词。")
        if target_mode == "ai" and (description_changed or switched_to_ai):
            print(f"检测到任务 {task_id} 需要刷新 AI 标准文件,开始重新生成...")
            try:
                description_for_ai = (
                    task_update.description
                    if task_update.description is not None
                    else existing_task.description
                )
                if not str(description_for_ai or "").strip():
                    raise HTTPException(status_code=400, detail="AI 模式下详细需求不能为空。")
                safe_keyword = "".join(
                    c for c in existing_task.keyword.lower().replace(' ', '_')
                    if c.isalnum() or c in "_-"
                ).rstrip()
                output_filename = f"prompts/{safe_keyword}_criteria.txt"
                print(f"目标文件路径: {output_filename}")
                print("开始调用 AI 生成新的分析标准...")
                generated_criteria = await generate_criteria(
                    user_description=description_for_ai,
                    reference_file_path="prompts/macbook_criteria.txt"
                )
                if not generated_criteria or len(generated_criteria.strip()) == 0:
                    print("AI 返回的内容为空")
                    raise HTTPException(status_code=500, detail="AI 未能生成分析标准,返回内容为空。")
                print(f"保存新的分析标准到: {output_filename}")
                os.makedirs("prompts", exist_ok=True)
                async with aiofiles.open(output_filename, 'w', encoding='utf-8') as f:
                    await f.write(generated_criteria)
                print(f"新的分析标准已保存")
                task_update.ai_prompt_criteria_file = output_filename
                print(f"已更新 ai_prompt_criteria_file 字段为: {output_filename}")
            except HTTPException:
                raise
            except Exception as e:
                error_msg = f"重新生成 criteria 文件时出错: {str(e)}"
                print(error_msg)
                import traceback
                print(traceback.format_exc())
                raise HTTPException(status_code=500, detail=error_msg)
        task = await service.update_task(task_id, task_update)
        await _reload_scheduler_if_needed(service, scheduler_service)
        return {"message": "任务更新成功", "task": serialize_task(task, scheduler_service)}
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))
@router.delete("/{task_id}", response_model=dict)
async def delete_task(
    task_id: int,
    service: TaskService = Depends(get_task_service),
    process_service: ProcessService = Depends(get_process_service),
    scheduler_service: SchedulerService = Depends(get_scheduler_service),
):
    """删除任务"""
    task = await service.get_task(task_id)
    if not task:
        raise HTTPException(status_code=404, detail="任务未找到")

    await process_service.stop_task(task_id)
    success = await service.delete_task(task_id)
    if not success:
        raise HTTPException(status_code=404, detail="任务未找到")
    await _reload_scheduler_if_needed(service, scheduler_service)
    try:
        keyword = (task.keyword or "").strip()
        if keyword:
            remaining_tasks = await service.get_all_tasks()
            keyword_still_in_use = any(
                (remaining_task.keyword or "").strip() == keyword
                for remaining_task in remaining_tasks
            )
            if not keyword_still_in_use:
                await delete_result_file_records(build_result_filename(keyword))
                delete_price_snapshots(keyword)
    except Exception as e:
        print(f"删除任务结果文件时出错: {e}")

    try:
        log_file_path = resolve_task_log_path(task_id, task.task_name)
        if os.path.exists(log_file_path):
            os.remove(log_file_path)
    except Exception as e:
        print(f"删除任务日志文件时出错: {e}")
    return {"message": "任务删除成功"}
@router.post("/start/{task_id}", response_model=dict)
async def start_task(
    task_id: int,
    task_service: TaskService = Depends(get_task_service),
    process_service: ProcessService = Depends(get_process_service),
):
    """启动单个任务"""
    task = await task_service.get_task(task_id)
    if not task:
        raise HTTPException(status_code=404, detail="任务未找到")
    if not task.enabled:
        raise HTTPException(status_code=400, detail="任务已被禁用,无法启动")
    if task.is_running:
        raise HTTPException(status_code=400, detail="任务已在运行中")
    success = await process_service.start_task(task_id, task.task_name)
    if not success:
        raise HTTPException(status_code=500, detail="启动任务失败")
    return {"message": f"任务 '{task.task_name}' 已启动"}
@router.post("/stop/{task_id}", response_model=dict)
async def stop_task(
    task_id: int,
    task_service: TaskService = Depends(get_task_service),
    process_service: ProcessService = Depends(get_process_service),
):
    """停止单个任务"""
    task = await task_service.get_task(task_id)
    if not task:
        raise HTTPException(status_code=404, detail="任务未找到")
    await process_service.stop_task(task_id)
    return {"message": f"任务ID {task_id} 已发送停止信号"}


================================================
FILE: src/api/routes/websocket.py
================================================
"""
WebSocket 路由
提供实时通信功能
"""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from typing import Set


router = APIRouter()

# 全局 WebSocket 连接管理
active_connections: Set[WebSocket] = set()


@router.websocket("/ws")
async def websocket_endpoint(
    websocket: WebSocket,
):
    """WebSocket 端点"""
    # 接受连接
    await websocket.accept()
    active_connections.add(websocket)

    try:
        # 保持连接并接收消息
        while True:
            # 接收客户端消息(如果有的话)
            data = await websocket.receive_text()
            # 这里可以处理客户端发送的消息
            # 目前我们主要用于服务端推送,所以暂时不处理
    except WebSocketDisconnect:
        active_connections.remove(websocket)
    except Exception as e:
        print(f"WebSocket 错误: {e}")
        if websocket in active_connections:
            active_connections.remove(websocket)


async def broadcast_message(message_type: str, data: dict):
    """向所有连接的客户端广播消息"""
    message = {
        "type": message_type,
        "data": data
    }

    # 移除已断开的连接
    disconnected = set()

    for connection in active_connections:
        try:
            await connection.send_json(message)
        except Exception:
            disconnected.add(connection)

    # 清理断开的连接
    for connection in disconnected:
        active_connections.discard(connection)


================================================
FILE: src/app.py
================================================
"""
新架构的主应用入口
整合所有路由和服务
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

from src.api.routes import (
    dashboard,
    tasks,
    logs,
    settings,
    prompts,
    results,
    login_state,
    websocket,
    accounts,
)
from src.api.dependencies import (
    set_process_service,
    set_scheduler_service,
    set_task_generation_service,
)
from src.services.task_service import TaskService
from src.services.process_service import ProcessService
from src.services.scheduler_service import SchedulerService
from src.services.task_log_cleanup_service import cleanup_task_logs
from src.services.task_generation_service import TaskGenerationService
from src.infrastructure.persistence.sqlite_bootstrap import bootstrap_sqlite_storage
from src.infrastructure.persistence.sqlite_task_repository import SqliteTaskRepository
from src.infrastructure.config.settings import settings as app_settings


# 全局服务实例
process_service = ProcessService()
scheduler_service = SchedulerService(process_service)
task_generation_service = TaskGenerationService()


async def _sync_task_runtime_status(task_id: int, is_running: bool) -> None:
    task_service = TaskService(SqliteTaskRepository())
    task = await task_service.get_task(task_id)
    if not task or task.is_running == is_running:
        return
    await task_service.update_task_status(task_id, is_running)
    await websocket.broadcast_message(
        "task_status_changed",
        {"id": task_id, "is_running": is_running},
    )


process_service.set_lifecycle_hooks(
    on_started=lambda task_id: _sync_task_runtime_status(task_id, True),
    on_stopped=lambda task_id: _sync_task_runtime_status(task_id, False),
)

# 设置全局 ProcessService 实例供依赖注入使用
set_process_service(process_service)
set_scheduler_service(scheduler_service)
set_task_generation_service(task_generation_service)


@asynccontextmanager
async def lifespan(app: FastAPI):
    """应用生命周期管理"""
    # 启动时
    print("正在启动应用...")
    bootstrap_sqlite_storage()
    cleanup_task_logs(keep_days=app_settings.task_log_retention_days)

    # 重置所有任务状态为停止
    task_repo = SqliteTaskRepository()
    task_service = TaskService(task_repo)
    tasks_list = await task_service.get_all_tasks()

    for task in tasks_list:
        if task.is_running:
            await task_service.update_task_status(task.id, False)

    # 加载定时任务
    await scheduler_service.reload_jobs(tasks_list)
    scheduler_service.start()

    print("应用启动完成")

    yield

    # 关闭时
    print("正在关闭应用...")
    scheduler_service.stop()
    await process_service.stop_all()
    print("应用已关闭")


# 创建 FastAPI 应用
app = FastAPI(
    title="闲鱼智能监控机器人",
    description="基于AI的闲鱼商品监控系统",
    version="2.0.0",
    lifespan=lifespan
)

# 注册路由
app.include_router(tasks.router)
app.include_router(dashboard.router)
app.include_router(logs.router)
app.include_router(settings.router)
app.include_router(prompts.router)
app.include_router(results.router)
app.include_router(login_state.router)
app.include_router(websocket.router)
app.include_router(accounts.router)

# 挂载静态文件
# 旧的静态文件目录(用于截图等)
app.mount("/static", StaticFiles(directory="static"), name="static")

# 挂载 Vue 3 前端构建产物
# 注意:需要在所有 API 路由之后挂载,以避免覆盖 API 路由
import os
if os.path.exists("dist"):
    app.mount("/assets", StaticFiles(directory="dist/assets"), name="assets")


# 健康检查端点
@app.get("/health")
async def health_check():
    """健康检查(无需认证)"""
    return {"status": "healthy", "message": "服务正常运行"}


# 认证状态检查端点
from fastapi import Request, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel

class LoginRequest(BaseModel):
    username: str
    password: str


@app.post("/auth/status")
async def auth_status(payload: LoginRequest):
    """检查认证状态"""
    if payload.username == app_settings.web_username and payload.password == app_settings.web_password:
        return {"authenticated": True, "username": payload.username}
    raise HTTPException(status_code=401, detail="认证失败")


# 主页路由 - 服务 Vue 3 SPA
from fastapi.responses import JSONResponse

@app.get("/")
async def read_root(request: Request):
    """提供 Vue 3 SPA 的主页面"""
    if os.path.exists("dist/index.html"):
        return FileResponse("dist/index.html")
    else:
        return JSONResponse(
            status_code=500,
            content={"error": "前端构建产物不存在,请先运行 cd web-ui && npm run build"}
        )


# Catch-all 路由 - 处理所有前端路由(必须放在最后)
@app.get("/{full_path:path}")
async def serve_spa(request: Request, full_path: str):
    """
    Catch-all 路由,将所有非 API 请求重定向到 index.html
    这样可以支持 Vue Router 的 HTML5 History 模式
    """
    # 如果请求的是静态资源(如 favicon.ico),返回 404
    if full_path.endswith(('.ico', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.css', '.js', '.json')):
        return JSONResponse(status_code=404, content={"error": "资源未找到"})

    # 其他所有路径都返回 index.html,让前端路由处理
    if os.path.exists("dist/index.html"):
        return FileResponse("dist/index.html")
    else:
        return JSONResponse(
            status_code=500,
            content={"error": "前端构建产物不存在,请先运行 cd web-ui && npm run build"}
        )


if __name__ == "__main__":
    import uvicorn
    from src.infrastructure.config.settings import settings

    print(f"启动新架构应用,端口: {app_settings.server_port}")
    uvicorn.run(app, host="0.0.0.0", port=app_settings.server_port)


================================================
FILE: src/config.py
================================================
import os
import sys

from dotenv import load_dotenv
from openai import AsyncOpenAI

# --- AI & Notification Configuration ---
load_dotenv()

# --- File Paths & Directories ---
STATE_FILE = "xianyu_state.json"
IMAGE_SAVE_DIR = "images"
CONFIG_FILE = "config.json"
os.makedirs(IMAGE_SAVE_DIR, exist_ok=True)

# 任务隔离的临时图片目录前缀
TASK_IMAGE_DIR_PREFIX = "task_images_"

# --- API URL Patterns ---
API_URL_PATTERN = "h5api.m.goofish.com/h5/mtop.taobao.idlemtopsearch.pc.search"
DETAIL_API_URL_PATTERN = "h5api.m.goofish.com/h5/mtop.taobao.idle.pc.detail"

# --- Environment Variables ---
API_KEY = os.getenv("OPENAI_API_KEY")
BASE_URL = os.getenv("OPENAI_BASE_URL")
MODEL_NAME = os.getenv("OPENAI_MODEL_NAME")
PROXY_URL = os.getenv("PROXY_URL")
NTFY_TOPIC_URL = os.getenv("NTFY_TOPIC_URL")
GOTIFY_URL = os.getenv("GOTIFY_URL")
GOTIFY_TOKEN = os.getenv("GOTIFY_TOKEN")
BARK_URL = os.getenv("BARK_URL")
WX_BOT_URL = os.getenv("WX_BOT_URL")
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID")
WEBHOOK_URL = os.getenv("WEBHOOK_URL")
WEBHOOK_METHOD = os.getenv("WEBHOOK_METHOD", "POST").upper()
WEBHOOK_HEADERS = os.getenv("WEBHOOK_HEADERS")
WEBHOOK_CONTENT_TYPE = os.getenv("WEBHOOK_CONTENT_TYPE", "JSON").upper()
WEBHOOK_QUERY_PARAMETERS = os.getenv("WEBHOOK_QUERY_PARAMETERS")
WEBHOOK_BODY = os.getenv("WEBHOOK_BODY")
PCURL_TO_MOBILE = os.getenv("PCURL_TO_MOBILE", "false").lower() == "true"
RUN_HEADLESS = os.getenv("RUN_HEADLESS", "true").lower() != "false"
LOGIN_IS_EDGE = os.getenv("LOGIN_IS_EDGE", "false").lower() == "true"
RUNNING_IN_DOCKER = os.getenv("RUNNING_IN_DOCKER", "false").lower() == "true"
AI_DEBUG_MODE = os.getenv("AI_DEBUG_MODE", "false").lower() == "true"
SKIP_AI_ANALYSIS = os.getenv("SKIP_AI_ANALYSIS", "false").lower() == "true"
ENABLE_THINKING = os.getenv("ENABLE_THINKING", "false").lower() == "true"
ENABLE_RESPONSE_FORMAT = os.getenv("ENABLE_RESPONSE_FORMAT", "true").lower() == "true"

# --- Headers ---
IMAGE_DOWNLOAD_HEADERS = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0',
    'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
    'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
    'Connection': 'keep-alive',
    'Upgrade-Insecure-Requests': '1',
}

# --- Client Initialization ---
# 检查配置是否齐全
if not all([BASE_URL, MODEL_NAME]):
    print("警告:未在 .env 文件中完整设置 OPENAI_BASE_URL 和 OPENAI_MODEL_NAME。AI相关功能可能无法使用。")
    client = None
else:
    try:
        if PROXY_URL:
            print(f"正在为AI请求使用HTTP/S代理: {PROXY_URL}")
            # httpx 会自动从环境变量中读取代理设置
            os.environ['HTTP_PROXY'] = PROXY_URL
            os.environ['HTTPS_PROXY'] = PROXY_URL

        # openai 客户端内部的 httpx 会自动从环境变量中获取代理配置
        client = AsyncOpenAI(api_key=API_KEY, base_url=BASE_URL)
    except Exception as e:
        print(f"初始化 OpenAI 客户端时出错: {e}")
        client = None

# 检查AI客户端是否成功初始化
if not client:
    # 在 prompt_generator.py 中,如果 client 为 None,会直接报错退出
    # 在 spider_v2.py 中,AI分析会跳过
    # 为了保持一致性,这里只打印警告,具体逻辑由调用方处理
    pass

# 检查关键配置
if not all([BASE_URL, MODEL_NAME]) and 'prompt_generator.py' in sys.argv[0]:
    sys.exit("错误:请确保在 .env 文件中完整设置了 OPENAI_BASE_URL 和 OPENAI_MODEL_NAME。(OPENAI_API_KEY 对于某些服务是可选的)")

def get_ai_request_params(**kwargs):
    """
    构建AI请求参数,根据ENABLE_THINKING和ENABLE_RESPONSE_FORMAT环境变量决定是否添加相应参数
    """
    if ENABLE_THINKING:
        kwargs["extra_body"] = {"enable_thinking": False}
    
    # 如果禁用结构化输出,则移除 text.format 配置
    if not ENABLE_RESPONSE_FORMAT and "text" in kwargs:
        text_config = kwargs.get("text")
        if isinstance(text_config, dict):
            text_config = dict(text_config)
            text_config.pop("format", None)
            if text_config:
                kwargs["text"] = text_config
            else:
                del kwargs["text"]
    
    return kwargs


================================================
FILE: src/core/cron_utils.py
================================================
"""
Cron 解析与校验工具。
"""
from __future__ import annotations

from typing import Optional

from apscheduler.triggers.cron import CronTrigger

CRON_ALIASES = {
    "@yearly": "0 0 1 1 *",
    "@annually": "0 0 1 1 *",
    "@monthly": "0 0 1 * *",
    "@weekly": "0 0 * * 0",
    "@daily": "0 0 * * *",
    "@midnight": "0 0 * * *",
    "@hourly": "0 * * * *",
}

CRON_FORMAT_HINT = (
    "Cron 表达式无效。支持 5 段(分 时 日 月 周)、"
    "6 段(秒 分 时 日 月 周)和常见别名(@hourly/@daily/@weekly/@monthly/@yearly)。"
    "示例:*/15 * * * *、0 8 * * *、0 0 8 * * *、@daily。"
)


def normalize_cron_expression(value: Optional[str]) -> Optional[str]:
    if value is None:
        return None

    normalized = " ".join(str(value).strip().split())
    if not normalized:
        return None

    return CRON_ALIASES.get(normalized.lower(), normalized)


def build_cron_trigger(
    expression: str,
    *,
    timezone=None,
) -> CronTrigger:
    normalized = normalize_cron_expression(expression)
    if normalized is None:
        raise ValueError(CRON_FORMAT_HINT)

    parts = normalized.split()
    try:
        if len(parts) == 5:
            return CronTrigger.from_crontab(normalized, timezone=timezone)

        if len(parts) == 6:
            second, minute, hour, day, month, day_of_week = parts
            return CronTrigger(
                second=second,
                minute=minute,
                hour=hour,
                day=day,
                month=month,
                day_of_week=day_of_week,
                timezone=timezone,
            )
    except ValueError as exc:
        raise ValueError(CRON_FORMAT_HINT) from exc

    raise ValueError(CRON_FORMAT_HINT)


def validate_cron_expression(value: Optional[str]) -> Optional[str]:
    normalized = normalize_cron_expression(value)
    if normalized is None:
        return None

    build_cron_trigger(normalized)
    return normalized


================================================
FILE: src/domain/__init__.py
================================================


================================================
FILE: src/domain/models/__init__.py
================================================
from .task import Task, TaskCreate, TaskUpdate, TaskStatus

__all__ = ["Task", "TaskCreate", "TaskUpdate", "TaskStatus"]


================================================
FILE: src/domain/models/task.py
================================================
"""
任务领域模型
定义任务实体及其业务逻辑
"""
import re
from enum import Enum
from typing import Any, List, Literal, Optional

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

from src.core.cron_utils import validate_cron_expression
from src.services.account_strategy_service import (
    clean_account_state_file,
    normalize_account_strategy,
)


class TaskStatus(str, Enum):
    """任务状态枚举"""

    STOPPED = "stopped"
    RUNNING = "running"
    SCHEDULED = "scheduled"


def _normalize_keyword_values(value) -> List[str]:
    if value is None:
        return []

    raw_values = []
    if isinstance(value, (list, tuple, set)):
        raw_values = list(value)
    elif isinstance(value, str):
        raw_values = re.split(r"[\n,]+", value)
    else:
        raw_values = [value]

    normalized: List[str] = []
    seen = set()
    for item in raw_values:
        text = str(item).strip()
        if not text:
            continue
        dedup_key = text.lower()
        if dedup_key in seen:
            continue
        seen.add(dedup_key)
        normalized.append(text)
    return normalized


def _extract_keywords_from_legacy_groups(groups) -> List[str]:
    if not groups:
        return []

    merged: List[str] = []
    for group in groups:
        include_keywords = []
        if isinstance(group, dict):
            include_keywords = group.get("include_keywords") or []
        else:
            include_keywords = getattr(group, "include_keywords", []) or []
        merged.extend(_normalize_keyword_values(include_keywords))
    return _normalize_keyword_values(merged)


def _normalize_payload_keywords(payload: Any) -> Any:
    if payload is None or not isinstance(payload, dict):
        return payload
    values = dict(payload)
    values["account_state_file"] = clean_account_state_file(values.get("account_state_file"))
    values["account_strategy"] = normalize_account_strategy(
        values.get("account_strategy"),
        values.get("account_state_file"),
    )
    if "keyword_rules" in values:
        values["keyword_rules"] = _normalize_keyword_values(values.get("keyword_rules"))
    elif "keyword_rule_groups" in values:
        values["keyword_rules"] = _extract_keywords_from_legacy_groups(
            values.get("keyword_rule_groups")
        )
    return values


def _has_keyword_rules(keyword_rules: List[str]) -> bool:
    return bool(keyword_rules and len(keyword_rules) > 0)


def _normalize_optional_string(value):
    if value == "" or value == "null" or value == "undefined" or value is None:
        return None
    return value


def _validate_cron_expression(value: Optional[str]) -> Optional[str]:
    return validate_cron_expression(value)


def _normalize_price_value(value):
    if _normalize_optional_string(value) is None:
        return None
    if isinstance(value, (int, float)):
        return str(value)
    return value


class Task(BaseModel):
    """任务实体"""

    model_config = ConfigDict(use_enum_values=True, extra="ignore")

    id: Optional[int] = None
    task_name: str
    enabled: bool
    keyword: str
    description: Optional[str] = ""
    analyze_images: bool = True
    max_pages: int
    personal_only: bool
    min_price: Optional[str] = None
    max_price: Optional[str] = None
    cron: Optional[str] = None
    ai_prompt_base_file: str
    ai_prompt_criteria_file: str
    account_state_file: Optional[str] = None
    account_strategy: Literal["auto", "fixed", "rotate"] = "auto"
    free_shipping: bool = True
    new_publish_option: Optional[str] = None
    region: Optional[str] = None
    decision_mode: Literal["ai", "keyword"] = "ai"
    keyword_rules: List[str] = Field(default_factory=list)
    is_running: bool = False

    @model_validator(mode="before")
    @classmethod
    def normalize_legacy_keyword_payload(cls, values):
        return _normalize_payload_keywords(values)

    @field_validator("keyword_rules", mode="before")
    @classmethod
    def normalize_keyword_rules(cls, value):
        return _normalize_keyword_values(value)

    def can_start(self) -> bool:
        """检查任务是否可以启动"""
        return self.enabled and not self.is_running

    def can_stop(self) -> bool:
        """检查任务是否可以停止"""
        return self.is_running

    def apply_update(self, update: "TaskUpdate") -> "Task":
        """应用更新并返回新的任务实例"""
        update_data = update.model_dump(exclude_unset=True)
        return self.model_copy(update=update_data)


class TaskCreate(BaseModel):
    """创建任务的DTO"""

    model_config = ConfigDict(extra="ignore")

    task_name: str
    enabled: bool = True
    keyword: str
    description: Optional[str] = ""
    analyze_images: bool = True
    max_pages: int = 3
    personal_only: bool = True
    min_price: Optional[str] = None
    max_price: Optional[str] = None
    cron: Optional[str] = None
    ai_prompt_base_file: str = "prompts/base_prompt.txt"
    ai_prompt_criteria_file: str = ""
    account_state_file: Optional[str] = None
    account_strategy: Literal["auto", "fixed", "rotate"] = "auto"
    free_shipping: bool = True
    new_publish_option: Optional[str] = None
    region: Optional[str] = None
    decision_mode: Literal["ai", "keyword"] = "ai"
    keyword_rules: List[str] = Field(default_factory=list)

    @model_validator(mode="before")
    @classmethod
    def normalize_legacy_keyword_payload(cls, values):
        return _normalize_payload_keywords(values)

    @field_validator("min_price", "max_price", mode="before")
    @classmethod
    def convert_price_to_str(cls, value):
        return _normalize_price_value(value)

    @field_validator("cron", mode="before")
    @classmethod
    def normalize_cron(cls, value):
        return _normalize_optional_string(value)

    @field_validator("account_state_file", mode="before")
    @classmethod
    def normalize_account_state_file(cls, value):
        return clean_account_state_file(value)

    @field_validator("cron")
    @classmethod
    def validate_cron(cls, value):
        return _validate_cron_expression(value)

    @field_validator("keyword_rules", mode="before")
    @classmethod
    def normalize_keyword_rules(cls, value):
        return _normalize_keyword_values(value)

    @model_validator(mode="after")
    def validate_decision_mode_payload(self):
        description = str(self.description or "").strip()
        if self.decision_mode == "ai" and not description:
            raise ValueError("AI 判断模式下,详细需求(description)不能为空。")
        if self.decision_mode == "keyword" and not _has_keyword_rules(self.keyword_rules):
            raise ValueError("关键词判断模式下,至少需要一个关键词。")
        if self.account_strategy == "fixed" and not self.account_state_file:
            raise ValueError("固定账号模式下必须选择账号。")
        return self


class TaskUpdate(BaseModel):
    """更新任务的DTO"""

    model_config = ConfigDict(extra="ignore")

    task_name: Optional[str] = None
    enabled: Optional[bool] = None
    keyword: Optional[str] = None
    description: Optional[str] = None
    analyze_images: Optional[bool] = None
    max_pages: Optional[int] = None
    personal_only: Optional[bool] = None
    min_price: Optional[str] = None
    max_price: Optional[str] = None
    cron: Optional[str] = None
    ai_prompt_base_file: Optional[str] = None
    ai_prompt_criteria_file: Optional[str] = None
    account_state_file: Optional[str] = None
    account_strategy: Optional[Literal["auto", "fixed", "rotate"]] = None
    free_shipping: Optional[bool] = None
    new_publish_option: Optional[str] = None
    region: Optional[str] = None
    decision_mode: Optional[Literal["ai", "keyword"]] = None
    keyword_rules: Optional[List[str]] = None
    is_running: Optional[bool] = None

    @model_validator(mode="before")
    @classmethod
    def normalize_legacy_keyword_payload(cls, values):
        return _normalize_payload_keywords(values)

    @field_validator("min_price", "max_price", mode="before")
    @classmethod
    def convert_price_to_str(cls, value):
        return _normalize_price_value(value)

    @field_validator("cron", mode="before")
    @classmethod
    def normalize_cron(cls, value):
        return _normalize_optional_string(value)

    @field_validator("account_state_file", mode="before")
    @classmethod
    def normalize_account_state_file(cls, value):
        return clean_account_state_file(value)

    @field_validator("cron")
    @classmethod
    def validate_cron(cls, value):
        return _validate_cron_expression(value)

    @field_validator("keyword_rules", mode="before")
    @classmethod
    def normalize_keyword_rules(cls, value):
        return _normalize_keyword_values(value)

    @model_validator(mode="after")
    def validate_partial_keyword_payload(self):
        if self.decision_mode == "keyword" and self.keyword_rules is not None:
            if not _has_keyword_rules(self.keyword_rules):
                raise ValueError("关键词判断模式下,至少需要一个关键词。")
        if self.decision_mode == "ai" and self.description is not None:
            if not str(self.description).strip():
                raise ValueError("AI 判断模式下,详细需求(description)不能为空。")
        return self


class TaskGenerateRequest(BaseModel):
    """任务创建请求DTO(AI模式支持自动生成标准)"""

    model_config = ConfigDict(extra="ignore")

    task_name: str
    keyword: str
    description: Optional[str] = ""
    analyze_images: bool = True
    personal_only: bool = True
    min_price: Optional[str] = None
    max_price: Optional[str] = None
    max_pages: int = 3
    cron: Optional[str] = None
    account_state_file: Optional[str] = None
    account_strategy: Literal["auto", "fixed", "rotate"] = "auto"
    free_shipping: bool = True
    new_publish_option: Optional[str] = None
    region: Optional[str] = None
    decision_mode: Literal["ai", "keyword"] = "ai"
    keyword_rules: List[str] = Field(default_factory=list)

    @model_validator(mode="before")
    @classmethod
    def normalize_legacy_keyword_payload(cls, values):
        return _normalize_payload_keywords(values)

    @field_validator("min_price", "max_price", mode="before")
    @classmethod
    def convert_price_to_str(cls, value):
        return _normalize_price_value(value)

    @field_validator("cron", mode="before")
    @classmethod
    def empty_str_to_none(cls, value):
        return _normalize_optional_string(value)

    @field_validator("cron")
    @classmethod
    def validate_cron(cls, value):
        return _validate_cron_expression(value)

    @field_validator("account_state_file", mode="before")
    @classmethod
    def empty_account_to_none(cls, value):
        return _normalize_optional_string(value)

    @field_validator("new_publish_option", "region", mode="before")
    @classmethod
    def empty_str_to_none_for_strings(cls, value):
        return _normalize_optional_string(value)

    @field_validator("keyword_rules", mode="before")
    @classmethod
    def normalize_keyword_rules(cls, value):
        return _normalize_keyword_values(value)

    @model_validator(mode="after")
    def validate_decision_mode_payload(self):
        description = str(self.description or "").strip()
        if self.decision_mode == "ai" and not description:
            raise ValueError("AI 判断模式下,详细需求(description)不能为空。")
        if self.decision_mode == "keyword" and not _has_keyword_rules(self.keyword_rules):
            raise ValueError("关键词判断模式下,至少需要一个关键词。")
        if self.account_strategy == "fixed" and not self.account_state_file:
            raise ValueError("固定账号模式下必须选择账号。")
        return self


================================================
FILE: src/domain/models/task_generation.py
================================================
"""
任务生成作业模型
"""
from typing import List, Literal, Optional

from pydantic import BaseModel, Field

from src.domain.models.task import Task


TaskGenerationStatus = Literal["queued", "running", "completed", "failed"]
TaskGenerationStepStatus = Literal["pending", "running", "completed", "failed"]


class TaskGenerationStep(BaseModel):
    """单个任务生成步骤"""

    key: str
    label: str
    status: TaskGenerationStepStatus = "pending"
    message: str = ""


class TaskGenerationJob(BaseModel):
    """任务生成作业"""

    job_id: str
    task_name: str
    status: TaskGenerationStatus = "queued"
    message: str = "任务已排队,等待开始。"
    current_step: Optional[str] = None
    steps: List[TaskGenerationStep] = Field(default_factory=list)
    task: Optional[Task] = None
    error: Optional[str] = None


================================================
FILE: src/domain/repositories/__init__.py
================================================


================================================
FILE: src/domain/repositories/task_repository.py
================================================
"""
任务仓储层
负责任务数据的持久化操作
"""
from typing import List, Optional
from abc import ABC, abstractmethod
import json
import aiofiles
from src.domain.models.task import Task


class TaskRepository(ABC):
    """任务仓储接口"""

    @abstractmethod
    async def find_all(self) -> List[Task]:
        """获取所有任务"""
        pass

    @abstractmethod
    async def find_by_id(self, task_id: int) -> Optional[Task]:
        """根据ID获取任务"""
        pass

    @abstractmethod
    async def save(self, task: Task) -> Task:
        """保存任务(创建或更新)"""
        pass

    @abstractmethod
    async def delete(self, task_id: int) -> bool:
        """删除任务"""
        pass


================================================
FILE: src/failure_guard.py
================================================
"""Task-level failure circuit breaker.

目标:
- 当登录态失效/风控导致任务持续失败时,避免无限重试、避免高频请求。
- 失败达到阈值后暂停任务一段时间。
- 暂停期间最多每天通知一次,直到用户更新 cookies / 登录态文件后自动恢复。

说明:
- 仅使用标准库,既可被 API 主进程使用,也可被爬虫子进程使用。
"""

from __future__ import annotations

import json
import os
import time
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Optional


try:
    from zoneinfo import ZoneInfo  # py3.9+

    def _load_tz(name: str):
        return ZoneInfo(name)


except Exception:  # pragma: no cover

    def _load_tz(name: str):
        return None


def _as_int(value: Any, default: int) -> int:
    try:
        return int(value)
    except (TypeError, ValueError):
        return default


def _now(tz_name: str, now: Optional[datetime] = None) -> datetime:
    if now is not None:
        return now
    tz = _load_tz(tz_name)
    if tz is None:
        return datetime.now()
    return datetime.now(tz)


def _today_str(tz_name: str, now: Optional[datetime] = None) -> str:
    return _now(tz_name, now=now).date().isoformat()


def _dt_to_str(dt: Optional[datetime]) -> Optional[str]:
    if dt is None:
        return None
    return dt.isoformat()


def _str_to_dt(value: Optional[str]) -> Optional[datetime]:
    if not value:
        return None
    try:
        return datetime.fromisoformat(value)
    except ValueError:
        return None


def _get_mtime(path: Optional[str]) -> Optional[float]:
    if not path:
        return None
    try:
        return os.path.getmtime(path)
    except OSError:
        return None


def _cookie_changed(
    cookie_path: Optional[str], previous_mtime: Optional[float]
) -> bool:
    if not cookie_path:
        return False
    current = _get_mtime(cookie_path)
    if current is None or previous_mtime is None:
        return False
    return current > (previous_mtime + 1e-6)


class _FileLock:
    def __init__(self, fh):
        self._fh = fh

    def __enter__(self):
        try:
            import fcntl

            fcntl.flock(self._fh.fileno(), fcntl.LOCK_EX)
        except Exception:
            pass
        return self

    def __exit__(self, exc_type, exc, tb):
        try:
            import fcntl

            fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN)
        except Exception:
            pass
        return False


def _ensure_parent_dir(path: str) -> None:
    parent = os.path.dirname(path)
    if parent:
        os.makedirs(parent, exist_ok=True)


def _read_json_file(path: str) -> dict:
    try:
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
        return data if isinstance(data, dict) else {}
    except FileNotFoundError:
        return {}
    except Exception:
        # 文件损坏时保留现场,避免无限解析失败。
        try:
            ts = str(int(time.time()))
            os.replace(path, f"{path}.corrupt.{ts}")
        except Exception:
            pass
        return {}


def _atomic_write_json(path: str, data: dict) -> None:
    _ensure_parent_dir(path)
    tmp = f"{path}.tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.flush()
        os.fsync(f.fileno())
    os.replace(tmp, path)


@dataclass(frozen=True)
class SkipDecision:
    skip: bool
    should_notify: bool
    reason: str
    paused_until: Optional[datetime]
    consecutive_failures: int


class FailureGuard:
    def __init__(
        self,
        path: Optional[str] = None,
        *,
        threshold: Optional[int] = None,
        pause_seconds: Optional[int] = None,
        tz_name: Optional[str] = None,
    ):
        self.path = (
            path
            or os.getenv("TASK_FAILURE_GUARD_PATH")
            or "logs/task-failure-guard.json"
        )
        self.threshold = max(
            1, threshold or _as_int(os.getenv("TASK_FAILURE_THRESHOLD"), 3)
        )
        self.pause_seconds = max(
            60,
            pause_seconds
            or _as_int(os.getenv("TASK_FAILURE_PAUSE_SECONDS"), 24 * 60 * 60),
        )
        self.tz_name = tz_name or os.getenv("TASK_FAILURE_TZ") or "Asia/Shanghai"

    def _load(self) -> dict:
        data = _read_json_file(self.path)
        if "tasks" not in data or not isinstance(data.get("tasks"), dict):
            data = {"version": 1, "tasks": {}}
        data.setdefault("version", 1)
        return data

    def _save(self, data: dict) -> None:
        _atomic_write_json(self.path, data)

    def _update_task(self, task_key: str, updater) -> dict:
        _ensure_parent_dir(self.path)
        with open(self.path, "a+", encoding="utf-8") as fh:
            with _FileLock(fh):
                fh.seek(0)
                data = self._load()
                tasks = data.setdefault("tasks", {})
                entry = tasks.get(task_key) or {}
                if not isinstance(entry, dict):
                    entry = {}
                entry = updater(entry) or entry
                tasks[task_key] = entry
                self._save(data)
                return entry

    def record_success(self, task_key: str, *, now: Optional[datetime] = None) -> None:
        def _reset(_: dict) -> dict:
            current = _now(self.tz_name, now=now)
            return {
                "consecutive_failures": 0,
                "paused_until": None,
                "last_notified_date": None,
                "last_failure_reason": None,
                "last_failure_at": None,
                "last_success_at": _dt_to_str(current),
                "cookie_path": None,
                "cookie_mtime": None,
            }

        self._update_task(task_key, _reset)

    def should_skip_start(
        self,
        task_key: str,
        *,
        cookie_path: Optional[str] = None,
        now: Optional[datetime] = None,
    ) -> SkipDecision:
        current = _now(self.tz_name, now=now)
        today = _today_str(self.tz_name, now=current)

        data = self._load()
        entry = (data.get("tasks") or {}).get(task_key) or {}
        if not isinstance(entry, dict):
            entry = {}

        paused_until = _str_to_dt(entry.get("paused_until"))
        consecutive = _as_int(entry.get("consecutive_failures"), 0)
        last_reason = (entry.get("last_failure_reason") or "").strip() or "未知错误"
        last_notified_date = entry.get("last_notified_date")

        previous_cookie_mtime = entry.get("cookie_mtime")
        if cookie_path and previous_cookie_mtime is not None:
            try:
                previous_cookie_mtime = float(previous_cookie_mtime)
            except (TypeError, ValueError):
                previous_cookie_mtime = None

        if (
            paused_until
            and paused_until > current
            and cookie_path
            and _cookie_changed(cookie_path, previous_cookie_mtime)
        ):
            # cookies / 登录态更新 => 自动恢复
            self.record_success(task_key, now=current)
            return SkipDecision(
                skip=False,
                should_notify=False,
                reason="cookie_updated",
                paused_until=None,
                consecutive_failures=0,
            )

        if paused_until and current < paused_until:
            should_notify = last_notified_date != today

            if should_notify:

                def _touch(e: dict) -> dict:
                    e = dict(e or {})
                    e["last_notified_date"] = today
                    return e

                self._update_task(task_key, _touch)

            return SkipDecision(
                skip=True,
                should_notify=should_notify,
                reason=last_reason,
                paused_until=paused_until,
                consecutive_failures=consecutive,
            )

        return SkipDecision(
            skip=False,
            should_notify=False,
            reason="not_paused",
            paused_until=None,
            consecutive_failures=consecutive,
        )

    def record_failure(
        self,
        task_key: str,
        reason: str,
        *,
        cookie_path: Optional[str] = None,
        min_failures_to_pause: Optional[int] = None,
        now: Optional[datetime] = None,
    ) -> dict:
        current = _now(self.tz_name, now=now)
        today = _today_str(self.tz_name, now=current)
        cookie_mtime = _get_mtime(cookie_path)

        effective_threshold = max(1, int(min_failures_to_pause or self.threshold))

        result = {
            "should_notify": False,
            "opened_circuit": False,
            "paused_until": None,
            "consecutive_failures": 0,
        }

        def _apply(entry: dict) -> dict:
            entry = dict(entry or {})
            previous_paused_until = _str_to_dt(entry.get("paused_until"))
            was_paused = bool(previous_paused_until and current < previous_paused_until)

            prev_mtime = entry.get("cookie_mtime")
            try:
                prev_mtime = float(prev_mtime) if prev_mtime is not None else None
            except (TypeError, ValueError):
                prev_mtime = None

            if cookie_path and _cookie_changed(cookie_path, prev_mtime):
                entry["consecutive_failures"] = 0
                entry["paused_until"] = None
                entry["last_notified_date"] = None

            consecutive = _as_int(entry.get("consecutive_failures"), 0) + 1
            entry["consecutive_failures"] = consecutive
            entry["last_failure_reason"] = (reason or "未知错误")[:1000]
            entry["last_failure_at"] = _dt_to_str(current)
            if cookie_path:
                entry["cookie_path"] = cookie_path
                if cookie_mtime is not None:
                    entry["cookie_mtime"] = cookie_mtime

            opened = False
            if consecutive >= effective_threshold:
                paused_until = current + timedelta(seconds=self.pause_seconds)
                entry["paused_until"] = _dt_to_str(paused_until)
                opened = not was_paused

                if entry.get("last_notified_date") != today:
                    entry["last_notified_date"] = today
                    result["should_notify"] = True

                result["paused_until"] = paused_until
            else:
                entry["paused_until"] = None

            result["opened_circuit"] = opened
            result["consecutive_failures"] = consecutive
            return entry

        self._update_task(task_key, _apply)
        return result


================================================
FILE: src/infrastructure/__init__.py
================================================


================================================
FILE: src/infrastructure/config/__init__.py
================================================
from .settings import settings, AppSettings, AISettings, NotificationSettings

__all__ = ["settings", "AppSettings", "AISettings", "NotificationSettings"]


================================================
FILE: src/infrastructure/config/env_manager.py
================================================
"""
环境变量管理器
负责读取和更新 .env 文件,并在读取时回退到运行时环境变量
"""
import os
import re
from typing import Dict, List, Optional
from pathlib import Path

from dotenv import dotenv_values


_PLAIN_ENV_VALUE_PATTERN = re.compile(r"^[A-Za-z0-9_./:-]+$")


class EnvManager:
    """环境变量管理器"""

    def __init__(self, env_file: str = ".env"):
        self.env_file = Path(env_file)
        self._ensure_env_file_exists()

    def _ensure_env_file_exists(self):
        """确保 .env 文件存在"""
        if not self.env_file.exists():
            self.env_file.touch()

    def read_env(self) -> Dict[str, str]:
        """读取所有环境变量"""
        if not self.env_file.exists():
            return {}

        loaded = dotenv_values(self.env_file, encoding="utf-8")
        return {
            key: value
            for key, value in loaded.items()
            if key and value is not None
        }

    def get_value(self, key: str, default: Optional[str] = None) -> Optional[str]:
        """获取单个环境变量的值,优先返回运行时环境变量"""
        runtime_value = os.getenv(key)
        if runtime_value is not None:
            return runtime_value

        env_vars = self.read_env()
        return env_vars.get(key, default)

    def update_values(self, updates: Dict[str, str]) -> bool:
        """批量更新环境变量"""
        return self.apply_changes(updates=updates)

    def apply_changes(
        self,
        updates: Dict[str, str],
        deletions: List[str] | None = None,
    ) -> bool:
        """批量更新并删除环境变量"""
        try:
            existing_vars = self.read_env()
            existing_vars.update(updates)
            for key in deletions or []:
                existing_vars.pop(key, None)
            return self._write_env(existing_vars)
        except Exception as e:
            print(f"更新环境变量失败: {e}")
            return False

    def set_value(self, key: str, value: str) -> bool:
        """设置单个环境变量"""
        return self.update_values({key: value})

    def delete_keys(self, keys: List[str]) -> bool:
        """删除指定的环境变量"""
        try:
            existing_vars = self.read_env()
            for key in keys:
                existing_vars.pop(key, None)
            return self._write_env(existing_vars)
        except Exception as e:
            print(f"删除环境变量失败: {e}")
            return False

    def _write_env(self, env_vars: Dict[str, str]) -> bool:
        """写入环境变量到文件"""
        try:
            with open(self.env_file, 'w', encoding='utf-8') as f:
                for key, value in env_vars.items():
                    f.write(f"{key}={self._serialize_value(value)}\n")
            return True
        except Exception as e:
            print(f"写入 .env 文件失败: {e}")
            return False

    def _serialize_value(self, value: str) -> str:
        text = str(value)
        if text == "":
            return ""
        if _PLAIN_ENV_VALUE_PATTERN.fullmatch(text):
            return text
        escaped = text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
        return f'"{escaped}"'


# 全局实例
env_manager = EnvManager()


================================================
FILE: src/infrastructure/config/settings.py
================================================
"""
统一配置管理模块
使用 Pydantic 进行类型安全的配置管理
"""
try:
    from pydantic_settings import BaseSettings, SettingsConfigDict
    _USING_PYDANTIC_SETTINGS = True
except ImportError:
    from pydantic import BaseSettings
    _USING_PYDANTIC_SETTINGS = False
from pydantic import Field
from typing import Optional
import os

DEFAULT_TELEGRAM_API_BASE_URL = "https://api.telegram.org"


def _env_field(default, env_name: str, **kwargs):
    if _USING_PYDANTIC_SETTINGS:
        return Field(default, validation_alias=env_name, **kwargs)
    return Field(default, env=env_name, **kwargs)


if _USING_PYDANTIC_SETTINGS:
    class _EnvSettings(BaseSettings):
        model_config = SettingsConfigDict(
            env_file=".env",
            env_file_encoding="utf-8",
            extra="ignore",
            protected_namespaces=(),
        )
else:
    class _EnvSettings(BaseSettings):
        class Config:
            env_file = ".env"
            env_file_encoding = "utf-8"
            extra = "ignore"
            protected_namespaces = ()


class AISettings(_EnvSettings):
    """AI模型配置"""
    api_key: Optional[str] = _env_field(None, "OPENAI_API_KEY")
    base_url: str = _env_field("", "OPENAI_BASE_URL")
    model_name: str = _env_field("", "OPENAI_MODEL_NAME")
    proxy_url: Optional[str] = _env_field(None, "PROXY_URL")
    debug_mode: bool = _env_field(False, "AI_DEBUG_MODE")
    enable_response_format: bool = _env_field(True, "ENABLE_RESPONSE_FORMAT")
    enable_thinking: bool = _env_field(False, "ENABLE_THINKING")
    skip_analysis: bool = _env_field(False, "SKIP_AI_ANALYSIS")

    def is_configured(self) -> bool:
        """检查AI是否已正确配置"""
        return bool(self.base_url and self.model_name)


class NotificationSettings(_EnvSettings):
    """通知服务配置"""
    ntfy_topic_url: Optional[str] = _env_field(None, "NTFY_TOPIC_URL")
    gotify_url: Optional[str] = _env_field(None, "GOTIFY_URL")
    gotify_token: Optional[str] = _env_field(None, "GOTIFY_TOKEN")
    bark_url: Optional[str] = _env_field(None, "BARK_URL")
    wx_bot_url: Optional[str] = _env_field(None, "WX_BOT_URL")
    telegram_bot_token: Optional[str] = _env_field(None, "TELEGRAM_BOT_TOKEN")
    telegram_chat_id: Optional[str] = _env_field(None, "TELEGRAM_CHAT_ID")
    telegram_api_base_url: Optional[str] = _env_field(
        DEFAULT_TELEGRAM_API_BASE_URL,
        "TELEGRAM_API_BASE_URL",
    )
    webhook_url: Optional[str] = _env_field(None, "WEBHOOK_URL")
    webhook_method: str = _env_field("POST", "WEBHOOK_METHOD")
    webhook_headers: Optional[str] = _env_field(None, "WEBHOOK_HEADERS")
    webhook_content_type: str = _env_field("JSON", "WEBHOOK_CONTENT_TYPE")
    webhook_query_parameters: Optional[str] = _env_field(None, "WEBHOOK_QUERY_PARAMETERS")
    webhook_body: Optional[str] = _env_field(None, "WEBHOOK_BODY")
    pcurl_to_mobile: bool = _env_field(True, "PCURL_TO_MOBILE")

    def has_any_notification_enabled(self) -> bool:
        """检查是否配置了任何通知服务"""
        return any([
            self.ntfy_topic_url,
            self.wx_bot_url,
            self.gotify_url and self.gotify_token,
            self.bark_url,
            self.telegram_bot_token and self.telegram_chat_id,
            self.webhook_url
        ])


class ScraperSettings(_EnvSettings):
    """爬虫相关配置"""
    run_headless: bool = _env_field(True, "RUN_HEADLESS")
    login_is_edge: bool = _env_field(False, "LOGIN_IS_EDGE")
    running_in_docker: bool = _env_field(False, "RUNNING_IN_DOCKER")
    state_file: str = _env_field("xianyu_state.json", "STATE_FILE")


class AppSettings(_EnvSettings):
    """应用主配置"""
    server_port: int = _env_field(8000, "SERVER_PORT")
    web_username: str = _env_field("admin", "WEB_USERNAME")
    web_password: str = _env_field("admin123", "WEB_PASSWORD")
    task_log_retention_days: int = _env_field(7, "TASK_LOG_RETENTION_DAYS", ge=1)

    # 文件路径配置
    config_file: str = "config.json"
    image_save_dir: str = "images"
    task_image_dir_prefix: str = "task_images_"

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        # 创建必要的目录
        os.makedirs(self.image_save_dir, exist_ok=True)


# 全局配置实例(单例模式)
_settings_instance = None

def get_settings() -> AppSettings:
    """获取全局配置实例"""
    global _settings_instance
    if _settings_instance is None:
        _settings_instance = AppSettings()
    return _settings_instance


def reload_settings() -> None:
    """重新加载全局配置实例"""
    global _settings_instance, settings, ai_settings, notification_settings, scraper_settings
    from dotenv import load_dotenv
    from src.infrastructure.config.env_manager import env_manager

    load_dotenv(dotenv_path=env_manager.env_file, override=True)
    _settings_instance = None
    settings = get_settings()
    ai_settings = AISettings()
    notification_settings = NotificationSettings()
    scraper_settings = ScraperSettings()


# 导出便捷访问的配置实例
settings = get_settings()
ai_settings = AISettings()
notification_settings = NotificationSettings()
scraper_settings = ScraperSettings()


================================================
FILE: src/infrastructure/external/__init__.py
================================================


================================================
FILE: src/infrastructure/external/ai_client.py
================================================
"""
AI 客户端封装
提供统一的 AI 调用接口
"""
import os
import json
import base64
from typing import Dict, List, Optional
from datetime import datetime
from dotenv import load_dotenv
from openai import AsyncOpenAI
from src.ai_message_builder import (
    build_analysis_text_prompt,
    build_user_message_content,
)
from src.infrastructure.config.settings import AISettings
from src.infrastructure.config.env_manager import env_manager
from src.services.ai_request_compat import (
    CHAT_COMPLETIONS_API_MODE,
    RESPONSES_API_MODE,
    build_ai_request_params,
    create_ai_response_async,
    is_chat_completions_api_unsupported_error,
    is_json_output_unsupported_error,
    is_responses_api_unsupported_error,
    is_temperature_unsupported_error,
    remove_temperature_param,
)
from src.services.ai_response_parser import (
    EmptyAIResponseError,
    extract_ai_response_content,
    parse_ai_response_json,
)


class AIClient:
    """AI 客户端封装"""

    def __init__(self):
        self.settings: Optional[AISettings] = None
        self.client: Optional[AsyncOpenAI] = None
        self.refresh()

    def _load_settings(self) -> None:
        load_dotenv(dotenv_path=env_manager.env_file, override=True)
        self.settings = AISettings()

    def refresh(self) -> None:
        self._load_settings()
        self.client = self._initialize_client()

    def _initialize_client(self) -> Optional[AsyncOpenAI]:
        """初始化 OpenAI 客户端"""
        if not self.settings or not self.settings.is_configured():
            print("警告:AI 配置不完整,AI 功能将不可用")
            return None

        try:
            if self.settings.proxy_url:
                print(f"正在为 AI 请求使用代理: {self.settings.proxy_url}")
                os.environ['HTTP_PROXY'] = self.settings.proxy_url
                os.environ['HTTPS_PROXY'] = self.settings.proxy_url

            return AsyncOpenAI(
                api_key=self.settings.api_key,
                base_url=self.settings.base_url
            )
        except Exception as e:
            print(f"初始化 AI 客户端失败: {e}")
            return None

    def is_available(self) -> bool:
        """检查 AI 客户端是否可用"""
        return self.client is not None

    async def close(self) -> None:
        """关闭底层异步客户端,避免事件循环结束后再触发清理。"""
        client = self.client
        self.client = None
        if client is None:
            return

        close = getattr(client, "close", None)
        if close is None:
            return
        await close()

    @staticmethod
    def encode_image(image_path: str) -> Optional[str]:
        """将图片编码为 Base64"""
        if not image_path or not os.path.exists(image_path):
            return None
        try:
            with open(image_path, "rb") as f:
                return base64.b64encode(f.read()).decode('utf-8')
        except Exception as e:
            print(f"编码图片失败: {e}")
            return None

    async def analyze(
        self,
        product_data: Dict,
        image_paths: List[str],
        prompt_text: str
    ) -> Optional[Dict]:
        """
        分析商品数据

        Args:
            product_data: 商品数据
            image_paths: 图片路径列表
            prompt_text: 分析提示词

        Returns:
            分析结果
        """
        if not self.is_available():
            print("AI 客户端不可用")
            return None

        try:
            messages = self._build_messages(product_data, image_paths, prompt_text)
            response = await self._call_ai(messages)
            return self._parse_response(response)
        except Exception as e:
            print(f"AI 分析失败: {e}")
            return None

    def _build_messages(self, product_data: Dict, image_paths: List[str], prompt_text: str) -> List[Dict]:
        """构建 AI 消息"""
        product_json = json.dumps(product_data, ensure_ascii=False, indent=2)
        image_data_urls: List[str] = []
        for path in image_paths:
            base64_img = self.encode_image(path)
            if base64_img:
                image_data_urls.append(f"data:image/jpeg;base64,{base64_img}")

        text_prompt = build_analysis_text_prompt(
            product_json,
            prompt_text,
            include_images=bool(image_data_urls),
        )
        user_content = build_user_message_content(text_prompt, image_data_urls)
        return [{"role": "user", "content": user_content}]

    async def _call_ai(
        self,
        messages: List[Dict],
        *,
        temperature: float = 0.1,
        max_output_tokens: int = 4000,
        enable_json_output: Optional[bool] = None,
    ) -> str:
        """调用 AI API"""
        api_mode = CHAT_COMPLETIONS_API_MODE
        use_response_format = (
            self.settings.enable_response_format
            if enable_json_output is None
            else enable_json_output
        )
        use_temperature = True
        max_attempts = 4

        for attempt in range(max_attempts):
            request_params = build_ai_request_params(
                api_mode,
                model=self.settings.model_name,
                messages=messages,
                temperature=temperature,
                max_output_tokens=max_output_tokens,
                enable_json_output=use_response_format,
            )
            if not use_temperature:
                request_params = remove_temperature_param(request_params)

            if self.settings.enable_thinking:
                request_params["extra_body"] = {"enable_thinking": False}

            try:
                response = await create_ai_response_async(
                    self.client,
                    api_mode,
                    request_params,
                )
                return extract_ai_response_content(response)
            except EmptyAIResponseError as exc:
                if attempt < max_attempts - 1:
                    print(
                        f"AI响应为空,正在自动重试 ({attempt + 2}/{max_attempts})"
                    )
                    continue
                raise exc
            except Exception as exc:
                changed = False
                if (
                    api_mode == CHAT_COMPLETIONS_API_MODE
                    and is_chat_completions_api_unsupported_error(exc)
                ):
                    api_mode = RESPONSES_API_MODE
                    changed = True
                    print("当前服务未实现 Chat Completions API,正在自动回退到 Responses API")
                elif (
                    api_mode == RESPONSES_API_MODE
                    and is_responses_api_unsupported_error(exc)
                ):
                    api_mode = CHAT_COMPLETIONS_API_MODE
                    changed = True
                    print("当前服务未实现 Responses API,正在自动回退到 Chat Completions API")
                if use_response_format and is_json_output_unsupported_error(exc):
                    use_response_format = False
                    changed = True
                    print("当前模型不支持结构化 JSON 输出,正在自动重试并移除该参数")
                if use_temperature and is_temperature_unsupported_error(exc):
                    use_temperature = False
                    changed = True
                    print("当前模型不支持 temperature 参数,正在自动重试并移除该参数")
                if changed and attempt < max_attempts - 1:
                    continue
                raise

        raise RuntimeError("AI 调用在兼容性重试后仍未返回结果")

    def _parse_response(self, response_text: str) -> Optional[Dict]:
        """解析 AI 响应"""
        try:
            return parse_ai_response_json(response_text)
        except json.JSONDecodeError:
            print(f"无法解析 AI 响应: {response_text[:100]}")
            return None


================================================
FILE: src/infrastructure/external/notification_clients/__init__.py
================================================
from .base import NotificationClient, NotificationMessage
from .bark_client import BarkClient
from .gotify_client import GotifyClient
from .ntfy_client import NtfyClient
from .telegram_client import TelegramClient
from .wecom_bot_client import WeComBotClient
from .webhook_client import WebhookClient

__all__ = [
    "NotificationClient",
    "NotificationMessage",
    "BarkClient",
    "GotifyClient",
    "NtfyClient",
    "TelegramClient",
    "WeComBotClient",
    "WebhookClient",
]


================================================
FILE: src/infrastructure/external/notification_clients/bark_client.py
================================================
"""
Bark 通知客户端
"""
import asyncio
import requests
from typing import Dict
from .base import NotificationClient


class BarkClient(NotificationClient):
    """Bark 通知客户端"""

    channel_key = "bark"
    display_name = "Bark"

    def __init__(self, bark_url: str = None, pcurl_to_mobile: bool = True):
        super().__init__(enabled=bool(bark_url), pcurl_to_mobile=pcurl_to_mobile)
        self.bark_url = bark_url

    async def send(self, product_data: Dict, reason: str) -> None:
        """发送 Bark 通知"""
        if not self.is_enabled():
            raise RuntimeError("Bark 未启用")

        message = self._build_message(product_data, reason)
        bark_payload = {
            "title": message.notification_title,
            "body": message.content,
            "url": message.mobile_link or message.desktop_link,
            "level": "timeSensitive",
            "group": "闲鱼监控"
        }

        if message.image_url:
            bark_payload["icon"] = message.image_url

        headers = {"Content-Type": "application/json; charset=utf-8"}
        loop = asyncio.get_running_loop()
        response = await loop.run_in_executor(
            None,
            lambda: requests.post(
                self.bark_url,
                json=bark_payload,
                headers=headers,
                timeout=10
            )
        )
        response.raise_for_status()


================================================
FILE: src/infrastructure/external/notification_clients/base.py
================================================
"""
通知客户端基类
定义通知客户端的统一接口
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Dict

from src.utils import convert_goofish_link


@dataclass(frozen=True)
class NotificationMessage:
    title: str
    price: str
    reason: str
    desktop_link: str
    mobile_link: str | None
    notification_title: str
    content: str
    image_url: str | None


class NotificationClient(ABC):
    """通知客户端抽象基类"""

    channel_key = "unknown"
    display_name = "未知渠道"

    def __init__(self, enabled: bool = False, pcurl_to_mobile: bool = True):
        self._enabled = enabled
        self._pcurl_to_mobile = pcurl_to_mobile

    def is_enabled(self) -> bool:
        """检查客户端是否启用"""
        return self._enabled

    @abstractmethod
    async def send(self, product_data: Dict, reason: str) -> bool:
        """
        发送通知

        Args:
            product_data: 商品数据
            reason: 推荐原因

        Returns:
            是否发送成功
        """
        raise NotImplementedError

    def _build_message(self, product_data: Dict, reason: str) -> NotificationMessage:
        """格式化消息内容"""
        title = product_data.get('商品标题', 'N/A')
        price = product_data.get('当前售价', 'N/A')
        desktop_link = product_data.get('商品链接', '#')
        mobile_link = None

        if self._pcurl_to_mobile and desktop_link and desktop_link != "#":
            mobile_link = convert_goofish_link(desktop_link)

        content_lines = [
            f"价格: {price}",
            f"原因: {reason}",
        ]
        if mobile_link:
            content_lines.append(f"手机端链接: {mobile_link}")
            content_lines.append(f"电脑端链接: {desktop_link}")
        else:
            content_lines.append(f"链接: {desktop_link}")

        short_title = title[:30]
        suffix = "..." if len(title) > 30 else ""
        notification_title = f"🚨 新推荐! {short_title}{suffix}"

        main_image = product_data.get('商品主图链接')
        if not main_image:
            image_list = product_data.get('商品图片列表', [])
            if image_list:
                main_image = image_list[0]

        return NotificationMessage(
            title=title,
            price=price,
            reason=reason,
            desktop_link=desktop_link,
            mobile_link=mobile_link,
            notification_title=notification_title,
            content="\n".join(content_lines),
            image_url=main_image,
        )


================================================
FILE: src/infrastructure/external/notification_clients/factory.py
================================================
"""
通知客户端工厂
"""
from src.infrastructure.config.settings import NotificationSettings

from .bark_client import BarkClient
from .gotify_client import GotifyClient
from .ntfy_client import NtfyClient
from .telegram_client import TelegramClient
from .wecom_bot_client import WeComBotClient
from .webhook_client import WebhookClient


def build_notification_clients(settings: NotificationSettings):
    pcurl_to_mobile = settings.pcurl_to_mobile
    return [
        NtfyClient(settings.ntfy_topic_url, pcurl_to_mobile=pcurl_to_mobile),
        BarkClient(settings.bark_url, pcurl_to_mobile=pcurl_to_mobile),
        GotifyClient(
            settings.gotify_url,
            settings.gotify_token,
            pcurl_to_mobile=pcurl_to_mobile,
        ),
        WeComBotClient(settings.wx_bot_url, pcurl_to_mobile=pcurl_to_mobile),
        TelegramClient(
            settings.telegram_bot_token,
            settings.telegram_chat_id,
            settings.telegram_api_base_url,
            pcurl_to_mobile=pcurl_to_mobile,
        ),
        WebhookClient(
            settings.webhook_url,
            webhook_method=settings.webhook_method,
            webhook_headers=settings.webhook_headers,
            webhook_content_type=settings.webhook_content_type,
            webhook_query_parameters=settings.webhook_query_parameters,
            webhook_body=settings.webhook_body,
            pcurl_to_mobile=pcurl_to_mobile,
        ),
    ]


================================================
FILE: src/infrastructure/external/notification_clients/gotify_client.py
================================================
"""
Gotify 通知客户端
"""
import asyncio
from typing import Dict

import requests

from .base import NotificationClient


class GotifyClient(NotificationClient):
    """Gotify 通知客户端"""

    channel_key = "gotify"
    display_name = "Gotify"

    def __init__(
        self,
        gotify_url: str | None = None,
        gotify_token: str | None = None,
        pcurl_to_mobile: bool = True,
    ):
        super().__init__(
            enabled=bool(gotify_url and gotify_token),
            pcurl_to_mobile=pcurl_to_mobile,
        )
        self.gotify_url = (gotify_url or "").rstrip("/")
        self.gotify_token = gotify_token

    async def send(self, product_data: Dict, reason: str) -> None:
        if not self.is_enabled():
            raise RuntimeError("Gotify 未启用")

        message = self._build_message(product_data, reason)
        payload = {
            "title": (None, message.notification_title),
            "message": (None, message.content),
            "priority": (None, "5"),
        }
        final_url = f"{self.gotify_url}/message?token={self.gotify_token}"
        loop = asyncio.get_running_loop()
        response = await loop.run_in_executor(
            None,
            lambda: requests.post(final_url, files=payload, timeout=10),
        )
        response.raise_for_status()


================================================
FILE: src/infrastructure/external/notification_clients/ntfy_client.py
================================================
"""
Ntfy 通知客户端
"""
import asyncio
import requests
from typing import Dict
from .base import NotificationClient


class NtfyClient(NotificationClient):
    """Ntfy 通知客户端"""

    channel_key = "ntfy"
    display_name = "Ntfy"

    def __init__(self, topic_url: str = None, pcurl_to_mobile: bool = True):
        super().__init__(enabled=bool(topic_url), pcurl_to_mobile=pcurl_to_mobile)
        self.topic_url = topic_url

    async def send(self, product_data: Dict, reason: str) -> None:
        """发送 Ntfy 通知"""
        if not self.is_enabled():
            raise RuntimeError("Ntfy 未启用")

        message = self._build_message(product_data, reason)
        loop = asyncio.get_running_loop()
        response = await loop.run_in_executor(
            None,
            lambda: requests.post(
                self.topic_url,
                data=message.content.encode('utf-8'),
                headers={
                    "Title": message.notification_title.encode('utf-8'),
                    "Priority": "urgent",
                    "Tags": "bell,vibration"
                },
                timeout=10
            )
        )
        response.raise_for_status()


================================================
FILE: src/infrastructure/external/notification_clients/telegram_client.py
================================================
"""
Telegram 通知客户端
"""
import asyncio
from typing import Dict

import requests

from src.infrastructure.config.settings import DEFAULT_TELEGRAM_API_BASE_URL

from .base import NotificationClient


class TelegramClient(NotificationClient):
    """Telegram 通知客户端"""

    channel_key = "telegram"
    display_name = "Telegram"

    def __init__(
        self,
        bot_token: str = None,
        chat_id: str = None,
        api_base_url: str = DEFAULT_TELEGRAM_API_BASE_URL,
        pcurl_to_mobile: bool = True,
    ):
        super().__init__(enabled=bool(bot_token and chat_id), pcurl_to_mobile=pcurl_to_mobile)
        self.bot_token = bot_token
        self.chat_id = chat_id
        self.api_base_url = (
            (api_base_url or DEFAULT_TELEGRAM_API_BASE_URL).rstrip("/")
        )

    async def send(self, product_data: Dict, reason: str) -> None:
        """发送 Telegram 通知"""
        if not self.is_enabled():
            raise RuntimeError("Telegram 未启用")

        message = self._build_message(product_data, reason)
        telegram_message = [
            "🚨 <b>新推荐!</b>",
            "",
            f"<b>{message.title[:50]}{'...' if len(message.title) > 50 else ''}</b>",
            "",
            f"💰 价格: {message.price}",
            f"📝 原因: {message.reason}",
        ]
        if message.mobile_link:
            telegram_message.append(f"📱 <a href='{message.mobile_link}'>手机端链接</a>")
        telegram_message.append(f"💻 <a href='{message.desktop_link}'>电脑端链接</a>")

        telegram_api_url = f"{self.api_base_url}/bot{self.bot_token}/sendMessage"
        telegram_payload = {
            "chat_id": self.chat_id,
            "text": "\n".join(telegram_message),
            "parse_mode": "HTML",
            "disable_web_page_preview": False
        }

        headers = {"Content-Type": "application/json"}
        loop = asyncio.get_running_loop()
        response = await loop.run_in_executor(
            None,
            lambda: requests.post(
                telegram_api_url,
                json=telegram_payload,
                headers=headers,
                timeout=10
            )
        )
        response.raise_for_status()
        result = response.json()
        if not result.get("ok"):
            raise RuntimeError(result.get("description", "Telegram 返回未知错误"))


================================================
FILE: src/infrastructure/external/notification_clients/webhook_client.py
================================================
"""
通用 Webhook 通知客户端
"""
import asyncio
import json
from typing import Any, Dict
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse

import requests

from .base import NotificationClient, NotificationMessage


class WebhookClient(NotificationClient):
    """通用 Webhook 通知客户端"""

    channel_key = "webhook"
    display_name = "Webhook"

    def __init__(
        self,
        webhook_url: str | None 
Download .txt
gitextract_pcspcjt1/

├── .dockerignore
├── .github/
│   └── workflows/
│       ├── claude.yml
│       └── docker-image.yml
├── .gitignore
├── AGENTS.md
├── CLAUDE.md
├── DISCLAIMER.md
├── Dockerfile
├── Dockerfile.base
├── Dockerfile.release
├── LICENSE
├── README.md
├── README_EN.md
├── chrome-extension/
│   ├── README.md
│   ├── background.js
│   ├── manifest.json
│   ├── popup.html
│   └── popup.js
├── config.json.example
├── desktop_launcher.py
├── docker-compose.dev.yaml
├── docker-compose.yaml
├── pyproject.toml
├── requirements-runtime.txt
├── requirements.txt
├── run_live_smoke.sh
├── spider_v2.py
├── src/
│   ├── __init__.py
│   ├── ai_handler.py
│   ├── ai_message_builder.py
│   ├── api/
│   │   ├── __init__.py
│   │   ├── dependencies.py
│   │   └── routes/
│   │       ├── __init__.py
│   │       ├── accounts.py
│   │       ├── dashboard.py
│   │       ├── login_state.py
│   │       ├── logs.py
│   │       ├── prompts.py
│   │       ├── results.py
│   │       ├── settings.py
│   │       ├── tasks.py
│   │       └── websocket.py
│   ├── app.py
│   ├── config.py
│   ├── core/
│   │   └── cron_utils.py
│   ├── domain/
│   │   ├── __init__.py
│   │   ├── models/
│   │   │   ├── __init__.py
│   │   │   ├── task.py
│   │   │   └── task_generation.py
│   │   └── repositories/
│   │       ├── __init__.py
│   │       └── task_repository.py
│   ├── failure_guard.py
│   ├── infrastructure/
│   │   ├── __init__.py
│   │   ├── config/
│   │   │   ├── __init__.py
│   │   │   ├── env_manager.py
│   │   │   └── settings.py
│   │   ├── external/
│   │   │   ├── __init__.py
│   │   │   ├── ai_client.py
│   │   │   └── notification_clients/
│   │   │       ├── __init__.py
│   │   │       ├── bark_client.py
│   │   │       ├── base.py
│   │   │       ├── factory.py
│   │   │       ├── gotify_client.py
│   │   │       ├── ntfy_client.py
│   │   │       ├── telegram_client.py
│   │   │       ├── webhook_client.py
│   │   │       └── wecom_bot_client.py
│   │   └── persistence/
│   │       ├── __init__.py
│   │       ├── json_task_repository.py
│   │       ├── sqlite_bootstrap.py
│   │       ├── sqlite_connection.py
│   │       ├── sqlite_task_repository.py
│   │       └── storage_names.py
│   ├── keyword_rule_engine.py
│   ├── parsers.py
│   ├── prompt_utils.py
│   ├── rotation.py
│   ├── scraper.py
│   ├── services/
│   │   ├── __init__.py
│   │   ├── account_strategy_service.py
│   │   ├── ai_request_compat.py
│   │   ├── ai_response_parser.py
│   │   ├── ai_service.py
│   │   ├── dashboard_payloads.py
│   │   ├── dashboard_service.py
│   │   ├── item_analysis_dispatcher.py
│   │   ├── notification_config_service.py
│   │   ├── notification_service.py
│   │   ├── price_history_service.py
│   │   ├── process_service.py
│   │   ├── result_export_service.py
│   │   ├── result_file_service.py
│   │   ├── result_storage_service.py
│   │   ├── scheduler_service.py
│   │   ├── search_pagination.py
│   │   ├── seller_profile_cache.py
│   │   ├── task_generation_runner.py
│   │   ├── task_generation_service.py
│   │   ├── task_log_cleanup_service.py
│   │   ├── task_payloads.py
│   │   └── task_service.py
│   └── utils.py
├── start.sh
├── tests/
│   ├── README.md
│   ├── __init__.py
│   ├── conftest.py
│   ├── fixtures/
│   │   ├── config.sample.json
│   │   ├── ratings.json
│   │   ├── search_results.json
│   │   ├── state.sample.json
│   │   ├── user_head.json
│   │   └── user_items.json
│   ├── integration/
│   │   ├── test_api_dashboard.py
│   │   ├── test_api_results.py
│   │   ├── test_api_settings.py
│   │   ├── test_api_tasks.py
│   │   ├── test_cli_spider.py
│   │   └── test_pipeline_parse.py
│   ├── live/
│   │   ├── _support.py
│   │   ├── conftest.py
│   │   └── test_live_smoke.py
│   ├── test_failure_guard.py
│   ├── test_frontend_build_paths.py
│   └── unit/
│       ├── test_ai_client.py
│       ├── test_ai_handler_analysis.py
│       ├── test_ai_handler_downloads.py
│       ├── test_ai_request_compat.py
│       ├── test_ai_response_parser.py
│       ├── test_app_lifespan.py
│       ├── test_cron_utils.py
│       ├── test_domain_task.py
│       ├── test_item_analysis_dispatcher.py
│       ├── test_keyword_rule_engine.py
│       ├── test_notification_service.py
│       ├── test_price_history_service.py
│       ├── test_process_service.py
│       ├── test_prompt_utils.py
│       ├── test_scraper_browser_channel.py
│       ├── test_search_pagination.py
│       ├── test_seller_profile_cache.py
│       ├── test_task_log_cleanup_service.py
│       └── test_utils.py
├── web-ui/
│   ├── .gitignore
│   ├── .vscode/
│   │   └── extensions.json
│   ├── Dockerfile
│   ├── README.md
│   ├── components.json
│   ├── index.html
│   ├── nginx.conf
│   ├── package.json
│   ├── postcss.config.cjs
│   ├── src/
│   │   ├── App.vue
│   │   ├── api/
│   │   │   ├── accounts.ts
│   │   │   ├── dashboard.ts
│   │   │   ├── logs.ts
│   │   │   ├── prompts.ts
│   │   │   ├── results.ts
│   │   │   ├── settings.ts
│   │   │   └── tasks.ts
│   │   ├── assets/
│   │   │   └── main.css
│   │   ├── components/
│   │   │   ├── HelloWorld.vue
│   │   │   ├── layout/
│   │   │   │   ├── DashboardTaskSearch.vue
│   │   │   │   ├── LocaleToggle.vue
│   │   │   │   ├── TheHeader.vue
│   │   │   │   └── TheSidebar.vue
│   │   │   ├── results/
│   │   │   │   ├── PriceTrendChart.vue
│   │   │   │   ├── ResultCard.vue
│   │   │   │   ├── ResultsFilterBar.vue
│   │   │   │   ├── ResultsGrid.vue
│   │   │   │   └── ResultsInsightsPanel.vue
│   │   │   ├── settings/
│   │   │   │   ├── NotificationSettingsPanel.vue
│   │   │   │   └── RotationSettingsPanel.vue
│   │   │   ├── tasks/
│   │   │   │   ├── TaskCreateDialog.vue
│   │   │   │   ├── TaskForm.vue
│   │   │   │   ├── TaskGenerationDialog.vue
│   │   │   │   ├── TaskGenerationProgress.vue
│   │   │   │   ├── TaskRegionSelector.vue
│   │   │   │   └── TasksTable.vue
│   │   │   └── ui/
│   │   │       ├── badge/
│   │   │       │   ├── Badge.vue
│   │   │       │   └── index.ts
│   │   │       ├── button/
│   │   │       │   ├── Button.vue
│   │   │       │   └── index.ts
│   │   │       ├── card/
│   │   │       │   ├── Card.vue
│   │   │       │   ├── CardContent.vue
│   │   │       │   ├── CardDescription.vue
│   │   │       │   ├── CardFooter.vue
│   │   │       │   ├── CardHeader.vue
│   │   │       │   ├── CardTitle.vue
│   │   │       │   └── index.ts
│   │   │       ├── checkbox/
│   │   │       │   ├── Checkbox.vue
│   │   │       │   └── index.ts
│   │   │       ├── dialog/
│   │   │       │   ├── Dialog.vue
│   │   │       │   ├── DialogClose.vue
│   │   │       │   ├── DialogContent.vue
│   │   │       │   ├── DialogDescription.vue
│   │   │       │   ├── DialogFooter.vue
│   │   │       │   ├── DialogHeader.vue
│   │   │       │   ├── DialogScrollContent.vue
│   │   │       │   ├── DialogTitle.vue
│   │   │       │   ├── DialogTrigger.vue
│   │   │       │   └── index.ts
│   │   │       ├── input/
│   │   │       │   ├── Input.vue
│   │   │       │   └── index.ts
│   │   │       ├── label/
│   │   │       │   ├── Label.vue
│   │   │       │   └── index.ts
│   │   │       ├── select/
│   │   │       │   ├── Select.vue
│   │   │       │   ├── SelectContent.vue
│   │   │       │   ├── SelectGroup.vue
│   │   │       │   ├── SelectItem.vue
│   │   │       │   ├── SelectItemText.vue
│   │   │       │   ├── SelectLabel.vue
│   │   │       │   ├── SelectScrollDownButton.vue
│   │   │       │   ├── SelectScrollUpButton.vue
│   │   │       │   ├── SelectSeparator.vue
│   │   │       │   ├── SelectTrigger.vue
│   │   │       │   ├── SelectValue.vue
│   │   │       │   └── index.ts
│   │   │       ├── switch/
│   │   │       │   ├── Switch.vue
│   │   │       │   └── index.ts
│   │   │       ├── table/
│   │   │       │   ├── Table.vue
│   │   │       │   ├── TableBody.vue
│   │   │       │   ├── TableCaption.vue
│   │   │       │   ├── TableCell.vue
│   │   │       │   ├── TableHead.vue
│   │   │       │   ├── TableHeader.vue
│   │   │       │   ├── TableRow.vue
│   │   │       │   └── index.ts
│   │   │       ├── tabs/
│   │   │       │   ├── Tabs.vue
│   │   │       │   ├── TabsContent.vue
│   │   │       │   ├── TabsList.vue
│   │   │       │   ├── TabsTrigger.vue
│   │   │       │   └── index.ts
│   │   │       ├── textarea/
│   │   │       │   ├── Textarea.vue
│   │   │       │   └── index.ts
│   │   │       └── toast/
│   │   │           ├── Toast.vue
│   │   │           ├── ToastAction.vue
│   │   │           ├── ToastClose.vue
│   │   │           ├── ToastDescription.vue
│   │   │           ├── ToastProvider.vue
│   │   │           ├── ToastTitle.vue
│   │   │           ├── ToastViewport.vue
│   │   │           ├── Toaster.vue
│   │   │           ├── index.ts
│   │   │           └── use-toast.ts
│   │   ├── composables/
│   │   │   ├── useAuth.ts
│   │   │   ├── useDashboard.ts
│   │   │   ├── useLogs.ts
│   │   │   ├── useMobileNav.ts
│   │   │   ├── useResults.ts
│   │   │   ├── useSettings.ts
│   │   │   ├── useTaskGenerationJob.ts
│   │   │   ├── useTasks.ts
│   │   │   └── useWebSocket.ts
│   │   ├── i18n/
│   │   │   ├── index.ts
│   │   │   └── messages/
│   │   │       ├── en-US-extra.ts
│   │   │       ├── en-US.ts
│   │   │       ├── zh-CN-extra.ts
│   │   │       └── zh-CN.ts
│   │   ├── layouts/
│   │   │   └── MainLayout.vue
│   │   ├── lib/
│   │   │   ├── http.ts
│   │   │   ├── taskFormQuery.ts
│   │   │   ├── taskSchedule.ts
│   │   │   └── utils.ts
│   │   ├── main.ts
│   │   ├── router/
│   │   │   └── index.ts
│   │   ├── services/
│   │   │   └── websocket.ts
│   │   ├── style.css
│   │   ├── types/
│   │   │   ├── dashboard.d.ts
│   │   │   ├── result.d.ts
│   │   │   └── task.d.ts
│   │   └── views/
│   │       ├── AccountsView.vue
│   │       ├── DashboardView.vue
│   │       ├── LoginView.vue
│   │       ├── LogsView.vue
│   │       ├── ResultsView.vue
│   │       ├── SettingsView.vue
│   │       └── TasksView.vue
│   ├── tailwind.config.cjs
│   ├── tsconfig.app.json
│   ├── tsconfig.json
│   ├── tsconfig.node.json
│   └── vite.config.ts
└── xianyu-login-state-privacy.html
Download .txt
SYMBOL INDEX (886 symbols across 128 files)

FILE: chrome-extension/background.js
  constant GOOFISH_HOST_PATTERN (line 3) | const GOOFISH_HOST_PATTERN = "*://*.goofish.com/*";
  constant MAX_STORAGE_ENTRY_LENGTH (line 4) | const MAX_STORAGE_ENTRY_LENGTH = 4096;
  function mapSameSiteValue (line 6) | function mapSameSiteValue(chromeSameSite) {
  function headersArrayToObject (line 17) | function headersArrayToObject(headers = []) {
  function getActiveGoofishTab (line 27) | async function getActiveGoofishTab() {
  function capturePageData (line 39) | async function capturePageData(tabId) {
  function filterEnvData (line 120) | function filterEnvData(env = {}) {
  function pruneStorageEntries (line 151) | function pruneStorageEntries(entries = {}) {
  function filterHeaders (line 165) | function filterHeaders(rawHeaders = {}) {
  function captureHeaders (line 195) | async function captureHeaders(tabId) {
  function captureCookies (line 246) | async function captureCookies(url) {
  function buildSnapshot (line 260) | async function buildSnapshot() {

FILE: chrome-extension/popup.js
  function setLoading (line 10) | function setLoading(isLoading) {
  function updateStatus (line 15) | function updateStatus(message, isSuccess = false) {
  function renderSnapshot (line 24) | function renderSnapshot(snapshot) {
  function captureSnapshot (line 29) | async function captureSnapshot() {
  function copySnapshot (line 51) | function copySnapshot() {

FILE: desktop_launcher.py
  function _prepare_environment (line 17) | def _prepare_environment() -> None:
  function run_app (line 24) | def run_app() -> None:

FILE: spider_v2.py
  function main (line 15) | async def main():

FILE: src/ai_handler.py
  function _positive_int (line 53) | def _positive_int(value, default: int) -> int:
  function safe_print (line 66) | def safe_print(text):
  function _build_debug_request_summary (line 79) | def _build_debug_request_summary(api_mode: str, request_params: dict) ->...
  function _extract_message_content_types (line 107) | def _extract_message_content_types(message: dict) -> list[str]:
  function _download_single_image (line 117) | async def _download_single_image(url, save_path):
  function _build_image_save_path (line 132) | def _build_image_save_path(
  function download_all_images (line 147) | async def download_all_images(product_id, image_urls, task_name="default...
  function cleanup_task_images (line 199) | def cleanup_task_images(task_name):
  function cleanup_ai_logs (line 212) | def cleanup_ai_logs(logs_dir: str, keep_days: int = 1) -> None:
  function encode_image_to_base64 (line 228) | def encode_image_to_base64(image_path):
  function validate_ai_response_format (line 240) | def validate_ai_response_format(parsed_response):
  function send_ntfy_notification (line 280) | async def send_ntfy_notification(product_data, reason):
  function get_ai_analysis (line 298) | async def get_ai_analysis(product_data, image_paths=None, prompt_text=""):

FILE: src/ai_message_builder.py
  function build_analysis_text_prompt (line 12) | def build_analysis_text_prompt(
  function build_user_message_content (line 36) | def build_user_message_content(

FILE: src/api/dependencies.py
  function set_process_service (line 22) | def set_process_service(service: ProcessService):
  function set_scheduler_service (line 28) | def set_scheduler_service(service: SchedulerService):
  function set_task_generation_service (line 34) | def set_task_generation_service(service: TaskGenerationService):
  function get_task_service (line 41) | def get_task_service() -> TaskService:
  function get_notification_service (line 47) | def get_notification_service() -> NotificationService:
  function get_ai_service (line 52) | def get_ai_service() -> AIAnalysisService:
  function get_process_service (line 58) | def get_process_service() -> ProcessService:
  function get_scheduler_service (line 65) | def get_scheduler_service() -> SchedulerService:
  function get_task_generation_service (line 72) | def get_task_generation_service() -> TaskGenerationService:

FILE: src/api/routes/accounts.py
  class AccountCreate (line 19) | class AccountCreate(BaseModel):
  class AccountUpdate (line 24) | class AccountUpdate(BaseModel):
  function _strip_quotes (line 28) | def _strip_quotes(value: str) -> str:
  function _state_dir (line 36) | def _state_dir() -> str:
  function _ensure_state_dir (line 41) | def _ensure_state_dir(path: str) -> None:
  function _validate_name (line 45) | def _validate_name(name: str) -> str:
  function _account_path (line 52) | def _account_path(name: str) -> str:
  function _validate_json (line 57) | def _validate_json(content: str) -> None:
  function list_accounts (line 65) | async def list_accounts():
  function get_account (line 81) | async def get_account(name: str):
  function create_account (line 92) | async def create_account(data: AccountCreate):
  function update_account (line 106) | async def update_account(name: str, data: AccountUpdate):
  function delete_account (line 120) | async def delete_account(name: str):

FILE: src/api/routes/dashboard.py
  function get_dashboard_summary (line 15) | async def get_dashboard_summary(

FILE: src/api/routes/login_state.py
  class LoginStateUpdate (line 14) | class LoginStateUpdate(BaseModel):
  function update_login_state (line 20) | async def update_login_state(
  function delete_login_state (line 41) | async def delete_login_state():

FILE: src/api/routes/logs.py
  function _read_tail_lines (line 17) | async def _read_tail_lines(
  function get_logs (line 56) | async def get_logs(
  function get_logs_tail (line 105) | async def get_logs_tail(
  function clear_logs (line 165) | async def clear_logs(

FILE: src/api/routes/prompts.py
  class PromptUpdate (line 13) | class PromptUpdate(BaseModel):
  function list_prompts (line 19) | async def list_prompts():
  function get_prompt (line 28) | async def get_prompt(filename: str):
  function update_prompt (line 43) | async def update_prompt(

FILE: src/api/routes/results.py
  function _build_download_headers (line 29) | def _build_download_headers(export_name: str) -> dict[str, str]:
  function get_result_files (line 43) | async def get_result_files():
  function download_result_file (line 49) | async def download_result_file(filename: str):
  function delete_result_file (line 63) | async def delete_result_file(filename: str):
  function get_result_file_content (line 76) | async def get_result_file_content(
  function get_result_file_insights (line 121) | async def get_result_file_insights(filename: str):
  function export_result_file_content (line 131) | async def export_result_file_content(

FILE: src/api/routes/settings.py
  function _reload_env (line 45) | def _reload_env() -> None:
  function _env_bool (line 50) | def _env_bool(key: str, default: bool = False) -> bool:
  function _env_int (line 57) | def _env_int(key: str, default: int) -> int:
  function _normalize_bool_value (line 67) | def _normalize_bool_value(value: bool) -> str:
  class NotificationSettingsModel (line 71) | class NotificationSettingsModel(BaseModel):
  class NotificationTestRequest (line 91) | class NotificationTestRequest(BaseModel):
  class AISettingsModel (line 98) | class AISettingsModel(BaseModel):
  class RotationSettingsModel (line 108) | class RotationSettingsModel(BaseModel):
  function get_notification_settings (line 122) | async def get_notification_settings():
  function update_notification_settings (line 127) | async def update_notification_settings(settings: NotificationSettingsMod...
  function test_notification_settings (line 148) | async def test_notification_settings(payload: NotificationTestRequest):
  function get_rotation_settings (line 177) | async def get_rotation_settings():
  function update_rotation_settings (line 193) | async def update_rotation_settings(settings: RotationSettingsModel):
  function get_system_status (line 209) | async def get_system_status(
  function get_ai_settings (line 249) | async def get_ai_settings():
  function update_ai_settings (line 259) | async def update_ai_settings(settings: AISettingsModel):
  function test_ai_settings (line 280) | async def test_ai_settings(settings: dict):

FILE: src/api/routes/tasks.py
  function _reload_scheduler_if_needed (line 33) | async def _reload_scheduler_if_needed(
  function _has_keyword_rules (line 41) | def _has_keyword_rules(rules) -> bool:
  function _validate_final_account_strategy (line 45) | def _validate_final_account_strategy(existing_task, task_update: TaskUpd...
  function get_tasks (line 59) | async def get_tasks(
  function get_task (line 67) | async def get_task(
  function create_task (line 78) | async def create_task(
  function generate_task (line 88) | async def generate_task(
  function get_task_generation_job (line 131) | async def get_task_generation_job(
  function update_task (line 141) | async def update_task(
  function delete_task (line 215) | async def delete_task(
  function start_task (line 253) | async def start_task(
  function stop_task (line 271) | async def stop_task(

FILE: src/api/routes/websocket.py
  function websocket_endpoint (line 16) | async def websocket_endpoint(
  function broadcast_message (line 39) | async def broadcast_message(message_type: str, data: dict):

FILE: src/app.py
  function _sync_task_runtime_status (line 42) | async def _sync_task_runtime_status(task_id: int, is_running: bool) -> N...
  function lifespan (line 66) | async def lifespan(app: FastAPI):
  function health_check (line 129) | async def health_check():
  class LoginRequest (line 139) | class LoginRequest(BaseModel):
  function auth_status (line 145) | async def auth_status(payload: LoginRequest):
  function read_root (line 156) | async def read_root(request: Request):
  function serve_spa (line 169) | async def serve_spa(request: Request, full_path: str):

FILE: src/config.py
  function get_ai_request_params (line 89) | def get_ai_request_params(**kwargs):

FILE: src/core/cron_utils.py
  function normalize_cron_expression (line 27) | def normalize_cron_expression(value: Optional[str]) -> Optional[str]:
  function build_cron_trigger (line 38) | def build_cron_trigger(
  function validate_cron_expression (line 69) | def validate_cron_expression(value: Optional[str]) -> Optional[str]:

FILE: src/domain/models/task.py
  class TaskStatus (line 18) | class TaskStatus(str, Enum):
  function _normalize_keyword_values (line 26) | def _normalize_keyword_values(value) -> List[str]:
  function _extract_keywords_from_legacy_groups (line 52) | def _extract_keywords_from_legacy_groups(groups) -> List[str]:
  function _normalize_payload_keywords (line 67) | def _normalize_payload_keywords(payload: Any) -> Any:
  function _has_keyword_rules (line 85) | def _has_keyword_rules(keyword_rules: List[str]) -> bool:
  function _normalize_optional_string (line 89) | def _normalize_optional_string(value):
  function _validate_cron_expression (line 95) | def _validate_cron_expression(value: Optional[str]) -> Optional[str]:
  function _normalize_price_value (line 99) | def _normalize_price_value(value):
  class Task (line 107) | class Task(BaseModel):
    method normalize_legacy_keyword_payload (line 136) | def normalize_legacy_keyword_payload(cls, values):
    method normalize_keyword_rules (line 141) | def normalize_keyword_rules(cls, value):
    method can_start (line 144) | def can_start(self) -> bool:
    method can_stop (line 148) | def can_stop(self) -> bool:
    method apply_update (line 152) | def apply_update(self, update: "TaskUpdate") -> "Task":
  class TaskCreate (line 158) | class TaskCreate(BaseModel):
    method normalize_legacy_keyword_payload (line 185) | def normalize_legacy_keyword_payload(cls, values):
    method convert_price_to_str (line 190) | def convert_price_to_str(cls, value):
    method normalize_cron (line 195) | def normalize_cron(cls, value):
    method normalize_account_state_file (line 200) | def normalize_account_state_file(cls, value):
    method validate_cron (line 205) | def validate_cron(cls, value):
    method normalize_keyword_rules (line 210) | def normalize_keyword_rules(cls, value):
    method validate_decision_mode_payload (line 214) | def validate_decision_mode_payload(self):
  class TaskUpdate (line 225) | class TaskUpdate(BaseModel):
    method normalize_legacy_keyword_payload (line 253) | def normalize_legacy_keyword_payload(cls, values):
    method convert_price_to_str (line 258) | def convert_price_to_str(cls, value):
    method normalize_cron (line 263) | def normalize_cron(cls, value):
    method normalize_account_state_file (line 268) | def normalize_account_state_file(cls, value):
    method validate_cron (line 273) | def validate_cron(cls, value):
    method normalize_keyword_rules (line 278) | def normalize_keyword_rules(cls, value):
    method validate_partial_keyword_payload (line 282) | def validate_partial_keyword_payload(self):
  class TaskGenerateRequest (line 292) | class TaskGenerateRequest(BaseModel):
    method normalize_legacy_keyword_payload (line 316) | def normalize_legacy_keyword_payload(cls, values):
    method convert_price_to_str (line 321) | def convert_price_to_str(cls, value):
    method empty_str_to_none (line 326) | def empty_str_to_none(cls, value):
    method validate_cron (line 331) | def validate_cron(cls, value):
    method empty_account_to_none (line 336) | def empty_account_to_none(cls, value):
    method empty_str_to_none_for_strings (line 341) | def empty_str_to_none_for_strings(cls, value):
    method normalize_keyword_rules (line 346) | def normalize_keyword_rules(cls, value):
    method validate_decision_mode_payload (line 350) | def validate_decision_mode_payload(self):

FILE: src/domain/models/task_generation.py
  class TaskGenerationStep (line 15) | class TaskGenerationStep(BaseModel):
  class TaskGenerationJob (line 24) | class TaskGenerationJob(BaseModel):

FILE: src/domain/repositories/task_repository.py
  class TaskRepository (line 12) | class TaskRepository(ABC):
    method find_all (line 16) | async def find_all(self) -> List[Task]:
    method find_by_id (line 21) | async def find_by_id(self, task_id: int) -> Optional[Task]:
    method save (line 26) | async def save(self, task: Task) -> Task:
    method delete (line 31) | async def delete(self, task_id: int) -> bool:

FILE: src/failure_guard.py
  function _load_tz (line 25) | def _load_tz(name: str):
  function _load_tz (line 31) | def _load_tz(name: str):
  function _as_int (line 35) | def _as_int(value: Any, default: int) -> int:
  function _now (line 42) | def _now(tz_name: str, now: Optional[datetime] = None) -> datetime:
  function _today_str (line 51) | def _today_str(tz_name: str, now: Optional[datetime] = None) -> str:
  function _dt_to_str (line 55) | def _dt_to_str(dt: Optional[datetime]) -> Optional[str]:
  function _str_to_dt (line 61) | def _str_to_dt(value: Optional[str]) -> Optional[datetime]:
  function _get_mtime (line 70) | def _get_mtime(path: Optional[str]) -> Optional[float]:
  function _cookie_changed (line 79) | def _cookie_changed(
  class _FileLock (line 90) | class _FileLock:
    method __init__ (line 91) | def __init__(self, fh):
    method __enter__ (line 94) | def __enter__(self):
    method __exit__ (line 103) | def __exit__(self, exc_type, exc, tb):
  function _ensure_parent_dir (line 113) | def _ensure_parent_dir(path: str) -> None:
  function _read_json_file (line 119) | def _read_json_file(path: str) -> dict:
  function _atomic_write_json (line 136) | def _atomic_write_json(path: str, data: dict) -> None:
  class SkipDecision (line 147) | class SkipDecision:
  class FailureGuard (line 155) | class FailureGuard:
    method __init__ (line 156) | def __init__(
    method _load (line 179) | def _load(self) -> dict:
    method _save (line 186) | def _save(self, data: dict) -> None:
    method _update_task (line 189) | def _update_task(self, task_key: str, updater) -> dict:
    method record_success (line 204) | def record_success(self, task_key: str, *, now: Optional[datetime] = N...
    method should_skip_start (line 220) | def should_skip_start(
    method record_failure (line 291) | def record_failure(

FILE: src/infrastructure/config/env_manager.py
  class EnvManager (line 16) | class EnvManager:
    method __init__ (line 19) | def __init__(self, env_file: str = ".env"):
    method _ensure_env_file_exists (line 23) | def _ensure_env_file_exists(self):
    method read_env (line 28) | def read_env(self) -> Dict[str, str]:
    method get_value (line 40) | def get_value(self, key: str, default: Optional[str] = None) -> Option...
    method update_values (line 49) | def update_values(self, updates: Dict[str, str]) -> bool:
    method apply_changes (line 53) | def apply_changes(
    method set_value (line 69) | def set_value(self, key: str, value: str) -> bool:
    method delete_keys (line 73) | def delete_keys(self, keys: List[str]) -> bool:
    method _write_env (line 84) | def _write_env(self, env_vars: Dict[str, str]) -> bool:
    method _serialize_value (line 95) | def _serialize_value(self, value: str) -> str:

FILE: src/infrastructure/config/settings.py
  function _env_field (line 18) | def _env_field(default, env_name: str, **kwargs):
  class _EnvSettings (line 25) | class _EnvSettings(BaseSettings):
    class Config (line 34) | class Config:
  class _EnvSettings (line 33) | class _EnvSettings(BaseSettings):
    class Config (line 34) | class Config:
  class AISettings (line 41) | class AISettings(_EnvSettings):
    method is_configured (line 52) | def is_configured(self) -> bool:
  class NotificationSettings (line 57) | class NotificationSettings(_EnvSettings):
    method has_any_notification_enabled (line 78) | def has_any_notification_enabled(self) -> bool:
  class ScraperSettings (line 90) | class ScraperSettings(_EnvSettings):
  class AppSettings (line 98) | class AppSettings(_EnvSettings):
    method __init__ (line 110) | def __init__(self, **kwargs):
  function get_settings (line 119) | def get_settings() -> AppSettings:
  function reload_settings (line 127) | def reload_settings() -> None:

FILE: src/infrastructure/external/ai_client.py
  class AIClient (line 36) | class AIClient:
    method __init__ (line 39) | def __init__(self):
    method _load_settings (line 44) | def _load_settings(self) -> None:
    method refresh (line 48) | def refresh(self) -> None:
    method _initialize_client (line 52) | def _initialize_client(self) -> Optional[AsyncOpenAI]:
    method is_available (line 72) | def is_available(self) -> bool:
    method close (line 76) | async def close(self) -> None:
    method encode_image (line 89) | def encode_image(image_path: str) -> Optional[str]:
    method analyze (line 100) | async def analyze(
    method _build_messages (line 129) | def _build_messages(self, product_data: Dict, image_paths: List[str], ...
    method _call_ai (line 146) | async def _call_ai(
    method _parse_response (line 223) | def _parse_response(self, response_text: str) -> Optional[Dict]:

FILE: src/infrastructure/external/notification_clients/bark_client.py
  class BarkClient (line 10) | class BarkClient(NotificationClient):
    method __init__ (line 16) | def __init__(self, bark_url: str = None, pcurl_to_mobile: bool = True):
    method send (line 20) | async def send(self, product_data: Dict, reason: str) -> None:

FILE: src/infrastructure/external/notification_clients/base.py
  class NotificationMessage (line 13) | class NotificationMessage:
  class NotificationClient (line 24) | class NotificationClient(ABC):
    method __init__ (line 30) | def __init__(self, enabled: bool = False, pcurl_to_mobile: bool = True):
    method is_enabled (line 34) | def is_enabled(self) -> bool:
    method send (line 39) | async def send(self, product_data: Dict, reason: str) -> bool:
    method _build_message (line 52) | def _build_message(self, product_data: Dict, reason: str) -> Notificat...

FILE: src/infrastructure/external/notification_clients/factory.py
  function build_notification_clients (line 14) | def build_notification_clients(settings: NotificationSettings):

FILE: src/infrastructure/external/notification_clients/gotify_client.py
  class GotifyClient (line 12) | class GotifyClient(NotificationClient):
    method __init__ (line 18) | def __init__(
    method send (line 31) | async def send(self, product_data: Dict, reason: str) -> None:

FILE: src/infrastructure/external/notification_clients/ntfy_client.py
  class NtfyClient (line 10) | class NtfyClient(NotificationClient):
    method __init__ (line 16) | def __init__(self, topic_url: str = None, pcurl_to_mobile: bool = True):
    method send (line 20) | async def send(self, product_data: Dict, reason: str) -> None:

FILE: src/infrastructure/external/notification_clients/telegram_client.py
  class TelegramClient (line 14) | class TelegramClient(NotificationClient):
    method __init__ (line 20) | def __init__(
    method send (line 34) | async def send(self, product_data: Dict, reason: str) -> None:

FILE: src/infrastructure/external/notification_clients/webhook_client.py
  class WebhookClient (line 14) | class WebhookClient(NotificationClient):
    method __init__ (line 20) | def __init__(
    method send (line 38) | async def send(self, product_data: Dict, reason: str) -> None:
    method _build_url (line 68) | def _build_url(self, message: NotificationMessage) -> str:
    method _build_body (line 81) | def _build_body(
    method _parse_json (line 106) | def _parse_json(
    method _render_template (line 122) | def _render_template(self, value: Any, message: NotificationMessage) -...
    method _replace_placeholders (line 134) | def _replace_placeholders(self, value: str, message: NotificationMessa...

FILE: src/infrastructure/external/notification_clients/wecom_bot_client.py
  class WeComBotClient (line 12) | class WeComBotClient(NotificationClient):
    method __init__ (line 18) | def __init__(self, bot_url: str | None = None, pcurl_to_mobile: bool =...
    method send (line 22) | async def send(self, product_data: Dict, reason: str) -> None:

FILE: src/infrastructure/persistence/json_task_repository.py
  class JsonTaskRepository (line 11) | class JsonTaskRepository(TaskRepository):
    method __init__ (line 14) | def __init__(self, config_file: str = "config.json"):
    method find_all (line 17) | async def find_all(self) -> List[Task]:
    method find_by_id (line 37) | async def find_by_id(self, task_id: int) -> Optional[Task]:
    method save (line 44) | async def save(self, task: Task) -> Task:
    method delete (line 59) | async def delete(self, task_id: int) -> bool:
    method _write_tasks (line 68) | async def _write_tasks(self, tasks: List[Task]):

FILE: src/infrastructure/persistence/sqlite_bootstrap.py
  function bootstrap_sqlite_storage (line 28) | def bootstrap_sqlite_storage(
  function _table_is_empty (line 43) | def _table_is_empty(conn, table_name: str) -> bool:
  function _load_json_file (line 48) | def _load_json_file(path: Path):
  function _import_tasks_if_needed (line 57) | def _import_tasks_if_needed(conn, legacy_config_file: str | None) -> None:
  function _import_results_if_needed (line 116) | def _import_results_if_needed(conn, legacy_result_dir: str) -> None:
  function _import_price_snapshots_if_needed (line 146) | def _import_price_snapshots_if_needed(conn, legacy_price_history_dir: st...
  function _insert_result_record (line 174) | def _insert_result_record(conn, record: dict, *, keyword: str, filename:...
  function _insert_price_snapshot (line 225) | def _insert_price_snapshot(conn, record: dict) -> None:
  function _as_int (line 256) | def _as_int(value) -> int:
  function _parse_price (line 264) | def _parse_price(value):
  function _bootstrap_completed (line 281) | def _bootstrap_completed(conn, key: str) -> bool:
  function _mark_bootstrap_completed (line 289) | def _mark_bootstrap_completed(conn, key: str) -> None:

FILE: src/infrastructure/persistence/sqlite_connection.py
  function get_database_path (line 120) | def get_database_path() -> str:
  function _prepare_database_file (line 124) | def _prepare_database_file(path: str) -> None:
  function _apply_pragmas (line 128) | def _apply_pragmas(conn: sqlite3.Connection) -> None:
  function init_schema (line 134) | def init_schema(conn: sqlite3.Connection) -> None:
  function sqlite_connection (line 141) | def sqlite_connection(

FILE: src/infrastructure/persistence/sqlite_task_repository.py
  function _row_to_task (line 16) | def _row_to_task(row) -> Task:
  function find_task_by_name_sync (line 27) | def find_task_by_name_sync(task_name: str) -> Task | None:
  class SqliteTaskRepository (line 37) | class SqliteTaskRepository(TaskRepository):
    method __init__ (line 40) | def __init__(
    method find_all (line 48) | async def find_all(self) -> List[Task]:
    method find_by_id (line 51) | async def find_by_id(self, task_id: int) -> Optional[Task]:
    method save (line 54) | async def save(self, task: Task) -> Task:
    method delete (line 57) | async def delete(self, task_id: int) -> bool:
    method _find_all_sync (line 60) | def _find_all_sync(self) -> List[Task]:
    method _find_by_id_sync (line 69) | def _find_by_id_sync(self, task_id: int) -> Optional[Task]:
    method _save_sync (line 78) | def _save_sync(self, task: Task) -> Task:
    method _delete_sync (line 109) | def _delete_sync(self, task_id: int) -> bool:
    method _next_task_id (line 119) | def _next_task_id(self, conn) -> int:
    method _task_values (line 123) | def _task_values(self, task: Task) -> dict:

FILE: src/infrastructure/persistence/storage_names.py
  function build_result_filename (line 11) | def build_result_filename(keyword: str) -> str:
  function normalize_keyword_from_filename (line 15) | def normalize_keyword_from_filename(filename: str) -> str:
  function normalize_keyword_slug (line 19) | def normalize_keyword_slug(keyword: str) -> str:

FILE: src/keyword_rule_engine.py
  function normalize_text (line 13) | def normalize_text(value: str) -> str:
  function _collect_text_fragments (line 17) | def _collect_text_fragments(value: Any, bucket: List[str]) -> None:
  function build_search_text (line 37) | def build_search_text(record: Dict[str, Any]) -> str:
  function _normalize_keywords (line 49) | def _normalize_keywords(values: Iterable[str]) -> List[str]:
  function _uses_ascii_token_match (line 61) | def _uses_ascii_token_match(keyword: str) -> bool:
  function _keyword_matches (line 65) | def _keyword_matches(keyword: str, normalized_text: str) -> bool:
  function evaluate_keyword_rules (line 72) | def evaluate_keyword_rules(keywords: List[str], search_text: str) -> Dic...

FILE: src/parsers.py
  function _parse_search_results_json (line 8) | async def _parse_search_results_json(json_data: dict, source: str) -> list:
  function calculate_reputation_from_ratings (line 67) | async def calculate_reputation_from_ratings(ratings_json: list) -> dict:
  function _parse_user_items_data (line 101) | async def _parse_user_items_data(items_json: list) -> list:
  function parse_user_head_data (line 124) | async def parse_user_head_data(head_json: dict) -> dict:
  function parse_ratings_data (line 145) | async def parse_ratings_data(ratings_json: list) -> list:

FILE: src/prompt_utils.py
  function _report_progress (line 39) | async def _report_progress(
  function _read_reference_text (line 48) | def _read_reference_text(reference_file_path: str) -> str:
  function _request_generated_text (line 58) | async def _request_generated_text(ai_client: AIClient, prompt: str) -> str:
  function _close_ai_client (line 75) | async def _close_ai_client(
  function generate_criteria (line 87) | async def generate_criteria(
  function update_config_with_new_task (line 123) | async def update_config_with_new_task(new_task: dict, config_file: str =...

FILE: src/rotation.py
  class RotationItem (line 9) | class RotationItem:
  class RotationPool (line 14) | class RotationPool:
    method __init__ (line 15) | def __init__(self, items: List[str], blacklist_ttl: int = 300, name: s...
    method _cleanup_blacklist (line 21) | def _cleanup_blacklist(self) -> None:
    method available_items (line 27) | def available_items(self) -> List[RotationItem]:
    method pick_random (line 31) | def pick_random(self) -> Optional[RotationItem]:
    method mark_bad (line 37) | def mark_bad(self, item: Optional[RotationItem], reason: str = "") -> ...
  function parse_proxy_pool (line 46) | def parse_proxy_pool(value: Optional[str]) -> List[str]:
  function load_state_files (line 54) | def load_state_files(state_dir: str) -> List[str]:

FILE: src/scraper.py
  class RiskControlError (line 66) | class RiskControlError(Exception):
  class LoginRequiredError (line 70) | class LoginRequiredError(Exception):
  function _is_login_url (line 78) | def _is_login_url(url: str) -> bool:
  function _resolve_browser_channel (line 85) | def _resolve_browser_channel() -> str:
  function _should_analyze_images (line 98) | def _should_analyze_images(task_config: dict) -> bool:
  function _format_failure_reason (line 105) | def _format_failure_reason(reason: str, limit: int = 500) -> str:
  function _notify_task_failure (line 114) | async def _notify_task_failure(
  function _as_bool (line 167) | def _as_bool(value, default: bool = False) -> bool:
  function _as_int (line 175) | def _as_int(value, default: int) -> int:
  function _get_rotation_settings (line 184) | def _get_rotation_settings(task_config: dict) -> dict:
  function _get_ai_analysis_concurrency (line 237) | def _get_ai_analysis_concurrency(task_config: dict) -> int:
  function _get_seller_profile_cache_ttl (line 243) | def _get_seller_profile_cache_ttl(task_config: dict) -> int:
  function _default_context_options (line 249) | def _default_context_options() -> dict:
  function _clean_kwargs (line 264) | def _clean_kwargs(options: dict) -> dict:
  function _looks_like_mobile (line 268) | def _looks_like_mobile(ua: str) -> Optional[bool]:
  function _build_context_overrides (line 279) | def _build_context_overrides(snapshot: dict) -> dict:
  function _build_extra_headers (line 329) | def _build_extra_headers(raw_headers: Optional[dict]) -> dict:
  function scrape_user_profile (line 341) | async def scrape_user_profile(context, user_id: str) -> dict:
  function scrape_xianyu (line 445) | async def scrape_xianyu(task_config: dict, debug_limit: int = 0):

FILE: src/services/account_strategy_service.py
  function clean_account_state_file (line 10) | def clean_account_state_file(value: Optional[str]) -> Optional[str]:
  function normalize_account_strategy (line 19) | def normalize_account_strategy(
  function resolve_account_runtime_plan (line 31) | def resolve_account_runtime_plan(

FILE: src/services/ai_request_compat.py
  function build_responses_input (line 38) | def build_responses_input(messages: Iterable[Dict[str, Any]]) -> List[Di...
  function add_json_text_format (line 52) | def add_json_text_format(
  function add_json_response_format (line 67) | def add_json_response_format(
  function is_json_output_unsupported_error (line 78) | def is_json_output_unsupported_error(error: Exception) -> bool:
  function is_responses_api_unsupported_error (line 87) | def is_responses_api_unsupported_error(error: Exception) -> bool:
  function is_chat_completions_api_unsupported_error (line 92) | def is_chat_completions_api_unsupported_error(error: Exception) -> bool:
  function build_ai_request_params (line 97) | def build_ai_request_params(
  function create_ai_response_async (line 127) | async def create_ai_response_async(
  function create_ai_response_sync (line 140) | def create_ai_response_sync(
  function is_temperature_unsupported_error (line 153) | def is_temperature_unsupported_error(error: Exception) -> bool:
  function remove_temperature_param (line 164) | def remove_temperature_param(request_params: Dict[str, Any]) -> Dict[str...
  function _is_api_unsupported_error (line 171) | def _is_api_unsupported_error(
  function _build_input_content (line 191) | def _build_input_content(content: Any) -> List[Dict[str, Any]]:
  function _coerce_content_item (line 200) | def _coerce_content_item(item: Any) -> Dict[str, Any]:
  function _build_image_input_item (line 217) | def _build_image_input_item(item: Dict[str, Any]) -> Dict[str, Any]:

FILE: src/services/ai_response_parser.py
  class EmptyAIResponseError (line 8) | class EmptyAIResponseError(ValueError):
  function extract_ai_response_content (line 12) | def extract_ai_response_content(response: Any) -> str:
  function parse_ai_response_json (line 39) | def parse_ai_response_json(content: str) -> dict:
  function _coerce_content_parts (line 48) | def _coerce_content_parts(content: Any) -> str:
  function _normalize_text_content (line 74) | def _normalize_text_content(content: str) -> str:
  function _strip_code_fences (line 81) | def _strip_code_fences(content: str) -> str:
  function _extract_first_json_value (line 92) | def _extract_first_json_value(

FILE: src/services/ai_service.py
  class AIAnalysisService (line 9) | class AIAnalysisService:
    method __init__ (line 12) | def __init__(self, ai_client: AIClient):
    method analyze_product (line 15) | async def analyze_product(
    method _validate_result (line 48) | def _validate_result(self, result: Dict) -> bool:

FILE: src/services/dashboard_payloads.py
  function normalize_text (line 17) | def normalize_text(value: str | None) -> str:
  function parse_timestamp (line 21) | def parse_timestamp(value: str | None) -> datetime | None:
  function serialize_timestamp (line 33) | def serialize_timestamp(value: datetime | None) -> str | None:
  function build_empty_summary (line 37) | def build_empty_summary(task: Task) -> dict[str, Any]:
  function build_activity (line 58) | def build_activity(
  function sort_key_by_latest_time (line 83) | def sort_key_by_latest_time(item: dict[str, Any]) -> tuple[float, str]:
  function sort_key_by_activity_time (line 88) | def sort_key_by_activity_time(item: dict[str, Any]) -> tuple[float, str]:
  function _build_fallback_summary (line 93) | def _build_fallback_summary(task_name: str, keyword: str) -> dict[str, A...
  function _resolve_task (line 114) | def _resolve_task(
  function _collect_record_metrics (line 129) | def _collect_record_metrics(records: list[dict[str, Any]]) -> dict[str, ...
  function _build_recommendation_activity (line 172) | def _build_recommendation_activity(
  function _build_scan_activity (line 202) | def _build_scan_activity(
  function summarize_result_file (line 227) | async def summarize_result_file(
  function build_task_state_activities (line 277) | def build_task_state_activities(tasks: list[Task]) -> list[dict[str, Any]]:

FILE: src/services/dashboard_service.py
  function _build_summary_metrics (line 24) | def _build_summary_metrics(tasks: list[Task], summary_list: list[dict[st...
  function build_dashboard_snapshot (line 37) | async def build_dashboard_snapshot(tasks: list[Task]) -> dict[str, Any]:

FILE: src/services/item_analysis_dispatcher.py
  class ItemAnalysisJob (line 22) | class ItemAnalysisJob:
  class ItemAnalysisDispatcher (line 35) | class ItemAnalysisDispatcher:
    method __init__ (line 38) | def __init__(
    method submit (line 59) | def submit(self, job: ItemAnalysisJob) -> None:
    method join (line 64) | async def join(self) -> None:
    method _process_with_limit (line 68) | async def _process_with_limit(self, job: ItemAnalysisJob) -> None:
    method _process_job (line 72) | async def _process_job(self, job: ItemAnalysisJob) -> None:
    method _load_seller_info (line 81) | async def _load_seller_info(self, job: ItemAnalysisJob) -> dict:
    method _build_analysis_result (line 93) | async def _build_analysis_result(self, job: ItemAnalysisJob, record: d...
    method _build_keyword_result (line 100) | def _build_keyword_result(self, job: ItemAnalysisJob, record: dict) ->...
    method _build_skip_ai_result (line 104) | def _build_skip_ai_result(self) -> dict:
    method _build_ai_error_result (line 112) | def _build_ai_error_result(self, reason: str, *, error: str = "") -> d...
    method _run_ai_analysis (line 123) | async def _run_ai_analysis(self, job: ItemAnalysisJob, record: dict) -...
    method _download_images (line 146) | async def _download_images(self, job: ItemAnalysisJob, record: dict) -...
    method _cleanup_images (line 159) | def _cleanup_images(self, image_paths: list[str]) -> None:
    method _notify_if_recommended (line 167) | async def _notify_if_recommended(self, item_data: dict, analysis_resul...

FILE: src/services/notification_config_service.py
  class NotificationSettingsValidationError (line 60) | class NotificationSettingsValidationError(ValueError):
  function model_dump (line 64) | def model_dump(model, *, exclude_unset: bool = False) -> dict:
  function build_notification_settings_response (line 70) | def build_notification_settings_response(
  function build_notification_status_flags (line 101) | def build_notification_status_flags(
  function build_configured_channels (line 118) | def build_configured_channels(
  function prepare_notification_settings_update (line 138) | def prepare_notification_settings_update(
  function _notification_settings_to_values (line 172) | def _notification_settings_to_values(settings: NotificationSettings) -> ...
  function load_notification_settings (line 179) | def load_notification_settings() -> NotificationSettings:
  function _build_notification_settings_model (line 204) | def _build_notification_settings_model(values: dict) -> NotificationSett...
  function _normalize_patch_value (line 210) | def _normalize_patch_value(env_name: str, value):
  function _normalize_existing_text (line 219) | def _normalize_existing_text(value: str | None) -> str | None:
  function _env_bool (line 226) | def _env_bool(value: str | None, default: bool) -> bool:
  function _normalize_notification_values (line 232) | def _normalize_notification_values(values: dict) -> dict:
  function _validate_notification_settings (line 255) | def _validate_notification_settings(settings: NotificationSettings) -> N...
  function _validate_http_url (line 305) | def _validate_http_url(field_name: str, value: str) -> None:
  function _validate_pair (line 313) | def _validate_pair(
  function _parse_json_field (line 326) | def _parse_json_field(

FILE: src/services/notification_service.py
  class NotificationService (line 14) | class NotificationService:
    method __init__ (line 17) | def __init__(self, clients: List[NotificationClient]):
    method send_notification (line 20) | async def send_notification(
    method send_test_notification (line 41) | async def send_test_notification(self) -> Dict[str, Dict[str, str | bo...
    method _send_with_result (line 52) | async def _send_with_result(
  function build_notification_service (line 75) | def build_notification_service(

FILE: src/services/price_history_service.py
  function normalize_keyword_slug (line 21) | def normalize_keyword_slug(keyword: str) -> str:
  function build_price_history_path (line 29) | def build_price_history_path(keyword: str) -> str:
  function parse_price_value (line 36) | def parse_price_value(value: Any) -> Optional[float]:
  function _safe_iso_datetime (line 53) | def _safe_iso_datetime(value: Optional[str]) -> str:
  function _to_day (line 59) | def _to_day(iso_text: str) -> str:
  function _build_snapshot_record (line 63) | def _build_snapshot_record(
  function record_market_snapshots (line 96) | def record_market_snapshots(
  function load_price_snapshots (line 159) | def load_price_snapshots(keyword: str) -> list[dict]:
  function delete_price_snapshots (line 194) | def delete_price_snapshots(keyword: str) -> int:
  function _dedupe_latest (line 205) | def _dedupe_latest(records: Iterable[dict], group_key: str) -> list[dict]:
  function _summarize_prices (line 215) | def _summarize_prices(records: Iterable[dict]) -> dict:
  function _build_daily_trend (line 236) | def _build_daily_trend(snapshots: list[dict]) -> list[dict]:
  function _recent_window_snapshots (line 250) | def _recent_window_snapshots(snapshots: list[dict], window_days: int) ->...
  function _resolve_deal_label (line 263) | def _resolve_deal_label(score: int) -> str:
  function build_item_price_context (line 273) | def build_item_price_context(
  function build_market_reference (line 333) | def build_market_reference(
  function build_price_history_insights (line 362) | def build_price_history_insights(

FILE: src/services/process_service.py
  class ProcessService (line 25) | class ProcessService:
    method __init__ (line 28) | def __init__(self):
    method set_lifecycle_hooks (line 38) | def set_lifecycle_hooks(
    method _invoke_hook (line 47) | async def _invoke_hook(self, hook: LifecycleHook | None, task_id: int)...
    method _resolve_cookie_path (line 54) | def _resolve_cookie_path(self, task_name: str) -> str | None:
    method is_running (line 65) | def is_running(self, task_id: int) -> bool:
    method _drain_finished_process (line 70) | async def _drain_finished_process(self, task_id: int) -> None:
    method _open_log_file (line 83) | def _open_log_file(self, task_id: int, task_name: str) -> tuple[str, T...
    method _build_spawn_command (line 89) | def _build_spawn_command(self, task_name: str) -> list[str]:
    method _spawn_process (line 102) | async def _spawn_process(
    method _register_runtime (line 119) | def _register_runtime(
    method start_task (line 133) | async def start_task(self, task_id: int, task_name: str) -> bool:
    method _notify_skip (line 163) | async def _notify_skip(self, task_name: str, decision) -> None:
    method _watch_process_exit (line 186) | async def _watch_process_exit(self, process: asyncio.subprocess.Proces...
    method _find_task_id_by_process (line 194) | def _find_task_id_by_process(self, process: asyncio.subprocess.Process...
    method _cleanup_runtime (line 200) | def _cleanup_runtime(
    method _close_log_handle (line 213) | def _close_log_handle(self, log_handle: TextIO | None) -> None:
    method _append_stop_marker (line 219) | def _append_stop_marker(self, log_path: str | None) -> None:
    method stop_task (line 229) | async def stop_task(self, task_id: int) -> bool:
    method _terminate_process (line 254) | async def _terminate_process(
    method _await_exit_watcher (line 280) | async def _await_exit_watcher(self, task_id: int) -> None:
    method reindex_after_delete (line 286) | def reindex_after_delete(self, deleted_task_id: int) -> None:
    method _reindex_mapping (line 294) | def _reindex_mapping(self, mapping: Dict[int, object], deleted_task_id...
    method stop_all (line 303) | async def stop_all(self) -> None:

FILE: src/services/result_export_service.py
  function build_results_csv (line 29) | def build_results_csv(records: list[dict]) -> str:

FILE: src/services/result_file_service.py
  function validate_result_filename (line 13) | def validate_result_filename(filename: str) -> None:
  function enrich_records_with_price_insight (line 18) | def enrich_records_with_price_insight(records: list[dict], filename: str...

FILE: src/services/result_storage_service.py
  function _get_link_unique_key (line 24) | def _get_link_unique_key(link: str) -> str:
  function _fallback_unique_key (line 28) | def _fallback_unique_key(record: dict, item: dict) -> str:
  function _parse_raw_record (line 38) | def _parse_raw_record(raw_json: str) -> dict:
  function _build_query_conditions (line 42) | def _build_query_conditions(
  function _sort_expression (line 61) | def _sort_expression(sort_by: str, sort_order: str) -> str:
  function save_result_record (line 67) | async def save_result_record(record: dict, keyword: str) -> bool:
  function _save_result_record_sync (line 71) | def _save_result_record_sync(record: dict, keyword: str) -> bool:
  function load_processed_link_keys (line 115) | def load_processed_link_keys(keyword: str) -> set[str]:
  function list_result_filenames (line 126) | async def list_result_filenames() -> list[str]:
  function _list_result_filenames_sync (line 130) | def _list_result_filenames_sync() -> list[str]:
  function result_file_exists (line 144) | async def result_file_exists(filename: str) -> bool:
  function _result_file_exists_sync (line 148) | def _result_file_exists_sync(filename: str) -> bool:
  function delete_result_file_records (line 158) | async def delete_result_file_records(filename: str) -> int:
  function _delete_result_file_records_sync (line 162) | def _delete_result_file_records_sync(filename: str) -> int:
  function query_result_records (line 173) | async def query_result_records(
  function _query_result_records_sync (line 195) | def _query_result_records_sync(
  function load_all_result_records (line 231) | async def load_all_result_records(
  function _load_all_result_records_sync (line 249) | def _load_all_result_records_sync(
  function build_result_ndjson (line 276) | async def build_result_ndjson(filename: str) -> str:
  function load_result_summary (line 287) | async def load_result_summary(filename: str) -> dict | None:
  function _load_result_summary_sync (line 291) | def _load_result_summary_sync(filename: str) -> dict | None:

FILE: src/services/scheduler_service.py
  class SchedulerService (line 14) | class SchedulerService:
    method __init__ (line 17) | def __init__(self, process_service: ProcessService):
    method start (line 21) | def start(self):
    method stop (line 27) | def stop(self):
    method get_next_run_time (line 33) | def get_next_run_time(self, task_id: int):
    method reload_jobs (line 52) | async def reload_jobs(self, tasks: List[Task]):
    method _run_task (line 78) | async def _run_task(self, task_id: int, task_name: str):

FILE: src/services/search_pagination.py
  class PageAdvanceResult (line 24) | class PageAdvanceResult:
  function is_search_results_response (line 30) | def is_search_results_response(
  function advance_search_page (line 40) | async def advance_search_page(

FILE: src/services/seller_profile_cache.py
  class _CacheEntry (line 15) | class _CacheEntry:
  class SellerProfileCache (line 20) | class SellerProfileCache:
    method __init__ (line 23) | def __init__(
    method _now (line 34) | def _now(self) -> float:
    method _clone (line 37) | def _clone(self, value: dict) -> dict:
    method _get_entry_value (line 40) | def _get_entry_value(self, user_id: str) -> Optional[dict]:
    method get_or_load (line 49) | async def get_or_load(self, user_id: str, loader: SellerProfileLoader)...
    method _load_and_store (line 60) | async def _load_and_store(self, user_id: str, loader: SellerProfileLoa...

FILE: src/services/task_generation_runner.py
  function build_criteria_filename (line 14) | def build_criteria_filename(keyword: str) -> str:
  function build_task_create (line 22) | def build_task_create(req: TaskGenerateRequest, criteria_file: str) -> T...
  function save_generated_criteria (line 46) | async def save_generated_criteria(output_filename: str, generated_criter...
  function reload_scheduler (line 55) | async def reload_scheduler(
  function advance_job (line 63) | async def advance_job(
  function run_ai_generation_job (line 72) | async def run_ai_generation_job(

FILE: src/services/task_generation_service.py
  class TaskGenerationService (line 23) | class TaskGenerationService:
    method __init__ (line 26) | def __init__(self, step_specs: Iterable[tuple[str, str]] = DEFAULT_GEN...
    method create_job (line 32) | async def create_job(self, task_name: str) -> TaskGenerationJob:
    method get_job (line 45) | async def get_job(self, job_id: str) -> Optional[TaskGenerationJob]:
    method track (line 52) | def track(self, coroutine: Awaitable[None]) -> None:
    method advance (line 69) | async def advance(self, job_id: str, step_key: str, message: str) -> T...
    method complete (line 89) | async def complete(self, job_id: str, task: Task, message: str) -> Tas...
    method fail (line 102) | async def fail(
    method _require_job (line 122) | def _require_job(self, job_id: str) -> TaskGenerationJob:
    method _find_step (line 128) | def _find_step(self, job: TaskGenerationJob, step_key: str) -> Optiona...
    method _find_step_index (line 134) | def _find_step_index(self, job: TaskGenerationJob, step_key: str) -> int:

FILE: src/services/task_log_cleanup_service.py
  function cleanup_task_logs (line 10) | def cleanup_task_logs(

FILE: src/services/task_payloads.py
  function serialize_timestamp (line 12) | def serialize_timestamp(value: datetime | None) -> str | None:
  function serialize_task (line 16) | def serialize_task(task: Task, scheduler_service) -> dict[str, Any]:
  function serialize_tasks (line 25) | def serialize_tasks(tasks: list[Task], scheduler_service) -> list[dict[s...

FILE: src/services/task_service.py
  class TaskService (line 10) | class TaskService:
    method __init__ (line 13) | def __init__(self, repository: TaskRepository):
    method get_all_tasks (line 16) | async def get_all_tasks(self) -> List[Task]:
    method get_task (line 20) | async def get_task(self, task_id: int) -> Optional[Task]:
    method create_task (line 24) | async def create_task(self, task_create: TaskCreate) -> Task:
    method update_task (line 29) | async def update_task(self, task_id: int, task_update: TaskUpdate) -> ...
    method delete_task (line 38) | async def delete_task(self, task_id: int) -> bool:
    method update_task_status (line 42) | async def update_task_status(self, task_id: int, is_running: bool) -> ...

FILE: src/utils.py
  function retry_on_failure (line 18) | def retry_on_failure(retries=3, delay=5):
  function safe_get (line 51) | async def safe_get(data, *keys, default="暂无"):
  function random_sleep (line 61) | async def random_sleep(min_seconds: float, max_seconds: float):
  function log_time (line 68) | def log_time(message: str, prefix: str = "") -> None:
  function sanitize_filename (line 77) | def sanitize_filename(value: str) -> str:
  function build_task_log_path (line 86) | def build_task_log_path(task_id: int, task_name: str) -> str:
  function resolve_task_log_path (line 93) | def resolve_task_log_path(task_id: int, task_name: str) -> str:
  function convert_goofish_link (line 105) | def convert_goofish_link(url: str) -> str:
  function get_link_unique_key (line 117) | def get_link_unique_key(link: str) -> str:
  function save_to_jsonl (line 122) | async def save_to_jsonl(data_record: dict, keyword: str):
  function format_registration_days (line 131) | def format_registration_days(total_days: int) -> str:

FILE: tests/conftest.py
  function fixtures_dir (line 25) | def fixtures_dir() -> Path:
  function load_json_fixture (line 30) | def load_json_fixture(fixtures_dir):
  function sample_task_payload (line 38) | def sample_task_payload():
  class FakeProcessService (line 57) | class FakeProcessService:
    method __init__ (line 58) | def __init__(self):
    method set_lifecycle_hooks (line 65) | def set_lifecycle_hooks(self, *, on_started=None, on_stopped=None):
    method start_task (line 69) | async def start_task(self, task_id: int, task_name: str) -> bool:
    method stop_task (line 75) | async def stop_task(self, task_id: int):
    method reindex_after_delete (line 80) | def reindex_after_delete(self, deleted_task_id: int):
  class FakeSchedulerService (line 84) | class FakeSchedulerService:
    method __init__ (line 85) | def __init__(self):
    method reload_jobs (line 89) | async def reload_jobs(self, tasks):
    method get_next_run_time (line 98) | def get_next_run_time(self, task_id: int):
  function api_context (line 103) | def api_context(tmp_path):
  function api_client (line 158) | def api_client(api_context):

FILE: tests/integration/test_api_dashboard.py
  function _write_jsonl (line 13) | def _write_jsonl(path, records):
  function test_dashboard_summary_aggregates_tasks_and_results (line 19) | def test_dashboard_summary_aggregates_tasks_and_results(tmp_path, monkey...

FILE: tests/integration/test_api_results.py
  function _write_jsonl (line 10) | def _write_jsonl(path, records):
  function test_results_filter_and_sort_for_keyword_recommendations (line 16) | def test_results_filter_and_sort_for_keyword_recommendations(tmp_path, m...
  function test_results_insights_and_export_csv (line 85) | def test_results_insights_and_export_csv(tmp_path, monkeypatch):
  function test_results_export_csv_supports_unicode_filename (line 201) | def test_results_export_csv_supports_unicode_filename(tmp_path, monkeypa...

FILE: tests/integration/test_api_settings.py
  class _IdleProcessService (line 43) | class _IdleProcessService:
    method __init__ (line 44) | def __init__(self) -> None:
  function _build_settings_client (line 48) | def _build_settings_client() -> TestClient:
  function _clear_settings_env (line 55) | def _clear_settings_env(monkeypatch) -> None:
  function test_rotation_settings_include_account_rotation_fields (line 60) | def test_rotation_settings_include_account_rotation_fields(tmp_path, mon...
  function test_notification_settings_redact_sensitive_values_and_expose_flags (line 110) | def test_notification_settings_redact_sensitive_values_and_expose_flags(...
  function test_update_notification_settings_rejects_invalid_channel_config (line 157) | def test_update_notification_settings_rejects_invalid_channel_config(tmp...
  function test_system_status_includes_notification_channel_flags (line 191) | def test_system_status_includes_notification_channel_flags(tmp_path, mon...
  function test_notification_test_endpoint_merges_stored_secret_values (line 226) | def test_notification_test_endpoint_merges_stored_secret_values(tmp_path...
  function test_ai_settings_fall_back_to_runtime_environment_when_env_file_missing (line 277) | def test_ai_settings_fall_back_to_runtime_environment_when_env_file_miss...
  function test_notification_settings_fall_back_to_runtime_environment_when_env_file_missing (line 305) | def test_notification_settings_fall_back_to_runtime_environment_when_env...
  function test_ai_test_endpoint_falls_back_to_responses_when_chat_completions_api_404 (line 331) | def test_ai_test_endpoint_falls_back_to_responses_when_chat_completions_...

FILE: tests/integration/test_api_tasks.py
  function test_create_list_update_delete_task (line 5) | def test_create_list_update_delete_task(api_client, api_context, sample_...
  function test_start_stop_task_updates_status (line 36) | def test_start_stop_task_updates_status(api_client, api_context, sample_...
  function test_generate_keyword_mode_task_without_ai_criteria (line 59) | def test_generate_keyword_mode_task_without_ai_criteria(api_client):
  function test_generate_ai_task_returns_job_and_completes_async (line 78) | def test_generate_ai_task_returns_job_and_completes_async(api_client, ap...
  function test_create_task_accepts_cron_alias (line 124) | def test_create_task_accepts_cron_alias(api_client, sample_task_payload):
  function test_create_task_rejects_fixed_account_strategy_without_state_file (line 134) | def test_create_task_rejects_fixed_account_strategy_without_state_file(a...
  function test_create_task_accepts_rotate_account_strategy (line 143) | def test_create_task_accepts_rotate_account_strategy(api_client, sample_...
  function test_update_task_accepts_six_field_cron_expression (line 154) | def test_update_task_accepts_six_field_cron_expression(api_client, sampl...
  function test_create_task_rejects_invalid_cron_expression (line 167) | def test_create_task_rejects_invalid_cron_expression(api_client, sample_...
  function test_delete_task_stops_runtime_and_reindexes_process_state (line 176) | def test_delete_task_stops_runtime_and_reindexes_process_state(

FILE: tests/integration/test_cli_spider.py
  function test_cli_runs_single_task_with_prompt (line 8) | def test_cli_runs_single_task_with_prompt(tmp_path, load_json_fixture, m...
  function test_cli_runs_keyword_mode_without_prompt_files (line 59) | def test_cli_runs_keyword_mode_without_prompt_files(tmp_path, load_json_...

FILE: tests/integration/test_pipeline_parse.py
  function test_parse_search_results (line 12) | def test_parse_search_results(load_json_fixture):
  function test_parse_user_head_and_items (line 24) | def test_parse_user_head_and_items(load_json_fixture):
  function test_parse_ratings_and_reputation (line 37) | def test_parse_ratings_and_reputation(load_json_fixture):

FILE: tests/live/_support.py
  class LiveTestSettings (line 39) | class LiveTestSettings:
  class LiveServer (line 52) | class LiveServer:
  function env_flag (line 60) | def env_flag(name: str, default: bool = False) -> bool:
  function env_int (line 67) | def env_int(name: str, default: int) -> int:
  function load_runtime_env (line 74) | def load_runtime_env(repo_root: Path) -> dict[str, str]:
  function build_ai_test_payload (line 88) | def build_ai_test_payload(runtime_env: dict[str, str]) -> dict[str, str]:
  function resolve_account_source (line 102) | def resolve_account_source(repo_root: Path) -> Path:
  function load_live_settings (line 115) | def load_live_settings(repo_root: Path) -> LiveTestSettings:
  function mirror_path (line 130) | def mirror_path(source: Path, destination: Path) -> None:
  function prepare_workspace (line 142) | def prepare_workspace(workspace: Path, settings: LiveTestSettings) -> Path:
  function build_server_env (line 155) | def build_server_env(workspace: Path, repo_root: Path, port: int) -> dic...
  function find_free_port (line 179) | def find_free_port() -> int:
  function wait_for_server_ready (line 186) | def wait_for_server_ready(base_url: str, process: subprocess.Popen, log_...
  function terminate_process (line 210) | def terminate_process(process: subprocess.Popen, timeout_seconds: int = ...

FILE: tests/live/conftest.py
  function pytest_collection_modifyitems (line 25) | def pytest_collection_modifyitems(config, items):
  function live_settings (line 36) | def live_settings():
  function live_server (line 51) | def live_server(live_settings, request, tmp_path_factory):

FILE: tests/live/test_live_smoke.py
  function api_request (line 25) | def api_request(session: requests.Session, method: str, url: str, **kwar...
  function fetch_task (line 29) | def fetch_task(session: requests.Session, base_url: str, task_id: int) -...
  function fetch_results_or_none (line 34) | def fetch_results_or_none(
  function find_task_log (line 52) | def find_task_log(workspace: Path, task_id: int) -> Path | None:
  function read_task_log (line 57) | def read_task_log(workspace: Path, task_id: int) -> tuple[Path | None, s...
  function assert_log_is_clean (line 63) | def assert_log_is_clean(log_text: str, log_path: Path | None) -> None:
  function wait_for_task_running (line 68) | def wait_for_task_running(
  function wait_for_task_completion (line 83) | def wait_for_task_completion(
  function delete_task_safely (line 117) | def delete_task_safely(session: requests.Session, base_url: str, task_id...
  function build_live_task_payload (line 121) | def build_live_task_payload(account_state_file: Path, task_name: str, ke...
  function test_live_preflight_smoke (line 137) | def test_live_preflight_smoke(live_server):
  function test_live_real_traffic_task_smoke (line 154) | def test_live_real_traffic_task_smoke(live_server):
  function test_live_ai_task_generation_job (line 215) | def test_live_ai_task_generation_job(live_server):

FILE: tests/test_failure_guard.py
  function test_failure_guard_opens_circuit_after_threshold_and_rate_limits (line 8) | def test_failure_guard_opens_circuit_after_threshold_and_rate_limits(tmp...
  function test_failure_guard_auto_recovers_on_cookie_change (line 49) | def test_failure_guard_auto_recovers_on_cookie_change(tmp_path):

FILE: tests/test_frontend_build_paths.py
  function read_repo_file (line 10) | def read_repo_file(relative_path: str) -> str:
  function test_frontend_build_output_path_is_consistent_across_configs (line 14) | def test_frontend_build_output_path_is_consistent_across_configs():

FILE: tests/unit/test_ai_client.py
  function _build_fake_client (line 10) | def _build_fake_client(responses_create_impl, chat_create_impl=None):
  function test_build_messages_without_images_uses_text_only_content (line 18) | def test_build_messages_without_images_uses_text_only_content():
  function test_build_messages_with_images_uses_multimodal_content (line 33) | def test_build_messages_with_images_uses_multimodal_content(monkeypatch):
  function test_build_responses_input_converts_multimodal_messages (line 49) | def test_build_responses_input_converts_multimodal_messages():
  function test_call_ai_retries_without_structured_output_when_model_rejects_it (line 77) | def test_call_ai_retries_without_structured_output_when_model_rejects_it():
  function test_call_ai_falls_back_to_responses_when_chat_completions_api_is_missing (line 113) | def test_call_ai_falls_back_to_responses_when_chat_completions_api_is_mi...
  function test_call_ai_retries_without_temperature_when_gateway_rejects_it (line 149) | def test_call_ai_retries_without_temperature_when_gateway_rejects_it():
  function test_call_ai_retries_when_response_content_is_empty (line 179) | def test_call_ai_retries_when_response_content_is_empty():
  function test_call_ai_raises_after_all_empty_response_retries_are_exhausted (line 202) | def test_call_ai_raises_after_all_empty_response_retries_are_exhausted():
  function test_close_closes_underlying_async_client_and_clears_reference (line 223) | def test_close_closes_underlying_async_client_and_clears_reference():
  function test_parse_response_uses_first_json_object_when_response_contains_multiple_objects (line 238) | def test_parse_response_uses_first_json_object_when_response_contains_mu...

FILE: tests/unit/test_ai_handler_analysis.py
  function _build_fake_client (line 10) | def _build_fake_client(responses_create_impl, chat_create_impl=None):
  function test_get_ai_analysis_stops_after_internal_retries_when_content_is_none (line 18) | def test_get_ai_analysis_stops_after_internal_retries_when_content_is_none(
  function test_get_ai_analysis_returns_parsed_json (line 45) | def test_get_ai_analysis_returns_parsed_json(monkeypatch, tmp_path):
  function test_get_ai_analysis_retries_without_structured_output_when_model_rejects_it (line 75) | def test_get_ai_analysis_retries_without_structured_output_when_model_re...
  function test_get_ai_analysis_falls_back_to_responses_when_chat_completions_api_is_missing (line 123) | def test_get_ai_analysis_falls_back_to_responses_when_chat_completions_a...
  function test_get_ai_analysis_retries_without_temperature_when_gateway_rejects_it (line 175) | def test_get_ai_analysis_retries_without_temperature_when_gateway_reject...
  function test_get_ai_analysis_uses_first_json_object_when_model_returns_multiple_objects (line 216) | def test_get_ai_analysis_uses_first_json_object_when_model_returns_multi...

FILE: tests/unit/test_ai_handler_downloads.py
  function test_download_all_images_runs_with_concurrency (line 7) | def test_download_all_images_runs_with_concurrency(tmp_path, monkeypatch):

FILE: tests/unit/test_ai_request_compat.py
  function test_is_temperature_unsupported_error_detects_unsupported_message (line 8) | def test_is_temperature_unsupported_error_detects_unsupported_message():
  function test_remove_temperature_param_removes_only_temperature (line 13) | def test_remove_temperature_param_removes_only_temperature():
  function test_is_responses_api_unsupported_error_detects_gemini_plain_404 (line 22) | def test_is_responses_api_unsupported_error_detects_gemini_plain_404():

FILE: tests/unit/test_ai_response_parser.py
  function test_parse_ai_response_json_uses_first_object_when_multiple_json_objects_are_concatenated (line 6) | def test_parse_ai_response_json_uses_first_object_when_multiple_json_obj...
  function test_parse_ai_response_json_extracts_json_from_wrapped_text (line 17) | def test_parse_ai_response_json_extracts_json_from_wrapped_text():
  function test_parse_ai_response_json_raises_when_no_json_exists (line 31) | def test_parse_ai_response_json_raises_when_no_json_exists():

FILE: tests/unit/test_app_lifespan.py
  class _FakeTaskService (line 6) | class _FakeTaskService:
    method __init__ (line 7) | def __init__(self, _repo):
    method get_all_tasks (line 10) | async def get_all_tasks(self):
    method update_task_status (line 13) | async def update_task_status(self, task_id, is_running):
  class _FakeSchedulerService (line 17) | class _FakeSchedulerService:
    method __init__ (line 18) | def __init__(self):
    method reload_jobs (line 23) | async def reload_jobs(self, tasks):
    method start (line 26) | def start(self):
    method stop (line 29) | def stop(self):
  class _FakeProcessService (line 33) | class _FakeProcessService:
    method __init__ (line 34) | def __init__(self):
    method stop_all (line 37) | async def stop_all(self):
  function test_lifespan_cleans_task_logs_on_startup (line 41) | def test_lifespan_cleans_task_logs_on_startup(monkeypatch):

FILE: tests/unit/test_cron_utils.py
  function test_validate_cron_expression_normalizes_alias (line 4) | def test_validate_cron_expression_normalizes_alias():
  function test_validate_cron_expression_accepts_six_fields (line 8) | def test_validate_cron_expression_accepts_six_fields():
  function test_build_cron_trigger_accepts_alias_and_timezone (line 12) | def test_build_cron_trigger_accepts_alias_and_timezone():
  function test_validate_cron_expression_rejects_invalid_value (line 19) | def test_validate_cron_expression_rejects_invalid_value():

FILE: tests/unit/test_domain_task.py
  function test_task_can_start_and_stop (line 4) | def test_task_can_start_and_stop():
  function test_task_apply_update (line 29) | def test_task_apply_update():
  function test_legacy_keyword_groups_are_flattened_to_keyword_rules (line 54) | def test_legacy_keyword_groups_are_flattened_to_keyword_rules():
  function test_generate_request_accepts_legacy_group_payload (line 79) | def test_generate_request_accepts_legacy_group_payload():
  function test_generate_request_enables_image_analysis_by_default (line 90) | def test_generate_request_enables_image_analysis_by_default():
  function test_generate_request_infers_fixed_account_strategy_from_state_file (line 100) | def test_generate_request_infers_fixed_account_strategy_from_state_file():
  function test_generate_request_requires_state_file_for_fixed_account_strategy (line 112) | def test_generate_request_requires_state_file_for_fixed_account_strategy():

FILE: tests/unit/test_item_analysis_dispatcher.py
  function test_item_analysis_dispatcher_uses_bounded_concurrency (line 9) | def test_item_analysis_dispatcher_uses_bounded_concurrency():
  function test_item_analysis_dispatcher_supports_keyword_mode_without_ai (line 81) | def test_item_analysis_dispatcher_supports_keyword_mode_without_ai():

FILE: tests/unit/test_keyword_rule_engine.py
  function _sample_record (line 4) | def _sample_record():
  function test_build_search_text_contains_product_and_seller_fields (line 18) | def test_build_search_text_contains_product_and_seller_fields():
  function test_keyword_rules_or_match_any_keyword (line 25) | def test_keyword_rules_or_match_any_keyword():
  function test_keyword_rules_count_multiple_hits (line 34) | def test_keyword_rules_count_multiple_hits():
  function test_keyword_rules_case_insensitive_contains (line 41) | def test_keyword_rules_case_insensitive_contains():
  function test_keyword_rules_no_match (line 48) | def test_keyword_rules_no_match():
  function test_keyword_rules_do_not_partially_match_alphanumeric_prefixes (line 55) | def test_keyword_rules_do_not_partially_match_alphanumeric_prefixes():
  function test_keyword_rules_still_match_full_alphanumeric_token (line 61) | def test_keyword_rules_still_match_full_alphanumeric_token():

FILE: tests/unit/test_notification_service.py
  class _OkClient (line 8) | class _OkClient(NotificationClient):
    method send (line 12) | async def send(self, product_data, reason):
  class _FailClient (line 16) | class _FailClient(NotificationClient):
    method send (line 20) | async def send(self, product_data, reason):
  function test_notification_service_collects_success_and_failure_results (line 24) | def test_notification_service_collects_success_and_failure_results():
  function test_webhook_client_renders_json_templates (line 37) | def test_webhook_client_renders_json_templates(monkeypatch):

FILE: tests/unit/test_price_history_service.py
  function test_record_market_snapshots_and_build_price_history_insights (line 9) | def test_record_market_snapshots_and_build_price_history_insights(tmp_pa...

FILE: tests/unit/test_process_service.py
  class FakeProcess (line 8) | class FakeProcess:
    method __init__ (line 9) | def __init__(self, pid: int):
    method wait (line 14) | async def wait(self):
    method finish (line 18) | def finish(self, returncode: int = 0):
    method terminate (line 22) | def terminate(self):
    method kill (line 25) | def kill(self):
  function test_process_service_marks_task_stopped_when_process_exits (line 29) | def test_process_service_marks_task_stopped_when_process_exits(monkeypat...
  function test_process_service_reindexes_runtime_maps_after_delete (line 77) | def test_process_service_reindexes_runtime_maps_after_delete():
  function test_process_service_adds_debug_limit_arg_when_env_enabled (line 97) | def test_process_service_adds_debug_limit_arg_when_env_enabled(monkeypat...

FILE: tests/unit/test_prompt_utils.py
  function test_generate_criteria_closes_ai_client_after_success (line 9) | def test_generate_criteria_closes_ai_client_after_success(monkeypatch, t...
  function test_generate_criteria_closes_ai_client_after_ai_failure (line 37) | def test_generate_criteria_closes_ai_client_after_ai_failure(monkeypatch...

FILE: tests/unit/test_scraper_browser_channel.py
  function _load_scraper (line 4) | def _load_scraper(monkeypatch, *, login_is_edge: bool, running_in_docker...
  function test_resolve_browser_channel_uses_chromium_in_docker_even_when_edge_requested (line 17) | def test_resolve_browser_channel_uses_chromium_in_docker_even_when_edge_...
  function test_resolve_browser_channel_uses_msedge_locally_when_requested (line 24) | def test_resolve_browser_channel_uses_msedge_locally_when_requested(monk...

FILE: tests/unit/test_search_pagination.py
  class FakeRequest (line 9) | class FakeRequest:
    method __init__ (line 10) | def __init__(self, method: str = "POST"):
  class FakeResponse (line 14) | class FakeResponse:
    method __init__ (line 15) | def __init__(self, url: str, ok: bool = True, method: str = "POST"):
  class FakeLocator (line 21) | class FakeLocator:
    method __init__ (line 22) | def __init__(self, count: int, click_error: Exception | None = None):
    method first (line 30) | def first(self):
    method count (line 33) | async def count(self) -> int:
    method scroll_into_view_if_needed (line 36) | async def scroll_into_view_if_needed(self) -> None:
    method click (line 39) | async def click(self, timeout: int | None = None) -> None:
  class FakeResponseContext (line 46) | class FakeResponseContext:
    method __init__ (line 47) | def __init__(self, outcome):
    method __aenter__ (line 50) | async def __aenter__(self):
    method __aexit__ (line 53) | async def __aexit__(self, exc_type, exc, tb):
    method value (line 57) | def value(self):
    method _resolve (line 60) | async def _resolve(self):
  class FakePage (line 66) | class FakePage:
    method __init__ (line 67) | def __init__(
    method locator (line 76) | def locator(self, _selector: str) -> FakeLocator:
    method expect_response (line 79) | def expect_response(self, _predicate, timeout: int):
  function _noop_random_sleep (line 86) | async def _noop_random_sleep(_min_seconds: float, _max_seconds: float) -...
  function _noop_sleep (line 90) | async def _noop_sleep(_seconds: float) -> None:
  function test_advance_search_page_stops_when_no_next_button (line 94) | def test_advance_search_page_stops_when_no_next_button() -> None:
  function test_advance_search_page_stops_after_timeout_retries (line 115) | def test_advance_search_page_stops_after_timeout_retries() -> None:
  function test_advance_search_page_returns_new_response_on_success (line 146) | def test_advance_search_page_returns_new_response_on_success() -> None:
  function test_advance_search_page_stops_when_click_times_out (line 170) | def test_advance_search_page_stops_when_click_times_out() -> None:
  function test_is_search_results_response_matches_exact_search_api (line 195) | def test_is_search_results_response_matches_exact_search_api() -> None:
  function test_is_search_results_response_rejects_search_shade_api (line 204) | def test_is_search_results_response_rejects_search_shade_api() -> None:
  function test_is_search_results_response_rejects_non_post_request (line 213) | def test_is_search_results_response_rejects_non_post_request() -> None:

FILE: tests/unit/test_seller_profile_cache.py
  function test_seller_profile_cache_reuses_value_and_returns_copy (line 6) | def test_seller_profile_cache_reuses_value_and_returns_copy():
  function test_seller_profile_cache_coalesces_inflight_requests (line 27) | def test_seller_profile_cache_coalesces_inflight_requests():

FILE: tests/unit/test_task_log_cleanup_service.py
  function _write_file (line 9) | def _write_file(path: Path, content: str = "log") -> None:
  function _set_mtime (line 14) | def _set_mtime(path: Path, when: datetime) -> None:
  function test_cleanup_task_logs_removes_only_expired_top_level_logs (line 23) | def test_cleanup_task_logs_removes_only_expired_top_level_logs(tmp_path):
  function test_cleanup_task_logs_skips_when_retention_is_invalid (line 50) | def test_cleanup_task_logs_skips_when_retention_is_invalid(tmp_path):

FILE: tests/unit/test_utils.py
  function test_safe_get_nested_and_default (line 12) | def test_safe_get_nested_and_default():
  function test_format_registration_days (line 18) | def test_format_registration_days():
  function test_get_link_unique_key (line 23) | def test_get_link_unique_key():
  function test_save_to_jsonl (line 28) | def test_save_to_jsonl(tmp_path, monkeypatch):

FILE: web-ui/src/api/accounts.ts
  type AccountItem (line 3) | interface AccountItem {
  type AccountDetail (line 8) | interface AccountDetail extends AccountItem {
  function listAccounts (line 12) | async function listAccounts(): Promise<AccountItem[]> {
  function getAccount (line 16) | async function getAccount(name: string): Promise<AccountDetail> {
  function createAccount (line 20) | async function createAccount(payload: { name: string; content: string })...
  function updateAccount (line 28) | async function updateAccount(name: string, content: string): Promise<Acc...
  function deleteAccount (line 36) | async function deleteAccount(name: string): Promise<{ message: string }> {

FILE: web-ui/src/api/dashboard.ts
  function getDashboardSummary (line 4) | async function getDashboardSummary(): Promise<DashboardSnapshot> {

FILE: web-ui/src/api/logs.ts
  function getLogs (line 3) | async function getLogs(fromPos: number = 0, taskId?: number | null): Pro...
  function clearLogs (line 11) | async function clearLogs(taskId?: number | null): Promise<void> {
  function getLogTail (line 19) | async function getLogTail(

FILE: web-ui/src/api/prompts.ts
  type PromptContent (line 3) | interface PromptContent {
  function listPrompts (line 8) | async function listPrompts(): Promise<string[]> {
  function getPromptContent (line 12) | async function getPromptContent(filename: string): Promise<PromptContent> {
  function updatePrompt (line 16) | async function updatePrompt(filename: string, content: string): Promise<...

FILE: web-ui/src/api/results.ts
  type GetResultContentParams (line 4) | interface GetResultContentParams {
  function getResultFiles (line 14) | async function getResultFiles(): Promise<string[]> {
  function deleteResultFile (line 19) | async function deleteResultFile(filename: string): Promise<{ message: st...
  function getResultContent (line 23) | async function getResultContent(
  function getResultInsights (line 30) | async function getResultInsights(filename: string): Promise<ResultInsigh...
  function buildResultExportUrl (line 34) | function buildResultExportUrl(filename: string, params: GetResultContent...
  function downloadResultExport (line 45) | function downloadResultExport(filename: string, params: GetResultContent...

FILE: web-ui/src/api/settings.ts
  type NotificationSettings (line 3) | interface NotificationSettings {
  type NotificationSettingsUpdate (line 28) | interface NotificationSettingsUpdate {
  type NotificationTestResponse (line 46) | interface NotificationTestResponse {
  type AiSettings (line 55) | interface AiSettings {
  type RotationSettings (line 62) | interface RotationSettings {
  type SystemStatus (line 75) | interface SystemStatus {
  function getNotificationSettings (line 104) | async function getNotificationSettings(): Promise<NotificationSettings> {
  function updateNotificationSettings (line 108) | async function updateNotificationSettings(settings: NotificationSettings...
  function testNotificationSettings (line 116) | async function testNotificationSettings(
  function getAiSettings (line 126) | async function getAiSettings(): Promise<AiSettings> {
  function updateAiSettings (line 130) | async function updateAiSettings(settings: AiSettings): Promise<void> {
  function getRotationSettings (line 138) | async function getRotationSettings(): Promise<RotationSettings> {
  function updateRotationSettings (line 142) | async function updateRotationSettings(settings: RotationSettings): Promi...
  function testAiSettings (line 150) | async function testAiSettings(settings: AiSettings): Promise<{ success: ...
  function getSystemStatus (line 158) | async function getSystemStatus(): Promise<SystemStatus> {
  function updateLoginState (line 162) | async function updateLoginState(content: string): Promise<{ message: str...
  function deleteLoginState (line 170) | async function deleteLoginState(): Promise<{ message: string }> {

FILE: web-ui/src/api/tasks.ts
  function getAllTasks (line 10) | async function getAllTasks(): Promise<Task[]> {
  function createTaskWithAI (line 14) | async function createTaskWithAI(data: TaskGenerateRequest): Promise<Task...
  function getTaskGenerationJob (line 24) | async function getTaskGenerationJob(jobId: string): Promise<TaskGenerati...
  function updateTask (line 29) | async function updateTask(taskId: number, data: TaskUpdate): Promise<Tas...
  function startTask (line 40) | async function startTask(taskId: number): Promise<void> {
  function stopTask (line 44) | async function stopTask(taskId: number): Promise<void> {
  function deleteTask (line 48) | async function deleteTask(taskId: number): Promise<void> {

FILE: web-ui/src/components/ui/badge/index.ts
  type BadgeVariants (line 26) | type BadgeVariants = VariantProps<typeof badgeVariants>

FILE: web-ui/src/components/ui/button/index.ts
  type ButtonVariants (line 37) | type ButtonVariants = VariantProps<typeof buttonVariants>

FILE: web-ui/src/components/ui/toast/index.ts
  type ToastVariants (line 33) | type ToastVariants = VariantProps<typeof toastVariants>
  type ToastProps (line 35) | interface ToastProps extends ToastRootProps {

FILE: web-ui/src/components/ui/toast/use-toast.ts
  constant TOAST_LIMIT (line 5) | const TOAST_LIMIT = 1
  constant TOAST_REMOVE_DELAY (line 6) | const TOAST_REMOVE_DELAY = 1000000
  type StringOrVNode (line 8) | type StringOrVNode
  type ToasterToast (line 13) | type ToasterToast = ToastProps & {
  function genId (line 29) | function genId() {
  type ActionType (line 34) | type ActionType = typeof actionTypes
  type Action (line 36) | type Action
  type State (line 54) | interface State {
  function addToRemoveQueue (line 60) | function addToRemoveQueue(toastId: string) {
  function dispatch (line 79) | function dispatch(action: Action) {
  function useToast (line 124) | function useToast() {
  type Toast (line 132) | type Toast = Omit<ToasterToast, "id">
  function toast (line 134) | function toast(props: Toast) {

FILE: web-ui/src/composables/useAuth.ts
  function useAuth (line 9) | function useAuth() {

FILE: web-ui/src/composables/useDashboard.ts
  function buildSuggestion (line 13) | function buildSuggestion(
  function useDashboard (line 50) | function useDashboard() {

FILE: web-ui/src/composables/useLogs.ts
  function useLogs (line 5) | function useLogs() {

FILE: web-ui/src/composables/useMobileNav.ts
  function useMobileNav (line 5) | function useMobileNav() {

FILE: web-ui/src/composables/useResults.ts
  function useResults (line 10) | function useResults() {

FILE: web-ui/src/composables/useSettings.ts
  function useSettings (line 12) | function useSettings() {

FILE: web-ui/src/composables/useTaskGenerationJob.ts
  constant POLL_INTERVAL_MS (line 5) | const POLL_INTERVAL_MS = 800
  function isTerminalStatus (line 7) | function isTerminalStatus(status: TaskGenerationJob['status']) {
  function useTaskGenerationJob (line 11) | function useTaskGenerationJob() {

FILE: web-ui/src/composables/useTasks.ts
  function useTasks (line 11) | function useTasks() {

FILE: web-ui/src/composables/useWebSocket.ts
  function useWebSocket (line 11) | function useWebSocket() {

FILE: web-ui/src/i18n/index.ts
  type AppLocale (line 6) | type AppLocale = 'zh-CN' | 'en-US'
  constant LOCALE_STORAGE_KEY (line 8) | const LOCALE_STORAGE_KEY = 'app_locale'
  constant DEFAULT_LOCALE (line 9) | const DEFAULT_LOCALE: AppLocale = 'zh-CN'
  constant SUPPORTED_LOCALES (line 10) | const SUPPORTED_LOCALES: AppLocale[] = ['zh-CN', 'en-US']
  function resolveInitialLocale (line 12) | function resolveInitialLocale(): AppLocale {
  function setLocale (line 31) | function setLocale(locale: AppLocale) {
  function useLocale (line 35) | function useLocale() {
  function t (line 50) | function t(key: string, params?: Record<string, unknown>) {
  function formatNumber (line 54) | function formatNumber(value: number, options?: Intl.NumberFormatOptions) {
  function formatDateTime (line 58) | function formatDateTime(
  function formatRelativeTimeFromNow (line 66) | function formatRelativeTimeFromNow(value: string | null | undefined) {

FILE: web-ui/src/lib/http.ts
  type FetchOptions (line 3) | interface FetchOptions extends RequestInit {
  function http (line 7) | async function http(url: string, options: FetchOptions = {}) {

FILE: web-ui/src/lib/taskFormQuery.ts
  type TaskFormDefaults (line 4) | type TaskFormDefaults = Partial<TaskGenerateRequest & TaskUpdate>
  type QueryValue (line 5) | type QueryValue = LocationQuery[string] | undefined
  constant TRUE_VALUES (line 7) | const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on'])
  constant FALSE_VALUES (line 8) | const FALSE_VALUES = new Set(['0', 'false', 'no', 'off'])
  function readString (line 10) | function readString(value: QueryValue): string | undefined {
  function readBoolean (line 16) | function readBoolean(value: QueryValue): boolean | undefined {
  function readNumber (line 24) | function readNumber(value: QueryValue): number | undefined {
  function readKeywordRules (line 31) | function readKeywordRules(value: QueryValue): string[] | undefined {
  function parseTaskFormDefaults (line 41) | function parseTaskFormDefaults(query: LocationQuery): TaskFormDefaults {

FILE: web-ui/src/lib/taskSchedule.ts
  constant SECOND_MS (line 3) | const SECOND_MS = 1000
  constant MINUTE_MS (line 4) | const MINUTE_MS = 60 * SECOND_MS
  constant HOUR_MS (line 5) | const HOUR_MS = 60 * MINUTE_MS
  constant DAY_MS (line 6) | const DAY_MS = 24 * HOUR_MS
  function parseDate (line 8) | function parseDate(value: string | null | undefined): Date | null {
  function padNumber (line 14) | function padNumber(value: number): string {
  function formatNextRunAbsolute (line 18) | function formatNextRunAbsolute(value: string | null | undefined): string {
  function formatCountdown (line 24) | function formatCountdown(

FILE: web-ui/src/lib/utils.ts
  function cn (line 4) | function cn(...inputs: ClassValue[]) {

FILE: web-ui/src/router/index.ts
  function updateDocumentTitle (line 69) | function updateDocumentTitle() {

FILE: web-ui/src/services/websocket.ts
  type WebSocketEventHandler (line 1) | type WebSocketEventHandler = (data: any) => void;
  class WebSocketService (line 3) | class WebSocketService {
    method constructor (line 10) | constructor() {
    method start (line 18) | public start() {
    method stop (line 26) | public stop() {
    method connect (line 35) | private connect() {
    method on (line 82) | public on(event: string, handler: WebSocketEventHandler) {
    method off (line 89) | public off(event: string, handler: WebSocketEventHandler) {
    method emit (line 99) | private emit(event: string, data: any) {

FILE: web-ui/src/types/dashboard.d.ts
  type DashboardSummary (line 3) | interface DashboardSummary {
  type DashboardTaskSummary (line 14) | interface DashboardTaskSummary {
  type DashboardActivity (line 33) | interface DashboardActivity {
  type DashboardSnapshot (line 45) | interface DashboardSnapshot {
  type DashboardSuggestion (line 52) | interface DashboardSuggestion {
  type DashboardState (line 60) | interface DashboardState {

FILE: web-ui/src/types/result.d.ts
  type ProductInfo (line 3) | interface ProductInfo {
  type SellerInfo (line 19) | interface SellerInfo {
  type AiAnalysis (line 37) | interface AiAnalysis {
  type PriceInsight (line 51) | interface PriceInsight {
  type ResultInsights (line 68) | interface ResultInsights {
  type ResultItem (line 96) | interface ResultItem {

FILE: web-ui/src/types/task.d.ts
  type Task (line 3) | interface Task {
  type TaskGenerationStatus (line 28) | type TaskGenerationStatus = 'queued' | 'running' | 'completed' | 'failed';
  type TaskGenerationStepStatus (line 29) | type TaskGenerationStepStatus = 'pending' | 'running' | 'completed' | 'f...
  type TaskGenerationStep (line 31) | interface TaskGenerationStep {
  type TaskGenerationJob (line 38) | interface TaskGenerationJob {
  type TaskCreateResponse (line 49) | interface TaskCreateResponse {
  type TaskUpdate (line 56) | type TaskUpdate = Partial<Omit<Task, 'id' | 'next_run_at'>>;
  type TaskGenerateRequest (line 59) | interface TaskGenerateRequest {
Condensed preview — 283 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (926K chars).
[
  {
    "path": ".dockerignore",
    "chars": 210,
    "preview": "__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/"
  },
  {
    "path": ".github/workflows/claude.yml",
    "chars": 1825,
    "preview": "name: Claude Code\n\non:\n  issue_comment:\n    types: [created]\n  pull_request_review_comment:\n    types: [created]\n  issue"
  },
  {
    "path": ".github/workflows/docker-image.yml",
    "chars": 3888,
    "preview": "name: Docker Image CI on merge to master\n\non:\n  workflow_dispatch:\n  pull_request:\n    types: [closed]\n    branches: [\"m"
  },
  {
    "path": ".gitignore",
    "chars": 158,
    "preview": ".idea\n*.iml\nxianyu_state.json\n.env\n.aider*\nimages/\nlogs/\njsonl/\n__pycache__/\nsrc/__pycache__/\ndist/\nstate/\nconfig.json\np"
  },
  {
    "path": "AGENTS.md",
    "chars": 1875,
    "preview": "# Repository Guidelines\n\n## 项目结构与模块组织\n- 后端位于 `src/`,入口 `src/app.py`,API 路由在 `src/api/routes/`,服务层在 `src/services/`,领域模型在"
  },
  {
    "path": "CLAUDE.md",
    "chars": 2195,
    "preview": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## "
  },
  {
    "path": "DISCLAIMER.md",
    "chars": 1921,
    "preview": "# 免责声明 / Disclaimer\n\n## 中文版本\n\n本项目是一个开源软件,仅供学习和研究目的使用。使用者在使用本软件时,必须遵守所在国家/地区的所有相关法律法规。\n\n项目作者及贡献者明确声明:\n\n1. 本项目仅用于技术学习和研究目的"
  },
  {
    "path": "Dockerfile",
    "chars": 1546,
    "preview": "# Stage 1: Build the Vue application\nFROM node:22-alpine AS frontend-builder\nWORKDIR /web-ui\nCOPY web-ui/package*.json ."
  },
  {
    "path": "Dockerfile.base",
    "chars": 924,
    "preview": "# syntax=docker/dockerfile:1.7\n\nFROM python:3.11-slim-bookworm\n\nWORKDIR /app\n\nENV DEBIAN_FRONTEND=noninteractive \\\n    V"
  },
  {
    "path": "Dockerfile.release",
    "chars": 541,
    "preview": "# syntax=docker/dockerfile:1.7\n\nARG BASE_IMAGE=ghcr.io/usagi-org/ai-goofish-base:latest\n\nFROM node:22-alpine AS frontend"
  },
  {
    "path": "LICENSE",
    "chars": 1068,
    "preview": "MIT License\n\nCopyright (c) 2025 dingyufei615\n\nPermission is hereby granted, free of charge, to any person obtaining a co"
  },
  {
    "path": "README.md",
    "chars": 7350,
    "preview": "# 闲鱼智能监控机器人\n\n[English README](README_EN.md)\n\n基于 Playwright 和 AI 的闲鱼多任务实时监控工具,提供完整的 Web 管理界面。\n\n## 核心特性\n\n- **Web 可视化管理**: "
  },
  {
    "path": "README_EN.md",
    "chars": 12182,
    "preview": "# Xianyu Intelligent Monitor Bot\n\n[中文说明](README.md)\n\nA Playwright and AI-powered multi-task real-time monitoring tool fo"
  },
  {
    "path": "chrome-extension/README.md",
    "chars": 1545,
    "preview": "# Xianyu Login State Extractor Chrome Extension\n\nThis Chrome extension helps extract complete login state information fr"
  },
  {
    "path": "chrome-extension/background.js",
    "chars": 7878,
    "preview": "// Service worker for capturing browser environment, headers, storage, and cookies\n\nconst GOOFISH_HOST_PATTERN = \"*://*."
  },
  {
    "path": "chrome-extension/manifest.json",
    "chars": 519,
    "preview": "{\n  \"manifest_version\": 3,\n  \"name\": \"Xianyu Login State Extractor\",\n  \"version\": \"1.1\",\n  \"description\": \"Extract login"
  },
  {
    "path": "chrome-extension/popup.html",
    "chars": 1086,
    "preview": "<!DOCTYPE html>\n<html>\n<meta charset=\"UTF-8\">\n<head>\n  <style>\n    body {\n      width: 400px;\n      padding: 20px;\n     "
  },
  {
    "path": "chrome-extension/popup.js",
    "chars": 1879,
    "preview": "// Popup script for the Chrome extension\ndocument.addEventListener('DOMContentLoaded', function() {\n  const extractBtn ="
  },
  {
    "path": "config.json.example",
    "chars": 537,
    "preview": "[\n  {\n    \"task_name\": \"苹果watch S10\",\n    \"enabled\": true,\n    \"keyword\": \"苹果watch S10\",\n    \"description\": \"九成新,充电线包装盒齐"
  },
  {
    "path": "desktop_launcher.py",
    "chars": 931,
    "preview": "\"\"\"\n桌面启动入口\n使用 PyInstaller 打包后作为单一可执行文件的入口,自动启动 FastAPI 服务并打开浏览器。\n\"\"\"\nimport os\nimport sys\nimport time\nimport webbrowser\n"
  },
  {
    "path": "docker-compose.dev.yaml",
    "chars": 220,
    "preview": "services:\n  app:\n    build: .\n    container_name: ai-goofish-monitor-app\n    init: true\n    ports:\n      - \"8000:8000\"\n "
  },
  {
    "path": "docker-compose.yaml",
    "chars": 594,
    "preview": "services:\n  app:\n    image: ${APP_IMAGE:-ghcr.io/usagi-org/ai-goofish:latest}\n    container_name: ai-goofish-monitor-app"
  },
  {
    "path": "pyproject.toml",
    "chars": 563,
    "preview": "[tool.pytest.ini_options]\naddopts = \"-v --tb=short\"\ntestpaths = [\"tests\"]\npython_files = [\"test_*.py\"]\npython_classes = "
  },
  {
    "path": "requirements-runtime.txt",
    "chars": 160,
    "preview": "python-dotenv\nplaywright\nrequests\nopenai\nfastapi\nuvicorn[standard]\npydantic-settings\njinja2\naiofiles\npython-socks\napsche"
  },
  {
    "path": "requirements.txt",
    "chars": 191,
    "preview": "python-dotenv\nplaywright\nrequests\nopenai\nfastapi\nuvicorn[standard]\npydantic-settings\njinja2\naiofiles\npython-socks\napsche"
  },
  {
    "path": "run_live_smoke.sh",
    "chars": 6139,
    "preview": "#!/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 \""
  },
  {
    "path": "spider_v2.py",
    "chars": 8214,
    "preview": "import asyncio\nimport sys\nimport os\nimport argparse\nimport json\nimport signal\nimport contextlib\nimport re\n\nfrom src.conf"
  },
  {
    "path": "src/__init__.py",
    "chars": 39,
    "preview": "# This file makes src a Python package\n"
  },
  {
    "path": "src/ai_handler.py",
    "chars": 16513,
    "preview": "import asyncio\nimport base64\nimport json\nimport os\nimport re\nimport sys\nimport shutil\nimport traceback\nfrom datetime imp"
  },
  {
    "path": "src/ai_message_builder.py",
    "chars": 1097,
    "preview": "\"\"\"\nAI 请求消息构造辅助函数\n\"\"\"\nfrom typing import Dict, List, Union\n\n\nTEXT_ONLY_ANALYSIS_NOTE = (\n    \"补充说明:本次未提供商品图片,请仅根据商品文字字段和"
  },
  {
    "path": "src/api/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "src/api/dependencies.py",
    "chars": 2310,
    "preview": "\"\"\"\nFastAPI 依赖注入\n提供服务实例的创建和管理\n\"\"\"\nfrom fastapi import Depends\nfrom src.services.task_service import TaskService\nfrom src"
  },
  {
    "path": "src/api/routes/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "src/api/routes/accounts.py",
    "chars": 3640,
    "preview": "\"\"\"\n闲鱼账号管理路由\n\"\"\"\nimport json\nimport os\nimport re\nimport aiofiles\nfrom fastapi import APIRouter, HTTPException\nfrom pydan"
  },
  {
    "path": "src/api/routes/dashboard.py",
    "chars": 656,
    "preview": "\"\"\"\nDashboard 概览路由\n\"\"\"\nfrom fastapi import APIRouter, Depends, HTTPException\n\nfrom src.api.dependencies import get_task_"
  },
  {
    "path": "src/api/routes/login_state.py",
    "chars": 1344,
    "preview": "\"\"\"\n登录状态管理路由\n\"\"\"\nimport os\nimport json\nimport aiofiles\nfrom fastapi import APIRouter, HTTPException\nfrom pydantic import"
  },
  {
    "path": "src/api/routes/logs.py",
    "chars": 5895,
    "preview": "\"\"\"\n日志管理路由\n\"\"\"\nimport os\nfrom typing import Optional, Tuple, List\nimport aiofiles\nfrom fastapi import APIRouter, Depends"
  },
  {
    "path": "src/api/routes/prompts.py",
    "chars": 1669,
    "preview": "\"\"\"\nPrompt 管理路由\n\"\"\"\nimport os\nimport aiofiles\nfrom fastapi import APIRouter, HTTPException\nfrom pydantic import BaseMode"
  },
  {
    "path": "src/api/routes/results.py",
    "chars": 5801,
    "preview": "\"\"\"\n结果文件管理路由\n\"\"\"\nfrom fastapi import APIRouter, HTTPException, Query\nfrom fastapi.responses import Response\nfrom urllib."
  },
  {
    "path": "src/api/routes/settings.py",
    "chars": 11729,
    "preview": "\"\"\"\n设置管理路由\n\"\"\"\nimport os\nfrom typing import Optional\n\nfrom dotenv import load_dotenv\nfrom fastapi import APIRouter, Depe"
  },
  {
    "path": "src/api/routes/tasks.py",
    "chars": 11564,
    "preview": "\"\"\"\n任务管理路由\n\"\"\"\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom fastapi.responses import JSONResponse\nfrom typ"
  },
  {
    "path": "src/api/routes/websocket.py",
    "chars": 1285,
    "preview": "\"\"\"\nWebSocket 路由\n提供实时通信功能\n\"\"\"\nfrom fastapi import APIRouter, WebSocket, WebSocketDisconnect\nfrom typing import Set\n\n\nrou"
  },
  {
    "path": "src/app.py",
    "chars": 5399,
    "preview": "\"\"\"\n新架构的主应用入口\n整合所有路由和服务\n\"\"\"\nfrom contextlib import asynccontextmanager\nfrom fastapi import FastAPI\nfrom fastapi.staticfi"
  },
  {
    "path": "src/config.py",
    "chars": 3885,
    "preview": "import os\nimport sys\n\nfrom dotenv import load_dotenv\nfrom openai import AsyncOpenAI\n\n# --- AI & Notification Configurati"
  },
  {
    "path": "src/core/cron_utils.py",
    "chars": 1880,
    "preview": "\"\"\"\nCron 解析与校验工具。\n\"\"\"\nfrom __future__ import annotations\n\nfrom typing import Optional\n\nfrom apscheduler.triggers.cron im"
  },
  {
    "path": "src/domain/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "src/domain/models/__init__.py",
    "chars": 121,
    "preview": "from .task import Task, TaskCreate, TaskUpdate, TaskStatus\n\n__all__ = [\"Task\", \"TaskCreate\", \"TaskUpdate\", \"TaskStatus\"]"
  },
  {
    "path": "src/domain/models/task.py",
    "chars": 11522,
    "preview": "\"\"\"\n任务领域模型\n定义任务实体及其业务逻辑\n\"\"\"\nimport re\nfrom enum import Enum\nfrom typing import Any, List, Literal, Optional\n\nfrom pydant"
  },
  {
    "path": "src/domain/models/task_generation.py",
    "chars": 792,
    "preview": "\"\"\"\n任务生成作业模型\n\"\"\"\nfrom typing import List, Literal, Optional\n\nfrom pydantic import BaseModel, Field\n\nfrom src.domain.mode"
  },
  {
    "path": "src/domain/repositories/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "src/domain/repositories/task_repository.py",
    "chars": 640,
    "preview": "\"\"\"\n任务仓储层\n负责任务数据的持久化操作\n\"\"\"\nfrom typing import List, Optional\nfrom abc import ABC, abstractmethod\nimport json\nimport aiof"
  },
  {
    "path": "src/failure_guard.py",
    "chars": 10498,
    "preview": "\"\"\"Task-level failure circuit breaker.\n\n目标:\n- 当登录态失效/风控导致任务持续失败时,避免无限重试、避免高频请求。\n- 失败达到阈值后暂停任务一段时间。\n- 暂停期间最多每天通知一次,直到用户更新"
  },
  {
    "path": "src/infrastructure/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "src/infrastructure/config/__init__.py",
    "chars": 155,
    "preview": "from .settings import settings, AppSettings, AISettings, NotificationSettings\n\n__all__ = [\"settings\", \"AppSettings\", \"AI"
  },
  {
    "path": "src/infrastructure/config/env_manager.py",
    "chars": 3028,
    "preview": "\"\"\"\n环境变量管理器\n负责读取和更新 .env 文件,并在读取时回退到运行时环境变量\n\"\"\"\nimport os\nimport re\nfrom typing import Dict, List, Optional\nfrom pathlib"
  },
  {
    "path": "src/infrastructure/config/settings.py",
    "chars": 5003,
    "preview": "\"\"\"\n统一配置管理模块\n使用 Pydantic 进行类型安全的配置管理\n\"\"\"\ntry:\n    from pydantic_settings import BaseSettings, SettingsConfigDict\n    _US"
  },
  {
    "path": "src/infrastructure/external/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "src/infrastructure/external/ai_client.py",
    "chars": 7579,
    "preview": "\"\"\"\nAI 客户端封装\n提供统一的 AI 调用接口\n\"\"\"\nimport os\nimport json\nimport base64\nfrom typing import Dict, List, Optional\nfrom datetime"
  },
  {
    "path": "src/infrastructure/external/notification_clients/__init__.py",
    "chars": 490,
    "preview": "from .base import NotificationClient, NotificationMessage\nfrom .bark_client import BarkClient\nfrom .gotify_client import"
  },
  {
    "path": "src/infrastructure/external/notification_clients/bark_client.py",
    "chars": 1381,
    "preview": "\"\"\"\nBark 通知客户端\n\"\"\"\nimport asyncio\nimport requests\nfrom typing import Dict\nfrom .base import NotificationClient\n\n\nclass B"
  },
  {
    "path": "src/infrastructure/external/notification_clients/base.py",
    "chars": 2413,
    "preview": "\"\"\"\n通知客户端基类\n定义通知客户端的统一接口\n\"\"\"\nfrom abc import ABC, abstractmethod\nfrom dataclasses import dataclass\nfrom typing import Di"
  },
  {
    "path": "src/infrastructure/external/notification_clients/factory.py",
    "chars": 1441,
    "preview": "\"\"\"\n通知客户端工厂\n\"\"\"\nfrom src.infrastructure.config.settings import NotificationSettings\n\nfrom .bark_client import BarkClient"
  },
  {
    "path": "src/infrastructure/external/notification_clients/gotify_client.py",
    "chars": 1309,
    "preview": "\"\"\"\nGotify 通知客户端\n\"\"\"\nimport asyncio\nfrom typing import Dict\n\nimport requests\n\nfrom .base import NotificationClient\n\n\ncla"
  },
  {
    "path": "src/infrastructure/external/notification_clients/ntfy_client.py",
    "chars": 1171,
    "preview": "\"\"\"\nNtfy 通知客户端\n\"\"\"\nimport asyncio\nimport requests\nfrom typing import Dict\nfrom .base import NotificationClient\n\n\nclass N"
  },
  {
    "path": "src/infrastructure/external/notification_clients/telegram_client.py",
    "chars": 2315,
    "preview": "\"\"\"\nTelegram 通知客户端\n\"\"\"\nimport asyncio\nfrom typing import Dict\n\nimport requests\n\nfrom src.infrastructure.config.settings "
  },
  {
    "path": "src/infrastructure/external/notification_clients/webhook_client.py",
    "chars": 5365,
    "preview": "\"\"\"\n通用 Webhook 通知客户端\n\"\"\"\nimport asyncio\nimport json\nfrom typing import Any, Dict\nfrom urllib.parse import parse_qsl, url"
  },
  {
    "path": "src/infrastructure/external/notification_clients/wecom_bot_client.py",
    "chars": 1683,
    "preview": "\"\"\"\n企业微信机器人通知客户端\n\"\"\"\nimport asyncio\nfrom typing import Dict\n\nimport requests\n\nfrom .base import NotificationClient\n\n\ncla"
  },
  {
    "path": "src/infrastructure/persistence/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "src/infrastructure/persistence/json_task_repository.py",
    "chars": 2237,
    "preview": "\"\"\"\n基于JSON文件的任务仓储实现\n\"\"\"\nfrom typing import List, Optional\nimport json\nimport aiofiles\nfrom src.domain.models.task import"
  },
  {
    "path": "src/infrastructure/persistence/sqlite_bootstrap.py",
    "chars": 10400,
    "preview": "\"\"\"\nSQLite 启动初始化与旧文件迁移。\n\"\"\"\nfrom __future__ import annotations\n\nimport hashlib\nimport json\nimport threading\nfrom pathlib"
  },
  {
    "path": "src/infrastructure/persistence/sqlite_connection.py",
    "chars": 4256,
    "preview": "\"\"\"\nSQLite 连接与 schema 初始化。\n\"\"\"\nfrom __future__ import annotations\n\nimport os\nimport sqlite3\nfrom contextlib import conte"
  },
  {
    "path": "src/infrastructure/persistence/sqlite_task_repository.py",
    "chars": 5249,
    "preview": "\"\"\"\n基于 SQLite 的任务仓储实现。\n\"\"\"\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nfrom typing import List, Optio"
  },
  {
    "path": "src/infrastructure/persistence/storage_names.py",
    "chars": 626,
    "preview": "\"\"\"\nSQLite 持久化相关的统一命名规则。\n\"\"\"\nfrom __future__ import annotations\n\n\nDEFAULT_DATABASE_PATH = \"data/app.sqlite3\"\nRESULT_FILE"
  },
  {
    "path": "src/keyword_rule_engine.py",
    "chars": 3248,
    "preview": "\"\"\"\n关键词判断引擎:单组 OR 逻辑,命中任意关键词即推荐。\n纯英数字关键词按完整词匹配,避免 Q1 误命中 Q1R5。\n\"\"\"\nimport re\nfrom typing import Any, Dict, Iterable, Lis"
  },
  {
    "path": "src/parsers.py",
    "chars": 6930,
    "preview": "import json\nfrom datetime import datetime\n\nfrom src.config import AI_DEBUG_MODE\nfrom src.utils import safe_get\n\n\nasync d"
  },
  {
    "path": "src/prompt_utils.py",
    "chars": 5048,
    "preview": "import json\nimport os\nimport sys\nfrom typing import Awaitable, Callable, Optional\n\nimport aiofiles\n\nfrom src.infrastruct"
  },
  {
    "path": "src/rotation.py",
    "chars": 1971,
    "preview": "import os\nimport random\nimport time\nfrom dataclasses import dataclass\nfrom typing import Dict, List, Optional\n\n\n@datacla"
  },
  {
    "path": "src/scraper.py",
    "chars": 53126,
    "preview": "import asyncio\nimport json\nimport os\nimport random\nfrom datetime import datetime\nfrom typing import Optional\nfrom urllib"
  },
  {
    "path": "src/services/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "src/services/account_strategy_service.py",
    "chars": 1732,
    "preview": "\"\"\"\n账号策略辅助函数\n\"\"\"\nfrom typing import Optional\n\n\nACCOUNT_STRATEGIES = {\"auto\", \"fixed\", \"rotate\"}\n\n\ndef clean_account_stat"
  },
  {
    "path": "src/services/ai_request_compat.py",
    "chars": 7128,
    "preview": "\"\"\"AI 请求兼容性辅助逻辑。\"\"\"\n\nimport copy\nfrom typing import Any, Dict, Iterable, List\n\n\nRESPONSES_API_MODE = \"responses\"\nCHAT_CO"
  },
  {
    "path": "src/services/ai_response_parser.py",
    "chars": 3118,
    "preview": "\"\"\"\nAI 响应解析工具\n\"\"\"\nimport json\nfrom typing import Any\n\n\nclass EmptyAIResponseError(ValueError):\n    \"\"\"AI 返回了空内容。\"\"\"\n\n\nde"
  },
  {
    "path": "src/services/ai_service.py",
    "chars": 2005,
    "preview": "\"\"\"\nAI 分析服务\n封装 AI 分析相关的业务逻辑\n\"\"\"\nfrom typing import Dict, List, Optional\nfrom src.infrastructure.external.ai_client impor"
  },
  {
    "path": "src/services/dashboard_payloads.py",
    "chars": 9621,
    "preview": "\"\"\"\nDashboard 数据拼装辅助函数\n\"\"\"\nfrom __future__ import annotations\n\nfrom datetime import datetime\nfrom typing import Any\n\nfro"
  },
  {
    "path": "src/services/dashboard_service.py",
    "chars": 2612,
    "preview": "\"\"\"\nDashboard 聚合服务\n统一汇总任务、结果文件和最近活动,供首页概览使用。\n\"\"\"\nfrom __future__ import annotations\n\nfrom typing import Any\n\nfrom src.do"
  },
  {
    "path": "src/services/item_analysis_dispatcher.py",
    "chars": 6148,
    "preview": "\"\"\"\n商品分析分发器\n将卖家资料采集、图片下载、AI 分析和结果保存移出主抓取链路。\n\"\"\"\nimport asyncio\nimport copy\nimport os\nfrom dataclasses import dataclass\nf"
  },
  {
    "path": "src/services/notification_config_service.py",
    "chars": 12157,
    "preview": "\"\"\"\n通知配置读写与校验服务\n\"\"\"\nimport json\nfrom urllib.parse import urlparse\n\nfrom src.infrastructure.config.env_manager import env"
  },
  {
    "path": "src/services/notification_service.py",
    "chars": 2373,
    "preview": "\"\"\"\n通知服务\n统一管理所有通知渠道\n\"\"\"\nimport asyncio\nfrom typing import Dict, List\n\nfrom src.infrastructure.external.notification_clie"
  },
  {
    "path": "src/services/price_history_service.py",
    "chars": 13229,
    "preview": "\"\"\"\n价格历史记录与聚合服务\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport math\nimport os\nfrom collections import defaul"
  },
  {
    "path": "src/services/process_service.py",
    "chars": 11177,
    "preview": "\"\"\"\n进程管理服务\n负责管理爬虫进程的启动和停止\n\"\"\"\n\nimport asyncio\nimport contextlib\nimport os\nimport signal\nimport sys\nfrom datetime import "
  },
  {
    "path": "src/services/result_export_service.py",
    "chars": 1819,
    "preview": "\"\"\"\n结果导出服务\n\"\"\"\nimport csv\nfrom io import StringIO\n\n\nEXPORT_HEADERS = [\n    \"任务名称\",\n    \"搜索关键字\",\n    \"商品ID\",\n    \"商品标题\",\n"
  },
  {
    "path": "src/services/result_file_service.py",
    "chars": 1002,
    "preview": "\"\"\"\n结果记录富化与文件名校验服务\n\"\"\"\n\nfrom src.infrastructure.persistence.storage_names import normalize_keyword_from_filename\nfrom sr"
  },
  {
    "path": "src/services/result_storage_service.py",
    "chars": 11077,
    "preview": "\"\"\"\n结果数据的 SQLite 读写服务。\n\"\"\"\nfrom __future__ import annotations\n\nimport asyncio\nimport hashlib\nimport json\n\nfrom src.infra"
  },
  {
    "path": "src/services/scheduler_service.py",
    "chars": 2589,
    "preview": "\"\"\"\n调度服务\n负责管理定时任务的调度\n\"\"\"\nfrom datetime import datetime\nfrom apscheduler.schedulers.asyncio import AsyncIOScheduler\nfrom "
  },
  {
    "path": "src/services/search_pagination.py",
    "chars": 3148,
    "preview": "import asyncio\nfrom dataclasses import dataclass\nfrom typing import Any, Awaitable, Callable, Optional\n\nfrom playwright."
  },
  {
    "path": "src/services/seller_profile_cache.py",
    "chars": 2145,
    "preview": "\"\"\"\n卖家资料缓存服务\n\"\"\"\nimport asyncio\nimport copy\nimport time\nfrom dataclasses import dataclass\nfrom typing import Awaitable, "
  },
  {
    "path": "src/services/task_generation_runner.py",
    "chars": 3822,
    "preview": "\"\"\"\n任务生成作业执行器\n\"\"\"\nimport os\n\nimport aiofiles\n\nfrom src.domain.models.task import TaskCreate, TaskGenerateRequest\nfrom sr"
  },
  {
    "path": "src/services/task_generation_service.py",
    "chars": 4597,
    "preview": "\"\"\"\n任务生成作业服务\n\"\"\"\nimport asyncio\nfrom copy import deepcopy\nimport threading\nfrom typing import Awaitable, Dict, Iterable,"
  },
  {
    "path": "src/services/task_log_cleanup_service.py",
    "chars": 1198,
    "preview": "\"\"\"\n任务运行日志清理服务。\n\"\"\"\nfrom __future__ import annotations\n\nfrom datetime import datetime, timedelta\nfrom pathlib import Pat"
  },
  {
    "path": "src/services/task_payloads.py",
    "chars": 762,
    "preview": "\"\"\"\n任务接口响应序列化辅助。\n\"\"\"\nfrom __future__ import annotations\n\nfrom datetime import datetime\nfrom typing import Any\n\nfrom src."
  },
  {
    "path": "src/services/task_service.py",
    "chars": 1468,
    "preview": "\"\"\"\n任务管理服务\n封装任务相关的业务逻辑\n\"\"\"\nfrom typing import List, Optional\nfrom src.domain.models.task import Task, TaskCreate, TaskUp"
  },
  {
    "path": "src/utils.py",
    "chars": 4790,
    "preview": "import asyncio\nimport json\nimport math\nimport os\nimport random\nimport re\nimport glob\nfrom datetime import datetime\nfrom "
  },
  {
    "path": "start.sh",
    "chars": 8638,
    "preview": "#!/bin/bash\n\n# 闲鱼监控系统本地启动脚本\n# 功能:清理旧构建、安装依赖、构建前端、启动服务\n\nset -e  # 遇到错误立即退出\n\n# 颜色输出\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYE"
  },
  {
    "path": "tests/README.md",
    "chars": 1899,
    "preview": "# 测试指南\n\n本项目使用 pytest 作为测试框架。以下是运行测试的指南。\n\n## 安装依赖\n\n在运行测试之前,请确保已安装所有开发依赖项:\n\n```bash\npip install -r requirements.txt\n```\n\n#"
  },
  {
    "path": "tests/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "tests/conftest.py",
    "chars": 4828,
    "preview": "import json\nimport os\nimport sys\nfrom datetime import datetime, timedelta\nfrom pathlib import Path\nfrom zoneinfo import "
  },
  {
    "path": "tests/fixtures/config.sample.json",
    "chars": 833,
    "preview": "[\n  {\n    \"task_name\": \"Sony A7M4\",\n    \"enabled\": true,\n    \"keyword\": \"sony a7m4\",\n    \"description\": \"body only\",\n   "
  },
  {
    "path": "tests/fixtures/ratings.json",
    "chars": 780,
    "preview": "[\n  {\n    \"cardData\": {\n      \"rateId\": \"r1\",\n      \"feedback\": \"Great seller\",\n      \"rate\": 1,\n      \"rateTagList\": [{"
  },
  {
    "path": "tests/fixtures/search_results.json",
    "chars": 1071,
    "preview": "{\n  \"data\": {\n    \"resultList\": [\n      {\n        \"data\": {\n          \"item\": {\n            \"main\": {\n              \"exC"
  },
  {
    "path": "tests/fixtures/state.sample.json",
    "chars": 25,
    "preview": "{\n  \"session\": \"dummy\"\n}\n"
  },
  {
    "path": "tests/fixtures/user_head.json",
    "chars": 477,
    "preview": "{\n  \"data\": {\n    \"module\": {\n      \"base\": {\n        \"displayName\": \"seller_01\",\n        \"avatar\": {\"avatar\": \"https://"
  },
  {
    "path": "tests/fixtures/user_items.json",
    "chars": 420,
    "preview": "[\n  {\n    \"cardData\": {\n      \"itemStatus\": 0,\n      \"id\": \"10001\",\n      \"title\": \"Lens 24-70\",\n      \"priceInfo\": {\"pr"
  },
  {
    "path": "tests/integration/test_api_dashboard.py",
    "chars": 4109,
    "preview": "import json\n\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\nfrom src.api import dependencies as "
  },
  {
    "path": "tests/integration/test_api_results.py",
    "chars": 7540,
    "preview": "import json\n\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\nfrom src.api.routes import results\nf"
  },
  {
    "path": "tests/integration/test_api_settings.py",
    "chars": 13695,
    "preview": "from fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\nfrom src.api import dependencies as deps\nfrom src"
  },
  {
    "path": "tests/integration/test_api_tasks.py",
    "chars": 6858,
    "preview": "import asyncio\nimport time\n\n\ndef test_create_list_update_delete_task(api_client, api_context, sample_task_payload):\n    "
  },
  {
    "path": "tests/integration/test_cli_spider.py",
    "chars": 3539,
    "preview": "import asyncio\nimport importlib\nimport json\nimport sys\nimport types\n\n\ndef test_cli_runs_single_task_with_prompt(tmp_path"
  },
  {
    "path": "tests/integration/test_pipeline_parse.py",
    "chars": 1448,
    "preview": "import asyncio\n\nfrom src.parsers import (\n    _parse_search_results_json,\n    _parse_user_items_data,\n    calculate_repu"
  },
  {
    "path": "tests/live/_support.py",
    "chars": 6920,
    "preview": "from __future__ import annotations\n\nimport os\nimport shutil\nimport socket\nimport subprocess\nimport time\nfrom dataclasses"
  },
  {
    "path": "tests/live/conftest.py",
    "chars": 2764,
    "preview": "from __future__ import annotations\n\nimport os\nimport shutil\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nimpor"
  },
  {
    "path": "tests/live/test_live_smoke.py",
    "chars": 10834,
    "preview": "from __future__ import annotations\n\nimport time\nfrom pathlib import Path\n\nimport pytest\nimport requests\n\nfrom src.infras"
  },
  {
    "path": "tests/test_failure_guard.py",
    "chars": 2545,
    "preview": "from __future__ import annotations\n\nfrom datetime import datetime, timedelta\n\nfrom src.failure_guard import FailureGuard"
  },
  {
    "path": "tests/test_frontend_build_paths.py",
    "chars": 1227,
    "preview": "from __future__ import annotations\n\nfrom pathlib import Path\n\n\nREPO_ROOT = Path(__file__).resolve().parents[1]\nROOT_DIST"
  },
  {
    "path": "tests/unit/test_ai_client.py",
    "chars": 7945,
    "preview": "import asyncio\nfrom types import SimpleNamespace\n\nimport pytest\n\nfrom src.infrastructure.external.ai_client import AICli"
  },
  {
    "path": "tests/unit/test_ai_handler_analysis.py",
    "chars": 8603,
    "preview": "import asyncio\nfrom types import SimpleNamespace\n\nimport pytest\n\nimport src.ai_handler as ai_handler\nimport src.config a"
  },
  {
    "path": "tests/unit/test_ai_handler_downloads.py",
    "chars": 1215,
    "preview": "import asyncio\nfrom pathlib import Path\n\nimport src.ai_handler as ai_handler\n\n\ndef test_download_all_images_runs_with_co"
  },
  {
    "path": "tests/unit/test_ai_request_compat.py",
    "chars": 995,
    "preview": "from src.services.ai_request_compat import (\n    is_responses_api_unsupported_error,\n    is_temperature_unsupported_erro"
  },
  {
    "path": "tests/unit/test_ai_response_parser.py",
    "chars": 844,
    "preview": "import pytest\n\nfrom src.services.ai_response_parser import parse_ai_response_json\n\n\ndef test_parse_ai_response_json_uses"
  },
  {
    "path": "tests/unit/test_app_lifespan.py",
    "chars": 1965,
    "preview": "import asyncio\n\nimport src.app as app_module\n\n\nclass _FakeTaskService:\n    def __init__(self, _repo):\n        self.updat"
  },
  {
    "path": "tests/unit/test_cron_utils.py",
    "chars": 785,
    "preview": "from src.core.cron_utils import build_cron_trigger, validate_cron_expression\n\n\ndef test_validate_cron_expression_normali"
  },
  {
    "path": "tests/unit/test_domain_task.py",
    "chars": 3578,
    "preview": "from src.domain.models.task import Task, TaskGenerateRequest, TaskUpdate\n\n\ndef test_task_can_start_and_stop():\n    task "
  },
  {
    "path": "tests/unit/test_item_analysis_dispatcher.py",
    "chars": 4236,
    "preview": "import asyncio\n\nfrom src.services.item_analysis_dispatcher import (\n    ItemAnalysisDispatcher,\n    ItemAnalysisJob,\n)\n\n"
  },
  {
    "path": "tests/unit/test_keyword_rule_engine.py",
    "chars": 2054,
    "preview": "from src.keyword_rule_engine import build_search_text, evaluate_keyword_rules\n\n\ndef _sample_record():\n    return {\n     "
  },
  {
    "path": "tests/unit/test_notification_service.py",
    "chars": 2439,
    "preview": "import asyncio\n\nfrom src.infrastructure.external.notification_clients.base import NotificationClient\nfrom src.infrastruc"
  },
  {
    "path": "tests/unit/test_price_history_service.py",
    "chars": 3036,
    "preview": "from src.services.price_history_service import (\n    build_item_price_context,\n    build_price_history_insights,\n    loa"
  },
  {
    "path": "tests/unit/test_process_service.py",
    "chars": 3142,
    "preview": "import asyncio\nimport sys\nfrom types import SimpleNamespace\n\nfrom src.services.process_service import ProcessService\n\n\nc"
  },
  {
    "path": "tests/unit/test_prompt_utils.py",
    "chars": 1806,
    "preview": "import asyncio\n\nimport pytest\n\nimport src.prompt_utils as prompt_utils\nfrom src.services.ai_response_parser import Empty"
  },
  {
    "path": "tests/unit/test_scraper_browser_channel.py",
    "chars": 1062,
    "preview": "import importlib\n\n\ndef _load_scraper(monkeypatch, *, login_is_edge: bool, running_in_docker: bool):\n    monkeypatch.sete"
  },
  {
    "path": "tests/unit/test_search_pagination.py",
    "chars": 6128,
    "preview": "import asyncio\n\nfrom playwright.async_api import TimeoutError as PlaywrightTimeoutError\n\nfrom src.services.search_pagina"
  },
  {
    "path": "tests/unit/test_seller_profile_cache.py",
    "chars": 1348,
    "preview": "import asyncio\n\nfrom src.services.seller_profile_cache import SellerProfileCache\n\n\ndef test_seller_profile_cache_reuses_"
  },
  {
    "path": "tests/unit/test_task_log_cleanup_service.py",
    "chars": 1756,
    "preview": "from __future__ import annotations\n\nfrom datetime import datetime\nfrom pathlib import Path\n\nfrom src.services.task_log_c"
  },
  {
    "path": "tests/unit/test_utils.py",
    "chars": 1541,
    "preview": "import asyncio\n\nfrom src.services.result_storage_service import load_all_result_records\nfrom src.utils import (\n    form"
  },
  {
    "path": "web-ui/.gitignore",
    "chars": 253,
    "preview": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndis"
  },
  {
    "path": "web-ui/.vscode/extensions.json",
    "chars": 39,
    "preview": "{\n  \"recommendations\": [\"Vue.volar\"]\n}\n"
  },
  {
    "path": "web-ui/Dockerfile",
    "chars": 558,
    "preview": "# Stage 1: Build the Vue application\nFROM node:22-alpine AS builder\n\nWORKDIR /app\n\n# Copy package files and install depe"
  },
  {
    "path": "web-ui/README.md",
    "chars": 442,
    "preview": "# Vue 3 + TypeScript + Vite\n\nThis template should help get you started developing with Vue 3 and TypeScript in Vite. The"
  },
  {
    "path": "web-ui/components.json",
    "chars": 299,
    "preview": "{\n  \"$schema\": \"https://shadcn-vue.com/schema.json\",\n  \"style\": \"default\",\n  \"tailwind\": {\n    \"config\": \"tailwind.confi"
  },
  {
    "path": "web-ui/index.html",
    "chars": 291,
    "preview": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-w"
  },
  {
    "path": "web-ui/nginx.conf",
    "chars": 1155,
    "preview": "server {\n    listen 80;\n    server_name localhost;\n\n    root /usr/share/nginx/html;\n    index index.html;\n\n    # Serve s"
  },
  {
    "path": "web-ui/package.json",
    "chars": 806,
    "preview": "{\n  \"name\": \"web-ui\",\n  \"private\": true,\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n   "
  },
  {
    "path": "web-ui/postcss.config.cjs",
    "chars": 82,
    "preview": "module.exports = {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n}\n"
  },
  {
    "path": "web-ui/src/App.vue",
    "chars": 185,
    "preview": "<script setup lang=\"ts\">\nimport { RouterView } from 'vue-router'\nimport Toaster from '@/components/ui/toast/Toaster.vue'"
  },
  {
    "path": "web-ui/src/api/accounts.ts",
    "chars": 1128,
    "preview": "import { http } from '@/lib/http'\n\nexport interface AccountItem {\n  name: string\n  path: string\n}\n\nexport interface Acco"
  },
  {
    "path": "web-ui/src/api/dashboard.ts",
    "chars": 221,
    "preview": "import { http } from '@/lib/http'\nimport type { DashboardSnapshot } from '@/types/dashboard.d.ts'\n\nexport async function"
  },
  {
    "path": "web-ui/src/api/logs.ts",
    "chars": 963,
    "preview": "import { http } from '@/lib/http'\n\nexport async function getLogs(fromPos: number = 0, taskId?: number | null): Promise<{"
  },
  {
    "path": "web-ui/src/api/prompts.ts",
    "chars": 608,
    "preview": "import { http } from '@/lib/http'\n\nexport interface PromptContent {\n  filename: string\n  content: string\n}\n\nexport async"
  },
  {
    "path": "web-ui/src/api/results.ts",
    "chars": 1861,
    "preview": "import type { ResultInsights, ResultItem } from '@/types/result.d.ts'\nimport { http } from '@/lib/http'\n\nexport interfac"
  },
  {
    "path": "web-ui/src/api/settings.ts",
    "chars": 4902,
    "preview": "import { http } from '@/lib/http'\n\nexport interface NotificationSettings {\n  NTFY_TOPIC_URL?: string\n  GOTIFY_URL?: stri"
  },
  {
    "path": "web-ui/src/api/tasks.ts",
    "chars": 1369,
    "preview": "import type {\n  Task,\n  TaskCreateResponse,\n  TaskGenerateRequest,\n  TaskGenerationJob,\n  TaskUpdate,\n} from '@/types/ta"
  },
  {
    "path": "web-ui/src/assets/main.css",
    "chars": 1576,
    "preview": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@layer base {\n  :root {\n    --background: 0 0% 100%;\n    --f"
  },
  {
    "path": "web-ui/src/components/HelloWorld.vue",
    "chars": 856,
    "preview": "<script setup lang=\"ts\">\nimport { ref } from 'vue'\n\ndefineProps<{ msg: string }>()\n\nconst count = ref(0)\n</script>\n\n<tem"
  },
  {
    "path": "web-ui/src/components/layout/DashboardTaskSearch.vue",
    "chars": 8863,
    "preview": "<script setup lang=\"ts\">\nimport { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'\nimport { useRouter } fro"
  },
  {
    "path": "web-ui/src/components/layout/LocaleToggle.vue",
    "chars": 1174,
    "preview": "<script setup lang=\"ts\">\nimport { Globe } from 'lucide-vue-next'\nimport { useLocale } from '@/i18n'\nimport { useI18n } f"
  },
  {
    "path": "web-ui/src/components/layout/TheHeader.vue",
    "chars": 4291,
    "preview": "<script setup lang=\"ts\">\nimport { computed, ref } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport { B"
  },
  {
    "path": "web-ui/src/components/layout/TheSidebar.vue",
    "chars": 3119,
    "preview": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { RouterLink } from 'vue-router'\nimport { \n  LayoutDashbo"
  },
  {
    "path": "web-ui/src/components/results/PriceTrendChart.vue",
    "chars": 4734,
    "preview": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { useI18n } from 'vue-i18n'\n\ninterface TrendPoint {\n  day"
  },
  {
    "path": "web-ui/src/components/results/ResultCard.vue",
    "chars": 6959,
    "preview": "<script setup lang=\"ts\">\nimport { ref, computed } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport type { ResultItem"
  },
  {
    "path": "web-ui/src/components/results/ResultsFilterBar.vue",
    "chars": 5579,
    "preview": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport {\n  Select,\n  SelectCo"
  },
  {
    "path": "web-ui/src/components/results/ResultsGrid.vue",
    "chars": 749,
    "preview": "<script setup lang=\"ts\">\nimport type { ResultItem } from '@/types/result.d.ts'\nimport { useI18n } from 'vue-i18n'\nimport"
  },
  {
    "path": "web-ui/src/components/results/ResultsInsightsPanel.vue",
    "chars": 4929,
    "preview": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport type { ResultInsights "
  },
  {
    "path": "web-ui/src/components/settings/NotificationSettingsPanel.vue",
    "chars": 19945,
    "preview": "<script setup lang=\"ts\">\nimport { computed, reactive, ref, watch } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport "
  },
  {
    "path": "web-ui/src/components/settings/RotationSettingsPanel.vue",
    "chars": 5052,
    "preview": "<script setup lang=\"ts\">\nimport { useI18n } from 'vue-i18n'\nimport { Button } from '@/components/ui/button'\nimport { Car"
  },
  {
    "path": "web-ui/src/components/tasks/TaskCreateDialog.vue",
    "chars": 4202,
    "preview": "<script setup lang=\"ts\">\nimport { ref, watch } from 'vue'\nimport { useRoute } from 'vue-router'\nimport { useI18n } from "
  },
  {
    "path": "web-ui/src/components/tasks/TaskForm.vue",
    "chars": 18378,
    "preview": "<script setup lang=\"ts\">\nimport { ref, watch, computed } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport type { Tas"
  },
  {
    "path": "web-ui/src/components/tasks/TaskGenerationDialog.vue",
    "chars": 1715,
    "preview": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport type { TaskGenerationJ"
  },
  {
    "path": "web-ui/src/components/tasks/TaskGenerationProgress.vue",
    "chars": 2886,
    "preview": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport { Badge } from '@/comp"
  },
  {
    "path": "web-ui/src/components/tasks/TaskRegionSelector.vue",
    "chars": 5053,
    "preview": "<script setup lang=\"ts\">\nimport { computed, ref, watch } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport { Button }"
  },
  {
    "path": "web-ui/src/components/tasks/TasksTable.vue",
    "chars": 15154,
    "preview": "<script setup lang=\"ts\">\nimport { onBeforeUnmount, onMounted, ref } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport"
  },
  {
    "path": "web-ui/src/components/ui/badge/Badge.vue",
    "chars": 395,
    "preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport type { BadgeVariants } from \".\"\nimport { cn } "
  },
  {
    "path": "web-ui/src/components/ui/badge/index.ts",
    "chars": 921,
    "preview": "import type { VariantProps } from \"class-variance-authority\"\nimport { cva } from \"class-variance-authority\"\n\nexport { de"
  },
  {
    "path": "web-ui/src/components/ui/button/Button.vue",
    "chars": 656,
    "preview": "<script setup lang=\"ts\">\nimport type { PrimitiveProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimport "
  },
  {
    "path": "web-ui/src/components/ui/button/index.ts",
    "chars": 1478,
    "preview": "import type { VariantProps } from \"class-variance-authority\"\nimport { cva } from \"class-variance-authority\"\n\nexport { de"
  },
  {
    "path": "web-ui/src/components/ui/card/Card.vue",
    "chars": 361,
    "preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defin"
  },
  {
    "path": "web-ui/src/components/ui/card/CardContent.vue",
    "chars": 269,
    "preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defin"
  },
  {
    "path": "web-ui/src/components/ui/card/CardDescription.vue",
    "chars": 286,
    "preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defin"
  },
  {
    "path": "web-ui/src/components/ui/card/CardFooter.vue",
    "chars": 287,
    "preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defin"
  },
  {
    "path": "web-ui/src/components/ui/card/CardHeader.vue",
    "chars": 288,
    "preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defin"
  },
  {
    "path": "web-ui/src/components/ui/card/CardTitle.vue",
    "chars": 328,
    "preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defin"
  },
  {
    "path": "web-ui/src/components/ui/card/index.ts",
    "chars": 340,
    "preview": "export { default as Card } from \"./Card.vue\"\nexport { default as CardContent } from \"./CardContent.vue\"\nexport { default"
  },
  {
    "path": "web-ui/src/components/ui/checkbox/Checkbox.vue",
    "chars": 1210,
    "preview": "<script setup lang=\"ts\">\nimport type { CheckboxRootEmits, CheckboxRootProps } from \"reka-ui\"\nimport type { HTMLAttribute"
  },
  {
    "path": "web-ui/src/components/ui/checkbox/index.ts",
    "chars": 53,
    "preview": "export { default as Checkbox } from \"./Checkbox.vue\"\n"
  },
  {
    "path": "web-ui/src/components/ui/dialog/Dialog.vue",
    "chars": 390,
    "preview": "<script setup lang=\"ts\">\nimport type { DialogRootEmits, DialogRootProps } from \"reka-ui\"\nimport { DialogRoot, useForward"
  },
  {
    "path": "web-ui/src/components/ui/dialog/DialogClose.vue",
    "chars": 253,
    "preview": "<script setup lang=\"ts\">\nimport type { DialogCloseProps } from \"reka-ui\"\nimport { DialogClose } from \"reka-ui\"\n\nconst pr"
  },
  {
    "path": "web-ui/src/components/ui/dialog/DialogContent.vue",
    "chars": 1944,
    "preview": "<script setup lang=\"ts\">\nimport type { DialogContentEmits, DialogContentProps } from \"reka-ui\"\nimport type { HTMLAttribu"
  },
  {
    "path": "web-ui/src/components/ui/dialog/DialogDescription.vue",
    "chars": 644,
    "preview": "<script setup lang=\"ts\">\nimport type { DialogDescriptionProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\""
  },
  {
    "path": "web-ui/src/components/ui/dialog/DialogFooter.vue",
    "chars": 362,
    "preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defin"
  },
  {
    "path": "web-ui/src/components/ui/dialog/DialogHeader.vue",
    "chars": 316,
    "preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\n\nconst props = defin"
  },
  {
    "path": "web-ui/src/components/ui/dialog/DialogScrollContent.vue",
    "chars": 1816,
    "preview": "<script setup lang=\"ts\">\nimport type { DialogContentEmits, DialogContentProps } from \"reka-ui\"\nimport type { HTMLAttribu"
  },
  {
    "path": "web-ui/src/components/ui/dialog/DialogTitle.vue",
    "chars": 671,
    "preview": "<script setup lang=\"ts\">\nimport type { DialogTitleProps } from \"reka-ui\"\nimport type { HTMLAttributes } from \"vue\"\nimpor"
  },
  {
    "path": "web-ui/src/components/ui/dialog/DialogTrigger.vue",
    "chars": 263,
    "preview": "<script setup lang=\"ts\">\nimport type { DialogTriggerProps } from \"reka-ui\"\nimport { DialogTrigger } from \"reka-ui\"\n\ncons"
  }
]

// ... and 83 more files (download for full content)

About this extraction

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

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

Copied to clipboard!