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