Skip to content

Commit 583e3d1

Browse files
authored
Merge pull request #278 from autoscrape-labs/fix/multicontext-proxies
Fix problem when trying to use private proxies in new browser contexts
2 parents b81d5e2 + caac8ce commit 583e3d1

File tree

7 files changed

+424
-5
lines changed

7 files changed

+424
-5
lines changed

public/docs/api/commands/target.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ incognito_tab = await create_target(
100100
)
101101
```
102102

103+
!!! info "Headless vs Headed: how contexts show up"
104+
Browser contexts are isolated logical environments. In headed mode, the first page created inside a new context will usually open in a new OS window. In headless mode, no window is shown — the isolation remains purely logical (cookies, storage, cache and auth state are still separate per context). Prefer contexts in headless/CI pipelines for performance and clean isolation.
105+
103106
## Advanced Features
104107

105108
### Target Events

public/docs/deep-dive/browser-domain.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,85 @@ Browser contexts are essential for several automation scenarios:
327327
4. **Session Isolation**: Prevent cross-contamination between test scenarios
328328
5. **Parallel Scraping**: Scrape multiple sites with different configurations
329329

330+
### Headless vs Headed: Windows and Best Practices
331+
332+
Browser contexts are a logical isolation layer. What you actually see is the page created inside a context:
333+
334+
- In headed mode (visible UI), creating the first page inside a new browser context will typically open a new OS window. The context is the isolated environment; the page is what renders in a tab or window.
335+
- In headless mode (no visible UI), no windows appear. The isolation still exists logically in the background, keeping cookies, storage, cache and auth state fully separate per context.
336+
337+
Recommendations:
338+
339+
- Prefer using multiple contexts in headless environments (e.g., CI/CD) for cleaner isolation, faster startup, and lower resource usage compared to launching multiple browser processes.
340+
- Use contexts to simulate multiple users or sessions in parallel without cross-contamination.
341+
342+
Why contexts are efficient:
343+
344+
- Creating a new browser context is significantly faster and lighter than starting a whole new browser instance. This makes test suites and scraping jobs more reliable and scalable.
345+
346+
### CDP Hierarchy and Context Window Semantics (Advanced)
347+
348+
To reason precisely about contexts, it's useful to map Pydoll concepts to CDP:
349+
350+
- Browser (process): single Chromium process running the DevTools endpoint.
351+
- BrowserContext: isolated profile inside that process (cookies, storage, cache, permissions).
352+
- Target/Page: an individual top-level page, popup, or background target that you control.
353+
354+
CDP and `browserContextId`:
355+
356+
- When creating a page via `Target.createTarget`, passing `browserContextId` tells the browser which isolated profile the new page should belong to. Without this ID, the target is created in the default context.
357+
- The ID is essential for isolation — it binds the new target to the correct storage/auth/permission boundary.
358+
359+
Why the first page in a context opens a window (headed):
360+
361+
- In headed mode, a page needs a top-level native window to render. A freshly created context initially has no window associated with it — it exists only in memory.
362+
- The first page created in that context implicitly materializes a window for that context. Subsequent pages can open as tabs within that window.
363+
364+
Implications for `new_window`/`newWindow` semantics:
365+
366+
- If you attempt to create a page with "tab-like" behavior (no new top-level window) in a context that has no existing window (first page), the browser may error because there is no host window to attach the tab to.
367+
- Practically: treat the first page in a new context (headed) as requiring a top-level window. Afterwards, you can create additional pages as tabs.
368+
369+
Headless mode makes this distinction moot:
370+
371+
- With no visible UI, windows vs tabs are logical constructs only. Context isolation is enforced the same way, but nothing is rendered, so there is no requirement to bootstrap a native window for the first page.
372+
373+
### Context-specific Proxy: sanitize + auth via Fetch events
374+
375+
When creating a browser context with a private proxy (credentials embedded in the URL), Pydoll follows a two-step strategy to avoid leaking credentials and reliably authenticate:
376+
377+
1) Sanitize the proxy server in the CDP command
378+
379+
- If you pass `proxy_server='http://user:pass@host:port'`, only the credential-free URL is sent to CDP (`http://host:port`).
380+
- Internally, Pydoll extracts and stores the credentials keyed by `browserContextId`.
381+
382+
2) Attach per-context auth handlers on first tab
383+
384+
- When you open a `Tab` inside that context, Pydoll enables Fetch events for that tab and registers two temporary listeners:
385+
- `Fetch.requestPaused`: continues normal requests.
386+
- `Fetch.authRequired`: automatically responds with the stored `user`/`pass`, then disables Fetch to avoid intercepting further requests.
387+
388+
Why this design?
389+
390+
- Prevents credential exposure in command logs and CDP parameters.
391+
- Keeps the auth scope strictly limited to the context that requested the proxy.
392+
- Works in both headed and headless modes (the auth flow is network-level, not UI-dependent).
393+
394+
Code flow highlights (simplified):
395+
396+
```python
397+
# On context creation
398+
context_id = await browser.create_browser_context(proxy_server='user:pwd@host:port')
399+
# => sends Target.createBrowserContext with 'http://host:port'
400+
# => stores {'context_id': ('user', 'pwd')} internally
401+
402+
# On first tab in that context
403+
tab = await browser.new_tab(browser_context_id=context_id)
404+
# => tab.enable_fetch_events(handle_auth=True)
405+
# => tab.on('Fetch.requestPaused', continue_request)
406+
# => tab.on('Fetch.authRequired', continue_with_auth(user, pwd))
407+
```
408+
330409
### Creating and Managing Contexts
331410

