feat: 添加重新生成AI标准功能

This commit is contained in:
ADS
2025-08-21 19:19:38 +08:00
parent 0a695dd892
commit 7cd51a0bef
7 changed files with 280 additions and 21 deletions

View File

@@ -10,6 +10,7 @@ load_dotenv()
# --- File Paths & Directories --- # --- File Paths & Directories ---
STATE_FILE = "xianyu_state.json" STATE_FILE = "xianyu_state.json"
IMAGE_SAVE_DIR = "images" IMAGE_SAVE_DIR = "images"
CONFIG_FILE = "config.json"
os.makedirs(IMAGE_SAVE_DIR, exist_ok=True) os.makedirs(IMAGE_SAVE_DIR, exist_ok=True)
# 任务隔离的临时图片目录前缀 # 任务隔离的临时图片目录前缀

46
src/file_operator.py Normal file
View File

@@ -0,0 +1,46 @@
import aiofiles
from pathlib import Path
class FileOperator:
def __init__(self, filepath: str):
self.filepath = filepath
async def read(self) -> str | None:
"""
读取
"""
try:
async with aiofiles.open(self.filepath, 'r', encoding='utf-8') as f:
content_str = await f.read()
if content_str.strip():
return content_str
else:
return None
except FileNotFoundError:
print(f"文件 {self.filepath} 不存在")
return None
except PermissionError:
print(f"错误:没有权限读取文件 {self.filepath}")
return None
except Exception as e:
print(f"读取文件 {self.filepath} 时发生错误: {e}")
return None
async def write(self, content: str) -> bool:
"""
写入
"""
try:
Path(self.filepath).parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(self.filepath, 'w', encoding='utf-8') as f:
await f.write(content)
return True
except PermissionError:
print(f"错误:没有权限写入文件 {self.filepath}")
return False
except Exception as e:
print(f"写入文件 {self.filepath} 时发生错误: {e}")
return False

95
src/task.py Normal file
View File

@@ -0,0 +1,95 @@
import json
from pydantic import BaseModel
from typing import Optional
from src.config import CONFIG_FILE
from src.file_operator import FileOperator
class Task(BaseModel):
task_name: str
enabled: bool
keyword: str
description: str
max_pages: int
personal_only: bool
min_price: Optional[str] = None
max_price: Optional[str] = None
cron: Optional[str] = None
ai_prompt_base_file: str
ai_prompt_criteria_file: str
is_running: Optional[bool] = False
class TaskUpdate(BaseModel):
task_name: Optional[str] = None
enabled: Optional[bool] = None
keyword: Optional[str] = None
description: Optional[str] = None
max_pages: Optional[int] = None
personal_only: Optional[bool] = None
min_price: Optional[str] = None
max_price: Optional[str] = None
cron: Optional[str] = None
ai_prompt_base_file: Optional[str] = None
ai_prompt_criteria_file: Optional[str] = None
is_running: Optional[bool] = None
async def add_task(task: Task) -> bool:
config_file_op = FileOperator(CONFIG_FILE)
config_data_str = await config_file_op.read()
config_data = json.loads(config_data_str) if config_data_str else []
config_data.append(task)
return await config_file_op.write(json.dumps(config_data, ensure_ascii=False, indent=2))
async def update_task(task_id: int, task: Task) -> bool:
config_file_op = FileOperator(CONFIG_FILE)
config_data_str = await config_file_op.read()
if not config_data_str:
return False
config_data = json.loads(config_data_str)
if len(config_data) <= task_id:
return False
config_data[task_id] = task
return await config_file_op.write(json.dumps(config_data, ensure_ascii=False, indent=2))
async def get_task(task_id: int) -> Task | None:
config_file_op = FileOperator(CONFIG_FILE)
config_data_str = await config_file_op.read()
if not config_data_str:
return None
config_data = json.loads(config_data_str)
if len(config_data) <= task_id:
return None
return config_data[task_id]
async def remove_task(task_id: int) -> bool:
config_file_op = FileOperator(CONFIG_FILE)
config_data_str = await config_file_op.read()
if not config_data_str:
return True
config_data = json.loads(config_data_str)
if len(config_data) <= task_id:
return True
config_data.pop(task_id)
return await config_file_op.write(json.dumps(config_data, ensure_ascii=False, indent=2))

View File

