Skip to content

Commit 0648573

Browse files
committed
Frame ID map for multi-tab support (API)
1 parent f68e86c commit 0648573

File tree

5 files changed

+552
-5
lines changed

5 files changed

+552
-5
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"stagehand": patch
3+
---
4+
5+
Added frame_id_map to support multi-tab handling on API

stagehand/context.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ def __init__(self, context: BrowserContext, stagehand):
1414
# Use a weak key dictionary to map Playwright Pages to our StagehandPage wrappers
1515
self.page_map = weakref.WeakKeyDictionary()
1616
self.active_stagehand_page = None
17+
# Map frame IDs to StagehandPage instances
18+
self.frame_id_map = {}
1719

1820
async def new_page(self) -> StagehandPage:
1921
pw_page: Page = await self._context.new_page()
@@ -23,9 +25,13 @@ async def new_page(self) -> StagehandPage:
2325

2426
async def create_stagehand_page(self, pw_page: Page) -> StagehandPage:
2527
# Create a StagehandPage wrapper for the given Playwright page
26-
stagehand_page = StagehandPage(pw_page, self.stagehand)
28+
stagehand_page = StagehandPage(pw_page, self.stagehand, self)
2729
await self.inject_custom_scripts(pw_page)
2830
self.page_map[pw_page] = stagehand_page
31+
32+
# Initialize frame tracking for this page
33+
await self._attach_frame_navigated_listener(pw_page, stagehand_page)
34+
2935
return stagehand_page
3036

3137
async def inject_custom_scripts(self, pw_page: Page):
@@ -69,6 +75,25 @@ def set_active_page(self, stagehand_page: StagehandPage):
6975
def get_active_page(self) -> StagehandPage:
7076
return self.active_stagehand_page
7177

78+
def register_frame_id(self, frame_id: str, page: StagehandPage):
79+
"""Register a frame ID to StagehandPage mapping."""
80+
self.frame_id_map[frame_id] = page
81+
self.stagehand.logger.debug(
82+
f"Registered frame ID {frame_id} to page", category="context"
83+
)
84+
85+
def unregister_frame_id(self, frame_id: str):
86+
"""Unregister a frame ID from the mapping."""
87+
if frame_id in self.frame_id_map:
88+
del self.frame_id_map[frame_id]
89+
self.stagehand.logger.debug(
90+
f"Unregistered frame ID {frame_id}", category="context"
91+
)
92+
93+
def get_stagehand_page_by_frame_id(self, frame_id: str) -> StagehandPage:
94+
"""Get StagehandPage by frame ID."""
95+
return self.frame_id_map.get(frame_id)
96+
7297
@classmethod
7398
async def init(cls, context: BrowserContext, stagehand):
7499
stagehand.logger.debug("StagehandContext.init() called", category="context")
@@ -150,3 +175,65 @@ async def wrapped_pages():
150175

151176
return wrapped_pages
152177
return attr
178+
179+
async def _attach_frame_navigated_listener(self, pw_page: Page, stagehand_page: StagehandPage):
180+
"""
181+
Attach CDP listener for frame navigation events to track frame IDs.
182+
This mirrors the TypeScript implementation's frame tracking.
183+
"""
184+
try:
185+
# Create CDP session for the page
186+
cdp_session = await self._context.new_cdp_session(pw_page)
187+
await cdp_session.send("Page.enable")
188+
189+
# Get the current root frame ID
190+
frame_tree = await cdp_session.send("Page.getFrameTree")
191+
root_frame_id = frame_tree.get("frameTree", {}).get("frame", {}).get("id")
192+
193+
if root_frame_id:
194+
# Initialize the page with its frame ID
195+
stagehand_page.update_root_frame_id(root_frame_id)
196+
self.register_frame_id(root_frame_id, stagehand_page)
197+
198+
# Set up event listener for frame navigation
199+
def on_frame_navigated(params):
200+
"""Handle Page.frameNavigated events"""
201+
frame = params.get("frame", {})
202+
frame_id = frame.get("id")
203+
parent_id = frame.get("parentId")
204+
205+
# Only track root frames (no parent)
206+
if not parent_id and frame_id:
207+
# Skip if it's the same frame ID
208+
if frame_id == stagehand_page.frame_id:
209+
return
210+
211+
# Unregister old frame ID if exists
212+
old_id = stagehand_page.frame_id
213+
if old_id:
214+
self.unregister_frame_id(old_id)
215+
216+
# Register new frame ID
217+
self.register_frame_id(frame_id, stagehand_page)
218+
stagehand_page.update_root_frame_id(frame_id)
219+
220+
self.stagehand.logger.debug(
221+
f"Frame navigated from {old_id} to {frame_id}",
222+
category="context"
223+
)
224+
225+
# Register the event listener
226+
cdp_session.on("Page.frameNavigated", on_frame_navigated)
227+
228+
# Clean up frame ID when page closes
229+
def on_page_close():
230+
if stagehand_page.frame_id:
231+
self.unregister_frame_id(stagehand_page.frame_id)
232+
233+
pw_page.once("close", on_page_close)
234+
235+
except Exception as e:
236+
self.stagehand.logger.error(
237+
f"Failed to attach frame navigation listener: {str(e)}",
238+
category="context"
239+
)

