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
+14
View File
@@ -0,0 +1,14 @@
# SteamPanel configuration
# Copy this file to .env and edit as needed.
# All settings are optional — defaults are shown below.
# Log level: DEBUG shows detailed internal logs (browser, DB, tasks)
# INFO is the default (clean output)
STEAM_PANEL_DEBUG=false
# Server bind address and port
STEAM_PANEL_HOST=127.0.0.1
STEAM_PANEL_PORT=8000
# Per-account HTTP timeout in milliseconds
STEAM_PANEL_REQUEST_TIMEOUT=30000
+4 -1
View File
@@ -1,3 +1,6 @@
# Environment / local config
.env
# Python
__pycache__/
*.py[cod]
@@ -34,4 +37,4 @@ Thumbs.db
# Misc
example/
*.log
*.mafile
*.mafile
+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>
@@ -23,9 +23,9 @@ function Row({
}) {
return (
<div className="flex text-[11px] font-mono leading-[1.7]">
<span className="w-[110px] shrink-0 text-gray-500">{label}</span>
<span className="w-27.5 shrink-0 text-gray-500">{label}</span>
<span
className={`min-w-0 break-words ${
className={`min-w-0 wrap-break-word ${
good ? "text-green-400" : bad ? "text-red-400" : "text-gray-200"
}`}
>
@@ -116,7 +116,7 @@ export function TokenCheckModal({ account, onClose, onRecheck }: Props) {
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
onClick={handleBackdrop}
>
<div className="bg-dark-800 border border-dark-600 rounded-xl w-full max-w-[640px] max-h-[85vh] flex flex-col shadow-2xl mx-4">
<div className="bg-dark-800 border border-dark-600 rounded-xl w-full max-w-160 max-h-[85vh] flex flex-col shadow-2xl mx-4">
{/* Header */}
<div className="px-5 pt-4 pb-3 border-b border-dark-600 flex items-start justify-between shrink-0">
<div className="flex items-start gap-3 min-w-0">
@@ -274,22 +274,41 @@ export function TokenCheckModal({ account, onClose, onRecheck }: Props) {
{/* Inventory */}
<Card title="Inventory">
<Row
label="CS2"
value={`${data.inventory_cs2} (${data.inventory_cs2_marketable})`}
/>
<Row
label="Dota 2"
value={`${data.inventory_dota2} (${data.inventory_dota2_marketable})`}
/>
<Row
label="TF2"
value={`${data.inventory_tf2} (${data.inventory_tf2_marketable})`}
/>
<Row
label="Rust"
value={`${data.inventory_rust} (${data.inventory_rust_marketable})`}
/>
{[
{
label: "CS2",
total: data.inventory_cs2,
market: data.inventory_cs2_marketable,
},
{
label: "Dota 2",
total: data.inventory_dota2,
market: data.inventory_dota2_marketable,
},
{
label: "TF2",
total: data.inventory_tf2,
market: data.inventory_tf2_marketable,
},
{
label: "Rust",
total: data.inventory_rust,
market: data.inventory_rust_marketable,
},
].map(({ label, total, market }) => (
<div
key={label}
className="flex text-[11px] font-mono leading-[1.7]"
>
<span className="w-27.5 shrink-0 text-gray-500">
{label}
</span>
<span className="text-gray-200">
{total}
<span className="text-gray-600"> ({market})</span>
</span>
</div>
))}
</Card>
</div>
)}
@@ -53,6 +53,7 @@ function ThreadStepper({
value: number;
onChange: (v: number) => void;
}) {
const t = useT();
const [editing, setEditing] = useState(false);
const [input, setInput] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
@@ -72,7 +73,7 @@ function ThreadStepper({
className="text-sm text-gray-400 flex-1"
title="Двойной клик на числе для ручного ввода"
>
Max threads
{t("tools.maxThreads")}
</span>
<div className="flex items-center gap-1">
<button
Regular → Executable
+1 -3
View File
@@ -26,15 +26,13 @@ from app.core.logging import setup_logging
from app.core.proxy_manager import proxy_manager
from app.database import close_db, get_db
from app.services.registry import register_all_handlers
from version import VERSION
mimetypes.add_type("application/javascript", ".js")
mimetypes.add_type("text/css", ".css")
mimetypes.add_type("image/svg+xml", ".svg")
VERSION = "0.3.5"
@asynccontextmanager
async def lifespan(app: FastAPI):
setup_logging()
+3 -2
View File
@@ -1,4 +1,4 @@
# === SteamPanel v0.3.0 ===
# === SteamPanel ===
# Web framework
fastapi>=0.115.0
@@ -26,6 +26,7 @@ loguru>=0.7.3
# Browser automation
nodriver
psutil>=6.0.0
# Steam auth (embedded locally from pysteamauth, no pip install needed)
bitstring>=3.1.2
@@ -37,4 +38,4 @@ yarl>=1.9.0
typing_extensions>=4.15.0
# Dev
pytest>=9.0.0
pytest>=9.0.0
+9 -7
View File
@@ -33,20 +33,22 @@ if errorlevel 1 (
venv\Scripts\pip.exe install -r requirements.txt
if errorlevel 1 goto :err_deps
echo Dependencies installed.
rem === Patch nodriver/cdp/network.py encoding bug (non-UTF-8 byte in comment) ===
venv\Scripts\python.exe -c "import pathlib; p=pathlib.Path('venv/Lib/site-packages/nodriver/cdp/network.py'); d=p.read_bytes() if p.exists() else b''; p.write_bytes(d.replace(b'\xb1',b'+-')) if p.exists() and b'\xb1' in d else None"
) else (
echo Dependencies OK.
)
rem === Ensure psutil is installed (required for browser process monitoring) ===
venv\Scripts\pip.exe show psutil >nul 2>&1
if errorlevel 1 (
echo Installing psutil...
venv\Scripts\pip.exe install psutil>=6.0.0 -q
)
:launch
cls
rem === Patch nodriver/cdp/network.py encoding bug (non-UTF-8 byte \xb1 in comment) ===
venv\Scripts\python.exe -c ^
"import pathlib;^
p=pathlib.Path('venv/Lib/site-packages/nodriver/cdp/network.py');^
d=p.read_bytes() if p.exists() else b'';^
p.write_bytes(d.replace(b'\xb1',b'+-')) if p.exists() and b'\xb1' in d else None"
rem === Check built frontend ===
if not exist "app\static\assets\" (
echo.
+1
View File
@@ -0,0 +1 @@
VERSION = "0.3.6"