google-mcp/lib/playwright_manager.py
Jarian Cottingham 645d8820f1 fix: repair middleware wiring, input validation, and resource lifecycle
- Wrap RateLimitMiddleware in Middleware() - bare class broke all requests (500)
- Clamp/validate num param - ?num=abc no longer returns 500
- Query length cap (500), result counts clamped to 1..20
- Wire lifespan into Starlette so shared httpx client closes on shutdown
- Purge stale per-IP rate limit entries to bound memory growth
- URL-encode queries in the Playwright scraper path
- Lazy lib imports so the package works without optional playwright
- Add pyproject.toml (activates ruff/pytest/bandit in CI), README, LICENSE
- Remove committed __pycache__, add .gitignore/.dockerignore
- Fix SearXNG healthcheck path (/health -> /healthz) and compose docs
2026-08-20 23:27:27 +00:00

115 lines
3.8 KiB
Python

import asyncio
import logging
from typing import Optional
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
logger = logging.getLogger(__name__)
class PlaywrightManager:
"""
Singleton-style manager for a single Playwright browser instance.
Ensures only ONE browser/process runs per container, with proper cleanup.
"""
_instance: Optional["PlaywrightManager"] = None
_pw_cm = None
_pw = None
_browser: Optional[Browser] = None
_context: Optional[BrowserContext] = None
_initialized = False
_cleanup_lock = asyncio.Lock()
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
@classmethod
async def initialize(cls, user_agent: Optional[str] = None, headless: bool = True):
if cls._initialized:
logger.debug("Playwright already initialized, reusing instance")
return cls._instance
cls._pw_cm = async_playwright()
cls._pw = await cls._pw_cm.__aenter__()
browser_args = {
"headless": headless,
"args": [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-blink-features=AutomationControlled",
],
}
cls._browser = await cls._pw.chromium.launch(**browser_args)
ua = user_agent or (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/131.0.0.0"
)
cls._context = await cls._browser.new_context(
user_agent=ua,
viewport={"width": 1920, "height": 1080},
locale="en-US",
timezone_id="America/New_York",
extra_http_headers={"Accept-Language": "en-US,en;q=0.9"},
)
cls._initialized = True
logger.info("Playwright browser initialized (single instance)")
return cls._instance
@classmethod
async def get_page(cls, stealth: bool = False) -> Page:
if not cls._initialized:
raise RuntimeError(
"PlaywrightManager not initialized. Call initialize() first."
)
page = await cls._context.new_page()
if stealth:
try:
from playwright_stealth import Stealth
stealth = Stealth()
await stealth.apply_stealth_async(page)
logger.debug("Stealth mode applied to page")
except Exception as e:
logger.warning(f"Could not apply stealth: {e}")
return page
@classmethod
async def close_page(cls, page: Page):
try:
await page.close()
except Exception as e:
logger.debug(f"Error closing page: {e}")
@classmethod
async def shutdown(cls):
async with cls._cleanup_lock:
if not cls._initialized:
return
logger.info("Shutting down Playwright browser...")
try:
if cls._context:
await cls._context.close()
cls._context = None
if cls._browser:
await cls._browser.close()
cls._browser = None
if cls._pw_cm:
await cls._pw_cm.__aexit__(None, None, None)
cls._pw = None
cls._pw_cm = None
except Exception as e:
logger.error(f"Error during Playwright shutdown: {e}")
finally:
cls._initialized = False
cls._instance = None
@classmethod
def is_initialized(cls) -> bool:
return cls._initialized