Docker打包

This commit is contained in:
2026-06-11 23:06:29 +08:00
parent ddcc7262b6
commit 8b54706ae7
15 changed files with 637 additions and 45 deletions
+160 -1
View File
@@ -4,13 +4,18 @@
启动后与主守护进程共享 asyncio 事件循环,不阻塞 Redis 消费。
"""
import asyncio
import os
import signal
import sys
from datetime import datetime
from pathlib import Path
import yaml
from aiohttp import web
from loguru import logger
from src.config.accounts import MultiAccountConfig
from src.config.settings import get_settings
# 默认只展示最近 N 天的截图
@@ -117,6 +122,76 @@ async def handle_index(request: web.Request) -> web.Response:
return web.Response(text=_INDEX_HTML, content_type="text/html")
async def handle_accounts_get(request: web.Request) -> web.Response:
"""GET /api/accounts — 返回 accounts.yaml 原始文本。"""
settings = get_settings()
config_path = Path(settings.accounts_config_path)
if not config_path.exists():
return web.Response(text="", content_type="text/plain", charset="utf-8")
return web.Response(
text=config_path.read_text(encoding="utf-8"),
content_type="text/plain",
charset="utf-8",
)
async def handle_accounts_put(request: web.Request) -> web.Response:
"""PUT /api/accounts — 校验并保存 accounts.yaml(Pydantic 结构校验通过才写入)。"""
raw_text = await request.text()
try:
raw_data = yaml.safe_load(raw_text) or {}
config = MultiAccountConfig.model_validate(raw_data)
except Exception as e:
return web.json_response({"ok": False, "error": f"配置校验失败: {e}"}, status=400)
settings = get_settings()
config_path = Path(settings.accounts_config_path)
config_path.parent.mkdir(parents=True, exist_ok=True)
# 先写临时文件再原子替换,避免写入中途崩溃损坏配置
tmp_path = config_path.with_name(config_path.name + ".tmp")
tmp_path.write_text(raw_text, encoding="utf-8")
os.replace(tmp_path, config_path)
account_count = len(config.sites.surugaya)
logger.warning(f"accounts.yaml 已通过 API 更新(骏河屋账号 {account_count} 个),重启进程后生效。")
return web.json_response({
"ok": True,
"message": f"配置已保存(骏河屋账号 {account_count} 个),需重启进程后生效。",
})
async def handle_restart(request: web.Request) -> web.Response:
"""
POST /api/restart — 触发进程优雅重启。
向自身发送 SIGTERM,复用 main.py 的优雅停机流程(停监听、关浏览器);
进程退出后由 supervisord(容器内 autorestart=true)拉起新进程,
重新加载 accounts.yaml 并预热浏览器。
"""
if sys.platform == "win32":
return web.json_response(
{"ok": False, "error": "Windows 环境不支持 API 重启,请手动重启进程。"},
status=501,
)
logger.warning("收到 API 重启请求,0.5 秒后向自身发送 SIGTERM 触发优雅重启...")
loop = asyncio.get_running_loop()
loop.call_later(0.5, os.kill, os.getpid(), signal.SIGTERM)
return web.json_response({
"ok": True,
"message": "进程即将优雅退出,由 supervisord 自动重启并加载最新配置。",
})
async def handle_accounts_page(request: web.Request) -> web.Response:
"""GET /accounts — accounts.yaml 在线编辑 HTML 页面。"""
return web.Response(text=_ACCOUNTS_HTML, content_type="text/html")
# 简易 HTML 页面,运维人员可直接在浏览器中查看
_INDEX_HTML = """<!DOCTYPE html>
<html lang="zh-CN">
@@ -147,7 +222,7 @@ _INDEX_HTML = """<!DOCTYPE html>
</style>
</head>
<body>
<h1>截图查看器</h1>
<h1>截图查看器 <a href="/accounts" style="font-size:0.6em;font-weight:normal;margin-left:12px;">账号配置 →</a></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>
@@ -196,12 +271,96 @@ load();
</html>"""
# accounts.yaml 在线编辑页面,供运维人员修改账号配置后触发优雅重启
_ACCOUNTS_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; }
textarea { width: 100%; height: 60vh; font-family: Consolas, Menlo, monospace; font-size: 13px;
padding: 10px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box;
background: #fff; white-space: pre; }
.actions { margin: 12px 0; display: flex; gap: 10px; align-items: center; }
.actions button { padding: 8px 20px; border: none; border-radius: 4px; cursor: pointer; color: #fff; }
#btnSave { background: #4a90d9; }
#btnSave:hover { background: #357abd; }
#btnSaveRestart { background: #d9534f; }
#btnSaveRestart:hover { background: #c9302c; }
.actions button:disabled { background: #aaa; cursor: not-allowed; }
#msg { font-size: 0.9em; }
#msg.ok { color: #2a7a2a; }
#msg.err { color: #c00; white-space: pre-wrap; }
.tip { font-size: 0.85em; color: #888; margin-bottom: 8px; }
</style>
</head>
<body>
<h1>账号配置 (accounts.yaml) <a href="/" style="font-size:0.6em;font-weight:normal;margin-left:12px;">← 截图查看器</a></h1>
<div class="tip">保存时会做 YAML 结构校验;修改仅在进程重启后生效。"保存并重启"会优雅停机后由 supervisord 自动拉起进程并按新配置重新预热浏览器。</div>
<textarea id="editor" spellcheck="false"></textarea>
<div class="actions">
<button id="btnSave" onclick="save(false)">保存</button>
<button id="btnSaveRestart" onclick="save(true)">保存并重启</button>
<span id="msg"></span>
</div>
<script>
function setMsg(text, isErr) {
const el = document.getElementById('msg');
el.textContent = text;
el.className = isErr ? 'err' : 'ok';
}
function setBusy(busy) {
document.getElementById('btnSave').disabled = busy;
document.getElementById('btnSaveRestart').disabled = busy;
}
async function load() {
const resp = await fetch('/api/accounts');
document.getElementById('editor').value = await resp.text();
}
async function save(restart) {
if (restart && !confirm('确认保存并重启进程?进行中的任务会被优雅停机打断。')) return;
setBusy(true);
setMsg('保存中...', false);
try {
const resp = await fetch('/api/accounts', {
method: 'PUT',
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
body: document.getElementById('editor').value,
});
const data = await resp.json();
if (!data.ok) { setMsg(data.error, true); return; }
if (!restart) { setMsg(data.message, false); return; }
setMsg('已保存,正在触发重启...', false);
const r = await fetch('/api/restart', { method: 'POST' });
const rd = await r.json();
if (!rd.ok) { setMsg(rd.error, true); return; }
setMsg('重启已触发,进程将在优雅停机后自动拉起(约数十秒),期间 API 短暂不可用。', false);
} catch (e) {
setMsg('请求失败: ' + e, true);
} finally {
setBusy(false);
}
}
load();
</script>
</body>
</html>"""
def create_app() -> web.Application:
"""创建 aiohttp Application 实例。"""
app = web.Application()
app.router.add_get("/", handle_index)
app.router.add_get("/accounts", handle_accounts_page)
app.router.add_get("/api/screenshots", handle_screenshots)
app.router.add_get("/api/screenshots/{date}/{topic}/{filename}", handle_screenshot_image)
app.router.add_get("/api/accounts", handle_accounts_get)
app.router.add_put("/api/accounts", handle_accounts_put)
app.router.add_post("/api/restart", handle_restart)
return app
+2
View File
@@ -46,6 +46,8 @@ class Settings(BaseSettings):
browser_headless: bool = Field(default=True, alias="BROWSER_HEADLESS")
browser_executable_path: str | None = Field(default=None, alias="BROWSER_EXECUTABLE_PATH")
browser_keep_open: bool = Field(default=False, alias="BROWSER_KEEP_OPEN")
# 启动时登录预检:预热浏览器后逐账号确认登录态,未登录则自动登录
login_precheck_enabled: bool = Field(default=True, alias="LOGIN_PRECHECK_ENABLED")
# 配置与资源路径
accounts_config_path: str = Field(default="./accounts.yaml", alias="ACCOUNTS_CONFIG_PATH")
+4
View File
@@ -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.tasks.surugaya_auth import login_precheck
from src.api.server import start_api_server
async def main() -> None:
@@ -51,6 +52,9 @@ async def main() -> None:
# 3. 预热浏览器:为所有已配置账号提前启动常驻浏览器上下文,任务到达时即开即用
await get_browser_manager().prewarm()
# 3.1 登录预检:逐账号确认登录态,未登录则自动登录,避免任务到达时再耗时走登录流程
await login_precheck()
# 4. 实例化核心任务分发器与 Stream 监听器
dispatcher = TaskDispatcher()
stream_listener = RedisStreamListener(dispatcher)
+7 -27
View File
@@ -15,6 +15,7 @@ from playwright.async_api import Page
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
from notifications import NotificationEvent
from src.tasks import surugaya_auth
from src.tasks.base import BasePurchaseTask
from src.config.accounts import get_account_config
from src.task_queue.redis_manager import get_redis_client
@@ -314,24 +315,13 @@ class SurugayaPurchaseTask(BasePurchaseTask):
async def check_login_status(self, page: Page) -> bool:
"""
通过限定页头区域来精准判断登录状态
通过限定页头区域来精准判断登录状态(共享实现见 surugaya_auth)。
"""
try:
text_login = page.locator(".top_nav .text-login").first
if await text_login.is_visible():
logger.debug(f"[任务 {self.task_id}] 检测结果:[未登录]")
return False
else:
# logger.debug(f"[任务 {self.task_id}] 检测结果:[已登录] 用户名: {await text_login.inner_text()}")
return True
except PlaywrightTimeoutError as e:
logger.warning(f"[任务 {self.task_id}] 检测失败: {e}")
return False
return await surugaya_auth.check_login_status(page, log_prefix=f"[任务 {self.task_id}] ")
async def surugaya_login(self, page: Page) -> None:
"""
骏河屋 (Surugaya) 专用安全登录方法
骏河屋 (Surugaya) 专用安全登录方法(共享实现见 surugaya_auth)。
:param page: Playwright 的 Page 对象
"""
account_config = get_account_config("surugaya", self.account_id)
@@ -373,21 +363,11 @@ class SurugayaPurchaseTask(BasePurchaseTask):
await password_input.press_sequentially(password, delay=100)
await asyncio.sleep(0.6)
# 6. 点击登录按钮("サインイン")
logger.debug(f"[任务 {self.task_id}] 正在点击登录按钮...")
await submit_button.click()
# 7. 【防风控核心】等待登录结果跳转
# 骏河屋成功登录后通常会跳转到 mypage (个人中心)
logger.info(f"[任务 {self.task_id}] 正在等待登录结果...")
try:
# 等待 URL 变成包含 pcmypage 的地址(代表成功进入个人中心)
await page.wait_for_url("**/pcmypage*", timeout=15000)
logger.info(f"[任务 {self.task_id}] 骏河屋登录成功。")
except Exception as e:
logger.error(f"[任务 {self.task_id}] 骏河屋登录超时或失败,可能触发了验证码或账号凭据异常。")
await surugaya_auth.surugaya_login(page, account_config, log_prefix=f"[任务 {self.task_id}] ")
except Exception:
await self.take_screenshot(scene="surugaya_login_failed")
raise e
raise
async def get_show_payment_methods(self, page: Page) -> list:
"""
+176
View File
@@ -0,0 +1,176 @@
"""
骏河屋登录共享逻辑:登录状态检测、拟真表单登录、启动时登录预检。
供两处复用:
- SurugayaPurchaseTask:任务执行中的登录兜底(src/tasks/surugaya.py);
- main.py:启动时登录预检(浏览器预热后、消息监听启动前),
提前确认各账号登录态并自动登录,避免任务到达时再花时间走登录流程。
"""
import asyncio
from datetime import datetime
from pathlib import Path
from loguru import logger
from playwright.async_api import Page
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
from src.browser.manager import get_browser_manager
from src.config.accounts import AccountConfig, load_accounts_config
from src.config.settings import get_settings
MYPAGE_URL = "https://www.suruga-ya.jp/pcmypage"
# 登录预检失败截图的归档 topic(可在 8080 截图查看器中按此主题过滤)
_PRECHECK_TOPIC = "login_precheck"
async def check_login_status(page: Page, *, log_prefix: str = "") -> bool:
"""
通过限定页头区域来精准判断登录状态。
"""
try:
text_login = page.locator(".top_nav .text-login").first
if await text_login.is_visible():
logger.debug(f"{log_prefix}检测结果:[未登录]")
return False
else:
# logger.debug(f"{log_prefix}检测结果:[已登录] 用户名: {await text_login.inner_text()}")
return True
except PlaywrightTimeoutError as e:
logger.warning(f"{log_prefix}检测失败: {e}")
return False
async def surugaya_login(page: Page, account: AccountConfig, *, log_prefix: str = "") -> None:
"""
骏河屋 (Surugaya) 专用安全登录方法(高拟真输入)。
失败时抛出异常,截图等善后由调用方按各自场景处置。
"""
if not account.password:
raise ValueError(f"账号 '{account.account_id}' 缺少密码配置,无法执行登录。")
email = account.username
password = account.password
logger.info(f"{log_prefix}正在初始化骏河屋登录流程...")
await page.goto(MYPAGE_URL, wait_until="domcontentloaded", timeout=30000)
# 1. 确保表单和输入框在画面上完全可见
# 骏河屋的账号框 id 是 'edit-mail'
email_input = page.locator("#edit-mail")
await email_input.wait_for(state="visible", timeout=10000)
# 2. 密码框定位(id 是 'edit-password')
password_input = page.locator("#edit-password")
await password_input.wait_for(state="visible", timeout=10000)
# 3. 登录提交按钮定位(id 是 'edit-submit',值是 'サインイン')
submit_button = page.locator("#edit-submit")
await submit_button.wait_for(state="visible", timeout=10000)
# 4. 使用异步 API 输入凭据,不阻塞其他 topic 的任务
logger.debug(f"{log_prefix}正在输入骏河屋账号...")
await email_input.focus()
await email_input.clear()
await email_input.press_sequentially(email, delay=100)
await asyncio.sleep(0.4)
# 5. 【高拟真输入】模拟真人输入密码
logger.debug(f"{log_prefix}正在输入密码...")
await password_input.focus()
await password_input.clear()
await password_input.press_sequentially(password, delay=100)
await asyncio.sleep(0.6)
# 6. 点击登录按钮("サインイン")
logger.debug(f"{log_prefix}正在点击登录按钮...")
await submit_button.click()
# 7. 【防风控核心】等待登录结果跳转
# 骏河屋成功登录后通常会跳转到 mypage (个人中心)
logger.info(f"{log_prefix}正在等待登录结果...")
try:
# 等待 URL 变成包含 pcmypage 的地址(代表成功进入个人中心)
await page.wait_for_url("**/pcmypage*", timeout=15000)
logger.info(f"{log_prefix}骏河屋登录成功。")
except Exception:
logger.error(f"{log_prefix}骏河屋登录超时或失败,可能触发了验证码或账号凭据异常。")
raise
async def login_precheck() -> None:
"""
启动时登录预检:逐个打开骏河屋账号的常驻浏览器页面确认登录态,
未登录则自动登录。串行执行以降低多账号并发登录的风控风险;
单账号失败不阻断启动(任务执行时仍有登录兜底)。
"""
settings = get_settings()
if not settings.login_precheck_enabled:
logger.info("登录预检已禁用(LOGIN_PRECHECK_ENABLED=false),跳过。")
return
accounts = load_accounts_config().sites.surugaya
if not accounts:
logger.info("未配置骏河屋账号,跳过登录预检。")
return
logger.info(f"开始启动登录预检,共 {len(accounts)} 个骏河屋账号...")
for account in accounts:
log_prefix = f"[登录预检 {account.account_id}] "
try:
await _precheck_account(account, log_prefix)
except Exception as e:
logger.error(f"{log_prefix}登录预检失败(任务执行时仍会自动登录兜底): {e}")
logger.success("启动登录预检完成。")
async def _precheck_account(account: AccountConfig, log_prefix: str) -> None:
"""
单账号登录预检:复用常驻浏览器上下文,仅开关自己的标签页。
"""
context = await get_browser_manager().get_context(account)
page = await context.new_page()
try:
await page.goto(MYPAGE_URL, wait_until="domcontentloaded", timeout=30000)
if await check_login_status(page, log_prefix=log_prefix):
logger.info(f"{log_prefix}登录态有效,无需登录。")
return
logger.warning(f"{log_prefix}未登录,开始自动登录...")
try:
await surugaya_login(page, account, log_prefix=log_prefix)
except Exception:
await _save_precheck_screenshot(page, account, log_prefix)
raise
if not await check_login_status(page, log_prefix=log_prefix):
await _save_precheck_screenshot(page, account, log_prefix)
raise RuntimeError("自动登录后仍未检测到登录态,请通过 noVNC 人工登录核查。")
logger.success(f"{log_prefix}自动登录成功,登录态已就绪。")
finally:
await page.close()
async def _save_precheck_screenshot(page: Page, account: AccountConfig, log_prefix: str) -> None:
"""
保存登录预检失败现场截图到归档目录(8080 截图查看器可见),失败不影响主流程。
"""
settings = get_settings()
account_tag = "".join(c if c.isalnum() else "_" for c in account.account_id)
timestamp_ms = int(datetime.now().timestamp() * 1000)
filepath = (
Path(settings.screenshot_dir)
/ datetime.now().strftime("%Y%m%d")
/ _PRECHECK_TOPIC
/ f"{account_tag}_登录预检失败_{timestamp_ms}.png"
)
try:
filepath.parent.mkdir(parents=True, exist_ok=True)
await page.screenshot(path=str(filepath), timeout=10000)
logger.info(f"{log_prefix}失败现场截图已保存: {filepath}")
except Exception as e:
logger.warning(f"{log_prefix}失败现场截图保存失败: {e}")
+19 -15
View File
@@ -1,5 +1,7 @@
import asyncio
import os
import shutil
import sys
from datetime import datetime
from pathlib import Path
import pyautogui
@@ -10,9 +12,24 @@ def _sanitize_path_component(value: str) -> str:
return sanitized or "unknown"
def _build_capture_args(fps: int, width: int, height: int) -> list[str]:
"""
按运行平台组装 FFmpeg 屏幕采集输入参数。
Windows 使用 gdigrab 抓取整个桌面;Linux(容器内 Xvfb)使用 x11grab 抓取 DISPLAY。
"""
common_args = [
"-draw_mouse", "1",
"-framerate", str(fps),
"-video_size", f"{width}x{height}",
]
if sys.platform == "win32":
return ["-f", "gdigrab", *common_args, "-offset_x", "0", "-offset_y", "0", "-i", "desktop"]
return ["-f", "x11grab", *common_args, "-i", os.environ.get("DISPLAY", ":0")]
class TaskVideoRecorder:
"""
使用 FFmpeg 进行 Windows 整桌面录制的轻量封装。
使用 FFmpeg 进行整桌面录制的轻量封装(Windows: gdigrab / Linux: x11grab)
生命周期由任务基类控制:start() 开始录制,stop() 停止并返回产物路径。
"""
@@ -61,20 +78,7 @@ class TaskVideoRecorder:
"-y",
"-loglevel",
"error",
"-f",
"gdigrab",
"-draw_mouse",
"1",
"-framerate",
str(self.fps),
"-video_size",
f"{screen_width}x{screen_height}",
"-offset_x",
"0",
"-offset_y",
"0",
"-i",
"desktop",
*_build_capture_args(self.fps, screen_width, screen_height),
"-c:v",
"libx264",
"-preset",