Skip to content

Commit f164713

Browse files
committed
feat(fetchers): keep browser tabs open and reuse them across requests
Browser sessions no longer close a tab after its request is completed. The tab returns to the pool as ready, and the next request reuses it, reapplying its own timeouts, extra headers, and resource/domain routes (after `unroute_all`) so nothing leaks between requests. Tabs that hit an error or got closed by the browser are closed and evicted, and the internal response listener is detached after every request so reused tabs don't stack handlers. Adds `close_pages()` to all browser sessions to close every open tab; the next request opens a fresh one. `PagePool` gains `get_ready_page`/`remove_page`/`clear` and `PageInfo.mark_ready`, new pages start busy, and the unused `cleanup_error_pages` is removed. Proxy-rotation contexts keep closing per request. Docs and the agent skill describe the new lifecycle.
1 parent 6c5b691 commit f164713

14 files changed

Lines changed: 644 additions & 320 deletions

File tree

agent-skill/Scrapling-Skill.zip

238 Bytes
Binary file not shown.

agent-skill/Scrapling-Skill/references/fetching/dynamic.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -325,14 +325,19 @@ async def scrape_multiple_sites():
325325
return pages
326326
```
327327

328-
You may have noticed the `max_pages` argument. This is a new argument that enables the fetcher to create a **rotating pool of Browser tabs**. Instead of using a single tab for all your requests, you set a limit on the maximum number of pages that can be displayed at once. With each request, the library will close all tabs that have finished their task and check if the number of the current tabs is lower than the maximum allowed number of pages/tabs, then:
328+
You may have noticed the `max_pages` argument. It enables the fetcher to keep a **pool of Browser tabs**, and you set the maximum number of tabs that can be open at once. Tabs stay open after their request finishes, so with each request, the library will:
329329

330-
1. If you are within the allowed range, the fetcher will create a new tab for you, and then all is as normal.
331-
2. Otherwise, it will keep checking every subsecond if creating a new tab is allowed or not for 60 seconds, then raise `TimeoutError`. This can happen when the website you are fetching becomes unresponsive.
330+
1. Reuse a free tab if there's one. Every request applies its own tab-level settings (`timeout`, `extra_headers`, `disable_resources`, `blocked_domains`, etc.) to the tab it gets, so nothing leaks from the previous request.
331+
2. Otherwise, open a new tab if the number of open tabs is lower than `max_pages`.
332+
3. Otherwise, keep checking every subsecond for a tab to become free for 60 seconds, then raise `TimeoutError`. This can happen when the website you are fetching becomes unresponsive.
333+
334+
Tabs that hit an error are closed and replaced, and you can close all the open tabs yourself at any point with `session.close_pages()`, then the next request opens a fresh one.
332335

333336
This logic allows for multiple URLs to be fetched at the same time in the same browser, which saves a lot of resources, but most importantly, is so fast :)
334337

335-
In versions 0.3 and 0.3.1, the pool was reusing finished tabs to save more resources/time. That logic proved flawed, as it's nearly impossible to protect pages/tabs from contamination by the previous configuration used in the request before this one.
338+
Keeping the tabs open also means the page you fetched is still there for the next request, so a `page_setup` function on the next request runs on it before navigating away. That's the building block for chaining automation across requests.
339+
340+
Versions 0.3.2 to 0.4.14 closed every tab after its request because reusing tabs used to leak settings between requests. Since 0.4.15, the settings are reset on every reuse, so the tabs stay open.
336341

337342
### Session Benefits
338343

agent-skill/Scrapling-Skill/references/fetching/stealthy.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,14 +228,19 @@ async def scrape_multiple_sites():
228228
return pages
229229
```
230230

231-
You may have noticed the `max_pages` argument. This is a new argument that enables the fetcher to create a **rotating pool of Browser tabs**. Instead of using a single tab for all your requests, you set a limit on the maximum number of pages that can be displayed at once. With each request, the library will close all tabs that have finished their task and check if the number of the current tabs is lower than the maximum allowed number of pages/tabs, then:
231+
You may have noticed the `max_pages` argument. It enables the fetcher to keep a **pool of Browser tabs**, and you set the maximum number of tabs that can be open at once. Tabs stay open after their request finishes, so with each request, the library will:
232232