@@ -279,6 +279,21 @@ h2 {
color: #ff4d4f; color: #ff4d4f;
} }
.criteria {
display: flex;
align-items: center;
justify-content: start;
gap: 10px;
}
.refresh-criteria {
display: flex;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
}
/* Toggle Switch */ /* Toggle Switch */
.switch { .switch {
position: relative; position: relative;

View File

@@ -682,6 +682,8 @@ document.addEventListener('DOMContentLoaded', function() {
return '<p>没有找到任何任务。请点击右上角“创建新任务”来添加一个。</p>'; return '<p>没有找到任何任务。请点击右上角“创建新任务”来添加一个。</p>';
} }
const refreshBtn = '<svg class="icon" viewBox="0 0 1025 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M914.17946 324.34283C854.308387 324.325508 750.895846 324.317788 750.895846 324.317788 732.045471 324.317788 716.764213 339.599801 716.764213 358.451121 716.764213 377.30244 732.045471 392.584453 750.895846 392.584453L955.787864 392.584453C993.448095 392.584453 1024 362.040424 1024 324.368908L1024 119.466667C1024 100.615347 1008.718742 85.333333 989.868367 85.333333 971.017993 85.333333 955.736735 100.615347 955.736735 119.466667L955.736735 256.497996C933.314348 217.628194 905.827487 181.795372 873.995034 149.961328 778.623011 54.584531 649.577119 0 511.974435 0 229.218763 0 0 229.230209 0 512 0 794.769791 229.218763 1024 511.974435 1024 794.730125 1024 1023.948888 794.769791 1023.948888 512 1023.948888 493.148681 1008.66763 477.866667 989.817256 477.866667 970.966881 477.866667 955.685623 493.148681 955.685623 512 955.685623 757.067153 757.029358 955.733333 511.974435 955.733333 266.91953 955.733333 68.263265 757.067153 68.263265 512 68.263265 266.932847 266.91953 68.266667 511.974435 68.266667 631.286484 68.266667 743.028524 115.531923 825.725634 198.233152 862.329644 234.839003 892.298522 277.528256 914.17946 324.34283L914.17946 324.34283Z" fill="#389BFF"></path></svg>'
const tableHeader = ` const tableHeader = `
<thead> <thead>
<tr> <tr>
@@ -722,7 +724,7 @@ document.addEventListener('DOMContentLoaded', function() {
<td>${task.min_price || '不限'} - ${task.max_price || '不限'}</td> <td>${task.min_price || '不限'} - ${task.max_price || '不限'}</td>
<td>${task.personal_only ? '<span class="tag personal">个人闲置</span>' : ''}</td> <td>${task.personal_only ? '<span class="tag personal">个人闲置</span>' : ''}</td>
<td>${task.max_pages || 3}</td> <td>${task.max_pages || 3}</td>
<td>${(task.ai_prompt_criteria_file || 'N/A').replace('prompts/', '')}</td> <td><div class="criteria"><button class="refresh-criteria" title="重新生成AI标准" data-task-id="${task.id}">${refreshBtn}</button>${(task.ai_prompt_criteria_file || 'N/A').replace('prompts/', '')}</div></td>
<td>${task.cron || '未设置'}</td> <td>${task.cron || '未设置'}</td>
<td> <td>
${actionButton} ${actionButton}
@@ -1306,6 +1308,14 @@ document.addEventListener('DOMContentLoaded', function() {
const container = document.getElementById('tasks-table-container'); const container = document.getElementById('tasks-table-container');
const tasks = await fetchTasks(); const tasks = await fetchTasks();
container.innerHTML = renderTasksTable(tasks); container.innerHTML = renderTasksTable(tasks);
} else if (button.matches('.refresh-criteria')) {
const task = JSON.parse(row.dataset.task);
const modal = document.getElementById('refresh-criteria-modal');
const textarea = document.getElementById('refresh-criteria-description');
textarea.value = task['description'] || '';
modal.dataset.taskId = taskId;
modal.style.display = 'flex';
setTimeout(() => modal.classList.add('visible'), 10);
} }
}); });
@@ -1398,6 +1408,57 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
// --- refresh criteria Modal Logic ---
const refreshCriteriaModal = document.getElementById('refresh-criteria-modal');
if (refreshCriteriaModal) {
const form = document.getElementById('refresh-criteria-form');
const closeModalBtn = document.getElementById('close-refresh-criteria-btn');
const cancelBtn = document.getElementById('cancel-refresh-criteria-btn');
const refreshBtn = document.getElementById('refresh-criteria-btn');
const closeModal = () => {
refreshCriteriaModal.classList.remove('visible');
setTimeout(() => {
refreshCriteriaModal.style.display = 'none';
form.reset(); // Reset form on close
}, 300);
};
closeModalBtn.addEventListener('click', closeModal);
cancelBtn.addEventListener('click', closeModal);
let canClose = false;
refreshCriteriaModal.addEventListener('mousedown', event => {
canClose = event.target === refreshCriteriaModal;
});
refreshCriteriaModal.addEventListener('mouseup', (event) => {
// Close if clicked on the overlay background
if (canClose && event.target === refreshCriteriaModal) {
closeModal();
}
});
refreshBtn.addEventListener('click', async () => {
if (form.checkValidity() === false) {
form.reportValidity();
return;
}
const btnText = refreshBtn.querySelector('.btn-text');
const spinner = refreshBtn.querySelector('.spinner');
btnText.style.display = 'none';
spinner.style.display = 'inline-block';
refreshBtn.disabled = true;
const taskId = refreshCriteriaModal.dataset.taskId
const formData = new FormData(form);
const result = await updateTask(taskId, {description: formData.get('description')});
if (result && result.task) {
closeModal();
}
})
}
// Initial load // Initial load
refreshLoginStatusWidget(); refreshLoginStatusWidget();

