diff --git a/oxlint.config.ts b/oxlint.config.ts index 2faec66b6c..8b7e0c4a1b 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -2,6 +2,9 @@ import { defineConfig } from "oxlint"; import { stagehandRuleConfig } from "./rules/oxlint/stagehand-plugin.ts"; export default defineConfig({ + // These standalone projects retain their own SDK generations and dependencies. + // Lint them with packages/examples/oxlint.examples.config.ts after copying or editing. + ignorePatterns: ["packages/examples/**"], jsPlugins: [{ name: "stagehand", specifier: "./rules/oxlint/stagehand-plugin.ts" }], rules: { "no-console": "error", diff --git a/packages/examples/.gitignore b/packages/examples/.gitignore new file mode 100644 index 0000000000..0265667eb5 --- /dev/null +++ b/packages/examples/.gitignore @@ -0,0 +1,13 @@ +# Local runtime output from standalone examples +.cache/ +__pycache__/ +.venv/ +.next/ +.convex/ +reports/ +screenshots/ +output/ +outputs/ +downloads/ +downloaded_files.zip +*.tsbuildinfo diff --git a/packages/examples/1password-extension/.env.example b/packages/examples/1password-extension/.env.example new file mode 100644 index 0000000000..2f2dace113 --- /dev/null +++ b/packages/examples/1password-extension/.env.example @@ -0,0 +1,5 @@ +BROWSERBASE_API_KEY= +BROWSERBASE_PROJECT_ID= +EXTENSION_ZIP_PATH=./1password.zip +MODEL_API_KEY= +EXTENSION_ID= diff --git a/packages/examples/1password-extension/README.md b/packages/examples/1password-extension/README.md new file mode 100644 index 0000000000..49f8de3c23 --- /dev/null +++ b/packages/examples/1password-extension/README.md @@ -0,0 +1,7 @@ +# 1Password extension guide + +Location in the Stagehand repository: `packages/examples/1password-extension`. + +Stagehand opens an uploaded browser extension, starts interactive sign-in, and visits Browserbase. Supply your own extension archive and the values named in `.env.example`. No extension binary, account state, or credentials are bundled. + +For the Node example, run `npm install`, then `npm run upload-extension` and `npm start`. This guide uses Stagehand v2. The Python files are preserved legacy `StagehandConfig` snippets: the source repo did not declare a compatible Python Stagehand dependency, so they require an SDK port before use with Stagehand v4. diff --git a/packages/examples/1password-extension/node/createExtension.ts b/packages/examples/1password-extension/node/createExtension.ts new file mode 100644 index 0000000000..bc64da0bbc --- /dev/null +++ b/packages/examples/1password-extension/node/createExtension.ts @@ -0,0 +1,26 @@ +import Browserbase from "@browserbasehq/sdk"; +import { createReadStream } from "fs"; +import dotenv from "dotenv"; + +dotenv.config(); + +export async function createExtension(bb: Browserbase): Promise { + const extensionPath = process.env.EXTENSION_ZIP_PATH!; + const extension = await bb.extensions.create({ + file: createReadStream(extensionPath), + }); + console.log("\n\n\n"); + console.log(`Extension uploaded with ID: ${extension.id}`); + console.log(`Load this extension into your .env file as EXTENSION_ID`); + console.log("\n\n\n"); + return extension.id; +} + +async function main() { + const bb = new Browserbase({ + apiKey: process.env.BROWSERBASE_API_KEY!, + }); + await createExtension(bb); +} + +main(); diff --git a/packages/examples/1password-extension/node/stagehand.ts b/packages/examples/1password-extension/node/stagehand.ts new file mode 100644 index 0000000000..11aa31041a --- /dev/null +++ b/packages/examples/1password-extension/node/stagehand.ts @@ -0,0 +1,35 @@ +import { ConstructorParams, Stagehand } from "@browserbasehq/stagehand"; +import dotenv from "dotenv"; + +dotenv.config(); + +const stagehandConfig = (): ConstructorParams => ({ + env: "BROWSERBASE", + modelName: "openai/gpt-4o-mini", + modelClientOptions: { apiKey: process.env.MODEL_API_KEY! }, + browserbaseSessionCreateParams: { + projectId: process.env.BROWSERBASE_PROJECT_ID!, + browserSettings: { + extensionId: process.env.EXTENSION_ID!, + }, + }, +}); +(async () => { + const stagehand = new Stagehand(stagehandConfig()); + await stagehand.init(); + const page = stagehand.page; + + // log in to 1password + await page.goto("chrome-extension://aeblfdkhhhdcdjpifhhbdiojplfjncoa/app/app.html#/page/welcome"); + await page.act("click the Continue button"); + await page.act("click the Sign in button"); + + // wait for enter in the terminal + await new Promise((resolve) => setTimeout(resolve, 10000)); + + // log in to browserbase using the credentials from 1password + await page.goto("https://browserbase.com/sign-in"); + await page.waitForLoadState("networkidle"); + + await page.close(); +})(); diff --git a/packages/examples/1password-extension/package.json b/packages/examples/1password-extension/package.json new file mode 100644 index 0000000000..dff1e4e801 --- /dev/null +++ b/packages/examples/1password-extension/package.json @@ -0,0 +1,19 @@ +{ + "name": "stagehand-example-1password-extension", + "private": true, + "type": "module", + "scripts": { + "start": "tsx node/stagehand.ts", + "upload-extension": "tsx node/createExtension.ts" + }, + "dependencies": { + "@browserbasehq/sdk": "^2.6.0", + "@browserbasehq/stagehand": "^2.2.1", + "dotenv": "^16.4.7" + }, + "devDependencies": { + "@types/node": "^22.9.1", + "tsx": "^4.19.2", + "typescript": "^5.0.0" + } +} diff --git a/packages/examples/1password-extension/python/create_extension.py b/packages/examples/1password-extension/python/create_extension.py new file mode 100644 index 0000000000..8822dbe6f9 --- /dev/null +++ b/packages/examples/1password-extension/python/create_extension.py @@ -0,0 +1,30 @@ +import os +from browserbase import Browserbase +from dotenv import load_dotenv + +load_dotenv() + +def create_extension(bb: Browserbase) -> str: + extension_path = os.getenv("EXTENSION_ZIP_PATH") + if not extension_path: + raise ValueError("EXTENSION_ZIP_PATH environment variable is required") + + with open(extension_path, 'rb') as file: + extension = bb.extensions.create(file=file) + + print("\n\n\n") + print(f"Extension uploaded with ID: {extension.id}") + print("Load this extension into your .env file as EXTENSION_ID") + print("\n\n\n") + return extension.id + +def main(): + api_key = os.getenv("BROWSERBASE_API_KEY") + if not api_key: + raise ValueError("BROWSERBASE_API_KEY environment variable is required") + + bb = Browserbase(api_key=api_key) + create_extension(bb) + +if __name__ == "__main__": + main() diff --git a/packages/examples/1password-extension/python/run_stagehand.py b/packages/examples/1password-extension/python/run_stagehand.py new file mode 100644 index 0000000000..9df2c03a64 --- /dev/null +++ b/packages/examples/1password-extension/python/run_stagehand.py @@ -0,0 +1,41 @@ +import asyncio +import os +from stagehand import Stagehand, StagehandConfig +from dotenv import load_dotenv + +load_dotenv() + +async def main(): + config = StagehandConfig( + env="BROWSERBASE", + api_key=os.getenv("BROWSERBASE_API_KEY"), + project_id=os.getenv("BROWSERBASE_PROJECT_ID"), + model_name="gpt-4o", + model_api_key=os.getenv("MODEL_API_KEY"), + browserbase_session_create_params={ + "project_id": os.getenv("BROWSERBASE_PROJECT_ID"), + "browser_settings": { + "extension_id": os.getenv("EXTENSION_ID") + } + } + ) + stagehand = Stagehand(config) + try: + await stagehand.init() + page = stagehand.page + + await page.goto("chrome-extension://aeblfdkhhhdcdjpifhhbdiojplfjncoa/app/app.html#/page/welcome") + await page.act("click the Continue button") + await page.act("click the Sign in button") + + # wait 60 seconds + await asyncio.sleep(60) + + await page.goto("https://browserbase.com/sign-in") + await asyncio.sleep(10) + + finally: + await stagehand.close() + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/packages/examples/LICENSE b/packages/examples/LICENSE new file mode 100644 index 0000000000..cb36e7b120 --- /dev/null +++ b/packages/examples/LICENSE @@ -0,0 +1,7 @@ +Copyright 2025 Browserbase, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the โ€œSoftwareโ€), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED โ€œAS ISโ€, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/examples/README.md b/packages/examples/README.md new file mode 100644 index 0000000000..4133ef71a1 --- /dev/null +++ b/packages/examples/README.md @@ -0,0 +1,77 @@ +# Stagehand examples + +42 examples, each in its own directory. The 24 examples with both TypeScript and Python versions keep those variants inside the example directory. This catalog contains 66 retained entries from the source collections. + +Each example keeps its own dependencies and setup instructions. These projects install published packages and are outside the root pnpm workspace. + +## Start here + +- [Basic caching](basic-caching/README.md): a small Stagehand v4 example in TypeScript and Python. +- [v4 demo kit](v4-demo-kit/README.md): an agent harness, reusable tools, mixed browser/AI control, and an HTTP runner. +- [Existing framework integrations](../integrations/README.md): the maintained framework implementations and their examples. +- [Skills](../skills/README.md): the Browserbase skill collections maintained in their own repository. + +## Run an example + +Open an example's README, choose a language when applicable, and enter that directory. Install the dependencies there and follow its run instructions. Supply credentials through your environment; configuration templates list the required inputs. + +The imported examples span several SDK generations. The paired templates and v4 demo kit target v4; other examples retain older APIs or consume integration adapters. Check the dependency column and local manifest before running. Consolidating these files does not migrate their APIs or certify their live workflows. + +Examples that authenticate, submit demo forms, book recreation slots, write to databases, or upload files describe those actions in their READMEs. Saved browser state, customer records, customer credentials, and generated session output are excluded; synthetic/demo values remain where documented. + +## Existing integration examples + +Use the existing [CrewAI](../integrations/crewai/README.md), [DeepAgents](../integrations/deepagents/README.md), and [Mastra](../integrations/mastra/README.md) examples. Their older standalone counterparts were omitted from this import. Reusable integration implementations stay in `packages/integrations`; examples below demonstrate additional workflows or adapters. + +## Catalog + +Source labels record provenance rather than directory categories. Templates, Playbook, and Integrations refer to the corresponding Browserbase source repositories; Reviewed demos denotes the selected generic demos. + +| Example | Runtime and Stagehand dependency | Purpose | Source | +| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | +| [1password-extension](1password-extension/README.md) | `^2.2.1` | Stagehand extension-login snippets for Node and legacy Python; credentials and extension IDs are runtime inputs, and the extension archive is excluded. | Playbook | +| [agentkit](agentkit/README.md) | `^3.2.0` | Reusable Stagehand tool definitions for an Inngest agent network; user-supplied tasks. | Integrations | +| [alaska-flights](alaska-flights/README.md) | `^2.2.1` | Public flight search; sample route and dates, no passenger profile or booking. | Playbook | +| [amazon-global-price-comparison](amazon-global-price-comparison/README.md) | [TypeScript](amazon-global-price-comparison/typescript/README.md) `4.0.0`; [Python](amazon-global-price-comparison/python/README.md) `4.0.0` | Public product search across storefronts with configurable product input; no customer account data. | Templates | +| [amazon-product-scraping](amazon-product-scraping/README.md) | [TypeScript](amazon-product-scraping/typescript/README.md) `4.0.0`; [Python](amazon-product-scraping/python/README.md) `4.0.0` | Structured extraction from public product search results. | Templates | +| [basic-caching](basic-caching/README.md) | [TypeScript](basic-caching/typescript/README.md) `4.0.2`; [Python](basic-caching/python/README.md) `4.0.0` | Observe/cache example on the reserved example.com domain. | Templates | +| [basic-recaptcha](basic-recaptcha/README.md) | [TypeScript](basic-recaptcha/typescript/README.md) `4.0.2`; [Python](basic-recaptcha/python/README.md) `4.0.0` | Stagehand actions against the public reCAPTCHA demo. | Templates | +| [box](box/README.md) | `^3.7.1` | Downloads public regulatory/product documents with Stagehand and uploads to a runtime-configured Box folder. | Integrations | +| [browserbase-reducto](browserbase-reducto/README.md) | [TypeScript](browserbase-reducto/typescript/README.md) `4.0.0`; [Python](browserbase-reducto/python/README.md) `4.0.0` | Public Apple investor documents downloaded with Stagehand and parsed with Reducto; keys come from the environment. | Templates | +| [caching-with-variables](caching-with-variables/README.md) | `3.0.7` | Generic caching experiments against a public test login; synthetic email examples use reserved domains. | Reviewed demos | +| [company-news-function](company-news-function/README.md) | `3.0.8` | Stagehand agent in a Browserbase Function; company name is an input. | Reviewed demos | +| [company-value-prop-generator](company-value-prop-generator/README.md) | [TypeScript](company-value-prop-generator/typescript/README.md) `4.0.2`; [Python](company-value-prop-generator/python/README.md) `4.0.0` | Configurable public-company website extraction; no customer tenant or account. | Templates | +| [configurable-browser-trial](configurable-browser-trial/README.md) | `^2.4.0` | Configurable Stagehand trial runner with public demo defaults; no customer URL list. | Reviewed demos | +| [context](context/README.md) | [TypeScript](context/typescript/README.md) `4.0.0`; [Python](context/python/README.md) `4.0.0` | Public recreation-site context reuse; login values are supplied at runtime, with no bundled account state. | Templates | +| [convex](convex/README.md) | `via Convex component` | Convex action wrappers for Stagehand act/extract/observe/navigation with runtime URL/action inputs; generated Convex bindings are created by convex dev. | Integrations | +| [council-events](council-events/README.md) | [TypeScript](council-events/typescript/README.md) `4.0.2`; [Python](council-events/python/README.md) `4.0.0` | Read-only extraction of public Philadelphia council calendar records. | Templates | +| [docs-search](docs-search/README.md) | `^2.2.1` | Searches public Stagehand documentation and extracts the answer. | Playbook | +| [download-financial-statements](download-financial-statements/README.md) | [TypeScript](download-financial-statements/typescript/README.md) `4.0.0`; [Python](download-financial-statements/python/README.md) `4.0.0` | Download public Apple financial statements using Stagehand-discovered controls. | Templates | +| [extend-browserbase](extend-browserbase/README.md) | [TypeScript](extend-browserbase/typescript/README.md) `4.0.0`; [Python](extend-browserbase/python/README.md) `4.0.0` | Downloads synthetic receipts from a demo expense portal and parses them with Extend. | Templates | +| [form-filling](form-filling/README.md) | [TypeScript](form-filling/typescript/README.md) `4.0.2`; [Python](form-filling/python/README.md) `4.0.0` | Browserbase contact-form demonstration with synthetic inputs; example email normalized to example.com; submit remains disabled. | Templates | +| [gift-finder](gift-finder/README.md) | [TypeScript](gift-finder/typescript/README.md) `4.0.0`; [Python](gift-finder/python/README.md) `4.0.0` | Public retail product recommendations with configurable recipient interests. | Templates | +| [google-trends](google-trends/README.md) | [TypeScript](google-trends/typescript/README.md) `4.0.0`; [Python](google-trends/python/README.md) `4.0.0` | Read-only public trending-search extraction with country/language inputs. | Templates | +| [hacker-news](hacker-news/README.md) | `^2.2.1` | Extracts public Hacker News headlines with Stagehand v2. | Playbook | +| [hacker-news-intelligence](hacker-news-intelligence/README.md) | `^1.5.0` | Public Hacker News discovery and article analysis; generated reports are excluded. | Reviewed demos | +| [image-url-download](image-url-download/README.md) | [TypeScript](image-url-download/typescript/README.md) `4.0.0`; [Python](image-url-download/python/README.md) `4.0.0` | Find and download public website images; no captured output is included. | Templates | +| [job-application](job-application/README.md) | [TypeScript](job-application/typescript/README.md) `4.0.0`; [Python](job-application/python/README.md) `4.0.0` | Dedicated agent job-board demo with generated example.com addresses and an agent resume, not a customer ATS. | Templates | +| [langchain](langchain/README.md) | `^2.4.4` | LangChain Stagehand toolkit demonstrated with a public web search. | Integrations | +| [manual-mfa-with-contexts](manual-mfa-with-contexts/README.md) | [TypeScript](manual-mfa-with-contexts/typescript/README.md) `4.0.0`; [Python](manual-mfa-with-contexts/python/README.md) `4.0.0` | Generic GitHub login with interactive user authentication and fresh runtime contexts. | Templates | +| [mfa-handling](mfa-handling/README.md) | [TypeScript](mfa-handling/typescript/README.md) `4.0.0`; [Python](mfa-handling/python/README.md) `4.0.0` | TOTP demonstration against a public authentication test site, with demo values discovered at runtime. | Templates | +| [mongodb](mongodb/README.md) | `0.3.0, ^4.0.0` | Public retail extraction persisted to a runtime-configured MongoDB database; Python and TypeScript variants. | Integrations | +| [pickleball](pickleball/README.md) | [TypeScript](pickleball/typescript/README.md) `4.0.0`; [Python](pickleball/python/README.md) `4.0.0` | Public recreation-site booking example; credentials and choices are runtime inputs, no customer account fixture. | Templates | +| [polymarket-research](polymarket-research/README.md) | [TypeScript](polymarket-research/typescript/README.md) `4.0.2`; [Python](polymarket-research/python/README.md) `4.0.0` | Read-only public prediction-market research. | Templates | +| [proxies](proxies/README.md) | [TypeScript](proxies/typescript/README.md) `4.0.2`; [Python](proxies/python/README.md) `4.0.0` | Stagehand extraction from public IP-information endpoints. | Templates | +| [proxies-weather](proxies-weather/README.md) | [TypeScript](proxies-weather/typescript/README.md) `4.0.0`; [Python](proxies-weather/python/README.md) `4.0.0` | Public weather extraction with proxy geography configuration. | Templates | +| [qa-agent](qa-agent/README.md) | `^2.5.2` | Reusable QA agent and intentionally buggy local storefront; no customer application. | Reviewed demos | +| [sec-filing-research](sec-filing-research/README.md) | [TypeScript](sec-filing-research/typescript/README.md) `4.0.0`; [Python](sec-filing-research/python/README.md) `4.0.0` | Public SEC filings research using sample public-company identifiers. | Templates | +| [smart-fetch-scraper](smart-fetch-scraper/README.md) | [TypeScript](smart-fetch-scraper/typescript/README.md) `4.0.0`; [Python](smart-fetch-scraper/python/README.md) `4.0.0` | Fetch-first extraction with a meaningful Stagehand browser fallback. | Templates | +| [southwest-flights](southwest-flights/README.md) | `^2.2.1` | Public flight search using relative travel dates, no passenger profile or booking. | Playbook | +| [temporal](temporal/README.md) | `^2.4.4` | Durable web-research activities and workflow; public search and runtime research input. | Integrations | +| [v4-demo-kit](v4-demo-kit/README.md) | `4.0.2` | Reusable v4 tools, agent harness, scripts, and HTTP runner using example.com and Hacker News. | Reviewed demos | +| [web-performance](web-performance/README.md) | `^3.0.0` | Stagehand agent custom tools read native browser performance metrics on public pages. | Reviewed demos | +| [website-link-tester](website-link-tester/README.md) | [TypeScript](website-link-tester/typescript/README.md) `4.0.2`; [Python](website-link-tester/python/README.md) `4.0.0` | Public website link checking using Stagehand extraction and configurable target URL. | Templates | + +## Validate source changes + +From the repository root, run `pnpm exec oxlint --config packages/examples/oxlint.examples.config.ts packages/examples` and `pnpm exec oxfmt --check packages/examples packages/skills`. Type checks and runtime tests belong to each example's dependency installation; the main SDK lint excludes these independent projects. diff --git a/packages/examples/agentkit/README.md b/packages/examples/agentkit/README.md new file mode 100644 index 0000000000..245c0d08be --- /dev/null +++ b/packages/examples/agentkit/README.md @@ -0,0 +1,102 @@ +# Simple Search Agent with AgentKit and Stagehand + +Location in the Stagehand repository: `packages/examples/agentkit`. + +This Web Search Agent uses [Stagehand](https://www.stagehand.dev/) as AgentKit tools to navigate the web autonomously. +For any question, the network with combine reasoning and web search to answer the question. + +## Overview + +### Tools + +The Web Search Agent provides four main tools: + +- `navigate`: Go to specific URLs +- `extract`: Extract structured data from web pages +- `act`: Perform actions like clicking buttons +- `observe`: Make observations about page content + +### Utils + +The `getStagehand()` function is used to retrieve the persisted Stagehand instance for the network execution. This function is using the `browserbaseSessionID` from the network state to retrieve the [kept alive Browserbase session](https://docs.browserbase.com/guides/long-running-sessions#keeping-sessions-alive-across-disconnects). + +## Prerequisites + +- Node.js (v16 or later) +- An Inngest account +- A Browserbase account and API key +- OpenAI API access + +## Setup + +1. Install dependencies: + +```bash +npm install +``` + +2. Create a `.env` file with the following variables: + +```env +BROWSERBASE_API_KEY=your_browserbase_api_key +BROWSERBASE_PROJECT_ID=your_browserbase_project_id +OPENAI_API_KEY=your_openai_api_key +``` + +3. Start the server: + +```bash +npm start +``` + +The server will start on port 3010. + +4. Start the Inngest Dev Server + +```bash +npx inngest-cli@latest dev +``` + +The Inngest Dev Server will start at [http://127.0.0.1:8288/](http://127.0.0.1:8288/). + +## Usage + +Navigate to the Inngest Dev Server runs view: [http://127.0.0.1:8288/functions](http://127.0.0.1:8288/functions). + +From there, trigger the `simple-search-agent-workflow` function with your query: + +```json +{ + "data": { + "input": "When Inngest was founded?" + } +} +``` + +The agent will: + +1. Create a new Browserbase session +2. Process your query through the agent network +3. Return structured data based on the search results +4. Automatically clean up the browser session + +## Configuration + +You can customize the agent's behavior by modifying: + +- `maxIter` in the search network (default: 15) +- The OpenAI model used (default: gpt-4o) +- The system prompts for both agents +- The tools available to the web search agent + +## Error Handling + +The workflow includes proper error handling and cleanup: + +- Browser sessions are automatically closed +- Network timeouts are managed +- Tool execution errors are caught and reported + +## License + +MIT diff --git a/packages/examples/agentkit/package.json b/packages/examples/agentkit/package.json new file mode 100644 index 0000000000..68d363615a --- /dev/null +++ b/packages/examples/agentkit/package.json @@ -0,0 +1,20 @@ +{ + "name": "simple-search-stagehand", + "private": true, + "type": "module", + "scripts": { + "start": "tsc; node dist/index.js" + }, + "dependencies": { + "@browserbasehq/sdk": "^2.6.0", + "@browserbasehq/stagehand": "^3.2.0", + "@inngest/agent-kit": "^0.8.0", + "dotenv": "^16.4.7", + "inngest": "latest", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/node": "^22.9.1", + "typescript": "^5.0.0" + } +} diff --git a/packages/examples/agentkit/src/index.ts b/packages/examples/agentkit/src/index.ts new file mode 100644 index 0000000000..0c2360fe33 --- /dev/null +++ b/packages/examples/agentkit/src/index.ts @@ -0,0 +1,141 @@ +/* eslint-disable */ +import "dotenv/config"; + +import { createServer } from "@inngest/agent-kit/server"; +import { Inngest } from "inngest"; +import { + createAgent, + createNetwork, + createRoutingAgent, + createTool, + openai, + State, +} from "@inngest/agent-kit"; +import { z } from "zod"; +import Browserbase from "@browserbasehq/sdk"; + +import { getStagehand, isLastMessageOfType, lastResult } from "./utils.js"; +import { navigate, extract, act, observe } from "./stagehand-tools.js"; + +const bb = new Browserbase({ + apiKey: process.env.BROWSERBASE_API_KEY as string, +}); + +const webSearchAgent = createAgent({ + name: "web_search_agent", + description: "I am a web search agent.", + system: `You are a web search agent. + `, + tools: [navigate, extract, act, observe], +}); + +const supervisorRoutingAgent = createRoutingAgent({ + name: "Supervisor", + description: "I am a Research supervisor.", + system: `You are a research supervisor. +Your goal is to search for information linked to the user request by augmenting your own research with the "web_search_agent" agent. + +Think step by step and reason through your decision. + +When the answer is found, call the "done" agent.`, + model: openai({ + model: "gpt-4o", + }), + tools: [ + createTool({ + name: "route_to_agent", + description: "Invoke an agent to perform a task", + parameters: z.object({ + agent: z.string().describe("The agent to invoke"), + }), + handler: async ({ agent }) => { + return agent; + }, + }), + ], + tool_choice: "route_to_agent", + lifecycle: { + onRoute: ({ result, network }) => { + const lastMessage = lastResult(network?.state.results); + + // ensure to loop back to the last executing agent if a tool has been called + if (lastMessage && isLastMessageOfType(lastMessage, "tool_call")) { + return [lastMessage.agentName]; + } + + const tool = result.toolCalls[0]; + if (!tool) { + return; + } + const toolName = tool.tool.name; + if (toolName === "done") { + return; + } else if (toolName === "route_to_agent") { + if ( + typeof tool.content === "object" && + tool.content !== null && + "data" in tool.content && + typeof tool.content.data === "string" + ) { + return [tool.content.data]; + } + } + return; + }, + }, +}); + +// Create a network with the agents and default router +const searchNetwork = createNetwork({ + name: "Simple Search Network", + agents: [webSearchAgent], + maxIter: 15, + defaultModel: openai({ + model: "gpt-4o", + }), + defaultRouter: supervisorRoutingAgent, +}); + +const inngest = new Inngest({ + id: "Simple Search Agent", +}); + +const simpleSearchWorkflow = inngest.createFunction( + { + id: "simple-search-agent-workflow", + }, + { + event: "app/support.ticket.created", + }, + async ({ step, event }) => { + const browserbaseSessionID = await step.run("create_browserbase_session", async () => { + const session = await bb.sessions.create({ + projectId: process.env.BROWSERBASE_PROJECT_ID as string, + keepAlive: true, + }); + return session.id; + }); + + const response = await searchNetwork.run(event.data.input, { + state: new State({ + data: { browserbaseSessionID }, + }), + }); + + await step.run("close-browserbase-session", async () => { + const stagehand = await getStagehand(browserbaseSessionID); + await stagehand.close(); + }); + + return { + response, + }; + }, +); + +// Create and start the server +const server = createServer({ + functions: [simpleSearchWorkflow as any], +}); + +server.listen(3010, () => console.log("Simple search Agent demo server is running on port 3010")); diff --git a/packages/examples/agentkit/src/stagehand-tools.ts b/packages/examples/agentkit/src/stagehand-tools.ts new file mode 100644 index 0000000000..00e0481c31 --- /dev/null +++ b/packages/examples/agentkit/src/stagehand-tools.ts @@ -0,0 +1,84 @@ +import { z } from "zod"; +import { stringToZodSchema } from "./utils.js"; +import { getStagehand } from "./utils.js"; +import { createTool } from "@inngest/agent-kit"; + +export const navigate = createTool({ + name: "navigate", + description: "Navigate to a given URL", + parameters: z.object({ + url: z.string().describe("the URL to navigate to"), + }), + handler: async ({ url }, { step, network }) => { + return await step?.run("navigate", async () => { + const stagehand = await getStagehand(network?.state.kv.get("browserbaseSessionID")!); + try { + const page = stagehand.context.pages()[0]; + await page.goto(url); + return `Navigated to ${url}.`; + } catch (error) { + return `Failed to navigate to ${url}: ${error}`; + } + }); + }, +}); + +export const extract = createTool({ + name: "extract", + description: "Extract data from the page", + parameters: z.object({ + instruction: z.string().describe("Instructions for what data to extract from the page"), + schema: z + .string() + .describe( + "A string representing the properties and types of data to extract, for example: '{ name: string, age: number }'", + ), + }), + handler: async ({ instruction, schema }, { step, network }) => { + return await step?.run("extract", async () => { + const stagehand = await getStagehand(network?.state.kv.get("browserbaseSessionID")!); + const zodSchema = stringToZodSchema(schema); + try { + return await stagehand.extract(instruction, zodSchema); + } catch (error) { + return `Failed to extract data from the page: ${error}`; + } + }); + }, +}); + +export const act = createTool({ + name: "act", + description: "Perform an action on the page", + parameters: z.object({ + action: z.string().describe("The action to perform (e.g. 'click the login button')"), + }), + handler: async ({ action }, { step, network }) => { + return await step?.run("act", async () => { + const stagehand = await getStagehand(network?.state.kv.get("browserbaseSessionID")!); + try { + return await stagehand.act(action); + } catch (error) { + return `Failed to perform action on the page: ${error}`; + } + }); + }, +}); + +export const observe = createTool({ + name: "observe", + description: "Observe the page", + parameters: z.object({ + instruction: z.string().describe("Specific instruction for what to observe on the page"), + }), + handler: async ({ instruction }, { step, network }) => { + return await step?.run("observe", async () => { + const stagehand = await getStagehand(network?.state.kv.get("browserbaseSessionID")!); + try { + return await stagehand.observe(instruction); + } catch (error) { + return `Failed to observe the page: ${error}`; + } + }); + }, +}); diff --git a/packages/examples/agentkit/src/utils.ts b/packages/examples/agentkit/src/utils.ts new file mode 100644 index 0000000000..83227c8616 --- /dev/null +++ b/packages/examples/agentkit/src/utils.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; +import { Stagehand } from "@browserbasehq/stagehand"; +import { + TextMessage, + ToolCallMessage, + ToolResultMessage, + type AgentResult, +} from "@inngest/agent-kit"; + +export async function getStagehand(sessionId: string) { + const stagehand = new Stagehand({ + env: "BROWSERBASE", + apiKey: process.env.BROWSERBASE_API_KEY, + projectId: process.env.BROWSERBASE_PROJECT_ID, + browserbaseSessionID: sessionId, + model: "openai/gpt-4o", + }); + await stagehand.init(); + return stagehand; +} + +export const StagehandAvailableModelSchema = z.enum([ + "openai/gpt-4o", + "openai/gpt-4o-mini", + "openai/gpt-4.1", + "openai/o3-mini", + "anthropic/claude-sonnet-4-6", + "google/gemini-2.5-flash", +]); + +// Transform string such as "{ lastFundraiseDate: string, amount: string, round: string }" into a zod schema +export function stringToZodSchema(schema: string) { + // Remove whitespace and curly braces + const trimmed = schema.replace(/\s/g, "").slice(1, -1); + + // Split into individual field definitions + const fields = trimmed.split(","); + + // Build object shape + const shape: Record = {}; + + for (const field of fields) { + const [key, type] = field.split(":"); + + // Check if type is an array (ends with []) + const isArray = type.endsWith("[]"); + const baseType = isArray ? type.slice(0, -2) : type; + + let zodType: z.ZodType; + switch (baseType) { + case "string": + zodType = z.string(); + break; + case "number": + zodType = z.number(); + break; + case "boolean": + zodType = z.boolean(); + break; + case "date": + zodType = z.date(); + break; + default: + zodType = z.string(); // Default to string for unknown types + } + + // Wrap in array if needed + shape[key] = isArray ? z.array(zodType) : zodType; + } + + return z.object(shape); +} + +export function lastResult(results: AgentResult[] | undefined) { + if (!results) { + return undefined; + } + return results[results.length - 1]; +} + +type MessageType = TextMessage["type"] | ToolCallMessage["type"] | ToolResultMessage["type"]; + +export function isLastMessageOfType(result: AgentResult, type: MessageType) { + return result.output[result.output.length - 1]?.type === type; +} diff --git a/packages/examples/agentkit/tsconfig.json b/packages/examples/agentkit/tsconfig.json new file mode 100644 index 0000000000..b745615ba5 --- /dev/null +++ b/packages/examples/agentkit/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2023", + "module": "Node16", + "lib": ["es2023", "DOM"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "Node16" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts"] +} diff --git a/packages/examples/alaska-flights/.env.example b/packages/examples/alaska-flights/.env.example new file mode 100644 index 0000000000..b92c0d5102 --- /dev/null +++ b/packages/examples/alaska-flights/.env.example @@ -0,0 +1,6 @@ +BROWSERBASE_API_KEY= +BROWSERBASE_PROJECT_ID= +OPENAI_API_KEY= +GOOGLE_API_KEY= +DEPARTURE_DATE= +RETURN_DATE= diff --git a/packages/examples/alaska-flights/README.md b/packages/examples/alaska-flights/README.md new file mode 100644 index 0000000000..1f55db1d11 --- /dev/null +++ b/packages/examples/alaska-flights/README.md @@ -0,0 +1,7 @@ +# Alaska Flights + +Location in the Stagehand repository: `packages/examples/alaska-flights`. + +Public flight search; sample route and dates, no passenger profile or booking. + +This is a legacy **Stagehand v2** example. Install dependencies in this directory with `npm install`, supply the environment variables listed in `.env.example`, and run `npm start`. Live site layout and model availability may have changed since the original example. diff --git a/packages/examples/alaska-flights/package.json b/packages/examples/alaska-flights/package.json new file mode 100644 index 0000000000..c222379eb0 --- /dev/null +++ b/packages/examples/alaska-flights/package.json @@ -0,0 +1,20 @@ +{ + "name": "stagehand-example-alaska-flights", + "private": true, + "type": "module", + "scripts": { + "start": "tsx searchAlaskaFlights.ts" + }, + "dependencies": { + "@browserbasehq/stagehand": "^2.2.1", + "boxen": "^8.0.1", + "chalk": "^5.3.0", + "dotenv": "^16.4.7", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/node": "^22.9.1", + "tsx": "^4.19.2", + "typescript": "^5.0.0" + } +} diff --git a/packages/examples/alaska-flights/searchAlaskaFlights.ts b/packages/examples/alaska-flights/searchAlaskaFlights.ts new file mode 100644 index 0000000000..fccc4c066d --- /dev/null +++ b/packages/examples/alaska-flights/searchAlaskaFlights.ts @@ -0,0 +1,86 @@ +import { Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod"; +import chalk from "chalk"; +import dotenv from "dotenv"; +import boxen from "boxen"; + +dotenv.config(); + +async function main() { + // Set the origin and destination + const origin = "SFO"; + const destination = "LAX"; + const departureDate = process.env.DEPARTURE_DATE; + const returnDate = process.env.RETURN_DATE; + if (!departureDate || !returnDate) + throw new Error("Set DEPARTURE_DATE and RETURN_DATE (MM/DD/YYYY)"); + + const stagehand = new Stagehand({ + env: "BROWSERBASE", // Environment to run in: LOCAL or BROWSERBASE + }); + await stagehand.init(); + const page = stagehand.page; + const context = stagehand.context; + + // Log your session recording in the terminal so you can see it + console.log( + boxen( + `View this session recording in your browser: \n${chalk.blue( + `https://browserbase.com/sessions/${stagehand.browserbaseSessionID}`, + )}`, + { + title: "Browserbase", + padding: 1, + margin: 3, + }, + ), + ); + + await stagehand.page.goto("https://www.alaskaair.com/"); + + // Select the origin + await page.act({ action: `type in starting location/origin: ${origin}` }); + await page.act({ action: `select ${origin} from the origin options` }); + + // Select the destination + await page.act({ action: `type in destination: ${destination}` }); + await page.act({ action: `select ${destination} from the destination options` }); + + // Select the departure date + await page.act({ action: `select ${departureDate} from the departure date options` }); + + // Select the return date + await page.act({ action: `select ${returnDate} from the return date options` }); + + // Click the search for flights button + await page.act({ action: "click search for flights" }); + + // get array of flight data + const flightData = await page.extract({ + instruction: + "extract all of the flight data from the page, only include the flight number, departure and arrival times, and the price", + schema: z.object({ + flights: z.array( + z.object({ + flight_number: z.string(), + departure_time: z.string(), + arrival_time: z.string(), + price: z.string(), + }), + ), + }), + }); + + console.log(flightData); + + await stagehand.close(); +} + +(async () => { + await main(); + console.log( + `\n๐Ÿค˜ Thanks for using Stagehand! Create an issue if you have any feedback: ${chalk.blue( + "https://github.com/browserbase/stagehand/issues/new", + )}\n`, + ); +})().catch(console.error); diff --git a/packages/examples/amazon-global-price-comparison/README.md b/packages/examples/amazon-global-price-comparison/README.md new file mode 100644 index 0000000000..29329dc10c --- /dev/null +++ b/packages/examples/amazon-global-price-comparison/README.md @@ -0,0 +1,12 @@ +# amazon-global-price-comparison + +Public product search across storefronts with configurable product input; no customer account data. + +Choose a language and follow its setup instructions: + +| Language | Stagehand dependency | Directory from the repository root | +| ---------------------------------- | -------------------- | ------------------------------------------------------------- | +| [TypeScript](typescript/README.md) | `4.0.0` | `packages/examples/amazon-global-price-comparison/typescript` | +| [Python](python/README.md) | `4.0.0` | `packages/examples/amazon-global-price-comparison/python` | + +Each variant installs the published Stagehand package and keeps its own dependencies. diff --git a/packages/examples/amazon-global-price-comparison/python/.env.example b/packages/examples/amazon-global-price-comparison/python/.env.example new file mode 100644 index 0000000000..e5830254fa --- /dev/null +++ b/packages/examples/amazon-global-price-comparison/python/.env.example @@ -0,0 +1,2 @@ +# Browserbase credentials - get these from https://www.browserbase.com/settings +BROWSERBASE_API_KEY= diff --git a/packages/examples/amazon-global-price-comparison/python/README.md b/packages/examples/amazon-global-price-comparison/python/README.md new file mode 100644 index 0000000000..3672d9544d --- /dev/null +++ b/packages/examples/amazon-global-price-comparison/python/README.md @@ -0,0 +1,79 @@ +# Amazon Global Price Comparison + +Location in the Stagehand repository: `packages/examples/amazon-global-price-comparison/python`. + +Stagehand is the SDK for browser agents. + +## AT A GLANCE + +- **Goal**: Compare Amazon product prices across multiple countries using geolocation proxies. +- **Pattern Template**: Demonstrates the integration of Browserbase geolocation proxies with Stagehand AI extraction for price comparison workflows. +- **Workflow**: Creates Browserbase sessions with geolocation proxies for each country, navigates to Amazon, searches for products, and extracts structured pricing data using Stagehand's AI-powered extraction. +- **Concurrent Processing**: Runs all country searches in parallel using `asyncio.gather()` for faster execution. +- **Structured Extraction**: Uses Pydantic schemas to extract consistent product data (name, price, rating, reviews) across different Amazon regions. +- Docs โ†’ [Browserbase Proxies](https://docs.browserbase.com/features/proxies) | [Stagehand Extract](https://docs.stagehand.dev/v4/basics/extract) + +## GLOSSARY + +- **geolocation proxies**: Route traffic through specific geographic locations (city, country) to access location-specific content and pricing. + Docs โ†’ https://docs.browserbase.com/features/proxies#set-proxy-geolocation +- **extract**: Extract structured data from web pages using natural language instructions and JSON schemas. + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- **act**: Perform UI actions from natural language prompts (click, scroll, type, navigate). + Docs โ†’ https://docs.stagehand.dev/v4/basics/act +- **proxies**: Browserbase's managed proxy infrastructure supporting 201+ countries for geolocation-based routing. + Docs โ†’ https://docs.browserbase.com/features/proxies + +## QUICKSTART + +1. cd packages/examples/amazon-global-price-comparison/python +2. cp .env.example .env +3. Add required API keys to .env: + - `BROWSERBASE_API_KEY` +4. Run the script: + ```bash + uv run python main.py + ``` + +## EXPECTED OUTPUT + +- Creates Browserbase sessions with geolocation proxies for each country (US, UK, DE, FR, IT, ES) +- Navigates to Amazon search results through location-specific proxies +- Extracts product name, price, rating, and review count for each location +- Displays formatted comparison table showing price differences across countries +- Outputs JSON results for programmatic use + +## COMMON PITFALLS + +- **Browserbase Developer plan or higher is required to use proxies** +- "ModuleNotFoundError": ensure you're running with `uv run python main.py` (uv automatically installs dependencies from pyproject.toml) +- Missing credentials: verify .env contains `BROWSERBASE_API_KEY` +- Geolocation fields are case-insensitive (city, country can be any case) +- Amazon may show different products in different regions - comparison works best for globally available products +- ERR_TUNNEL_CONNECTION_FAILED: indicates either a temporary proxy hiccup or a site unsupported by built-in proxies + +## USE CASES + +โ€ข **Price arbitrage**: Find the best country to purchase products from for international shipping +โ€ข **Market research**: Compare pricing strategies across different Amazon regions +โ€ข **Competitive analysis**: Monitor how competitors price products globally +โ€ข **Travel shopping**: Check prices before international trips to plan purchases + +## NEXT STEPS + +โ€ข **Add more countries**: Extend the `COUNTRIES` list with additional regions (Japan, Australia, Canada, etc.) +โ€ข **Currency conversion**: Add real-time currency conversion to normalize prices for comparison +โ€ข **Price tracking**: Store results over time to track price changes across regions +โ€ข **Email alerts**: Send notifications when price drops below a threshold in any country +โ€ข **Product matching**: Use fuzzy matching to ensure you're comparing the same product across regions + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐Ÿ“š Python SDK: https://docs.stagehand.dev/v4/sdk/python +๐Ÿ“š Browserbase Proxies: https://docs.browserbase.com/features/proxies +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/amazon-global-price-comparison/python/main.py b/packages/examples/amazon-global-price-comparison/python/main.py new file mode 100644 index 0000000000..525addad5b --- /dev/null +++ b/packages/examples/amazon-global-price-comparison/python/main.py @@ -0,0 +1,159 @@ +"""Compare live Amazon prices through regional proxies with Stagehand V4.""" + +import asyncio +import json +import os +from dataclasses import asdict, dataclass +from urllib.parse import quote_plus, urljoin + +from dotenv import load_dotenv +from pydantic import BaseModel, Field, HttpUrl +from stagehand import BrowserbaseProxyConfig, Stagehand, browserbase + +load_dotenv() + + +class Product(BaseModel): + name: str + price: str + rating: str + reviews_count: str + product_url: HttpUrl = Field(description="Absolute Amazon product-detail href") + + +class Products(BaseModel): + products: list[Product] + + +@dataclass(frozen=True) +class Country: + name: str + code: str + domain: str + currency: str + city: str | None = None + + +@dataclass +class CountryResult: + country: str + country_code: str + currency: str + products: list[dict] + error: str | None = None + + +COUNTRIES = [ + Country("United States", "US", "www.amazon.com", "USD"), + Country("United Kingdom", "GB", "www.amazon.co.uk", "GBP", "LONDON"), + Country("Germany", "DE", "www.amazon.de", "EUR", "BERLIN"), + Country("France", "FR", "www.amazon.fr", "EUR", "PARIS"), + Country("Italy", "IT", "www.amazon.it", "EUR", "ROME"), + Country("Spain", "ES", "www.amazon.es", "EUR", "MADRID"), +] + + +async def products_for_country( + query: str, + country: Country, + result_count: int, +) -> CountryResult: + api_key = os.environ.get("BROWSERBASE_API_KEY") + if not api_key: + raise RuntimeError("BROWSERBASE_API_KEY is required") + proxy: BrowserbaseProxyConfig = { + "type": "browserbase", + "geolocation": { + "country": country.code, + **({"city": country.city} if country.city else {}), + }, + } + + browser = await browserbase.launch(api_key=api_key, proxies=[proxy]) + try: + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + origin = f"https://{country.domain}" + await page.goto(origin, wait_until="domcontentloaded", timeout=60_000) + semantic_search_succeeded = False + try: + typed = await stagehand.act(f'Type "{query}" into the search bar', page=page) + submitted = await stagehand.act("Click the search button", page=page) + semantic_search_succeeded = typed.data.success and submitted.data.success + except Exception as error: + print( + f"[{country.name}] Semantic search failed; " + f"checking results before fallback: {error}" + ) + page = await browser.context.active_page() or page + results_ready = None + if semantic_search_succeeded: + try: + results_ready = await page.wait_for_selector( + '[data-component-type="s-search-result"]', + timeout=10_000, + ) + except Exception: + results_ready = None + if not results_ready: + search_url = f"{origin}/s?k={quote_plus(query)}" + await page.goto(search_url, wait_until="domcontentloaded", timeout=60_000) + await page.wait_for_selector( + '[data-component-type="s-search-result"]', + timeout=15_000, + ) + extracted = await stagehand.extract( + ( + f"Extract the first {result_count} product search results. For each product, " + "return the full title, displayed price with currency symbol or N/A, star " + "rating, review count, and absolute product-page href. Each URL must be a " + "real Amazon link containing /dp/. " + "Only include actual listings." + ), + Products, + page=page, + ) + products = [ + { + **product.model_dump(mode="json"), + "product_url": urljoin(origin, str(product.product_url)), + } + for product in extracted.data.products[:result_count] + ] + return CountryResult( + country=country.name, + country_code=country.code, + currency=country.currency, + products=products, + ) + finally: + await stagehand.close() + except Exception as error: + return CountryResult( + country=country.name, + country_code=country.code, + currency=country.currency, + products=[], + error=str(error), + ) + finally: + await browser.close() + + +async def main() -> None: + query = "iPhone 15 Pro Max 256GB" + result_count = 3 + country_limit = int(os.environ.get("MAX_COUNTRIES", str(len(COUNTRIES)))) + selected = COUNTRIES[:country_limit] + results = await asyncio.gather( + *(products_for_country(query, country, result_count) for country in selected) + ) + print(json.dumps([asdict(result) for result in results], indent=2)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/packages/examples/amazon-global-price-comparison/python/pyproject.toml b/packages/examples/amazon-global-price-comparison/python/pyproject.toml new file mode 100644 index 0000000000..3774e788a8 --- /dev/null +++ b/packages/examples/amazon-global-price-comparison/python/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "amazon-global-price-comparison" +version = "0.1.0" +description = "Compare Amazon product prices across multiple countries using geolocation proxies" +readme = "README.md" +requires-python = ">=3.11,<3.14" +dependencies = ["browserbase>=1.7.0", "python-dotenv", "pydantic>=2.0.0", "stagehand==4.0.0"] + +[project.optional-dependencies] +dev = ["pytest>=7.0.0", "black>=23.0.0", "ruff>=0.1.0"] + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 100 +target-version = ['py39', 'py310', 'py311'] + +[tool.ruff] +line-length = 100 +target-version = "py39" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] diff --git a/packages/examples/amazon-global-price-comparison/typescript/.env.example b/packages/examples/amazon-global-price-comparison/typescript/.env.example new file mode 100644 index 0000000000..e5830254fa --- /dev/null +++ b/packages/examples/amazon-global-price-comparison/typescript/.env.example @@ -0,0 +1,2 @@ +# Browserbase credentials - get these from https://www.browserbase.com/settings +BROWSERBASE_API_KEY= diff --git a/packages/examples/amazon-global-price-comparison/typescript/README.md b/packages/examples/amazon-global-price-comparison/typescript/README.md new file mode 100644 index 0000000000..adedaed92d --- /dev/null +++ b/packages/examples/amazon-global-price-comparison/typescript/README.md @@ -0,0 +1,68 @@ +# Amazon Global Price Comparison + +Location in the Stagehand repository: `packages/examples/amazon-global-price-comparison/typescript`. + +## AT A GLANCE + +- Goal: compare Amazon product prices across multiple countries using geolocation proxies. +- Uses Browserbase's managed proxy infrastructure to route traffic through different geographic locations (US, UK, Germany, France, Italy, Spain). +- Opens each matching regional Amazon storefront, searches with `act()`, and extracts validated product records with `extract()` and Zod. +- Sequential processing shows how different proxy locations return different pricing from the same Amazon search. +- Docs โ†’ https://docs.browserbase.com/features/proxies + +## GLOSSARY + +- geolocation proxies: route traffic through specific geographic locations (city, country) to access location-specific content and pricing + Docs โ†’ https://docs.browserbase.com/features/proxies#set-proxy-geolocation +- act / extract: interact semantically and return schema-validated product records + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- proxies: Browserbase's managed proxy infrastructure supporting 201+ countries for geolocation-based routing + Docs โ†’ https://docs.browserbase.com/features/proxies + +## QUICKSTART + +1. cd packages/examples/amazon-global-price-comparison/typescript +2. pnpm install +3. cp .env.example .env +4. Add your Browserbase API key to .env +5. pnpm start + +## EXPECTED OUTPUT + +- Creates Browserbase sessions with geolocation proxies for each country (US, UK, DE, FR, IT, ES) +- Navigates to Amazon search results through location-specific proxies +- Returns structured product records with regional URLs for each location +- Displays formatted comparison table showing price differences across countries +- Outputs JSON results for programmatic use + +## COMMON PITFALLS + +- Browserbase Developer plan or higher is required to use proxies +- "Cannot find module": ensure all dependencies are installed (`pnpm install`) +- Missing credentials: verify .env contains BROWSERBASE_API_KEY +- Geolocation fields are case-insensitive (city, country can be any case) +- Amazon may show different products in different regions - comparison works best for globally available products +- ERR_TUNNEL_CONNECTION_FAILED: indicates either a temporary proxy hiccup or a site unsupported by built-in proxies + +## USE CASES + +โ€ข Price arbitrage: Find the best country to purchase products from for international shipping +โ€ข Market research: Compare pricing strategies across different Amazon regions +โ€ข Competitive analysis: Monitor how competitors price products globally +โ€ข Travel shopping: Check prices before international trips to plan purchases + +## NEXT STEPS + +โ€ข Add more countries: Extend the COUNTRIES array with additional regions (Japan, Australia, Canada, etc.) +โ€ข Currency conversion: Add real-time currency conversion to normalize prices for comparison +โ€ข Price tracking: Store results over time to track price changes across regions +โ€ข Email alerts: Send notifications when price drops below a threshold in any country + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/amazon-global-price-comparison/typescript/index.ts b/packages/examples/amazon-global-price-comparison/typescript/index.ts new file mode 100644 index 0000000000..82a9ffd344 --- /dev/null +++ b/packages/examples/amazon-global-price-comparison/typescript/index.ts @@ -0,0 +1,287 @@ +// Amazon Global Price Comparison - See README.md for full documentation + +import "dotenv/config"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod/v4"; + +// Schema for a single product with structured extraction fields +const ProductSchema = z.object({ + name: z.string().describe("The full product title/name"), + price: z + .string() + .describe( + "The product price including currency symbol (e.g., '$29.99', '29,99 EUR', '29.99 GBP'). If no price is visible, return 'N/A'", + ), + rating: z.string().describe("The star rating (e.g., '4.5 out of 5 stars')"), + reviews_count: z.string().describe("The number of customer reviews (e.g., '1,234')"), + product_url: z.string().url().describe("The absolute href URL of the product detail page"), +}); + +// Schema for extracting multiple products from search results +const ProductsSchema = z.object({ + products: z.array(ProductSchema).describe("Array of products from search results"), +}); + +type Product = z.infer; + +// Country configuration with geolocation proxy settings +// Each country routes traffic through its geographic location to see local pricing +interface CountryConfig { + name: string; + code: string; + domain: string; + city?: string; + currency: string; +} + +// Supported countries for price comparison +// Add or remove countries as needed - see https://docs.browserbase.com/features/proxies for available geolocations +const COUNTRIES: CountryConfig[] = [ + { name: "United States", code: "US", domain: "www.amazon.com", currency: "USD" }, + { + name: "United Kingdom", + code: "GB", + domain: "www.amazon.co.uk", + city: "LONDON", + currency: "GBP", + }, + { name: "Germany", code: "DE", domain: "www.amazon.de", city: "BERLIN", currency: "EUR" }, + { name: "France", code: "FR", domain: "www.amazon.fr", city: "PARIS", currency: "EUR" }, + { name: "Italy", code: "IT", domain: "www.amazon.it", city: "ROME", currency: "EUR" }, + { name: "Spain", code: "ES", domain: "www.amazon.es", city: "MADRID", currency: "EUR" }, +]; + +// Results structure for each country +interface CountryResult { + country: string; + countryCode: string; + currency: string; + products: Product[]; + error?: string; +} + +async function closeSession( + stagehand: Stagehand, + browser: Awaited>, +) { + await stagehand.close().catch((error) => console.warn("Stagehand cleanup warning:", error)); + await browser.close().catch((error) => console.warn("Browser cleanup warning:", error)); +} + +/** + * Fetches products from Amazon for a specific country using geolocation proxy + * Uses Browserbase's managed proxy infrastructure to route traffic through the target country + * This ensures Amazon shows location-specific pricing and availability + */ +async function getProductsForCountry( + searchQuery: string, + country: CountryConfig, + resultsCount: number = 3, +): Promise { + console.log(`\n=== Searching Amazon for "${searchQuery}" in ${country.name} ===`); + + // Build geolocation config for proxy routing + const geolocation: { country: string; city?: string } = { + country: country.code, + }; + if (country.city) { + geolocation.city = country.city; + } + + // Initialize Stagehand with geolocation proxy configuration + // This ensures all browser traffic routes through the specified geographic location + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + proxies: [ + { + type: "browserbase", // Use Browserbase's managed proxy infrastructure for reliable geolocation routing + geolocation, + }, + ], + }); + const stagehand = await Stagehand.create({ browser: browser, logging: { level: "error" } }); + + try { + console.log(`Initializing browser session with ${country.name} proxy...`); + + let page = (await browser.context.pages())[0]; + + // Alternative: Skip the search bar and go straight to results by building the search URL. + // Uncomment below to use direct navigation instead of stagehand.act() typing + clicking. + // const searchUrl = `https://www.amazon.com/s?k=${encodeURIComponent(searchQuery)}`; + // console.log(`Navigating to: ${searchUrl}`); + // await page.goto(searchUrl, { waitUntil: "domcontentloaded", timeout: 60000 }); + + // Use the matching regional storefront, then let Stagehand perform the + // semantic search interaction rather than coupling the template to the DOM. + const origin = `https://${country.domain}`; + console.log(`[${country.name}] Navigating to ${origin}...`); + await page.goto(origin, { + waitUntil: "domcontentloaded", + timeout: 60000, + }); + let semanticSearchSucceeded = false; + try { + const typed = await stagehand.act(`Type "${searchQuery}" into the search bar`); + const submitted = await stagehand.act("Click the search button"); + semanticSearchSucceeded = typed.data.success && submitted.data.success; + } catch (error) { + console.warn( + `[${country.name}] Semantic search failed; checking results before fallback`, + error, + ); + } + page = (await browser.context.activePage()) ?? page; + const resultsReady = semanticSearchSucceeded + ? await page + .waitForSelector('[data-component-type="s-search-result"]', { timeout: 10000 }) + .catch(() => false) + : false; + if (!resultsReady) { + const searchUrl = `${origin}/s?k=${encodeURIComponent(searchQuery)}`; + await page.goto(searchUrl, { waitUntil: "domcontentloaded", timeout: 60000 }); + await page.waitForSelector('[data-component-type="s-search-result"]', { timeout: 15000 }); + } + + console.log(`[${country.name}] Extracting top ${resultsCount} products...`); + const { data: extractionResult } = await stagehand.extract( + `Extract the first ${resultsCount} product search results from this Amazon page. For each product, extract the full title, displayed price with currency symbol (or "N/A"), star rating, review count, and absolute product-page href. Each URL must be a real Amazon link containing /dp/. Only extract actual product listings.`, + ProductsSchema, + ); + + // Clean up products - ensure price is never null and URLs are absolute + const cleanedProducts = extractionResult.products.map((p) => ({ + ...p, + price: p.price || "N/A", + product_url: p.product_url?.startsWith("http") + ? p.product_url + : p.product_url?.startsWith("/") + ? `${origin}${p.product_url}` + : p.product_url || "N/A", + })); + + console.log(`Found ${cleanedProducts.length} products in ${country.name}`); + + await closeSession(stagehand, browser); + + return { + country: country.name, + countryCode: country.code, + currency: country.currency, + products: cleanedProducts.slice(0, resultsCount), + }; + } catch (error) { + console.error(`Error fetching products from ${country.name}:`, error); + await closeSession(stagehand, browser); + + return { + country: country.name, + countryCode: country.code, + currency: country.currency, + products: [], + error: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * Displays results in a formatted comparison table + * Shows product name, price, rating, and review count for each country + */ +function displayComparisonTable(results: CountryResult[]) { + console.log("\n" + "=".repeat(100)); + console.log("PRICE COMPARISON ACROSS COUNTRIES"); + console.log("=".repeat(100)); + + // Find the first successful result to get product count + const successfulResult = results.find((r) => r.products.length > 0); + if (!successfulResult) { + console.log("No products found in any country."); + return; + } + + // Display results for each product position + const maxProducts = Math.max(...results.map((r) => r.products.length)); + + for (let i = 0; i < maxProducts; i++) { + console.log(`\n--- Product ${i + 1} ---`); + + // Find the first available product name for this position + const productName = results.find((r) => r.products[i])?.products[i]?.name; + if (productName) { + const truncatedName = + productName.length > 80 ? productName.slice(0, 77) + "..." : productName; + console.log(`Product: ${truncatedName}`); + } + + console.log("\nPrices by Country:"); + console.log("-".repeat(70)); + + for (const result of results) { + const countryPad = result.country.padEnd(20); + if (result.error) { + console.log(` ${countryPad} | Error: ${result.error}`); + } else if (result.products[i]) { + const product = result.products[i]; + const price = product.price || "N/A"; + const pricePad = price.padEnd(18); + const ratingShort = product.rating?.split(" out")[0] || "N/A"; + const ratingPad = ratingShort.padEnd(6); + const reviews = product.reviews_count || "N/A"; + console.log(` ${countryPad} | ${pricePad} | ${ratingPad} stars | ${reviews} reviews`); + } else { + console.log(` ${countryPad} | Not available in this country`); + } + } + } + + console.log("\n" + "=".repeat(100)); +} + +async function main() { + // Configure search parameters + const searchQuery = "iPhone 15 Pro Max 256GB"; + const resultsCount = 3; + const countryLimit = Number.parseInt(process.env.MAX_COUNTRIES ?? String(COUNTRIES.length), 10); + if (!Number.isInteger(countryLimit) || countryLimit < 1) { + throw new Error("MAX_COUNTRIES must be a positive integer"); + } + const selectedCountries = COUNTRIES.slice(0, countryLimit); + + console.log("=".repeat(60)); + console.log("AMAZON PRICE COMPARISON - GEOLOCATION PROXY DEMO"); + console.log("=".repeat(60)); + console.log(`Search Query: ${searchQuery}`); + console.log(`Results per country: ${resultsCount}`); + console.log(`Countries: ${selectedCountries.map((c) => c.code).join(", ")}`); + console.log("=".repeat(60)); + + // Process all countries concurrently for faster execution + // Each country uses its own browser session, so they can run in parallel + console.log(`\nFetching prices from ${selectedCountries.length} countries concurrently...`); + + const results = await Promise.all( + selectedCountries.map((country) => getProductsForCountry(searchQuery, country, resultsCount)), + ); + + // Display formatted comparison table + displayComparisonTable(results); + + // Output JSON results for programmatic use + console.log("\n--- JSON OUTPUT ---"); + console.log(JSON.stringify(results, null, 2)); + + console.log("\n=== Price comparison completed ==="); +} + +main().catch((err) => { + console.error("Application error:", err); + console.error("\nCommon issues:"); + console.error(" - Check .env file has BROWSERBASE_API_KEY"); + console.error( + " - Verify geolocation proxy locations are valid (see https://docs.browserbase.com/features/proxies)", + ); + console.error(" - Ensure you have sufficient Browserbase credits"); + console.error("Docs: https://docs.stagehand.dev/v4/first-steps/introduction"); + process.exit(1); +}); diff --git a/packages/examples/amazon-global-price-comparison/typescript/package.json b/packages/examples/amazon-global-price-comparison/typescript/package.json new file mode 100644 index 0000000000..c60418e764 --- /dev/null +++ b/packages/examples/amazon-global-price-comparison/typescript/package.json @@ -0,0 +1,22 @@ +{ + "name": "amazon-global-price-comparison-template", + "type": "module", + "scripts": { + "build": "tsc --noEmit --skipLibCheck --target ES2022 --module NodeNext --moduleResolution NodeNext index.ts", + "start": "tsx index.ts" + }, + "dependencies": { + "@browserbasehq/stagehand": "4.0.0", + "dotenv": "^16.4.7", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^22.18.0", + "tsx": "^4.19.2", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=22.18.0" + }, + "packageManager": "pnpm@10.24.0" +} diff --git a/packages/examples/amazon-product-scraping/README.md b/packages/examples/amazon-product-scraping/README.md new file mode 100644 index 0000000000..41423587cb --- /dev/null +++ b/packages/examples/amazon-product-scraping/README.md @@ -0,0 +1,12 @@ +# amazon-product-scraping + +Structured extraction from public product search results. + +Choose a language and follow its setup instructions: + +| Language | Stagehand dependency | Directory from the repository root | +| ---------------------------------- | -------------------- | ------------------------------------------------------ | +| [TypeScript](typescript/README.md) | `4.0.0` | `packages/examples/amazon-product-scraping/typescript` | +| [Python](python/README.md) | `4.0.0` | `packages/examples/amazon-product-scraping/python` | + +Each variant installs the published Stagehand package and keeps its own dependencies. diff --git a/packages/examples/amazon-product-scraping/python/.env.example b/packages/examples/amazon-product-scraping/python/.env.example new file mode 100644 index 0000000000..c27192ccea --- /dev/null +++ b/packages/examples/amazon-product-scraping/python/.env.example @@ -0,0 +1,3 @@ +# Browserbase credentials (required) +# Get these from https://www.browserbase.com/settings +BROWSERBASE_API_KEY= diff --git a/packages/examples/amazon-product-scraping/python/README.md b/packages/examples/amazon-product-scraping/python/README.md new file mode 100644 index 0000000000..26e150ac01 --- /dev/null +++ b/packages/examples/amazon-product-scraping/python/README.md @@ -0,0 +1,69 @@ +# Stagehand + Browserbase: Amazon Product Scraping + +Location in the Stagehand repository: `packages/examples/amazon-product-scraping/python`. + +Stagehand is the SDK for browser agents. + +## AT A GLANCE + +- **Goal**: Scrape the first 3 Amazon search results for a given query and return structured product data. +- **AI-Powered Search**: Uses Stagehand `act` to type in the search bar and click search (or optionally navigate directly to the search URL). +- **Structured Extraction**: Uses `extract` with a JSON schema to get product name, price, rating, review count, and product URL. +- **Model**: Uses `google/gemini-2.5-flash` for fast, cost-effective automation. +- Docs โ†’ https://docs.stagehand.dev + +## GLOSSARY + +- **act**: Perform UI actions from a prompt (type in search bar, click search). + Docs โ†’ https://docs.stagehand.dev/v4/basics/act +- **extract**: Pull structured data from pages using JSON schemas. + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract + +## QUICKSTART + +1. cd packages/examples/amazon-product-scraping/python +2. cp .env.example .env (or create .env with required keys) +3. Add `BROWSERBASE_API_KEY` to .env +4. Optionally edit `SEARCH_QUERY` in main.py +5. Run the script: + ```bash + uv run python main.py + ``` + +## EXPECTED OUTPUT + +- Initializes Stagehand session with Browserbase +- Displays live session link for monitoring +- Navigates to Amazon and performs search (or direct URL navigation if uncommented) +- Extracts the first 3 products with name, price, rating, reviews count, and product URL +- Outputs JSON to console +- Closes session cleanly + +## COMMON PITFALLS + +- **Import errors**: Ensure you're running with `uv run python main.py` so dependencies are installed +- **Missing credentials**: Verify .env contains `BROWSERBASE_API_KEY` +- **Amazon layout changes**: Extraction may need prompt/schema updates if Amazon changes their search results UI +- Find more information on your Browserbase dashboard โ†’ https://www.browserbase.com/sign-in + +## USE CASES + +โ€ข **Price monitoring**: Scrape top results for a product query to track prices and availability over time. +โ€ข **Competitor research**: Extract product titles, ratings, and review counts for comparison. +โ€ข **Catalog building**: Pull structured product data for feeds, dashboards, or internal tools. + +## NEXT STEPS + +โ€ข **Switch to direct URL**: Uncomment the URL-based search block in main.py for faster runs without LLM search actions. +โ€ข **Parameterize query**: Accept `SEARCH_QUERY` from CLI or env for different products without editing code. +โ€ข **Paginate**: Extend extraction to multiple pages or increase the number of products per run. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev +๐Ÿ“š Python SDK: https://docs.stagehand.dev/v4/sdk/python +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/amazon-product-scraping/python/main.py b/packages/examples/amazon-product-scraping/python/main.py new file mode 100644 index 0000000000..06a793981b --- /dev/null +++ b/packages/examples/amazon-product-scraping/python/main.py @@ -0,0 +1,106 @@ +"""Scrape Amazon search results with Stagehand V4.""" + +import asyncio +import json +import os +from urllib.parse import quote_plus, urljoin + +from dotenv import load_dotenv +from pydantic import BaseModel, Field, HttpUrl +from stagehand import Stagehand, browserbase + +load_dotenv() + +SEARCH_QUERY = "Seiko 5" + + +class Product(BaseModel): + name: str + price: str + rating: str + reviews_count: str + product_url: HttpUrl = Field(description="Absolute Amazon product-detail href") + + +class Products(BaseModel): + products: list[Product] = Field(description="First three Amazon search results") + + +async def main() -> None: + api_key = os.environ.get("BROWSERBASE_API_KEY") + if not api_key: + raise RuntimeError("BROWSERBASE_API_KEY is required") + + browser = await browserbase.launch(api_key=api_key) + try: + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + await page.goto( + "https://www.amazon.com", + wait_until="domcontentloaded", + timeout=60_000, + ) + typed = await stagehand.act( + f'Type "{SEARCH_QUERY}" into the search bar', + page=page, + ) + submitted = await stagehand.act("Click the search button", page=page) + if not typed.data.success or not submitted.data.success: + raise RuntimeError( + typed.data.message or submitted.data.message or "Amazon search failed" + ) + page = await browser.context.active_page() or page + try: + results_ready = await page.wait_for_selector( + '[data-component-type="s-search-result"]', + timeout=10_000, + ) + except Exception: + results_ready = None + if not results_ready: + # Amazon can replace the document during submit and invalidate + # the action frame. Use the direct URL only after that failure. + search_url = f"https://www.amazon.com/s?k={quote_plus(SEARCH_QUERY)}" + await page.goto(search_url, wait_until="domcontentloaded", timeout=60_000) + await page.wait_for_selector( + '[data-component-type="s-search-result"]', + timeout=15_000, + ) + extracted = await stagehand.extract( + ( + "Extract the details of the FIRST 3 products in the search results. " + "Return each product's full name, displayed price, star rating, review " + "count, and absolute product-page href. Each URL must be a real Amazon " + "link containing /dp/." + ), + Products, + page=page, + ) + products = extracted.data.products + normalized = [ + { + **product.model_dump(mode="json"), + "product_url": urljoin("https://www.amazon.com", str(product.product_url)), + } + for product in products + ] + + print(json.dumps({"products": normalized}, indent=2)) + finally: + await stagehand.close() + finally: + await browser.close() + print("Session closed successfully") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as error: + print(f"Amazon product scraping failed: {error}") + print("Docs: https://docs.stagehand.dev/v4/first-steps/introduction") + raise diff --git a/packages/examples/amazon-product-scraping/python/pyproject.toml b/packages/examples/amazon-product-scraping/python/pyproject.toml new file mode 100644 index 0000000000..35fcf19787 --- /dev/null +++ b/packages/examples/amazon-product-scraping/python/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "amazon-product-scraping" +version = "0.1.0" +description = "Scrape Amazon product search results using Stagehand and Browserbase" +readme = "README.md" +requires-python = ">=3.11,<3.14" +dependencies = [ + "beautifulsoup4==4.14.3", + "pydantic>=2.12,<3", + "python-dotenv==1.2.2", + "stagehand==4.0.0", +] + +[project.optional-dependencies] +dev = ["pytest>=7.0.0", "black>=23.0.0", "ruff>=0.1.0"] + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 100 +target-version = ['py311'] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] diff --git a/packages/examples/amazon-product-scraping/typescript/.env.example b/packages/examples/amazon-product-scraping/typescript/.env.example new file mode 100644 index 0000000000..683b0a737f --- /dev/null +++ b/packages/examples/amazon-product-scraping/typescript/.env.example @@ -0,0 +1,2 @@ +# Browserbase Configuration +BROWSERBASE_API_KEY= diff --git a/packages/examples/amazon-product-scraping/typescript/README.md b/packages/examples/amazon-product-scraping/typescript/README.md new file mode 100644 index 0000000000..33d76a35a7 --- /dev/null +++ b/packages/examples/amazon-product-scraping/typescript/README.md @@ -0,0 +1,62 @@ +# Stagehand + Browserbase: Amazon Product Scraping + +Location in the Stagehand repository: `packages/examples/amazon-product-scraping/typescript`. + +## AT A GLANCE + +- Goal: scrape the first 3 Amazon search results for a given query and return structured product data. +- Semantic Search: uses `act()` to find and operate Amazon's current search UI. +- Structured Results: uses `extract()` with Zod to return and validate product name, price, rating, review count, and URL. +- Model: uses `google/gemini-2.5-flash` for fast, cost-effective automation. + Docs โ†’ https://docs.stagehand.dev + +## GLOSSARY + +- act / extract: use natural-language interaction and schema-validated semantic extraction + Docs โ†’ https://docs.stagehand.dev/v4/basics/act + +## QUICKSTART + +1. cd packages/examples/amazon-product-scraping/typescript +2. npm install +3. cp .env.example .env (or create .env with required keys) +4. Add BROWSERBASE_API_KEY to .env +5. Optionally edit SEARCH_QUERY in index.ts +6. npm start + +## EXPECTED OUTPUT + +- Initializes Stagehand session with Browserbase +- Uses `act()` to run the configured search through Amazon's live UI +- Returns structured product-detail records with Zod +- Extracts the first 3 products with name, price, rating, reviews count, and product URL +- Outputs JSON to console +- Closes session cleanly + +## COMMON PITFALLS + +- "Cannot find module": ensure npm install completed +- Missing credentials: verify .env contains BROWSERBASE_API_KEY +- Amazon access challenges: retry if Amazon presents a CAPTCHA or consent interstitial +- Find more information on your Browserbase dashboard โ†’ https://www.browserbase.com/sign-in + +## USE CASES + +โ€ข Price monitoring: Scrape top results for a product query to track prices and availability over time. +โ€ข Competitor research: Extract product titles, ratings, and review counts for comparison. +โ€ข Catalog building: Pull structured product data for feeds, dashboards, or internal tools. + +## NEXT STEPS + +โ€ข Parameterize storefront: Accept an Amazon domain or country from CLI/env. +โ€ข Parameterize query: Accept SEARCH_QUERY from CLI or env for different products without editing code. +โ€ข Paginate: Extend extraction to multiple pages or increase the number of products per run. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/amazon-product-scraping/typescript/index.ts b/packages/examples/amazon-product-scraping/typescript/index.ts new file mode 100644 index 0000000000..13a65853d8 --- /dev/null +++ b/packages/examples/amazon-product-scraping/typescript/index.ts @@ -0,0 +1,112 @@ +// Stagehand + Browserbase: Amazon Product Scraping - See README.md for full documentation + +import "dotenv/config"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod/v4"; + +// ============= CONFIGURATION ============= +// Update this value to search for different products +const SEARCH_QUERY = "Seiko 5"; +// ========================================= + +// Schema for a single product with structured extraction fields +const ProductSchema = z.object({ + name: z.string().describe("The full product title/name"), + price: z.string().describe("The product price including currency symbol (e.g., '$29.99')"), + rating: z.string().describe("The star rating (e.g., '4.5 out of 5 stars')"), + reviews_count: z.string().describe("The number of customer reviews (e.g., '1,234')"), + product_url: z.string().url().describe("The absolute href of the Amazon product detail page"), +}); + +// Schema for extracting multiple products from search results +const ProductsSchema = z.object({ + products: z.array(ProductSchema).describe("Array of the first 3 products from search results"), +}); + +async function main(): Promise { + console.log("Starting Amazon Product Scraping..."); + + // Initialize Stagehand with Browserbase for cloud-based browser automation. + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + }); + const stagehand = await Stagehand.create({ + browser: browser, + model: { modelName: "google/gemini-2.5-flash" }, + logging: { level: "info" }, + }); + + try { + // Initialize browser session to start automation. + + console.log("Stagehand initialized successfully!"); + let page = (await browser.context.pages())[0]; + + // Alternative: skip the search bar and go straight to results by building the search URL. + // Uncomment below to use direct navigation instead of stagehand.act() typing + clicking. + // // Build search URL + // const encodedQuery = encodeURIComponent(query).replace(/%20/g, "+"); + // const searchUrl = `https://www.amazon.com/s?k=${encodedQuery}`; + + // console.log(`Navigating to: ${searchUrl}`); + // await page.goto(searchUrl, { + // waitUntil: "domcontentloaded", + // }); + + // Navigate to Amazon and use Stagehand's semantic browser primitives for + // the search workflow so the template remains resilient to UI changes. + console.log("Navigating to Amazon..."); + await page.goto("https://www.amazon.com", { + waitUntil: "domcontentloaded", + timeout: 60000, + }); + console.log(`Searching for: ${SEARCH_QUERY}`); + const typed = await stagehand.act(`Type "${SEARCH_QUERY}" into the search bar`); + const submitted = await stagehand.act("Click the search button"); + if (!typed.data.success || !submitted.data.success) { + throw new Error(typed.data.message || submitted.data.message || "Amazon search failed"); + } + page = (await browser.context.activePage()) ?? page; + const resultsReady = await page + .waitForSelector('[data-component-type="s-search-result"]', { timeout: 10000 }) + .catch(() => false); + if (!resultsReady) { + // Amazon occasionally replaces the document during the semantic submit, + // invalidating the result frame. Fall back only after the readiness check + // proves the act-driven navigation did not produce a results page. + const searchUrl = `https://www.amazon.com/s?k=${encodeURIComponent(SEARCH_QUERY)}`; + await page.goto(searchUrl, { waitUntil: "domcontentloaded", timeout: 60000 }); + await page.waitForSelector('[data-component-type="s-search-result"]', { timeout: 15000 }); + } + + console.log("Extracting product data..."); + const { data: products } = await stagehand.extract( + "Extract the details of the FIRST 3 products in the search results. Get the product name, price, star rating, number of reviews, and the absolute href of the product page. The product URL must be a real Amazon link containing /dp/.", + ProductsSchema, + ); + + const normalizedProducts = products.products.map((product) => ({ + ...product, + product_url: new URL(product.product_url, "https://www.amazon.com").href, + })); + console.log("Products found:"); + console.log(JSON.stringify({ products: normalizedProducts }, null, 2)); + } catch (error) { + console.error("Error during product scraping:", error); + throw error; + } finally { + // Always close session to release resources and clean up. + await stagehand.close().catch((error) => console.warn("Stagehand cleanup warning:", error)); + await browser.close().catch((error) => console.warn("Browser cleanup warning:", error)); + console.log("Session closed successfully"); + } +} + +main().catch((err) => { + console.error("Error in Amazon product scraping:", err); + console.error("Common issues:"); + console.error(" - Check .env file has BROWSERBASE_API_KEY"); + console.error(" - Verify network connectivity"); + console.error("Docs: https://docs.stagehand.dev"); + process.exit(1); +}); diff --git a/packages/examples/amazon-product-scraping/typescript/package.json b/packages/examples/amazon-product-scraping/typescript/package.json new file mode 100644 index 0000000000..f251ccfb07 --- /dev/null +++ b/packages/examples/amazon-product-scraping/typescript/package.json @@ -0,0 +1,25 @@ +{ + "name": "amazon-product-scraping", + "version": "1.0.0", + "description": "Stagehand + Browserbase: Amazon Product Scraping", + "type": "module", + "main": "index.ts", + "scripts": { + "start": "tsx index.ts", + "dev": "tsx watch index.ts" + }, + "dependencies": { + "@browserbasehq/stagehand": "4.0.0", + "dotenv": "^16.4.5", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "tsx": "^4.16.0", + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=22.18.0" + }, + "packageManager": "pnpm@10.24.0" +} diff --git a/packages/examples/basic-caching/README.md b/packages/examples/basic-caching/README.md new file mode 100644 index 0000000000..0312f3d34a --- /dev/null +++ b/packages/examples/basic-caching/README.md @@ -0,0 +1,12 @@ +# basic-caching + +Observe/cache example on the reserved example.com domain. + +Choose a language and follow its setup instructions: + +| Language | Stagehand dependency | Directory from the repository root | +| ---------------------------------- | -------------------- | -------------------------------------------- | +| [TypeScript](typescript/README.md) | `4.0.2` | `packages/examples/basic-caching/typescript` | +| [Python](python/README.md) | `4.0.0` | `packages/examples/basic-caching/python` | + +Each variant installs the published Stagehand package and keeps its own dependencies. diff --git a/packages/examples/basic-caching/python/.env.example b/packages/examples/basic-caching/python/.env.example new file mode 100644 index 0000000000..d0cde0427d --- /dev/null +++ b/packages/examples/basic-caching/python/.env.example @@ -0,0 +1 @@ +BROWSERBASE_API_KEY= diff --git a/packages/examples/basic-caching/python/README.md b/packages/examples/basic-caching/python/README.md new file mode 100644 index 0000000000..8e670e9c42 --- /dev/null +++ b/packages/examples/basic-caching/python/README.md @@ -0,0 +1,130 @@ +# Stagehand + Browserbase: Basic Caching + +Location in the Stagehand repository: `packages/examples/basic-caching/python`. + +Stagehand is the SDK for browser agents. + +## AT A GLANCE + +- Goal: Demonstrate how Stagehand's caching feature dramatically reduces cost and latency by reusing previously computed actions instead of calling the LLM every time. +- Shows side-by-side comparison of workflows with and without caching enabled. +- Demonstrates massive cost savings for repeated workflows (99.9% reduction in LLM calls). +- Docs โ†’ https://docs.stagehand.dev/v4/best-practices/caching#caching-actions + +## GLOSSARY + +- caching: Stagehand can cache action results based on instruction text and page context, eliminating redundant LLM calls + Docs โ†’ https://docs.stagehand.dev/v4/best-practices/caching#caching-actions +- act: execute actions on web pages using natural language instructions + Docs โ†’ https://docs.stagehand.dev/v4/basics/act +- observe: observe page elements and generate actions that can be cached and reused + Docs โ†’ https://docs.stagehand.dev/v4/basics/observe + +## QUICKSTART + +1. uv venv venv +2. source venv/bin/activate # On Windows: venv\Scripts\activate +3. uvx install stagehand python-dotenv aiofiles +4. cp .env.example .env +5. Add your Browserbase API key to .env +6. python main.py (run twice to see cache benefits!) + +## EXPECTED OUTPUT + +- First run: Executes workflow without cache, then with cache enabled (populates cache) +- Subsequent runs: Uses cached actions for instant execution with zero LLM calls +- Displays timing comparison, cost savings, and cache statistics +- Shows cache location and file structure + +## HOW CACHING WORKS + +**Cache Key Generation:** + +- Based on instruction text (used as cache key) +- Actions are observed once and cached +- Automatically computed + +**When Cache is Used:** + +- โœ… Same instruction text +- โœ… Cache file exists (`cache.json`) +- โŒ Different instruction text +- โŒ Cache file cleared or missing + +**Cache Storage:** + +- Location: `cache.json` in the same directory as `main.py` +- Format: JSON file containing cached actions +- Persistent across runs + +## BENEFITS FOR REPEATED WORKFLOWS + +**Example Scenario: 1,000 customers ร— 10 portals = 10,000 payment flows** + +**Without caching:** + +- 10,000 workflows ร— 5 actions = 50,000 LLM calls +- Cost: ~$500-2,500 +- Latency: 2-3s per action ร— 5 = 10-15s per payment + +**With caching:** + +- First payment per portal: 5 LLM calls (populate cache) +- Next 999 payments: 0 LLM calls (use cache) +- Total: 10 portals ร— 5 actions = 50 LLM calls +- Cost: ~$0.50-2.50 (99.9% savings!) +- Latency: <100ms per action ร— 5 = <0.5s per payment + +**Key Insight:** +Payment portals rarely change โ†’ Cache actions once โ†’ Reuse for thousands of payments โ†’ Massive cost + latency reduction + +## COMMON PITFALLS + +- Missing credentials: verify .env contains BROWSERBASE_API_KEY +- Cache not working: ensure cache.json is writable and check that instruction text matches exactly +- First run slower: expected behavior - cache is populated on first run, subsequent runs will be instant +- ModuleNotFoundError: ensure virtual environment is activated and dependencies are installed via `uvx install` +- Import errors: activate your virtual environment if you created one +- Find more information on your Browserbase dashboard -> https://www.browserbase.com/sign-in + +## USE CASES + +โ€ข Payment processing: Cache form-filling actions for payment portals that don't change frequently, processing thousands of payments with minimal LLM calls. +โ€ข Data entry automation: Reuse actions for repetitive data entry tasks across similar forms or interfaces. +โ€ข Testing workflows: Cache test actions to speed up regression testing and reduce API costs during development. + +## BEST PRACTICES + +- โœ… Enable caching in production for repeated workflows +- โœ… One cache per portal/interface type +- โœ… Invalidate cache when page structure changes significantly +- โœ… Monitor cache hit rate to optimize cache effectiveness +- โœ… Warm cache with test runs before production deployment + +## NEXT STEPS + +โ€ข Customize cache file location: Modify CACHE_FILE path to organize caches by workflow type or environment. +โ€ข Add cache invalidation: Implement logic to clear cache when page structure changes or after a certain time period. +โ€ข Monitor cache performance: Track cache hit rates and cost savings to measure effectiveness. + +## TRY IT YOURSELF + +1. Run this script again: `python main.py` + โ†’ Second run will be MUCH faster (cache hits) + +2. Clear cache and run again: + `rm cache.json && python main.py` + โ†’ Back to first-run behavior + +3. Check cache contents: + `cat cache.json` + โ†’ See cached action data + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/basic-caching/python/main.py b/packages/examples/basic-caching/python/main.py new file mode 100644 index 0000000000..46cbc1edd8 --- /dev/null +++ b/packages/examples/basic-caching/python/main.py @@ -0,0 +1,74 @@ +"""Prove a repeated Stagehand V4 observation is served from cache.""" + +import asyncio +import json +import os +import time + +from dotenv import load_dotenv + +from stagehand import Stagehand, browserbase + +load_dotenv() + +INSTRUCTION = "Find the More information link" + + +async def main() -> None: + api_key = os.environ.get("BROWSERBASE_API_KEY") + if not api_key: + raise RuntimeError("BROWSERBASE_API_KEY is required") + + browser = await browserbase.launch(api_key=api_key) + try: + stagehand = await Stagehand.create( + browser=browser, + cache={"threshold": 1}, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + await page.goto("https://example.com", wait_until="domcontentloaded") + + started = time.perf_counter() + first = await stagehand.observe(INSTRUCTION, page=page) + first_ms = round((time.perf_counter() - started) * 1_000) + if not first.data: + raise RuntimeError("First observation returned no link") + + started = time.perf_counter() + second = await stagehand.observe(INSTRUCTION, page=page) + second_ms = round((time.perf_counter() - started) * 1_000) + if not second.data: + raise RuntimeError("Second observation returned no link") + + first_cache = first.metadata.cache + second_cache = second.metadata.cache + report = { + "first": { + "cache": first_cache.status if first_cache else "DISABLED", + "duration_ms": first_ms, + }, + "second": { + "cache": second_cache.status if second_cache else "DISABLED", + "duration_ms": second_ms, + "tokens_saved": ( + second_cache.tokens_saved.model_dump(mode="json") + if second_cache and second_cache.tokens_saved + else None + ), + }, + } + print(json.dumps(report, indent=2)) + if second_cache is None or second_cache.status != "HIT": + status = second_cache.status if second_cache else "DISABLED" + raise RuntimeError(f"Expected a cache HIT, received {status}") + print("Cache verified: repeated observation avoided inference") + finally: + await stagehand.close() + finally: + await browser.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/packages/examples/basic-caching/python/pyproject.toml b/packages/examples/basic-caching/python/pyproject.toml new file mode 100644 index 0000000000..a041c918fc --- /dev/null +++ b/packages/examples/basic-caching/python/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "basic-caching" +version = "0.1.0" +requires-python = ">=3.11,<3.14" +dependencies = ["python-dotenv==1.2.2", "stagehand==4.0.0"] + +[tool.uv] +package = false diff --git a/packages/examples/basic-caching/python/requirements.txt b/packages/examples/basic-caching/python/requirements.txt new file mode 100644 index 0000000000..d22bf5b873 --- /dev/null +++ b/packages/examples/basic-caching/python/requirements.txt @@ -0,0 +1,3 @@ +stagehand==4.0.0 +python-dotenv +aiofiles diff --git a/packages/examples/basic-caching/typescript/.env.example b/packages/examples/basic-caching/typescript/.env.example new file mode 100644 index 0000000000..d0cde0427d --- /dev/null +++ b/packages/examples/basic-caching/typescript/.env.example @@ -0,0 +1 @@ +BROWSERBASE_API_KEY= diff --git a/packages/examples/basic-caching/typescript/README.md b/packages/examples/basic-caching/typescript/README.md new file mode 100644 index 0000000000..7c898ff18a --- /dev/null +++ b/packages/examples/basic-caching/typescript/README.md @@ -0,0 +1,117 @@ +# Stagehand + Browserbase: Basic Caching + +Location in the Stagehand repository: `packages/examples/basic-caching/typescript`. + +## AT A GLANCE + +- Goal: Demonstrate how Stagehand's caching feature dramatically reduces cost and latency by reusing previously computed actions instead of calling the LLM every time. +- Runs the same observation twice and verifies the second result is a Browserbase Cache hit. +- Reads cache status and saved-token data from the V4 result metadata. +- Docs โ†’ https://docs.stagehand.dev/v4/best-practices/caching#caching-actions + +## GLOSSARY + +- caching: Stagehand can cache action results based on instruction text and page context, eliminating redundant LLM calls + Docs โ†’ https://docs.stagehand.dev/v4/best-practices/caching#caching-actions +- act: execute actions on web pages using natural language instructions + Docs โ†’ https://docs.stagehand.dev/v4/basics/act + +## QUICKSTART + +1. pnpm install +2. cp .env.example .env +3. Add your Browserbase API key to .env +4. pnpm start + +## EXPECTED OUTPUT + +- The first observation is normally a cache miss and primes the managed cache. +- The repeated observation is verified as a `HIT`. +- Output includes each operation's cache status, duration, and saved-token metadata. + +## HOW CACHING WORKS + +**Cache Key Generation:** + +- Based on instruction text +- Based on page context +- Automatically computed + +**When Cache is Used:** + +- โœ… Same instruction +- โœ… Same page structure +- โœ… Cache file exists +- โŒ Different instruction +- โŒ Page structure changed significantly + +**Cache Storage:** + +- Browserbase manages the cache server-side. +- There are no local cache files or directories to maintain. +- `result.metadata.cache.status` reports `HIT`, `MISS`, or `DISABLED`. + +## BENEFITS FOR REPEATED WORKFLOWS + +**Example Scenario: 1,000 customers ร— 10 portals = 10,000 payment flows** + +**Without caching:** + +- 10,000 workflows ร— 5 actions = 50,000 LLM calls +- Cost: ~$500-2,500 +- Latency: 2-3s per action ร— 5 = 10-15s per payment + +**With caching:** + +- First payment per portal: 5 LLM calls (populate cache) +- Next 999 payments: 0 LLM calls (use cache) +- Total: 10 portals ร— 5 actions = 50 LLM calls +- Cost: ~$0.50-2.50 (99.9% savings!) +- Latency: <100ms per action ร— 5 = <0.5s per payment + +**Key Insight:** +Payment portals rarely change โ†’ Cache actions once โ†’ Reuse for thousands of payments โ†’ Massive cost + latency reduction + +## COMMON PITFALLS + +- Missing credentials: verify .env contains BROWSERBASE_API_KEY +- Cache not working: check that the instruction and page content match exactly +- First observation slower: expected behaviorโ€”the first result primes the managed cache +- Find more information on your Browserbase dashboard -> https://www.browserbase.com/sign-in + +## USE CASES + +โ€ข Payment processing: Cache form-filling actions for payment portals that don't change frequently, processing thousands of payments with minimal LLM calls. +โ€ข Data entry automation: Reuse actions for repetitive data entry tasks across similar forms or interfaces. +โ€ข Testing workflows: Cache test actions to speed up regression testing and reduce API costs during development. + +## BEST PRACTICES + +- โœ… Enable caching in production for repeated workflows +- โœ… Keep the page environment and instruction stable +- โœ… Tune `cache.threshold` for the workflow's tolerance for change +- โœ… Monitor cache hit rate to optimize cache effectiveness +- โœ… Warm cache with test runs before production deployment + +## NEXT STEPS + +โ€ข Tune the cache threshold per instance or per operation. +โ€ข Scope operations to a stable selector when the surrounding page changes frequently. +โ€ข Monitor `metadata.cache` to measure hit rates and token savings. + +## TRY IT YOURSELF + +1. Change the instruction text and run again to observe a miss followed by a hit. + +2. Change `cache: { threshold: 1 }` to a higher threshold and compare warm-up behavior. + +3. Print the complete `metadata.cache` object to inspect miss reasons and token savings. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/basic-caching/typescript/index.ts b/packages/examples/basic-caching/typescript/index.ts new file mode 100644 index 0000000000..2e0aa76038 --- /dev/null +++ b/packages/examples/basic-caching/typescript/index.ts @@ -0,0 +1,65 @@ +// Stagehand + Browserbase: Basic Caching - See README.md for full documentation + +import "dotenv/config"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; + +const INSTRUCTION = "Find the More information link"; + +async function main() { + console.log("Starting Browserbase Cache demo..."); + + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + }); + const stagehand = await Stagehand.create({ + browser, + cache: { threshold: 1 }, + logging: { level: "error" }, + }); + + try { + const page = (await browser.context.pages())[0]; + await page.goto("https://example.com", { waitUntil: "domcontentloaded" }); + + const firstStart = Date.now(); + const first = await stagehand.observe(INSTRUCTION); + const firstMs = Date.now() - firstStart; + if (first.data.length === 0) throw new Error("First observation returned no link"); + + const secondStart = Date.now(); + const second = await stagehand.observe(INSTRUCTION); + const secondMs = Date.now() - secondStart; + if (second.data.length === 0) throw new Error("Cached observation returned no link"); + + console.log( + JSON.stringify( + { + first: { cache: first.metadata.cache.status, durationMs: firstMs }, + second: { + cache: second.metadata.cache.status, + durationMs: secondMs, + tokensSaved: second.metadata.cache.tokensSaved ?? null, + }, + }, + null, + 2, + ), + ); + + if (second.metadata.cache.status !== "HIT") { + throw new Error( + `Expected the repeated observation to be a cache HIT, got ${second.metadata.cache.status}`, + ); + } + console.log("Cache verified: the repeated observation was served without inference."); + } finally { + await stagehand.close(); + await browser.close(); + } +} + +main().catch((error) => { + console.error("Error in caching demo:", error); + console.error("Check BROWSERBASE_API_KEY and Browserbase Cache availability."); + process.exit(1); +}); diff --git a/packages/examples/basic-caching/typescript/package.json b/packages/examples/basic-caching/typescript/package.json new file mode 100644 index 0000000000..8324cd6eaa --- /dev/null +++ b/packages/examples/basic-caching/typescript/package.json @@ -0,0 +1,23 @@ +{ + "name": "basic-caching", + "version": "1.0.0", + "description": "Stagehand + Browserbase: Basic Caching Demo", + "type": "module", + "main": "index.ts", + "scripts": { + "start": "tsx index.ts" + }, + "dependencies": { + "@browserbasehq/stagehand": "4.0.2", + "dotenv": "latest" + }, + "devDependencies": { + "@types/node": "latest", + "tsx": "latest", + "typescript": "latest" + }, + "engines": { + "node": ">=22.18.0" + }, + "packageManager": "pnpm@10.24.0" +} diff --git a/packages/examples/basic-recaptcha/README.md b/packages/examples/basic-recaptcha/README.md new file mode 100644 index 0000000000..3009915975 --- /dev/null +++ b/packages/examples/basic-recaptcha/README.md @@ -0,0 +1,12 @@ +# basic-recaptcha + +Stagehand actions against the public reCAPTCHA demo. + +Choose a language and follow its setup instructions: + +| Language | Stagehand dependency | Directory from the repository root | +| ---------------------------------- | -------------------- | ---------------------------------------------- | +| [TypeScript](typescript/README.md) | `4.0.2` | `packages/examples/basic-recaptcha/typescript` | +| [Python](python/README.md) | `4.0.0` | `packages/examples/basic-recaptcha/python` | + +Each variant installs the published Stagehand package and keeps its own dependencies. diff --git a/packages/examples/basic-recaptcha/python/.env.example b/packages/examples/basic-recaptcha/python/.env.example new file mode 100644 index 0000000000..d0cde0427d --- /dev/null +++ b/packages/examples/basic-recaptcha/python/.env.example @@ -0,0 +1 @@ +BROWSERBASE_API_KEY= diff --git a/packages/examples/basic-recaptcha/python/README.md b/packages/examples/basic-recaptcha/python/README.md new file mode 100644 index 0000000000..12f5ab6f95 --- /dev/null +++ b/packages/examples/basic-recaptcha/python/README.md @@ -0,0 +1,110 @@ +# Stagehand + Browserbase: Basic reCAPTCHA Solving + +Location in the Stagehand repository: `packages/examples/basic-recaptcha/python`. + +Stagehand is the SDK for browser agents. + +## AT A GLANCE + +- Goal: Demonstrate automatic reCAPTCHA solving using Browserbase's built-in captcha solving capabilities. +- Automated Solving: Browserbase automatically detects and solves CAPTCHAs in the background. CAPTCHA solving is **enabled by default** - you don't need to set `solveCaptchas: true` unless you want to explicitly enable it (or set it to `false` to disable). +- Solving Time: CAPTCHA solving typically takes between 5-30 seconds depending on CAPTCHA type and complexity. +- Progress Monitoring: Listen for console messages (`browserbase-solving-started`, `browserbase-solving-finished`) to track captcha solving progress in real-time. +- Proxies Recommended: Enable proxies for higher CAPTCHA solving success rates. +- Result inspection: Extracts and prints the page content after form submission. +- Docs โ†’ https://docs.browserbase.com/features/stealth-mode#captcha-solving + +## GLOSSARY + +- solveCaptchas: Browserbase browser setting that enables automatic captcha solving for reCAPTCHA, hCaptcha, and other captcha types. Enabled by default for Basic and Advanced Stealth Mode. + Docs โ†’ https://docs.browserbase.com/features/stealth-mode#captcha-solving +- CAPTCHA solving: When a CAPTCHA is detected, Browserbase attempts to solve it automatically in the background, allowing your automation to continue without manual intervention. +- console messages: browser console events that indicate captcha solving status: + - `browserbase-solving-started`: emitted when CAPTCHA detection begins + - `browserbase-solving-finished`: emitted when CAPTCHA solving completes +- custom CAPTCHA solving: For non-standard or custom captcha providers, you can specify CSS selectors for the captcha image and input field using `captchaImageSelector` and `captchaInputSelector` in browserSettings. +- act: perform UI actions from a prompt (type, click, fill forms) + Docs โ†’ https://docs.stagehand.dev/v4/basics/act +- extract: pull data from web pages using natural language instructions + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract + +## CAPTCHA SOLVING DETAILS + +### How CAPTCHA Solving Works + +Browserbase provides integrated CAPTCHA solving to handle challenges automatically: + +- **Automatic Detection**: When a CAPTCHA is detected on a page, Browserbase attempts to solve it in the background +- **Solving Time**: CAPTCHA solving typically takes between 5-30 seconds, depending on the CAPTCHA type and complexity +- **Default Behavior**: CAPTCHA solving is enabled by default for Basic and Advanced Stealth Mode +- **Proxies**: It's recommended to enable proxies when using CAPTCHA solving for higher success rates +- **Multiple Types**: Browserbase supports reCAPTCHA, hCaptcha, and other common captcha providers automatically + +### Custom CAPTCHA Solving + +For non-standard or custom captcha providers, you can specify CSS selectors to guide the solution process: + +```python +browserbase_session_create_params = { + "browser_settings": { + "solveCaptchas": True, + "captchaImageSelector": "#custom-captcha-image-id", + "captchaInputSelector": "#custom-captcha-input-id", + } +} +``` + +To find the selectors: + +1. Right-click on the captcha image and select "Inspect" to get the image selector +2. Right-click on the input field and select "Inspect" to get the input selector +3. Use the element's `id` or a CSS selector that uniquely identifies it + +### Disabling CAPTCHA Solving + +If you want to disable automatic captcha solving, set `solveCaptchas: False` in browserSettings: + +```python +browserbase_session_create_params = {"browser_settings": {"solveCaptchas": False}} +``` + +## QUICKSTART + +1. uv venv venv +2. source venv/bin/activate # On Windows: venv\Scripts\activate +3. uvx install stagehand python-dotenv +4. cp .env.example .env # Add your Browserbase API key to .env +5. python main.py + +## EXPECTED OUTPUT + +- Initializes Stagehand session with Browserbase +- Displays live session link for monitoring +- Navigates to Google reCAPTCHA demo page +- Waits for Browserbase to automatically solve the captcha +- Logs captcha solving progress messages +- Clicks submit button after captcha is solved +- Extracts and displays page content +- Prints the resulting page content for inspection +- Closes session cleanly + +## COMMON PITFALLS + +- Missing credentials: verify .env contains BROWSERBASE_API_KEY +- Captcha solving not enabled: ensure `solveCaptchas: True` is set in browserSettings (enabled by default) +- Solving timeout: allow up to 30 seconds for CAPTCHA solving to complete before timing out +- Proxies not enabled: enable proxies in browserSettings for higher CAPTCHA solving success rates +- Demo page inaccessible: verify the reCAPTCHA demo page URL is accessible and hasn't changed +- Console message timing: ensure console event listeners are set up before triggering the captcha +- Custom captcha selectors: for non-standard CAPTCHAs, verify that `captchaImageSelector` and `captchaInputSelector` are correctly defined +- Import errors: activate your virtual environment if you created one +- ModuleNotFoundError: ensure all dependencies are installed via uvx install + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/basic-recaptcha/python/main.py b/packages/examples/basic-recaptcha/python/main.py new file mode 100644 index 0000000000..73128f839e --- /dev/null +++ b/packages/examples/basic-recaptcha/python/main.py @@ -0,0 +1,63 @@ +"""Solve and verify Google's reCAPTCHA demo with Stagehand V4.""" + +import asyncio +import os + +from dotenv import load_dotenv + +from stagehand import Stagehand, browserbase + +load_dotenv() + + +async def main() -> None: + api_key = os.environ.get("BROWSERBASE_API_KEY") + if not api_key: + raise RuntimeError("BROWSERBASE_API_KEY is required") + + browser = await browserbase.launch( + api_key=api_key, + browser_settings={"solve_captchas": True}, + ) + try: + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + await page.goto( + "https://google.com/recaptcha/api2/demo", + wait_until="domcontentloaded", + timeout=60_000, + ) + + print("Waiting for Browserbase captcha solving...") + token = "" + for _ in range(60): + token = await page.locator("#g-recaptcha-response").input_value() + if token: + break + await asyncio.sleep(1) + if not token: + raise RuntimeError("Captcha token was not populated within 60 seconds") + + await stagehand.act("Click the Submit button", page=page) + extracted = await stagehand.extract("Extract all text on this page", page=page) + text = extracted.data.extraction + print("Page content after submission:") + print(text) + finally: + await stagehand.close() + finally: + await browser.close() + print("Session closed successfully") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as error: + print(f"reCAPTCHA example failed: {error}") + print("Docs: https://docs.stagehand.dev/v4/first-steps/introduction") + raise diff --git a/packages/examples/basic-recaptcha/python/pyproject.toml b/packages/examples/basic-recaptcha/python/pyproject.toml new file mode 100644 index 0000000000..d9daf3e6ab --- /dev/null +++ b/packages/examples/basic-recaptcha/python/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "basic-recaptcha" +version = "0.1.0" +requires-python = ">=3.11,<3.14" +dependencies = ["python-dotenv==1.2.2", "stagehand==4.0.0"] + +[tool.uv] +package = false diff --git a/packages/examples/basic-recaptcha/typescript/.env.example b/packages/examples/basic-recaptcha/typescript/.env.example new file mode 100644 index 0000000000..d0cde0427d --- /dev/null +++ b/packages/examples/basic-recaptcha/typescript/.env.example @@ -0,0 +1 @@ +BROWSERBASE_API_KEY= diff --git a/packages/examples/basic-recaptcha/typescript/README.md b/packages/examples/basic-recaptcha/typescript/README.md new file mode 100644 index 0000000000..4b41ece5b2 --- /dev/null +++ b/packages/examples/basic-recaptcha/typescript/README.md @@ -0,0 +1,114 @@ +# Stagehand + Browserbase: Basic reCAPTCHA Solving + +Location in the Stagehand repository: `packages/examples/basic-recaptcha/typescript`. + +## AT A GLANCE + +- Goal: Demonstrate automatic reCAPTCHA solving using Browserbase's built-in captcha solving capabilities. +- Automated Solving: Browserbase automatically detects and solves CAPTCHAs in the background. CAPTCHA solving is **enabled by default** - you don't need to set `solveCaptchas: true` unless you want to explicitly enable it (or set it to `false` to disable). +- Solving Time: CAPTCHA solving typically takes between 5-30 seconds depending on CAPTCHA type and complexity. +- Progress Monitoring: Listen for console messages (`browserbase-solving-started`, `browserbase-solving-finished`) to track captcha solving progress in real-time. +- Proxies Recommended: Enable proxies for higher CAPTCHA solving success rates. +- Result inspection: Extracts and prints the page content after form submission. +- Docs โ†’ https://docs.browserbase.com/features/stealth-mode#captcha-solving + +## GLOSSARY + +- solveCaptchas: Browserbase browser setting that enables automatic captcha solving for reCAPTCHA, hCaptcha, and other captcha types. Enabled by default for Basic and Advanced Stealth Mode. + Docs โ†’ https://docs.browserbase.com/features/stealth-mode#captcha-solving +- CAPTCHA solving: When a CAPTCHA is detected, Browserbase attempts to solve it automatically in the background, allowing your automation to continue without manual intervention. +- console messages: browser console events that indicate captcha solving status: + - `browserbase-solving-started`: emitted when CAPTCHA detection begins + - `browserbase-solving-finished`: emitted when CAPTCHA solving completes +- custom CAPTCHA solving: For non-standard or custom captcha providers, you can specify CSS selectors for the captcha image and input field using `captchaImageSelector` and `captchaInputSelector` in browserSettings. +- act: perform UI actions from a prompt (type, click, fill forms) + Docs โ†’ https://docs.stagehand.dev/v4/basics/act +- extract: pull data from web pages using natural language instructions + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract + +## STAGEHAND VS PLAYWRIGHT + +| Feature | Stagehand (this template) | Playwright | +| --------------- | ------------------------------------ | ----------------------------- | +| Actions | `stagehand.act("Click the button")` | `page.click()`, `page.goto()` | +| Data Extraction | `stagehand.extract("Get the price")` | Manual DOM queries | + +## CAPTCHA SOLVING DETAILS + +### How CAPTCHA Solving Works + +Browserbase provides integrated CAPTCHA solving to handle challenges automatically: + +- **Automatic Detection**: When a CAPTCHA is detected on a page, Browserbase attempts to solve it in the background +- **Solving Time**: CAPTCHA solving typically takes between 5-30 seconds, depending on the CAPTCHA type and complexity +- **Default Behavior**: CAPTCHA solving is enabled by default for Basic and Advanced Stealth Mode +- **Proxies**: It's recommended to enable proxies when using CAPTCHA solving for higher success rates +- **Multiple Types**: Browserbase supports reCAPTCHA, hCaptcha, and other common captcha providers automatically + +### Custom CAPTCHA Solving + +For non-standard or custom captcha providers, you can specify CSS selectors to guide the solution process: + +```typescript +browserSettings: { + solveCaptchas: true, + captchaImageSelector: "#custom-captcha-image-id", + captchaInputSelector: "#custom-captcha-input-id" +} +``` + +To find the selectors: + +1. Right-click on the captcha image and select "Inspect" to get the image selector +2. Right-click on the input field and select "Inspect" to get the input selector +3. Use the element's `id` or a CSS selector that uniquely identifies it + +### Disabling CAPTCHA Solving + +If you want to disable automatic captcha solving, set `solveCaptchas: false` in browserSettings: + +```typescript +browserSettings: { + solveCaptchas: false; +} +``` + +## QUICKSTART + +1. cd packages/examples/basic-recaptcha/typescript +2. pnpm install +3. cp .env.example .env +4. Add your Browserbase API key to .env +5. pnpm start + +## EXPECTED OUTPUT + +- Initializes Stagehand session with Browserbase +- Listens for Browserbase captcha progress through Stagehand V4 console events +- Navigates to Google reCAPTCHA demo page +- Clicks submit button to trigger reCAPTCHA challenge +- Waits for Browserbase to automatically solve the captcha +- Logs captcha solving progress messages +- Clicks submit again after captcha is solved +- Extracts and displays page content +- Prints the resulting page content for inspection +- Closes session cleanly + +## COMMON PITFALLS + +- Missing credentials: verify .env contains BROWSERBASE_API_KEY +- Captcha solving not enabled: ensure `solveCaptchas: true` is set in browserSettings (enabled by default) +- Solving timeout: allow up to 30 seconds for CAPTCHA solving to complete before timing out +- Proxies not enabled: enable proxies in browserSettings for higher CAPTCHA solving success rates +- Demo page inaccessible: verify the reCAPTCHA demo page URL is accessible and hasn't changed +- Console message timing: ensure console event listeners are set up before triggering the captcha +- Custom captcha selectors: for non-standard CAPTCHAs, verify that `captchaImageSelector` and `captchaInputSelector` are correctly defined + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/basic-recaptcha/typescript/index.ts b/packages/examples/basic-recaptcha/typescript/index.ts new file mode 100644 index 0000000000..ce5e4d8063 --- /dev/null +++ b/packages/examples/basic-recaptcha/typescript/index.ts @@ -0,0 +1,90 @@ +// Basic reCAPTCHA Solving with Browserbase - See README.md for full documentation + +import "dotenv/config"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; + +async function main() { + // Initialize Stagehand with Browserbase for cloud-based browser automation. + // Enable captcha solving in browser settings for automatic reCAPTCHA handling. + + const solveCaptchas = true; // Set to false to disable automatic captcha solving (true by default) + + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + browserSettings: { + solveCaptchas: solveCaptchas, + }, + }); + const stagehand = await Stagehand.create({ browser: browser, logging: { level: "info" } }); + + try { + // Initialize browser session to start automation. + + console.log("Stagehand initialized successfully!"); + const page = (await browser.context.pages())[0]; + + // Navigate to Google reCAPTCHA demo page to test captcha solving. + console.log("Navigating to reCAPTCHA demo page..."); + await page.goto("https://google.com/recaptcha/api2/demo"); + + // Wait for Browserbase to solve the captcha automatically. + // Listen for console messages indicating captcha solving progress. + if (solveCaptchas) { + console.log("Waiting for captcha to be solved..."); + let resolveCaptcha!: () => void; + const captchaSolved = new Promise((resolve) => { + resolveCaptcha = resolve; + }); + const subscription = await page.on("console", (event) => { + const args = event.params.args; + if (!Array.isArray(args)) return; + const message = args + .map((arg) => { + if (typeof arg === "object" && arg !== null && !Array.isArray(arg) && "value" in arg) { + return String(arg.value); + } + return ""; + }) + .join(" "); + + if (message === "browserbase-solving-started") { + console.log("Captcha solving in progress..."); + } else if (message === "browserbase-solving-finished") { + console.log("Captcha solving completed!"); + resolveCaptcha(); + } + }); + await captchaSolved; + await subscription.unsubscribe(); + } else { + console.log("Captcha solving is disabled. Skipping wait..."); + } + + // Click submit again after captcha is solved to complete the form submission. + console.log("Clicking submit button after captcha is solved..."); + await stagehand.act("Click the Submit button"); + + // Extract and display the page content after submission. + console.log("Extracting page content..."); + const { data: text } = await stagehand.extract("Extract all the text on this page"); + console.log("Page content:"); + console.log(text); + } catch (error) { + console.error("Error during reCAPTCHA solving:", error); + } finally { + // Always close session to release resources and clean up. + await stagehand.close(); + await browser.close(); + console.log("Session closed successfully"); + } +} + +main().catch((err) => { + console.error("Error in reCAPTCHA solving example:", err); + console.error("Common issues:"); + console.error(" - Check .env file has BROWSERBASE_API_KEY"); + console.error(" - Verify solveCaptchas is enabled in browserSettings"); + console.error(" - Ensure the demo page is accessible"); + console.error("Docs: https://docs.stagehand.dev/v4/first-steps/introduction"); + process.exit(1); +}); diff --git a/packages/examples/basic-recaptcha/typescript/package.json b/packages/examples/basic-recaptcha/typescript/package.json new file mode 100644 index 0000000000..bf5424ca1e --- /dev/null +++ b/packages/examples/basic-recaptcha/typescript/package.json @@ -0,0 +1,22 @@ +{ + "name": "basic-recaptcha", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "tsx index.ts" + }, + "dependencies": { + "@browserbasehq/stagehand": "4.0.2", + "dotenv": "^17.4.2" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "tsx": "^4.23.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.18.0" + }, + "packageManager": "pnpm@10.24.0" +} diff --git a/packages/examples/box/.env.example b/packages/examples/box/.env.example new file mode 100644 index 0000000000..9e2d2a954d --- /dev/null +++ b/packages/examples/box/.env.example @@ -0,0 +1,16 @@ +BROWSERBASE_API_KEY= + +# Create a Server app using Client Credentials Grant in the Box Developer Console. +BOX_CLIENT_ID= +BOX_CLIENT_SECRET= +BOX_ENTERPRISE_ID= + +# Share this folder with the app's service account using the Editor role. +BOX_FOLDER_ID= + +# Optional source overrides. The defaults download a Clorox SDS and an EPA +# sample pesticide label. +SDS_PAGE_URL=https://www.thecloroxcompany.com/sds/clorox-disinfecting-wipes1-fresh-scent/ +SDS_LINK_TEXT=Download Safety Data Sheet +LABEL_PAGE_URL=https://www.epa.gov/pesticide-labels/sample-pesticide-label-current-and-ghs-requirements +LABEL_LINK_TEXT=Sample Pesticide Label with Current and GHS Requirements diff --git a/packages/examples/box/README.md b/packages/examples/box/README.md new file mode 100644 index 0000000000..8d079c7e73 --- /dev/null +++ b/packages/examples/box/README.md @@ -0,0 +1,107 @@ +# Browserbase + Box AI Compliance Intake + +Location in the Stagehand repository: `packages/examples/box`. + +This example uses Browserbase to download a safety data sheet and product label +from the web, uploads both files to Box, and turns them into an agentic +compliance workflow with Box AI. + +The default sources are a Clorox safety data sheet and an EPA sample pesticide +label. They intentionally demonstrate how an agent can identify a packet that +needs human review. Override the source variables in `.env` to process a matching +SDS and label pair. + +## What it demonstrates + +1. Stagehand opens real manufacturer and government web pages in a Browserbase session. +2. The remote browser downloads both PDFs to Browserbase cloud storage. +3. The Browserbase Downloads API returns the original file bytes. +4. The files are uploaded to Box. +5. `POST /ai/ask` answers safety questions about the SDS with citations. +6. `POST /ai/extract_structured` extracts structured metadata from the label + document, including text embedded in the label artwork. +7. A deterministic agent checks the extracted EPA registration numbers and SDS + revision date. +8. The extraction and decision are saved on each Box file using the global + `properties` metadata template. + +## Prerequisites + +- Node.js 18 or newer +- A Browserbase API key +- A Box Server App using Client Credentials Grant with: + - Read and write access to files and folders + - Manage AI enabled +- A Box folder shared with the app's service account using the **Editor** role +- A Box plan with access to the Box AI API + +In the [Box Developer Console](https://app.box.com/developers/console), create a +**Server** app using **Client Credentials Grant**, enable the required scopes, and +authorize it. Copy its Client ID, Client Secret, and Enterprise ID into `.env`. +The demo exchanges those credentials for a temporary service-account access token +at runtime, so it does not require an interactive login or a Developer Token. + +Copy the service account email from the app's details, share a project folder with +that account using the **Editor** role, and set `BOX_FOLDER_ID` to the number in the +folder's Box URL. The agent can access only content available to its service account. + +## Run it + +```bash +cd packages/examples/box +pnpm install --ignore-workspace +cp .env.example .env +# Fill in Browserbase and Box credentials. +pnpm start +``` + +The command prints: + +- A Browserbase Session Inspector URL +- The cited Box AI answer +- Extracted metadata, confidence scores, and source references +- The compliance decision +- Links to the uploaded Box files + +## Configuration + +| Variable | Required | Description | +| --------------------- | -------- | -------------------------------------------------- | +| `BROWSERBASE_API_KEY` | Yes | Browserbase browser and Model Gateway API key | +| `BOX_CLIENT_ID` | Yes | Box Server App client ID | +| `BOX_CLIENT_SECRET` | Yes | Box Server App client secret | +| `BOX_ENTERPRISE_ID` | Yes | Enterprise that owns the app's service account | +| `BOX_FOLDER_ID` | Yes | Destination folder shared with the service account | +| `SDS_PAGE_URL` | No | Web page containing the SDS link | +| `SDS_LINK_TEXT` | No | Accessible name of the SDS download link | +| `LABEL_PAGE_URL` | No | Web page containing the product-label link | +| `LABEL_LINK_TEXT` | No | Accessible name of the label download link | + +The source link must initiate a browser download. Stagehand clicks it normally, +and the Browserbase Downloads API exposes the resulting file. + +New Box files can take a short time to become available to Box AI. The demo +retries transient readiness, rate-limit, and server responses with bounded +exponential backoff before failing. + +## Why structured extraction + +Box's freeform `POST /ai/extract` endpoint does not perform OCR. This example uses +`POST /ai/extract_structured`, which supports OCR for scanned PDFs and image files +such as TIFF, PNG, and JPEG. It also returns confidence scores and references that +an agent can use to decide when human review is required. + +Box AI does not process a mixed text-and-image packet as one multimodal request. +The example therefore asks questions about the SDS, extracts each file separately, +and compares the structured results in application code. + +## Useful documentation + +- [Browserbase downloads](https://docs.browserbase.com/platform/browser/files/downloads) +- [Stagehand](https://docs.stagehand.dev/v3/first-steps/quickstart) +- [Box file uploads](https://developer.box.com/reference/post-files-content) +- [Connect an AI agent to Box](https://developer.box.com/tutorials/connect-an-agent-to-box) +- [Box Client Credentials Grant](https://developer.box.com/guides/authentication/client-credentials) +- [Box AI Q&A](https://developer.box.com/reference/post-ai-ask) +- [Box AI structured extraction](https://developer.box.com/reference/post-ai-extract-structured) +- [Box metadata instances](https://developer.box.com/guides/metadata/instances/create) diff --git a/packages/examples/box/package.json b/packages/examples/box/package.json new file mode 100644 index 0000000000..880a8a5f22 --- /dev/null +++ b/packages/examples/box/package.json @@ -0,0 +1,19 @@ +{ + "name": "browserbase-box-compliance-intake", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "tsx src/index.ts" + }, + "dependencies": { + "@browserbasehq/stagehand": "^3.7.1", + "dotenv": "^16.4.7" + }, + "devDependencies": { + "@types/node": "^25.0.9", + "tsx": "^4.19.2", + "typescript": "^6.0.2" + } +} diff --git a/packages/examples/box/src/box.ts b/packages/examples/box/src/box.ts new file mode 100644 index 0000000000..9c4171a690 --- /dev/null +++ b/packages/examples/box/src/box.ts @@ -0,0 +1,221 @@ +import type { DownloadedFile } from "./browserbase.js"; +import { metadataValues, type ExtractionAnswer } from "./compliance.js"; + +export type BoxFile = { + id: string; + name: string; +}; + +export type BoxAiAskResponse = { + answer: string; + citations?: Array<{ + id: string; + name?: string; + content?: string; + type: string; + }>; +}; + +export type BoxAiExtractResponse = { + answer: ExtractionAnswer; + confidence_score?: Record; + reference?: Record; +}; + +async function responseError(response: Response, action: string) { + throw new Error( + `${action} failed (${response.status} ${response.statusText}): ${await response.text()}`, + ); +} + +function field(key: string, displayName: string, prompt: string, type = "string") { + return { key, displayName, prompt, type }; +} + +export const SDS_FIELDS = [ + field("productName", "Product name", "The product identifier or product name."), + field("manufacturer", "Manufacturer", "The supplier or manufacturer name."), + field( + "epaRegistrationNumber", + "EPA registration number", + "The EPA pesticide registration number, preserving its printed punctuation.", + ), + field("revisionDate", "Revision date", "The document revision date.", "date"), + field( + "emergencyPhone", + "Emergency phone", + "The medical or transportation emergency phone number.", + ), + field("recommendedUse", "Recommended use", "The recommended product use."), + field("hazards", "Hazards", "The primary hazards or hazard statements."), + field("ppe", "PPE", "Required personal protective equipment."), + field("storageRequirements", "Storage requirements", "The conditions required for safe storage."), +]; + +export const LABEL_FIELDS = [ + field("productName", "Product name", "The product name shown on the label."), + field( + "epaRegistrationNumber", + "EPA registration number", + "The EPA registration number shown on the label, preserving punctuation.", + ), + field("signalWord", "Signal word", "The signal word, such as Danger or Caution."), + field("activeIngredients", "Active ingredients", "The active ingredients."), + field("contactTime", "Contact time", "The required wet contact or dwell time."), + field("firstAid", "First aid", "The first-aid instructions."), + field("directions", "Directions", "The directions for use."), + field("storageAndDisposal", "Storage and disposal", "The storage and disposal instructions."), +]; + +export async function boxAccessToken(): Promise { + const response = await fetch("https://api.box.com/oauth2/token", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "client_credentials", + client_id: process.env.BOX_CLIENT_ID as string, + client_secret: process.env.BOX_CLIENT_SECRET as string, + box_subject_type: "enterprise", + box_subject_id: process.env.BOX_ENTERPRISE_ID as string, + }), + }); + + if (!response.ok) { + await responseError(response, "Authenticating with Box"); + } + + const body = (await response.json()) as { access_token?: string }; + if (!body.access_token) { + throw new Error("Box did not return an access token."); + } + return body.access_token; +} + +export async function uploadToBox( + token: string, + file: DownloadedFile, + role: "sds" | "label", +): Promise { + const extensionIndex = file.filename.lastIndexOf("."); + const extension = extensionIndex >= 0 ? file.filename.slice(extensionIndex) : ""; + const name = `browserbase-${role}-${Date.now()}${extension}`; + const form = new FormData(); + form.append( + "attributes", + JSON.stringify({ + name, + parent: { id: process.env.BOX_FOLDER_ID as string }, + }), + ); + form.append("file", new Blob([file.bytes], { type: file.mimeType }), name); + + const response = await fetch("https://upload.box.com/api/2.0/files/content", { + method: "POST", + headers: { authorization: `Bearer ${token}` }, + body: form, + }); + + if (!response.ok) { + await responseError(response, `Uploading ${file.filename} to Box`); + } + + const body = (await response.json()) as { entries: BoxFile[] }; + const uploaded = body.entries[0]; + if (!uploaded) { + throw new Error(`Box did not return an uploaded file for ${file.filename}.`); + } + + console.log(`Uploaded ${uploaded.name} to Box (file ${uploaded.id})`); + return uploaded; +} + +async function boxAiRequest( + token: string, + path: string, + body: Record, +): Promise { + const maxAttempts = 6; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const response = await fetch(`https://api.box.com/2.0${path}`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (response.ok) { + return (await response.json()) as T; + } + + const retryable = + response.status === 400 || + response.status === 412 || + response.status === 429 || + response.status >= 500; + if (attempt < maxAttempts && retryable) { + const retryAfterHeader = response.headers.get("retry-after"); + const retryAfter = retryAfterHeader === null ? Number.NaN : Number(retryAfterHeader); + const delayMs = + Number.isFinite(retryAfter) && retryAfter >= 0 + ? retryAfter * 1_000 + : Math.min(2 ** attempt * 1_000, 30_000); + console.log( + `Box AI is not ready yet; retrying in ${Math.ceil(delayMs / 1_000)}s ` + + `(${attempt}/${maxAttempts})...`, + ); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + continue; + } + + await responseError(response, `Calling Box AI ${path}`); + } + + throw new Error(`Calling Box AI ${path} exhausted all retries.`); +} + +export function askBox(token: string, file: BoxFile) { + return boxAiRequest(token, "/ai/ask", { + mode: "single_item_qa", + prompt: + "Summarize the safe handling, storage, personal protective equipment, and emergency response requirements in this safety data sheet.", + items: [{ type: "file", id: file.id }], + include_citations: true, + }); +} + +export function extractBoxMetadata( + token: string, + file: BoxFile, + fields: ReturnType[], +) { + return boxAiRequest(token, "/ai/extract_structured", { + items: [{ type: "file", id: file.id }], + fields, + include_confidence_score: true, + include_reference: true, + }); +} + +export async function applyGlobalProperties( + token: string, + file: BoxFile, + properties: Record, +) { + const response = await fetch( + `https://api.box.com/2.0/files/${file.id}/metadata/global/properties`, + { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify(metadataValues(properties)), + }, + ); + + if (!response.ok) { + await responseError(response, `Applying metadata to ${file.name}`); + } +} diff --git a/packages/examples/box/src/browserbase.ts b/packages/examples/box/src/browserbase.ts new file mode 100644 index 0000000000..411ae1755b --- /dev/null +++ b/packages/examples/box/src/browserbase.ts @@ -0,0 +1,162 @@ +import { Stagehand } from "@browserbasehq/stagehand"; + +type Source = { + role: "sds" | "label"; + pageUrl: string; + linkText: string; +}; + +export const sources: Source[] = [ + { + role: "sds", + pageUrl: + process.env.SDS_PAGE_URL ?? + "https://www.thecloroxcompany.com/sds/clorox-disinfecting-wipes1-fresh-scent/", + linkText: process.env.SDS_LINK_TEXT ?? "Download Safety Data Sheet", + }, + { + role: "label", + pageUrl: + process.env.LABEL_PAGE_URL ?? + "https://www.epa.gov/pesticide-labels/sample-pesticide-label-current-and-ghs-requirements", + linkText: + process.env.LABEL_LINK_TEXT ?? "Sample Pesticide Label with Current and GHS Requirements", + }, +]; + +type BrowserbaseDownload = { + id: string; + sessionId: string; + filename: string; + mimeType: string; + size: number; + checksum: string; + createdAt: string; +}; + +export type DownloadedFile = BrowserbaseDownload & { + bytes: ArrayBuffer; +}; + +async function responseError(response: Response, action: string) { + throw new Error( + `${action} failed (${response.status} ${response.statusText}): ${await response.text()}`, + ); +} + +async function triggerDownload(stagehand: Stagehand, source: Source) { + const page = stagehand.context.pages()[0]; + console.log(`\nOpening ${source.pageUrl}`); + await page.goto(source.pageUrl, { + waitUntil: "domcontentloaded", + timeoutMs: 60_000, + }); + + const [action] = await stagehand.observe( + `Find the link named "${source.linkText}" that downloads the document.`, + ); + if (!action) { + throw new Error(`Stagehand could not find the ${source.linkText} link.`); + } + + await stagehand.act(action); + console.log(`Stagehand clicked ${source.linkText}`); +} + +async function listDownloads( + sessionId: string, + createdAfter: string, +): Promise { + const query = new URLSearchParams({ + sessionId, + createdAfter, + limit: "20", + }); + const response = await fetch(`https://api.browserbase.com/v1/downloads?${query}`, { + headers: { + "x-bb-api-key": process.env.BROWSERBASE_API_KEY as string, + }, + }); + + if (!response.ok) { + await responseError(response, "Listing Browserbase downloads"); + } + + const body = (await response.json()) as { + downloads: BrowserbaseDownload[]; + }; + return body.downloads.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); +} + +async function waitForDownload( + sessionId: string, + createdAfter: string, + seenDownloadIds: Set, +): Promise { + for (let attempt = 1; attempt <= 15; attempt += 1) { + const downloads = await listDownloads(sessionId, createdAfter); + const download = downloads.find((candidate) => !seenDownloadIds.has(candidate.id)); + if (download) { + return download; + } + + console.log("Waiting for Browserbase cloud sync..."); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + } + + throw new Error("Browserbase did not sync the download within 30 seconds."); +} + +async function getDownload(download: BrowserbaseDownload): Promise { + const response = await fetch(`https://api.browserbase.com/v1/downloads/${download.id}`, { + headers: { + "x-bb-api-key": process.env.BROWSERBASE_API_KEY as string, + accept: "application/octet-stream", + }, + }); + + if (!response.ok) { + await responseError(response, `Retrieving ${download.filename}`); + } + + return { ...download, bytes: await response.arrayBuffer() }; +} + +export async function downloadWithStagehand() { + const stagehand = new Stagehand({ + env: "BROWSERBASE", + apiKey: process.env.BROWSERBASE_API_KEY, + }); + await stagehand.init(); + const sessionId = stagehand.browserbaseSessionID; + if (!sessionId) { + await stagehand.close(); + throw new Error("Stagehand did not return a Browserbase session ID."); + } + + try { + await stagehand.context.pages()[0].sendCDP("Browser.setDownloadBehavior", { + behavior: "allow", + downloadPath: "downloads", + eventsEnabled: true, + }); + console.log(`Watch the browser live: https://browserbase.com/sessions/${sessionId}`); + + const files: DownloadedFile[] = []; + const seenDownloadIds = new Set(); + for (const source of sources) { + const startedAt = new Date(Date.now() - 1_000).toISOString(); + await triggerDownload(stagehand, source); + const download = await waitForDownload(sessionId, startedAt, seenDownloadIds); + seenDownloadIds.add(download.id); + const file = await getDownload(download); + files.push(file); + console.log(`Browserbase synced ${file.filename} (${file.size} bytes)`); + } + + return { sessionId, stagehand, files }; + } catch (error) { + await stagehand.close(); + throw error; + } +} diff --git a/packages/examples/box/src/compliance.ts b/packages/examples/box/src/compliance.ts new file mode 100644 index 0000000000..33aea28e4a --- /dev/null +++ b/packages/examples/box/src/compliance.ts @@ -0,0 +1,66 @@ +export type ExtractionAnswer = { + epaRegistrationNumber?: string; + revisionDate?: string; + [key: string]: unknown; +}; + +export type ComplianceDecision = { + status: "APPROVED" | "NEEDS_REVIEW"; + reasons: string[]; +}; + +export function normalizeRegistrationNumber(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + + const normalized = value.replace(/[^0-9]/g, ""); + return normalized.length > 0 ? normalized : undefined; +} + +export function decideCompliance( + sds: ExtractionAnswer, + label: ExtractionAnswer, +): ComplianceDecision { + const reasons: string[] = []; + const sdsRegistration = normalizeRegistrationNumber(sds.epaRegistrationNumber); + const labelRegistration = normalizeRegistrationNumber(label.epaRegistrationNumber); + + if (!sdsRegistration) { + reasons.push("The SDS is missing an EPA registration number."); + } + + if (!labelRegistration) { + reasons.push("The label is missing an EPA registration number."); + } + + if (sdsRegistration && labelRegistration && sdsRegistration !== labelRegistration) { + reasons.push(`EPA registration mismatch: SDS ${sdsRegistration}, label ${labelRegistration}.`); + } + + if (!sds.revisionDate?.trim()) { + reasons.push("The SDS is missing a revision date."); + } + + if (reasons.length > 0) { + return { status: "NEEDS_REVIEW", reasons }; + } + + return { + status: "APPROVED", + reasons: ["The label and SDS registration metadata agree."], + }; +} + +export function metadataValues(values: Record): Record { + return Object.fromEntries( + Object.entries(values).flatMap(([key, value]) => { + if (value === undefined || value === null) { + return []; + } + + const serialized = typeof value === "string" ? value : JSON.stringify(value); + return serialized.length > 0 ? [[key, serialized]] : []; + }), + ); +} diff --git a/packages/examples/box/src/index.ts b/packages/examples/box/src/index.ts new file mode 100644 index 0000000000..28e98234ac --- /dev/null +++ b/packages/examples/box/src/index.ts @@ -0,0 +1,79 @@ +import "dotenv/config"; + +import { + applyGlobalProperties, + askBox, + boxAccessToken, + extractBoxMetadata, + LABEL_FIELDS, + SDS_FIELDS, + uploadToBox, +} from "./box.js"; +import { downloadWithStagehand, sources } from "./browserbase.js"; +import { decideCompliance } from "./compliance.js"; + +async function main() { + console.log("Starting Browserbase + Box compliance intake..."); + const token = await boxAccessToken(); + const { stagehand, sessionId, files } = await downloadWithStagehand(); + + try { + if (files.length < sources.length) { + throw new Error(`Expected ${sources.length} files, received ${files.length}.`); + } + + const [sdsFile, labelFile] = await Promise.all([ + uploadToBox(token, files[0], "sds"), + uploadToBox(token, files[1], "label"), + ]); + const [qa, sdsExtraction, labelExtraction] = await Promise.all([ + askBox(token, sdsFile), + extractBoxMetadata(token, sdsFile, SDS_FIELDS), + extractBoxMetadata(token, labelFile, LABEL_FIELDS), + ]); + const decision = decideCompliance(sdsExtraction.answer, labelExtraction.answer); + + await Promise.all([ + applyGlobalProperties(token, sdsFile, { + ...sdsExtraction.answer, + browserbaseSessionId: sessionId, + documentRole: "safety_data_sheet", + complianceStatus: decision.status, + sourceUrl: sources[0].pageUrl, + }), + applyGlobalProperties(token, labelFile, { + ...labelExtraction.answer, + browserbaseSessionId: sessionId, + documentRole: "product_label", + complianceStatus: decision.status, + sourceUrl: sources[1].pageUrl, + }), + ]); + + console.log("\n=== Box AI Q&A ==="); + console.log(qa.answer); + if (qa.citations?.length) { + console.log("Citations:"); + for (const citation of qa.citations) { + console.log(`- ${citation.name ?? citation.id}: ${citation.content ?? ""}`); + } + } + + console.log("\n=== Extracted SDS metadata ==="); + console.log(JSON.stringify(sdsExtraction, null, 2)); + console.log("\n=== Extracted label metadata ==="); + console.log(JSON.stringify(labelExtraction, null, 2)); + console.log("\n=== Compliance decision ==="); + console.log(JSON.stringify(decision, null, 2)); + console.log("\nBox files:"); + console.log(`- https://app.box.com/file/${sdsFile.id}`); + console.log(`- https://app.box.com/file/${labelFile.id}`); + } finally { + await stagehand.close(); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/packages/examples/box/tsconfig.json b/packages/examples/box/tsconfig.json new file mode 100644 index 0000000000..c62c303421 --- /dev/null +++ b/packages/examples/box/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/examples/browserbase-reducto/README.md b/packages/examples/browserbase-reducto/README.md new file mode 100644 index 0000000000..224386867a --- /dev/null +++ b/packages/examples/browserbase-reducto/README.md @@ -0,0 +1,12 @@ +# browserbase-reducto + +Public Apple investor documents downloaded with Stagehand and parsed with Reducto; keys come from the environment. + +Choose a language and follow its setup instructions: + +| Language | Stagehand dependency | Directory from the repository root | +| ---------------------------------- | -------------------- | -------------------------------------------------- | +| [TypeScript](typescript/README.md) | `4.0.0` | `packages/examples/browserbase-reducto/typescript` | +| [Python](python/README.md) | `4.0.0` | `packages/examples/browserbase-reducto/python` | + +Each variant installs the published Stagehand package and keeps its own dependencies. diff --git a/packages/examples/browserbase-reducto/python/README.md b/packages/examples/browserbase-reducto/python/README.md new file mode 100644 index 0000000000..46a40d8e57 --- /dev/null +++ b/packages/examples/browserbase-reducto/python/README.md @@ -0,0 +1,86 @@ +# Stagehand + Browserbase + Reducto: Download PDFs and Extract Financial Data + +Location in the Stagehand repository: `packages/examples/browserbase-reducto/python`. + +Stagehand is the SDK for browser agents. + +## AT A GLANCE + +- **Goal**: Automate downloading financial PDFs from websites and extract structured data using AI-powered document parsing. +- **Pattern Template**: Demonstrates the integration pattern of Browserbase (download automation) + Reducto (document extraction). +- **Workflow**: Uses Stagehand to navigate websites, Browserbase automatically downloads PDFs when opened, then Reducto extracts structured financial data using schema-based extraction. +- **Download Handling**: Implements retry logic with polling to handle Browserbase's async download sync (files sync to cloud storage in real-time). +- **Structured Extraction**: Uses Reducto's extract API with JSON schema to pull specific financial metrics from complex PDF tables. +- Docs โ†’ [Browserbase Downloads](https://docs.browserbase.com/features/downloads) | [Reducto Extract](https://docs.reducto.ai/parse/best-practices) + +## GLOSSARY + +- **act**: perform UI actions from natural language prompts (click, scroll, navigate) + Docs โ†’ https://docs.stagehand.dev/v4/basics/act +- **Browserbase Downloads**: When a PDF URL is opened in a browser session, Browserbase automatically downloads and stores it in cloud storage. Files must be retrieved via the Session Downloads API as a ZIP archive. + Docs โ†’ https://docs.browserbase.com/features/downloads +- **Reducto Extract**: Extract structured data from PDFs using JSON schema definitions. More efficient than parsing entire documents when you only need specific fields. + Docs โ†’ https://docs.reducto.ai/extract +- **Schema-based extraction**: Define the exact structure you want extracted (fields, types, descriptions) and Reducto returns JSON matching your schema. +- **Download polling**: Browserbase syncs downloads in real-time; larger files may need retry logic to ensure availability via the API. + +## QUICKSTART + +1. cd packages/examples/browserbase-reducto/python +2. Install dependencies with uv: + + ```bash + uv pip install -e . + ``` + + This will install all dependencies from `pyproject.toml`. + + Alternatively, use uvx to run without installation: + + ```bash + uvx --with browserbase --with reductoai --with stagehand-ai --with python-dotenv python main.py + ``` + +3. cp .env.example .env +4. Add required API keys to .env: + - `BROWSERBASE_API_KEY` + - `REDUCTOAI_API_KEY` +5. Run the script: + ```bash + python main.py + ``` + Or with uv: + ```bash + uv run python main.py + ``` + +## EXPECTED OUTPUT + +- Initializes Stagehand session with Browserbase and displays live view link +- Navigates to Apple.com investor relations section +- Clicks through to Q4 financial statements +- Browserbase automatically downloads PDF when link is opened +- Polls Browserbase Downloads API until file is ready (with retry logic) +- Extracts PDF from ZIP archive downloaded from Browserbase +- Uploads PDF to Reducto and extracts structured iPhone net sales data +- Outputs extracted financial data as formatted JSON +- Closes session cleanly + +## NEXT STEPS + +โ€ข **Parameterize extraction**: Accept different schema definitions or document types as configuration to extract various financial metrics or data structures. +โ€ข **Batch processing**: Process multiple quarters or companies by looping through different navigation paths and extracting data for each. +โ€ข **Multi-document support**: Handle ZIP archives with multiple PDFs and extract data from each, aggregating results into a unified dataset. +โ€ข **Optimize extraction**: Use Reducto's agentic mode selectively (only for complex tables or low-quality scans) to reduce latency and credit usage. Enable `scope: "table"` only when tables are misaligned or have merged cells. +Docs โ†’ https://docs.reducto.ai/parse/best-practices#2-enable-agentic-mode-only-when-needed + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐Ÿ“š Browserbase Downloads: https://docs.browserbase.com/features/downloads +๐Ÿ“š Reducto Best Practices: https://docs.reducto.ai/parse/best-practices +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/browserbase-reducto/python/main.py b/packages/examples/browserbase-reducto/python/main.py new file mode 100644 index 0000000000..878796d035 --- /dev/null +++ b/packages/examples/browserbase-reducto/python/main.py @@ -0,0 +1,202 @@ +"""Download Apple's FY2025 Q4 statement and extract sales with Reducto.""" + +import asyncio +import json +import os +import time +import zipfile +from pathlib import Path +from typing import Any + +from browserbase import AsyncBrowserbase +from dotenv import load_dotenv +from pydantic import BaseModel, HttpUrl +from reducto import Reducto +from stagehand import Stagehand, browserbase + +load_dotenv() + + +class StatementLink(BaseModel): + statement_url: HttpUrl + + +async def save_downloads_with_retry( + client: AsyncBrowserbase, + session_id: str, + retry_for_seconds: int = 60, +) -> int: + started = time.monotonic() + while time.monotonic() - started < retry_for_seconds: + response = await client.sessions.downloads.list(session_id) + payload = await response.read() + if len(payload) > 100: + Path("downloaded_files.zip").write_bytes(payload) + print(f"Saved downloaded_files.zip ({len(payload)} bytes)") + return len(payload) + await asyncio.sleep(2) + raise TimeoutError("Download timeout exceeded") + + +def extract_pdf_from_zip( + zip_path: str, + output_dir: str = "downloaded_files", +) -> Path: + destination = Path(output_dir).resolve() + destination.mkdir(parents=True, exist_ok=True) + first_pdf: Path | None = None + + with zipfile.ZipFile(zip_path) as archive: + entries = [name for name in archive.namelist() if not name.endswith("/")] + for entry in entries: + with archive.open(entry) as source: + payload = source.read() + if not payload.startswith(b"%PDF"): + continue + output_name = entry if entry.lower().endswith(".pdf") else f"{entry}.pdf" + output = (destination / output_name).resolve() + if destination not in output.parents: + raise RuntimeError(f"Unsafe ZIP entry: {entry}") + output.parent.mkdir(parents=True, exist_ok=True) + with output.open("wb") as target: + target.write(payload) + first_pdf = first_pdf or output + + if first_pdf is None: + raise RuntimeError("Failed to extract a PDF") + return first_pdf + + +async def extract_pdf_with_reducto(pdf_path: Path, reducto_client: Reducto) -> dict[str, Any]: + upload = await asyncio.to_thread(reducto_client.upload, file=pdf_path) + print("Uploaded statement to Reducto") + schema = { + "type": "object", + "properties": { + "iphone_net_sales": { + "type": "object", + "properties": { + "current_quarter": {"type": "number"}, + "previous_quarter": {"type": "number"}, + "current_year": {"type": "number"}, + "previous_year": {"type": "number"}, + "current_quarter_date": {"type": "string"}, + "previous_quarter_date": {"type": "string"}, + }, + "required": [ + "current_quarter", + "previous_quarter", + "current_year", + "previous_year", + "current_quarter_date", + "previous_quarter_date", + ], + } + }, + "required": ["iphone_net_sales"], + } + response = await asyncio.to_thread( + reducto_client.extract.run, + input=upload, + instructions={ + "schema": schema, + "system_prompt": ( + "Extract the iPhone net sales values from the net sales by " + "reportable segment table in this financial statement." + ), + }, + settings={ + "optimize_for_latency": True, + "citations": {"numerical_confidence": False}, + }, + ) + + extracted: Any = getattr(response, "result", response) + if isinstance(extracted, list): + extracted = extracted[0] if extracted else None + if hasattr(extracted, "model_dump"): + extracted = extracted.model_dump(mode="json") + return extracted + + +async def main() -> None: + browserbase_key = os.environ.get("BROWSERBASE_API_KEY") + reducto_key = os.environ.get("REDUCTOAI_API_KEY") + if not browserbase_key or not reducto_key: + raise RuntimeError("BROWSERBASE_API_KEY and REDUCTOAI_API_KEY are required") + + api = AsyncBrowserbase(api_key=browserbase_key) + reducto = Reducto(api_key=reducto_key) + browser = await browserbase.launch(api_key=browserbase_key) + session_id = browser.session_id + if not session_id: + await browser.close() + raise RuntimeError("Browserbase launch did not return a session ID") + + try: + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + await page.goto("https://www.apple.com/", wait_until="domcontentloaded", timeout=60_000) + await stagehand.act( + "Click the Investors button at the bottom of the page", + page=page, + ) + await stagehand.act( + "Scroll down to the Financial Data section", + page=page, + ) + await stagehand.act( + "Under Quarterly Earnings Reports, click 2025", + page=page, + ) + page = await browser.context.active_page() or page + extracted = await stagehand.extract( + ( + "Extract the actual absolute HTTP(S) href URL of the FY2025 Q4 Financial " + "Statements PDF." + ), + StatementLink, + page=page, + ) + statement_url = str(extracted.data.statement_url) + + opened_statement = await stagehand.act( + "Click the Financial Statements link under Q4", + page=page, + ) + if not opened_statement.data.success: + encoded_url = json.dumps(statement_url) + await page.evaluate( + f"""(() => {{ + const link = document.createElement('a'); + link.href = {encoded_url}; + link.target = '_blank'; + document.body.appendChild(link); + link.click(); + link.remove(); + }})()""" + ) + print("Triggered FY2025 Q4 statement download") + await save_downloads_with_retry(api, session_id) + pdf_path = extract_pdf_from_zip("downloaded_files.zip") + extracted = await extract_pdf_with_reducto(pdf_path, reducto) + print(json.dumps(extracted, indent=2)) + finally: + await stagehand.close() + finally: + await browser.close() + await api.close() + print("Session closed successfully") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as error: + print(f"Application error: {error}") + print("Docs: https://docs.stagehand.dev/v4/first-steps/introduction") + raise diff --git a/packages/examples/browserbase-reducto/python/pyproject.toml b/packages/examples/browserbase-reducto/python/pyproject.toml new file mode 100644 index 0000000000..c1b8e1f8d3 --- /dev/null +++ b/packages/examples/browserbase-reducto/python/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "browserbase-reducto" +version = "0.1.0" +description = "Download Apple's Q4 Financial Statement and Parse with Reducto using Stagehand and Browserbase" +readme = "README.md" +requires-python = ">=3.11,<3.14" +dependencies = ["browserbase>=1.7.0", "python-dotenv", "reductoai", "stagehand==4.0.0"] + +[project.optional-dependencies] +dev = ["pytest>=7.0.0", "black>=23.0.0", "ruff>=0.1.0"] + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 100 +target-version = ['py39', 'py310', 'py311'] + +[tool.ruff] +line-length = 100 +target-version = "py39" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] diff --git a/packages/examples/browserbase-reducto/typescript/.env.example b/packages/examples/browserbase-reducto/typescript/.env.example new file mode 100644 index 0000000000..f57f77866e --- /dev/null +++ b/packages/examples/browserbase-reducto/typescript/.env.example @@ -0,0 +1,5 @@ +# Browserbase Configuration +BROWSERBASE_API_KEY= + +# Reducto Configuration +REDUCTOAI_API_KEY= diff --git a/packages/examples/browserbase-reducto/typescript/README.md b/packages/examples/browserbase-reducto/typescript/README.md new file mode 100644 index 0000000000..a70f1b6d0a --- /dev/null +++ b/packages/examples/browserbase-reducto/typescript/README.md @@ -0,0 +1,64 @@ +# Stagehand + Browserbase + Reducto: Download PDFs and Extract Financial Data + +Location in the Stagehand repository: `packages/examples/browserbase-reducto/typescript`. + +## AT A GLANCE + +- **Goal**: Automate downloading financial PDFs from websites and extract structured data using AI-powered document parsing. +- **Pattern Template**: Demonstrates the integration pattern of Browserbase (download automation) + Reducto (document extraction). +- **Workflow**: Uses Stagehand `act()` and `extract()` to find Apple's FY2025 Q4 statement, Browserbase captures the PDF when opened, then Reducto extracts structured financial data with a schema. +- **Download Handling**: Implements retry logic with polling to handle Browserbase's async download sync (files sync to cloud storage in real-time). +- **Structured Extraction**: Uses Reducto's extract API with JSON schema to pull specific financial metrics from complex PDF tables. +- Docs โ†’ [Browserbase Downloads](https://docs.browserbase.com/features/downloads) | [Reducto Extract](https://docs.reducto.ai/parse/best-practices) + +## GLOSSARY + +- **act / extract**: navigate investor relations semantically and discover the intended statement URL + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- **Browserbase Downloads**: When a PDF URL is opened in a browser session, Browserbase automatically downloads and stores it in cloud storage. Files must be retrieved via the Session Downloads API as a ZIP archive. + Docs โ†’ https://docs.browserbase.com/features/downloads +- **Reducto Extract**: Extract structured data from PDFs using JSON schema definitions. More efficient than parsing entire documents when you only need specific fields. + Docs โ†’ https://docs.reducto.ai/extract +- **Schema-based extraction**: Define the exact structure you want extracted (fields, types, descriptions) and Reducto returns JSON matching your schema. +- **Download polling**: Browserbase syncs downloads in real-time; larger files may need retry logic to ensure availability via the API. + +## QUICKSTART + +1. cd packages/examples/browserbase-reducto/typescript +2. pnpm install +3. cp .env.example .env +4. Add required API keys to .env: + - `BROWSERBASE_API_KEY` + - `REDUCTOAI_API_KEY` +5. pnpm start + +## EXPECTED OUTPUT + +- Initializes Stagehand V4 with a Browserbase browser; Live View remains available in the Sessions dashboard +- Uses `act()` and `extract()` to find and validate the FY2025 Q4 PDF URL +- Opens the statement to trigger Browserbase PDF capture +- Browserbase automatically downloads PDF when link is opened +- Polls Browserbase Downloads API until file is ready (with retry logic) +- Extracts PDF from ZIP archive downloaded from Browserbase +- Uploads PDF to Reducto and extracts structured iPhone net sales data +- Outputs extracted financial data as formatted JSON +- Closes session cleanly + +## NEXT STEPS + +โ€ข **Parameterize extraction**: Accept different schema definitions or document types as configuration to extract various financial metrics or data structures. +โ€ข **Batch processing**: Process multiple quarters or companies by looping through different navigation paths and extracting data for each. +โ€ข **Multi-document support**: Handle ZIP archives with multiple PDFs and extract data from each, aggregating results into a unified dataset. +โ€ข **Optimize extraction**: Use Reducto's agentic mode selectively (only for complex tables or low-quality scans) to reduce latency and credit usage. Enable `scope: "table"` only when tables are misaligned or have merged cells. +Docs โ†’ https://docs.reducto.ai/parse/best-practices#2-enable-agentic-mode-only-when-needed + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐Ÿ“š Browserbase Downloads: https://docs.browserbase.com/features/downloads +๐Ÿ“š Reducto Best Practices: https://docs.reducto.ai/parse/best-practices +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/browserbase-reducto/typescript/index.ts b/packages/examples/browserbase-reducto/typescript/index.ts new file mode 100644 index 0000000000..49e9470740 --- /dev/null +++ b/packages/examples/browserbase-reducto/typescript/index.ts @@ -0,0 +1,348 @@ +// Stagehand + Browserbase: Download Apple's Q4 Financial Statement and Parse with Reducto - See README.md for full documentation + +import "dotenv/config"; +import { Browserbase } from "@browserbasehq/sdk"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import fs from "fs"; +import path from "path"; +import reductoai from "reductoai"; +import AdmZip from "adm-zip"; +import { z } from "zod/v4"; + +// Net sales data structure extracted from financial statements +interface IPhoneNetSales { + current_quarter: number; + previous_quarter: number; + current_year: number; + previous_year: number; + current_quarter_date?: string; + previous_quarter_date?: string; +} + +// Complete financial data structure returned from Reducto extraction +interface ExtractedFinancialData { + iphone_net_sales: IPhoneNetSales; +} + +// Reducto API response structure +interface ReductoExtractResult { + result?: ExtractedFinancialData | ExtractedFinancialData[]; +} + +// Polls Browserbase API for completed downloads with retry logic +function saveDownloadsWithRetry( + bb: Browserbase, + sessionId: string, + retryForSeconds: number = 30, +): { promise: Promise; stopPolling: () => void } { + // Track polling intervals and timeout for cleanup + const intervals = { + poller: undefined as NodeJS.Timeout | undefined, + timeout: undefined as NodeJS.Timeout | undefined, + isStopped: false, + }; + + // Cleanup function to stop all polling and timeouts + const stopPolling = (): void => { + if (intervals.isStopped) return; + intervals.isStopped = true; + if (intervals.poller) { + clearInterval(intervals.poller); + intervals.poller = undefined; + } + if (intervals.timeout) { + clearTimeout(intervals.timeout); + intervals.timeout = undefined; + } + }; + + const promise = new Promise((resolve, reject) => { + console.log(`Waiting up to ${retryForSeconds} seconds for downloads to complete...`); + + // Fetch downloads from Browserbase API and save to disk when ready + async function fetchDownloads(): Promise { + if (intervals.isStopped) { + return; + } + + try { + console.log("Checking for downloads..."); + const response = await bb.sessions.downloads.list(sessionId); + const downloadBuffer: ArrayBuffer = await response.arrayBuffer(); + + // Save downloads to disk when file size indicates content is available + if (downloadBuffer.byteLength > 0) { + console.log(`Downloads ready! File size: ${downloadBuffer.byteLength} bytes`); + fs.writeFileSync("downloaded_files.zip", Buffer.from(downloadBuffer)); + console.log("Files saved as: downloaded_files.zip"); + + stopPolling(); + resolve(downloadBuffer.byteLength); + } else { + console.log("Downloads not ready yet, retrying..."); + } + } catch (e: unknown) { + if (intervals.isStopped) { + return; + } + // Handle session not found errors gracefully (session may have expired) + const errorMessage = e instanceof Error ? e.message : String(e); + if ( + errorMessage.includes("Session with given id not found") || + errorMessage.includes("-32001") + ) { + stopPolling(); + resolve(0); + return; + } + console.error("Error fetching downloads:", e); + stopPolling(); + reject(e); + } + } + + // Set timeout to fail if downloads don't complete within retry window + intervals.timeout = setTimeout(() => { + if (!intervals.isStopped) { + stopPolling(); + reject(new Error("Download timeout exceeded")); + } + }, retryForSeconds * 1000); + + // Poll every 2 seconds to check if downloads are ready + intervals.poller = setInterval(fetchDownloads, 2000); + }); + + return { promise, stopPolling }; +} + +// Extracts PDF files from downloaded zip archive +function extractPdfFromZip(zipPath: string, outputDir: string = "downloaded_files"): string { + console.log(`Extracting PDF from ${zipPath}...`); + + // Create output directory if it doesn't exist + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // Open zip file and filter for PDF entries only + const zip = new AdmZip(zipPath); + const pdfEntries = zip.getEntries().filter((entry: AdmZip.IZipEntry) => { + if (entry.isDirectory) return false; + return ( + entry.entryName.toLowerCase().endsWith(".pdf") || + entry.getData().subarray(0, 5).toString() === "%PDF-" + ); + }); + + if (pdfEntries.length === 0) { + throw new Error("No PDF files found in the downloaded zip"); + } + + // Extract all PDF files and return path to first one + let pdfPath: string | null = null; + for (const entry of pdfEntries) { + const outputName = entry.entryName.toLowerCase().endsWith(".pdf") + ? entry.entryName + : `${entry.entryName}.pdf`; + const resolvedOutputDir = path.resolve(outputDir); + const outputPath = path.resolve(resolvedOutputDir, outputName); + if (!outputPath.startsWith(`${resolvedOutputDir}${path.sep}`)) { + throw new Error(`Refusing to extract a zip entry outside ${outputDir}: ${entry.entryName}`); + } + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, entry.getData()); + console.log(`Extracted: ${outputPath}`); + + if (!pdfPath) { + pdfPath = outputPath; + } + } + + if (!pdfPath) { + throw new Error("Failed to extract PDF file"); + } + + return pdfPath; +} + +// Uploads PDF to Reducto and extracts structured financial data +async function extractPDFWithReducto(pdfPath: string, reductoaiClient: reductoai): Promise { + console.log(`\nExtracting financial data with Reducto: ${pdfPath}...`); + + // Upload PDF to Reducto for processing + const upload = await reductoaiClient.upload({ + file: fs.createReadStream(pdfPath), + }); + console.log(`Uploaded to Reducto: ${upload.file_id}`); + + // Define JSON schema to extract iPhone net sales from financial statements + const schema = { + type: "object", + properties: { + iphone_net_sales: { + type: "object", + properties: { + current_quarter: { + type: "number", + description: "iPhone net sales for the current quarter (in millions)", + }, + previous_quarter: { + type: "number", + description: "iPhone net sales for the previous quarter (in millions)", + }, + current_year: { + type: "number", + description: "iPhone net sales for the current year (in millions)", + }, + previous_year: { + type: "number", + description: "iPhone net sales for the previous year (in millions)", + }, + current_quarter_date: { + type: "string", + description: "Date or period label for the current quarter", + }, + previous_quarter_date: { + type: "string", + description: "Date or period label for the previous quarter", + }, + }, + required: [ + "current_quarter", + "previous_quarter", + "current_year", + "previous_year", + "current_quarter_date", + "previous_quarter_date", + ], + description: "iPhone net sales values from the financial statements", + }, + }, + required: ["iphone_net_sales"], + }; + + // Extract structured data using Reducto's AI extraction with schema + const result = (await reductoaiClient.extract.run({ + input: upload.file_id, + instructions: { + schema: schema, + system_prompt: + "Extract the iPhone net sales values from the financial statements. Find the iPhone line item in the net sales by category table and extract the values for current quarter, previous quarter, current year, and previous year (typically shown in columns in the income statement or operations statement).", + }, + settings: { + optimize_for_latency: true, + citations: { + numerical_confidence: false, + }, + }, + })) as ReductoExtractResult; + + // Display extracted financial data in formatted JSON + console.log("\n=== Extracted Financial Data ===\n"); + // Reducto's synchronous V3 extraction response returns a list even when + // chunking is disabled, so unwrap the single structured result. + const extractedData = Array.isArray(result.result) ? result.result[0] : result.result; + console.log(JSON.stringify(extractedData, null, 2)); +} + +async function main(): Promise { + console.log("Starting Apple Q4 Financial Statement Download and Parse Automation..."); + + // Initialize Browserbase SDK for session management and download retrieval + const bb = new Browserbase({ + apiKey: process.env.BROWSERBASE_API_KEY as string, + }); + + if (!process.env.REDUCTOAI_API_KEY) throw new Error("REDUCTOAI_API_KEY is required"); + + // Initialize Reducto AI client for PDF data extraction + const reductoaiClient = new reductoai({ + apiKey: process.env.REDUCTOAI_API_KEY, + }); + + // Initialize Stagehand with Browserbase for cloud-based browser automation + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + }); + const sessionId = browser.sessionId; + if (!sessionId) throw new Error("Browserbase launch did not return a session ID"); + const stagehand = await Stagehand.create({ + browser: browser, + model: { modelName: "google/gemini-2.5-pro" }, + logging: { level: "error" }, + }); + + try { + // Initialize browser session to start automation + + console.log("Stagehand initialized successfully!"); + let page = (await browser.context.pages())[0]; + + console.log("Live View is available in the Browserbase Sessions dashboard"); + + console.log("Navigating to Apple.com..."); + await page.goto("https://www.apple.com/", { waitUntil: "domcontentloaded", timeout: 60000 }); + await stagehand.act("Click the 'Investors' button at the bottom of the page"); + await stagehand.act("Scroll down to the Financial Data section of the page"); + await stagehand.act("Under Quarterly Earnings Reports, click on '2025'"); + page = (await browser.context.activePage()) ?? page; + const { data: statement } = await stagehand.extract( + "Extract the actual absolute HTTP(S) href URL of the FY2025 Q4 Financial Statements PDF.", + z.object({ statementUrl: z.string().url() }), + ); + const statementUrl = statement.statementUrl; + const openedStatement = await stagehand.act("Click the Financial Statements link under Q4", { + page, + }); + if (!openedStatement.data.success) { + await page.evaluate((url: string) => { + const link = document.createElement("a"); + link.href = url; + link.target = "_blank"; + document.body.appendChild(link); + link.click(); + link.remove(); + }, statementUrl); + } + console.log("Triggered FY2025 Q4 financial statement download"); + + // Retrieve all downloads triggered during this session from Browserbase API + console.log("Retrieving downloads from Browserbase..."); + const { promise: downloadPromise, stopPolling } = saveDownloadsWithRetry(bb, sessionId, 45); + + try { + const downloadSize = await downloadPromise; + if (downloadSize <= 0) throw new Error("Browserbase returned no downloaded statement"); + console.log("Download completed successfully!"); + stopPolling(); + + // Extract PDF from downloaded zip archive + const pdfPath = extractPdfFromZip("downloaded_files.zip"); + console.log(`PDF extracted to: ${pdfPath}`); + + // Extract structured financial data using Reducto AI + await extractPDFWithReducto(pdfPath, reductoaiClient); + } catch (error) { + stopPolling(); + throw error; + } + } catch (error) { + console.error("Error during automation:", error); + throw error; + } finally { + await stagehand.close().catch((error) => console.warn("Stagehand cleanup warning:", error)); + await browser.close().catch((error) => console.warn("Browser cleanup warning:", error)); + console.log("Session closed successfully"); + } +} + +main().catch((err) => { + console.error("Application error:", err); + console.error("Common issues:"); + console.error(" - Check .env file has BROWSERBASE_API_KEY and REDUCTOAI_API_KEY"); + console.error(" - Verify internet connection and Apple website accessibility"); + console.error(" - Ensure sufficient timeout for slow-loading pages"); + console.error("Docs: https://docs.stagehand.dev/v4/first-steps/introduction"); + process.exit(1); +}); diff --git a/packages/examples/browserbase-reducto/typescript/package.json b/packages/examples/browserbase-reducto/typescript/package.json new file mode 100644 index 0000000000..9fd54bf6ce --- /dev/null +++ b/packages/examples/browserbase-reducto/typescript/package.json @@ -0,0 +1,27 @@ +{ + "name": "browserbase-reducto", + "version": "1.0.0", + "description": "Stagehand + Browserbase: Download Apple's Q4 Financial Statement and Parse with Reducto", + "type": "module", + "main": "index.ts", + "scripts": { + "start": "tsx index.ts" + }, + "dependencies": { + "@browserbasehq/sdk": "^2.9.0", + "@browserbasehq/stagehand": "4.0.0", + "adm-zip": "latest", + "dotenv": "latest", + "reductoai": "latest", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/node": "latest", + "tsx": "latest", + "typescript": "latest" + }, + "engines": { + "node": ">=22.18.0" + }, + "packageManager": "pnpm@10.24.0" +} diff --git a/packages/examples/caching-with-variables/.env.example b/packages/examples/caching-with-variables/.env.example new file mode 100644 index 0000000000..e81139f0a5 --- /dev/null +++ b/packages/examples/caching-with-variables/.env.example @@ -0,0 +1,9 @@ +# OpenAI API Key (required for LLM calls) +OPENAI_API_KEY= + +# Anthropic API Key (optional, for Claude models) +ANTHROPIC_API_KEY= + +# Browserbase credentials (optional, for BROWSERBASE env) +BROWSERBASE_API_KEY= +BROWSERBASE_PROJECT_ID= diff --git a/packages/examples/caching-with-variables/.gitignore b/packages/examples/caching-with-variables/.gitignore new file mode 100644 index 0000000000..3e7467a42e --- /dev/null +++ b/packages/examples/caching-with-variables/.gitignore @@ -0,0 +1,24 @@ +# Dependencies +node_modules/ + +# Environment variables +.env +.env.local +.env.*.local + +# Build outputs +dist/ +build/ + +# Logs +*.log + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store + +# Claude +.claude/ diff --git a/packages/examples/caching-with-variables/README.md b/packages/examples/caching-with-variables/README.md new file mode 100644 index 0000000000..46fae3157b --- /dev/null +++ b/packages/examples/caching-with-variables/README.md @@ -0,0 +1,229 @@ +# Stagehand Caching with Variables Demo + +Location in the Stagehand repository: `packages/examples/caching-with-variables`. + +This demo showcases Stagehand's caching mechanism, specifically demonstrating: + +1. **Privacy-Preserving Caching**: Variable VALUES are NOT stored in cache, only variable KEYS +2. **Cache Efficiency**: Different variable values still hit the same cache entry +3. **Agent Caching**: Testing whether `agent()` uses caching (it does!) + +## Quick Start + +```bash +# Install dependencies +npm install + +# Copy environment variables +cp .env.example .env +# Add your OPENAI_API_KEY to .env + +# Run Demo 1: Act caching with variables +npm run demo:act-cache + +# Run Demo 2: Agent caching test +npm run demo:agent-cache + +# Run both demos +npm run demo:all +``` + +## How Stagehand Caching Works + +### Act Caching with Variables + +The key insight is that **cache keys are based on variable KEYS, not VALUES**. + +```typescript +// This is how the cache key is generated: +cacheKey = hash({ + instruction: "Type %username% into the username field", + url: "https://example.com/login", + variableKeys: ["username"], // Only KEYS, not values! +}); +``` + +This means: + +- Running with `username = "john@example.com"` creates a cache entry +- Running with `username = "secret@example.com"` **HITS THE SAME CACHE** +- The actual username value is **NEVER STORED** in the cache file + +### What's Stored in Cache + +```json +{ + "version": 1, + "instruction": "Type %username% into the username field", + "url": "https://the-internet.herokuapp.com/login", + "variableKeys": ["username"], + "actions": [ + { + "method": "fill", + "selector": "#username", + "arguments": ["%username%"] + } + ] +} +``` + +Notice: + +- The `instruction` contains the placeholder `%username%` +- The `variableKeys` array lists which variables are expected +- The `arguments` contain `%username%` placeholder, NOT the actual value +- **No actual username values in the cache file!** + +### At Replay Time + +When a cache hit occurs: + +1. Cached action is loaded (with `%username%` placeholder) +2. Current variable values are substituted +3. Action is executed with real values +4. No LLM call needed! + +## Demo 1: Act Caching with Variables + +This demo runs the same `act()` command with three different usernames: + +``` +Run 1: john.doe@example.com -> Cache MISS (LLM inference) +Run 2: john.doe@example.com -> Cache HIT (instant) +Run 3: jane.smith@example.com -> Cache HIT (still works!) +Run 4: secret.user@example.com -> Cache HIT (still works!) +``` + +**Key Takeaway**: Different usernames still hit the same cache because only the variable KEY is part of the cache key. + +## Demo 2: Agent Caching Test + +Tests whether `stagehand.agent()` uses caching: + +- **Result**: YES, agent has full caching support +- Stores entire workflow (all steps) +- Replays steps without LLM calls + +However, **agent does NOT support variables** like `act()` does. Variable data in agent instructions is stored in cache as-is. + +## Comparison Table + +| Feature | `act()` Cache | `agent()` Cache | +| -------------------- | -------------------------------- | ------------------------------------ | +| Caching supported | Yes | Yes | +| Variables supported | Yes | **No** | +| Privacy preservation | **Yes** | No | +| Multi-step workflows | Single action | Full workflow | +| Cache key based on | instruction + url + variableKeys | instruction + url + options + config | + +## Performance Benefits + +| Scenario | Time per Action | Cost | +| ---------------- | --------------- | ------------ | +| Cache MISS (LLM) | 1-3 seconds | ~$0.001-0.01 | +| Cache HIT | <100ms | $0 | + +For high-volume automation: + +- 10,000 payments ร— 5 actions = 50,000 actions +- Without cache: ~$500-2,500, 15+ hours +- With cache: ~$0.50 (10 cache populating runs), <1 hour + +## Privacy Use Cases + +The variable caching mechanism is perfect for: + +1. **Login Forms**: Different usernames, same cached action +2. **Payment Forms**: Different card numbers, never stored in cache +3. **Search Inputs**: Different queries, cached navigation +4. **Form Filling**: Sensitive data never persisted + +```typescript +// Example: Login with sensitive credentials +await page.act("Type %email% into the email field", { + variables: { email: "sensitive@example.com" }, +}); + +await page.act("Type %password% into the password field", { + variables: { password: "super-secret-password" }, +}); + +// Cache files will contain: +// - %email% placeholder (not the actual email) +// - %password% placeholder (not the actual password) +``` + +## Future: Agent Cache with Variables + +Currently, agent does NOT support variables. To add this functionality would require: + +1. Variable placeholder syntax in agent instructions +2. Stripping variable values from cached steps +3. Value injection during replay +4. Cache key based on variable keys (like act) + +This demo sets up the testing framework for when/if this feature is added. + +## File Structure + +``` +stagehand-caching-demo/ +โ”œโ”€โ”€ package.json +โ”œโ”€โ”€ tsconfig.json +โ”œโ”€โ”€ .env.example +โ”œโ”€โ”€ README.md +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ 01-act-cache-with-variables.ts # Demo 1 +โ”‚ โ””โ”€โ”€ 02-agent-cache-test.ts # Demo 2 +โ””โ”€โ”€ .cache/ # Cache directory (created on run) + โ”œโ”€โ”€ act-cache/ # Act cache files + โ””โ”€โ”€ agent-cache/ # Agent cache files +``` + +## Commands + +```bash +# Run demos +npm run demo:act-cache # Demo 1: Act with variables +npm run demo:agent-cache # Demo 2: Agent caching +npm run demo:all # Both demos + +# Cache management +npm run clear-cache # Clear all cache files + +# Run with fresh cache +npm run demo:act-cache -- --fresh +npm run demo:agent-cache -- --fresh + +# Inspect cache contents +cat .cache/act-cache/*.json | jq . +cat .cache/agent-cache/*.json | jq . +``` + +## Environment Variables + +| Variable | Required | Description | +| ------------------------ | -------- | ------------------------------------- | +| `OPENAI_API_KEY` | Yes | OpenAI API key for LLM calls | +| `ANTHROPIC_API_KEY` | No | Anthropic API key (for Claude models) | +| `BROWSERBASE_API_KEY` | No | For running in Browserbase cloud | +| `BROWSERBASE_PROJECT_ID` | No | For running in Browserbase cloud | + +## Troubleshooting + +### Cache not being hit + +1. Make sure you're using the same instruction text +2. Check that the URL matches (exact match required) +3. Verify variable keys are the same (order matters) + +### LLM errors + +1. Check your `OPENAI_API_KEY` is set correctly +2. Ensure you have API credits available +3. Try a different model if rate limited + +### Browser not launching + +1. Make sure Playwright is installed: `npx playwright install chromium` +2. Check you're running in LOCAL env (not BROWSERBASE without credentials) diff --git a/packages/examples/caching-with-variables/package.json b/packages/examples/caching-with-variables/package.json new file mode 100644 index 0000000000..b60a5b5205 --- /dev/null +++ b/packages/examples/caching-with-variables/package.json @@ -0,0 +1,24 @@ +{ + "name": "stagehand-caching-demo", + "version": "1.0.0", + "description": "Demo showcasing Stagehand caching with variables for privacy-preserving performance optimization", + "type": "module", + "scripts": { + "demo": "tsx src/act-cache-demo.ts", + "demo:act-cache": "tsx src/01-act-cache-with-variables.ts", + "demo:agent-cache": "tsx src/02-agent-cache-test.ts", + "demo:all": "npm run demo:act-cache && npm run demo:agent-cache", + "clear-cache": "rm -rf .cache && echo 'Cache cleared!'" + }, + "dependencies": { + "@browserbasehq/sdk": "latest", + "@browserbasehq/stagehand": "3.0.7", + "dotenv": "^16.4.5", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.7.0", + "typescript": "^5.3.0" + } +} diff --git a/packages/examples/caching-with-variables/src/01-act-cache-with-variables.ts b/packages/examples/caching-with-variables/src/01-act-cache-with-variables.ts new file mode 100644 index 0000000000..85da52ab10 --- /dev/null +++ b/packages/examples/caching-with-variables/src/01-act-cache-with-variables.ts @@ -0,0 +1,268 @@ +/** + * Demo 1: Stagehand Act Caching with Variables + * + * This demo demonstrates how Stagehand's caching works with variables, + * specifically showing that: + * + * 1. Variable VALUES are NOT stored in the cache (privacy preservation) + * 2. Only variable KEYS are stored in the cache + * 3. Cache still works effectively with different variable values + * 4. Significant performance improvement on subsequent runs + * + * How the cache key works: + * - Cache key = hash(instruction + URL + variableKeys) + * - Variable values are NOT part of the cache key + * - At replay time, current variable values are substituted + * + * Run this demo multiple times with different usernames to see: + * - First run: LLM inference (slow, ~2-3s per action) + * - Subsequent runs: Cache hit (fast, <100ms per action) + * - Cache file does NOT contain the actual username + */ + +import { Stagehand } from "@browserbasehq/stagehand"; +import * as fs from "fs"; +import * as path from "path"; +import "dotenv/config"; + +const CACHE_DIR = path.join(process.cwd(), ".cache", "act-cache"); + +// Different usernames to test with - demonstrates cache works with different values +const TEST_USERNAMES = [ + "john.doe@example.com", + "jane.smith@example.com", + "secret.user@example.com", +]; + +interface RunResult { + username: string; + elapsed: number; + cacheHit: boolean; +} + +async function runWithVariables(username: string, runNumber: number): Promise { + console.log(`\n${"=".repeat(60)}`); + console.log(`RUN ${runNumber}: Testing with username: ${username}`); + console.log("=".repeat(60)); + + const startTime = Date.now(); + + const stagehand = new Stagehand({ + env: "LOCAL", + verbose: 1, + model: "gpt-4o-mini", + cacheDir: CACHE_DIR, + }); + + await stagehand.init(); + const page = stagehand.context.pages()[0]; + + try { + // Using a simple form page for demonstration + console.log("\nNavigating to demo form..."); + await page.goto("https://the-internet.herokuapp.com/login"); + await page.waitForLoadState("domcontentloaded"); + + // Check if cache exists before this run + const cacheExistedBefore = fs.existsSync(CACHE_DIR) && fs.readdirSync(CACHE_DIR).length > 0; + + console.log(`\nCache before run: ${cacheExistedBefore ? "EXISTS" : "EMPTY"}`); + + // Use act() with variables - the %username% placeholder will be replaced + // with the actual value at runtime, but NOT stored in the cache + console.log("\nExecuting act() with variable..."); + console.log(` Instruction: Type %username% into the username field`); + console.log(` Variable: username = "${username}"`); + + const actStartTime = Date.now(); + + await stagehand.act("Type %username% into the username field", { + variables: { username }, + }); + + const actElapsed = Date.now() - actStartTime; + + // Determine if this was a cache hit based on timing + // LLM calls typically take 1-3 seconds, cache hits are <200ms + const cacheHit = actElapsed < 500; + + console.log(`\nAction completed in ${actElapsed}ms`); + console.log( + `Cache ${cacheHit ? "HIT" : "MISS"} (${cacheHit ? "instant replay" : "LLM inference"})`, + ); + + const elapsed = Date.now() - startTime; + + await stagehand.close(); + + return { + username, + elapsed, + cacheHit, + }; + } catch (error) { + console.error("Error:", error); + await stagehand.close(); + throw error; + } +} + +async function inspectCacheContents() { + console.log(`\n${"=".repeat(60)}`); + console.log("CACHE INSPECTION"); + console.log("=".repeat(60)); + + if (!fs.existsSync(CACHE_DIR)) { + console.log("\nNo cache directory found."); + return; + } + + const cacheFiles = fs.readdirSync(CACHE_DIR).filter((f) => f.endsWith(".json")); + console.log(`\nFound ${cacheFiles.length} cache file(s):`); + + for (const file of cacheFiles) { + const filePath = path.join(CACHE_DIR, file); + const content = JSON.parse(fs.readFileSync(filePath, "utf-8")); + + console.log(`\n--- ${file} ---`); + console.log(` Version: ${content.version}`); + console.log(` Instruction: "${content.instruction}"`); + console.log(` URL: ${content.url}`); + console.log(` Variable Keys: ${JSON.stringify(content.variableKeys)}`); + console.log(` Actions: ${content.actions?.length ?? 0} action(s)`); + + // Show action details + if (content.actions && content.actions.length > 0) { + for (const action of content.actions) { + console.log(` - Method: ${action.method}`); + console.log(` Arguments: ${JSON.stringify(action.arguments)}`); + console.log(` Selector: ${action.selector?.substring(0, 50)}...`); + } + } + + // IMPORTANT: Check if username value is in the cache + const cacheString = JSON.stringify(content); + const containsSecretUser = TEST_USERNAMES.some((u) => cacheString.includes(u)); + + console.log(`\n PRIVACY CHECK:`); + console.log( + ` Contains any test username value? ${containsSecretUser ? "YES (BAD!)" : "NO (GOOD!)"}`, + ); + console.log( + ` Variable placeholder preserved? ${cacheString.includes("%username%") ? "YES" : "NO"}`, + ); + } +} + +async function main() { + console.log(` +${"#".repeat(60)} +# Stagehand Act Caching with Variables Demo +# +# Demonstrates privacy-preserving cache mechanism: +# - Variable VALUES are NOT stored in cache +# - Only variable KEYS are stored +# - Cache works with different values +${"#".repeat(60)} +`); + + // Clear cache for fresh demo + const args = process.argv.slice(2); + if (args.includes("--fresh")) { + console.log("Clearing cache for fresh run..."); + if (fs.existsSync(CACHE_DIR)) { + fs.rmSync(CACHE_DIR, { recursive: true }); + } + } + + const results: RunResult[] = []; + + // Run 1: First username - should be cache MISS (LLM inference) + console.log("\n\n>>> PHASE 1: First run with first username (expect cache MISS)"); + results.push(await runWithVariables(TEST_USERNAMES[0], 1)); + + // Run 2: Same username again - should be cache HIT + console.log("\n\n>>> PHASE 2: Second run with same username (expect cache HIT)"); + results.push(await runWithVariables(TEST_USERNAMES[0], 2)); + + // Run 3: DIFFERENT username - should STILL be cache HIT! + // This proves that the cache key is based on variable KEYS, not VALUES + console.log("\n\n>>> PHASE 3: Run with DIFFERENT username (should STILL be cache HIT!)"); + console.log(">>> This proves variable VALUES are not part of the cache key"); + results.push(await runWithVariables(TEST_USERNAMES[1], 3)); + + // Run 4: Another different username + console.log("\n\n>>> PHASE 4: Run with yet another username (should be cache HIT)"); + results.push(await runWithVariables(TEST_USERNAMES[2], 4)); + + // Inspect cache contents + await inspectCacheContents(); + + // Summary + console.log(`\n${"=".repeat(60)}`); + console.log("RESULTS SUMMARY"); + console.log("=".repeat(60)); + + console.log("\n| Run | Username | Time (ms) | Cache |"); + console.log("|-----|-----------------------------|-----------| -------|"); + + for (let i = 0; i < results.length; i++) { + const r = results[i]; + const usernameDisplay = r.username.substring(0, 25).padEnd(27); + const timeDisplay = r.elapsed.toString().padStart(9); + const cacheDisplay = r.cacheHit ? "HIT " : "MISS"; + console.log(`| ${i + 1} | ${usernameDisplay} | ${timeDisplay} | ${cacheDisplay} |`); + } + + console.log(`\n${"=".repeat(60)}`); + console.log("KEY FINDINGS"); + console.log("=".repeat(60)); + + console.log(` +1. PRIVACY PRESERVED: + - Cache file does NOT contain actual username values + - Only the variable KEY "username" is stored + - Variable placeholder %username% is preserved in cache + +2. CACHE EFFICIENCY: + - First run: Cache miss (LLM inference required) + - All subsequent runs: Cache hit (instant replay) + - Different usernames still hit the same cache! + +3. HOW IT WORKS: + - Cache key = hash(instruction + URL + variableKeys) + - Variable VALUES are not part of the cache key + - At replay, current values replace %placeholder% tokens + +4. PERFORMANCE BENEFIT: + - Cache hit: <100ms per action + - Cache miss: 1-3 seconds per action (LLM call) + - ${results.length > 1 ? `Speedup: ~${Math.round(results[0].elapsed / results[1].elapsed)}x faster with cache` : ""} + +5. USE CASES: + - Login forms with different users + - Payment forms with different card numbers + - Search inputs with different queries + - Any action with sensitive/variable data +`); + + console.log(`\n${"=".repeat(60)}`); + console.log("TRY IT YOURSELF"); + console.log("=".repeat(60)); + + console.log(` +1. Run again (cache will be hit): + npm run demo:act-cache + +2. Run with fresh cache: + npm run demo:act-cache -- --fresh + +3. Clear cache manually: + npm run clear-cache + +4. Inspect cache contents: + cat .cache/act-cache/*.json | jq . +`); +} + +main().catch(console.error); diff --git a/packages/examples/caching-with-variables/src/02-agent-cache-test.ts b/packages/examples/caching-with-variables/src/02-agent-cache-test.ts new file mode 100644 index 0000000000..00eab96e2a --- /dev/null +++ b/packages/examples/caching-with-variables/src/02-agent-cache-test.ts @@ -0,0 +1,280 @@ +/** + * Demo 2: Stagehand Agent Caching Test + * + * This demo tests whether Stagehand's agent() API uses caching. + * + * Key findings from code analysis: + * - YES, Agent does have caching support (AgentCache.ts) + * - Agent cache stores: instruction, startUrl, options, configSignature, steps, result + * - Cache key = hash(instruction + startUrl + options + configSignature) + * - Agent does NOT currently support variables like act() does + * + * This demo: + * 1. Tests agent caching with a simple task + * 2. Runs the same task twice to observe cache behavior + * 3. Compares performance between first and second runs + * + * Future: Test agent with variables (if/when supported) + */ + +import { Stagehand } from "@browserbasehq/stagehand"; +import * as fs from "fs"; +import * as path from "path"; +import "dotenv/config"; + +const CACHE_DIR = path.join(process.cwd(), ".cache", "agent-cache"); + +interface RunResult { + runNumber: number; + elapsed: number; + stepCount: number; + success: boolean; + cacheHit: boolean; +} + +async function runAgentTask(runNumber: number): Promise { + console.log(`\n${"=".repeat(60)}`); + console.log(`AGENT RUN ${runNumber}`); + console.log("=".repeat(60)); + + const startTime = Date.now(); + + const stagehand = new Stagehand({ + env: "LOCAL", + verbose: 1, + model: "gpt-4o-mini", + cacheDir: CACHE_DIR, + }); + + await stagehand.init(); + const page = stagehand.context.pages()[0]; + + try { + // Check if cache exists before this run + const cacheExistedBefore = + fs.existsSync(CACHE_DIR) && + fs.readdirSync(CACHE_DIR).filter((f) => f.startsWith("agent-")).length > 0; + + console.log(`\nCache before run: ${cacheExistedBefore ? "EXISTS" : "EMPTY"}`); + + // Navigate to a simple page + console.log("\nNavigating to demo page..."); + await page.goto("https://the-internet.herokuapp.com/"); + await page.waitForLoadState("domcontentloaded"); + + // Create agent and execute a simple task + console.log("\nCreating agent..."); + const agent = stagehand.agent({ + model: "gpt-4o", // Agent requires a capable model + }); + + console.log("\nExecuting agent task..."); + console.log(` Instruction: "Click on the 'Form Authentication' link"`); + + const agentStartTime = Date.now(); + + const result = await agent.execute({ + instruction: "Click on the 'Form Authentication' link", + maxSteps: 5, + }); + + const agentElapsed = Date.now() - agentStartTime; + + // Determine if this was a cache hit based on timing and metadata + const cacheHit = (result as any).metadata?.cacheHit === true || agentElapsed < 1000; + + console.log(`\nAgent completed in ${agentElapsed}ms`); + console.log(`Success: ${result.success}`); + console.log(`Message: ${result.message}`); + console.log(`Steps: ${result.actions?.length ?? 0}`); + console.log(`Cache: ${cacheHit ? "HIT" : "MISS"}`); + + if ((result as any).metadata?.cacheHit) { + console.log(`Cache Timestamp: ${(result as any).metadata?.cacheTimestamp}`); + } + + const elapsed = Date.now() - startTime; + + await stagehand.close(); + + return { + runNumber, + elapsed, + stepCount: result.actions?.length ?? 0, + success: result.success, + cacheHit, + }; + } catch (error) { + console.error("Error:", error); + await stagehand.close(); + throw error; + } +} + +async function inspectAgentCache() { + console.log(`\n${"=".repeat(60)}`); + console.log("AGENT CACHE INSPECTION"); + console.log("=".repeat(60)); + + if (!fs.existsSync(CACHE_DIR)) { + console.log("\nNo cache directory found."); + return; + } + + const cacheFiles = fs.readdirSync(CACHE_DIR).filter((f) => f.startsWith("agent-")); + console.log(`\nFound ${cacheFiles.length} agent cache file(s):`); + + for (const file of cacheFiles) { + const filePath = path.join(CACHE_DIR, file); + const content = JSON.parse(fs.readFileSync(filePath, "utf-8")); + + console.log(`\n--- ${file} ---`); + console.log(` Version: ${content.version}`); + console.log(` Instruction: "${content.instruction}"`); + console.log(` Start URL: ${content.startUrl}`); + console.log(` Options: ${JSON.stringify(content.options)}`); + console.log(` Steps: ${content.steps?.length ?? 0} step(s)`); + console.log(` Timestamp: ${content.timestamp}`); + + // Show step types + if (content.steps && content.steps.length > 0) { + console.log(` Step Types:`); + for (const step of content.steps) { + console.log(` - ${step.type}${step.instruction ? `: "${step.instruction}"` : ""}`); + } + } + + // Show result summary + if (content.result) { + console.log(` Result:`); + console.log(` Success: ${content.result.success}`); + console.log(` Message: ${content.result.message}`); + console.log(` Actions: ${content.result.actions?.length ?? 0}`); + } + } +} + +async function main() { + console.log(` +${"#".repeat(60)} +# Stagehand Agent Caching Test Demo +# +# Tests whether agent() uses caching: +# - Run 1: Expected cache MISS (first execution) +# - Run 2: Expected cache HIT (replay from cache) +${"#".repeat(60)} +`); + + // Clear cache for fresh demo + const args = process.argv.slice(2); + if (args.includes("--fresh")) { + console.log("Clearing cache for fresh run..."); + if (fs.existsSync(CACHE_DIR)) { + fs.rmSync(CACHE_DIR, { recursive: true }); + } + } + + const results: RunResult[] = []; + + // Run 1: First execution - should be cache MISS + console.log("\n\n>>> PHASE 1: First agent run (expect cache MISS)"); + results.push(await runAgentTask(1)); + + // Wait a moment between runs + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Run 2: Same task again - should be cache HIT + console.log("\n\n>>> PHASE 2: Second agent run (expect cache HIT)"); + results.push(await runAgentTask(2)); + + // Inspect cache contents + await inspectAgentCache(); + + // Summary + console.log(`\n${"=".repeat(60)}`); + console.log("RESULTS SUMMARY"); + console.log("=".repeat(60)); + + console.log("\n| Run | Time (ms) | Steps | Success | Cache |"); + console.log("|-----|-----------|-------|---------|--------|"); + + for (const r of results) { + const timeDisplay = r.elapsed.toString().padStart(9); + const stepsDisplay = r.stepCount.toString().padStart(5); + const successDisplay = r.success ? "Yes" : "No "; + const cacheDisplay = r.cacheHit ? "HIT " : "MISS"; + console.log( + `| ${r.runNumber} | ${timeDisplay} | ${stepsDisplay} | ${successDisplay} | ${cacheDisplay} |`, + ); + } + + if (results.length >= 2) { + const speedup = (results[0].elapsed / results[1].elapsed).toFixed(1); + console.log(`\nPerformance improvement: ${speedup}x faster with cache`); + } + + console.log(`\n${"=".repeat(60)}`); + console.log("KEY FINDINGS"); + console.log("=".repeat(60)); + + const agentCacheExists = + fs.existsSync(CACHE_DIR) && + fs.readdirSync(CACHE_DIR).filter((f) => f.startsWith("agent-")).length > 0; + + console.log(` +1. AGENT CACHING STATUS: + - Cache files created: ${agentCacheExists ? "YES" : "NO"} + - Second run faster: ${results.length >= 2 ? (results[1].elapsed < results[0].elapsed ? "YES" : "NO") : "N/A"} + +2. HOW AGENT CACHE WORKS: + - Cache key = hash(instruction + startUrl + options + configSignature) + - Stores: steps (act, goto, scroll, etc.) + final result + - On replay: executes cached steps without LLM calls + +3. CURRENT LIMITATIONS: + - Agent does NOT support variables like act() does + - Cannot use %placeholder% syntax in agent instructions + - Variable data in agent instructions is stored in cache + +4. FUTURE CONSIDERATION: + - Agent with variables would need similar mechanism to act() + - Would need to strip variable values from cached steps + - Would need to inject values during replay +`); + + console.log(`\n${"=".repeat(60)}`); + console.log("COMPARISON: ACT vs AGENT CACHING"); + console.log("=".repeat(60)); + + console.log(` +| Feature | act() Cache | agent() Cache | +|----------------------------|-------------|---------------| +| Caching supported | Yes | Yes | +| Variables supported | Yes | No | +| Privacy preservation | Yes | No | +| Cache key based on | instr+url+ | instr+url+ | +| | varKeys | options+config| +| Replays actions | Yes | Yes | +| Multi-step workflows | Single act | Full workflow | +`); + + console.log(`\n${"=".repeat(60)}`); + console.log("TRY IT YOURSELF"); + console.log("=".repeat(60)); + + console.log(` +1. Run again (cache will be hit): + npm run demo:agent-cache + +2. Run with fresh cache: + npm run demo:agent-cache -- --fresh + +3. Clear cache manually: + npm run clear-cache + +4. Inspect cache contents: + cat .cache/agent-cache/agent-*.json | jq . +`); +} + +main().catch(console.error); diff --git a/packages/examples/caching-with-variables/src/act-cache-demo.ts b/packages/examples/caching-with-variables/src/act-cache-demo.ts new file mode 100644 index 0000000000..c9f0615399 --- /dev/null +++ b/packages/examples/caching-with-variables/src/act-cache-demo.ts @@ -0,0 +1,51 @@ +/** + * Stagehand Act Caching with Variables + * + * Demonstrates privacy-preserving caching: + * - Variable VALUES are NOT stored in cache (only keys) + * - Different values still hit the same cache entry + * - ~100x speedup on cache hits + */ + +import { Stagehand } from "@browserbasehq/stagehand"; +import "dotenv/config"; + +const CACHE_DIR = ".cache/act-cache"; + +async function demo() { + const usernames = ["john.doe@example.com", "jane.smith@example.com", "secret.user@example.com"]; + + for (let i = 0; i < usernames.length; i++) { + const username = usernames[i]; + console.log(`\nRun ${i + 1}: ${username}`); + + const stagehand = new Stagehand({ + env: "LOCAL", + verbose: 0, + cacheDir: CACHE_DIR, + }); + + await stagehand.init(); + const page = stagehand.context.pages()[0]; + + await page.goto("https://the-internet.herokuapp.com/login"); + await page.waitForLoadState("domcontentloaded"); + + const start = Date.now(); + + // The %username% placeholder is replaced at runtime + // but NOT stored in cache - only the key "username" is cached + await stagehand.act("Type %username% into the username field", { + variables: { username }, + }); + + const elapsed = Date.now() - start; + const cacheHit = elapsed < 500; + + console.log(` โ†’ ${elapsed}ms (${cacheHit ? "CACHE HIT" : "CACHE MISS"})`); + + await stagehand.close(); + } +} + +demo().catch(console.error); diff --git a/packages/examples/caching-with-variables/tsconfig.json b/packages/examples/caching-with-variables/tsconfig.json new file mode 100644 index 0000000000..65e0cfaa78 --- /dev/null +++ b/packages/examples/caching-with-variables/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "outDir": "dist", + "declaration": true, + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/examples/company-news-function/.browserbase/functions/manifests/company-news-finder.json b/packages/examples/company-news-function/.browserbase/functions/manifests/company-news-finder.json new file mode 100644 index 0000000000..7e7921d597 --- /dev/null +++ b/packages/examples/company-news-function/.browserbase/functions/manifests/company-news-finder.json @@ -0,0 +1,11 @@ +{ + "name": "company-news-finder", + "config": { + "sessionConfig": { + "browserSettings": { + "advancedStealth": true + } + }, + "parametersSchema": {} + } +} diff --git a/packages/examples/company-news-function/.env.example b/packages/examples/company-news-function/.env.example new file mode 100644 index 0000000000..9ec14a6f2d --- /dev/null +++ b/packages/examples/company-news-function/.env.example @@ -0,0 +1,10 @@ +# Browserbase Configuration +BROWSERBASE_API_KEY= +BROWSERBASE_PROJECT_ID= + +# AI Model API Key (Required for Stagehand) +# Get your key from one of these providers: +# - Google Gemini: https://aistudio.google.com/apikey +# - OpenAI: https://platform.openai.com/api-keys +# - Anthropic: https://console.anthropic.com/ +MODEL_API_KEY= diff --git a/packages/examples/company-news-function/.gitignore b/packages/examples/company-news-function/.gitignore new file mode 100644 index 0000000000..3e7467a42e --- /dev/null +++ b/packages/examples/company-news-function/.gitignore @@ -0,0 +1,24 @@ +# Dependencies +node_modules/ + +# Environment variables +.env +.env.local +.env.*.local + +# Build outputs +dist/ +build/ + +# Logs +*.log + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store + +# Claude +.claude/ diff --git a/packages/examples/company-news-function/README.md b/packages/examples/company-news-function/README.md new file mode 100644 index 0000000000..2e62468b77 --- /dev/null +++ b/packages/examples/company-news-function/README.md @@ -0,0 +1,373 @@ +# Company News Finder Function + +Location in the Stagehand repository: `packages/examples/company-news-function`. + +A Browserbase Function that uses Stagehand's AI agent to search Google for the latest news about a company and provide an intelligent summary along with top news links. + +## What It Does + +This function takes a company name, searches Google for the latest news about that company, and uses Stagehand's AI-powered agent to analyze the results. It returns: + +- **Summary** - A comprehensive 2-3 paragraph summary of what's currently happening with the company +- **Top News Links** - The most relevant and recent news articles with titles, URLs, and sources +- **Metadata** - Execution time, session replay URL, and timestamp + +**Use Cases:** + +- Get quick updates on what's happening with a competitor +- Research a company before a meeting or interview +- Track news about companies in your portfolio +- Monitor industry leaders and their recent developments +- Stay informed about customer or partner companies + +## Prerequisites + +1. **Browserbase Account** - Sign up at [browserbase.com](https://www.browserbase.com) +2. **API Key** - Get your API key from [Settings](https://www.browserbase.com/settings) +3. **AI Model API Key** - Get an API key from your AI provider (see below) +4. **Node.js** - Version 18 or higher + +## Setup + +1. **Install dependencies:** + +```bash +npm install +``` + +2. **Set environment variables:** + +```bash +export BROWSERBASE_API_KEY="your-api-key-here" +export BROWSERBASE_PROJECT_ID="your-project-id-here" +export MODEL_API_KEY="your-ai-model-api-key-here" +``` + +Or create a `.env` file: + +```env +BROWSERBASE_API_KEY=your-api-key-here +BROWSERBASE_PROJECT_ID=your-project-id-here +MODEL_API_KEY=your-ai-model-api-key-here +``` + +**Note:** The `MODEL_API_KEY` is required for Stagehand's AI agent. Get an API key from your AI provider: + +- **Google Gemini** (recommended): [Google AI Studio](https://aistudio.google.com/apikey) +- **OpenAI**: [OpenAI Platform](https://platform.openai.com/api-keys) +- **Anthropic**: [Anthropic Console](https://console.anthropic.com/) + +## Deploy the Function + +Deploy the function to Browserbase: + +```bash +npm run deploy +``` + +This will: + +1. Bundle the function code +2. Upload it to Browserbase +3. Return a Function ID you can invoke + +**Save the Function ID** from the output - you'll need it to invoke the function. + +## Test the Function + +### Option 1: Test via Browserbase Dashboard + +1. Go to [Browserbase Dashboard โ†’ Functions](https://www.browserbase.com/functions) +2. Find your `company-news-finder` function +3. Click "Test" or "Invoke" +4. Provide test parameters: + +```json +{ + "companyName": "Tesla", + "model": "google/gemini-3-flash-preview", + "maxSteps": 30 +} +``` + +**Note:** The `model` and `maxSteps` parameters are optional and will use defaults if not provided. + +5. Click "Invoke" and watch the results appear + +### Option 2: Test via API + +Use the Functions API to invoke directly: + +```bash +# Get your Function ID +FUNCTION_ID="func_xxxxxxxxxxxxx" + +# Invoke the function +curl -X POST "https://api.browserbase.com/v1/functions/${FUNCTION_ID}/invoke" \ + -H "x-bb-api-key: ${BROWSERBASE_API_KEY}" \ + -H "Content-Type: application/json" \ + -d '{ + "params": { + "companyName": "OpenAI", + "model": "google/gemini-3-flash-preview" + } + }' +``` + +This returns an invocation ID. Poll for results: + +```bash +INVOCATION_ID="inv_xxxxxxxxxxxxx" + +curl "https://api.browserbase.com/v1/functions/invocations/${INVOCATION_ID}" \ + -H "x-bb-api-key: ${BROWSERBASE_API_KEY}" +``` + +### Option 3: Test with Node.js Script + +Create `test.js`: + +```javascript +const BROWSERBASE_API_KEY = process.env.BROWSERBASE_API_KEY; +const FUNCTION_ID = "func_xxxxxxxxxxxxx"; // Your function ID + +async function testCompanyNewsFinder() { + // Invoke function + const invokeRes = await fetch(`https://api.browserbase.com/v1/functions/${FUNCTION_ID}/invoke`, { + method: "POST", + headers: { + "x-bb-api-key": BROWSERBASE_API_KEY, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + params: { + companyName: "Microsoft", + model: "google/gemini-3-flash-preview", + }, + }), + }); + + const { id: invocationId } = await invokeRes.json(); + console.log("Invocation ID:", invocationId); + + // Poll for completion + let status = "RUNNING"; + let result; + + while (status === "RUNNING") { + await new Promise((resolve) => setTimeout(resolve, 3000)); + + const pollRes = await fetch( + `https://api.browserbase.com/v1/functions/invocations/${invocationId}`, + { + headers: { "x-bb-api-key": BROWSERBASE_API_KEY }, + }, + ); + + result = await pollRes.json(); + status = result.status; + console.log("Status:", status); + } + + console.log("\n=== Company News Summary ==="); + console.log(result.results.summary); + console.log("\n=== Top News Links ==="); + result.results.topLinks.forEach((link, i) => { + console.log(`\n${i + 1}. ${link.title}`); + console.log(` Source: ${link.source || "Unknown"}`); + console.log(` URL: ${link.url}`); + }); + console.log(`\nSession Replay: ${result.results.metadata.sessionReplayUrl}`); +} + +testCompanyNewsFinder(); +``` + +Run it: + +```bash +node test.js +``` + +## Example Output + +```json +{ + "success": true, + "companyName": "Tesla", + "summary": "Tesla has been making headlines recently with several major developments. The company reported strong Q4 earnings, beating analyst expectations with record vehicle deliveries despite ongoing supply chain challenges. CEO Elon Musk announced plans for a new manufacturing facility in Southeast Asia, marking Tesla's continued global expansion.\n\nIn other news, Tesla's Full Self-Driving (FSD) beta program reached a new milestone with over 500,000 active users. The company also unveiled updated versions of the Model 3 and Model Y with improved range and new features. However, Tesla faces increased competition from traditional automakers and Chinese EV manufacturers who are rapidly expanding their electric vehicle offerings.", + "topLinks": [ + { + "title": "Tesla Reports Record Q4 Earnings, Beats Expectations", + "url": "https://www.reuters.com/business/tesla-earnings-q4-2024", + "source": "Reuters" + }, + { + "title": "Elon Musk Announces New Tesla Factory in Southeast Asia", + "url": "https://www.bloomberg.com/news/tesla-factory-asia", + "source": "Bloomberg" + }, + { + "title": "Tesla's FSD Beta Reaches 500,000 Active Users", + "url": "https://techcrunch.com/tesla-fsd-milestone", + "source": "TechCrunch" + }, + { + "title": "Updated Model 3 and Model Y Feature Improved Range", + "url": "https://www.theverge.com/tesla-model-update", + "source": "The Verge" + }, + { + "title": "Tesla Faces Growing Competition from Chinese EV Makers", + "url": "https://www.cnbc.com/tesla-competition-china", + "source": "CNBC" + } + ], + "metadata": { + "totalLinks": 5, + "scrapedAt": "2024-01-15T10:30:00.000Z", + "duration": 25834, + "sessionReplayUrl": "https://www.browserbase.com/sessions/sess_xxxxx" + } +} +``` + +## How It Works + +1. **Connect** - Function connects to Browserbase browser session via CDP +2. **Navigate to Google** - Browser visits google.com +3. **Search** - AI agent types the company name + "latest news" and submits search +4. **Analyze Results** - Agent reads headlines, snippets, and sources from top results +5. **Generate Summary** - Agent creates a comprehensive summary of current developments +6. **Extract Links** - Agent collects the top 5-7 most relevant news articles +7. **Return Data** - Returns clean JSON with summary, links, and metadata +8. **Session Replay** - Full browser session recorded for debugging + +**Technical Details:** + +- Uses Playwright's Chrome DevTools Protocol (CDP) connection +- Stagehand runs in "LOCAL" mode using the existing browser session +- Agent operates in "hybrid" mode for optimal performance +- Default model: `google/gemini-3-flash-preview` (configurable) +- Searches Google and analyzes results in real-time + +**Key Features:** + +- โœ… **AI-Powered Summary** - Get the big picture quickly +- โœ… **Latest News** - Always searches for recent developments +- โœ… **Top Sources** - Links to original articles from major news outlets +- โœ… **Fast** - Typically completes in 20-30 seconds +- โœ… **Debuggable** - Session replay for every run +- โœ… **Flexible** - Works for any company name +- โœ… **Configurable** - Choose your preferred AI model + +## Troubleshooting + +### Function times out + +- Google search results load slowly sometimes +- Check the session replay URL to see where it got stuck +- Try increasing `maxSteps` if the agent needs more time + +### Summary is empty or links missing + +- The agent may not have found clear news results +- Try a more specific company name (e.g., "Tesla Inc" instead of just "Tesla") +- Check the session replay to see what Google returned +- Increase `maxSteps` to give the agent more time + +### API Key errors + +- Verify your `BROWSERBASE_API_KEY` is set correctly +- Ensure your `MODEL_API_KEY` is set and valid for your chosen AI provider +- Ensure your account has Functions enabled +- Check that you're using the correct project ID + +### Deployment fails + +- Make sure you have the latest `@browserbasehq/sdk-functions` package +- Verify your TypeScript configuration is correct +- Check that all dependencies are installed + +### Model errors + +- Verify your AI model API key is correct for the provider +- Check that the model name is valid (e.g., `google/gemini-3-flash-preview`) +- Ensure you have sufficient credits with your AI provider + +### Google blocks the search + +- Browserbase uses advanced stealth mode to avoid detection +- If blocked, check the session replay to see what happened +- This is rare but can happen with high request volumes + +## Customization + +### Adjust the search query + +Edit the instruction in `index.ts` to modify the search: + +```typescript +instruction: `Search Google for "${params.companyName} news 2024" and analyze the results. + Focus on financial news and company announcements. + Exclude opinion pieces and analysis articles.`; +``` + +### Change the AI model + +You can use different AI models by passing the `model` parameter: + +```json +{ + "companyName": "Apple", + "model": "openai/gpt-4o" +} +``` + +Supported models: + +- `google/gemini-3-flash-preview` (default, fast and cost-effective) +- `openai/gpt-4o` (powerful, requires OpenAI API key) +- `anthropic/claude-3.5-sonnet` (excellent reasoning, requires Anthropic API key) + +### Adjust agent steps + +Control how many steps the agent takes: + +```json +{ + "companyName": "Amazon", + "maxSteps": 40 +} +``` + +Higher `maxSteps` allows more thorough analysis but takes longer. + +### Modify summary style + +Edit the instruction to change the summary format: + +```typescript +instruction: `Search Google for "${params.companyName} latest news" and analyze the results. + + Create a summary that: + - Starts with the most important recent development + - Focuses on financial and business metrics + - Is written in a professional, objective tone + - Includes specific dates and numbers when available + + Return JSON with "summary" and "topLinks" fields.`; +``` + +## Resources + +- [Browserbase Functions Documentation](https://docs.browserbase.com/features/functions) +- [Stagehand Documentation](https://docs.browserbase.com/guides/stagehand) +- [Functions API Reference](https://docs.browserbase.com/functions/reference) +- [Session Replay](https://docs.browserbase.com/features/session-replay) + +## Support + +- [Discord Community](https://discord.gg/browserbase) +- [GitHub Issues](https://github.com/browserbase/sdk-functions/issues) +- [Email Support](mailto:support@browserbase.com) diff --git a/packages/examples/company-news-function/index.ts b/packages/examples/company-news-function/index.ts new file mode 100644 index 0000000000..d14d454375 --- /dev/null +++ b/packages/examples/company-news-function/index.ts @@ -0,0 +1,116 @@ +import { defineFn } from "@browserbasehq/sdk-functions"; +import { chromium } from "playwright-core"; +import { Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod"; + +const parametersSchema = z.object({ + companyName: z.string().describe("The company name to search for news about"), + apiKey: z.string().describe("The AI model API key"), + model: z + .string() + .optional() + .describe("The AI model to use (default: anthropic/claude-sonnet-4-20250514)"), + maxSteps: z.number().optional().describe("Maximum steps for agent execution (default: 30)"), +}); + +defineFn( + "company-news-finder", + async (context, params) => { + const { session } = context; + const startTime = Date.now(); + + try { + console.log("Connecting to browser session:", session.id); + console.log(`Searching for latest news about: ${params.companyName}`); + + // Connect to the browser instance + const browser = await chromium.connectOverCDP(session.connectUrl); + const browserContext = browser.contexts()[0]!; + const page = browserContext.pages()[0]!; + + console.log("Navigating to Google..."); + await page.goto("https://www.google.com", { waitUntil: "domcontentloaded" }); + + // Wait a moment for the page to fully load + await page.waitForTimeout(1000); + + // Configure Stagehand to use the existing browser session + const stagehand = new Stagehand({ + model: { + modelName: params.model ?? "anthropic/claude-sonnet-4-20250514", + apiKey: params.apiKey, + }, + env: "LOCAL", + localBrowserLaunchOptions: { + cdpUrl: session.connectUrl, + }, + experimental: true, + }); + + await stagehand.init(); + + console.log("Stagehand initialized, searching for company news..."); + + // Use Stagehand agent to search Google and analyze news + const agent = stagehand.agent({ + mode: "hybrid", + model: params.model ?? "anthropic/claude-sonnet-4-20250514", + systemPrompt: `You are a helpful assistant that searches for company news and provides summaries.`, + }); + + const result = await agent.execute({ + instruction: `Search Google for "${params.companyName} latest news" and analyze the results. + + Steps: + 1. In the Google search box, type "${params.companyName} latest news" and submit the search + 2. Wait for the results to load + 3. Look at the top news articles (typically the first 5-10 results) + 4. Read the headlines, snippets, and sources + 5. Create a comprehensive summary of what's happening with ${params.companyName} based on the news headlines and snippets + + Return a JSON object with: + - "summary": A 2-3 paragraph summary of the current situation and recent news about ${params.companyName} + - "topLinks": An array of the top 5-7 news articles with "title", "url", and "source" fields + + Make the summary informative and capture the key themes and developments.`, + maxSteps: params.maxSteps ?? 30, + }); + + console.log("Agent execution completed"); + + // Strip screenshots/large data from actions to stay under 64KB result limit + const agentResult = result as any; + const actions = (agentResult?.actions ?? []).map((a: any) => { + const { screenshot, ...rest } = a; + return rest; + }); + + return { + companyName: params.companyName, + success: agentResult?.success ?? false, + completed: agentResult?.completed ?? false, + message: agentResult?.message ?? "", + actions, + sessionReplayUrl: `https://www.browserbase.com/sessions/${session.id}`, + duration: Date.now() - startTime, + }; + } catch (error) { + console.error("Company news finder failed:", error); + + return { + companyName: params.companyName, + error: error instanceof Error ? error.message : String(error), + sessionReplayUrl: `https://www.browserbase.com/sessions/${session.id}`, + duration: Date.now() - startTime, + }; + } + }, + { + parametersSchema, + sessionConfig: { + browserSettings: { + advancedStealth: true, + }, + }, + }, +); diff --git a/packages/examples/company-news-function/package.json b/packages/examples/company-news-function/package.json new file mode 100644 index 0000000000..3822b7bd48 --- /dev/null +++ b/packages/examples/company-news-function/package.json @@ -0,0 +1,19 @@ +{ + "name": "company-news-finder-function", + "version": "1.0.0", + "description": "Browserbase Function that searches Google for company news and provides AI-generated summaries with top news links", + "main": "index.ts", + "scripts": { + "deploy": "npx @browserbasehq/sdk-functions publish index.ts" + }, + "dependencies": { + "@browserbasehq/sdk-functions": "latest", + "@browserbasehq/stagehand": "3.0.8", + "playwright-core": "^1.48.2", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.3.3" + } +} diff --git a/packages/examples/company-news-function/tsconfig.json b/packages/examples/company-news-function/tsconfig.json new file mode 100644 index 0000000000..f683de62d1 --- /dev/null +++ b/packages/examples/company-news-function/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["index.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/examples/company-value-prop-generator/README.md b/packages/examples/company-value-prop-generator/README.md new file mode 100644 index 0000000000..b8fcc112f1 --- /dev/null +++ b/packages/examples/company-value-prop-generator/README.md @@ -0,0 +1,12 @@ +# company-value-prop-generator + +Configurable public-company website extraction; no customer tenant or account. + +Choose a language and follow its setup instructions: + +| Language | Stagehand dependency | Directory from the repository root | +| ---------------------------------- | -------------------- | ----------------------------------------------------------- | +| [TypeScript](typescript/README.md) | `4.0.2` | `packages/examples/company-value-prop-generator/typescript` | +| [Python](python/README.md) | `4.0.0` | `packages/examples/company-value-prop-generator/python` | + +Each variant installs the published Stagehand package and keeps its own dependencies. diff --git a/packages/examples/company-value-prop-generator/python/.env.example b/packages/examples/company-value-prop-generator/python/.env.example new file mode 100644 index 0000000000..d0cde0427d --- /dev/null +++ b/packages/examples/company-value-prop-generator/python/.env.example @@ -0,0 +1 @@ +BROWSERBASE_API_KEY= diff --git a/packages/examples/company-value-prop-generator/python/README.md b/packages/examples/company-value-prop-generator/python/README.md new file mode 100644 index 0000000000..54ebe89539 --- /dev/null +++ b/packages/examples/company-value-prop-generator/python/README.md @@ -0,0 +1,62 @@ +# Stagehand + Browserbase: Value Prop One-Liner Generator + +Location in the Stagehand repository: `packages/examples/company-value-prop-generator/python`. + +Stagehand is the SDK for browser agents. + +## AT A GLANCE + +- Goal: Automatically extract and format website value propositions into concise one-liners for email personalization +- Demonstrates Stagehand's `extract` method with Pydantic schemas to pull structured data from landing pages +- Shows how to chain Stagehand V4 extractions to transform grounded page content with custom prompts +- Docs โ†’ https://docs.stagehand.dev/v4/basics/extract + +## GLOSSARY + +- Extract: Stagehand method that uses AI to pull structured data from pages using natural language instructions + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- Value Proposition: The core benefit or unique selling point a company communicates to customers + +## QUICKSTART + +1. cd packages/examples/company-value-prop-generator/python +2. pip install python-dotenv stagehand openai pydantic +3. cp .env.example .env # Add your Browserbase API key to .env +4. python main.py + +## EXPECTED OUTPUT + +- Stagehand initializes and creates a Browserbase session +- Navigates to target domain and waits for page load +- Extracts value proposition from landing page using AI +- Generates formatted one-liner via LLM (constraints: 9 words max, starts with "your") +- Prints generated one-liner to console +- Closes browser session + +## COMMON PITFALLS + +- Dependency install errors: ensure pip install completed +- Missing credentials: + - BROWSERBASE_API_KEY (required for browser automation) +- Placeholder pages: extraction quality depends on the content available on the target page +- Slow-loading sites: 5-minute timeout configured, but extremely slow sites may still timeout + +## USE CASES + +โ€ข Generate personalized email openers by extracting value props from prospect domains +โ€ข Build prospecting tools that automatically understand what companies do from their websites +โ€ข Create dynamic messaging systems that adapt content based on extracted company information + +## NEXT STEPS + +โ€ข Batch process multiple domains by iterating over a list and aggregating results +โ€ข Extract additional metadata like company description, industry tags, or key features alongside value prop +โ€ข Add caching layer to avoid re-extracting value props for previously analyzed domains + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/company-value-prop-generator/python/main.py b/packages/examples/company-value-prop-generator/python/main.py new file mode 100644 index 0000000000..8d51709645 --- /dev/null +++ b/packages/examples/company-value-prop-generator/python/main.py @@ -0,0 +1,82 @@ +"""Generate a concise company value proposition with Stagehand V4.""" + +import asyncio +import os + +from dotenv import load_dotenv +from pydantic import BaseModel + +from stagehand import Stagehand, browserbase + +load_dotenv() + +TARGET_DOMAIN = "www.browserbase.com" + + +class ValueProposition(BaseModel): + value_prop: str + + +class OneLiner(BaseModel): + one_liner: str + + +async def generate_one_liner(domain: str) -> str: + api_key = os.environ.get("BROWSERBASE_API_KEY") + if not api_key: + raise RuntimeError("BROWSERBASE_API_KEY is required") + + browser = await browserbase.launch(api_key=api_key) + try: + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + await page.goto( + f"https://{domain}/", + wait_until="domcontentloaded", + timeout=300_000, + ) + + value_prop_result = await stagehand.extract( + "Extract the value proposition from the landing page", + ValueProposition, + page=page, + ) + value_prop = value_prop_result.data.value_prop.strip() + print(f"Extracted value proposition: {value_prop}") + + formatted_result = await stagehand.extract( + ( + f'Using the company value proposition "{value_prop}", write a unique ' + 'English description that starts with "your", uses no quotes, avoids ' + "generic adjectives, and is no more than 9 words" + ), + OneLiner, + page=page, + ) + one_liner = formatted_result.data.one_liner.strip() + print(f"Generated one-liner: {one_liner}") + return one_liner + finally: + await stagehand.close() + finally: + await browser.close() + print("Session closed successfully") + + +async def main() -> None: + print("Starting One-Liner Generator...") + one_liner = await generate_one_liner(TARGET_DOMAIN) + print(f"Success: {one_liner}") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as error: + print(f"Error: {error}") + print("Docs: https://docs.stagehand.dev/v4/first-steps/introduction") + raise diff --git a/packages/examples/company-value-prop-generator/python/pyproject.toml b/packages/examples/company-value-prop-generator/python/pyproject.toml new file mode 100644 index 0000000000..e979f6c300 --- /dev/null +++ b/packages/examples/company-value-prop-generator/python/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "company-value-prop-generator" +version = "0.1.0" +requires-python = ">=3.11,<3.14" +dependencies = ["pydantic>=2.12,<3", "python-dotenv==1.2.2", "stagehand==4.0.0"] + +[tool.uv] +package = false diff --git a/packages/examples/company-value-prop-generator/typescript/.env.example b/packages/examples/company-value-prop-generator/typescript/.env.example new file mode 100644 index 0000000000..d0cde0427d --- /dev/null +++ b/packages/examples/company-value-prop-generator/typescript/.env.example @@ -0,0 +1 @@ +BROWSERBASE_API_KEY= diff --git a/packages/examples/company-value-prop-generator/typescript/README.md b/packages/examples/company-value-prop-generator/typescript/README.md new file mode 100644 index 0000000000..3b8b99aad6 --- /dev/null +++ b/packages/examples/company-value-prop-generator/typescript/README.md @@ -0,0 +1,62 @@ +# Stagehand + Browserbase: Value Prop One-Liner Generator + +Location in the Stagehand repository: `packages/examples/company-value-prop-generator/typescript`. + +## AT A GLANCE + +- Goal: Automatically extract and format website value propositions into concise one-liners for email personalization +- Demonstrates Stagehand's `extract` method with Zod schemas to pull structured data from landing pages +- Shows how to chain Stagehand V4 extractions to transform grounded page content with custom prompts +- Docs โ†’ https://docs.stagehand.dev/v4/basics/extract + +## GLOSSARY + +- Extract: Stagehand method that uses AI to pull structured data from pages using natural language instructions + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- Value Proposition: The core benefit or unique selling point a company communicates to customers + +## QUICKSTART + +1. cd packages/examples/company-value-prop-generator/typescript +2. npm install +3. cp .env.example .env +4. Add your Browserbase API key to .env +5. npm start + +## EXPECTED OUTPUT + +- Stagehand initializes and creates a Browserbase session +- Chains two Stagehand V4 extractions to produce the formatted one-liner +- Navigates to target domain and waits for page load +- Extracts value proposition from landing page using AI +- Generates formatted one-liner via LLM (constraints: 9 words max, starts with "your") +- Prints generated one-liner to console +- Closes browser session + +## COMMON PITFALLS + +- Dependency install errors: ensure npm install completed +- Missing credentials: + - BROWSERBASE_API_KEY (required for browser automation) +- Placeholder pages: extraction quality depends on the content available on the target page +- Slow-loading sites: 5-minute timeout configured, but extremely slow sites may still timeout + +## USE CASES + +โ€ข Generate personalized email openers by extracting value props from prospect domains +โ€ข Build prospecting tools that automatically understand what companies do from their websites +โ€ข Create dynamic messaging systems that adapt content based on extracted company information + +## NEXT STEPS + +โ€ข Batch process multiple domains by iterating over a list and aggregating results +โ€ข Extract additional metadata like company description, industry tags, or key features alongside value prop +โ€ข Add caching layer to avoid re-extracting value props for previously analyzed domains + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/company-value-prop-generator/typescript/index.ts b/packages/examples/company-value-prop-generator/typescript/index.ts new file mode 100644 index 0000000000..475593b003 --- /dev/null +++ b/packages/examples/company-value-prop-generator/typescript/index.ts @@ -0,0 +1,100 @@ +// Stagehand + Browserbase: Value Prop One-Liner Generator - See README.md for full documentation + +import "dotenv/config"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod/v4"; + +// Domain to analyze - change this to target a different website +const targetDomain = "www.browserbase.com"; // Or extract from email: email.split("@")[1] + +/** + * Analyzes a website's landing page to generate a concise one-liner value proposition. + * Extracts the value prop using Stagehand, then uses an LLM to format it into a short phrase starting with "your". + */ +async function generateOneLiner(domain: string): Promise { + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + }); + const stagehand = await Stagehand.create({ + browser: browser, + model: { modelName: "openai/gpt-4.1" }, + logging: { level: "error" }, + }); + + try { + console.log("Stagehand initialized successfully!"); + const page = (await browser.context.pages())[0]; + + // Navigate to domain + console.log(`๐ŸŒ Navigating to https://${domain}...`); + // 5min timeout to handle slow-loading sites or network issues + await page.goto(`https://${domain}/`, { + waitUntil: "domcontentloaded", + timeout: 300000, + }); + + console.log(`โœ… Successfully loaded ${domain}`); + + // Extract value proposition from landing page + console.log(`๐Ÿ“ Extracting value proposition for ${domain}...`); + const { data: valueProp } = await stagehand.extract( + "extract the value proposition from the landing page", + z.object({ + value_prop: z.string(), + }), + ); + + console.log(`๐Ÿ“Š Extracted value prop for ${domain}:`, valueProp.value_prop); + + // Generate the one-liner with a second V4 extraction. Including the first extraction + // keeps the request grounded while Stagehand's configured model handles formatting. + console.log(`๐Ÿค– Generating email one-liner for ${domain}...`); + + const { data: formatted } = await stagehand.extract( + `Using the company's value proposition "${valueProp.value_prop}", write a unique English description that starts with "your", uses no quotes, avoids generic adjectives, and is no more than 9 words`, + z.object({ one_liner: z.string() }), + ); + + const oneLiner = formatted.one_liner.trim(); + + console.log(`โœจ Generated one-liner for ${domain}:`, oneLiner); + return oneLiner; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`โŒ Generation failed for ${domain}: ${errorMessage}`); + throw error; + } finally { + await stagehand.close(); + await browser.close(); + console.log("Session closed successfully"); + } +} + +/** + * Main entry point: generates a one-liner value proposition for the target domain. + */ +async function main() { + console.log("Starting One-Liner Generator..."); + + try { + const oneLiner = await generateOneLiner(targetDomain); + console.log("\nโœ… Success!"); + console.log(`One-liner: ${oneLiner}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`\nโŒ Error: ${errorMessage}`); + console.error("\nCommon issues:"); + console.error( + " - Check .env file has BROWSERBASE_API_KEY set (required for browser automation)", + ); + console.error(" - Ensure the domain is accessible and not a placeholder/maintenance page"); + console.error(" - Verify internet connectivity and that the target site is reachable"); + console.error("Docs: https://docs.browserbase.com/stagehand"); + process.exit(1); + } +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/packages/examples/company-value-prop-generator/typescript/package.json b/packages/examples/company-value-prop-generator/typescript/package.json new file mode 100644 index 0000000000..c0334b9729 --- /dev/null +++ b/packages/examples/company-value-prop-generator/typescript/package.json @@ -0,0 +1,22 @@ +{ + "name": "company-value-prop-generator", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "tsx index.ts" + }, + "dependencies": { + "@browserbasehq/stagehand": "4.0.2", + "dotenv": "^17.4.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "tsx": "^4.23.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.18.0" + } +} diff --git a/packages/examples/configurable-browser-trial/.env.example b/packages/examples/configurable-browser-trial/.env.example new file mode 100644 index 0000000000..2a1a727e8a --- /dev/null +++ b/packages/examples/configurable-browser-trial/.env.example @@ -0,0 +1,10 @@ +# โ”€โ”€ Browserbase โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Get these from https://www.browserbase.com/settings +BROWSERBASE_API_KEY= +BROWSERBASE_PROJECT_ID= + +# โ”€โ”€ Model (for the AI that drives + grades each task) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Use ONE of the following. Anthropic Claude is the default and recommended. +ANTHROPIC_API_KEY= +# OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx +# GOOGLE_GENERATIVE_AI_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxx diff --git a/packages/examples/configurable-browser-trial/.gitignore b/packages/examples/configurable-browser-trial/.gitignore new file mode 100644 index 0000000000..b4924b70d1 --- /dev/null +++ b/packages/examples/configurable-browser-trial/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +results/ +*.log +.DS_Store diff --git a/packages/examples/configurable-browser-trial/README.md b/packages/examples/configurable-browser-trial/README.md new file mode 100644 index 0000000000..510e08dc68 --- /dev/null +++ b/packages/examples/configurable-browser-trial/README.md @@ -0,0 +1,184 @@ +# bbpoc โ€” Browserbase Verified Trial in a Box + +Location in the Stagehand repository: `packages/examples/configurable-browser-trial`. + +> Turn a one-week "advanced stealth" trial into a one-day, self-serve smoke test. +> Edit **one file**, run **one command**, get a **leadership-ready scorecard**. + +Every Browserbase proof-of-concept looks the same: a customer hands over a list +of their own bot-protected URLs, then someone spends a week wiring up +concurrency, logging, retries, and stealth settings just to find out the success +rate. `bbpoc` is that week, pre-built. You bring the URLs; it runs them at scale +with stealth + residential proxies, classifies every outcome, and emits the exact +scorecard a Browserbase CE would otherwise assemble by hand. + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ trial.yaml โ”‚ โ”€โ”€โ–ถ โ”‚ bbpoc run โ”‚ โ”€โ”€โ–ถ โ”‚ scorecard.html โ”‚ +โ”‚ your URLs โ”‚ โ”‚ stealth ยท proxies ยท โ”‚ โ”‚ scorecard.md โ”‚ +โ”‚ + tasks โ”‚ โ”‚ captcha ยท concurrency ยท โ”‚ โ”‚ results.json โ”‚ +โ”‚ + targets โ”‚ โ”‚ retry-on-new-proxy โ”‚ โ”‚ (session replays) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +--- + +## Quick start (talk to it โ€” recommended) + +This repo ships as a **Claude Code skill**. Open the folder in Claude Code (or +Cursor) and just describe what you want to test โ€” Claude writes the config, runs +the trial, and hands you the scorecard. No YAML, no flags to learn. + +```bash +cd packages/examples/configurable-browser-trial +npm ci +cp .env.example .env # fill in your Browserbase + model keys +``` + +Then, in Claude Code: + +``` +/bbpoc test these for us: + portal.acme.com/login โ€” log in and confirm the dashboard loads + acme.com/search โ€” search "widgets" and read the first result + 80% bar, run each 5 times +``` + +Claude generates `trial.yaml`, runs it on real cloud browsers, and points you at +`results/scorecard.html`. The skill lives in `.claude/skills/bbpoc/` โ€” it's +auto-discovered when you open this repo. To use it in any project, copy that +folder into your global `~/.claude/skills/`. + +## Quick start (CLI โ€” power users / CI) + +Prefer flags? The engine underneath is a normal CLI: + +```bash +npx bbpoc init # scaffolds trial.yaml + .env +# edit trial.yaml โ€” your URLs, tasks, targets +npx bbpoc smoke # quick sanity โ€” 1 attempt per site +npx bbpoc run # full trial โ€” N attempts per site, at concurrency +``` + +Open `results/scorecard.html` and send it to your team. + +> **Need Enterprise/Scale access?** Advanced stealth ("Verified") and residential +> proxies are Enterprise-plan features. If a run errors on session creation, ask +> your Browserbase contact to enable them โ€” or run a control with +> `npx bbpoc run --preset baseline` to see the un-stealthed baseline. + +--- + +## The one file you edit: `trial.yaml` + +```yaml +name: "Acme โ€” Browserbase Verified Trial" +customer: "Acme Inc." + +defaults: + attempts: 3 # run EACH site N times (stability is the real bar) + concurrency: 5 + region: us-west-2 + target: 0.8 # per-site success target + model: anthropic/claude-sonnet-4-5 + features: + advancedStealth: true # "Verified" โ€” DEFAULT ON (the #1 trial mistake is leaving it off) + proxies: true # residential proxies โ€” pair with stealth + solveCaptchas: true + # proxyCountry: BR # geo-target the proxy if the site checks location + +sites: + - name: "Login flow" + url: "https://portal.example.com/login" + task: "Log in with the provided credentials and confirm the dashboard loads." + expect: "The account dashboard is visible after login." + antibot: akamai # informational + target: 0.85 +``` + +That's it. No pipeline code, no logging setup, no concurrency plumbing. + +--- + +## What you get + +**`scorecard.md` โ€” the acceptance matrix** (the artifact CEs build by hand): + +| Site | Success | Target | Result | Top failure | Anti-bot seen | +| ---------- | ---------- | ------ | -------- | ---------------- | ------------- | +| Login flow | 8/10 (80%) | 80% | โœ… Met | โ€” | Akamai | +| Search | 4/10 (40%) | 80% | โŒ Below | CAPTCHA unsolved | hCaptcha | + +**`scorecard.html` โ€” a branded, leadership-ready report** with per-site donuts, +the exact feature posture used, and a **replayable Browserbase session link for +every single attempt** (pass _and_ fail) โ€” the evidence customers actually want. + +**`results.json`** โ€” raw data for your own dashboards. + +--- + +## Commands + +| Command | What it does | +| -------------- | ---------------------------------------------- | +| `bbpoc init` | Scaffold `trial.yaml` + `.env` | +| `bbpoc smoke` | Quick sanity run, 1 attempt/site | +| `bbpoc run` | Full trial per `trial.yaml` | +| `bbpoc report` | Rebuild reports from a previous `results.json` | + +**Useful flags** (on `run` / `smoke`): + +| Flag | Purpose | +| ------------------------------------------- | ----------------------------------------- | +| `--preset verified\|baseline\|stealth-only` | Flip the whole feature posture | +| `--concurrency ` | e.g. `--concurrency 100` for a scale test | +| `--attempts ` | Override attempts per site | +| `--no-stealth` / `--no-proxies` | Run a control to prove the lift | +| `-c, --config ` | Use a different manifest | + +**Prove the value of stealth** by running the same manifest twice: + +```bash +npx bbpoc run --preset baseline -o results/baseline # stealth OFF +npx bbpoc run --preset verified -o results/verified # stealth ON +``` + +โ€ฆthen compare the two `scorecard.html` files side by side. (Customers routinely +see jumps like 30% โ†’ 80% once Verified + proxies are on.) + +--- + +## How it decides pass vs. fail + +For each attempt, `bbpoc`: + +1. Opens the URL in a fresh Browserbase session (fresh session = fresh proxy IP โ€” + the "retry to rotate the proxy" pattern, built in). +2. Drives the task with a Stagehand agent. +3. Scrapes the page and **classifies the outcome**: `pass`, `blocked (anti-bot)`, + `CAPTCHA unsolved`, `account/OTP wall`, `timeout`, or `error` โ€” and names the + vendor it detected (Cloudflare, Akamai, PerimeterX, DataDome, hCaptcha, โ€ฆ). + +So a failure tells you _why_: "you weren't blocked by Browserbase, you hit +Akamai" vs. "the agent ran out of steps." That distinction is the whole game. + +--- + +## The skill is the front door + +`.claude/skills/bbpoc/` contains the skill that drives everything from plain +English, plus a `FAQ.md`. Because it's a project skill, it's available the moment +you open this repo in Claude Code. It can also answer "what's a context?", "why is +this site still blocked?", and "how do I geo-target a proxy?" โ€” so your trial +rarely needs a support ticket. Copy the folder into `~/.claude/skills/` to use it +everywhere. + +--- + +## Requirements + +- Node โ‰ฅ 20 +- A Browserbase account (`BROWSERBASE_API_KEY`, `BROWSERBASE_PROJECT_ID`) +- A model key: `ANTHROPIC_API_KEY` (default), `OPENAI_API_KEY`, or `GOOGLE_GENERATIVE_AI_API_KEY` + +Built on [Browserbase](https://browserbase.com) + [Stagehand](https://github.com/browserbase/stagehand). diff --git a/packages/examples/configurable-browser-trial/bin/bbpoc.mjs b/packages/examples/configurable-browser-trial/bin/bbpoc.mjs new file mode 100644 index 0000000000..d8aa05db25 --- /dev/null +++ b/packages/examples/configurable-browser-trial/bin/bbpoc.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +// Thin launcher so `bbpoc ...` runs the TypeScript CLI via tsx with no build step. +import { spawn } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, join } from "node:path"; +import { createRequire } from "node:module"; + +const here = dirname(fileURLToPath(import.meta.url)); +const cli = join(here, "..", "src", "cli.ts"); + +// Resolve tsx from THIS package's node_modules so the launcher works from any cwd. +const require = createRequire(import.meta.url); +let tsxImport = "tsx"; +try { + tsxImport = pathToFileURL(require.resolve("tsx")).href; +} catch { + /* fall back to bare specifier; resolves when run inside the installed repo */ +} + +const child = spawn(process.execPath, ["--import", tsxImport, cli, ...process.argv.slice(2)], { + stdio: "inherit", +}); +child.on("exit", (code) => process.exit(code ?? 0)); +child.on("error", (err) => { + console.error("Failed to launch bbpoc:", err.message); + console.error("Did you run `npm install`?"); + process.exit(1); +}); diff --git a/packages/examples/configurable-browser-trial/examples/trial.example.yaml b/packages/examples/configurable-browser-trial/examples/trial.example.yaml new file mode 100644 index 0000000000..1efe2793b4 --- /dev/null +++ b/packages/examples/configurable-browser-trial/examples/trial.example.yaml @@ -0,0 +1,47 @@ +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# bbpoc trial manifest โ€” THE ONLY FILE YOU NEED TO EDIT +# +# Drop in your own protected URLs + what success looks like, then run: +# bbpoc smoke # quick sanity (1 attempt/site) +# bbpoc run # full trial +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +name: "Acme โ€” Browserbase Verified Trial" +customer: "Acme Inc." + +defaults: + attempts: 3 # run EACH site N times โ€” stability over N runs is the real bar, not 1-shot + concurrency: 5 # how many sessions run at once + region: us-west-2 # us-west-2 | us-east-1 | eu-central-1 | ap-southeast-1 + target: 0.8 # default per-site success target (0โ€“1) + model: anthropic/claude-sonnet-4-5 + features: + advancedStealth: true # "Verified" โ€” DEFAULT ON. The #1 trial mistake is leaving this off. + proxies: true # residential proxies โ€” pair with stealth ("it's a must") + solveCaptchas: true # in-house Cloudflare / hCaptcha / reCAPTCHA / FunCaptcha solvers + blockAds: true + # proxyCountry: US # set if the site geo-checks the proxy (e.g. BR, MX, GB) + +sites: + # Each site: where to start, the task in plain English, and what "success" means. + - name: "Example login" + url: "https://www.example.com/" + task: "Confirm the page loads and read the main heading." + expect: "The page rendered and the main heading text is visible." + target: 0.9 + # antibot: cloudflare # optional: the vendor you expect to face (informational) + + - name: "Product page" + url: "https://books.toscrape.com/" + task: "Find the first book on the page and read its title and price." + expect: "A book title and a price (e.g. ยฃ51.77) are visible." + target: 0.9 + + # - name: "Your protected portal" + # url: "https://portal.example.com/login" + # task: "Log in with the provided credentials and confirm the dashboard loads." + # expect: "The account dashboard / home screen is visible after login." + # antibot: akamai + # target: 0.8 + # features: + # proxyCountry: US # per-site overrides are allowed diff --git a/packages/examples/configurable-browser-trial/package.json b/packages/examples/configurable-browser-trial/package.json new file mode 100644 index 0000000000..ae530e6b97 --- /dev/null +++ b/packages/examples/configurable-browser-trial/package.json @@ -0,0 +1,34 @@ +{ + "name": "@browserbasehq/bbpoc", + "version": "0.1.0", + "description": "Turn a 1-week Browserbase verified/advanced-stealth trial into a 1-day, self-serve smoke test. Edit one file, run one command, get a leadership-ready scorecard.", + "bin": { + "bbpoc": "bin/bbpoc.mjs" + }, + "type": "module", + "scripts": { + "bbpoc": "node --import tsx src/cli.ts", + "init": "node --import tsx src/cli.ts init", + "smoke": "node --import tsx src/cli.ts smoke", + "run": "node --import tsx src/cli.ts run", + "report": "node --import tsx src/cli.ts report", + "test": "tsc --noEmit", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@browserbasehq/sdk": "^2.6.0", + "@browserbasehq/stagehand": "^2.4.0", + "commander": "^12.1.0", + "picocolors": "^1.1.1", + "yaml": "^2.6.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.9.0", + "tsx": "^4.19.2", + "typescript": "^5.6.3" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/examples/configurable-browser-trial/src/classify.ts b/packages/examples/configurable-browser-trial/src/classify.ts new file mode 100644 index 0000000000..a9b1d196c3 --- /dev/null +++ b/packages/examples/configurable-browser-trial/src/classify.ts @@ -0,0 +1,145 @@ +import type { Outcome } from "./types.js"; + +/** + * Signatures of common anti-bot / challenge pages. We classify a failure as an + * anti-bot block (vs. agent logic or a timeout) so the scorecard tells the + * customer the truth: "you weren't blocked by us, you were blocked by Akamai." + */ +const VENDOR_SIGNATURES: { vendor: string; patterns: RegExp[] }[] = [ + { + vendor: "Cloudflare", + patterns: [ + /cloudflare/i, + /cf-chl/i, + /attention required/i, + /checking your browser/i, + /cf-ray/i, + ], + }, + { vendor: "Akamai", patterns: [/akamai/i, /reference #\d+\.\w+/i, /access denied.*akamai/i] }, + { + vendor: "PerimeterX / HUMAN", + patterns: [/perimeterx/i, /px-captcha/i, /press (?:and|&) hold/i, /human challenge/i], + }, + { vendor: "DataDome", patterns: [/datadome/i, /geo\.captcha-delivery/i] }, + { vendor: "Kasada", patterns: [/kasada/i, /kpsdk/i] }, + { vendor: "reCAPTCHA", patterns: [/recaptcha/i, /i'?m not a robot/i, /g-recaptcha/i] }, + { vendor: "hCaptcha", patterns: [/hcaptcha/i] }, + { vendor: "FunCaptcha / Arkose", patterns: [/funcaptcha/i, /arkose/i] }, + { vendor: "Shape / F5", patterns: [/shape security/i, /imperva/i] }, +]; + +const CAPTCHA_HINTS = [ + /captcha/i, + /verify you are (?:a )?human/i, + /are you a robot/i, + /complete the security check/i, +]; +const BLOCK_HINTS = [ + /access denied/i, + /forbidden/i, + /403/i, + /unusual traffic/i, + /blocked/i, + /bot detected/i, + /request blocked/i, + /you have been blocked/i, +]; +const WALL_HINTS = [ + /sign in/i, + /log in to continue/i, + /create (?:an )?account/i, + /one[- ]time (?:pass)?code/i, + /enter the code/i, + /verification code/i, + /please log in/i, +]; + +export interface PageSignals { + title: string; + url: string; + /** A chunk of body text (first ~4k chars is plenty). */ + text: string; +} + +/** Returns the detected anti-bot / captcha vendor on the page, if any. */ +export function detectVendor(s: PageSignals): string | undefined { + const hay = `${s.title}\n${s.url}\n${s.text}`; + for (const { vendor, patterns } of VENDOR_SIGNATURES) { + if (patterns.some((p) => p.test(hay))) return vendor; + } + return undefined; +} + +function anyMatch(s: PageSignals, hints: RegExp[]): boolean { + const hay = `${s.title}\n${s.text}`; + return hints.some((p) => p.test(hay)); +} + +/** + * Decide the outcome of an attempt from (a) the model's success judgment and + * (b) hard signals scraped off the page. Page signals win for failures so we + * can attribute the failure to a vendor rather than vague "agent failed". + */ +export function classify(opts: { + graderSuccess: boolean; + graderBlocked: boolean; + timedOut: boolean; + errored: boolean; + signals?: PageSignals; +}): { outcome: Outcome; detected?: string; reason: string } { + const { graderSuccess, graderBlocked, timedOut, errored, signals } = opts; + const detected = signals ? detectVendor(signals) : undefined; + + if (errored && !signals) { + return { + outcome: "error", + reason: "Session/runtime error before the page could be evaluated.", + }; + } + + if (graderSuccess && !graderBlocked) { + return { outcome: "pass", detected, reason: "Task completed and success criteria met." }; + } + + // Failure path โ€” attribute it as specifically as possible. + if (signals) { + if (anyMatch(signals, CAPTCHA_HINTS) || /captcha/i.test(detected ?? "")) { + return { + outcome: "captcha_unsolved", + detected, + reason: `CAPTCHA challenge not solved${detected ? ` (${detected})` : ""}.`, + }; + } + if (graderBlocked || anyMatch(signals, BLOCK_HINTS)) { + return { + outcome: "blocked_antibot", + detected, + reason: `Blocked by anti-bot${detected ? ` (${detected})` : ""}.`, + }; + } + if (anyMatch(signals, WALL_HINTS)) { + return { + outcome: "account_wall", + detected, + reason: "Stopped at a login / OTP / account wall.", + }; + } + } + + if (timedOut) { + return { + outcome: "timeout", + detected, + reason: "Ran out of steps / time before completing the task.", + }; + } + if (graderBlocked) { + return { + outcome: "blocked_antibot", + detected, + reason: `Blocked by anti-bot${detected ? ` (${detected})` : ""}.`, + }; + } + return { outcome: "error", detected, reason: "Task did not meet success criteria." }; +} diff --git a/packages/examples/configurable-browser-trial/src/cli.ts b/packages/examples/configurable-browser-trial/src/cli.ts new file mode 100644 index 0000000000..dbbc1fc296 --- /dev/null +++ b/packages/examples/configurable-browser-trial/src/cli.ts @@ -0,0 +1,201 @@ +import { Command } from "commander"; +import { mkdirSync, writeFileSync, existsSync, copyFileSync, readFileSync } from "node:fs"; +import { resolve, join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import pc from "picocolors"; +import { loadManifest, checkEnv, applyPreset } from "./config.js"; +import { runAttempt } from "./runner.js"; +import { pool } from "./pool.js"; +import { buildScorecard, OUTCOME_LABELS } from "./scorecard.js"; +import { renderMarkdown } from "./report/markdown.js"; +import { renderHtml } from "./report/html.js"; +import type { AttemptResult, Manifest, Scorecard } from "./types.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, ".."); + +// Best-effort .env loader (no dotenv dep โ€” keep the template lean). +function loadDotenv() { + const p = resolve(process.cwd(), ".env"); + if (!existsSync(p)) return; + for (const line of readFileSync(p, "utf8").split("\n")) { + const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); + if (m && !process.env[m[1]]) { + process.env[m[1]] = m[2].replace(/^["']|["']$/g, ""); + } + } +} +loadDotenv(); + +const ts = () => new Date().toISOString().replace("T", " ").slice(0, 19); +const banner = () => + console.log(pc.bold(pc.red("\n bbpoc")) + pc.dim(" โ€” Browserbase verified-trial harness\n")); + +/** Build the flat task list (site ร— attempts) and run it through the pool. */ +async function executeTrial(manifest: Manifest, opts: { live: boolean }): Promise { + const startedAt = ts(); + const jobs: { siteIndex: number; attempt: number }[] = []; + manifest.sites.forEach((s, i) => { + const n = s.attempts ?? manifest.defaults.attempts; + for (let a = 1; a <= n; a++) jobs.push({ siteIndex: i, attempt: a }); + }); + + console.log( + pc.dim( + ` ${manifest.sites.length} sites ร— attempts = ${jobs.length} sessions ยท ` + + `concurrency ${manifest.defaults.concurrency} ยท region ${manifest.defaults.region}`, + ), + ); + console.log( + pc.dim( + ` stealth ${manifest.defaults.features.advancedStealth ? "ON" : "OFF"} ยท ` + + `proxies ${manifest.defaults.features.proxies ? "ON" : "OFF"}` + + `${manifest.defaults.features.proxyCountry ? ` (${manifest.defaults.features.proxyCountry})` : ""} ยท ` + + `captcha ${manifest.defaults.features.solveCaptchas ? "ON" : "OFF"}\n`, + ), + ); + + let done = 0; + const results = await pool<(typeof jobs)[number], AttemptResult>( + jobs, + manifest.defaults.concurrency, + (job) => runAttempt(manifest.sites[job.siteIndex], manifest.defaults, job.attempt), + (r) => { + done++; + const icon = r.success ? pc.green("โœ“") : pc.red("โœ—"); + const tag = r.success + ? pc.green(OUTCOME_LABELS[r.outcome]) + : pc.yellow(OUTCOME_LABELS[r.outcome]); + console.log( + ` ${icon} [${String(done).padStart(2)}/${jobs.length}] ${pc.bold(r.site)} #${r.attempt} โ€” ${tag}` + + (r.detected ? pc.dim(` ยท ${r.detected}`) : "") + + pc.dim(` ยท ${(r.durationMs / 1000).toFixed(1)}s`), + ); + }, + ); + + return buildScorecard(manifest, results, startedAt, ts()); +} + +function writeReports(s: Scorecard, outDir: string) { + mkdirSync(outDir, { recursive: true }); + const md = join(outDir, "scorecard.md"); + const html = join(outDir, "scorecard.html"); + const jsonPath = join(outDir, "results.json"); + writeFileSync(md, renderMarkdown(s)); + writeFileSync(html, renderHtml(s)); + writeFileSync(jsonPath, JSON.stringify(s, null, 2)); + console.log("\n" + pc.bold(" Reports written:")); + console.log(" " + pc.cyan(md) + pc.dim(" (acceptance matrix)")); + console.log(" " + pc.cyan(html) + pc.dim(" (leadership-ready, open in a browser)")); + console.log(" " + pc.cyan(jsonPath) + pc.dim(" (raw)")); +} + +function printSummary(s: Scorecard) { + console.log("\n" + pc.bold(" โ”€โ”€ Scorecard โ”€โ”€")); + for (const site of s.sites) { + const ok = site.met ? pc.green("MET ") : pc.red("MISS"); + const rate = `${Math.round(site.successRate * 100)}%`.padStart(4); + console.log( + ` ${ok} ${rate} ${pc.bold(site.name)} ${pc.dim(`(target ${Math.round(site.target * 100)}%)`)}`, + ); + } + const all = s.sitesMet === s.siteCount; + console.log( + "\n " + + (all + ? pc.green(pc.bold(`โœ“ ${s.sitesMet}/${s.siteCount} sites met target`)) + : pc.yellow(pc.bold(`${s.sitesMet}/${s.siteCount} sites met target`))) + + pc.dim(` ยท ${Math.round(s.overallRate * 100)}% overall`), + ); +} + +const program = new Command(); +program + .name("bbpoc") + .description( + "Turn a Browserbase verified/advanced-stealth trial into a one-day self-serve smoke test.", + ) + .version("0.1.0"); + +program + .command("init") + .description("Scaffold trial.yaml + .env in the current directory") + .action(() => { + banner(); + const targets: [string, string][] = [ + [join(ROOT, "examples", "trial.example.yaml"), "trial.yaml"], + [join(ROOT, ".env.example"), ".env"], + ]; + for (const [src, dest] of targets) { + const out = resolve(process.cwd(), dest); + if (existsSync(out)) { + console.log(pc.yellow(` โ€ข ${dest} already exists โ€” skipped`)); + continue; + } + copyFileSync(src, out); + console.log(pc.green(` โœ“ created ${dest}`)); + } + console.log( + "\n Next:\n" + + pc.dim(" 1. fill in .env with your Browserbase + model keys\n") + + pc.dim(" 2. edit trial.yaml โ€” your URLs, tasks, targets\n") + + pc.dim(" 3. ") + + pc.cyan("bbpoc smoke") + + pc.dim(" (quick sanity, 1 attempt/site)\n") + + pc.dim(" 4. ") + + pc.cyan("bbpoc run") + + pc.dim(" (full trial)\n"), + ); + }); + +function trialCommand(name: string, smoke: boolean) { + program + .command(name) + .description( + smoke ? "Quick sanity run โ€” 1 attempt per site" : "Run the full trial per trial.yaml", + ) + .option("-c, --config ", "path to manifest", "trial.yaml") + .option("-o, --out ", "output directory", "results") + .option("--preset ", "feature preset: verified | baseline | stealth-only") + .option("--concurrency ", "override concurrency", (v) => parseInt(v, 10)) + .option("--attempts ", "override attempts per site", (v) => parseInt(v, 10)) + .option("--no-stealth", "disable advanced stealth (control run)") + .option("--no-proxies", "disable residential proxies") + .action(async (opts) => { + banner(); + let manifest = loadManifest(opts.config); + manifest = applyPreset(manifest, opts.preset); + if (smoke) manifest.defaults.attempts = 1; + if (opts.attempts) manifest.defaults.attempts = opts.attempts; + if (opts.concurrency) manifest.defaults.concurrency = opts.concurrency; + if (opts.stealth === false) manifest.defaults.features.advancedStealth = false; + if (opts.proxies === false) manifest.defaults.features.proxies = false; + + checkEnv(manifest.defaults.model); + + const s = await executeTrial(manifest, { live: true }); + printSummary(s); + writeReports(s, resolve(process.cwd(), opts.out)); + }); +} +trialCommand("run", false); +trialCommand("smoke", true); + +program + .command("report") + .description("Regenerate markdown + HTML from a previous results.json") + .option("-i, --in ", "results.json path", "results/results.json") + .option("-o, --out ", "output directory", "results") + .action((opts) => { + banner(); + const p = resolve(process.cwd(), opts.in); + if (!existsSync(p)) { + console.error(pc.red(`โœ– ${opts.in} not found. Run a trial first.`)); + process.exit(1); + } + const s = JSON.parse(readFileSync(p, "utf8")) as Scorecard; + writeReports(s, resolve(process.cwd(), opts.out)); + }); + +program.parseAsync(process.argv); diff --git a/packages/examples/configurable-browser-trial/src/config.ts b/packages/examples/configurable-browser-trial/src/config.ts new file mode 100644 index 0000000000..29644594df --- /dev/null +++ b/packages/examples/configurable-browser-trial/src/config.ts @@ -0,0 +1,96 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { parse } from "yaml"; +import pc from "picocolors"; +import { ManifestSchema, type Manifest, type Features } from "./types.js"; + +/** Resolve which model API key env var to use for a given "provider/model" id. */ +export function resolveModelKey(model: string): { key: string; envVar: string } | null { + const provider = model.split("/")[0]; + const map: Record = { + anthropic: "ANTHROPIC_API_KEY", + openai: "OPENAI_API_KEY", + google: "GOOGLE_GENERATIVE_AI_API_KEY", + gemini: "GOOGLE_GENERATIVE_AI_API_KEY", + }; + const envVar = map[provider]; + if (envVar && process.env[envVar]) return { key: process.env[envVar]!, envVar }; + if (process.env.STAGEHAND_MODEL_API_KEY) + return { key: process.env.STAGEHAND_MODEL_API_KEY, envVar: "STAGEHAND_MODEL_API_KEY" }; + return null; +} + +/** Load + validate the trial manifest. Exits with a friendly message on error. */ +export function loadManifest(path: string): Manifest { + const abs = resolve(process.cwd(), path); + let raw: string; + try { + raw = readFileSync(abs, "utf8"); + } catch { + console.error(pc.red(`โœ– Could not read manifest at ${abs}`)); + console.error(pc.dim(` Run ${pc.cyan("bbpoc init")} to scaffold one.`)); + process.exit(1); + } + let data: unknown; + try { + data = parse(raw); + } catch (e) { + console.error(pc.red(`โœ– ${path} is not valid YAML:`), (e as Error).message); + process.exit(1); + } + const parsed = ManifestSchema.safeParse(data); + if (!parsed.success) { + console.error(pc.red(`โœ– ${path} has invalid fields:`)); + for (const issue of parsed.error.issues) { + console.error(pc.dim(` โ€ข ${issue.path.join(".") || "(root)"}: ${issue.message}`)); + } + process.exit(1); + } + return parsed.data; +} + +/** Verify the required env is present before we spend money on sessions. */ +export function checkEnv(model: string): void { + const missing: string[] = []; + if (!process.env.BROWSERBASE_API_KEY) missing.push("BROWSERBASE_API_KEY"); + if (!process.env.BROWSERBASE_PROJECT_ID) missing.push("BROWSERBASE_PROJECT_ID"); + if (missing.length) { + console.error(pc.red(`โœ– Missing required env: ${missing.join(", ")}`)); + console.error(pc.dim(" Copy .env.example โ†’ .env and fill it in.")); + process.exit(1); + } + if (!resolveModelKey(model)) { + console.error(pc.red(`โœ– No model API key found for "${model}".`)); + console.error( + pc.dim(" Set ANTHROPIC_API_KEY (default), OPENAI_API_KEY, or GOOGLE_GENERATIVE_AI_API_KEY."), + ); + process.exit(1); + } +} + +/** Merge manifest defaults with per-site feature overrides. */ +export function effectiveFeatures(base: Features, override?: Partial): Features { + return { ...base, ...(override ?? {}) }; +} + +/** Apply a named preset to the parsed manifest (CLI sugar like `--preset verified`). */ +export function applyPreset(manifest: Manifest, preset?: string): Manifest { + if (!preset) return manifest; + const presets: Record> = { + // Full advanced-stealth posture โ€” the canonical enterprise trial. + verified: { advancedStealth: true, proxies: true, solveCaptchas: true }, + // Baseline cloud browser, no stealth โ€” useful as an A/B control. + baseline: { advancedStealth: false, proxies: false, solveCaptchas: false }, + // Stealth without proxies (rare, but customers ask). + "stealth-only": { advancedStealth: true, proxies: false }, + }; + const p = presets[preset]; + if (!p) { + console.error( + pc.red(`โœ– Unknown preset "${preset}". Options: ${Object.keys(presets).join(", ")}`), + ); + process.exit(1); + } + manifest.defaults.features = { ...manifest.defaults.features, ...p }; + return manifest; +} diff --git a/packages/examples/configurable-browser-trial/src/pool.ts b/packages/examples/configurable-browser-trial/src/pool.ts new file mode 100644 index 0000000000..0d8515d344 --- /dev/null +++ b/packages/examples/configurable-browser-trial/src/pool.ts @@ -0,0 +1,24 @@ +/** Run async tasks with a bounded concurrency limit, preserving input order. */ +export async function pool( + items: T[], + limit: number, + worker: (item: T, index: number) => Promise, + onResult?: (result: R, item: T, index: number) => void, +): Promise { + const results = new Array(items.length); + let next = 0; + const size = Math.max(1, Math.min(limit, items.length)); + + async function runner() { + while (true) { + const i = next++; + if (i >= items.length) return; + const r = await worker(items[i], i); + results[i] = r; + onResult?.(r, items[i], i); + } + } + + await Promise.all(Array.from({ length: size }, runner)); + return results; +} diff --git a/packages/examples/configurable-browser-trial/src/report/html.ts b/packages/examples/configurable-browser-trial/src/report/html.ts new file mode 100644 index 0000000000..a47235c418 --- /dev/null +++ b/packages/examples/configurable-browser-trial/src/report/html.ts @@ -0,0 +1,156 @@ +import { OUTCOME_LABELS } from "../scorecard.js"; +import type { Outcome, Scorecard, SiteScore } from "../types.js"; + +// Official Browserbase brand palette. +const C = { + primary: "#F03603", + black: "#100D0D", + gray: "#514F4F", + white: "#F9F6F4", + blue: "#4DA9E4", + yellow: "#F4BA41", + green: "#90C94D", + border: "#edebeb", +}; + +const OUTCOME_COLOR: Record = { + pass: C.green, + blocked_antibot: C.primary, + captcha_unsolved: C.primary, + account_wall: C.yellow, + timeout: C.yellow, + error: C.gray, +}; + +const pct = (n: number) => `${Math.round(n * 100)}%`; +const esc = (s: string) => + s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +function donut(rate: number, met: boolean): string { + const color = met ? C.green : rate >= 0.5 ? C.yellow : C.primary; + const deg = Math.round(rate * 360); + return `
+
${pct(rate)}
+
`; +} + +function featureChips(s: Scorecard): string { + const f = s.features; + const chip = (label: string, on: boolean) => + `${on ? "โœ“" : "โœ•"} ${label}`; + return [ + chip("Advanced Stealth", f.advancedStealth), + chip(`Residential Proxies${f.proxyCountry ? ` ยท ${f.proxyCountry}` : ""}`, f.proxies), + chip("CAPTCHA Solving", f.solveCaptchas), + chip("Ad Blocking", f.blockAds), + ].join(""); +} + +function siteCard(site: SiteScore): string { + const rows = site.results + .map((r) => { + const color = OUTCOME_COLOR[r.outcome]; + const replay = r.replayUrl + ? `โ–ถ replay` + : "โ€”"; + return ` + ${r.attempt} + ${OUTCOME_LABELS[r.outcome]} + ${esc(r.reason)} + ${replay} + `; + }) + .join(""); + + const seen = site.detected.length ? site.detected.join(", ") : (site.antibot ?? "โ€”"); + return `
+
+ ${donut(site.successRate, site.met)} +
+

${esc(site.name)} ${site.met ? `TARGET MET` : `BELOW TARGET`}

+
${esc(site.task)}
+
${site.passes}/${site.attempts} passed ยท target ${pct(site.target)} ยท anti-bot: ${esc(seen)}
+ ${esc(site.url)} +
+
+ + + ${rows} +
#OutcomeReasonSession
+
`; +} + +/** A clean, on-brand, single-file report a customer can forward to their CTO. */ +export function renderHtml(s: Scorecard): string { + const title = s.customer ? `${esc(s.customer)} โ€” ${esc(s.name)}` : esc(s.name); + return ` + + + + +${title} + + + +
+
+
Browserbase ยท Verified Trial Scorecard
+

${title}

+
${esc(s.startedAt)} โ†’ ${esc(s.finishedAt)} ยท ${esc(s.region)} ยท ${esc(s.model)}
+
${featureChips(s)}
+
+ +
+
${s.sitesMet}/${s.siteCount}
Sites hit target
+
${pct(s.overallRate)}
Overall success
+
${s.totalPasses}/${s.totalAttempts}
Attempts passed
+
+ +

Per-site results

+ ${s.sites.map(siteCard).join("\n")} + +
+ Generated by bbpoc โ€” the Browserbase verified-trial harness. Each session above is a real, replayable cloud-browser run. +
+
+ +`; +} diff --git a/packages/examples/configurable-browser-trial/src/report/markdown.ts b/packages/examples/configurable-browser-trial/src/report/markdown.ts new file mode 100644 index 0000000000..08b8963229 --- /dev/null +++ b/packages/examples/configurable-browser-trial/src/report/markdown.ts @@ -0,0 +1,75 @@ +import { OUTCOME_LABELS } from "../scorecard.js"; +import type { Scorecard } from "../types.js"; + +const pct = (n: number) => `${Math.round(n * 100)}%`; + +function featureLine(s: Scorecard): string { + const f = s.features; + const on = (b: boolean) => (b ? "โœ…" : "โŒ"); + return [ + `Advanced Stealth ${on(f.advancedStealth)}`, + `Residential Proxies ${on(f.proxies)}${f.proxyCountry ? ` (${f.proxyCountry})` : ""}`, + `CAPTCHA Solving ${on(f.solveCaptchas)}`, + `Ad Blocking ${on(f.blockAds)}`, + ].join(" ยท "); +} + +/** The "acceptance matrix" CEs build by hand โ€” one row per site, machine-made. */ +export function renderMarkdown(s: Scorecard): string { + const lines: string[] = []; + lines.push(`# ${s.name} โ€” Results Scorecard`); + if (s.customer) lines.push(`**Customer:** ${s.customer} `); + lines.push(`**Run:** ${s.startedAt} โ†’ ${s.finishedAt} `); + lines.push(`**Region:** ${s.region} ยท **Model:** ${s.model} `); + lines.push(`**Config:** ${featureLine(s)}`); + lines.push(""); + + // Headline. + const headline = + `> **${s.sitesMet}/${s.siteCount} sites hit their target.** ` + + `Overall ${pct(s.overallRate)} success across ${s.totalAttempts} attempts.`; + lines.push(headline); + lines.push(""); + + // Acceptance matrix. + lines.push("## Acceptance Matrix"); + lines.push(""); + lines.push("| Site | Task | Success | Target | Result | Top failure | Anti-bot seen |"); + lines.push("|------|------|---------|--------|--------|-------------|---------------|"); + for (const site of s.sites) { + const result = site.met ? "โœ… Met" : "โŒ Below"; + const fail = site.topFailure ? OUTCOME_LABELS[site.topFailure] : "โ€”"; + const seen = site.detected.length ? site.detected.join(", ") : (site.antibot ?? "โ€”"); + lines.push( + `| ${site.name} | ${escapeCell(site.task)} | ${site.passes}/${site.attempts} (${pct(site.successRate)}) | ${pct(site.target)} | ${result} | ${fail} | ${seen} |`, + ); + } + lines.push(""); + + // Per-site detail with session replay links (the evidence customers want). + lines.push("## Session Detail"); + for (const site of s.sites) { + lines.push(""); + lines.push(`### ${site.name} โ€” ${pct(site.successRate)} (${site.passes}/${site.attempts})`); + lines.push(""); + lines.push("| # | Outcome | Reason | Replay |"); + lines.push("|---|---------|--------|--------|"); + for (const r of site.results) { + const icon = r.success ? "๐ŸŸข" : "๐Ÿ”ด"; + const replay = r.replayUrl ? `[session](${r.replayUrl})` : "โ€”"; + lines.push( + `| ${r.attempt} | ${icon} ${OUTCOME_LABELS[r.outcome]} | ${escapeCell(r.reason)} | ${replay} |`, + ); + } + } + lines.push(""); + lines.push("---"); + lines.push( + `_Generated by [bbpoc](https://github.com/browserbase/bbpoc) ยท Browserbase verified-trial harness._`, + ); + return lines.join("\n"); +} + +function escapeCell(s: string): string { + return s.replace(/\|/g, "\\|").replace(/\n/g, " ").slice(0, 160); +} diff --git a/packages/examples/configurable-browser-trial/src/runner.ts b/packages/examples/configurable-browser-trial/src/runner.ts new file mode 100644 index 0000000000..551fa97da3 --- /dev/null +++ b/packages/examples/configurable-browser-trial/src/runner.ts @@ -0,0 +1,169 @@ +import { Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod"; +import { classify, type PageSignals } from "./classify.js"; +import { resolveModelKey, effectiveFeatures } from "./config.js"; +import type { AttemptResult, Defaults, Features, Site } from "./types.js"; + +const REPLAY_BASE = "https://www.browserbase.com/sessions"; + +/** Build the Browserbase session create params from the resolved feature flags. */ +function sessionParams(features: Features, region: string, projectId: string) { + const browserSettings: Record = { + advancedStealth: features.advancedStealth, + blockAds: features.blockAds, + solveCaptchas: features.solveCaptchas, + }; + // Geo-targeted residential proxies need the array form; plain `true` otherwise. + let proxies: unknown = features.proxies; + if (features.proxies && features.proxyCountry) { + proxies = [{ type: "browserbase", geolocation: { country: features.proxyCountry } }]; + } + return { projectId, region, proxies, browserSettings, keepAlive: false }; +} + +const GraderSchema = z.object({ + success: z.boolean().describe("True only if the stated goal was clearly achieved."), + blocked: z + .boolean() + .describe("True if the page shows bot-detection, access-denied, or an unsolved CAPTCHA."), + evidence: z.string().describe("One sentence of evidence for the judgment."), +}); + +async function captureSignals(page: any): Promise { + try { + const [title, text] = await Promise.all([ + page.title().catch(() => ""), + page.evaluate(() => (document.body?.innerText || "").slice(0, 4000)).catch(() => ""), + ]); + return { title, url: page.url(), text }; + } catch { + return undefined; + } +} + +function withTimeout(p: Promise, ms: number): Promise { + return Promise.race([ + p, + new Promise<"__timeout__">((res) => setTimeout(() => res("__timeout__"), ms)), + ]); +} + +/** + * Run ONE attempt against ONE site in a fresh session (= fresh proxy IP, which + * is exactly the "retry to rotate the proxy" pattern CEs recommend). + */ +export async function runAttempt( + site: Site, + defaults: Defaults, + attempt: number, +): Promise { + const start = Date.now(); + const name = site.name ?? new URL(site.url).hostname.replace(/^www\./, ""); + const features = effectiveFeatures(defaults.features, site.features); + const expect = site.expect ?? site.task; + const model = defaults.model; + const modelKey = resolveModelKey(model)!; + + const base: Omit = { + site: name, + url: site.url, + attempt, + durationMs: 0, + }; + + let stagehand: Stagehand | null = null; + try { + stagehand = new Stagehand({ + env: "BROWSERBASE", + apiKey: process.env.BROWSERBASE_API_KEY!, + projectId: process.env.BROWSERBASE_PROJECT_ID!, + modelName: model, + modelClientOptions: { apiKey: modelKey.key }, + verbose: 0, + browserbaseSessionCreateParams: sessionParams( + features, + defaults.region, + process.env.BROWSERBASE_PROJECT_ID!, + ) as any, + }); + + await stagehand.init(); + const sessionId = (stagehand as any).browserbaseSessionID as string | undefined; + const replayUrl = sessionId ? `${REPLAY_BASE}/${sessionId}` : undefined; + const page = stagehand.page; + + // Drive the task, bounded by the per-attempt time budget. + let timedOut = false; + const work = (async () => { + await page.goto(site.url, { waitUntil: "domcontentloaded" }); + const agent = stagehand!.agent(); + await agent.execute({ instruction: site.task, maxSteps: defaults.maxSteps }); + })(); + + const raced = await withTimeout(work, defaults.timeoutMs); + if (raced === "__timeout__") timedOut = true; + + const signals = await captureSignals(page); + + // Grade the result with the model (separate from the doing). + let graderSuccess = false; + let graderBlocked = false; + try { + const verdict = await withTimeout( + page.extract({ + instruction: + `Goal: "${expect}".\n` + + `Judge ONLY from the current page. Did the goal succeed? ` + + `Set blocked=true if you see bot-detection, access-denied, or an unsolved CAPTCHA.`, + schema: GraderSchema, + }), + 30_000, + ); + if (verdict !== "__timeout__") { + graderSuccess = verdict.success; + graderBlocked = verdict.blocked; + } + } catch { + /* grading failed; classifier falls back to page signals */ + } + + const { outcome, detected, reason } = classify({ + graderSuccess, + graderBlocked, + timedOut, + errored: false, + signals, + }); + + return { + ...base, + outcome, + success: outcome === "pass", + reason, + detected, + sessionId, + replayUrl, + durationMs: Date.now() - start, + }; + } catch (err) { + const message = (err as Error)?.message ?? String(err); + // Make the enterprise-gating failure mode unmissable. + const stealthGated = + /stealth|enterprise|not.*allowed|forbidden|plan/i.test(message) && features.advancedStealth; + return { + ...base, + outcome: "error", + success: false, + reason: stealthGated + ? `Session failed โ€” advanced stealth is Enterprise/Scale-plan only. Ask your Browserbase contact to enable it, or run with --preset baseline. (${message})` + : `Session error: ${message}`, + durationMs: Date.now() - start, + }; + } finally { + try { + await stagehand?.close(); + } catch { + /* ignore */ + } + } +} diff --git a/packages/examples/configurable-browser-trial/src/scorecard.ts b/packages/examples/configurable-browser-trial/src/scorecard.ts new file mode 100644 index 0000000000..b53ca2e385 --- /dev/null +++ b/packages/examples/configurable-browser-trial/src/scorecard.ts @@ -0,0 +1,85 @@ +import type { AttemptResult, Manifest, Outcome, Scorecard, SiteScore } from "./types.js"; + +function topFailure(results: AttemptResult[]): Outcome | undefined { + const counts = new Map(); + for (const r of results) { + if (r.outcome === "pass") continue; + counts.set(r.outcome, (counts.get(r.outcome) ?? 0) + 1); + } + let best: Outcome | undefined; + let bestN = 0; + for (const [o, n] of counts) { + if (n > bestN) { + best = o; + bestN = n; + } + } + return best; +} + +/** Fold the flat attempt list into a per-site + overall scorecard. */ +export function buildScorecard( + manifest: Manifest, + attempts: AttemptResult[], + startedAt: string, + finishedAt: string, +): Scorecard { + const bySite = new Map(); + for (const a of attempts) { + const arr = bySite.get(a.url) ?? []; + arr.push(a); + bySite.set(a.url, arr); + } + + const sites: SiteScore[] = manifest.sites.map((s) => { + const results = bySite.get(s.url) ?? []; + const name = s.name ?? new URL(s.url).hostname.replace(/^www\./, ""); + const passes = results.filter((r) => r.success).length; + const successRate = results.length ? passes / results.length : 0; + const target = s.target ?? manifest.defaults.target; + const detected = [...new Set(results.map((r) => r.detected).filter(Boolean) as string[])]; + return { + name, + url: s.url, + task: s.task, + antibot: s.antibot, + attempts: results.length, + passes, + successRate, + target, + met: successRate >= target, + topFailure: topFailure(results), + detected, + results, + }; + }); + + const totalAttempts = attempts.length; + const totalPasses = attempts.filter((a) => a.success).length; + const sitesMet = sites.filter((s) => s.met).length; + + return { + name: manifest.name, + customer: manifest.customer, + startedAt, + finishedAt, + features: manifest.defaults.features, + region: manifest.defaults.region, + model: manifest.defaults.model, + sites, + totalAttempts, + totalPasses, + overallRate: totalAttempts ? totalPasses / totalAttempts : 0, + sitesMet, + siteCount: sites.length, + }; +} + +export const OUTCOME_LABELS: Record = { + pass: "Pass", + blocked_antibot: "Blocked (anti-bot)", + captcha_unsolved: "CAPTCHA unsolved", + account_wall: "Account/OTP wall", + timeout: "Timed out", + error: "Error", +}; diff --git a/packages/examples/configurable-browser-trial/src/types.ts b/packages/examples/configurable-browser-trial/src/types.ts new file mode 100644 index 0000000000..75cfef7400 --- /dev/null +++ b/packages/examples/configurable-browser-trial/src/types.ts @@ -0,0 +1,135 @@ +import { z } from "zod"; + +/** + * The feature flags that map 1:1 to what customers actually toggle during a + * Browserbase verified / advanced-stealth trial. These are the knobs CEs spend + * a week explaining over Slack โ€” here they are defaults you can't get wrong. + */ +export const FeaturesSchema = z.object({ + /** + * Advanced stealth ("Verified"). Enterprise/Scale-plan only. Default ON โ€” + * the single most common trial mistake is testing bot-detection WITHOUT it. + */ + advancedStealth: z.boolean().default(true), + /** Browserbase-managed residential proxies. Paired with stealth โ€” "it's a must". */ + proxies: z.boolean().default(true), + /** In-house CAPTCHA solving (Cloudflare, hCaptcha, FunCaptcha, reCAPTCHAโ€ฆ). */ + solveCaptchas: z.boolean().default(true), + /** Block ads/trackers to cut noise + bandwidth. */ + blockAds: z.boolean().default(true), + /** + * ISO country code for residential proxy geo-targeting (e.g. "US", "BR", + * "MX"). Many sites surface proxy location during MFA โ€” match the customer's. + */ + proxyCountry: z.string().optional(), +}); +export type Features = z.infer; + +export const SiteSchema = z.object({ + /** Target URL to start from. */ + url: z.string().url(), + /** Natural-language description of what the agent should accomplish. */ + task: z.string().min(1), + /** + * What "success" looks like, in plain English. Used to grade each attempt. + * If omitted, falls back to `task`. + */ + expect: z.string().optional(), + /** Friendly label for reports. Defaults to the URL hostname. */ + name: z.string().optional(), + /** Per-site success-rate target (0โ€“1). Defaults to manifest-level target. */ + target: z.number().min(0).max(1).optional(), + /** Informational: anti-bot vendor you expect to face (cloudflare, akamaiโ€ฆ). */ + antibot: z.string().optional(), + /** Per-site feature overrides (e.g. turn proxies off for one site). */ + features: FeaturesSchema.partial().optional(), + /** Per-site attempt-count override. */ + attempts: z.number().int().positive().optional(), +}); +export type Site = z.infer; + +export const DefaultsSchema = z.object({ + /** Times to run EACH site. Stability over N runs is the real bar, not 1-shot. */ + attempts: z.number().int().positive().default(3), + /** How many sessions to run at once. */ + concurrency: z.number().int().positive().default(5), + /** Browserbase region. */ + region: z.enum(["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"]).default("us-west-2"), + /** Default per-site success target (0โ€“1). */ + target: z.number().min(0).max(1).default(0.8), + /** Model that drives + grades each task. */ + model: z.string().default("anthropic/claude-sonnet-4-5"), + /** Max agent steps per attempt before we call it a timeout. */ + maxSteps: z.number().int().positive().default(18), + /** Per-attempt wall-clock budget in ms. */ + timeoutMs: z.number().int().positive().default(120_000), + features: FeaturesSchema.default({}), +}); +export type Defaults = z.infer; + +export const ManifestSchema = z.object({ + /** Name of the trial โ€” shows up on the report. */ + name: z.string().default("Browserbase Verified Trial"), + /** Customer / company name for branding the leadership report. */ + customer: z.string().optional(), + defaults: DefaultsSchema.default({}), + sites: z.array(SiteSchema).min(1), +}); +export type Manifest = z.infer; + +/** How a single attempt ended. Drives the scorecard + guardrail warnings. */ +export type Outcome = + | "pass" + | "blocked_antibot" + | "captcha_unsolved" + | "account_wall" + | "timeout" + | "error"; + +export interface AttemptResult { + site: string; + url: string; + attempt: number; + outcome: Outcome; + /** True only for `pass`. */ + success: boolean; + reason: string; + /** Anti-bot / captcha vendor detected on the page, if any. */ + detected?: string; + sessionId?: string; + /** Browserbase session replay URL โ€” the artifact CEs paste into Slack. */ + replayUrl?: string; + durationMs: number; +} + +export interface SiteScore { + name: string; + url: string; + task: string; + antibot?: string; + attempts: number; + passes: number; + successRate: number; + target: number; + met: boolean; + /** Most common non-pass outcome โ€” the headline failure mode. */ + topFailure?: Outcome; + detected: string[]; + results: AttemptResult[]; +} + +export interface Scorecard { + name: string; + customer?: string; + startedAt: string; + finishedAt: string; + features: Features; + region: string; + model: string; + sites: SiteScore[]; + totalAttempts: number; + totalPasses: number; + overallRate: number; + sitesMet: number; + siteCount: number; +} diff --git a/packages/examples/configurable-browser-trial/tsconfig.json b/packages/examples/configurable-browser-trial/tsconfig.json new file mode 100644 index 0000000000..01f4767512 --- /dev/null +++ b/packages/examples/configurable-browser-trial/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/examples/context/README.md b/packages/examples/context/README.md new file mode 100644 index 0000000000..274681141d --- /dev/null +++ b/packages/examples/context/README.md @@ -0,0 +1,12 @@ +# context + +Public recreation-site context reuse; login values are supplied at runtime, with no bundled account state. + +Choose a language and follow its setup instructions: + +| Language | Stagehand dependency | Directory from the repository root | +| ---------------------------------- | -------------------- | -------------------------------------- | +| [TypeScript](typescript/README.md) | `4.0.0` | `packages/examples/context/typescript` | +| [Python](python/README.md) | `4.0.0` | `packages/examples/context/python` | + +Each variant installs the published Stagehand package and keeps its own dependencies. diff --git a/packages/examples/context/python/.env.example b/packages/examples/context/python/.env.example new file mode 100644 index 0000000000..51832c620e --- /dev/null +++ b/packages/examples/context/python/.env.example @@ -0,0 +1,3 @@ +BROWSERBASE_API_KEY= +SF_REC_PARK_EMAIL= +SF_REC_PARK_PASSWORD= diff --git a/packages/examples/context/python/README.md b/packages/examples/context/python/README.md new file mode 100644 index 0000000000..12d5233464 --- /dev/null +++ b/packages/examples/context/python/README.md @@ -0,0 +1,65 @@ +# Stagehand + Browserbase: Context Authentication Example + +Location in the Stagehand repository: `packages/examples/context/python`. + +Stagehand is the SDK for browser agents. + +## AT A GLANCE + +- Goal: demonstrate persistent authentication using Browserbase **contexts** that survive across sessions. +- Flow: create context โ†’ log in once โ†’ persist cookies/tokens โ†’ reuse context in a new session โ†’ extract data โ†’ clean up. +- Benefits: skip re-auth on subsequent runs, reduce MFA prompts, speed up protected flows, and keep state stable across retries. + Docs โ†’ https://docs.browserbase.com/features/contexts + +## GLOSSARY + +- context: a persistent browser state (cookies, localStorage, cache) stored server-side and reusable by new sessions. + Docs โ†’ https://docs.browserbase.com/features/contexts +- persist: when true, any state changes during a session are written back to the context for future reuse. +- act: perform UI actions from a prompt (click, type, navigate). + Docs โ†’ https://docs.stagehand.dev/v4/basics/act + +## QUICKSTART + +1. cd packages/examples/context/python +2. uv venv venv +3. source venv/bin/activate # On Windows: venv\Scripts\activate +4. pip install -r requirements.txt +5. pip install browserbase pydantic requests +6. cp .env.example .env # Add your Browserbase API key and SF Rec Park credentials to .env +7. python main.py + +## EXPECTED OUTPUT + +- Creates context, performs login, saves auth state +- Reuses context in new session to access authenticated pages +- Extracts user data using structured schemas +- Cleans up context after completion + +## COMMON PITFALLS + +- "ModuleNotFoundError": ensure all dependencies are installed via pip +- Missing credentials: verify .env contains all required variables +- Context persistence: ensure persist=True is set to save login state +- Import errors: activate your virtual environment if you created one + +## USE CASES + +โ€ข Persistent login sessions: Automate workflows that require authentication without re-logging in every run. +โ€ข Access to gated content: Crawl or extract data from behind login walls (e.g., booking portals, dashboards, intranets). +โ€ข Multi-step workflows: Maintain cookies/tokens across different automation steps or scheduled jobs. + +## NEXT STEPS + +โ€ข Extend to multiple apps: Reuse the same context across different authenticated websites within one session. +โ€ข Add session validation: Extract and verify account info (e.g., username, profile details) to confirm successful auth. +โ€ข Secure lifecycle: Rotate, refresh, and delete contexts programmatically to enforce security policies. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/context/python/main.py b/packages/examples/context/python/main.py new file mode 100644 index 0000000000..f31ad59c8d --- /dev/null +++ b/packages/examples/context/python/main.py @@ -0,0 +1,110 @@ +"""Persist and verify an authenticated Browserbase context with Stagehand V4.""" + +import asyncio +import json +import os + +from browserbase import AsyncBrowserbase +from dotenv import load_dotenv +from pydantic import BaseModel, Field + +from stagehand import Stagehand, browserbase + +load_dotenv() + +TARGET_URL = "https://www.rec.us/organizations/san-francisco-rec-park" + + +class UserData(BaseModel): + full_name: str = Field(min_length=1) + address: str = Field(min_length=1) + + +def require_env(name: str) -> str: + value = os.environ.get(name) + if not value: + raise RuntimeError(f"{name} is required") + return value + + +async def login_and_persist(context_id: str) -> None: + browser = await browserbase.launch( + api_key=require_env("BROWSERBASE_API_KEY"), + browser_settings={"context": {"id": context_id, "persist": True}}, + ) + try: + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + await page.goto(TARGET_URL, wait_until="domcontentloaded", timeout=60_000) + await stagehand.act("Click the Login button", page=page) + await stagehand.act( + "Fill the email or username field with %email%", + page=page, + variables={"email": require_env("SF_REC_PARK_EMAIL")}, + ) + await stagehand.act("Click the next, continue, or submit button", page=page) + await stagehand.act( + "Fill the password field with %password%", + page=page, + variables={"password": require_env("SF_REC_PARK_PASSWORD")}, + ) + await stagehand.act("Click the login, sign in, or submit button", page=page) + finally: + await stagehand.close() + finally: + await browser.close() + + +async def verify_reused_context(context_id: str) -> UserData: + browser = await browserbase.launch( + api_key=require_env("BROWSERBASE_API_KEY"), + browser_settings={"context": {"id": context_id, "persist": True}}, + ) + try: + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + await page.goto(TARGET_URL, wait_until="domcontentloaded", timeout=60_000) + await stagehand.act("Click the reservations button", page=page) + extracted = await stagehand.extract( + "Extract the authenticated user's full name and address", + UserData, + page=page, + ) + return extracted.data + finally: + await stagehand.close() + finally: + await browser.close() + + +async def main() -> None: + async with AsyncBrowserbase(api_key=require_env("BROWSERBASE_API_KEY")) as api: + context = await api.contexts.create() + print("Created temporary Browserbase context") + try: + await login_and_persist(context.id) + user = await verify_reused_context(context.id) + print("Reused context reached authenticated profile data:") + print(json.dumps(user.model_dump(mode="json"), indent=2)) + finally: + # The generated SDK currently sets a JSON content type on DELETE, so send an + # explicit empty object instead of an empty body. + await api.contexts.delete(context.id, extra_body={}) + print("Deleted temporary Browserbase context") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as error: + print(f"Context authentication example failed: {error}") + print("Docs: https://docs.stagehand.dev/v4/first-steps/introduction") + raise diff --git a/packages/examples/context/python/pyproject.toml b/packages/examples/context/python/pyproject.toml new file mode 100644 index 0000000000..ddfe0dc07e --- /dev/null +++ b/packages/examples/context/python/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "context" +version = "0.1.0" +requires-python = ">=3.11,<3.14" +dependencies = [ + "browserbase>=1.7.0", + "pydantic>=2.12,<3", + "python-dotenv==1.2.2", + "stagehand==4.0.0", +] + +[tool.uv] +package = false diff --git a/packages/examples/context/typescript/.env.example b/packages/examples/context/typescript/.env.example new file mode 100644 index 0000000000..51832c620e --- /dev/null +++ b/packages/examples/context/typescript/.env.example @@ -0,0 +1,3 @@ +BROWSERBASE_API_KEY= +SF_REC_PARK_EMAIL= +SF_REC_PARK_PASSWORD= diff --git a/packages/examples/context/typescript/README.md b/packages/examples/context/typescript/README.md new file mode 100644 index 0000000000..7f9c2879ca --- /dev/null +++ b/packages/examples/context/typescript/README.md @@ -0,0 +1,61 @@ +# Stagehand + Browserbase: Context Authentication Example + +Location in the Stagehand repository: `packages/examples/context/typescript`. + +## AT A GLANCE + +- Goal: demonstrate persistent authentication using Browserbase **contexts** that survive across sessions. +- Flow: create context โ†’ log in once โ†’ persist cookies/tokens โ†’ reuse context in a new session โ†’ extract data โ†’ clean up. +- Benefits: skip re-auth on subsequent runs, reduce MFA prompts, speed up protected flows, and keep state stable across retries. + Docs โ†’ https://docs.browserbase.com/features/contexts + +## GLOSSARY + +- context: a persistent browser state (cookies, localStorage, cache) stored server-side and reusable by new sessions. + Docs โ†’ https://docs.browserbase.com/features/contexts +- persist: when true, any state changes during a session are written back to the context for future reuse. +- act: perform UI actions from a prompt (click, type, navigate). + Docs โ†’ https://docs.stagehand.dev/v4/basics/act + +## QUICKSTART + +1. cd packages/examples/context/typescript +2. npm install +3. npm install axios +4. cp .env.example .env +5. Add your Browserbase API key, Project ID, and SF Rec Park credentials to .env +6. npm start + +## EXPECTED OUTPUT + +- Creates context, performs login, saves auth state +- Reuses context in new session to access authenticated pages +- Extracts user data using structured schemas +- Cleans up context after completion + +## COMMON PITFALLS + +- "Cannot find module": ensure all dependencies are installed +- Missing credentials: verify .env contains all required variables +- Context persistence: ensure persist: true is set to save login state + +## USE CASES + +โ€ข Persistent login sessions: Automate workflows that require authentication without re-logging in every run. +โ€ข Access to gated content: Crawl or extract data from behind login walls (e.g., booking portals, dashboards, intranets). +โ€ข Multi-step workflows: Maintain cookies/tokens across different automation steps or scheduled jobs. + +## NEXT STEPS + +โ€ข Extend to multiple apps: Reuse the same context across different authenticated websites within one session. +โ€ข Add session validation: Extract and verify account info (e.g., username, profile details) to confirm successful auth. +โ€ข Secure lifecycle: Rotate, refresh, and delete contexts programmatically to enforce security policies. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/context/typescript/index.ts b/packages/examples/context/typescript/index.ts new file mode 100644 index 0000000000..aabb546aa0 --- /dev/null +++ b/packages/examples/context/typescript/index.ts @@ -0,0 +1,149 @@ +// Stagehand + Browserbase: Context Authentication Example - See README.md for full documentation + +import "dotenv/config"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import { Browserbase } from "@browserbasehq/sdk"; +import { z } from "zod/v4"; + +async function createSessionContextID() { + const email = process.env.SF_REC_PARK_EMAIL; + const password = process.env.SF_REC_PARK_PASSWORD; + if (!process.env.BROWSERBASE_API_KEY || !email || !password) { + throw new Error( + "BROWSERBASE_API_KEY, SF_REC_PARK_EMAIL, and SF_REC_PARK_PASSWORD are required", + ); + } + + console.log("Creating new Browserbase context..."); + // First create a context using Browserbase SDK to get a context ID. + const bb = new Browserbase({ apiKey: process.env.BROWSERBASE_API_KEY! }); + const context = await bb.contexts.create(); + + console.log("Created Browserbase context"); + + // Create a single session using the context ID to perform initial login. + console.log("Creating session for initial login..."); + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + browserSettings: { + context: { + id: context.id, + persist: true, // Save authentication state to context + }, + }, + }); + console.log("Live View is available in the Browserbase Sessions dashboard"); + const stagehand = await Stagehand.create({ + browser: browser, + model: { modelName: "openai/gpt-4.1" }, + logging: { level: "info" }, + }); + + // Connect to existing session for login process. + + const page = (await browser.context.pages())[0]; + // Navigate to login page with extended timeout for slow-loading sites. + console.log("Navigating to SF Rec & Park login page..."); + await page.goto("https://www.rec.us/organizations/san-francisco-rec-park", { + waitUntil: "domcontentloaded", + timeout: 60000, + }); + + // Perform login sequence: each step is atomic to handle dynamic page changes. + console.log("Starting login sequence..."); + await stagehand.act("Click the Login button"); + await stagehand.act(`Fill in the email or username field with "${email}"`); + await stagehand.act("Click the next, continue, or submit button to proceed"); + await stagehand.act(`Fill in the password field with "${password}"`); + await stagehand.act("Click the login, sign in, or submit button"); + console.log("Login sequence completed!"); + + await stagehand.close().catch((error) => console.warn("Stagehand cleanup warning:", error)); + await browser.close().catch((error) => console.warn("Browser cleanup warning:", error)); + console.log("Authentication state saved to context"); + + // Return the context ID for reuse in future sessions. + return { id: context.id }; +} + +async function deleteContext(contextId: string) { + try { + console.log("Cleaning up Browserbase context"); + const bb = new Browserbase({ apiKey: process.env.BROWSERBASE_API_KEY! }); + // The generated SDK currently sets a JSON content type on DELETE, so send an + // explicit empty object instead of an empty body. + await bb.contexts.delete(contextId, { body: {} }); + console.log("Context deleted successfully"); + } catch (error: unknown) { + console.error("Error deleting context:", error instanceof Error ? error.message : error); + } +} + +async function main() { + console.log("Starting Context Authentication Example..."); + // Create context with login state for reuse in authenticated sessions. + const contextId = await createSessionContextID(); + + // Initialize new session using existing context to inherit authentication state. + // persist: true ensures any new changes (cookies, cache) are saved back to context. + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + browserSettings: { + context: { + id: contextId.id, + persist: true, + }, + }, + }); + const stagehand = await Stagehand.create({ + browser: browser, + model: { modelName: "openai/gpt-4.1" }, + logging: { level: "info" }, + }); + + // Creates session with inherited login state from context. + console.log("Authenticated session ready!"); + + const page = (await browser.context.pages())[0]; + + // Navigate to authenticated area - should skip login due to persisted cookies. + console.log("Navigating to authenticated area (should skip login)..."); + await page.goto("https://www.rec.us/organizations/san-francisco-rec-park", { + waitUntil: "domcontentloaded", + timeout: 60000, + }); + + // Navigate to user-specific area to access personal data. + await stagehand.act("Click on the reservations button"); + + // Extract structured user data using Zod schema for type safety. + // Schema ensures consistent data format and validates extracted content. + console.log("Extracting user profile data..."); + const { data: userData } = await stagehand.extract( + "Extract the user's full name and address", + z.object({ + fullName: z.string().min(1).describe("the user's full name"), + address: z.string().min(1).describe("the user's address"), + }), + ); + + console.log("Extracted user data:", userData); + + // Always close session to release resources and save any context changes. + await stagehand.close().catch((error) => console.warn("Stagehand cleanup warning:", error)); + await browser.close().catch((error) => console.warn("Browser cleanup warning:", error)); + console.log("Session closed successfully"); + + // Clean up context to prevent accumulation and ensure security. + await deleteContext(contextId.id); +} + +main().catch((err) => { + console.error("Error in context authentication example:", err); + console.error("Common issues:"); + console.error(" - Check .env file has SF_REC_PARK_EMAIL and SF_REC_PARK_PASSWORD"); + console.error(" - Verify BROWSERBASE_API_KEY is set"); + console.error(" - Ensure credentials are valid for SF Rec & Park"); + console.error("Docs: https://docs.stagehand.dev/v4/first-steps/introduction"); + process.exit(1); +}); diff --git a/packages/examples/context/typescript/package.json b/packages/examples/context/typescript/package.json new file mode 100644 index 0000000000..9976847c89 --- /dev/null +++ b/packages/examples/context/typescript/package.json @@ -0,0 +1,27 @@ +{ + "name": "context-authentication-template", + "version": "1.0.0", + "description": "Stagehand + Browserbase: Persistent Authentication with Contexts", + "type": "module", + "main": "index.ts", + "scripts": { + "build": "tsc --noEmit --skipLibCheck --target ES2022 --module NodeNext --moduleResolution NodeNext index.ts", + "start": "tsx index.ts", + "dev": "tsx watch index.ts" + }, + "dependencies": { + "@browserbasehq/sdk": "^2.9.0", + "@browserbasehq/stagehand": "4.0.0", + "dotenv": "^16.4.5", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^22.18.0", + "tsx": "^4.19.2", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.18.0" + }, + "packageManager": "pnpm@10.24.0" +} diff --git a/packages/examples/convex/.gitignore b/packages/examples/convex/.gitignore new file mode 100644 index 0000000000..8c5fbb9ced --- /dev/null +++ b/packages/examples/convex/.gitignore @@ -0,0 +1,2 @@ + +.env.local diff --git a/packages/examples/convex/README.md b/packages/examples/convex/README.md new file mode 100644 index 0000000000..aed8508444 --- /dev/null +++ b/packages/examples/convex/README.md @@ -0,0 +1,212 @@ +# Stagehand Component Example + +Location in the Stagehand repository: `packages/examples/convex`. + +Complete example showing how to use the `convex-stagehand` component for AI-powered web scraping. + +## Prerequisites + +Before running this example, you'll need: + +1. **Browserbase Account** (for cloud browser infrastructure) + - Sign up at https://browserbase.com + - Get your API key and Project ID from the dashboard + +2. **AI Model API Key** (for intelligent extraction) + - OpenAI: Get API key from https://platform.openai.com/api-keys + - OR Anthropic: Get API key from https://console.anthropic.com/ + +3. **Convex Account** (for backend deployment) + - Sign up at https://convex.dev (free) + +## Setup Instructions + +### 1. Install Dependencies + +```bash +cd packages/examples/convex +npm install +``` + +### 2. Configure Convex + +Create a new Convex deployment: + +```bash +npx convex dev +``` + +This will: + +- Prompt you to log in (if first time) +- Create a new project +- Start watching for changes + +### 3. Set Environment Variables + +In the Convex dashboard (opens automatically), go to **Settings โ†’ Environment Variables** and add: + +``` +BROWSERBASE_API_KEY=your_browserbase_api_key +BROWSERBASE_PROJECT_ID=your_browserbase_project_id +MODEL_API_KEY=your_llm_provider_api_key +``` + +**Where to find these:** + +- Browserbase: Dashboard โ†’ Settings โ†’ API Keys +- OpenAI: https://platform.openai.com/api-keys +- Anthropic: https://console.anthropic.com/settings/keys +- Other providers: See [supported models](https://docs.stagehand.dev/configuration/models) + +### 4. Run the Examples + +With `npx convex dev` still running in one terminal, open another terminal and run: + +**Extract HackerNews stories:** + +```bash +npx convex run example:scrapeHackerNews '{"maxStories": 5}' +``` + +**Extract GitHub repository info:** + +```bash +npx convex run example:scrapeGitHubRepo '{"owner": "anthropics", "repo": "anthropic-sdk-typescript"}' +``` + +**Find navigation links:** + +```bash +npx convex run example:findNavLinks '{"url": "https://news.ycombinator.com"}' +``` + +**Perform an action:** + +```bash +npx convex run example:performAction '{"url": "https://example.com", "actionToPerform": "Click the More information link"}' +``` + +**Search and extract results:** + +```bash +npx convex run example:searchAndExtract '{"searchQuery": "convex database"}' +``` + +**Extract products from e-commerce:** + +```bash +npx convex run example:scrapeProducts '{"url": "https://www.amazon.com/s?k=laptop"}' +``` + +## What Each Example Does + +### 1. `scrapeHackerNews` - Data Extraction + Database Persistence + +Extracts top stories from HackerNews and saves them to your Convex database. Shows the complete pattern: extract with AI โ†’ persist to Convex. + +**Expected output:** + +```json +{ + "count": 5, + "scrapedAt": "2024-01-14T12:03:32.123Z" +} +``` + +**Time:** ~10-12 seconds + +### 2. `scrapeGitHubRepo` - Dynamic Page Extraction + +Extracts repository metadata from GitHub including stars, forks, and language. + +**Time:** ~8-10 seconds + +### 3. `findNavLinks` - Element Discovery + +Uses the `observe` method to find interactive elements on a page. + +**Time:** ~5-8 seconds + +### 4. `performAction` - Browser Automation + +Uses the `act` method to click buttons and interact with pages. + +**Time:** ~6-9 seconds + +### 5. `searchAndExtract` - Multi-step Workflow + +Chains multiple actions together: searches Google, then extracts results. Shows the `workflow` method for complex automation. + +**Time:** ~15-18 seconds + +### 6. `scrapeProducts` - Real-world E-commerce + +Extracts product listings with prices and ratings. + +**Time:** ~10-13 seconds + +## View Your Data + +To see the scraped HackerNews stories in your database: + +```bash +npx convex dashboard +``` + +Navigate to **Data โ†’ hackerNewsStories** to see all scraped articles. + +## Using Different AI Models + +By default, examples use `openai/gpt-4o`. To use a different model, edit `convex/example.ts`: + +```typescript +const stagehand = new Stagehand(components.stagehand, { + browserbaseApiKey: process.env.BROWSERBASE_API_KEY!, + browserbaseProjectId: process.env.BROWSERBASE_PROJECT_ID!, + modelApiKey: process.env.MODEL_API_KEY!, // Your LLM provider API key + modelName: "anthropic/claude-3-5-sonnet-20241022", // Change model +}); +``` + +See the [Stagehand Models documentation](https://docs.stagehand.dev/configuration/models) for all supported models and providers. + +## Troubleshooting + +**"Couldn't resolve stagehand.lib.extract"** + +- Make sure you ran `npx convex dev` to deploy your functions +- The component needs to be pushed to Convex first + +**"Missing environment variable"** + +- Check that all three environment variables are set in Convex dashboard +- Variable names must match exactly (case-sensitive) + +**"Browserbase session failed"** + +- Verify your Browserbase API key and Project ID are correct +- Check your Browserbase dashboard for session logs + +**Slow performance** + +- This is normal - AI-powered browser automation takes 5-15 seconds per operation +- Check Browserbase dashboard to watch the live browser session + +**Model API errors** + +- Verify your OpenAI/Anthropic API key is valid +- Check you have credits available in your account + +## Next Steps + +1. **Modify the examples** - Edit `convex/example.ts` to scrape your own websites +2. **Add your own schema** - Extend `convex/schema.ts` with new tables +3. **Build your app** - Use these patterns in your own Convex application + +## Learn More + +- [Component Documentation](../README.md) +- [Stagehand REST API](https://stagehand.stldocs.app/api) +- [Convex Docs](https://docs.convex.dev) +- [Browserbase Docs](https://docs.browserbase.com) diff --git a/packages/examples/convex/convex/convex.config.ts b/packages/examples/convex/convex/convex.config.ts new file mode 100644 index 0000000000..e1b0222005 --- /dev/null +++ b/packages/examples/convex/convex/convex.config.ts @@ -0,0 +1,7 @@ +import { defineApp } from "convex/server"; +import stagehand from "@browserbasehq/convex-stagehand/convex.config"; + +const app = defineApp(); +app.use(stagehand, { name: "stagehand" }); + +export default app; diff --git a/packages/examples/convex/convex/example.ts b/packages/examples/convex/convex/example.ts new file mode 100644 index 0000000000..0ffc1819d2 --- /dev/null +++ b/packages/examples/convex/convex/example.ts @@ -0,0 +1,287 @@ +/** + * Example usage of the Stagehand component + */ + +import { action, internalMutation } from "./_generated/server"; +import { v } from "convex/values"; +import { Stagehand } from "@browserbasehq/convex-stagehand"; +import { components } from "./_generated/api"; +import { z } from "zod"; +import { internal } from "./_generated/api"; + +// Initialize the Stagehand client +const stagehand = new Stagehand(components.stagehand, { + browserbaseApiKey: process.env.BROWSERBASE_API_KEY!, + browserbaseProjectId: process.env.BROWSERBASE_PROJECT_ID!, + modelApiKey: process.env.MODEL_API_KEY!, + modelName: "openai/gpt-4o", +}); + +/** + * Example 1: Extract data from HackerNews and save to database + * + * Demonstrates the complete pattern: extract data with AI and persist to Convex. + */ +export const scrapeHackerNews = action({ + args: { + maxStories: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const numStories = args.maxStories || 5; + + // Extract data using Stagehand + const data = await stagehand.extract(ctx, { + url: "https://news.ycombinator.com", + instruction: `Extract the top ${numStories} stories from the front page. + For each story, get the title, URL, score (points), and age.`, + schema: z.object({ + stories: z.array( + z.object({ + title: z.string(), + url: z.string(), + score: z.string(), + age: z.string(), + }), + ), + }), + }); + + // Save to database + const scrapedAt = new Date().toISOString(); + await ctx.runMutation(internal.example.saveStories, { + stories: data.stories.map((story, index) => ({ + rank: index + 1, + ...story, + scrapedAt, + })), + }); + + return { + count: data.stories.length, + scrapedAt, + }; + }, +}); + +/** + * Example 2: Extract GitHub repository information + * + * Shows how to extract data from a dynamic page. + */ +export const scrapeGitHubRepo = action({ + args: { + owner: v.string(), + repo: v.string(), + }, + handler: async (ctx, args) => { + const url = `https://github.com/${args.owner}/${args.repo}`; + + const data = await stagehand.extract(ctx, { + url, + instruction: + "Extract the repository name, description, star count, fork count, primary language, and license.", + schema: z.object({ + name: z.string(), + description: z.string(), + stars: z.string(), + forks: z.string(), + language: z.string(), + license: z.string().optional(), + }), + }); + + return { + ...data, + url, + scrapedAt: new Date().toISOString(), + }; + }, +}); + +/** + * Example 3: Observe available actions on a page + * + * Demonstrates finding interactive elements. + */ +export const findNavLinks = action({ + args: { + url: v.string(), + }, + handler: async (ctx, args) => { + const actions = await stagehand.observe(ctx, { + url: args.url, + instruction: "Find all clickable navigation links in the header or navbar", + }); + + return { + url: args.url, + links: actions, + count: actions.length, + }; + }, +}); + +/** + * Example 4: Perform an action on a page + * + * Demonstrates clicking buttons and interacting with pages. + */ +export const performAction = action({ + args: { + url: v.string(), + actionToPerform: v.string(), + }, + handler: async (ctx, args) => { + const result = await stagehand.act(ctx, { + url: args.url, + action: args.actionToPerform, + }); + + return result; + }, +}); + +/** + * Example 5: Session management - search and extract with preserved state + * + * Demonstrates using session management to perform multiple operations + * while preserving browser state (cookies, login, page context). + */ +export const searchAndExtractWithSession = action({ + args: { + searchQuery: v.string(), + }, + handler: async (ctx, args) => { + // Start a session + const session = await stagehand.startSession(ctx, { + url: "https://www.google.com", + }); + + try { + // Perform search action + await stagehand.act(ctx, { + sessionId: session.sessionId, + action: `Search for "${args.searchQuery}"`, + }); + + // Extract results from the same session + const data = await stagehand.extract(ctx, { + sessionId: session.sessionId, + instruction: "Extract the top 3 search results with title, URL, and snippet", + schema: z.object({ + results: z.array( + z.object({ + title: z.string(), + url: z.string(), + snippet: z.string(), + }), + ), + }), + }); + + return { + searchQuery: args.searchQuery, + results: data.results, + completedAt: new Date().toISOString(), + }; + } finally { + // Always end the session + await stagehand.endSession(ctx, { sessionId: session.sessionId }); + } + }, +}); + +/** + * Example 6: Autonomous agent + * + * Demonstrates using the agent to autonomously complete a multi-step task. + * The agent decides what actions to take based on the instruction. + */ +export const agentSearchAndExtract = action({ + args: { + searchQuery: v.string(), + }, + handler: async (ctx, args) => { + const result = await stagehand.agent(ctx, { + url: "https://www.google.com", + instruction: `Search for "${args.searchQuery}" and extract the top 3 search results including their title, URL, and description`, + options: { + maxSteps: 10, + }, + }); + + return { + searchQuery: args.searchQuery, + success: result.success, + message: result.message, + actionsPerformed: result.actions.length, + completedAt: new Date().toISOString(), + }; + }, +}); + +/** + * Example 7: E-commerce product extraction + * + * Real-world example of extracting product data. + */ +export const scrapeProducts = action({ + args: { + url: v.string(), + }, + handler: async (ctx, args) => { + const data = await stagehand.extract(ctx, { + url: args.url, + instruction: `Extract all visible products on this page. + For each product, get the name, price, image URL (if available), + and any ratings or reviews count.`, + schema: z.object({ + products: z.array( + z.object({ + name: z.string(), + price: z.string(), + imageUrl: z.string().optional(), + rating: z.string().optional(), + reviewCount: z.string().optional(), + }), + ), + pageTitle: z.string(), + }), + }); + + return { + url: args.url, + products: data.products, + pageTitle: data.pageTitle, + count: data.products.length, + scrapedAt: new Date().toISOString(), + }; + }, +}); + +/** + * Internal mutation to save scraped stories to the database. + * Called by scrapeHackerNews action. + */ +export const saveStories = internalMutation({ + args: { + stories: v.array( + v.object({ + rank: v.number(), + title: v.string(), + url: v.string(), + score: v.string(), + age: v.string(), + scrapedAt: v.string(), + }), + ), + }, + handler: async (ctx, args) => { + const ids = []; + for (const story of args.stories) { + const id = await ctx.db.insert("hackerNewsStories", story); + ids.push(id); + } + return ids; + }, +}); diff --git a/packages/examples/convex/convex/schema.ts b/packages/examples/convex/convex/schema.ts new file mode 100644 index 0000000000..49fffd9ab0 --- /dev/null +++ b/packages/examples/convex/convex/schema.ts @@ -0,0 +1,14 @@ +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + // Example table for storing scraped HackerNews stories + hackerNewsStories: defineTable({ + rank: v.number(), + title: v.string(), + url: v.string(), + score: v.string(), + age: v.string(), + scrapedAt: v.string(), + }).index("by_scraped_at", ["scrapedAt"]), +}); diff --git a/packages/examples/convex/package.json b/packages/examples/convex/package.json new file mode 100644 index 0000000000..1b399824af --- /dev/null +++ b/packages/examples/convex/package.json @@ -0,0 +1,19 @@ +{ + "name": "stagehand-example", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "npx convex dev", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@browserbasehq/convex-stagehand": "0.1.1", + "convex": "^1.31.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "typescript": "^5.9.0" + } +} diff --git a/packages/examples/convex/tsconfig.json b/packages/examples/convex/tsconfig.json new file mode 100644 index 0000000000..1dfc0c8fa2 --- /dev/null +++ b/packages/examples/convex/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ES2021", "DOM"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["convex/**/*"] +} diff --git a/packages/examples/council-events/README.md b/packages/examples/council-events/README.md new file mode 100644 index 0000000000..fe8a6f0277 --- /dev/null +++ b/packages/examples/council-events/README.md @@ -0,0 +1,12 @@ +# council-events + +Read-only extraction of public Philadelphia council calendar records. + +Choose a language and follow its setup instructions: + +| Language | Stagehand dependency | Directory from the repository root | +| ---------------------------------- | -------------------- | --------------------------------------------- | +| [TypeScript](typescript/README.md) | `4.0.2` | `packages/examples/council-events/typescript` | +| [Python](python/README.md) | `4.0.0` | `packages/examples/council-events/python` | + +Each variant installs the published Stagehand package and keeps its own dependencies. diff --git a/packages/examples/council-events/python/.env.example b/packages/examples/council-events/python/.env.example new file mode 100644 index 0000000000..d0cde0427d --- /dev/null +++ b/packages/examples/council-events/python/.env.example @@ -0,0 +1 @@ +BROWSERBASE_API_KEY= diff --git a/packages/examples/council-events/python/README.md b/packages/examples/council-events/python/README.md new file mode 100644 index 0000000000..c874aa563a --- /dev/null +++ b/packages/examples/council-events/python/README.md @@ -0,0 +1,67 @@ +# Stagehand + Browserbase: Philadelphia Council Events Scraper + +Location in the Stagehand repository: `packages/examples/council-events/python`. + +Stagehand is the SDK for browser agents. + +## AT A GLANCE + +- Goal: automate extraction of Philadelphia Council events for 2025 from the official calendar. +- Flow: navigate to phila.legistar.com โ†’ click calendar โ†’ select 2025 โ†’ extract event data (name, date, time). +- Benefits: quickly gather upcoming council events without manual browsing, structured data ready for analysis or notifications. + Docs โ†’ https://docs.stagehand.dev/v4/first-steps/introduction + +## GLOSSARY + +- act: perform UI actions from a prompt (click, select, navigate). + Docs โ†’ https://docs.stagehand.dev/v4/basics/act +- extract: pull structured data from a page using AI and Pydantic schemas. + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- Pydantic schema: type-safe data models that validate extracted content. + +## QUICKSTART + +1. cd packages/examples/council-events/python +2. uv venv && source .venv/bin/activate # On Windows: .venv\Scripts\activate +3. pip install stagehand python-dotenv pydantic +4. cp .env.example .env # Add your Browserbase API key to .env +5. python main.py + +## EXPECTED OUTPUT + +- Initializes Stagehand session with Browserbase +- Navigates to Philadelphia Council calendar +- Selects 2025 events from dropdown +- Extracts event names, dates, and times +- Displays structured JSON output with all events +- Provides live session URL for monitoring +- Closes session cleanly + +## COMMON PITFALLS + +- "ModuleNotFoundError": ensure all dependencies are installed via pip +- Missing credentials: verify .env contains BROWSERBASE_API_KEY +- No events found: check if the website structure has changed or if 2025 calendar is available +- Network issues: ensure internet access and phila.legistar.com is accessible +- Import errors: activate your virtual environment if you created one + +## USE CASES + +โ€ข Civic monitoring: Track upcoming council meetings, hearings, and votes for advocacy or journalism. +โ€ข Event aggregation: Pull council calendars into dashboards, newsletters, or community notification systems. +โ€ข Research & analysis: Collect historical event data to analyze meeting frequency, topics, or scheduling patterns. + +## NEXT STEPS + +โ€ข Multi-year extraction: Loop through multiple years to build historical event database. +โ€ข Event details: Click into individual events to extract agendas, attendees, and documents. +โ€ข Notifications: Set up scheduled runs to detect new events and send alerts via email/Slack. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/council-events/python/main.py b/packages/examples/council-events/python/main.py new file mode 100644 index 0000000000..7ddaf0af7d --- /dev/null +++ b/packages/examples/council-events/python/main.py @@ -0,0 +1,83 @@ +"""Extract current Philadelphia City Council events with Stagehand V4.""" + +import asyncio +import json +import os +from datetime import UTC, datetime + +from dotenv import load_dotenv +from pydantic import BaseModel + +from stagehand import Stagehand, browserbase + +load_dotenv() + + +class CouncilEvent(BaseModel): + name: str + date: str + time: str + + +class CouncilEvents(BaseModel): + results: list[CouncilEvent] + + +async def main() -> None: + api_key = os.environ.get("BROWSERBASE_API_KEY") + if not api_key: + raise RuntimeError("BROWSERBASE_API_KEY is required") + + year = datetime.now(UTC).year + print(f"Starting Philadelphia Council Events automation for {year}...") + browser = await browserbase.launch(api_key=api_key) + try: + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + await page.goto( + "https://phila.legistar.com/", + wait_until="domcontentloaded", + timeout=60_000, + ) + await stagehand.act("Click Calendar in the navigation menu", page=page) + await stagehand.act(f"Select {year} from the year dropdown", page=page) + page = await browser.context.active_page() or page + if "Calendar.aspx" not in await page.url(): + await page.goto( + "https://phila.legistar.com/Calendar.aspx", + wait_until="domcontentloaded", + timeout=60_000, + ) + await stagehand.observe( + f"Find the calendar table rows for {year}", + page=page, + ) + + extracted = await stagehand.extract( + ( + f"Extract every {year} event visible in the calendar table with its " + "name, date, and time" + ), + CouncilEvents, + page=page, + ) + print(f"Found {len(extracted.data.results)} events") + print(json.dumps(extracted.data.model_dump(mode="json"), indent=2)) + finally: + await stagehand.close() + finally: + await browser.close() + print("Session closed successfully") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as error: + print(f"Application error: {error}") + print("Docs: https://docs.stagehand.dev/v4/first-steps/introduction") + raise diff --git a/packages/examples/council-events/python/pyproject.toml b/packages/examples/council-events/python/pyproject.toml new file mode 100644 index 0000000000..d74b352e11 --- /dev/null +++ b/packages/examples/council-events/python/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "council-events" +version = "0.1.0" +requires-python = ">=3.11,<3.14" +dependencies = ["pydantic>=2.12,<3", "python-dotenv==1.2.2", "stagehand==4.0.0"] + +[tool.uv] +package = false diff --git a/packages/examples/council-events/typescript/.env.example b/packages/examples/council-events/typescript/.env.example new file mode 100644 index 0000000000..d0cde0427d --- /dev/null +++ b/packages/examples/council-events/typescript/.env.example @@ -0,0 +1 @@ +BROWSERBASE_API_KEY= diff --git a/packages/examples/council-events/typescript/README.md b/packages/examples/council-events/typescript/README.md new file mode 100644 index 0000000000..9e6a4c3571 --- /dev/null +++ b/packages/examples/council-events/typescript/README.md @@ -0,0 +1,67 @@ +# Stagehand + Browserbase: Council Events Automation + +Location in the Stagehand repository: `packages/examples/council-events/typescript`. + +## AT A GLANCE + +- Goal: demonstrate how to automate event information extraction from Philadelphia Council. +- Navigation & Search: automate website navigation, calendar selection, and year filtering. +- Data Extraction: extract structured event data with validated output using Zod schemas. +- Practical Example: extract council events with name, date, and time information. + +## GLOSSARY + +- act: perform UI actions from a natural language prompt (type, click, navigate). + Docs โ†’ https://docs.stagehand.dev/v4/basics/act +- extract: pull structured data from web pages into validated objects. + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- schema: a Zod definition that enforces data types, optional fields, and validation rules. + Docs โ†’ https://zod.dev/ +- council events automation: navigate to council website, select calendar, and extract event information. +- structured data extraction: convert unstructured web content into typed, validated objects. + +## QUICKSTART + +1. cd packages/examples/council-events/typescript +2. npm install +3. cp .env.example .env (or create .env with BROWSERBASE_API_KEY) +4. Add your Browserbase API key to .env +5. npm start + +## EXPECTED OUTPUT + +- Navigates to Philadelphia Council website +- Clicks calendar from the navigation menu +- Selects 2025 from the year dropdown +- Extracts structured event data including name, date, and time +- Returns typed object with event information + +## COMMON PITFALLS + +- "Cannot find module 'dotenv'": ensure npm install ran successfully +- Missing API key: verify .env is loaded and file is not committed +- Events not found: check if the website structure has changed or if no events exist for the selected period +- Schema validation errors: ensure extracted data matches Zod schema structure + +## USE CASES + +โ€ข Event tracking: automate monitoring of council meetings and public events. +โ€ข Calendar aggregation: collect event information for integration with calendar systems. +โ€ข Public information access: extract structured event data for citizens and researchers. +โ€ข Meeting scheduling: track upcoming council meetings for attendance planning. + +## NEXT STEPS + +โ€ข Date filtering: make the year or date selection configurable via environment variables or prompts. +โ€ข Multi-year extraction: extend the flow to extract events from multiple years in parallel. +โ€ข Calendar integration: add logic to export extracted events to calendar formats (iCal, Google Calendar). +โ€ข Event notifications: add logic to send alerts for upcoming meetings or important events. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/council-events/typescript/index.ts b/packages/examples/council-events/typescript/index.ts new file mode 100644 index 0000000000..b2472a6a2f --- /dev/null +++ b/packages/examples/council-events/typescript/index.ts @@ -0,0 +1,95 @@ +// Stagehand + Browserbase: Philadelphia Council Events Scraper - See README.md for full documentation +import "dotenv/config"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod/v4"; + +const CURRENT_YEAR = new Date().getUTCFullYear(); + +/** Searches the current Philadelphia Council calendar and extracts event information. */ +async function main() { + console.log("Starting Philadelphia Council Events automation..."); + + // Initialize Stagehand with Browserbase for cloud-based browser automation + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + }); + const stagehand = await Stagehand.create({ + browser: browser, + model: { modelName: "openai/gpt-4.1" }, + logging: { level: "info" }, + }); + + try { + let page = (await browser.context.pages())[0]; + + console.log("Navigating to: https://phila.legistar.com/"); + await page.goto("https://phila.legistar.com/"); + + console.log("Clicking calendar from the navigation menu"); + const calendar = await stagehand.act("click calendar from the navigation menu"); + if (!calendar.data.success) { + throw new Error(calendar.data.message || "Could not open the calendar"); + } + + console.log(`Selecting ${CURRENT_YEAR} from the year dropdown`); + const selection = await stagehand.act(`select ${CURRENT_YEAR} from the year dropdown`); + if (!selection.data.success) { + throw new Error(selection.data.message || `Could not select ${CURRENT_YEAR}`); + } + page = (await browser.context.activePage()) ?? page; + if (!(await page.url()).includes("Calendar.aspx")) { + await page.goto("https://phila.legistar.com/Calendar.aspx", { + waitUntil: "domcontentloaded", + timeout: 60000, + }); + } + await stagehand.observe(`Find the calendar table rows for ${CURRENT_YEAR}`); + + // Extract event data using AI to parse the structured information + console.log("Extracting event information..."); + const EventResultsSchema = z.object({ + results: z.array( + z.object({ + name: z.string(), + date: z.string(), + time: z.string(), + }), + ), + }); + const { data: results } = await stagehand.extract( + `Extract every ${CURRENT_YEAR} event currently visible in the calendar table, including its name, date, and time`, + EventResultsSchema, + ); + + console.log(`Found ${results.results.length} events for ${CURRENT_YEAR}`); + console.log("Event data extracted successfully:"); + console.log(JSON.stringify(results, null, 2)); + } catch (error) { + console.error("Error during event extraction:", error); + + // Provide helpful troubleshooting information + console.error("\nCommon issues:"); + console.error("1. Check .env file has BROWSERBASE_API_KEY"); + console.error("2. Ensure internet access and https://phila.legistar.com is accessible"); + console.error("3. Verify Browserbase account has sufficient credits"); + console.error("4. Check if the calendar page structure has changed"); + + throw error; + } finally { + try { + await stagehand.close(); + } catch (error) { + console.warn("Stagehand cleanup warning:", error); + } + try { + await browser.close(); + } catch (error) { + console.warn("Browser cleanup warning:", error); + } + } +} + +main().catch((err) => { + console.error("Application error:", err); + process.exit(1); +}); diff --git a/packages/examples/council-events/typescript/package.json b/packages/examples/council-events/typescript/package.json new file mode 100644 index 0000000000..88955bef82 --- /dev/null +++ b/packages/examples/council-events/typescript/package.json @@ -0,0 +1,22 @@ +{ + "name": "council-events", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "tsx index.ts" + }, + "dependencies": { + "@browserbasehq/stagehand": "4.0.2", + "dotenv": "^17.4.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "tsx": "^4.23.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.18.0" + } +} diff --git a/packages/examples/docs-search/.env.example b/packages/examples/docs-search/.env.example new file mode 100644 index 0000000000..594a0d8bc4 --- /dev/null +++ b/packages/examples/docs-search/.env.example @@ -0,0 +1,4 @@ +BROWSERBASE_API_KEY= +BROWSERBASE_PROJECT_ID= +OPENAI_API_KEY= +GOOGLE_API_KEY= diff --git a/packages/examples/docs-search/README.md b/packages/examples/docs-search/README.md new file mode 100644 index 0000000000..8ad31c9e68 --- /dev/null +++ b/packages/examples/docs-search/README.md @@ -0,0 +1,7 @@ +# Docs Search + +Location in the Stagehand repository: `packages/examples/docs-search`. + +Searches public Stagehand documentation and extracts the answer. + +This is a legacy **Stagehand v2** example. Install dependencies in this directory with `npm install`, supply the environment variables listed in `.env.example`, and run `npm start`. Live site layout and model availability may have changed since the original example. diff --git a/packages/examples/docs-search/package.json b/packages/examples/docs-search/package.json new file mode 100644 index 0000000000..53bb69c3f2 --- /dev/null +++ b/packages/examples/docs-search/package.json @@ -0,0 +1,20 @@ +{ + "name": "stagehand-example-docs-search", + "private": true, + "type": "module", + "scripts": { + "start": "tsx searchDocs.ts" + }, + "dependencies": { + "@browserbasehq/stagehand": "^2.2.1", + "boxen": "^8.0.1", + "chalk": "^5.3.0", + "dotenv": "^16.4.7", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/node": "^22.9.1", + "tsx": "^4.19.2", + "typescript": "^5.0.0" + } +} diff --git a/packages/examples/docs-search/searchDocs.ts b/packages/examples/docs-search/searchDocs.ts new file mode 100644 index 0000000000..47bdea2788 --- /dev/null +++ b/packages/examples/docs-search/searchDocs.ts @@ -0,0 +1,85 @@ +import "dotenv/config"; +import { Stagehand } from "@browserbasehq/stagehand"; +import boxen from "boxen"; +import chalk from "chalk"; +import { z } from "zod"; + +async function main() { + const stagehand = new Stagehand({ + /** + * With npx create-browser-app, this config is found + * in a separate stagehand.config.ts file + */ + env: "BROWSERBASE", // Environment to run in: LOCAL or BROWSERBASE + apiKey: process.env.BROWSERBASE_API_KEY /* API key for authentication */, + projectId: process.env.BROWSERBASE_PROJECT_ID /* Project identifier */, + + // LLM configuration + modelName: "google/gemini-2.0-flash" /* Name of the model to use */, + modelClientOptions: { + apiKey: process.env.GOOGLE_API_KEY, + }, + }); + + // Initialize the stagehand instance + await stagehand.init(); + const page = stagehand.page; + + // If running in Browserbase, print a link to the session + if (stagehand.env === "BROWSERBASE" && stagehand.browserbaseSessionID) { + console.log( + boxen( + `View this session live in your browser: \n${`https://browserbase.com/sessions/${stagehand.browserbaseSessionID}`}`, + { + title: "Browserbase", + padding: 1, + margin: 3, + }, + ), + ); + } + + // Define the question and the URL of the docs + const question = "Tell me, in one sentence, why I should use Stagehand"; + const docsUrl = "https://docs.stagehand.dev"; + + // Navigate to the docs URL + await page.goto(docsUrl); + + // Click on the search bar + await page.act({ + action: "click on the search bar", + }); + + // Type the question into the search bar and click on the suggestion that says 'Use AI to answer your question' + await page.act({ + action: `type '${question}' into the search bar and click on the suggestion that says 'Use AI to answer your question'`, + }); + + // Wait for 3 seconds + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // Extract the response from the chatbot + const { text } = await page.extract({ + instruction: "extract the response from the chatbot", + schema: z.object({ + text: z.string(), + }), + }); + + // Log the question and the answer + console.log( + "\n\n" + + chalk.gray("Question: ") + + question + + "\n" + + chalk.green("Answer from Mintlify AI: ") + + text + + "\n\n", + ); + + // Close the stagehand instance + await stagehand.close(); +} + +main(); diff --git a/packages/examples/download-financial-statements/README.md b/packages/examples/download-financial-statements/README.md new file mode 100644 index 0000000000..d04d7c7c07 --- /dev/null +++ b/packages/examples/download-financial-statements/README.md @@ -0,0 +1,12 @@ +# download-financial-statements + +Download public Apple financial statements using Stagehand-discovered controls. + +Choose a language and follow its setup instructions: + +| Language | Stagehand dependency | Directory from the repository root | +| ---------------------------------- | -------------------- | ------------------------------------------------------------ | +| [TypeScript](typescript/README.md) | `4.0.0` | `packages/examples/download-financial-statements/typescript` | +| [Python](python/README.md) | `4.0.0` | `packages/examples/download-financial-statements/python` | + +Each variant installs the published Stagehand package and keeps its own dependencies. diff --git a/packages/examples/download-financial-statements/python/.env.example b/packages/examples/download-financial-statements/python/.env.example new file mode 100644 index 0000000000..d0cde0427d --- /dev/null +++ b/packages/examples/download-financial-statements/python/.env.example @@ -0,0 +1 @@ +BROWSERBASE_API_KEY= diff --git a/packages/examples/download-financial-statements/python/README.md b/packages/examples/download-financial-statements/python/README.md new file mode 100644 index 0000000000..c8ed54a7c6 --- /dev/null +++ b/packages/examples/download-financial-statements/python/README.md @@ -0,0 +1,66 @@ +# Stagehand + Browserbase: Download Apple's Quarterly Financial Statements + +Location in the Stagehand repository: `packages/examples/download-financial-statements/python`. + +Stagehand is the SDK for browser agents. + +## AT A GLANCE + +- Goal: automate downloading Apple's quarterly financial statements (PDFs) from their investor relations site. +- Download Handling: Browserbase automatically captures PDFs opened during the session and bundles them into a ZIP file. +- Retry Logic: polls Browserbase downloads API with configurable timeout to ensure files are ready before retrieval. +- Live Debugging: the session remains available in the Browserbase Sessions dashboard without logging a signed URL. + +## GLOSSARY + +- act: perform UI actions from a prompt (click, scroll, navigate) + Docs โ†’ https://docs.stagehand.dev/v4/basics/act +- downloads API: retrieve files downloaded during a Browserbase session as a ZIP archive + Docs โ†’ https://docs.browserbase.com/features/screenshots#pdfs +- live view: real-time browser debugging interface for monitoring automation + Docs โ†’ https://docs.browserbase.com/features/session-live-view + +## QUICKSTART + +1. cd packages/examples/download-financial-statements/python +2. cp .env.example .env # Add your Browserbase API key to .env +3. uvx --with browserbase --with python-dotenv stagehand main.py + +## EXPECTED OUTPUT + +- Initializes Stagehand session with Browserbase +- Navigates to Apple.com โ†’ Investors section +- Locates Q1-Q4 2025 quarterly earnings reports +- Clicks each Financial Statements PDF link (triggers downloads) +- Polls Browserbase API until downloads are ready +- Saves all PDFs as `downloaded_files.zip` in current directory +- Displays session history and closes cleanly + +## COMMON PITFALLS + +- "Cannot find module": ensure uvx is installed (`pip install uv`) or use `pip install stagehand-ai browserbase python-dotenv` for traditional setup +- Missing credentials: verify .env contains BROWSERBASE_API_KEY +- Download timeout: increase `retry_for_seconds` parameter if downloads take longer than 45 seconds +- Empty ZIP file: ensure PDFs were actually triggered (check live view link to debug) +- Network issues: check internet connection and Apple website accessibility + +## USE CASES + +โ€ข Financial reporting automation: Download quarterly/annual reports from investor relations sites for analysis, archiving, or compliance. +โ€ข Document batch retrieval: Collect multiple PDFs (contracts, invoices, statements) from web portals without manual clicking. +โ€ข Scheduled data collection: Run on cron/Lambda to automatically fetch latest financial filings or regulatory documents. + +## NEXT STEPS + +โ€ข Generalize for other sites: Extract URL patterns, adapt act() prompts, and support multiple companies/document types. +โ€ข Parse downloaded PDFs: Unzip, OCR/parse text (PyPDF2/pdfplumber), and load into structured format (CSV/DB/JSON). +โ€ข Add validation: Check file count, sizes, naming conventions; alert on failures; retry missing quarters. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/download-financial-statements/python/main.py b/packages/examples/download-financial-statements/python/main.py new file mode 100644 index 0000000000..0632ddb789 --- /dev/null +++ b/packages/examples/download-financial-statements/python/main.py @@ -0,0 +1,117 @@ +"""Download Apple's FY2025 statements with Stagehand V4.""" + +import asyncio +import json +import os +import time +from pathlib import Path + +from browserbase import Browserbase +from dotenv import load_dotenv +from pydantic import BaseModel, HttpUrl + +from stagehand import Stagehand, browserbase + +load_dotenv() + + +class StatementLinks(BaseModel): + statement_urls: list[HttpUrl] + + +async def save_downloads_with_retry( + client: Browserbase, + session_id: str, + retry_for_seconds: int = 45, +) -> int: + started = time.monotonic() + while time.monotonic() - started < retry_for_seconds: + response = await asyncio.to_thread(client.sessions.downloads.list, session_id) + payload = await asyncio.to_thread(response.read) + if payload: + Path("downloaded_files.zip").write_bytes(payload) + print(f"Saved downloaded_files.zip ({len(payload)} bytes)") + return len(payload) + await asyncio.sleep(2) + raise TimeoutError("Download timeout exceeded") + + +async def main() -> None: + api_key = os.environ.get("BROWSERBASE_API_KEY") + if not api_key: + raise RuntimeError("BROWSERBASE_API_KEY is required") + + print("Starting Apple Financial Statements Download Automation...") + api = Browserbase(api_key=api_key) + browser = await browserbase.launch(api_key=api_key) + session_id = browser.session_id + if not session_id: + await browser.close() + raise RuntimeError("Browserbase launch did not return a session ID") + + try: + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + await page.goto("https://www.apple.com/", wait_until="domcontentloaded", timeout=60_000) + await stagehand.act( + "Click the Investors button at the bottom of the page", + page=page, + ) + await stagehand.act( + "Scroll down to the Financial Data section", + page=page, + ) + await stagehand.act( + "Under Quarterly Earnings Reports, click 2025", + page=page, + ) + page = await browser.context.active_page() or page + extracted = await stagehand.extract( + ( + "Extract the actual absolute HTTP(S) href URLs of the four FY2025 Financial " + "Statements PDF links, ordered Q4 through Q1." + ), + StatementLinks, + page=page, + ) + statement_urls = [str(url) for url in extracted.data.statement_urls[:4]] + for index, statement_url in enumerate(statement_urls): + opened = await stagehand.act( + f"Click the Financial Statements link under Q{4 - index}", + page=page, + ) + if not opened.data.success: + encoded_url = json.dumps(statement_url) + await page.evaluate( + f"""(() => {{ + const link = document.createElement('a'); + link.href = {encoded_url}; + link.target = '_blank'; + document.body.appendChild(link); + link.click(); + link.remove(); + }})()""" + ) + await page.wait_for_timeout(500) + print(f"Triggered FY2025 Q{4 - index} download") + + await save_downloads_with_retry(api, session_id) + print("Downloads completed") + finally: + await stagehand.close() + finally: + await browser.close() + print("Session closed successfully") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as error: + print(f"Application error: {error}") + print("Docs: https://docs.stagehand.dev/v4/first-steps/introduction") + raise diff --git a/packages/examples/download-financial-statements/python/pyproject.toml b/packages/examples/download-financial-statements/python/pyproject.toml new file mode 100644 index 0000000000..977de9edfb --- /dev/null +++ b/packages/examples/download-financial-statements/python/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "download-financial-statements" +version = "0.1.0" +requires-python = ">=3.11,<3.14" +dependencies = ["browserbase>=1.7.0", "httpx==0.28.1", "python-dotenv==1.2.2", "stagehand==4.0.0"] + +[tool.uv] +package = false diff --git a/packages/examples/download-financial-statements/typescript/.env.example b/packages/examples/download-financial-statements/typescript/.env.example new file mode 100644 index 0000000000..d0cde0427d --- /dev/null +++ b/packages/examples/download-financial-statements/typescript/.env.example @@ -0,0 +1 @@ +BROWSERBASE_API_KEY= diff --git a/packages/examples/download-financial-statements/typescript/README.md b/packages/examples/download-financial-statements/typescript/README.md new file mode 100644 index 0000000000..e95a81a1a0 --- /dev/null +++ b/packages/examples/download-financial-statements/typescript/README.md @@ -0,0 +1,66 @@ +# Stagehand + Browserbase: Download Apple's Quarterly Financial Statements + +Location in the Stagehand repository: `packages/examples/download-financial-statements/typescript`. + +## AT A GLANCE + +- Goal: automate downloading Apple's quarterly financial statements (PDFs) from their investor relations site. +- Download Handling: Browserbase automatically captures PDFs opened during the session and bundles them into a ZIP file. +- Retry Logic: polls Browserbase downloads API with configurable timeout to ensure files are ready before retrieval. +- Live Debugging: the session can be monitored from the Browserbase Sessions dashboard without logging a signed URL. + +## GLOSSARY + +- act / extract: navigate investor relations semantically and discover the intended statement URLs + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- downloads API: retrieve files downloaded during a Browserbase session as a ZIP archive + Docs โ†’ https://docs.browserbase.com/features/screenshots#pdfs +- live view: real-time browser debugging interface for monitoring automation + Docs โ†’ https://docs.browserbase.com/features/session-live-view + +## QUICKSTART + +1. cd packages/examples/download-financial-statements/typescript +2. npm install +3. cp .env.example .env +4. Add your Browserbase API key to .env +5. npm start + +## EXPECTED OUTPUT + +- Initializes Stagehand session with Browserbase +- Uses `act()` to navigate from Apple.com to the FY2025 investor statements +- Discovers the FY2025 Financial Statements PDF URLs +- Opens each statement to trigger Browserbase downloads +- Polls Browserbase API until downloads are ready +- Saves all PDFs as `downloaded_files.zip` in current directory +- Displays Stagehand metrics and closes cleanly + +## COMMON PITFALLS + +- "Cannot find module": ensure all dependencies are installed +- Missing credentials: verify .env contains BROWSERBASE_API_KEY +- Download timeout: increase `retryForSeconds` parameter if downloads take longer than 45 seconds +- Empty ZIP file: ensure PDFs were actually triggered (inspect the session in the Browserbase dashboard) +- Network issues: check internet connection and Apple website accessibility + +## USE CASES + +โ€ข Financial reporting automation: Download quarterly/annual reports from investor relations sites for analysis, archiving, or compliance. +โ€ข Document batch retrieval: Collect multiple PDFs (contracts, invoices, statements) from web portals without manual clicking. +โ€ข Scheduled data collection: Run on cron/Lambda to automatically fetch latest financial filings or regulatory documents. + +## NEXT STEPS + +โ€ข Generalize for other sites: Adapt URL/link matching and support multiple companies or document types. +โ€ข Parse downloaded PDFs: Unzip, OCR/parse text (PyPDF2/pdfplumber), and load into structured format (CSV/DB/JSON). +โ€ข Add validation: Check file count, sizes, naming conventions; alert on failures; retry missing quarters. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/download-financial-statements/typescript/index.ts b/packages/examples/download-financial-statements/typescript/index.ts new file mode 100644 index 0000000000..1a205c7d5c --- /dev/null +++ b/packages/examples/download-financial-statements/typescript/index.ts @@ -0,0 +1,158 @@ +// Stagehand + Browserbase: Download Apple's Quarterly Financial Statements - See README.md for full documentation + +import { Browserbase } from "@browserbasehq/sdk"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import "dotenv/config"; +import fs from "fs"; +import { z } from "zod/v4"; + +/** + * Polls Browserbase API for downloads with timeout handling. + * Retries every 2 seconds until downloads are ready or timeout is reached. + */ +async function saveDownloadsWithRetry( + bb: Browserbase, + sessionId: string, + retryForSeconds: number = 30, +): Promise { + return new Promise((resolve, reject) => { + console.log(`Waiting up to ${retryForSeconds} seconds for downloads to complete...`); + + const intervals = { + poller: undefined as NodeJS.Timeout | undefined, + timeout: undefined as NodeJS.Timeout | undefined, + }; + + async function fetchDownloads(): Promise { + try { + console.log("Checking for downloads..."); + const response = await bb.sessions.downloads.list(sessionId); + const downloadBuffer: ArrayBuffer = await response.arrayBuffer(); + + if (downloadBuffer.byteLength > 0) { + console.log(`Downloads ready! File size: ${downloadBuffer.byteLength} bytes`); + fs.writeFileSync("downloaded_files.zip", Buffer.from(downloadBuffer)); + console.log("Files saved as: downloaded_files.zip"); + + if (intervals.poller) clearInterval(intervals.poller); + if (intervals.timeout) clearTimeout(intervals.timeout); + resolve(downloadBuffer.byteLength); + } else { + console.log("Downloads not ready yet, retrying..."); + } + } catch (e: unknown) { + console.error("Error fetching downloads:", e); + if (intervals.poller) clearInterval(intervals.poller); + if (intervals.timeout) clearTimeout(intervals.timeout); + reject(e); + } + } + + // Set timeout to prevent infinite polling if downloads never complete + intervals.timeout = setTimeout(() => { + if (intervals.poller) { + clearInterval(intervals.poller); + } + reject(new Error("Download timeout exceeded")); + }, retryForSeconds * 1000); + + // Poll every 2 seconds to check if downloads are ready + intervals.poller = setInterval(fetchDownloads, 2000); + }); +} + +async function main(): Promise { + console.log("Starting Apple Financial Statements Download Automation..."); + + console.log("Initializing Browserbase client..."); + const bb: Browserbase = new Browserbase({ + apiKey: process.env.BROWSERBASE_API_KEY as string, + }); + + // V4's browser factory provisions and owns the Stagehand extension. The returned + // browser exposes its Browserbase session ID for downloads and Live View APIs. + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + }); + const sessionId = browser.sessionId; + if (!sessionId) throw new Error("Browserbase launch did not return a session ID"); + const stagehand: Stagehand = await Stagehand.create({ + browser: browser, + logging: { level: "error", onLog: console.log }, + }); + + try { + // Initialize browser session to start automation + + console.log("Stagehand initialized successfully!"); + const context = browser.context; + let page = (await context.pages())[0]; + + // The session can be monitored from the Browserbase Sessions dashboard. + // Avoid printing its signed Live View URL into application logs. + console.log("Live View is available in the Browserbase Sessions dashboard"); + + console.log("Navigating to Apple.com..."); + await page.goto("https://www.apple.com/", { waitUntil: "domcontentloaded", timeout: 60000 }); + await stagehand.act("Click the 'Investors' button at the bottom of the page"); + await stagehand.act("Scroll down to the Financial Data section of the page"); + await stagehand.act("Under Quarterly Earnings Reports, click on '2025'"); + page = (await context.activePage()) ?? page; + + // Discover the intended documents semantically and keep the actual UI + // interaction in Stagehand act(). + const { data: statements } = await stagehand.extract( + "Extract the actual absolute HTTP(S) href URLs of the four FY2025 Financial Statements PDF links, ordered Q4 through Q1.", + z.object({ statementUrls: z.array(z.string().url()) }), + ); + const statementUrls = statements.statementUrls.slice(0, 4); + + console.log("Downloading quarterly financial statements..."); + for (const [index, statementUrl] of statementUrls.entries()) { + const opened = await stagehand.act( + `Click the Financial Statements link under Q${4 - index}`, + { page }, + ); + if (!opened.data.success) { + // A direct link trigger is the smallest correctness fallback when the + // semantic click cannot interact with a PDF target. + await page.evaluate((url: string) => { + const link = document.createElement("a"); + link.href = url; + link.target = "_blank"; + document.body.appendChild(link); + link.click(); + link.remove(); + }, statementUrl); + } + await page.waitForTimeout(500); + console.log(`Triggered FY2025 Q${4 - index} download`); + } + + // Retrieve all downloads triggered during this session from Browserbase API + console.log("Retrieving downloads from Browserbase..."); + await saveDownloadsWithRetry(bb, sessionId, 45); + console.log("All downloads completed successfully!"); + + console.log("\nStagehand Metrics:"); + console.log(await stagehand.metrics()); + } catch (error) { + console.error("Error during automation:", error); + throw error; + } finally { + // Always close session to release resources and clean up + await stagehand.close().catch((error) => console.warn("Stagehand cleanup warning:", error)); + await browser.close().catch((error) => console.warn("Browser cleanup warning:", error)); + console.log("Session closed successfully"); + } +} + +main().catch((err) => { + console.error("Application error:", err); + console.error("Common issues:"); + console.error(" - Check .env file has BROWSERBASE_API_KEY"); + console.error(" - Verify internet connection and Apple website accessibility"); + console.error(" - Ensure sufficient timeout for slow-loading pages"); + console.error("Docs: https://docs.stagehand.dev/v4/first-steps/introduction"); + process.exit(1); +}); diff --git a/packages/examples/download-financial-statements/typescript/package.json b/packages/examples/download-financial-statements/typescript/package.json new file mode 100644 index 0000000000..8db642318e --- /dev/null +++ b/packages/examples/download-financial-statements/typescript/package.json @@ -0,0 +1,26 @@ +{ + "name": "download-financial-statements", + "version": "1.0.0", + "description": "Stagehand + Browserbase: download quarterly financial statements and retrieve the session downloads", + "type": "module", + "main": "index.ts", + "scripts": { + "start": "tsx index.ts", + "dev": "tsx watch index.ts" + }, + "dependencies": { + "@browserbasehq/sdk": "^2.18.0", + "@browserbasehq/stagehand": "4.0.0", + "dotenv": "^16.4.5", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "tsx": "^4.16.0", + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=22.18.0" + }, + "packageManager": "pnpm@10.24.0" +} diff --git a/packages/examples/extend-browserbase/README.md b/packages/examples/extend-browserbase/README.md new file mode 100644 index 0000000000..6db8703457 --- /dev/null +++ b/packages/examples/extend-browserbase/README.md @@ -0,0 +1,12 @@ +# extend-browserbase + +Downloads synthetic receipts from a demo expense portal and parses them with Extend. + +Choose a language and follow its setup instructions: + +| Language | Stagehand dependency | Directory from the repository root | +| ---------------------------------- | -------------------- | ------------------------------------------------- | +| [TypeScript](typescript/README.md) | `4.0.0` | `packages/examples/extend-browserbase/typescript` | +| [Python](python/README.md) | `4.0.0` | `packages/examples/extend-browserbase/python` | + +Each variant installs the published Stagehand package and keeps its own dependencies. diff --git a/packages/examples/extend-browserbase/python/.env.example b/packages/examples/extend-browserbase/python/.env.example new file mode 100644 index 0000000000..b0c5f4a12a --- /dev/null +++ b/packages/examples/extend-browserbase/python/.env.example @@ -0,0 +1,6 @@ +# Browserbase credentials (required) +# Get these from https://www.browserbase.com/settings +BROWSERBASE_API_KEY= + +# Extend AI (optional โ€“ enables receipt parsing; omit to only download receipts) +EXTEND_API_KEY= diff --git a/packages/examples/extend-browserbase/python/README.md b/packages/examples/extend-browserbase/python/README.md new file mode 100644 index 0000000000..c27ffd3242 --- /dev/null +++ b/packages/examples/extend-browserbase/python/README.md @@ -0,0 +1,82 @@ +# Stagehand + Browserbase + Extend: Download Expense Receipts and Parse with Extend AI + +Location in the Stagehand repository: `packages/examples/extend-browserbase/python`. + +Stagehand is the SDK for browser agents. + +## AT A GLANCE + +- **Goal**: Automate downloading receipts from an expense portal and extract structured receipt data using AI-powered document parsing. +- **Pattern Template**: Demonstrates the integration pattern of Browserbase (browser automation + download capture) + Extend AI (schema-based document extraction). +- **Workflow**: Stagehand navigates the expense portal and clicks each receipt's download link; Browserbase captures downloads. The script polls for the session's download ZIP, extracts files, then optionally sends them to Extend for structured extraction (vendor, date, totals, line items, etc.). +- **Download Handling**: Implements retry/polling around Browserbase's Session Downloads API until the ZIP is available. +- **Structured Extraction**: Extend AI extraction with inline receipt JSON schema config; results written to `output/results/receipts.json` and `receipts.csv`. +- Docs โ†’ [Browserbase Downloads](https://docs.browserbase.com/features/downloads) | [Extend AI](https://docs.extend.app) + +## GLOSSARY + +- **act**: perform UI actions from natural language prompts (click, scroll, navigate) + Docs โ†’ https://docs.stagehand.dev/v4/basics/act +- **observe**: find and return interactive elements on the page matching a description, without performing actions. Used here to locate all individual download buttons before clicking them. + Docs โ†’ https://docs.stagehand.dev/v4/basics/observe +- **Browserbase Downloads**: When files are downloaded during a browser session, Browserbase captures and stores them. Files are retrieved via the Session Downloads API as a ZIP archive. + Docs โ†’ https://docs.browserbase.com/features/downloads +- **Extend AI extraction**: A configurable document extraction pipeline that parses files against a JSON schema and returns structured data. Config can be passed inline or via a saved extractor resource. + Docs โ†’ https://docs.extend.app +- **Download polling**: Browserbase syncs downloads in real-time; the script retries every 2 seconds until the ZIP is available or a timeout is reached. + +## QUICKSTART + +1. cd packages/examples/extend-browserbase/python +2. cp .env.example .env +3. Add required API keys to .env: + - `BROWSERBASE_API_KEY` + - `EXTEND_API_KEY` (optional โ€” enables receipt parsing) +4. Run the script: + + ```bash + uv run python main.py + ``` + +## EXPECTED OUTPUT + +- Initializes Stagehand session with Browserbase and opens the live view link +- Navigates to the expense portal and finds all per-receipt download links via observe +- Clicks each download button; Browserbase captures files +- After closing the session, polls for the session's download ZIP and extracts to `output/documents/` +- If `EXTEND_API_KEY` is set: uploads each file to Extend and runs extraction with inline config, writes `output/results/receipts.json` and `receipts.csv` +- Closes session cleanly + +## COMMON PITFALLS + +- "ModuleNotFoundError": ensure you're running with `uv run python main.py` so dependencies are installed automatically from pyproject.toml +- Missing credentials: verify .env contains BROWSERBASE_API_KEY +- Download timeout: increase `retry_for_seconds` parameter in `save_downloads_with_retry` if downloads take longer than 60 seconds +- Empty ZIP file: ensure downloads were actually triggered (check live view link to debug) +- Rate limiting on Extend: the script retries with exponential backoff on 429 errors, but very large batches may need the batch size reduced from 9 +- Find more information on your Browserbase dashboard โ†’ https://www.browserbase.com/sign-in + +## USE CASES + +โ€ข Expense automation: Download receipts from expense portal and extract vendor, date, totals, and line items for accounting systems. +โ€ข Document batch processing: Collect files from web portals and run structured extraction across all of them with a single script. +โ€ข Receipt digitization: Convert paper/PDF receipts into structured JSON and CSV for import into ERP, bookkeeping, or reimbursement tools. + +## NEXT STEPS + +โ€ข Parameterize the portal URL: Accept the expense portal URL from env or CLI to support different receipt sources. +โ€ข Custom schemas: Modify `RECEIPT_EXTRACTION_CONFIG` to extract different document types (invoices, W-2s, contracts) by changing the JSON schema. +โ€ข Add validation: Compare extracted totals against line item sums to flag discrepancies or incomplete extractions. +โ€ข Scheduled runs: Deploy on cron/Lambda to periodically check for new receipts and process them automatically. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐Ÿ“š Python SDK: https://docs.stagehand.dev/v4/sdk/python +๐Ÿ“š Browserbase Downloads: https://docs.browserbase.com/features/downloads +๐Ÿ“š Extend AI: https://docs.extend.app +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/extend-browserbase/python/main.py b/packages/examples/extend-browserbase/python/main.py new file mode 100644 index 0000000000..e6770b9b77 --- /dev/null +++ b/packages/examples/extend-browserbase/python/main.py @@ -0,0 +1,548 @@ +# Stagehand + Browserbase + Extend: Download Expense Receipts and Parse with Extend AI +# See README.md for full documentation + +import asyncio +import csv +import json +import os +import zipfile +from pathlib import Path + +from browserbase import APIStatusError, Browserbase +from dotenv import load_dotenv +from extend_ai import Extend +from stagehand import Stagehand, browserbase + +# Load environment variables from .env file +# Required: BROWSERBASE_API_KEY +# Optional: EXTEND_API_KEY +load_dotenv() + + +# Receipt extraction config for Extend AI +# Uses extraction_light base extractor with parse_performance engine for low latency +RECEIPT_EXTRACTION_CONFIG = { + "baseProcessor": "extraction_light", + "baseVersion": "3.4.0", + "parseConfig": { + "engine": "parse_performance", + "target": "markdown", + "blockOptions": { + "text": { + "agentic": {"enabled": False}, + "signatureDetectionEnabled": False, + }, + "tables": { + "agentic": {"enabled": False}, + "targetFormat": "markdown", + "cellBlocksEnabled": False, + "tableHeaderContinuationEnabled": False, + }, + "figures": { + "enabled": False, + "figureImageClippingEnabled": False, + }, + }, + "engineVersion": "1.0.1", + "advancedOptions": { + "engine": "parse_performance", + "agenticOcrEnabled": False, + "pageBreaksEnabled": True, + "pageRotationEnabled": False, + "verticalGroupingThreshold": 1, + }, + "chunkingStrategy": {"type": "document"}, + }, + "schema": { + "type": "object", + "required": [ + "vendor_name", + "receipt_date", + "receipt_number", + "total_amount", + "subtotal_amount", + "tax_amount", + "line_items", + "payment_method", + ], + "properties": { + "vendor_name": { + "type": ["string", "null"], + "description": "The name of the merchant or vendor on the receipt.", + }, + "receipt_date": { + "type": ["string", "null"], + "description": "The date of the transaction shown on the receipt.", + "extend:type": "date", + }, + "receipt_number": { + "type": ["string", "null"], + "description": "The receipt or transaction number, if present.", + }, + "total_amount": { + "type": "object", + "required": ["amount", "iso_4217_currency_code"], + "properties": { + "amount": {"type": ["number", "null"]}, + "iso_4217_currency_code": {"type": ["string", "null"]}, + }, + "description": "The total amount paid on the receipt.", + "extend:type": "currency", + "additionalProperties": False, + }, + "subtotal_amount": { + "type": "object", + "required": ["amount", "iso_4217_currency_code"], + "properties": { + "amount": {"type": ["number", "null"]}, + "iso_4217_currency_code": {"type": ["string", "null"]}, + }, + "description": "The subtotal before tax, if shown.", + "extend:type": "currency", + "additionalProperties": False, + }, + "tax_amount": { + "type": "object", + "required": ["amount", "iso_4217_currency_code"], + "properties": { + "amount": {"type": ["number", "null"]}, + "iso_4217_currency_code": {"type": ["string", "null"]}, + }, + "description": "The tax amount on the receipt.", + "extend:type": "currency", + "additionalProperties": False, + }, + "line_items": { + "type": "array", + "items": { + "type": "object", + "required": ["description", "quantity", "unit_price", "amount"], + "properties": { + "description": { + "type": ["string", "null"], + "description": "Description of the item purchased.", + }, + "quantity": { + "type": ["number", "null"], + "description": "Quantity of the item, if shown.", + }, + "unit_price": { + "type": ["number", "null"], + "description": "Price per unit, if shown.", + }, + "amount": { + "type": ["number", "null"], + "description": "Total amount for this line item.", + }, + }, + "additionalProperties": False, + }, + "description": "Individual items on the receipt.", + }, + "payment_method": { + "type": ["string", "null"], + "description": "The payment method used (e.g., cash, credit card, etc.).", + }, + }, + "additionalProperties": False, + }, + "advancedOptions": { + "advancedMultimodalEnabled": False, + "citationsEnabled": True, + "arrayCitationStrategy": "item", + "pageRanges": [], + "chunkingOptions": {}, + "advancedFigureParsingEnabled": True, + }, +} + + +# Polls Browserbase API for completed downloads with retry logic +async def save_downloads_with_retry( + bb: Browserbase, session_id: str, retry_for_seconds: int = 60 +) -> int: + """ + Polls Browserbase API for downloads with timeout handling. + + Browserbase stores downloaded files during a session and makes them available + via API. Files may take a few seconds to process, so this function implements + retry logic to wait for downloads to be ready before retrieving them. + + Args: + bb: Browserbase client instance for API calls + session_id: The Browserbase session ID to retrieve downloads from + retry_for_seconds: Maximum time to wait for downloads (default: 60 seconds) + + Returns: + int: The size of the downloaded ZIP file in bytes + + Raises: + TimeoutError: If downloads aren't ready within the specified timeout + """ + print(f"Waiting up to {retry_for_seconds} seconds for downloads to complete...") + + # Track elapsed time to implement timeout without using threading timers + start_time = asyncio.get_event_loop().time() + timeout = retry_for_seconds + + while True: + elapsed = asyncio.get_event_loop().time() - start_time + + # Check if we've exceeded the timeout period + if elapsed >= timeout: + raise TimeoutError("Download timeout exceeded") + + try: + print("Checking for downloads...") + # Fetch downloads from Browserbase API and save to disk when ready + # Use asyncio.to_thread for synchronous Browserbase SDK calls + # This prevents blocking the event loop while waiting for API responses + response = await asyncio.to_thread(bb.sessions.downloads.list, session_id) + download_buffer = await asyncio.to_thread(response.read) + + # Save downloads to disk when file size indicates content is available + # Empty zip files are ~22 bytes, so require at least 100 bytes for real content + if len(download_buffer) > 100: + print(f"Downloads ready! File size: {len(download_buffer)} bytes") + # Save the ZIP file containing all downloaded receipts to disk + with open("downloaded_files.zip", "wb") as f: + f.write(download_buffer) + print("Files saved as: downloaded_files.zip") + return len(download_buffer) + else: + print("Downloads not ready yet, retrying...") + except APIStatusError as e: + # Handle 404 (session not found) gracefully + if e.status_code == 404: + print("Session not found, returning empty result") + return 0 + print(f"Error fetching downloads: {e}") + raise + except Exception as e: + # HTML error response - session may not be ready yet, keep retrying + error_message = str(e) + if "Unexpected token '<'" in error_message or " list[str]: + """ + Extract receipt files from a ZIP archive. + + Args: + zip_path: Path to the ZIP file containing receipts + output_dir: Directory to extract files to (default: "output/documents") + + Returns: + list[str]: Paths to all extracted files + + Raises: + ValueError: If no files are found in the ZIP + """ + print(f"Extracting files from {zip_path}...") + + # Create output directories for documents and results if they don't exist + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + Path("output/results").mkdir(parents=True, exist_ok=True) + + extracted_files: list[str] = [] + + with zipfile.ZipFile(zip_path, "r") as zip_ref: + # Open zip file and iterate over entries + entries = [e for e in zip_ref.namelist() if not e.endswith("/")] + + if len(entries) == 0: + raise ValueError("No files found in the downloaded zip") + + # Extract all non-directory entries and collect file paths + resolved_output = output_path.resolve() + for entry in entries: + extracted_path = (resolved_output / entry).resolve() + if resolved_output not in extracted_path.parents: + raise ValueError(f"Unsafe ZIP entry: {entry}") + extracted_path.parent.mkdir(parents=True, exist_ok=True) + with zip_ref.open(entry) as source, extracted_path.open("wb") as target: + target.write(source.read()) + print(f"Extracted: {extracted_path}") + extracted_files.append(str(extracted_path)) + + print(f"\nTotal files extracted: {len(extracted_files)}") + return extracted_files + + +# Uploads receipt files to Extend AI, runs extraction, and saves results as JSON and CSV +async def parse_receipts_with_extend(file_paths: list[str]) -> list[dict]: + """ + Upload receipt files to Extend AI, run extraction, and save results. + + Initializes the Extend client, uploads each file, runs synchronous extraction + using inline config (no need to pre-create an extractor resource), and saves + results as JSON and CSV. + + Args: + file_paths: List of file paths to receipt documents + """ + # Skip parsing if Extend API key is not configured + extend_api_key = os.environ.get("EXTEND_API_KEY") + if not extend_api_key or extend_api_key == "YOUR_EXTEND_API_KEY_HERE": + print("\nWARNING: EXTEND_API_KEY not configured. Skipping receipt parsing.") + print(" Add your Extend API key to .env to enable automatic receipt parsing.") + return [] + + print("\n=== Parsing Receipts with Extend AI ===\n") + + # Initialize Extend AI client + extend_client = Extend(token=extend_api_key) + + print(f"Processing {len(file_paths)} receipts with inline config...\n") + + # Process all files with retry on rate limiting (429 errors) + results: list[dict] = [] + + # Uploads a single file to Extend and runs extraction with exponential backoff retry + async def process_with_retry(file_path: str, max_retries: int = 3) -> dict: + file_name = Path(file_path).name + + for attempt in range(1, max_retries + 1): + try: + # Upload the file to Extend (multipart form upload) + # Pass as (filename, bytes) tuple so Extend knows the file name + with open(file_path, "rb") as f: + file_bytes = f.read() + upload_response = await asyncio.to_thread( + extend_client.files.upload, + file=(file_name, file_bytes), + ) + file_id = upload_response.id + + # Run extraction using inline config โ€” no need to pre-create an extractor + result = await asyncio.to_thread( + extend_client.extract, + config=RECEIPT_EXTRACTION_CONFIG, + file={"id": file_id}, + ) + + run_id = result.id + print(f" Parsed {file_name} (run: {run_id})") + + # Convert to dict for JSON serialization + data = result + if hasattr(result, "to_dict"): + data = result.to_dict() + elif hasattr(result, "dict"): + data = result.dict() + + return { + "file": file_name, + "runId": run_id, + "data": data, + } + except Exception as error: + error_msg = str(error) + is_retryable = ( + "429" in error_msg or "rate" in error_msg or "disturbed or locked" in error_msg + ) + + if is_retryable and attempt < max_retries: + delay = 2**attempt # Exponential backoff: 2s, 4s, 8s + print( + f" Rate limited on {file_name}, retrying in {delay}s " + f"(attempt {attempt}/{max_retries})" + ) + await asyncio.sleep(delay) + else: + print(f" Failed to parse {file_name}: {error_msg}") + return {"file": file_name, "data": {"error": error_msg}} + + return {"file": file_name, "data": {"error": "Max retries exceeded"}} + + # Process in batches of 9 to balance speed and reliability + for i in range(0, len(file_paths), 9): + batch = file_paths[i : i + 9] + batch_results = await asyncio.gather(*[process_with_retry(fp) for fp in batch]) + results.extend(batch_results) + + # Save results to JSON + json_path = "output/results/receipts.json" + with open(json_path, "w") as f: + json.dump(results, f, indent=2, default=str) + print(f"\nSaved JSON: {json_path}") + + # Convert results to CSV for easy viewing in spreadsheet tools + csv_path = "output/results/receipts.csv" + with open(csv_path, "w", newline="") as f: + writer = csv.writer(f, quoting=csv.QUOTE_ALL) + writer.writerow( + [ + "file", + "vendor_name", + "receipt_date", + "receipt_number", + "total_amount", + "currency", + "subtotal", + "tax", + "payment_method", + "line_items_count", + ] + ) + + # Build CSV rows from extraction results + for result in results: + data = result.get("data", {}) + output = {} + if isinstance(data, dict): + output = data.get("output", {}).get("value", {}) or {} + + writer.writerow( + [ + result.get("file", ""), + output.get("vendor_name", ""), + output.get("receipt_date", ""), + output.get("receipt_number", ""), + (output.get("total_amount") or {}).get("amount", ""), + (output.get("total_amount") or {}).get("iso_4217_currency_code", ""), + (output.get("subtotal_amount") or {}).get("amount", ""), + (output.get("tax_amount") or {}).get("amount", ""), + output.get("payment_method", ""), + len(output.get("line_items", [])) + if isinstance(output.get("line_items"), list) + else 0, + ] + ) + + print(f"Saved CSV: {csv_path}") + return results + + +async def main() -> None: + """ + Main application entry point. + + Orchestrates the entire receipt download and extraction automation process: + 1. Initializes Browserbase and Stagehand clients + 2. Navigates to the expense portal + 3. Finds and clicks all individual receipt download buttons + 4. Retrieves downloads from Browserbase and extracts files + 5. Optionally parses receipts with Extend AI for structured data extraction + """ + print("Starting Expense Receipt Downloader...\n") + + browserbase_api_key = os.environ.get("BROWSERBASE_API_KEY") + if not browserbase_api_key: + raise ValueError("BROWSERBASE_API_KEY is required") + + # Initialize Browserbase SDK for session management and download retrieval + bb = Browserbase(api_key=browserbase_api_key) + + browser = await browserbase.launch(api_key=browserbase_api_key) + session_id = browser.session_id + if not session_id: + await browser.close() + raise RuntimeError("Browserbase launch did not return a session ID") + + try: + stagehand = await Stagehand.create( + browser=browser, + ) + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + print("Live View is available in the Browserbase Sessions dashboard") + + # Navigate to the expense portal where receipts are hosted + print("\nNavigating to expense portal...") + await page.goto( + "https://v0-reimburse-me-expense-portal.vercel.app/", + wait_until="domcontentloaded", + timeout=60_000, + ) + + # Use observe to find all individual download buttons (not the Download All button) + print("\nFinding all individual download buttons...") + observe_response = await stagehand.observe( + "Find all the small Download links on individual receipt cards.", + page=page, + ) + download_buttons = observe_response.data + if not download_buttons: + raise RuntimeError("No receipt download links were found") + + # Click each download button using observe -> act pattern + # Pass the observed action directly to act for precise element targeting + success_count = 0 + for i, action in enumerate(download_buttons): + print(f"Downloading receipt {i + 1}/{len(download_buttons)}...") + + try: + await stagehand.act(action, page=page) + success_count += 1 + except Exception: + # If click fails, scroll element into view and retry + print(f" Could not click download button {i + 1}, trying to scroll and retry...") + try: + await stagehand.act("Scroll down slightly", page=page) + await stagehand.act(action, page=page) + success_count += 1 + except Exception: + print(f" Skipping receipt {i + 1}") + + # Scroll down periodically to ensure elements are in view + if (i + 1) % 4 == 0 and (i + 1) < len(download_buttons): + await stagehand.act("Scroll down slightly", page=page) + + print(f"\nDownload clicks completed! ({success_count}/{len(download_buttons)} successful)") + + await stagehand.close() + await browser.close() + print("Session closed successfully") + + # Wait for session to finalize downloads before polling + await asyncio.sleep(2) + + # Retrieve all downloads triggered during this session from Browserbase API + print("\nRetrieving downloads from Browserbase...") + download_size = await save_downloads_with_retry(bb, session_id, 60) + + if download_size > 0: + # Extract receipt files from downloaded zip archive + extracted_files = extract_files_from_zip("downloaded_files.zip") + + print("\n=== Download Summary ===") + print(f"Total files downloaded: {len(extracted_files)}") + print("Files saved to: ./output/documents/") + + # Parse downloaded receipts with Extend AI for structured data extraction + await parse_receipts_with_extend(extracted_files) + else: + print("No downloads were captured") + + print("\nExpense receipt download complete!") + + except Exception as error: + print(f"Error during automation: {error}") + if "stagehand" in locals(): + await stagehand.close() + await browser.close() + raise + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as err: + print(f"Application error: {err}") + print("Common issues:") + print(" - Check .env file has BROWSERBASE_API_KEY") + print(" - Add EXTEND_API_KEY to .env to enable receipt parsing with Extend AI") + print(" - Verify internet connection and expense portal accessibility") + print("Docs: https://docs.stagehand.dev/v4/first-steps/introduction") + exit(1) diff --git a/packages/examples/extend-browserbase/python/pyproject.toml b/packages/examples/extend-browserbase/python/pyproject.toml new file mode 100644 index 0000000000..be829d8ca0 --- /dev/null +++ b/packages/examples/extend-browserbase/python/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "extend-browserbase" +version = "0.1.0" +description = "Download expense receipts and parse with Extend AI using Stagehand and Browserbase" +readme = "README.md" +requires-python = ">=3.11,<3.14" +dependencies = [ + "browserbase>=1.7.0", + "extend-ai>=1.0.0", + "python-dotenv>=1.2.1", + "stagehand==4.0.0", +] + +[project.optional-dependencies] +dev = ["pytest>=7.0.0", "black>=23.0.0", "ruff>=0.1.0"] + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 100 +target-version = ['py39', 'py310', 'py311'] + +[tool.ruff] +line-length = 100 +target-version = "py39" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] diff --git a/packages/examples/extend-browserbase/typescript/.env.example b/packages/examples/extend-browserbase/typescript/.env.example new file mode 100644 index 0000000000..e37c3a490e --- /dev/null +++ b/packages/examples/extend-browserbase/typescript/.env.example @@ -0,0 +1,5 @@ +# Browserbase Configuration +BROWSERBASE_API_KEY= + +# Extend AI (optional โ€“ enables receipt parsing; omit to only download receipts) +EXTEND_API_KEY= diff --git a/packages/examples/extend-browserbase/typescript/README.md b/packages/examples/extend-browserbase/typescript/README.md new file mode 100644 index 0000000000..0b26a0366a --- /dev/null +++ b/packages/examples/extend-browserbase/typescript/README.md @@ -0,0 +1,76 @@ +# Stagehand + Browserbase + Extend: Download Expense Receipts and Parse with Extend AI + +Location in the Stagehand repository: `packages/examples/extend-browserbase/typescript`. + +## AT A GLANCE + +- **Goal**: Automate downloading receipts from an expense portal and extract structured receipt data using AI-powered document parsing. +- **Pattern Template**: Demonstrates the integration pattern of Browserbase (browser automation + download capture) + Extend AI (schema-based document extraction). +- **Workflow**: Stagehand navigates the expense portal and clicks each receipt's download link; Browserbase captures downloads. The script polls for the session's download ZIP, extracts files, then optionally sends them to Extend for structured extraction (vendor, date, totals, line items, etc.). +- **Download Handling**: Implements retry/polling around Browserbase's Session Downloads API until the ZIP is available. +- **Structured Extraction**: Extend AI extraction with inline receipt JSON schema config; results written to `output/results/receipts.json` and `receipts.csv`. +- Docs โ†’ [Browserbase Downloads](https://docs.browserbase.com/features/downloads) | [Extend AI](https://docs.extend.app) + +## GLOSSARY + +- **act**: perform UI actions from natural language prompts (click, scroll, navigate) + Docs โ†’ https://docs.stagehand.dev/v4/basics/act +- **observe**: find and return interactive elements on the page matching a description, without performing actions. Used here to locate all individual download buttons before clicking them. + Docs โ†’ https://docs.stagehand.dev/v4/basics/observe +- **Browserbase Downloads**: When files are downloaded during a browser session, Browserbase captures and stores them. Files are retrieved via the Session Downloads API as a ZIP archive. + Docs โ†’ https://docs.browserbase.com/features/downloads +- **Extend AI extraction**: A configurable document extraction pipeline that parses files against a JSON schema and returns structured data. Config can be passed inline or via a saved extractor resource. + Docs โ†’ https://docs.extend.app +- **Download polling**: Browserbase syncs downloads in real-time; the script retries every 2 seconds until the ZIP is available or a timeout is reached. + +## QUICKSTART + +1. cd packages/examples/extend-browserbase/typescript +2. pnpm install +3. cp .env.example .env +4. Add required API keys to .env: + - `BROWSERBASE_API_KEY` + - `EXTEND_API_KEY` (optional โ€” enables receipt parsing) +5. pnpm start + +## EXPECTED OUTPUT + +- Initializes Stagehand V4 with Browserbase; Live View remains available in the Sessions dashboard +- Navigates to the expense portal and finds all per-receipt download links via observe +- Clicks each download button; Browserbase captures files +- After closing the session, polls for the session's download ZIP and extracts to `output/documents/` +- If `EXTEND_API_KEY` is set: uploads each file to Extend and runs extraction with inline config, writes `output/results/receipts.json` and `receipts.csv` +- Closes session cleanly + +## COMMON PITFALLS + +- "Cannot find module": ensure pnpm install completed in the extend-browserbase directory +- Missing credentials: verify .env contains BROWSERBASE_API_KEY +- Download timeout: increase `retryForSeconds` parameter in `saveDownloadsWithRetry` if downloads take longer than 60 seconds +- Empty ZIP file: ensure downloads were actually triggered (inspect the session in the Browserbase dashboard) +- Rate limiting on Extend: the script retries with exponential backoff on 429 errors, but very large batches may need the batch size reduced from 9 +- Find more information on your Browserbase dashboard โ†’ https://www.browserbase.com/sign-in + +## USE CASES + +โ€ข Expense automation: Download receipts from expense portals and extract vendor, date, totals, and line items for accounting systems. +โ€ข Document batch processing: Collect files from web portals and run structured extraction across all of them with a single script. +โ€ข Receipt digitization: Convert paper/PDF receipts into structured JSON and CSV for import into ERP, bookkeeping, or reimbursement tools. + +## NEXT STEPS + +โ€ข Parameterize the portal URL: Accept the expense portal URL from env or CLI to support different receipt sources. +โ€ข Custom schemas: Modify `receiptExtractionConfig` to extract different document types (invoices, W-2s, contracts) by changing the JSON schema. +โ€ข Add validation: Compare extracted totals against line item sums to flag discrepancies or incomplete extractions. +โ€ข Scheduled runs: Deploy on cron/Lambda to periodically check for new receipts and process them automatically. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐Ÿ“š Browserbase Downloads: https://docs.browserbase.com/features/downloads +๐Ÿ“š Extend AI: https://docs.extend.app +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/extend-browserbase/typescript/index.ts b/packages/examples/extend-browserbase/typescript/index.ts new file mode 100644 index 0000000000..67e0380b04 --- /dev/null +++ b/packages/examples/extend-browserbase/typescript/index.ts @@ -0,0 +1,459 @@ +// Stagehand + Browserbase + Extend: Download Expense Receipts and Parse with Extend AI - See README.md for full documentation + +import "dotenv/config"; +import { Browserbase } from "@browserbasehq/sdk"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import fs from "fs"; +import path from "path"; +import AdmZip from "adm-zip"; +import { ExtendClient } from "extend-ai"; + +// Polls Browserbase API for completed downloads with retry logic. +// Retries every 2 seconds until downloads are ready or timeout is reached. +async function saveDownloadsWithRetry( + bb: Browserbase, + sessionId: string, + timeoutSecs: number = 60, +): Promise { + console.log(`Waiting up to ${timeoutSecs} seconds for downloads to complete...`); + const deadline = Date.now() + timeoutSecs * 1000; + + while (Date.now() < deadline) { + try { + console.log("Checking for downloads..."); + const response = await bb.sessions.downloads.list(sessionId); + const buf = Buffer.from(await response.arrayBuffer()); + + if (buf.byteLength > 100) { + console.log(`Downloads ready! File size: ${buf.byteLength} bytes`); + fs.writeFileSync("downloaded_files.zip", buf); + console.log("Files saved as: downloaded_files.zip"); + return buf.byteLength; + } + console.log("Downloads not ready yet, retrying..."); + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : String(e); + // HTML error response - session may not be ready yet, keep retrying + if (errorMessage.includes("Unexpected token '<'") || errorMessage.includes(" setTimeout(r, 2000)); + } + throw new Error("Download timeout exceeded"); +} + +// Extracts receipt files from downloaded zip archive into output directories +function extractFilesFromZip(zipPath: string, outputDir: string = "output/documents"): string[] { + console.log(`Extracting files from ${zipPath}...`); + + // Create output directories for documents and results if they don't exist + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + if (!fs.existsSync("output/results")) { + fs.mkdirSync("output/results", { recursive: true }); + } + + // Open zip file and iterate over entries + const zip = new AdmZip(zipPath); + const entries = zip.getEntries(); + + if (entries.length === 0) { + throw new Error("No files found in the downloaded zip"); + } + + // Extract all non-directory entries and collect file paths + const extractedFiles: string[] = []; + + for (const entry of entries) { + if (!entry.isDirectory) { + const outputPath = path.join(outputDir, entry.entryName); + zip.extractEntryTo(entry, outputDir, false, true); + console.log(`Extracted: ${outputPath}`); + extractedFiles.push(outputPath); + } + } + + console.log(`\nTotal files extracted: ${extractedFiles.length}`); + return extractedFiles; +} + +// Receipt extraction config for Extend AI +// Uses extraction_light base extractor with parse_performance engine for low latency +const receiptExtractionConfig = { + baseProcessor: "extraction_light", + baseVersion: "3.4.0", + parseConfig: { + engine: "parse_performance", + target: "markdown", + blockOptions: { + text: { + agentic: { enabled: false }, + signatureDetectionEnabled: false, + }, + tables: { + agentic: { enabled: false }, + targetFormat: "markdown", + cellBlocksEnabled: false, + tableHeaderContinuationEnabled: false, + }, + figures: { + enabled: false, + figureImageClippingEnabled: false, + }, + }, + engineVersion: "1.0.1", + advancedOptions: { + engine: "parse_performance", + agenticOcrEnabled: false, + pageBreaksEnabled: true, + pageRotationEnabled: false, + verticalGroupingThreshold: 1, + }, + chunkingStrategy: { type: "document" }, + }, + schema: { + type: "object", + required: [ + "vendor_name", + "receipt_date", + "receipt_number", + "total_amount", + "subtotal_amount", + "tax_amount", + "line_items", + "payment_method", + ], + properties: { + vendor_name: { + type: ["string", "null"], + description: "The name of the merchant or vendor on the receipt.", + }, + receipt_date: { + type: ["string", "null"], + description: "The date of the transaction shown on the receipt.", + "extend:type": "date", + }, + receipt_number: { + type: ["string", "null"], + description: "The receipt or transaction number, if present.", + }, + total_amount: { + type: "object", + required: ["amount", "iso_4217_currency_code"], + properties: { + amount: { type: ["number", "null"] }, + iso_4217_currency_code: { type: ["string", "null"] }, + }, + description: "The total amount paid on the receipt.", + "extend:type": "currency", + additionalProperties: false, + }, + subtotal_amount: { + type: "object", + required: ["amount", "iso_4217_currency_code"], + properties: { + amount: { type: ["number", "null"] }, + iso_4217_currency_code: { type: ["string", "null"] }, + }, + description: "The subtotal before tax, if shown.", + "extend:type": "currency", + additionalProperties: false, + }, + tax_amount: { + type: "object", + required: ["amount", "iso_4217_currency_code"], + properties: { + amount: { type: ["number", "null"] }, + iso_4217_currency_code: { type: ["string", "null"] }, + }, + description: "The tax amount on the receipt.", + "extend:type": "currency", + additionalProperties: false, + }, + line_items: { + type: "array", + items: { + type: "object", + required: ["description", "quantity", "unit_price", "amount"], + properties: { + description: { + type: ["string", "null"], + description: "Description of the item purchased.", + }, + quantity: { + type: ["number", "null"], + description: "Quantity of the item, if shown.", + }, + unit_price: { + type: ["number", "null"], + description: "Price per unit, if shown.", + }, + amount: { + type: ["number", "null"], + description: "Total amount for this line item.", + }, + }, + additionalProperties: false, + }, + description: "Individual items on the receipt.", + }, + payment_method: { + type: ["string", "null"], + description: "The payment method used (e.g., cash, credit card, etc.).", + }, + }, + additionalProperties: false, + }, + advancedOptions: { + advancedMultimodalEnabled: false, + citationsEnabled: true, + arrayCitationStrategy: "item", + pageRanges: [], + chunkingOptions: {}, + advancedFigureParsingEnabled: true, + }, +}; + +// Uploads receipt files to Extend AI, runs extraction, and saves results as JSON and CSV +async function parseReceiptsWithExtend(filePaths: string[]): Promise { + // Skip parsing if Extend API key is not configured + if (!process.env.EXTEND_API_KEY || process.env.EXTEND_API_KEY === "YOUR_EXTEND_API_KEY_HERE") { + console.log("\nWARNING: EXTEND_API_KEY not configured. Skipping receipt parsing."); + console.log(" Add your Extend API key to .env to enable automatic receipt parsing."); + return; + } + + console.log("\n=== Parsing Receipts with Extend AI ===\n"); + + // Initialize Extend AI client + // SDK auto-retries 429s and 5xx errors with exponential backoff + const client = new ExtendClient({ token: process.env.EXTEND_API_KEY }); + + console.log(`Processing ${filePaths.length} receipts with inline config...\n`); + + // Process all files - SDK handles retries automatically with exponential backoff + const results: { file: string; runId?: string; data: unknown }[] = []; + + // Process in batches of 9 to balance speed and reliability + for (let i = 0; i < filePaths.length; i += 9) { + const batch = filePaths.slice(i, i + 9); + const batchResults = await Promise.all( + batch.map(async (filePath) => { + const fileName = path.basename(filePath); + try { + // Upload the file to Extend + const fileBuffer = fs.readFileSync(filePath); + const blob = new Blob([fileBuffer]); + const uploadResponse = await client.files.upload( + blob as Parameters[0], + {}, + ); + const fileId = uploadResponse.id; + + // Run extraction using inline config โ€” no need to pre-create an extractor resource + const result = await client.extract( + { + config: receiptExtractionConfig as Parameters[0]["config"], + file: { id: fileId }, + }, + { maxRetries: 4 }, + ); + + const runId = result.id; + console.log(` Parsed ${fileName} (run: ${runId})`); + return { file: fileName, runId, data: result }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(` Failed to parse ${fileName}:`, errorMsg); + return { file: fileName, data: { error: errorMsg } }; + } + }), + ); + results.push(...batchResults); + } + + // Save results to JSON + const jsonPath = "output/results/receipts.json"; + fs.writeFileSync(jsonPath, JSON.stringify(results, null, 2)); + console.log(`\nSaved JSON: ${jsonPath}`); + + // Convert results to CSV for easy viewing in spreadsheet tools + const csvRows: string[] = []; + csvRows.push( + "file,vendor_name,receipt_date,receipt_number,total_amount,currency,subtotal,tax,payment_method,line_items_count", + ); + + // Shape of the extracted receipt data from Extend extract runs + type ReceiptOutput = { + vendor_name?: string; + receipt_date?: string; + receipt_number?: string; + total_amount?: { amount?: string; iso_4217_currency_code?: string }; + subtotal_amount?: { amount?: string }; + tax_amount?: { amount?: string }; + payment_method?: string; + line_items?: unknown[]; + }; + + // Build CSV rows from extraction results + for (const result of results) { + const data = result.data as { output?: { value?: ReceiptOutput } } | undefined; + const output: ReceiptOutput = data?.output?.value || {}; + const row = [ + result.file, + output.vendor_name || "", + output.receipt_date || "", + output.receipt_number || "", + output.total_amount?.amount ?? "", + output.total_amount?.iso_4217_currency_code || "", + output.subtotal_amount?.amount ?? "", + output.tax_amount?.amount ?? "", + output.payment_method || "", + Array.isArray(output.line_items) ? output.line_items.length : 0, + ] + .map((v) => `"${String(v).replace(/"/g, '""')}"`) + .join(","); + csvRows.push(row); + } + + const csvPath = "output/results/receipts.csv"; + fs.writeFileSync(csvPath, csvRows.join("\n")); + console.log(`Saved CSV: ${csvPath}`); +} + +async function main(): Promise { + console.log("Starting Expense Receipt Downloader...\n"); + + if (!process.env.BROWSERBASE_API_KEY) { + throw new Error("BROWSERBASE_API_KEY is required"); + } + + // Initialize Browserbase SDK for session management and download retrieval + const bb = new Browserbase({ + apiKey: process.env.BROWSERBASE_API_KEY as string, + }); + + // V4's browser factory provisions and owns the Stagehand extension. + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + }); + const sessionId = browser.sessionId; + if (!sessionId) throw new Error("Browserbase launch did not return a session ID"); + const stagehand = await Stagehand.create({ + browser: browser, + model: { modelName: "google/gemini-2.5-flash" }, + logging: { level: "info" }, + }); + + try { + // Initialize browser session to start automation + + console.log("Stagehand initialized successfully!"); + const page = (await browser.context.pages())[0]; + console.log("Live View is available in the Browserbase Sessions dashboard"); + + // Navigate to the expense portal where receipts are hosted + console.log("\nNavigating to expense portal..."); + await page.goto("https://v0-reimburse-me-expense-portal.vercel.app/", { + waitUntil: "domcontentloaded", + }); + + // Use observe to find all individual download buttons (not the Download All button) + console.log("\nFinding all individual download buttons..."); + const { data: downloadButtons } = await stagehand.observe( + "Find all the small Download links on individual receipt cards.", + ); + if (downloadButtons.length === 0) throw new Error("No receipt download links were found"); + + // Click each download button using observe โ†’ act pattern + // Pass the observed action directly to act for precise element targeting + let successCount = 0; + for (let i = 0; i < downloadButtons.length; i++) { + const action = downloadButtons[i]; + console.log(`Downloading receipt ${i + 1}/${downloadButtons.length}...`); + + try { + await stagehand.act(action, { page }); + successCount++; + } catch (_clickError) { + // If click fails, scroll element into view and retry + console.log(` Could not click download button ${i + 1}, trying to scroll and retry...`); + try { + await stagehand.act("Scroll down slightly", { page }); + await stagehand.act(action, { page }); + successCount++; + } catch { + console.log(` Skipping receipt ${i + 1}`); + } + } + + // Scroll down periodically to ensure elements are in view + if ((i + 1) % 4 === 0 && i + 1 < downloadButtons.length) { + await stagehand.act("Scroll down slightly", { page }); + } + } + + console.log( + `\nDownload clicks completed! (${successCount}/${downloadButtons.length} successful)`, + ); + + // Retrieve all downloads triggered during this session from Browserbase API + if (sessionId) { + console.log("\nRetrieving downloads from Browserbase..."); + + // Close the browser session before fetching downloads + await stagehand.close().catch((error) => console.warn("Stagehand cleanup warning:", error)); + await browser.close().catch((error) => console.warn("Browser cleanup warning:", error)); + + // Wait for session to finalize downloads before polling + await new Promise((resolve) => setTimeout(resolve, 2000)); + + try { + const downloadSize = await saveDownloadsWithRetry(bb, sessionId, 60); + + if (downloadSize > 0) { + // Extract receipt files from downloaded zip archive + const extractedFiles = extractFilesFromZip("downloaded_files.zip"); + + console.log("\n=== Download Summary ==="); + console.log(`Total files downloaded: ${extractedFiles.length}`); + console.log("Files saved to: ./output/documents/"); + + // Parse downloaded receipts with Extend AI for structured data extraction + await parseReceiptsWithExtend(extractedFiles); + } else { + console.log("No downloads were captured"); + } + } catch (downloadError) { + console.error("Download retrieval failed:", downloadError); + throw downloadError; + } + } + + console.log("\nExpense receipt download complete!"); + } catch (error) { + console.error("Error during automation:", error); + try { + await stagehand.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } catch { + // Ignore close errors during cleanup + } + throw error; + } +} + +main().catch((err) => { + console.error("Application error:", err); + console.error("Common issues:"); + console.error(" - Check .env file has BROWSERBASE_API_KEY"); + console.error(" - Add EXTEND_API_KEY to .env to enable receipt parsing with Extend AI"); + console.error(" - Verify internet connection and expense portal accessibility"); + console.error("Docs: https://docs.stagehand.dev/v4/first-steps/introduction"); + process.exit(1); +}); diff --git a/packages/examples/extend-browserbase/typescript/package.json b/packages/examples/extend-browserbase/typescript/package.json new file mode 100644 index 0000000000..7d1300226f --- /dev/null +++ b/packages/examples/extend-browserbase/typescript/package.json @@ -0,0 +1,27 @@ +{ + "name": "extend-browserbase-partnership-template", + "version": "1.0.0", + "description": "Stagehand + Browserbase + Extend: Download receipts from expense portal and extract structured data with Extend AI", + "type": "module", + "main": "index.ts", + "scripts": { + "start": "tsx index.ts" + }, + "dependencies": { + "@browserbasehq/sdk": "^2.9.0", + "@browserbasehq/stagehand": "4.0.0", + "adm-zip": "^0.5.16", + "dotenv": "^17.2.4", + "extend-ai": "^1.0.2", + "open": "^11.0.0" + }, + "devDependencies": { + "@types/node": "^25.2.2", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.18.0" + }, + "packageManager": "pnpm@10.24.0" +} diff --git a/packages/examples/form-filling/README.md b/packages/examples/form-filling/README.md new file mode 100644 index 0000000000..48689a0161 --- /dev/null +++ b/packages/examples/form-filling/README.md @@ -0,0 +1,12 @@ +# form-filling + +Browserbase contact-form demonstration with synthetic inputs; example email normalized to example.com; submit remains disabled. + +Choose a language and follow its setup instructions: + +| Language | Stagehand dependency | Directory from the repository root | +| ---------------------------------- | -------------------- | ------------------------------------------- | +| [TypeScript](typescript/README.md) | `4.0.2` | `packages/examples/form-filling/typescript` | +| [Python](python/README.md) | `4.0.0` | `packages/examples/form-filling/python` | + +Each variant installs the published Stagehand package and keeps its own dependencies. diff --git a/packages/examples/form-filling/python/.env.example b/packages/examples/form-filling/python/.env.example new file mode 100644 index 0000000000..d0cde0427d --- /dev/null +++ b/packages/examples/form-filling/python/.env.example @@ -0,0 +1 @@ +BROWSERBASE_API_KEY= diff --git a/packages/examples/form-filling/python/README.md b/packages/examples/form-filling/python/README.md new file mode 100644 index 0000000000..2f39ebffac --- /dev/null +++ b/packages/examples/form-filling/python/README.md @@ -0,0 +1,64 @@ +# Stagehand + Browserbase: Form Filling Automation + +Location in the Stagehand repository: `packages/examples/form-filling/python`. + +Stagehand is the SDK for browser agents. + +## AT A GLANCE + +- Goal: showcase how to automate form filling with Stagehand and Browserbase. +- Smart Form Automation: dynamically fill contact forms with variable-driven data. +- AI-Powered Interaction: use `act()` to map each labeled input to the right field reliably. +- Variable-driven actions: fill each form control with the supplied sample values. + Docs โ†’ https://docs.browserbase.com/fundamentals/create-browser-session + +## GLOSSARY + +- act: perform UI actions from a prompt (type, click, fill forms) + Docs โ†’ https://docs.stagehand.dev/v4/basics/act +- variable substitution: inject dynamic values into actions using `%variable%` syntax + +## QUICKSTART + +1. cd packages/examples/form-filling/python +2. uv venv && source .venv/bin/activate # On Windows: .venv\Scripts\activate +3. pip install stagehand python-dotenv +4. cp .env.example .env # Add your Browserbase API key to .env +5. python main.py + +## EXPECTED OUTPUT + +- Initializes Stagehand session with Browserbase +- Navigates to contact form page +- Fills form with sample data using `act()` and variable substitution +- Closes session cleanly + +## COMMON PITFALLS + +- "ModuleNotFoundError": ensure all dependencies are installed via pip +- Missing credentials: verify .env contains all required API keys +- Form detection: ensure target page has fillable form fields +- Variable mismatch: ensure variable names in action match variables object +- Network issues: check internet connection and website accessibility +- Import errors: activate your virtual environment if you created one + +## USE CASES + +โ€ข Lead & intake automation: Auto-fill contact/quote/request forms from CRM or CSV to speed up inbound/outbound workflows. +โ€ข QA & regression testing: Validate form fields, required rules, and error states across releases/environments. +โ€ข Bulk registrations & surveys: Programmatically complete repeatable sign-ups or survey passes for pilots and internal ops. + +## NEXT STEPS + +โ€ข Wire in data sources: Load variables from CSV/JSON/CRM, map fields via observe, and support per-site field aliases. +โ€ข Submit & verify: Enable submit, capture success toasts/emails, take screenshots, and retry on validation errors. +โ€ข Handle complex widgets: Add file uploads, multi-step flows, dropdown/radio/datepickers, and basic anti-bot tactics (delays/proxies). + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/form-filling/python/main.py b/packages/examples/form-filling/python/main.py new file mode 100644 index 0000000000..4136da3a05 --- /dev/null +++ b/packages/examples/form-filling/python/main.py @@ -0,0 +1,82 @@ +"""Fill Browserbase's contact form with Stagehand V4.""" + +import asyncio +import os + +from dotenv import load_dotenv + +from stagehand import Stagehand, browserbase + +load_dotenv() + +FORM_FIELDS = { + "firstName": "Alex", + "lastName": "Johnson", + "companyName": "TechCorp Solutions", + "jobTitle": "Software Developer", + "email": "alex.johnson@example.com", + "project": ( + "Hello, I'm interested in learning more about your services and would " + "like to schedule a demo." + ), +} + + +async def main() -> None: + api_key = os.environ.get("BROWSERBASE_API_KEY") + if not api_key: + raise RuntimeError("BROWSERBASE_API_KEY is required") + + print("Starting Form Filling Example...") + browser = await browserbase.launch(api_key=api_key) + try: + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + + print("Navigating to Browserbase contact page...") + await page.goto( + "https://www.browserbase.com/contact", + wait_until="domcontentloaded", + timeout=60_000, + ) + await page.wait_for_timeout(1_500) + + field_prompts = { + "firstName": "first name", + "lastName": "last name", + "companyName": "company", + "jobTitle": "job title", + "email": "work email", + "project": "project description or message", + } + for name, label in field_prompts.items(): + await stagehand.act( + f"Fill the {label} field with %value%", + page=page, + variables={"value": FORM_FIELDS[name]}, + ) + + await stagehand.act("Click the How Can We Help dropdown", page=page) + await stagehand.act("Click the demo option in the open dropdown", page=page) + + # Uncomment to submit the form: + # await stagehand.act("Click the submit button", page=page) + print("Form filled successfully") + finally: + await stagehand.close() + finally: + await browser.close() + print("Session closed successfully") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as error: + print(f"Error in form filling example: {error}") + print("Docs: https://docs.stagehand.dev/v4/first-steps/introduction") + raise diff --git a/packages/examples/form-filling/python/pyproject.toml b/packages/examples/form-filling/python/pyproject.toml new file mode 100644 index 0000000000..4c30b97364 --- /dev/null +++ b/packages/examples/form-filling/python/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "form-filling" +version = "0.1.0" +requires-python = ">=3.11,<3.14" +dependencies = ["python-dotenv==1.2.2", "stagehand==4.0.0"] + +[tool.uv] +package = false diff --git a/packages/examples/form-filling/typescript/.env.example b/packages/examples/form-filling/typescript/.env.example new file mode 100644 index 0000000000..d0cde0427d --- /dev/null +++ b/packages/examples/form-filling/typescript/.env.example @@ -0,0 +1 @@ +BROWSERBASE_API_KEY= diff --git a/packages/examples/form-filling/typescript/README.md b/packages/examples/form-filling/typescript/README.md new file mode 100644 index 0000000000..abf09c8775 --- /dev/null +++ b/packages/examples/form-filling/typescript/README.md @@ -0,0 +1,60 @@ +# Stagehand + Browserbase: Form Filling Automation + +Location in the Stagehand repository: `packages/examples/form-filling/typescript`. + +## AT A GLANCE + +- Goal: showcase how to automate form filling with Stagehand and Browserbase. +- Smart Form Automation: dynamically fill contact forms with variable-driven data. +- Observe โ†’ Act: discovers the live form controls once, then fills the observed actions with the matching values. +- Variable-driven actions: pair observed form controls with the supplied sample values. + Docs โ†’ https://docs.browserbase.com/fundamentals/create-browser-session + +## GLOSSARY + +- observe / act: discover interactive elements, then execute the observed actions + Docs โ†’ https://docs.stagehand.dev/v4/basics/observe + +## QUICKSTART + +1. cd packages/examples/form-filling/typescript +2. npm install +3. cp .env.example .env +4. Add your Browserbase API key and Project ID to .env +5. npm start + +## EXPECTED OUTPUT + +- Initializes Stagehand session with Browserbase +- Navigates to contact form page +- Fills the known form fields and help dropdown with sample data +- Closes both the Stagehand instance and browser handle after the workflow +- Closes session cleanly + +## COMMON PITFALLS + +- "Cannot find module": ensure all dependencies are installed +- Missing credentials: verify .env contains all required API keys +- Field mismatch: adjust the semantic field descriptions if the contact form changes +- Network issues: check internet connection and website accessibility + +## USE CASES + +โ€ข Lead & intake automation: Auto-fill contact/quote/request forms from CRM or CSV to speed up inbound/outbound workflows. +โ€ข QA & regression testing: Validate form fields, required rules, and error states across releases/environments. +โ€ข Bulk registrations & surveys: Programmatically complete repeatable sign-ups or survey passes for pilots and internal ops. + +## NEXT STEPS + +โ€ข Wire in data sources: Load variables from CSV/JSON/CRM and add per-site field mappings. +โ€ข Submit & verify: Enable submit, capture success toasts/emails, take screenshots, and retry on validation errors. +โ€ข Handle complex widgets: Add file uploads, multi-step flows, dropdown/radio/datepickers, and basic anti-bot tactics (delays/proxies). + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/form-filling/typescript/index.ts b/packages/examples/form-filling/typescript/index.ts new file mode 100644 index 0000000000..4d8b5e0985 --- /dev/null +++ b/packages/examples/form-filling/typescript/index.ts @@ -0,0 +1,104 @@ +// Stagehand + Browserbase: Form Filling Automation - See README.md for full documentation + +import "dotenv/config"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; + +// Form data variables - using random/fake data for testing +// Set your own variables below to customize the form submission +const firstName = "Alex"; +const lastName = "Johnson"; +const company = "TechCorp Solutions"; +const jobTitle = "Software Developer"; +const email = "alex.johnson@example.com"; +const message = + "Hello, I'm interested in learning more about your services and would like to schedule a demo."; + +async function main() { + console.log("Starting Form Filling Example..."); + + // Initialize Stagehand with Browserbase for cloud-based browser automation. + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + }); + const stagehand = await Stagehand.create({ + browser: browser, + model: { modelName: "openai/gpt-4.1" }, + logging: { level: "info" }, + }); + + try { + // Initialize browser session to start automation. + + console.log("Stagehand initialized successfully!"); + const page = (await browser.context.pages())[0]; + + // Navigate to contact page with extended timeout for slow-loading sites. + console.log("Navigating to Browserbase contact page..."); + await page.goto("https://www.browserbase.com/contact", { + waitUntil: "domcontentloaded", // Wait for DOM to be ready before proceeding. + timeout: 60000, // Extended timeout for reliable page loading. + }); + + const fields = [ + ["firstName", firstName], + ["lastName", lastName], + ["companyName", company], + ["jobTitle", jobTitle], + ["email", email], + ["project", message], + ] as const; + const { data: formFields } = await stagehand.observe( + "Find form fields for: first name, last name, company, job title, email, message", + ); + for (const field of formFields) { + const description = field.description.toLowerCase(); + const match = fields.find(([name]) => { + const labels: Record = { + firstName: ["first name"], + lastName: ["last name"], + companyName: ["company"], + jobTitle: ["job title"], + email: ["email"], + project: ["message", "project"], + }; + return labels[name].some((label) => description.includes(label)); + }); + if (match) { + await stagehand.act({ ...field, arguments: [match[1]] }); + } + } + await stagehand.act("Click on the How Can we help? dropdown"); + await stagehand.act("Click on the demo option from the dropdown"); + + // Uncomment the line below if you want to submit the form + // await stagehand.act("Click the submit button"); + + console.log("Form filled successfully! Waiting 3 seconds..."); + await page.waitForTimeout(3000); + } catch (error) { + console.error(`Error during form filling: ${error}`); + throw error; + } finally { + // Always close session to release resources and clean up. + try { + await stagehand.close(); + } catch (error) { + console.warn(`Stagehand cleanup warning: ${error}`); + } + try { + await browser.close(); + } catch (error) { + console.warn(`Browser cleanup warning: ${error}`); + } + console.log("Session closed successfully"); + } +} + +main().catch((err) => { + console.error("Error in form filling example:", err); + console.error("Common issues:"); + console.error(" - Check .env file has BROWSERBASE_API_KEY"); + console.error(" - Ensure form fields are available on the contact page"); + console.error("Docs: https://docs.stagehand.dev/v4/first-steps/introduction"); + process.exit(1); +}); diff --git a/packages/examples/form-filling/typescript/package.json b/packages/examples/form-filling/typescript/package.json new file mode 100644 index 0000000000..0dde12e7fd --- /dev/null +++ b/packages/examples/form-filling/typescript/package.json @@ -0,0 +1,21 @@ +{ + "name": "form-filling", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "tsx index.ts" + }, + "dependencies": { + "@browserbasehq/stagehand": "4.0.2", + "dotenv": "^17.4.2" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "tsx": "^4.23.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.18.0" + } +} diff --git a/packages/examples/gift-finder/README.md b/packages/examples/gift-finder/README.md new file mode 100644 index 0000000000..a6464c515e --- /dev/null +++ b/packages/examples/gift-finder/README.md @@ -0,0 +1,12 @@ +# gift-finder + +Public retail product recommendations with configurable recipient interests. + +Choose a language and follow its setup instructions: + +| Language | Stagehand dependency | Directory from the repository root | +| ---------------------------------- | -------------------- | ------------------------------------------ | +| [TypeScript](typescript/README.md) | `4.0.0` | `packages/examples/gift-finder/typescript` | +| [Python](python/README.md) | `4.0.0` | `packages/examples/gift-finder/python` | + +Each variant installs the published Stagehand package and keeps its own dependencies. diff --git a/packages/examples/gift-finder/python/.env.example b/packages/examples/gift-finder/python/.env.example new file mode 100644 index 0000000000..fb300c35ab --- /dev/null +++ b/packages/examples/gift-finder/python/.env.example @@ -0,0 +1,5 @@ +BROWSERBASE_API_KEY= +# Recommended: a Vercel AI Gateway key that can route to openai/gpt-4.1. +AI_GATEWAY_API_KEY= +# Alternative when not using AI Gateway. +OPENAI_API_KEY= diff --git a/packages/examples/gift-finder/python/README.md b/packages/examples/gift-finder/python/README.md new file mode 100644 index 0000000000..7bfa6f9fa1 --- /dev/null +++ b/packages/examples/gift-finder/python/README.md @@ -0,0 +1,71 @@ +# Stagehand + Browserbase: AI-Powered Gift Finder + +Location in the Stagehand repository: `packages/examples/gift-finder/python`. + +Stagehand is the SDK for browser agents. + +## AT A GLANCE + +- Goal: find personalized gift recommendations using AI-generated search queries and intelligent product scoring. +- AI Integration: Stagehand for AI-generated search queries and score products based on recipient profile. +- Concurrent Sessions: runs multiple browser sessions simultaneously to search different queries in parallel. +- Proxies: uses Browserbase proxies with UK geolocation for European website access (Firebox.eu). + +## GLOSSARY + +- act: perform UI actions from a prompt (search, click, type) + Docs โ†’ https://docs.stagehand.dev/v4/basics/act +- extract: pull structured data from pages using schemas + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- concurrent sessions: run multiple browser sessions simultaneously for faster searching + Docs โ†’ https://docs.browserbase.com/guides/concurrency-rate-limits +- proxies: use geolocation-based routing for European website access (Firebox.eu) + Docs โ†’ https://docs.browserbase.com/features/proxies + +## QUICKSTART + +1. cd packages/examples/gift-finder/python +2. uv venv venv +3. source venv/bin/activate # On Windows: venv\Scripts\activate +4. pip install -r requirements.txt +5. pip install InquirerPy pydantic openai +6. cp .env.example .env # Add your Browserbase API key to .env +7. python main.py + +## EXPECTED OUTPUT + +- Prompts user for recipient and description +- Generates 3 search queries using OpenAI +- Runs concurrent browser sessions to search Firebox.eu +- Extracts product data using structured schemas +- AI-scores products based on recipient profile +- Displays top 3 personalized gift recommendations + +## COMMON PITFALLS + +- Browserbase Developer plan or higher is required to use proxies (they have been commented out in the code) +- "ModuleNotFoundError": ensure all dependencies are installed via pip +- Missing credentials: verify .env contains all required API keys +- Search failures: check internet connection and website accessibility +- Import errors: activate your virtual environment if you created one + +## USE CASES + +โ€ข Multi-retailer product discovery: Generate smart queries, browse in parallel, and extract structured results across sites (with geo-specific proxies when needed). +โ€ข Personalized gifting/recommendations: Score items against a recipient profile for gift lists, concierge shopping, or corporate gifting portals. +โ€ข Assortment & market checks: Rapidly sample categories to compare price/availability/ratings across regions or competitors. + +## NEXT STEPS + +โ€ข Add site adapters: Plug in more retailers with per-site extract schemas, result normalization, and de-duplication (canonical URL matching). +โ€ข Upgrade ranking: Blend AI scores with signals (price, reviews, shipping, stock), and persist results to JSON/CSV/DB for re-scoring and audits. +โ€ข Scale & geo-test: Fan out more concurrent sessions and run a geo matrix via proxies (e.g., UK/EU/US) to compare localized inventory and pricing. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/gift-finder/python/main.py b/packages/examples/gift-finder/python/main.py new file mode 100644 index 0000000000..4cef0b4b24 --- /dev/null +++ b/packages/examples/gift-finder/python/main.py @@ -0,0 +1,186 @@ +"""Find, score, and verify live gift recommendations with Stagehand V4.""" + +import asyncio +import json +import os +from urllib.parse import urljoin, urlparse + +from dotenv import load_dotenv +from openai import OpenAI +from pydantic import BaseModel, Field + +from stagehand import Stagehand, browserbase + +load_dotenv() + +RECIPIENT = "Friend" +DESCRIPTION = "loves cooking and trying new recipes" + + +class Product(BaseModel): + title: str + url: str + price: str + rating: str + ai_score: int | None + ai_reason: str | None + + +class Products(BaseModel): + products: list[Product] = Field(max_length=3) + + +class ProductScore(BaseModel): + product_index: int = Field(alias="productIndex") + score: int = Field(ge=1, le=10) + reason: str = Field(min_length=1, max_length=100) + + +def openai_client() -> tuple[OpenAI, str]: + gateway_key = os.environ.get("AI_GATEWAY_API_KEY") + if gateway_key: + return ( + OpenAI(api_key=gateway_key, base_url="https://ai-gateway.vercel.sh/v1"), + "openai/gpt-4.1", + ) + key = os.environ.get("OPENAI_API_KEY") + if not key: + raise RuntimeError("AI_GATEWAY_API_KEY or OPENAI_API_KEY is required") + return OpenAI(api_key=key), "gpt-4.1" + + +def generate_search_queries() -> list[str]: + client, model = openai_client() + response = client.chat.completions.create( + model=model, + messages=[ + { + "role": "user", + "content": ( + "Generate exactly three short gift search queries of one or two words " + f"for a {RECIPIENT.lower()} who {DESCRIPTION}. Focus on thoughtful " + "accessories, upgrades, and related unexpected items rather than basic " + "necessities. Return one plain query per line with no bullets." + ), + } + ], + max_completion_tokens=200, + ) + content = response.choices[0].message.content or "" + queries = [line.strip(" -0123456789.\t") for line in content.splitlines() if line.strip()] + if len(queries) != 3: + raise RuntimeError(f"OpenAI returned {len(queries)} queries instead of three") + return queries + + +def score_products(products: list[Product]) -> list[Product]: + product_list = "\n".join( + f"{index + 1}. {product.title} - {product.price} - {product.rating}" + for index, product in enumerate(products) + ) + client, model = openai_client() + response = client.chat.completions.create( + model=model, + messages=[ + { + "role": "user", + "content": ( + f"Score every gift from 1-10 for a {RECIPIENT.lower()} who {DESCRIPTION}. " + "Return only a JSON array where every object has productIndex, score, " + f"and a reason under 100 characters.\n\n{product_list}" + ), + } + ], + max_completion_tokens=1_000, + ) + content = (response.choices[0].message.content or "[]").strip() + content = content.removeprefix("```json").removeprefix("```").removesuffix("```").strip() + raw_scores = json.loads(content) + scores = [ProductScore.model_validate(item) for item in raw_scores] + expected_indexes = set(range(1, len(products) + 1)) + if ( + len(scores) != len(products) + or {score.product_index for score in scores} != expected_indexes + ): + raise RuntimeError("OpenAI did not return one unique score for every product") + + by_index = {score.product_index: score for score in scores} + scored = [] + for index, product in enumerate(products, start=1): + score = by_index[index] + scored.append( + product.model_copy(update={"ai_score": score.score, "ai_reason": score.reason}) + ) + return sorted(scored, key=lambda product: product.ai_score or 0, reverse=True) + + +async def search_products(query: str, index: int) -> list[Product]: + api_key = os.environ.get("BROWSERBASE_API_KEY") + if not api_key: + raise RuntimeError("BROWSERBASE_API_KEY is required") + + print(f"Search {index + 1}: {query}") + browser = await browserbase.launch(api_key=api_key, region="us-east-1") + try: + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + await page.goto("https://firebox.eu/", wait_until="domcontentloaded", timeout=60_000) + await stagehand.act(f"Type {query} into the search bar", page=page) + await stagehand.act("Click the search button", page=page) + await page.wait_for_timeout(1_000) + extracted = await stagehand.extract( + "Extract the first three products from the search results", + Products, + page=page, + ) + base_url = await page.url() + products = [] + for product in extracted.data.products: + if not product.title.strip() or not product.url.strip(): + continue + absolute_url = urljoin(base_url, product.url) + parsed_url = urlparse(absolute_url) + if parsed_url.scheme in {"http", "https"} and parsed_url.hostname: + products.append(product.model_copy(update={"url": absolute_url})) + if not products: + raise RuntimeError(f"No products found for {query!r}") + return products + finally: + await stagehand.close() + finally: + await browser.close() + + +async def main() -> None: + if len(DESCRIPTION.strip()) < 5: + raise RuntimeError("Recipient description is too short") + queries = await asyncio.to_thread(generate_search_queries) + print(f"Generated queries: {queries}") + + products: list[Product] = [] + for index, query in enumerate(queries): + try: + products.extend(await search_products(query, index)) + except Exception as error: + print(f"Search {index + 1} produced no usable products: {error}") + if len(products) < 3: + raise RuntimeError(f"Expected at least three products, received {len(products)}") + + scored = await asyncio.to_thread(score_products, products) + top_three = scored[:3] + if len(top_three) != 3 or any(product.ai_score is None for product in top_three): + raise RuntimeError("Gift ranking did not produce three scored recommendations") + print("Top three recommendations:") + print(json.dumps([product.model_dump(mode="json") for product in top_three], indent=2)) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as error: + print(f"Gift finder failed: {error}") + raise SystemExit(1) from error diff --git a/packages/examples/gift-finder/python/pyproject.toml b/packages/examples/gift-finder/python/pyproject.toml new file mode 100644 index 0000000000..5e4b41f550 --- /dev/null +++ b/packages/examples/gift-finder/python/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "gift-finder" +version = "0.1.0" +requires-python = ">=3.11,<3.14" +dependencies = ["openai>=2.26,<3", "pydantic>=2.12,<3", "python-dotenv==1.2.2", "stagehand==4.0.0"] + +[tool.uv] +package = false diff --git a/packages/examples/gift-finder/typescript/.env.example b/packages/examples/gift-finder/typescript/.env.example new file mode 100644 index 0000000000..24b9f29a29 --- /dev/null +++ b/packages/examples/gift-finder/typescript/.env.example @@ -0,0 +1,4 @@ +BROWSERBASE_API_KEY= +AI_GATEWAY_API_KEY= +# Optional fallback when AI_GATEWAY_API_KEY is not set: +OPENAI_API_KEY= diff --git a/packages/examples/gift-finder/typescript/README.md b/packages/examples/gift-finder/typescript/README.md new file mode 100644 index 0000000000..9c11a9ad75 --- /dev/null +++ b/packages/examples/gift-finder/typescript/README.md @@ -0,0 +1,64 @@ +# Stagehand + Browserbase: AI-Powered Gift Finder + +Location in the Stagehand repository: `packages/examples/gift-finder/typescript`. + +## AT A GLANCE + +- Goal: find personalized gift recommendations using AI-generated search queries and intelligent product scoring. +- AI Integration: OpenAI through Vercel AI Gateway generates and scores personalized search terms; Stagehand searches and extracts the live products. A direct OpenAI key remains a fallback. +- Concurrent Sessions: runs multiple browser sessions simultaneously to search different queries in parallel. + +## GLOSSARY + +- act: perform UI actions from a prompt (search, click, type) + Docs โ†’ https://docs.stagehand.dev/v4/basics/act +- extract: pull structured data from pages using schemas + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- concurrent sessions: run multiple browser sessions simultaneously for faster searching + Docs โ†’ https://docs.browserbase.com/guides/concurrency-rate-limits +- proxies: use geolocation-based routing for European website access (Firebox.eu) + Docs โ†’ https://docs.browserbase.com/features/proxies + +## QUICKSTART + +1. cd packages/examples/gift-finder/typescript +2. npm install +3. cp .env.example .env +4. Add `BROWSERBASE_API_KEY` and `AI_GATEWAY_API_KEY` to .env +5. npm start + +## EXPECTED OUTPUT + +- Reads the recipient and description from `CONFIG` in `index.ts` +- Generates 3 search queries using OpenAI +- Runs concurrent browser sessions to search Firebox.eu +- Extracts product data using structured schemas +- AI-scores products based on recipient profile +- Displays top 3 personalized gift recommendations + +## COMMON PITFALLS + +- "Cannot find module": ensure all dependencies are installed +- Missing credentials: verify .env contains BROWSERBASE_API_KEY and AI_GATEWAY_API_KEY (or OPENAI_API_KEY for the direct fallback) +- Search failures: check internet connection and website accessibility + +## USE CASES + +โ€ข Multi-retailer product discovery: Generate smart queries, browse in parallel, and extract structured results across sites (with geo-specific proxies when needed). +โ€ข Personalized gifting/recommendations: Score items against a recipient profile for gift lists, concierge shopping, or corporate gifting portals. +โ€ข Assortment & market checks: Rapidly sample categories to compare price/availability/ratings across regions or competitors. + +## NEXT STEPS + +โ€ข Add site adapters: Plug in more retailers with per-site extract schemas, result normalization, and de-duplication (canonical URL matching). +โ€ข Upgrade ranking: Blend AI scores with signals (price, reviews, shipping, stock), and persist results to JSON/CSV/DB for re-scoring and audits. +โ€ข Scale & geo-test: Fan out more concurrent sessions and run a geo matrix via proxies (e.g., UK/EU/US) to compare localized inventory and pricing. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/gift-finder/typescript/index.ts b/packages/examples/gift-finder/typescript/index.ts new file mode 100644 index 0000000000..f5a0e88ad0 --- /dev/null +++ b/packages/examples/gift-finder/typescript/index.ts @@ -0,0 +1,393 @@ +// Stagehand + Browserbase: AI-Powered Gift Finder - See README.md for full documentation + +import "dotenv/config"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import OpenAI from "openai"; +import { z } from "zod/v4"; + +// ============= CONFIGURATION ============= +// Update these values to customize your gift search +const CONFIG = { + recipient: "Friend", // Options: "Mum", "Dad", "Sister", "Brother", "Friend", "Boss" + description: "loves cooking and trying new recipes", // Describe their interests, hobbies, age, etc. +}; +// ========================================= + +interface GiftFinderAnswers { + recipient: string; + description: string; +} + +interface Product { + title: string; + url: string; + price: string; + rating: string; + aiScore?: number; + aiReason?: string; +} + +interface SearchResult { + query: string; + sessionIndex: number; + products: Product[]; +} + +async function closeSession( + stagehand: Stagehand, + browser: Awaited>, +) { + await stagehand.close().catch((error) => console.warn("Stagehand cleanup warning:", error)); + await browser.close().catch((error) => console.warn("Browser cleanup warning:", error)); +} + +function openAIClient(): { client: OpenAI; model: string } { + if (process.env.AI_GATEWAY_API_KEY) { + return { + client: new OpenAI({ + apiKey: process.env.AI_GATEWAY_API_KEY, + baseURL: "https://ai-gateway.vercel.sh/v1", + }), + model: "openai/gpt-4.1", + }; + } + if (process.env.OPENAI_API_KEY) { + return { + client: new OpenAI({ apiKey: process.env.OPENAI_API_KEY }), + model: "gpt-4.1", + }; + } + throw new Error("AI_GATEWAY_API_KEY or OPENAI_API_KEY is required"); +} + +async function generateSearchQueries(recipient: string, description: string): Promise { + console.log(`Generating search queries for ${recipient}...`); + + // Use AI to generate search terms based on recipient profile + // This avoids generic searches and focuses on thoughtful, complementary gifts + const { client, model } = openAIClient(); + const response = await client.chat.completions.create({ + model, + messages: [ + { + role: "user", + content: `Generate exactly 3 short gift search queries (1-2 words each) for finding gifts for a ${recipient.toLowerCase()} who is described as: "${description}". + +IMPORTANT: Assume they already have the basic necessities related to their interests. Focus on: +- Complementary items that enhance their hobbies +- Thoughtful accessories or upgrades +- Related but unexpected items +- Premium or unique versions of things they might not buy themselves + +AVOID obvious basics like "poker set" for poker players, "dumbbells" for fitness enthusiasts, etc. + +Examples for "loves cooking": +spice rack +chef knife +herb garden + +Return ONLY the search terms, one per line, no dashes, bullets, or numbers. Just the plain search terms:`, + }, + ], + max_completion_tokens: 1000, + }); + + // Parse AI response and clean up formatting + const queries = + response.choices[0]?.message?.content + ?.trim() + .split("\n") + .filter((q) => q.trim()) || []; + return queries.slice(0, 3); +} + +async function scoreProducts( + products: Product[], + recipient: string, + description: string, +): Promise { + console.log("AI is analyzing gift options based on recipient profile..."); + + // Flatten all products from multiple search sessions into single array + const allProducts = products.flat(); + + if (allProducts.length === 0) { + console.log("No products to score"); + return []; + } + + // Format products for AI analysis with index numbers for reference + const productList = allProducts + .map( + (product, index) => `${index + 1}. ${product.title} - ${product.price} - ${product.rating}`, + ) + .join("\n"); + + console.log(`Scoring ${allProducts.length} products...`); + + const { client, model } = openAIClient(); + const response = await client.chat.completions.create({ + model, + messages: [ + { + role: "user", + content: `You are a gift recommendation expert. Score each product based on how well it matches the recipient profile. + +RECIPIENT: ${recipient} +DESCRIPTION: ${description} + +PRODUCTS TO SCORE: +${productList} + +For each product, provide a score from 1-10 (10 being perfect match) and a brief reason. Consider: +- How well it matches their interests/hobbies +- Appropriateness for the relationship (${recipient.toLowerCase()}) +- Value for money +- Uniqueness/thoughtfulness +- Practical usefulness + +Return ONLY a valid JSON array (no markdown, no code blocks) with this exact format: +[ + { + "productIndex": 1, + "score": 8, + "reason": "Perfect for poker enthusiasts, high quality chips enhance the gaming experience" + }, + { + "productIndex": 2, + "score": 6, + "reason": "Useful but basic, might already own similar item" + } +] + +IMPORTANT: +- Return raw JSON only, no code blocks +- Include all ${allProducts.length} products +- Keep reasons under 100 characters +- Use productIndex 1-${allProducts.length}`, + }, + ], + max_completion_tokens: 1000, + }); + + // Clean up AI response by removing markdown code blocks + let responseContent = response.choices[0]?.message?.content?.trim() || "[]"; + + responseContent = responseContent.replace(/```json\n?/g, "").replace(/```\n?/g, ""); + + const ScoreSchema = z.object({ + productIndex: z.number().int().min(1).max(allProducts.length), + score: z.number().min(1).max(10), + reason: z.string().min(1).max(100), + }); + const scoresData = z + .array(ScoreSchema) + .length(allProducts.length) + .parse(JSON.parse(responseContent)); + if (new Set(scoresData.map((score) => score.productIndex)).size !== allProducts.length) { + throw new Error("OpenAI scoring did not return one unique score per product"); + } + + // Map AI scores back to products using index matching + const scoredProducts = allProducts.map((product, index) => { + const scoreInfo = scoresData.find((score) => score.productIndex === index + 1)!; + return { + ...product, + aiScore: scoreInfo.score, + aiReason: scoreInfo.reason, + }; + }); + + // Sort by AI score descending to show best matches first + return scoredProducts.sort((a, b) => (b.aiScore || 0) - (a.aiScore || 0)); +} + +async function getUserInput(): Promise { + console.log("Welcome to the Gift Finder App!"); + console.log("Find the perfect gift with intelligent web browsing"); + console.log(`\nSearching for gifts for: ${CONFIG.recipient}`); + console.log(`Profile: ${CONFIG.description}\n`); + + // Validate description length + if (CONFIG.description.trim().length < 5) { + throw new Error( + "Description must be at least 5 characters long. Please update the CONFIG at the top of the file.", + ); + } + + return CONFIG; +} + +async function main(): Promise { + console.log("Starting Gift Finder Application..."); + + if ( + !process.env.BROWSERBASE_API_KEY || + (!process.env.AI_GATEWAY_API_KEY && !process.env.OPENAI_API_KEY) + ) { + throw new Error( + "BROWSERBASE_API_KEY and either AI_GATEWAY_API_KEY or OPENAI_API_KEY are required", + ); + } + + const { recipient, description } = await getUserInput(); + console.log(`User input received: ${recipient} - ${description}`); + + console.log("\nGenerating intelligent search queries..."); + + const searchQueries = await generateSearchQueries(recipient, description); + if (searchQueries.length !== 3) { + throw new Error(`Expected 3 generated search queries, received ${searchQueries.length}`); + } + console.log("\nGenerated Search Queries:"); + searchQueries.forEach((query, index) => { + console.log(` ${index + 1}. ${query.replace(/['"]/g, "")}`); + }); + + console.log("\nStarting concurrent browser searches..."); + + async function runSingleSearch(query: string, sessionIndex: number): Promise { + console.log(`Starting search session ${sessionIndex + 1} for: "${query}"`); + + // Create separate Stagehand instance for each search to run concurrently + // Each session searches independently to maximize speed + const sessionBrowser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + region: "us-east-1", + timeout: 900, + browserSettings: { + viewport: { + width: 1920, + height: 1080, + }, + }, + }); + const sessionStagehand = await Stagehand.create({ + browser: sessionBrowser, + model: { modelName: "openai/gpt-4.1" }, + logging: { level: "info" }, + }); + + try { + const sessionPage = (await sessionBrowser.context.pages())[0]; + + // Navigate to European gift site - proxies help with regional access + console.log(`Session ${sessionIndex + 1}: Navigating to Firebox.eu...`); + await sessionPage.goto("https://firebox.eu/"); + + // Perform search using natural language actions + console.log(`Session ${sessionIndex + 1}: Searching for "${query}"...`); + await sessionStagehand.act(`Type ${query} into the search bar`); + await sessionStagehand.act("Click the search button"); + await sessionPage.waitForTimeout(1000); + + // Extract structured product data using Zod schema for type safety + console.log(`Session ${sessionIndex + 1}: Extracting product data...`); + const { data: productsData } = await sessionStagehand.extract( + "Extract the first 3 products from the search results", + z.object({ + products: z + .array( + z.object({ + title: z.string().describe("the title/name of the product"), + url: z.string().describe("the full URL link to the product page"), + price: z.string().describe("the price of the product (include currency symbol)"), + rating: z + .string() + .describe( + "the star rating or number of reviews (e.g., '4.5 stars' or '123 reviews')", + ), + }), + ) + .max(3) + .describe("array of the first 3 products from search results"), + }), + ); + + const baseUrl = await sessionPage.url(); + const products = productsData.products.flatMap((product) => { + if (!product.title.trim() || !product.url.trim()) return []; + try { + const url = new URL(product.url, baseUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") return []; + return [{ ...product, url: url.href }]; + } catch { + return []; + } + }); + + console.log(`Session ${sessionIndex + 1}: Found ${products.length} products for "${query}"`); + + await closeSession(sessionStagehand, sessionBrowser); + + return { + query, + sessionIndex: sessionIndex + 1, + products, + }; + } catch (error) { + console.error(`Session ${sessionIndex + 1} failed:`, error); + + await closeSession(sessionStagehand, sessionBrowser); + + return { + query, + sessionIndex: sessionIndex + 1, + products: [], + }; + } + } + + const searchPromises = searchQueries.map((query, index) => runSingleSearch(query, index)); + + console.log("\nBrowser Sessions Starting..."); + console.log("Search sessions are running concurrently"); + + // Wait for all concurrent searches to complete + const allResults = await Promise.all(searchPromises); + const failedSearches = allResults.filter((result) => result.products.length === 0); + if (failedSearches.length > 0) { + console.warn( + `${failedSearches.length} of ${allResults.length} gift searches produced no usable products; continuing with the successful results`, + ); + } + + // Calculate total products found across all search sessions + const totalProducts = allResults.reduce((sum, result) => sum + result.products.length, 0); + console.log(`\nTotal products found: ${totalProducts} across ${searchQueries.length} searches`); + + // Flatten all products into single array for AI scoring + const allProductsFlat = allResults.flatMap((result) => result.products); + + if (allProductsFlat.length < 3) { + throw new Error(`Expected at least 3 products to rank, received ${allProductsFlat.length}`); + } + + // AI scores all products and ranks them by relevance to recipient + const scoredProducts = await scoreProducts(allProductsFlat, recipient, description); + const top3Products = scoredProducts.slice(0, 3); + + console.log("\nTOP 3 RECOMMENDED GIFTS:"); + + // Display top 3 products with AI reasoning for transparency + top3Products.forEach((product, index) => { + const rank = `#${index + 1}`; + console.log(`\n${rank} - ${product.title}`); + console.log(`Price: ${product.price}`); + console.log(`Rating: ${product.rating}`); + console.log(`Score: ${product.aiScore}/10`); + console.log(`Why: ${product.aiReason}`); + console.log(`Link: ${product.url}`); + }); + + console.log( + `\nGift finding complete! Found ${totalProducts} products, analyzed ${scoredProducts.length} with AI.`, + ); + + console.log("\nThank you for using Gift Finder!"); +} + +main().catch((err) => { + console.error("Application error:", err); + process.exit(1); +}); diff --git a/packages/examples/gift-finder/typescript/package.json b/packages/examples/gift-finder/typescript/package.json new file mode 100644 index 0000000000..66671065d4 --- /dev/null +++ b/packages/examples/gift-finder/typescript/package.json @@ -0,0 +1,25 @@ +{ + "name": "gift-finder", + "version": "1.0.0", + "description": "Stagehand + Browserbase: AI-Powered Gift Finder", + "type": "module", + "main": "index.ts", + "scripts": { + "start": "tsx index.ts" + }, + "dependencies": { + "@browserbasehq/stagehand": "4.0.0", + "dotenv": "latest", + "openai": "latest", + "zod": "^4.1.12" + }, + "devDependencies": { + "@types/node": "latest", + "tsx": "latest", + "typescript": "latest" + }, + "engines": { + "node": ">=22.18.0" + }, + "packageManager": "pnpm@10.24.0" +} diff --git a/packages/examples/google-trends/README.md b/packages/examples/google-trends/README.md new file mode 100644 index 0000000000..2b7976950d --- /dev/null +++ b/packages/examples/google-trends/README.md @@ -0,0 +1,12 @@ +# google-trends + +Read-only public trending-search extraction with country/language inputs. + +Choose a language and follow its setup instructions: + +| Language | Stagehand dependency | Directory from the repository root | +| ---------------------------------- | -------------------- | -------------------------------------------- | +| [TypeScript](typescript/README.md) | `4.0.0` | `packages/examples/google-trends/typescript` | +| [Python](python/README.md) | `4.0.0` | `packages/examples/google-trends/python` | + +Each variant installs the published Stagehand package and keeps its own dependencies. diff --git a/packages/examples/google-trends/python/.env.example b/packages/examples/google-trends/python/.env.example new file mode 100644 index 0000000000..19e3f4e3b4 --- /dev/null +++ b/packages/examples/google-trends/python/.env.example @@ -0,0 +1,2 @@ +# Browserbase credentials (required) +BROWSERBASE_API_KEY= diff --git a/packages/examples/google-trends/python/README.md b/packages/examples/google-trends/python/README.md new file mode 100644 index 0000000000..b706a5a94d --- /dev/null +++ b/packages/examples/google-trends/python/README.md @@ -0,0 +1,64 @@ +# Stagehand + Browserbase: Google Trends Keywords Extractor + +Location in the Stagehand repository: `packages/examples/google-trends/python`. + +Stagehand is the SDK for browser agents. + +## AT A GLANCE + +- Goal: Extract trending search keywords from Google Trends for any country with structured JSON output. +- Configurable by country code (US, GB, IN, DE, etc.) and language preference. +- Uses JSON schema validation for consistent, typed data extraction. +- Docs โ†’ https://docs.stagehand.dev/v4/basics/extract + +## GLOSSARY + +- extract: extract structured data from web pages using natural language instructions and JSON schemas + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- act: perform UI actions from a prompt (click, type, dismiss dialogs) + Docs โ†’ https://docs.stagehand.dev/v4/basics/act + +## QUICKSTART + +1. uv sync +2. cp .env.example .env # Add your Browserbase API key to .env +3. uv run python main.py + +## EXPECTED OUTPUT + +- Initializes Stagehand session with Browserbase +- Displays live session link for monitoring +- Navigates to Google Trends trending page with configured country/language +- Dismisses any consent dialogs if present +- Extracts trending keywords with rank positions +- Outputs structured JSON with country code, language, timestamp, and keyword list +- Closes session cleanly + +## COMMON PITFALLS + +- "ModuleNotFoundError": ensure all dependencies are installed via `uv sync` +- Missing credentials: verify .env contains BROWSERBASE_API_KEY +- Invalid country code: ensure country code is a valid 2-letter ISO code (US, GB, IN, DE, FR, BR, etc.) +- Empty results: Google Trends may not have trending data for all country/language combinations +- Find more information on your Browserbase dashboard -> https://www.browserbase.com/sign-in + +## USE CASES + +โ€ข Market research: Track trending topics across different regions to identify emerging interests and market opportunities. +โ€ข Content strategy: Discover popular search terms to inform blog posts, social media content, or SEO keyword targeting. +โ€ข Competitive intelligence: Monitor trending keywords in your industry to stay ahead of market shifts and consumer interests. + +## NEXT STEPS + +โ€ข Parameterize inputs: Accept country code and limit as command-line arguments or environment variables for flexible deployment. +โ€ข Historical tracking: Store extracted keywords with timestamps to build a trends database and analyze keyword momentum over time. +โ€ข Multi-region comparison: Extend to fetch trends from multiple countries in parallel and compare regional differences. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/google-trends/python/main.py b/packages/examples/google-trends/python/main.py new file mode 100644 index 0000000000..79d7dc6c3c --- /dev/null +++ b/packages/examples/google-trends/python/main.py @@ -0,0 +1,84 @@ +"""Extract current Google Trends keywords with Stagehand V4.""" + +import asyncio +import json +import os +from datetime import UTC, datetime + +from dotenv import load_dotenv +from pydantic import BaseModel, Field, RootModel +from stagehand import Stagehand, browserbase + +load_dotenv() + +COUNTRY_CODE = "US" +LANGUAGE = "en-US" +LIMIT = 20 + + +class TrendingKeyword(BaseModel): + rank: int = Field(description="Position in the visible trending list") + keyword: str = Field(description="Main trending search term") + + +class TrendingKeywords(RootModel[list[TrendingKeyword]]): + pass + + +async def main() -> None: + api_key = os.environ.get("BROWSERBASE_API_KEY") + if not api_key: + raise RuntimeError("BROWSERBASE_API_KEY is required") + + print(f"Extracting up to {LIMIT} Google Trends keywords for {COUNTRY_CODE}") + browser = await browserbase.launch(api_key=api_key) + try: + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + url = f"https://trends.google.com/trending?geo={COUNTRY_CODE.upper()}&hl={LANGUAGE}" + await page.goto(url, wait_until="networkidle", timeout=60_000) + + try: + await stagehand.act( + 'Click the "Got it" button if it is visible', + page=page, + timeout=5_000, + ) + except Exception: + print("No consent dialog found") + + extracted = await stagehand.extract( + ( + "Extract the visible trending search keywords from the table. " + "Assign rank 1 to the first row and continue in order. " + f"Return at most {LIMIT} items." + ), + TrendingKeywords, + page=page, + ) + keywords = extracted.data.root[:LIMIT] + output = { + "country_code": COUNTRY_CODE, + "language": LANGUAGE, + "extracted_at": datetime.now(UTC).isoformat(), + "trending_keywords": [item.model_dump() for item in keywords], + } + print(json.dumps(output, indent=2)) + finally: + await stagehand.close() + finally: + await browser.close() + print("Session closed successfully") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as error: + print(f"Google Trends extraction failed: {error}") + print("Docs: https://docs.stagehand.dev/v4/first-steps/introduction") + raise diff --git a/packages/examples/google-trends/python/pyproject.toml b/packages/examples/google-trends/python/pyproject.toml new file mode 100644 index 0000000000..9e4b671705 --- /dev/null +++ b/packages/examples/google-trends/python/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "google-trends" +version = "0.1.0" +description = "Extract trending keywords from Google Trends using Stagehand and Browserbase" +readme = "README.md" +requires-python = ">=3.11,<3.14" +dependencies = ["pydantic>=2.12,<3", "python-dotenv==1.2.2", "stagehand==4.0.0"] + +[project.optional-dependencies] +dev = ["pytest>=7.0.0", "black>=23.0.0", "ruff>=0.1.0"] + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 100 +target-version = ['py311'] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] diff --git a/packages/examples/google-trends/typescript/.env.example b/packages/examples/google-trends/typescript/.env.example new file mode 100644 index 0000000000..19e3f4e3b4 --- /dev/null +++ b/packages/examples/google-trends/typescript/.env.example @@ -0,0 +1,2 @@ +# Browserbase credentials (required) +BROWSERBASE_API_KEY= diff --git a/packages/examples/google-trends/typescript/README.md b/packages/examples/google-trends/typescript/README.md new file mode 100644 index 0000000000..a1dbd89284 --- /dev/null +++ b/packages/examples/google-trends/typescript/README.md @@ -0,0 +1,63 @@ +# Stagehand + Browserbase: Google Trends Keywords Extractor + +Location in the Stagehand repository: `packages/examples/google-trends/typescript`. + +## AT A GLANCE + +- Goal: Extract trending search keywords from Google Trends for any country with structured JSON output. +- Configurable by country code (US, GB, IN, DE, etc.) and language preference. +- Uses Zod schema validation for consistent, typed data extraction. +- Docs โ†’ https://docs.stagehand.dev/v4/basics/extract + +## GLOSSARY + +- extract: extract structured data from web pages using natural language instructions and Zod schemas + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- act: perform UI actions from a prompt (click, type, dismiss dialogs) + Docs โ†’ https://docs.stagehand.dev/v4/basics/act + +## QUICKSTART + +1. pnpm install +2. cp .env.example .env +3. Add your Browserbase API key to .env +4. pnpm start + +## EXPECTED OUTPUT + +- Initializes Stagehand session with Browserbase +- Closes both the Stagehand instance and browser handle after extraction +- Navigates to Google Trends trending page with configured country/language +- Dismisses any consent dialogs if present +- Extracts trending keywords with rank positions +- Outputs structured JSON with country code, language, timestamp, and keyword list +- Closes session cleanly + +## COMMON PITFALLS + +- Missing credentials: verify .env contains BROWSERBASE_API_KEY +- Invalid country code: ensure country code is a valid 2-letter ISO code (US, GB, IN, DE, FR, BR, etc.) +- Empty results: Google Trends may not have trending data for all country/language combinations +- Consent dialogs: script handles "Got it" dialogs automatically, but regional variations may require adjustment +- Find more information on your Browserbase dashboard -> https://www.browserbase.com/sign-in + +## USE CASES + +โ€ข Market research: Track trending topics across different regions to identify emerging interests and market opportunities. +โ€ข Content strategy: Discover popular search terms to inform blog posts, social media content, or SEO keyword targeting. +โ€ข Competitive intelligence: Monitor trending keywords in your industry to stay ahead of market shifts and consumer interests. + +## NEXT STEPS + +โ€ข Parameterize inputs: Accept country code and limit as command-line arguments or environment variables for flexible deployment. +โ€ข Historical tracking: Store extracted keywords with timestamps to build a trends database and analyze keyword momentum over time. +โ€ข Multi-region comparison: Extend to fetch trends from multiple countries in parallel and compare regional differences. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/google-trends/typescript/index.ts b/packages/examples/google-trends/typescript/index.ts new file mode 100644 index 0000000000..81bae542d3 --- /dev/null +++ b/packages/examples/google-trends/typescript/index.ts @@ -0,0 +1,109 @@ +// Stagehand + Browserbase: Google Trends Keywords Extractor - See README.md for full documentation + +import "dotenv/config"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod/v4"; + +// Configuration variables +const countryCode = "US"; // Two-letter ISO code (US, GB, IN, DE, FR, BR) +const limit = 20; // Max keywords to return +const language = "en-US"; // Language code for results + +// Define Zod schema for structured data extraction +const TrendingKeywordSchema = z.object({ + rank: z.number().describe("Position in the trending list (1, 2, 3, etc.)"), + keyword: z.string().describe("The main trending search term or keyword"), +}); + +async function main() { + console.log("Starting Google Trends Keywords Extractor..."); + console.log(`Country Code: ${countryCode}`); + console.log(`Language: ${language}`); + console.log(`Limit: ${limit} keywords`); + + // Initialize Stagehand with Browserbase for cloud-based browser automation. + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + }); + const stagehand = await Stagehand.create({ + browser: browser, + model: { modelName: "google/gemini-2.5-flash" }, + logging: { level: "info" }, + }); + + try { + // Initialize browser session to start data extraction process. + + console.log("Stagehand initialized successfully"); + + const page = (await browser.context.pages())[0]; + + // Build and navigate to Google Trends URL with country code and language. + const trendsUrl = `https://trends.google.com/trending?geo=${countryCode.toUpperCase()}&hl=${language}`; + console.log(`Navigating to: ${trendsUrl}`); + await page.goto(trendsUrl, { + waitUntil: "networkidle", + }); + console.log("Page loaded successfully"); + + // Dismiss any consent/welcome dialogs that block content. + try { + console.log("Checking for consent dialogs..."); + await stagehand.act('Click the "Got it" button if visible', { timeout: 5000 }); + // Small delay to let the dialog close and content load. + await new Promise((resolve) => setTimeout(resolve, 1500)); + } catch { + // No dialog present, continue. + console.log("No consent dialog found, continuing..."); + } + + // Extract trending keywords using Stagehand's structured extraction with Zod schema. + console.log("Extracting trending keywords from table..."); + const { data: extractResult } = await stagehand.extract( + `Extract the trending search keywords from the Google Trends table. Each row has a trending topic/keyword shown as a button (like "catherine ohara", "don lemon arrested", "fed chair", etc.). For each trend, extract the main keyword text and assign a rank starting from 1 for the first trend. Return up to ${limit} items.`, + z.array(TrendingKeywordSchema), + ); + + // Apply limit to results and build output structure. + const limitedKeywords = extractResult.slice(0, limit); + console.log(`Successfully extracted ${limitedKeywords.length} trending keywords`); + + // Output formatted results with metadata. + const result = { + country_code: countryCode.toUpperCase(), + language: language, + extracted_at: new Date().toISOString(), + trending_keywords: limitedKeywords, + }; + + console.log("\n=== Results ==="); + console.log(JSON.stringify(result, null, 2)); + console.log(`\nExtraction complete! Found ${limitedKeywords.length} trending keywords.`); + } catch (error) { + console.error("Error extracting trending keywords:", error); + + // Provide helpful troubleshooting information. + console.error("\nCommon issues:"); + console.error("1. Check .env file has BROWSERBASE_API_KEY"); + console.error("2. Ensure country code is a valid 2-letter ISO code (US, GB, IN, DE, etc.)"); + console.error("3. Verify Browserbase account has sufficient credits"); + console.error("4. Check if Google Trends page structure has changed"); + + throw error; + } finally { + // Always close session to release resources and clean up. + console.log("Closing browser session..."); + await stagehand.close().catch((error) => console.warn("Stagehand cleanup warning:", error)); + await browser.close().catch((error) => console.warn("Browser cleanup warning:", error)); + console.log("Session closed successfully"); + } +} + +main().catch((err) => { + console.error("Application error:", err); + console.error("Common issues:"); + console.error(" - Check .env file has BROWSERBASE_API_KEY"); + console.error(" - Verify country code is a valid 2-letter ISO code (US, GB, IN, DE, etc.)"); + console.error("Docs: https://docs.stagehand.dev/v4/first-steps/introduction"); + process.exit(1); +}); diff --git a/packages/examples/google-trends/typescript/package.json b/packages/examples/google-trends/typescript/package.json new file mode 100644 index 0000000000..f5257326e0 --- /dev/null +++ b/packages/examples/google-trends/typescript/package.json @@ -0,0 +1,36 @@ +{ + "name": "google-trends-template", + "version": "1.0.0", + "description": "Stagehand + Browserbase: Google Trends Keywords Extractor", + "keywords": [ + "automation", + "browserbase", + "google-trends", + "keywords", + "seo", + "stagehand", + "trending" + ], + "license": "MIT", + "author": "", + "type": "module", + "main": "index.ts", + "scripts": { + "start": "tsx index.ts", + "dev": "tsx watch index.ts" + }, + "dependencies": { + "@browserbasehq/stagehand": "4.0.0", + "dotenv": "^16.0.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.7.0", + "typescript": "^5.3.0" + }, + "engines": { + "node": ">=22.18.0" + }, + "packageManager": "pnpm@10.24.0" +} diff --git a/packages/examples/hacker-news-intelligence/.env.example b/packages/examples/hacker-news-intelligence/.env.example new file mode 100644 index 0000000000..86fbd2b825 --- /dev/null +++ b/packages/examples/hacker-news-intelligence/.env.example @@ -0,0 +1,12 @@ +# Browserbase Configuration +BROWSERBASE_API_KEY= +BROWSERBASE_PROJECT_ID= + +# Optional Configuration +NODE_ENV=development +LOG_LEVEL=info + +# Demo Configuration +MAX_POSTS=5 +ENABLE_CONTENT_EXTRACTION=true +TIMEOUT_MS=30000 diff --git a/packages/examples/hacker-news-intelligence/.gitignore b/packages/examples/hacker-news-intelligence/.gitignore new file mode 100644 index 0000000000..bea4ea60c4 --- /dev/null +++ b/packages/examples/hacker-news-intelligence/.gitignore @@ -0,0 +1,35 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment variables +.env +.env.local +.env.*.local + +# Build outputs +dist/ +build/ + +# Logs +logs/ +*.log + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Coverage +coverage/ + +# Temporary files +.tmp/ +temp/ \ No newline at end of file diff --git a/packages/examples/hacker-news-intelligence/README.md b/packages/examples/hacker-news-intelligence/README.md new file mode 100644 index 0000000000..9b76683854 --- /dev/null +++ b/packages/examples/hacker-news-intelligence/README.md @@ -0,0 +1,311 @@ +# Hacker News Intelligence Demo + +Location in the Stagehand repository: `packages/examples/hacker-news-intelligence`. + +> **Enterprise-grade Browserbase automation demo for news aggregation and content intelligence** + +This demonstration showcases Browserbase's cloud browser automation capabilities through an AI-powered Hacker News intelligence system. Built with Stagehand's natural language automation framework, it demonstrates how enterprises can automate complex web workflows for competitive intelligence, content research, and trend analysis. + +## ๐ŸŽฏ What This Demo Does + +The demo performs comprehensive Hacker News intelligence gathering: + +1. **๐Ÿ” Intelligent Post Discovery**: Navigates to Hacker News and extracts the top trending posts using AI-powered element detection +2. **๐Ÿ“– Content Analysis**: Automatically visits external links and extracts key insights from articles +3. **๐Ÿค– AI-Powered Categorization**: Analyzes content for business relevance, sentiment, and technical complexity +4. **๐Ÿ“Š Executive Reporting**: Generates structured intelligence reports with actionable insights +5. **๐ŸŽ›๏ธ Enterprise Configuration**: Provides customizable parameters for different use cases + +## ๐Ÿข Business Value for Enterprise Customers + +- **Competitive Intelligence**: Monitor trending technologies and startup activities +- **Content Strategy**: Identify popular topics for content marketing and thought leadership +- **Market Research**: Track sentiment and engagement around industry topics +- **Developer Relations**: Stay current with developer community interests and concerns +- **Investment Research**: Analyze startup and technology trends for investment decisions + +## ๐Ÿš€ Quick Start + +### Prerequisites + +- Node.js 18+ and npm/yarn +- Browserbase account with API key +- 5 minutes for setup + +### 1. Clone and Install + +```bash +cd packages/examples/hacker-news-intelligence +npm install +``` + +### 2. Configure Environment + +```bash +cp .env.example .env +``` + +Edit `.env` with your Browserbase credentials: + +```env +BROWSERBASE_API_KEY=your_browserbase_api_key_here +BROWSERBASE_PROJECT_ID=your_project_id_here + +# Optional customization +MAX_POSTS=5 +ENABLE_CONTENT_EXTRACTION=true +LOG_LEVEL=info +``` + +### 3. Verify Setup + +```bash +npm run test +# or +npm run dev -- --health-check +``` + +### 4. Run the Demo + +```bash +npm run dev +``` + +## ๐Ÿ“– How to Use This Demo + +### Basic Usage + +The demo runs automatically once started: + +```bash +npm run dev +``` + +**What happens during execution:** + +1. **Initialization**: Establishes secure Browserbase session with residential proxies +2. **Navigation**: Opens Hacker News front page using cloud browser infrastructure +3. **AI Extraction**: Uses Stagehand's natural language commands to identify and extract post data +4. **Content Analysis**: Visits external links and extracts article content using intelligent parsing +5. **Intelligence Generation**: Applies AI analysis for categorization, sentiment, and business relevance scoring +6. **Report Generation**: Creates comprehensive reports in both console and JSON formats + +### Configuration Options + +Customize the demo behavior through environment variables: + +```env +# Number of posts to analyze (1-30) +MAX_POSTS=10 + +# Enable/disable full content extraction +ENABLE_CONTENT_EXTRACTION=true + +# Logging verbosity +LOG_LEVEL=debug # debug, info, warn, error + +# Request timeout +TIMEOUT_MS=45000 +``` + +### Output Formats + +The demo generates multiple output formats: + +- **Console Display**: Rich formatted output with colors and emojis +- **JSON Reports**: Structured data saved to `reports/` directory +- **Executive Summary**: Business-focused insights for stakeholders + +## ๐Ÿ—๏ธ Technical Architecture + +### Built with Enterprise-Grade Technologies + +- **Browserbase Cloud Platform**: Scalable browser automation infrastructure +- **Stagehand AI Framework**: Natural language web automation +- **TypeScript**: Type-safe development with comprehensive error handling +- **Advanced Logging**: Structured logging with multiple verbosity levels +- **Graceful Error Handling**: Robust error recovery and reporting + +### Key Components + +``` +src/ +โ”œโ”€โ”€ main.ts # Main orchestration and CLI interface +โ”œโ”€โ”€ extractor.ts # AI-powered content extraction logic +โ”œโ”€โ”€ reporter.ts # Intelligence report generation +โ”œโ”€โ”€ config.ts # Environment and runtime configuration +โ”œโ”€โ”€ logger.ts # Enterprise logging system +โ”œโ”€โ”€ types.ts # TypeScript type definitions +โ””โ”€โ”€ test.ts # Automated testing suite +``` + +### Stagehand Integration Highlights + +The demo showcases advanced Stagehand capabilities: + +```typescript +// Natural language automation +await page.act("Extract the top 5 trending posts with all metadata"); + +// Intelligent content analysis +const insights = await page.extract(` + Analyze this article and provide: + - Key business insights + - Technical complexity assessment + - Market relevance score +`); + +// Adaptive element detection +await page.observe("Check if content loaded successfully"); +``` + +## ๐Ÿ”ง Troubleshooting + +### Common Issues + +**Error: "BROWSERBASE_API_KEY is required"** + +- Ensure `.env` file exists with valid API credentials +- Verify API key is active in your Browserbase dashboard + +**Error: "No posts extracted"** + +- Check internet connectivity +- Verify Hacker News is accessible +- Try running with `LOG_LEVEL=debug` for detailed information + +**Slow Performance** + +- Reduce `MAX_POSTS` for faster testing +- Disable content extraction: `ENABLE_CONTENT_EXTRACTION=false` +- Check Browserbase region settings in `src/config.ts` + +### Advanced Debugging + +Enable verbose logging: + +```bash +LOG_LEVEL=debug npm run dev +``` + +Run health check: + +```bash +npm run dev -- --health-check +``` + +Check Browserbase sessions: + +- View active sessions in your Browserbase dashboard +- Use Session Inspector URLs provided in debug output +- Review Session Replay for detailed interaction analysis + +## ๐ŸŽ›๏ธ Customization for Your Use Case + +### Industry-Specific Adaptations + +**Financial Services**: Focus on fintech and blockchain posts + +```typescript +// Modify src/extractor.ts +const targetCategories = ["fintech", "blockchain", "trading", "banking"]; +``` + +**Healthcare**: Monitor health tech and biotech trends + +```typescript +// Custom extraction parameters +const healthcareKeywords = ["healthtech", "biotech", "medical", "pharma"]; +``` + +**Enterprise SaaS**: Track SaaS and enterprise technology + +```typescript +// Business relevance scoring adjustments +const enterpriseWeight = 1.5; // Boost enterprise-focused content +``` + +### Scaling for Production + +**High-Volume Processing**: + +```typescript +// Parallel processing configuration +const browserbaseConfig = { + concurrent: 5, // Multiple browser sessions + rateLimiting: true, // Respect rate limits + retryLogic: 3, // Automatic retry on failures +}; +``` + +**Data Integration**: + +```typescript +// Export to external systems +await exportToSlack(report); +await saveToDatabase(analyses); +await sendToDataWarehouse(intelligenceData); +``` + +## ๐Ÿ” Security and Compliance + +This demo implements enterprise security best practices: + +- **No Data Persistence**: No sensitive data stored locally +- **Secure Sessions**: All browser sessions use Browserbase's secure infrastructure +- **Configurable Privacy**: Session recording can be disabled for compliance +- **Rate Limiting**: Respects website rate limits and robots.txt +- **Error Isolation**: Failed extractions don't impact overall execution + +## ๐Ÿ“Š Performance Metrics + +Typical performance benchmarks: + +- **5 Posts**: ~30-45 seconds +- **10 Posts**: ~60-90 seconds +- **20 Posts**: ~120-180 seconds + +Performance factors: + +- Content extraction enabled/disabled +- Network latency to target sites +- Browserbase region selection +- Article length and complexity + +## ๐Ÿš€ Next Steps for Enterprise Implementation + +### Immediate Enhancements + +1. **Scheduling**: Add cron jobs for regular intelligence gathering +2. **Notifications**: Integrate with Slack, Teams, or email for alerts +3. **Data Storage**: Connect to databases or data warehouses +4. **Custom Sources**: Extend beyond Hacker News to industry-specific sites + +### Advanced Features + +1. **Multi-Source Aggregation**: Reddit, Product Hunt, GitHub trending +2. **Sentiment Tracking**: Historical sentiment analysis and trending +3. **Competitor Monitoring**: Track specific companies or technologies +4. **AI Summarization**: Generate executive briefings and trend reports + +### Enterprise Integration + +1. **API Development**: RESTful API for integration with existing systems +2. **Dashboard Creation**: Real-time intelligence dashboards +3. **Workflow Automation**: Integration with marketing and research workflows +4. **Custom Analytics**: Business-specific KPIs and metrics + +## ๐Ÿค Support and Professional Services + +This demo represents a starting point for enterprise automation workflows. For production implementation, custom development, or integration support: + +- **Technical Support**: Contact your Browserbase customer success team +- **Custom Development**: Professional services available for enterprise customization +- **Training**: Workshops and training sessions for your development team +- **Architecture Review**: Best practices consultation for large-scale deployments + +--- + +**Built with โค๏ธ using Browserbase and Stagehand** + +_This demo showcases the power of cloud browser automation for enterprise intelligence gathering. Ready to see how Browserbase can transform your web automation workflows?_ diff --git a/packages/examples/hacker-news-intelligence/package.json b/packages/examples/hacker-news-intelligence/package.json new file mode 100644 index 0000000000..96ef090f6f --- /dev/null +++ b/packages/examples/hacker-news-intelligence/package.json @@ -0,0 +1,34 @@ +{ + "name": "hacker-news-intelligence-demo", + "version": "1.0.0", + "description": "Enterprise-ready Browserbase demo for Hacker News content intelligence and automation", + "keywords": [ + "ai-automation", + "automation", + "browserbase", + "news-aggregation", + "stagehand", + "web-scraping" + ], + "license": "MIT", + "author": "Browserbase", + "main": "dist/main.js", + "scripts": { + "build": "tsc", + "start": "node dist/main.js", + "dev": "tsx src/main.ts", + "test": "tsx src/test.ts", + "clean": "rm -rf dist" + }, + "dependencies": { + "@browserbasehq/stagehand": "^1.5.0", + "chalk": "^5.3.0", + "dotenv": "^16.3.1", + "ora": "^8.0.1" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "tsx": "^4.6.0", + "typescript": "^5.3.0" + } +} diff --git a/packages/examples/hacker-news-intelligence/src/config.ts b/packages/examples/hacker-news-intelligence/src/config.ts new file mode 100644 index 0000000000..c839613252 --- /dev/null +++ b/packages/examples/hacker-news-intelligence/src/config.ts @@ -0,0 +1,39 @@ +import { DemoConfig, BrowserbaseConfig } from "./types"; +import * as dotenv from "dotenv"; + +// Load environment variables +dotenv.config(); + +export const demoConfig: DemoConfig = { + maxPosts: parseInt(process.env.MAX_POSTS || "5"), + enableContentExtraction: process.env.ENABLE_CONTENT_EXTRACTION === "true", + timeoutMs: parseInt(process.env.TIMEOUT_MS || "30000"), + logLevel: (process.env.LOG_LEVEL as any) || "info", + outputFormat: "both", +}; + +export const browserbaseConfig: BrowserbaseConfig = { + apiKey: process.env.BROWSERBASE_API_KEY || "", + projectId: process.env.BROWSERBASE_PROJECT_ID || "", + region: "us-east-1", + proxies: true, + keepAlive: false, + fingerprint: { + screen: { width: 1920, height: 1080 }, + timezone: "America/New_York", + }, +}; + +export function validateConfig(): void { + if (!browserbaseConfig.apiKey) { + throw new Error("BROWSERBASE_API_KEY is required. Please check your .env file."); + } + + if (!browserbaseConfig.projectId) { + throw new Error("BROWSERBASE_PROJECT_ID is required. Please check your .env file."); + } + + if (demoConfig.maxPosts < 1 || demoConfig.maxPosts > 30) { + throw new Error("MAX_POSTS must be between 1 and 30"); + } +} diff --git a/packages/examples/hacker-news-intelligence/src/extractor.ts b/packages/examples/hacker-news-intelligence/src/extractor.ts new file mode 100644 index 0000000000..cf2af76aa4 --- /dev/null +++ b/packages/examples/hacker-news-intelligence/src/extractor.ts @@ -0,0 +1,263 @@ +import { Stagehand } from "@browserbasehq/stagehand"; +import { HackerNewsPost, PostContent, PostAnalysis } from "./types"; +import { logger } from "./logger"; +import { demoConfig } from "./config"; + +export class HackerNewsExtractor { + private stagehand: Stagehand; + + constructor(stagehand: Stagehand) { + this.stagehand = stagehand; + } + + /** + * Extract top posts from Hacker News front page + */ + async extractTopPosts(): Promise { + logger.step(1, 4, "Navigating to Hacker News front page..."); + + await this.stagehand.page.goto("https://news.ycombinator.com"); + + logger.step(2, 4, `Extracting top ${demoConfig.maxPosts} posts...`); + + // Use Stagehand's AI-powered extraction + const postsData = await this.stagehand.page.extract( + `Extract the top ${demoConfig.maxPosts} posts from Hacker News front page. For each post, get: + - rank (position number) + - title + - URL (if it's an external link, get the actual URL; if it's a Hacker News discussion, note it) + - points (upvotes) + - author username + - comments count + - age text (how long ago it was posted) + - domain (if external) + Return as an array of objects with these exact field names.`, + ); + + logger.debug("Raw extracted data:", postsData); + + // Process and validate the extracted data + const posts = this.processExtractedPosts(postsData); + + logger.success(`Successfully extracted ${posts.length} posts`); + return posts; + } + + /** + * Extract content from individual post URLs + */ + async extractPostContent(post: HackerNewsPost): Promise { + if (!post.isExternal) { + logger.debug(`Skipping content extraction for Hacker News discussion: ${post.title}`); + return { + title: post.title, + url: post.url, + extractedText: "Hacker News discussion - no external content", + keyPoints: ["This is a Hacker News discussion post"], + wordCount: 0, + readingTimeMinutes: 0, + extractionSuccess: false, + error: "Internal Hacker News post", + }; + } + + try { + logger.debug(`Extracting content from: ${post.url}`); + + await this.stagehand.page.goto(post.url); + + // Use Stagehand's AI extraction for article content + const content = await this.stagehand.page.extract(` + Extract the main article content from this webpage. Return: + - title: The main article title + - extractedText: The full article text (clean, without ads or navigation) + - keyPoints: Array of 3-5 key points or main takeaways from the article + - wordCount: Approximate word count + Format as JSON with these exact field names. + `); + + const processedContent = this.processExtractedContent(content, post); + + logger.debug(`Content extracted for "${post.title}": ${processedContent.wordCount} words`); + return processedContent; + } catch (error) { + logger.error(`Failed to extract content from ${post.url}:`, error); + return { + title: post.title, + url: post.url, + extractedText: "", + keyPoints: [], + wordCount: 0, + readingTimeMinutes: 0, + extractionSuccess: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + /** + * Generate AI-powered analysis of extracted content + */ + async analyzePost(post: HackerNewsPost, content: PostContent): Promise { + try { + // Use Stagehand's AI capabilities for content analysis + const analysis = await this.stagehand.page.extract(` + Analyze this Hacker News post and its content: + + Post Title: ${post.title} + Domain: ${post.domain || "news.ycombinator.com"} + Points: ${post.points} + Comments: ${post.commentsCount} + Content: ${content.extractedText.substring(0, 1000)}... + + Provide analysis with: + - category: Tech category (e.g., "AI/ML", "Web Development", "Startup", "Hardware", "Security", "Other") + - sentiment: Overall sentiment (positive, neutral, negative) + - complexity: Technical complexity level (low, medium, high) + - businessRelevance: Business relevance score from 1-10 + + Return as JSON with these exact field names. + `); + + return { + post, + content, + analysis: this.processAnalysis(analysis), + }; + } catch (error) { + logger.warn(`Analysis failed for "${post.title}":`, error); + + // Fallback analysis + return { + post, + content, + analysis: { + category: "Other", + sentiment: "neutral", + complexity: "medium", + businessRelevance: 5, + }, + }; + } + } + + private processExtractedPosts(rawData: any): HackerNewsPost[] { + try { + logger.debug("Processing extracted data:", rawData); + + // Handle different possible response formats from Stagehand + let posts: any[] = []; + + if (Array.isArray(rawData)) { + posts = rawData; + } else if (rawData.extraction) { + // Stagehand returns data in extraction field + const extractionData = + typeof rawData.extraction === "string" + ? JSON.parse(rawData.extraction) + : rawData.extraction; + posts = Array.isArray(extractionData) ? extractionData : []; + } else if (rawData.posts) { + posts = rawData.posts; + } + + logger.debug(`Found ${posts.length} posts to process`); + + return posts.slice(0, demoConfig.maxPosts).map((item: any, index: number) => { + const url = item.URL || item.url || item.link || ""; + const isExternal = url && !url.includes("news.ycombinator.com") && url.startsWith("http"); + + // Extract numeric values from strings like "623 points" + const pointsStr = item.points || item["points"] || "0"; + const points = parseInt(pointsStr.toString().replace(/\D/g, "")) || 0; + + const commentsStr = item["comments count"] || item.comments || item.commentsCount || "0"; + const commentsCount = parseInt(commentsStr.toString().replace(/\D/g, "")) || 0; + + return { + rank: parseInt(item.rank?.toString().replace(/\D/g, "")) || index + 1, + title: item.title || "Unknown Title", + url: url, + points: points, + author: item["author username"] || item.author || item.user || "Unknown", + commentsCount: commentsCount, + ageText: item["age text"] || item.age || item.ageText || "Unknown", + domain: isExternal ? this.extractDomain(url) : undefined, + isExternal, + }; + }); + } catch (error) { + logger.error("Failed to process extracted posts:", error); + return []; + } + } + + private processExtractedContent(rawContent: any, post: HackerNewsPost): PostContent { + try { + const content = typeof rawContent === "string" ? JSON.parse(rawContent) : rawContent; + const extractedText = content.extractedText || content.text || ""; + const wordCount = this.countWords(extractedText); + + return { + title: content.title || post.title, + url: post.url, + extractedText, + keyPoints: Array.isArray(content.keyPoints) ? content.keyPoints : [], + wordCount, + readingTimeMinutes: Math.ceil(wordCount / 200), // Average reading speed + extractionSuccess: true, + }; + } catch (error) { + logger.error("Failed to process extracted content:", error); + return { + title: post.title, + url: post.url, + extractedText: "", + keyPoints: [], + wordCount: 0, + readingTimeMinutes: 0, + extractionSuccess: false, + error: "Content processing failed", + }; + } + } + + private processAnalysis(rawAnalysis: any): PostAnalysis["analysis"] { + try { + const analysis = typeof rawAnalysis === "string" ? JSON.parse(rawAnalysis) : rawAnalysis; + + return { + category: analysis.category || "Other", + sentiment: ["positive", "neutral", "negative"].includes(analysis.sentiment) + ? analysis.sentiment + : "neutral", + complexity: ["low", "medium", "high"].includes(analysis.complexity) + ? analysis.complexity + : "medium", + businessRelevance: Math.max(1, Math.min(10, parseInt(analysis.businessRelevance) || 5)), + }; + } catch (error) { + return { + category: "Other", + sentiment: "neutral", + complexity: "medium", + businessRelevance: 5, + }; + } + } + + private extractDomain(url: string): string { + try { + return new URL(url).hostname.replace("www.", ""); + } catch { + return "Unknown Domain"; + } + } + + private countWords(text: string): number { + return text + .trim() + .split(/\s+/) + .filter((word) => word.length > 0).length; + } +} diff --git a/packages/examples/hacker-news-intelligence/src/logger.ts b/packages/examples/hacker-news-intelligence/src/logger.ts new file mode 100644 index 0000000000..fced65a3a7 --- /dev/null +++ b/packages/examples/hacker-news-intelligence/src/logger.ts @@ -0,0 +1,83 @@ +import chalk from "chalk"; +import { demoConfig } from "./config"; + +export enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, +} + +const logLevelMap = { + debug: LogLevel.DEBUG, + info: LogLevel.INFO, + warn: LogLevel.WARN, + error: LogLevel.ERROR, +}; + +class Logger { + private currentLevel: LogLevel; + + constructor() { + this.currentLevel = logLevelMap[demoConfig.logLevel] || LogLevel.INFO; + } + + private shouldLog(level: LogLevel): boolean { + return level >= this.currentLevel; + } + + private formatMessage(level: string, message: string, data?: any): string { + const timestamp = new Date().toISOString(); + const baseMessage = `[${timestamp}] [${level}] ${message}`; + + if (data) { + return `${baseMessage}\n${JSON.stringify(data, null, 2)}`; + } + + return baseMessage; + } + + debug(message: string, data?: any): void { + if (this.shouldLog(LogLevel.DEBUG)) { + console.log(chalk.gray(this.formatMessage("DEBUG", message, data))); + } + } + + info(message: string, data?: any): void { + if (this.shouldLog(LogLevel.INFO)) { + console.log(chalk.blue(this.formatMessage("INFO", message, data))); + } + } + + success(message: string, data?: any): void { + if (this.shouldLog(LogLevel.INFO)) { + console.log(chalk.green(this.formatMessage("SUCCESS", message, data))); + } + } + + warn(message: string, data?: any): void { + if (this.shouldLog(LogLevel.WARN)) { + console.warn(chalk.yellow(this.formatMessage("WARN", message, data))); + } + } + + error(message: string, error?: any): void { + if (this.shouldLog(LogLevel.ERROR)) { + const errorData = + error instanceof Error + ? { + message: error.message, + stack: error.stack, + } + : error; + console.error(chalk.red(this.formatMessage("ERROR", message, errorData))); + } + } + + step(stepNumber: number, totalSteps: number, message: string): void { + const progress = chalk.cyan(`[${stepNumber}/${totalSteps}]`); + console.log(`${progress} ${message}`); + } +} + +export const logger = new Logger(); diff --git a/packages/examples/hacker-news-intelligence/src/main.ts b/packages/examples/hacker-news-intelligence/src/main.ts new file mode 100644 index 0000000000..c0b00391dd --- /dev/null +++ b/packages/examples/hacker-news-intelligence/src/main.ts @@ -0,0 +1,222 @@ +#!/usr/bin/env node + +import { Stagehand } from "@browserbasehq/stagehand"; +import { HackerNewsExtractor } from "./extractor"; +import { IntelligenceReporter } from "./reporter"; +import { validateConfig, browserbaseConfig, demoConfig } from "./config"; +import { logger } from "./logger"; +import { PostAnalysis } from "./types"; +import chalk from "chalk"; +import ora from "ora"; + +/** + * Hacker News Intelligence Demo - Browserbase Enterprise Automation + * + * This demo showcases Browserbase's capabilities for: + * - AI-powered web scraping with Stagehand + * - Intelligent content extraction and analysis + * - Enterprise-grade error handling and reporting + * - Scalable news aggregation workflows + */ +class HackerNewsIntelligenceDemo { + private stagehand: Stagehand; + private extractor: HackerNewsExtractor; + private reporter: IntelligenceReporter; + + constructor() { + this.stagehand = new Stagehand({ + env: "BROWSERBASE", + apiKey: browserbaseConfig.apiKey, + projectId: browserbaseConfig.projectId, + ...browserbaseConfig, + }); + + this.extractor = new HackerNewsExtractor(this.stagehand); + this.reporter = new IntelligenceReporter(); + } + + /** + * Run the complete intelligence gathering workflow + */ + async run(): Promise { + const startTime = Date.now(); + let analyses: PostAnalysis[] = []; + + try { + // Initialize Stagehand + logger.info("๐Ÿš€ Starting Hacker News Intelligence Demo"); + logger.info( + `Configuration: ${demoConfig.maxPosts} posts, content extraction: ${demoConfig.enableContentExtraction}`, + ); + + await this.stagehand.init(); + logger.success("Browserbase session initialized"); + + // Step 1: Extract top posts + const posts = await this.extractor.extractTopPosts(); + if (posts.length === 0) { + throw new Error("No posts extracted from Hacker News"); + } + + // Step 2: Process each post + logger.step(3, 4, `Processing ${posts.length} posts...`); + const spinner = ora("Analyzing posts...").start(); + + for (const [index, post] of posts.entries()) { + spinner.text = `Analyzing post ${index + 1}/${posts.length}: ${post.title}`; + + try { + // Extract content if enabled and post is external + let content; + if (demoConfig.enableContentExtraction) { + content = await this.extractor.extractPostContent(post); + } else { + content = { + title: post.title, + url: post.url, + extractedText: "Content extraction disabled", + keyPoints: [], + wordCount: 0, + readingTimeMinutes: 0, + extractionSuccess: false, + error: "Content extraction disabled in configuration", + }; + } + + // Generate AI analysis + const analysis = await this.extractor.analyzePost(post, content); + analyses.push(analysis); + + logger.debug(`Completed analysis for: ${post.title}`); + } catch (error) { + logger.error(`Failed to process post "${post.title}":`, error); + + // Add failed analysis to results + analyses.push({ + post, + content: { + title: post.title, + url: post.url, + extractedText: "", + keyPoints: [], + wordCount: 0, + readingTimeMinutes: 0, + extractionSuccess: false, + error: error instanceof Error ? error.message : "Processing failed", + }, + analysis: { + category: "Other", + sentiment: "neutral", + complexity: "medium", + businessRelevance: 1, + }, + }); + } + } + + spinner.succeed(`Completed analysis of ${analyses.length} posts`); + + // Step 3: Generate and display report + logger.step(4, 4, "Generating intelligence report..."); + const report = this.reporter.generateReport(analyses); + + // Display results + if (demoConfig.outputFormat === "console" || demoConfig.outputFormat === "both") { + this.reporter.displayConsoleReport(report); + } + + // Save JSON report + if (demoConfig.outputFormat === "json" || demoConfig.outputFormat === "both") { + await this.reporter.saveJsonReport(report); + } + + // Generate executive summary + const executiveSummary = this.reporter.generateExecutiveSummary(report); + logger.info("Executive Summary:", executiveSummary); + + // Performance metrics + const duration = (Date.now() - startTime) / 1000; + const successRate = (report.successfulExtractions / report.totalPostsAnalyzed) * 100; + + logger.success(`Demo completed successfully in ${duration.toFixed(1)}s`); + logger.info( + `Success rate: ${successRate.toFixed(1)}% (${report.successfulExtractions}/${report.totalPostsAnalyzed})`, + ); + } catch (error) { + logger.error("Demo execution failed:", error); + throw error; + } finally { + // Cleanup + try { + await this.stagehand.close(); + logger.debug("Browserbase session closed"); + } catch (error) { + logger.warn("Error closing Browserbase session:", error); + } + } + } + + /** + * Health check for demo dependencies + */ + async healthCheck(): Promise { + try { + logger.info("Running health check..."); + + validateConfig(); + logger.success("โœ… Configuration validated"); + + // Test Browserbase connection + await this.stagehand.init(); + await this.stagehand.page.goto("https://httpbin.org/status/200"); + await this.stagehand.close(); + logger.success("โœ… Browserbase connection verified"); + + logger.success("Health check passed - demo is ready to run"); + return true; + } catch (error) { + logger.error("Health check failed:", error); + return false; + } + } +} + +// CLI execution +async function main() { + const args = process.argv.slice(2); + const demo = new HackerNewsIntelligenceDemo(); + + try { + if (args.includes("--health-check")) { + const healthy = await demo.healthCheck(); + process.exit(healthy ? 0 : 1); + } else { + validateConfig(); + await demo.run(); + } + } catch (error) { + logger.error("Fatal error:", error); + console.log(chalk.red("\nโŒ Demo failed to complete")); + console.log(chalk.yellow("๐Ÿ’ก Try running with --health-check to verify setup")); + process.exit(1); + } +} + +// Handle graceful shutdown +process.on("SIGINT", () => { + logger.info("Received SIGINT, shutting down gracefully..."); + process.exit(0); +}); + +process.on("SIGTERM", () => { + logger.info("Received SIGTERM, shutting down gracefully..."); + process.exit(0); +}); + +// Export for programmatic use +export { HackerNewsIntelligenceDemo }; + +// Run if called directly +if (require.main === module) { + main(); +} diff --git a/packages/examples/hacker-news-intelligence/src/reporter.ts b/packages/examples/hacker-news-intelligence/src/reporter.ts new file mode 100644 index 0000000000..ffc25bb3a8 --- /dev/null +++ b/packages/examples/hacker-news-intelligence/src/reporter.ts @@ -0,0 +1,245 @@ +import { IntelligenceReport, PostAnalysis } from "./types"; +import { logger } from "./logger"; +import { demoConfig } from "./config"; +import chalk from "chalk"; +import * as fs from "fs"; +import * as path from "path"; + +export class IntelligenceReporter { + /** + * Generate comprehensive intelligence report + */ + generateReport(analyses: PostAnalysis[]): IntelligenceReport { + const successful = analyses.filter((a) => a.content.extractionSuccess); + const totalComments = analyses.reduce((sum, a) => sum + a.post.commentsCount, 0); + const totalPoints = analyses.reduce((sum, a) => sum + a.post.points, 0); + + // Extract domains and count occurrences + const domainCounts: { [key: string]: number } = {}; + analyses.forEach((a) => { + if (a.post.domain) { + domainCounts[a.post.domain] = (domainCounts[a.post.domain] || 0) + 1; + } + }); + + const topDomains = Object.entries(domainCounts) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([domain]) => domain); + + // Extract key topics from titles and content + const keyTopics = this.extractKeyTopics(analyses); + + return { + generatedAt: new Date().toISOString(), + totalPostsAnalyzed: analyses.length, + successfulExtractions: successful.length, + posts: analyses, + summary: { + topDomains, + averagePoints: totalPoints / analyses.length, + totalComments, + keyTopics, + }, + }; + } + + /** + * Display report in console with rich formatting + */ + displayConsoleReport(report: IntelligenceReport): void { + console.log("\n"); + console.log(chalk.bold.cyan("๐Ÿš€ HACKER NEWS INTELLIGENCE REPORT")); + console.log(chalk.gray("=".repeat(60))); + + // Summary + console.log(chalk.bold("\n๐Ÿ“Š SUMMARY")); + console.log(`Generated: ${chalk.yellow(new Date(report.generatedAt).toLocaleString())}`); + console.log(`Posts Analyzed: ${chalk.green(report.totalPostsAnalyzed)}`); + console.log( + `Successful Extractions: ${chalk.green(report.successfulExtractions)}/${report.totalPostsAnalyzed}`, + ); + console.log(`Average Points: ${chalk.blue(Math.round(report.summary.averagePoints))}`); + console.log(`Total Comments: ${chalk.blue(report.summary.totalComments)}`); + + // Top Domains + if (report.summary.topDomains.length > 0) { + console.log(chalk.bold("\n๐ŸŒ TOP DOMAINS")); + report.summary.topDomains.forEach((domain, index) => { + console.log(`${index + 1}. ${chalk.cyan(domain)}`); + }); + } + + // Key Topics + if (report.summary.keyTopics.length > 0) { + console.log(chalk.bold("\n๐Ÿท๏ธ KEY TOPICS")); + console.log(report.summary.keyTopics.map((topic) => chalk.magenta(`#${topic}`)).join(" ")); + } + + // Individual Posts + console.log(chalk.bold("\n๐Ÿ“ POST ANALYSIS")); + console.log(chalk.gray("-".repeat(60))); + + report.posts.forEach((analysis, index) => { + const { post, content, analysis: postAnalysis } = analysis; + + console.log(chalk.bold(`\n${index + 1}. ${post.title}`)); + console.log(` ${chalk.gray("URL:")} ${chalk.blue(post.url)}`); + console.log( + ` ${chalk.gray("Author:")} ${post.author} | ${chalk.gray("Points:")} ${post.points} | ${chalk.gray("Comments:")} ${post.commentsCount}`, + ); + console.log( + ` ${chalk.gray("Category:")} ${this.getCategoryEmoji(postAnalysis.category)} ${postAnalysis.category}`, + ); + console.log( + ` ${chalk.gray("Sentiment:")} ${this.getSentimentEmoji(postAnalysis.sentiment)} ${postAnalysis.sentiment}`, + ); + console.log( + ` ${chalk.gray("Complexity:")} ${this.getComplexityEmoji(postAnalysis.complexity)} ${postAnalysis.complexity}`, + ); + console.log( + ` ${chalk.gray("Business Relevance:")} ${"โญ".repeat(Math.round(postAnalysis.businessRelevance / 2))}`, + ); + + if (content.extractionSuccess && content.keyPoints.length > 0) { + console.log(` ${chalk.gray("Key Points:")}`); + content.keyPoints.slice(0, 3).forEach((point) => { + console.log(` โ€ข ${chalk.white(point)}`); + }); + console.log( + ` ${chalk.gray("Reading Time:")} ${content.readingTimeMinutes} min (${content.wordCount} words)`, + ); + } else if (content.error) { + console.log(` ${chalk.red("โš ๏ธ Extraction failed:")} ${content.error}`); + } + }); + + console.log(chalk.gray("\n" + "=".repeat(60))); + console.log(chalk.green("โœ… Report generation complete!\n")); + } + + /** + * Save report to JSON file + */ + async saveJsonReport(report: IntelligenceReport): Promise { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const filename = `hacker-news-report-${timestamp}.json`; + const filepath = path.join(process.cwd(), "reports", filename); + + // Ensure reports directory exists + const reportsDir = path.dirname(filepath); + if (!fs.existsSync(reportsDir)) { + fs.mkdirSync(reportsDir, { recursive: true }); + } + + // Save formatted JSON + const jsonContent = JSON.stringify(report, null, 2); + fs.writeFileSync(filepath, jsonContent, "utf8"); + + logger.success(`Report saved to: ${filepath}`); + return filepath; + } + + /** + * Generate executive summary for business stakeholders + */ + generateExecutiveSummary(report: IntelligenceReport): string { + const trending = report.posts.sort((a, b) => b.post.points - a.post.points).slice(0, 3); + + const highBusinessRelevance = report.posts.filter( + (p) => p.analysis.businessRelevance >= 8, + ).length; + + return ` +EXECUTIVE SUMMARY - Hacker News Intelligence Report +Generated: ${new Date(report.generatedAt).toLocaleDateString()} + +KEY INSIGHTS: +โ€ข Analyzed ${report.totalPostsAnalyzed} trending posts with ${report.summary.totalComments} total comments +โ€ข ${highBusinessRelevance} posts identified as high business relevance (8+ score) +โ€ข Average engagement: ${Math.round(report.summary.averagePoints)} points per post +โ€ข Top content domains: ${report.summary.topDomains.slice(0, 3).join(", ")} + +TRENDING TOPICS: +${trending.map((p, i) => `${i + 1}. ${p.post.title} (${p.post.points} points)`).join("\n")} + +RECOMMENDATIONS: +โ€ข Monitor posts with high business relevance scores for competitive intelligence +โ€ข Engage with trending topics in ${report.summary.keyTopics.slice(0, 2).join(" and ")} categories +โ€ข Consider content opportunities around emerging themes + `.trim(); + } + + private extractKeyTopics(analyses: PostAnalysis[]): string[] { + const topicCounts: { [key: string]: number } = {}; + + // Extract topics from categories + analyses.forEach((a) => { + const category = a.analysis.category.toLowerCase(); + if (category !== "other") { + topicCounts[category] = (topicCounts[category] || 0) + 1; + } + }); + + // Extract common keywords from titles + const commonWords = [ + "ai", + "ml", + "crypto", + "startup", + "open", + "source", + "web", + "security", + "data", + "tech", + ]; + analyses.forEach((a) => { + const title = a.post.title.toLowerCase(); + commonWords.forEach((word) => { + if (title.includes(word)) { + topicCounts[word] = (topicCounts[word] || 0) + 1; + } + }); + }); + + return Object.entries(topicCounts) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([topic]) => topic); + } + + private getCategoryEmoji(category: string): string { + const emojiMap: { [key: string]: string } = { + "AI/ML": "๐Ÿค–", + "Web Development": "๐ŸŒ", + Startup: "๐Ÿš€", + Hardware: "๐Ÿ’ป", + Security: "๐Ÿ”’", + Crypto: "โ‚ฟ", + Mobile: "๐Ÿ“ฑ", + Data: "๐Ÿ“Š", + DevOps: "โš™๏ธ", + Other: "๐Ÿ“„", + }; + return emojiMap[category] || "๐Ÿ“„"; + } + + private getSentimentEmoji(sentiment: string): string { + const emojiMap = { + positive: "๐Ÿ˜Š", + neutral: "๐Ÿ˜", + negative: "๐Ÿ˜Ÿ", + }; + return emojiMap[sentiment as keyof typeof emojiMap] || "๐Ÿ˜"; + } + + private getComplexityEmoji(complexity: string): string { + const emojiMap = { + low: "๐ŸŸข", + medium: "๐ŸŸก", + high: "๐Ÿ”ด", + }; + return emojiMap[complexity as keyof typeof emojiMap] || "๐ŸŸก"; + } +} diff --git a/packages/examples/hacker-news-intelligence/src/test.ts b/packages/examples/hacker-news-intelligence/src/test.ts new file mode 100644 index 0000000000..ddc6042b12 --- /dev/null +++ b/packages/examples/hacker-news-intelligence/src/test.ts @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +import { HackerNewsIntelligenceDemo } from "./main"; +import { logger } from "./logger"; +import chalk from "chalk"; + +/** + * Test suite for Hacker News Intelligence Demo + * Validates core functionality and Browserbase integration + */ +async function runTests() { + console.log(chalk.bold.blue("\n๐Ÿงช HACKER NEWS INTELLIGENCE DEMO - TEST SUITE")); + console.log(chalk.gray("=".repeat(60))); + + const demo = new HackerNewsIntelligenceDemo(); + let testsPassed = 0; + let totalTests = 0; + + const test = async (name: string, testFn: () => Promise) => { + totalTests++; + process.stdout.write(`${totalTests}. ${name}... `); + + try { + const result = await testFn(); + if (result) { + console.log(chalk.green("โœ… PASS")); + testsPassed++; + } else { + console.log(chalk.red("โŒ FAIL")); + } + } catch (error) { + console.log(chalk.red("โŒ ERROR")); + logger.debug(`Test "${name}" failed:`, error); + } + }; + + // Test 1: Configuration validation + await test("Configuration validation", async () => { + try { + const { validateConfig } = await import("./config"); + validateConfig(); + return true; + } catch (error) { + return false; + } + }); + + // Test 2: Browserbase connection + await test("Browserbase connection", async () => { + return await demo.healthCheck(); + }); + + // Test 3: Basic extraction (limited to 2 posts for testing) + await test("Basic post extraction", async () => { + try { + // Temporarily override config for testing + const originalMaxPosts = process.env.MAX_POSTS; + process.env.MAX_POSTS = "2"; + process.env.ENABLE_CONTENT_EXTRACTION = "false"; + + const testDemo = new HackerNewsIntelligenceDemo(); + await testDemo.run(); + + // Restore original config + if (originalMaxPosts) { + process.env.MAX_POSTS = originalMaxPosts; + } + + return true; + } catch (error) { + logger.debug("Basic extraction test failed:", error); + return false; + } + }); + + // Test 4: Error handling + await test("Error handling", async () => { + try { + // Test with invalid configuration + const originalApiKey = process.env.BROWSERBASE_API_KEY; + process.env.BROWSERBASE_API_KEY = "invalid-key"; + + const testDemo = new HackerNewsIntelligenceDemo(); + const healthy = await testDemo.healthCheck(); + + // Restore original config + if (originalApiKey) { + process.env.BROWSERBASE_API_KEY = originalApiKey; + } + + // Should return false for invalid config + return !healthy; + } catch (error) { + return true; // Error handling working correctly + } + }); + + // Test Results + console.log(chalk.gray("\n" + "-".repeat(60))); + console.log(`Tests completed: ${testsPassed}/${totalTests} passed`); + + if (testsPassed === totalTests) { + console.log(chalk.green("๐ŸŽ‰ All tests passed! Demo is ready for use.")); + process.exit(0); + } else { + console.log(chalk.red("โš ๏ธ Some tests failed. Please check configuration and dependencies.")); + process.exit(1); + } +} + +// Run tests if called directly +if (require.main === module) { + runTests(); +} diff --git a/packages/examples/hacker-news-intelligence/src/types.ts b/packages/examples/hacker-news-intelligence/src/types.ts new file mode 100644 index 0000000000..4eb89c07b9 --- /dev/null +++ b/packages/examples/hacker-news-intelligence/src/types.ts @@ -0,0 +1,70 @@ +/** + * Type definitions for Hacker News Intelligence Demo + */ + +export interface HackerNewsPost { + rank: number; + title: string; + url: string; + points: number; + author: string; + commentsCount: number; + ageText: string; + domain?: string; + isExternal: boolean; +} + +export interface PostContent { + title: string; + url: string; + extractedText: string; + keyPoints: string[]; + wordCount: number; + readingTimeMinutes: number; + extractionSuccess: boolean; + error?: string; +} + +export interface IntelligenceReport { + generatedAt: string; + totalPostsAnalyzed: number; + successfulExtractions: number; + posts: PostAnalysis[]; + summary: { + topDomains: string[]; + averagePoints: number; + totalComments: number; + keyTopics: string[]; + }; +} + +export interface PostAnalysis { + post: HackerNewsPost; + content: PostContent; + analysis: { + category: string; + sentiment: "positive" | "neutral" | "negative"; + complexity: "low" | "medium" | "high"; + businessRelevance: number; // 1-10 score + }; +} + +export interface DemoConfig { + maxPosts: number; + enableContentExtraction: boolean; + timeoutMs: number; + logLevel: "debug" | "info" | "warn" | "error"; + outputFormat: "json" | "console" | "both"; +} + +export interface BrowserbaseConfig { + apiKey: string; + projectId: string; + region?: string; + proxies?: boolean; + keepAlive?: boolean; + fingerprint?: { + screen?: { width: number; height: number }; + timezone?: string; + }; +} diff --git a/packages/examples/hacker-news-intelligence/tsconfig.json b/packages/examples/hacker-news-intelligence/tsconfig.json new file mode 100644 index 0000000000..1951778498 --- /dev/null +++ b/packages/examples/hacker-news-intelligence/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/examples/hacker-news/.env.example b/packages/examples/hacker-news/.env.example new file mode 100644 index 0000000000..594a0d8bc4 --- /dev/null +++ b/packages/examples/hacker-news/.env.example @@ -0,0 +1,4 @@ +BROWSERBASE_API_KEY= +BROWSERBASE_PROJECT_ID= +OPENAI_API_KEY= +GOOGLE_API_KEY= diff --git a/packages/examples/hacker-news/README.md b/packages/examples/hacker-news/README.md new file mode 100644 index 0000000000..d8b23342c7 --- /dev/null +++ b/packages/examples/hacker-news/README.md @@ -0,0 +1,7 @@ +# Hacker News + +Location in the Stagehand repository: `packages/examples/hacker-news`. + +Extracts public Hacker News headlines with Stagehand v2. + +This is a legacy **Stagehand v2** example. Install dependencies in this directory with `npm install`, supply the environment variables listed in `.env.example`, and run `npm start`. Live site layout and model availability may have changed since the original example. diff --git a/packages/examples/hacker-news/package.json b/packages/examples/hacker-news/package.json new file mode 100644 index 0000000000..362afb592a --- /dev/null +++ b/packages/examples/hacker-news/package.json @@ -0,0 +1,20 @@ +{ + "name": "stagehand-example-hacker-news", + "private": true, + "type": "module", + "scripts": { + "start": "tsx pullNews.ts" + }, + "dependencies": { + "@browserbasehq/stagehand": "^2.2.1", + "boxen": "^8.0.1", + "chalk": "^5.3.0", + "dotenv": "^16.4.7", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/node": "^22.9.1", + "tsx": "^4.19.2", + "typescript": "^5.0.0" + } +} diff --git a/packages/examples/hacker-news/pullNews.ts b/packages/examples/hacker-news/pullNews.ts new file mode 100644 index 0000000000..eed4c8f5f3 --- /dev/null +++ b/packages/examples/hacker-news/pullNews.ts @@ -0,0 +1,59 @@ +import { Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod"; +import chalk from "chalk"; +import boxen from "boxen"; +import dotenv from "dotenv"; + +dotenv.config(); + +async function main() { + const stagehand = new Stagehand({ + env: "BROWSERBASE", + }); + + await stagehand.init(); + const page = stagehand.page; + + if (stagehand.env === "BROWSERBASE" && stagehand.browserbaseSessionID) { + console.log("Session completed. Waiting for 10 seconds to see the logs and recording..."); + + // Log your session recording in the terminal so you can see it + console.log( + boxen( + `View this session recording in your browser: \n${chalk.blue( + `https://browserbase.com/sessions/${stagehand.browserbaseSessionID}`, + )}`, + { + title: "Browserbase", + padding: 1, + margin: 3, + }, + ), + ); + } + + // Navigate to Hacker News + await page.goto("https://news.ycombinator.com"); + + console.log("Pulling news from Hacker News..."); + + // Extract the top 3 stories from the Hacker News homepage + const headlines = await page.extract({ + instruction: "Extract the top 3 stories from the Hacker News homepage.", + schema: z.object({ + stories: z.array( + z.object({ + title: z.string(), + url: z.string(), + }), + ), + }), + }); + + console.log(headlines); + + // Close the browser + await stagehand.close(); +} + +main(); diff --git a/packages/examples/image-url-download/README.md b/packages/examples/image-url-download/README.md new file mode 100644 index 0000000000..dafcec3713 --- /dev/null +++ b/packages/examples/image-url-download/README.md @@ -0,0 +1,12 @@ +# image-url-download + +Find and download public website images; no captured output is included. + +Choose a language and follow its setup instructions: + +| Language | Stagehand dependency | Directory from the repository root | +| ---------------------------------- | -------------------- | ------------------------------------------------- | +| [TypeScript](typescript/README.md) | `4.0.0` | `packages/examples/image-url-download/typescript` | +| [Python](python/README.md) | `4.0.0` | `packages/examples/image-url-download/python` | + +Each variant installs the published Stagehand package and keeps its own dependencies. diff --git a/packages/examples/image-url-download/python/.env.example b/packages/examples/image-url-download/python/.env.example new file mode 100644 index 0000000000..d6c14bf7c6 --- /dev/null +++ b/packages/examples/image-url-download/python/.env.example @@ -0,0 +1,5 @@ +# Browserbase Configuration +BROWSERBASE_API_KEY= + +# Optional: maximum number of images to download per run (default: 10) +# MAX_IMAGES=10 diff --git a/packages/examples/image-url-download/python/.gitignore b/packages/examples/image-url-download/python/.gitignore new file mode 100644 index 0000000000..230488274a --- /dev/null +++ b/packages/examples/image-url-download/python/.gitignore @@ -0,0 +1,2 @@ +.env +images/ diff --git a/packages/examples/image-url-download/python/README.md b/packages/examples/image-url-download/python/README.md new file mode 100644 index 0000000000..2c7b5b9ce9 --- /dev/null +++ b/packages/examples/image-url-download/python/README.md @@ -0,0 +1,78 @@ +# Stagehand + Browserbase: Image URL Download + +Location in the Stagehand repository: `packages/examples/image-url-download/python`. + +Stagehand is the SDK for browser agents. + +## AT A GLANCE + +- Goal: extract all image URLs from a page with Stagehand and download each image with the Browserbase Fetch API. +- SDK-backed downloads: `AsyncBrowserbase.fetch_api.create()` retrieves each discovered image through Browserbase without handwritten HTTP requests. +- AI-powered URL extraction: uses `extract()` with a JSON schema to reliably pull `` src attributes and background image URLs from any page. +- Format-agnostic: uses the `Content-Type` response header to detect the real MIME type โ€” files are saved with the correct extension (`.jpg`, `.png`, `.svg`, `.webp`, etc.). +- Organized output: images are saved to `./images//` so runs against different sites never mix. +- V4 page access: the Python SDK exposes the active Stagehand page directly for navigation; URL discovery remains an `extract()` operation. + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract + +## GLOSSARY + +- extract: pull structured data from a page using a natural language instruction and a JSON schema. + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- Browserbase Fetch API: retrieve a discovered asset through the first-party Browserbase SDK. + Docs โ†’ https://docs.browserbase.com/features/fetch-api +- page.evaluate: a narrow fallback that reads exact DOM asset URLs only when semantic extraction + returns no fetchable candidates. + Docs โ†’ https://docs.stagehand.dev/v4/reference/page +- ImageUrls: Pydantic schema passed to `extract()` for typed URL discovery. +- MAX_IMAGES: configurable cap (default: 10) on how many images to download per run. Set via the `MAX_IMAGES` env var or the constant at the top of `main.py`. + +## QUICKSTART + +1. cd packages/examples/image-url-download/python +2. cp .env.example .env +3. Add BROWSERBASE_API_KEY to .env +4. uv run main.py \ โ€” e.g. `uv run main.py https://www.browserbase.com` + +## EXPECTED OUTPUT + +- Initializes Stagehand session with Browserbase +- Connects Playwright to the same session via CDP +- Navigates to the target URL and waits for the page to fully render +- Extracts all image URLs from the page using `extract()` +- Deduplicates URLs and caps at `MAX_IMAGES` (default: 10) +- Downloads each image with the Browserbase Fetch API through the first-party Python SDK +- Saves images to `./images//`, named `-.` with the extension derived from the `Content-Type` header +- Logs per-image status (saved / failed) and a final summary count +- Closes session cleanly + +## COMMON PITFALLS + +- `ModuleNotFoundError`: ensure all dependencies are installed โ€” `uv run` handles this automatically via `pyproject.toml` +- Missing credentials: verify .env contains BROWSERBASE_API_KEY +- Zero images found: the page may load images lazily or use CSS background images โ€” try scrolling before extraction with `stagehand.act()`, or refine the extract instruction +- Download failures (403): some auth-gated image URLs require session cookies that the Fetch API request does not inherit +- MAX_IMAGES cap: if you need more than 10 images, set `MAX_IMAGES=50` in your .env or edit the constant at the top of `main.py` +- Large pages: pages with hundreds of images may slow down `extract()` โ€” use MAX_IMAGES to limit the download set + +## USE CASES + +โ€ข Asset archiving: bulk-save product images, thumbnails, or media assets from websites you own or have permission to scrape. +โ€ข Visual regression testing: download reference images from a staging environment to diff against production. +โ€ข Dataset collection: gather labeled image sets from public pages for ML training pipelines. +โ€ข Public media: archive images discovered on pages without maintaining separate download HTTP code. + +## NEXT STEPS + +โ€ข Scroll before extracting: use `stagehand.act()` to scroll the page before `extract()` to trigger lazy-loaded images. +โ€ข Concurrent downloads: fan out the Fetch API calls with `asyncio.gather()` for faster bulk downloads. +โ€ข Metadata CSV: write a `manifest.csv` alongside the images recording original URL, filename, MIME type, byte size, and download timestamp. +โ€ข Extend MIME support: add entries to the `MIME_TO_EXT` dict at the top of `main.py` for any formats not already covered. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/image-url-download/python/main.py b/packages/examples/image-url-download/python/main.py new file mode 100644 index 0000000000..fa2298915c --- /dev/null +++ b/packages/examples/image-url-download/python/main.py @@ -0,0 +1,185 @@ +"""Download images discovered in a live page with Stagehand V4.""" + +import asyncio +import base64 +import os +import re +import sys +import time +from pathlib import Path +from urllib.parse import urljoin, urlparse + +from browserbase import AsyncBrowserbase +from dotenv import load_dotenv +from pydantic import BaseModel, Field +from stagehand import Page, Stagehand, browserbase + +load_dotenv() + +MAX_IMAGES = int(os.environ.get("MAX_IMAGES", "10")) +OUTPUT_DIR = Path("images") +MIME_TO_EXT = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/gif": "gif", + "image/svg+xml": "svg", + "image/avif": "avif", + "image/bmp": "bmp", + "image/tiff": "tiff", +} + + +class ImageUrls(BaseModel): + urls: list[str] = Field( + description="Absolute HTTP(S) image resource URLs from src or background-image values" + ) + + +def image_filename(url: str, mime_type: str, index: int) -> str: + extension = MIME_TO_EXT.get(mime_type, "bin") + segment = Path(urlparse(url).path).name + stem = Path(segment).stem or f"image-{index}" + safe_stem = re.sub(r"[^a-zA-Z0-9_-]", "_", stem)[:80] + return f"{safe_stem}-{int(time.time() * 1000)}.{extension}" + + +async def fetch_image(api: AsyncBrowserbase, url: str) -> tuple[bytes, str] | None: + result = await api.fetch_api.create(url=url, format="raw", allow_redirects=True) + mime_type = (result.content_type or "").split(";", 1)[0] + if not 200 <= result.status_code < 300 or not mime_type.startswith("image/"): + return None + if not isinstance(result.content, str): + return None + if result.encoding == "base64": + payload = base64.b64decode(result.content) + else: + payload = result.content.encode(result.encoding or "utf-8") + return payload, mime_type + + +async def exact_dom_image_urls(page: Page, target_url: str) -> list[str]: + values = await page.evaluate( + r"""(() => { + const urls = new Set(); + for (const image of Array.from(document.images)) { + if (image.currentSrc) urls.add(image.currentSrc); + if (image.src) urls.add(image.src); + } + for (const element of Array.from(document.querySelectorAll('[style]'))) { + const background = getComputedStyle(element).backgroundImage; + for (const match of background.matchAll(/url\(["']?(.*?)["']?\)/g)) { + if (match[1]) urls.add(new URL(match[1], document.baseURI).href); + } + } + return [...urls]; + })()""" + ) + if not isinstance(values, list): + return [] + urls = [] + for value in values: + absolute = urljoin(target_url, str(value)) + if urlparse(absolute).scheme in {"http", "https"} and absolute not in urls: + urls.append(absolute) + return urls + + +async def download_images( + api: AsyncBrowserbase, urls: list[str], output_dir: Path +) -> tuple[int, int]: + saved = 0 + failed = 0 + for index, url in enumerate(urls): + try: + fetched = await fetch_image(api, url) + except Exception: + fetched = None + if fetched is None: + failed += 1 + continue + + payload, mime_type = fetched + filename = image_filename(url, mime_type, index) + (output_dir / filename).write_bytes(payload) + print(f"Saved {filename} ({len(payload)} bytes)") + saved += 1 + return saved, failed + + +async def main() -> None: + if len(sys.argv) < 2: + raise RuntimeError("Usage: uv run python main.py ") + target_url = sys.argv[1] + api_key = os.environ.get("BROWSERBASE_API_KEY") + if not api_key: + raise RuntimeError("BROWSERBASE_API_KEY is required") + + browser = await browserbase.launch(api_key=api_key) + try: + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + await page.goto(target_url, wait_until="domcontentloaded", timeout=60_000) + await page.wait_for_timeout(3_000) + + extracted = await stagehand.extract( + ( + "Extract all rendered image URLs on this page, including image src " + "attributes and background-image URLs. Return absolute HTTP(S) image " + "resource URLs exactly as rendered. Preserve every hostname character, " + "including any www prefix." + ), + ImageUrls, + page=page, + ) + normalized = [] + for value in extracted.data.urls: + candidate = str(value) + if not candidate.lower().startswith(("http://", "https://")): + continue + absolute = candidate + if urlparse(absolute).scheme in {"http", "https"} and absolute not in normalized: + normalized.append(absolute) + if not normalized: + # Accessibility snapshots can omit decorative images. Inspect the exact DOM + # shape only when semantic extraction returns no usable candidates at all. + normalized = await exact_dom_image_urls(page, target_url) + urls = normalized[:MAX_IMAGES] + hostname = urlparse(target_url).hostname or "unknown" + output_dir = OUTPUT_DIR / hostname + output_dir.mkdir(parents=True, exist_ok=True) + async with AsyncBrowserbase(api_key=api_key) as api: + saved, failed = await download_images(api, urls, output_dir) + if saved == 0 and normalized: + # If semantic extraction produced URLs that cannot be fetched, fall back to + # exact DOM mechanics without changing the semantic-first discovery path. + fallback_urls = [ + url + for url in await exact_dom_image_urls(page, target_url) + if url not in normalized + ][:MAX_IMAGES] + fallback_saved, fallback_failed = await download_images( + api, fallback_urls, output_dir + ) + saved += fallback_saved + failed += fallback_failed + + print(f"Downloaded {saved} image(s); {failed} failed") + finally: + await stagehand.close() + finally: + await browser.close() + print("Session closed successfully") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as error: + print(f"Image download failed: {error}") + print("Docs: https://docs.stagehand.dev/v4/first-steps/introduction") + raise diff --git a/packages/examples/image-url-download/python/pyproject.toml b/packages/examples/image-url-download/python/pyproject.toml new file mode 100644 index 0000000000..2d4b41ee33 --- /dev/null +++ b/packages/examples/image-url-download/python/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "image-url-download" +version = "1.0.0" +description = "Stagehand + Browserbase: extract image URLs from a page and download them with the Browserbase Fetch API" +readme = "README.md" +requires-python = ">=3.11,<3.14" +dependencies = ["browserbase>=1.17.0", "playwright", "python-dotenv", "stagehand==4.0.0"] + +[project.optional-dependencies] +dev = ["pytest>=7.0.0", "black>=23.0.0", "ruff>=0.1.0"] + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 100 +target-version = ['py39', 'py310', 'py311'] + +[tool.ruff] +line-length = 100 +target-version = "py39" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] diff --git a/packages/examples/image-url-download/typescript/.env.example b/packages/examples/image-url-download/typescript/.env.example new file mode 100644 index 0000000000..d6c14bf7c6 --- /dev/null +++ b/packages/examples/image-url-download/typescript/.env.example @@ -0,0 +1,5 @@ +# Browserbase Configuration +BROWSERBASE_API_KEY= + +# Optional: maximum number of images to download per run (default: 10) +# MAX_IMAGES=10 diff --git a/packages/examples/image-url-download/typescript/.gitignore b/packages/examples/image-url-download/typescript/.gitignore new file mode 100644 index 0000000000..935ebdb558 --- /dev/null +++ b/packages/examples/image-url-download/typescript/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +package-lock.json +.env +images/ diff --git a/packages/examples/image-url-download/typescript/README.md b/packages/examples/image-url-download/typescript/README.md new file mode 100644 index 0000000000..48dde6df42 --- /dev/null +++ b/packages/examples/image-url-download/typescript/README.md @@ -0,0 +1,74 @@ +# Stagehand + Browserbase: Image URL Download + +Location in the Stagehand repository: `packages/examples/image-url-download/typescript`. + +## AT A GLANCE + +- Goal: extract all image URLs from a page with Stagehand and download each image with the Browserbase Fetch API. +- SDK-backed downloads: `Browserbase.fetchAPI.create()` retrieves each discovered image through Browserbase without handwritten HTTP requests. +- Semantic URL discovery: uses `extract()` with a Zod schema to find rendered image and background-image URLs. +- Narrow DOM fallback: reads exact image URLs only when semantic extraction yields no fetchable candidates. +- Format-agnostic: uses the Fetch API response MIME type and base64 payload to save files with the correct extension (`.jpg`, `.png`, `.svg`, `.webp`, etc.). +- Organized output: images are saved to `./images//` so runs against different sites never mix. + Docs โ†’ https://docs.stagehand.dev/v4/reference/page + +## GLOSSARY + +- Browserbase Fetch API: retrieve a discovered asset through the first-party Browserbase SDK. + Docs โ†’ https://docs.browserbase.com/features/fetch-api +- page.evaluate: a narrow fallback that reads exact DOM asset URLs only when semantic extraction + returns no fetchable candidates. + Docs โ†’ https://docs.stagehand.dev/v4/reference/page +- MAX_IMAGES: configurable cap (default: 10) on how many images to download per run. Set via the `MAX_IMAGES` env var or the constant at the top of `index.ts`. + +## QUICKSTART + +1. cd packages/examples/image-url-download/typescript +2. npm install +3. cp .env.example .env +4. Add your Browserbase API key to .env +5. npm start \ โ€” e.g. `npm start https://www.browserbase.com` + +## EXPECTED OUTPUT + +- Initializes Stagehand session with Browserbase +- Navigates to the target URL +- Reads rendered image and inline background-image URLs from the page +- Deduplicates URLs and caps at `MAX_IMAGES` (default: 10) +- Downloads each image with the Browserbase Fetch API through the first-party TypeScript SDK +- Saves images to `./images//`, named `-.` with the extension derived from the real MIME type +- Logs per-image status (saved / failed) and a final summary count +- Closes session cleanly + +## COMMON PITFALLS + +- "Cannot find module": ensure all dependencies are installed with `npm install` +- Missing credentials: verify .env contains BROWSERBASE_API_KEY +- Empty images folder: some pages load images lazily โ€” try scrolling the page before extraction, or increase the page load wait +- Zero images found: the page may lazy-load media or use stylesheet-only backgrounds; scroll or add target-specific selectors +- Download failures (403): some auth-gated image URLs require session cookies that the Fetch API request does not inherit +- MAX_IMAGES cap: if you need more than 10 images, set `MAX_IMAGES=50` in your .env or edit the constant at the top of `index.ts` +- Large pages: use `MAX_IMAGES` to cap the download set + +## USE CASES + +โ€ข Asset archiving: bulk-save product images, thumbnails, or media assets from websites you own or have permission to scrape. +โ€ข Visual regression testing: download reference images from a staging environment to diff against production. +โ€ข Dataset collection: gather labeled image sets from public pages for ML training pipelines. +โ€ข Public media: archive images discovered on pages without maintaining separate download HTTP code. + +## NEXT STEPS + +โ€ข Scroll before discovery: call `stagehand.act("Scroll to the bottom of the page")` to trigger lazy-loaded images. +โ€ข Concurrent downloads: fan out the Fetch API calls with `Promise.allSettled` for faster bulk downloads. +โ€ข Metadata CSV: write a `manifest.csv` alongside the images recording original URL, filename, MIME type, byte size, and download timestamp. +โ€ข Extend MIME support: add entries to the `MIME_TO_EXT` map at the top of `index.ts` for any formats not already covered. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/image-url-download/typescript/index.ts b/packages/examples/image-url-download/typescript/index.ts new file mode 100644 index 0000000000..46e363a426 --- /dev/null +++ b/packages/examples/image-url-download/typescript/index.ts @@ -0,0 +1,245 @@ +// Stagehand + Browserbase: Image URL Download - See README.md for full documentation +// +// Uses Stagehand extract() to find all image URLs on a page, then downloads each +// image through the Browserbase Fetch API. Works for any image format (JPG, PNG, +// WebP, etc.). + +import "dotenv/config"; +import { Browserbase } from "@browserbasehq/sdk"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod/v4"; +import fs from "fs"; +import path from "path"; + +// ============= CONFIGURATION ============= + +// Maximum number of images to download per run. +// Increase this if you need more images, or set MAX_IMAGES in your .env. +const MAX_IMAGES = parseInt(process.env.MAX_IMAGES ?? "10", 10) || 10; + +// Directory (relative to where the script is run) where images are saved. +const OUTPUT_DIR = "./images"; + +// ========================================= + +// Maps MIME types to file extensions for the most common image formats. +const MIME_TO_EXT: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/gif": "gif", + "image/svg+xml": "svg", + "image/avif": "avif", + "image/bmp": "bmp", + "image/tiff": "tiff", +}; + +/** + * Derive a safe filename from an image URL and its detected MIME type. + * Takes the last path segment for the base name, uses the MIME type for the + * extension (more reliable than trusting the URL), and appends a timestamp + * so repeated runs never overwrite earlier downloads. + */ +function imageFilename(url: string, mimeType: string, index: number): string { + const ext = MIME_TO_EXT[mimeType] ?? "bin"; + try { + const segment = new URL(url).pathname.split("/").filter(Boolean).pop() ?? ""; + // Strip any existing extension โ€” we'll use the one from the actual MIME type. + const base = segment.replace(/\.[^.]+$/, "") || `image-${index}`; + // Sanitize to filesystem-safe characters. + const safe = base.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 80); + return `${safe}-${Date.now()}.${ext}`; + } catch { + return `image-${index}-${Date.now()}.${ext}`; + } +} + +async function main(): Promise { + const targetUrl = process.argv[2]; + if (!targetUrl) { + console.error("Usage: npm start "); + console.error("Example: npm start https://www.browserbase.com"); + process.exit(1); + } + + console.log(`Image URL Download โ€” target: ${targetUrl}`); + console.log(`Max images: ${MAX_IMAGES} | Output: ${OUTPUT_DIR}//\n`); + + if (!process.env.BROWSERBASE_API_KEY) { + throw new Error("BROWSERBASE_API_KEY is required"); + } + const bb = new Browserbase({ apiKey: process.env.BROWSERBASE_API_KEY }); + + // Initialize Stagehand with Browserbase for cloud-based browser automation. + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + }); + const stagehand = await Stagehand.create({ + browser: browser, + model: { modelName: "google/gemini-2.5-flash" }, + logging: { level: "info" }, + }); + + try { + // Initialize browser session to start automation. + + console.log("Stagehand initialized successfully!"); + const page = (await browser.context.pages())[0]; + const exactDomImageUrls = async (): Promise => + (await page.evaluate(() => { + const urls = new Set(); + for (const image of Array.from(document.images)) { + if (image.currentSrc) urls.add(image.currentSrc); + if (image.src) urls.add(image.src); + } + for (const element of Array.from(document.querySelectorAll("[style]"))) { + const background = getComputedStyle(element).backgroundImage; + for (const match of background.matchAll(/url\(["']?(.*?)["']?\)/g)) { + if (match[1]) urls.add(new URL(match[1], document.baseURI).href); + } + } + return [...urls]; + })) as string[]; + + // Many modern sites keep analytics and streaming requests open indefinitely, so + // wait for DOM readiness and then allow client-rendered images a short settle period. + console.log(`\nNavigating to ${targetUrl}...`); + await page.goto(targetUrl, { + waitUntil: "domcontentloaded", + timeout: 60000, // Extended timeout for reliable page loading. + }); + await page.waitForTimeout(3000); + + console.log("Extracting image URLs from page..."); + const { data: extractedUrls } = await stagehand.extract( + "Extract the absolute HTTP(S) source URLs of all rendered images on this page, including image src attributes and background-image URLs. Return each URL exactly as rendered and preserve every hostname character, including any www prefix.", + z.object({ + urls: z + .array(z.string()) + .describe("Absolute HTTP(S) image resource URLs from src or background-image values"), + }), + ); + let allUrls = extractedUrls.urls.filter((url) => /^https?:\/\//i.test(url)); + if (allUrls.length === 0) { + // Accessibility snapshots can omit decorative images. Use the exact DOM + // shape only when semantic extraction returns no candidates at all. + allUrls = await exactDomImageUrls(); + } + + // Extract only absolute image resource URLs, then deduplicate before applying the limit. + const normalizedUrls = allUrls.flatMap((url) => { + try { + return [new URL(url).href]; + } catch { + return []; + } + }); + const uniqueUrls = [...new Set(normalizedUrls)].filter((url) => { + const { protocol } = new URL(url); + return protocol === "https:" || protocol === "http:"; + }); + console.log(`Found ${uniqueUrls.length} unique image URL(s)`); + + const urls = uniqueUrls.slice(0, MAX_IMAGES); + if (uniqueUrls.length > MAX_IMAGES) { + console.log(`Capping at ${MAX_IMAGES} (adjust MAX_IMAGES to change this)`); + } + + // Create a subdirectory scoped to the target site's hostname (e.g. images/browserbase.com/). + const hostname = new URL(targetUrl).hostname; + const outputDir = path.join(OUTPUT_DIR, hostname); + fs.mkdirSync(outputDir, { recursive: true }); + + let saved = 0; + let failed = 0; + + const downloadImages = async (candidateUrls: string[]): Promise => { + for (let i = 0; i < candidateUrls.length; i++) { + const url = candidateUrls[i]; + process.stdout.write(`[${i + 1}/${candidateUrls.length}] ${url} โ†’ `); + + let buffer: Buffer | null = null; + let mimeType = ""; + try { + const response = await bb.fetchAPI.create({ + url, + format: "raw", + allowRedirects: true, + }); + mimeType = response.contentType.split(";", 1)[0]; + if ( + response.statusCode >= 200 && + response.statusCode < 300 && + mimeType.startsWith("image/") && + typeof response.content === "string" + ) { + buffer = Buffer.from( + response.content, + response.encoding === "base64" ? "base64" : "utf8", + ); + } + } catch { + // The common failure path below records this URL as skipped. + } + + if (!buffer) { + console.log("FAILED (skipping)"); + failed++; + continue; + } + + const filename = imageFilename(url, mimeType, i); + const filepath = path.join(outputDir, filename); + try { + fs.writeFileSync(filepath, buffer); + } catch (err) { + console.log( + `FAILED (write error: ${err instanceof Error ? err.message : err}, skipping)`, + ); + failed++; + continue; + } + console.log(`saved as ${filename} (${buffer.length} bytes)`); + saved++; + } + }; + + console.log(`\nDownloading ${urls.length} image(s) via the Browserbase Fetch API...\n`); + await downloadImages(urls); + if (saved === 0 && uniqueUrls.length > 0) { + // If semantic extraction produced URLs that cannot be fetched, fall back to + // exact DOM mechanics without changing the semantic-first discovery path. + const fallbackUrls = (await exactDomImageUrls()) + .filter((url) => !uniqueUrls.includes(url)) + .slice(0, MAX_IMAGES); + await downloadImages(fallbackUrls); + } + + console.log(`\nDone! ${saved} saved, ${failed} failed โ†’ ${outputDir}/`); + } catch (error) { + console.error("Error during image download:", error); + throw error; + } finally { + // Always close session to release resources and clean up. + try { + await stagehand.close(); + } catch (error) { + console.warn("Stagehand cleanup warning:", error); + } + try { + await browser.close(); + } catch (error) { + console.warn("Browser cleanup warning:", error); + } + console.log("Session closed successfully"); + } +} + +main().catch((err) => { + console.error("Error:", err); + console.error("Common issues:"); + console.error(" - Check .env file has BROWSERBASE_API_KEY"); + console.error(" - Verify the target URL is accessible"); + console.error("Docs: https://docs.stagehand.dev/v4/first-steps/introduction"); + process.exit(1); +}); diff --git a/packages/examples/image-url-download/typescript/package.json b/packages/examples/image-url-download/typescript/package.json new file mode 100644 index 0000000000..d7abd9e107 --- /dev/null +++ b/packages/examples/image-url-download/typescript/package.json @@ -0,0 +1,26 @@ +{ + "name": "image-url-download", + "version": "1.0.0", + "description": "Stagehand + Browserbase: extract image URLs from a page and download them with the Browserbase Fetch API", + "type": "module", + "main": "index.ts", + "scripts": { + "start": "tsx index.ts", + "dev": "tsx watch index.ts" + }, + "dependencies": { + "@browserbasehq/sdk": "^2.18.0", + "@browserbasehq/stagehand": "4.0.0", + "dotenv": "^16.4.5", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "tsx": "^4.16.0", + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=22.18.0" + }, + "packageManager": "pnpm@10.24.0" +} diff --git a/packages/examples/job-application/README.md b/packages/examples/job-application/README.md new file mode 100644 index 0000000000..7335cadc01 --- /dev/null +++ b/packages/examples/job-application/README.md @@ -0,0 +1,12 @@ +# job-application + +Dedicated agent job-board demo with generated example.com addresses and an agent resume, not a customer ATS. + +Choose a language and follow its setup instructions: + +| Language | Stagehand dependency | Directory from the repository root | +| ---------------------------------- | -------------------- | ---------------------------------------------- | +| [TypeScript](typescript/README.md) | `4.0.0` | `packages/examples/job-application/typescript` | +| [Python](python/README.md) | `4.0.0` | `packages/examples/job-application/python` | + +Each variant installs the published Stagehand package and keeps its own dependencies. diff --git a/packages/examples/job-application/python/.env.example b/packages/examples/job-application/python/.env.example new file mode 100644 index 0000000000..ec2065ba7a --- /dev/null +++ b/packages/examples/job-application/python/.env.example @@ -0,0 +1,4 @@ +BROWSERBASE_API_KEY= +# Optional controls for test volume and Browserbase concurrency. +MAX_CONCURRENCY=2 +MAX_JOBS=0 diff --git a/packages/examples/job-application/python/README.md b/packages/examples/job-application/python/README.md new file mode 100644 index 0000000000..672dd9f547 --- /dev/null +++ b/packages/examples/job-application/python/README.md @@ -0,0 +1,21 @@ +# Stagehand + Browserbase: Job Application Automation + +Location in the Stagehand repository: `packages/examples/job-application/python`. + +Stagehand is the SDK for browser agents. + +This template uses Stagehand V4 to discover every role on a public test job board, fill each application with unique test data, upload a PDF resume, and submit it. + +## Run + +```bash +cp .env.example .env +uv sync +uv run python main.py +``` + +Set `BROWSERBASE_API_KEY` in `.env`. `MAX_CONCURRENCY` defaults to `2`; set `MAX_JOBS` to a positive number when you want a bounded test run. + +Expected output includes the number of discovered jobs, a submission line for every application, and a final completed-submission count. Runtime errors still make the process exit nonzero. + +Docs: https://docs.stagehand.dev/v4/first-steps/introduction diff --git a/packages/examples/job-application/python/main.py b/packages/examples/job-application/python/main.py new file mode 100644 index 0000000000..9818b9a9f6 --- /dev/null +++ b/packages/examples/job-application/python/main.py @@ -0,0 +1,155 @@ +"""Discover and submit test applications with Stagehand V4.""" + +import asyncio +import os +import random +import string +import time + +import httpx +from dotenv import load_dotenv +from pydantic import BaseModel, Field, HttpUrl + +from stagehand import FilePayload, Stagehand, browserbase + +load_dotenv() + +JOB_BOARD_URL = "https://agent-job-board.vercel.app/" +RESUME_URL = f"{JOB_BOARD_URL}Agent%20Resume.pdf" + + +class JobInfo(BaseModel): + url: HttpUrl = Field(description="Job URL") + title: str = Field(min_length=1, description="Job title") + + +class JobsData(BaseModel): + jobs: list[JobInfo] + + +def require_env(name: str) -> str: + value = os.environ.get(name) + if not value: + raise RuntimeError(f"{name} is required") + return value + + +def generate_random_email() -> str: + suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8)) + return f"agent-{suffix}@example.com" + + +def generate_agent_id() -> str: + suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=7)) + return f"agent-{int(time.time() * 1000)}-{suffix}" + + +async def close_session(stagehand: Stagehand, browser: object) -> None: + await stagehand.close() + await browser.close() # type: ignore[attr-defined] + + +async def discover_jobs() -> list[JobInfo]: + browser = await browserbase.launch(api_key=require_env("BROWSERBASE_API_KEY")) + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + await page.goto(JOB_BOARD_URL, wait_until="domcontentloaded", timeout=60_000) + await stagehand.act("Click the View Jobs button", page=page) + extracted = await stagehand.extract( + "Extract every visible job listing with its title and absolute URL", + JobsData, + page=page, + ) + jobs = extracted.data.jobs + if not jobs: + raise RuntimeError("The job board returned no job listings") + return jobs + finally: + await close_session(stagehand, browser) + + +async def apply_to_job(job: JobInfo, resume: bytes, semaphore: asyncio.Semaphore) -> str: + async with semaphore: + browser = await browserbase.launch(api_key=require_env("BROWSERBASE_API_KEY")) + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + await page.goto(str(job.url), wait_until="domcontentloaded", timeout=60_000) + await stagehand.act(f"Click the job listing titled {job.title}", page=page) + + agent_id = generate_agent_id() + email = generate_random_email() + await stagehand.act( + "Fill the agent identifier field with %agent_id%", + page=page, + variables={"agent_id": agent_id}, + ) + await stagehand.act( + "Fill the contact endpoint field with %email%", + page=page, + variables={"email": email}, + ) + await stagehand.act("Fill the deployment region field with us-west-2", page=page) + + observed = await stagehand.observe( + "Find the file input for the agent profile or resume", + page=page, + ) + if not observed.data or not observed.data[0].selector: + raise RuntimeError(f"[{job.title}] Could not locate the resume upload input") + await page.locator(observed.data[0].selector).set_input_files( + FilePayload( + name="Agent Resume.pdf", + buffer=resume, + mime_type="application/pdf", + ) + ) + + await stagehand.act("Select Yes for multi-region deployment", page=page) + await stagehand.act("Click the Deploy Agent button", page=page) + print(f"[{job.title}] Application submitted ({agent_id}, {email})") + return job.title + finally: + await close_session(stagehand, browser) + + +async def main() -> None: + max_concurrency = max(1, int(os.environ.get("MAX_CONCURRENCY", "2"))) + max_jobs = int(os.environ.get("MAX_JOBS", "0")) + jobs = await discover_jobs() + if max_jobs > 0: + jobs = jobs[:max_jobs] + print(f"Discovered {len(jobs)} jobs; applying with concurrency {max_concurrency}") + + async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: + response = await client.get(RESUME_URL) + response.raise_for_status() + resume = response.content + if not resume.startswith(b"%PDF"): + raise RuntimeError("The resume download was not a PDF") + + semaphore = asyncio.Semaphore(max_concurrency) + results = await asyncio.gather( + *(apply_to_job(job, resume, semaphore) for job in jobs), + return_exceptions=True, + ) + failures = [result for result in results if isinstance(result, BaseException)] + if failures: + raise RuntimeError(f"{len(failures)} of {len(jobs)} applications failed: {failures}") + print(f"Completed {len(results)} job application submissions") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as error: + print(f"Job application automation failed: {error}") + print("Docs: https://docs.stagehand.dev/v4/first-steps/introduction") + raise diff --git a/packages/examples/job-application/python/pyproject.toml b/packages/examples/job-application/python/pyproject.toml new file mode 100644 index 0000000000..f9e0677c8e --- /dev/null +++ b/packages/examples/job-application/python/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "job-application" +version = "0.1.0" +requires-python = ">=3.11,<3.14" +dependencies = ["httpx==0.28.1", "pydantic>=2.12,<3", "python-dotenv==1.2.2", "stagehand==4.0.0"] + +[tool.uv] +package = false diff --git a/packages/examples/job-application/typescript/.env.example b/packages/examples/job-application/typescript/.env.example new file mode 100644 index 0000000000..e8c8c8eda7 --- /dev/null +++ b/packages/examples/job-application/typescript/.env.example @@ -0,0 +1,2 @@ +BROWSERBASE_API_KEY= +BROWSERBASE_PROJECT_ID= diff --git a/packages/examples/job-application/typescript/README.md b/packages/examples/job-application/typescript/README.md new file mode 100644 index 0000000000..85c0164e86 --- /dev/null +++ b/packages/examples/job-application/typescript/README.md @@ -0,0 +1,85 @@ +# Stagehand V4 + Browserbase: Automated Job Application Workflow + +Location in the Stagehand repository: `packages/examples/job-application/typescript`. + +## AT A GLANCE + +- Goal: Automate job applications by discovering job listings and submitting applications with unique agent identifiers. +- Concurrent Processing: applies to multiple jobs in parallel with configurable concurrency limits based on Browserbase project settings. +- Dynamic Data Generation: generates unique agent IDs and email addresses for each application. +- File Upload Support: automatically uploads resume PDF from a remote URL during the application process. +- Docs โ†’ https://docs.stagehand.dev/v4/basics/act + +## GLOSSARY + +- act: perform UI actions from a prompt (click, type, fill forms) + Docs โ†’ https://docs.stagehand.dev/v4/basics/act +- extract: extract structured data from web pages using natural language instructions + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- observe: analyze a page and return selectors or action plans before executing + Docs โ†’ https://docs.stagehand.dev/v4/basics/observe +- semaphore: concurrency control mechanism to limit parallel job applications based on project limits + +## QUICKSTART + +1. pnpm install +2. cp .env.example .env +3. Add your Browserbase API key and Project ID to .env (BROWSERBASE_API_KEY, BROWSERBASE_PROJECT_ID) +4. pnpm start + +## EXPECTED OUTPUT + +- Fetches project concurrency limit from Browserbase (maxed at 5) +- Initializes main Stagehand session with Browserbase +- Navigates to agent job board +- Clicks "View Jobs" button +- Extracts all job listings with titles and URLs using structured schema +- Closes main session +- Creates semaphore for concurrency control +- Applies to all jobs in parallel (respecting concurrency limit) +- For each job application: + - Generates unique agent ID and email + - Navigates to job page + - Clicks on specific job + - Fills agent identifier field + - Fills contact endpoint (email) field + - Fills deployment region field + - Uploads resume PDF from remote URL + - Selects multi-region deployment option + - Submits application +- Displays completion message when all applications are finished + +## COMMON PITFALLS + +- Dependency install errors: ensure npm install completed +- Missing credentials: verify .env contains BROWSERBASE_PROJECT_ID and BROWSERBASE_API_KEY +- Concurrency limits: script automatically respects Browserbase project concurrency (capped at 5) +- Resume URL: ensure the resume URL (https://agent-job-board.vercel.app/Agent%20Resume.pdf) is accessible +- Job detection: verify that job listings are visible on the page and match expected structure +- Network issues: check internet connection and website accessibility +- Find more information on your Browserbase dashboard -> https://www.browserbase.com/sign-in + +## USE CASES + +โ€ข Bulk job applications: Automate applying to multiple job postings simultaneously with unique credentials for each application. +โ€ข Agent deployment automation: Streamline the process of deploying multiple AI agents by automating the application and registration workflow. +โ€ข Testing & QA: Validate job application forms and workflows across multiple listings to ensure consistent functionality. +โ€ข Recruitment automation: Scale agent recruitment processes by programmatically submitting applications with generated identifiers. + +## NEXT STEPS + +โ€ข Add filtering: Implement job filtering by title keywords, location, or other criteria before applying. +โ€ข Error handling: Add retry logic for failed applications and better error reporting with job-specific logs. +โ€ข Resume customization: Support multiple resume versions or dynamic resume generation based on job requirements. +โ€ข Application tracking: Store application status, timestamps, and results in a database for tracking and follow-up. +โ€ข Rate limiting: Add delays between applications to avoid overwhelming the target system. +โ€ข Multi-site support: Extend to support multiple job boards with site-specific form field mappings. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/job-application/typescript/index.ts b/packages/examples/job-application/typescript/index.ts new file mode 100644 index 0000000000..dbdd77f335 --- /dev/null +++ b/packages/examples/job-application/typescript/index.ts @@ -0,0 +1,235 @@ +// Stagehand + Browserbase: Job Application Automation - See README.md for full documentation + +import "dotenv/config"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import Browserbase from "@browserbasehq/sdk"; +import { z } from "zod/v4"; + +// Define Zod schema for structured data extraction +// Using schemas ensures consistent data extraction even if page layout changes +const JobInfoSchema = z.object({ + url: z.string().url(), + title: z.string(), +}); + +type JobInfo = z.infer; + +async function closeSession( + stagehand: Stagehand, + browser: Awaited>, +) { + await stagehand.close().catch((error) => console.warn("Stagehand cleanup warning:", error)); + await browser.close().catch((error) => console.warn("Browser cleanup warning:", error)); +} + +export async function getProjectConcurrency(): Promise { + // Fetch project concurrency limit from Browserbase SDK + // Capped at 5 to prevent overwhelming the system with too many parallel requests + const bb = new Browserbase({ + apiKey: process.env.BROWSERBASE_API_KEY!, + }); + + const project = await bb.projects.retrieve(process.env.BROWSERBASE_PROJECT_ID!); + return Math.min(project.concurrency, 5); +} + +export function generateRandomEmail(): string { + // Generate a random email address for form submission + const randomString = Math.random().toString(36).substring(2, 10); + return `agent-${randomString}@example.com`; +} + +export function generateAgentId(): string { + // Generate a unique agent identifier for job applications + // Combines timestamp and random string to ensure uniqueness + return `agent-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; +} + +export function createSemaphore(maxConcurrency: number) { + // Semaphore implementation for concurrency control + // Ensures we don't exceed Browserbase project limits when applying to multiple jobs + let activeCount = 0; + const queue: (() => void)[] = []; + + const semaphore = () => + new Promise((resolve) => { + if (activeCount < maxConcurrency) { + activeCount++; + resolve(); + } else { + queue.push(resolve); + } + }); + + const release = () => { + activeCount--; + if (queue.length > 0) { + const next = queue.shift()!; + activeCount++; + next(); + } + }; + + return { semaphore, release }; +} + +async function applyToJob(jobInfo: JobInfo, semaphore: () => Promise, release: () => void) { + // Acquire semaphore slot before starting job application + await semaphore(); + + // Initialize Stagehand with Browserbase for cloud-based browser automation + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + }); + const stagehand = await Stagehand.create({ + browser: browser, + model: { modelName: "google/gemini-2.5-flash" }, + }); + + try { + // Initialize browser session to start automation + + console.log(`[${jobInfo.title}] Session Started`); + const page = (await browser.context.pages())[0]; + + // Navigate to job URL + await page.goto(jobInfo.url); + console.log(`[${jobInfo.title}] Navigated to job page`); + + // Click on the specific job listing to open application form + await stagehand.act(`click on ${jobInfo.title}`); + console.log(`[${jobInfo.title}] Clicked on job`); + + // Generate unique identifiers for this application + const agentId = generateAgentId(); + const email = generateRandomEmail(); + + console.log(`[${jobInfo.title}] Agent ID: ${agentId}`); + console.log(`[${jobInfo.title}] Email: ${email}`); + + // Fill out application form fields using natural language actions + // Stagehand's act() method understands natural language instructions + await stagehand.act(`type '${agentId}' into the agent identifier field`); + + await stagehand.act(`type '${email}' into the contact endpoint field`); + + await stagehand.act(`type 'us-west-2' into the deployment region field`); + + // Upload agent profile/resume file + // Using observe() to find the upload button, then setting files programmatically + const { + data: [uploadAction], + } = await stagehand.observe("find the file upload button for agent profile"); + if (uploadAction) { + const uploadSelector = uploadAction.selector; + if (uploadSelector) { + const fileInput = page.locator(uploadSelector); + + // Fetch resume PDF from remote URL + // Using fetch to download the file before uploading + const resumeUrl = "https://agent-job-board.vercel.app/Agent%20Resume.pdf"; + const response = await fetch(resumeUrl); + if (!response.ok) { + throw new Error(`Failed to fetch resume: ${response.statusText}`); + } + const resumeBuffer = Buffer.from(await response.arrayBuffer()); + + // Upload file using Playwright's setInputFiles with buffer + await fileInput.setInputFiles({ + name: "Agent Resume.pdf", + mimeType: "application/pdf", + buffer: resumeBuffer, + }); + console.log(`[${jobInfo.title}] Uploaded resume from ${resumeUrl}`); + } + } + + // Select multi-region deployment option + await stagehand.act(`select 'Yes' for multi region deployment`); + + // Submit the application form + await stagehand.act(`click deploy agent button`); + + console.log(`[${jobInfo.title}] Application submitted successfully!`); + + await closeSession(stagehand, browser); + } catch (error) { + console.error(`[${jobInfo.title}] Error:`, error); + await closeSession(stagehand, browser); + throw error; + } finally { + // Always release semaphore slot to allow next job application to proceed + release(); + } +} + +async function main() { + console.log("Starting Job Application Automation..."); + + // Get project concurrency limit to control parallel execution + const maxConcurrency = await getProjectConcurrency(); + console.log(`Executing with concurrency limit: ${maxConcurrency}`); + + // Initialize Stagehand with Browserbase for cloud-based browser automation + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + }); + const stagehand = await Stagehand.create({ + browser: browser, + model: { modelName: "google/gemini-2.5-flash" }, + }); + + // Initialize browser session to start automation + + console.log(`Main Stagehand Session Started`); + const page = (await browser.context.pages())[0]; + + // Navigate to agent job board homepage + await page.goto("https://agent-job-board.vercel.app/"); + console.log("Navigated to agent-job-board.vercel.app"); + + // Click on "View Jobs" button to access job listings + await stagehand.act("click on the view jobs button"); + console.log("Clicked on view jobs button"); + + // Extract all job listings with titles and URLs using structured schema + // Using extract() with Zod schema ensures consistent data extraction + const { data: jobsData } = await stagehand.extract( + "extract all job listings with their titles and URLs", + z.array(JobInfoSchema), + ); + + console.log(`Found ${jobsData.length} jobs`); + + await closeSession(stagehand, browser); + + // Create semaphore with concurrency limit to control parallel job applications + // Semaphore ensures we don't exceed Browserbase project limits + const { semaphore, release } = createSemaphore(maxConcurrency); + + // Apply to all jobs in parallel with concurrency control + // Using Promise.all() to run all applications concurrently + console.log( + `Starting to apply to ${jobsData.length} jobs with max concurrency of ${maxConcurrency}`, + ); + + const applicationPromises = jobsData.map((job) => applyToJob(job, semaphore, release)); + + // Attempt every application even if an earlier one fails, then report the + // complete business outcome instead of stopping on the first rejection. + const applicationResults = await Promise.allSettled(applicationPromises); + const failures = applicationResults.filter((result) => result.status === "rejected"); + if (failures.length > 0) { + throw new Error(`${failures.length} of ${jobsData.length} applications failed`); + } + + console.log("All applications completed!"); +} + +main().catch((err) => { + console.error("Error in job application automation:", err); + console.error("Common issues:"); + console.error(" - Check .env file has BROWSERBASE_PROJECT_ID and BROWSERBASE_API_KEY"); + console.error("Docs: https://docs.stagehand.dev/v4/first-steps/introduction"); + process.exit(1); +}); diff --git a/packages/examples/job-application/typescript/package.json b/packages/examples/job-application/typescript/package.json new file mode 100644 index 0000000000..86080988e0 --- /dev/null +++ b/packages/examples/job-application/typescript/package.json @@ -0,0 +1,26 @@ +{ + "name": "job-application", + "version": "1.0.0", + "description": "Stagehand + Browserbase: discover and submit job applications with bounded concurrency", + "type": "module", + "main": "index.ts", + "scripts": { + "start": "tsx index.ts", + "dev": "tsx watch index.ts" + }, + "dependencies": { + "@browserbasehq/sdk": "^2.18.0", + "@browserbasehq/stagehand": "4.0.0", + "dotenv": "^16.4.5", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "tsx": "^4.16.0", + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=22.18.0" + }, + "packageManager": "pnpm@10.24.0" +} diff --git a/packages/examples/langchain/README.md b/packages/examples/langchain/README.md new file mode 100644 index 0000000000..55c271e773 --- /dev/null +++ b/packages/examples/langchain/README.md @@ -0,0 +1,37 @@ +# Langchain JS + +Location in the Stagehand repository: `packages/examples/langchain`. + +## Integrate Stagehand with Langchain JS + +Stagehand can be integrated into Langchain JS by wrapping Stagehand's browser automation functionality with the StagehandToolkit. + +This toolkit provides specialized tools such as navigate, act, extract, and observe, all powered by Stagehand's underlying capabilities. + +For more details on this integration and how to work with Langchain, see the official Langchain documentation. + +## Use the tools + +- **stagehand_navigate**: Navigate to a specific URL. +- **stagehand_act**: Perform browser automation tasks like clicking buttons and typing in fields. +- **stagehand_extract**: Extract structured data from pages using Zod schemas. +- **stagehand_observe**: Investigate the DOM for possible actions or relevant elements. + +## Remote Browsers (Browserbase) + +Instead of `env: "LOCAL"`, specify `env: "BROWSERBASE"` and pass in your Browserbase credentials through environment variables: + +- `BROWSERBASE_API_KEY` +- `BROWSERBASE_PROJECT_ID` + +## Using LangGraph Agents + +The StagehandToolkit can also be plugged into LangGraph's existing agent system. This lets you orchestrate more complex flows by combining Stagehand's tools with other Langchain tools. + +With the StagehandToolkit, you can quickly integrate natural-language-driven browser automation into workflows supported by Langchain. This enables use cases such as: + +- Searching, extracting, and summarizing data from websites +- Automating login flows +- Navigating or clicking through forms based on instructions from a larger chain of agents + +Consult Stagehand's and Langchain's official references for troubleshooting and advanced integrations or reach out to us on [Slack](https://stagehand.dev/slack). diff --git a/packages/examples/langchain/package.json b/packages/examples/langchain/package.json new file mode 100644 index 0000000000..e99c372d84 --- /dev/null +++ b/packages/examples/langchain/package.json @@ -0,0 +1,19 @@ +{ + "name": "langchain-stagehand", + "version": "1.0.0", + "description": "", + "keywords": [], + "license": "ISC", + "author": "", + "type": "commonjs", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "@browserbasehq/stagehand": "^2.4.4", + "@langchain/community": "^0.3.44", + "@langchain/core": "^0.3.57", + "@langchain/langgraph": "^0.2.73" + } +} diff --git a/packages/examples/langchain/src/index.js b/packages/examples/langchain/src/index.js new file mode 100644 index 0000000000..6bb6a396f1 --- /dev/null +++ b/packages/examples/langchain/src/index.js @@ -0,0 +1,32 @@ +import { Stagehand } from "@browserbasehq/stagehand"; +import { StagehandToolkit } from "@stagehand/langchain"; + +const stagehand = new Stagehand({ + env: "LOCAL", + verbose: 2, + enableCaching: false, +}); + +const stagehandToolkit = await StagehandToolkit.fromStagehand(stagehand); + +// Find the relevant tool +const navigateTool = stagehandToolkit.tools.find((t) => t.name === "stagehand_navigate"); + +// Invoke it +await navigateTool.invoke("https://www.google.com"); + +// Suppose you want to act on the page +const actionTool = stagehandToolkit.tools.find((t) => t.name === "stagehand_act"); + +await actionTool.invoke('Search for "OpenAI"'); + +// Observe the current page +const observeTool = stagehandToolkit.tools.find((t) => t.name === "stagehand_observe"); + +const result = await observeTool.invoke("What actions can be performed on the current page?"); + +console.log(JSON.parse(result)); + +// Verification +const currentUrl = stagehand.page.url(); +console.log("Current URL:", currentUrl); diff --git a/packages/examples/manual-mfa-with-contexts/README.md b/packages/examples/manual-mfa-with-contexts/README.md new file mode 100644 index 0000000000..e70b2661fd --- /dev/null +++ b/packages/examples/manual-mfa-with-contexts/README.md @@ -0,0 +1,12 @@ +# manual-mfa-with-contexts + +Generic GitHub login with interactive user authentication and fresh runtime contexts. + +Choose a language and follow its setup instructions: + +| Language | Stagehand dependency | Directory from the repository root | +| ---------------------------------- | -------------------- | ------------------------------------------------------- | +| [TypeScript](typescript/README.md) | `4.0.0` | `packages/examples/manual-mfa-with-contexts/typescript` | +| [Python](python/README.md) | `4.0.0` | `packages/examples/manual-mfa-with-contexts/python` | + +Each variant installs the published Stagehand package and keeps its own dependencies. diff --git a/packages/examples/manual-mfa-with-contexts/python/.env.example b/packages/examples/manual-mfa-with-contexts/python/.env.example new file mode 100644 index 0000000000..38bd3488f9 --- /dev/null +++ b/packages/examples/manual-mfa-with-contexts/python/.env.example @@ -0,0 +1,3 @@ +BROWSERBASE_API_KEY= +GITHUB_USERNAME= +GITHUB_PASSWORD= diff --git a/packages/examples/manual-mfa-with-contexts/python/README.md b/packages/examples/manual-mfa-with-contexts/python/README.md new file mode 100644 index 0000000000..a9ec9e8a0f --- /dev/null +++ b/packages/examples/manual-mfa-with-contexts/python/README.md @@ -0,0 +1,71 @@ +# Stagehand + Browserbase: Manual MFA with Contexts + +Location in the Stagehand repository: `packages/examples/manual-mfa-with-contexts/python`. + +Stagehand is the SDK for browser agents. + +## AT A GLANCE + +- Goal: demonstrate how to persist authentication across sessions using Browserbase Contexts, eliminating MFA friction after the first login. +- Flow: first session creates context and completes MFA manually โ†’ context saves auth state โ†’ second session reuses context with no MFA required. + +## GLOSSARY + +- context: a Browserbase feature that persists browser state (cookies, localStorage, sessionStorage) across sessions. + Docs โ†’ https://docs.browserbase.com/features/contexts +- persist: setting that saves authentication state including MFA trust/remember device state to the context. +- MFA (Multi-Factor Authentication): two-factor authentication requiring a code from an authenticator app. +- session persistence: maintaining logged-in state across multiple browser sessions without re-authentication. + +## QUICKSTART + +1. cd packages/examples/manual-mfa-with-contexts/python +2. uv venv venv +3. source venv/bin/activate # On Windows: venv\Scripts\activate +4. uvx install stagehand browserbase python-dotenv pydantic requests +5. cp .env.example .env +6. Add your Browserbase API key, GitHub username, and password to .env +7. Ensure 2FA is enabled on your GitHub test account (Settings โ†’ Password and authentication โ†’ Enable two-factor authentication) +8. python main.py + +## EXPECTED OUTPUT + +- Creates a new Browserbase context +- First session: navigates to GitHub login, fills credentials, detects MFA prompt +- Pauses and displays Browserbase session link for manual MFA completion +- Waits for MFA completion (2 minute timeout) +- Saves authentication state to context +- Second session: reuses context, navigates to GitHub (already logged in, no MFA) +- Extracts and prints the logged-in username from the reused context +- Cleans up context + +## COMMON PITFALLS + +- "ModuleNotFoundError": ensure all dependencies are installed via uvx install +- Missing credentials: verify .env contains BROWSERBASE_API_KEY, GITHUB_USERNAME, and GITHUB_PASSWORD +- MFA timeout: ensure you complete MFA within 2 minutes, or increase timeout value +- 2FA not enabled: GitHub account must have 2FA enabled for this demo to work +- Context not persisting: verify context.persist is set to true in browser_settings + +## USE CASES + +โ€ข Payment automation: Complete MFA once for utility portals, then automate future payments without MFA prompts. +โ€ข Account management: Persist authentication for services requiring MFA, enabling automated account management workflows. +โ€ข Compliance automation: Store trusted device state for regulatory portals, reducing friction for recurring compliance tasks. + +## NEXT STEPS + +โ€ข Store context IDs: Save context_id per customer in your database to reuse across sessions. +โ€ข Multi-portal support: Extend to multiple portals/services, each with their own context. +โ€ข Context management: Implement context cleanup, rotation, and expiration policies. +โ€ข Error handling: Add retry logic and better error messages for MFA timeouts. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ“š Contexts Docs: https://docs.browserbase.com/features/contexts +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/manual-mfa-with-contexts/python/main.py b/packages/examples/manual-mfa-with-contexts/python/main.py new file mode 100644 index 0000000000..6422c16659 --- /dev/null +++ b/packages/examples/manual-mfa-with-contexts/python/main.py @@ -0,0 +1,132 @@ +"""Persist a manually completed GitHub MFA login with Stagehand V4.""" + +import asyncio +import os +import time + +from browserbase import AsyncBrowserbase +from dotenv import load_dotenv +from pydantic import BaseModel + +from stagehand import Stagehand, browserbase + +load_dotenv() + + +class MFAStatus(BaseModel): + mfa_required: bool + + +class AuthenticationState(BaseModel): + username: str + + +def require_env(name: str) -> str: + value = os.environ.get(name) + if not value: + raise RuntimeError(f"{name} is required") + return value + + +async def first_login(context_id: str) -> None: + browser = await browserbase.launch( + api_key=require_env("BROWSERBASE_API_KEY"), + browser_settings={"context": {"id": context_id, "persist": True}}, + ) + try: + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + await page.goto("https://github.com/login", wait_until="domcontentloaded") + await stagehand.act( + "Fill the username field with %username%", + page=page, + variables={"username": require_env("GITHUB_USERNAME")}, + ) + await stagehand.act( + "Fill the password field with %password%", + page=page, + variables={"password": require_env("GITHUB_PASSWORD")}, + ) + await stagehand.act("Click the Sign in button", page=page) + + status = await stagehand.extract( + "Is a two-factor authentication or verification-code prompt visible?", + MFAStatus, + page=page, + ) + if status.data.mfa_required: + print("MFA is required. Open the newest Browserbase session and complete it.") + deadline = time.monotonic() + 120 + while time.monotonic() < deadline: + current_url = await page.url() + if "/login" not in current_url and "/sessions/two-factor" not in current_url: + break + await asyncio.sleep(3) + else: + raise TimeoutError("MFA was not completed within two minutes") + + print("First session authenticated and persisted") + finally: + await stagehand.close() + finally: + await browser.close() + + +async def verify_context(context_id: str) -> None: + browser = await browserbase.launch( + api_key=require_env("BROWSERBASE_API_KEY"), + browser_settings={"context": {"id": context_id, "persist": True}}, + ) + try: + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + await page.goto("https://github.com", wait_until="domcontentloaded") + extracted = await stagehand.extract( + ( + "Extract the logged-in GitHub username. Return an empty string if the page " + "is not authenticated." + ), + AuthenticationState, + page=page, + ) + username = extracted.data.username + print("Second session reused GitHub authentication without another login") + print(f"Logged-in username: {username}") + finally: + await stagehand.close() + finally: + await browser.close() + + +async def main() -> None: + require_env("GITHUB_USERNAME") + require_env("GITHUB_PASSWORD") + async with AsyncBrowserbase(api_key=require_env("BROWSERBASE_API_KEY")) as api: + context = await api.contexts.create() + print("Created temporary Browserbase context") + try: + await first_login(context.id) + await asyncio.sleep(5) + await verify_context(context.id) + finally: + # The generated SDK currently sets a JSON content type on DELETE, so send an + # explicit empty object instead of an empty body. + await api.contexts.delete(context.id, extra_body={}) + print("Deleted temporary Browserbase context") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as error: + print(f"MFA context demo failed: {error}") + print("Docs: https://docs.stagehand.dev/v4/first-steps/introduction") + raise diff --git a/packages/examples/manual-mfa-with-contexts/python/pyproject.toml b/packages/examples/manual-mfa-with-contexts/python/pyproject.toml new file mode 100644 index 0000000000..4daf27b24a --- /dev/null +++ b/packages/examples/manual-mfa-with-contexts/python/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "manual-mfa-with-contexts" +version = "0.1.0" +requires-python = ">=3.11,<3.14" +dependencies = [ + "browserbase>=1.7.0", + "pydantic>=2.12,<3", + "python-dotenv==1.2.2", + "stagehand==4.0.0", +] + +[tool.uv] +package = false diff --git a/packages/examples/manual-mfa-with-contexts/python/requirements.txt b/packages/examples/manual-mfa-with-contexts/python/requirements.txt new file mode 100644 index 0000000000..f7af058e34 --- /dev/null +++ b/packages/examples/manual-mfa-with-contexts/python/requirements.txt @@ -0,0 +1,5 @@ +browserbase>=1.7.0 +python-dotenv +pydantic +stagehand==4.0.0 +requests diff --git a/packages/examples/manual-mfa-with-contexts/typescript/.env.example b/packages/examples/manual-mfa-with-contexts/typescript/.env.example new file mode 100644 index 0000000000..38bd3488f9 --- /dev/null +++ b/packages/examples/manual-mfa-with-contexts/typescript/.env.example @@ -0,0 +1,3 @@ +BROWSERBASE_API_KEY= +GITHUB_USERNAME= +GITHUB_PASSWORD= diff --git a/packages/examples/manual-mfa-with-contexts/typescript/README.md b/packages/examples/manual-mfa-with-contexts/typescript/README.md new file mode 100644 index 0000000000..4dad2da234 --- /dev/null +++ b/packages/examples/manual-mfa-with-contexts/typescript/README.md @@ -0,0 +1,67 @@ +# Stagehand + Browserbase: Manual MFA with Contexts + +Location in the Stagehand repository: `packages/examples/manual-mfa-with-contexts/typescript`. + +## AT A GLANCE + +- Goal: demonstrate how to persist authentication across sessions using Browserbase Contexts, eliminating MFA friction after the first login. +- Flow: first session creates context and completes MFA manually โ†’ context saves auth state โ†’ second session reuses context with no MFA required. + +## GLOSSARY + +- context: a Browserbase feature that persists browser state (cookies, localStorage, sessionStorage) across sessions. + Docs โ†’ https://docs.browserbase.com/features/contexts +- persist: setting that saves authentication state including MFA trust/remember device state to the context. +- MFA (Multi-Factor Authentication): two-factor authentication requiring a code from an authenticator app. +- session persistence: maintaining logged-in state across multiple browser sessions without re-authentication. + +## QUICKSTART + +1. cd packages/examples/manual-mfa-with-contexts/typescript +2. pnpm install +3. cp .env.example .env +4. Add your Browserbase API key, GitHub username, and password to .env +5. Ensure 2FA is enabled on your GitHub test account (Settings โ†’ Password and authentication โ†’ Enable two-factor authentication) +6. pnpm start + +## EXPECTED OUTPUT + +- Creates a new Browserbase context +- First session: navigates to GitHub login, fills credentials, detects MFA prompt +- Pauses and directs the user to the newest session in the Browserbase Sessions dashboard for manual MFA completion +- Waits for MFA completion (2 minute timeout) +- Saves authentication state to context +- Second session: reuses context, navigates to GitHub (already logged in, no MFA) +- Extracts and prints the GitHub username from the reused context +- Cleans up context + +## COMMON PITFALLS + +- "Cannot find module 'dotenv'": ensure pnpm install ran successfully +- Missing credentials: verify .env contains BROWSERBASE_API_KEY, GITHUB_USERNAME, and GITHUB_PASSWORD +- MFA timeout: ensure you complete MFA within 2 minutes, or increase timeout value +- 2FA not enabled: GitHub account must have 2FA enabled for this demo to work +- Context not persisting: verify context.persist is set to true in browserSettings + +## USE CASES + +โ€ข Payment automation: Complete MFA once for utility portals, then automate future payments without MFA prompts. +โ€ข Account management: Persist authentication for services requiring MFA, enabling automated account management workflows. +โ€ข Compliance automation: Store trusted device state for regulatory portals, reducing friction for recurring compliance tasks. + +## NEXT STEPS + +โ€ข Store context IDs: Save context_id per customer in your database to reuse across sessions. +โ€ข Multi-portal support: Extend to multiple portals/services, each with their own context. +โ€ข Context management: Implement context cleanup, rotation, and expiration policies. +โ€ข Error handling: Add retry logic and better error messages for MFA timeouts. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ“š Contexts Docs: https://docs.browserbase.com/features/contexts +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/manual-mfa-with-contexts/typescript/index.ts b/packages/examples/manual-mfa-with-contexts/typescript/index.ts new file mode 100644 index 0000000000..a63c08587e --- /dev/null +++ b/packages/examples/manual-mfa-with-contexts/typescript/index.ts @@ -0,0 +1,233 @@ +// Manual MFA with Browserbase Contexts - See README.md for full documentation + +import "dotenv/config"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import { Browserbase } from "@browserbasehq/sdk"; +import { z } from "zod/v4"; + +const bb = new Browserbase({ + apiKey: process.env.BROWSERBASE_API_KEY, +}); + +/** + * First session: Create context and login (with MFA) + */ +async function createSessionWithContext() { + console.log("Creating new Browserbase context..."); + + const context = await bb.contexts.create(); + + console.log("Browserbase context created"); + console.log("First session: Performing login with MFA..."); + + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + browserSettings: { + context: { + id: context.id, + persist: true, // Save authentication state including MFA + }, + }, + }); + const stagehand = await Stagehand.create({ + browser: browser, + model: { modelName: "openai/gpt-4.1-mini" }, + logging: { level: "error" }, + }); + + console.log("Live View is available in the Browserbase Sessions dashboard"); + + const page = (await browser.context.pages())[0]; + + // Navigate to GitHub login + console.log("Navigating to GitHub login..."); + await page.goto("https://github.com/login"); + await page.waitForLoadState("domcontentloaded"); + + // Fill in credentials + console.log("Entering username..."); + await stagehand.act(`Type '${process.env.GITHUB_USERNAME}' into the username field`); + + console.log("Entering password..."); + await stagehand.act(`Type '${process.env.GITHUB_PASSWORD}' into the password field`); + + console.log("Clicking Sign in..."); + await stagehand.act("Click the Sign in button"); + + await page.waitForLoadState("networkidle"); + + // Check if MFA is required + const { data: mfaRequired } = await stagehand.extract( + "Is there a two-factor authentication or verification code prompt on the page?", + z.boolean(), + ); + + if (mfaRequired) { + console.log("MFA DETECTED!"); + console.log("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•"); + console.log("PAUSED: Please complete MFA in the browser"); + console.log("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•"); + console.log("1. Open the newest running session in the Browserbase Sessions dashboard"); + console.log("2. Enter your 2FA code from authenticator app"); + console.log("3. Click 'Verify' or submit"); + console.log("4. Wait for login to complete"); + console.log("\nThe script will wait for you to complete MFA...\n"); + + // Wait for MFA completion (poll until we're no longer on login page) + let loginComplete = false; + const startTime = Date.now(); + const timeout = 120000; // 2 minutes + + while (!loginComplete && Date.now() - startTime < timeout) { + await new Promise((resolve) => setTimeout(resolve, 3000)); // Check every 3 seconds + + const currentUrl = await page.url(); + if (!currentUrl.includes("/login") && !currentUrl.includes("/sessions/two-factor")) { + loginComplete = true; + } + } + + if (!loginComplete) { + throw new Error("MFA timeout - login was not completed within 2 minutes"); + } + + console.log("MFA completed! Login successful.\n"); + } else { + console.log("Login successful (no MFA required)\n"); + } + + console.log("The Browserbase context now contains:"); + console.log(" - Session cookies"); + console.log(" - MFA trust/remember device state"); + console.log(" - All authentication data\n"); + + await stagehand.close().catch((error) => console.warn("Stagehand cleanup warning:", error)); + await browser.close().catch((error) => console.warn("Browser cleanup warning:", error)); + + return context.id; +} + +/** + * Second session: Reuse context - NO MFA needed! + */ +async function reuseContext(contextId: string) { + console.log("Second session: Reusing the saved context"); + console.log(" (No login, no MFA required - auth state persisted)\n"); + + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + browserSettings: { + context: { + id: contextId, + persist: true, + }, + }, + }); + const stagehand = await Stagehand.create({ + browser: browser, + model: { modelName: "openai/gpt-4.1-mini" }, + logging: { level: "error" }, + }); + + console.log("Live View is available in the Browserbase Sessions dashboard"); + + const page = (await browser.context.pages())[0]; + + // Navigate directly to GitHub (should already be logged in) + console.log("Navigating to GitHub..."); + await page.goto("https://github.com"); + await page.waitForLoadState("networkidle"); + + const { data: username } = await stagehand.extract( + "Extract the logged-in GitHub username. Return an empty string if the page is not authenticated.", + z.string(), + ); + + console.log("\nReused context opened GitHub without another login step."); + console.log(` Username: ${username}`); + console.log("\nThis is the power of Browserbase Contexts:"); + console.log(" - First session: User completes MFA once"); + console.log(" - Context saves trusted device state"); + console.log(" - All future sessions: No MFA required\n"); + + await stagehand.close().catch((error) => console.warn("Stagehand cleanup warning:", error)); + await browser.close().catch((error) => console.warn("Browser cleanup warning:", error)); +} + +/** + * Clean up context + */ +async function deleteContext(contextId: string) { + console.log("Deleting Browserbase context"); + try { + // The generated SDK currently sets a JSON content type on DELETE, so send an + // explicit empty object instead of an empty body. + await bb.contexts.delete(contextId, { body: {} }); + console.log("Context deleted\n"); + } catch (error) { + console.log( + `Could not delete context: ${error instanceof Error ? error.message : String(error)}`, + ); + console.log(" Context will auto-expire after 30 days\n"); + } +} + +async function main() { + console.log("Starting Browserbase Context MFA Persistence Demo..."); + + // Check environment variables + if (!process.env.BROWSERBASE_API_KEY) { + console.error("\nโŒ Missing Browserbase credentials"); + console.error(" Set BROWSERBASE_API_KEY in .env"); + process.exit(1); + } + + if (!process.env.GITHUB_USERNAME || !process.env.GITHUB_PASSWORD) { + console.error("\nError: Missing GitHub credentials"); + console.error(" Set GITHUB_USERNAME and GITHUB_PASSWORD in .env"); + console.error("Setup Instructions:"); + console.error(" 1. Create a test GitHub account"); + console.error(" 2. Enable 2FA: Settings โ†’ Password and authentication"); + console.error(" 3. Set credentials in .env file"); + process.exit(1); + } + + try { + console.log("\n๐Ÿ“‹ Demo Flow:"); + console.log(" 1. First session: Login + complete MFA manually"); + console.log(" 2. Second session: No login, no MFA needed"); + console.log(" 3. Clean up context\n"); + + // First session: Create context and login with MFA + const contextId = await createSessionWithContext(); + + console.log("โณ Waiting 5 seconds before reusing context...\n"); + await new Promise((resolve) => setTimeout(resolve, 5000)); + + // Second session: Reuse context (NO MFA!) + await reuseContext(contextId); + + // Clean up + await deleteContext(contextId); + + console.log("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•"); + console.log("Key Takeaway:"); + console.log("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•"); + console.log("โœ… First session: User completes MFA once"); + console.log("โœ… Context saves trusted device state"); + console.log("โœ… All future sessions: No MFA prompt"); + console.log("โœ… Store context_id per customer in database\n"); + } catch (error) { + console.error("\nโŒ Error:", error instanceof Error ? error.message : String(error)); + console.error("\nTroubleshooting:"); + console.error(" - Ensure GitHub credentials are correct"); + console.error(" - Ensure 2FA is enabled on the test account"); + console.error(" - Check Browserbase dashboard for session details"); + throw error; + } +} + +main().catch((err) => { + console.error("Application error:", err); + process.exit(1); +}); diff --git a/packages/examples/manual-mfa-with-contexts/typescript/package.json b/packages/examples/manual-mfa-with-contexts/typescript/package.json new file mode 100644 index 0000000000..079cddf650 --- /dev/null +++ b/packages/examples/manual-mfa-with-contexts/typescript/package.json @@ -0,0 +1,26 @@ +{ + "name": "manual-mfa-with-contexts", + "version": "1.0.0", + "description": "Stagehand + Browserbase: Manual MFA with Contexts", + "type": "module", + "main": "index.ts", + "scripts": { + "start": "tsx index.ts", + "dev": "tsx watch index.ts" + }, + "dependencies": { + "@browserbasehq/sdk": "^2.9.0", + "@browserbasehq/stagehand": "4.0.0", + "dotenv": "^16.4.5", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "tsx": "^4.16.0", + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=22.18.0" + }, + "packageManager": "pnpm@10.24.0" +} diff --git a/packages/examples/mfa-handling/README.md b/packages/examples/mfa-handling/README.md new file mode 100644 index 0000000000..73abfb0776 --- /dev/null +++ b/packages/examples/mfa-handling/README.md @@ -0,0 +1,12 @@ +# mfa-handling + +TOTP demonstration against a public authentication test site, with demo values discovered at runtime. + +Choose a language and follow its setup instructions: + +| Language | Stagehand dependency | Directory from the repository root | +| ---------------------------------- | -------------------- | ------------------------------------------- | +| [TypeScript](typescript/README.md) | `4.0.0` | `packages/examples/mfa-handling/typescript` | +| [Python](python/README.md) | `4.0.0` | `packages/examples/mfa-handling/python` | + +Each variant installs the published Stagehand package and keeps its own dependencies. diff --git a/packages/examples/mfa-handling/python/.env.example b/packages/examples/mfa-handling/python/.env.example new file mode 100644 index 0000000000..d0cde0427d --- /dev/null +++ b/packages/examples/mfa-handling/python/.env.example @@ -0,0 +1 @@ +BROWSERBASE_API_KEY= diff --git a/packages/examples/mfa-handling/python/README.md b/packages/examples/mfa-handling/python/README.md new file mode 100644 index 0000000000..f44ae54e3a --- /dev/null +++ b/packages/examples/mfa-handling/python/README.md @@ -0,0 +1,80 @@ +# Stagehand + Browserbase: MFA Handling - TOTP Automation + +Location in the Stagehand repository: `packages/examples/mfa-handling/python`. + +Stagehand is the SDK for browser agents. + +## AT A GLANCE + +- Goal: Automate MFA (Multi-Factor Authentication) completion using TOTP (Time-based One-Time Password) code generation. +- TOTP Generation: Implements RFC 6238 compliant algorithm to generate time-based authentication codes programmatically. +- Automatic Form Filling: Extracts TOTP secrets from pages and automatically fills MFA forms without user interaction. +- Retry Logic: Handles time window edge cases by regenerating codes and retrying authentication when needed. +- Docs โ†’ https://docs.stagehand.dev/v4/basics/act + +## GLOSSARY + +- act: perform UI actions from a prompt (type, click, fill forms) + Docs โ†’ https://docs.stagehand.dev/v4/basics/act +- extract: extract structured data from web pages using natural language instructions + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- TOTP: Time-based One-Time Password - a 6-digit code that changes every 30 seconds, generated using HMAC-SHA1 algorithm +- RFC 6238: Standard specification for TOTP authentication codes used by Google Authenticator, Authy, and other authenticator apps + +## QUICKSTART + +1. python -m venv venv +2. source venv/bin/activate # On Windows: venv\Scripts\activate +3. pip install stagehand python-dotenv pydantic +4. cp .env.example .env +5. Add your Browserbase API key to .env (BROWSERBASE_API_KEY) +6. python main.py + +## EXPECTED OUTPUT + +- Initializes Stagehand session with Browserbase +- Displays live session link for monitoring +- Navigates to TOTP challenge demo page (authenticationtest.com/totpChallenge/) +- Extracts test credentials (email, password) and TOTP secret from the page +- Generates TOTP code using RFC 6238 algorithm +- Fills in email and password fields +- Fills in TOTP code field with generated code +- Submits authentication form +- Checks authentication result +- Retries with fresh code if initial attempt fails (handles time window edge cases) +- Closes session cleanly + +## COMMON PITFALLS + +- "ModuleNotFoundError": ensure all dependencies are installed via pip +- Missing credentials: verify .env contains BROWSERBASE_API_KEY +- TOTP code expiration: codes are valid for 30 seconds - if authentication fails, the script automatically retries with a fresh code +- Page structure changes: if the demo site structure changes, extraction may fail +- Network timeouts: ensure stable internet connection for reliable page loading +- Import errors: activate your virtual environment if you created one +- Find more information on your Browserbase dashboard -> https://www.browserbase.com/sign-in + +## USE CASES + +โ€ข Automated authentication: Complete MFA challenges automatically when session persistence isn't enough (session expired, new device, etc.) +โ€ข TOTP integration: Store encrypted TOTP secrets during user onboarding and generate codes programmatically when needed +โ€ข Zero-touch MFA: Eliminate user interaction for MFA completion in automated workflows +โ€ข Session recovery: Automatically handle MFA prompts when re-authenticating expired sessions + +## NEXT STEPS + +โ€ข Secure storage: Implement encrypted TOTP secret storage (AES-256) in your database during user onboarding +โ€ข Multiple time windows: Add support for trying ยฑ1 time window (60s range) if current code fails +โ€ข SMS/Email MFA: Extend to support SMS codes (via Twilio/Bandwidth API) or email codes (via Gmail API/IMAP) +โ€ข Backup codes: Implement fallback to backup codes stored during initial MFA setup +โ€ข Context integration: Combine with Browserbase Contexts to minimize MFA prompts (95% context reuse, 4% auto TOTP, 1% user-mediated) +โ€ข Error handling: Add graceful fallback to user prompts when automation fails + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/mfa-handling/python/main.py b/packages/examples/mfa-handling/python/main.py new file mode 100644 index 0000000000..0e9379b9f6 --- /dev/null +++ b/packages/examples/mfa-handling/python/main.py @@ -0,0 +1,118 @@ +"""Complete a live RFC 6238 TOTP challenge with Stagehand V4.""" + +import asyncio +import base64 +import hashlib +import hmac +import os +import struct +import time + +from dotenv import load_dotenv +from pydantic import BaseModel, Field + +from stagehand import Page, Stagehand, browserbase + +load_dotenv() + +DEMO_URL = "https://authenticationtest.com/totpChallenge/" + + +class Credentials(BaseModel): + email: str + password: str + totp_secret: str = Field(description="TOTP secret key shown by the demo") + + +class AuthResult(BaseModel): + success: bool + message: str + + +def generate_totp(secret: str, window: int = 0) -> str: + normalized = secret.upper().replace(" ", "").rstrip("=") + padding = "=" * ((8 - len(normalized) % 8) % 8) + key = base64.b32decode(normalized + padding) + counter = int(time.time() // 30) + window + digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest() + offset = digest[-1] & 0x0F + code = struct.unpack(">I", digest[offset : offset + 4])[0] & 0x7FFFFFFF + return str(code % 1_000_000).zfill(6) + + +async def submit(stagehand: Stagehand, page: Page, credentials: Credentials) -> None: + await stagehand.act( + "Fill the email field with %email%", + page=page, + variables={"email": credentials.email}, + ) + await stagehand.act( + "Fill the password field with %password%", + page=page, + variables={"password": credentials.password}, + ) + seconds_left = 30 - int(time.time()) % 30 + if seconds_left < 12: + await asyncio.sleep(seconds_left + 1) + code = generate_totp(credentials.totp_secret) + await stagehand.act( + "Fill the TOTP code field with %code%", + page=page, + variables={"code": code}, + ) + await stagehand.act("Click the submit or login button", page=page) + + +async def main() -> None: + api_key = os.environ.get("BROWSERBASE_API_KEY") + if not api_key: + raise RuntimeError("BROWSERBASE_API_KEY is required") + + browser = await browserbase.launch(api_key=api_key) + try: + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + await page.goto(DEMO_URL, wait_until="domcontentloaded", timeout=60_000) + extracted = await stagehand.extract( + "Extract the test email, password, and TOTP secret shown on the page", + Credentials, + page=page, + ) + credentials = extracted.data + await submit(stagehand, page, credentials) + await page.wait_for_timeout(1_000) + result = await stagehand.extract( + "Check whether the TOTP login succeeded and return its message", + AuthResult, + page=page, + ) + if not result.data.success: + await page.goto(DEMO_URL, wait_until="domcontentloaded") + await submit(stagehand, page, credentials) + await page.wait_for_timeout(1_000) + result = await stagehand.extract( + "Check whether the TOTP login succeeded and return its message", + AuthResult, + page=page, + ) + if not result.data.success: + raise RuntimeError(f"TOTP authentication failed: {result.data.message}") + print(f"TOTP authentication succeeded: {result.data.message}") + finally: + await stagehand.close() + finally: + await browser.close() + print("Session closed successfully") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as error: + print(f"TOTP example failed: {error}") + print("Docs: https://docs.stagehand.dev/v4/first-steps/introduction") + raise diff --git a/packages/examples/mfa-handling/python/pyproject.toml b/packages/examples/mfa-handling/python/pyproject.toml new file mode 100644 index 0000000000..97deabc64e --- /dev/null +++ b/packages/examples/mfa-handling/python/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "mfa-handling" +version = "0.1.0" +requires-python = ">=3.11,<3.14" +dependencies = ["pydantic>=2.12,<3", "python-dotenv==1.2.2", "stagehand==4.0.0"] + +[tool.uv] +package = false diff --git a/packages/examples/mfa-handling/python/requirements.txt b/packages/examples/mfa-handling/python/requirements.txt new file mode 100644 index 0000000000..b64f3057fb --- /dev/null +++ b/packages/examples/mfa-handling/python/requirements.txt @@ -0,0 +1,3 @@ +stagehand==4.0.0 +python-dotenv +pydantic diff --git a/packages/examples/mfa-handling/typescript/.env.example b/packages/examples/mfa-handling/typescript/.env.example new file mode 100644 index 0000000000..d0cde0427d --- /dev/null +++ b/packages/examples/mfa-handling/typescript/.env.example @@ -0,0 +1 @@ +BROWSERBASE_API_KEY= diff --git a/packages/examples/mfa-handling/typescript/README.md b/packages/examples/mfa-handling/typescript/README.md new file mode 100644 index 0000000000..f453b3e298 --- /dev/null +++ b/packages/examples/mfa-handling/typescript/README.md @@ -0,0 +1,74 @@ +# Stagehand + Browserbase: MFA Handling - TOTP Automation + +Location in the Stagehand repository: `packages/examples/mfa-handling/typescript`. + +## AT A GLANCE + +- Goal: Automate MFA (Multi-Factor Authentication) completion using TOTP (Time-based One-Time Password) code generation. +- TOTP Generation: Implements RFC 6238 compliant algorithm to generate time-based authentication codes programmatically. +- Automatic Form Filling: Extracts TOTP secrets from pages and automatically fills MFA forms without user interaction. +- Retry Logic: Handles time window edge cases by regenerating codes and retrying authentication when needed. +- Docs โ†’ https://docs.stagehand.dev/v4/basics/act + +## GLOSSARY + +- act: perform UI actions from a prompt (type, click, fill forms) + Docs โ†’ https://docs.stagehand.dev/v4/basics/act +- extract: extract structured data from web pages using natural language instructions + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- TOTP: Time-based One-Time Password - a 6-digit code that changes every 30 seconds, generated using HMAC-SHA1 algorithm +- RFC 6238: Standard specification for TOTP authentication codes used by Google Authenticator, Authy, and other authenticator apps + +## QUICKSTART + +1. pnpm install +2. cp .env.example .env +3. Add your Browserbase API key to .env (BROWSERBASE_API_KEY) +4. pnpm start + +## EXPECTED OUTPUT + +- Initializes Stagehand session with Browserbase +- Retries with a fresh TOTP code when the first authentication attempt fails +- Navigates to TOTP challenge demo page (authenticationtest.com/totpChallenge/) +- Extracts test credentials (email, password) and TOTP secret from the page +- Generates TOTP code using RFC 6238 algorithm +- Fills in email and password fields +- Fills in TOTP code field with generated code +- Submits authentication form +- Checks authentication result +- Retries with fresh code if initial attempt fails (handles time window edge cases) +- Closes session cleanly + +## COMMON PITFALLS + +- Dependency install errors: ensure pnpm install completed +- Missing credentials: verify .env contains BROWSERBASE_API_KEY +- TOTP code expiration: codes are valid for 30 seconds - if authentication fails, the script automatically retries with a fresh code +- Page structure changes: if the demo site structure changes, extraction may fail +- Network timeouts: ensure stable internet connection for reliable page loading +- Find more information on your Browserbase dashboard -> https://www.browserbase.com/sign-in + +## USE CASES + +โ€ข Automated authentication: Complete MFA challenges automatically when session persistence isn't enough (session expired, new device, etc.) +โ€ข TOTP integration: Store encrypted TOTP secrets during user onboarding and generate codes programmatically when needed +โ€ข Zero-touch MFA: Eliminate user interaction for MFA completion in automated workflows +โ€ข Session recovery: Automatically handle MFA prompts when re-authenticating expired sessions + +## NEXT STEPS + +โ€ข Secure storage: Implement encrypted TOTP secret storage (AES-256) in your database during user onboarding +โ€ข Multiple time windows: Add support for trying ยฑ1 time window (60s range) if current code fails +โ€ข SMS/Email MFA: Extend to support SMS codes (via Twilio/Bandwidth API) or email codes (via Gmail API/IMAP) +โ€ข Backup codes: Implement fallback to backup codes stored during initial MFA setup +โ€ข Context integration: Combine with Browserbase Contexts to minimize MFA prompts (95% context reuse, 4% auto TOTP, 1% user-mediated) +โ€ข Error handling: Add graceful fallback to user prompts when automation fails + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com diff --git a/packages/examples/mfa-handling/typescript/index.ts b/packages/examples/mfa-handling/typescript/index.ts new file mode 100644 index 0000000000..d7737ea48b --- /dev/null +++ b/packages/examples/mfa-handling/typescript/index.ts @@ -0,0 +1,206 @@ +// Stagehand + Browserbase: MFA Handling - TOTP Automation - See README.md for full documentation + +import "dotenv/config"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod/v4"; +import crypto from "crypto"; + +// Demo site URL for TOTP challenge testing +const DEMO_URL = "https://authenticationtest.com/totpChallenge/"; + +// Generate TOTP code (Time-based One-Time Password) using RFC 6238 compliant algorithm +// Same algorithm used by Google Authenticator, Authy, and other authenticator apps +function generateTOTP(secret: string, window = 0): string { + // Convert base32 secret to buffer + const base32chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let bits = ""; + let hex = ""; + + secret = secret.toUpperCase().replace(/=+$/, ""); + + for (let i = 0; i < secret.length; i++) { + const val = base32chars.indexOf(secret.charAt(i)); + if (val === -1) throw new Error("Invalid base32 character in secret"); + bits += val.toString(2).padStart(5, "0"); + } + + for (let i = 0; i + 4 <= bits.length; i += 4) { + const chunk = bits.substr(i, 4); + hex += parseInt(chunk, 2).toString(16); + } + + const secretBuffer = Buffer.from(hex, "hex"); + + // Get current time window (30 second intervals) + const time = Math.floor(Date.now() / 1000 / 30) + window; + const timeBuffer = Buffer.alloc(8); + timeBuffer.writeBigInt64BE(BigInt(time)); + + // Generate HMAC-SHA1 hash + const hmac = crypto.createHmac("sha1", secretBuffer); + hmac.update(timeBuffer); + const hmacResult = hmac.digest(); + + // Dynamic truncation to extract 6-digit code + const offset = hmacResult[hmacResult.length - 1] & 0xf; + const code = + ((hmacResult[offset] & 0x7f) << 24) | + ((hmacResult[offset + 1] & 0xff) << 16) | + ((hmacResult[offset + 2] & 0xff) << 8) | + (hmacResult[offset + 3] & 0xff); + + // Return 6-digit code with leading zeros + return (code % 1000000).toString().padStart(6, "0"); +} + +async function main() { + console.log("Starting MFA Handling - TOTP Automation..."); + + // Initialize Stagehand with Browserbase for cloud-based browser automation + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + }); + const stagehand = await Stagehand.create({ + browser: browser, + model: { modelName: "google/gemini-2.5-flash" }, + logging: { level: "error" }, + }); + + try { + // Initialize browser session to start automation + + console.log("Stagehand initialized successfully!"); + const page = (await browser.context.pages())[0]; + + // Navigate to TOTP challenge demo page + console.log("Navigating to TOTP Challenge page..."); + await page.goto(DEMO_URL, { + waitUntil: "domcontentloaded", + }); + + // Extract test credentials and TOTP secret from the page + console.log("Extracting test credentials and TOTP secret..."); + const { data: credentials } = await stagehand.extract( + "Extract the test email, password, and TOTP secret key shown on the page", + z.object({ + email: z.string(), + password: z.string(), + totpSecret: z.string().describe("The TOTP secret key for generating codes"), + }), + ); + + console.log(`Credentials extracted - Email: ${credentials.email}`); + + // Fill in login form with email and password + console.log("Filling in email..."); + await stagehand.act(`Type '${credentials.email}' into the email field`); + + console.log("Filling in password..."); + await stagehand.act(`Type '${credentials.password}' into the password field`); + + // Generate the short-lived code only after the slower semantic actions. + let secondsLeft = 30 - (Math.floor(Date.now() / 1000) % 30); + if (secondsLeft < 12) { + console.log(`Waiting ${secondsLeft + 1} seconds for a fresh TOTP window...`); + await page.waitForTimeout((secondsLeft + 1) * 1000); + } + const totpCode = generateTOTP(credentials.totpSecret); + secondsLeft = 30 - (Math.floor(Date.now() / 1000) % 30); + console.log(`Generated TOTP code: ${totpCode} (valid for ${secondsLeft} seconds)`); + + // Fill in TOTP code + console.log("Filling in TOTP code..."); + await stagehand.act(`Type '${totpCode}' into the TOTP code field`); + + // Submit the form + console.log("Submitting form..."); + await stagehand.act("Click the submit or login button"); + + // Wait for response - be tolerant of sites that never reach full "networkidle" + try { + console.log("Waiting for page to finish loading after submit..."); + await page.waitForLoadState("networkidle", 15000); + } catch (err) { + console.warn( + "Timed out waiting for 'networkidle' after submit; continuing because the login likely succeeded.", + err, + ); + } + + // Check if login succeeded + console.log("Checking authentication result..."); + const { data: result } = await stagehand.extract( + "Check if the login was successful or if there's an error message", + z.object({ + success: z.boolean(), + message: z.string(), + }), + ); + + if (result.success) { + console.log("SUCCESS! TOTP authentication completed automatically!"); + console.log("Authentication Result:", JSON.stringify(result, null, 2)); + } else { + console.log("Authentication may have failed. Message:", result.message); + console.log("Retrying with a fresh TOTP code..."); + + // Regenerate and retry with new code (handles time window edge cases) + // A failed submission navigates to a separate failure page, so return to + // the challenge before filling and submitting a fresh code. + await page.goto(DEMO_URL, { waitUntil: "domcontentloaded" }); + + secondsLeft = 30 - (Math.floor(Date.now() / 1000) % 30); + if (secondsLeft < 8) { + console.log(`Waiting ${secondsLeft + 1} seconds for a fresh TOTP window...`); + await page.waitForTimeout((secondsLeft + 1) * 1000); + } + + await stagehand.act(`Type '${credentials.email}' into the email field`); + await stagehand.act(`Type '${credentials.password}' into the password field`); + + const newCode = generateTOTP(credentials.totpSecret); + console.log(`New TOTP code: ${newCode}`); + await stagehand.act(`Type '${newCode}' into the TOTP code field`); + await stagehand.act("Click the submit or login button"); + + try { + console.log("Waiting for page to finish loading after retry submit..."); + await page.waitForLoadState("networkidle", 15000); + } catch (err) { + console.warn( + "Timed out waiting for 'networkidle' after retry submit; continuing because the login likely succeeded.", + err, + ); + } + + const { data: retryResult } = await stagehand.extract( + "Check if the login was successful", + z.boolean(), + ); + + if (retryResult) { + console.log("Success on retry!"); + } else { + throw new Error("Authentication failed after retry"); + } + } + } catch (error) { + console.error("Error during MFA handling:", error); + throw error; + } finally { + // Always close session to release resources and clean up + await stagehand.close().catch((error) => console.warn("Stagehand cleanup warning:", error)); + await browser.close().catch((error) => console.warn("Browser cleanup warning:", error)); + console.log("Session closed successfully"); + } +} + +main().catch((err) => { + console.error("Error in MFA handling:", err); + console.error("Common issues:"); + console.error(" - Check .env file has BROWSERBASE_API_KEY"); + console.error(" - TOTP code may have expired (try running again)"); + console.error(" - Page structure may have changed"); + console.error("Docs: https://docs.stagehand.dev/v4/first-steps/introduction"); + process.exit(1); +}); diff --git a/packages/examples/mfa-handling/typescript/package.json b/packages/examples/mfa-handling/typescript/package.json new file mode 100644 index 0000000000..81563dc3ae --- /dev/null +++ b/packages/examples/mfa-handling/typescript/package.json @@ -0,0 +1,35 @@ +{ + "name": "mfa-handling-template", + "version": "1.0.0", + "description": "Stagehand + Browserbase: MFA Handling - TOTP Automation", + "keywords": [ + "2fa", + "automation", + "browserbase", + "mfa", + "stagehand", + "totp" + ], + "license": "MIT", + "author": "", + "type": "module", + "main": "index.ts", + "scripts": { + "start": "tsx index.ts", + "dev": "tsx watch index.ts" + }, + "dependencies": { + "@browserbasehq/stagehand": "4.0.0", + "dotenv": "^16.0.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.7.0", + "typescript": "^5.3.0" + }, + "engines": { + "node": ">=22.18.0" + }, + "packageManager": "pnpm@10.24.0" +} diff --git a/packages/examples/mongodb/README.md b/packages/examples/mongodb/README.md new file mode 100644 index 0000000000..689665fe00 --- /dev/null +++ b/packages/examples/mongodb/README.md @@ -0,0 +1,216 @@ +# Browserbase + Stagehand MongoDB Integration + +Location in the Stagehand repository: `packages/examples/mongodb`. + +A comprehensive web scraping integration that uses Stagehand to extract structured data from e-commerce websites and store it in MongoDB for analysis. Available in both **Python** and **TypeScript**. + +## ๐Ÿš€ Choose Your Language + + + + + + +
+ +### ๐Ÿ **Python Version** + +**`๐Ÿ“ python/`** + +Perfect for data scientists and Python developers who want: + +- **Rich terminal output** with beautiful tables and progress indicators +- **Pydantic models** for robust data validation +- **Async/await** support for high-performance scraping +- **pymongo** for MongoDB operations +- Simple single-file architecture + +**[โ†’ Get Started with Python](python/README.md)** + +```bash +cd python/ +pip install -r requirements.txt +python main.py +``` + + + +### ๐Ÿ“˜ **TypeScript Version** + +**`๐Ÿ“ typescript/`** + +Ideal for JavaScript/Node.js developers who prefer: + +- **Type safety** with full TypeScript support +- **Zod schemas** for runtime validation +- **Modern ES modules** and clean architecture +- **MongoDB native driver** with full typing +- Modular, well-structured codebase + +**[โ†’ Get Started with TypeScript](typescript/README.md)** + +```bash +cd typescript/ +npm install +npm start +``` + +
+ +## ๐ŸŒŸ Features (Both Versions) + +- **๐ŸŒ Intelligent Web Scraping**: Uses Stagehand's AI-powered extraction +- **๐Ÿ—„๏ธ MongoDB Storage**: Persistent data storage with proper indexing +- **๐Ÿ“Š Data Analysis**: Built-in queries and reporting +- **๐Ÿ›ก๏ธ Error Handling**: Robust error handling and recovery +- **โšก Performance**: Optimized for speed and reliability +- **๐Ÿ” Schema Validation**: Type-safe data models + +## ๐Ÿ“‹ What It Does + +Both versions perform the same core functionality: + +1. **๐Ÿ”Œ Connect** to MongoDB and set up collections with proper indexes +2. **๐Ÿ“Š Scrape** Amazon product listings using Stagehand's AI extraction +3. **๐Ÿ” Extract** detailed product information including: + - Product names, prices, ratings + - Categories, descriptions, specifications + - Review counts and availability +4. **๐Ÿ’พ Store** all data in MongoDB with validated schemas +5. **๐Ÿ“ˆ Analyze** the data with built-in reporting: + - Collection statistics + - Products by category + - Top-rated products + +## ๐Ÿ› ๏ธ Prerequisites + +**For Both Versions:** + +- MongoDB installed locally or MongoDB Atlas account +- Stagehand API key + +**Python Version:** + +- Python 3.8+ + +**TypeScript Version:** + +- Node.js 16+ +- npm or pnpm + +## ๐Ÿšฆ Quick Start + +### Python Quick Start + +```bash +# Navigate to Python version +cd packages/examples/mongodb/python + +# Install dependencies +pip install -r requirements.txt + +# Set up environment +cp env.example .env +# Edit .env with your MongoDB URI and Stagehand API key + +# Run the scraper +python main.py +``` + +### TypeScript Quick Start + +```bash +# Navigate to TypeScript version +cd packages/examples/mongodb/typescript + +# Install dependencies +npm install + +# Set up environment +cp .env.example .env +# Edit .env with your MongoDB URI and Stagehand API key + +# Run the scraper +npm start +``` + +## ๐Ÿ“Š Sample Output + +Both versions provide rich, colorful output showing the scraping progress: + +``` +๐Ÿค˜ Welcome to Stagehand MongoDB Scraper! + +๐Ÿ”Œ Connecting to MongoDB... +โœ… Connected to MongoDB +โš™๏ธ Creating indexes... +โœ… Index creation completed + +๐Ÿ“Š Starting to scrape product listing... +โœ… Scraped 16 products from category: Laptops + +๐Ÿ“Š Scraping details for product 1/3: MacBook Pro M3 +โœ… Scraped detailed information for: MacBook Pro M3 + +๐Ÿ“Š Running Data Analysis +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Collection โ”‚ Count โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ PRODUCTS โ”‚ 19 โ”‚ +โ”‚ PRODUCT_LISTS โ”‚ 1 โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +๐ŸŽ‰ Scraping completed successfully! +``` + +## ๐Ÿ—๏ธ Architecture + +Both versions follow the same architectural patterns: + +- **MongoDB Manager**: Handles database connections, indexing, and operations +- **Product Scraper**: Manages web scraping using Stagehand +- **Data Models**: Structured schemas for products and product lists +- **Data Analyzer**: Provides insights and reporting on collected data + +## ๐Ÿ”ง Configuration + +Both versions support: + +- **Browserbase** cloud browsers for scalability +- **Environment-based** configuration +- **Flexible MongoDB** connection options + +## ๐Ÿ“š Documentation + +- **[Python Version Documentation](python/README.md)** - Detailed Python setup and usage +- **[TypeScript Version Documentation](typescript/README.md)** - Complete TypeScript guide +- **[Stagehand Documentation](https://docs.stagehand.dev/)** - Learn more about Stagehand +- **[MongoDB Documentation](https://docs.mongodb.com/)** - MongoDB setup and operations + +## ๐Ÿค Contributing + +Both versions are actively maintained and welcome contributions: + +- Bug reports and feature requests +- Code improvements and optimizations +- Documentation enhancements +- Additional data analysis features + +## ๐Ÿ“„ License + +MIT License - feel free to use in your projects! + +## ๐Ÿ™ Acknowledgements + +- **[Stagehand](https://docs.stagehand.dev/)** - AI-powered web scraping +- **[MongoDB](https://www.mongodb.com/)** - Flexible document database +- **[Pydantic](https://pydantic.dev/)** (Python) - Data validation +- **[Zod](https://zod.dev/)** (TypeScript) - Schema validation + +--- + +## ๐Ÿค˜ Ready to Start? + +Choose your preferred language and dive in: + +**๐Ÿ [Python Version โ†’](python/README.md)** | **๐Ÿ“˜ [TypeScript Version โ†’](typescript/README.md)** diff --git a/packages/examples/mongodb/python/.gitignore b/packages/examples/mongodb/python/.gitignore new file mode 100644 index 0000000000..df099fc3f5 --- /dev/null +++ b/packages/examples/mongodb/python/.gitignore @@ -0,0 +1,2 @@ +.env +/venv \ No newline at end of file diff --git a/packages/examples/mongodb/python/README.md b/packages/examples/mongodb/python/README.md new file mode 100644 index 0000000000..6a1e301ef3 --- /dev/null +++ b/packages/examples/mongodb/python/README.md @@ -0,0 +1,245 @@ +# Stagehand MongoDB Scraper (Python) + +Location in the Stagehand repository: `packages/examples/mongodb/python`. + +A Python web scraping project that uses Stagehand to extract structured data from e-commerce websites and store it in MongoDB for analysis. + +## Features + +- **๐ŸŒ Web Scraping**: Uses Stagehand (built on Playwright) for intelligent web scraping +- **๐Ÿง  AI-Powered Extraction**: Extracts structured product data using AI-powered instructions +- **๐Ÿ—„๏ธ MongoDB Storage**: Stores scraped data in MongoDB for persistence and querying +- **โœ… Schema Validation**: Uses Pydantic for schema validation and type safety +- **๐Ÿ›ก๏ธ Error Handling**: Robust error handling to prevent crashes during scraping +- **๐Ÿ“Š Data Analysis**: Built-in MongoDB queries for data analysis with beautiful tables +- **๐ŸŽจ Rich Output**: Colorful console output with progress indicators + +## Prerequisites + +- Python 3.8 or higher +- MongoDB installed locally or MongoDB Atlas account +- Stagehand API key + +## Installation + +1. Navigate to the Python directory: + + ```bash + cd packages/examples/mongodb/python + ``` + +2. Install dependencies: + + ```bash + pip install -r requirements.txt + ``` + +3. Set up environment variables: + + ```bash + # Copy the example environment file + cp env.example .env + + # Edit .env with your actual values + # MONGO_URI=mongodb://localhost:27017 + # DB_NAME=scraper_db + # STAGEHAND_API_KEY=your_stagehand_api_key_here + ``` + +## Usage + +1. Start MongoDB locally: + + ```bash + mongod + ``` + +2. Run the scraper: + + ```bash + python main.py + ``` + +3. The script will: + - ๐Ÿ”Œ Connect to MongoDB and create necessary indexes + - ๐Ÿ“Š Scrape product listings from Amazon laptops category + - ๐Ÿ” Extract detailed information for the first 3 products + - ๐Ÿ’พ Store all data in MongoDB with proper schemas + - ๐Ÿ“ˆ Run analysis queries showing: + - Collection document counts + - Products grouped by category + - Top-rated products (4+ stars) + +## Project Structure + +``` +python/ +โ”œโ”€โ”€ main.py # Main application with all functionality +โ”œโ”€โ”€ requirements.txt # Python dependencies +โ”œโ”€โ”€ env.example # Example environment variables +โ””โ”€โ”€ README.md # This file +``` + +## Data Models + +The project uses Pydantic models for data validation: + +### Product Model + +```python +class Product(BaseModel): + url: str + date_scraped: datetime + name: str + price: str + rating: Optional[float] = None + category: Optional[str] = None + id: Optional[str] = None + currency: Optional[str] = None + image_url: Optional[str] = None + review_count: Optional[int] = None + description: Optional[str] = None + specs: Optional[Dict[str, Any]] = None +``` + +### ProductList Model + +```python +class ProductList(BaseModel): + products: List[Product] + category: Optional[str] = None + date_scraped: datetime + total_products: Optional[int] = None + page: Optional[int] = None + website_name: Optional[str] = None +``` + +## MongoDB Collections + +Data is stored in the following MongoDB collections: + +- **`products`**: Individual product information with indexes on: + - `rating` (ascending) + - `category` (ascending) + - `url` (ascending, unique) + - `date_scraped` (descending) + +- **`product_lists`**: Lists of products from category pages with indexes on: + - `category` (ascending) + - `date_scraped` (descending) + +## Configuration + +The application supports both local and Browserbase environments: + +```python +# Local browser (default) +config = StagehandConfig( + api_key=os.getenv('STAGEHAND_API_KEY'), + env="LOCAL", + verbose=1 +) + +# Browserbase (cloud browsers) +config = StagehandConfig( + api_key=os.getenv('STAGEHAND_API_KEY'), + env="BROWSERBASE", + verbose=1 +) +``` + +## Key Classes + +### MongoDBManager + +Handles all MongoDB operations including: + +- Connection management +- Index creation +- Data storage and retrieval +- Aggregation queries + +### ProductScraper + +Handles web scraping using Stagehand: + +- Product list scraping from category pages +- Detailed product information extraction +- Rate limiting and error handling + +### DataAnalyzer + +Provides data analysis and reporting: + +- Collection statistics +- Category-based analysis +- Top-rated product reports + +## Error Handling + +The application includes comprehensive error handling: + +- MongoDB connection errors +- Web scraping failures +- Data validation errors +- Graceful cleanup on exit + +## Example Output + +``` +๐Ÿค˜ Welcome to Stagehand MongoDB Scraper! + +๐Ÿ”Œ Connecting to MongoDB... +โœ… Connected to MongoDB +โš™๏ธ Creating indexes... +โœ… Created index rating_idx on products +โœ… Index creation completed + +๐Ÿ“Š Starting to scrape product listing from: https://www.amazon.com/s?k=laptops +โœ… Scraped 16 products from category: Laptops + +๐Ÿ“Š Running Data Analysis +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ณโ”โ”โ”โ”โ”โ”โ”โ”“ +โ”ƒ Collection โ”ƒ Count โ”ƒ +โ”กโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ•‡โ”โ”โ”โ”โ”โ”โ”โ”ฉ +โ”‚ PRODUCTS โ”‚ 19 โ”‚ +โ”‚ PRODUCT_LISTS โ”‚ 1 โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +๐ŸŽ‰ Scraping and MongoDB operations completed successfully! +``` + +## Troubleshooting + +### MongoDB Connection Issues + +- Ensure MongoDB is running: `mongod` +- Check connection string in `.env` file +- Verify database permissions + +### Stagehand API Issues + +- Verify API key in `.env` file +- Check Stagehand service status +- Review rate limiting settings + +### Dependencies Issues + +```bash +# Reinstall dependencies +pip install --upgrade -r requirements.txt + +# For Playwright browser issues +playwright install +``` + +## License + +MIT + +## Acknowledgements + +- [Stagehand](https://docs.stagehand.dev/) - Powerful web scraping with AI +- [MongoDB](https://www.mongodb.com/) - Flexible document database +- [Pydantic](https://pydantic.dev/) - Data validation using Python type hints +- [Rich](https://rich.readthedocs.io/) - Beautiful terminal output diff --git a/packages/examples/mongodb/python/env.example b/packages/examples/mongodb/python/env.example new file mode 100644 index 0000000000..68d9c06210 --- /dev/null +++ b/packages/examples/mongodb/python/env.example @@ -0,0 +1,10 @@ +# MongoDB Configuration +MONGO_URI=mongodb://localhost:27017 +DB_NAME=scraper_db + +# Stagehand Configuration +MODEL_API_KEY=your_model_api_key_here + +# Optional: Browserbase Configuration (if using BROWSERBASE env) +BROWSERBASE_API_KEY=your_browserbase_api_key_here +BROWSERBASE_PROJECT_ID=your_browserbase_project_id_here \ No newline at end of file diff --git a/packages/examples/mongodb/python/main.py b/packages/examples/mongodb/python/main.py new file mode 100644 index 0000000000..daf752e2d0 --- /dev/null +++ b/packages/examples/mongodb/python/main.py @@ -0,0 +1,577 @@ +import os +import asyncio +import logging +from datetime import datetime +from typing import List, Dict, Any, Optional + +from pydantic import BaseModel +from pymongo import MongoClient, IndexModel, ASCENDING, DESCENDING +from pymongo.errors import DuplicateKeyError +from stagehand import Stagehand +from stagehand.schemas import AvailableModel +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Initialize rich console for better output +console = Console() + +# ========== MongoDB Configuration ========== +MONGO_URI = os.getenv('MONGO_URI', 'mongodb://localhost:27017') +DB_NAME = os.getenv('DB_NAME', 'scraper_db') + +# ========== Pydantic Models (Schema Definitions) ========== +class Product(BaseModel): + """Product model for e-commerce websites""" + url: str + date_scraped: datetime + name: str + price: str + rating: Optional[float] = None + category: Optional[str] = None + id: Optional[str] = None + currency: Optional[str] = None + image_url: Optional[str] = None + review_count: Optional[int] = None + description: Optional[str] = None + specs: Optional[Dict[str, Any]] = None + +class ProductList(BaseModel): + """Product list model for results from category pages""" + products: List[Product] + category: Optional[str] = None + date_scraped: datetime + total_products: Optional[int] = None + page: Optional[int] = None + website_name: Optional[str] = None + +# Schema for extraction (without date_scraped since that's added later) +class ProductExtraction(BaseModel): + """Schema for extracting product data from pages""" + name: str + price: str + url: str + rating: Optional[float] = None + reviewCount: Optional[int] = None + description: Optional[str] = None + specs: Optional[Dict[str, Any]] = None + +class ProductListExtraction(BaseModel): + """Schema for extracting product list data from category pages""" + products: List[ProductExtraction] + category: str + totalProducts: Optional[int] = None + + +# ========== MongoDB Connection and Operations ========== +class MongoDBManager: + """Handles MongoDB connections and operations""" + + def __init__(self, uri: str, db_name: str): + self.uri = uri + self.db_name = db_name + self.client = None + self.db = None + + # Collection names + self.COLLECTIONS = { + 'PRODUCTS': 'products', + 'PRODUCT_LISTS': 'product_lists' + } + + # Index definitions + self.INDEXES = { + self.COLLECTIONS['PRODUCTS']: [ + IndexModel([("rating", ASCENDING)], name="rating_idx"), + IndexModel([("category", ASCENDING)], name="category_idx"), + IndexModel([("url", ASCENDING)], name="url_idx", unique=True), + IndexModel([("date_scraped", DESCENDING)], name="date_scraped_idx") + ], + self.COLLECTIONS['PRODUCT_LISTS']: [ + IndexModel([("category", ASCENDING)], name="category_idx"), + IndexModel([("date_scraped", DESCENDING)], name="date_scraped_idx") + ] + } + + async def connect(self): + """Connect to MongoDB""" + try: + console.print("๐Ÿ”Œ Connecting to MongoDB...", style="blue") + self.client = MongoClient(self.uri) + + # Test connection + self.client.admin.command('ismaster') + self.db = self.client[self.db_name] + + console.print("โœ… Connected to MongoDB", style="green") + + # Create indexes + await self._create_indexes() + + except Exception as e: + console.print(f"โŒ Error connecting to MongoDB: {e}", style="red") + raise + + async def _create_indexes(self): + """Create indexes for all collections""" + console.print("โš™๏ธ Creating indexes...", style="blue") + + for collection_name, indexes in self.INDEXES.items(): + try: + collection = self.db[collection_name] + + # Create indexes + for index in indexes: + try: + collection.create_index( + index.document['key'], + name=index.document.get('name'), + unique=index.document.get('unique', False), + background=True + ) + console.print(f"โœ… Created index {index.document.get('name')} on {collection_name}", style="green") + except DuplicateKeyError: + console.print(f"โš ๏ธ Index {index.document.get('name')} already exists on {collection_name}", style="yellow") + + except Exception as e: + console.print(f"โŒ Error creating indexes for {collection_name}: {e}", style="red") + + console.print("โœ… Index creation completed", style="green") + + async def store_data(self, collection_name: str, data): + """Store data in MongoDB collection""" + try: + collection = self.db[collection_name] + + if isinstance(data, list): + # Check if list is empty + if not data: + console.print(f"โš ๏ธ No data to store in {collection_name} (empty list)", style="yellow") + return + + # Convert Pydantic models to dict (using model_dump for Pydantic v2) + documents = [item.model_dump() if hasattr(item, 'model_dump') else item for item in data] + + # Handle duplicate key errors gracefully + try: + result = collection.insert_many(documents, ordered=False) + console.print(f"โœ… Stored {len(result.inserted_ids)} documents in {collection_name}", style="green") + except DuplicateKeyError as e: + # Count successful inserts + inserted = len(documents) - len(e.details.get('writeErrors', [])) + if inserted > 0: + console.print(f"โœ… Stored {inserted} new documents in {collection_name} (skipped {len(e.details.get('writeErrors', []))} duplicates)", style="green") + else: + console.print(f"โš ๏ธ All {len(documents)} documents already exist in {collection_name}", style="yellow") + else: + # Convert Pydantic model to dict (using model_dump for Pydantic v2) + document = data.model_dump() if hasattr(data, 'model_dump') else data + + # Handle duplicate key errors gracefully + try: + result = collection.insert_one(document) + console.print(f"โœ… Stored document in {collection_name}", style="green") + except DuplicateKeyError: + console.print(f"โš ๏ธ Document already exists in {collection_name} (skipped duplicate)", style="yellow") + + except DuplicateKeyError: + # Already handled above + pass + except Exception as e: + console.print(f"โŒ Error storing data in {collection_name}: {e}", style="red") + raise + + async def find_data(self, collection_name: str, query: Dict = None): + """Find documents in MongoDB collection""" + try: + collection = self.db[collection_name] + query = query or {} + documents = list(collection.find(query)) + return documents + except Exception as e: + console.print(f"โŒ Error finding data in {collection_name}: {e}", style="red") + raise + + async def aggregate_data(self, collection_name: str, pipeline: List[Dict]): + """Aggregate data in MongoDB collection""" + try: + collection = self.db[collection_name] + results = list(collection.aggregate(pipeline)) + return results + except Exception as e: + console.print(f"โŒ Error aggregating data in {collection_name}: {e}", style="red") + raise + + async def get_collection_count(self, collection_name: str) -> int: + """Get document count for a collection""" + try: + collection = self.db[collection_name] + return collection.count_documents({}) + except Exception as e: + console.print(f"โŒ Error getting count for {collection_name}: {e}", style="red") + return 0 + + def close(self): + """Close MongoDB connection""" + if self.client: + self.client.close() + console.print("๐Ÿ”Œ MongoDB connection closed", style="blue") + +# ========== Web Scraping Functions ========== +class ProductScraper: + """Handles web scraping operations using Stagehand""" + + def __init__(self, stagehand: Stagehand, mongodb: MongoDBManager): + self.stagehand = stagehand + self.page = stagehand.page + self.mongodb = mongodb + + async def scrape_product_list(self, category_url: str) -> ProductList: + """Scrape a product list from an Amazon category page""" + console.print(f"๐Ÿ“Š Starting to scrape product listing from: {category_url}", style="blue") + + # Navigate to Amazon homepage first + await self.page.goto('https://www.amazon.com') + await self.page.wait_for_timeout(2000) + + # Then navigate to the category page + await self.page.goto(category_url) + + # Wait for products to load + await self.page.wait_for_selector('[data-component-type="s-search-result"]', timeout=10000) + await self.page.wait_for_timeout(2000) + + # Scroll to load more products + await self.page.evaluate(""" + () => { + window.scrollTo(0, document.body.scrollHeight / 2); + } + """) + await self.page.wait_for_timeout(1000) + + await self.page.evaluate(""" + () => { + window.scrollTo(0, document.body.scrollHeight); + } + """) + await self.page.wait_for_timeout(1000) + + # Extract product data using Stagehand with better error handling + console.print("๐Ÿ” Extracting product data with AI...", style="blue") + + try: + # Use Pydantic BaseModel schema as per documentation + extraction_result = await self.page.extract( + """Extract all product information from this Amazon category page. For each product, extract: + - name: ONLY the main product title/name (e.g., "HP 14 Laptop" or "Dell Inspiron 15") + - price: The current price (if available) + - url: The actual clickable link/href to the product detail page (NOT the product name) + - rating: The star rating (if available) + - reviewCount: Number of reviews (if available) + - description: The detailed product description with features, specifications, and details (e.g., "Intel Celeron N4020, 4 GB RAM, 64 GB Storage, 14-inch Micro-edge HD Display, Windows 11 Home, Thin & Portable, 4K Graphics, One Year of Microsoft 365") + - specs: Technical specifications as a dictionary/object with key-value pairs like {"RAM": "16GB", "Storage": "512GB SSD", "Processor": "Intel i7", "Screen": "15.6 inch"} (if available) + + IMPORTANT: + - The 'url' field must contain the actual product link/href, not the product name. + - The 'name' field should be SHORT and concise (just the brand and model). + - The 'description' field should contain the DETAILED product information, features, and specifications. + - For 'specs', extract any technical details you can see and structure them as key-value pairs. + - DO NOT put promotional text like "1K+ bought" or "Limited time deal" in the description field.""", + schema=ProductListExtraction + ) + + # Handle the result - should be a ProductListExtraction object directly + if isinstance(extraction_result, ProductListExtraction): + extraction_data = extraction_result + console.print(f"โœ… Extraction successful: {len(extraction_result.products)} products found", style="green") + elif hasattr(extraction_result, 'data'): + # Debug: print the raw data to understand what we're getting + console.print(f"๐Ÿ” DEBUG: Raw data type: {type(extraction_result.data)}", style="cyan") + console.print(f"๐Ÿ” DEBUG: Raw data (first 300 chars): {str(extraction_result.data)[:300]}...", style="cyan") + + # Check if data is a string that needs parsing or if it's the raw data we need + if isinstance(extraction_result.data, str): + try: + import json + parsed_data = json.loads(extraction_result.data) + # Create ProductListExtraction from parsed JSON + extraction_data = ProductListExtraction(**parsed_data) + console.print(f"โœ… Extraction successful (parsed JSON): {len(extraction_data.products)} products found", style="green") + except (json.JSONDecodeError, Exception) as e: + console.print(f"โš ๏ธ Failed to parse JSON extraction data: {e}", style="yellow") + extraction_data = ProductListExtraction(products=[], category="Unknown") + elif isinstance(extraction_result.data, ProductListExtraction): + extraction_data = extraction_result.data + console.print(f"โœ… Extraction successful: {len(extraction_result.data.products)} products found", style="green") + elif isinstance(extraction_result.data, dict): + # Try to create ProductListExtraction from dict + try: + extraction_data = ProductListExtraction(**extraction_result.data) + console.print(f"โœ… Extraction successful (from dict): {len(extraction_data.products)} products found", style="green") + except Exception as e: + console.print(f"โš ๏ธ Failed to create ProductListExtraction from dict: {e}", style="yellow") + extraction_data = ProductListExtraction(products=[], category="Unknown") + else: + console.print(f"โš ๏ธ Unexpected data type: {type(extraction_result.data)}", style="yellow") + extraction_data = ProductListExtraction(products=[], category="Unknown") + else: + console.print("โš ๏ธ Extraction completed but no products found", style="yellow") + extraction_data = ProductListExtraction(products=[], category="Unknown") + + except Exception as e: + console.print(f"โš ๏ธ AI extraction failed: {str(e)[:100]}...", style="yellow") + extraction_data = ProductListExtraction(products=[], category="Unknown") + + # Process the extracted data + current_time = datetime.now() + timestamp = int(current_time.timestamp()) + products = [] + + # Handle both ProductListExtraction object and dict formats + if isinstance(extraction_data, ProductListExtraction): + products_list = extraction_data.products + category = extraction_data.category + total_products = extraction_data.totalProducts + else: + products_list = extraction_data.get('products', []) + category = extraction_data.get('category', 'Unknown') + total_products = extraction_data.get('totalProducts') + + for i, product_data in enumerate(products_list): + try: + if isinstance(product_data, ProductExtraction): + # Validate and fix URL if it's actually the product name + extracted_url = product_data.url + if not extracted_url.startswith(('http://', 'https://', '/')): + # If URL doesn't look like a URL, create a proper one + extracted_url = f"{category_url}#product-{i}" + console.print(f"โš ๏ธ Fixed invalid URL for: {product_data.name[:50]}...", style="yellow") + + unique_url = f"{extracted_url}?scraped_at={timestamp}&index={i}" + product = Product( + url=unique_url, + date_scraped=current_time, + name=product_data.name, + price=product_data.price, + rating=product_data.rating, + review_count=product_data.reviewCount, + description=product_data.description, + specs=product_data.specs + ) + else: + # If it's a dictionary, validate and fix URL + base_url = product_data.get('url', category_url) + if not base_url.startswith(('http://', 'https://', '/')): + # If URL doesn't look like a URL, create a proper one + base_url = f"{category_url}#product-{i}" + console.print(f"โš ๏ธ Fixed invalid URL for: {product_data.get('name', 'Unknown')[:50]}...", style="yellow") + + unique_url = f"{base_url}?scraped_at={timestamp}&index={i}" + product = Product( + url=unique_url, + date_scraped=current_time, + name=product_data['name'], + price=product_data['price'], + rating=product_data.get('rating'), + review_count=product_data.get('reviewCount'), + description=product_data.get('description'), + specs=product_data.get('specs') + ) + products.append(product) + console.print(f"โœ… Processed: {product.name[:50]}...", style="green") + except Exception as e: + console.print(f"โš ๏ธ Error processing product: {e}", style="yellow") + console.print(f"Product data: {product_data}", style="yellow") + + # Create the product list object + product_list = ProductList( + products=products, + category=category, + date_scraped=current_time, + total_products=total_products or len(products), + website_name="Amazon" + ) + + # Create sample products if extraction failed completely + if not products: + console.print("โš ๏ธ No products were successfully extracted. Creating sample products for demonstration...", style="yellow") + console.print(" โ€ข This might be due to Amazon's anti-bot measures", style="yellow") + console.print(" โ€ข Changes in Amazon's page structure", style="yellow") + console.print(" โ€ข Network issues or timeouts", style="yellow") + console.print(" โ€ข Geographic restrictions", style="yellow") + + # Create sample products for demonstration + sample_products = [ + {"name": "Premium Laptop Pro", "price": "$1,299.99", "rating": 4.5}, + {"name": "Laptop Ultra Performance", "price": "$899.99", "rating": 4.3}, + {"name": "Budget Laptop Essential", "price": "$499.99", "rating": 4.1}, + {"name": "Gaming Laptop Elite", "price": "$1,599.99", "rating": 4.7}, + {"name": "Portable Laptop Lite", "price": "$699.99", "rating": 4.2} + ] + + # Use current timestamp for unique URLs + for i, sample in enumerate(sample_products[:3]): # Create 3 sample products + product = Product( + url=f"{category_url}&sample_product={i+1}&ts={timestamp}", + date_scraped=current_time, + name=sample["name"], + price=sample["price"], + rating=sample["rating"] + ) + products.append(product) + console.print(f"๐Ÿ“ Created sample: {product.name}", style="cyan") + + # Store the data in MongoDB + await self.mongodb.store_data(self.mongodb.COLLECTIONS['PRODUCT_LISTS'], product_list) + if products: # Only store products if we have any + await self.mongodb.store_data(self.mongodb.COLLECTIONS['PRODUCTS'], products) + + console.print(f"โœ… Scraped {len(products)} products from category: {product_list.category}", style="green") + return product_list + + +# ========== Data Analysis Functions ========== +class DataAnalyzer: + """Handles data analysis and reporting""" + + def __init__(self, mongodb: MongoDBManager): + self.mongodb = mongodb + + async def run_analysis(self): + """Run comprehensive data analysis""" + console.print("\n๐Ÿ“Š Running Data Analysis", style="bold blue") + + # 1. Collection counts + await self._show_collection_counts() + + # 2. Products by category + await self._show_products_by_category() + + # 3. Top rated products + await self._show_top_rated_products() + + console.print("\nโœ… Data analysis completed!", style="bold green") + + async def _show_collection_counts(self): + """Show document counts for each collection""" + console.print("\n๐Ÿ“Š Collection Counts:", style="yellow") + + table = Table() + table.add_column("Collection", style="cyan") + table.add_column("Count", style="green") + + for name, collection in self.mongodb.COLLECTIONS.items(): + count = await self.mongodb.get_collection_count(collection) + table.add_row(name, str(count)) + + console.print(table) + + async def _show_products_by_category(self): + """Show products grouped by category""" + console.print("\n๐Ÿ“Š Products by Category:", style="yellow") + + pipeline = [ + {"$group": {"_id": "$category", "count": {"$sum": 1}}}, + {"$sort": {"count": -1}} + ] + + results = await self.mongodb.aggregate_data( + self.mongodb.COLLECTIONS['PRODUCTS'], + pipeline + ) + + if results: + table = Table() + table.add_column("Category", style="cyan") + table.add_column("Count", style="green") + + for item in results: + category = item['_id'] or "Unknown" + count = item['count'] + table.add_row(category, str(count)) + + console.print(table) + else: + console.print("No category data found", style="yellow") + + async def _show_top_rated_products(self): + """Show highest rated products""" + console.print("\n๐Ÿ“Š Top Rated Products (4+ stars):", style="yellow") + + # Count highly rated products + highly_rated = await self.mongodb.find_data( + self.mongodb.COLLECTIONS['PRODUCTS'], + {"rating": {"$gte": 4}} + ) + + console.print(f"Found {len(highly_rated)} highly rated products", style="blue") + + if highly_rated: + table = Table() + table.add_column("Name", style="cyan", max_width=40) + table.add_column("Price", style="green") + table.add_column("Rating", style="yellow") + table.add_column("Category", style="magenta") + + for product in highly_rated[:10]: # Show top 10 + table.add_row( + product.get('name', 'N/A')[:37] + "..." if len(product.get('name', '')) > 40 else product.get('name', 'N/A'), + product.get('price', 'N/A'), + str(product.get('rating', 'N/A')), + product.get('category', 'Unknown') + ) + + console.print(table) + +# ========== Main Application ========== +async def main(): + """Main application function""" + try: + # Initialize MongoDB + mongodb = MongoDBManager(MONGO_URI, DB_NAME) + await mongodb.connect() + + # Initialize Stagehand with proper config overrides + stagehand = Stagehand( + env="BROWSERBASE", + model_name=AvailableModel.CLAUDE_3_7_SONNET_LATEST, + model_api_key=os.getenv("MODEL_API_KEY"), + verbose=1, + dom_settle_timeout_ms=30000 + ) + await stagehand.init() + + # Initialize scraper + scraper = ProductScraper(stagehand, mongodb) + + # Define category URL + category_url = "https://www.amazon.com/s?k=laptops" + + # Scrape product listing + product_list = await scraper.scrape_product_list(category_url) + + + # Run data analysis + analyzer = DataAnalyzer(mongodb) + await analyzer.run_analysis() + + console.print("\n๐ŸŽ‰ Scraping and MongoDB operations completed successfully!", style="bold green") + + except Exception as e: + console.print(f"โŒ Error during execution: {e}", style="red") + raise + finally: + # Cleanup + if 'stagehand' in locals(): + await stagehand.close() + if 'mongodb' in locals(): + mongodb.close() + +# ========== Entry Point ========== +if __name__ == "__main__": + # Run the main function + asyncio.run(main()) \ No newline at end of file diff --git a/packages/examples/mongodb/python/requirements.txt b/packages/examples/mongodb/python/requirements.txt new file mode 100644 index 0000000000..3ca516869c --- /dev/null +++ b/packages/examples/mongodb/python/requirements.txt @@ -0,0 +1,6 @@ +stagehand>=0.3.0 +pymongo>=4.6.0 +pydantic>=2.0.0 +python-dotenv>=1.0.0 +colorama>=0.4.6 +rich>=13.0.0 \ No newline at end of file diff --git a/packages/examples/mongodb/typescript/.env.example b/packages/examples/mongodb/typescript/.env.example new file mode 100644 index 0000000000..f8a6bda7d6 --- /dev/null +++ b/packages/examples/mongodb/typescript/.env.example @@ -0,0 +1,12 @@ +# MongoDB Connection + +# Local MongoDB instance +# MONGO_URI=mongodb://localhost:27017 + +# MongoDB Atlas connection string format: +# MONGO_URI=mongodb+srv://:@.mongodb.net/?retryWrites=true&w=majority + +# Database name +DB_NAME=scraper_db + +BROWSERBASE_API_KEY= diff --git a/packages/examples/mongodb/typescript/.gitignore b/packages/examples/mongodb/typescript/.gitignore new file mode 100644 index 0000000000..b9151e0579 --- /dev/null +++ b/packages/examples/mongodb/typescript/.gitignore @@ -0,0 +1,7 @@ +.env +node_modules +tmp +downloads +.DS_Store +dist +cache.json \ No newline at end of file diff --git a/packages/examples/mongodb/typescript/LICENSE b/packages/examples/mongodb/typescript/LICENSE new file mode 100644 index 0000000000..6d2e8c2114 --- /dev/null +++ b/packages/examples/mongodb/typescript/LICENSE @@ -0,0 +1,7 @@ +Copyright 2025 Browserbase, Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the โ€œSoftwareโ€), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED โ€œAS ISโ€, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/examples/mongodb/typescript/README.md b/packages/examples/mongodb/typescript/README.md new file mode 100644 index 0000000000..b472debdf9 --- /dev/null +++ b/packages/examples/mongodb/typescript/README.md @@ -0,0 +1,104 @@ +# Stagehand MongoDB Scraper + +Location in the Stagehand repository: `packages/examples/mongodb/typescript`. + +A web scraping project that uses Stagehand to extract structured data from e-commerce websites and store it in MongoDB for analysis. + +## Features + +- **Web Scraping**: Uses Stagehand (built on Playwright) for intelligent web scraping +- **Data Extraction**: Extracts structured product data using AI-powered instructions +- **MongoDB Storage**: Stores scraped data in MongoDB for persistence and querying +- **Schema Validation**: Uses Zod for schema validation and TypeScript interfaces +- **Error Handling**: Robust error handling to prevent crashes during scraping +- **Data Analysis**: Built-in MongoDB queries for data analysis + +## Prerequisites + +- Node.js 22.18 or higher +- MongoDB installed locally or MongoDB Atlas account +- Browserbase API key + +## Installation + +1. Clone the repository: + + ``` + cd packages/examples/mongodb/typescript + ``` + +2. Install dependencies: + + ``` + npm install + ``` + +3. Set up environment variables: + ``` + # Create a .env file with the following variables + BROWSERBASE_API_KEY=your_browserbase_api_key + MONGO_URI=mongodb://localhost:27017 + DB_NAME=scraper_db + ``` + +## Usage + +1. Start MongoDB locally: + + ``` + mongod + ``` + +2. Run the scraper: + + ``` + npm start + ``` + +3. The script will: + - Scrape product listings from Amazon + - Extract detailed information for the first 3 products + - Extract reviews for each product + - Store all data in MongoDB + - Run analysis queries on the collected data showing: + - Collection counts + - Products by category + - Top-rated products + +## Project Structure + +The project has a simple structure with a single file containing all functionality: + +- `index.ts`: Contains the complete implementation including: + - MongoDB connection and data operations + - Schema definitions + - Scraping functions + - Data analysis + - Main execution logic +- `.env.example`: Example environment variables + +## Data Models + +The project uses the following data models: + +- **Product**: Individual product information +- **ProductList**: List of products from a category page +- **Review**: Product reviews + +## MongoDB Collections + +Data is stored in the following MongoDB collections: + +- **products**: Individual product information +- **product_lists**: Lists of products from category pages +- **reviews**: Product reviews + +## License + +MIT + +## Acknowledgements + +- [Stagehand](https://docs.stagehand.dev/) for the powerful web scraping capabilities +- [MongoDB](https://www.mongodb.com/) for the flexible document database +- [Zod](https://zod.dev/) for runtime schema validation diff --git a/packages/examples/mongodb/typescript/index.ts b/packages/examples/mongodb/typescript/index.ts new file mode 100644 index 0000000000..db721f9610 --- /dev/null +++ b/packages/examples/mongodb/typescript/index.ts @@ -0,0 +1,539 @@ +import { browserbase, Stagehand, type Page } from "@browserbasehq/stagehand"; +import chalk from "chalk"; +import { z } from "zod/v4"; +import { MongoServerError } from "mongodb"; +import { MongoClient, Db, Document } from "mongodb"; + +/** + * ๐Ÿค˜ Welcome to Stagehand! Thanks so much for trying us out! + * ๐Ÿ“ Check out our docs for more fun use cases, like building agents + * https://docs.stagehand.dev/ + * + * ๐Ÿ’ฌ If you have any feedback, reach out to us on Slack! + * https://stagehand.dev/slack + * + * ๐Ÿ“š You might also benefit from the docs for Zod, Browserbase, and Playwright: + * - https://zod.dev/ + * - https://docs.browserbase.com/ + * - https://playwright.dev/docs/intro + */ + +// ========== MongoDB Connection Configuration ========== +const MONGO_URI = process.env.MONGO_URI || "mongodb://localhost:27017"; +const DB_NAME = process.env.DB_NAME || "scraper_db"; + +let client: MongoClient | null = null; +let db: Db | null = null; + +// ========== Schema Definitions ========== +// Product schema for e-commerce websites +const ProductSchema = z.object({ + url: z.string(), + dateScraped: z.date(), + name: z.string(), + price: z.string(), + rating: z.number().optional(), + category: z.string().optional(), + id: z.string().optional(), + currency: z.string().optional(), + imageUrl: z.string().optional(), + reviewCount: z.number().optional(), + description: z.string().optional(), + specs: z.record(z.string(), z.any()).optional(), +}) satisfies z.ZodType; + +// Product list schema for results from category pages +const ProductListSchema = z.object({ + products: z.array(ProductSchema), + category: z.string().optional(), + dateScraped: z.date(), + totalProducts: z.number().optional(), + page: z.number().optional(), + websiteName: z.string().optional(), +}) satisfies z.ZodType; + +// Types are inferred from the schemas +export type Product = z.infer; +export type ProductList = z.infer; + +// Collection names for MongoDB +const COLLECTIONS = { + PRODUCTS: "products", + PRODUCT_LISTS: "product_lists", +} as const; + +// Index definitions for MongoDB collections +interface IndexDefinition { + key: { [key: string]: number }; + name: string; + unique?: boolean; +} + +const INDEXES = { + [COLLECTIONS.PRODUCTS]: [ + { key: { rating: 1 }, name: "rating_idx" } as IndexDefinition, + { key: { category: 1 }, name: "category_idx" } as IndexDefinition, + { key: { url: 1 }, name: "url_idx", unique: true } as IndexDefinition, + { key: { dateScraped: -1 }, name: "dateScraped_idx" } as IndexDefinition, + ], + [COLLECTIONS.PRODUCT_LISTS]: [ + { key: { category: 1 }, name: "category_idx" } as IndexDefinition, + { key: { dateScraped: -1 }, name: "dateScraped_idx" } as IndexDefinition, + ], +} as const; + +// Check and create indexes for all collections +async function createIndexes(db: Db): Promise { + console.log(chalk.blue("โš™๏ธ Starting index creation...")); + + // First create all collections if they don't exist + for (const collectionName of Object.keys(INDEXES)) { + try { + await db.createCollection(collectionName); + console.log(chalk.green(`โœ… Created collection: ${collectionName}`)); + } catch (error) { + if (error instanceof MongoServerError && error.code === 48) { + console.log(chalk.yellow(`โš ๏ธ Collection ${collectionName} already exists`)); + } else { + console.error(chalk.red(`โŒ Error creating collection ${collectionName}:`), error); + throw error; + } + } + } + + // Now create indexes for each collection + for (const [collectionName, indexes] of Object.entries(INDEXES)) { + console.log(chalk.blue(`โš™๏ธ Processing indexes for collection: ${collectionName}`)); + const collection = db.collection(collectionName); + + for (const index of indexes) { + try { + console.log( + chalk.blue(`โš™๏ธ Creating index ${index.name} on ${collectionName} with keys:`, index.key), + ); + const existingIndexes = await collection.listIndexes().toArray(); + const indexExists = existingIndexes.some((idx) => idx.name === index.name); + + if (indexExists) { + console.log(chalk.yellow(`โš ๏ธ Index ${index.name} already exists on ${collectionName}`)); + } else { + await collection.createIndex(index.key, { + name: index.name, + unique: index.unique || false, + background: false, + }); + console.log(chalk.green(`โœ… Created index ${index.name} on ${collectionName}`)); + } + } catch (error) { + console.error( + chalk.red(`โŒ Error creating index ${index.name} on ${collectionName}:`), + error, + ); + } + } + } + + // Verify indexes were created + for (const [collectionName, indexes] of Object.entries(INDEXES)) { + const collection = db.collection(collectionName); + const existingIndexes = await collection.listIndexes().toArray(); + console.log(chalk.blue(`Indexes for ${collectionName}:`)); + console.log(existingIndexes); + } + + console.log(chalk.green("โœ… Index creation completed")); +} + +// ========== MongoDB Utility Functions ========== +/** + * Connects to MongoDB + */ +async function connectToMongo(): Promise { + if (client) { + console.log("Using existing MongoDB connection"); + return client.db(DB_NAME); + } + + try { + console.log("Connecting to MongoDB..."); + client = new MongoClient(MONGO_URI); + await client.connect(); + + console.log("Connected to MongoDB"); + + // Verify if database exists + const adminDb = client.db("admin"); + const databases = await adminDb.admin().listDatabases(); + const dbExists = + databases.databases?.some((db: { name: string }) => db.name === DB_NAME) ?? false; + + if (!dbExists) { + console.log(chalk.blue(`โš™๏ธ Creating database: ${DB_NAME}`)); + // Create a collection to trigger database creation + const db = client.db(DB_NAME); + await db.createCollection(COLLECTIONS.PRODUCTS); + console.log(chalk.green(`โœ… Database ${DB_NAME} created successfully`)); + } else { + console.log(chalk.yellow(`โš ๏ธ Database ${DB_NAME} already exists`)); + } + + const db = client.db(DB_NAME); + + // Create indexes for all collections + console.log("Creating indexes..."); + await createIndexes(db); + console.log("Indexes created successfully"); + + return db; + } catch (error) { + console.error("Error connecting to MongoDB:", error); + throw error; + } +} + +/** + * Closes the MongoDB connection + */ +async function closeMongo(): Promise { + if (client) { + await client.close(); + console.log("MongoDB connection closed"); + client = null; + db = null; + } +} + +/** + * Stores data in a MongoDB collection + */ +async function storeData(collectionName: string, data: T | T[]): Promise { + const db = await connectToMongo(); + + // Ensure collection exists + try { + await db.createCollection(collectionName); + console.log(chalk.green(`โœ… Created collection: ${collectionName}`)); + } catch (error) { + if (error instanceof MongoServerError && error.code === 48) { + console.log(chalk.yellow(`โš ๏ธ Collection ${collectionName} already exists`)); + } else { + console.error(chalk.red(`โŒ Error creating collection ${collectionName}:`), error); + throw error; + } + } + + const collection = db.collection(collectionName); + + try { + if (Array.isArray(data)) { + await collection.insertMany(data as Document[]); + } else { + await collection.insertOne(data as Document); + } + console.log(chalk.green(`โœ… Stored data in ${collectionName}`)); + } catch (error) { + console.error(chalk.red(`โŒ Error storing data in ${collectionName}:`), error); + throw error; + } +} + +/** + * Finds documents in a MongoDB collection + */ +async function findData(collectionName: string, query = {}): Promise { + const database = await connectToMongo(); + const collection = database.collection(collectionName); + + try { + const documents = await collection.find(query).toArray(); + return documents as T[]; + } catch (error) { + console.error(`Error finding data in ${collectionName}:`, error); + throw error; + } +} + +/** + * Aggregates data in a MongoDB collection + */ +async function aggregateData(collectionName: string, pipeline: object[]): Promise { + const database = await connectToMongo(); + const collection = database.collection(collectionName); + + try { + const results = await collection.aggregate(pipeline).toArray(); + return results as T[]; + } catch (error) { + console.error(`Error aggregating data in ${collectionName}:`, error); + throw error; + } +} + +// ========== Scraping Functions ========== +/** + * Scrapes a product list from an Amazon category page + */ +async function scrapeProductList( + page: Page, + categoryUrl: string, + stagehand: Stagehand, +): Promise { + // Navigate to Amazon homepage first + await page.goto("https://www.amazon.com"); + await page.waitForTimeout(2000); + + // Then navigate to the category page + await page.goto(categoryUrl); + + // Wait for products to load + await page.waitForSelector('[data-component-type="s-search-result"]', { timeout: 10000 }); + await page.waitForTimeout(2000); + + // Scroll to load more products + await page.evaluate(() => { + window.scrollTo(0, document.body.scrollHeight / 2); + }); + await page.waitForTimeout(1000); + await page.evaluate(() => { + window.scrollTo(0, document.body.scrollHeight); + }); + await page.waitForTimeout(1000); + + const listSchema = z.object({ + products: z.array( + z.object({ + name: z.string(), + price: z.string(), + url: z.string(), + rating: z.number().optional(), + reviewCount: z.number().optional(), + }), + ), + category: z.string(), + totalProducts: z.number().optional(), + }); + + const { data } = await stagehand.extract( + "Extract all product information from this Amazon category page, including product names, prices, URLs, ratings", + listSchema, + ); + + const products = data.products.map((product: (typeof data.products)[number]) => ({ + ...product, + dateScraped: new Date(), + })); + + // Create the product list object + const productList: ProductList = { + products: products, + category: data.category, + dateScraped: new Date(), + totalProducts: products.length, + websiteName: "Amazon", + }; + + // Store the data in MongoDB + await storeData(COLLECTIONS.PRODUCT_LISTS, productList); + await storeData(COLLECTIONS.PRODUCTS, products); + + return productList; +} + +/** + * Scrapes detailed information for a single product + */ +async function scrapeProductDetails( + page: Page, + productUrl: string, + stagehand: Stagehand, +): Promise { + await page.goto(productUrl); + + // Wait for the page to load + await page.waitForTimeout(2000); + + // Scroll down to load more content + await page.evaluate(() => { + window.scrollTo(0, document.body.scrollHeight / 3); + }); + await page.waitForTimeout(1000); + await page.evaluate(() => { + window.scrollTo(0, (document.body.scrollHeight * 2) / 3); + }); + await page.waitForTimeout(1000); + + const { data: product } = await stagehand.extract( + "Extract detailed product information from this Amazon product page, including name, price, description, specifications, brand, category, image URL, rating, review count, and availability", + ProductSchema.omit({ dateScraped: true }), + ); + + // Add additional data + const completeProduct: Product = { + ...product, + url: productUrl, + dateScraped: new Date(), + }; + + // Store the data in MongoDB + await storeData(COLLECTIONS.PRODUCTS, completeProduct); + + return completeProduct; +} + +// ========== Data Analysis Functions ========== +/** + * Run queries on the collected data + */ +async function runQueries(): Promise { + try { + // Connect to MongoDB + await connectToMongo(); + console.log(chalk.blue("๐Ÿ”Œ Connected to MongoDB")); + + // Define types for MongoDB query results + interface CategoryCount { + _id: string | null; + count: number; + } + + // 1. Get total counts for each collection using MongoDB's native countDocuments + console.log(chalk.yellow("\n๐Ÿ“Š Collection Counts:")); + if (!db) throw new Error("MongoDB connection not established"); + for (const [name, collection] of Object.entries(COLLECTIONS)) { + const count = await db.collection(collection).countDocuments(); + console.log(`${chalk.green(name)}: ${count} documents`); + } + + // 2. Products by category + console.log(chalk.yellow("\n๐Ÿ“Š Products by Category:")); + const productsByCategory = await aggregateData(COLLECTIONS.PRODUCTS, [ + { $group: { _id: "$category", count: { $sum: 1 } } }, + { $sort: { count: -1 } }, + ]); + + console.table( + productsByCategory.map((item) => ({ + Category: item._id || "Unknown", + Count: item.count, + })), + ); + + // 3. Find highest rated products + console.log(chalk.yellow("\n๐Ÿ“Š Top Rated Products:")); + // First get the count of highly rated products + if (!db) throw new Error("MongoDB connection not established"); + const count = await db.collection(COLLECTIONS.PRODUCTS).countDocuments({ rating: { $gte: 4 } }); + console.log(chalk.yellow(`Found ${count} highly rated products (4+ stars)`)); + + // Only fetch and display the products if there are any + if (count > 0) { + const highestRatedProducts = await findData(COLLECTIONS.PRODUCTS, { rating: { $gte: 4 } }); + console.table( + highestRatedProducts.map((product: any) => ({ + Name: product.name, + Price: product.price, + Rating: product.rating, + Category: product.category || "Unknown", + })), + ); + } + + console.log(chalk.green("\nโœ… Queries completed successfully!")); + } catch (error) { + console.error(chalk.red("โŒ Error running queries:"), error); + } +} + +// ========== Main Function ========== +async function main({ page, stagehand }: { page: Page; stagehand: Stagehand }) { + try { + // Connect to MongoDB + const db = await connectToMongo(); + + // Verify indexes were created + console.log(chalk.blue("Verifying indexes...")); + for (const [collectionName, indexes] of Object.entries(INDEXES)) { + const collection = db.collection(collectionName); + const existingIndexes = await collection.listIndexes().toArray(); + console.log(chalk.blue(`Indexes for ${collectionName}:`)); + console.log(existingIndexes); + } + + // Define the category URL for Amazon electronics + const categoryUrl = "https://www.amazon.com/s?k=laptops"; + + console.log(chalk.blue("๐Ÿ“Š Starting to scrape product listing...")); + + // Scrape product listing + const productList = await scrapeProductList(page, categoryUrl, stagehand); + console.log( + chalk.green( + `โœ… Scraped ${productList.products.length} products from category: ${productList.category}`, + ), + ); + + // Scrape detailed information for the first 3 products + const productsToScrape = productList.products.slice(0, 3); + + for (const [index, product] of productsToScrape.entries()) { + console.log( + chalk.blue( + `๐Ÿ“Š Scraping details for product ${index + 1}/${productsToScrape.length}: ${product.name}`, + ), + ); + + try { + // Scrape product details + const detailedProduct = await scrapeProductDetails(page, product.url, stagehand); + console.log(chalk.green(`โœ… Scraped detailed information for: ${detailedProduct.name}`)); + + // Wait between requests to avoid rate limiting + await page.waitForTimeout(2000); + } catch (error) { + console.error(chalk.red(`โŒ Error scraping product ${product.name}:`), error); + } + } + + // Run queries on the collected data + await runQueries(); + + console.log(chalk.green("๐ŸŽ‰ Scraping and MongoDB operations completed successfully!")); + } catch (error) { + console.error(chalk.red("โŒ Error during scraping:"), error); + } finally { + // Close MongoDB connection + await closeMongo(); + } +} + +// ========== Entry Point ========== +async function run() { + const apiKey = process.env.BROWSERBASE_API_KEY; + if (!apiKey) throw new Error("BROWSERBASE_API_KEY is required"); + + const browser = await browserbase.launch({ + apiKey, + browserSettings: { + blockAds: true, + viewport: { width: 1024, height: 768 }, + }, + }); + const stagehand = await Stagehand.create({ + browser, + logging: { level: "info", format: "pretty" }, + }); + + console.log(`View this session live: https://browserbase.com/sessions/${browser.sessionId}`); + + const [page] = await browser.context.pages(); + + await main({ + page, + stagehand, + }); + + await stagehand.close(); + await browser.close(); +} + +run(); diff --git a/packages/examples/mongodb/typescript/package.json b/packages/examples/mongodb/typescript/package.json new file mode 100644 index 0000000000..4f397e0ccf --- /dev/null +++ b/packages/examples/mongodb/typescript/package.json @@ -0,0 +1,24 @@ +{ + "name": "mongodb", + "type": "module", + "scripts": { + "build": "tsc", + "start": "tsx index.ts", + "postinstall": "playwright install" + }, + "dependencies": { + "@browserbasehq/sdk": "^2.6.0", + "@browserbasehq/stagehand": "^4.0.0", + "@playwright/test": "^1.49.1", + "boxen": "^8.0.1", + "chalk": "^5.3.0", + "dotenv": "^16.4.7", + "mongodb": "^6.16.0", + "zod": "^4.2.0" + }, + "devDependencies": { + "tsx": "^4.19.2", + "typescript": "^5.0.0" + }, + "packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c" +} diff --git a/packages/examples/mongodb/typescript/tsconfig.json b/packages/examples/mongodb/typescript/tsconfig.json new file mode 100644 index 0000000000..2b269c1df1 --- /dev/null +++ b/packages/examples/mongodb/typescript/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": false, + "noEmit": false + }, + "include": ["*.ts", "api", "examples/run.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/examples/mongodb/typescript/utils.ts b/packages/examples/mongodb/typescript/utils.ts new file mode 100644 index 0000000000..feb459933f --- /dev/null +++ b/packages/examples/mongodb/typescript/utils.ts @@ -0,0 +1,137 @@ +import type { Action, ObserveResult, Page, Stagehand } from "@browserbasehq/stagehand"; +import boxen from "boxen"; +import chalk from "chalk"; +import fs from "fs/promises"; +import { z } from "zod/v4"; + +export function announce(message: string, title?: string) { + console.log( + boxen(message, { + padding: 1, + margin: 3, + title: title || "Stagehand", + }), + ); +} + +export function getEnvVar(name: string, required = true): string | undefined { + const value = process.env[name]; + if (!value && required) { + throw new Error(`${name} not found in environment variables`); + } + return value; +} + +export function validateZodSchema(schema: z.ZodTypeAny, data: unknown) { + try { + schema.parse(data); + return true; + } catch { + return false; + } +} + +export async function drawObserveOverlay(page: Page, results: ObserveResult) { + const xpathList = results.data.map((result: Action) => result.selector); + + const validXpaths = xpathList.filter((xpath) => xpath !== "xpath="); + + await page.evaluate((selectors) => { + selectors.forEach((selector) => { + let element; + if (selector.startsWith("xpath=")) { + const xpath = selector.substring(6); + element = document.evaluate( + xpath, + document, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null, + ).singleNodeValue; + } else { + element = document.querySelector(selector); + } + + if (element instanceof HTMLElement) { + const overlay = document.createElement("div"); + overlay.setAttribute("stagehandObserve", "true"); + const rect = element.getBoundingClientRect(); + overlay.style.position = "absolute"; + overlay.style.left = rect.left + "px"; + overlay.style.top = rect.top + "px"; + overlay.style.width = rect.width + "px"; + overlay.style.height = rect.height + "px"; + overlay.style.backgroundColor = "rgba(255, 255, 0, 0.3)"; + overlay.style.pointerEvents = "none"; + overlay.style.zIndex = "10000"; + document.body.appendChild(overlay); + } + }); + }, validXpaths); +} + +export async function clearOverlays(page: Page) { + await page.evaluate(() => { + const elements = document.querySelectorAll('[stagehandObserve="true"]'); + elements.forEach((el) => { + const parent = el.parentNode; + while (el.firstChild) { + parent?.insertBefore(el.firstChild, el); + } + parent?.removeChild(el); + }); + }); +} + +export async function simpleCache(instruction: string, actionToCache: Action) { + try { + let cache: Record = {}; + try { + const existingCache = await fs.readFile("cache.json", "utf-8"); + cache = JSON.parse(existingCache); + } catch { + // no file yet + } + + cache[instruction] = actionToCache; + + await fs.writeFile("cache.json", JSON.stringify(cache, null, 2)); + } catch (error) { + console.error(chalk.red("Failed to save to cache:"), error); + } +} + +export async function readCache(instruction: string): Promise { + try { + const existingCache = await fs.readFile("cache.json", "utf-8"); + const cache: Record = JSON.parse(existingCache); + return cache[instruction] || null; + } catch { + return null; + } +} + +export async function actWithCache( + stagehand: Stagehand, + page: Page, + instruction: string, +): Promise { + const cachedAction = await readCache(instruction); + if (cachedAction) { + console.log(chalk.blue("Using cached action for:"), instruction); + await stagehand.act(cachedAction); + return; + } + + const results = await stagehand.observe(instruction); + console.log(chalk.blue("Got results:"), results); + + const actionToCache = results.data[0]; + console.log(chalk.blue("Taking cacheable action:"), actionToCache); + await simpleCache(instruction, actionToCache); + await drawObserveOverlay(page, results); + await page.waitForTimeout(1000); + await clearOverlays(page); + + await stagehand.act(actionToCache); +} diff --git a/packages/examples/oxlint.examples.config.ts b/packages/examples/oxlint.examples.config.ts new file mode 100644 index 0000000000..a255eadd11 --- /dev/null +++ b/packages/examples/oxlint.examples.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "oxlint"; + +export default defineConfig({ + // Each example installs its own dependencies and may use an older SDK. + options: { typeAware: false }, + rules: { "no-console": "off" }, +}); diff --git a/packages/examples/pickleball/README.md b/packages/examples/pickleball/README.md new file mode 100644 index 0000000000..3479fd683d --- /dev/null +++ b/packages/examples/pickleball/README.md @@ -0,0 +1,12 @@ +# pickleball + +Public recreation-site booking example; credentials and choices are runtime inputs, no customer account fixture. + +Choose a language and follow its setup instructions: + +| Language | Stagehand dependency | Directory from the repository root | +| ---------------------------------- | -------------------- | ----------------------------------------- | +| [TypeScript](typescript/README.md) | `4.0.0` | `packages/examples/pickleball/typescript` | +| [Python](python/README.md) | `4.0.0` | `packages/examples/pickleball/python` | + +Each variant installs the published Stagehand package and keeps its own dependencies. diff --git a/packages/examples/pickleball/python/.env.example b/packages/examples/pickleball/python/.env.example new file mode 100644 index 0000000000..e7e9b99c8b --- /dev/null +++ b/packages/examples/pickleball/python/.env.example @@ -0,0 +1,9 @@ +BROWSERBASE_API_KEY= +SF_REC_PARK_EMAIL= +SF_REC_PARK_PASSWORD= +ACTIVITY=Pickleball +# ISO date, for example 2026-08-12. Defaults to tomorrow when omitted. +SELECTED_DATE= +TIME_OF_DAY=Evening +# Set true only when you intend to create a real reservation. +BOOK_COURT=false diff --git a/packages/examples/pickleball/python/README.md b/packages/examples/pickleball/python/README.md new file mode 100644 index 0000000000..faa60fa3b8 --- /dev/null +++ b/packages/examples/pickleball/python/README.md @@ -0,0 +1,87 @@ +# Stagehand + Browserbase: AI-Powered Court Booking Automation + +Location in the Stagehand repository: `packages/examples/pickleball/python`. + +Stagehand is the SDK for browser agents. + +## AT A GLANCE + +- Goal: automate tennis and pickleball court bookings in San Francisco Recreation & Parks system. +- AI Integration: Stagehand for UI interaction and data extraction. +- Browser Automation: automates login, filtering, court selection, and booking confirmation. +- User Interaction: prompts for activity type, date, and time preferences with validation. + Docs โ†’ https://docs.browserbase.com/fundamentals/create-browser-session + +## GLOSSARY + +- act: perform UI actions from a prompt (click, type, select) + Docs โ†’ https://docs.stagehand.dev/v4/basics/act +- extract: pull structured data from pages using schemas + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- observe: plan actions and get selectors before executing + Docs โ†’ https://docs.stagehand.dev/v4/basics/observe +- browser automation: automated interaction with web applications for booking systems + Docs โ†’ https://docs.browserbase.com/fundamentals/create-browser-session +- form validation: ensure user input meets booking system requirements + +## QUICKSTART + +1. Create an account with SF Recreation & Parks website -> https://www.rec.us/organizations/san-francisco-rec-park +2. cd packages/examples/pickleball/python +3. uv venv venv +4. source venv/bin/activate # On Windows: venv\Scripts\activate +5. pip install -r requirements.txt +6. pip install InquirerPy pydantic +7. cp .env.example .env # Add your Browserbase API key and SF Rec Park credentials to .env +8. python main.py + +## EXPECTED OUTPUT + +- Prompts user for activity type (Tennis/Pickleball), date, and time +- Automates login to SF Recreation & Parks booking system +- Filters courts by activity, date, and time preferences +- Extracts available court information and displays options +- Automates court booking with verification code handling +- Confirms successful booking with details + +## COMMON PITFALLS + +- "ModuleNotFoundError": ensure all dependencies are installed via pip +- Missing credentials: verify .env contains all required API keys and SF Rec Park login +- Login failures: check SF Rec Park credentials and account status +- Booking errors: verify court availability and booking system accessibility +- Verification codes: ensure you can receive SMS/email codes for booking confirmation +- Import errors: activate your virtual environment if you created one + +## FURTHER USE CASES + +โ€ข Court Booking: Automate tennis and pickleball court reservations in San Francisco +โ€ข Recreation & ticketing: courts, parks, events, museum passes, campsite reservations +โ€ข Appointments & scheduling: DMV, healthcare visits, test centers, field service dispatch +โ€ข Permits & licensing: business licenses, parking permits, construction approvals, hunting/fishing tags +โ€ข Procurement portals: reserve inventory, request quotes, confirm orders +โ€ข Travel & logistics: dock door scheduling, freight pickups, crew shifts, equipment rentals +โ€ข Education & training: lab reservations, proctored exam slots, workshop sign-ups +โ€ข Internal admin portals: hardware checkout, conference-room overflow, cafeteria or shift scheduling + +## NEXT STEPS + +โ€ข Swap the target site: point the script at a different booking or reservation portal (e.g., gyms, coworking, campsites) +โ€ข Generalize filters: extend date/time/activity prompts to handle more categories or custom filters +โ€ข Automate recurring bookings: wrap the script in a scheduler (cron/queue) to secure slots automatically +โ€ข Add notifications: send booking confirmations to Slack, email, or SMS once a reservation succeeds +โ€ข Handle multi-user accounts: support multiple credentials so a team can share automation +โ€ข Export structured results: save court/slot data as JSON, CSV, or push to a database for reporting +โ€ข Integrate with APIs: connect confirmed reservations to a calendar system (Google Calendar, Outlook) +โ€ข Enhance verification flow: add support for automatically fetching OTP codes from email/SMS inboxes +โ€ข Improve resilience: add retries, backoff, and selector caching to handle UI changes gracefully +โ€ข Template it: strip out "pickleball" wording and reuse as a boilerplate for any authenticate โ†’ filter โ†’ extract โ†’ book workflow + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/pickleball/python/main.py b/packages/examples/pickleball/python/main.py new file mode 100644 index 0000000000..80749217f2 --- /dev/null +++ b/packages/examples/pickleball/python/main.py @@ -0,0 +1,175 @@ +"""Find, and optionally book, SF courts with Stagehand V4.""" + +import asyncio +import json +import os +from datetime import date, timedelta + +from dotenv import load_dotenv +from pydantic import BaseModel, Field + +from stagehand import Stagehand, browserbase + +load_dotenv() + +BOOKING_URL = "https://www.rec.us/organizations/san-francisco-rec-park" + + +class Court(BaseModel): + name: str = Field(min_length=1) + opening_times: str = Field(description="Available or displayed time slots") + location: str + availability: str + duration: str | None = None + + +class CourtResults(BaseModel): + courts: list[Court] + + +class BookingConfirmation(BaseModel): + confirmation_message: str | None = None + booking_details: str | None = None + error_message: str | None = None + + +def require_env(name: str) -> str: + value = os.environ.get(name) + if not value: + raise RuntimeError(f"{name} is required") + return value + + +def requested_preferences() -> tuple[str, str, str]: + activity = os.environ.get("ACTIVITY", "Pickleball") + selected_date = os.environ.get("SELECTED_DATE", str(date.today() + timedelta(days=1))) + time_of_day = os.environ.get("TIME_OF_DAY", "Evening") + if activity not in {"Tennis", "Pickleball"}: + raise RuntimeError("ACTIVITY must be Tennis or Pickleball") + if time_of_day not in {"Morning", "Afternoon", "Evening"}: + raise RuntimeError("TIME_OF_DAY must be Morning, Afternoon, or Evening") + date.fromisoformat(selected_date) + return activity, selected_date, time_of_day + + +async def login(stagehand: Stagehand, page: object) -> None: + await stagehand.act("Click the Login button", page=page) + await stagehand.act( + "Fill the email or username field with %email%", + page=page, + variables={"email": require_env("SF_REC_PARK_EMAIL")}, + ) + await stagehand.act("Click the next, continue, or submit button", page=page) + await stagehand.act( + "Fill the password field with %password%", + page=page, + variables={"password": require_env("SF_REC_PARK_PASSWORD")}, + ) + await stagehand.act("Click the login, sign in, or submit button", page=page) + + +async def select_filters( + stagehand: Stagehand, + page: object, + activity: str, + selected_date: str, + time_of_day: str, +) -> None: + day_number = date.fromisoformat(selected_date).day + await stagehand.act("Click the Activities dropdown", page=page) + await stagehand.act(f"Select the {activity} activity", page=page) + await stagehand.act("Click Done", page=page) + await stagehand.act("Click the date picker or calendar", page=page) + await stagehand.act(f"Click day {day_number} in the calendar", page=page) + await stagehand.act("Click the time filter", page=page) + await stagehand.act(f"Select the {time_of_day} time period", page=page) + await stagehand.act("Click Done", page=page) + await stagehand.act("Enable Available Only", page=page) + await stagehand.act("Click the All Facilities dropdown", page=page) + await stagehand.act("Select Accept Reservations", page=page) + await stagehand.act("Click Done", page=page) + + +async def extract_courts(stagehand: Stagehand, page: object) -> list[Court]: + extracted = await stagehand.extract( + "Extract every displayed court option, its time slots, location, availability, and duration", + CourtResults, + page=page, + ) + courts = extracted.data.courts + if not courts: + raise RuntimeError("The booking site returned no court availability information") + return courts + + +async def book_first_court(stagehand: Stagehand, page: object) -> BookingConfirmation: + await stagehand.act("Click the first available court time slot", page=page) + await stagehand.act("Open the participant dropdown", page=page) + await stagehand.act("Select the only named participant", page=page) + await stagehand.act("Click the Book or Reserve button", page=page) + await stagehand.act("Click Send Code", page=page) + + verification_code = input("Enter the one-time booking verification code: ").strip() + if not verification_code: + raise RuntimeError("A verification code is required to finish the reservation") + await stagehand.act( + "Fill the verification-code field with %code%", + page=page, + variables={"code": verification_code}, + ) + await stagehand.act("Click Confirm", page=page) + extracted = await stagehand.extract( + "Extract the booking confirmation, reservation details, and any error message", + BookingConfirmation, + page=page, + ) + confirmation = extracted.data + if confirmation.error_message: + raise RuntimeError(f"Booking failed: {confirmation.error_message}") + if not confirmation.confirmation_message and not confirmation.booking_details: + raise RuntimeError("The site did not show a booking confirmation") + return confirmation + + +async def main() -> None: + activity, selected_date, time_of_day = requested_preferences() + print(f"Finding {activity} courts for {time_of_day} on {selected_date}") + + browser = await browserbase.launch( + api_key=require_env("BROWSERBASE_API_KEY"), + timeout=900, + region="us-west-2", + ) + try: + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + await page.goto(BOOKING_URL, wait_until="domcontentloaded", timeout=60_000) + await login(stagehand, page) + await select_filters(stagehand, page, activity, selected_date, time_of_day) + courts = await extract_courts(stagehand, page) + print("Live court availability:") + print(json.dumps([court.model_dump(mode="json") for court in courts], indent=2)) + + if os.environ.get("BOOK_COURT", "false").lower() == "true": + confirmation = await book_first_court(stagehand, page) + print("Booking confirmed:") + print(json.dumps(confirmation.model_dump(mode="json"), indent=2)) + else: + print("Set BOOK_COURT=true to reserve a court") + finally: + await stagehand.close() + finally: + await browser.close() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as error: + print(f"Court workflow failed: {error}") + print("Docs: https://docs.stagehand.dev/v4/first-steps/introduction") + raise diff --git a/packages/examples/pickleball/python/pyproject.toml b/packages/examples/pickleball/python/pyproject.toml new file mode 100644 index 0000000000..da79cbc479 --- /dev/null +++ b/packages/examples/pickleball/python/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "pickleball" +version = "0.1.0" +requires-python = ">=3.11,<3.14" +dependencies = ["pydantic>=2.12,<3", "python-dotenv==1.2.2", "stagehand==4.0.0"] + +[tool.uv] +package = false diff --git a/packages/examples/pickleball/typescript/.env.example b/packages/examples/pickleball/typescript/.env.example new file mode 100644 index 0000000000..794193225d --- /dev/null +++ b/packages/examples/pickleball/typescript/.env.example @@ -0,0 +1,4 @@ +BROWSERBASE_API_KEY= +SF_REC_PARK_EMAIL= +SF_REC_PARK_PASSWORD= +# DEBUG=false diff --git a/packages/examples/pickleball/typescript/README.md b/packages/examples/pickleball/typescript/README.md new file mode 100644 index 0000000000..5cbdabad1a --- /dev/null +++ b/packages/examples/pickleball/typescript/README.md @@ -0,0 +1,83 @@ +# Stagehand + Browserbase: AI-Powered Court Booking Automation + +Location in the Stagehand repository: `packages/examples/pickleball/typescript`. + +## AT A GLANCE + +- Goal: automate tennis and pickleball court bookings in San Francisco Recreation & Parks system. +- AI Integration: Stagehand for UI interaction and data extraction. +- Browser Automation: automates login, filtering, court selection, and booking confirmation. +- User Interaction: prompts for activity type, date, and time preferences with validation. + Docs โ†’ https://docs.browserbase.com/fundamentals/create-browser-session + +## GLOSSARY + +- act: perform UI actions from a prompt (click, type, select) + Docs โ†’ https://docs.stagehand.dev/v4/basics/act +- extract: pull structured data from pages using schemas + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- observe: plan actions and get selectors before executing + Docs โ†’ https://docs.stagehand.dev/v4/basics/observe +- browser automation: automated interaction with web applications for booking systems + Docs โ†’ https://docs.browserbase.com/fundamentals/create-browser-session +- form validation: ensure user input meets booking system requirements + +## QUICKSTART + +1. Create an account with SF Recreation & Parks website -> https://www.rec.us/organizations/san-francisco-rec-park +2. cd packages/examples/pickleball/typescript +3. npm install +4. npm install inquirer +5. cp .env.example .env +6. Add your Browserbase API key, Project ID, and SF Rec Park credentials to .env +7. npm start + +## EXPECTED OUTPUT + +- Prompts user for activity type (Tennis/Pickleball), date, and time +- Automates login to SF Recreation & Parks booking system +- Filters courts by activity, date, and time preferences +- Extracts available court information and displays options +- Automates court booking with verification code handling +- Confirms successful booking with details + +## COMMON PITFALLS + +- "Cannot find module": ensure all dependencies are installed +- Missing credentials: verify .env contains all required API keys and SF Rec Park login +- Login failures: check SF Rec Park credentials and account status +- Booking errors: verify court availability and booking system accessibility +- Verification codes: ensure you can receive SMS/email codes for booking confirmation + +## FURTHER USE CASES + +โ€ข Court Booking: Automate tennis and pickleball court reservations in San Francisco +โ€ข Recreation & ticketing: courts, parks, events, museum passes, campsite reservations +โ€ข Appointments & scheduling: DMV, healthcare visits, test centers, field service dispatch +โ€ข Permits & licensing: business licenses, parking permits, construction approvals, hunting/fishing tags +โ€ข Procurement portals: reserve inventory, request quotes, confirm orders +โ€ข Travel & logistics: dock door scheduling, freight pickups, crew shifts, equipment rentals +โ€ข Education & training: lab reservations, proctored exam slots, workshop sign-ups +โ€ข Internal admin portals: hardware checkout, conference-room overflow, cafeteria or shift scheduling + +## NEXT STEPS + +โ€ข Swap the target site: point the script at a different booking or reservation portal (e.g., gyms, coworking, campsites) +โ€ข Generalize filters: extend date/time/activity prompts to handle more categories or custom filters +โ€ข Automate recurring bookings: wrap the script in a scheduler (cron/queue) to secure slots automatically +โ€ข Add notifications: send booking confirmations to Slack, email, or SMS once a reservation succeeds +โ€ข Handle multi-user accounts: support multiple credentials so a team can share automation +โ€ข Export structured results: save court/slot data as JSON, CSV, or push to a database for reporting +โ€ข Integrate with APIs: connect confirmed reservations to a calendar system (Google Calendar, Outlook) +โ€ข Enhance verification flow: add support for automatically fetching OTP codes from email/SMS inboxes +โ€ข Improve resilience: add retries, backoff, and selector caching to handle UI changes gracefully +โ€ข Template it: strip out "pickleball" wording and reuse as a boilerplate for any authenticate โ†’ filter โ†’ extract โ†’ book workflow + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/pickleball/typescript/index.ts b/packages/examples/pickleball/typescript/index.ts new file mode 100644 index 0000000000..5d794e523a --- /dev/null +++ b/packages/examples/pickleball/typescript/index.ts @@ -0,0 +1,467 @@ +// SF Court Booking Automation - See README.md for full documentation +import "dotenv/config"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import inquirer from "inquirer"; +import { z } from "zod/v4"; + +async function loginToSite(stagehand: Stagehand, email: string, password: string): Promise { + console.log("Logging in..."); + // Perform login sequence: each step is atomic to handle dynamic page changes. + await stagehand.act("Click the Login button"); + await stagehand.act(`Fill in the email or username field with "${email}"`); + await stagehand.act("Click the next, continue, or submit button to proceed"); + await stagehand.act(`Fill in the password field with "${password}"`); + await stagehand.act("Click the login, sign in, or submit button"); + console.log("Logged in"); +} + +async function selectFilters( + stagehand: Stagehand, + activity: string, + timeOfDay: string, + selectedDate: string, +): Promise { + console.log("Selecting the activity"); + // Filter by activity type first to narrow down available courts. + await stagehand.act(`Click the activites drop down menu`); + await stagehand.act(`Select the ${activity} activity`); + await stagehand.act(`Click the Done button`); + + console.log(`Selecting date: ${selectedDate}`); + // Open calendar to select specific date for court booking. + await stagehand.act(`Click the date picker or calendar`); + + // Parse date string to extract day number for calendar selection. + const dateParts = selectedDate.split("-"); + if (dateParts.length !== 3) { + throw new Error(`Invalid date format: ${selectedDate}. Expected YYYY-MM-DD`); + } + + const dayNumber = parseInt(dateParts[2], 10); + if (isNaN(dayNumber) || dayNumber < 1 || dayNumber > 31) { + throw new Error(`Invalid day number: ${dayNumber} from date: ${selectedDate}`); + } + + console.log(`Looking for day number: ${dayNumber} in calendar`); + // Click specific day number in calendar to select date. + await stagehand.act(`Click on the number ${dayNumber} in the calendar`); + + console.log(`Selecting time of day: ${timeOfDay}`); + // Filter by time period to find courts available during preferred hours. + await stagehand.act(`Click the time filter or time selection dropdown`); + await stagehand.act(`Select ${timeOfDay} time period`); + await stagehand.act(`Click the Done button`); + + // Apply additional filters to show only available courts that accept reservations. + await stagehand.act(`Click Available Only button`); + await stagehand.act(`Click All Facilities dropdown list`); + await stagehand.act(`Select Accept Reservations checkbox`); + await stagehand.act(`Click the Done button`); +} + +async function checkAndExtractCourts(stagehand: Stagehand, timeOfDay: string): Promise { + console.log("Checking for available courts..."); + + // First observe the page to find all available court booking options. + const { data: availableCourts } = await stagehand.observe( + "Find all available court booking slots, time slots, or court reservation options", + ); + console.log(`Found ${availableCourts.length} available court options`); + + // Extract structured court data using Zod schema for type safety and validation. + const { data: courtData } = await stagehand.extract( + "Extract all available court booking information including court names, time slots, locations, and any other relevant details", + z.object({ + courts: z.array( + z.object({ + name: z.string().describe("the name or identifier of the court"), + openingTimes: z.string().describe("the opening hours or operating times of the court"), + location: z.string().describe("the location or facility name"), + availability: z.string().describe("availability status or any restrictions"), + duration: z.string().nullable().describe("the duration of the court session in minutes"), + }), + ), + }), + ); + + // Check if any courts are actually available by filtering out unavailable status messages. + let hasAvailableCourts = courtData.courts.some( + (court: { availability: string }) => + !court.availability.toLowerCase().includes("no free spots") && + !court.availability.toLowerCase().includes("unavailable") && + !court.availability.toLowerCase().includes("next available") && + !court.availability.toLowerCase().includes("the next available reservation"), + ); + + // If no courts available for selected time, try alternative time periods as fallback. + if (availableCourts.length === 0 || !hasAvailableCourts) { + console.log("No courts available for selected time. Trying different time periods..."); + + // Generate alternative time periods to try if original selection has no availability. + const alternativeTimes = + timeOfDay === "Morning" + ? ["Afternoon", "Evening"] + : timeOfDay === "Afternoon" + ? ["Morning", "Evening"] + : ["Morning", "Afternoon"]; + + for (const altTime of alternativeTimes) { + console.log(`Trying ${altTime} time period...`); + + // Change time filter to alternative time period and check availability. + await stagehand.act(`Click the time filter dropdown that currently shows "${timeOfDay}"`); + await stagehand.act(`Select ${altTime} from the time period options`); + await stagehand.act(`Click the Done button`); + + const { data: altAvailableCourts } = await stagehand.observe( + "Find all available court booking slots, time slots, or court reservation options", + ); + console.log(`Found ${altAvailableCourts.length} available court options for ${altTime}`); + + if (altAvailableCourts.length > 0) { + const { data: altCourtData } = await stagehand.extract( + "Extract all available court booking information including court names, time slots, locations, and any other relevant details", + z.object({ + courts: z.array( + z.object({ + name: z.string().describe("the name or identifier of the court"), + openingTimes: z + .string() + .describe("the opening hours or operating times of the court"), + location: z.string().describe("the location or facility name"), + availability: z.string().describe("availability status or any restrictions"), + duration: z + .string() + .nullable() + .describe("the duration of the court session in minutes"), + }), + ), + }), + ); + + const hasAltAvailableCourts = altCourtData.courts.some( + (court: { availability: string }) => + !court.availability.toLowerCase().includes("no free spots") && + !court.availability.toLowerCase().includes("unavailable") && + !court.availability.toLowerCase().includes("next available") && + !court.availability.toLowerCase().includes("the next available reservation"), + ); + + // If alternative time has available courts, use that data and stop searching. + if (hasAltAvailableCourts) { + console.log(`Found actually available courts for ${altTime}!`); + courtData.courts = altCourtData.courts; + hasAvailableCourts = true; + break; + } + } + } + } + + // If still no available courts found, extract final court data for display. + if (!hasAvailableCourts) { + console.log("Extracting final court information..."); + const { data: finalCourtData } = await stagehand.extract( + "Extract all available court booking information including court names, time slots, locations, and any other relevant details", + z.object({ + courts: z.array( + z.object({ + name: z.string().describe("the name or identifier of the court"), + openingTimes: z.string().describe("the opening hours or operating times of the court"), + location: z.string().describe("the location or facility name"), + availability: z.string().describe("availability status or any restrictions"), + duration: z + .string() + .nullable() + .describe("the duration of the court session in minutes"), + }), + ), + }), + ); + courtData.courts = finalCourtData.courts; + } + + // Display all found court information to user for review and selection. + console.log("Available Courts:"); + if (courtData.courts && courtData.courts.length > 0) { + courtData.courts.forEach( + ( + court: { + name: string; + openingTimes: string; + location: string; + availability: string; + duration: string | null; + }, + index: number, + ) => { + console.log(`${index + 1}. ${court.name}`); + console.log(` Opening Times: ${court.openingTimes}`); + console.log(` Location: ${court.location}`); + console.log(` Availability: ${court.availability}`); + if (court.duration) { + console.log(` Duration: ${court.duration} minutes`); + } + console.log(""); + }, + ); + } else { + console.log("No court data available to display"); + } +} + +async function bookCourt(stagehand: Stagehand): Promise { + console.log("Starting court booking process..."); + + try { + // Select the first available court time slot for booking. + console.log("Clicking the top available time slot..."); + await stagehand.act("Click the first available time slot or court booking option"); + + // Select participant from dropdown - assumes only one participant is available. + console.log("Opening participant dropdown..."); + await stagehand.act("Click the participant dropdown menu or select participant field"); + await stagehand.act("Click the only named participant in the dropdown!"); + + // Complete booking process and trigger verification code request. + console.log("Clicking the book button to complete reservation..."); + await stagehand.act("Click the book, reserve, or confirm booking button"); + await stagehand.act("Click the Send Code Button"); + + // Prompt user for verification code received via SMS/email for booking confirmation. + const codeAnswer = await inquirer.prompt([ + { + type: "input", + name: "verificationCode", + message: "Please enter the verification code you received:", + validate: (input: string) => { + if (!input.trim()) { + return "Please enter a verification code"; + } + return true; + }, + }, + ]); + + console.log(`Verification code: ${codeAnswer.verificationCode}`); + + // Enter verification code and confirm booking to complete reservation. + await stagehand.act( + `Fill in the verification code field with "${codeAnswer.verificationCode}"`, + ); + await stagehand.act("Click the confirm button"); + + // Extract booking confirmation details to verify successful reservation. + console.log("Checking for booking confirmation..."); + const { data: confirmation } = await stagehand.extract( + "Extract any booking confirmation message, success notification, or reservation details", + z.object({ + confirmationMessage: z.string().nullable().describe("any confirmation or success message"), + bookingDetails: z.string().nullable().describe("booking details like time, court, etc."), + errorMessage: z.string().nullable().describe("any error message if booking failed"), + }), + ); + + // Display confirmation details if booking was successful. + if (confirmation.confirmationMessage || confirmation.bookingDetails) { + console.log("Booking Confirmed!"); + if (confirmation.confirmationMessage) { + console.log(`${confirmation.confirmationMessage}`); + } + if (confirmation.bookingDetails) { + console.log(`${confirmation.bookingDetails}`); + } + } + + // Display error message if booking failed. + if (confirmation.errorMessage) { + console.log("Booking Error:"); + console.log(confirmation.errorMessage); + } + } catch (error) { + console.error("Error during court booking:", error); + throw error; + } +} + +async function selectActivity(): Promise { + // Prompt user to select between Tennis and Pickleball activities. + const answers = await inquirer.prompt([ + { + type: "list", + name: "activity", + message: "Please select an activity:", + choices: [ + { name: "Tennis", value: "Tennis" }, + { name: "Pickleball", value: "Pickleball" }, + ], + default: 0, + }, + ]); + + console.log(`Selected: ${answers.activity}`); + return answers.activity; +} + +async function selectTimeOfDay(): Promise { + // Prompt user to select preferred time period for court booking. + const answers = await inquirer.prompt([ + { + type: "list", + name: "timeOfDay", + message: "Please select the time of day:", + choices: [ + { name: "Morning (Before 12 PM)", value: "Morning" }, + { name: "Afternoon (After 12 PM)", value: "Afternoon" }, + { name: "Evening (After 5 PM)", value: "Evening" }, + ], + default: 0, + }, + ]); + + console.log(`Selected: ${answers.timeOfDay}`); + return answers.timeOfDay; +} + +async function selectDate(): Promise { + // Generate date options for the next 7 days including today. + const today = new Date(); + const dateOptions: { name: string; value: string }[] = []; + + for (let i = 0; i < 7; i++) { + const date = new Date(today); + date.setDate(today.getDate() + i); + + const dayName = date.toLocaleDateString("en-US", { weekday: "long" }); + const monthDay = date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + const fullDate = date.toISOString().split("T")[0]; + + const displayName = i === 0 ? `${dayName}, ${monthDay} (Today)` : `${dayName}, ${monthDay}`; + + dateOptions.push({ + name: displayName, + value: fullDate, + }); + } + + // Prompt user to select from available date options. + const answers = await inquirer.prompt([ + { + type: "list", + name: "selectedDate", + message: "Please select a date:", + choices: dateOptions, + default: 0, + }, + ]); + + // Format selected date for display and return ISO date string. + const selectedDate = new Date(answers.selectedDate); + const displayDate = selectedDate.toLocaleDateString("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }); + + console.log(`Selected: ${displayDate}`); + return answers.selectedDate; +} + +async function bookTennisPaddleCourt() { + console.log("Starting tennis/paddle court booking automation in SF..."); + + // Load credentials from environment variables for SF Rec & Parks login. + const email = process.env.SF_REC_PARK_EMAIL; + const password = process.env.SF_REC_PARK_PASSWORD; + const _debugMode = process.env.DEBUG === "true"; + + // Collect user preferences for activity, date, and time selection. + const activity = await selectActivity(); + const selectedDate = await selectDate(); + const timeOfDay = await selectTimeOfDay(); + + console.log(`Booking ${activity} courts in San Francisco for ${timeOfDay} on ${selectedDate}...`); + + // Validate that required credentials are available before proceeding. + if (!email || !password) { + throw new Error("Missing SF_REC_PARK_EMAIL or SF_REC_PARK_PASSWORD environment variables"); + } + + // Initialize Stagehand with Browserbase for AI-powered browser automation. + console.log("Initializing Stagehand with Browserbase"); + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + api_timeout: 900, + region: "us-west-2", + }); + const stagehand = await Stagehand.create({ + browser: browser, + model: { modelName: "openai/gpt-4.1" }, + logging: { level: "info" }, + }); + + try { + // Start browser session and connect to SF Rec & Parks booking system. + + console.log("Browserbase Session Started"); + const page = (await browser.context.pages())[0]; + + // Navigate to SF Rec & Parks booking site with extended timeout for slow loading. + console.log("Navigating to court booking site..."); + await page.goto("https://www.rec.us/organizations/san-francisco-rec-park", { + waitUntil: "domcontentloaded", + timeout: 60000, + }); + + // Execute booking workflow: login, filter, find courts, and complete booking. + await loginToSite(stagehand, email, password); + await selectFilters(stagehand, activity, timeOfDay, selectedDate); + await checkAndExtractCourts(stagehand, timeOfDay); + await bookCourt(stagehand); + } catch (error) { + console.error("Error during court booking:", error); + throw error; + } finally { + // Always close browser session to release resources and clean up. + await stagehand.close(); + await browser.close(); + console.log("\nBrowser session closed"); + } +} + +async function main() { + // Display welcome message and explain the automation workflow to user. + console.log("Welcome to SF Court Booking Automation!"); + console.log(""); + console.log("This tool automates tennis and pickleball court bookings in San Francisco."); + console.log("Here's what we'll do:"); + console.log(""); + console.log("1. Navigate to https://www.rec.us/organizations/san-francisco-rec-park"); + console.log("2. Use automated login with your credentials"); + console.log("3. Select your preferred activity, date, and time"); + console.log("4. Find and book available courts automatically"); + console.log("5. Handle verification codes and confirmation"); + console.log(""); + + try { + // Execute the complete court booking automation workflow. + await bookTennisPaddleCourt(); + + console.log("Court booking completed successfully!"); + console.log("Your court has been reserved. Check your email for confirmation details."); + } catch (error) { + console.log("Failed to complete court booking"); + console.log(`Error: ${error}`); + process.exit(1); + } +} + +main().catch((err) => { + console.error("Application error:", err); + console.log("Check your environment variables"); + process.exit(1); +}); diff --git a/packages/examples/pickleball/typescript/package.json b/packages/examples/pickleball/typescript/package.json new file mode 100644 index 0000000000..656fca4a04 --- /dev/null +++ b/packages/examples/pickleball/typescript/package.json @@ -0,0 +1,27 @@ +{ + "name": "pickleball-template", + "version": "1.0.0", + "description": "Stagehand + Browserbase: SF Court Booking Automation", + "type": "module", + "main": "index.ts", + "scripts": { + "build": "tsc --noEmit --skipLibCheck --target ES2022 --module NodeNext --moduleResolution NodeNext index.ts", + "start": "tsx index.ts", + "dev": "tsx watch index.ts" + }, + "dependencies": { + "@browserbasehq/stagehand": "4.0.0", + "dotenv": "^16.4.5", + "inquirer": "^12.9.4", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^22.18.0", + "tsx": "^4.19.2", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.18.0" + }, + "packageManager": "pnpm@10.24.0" +} diff --git a/packages/examples/polymarket-research/README.md b/packages/examples/polymarket-research/README.md new file mode 100644 index 0000000000..842eba0877 --- /dev/null +++ b/packages/examples/polymarket-research/README.md @@ -0,0 +1,12 @@ +# polymarket-research + +Read-only public prediction-market research. + +Choose a language and follow its setup instructions: + +| Language | Stagehand dependency | Directory from the repository root | +| ---------------------------------- | -------------------- | -------------------------------------------------- | +| [TypeScript](typescript/README.md) | `4.0.2` | `packages/examples/polymarket-research/typescript` | +| [Python](python/README.md) | `4.0.0` | `packages/examples/polymarket-research/python` | + +Each variant installs the published Stagehand package and keeps its own dependencies. diff --git a/packages/examples/polymarket-research/python/.env.example b/packages/examples/polymarket-research/python/.env.example new file mode 100644 index 0000000000..d0cde0427d --- /dev/null +++ b/packages/examples/polymarket-research/python/.env.example @@ -0,0 +1 @@ +BROWSERBASE_API_KEY= diff --git a/packages/examples/polymarket-research/python/README.md b/packages/examples/polymarket-research/python/README.md new file mode 100644 index 0000000000..39401e08ba --- /dev/null +++ b/packages/examples/polymarket-research/python/README.md @@ -0,0 +1,68 @@ +# Stagehand + Browserbase: Polymarket Prediction Market Research + +Location in the Stagehand repository: `packages/examples/polymarket-research/python`. + +Stagehand is the SDK for browser agents. + +## AT A GLANCE + +- Goal: automate research of prediction markets on Polymarket to extract current odds, pricing, and volume data. +- Flow: navigate to polymarket.com โ†’ search for market โ†’ select result โ†’ extract market data (odds, prices, volume, changes). +- Benefits: quickly gather market intelligence on prediction markets without manual browsing, structured data ready for analysis or trading decisions. + Docs โ†’ https://docs.stagehand.dev/v4/first-steps/introduction + +## GLOSSARY + +- act: perform UI actions from a prompt (click, type, search). + Docs โ†’ https://docs.stagehand.dev/v4/basics/act +- extract: pull structured data from a page using AI and Pydantic schemas. + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- prediction market: a market where participants trade contracts based on the outcome of future events. + +## QUICKSTART + +1. cd packages/examples/polymarket-research/python +2. uv venv && source .venv/bin/activate # On Windows: .venv\Scripts\activate +3. pip install stagehand python-dotenv pydantic +4. cp .env.example .env # Add your Browserbase API key to .env +5. python main.py + +## EXPECTED OUTPUT + +- Initializes Stagehand session with Browserbase +- Navigates to Polymarket website +- Searches for "Elon Musk unfollow Trump" prediction market +- Selects first market result from search dropdown +- Extracts market data: title, odds, yes/no prices, volume, price changes +- Displays structured JSON output with market information +- Provides live session URL for monitoring +- Closes session cleanly + +## COMMON PITFALLS + +- "ModuleNotFoundError": ensure all dependencies are installed via pip +- Missing credentials: verify .env contains BROWSERBASE_API_KEY +- No search results: check if the search query returns valid markets or try different search terms +- Network issues: ensure internet access and polymarket.com is accessible +- Import errors: activate your virtual environment if you created one + +## USE CASES + +โ€ข Market research: Track odds and sentiment on political events, sports outcomes, or business predictions. +โ€ข Trading analysis: Monitor price movements, volume trends, and market efficiency for investment decisions. +โ€ข News aggregation: Collect prediction market data to supplement traditional news sources with crowd-sourced forecasts. + +## NEXT STEPS + +โ€ข Multi-market tracking: Loop through multiple markets to build comprehensive prediction database. +โ€ข Historical analysis: Track price changes over time to identify trends and patterns. +โ€ข Automated alerts: Set up scheduled runs to detect significant market movements and send notifications. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/polymarket-research/python/main.py b/packages/examples/polymarket-research/python/main.py new file mode 100644 index 0000000000..2ddd994c60 --- /dev/null +++ b/packages/examples/polymarket-research/python/main.py @@ -0,0 +1,91 @@ +"""Research a live Polymarket prediction market with Stagehand V4.""" + +import asyncio +import json +import os + +from dotenv import load_dotenv +from pydantic import BaseModel + +from stagehand import Stagehand, browserbase + +load_dotenv() + +SEARCH_QUERY = "Will Elon Musk rejoin the Trump administration in 2026" + + +class MarketData(BaseModel): + market_title: str + current_odds: str | None + yes_price: str | None + no_price: str | None + total_volume: str | None + price_change: str | None + + +async def main() -> None: + api_key = os.environ.get("BROWSERBASE_API_KEY") + if not api_key: + raise RuntimeError("BROWSERBASE_API_KEY is required") + + print("Starting Polymarket research automation...") + browser = await browserbase.launch(api_key=api_key) + try: + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + await page.goto("https://polymarket.com", wait_until="domcontentloaded", timeout=60_000) + opened_search = await stagehand.act( + "Click the search box at the top of the page", page=page + ) + typed_search = await stagehand.act( + "Fill the search box with %query%", + page=page, + variables={"query": SEARCH_QUERY}, + ) + opened_market = await stagehand.act( + "Click the first matching market in the search results", + page=page, + ) + page = await browser.context.active_page() or page + current_url = await page.url() + market_url = ( + "https://polymarket.com/event/" + "will-elon-musk-rejoin-the-trump-administration-in-2026" + ) + if ( + not opened_search.data.success + or not typed_search.data.success + or not opened_market.data.success + or "will-elon-musk-rejoin-the-trump-administration-in-2026" not in current_url + ): + await page.goto( + market_url, + wait_until="domcontentloaded", + timeout=60_000, + ) + + extracted = await stagehand.extract( + "Extract the current odds and market information for this prediction market", + MarketData, + page=page, + ) + market = extracted.data + print(json.dumps(market.model_dump(mode="json"), indent=2)) + finally: + await stagehand.close() + finally: + await browser.close() + print("Session closed successfully") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as error: + print(f"Application error: {error}") + print("Docs: https://docs.stagehand.dev/v4/first-steps/introduction") + raise diff --git a/packages/examples/polymarket-research/python/pyproject.toml b/packages/examples/polymarket-research/python/pyproject.toml new file mode 100644 index 0000000000..caa70e4f0b --- /dev/null +++ b/packages/examples/polymarket-research/python/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "polymarket-research" +version = "0.1.0" +requires-python = ">=3.11,<3.14" +dependencies = ["pydantic>=2.12,<3", "python-dotenv==1.2.2", "stagehand==4.0.0"] + +[tool.uv] +package = false diff --git a/packages/examples/polymarket-research/typescript/.env.example b/packages/examples/polymarket-research/typescript/.env.example new file mode 100644 index 0000000000..d0cde0427d --- /dev/null +++ b/packages/examples/polymarket-research/typescript/.env.example @@ -0,0 +1 @@ +BROWSERBASE_API_KEY= diff --git a/packages/examples/polymarket-research/typescript/README.md b/packages/examples/polymarket-research/typescript/README.md new file mode 100644 index 0000000000..8a334df799 --- /dev/null +++ b/packages/examples/polymarket-research/typescript/README.md @@ -0,0 +1,62 @@ +# Stagehand + Browserbase: Market Research Automation + +Location in the Stagehand repository: `packages/examples/polymarket-research/typescript`. + +## AT A GLANCE + +- Goal: demonstrate how to automate market research on prediction markets using Stagehand. +- Semantic Navigation: uses `act()` to search for and open the requested live market. +- Data Extraction: extract structured market data with validated output using Zod schemas. +- Practical Example: research and extract current odds from Polymarket prediction markets. + +## GLOSSARY + +- extract: pull structured data from web pages into validated objects. + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- schema: a Zod definition that enforces data types, optional fields, and validation rules. + Docs โ†’ https://zod.dev/ +- market research automation: navigate to a specific prediction market and extract current odds. +- structured data extraction: convert unstructured web content into typed, validated objects. + +## QUICKSTART + +1. cd packages/examples/polymarket-research/typescript +2. npm install +3. cp ../../.env.example .env (or create .env with BROWSERBASE_API_KEY) +4. Add your Browserbase API key to .env +5. npm start + +## EXPECTED OUTPUT + +- Navigates to Polymarket prediction market website +- Opens the configured market URL directly +- Extracts structured market data including odds, prices, and volume +- Returns typed object with market information + +## COMMON PITFALLS + +- "Cannot find module 'dotenv'": ensure npm install ran successfully +- Missing API key: verify .env is loaded and file is not committed +- Market not found: check whether the configured market URL still exists +- Schema validation errors: ensure extracted data matches Zod schema structure + +## USE CASES + +โ€ข Market tracking: automate monitoring of prediction market odds for specific events or topics. +โ€ข Research aggregation: collect current prices and volume data from multiple prediction markets. +โ€ข Trading automation: extract structured market data for integration with trading or analysis systems. +โ€ข Sentiment analysis: track how prediction markets assess the likelihood of future events. + +## NEXT STEPS + +โ€ข Parameterize market URLs: make the target market configurable via environment variables or CLI input. +โ€ข Multi-market extraction: extend the flow to search and extract data from multiple markets in parallel. +โ€ข Historical tracking: persist extracted data over time to track market movement and trends. +โ€ข Price alerts: add logic to monitor specific price thresholds and send notifications. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/polymarket-research/typescript/index.ts b/packages/examples/polymarket-research/typescript/index.ts new file mode 100644 index 0000000000..80795f0f74 --- /dev/null +++ b/packages/examples/polymarket-research/typescript/index.ts @@ -0,0 +1,98 @@ +// Stagehand + Browserbase: Polymarket prediction market research - See README.md for full documentation + +import "dotenv/config"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod/v4"; + +/** + * Searches Polymarket for a prediction market and extracts current odds, pricing, and volume data. + * Uses AI-powered browser automation to navigate and interact with the site. + */ +async function main() { + console.log("Starting Polymarket research automation..."); + + // Initialize Stagehand with Browserbase for cloud-based browser automation + // Using BROWSERBASE environment to run in cloud rather than locally + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + }); + const stagehand = await Stagehand.create({ + browser: browser, + model: { modelName: "openai/gpt-4.1" }, + logging: { level: "info" }, + }); + + try { + // Initialize browser session + console.log("Initializing browser session..."); + + console.log("Stagehand session started successfully"); + + let page = (await browser.context.pages())[0]; + + const searchQuery = "Will Elon Musk rejoin the Trump administration in 2026"; + console.log("Navigating to Polymarket..."); + await page.goto("https://polymarket.com", { + waitUntil: "domcontentloaded", + timeout: 60000, + }); + const openedSearch = await stagehand.act("Click the search box at the top of the page"); + const typedSearch = await stagehand.act(`Type '${searchQuery}' into the search box`); + const openedMarket = await stagehand.act( + "Click the first market result from the search dropdown", + ); + page = (await browser.context.activePage()) ?? page; + const marketUrl = + "https://polymarket.com/event/will-elon-musk-rejoin-the-trump-administration-in-2026"; + const currentUrl = await page.url(); + if ( + !openedSearch.data.success || + !typedSearch.data.success || + !openedMarket.data.success || + !currentUrl.includes("will-elon-musk-rejoin-the-trump-administration-in-2026") + ) { + // The homepage search currently returns a non-actionable result on some + // sessions. Preserve semantic navigation as the primary path and use the + // known market URL only when its postcondition fails. + await page.goto(marketUrl, { waitUntil: "domcontentloaded", timeout: 60000 }); + } + + // Extract market data using AI to parse the structured information + console.log("Extracting market information..."); + const { data: marketData } = await stagehand.extract( + "Extract the current odds and market information for the prediction market", + z.object({ + marketTitle: z.string().describe("the title of the market"), + currentOdds: z.string().nullable().describe("the current odds or probability"), + yesPrice: z.string().nullable().describe("the yes price"), + noPrice: z.string().nullable().describe("the no price"), + totalVolume: z.string().nullable().describe("the total trading volume"), + priceChange: z.string().nullable().describe("the recent price change"), + }), + ); + + console.log("Market data extracted successfully:"); + console.log(JSON.stringify(marketData, null, 2)); + } catch (error) { + console.error("Error during market research:", error); + + // Provide helpful troubleshooting information + console.error("\nCommon issues:"); + console.error("1. Check .env file has BROWSERBASE_API_KEY"); + console.error("2. Ensure internet access and https://polymarket.com is accessible"); + console.error("3. Verify Browserbase account has sufficient credits"); + + throw error; + } finally { + // Clean up browser session + console.log("Closing browser session..."); + await stagehand.close().catch((error) => console.warn("Stagehand cleanup warning:", error)); + await browser.close().catch((error) => console.warn("Browser cleanup warning:", error)); + console.log("Session closed successfully"); + } +} + +main().catch((err) => { + console.error("Application error:", err); + process.exit(1); +}); diff --git a/packages/examples/polymarket-research/typescript/package.json b/packages/examples/polymarket-research/typescript/package.json new file mode 100644 index 0000000000..5a18186f8c --- /dev/null +++ b/packages/examples/polymarket-research/typescript/package.json @@ -0,0 +1,22 @@ +{ + "name": "polymarket-research", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "tsx index.ts" + }, + "dependencies": { + "@browserbasehq/stagehand": "4.0.2", + "dotenv": "^17.4.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "tsx": "^4.23.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.18.0" + } +} diff --git a/packages/examples/proxies-weather/README.md b/packages/examples/proxies-weather/README.md new file mode 100644 index 0000000000..3c9262e58b --- /dev/null +++ b/packages/examples/proxies-weather/README.md @@ -0,0 +1,12 @@ +# proxies-weather + +Public weather extraction with proxy geography configuration. + +Choose a language and follow its setup instructions: + +| Language | Stagehand dependency | Directory from the repository root | +| ---------------------------------- | -------------------- | ---------------------------------------------- | +| [TypeScript](typescript/README.md) | `4.0.0` | `packages/examples/proxies-weather/typescript` | +| [Python](python/README.md) | `4.0.0` | `packages/examples/proxies-weather/python` | + +Each variant installs the published Stagehand package and keeps its own dependencies. diff --git a/packages/examples/proxies-weather/python/README.md b/packages/examples/proxies-weather/python/README.md new file mode 100644 index 0000000000..c62205b7b2 --- /dev/null +++ b/packages/examples/proxies-weather/python/README.md @@ -0,0 +1,60 @@ +# Stagehand + Browserbase: Weather Proxy Demo + +Location in the Stagehand repository: `packages/examples/proxies-weather/python`. + +Stagehand is the SDK for browser agents. + +## AT A GLANCE + +- Goal: demonstrate geolocation proxies by fetching location-specific weather data from multiple cities using Browserbase's proxy infrastructure. +- Uses geolocation proxies to route traffic through specific geographic locations (New York, London, Tokyo, Sรฃo Paulo). +- Extracts structured weather data using Stagehand's extraction capabilities with Pydantic schema validation. +- Sequential processing shows how different proxy locations return different weather data from the same website. +- Docs โ†’ https://docs.browserbase.com/features/proxies + +## GLOSSARY + +- geolocation proxies: route traffic through specific geographic locations (city, country, state) to access location-specific content + Docs โ†’ https://docs.browserbase.com/features/proxies#set-proxy-geolocation +- extract: extract structured data from web pages using natural language instructions and Pydantic schemas + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- proxies: Browserbase's managed proxy infrastructure supporting 201+ countries for geolocation-based routing + Docs โ†’ https://docs.browserbase.com/features/proxies + +## QUICKSTART + +1. cd packages/examples/proxies-weather/python +2. uv venv venv +3. source venv/bin/activate # On Windows: venv\Scripts\activate +4. uvx install stagehand browserbase python-dotenv pydantic +5. cp .env.example .env +6. Add your Browserbase API key to .env +7. python main.py + +## EXPECTED OUTPUT + +- Creates Browserbase sessions with geolocation proxies for each location (New York, London, Tokyo, Sรฃo Paulo) +- Displays session URLs for each location for monitoring +- Navigates to weather service (windy.com) through location-specific proxies +- Extracts temperature and unit for each location +- Displays formatted results showing different weather data based on proxy location +- Demonstrates how geolocation proxies enable location-specific content access + +## COMMON PITFALLS + +- Browserbase Developer plan or higher is required to use proxies +- "ModuleNotFoundError": ensure all dependencies are installed via uvx install +- Missing credentials: verify .env contains BROWSERBASE_API_KEY +- Geolocation fields are case-insensitive (city, country, state can be any case) +- State is required for US locations to ensure accurate geolocation +- ERR_TUNNEL_CONNECTION_FAILED: indicates either a temporary proxy hiccup or a site unsupported by our built-in proxies +- Import errors: activate your virtual environment if you created one + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/proxies-weather/python/main.py b/packages/examples/proxies-weather/python/main.py new file mode 100644 index 0000000000..698e6e9e82 --- /dev/null +++ b/packages/examples/proxies-weather/python/main.py @@ -0,0 +1,136 @@ +"""Verify geolocation proxies with live weather data and Stagehand V4.""" + +import asyncio +import os +from dataclasses import dataclass + +from dotenv import load_dotenv +from pydantic import BaseModel + +from stagehand import BrowserbaseProxyConfig, Stagehand, browserbase + +load_dotenv() + +EXPECTED_COUNTRIES = { + "US": "United States", + "GB": "United Kingdom", + "JP": "Japan", + "BR": "Brazil", +} + + +@dataclass(frozen=True) +class Geolocation: + city: str + country: str + state: str | None = None + + +@dataclass(frozen=True) +class WeatherResult: + city: str + country: str + temperature: float + conditions: str + reported_location: str + reported_country: str + + +class ExtractedWeather(BaseModel): + temperature: float + conditions: str + reported_location: str + reported_country: str + + +async def get_weather_for_location(location: Geolocation) -> WeatherResult: + api_key = os.environ.get("BROWSERBASE_API_KEY") + if not api_key: + raise RuntimeError("BROWSERBASE_API_KEY is required") + + city_name = location.city.replace("_", " ") + proxy: BrowserbaseProxyConfig = { + "type": "browserbase", + "geolocation": { + "city": location.city, + "country": location.country, + **({"state": location.state} if location.state else {}), + }, + } + + print(f"Getting live weather for {city_name}, {location.country}") + browser = await browserbase.launch(api_key=api_key, proxies=[proxy]) + try: + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + await page.goto( + "https://wttr.in/?format=j1", + wait_until="domcontentloaded", + timeout=60_000, + ) + extracted = await stagehand.extract( + ( + "Extract the current temperature in Celsius, current weather description, " + "nearest reported city or area, and reported country from this weather JSON" + ), + ExtractedWeather, + page=page, + ) + weather = extracted.data + temperature = weather.temperature + conditions = weather.conditions.strip() + reported_location = weather.reported_location.strip() + reported_country = weather.reported_country.strip() + + if not conditions or not reported_location or not reported_country: + raise RuntimeError("Weather service returned incomplete current conditions") + expected_country = EXPECTED_COUNTRIES[location.country] + if expected_country.lower() not in reported_country.lower(): + raise RuntimeError( + f"Proxy mismatch: expected {expected_country}, received {reported_country}" + ) + + return WeatherResult( + city=city_name, + country=location.country, + temperature=temperature, + conditions=conditions, + reported_location=reported_location, + reported_country=reported_country, + ) + finally: + await stagehand.close() + finally: + await browser.close() + + +async def main() -> None: + locations = [ + Geolocation("NEW_YORK", "US", "NY"), + Geolocation("LONDON", "GB"), + Geolocation("TOKYO", "JP"), + Geolocation("SAO_PAULO", "BR"), + ] + results = [await get_weather_for_location(location) for location in locations] + + print("\n=== Weather Results ===") + for result in results: + print( + f"{result.city}, {result.country}: {result.temperature} ยฐC, " + f"{result.conditions} (reported near {result.reported_location}, " + f"{result.reported_country})" + ) + print("All four proxy locations returned validated live weather") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as error: + print(f"Application error: {error}") + print("Docs: https://docs.stagehand.dev/v4/first-steps/introduction") + raise diff --git a/packages/examples/proxies-weather/python/pyproject.toml b/packages/examples/proxies-weather/python/pyproject.toml new file mode 100644 index 0000000000..2544c4468b --- /dev/null +++ b/packages/examples/proxies-weather/python/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "proxies-weather" +version = "0.1.0" +requires-python = ">=3.11,<3.14" +dependencies = ["python-dotenv==1.2.2", "stagehand==4.0.0"] + +[tool.uv] +package = false diff --git a/packages/examples/proxies-weather/python/requirements.txt b/packages/examples/proxies-weather/python/requirements.txt new file mode 100644 index 0000000000..427e839b3b --- /dev/null +++ b/packages/examples/proxies-weather/python/requirements.txt @@ -0,0 +1,3 @@ +python-dotenv +pydantic +stagehand==4.0.0 diff --git a/packages/examples/proxies-weather/typescript/.env.example b/packages/examples/proxies-weather/typescript/.env.example new file mode 100644 index 0000000000..d0cde0427d --- /dev/null +++ b/packages/examples/proxies-weather/typescript/.env.example @@ -0,0 +1 @@ +BROWSERBASE_API_KEY= diff --git a/packages/examples/proxies-weather/typescript/README.md b/packages/examples/proxies-weather/typescript/README.md new file mode 100644 index 0000000000..267da7afbf --- /dev/null +++ b/packages/examples/proxies-weather/typescript/README.md @@ -0,0 +1,55 @@ +# Stagehand + Browserbase: Weather Proxy Demo + +Location in the Stagehand repository: `packages/examples/proxies-weather/typescript`. + +## AT A GLANCE + +- Goal: demonstrate geolocation proxies by fetching location-specific weather data from multiple cities using Browserbase's proxy infrastructure. +- Uses geolocation proxies to route traffic through specific geographic locations (New York, London, Tokyo, Sรฃo Paulo). +- Uses `extract()` to read current `wttr.in` JSON through each proxied browser and verifies that the service reports the expected country. +- Sequential processing shows how different proxy locations return different weather data from the same website. +- Docs โ†’ https://docs.browserbase.com/features/proxies + +## GLOSSARY + +- geolocation proxies: route traffic through specific geographic locations (city, country, state) to access location-specific content + Docs โ†’ https://docs.browserbase.com/features/proxies#set-proxy-geolocation +- extract: convert each weather response into schema-validated data + Docs โ†’ https://docs.stagehand.dev/v4/basics/extract +- proxies: Browserbase's managed proxy infrastructure supporting 201+ countries for geolocation-based routing + Docs โ†’ https://docs.browserbase.com/features/proxies + +## QUICKSTART + +1. cd packages/examples/proxies-weather/typescript +2. pnpm install +3. cp .env.example .env +4. Add your Browserbase API key to .env +5. pnpm start + +## EXPECTED OUTPUT + +- Creates Browserbase sessions with geolocation proxies for each location (New York, London, Tokyo, Sรฃo Paulo) +- Closes each Stagehand instance and Browserbase browser handle after extraction +- Navigates to `wttr.in` through location-specific proxies +- Validates temperature, conditions, nearest reported location, and country for every proxy +- Displays formatted results showing different weather data based on proxy location +- Demonstrates how geolocation proxies enable location-specific content access + +## COMMON PITFALLS + +- Browserbase Developer plan or higher is required to use proxies +- "Cannot find module": install the template dependencies with `pnpm install` +- Missing credentials: verify .env contains BROWSERBASE_API_KEY +- Geolocation fields are case-insensitive (city, country, state can be any case) +- State is required for US locations to ensure accurate geolocation +- ERR_TUNNEL_CONNECTION_FAILED: indicates either a temporary proxy hiccup or a site unsupported by our built-in proxies + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/proxies-weather/typescript/index.ts b/packages/examples/proxies-weather/typescript/index.ts new file mode 100644 index 0000000000..cc4210da9c --- /dev/null +++ b/packages/examples/proxies-weather/typescript/index.ts @@ -0,0 +1,220 @@ +// Stagehand + Browserbase: Weather Proxy Demo - See README.md for full documentation + +import "dotenv/config"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod/v4"; + +interface GeolocationConfig { + city: string; + country: string; + state?: string; +} + +interface WeatherResult { + city: string; + country: string; + temperature: number; + unit: string; + conditions: string; + reportedLocation: string; + reportedCountry: string; + error?: string; +} + +const WeatherSchema = z.object({ + temperature: z.number().describe("Current temperature in degrees Celsius"), + conditions: z.string().min(1).describe("Current weather description"), + reportedLocation: z.string().min(1).describe("Nearest reported city or area"), + reportedCountry: z.string().min(1).describe("Reported country name"), +}); + +const EXPECTED_COUNTRIES: Record = { + US: "United States", + GB: "United Kingdom", + JP: "Japan", + BR: "Brazil", +}; + +async function closeSession( + stagehand: Stagehand, + browser: Awaited>, +) { + await stagehand.close().catch((error) => console.warn("Stagehand cleanup warning:", error)); + await browser.close().catch((error) => console.warn("Browser cleanup warning:", error)); +} + +// Fetches weather data for a specific location using geolocation proxies +// Configures Stagehand with location-specific proxy, navigates to weather site, +// and extracts temperature data using Stagehand's structured extraction capabilities +async function getWeatherForLocation(geolocation: GeolocationConfig): Promise { + const cityName = geolocation.city.replace(/_/g, " "); + console.log(`\n=== Getting weather for ${cityName}, ${geolocation.country} ===`); + + // Initialize Stagehand with geolocation proxy configuration + // This ensures all browser traffic routes through the specified geographic location + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + proxies: [ + { + type: "browserbase", // Use Browserbase's managed proxy infrastructure for reliable geolocation routing + geolocation: { + city: geolocation.city, // City name (case-insensitive, e.g., "NEW_YORK", "new_york", "New York" all work) + country: geolocation.country, // ISO country code (case-insensitive, e.g., "US", "us", "gb", "GB" all work) + ...(geolocation.state && { state: geolocation.state }), // State required for US locations (case-insensitive) + }, + }, + ], + }); + const stagehand = await Stagehand.create({ browser: browser, logging: { level: "error" } }); + + try { + // Initialize browser session to start automation + console.log(`Initializing Stagehand for ${cityName}...`); + + console.log(`Stagehand initialized successfully for ${cityName}`); + + const page = (await browser.context.pages())[0]; + + // Navigate to weather service - geolocation proxy ensures location-specific weather data + console.log(`Navigating to weather service for ${cityName}...`); + await page.goto("https://wttr.in/?format=j1", { + waitUntil: "domcontentloaded", + timeout: 60000, + }); + console.log(`Page loaded for ${cityName}`); + + // wttr.in derives the location from the proxied IP and returns current + // conditions as JSON; Stagehand turns that response into the template schema. + console.log(`Extracting current weather data for ${cityName}...`); + const { data: weather } = await stagehand.extract( + "Extract the current temperature in Celsius, weather description, nearest reported city or area, and reported country from this weather JSON", + WeatherSchema, + ); + const { temperature, conditions, reportedLocation, reportedCountry } = weather; + + const expectedCountry = EXPECTED_COUNTRIES[geolocation.country]; + if (!Number.isFinite(temperature)) { + throw new Error("Weather service did not return a numeric current temperature"); + } + if (!reportedCountry.toLowerCase().includes(expectedCountry.toLowerCase())) { + throw new Error( + `Proxy location mismatch: expected ${expectedCountry}, received ${reportedCountry}`, + ); + } + + console.log( + `Successfully read weather near ${reportedLocation}, ${reportedCountry}: ${temperature} ยฐC, ${conditions}`, + ); + + // Close Stagehand session to release resources + await closeSession(stagehand, browser); + + return { + city: cityName, + country: geolocation.country, + temperature, + unit: "ยฐC", + conditions, + reportedLocation, + reportedCountry, + }; + } catch (error) { + await closeSession(stagehand, browser); + console.error(`Error getting weather for ${cityName}:`, error); + return { + city: cityName, + country: geolocation.country, + temperature: 0, + unit: "", + conditions: "", + reportedLocation: "", + reportedCountry: "", + error: error instanceof Error ? error.message : String(error), + }; + } +} + +// Displays formatted weather results for all processed locations +// Shows successful results with temperature data or error messages for failed locations +function displayResults(results: WeatherResult[]) { + console.log("\n=== Weather Results ==="); + + for (const result of results) { + if (result.error) { + console.log(`${result.city}, ${result.country}: Error - ${result.error}`); + } else { + console.log( + `${result.city}, ${result.country}: ${result.temperature} ${result.unit}, ${result.conditions} (reported near ${result.reportedLocation}, ${result.reportedCountry})`, + ); + } + } +} + +// Main orchestration function: processes multiple locations sequentially using geolocation proxies +// Demonstrates how different proxy locations return different weather data from the same website +async function main() { + // Define locations to test - demonstrating the power of geolocation proxies + // Each location will route traffic through its respective geographic proxy to get location-specific weather + // Note: All geolocation fields (city, country, state) are case-insensitive + const locations: GeolocationConfig[] = [ + { + city: "NEW_YORK", + state: "NY", // State required for US locations (case-insensitive) + country: "US", + }, + { + city: "LONDON", + country: "GB", + }, + { + city: "TOKYO", + country: "JP", + }, + { + city: "SAO_PAULO", + country: "BR", + }, + ]; + + console.log("=== Weather Proxy Demo - Running Sequentially ===\n"); + console.log(`Processing ${locations.length} locations with geolocation proxies...`); + console.log("Each location will use a different proxy to fetch location-specific weather data\n"); + + // Collect all results for final summary + const results: WeatherResult[] = []; + + // Run each location sequentially to show different weather based on proxy location + // Sequential processing ensures clear demonstration of proxy-based location differences + for (let i = 0; i < locations.length; i++) { + const location = locations[i]; + console.log( + `\n[${i + 1}/${locations.length}] Processing ${location.city}, ${location.country}...`, + ); + const result = await getWeatherForLocation(location); + results.push(result); + } + + // Display all results in formatted summary + displayResults(results); + + const failures = results.filter((result) => result.error); + if (failures.length > 0) { + throw new Error( + `Weather extraction failed for ${failures.length} of ${results.length} locations`, + ); + } + + console.log("\n=== All locations completed ==="); +} + +main().catch((err) => { + console.error("Application error:", err); + console.error("Common issues:"); + console.error(" - Check .env file has BROWSERBASE_API_KEY"); + console.error( + " - Verify geolocation proxy locations are valid (see https://docs.browserbase.com/features/proxies)", + ); + console.error(" - Ensure locations array is properly configured"); + console.error("Docs: https://docs.stagehand.dev/v4/first-steps/introduction"); + process.exit(1); +}); diff --git a/packages/examples/proxies-weather/typescript/package.json b/packages/examples/proxies-weather/typescript/package.json new file mode 100644 index 0000000000..3f94fb3d46 --- /dev/null +++ b/packages/examples/proxies-weather/typescript/package.json @@ -0,0 +1,22 @@ +{ + "name": "proxies-weather-template", + "type": "module", + "scripts": { + "build": "tsc --noEmit --skipLibCheck --target ES2022 --module NodeNext --moduleResolution NodeNext index.ts", + "start": "tsx index.ts" + }, + "dependencies": { + "@browserbasehq/stagehand": "4.0.0", + "dotenv": "^16.4.7", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^22.18.0", + "tsx": "^4.19.2", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=22.18.0" + }, + "packageManager": "pnpm@10.24.0" +} diff --git a/packages/examples/proxies/README.md b/packages/examples/proxies/README.md new file mode 100644 index 0000000000..cb405254ab --- /dev/null +++ b/packages/examples/proxies/README.md @@ -0,0 +1,12 @@ +# proxies + +Stagehand extraction from public IP-information endpoints. + +Choose a language and follow its setup instructions: + +| Language | Stagehand dependency | Directory from the repository root | +| ---------------------------------- | -------------------- | -------------------------------------- | +| [TypeScript](typescript/README.md) | `4.0.2` | `packages/examples/proxies/typescript` | +| [Python](python/README.md) | `4.0.0` | `packages/examples/proxies/python` | + +Each variant installs the published Stagehand package and keeps its own dependencies. diff --git a/packages/examples/proxies/python/.env.example b/packages/examples/proxies/python/.env.example new file mode 100644 index 0000000000..d0cde0427d --- /dev/null +++ b/packages/examples/proxies/python/.env.example @@ -0,0 +1 @@ +BROWSERBASE_API_KEY= diff --git a/packages/examples/proxies/python/README.md b/packages/examples/proxies/python/README.md new file mode 100644 index 0000000000..529d9fd77e --- /dev/null +++ b/packages/examples/proxies/python/README.md @@ -0,0 +1,63 @@ +# Browserbase Proxy Testing Script + +Location in the Stagehand repository: `packages/examples/proxies/python`. + +Stagehand is the SDK for browser agents. + +## AT A GLANCE + +- Goal: demonstrate different proxy configurations with Browserbase sessions. + +## GLOSSARY + +- Proxies: Browserbase's default proxy rotation for enhanced privacy + Docs โ†’ https://docs.browserbase.com/features/proxies + +## QUICKSTART + +1. cd packages/examples/proxies/python +2. uv venv venv +3. source venv/bin/activate # On Windows: venv\Scripts\activate +4. pip install -r requirements.txt +5. pip install browserbase playwright +6. playwright install chromium +7. cp .env.example .env # Add your Browserbase API key to .env +8. python main.py + +## EXPECTED OUTPUT + +- Tests built-in proxy rotation +- Tests geolocation-specific proxies (New York) +- Tests custom external proxies (commented out by default) +- Displays IP information and geolocation data for each test +- Shows how different proxy configurations affect your apparent location + +## COMMON PITFALLS + +- Browserbase Developer plan or higher is required to use proxies +- "ModuleNotFoundError": ensure all dependencies are installed via pip +- Missing credentials: verify .env contains BROWSERBASE_API_KEY +- Custom proxy errors: verify external proxy server credentials and availability +- Playwright not installed: run `playwright install chromium` after pip install +- Import errors: activate your virtual environment if you created one + +## USE CASES + +โ€ข Geo-testing: Verify location-specific content, pricing, or compliance banners. +โ€ข Scraping at scale: Rotate IPs to reduce blocks and increase CAPTCHA success rates. +โ€ข Custom routing: Mix built-in and external proxies, or apply domain-based rules for compliance. + +## NEXT STEPS + +โ€ข Add routing rules: Configure domainPattern to direct specific sites through targeted proxies. +โ€ข Test multiple geos: Compare responses from different cities/countries and log differences. +โ€ข Improve reliability: Add retries and fallbacks to handle proxy errors like ERR_TUNNEL_CONNECTION_FAILED. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/proxies/python/main.py b/packages/examples/proxies/python/main.py new file mode 100644 index 0000000000..b41c48b5e4 --- /dev/null +++ b/packages/examples/proxies/python/main.py @@ -0,0 +1,84 @@ +"""Verify Browserbase built-in and geolocation proxies with Stagehand V4.""" + +import asyncio +import json +import os + +from dotenv import load_dotenv +from pydantic import BaseModel + +from stagehand import BrowserbaseProxyConfig, Stagehand, browserbase + +load_dotenv() + + +class GeoInfo(BaseModel): + ip: str + city: str + region: str + country: str + loc: str + timezone: str + org: str + postal: str | None + hostname: str | None + + +async def test_session(proxies: bool | list[BrowserbaseProxyConfig], name: str) -> GeoInfo: + api_key = os.environ.get("BROWSERBASE_API_KEY") + if not api_key: + raise RuntimeError("BROWSERBASE_API_KEY is required") + + print(f"\n=== Testing {name} ===") + browser = await browserbase.launch(api_key=api_key, proxies=proxies) + try: + stagehand = await Stagehand.create( + browser=browser, + ) + try: + pages = await browser.context.pages() + page = pages[0] if pages else await browser.context.new_page() + await page.goto("https://ipinfo.io/json", wait_until="domcontentloaded") + extracted = await stagehand.extract( + "Extract the complete IP geolocation record shown in this JSON response", + GeoInfo, + page=page, + ) + geo_info = extracted.data + print(json.dumps(geo_info.model_dump(mode="json"), indent=2)) + return geo_info + finally: + await stagehand.close() + finally: + await browser.close() + + +async def main() -> None: + built_in = await test_session(True, "Built-in Proxies") + new_york = await test_session( + [ + { + "type": "browserbase", + "geolocation": {"city": "NEW_YORK", "state": "NY", "country": "US"}, + } + ], + "Geolocation Proxies (New York)", + ) + + if ( + new_york.country != "US" + or new_york.region not in {"New York", "New Jersey"} + or new_york.timezone != "America/New_York" + ): + raise RuntimeError( + "Expected a New York metropolitan-area proxy; received " + f"{new_york.city}, {new_york.region}, {new_york.country}" + ) + if built_in.ip == new_york.ip: + raise RuntimeError("Built-in and geolocation proxy sessions returned the same IP") + + print("\nAll proxy tests completed with distinct IPs") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/packages/examples/proxies/python/pyproject.toml b/packages/examples/proxies/python/pyproject.toml new file mode 100644 index 0000000000..190e6c18f1 --- /dev/null +++ b/packages/examples/proxies/python/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "proxies" +version = "0.1.0" +requires-python = ">=3.11,<3.14" +dependencies = ["pydantic>=2.12,<3", "python-dotenv==1.2.2", "stagehand==4.0.0"] + +[tool.uv] +package = false diff --git a/packages/examples/proxies/typescript/.env.example b/packages/examples/proxies/typescript/.env.example new file mode 100644 index 0000000000..d0cde0427d --- /dev/null +++ b/packages/examples/proxies/typescript/.env.example @@ -0,0 +1 @@ +BROWSERBASE_API_KEY= diff --git a/packages/examples/proxies/typescript/README.md b/packages/examples/proxies/typescript/README.md new file mode 100644 index 0000000000..919d8c6075 --- /dev/null +++ b/packages/examples/proxies/typescript/README.md @@ -0,0 +1,54 @@ +# Browserbase Proxy Testing Script + +Location in the Stagehand repository: `packages/examples/proxies/typescript`. + +## AT A GLANCE + +- Goal: demonstrate different proxy configurations with Browserbase sessions. + +## GLOSSARY + +- Proxies: Browserbase's default proxy rotation for enhanced privacy + Docs โ†’ https://docs.browserbase.com/features/proxies + +## QUICKSTART + +1. cd packages/examples/proxies/typescript +2. Install the template dependencies +3. cp .env.example .env +4. Add your Browserbase API key to .env +5. Run the template entrypoint + +## EXPECTED OUTPUT + +- Tests built-in proxy rotation +- Tests geolocation-specific proxies (New York) +- Displays IP information and geolocation data for each test +- Verifies that the New York session reports the expected region/country/timezone and a different IP + +## COMMON PITFALLS + +- Browserbase Developer plan or higher is required to use proxies +- "Cannot find module": ensure all dependencies are installed +- Missing credentials: verify .env contains BROWSERBASE_API_KEY + +## USE CASES + +โ€ข Geo-testing: Verify location-specific content, pricing, or compliance banners. +โ€ข Scraping at scale: Rotate IPs to reduce blocks and increase CAPTCHA success rates. +โ€ข Custom routing: Add external proxies or domain-based rules for compliance. + +## NEXT STEPS + +โ€ข Add routing rules: Configure domainPattern to direct specific sites through targeted proxies. +โ€ข Test multiple geos: Compare responses from different cities/countries and log differences. +โ€ข Improve reliability: Add retries and fallbacks to handle proxy errors like ERR_TUNNEL_CONNECTION_FAILED. + +## HELPFUL RESOURCES + +๐Ÿ“š Stagehand Docs: https://docs.stagehand.dev/v4/first-steps/introduction +๐ŸŽฎ Browserbase: https://www.browserbase.com +๐Ÿ’ก Try it out: https://www.browserbase.com/playground +๐Ÿ”ง Templates: https://www.browserbase.com/templates +๐Ÿ“ง Need help? support@browserbase.com +๐Ÿ’ฌ Discord: http://stagehand.dev/discord diff --git a/packages/examples/proxies/typescript/index.ts b/packages/examples/proxies/typescript/index.ts new file mode 100644 index 0000000000..70efdebef2 --- /dev/null +++ b/packages/examples/proxies/typescript/index.ts @@ -0,0 +1,101 @@ +// Browserbase Proxy Testing Script - See README.md for full documentation + +import { browserbase, Stagehand, type BrowserbaseLaunchOptions } from "@browserbasehq/stagehand"; +import { z } from "zod/v4"; +import "dotenv/config"; + +const GeoInfoSchema = z.object({ + ip: z.string().min(1), + city: z.string().min(1), + region: z.string().min(1), + country: z.string().min(1), + loc: z.string().min(1), + timezone: z.string().min(1), + org: z.string().min(1), + postal: z.string().nullable(), + hostname: z.string().nullable(), +}); + +type GeoInfo = z.infer; + +async function testSession( + proxies: BrowserbaseLaunchOptions["proxies"], + sessionName: string, +): Promise { + console.log(`\n=== Testing ${sessionName} ===`); + + const browser = await browserbase.launch({ + apiKey: process.env.BROWSERBASE_API_KEY!, + proxies, + }); + const stagehand = await Stagehand.create({ + browser, + logging: { level: "error" }, + }); + + try { + console.log("Browserbase session launched"); + const page = (await browser.context.pages())[0]; + + // These services report the public IP observed after Browserbase applies the proxy. + // Keep a second provider because public IP endpoints occasionally reject cloud traffic. + let geoInfo: GeoInfo | undefined; + let endpointError: unknown; + for (const endpoint of ["https://ipinfo.io/json", "https://ifconfig.co/json"]) { + try { + await page.goto(endpoint, { waitUntil: "domcontentloaded" }); + const extracted = await stagehand.extract( + "Extract the complete IP geolocation record shown in this JSON response. Return country as its two-letter code, loc as latitude,longitude, timezone as its IANA name, org as the network organization, and null for missing postal or hostname values.", + GeoInfoSchema, + ); + geoInfo = extracted.data; + break; + } catch (error) { + endpointError = error; + console.warn(`Could not read ${endpoint}; trying the next geolocation endpoint`); + } + } + if (!geoInfo) throw endpointError ?? new Error("No IP geolocation endpoint succeeded"); + + console.log("Geo Info:", JSON.stringify(geoInfo, null, 2)); + console.log(`${sessionName} test completed`); + return geoInfo; + } finally { + await stagehand.close().catch((error) => console.warn("Stagehand cleanup warning:", error)); + await browser.close().catch((error) => console.warn("Browser cleanup warning:", error)); + } +} + +async function main() { + const builtIn = await testSession(true, "Built-in Proxies"); + + const newYork = await testSession( + [ + { + type: "browserbase", + geolocation: { city: "NEW_YORK", state: "NY", country: "US" }, + }, + ], + "Geolocation Proxies (New York)", + ); + + if ( + newYork.country !== "US" || + !/^(?:new york|new jersey|ny|nj)$/i.test(newYork.region.trim()) || + newYork.timezone !== "America/New_York" + ) { + throw new Error( + `Expected a New York-region proxy; received ${newYork.city}, ${newYork.region}, ${newYork.country}`, + ); + } + if (builtIn.ip === newYork.ip) { + throw new Error("Built-in and geolocation proxy sessions returned the same IP"); + } + + console.log("\n=== All proxy tests completed with distinct IPs ==="); +} + +main().catch((error) => { + console.error("Proxy test failed:", error); + process.exit(1); +}); diff --git a/packages/examples/proxies/typescript/package.json b/packages/examples/proxies/typescript/package.json new file mode 100644 index 0000000000..84376ce8d0 --- /dev/null +++ b/packages/examples/proxies/typescript/package.json @@ -0,0 +1,22 @@ +{ + "name": "proxies", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "tsx index.ts" + }, + "dependencies": { + "@browserbasehq/stagehand": "4.0.2", + "dotenv": "^17.4.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "tsx": "^4.23.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.18.0" + } +} diff --git a/packages/examples/qa-agent/README.md b/packages/examples/qa-agent/README.md new file mode 100644 index 0000000000..b71d8950e7 --- /dev/null +++ b/packages/examples/qa-agent/README.md @@ -0,0 +1,230 @@ +# QA AI Agent Demo โ€” Browserbase + Stagehand + +Location in the Stagehand repository: `packages/examples/qa-agent`. + +A demo showing how to build an AI-powered QA agent using [Browserbase](https://browserbase.com) and [Stagehand](https://stagehand.dev), with the [Vercel AI SDK](https://ai-sdk.dev) for tool orchestration. + +The demo includes a **buggy e-commerce app** with 20 intentional bugs and a **QA agent** that automatically finds them using two different architectural approaches. + +## Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Vercel AI SDK โ”‚ Claude orchestrates the QA session +โ”‚ (Claude Sonnet) โ”‚ via tool calls +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ tool calls + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Stagehand Tools โ”‚ act(), extract(), observe(), +โ”‚ (or Agent) โ”‚ screenshot(), a11y, UX audit +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ browser commands + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Browserbase โ”‚ Cloud browser session +โ”‚ (Chrome) โ”‚ with session recording +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ navigates + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ BugMart App โ”‚ Next.js app with +โ”‚ (target URL) โ”‚ 20 intentional bugs +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## Two Approaches + +### Approach A: Primitives as Tools + +Each Stagehand primitive is exposed as an individual Vercel AI SDK tool. The LLM (Claude) decides which tool to call and when, maintaining full control over the testing flow. Includes programmatic checks like accessibility audits and a **UI/UX best practices audit** based on [userinterface.wiki](https://userinterface.wiki) (152 rules). + +**Tools:** `navigate`, `act`, `extract`, `observe`, `screenshot`, `get_console_logs`, `check_accessibility`, `check_ux_best_practices`, `get_page_url` + +### Approach B: Agent as Tool + +Stagehand's built-in `agent()` is wrapped as a single tool in hybrid mode (DOM + screenshot-based). The outer LLM acts as a coordinator, delegating high-level testing tasks to the agent which autonomously navigates and interacts. + +**Tools:** `stagehand_agent`, `extract_data`, `screenshot`, `get_console_logs` + +### Comparison + +| Dimension | Approach A (Primitives) | Approach B (Agent) | +| ------------------- | ---------------------------------------------- | -------------------------------------------------- | +| **Control** | Full โ€” LLM decides every click | Delegated โ€” agent navigates autonomously | +| **Custom checks** | Can run JS (console logs, a11y, UX audit) | Limited to what the agent sees visually | +| **Token usage** | Higher outer-loop (every step hits LLM) | Lower outer-loop, but agent uses tokens internally | +| **Debugging** | Every action visible as a tool call | Agent steps are partially opaque | +| **Code complexity** | More tool definitions to write | Fewer, simpler tools | +| **Flexibility** | Mix browser actions with custom logic | Constrained to agent capabilities | +| **Best for** | Protocol-driven testing with custom assertions | Exploratory testing, quick coverage | +| **Bug types found** | Visual + programmatic + UX violations | Primarily visual + functional | + +## Benchmark Results + +We ran both approaches against the same buggy app. Results are non-deterministic (LLM-based), but the pattern is consistent across runs: + +| Metric | Approach A (Primitives) | Approach B (Agent) | +| -------------- | ----------------------- | ------------------ | +| **Duration** | ~139s | ~337s | +| **Tool calls** | 34 | 15 (outer) | +| **Bugs found** | 11 | 6 | + +### What Each Approach Catches + +| Bug | Approach A | Approach B | +| --------------------------------- | ------------------ | ------------------ | +| Negative price (-$5.00) | :white_check_mark: | :white_check_mark: | +| Price 10x on detail pages | :white_check_mark: | :white_check_mark: | +| Broken image (keyboard 404) | :white_check_mark: | :x: | +| "Prodcuts" typo | :white_check_mark: | :x: | +| Missing alt text (a11y) | :white_check_mark: | :x: | +| Heading hierarchy skip (a11y) | :white_check_mark: | :x: | +| Console errors / 404s | :white_check_mark: | :white_check_mark: | +| Low contrast text (a11y) | :white_check_mark: | :x: | +| Small click targets (UX) | :white_check_mark: | :x: | +| Typography violations (UX) | :white_check_mark: | :x: | +| Disabled button no indicator (UX) | :white_check_mark: | :x: | +| Cart nav link โ†’ /carts (404) | :x: | :white_check_mark: | + +### Key Insights + +1. **Approach A excels at systematic, programmatic testing.** The `check_accessibility` and `check_ux_best_practices` tools run JavaScript audits that catch issues no amount of visual browsing would find (missing alt text, heading hierarchy, click target sizes, font-variant-numeric). + +2. **Approach B excels at exploratory, user-like testing.** The autonomous agent naturally clicks nav links and follows user flows, which is how it caught the broken cart link (`/carts` instead of `/cart`) that Approach A missed by navigating directly via URL. + +3. **Custom `page.evaluate()` tools don't mix well with the agent approach.** Adding UX audit tools to Approach B caused a regression (from 10 bugs to 5) because the coordinator had to waste steps navigating the agent back to pages just to run JS checks. Removing them restored performance. The lesson: **let each approach play to its strengths**. + +4. **Combine both for maximum coverage.** Run Approach A for protocol-driven testing with custom assertions, then run Approach B for exploratory testing that mimics real user behavior. Together they catch more than either alone. + +## Bug Catalog + +The buggy app contains 20 intentional bugs across different categories: + +| # | Type | Page | Description | Difficulty | +| --- | -------------- | -------------- | ------------------------------------------------- | ----------- | +| 01 | Missing image | Homepage | Product image returns 404 | Obvious | +| 02 | Wrong price | Product detail | Price displayed 10x too high | Non-obvious | +| 03 | Negative price | Homepage | Product shows -$5.00 | Obvious | +| 04 | Race condition | Cart | Double-click adds item twice | Non-obvious | +| 05 | Off-by-one | Cart | Quantity goes to 0 instead of removing item | Non-obvious | +| 06 | Typo | Homepage | Heading says "Our Prodcuts" | Obvious | +| 07 | Console error | All pages | Failed fetch to /api/analytics | Non-obvious | +| 08 | Dead button | Homepage | Add to Cart disabled on product #3, no visual cue | Non-obvious | +| 09 | Accessibility | Homepage | Images missing alt text | Non-obvious | +| 10 | Layout | Product detail | Description overlaps button on narrow viewport | Non-obvious | +| 11 | Calculation | Cart | Tax = subtotal x 0.8 instead of 0.08 (80% tax!) | Non-obvious | +| 12 | Calculation | Cart | Total doesn't include tax | Non-obvious | +| 13 | Validation | Checkout | Email field accepts any string | Non-obvious | +| 14 | Validation | Checkout | Card number accepts letters | Non-obvious | +| 15 | Dead button | Checkout | "Place Order" does nothing (TODO in code) | Obvious | +| 16 | Accessibility | About | Light gray text (#ccc) on white background | Non-obvious | +| 17 | Broken link | About | "Contact Us" links to /contact (404) | Obvious | +| 18 | Accessibility | About | Heading jumps h1 to h4 | Non-obvious | +| 19 | Broken link | Navbar | Cart link points to /carts (typo) | Obvious | +| 20 | CSS | Global | Modal z-index: -1 (behind content) | Non-obvious | + +## Setup + +### Prerequisites + +- Node.js 18+ +- A [Browserbase](https://browserbase.com) account +- An [Anthropic](https://console.anthropic.com) API key +- An [OpenAI](https://platform.openai.com) API key +- [ngrok](https://ngrok.com) (if running the buggy app locally โ€” Browserbase needs a public URL) + +### 1. Start the Buggy App + +```bash +cd buggy-app +npm install +npm run dev +# Runs on http://localhost:3000 +``` + +If using Browserbase (cloud browser), expose your local app via ngrok: + +```bash +ngrok http 3000 +# Copy the https://xxxx.ngrok-free.app URL +``` + +### 2. Configure the QA Agent + +```bash +cd qa-agent +npm install +cp .env.example .env +``` + +Edit `.env` with your keys: + +``` +BROWSERBASE_API_KEY=your-key +BROWSERBASE_PROJECT_ID=your-project-id +OPENAI_API_KEY=your-key +ANTHROPIC_API_KEY=your-key +APP_URL=https://your-ngrok-url.ngrok-free.app +``` + +### 3. Run the QA Agent + +```bash +# Approach A: Primitives as Tools (with UX audit) +npm run approach-a + +# Approach B: Agent as Tool (exploratory) +npm run approach-b + +# Or use the CLI +npm start -- a +npm start -- b +``` + +Watch the live browser session in your [Browserbase dashboard](https://browserbase.com/sessions). + +## How It Works + +### Approach A Flow + +1. Claude receives a system prompt with the testing strategy +2. It calls `navigate` to go to each page +3. It uses `extract` to check data, `act` to interact, `observe` to plan +4. It runs `check_accessibility` and `check_ux_best_practices` for programmatic checks +5. It takes `screenshot`s to visually inspect pages +6. After testing all pages, it compiles a bug report + +### Approach B Flow + +1. Claude acts as a coordinator, breaking testing into chunks +2. Each chunk is delegated to `stagehand_agent` (e.g., "test the homepage for all visible issues") +3. The agent autonomously navigates, clicks, and explores in hybrid mode (DOM + screenshots) +4. Claude reviews agent results and extracts additional data +5. Console logs are checked between agent tasks +6. All findings are compiled into a final report + +## The UX Best Practices Tool + +Approach A includes a `check_ux_best_practices` tool that runs automated checks based on [userinterface.wiki](https://userinterface.wiki) โ€” 152 rules across 12 categories. The tool runs `page.evaluate()` to programmatically check: + +- **Fitts's Law**: Interactive targets must be at least 32x32px +- **Hick's Law**: Navigation shouldn't have >7 items without grouping +- **Z-index hierarchy**: Flags negative z-index values +- **Tabular numbers**: Prices should use `font-variant-numeric: tabular-nums` +- **Text-wrap balance**: Headings should use `text-wrap: balance` +- **Active states**: Buttons should have transitions for feedback +- **Input types**: Email fields should use `type="email"`, not `type="text"` +- **Disabled states**: Disabled buttons must have visual indicators (opacity, cursor) +- **Progressive disclosure**: Forms with >6 fields should be broken into sections + +This tool is only effective in Approach A because it requires direct browser control via `page.evaluate()`. The agent in Approach B navigates autonomously and can't run custom JavaScript checks. + +## Extending This Demo + +- **Add more bugs**: Edit the app files in `buggy-app/src/` +- **Add more tools**: Extend `qa-agent/src/approach-a/tools.ts` +- **Add more UX rules**: Extend the `check_ux_best_practices` tool with rules from [userinterface.wiki](https://userinterface.wiki) +- **Custom test plans**: Modify the system prompts in the run files +- **Different models**: Change the model in `stagehand-init.ts` or the `generateText` calls +- **Combine approaches**: Run A then B for maximum coverage diff --git a/packages/examples/qa-agent/buggy-app/next-env.d.ts b/packages/examples/qa-agent/buggy-app/next-env.d.ts new file mode 100644 index 0000000000..40c3d68096 --- /dev/null +++ b/packages/examples/qa-agent/buggy-app/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/packages/examples/qa-agent/buggy-app/next.config.js b/packages/examples/qa-agent/buggy-app/next.config.js new file mode 100644 index 0000000000..d918f80407 --- /dev/null +++ b/packages/examples/qa-agent/buggy-app/next.config.js @@ -0,0 +1,3 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; +module.exports = nextConfig; diff --git a/packages/examples/qa-agent/buggy-app/package.json b/packages/examples/qa-agent/buggy-app/package.json new file mode 100644 index 0000000000..9e4159ad26 --- /dev/null +++ b/packages/examples/qa-agent/buggy-app/package.json @@ -0,0 +1,24 @@ +{ + "name": "buggy-app", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "^14.2.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.3.0" + } +} diff --git a/packages/examples/qa-agent/buggy-app/postcss.config.js b/packages/examples/qa-agent/buggy-app/postcss.config.js new file mode 100644 index 0000000000..12a703d900 --- /dev/null +++ b/packages/examples/qa-agent/buggy-app/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/examples/qa-agent/buggy-app/src/app/about/page.tsx b/packages/examples/qa-agent/buggy-app/src/app/about/page.tsx new file mode 100644 index 0000000000..eafb7a985c --- /dev/null +++ b/packages/examples/qa-agent/buggy-app/src/app/about/page.tsx @@ -0,0 +1,40 @@ +import Link from "next/link"; + +export default function AboutPage() { + return ( +
+

About BugMart

+ + {/* BUG-18: Heading jumps from h1 to h4, skipping h2 and h3 */} +

Our Mission

+ + {/* BUG-16: Light gray text on white background โ€” poor contrast */} +

+ BugMart was founded in 2024 with a simple mission: to provide high-quality electronics at + affordable prices. We believe that everyone deserves access to the best technology without + breaking the bank. Our team of experts carefully curates each product in our catalog to + ensure it meets our standards for quality, performance, and value. +

+ +

Our Team

+

+ We are a team of passionate tech enthusiasts who love helping people find the perfect + gadgets. With decades of combined experience in electronics retail, we know what makes a + great product. +

+ +

Get in Touch

+

+ Have questions or feedback? We would love to hear from you! +

+ + {/* BUG-17: Contact Us link goes to /contact which doesn't exist */} + + Contact Us + +
+ ); +} diff --git a/packages/examples/qa-agent/buggy-app/src/app/cart/page.tsx b/packages/examples/qa-agent/buggy-app/src/app/cart/page.tsx new file mode 100644 index 0000000000..b7d81ff4c4 --- /dev/null +++ b/packages/examples/qa-agent/buggy-app/src/app/cart/page.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useCart } from "@/lib/cart-store"; +import CartSummary from "@/components/CartSummary"; +import Link from "next/link"; + +export default function CartPage() { + const { items, removeFromCart, updateQuantity } = useCart(); + + if (items.length === 0) { + return ( +
+

Your Cart is Empty

+

Add some products to get started!

+ + Continue Shopping + +
+ ); + } + + return ( +
+

Shopping Cart

+
+
+ {items.map((item) => ( +
+ +
+

{item.product.name}

+

${item.product.price.toFixed(2)} each

+
+
+ + {/* BUG-05 surfaces here: quantity can show 0 */} + {item.quantity} + +
+ + ${(item.product.price * item.quantity).toFixed(2)} + +
+ ))} +
+
+ + + Proceed to Checkout + +
+
+
+ ); +} diff --git a/packages/examples/qa-agent/buggy-app/src/app/checkout/page.tsx b/packages/examples/qa-agent/buggy-app/src/app/checkout/page.tsx new file mode 100644 index 0000000000..e670d80171 --- /dev/null +++ b/packages/examples/qa-agent/buggy-app/src/app/checkout/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import CheckoutForm from "@/components/CheckoutForm"; +import CartSummary from "@/components/CartSummary"; + +export default function CheckoutPage() { + return ( +
+

Checkout

+
+
+ +
+
+ +
+
+
+ ); +} diff --git a/packages/examples/qa-agent/buggy-app/src/app/globals.css b/packages/examples/qa-agent/buggy-app/src/app/globals.css new file mode 100644 index 0000000000..8ad35bb36f --- /dev/null +++ b/packages/examples/qa-agent/buggy-app/src/app/globals.css @@ -0,0 +1,32 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: + system-ui, + -apple-system, + sans-serif; +} + +/* BUG-20: Modal z-index is -1, making modals appear behind content */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: -1; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-content { + background: white; + border-radius: 8px; + padding: 24px; + max-width: 500px; + width: 90%; +} diff --git a/packages/examples/qa-agent/buggy-app/src/app/layout.tsx b/packages/examples/qa-agent/buggy-app/src/app/layout.tsx new file mode 100644 index 0000000000..3b410af166 --- /dev/null +++ b/packages/examples/qa-agent/buggy-app/src/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import Navbar from "@/components/Navbar"; +import { CartProvider } from "@/lib/cart-store"; + +export const metadata: Metadata = { + title: "BugMart - Shop Electronics", + description: "Your favorite electronics store", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + +
{children}
+
+ + + ); +} diff --git a/packages/examples/qa-agent/buggy-app/src/app/page.tsx b/packages/examples/qa-agent/buggy-app/src/app/page.tsx new file mode 100644 index 0000000000..a998648976 --- /dev/null +++ b/packages/examples/qa-agent/buggy-app/src/app/page.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useEffect } from "react"; +import { products } from "@/lib/products"; +import ProductCard from "@/components/ProductCard"; + +export default function HomePage() { + // BUG-07: Console error โ€” fetches non-existent analytics endpoint + useEffect(() => { + fetch("/api/analytics").catch(() => {}); + }, []); + + return ( +
+ {/* BUG-06: Typo โ€” "Prodcuts" instead of "Products" */} +

Our Prodcuts

+
+ {products.map((product) => ( + + ))} +
+
+ ); +} diff --git a/packages/examples/qa-agent/buggy-app/src/app/product/[id]/page.tsx b/packages/examples/qa-agent/buggy-app/src/app/product/[id]/page.tsx new file mode 100644 index 0000000000..f053eb2872 --- /dev/null +++ b/packages/examples/qa-agent/buggy-app/src/app/product/[id]/page.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { products } from "@/lib/products"; +import { useCart } from "@/lib/cart-store"; +import Link from "next/link"; + +export default function ProductPage() { + const params = useParams(); + const { addToCart } = useCart(); + const product = products.find((p) => p.id === Number(params.id)); + + if (!product) { + return ( +
+

Product Not Found

+ + Back to Home + +
+ ); + } + + return ( +
+
+
+ +
+
+ {product.category} +

{product.name}

+ + {/* BUG-10: Description has position absolute, overlaps button on narrow viewports */} +

+ {product.description} +

+ + {/* BUG-02: Price is multiplied by 10 (displayed as 10x actual) */} +
+ + ${(product.price * 10).toFixed(2)} + +
+ +
+ {product.inStock ? ( + + ) : ( + Out of Stock + )} + + Back to Shop + +
+
+
+
+ ); +} diff --git a/packages/examples/qa-agent/buggy-app/src/components/CartSummary.tsx b/packages/examples/qa-agent/buggy-app/src/components/CartSummary.tsx new file mode 100644 index 0000000000..e6803c18e3 --- /dev/null +++ b/packages/examples/qa-agent/buggy-app/src/components/CartSummary.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useCart } from "@/lib/cart-store"; + +export default function CartSummary() { + const { items } = useCart(); + + const subtotal = items.reduce((sum, item) => sum + item.product.price * item.quantity, 0); + + // BUG-11: Tax is 80% instead of 8% (0.8 vs 0.08) + const tax = subtotal * 0.8; + + // BUG-12: Total doesn't include tax โ€” just shows subtotal + const total = subtotal; + + return ( +
+

Order Summary

+
+
+ Subtotal + ${subtotal.toFixed(2)} +
+
+ Tax + ${tax.toFixed(2)} +
+
+ Total + ${total.toFixed(2)} +
+
+
+ ); +} diff --git a/packages/examples/qa-agent/buggy-app/src/components/CheckoutForm.tsx b/packages/examples/qa-agent/buggy-app/src/components/CheckoutForm.tsx new file mode 100644 index 0000000000..d3a7d3af27 --- /dev/null +++ b/packages/examples/qa-agent/buggy-app/src/components/CheckoutForm.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useState } from "react"; + +export default function CheckoutForm() { + const [formData, setFormData] = useState({ + name: "", + email: "", + address: "", + city: "", + zip: "", + cardNumber: "", + expiry: "", + cvv: "", + }); + const [submitted, setSubmitted] = useState(false); + + const handleChange = (e: React.ChangeEvent) => { + setFormData({ ...formData, [e.target.name]: e.target.value }); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + // BUG-15: Submit handler does nothing โ€” TODO never implemented + // TODO: implement order submission + console.log("Form submitted", formData); + }; + + return ( +
+
+

Shipping Information

+
+
+ + +
+
+ + {/* BUG-13: Email field has type="text" โ€” no email validation */} + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ +
+

Payment Information

+
+
+ + {/* BUG-14: Card number field accepts letters โ€” no pattern/type restriction */} + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+ ); +} diff --git a/packages/examples/qa-agent/buggy-app/src/components/Navbar.tsx b/packages/examples/qa-agent/buggy-app/src/components/Navbar.tsx new file mode 100644 index 0000000000..f446b63952 --- /dev/null +++ b/packages/examples/qa-agent/buggy-app/src/components/Navbar.tsx @@ -0,0 +1,35 @@ +"use client"; + +import Link from "next/link"; +import { useCart } from "@/lib/cart-store"; + +export default function Navbar() { + const { totalItems } = useCart(); + + return ( + + ); +} diff --git a/packages/examples/qa-agent/buggy-app/src/components/ProductCard.tsx b/packages/examples/qa-agent/buggy-app/src/components/ProductCard.tsx new file mode 100644 index 0000000000..5dfc824e28 --- /dev/null +++ b/packages/examples/qa-agent/buggy-app/src/components/ProductCard.tsx @@ -0,0 +1,41 @@ +"use client"; + +import Link from "next/link"; +import { Product } from "@/lib/products"; +import { useCart } from "@/lib/cart-store"; + +export default function ProductCard({ product }: { product: Product }) { + const { addToCart } = useCart(); + + return ( +
+ + {/* BUG-09: Images have empty alt text โ€” no descriptive alt */} + + +
+ +

+ {product.name} +

+ +

{product.description}

+
+ ${product.price.toFixed(2)} + {product.inStock ? ( + + ) : ( + Out of Stock + )} +
+
+
+ ); +} diff --git a/packages/examples/qa-agent/buggy-app/src/lib/cart-store.ts b/packages/examples/qa-agent/buggy-app/src/lib/cart-store.ts new file mode 100644 index 0000000000..461ee36454 --- /dev/null +++ b/packages/examples/qa-agent/buggy-app/src/lib/cart-store.ts @@ -0,0 +1,71 @@ +"use client"; + +import { createContext, useContext, useState, useCallback, ReactNode } from "react"; +import React from "react"; +import { Product } from "./products"; + +export interface CartItem { + product: Product; + quantity: number; +} + +interface CartContextType { + items: CartItem[]; + addToCart: (product: Product) => void; + removeFromCart: (productId: number) => void; + updateQuantity: (productId: number, quantity: number) => void; + clearCart: () => void; + totalItems: number; +} + +const CartContext = createContext(undefined); + +export function CartProvider({ children }: { children: ReactNode }) { + const [items, setItems] = useState([]); + + // BUG-04: Race condition โ€” setTimeout causes double-click to add item twice + const addToCart = useCallback((product: Product) => { + setTimeout(() => { + setItems((prev) => { + const existing = prev.find((item) => item.product.id === product.id); + if (existing) { + return prev.map((item) => + item.product.id === product.id ? { ...item, quantity: item.quantity + 1 } : item, + ); + } + return [...prev, { product, quantity: 1 }]; + }); + }, 0); + }, []); + + // BUG-05: Off-by-one โ€” decrements to 0 instead of removing + const removeFromCart = useCallback((productId: number) => { + setItems((prev) => + prev.map((item) => + item.product.id === productId ? { ...item, quantity: item.quantity - 1 } : item, + ), + ); + }, []); + + const updateQuantity = useCallback((productId: number, quantity: number) => { + setItems((prev) => + prev.map((item) => (item.product.id === productId ? { ...item, quantity } : item)), + ); + }, []); + + const clearCart = useCallback(() => setItems([]), []); + + const totalItems = items.reduce((sum, item) => sum + item.quantity, 0); + + return React.createElement( + CartContext.Provider, + { value: { items, addToCart, removeFromCart, updateQuantity, clearCart, totalItems } }, + children, + ); +} + +export function useCart() { + const context = useContext(CartContext); + if (!context) throw new Error("useCart must be used within CartProvider"); + return context; +} diff --git a/packages/examples/qa-agent/buggy-app/src/lib/products.ts b/packages/examples/qa-agent/buggy-app/src/lib/products.ts new file mode 100644 index 0000000000..412ed5e8a1 --- /dev/null +++ b/packages/examples/qa-agent/buggy-app/src/lib/products.ts @@ -0,0 +1,72 @@ +export interface Product { + id: number; + name: string; + description: string; + price: number; + image: string; + category: string; + inStock: boolean; +} + +export const products: Product[] = [ + { + id: 1, + name: "Wireless Bluetooth Speaker", + description: + "Portable speaker with 12-hour battery life and rich bass. Perfect for outdoor adventures and indoor gatherings alike.", + price: 49.99, + image: "https://picsum.photos/seed/speaker/400/400", + category: "Electronics", + inStock: true, + }, + { + id: 2, + name: "Premium Headphones", + description: + "Noise-cancelling over-ear headphones with premium sound quality. Features active noise cancellation and 30-hour battery life.", + price: 29.99, // BUG-02: Actually worth $299.90 - product detail page multiplies by 10 + image: "https://picsum.photos/seed/headphones/400/400", + category: "Electronics", + inStock: true, + }, + { + id: 3, + name: "Ergonomic Mouse", + description: + "Wireless ergonomic mouse designed for all-day comfort. Features adjustable DPI and silent clicks.", + price: 34.99, + image: "https://picsum.photos/seed/mouse/400/400", + category: "Accessories", + inStock: true, // BUG-08: Button is disabled despite being "in stock" + }, + { + id: 4, + name: "Mechanical Keyboard", + description: + "RGB mechanical keyboard with Cherry MX switches. Full-size layout with dedicated media controls.", + price: 89.99, + image: "/images/nonexistent-keyboard.png", // BUG-01: Image 404 + category: "Accessories", + inStock: true, + }, + { + id: 5, + name: "USB-C Hub", + description: + "7-in-1 USB-C hub with HDMI, USB 3.0, SD card reader, and power delivery passthrough.", + price: -5.0, // BUG-03: Negative price + image: "https://picsum.photos/seed/usbhub/400/400", + category: "Accessories", + inStock: true, + }, + { + id: 6, + name: "Laptop Stand", + description: + "Adjustable aluminum laptop stand with ventilation. Raises screen to eye level for better ergonomics.", + price: 45.99, + image: "https://picsum.photos/seed/stand/400/400", + category: "Accessories", + inStock: false, + }, +]; diff --git a/packages/examples/qa-agent/buggy-app/tailwind.config.js b/packages/examples/qa-agent/buggy-app/tailwind.config.js new file mode 100644 index 0000000000..629ad1197e --- /dev/null +++ b/packages/examples/qa-agent/buggy-app/tailwind.config.js @@ -0,0 +1,6 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"], + theme: { extend: {} }, + plugins: [], +}; diff --git a/packages/examples/qa-agent/buggy-app/tsconfig.json b/packages/examples/qa-agent/buggy-app/tsconfig.json new file mode 100644 index 0000000000..49e4cf3b82 --- /dev/null +++ b/packages/examples/qa-agent/buggy-app/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./src/*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/examples/qa-agent/qa-agent/.env.example b/packages/examples/qa-agent/qa-agent/.env.example new file mode 100644 index 0000000000..7c0e99d54a --- /dev/null +++ b/packages/examples/qa-agent/qa-agent/.env.example @@ -0,0 +1,5 @@ +BROWSERBASE_API_KEY= +BROWSERBASE_PROJECT_ID= +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +APP_URL=http://localhost:3000 diff --git a/packages/examples/qa-agent/qa-agent/package.json b/packages/examples/qa-agent/qa-agent/package.json new file mode 100644 index 0000000000..74535952be --- /dev/null +++ b/packages/examples/qa-agent/qa-agent/package.json @@ -0,0 +1,23 @@ +{ + "name": "qa-agent", + "version": "1.0.0", + "type": "module", + "scripts": { + "approach-a": "tsx src/approach-a/run.ts", + "approach-b": "tsx src/approach-b/run.ts", + "start": "tsx src/index.ts" + }, + "dependencies": { + "@ai-sdk/anthropic": "^1.0.0", + "@ai-sdk/openai": "^1.0.0", + "@browserbasehq/stagehand": "^2.5.2", + "ai": "^4.0.0", + "dotenv": "^16.4.5", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.7.0", + "typescript": "^5.3.0" + } +} diff --git a/packages/examples/qa-agent/qa-agent/src/approach-a/run.ts b/packages/examples/qa-agent/qa-agent/src/approach-a/run.ts new file mode 100644 index 0000000000..f44f55425d --- /dev/null +++ b/packages/examples/qa-agent/qa-agent/src/approach-a/run.ts @@ -0,0 +1,92 @@ +import { generateText } from "ai"; +import { anthropic } from "@ai-sdk/anthropic"; +import { createStagehand } from "../shared/stagehand-init.js"; +import { createTools } from "./tools.js"; +import "dotenv/config"; + +const BASE_URL = process.env.APP_URL || "http://localhost:3000"; + +const SYSTEM_PROMPT = `You are a senior QA engineer performing comprehensive testing on a web application. +The application is an e-commerce store called "BugMart" running at ${BASE_URL}. + +Your mission: Systematically test every page and feature, finding as many bugs as possible. + +## Testing Strategy + +Test each page in this order: +1. **Homepage** (${BASE_URL}/) - Check product listing, images, prices, text, buttons +2. **Product Detail Pages** (${BASE_URL}/product/1 through /product/6) - Check prices, descriptions, layout, add-to-cart +3. **Cart** (${BASE_URL}/cart) - Add items first, then check calculations, quantities, remove functionality +4. **Checkout** (${BASE_URL}/checkout) - Test form validation with invalid inputs, test submission +5. **About** (${BASE_URL}/about) - Check content, links, accessibility +6. **Navigation** - Test all nav links, check for broken links + +## What to Check on Each Page +- Visual: broken images, layout overlaps, typos in text +- Functional: buttons that don't work, forms that don't validate, broken links +- Data: incorrect prices, wrong calculations, negative values +- Console: JavaScript errors, failed network requests +- Accessibility: missing alt text, contrast issues, heading hierarchy, missing labels +- UX Best Practices: click target sizes, typography, spacing, z-index issues, disabled button indicators, form field types + +## Tools Available +- navigate: Go to a page +- act: Click buttons, fill forms, interact with elements +- extract: Pull data from the page to verify correctness +- observe: See what interactive elements are available +- screenshot: Take a screenshot for visual inspection +- get_console_logs: Check for JS errors +- check_accessibility: Run automated accessibility checks +- check_ux_best_practices: Run UI/UX best practices audit (userinterface.wiki 152 rules) +- get_page_url: Verify current URL + +## Bug Report Format +After testing all pages, output a comprehensive bug report with: +- Bug ID, title, severity (critical/major/minor/cosmetic) +- Page where it occurs +- Steps to reproduce +- Expected vs actual behavior + +Be thorough! A good QA engineer catches both obvious and subtle bugs.`; + +async function main() { + console.log("=".repeat(60)); + console.log("QA Agent - Approach A: Stagehand Primitives as Tools"); + console.log("=".repeat(60)); + console.log(`Target: ${BASE_URL}`); + console.log(); + + const stagehand = await createStagehand(); + const tools = createTools(stagehand); + const startTime = Date.now(); + + try { + const result = await generateText({ + model: anthropic("claude-sonnet-4-20250514"), + system: SYSTEM_PROMPT, + prompt: + "Begin your QA testing now. Start with the homepage and work through every page systematically. Use all available tools to find bugs. Report everything you find.", + tools, + maxSteps: 50, + }); + + const duration = ((Date.now() - startTime) / 1000).toFixed(1); + + console.log("\n" + "=".repeat(60)); + console.log("QA REPORT - Approach A: Primitives as Tools"); + console.log("=".repeat(60)); + console.log(result.text); + console.log("\n" + "-".repeat(60)); + console.log(`Duration: ${duration}s`); + console.log(`Steps: ${result.steps?.length || "N/A"}`); + console.log( + `Tool calls: ${result.steps?.reduce((sum, s) => sum + (s.toolCalls?.length || 0), 0) || "N/A"}`, + ); + console.log("=".repeat(60)); + } finally { + await stagehand.close(); + console.log("\nSession closed."); + } +} + +main().catch(console.error); diff --git a/packages/examples/qa-agent/qa-agent/src/approach-a/tools.ts b/packages/examples/qa-agent/qa-agent/src/approach-a/tools.ts new file mode 100644 index 0000000000..90ab3ad0c6 --- /dev/null +++ b/packages/examples/qa-agent/qa-agent/src/approach-a/tools.ts @@ -0,0 +1,486 @@ +import { tool } from "ai"; +import { z } from "zod"; +import { writeFileSync, mkdirSync } from "fs"; +import type { Stagehand } from "@browserbasehq/stagehand"; + +export function createTools(stagehand: Stagehand) { + const page = stagehand.page; + const consoleLogs: string[] = []; + + // Capture console messages (errors, warnings) + page.on("console", (msg) => { + const type = msg.type(); + if (type === "error" || type === "warning") { + consoleLogs.push(`[${type.toUpperCase()}] ${msg.text()}`); + } + }); + + // Capture page errors + page.on("pageerror", (err) => { + consoleLogs.push(`[PAGE_ERROR] ${err.message}`); + }); + + // Capture failed network requests + page.on("requestfailed", (req) => { + consoleLogs.push(`[NETWORK_ERROR] ${req.method()} ${req.url()} - ${req.failure()?.errorText}`); + }); + + return { + navigate: tool({ + description: + "Navigate to a URL in the browser. Use this to go to specific pages of the application.", + parameters: z.object({ + url: z.string().describe("The full URL to navigate to"), + }), + execute: async ({ url }) => { + try { + await page.goto(url, { waitUntil: "load", timeout: 60000 }); + } catch (error: any) { + // Even if timeout occurs, we may still be on the page + console.log(`Navigation warning: ${error.message?.substring(0, 100)}`); + } + try { + await page.waitForTimeout(2000); + const title = await page.title(); + const currentUrl = page.url(); + return { + success: true, + url: currentUrl, + title, + message: `Navigated to ${currentUrl} (title: "${title}")`, + }; + } catch (error: any) { + return { + success: false, + url, + error: error.message, + }; + } + }, + }), + + act: tool({ + description: + "Perform a browser action described in natural language. Examples: 'click the Add to Cart button', 'type hello@example.com into the email field', 'scroll down'. Use this for any interaction with the page.", + parameters: z.object({ + instruction: z.string().describe("Natural language description of the action to perform"), + }), + execute: async ({ instruction }) => { + try { + await stagehand.act(instruction); + return { success: true, action: instruction }; + } catch (error: any) { + return { + success: false, + action: instruction, + error: error.message, + }; + } + }, + }), + + extract: tool({ + description: + "Extract structured data from the current page using AI. Describe what information you want to extract. Returns the extracted data as a JSON object.", + parameters: z.object({ + instruction: z.string().describe("What data to extract from the current page"), + }), + execute: async ({ instruction }) => { + try { + const result = await stagehand.extract({ + instruction, + schema: z.object({ + data: z.any().describe("The extracted data"), + }), + }); + return { success: true, extracted: result }; + } catch (error: any) { + return { success: false, error: error.message }; + } + }, + }), + + observe: tool({ + description: + "Observe the current page and list available interactive elements and possible actions. Use this to understand what's on the page before acting.", + parameters: z.object({ + instruction: z + .string() + .describe( + "What to look for on the page, e.g. 'find all buttons' or 'find navigation links'", + ), + }), + execute: async ({ instruction }) => { + try { + const observations = await stagehand.observe(instruction); + return { success: true, observations }; + } catch (error: any) { + return { success: false, error: error.message }; + } + }, + }), + + screenshot: tool({ + description: + "Take a screenshot of the current page. Use this to visually inspect the page for layout issues, broken images, or other visual bugs.", + parameters: z.object({ + description: z + .string() + .optional() + .describe("Optional note about what to look for in the screenshot"), + }), + execute: async ({ description }) => { + try { + mkdirSync("screenshots", { recursive: true }); + const filename = `screenshots/screenshot-${Date.now()}.png`; + const buffer = await page.screenshot({ fullPage: true }); + writeFileSync(filename, buffer); + // Get page text content for analysis instead of sending huge base64 + const textContent = await page.evaluate(() => { + return document.body.innerText.substring(0, 3000); + }); + return { + success: true, + savedTo: filename, + note: description || "Screenshot captured", + url: page.url(), + pageText: textContent, + }; + } catch (error: any) { + return { success: false, error: error.message }; + } + }, + }), + + get_console_logs: tool({ + description: + "Get all JavaScript console errors and warnings captured since the session started. Use this to check for runtime errors, failed API calls, and other issues.", + parameters: z.object({}), + execute: async () => { + return { + logs: [...consoleLogs], + count: consoleLogs.length, + message: + consoleLogs.length > 0 + ? `Found ${consoleLogs.length} console issues` + : "No console errors detected", + }; + }, + }), + + check_accessibility: tool({ + description: + "Run accessibility checks on the current page. Checks for missing alt text, poor color contrast, missing form labels, heading hierarchy issues, and more.", + parameters: z.object({}), + execute: async () => { + const issues = await page.evaluate(() => { + const problems: string[] = []; + + // Check images without meaningful alt text + document.querySelectorAll("img").forEach((img, i) => { + if (!img.alt || img.alt.trim() === "") { + problems.push(`Image #${i + 1} missing alt text (src: ${img.src.substring(0, 80)})`); + } + }); + + // Check broken images + document.querySelectorAll("img").forEach((img, i) => { + if (!img.complete || img.naturalWidth === 0) { + problems.push(`Image #${i + 1} failed to load (src: ${img.src.substring(0, 80)})`); + } + }); + + // Check form inputs without labels + document.querySelectorAll("input, select, textarea").forEach((el) => { + const input = el as HTMLInputElement; + const id = input.id; + if (id && !document.querySelector(`label[for="${id}"]`)) { + problems.push(`Input "${id}" (type: ${input.type}) has no associated