Ctrl K
ComunidadGuías…

tests/integration/project_web_browser.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import logging
import os
import shutil
import subprocess
from typing import List

import html2text
import requests
from playwright.async_api import async_playwright

# Import your cache class
from src.api.entities_api.cache.web_cache import WebSessionCache

logger = logging.getLogger("UniversalWebReader")


class UniversalWebReader:
    """
    The 'Eyes' of the agent.
    Responsibility: Fetch raw content (Static or Dynamic), clean it, chunk it,
    and hand it off to the WebSessionCache.
    """

    def __init__(self, cache_service: WebSessionCache):
        self.cache = cache_service

        # HTML to Markdown Configuration
        self.converter = html2text.HTML2Text()
        self.converter.ignore_links = False
        self.converter.ignore_images = True
        self.converter.ignore_tables = False
        self.converter.body_width = 0

        # Config
        self.CHUNK_SIZE = 4000
        self.browser_ws = os.getenv(
            "BROWSER_WS_ENDPOINT", None
        )  # e.g. ws://browser:3000
        self.has_curl = shutil.which("curl") is not None

        self.garbage_triggers = [
            "enable javascript",
            "please wait",
            "loading...",
            "javascript is required",
            "checking your browser",
            "turn on js",
        ]

    async def read(self, url: str, force_refresh: bool = False) -> str:
        """
        Action: Scrape a URL.
        1. Checks Redis (via WebSessionCache).
        2. If missing, Fetches (Curl -> Browserless).
        3. Saves to Redis.
        4. Returns Page 0.
        """
        # 1. Check Cache
        if not force_refresh:
            # We check if the session exists in your Redis Cache
            cached_session = await self.cache.get_session(url)
            if cached_session:
                logger.info(f"⚡ Cache Hit for {url}")
                # Return Page 0 using your cache's formatting logic
                return await self.cache.get_page_view(url, 0)

        # 2. Fetch Content
        logger.info(f"🌐 Fetching fresh content: {url}")
        content = self._fetch_static(url)
        source = "Static (Fast)"

        # 3. Validate & Failover to Browser
        if not self._is_valid_content(content):
            logger.info(
                "⚠️ Static fetch failed/garbage. Switching to Sidecar Browser..."
            )
            content = await self._fetch_dynamic(url)
            source = "Dynamic (Browser)"

        # 4. Process & Chunk
        clean_text = content.strip()
        if not clean_text:
            return "❌ Error: Could not extract any text from this URL."

        chunks = self._chunk_text(clean_text)

        # 5. Save to your Redis Cache
        await self.cache.save_session(
            url=url, full_text=clean_text, chunks=chunks, source=source
        )

        # 6. Return Page 0
        return await self.cache.get_page_view(url, 0)

    async def scroll(self, url: str, page: int) -> str:
        """
        Action: Scroll.
        Direct pass-through to the cache logic.
        """
        return await self.cache.get_page_view(url, page)

    # --- INTERNAL HELPERS ---

    def _chunk_text(self, text: str) -> List[str]:
        """Splits text into 4000-char pages."""
        return [
            text[i : i + self.CHUNK_SIZE] for i in range(0, len(text), self.CHUNK_SIZE)
        ]

    def _is_valid_content(self, text: str) -> bool:
        """Detects if we got a 'Please enable JS' stub."""
        if not text or len(text) < 300:
            return False
        header = text.lower()[:500]
        return not any(trigger in header for trigger in self.garbage_triggers)

    # --- NETWORK LAYERS (Docker Optimized) ---

    def _fetch_static(self, url: str) -> str:
        """Layer 1: Curl (Fastest, avoids some bot blocks)."""
        if self.has_curl:
            try:
                # -L follows redirects, -s silent, --max-time prevents hangs
                cmd = [
                    "curl",
                    "-L",
                    "-s",
                    "--max-time",
                    "10",
                    "--user-agent",
                    "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
                    url,
                ]
                result = subprocess.run(cmd, capture_output=True, text=True)

                if result.returncode == 0 and len(result.stdout) > 100:
                    return self.converter.handle(result.stdout)
            except Exception as e:
                logger.warning(f"Curl error: {e}")

        # Fallback: Requests
        try:
            resp = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
            return self.converter.handle(resp.text)
        except Exception:
            return ""

    async def _fetch_dynamic(self, url: str) -> str:
        """Layer 2: Browserless Sidecar (Heavy duty)."""
        async with async_playwright() as p:
            browser = None
            try:
                if self.browser_ws:
                    # Connect to the 'browser' container defined in docker-compose
                    browser = await p.chromium.connect_over_cdp(self.browser_ws)
                else:
                    # Local fallback (dev mode)
                    browser = await p.chromium.launch(headless=True)

                context = await browser.new_context()
                page = await context.new_page()

                # Extended timeout for heavy sites
                await page.goto(url, timeout=45000, wait_until="domcontentloaded")

                # Optional: Smart wait for text hydration
                try:
                    await page.wait_for_selector("body", timeout=5000)
                except Exception:
                    pass

                html = await page.content()
                return self.converter.handle(html)
            except Exception as e:
                logger.error(f"Browser error: {e}")
                return f"Error reading page via browser: {e}"
            finally:
                if browser:
                    await browser.close()