View File

@@ -235,6 +235,34 @@
</div> </div>
</div> </div>
<!-- refresh criteria Modal -->
<div id="refresh-criteria-modal" class="modal-overlay" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h2>重新生成AI标准</h2>
<button id="close-refresh-criteria-btn" class="close-button">&times;</button>
</div>
<div class="modal-body">
<form id="refresh-criteria-form">
<div class="form-group">
<label for="refresh-criteria-description">详细购买需求</label>
<textarea id="refresh-criteria-description" name="description" rows="6"
placeholder="请用自然语言详细描述你的购买需求AI将根据此描述生成分析标准。例如我想买一台95新以上的索尼A7M4相机预算在10000到13000元之间快门数要低于5000。必须是国行且配件齐全。优先考虑个人卖家..."
required></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button id="cancel-refresh-criteria-btn" class="control-button">取消</button>
<button id="refresh-criteria-btn" class="control-button primary-btn">
<span class="btn-text">重新生成</span>
<span class="spinner" style="display: none;"></span>
</button>
</div>
</div>
</div>
<!-- JSON Viewer Modal --> <!-- JSON Viewer Modal -->
<div id="json-viewer-modal" class="modal-overlay" style="display: none;"> <div id="json-viewer-modal" class="modal-overlay" style="display: none;">
<div class="modal-content"> <div class="modal-content">
@@ -263,7 +291,8 @@
<p>此方法用于在无法运行图形化浏览器的服务器上更新闲鱼登录凭证。</p> <p>此方法用于在无法运行图形化浏览器的服务器上更新闲鱼登录凭证。</p>
<p>安装Chrome扩展来提取闲鱼登录状态</p> <p>安装Chrome扩展来提取闲鱼登录状态</p>
<ol class="instructions"> <ol class="instructions">
<li>在Chrome浏览器中安装 <a href="https://chromewebstore.google.com/detail/xianyu-login-state-extrac/eidlpfjiodpigmfcahkmlenhppfklcoa" target="_blank" rel="noopener noreferrer">闲鱼登录状态提取扩展</a></li> <li>在Chrome浏览器中安装 <a href="https://chromewebstore.google.com/detail/xianyu-login-state-extrac/eidlpfjiodpigmfcahkmlenhppfklcoa" target="_blank"
rel="noopener noreferrer">闲鱼登录状态提取扩展</a></li>
<li>打开并登录 <a href="https://www.goofish.com" target="_blank" <li>打开并登录 <a href="https://www.goofish.com" target="_blank"
rel="noopener noreferrer">闲鱼官网</a> rel="noopener noreferrer">闲鱼官网</a>
</li> </li>

View File

