Skip to content

Commit b16ae30

Browse files
committed
Merge branch 'main' of github-personal.com:autoscrape-labs/pydoll
2 parents 8b70209 + d7f774c commit b16ae30

9 files changed

Lines changed: 259 additions & 58 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
## 2.23.1 (2026-07-16)
2+
3+
### Fix
4+
5+
- **cloudflare**: improve checkbox click logic for faster failure handling
6+
- **cloudflare**: update checkbox selector and improve bypass logic
7+
- nested iframes
8+
- **mouse**: keep debug overlay visible across page navigations
9+
110
## 2.23.0 (2026-05-22)
211

312
### Feat

cz.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
commitizen:
33
name: cz_conventional_commits
44
tag_format: $version
5-
version: 2.23.0
5+
version: 2.23.1

pydoll/browser/requests/har_recorder.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@
5454

5555
_PYDOLL_CREATOR_NAME = 'pydoll'
5656
_HTTP_NOT_MODIFIED = 304
57+
_BODY_FETCH_ATTEMPTS = 5
58+
_BODY_FETCH_RETRY_DELAY = 0.1
5759

5860

5961
def _get_pydoll_version() -> str:
@@ -265,7 +267,8 @@ async def _finalize_entry(self, request_id: str) -> None:
265267
if not pending:
266268
return
267269

268-
body, base64_encoded = await self._fetch_response_body(request_id)
270+
expects_body = pending.get('body_bytes', -1) > 0
271+
body, base64_encoded = await self._fetch_response_body(request_id, expects_body)
269272
pending['response_body'] = body
270273
pending['response_body_base64'] = base64_encoded
271274

@@ -302,20 +305,43 @@ def _flush_pending(self) -> None:
302305
self._entries.append(entry)
303306
logger.debug('HAR: flushed pending entries')
304307

305-
async def _fetch_response_body(self, request_id: str) -> tuple[str, bool]:
308+
async def _fetch_response_body(
309+
self, request_id: str, expects_body: bool = False
310+
) -> tuple[str, bool]:
306311
"""Fetch the response body via Network.getResponseBody.
307312
313+
The DevTools body buffer is occasionally not ready the instant
314+
loadingFinished fires (seen intermittently on Windows). When the
315+
dataReceived events already told us a body exists (``expects_body``),
316+
retry briefly on an errored or empty result instead of silently
317+
recording an empty body.
318+
308319
Returns:
309320
Tuple of (body_text, is_base64_encoded). Returns ('', False) on failure.
310321
"""
311-
try:
312-
command = NetworkCommands.get_response_body(request_id)
313-
response: GetResponseBodyResponse = await self._tab._execute_command(command)
314-
body_result = response['result']
315-
return body_result['body'], body_result['base64Encoded']
316-
except Exception:
317-
logger.debug('HAR: failed to fetch response body for %s', request_id)
318-
return '', False
322+
attempts = _BODY_FETCH_ATTEMPTS if expects_body else 1
323+
for attempt in range(attempts):
324+
try:
325+
command = NetworkCommands.get_response_body(request_id)
326+
response: GetResponseBodyResponse = await self._tab._execute_command(command)
327+
body_result = response['result']
328+
body, base64_encoded = body_result['body'], body_result['base64Encoded']
329+
if body or not expects_body:
330+
return body, base64_encoded
331+
except Exception:
332+
logger.debug(
333+
'HAR: failed to fetch response body for %s (attempt %d/%d)',
334+
request_id,
335+
attempt + 1,
336+
attempts,
337+
)
338+
if attempt + 1 < attempts:
339+
await asyncio.sleep(_BODY_FETCH_RETRY_DELAY)
340+
341+
logger.debug(
342+
'HAR: response body unavailable for %s after %d attempt(s)', request_id, attempts
343+
)
344+
return '', False
319345

320346
def _build_entry(self, pending: dict[str, Any]) -> HarEntry:
321347
"""Build a HAR entry from accumulated pending data."""

pydoll/browser/tab.py

Lines changed: 52 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@
131131

132132
_CLOUDFLARE_CHALLENGE_DOMAIN = 'challenges.cloudflare.com'
133133
_CLOUDFLARE_IFRAME_SELECTOR = f'iframe[src*="{_CLOUDFLARE_CHALLENGE_DOMAIN}"]'
134-
_CLOUDFLARE_CHECKBOX_SELECTOR = 'span.cb-i'
134+
_CLOUDFLARE_CHECKBOX_SELECTOR = 'input[type="checkbox"]'
135135

