diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b06eabe --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example index e11ca77..b40580d 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,8 @@ REDIS_DELAY_BATCH_SIZE=10 BROWSER_HEADLESS=false BROWSER_EXECUTABLE_PATH= BROWSER_KEEP_OPEN=false +# 启动时登录预检:预热浏览器后逐账号确认登录态,未登录则自动登录(默认开启) +LOGIN_PRECHECK_ENABLED=true # 账号配置 ACCOUNTS_CONFIG_PATH=./accounts.yaml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f824bd2 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 1a21294..3510128 100644 --- a/README.md +++ b/README.md @@ -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`。 6. 任务结束后自动进行 `XACK` 确认并安全关舱。 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://:6080/vnc.html` | 查看/操作容器内浏览器(鼠标键盘全可用,Google Pay 最终确认等人工环节在此操作) | +| 截图查看器 | `http://:8080/` | 浏览归档截图 | +| 账号配置 | `http://: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:`(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` 调整;调整后模板图需按新分辨率重新采集。 diff --git a/accounts.yaml b/accounts.yaml index 11281bd..0a92575 100644 --- a/accounts.yaml +++ b/accounts.yaml @@ -17,8 +17,8 @@ sites: - account_id: "trdian023@tokugen.jp" username: "trdian023@tokugen.jp" password: "trdian023.." - user_data_dir: "C:\\Users\\yan1h\\Code\\jp_pruchase_zdh_5\\chrome-profile\\787235076" - proxy: "" # Optional residential proxy (e.g. "http://127.0.0.1:10808") + user_data_dir: "C:\\Users\\Administrator\\Code\\pruchase_jhw\\chrome-profile\\787235076" + proxy: "http://127.0.0.1:7890" # Optional residential proxy (e.g. "http://127.0.0.1:10808") # Future sites can be appended here mercari: [] diff --git a/deploy/config/accounts.yaml b/deploy/config/accounts.yaml new file mode 100644 index 0000000..e002cc2 --- /dev/null +++ b/deploy/config/accounts.yaml @@ -0,0 +1,22 @@ +# ========================================== +# Site Accounts Configuration (容器部署版) +# ========================================== +# 容器内路径约定: +# - user_data_dir 必须位于 /app/chrome-profile/ 下(compose 已挂载持久化卷) +# - 本文件由 compose 挂载到 /app/config/accounts.yaml,可在 http://:8080/accounts 在线编辑 +# +# 代理注意:容器内 127.0.0.1 指向容器自身! +# - 代理跑在宿主机时,使用 http://host.docker.internal:(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: [] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..95906ae --- /dev/null +++ b/docker-compose.yml @@ -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://: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 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..14be706 --- /dev/null +++ b/docker/entrypoint.sh @@ -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 diff --git a/docker/supervisord.conf b/docker/supervisord.conf new file mode 100644 index 0000000..519963d --- /dev/null +++ b/docker/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 diff --git a/src/api/server.py b/src/api/server.py index 88fc6a0..ce8f19b 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -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 = """ @@ -147,7 +222,7 @@ _INDEX_HTML = """ -

截图查看器

+

截图查看器 账号配置 →

@@ -196,12 +271,96 @@ load(); """ +# accounts.yaml 在线编辑页面,供运维人员修改账号配置后触发优雅重启 +_ACCOUNTS_HTML = """ + + + + +账号配置 — JP Purchase Bot + + + +

账号配置 (accounts.yaml) ← 截图查看器

+
保存时会做 YAML 结构校验;修改仅在进程重启后生效。"保存并重启"会优雅停机后由 supervisord 自动拉起进程并按新配置重新预热浏览器。
+ +
+ + + +
+ + +""" + + 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 diff --git a/src/config/settings.py b/src/config/settings.py index 01580a5..93ea6cf 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -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") diff --git a/src/main.py b/src/main.py index b8e316c..2e51fe6 100644 --- a/src/main.py +++ b/src/main.py @@ -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) diff --git a/src/tasks/surugaya.py b/src/tasks/surugaya.py index 7248bcb..955a220 100644 --- a/src/tasks/surugaya.py +++ b/src/tasks/surugaya.py @@ -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: """ diff --git a/src/tasks/surugaya_auth.py b/src/tasks/surugaya_auth.py new file mode 100644 index 0000000..1c7a68a --- /dev/null +++ b/src/tasks/surugaya_auth.py @@ -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}") diff --git a/src/utils/video_recorder.py b/src/utils/video_recorder.py index ce53314..5666d00 100644 --- a/src/utils/video_recorder.py +++ b/src/utils/video_recorder.py @@ -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",