@@ -20,11 +20,15 @@ from typing import List, Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from src.file_operator import FileOperator
from src.task import get_task, update_task
class Task(BaseModel): class Task(BaseModel):
task_name: str task_name: str
enabled: bool enabled: bool
keyword: str keyword: str
description: str
max_pages: int max_pages: int
personal_only: bool personal_only: bool
min_price: Optional[str] = None min_price: Optional[str] = None
@@ -39,6 +43,7 @@ class TaskUpdate(BaseModel):
task_name: Optional[str] = None task_name: Optional[str] = None
enabled: Optional[bool] = None enabled: Optional[bool] = None
keyword: Optional[str] = None keyword: Optional[str] = None
description: Optional[str] = None
max_pages: Optional[int] = None max_pages: Optional[int] = None
personal_only: Optional[bool] = None personal_only: Optional[bool] = None
min_price: Optional[str] = None min_price: Optional[str] = None
@@ -511,6 +516,7 @@ async def generate_task(req: TaskGenerateRequest, username: str = Depends(verify
"min_price": req.min_price, "min_price": req.min_price,
"max_price": req.max_price, "max_price": req.max_price,
"cron": req.cron, "cron": req.cron,
"description": req.description,
"ai_prompt_base_file": "prompts/base_prompt.txt", "ai_prompt_base_file": "prompts/base_prompt.txt",
"ai_prompt_criteria_file": output_filename, "ai_prompt_criteria_file": output_filename,
"is_running": False "is_running": False
@@ -564,17 +570,12 @@ async def create_task(task: Task, username: str = Depends(verify_credentials)):
@app.patch("/api/tasks/{task_id}", response_model=dict) @app.patch("/api/tasks/{task_id}", response_model=dict)
async def update_task(task_id: int, task_update: TaskUpdate, username: str = Depends(verify_credentials)): async def update_task_api(task_id: int, task_update: TaskUpdate, username: str = Depends(verify_credentials)):
""" """
更新指定ID任务的属性。 更新指定ID任务的属性。
""" """
try: task = await get_task(task_id)
async with aiofiles.open(CONFIG_FILE, 'r', encoding='utf-8') as f: if not task:
tasks = json.loads(await f.read())
except (FileNotFoundError, json.JSONDecodeError) as e:
raise HTTPException(status_code=500, detail=f"读取或解析配置文件失败: {e}")
if not (0 <= task_id < len(tasks)):
raise HTTPException(status_code=404, detail="任务未找到。") raise HTTPException(status_code=404, detail="任务未找到。")
# 更新数据 # 更新数据
@@ -583,27 +584,38 @@ async def update_task(task_id: int, task_update: TaskUpdate, username: str = Dep
if not update_data: if not update_data:
return JSONResponse(content={"message": "数据无变化,未执行更新。"}, status_code=200) return JSONResponse(content={"message": "数据无变化,未执行更新。"}, status_code=200)
if 'description' in update_data:
criteria_filename = task.get('ai_prompt_criteria_file')
criteria_file_op = FileOperator(criteria_filename)
try:
generated_criteria = await generate_criteria(
user_description=update_data.get("description"),
reference_file_path="prompts/macbook_criteria.txt" # 使用默认的macbook标准作为参考
)
if not generated_criteria:
raise HTTPException(status_code=500, detail="AI未能生成分析标准。")
except Exception as e:
raise HTTPException(status_code=500, detail=f"调用AI生成标准时出错: {e}")
success = await criteria_file_op.write(generated_criteria)
if not success:
raise HTTPException(status_code=500, detail=f"更新的AI标准写入出错")
# 如果任务从“启用”变为“禁用”,且正在运行,则先停止它 # 如果任务从“启用”变为“禁用”,且正在运行,则先停止它
if 'enabled' in update_data and not update_data['enabled']: if 'enabled' in update_data and not update_data['enabled']:
if scraper_processes.get(task_id): if scraper_processes.get(task_id):
print(f"任务 '{tasks[task_id]['task_name']}' 已被禁用,正在停止其进程...") print(f"任务 '{tasks[task_id]['task_name']}' 已被禁用,正在停止其进程...")
await stop_task_process(task_id) # 这会处理进程和is_running状态 await stop_task_process(task_id) # 这会处理进程和is_running状态
tasks[task_id].update(update_data) task.update(update_data)
# 异步写回文件 success = await update_task(task_id, task)
try:
async with aiofiles.open(CONFIG_FILE, 'w', encoding='utf-8') as f:
await f.write(json.dumps(tasks, ensure_ascii=False, indent=2))
await reload_scheduler_jobs() if not success:
updated_task = tasks[task_id]
updated_task['id'] = task_id
return {"message": "任务更新成功。", "task": updated_task}
except Exception as e:
raise HTTPException(status_code=500, detail=f"写入配置文件时发生错误: {e}") raise HTTPException(status_code=500, detail=f"写入配置文件时发生错误: {e}")
return {"message": "任务更新成功。", "task": task}
async def start_task_process(task_id: int, task_name: str): async def start_task_process(task_id: int, task_name: str):
"""内部函数:启动一个指定的任务进程。""" """内部函数:启动一个指定的任务进程。"""