You've already forked pruchase_jhw
提供 API 方便排查问题
This commit is contained in:
@@ -22,6 +22,7 @@ dependencies = [
|
||||
"pyscreeze>=1.0.1",
|
||||
"pillow>=12.2.0",
|
||||
"httpx>=0.27.0",
|
||||
"aiohttp>=3.9.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
轻量级 HTTP API 服务,为运维人员提供截图查看接口。
|
||||
|
||||
启动后与主守护进程共享 asyncio 事件循环,不阻塞 Redis 消费。
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from loguru import logger
|
||||
|
||||
from src.config.settings import get_settings
|
||||
|
||||
# 默认只展示最近 N 天的截图
|
||||
_DEFAULT_DAYS = 7
|
||||
|
||||
|
||||
def _scan_screenshots(archives_root: str, days: int = _DEFAULT_DAYS) -> list[dict]:
|
||||
"""
|
||||
扫描 archives 目录,返回最近 `days` 天的截图信息列表。
|
||||
|
||||
按修改时间倒序排列(最新在前)。
|
||||
"""
|
||||
root = Path(archives_root)
|
||||
if not root.exists():
|
||||
return []
|
||||
|
||||
results: list[dict] = []
|
||||
|
||||
# 筛选最近的日期目录
|
||||
today = datetime.now()
|
||||
valid_date_dirs: set[str] = set()
|
||||
for i in range(days):
|
||||
d = today - __import__("datetime").timedelta(days=i)
|
||||
valid_date_dirs.add(d.strftime("%Y%m%d"))
|
||||
|
||||
for date_dir in sorted(root.iterdir(), reverse=True):
|
||||
if not date_dir.is_dir():
|
||||
continue
|
||||
if date_dir.name not in valid_date_dirs:
|
||||
continue
|
||||
|
||||
for topic_dir in date_dir.iterdir():
|
||||
if not topic_dir.is_dir():
|
||||
continue
|
||||
for img_file in topic_dir.iterdir():
|
||||
if not img_file.is_file():
|
||||
continue
|
||||
if img_file.suffix.lower() not in (".png", ".jpg", ".jpeg"):
|
||||
continue
|
||||
|
||||
# 解析文件名:{task_id}_{scene}_{timestamp_ms}.png
|
||||
name_parts = img_file.stem.split("_")
|
||||
scene = name_parts[1] if len(name_parts) >= 2 else "unknown"
|
||||
|
||||
stat = img_file.stat()
|
||||
results.append({
|
||||
"filename": img_file.name,
|
||||
"date": date_dir.name,
|
||||
"topic": topic_dir.name,
|
||||
"scene": scene,
|
||||
"relative_path": f"{date_dir.name}/{topic_dir.name}/{img_file.name}",
|
||||
"size_kb": round(stat.st_size / 1024, 1),
|
||||
"modified_at": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
})
|
||||
|
||||
# 按修改时间倒序
|
||||
results.sort(key=lambda x: x["modified_at"], reverse=True)
|
||||
return results
|
||||
|
||||
|
||||
async def handle_screenshots(request: web.Request) -> web.Response:
|
||||
"""GET /api/screenshots — 返回截图列表 JSON。"""
|
||||
days = int(request.query.get("days", _DEFAULT_DAYS))
|
||||
scene_filter = request.query.get("scene", "").strip()
|
||||
topic_filter = request.query.get("topic", "").strip()
|
||||
|
||||
settings = get_settings()
|
||||
screenshots = _scan_screenshots(settings.screenshot_dir, days=days)
|
||||
|
||||
# 可选过滤
|
||||
if scene_filter:
|
||||
screenshots = [s for s in screenshots if scene_filter in s["scene"]]
|
||||
if topic_filter:
|
||||
screenshots = [s for s in screenshots if topic_filter in s["topic"]]
|
||||
|
||||
return web.json_response({
|
||||
"total": len(screenshots),
|
||||
"days": days,
|
||||
"screenshots": screenshots,
|
||||
})
|
||||
|
||||
|
||||
async def handle_screenshot_image(request: web.Request) -> web.Response:
|
||||
"""GET /api/screenshots/{date}/{topic}/{filename} — 返回截图图片文件。"""
|
||||
date = request.match_info["date"]
|
||||
topic = request.match_info["topic"]
|
||||
filename = request.match_info["filename"]
|
||||
|
||||
# 安全校验:防止路径遍历
|
||||
if ".." in date or ".." in topic or ".." in filename:
|
||||
return web.json_response({"error": "非法路径"}, status=400)
|
||||
|
||||
settings = get_settings()
|
||||
filepath = Path(settings.screenshot_dir) / date / topic / filename
|
||||
|
||||
if not filepath.exists() or not filepath.is_file():
|
||||
return web.json_response({"error": "文件不存在"}, status=404)
|
||||
|
||||
return web.FileResponse(filepath)
|
||||
|
||||
|
||||
async def handle_index(request: web.Request) -> web.Response:
|
||||
"""GET / — 简单的截图浏览 HTML 页面。"""
|
||||
return web.Response(text=_INDEX_HTML, content_type="text/html")
|
||||
|
||||
|
||||
# 简易 HTML 页面,运维人员可直接在浏览器中查看
|
||||
_INDEX_HTML = """<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>截图查看器 — JP Purchase Bot</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 20px; background: #f5f5f5; }
|
||||
h1 { color: #333; font-size: 1.4em; }
|
||||
.filters { margin: 12px 0; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||
.filters label { font-size: 0.9em; color: #666; }
|
||||
.filters input, .filters select { padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px; }
|
||||
.filters button { padding: 6px 16px; background: #4a90d9; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
|
||||
.filters button:hover { background: #357abd; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 0.85em; }
|
||||
th { background: #fafafa; color: #555; font-weight: 600; }
|
||||
tr:hover { background: #f0f7ff; }
|
||||
.scene-tag { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 0.8em; }
|
||||
.scene-error { background: #ffe0e0; color: #c00; }
|
||||
.scene-other { background: #e0f0ff; color: #369; }
|
||||
.thumb { max-width: 60px; max-height: 40px; cursor: pointer; border: 1px solid #ddd; }
|
||||
.thumb:hover { border-color: #4a90d9; }
|
||||
#overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); z-index: 999; cursor: pointer; }
|
||||
#overlay img { max-width: 95%; max-height: 95%; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); }
|
||||
.stats { font-size: 0.85em; color: #888; margin-bottom: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>截图查看器</h1>
|
||||
<div class="filters">
|
||||
<label>最近 <input type="number" id="days" value="3" min="1" max="30" style="width:50px"> 天</label>
|
||||
<label>场景 <input type="text" id="scene" placeholder="如:任务执行出错"></label>
|
||||
<label>主题 <input type="text" id="topic" placeholder="如:surugaya"></label>
|
||||
<button onclick="load()">查询</button>
|
||||
</div>
|
||||
<div class="stats" id="stats"></div>
|
||||
<table>
|
||||
<thead><tr><th>时间</th><th>主题</th><th>场景</th><th>文件</th><th>大小</th><th>预览</th></tr></thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
<div id="overlay" onclick="this.style.display='none'"><img id="bigImg" src=""></div>
|
||||
<script>
|
||||
async function load() {
|
||||
const days = document.getElementById('days').value;
|
||||
const scene = document.getElementById('scene').value;
|
||||
const topic = document.getElementById('topic').value;
|
||||
let url = '/api/screenshots?days=' + days;
|
||||
if (scene) url += '&scene=' + encodeURIComponent(scene);
|
||||
if (topic) url += '&topic=' + encodeURIComponent(topic);
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
document.getElementById('stats').textContent = '共 ' + data.total + ' 张截图(最近 ' + data.days + ' 天)';
|
||||
const tbody = document.getElementById('tbody');
|
||||
tbody.innerHTML = '';
|
||||
for (const s of data.screenshots) {
|
||||
const cls = s.scene.includes('出错') || s.scene.includes('失败') || s.scene.includes('异常') ? 'scene-error' : 'scene-other';
|
||||
const imgUrl = '/api/screenshots/' + s.relative_path;
|
||||
tbody.innerHTML += '<tr>'
|
||||
+ '<td>' + s.modified_at + '</td>'
|
||||
+ '<td>' + s.topic + '</td>'
|
||||
+ '<td><span class="scene-tag ' + cls + '">' + s.scene + '</span></td>'
|
||||
+ '<td>' + s.filename + '</td>'
|
||||
+ '<td>' + s.size_kb + ' KB</td>'
|
||||
+ '<td><img class="thumb" src="' + imgUrl + '" onclick="showBig(\\'' + imgUrl + '\\')"></td>'
|
||||
+ '</tr>';
|
||||
}
|
||||
}
|
||||
function showBig(url) {
|
||||
document.getElementById('bigImg').src = url;
|
||||
document.getElementById('overlay').style.display = 'block';
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def create_app() -> web.Application:
|
||||
"""创建 aiohttp Application 实例。"""
|
||||
app = web.Application()
|
||||
app.router.add_get("/", handle_index)
|
||||
app.router.add_get("/api/screenshots", handle_screenshots)
|
||||
app.router.add_get("/api/screenshots/{date}/{topic}/{filename}", handle_screenshot_image)
|
||||
return app
|
||||
|
||||
|
||||
async def start_api_server(host: str = "0.0.0.0", port: int = 8080) -> web.AppRunner:
|
||||
"""启动 API 服务,返回 AppRunner 以便后续优雅关闭。"""
|
||||
app = create_app()
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, host, port)
|
||||
await site.start()
|
||||
logger.info(f"截图查看 API 已启动 → http://{host}:{port}/")
|
||||
return runner
|
||||
@@ -21,6 +21,7 @@ from src.task_queue.delay_zset_listener import RedisZSetDelayListener
|
||||
from src.task_queue.listener import RedisStreamListener
|
||||
from src.task_queue.trade_fetch_listener import TradeFetchStreamListener
|
||||
from src.task_queue.redis_manager import get_redis_manager
|
||||
from src.api.server import start_api_server
|
||||
|
||||
async def main() -> None:
|
||||
"""
|
||||
@@ -56,6 +57,9 @@ async def main() -> None:
|
||||
delay_listener = RedisZSetDelayListener(dispatcher)
|
||||
trade_fetch_listener = TradeFetchStreamListener(dispatcher)
|
||||
|
||||
# 4.1 启动截图查看 API 服务
|
||||
api_runner = await start_api_server()
|
||||
|
||||
# 5. 优雅停机信号处理 (跨平台支持)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
@@ -95,6 +99,7 @@ async def main() -> None:
|
||||
trade_fetch_listener.stop(),
|
||||
get_browser_manager().close_all(),
|
||||
get_redis_manager().close(),
|
||||
api_runner.cleanup(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
logger.success("日本采购自动化守护进程已安全优雅退出。")
|
||||
|
||||
Reference in New Issue
Block a user