233-
1. If you are within the allowed range, the fetcher will create a new tab for you, and then all is as normal.
234-
2. Otherwise, it will keep checking every subsecond if creating a new tab is allowed or not for 60 seconds, then raise `TimeoutError`. This can happen when the website you are fetching becomes unresponsive.
233+
1. Reuse a free tab if there's one. Every request applies its own tab-level settings (`timeout`, `extra_headers`, `disable_resources`, `blocked_domains`, etc.) to the tab it gets, so nothing leaks from the previous request.
234+
2. Otherwise, open a new tab if the number of open tabs is lower than `max_pages`.
235+
3. Otherwise, keep checking every subsecond for a tab to become free for 60 seconds, then raise `TimeoutError`. This can happen when the website you are fetching becomes unresponsive.
236+
237+
Tabs that hit an error are closed and replaced, and you can close all the open tabs yourself at any point with `session.close_pages()`, then the next request opens a fresh one.
235238

236239
This logic allows for multiple URLs to be fetched at the same time in the same browser, which saves a lot of resources, but most importantly, is so fast :)
237240

238-
In versions 0.3 and 0.3.1, the pool was reusing finished tabs to save more resources/time. That logic proved flawed, as it's nearly impossible to protect pages/tabs from contamination by the previous configuration used in the request before this one.
241+
Keeping the tabs open also means the page you fetched is still there for the next request, so a `page_setup` function on the next request runs on it before navigating away. That's the building block for chaining automation across requests.
242+
243+
Versions 0.3.2 to 0.4.14 closed every tab after its request because reusing tabs used to leak settings between requests. Since 0.4.15, the settings are reset on every reuse, so the tabs stay open.
239244

240245
### Session Benefits
241246

docs/fetching/dynamic.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,14 +151,19 @@ async def scrape_multiple_sites():
151151
return pages
152152
```
153153

154-
You may have noticed the `max_pages` argument. This is a new argument that enables the fetcher to create a **rotating pool of Browser tabs**. Instead of using a single tab for all your requests, you set a limit on the maximum number of pages that can be displayed at once. With each request, the library will close all tabs that have finished their task and check if the number of the current tabs is lower than the maximum allowed number of pages/tabs, then:
154+
You may have noticed the `max_pages` argument. It enables the fetcher to keep a **pool of Browser tabs**, and you set the maximum number of tabs that can be open at once. Tabs stay open after their request finishes, so with each request, the library will:
155155

156-
1. If you are within the allowed range, the fetcher will create a new tab for you, and then all is as normal.
157-
2. Otherwise, it will keep checking every subsecond if creating a new tab is allowed or not for 60 seconds, then raise `TimeoutError`. This can happen when the website you are fetching becomes unresponsive.
156+
1. Reuse a free tab if there's one. Every request applies its own tab-level settings (`timeout`, `extra_headers`, `disable_resources`, `blocked_domains`, etc.) to the tab it gets, so nothing leaks from the previous request.
157+
2. Otherwise, open a new tab if the number of open tabs is lower than `max_pages`.
158+
3. Otherwise, keep checking every subsecond for a tab to become free for 60 seconds, then raise `TimeoutError`. This can happen when the website you are fetching becomes unresponsive.
159+
160+
Tabs that hit an error are closed and replaced, and you can close all the open tabs yourself at any point with `session.close_pages()`, then the next request opens a fresh one.
158161

159162
This logic allows for multiple URLs to be fetched at the same time in the same browser, which saves a lot of resources, but most importantly, is so fast :)
160163

161-
In versions 0.3 and 0.3.1, the pool was reusing finished tabs to save more resources/time. That logic proved flawed, as it's nearly impossible to protect pages/tabs from contamination by the previous configuration used in the request before this one.
164+
Keeping the tabs open also means the page you fetched is still there for the next request, so a `page_setup` function on the next request runs on it before navigating away. That's the building block for chaining automation across requests.
165+
166+
Versions 0.3.2 to 0.4.14 closed every tab after its request because reusing tabs used to leak settings between requests. Since 0.4.15, the settings are reset on every reuse, so the tabs stay open.
162167

163168
### Session Benefits
164169

docs/fetching/stealthy.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -240,14 +240,19 @@ async def scrape_multiple_sites():
240240
return pages
241241
```
242242

243-
You may have noticed the `max_pages` argument. This is a new argument that enables the fetcher to create a **rotating pool of Browser tabs**. Instead of using a single tab for all your requests, you set a limit on the maximum number of pages that can be displayed at once. With each request, the library will close all tabs that have finished their task and check if the number of the current tabs is lower than the maximum allowed number of pages/tabs, then:
243+
You may have noticed the `max_pages` argument. It enables the fetcher to keep a **pool of Browser tabs**, and you set the maximum number of tabs that can be open at once. Tabs stay open after their request finishes, so with each request, the library will:
244244

