diff --git a/README.md b/README.md index 1800f53..43557ef 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,15 @@ export MODEL_API_KEY="sk-proj-your-llm-api-key" uv run python examples/full_example.py ``` +## Local mode example + +If you want to run Stagehand locally, use the local example (`examples/local_example.py`). It shows how to configure the client for `server="local"`: + +```bash +pip install stagehand-alpha +uv run python examples/local_example.py +``` +
Local development @@ -73,7 +82,6 @@ async def main() -> None: # Navigate to a webpage await session.navigate( url="https://news.ycombinator.com", - frame_id="", # empty string for the main frame ) print("Navigated to Hacker News") diff --git a/examples/act_example.py b/examples/act_example.py index 377c1df..c472983 100644 --- a/examples/act_example.py +++ b/examples/act_example.py @@ -38,7 +38,6 @@ async def main() -> None: # Navigate to example.com await session.navigate( url="https://www.example.com", - frame_id="", # Empty string for main frame ) print("Navigated to example.com") diff --git a/examples/byob_example.py b/examples/byob_example.py new file mode 100644 index 0000000..1f6cb0e --- /dev/null +++ b/examples/byob_example.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +""" +Example showing how to bring your own browser driver while still using Stagehand. + +This script runs Playwright locally to drive the browser and uses Stagehand to +plan the interactions (observe → extract) without having Stagehand own the page. + +Required environment variables: +- BROWSERBASE_API_KEY +- BROWSERBASE_PROJECT_ID +- MODEL_API_KEY + +Usage: + +``` +pip install playwright stagehand-alpha +# (if Playwright is new) playwright install chromium +uv run python examples/byob_example.py +``` +""" + +import os +import asyncio + +from playwright.async_api import async_playwright + +from stagehand import AsyncStagehand + + +async def main() -> None: + async with AsyncStagehand( + browserbase_api_key=os.environ.get("BROWSERBASE_API_KEY"), + browserbase_project_id=os.environ.get("BROWSERBASE_PROJECT_ID"), + model_api_key=os.environ.get("MODEL_API_KEY"), + ) as client, async_playwright() as playwright: + browser = await playwright.chromium.launch(headless=True) + page = await browser.new_page() + session = await client.sessions.create(model_name="openai/gpt-5-nano") + + try: + target_url = "https://news.ycombinator.com" + await session.navigate(url=target_url) + await page.goto(target_url, wait_until="networkidle") + + print("🎯 Stagehand already navigated to Hacker News; Playwright now drives that page.") + + # Click the first story's comments link with Playwright. + comments_selector = "tr.athing:first-of-type + tr .subline > a[href^='item?id=']:nth-last-of-type(1)" + await page.click(comments_selector, timeout=15_000) + await page.wait_for_load_state("networkidle") + + print("✅ Playwright clicked the first story link.") + + print("🔄 Syncing Stagehand to Playwright's current URL:", page.url) + await session.navigate(url=page.url) + + extract_response = await session.extract( + instruction="extract the text of the top comment on this page", + schema={ + "type": "object", + "properties": {"comment": {"type": "string"}}, + "required": ["comment"], + }, + ) + + print("🧮 Stagehand extraction result:", extract_response.data.result) + finally: + await session.end() + await browser.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/full_example.py b/examples/full_example.py index e6b1997..dcba741 100644 --- a/examples/full_example.py +++ b/examples/full_example.py @@ -40,7 +40,6 @@ async def main() -> None: # Navigate to Hacker News await session.navigate( url="https://news.ycombinator.com", - frame_id="", # Empty string for main frame ) print("Navigated to Hacker News") diff --git a/examples/local_example.py b/examples/local_example.py index d33ecee..2d0ae71 100644 --- a/examples/local_example.py +++ b/examples/local_example.py @@ -52,7 +52,6 @@ def main() -> None: client.sessions.navigate( id=session_id, url="https://www.example.com", - frame_id="", ) print("✅ Navigation complete") diff --git a/src/stagehand/session.py b/src/stagehand/session.py index eebea84..292243f 100644 --- a/src/stagehand/session.py +++ b/src/stagehand/session.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING, Any, Union, Mapping, TypeVar, cast from datetime import datetime from typing_extensions import Unpack, Literal @@ -23,6 +23,15 @@ from .types.session_observe_response import SessionObserveResponse from .types.session_navigate_response import SessionNavigateResponse +TSessionParams = TypeVar("TSessionParams", bound=Mapping[str, Any]) + + +def _with_default_frame_id(params: TSessionParams) -> TSessionParams: + prepared = dict(params) + if "frame_id" not in prepared: + prepared["frame_id"] = "" + return cast(TSessionParams, prepared) + if TYPE_CHECKING: from ._client import Stagehand, AsyncStagehand @@ -49,7 +58,7 @@ def navigate( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - **params, + **_with_default_frame_id(params), ) def act( @@ -67,7 +76,7 @@ def act( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - **params, + **_with_default_frame_id(params), ) def observe( @@ -85,7 +94,7 @@ def observe( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - **params, + **_with_default_frame_id(params), ) def extract( @@ -103,7 +112,7 @@ def extract( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - **params, + **_with_default_frame_id(params), ) def execute( @@ -121,7 +130,7 @@ def execute( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - **params, + **_with_default_frame_id(params), ) def end( @@ -171,7 +180,7 @@ async def navigate( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - **params, + **_with_default_frame_id(params), ) async def act( @@ -189,7 +198,7 @@ async def act( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - **params, + **_with_default_frame_id(params), ) async def observe( @@ -207,7 +216,7 @@ async def observe( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - **params, + **_with_default_frame_id(params), ) async def extract( @@ -225,7 +234,7 @@ async def extract( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - **params, + **_with_default_frame_id(params), ) async def execute( @@ -243,7 +252,7 @@ async def execute( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - **params, + **_with_default_frame_id(params), ) async def end( diff --git a/tests/test_sessions_create_helper.py b/tests/test_sessions_create_helper.py index 1236119..f9c9d99 100644 --- a/tests/test_sessions_create_helper.py +++ b/tests/test_sessions_create_helper.py @@ -3,10 +3,13 @@ from __future__ import annotations import os +import json +from typing import cast import httpx import pytest from respx import MockRouter +from respx.models import Call from stagehand import Stagehand, AsyncStagehand @@ -37,8 +40,11 @@ def test_sessions_create_returns_bound_session(respx_mock: MockRouter, client: S session = client.sessions.create(model_name="openai/gpt-5-nano") assert session.id == session_id - session.navigate(url="https://example.com", frame_id="") + session.navigate(url="https://example.com") assert navigate_route.called is True + first_call = cast(Call, navigate_route.calls[0]) + request_body = json.loads(first_call.request.content) + assert request_body["frameId"] == "" @pytest.mark.respx(base_url=base_url) @@ -67,5 +73,8 @@ async def test_async_sessions_create_returns_bound_session( session = await async_client.sessions.create(model_name="openai/gpt-5-nano") assert session.id == session_id - await session.navigate(url="https://example.com", frame_id="") + await session.navigate(url="https://example.com") assert navigate_route.called is True + first_call = cast(Call, navigate_route.calls[0]) + request_body = json.loads(first_call.request.content) + assert request_body["frameId"] == ""