diff --git a/python/extend-browserbase/.env.example b/python/extend-browserbase/.env.example new file mode 100644 index 0000000..fad9566 --- /dev/null +++ b/python/extend-browserbase/.env.example @@ -0,0 +1,13 @@ +# Browserbase credentials (required) +# Get these from https://www.browserbase.com/settings +BROWSERBASE_PROJECT_ID=your_browserbase_project_id +BROWSERBASE_API_KEY=your_browserbase_api_key + +# Google API key for Gemini model (required for Stagehand) +GOOGLE_API_KEY=your_google_api_key + +# Extend AI (optional – enables receipt parsing; omit to only download receipts) +EXTEND_API_KEY=your_extend_api_key + +# Optional: set after first run to reuse the created processor +# EXTEND_PROCESSOR_ID=your_processor_id diff --git a/python/extend-browserbase/README.md b/python/extend-browserbase/README.md new file mode 100644 index 0000000..15d558f --- /dev/null +++ b/python/extend-browserbase/README.md @@ -0,0 +1,81 @@ +# Stagehand + Browserbase + Extend: Download Expense Receipts and Parse with Extend AI + +## 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 EXTRACT processor with a receipt JSON schema; 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/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/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 EXTRACT processor**: A configurable document extraction pipeline that parses files against a JSON schema and returns structured data. Processors are reusable and persist across runs. + 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 python/extend-browserbase +2. cp .env.example .env +3. Add required API keys to .env: + - `BROWSERBASE_PROJECT_ID` + - `BROWSERBASE_API_KEY` + - `GOOGLE_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: creates/uses an Extend "Receipt Extractor" processor, uploads each file, runs extraction, writes `output/results/receipts.json` and `receipts.csv` +- Opens the Extend dashboard runs page in your browser for review +- 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_PROJECT_ID, BROWSERBASE_API_KEY, and GOOGLE_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/v3/first-steps/introduction +📚 Python SDK: https://docs.stagehand.dev/v3/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/python/extend-browserbase/main.py b/python/extend-browserbase/main.py new file mode 100644 index 0000000..8caea1a --- /dev/null +++ b/python/extend-browserbase/main.py @@ -0,0 +1,641 @@ +# 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 re +import webbrowser +import zipfile +from pathlib import Path + +from browserbase import APIStatusError, Browserbase +from dotenv import load_dotenv +from extend_ai import Extend +from stagehand import AsyncStagehand + +# Load environment variables from .env file +# Required: BROWSERBASE_API_KEY, BROWSERBASE_PROJECT_ID, GOOGLE_API_KEY +# Optional: EXTEND_API_KEY, EXTEND_PROCESSOR_ID +load_dotenv() + + +# Receipt extraction config for Extend AI +# Uses extraction_light base processor with parse_performance engine for low latency +RECEIPT_EXTRACTION_CONFIG = { + "type": "EXTRACT", + "baseProcessor": "extraction_light", + "baseVersion": "3.4.0", + "parser": { + "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, + }, +} + + +def open_in_browser(url: str) -> None: + """Opens a URL in the default browser for live view and dashboard links.""" + try: + webbrowser.open(url) + except Exception: + print(f"Could not auto-open: {url}") + + +# 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 + for entry in entries: + zip_ref.extract(entry, output_dir) + extracted_path = output_path / entry + print(f"Extracted: {extracted_path}") + extracted_files.append(str(extracted_path)) + + print(f"\nTotal files extracted: {len(extracted_files)}") + return extracted_files + + +# Gets existing Extend processor or creates a new one, saving the ID to .env for reuse. +# This avoids recreating the processor on every run. +async def get_or_create_processor(extend_client: Extend) -> str: + """ + Get an existing Extend processor or create a new one. + + Checks for EXTEND_PROCESSOR_ID in environment. If not found, creates a new + 'Receipt Extractor' processor and saves the ID to .env for future runs. + + Args: + extend_client: Extend client instance for API calls + + Returns: + str: The processor ID + """ + # Check if a processor ID already exists in environment + existing_id = os.environ.get("EXTEND_PROCESSOR_ID") + if existing_id and existing_id != "YOUR_EXTEND_PROCESSOR_ID_HERE": + print(f"Using existing processor: {existing_id}") + return existing_id + + # Create a new Receipt Extractor processor via the Extend API + print("No EXTEND_PROCESSOR_ID found. Creating 'Receipt Extractor' processor...") + + response = await asyncio.to_thread( + extend_client.processor.create, + name="Receipt Extractor", + type="EXTRACT", + config=RECEIPT_EXTRACTION_CONFIG, + ) + + processor_id = response.processor.id + print(f"Created processor: {processor_id}") + print(f" View in dashboard: https://dashboard.extend.app/studio/processors/{processor_id}") + + # Persist the processor ID to .env so we don't recreate on next run + env_path = Path(".env") + if env_path.exists(): + env_content = env_path.read_text() + if "EXTEND_PROCESSOR_ID=" in env_content: + env_content = re.sub( + r"EXTEND_PROCESSOR_ID=.*", + f"EXTEND_PROCESSOR_ID={processor_id}", + env_content, + ) + else: + env_content += f"\nEXTEND_PROCESSOR_ID={processor_id}\n" + env_path.write_text(env_content) + else: + with open(env_path, "a") as f: + f.write(f"EXTEND_PROCESSOR_ID={processor_id}\n") + print(" Saved EXTEND_PROCESSOR_ID to .env for future runs.") + + return processor_id + + +# 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]) -> None: + """ + Upload receipt files to Extend AI, run extraction, and save results. + + Initializes the Extend client, gets or creates a processor, uploads each file, + runs synchronous extraction, 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 and get or create the receipt processor + extend_client = Extend(token=extend_api_key) + processor_id = await get_or_create_processor(extend_client) + + print(f"Processing {len(file_paths)} receipts...") + runs_url = f"https://dashboard.extend.app/studio/processors/{processor_id}?tab=Runs" + print(f"View all runs: {runs_url}\n") + open_in_browser(runs_url) + + # 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.file.upload, + file=(file_name, file_bytes), + ) + file_id = upload_response.file.id + + # Run extraction on the uploaded file + extraction_response = await asyncio.to_thread( + extend_client.processor_run.create, + processor_id=processor_id, + file={"fileId": file_id}, + sync=True, + config=RECEIPT_EXTRACTION_CONFIG, + ) + + parsed_data = extraction_response.processor_run + run_id = parsed_data.id + run_url = ( + f"https://dashboard.extend.app/studio/processors/{processor_id}/runs/{run_id}" + ) + print(f" Parsed {file_name}") + print(f" -> {run_url}") + + # Convert to dict for JSON serialization + data = parsed_data + if hasattr(parsed_data, "to_dict"): + data = parsed_data.to_dict() + elif hasattr(parsed_data, "dict"): + data = parsed_data.dict() + + return { + "file": file_name, + "runId": run_id, + "runUrl": run_url, + "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", + "run_url", + "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", ""), + result.get("runUrl", ""), + 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}") + print(f"\nView all runs: {runs_url}") + + +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") + browserbase_project_id = os.environ.get("BROWSERBASE_PROJECT_ID") + google_api_key = os.environ.get("GOOGLE_API_KEY") + + if not browserbase_api_key or not browserbase_project_id: + raise ValueError("BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID are required") + + # Initialize Browserbase SDK for session management and download retrieval + bb = Browserbase(api_key=browserbase_api_key) + + # Initialize AsyncStagehand client (v3 BYOB architecture) + client = AsyncStagehand( + browserbase_api_key=browserbase_api_key, + browserbase_project_id=browserbase_project_id, + model_api_key=google_api_key, + ) + + # Start a Stagehand session (returns a response with session_id) + start_response = await client.sessions.start( + model_name="google/gemini-2.5-flash", + ) + session_id = start_response.data.session_id + print(f"Stagehand session started: {session_id}") + + try: + # Get live view URL for monitoring browser session in real-time + # Use asyncio.to_thread for synchronous Browserbase SDK calls + live_view_links = await asyncio.to_thread(bb.sessions.debug, session_id) + live_view_link = live_view_links.debuggerFullscreenUrl + print(f"Live View Link: {live_view_link}") + open_in_browser(live_view_link) + + # Navigate to the expense portal where receipts are hosted + print("\nNavigating to expense portal...") + await client.sessions.navigate( + id=session_id, + url="https://v0-reimburse-me-expense-portal.vercel.app/", + ) + + # Use observe to find all individual download buttons (not the Download All button) + print("\nFinding all individual download buttons...") + observe_response = await client.sessions.observe( + id=session_id, + instruction="Find all the small Download links on individual receipt cards.", + ) + download_buttons = observe_response.data.result + + # 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)}...") + + # Convert observed action to dict for passing to act + action_dict = ( + action.to_dict(exclude_none=True) if hasattr(action, "to_dict") else action + ) + + try: + await client.sessions.act(id=session_id, input=action_dict) + 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 client.sessions.act(id=session_id, input="Scroll down slightly") + await client.sessions.act(id=session_id, input=action_dict) + 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 client.sessions.act(id=session_id, input="Scroll down slightly") + + print(f"\nDownload clicks completed! ({success_count}/{len(download_buttons)} successful)") + + # End the Stagehand session before fetching downloads + await client.sessions.end(id=session_id) + 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}") + try: + await client.sessions.end(id=session_id) + except Exception: + # Ignore close errors during cleanup + pass + 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_PROJECT_ID, " + "BROWSERBASE_API_KEY, and GOOGLE_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/v3/first-steps/introduction") + exit(1) diff --git a/python/extend-browserbase/pyproject.toml b/python/extend-browserbase/pyproject.toml new file mode 100644 index 0000000..29b4f90 --- /dev/null +++ b/python/extend-browserbase/pyproject.toml @@ -0,0 +1,34 @@ +[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.9" +dependencies = [ + "browserbase>=1.4.0", + "extend-ai>=0.0.21", + "python-dotenv>=1.2.1", + "stagehand>=3.5.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/typescript/extend-browserbase/.env.example b/typescript/extend-browserbase/.env.example new file mode 100644 index 0000000..b249aaf --- /dev/null +++ b/typescript/extend-browserbase/.env.example @@ -0,0 +1,12 @@ +# Browserbase Configuration +BROWSERBASE_PROJECT_ID=your_browserbase_project_id +BROWSERBASE_API_KEY=your_browserbase_api_key + +# Google API key for Gemini model (Stagehand) +GOOGLE_API_KEY=your_google_api_key + +# Extend AI (optional – enables receipt parsing; omit to only download receipts) +EXTEND_API_KEY=your_extend_api_key + +# Optional: set after first run to reuse the created processor +# EXTEND_PROCESSOR_ID=your_processor_id diff --git a/typescript/extend-browserbase/README.md b/typescript/extend-browserbase/README.md new file mode 100644 index 0000000..3ec8439 --- /dev/null +++ b/typescript/extend-browserbase/README.md @@ -0,0 +1,77 @@ +# Stagehand + Browserbase + Extend: Download Expense Receipts and Parse with Extend AI + +## 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 EXTRACT processor with a receipt JSON schema; 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/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/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 EXTRACT processor**: A configurable document extraction pipeline that parses files against a JSON schema and returns structured data. Processors are reusable and persist across runs. + 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 typescript/extend-browserbase +2. pnpm install +3. cp .env.example .env +4. Add required API keys to .env: + - `BROWSERBASE_PROJECT_ID` + - `BROWSERBASE_API_KEY` + - `GOOGLE_API_KEY` + - `EXTEND_API_KEY` (optional — enables receipt parsing) +5. pnpm start + +## 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: creates/uses an Extend "Receipt Extractor" processor, uploads each file, runs extraction, writes `output/results/receipts.json` and `receipts.csv` +- Opens the Extend dashboard runs page in your browser for review +- Closes session cleanly + +## COMMON PITFALLS + +- "Cannot find module": ensure pnpm install completed in the extend-browserbase directory +- Missing credentials: verify .env contains BROWSERBASE_PROJECT_ID, BROWSERBASE_API_KEY, and GOOGLE_API_KEY +- Download timeout: increase `retryForSeconds` parameter in `saveDownloadsWithRetry` 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 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/v3/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/typescript/extend-browserbase/index.ts b/typescript/extend-browserbase/index.ts new file mode 100644 index 0000000..679c52d --- /dev/null +++ b/typescript/extend-browserbase/index.ts @@ -0,0 +1,534 @@ +// 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 { Stagehand } from "@browserbasehq/stagehand"; +import fs from "fs"; +import path from "path"; +import AdmZip from "adm-zip"; +import { ExtendClient } from "extend-ai"; +import open from "open"; + +// Opens a URL in the default browser (cross-platform) +function openInBrowser(url: string): void { + open(url).catch(() => { + console.log(`Could not auto-open: ${url}`); + }); +} + +// 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 processor with parse_performance engine for low latency +const receiptExtractionConfig = { + type: "EXTRACT", + baseProcessor: "extraction_light", + baseVersion: "3.4.0", + parser: { + 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, + }, +}; + +// Gets existing Extend processor or creates a new one, saving the ID to .env for reuse. +// This avoids recreating the processor on every run. +async function getOrCreateProcessor(client: ExtendClient): Promise { + // Check if a processor ID already exists in environment + const existingId = process.env.EXTEND_PROCESSOR_ID; + if (existingId && existingId !== "YOUR_EXTEND_PROCESSOR_ID_HERE") { + console.log(`Using existing processor: ${existingId}`); + return existingId; + } + + // Create a new Receipt Extractor processor via the Extend API + console.log("No EXTEND_PROCESSOR_ID found. Creating 'Receipt Extractor' processor..."); + + const response = await client.processor.create({ + name: "Receipt Extractor", + type: "EXTRACT", + config: receiptExtractionConfig as Parameters[0]["config"], + }); + + const processorId = response.processor.id; + console.log(`Created processor: ${processorId}`); + console.log(` View in dashboard: https://dashboard.extend.app/studio/processors/${processorId}`); + + // Persist the processor ID to .env so we don't recreate on next run + const envPath = path.resolve(".env"); + if (fs.existsSync(envPath)) { + let envContent = fs.readFileSync(envPath, "utf-8"); + if (envContent.includes("EXTEND_PROCESSOR_ID=")) { + envContent = envContent.replace( + /EXTEND_PROCESSOR_ID=.*/, + `EXTEND_PROCESSOR_ID=${processorId}`, + ); + } else { + envContent += `\nEXTEND_PROCESSOR_ID=${processorId}\n`; + } + fs.writeFileSync(envPath, envContent); + } else { + fs.writeFileSync(envPath, `EXTEND_PROCESSOR_ID=${processorId}\n`, { flag: "a" }); + } + console.log(" Saved EXTEND_PROCESSOR_ID to .env for future runs."); + + return processorId; +} + +// 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 and get or create the receipt processor + // SDK auto-retries 429s and 5xx errors with exponential backoff + const client = new ExtendClient({ token: process.env.EXTEND_API_KEY }); + const processorId = await getOrCreateProcessor(client); + + console.log(`Processing ${filePaths.length} receipts...`); + const runsUrl = `https://dashboard.extend.app/studio/processors/${processorId}?tab=Runs`; + console.log(`View all runs: ${runsUrl}\n`); + openInBrowser(runsUrl); + + // Process all files - SDK handles retries automatically with exponential backoff + const results: { file: string; runId?: string; runUrl?: 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.file.upload( + blob as Parameters[0], + { maxRetries: 4 }, + ); + const fileId = uploadResponse.file.id; + + // Run extraction on the uploaded file + const extractionResponse = await client.processorRun.create( + { + processorId, + file: { fileId }, + sync: true, + config: receiptExtractionConfig as Parameters< + typeof client.processorRun.create + >[0]["config"], + }, + { maxRetries: 4 }, + ); + + const parsedData = extractionResponse.processorRun; + const runId = parsedData.id; + const runUrl = `https://dashboard.extend.app/studio/processors/${processorId}/runs/${runId}`; + console.log(` Parsed ${fileName}`); + console.log(` → ${runUrl}`); + return { file: fileName, runId, runUrl, data: parsedData }; + } 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,run_url,vendor_name,receipt_date,receipt_number,total_amount,currency,subtotal,tax,payment_method,line_items_count", + ); + + // Shape of the extracted receipt data from Extend processor 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, + result.runUrl || "", + 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}`); + + console.log(`\nView all runs: ${runsUrl}`); +} + +async function main(): Promise { + console.log("Starting Expense Receipt Downloader...\n"); + + if (!process.env.BROWSERBASE_API_KEY || !process.env.BROWSERBASE_PROJECT_ID) { + throw new Error("BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID are required"); + } + + // Initialize Browserbase SDK for session management and download retrieval + const bb = new Browserbase({ + apiKey: process.env.BROWSERBASE_API_KEY as string, + }); + + // Initialize Stagehand with Browserbase for cloud-based browser automation + const stagehand = new Stagehand({ + env: "BROWSERBASE", + verbose: 1, + // 0 = errors only, 1 = info, 2 = debug + // (When handling sensitive data like passwords or API keys, set verbose: 0 to prevent secrets from appearing in logs.) + // https://docs.stagehand.dev/configuration/logging + model: { + modelName: "google/gemini-2.5-flash", + apiKey: process.env.GOOGLE_API_KEY, + }, + }); + + let sessionId: string | undefined; + + try { + // Initialize browser session to start automation + await stagehand.init(); + console.log("Stagehand initialized successfully!"); + const page = stagehand.context.pages()[0]; + sessionId = stagehand.browserbaseSessionId; + + // Get live view URL for monitoring browser session in real-time + if (sessionId) { + const liveViewLinks = await bb.sessions.debug(sessionId); + console.log(`Live View Link: ${liveViewLinks.debuggerFullscreenUrl}`); + openInBrowser(liveViewLinks.debuggerFullscreenUrl); + } + + // 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 downloadButtons = await stagehand.observe( + "Find all the small Download links on individual receipt cards.", + ); + + // 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 page.evaluate(() => window.scrollBy(0, 200)); + 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 page.evaluate(() => window.scrollBy(0, 300)); + } + } + + 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(); + + // 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); + } + } + + console.log("\nExpense receipt download complete!"); + } catch (error) { + console.error("Error during automation:", error); + try { + await stagehand.close(); + } 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_PROJECT_ID, BROWSERBASE_API_KEY, and GOOGLE_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/v3/first-steps/introduction"); + process.exit(1); +}); diff --git a/typescript/extend-browserbase/package.json b/typescript/extend-browserbase/package.json new file mode 100644 index 0000000..a45260d --- /dev/null +++ b/typescript/extend-browserbase/package.json @@ -0,0 +1,24 @@ +{ + "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.6.0", + "@browserbasehq/stagehand": "^3.0.8", + "adm-zip": "^0.5.16", + "dotenv": "^17.2.4", + "extend-ai": "^0.0.18", + "open": "^11.0.0" + }, + "devDependencies": { + "@types/node": "^25.2.2", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + }, + "packageManager": "pnpm@9.0.0" +}