# retoor <retoor@molodetz.nl>
import asyncio
import logging
import random
import time
from typing import Optional
from playwright.async_api import Page, Playwright, async_playwright
from devplacepy.services.bot.config import (
MONITOR_JPEG_QUALITY,
MONITOR_MIN_INTERVAL_SECONDS,
MONITOR_SCALE,
)
from devplacepy.services.bot.monitor import monitor
logger = logging.getLogger(__name__)
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"
)
DEFAULT_VIEWPORT = {"width": 1280, "height": 900}
class BotBrowser:
def __init__(
self,
headless: bool = True,
user_agent: str = "",
viewport: Optional[dict] = None,
):
self._headless = headless
self._user_agent = user_agent or DEFAULT_USER_AGENT
self._viewport = dict(viewport) if viewport else dict(DEFAULT_VIEWPORT)
self._playwright: Optional[Playwright] = None
self._browser = None
self._context = None
self._page: Optional[Page] = None
self._monitor_slot: Optional[int] = None
self._last_capture: float = 0.0
async def launch(self) -> None:
self._playwright = await async_playwright().start()
self._browser = await self._playwright.chromium.launch(
headless=self._headless,
args=[
"--disable-blink-features=AutomationControlled",
"--no-first-run",
"--disable-dev-shm-usage",
f"--window-size={self._viewport['width']},{self._viewport['height']}",
],
)
self._context = await self._browser.new_context(
user_agent=self._user_agent,
viewport=self._viewport,
locale="en-US",
)
self._page = await self._context.new_page()
async def close(self) -> None:
context, self._context = self._context, None
browser, self._browser = self._browser, None
playwright, self._playwright = self._playwright, None
self._page = None
for label, resource, shutdown in (
("context", context, lambda r: r.close()),
("browser", browser, lambda r: r.close()),
("playwright", playwright, lambda r: r.stop()),
):
if resource is None:
continue
try:
await shutdown(resource)
except Exception as e:
logger.debug("Browser %s close error: %s", label, e)
@property
def page(self) -> Page:
return self._page
def bind_monitor(
self, slot: int, *, username: str = "", persona: str = ""
) -> None:
self._monitor_slot = slot
monitor.update_meta(slot, username=username, persona=persona)
def note(self, *, action: str = "", status: str = "") -> None:
if self._monitor_slot is None:
return
monitor.update_meta(self._monitor_slot, action=action, status=status)
async def capture(self, reason: str = "", *, force: bool = False) -> None:
if self._monitor_slot is None or self._page is None:
return
now = time.time()
if not force and now - self._last_capture < MONITOR_MIN_INTERVAL_SECONDS:
return
self._last_capture = now
try:
current_url = ""
try:
current_url = self._page.url
except Exception:
current_url = ""
monitor.update_meta(
self._monitor_slot, status=reason, url=current_url
)
image = await self._page.screenshot(
type="jpeg",
quality=MONITOR_JPEG_QUALITY,
scale=MONITOR_SCALE,
full_page=False,
timeout=4000,
)
monitor.store_image(self._monitor_slot, image)
except Exception as e:
logger.debug("monitor capture (%s) failed: %s", reason, e)
async def goto(self, url: str) -> None:
try:
await self._page.goto(url, wait_until="domcontentloaded", timeout=15000)
except Exception as e:
logger.debug("goto (domcontentloaded) failed for %s: %s", url[:80], e)
try:
await self._page.goto(url, timeout=20000)
except Exception as e2:
logger.warning("goto failed for %s: %s", url[:80], e2)
await self._idle(0.5, 1.5)
await self.capture("page load", force=True)
async def _idle(self, lo: float = 0.5, hi: float = 2.0) -> None:
await asyncio.sleep(random.uniform(lo, hi))
async def scroll(self, amount: Optional[int] = None) -> None:
if amount is None:
amount = random.randint(300, 800)
try:
await self._page.evaluate(f"window.scrollBy(0, {amount})")
except Exception as e:
logger.debug("scroll failed: %s", e)
await self._idle(0.2, 0.6)
await self.capture("scroll")
async def scroll_to_bottom(self) -> None:
try:
await self._page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
except Exception as e:
logger.debug("scroll_to_bottom failed: %s", e)
async def fill(self, sel: str, val: str) -> bool:
try:
el = self._page.locator(sel).first
if await el.count() == 0:
logger.debug("fill: selector '%s' not found", sel)
return False
await el.click(timeout=3000)
await self._idle(0.05, 0.15)
await el.fill("")
for ch in val:
await self._page.keyboard.type(ch, delay=random.randint(20, 60))
if ch == " ":
await asyncio.sleep(random.uniform(0.02, 0.08))
await self.capture("field input")
return True
except Exception as e:
logger.debug("fill failed for '%s': %s", sel, e)
return False
async def click(self, sel: str) -> bool:
try:
el = self._page.locator(sel).first
if await el.count() == 0:
logger.debug("click: selector '%s' not found", sel)
return False
box = await el.bounding_box()
if box:
tx = box["x"] + box["width"] / 2 + random.uniform(-3, 3)
ty = box["y"] + box["height"] / 2 + random.uniform(-3, 3)
await self._page.mouse.move(tx, ty, steps=random.randint(8, 18))
await self._idle(0.02, 0.06)
await el.click(timeout=5000)
return True
except Exception as e:
logger.debug("click failed for '%s': %s", sel, e)
return False
async def text(self, sel: str) -> str:
try:
return (await self._page.locator(sel).first.inner_text()) or ""
except Exception as e:
logger.debug("text failed for '%s': %s", sel, e)
return ""
async def attr(self, sel: str, attr: str) -> str:
try:
val = await self._page.locator(sel).first.get_attribute(attr)
return val or ""
except Exception as e:
logger.debug("attr '%s' on '%s' failed: %s", attr, sel, e)
return ""
async def html(self) -> str:
try:
return await self._page.content()
except Exception as e:
logger.debug("html() failed: %s", e)
return ""
async def url(self) -> str:
try:
return self._page.url
except Exception as e:
logger.debug("url() failed: %s", e)
return ""
async def title(self) -> str:
try:
return await self._page.title()
except Exception as e:
logger.debug("title() failed: %s", e)
return ""
async def reload(self) -> None:
try:
await self._page.reload(timeout=15000)
await self._idle()
await self.capture("reload", force=True)
except Exception as e:
logger.debug("reload failed: %s", e)