File size: 12,836 Bytes
ed79a7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
"""
High-Performance Playwright Browser Controller.
Manages browser lifecycle, isolated contexts, stealth flags, fast execution,
and visual annotated screenshot auditing.
"""

from __future__ import annotations

import asyncio
import io
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
from PIL import Image, ImageDraw
from playwright.async_api import Browser, BrowserContext, Page, Error as PlaywrightError, async_playwright

from miniswardbower.browser.action_executor import ActionExecutor
from miniswardbower.browser.dom_distiller import DOMDistiller
from miniswardbower.core.config import BrowserConfig
from miniswardbower.core.schemas import ActionBatch, BrowserAction, BrowserActionType, PrunedAXTree


class BrowserController:
    """Async Playwright browser engine for the multi-agent team."""

    def __init__(self, config: Optional[BrowserConfig] = None):
        self.config = config or BrowserConfig()
        self._playwright = None
        self._browser: Optional[Browser] = None
        self._context: Optional[BrowserContext] = None
        self._page: Optional[Page] = None
        self._lightpanda_driver = None
        self._obscura_driver = None
        self.distiller = DOMDistiller()
        # Obscura 0.2.2 hit-testing lets pointer-events:none overlays intercept
        # clicks. Keep annotations in the captured image until upstream fixes it.
        if self.config.browser_engine == "obscura":
            self.distiller.draw_overlays = False
        self.executor = ActionExecutor(action_timeout_ms=self.config.action_timeout_ms, enable_natural_dynamics=True)

    async def start(self) -> None:
        """Launches the configured browser; failures never silently change engines."""
        if self._playwright is not None or self._lightpanda_driver is not None or self._obscura_driver is not None:
            return

        if self.config.browser_engine in ("lightpanda", "obscura"):
            if self.config.browser_engine == "obscura":
                from miniswardbower.browser.obscura_driver import ObscuraDriver
                driver = ObscuraDriver(self.config.obscura_bin_path, self.config.obscura_allow_private_network)
            else:
                from miniswardbower.browser.lightpanda_driver import LightpandaDriver
                driver = LightpandaDriver(bin_path=self.config.lightpanda_bin_path)
            try:
                self._browser, self._context, self._page = await driver.start()
                await self.page.set_viewport_size({"width": self.config.window_width, "height": self.config.window_height})
            except BaseException:
                await driver.stop()
                self._browser = self._context = self._page = None
                raise
            setattr(self, f"_{self.config.browser_engine}_driver", driver)
            return

        self._playwright = await async_playwright().start()
        self._browser = await self._playwright.chromium.launch(
            headless=self.config.headless,
            args=[
                "--disable-blink-features=AutomationControlled",
                "--no-sandbox",
                "--disable-setuid-sandbox",
                "--disable-infobars",
                "--window-size=1280,800",
            ],
        )
        self._context = await self._browser.new_context(
            viewport={"width": self.config.window_width, "height": self.config.window_height},
            user_agent=self.config.user_agent,
            device_scale_factor=1,
            accept_downloads=True,
            locale="en-US",
            timezone_id="America/New_York",
            permissions=["geolocation", "notifications"],
            extra_http_headers={
                "Accept-Language": "en-US,en;q=0.9",
                "Sec-Ch-Ua": '"Not A(Brand";v="99", "Google Chrome";v="121", "Chromium";v="121"',
                "Sec-Ch-Ua-Mobile": "?0",
                "Sec-Ch-Ua-Platform": '"Linux"',
            },
        )

        # Stealth CDP Profile: Mask automation signatures & emulate authentic physical browser
        await self._context.add_init_script("""
            // 1. Mask navigator.webdriver
            try {
                Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
            } catch (e) {}

            // 2. Realistic window.chrome object
            if (!window.chrome) {
                window.chrome = {
                    app: { isInstalled: false },
                    runtime: { OnInstalledReason: {}, OnRestartRequiredReason: {} }
                };
            }

            // 3. Realistic plugins list
            try {
                Object.defineProperty(navigator, 'plugins', {
                    get: () => [
                        { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
                        { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
                        { name: 'Native Client', filename: 'internal-nacl-plugin', description: '' }
                    ]
                });
            } catch (e) {}

            // 4. Genuine languages
            try {
                Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
            } catch (e) {}

            // 5. Hardware-accelerated WebGL vendor profile (NVIDIA)
            try {
                const origGetParam = WebGLRenderingContext.prototype.getParameter;
                WebGLRenderingContext.prototype.getParameter = function(param) {
                    if (param === 37445) return 'Google Inc. (NVIDIA)';
                    if (param === 37446) return 'ANGLE (NVIDIA, NVIDIA GeForce RTX 5090 Direct3D11 vs_5_0 ps_5_0, D3D11)';
                    return origGetParam.apply(this, arguments);
                };
            } catch (e) {}
        """)

        self._page = await self._context.new_page()

    async def stop(self) -> None:
        """Closes browser context and its driver."""
        if self._obscura_driver is not None:
            await self._obscura_driver.stop()
            self._obscura_driver = None
            self._page = self._context = self._browser = None
            return

        if self._lightpanda_driver is not None:
            await self._lightpanda_driver.stop()
            self._lightpanda_driver = None
            self._page = None
            self._context = None
            self._browser = None
            return

        # A stalled page must not prevent closing its browser and transport.
        for resource, method in ((self._page, "close"), (self._context, "close"),
                                 (self._browser, "close"), (self._playwright, "stop")):
            if resource is not None:
                try:
                    await asyncio.wait_for(getattr(resource, method)(), timeout=5)
                except Exception as exc:
                    logging.getLogger(__name__).warning("Browser cleanup %s failed: %s", method, exc)
        self._page = None
        self._context = None
        self._browser = None
        self._playwright = None

    @property
    def page(self) -> Page:
        if self._page is None:
            raise RuntimeError("Browser is not started. Call await controller.start() first.")
        return self._page

    async def goto(self, url: str) -> None:
        """Navigates to URL and waits for DOM content loaded."""
        await self.page.goto(url, wait_until="domcontentloaded", timeout=self.config.navigation_timeout_ms)

    async def get_pruned_tree(self) -> PrunedAXTree:
        """Extracts the lightweight, sanitized AXTree."""
        for attempt in range(3):
            try:
                return await self.distiller.extract_pruned_tree(self.page)
            except PlaywrightError as exc:
                if "Execution context was destroyed" not in str(exc) or attempt == 2:
                    raise
                await self.page.wait_for_load_state("domcontentloaded", timeout=self.config.navigation_timeout_ms)
                await asyncio.sleep(0.2)

    async def screenshot(self, output_path: Union[Path, str]) -> Path:
        """Captures viewport screenshot for vision grounding or audit trail."""
        output_path = Path(output_path)
        output_path.parent.mkdir(parents=True, exist_ok=True)
        await self.page.screenshot(path=str(output_path), full_page=False)
        return output_path

    async def capture_annotated_step_screenshot(
        self,
        output_path: Union[Path, str],
        step_num: int,
        action_label: str,
        bbox: Optional[Tuple[float, float, float, float]] = None,
        click_coords: Optional[Tuple[float, float]] = None,
    ) -> Path:
        """
        Captures a high-resolution screenshot with visual annotations:
        - Target element bounding box with translucent green tint
        - Click coordinate glowing crosshairs
        - Step execution HUD badge
        """
        output_path = Path(output_path)
        output_path.parent.mkdir(parents=True, exist_ok=True)
        raw_bytes = await self.page.screenshot(full_page=False)
        img = Image.open(io.BytesIO(raw_bytes)).convert("RGBA")

        overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
        draw = ImageDraw.Draw(overlay)

        # 1. Bounding box overlay
        if bbox and bbox[2] > 0 and bbox[3] > 0:
            bx, by, bw, bh = bbox
            draw.rectangle(
                [bx, by, bx + bw, by + bh],
                fill=(16, 185, 129, 45),
                outline=(16, 185, 129, 255),
                width=3,
            )

        # 2. Click coordinate crosshair overlay
        if click_coords:
            cx, cy = click_coords
            # Outer ring
            draw.ellipse([cx - 14, cy - 14, cx + 14, cy + 14], outline=(239, 68, 68, 220), width=3)
            # Center target
            draw.ellipse([cx - 4, cy - 4, cx + 4, cy + 4], fill=(239, 68, 68, 255))
            # Crosshair tick marks
            draw.line([cx - 20, cy, cx - 6, cy], fill=(239, 68, 68, 240), width=2)
            draw.line([cx + 6, cy, cx + 20, cy], fill=(239, 68, 68, 240), width=2)
            draw.line([cx, cy - 20, cx, cy - 6], fill=(239, 68, 68, 240), width=2)
            draw.line([cx, cy + 6, cx, cy + 20], fill=(239, 68, 68, 240), width=2)

        # 3. Top-left HUD badge
        badge_width = min(420, max(280, len(action_label) * 8 + 60))
        draw.rectangle([12, 12, 12 + badge_width, 50], fill=(15, 23, 42, 225), outline=(56, 189, 248, 255), width=2)
        draw.text((22, 20), f"Step {step_num}: {action_label[:45]}", fill=(248, 250, 252, 255))

        annotated = Image.alpha_composite(img, overlay).convert("RGB")
        annotated.save(output_path, "PNG")
        return output_path

    async def execute_action(self, action: BrowserAction, tree: Optional[PrunedAXTree] = None) -> Dict[str, Any]:
        """Executes a single atomic action."""
        return await self.executor.execute_action(self.page, action, tree)

    async def execute_batch(self, batch: ActionBatch, tree: Optional[PrunedAXTree] = None) -> List[Dict[str, Any]]:
        """Executes a speculative batch of actions."""
        return await self.executor.execute_batch(self.page, batch, tree)

    async def execute_chunk(
        self,
        actions: List[BrowserAction],
        tree: Optional[PrunedAXTree] = None,
        early_stop_on_navigation: bool = True,
    ) -> List[Dict[str, Any]]:
        """Executes a pipelined speculative action chunk."""
        return await self.executor.execute_chunk(
            self.page, actions, tree, early_stop_on_navigation=early_stop_on_navigation
        )

    async def scrape_page(self) -> Dict[str, Any]:
        """Scrapes structured page data (metadata, tables, card elements, text)."""
        action = BrowserAction(op=BrowserActionType.EXTRACT)
        details = await self.executor.execute_action(self.page, action)
        return details.get("extracted_data", {})

    async def scrape_element(self, target: str, tree: Optional[PrunedAXTree] = None) -> Dict[str, Any]:
        """Scrapes structured content and attributes from a specific targeted element."""
        action = BrowserAction(op=BrowserActionType.EXTRACT, target=target)
        details = await self.executor.execute_action(self.page, action, tree)
        return details.get("extracted_data", {})

    async def scrape_table(self, selector: str = "table") -> List[List[str]]:
        """Scrapes 2D matrix rows from a tabular element."""
        action = BrowserAction(op=BrowserActionType.EXTRACT, extract_selector=selector)
        details = await self.executor.execute_action(self.page, action)
        data = details.get("extracted_data", [])
        return data