332411
```python

public/docs/zh/api/commands/target.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ incognito_tab = await create_target(
101101
)
102102
```
103103

104+
!!! info "Headless 与 Headed:上下文如何呈现"
105+
浏览器上下文是逻辑上的隔离环境。在 Headed 模式下,在新的上下文中创建的第一个页面通常会打开一个新的系统窗口。 在 Headless 模式下不会显示窗口——隔离依然存在于后台(cookies、storage、缓存与认证状态仍按上下文分离)。在 CI/Headless 环境中优先使用上下文以获得更高性能与更干净的隔离。
106+
104107
## 高级特性
105108

106109
### 目标事件

public/docs/zh/deep-dive/browser-domain.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,85 @@ graph TB
325325
4. **会话隔离**:防止测试场景间的交叉污染
326326
5. **并行抓取**:使用不同配置同时抓取多个网站
327327

328+
### Headless 与 Headed:窗口表现与最佳实践
329+
330+
浏览器上下文是一个逻辑上的隔离环境。实际显示在屏幕上的,是在该上下文内创建的页面(page):
331+
332+
- 在 Headed 模式(可见 UI)下,在新的浏览器上下文内创建第一个页面通常会打开一个新的系统窗口。上下文是隔离的环境;页面才是会在标签页或窗口中渲染的对象。
333+
- 在 Headless 模式(无界面)下,不会出现可见窗口。上下文的隔离仍然存在于后台,确保 cookies、storage、缓存与认证状态在不同上下文之间完全分离。
334+
335+
建议:
336+
337+
- 在 CI/CD 等无界面环境中,优先使用多个上下文来实现隔离。相比启动多个浏览器进程,创建新上下文更快、资源占用更低。
338+
- 使用上下文来并行模拟多个用户或会话,避免相互污染。
339+
340+
为什么上下文更高效:
341+
342+
- 创建浏览器上下文远比启动一个新的浏览器实例更轻量、更迅速。这将使测试套件与抓取任务更稳定、更具可扩展性。
343+
344+
### CDP 层级与上下文窗口语义(高级)
345+
346+
将 Pydoll 概念映射到 CDP,有助于精确理解上下文:
347+
348+
- 浏览器(进程):运行 DevTools 端点的单个 Chromium 进程。
349+
- 浏览器上下文(BrowserContext):该进程内的隔离“用户配置文件”(cookies、存储、缓存、权限相互独立)。
350+
- 目标/页面(Target/Page):可控制的顶层页面、弹窗或后台目标。
351+
352+
CDP 与 `browserContextId`
353+
354+
- 使用 `Target.createTarget` 创建页面时传入 `browserContextId`,告诉浏览器将新页面归属到指定的隔离配置文件。未传入时,目标将创建在默认上下文中。
355+
- 该 ID 是实现隔离的关键——它将新目标绑定到正确的存储/认证/权限边界。
356+
357+
为何上下文中的“第一个页面”在 Headed 模式下会打开窗口:
358+
359+
- 在 Headed 模式中,页面需要一个顶层的原生窗口来渲染。新创建的上下文起初只存在于内存中,并没有关联的窗口。
360+
- 在该上下文中创建的第一个页面会隐式“实体化”一个窗口。之后再创建的页面可以作为该窗口中的标签页加入。
361+
362+
`new_window`/`newWindow` 语义的影响:
363+
364+
- 如果你希望以“仅新标签”的方式创建页面(不新建顶层窗口),但目标上下文尚无窗口(即第一个页面),浏览器可能会报错,因为没有可附着的宿主窗口。
365+
- 实践上:在新的上下文(Headed)里,首个页面可视为需要一个顶层窗口;随后你就可以创建额外页面作为标签页。
366+
367+
在 Headless 模式下,这个区分不再重要:
368+
369+
- 没有可见 UI 时,“窗口 vs 标签”的区别只是逻辑概念。隔离照常生效,但无需为首个页面引导原生窗口。
370+
371+
### 上下文专属代理:URL 净化 + 通过 Fetch 事件进行认证
372+
373+
当你为某个浏览器上下文配置带凭证的私有代理(在 URL 中嵌入用户名/密码)时,Pydoll 采用“两步法”以避免凭证泄漏并实现可靠认证:
374+
375+
1)在 CDP 命令中净化代理地址
376+
377+
- 若传入 `proxy_server='http://user:pass@host:port'`,发送给 CDP 的仅为去除凭证的 URL(`http://host:port`)。
378+
- 在内部,Pydoll 会提取并按 `browserContextId` 存储凭证。
379+
380+
2)在该上下文的首个 Tab 上附加认证处理器
381+
382+
- 在该上下文内打开 Tab 时,Pydoll 会为该 Tab 启用 Fetch 事件,并注册两个临时监听器:
383+
- `Fetch.requestPaused`:继续普通请求。
384+
- `Fetch.authRequired`:自动使用存储的 `user`/`pass` 响应,然后关闭 Fetch 以免继续拦截。
385+
386+
设计动机:
387+
388+
- 防止凭证出现在命令日志和 CDP 参数中。
389+
- 将认证作用域限制在请求该代理的上下文中。
390+
- 在 Headed/Headless 场景下均可工作(认证流程在网络层,不依赖 UI)。
391+
392+
流程(简化):
393+
394+
```python
395+
# 创建上下文
396+
context_id = await browser.create_browser_context(proxy_server='user:pwd@host:port')
397+
# => 发送 Target.createBrowserContext 时使用 'http://host:port'
398+
# => 内部存储 {'context_id': ('user', 'pwd')}
399+
400+
# 在该上下文打开第一个 Tab
401+
tab = await browser.new_tab(browser_context_id=context_id)
402+
# => tab.enable_fetch_events(handle_auth=True)
403+
# => tab.on('Fetch.requestPaused', continue_request)
404+
# => tab.on('Fetch.authRequired', continue_with_auth(user, pwd))
405+
```
406+
328407
### 创建与管理上下文
329408