136136

137137
class Tab(FindElementsMixin):
@@ -1966,36 +1966,36 @@ def on_loaded(_: dict):
19661966
with contextlib.suppress(Exception):
19671967
await self.disable_page_events()
19681968

1969-
async def _find_cloudflare_shadow_root(self, timeout: float) -> ShadowRoot:
1970-
"""Poll for the Cloudflare Turnstile shadow root.
1969+
async def _find_cloudflare_shadow_root(self) -> Optional[ShadowRoot]:
1970+
"""Return the Cloudflare Turnstile shadow root if currently present.
19711971
1972-
Repeatedly calls ``find_shadow_roots(deep=False)`` and checks each
1973-
shadow root's ``inner_html`` for the Cloudflare challenge domain.
1974-
1975-
Args:
1976-
timeout: Maximum seconds to wait for the shadow root.
1977-
1978-
Returns:
1979-
The first ShadowRoot whose inner HTML contains
1980-
``challenges.cloudflare.com``.
1981-
1982-
Raises:
1983-
WaitElementTimeout: If no matching shadow root is found within
1984-
*timeout* seconds.
1972+
Performs a single scan of the page's shadow roots and returns the first
1973+
one whose ``inner_html`` references ``challenges.cloudflare.com``, or
1974+
``None`` when the challenge widget has not been injected yet.
19851975
"""
1986-
start_time = asyncio.get_event_loop().time()
1987-
while True:
1988-
shadow_roots = await self.find_shadow_roots(deep=False)
1989-
for sr in shadow_roots:
1990-
html = await sr.inner_html
1991-
if _CLOUDFLARE_CHALLENGE_DOMAIN in html:
1992-
return sr
1976+
for shadow_root in await self.find_shadow_roots(deep=False):
1977+
with contextlib.suppress(Exception):
1978+
if _CLOUDFLARE_CHALLENGE_DOMAIN in await shadow_root.inner_html:
1979+
return shadow_root
1980+
return None
19931981

1994-
if asyncio.get_event_loop().time() - start_time > timeout:
1995-
raise WaitElementTimeout(
1996-
f'Timed out after {timeout}s waiting for Cloudflare Turnstile shadow root'
1997-
)
1998-
await asyncio.sleep(0.5)
1982+
@staticmethod
1983+
async def _click_cloudflare_checkbox(shadow_root: ShadowRoot) -> None:
1984+
"""Traverse the Turnstile widget and click its verification checkbox.
1985+
1986+
Navigates shadow root -> challenge iframe -> body -> inner shadow root
1987+
and clicks the ``input[type="checkbox"]`` element. Every step fails
1988+
fast (``timeout=0``): any node captured here can go stale while
1989+
Cloudflare re-renders the iframe, and polling locally on a stale node
1990+
both wastes time and can let four sequential waits overrun the caller's
1991+
deadline. Failing fast lets ``_bypass_cloudflare`` restart the whole
1992+
traversal from the top on its next poll.
1993+
"""
1994+
iframe = await shadow_root.query(_CLOUDFLARE_IFRAME_SELECTOR, timeout=0)
1995+
body = await iframe.find(tag_name='body', timeout=0)
1996+
inner_shadow = await body.get_shadow_root(timeout=0)
1997+
checkbox = await inner_shadow.query(_CLOUDFLARE_CHECKBOX_SELECTOR, timeout=0)
1998+
await checkbox.click()
19991999

20002000
async def _bypass_cloudflare(
20012001
self,
@@ -2004,21 +2004,31 @@ async def _bypass_cloudflare(
20042004
) -> None:
20052005
"""Attempt to bypass Cloudflare Turnstile captcha via shadow root traversal.
20062006
2007-
Traverses shadow roots to locate the Cloudflare iframe, navigates into
2008-
it, and clicks the actual checkbox element (``span.cb-i``).
2007+
Polls for the challenge widget and clicks its checkbox, retrying the
2008+
whole traversal until *time_to_wait_captcha* elapses. Retrying is
2009+
required because Cloudflare injects the widget after the load event and
2010+
re-renders the challenge iframe during its proof-of-work, which
2011+
invalidates any node captured mid-traversal.
20092012
"""
2010-
try:
2011-
timeout_int = int(time_to_wait_captcha)
2012-
shadow_root = await self._find_cloudflare_shadow_root(
2013-
timeout=time_to_wait_captcha,
2014-
)
2015-
iframe = await shadow_root.query(_CLOUDFLARE_IFRAME_SELECTOR, timeout=timeout_int)
2016-
body = await iframe.find(tag_name='body', timeout=timeout_int)
2017-
inner_shadow = await body.get_shadow_root(timeout=time_to_wait_captcha)
2018-
checkbox = await inner_shadow.query(_CLOUDFLARE_CHECKBOX_SELECTOR, timeout=timeout_int)
2019-
await checkbox.click()
2020-
except Exception as exc:
2021-
logger.error(f'Error in cloudflare bypass: {exc}')
2013+
loop = asyncio.get_event_loop()
2014+
deadline = loop.time() + time_to_wait_captcha
2015+
last_error: Optional[Exception] = None
2016+
while True:
2017+
try:
2018+
shadow_root = await self._find_cloudflare_shadow_root()
2019+
if shadow_root is not None:
2020+
await self._click_cloudflare_checkbox(shadow_root)
2021+
return
2022+
except Exception as exc:
2023+
last_error = exc
2024+
logger.debug(f'Cloudflare bypass attempt failed, retrying: {exc}')
2025+
2026+
if loop.time() >= deadline:
2027+
break
2028+
await asyncio.sleep(0.5)
2029+
2030+
if last_error is not None:
2031+
logger.error(f'Error in cloudflare bypass: {last_error}')
20222032

20232033

20242034
class _DownloadHandle:

pydoll/interactions/iframe.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,13 @@ async def resolve(self) -> IFrameContext:
7474

7575
context = IFrameContext(frame_id=frame_id, document_url=document_url)
7676

77-
if session_handler and session_id:
78-
context.session_handler = session_handler
79-
context.session_id = session_id
80-
8177
effective_handler = session_handler or base_handler
8278
effective_session_id = session_id or base_session_id
8379

80+
if effective_session_id:
81+
context.session_handler = effective_handler
82+
context.session_id = effective_session_id
83+
8484
execution_context_id = await self._create_isolated_world_for_frame(
8585
frame_id, effective_handler, effective_session_id
8686
)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "pydoll-python"
3-
version = "2.23.0"
3+
version = "2.23.1"
44
description = "Pydoll is a library for automating chromium-based browsers without a WebDriver, offering realistic interactions."
55
authors = ["Thalison Fernandes <thalissfernandes99@gmail.com>"]
66
readme = "README.md"

tests/integration/pages/oopif/oopif_content.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ <h1 id="oopif-heading">Cross-Origin Content</h1>
1919
<iframe id="nested-iframe" src="oopif_nested.html"
2020
style="width:600px;height:200px;border:1px solid #666;"></iframe>
2121

22+
<!-- Nested data: URL iframe (opaque origin, no own OOPIF target) -->
23+
<iframe id="data-iframe"
24+
style="width:400px;height:120px;border:1px solid #333;"></iframe>
25+
2226
<!-- Shadow root with elements and a nested iframe -->
2327
<div id="shadow-host"></div>
2428

@@ -32,6 +36,16 @@ <h1 id="oopif-heading">Cross-Origin Content</h1>
3236
});
3337
})();
3438

39+
// Data: URL iframe (opaque origin) nested inside this OOPIF
40+
(function() {
41+
document.getElementById('data-iframe').src =
42+
'data:text/html,' +
43+
encodeURIComponent(
44+
'<h2 id="data-heading">Data Frame Content</h2>' +
45+
'<input id="data-input" type="text">'
46+
);
47+
})();
48+
3549
// Shadow root containing text, button, and a nested iframe
3650
(function() {
3751
var host = document.getElementById('shadow-host');

tests/integration/test_nested_oopif_integration.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,26 @@ def _wait_for_server(host: str, port: int, timeout: float = 5.0) -> None:
3838
raise RuntimeError(f'Server {host}:{port} not ready within {timeout}s')
3939

4040

41+
def _cross_site_main_url(port_a: int, port_b: int) -> str:
42+
"""Build a main-page URL that is a different *site* than the OOPIF.
43+
44+
``oopif_main.html`` always points its child iframe at ``127.0.0.1:{port_b}``,
45+
so serving the main page from ``localhost`` (a distinct registrable domain
46+
for Chrome's site isolation) forces the child into a real out-of-process
47+
iframe with its own target/session. Cross-*port* alone (``127.0.0.1`` on
48+
both sides) is same-site and would not create an OOPIF.
49+
50+
Skips the test when ``localhost`` does not resolve to the loopback address
51+
the server is bound to (e.g. IPv6-only ``localhost``).
52+
"""
53+
try:
54+
with socket.create_connection(('localhost', port_a), timeout=0.5):
55+
pass
56+
except OSError:
57+
pytest.skip('localhost is not reachable on the IPv4 loopback server')
58+
return f'http://localhost:{port_a}/oopif_main.html?port={port_b}'
59+
60+
4161
@pytest.fixture(scope='module')
4262
def cross_origin_servers():
4363
"""Two HTTP servers on different ports -> different origins -> OOPIF."""
@@ -152,6 +172,75 @@ async def test_type_text_in_nested_iframe_inside_oopif(
152172
await wait_for_js_value(input_el, 'this.value', 'hello from nested oopif')
153173

154174

175+
class TestDataUrlIframeInsideOopif:
176+
"""Regression for nested cross-origin iframes: main -> OOPIF -> data: iframe.
177+
178+
Reproduces the reporter's scenario (nestedframes.netlify.app) where the
179+
inner iframe uses a ``data:`` URL. A ``data:`` frame has an opaque origin
180+
and stays inside the parent OOPIF's process, so it has no target of its
181+
own. IFrameContextResolver therefore resolves no OOPIF session for it and
182+
must fall back to the parent OOPIF session that created the isolated world;
183+
before the fix it evaluated on the tab session and raised InvalidIFrame.
184+
"""
185+
186+
@pytest.mark.asyncio
187+
async def test_find_element_in_data_iframe_inside_oopif(
188+
self, ci_chrome_options, cross_origin_servers
189+
):
190+
port_a, port_b = cross_origin_servers
191+
url = _cross_site_main_url(port_a, port_b)
192+
193+
ci_chrome_options.add_argument('--site-per-process')
194+
async with Chrome(options=ci_chrome_options) as browser:
195+
tab = await browser.start()
196+
await tab.go_to(url)
197+
198+
oopif = await tab.find(id='cross-origin-iframe', timeout=10)
199+
data_iframe = await oopif.find(id='data-iframe', timeout=10)
200+
assert data_iframe.is_iframe
201+
202+
heading = await data_iframe.find(id='data-heading', timeout=10)
203+
assert await heading.text == 'Data Frame Content'
204+
205+
@pytest.mark.asyncio
206+
async def test_find_body_in_data_iframe_inside_oopif(
207+
self, ci_chrome_options, cross_origin_servers
208+
):
209+
"""The exact call from the bug report: find(tag_name='body')."""
210+
port_a, port_b = cross_origin_servers
211+
url = _cross_site_main_url(port_a, port_b)
212+
213+
ci_chrome_options.add_argument('--site-per-process')
214+
async with Chrome(options=ci_chrome_options) as browser:
215+
tab = await browser.start()
216+
await tab.go_to(url)
217+
218+
oopif = await tab.find(id='cross-origin-iframe', timeout=10)
219+
data_iframe = await oopif.find(id='data-iframe', timeout=10)
220+
221+
body = await data_iframe.find(tag_name='body', timeout=10)
222+
assert 'Data Frame Content' in await body.text
223+
224+
@pytest.mark.asyncio
225+
async def test_type_text_in_data_iframe_inside_oopif(
226+
self, ci_chrome_options, cross_origin_servers
227+
):
228+
port_a, port_b = cross_origin_servers
229+
url = _cross_site_main_url(port_a, port_b)
230+
231+
ci_chrome_options.add_argument('--site-per-process')
232+
async with Chrome(options=ci_chrome_options) as browser:
233+
tab = await browser.start()
234+
await tab.go_to(url)
235+
236+
oopif = await tab.find(id='cross-origin-iframe', timeout=10)
237+
data_iframe = await oopif.find(id='data-iframe', timeout=10)
238+
239+
input_el = await data_iframe.find(id='data-input', timeout=10)
240+
await input_el.type_text('typed into data frame')
241+
await wait_for_js_value(input_el, 'this.value', 'typed into data frame')
242+
243+
155244
class TestShadowRootInsideOopif:
156245
"""Discovering and interacting with shadow roots inside OOPIFs."""
157246

0 commit comments

Comments
 (0)