stagehand/page.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import asyncio
2+
import time
13
from typing import Optional, Union
24

35
from playwright.async_api import CDPSession, Page
@@ -26,17 +28,32 @@ class StagehandPage:
2628

2729
_cdp_client: Optional[CDPSession] = None
2830

29-
def __init__(self, page: Page, stagehand_client):
31+
def __init__(self, page: Page, stagehand_client, context=None):
3032
"""
3133
Initialize a StagehandPage instance.
3234
3335
Args:
3436
page (Page): The underlying Playwright page.
3537
stagehand_client: The client used to interface with the Stagehand server.
38+
context: The StagehandContext instance (optional).
3639
"""
3740
self._page = page
3841
self._stagehand = stagehand_client
39-
42+
self._context = context
43+
self._frame_id = None
44+
45+
@property
46+
def frame_id(self) -> Optional[str]:
47+
"""Get the current root frame ID."""
48+
return self._frame_id
49+
50+
def update_root_frame_id(self, new_id: str):
51+
"""Update the root frame ID."""
52+
self._frame_id = new_id
53+
self._stagehand.logger.debug(
54+
f"Updated frame ID to {new_id}", category="page"
55+
)
56+
4057
# TODO try catch here
4158
async def ensure_injection(self):
4259
"""Ensure custom injection scripts are present on the page using domScripts.js."""
@@ -97,6 +114,10 @@ async def goto(
97114
payload = {"url": url}
98115
if options:
99116
payload["options"] = options
117+
118+
# Add frame ID if available
119+
if self._frame_id:
120+
payload["frameId"] = self._frame_id
100121

101122
lock = self._stagehand._get_lock_for_session()
102123
async with lock:
@@ -168,6 +189,10 @@ async def act(
168189
result = await self._act_handler.act(payload)
169190
return result
170191

192+
# Add frame ID if available
193+
if self._frame_id:
194+
payload["frameId"] = self._frame_id
195+
171196
lock = self._stagehand._get_lock_for_session()
172197
async with lock:
173198
result = await self._stagehand._execute("act", payload)
@@ -237,6 +262,10 @@ async def observe(
237262

238263
return result
239264

265+
# Add frame ID if available
266+
if self._frame_id:
267+
payload["frameId"] = self._frame_id
268+
240269
lock = self._stagehand._get_lock_for_session()
241270
async with lock:
242271
result = await self._stagehand._execute("observe", payload)
@@ -361,6 +390,10 @@ async def extract(
361390
return result.data
362391

363392
# Use API
393+
# Add frame ID if available
394+
if self._frame_id:
395+
payload["frameId"] = self._frame_id
396+
364397
lock = self._stagehand._get_lock_for_session()
365398
async with lock:
366399
result_dict = await self._stagehand._execute("extract", payload)
@@ -487,8 +520,7 @@ async def _wait_for_settled_dom(self, timeout_ms: int = None):
487520
timeout_ms (int, optional): Maximum time to wait in milliseconds.
488521
If None, uses the stagehand client's dom_settle_timeout_ms.
489522
"""
490-
import asyncio
491-
import time
523+
492524

493525
timeout = timeout_ms or getattr(self._stagehand, "dom_settle_timeout_ms", 30000)
494526
client = await self.get_cdp_client()

0 commit comments

Comments
 (0)