diff --git a/Dockers/puppeteer-api/app/routes/browser.py b/Dockers/puppeteer-api/app/routes/browser.py index 98611a9..c592a73 100644 --- a/Dockers/puppeteer-api/app/routes/browser.py +++ b/Dockers/puppeteer-api/app/routes/browser.py @@ -1,13 +1,13 @@ from fastapi import APIRouter, HTTPException, Header, Response -from typing import Optional +from typing import Optional, List, Literal, Union, Annotated from urllib.parse import unquote from app.config import API_KEY from app.database import get_cached_data, save_to_cache -from pydantic import BaseModel +from pydantic import BaseModel, Field from app.services.browser import ( visit_url_service, screenshot_url_service, - click_selector_service, + interactions_service, extract_seo_service, extract_meta_tags_service, capture_outgoing_calls_service, @@ -16,11 +16,27 @@ from app.services.browser import ( router = APIRouter() -class ClickRequest(BaseModel): - url: str +class ClickInteraction(BaseModel): + action: Literal["click"] selector: str - # Default behavior: return screenshot after clicking. - returnType: Optional[str] = "screenshot" + + +class TypeInteraction(BaseModel): + action: Literal["type"] + selector: str + text: str + + +Interaction = Annotated[ + Union[ClickInteraction, TypeInteraction], + Field(discriminator="action"), +] + + +class InteractionsRequest(BaseModel): + url: str + returnType: Optional[Literal["screenshot", "html"]] = "screenshot" + interactions: List[Interaction] @router.get("/") async def visit_url(url: str, skipCache: bool = False, x_api_key: Optional[str] = Header(None)): @@ -74,21 +90,19 @@ async def screenshot_url(url: str, fullPage: bool = True, x_api_key: Optional[st print(f"Error taking screenshot for URL {decoded_url}: {e}") raise HTTPException(status_code=500, detail=str(e)) -@router.post("/click") -async def click_selector(payload: ClickRequest, x_api_key: Optional[str] = Header(None)): +@router.post("/interactions") +async def run_interactions(payload: InteractionsRequest, x_api_key: Optional[str] = Header(None)): if not x_api_key or x_api_key != API_KEY: raise HTTPException(status_code=401, detail="Invalid API key") decoded_url = unquote(payload.url) return_type = (payload.returnType or "screenshot").lower() - if return_type not in ("screenshot", "html"): - raise HTTPException(status_code=400, detail="Invalid returnType. Use 'screenshot' or 'html'.") try: - result = await click_selector_service(decoded_url, payload.selector, return_type) + result = await interactions_service(decoded_url, payload.interactions, return_type) if isinstance(result, dict) and result.get("status") == "error": - raise HTTPException(status_code=500, detail=result.get("error", "Click failed")) + raise HTTPException(status_code=500, detail=result.get("error", "Interaction failed")) if return_type == "html": if not isinstance(result, str): @@ -101,7 +115,7 @@ async def click_selector(payload: ClickRequest, x_api_key: Optional[str] = Heade except HTTPException: raise except Exception as e: - print(f"Error clicking selector on URL {decoded_url}: {e}") + print(f"Error running interactions on URL {decoded_url}: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/seo") diff --git a/Dockers/puppeteer-api/app/services/browser.py b/Dockers/puppeteer-api/app/services/browser.py index 306db86..2ebc1b0 100644 --- a/Dockers/puppeteer-api/app/services/browser.py +++ b/Dockers/puppeteer-api/app/services/browser.py @@ -175,6 +175,85 @@ async def click_selector_service(decoded_url: str, selector: str, return_type: s return await safe_browser_operation(decoded_url, click_operation) +async def interactions_service(decoded_url: str, interactions, return_type: str): + """Open `decoded_url`, execute click/type interactions in order, then return screenshot or HTML.""" + print(f"Running interactions on {decoded_url} (return_type={return_type})") + + async def interactions_operation(page): + try: + # Navigate and wait for initial DOM. + await page.goto(decoded_url, wait_until="domcontentloaded", timeout=30000) + await page.wait_for_load_state("load", timeout=30000) + + # Ensure the page is fully loaded (helps with JS-driven UIs). + await page.wait_for_function("document.readyState === 'complete'", timeout=30000) + + # Best-effort cookie/banner handling (helps avoid click interception). + cookie_buttons = [ + "Accept all", + "Accepteer", + "Accepteren", + "Accept", + ] + for button in cookie_buttons: + try: + element = await page.query_selector(f'text="{button}"') + if element: + await element.click() + await page.wait_for_timeout(1000) + break + except Exception: + pass + + for interaction in interactions: + # Support both Pydantic model instances and raw dicts. + if isinstance(interaction, dict): + action = interaction.get("action") + selector = interaction.get("selector") + text = interaction.get("text") + else: + action = getattr(interaction, "action", None) + selector = getattr(interaction, "selector", None) + text = getattr(interaction, "text", None) + + if not action or not selector: + raise ValueError("Interaction must contain action and selector") + + if action == "click": + await page.wait_for_selector(selector, timeout=30000) + locator = page.locator(selector) + await locator.scroll_into_view_if_needed(timeout=30000) + await locator.click(force=True, timeout=30000) + + elif action == "type": + if text is None: + raise ValueError("Type interaction must contain text") + await page.wait_for_selector(selector, timeout=30000) + locator = page.locator(selector) + await locator.scroll_into_view_if_needed(timeout=30000) + await locator.fill(text) + + else: + raise ValueError(f"Unknown interaction action: {action}") + + # Let the UI settle after each action. + await page.wait_for_timeout(1000) + try: + await page.wait_for_load_state("networkidle", timeout=10000) + except Exception: + pass + + if return_type == "html": + return await page.content() + + return await page.screenshot(full_page=True, type="png") + + except Exception as e: + print(f"Error during interactions capture: {e}") + return {"status": "error", "url": decoded_url, "error": str(e)} + + return await safe_browser_operation(decoded_url, interactions_operation) + async def extract_seo_service(decoded_url): """Service function to extract SEO information from a website""" print(f"Extracting SEO from: {decoded_url}")