Skip to content

[Bug]: Pydoll being detected by the Datadome #438

Description

@GustavoHP2006

Checklist before reporting

  • I have searched for similar issues and didn't find a duplicate.
  • I have updated to the latest version of pydoll to verify the issue still exists.

pydoll Version

2.23.1

Python Version

3.13

Operating System

Windows

Bug Description

I've been trying to do an information search automation, but sometimes Datadome's slidercaptcha appeared. but now, they basically always appear And the worst, I made a code with pydoll to grab and drag the slide of the datadome, the problem that it detects that it is a robot doing, but if I use it in my hand it recognizes that it is not let it continue One way to force the appearance of the Datadome Slider Captcha is to open the browser as headless on a site protected by Datadome, and then it can be opened without being headless because Datadome has already marked it as a bot (it will appear every time you open the site, thus having to solve the captchas)

Steps to Reproduce

To reproduce the drag method must be used for a datadome slider captch, as it is being caught

Code Example

import asyncio
import traceback

from pydoll.browser import Chrome
from pydoll.browser.tab import Tab
from pydoll.browser.options import ChromiumOptions
from pydoll.interactions.mouse import MouseTimingConfig
import os


# Parâmetros de humanização do movimento (curvas, tremor, timing) —
# já embutidos no pydoll, não precisamos reinventar em Python puro.
MOUSE_CONFIG = MouseTimingConfig(
    fitts_a=0.070,
    fitts_b=0.150,
    frame_interval=0.012,
    curvature_min=0.10,
    curvature_max=0.30,
    tremor_amplitude=1.0,
    overshoot_probability=0.70,
    min_duration=0.35,
    max_duration=0.65,
)


def create_full_stealth_options() -> ChromiumOptions:
    """Configura e retorna as opções do navegador Chromium."""
    options = ChromiumOptions()
    options.add_argument("--start-maximized")
    options.prompt_for_download = False
    options.set_default_download_directory(os.path.join(os.getcwd(), "downloads"))
    
    options.browser_preferences = {
        "profile": {
            "default_content_setting_values": {
                "automatic_downloads": 1,
                "images": 1,
                "notifications": 1,
                "popups": 1,
                "geolocation": 1,
                "media_stream": 1,
                "media_stream_mic": 1,
                "media_stream_camera": 1,
            }
        },
        "translate": {"enabled": False},
        "credentials_enable_service": False,
        "devtools": {"preferences": {"currentDockState": '"undocked"'}},
    }
    return options


