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
+20
View File
@@ -0,0 +1,20 @@
.git
.gitignore
.venv
.claude
.idea
.vscode
**/__pycache__
*.pyc
chrome-profile
archives
logs
tmp
deploy/chrome-profile
deploy/archives
deploy/logs
.env.dev
.env.example
accounts.yaml
README.md
docker-compose.yml
+2
View File
@@ -30,6 +30,8 @@ REDIS_DELAY_BATCH_SIZE=10
BROWSER_HEADLESS=false BROWSER_HEADLESS=false
BROWSER_EXECUTABLE_PATH= BROWSER_EXECUTABLE_PATH=
BROWSER_KEEP_OPEN=false BROWSER_KEEP_OPEN=false
# 启动时登录预检:预热浏览器后逐账号确认登录态,未登录则自动登录(默认开启)
LOGIN_PRECHECK_ENABLED=true
# 账号配置 # 账号配置
ACCOUNTS_CONFIG_PATH=./accounts.yaml ACCOUNTS_CONFIG_PATH=./accounts.yaml
+68
View File
@@ -0,0 +1,68 @@
# syntax=docker/dockerfile:1
# 基础镜像版本必须与 uv.lock 中的 playwright 版本一致(当前 1.60.0),
# 浏览器二进制与系统依赖已预装在 /ms-playwright。
FROM mcr.microsoft.com/playwright/python:v1.60.0-noble
ENV DEBIAN_FRONTEND=noninteractive
# X 虚拟桌面 + VNC/noVNC + 录屏/截图/图像匹配运行依赖 + 日文字体 + 进程编排
RUN apt-get update && apt-get install -y --no-install-recommends \
xvfb \
fluxbox \
x11vnc \
novnc \
websockify \
ffmpeg \
scrot \
libgl1 \
fonts-noto-cjk \
supervisor \
curl \
tzdata \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
WORKDIR /app
# 私有 PyPI 索引(git.jerryyan.net)若需要认证,构建时传入:
# docker build --build-arg UV_INDEX_JERRYYAN_USERNAME=xxx --build-arg UV_INDEX_JERRYYAN_PASSWORD=xxx .
ARG UV_INDEX_JERRYYAN_USERNAME
ARG UV_INDEX_JERRYYAN_PASSWORD
# 依赖层缓存:仅复制依赖清单后同步,源码变更不触发依赖重装
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
COPY src ./src
COPY resources ./resources
COPY .env.prod ./.env.prod
# 容器配置模板:运行时若挂载的 /app/config 中无 accounts.yaml,entrypoint 会用它初始化
COPY deploy/config/accounts.yaml /app/deploy-defaults/accounts.yaml
COPY docker/supervisord.conf /etc/supervisor/supervisord.conf
COPY docker/entrypoint.sh /entrypoint.sh
# 防御 Windows 仓库 CRLF 换行导致脚本无法执行
RUN sed -i 's/\r$//' /entrypoint.sh && chmod +x /entrypoint.sh
ENV APP_ENV=prod \
TZ=Asia/Shanghai \
DISPLAY=:99 \
SCREEN_RES=1920x1080x24 \
ACCOUNTS_CONFIG_PATH=/app/config/accounts.yaml \
PYTHONUNBUFFERED=1
# 关键数据声明为卷(兜底):即使 docker run 时未显式挂载,
# 浏览器登录态(accounts 的 user_data_dir 父目录)与账号配置也会落入匿名卷持久化。
# accounts.yaml 中每个账号的 user_data_dir 都应指向 /app/chrome-profile/ 下的子目录。
VOLUME ["/app/chrome-profile", "/app/config"]
# 6080: noVNC web | 5900: 原生 VNC | 8080: 截图/账号配置 API
EXPOSE 5900 6080 8080
# 启动需等待 Xvfb + 浏览器预热 + API 拉起
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
CMD curl -fs "http://localhost:8080/api/screenshots?days=1" > /dev/null || exit 1
ENTRYPOINT ["/entrypoint.sh"]
+43
View File
@@ -203,3 +203,46 @@ XADD purchase:tasks * task_id "task_test_1001" topic "surugaya_tasks" purchase_a
5.`record_video="1"`,任务结束后会输出视频文件名与绝对路径,并归档到:`archives/YYYYMMDD/surugaya_tasks/video_task_test_1001_{timestamp}.mp4` 5.`record_video="1"`,任务结束后会输出视频文件名与绝对路径,并归档到:`archives/YYYYMMDD/surugaya_tasks/video_task_test_1001_{timestamp}.mp4`
6. 任务结束后自动进行 `XACK` 确认并安全关舱。 6. 任务结束后自动进行 `XACK` 确认并安全关舱。
7. 若任务执行失败且通知模块已启用,系统会向 `MAIL_TO_ADDRESSES` 中配置的收件人发送失败邮件,邮件正文包含任务 ID、账号、错误原因、截图路径和录屏路径。 7. 若任务执行失败且通知模块已启用,系统会向 `MAIL_TO_ADDRESSES` 中配置的收件人发送失败邮件,邮件正文包含任务 ID、账号、错误原因、截图路径和录屏路径。
---
## 🐳 Docker 部署(Linux 服务器 + noVNC 远程桌面)
容器内通过 supervisord 编排 5 个进程:`Xvfb`(虚拟显示器 :99)→ `fluxbox`(窗口管理器)→ `x11vnc`(VNC server)→ `noVNC/websockify`(浏览器远程桌面)→ 业务进程。浏览器以有头模式跑在虚拟桌面上,Google Pay 模板匹配、pyautogui 鼠标操作与 FFmpeg 录屏(Linux 下自动切换为 `x11grab`)均在该桌面执行。
### 构建与启动
```bash
# 构建(构建机需能访问私有 PyPI 索引 git.jerryyan.net 拉取 surugaya-common;
# 若索引需认证,追加 --build-arg UV_INDEX_JERRYYAN_USERNAME=xxx --build-arg UV_INDEX_JERRYYAN_PASSWORD=xxx)
docker compose build
# 启动(VNC_PASSWORD 必填,是 noVNC/VNC 的访问密码)
export VNC_PASSWORD=your_password
docker compose up -d
```
### 访问入口
| 入口 | 地址 | 用途 |
| ---- | ---- | ---- |
| noVNC 远程桌面 | `http://<host>:6080/vnc.html` | 查看/操作容器内浏览器(鼠标键盘全可用,Google Pay 最终确认等人工环节在此操作) |
| 截图查看器 | `http://<host>:8080/` | 浏览归档截图 |
| 账号配置 | `http://<host>:8080/accounts` | 在线编辑 accounts.yaml,支持"保存并重启"(优雅停机后由 supervisord 自动拉起,按新配置重新预热浏览器) |
> ⚠️ 8080 与 6080 端口均无应用层鉴权(accounts.yaml 含账号密码),切勿直接暴露公网,应置于内网或经反向代理加鉴权后访问。
### 持久化目录(compose 已挂载)
- `deploy/chrome-profile/``/app/chrome-profile`:浏览器登录态,**必须持久化**;
- `deploy/config/accounts.yaml``/app/config/accounts.yaml`:账号配置(容器内 `ACCOUNTS_CONFIG_PATH` 已指向该路径),首次启动时若不存在会用镜像内模板初始化;
- `deploy/archives/``/archives``deploy/logs/``/logs`:截图/录屏归档与日志。
> 说明:`/app/chrome-profile` 与 `/app/config` 已在 Dockerfile 中声明 `VOLUME`——即使绕过 compose 直接 `docker run` 且忘记挂载,登录态与账号配置也会落入匿名卷而不会随容器删除丢失。新增账号无需改挂载:在 accounts.yaml 中把 `user_data_dir` 指向 `/app/chrome-profile/` 下新的子目录即可。
### 部署注意事项
1. **代理地址**:容器内 `127.0.0.1` 指向容器自身。代理跑在宿主机时,accounts.yaml 中应填 `http://host.docker.internal:<port>`(compose 已配置 `host-gateway`)。
2. **登录态迁移**:可将 Windows 的 `chrome-profile/<账号>` 目录拷入 `deploy/chrome-profile/`,但建议首次部署后通过 noVNC 人工核验登录态,失效则在远程桌面内重新登录一次。
3. **模板图重采集(重要)**`resources/images/` 下的模板图截自 Windows 屏幕,Linux 下字体/渲染差异可能导致 OpenCV 匹配(阈值 0.85)不达标。识别失败时系统会把整屏截图存到 `archives/<日期>/google_pay_debug/`(可经 8080 截图查看器浏览),从中裁出新模板替换后重新构建镜像即可。
4. **分辨率**:虚拟桌面默认 `1920x1080x24`,可通过环境变量 `SCREEN_RES` 调整;调整后模板图需按新分辨率重新采集。
+2 -2
View File
@@ -17,8 +17,8 @@ sites:
- account_id: "trdian023@tokugen.jp" - account_id: "trdian023@tokugen.jp"
username: "trdian023@tokugen.jp" username: "trdian023@tokugen.jp"
password: "trdian023.." password: "trdian023.."
user_data_dir: "C:\\Users\\yan1h\\Code\\jp_pruchase_zdh_5\\chrome-profile\\787235076" user_data_dir: "C:\\Users\\Administrator\\Code\\pruchase_jhw\\chrome-profile\\787235076"
proxy: "" # Optional residential proxy (e.g. "http://127.0.0.1:10808") proxy: "http://127.0.0.1:7890" # Optional residential proxy (e.g. "http://127.0.0.1:10808")
# Future sites can be appended here # Future sites can be appended here
mercari: [] mercari: []
+22
View File
@@ -0,0 +1,22 @@
# ==========================================
# Site Accounts Configuration (容器部署版)
# ==========================================
# 容器内路径约定:
# - user_data_dir 必须位于 /app/chrome-profile/ 下(compose 已挂载持久化卷)
# - 本文件由 compose 挂载到 /app/config/accounts.yaml,可在 http://<host>:8080/accounts 在线编辑
#
# 代理注意:容器内 127.0.0.1 指向容器自身!
# - 代理跑在宿主机时,使用 http://host.docker.internal:<port>(compose 已配置 host-gateway)
# - 或直接填写代理服务器的内网/公网地址
sites:
surugaya:
- account_id: "trdian023@tokugen.jp"
username: "trdian023@tokugen.jp"
password: "trdian023.."
user_data_dir: "/app/chrome-profile/787235076"
proxy: ""
# Future sites can be appended here
mercari: []
yahoo: []
+28
View File
@@ -0,0 +1,28 @@
services:
purchase-worker:
build: .
image: jp-purchase-zdh-5:latest
container_name: jp-purchase-worker
restart: unless-stopped
# Chromium 共享内存需求大,避免 /dev/shm 不足导致渲染崩溃
shm_size: "2g"
environment:
# noVNC/VNC 访问密码,部署机必须设置:export VNC_PASSWORD=xxx
VNC_PASSWORD: ${VNC_PASSWORD:?请在宿主机设置环境变量 VNC_PASSWORD}
# 虚拟桌面分辨率(重采集模板图后如需调整在此修改)
# SCREEN_RES: "1920x1080x24"
# 容器内通过 host.docker.internal 访问宿主机服务(如本机代理)
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "6080:6080" # noVNC:http://<host>:6080/vnc.html
- "8080:8080" # 截图查看 / 账号配置 API
# - "5900:5900" # 原生 VNC 客户端,按需开放
volumes:
# 浏览器登录态(user_data_dir),必须持久化
- ./deploy/chrome-profile:/app/chrome-profile
# accounts.yaml 所在目录(容器内 ACCOUNTS_CONFIG_PATH=/app/config/accounts.yaml)
- ./deploy/config:/app/config
# 截图/录屏归档与日志
- ./deploy/archives:/archives
- ./deploy/logs:/logs
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
set -e
# VNC 密码必填,避免远程桌面裸奔
: "${VNC_PASSWORD:?必须通过环境变量 VNC_PASSWORD 设置 noVNC/VNC 访问密码}"
mkdir -p /archives /logs /app/config /app/chrome-profile
# 生成 x11vnc 密码文件
x11vnc -storepasswd "$VNC_PASSWORD" /root/.vncpass > /dev/null 2>&1
# 挂载的配置目录中没有 accounts.yaml 时,用镜像内模板初始化
if [ ! -f /app/config/accounts.yaml ] && [ -f /app/deploy-defaults/accounts.yaml ]; then
echo "[entrypoint] /app/config/accounts.yaml 不存在,使用镜像内模板初始化。"
cp /app/deploy-defaults/accounts.yaml /app/config/accounts.yaml
fi
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
+66
View File
@@ -0,0 +1,66 @@
[supervisord]
nodaemon=true
user=root
logfile=/dev/null
logfile_maxbytes=0
pidfile=/tmp/supervisord.pid
[unix_http_server]
file=/tmp/supervisor.sock
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///tmp/supervisor.sock
; ---------- 虚拟桌面 ----------
[program:xvfb]
command=/usr/bin/Xvfb %(ENV_DISPLAY)s -screen 0 %(ENV_SCREEN_RES)s -ac -nolisten tcp
priority=10
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
redirect_stderr=true
[program:fluxbox]
command=/usr/bin/fluxbox
priority=20
autorestart=true
startretries=10
stdout_logfile=/dev/null
stdout_logfile_maxbytes=0
redirect_stderr=true
; ---------- VNC / noVNC ----------
[program:x11vnc]
command=/usr/bin/x11vnc -display %(ENV_DISPLAY)s -rfbauth /root/.vncpass -rfbport 5900 -forever -shared -noxdamage -quiet
priority=30
autorestart=true
startretries=10
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
redirect_stderr=true
[program:novnc]
command=/usr/bin/websockify --web /usr/share/novnc 6080 localhost:5900
priority=40
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
redirect_stderr=true
; ---------- 业务进程 ----------
; autorestart=true:进程任何形式退出都会被拉起,
; 配合 API 的 POST /api/restart(SIGTERM 优雅停机)实现"保存配置后重启"。
[program:app]
command=/app/.venv/bin/python -m src.main
directory=/app
priority=50
autorestart=true
startretries=3
stopsignal=TERM
stopwaitsecs=120
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
redirect_stderr=true
+160 -1
View File
@@ -4,13 +4,18 @@
启动后与主守护进程共享 asyncio 事件循环,不阻塞 Redis 消费。 启动后与主守护进程共享 asyncio 事件循环,不阻塞 Redis 消费。
""" """
import asyncio
import os import os
import signal
import sys
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
import yaml
from aiohttp import web from aiohttp import web
from loguru import logger from loguru import logger
from src.config.accounts import MultiAccountConfig
from src.config.settings import get_settings from src.config.settings import get_settings
# 默认只展示最近 N 天的截图 # 默认只展示最近 N 天的截图
@@ -117,6 +122,76 @@ async def handle_index(request: web.Request) -> web.Response:
return web.Response(text=_INDEX_HTML, content_type="text/html") 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 页面,运维人员可直接在浏览器中查看 # 简易 HTML 页面,运维人员可直接在浏览器中查看
_INDEX_HTML = """<!DOCTYPE html> _INDEX_HTML = """<!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
@@ -147,7 +222,7 @@ _INDEX_HTML = """<!DOCTYPE html>
</style> </style>
</head> </head>
<body> <body>
<h1>截图查看器</h1> <h1>截图查看器 <a href="/accounts" style="font-size:0.6em;font-weight:normal;margin-left:12px;">账号配置 →</a></h1>
<div class="filters"> <div class="filters">
<label>最近 <input type="number" id="days" value="3" min="1" max="30" style="width:50px"> 天</label> <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="scene" placeholder="如:任务执行出错"></label>
@@ -196,12 +271,96 @@ load();
</html>""" </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: def create_app() -> web.Application:
"""创建 aiohttp Application 实例。""" """创建 aiohttp Application 实例。"""
app = web.Application() app = web.Application()
app.router.add_get("/", handle_index) 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", handle_screenshots)
app.router.add_get("/api/screenshots/{date}/{topic}/{filename}", handle_screenshot_image) 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 return app
+2
View File
@@ -46,6 +46,8 @@ class Settings(BaseSettings):
browser_headless: bool = Field(default=True, alias="BROWSER_HEADLESS") browser_headless: bool = Field(default=True, alias="BROWSER_HEADLESS")
browser_executable_path: str | None = Field(default=None, alias="BROWSER_EXECUTABLE_PATH") browser_executable_path: str | None = Field(default=None, alias="BROWSER_EXECUTABLE_PATH")
browser_keep_open: bool = Field(default=False, alias="BROWSER_KEEP_OPEN") 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") 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.listener import RedisStreamListener
from src.task_queue.trade_fetch_listener import TradeFetchStreamListener from src.task_queue.trade_fetch_listener import TradeFetchStreamListener
from src.task_queue.redis_manager import get_redis_manager 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 from src.api.server import start_api_server
async def main() -> None: async def main() -> None:
@@ -51,6 +52,9 @@ async def main() -> None:
# 3. 预热浏览器:为所有已配置账号提前启动常驻浏览器上下文,任务到达时即开即用 # 3. 预热浏览器:为所有已配置账号提前启动常驻浏览器上下文,任务到达时即开即用
await get_browser_manager().prewarm() await get_browser_manager().prewarm()
# 3.1 登录预检:逐账号确认登录态,未登录则自动登录,避免任务到达时再耗时走登录流程
await login_precheck()
# 4. 实例化核心任务分发器与 Stream 监听器 # 4. 实例化核心任务分发器与 Stream 监听器
dispatcher = TaskDispatcher() dispatcher = TaskDispatcher()
stream_listener = RedisStreamListener(dispatcher) 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 playwright.async_api import TimeoutError as PlaywrightTimeoutError
from notifications import NotificationEvent from notifications import NotificationEvent
from src.tasks import surugaya_auth
from src.tasks.base import BasePurchaseTask from src.tasks.base import BasePurchaseTask
from src.config.accounts import get_account_config from src.config.accounts import get_account_config
from src.task_queue.redis_manager import get_redis_client 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: async def check_login_status(self, page: Page) -> bool:
""" """
通过限定页头区域来精准判断登录状态 通过限定页头区域来精准判断登录状态(共享实现见 surugaya_auth)。
""" """
try: return await surugaya_auth.check_login_status(page, log_prefix=f"[任务 {self.task_id}] ")
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
async def surugaya_login(self, page: Page) -> None: async def surugaya_login(self, page: Page) -> None:
""" """
骏河屋 (Surugaya) 专用安全登录方法 骏河屋 (Surugaya) 专用安全登录方法(共享实现见 surugaya_auth)。
:param page: Playwright 的 Page 对象 :param page: Playwright 的 Page 对象
""" """
account_config = get_account_config("surugaya", self.account_id) 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 password_input.press_sequentially(password, delay=100)
await asyncio.sleep(0.6) 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: try:
# 等待 URL 变成包含 pcmypage 的地址(代表成功进入个人中心) await surugaya_auth.surugaya_login(page, account_config, log_prefix=f"[任务 {self.task_id}] ")
await page.wait_for_url("**/pcmypage*", timeout=15000) except Exception:
logger.info(f"[任务 {self.task_id}] 骏河屋登录成功。")
except Exception as e:
logger.error(f"[任务 {self.task_id}] 骏河屋登录超时或失败,可能触发了验证码或账号凭据异常。")
await self.take_screenshot(scene="surugaya_login_failed") await self.take_screenshot(scene="surugaya_login_failed")
raise e raise
async def get_show_payment_methods(self, page: Page) -> list: 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 asyncio
import os
import shutil import shutil
import sys
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
import pyautogui import pyautogui
@@ -10,9 +12,24 @@ def _sanitize_path_component(value: str) -> str:
return sanitized or "unknown" 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: class TaskVideoRecorder:
""" """
使用 FFmpeg 进行 Windows 整桌面录制的轻量封装。 使用 FFmpeg 进行整桌面录制的轻量封装(Windows: gdigrab / Linux: x11grab)
生命周期由任务基类控制:start() 开始录制,stop() 停止并返回产物路径。 生命周期由任务基类控制:start() 开始录制,stop() 停止并返回产物路径。
""" """
@@ -61,20 +78,7 @@ class TaskVideoRecorder:
"-y", "-y",
"-loglevel", "-loglevel",
"error", "error",
"-f", *_build_capture_args(self.fps, screen_width, screen_height),
"gdigrab",
"-draw_mouse",
"1",
"-framerate",
str(self.fps),
"-video_size",
f"{screen_width}x{screen_height}",
"-offset_x",
"0",
"-offset_y",
"0",
"-i",
"desktop",
"-c:v", "-c:v",
"libx264", "libx264",
"-preset", "-preset",