You've already forked pruchase_jhw
Docker打包
This commit is contained in:
@@ -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
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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
@@ -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: []
|
||||||
|
|||||||
@@ -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: []
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user