host1syan commited on
Commit
5378afe
·
verified ·
1 Parent(s): 16fca23

Upload 212 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.dockerignore ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__
2
+ *.pyc
3
+ .env
4
+ .venv
5
+ logs/
6
+ jsonl/
7
+ web-ui/node_modules
8
+ web-ui/dist
9
+ .git
10
+ .DS_Store
11
+ task_images/
12
+ images/
13
+ archive/
14
+ tests/
15
+ *.md
16
+ !README.md
.env.example ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # --- AI 模型相关配置 ---
2
+ # 模型的API Key。
3
+ OPENAI_API_KEY="sk-..."
4
+
5
+ # 模型的API接口地址。这里需要填写服务商提供的、兼容OpenAI格式的API地址,基本所有模型都有提供OpenAI格式兼容的接口
6
+ # 可查阅你使用的大模型API文档,如格式为 https://xx.xx.com/v1/chat/completions 则OPENAI_BASE_URL只需要填入前半段 https://xx.xx.com/v1/
7
+ OPENAI_BASE_URL="https://generativelanguage.googleapis.com/v1beta/openai/"
8
+
9
+ # 使用的模型名称,模型需要支持图片上传。
10
+ OPENAI_MODEL_NAME="gemini-2.5-pro"
11
+
12
+ # (可选) 为AI请求配置HTTP/S代理。支持 http 和 socks5。例如: http://127.0.0.1:7890 或 socks5://127.0.0.1:1080
13
+ PROXY_URL=""
14
+
15
+
16
+ # 代理轮换配置
17
+ PROXY_ROTATION_ENABLED=false
18
+ PROXY_ROTATION_MODE="per_task" # per_task 或 on_failure
19
+ PROXY_POOL=""
20
+ PROXY_ROTATION_RETRY_LIMIT=2
21
+ PROXY_BLACKLIST_TTL=300
22
+
23
+ # ntfy 通知服务配置
24
+ NTFY_TOPIC_URL="https://ntfy.sh/your-topic-name" # 替换为你的 ntfy 主题 URL
25
+
26
+ # (可选) Gotify 通知服务配置
27
+ GOTIFY_URL="" # 你的 Gotify 服务地址, 例如: https://push.example.de
28
+ GOTIFY_TOKEN="" # 你的 Gotify 应用的 Token
29
+
30
+ # (可选) Bark 通知服务配置
31
+ BARK_URL="" # 你的 Bark 推送地址, 例如: https://api.day.app/your_key
32
+
33
+ # 企业微信机器人通知配置 如果无则不用配置
34
+ WX_BOT_URL="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxx"
35
+
36
+ # Telegram 机器人通知配置 如果无则不用配置
37
+ # 获取 Bot Token: 与 @BotFather 对话创建新的机器人
38
+ # 获取 Chat ID: 与 @userinfobot 对话获取你的用户ID,或者将机器人添加到群组并获取群组ID
39
+ TELEGRAM_BOT_TOKEN="your_telegram_bot_token"
40
+ TELEGRAM_CHAT_ID="your_telegram_chat_id"
41
+
42
+ # (可选) 通用 Webhook 通知配置
43
+ WEBHOOK_URL="" # 你的 Webhook URL, 例如: https://foo.bar.com/quz?a=b
44
+ WEBHOOK_METHOD="POST" # 请求方法: "GET" 或 "POST"
45
+ WEBHOOK_HEADERS='{"X-API-TOKEN":"your-secret-token"}' # 自定义请求头 (JSON格式)
46
+ WEBHOOK_CONTENT_TYPE="JSON" # POST请求内容类型: "JSON" 或 "FORM"
47
+ WEBHOOK_QUERY_PARAMETERS='{"title":"{{title}}","content":"{{content}}"}' # GET请求的查询参数 (JSON格式, 支持 {{title}}, {{content}} 占位符)
48
+ WEBHOOK_BODY='{"title":"{{title}}","content":"{{content}}"}' # POST请求的请求体 (JSON格式, 支持 {{title}}, {{content}} 占位符)
49
+
50
+ # 是否使用edge浏览器 默认使用chrome浏览器
51
+ LOGIN_IS_EDGE=false
52
+
53
+ # 是否开启电脑链接转换为手机链接
54
+ PCURL_TO_MOBILE=true
55
+
56
+ # 爬虫是否以无头模式运行 (true/false)。
57
+ # 本地运行时遇到滑动验证码时,可设为 false 手动进行滑动验证,如果出现风控建议停止运行。
58
+ # 使用docker部署不支持GUI,设置 RUN_HEADLESS=true 否则无法运行。
59
+ RUN_HEADLESS=true
60
+
61
+ # (可选) AI调试模式 (true/false)。开启后会在控制台打印更多用于排查AI分析问题的日志。
62
+ AI_DEBUG_MODE=false
63
+
64
+ # 是否启用enable_thinking参数 (true/false)。某些AI模型需要此参数,而有些则不支持。
65
+ ENABLE_THINKING=false
66
+
67
+ # 是否启用response_format参数 (true/false)。豆包模型不支持json_object响应格式,需要设为false。其他模型如Gemini支持可设为true。
68
+ ENABLE_RESPONSE_FORMAT=true
69
+
70
+ # 服务端口自定义 不配置默认8000
71
+ SERVER_PORT=8000
72
+
73
+ # Web服务认证配置
74
+ WEB_USERNAME=admin
75
+ WEB_PASSWORD=admin123
.gitattributes CHANGED
@@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ static/img_1.png filter=lfs diff=lfs merge=lfs -text
37
+ static/img_2.png filter=lfs diff=lfs merge=lfs -text
38
+ static/img.png filter=lfs diff=lfs merge=lfs -text
39
+ static/wx_support.png filter=lfs diff=lfs merge=lfs -text
40
+ static/zfb_support.jpg filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .idea
2
+ *.iml
3
+ xianyu_state.json
4
+ .env
5
+ .aider*
6
+ images/
7
+ logs/
8
+ jsonl/
9
+ __pycache__/
10
+ src/__pycache__/
11
+ dist/
12
+ state/
13
+ config.json
14
+ prompts/
AGENTS.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Repository Guidelines
2
+
3
+ ## Project Structure & Module Organization
4
+ - Backend lives in `src/` (FastAPI app entry at `src/app.py`, routes in `src/api/routes/`, services in `src/services/`, domain models in `src/domain/`, infrastructure in `src/infrastructure/`).
5
+ - Frontend lives in `web-ui/` (Vue 3 + Vite; views in `web-ui/src/views/`, components in `web-ui/src/components/`).
6
+ - Tests are in `tests/` and follow `test_*.py` naming.
7
+ - Runtime data and assets: `prompts/` (AI templates), `jsonl/` (results), `logs/`, `images/`, `dist/` (built frontend), plus `config.json` and `.env` at repo root.
8
+
9
+ ## Build, Test, and Development Commands
10
+ - Backend dev: `python -m src.app` or `uvicorn src.app:app --host 0.0.0.0 --port 8000 --reload`.
11
+ - Run spider tasks: `python spider_v2.py` (examples: `--task-name "MacBook Air M1"`, `--debug-limit 3`, `--config custom_config.json`).
12
+ - Frontend dev: `cd web-ui && npm install && npm run dev`.
13
+ - Frontend build: `cd web-ui && npm run build` (copies `dist/` to root in `start.sh`).
14
+ - Docker: `docker compose up --build -d` (see `docker-compose*.yaml`).
15
+ - One-shot local start: `bash start.sh` (builds frontend, installs deps, starts backend).
16
+
17
+ ## Coding Style & Naming Conventions
18
+ - Python tests: files `tests/test_*.py` or `tests/*/test_*.py`, functions `test_*`, tests are sync-only and do not require pytest-asyncio.
19
+ - Keep modules small and layered (API → services → domain → infrastructure). Avoid cross-layer shortcuts.
20
+ - Use descriptive, task-focused names for spider jobs and config keys in `config.json`.
21
+
22
+ ## Testing Guidelines
23
+ - Framework: `pytest` with `pytest-asyncio`.
24
+ - Run all tests: `pytest`.
25
+ - Coverage: `pytest --cov=src` or `coverage run -m pytest`.
26
+ - Target specific tests: `pytest tests/test_utils.py::test_safe_get`.
27
+
28
+ ## Commit & Pull Request Guidelines
29
+ - Commit style follows a Conventional Commits-like pattern (examples in history: `feat(...)`, `fix(...)`, `refactor(...)`, `chore(...)`, `docs(...)`).
30
+ - PRs should describe scope, list affected modules, and include screenshots for UI changes (see `web-ui/`).
31
+ - Link related issues when applicable and note any config or migration steps.
32
+
33
+ ## Security & Configuration Tips
34
+ - Copy `.env` from `.env.example` and set required keys (e.g., `OPENAI_API_KEY`).
35
+ - Do not commit real credentials or cookies (`state.json`).
36
+ - Playwright requires a local browser; Docker installs Chromium automatically.
CLAUDE.md ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## 项目概述
6
+
7
+ 这是一个基于 Playwright 和 AI 的闲鱼智能监控机器人,提供完整的 Web 管理界面。系统采用 FastAPI 后端 + Vue 3 前端架构,支持多任务并发监控、AI 驱动的商品分析和多渠道通知推送。
8
+
9
+ ## 核心架构
10
+
11
+ ### 后端架构(已完成重构)
12
+
13
+ 项目采用清晰的分层架构:
14
+
15
+ ```
16
+ API层 (src/api/routes)
17
+
18
+ 服务层 (src/services)
19
+
20
+ 领域层 (src/domain)
21
+
22
+ 基础设施层 (src/infrastructure)
23
+ ```
24
+
25
+ **关键组件:**
26
+
27
+ - **主应用入口**: `src/app.py` - FastAPI 应用,整合所有路由和服务
28
+ - **爬虫核心**: `src/scraper.py` - Playwright 驱动的闲鱼爬虫逻辑
29
+ - **任务执行器**: `spider_v2.py` - 命令行入口,支持单任务和多任务执行
30
+ - **领域模型**: `src/domain/models/task.py` - Task 实体和 DTOs
31
+ - **服务层**:
32
+ - `TaskService` - 任务管理
33
+ - `ProcessService` - 进程管理(启动/停止爬虫)
34
+ - `SchedulerService` - 定时调度(基于 APScheduler)
35
+ - `AIAnalysisService` - AI 分析服务
36
+ - `NotificationService` - 通知服务(支持 ntfy、Bark、企业微信、Telegram、Webhook)
37
+ - **仓储层**: `JsonTaskRepository` - 基于 JSON 文件的任务持久化
38
+
39
+ ### 前端架构(Vue 3 重构中)
40
+
41
+ 位于 `web-ui/` 目录,采用 Vue 3 + TypeScript + Vite + shadcn-vue + Tailwind CSS:
42
+
43
+ ```
44
+ web-ui/
45
+ ├── src/
46
+ │ ├── api/ # API 请求层
47
+ │ ├── components/ # 全局组件和 shadcn-vue UI 组件
48
+ │ ├── composables/ # 状态与业务逻辑
49
+ │ ├── layouts/ # 页面主布局
50
+ │ ├── router/ # 路由配置
51
+ │ ├── services/ # 核心服务(如 WebSocket)
52
+ │ ├── types/ # TypeScript 类型定义
53
+ │ └── views/ # 页面级视图组件
54
+ ```
55
+
56
+ **设计原则**:
57
+ - 渲染层与业务层解耦
58
+ - 容器组件(智能)vs 展示组件(哑)
59
+ - Composables 管理状态和业务逻辑
60
+
61
+ ## 开发环境设置
62
+
63
+ ### 后端开发
64
+
65
+ ```bash
66
+ # 安装依赖
67
+ pip install -r requirements.txt
68
+
69
+ # 安装 Playwright 浏览器
70
+ playwright install chromium
71
+
72
+ # 配置环境变量
73
+ cp .env.example .env
74
+ # 编辑 .env 文件,至少配置:
75
+ # - OPENAI_API_KEY
76
+ # - OPENAI_BASE_URL
77
+ # - OPENAI_MODEL_NAME
78
+
79
+ # 启动开发服务器
80
+ python -m src.app
81
+ # 或使用 uvicorn
82
+ uvicorn src.app:app --host 0.0.0.0 --port 8000 --reload
83
+ ```
84
+
85
+ ### 前端开发
86
+
87
+ ```bash
88
+ cd web-ui
89
+
90
+ # 安装依赖
91
+ npm install
92
+
93
+ # 启动开发服务器
94
+ npm run dev
95
+
96
+ # 构建生产版本
97
+ npm run build
98
+ ```
99
+
100
+ ### Docker 部署
101
+
102
+ ```bash
103
+ # 启动服务
104
+ docker-compose up --build -d
105
+
106
+ # 查看日志
107
+ docker-compose logs -f
108
+
109
+ # 停止服务
110
+ docker-compose down
111
+ ```
112
+
113
+ ## 常用命令
114
+
115
+ ### 运行爬虫任务
116
+
117
+ ```bash
118
+ # 运行所有启用的任务
119
+ python spider_v2.py
120
+
121
+ # 运行指定任务
122
+ python spider_v2.py --task-name "MacBook Air M1"
123
+
124
+ # 调试模式(限制处理商品数量)
125
+ python spider_v2.py --debug-limit 3
126
+
127
+ # 使用自定义配置文件
128
+ python spider_v2.py --config custom_config.json
129
+ ```
130
+
131
+ ### 测试
132
+
133
+ ```bash
134
+ # 运行后端测试
135
+ pytest
136
+
137
+ # 运行测试并生成覆盖率报告
138
+ pytest --cov=src
139
+
140
+ # 测试新架构 API
141
+ python test_new_api.py
142
+ ```
143
+
144
+ ## 配置文件说明
145
+
146
+ ### config.json
147
+
148
+ 任务配置文件,定义所有监控任务:
149
+
150
+ ```json
151
+ {
152
+ "task_name": "任务名称",
153
+ "enabled": true,
154
+ "keyword": "搜索关键词",
155
+ "description": "任务描述",
156
+ "max_pages": 5,
157
+ "personal_only": true,
158
+ "min_price": "3000",
159
+ "max_price": "5000",
160
+ "cron": "0 */2 * * *",
161
+ "ai_prompt_base_file": "prompts/base_prompt.txt",
162
+ "ai_prompt_criteria_file": "prompts/macbook_criteria.txt",
163
+ "is_running": false
164
+ }
165
+ ```
166
+
167
+ ### .env
168
+
169
+ 环境变量配置,关键配置项:
170
+
171
+ - **AI 模型**: `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL_NAME`
172
+ - **通知服务**: `NTFY_TOPIC_URL`, `BARK_URL`, `WX_BOT_URL`, `TELEGRAM_BOT_TOKEN`
173
+ - **爬虫设置**: `RUN_HEADLESS`, `LOGIN_IS_EDGE`
174
+ - **Web 认证**: `WEB_USERNAME`, `WEB_PASSWORD`
175
+ - **服务端口**: `SERVER_PORT`
176
+
177
+ ## 数据流程
178
+
179
+ 1. **任务创建** → Web UI 或直接编辑 `config.json`
180
+ 2. **任务调度** → `SchedulerService` 根据 cron 表达式或手动触发
181
+ 3. **进程启动** → `ProcessService` 启动 `spider_v2.py` 子进程
182
+ 4. **商品爬取** → `scraper.py` 使用 Playwright 抓取闲鱼商品
183
+ 5. **AI 分析** → `AIAnalysisService` 调用多模态模型分析商品图片和描述
184
+ 6. **通知推送** → `NotificationService` 根据 AI 推荐结果发送通知
185
+ 7. **数据存储** → 结果保存到 `jsonl/` 目录,图片保存到 `images/`
186
+
187
+ ## 关键技术点
188
+
189
+ ### 登录状态管理
190
+
191
+ - 登录状态存储在 `state.json` 文件中
192
+ - 通过 Chrome 扩展提取登录信息(无法在 Docker 内扫码登录)
193
+ - Web UI 提供"手动更新登录状态"功能
194
+
195
+ ### 进程管理
196
+
197
+ - `ProcessService` 使用 `asyncio.create_subprocess_exec` 管理爬虫进程
198
+ - 每个任务运行在独立的子进程中
199
+ - 支持进程组管理(Unix)和优雅终止
200
+
201
+ ### 定时调度
202
+
203
+ - 基于 APScheduler 的 `BackgroundScheduler`
204
+ - 支持 Cron 表达式配置
205
+ - 应用启动时自动加载所有定时任务
206
+
207
+ ### AI 分析
208
+
209
+ - 支持多模态模型(需支持图片上传)
210
+ - 使用两阶段 Prompt:base_prompt + criteria_prompt
211
+ - 返回结构化 JSON 结果(推荐/不推荐 + 理由)
212
+
213
+ ### 通知系统
214
+
215
+ 插件化设计,支持多种通知渠道:
216
+ - ntfy.sh
217
+ - Bark
218
+ - 企业微信 Webhook
219
+ - Telegram Bot
220
+ - Gotify
221
+ - 通用 Webhook
222
+
223
+ ## API 端点
224
+
225
+ 主要 API 路由(需 Basic Auth):
226
+
227
+ - `GET /` - Web UI 主页
228
+ - `GET /health` - 健康检查(无需认证)
229
+ - `GET /api/tasks` - 获取所有任务
230
+ - `POST /api/tasks` - 创建任务
231
+ - `PUT /api/tasks/{task_id}` - 更新任务
232
+ - `DELETE /api/tasks/{task_id}` - 删除任务
233
+ - `POST /api/tasks/{task_id}/start` - 启动任务
234
+ - `POST /api/tasks/{task_id}/stop` - 停止任务
235
+ - `GET /api/logs` - 获取日志
236
+ - `GET /api/results` - 获取监控结果
237
+ - `GET /api/settings/check` - 检查系统配置
238
+ - `GET /api/prompts` - 获取 Prompt 文件列表
239
+ - `GET /api/login-state` - 获取登录状态
240
+
241
+ 完整 API 文档:启动服务后访问 `http://localhost:8000/docs`
242
+
243
+ ## 文件结构关键路径
244
+
245
+ - `src/app.py` - FastAPI 应用主入口
246
+ - `src/scraper.py` - 爬虫核心逻辑
247
+ - `spider_v2.py` - 命令行任务执行器
248
+ - `src/services/` - 所有业务服务
249
+ - `src/api/routes/` - API 路由定义
250
+ - `src/domain/models/` - 领域模型
251
+ - `src/infrastructure/` - 基础设施(配置、持久化、外部客户端)
252
+ - `config.json` - 任务配置
253
+ - `state.json` - 登录状态
254
+ - `prompts/` - AI Prompt 模板
255
+ - `jsonl/` - 监控结果数据
256
+ - `logs/` - 日志文件
257
+ - `images/` - 下载的商品图片
258
+
259
+ ## 注意事项
260
+
261
+ 1. **登录状态**: Docker 部署时必须通过 Web UI 手动更新登录状态
262
+ 2. **浏览器依赖**: 需要安装 Playwright 浏览器驱动
263
+ 3. **AI 模型**: 必须使用支持多模态(图片)的模型
264
+ 4. **反爬虫**: 避免过于频繁的请求,遇到滑动验证码时可设置 `RUN_HEADLESS=false` 手动处理
265
+ 5. **认证**: 生产环境务必修改默认的 Web 认证密码
266
+ 6. **端口冲突**: 默认端口 8000,可通过 `SERVER_PORT` 环境变量修改
267
+
268
+ ## 参考文档
269
+
270
+ - 重构完成报告: `archive/REFACTORING_COMPLETE.md`
271
+ - 前端架构方案: `FRONTEND_REFACTOR_ARCHITECTURE.md`
272
+ - 常见问题: `FAQ.md`
273
+ - 免责声明: `DISCLAIMER.md`
DISCLAIMER.md ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 免责声明 / Disclaimer
2
+
3
+ ## 中文版本
4
+
5
+ 本项目是一个开源软件,仅供学习和研究目的使用。使用者在使用本软件时,必须遵守所在国家/地区的所有相关法律法规。
6
+
7
+ 项目作者及贡献者明确声明:
8
+
9
+ 1. 本项目仅用于技术学习和研究目的,不得用于任何违法或不道德的活动。
10
+ 2. 使用者对本软件的使用行为承担全部责任,包括但不限于任何修改、分发或商业应用。
11
+ 3. 项目作者及贡献者不对因使用本软件而导致的任何直接、间接、附带或特殊的损害或损失承担责任,即使已被告知可能发生此类损害。
12
+ 4. 如果您的使用行为违反了所在司法管辖区的法律,请立即停止使用并删除本软件。
13
+ 5. 本项目按"现状"提供,不提供任何形式的担保,包括但不限于适销性、特定用途适用性和非侵权性担保。
14
+
15
+ 本项目采用 MIT 许可证发布。根据该许可证,您可以自由使用、复制、修改、分发本软件,但必须保留原始版权声明和本免责声明。
16
+
17
+ 项目作者保留随时更改本免责声明的权利,恕不另行通知。使用本软件即表示您同意受本免责声明条款的约束。
18
+
19
+ ## English Version
20
+
21
+ 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.
22
+
23
+ The project owner and contributors explicitly state:
24
+
25
+ 1. This project is for technical learning and research purposes only and must not be used for any illegal or unethical activities.
26
+ 2. Users assume full responsibility for their use of the software, including but not limited to any modifications, distributions, or commercial applications.
27
+ 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.
28
+ 4. If your use violates the laws of your jurisdiction, please stop using and delete this software immediately.
29
+ 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.
30
+
31
+ 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.
32
+
33
+ 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.
Dockerfile CHANGED
@@ -77,4 +77,4 @@ ENTRYPOINT ["tini", "--"]
77
 