class DataDomeSliderSolver:
    """Resolve o slider captcha do DataDome usando o mouse humanizado
    NATIVO do pydoll (via CDP, coordenadas relativas ao próprio iframe).

    Isso elimina toda a cadeia de conversão OS/DPI/janela que causava
    imprecisão: sem PyAutoGUI, sem win32, sem devicePixelRatio, sem
    posição de janela — o CDP já lida com tudo isso internamente.
    """

    def __init__(
        self,
        browser: Chrome,
        page: Tab,
        timeout: int = 30,
        max_attempts: int = 10,
        overshoot: float = 20,
        debug: bool = False,
    ):
        self.browser = browser
        self.page = page
        self.timeout = timeout
        self.max_attempts = max_attempts
        self.overshoot = overshoot
        self.debug = debug

    def _log(self, *args):
        if self.debug:
            print("[DataDome]", *args)

    # -- localização do iframe/slider ---------------------------------------

    async def _find_datadome_iframe(self):
        """Procura, entre os iframes da página, o do DataDome que
        contenha o slider. Retorna só o `src` (evita objectId stale)."""
        for attempt in range(self.max_attempts):
            iframes = await self.page.find(
                tag_name="iframe",
                timeout=self.timeout,
                raise_exc=False,
                find_all=True,
            )
            if not iframes:
                await asyncio.sleep(0.3)
                continue
            for ifrm in iframes:
                src = ifrm.get_attribute("src") or ""
                if "https://geo.captcha-delivery.com/" not in src:
                    continue
                slider_container = await ifrm.find(
                    class_name="sliderContainer",
                    raise_exc=False,
                    timeout=2,
                )
                if slider_container is not None:
                    self._log(f"iframe encontrado na tentativa {attempt + 1}: {src}")
                    return src
            await asyncio.sleep(0.3)
        return None

    async def _get_fresh_iframe_by_src(self, src: str):
        """Reconsulta o iframe usando o src como chave, garantindo um
        objectId válido no momento da leitura."""
        iframes = await self.page.find(
            tag_name="iframe",
            timeout=self.timeout,
            raise_exc=False,
            find_all=True,
        )
        for ifrm in iframes or []:
            if ifrm.get_attribute("src") == src:
                return ifrm
        return None

    async def _get_bounds_with_retry(self, element, retries: int = 3, delay: float = 0.3):
        """Tenta pegar o bounding box; se der erro de CDP (objectId
        stale), espera um pouco e tenta de novo."""
        last_error = None
        for _ in range(retries):
            try:
                return await element.get_bounds_using_js()
            except KeyError as e:
                last_error = e
                await asyncio.sleep(delay)
        raise last_error

    async def _find_visible_element(self, iframe, class_name: str):
        """Busca TODOS os elementos com essa classe dentro do iframe e
        retorna o primeiro com dimensão visível real (evita pegar
        duplicatas ocultas de acessibilidade com a mesma classe)."""
        candidates = await iframe.find(
            class_name=class_name,
            find_all=True,
            raise_exc=False,
            timeout=self.timeout,
        )
        if not candidates:
            return None, None

        for el in candidates:
            try:
                box = await self._get_bounds_with_retry(el)
            except Exception:
                continue
            if box["width"] > 0 and box["height"] > 0:
                self._log(f"'.{class_name}' visível: x={box['x']:.1f} y={box['y']:.1f} w={box['width']:.1f} h={box['height']:.1f}")
                return el, box

        self._log(f"'.{class_name}': nenhum candidato visível entre {len(candidates)} encontrados")
        return None, None

    # -- fluxo principal -------------------------------------------------------

    async def solve(self) -> bool:
        src = await self._find_datadome_iframe()
        if not src:
            self._log("iframe/slider não encontrado")
            return False

        iframe = await self._get_fresh_iframe_by_src(src)
        if not iframe:
            self._log("iframe sumiu antes de conseguir medir")
            return False

        slider_container, container_box = await self._find_visible_element(iframe, "sliderContainer")
        slider_btn, slider_box = await self._find_visible_element(iframe, "slider")

        if not slider_container or not slider_btn:
            self._log("slider/container visível não encontrado")
            return False

        # Coordenadas RELATIVAS AO PRÓPRIO IFRAME — sem nenhuma conversão
        # de tela/DPI/janela. É isso que o CDP espera quando o dispatch
        # é feito através do objeto `iframe` (não da `page`).
        start_x = slider_box["x"] + slider_box["width"] / 2
        start_y = slider_box["y"] + slider_box["height"] / 2
        end_x = (
            container_box["x"]
            + container_box["width"]
            - slider_box["width"] / 2
            + self.overshoot
        )
        end_y = start_y  # o slider é horizontal, sem variação de Y

        self._log(f"drag: start=({start_x:.1f}, {start_y:.1f}) end=({end_x:.1f}, {end_y:.1f})")
        self.page.mouse.timing = MOUSE_CONFIG
        await iframe._mouse.drag(
            start_x, start_y, end_x, end_y,
            humanize=True,
        )

        return await self._confirm_solved(iframe)

    async def _confirm_solved(self, iframe, wait_seconds: int = 15) -> bool:
        await asyncio.sleep(wait_seconds)
        success = await iframe.find(
            tag_name="div",
            class_name="slider-success",
            timeout=self.timeout,
            find_all=True,
            raise_exc=False,
        )
        solved = bool(success)
        self._log("captcha resolvido" if solved else "não foi possível confirmar a resolução")
        return solved


# ---------------------------------------------------------------------------
# Exemplo de uso
# ---------------------------------------------------------------------------

async def main():
    url_alvo = "https://www.idealista.com/venta-viviendas/barcelona-provincia/"

    browser = Chrome(options=create_full_stealth_options())
    page = await browser.start(headless=False)

    try:
        await page.go_to(url_alvo, timeout=120)
        await asyncio.sleep(5)

        solver = DataDomeSliderSolver(browser, page, debug=True)
        resolved = await solver.solve()

        if resolved:
            print("seguindo fluxo normal...")
        else:
            print("captcha não resolvido, tratar retry/erro")

        await asyncio.sleep(1_000_000)

    except Exception:
        traceback.print_exc()
        await asyncio.sleep(1_000_000)


if __name__ == "__main__":
    asyncio.run(main())

Expected Behavior

No response

Actual Behavior

No response

Relevant Log Output

Additional Context

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions