|
# 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)
|