78
  # 容器启动时执行的命令
79
  # 使用新架构的启动方式
80
- CMD ["python", "-m", "src.app"]
 
77
 
78
  # 容器启动时执行的命令
79
  # 使用新架构的启动方式
80
+ CMD ["python", "-m", "src.app"]
FAQ.md ADDED
File without changes
FRONTEND_REFACTOR_ARCHITECTURE.md ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Vue 3 前端重构架构方案 (v1.1)
2
+
3
+ ## 0. 背景与原则
4
+
5
+ 本文档旨在为 `ai-goofish-monitor` 项目的前端重构提供一套完整的工程架构方案。此方案基于对现有后端(FastAPI)和前端(原生 JavaScript)代码的深入分析,并严格遵循以下核心原则:
6
+
7
+ 1. **复杂度对等**: 确保前端架构的复杂度与后端服务的简洁性相匹配,避免过度设计。
8
+ 2. **可维护性优先**: 产出易于理解、易于接手、易于扩展的代码。
9
+ 3. **业务驱动**: 任何引入的复杂度(如 WebSocket、RBAC)都必须有明确的业务或未来功能需求支撑。
10
+
11
+ ---
12
+
13
+ ## 1. 模块拆分结构 (Logical Modules)
14
+
15
+ 应用将按功能领域进行逻辑拆分,每个模块都包含其独立的视图、路由、状态逻辑和组件。
16
+
17
+ - **`Auth` (认证模块)**
18
+ - **`Tasks` (任务管理模块)**
19
+ - **`Results` (结果查看模块)**
20
+ - **`Logs` (日志模块)**
21
+ - **`Settings` (系统设置模块)**
22
+ - **`Dashboard` (仪表盘模块)** - *为未来预留*
23
+ - **`Core` (核心/共享模块)**
24
+
25
+ ---
26
+
27
+ ## 2. 页面路由树 (Vue Router)
28
+
29
+ 路由设计将遵循模块化,并为未来的权限控制预留 `meta` 字段。
30
+
31
+ ```typescript
32
+ // src/router/index.ts
33
+ import { createRouter, createWebHistory } from 'vue-router';
34
+
35
+ const routes = [
36
+ {
37
+ path: '/',
38
+ component: () => import('@/layouts/MainLayout.vue'),
39
+ redirect: '/tasks',
40
+ children: [
41
+ {
42
+ path: 'tasks',
43
+ name: 'Tasks',
44
+ component: () => import('@/views/TasksView.vue'),
45
+ meta: { title: '任务管理', requiresAuth: true },
46
+ },
47
+ // ... 其他子路由
48
+ ],
49
+ },
50
+ { path: '/:pathMatch(.*)*', name: 'NotFound', redirect: '/' },
51
+ ];
52
+
53
+ const router = createRouter({
54
+ history: createWebHistory(),
55
+ routes,
56
+ });
57
+
58
+ // ... 路由守卫
59
+ export default router;
60
+ ```
61
+
62
+ ---
63
+
64
+ ## 3. 状态管理设计 (Composables)
65
+
66
+ 采用基于 Vue 3 Composition API 的 **Composable** 函数进行状态管理,放弃引入 Pinia,以降低复杂度。
67
+
68
+ - **`useWebSocket.ts`**: 唯一且核心的 WebSocket 管理器,封装连接、重连及消息分发。
69
+ - **`useAuth.ts`**: 管理当前用户的状态和权限,为 RBAC 提供基础。
70
+ - **`useTasks.ts`**: 封装 `Tasks` 模块的所有业务逻辑,并响应 WebSocket 推送。
71
+ - **`useLogs.ts`**: 封装 `Logs` 模块的业务逻辑,并响应 WebSocket 推送。
72
+
73
+ ---
74
+
75
+ ## 4. UI 构建方案 (UI Implementation Strategy)
76
+
77
+ 我们将采用 **`shadcn-vue`** 作为 UI 的构建方案。
78
+
79
+ - **核心理念**: `shadcn-vue` **不是一个组件库**,而是一系列可复用组件的**代码集合**。我们通过其 CLI 将组件源代码直接复制到项目中。
80
+ - **技术栈**:
81
+ - **样式**: **Tailwind CSS**。所有组件都基于其原子化类名构建,提供最大化的样式控制力。
82
+ - **底层**: **Radix Vue**。提供无头(headless)、功能完备且高度符合 WAI-ARIA 标准的底层组件原语。
83
+ - **Implications**:
84
+ - **组件所有权**: 我们 100% 拥有组件代码,可以随意修改以满足设计需求,无需覆盖库的样式。
85
+ - **可维护性**: 由于代码在本地,追踪和调试组件行为变得非常直接。
86
+ - **打包体积**: 最终产物只包含实际使用的代码和样式,体积更小。
87
+ - **开发流程**: 初始设置(如配置 Tailwind)会比使用传统组件库稍复杂,但后续开发和定制会更高效。
88
+
89
+ ---
90
+
91
+ ## 5. 接口对齐表 (API Alignment)
92
+
93
+ | 模块 | 功能 | HTTP 方法 | API Endpoint | Vue 调用函数 |
94
+ | :--- | :--- | :--- | :--- | :--- |
95
+ | **Tasks** | 获取所有任务 | `GET` | `/api/tasks` | `api.tasks.getAll()` |
96
+ | | AI 创建任务 | `POST` | `/api/tasks/generate` | `api.tasks.createWithAI()` |
97
+ | | ... | ... | ... | ... |
98
+ | **Results** | 获取结果文件列表 | `GET` | `/api/results/files` | `api.results.getFiles()` |
99
+ | | ... | ... | ... | ... |
100
+ | **Logs** | 获取日志 | `GET` | `/api/logs` | `api.logs.get()` |
101
+ | | ... | ... | ... | ... |
102
+ | **Settings**| 获取系统状态 | `GET` | `/api/settings/status` | `api.settings.getStatus()` |
103
+ | | ... | ... | ... | ... |
104
+
105
+ *(表格内容与 v1.0 版本一致)*
106
+
107
+ ---
108
+
109
+ ## 6. 目录结构
110
+
111
+ 新的 `web-ui` 目录将采用功能驱动和分层结合的结构。
112
+
113
+ ```
114
+ web-ui/
115
+ ├── src/
116
+ │ ├── api/ # API 请求层
117
+ │ ├── assets/ # 静态资源
118
+ │ ├── components/ # 全局及 shadcn-vue 生成的组件
119
+ │ │ ├── ui/ # - shadcn-vue 生成的 UI 组件 (e.g., button.vue, dialog.vue)
120
+ │ │ └── common/ # - 项目自身的通用业务组件
121
+ │ ├── composables/ # 状态与业务逻辑
122
+ │ ├── layouts/ # 页面主布局
123
+ │ ├── router/ # 路由配置
124
+ │ ├── services/ # 核心服务 (e.g., websocket.ts)
125
+ │ ├── lib/ # Tailwind CSS 相关工具 (e.g., utils.ts)
126
+ │ ├── types/ # TypeScript 类型定义
127
+ │ └── views/ # 页面级视图组件
128
+ ├── tailwind.config.js # Tailwind CSS 配置文件
129
+ ├── components.json # shadcn-vue 配置文件
130
+ ├── vite.config.ts
131
+ └── package.json
132
+ ```
133
+
134
+ ---
135
+
136
+ ## 7. 组件设计边界
137
+
138
+ 组件将严格划分为“容器组件”和“展示组件”。
139
+
140
+ - **`TasksView.vue` (容器/视图组件)**
141
+ - **职责**: 页面入口,调用 `useTasks()` 获取数据和方法,传递给子组件。
142
+ - **内部**: `const { tasks, isLoading, createTask } = useTasks();`
143
+
144
+ - **`TasksTable.vue` (展示组件)**
145
+ - **职责**: 接收任务列表并使用 `shadcn-vue` 的 `Table` 组件进行渲染。当用户操作时,**只发出事件**。
146
+ - **Props**: `tasks: Task[]`, `isLoading: boolean`。
147
+ - **Emits**: `@edit-task`, `@delete-task`, `@run-task`, `@stop-task`。
148
+
149
+ - **`TaskFormWizard.vue` (容器/功能组件)**
150
+ - **职责**: 实现任务创建的多步引导流程,使用 `shadcn-vue` 的 `Dialog`, `Input`, `Button` 等组件构建。
151
+ - **Props**: `initialData?: Task`。
152
+ - **Emits**: `@save`。
153
+
154
+ ---
155
+
156
+ ## 8. 渲染层与业务层的解耦说明
157
+
158
+ 核心思想是**清晰地分离渲染、业务逻辑和数据请求**。
159
+
160
+ 1. **渲染层 (Views & Components)**:
161
+ - **角色**: “哑”组件。只负责“看什么样”。
162
+ - **实现**: 使用 Vue 模板语法、**由 `shadcn-vue` 生成并由我们维护的 UI 组件**以及 **Tailwind CSS** 类名。接收 `props` 数据进行渲染,通过 `emits` 报告用户交互。
163
+
164
+ 2. **业务逻辑层 (Composables)**:
165
+ - **角色**: “聪明”的协调者。负责“做什么”。
166
+ - **实现**: `use...` 函数。它们是响应式的、有状态的逻辑单元,负责调用 API、处理数据,并暴露给渲染层。
167
+
168
+ 3. **数据服务层 (API & Services)**:
169
+ - **角色**: 数据的“搬运工”。负责“从哪拿数据”。
170
+ - **实现**: 在 `src/api` 目录下类型化的 API 请求函数,以及 `src/services` 下的 WebSocket 服务。
171
+
172
+ #### 数据流示例 (单向数据流):
173
+
174
+ `用户点击“删除”按钮` -> `TaskTableRow.vue` **emits** `@delete` 事件 -> `TasksView.vue` 监听到事件,调用 `useTasks()` 提供的 `deleteTask(id)` 方法 -> `useTasks.ts` 调用 `api.tasks.delete(id)` -> `api/tasks.ts` 发出 `fetch` 请求 -> 成功后 `useTasks.ts` 更新内部的 `tasks` ref -> `TasksView.vue` 自动响应 `tasks` 的变化并重新渲染 `TasksTable.vue`。
GEMINI.md ADDED
File without changes
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 dingyufei615
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
LOCAL_GUIDE.md ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 本地运行指南
2
+
3
+ ## 🐳 Docker 一键启动 (推荐)
4
+
5
+ 只需要 **git clone** 后执行一条命令即可启动:
6
+
7
+ ```bash
8
+ git clone <your-repo-url>
9
+ cd ai-goofish-monitor
10
+ cp .env.example .env
11
+ cp config.json.example config.json
12
+ docker compose up -d
13
+ ```
14
+
15
+ - 访问地址:`http://127.0.0.1:8000`
16
+ - 默认账号/密码同下方说明
17
+
18
+ ### 开发模式 (可选)
19
+
20
+ 如果你要开发前端或后端代码,使用开发版 compose:
21
+
22
+ ```bash
23
+ docker compose -f docker-compose.dev.yaml up -d --build
24
+ ```
25
+
26
+ ## 🛠️ 手动安装步骤
27
+
28
+ ### 第一步:环境准备 (后端 Python)
29
+
30
+ 1. **克隆项目并进入目录**:
31
+ ```bash
32
+ git clone <your-repo-url>
33
+ cd ai-goofish-monitor
34
+ ```
35
+
36
+ 2. **创建并激活虚拟环境** (推荐):
37
+ - **Linux/macOS**:
38
+ ```bash
39
+ python3 -m venv .venv
40
+ source .venv/bin/activate
41
+ ```
42
+ - **Windows**:
43
+ ```bash
44
+ python -m venv .venv
45
+ .venv\Scripts\activate
46
+ ```
47
+
48
+ 3. **安装 Python 依赖**:
49
+ ```bash
50
+ pip install -r requirements.txt
51
+ ```
52
+
53
+ 4. **浏览器准备**:
54
+ 本项目在本地运行时默认通过 `channel="chrome"` 调用您系统中已安装的 **Google Chrome** 或 **Microsoft Edge**。
55
+
56
+ - 请确保您的电脑已安装其中之一。
57
+ - **无需** 运行 `playwright install` 下载额外的浏览器内核。
58
+
59
+ ### 第二步:编译前端 (Vue3 + Shadcn UI)
60
+
61
+ 项目采用前后端分离架构,需要先将前端代码编译打包,后端才能正常提供 Web 界面。
62
+
63
+ 1. **进入前端目录**:
64
+
65
+ ```bash
66
+ cd web-ui
67
+ ```
68
+
69
+ 2. **安装 Node.js 依赖**:
70
+ ```bash
71
+ npm install
72
+ ```
73
+
74
+ 3. **执行构建打包**:
75
+
76
+ ```bash
77
+ npm run build
78
+ ```
79
+
80
+ 4. **将构建产物移动到后端可访问位置**:
81
+ - **Linux/macOS**:
82
+ ```bash
83
+ rm -rf ../dist && mv dist ../
84
+ ```
85
+ - **Windows (PowerShell)**:
86
+ ```powershell
87
+ Remove-Item -Recurse -Force ..\dist; Move-Item dist ..\
88
+ ```
89
+
90
+ 5. **返回根目录**:
91
+ ```bash
92
+ cd ..
93
+ ```
94
+
95
+ ### 第三步:配置文件
96
+
97
+ 1. **创建 `.env` 文件**:
98
+ ```bash
99
+ cp .env.example .env
100
+ ```
101
+ 编辑 `.env` 文件,至少填入 `OPENAI_API_KEY`。如果您没有特定的模型需求,建议保持 `OPENAI_BASE_URL` 为默认或使用您可靠的代理地址。
102
+
103
+ 2. **创建 `config.json` 文件** (任务配置):
104
+
105
+ ```bash
106
+ cp config.json.example config.json
107
+ ```
108
+
109
+ ### 第四步:启动服务
110
+
111
+ 在项目根目录下,且确保虚拟环境已激活的状态下运行:
112
+
113
+ ```bash
114
+ python web_server.py
115
+ ```
116
+
117
+ - **默认地址**:`http://127.0.0.1:8000`
118
+ - **默认账号**:`admin`
119
+ - **默认密码**:`admin123` (可在 `.env` 中通过 `WEB_USERNAME` 和 `WEB_PASSWORD` 修改)
120
+
121
+ ##
README.md CHANGED
@@ -1,11 +1,237 @@
1
- ---
2
- title: Goofish
3
- emoji: 👁
4
- colorFrom: indigo
5
- colorTo: yellow
6
- sdk: docker
7
- app_port: 8000
8
- pinned: false
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 闲鱼智能监控机器人
2
+
3
+ 基于 Playwright 和 AI 的闲鱼多任务实时监控工具,提供完整的 Web 管理界面。
4
+
5
+ ## 核心特性
6
+
7
+ - **Web 可视化管理**: 任务管理、账号管理、AI 标准编辑、运行日志、结果浏览
8
+ - **AI 驱动**: 自然语言创建任务,多模态模型深度分析商品
9
+ - **多任务并发**: 独立配置关键词、价格、筛选条件和 AI Prompt
10
+ - **高级筛选**: 包邮、新发布时间范围、省/市/区三级区域筛选
11
+ - **即时通知**: 支持 ntfy.sh、企业微信、Bark、Telegram、Webhook
12
+ - **定时调度**: Cron 表达式配置周期性任务
13
+ - **账号与代理轮换**: 多账号管理、任务绑定账号、代理池轮换与失败重试
14
+ - **Docker 部署**: 一键容器化部署
15
+
16
+ ## 截图
17
+
18
+ ![任务管理](static/img.png)
19
+ ![监控界面](static/img_1.png)
20
+ ![通知示例](static/img_2.png)
21
+
22
+ ## 快速开始
23
+
24
+ ### 环境准备
25
+
26
+ **要求**:
27
+ - Python 3.10+
28
+ - Node.js + npm(用于前端构建)
29
+ - Playwright 浏览器依赖(未安装时执行 `playwright install chromium`)
30
+
31
+ ```bash
32
+ git clone https://github.com/Usagi-org/ai-goofish-monitor
33
+ cd ai-goofish-monitor
34
+ ```
35
+
36
+ ### 配置
37
+
38
+ 1. **创建配置文件**
39
+
40
+ ```bash
41
+ cp .env.example .env
42
+ ```
43
+
44
+ 2. **核心配置项**
45
+
46
+ | 变量 | 说明 | 必填 |
47
+ |------|------|------|
48
+ | `OPENAI_API_KEY` | AI 模型 API Key | 是 |
49
+ | `OPENAI_BASE_URL` | API 接口地址(兼容 OpenAI 格式) | 是 |
50
+ | `OPENAI_MODEL_NAME` | 多模态模型名称(如 `gpt-4o`) | 是 |
51
+ | `WEB_USERNAME` / `WEB_PASSWORD` | Web 界面登录凭据(默认 `admin` / `admin123`) | 否 |
52
+ | `NTFY_TOPIC_URL` | ntfy.sh 通知地址 | 否 |
53
+ | `BARK_URL` | Bark 推送地址 | 否 |
54
+ | `WX_BOT_URL` | 企业微信 Webhook(需用双引号包围) | 否 |
55
+
56
+ 完整配置项参考 `.env.example`
57
+
58
+ 3. **启动服务**
59
+
60
+ ```bash
61
+ chmod +x start.sh && ./start.sh
62
+ ```
63
+
64
+ start.sh 会自动完成依赖安装、前端构建与后端启动。
65
+
66
+ 4. **访问 Web UI**
67
+ 访问 `http://127.0.0.1:8000`,
68
+ **登录默认密码(admin/admin123)** → **闲鱼账号管理**,按提示使用 [Chrome 扩展](https://chromewebstore.google.com/detail/xianyu-login-state-extrac/eidlpfjiodpigmfcahkmlenhppfklcoa) 提取并粘贴登录状态 JSON。
69
+ 账号会保存到 `state/` 目录(例如 `state/acc_1.json`)。随后在**任务管理**中选择绑定账号即可开始使用。
70
+
71
+ ## 🐳 Docker 部署
72
+
73
+ 使用 `docker-compose.yaml` 一键启动,镜像已预置前端构建与运行环境。
74
+
75
+ ### 1) 准备
76
+
77
+ ```bash
78
+ cp .env.example .env
79
+ ```
80
+
81
+ ### 2) 启动
82
+
83
+ ```bash
84
+ docker compose up -d
85
+ ```
86
+
87
+ ### 3) 访问与管理
88
+
89
+ - **访问 Web UI**: `http://127.0.0.1:8000`
90
+ - **查看日志**: `docker compose logs -f app`
91
+ - **停止服务**: `docker compose down`
92
+ 账号状态默认保存在容器内 `/app/state`,如需持久化可在 compose 中添加挂载 `./state:/app/state`。
93
+
94
+ ### 4) 更新镜像
95
+
96
+ ```bash
97
+ docker compose pull
98
+ docker compose up -d
99
+ ```
100
+
101
+ ## Web UI 功能一览
102
+
103
+ <details>
104
+ <summary>点击展开 Web UI 功能详情</summary>
105
+
106
+ - **任务管理**:AI 创建、参数编辑、任务调度、账号绑定
107
+ - **闲鱼账号管理**:添加/更新/删除账号,导入登录状态 JSON
108
+ - **结果查看**:卡片浏览、筛选排序、详情查看
109
+ - **运行日志**:按任务分组、增量加载、自动刷新
110
+ - **系统设置**:状态检查、Prompt 编辑、代理轮换
111
+
112
+ </details>
113
+
114
+ ## 🚀 工作流程
115
+
116
+ 下图描述了单个监控任务从启动到完成的核心处理逻辑。在实际使用中,`src.app` 会作为主服务,根据用户操作或定时调度来启动一个或多个任务进程。
117
+
118
+ ```mermaid
119
+ graph TD
120
+ A[启动监控任务] --> B[选择账号/代理配置];
121
+ B --> C[任务: 搜索商品];
122
+ C --> D{发现新商品?};
123
+ D -- 是 --> E[抓取商品详情 & 卖家信息];
124
+ E --> F[下载商品图片];
125
+ F --> G[调用AI进行分析];
126
+ G --> H{AI是否推荐?};
127
+ H -- 是 --> I[发送通知];
128
+ H -- 否 --> J[保存记录到 JSONL];
129
+ I --> J;
130
+ D -- 否 --> K[翻页/等待];
131
+ K --> C;
132
+ J --> C;
133
+ C --> L{触发风控/异常?};
134
+ L -- 是 --> M[账号/代理轮换并重试];
135
+ M --> C;
136
+ ```
137
+
138
+ ## Web 界面认证
139
+
140
+ <details>
141
+ <summary>点击展开认证配置详情</summary>
142
+
143
+ ### 认证配置
144
+
145
+ Web界面已启用Basic认证保护,确保只有授权用户才能访问管理界面和API。
146
+
147
+ #### 配置方法
148
+
149
+ 在 `.env` 文件中设置认证凭据:
150
+
151
+ ```bash
152
+ # Web服务认证配置
153
+ WEB_USERNAME=admin
154
+ WEB_PASSWORD=admin123
155
+ ```
156
+
157
+ #### 默认凭据
158
+
159
+ 如果未在 `.env` 文件中设置认证凭据,系统将使用以下默认值:
160
+ - 用户名:`admin`
161
+ - 密码:`admin123`
162
+
163
+ **⚠️ 重要:生产环境请务必修改默认密码!**
164
+
165
+ #### 认证范围
166
+
167
+ - **需要认证**:所有API端点、Web界面、静态资源
168
+ - **无需认证**:健康检查端点 (`/health`)
169
+
170
+ #### 使用方法
171
+
172
+ 1. **浏览器访问**:访问Web界面时会弹出认证对话框
173
+ 2. **API调用**:需要在请求头中包含Basic认证信息
174
+ 3. **前端JavaScript**:会自动处理认证,无需修改
175
+
176
+ #### 安全建议
177
+
178
+ 1. 修改默认密码为强密码
179
+ 2. 生产环境使用HTTPS协议
180
+ 3. 定期更换认证凭据
181
+ 4. 通过防火墙限制访问IP范围
182
+
183
+ 详细配置说明请参考 [AUTH_README.md](AUTH_README.md)。
184
+
185
+ </details>
186
+
187
+
188
+
189
+ ## 致谢
190
+
191
+ <details>
192
+ <summary>点击展开致谢内容</summary>
193
+
194
+ 本项目在开发过程中参考了以下优秀项目,特此感谢:
195
+
196
+ - [superboyyy/xianyu_spider](https://github.com/superboyyy/xianyu_spider)
197
+
198
+ 以及感谢LinuxDo相关人员的脚本贡献
199
+
200
+ - [@jooooody](https://linux.do/u/jooooody/summary)
201
+
202
+ 以及感谢 [LinuxDo](https://linux.do/) 社区。
203
+
204
+ 以及感谢 ClaudeCode/Gemini/Codex 等模型工具,解放双手 体验Vibe Coding的快乐。
205
+
206
+ </details>
207
+
208
+ ## 体会
209
+
210
+ <details>
211
+ <summary>点击展开项目体会</summary>
212
+
213
+ 本项目 90%+ 的代码都由AI生成,包括 ISSUE 中涉及的 PR 。
214
+
215
+ Vibe Coding 的可怕之处在于如果不过多的参与项目建设,对AI生成的代码没有进行细致的review,没有思考过AI为什么这么写,盲目的通过跑测试用例验证功能可用性只会导致项目变成一个黑盒。
216
+
217
+ 同样再用AI对AI生成的代码进行code review时,就像是用AI来验证另一个AI的回答是不是AI,陷入了自我证明的困境之中,所以AI可以辅助分析,但不应该成为真相的仲裁者。
218
+
219
+
220
+ </details>
221
+
222
+ ## 注意事项
223
+
224
+ <details>
225
+ <summary>点击展开注意事项详情</summary>
226
+
227
+ - 请遵守闲鱼的用户协议和robots.txt规则,不要进行过于频繁的请求,以免对服务器造成负担或导致账号被限制。
228
+ - 本项目仅供学习和技术研究使用,请勿用于非法用途。
229
+ - 本项目采用 [MIT 许可证](LICENSE) 发布,按"现状"提供,不提供任何形式的担保。
230
+ - 项目作者及贡献者不对因使用本软件而导致的任何直接、间接、附带或特殊的损害或损失承担责任。
231
+ - 如需了解更多详细信息,请查看 [免责声明](DISCLAIMER.md) 文件。
232
+
233
+ </details>
234
+
235
+ ## Star History
236
+
237
+ [![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)
chrome-extension/README.md ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Xianyu Login State Extractor Chrome Extension
2
+
3
+ This Chrome extension helps extract complete login state information from Xianyu (Goofish) for use with the monitoring robot.
4
+
5
+ ## Installation
6
+
7
+ 1. Open Chrome and navigate to `chrome://extensions`
8
+ 2. Enable "Developer mode" in the top right corner
9
+ 3. Click "Load unpacked" and select the `chrome-extension` directory
10
+ 4. The extension icon should now appear in your toolbar
11
+
12
+ ## Usage
13
+
14
+ 1. Navigate to [https://www.goofish.com](https://www.goofish.com)
15
+ 2. Log in to your account
16
+ 3. Click the extension icon in the toolbar
17
+ 4. Click "Extract Login State"
18
+ 5. The complete login state will be displayed - click "Copy to Clipboard" to copy it
19
+ 6. Save the copied JSON as `xianyu_state.json` in your project directory
20
+
21
+ ## Features
22
+
23
+ - Extracts all cookies including HttpOnly cookies that are not accessible via JavaScript
24
+ - Formats output as JSON compatible with the monitoring robot
25
+ - One-click copy to clipboard functionality
26
+ - Real-time status feedback
27
+
28
+ ## How It Works
29
+
30
+ 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.
chrome-extension/manifest.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "Xianyu Login State Extractor",
4
+ "version": "1.0",
5
+ "description": "Extract complete login state for Xianyu monitoring robot",
6
+ "permissions": [
7
+ "activeTab",
8
+ "cookies"
9
+ ],
10
+ "host_permissions": [
11
+ "*://*.goofish.com/*"
12
+ ],
13
+ "action": {
14
+ "default_popup": "popup.html",
15
+ "default_title": "Extract Xianyu Login State"
16
+ }
17
+ }
chrome-extension/popup.html ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <meta charset="UTF-8">
4
+ <head>
5
+ <style>
6
+ body {
7
+ width: 400px;
8
+ padding: 20px;
9
+ font-family: Arial, sans-serif;
10
+ }
11
+ #stateOutput {
12
+ width: 100%;
13
+ height: 300px;
14
+ font-family: monospace;
15
+ font-size: 12px;
16
+ white-space: pre-wrap;
17
+ overflow-y: auto;
18
+ }
19
+ button {
20
+ margin: 10px 0;
21
+ padding: 10px;
22
+ background-color: #4CAF50;
23
+ color: white;
24
+ border: none;
25
+ cursor: pointer;
26
+ width: 100%;
27
+ }
28
+ button:hover {
29
+ background-color: #45a049;
30
+ }
31
+ .status {
32
+ margin: 10px 0;
33
+ padding: 10px;
34
+ border-radius: 4px;
35
+ }
36
+ .success {
37
+ background-color: #dff0d8;
38
+ color: #3c763d;
39
+ }
40
+ .error {
41
+ background-color: #f2dede;
42
+ color: #a94442;
43
+ }
44
+ </style>
45
+ </head>
46
+ <body>
47
+ <h2>Xianyu Login State Extractor</h2>
48
+ <button id="extractBtn">1.点击获取登录状态</button>
49
+ <div id="status"></div>
50
+ <textarea id="stateOutput" readonly></textarea>
51
+ <button id="copyBtn">2.点击复制</button>
52
+
53
+ <script src="popup.js"></script>
54
+ </body>
55
+ </html>
chrome-extension/popup.js ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Popup script for the Chrome extension
2
+ document.addEventListener('DOMContentLoaded', function() {
3
+ const extractBtn = document.getElementById('extractBtn');
4
+ const copyBtn = document.getElementById('copyBtn');
5
+ const stateOutput = document.getElementById('stateOutput');
6
+ const statusDiv = document.getElementById('status');
7
+
8
+ // Update status message
9
+ function updateStatus(message, isSuccess = false) {
10
+ statusDiv.textContent = message;
11
+ statusDiv.className = 'status ' + (isSuccess ? 'success' : 'error');
12
+ setTimeout(() => {
13
+ statusDiv.textContent = '';
14
+ statusDiv.className = 'status';
15
+ }, 3000);
16
+ }
17
+
18
+ // Map Chrome cookie sameSite values to Playwright compatible values
19
+ function mapSameSiteValue(chromeSameSite) {
20
+ // Chrome returns undefined for cookies without SameSite attribute
21
+ if (chromeSameSite === undefined || chromeSameSite === null) {
22
+ return "Lax"; // Default value for unspecified cookies
23
+ }
24
+
25
+ // Map Chrome's cookie sameSite values to Playwright's expected values (with proper capitalization)
26
+ const sameSiteMap = {
27
+ "no_restriction": "None",
28
+ "lax": "Lax",
29
+ "strict": "Strict",
30
+ "unspecified": "Lax" // Treat unspecified as Lax (browser default)
31
+ };
32
+
33
+ return sameSiteMap[chromeSameSite] || "Lax";
34
+ }
35
+
36
+ // Extract cookies when button is clicked
37
+ extractBtn.addEventListener('click', async () => {
38
+ try {
39
+ const [tab] = await chrome.tabs.query({active: true, currentWindow: true});
40
+
41
+ if (!tab.url.includes('goofish.com')) {
42
+ updateStatus('Please navigate to goofish.com first');
43
+ return;
44
+ }
45
+
46
+ // Directly call chrome.cookies API from popup script
47
+ const cookies = await new Promise((resolve) => {
48
+ chrome.cookies.getAll({url: "https://www.goofish.com/"}, resolve);
49
+ });
50
+
51
+ const state = {
52
+ cookies: cookies.map(cookie => ({
53
+ name: cookie.name,
54
+ value: cookie.value,
55
+ domain: cookie.domain,
56
+ path: cookie.path,
57
+ expires: cookie.expirationDate,
58
+ httpOnly: cookie.httpOnly,
59
+ secure: cookie.secure,
60
+ sameSite: mapSameSiteValue(cookie.sameSite)
61
+ }))
62
+ };
63
+
64
+ stateOutput.value = JSON.stringify(state, null, 2);
65
+ updateStatus('Login state extracted successfully!', true);
66
+ } catch (error) {
67
+ console.error('Error extracting cookies:', error);
68
+ updateStatus('Error: ' + error.message);
69
+ }
70
+ });
71
+
72
+ // Copy to clipboard when button is clicked
73
+ copyBtn.addEventListener('click', () => {
74
+ if (stateOutput.value) {
75
+ navigator.clipboard.writeText(stateOutput.value)
76
+ .then(() => {
77
+ updateStatus('Copied to clipboard!', true);
78
+ })
79
+ .catch(err => {
80
+ updateStatus('Failed to copy: ' + err);
81
+ });
82
+ } else {
83
+ updateStatus('No data to copy');
84
+ }
85
+ });
86
+ });
config.json ADDED
@@ -0,0 +1 @@
 
 
1
+ []
config.json.example ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "task_name": "苹果watch S10",
4
+ "enabled": true,
5
+ "keyword": "苹果watch S10",
6
+ "description": "九成新,充电线包装盒齐全,无明显磕碰,卖家信用优秀",
7
+ "max_pages": 10,
8
+ "personal_only": true,
9
+ "min_price": "8000",
10
+ "max_price": "2000",
11
+ "cron": null,
12
+ "ai_prompt_base_file": "prompts/base_prompt.txt",
13
+ "ai_prompt_criteria_file": "prompts/苹果watch_s10_criteria.txt",
14
+ "account_state_file": "state/acc1.json",
15
+ "free_shipping": true,
16
+ "new_publish_option": "14天内",
17
+ "region": "江苏/南京/全南京",
18
+ "is_running": false
19
+ }
20
+ ]
desktop_launcher.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 桌面启动入口
3
+ 使用 PyInstaller 打包后作为单一可执行文件的入口,自动启动 FastAPI 服务并打开浏览器。
4
+ """
5
+ import os
6
+ import sys
7
+ import time
8
+ import webbrowser
9
+ from pathlib import Path
10
+
11
+ import uvicorn
12
+
13
+ # PyInstaller 运行时资源目录:_MEIPASS;未打包时则为当前文件所在目录
14
+ BASE_DIR = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
15
+
16
+
17
+ def _prepare_environment() -> None:
18
+ """确保工作目录和模块路径正确"""
19
+ os.chdir(BASE_DIR)
20
+ if str(BASE_DIR) not in sys.path:
21
+ sys.path.insert(0, str(BASE_DIR))
22
+
23
+
24
+ def run_app() -> None:
25
+ """启动 FastAPI 应用并自动打开浏览器"""
26
+ _prepare_environment()
27
+
28
+ from src.app import app
29
+ from src.infrastructure.config.settings import settings
30
+
31
+ # 先尝试打开浏览器,稍等服务起来
32
+ url = f"http://127.0.0.1:{settings.server_port}"
33
+ webbrowser.open(url)
34
+ time.sleep(0.5)
35
+
36
+ uvicorn.run(
37
+ app,
38
+ host="127.0.0.1",
39
+ port=settings.server_port,
40
+ log_level="info",
41
+ reload=False,
42
+ )
43
+
44
+
45
+ if __name__ == "__main__":
46
+ run_app()
docker-compose.dev.yaml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ app:
3
+ build: .
4
+ container_name: ai-goofish-monitor-app
5
+ init: true
6
+ ports:
7
+ - "8000:8000"
8
+ env_file:
9
+ - .env
10
+ volumes:
11
+ - .:/app
12
+ - /app/dist
13
+ restart: unless-stopped
docker-compose.yaml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ app:
3
+ image: ghcr.io/usagi-org/ai-goofish:latest
4
+ container_name: ai-goofish-monitor-app
5
+ pull_policy: always
6
+ init: true
7
+ ports:
8
+ - "8000:8000"
9
+ env_file:
10
+ - .env
11
+ volumes:
12
+ - ./config.json:/app/config.json
13
+ - ./prompts:/app/prompts
14
+ - ./jsonl:/app/jsonl
15
+ - ./logs:/app/logs
16
+ - ./images:/app/images
17
+ restart: unless-stopped
prompts/base_prompt.txt ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 你是世界顶级的二手交易分析专家,代号 **EagleEye-V6.4**。你的核心任务是基于我提供的严格标准,对一个以JSON格式提供的商品信息进行深入的、基于用户画像的评估。你的分析必须极度严谨,并以一个结构化的JSON对象返回你的完整分析,不能有任何额外的文字。
2
+
3
+ {{CRITERIA_SECTION}}
4
+
5
+ ### **第三部分:输出格式 (必须严格遵守)**
6
+
7
+ 你的输出必须是以下格式的单个 JSON 对象,不能包含任何额外的注释或解释性文字。
8
+
9
+ ```json
10
+ {
11
+ "prompt_version": "EagleEye-V6.4",
12
+ "is_recommended": boolean,
13
+ "reason": "一句话综合评价。若为有条件推荐,需明确指出:'有条件推荐,卖家画像为顶级个人玩家,但需在购买前向其确认[电池健康度]和[维修历史]等缺失信息。'",
14
+ "risk_tags": ["string"],
15
+ "criteria_analysis": {
16
+ "model_chip": { "status": "string", "comment": "string", "evidence": "string" },
17
+ "battery_health": { "status": "string", "comment": "string", "evidence": "string" },
18
+ "condition": { "status": "string", "comment": "string", "evidence": "string" },
19
+ "history": { "status": "string", "comment": "string", "evidence": "string" },
20
+ "seller_type": {
21
+ "status": "string",
22
+ "persona": "string",
23
+ "comment": "【首要结论】综合性的结论,必须首先点明卖家画像。如果判定为FAIL,必须在此明确指出是基于哪个危险信号以及不符合的逻辑链。",
24
+ "analysis_details": {
25
+ "temporal_analysis": {
26
+ "comment": "关于交易时间间隔和分布的分析结论。",
27
+ "evidence": "例如:交易记录横跨数年,间隔期长,符合个人卖家特征。"
28
+ },
29
+ "selling_behavior": {
30
+ "comment": "关于其售卖商品种类的分析。",
31
+ "evidence": "例如:售卖商品多为个人升级换代的数码产品,逻辑自洽。"
32
+ },
33
+ "buying_behavior": {
34
+ "comment": "【关键】关于其购买历史的分析结论。",
35
+ "evidence": "例如:购买记录显示为游戏盘和生活用品,符合个人消费行为。"
36
+ },
37
+ "behavioral_summary": {
38
+ "comment": "【V6.3 新增】对卖家完整行为逻辑链的最终总结。必须明确回答:这是一个怎样的卖家?其买卖行为是否构成一个可信的个人故事?",
39
+ "evidence": "例如:'该卖家的行为逻辑链完整:早期购买游戏,中期购入相机和镜头,近期开始出售旧款电子设备。这是一个典型的数码产品消费者的成长路径,可信度极高。' 或 '逻辑链断裂:该卖家大量购买维修配件,却声称所有售卖设备均为自用,故事不可信。'"
40
+ }
41
+ }
42
+ },
43
+ "shipping": { "status": "string", "comment": "string", "evidence": "string" },
44
+ "seller_credit": { "status": "string", "comment": "string", "evidence": "string" }
45
+ }
46
+ }
47
+ ```
prompts/macbook_criteria.txt ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ### **第一部分:核心分析原则 (不可违背)**
2
+
3
+ 1. **画像优先原则 (PERSONA-FIRST PRINCIPLE) [V6.3 核心升级]**: 这是解决“高级玩家”与“普通贩子”识别混淆的最高指导原则。在评估卖家时,你的首要任务不是寻找孤立的疑点,而是**构建一个连贯的卖家“行为画像”**。你必须回答核心问题:“这个卖家的所有行为(买、卖、评价、签名)组合起来,讲述的是一个怎样的故事?”
4
+ * **如果故事是连贯的个人行为**(例如,一个热爱数码产品,不断体验、升级、出掉旧设备的发烧友),那么一些表面上的“疑点”(如交易频率略高)可以被合理解释,**不应**作为否决依据。
5
+ * **如果故事是矛盾的、不连贯的,或者明确指向商业行为**(例如,购买记录是配件和坏机,售卖记录却是大量“几乎全新”的同型号机器),那么即便卖家伪装得很好,也必须判定为商家。
6
+
7
+ 2. **一票否决硬性原则 (HARD DEAL-BREAKER RULES)**: 以下是必须严格遵守的否决条件。任何一项不满足,`is_recommended` 必须立即判定为 `false`。
8
+ * **型号/芯片**: 必须是 **MacBook Air** 且明确为 **M1 芯片**。
9
+ * **卖家信用**: `卖家信用等级` 必须是 **'卖家信用极好'**。
10
+ * **邮寄方式**: 必须 **支持邮寄**。
11
+ * **电池健康硬性门槛**: 若明确提供了电池健康度,其数值 **`必须 ≥ 90%`**。
12
+ * **【V6.4 逻辑修正】机器历史**: **不得出现**任何“维修过”、“更换过部件”、“有暗病”等明确表示有拆修历史的描述。
13
+
14
+ 3. **图片至上原则 (IMAGE-FIRST PRINCIPLE)**: 如果图片信息(如截图)与文本描述冲突,**必须以图片信息为最终裁决依据**。
15
+
16
+ 4. **【V6.4 逻辑修正】信息缺失处理原则 (MISSING-INFO HANDLING)**: 对于可后天询问的关键信息(特指**电池健康度**和**维修历史**),若完全未找到,状态应为 `NEEDS_MANUAL_CHECK`,这**不直接导致否决**。如果卖家画像极为优秀,可以进行“有条件推荐”。
17
+
18
+ ---
19
+
20
+ ### **第二部分:详细分析指南**
21
+
22
+ **A. 商品本身评估 (Criteria Analysis):**
23
+
24
+ 1. **型号芯片 (`model_chip`)**: 核对所有文本和图片。非 MacBook Air M1 则 `FAIL`。
25
+ 2. **电池健康 (`battery_health`)**: 健康度 ≥ 90%。若无信息,则为 `NEEDS_MANUAL_CHECK`。
26
+ 3. **成色外观 (`condition`)**: 最多接受“细微划痕”。仔细审查图片四角、A/D面。
27
+ 4. **【V6.4 逻辑修正】机器历史 (`history`)**: 严格审查所有文本和图片,寻找“换过”、“维修”、“拆过”、“进水”、“功能不正常”等负面描述。**若完全未提及,则状态为 `NEEDS_MANUAL_CHECK`**;若有任何拆修证据,则为 `FAIL`。
28
+
29
+ **B. 卖家与市场评估 (核心)**
30
+
31
+ 5. **卖家背景深度分析 (`seller_type`) - [决定性评估]**:
32
+ * **核心目标**: 运用“画像优先原则”,判定卖家是【个人玩家】还是【商家/贩子】。
33
+ * **【V6.3 升级】危险信号清单 (Red Flag List) 及豁免条款**:
34
+ * **交易频率**: 短期内有密集交易。
35
+ * **【发烧友豁免条款】**: 如果交易记录时间跨度长(如超过2年),且买卖行为能形成“体验-升级-出售”的逻辑闭环,则此条不适用。一个长期发烧友在几年内有数十次交易是正常的。
36
+ * **商品垂直度**: 发布的商品高度集中于某一特定型号或品类。
37
+ * **【发烧友豁免条款】**: 如果卖家是该领域的深度玩家(例如,从他的购买记录、评价和发言能看出),专注于某个系列是其专业性的体现。关键看他是在“玩”还是在“出货”。
38
+ * **“行话”**: 描述中出现“同行、工作室、拿货、量大从优”等术语。
39
+ * **【无豁免】**: 此为强烈的商家信号。
40
+ * **物料购买**: 购买记录中出现批量配件、维修工具、坏机等。
41
+ * **【无豁免】**: 此为决定性的商家证据。
42
+ * **图片/标题风格**: 图片背景高度统一、专业;或标题模板化。
43
+ * **【发烧友豁免条款】**: 如果卖家追求完美,有自己的“摄影棚”或固定角落来展示他心爱的物品,这是加分项。关键看图片传递的是“爱惜感”还是“商品感”。
44
+
45
+ 6. **邮寄方式 (`shipping`)**: 明确“仅限xx地面交/自提”则 `FAIL`。
46
+ 7. **卖家信用 (`seller_credit`)**: `卖家信用等级` 必须为 **'卖家信用极好'**。
pyproject.toml ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [tool.pytest.ini_options]
2
+ addopts = "-v --tb=short"
3
+ testpaths = ["tests"]
4
+ python_files = ["test_*.py"]
5
+ python_classes = ["Test*"]
6
+ python_functions = ["test_*"]
7
+
8
+ [tool.coverage.run]
9
+ source = ["src"]
10
+
11
+ [tool.coverage.report]
12
+ exclude_lines = [
13
+ "pragma: no cover",
14
+ "def __repr__",
15
+ "raise AssertionError",
16
+ "raise NotImplementedError",
17
+ "if __name__ == .__main__.:",
18
+ ]
requirements.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ python-dotenv
2
+ playwright
3
+ requests
4
+ openai
5
+ fastapi
6
+ uvicorn[standard]
7
+ pydantic-settings
8
+ jinja2
9
+ aiofiles
10
+ python-socks
11
+ apscheduler
12
+ httpx[socks]
13
+ Pillow
14
+ pyzbar
15
+ qrcode
16
+ pytest
17
+ pytest-asyncio
18
+ coverage
spider_v2.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import sys
3
+ import os
4
+ import argparse
5
+ import json
6
+ import signal
7
+ import contextlib
8
+
9
+ from src.config import STATE_FILE
10
+ from src.scraper import scrape_xianyu
11
+
12
+
13
+ async def main():
14
+ parser = argparse.ArgumentParser(
15
+ description="闲鱼商品监控脚本,支持多任务配置和实时AI分析。",
16
+ epilog="""
17
+ 使用示例:
18
+ # 运行 config.json 中定义的所有任务
19
+ python spider_v2.py
20
+
21
+ # 只运行名为 "Sony A7M4" 的任务 (通常由调度器调用)
22
+ python spider_v2.py --task-name "Sony A7M4"
23
+
24
+ # 调试模式: 运行所有任务,但每个任务只处理前3个新发现的商品
25
+ python spider_v2.py --debug-limit 3
26
+ """,
27
+ formatter_class=argparse.RawDescriptionHelpFormatter
28
+ )
29
+ parser.add_argument("--debug-limit", type=int, default=0, help="调试模式:每个任务仅处理前 N 个新商品(0 表示无限制)")
30
+ parser.add_argument("--config", type=str, default="config.json", help="指定任务配置文件路径(默认为 config.json)")
31
+ parser.add_argument("--task-name", type=str, help="只运行指定名称的单个任务 (用于定时任务调度)")
32
+ args = parser.parse_args()
33
+
34
+ if not os.path.exists(args.config):
35
+ sys.exit(f"错误: 配置文件 '{args.config}' 不存在。")
36
+
37
+ try:
38
+ with open(args.config, 'r', encoding='utf-8') as f:
39
+ tasks_config = json.load(f)
40
+ except (json.JSONDecodeError, IOError) as e:
41
+ sys.exit(f"错误: 读取或解析配置文件 '{args.config}' 失败: {e}")
42
+
43
+ def has_bound_account(tasks: list) -> bool:
44
+ for task in tasks:
45
+ account = task.get("account_state_file")
46
+ if isinstance(account, str) and account.strip():
47
+ return True
48
+ return False
49
+
50
+ def has_any_state_file() -> bool:
51
+ state_dir = os.getenv("ACCOUNT_STATE_DIR", "state").strip().strip('"').strip("'")
52
+ if os.path.isdir(state_dir):
53
+ for name in os.listdir(state_dir):
54
+ if name.endswith(".json"):
55
+ return True
56
+ return False
57
+
58
+ if not os.path.exists(STATE_FILE) and not has_bound_account(tasks_config) and not has_any_state_file():
59
+ sys.exit(
60
+ f"错误: 未找到登录状态文件。请在 state/ 中添加账号或配置 account_state_file。"
61
+ )
62
+
63
+ # 读取所有prompt文件内容
64
+ for task in tasks_config:
65
+ if task.get("enabled", False) and task.get("ai_prompt_base_file") and task.get("ai_prompt_criteria_file"):
66
+ try:
67
+ with open(task["ai_prompt_base_file"], 'r', encoding='utf-8') as f_base:
68
+ base_prompt = f_base.read()
69
+ with open(task["ai_prompt_criteria_file"], 'r', encoding='utf-8') as f_criteria:
70
+ criteria_text = f_criteria.read()
71
+
72
+ # 动态组合成最终的Prompt
73
+ task['ai_prompt_text'] = base_prompt.replace("{{CRITERIA_SECTION}}", criteria_text)
74
+
75
+ # 验证生成的prompt是否有效
76
+ if len(task['ai_prompt_text']) < 100:
77
+ print(f"警告: 任务 '{task['task_name']}' 生成的prompt过短 ({len(task['ai_prompt_text'])} 字符),可能存在问题。")
78
+ elif "{{CRITERIA_SECTION}}" in task['ai_prompt_text']:
79
+ print(f"警告: 任务 '{task['task_name']}' 的prompt中仍包含占位符,替换可能失败。")
80
+ else:
81
+ print(f"✅ 任务 '{task['task_name']}' 的prompt生成成功,长度: {len(task['ai_prompt_text'])} 字符")
82
+
83
+ except FileNotFoundError as e:
84
+ print(f"警告: 任务 '{task['task_name']}' 的prompt文件缺失: {e},该任务的AI分析将被跳过。")
85
+ task['ai_prompt_text'] = ""
86
+ except Exception as e:
87
+ print(f"错误: 任务 '{task['task_name']}' 处理prompt文件时发生异常: {e},该任务的AI分析将被跳过。")
88
+ task['ai_prompt_text'] = ""
89
+ elif task.get("enabled", False) and task.get("ai_prompt_file"):
90
+ try:
91
+ with open(task["ai_prompt_file"], 'r', encoding='utf-8') as f:
92
+ task['ai_prompt_text'] = f.read()
93
+ print(f"✅ 任务 '{task['task_name']}' 的prompt文件读取成功,长度: {len(task['ai_prompt_text'])} 字符")
94
+ except FileNotFoundError:
95
+ print(f"警告: 任务 '{task['task_name']}' 的prompt文件 '{task['ai_prompt_file']}' 未找到,该任务的AI分析将被跳过。")
96
+ task['ai_prompt_text'] = ""
97
+ except Exception as e:
98
+ print(f"错误: 任务 '{task['task_name']}' 读取prompt文件时发生异常: {e},该任务的AI分析将被跳过。")
99
+ task['ai_prompt_text'] = ""
100
+
101
+ print("\n--- 开始执行监控任务 ---")
102
+ if args.debug_limit > 0:
103
+ print(f"** 调试模式已激活,每个任务最多处理 {args.debug_limit} 个新商品 **")
104
+
105
+ if args.task_name:
106
+ print(f"** 定时任务模式:只执行任务 '{args.task_name}' **")
107
+
108
+ print("--------------------")
109
+
110
+ active_task_configs = []
111
+ if args.task_name:
112
+ # 如果指定了任务名称,只查找该任务
113
+ task_found = next((task for task in tasks_config if task.get('task_name') == args.task_name), None)
114
+ if task_found:
115
+ if task_found.get("enabled", False):
116
+ active_task_configs.append(task_found)
117
+ else:
118
+ print(f"任务 '{args.task_name}' 已被禁用,跳过执行。")
119
+ else:
120
+ print(f"错误:在配置文件中未找到名为 '{args.task_name}' 的任务。")
121
+ return
122
+ else:
123
+ # 否则,按原计划加载所有启用的任务
124
+ active_task_configs = [task for task in tasks_config if task.get("enabled", False)]
125
+
126
+ if not active_task_configs:
127
+ print("没有需要执行的任务,程序退出。")
128
+ return
129
+
130
+ # 为每个启用的任务创建一个异步执行协程
131
+ stop_event = asyncio.Event()
132
+ loop = asyncio.get_running_loop()
133
+ for sig in (signal.SIGTERM, signal.SIGINT):
134
+ try:
135
+ loop.add_signal_handler(sig, stop_event.set)
136
+ except NotImplementedError:
137
+ pass
138
+
139
+ tasks = []
140
+ for task_conf in active_task_configs:
141
+ print(f"-> 任务 '{task_conf['task_name']}' 已加入执行队列。")
142
+ tasks.append(asyncio.create_task(scrape_xianyu(task_config=task_conf, debug_limit=args.debug_limit)))
143
+
144
+ async def _shutdown_watcher():
145
+ await stop_event.wait()
146
+ print("\n收到终止信号,正在优雅退出,取消所有爬虫任务...")
147
+ for t in tasks:
148
+ if not t.done():
149
+ t.cancel()
150
+
151
+ shutdown_task = asyncio.create_task(_shutdown_watcher())
152
+
153
+ try:
154
+ # 并发执行所有任务
155
+ results = await asyncio.gather(*tasks, return_exceptions=True)
156
+ finally:
157
+ shutdown_task.cancel()
158
+ with contextlib.suppress(asyncio.CancelledError):
159
+ await shutdown_task
160
+
161
+ print("\n--- 所有任务执行完毕 ---")
162
+ for i, result in enumerate(results):
163
+ task_name = active_task_configs[i]['task_name']
164
+ if isinstance(result, Exception):
165
+ print(f"任务 '{task_name}' 因异常而终止: {result}")
166
+ else:
167
+ print(f"任务 '{task_name}' 正常结束,本次运行共处理了 {result} 个新商品。")
168
+
169
+ if __name__ == "__main__":
170
+ asyncio.run(main())
src/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # This file makes src a Python package
src/ai_handler.py ADDED
@@ -0,0 +1,701 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import base64
3
+ import json
4
+ import os
5
+ import re
6
+ import sys
7
+ import shutil
8
+ from datetime import datetime, timedelta
9
+ from urllib.parse import urlencode, urlparse, urlunparse, parse_qsl
10
+
11
+ import requests
12
+
13
+ # 设置标准输出编码为UTF-8,解决Windows控制台编码问题
14
+ if sys.platform.startswith('win'):
15
+ import codecs
16
+ sys.stdout = codecs.getwriter('utf-8')(sys.stdout.detach())
17
+ sys.stderr = codecs.getwriter('utf-8')(sys.stderr.detach())
18
+
19
+ from src.config import (
20
+ AI_DEBUG_MODE,
21
+ IMAGE_DOWNLOAD_HEADERS,
22
+ IMAGE_SAVE_DIR,
23
+ TASK_IMAGE_DIR_PREFIX,
24
+ MODEL_NAME,
25
+ NTFY_TOPIC_URL,
26
+ GOTIFY_URL,
27
+ GOTIFY_TOKEN,
28
+ BARK_URL,
29
+ PCURL_TO_MOBILE,
30
+ WX_BOT_URL,
31
+ TELEGRAM_BOT_TOKEN,
32
+ TELEGRAM_CHAT_ID,
33
+ WEBHOOK_URL,
34
+ WEBHOOK_METHOD,
35
+ WEBHOOK_HEADERS,
36
+ WEBHOOK_CONTENT_TYPE,
37
+ WEBHOOK_QUERY_PARAMETERS,
38
+ WEBHOOK_BODY,
39
+ ENABLE_RESPONSE_FORMAT,
40
+ client,
41
+ )
42
+ from src.utils import convert_goofish_link, retry_on_failure
43
+
44
+
45
+ def safe_print(text):
46
+ """安全的打印函数,处理编码错误"""
47
+ try:
48
+ print(text)
49
+ except UnicodeEncodeError:
50
+ # 如果遇到编码错误,尝试用ASCII编码并忽略无法编码的字符
51
+ try:
52
+ print(text.encode('ascii', errors='ignore').decode('ascii'))
53
+ except:
54
+ # 如果还是失败,打印一个简化的消息
55
+ print("[输出包含无法显示的字符]")
56
+
57
+
58
+ @retry_on_failure(retries=2, delay=3)
59
+ async def _download_single_image(url, save_path):
60
+ """一个带重试的内部函数,用于异步下载单个图片。"""
61
+ loop = asyncio.get_running_loop()
62
+ # 使用 run_in_executor 运行同步的 requests 代码,避免阻塞事件循环
63
+ response = await loop.run_in_executor(
64
+ None,
65
+ lambda: requests.get(url, headers=IMAGE_DOWNLOAD_HEADERS, timeout=20, stream=True)
66
+ )
67
+ response.raise_for_status()
68
+ with open(save_path, 'wb') as f:
69
+ for chunk in response.iter_content(chunk_size=8192):
70
+ f.write(chunk)
71
+ return save_path
72
+
73
+
74
+ async def download_all_images(product_id, image_urls, task_name="default"):
75
+ """异步下载一个商品的所有图片。如果图片已存在则跳过。支持任务隔离。"""
76
+ if not image_urls:
77
+ return []
78
+
79
+ # 为每个任务创建独立的图片目录
80
+ task_image_dir = os.path.join(IMAGE_SAVE_DIR, f"{TASK_IMAGE_DIR_PREFIX}{task_name}")
81
+ os.makedirs(task_image_dir, exist_ok=True)
82
+
83
+ urls = [url.strip() for url in image_urls if url.strip().startswith('http')]
84
+ if not urls:
85
+ return []
86
+
87
+ saved_paths = []
88
+ total_images = len(urls)
89
+ for i, url in enumerate(urls):
90
+ try:
91
+ clean_url = url.split('.heic')[0] if '.heic' in url else url
92
+ file_name_base = os.path.basename(clean_url).split('?')[0]
93
+ file_name = f"product_{product_id}_{i + 1}_{file_name_base}"
94
+ file_name = re.sub(r'[\\/*?:"<>|]', "", file_name)
95
+ if not os.path.splitext(file_name)[1]:
96
+ file_name += ".jpg"
97
+
98
+ save_path = os.path.join(task_image_dir, file_name)
99
+
100
+ if os.path.exists(save_path):
101
+ safe_print(f" [图片] 图片 {i + 1}/{total_images} 已存在,跳过下载: {os.path.basename(save_path)}")
102
+ saved_paths.append(save_path)
103
+ continue
104
+
105
+ safe_print(f" [图片] 正在下载图片 {i + 1}/{total_images}: {url}")
106
+ if await _download_single_image(url, save_path):
107
+ safe_print(f" [图片] 图片 {i + 1}/{total_images} 已成功下载到: {os.path.basename(save_path)}")
108
+ saved_paths.append(save_path)
109
+ except Exception as e:
110
+ safe_print(f" [图片] 处理图片 {url} 时发生错误,已跳过此图: {e}")
111
+
112
+ return saved_paths
113
+
114
+
115
+ def cleanup_task_images(task_name):
116
+ """清理指定任务的图片目录"""
117
+ task_image_dir = os.path.join(IMAGE_SAVE_DIR, f"{TASK_IMAGE_DIR_PREFIX}{task_name}")
118
+ if os.path.exists(task_image_dir):
119
+ try:
120
+ shutil.rmtree(task_image_dir)
121
+ safe_print(f" [清理] 已删除任务 '{task_name}' 的临时图片目录: {task_image_dir}")
122
+ except Exception as e:
123
+ safe_print(f" [清理] 删除任务 '{task_name}' 的临时图片目录时出错: {e}")
124
+ else:
125
+ safe_print(f" [清理] 任务 '{task_name}' 的临时图片目录不存在: {task_image_dir}")
126
+
127
+
128
+ def cleanup_ai_logs(logs_dir: str, keep_days: int = 1) -> None:
129
+ try:
130
+ cutoff = datetime.now() - timedelta(days=keep_days)
131
+ for filename in os.listdir(logs_dir):
132
+ if not filename.endswith(".log"):
133
+ continue
134
+ try:
135
+ timestamp = datetime.strptime(filename[:15], "%Y%m%d_%H%M%S")
136
+ except ValueError:
137
+ continue
138
+ if timestamp < cutoff:
139
+ os.remove(os.path.join(logs_dir, filename))
140
+ except Exception as e:
141
+ safe_print(f" [日志] 清理AI日志时出错: {e}")
142
+
143
+
144
+ def encode_image_to_base64(image_path):
145
+ """将本地图片文件编码为 Base64 字符串。"""
146
+ if not image_path or not os.path.exists(image_path):
147
+ return None
148
+ try:
149
+ with open(image_path, "rb") as image_file:
150
+ return base64.b64encode(image_file.read()).decode('utf-8')
151
+ except Exception as e:
152
+ safe_print(f"编码图片时出错: {e}")
153
+ return None
154
+
155
+
156
+ def validate_ai_response_format(parsed_response):
157
+ """验证AI响应的格式是否符合预期结构"""
158
+ required_fields = [
159
+ "prompt_version",
160
+ "is_recommended",
161
+ "reason",
162
+ "risk_tags",
163
+ "criteria_analysis"
164
+ ]
165
+
166
+ # 检查顶层字段
167
+ for field in required_fields:
168
+ if field not in parsed_response:
169
+ safe_print(f" [AI分析] 警告:响应缺少必需字段 '{field}'")
170
+ return False
171
+
172
+ # 检查criteria_analysis是否为字典且不为空
173
+ criteria_analysis = parsed_response.get("criteria_analysis", {})
174
+ if not isinstance(criteria_analysis, dict) or not criteria_analysis:
175
+ safe_print(" [AI分析] 警告:criteria_analysis必须是非空字典")
176
+ return False
177
+
178
+ # 检查seller_type字段(所有商品都需要)
179
+ if "seller_type" not in criteria_analysis:
180
+ safe_print(" [AI分析] 警告:criteria_analysis缺少必需字段 'seller_type'")
181
+ return False
182
+
183
+ # 检查数据类型
184
+ if not isinstance(parsed_response.get("is_recommended"), bool):
185
+ safe_print(" [AI分析] 警告:is_recommended字段不是布尔类型")
186
+ return False
187
+
188
+ if not isinstance(parsed_response.get("risk_tags"), list):
189
+ safe_print(" [AI分析] 警告:risk_tags字段不是列表类型")
190
+ return False
191
+
192
+ return True
193
+
194
+
195
+ @retry_on_failure(retries=3, delay=5)
196
+ async def send_ntfy_notification(product_data, reason):
197
+ """当发现推荐商品时,异步发送一个高优先级的 ntfy.sh 通知。"""
198
+ if not NTFY_TOPIC_URL and not WX_BOT_URL and not (GOTIFY_URL and GOTIFY_TOKEN) and not BARK_URL and not (TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID) and not WEBHOOK_URL:
199
+ safe_print("警告:未在 .env 文件中配置任何通知服务 (NTFY_TOPIC_URL, WX_BOT_URL, GOTIFY_URL/TOKEN, BARK_URL, TELEGRAM_BOT_TOKEN/CHAT_ID, WEBHOOK_URL),跳过通知。")
200
+ return
201
+
202
+ title = product_data.get('商品标题', 'N/A')
203
+ price = product_data.get('当前售价', 'N/A')
204
+ link = product_data.get('商品链接', '#')
205
+ if PCURL_TO_MOBILE:
206
+ mobile_link = convert_goofish_link(link)
207
+ message = f"价格: {price}\n原因: {reason}\n手机端链接: {mobile_link}\n电脑端链接: {link}"
208
+ else:
209
+ message = f"价格: {price}\n原因: {reason}\n链接: {link}"
210
+
211
+ notification_title = f"🚨 新推荐! {title[:30]}..."
212
+
213
+ # --- 发送 ntfy 通知 ---
214
+ if NTFY_TOPIC_URL:
215
+ try:
216
+ safe_print(f" -> 正在发送 ntfy 通知到: {NTFY_TOPIC_URL}")
217
+ loop = asyncio.get_running_loop()
218
+ await loop.run_in_executor(
219
+ None,
220
+ lambda: requests.post(
221
+ NTFY_TOPIC_URL,
222
+ data=message.encode('utf-8'),
223
+ headers={
224
+ "Title": notification_title.encode('utf-8'),
225
+ "Priority": "urgent",
226
+ "Tags": "bell,vibration"
227
+ },
228
+ timeout=10
229
+ )
230
+ )
231
+ safe_print(" -> ntfy 通知发送成功。")
232
+ except Exception as e:
233
+ safe_print(f" -> 发送 ntfy 通知失败: {e}")
234
+
235
+ # --- 发送 Gotify 通知 ---
236
+ if GOTIFY_URL and GOTIFY_TOKEN:
237
+ try:
238
+ safe_print(f" -> 正在发送 Gotify 通知到: {GOTIFY_URL}")
239
+ # Gotify uses multipart/form-data
240
+ payload = {
241
+ 'title': (None, notification_title),
242
+ 'message': (None, message),
243
+ 'priority': (None, '5')
244
+ }
245
+
246
+ gotify_url_with_token = f"{GOTIFY_URL}/message?token={GOTIFY_TOKEN}"
247
+
248
+ loop = asyncio.get_running_loop()
249
+ response = await loop.run_in_executor(
250
+ None,
251
+ lambda: requests.post(
252
+ gotify_url_with_token,
253
+ files=payload,
254
+ timeout=10
255
+ )
256
+ )
257
+ response.raise_for_status()
258
+ safe_print(" -> Gotify 通知发送成功。")
259
+ except requests.exceptions.RequestException as e:
260
+ safe_print(f" -> 发送 Gotify 通知失败: {e}")
261
+ except Exception as e:
262
+ safe_print(f" -> 发送 Gotify 通知时发生未知错误: {e}")
263
+
264
+ # --- 发送 Bark 通知 ---
265
+ if BARK_URL:
266
+ try:
267
+ safe_print(f" -> 正在发送 Bark 通知...")
268
+
269
+ bark_payload = {
270
+ "title": notification_title,
271
+ "body": message,
272
+ "level": "timeSensitive",
273
+ "group": "闲鱼监控"
274
+ }
275
+
276
+ link_to_use = convert_goofish_link(link) if PCURL_TO_MOBILE else link
277
+ bark_payload["url"] = link_to_use
278
+
279
+ # Add icon if available
280
+ main_image = product_data.get('商品主图链接')
281
+ if not main_image:
282
+ # Fallback to image list if main image not present
283
+ image_list = product_data.get('商品图片列表', [])
284
+ if image_list:
285
+ main_image = image_list[0]
286
+
287
+ if main_image:
288
+ bark_payload['icon'] = main_image
289
+
290
+ headers = { "Content-Type": "application/json; charset=utf-8" }
291
+ loop = asyncio.get_running_loop()
292
+ response = await loop.run_in_executor(
293
+ None,
294
+ lambda: requests.post(
295
+ BARK_URL,
296
+ json=bark_payload,
297
+ headers=headers,
298
+ timeout=10
299
+ )
300
+ )
301
+ response.raise_for_status()
302
+ safe_print(" -> Bark 通知发送成功。")
303
+ except requests.exceptions.RequestException as e:
304
+ safe_print(f" -> 发送 Bark 通知失败: {e}")
305
+ except Exception as e:
306
+ safe_print(f" -> 发送 Bark 通知时发生未知错误: {e}")
307
+
308
+ # --- 发送企业微信机器人通知 ---
309
+ if WX_BOT_URL:
310
+ # 将消息转换为Markdown格式,使链接可点击
311
+ lines = message.split('\n')
312
+ markdown_content = f"## {notification_title}\n\n"
313
+
314
+ for line in lines:
315
+ if line.startswith('手机端链接:') or line.startswith('电脑端链接:') or line.startswith('链接:'):
316
+ # 提取链接部分并转换为Markdown超链接
317
+ if ':' in line:
318
+ label, url = line.split(':', 1)
319
+ url = url.strip()
320
+ if url and url != '#':
321
+ markdown_content += f"- **{label}:** [{url}]({url})\n"
322
+ else:
323
+ markdown_content += f"- **{label}:** 暂无链接\n"
324
+ else:
325
+ markdown_content += f"- {line}\n"
326
+ else:
327
+ # 其他行保持原样
328
+ if line:
329
+ markdown_content += f"- {line}\n"
330
+ else:
331
+ markdown_content += "\n"
332
+
333
+ payload = {
334
+ "msgtype": "markdown",
335
+ "markdown": {
336
+ "content": markdown_content
337
+ }
338
+ }
339
+
340
+ try:
341
+ safe_print(f" -> 正在发送企业微信通知到: {WX_BOT_URL}")
342
+ headers = { "Content-Type": "application/json" }
343
+ loop = asyncio.get_running_loop()
344
+ response = await loop.run_in_executor(
345
+ None,
346
+ lambda: requests.post(
347
+ WX_BOT_URL,
348
+ json=payload,
349
+ headers=headers,
350
+ timeout=10
351
+ )
352
+ )
353
+ response.raise_for_status()
354
+ result = response.json()
355
+ safe_print(f" -> 企业微信通知发送成功。响应: {result}")
356
+ except requests.exceptions.RequestException as e:
357
+ safe_print(f" -> 发送企业微信通知失败: {e}")
358
+ except Exception as e:
359
+ safe_print(f" -> 发送企业微信通知时发生未知错误: {e}")
360
+
361
+ # --- 发送 Telegram 机器人通知 ---
362
+ if TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID:
363
+ try:
364
+ safe_print(f" -> 正在发送 Telegram 通知...")
365
+
366
+ # 构建 Telegram API URL
367
+ telegram_api_url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
368
+
369
+ # 格式化消息内容
370
+ telegram_message = f"🚨 <b>新推荐!</b>\n\n"
371
+ telegram_message += f"<b>{title[:50]}...</b>\n\n"
372
+ telegram_message += f"💰 价格: {price}\n"
373
+ telegram_message += f"📝 原因: {reason}\n"
374
+
375
+ # 添加链接
376
+ if PCURL_TO_MOBILE:
377
+ mobile_link = convert_goofish_link(link)
378
+ telegram_message += f"📱 <a href='{mobile_link}'>手机端链接</a>\n"
379
+ telegram_message += f"💻 <a href='{link}'>电脑端链接</a>"
380
+
381
+ # 构建请求负载
382
+ telegram_payload = {
383
+ "chat_id": TELEGRAM_CHAT_ID,
384
+ "text": telegram_message,
385
+ "parse_mode": "HTML",
386
+ "disable_web_page_preview": False
387
+ }
388
+
389
+ headers = {"Content-Type": "application/json"}
390
+ loop = asyncio.get_running_loop()
391
+ response = await loop.run_in_executor(
392
+ None,
393
+ lambda: requests.post(
394
+ telegram_api_url,
395
+ json=telegram_payload,
396
+ headers=headers,
397
+ timeout=10
398
+ )
399
+ )
400
+ response.raise_for_status()
401
+ result = response.json()
402
+ if result.get("ok"):
403
+ safe_print(" -> Telegram 通知发送成功。")
404
+ else:
405
+ safe_print(f" -> Telegram 通知发送失败: {result.get('description', '未知错误')}")
406
+ except requests.exceptions.RequestException as e:
407
+ safe_print(f" -> 发送 Telegram 通知失败: {e}")
408
+ except Exception as e:
409
+ safe_print(f" -> 发送 Telegram 通知时发生未知错误: {e}")
410
+
411
+ # --- 发送通用 Webhook 通知 ---
412
+ if WEBHOOK_URL:
413
+ try:
414
+ safe_print(f" -> 正在发送通用 Webhook 通知到: {WEBHOOK_URL}")
415
+
416
+ # 替换占位符
417
+ def replace_placeholders(template_str):
418
+ if not template_str:
419
+ return ""
420
+ # 对内容进行JSON转义,避免换行符和特殊字符破坏JSON格式
421
+ safe_title = json.dumps(notification_title, ensure_ascii=False)[1:-1] # 去掉外层引号
422
+ safe_content = json.dumps(message, ensure_ascii=False)[1:-1] # 去掉外层引号
423
+ # 同时支持旧的${title}${content}和新的{{title}}{{content}}格式
424
+ return template_str.replace("${title}", safe_title).replace("${content}", safe_content).replace("{{title}}", safe_title).replace("{{content}}", safe_content)
425
+
426
+ # 准备请求头
427
+ headers = {}
428
+ if WEBHOOK_HEADERS:
429
+ try:
430
+ headers = json.loads(WEBHOOK_HEADERS)
431
+ except json.JSONDecodeError:
432
+ safe_print(f" -> [警告] Webhook 请求头格式错误,请检查 .env 中的 WEBHOOK_HEADERS。")
433
+
434
+ loop = asyncio.get_running_loop()
435
+
436
+ if WEBHOOK_METHOD == "GET":
437
+ # 准备查询参数
438
+ final_url = WEBHOOK_URL
439
+ if WEBHOOK_QUERY_PARAMETERS:
440
+ try:
441
+ params_str = replace_placeholders(WEBHOOK_QUERY_PARAMETERS)
442
+ params = json.loads(params_str)
443
+
444
+ # 解析原始URL并追加新参数
445
+ url_parts = list(urlparse(final_url))
446
+ query = dict(parse_qsl(url_parts[4]))
447
+ query.update(params)
448
+ url_parts[4] = urlencode(query)
449
+ final_url = urlunparse(url_parts)
450
+ except json.JSONDecodeError:
451
+ safe_print(f" -> [警告] Webhook 查询参数格式错误,请检查 .env 中的 WEBHOOK_QUERY_PARAMETERS。")
452
+
453
+ response = await loop.run_in_executor(
454
+ None,
455
+ lambda: requests.get(final_url, headers=headers, timeout=15)
456
+ )
457
+
458
+ elif WEBHOOK_METHOD == "POST":
459
+ # 准备URL(处理查询参数)
460
+ final_url = WEBHOOK_URL
461
+ if WEBHOOK_QUERY_PARAMETERS:
462
+ try:
463
+ params_str = replace_placeholders(WEBHOOK_QUERY_PARAMETERS)
464
+ params = json.loads(params_str)
465
+
466
+ # 解析原始URL并追加新参数
467
+ url_parts = list(urlparse(final_url))
468
+ query = dict(parse_qsl(url_parts[4]))
469
+ query.update(params)
470
+ url_parts[4] = urlencode(query)
471
+ final_url = urlunparse(url_parts)
472
+ except json.JSONDecodeError:
473
+ safe_print(f" -> [警告] Webhook 查询参数格式错误,请检查 .env 中的 WEBHOOK_QUERY_PARAMETERS。")
474
+
475
+ # 准备请求体
476
+ data = None
477
+ json_payload = None
478
+
479
+ if WEBHOOK_BODY:
480
+ body_str = replace_placeholders(WEBHOOK_BODY)
481
+ try:
482
+ if WEBHOOK_CONTENT_TYPE == "JSON":
483
+ json_payload = json.loads(body_str)
484
+ if 'Content-Type' not in headers and 'content-type' not in headers:
485
+ headers['Content-Type'] = 'application/json; charset=utf-8'
486
+ elif WEBHOOK_CONTENT_TYPE == "FORM":
487
+ data = json.loads(body_str) # requests会处理url-encoding
488
+ if 'Content-Type' not in headers and 'content-type' not in headers:
489
+ headers['Content-Type'] = 'application/x-www-form-urlencoded'
490
+ else:
491
+ safe_print(f" -> [警告] 不支持的 WEBHOOK_CONTENT_TYPE: {WEBHOOK_CONTENT_TYPE}。")
492
+ except json.JSONDecodeError:
493
+ safe_print(f" -> [警告] Webhook 请求体格式错误,请检查 .env 中的 WEBHOOK_BODY。")
494
+
495
+ response = await loop.run_in_executor(
496
+ None,
497
+ lambda: requests.post(final_url, headers=headers, json=json_payload, data=data, timeout=15)
498
+ )
499
+ else:
500
+ safe_print(f" -> [警告] 不支持的 WEBHOOK_METHOD: {WEBHOOK_METHOD}。")
501
+ return
502
+
503
+ response.raise_for_status()
504
+ safe_print(f" -> Webhook 通知发送成功。状态码: {response.status_code}")
505
+
506
+ except requests.exceptions.RequestException as e:
507
+ safe_print(f" -> 发送 Webhook 通知失败: {e}")
508
+ except Exception as e:
509
+ safe_print(f" -> 发送 Webhook 通知时发生未知错误: {e}")
510
+
511
+
512
+ @retry_on_failure(retries=3, delay=5)
513
+ async def get_ai_analysis(product_data, image_paths=None, prompt_text=""):
514
+ """将完整的商品JSON数据和所有图片发送给 AI 进行分析(异步)。"""
515
+ if not client:
516
+ safe_print(" [AI分析] 错误:AI客户端未初始化,跳过分析。")
517
+ return None
518
+
519
+ item_info = product_data.get('商品信息', {})
520
+ product_id = item_info.get('商品ID', 'N/A')
521
+
522
+ safe_print(f"\n [AI分析] 开始分析商品 #{product_id} (含 {len(image_paths or [])} 张图片)...")
523
+ safe_print(f" [AI分析] 标题: {item_info.get('商品标题', '无')}")
524
+
525
+ if not prompt_text:
526
+ safe_print(" [AI分析] 错误:未提供AI分析所需的prompt文本。")
527
+ return None
528
+
529
+ product_details_json = json.dumps(product_data, ensure_ascii=False, indent=2)
530
+ system_prompt = prompt_text
531
+
532
+ if AI_DEBUG_MODE:
533
+ safe_print("\n--- [AI DEBUG] ---")
534
+ safe_print("--- PRODUCT DATA (JSON) ---")
535
+ safe_print(product_details_json)
536
+ safe_print("--- PROMPT TEXT (完整内容) ---")
537
+ safe_print(prompt_text)
538
+ safe_print("-------------------\n")
539
+
540
+ combined_text_prompt = f"""请基于你的专业知识和我的要求,分析以下完整的商品JSON数据:
541
+
542
+ ```json
543
+ {product_details_json}
544
+ ```
545
+
546
+ {system_prompt}
547
+ """
548
+ user_content_list = []
549
+
550
+ # 先添加图片内容
551
+ if image_paths:
552
+ for path in image_paths:
553
+ base64_image = encode_image_to_base64(path)
554
+ if base64_image:
555
+ user_content_list.append(
556
+ {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}})
557
+
558
+ # 再添加文本内容
559
+ user_content_list.append({"type": "text", "text": combined_text_prompt})
560
+
561
+ messages = [{"role": "user", "content": user_content_list}]
562
+
563
+ # 保存最终传输内容到日志文件
564
+ try:
565
+ # 创建logs文件夹
566
+ logs_dir = os.path.join("logs", "ai")
567
+ os.makedirs(logs_dir, exist_ok=True)
568
+ cleanup_ai_logs(logs_dir, keep_days=1)
569
+
570
+ # 生成日志文件名(当前时间)
571
+ current_time = datetime.now().strftime("%Y%m%d_%H%M%S")
572
+ log_filename = f"{current_time}.log"
573
+ log_filepath = os.path.join(logs_dir, log_filename)
574
+
575
+ task_name = product_data.get("任务名称") or product_data.get("任务名") or "unknown"
576
+ log_payload = {
577
+ "timestamp": current_time,
578
+ "task_name": task_name,
579
+ "product_id": product_id,
580
+ "title": item_info.get("商品标题", "无"),
581
+ "image_count": len(image_paths or []),
582
+ }
583
+ log_content = json.dumps(log_payload, ensure_ascii=False)
584
+
585
+ # 写入日志文件
586
+ with open(log_filepath, 'w', encoding='utf-8') as f:
587
+ f.write(log_content)
588
+
589
+ safe_print(f" [日志] AI分析请求已保存到: {log_filepath}")
590
+
591
+ except Exception as e:
592
+ safe_print(f" [日志] 保存AI分析日志时出错: {e}")
593
+
594
+ # 增强的AI调用,包含更严格的格式控制和重试机制
595
+ max_retries = 3
596
+ for attempt in range(max_retries):
597
+ try:
598
+ # 根据重试次数调整参数
599
+ current_temperature = 0.1 if attempt == 0 else 0.05 # 重试时使用更低的温度
600
+
601
+ from src.config import get_ai_request_params
602
+
603
+ # 构建请求参数,根据ENABLE_RESPONSE_FORMAT决定是否使用response_format
604
+ request_params = {
605
+ "model": MODEL_NAME,
606
+ "messages": messages,
607
+ "temperature": current_temperature,
608
+ "max_tokens": 4000
609
+ }
610
+
611
+ # 只有启用response_format时才添加该参数
612
+ if ENABLE_RESPONSE_FORMAT:
613
+ request_params["response_format"] = {"type": "json_object"}
614
+
615
+ response = await client.chat.completions.create(
616
+ **get_ai_request_params(**request_params)
617
+ )
618
+
619
+ # 兼容不同API响应格式,检查response是否为字符串
620
+ if hasattr(response, 'choices'):
621
+ ai_response_content = response.choices[0].message.content
622
+ else:
623
+ # 如果response是字符串,则直接使用
624
+ ai_response_content = response
625
+
626
+ if AI_DEBUG_MODE:
627
+ safe_print(f"\n--- [AI DEBUG] 第{attempt + 1}次尝试 ---")
628
+ safe_print("--- RAW AI RESPONSE ---")
629
+ safe_print(ai_response_content)
630
+ safe_print("---------------------\n")
631
+
632
+ # 尝试直接解析JSON
633
+ try:
634
+ parsed_response = json.loads(ai_response_content)
635
+
636
+ # 验证响应格式
637
+ if validate_ai_response_format(parsed_response):
638
+ safe_print(f" [AI分析] 第{attempt + 1}次尝试成功,响应格式验证通过")
639
+ return parsed_response
640
+ else:
641
+ safe_print(f" [AI分析] 第{attempt + 1}次尝试格式验证失败")
642
+ if attempt < max_retries - 1:
643
+ safe_print(f" [AI分析] 准备第{attempt + 2}次重试...")
644
+ continue
645
+ else:
646
+ safe_print(" [AI分析] 所有重试完成,使用最后一次结果")
647
+ return parsed_response
648
+
649
+ except json.JSONDecodeError:
650
+ safe_print(f" [AI分析] 第{attempt + 1}次尝试JSON解析失败,尝试清理响应内容...")
651
+
652
+ # 清理可能的Markdown代码块标记
653
+ cleaned_content = ai_response_content.strip()
654
+ if cleaned_content.startswith('```json'):
655
+ cleaned_content = cleaned_content[7:]
656
+ if cleaned_content.startswith('```'):
657
+ cleaned_content = cleaned_content[3:]
658
+ if cleaned_content.endswith('```'):
659
+ cleaned_content = cleaned_content[:-3]
660
+ cleaned_content = cleaned_content.strip()
661
+
662
+ # 寻找JSON对象边界
663
+ json_start_index = cleaned_content.find('{')
664
+ json_end_index = cleaned_content.rfind('}')
665
+
666
+ if json_start_index != -1 and json_end_index != -1 and json_end_index > json_start_index:
667
+ json_str = cleaned_content[json_start_index:json_end_index + 1]
668
+ try:
669
+ parsed_response = json.loads(json_str)
670
+ if validate_ai_response_format(parsed_response):
671
+ safe_print(f" [AI分析] 第{attempt + 1}次尝试清理后成功")
672
+ return parsed_response
673
+ else:
674
+ if attempt < max_retries - 1:
675
+ safe_print(f" [AI分析] 准备第{attempt + 2}次重试...")
676
+ continue
677
+ else:
678
+ safe_print(" [AI分析] 所有重试完成,使用清理后的结果")
679
+ return parsed_response
680
+ except json.JSONDecodeError as e:
681
+ safe_print(f" [AI分析] 第{attempt + 1}次尝试清理后JSON解析仍然失败: {e}")
682
+ if attempt < max_retries - 1:
683
+ safe_print(f" [AI分析] 准备第{attempt + 2}次重试...")
684
+ continue
685
+ else:
686
+ raise e
687
+ else:
688
+ safe_print(f" [AI分析] 第{attempt + 1}次尝试无法在响应中找到有效的JSON对象")
689
+ if attempt < max_retries - 1:
690
+ safe_print(f" [AI分析] 准备第{attempt + 2}次重试...")
691
+ continue
692
+ else:
693
+ raise json.JSONDecodeError("No valid JSON object found", ai_response_content, 0)
694
+
695
+ except Exception as e:
696
+ safe_print(f" [AI分析] 第{attempt + 1}次尝试AI调用失败: {e}")
697
+ if attempt < max_retries - 1:
698
+ safe_print(f" [AI分析] 准备第{attempt + 2}次重试...")
699
+ continue
700
+ else:
701
+ raise e
src/api/__init__.py ADDED
File without changes
src/api/dependencies.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI 依赖注入
3
+ 提供服务实例的创建和管理
4
+ """
5
+ from fastapi import Depends
6
+ from src.services.task_service import TaskService
7
+ from src.services.notification_service import NotificationService
8
+ from src.services.ai_service import AIAnalysisService
9
+ from src.services.process_service import ProcessService
10
+ from src.infrastructure.persistence.json_task_repository import JsonTaskRepository
11
+ from src.infrastructure.external.ai_client import AIClient
12
+ from src.infrastructure.external.notification_clients.ntfy_client import NtfyClient
13
+ from src.infrastructure.external.notification_clients.bark_client import BarkClient
14
+ from src.infrastructure.external.notification_clients.telegram_client import TelegramClient
15
+ from src.infrastructure.config.settings import notification_settings
16
+
17
+
18
+ # 全局 ProcessService 实例(将在 app.py 中设置)
19
+ _process_service_instance = None
20
+
21
+
22
+ def set_process_service(service: ProcessService):
23
+ """设置全局 ProcessService 实例"""
24
+ global _process_service_instance
25
+ _process_service_instance = service
26
+
27
+
28
+ # 服务依赖注入
29
+ def get_task_service() -> TaskService:
30
+ """获取任务管理服务实例"""
31
+ repository = JsonTaskRepository()
32
+ return TaskService(repository)
33
+
34
+
35
+ def get_notification_service() -> NotificationService:
36
+ """获取通知服务实例"""
37
+ clients = [
38
+ NtfyClient(notification_settings.ntfy_topic_url),
39
+ BarkClient(notification_settings.bark_url),
40
+ TelegramClient(
41
+ notification_settings.telegram_bot_token,
42
+ notification_settings.telegram_chat_id
43
+ )
44
+ ]
45
+ return NotificationService(clients)
46
+
47
+
48
+ def get_ai_service() -> AIAnalysisService:
49
+ """获取AI分析服务实例"""
50
+ ai_client = AIClient()
51
+ return AIAnalysisService(ai_client)
52
+
53
+
54
+ def get_process_service() -> ProcessService:
55
+ """获取进程管理服务实例"""
56
+ if _process_service_instance is None:
57
+ raise RuntimeError("ProcessService 未初始化")
58
+ return _process_service_instance
src/api/routes/__init__.py ADDED
File without changes
src/api/routes/accounts.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 闲鱼账号管理路由
3
+ """
4
+ import json
5
+ import os
6
+ import re
7
+ import aiofiles
8
+ from fastapi import APIRouter, HTTPException
9
+ from pydantic import BaseModel
10
+ from typing import List
11
+ from src.infrastructure.config.env_manager import env_manager
12
+
13
+
14
+ router = APIRouter(prefix="/api/accounts", tags=["accounts"])
15
+
16
+ ACCOUNT_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,50}$")
17
+
18
+
19
+ class AccountCreate(BaseModel):
20
+ name: str
21
+ content: str
22
+
23
+
24
+ class AccountUpdate(BaseModel):
25
+ content: str
26
+
27
+
28
+ def _strip_quotes(value: str) -> str:
29
+ if not value:
30
+ return value
31
+ if value.startswith(("\"", "'")) and value.endswith(("\"", "'")):
32
+ return value[1:-1]
33
+ return value
34
+
35
+
36
+ def _state_dir() -> str:
37
+ raw = env_manager.get_value("ACCOUNT_STATE_DIR", "state") or "state"
38
+ return _strip_quotes(raw.strip())
39
+
40
+
41
+ def _ensure_state_dir(path: str) -> None:
42
+ os.makedirs(path, exist_ok=True)
43
+
44
+
45
+ def _validate_name(name: str) -> str:
46
+ trimmed = name.strip()
47
+ if not trimmed or not ACCOUNT_NAME_RE.match(trimmed):
48
+ raise HTTPException(status_code=400, detail="账号名称只能包含字母、数字、下划线或短横线。")
49
+ return trimmed
50
+
51
+
52
+ def _account_path(name: str) -> str:
53
+ filename = f"{name}.json"
54
+ return os.path.join(_state_dir(), filename)
55
+
56
+
57
+ def _validate_json(content: str) -> None:
58
+ try:
59
+ json.loads(content)
60
+ except json.JSONDecodeError:
61
+ raise HTTPException(status_code=400, detail="提供的内容不是有效的JSON格式。")
62
+
63
+
64
+ @router.get("", response_model=List[dict])
65
+ async def list_accounts():
66
+ state_dir = _state_dir()
67
+ if not os.path.isdir(state_dir):
68
+ return []
69
+ files = [f for f in os.listdir(state_dir) if f.endswith(".json")]
70
+ accounts = []
71
+ for filename in sorted(files):
72
+ name = filename[:-5]
73
+ accounts.append({
74
+ "name": name,
75
+ "path": os.path.join(state_dir, filename),
76
+ })
77
+ return accounts
78
+
79
+
80
+ @router.get("/{name}", response_model=dict)
81
+ async def get_account(name: str):
82
+ account_name = _validate_name(name)
83
+ path = _account_path(account_name)
84
+ if not os.path.exists(path):
85
+ raise HTTPException(status_code=404, detail="账号不存在")
86
+ async with aiofiles.open(path, "r", encoding="utf-8") as f:
87
+ content = await f.read()
88
+ return {"name": account_name, "path": path, "content": content}
89
+
90
+
91
+ @router.post("", response_model=dict)
92
+ async def create_account(data: AccountCreate):
93
+ account_name = _validate_name(data.name)
94
+ _validate_json(data.content)
95
+ state_dir = _state_dir()
96
+ _ensure_state_dir(state_dir)
97
+ path = _account_path(account_name)
98
+ if os.path.exists(path):
99
+ raise HTTPException(status_code=409, detail="账号已存在")
100
+ async with aiofiles.open(path, "w", encoding="utf-8") as f:
101
+ await f.write(data.content)
102
+ return {"message": "账号已添加", "name": account_name, "path": path}
103
+
104
+
105
+ @router.put("/{name}", response_model=dict)
106
+ async def update_account(name: str, data: AccountUpdate):
107
+ account_name = _validate_name(name)
108
+ _validate_json(data.content)
109
+ state_dir = _state_dir()
110
+ _ensure_state_dir(state_dir)
111
+ path = _account_path(account_name)
112
+ if not os.path.exists(path):
113
+ raise HTTPException(status_code=404, detail="账号不存在")
114
+ async with aiofiles.open(path, "w", encoding="utf-8") as f:
115
+ await f.write(data.content)
116
+ return {"message": "账号已更新", "name": account_name, "path": path}
117
+
118
+
119
+ @router.delete("/{name}", response_model=dict)
120
+ async def delete_account(name: str):
121
+ account_name = _validate_name(name)
122
+ path = _account_path(account_name)
123
+ if not os.path.exists(path):
124
+ raise HTTPException(status_code=404, detail="账号不存在")
125
+ os.remove(path)
126
+ return {"message": "账号已删除"}
src/api/routes/login_state.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 登录状态管理路由
3
+ """
4
+ import os
5
+ import json
6
+ import aiofiles
7
+ from fastapi import APIRouter, HTTPException
8
+ from pydantic import BaseModel
9
+
10
+
11
+ router = APIRouter(prefix="/api/login-state", tags=["login-state"])
12
+
13
+
14
+ class LoginStateUpdate(BaseModel):
15
+ """登录状态更新模型"""
16
+ content: str
17
+
18
+
19
+ @router.post("", response_model=dict)
20
+ async def update_login_state(
21
+ data: LoginStateUpdate,
22
+ ):
23
+ """接收前端发送的登录状态JSON字符串,并保存到 xianyu_state.json"""
24
+ state_file = "xianyu_state.json"
25
+
26
+ try:
27
+ # 验证是否是有效的JSON
28
+ json.loads(data.content)
29
+ except json.JSONDecodeError:
30
+ raise HTTPException(status_code=400, detail="提供的内容不是有效的JSON格式。")
31
+
32
+ try:
33
+ async with aiofiles.open(state_file, 'w', encoding='utf-8') as f:
34
+ await f.write(data.content)
35
+ return {"message": f"登录状态文件 '{state_file}' 已成功更新。"}
36
+ except Exception as e:
37
+ raise HTTPException(status_code=500, detail=f"写入登录状态文件时出错: {e}")
38
+
39
+
40
+ @router.delete("", response_model=dict)
41
+ async def delete_login_state():
42
+ """删除 xianyu_state.json 文件"""
43
+ state_file = "xianyu_state.json"
44
+
45
+ if os.path.exists(state_file):
46
+ try:
47
+ os.remove(state_file)
48
+ return {"message": "登录状态文件已成功删除。"}
49
+ except OSError as e:
50
+ raise HTTPException(status_code=500, detail=f"删除登录状态文件时出错: {e}")
51
+
52
+ return {"message": "登录状态文件不存在,无需删除。"}
src/api/routes/logs.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 日志管理路由
3
+ """
4
+ import os
5
+ from typing import Optional, Tuple, List
6
+ import aiofiles
7
+ from fastapi import APIRouter, Depends, Query
8
+ from fastapi.responses import JSONResponse
9
+ from src.api.dependencies import get_task_service
10
+ from src.services.task_service import TaskService
11
+ from src.utils import resolve_task_log_path
12
+
13
+
14
+ router = APIRouter(prefix="/api/logs", tags=["logs"])
15
+
16
+
17
+ async def _read_tail_lines(
18
+ log_file_path: str,
19
+ offset_lines: int,
20
+ limit_lines: int,
21
+ chunk_size: int = 8192
22
+ ) -> Tuple[List[str], bool, int]:
23
+ async with aiofiles.open(log_file_path, 'rb') as f:
24
+ await f.seek(0, os.SEEK_END)
25
+ file_size = await f.tell()
26
+
27
+ if file_size == 0 or limit_lines <= 0:
28
+ return [], False, file_size
29
+
30
+ offset_lines = max(0, int(offset_lines))
31
+ limit_lines = max(0, int(limit_lines))
32
+ lines_needed = offset_lines + limit_lines
33
+
34
+ pos = file_size
35
+ buffer = b""
36
+ lines: List[bytes] = []
37
+
38
+ while pos > 0 and len(lines) < lines_needed:
39
+ read_size = min(chunk_size, pos)
40
+ pos -= read_size
41
+ await f.seek(pos)
42
+ chunk = await f.read(read_size)
43
+ buffer = chunk + buffer
44
+ lines = buffer.splitlines()
45
+
46
+ start = max(0, len(lines) - lines_needed)
47
+ end = max(0, len(lines) - offset_lines)
48
+ selected = lines[start:end] if end > start else []
49
+
50
+ has_more = pos > 0 or len(lines) > lines_needed
51
+ decoded = [line.decode('utf-8', errors='replace') for line in selected]
52
+ return decoded, has_more, file_size
53
+
54
+
55
+ @router.get("")
56
+ async def get_logs(
57
+ from_pos: int = 0,
58
+ task_id: Optional[int] = Query(default=None, ge=0),
59
+ task_service: TaskService = Depends(get_task_service),
60
+ ):
61
+ """获取日志内容(增量读取)"""
62
+ if task_id is None:
63
+ return JSONResponse(content={
64
+ "new_content": "请选择任务后查看日志。",
65
+ "new_pos": 0
66
+ })
67
+
68
+ task = await task_service.get_task(task_id)
69
+ if not task:
70
+ return JSONResponse(status_code=404, content={
71
+ "new_content": "任务不存在或已删除。",
72
+ "new_pos": 0
73
+ })
74
+
75
+ log_file_path = resolve_task_log_path(task_id, task.task_name)
76
+
77
+ if not os.path.exists(log_file_path):
78
+ return JSONResponse(content={
79
+ "new_content": "",
80
+ "new_pos": 0
81
+ })
82
+
83
+ try:
84
+ async with aiofiles.open(log_file_path, 'rb') as f:
85
+ await f.seek(0, os.SEEK_END)
86
+ file_size = await f.tell()
87
+
88
+ if from_pos >= file_size:
89
+ return {"new_content": "", "new_pos": file_size}
90
+
91
+ await f.seek(from_pos)
92
+ new_bytes = await f.read()
93
+
94
+ new_content = new_bytes.decode('utf-8', errors='replace')
95
+ return {"new_content": new_content, "new_pos": file_size}
96
+
97
+ except Exception as e:
98
+ return JSONResponse(
99
+ status_code=500,
100
+ content={"new_content": f"\n读取日志文件时出错: {e}", "new_pos": from_pos}
101
+ )
102
+
103
+
104
+ @router.get("/tail")
105
+ async def get_logs_tail(
106
+ task_id: Optional[int] = Query(default=None, ge=0),
107
+ offset_lines: int = Query(default=0, ge=0),
108
+ limit_lines: int = Query(default=50, ge=1, le=1000),
109
+ task_service: TaskService = Depends(get_task_service),
110
+ ):
111
+ """获取日志尾部内容(按行分页)"""
112
+ if task_id is None:
113
+ return JSONResponse(content={
114
+ "content": "",
115
+ "has_more": False,
116
+ "next_offset": 0,
117
+ "new_pos": 0
118
+ })
119
+
120
+ task = await task_service.get_task(task_id)
121
+ if not task:
122
+ return JSONResponse(status_code=404, content={
123
+ "content": "",
124
+ "has_more": False,
125
+ "next_offset": 0,
126
+ "new_pos": 0
127
+ })
128
+
129
+ log_file_path = resolve_task_log_path(task_id, task.task_name)
130
+
131
+ if not os.path.exists(log_file_path):
132
+ return JSONResponse(content={
133
+ "content": "",
134
+ "has_more": False,
135
+ "next_offset": 0,
136
+ "new_pos": 0
137
+ })
138
+
139
+ try:
140
+ lines, has_more, file_size = await _read_tail_lines(
141
+ log_file_path,
142
+ offset_lines=offset_lines,
143
+ limit_lines=limit_lines
144
+ )
145
+ next_offset = offset_lines + len(lines)
146
+ return {
147
+ "content": "\n".join(lines),
148
+ "has_more": has_more,
149
+ "next_offset": next_offset,
150
+ "new_pos": file_size
151
+ }
152
+ except Exception as e:
153
+ return JSONResponse(
154
+ status_code=500,
155
+ content={
156
+ "content": f"读取日志文件时出错: {e}",
157
+ "has_more": False,
158
+ "next_offset": offset_lines,
159
+ "new_pos": 0
160
+ }
161
+ )
162
+
163
+
164
+ @router.delete("", response_model=dict)
165
+ async def clear_logs(
166
+ task_id: Optional[int] = Query(default=None, ge=0),
167
+ task_service: TaskService = Depends(get_task_service),
168
+ ):
169
+ """清空日志文件"""
170
+ if task_id is None:
171
+ return {"message": "未指定任务,无法清空日志。"}
172
+
173
+ task = await task_service.get_task(task_id)
174
+ if not task:
175
+ return {"message": "任务不存在或已删除。"}
176
+
177
+ log_file_path = resolve_task_log_path(task_id, task.task_name)
178
+
179
+ if not os.path.exists(log_file_path):
180
+ return {"message": "日志文件不存在,无需清空。"}
181
+
182
+ try:
183
+ async with aiofiles.open(log_file_path, 'w', encoding='utf-8') as f:
184
+ await f.write("")
185
+ return {"message": "日志已成功清空。"}
186
+ except Exception as e:
187
+ return JSONResponse(
188
+ status_code=500,
189
+ content={"message": f"清空日志文件时出错: {e}"}
190
+ )
191
+
192
+ if not os.path.exists(log_file_path):
193
+ return {"message": "日志文件不存在,无需清空。"}
194
+
195
+ try:
196
+ async with aiofiles.open(log_file_path, 'w', encoding='utf-8') as f:
197
+ await f.write("")
198
+ return {"message": "日志已成功清空。"}
199
+ except Exception as e:
200
+ return JSONResponse(
201
+ status_code=500,
202
+ content={"message": f"清空日志文件时出错: {e}"}
203
+ )
src/api/routes/prompts.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Prompt 管理路由
3
+ """
4
+ import os
5
+ import aiofiles
6
+ from fastapi import APIRouter, HTTPException
7
+ from pydantic import BaseModel
8
+
9
+
10
+ router = APIRouter(prefix="/api/prompts", tags=["prompts"])
11
+
12
+
13
+ class PromptUpdate(BaseModel):
14
+ """Prompt 更新模型"""
15
+ content: str
16
+
17
+
18
+ @router.get("")
19
+ async def list_prompts():
20
+ """列出所有 prompt 文件"""
21
+ prompts_dir = "prompts"
22
+ if not os.path.isdir(prompts_dir):
23
+ return []
24
+ return [f for f in os.listdir(prompts_dir) if f.endswith(".txt")]
25
+
26
+
27
+ @router.get("/{filename}")
28
+ async def get_prompt(filename: str):
29
+ """获取 prompt 文件内容"""
30
+ if "/" in filename or ".." in filename:
31
+ raise HTTPException(status_code=400, detail="无效的文件名")
32
+
33
+ filepath = os.path.join("prompts", filename)
34
+ if not os.path.exists(filepath):
35
+ raise HTTPException(status_code=404, detail="Prompt 文件未找到")
36
+
37
+ async with aiofiles.open(filepath, 'r', encoding='utf-8') as f:
38
+ content = await f.read()
39
+ return {"filename": filename, "content": content}
40
+
41
+
42
+ @router.put("/{filename}")
43
+ async def update_prompt(
44
+ filename: str,
45
+ prompt_update: PromptUpdate,
46
+ ):
47
+ """更新 prompt 文件内容"""
48
+ if "/" in filename or ".." in filename:
49
+ raise HTTPException(status_code=400, detail="无效的文件名")
50
+
51
+ filepath = os.path.join("prompts", filename)
52
+ if not os.path.exists(filepath):
53
+ raise HTTPException(status_code=404, detail="Prompt 文件未找到")
54
+
55
+ try:
56
+ async with aiofiles.open(filepath, 'w', encoding='utf-8') as f:
57
+ await f.write(prompt_update.content)
58
+ return {"message": f"Prompt 文件 '{filename}' 更新成功"}
59
+ except Exception as e:
60
+ raise HTTPException(status_code=500, detail=f"写入文件时出错: {e}")
src/api/routes/results.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 结果文件管理路由
3
+ """
4
+ from fastapi import APIRouter, HTTPException, Query
5
+ from fastapi.responses import FileResponse
6
+ from typing import List
7
+ import os
8
+ import glob
9
+ import json
10
+ import aiofiles
11
+
12
+
13
+ router = APIRouter(prefix="/api/results", tags=["results"])
14
+
15
+
16
+ @router.get("/files")
17
+ async def get_result_files():
18
+ """获取所有结果文件列表"""
19
+ # 主要从 jsonl 目录获取文件
20
+ jsonl_dir = "jsonl"
21
+ files = []
22
+
23
+ if os.path.isdir(jsonl_dir):
24
+ files = [f for f in os.listdir(jsonl_dir) if f.endswith(".jsonl")]
25
+
26
+ # 返回格式与前端期望一致
27
+ return {"files": files}
28
+
29
+
30
+ @router.get("/files/{filename:path}")
31
+ async def download_result_file(filename: str):
32
+ """下载指定的结果文件"""
33
+ # 安全检查:防止路径遍历攻击
34
+ if ".." in filename or filename.startswith("/"):
35
+ return {"error": "非法的文件路径"}
36
+
37
+ # 文件在 jsonl 目录中
38
+ file_path = os.path.join("jsonl", filename)
39
+
40
+ if not os.path.exists(file_path) or not filename.endswith(".jsonl"):
41
+ return {"error": "文件不存在"}
42
+
43
+ return FileResponse(
44
+ path=file_path,
45
+ filename=filename,
46
+ media_type="application/x-ndjson"
47
+ )
48
+
49
+
50
+ @router.delete("/files/{filename:path}")
51
+ async def delete_result_file(filename: str):
52
+ """删除指定的结果文件"""
53
+ # 安全检查:防止路径遍历攻击
54
+ if ".." in filename or filename.startswith("/"):
55
+ raise HTTPException(status_code=400, detail="非法的文件路径")
56
+
57
+ # 只允许删除 .jsonl 文件
58
+ if not filename.endswith(".jsonl"):
59
+ raise HTTPException(status_code=400, detail="只能删除 .jsonl 文件")
60
+
61
+ # 文件在 jsonl 目录中
62
+ file_path = os.path.join("jsonl", filename)
63
+
64
+ if not os.path.exists(file_path):
65
+ raise HTTPException(status_code=404, detail="文件不存在")
66
+
67
+ try:
68
+ os.remove(file_path)
69
+ return {"message": f"文件 {filename} 已成功删除"}
70
+ except Exception as e:
71
+ raise HTTPException(status_code=500, detail=f"删除文件时出错: {str(e)}")
72
+
73
+
74
+ @router.get("/{filename}")
75
+ async def get_result_file_content(
76
+ filename: str,
77
+ page: int = Query(1, ge=1),
78
+ limit: int = Query(20, ge=1, le=100),
79
+ recommended_only: bool = Query(False),
80
+ sort_by: str = Query("crawl_time"),
81
+ sort_order: str = Query("desc"),
82
+ ):
83
+ """读取指定的 .jsonl 文件内容,支持分页、筛选和排序"""
84
+ # 安全检查
85
+ if not filename.endswith(".jsonl") or "/" in filename or ".." in filename:
86
+ raise HTTPException(status_code=400, detail="无效的文件名")
87
+
88
+ filepath = os.path.join("jsonl", filename)
89
+ if not os.path.exists(filepath):
90
+ raise HTTPException(status_code=404, detail="结果文件未找到")
91
+
92
+ results = []
93
+ try:
94
+ async with aiofiles.open(filepath, 'r', encoding='utf-8') as f:
95
+ async for line in f:
96
+ try:
97
+ record = json.loads(line)
98
+ if recommended_only:
99
+ if record.get("ai_analysis", {}).get("is_recommended") is True:
100
+ results.append(record)
101
+ else:
102
+ results.append(record)
103
+ except json.JSONDecodeError:
104
+ continue
105
+ except Exception as e:
106
+ raise HTTPException(status_code=500, detail=f"读取结果文件时出错: {e}")
107
+
108
+ # 排序逻辑
109
+ def get_sort_key(item):
110
+ info = item.get("商品信息", {})
111
+ if sort_by == "publish_time":
112
+ return info.get("发布时间", "0000-00-00 00:00")
113
+ elif sort_by == "price":
114
+ price_str = str(info.get("当前售价", "0")).replace("¥", "").replace(",", "").strip()
115
+ try:
116
+ return float(price_str)
117
+ except (ValueError, TypeError):
118
+ return 0.0
119
+ else: # default to crawl_time
120
+ return item.get("爬取时间", "")
121
+
122
+ is_reverse = (sort_order == "desc")
123
+ results.sort(key=get_sort_key, reverse=is_reverse)
124
+
125
+ # 分页
126
+ total_items = len(results)
127
+ start = (page - 1) * limit
128
+ end = start + limit
129
+ paginated_results = results[start:end]
130
+
131
+ return {
132
+ "total_items": total_items,
133
+ "page": page,
134
+ "limit": limit,
135
+ "items": paginated_results
136
+ }
src/api/routes/settings.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 设置管理路由
3
+ """
4
+ import os
5
+ from dotenv import load_dotenv
6
+ from fastapi import APIRouter
7
+ from pydantic import BaseModel
8
+ from typing import Optional
9
+ from src.infrastructure.config.env_manager import env_manager
10
+ from src.infrastructure.config.settings import AISettings, notification_settings, scraper_settings, reload_settings
11
+
12
+
13
+ router = APIRouter(prefix="/api/settings", tags=["settings"])
14
+
15
+ def _reload_env() -> None:
16
+ load_dotenv(dotenv_path=env_manager.env_file, override=True)
17
+ reload_settings()
18
+
19
+ def _env_bool(key: str, default: bool = False) -> bool:
20
+ value = env_manager.get_value(key)
21
+ if value is None:
22
+ return default
23
+ return str(value).strip().lower() in {"1", "true", "yes", "y", "on"}
24
+
25
+
26
+ def _env_int(key: str, default: int) -> int:
27
+ value = env_manager.get_value(key)
28
+ if value is None:
29
+ return default
30
+ try:
31
+ return int(value)
32
+ except ValueError:
33
+ return default
34
+
35
+
36
+ def _normalize_bool_value(value: bool) -> str:
37
+ return "true" if value else "false"
38
+
39
+
40
+ class NotificationSettingsModel(BaseModel):
41
+ """通知设置模型"""
42
+ NTFY_TOPIC_URL: Optional[str] = None
43
+ BARK_URL: Optional[str] = None
44
+ TELEGRAM_BOT_TOKEN: Optional[str] = None
45
+ TELEGRAM_CHAT_ID: Optional[str] = None
46
+
47
+
48
+ class AISettingsModel(BaseModel):
49
+ """AI设置模型"""
50
+ OPENAI_API_KEY: Optional[str] = None
51
+ OPENAI_BASE_URL: Optional[str] = None
52
+ OPENAI_MODEL_NAME: Optional[str] = None
53
+ SKIP_AI_ANALYSIS: Optional[bool] = None
54
+ PROXY_URL: Optional[str] = None
55
+
56
+
57
+ class RotationSettingsModel(BaseModel):
58
+ PROXY_ROTATION_ENABLED: Optional[bool] = None
59
+ PROXY_ROTATION_MODE: Optional[str] = None
60
+ PROXY_POOL: Optional[str] = None
61
+ PROXY_ROTATION_RETRY_LIMIT: Optional[int] = None
62
+ PROXY_BLACKLIST_TTL: Optional[int] = None
63
+
64
+
65
+ @router.get("/notifications")
66
+ async def get_notification_settings():
67
+ """获取通知设置"""
68
+ return {
69
+ "NTFY_TOPIC_URL": env_manager.get_value("NTFY_TOPIC_URL", ""),
70
+ "BARK_URL": env_manager.get_value("BARK_URL", ""),
71
+ "TELEGRAM_BOT_TOKEN": env_manager.get_value("TELEGRAM_BOT_TOKEN", ""),
72
+ "TELEGRAM_CHAT_ID": env_manager.get_value("TELEGRAM_CHAT_ID", "")
73
+ }
74
+
75
+
76
+ @router.put("/notifications")
77
+ async def update_notification_settings(
78
+ settings: NotificationSettingsModel,
79
+ ):
80
+ """更新通知设置"""
81
+ updates = settings.dict(exclude_none=True)
82
+ success = env_manager.update_values(updates)
83
+ if success:
84
+ _reload_env()
85
+ return {"message": "通知设置已成功更新"}
86
+ return {"message": "更新通知设置失败"}
87
+
88
+ @router.get("/rotation")
89
+ async def get_rotation_settings():
90
+ return {
91
+ "PROXY_ROTATION_ENABLED": _env_bool("PROXY_ROTATION_ENABLED", False),
92
+ "PROXY_ROTATION_MODE": env_manager.get_value("PROXY_ROTATION_MODE", "per_task"),
93
+ "PROXY_POOL": env_manager.get_value("PROXY_POOL", ""),
94
+ "PROXY_ROTATION_RETRY_LIMIT": _env_int("PROXY_ROTATION_RETRY_LIMIT", 2),
95
+ "PROXY_BLACKLIST_TTL": _env_int("PROXY_BLACKLIST_TTL", 300),
96
+ }
97
+
98
+
99
+ @router.put("/rotation")
100
+ async def update_rotation_settings(
101
+ settings: RotationSettingsModel,
102
+ ):
103
+ updates = {}
104
+ payload = settings.dict(exclude_none=True)
105
+ for key, value in payload.items():
106
+ if isinstance(value, bool):
107
+ updates[key] = _normalize_bool_value(value)
108
+ else:
109
+ updates[key] = str(value)
110
+ success = env_manager.update_values(updates)
111
+ if success:
112
+ _reload_env()
113
+ return {"message": "轮换设置已成功更新"}
114
+ return {"message": "更新轮换设置失败"}
115
+
116
+
117
+ @router.get("/status")
118
+ async def get_system_status():
119
+ """获取系统状态"""
120
+ state_file = "xianyu_state.json"
121
+ login_state_exists = os.path.exists(state_file)
122
+
123
+ # 检查 .env 文件
124
+ env_file_exists = os.path.exists(".env")
125
+
126
+ # 检查关键环境变量是否设置
127
+ openai_api_key = env_manager.get_value("OPENAI_API_KEY", "")
128
+ openai_base_url = env_manager.get_value("OPENAI_BASE_URL", "")
129
+ openai_model_name = env_manager.get_value("OPENAI_MODEL_NAME", "")
130
+ ntfy_topic_url = env_manager.get_value("NTFY_TOPIC_URL", "")
131
+
132
+ ai_settings = AISettings()
133
+ return {
134
+ "ai_configured": ai_settings.is_configured(),
135
+ "notification_configured": notification_settings.has_any_notification_enabled(),
136
+ "headless_mode": scraper_settings.run_headless,
137
+ "running_in_docker": scraper_settings.running_in_docker,
138
+ "login_state_file": {
139
+ "exists": login_state_exists,
140
+ "path": state_file
141
+ },
142
+ "env_file": {
143
+ "exists": env_file_exists,
144
+ "openai_api_key_set": bool(openai_api_key),
145
+ "openai_base_url_set": bool(openai_base_url),
146
+ "openai_model_name_set": bool(openai_model_name),
147
+ "ntfy_topic_url_set": bool(ntfy_topic_url)
148
+ }
149
+ }
150
+
151
+
152
+ class AISettingsModel(BaseModel):
153
+ """AI设置模���"""
154
+ OPENAI_API_KEY: Optional[str] = None
155
+ OPENAI_BASE_URL: Optional[str] = None
156
+ OPENAI_MODEL_NAME: Optional[str] = None
157
+ SKIP_AI_ANALYSIS: Optional[bool] = None
158
+
159
+
160
+ @router.get("/ai")
161
+ async def get_ai_settings():
162
+ """获取AI设置"""
163
+ return {
164
+ "OPENAI_BASE_URL": env_manager.get_value("OPENAI_BASE_URL", ""),
165
+ "OPENAI_MODEL_NAME": env_manager.get_value("OPENAI_MODEL_NAME", ""),
166
+ "SKIP_AI_ANALYSIS": env_manager.get_value("SKIP_AI_ANALYSIS", "false").lower() == "true"
167
+ }
168
+
169
+
170
+ @router.put("/ai")
171
+ async def update_ai_settings(
172
+ settings: AISettingsModel,
173
+ ):
174
+ """更新AI设置"""
175
+ updates = {}
176
+ if settings.OPENAI_API_KEY is not None:
177
+ updates["OPENAI_API_KEY"] = settings.OPENAI_API_KEY
178
+ if settings.OPENAI_BASE_URL is not None:
179
+ updates["OPENAI_BASE_URL"] = settings.OPENAI_BASE_URL
180
+ if settings.OPENAI_MODEL_NAME is not None:
181
+ updates["OPENAI_MODEL_NAME"] = settings.OPENAI_MODEL_NAME
182
+ if settings.SKIP_AI_ANALYSIS is not None:
183
+ updates["SKIP_AI_ANALYSIS"] = str(settings.SKIP_AI_ANALYSIS).lower()
184
+
185
+ success = env_manager.update_values(updates)
186
+ if success:
187
+ _reload_env()
188
+ return {"message": "AI设置已成功更新"}
189
+ return {"message": "更新AI设置失败"}
190
+
191
+
192
+ @router.post("/ai/test")
193
+ async def test_ai_settings(
194
+ settings: dict,
195
+ ):
196
+ """测试AI模型设置是否有效"""
197
+ try:
198
+ from openai import OpenAI
199
+ import httpx
200
+
201
+ stored_api_key = env_manager.get_value("OPENAI_API_KEY", "")
202
+ submitted_api_key = settings.get("OPENAI_API_KEY", "")
203
+ api_key = submitted_api_key or stored_api_key
204
+
205
+ # 创建OpenAI客户端
206
+ client_params = {
207
+ "api_key": api_key,
208
+ "base_url": settings.get("OPENAI_BASE_URL", ""),
209
+ "timeout": httpx.Timeout(30.0),
210
+ }
211
+
212
+ # 如果有代理设置
213
+ proxy_url = settings.get("PROXY_URL", "")
214
+ if proxy_url:
215
+ client_params["http_client"] = httpx.Client(proxy=proxy_url)
216
+
217
+ model_name = settings.get("OPENAI_MODEL_NAME", "")
218
+ print(f"AI测试 - BASE_URL: {client_params['base_url']}, MODEL: {model_name}")
219
+
220
+ client = OpenAI(**client_params)
221
+
222
+ # 测试连接
223
+ response = client.chat.completions.create(
224
+ model=model_name,
225
+ messages=[
226
+ {"role": "user", "content": "Hello, this is a test message."}
227
+ ],
228
+ max_tokens=10
229
+ )
230
+
231
+ return {
232
+ "success": True,
233
+ "message": "AI模型连接测试成功!",
234
+ "response": response.choices[0].message.content if response.choices else "No response"
235
+ }
236
+ except Exception as e:
237
+ return {
238
+ "success": False,
239
+ "message": f"AI模型连接测试失败: {str(e)}"
240
+ }
src/api/routes/tasks.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 任务管理路由
3
+ """
4
+ from fastapi import APIRouter, Depends, HTTPException
5
+ from typing import List
6
+ import os
7
+ import aiofiles
8
+ from src.api.dependencies import get_task_service, get_process_service
9
+ from src.services.task_service import TaskService
10
+ from src.services.process_service import ProcessService
11
+ from src.domain.models.task import Task, TaskCreate, TaskUpdate, TaskGenerateRequest
12
+ from src.api.routes.websocket import broadcast_message
13
+ from src.prompt_utils import generate_criteria
14
+ from src.utils import resolve_task_log_path
15
+
16
+
17
+ router = APIRouter(prefix="/api/tasks", tags=["tasks"])
18
+
19
+
20
+ @router.get("", response_model=List[dict])
21
+ async def get_tasks(
22
+ service: TaskService = Depends(get_task_service),
23
+ ):
24
+ """获取所有任务"""
25
+ tasks = await service.get_all_tasks()
26
+ return [task.dict() for task in tasks]
27
+
28
+
29
+ @router.get("/{task_id}", response_model=dict)
30
+ async def get_task(
31
+ task_id: int,
32
+ service: TaskService = Depends(get_task_service),
33
+ ):
34
+ """获取单个任务"""
35
+ task = await service.get_task(task_id)
36
+ if not task:
37
+ raise HTTPException(status_code=404, detail="任务未找到")
38
+ return task.dict()
39
+
40
+
41
+ @router.post("/", response_model=dict)
42
+ async def create_task(
43
+ task_create: TaskCreate,
44
+ service: TaskService = Depends(get_task_service),
45
+ ):
46
+ """创建新任务"""
47
+ task = await service.create_task(task_create)
48
+ return {"message": "任务创建成功", "task": task.dict()}
49
+
50
+
51
+ @router.post("/generate", response_model=dict)
52
+ async def generate_task(
53
+ req: TaskGenerateRequest,
54
+ service: TaskService = Depends(get_task_service),
55
+ ):
56
+ """使用 AI 生成分析标准并创建新任务"""
57
+ print(f"收到 AI 任务生成请求: {req.task_name}")
58
+
59
+ try:
60
+ # 1. 生成唯一的文件名
61
+ safe_keyword = "".join(
62
+ c for c in req.keyword.lower().replace(' ', '_')
63
+ if c.isalnum() or c in "_-"
64
+ ).rstrip()
65
+ output_filename = f"prompts/{safe_keyword}_criteria.txt"
66
+ print(f"生成的文件路径: {output_filename}")
67
+
68
+ # 2. 调用 AI 生成分析标准
69
+ print("开始调用AI生成分析标准...")
70
+ generated_criteria = await generate_criteria(
71
+ user_description=req.description,
72
+ reference_file_path="prompts/macbook_criteria.txt"
73
+ )
74
+
75
+ print(f"AI生成的分析标准长度: {len(generated_criteria) if generated_criteria else 0}")
76
+ if not generated_criteria or len(generated_criteria.strip()) == 0:
77
+ print("AI返回的内容为空或只有空白字符")
78
+ raise HTTPException(status_code=500, detail="AI未能生成分析标准,返回内容为空。")
79
+
80
+ # 3. 保存生成的文本到新文件
81
+ print(f"开始保存分析标准到文件: {output_filename}")
82
+ try:
83
+ os.makedirs("prompts", exist_ok=True)
84
+ async with aiofiles.open(output_filename, 'w', encoding='utf-8') as f:
85
+ await f.write(generated_criteria)
86
+ print(f"新的分析标准已保存到: {output_filename}")
87
+ except IOError as e:
88
+ print(f"保存分析标准文件失败: {e}")
89
+ raise HTTPException(status_code=500, detail=f"保存分析标准文件失败: {e}")
90
+
91
+ # 4. 创建新任务对象
92
+ print("开始创建新任务对象...")
93
+ task_create = TaskCreate(
94
+ task_name=req.task_name,
95
+ enabled=True,
96
+ keyword=req.keyword,
97
+ description=req.description,
98
+ max_pages=req.max_pages,
99
+ personal_only=req.personal_only,
100
+ min_price=req.min_price,
101
+ max_price=req.max_price,
102
+ cron=req.cron,
103
+ ai_prompt_base_file="prompts/base_prompt.txt",
104
+ ai_prompt_criteria_file=output_filename,
105
+ account_state_file=req.account_state_file,
106
+ free_shipping=req.free_shipping,
107
+ new_publish_option=req.new_publish_option,
108
+ region=req.region,
109
+ )
110
+
111
+ # 5. 使用 TaskService 创建任务
112
+ print("开始通过 TaskService 创建任务...")
113
+ task = await service.create_task(task_create)
114
+
115
+ print(f"AI任务创建成功: {req.task_name}")
116
+ return {"message": "AI 任务创建成功。", "task": task.dict()}
117
+
118
+ except HTTPException:
119
+ raise
120
+ except Exception as e:
121
+ error_msg = f"AI任务生成API发生未知错误: {str(e)}"
122
+ print(error_msg)
123
+ import traceback
124
+ print(traceback.format_exc())
125
+
126
+ # 如果文件已创建但任务创建失败,清理文件
127
+ if 'output_filename' in locals() and os.path.exists(output_filename):
128
+ try:
129
+ os.remove(output_filename)
130
+ print(f"已删除失败的文件: {output_filename}")
131
+ except Exception as cleanup_error:
132
+ print(f"清理失败文件时出错: {cleanup_error}")
133
+
134
+ raise HTTPException(status_code=500, detail=error_msg)
135
+
136
+
137
+ @router.patch("/{task_id}", response_model=dict)
138
+ async def update_task(
139
+ task_id: int,
140
+ task_update: TaskUpdate,
141
+ service: TaskService = Depends(get_task_service),
142
+ ):
143
+ """更新任务"""
144
+ try:
145
+ existing_task = await service.get_task(task_id)
146
+ if not existing_task:
147
+ raise HTTPException(status_code=404, detail="任务未找到")
148
+
149
+ # 检查是否需要重新生成 criteria 文件
150
+ if task_update.description is not None and task_update.description != existing_task.description:
151
+ print(f"检测到任务 {task_id} 的 description 更新,开始重新生成 criteria 文件...")
152
+
153
+ try:
154
+ # 生成新的文件名
155
+ safe_keyword = "".join(
156
+ c for c in existing_task.keyword.lower().replace(' ', '_')
157
+ if c.isalnum() or c in "_-"
158
+ ).rstrip()
159
+ output_filename = f"prompts/{safe_keyword}_criteria.txt"
160
+ print(f"目标文件路径: {output_filename}")
161
+
162
+ # 调用 AI 生成新的分析标准
163
+ print("开始调用 AI 生成新的分析标准...")
164
+ generated_criteria = await generate_criteria(
165
+ user_description=task_update.description,
166
+ reference_file_path="prompts/macbook_criteria.txt"
167
+ )
168
+
169
+ if not generated_criteria or len(generated_criteria.strip()) == 0:
170
+ print("AI 返回的内容为空")
171
+ raise HTTPException(status_code=500, detail="AI 未能生成分析标准,返回内容为空。")
172
+
173
+ # 保存生成的文本到文件
174
+ print(f"保存新的分析标准到: {output_filename}")
175
+ os.makedirs("prompts", exist_ok=True)
176
+ async with aiofiles.open(output_filename, 'w', encoding='utf-8') as f:
177
+ await f.write(generated_criteria)
178
+ print(f"新的分析标准已保存")
179
+
180
+ # 更新 task_update 中的 ai_prompt_criteria_file 字段
181
+ task_update.ai_prompt_criteria_file = output_filename
182
+ print(f"已更新 ai_prompt_criteria_file 字段为: {output_filename}")
183
+
184
+ except HTTPException:
185
+ raise
186
+ except Exception as e:
187
+ error_msg = f"重新生成 criteria 文件时出错: {str(e)}"
188
+ print(error_msg)
189
+ import traceback
190
+ print(traceback.format_exc())
191
+ raise HTTPException(status_code=500, detail=error_msg)
192
+
193
+ # 执行任务更新
194
+ task = await service.update_task(task_id, task_update)
195
+ return {"message": "任务更新成功", "task": task.dict()}
196
+ except ValueError as e:
197
+ raise HTTPException(status_code=404, detail=str(e))
198
+
199
+
200
+ @router.delete("/{task_id}", response_model=dict)
201
+ async def delete_task(
202
+ task_id: int,
203
+ service: TaskService = Depends(get_task_service),
204
+ ):
205
+ """删除任务"""
206
+ task = await service.get_task(task_id)
207
+ if not task:
208
+ raise HTTPException(status_code=404, detail="任务未找到")
209
+
210
+ success = await service.delete_task(task_id)
211
+ if not success:
212
+ raise HTTPException(status_code=404, detail="任务未找到")
213
+
214
+ try:
215
+ keyword = (task.keyword or "").strip()
216
+ if keyword:
217
+ filename = f"{keyword.replace(' ', '_')}_full_data.jsonl"
218
+ file_path = os.path.join("jsonl", filename)
219
+ if os.path.exists(file_path):
220
+ os.remove(file_path)
221
+ except Exception as e:
222
+ print(f"删除任务结果文件时出错: {e}")
223
+
224
+ try:
225
+ log_file_path = resolve_task_log_path(task_id, task.task_name)
226
+ if os.path.exists(log_file_path):
227
+ os.remove(log_file_path)
228
+ except Exception as e:
229
+ print(f"删除任务日志文件时出错: {e}")
230
+
231
+ return {"message": "任务删除成功"}
232
+
233
+
234
+ @router.post("/start/{task_id}", response_model=dict)
235
+ async def start_task(
236
+ task_id: int,
237
+ task_service: TaskService = Depends(get_task_service),
238
+ process_service: ProcessService = Depends(get_process_service),
239
+ ):
240
+ """启动单个任务"""
241
+ # 获取任务信息
242
+ task = await task_service.get_task(task_id)
243
+ if not task:
244
+ raise HTTPException(status_code=404, detail="任务未找到")
245
+
246
+ # 检查任务是否已启用
247
+ if not task.enabled:
248
+ raise HTTPException(status_code=400, detail="任务已被禁用,无法启动")
249
+
250
+ # 检查任务是否已在运行
251
+ if task.is_running:
252
+ raise HTTPException(status_code=400, detail="任务已在运行中")
253
+
254
+ # 启动任务进程
255
+ success = await process_service.start_task(task_id, task.task_name)
256
+ if not success:
257
+ raise HTTPException(status_code=500, detail="启动任务失败")
258
+
259
+ # 更新任务状态
260
+ await task_service.update_task_status(task_id, True)
261
+
262
+ # 广播任务状态变更
263
+ await broadcast_message("task_status_changed", {"id": task_id, "is_running": True})
264
+
265
+ return {"message": f"任务 '{task.task_name}' 已启动"}
266
+
267
+
268
+ @router.post("/stop/{task_id}", response_model=dict)
269
+ async def stop_task(
270
+ task_id: int,
271
+ task_service: TaskService = Depends(get_task_service),
272
+ process_service: ProcessService = Depends(get_process_service),
273
+ ):
274
+ """停止单个任务"""
275
+ # 获取任务信息
276
+ task = await task_service.get_task(task_id)
277
+ if not task:
278
+ raise HTTPException(status_code=404, detail="任务未找到")
279
+
280
+ # 停止任务进程
281
+ await process_service.stop_task(task_id)
282
+
283
+ # 更新任务状态
284
+ await task_service.update_task_status(task_id, False)
285
+
286
+ # 广播任务状态变更
287
+ await broadcast_message("task_status_changed", {"id": task_id, "is_running": False})
288
+
289
+ return {"message": f"任务ID {task_id} 已发送停止信号"}
src/api/routes/websocket.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ WebSocket 路由
3
+ 提供实时通信功能
4
+ """
5
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
6
+ from typing import Set
7
+
8
+
9
+ router = APIRouter()
10
+
11
+ # 全局 WebSocket 连接管理
12
+ active_connections: Set[WebSocket] = set()
13
+
14
+
15
+ @router.websocket("/ws")
16
+ async def websocket_endpoint(
17
+ websocket: WebSocket,
18
+ ):
19
+ """WebSocket 端点"""
20
+ # 接受连接
21
+ await websocket.accept()
22
+ active_connections.add(websocket)
23
+
24
+ try:
25
+ # 保持连接并接收消息
26
+ while True:
27
+ # 接收客户端消息(如果有的话)
28
+ data = await websocket.receive_text()
29
+ # 这里可以处理客户端发送的消息
30
+ # 目前我们主要用于服务端推送,所以暂时不处理
31
+ except WebSocketDisconnect:
32
+ active_connections.remove(websocket)
33
+ except Exception as e:
34
+ print(f"WebSocket 错误: {e}")
35
+ if websocket in active_connections:
36
+ active_connections.remove(websocket)
37
+
38
+
39
+ async def broadcast_message(message_type: str, data: dict):
40
+ """向所有连接的客户端广播消息"""
41
+ message = {
42
+ "type": message_type,
43
+ "data": data
44
+ }
45
+
46
+ # 移除已断开的连接
47
+ disconnected = set()
48
+
49
+ for connection in active_connections:
50
+ try:
51
+ await connection.send_json(message)
52
+ except Exception:
53
+ disconnected.add(connection)
54
+
55
+ # 清理断开的连接
56
+ for connection in disconnected:
57
+ active_connections.discard(connection)
src/app.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 新架构的主应用入口
3
+ 整合所有路由和服务
4
+ """
5
+ from contextlib import asynccontextmanager
6
+ from fastapi import FastAPI
7
+ from fastapi.staticfiles import StaticFiles
8
+ from fastapi.templating import Jinja2Templates
9
+
10
+ from src.api.routes import tasks, logs, settings, prompts, results, login_state, websocket, accounts
11
+ from src.api.dependencies import set_process_service
12
+ from src.services.task_service import TaskService
13
+ from src.services.process_service import ProcessService
14
+ from src.services.scheduler_service import SchedulerService
15
+ from src.infrastructure.persistence.json_task_repository import JsonTaskRepository
16
+
17
+
18
+ # 全局服务实例
19
+ process_service = ProcessService()
20
+ scheduler_service = SchedulerService(process_service)
21
+
22
+ # 设置全局 ProcessService 实例供依赖注入使用
23
+ set_process_service(process_service)
24
+
25
+
26
+ @asynccontextmanager
27
+ async def lifespan(app: FastAPI):
28
+ """应用生命周期管理"""
29
+ # 启动时
30
+ print("正在启动应用...")
31
+
32
+ # 重置所有任务状态为停止
33
+ task_repo = JsonTaskRepository()
34
+ task_service = TaskService(task_repo)
35
+ tasks_list = await task_service.get_all_tasks()
36
+
37
+ for task in tasks_list:
38
+ if task.is_running:
39
+ await task_service.update_task_status(task.id, False)
40
+
41
+ # 加载定时任务
42
+ await scheduler_service.reload_jobs(tasks_list)
43
+ scheduler_service.start()
44
+
45
+ print("应用启动完成")
46
+
47
+ yield
48
+
49
+ # 关闭时
50
+ print("正在关闭应用...")
51
+ scheduler_service.stop()
52
+ await process_service.stop_all()
53
+ print("应用已关闭")
54
+
55
+
56
+ # 创建 FastAPI 应用
57
+ app = FastAPI(
58
+ title="闲鱼智能监控机器人",
59
+ description="基于AI的闲鱼商品监控系统",
60
+ version="2.0.0",
61
+ lifespan=lifespan
62
+ )
63
+
64
+ # 注册路由
65
+ app.include_router(tasks.router)
66
+ app.include_router(logs.router)
67
+ app.include_router(settings.router)
68
+ app.include_router(prompts.router)
69
+ app.include_router(results.router)
70
+ app.include_router(login_state.router)
71
+ app.include_router(websocket.router)
72
+ app.include_router(accounts.router)
73
+
74
+ # 挂载静态文件
75
+ # 旧的静态文件目录(用于截图等)
76
+ app.mount("/static", StaticFiles(directory="static"), name="static")
77
+
78
+ # 挂载 Vue 3 前端构建产物
79
+ # 注意:需要在所有 API 路由之后挂载,以避免覆盖 API 路由
80
+ import os
81
+ if os.path.exists("dist"):
82
+ app.mount("/assets", StaticFiles(directory="dist/assets"), name="assets")
83
+
84
+
85
+ # 健康检查端点
86
+ @app.get("/health")
87
+ async def health_check():
88
+ """健康检查(无需认证)"""
89
+ return {"status": "healthy", "message": "服务正常运行"}
90
+
91
+
92
+ # 认证状态检查端点
93
+ from fastapi import Request, HTTPException
94
+ from fastapi.responses import FileResponse
95
+ from pydantic import BaseModel
96
+ from src.infrastructure.config.settings import settings
97
+
98
+ class LoginRequest(BaseModel):
99
+ username: str
100
+ password: str
101
+
102
+
103
+ @app.post("/auth/status")
104
+ async def auth_status(payload: LoginRequest):
105
+ """检查认证状态"""
106
+ if payload.username == settings.web_username and payload.password == settings.web_password:
107
+ return {"authenticated": True, "username": payload.username}
108
+ raise HTTPException(status_code=401, detail="认证失败")
109
+
110
+
111
+ # 主页路由 - 服务 Vue 3 SPA
112
+ from fastapi.responses import JSONResponse
113
+
114
+ @app.get("/")
115
+ async def read_root(request: Request):
116
+ """提供 Vue 3 SPA 的主页面"""
117
+ if os.path.exists("dist/index.html"):
118
+ return FileResponse("dist/index.html")
119
+ else:
120
+ return JSONResponse(
121
+ status_code=500,
122
+ content={"error": "前端构建产物不存在,请先运行 cd web-ui && npm run build"}
123
+ )
124
+
125
+
126
+ # Catch-all 路由 - 处理所有前端路由(必须放在最后)
127
+ @app.get("/{full_path:path}")
128
+ async def serve_spa(request: Request, full_path: str):
129
+ """
130
+ Catch-all 路由,将所有非 API 请求重定向到 index.html
131
+ 这样可以支持 Vue Router 的 HTML5 History 模式
132
+ """
133
+ # 如果请求的是静态资源(如 favicon.ico),返回 404
134
+ if full_path.endswith(('.ico', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.css', '.js', '.json')):
135
+ return JSONResponse(status_code=404, content={"error": "资源未找到"})
136
+
137
+ # 其他所有路径都返回 index.html,让前端路由处理
138
+ if os.path.exists("dist/index.html"):
139
+ return FileResponse("dist/index.html")
140
+ else:
141
+ return JSONResponse(
142
+ status_code=500,
143
+ content={"error": "前端构建产物不存在,请先运行 cd web-ui && npm run build"}
144
+ )
145
+
146
+
147
+ if __name__ == "__main__":
148
+ import uvicorn
149
+ from src.infrastructure.config.settings import settings
150
+
151
+ print(f"启动新架构应用,端口: {settings.server_port}")
152
+ uvicorn.run(app, host="0.0.0.0", port=settings.server_port)
src/config.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+
4
+ from dotenv import load_dotenv
5
+ from openai import AsyncOpenAI
6
+
7
+ # --- AI & Notification Configuration ---
8
+ load_dotenv()
9
+
10
+ # --- File Paths & Directories ---
11
+ STATE_FILE = "xianyu_state.json"
12
+ IMAGE_SAVE_DIR = "images"
13
+ CONFIG_FILE = "config.json"
14
+ os.makedirs(IMAGE_SAVE_DIR, exist_ok=True)
15
+
16
+ # 任务隔离的临时图片目录前缀
17
+ TASK_IMAGE_DIR_PREFIX = "task_images_"
18
+
19
+ # --- API URL Patterns ---
20
+ API_URL_PATTERN = "h5api.m.goofish.com/h5/mtop.taobao.idlemtopsearch.pc.search"
21
+ DETAIL_API_URL_PATTERN = "h5api.m.goofish.com/h5/mtop.taobao.idle.pc.detail"
22
+
23
+ # --- Environment Variables ---
24
+ API_KEY = os.getenv("OPENAI_API_KEY")
25
+ BASE_URL = os.getenv("OPENAI_BASE_URL")
26
+ MODEL_NAME = os.getenv("OPENAI_MODEL_NAME")
27
+ PROXY_URL = os.getenv("PROXY_URL")
28
+ NTFY_TOPIC_URL = os.getenv("NTFY_TOPIC_URL")
29
+ GOTIFY_URL = os.getenv("GOTIFY_URL")
30
+ GOTIFY_TOKEN = os.getenv("GOTIFY_TOKEN")
31
+ BARK_URL = os.getenv("BARK_URL")
32
+ WX_BOT_URL = os.getenv("WX_BOT_URL")
33
+ TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
34
+ TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID")
35
+ WEBHOOK_URL = os.getenv("WEBHOOK_URL")
36
+ WEBHOOK_METHOD = os.getenv("WEBHOOK_METHOD", "POST").upper()
37
+ WEBHOOK_HEADERS = os.getenv("WEBHOOK_HEADERS")
38
+ WEBHOOK_CONTENT_TYPE = os.getenv("WEBHOOK_CONTENT_TYPE", "JSON").upper()
39
+ WEBHOOK_QUERY_PARAMETERS = os.getenv("WEBHOOK_QUERY_PARAMETERS")
40
+ WEBHOOK_BODY = os.getenv("WEBHOOK_BODY")
41
+ PCURL_TO_MOBILE = os.getenv("PCURL_TO_MOBILE", "false").lower() == "true"
42
+ RUN_HEADLESS = os.getenv("RUN_HEADLESS", "true").lower() != "false"
43
+ LOGIN_IS_EDGE = os.getenv("LOGIN_IS_EDGE", "false").lower() == "true"
44
+ RUNNING_IN_DOCKER = os.getenv("RUNNING_IN_DOCKER", "false").lower() == "true"
45
+ AI_DEBUG_MODE = os.getenv("AI_DEBUG_MODE", "false").lower() == "true"
46
+ SKIP_AI_ANALYSIS = os.getenv("SKIP_AI_ANALYSIS", "false").lower() == "true"
47
+ ENABLE_THINKING = os.getenv("ENABLE_THINKING", "false").lower() == "true"
48
+ ENABLE_RESPONSE_FORMAT = os.getenv("ENABLE_RESPONSE_FORMAT", "true").lower() == "true"
49
+
50
+ # --- Headers ---
51
+ IMAGE_DOWNLOAD_HEADERS = {
52
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0',
53
+ 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
54
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
55
+ 'Connection': 'keep-alive',
56
+ 'Upgrade-Insecure-Requests': '1',
57
+ }
58
+
59
+ # --- Client Initialization ---
60
+ # 检查配置是否齐全
61
+ if not all([BASE_URL, MODEL_NAME]):
62
+ print("警告:未在 .env 文件中完整设置 OPENAI_BASE_URL 和 OPENAI_MODEL_NAME。AI相关功能可能无法使用。")
63
+ client = None
64
+ else:
65
+ try:
66
+ if PROXY_URL:
67
+ print(f"正在为AI请求使用HTTP/S代理: {PROXY_URL}")
68
+ # httpx 会自动从环境变量中读取代理设置
69
+ os.environ['HTTP_PROXY'] = PROXY_URL
70
+ os.environ['HTTPS_PROXY'] = PROXY_URL
71
+
72
+ # openai 客户端内部的 httpx 会自动从环境变量中获取代理配置
73
+ client = AsyncOpenAI(api_key=API_KEY, base_url=BASE_URL)
74
+ except Exception as e:
75
+ print(f"初始化 OpenAI 客户端时出错: {e}")
76
+ client = None
77
+
78
+ # 检查AI客户端是否成功初始化
79
+ if not client:
80
+ # 在 prompt_generator.py 中,如果 client 为 None,会直接报错退出
81
+ # 在 spider_v2.py 中,AI分析会跳过
82
+ # 为了保持一致性,这里只打印警告,具体逻辑由调用方处理
83
+ pass
84
+
85
+ # 检查关键配置
86
+ if not all([BASE_URL, MODEL_NAME]) and 'prompt_generator.py' in sys.argv[0]:
87
+ sys.exit("错误:请确保在 .env 文件中完整设置了 OPENAI_BASE_URL 和 OPENAI_MODEL_NAME。(OPENAI_API_KEY 对于某些服务是可选的)")
88
+
89
+ def get_ai_request_params(**kwargs):
90
+ """
91
+ 构建AI请求参数,根据ENABLE_THINKING和ENABLE_RESPONSE_FORMAT环境变量决定是否添加相应参数
92
+ """
93
+ if ENABLE_THINKING:
94
+ kwargs["extra_body"] = {"enable_thinking": False}
95
+
96
+ # 如果禁用response_format,则移除该参数
97
+ if not ENABLE_RESPONSE_FORMAT and "response_format" in kwargs:
98
+ del kwargs["response_format"]
99
+
100
+ return kwargs
src/domain/__init__.py ADDED
File without changes
src/domain/models/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .task import Task, TaskCreate, TaskUpdate, TaskStatus
2
+
3
+ __all__ = ["Task", "TaskCreate", "TaskUpdate", "TaskStatus"]
src/domain/models/task.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 任务领域模型
3
+ 定义任务实体及其业务逻辑
4
+ """
5
+ from pydantic import BaseModel, Field, validator
6
+ from typing import Optional
7
+ from enum import Enum
8
+
9
+
10
+ class TaskStatus(str, Enum):
11
+ """任务状态枚举"""
12
+ STOPPED = "stopped"
13
+ RUNNING = "running"
14
+ SCHEDULED = "scheduled"
15
+
16
+
17
+ class Task(BaseModel):
18
+ """任务实体"""
19
+ id: Optional[int] = None
20
+ task_name: str
21
+ enabled: bool
22
+ keyword: str
23
+ description: Optional[str] = ""
24
+ max_pages: int
25
+ personal_only: bool
26
+ min_price: Optional[str] = None
27
+ max_price: Optional[str] = None
28
+ cron: Optional[str] = None
29
+ ai_prompt_base_file: str
30
+ ai_prompt_criteria_file: str
31
+ account_state_file: Optional[str] = None
32
+ free_shipping: bool = True
33
+ new_publish_option: Optional[str] = None
34
+ region: Optional[str] = "江苏/南京/全南京"
35
+ is_running: bool = False
36
+
37
+ class Config:
38
+ use_enum_values = True
39
+
40
+ def can_start(self) -> bool:
41
+ """检查任务是否可以启动"""
42
+ return self.enabled and not self.is_running
43
+
44
+ def can_stop(self) -> bool:
45
+ """检查任务是否可以停止"""
46
+ return self.is_running
47
+
48
+ def apply_update(self, update: 'TaskUpdate') -> 'Task':
49
+ """应用更新并返回新的任务实例"""
50
+ update_data = update.dict(exclude_unset=True)
51
+ return self.copy(update=update_data)
52
+
53
+
54
+ class TaskCreate(BaseModel):
55
+ """创建任务的DTO"""
56
+ task_name: str
57
+ enabled: bool = True
58
+ keyword: str
59
+ description: Optional[str] = ""
60
+ max_pages: int = 3
61
+ personal_only: bool = True
62
+ min_price: Optional[str] = None
63
+ max_price: Optional[str] = None
64
+ cron: Optional[str] = None
65
+ ai_prompt_base_file: str = "prompts/base_prompt.txt"
66
+ ai_prompt_criteria_file: str
67
+ account_state_file: Optional[str] = None
68
+ free_shipping: bool = True
69
+ new_publish_option: Optional[str] = None
70
+ region: Optional[str] = "江苏/南京/全南京"
71
+
72
+
73
+ class TaskUpdate(BaseModel):
74
+ """更新任务的DTO"""
75
+ task_name: Optional[str] = None
76
+ enabled: Optional[bool] = None
77
+ keyword: Optional[str] = None
78
+ description: Optional[str] = None
79
+ max_pages: Optional[int] = None
80
+ personal_only: Optional[bool] = None
81
+ min_price: Optional[str] = None
82
+ max_price: Optional[str] = None
83
+ cron: Optional[str] = None
84
+ ai_prompt_base_file: Optional[str] = None
85
+ ai_prompt_criteria_file: Optional[str] = None
86
+ account_state_file: Optional[str] = None
87
+ free_shipping: Optional[bool] = None
88
+ new_publish_option: Optional[str] = None
89
+ region: Optional[str] = None
90
+ is_running: Optional[bool] = None
91
+
92
+
93
+ class TaskGenerateRequest(BaseModel):
94
+ """AI生成任务的请求DTO"""
95
+ task_name: str
96
+ keyword: str
97
+ description: str
98
+ personal_only: bool = True
99
+ min_price: Optional[str] = None
100
+ max_price: Optional[str] = None
101
+ max_pages: int = 3
102
+ cron: Optional[str] = None
103
+ account_state_file: Optional[str] = None
104
+ free_shipping: bool = True
105
+ new_publish_option: Optional[str] = None
106
+ region: Optional[str] = "江苏/南京/全南京"
107
+
108
+ @validator('min_price', 'max_price', pre=True)
109
+ def convert_price_to_str(cls, v):
110
+ """将价格转换为字符串,处理空字符串和数字"""
111
+ if v == "" or v == "null" or v == "undefined" or v is None:
112
+ return None
113
+ # 如果是数字,转换为字符串
114
+ if isinstance(v, (int, float)):
115
+ return str(v)
116
+ return v
117
+
118
+ @validator('cron', pre=True)
119
+ def empty_str_to_none(cls, v):
120
+ """将空字符串转换为 None"""
121
+ if v == "" or v == "null" or v == "undefined":
122
+ return None
123
+ return v
124
+
125
+ @validator('account_state_file', pre=True)
126
+ def empty_account_to_none(cls, v):
127
+ if v == "" or v == "null" or v == "undefined":
128
+ return None
129
+ return v
130
+
131
+ @validator('new_publish_option', 'region', pre=True)
132
+ def empty_str_to_none_for_strings(cls, v):
133
+ if v == "" or v == "null" or v == "undefined":
134
+ return None
135
+ return v
src/domain/repositories/__init__.py ADDED
File without changes
src/domain/repositories/task_repository.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 任务仓储层
3
+ 负责任务数据的持久化操作
4
+ """
5
+ from typing import List, Optional
6
+ from abc import ABC, abstractmethod
7
+ import json
8
+ import aiofiles
9
+ from src.domain.models.task import Task
10
+
11
+
12
+ class TaskRepository(ABC):
13
+ """任务仓储接口"""
14
+
15
+ @abstractmethod
16
+ async def find_all(self) -> List[Task]:
17
+ """获取所有任务"""
18
+ pass
19
+
20
+ @abstractmethod
21
+ async def find_by_id(self, task_id: int) -> Optional[Task]:
22
+ """根据ID获取任务"""
23
+ pass
24
+
25
+ @abstractmethod
26
+ async def save(self, task: Task) -> Task:
27
+ """保存任务(创建或更新)"""
28
+ pass
29
+
30
+ @abstractmethod
31
+ async def delete(self, task_id: int) -> bool:
32
+ """删除任务"""
33
+ pass
src/infrastructure/__init__.py ADDED
File without changes
src/infrastructure/config/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .settings import settings, AppSettings, AISettings, NotificationSettings
2
+
3
+ __all__ = ["settings", "AppSettings", "AISettings", "NotificationSettings"]