This commit is contained in:
Manchik
2026-05-15 17:17:42 +03:00
parent 13dc22f74f
commit 9cce9b5b36
16 changed files with 332 additions and 108 deletions
+18 -7
View File
@@ -3,9 +3,9 @@ import asyncio
from fastapi import APIRouter, HTTPException
from loguru import logger
from app.database import get_db
from app.config import read_validation_settings
from app.core.task_manager import task_manager
from app.database import get_db
router = APIRouter(prefix="/api", tags=["browser"])
@@ -20,8 +20,7 @@ async def open_browser(account_id: int):
account = dict(row)
from app.services.steam_auth import check_cookies_alive, _resolve_proxy
from app.config import read_validation_settings
from app.services.steam_auth import _resolve_proxy, check_cookies_alive
# If account isn't validated or has no cookies — auto-revalidate if enabled
if account["status"] != "valid" or not account.get("session_cookies"):
@@ -32,8 +31,14 @@ async def open_browser(account_id: int):
accounts=[account],
params={},
)
logger.info(f"Account {account['login']} not valid/no cookies, auto-revalidating → task {task_id}")
return {"status": "revalidating", "message": "Cookies expired. Re-validating...", "task_id": task_id}
logger.info(
f"Account {account['login']} not valid/no cookies, auto-revalidating → task {task_id}"
)
return {
"status": "revalidating",
"message": "Cookies expired. Re-validating...",
"task_id": task_id,
}
if account["status"] != "valid":
raise HTTPException(400, "Account is not validated")
raise HTTPException(400, "No session cookies. Validate the account first.")
@@ -48,8 +53,14 @@ async def open_browser(account_id: int):
accounts=[account],
params={},
)
logger.info(f"Cookies dead for {account['login']}, auto-revalidating → task {task_id}")
return {"status": "revalidating", "message": "Cookies expired. Re-validating...", "task_id": task_id}
logger.info(
f"Cookies dead for {account['login']}, auto-revalidating → task {task_id}"
)
return {
"status": "revalidating",
"message": "Cookies expired. Re-validating...",
"task_id": task_id,
}
raise HTTPException(400, "Session cookies expired. Re-validate the account.")
from app.services.browser_login import open_browser_with_cookies
+7 -2
View File
@@ -17,10 +17,15 @@ class Settings(BaseSettings):
proxies_path: Path = data_dir / "proxies.json"
max_concurrent_tasks: int = 5
request_timeout: int = 30
request_timeout: int = 30000 # milliseconds
proxy_rotation_interval: int = 60
model_config = {"env_prefix": "STEAM_PANEL_"}
model_config = {
"env_prefix": "STEAM_PANEL_",
"env_file": Path(__file__).resolve().parent.parent / ".env",
"env_file_encoding": "utf-8",
"extra": "ignore",
}
settings = Settings()
+18 -18
View File
@@ -6,30 +6,29 @@ from loguru import logger
from app.config import settings
LEVEL_ICONS = {
"TRACE": "[~]",
"DEBUG": "[.]",
"INFO": "[i]",
"TRACE": "[~]",
"DEBUG": "[.]",
"INFO": "[i]",
"SUCCESS": "[+]",
"WARNING": "[!]",
"ERROR": "[e]",
"CRITICAL":"[e]",
"ERROR": "[e]",
"CRITICAL": "[e]",
}
LEVEL_COLORS = {
"TRACE": "\033[35m",
"DEBUG": "\033[37m",
"INFO": "\033[34m",
"TRACE": "\033[35m",
"DEBUG": "\033[37m",
"INFO": "\033[34m",
"SUCCESS": "\033[32m",
"WARNING": "\033[33m",
"ERROR": "\033[31m",
"CRITICAL":"\033[31m",
"ERROR": "\033[31m",
"CRITICAL": "\033[31m",
}
RESET = "\033[0m"
def _loguru_fmt(record: dict) -> str:
def _loguru_fmt(record) -> str: # type: ignore[override]
level = record["level"].name
icon = LEVEL_ICONS.get(level, "[i]")
color = LEVEL_COLORS.get(level, "")
@@ -52,7 +51,9 @@ class _UvicornInterceptHandler(logging.Handler):
frame = frame.f_back
depth += 1
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
logger.opt(depth=depth, exception=record.exc_info).log(
level, record.getMessage()
)
_logging_configured = False
@@ -75,11 +76,13 @@ def setup_logging() -> None:
settings.logs_dir.mkdir(parents=True, exist_ok=True)
def _file_fmt(record: dict) -> str:
def _file_fmt(record) -> str: # type: ignore[override]
level = record["level"].name
icon = LEVEL_ICONS.get(level, "[i]")
ts = record["time"].strftime("%Y-%m-%d %H:%M:%S")
msg = record["message"].replace("<", "\\<").replace("{", "{{").replace("}", "}}")
msg = (
record["message"].replace("<", "\\<").replace("{", "{{").replace("}", "}}")
)
return f"[{ts}] {icon} {msg}\n"
logger.add(
@@ -133,6 +136,3 @@ def _install_intercept() -> None:
aiosqlite_log.addFilter(_AioSQLiteFilter())
logging.getLogger("asyncio").setLevel(logging.WARNING)
+211 -42
View File
@@ -1,5 +1,8 @@
import asyncio
import json
import shutil
import sys
from pathlib import Path
from loguru import logger
@@ -13,6 +16,93 @@ STEAM_DOMAINS = [
".login.steampowered.com",
]
# Common Chrome/Chromium install locations on Windows
_WINDOWS_CHROME_PATHS = [
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
r"C:\Program Files\Google\Chrome Beta\Application\chrome.exe",
r"C:\Program Files\Google\Chrome Dev\Application\chrome.exe",
r"C:\Program Files\Chromium\Application\chrome.exe",
r"C:\Program Files (x86)\Chromium\Application\chrome.exe",
]
def _find_chrome() -> str | None:
"""Try to locate a Chrome/Chromium binary. Returns path or None."""
# 1. Check PATH
for name in ("chrome", "chromium", "chromium-browser", "google-chrome"):
found = shutil.which(name)
if found:
return found
# 2. Windows-specific fixed paths + Registry
if sys.platform == "win32":
import os
local = os.environ.get("LOCALAPPDATA", "")
user_path = Path(local) / "Google" / "Chrome" / "Application" / "chrome.exe"
candidates = _WINDOWS_CHROME_PATHS + ([str(user_path)] if local else [])
for p in candidates:
if Path(p).exists():
return p
# Registry lookup (handles non-standard install paths)
try:
import winreg
reg_keys = [
(
winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe",
),
(
winreg.HKEY_CURRENT_USER,
r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe",
),
]
for hive, key_path in reg_keys:
try:
with winreg.OpenKey(hive, key_path) as key:
path, _ = winreg.QueryValueEx(key, "")
if path and Path(path).exists():
return path
except FileNotFoundError:
pass
except Exception:
pass
return None
async def _wait_connection_closed(browser) -> None:
"""Fallback: wait until CDP connection closes (with grace period)."""
await asyncio.sleep(3)
consecutive_closed = 0
tick = 0
while True:
await asyncio.sleep(1)
tick += 1
try:
closed = not browser or not browser.connection or browser.connection.closed
except Exception as e:
logger.debug(f"[browser] connection check error at tick={tick}: {e}")
closed = True
if tick % 5 == 0:
logger.debug(
f"[browser] fallback tick={tick} closed={closed} consecutive={consecutive_closed}"
)
if closed:
consecutive_closed += 1
logger.debug(
f"[browser] connection closed tick={tick}, consecutive={consecutive_closed}"
)
if consecutive_closed >= 3:
break
else:
consecutive_closed = 0
async def open_browser_with_cookies(account: dict) -> None:
"""Open a Chrome browser with Steam session cookies using nodriver."""
@@ -24,55 +114,134 @@ async def open_browser_with_cookies(account: dict) -> None:
cookies = json.loads(cookies_json)
browser = await uc.start()
# Try to find Chrome ourselves so we can give a better error
chrome_path = _find_chrome()
if chrome_path:
logger.debug(f"[browser] Using Chrome at: {chrome_path}")
else:
logger.warning(
"[browser] Chrome not found in standard locations — "
"nodriver will attempt its own detection"
)
try:
if chrome_path:
browser = await uc.start(browser_executable_path=chrome_path)
else:
browser = await uc.start()
except Exception as exc:
exc_str = str(exc).lower()
if (
"chrome" in exc_str
or "binary" in exc_str
or "executable" in exc_str
or "not found" in exc_str
):
raise RuntimeError(
"Chrome не найден. Установите Google Chrome с https://www.google.com/chrome/ "
f"(искали в: {chrome_path or 'стандартные пути'})"
) from exc
raise RuntimeError(f"Не удалось запустить браузер: {exc}") from exc
_active_browsers.append(browser)
tab = await browser.get("about:blank")
for c in cookies:
domain = c.get("domain") or ".steamcommunity.com"
domains_to_set = [domain]
if "steam" in domain:
domains_to_set = list(set([domain] + STEAM_DOMAINS))
for d in domains_to_set:
try:
await tab.send(uc.cdp.network.set_cookie(
name=c["name"],
value=c["value"],
domain=d,
path=c.get("path") or "/",
secure=c.get("secure", True),
http_only=c.get("httpOnly", False),
))
except Exception:
pass
steam_id = account.get("steam_id") or ""
if steam_id:
start_url = f"https://steamcommunity.com/profiles/{steam_id}/"
else:
start_url = "https://steamcommunity.com/"
await browser.get(start_url)
logger.info(f"Browser opened for {account['login']}")
# Wait until browser is closed by user, suppress connection errors
try:
while browser:
await asyncio.sleep(1)
tab = await browser.get("about:blank")
for c in cookies:
domain = c.get("domain") or ".steamcommunity.com"
domains_to_set = [domain]
if "steam" in domain:
domains_to_set = list(set([domain] + STEAM_DOMAINS))
for d in domains_to_set:
try:
await tab.send(
uc.cdp.network.set_cookie(
name=c["name"],
value=c["value"],
domain=d,
path=c.get("path") or "/",
secure=c.get("secure", True),
http_only=c.get("httpOnly", False),
)
)
except Exception:
pass
steam_id = account.get("steam_id") or ""
start_url = (
f"https://steamcommunity.com/profiles/{steam_id}/"
if steam_id
else "https://steamcommunity.com/"
)
await browser.get(start_url)
logger.info(f"[browser] Opened for {account.get('login', account.get('id'))}")
# Wait until the Chrome process exits.
# We check the process PID directly — this is immune to CDP
# connection state fluctuations during page navigation.
await asyncio.sleep(2) # let Chrome fully start
pid = getattr(browser, "_process_pid", None) or getattr(
getattr(browser, "_process", None), "pid", None
)
logger.debug(f"[browser] monitoring Chrome: pid={pid}")
if pid:
try:
if not browser.connection or browser.connection.closed:
break
except Exception:
break
except Exception:
pass
import psutil # type: ignore
proc = psutil.Process(pid)
logger.debug(
f"[browser] Chrome pid={pid} status={proc.status()} — waiting for user to close"
)
tick = 0
while True:
await asyncio.sleep(1)
tick += 1
try:
running = proc.is_running()
status = proc.status()
except psutil.NoSuchProcess:
logger.debug(
f"[browser] Chrome pid={pid} — process no longer exists (tick={tick})"
)
break
except Exception as e:
logger.debug(
f"[browser] Chrome pid={pid} — psutil error (tick={tick}): {e}"
)
break
if tick % 5 == 0:
conn_closed = getattr(
getattr(browser, "connection", None), "closed", "?"
)
logger.debug(
f"[browser] Chrome pid={pid} alive tick={tick} status={status} conn.closed={conn_closed}"
)
if not running or status == psutil.STATUS_ZOMBIE:
logger.debug(
f"[browser] Chrome pid={pid} exited at tick={tick}, final status={status}"
)
break
except ImportError:
logger.debug(
"[browser] psutil not installed — falling back to connection check"
)
await _wait_connection_closed(browser)
except Exception as e:
logger.debug(f"[browser] pid monitoring error: {e}")
else:
logger.debug(
"[browser] could not get Chrome PID — falling back to connection check"
)
await _wait_connection_closed(browser)
finally:
if browser in _active_browsers:
_active_browsers.remove(browser)
# Temporarily suppress nodriver's internal task errors during cleanup
# (Browser.update_targets() raises ConnectionRefusedError after close)
loop = asyncio.get_running_loop()
_original_handler = loop.get_exception_handler()
@@ -93,4 +262,4 @@ async def open_browser_with_cookies(account: dict) -> None:
await asyncio.sleep(0.5)
loop.set_exception_handler(_original_handler)
logger.debug(f"Browser closed for {account['login']}")
logger.debug(f"[browser] Closed for {account.get('login', account.get('id'))}")
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SteamPanel</title>
<script type="module" crossorigin src="/assets/index-_9agmVKx.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ztmqkldd.css">
<script type="module" crossorigin src="/assets/index-sYKK8_qa.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-gklBug3W.css">
</head>
<body>
<div id="root"></div>