- 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
115 lines
3.8 KiB
Python
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
|