Skip to content

Commit 7da9334

Browse files
committed
Added a minimal playwright Page helper
1 parent fc21b94 commit 7da9334

File tree

3 files changed

+395
-12
lines changed

3 files changed

+395
-12
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""
2+
Example: use a Playwright Page with the Stagehand Python SDK.
3+
4+
What this demonstrates:
5+
- Start a Stagehand session (remote Stagehand API / Browserbase browser)
6+
- Attach Playwright to the same browser via CDP (`cdp_url`)
7+
- Pass the Playwright `page` into `session.observe/act/extract` so Stagehand
8+
auto-detects the correct `frame_id` for that page.
9+
10+
Environment variables required:
11+
- MODEL_API_KEY
12+
- BROWSERBASE_API_KEY
13+
- BROWSERBASE_PROJECT_ID
14+
15+
Optional:
16+
- STAGEHAND_BASE_URL (defaults to https://api.stagehand.browserbase.com)
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import os
22+
import sys
23+
from typing import Optional
24+
25+
from stagehand import Stagehand
26+
27+
28+
def main() -> None:
29+
model_api_key = os.environ.get("MODEL_API_KEY")
30+
if not model_api_key:
31+
sys.exit("Set the MODEL_API_KEY environment variable to run this example.")
32+
33+
bb_api_key = os.environ.get("BROWSERBASE_API_KEY")
34+
bb_project_id = os.environ.get("BROWSERBASE_PROJECT_ID")
35+
if not bb_api_key or not bb_project_id:
36+
sys.exit(
37+
"Set BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID to run this example."
38+
)
39+
40+
try:
41+
from playwright.sync_api import sync_playwright # type: ignore[import-not-found]
42+
except Exception:
43+
sys.exit(
44+
"Playwright is not installed. Install it with:\n"
45+
" uv pip install playwright\n"
46+
"and ensure browsers are installed (e.g. `playwright install chromium`)."
47+
)
48+
49+
session_id: Optional[str] = None
50+
51+
with Stagehand(
52+
server="remote",
53+
browserbase_api_key=bb_api_key,
54+
browserbase_project_id=bb_project_id,
55+
model_api_key=model_api_key,
56+
) as client:
57+
print("⏳ Starting Stagehand session...")
58+
session = client.sessions.create(
59+
model_name="openai/gpt-5-nano",
60+
browser={"type": "browserbase"},
61+
)
62+
session_id = session.id
63+
64+
cdp_url = session.data.cdp_url
65+
if not cdp_url:
66+
sys.exit(
67+
"No cdp_url returned from the API for this session; cannot attach Playwright."
68+
)
69+
70+
print(f"✅ Session started: {session_id}")
71+
print("🔌 Connecting Playwright to the same browser over CDP...")
72+
73+
with sync_playwright() as p:
74+
# Attach to the same browser session Stagehand is controlling.
75+
browser = p.chromium.connect_over_cdp(cdp_url)
76+
try:
77+
# Reuse an existing context/page if present; otherwise create one.
78+
context = browser.contexts[0] if browser.contexts else browser.new_context()
79+
page = context.pages[0] if context.pages else context.new_page()
80+
81+
page.goto("https://example.com", wait_until="domcontentloaded")
82+
83+
print("👀 Stagehand.observe(page=...) ...")
84+
actions = session.observe(
85+
instruction="Find the most relevant click target on this page",
86+
page=page,
87+
)
88+
print(f"Observed {len(actions.data.result)} actions")
89+
90+
print("🧠 Stagehand.extract(page=...) ...")
91+
extracted = session.extract(
92+
instruction="Extract the page title and the primary heading (h1) text",
93+
schema={
94+
"type": "object",
95+
"properties": {
96+
"title": {"type": "string"},
97+
"h1": {"type": "string"},
98+
},
99+
"required": ["title", "h1"],
100+
"additionalProperties": False,
101+
},
102+
page=page,
103+
)
104+
print("Extracted:", extracted.data.result)
105+
106+
print("🖱️ Stagehand.act(page=...) ...")
107+
_ = session.act(
108+
input="Click the 'More information' link",
109+
page=page,
110+
)
111+
print("Done.")
112+
finally:
113+
browser.close()
114+
115+
116+
if __name__ == "__main__":
117+
main()
118+

src/stagehand/session.py

Lines changed: 122 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
from __future__ import annotations
44

5-
from typing import TYPE_CHECKING, Any, Union
5+
from typing import TYPE_CHECKING, Any, Union, cast
66
from datetime import datetime
7-
from typing_extensions import Unpack, Literal
7+
import inspect
8+
from typing_extensions import Unpack, Literal, Protocol
89

910
import httpx
1011

@@ -23,11 +24,110 @@
2324
from .types.session_extract_response import SessionExtractResponse
2425
from .types.session_observe_response import SessionObserveResponse
2526
from .types.session_navigate_response import SessionNavigateResponse
27+
from ._exceptions import StagehandError
2628

2729
if TYPE_CHECKING:
2830
from ._client import Stagehand, AsyncStagehand
2931

3032

33+
class _PlaywrightCDPSession(Protocol):
34+
def send(self, method: str, params: Any = ...) -> Any: # noqa: ANN401
35+
...
36+
37+
38+
class _PlaywrightContext(Protocol):
39+
def new_cdp_session(self, page: Any) -> Any: # noqa: ANN401
40+
...
41+
42+
43+
def _extract_frame_id_from_playwright_page(page: Any) -> str:
44+
context = getattr(page, "context", None)
45+
if context is None:
46+
raise StagehandError("page must be a Playwright Page with a .context attribute")
47+
48+
if callable(context):
49+
context = context()
50+
51+
new_cdp_session = getattr(context, "new_cdp_session", None)
52+
if not callable(new_cdp_session):
53+
raise StagehandError(
54+
"page must be a Playwright Page; expected page.context.new_cdp_session(...) to exist"
55+
)
56+
57+
pw_context = cast(_PlaywrightContext, context)
58+
cdp = pw_context.new_cdp_session(page)
59+
if inspect.isawaitable(cdp):
60+
raise StagehandError(
61+
"Expected a synchronous Playwright Page, but received an async CDP session; use AsyncSession methods"
62+
)
63+
64+
send = getattr(cdp, "send", None)
65+
if not callable(send):
66+
raise StagehandError("Playwright CDP session missing .send(...) method")
67+
68+
pw_cdp = cast(_PlaywrightCDPSession, cdp)
69+
result = pw_cdp.send("Page.getFrameTree")
70+
if inspect.isawaitable(result):
71+
raise StagehandError(
72+
"Expected a synchronous Playwright Page, but received an async CDP session; use AsyncSession methods"
73+
)
74+
75+
try:
76+
return result["frameTree"]["frame"]["id"]
77+
except Exception as e: # noqa: BLE001
78+
raise StagehandError("Failed to extract frame id from Playwright CDP Page.getFrameTree response") from e
79+
80+
81+
async def _extract_frame_id_from_playwright_page_async(page: Any) -> str:
82+
context = getattr(page, "context", None)
83+
if context is None:
84+
raise StagehandError("page must be a Playwright Page with a .context attribute")
85+
86+
if callable(context):
87+
context = context()
88+
89+
new_cdp_session = getattr(context, "new_cdp_session", None)
90+
if not callable(new_cdp_session):
91+
raise StagehandError(
92+
"page must be a Playwright Page; expected page.context.new_cdp_session(...) to exist"
93+
)
94+
95+
pw_context = cast(_PlaywrightContext, context)
96+
cdp = pw_context.new_cdp_session(page)
97+
if inspect.isawaitable(cdp):
98+
cdp = await cdp
99+
100+
send = getattr(cdp, "send", None)
101+
if not callable(send):
102+
raise StagehandError("Playwright CDP session missing .send(...) method")
103+
104+
pw_cdp = cast(_PlaywrightCDPSession, cdp)
105+
result = pw_cdp.send("Page.getFrameTree")
106+
if inspect.isawaitable(result):
107+
result = await result
108+
109+
try:
110+
return result["frameTree"]["frame"]["id"]
111+
except Exception as e: # noqa: BLE001
112+
raise StagehandError("Failed to extract frame id from Playwright CDP Page.getFrameTree response") from e
113+
114+
115+
def _maybe_inject_frame_id(params: dict[str, Any], page: Any | None) -> dict[str, Any]:
116+
if page is None:
117+
return params
118+
if "frame_id" in params:
119+
return params
120+
return {**params, "frame_id": _extract_frame_id_from_playwright_page(page)}
121+
122+
123+
async def _maybe_inject_frame_id_async(params: dict[str, Any], page: Any | None) -> dict[str, Any]:
124+
if page is None:
125+
return params
126+
if "frame_id" in params:
127+
return params
128+
return {**params, "frame_id": await _extract_frame_id_from_playwright_page_async(page)}
129+
130+
31131
class Session(SessionStartResponse):
32132
"""A Stagehand session bound to a specific `session_id`."""
33133

@@ -41,6 +141,7 @@ def __init__(self, client: Stagehand, id: str, data: SessionStartResponseData, s
41141
def navigate(
42142
self,
43143
*,
144+
page: Any | None = None,
44145
extra_headers: Headers | None = None,
45146
extra_query: Query | None = None,
46147
extra_body: Body | None = None,
@@ -53,12 +154,13 @@ def navigate(
53154
extra_query=extra_query,
54155
extra_body=extra_body,
55156
timeout=timeout,
56-
**params,
157+
**_maybe_inject_frame_id(dict(params), page),
57158
)
58159

59160
def act(
60161
self,
61162
*,
163+
page: Any | None = None,
62164
extra_headers: Headers | None = None,
63165
extra_query: Query | None = None,
64166
extra_body: Body | None = None,
@@ -71,12 +173,13 @@ def act(
71173
extra_query=extra_query,
72174
extra_body=extra_body,
73175
timeout=timeout,
74-
**params,
176+
**_maybe_inject_frame_id(dict(params), page),
75177
)
76178

77179
def observe(
78180
self,
79181
*,
182+
page: Any | None = None,
80183
extra_headers: Headers | None = None,
81184
extra_query: Query | None = None,
82185
extra_body: Body | None = None,
@@ -89,12 +192,13 @@ def observe(
89192
extra_query=extra_query,
90193
extra_body=extra_body,
91194
timeout=timeout,
92-
**params,
195+
**_maybe_inject_frame_id(dict(params), page),
93196
)
94197

95198
def extract(
96199
self,
97200
*,
201+
page: Any | None = None,
98202
extra_headers: Headers | None = None,
99203
extra_query: Query | None = None,
100204
extra_body: Body | None = None,
@@ -107,12 +211,13 @@ def extract(
107211
extra_query=extra_query,
108212
extra_body=extra_body,
109213
timeout=timeout,
110-
**params,
214+
**_maybe_inject_frame_id(dict(params), page),
111215
)
112216

113217
def execute(
114218
self,
115219
*,
220+
page: Any | None = None,
116221
extra_headers: Headers | None = None,
117222
extra_query: Query | None = None,
118223
extra_body: Body | None = None,
@@ -125,7 +230,7 @@ def execute(
125230
extra_query=extra_query,
126231
extra_body=extra_body,
127232
timeout=timeout,
128-
**params,
233+
**_maybe_inject_frame_id(dict(params), page),
129234
)
130235

131236
def end(
@@ -161,6 +266,7 @@ def __init__(self, client: AsyncStagehand, id: str, data: SessionStartResponseDa
161266
async def navigate(
162267
self,
163268
*,
269+
page: Any | None = None,
164270
extra_headers: Headers | None = None,
165271
extra_query: Query | None = None,
166272
extra_body: Body | None = None,
@@ -173,12 +279,13 @@ async def navigate(
173279
extra_query=extra_query,
174280
extra_body=extra_body,
175281
timeout=timeout,
176-
**params,
282+
**(await _maybe_inject_frame_id_async(dict(params), page)),
177283
)
178284

179285
async def act(
180286
self,
181287
*,
288+
page: Any | None = None,
182289
extra_headers: Headers | None = None,
183290
extra_query: Query | None = None,
184291
extra_body: Body | None = None,
@@ -191,12 +298,13 @@ async def act(
191298
extra_query=extra_query,
192299
extra_body=extra_body,
193300
timeout=timeout,
194-
**params,
301+
**(await _maybe_inject_frame_id_async(dict(params), page)),
195302
)
196303

197304
async def observe(
198305
self,
199306
*,
307+
page: Any | None = None,
200308
extra_headers: Headers | None = None,
201309
extra_query: Query | None = None,
202310
extra_body: Body | None = None,
@@ -209,12 +317,13 @@ async def observe(
209317
extra_query=extra_query,
210318
extra_body=extra_body,
211319
timeout=timeout,
212-
**params,
320+
**(await _maybe_inject_frame_id_async(dict(params), page)),
213321
)
214322

215323
async def extract(
216324
self,
217325
*,
326+
page: Any | None = None,
218327
extra_headers: Headers | None = None,
219328
extra_query: Query | None = None,
220329
extra_body: Body | None = None,
@@ -227,12 +336,13 @@ async def extract(
227336
extra_query=extra_query,
228337
extra_body=extra_body,
229338
timeout=timeout,
230-
**params,
339+
**(await _maybe_inject_frame_id_async(dict(params), page)),
231340
)
232341

233342
async def execute(
234343
self,
235344
*,
345+
page: Any | None = None,
236346
extra_headers: Headers | None = None,
237347
extra_query: Query | None = None,
238348
extra_body: Body | None = None,
@@ -245,7 +355,7 @@ async def execute(
245355
extra_query=extra_query,
246356
extra_body=extra_body,
247357
timeout=timeout,
248-
**params,
358+
**(await _maybe_inject_frame_id_async(dict(params), page)),
249359
)
250360

251361
async def end(

0 commit comments

Comments
 (0)