245-
1. If you are within the allowed range, the fetcher will create a new tab for you, and then all is as normal.
246-
2. Otherwise, it will keep checking every subsecond if creating a new tab is allowed or not for 60 seconds, then raise `TimeoutError`. This can happen when the website you are fetching becomes unresponsive.
245+
1. Reuse a free tab if there's one. Every request applies its own tab-level settings (`timeout`, `extra_headers`, `disable_resources`, `blocked_domains`, etc.) to the tab it gets, so nothing leaks from the previous request.
246+
2. Otherwise, open a new tab if the number of open tabs is lower than `max_pages`.
247+
3. Otherwise, keep checking every subsecond for a tab to become free for 60 seconds, then raise `TimeoutError`. This can happen when the website you are fetching becomes unresponsive.
248+
249+
Tabs that hit an error are closed and replaced, and you can close all the open tabs yourself at any point with `session.close_pages()`, then the next request opens a fresh one.
247250

248251
This logic allows for multiple URLs to be fetched at the same time in the same browser, which saves a lot of resources, but most importantly, is so fast :)
249252

250-
In versions 0.3 and 0.3.1, the pool was reusing finished tabs to save more resources/time. That logic proved flawed, as it's nearly impossible to protect pages/tabs from contamination by the previous configuration used in the request before this one.
253+
Keeping the tabs open also means the page you fetched is still there for the next request, so a `page_setup` function on the next request runs on it before navigating away. That's the building block for chaining automation across requests.
254+
255+
Versions 0.3.2 to 0.4.14 closed every tab after its request because reusing tabs used to leak settings between requests. Since 0.4.15, the settings are reset on every reuse, so the tabs stay open.
251256

252257
### Session Benefits
253258

scrapling/engines/_browsers/_base.py

Lines changed: 55 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from time import time
22
from re import search as re_search
33
from asyncio import sleep as asyncio_sleep, Lock
4-
from contextlib import contextmanager, asynccontextmanager
4+
from contextlib import contextmanager, asynccontextmanager, suppress
55