330409
```python

pydoll/browser/chromium/base.py

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from random import randint
1010
from tempfile import TemporaryDirectory
1111
from typing import Any, Awaitable, Callable, Optional, overload
12+
from urllib.parse import urlsplit, urlunsplit
1213

1314
from pydoll.browser.interfaces import BrowserOptionsManager
1415
from pydoll.browser.managers import (
@@ -93,6 +94,7 @@ def __init__(
9394
self._connection_handler = ConnectionHandler(self._connection_port)
9495
self._backup_preferences_dir = ''
9596
self._tabs_opened: dict[str, Tab] = {}
97+
self._context_proxy_auth: dict[str, tuple[str, str]] = {}
9698

9799
async def __aenter__(self) -> 'Browser':
98100
"""Async context manager entry."""
@@ -203,13 +205,22 @@ async def create_browser_context(
203205
Returns:
204206
Browser context ID for use with other methods.
205207
"""
208+
# If proxy_server contains credentials, strip them and store per-context auth
209+
sanitized_proxy = proxy_server
210+
extracted_auth: Optional[tuple[str, str]] = None
211+
if proxy_server:
212+
sanitized_proxy, extracted_auth = self._sanitize_proxy_and_extract_auth(proxy_server)
213+
206214
response: CreateBrowserContextResponse = await self._execute_command(
207215
TargetCommands.create_browser_context(
208-
proxy_server=proxy_server,
216+
proxy_server=sanitized_proxy,
209217
proxy_bypass_list=proxy_bypass_list,
210218
)
211219
)
212-
return response['result']['browserContextId']
220+
context_id = response['result']['browserContextId']
221+
if extracted_auth:
222+
self._context_proxy_auth[context_id] = extracted_auth
223+
return context_id
213224

214225
async def delete_browser_context(self, browser_context_id: str):
215226
"""
@@ -251,8 +262,8 @@ async def new_tab(self, url: str = '', browser_context_id: Optional[str] = None)
251262
target_id = response['result']['targetId']
252263
tab = Tab(self, **self._get_tab_kwargs(target_id, browser_context_id))
253264
self._tabs_opened[target_id] = tab
254-
if url:
255-
await tab.go_to(url)
265+
await self._setup_context_proxy_auth_for_tab(tab, browser_context_id)
266+
if url: await tab.go_to(url)
256267
return tab
257268

258269
async def get_targets(self) -> list[TargetInfo]:
@@ -577,6 +588,60 @@ async def _continue_request_with_auth_callback(
577588
await self.disable_fetch_events()
578589
return response
579590

591+
@staticmethod
592+
async def _tab_continue_request_callback(event: RequestPausedEvent, tab: Tab):
593+
"""Internal callback to continue paused requests at Tab level."""
594+
request_id = event['params']['requestId']
595+
return await tab.continue_request(request_id)
596+
597+
@staticmethod
598+
async def _tab_continue_request_with_auth_callback(
599+
event: RequestPausedEvent,
600+
tab: Tab,
601+
proxy_username: Optional[str],
602+
proxy_password: Optional[str],
603+
):
604+
"""Internal callback for proxy/server authentication at Tab level."""
605+
request_id = event['params']['requestId']
606+
response: Response = await tab.continue_with_auth(
607+
request_id=request_id,
608+
auth_challenge_response=AuthChallengeResponseType.PROVIDE_CREDENTIALS,
609+
proxy_username=proxy_username,
610+
proxy_password=proxy_password,
611+
)
612+
await tab.disable_fetch_events()
613+
return response
614+
615+
async def _setup_context_proxy_auth_for_tab(
616+
self, tab: Tab, browser_context_id: Optional[str]
617+
) -> None:
618+
"""Enable proxy auth handling for a Tab if its context has credentials stored."""
619+
if not browser_context_id:
620+
return
621+
creds = self._context_proxy_auth.get(browser_context_id)
622+
if not creds:
623+
return
624+
username, password = creds
625+
await tab.enable_fetch_events(handle_auth=True)
626+
await tab.on(
627+
FetchEvent.REQUEST_PAUSED,
628+
partial(
629+
self._tab_continue_request_callback,
630+
tab=tab,
631+
),
632+
temporary=True,
633+
)
634+
await tab.on(
635+
FetchEvent.AUTH_REQUIRED,
636+
partial(
637+
self._tab_continue_request_with_auth_callback,
638+
tab=tab,
639+
proxy_username=username,
640+
proxy_password=password,
641+
),
642+
temporary=True,
643+
)
644+
580645
async def _verify_browser_running(self):
581646
"""
582647
Verify browser started successfully.
@@ -763,6 +828,49 @@ def _get_tab_ws_address(self, tab_id: str) -> str:
763828
ws_domain = '/'.join(self._ws_address.split('/')[:3])
764829
return f'{ws_domain}/devtools/page/{tab_id}'
765830

831+
@staticmethod
832+
def _sanitize_proxy_and_extract_auth(
833+
proxy_server: str,
834+
) -> tuple[str, Optional[tuple[str, str]]]:
835+
"""Strip credentials from a proxy URL and return sanitized URL plus (user, pass).
836+
837+
Accepts inputs like:
838+
- username:password@host:port
839+
- http://username:password@host:port
840+
- socks5://username:password@host:port
841+
- host:port (no credentials)
842+
Returns a (sanitized_proxy, (user, pass) | None).
843+
Ensures scheme is present in the sanitized URL (defaults to http).
844+
"""
845+
base = proxy_server if '://' in proxy_server else f'http://{proxy_server}'
846+
parts = urlsplit(base)
847+
netloc = parts.netloc
848+
creds: Optional[tuple[str, str]] = None
849+
if '@' in netloc:
850+
cred_part, host_part = netloc.split('@', 1)
851+
if ':' in cred_part:
852+
user, pwd = cred_part.split(':', 1)
853+
else:
854+
user, pwd = cred_part, ''
855+
creds = (user, pwd)
856+
sanitized = urlunsplit((
857+
parts.scheme,
858+
host_part,
859+
parts.path,
860+
parts.query,
861+
parts.fragment,
862+
))
863+
else:
864+
# No creds; ensure scheme
865+
sanitized = urlunsplit((
866+
parts.scheme,
867+
parts.netloc,
868+
parts.path,
869+
parts.query,
870+
parts.fragment,
871+
))
872+
return sanitized, creds
873+
766874
@abstractmethod
767875
def _get_default_binary_location(self) -> str:
768876
"""Get default browser executable path (implemented by subclasses)."""

pydoll/browser/tab.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
DownloadWillBeginEvent,
5757
)
5858
from pydoll.protocol.browser.types import DownloadBehavior, DownloadProgressState
59-
from pydoll.protocol.fetch.types import HeaderEntry, RequestStage
59+
from pydoll.protocol.fetch.types import AuthChallengeResponseType, HeaderEntry, RequestStage
6060
from pydoll.protocol.network.events import RequestWillBeSentEvent
6161
from pydoll.protocol.network.types import (
6262
Cookie,
@@ -709,6 +709,27 @@ async def fulfill_request(
709709
)
710710
)
711711

712+
async def continue_with_auth(
713+
self,
714+
request_id: str,
715+
auth_challenge_response: AuthChallengeResponseType,
716+
proxy_username: Optional[str] = None,
717+
proxy_password: Optional[str] = None,
718+
):
719+
"""Continue a paused request replying to an authentication challenge.
720+
721+
Useful for proxy auth (407) or server auth (401) when Fetch is enabled
722+
with handle_auth=True.
723+
"""
724+
return await self._execute_command(
725+
FetchCommands.continue_request_with_auth(
726+
request_id=request_id,
727+
auth_challenge_response=auth_challenge_response,
728+
proxy_username=proxy_username,
729+
proxy_password=proxy_password,
730+
)
731+
)
732+
712733
@asynccontextmanager
713734
async def expect_file_chooser(
714735
self, files: Union[str, Path, list[Union[str, Path]]]

0 commit comments

Comments
 (0)