66
from playwright.sync_api._generated import Page
77
from playwright.sync_api import (
@@ -63,11 +63,18 @@ def __init__(self, max_pages: int = 1):
6363
def start(self) -> None:
6464
pass
6565

66+
def close_pages(self) -> None:
67+
"""Close every open tab in the session's pool. The next request opens a fresh tab."""
68+
for page_info in self.page_pool.clear():
69+
with suppress(Exception):
70+
page_info.page.close()
71+
6672
def close(self): # pragma: no cover
6773
"""Close all resources"""
6874
if not self._is_alive:
6975
return
7076

77+
self.close_pages()
7178
if self.context:
7279
self.context.close()
7380
self.context = None
@@ -107,21 +114,21 @@ def _get_page(
107114
blocked_domains: Optional[Set[str]] = None,
108115
context: Optional[BrowserContext] = None,
109116
) -> PageInfo[Page]: # pragma: no cover
110-
"""Get a new page to use"""
111-
# No need to check if a page is available or not in sync code because the code blocked before reaching here till the page closed, ofc.
112-
ctx = context if context is not None else self.context
113-
assert ctx is not None, "Browser context not initialized"
114-
page = ctx.new_page()
117+
"""Get a ready page from the pool, or open a new one"""
118+
page_info = self.page_pool.get_ready_page() if context is None else None
119+
if page_info is None:
120+
ctx = context if context is not None else self.context
121+
assert ctx is not None, "Browser context not initialized"
122+
page_info = self.page_pool.add_page(ctx.new_page())
123+
124+
page = cast(Page, page_info.page)
115125
page.set_default_navigation_timeout(timeout)
116126
page.set_default_timeout(timeout)
117-
if extra_headers:
118-
page.set_extra_http_headers(extra_headers)
119-
127+
page.set_extra_http_headers(extra_headers or {})
128+
page.unroute_all(behavior="ignoreErrors")
120129
if disable_resources or blocked_domains:
121130
page.route("**/*", create_intercept_handler(disable_resources, blocked_domains))
122-
page_info = self.page_pool.add_page(page)
123-
page_info.mark_busy()
124-
return page_info
131+
return cast(PageInfo[Page], page_info)
125132

126133
def get_pool_stats(self) -> Dict[str, int]:
127134
"""Get statistics about the current page pool"""
@@ -202,17 +209,21 @@ def _page_generator(
202209
page_info = self._get_page(timeout, extra_headers, disable_resources, blocked_domains, context=context)
203210
yield page_info
204211
finally:
205-
if page_info is not None and page_info in self.page_pool.pages:
206-
self.page_pool.pages.remove(page_info)
212+
if page_info is not None:
213+
self.page_pool.remove_page(page_info)
207214
context.close()
208215
else:
209216
# Standard mode: use PagePool with persistent context
210217
page_info = self._get_page(timeout, extra_headers, disable_resources, blocked_domains)
211218
try:
212219
yield page_info
213220
finally:
214-
page_info.page.close()
215-
self.page_pool.pages.remove(page_info)
221+
if page_info.state == "error" or page_info.page.is_closed():
222+
with suppress(Exception):
223+
page_info.page.close()
224+
self.page_pool.remove_page(page_info)
225+
else:
226+
page_info.mark_ready()
216227

217228

218229
class AsyncSession:
@@ -234,11 +245,18 @@ def __init__(self, max_pages: int = 1):
234245
async def start(self) -> None:
235246
pass
236247

248+
async def close_pages(self) -> None:
249+
"""Close every open tab in the session's pool. The next request opens a fresh tab."""
250+
for page_info in self.page_pool.clear():
251+
with suppress(Exception):
252+
await cast(AsyncPage, page_info.page).close()
253+
237254
async def close(self):
238255
"""Close all resources"""
239256
if not self._is_alive: # pragma: no cover
240257
return
241258

259+
await self.close_pages()
242260
if self.context:
243261
await self.context.close()
244262
self.context = None # pyright: ignore
@@ -280,35 +298,37 @@ async def _get_page(
280298
blocked_domains: Optional[Set[str]] = None,
281299
context: Optional[AsyncBrowserContext] = None,
282300
) -> PageInfo[AsyncPage]: # pragma: no cover
283-
"""Get a new page to use"""
301+
"""Get a ready page from the pool, or open a new one"""
284302
ctx = context if context is not None else self.context
285303
if TYPE_CHECKING:
286304
assert ctx is not None, "Browser context not initialized"
287305

288306
async with self._lock:
289-
# If we're at max capacity after cleanup, wait for busy pages to finish
290-
if context is None and self.page_pool.pages_count >= self.max_pages:
291-
# Only applies when using persistent context
307+
page_info = self.page_pool.get_ready_page() if context is None else None
308+
if page_info is None and context is None and self.page_pool.pages_count >= self.max_pages:
309+
# At max capacity with the persistent context, so wait for a busy page to become ready
292310
start_time = time()
293311
while time() - start_time < self._max_wait_for_page:
294312
await asyncio_sleep(0.05)
295-
if self.page_pool.pages_count < self.max_pages:
313+
page_info = self.page_pool.get_ready_page()
314+
if page_info is not None:
296315
break
297316
else:
298317
raise TimeoutError(
299318
f"No pages finished to clear place in the pool within the {self._max_wait_for_page}s timeout period"
300319
)
301320

302-
page = await ctx.new_page()
321+
if page_info is None:
322+
page_info = self.page_pool.add_page(await ctx.new_page())
323+
324+
page = cast(AsyncPage, page_info.page)
303325
page.set_default_navigation_timeout(timeout)
304326
page.set_default_timeout(timeout)
305-
if extra_headers:
306-
await page.set_extra_http_headers(extra_headers)
307-
327+
await page.set_extra_http_headers(extra_headers or {})
328+
await page.unroute_all(behavior="ignoreErrors")
308329
if disable_resources or blocked_domains:
309330
await page.route("**/*", create_async_intercept_handler(disable_resources, blocked_domains))
310-
311-
return self.page_pool.add_page(page)
331+
return cast(PageInfo[AsyncPage], page_info)
312332

313333
def get_pool_stats(self) -> Dict[str, int]:
314334
"""Get statistics about the current page pool"""
@@ -391,17 +411,21 @@ async def _page_generator(
391411
)
392412
yield page_info
393413
finally:
394-
if page_info is not None and page_info in self.page_pool.pages:
395-
self.page_pool.pages.remove(page_info)
414+
if page_info is not None:
415+
self.page_pool.remove_page(page_info)
396416
await context.close()
397417
else:
398418
# Standard mode: use PagePool with persistent context
399419
page_info = await self._get_page(timeout, extra_headers, disable_resources, blocked_domains)
400420
try:
401421
yield page_info
402422
finally:
403-
await page_info.page.close()
404-
self.page_pool.pages.remove(page_info)
423+
if page_info.state == "error" or page_info.page.is_closed():
424+
with suppress(Exception):
425+
await page_info.page.close()
426+
self.page_pool.remove_page(page_info)
427+
else:
428+
page_info.mark_ready()
405429

406430

407431
class BaseSessionMixin:

0 commit comments

Comments
 (0)