├── .env.example ├── .github └── workflows │ ├── ci.yml │ └── python-publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── dendrite ├── __init__.py ├── _cli │ ├── __init__.py │ └── main.py ├── _loggers │ └── d_logger.py ├── browser │ ├── __init__.py │ ├── _common │ │ ├── _exceptions │ │ │ ├── __init__.py │ │ │ ├── _constants.py │ │ │ └── dendrite_exception.py │ │ ├── constants.py │ │ └── types.py │ ├── async_api │ │ ├── __init__.py │ │ ├── _event_sync.py │ │ ├── _utils.py │ │ ├── browser_impl │ │ │ ├── __init__.py │ │ │ ├── browserbase │ │ │ │ ├── __init__.py │ │ │ │ ├── _client.py │ │ │ │ ├── _download.py │ │ │ │ └── _impl.py │ │ │ ├── browserless │ │ │ │ ├── __init__.py │ │ │ │ └── _impl.py │ │ │ ├── impl_mapping.py │ │ │ └── local │ │ │ │ └── _impl.py │ │ ├── dendrite_browser.py │ │ ├── dendrite_element.py │ │ ├── dendrite_page.py │ │ ├── js │ │ │ ├── __init__.py │ │ │ ├── eventListenerPatch.js │ │ │ ├── generateDendriteIDs.js │ │ │ └── generateDendriteIDsIframe.js │ │ ├── manager │ │ │ ├── __init__.py │ │ │ ├── navigation_tracker.py │ │ │ ├── page_manager.py │ │ │ └── screenshot_manager.py │ │ ├── mixin │ │ │ ├── __init__.py │ │ │ ├── ask.py │ │ │ ├── click.py │ │ │ ├── extract.py │ │ │ ├── fill_fields.py │ │ │ ├── get_element.py │ │ │ ├── keyboard.py │ │ │ ├── markdown.py │ │ │ ├── screenshot.py │ │ │ └── wait_for.py │ │ ├── protocol │ │ │ ├── __init__.py │ │ │ ├── browser_protocol.py │ │ │ ├── download_protocol.py │ │ │ └── page_protocol.py │ │ └── types.py │ ├── remote │ │ ├── __init__.py │ │ ├── browserbase_config.py │ │ ├── browserless_config.py │ │ └── provider.py │ └── sync_api │ │ ├── __init__.py │ │ ├── _event_sync.py │ │ ├── _utils.py │ │ ├── browser_impl │ │ ├── __init__.py │ │ ├── browserbase │ │ │ ├── __init__.py │ │ │ ├── _client.py │ │ │ ├── _download.py │ │ │ └── _impl.py │ │ ├── browserless │ │ │ ├── __init__.py │ │ │ └── _impl.py │ │ ├── impl_mapping.py │ │ └── local │ │ │ └── _impl.py │ │ ├── dendrite_browser.py │ │ ├── dendrite_element.py │ │ ├── dendrite_page.py │ │ ├── js │ │ ├── __init__.py │ │ ├── eventListenerPatch.js │ │ ├── generateDendriteIDs.js │ │ └── generateDendriteIDsIframe.js │ │ ├── manager │ │ ├── __init__.py │ │ ├── navigation_tracker.py │ │ ├── page_manager.py │ │ └── screenshot_manager.py │ │ ├── mixin │ │ ├── __init__.py │ │ ├── ask.py │ │ ├── click.py │ │ ├── extract.py │ │ ├── fill_fields.py │ │ ├── get_element.py │ │ ├── keyboard.py │ │ ├── markdown.py │ │ ├── screenshot.py │ │ └── wait_for.py │ │ ├── protocol │ │ ├── __init__.py │ │ ├── browser_protocol.py │ │ ├── download_protocol.py │ │ └── page_protocol.py │ │ └── types.py ├── exceptions │ └── __init__.py ├── logic │ ├── __init__.py │ ├── ask │ │ ├── __init__.py │ │ ├── ask.py │ │ └── image.py │ ├── async_logic_engine.py │ ├── cache │ │ ├── __init__.py │ │ └── file_cache.py │ ├── code │ │ ├── __init__.py │ │ └── code_session.py │ ├── config.py │ ├── dom │ │ ├── __init__.py │ │ ├── css.py │ │ ├── strip.py │ │ └── truncate.py │ ├── extract │ │ ├── __init__.py │ │ ├── cache.py │ │ ├── compress_html.py │ │ ├── extract.py │ │ ├── extract_agent.py │ │ ├── prompts.py │ │ └── scroll_agent.py │ ├── get_element │ │ ├── __init__.py │ │ ├── agents │ │ │ ├── prompts │ │ │ │ └── __init__.py │ │ │ ├── segment_agent.py │ │ │ └── select_agent.py │ │ ├── cache.py │ │ ├── get_element.py │ │ ├── hanifi_search.py │ │ ├── hanifi_segment.py │ │ └── models.py │ ├── llm │ │ ├── __init__.py │ │ ├── agent.py │ │ ├── config.py │ │ └── token_count.py │ ├── sync_logic_engine.py │ └── verify_interaction │ │ ├── __init__.py │ │ └── verify_interaction.py ├── models │ ├── __init__.py │ ├── dto │ │ ├── __init__.py │ │ ├── ask_page_dto.py │ │ ├── cached_extract_dto.py │ │ ├── cached_selector_dto.py │ │ ├── extract_dto.py │ │ ├── get_elements_dto.py │ │ └── make_interaction_dto.py │ ├── page_information.py │ ├── response │ │ ├── __init__.py │ │ ├── ask_page_response.py │ │ ├── extract_response.py │ │ ├── get_element_response.py │ │ └── interaction_response.py │ ├── scripts.py │ ├── selector.py │ └── status.py ├── py.typed └── remote │ └── __init__.py ├── poetry.lock ├── publish.py ├── pyproject.toml ├── scripts └── generate_sync.py ├── test.py └── tests ├── tests_async ├── conftest.py ├── test_browserbase.py ├── test_download.py └── tests.py └── tests_sync ├── conftest.py ├── test_context.py └── test_download.py /.env.example: -------------------------------------------------------------------------------- 1 | ANTHROPIC_API_KEY= 2 | 3 | # If using Browserbase 4 | BROWSERBASE_API_KEY= 5 | BROWSERBASE_PROJECT_ID= -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '**' 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | 14 | 15 | jobs: 16 | lint: 17 | runs-on: ubuntu-latest 18 | name: lint ${{ matrix.python-version }} 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | python-version: ['3.9', '3.10', '3.11', '3.12'] 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - name: Set up Python version 27 | uses: actions/setup-python@v1 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | 31 | - name: Create and start virtual environment 32 | run: | 33 | python -m venv venv 34 | source venv/bin/activate 35 | 36 | - name: Install dependencies 37 | run: | 38 | pipx install poetry 39 | poetry install --with dev 40 | 41 | - name: Linting with flake8 42 | run: | 43 | # stop the build if there are Python syntax errors or undefined names 44 | poetry run flake8 dendrite/ --count --select=E9,F63,F7,F82 --show-source --statistics 45 | 46 | - name: Linting with pylint 47 | run: poetry run pylint dendrite/ --errors-only 48 | 49 | test: 50 | runs-on: ubuntu-latest 51 | env: 52 | BROWSERBASE_API_KEY: ${{ secrets.BROWSERBASE_API_KEY }} 53 | BROWSERBASE_PROJECT_ID: ${{ secrets.BROWSERBASE_PROJECT_ID }} 54 | steps: 55 | - uses: actions/checkout@v4 56 | 57 | - name: Set up Python version 58 | uses: actions/setup-python@v1 59 | with: 60 | python-version: 3.9 61 | 62 | - name: Create and start virtual environment 63 | run: | 64 | python -m venv venv 65 | source venv/bin/activate 66 | 67 | - name: Install dependencies 68 | run: | 69 | pipx install poetry 70 | poetry install --with dev 71 | 72 | - name: Ensure browsers are installed 73 | run: poetry run dendrite install 74 | 75 | - name: Run async tests 76 | run: poetry run pytest tests/tests_async 77 | 78 | - name: Run sync tests 79 | run: poetry run pytest tests/tests_sync -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish dendrite-python-sdk 🐍 distribution 📦 to PyPI and TestPyPI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - 'v[0-9]+.[0-9]+.[0-9]+' 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build: 13 | name: Build distribution 📦 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.9" 21 | - name: Install Poetry 22 | run: | 23 | pipx install poetry 24 | poetry install 25 | - name: Install dependencies 26 | run: | 27 | poetry install 28 | - name: Build package 29 | run: poetry build 30 | - name: Store the distribution packages 31 | uses: actions/upload-artifact@v3 32 | with: 33 | name: python-package-distributions 34 | path: dist/ 35 | 36 | publish-to-pypi: 37 | name: Publish dendrite-python-sdk 📦 to PyPI 38 | if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 39 | needs: 40 | - build 41 | runs-on: ubuntu-latest 42 | environment: 43 | name: pypi 44 | url: https://pypi.org/p/dendrite-sdk 45 | permissions: 46 | id-token: write # IMPORTANT: mandatory for trusted publishing 47 | steps: 48 | - name: Download all the dists 49 | uses: actions/download-artifact@v3 50 | with: 51 | name: python-package-distributions 52 | path: dist/ 53 | - name: Publish distribution 📦 to PyPI 54 | uses: pypa/gh-action-pypi-publish@release/v1 55 | 56 | github-release: 57 | name: >- 58 | Sign the dendrite-python-sdk 📦 with Sigstore 59 | and upload them to GitHub Release 60 | needs: 61 | - publish-to-pypi 62 | runs-on: ubuntu-latest 63 | permissions: 64 | contents: write # IMPORTANT: mandatory for making GitHub Releases 65 | id-token: write # IMPORTANT: mandatory for sigstore 66 | steps: 67 | - name: Download all the dists 68 | uses: actions/download-artifact@v3 69 | with: 70 | name: python-package-distributions 71 | path: dist/ 72 | - name: Sign the dists with Sigstore 73 | uses: sigstore/gh-action-sigstore-python@v2.1.1 74 | with: 75 | inputs: >- 76 | ./dist/*.tar.gz 77 | ./dist/*.whl 78 | - name: Create GitHub Release 79 | env: 80 | GITHUB_TOKEN: ${{ github.token }} 81 | run: >- 82 | gh release create 83 | '${{ github.ref_name }}' 84 | --repo '${{ github.repository }}' 85 | --notes "" 86 | - name: Upload artifact signatures to GitHub Release 87 | env: 88 | GITHUB_TOKEN: ${{ github.token }} 89 | run: >- 90 | gh release upload 91 | '${{ github.ref_name }}' dist/** 92 | --repo '${{ github.repository }}' 93 | 94 | manual-publish-to-pypi: 95 | name: Manually Publish dendrite-python-sdk 📦 to PyPI 96 | if: github.event_name == 'workflow_dispatch' 97 | needs: 98 | - build 99 | runs-on: ubuntu-latest 100 | environment: 101 | name: pypi 102 | url: https://pypi.org/p/dendrite-sdk 103 | permissions: 104 | id-token: write # IMPORTANT: mandatory for trusted publishing 105 | steps: 106 | - name: Download all the dists 107 | uses: actions/download-artifact@v3 108 | with: 109 | name: python-package-distributions 110 | path: dist/ 111 | - name: Publish distribution 📦 to PyPI 112 | uses: pypa/gh-action-pypi-publish@release/v1 113 | 114 | manual-github-release: 115 | name: >- 116 | Sign the dendrite-python-sdk 📦 with Sigstore 117 | and upload them to GitHub Release 118 | needs: 119 | - manual-publish-to-pypi 120 | runs-on: ubuntu-latest 121 | permissions: 122 | contents: write # IMPORTANT: mandatory for making GitHub Releases 123 | id-token: write # IMPORTANT: mandatory for sigstore 124 | steps: 125 | - name: Download all the dists 126 | uses: actions/download-artifact@v3 127 | with: 128 | name: python-package-distributions 129 | path: dist/ 130 | - name: Sign the dists with Sigstore 131 | uses: sigstore/gh-action-sigstore-python@v2.1.1 132 | with: 133 | inputs: >- 134 | ./dist/*.tar.gz 135 | ./dist/*.whl 136 | - name: Create GitHub Release 137 | env: 138 | GITHUB_TOKEN: ${{ github.token }} 139 | run: >- 140 | gh release create 141 | '${{ github.ref_name }}' 142 | --repo '${{ github.repository }}' 143 | --notes "" 144 | - name: Upload artifact signatures to GitHub Release 145 | env: 146 | GITHUB_TOKEN: ${{ github.token }} 147 | run: >- 148 | gh release upload 149 | '${{ github.ref_name }}' dist/** 150 | --repo '${{ github.repository }}' 151 | 152 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | 3 | __pycache__/ 4 | *.pyc 5 | test/* 6 | test_screenshots/* 7 | .DS_Store 8 | .vscode/ 9 | dist/ 10 | examples/ 11 | tmp/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Dendrite Systems 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /dendrite/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from dendrite._loggers.d_logger import logger 4 | from dendrite.browser.async_api import AsyncDendrite, AsyncElement, AsyncPage 5 | from dendrite.logic.config import Config 6 | 7 | from dendrite.browser.sync_api import ( 8 | Dendrite, 9 | Element, 10 | Page, 11 | ) 12 | 13 | 14 | __all__ = [ 15 | "AsyncDendrite", 16 | "AsyncElement", 17 | "AsyncPage", 18 | "Dendrite", 19 | "Element", 20 | "Page", 21 | "Config", 22 | ] 23 | -------------------------------------------------------------------------------- /dendrite/_cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrite-systems/dendrite-python-sdk/16e591332855547ca31fb8a72793d05a96b2f4d3/dendrite/_cli/__init__.py -------------------------------------------------------------------------------- /dendrite/_cli/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import asyncio 3 | import subprocess 4 | import sys 5 | 6 | from dendrite.browser.async_api import AsyncDendrite 7 | from dendrite.logic.config import Config 8 | 9 | 10 | def run_playwright_install(): 11 | try: 12 | subprocess.run(["playwright", "install", "chromium"], check=True) 13 | print("Playwright browser installation completed successfully.") 14 | except subprocess.CalledProcessError as e: 15 | print(f"Error during Playwright browser installation: {e}") 16 | sys.exit(1) 17 | except FileNotFoundError: 18 | print( 19 | "Playwright command not found. Please ensure Playwright is installed in your environment." 20 | ) 21 | sys.exit(1) 22 | 23 | 24 | async def setup_auth(url: str): 25 | try: 26 | async with AsyncDendrite() as browser: 27 | await browser.setup_auth( 28 | url=url, 29 | message="Please log in to the website. Once done, press Enter to continue...", 30 | ) 31 | except Exception as e: 32 | print(f"Error during authentication setup: {e}") 33 | sys.exit(1) 34 | 35 | 36 | def main(): 37 | parser = argparse.ArgumentParser(description="Dendrite SDK CLI tool") 38 | parser.add_argument( 39 | "command", choices=["install", "auth"], help="Command to execute" 40 | ) 41 | 42 | # Add auth-specific arguments 43 | parser.add_argument("--url", help="URL to navigate to for authentication") 44 | 45 | args = parser.parse_args() 46 | 47 | if args.command == "install": 48 | run_playwright_install() 49 | elif args.command == "auth": 50 | if not args.url: 51 | parser.error("The --url argument is required for the auth command") 52 | asyncio.run(setup_auth(args.url)) 53 | 54 | 55 | if __name__ == "__main__": 56 | main() 57 | -------------------------------------------------------------------------------- /dendrite/_loggers/d_logger.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from loguru import logger 4 | 5 | logger.remove() 6 | fmt = "{time: HH:mm:ss.SSS} | {level: <8} | {message}" 7 | logger.add(sys.stderr, level="DEBUG", format=fmt) 8 | -------------------------------------------------------------------------------- /dendrite/browser/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrite-systems/dendrite-python-sdk/16e591332855547ca31fb8a72793d05a96b2f4d3/dendrite/browser/__init__.py -------------------------------------------------------------------------------- /dendrite/browser/_common/_exceptions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrite-systems/dendrite-python-sdk/16e591332855547ca31fb8a72793d05a96b2f4d3/dendrite/browser/_common/_exceptions/__init__.py -------------------------------------------------------------------------------- /dendrite/browser/_common/_exceptions/_constants.py: -------------------------------------------------------------------------------- 1 | INVALID_AUTH_SESSION_MSG = "Missing auth session for any of: {domain}. Make sure that you have used the Dendrite Vault extension to extract your authenticated session(s) for the domain(s) you are trying to access." 2 | -------------------------------------------------------------------------------- /dendrite/browser/_common/constants.py: -------------------------------------------------------------------------------- 1 | STEALTH_ARGS = [ 2 | "--no-pings", 3 | "--mute-audio", 4 | "--no-first-run", 5 | "--no-default-browser-check", 6 | "--disable-cloud-import", 7 | "--disable-gesture-typing", 8 | "--disable-offer-store-unmasked-wallet-cards", 9 | "--disable-offer-upload-credit-cards", 10 | "--disable-print-preview", 11 | "--disable-voice-input", 12 | "--disable-wake-on-wifi", 13 | "--disable-cookie-encryption", 14 | "--ignore-gpu-blocklist", 15 | "--enable-async-dns", 16 | "--enable-simple-cache-backend", 17 | "--enable-tcp-fast-open", 18 | "--prerender-from-omnibox=disabled", 19 | "--enable-web-bluetooth", 20 | "--disable-features=AudioServiceOutOfProcess,IsolateOrigins,site-per-process,TranslateUI,BlinkGenPropertyTrees", 21 | "--aggressive-cache-discard", 22 | "--disable-extensions", 23 | "--disable-ipc-flooding-protection", 24 | "--disable-blink-features=AutomationControlled", 25 | "--test-type", 26 | "--enable-features=NetworkService,NetworkServiceInProcess,TrustTokens,TrustTokensAlwaysAllowIssuance", 27 | "--disable-component-extensions-with-background-pages", 28 | "--disable-default-apps", 29 | "--disable-breakpad", 30 | "--disable-component-update", 31 | "--disable-domain-reliability", 32 | "--disable-sync", 33 | "--disable-client-side-phishing-detection", 34 | "--disable-hang-monitor", 35 | "--disable-popup-blocking", 36 | "--disable-prompt-on-repost", 37 | "--metrics-recording-only", 38 | "--safebrowsing-disable-auto-update", 39 | "--password-store=basic", 40 | "--autoplay-policy=no-user-gesture-required", 41 | "--use-mock-keychain", 42 | "--force-webrtc-ip-handling-policy=disable_non_proxied_udp", 43 | "--webrtc-ip-handling-policy=disable_non_proxied_udp", 44 | "--disable-session-crashed-bubble", 45 | "--disable-crash-reporter", 46 | "--disable-dev-shm-usage", 47 | "--force-color-profile=srgb", 48 | "--disable-translate", 49 | "--disable-background-networking", 50 | "--disable-background-timer-throttling", 51 | "--disable-backgrounding-occluded-windows", 52 | "--disable-infobars", 53 | "--hide-scrollbars", 54 | "--disable-renderer-backgrounding", 55 | "--font-render-hinting=none", 56 | "--disable-logging", 57 | "--enable-surface-synchronization", 58 | # "--run-all-compositor-stages-before-draw", 59 | "--disable-threaded-animation", 60 | "--disable-threaded-scrolling", 61 | "--disable-checker-imaging", 62 | "--disable-new-content-rendering-timeout", 63 | "--disable-image-animation-resync", 64 | "--disable-partial-raster", 65 | "--blink-settings=primaryHoverType=2,availableHoverTypes=2," 66 | "primaryPointerType=4,availablePointerTypes=4", 67 | "--disable-layer-tree-host-memory-pressure", 68 | ] 69 | -------------------------------------------------------------------------------- /dendrite/browser/_common/types.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | Status = Literal["success", "failed", "loading", "impossible"] 4 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/__init__.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | 3 | from .dendrite_browser import AsyncDendrite 4 | from .dendrite_element import AsyncElement 5 | from .dendrite_page import AsyncPage 6 | 7 | __all__ = [ 8 | "AsyncDendrite", 9 | "AsyncElement", 10 | "AsyncPage", 11 | ] 12 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/_event_sync.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | from typing import Generic, Optional, Type, TypeVar 4 | 5 | from playwright.async_api import Download, FileChooser, Page 6 | 7 | Events = TypeVar("Events", Download, FileChooser) 8 | 9 | mapping = { 10 | Download: "download", 11 | FileChooser: "filechooser", 12 | } 13 | 14 | 15 | class EventSync(Generic[Events]): 16 | 17 | def __init__(self, event_type: Type[Events]): 18 | self.event_type = event_type 19 | self.event_set = False 20 | self.data: Optional[Events] = None 21 | 22 | async def get_data(self, pw_page: Page, timeout: float = 30000) -> Events: 23 | start_time = time.time() 24 | while not self.event_set: 25 | elapsed_time = (time.time() - start_time) * 1000 # Convert to milliseconds 26 | if elapsed_time > timeout: 27 | raise TimeoutError(f'Timeout waiting for event "{self.event_type}".') 28 | # Advance the playwright event loop without blocking 29 | await pw_page.wait_for_timeout(0) 30 | # Sleep briefly to prevent CPU spinning 31 | await asyncio.sleep(0.01) 32 | data = self.data 33 | self.data = None 34 | self.event_set = False 35 | if data is None: 36 | raise ValueError("Data is None for event type: ", self.event_type) 37 | return data 38 | 39 | def set_event(self, data: Events) -> None: 40 | """ 41 | Sets the event and stores the provided data. 42 | 43 | This method is used to signal that the data is ready to be retrieved by any waiting tasks. 44 | 45 | Args: 46 | data (T): The data to be stored and associated with the event. 47 | 48 | Returns: 49 | None 50 | """ 51 | self.data = data 52 | self.event_set = True 53 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/_utils.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union 3 | 4 | import tldextract 5 | from bs4 import BeautifulSoup 6 | from loguru import logger 7 | from playwright.async_api import Error, Frame 8 | from pydantic import BaseModel 9 | 10 | from dendrite.models.selector import Selector 11 | 12 | from .dendrite_element import AsyncElement 13 | from .types import PlaywrightPage, TypeSpec 14 | 15 | if TYPE_CHECKING: 16 | from .dendrite_page import AsyncPage 17 | 18 | from dendrite.logic.dom.strip import mild_strip_in_place 19 | 20 | from .js import GENERATE_DENDRITE_IDS_IFRAME_SCRIPT 21 | 22 | 23 | def get_domain_w_suffix(url: str) -> str: 24 | parsed_url = tldextract.extract(url) 25 | if parsed_url.suffix == "": 26 | raise ValueError(f"Invalid URL: {url}") 27 | 28 | return f"{parsed_url.domain}.{parsed_url.suffix}" 29 | 30 | 31 | async def expand_iframes( 32 | page: PlaywrightPage, 33 | page_soup: BeautifulSoup, 34 | ): 35 | async def get_iframe_path(frame: Frame): 36 | path_parts = [] 37 | current_frame = frame 38 | while current_frame.parent_frame is not None: 39 | iframe_element = await current_frame.frame_element() 40 | iframe_id = await iframe_element.get_attribute("d-id") 41 | if iframe_id is None: 42 | # If any iframe_id in the path is None, we cannot build the path 43 | return None 44 | path_parts.insert(0, iframe_id) 45 | current_frame = current_frame.parent_frame 46 | return "|".join(path_parts) 47 | 48 | for frame in page.frames: 49 | if frame.parent_frame is None: 50 | continue # Skip the main frame 51 | try: 52 | iframe_element = await frame.frame_element() 53 | 54 | iframe_id = await iframe_element.get_attribute("d-id") 55 | if iframe_id is None: 56 | continue 57 | iframe_path = await get_iframe_path(frame) 58 | except Error as e: 59 | continue 60 | 61 | if iframe_path is None: 62 | continue 63 | 64 | try: 65 | await frame.evaluate( 66 | GENERATE_DENDRITE_IDS_IFRAME_SCRIPT, {"frame_path": iframe_path} 67 | ) 68 | frame_content = await frame.content() 69 | frame_tree = BeautifulSoup(frame_content, "lxml") 70 | mild_strip_in_place(frame_tree) 71 | merge_iframe_to_page(iframe_id, page_soup, frame_tree) 72 | except Error as e: 73 | continue 74 | 75 | 76 | def merge_iframe_to_page( 77 | iframe_id: str, 78 | page: BeautifulSoup, 79 | iframe: BeautifulSoup, 80 | ): 81 | iframe_element = page.find("iframe", {"d-id": iframe_id}) 82 | if iframe_element is None: 83 | logger.debug(f"Could not find iframe with ID {iframe_id} in page soup") 84 | return 85 | 86 | iframe_element.replace_with(iframe) 87 | 88 | 89 | async def _get_all_elements_from_selector_soup( 90 | selector: str, soup: BeautifulSoup, page: "AsyncPage" 91 | ) -> List[AsyncElement]: 92 | dendrite_elements: List[AsyncElement] = [] 93 | 94 | elements = soup.select(selector) 95 | 96 | for element in elements: 97 | frame = page._get_context(element) 98 | d_id = element.get("d-id", "") 99 | locator = frame.locator(f"xpath=//*[@d-id='{d_id}']") 100 | 101 | if not d_id: 102 | continue 103 | 104 | if isinstance(d_id, list): 105 | d_id = d_id[0] 106 | dendrite_elements.append( 107 | AsyncElement(d_id, locator, page.dendrite_browser, page._browser_api_client) 108 | ) 109 | 110 | return dendrite_elements 111 | 112 | 113 | async def get_elements_from_selectors_soup( 114 | page: "AsyncPage", 115 | soup: BeautifulSoup, 116 | selectors: List[Selector], 117 | only_one: bool, 118 | ) -> Union[Optional[AsyncElement], List[AsyncElement]]: 119 | 120 | for selector in reversed(selectors): 121 | dendrite_elements = await _get_all_elements_from_selector_soup( 122 | selector.selector, soup, page 123 | ) 124 | 125 | if len(dendrite_elements) > 0: 126 | return dendrite_elements[0] if only_one else dendrite_elements 127 | 128 | return None 129 | 130 | 131 | def to_json_schema(type_spec: TypeSpec) -> Dict[str, Any]: 132 | if isinstance(type_spec, dict): 133 | # Assume it's already a JSON schema 134 | return type_spec 135 | if inspect.isclass(type_spec) and issubclass(type_spec, BaseModel): 136 | # Convert Pydantic model to JSON schema 137 | return type_spec.model_json_schema() 138 | if type_spec in (bool, int, float, str): 139 | # Convert basic Python types to JSON schema 140 | type_map = {bool: "boolean", int: "integer", float: "number", str: "string"} 141 | return {"type": type_map[type_spec]} 142 | 143 | raise ValueError(f"Unsupported type specification: {type_spec}") 144 | 145 | 146 | def convert_to_type_spec(type_spec: TypeSpec, return_data: Any) -> TypeSpec: 147 | if isinstance(type_spec, type): 148 | if issubclass(type_spec, BaseModel): 149 | return type_spec.model_validate(return_data) 150 | if type_spec in (str, float, bool, int): 151 | return type_spec(return_data) 152 | 153 | raise ValueError(f"Unsupported type: {type_spec}") 154 | if isinstance(type_spec, dict): 155 | return return_data 156 | 157 | raise ValueError(f"Unsupported type specification: {type_spec}") 158 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/browser_impl/__init__.py: -------------------------------------------------------------------------------- 1 | from .browserbase import AsyncBrowserbaseDownload 2 | 3 | __all__ = [ 4 | "AsyncBrowserbaseDownload", 5 | ] 6 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/browser_impl/browserbase/__init__.py: -------------------------------------------------------------------------------- 1 | from ._download import AsyncBrowserbaseDownload 2 | 3 | __all__ = ["AsyncBrowserbaseDownload"] 4 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/browser_impl/browserbase/_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | from pathlib import Path 4 | from typing import Optional, Union 5 | 6 | import httpx 7 | from loguru import logger 8 | 9 | from dendrite.browser._common._exceptions.dendrite_exception import DendriteException 10 | 11 | 12 | class BrowserbaseClient: 13 | def __init__(self, api_key: str, project_id: str) -> None: 14 | self.api_key = api_key 15 | self.project_id = project_id 16 | 17 | async def create_session(self) -> str: 18 | logger.debug("Creating session") 19 | """ 20 | Creates a session using the Browserbase API. 21 | 22 | Returns: 23 | str: The ID of the created session. 24 | """ 25 | url = "https://www.browserbase.com/v1/sessions" 26 | headers = { 27 | "Content-Type": "application/json", 28 | "x-bb-api-key": self.api_key, 29 | } 30 | json = { 31 | "projectId": self.project_id, 32 | "keepAlive": False, 33 | } 34 | response = httpx.post(url, json=json, headers=headers) 35 | 36 | if response.status_code >= 400: 37 | raise DendriteException(f"Failed to create session: {response.text}") 38 | 39 | return response.json()["id"] 40 | 41 | async def stop_session(self, session_id: str): 42 | url = f"https://www.browserbase.com/v1/sessions/{session_id}" 43 | 44 | headers = { 45 | "Content-Type": "application/json", 46 | "x-bb-api-key": self.api_key, 47 | } 48 | json = { 49 | "projectId": self.project_id, 50 | "status": "REQUEST_RELEASE", 51 | } 52 | async with httpx.AsyncClient() as client: 53 | response = await client.post(url, json=json, headers=headers) 54 | 55 | return response.json() 56 | 57 | async def connect_url( 58 | self, enable_proxy: bool, session_id: Optional[str] = None 59 | ) -> str: 60 | url = f"wss://connect.browserbase.com?apiKey={self.api_key}" 61 | if session_id: 62 | url += f"&sessionId={session_id}" 63 | if enable_proxy: 64 | url += "&enableProxy=true" 65 | return url 66 | 67 | async def save_downloads_on_disk( 68 | self, session_id: str, path: Union[str, Path], retry_for_seconds: float 69 | ): 70 | url = f"https://www.browserbase.com/v1/sessions/{session_id}/downloads" 71 | headers = {"x-bb-api-key": self.api_key} 72 | 73 | file_path = Path(path) 74 | async with httpx.AsyncClient() as session: 75 | timeout = time.time() + retry_for_seconds 76 | while time.time() < timeout: 77 | try: 78 | response = await session.get(url, headers=headers) 79 | if response.status_code == 200: 80 | array_buffer = response.read() 81 | if len(array_buffer) > 0: 82 | with open(file_path, "wb") as f: 83 | f.write(array_buffer) 84 | return 85 | except Exception as e: 86 | logger.debug(f"Error fetching downloads: {e}") 87 | await asyncio.sleep(2) 88 | logger.debug("Failed to download files within the time limit.") 89 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/browser_impl/browserbase/_download.py: -------------------------------------------------------------------------------- 1 | import re 2 | import shutil 3 | import zipfile 4 | from pathlib import Path 5 | from typing import Union 6 | 7 | from loguru import logger 8 | from playwright.async_api import Download 9 | 10 | from dendrite.browser.async_api.browser_impl.browserbase._client import ( 11 | BrowserbaseClient, 12 | ) 13 | from dendrite.browser.async_api.protocol.download_protocol import DownloadInterface 14 | 15 | 16 | class AsyncBrowserbaseDownload(DownloadInterface): 17 | def __init__( 18 | self, session_id: str, download: Download, client: BrowserbaseClient 19 | ) -> None: 20 | super().__init__(download) 21 | self._session_id = session_id 22 | self._client = client 23 | 24 | async def save_as(self, path: Union[str, Path], timeout: float = 20) -> None: 25 | """ 26 | Save the latest file from the downloaded ZIP archive to the specified path. 27 | 28 | Args: 29 | path (Union[str, Path]): The destination file path where the latest file will be saved. 30 | timeout (float, optional): Timeout for the save operation. Defaults to 20 seconds. 31 | 32 | Raises: 33 | Exception: If no matching files are found in the ZIP archive or if the file cannot be saved. 34 | """ 35 | 36 | destination_path = Path(path) 37 | 38 | source_path = await self._download.path() 39 | destination_path.parent.mkdir(parents=True, exist_ok=True) 40 | 41 | with zipfile.ZipFile(source_path, "r") as zip_ref: 42 | # Get all file names in the ZIP 43 | file_list = zip_ref.namelist() 44 | 45 | # Filter and sort files based on timestamp 46 | 47 | sorted_files = sorted( 48 | file_list, 49 | key=extract_timestamp, 50 | reverse=True, 51 | ) 52 | 53 | if not sorted_files: 54 | raise FileNotFoundError( 55 | "No files found in the Browserbase download ZIP" 56 | ) 57 | 58 | # Extract the latest file 59 | latest_file = sorted_files[0] 60 | with zip_ref.open(latest_file) as source, open( 61 | destination_path, "wb" 62 | ) as target: 63 | shutil.copyfileobj(source, target) 64 | logger.info(f"Latest file saved successfully to {destination_path}") 65 | 66 | 67 | def extract_timestamp(filename): 68 | timestamp_pattern = re.compile(r"-(\d+)\.") 69 | match = timestamp_pattern.search(filename) 70 | return int(match.group(1)) if match else 0 71 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/browser_impl/browserbase/_impl.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Optional 2 | 3 | from dendrite.browser._common._exceptions.dendrite_exception import ( 4 | BrowserNotLaunchedError, 5 | ) 6 | from dendrite.browser.async_api.protocol.browser_protocol import BrowserProtocol 7 | from dendrite.browser.async_api.types import PlaywrightPage 8 | from dendrite.browser.remote.browserbase_config import BrowserbaseConfig 9 | 10 | if TYPE_CHECKING: 11 | from dendrite.browser.async_api.dendrite_browser import AsyncDendrite 12 | 13 | from loguru import logger 14 | from playwright.async_api import Playwright 15 | 16 | from ._client import BrowserbaseClient 17 | from ._download import AsyncBrowserbaseDownload 18 | 19 | 20 | class BrowserbaseImpl(BrowserProtocol): 21 | def __init__(self, settings: BrowserbaseConfig) -> None: 22 | self.settings = settings 23 | self._client = BrowserbaseClient( 24 | self.settings.api_key, self.settings.project_id 25 | ) 26 | self._session_id: Optional[str] = None 27 | 28 | async def stop_session(self): 29 | if self._session_id: 30 | await self._client.stop_session(self._session_id) 31 | 32 | async def start_browser(self, playwright: Playwright, pw_options: dict): 33 | logger.debug("Starting browser") 34 | self._session_id = await self._client.create_session() 35 | url = await self._client.connect_url( 36 | self.settings.enable_proxy, self._session_id 37 | ) 38 | logger.debug(f"Connecting to browser at {url}") 39 | return await playwright.chromium.connect_over_cdp(url) 40 | 41 | async def configure_context(self, browser: "AsyncDendrite"): 42 | logger.debug("Configuring browser context") 43 | 44 | page = await browser.get_active_page() 45 | pw_page = page.playwright_page 46 | 47 | if browser.browser_context is None: 48 | raise BrowserNotLaunchedError() 49 | 50 | client = await browser.browser_context.new_cdp_session(pw_page) 51 | await client.send( 52 | "Browser.setDownloadBehavior", 53 | { 54 | "behavior": "allow", 55 | "downloadPath": "downloads", 56 | "eventsEnabled": True, 57 | }, 58 | ) 59 | 60 | async def get_download( 61 | self, 62 | dendrite_browser: "AsyncDendrite", 63 | pw_page: PlaywrightPage, 64 | timeout: float = 30000, 65 | ) -> AsyncBrowserbaseDownload: 66 | if not self._session_id: 67 | raise ValueError( 68 | "Downloads are not enabled for this provider. Specify enable_downloads=True in the constructor" 69 | ) 70 | logger.debug("Getting download") 71 | download = await dendrite_browser._download_handler.get_data(pw_page, timeout) 72 | await self._client.save_downloads_on_disk( 73 | self._session_id, await download.path(), 30 74 | ) 75 | return AsyncBrowserbaseDownload(self._session_id, download, self._client) 76 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/browser_impl/browserless/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/browser_impl/browserless/_impl.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import TYPE_CHECKING, Optional 3 | 4 | from dendrite.browser._common._exceptions.dendrite_exception import ( 5 | BrowserNotLaunchedError, 6 | ) 7 | from dendrite.browser.async_api.protocol.browser_protocol import BrowserProtocol 8 | from dendrite.browser.async_api.types import PlaywrightPage 9 | from dendrite.browser.remote.browserless_config import BrowserlessConfig 10 | 11 | if TYPE_CHECKING: 12 | from dendrite.browser.async_api.dendrite_browser import AsyncDendrite 13 | 14 | import urllib.parse 15 | 16 | from loguru import logger 17 | from playwright.async_api import Playwright 18 | 19 | from dendrite.browser.async_api.browser_impl.browserbase._client import ( 20 | BrowserbaseClient, 21 | ) 22 | from dendrite.browser.async_api.browser_impl.browserbase._download import ( 23 | AsyncBrowserbaseDownload, 24 | ) 25 | 26 | 27 | class BrowserlessImpl(BrowserProtocol): 28 | def __init__(self, settings: BrowserlessConfig) -> None: 29 | self.settings = settings 30 | self._session_id: Optional[str] = None 31 | 32 | async def stop_session(self): 33 | pass 34 | 35 | async def start_browser(self, playwright: Playwright, pw_options: dict): 36 | logger.debug("Starting browser") 37 | url = self._format_connection_url(pw_options) 38 | logger.debug(f"Connecting to browser at {url}") 39 | return await playwright.chromium.connect_over_cdp(url) 40 | 41 | def _format_connection_url(self, pw_options: dict) -> str: 42 | url = self.settings.url.rstrip("?").rstrip("/") 43 | 44 | query = { 45 | "token": self.settings.api_key, 46 | "blockAds": self.settings.block_ads, 47 | "launch": json.dumps(pw_options), 48 | } 49 | 50 | if self.settings.proxy: 51 | query["proxy"] = (self.settings.proxy,) 52 | query["proxyCountry"] = (self.settings.proxy_country,) 53 | return f"{url}?{urllib.parse.urlencode(query)}" 54 | 55 | async def configure_context(self, browser: "AsyncDendrite"): 56 | pass 57 | 58 | async def get_download( 59 | self, 60 | dendrite_browser: "AsyncDendrite", 61 | pw_page: PlaywrightPage, 62 | timeout: float = 30000, 63 | ) -> AsyncBrowserbaseDownload: 64 | raise NotImplementedError("Downloads are not supported for Browserless") 65 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/browser_impl/impl_mapping.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional, Type 2 | 3 | from dendrite.browser.remote import Providers 4 | from dendrite.browser.remote.browserbase_config import BrowserbaseConfig 5 | from dendrite.browser.remote.browserless_config import BrowserlessConfig 6 | 7 | from ..protocol.browser_protocol import BrowserProtocol 8 | from .browserbase._impl import BrowserbaseImpl 9 | from .browserless._impl import BrowserlessImpl 10 | from .local._impl import LocalImpl 11 | 12 | IMPL_MAPPING: Dict[Type[Providers], Type[BrowserProtocol]] = { 13 | BrowserbaseConfig: BrowserbaseImpl, 14 | BrowserlessConfig: BrowserlessImpl, 15 | } 16 | 17 | SETTINGS_CLASSES: Dict[str, Type[Providers]] = { 18 | "browserbase": BrowserbaseConfig, 19 | "browserless": BrowserlessConfig, 20 | } 21 | 22 | 23 | def get_impl(remote_provider: Optional[Providers]) -> BrowserProtocol: 24 | if remote_provider is None: 25 | return LocalImpl() 26 | 27 | try: 28 | provider_class = IMPL_MAPPING[type(remote_provider)] 29 | except KeyError: 30 | raise ValueError( 31 | f"No implementation for {type(remote_provider)}. Available providers: {', '.join(map(lambda x: x.__name__, IMPL_MAPPING.keys()))}" 32 | ) 33 | 34 | return provider_class(remote_provider) 35 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/browser_impl/local/_impl.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING, Optional, Union, overload 3 | 4 | from loguru import logger 5 | from typing_extensions import Literal 6 | 7 | from dendrite.browser._common.constants import STEALTH_ARGS 8 | 9 | if TYPE_CHECKING: 10 | from dendrite.browser.async_api.dendrite_browser import AsyncDendrite 11 | 12 | import os 13 | import shutil 14 | import tempfile 15 | 16 | from playwright.async_api import ( 17 | Browser, 18 | BrowserContext, 19 | Download, 20 | Playwright, 21 | StorageState, 22 | ) 23 | 24 | from dendrite.browser.async_api.protocol.browser_protocol import BrowserProtocol 25 | from dendrite.browser.async_api.types import PlaywrightPage 26 | 27 | 28 | class LocalImpl(BrowserProtocol): 29 | def __init__(self) -> None: 30 | pass 31 | 32 | async def start_browser( 33 | self, 34 | playwright: Playwright, 35 | pw_options: dict, 36 | storage_state: Optional[StorageState] = None, 37 | ) -> Browser: 38 | return await playwright.chromium.launch(**pw_options) 39 | 40 | async def get_download( 41 | self, 42 | dendrite_browser: "AsyncDendrite", 43 | pw_page: PlaywrightPage, 44 | timeout: float, 45 | ) -> Download: 46 | return await dendrite_browser._download_handler.get_data(pw_page, timeout) 47 | 48 | async def configure_context(self, browser: "AsyncDendrite"): 49 | pass 50 | 51 | async def stop_session(self): 52 | pass 53 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/js/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | def load_script(filename: str) -> str: 5 | current_dir = Path(__file__).parent 6 | 7 | file_path = current_dir / filename 8 | return file_path.read_text(encoding="utf-8") 9 | 10 | 11 | GENERATE_DENDRITE_IDS_SCRIPT = load_script("generateDendriteIDs.js") 12 | GENERATE_DENDRITE_IDS_IFRAME_SCRIPT = load_script("generateDendriteIDsIframe.js") 13 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/js/eventListenerPatch.js: -------------------------------------------------------------------------------- 1 | // Save the original methods before redefining them 2 | EventTarget.prototype._originalAddEventListener = EventTarget.prototype.addEventListener; 3 | EventTarget.prototype._originalRemoveEventListener = EventTarget.prototype.removeEventListener; 4 | 5 | // Redefine the addEventListener method 6 | EventTarget.prototype.addEventListener = function(event, listener, options = false) { 7 | // Initialize the eventListenerList if it doesn't exist 8 | if (!this.eventListenerList) { 9 | this.eventListenerList = {}; 10 | } 11 | // Initialize the event list for the specific event if it doesn't exist 12 | if (!this.eventListenerList[event]) { 13 | this.eventListenerList[event] = []; 14 | } 15 | // Add the event listener details to the event list 16 | this.eventListenerList[event].push({ listener, options, outerHTML: this.outerHTML }); 17 | 18 | // Call the original addEventListener method 19 | this._originalAddEventListener(event, listener, options); 20 | }; 21 | 22 | // Redefine the removeEventListener method 23 | EventTarget.prototype.removeEventListener = function(event, listener, options = false) { 24 | // Remove the event listener details from the event list 25 | if (this.eventListenerList && this.eventListenerList[event]) { 26 | this.eventListenerList[event] = this.eventListenerList[event].filter( 27 | item => item.listener !== listener 28 | ); 29 | } 30 | 31 | // Call the original removeEventListener method 32 | this._originalRemoveEventListener( event, listener, options); 33 | }; 34 | 35 | // Get event listeners for a specific event type or all events if not specified 36 | EventTarget.prototype._getEventListeners = function(eventType) { 37 | if (!this.eventListenerList) { 38 | this.eventListenerList = {}; 39 | } 40 | 41 | const eventsToCheck = ['click', 'dblclick', 'mousedown', 'mouseup', 'mouseover', 'mouseout', 'mousemove', 'keydown', 'keyup', 'keypress']; 42 | 43 | eventsToCheck.forEach(type => { 44 | if (!eventType || eventType === type) { 45 | if (this[`on${type}`]) { 46 | if (!this.eventListenerList[type]) { 47 | this.eventListenerList[type] = []; 48 | } 49 | this.eventListenerList[type].push({ listener: this[`on${type}`], inline: true }); 50 | } 51 | } 52 | }); 53 | 54 | return eventType === undefined ? this.eventListenerList : this.eventListenerList[eventType]; 55 | }; 56 | 57 | // Utility to show events 58 | function _showEvents(events) { 59 | let result = ''; 60 | for (let event in events) { 61 | result += `${event} ----------------> ${events[event].length}\n`; 62 | for (let listenerObj of events[event]) { 63 | result += `${listenerObj.listener.toString()}\n`; 64 | } 65 | } 66 | return result; 67 | } 68 | 69 | // Extend EventTarget prototype with utility methods 70 | EventTarget.prototype.on = function(event, callback, options) { 71 | this.addEventListener(event, callback, options); 72 | return this; 73 | }; 74 | 75 | EventTarget.prototype.off = function(event, callback, options) { 76 | this.removeEventListener(event, callback, options); 77 | return this; 78 | }; 79 | 80 | EventTarget.prototype.emit = function(event, args = null) { 81 | this.dispatchEvent(new CustomEvent(event, { detail: args })); 82 | return this; 83 | }; 84 | 85 | // Make these methods non-enumerable 86 | Object.defineProperties(EventTarget.prototype, { 87 | on: { enumerable: false }, 88 | off: { enumerable: false }, 89 | emit: { enumerable: false } 90 | }); 91 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/js/generateDendriteIDs.js: -------------------------------------------------------------------------------- 1 | var hashCode = (str) => { 2 | var hash = 0, i, chr; 3 | if (str.length === 0) return hash; 4 | for (i = 0; i < str.length; i++) { 5 | chr = str.charCodeAt(i); 6 | hash = ((hash << 5) - hash) + chr; 7 | hash |= 0; // Convert to 32bit integer 8 | } 9 | return hash; 10 | } 11 | 12 | 13 | const getElementIndex = (element) => { 14 | let index = 1; 15 | let sibling = element.previousElementSibling; 16 | 17 | while (sibling) { 18 | if (sibling.localName === element.localName) { 19 | index++; 20 | } 21 | sibling = sibling.previousElementSibling; 22 | } 23 | 24 | return index; 25 | }; 26 | 27 | 28 | const segs = function elmSegs(elm) { 29 | if (!elm || elm.nodeType !== 1) return ['']; 30 | if (elm.id && document.getElementById(elm.id) === elm) return [`id("${elm.id}")`]; 31 | const localName = typeof elm.localName === 'string' ? elm.localName.toLowerCase() : 'unknown'; 32 | let index = getElementIndex(elm); 33 | 34 | return [...elmSegs(elm.parentNode), `${localName}[${index}]`]; 35 | }; 36 | 37 | var getXPathForElement = (element) => { 38 | return segs(element).join('/'); 39 | } 40 | 41 | // Create a Map to store used hashes and their counters 42 | const usedHashes = new Map(); 43 | 44 | var markHidden = (hidden_element) => { 45 | // Mark the hidden element itself 46 | hidden_element.setAttribute('data-hidden', 'true'); 47 | 48 | } 49 | 50 | document.querySelectorAll('*').forEach((element, index) => { 51 | try { 52 | 53 | const xpath = getXPathForElement(element); 54 | const hash = hashCode(xpath); 55 | const baseId = hash.toString(36); 56 | 57 | // const is_marked_hidden = element.getAttribute("data-hidden") === "true"; 58 | const isHidden = !element.checkVisibility(); 59 | // computedStyle.width === '0px' || 60 | // computedStyle.height === '0px'; 61 | 62 | if (isHidden) { 63 | markHidden(element); 64 | }else{ 65 | element.removeAttribute("data-hidden") // in case we hid it in a previous call 66 | } 67 | 68 | let uniqueId = baseId; 69 | let counter = 0; 70 | 71 | // Check if this hash has been used before 72 | while (usedHashes.has(uniqueId)) { 73 | // If it has, increment the counter and create a new uniqueId 74 | counter++; 75 | uniqueId = `${baseId}_${counter}`; 76 | } 77 | 78 | // Add the uniqueId to the usedHashes Map 79 | usedHashes.set(uniqueId, true); 80 | element.setAttribute('d-id', uniqueId); 81 | } catch (error) { 82 | // Fallback: use a hash of the tag name and index 83 | const fallbackId = hashCode(`${element.tagName}_${index}`).toString(36); 84 | console.error('Error processing element, using fallback:',fallbackId, element, error); 85 | 86 | element.setAttribute('d-id', `fallback_${fallbackId}`); 87 | } 88 | }); -------------------------------------------------------------------------------- /dendrite/browser/async_api/js/generateDendriteIDsIframe.js: -------------------------------------------------------------------------------- 1 | ({frame_path}) => { 2 | var hashCode = (str) => { 3 | var hash = 0, i, chr; 4 | if (str.length === 0) return hash; 5 | for (i = 0; i < str.length; i++) { 6 | chr = str.charCodeAt(i); 7 | hash = ((hash << 5) - hash) + chr; 8 | hash |= 0; // Convert to 32bit integer 9 | } 10 | return hash; 11 | } 12 | 13 | const getElementIndex = (element) => { 14 | let index = 1; 15 | let sibling = element.previousElementSibling; 16 | 17 | while (sibling) { 18 | if (sibling.localName === element.localName) { 19 | index++; 20 | } 21 | sibling = sibling.previousElementSibling; 22 | } 23 | 24 | return index; 25 | }; 26 | 27 | 28 | const segs = function elmSegs(elm) { 29 | if (!elm || elm.nodeType !== 1) return ['']; 30 | if (elm.id && document.getElementById(elm.id) === elm) return [`id("${elm.id}")`]; 31 | const localName = typeof elm.localName === 'string' ? elm.localName.toLowerCase() : 'unknown'; 32 | let index = getElementIndex(elm); 33 | 34 | return [...elmSegs(elm.parentNode), `${localName}[${index}]`]; 35 | }; 36 | 37 | var getXPathForElement = (element) => { 38 | return segs(element).join('/'); 39 | } 40 | 41 | // Create a Map to store used hashes and their counters 42 | const usedHashes = new Map(); 43 | 44 | var markHidden = (hidden_element) => { 45 | // Mark the hidden element itself 46 | hidden_element.setAttribute('data-hidden', 'true'); 47 | } 48 | 49 | document.querySelectorAll('*').forEach((element, index) => { 50 | try { 51 | 52 | 53 | // const is_marked_hidden = element.getAttribute("data-hidden") === "true"; 54 | const isHidden = !element.checkVisibility(); 55 | // computedStyle.width === '0px' || 56 | // computedStyle.height === '0px'; 57 | 58 | if (isHidden) { 59 | markHidden(element); 60 | }else{ 61 | element.removeAttribute("data-hidden") // in case we hid it in a previous call 62 | } 63 | let xpath = getXPathForElement(element); 64 | if(frame_path){ 65 | element.setAttribute("iframe-path",frame_path) 66 | xpath = frame_path + xpath; 67 | } 68 | const hash = hashCode(xpath); 69 | const baseId = hash.toString(36); 70 | 71 | let uniqueId = baseId; 72 | let counter = 0; 73 | 74 | // Check if this hash has been used before 75 | while (usedHashes.has(uniqueId)) { 76 | // If it has, increment the counter and create a new uniqueId 77 | counter++; 78 | uniqueId = `${baseId}_${counter}`; 79 | } 80 | 81 | // Add the uniqueId to the usedHashes Map 82 | usedHashes.set(uniqueId, true); 83 | element.setAttribute('d-id', uniqueId); 84 | } catch (error) { 85 | // Fallback: use a hash of the tag name and index 86 | const fallbackId = hashCode(`${element.tagName}_${index}`).toString(36); 87 | console.error('Error processing element, using fallback:',fallbackId, element, error); 88 | 89 | element.setAttribute('d-id', `fallback_${fallbackId}`); 90 | } 91 | }); 92 | } 93 | 94 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/manager/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrite-systems/dendrite-python-sdk/16e591332855547ca31fb8a72793d05a96b2f4d3/dendrite/browser/async_api/manager/__init__.py -------------------------------------------------------------------------------- /dendrite/browser/async_api/manager/navigation_tracker.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | from typing import TYPE_CHECKING, Dict, Optional 4 | 5 | if TYPE_CHECKING: 6 | from ..dendrite_page import AsyncPage 7 | 8 | 9 | class NavigationTracker: 10 | def __init__( 11 | self, 12 | page: "AsyncPage", 13 | ): 14 | self.playwright_page = page.playwright_page 15 | self._nav_start_timestamp: Optional[float] = None 16 | 17 | # Track all navigation-related events 18 | self.playwright_page.on("framenavigated", self._on_frame_navigated) 19 | self.playwright_page.on("popup", self._on_popup) 20 | 21 | # Store last event times 22 | self._last_events: Dict[str, Optional[float]] = { 23 | "framenavigated": None, 24 | "popup": None, 25 | } 26 | 27 | def _on_frame_navigated(self, frame): 28 | self._last_events["framenavigated"] = time.time() 29 | if frame is self.playwright_page.main_frame: 30 | self._last_main_frame_url = frame.url 31 | self._last_frame_navigated_timestamp = time.time() 32 | 33 | def _on_popup(self, page): 34 | self._last_events["popup"] = time.time() 35 | 36 | def start_nav_tracking(self): 37 | """Call this just before performing an action that might trigger navigation""" 38 | self._nav_start_timestamp = time.time() 39 | # Reset event timestamps 40 | for event in self._last_events: 41 | self._last_events[event] = None 42 | 43 | def get_nav_events_since_start(self): 44 | """ 45 | Returns which events have fired since start_nav_tracking() was called 46 | and how long after the start they occurred 47 | """ 48 | if self._nav_start_timestamp is None: 49 | return "Navigation tracking not started. Call start_nav_tracking() first." 50 | 51 | results = {} 52 | for event, timestamp in self._last_events.items(): 53 | if timestamp is not None: 54 | delay = timestamp - self._nav_start_timestamp 55 | results[event] = f"{delay:.3f}s" 56 | else: 57 | results[event] = "not fired" 58 | 59 | return results 60 | 61 | async def has_navigated_since_start(self): 62 | """Returns True if any navigation event has occurred since start_nav_tracking()""" 63 | if self._nav_start_timestamp is None: 64 | return False 65 | 66 | start_time = time.time() 67 | max_wait = 1.0 # Maximum wait time in seconds 68 | 69 | while time.time() - start_time < max_wait: 70 | if any( 71 | timestamp is not None and timestamp > self._nav_start_timestamp 72 | for timestamp in self._last_events.values() 73 | ): 74 | return True 75 | await asyncio.sleep(0.1) 76 | 77 | return False 78 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/manager/page_manager.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Optional 2 | 3 | from loguru import logger 4 | from playwright.async_api import BrowserContext, Download, FileChooser 5 | 6 | if TYPE_CHECKING: 7 | from ..dendrite_browser import AsyncDendrite 8 | 9 | from ..dendrite_page import AsyncPage 10 | from ..types import PlaywrightPage 11 | 12 | 13 | class PageManager: 14 | def __init__(self, dendrite_browser, browser_context: BrowserContext): 15 | self.pages: list[AsyncPage] = [] 16 | self.active_page: Optional[AsyncPage] = None 17 | self.browser_context = browser_context 18 | self.dendrite_browser: AsyncDendrite = dendrite_browser 19 | 20 | # Handle existing pages in the context 21 | existing_pages = browser_context.pages 22 | if existing_pages: 23 | for page in existing_pages: 24 | client = self.dendrite_browser.logic_engine 25 | dendrite_page = AsyncPage(page, self.dendrite_browser, client) 26 | self.pages.append(dendrite_page) 27 | # Set the first existing page as active 28 | if self.active_page is None: 29 | self.active_page = dendrite_page 30 | 31 | browser_context.on("page", self._page_on_open_handler) 32 | 33 | async def new_page(self) -> AsyncPage: 34 | new_page = await self.browser_context.new_page() 35 | 36 | # if we added the page via the new_page method, we don't want to add it again since it is done in the on_open_handler 37 | if self.active_page and new_page == self.active_page.playwright_page: 38 | return self.active_page 39 | 40 | client = self.dendrite_browser.logic_engine 41 | dendrite_page = AsyncPage(new_page, self.dendrite_browser, client) 42 | self.pages.append(dendrite_page) 43 | self.active_page = dendrite_page 44 | return dendrite_page 45 | 46 | async def get_active_page(self) -> AsyncPage: 47 | if self.active_page is None: 48 | return await self.new_page() 49 | 50 | return self.active_page 51 | 52 | async def _page_on_close_handler(self, page: PlaywrightPage): 53 | if self.browser_context and not self.dendrite_browser.closed: 54 | copy_pages = self.pages.copy() 55 | is_active_page = False 56 | for dendrite_page in copy_pages: 57 | if dendrite_page.playwright_page == page: 58 | self.pages.remove(dendrite_page) 59 | if dendrite_page == self.active_page: 60 | is_active_page = True 61 | break 62 | 63 | for i in reversed(range(len(self.pages))): 64 | try: 65 | self.active_page = self.pages[i] 66 | await self.pages[i].playwright_page.bring_to_front() 67 | break 68 | except Exception as e: 69 | logger.warning(f"Error switching to the next page: {e}") 70 | continue 71 | 72 | async def _page_on_crash_handler(self, page: PlaywrightPage): 73 | logger.error(f"Page crashed: {page.url}") 74 | await page.reload() 75 | 76 | async def _page_on_download_handler(self, download: Download): 77 | logger.debug(f"Download started: {download.url}") 78 | self.dendrite_browser._download_handler.set_event(download) 79 | 80 | async def _page_on_filechooser_handler(self, file_chooser: FileChooser): 81 | logger.debug("File chooser opened") 82 | self.dendrite_browser._upload_handler.set_event(file_chooser) 83 | 84 | def _page_on_open_handler(self, page: PlaywrightPage): 85 | page.on("close", self._page_on_close_handler) 86 | page.on("crash", self._page_on_crash_handler) 87 | page.on("download", self._page_on_download_handler) 88 | page.on("filechooser", self._page_on_filechooser_handler) 89 | 90 | client = self.dendrite_browser.logic_engine 91 | dendrite_page = AsyncPage(page, self.dendrite_browser, client) 92 | self.pages.append(dendrite_page) 93 | self.active_page = dendrite_page 94 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/manager/screenshot_manager.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | from uuid import uuid4 4 | 5 | from ..types import PlaywrightPage 6 | 7 | 8 | class ScreenshotManager: 9 | def __init__(self, page: PlaywrightPage) -> None: 10 | self.screenshot_before: str = "" 11 | self.screenshot_after: str = "" 12 | self.page = page 13 | 14 | async def take_full_page_screenshot(self) -> str: 15 | try: 16 | # Check the page height 17 | scroll_height = await self.page.evaluate( 18 | """ 19 | () => { 20 | const body = document.body; 21 | if (!body) { 22 | return 0; // Return 0 if body is null 23 | } 24 | return body.scrollHeight || 0; 25 | } 26 | """ 27 | ) 28 | 29 | if scroll_height > 30000: 30 | print( 31 | f"Page height ({scroll_height}px) exceeds 30000px. Taking viewport screenshot instead." 32 | ) 33 | return await self.take_viewport_screenshot() 34 | 35 | # Attempt to take a full-page screenshot 36 | image_data = await self.page.screenshot( 37 | type="jpeg", full_page=True, timeout=10000 38 | ) 39 | except Exception as e: # Catch any exception, including timeout 40 | print( 41 | f"Full-page screenshot failed: {e}. Falling back to viewport screenshot." 42 | ) 43 | # Fall back to viewport screenshot 44 | return await self.take_viewport_screenshot() 45 | 46 | if image_data is None: 47 | return "" 48 | 49 | return base64.b64encode(image_data).decode("utf-8") 50 | 51 | async def take_viewport_screenshot(self) -> str: 52 | image_data = await self.page.screenshot(type="jpeg", timeout=10000) 53 | 54 | if image_data is None: 55 | return "" 56 | 57 | reduced_base64 = base64.b64encode(image_data).decode("utf-8") 58 | 59 | return reduced_base64 60 | 61 | def store_screenshot(self, name, image_data): 62 | if not name: 63 | name = str(uuid4()) 64 | filepath = os.path.join("test", f"{name}.jpeg") 65 | os.makedirs(os.path.dirname(filepath), exist_ok=True) 66 | 67 | with open(filepath, "wb") as file: 68 | file.write(image_data) 69 | return filepath 70 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/mixin/__init__.py: -------------------------------------------------------------------------------- 1 | from .ask import AskMixin 2 | from .click import ClickMixin 3 | from .extract import ExtractionMixin 4 | from .fill_fields import FillFieldsMixin 5 | from .get_element import GetElementMixin 6 | from .keyboard import KeyboardMixin 7 | from .markdown import MarkdownMixin 8 | from .screenshot import ScreenshotMixin 9 | from .wait_for import WaitForMixin 10 | 11 | __all__ = [ 12 | "AskMixin", 13 | "ClickMixin", 14 | "ExtractionMixin", 15 | "FillFieldsMixin", 16 | "GetElementMixin", 17 | "KeyboardMixin", 18 | "MarkdownMixin", 19 | "ScreenshotMixin", 20 | "WaitForMixin", 21 | ] 22 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/mixin/click.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from dendrite.browser._common._exceptions.dendrite_exception import DendriteException 4 | from dendrite.models.response.interaction_response import InteractionResponse 5 | 6 | from ..mixin.get_element import GetElementMixin 7 | from ..protocol.page_protocol import DendritePageProtocol 8 | 9 | 10 | class ClickMixin(GetElementMixin, DendritePageProtocol): 11 | 12 | async def click( 13 | self, 14 | prompt: str, 15 | expected_outcome: Optional[str] = None, 16 | use_cache: bool = True, 17 | timeout: int = 15000, 18 | force: bool = False, 19 | *args, 20 | **kwargs, 21 | ) -> InteractionResponse: 22 | """ 23 | Clicks an element on the page based on the provided prompt. 24 | 25 | This method combines the functionality of get_element and click, 26 | allowing for a more concise way to interact with elements on the page. 27 | 28 | Args: 29 | prompt (str): The prompt describing the element to be clicked. 30 | expected_outcome (Optional[str]): The expected outcome of the click action. 31 | use_cache (bool, optional): Whether to use cached results for element retrieval. Defaults to True. 32 | timeout (int, optional): The timeout (in milliseconds) for the click operation. Defaults to 15000. 33 | force (bool, optional): Whether to force the click operation. Defaults to False. 34 | *args: Additional positional arguments for the click operation. 35 | **kwargs: Additional keyword arguments for the click operation. 36 | 37 | Returns: 38 | InteractionResponse: The response from the interaction. 39 | 40 | Raises: 41 | DendriteException: If no suitable element is found or if the click operation fails. 42 | """ 43 | augmented_prompt = prompt + "\n\nThe element should be clickable." 44 | element = await self.get_element( 45 | augmented_prompt, 46 | use_cache=use_cache, 47 | timeout=timeout, 48 | ) 49 | 50 | if not element: 51 | raise DendriteException( 52 | message=f"No element found with the prompt: {prompt}", 53 | screenshot_base64="", 54 | ) 55 | 56 | return await element.click( 57 | expected_outcome=expected_outcome, 58 | timeout=timeout, 59 | force=force, 60 | *args, 61 | **kwargs, 62 | ) 63 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/mixin/fill_fields.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Any, Dict, Optional 3 | 4 | from dendrite.browser._common._exceptions.dendrite_exception import DendriteException 5 | from dendrite.models.response.interaction_response import InteractionResponse 6 | 7 | from ..mixin.get_element import GetElementMixin 8 | from ..protocol.page_protocol import DendritePageProtocol 9 | 10 | 11 | class FillFieldsMixin(GetElementMixin, DendritePageProtocol): 12 | 13 | async def fill_fields(self, fields: Dict[str, Any]): 14 | """ 15 | Fills multiple fields on the page with the provided values. 16 | 17 | This method iterates through the given dictionary of fields and their corresponding values, 18 | making a separate fill request for each key-value pair. 19 | 20 | Args: 21 | fields (Dict[str, Any]): A dictionary where each key is a field identifier (e.g., a prompt or selector) 22 | and each value is the content to fill in that field. 23 | 24 | Returns: 25 | None 26 | 27 | Note: 28 | This method will make multiple fill requests, one for each key in the 'fields' dictionary. 29 | """ 30 | 31 | for field, value in fields.items(): 32 | prompt = f"I'll be filling in text in several fields with these keys: {fields.keys()} in this page. Get the field best described as '{field}'. I want to fill it with a '{type(value)}' type value." 33 | await self.fill(prompt, value) 34 | await asyncio.sleep(0.5) 35 | 36 | async def fill( 37 | self, 38 | prompt: str, 39 | value: str, 40 | expected_outcome: Optional[str] = None, 41 | use_cache: bool = True, 42 | timeout: int = 15000, 43 | *args, 44 | kwargs={}, 45 | ) -> InteractionResponse: 46 | """ 47 | Fills an element on the page with the provided value based on the given prompt. 48 | 49 | This method combines the functionality of get_element and fill, 50 | allowing for a more concise way to interact with elements on the page. 51 | 52 | Args: 53 | prompt (str): The prompt describing the element to be filled. 54 | value (str): The value to fill the element with. 55 | expected_outcome (Optional[str]): The expected outcome of the fill action. 56 | use_cache (bool, optional): Whether to use cached results for element retrieval. Defaults to True. 57 | max_retries (int, optional): The maximum number of retry attempts for element retrieval. Defaults to 3. 58 | timeout (int, optional): The timeout (in milliseconds) for the fill operation. Defaults to 15000. 59 | *args: Additional positional arguments for the fill operation. 60 | kwargs: Additional keyword arguments for the fill operation. 61 | 62 | Returns: 63 | InteractionResponse: The response from the interaction. 64 | 65 | Raises: 66 | DendriteException: If no suitable element is found or if the fill operation fails. 67 | """ 68 | augmented_prompt = prompt + "\n\nMake sure the element can be filled with text." 69 | element = await self.get_element( 70 | augmented_prompt, 71 | use_cache=use_cache, 72 | timeout=timeout, 73 | ) 74 | 75 | if not element: 76 | raise DendriteException( 77 | message=f"No element found with the prompt: {prompt}", 78 | screenshot_base64="", 79 | ) 80 | 81 | return await element.fill( 82 | value, 83 | expected_outcome=expected_outcome, 84 | timeout=timeout, 85 | *args, 86 | **kwargs, 87 | ) 88 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/mixin/keyboard.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Union 2 | 3 | from dendrite.browser._common._exceptions.dendrite_exception import DendriteException 4 | 5 | from ..protocol.page_protocol import DendritePageProtocol 6 | 7 | 8 | class KeyboardMixin(DendritePageProtocol): 9 | 10 | async def press( 11 | self, 12 | key: Union[ 13 | str, 14 | Literal[ 15 | "Enter", 16 | "Tab", 17 | "Escape", 18 | "Backspace", 19 | "ArrowUp", 20 | "ArrowDown", 21 | "ArrowLeft", 22 | "ArrowRight", 23 | ], 24 | ], 25 | hold_shift: bool = False, 26 | hold_ctrl: bool = False, 27 | hold_alt: bool = False, 28 | hold_cmd: bool = False, 29 | ): 30 | """ 31 | Presses a keyboard key on the active page, optionally with modifier keys. 32 | 33 | Args: 34 | key (Union[str, Literal["Enter", "Tab", "Escape", "Backspace", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]]): The main key to be pressed. 35 | hold_shift (bool, optional): Whether to hold the Shift key. Defaults to False. 36 | hold_ctrl (bool, optional): Whether to hold the Control key. Defaults to False. 37 | hold_alt (bool, optional): Whether to hold the Alt key. Defaults to False. 38 | hold_cmd (bool, optional): Whether to hold the Command key (Meta on some systems). Defaults to False. 39 | 40 | Returns: 41 | Any: The result of the key press operation. 42 | 43 | Raises: 44 | DendriteException: If the key press operation fails. 45 | """ 46 | modifiers = [] 47 | if hold_shift: 48 | modifiers.append("Shift") 49 | if hold_ctrl: 50 | modifiers.append("Control") 51 | if hold_alt: 52 | modifiers.append("Alt") 53 | if hold_cmd: 54 | modifiers.append("Meta") 55 | 56 | if modifiers: 57 | key = "+".join(modifiers + [key]) 58 | 59 | try: 60 | page = await self._get_page() 61 | await page.keyboard.press(key) 62 | except Exception as e: 63 | raise DendriteException( 64 | message=f"Failed to press key: {key}. Error: {str(e)}", 65 | screenshot_base64="", 66 | ) 67 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/mixin/markdown.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Optional 3 | 4 | from bs4 import BeautifulSoup 5 | from markdownify import markdownify as md 6 | 7 | from ..mixin.extract import ExtractionMixin 8 | from ..protocol.page_protocol import DendritePageProtocol 9 | 10 | 11 | class MarkdownMixin(ExtractionMixin, DendritePageProtocol): 12 | async def markdown(self, prompt: Optional[str] = None): 13 | page = await self._get_page() 14 | page_information = await page.get_page_information() 15 | if prompt: 16 | extract_prompt = f"Create a script that returns the HTML from one element from the DOM that best matches this requested section of the website.\n\nDescription of section: '{prompt}'\n\nWe will be converting your returned HTML to markdown, so just return ONE stringified HTML element and nothing else. It's OK if extra information is present. Example script: 'response_data = soup.find('tag', {{'attribute': 'value'}}).prettify()'" 17 | res = await self.extract(extract_prompt) 18 | markdown_text = md(res) 19 | # Remove excessive newlines (3 or more) and replace with 2 newlines 20 | cleaned_markdown = re.sub(r"\n{3,}", "\n\n", markdown_text) 21 | return cleaned_markdown 22 | else: 23 | markdown_text = md(page_information.raw_html) 24 | # Remove excessive newlines (3 or more) and replace with 2 newlines 25 | cleaned_markdown = re.sub(r"\n{3,}", "\n\n", markdown_text) 26 | return cleaned_markdown 27 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/mixin/screenshot.py: -------------------------------------------------------------------------------- 1 | from ..protocol.page_protocol import DendritePageProtocol 2 | 3 | 4 | class ScreenshotMixin(DendritePageProtocol): 5 | 6 | async def screenshot(self, full_page: bool = False) -> str: 7 | """ 8 | Take a screenshot of the current page. 9 | 10 | Args: 11 | full_page (bool, optional): If True, captures the full page. If False, captures only the viewport. Defaults to False. 12 | 13 | Returns: 14 | str: A base64 encoded string of the screenshot in JPEG format. 15 | """ 16 | page = await self._get_page() 17 | if full_page: 18 | return await page.screenshot_manager.take_full_page_screenshot() 19 | else: 20 | return await page.screenshot_manager.take_viewport_screenshot() 21 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/mixin/wait_for.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | 4 | from loguru import logger 5 | 6 | from dendrite.browser._common._exceptions.dendrite_exception import ( 7 | DendriteException, 8 | PageConditionNotMet, 9 | ) 10 | 11 | from ..mixin.ask import AskMixin 12 | from ..protocol.page_protocol import DendritePageProtocol 13 | 14 | 15 | class WaitForMixin(AskMixin, DendritePageProtocol): 16 | 17 | async def wait_for( 18 | self, 19 | prompt: str, 20 | timeout: float = 30000, 21 | ): 22 | """ 23 | Waits for the condition specified in the prompt to become true by periodically checking the page content. 24 | 25 | This method attempts to retrieve the page information and evaluate whether the specified 26 | condition (provided in the prompt) is met. It continues to retry until the total elapsed time 27 | exceeds the specified timeout. 28 | 29 | Args: 30 | prompt (str): The prompt to determine the condition to wait for on the page. 31 | timeout (float, optional): The maximum time (in milliseconds) to wait for the condition. Defaults to 15000. 32 | 33 | Returns: 34 | Any: The result of the condition evaluation if successful. 35 | 36 | Raises: 37 | PageConditionNotMet: If the condition is not met within the specified timeout. 38 | """ 39 | 40 | start_time = time.time() 41 | await asyncio.sleep( 42 | 0.2 43 | ) # HACK: Wait for page to load slightly when running first time 44 | 45 | while True: 46 | elapsed_time = (time.time() - start_time) * 1000 # Convert to milliseconds 47 | if elapsed_time >= timeout: 48 | break 49 | 50 | page = await self._get_page() 51 | page_information = await page.get_page_information() 52 | prompt_with_instruction = f"Prompt: '{prompt}'\n\nReturn a boolean that determines if the requested information or thing is available on the page. {round(page_information.time_since_frame_navigated, 2)} seconds have passed since the page first loaded." 53 | 54 | try: 55 | res = await self.ask(prompt_with_instruction, bool) 56 | if res: 57 | return res 58 | except DendriteException as e: 59 | logger.debug(f"Attempt failed: {e.message}") 60 | 61 | # Wait for a short interval before the next attempt 62 | await asyncio.sleep(0.5) 63 | 64 | page = await self._get_page() 65 | page_information = await page.get_page_information() 66 | raise PageConditionNotMet( 67 | message=f"Failed to wait for the requested condition within the {timeout}ms timeout.", 68 | screenshot_base64=page_information.screenshot_base64, 69 | ) 70 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/protocol/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrite-systems/dendrite-python-sdk/16e591332855547ca31fb8a72793d05a96b2f4d3/dendrite/browser/async_api/protocol/__init__.py -------------------------------------------------------------------------------- /dendrite/browser/async_api/protocol/browser_protocol.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Optional, Protocol, Union 2 | 3 | from typing_extensions import Literal 4 | 5 | from dendrite.browser.remote import Providers 6 | 7 | if TYPE_CHECKING: 8 | from ..dendrite_browser import AsyncDendrite 9 | 10 | from playwright.async_api import Browser, Download, Playwright 11 | 12 | from ..types import PlaywrightPage 13 | 14 | 15 | class BrowserProtocol(Protocol): 16 | def __init__(self, settings: Providers) -> None: ... 17 | 18 | async def get_download( 19 | self, dendrite_browser: "AsyncDendrite", pw_page: PlaywrightPage, timeout: float 20 | ) -> Download: 21 | """ 22 | Retrieves the download event from the browser. 23 | 24 | Returns: 25 | Download: The download event. 26 | 27 | Raises: 28 | Exception: If there is an issue retrieving the download event. 29 | """ 30 | ... 31 | 32 | async def start_browser( 33 | self, 34 | playwright: Playwright, 35 | pw_options: dict, 36 | ) -> Browser: 37 | """ 38 | Starts the browser session. 39 | 40 | Args: 41 | playwright: The playwright instance 42 | pw_options: Playwright launch options 43 | 44 | Returns: 45 | Browser: A Browser instance 46 | """ 47 | ... 48 | 49 | async def configure_context(self, browser: "AsyncDendrite") -> None: 50 | """ 51 | Configures the browser context. 52 | 53 | Args: 54 | browser (AsyncDendrite): The browser to configure. 55 | 56 | Raises: 57 | Exception: If there is an issue configuring the browser context. 58 | """ 59 | ... 60 | 61 | async def stop_session(self) -> None: 62 | """ 63 | Stops the browser session. 64 | 65 | Raises: 66 | Exception: If there is an issue stopping the browser session. 67 | """ 68 | ... 69 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/protocol/download_protocol.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from pathlib import Path 3 | from typing import Any, Union 4 | 5 | from playwright.async_api import Download 6 | 7 | 8 | class DownloadInterface(ABC, Download): 9 | def __init__(self, download: Download): 10 | self._download = download 11 | 12 | def __getattribute__(self, name: str) -> Any: 13 | # First, check if DownloadInterface has the attribute 14 | try: 15 | return super().__getattribute__(name) 16 | except AttributeError: 17 | # If not, delegate to the wrapped Download instance 18 | return getattr(self._download, name) 19 | 20 | @abstractmethod 21 | async def save_as(self, path: Union[str, Path]) -> None: 22 | pass 23 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/protocol/page_protocol.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Protocol 2 | 3 | from dendrite.logic import AsyncLogicEngine 4 | 5 | if TYPE_CHECKING: 6 | from ..dendrite_browser import AsyncDendrite 7 | from ..dendrite_page import AsyncPage 8 | 9 | 10 | class DendritePageProtocol(Protocol): 11 | """ 12 | Protocol that specifies the required methods and attributes 13 | for the `ExtractionMixin` to work. 14 | """ 15 | 16 | @property 17 | def logic_engine(self) -> AsyncLogicEngine: ... 18 | 19 | @property 20 | def dendrite_browser(self) -> "AsyncDendrite": ... 21 | 22 | async def _get_page(self) -> "AsyncPage": ... 23 | -------------------------------------------------------------------------------- /dendrite/browser/async_api/types.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Any, Dict, Literal, Type, TypeVar, Union 3 | 4 | from playwright.async_api import Page 5 | from pydantic import BaseModel 6 | 7 | Interaction = Literal["click", "fill", "hover"] 8 | 9 | T = TypeVar("T") 10 | PydanticModel = TypeVar("PydanticModel", bound=BaseModel) 11 | PrimitiveTypes = PrimitiveTypes = Union[Type[bool], Type[int], Type[float], Type[str]] 12 | JsonSchema = Dict[str, Any] 13 | TypeSpec = Union[PrimitiveTypes, PydanticModel, JsonSchema] 14 | 15 | PlaywrightPage = Page 16 | -------------------------------------------------------------------------------- /dendrite/browser/remote/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from dendrite.browser.remote.browserbase_config import BrowserbaseConfig 4 | from dendrite.browser.remote.browserless_config import BrowserlessConfig 5 | 6 | Providers = Union[BrowserbaseConfig, BrowserlessConfig] 7 | 8 | __all__ = ["Providers", "BrowserbaseConfig"] 9 | -------------------------------------------------------------------------------- /dendrite/browser/remote/browserbase_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional 3 | 4 | from dendrite.exceptions import MissingApiKeyError 5 | 6 | 7 | class BrowserbaseConfig: 8 | def __init__( 9 | self, 10 | api_key: Optional[str] = None, 11 | project_id: Optional[str] = None, 12 | enable_proxy: bool = False, 13 | ): 14 | api_key = api_key if api_key is not None else os.getenv("BROWSERBASE_API_KEY") 15 | if api_key is None: 16 | raise MissingApiKeyError("BROWSERBASE_API_KEY") 17 | project_id = ( 18 | project_id 19 | if project_id is not None 20 | else os.getenv("BROWSERBASE_PROJECT_ID") 21 | ) 22 | if project_id is None: 23 | raise MissingApiKeyError("BROWSERBASE_PROJECT_ID") 24 | 25 | self.api_key = api_key 26 | self.project_id = project_id 27 | self.enable_proxy = enable_proxy 28 | -------------------------------------------------------------------------------- /dendrite/browser/remote/browserless_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional 3 | 4 | from dendrite.browser._common._exceptions.dendrite_exception import MissingApiKeyError 5 | 6 | 7 | class BrowserlessConfig: 8 | def __init__( 9 | self, 10 | url: str = "wss://production-sfo.browserless.io", 11 | api_key: Optional[str] = None, 12 | proxy: Optional[str] = None, 13 | proxy_country: Optional[str] = None, 14 | block_ads: bool = False, 15 | ): 16 | api_key = api_key if api_key is not None else os.getenv("BROWSERLESS_API_KEY") 17 | if api_key is None: 18 | raise MissingApiKeyError("BROWSERLESS_API_KEY") 19 | 20 | self.url = url 21 | self.api_key = api_key 22 | self.block_ads = block_ads 23 | self.proxy = proxy 24 | self.proxy_country = proxy_country 25 | -------------------------------------------------------------------------------- /dendrite/browser/remote/provider.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Union 3 | 4 | from dendrite.browser.remote import Providers 5 | from dendrite.browser.remote.browserbase_config import BrowserbaseConfig 6 | 7 | try: 8 | import tomllib # type: ignore 9 | except ModuleNotFoundError: 10 | import tomli as tomllib # tomllib is only included standard lib for python 3.11+ 11 | 12 | 13 | NAME_TO_CONFIG = {"browserbase": BrowserbaseConfig} 14 | 15 | 16 | class ProviderConfig: 17 | @classmethod 18 | def from_toml(cls, path: Union[str, Path]) -> Providers: 19 | if isinstance(path, str): 20 | path = Path(path) 21 | if not path.exists(): 22 | raise FileNotFoundError(f"Config file not found at {path}") 23 | 24 | # Load the TOML config file 25 | 26 | config = tomllib.loads(path.read_text()) 27 | 28 | remote_provider = config.get("remote_provider") 29 | if remote_provider is None: 30 | raise ValueError("Config file must contain a 'remote_provider' key") 31 | # Determine the provider type 32 | provider_type = remote_provider.get("name") 33 | if provider_type is None: 34 | raise ValueError("Config file must contain a 'remote_provider.name' key") 35 | 36 | # Get the corresponding config class 37 | config_class = NAME_TO_CONFIG.get(provider_type) 38 | if config_class is None: 39 | raise ValueError( 40 | f"Unsupported provider type: {provider_type}, must be one of {NAME_TO_CONFIG.keys()}" 41 | ) 42 | 43 | settings = remote_provider.get("settings") 44 | if settings is None: 45 | raise ValueError( 46 | "Config file must contain a 'remote_provider.settings' key" 47 | ) 48 | 49 | return config_class(**settings) 50 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/__init__.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | from .dendrite_browser import Dendrite 3 | from .dendrite_element import Element 4 | from .dendrite_page import Page 5 | 6 | __all__ = ["Dendrite", "Element", "Page"] 7 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/_event_sync.py: -------------------------------------------------------------------------------- 1 | import time 2 | import time 3 | from typing import Generic, Optional, Type, TypeVar 4 | from playwright.sync_api import Download, FileChooser, Page 5 | 6 | Events = TypeVar("Events", Download, FileChooser) 7 | mapping = {Download: "download", FileChooser: "filechooser"} 8 | 9 | 10 | class EventSync(Generic[Events]): 11 | 12 | def __init__(self, event_type: Type[Events]): 13 | self.event_type = event_type 14 | self.event_set = False 15 | self.data: Optional[Events] = None 16 | 17 | def get_data(self, pw_page: Page, timeout: float = 30000) -> Events: 18 | start_time = time.time() 19 | while not self.event_set: 20 | elapsed_time = (time.time() - start_time) * 1000 21 | if elapsed_time > timeout: 22 | raise TimeoutError(f'Timeout waiting for event "{self.event_type}".') 23 | pw_page.wait_for_timeout(0) 24 | time.sleep(0.01) 25 | data = self.data 26 | self.data = None 27 | self.event_set = False 28 | if data is None: 29 | raise ValueError("Data is None for event type: ", self.event_type) 30 | return data 31 | 32 | def set_event(self, data: Events) -> None: 33 | """ 34 | Sets the event and stores the provided data. 35 | 36 | This method is used to signal that the data is ready to be retrieved by any waiting tasks. 37 | 38 | Args: 39 | data (T): The data to be stored and associated with the event. 40 | 41 | Returns: 42 | None 43 | """ 44 | self.data = data 45 | self.event_set = True 46 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/_utils.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union 3 | import tldextract 4 | from bs4 import BeautifulSoup 5 | from loguru import logger 6 | from playwright.sync_api import Error, Frame 7 | from pydantic import BaseModel 8 | from dendrite.models.selector import Selector 9 | from .dendrite_element import Element 10 | from .types import PlaywrightPage, TypeSpec 11 | 12 | if TYPE_CHECKING: 13 | from .dendrite_page import Page 14 | from dendrite.logic.dom.strip import mild_strip_in_place 15 | from .js import GENERATE_DENDRITE_IDS_IFRAME_SCRIPT 16 | 17 | 18 | def get_domain_w_suffix(url: str) -> str: 19 | parsed_url = tldextract.extract(url) 20 | if parsed_url.suffix == "": 21 | raise ValueError(f"Invalid URL: {url}") 22 | return f"{parsed_url.domain}.{parsed_url.suffix}" 23 | 24 | 25 | def expand_iframes(page: PlaywrightPage, page_soup: BeautifulSoup): 26 | 27 | def get_iframe_path(frame: Frame): 28 | path_parts = [] 29 | current_frame = frame 30 | while current_frame.parent_frame is not None: 31 | iframe_element = current_frame.frame_element() 32 | iframe_id = iframe_element.get_attribute("d-id") 33 | if iframe_id is None: 34 | return None 35 | path_parts.insert(0, iframe_id) 36 | current_frame = current_frame.parent_frame 37 | return "|".join(path_parts) 38 | 39 | for frame in page.frames: 40 | if frame.parent_frame is None: 41 | continue 42 | try: 43 | iframe_element = frame.frame_element() 44 | iframe_id = iframe_element.get_attribute("d-id") 45 | if iframe_id is None: 46 | continue 47 | iframe_path = get_iframe_path(frame) 48 | except Error as e: 49 | continue 50 | if iframe_path is None: 51 | continue 52 | try: 53 | frame.evaluate( 54 | GENERATE_DENDRITE_IDS_IFRAME_SCRIPT, {"frame_path": iframe_path} 55 | ) 56 | frame_content = frame.content() 57 | frame_tree = BeautifulSoup(frame_content, "lxml") 58 | mild_strip_in_place(frame_tree) 59 | merge_iframe_to_page(iframe_id, page_soup, frame_tree) 60 | except Error as e: 61 | continue 62 | 63 | 64 | def merge_iframe_to_page(iframe_id: str, page: BeautifulSoup, iframe: BeautifulSoup): 65 | iframe_element = page.find("iframe", {"d-id": iframe_id}) 66 | if iframe_element is None: 67 | logger.debug(f"Could not find iframe with ID {iframe_id} in page soup") 68 | return 69 | iframe_element.replace_with(iframe) 70 | 71 | 72 | def _get_all_elements_from_selector_soup( 73 | selector: str, soup: BeautifulSoup, page: "Page" 74 | ) -> List[Element]: 75 | dendrite_elements: List[Element] = [] 76 | elements = soup.select(selector) 77 | for element in elements: 78 | frame = page._get_context(element) 79 | d_id = element.get("d-id", "") 80 | locator = frame.locator(f"xpath=//*[@d-id='{d_id}']") 81 | if not d_id: 82 | continue 83 | if isinstance(d_id, list): 84 | d_id = d_id[0] 85 | dendrite_elements.append( 86 | Element(d_id, locator, page.dendrite_browser, page._browser_api_client) 87 | ) 88 | return dendrite_elements 89 | 90 | 91 | def get_elements_from_selectors_soup( 92 | page: "Page", soup: BeautifulSoup, selectors: List[Selector], only_one: bool 93 | ) -> Union[Optional[Element], List[Element]]: 94 | for selector in reversed(selectors): 95 | dendrite_elements = _get_all_elements_from_selector_soup( 96 | selector.selector, soup, page 97 | ) 98 | if len(dendrite_elements) > 0: 99 | return dendrite_elements[0] if only_one else dendrite_elements 100 | return None 101 | 102 | 103 | def to_json_schema(type_spec: TypeSpec) -> Dict[str, Any]: 104 | if isinstance(type_spec, dict): 105 | return type_spec 106 | if inspect.isclass(type_spec) and issubclass(type_spec, BaseModel): 107 | return type_spec.model_json_schema() 108 | if type_spec in (bool, int, float, str): 109 | type_map = {bool: "boolean", int: "integer", float: "number", str: "string"} 110 | return {"type": type_map[type_spec]} 111 | raise ValueError(f"Unsupported type specification: {type_spec}") 112 | 113 | 114 | def convert_to_type_spec(type_spec: TypeSpec, return_data: Any) -> TypeSpec: 115 | if isinstance(type_spec, type): 116 | if issubclass(type_spec, BaseModel): 117 | return type_spec.model_validate(return_data) 118 | if type_spec in (str, float, bool, int): 119 | return type_spec(return_data) 120 | raise ValueError(f"Unsupported type: {type_spec}") 121 | if isinstance(type_spec, dict): 122 | return return_data 123 | raise ValueError(f"Unsupported type specification: {type_spec}") 124 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/browser_impl/__init__.py: -------------------------------------------------------------------------------- 1 | from .browserbase import BrowserbaseDownload 2 | 3 | __all__ = ["BrowserbaseDownload"] 4 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/browser_impl/browserbase/__init__.py: -------------------------------------------------------------------------------- 1 | from ._download import BrowserbaseDownload 2 | 3 | __all__ = ["BrowserbaseDownload"] 4 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/browser_impl/browserbase/_client.py: -------------------------------------------------------------------------------- 1 | import time 2 | import time 3 | from pathlib import Path 4 | from typing import Optional, Union 5 | import httpx 6 | from loguru import logger 7 | from dendrite.browser._common._exceptions.dendrite_exception import DendriteException 8 | 9 | 10 | class BrowserbaseClient: 11 | 12 | def __init__(self, api_key: str, project_id: str) -> None: 13 | self.api_key = api_key 14 | self.project_id = project_id 15 | 16 | def create_session(self) -> str: 17 | logger.debug("Creating session") 18 | "\n Creates a session using the Browserbase API.\n\n Returns:\n str: The ID of the created session.\n " 19 | url = "https://www.browserbase.com/v1/sessions" 20 | headers = {"Content-Type": "application/json", "x-bb-api-key": self.api_key} 21 | json = {"projectId": self.project_id, "keepAlive": False} 22 | response = httpx.post(url, json=json, headers=headers) 23 | if response.status_code >= 400: 24 | raise DendriteException(f"Failed to create session: {response.text}") 25 | return response.json()["id"] 26 | 27 | def stop_session(self, session_id: str): 28 | url = f"https://www.browserbase.com/v1/sessions/{session_id}" 29 | headers = {"Content-Type": "application/json", "x-bb-api-key": self.api_key} 30 | json = {"projectId": self.project_id, "status": "REQUEST_RELEASE"} 31 | with httpx.Client() as client: 32 | response = client.post(url, json=json, headers=headers) 33 | return response.json() 34 | 35 | def connect_url(self, enable_proxy: bool, session_id: Optional[str] = None) -> str: 36 | url = f"wss://connect.browserbase.com?apiKey={self.api_key}" 37 | if session_id: 38 | url += f"&sessionId={session_id}" 39 | if enable_proxy: 40 | url += "&enableProxy=true" 41 | return url 42 | 43 | def save_downloads_on_disk( 44 | self, session_id: str, path: Union[str, Path], retry_for_seconds: float 45 | ): 46 | url = f"https://www.browserbase.com/v1/sessions/{session_id}/downloads" 47 | headers = {"x-bb-api-key": self.api_key} 48 | file_path = Path(path) 49 | with httpx.Client() as session: 50 | timeout = time.time() + retry_for_seconds 51 | while time.time() < timeout: 52 | try: 53 | response = session.get(url, headers=headers) 54 | if response.status_code == 200: 55 | array_buffer = response.read() 56 | if len(array_buffer) > 0: 57 | with open(file_path, "wb") as f: 58 | f.write(array_buffer) 59 | return 60 | except Exception as e: 61 | logger.debug(f"Error fetching downloads: {e}") 62 | time.sleep(2) 63 | logger.debug("Failed to download files within the time limit.") 64 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/browser_impl/browserbase/_download.py: -------------------------------------------------------------------------------- 1 | import re 2 | import shutil 3 | import zipfile 4 | from pathlib import Path 5 | from typing import Union 6 | from loguru import logger 7 | from playwright.sync_api import Download 8 | from dendrite.browser.sync_api.browser_impl.browserbase._client import BrowserbaseClient 9 | from dendrite.browser.sync_api.protocol.download_protocol import DownloadInterface 10 | 11 | 12 | class BrowserbaseDownload(DownloadInterface): 13 | 14 | def __init__( 15 | self, session_id: str, download: Download, client: BrowserbaseClient 16 | ) -> None: 17 | super().__init__(download) 18 | self._session_id = session_id 19 | self._client = client 20 | 21 | def save_as(self, path: Union[str, Path], timeout: float = 20) -> None: 22 | """ 23 | Save the latest file from the downloaded ZIP archive to the specified path. 24 | 25 | Args: 26 | path (Union[str, Path]): The destination file path where the latest file will be saved. 27 | timeout (float, optional): Timeout for the save operation. Defaults to 20 seconds. 28 | 29 | Raises: 30 | Exception: If no matching files are found in the ZIP archive or if the file cannot be saved. 31 | """ 32 | destination_path = Path(path) 33 | source_path = self._download.path() 34 | destination_path.parent.mkdir(parents=True, exist_ok=True) 35 | with zipfile.ZipFile(source_path, "r") as zip_ref: 36 | file_list = zip_ref.namelist() 37 | sorted_files = sorted(file_list, key=extract_timestamp, reverse=True) 38 | if not sorted_files: 39 | raise FileNotFoundError( 40 | "No files found in the Browserbase download ZIP" 41 | ) 42 | latest_file = sorted_files[0] 43 | with zip_ref.open(latest_file) as source, open( 44 | destination_path, "wb" 45 | ) as target: 46 | shutil.copyfileobj(source, target) 47 | logger.info(f"Latest file saved successfully to {destination_path}") 48 | 49 | 50 | def extract_timestamp(filename): 51 | timestamp_pattern = re.compile("-(\\d+)\\.") 52 | match = timestamp_pattern.search(filename) 53 | return int(match.group(1)) if match else 0 54 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/browser_impl/browserbase/_impl.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Optional 2 | from dendrite.browser._common._exceptions.dendrite_exception import ( 3 | BrowserNotLaunchedError, 4 | ) 5 | from dendrite.browser.sync_api.protocol.browser_protocol import BrowserProtocol 6 | from dendrite.browser.sync_api.types import PlaywrightPage 7 | from dendrite.browser.remote.browserbase_config import BrowserbaseConfig 8 | 9 | if TYPE_CHECKING: 10 | from dendrite.browser.sync_api.dendrite_browser import Dendrite 11 | from loguru import logger 12 | from playwright.sync_api import Playwright 13 | from ._client import BrowserbaseClient 14 | from ._download import BrowserbaseDownload 15 | 16 | 17 | class BrowserbaseImpl(BrowserProtocol): 18 | 19 | def __init__(self, settings: BrowserbaseConfig) -> None: 20 | self.settings = settings 21 | self._client = BrowserbaseClient( 22 | self.settings.api_key, self.settings.project_id 23 | ) 24 | self._session_id: Optional[str] = None 25 | 26 | def stop_session(self): 27 | if self._session_id: 28 | self._client.stop_session(self._session_id) 29 | 30 | def start_browser(self, playwright: Playwright, pw_options: dict): 31 | logger.debug("Starting browser") 32 | self._session_id = self._client.create_session() 33 | url = self._client.connect_url(self.settings.enable_proxy, self._session_id) 34 | logger.debug(f"Connecting to browser at {url}") 35 | return playwright.chromium.connect_over_cdp(url) 36 | 37 | def configure_context(self, browser: "Dendrite"): 38 | logger.debug("Configuring browser context") 39 | page = browser.get_active_page() 40 | pw_page = page.playwright_page 41 | if browser.browser_context is None: 42 | raise BrowserNotLaunchedError() 43 | client = browser.browser_context.new_cdp_session(pw_page) 44 | client.send( 45 | "Browser.setDownloadBehavior", 46 | {"behavior": "allow", "downloadPath": "downloads", "eventsEnabled": True}, 47 | ) 48 | 49 | def get_download( 50 | self, 51 | dendrite_browser: "Dendrite", 52 | pw_page: PlaywrightPage, 53 | timeout: float = 30000, 54 | ) -> BrowserbaseDownload: 55 | if not self._session_id: 56 | raise ValueError( 57 | "Downloads are not enabled for this provider. Specify enable_downloads=True in the constructor" 58 | ) 59 | logger.debug("Getting download") 60 | download = dendrite_browser._download_handler.get_data(pw_page, timeout) 61 | self._client.save_downloads_on_disk(self._session_id, download.path(), 30) 62 | return BrowserbaseDownload(self._session_id, download, self._client) 63 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/browser_impl/browserless/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrite-systems/dendrite-python-sdk/16e591332855547ca31fb8a72793d05a96b2f4d3/dendrite/browser/sync_api/browser_impl/browserless/__init__.py -------------------------------------------------------------------------------- /dendrite/browser/sync_api/browser_impl/browserless/_impl.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import TYPE_CHECKING, Optional 3 | from dendrite.browser._common._exceptions.dendrite_exception import ( 4 | BrowserNotLaunchedError, 5 | ) 6 | from dendrite.browser.sync_api.protocol.browser_protocol import BrowserProtocol 7 | from dendrite.browser.sync_api.types import PlaywrightPage 8 | from dendrite.browser.remote.browserless_config import BrowserlessConfig 9 | 10 | if TYPE_CHECKING: 11 | from dendrite.browser.sync_api.dendrite_browser import Dendrite 12 | import urllib.parse 13 | from loguru import logger 14 | from playwright.sync_api import Playwright 15 | from dendrite.browser.sync_api.browser_impl.browserbase._client import BrowserbaseClient 16 | from dendrite.browser.sync_api.browser_impl.browserbase._download import ( 17 | BrowserbaseDownload, 18 | ) 19 | 20 | 21 | class BrowserlessImpl(BrowserProtocol): 22 | 23 | def __init__(self, settings: BrowserlessConfig) -> None: 24 | self.settings = settings 25 | self._session_id: Optional[str] = None 26 | 27 | def stop_session(self): 28 | pass 29 | 30 | def start_browser(self, playwright: Playwright, pw_options: dict): 31 | logger.debug("Starting browser") 32 | url = self._format_connection_url(pw_options) 33 | logger.debug(f"Connecting to browser at {url}") 34 | return playwright.chromium.connect_over_cdp(url) 35 | 36 | def _format_connection_url(self, pw_options: dict) -> str: 37 | url = self.settings.url.rstrip("?").rstrip("/") 38 | query = { 39 | "token": self.settings.api_key, 40 | "blockAds": self.settings.block_ads, 41 | "launch": json.dumps(pw_options), 42 | } 43 | if self.settings.proxy: 44 | query["proxy"] = (self.settings.proxy,) 45 | query["proxyCountry"] = (self.settings.proxy_country,) 46 | return f"{url}?{urllib.parse.urlencode(query)}" 47 | 48 | def configure_context(self, browser: "Dendrite"): 49 | pass 50 | 51 | def get_download( 52 | self, 53 | dendrite_browser: "Dendrite", 54 | pw_page: PlaywrightPage, 55 | timeout: float = 30000, 56 | ) -> BrowserbaseDownload: 57 | raise NotImplementedError("Downloads are not supported for Browserless") 58 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/browser_impl/impl_mapping.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional, Type 2 | from dendrite.browser.remote import Providers 3 | from dendrite.browser.remote.browserbase_config import BrowserbaseConfig 4 | from dendrite.browser.remote.browserless_config import BrowserlessConfig 5 | from ..protocol.browser_protocol import BrowserProtocol 6 | from .browserbase._impl import BrowserbaseImpl 7 | from .browserless._impl import BrowserlessImpl 8 | from .local._impl import LocalImpl 9 | 10 | IMPL_MAPPING: Dict[Type[Providers], Type[BrowserProtocol]] = { 11 | BrowserbaseConfig: BrowserbaseImpl, 12 | BrowserlessConfig: BrowserlessImpl, 13 | } 14 | SETTINGS_CLASSES: Dict[str, Type[Providers]] = { 15 | "browserbase": BrowserbaseConfig, 16 | "browserless": BrowserlessConfig, 17 | } 18 | 19 | 20 | def get_impl(remote_provider: Optional[Providers]) -> BrowserProtocol: 21 | if remote_provider is None: 22 | return LocalImpl() 23 | try: 24 | provider_class = IMPL_MAPPING[type(remote_provider)] 25 | except KeyError: 26 | raise ValueError( 27 | f"No implementation for {type(remote_provider)}. Available providers: {', '.join(map(lambda x: x.__name__, IMPL_MAPPING.keys()))}" 28 | ) 29 | return provider_class(remote_provider) 30 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/browser_impl/local/_impl.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING, Optional, Union, overload 3 | from loguru import logger 4 | from typing_extensions import Literal 5 | from dendrite.browser._common.constants import STEALTH_ARGS 6 | 7 | if TYPE_CHECKING: 8 | from dendrite.browser.sync_api.dendrite_browser import Dendrite 9 | import os 10 | import shutil 11 | import tempfile 12 | from playwright.sync_api import ( 13 | Browser, 14 | BrowserContext, 15 | Download, 16 | Playwright, 17 | StorageState, 18 | ) 19 | from dendrite.browser.sync_api.protocol.browser_protocol import BrowserProtocol 20 | from dendrite.browser.sync_api.types import PlaywrightPage 21 | 22 | 23 | class LocalImpl(BrowserProtocol): 24 | 25 | def __init__(self) -> None: 26 | pass 27 | 28 | def start_browser( 29 | self, 30 | playwright: Playwright, 31 | pw_options: dict, 32 | storage_state: Optional[StorageState] = None, 33 | ) -> Browser: 34 | return playwright.chromium.launch(**pw_options) 35 | 36 | def get_download( 37 | self, dendrite_browser: "Dendrite", pw_page: PlaywrightPage, timeout: float 38 | ) -> Download: 39 | return dendrite_browser._download_handler.get_data(pw_page, timeout) 40 | 41 | def configure_context(self, browser: "Dendrite"): 42 | pass 43 | 44 | def stop_session(self): 45 | pass 46 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/js/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | def load_script(filename: str) -> str: 5 | current_dir = Path(__file__).parent 6 | file_path = current_dir / filename 7 | return file_path.read_text(encoding="utf-8") 8 | 9 | 10 | GENERATE_DENDRITE_IDS_SCRIPT = load_script("generateDendriteIDs.js") 11 | GENERATE_DENDRITE_IDS_IFRAME_SCRIPT = load_script("generateDendriteIDsIframe.js") 12 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/js/eventListenerPatch.js: -------------------------------------------------------------------------------- 1 | // Save the original methods before redefining them 2 | EventTarget.prototype._originalAddEventListener = EventTarget.prototype.addEventListener; 3 | EventTarget.prototype._originalRemoveEventListener = EventTarget.prototype.removeEventListener; 4 | 5 | // Redefine the addEventListener method 6 | EventTarget.prototype.addEventListener = function(event, listener, options = false) { 7 | // Initialize the eventListenerList if it doesn't exist 8 | if (!this.eventListenerList) { 9 | this.eventListenerList = {}; 10 | } 11 | // Initialize the event list for the specific event if it doesn't exist 12 | if (!this.eventListenerList[event]) { 13 | this.eventListenerList[event] = []; 14 | } 15 | // Add the event listener details to the event list 16 | this.eventListenerList[event].push({ listener, options, outerHTML: this.outerHTML }); 17 | 18 | // Call the original addEventListener method 19 | this._originalAddEventListener(event, listener, options); 20 | }; 21 | 22 | // Redefine the removeEventListener method 23 | EventTarget.prototype.removeEventListener = function(event, listener, options = false) { 24 | // Remove the event listener details from the event list 25 | if (this.eventListenerList && this.eventListenerList[event]) { 26 | this.eventListenerList[event] = this.eventListenerList[event].filter( 27 | item => item.listener !== listener 28 | ); 29 | } 30 | 31 | // Call the original removeEventListener method 32 | this._originalRemoveEventListener( event, listener, options); 33 | }; 34 | 35 | // Get event listeners for a specific event type or all events if not specified 36 | EventTarget.prototype._getEventListeners = function(eventType) { 37 | if (!this.eventListenerList) { 38 | this.eventListenerList = {}; 39 | } 40 | 41 | const eventsToCheck = ['click', 'dblclick', 'mousedown', 'mouseup', 'mouseover', 'mouseout', 'mousemove', 'keydown', 'keyup', 'keypress']; 42 | 43 | eventsToCheck.forEach(type => { 44 | if (!eventType || eventType === type) { 45 | if (this[`on${type}`]) { 46 | if (!this.eventListenerList[type]) { 47 | this.eventListenerList[type] = []; 48 | } 49 | this.eventListenerList[type].push({ listener: this[`on${type}`], inline: true }); 50 | } 51 | } 52 | }); 53 | 54 | return eventType === undefined ? this.eventListenerList : this.eventListenerList[eventType]; 55 | }; 56 | 57 | // Utility to show events 58 | function _showEvents(events) { 59 | let result = ''; 60 | for (let event in events) { 61 | result += `${event} ----------------> ${events[event].length}\n`; 62 | for (let listenerObj of events[event]) { 63 | result += `${listenerObj.listener.toString()}\n`; 64 | } 65 | } 66 | return result; 67 | } 68 | 69 | // Extend EventTarget prototype with utility methods 70 | EventTarget.prototype.on = function(event, callback, options) { 71 | this.addEventListener(event, callback, options); 72 | return this; 73 | }; 74 | 75 | EventTarget.prototype.off = function(event, callback, options) { 76 | this.removeEventListener(event, callback, options); 77 | return this; 78 | }; 79 | 80 | EventTarget.prototype.emit = function(event, args = null) { 81 | this.dispatchEvent(new CustomEvent(event, { detail: args })); 82 | return this; 83 | }; 84 | 85 | // Make these methods non-enumerable 86 | Object.defineProperties(EventTarget.prototype, { 87 | on: { enumerable: false }, 88 | off: { enumerable: false }, 89 | emit: { enumerable: false } 90 | }); 91 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/js/generateDendriteIDs.js: -------------------------------------------------------------------------------- 1 | var hashCode = (str) => { 2 | var hash = 0, i, chr; 3 | if (str.length === 0) return hash; 4 | for (i = 0; i < str.length; i++) { 5 | chr = str.charCodeAt(i); 6 | hash = ((hash << 5) - hash) + chr; 7 | hash |= 0; // Convert to 32bit integer 8 | } 9 | return hash; 10 | } 11 | 12 | 13 | const getElementIndex = (element) => { 14 | let index = 1; 15 | let sibling = element.previousElementSibling; 16 | 17 | while (sibling) { 18 | if (sibling.localName === element.localName) { 19 | index++; 20 | } 21 | sibling = sibling.previousElementSibling; 22 | } 23 | 24 | return index; 25 | }; 26 | 27 | 28 | const segs = function elmSegs(elm) { 29 | if (!elm || elm.nodeType !== 1) return ['']; 30 | if (elm.id && document.getElementById(elm.id) === elm) return [`id("${elm.id}")`]; 31 | const localName = typeof elm.localName === 'string' ? elm.localName.toLowerCase() : 'unknown'; 32 | let index = getElementIndex(elm); 33 | 34 | return [...elmSegs(elm.parentNode), `${localName}[${index}]`]; 35 | }; 36 | 37 | var getXPathForElement = (element) => { 38 | return segs(element).join('/'); 39 | } 40 | 41 | // Create a Map to store used hashes and their counters 42 | const usedHashes = new Map(); 43 | 44 | var markHidden = (hidden_element) => { 45 | // Mark the hidden element itself 46 | hidden_element.setAttribute('data-hidden', 'true'); 47 | 48 | } 49 | 50 | document.querySelectorAll('*').forEach((element, index) => { 51 | try { 52 | 53 | const xpath = getXPathForElement(element); 54 | const hash = hashCode(xpath); 55 | const baseId = hash.toString(36); 56 | 57 | // const is_marked_hidden = element.getAttribute("data-hidden") === "true"; 58 | const isHidden = !element.checkVisibility(); 59 | // computedStyle.width === '0px' || 60 | // computedStyle.height === '0px'; 61 | 62 | if (isHidden) { 63 | markHidden(element); 64 | }else{ 65 | element.removeAttribute("data-hidden") // in case we hid it in a previous call 66 | } 67 | 68 | let uniqueId = baseId; 69 | let counter = 0; 70 | 71 | // Check if this hash has been used before 72 | while (usedHashes.has(uniqueId)) { 73 | // If it has, increment the counter and create a new uniqueId 74 | counter++; 75 | uniqueId = `${baseId}_${counter}`; 76 | } 77 | 78 | // Add the uniqueId to the usedHashes Map 79 | usedHashes.set(uniqueId, true); 80 | element.setAttribute('d-id', uniqueId); 81 | } catch (error) { 82 | // Fallback: use a hash of the tag name and index 83 | const fallbackId = hashCode(`${element.tagName}_${index}`).toString(36); 84 | console.error('Error processing element, using fallback:',fallbackId, element, error); 85 | 86 | element.setAttribute('d-id', `fallback_${fallbackId}`); 87 | } 88 | }); -------------------------------------------------------------------------------- /dendrite/browser/sync_api/js/generateDendriteIDsIframe.js: -------------------------------------------------------------------------------- 1 | ({frame_path}) => { 2 | var hashCode = (str) => { 3 | var hash = 0, i, chr; 4 | if (str.length === 0) return hash; 5 | for (i = 0; i < str.length; i++) { 6 | chr = str.charCodeAt(i); 7 | hash = ((hash << 5) - hash) + chr; 8 | hash |= 0; // Convert to 32bit integer 9 | } 10 | return hash; 11 | } 12 | 13 | const getElementIndex = (element) => { 14 | let index = 1; 15 | let sibling = element.previousElementSibling; 16 | 17 | while (sibling) { 18 | if (sibling.localName === element.localName) { 19 | index++; 20 | } 21 | sibling = sibling.previousElementSibling; 22 | } 23 | 24 | return index; 25 | }; 26 | 27 | 28 | const segs = function elmSegs(elm) { 29 | if (!elm || elm.nodeType !== 1) return ['']; 30 | if (elm.id && document.getElementById(elm.id) === elm) return [`id("${elm.id}")`]; 31 | const localName = typeof elm.localName === 'string' ? elm.localName.toLowerCase() : 'unknown'; 32 | let index = getElementIndex(elm); 33 | 34 | return [...elmSegs(elm.parentNode), `${localName}[${index}]`]; 35 | }; 36 | 37 | var getXPathForElement = (element) => { 38 | return segs(element).join('/'); 39 | } 40 | 41 | // Create a Map to store used hashes and their counters 42 | const usedHashes = new Map(); 43 | 44 | var markHidden = (hidden_element) => { 45 | // Mark the hidden element itself 46 | hidden_element.setAttribute('data-hidden', 'true'); 47 | } 48 | 49 | document.querySelectorAll('*').forEach((element, index) => { 50 | try { 51 | 52 | 53 | // const is_marked_hidden = element.getAttribute("data-hidden") === "true"; 54 | const isHidden = !element.checkVisibility(); 55 | // computedStyle.width === '0px' || 56 | // computedStyle.height === '0px'; 57 | 58 | if (isHidden) { 59 | markHidden(element); 60 | }else{ 61 | element.removeAttribute("data-hidden") // in case we hid it in a previous call 62 | } 63 | let xpath = getXPathForElement(element); 64 | if(frame_path){ 65 | element.setAttribute("iframe-path",frame_path) 66 | xpath = frame_path + xpath; 67 | } 68 | const hash = hashCode(xpath); 69 | const baseId = hash.toString(36); 70 | 71 | let uniqueId = baseId; 72 | let counter = 0; 73 | 74 | // Check if this hash has been used before 75 | while (usedHashes.has(uniqueId)) { 76 | // If it has, increment the counter and create a new uniqueId 77 | counter++; 78 | uniqueId = `${baseId}_${counter}`; 79 | } 80 | 81 | // Add the uniqueId to the usedHashes Map 82 | usedHashes.set(uniqueId, true); 83 | element.setAttribute('d-id', uniqueId); 84 | } catch (error) { 85 | // Fallback: use a hash of the tag name and index 86 | const fallbackId = hashCode(`${element.tagName}_${index}`).toString(36); 87 | console.error('Error processing element, using fallback:',fallbackId, element, error); 88 | 89 | element.setAttribute('d-id', `fallback_${fallbackId}`); 90 | } 91 | }); 92 | } 93 | 94 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/manager/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrite-systems/dendrite-python-sdk/16e591332855547ca31fb8a72793d05a96b2f4d3/dendrite/browser/sync_api/manager/__init__.py -------------------------------------------------------------------------------- /dendrite/browser/sync_api/manager/navigation_tracker.py: -------------------------------------------------------------------------------- 1 | import time 2 | import time 3 | from typing import TYPE_CHECKING, Dict, Optional 4 | 5 | if TYPE_CHECKING: 6 | from ..dendrite_page import Page 7 | 8 | 9 | class NavigationTracker: 10 | 11 | def __init__(self, page: "Page"): 12 | self.playwright_page = page.playwright_page 13 | self._nav_start_timestamp: Optional[float] = None 14 | self.playwright_page.on("framenavigated", self._on_frame_navigated) 15 | self.playwright_page.on("popup", self._on_popup) 16 | self._last_events: Dict[str, Optional[float]] = { 17 | "framenavigated": None, 18 | "popup": None, 19 | } 20 | 21 | def _on_frame_navigated(self, frame): 22 | self._last_events["framenavigated"] = time.time() 23 | if frame is self.playwright_page.main_frame: 24 | self._last_main_frame_url = frame.url 25 | self._last_frame_navigated_timestamp = time.time() 26 | 27 | def _on_popup(self, page): 28 | self._last_events["popup"] = time.time() 29 | 30 | def start_nav_tracking(self): 31 | """Call this just before performing an action that might trigger navigation""" 32 | self._nav_start_timestamp = time.time() 33 | for event in self._last_events: 34 | self._last_events[event] = None 35 | 36 | def get_nav_events_since_start(self): 37 | """ 38 | Returns which events have fired since start_nav_tracking() was called 39 | and how long after the start they occurred 40 | """ 41 | if self._nav_start_timestamp is None: 42 | return "Navigation tracking not started. Call start_nav_tracking() first." 43 | results = {} 44 | for event, timestamp in self._last_events.items(): 45 | if timestamp is not None: 46 | delay = timestamp - self._nav_start_timestamp 47 | results[event] = f"{delay:.3f}s" 48 | else: 49 | results[event] = "not fired" 50 | return results 51 | 52 | def has_navigated_since_start(self): 53 | """Returns True if any navigation event has occurred since start_nav_tracking()""" 54 | if self._nav_start_timestamp is None: 55 | return False 56 | start_time = time.time() 57 | max_wait = 1.0 58 | while time.time() - start_time < max_wait: 59 | if any( 60 | ( 61 | timestamp is not None and timestamp > self._nav_start_timestamp 62 | for timestamp in self._last_events.values() 63 | ) 64 | ): 65 | return True 66 | time.sleep(0.1) 67 | return False 68 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/manager/page_manager.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Optional 2 | from loguru import logger 3 | from playwright.sync_api import BrowserContext, Download, FileChooser 4 | 5 | if TYPE_CHECKING: 6 | from ..dendrite_browser import Dendrite 7 | from ..dendrite_page import Page 8 | from ..types import PlaywrightPage 9 | 10 | 11 | class PageManager: 12 | 13 | def __init__(self, dendrite_browser, browser_context: BrowserContext): 14 | self.pages: list[Page] = [] 15 | self.active_page: Optional[Page] = None 16 | self.browser_context = browser_context 17 | self.dendrite_browser: Dendrite = dendrite_browser 18 | existing_pages = browser_context.pages 19 | if existing_pages: 20 | for page in existing_pages: 21 | client = self.dendrite_browser.logic_engine 22 | dendrite_page = Page(page, self.dendrite_browser, client) 23 | self.pages.append(dendrite_page) 24 | if self.active_page is None: 25 | self.active_page = dendrite_page 26 | browser_context.on("page", self._page_on_open_handler) 27 | 28 | def new_page(self) -> Page: 29 | new_page = self.browser_context.new_page() 30 | if self.active_page and new_page == self.active_page.playwright_page: 31 | return self.active_page 32 | client = self.dendrite_browser.logic_engine 33 | dendrite_page = Page(new_page, self.dendrite_browser, client) 34 | self.pages.append(dendrite_page) 35 | self.active_page = dendrite_page 36 | return dendrite_page 37 | 38 | def get_active_page(self) -> Page: 39 | if self.active_page is None: 40 | return self.new_page() 41 | return self.active_page 42 | 43 | def _page_on_close_handler(self, page: PlaywrightPage): 44 | if self.browser_context and (not self.dendrite_browser.closed): 45 | copy_pages = self.pages.copy() 46 | is_active_page = False 47 | for dendrite_page in copy_pages: 48 | if dendrite_page.playwright_page == page: 49 | self.pages.remove(dendrite_page) 50 | if dendrite_page == self.active_page: 51 | is_active_page = True 52 | break 53 | for i in reversed(range(len(self.pages))): 54 | try: 55 | self.active_page = self.pages[i] 56 | self.pages[i].playwright_page.bring_to_front() 57 | break 58 | except Exception as e: 59 | logger.warning(f"Error switching to the next page: {e}") 60 | continue 61 | 62 | def _page_on_crash_handler(self, page: PlaywrightPage): 63 | logger.error(f"Page crashed: {page.url}") 64 | page.reload() 65 | 66 | def _page_on_download_handler(self, download: Download): 67 | logger.debug(f"Download started: {download.url}") 68 | self.dendrite_browser._download_handler.set_event(download) 69 | 70 | def _page_on_filechooser_handler(self, file_chooser: FileChooser): 71 | logger.debug("File chooser opened") 72 | self.dendrite_browser._upload_handler.set_event(file_chooser) 73 | 74 | def _page_on_open_handler(self, page: PlaywrightPage): 75 | page.on("close", self._page_on_close_handler) 76 | page.on("crash", self._page_on_crash_handler) 77 | page.on("download", self._page_on_download_handler) 78 | page.on("filechooser", self._page_on_filechooser_handler) 79 | client = self.dendrite_browser.logic_engine 80 | dendrite_page = Page(page, self.dendrite_browser, client) 81 | self.pages.append(dendrite_page) 82 | self.active_page = dendrite_page 83 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/manager/screenshot_manager.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | from uuid import uuid4 4 | from ..types import PlaywrightPage 5 | 6 | 7 | class ScreenshotManager: 8 | 9 | def __init__(self, page: PlaywrightPage) -> None: 10 | self.screenshot_before: str = "" 11 | self.screenshot_after: str = "" 12 | self.page = page 13 | 14 | def take_full_page_screenshot(self) -> str: 15 | try: 16 | scroll_height = self.page.evaluate( 17 | "\n () => {\n const body = document.body;\n if (!body) {\n return 0; // Return 0 if body is null\n }\n return body.scrollHeight || 0;\n }\n " 18 | ) 19 | if scroll_height > 30000: 20 | print( 21 | f"Page height ({scroll_height}px) exceeds 30000px. Taking viewport screenshot instead." 22 | ) 23 | return self.take_viewport_screenshot() 24 | image_data = self.page.screenshot( 25 | type="jpeg", full_page=True, timeout=10000 26 | ) 27 | except Exception as e: 28 | print( 29 | f"Full-page screenshot failed: {e}. Falling back to viewport screenshot." 30 | ) 31 | return self.take_viewport_screenshot() 32 | if image_data is None: 33 | return "" 34 | return base64.b64encode(image_data).decode("utf-8") 35 | 36 | def take_viewport_screenshot(self) -> str: 37 | image_data = self.page.screenshot(type="jpeg", timeout=10000) 38 | if image_data is None: 39 | return "" 40 | reduced_base64 = base64.b64encode(image_data).decode("utf-8") 41 | return reduced_base64 42 | 43 | def store_screenshot(self, name, image_data): 44 | if not name: 45 | name = str(uuid4()) 46 | filepath = os.path.join("test", f"{name}.jpeg") 47 | os.makedirs(os.path.dirname(filepath), exist_ok=True) 48 | with open(filepath, "wb") as file: 49 | file.write(image_data) 50 | return filepath 51 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/mixin/__init__.py: -------------------------------------------------------------------------------- 1 | from .ask import AskMixin 2 | from .click import ClickMixin 3 | from .extract import ExtractionMixin 4 | from .fill_fields import FillFieldsMixin 5 | from .get_element import GetElementMixin 6 | from .keyboard import KeyboardMixin 7 | from .markdown import MarkdownMixin 8 | from .screenshot import ScreenshotMixin 9 | from .wait_for import WaitForMixin 10 | 11 | __all__ = [ 12 | "AskMixin", 13 | "ClickMixin", 14 | "ExtractionMixin", 15 | "FillFieldsMixin", 16 | "GetElementMixin", 17 | "KeyboardMixin", 18 | "MarkdownMixin", 19 | "ScreenshotMixin", 20 | "WaitForMixin", 21 | ] 22 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/mixin/click.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from dendrite.browser._common._exceptions.dendrite_exception import DendriteException 3 | from dendrite.models.response.interaction_response import InteractionResponse 4 | from ..mixin.get_element import GetElementMixin 5 | from ..protocol.page_protocol import DendritePageProtocol 6 | 7 | 8 | class ClickMixin(GetElementMixin, DendritePageProtocol): 9 | 10 | def click( 11 | self, 12 | prompt: str, 13 | expected_outcome: Optional[str] = None, 14 | use_cache: bool = True, 15 | timeout: int = 15000, 16 | force: bool = False, 17 | *args, 18 | **kwargs, 19 | ) -> InteractionResponse: 20 | """ 21 | Clicks an element on the page based on the provided prompt. 22 | 23 | This method combines the functionality of get_element and click, 24 | allowing for a more concise way to interact with elements on the page. 25 | 26 | Args: 27 | prompt (str): The prompt describing the element to be clicked. 28 | expected_outcome (Optional[str]): The expected outcome of the click action. 29 | use_cache (bool, optional): Whether to use cached results for element retrieval. Defaults to True. 30 | timeout (int, optional): The timeout (in milliseconds) for the click operation. Defaults to 15000. 31 | force (bool, optional): Whether to force the click operation. Defaults to False. 32 | *args: Additional positional arguments for the click operation. 33 | **kwargs: Additional keyword arguments for the click operation. 34 | 35 | Returns: 36 | InteractionResponse: The response from the interaction. 37 | 38 | Raises: 39 | DendriteException: If no suitable element is found or if the click operation fails. 40 | """ 41 | augmented_prompt = prompt + "\n\nThe element should be clickable." 42 | element = self.get_element( 43 | augmented_prompt, use_cache=use_cache, timeout=timeout 44 | ) 45 | if not element: 46 | raise DendriteException( 47 | message=f"No element found with the prompt: {prompt}", 48 | screenshot_base64="", 49 | ) 50 | return element.click( 51 | *args, 52 | expected_outcome=expected_outcome, 53 | timeout=timeout, 54 | force=force, 55 | **kwargs, 56 | ) 57 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/mixin/fill_fields.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Any, Dict, Optional 3 | from dendrite.browser._common._exceptions.dendrite_exception import DendriteException 4 | from dendrite.models.response.interaction_response import InteractionResponse 5 | from ..mixin.get_element import GetElementMixin 6 | from ..protocol.page_protocol import DendritePageProtocol 7 | 8 | 9 | class FillFieldsMixin(GetElementMixin, DendritePageProtocol): 10 | 11 | def fill_fields(self, fields: Dict[str, Any]): 12 | """ 13 | Fills multiple fields on the page with the provided values. 14 | 15 | This method iterates through the given dictionary of fields and their corresponding values, 16 | making a separate fill request for each key-value pair. 17 | 18 | Args: 19 | fields (Dict[str, Any]): A dictionary where each key is a field identifier (e.g., a prompt or selector) 20 | and each value is the content to fill in that field. 21 | 22 | Returns: 23 | None 24 | 25 | Note: 26 | This method will make multiple fill requests, one for each key in the 'fields' dictionary. 27 | """ 28 | for field, value in fields.items(): 29 | prompt = f"I'll be filling in text in several fields with these keys: {fields.keys()} in this page. Get the field best described as '{field}'. I want to fill it with a '{type(value)}' type value." 30 | self.fill(prompt, value) 31 | time.sleep(0.5) 32 | 33 | def fill( 34 | self, 35 | prompt: str, 36 | value: str, 37 | expected_outcome: Optional[str] = None, 38 | use_cache: bool = True, 39 | timeout: int = 15000, 40 | *args, 41 | kwargs={}, 42 | ) -> InteractionResponse: 43 | """ 44 | Fills an element on the page with the provided value based on the given prompt. 45 | 46 | This method combines the functionality of get_element and fill, 47 | allowing for a more concise way to interact with elements on the page. 48 | 49 | Args: 50 | prompt (str): The prompt describing the element to be filled. 51 | value (str): The value to fill the element with. 52 | expected_outcome (Optional[str]): The expected outcome of the fill action. 53 | use_cache (bool, optional): Whether to use cached results for element retrieval. Defaults to True. 54 | max_retries (int, optional): The maximum number of retry attempts for element retrieval. Defaults to 3. 55 | timeout (int, optional): The timeout (in milliseconds) for the fill operation. Defaults to 15000. 56 | *args: Additional positional arguments for the fill operation. 57 | kwargs: Additional keyword arguments for the fill operation. 58 | 59 | Returns: 60 | InteractionResponse: The response from the interaction. 61 | 62 | Raises: 63 | DendriteException: If no suitable element is found or if the fill operation fails. 64 | """ 65 | augmented_prompt = prompt + "\n\nMake sure the element can be filled with text." 66 | element = self.get_element( 67 | augmented_prompt, use_cache=use_cache, timeout=timeout 68 | ) 69 | if not element: 70 | raise DendriteException( 71 | message=f"No element found with the prompt: {prompt}", 72 | screenshot_base64="", 73 | ) 74 | return element.fill( 75 | value, *args, expected_outcome=expected_outcome, timeout=timeout, **kwargs 76 | ) 77 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/mixin/keyboard.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Union 2 | from dendrite.browser._common._exceptions.dendrite_exception import DendriteException 3 | from ..protocol.page_protocol import DendritePageProtocol 4 | 5 | 6 | class KeyboardMixin(DendritePageProtocol): 7 | 8 | def press( 9 | self, 10 | key: Union[ 11 | str, 12 | Literal[ 13 | "Enter", 14 | "Tab", 15 | "Escape", 16 | "Backspace", 17 | "ArrowUp", 18 | "ArrowDown", 19 | "ArrowLeft", 20 | "ArrowRight", 21 | ], 22 | ], 23 | hold_shift: bool = False, 24 | hold_ctrl: bool = False, 25 | hold_alt: bool = False, 26 | hold_cmd: bool = False, 27 | ): 28 | """ 29 | Presses a keyboard key on the active page, optionally with modifier keys. 30 | 31 | Args: 32 | key (Union[str, Literal["Enter", "Tab", "Escape", "Backspace", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]]): The main key to be pressed. 33 | hold_shift (bool, optional): Whether to hold the Shift key. Defaults to False. 34 | hold_ctrl (bool, optional): Whether to hold the Control key. Defaults to False. 35 | hold_alt (bool, optional): Whether to hold the Alt key. Defaults to False. 36 | hold_cmd (bool, optional): Whether to hold the Command key (Meta on some systems). Defaults to False. 37 | 38 | Returns: 39 | Any: The result of the key press operation. 40 | 41 | Raises: 42 | DendriteException: If the key press operation fails. 43 | """ 44 | modifiers = [] 45 | if hold_shift: 46 | modifiers.append("Shift") 47 | if hold_ctrl: 48 | modifiers.append("Control") 49 | if hold_alt: 50 | modifiers.append("Alt") 51 | if hold_cmd: 52 | modifiers.append("Meta") 53 | if modifiers: 54 | key = "+".join(modifiers + [key]) 55 | try: 56 | page = self._get_page() 57 | page.keyboard.press(key) 58 | except Exception as e: 59 | raise DendriteException( 60 | message=f"Failed to press key: {key}. Error: {str(e)}", 61 | screenshot_base64="", 62 | ) 63 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/mixin/markdown.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Optional 3 | from bs4 import BeautifulSoup 4 | from markdownify import markdownify as md 5 | from ..mixin.extract import ExtractionMixin 6 | from ..protocol.page_protocol import DendritePageProtocol 7 | 8 | 9 | class MarkdownMixin(ExtractionMixin, DendritePageProtocol): 10 | 11 | def markdown(self, prompt: Optional[str] = None): 12 | page = self._get_page() 13 | page_information = page.get_page_information() 14 | if prompt: 15 | extract_prompt = f"Create a script that returns the HTML from one element from the DOM that best matches this requested section of the website.\n\nDescription of section: '{prompt}'\n\nWe will be converting your returned HTML to markdown, so just return ONE stringified HTML element and nothing else. It's OK if extra information is present. Example script: 'response_data = soup.find('tag', {{'attribute': 'value'}}).prettify()'" 16 | res = self.extract(extract_prompt) 17 | markdown_text = md(res) 18 | cleaned_markdown = re.sub("\\n{3,}", "\n\n", markdown_text) 19 | return cleaned_markdown 20 | else: 21 | markdown_text = md(page_information.raw_html) 22 | cleaned_markdown = re.sub("\\n{3,}", "\n\n", markdown_text) 23 | return cleaned_markdown 24 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/mixin/screenshot.py: -------------------------------------------------------------------------------- 1 | from ..protocol.page_protocol import DendritePageProtocol 2 | 3 | 4 | class ScreenshotMixin(DendritePageProtocol): 5 | 6 | def screenshot(self, full_page: bool = False) -> str: 7 | """ 8 | Take a screenshot of the current page. 9 | 10 | Args: 11 | full_page (bool, optional): If True, captures the full page. If False, captures only the viewport. Defaults to False. 12 | 13 | Returns: 14 | str: A base64 encoded string of the screenshot in JPEG format. 15 | """ 16 | page = self._get_page() 17 | if full_page: 18 | return page.screenshot_manager.take_full_page_screenshot() 19 | else: 20 | return page.screenshot_manager.take_viewport_screenshot() 21 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/mixin/wait_for.py: -------------------------------------------------------------------------------- 1 | import time 2 | import time 3 | from loguru import logger 4 | from dendrite.browser._common._exceptions.dendrite_exception import ( 5 | DendriteException, 6 | PageConditionNotMet, 7 | ) 8 | from ..mixin.ask import AskMixin 9 | from ..protocol.page_protocol import DendritePageProtocol 10 | 11 | 12 | class WaitForMixin(AskMixin, DendritePageProtocol): 13 | 14 | def wait_for(self, prompt: str, timeout: float = 30000): 15 | """ 16 | Waits for the condition specified in the prompt to become true by periodically checking the page content. 17 | 18 | This method attempts to retrieve the page information and evaluate whether the specified 19 | condition (provided in the prompt) is met. It continues to retry until the total elapsed time 20 | exceeds the specified timeout. 21 | 22 | Args: 23 | prompt (str): The prompt to determine the condition to wait for on the page. 24 | timeout (float, optional): The maximum time (in milliseconds) to wait for the condition. Defaults to 15000. 25 | 26 | Returns: 27 | Any: The result of the condition evaluation if successful. 28 | 29 | Raises: 30 | PageConditionNotMet: If the condition is not met within the specified timeout. 31 | """ 32 | start_time = time.time() 33 | time.sleep(0.2) 34 | while True: 35 | elapsed_time = (time.time() - start_time) * 1000 36 | if elapsed_time >= timeout: 37 | break 38 | page = self._get_page() 39 | page_information = page.get_page_information() 40 | prompt_with_instruction = f"Prompt: '{prompt}'\n\nReturn a boolean that determines if the requested information or thing is available on the page. {round(page_information.time_since_frame_navigated, 2)} seconds have passed since the page first loaded." 41 | try: 42 | res = self.ask(prompt_with_instruction, bool) 43 | if res: 44 | return res 45 | except DendriteException as e: 46 | logger.debug(f"Attempt failed: {e.message}") 47 | time.sleep(0.5) 48 | page = self._get_page() 49 | page_information = page.get_page_information() 50 | raise PageConditionNotMet( 51 | message=f"Failed to wait for the requested condition within the {timeout}ms timeout.", 52 | screenshot_base64=page_information.screenshot_base64, 53 | ) 54 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/protocol/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrite-systems/dendrite-python-sdk/16e591332855547ca31fb8a72793d05a96b2f4d3/dendrite/browser/sync_api/protocol/__init__.py -------------------------------------------------------------------------------- /dendrite/browser/sync_api/protocol/browser_protocol.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Optional, Protocol, Union 2 | from typing_extensions import Literal 3 | from dendrite.browser.remote import Providers 4 | 5 | if TYPE_CHECKING: 6 | from ..dendrite_browser import Dendrite 7 | from playwright.sync_api import Browser, Download, Playwright 8 | from ..types import PlaywrightPage 9 | 10 | 11 | class BrowserProtocol(Protocol): 12 | 13 | def __init__(self, settings: Providers) -> None: ... 14 | 15 | def get_download( 16 | self, dendrite_browser: "Dendrite", pw_page: PlaywrightPage, timeout: float 17 | ) -> Download: 18 | """ 19 | Retrieves the download event from the browser. 20 | 21 | Returns: 22 | Download: The download event. 23 | 24 | Raises: 25 | Exception: If there is an issue retrieving the download event. 26 | """ 27 | ... 28 | 29 | def start_browser(self, playwright: Playwright, pw_options: dict) -> Browser: 30 | """ 31 | Starts the browser session. 32 | 33 | Args: 34 | playwright: The playwright instance 35 | pw_options: Playwright launch options 36 | 37 | Returns: 38 | Browser: A Browser instance 39 | """ 40 | ... 41 | 42 | def configure_context(self, browser: "Dendrite") -> None: 43 | """ 44 | Configures the browser context. 45 | 46 | Args: 47 | browser (Dendrite): The browser to configure. 48 | 49 | Raises: 50 | Exception: If there is an issue configuring the browser context. 51 | """ 52 | ... 53 | 54 | def stop_session(self) -> None: 55 | """ 56 | Stops the browser session. 57 | 58 | Raises: 59 | Exception: If there is an issue stopping the browser session. 60 | """ 61 | ... 62 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/protocol/download_protocol.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from pathlib import Path 3 | from typing import Any, Union 4 | from playwright.sync_api import Download 5 | 6 | 7 | class DownloadInterface(ABC, Download): 8 | 9 | def __init__(self, download: Download): 10 | self._download = download 11 | 12 | def __getattribute__(self, name: str) -> Any: 13 | try: 14 | return super().__getattribute__(name) 15 | except AttributeError: 16 | return getattr(self._download, name) 17 | 18 | @abstractmethod 19 | def save_as(self, path: Union[str, Path]) -> None: 20 | pass 21 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/protocol/page_protocol.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Protocol 2 | from dendrite.logic import LogicEngine 3 | 4 | if TYPE_CHECKING: 5 | from ..dendrite_browser import Dendrite 6 | from ..dendrite_page import Page 7 | 8 | 9 | class DendritePageProtocol(Protocol): 10 | """ 11 | Protocol that specifies the required methods and attributes 12 | for the `ExtractionMixin` to work. 13 | """ 14 | 15 | @property 16 | def logic_engine(self) -> LogicEngine: ... 17 | 18 | @property 19 | def dendrite_browser(self) -> "Dendrite": ... 20 | 21 | def _get_page(self) -> "Page": ... 22 | -------------------------------------------------------------------------------- /dendrite/browser/sync_api/types.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Any, Dict, Literal, Type, TypeVar, Union 3 | from playwright.sync_api import Page 4 | from pydantic import BaseModel 5 | 6 | Interaction = Literal["click", "fill", "hover"] 7 | T = TypeVar("T") 8 | PydanticModel = TypeVar("PydanticModel", bound=BaseModel) 9 | PrimitiveTypes = PrimitiveTypes = Union[Type[bool], Type[int], Type[float], Type[str]] 10 | JsonSchema = Dict[str, Any] 11 | TypeSpec = Union[PrimitiveTypes, PydanticModel, JsonSchema] 12 | PlaywrightPage = Page 13 | -------------------------------------------------------------------------------- /dendrite/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | from ..browser._common._exceptions.dendrite_exception import ( 2 | BaseDendriteException, 3 | BrowserNotLaunchedError, 4 | DendriteException, 5 | IncorrectOutcomeError, 6 | InvalidAuthSessionError, 7 | MissingApiKeyError, 8 | PageConditionNotMet, 9 | ) 10 | 11 | __all__ = [ 12 | "BaseDendriteException", 13 | "DendriteException", 14 | "IncorrectOutcomeError", 15 | "InvalidAuthSessionError", 16 | "MissingApiKeyError", 17 | "PageConditionNotMet", 18 | "BrowserNotLaunchedError", 19 | ] 20 | -------------------------------------------------------------------------------- /dendrite/logic/__init__.py: -------------------------------------------------------------------------------- 1 | from .async_logic_engine import AsyncLogicEngine 2 | from .sync_logic_engine import LogicEngine 3 | 4 | __all__ = ["LogicEngine", "AsyncLogicEngine"] 5 | -------------------------------------------------------------------------------- /dendrite/logic/ask/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrite-systems/dendrite-python-sdk/16e591332855547ca31fb8a72793d05a96b2f4d3/dendrite/logic/ask/__init__.py -------------------------------------------------------------------------------- /dendrite/logic/ask/image.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import io 3 | from typing import List 4 | 5 | from loguru import logger 6 | from PIL import Image 7 | 8 | 9 | def segment_image( 10 | base64_image: str, 11 | segment_height: int = 7900, 12 | ) -> List[str]: 13 | if len(base64_image) < 100: 14 | raise Exception("Failed to segment image since it is too small / glitched.") 15 | 16 | image_data = base64.b64decode(base64_image) 17 | image = Image.open(io.BytesIO(image_data)) 18 | width, height = image.size 19 | segments = [] 20 | 21 | for i in range(0, height, segment_height): 22 | # Define the box for cropping (left, upper, right, lower) 23 | box = (0, i, width, min(i + segment_height, height)) 24 | segment = image.crop(box) 25 | 26 | # Convert RGBA to RGB if necessary 27 | if segment.mode == "RGBA": 28 | segment = segment.convert("RGB") 29 | 30 | buffer = io.BytesIO() 31 | segment.save(buffer, format="JPEG") 32 | segment_data = buffer.getvalue() 33 | segments.append(base64.b64encode(segment_data).decode()) 34 | 35 | return segments 36 | -------------------------------------------------------------------------------- /dendrite/logic/async_logic_engine.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Protocol 2 | 3 | from dendrite.logic.ask import ask 4 | from dendrite.logic.config import Config 5 | from dendrite.logic.extract import extract 6 | from dendrite.logic.get_element import get_element 7 | from dendrite.logic.verify_interaction import verify_interaction 8 | from dendrite.models.dto.ask_page_dto import AskPageDTO 9 | from dendrite.models.dto.cached_extract_dto import CachedExtractDTO 10 | from dendrite.models.dto.cached_selector_dto import CachedSelectorDTO 11 | from dendrite.models.dto.extract_dto import ExtractDTO 12 | from dendrite.models.dto.get_elements_dto import GetElementsDTO 13 | from dendrite.models.dto.make_interaction_dto import VerifyActionDTO 14 | from dendrite.models.response.ask_page_response import AskPageResponse 15 | from dendrite.models.response.extract_response import ExtractResponse 16 | from dendrite.models.response.get_element_response import GetElementResponse 17 | from dendrite.models.response.interaction_response import InteractionResponse 18 | from dendrite.models.scripts import Script 19 | from dendrite.models.selector import Selector 20 | 21 | 22 | class AsyncLogicEngine: 23 | def __init__(self, config: Config): 24 | self._config = config 25 | 26 | async def get_element(self, dto: GetElementsDTO) -> GetElementResponse: 27 | return await get_element.get_element(dto, self._config) 28 | 29 | async def get_cached_selectors(self, dto: CachedSelectorDTO) -> List[Selector]: 30 | return await get_element.get_cached_selector(dto, self._config) 31 | 32 | async def get_cached_scripts(self, dto: CachedExtractDTO) -> List[Script]: 33 | return await extract.get_cached_scripts(dto, self._config) 34 | 35 | async def extract(self, dto: ExtractDTO) -> ExtractResponse: 36 | return await extract.extract(dto, self._config) 37 | 38 | async def verify_action(self, dto: VerifyActionDTO) -> InteractionResponse: 39 | return await verify_interaction.verify_action(dto, self._config) 40 | 41 | async def ask_page(self, dto: AskPageDTO) -> AskPageResponse: 42 | return await ask.ask_page_action(dto, self._config) 43 | -------------------------------------------------------------------------------- /dendrite/logic/cache/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrite-systems/dendrite-python-sdk/16e591332855547ca31fb8a72793d05a96b2f4d3/dendrite/logic/cache/__init__.py -------------------------------------------------------------------------------- /dendrite/logic/code/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrite-systems/dendrite-python-sdk/16e591332855547ca31fb8a72793d05a96b2f4d3/dendrite/logic/code/__init__.py -------------------------------------------------------------------------------- /dendrite/logic/config.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional, Union 3 | 4 | from playwright.async_api import StorageState 5 | 6 | from dendrite.logic.cache.file_cache import FileCache 7 | from dendrite.logic.llm.config import LLMConfig 8 | from dendrite.models.scripts import Script 9 | from dendrite.models.selector import Selector 10 | 11 | 12 | class Config: 13 | """ 14 | Configuration class for Dendrite that manages file paths and LLM settings. 15 | 16 | This class handles the configuration of cache locations, authentication sessions, 17 | and LLM (Language Learning Model) settings for the Dendrite system. 18 | 19 | Attributes: 20 | cache_path (Path): Path to the cache directory 21 | llm_config (LLMConfig): Configuration for language learning models 22 | extract_cache (FileCache): Cache for extracted script data 23 | element_cache (FileCache): Cache for element selectors 24 | storage_cache (FileCache): Cache for browser storage states 25 | auth_session_path (Path): Path to authentication session data 26 | """ 27 | 28 | def __init__( 29 | self, 30 | root_path: Union[str, Path] = ".dendrite", 31 | cache_path: Union[str, Path] = "cache", 32 | auth_session_path: Union[str, Path] = "auth", 33 | llm_config: Optional[LLMConfig] = None, 34 | ): 35 | """ 36 | Initialize the Config with specified paths and LLM configuration. 37 | 38 | Args: 39 | root_path (Union[str, Path]): Base directory for all Dendrite data. 40 | Defaults to ".dendrite". 41 | cache_path (Union[str, Path]): Directory name for cache storage relative 42 | to root_path. Defaults to "cache". 43 | auth_session_path (Union[str, Path]): Directory name for authentication 44 | sessions relative to root_path. Defaults to "auth". 45 | llm_config (Optional[LLMConfig]): Configuration for language models. 46 | If None, creates a default LLMConfig instance. 47 | """ 48 | self.cache_path = root_path / Path(cache_path) 49 | self.llm_config = llm_config or LLMConfig() 50 | self.extract_cache = FileCache(Script, self.cache_path / "extract.json") 51 | self.element_cache = FileCache(Selector, self.cache_path / "get_element.json") 52 | self.storage_cache = FileCache( 53 | StorageState, self.cache_path / "storage_state.json" 54 | ) 55 | self.auth_session_path = root_path / Path(auth_session_path) 56 | -------------------------------------------------------------------------------- /dendrite/logic/dom/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrite-systems/dendrite-python-sdk/16e591332855547ca31fb8a72793d05a96b2f4d3/dendrite/logic/dom/__init__.py -------------------------------------------------------------------------------- /dendrite/logic/dom/css.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from bs4 import BeautifulSoup, Tag 4 | from loguru import logger 5 | 6 | 7 | def find_css_selector(ele: Tag, soup: BeautifulSoup) -> str: 8 | logger.debug(f"Finding selector for element: {ele.name} with attrs: {ele.attrs}") 9 | 10 | # Add this debug block 11 | final_selector = "" # Track the selector being built 12 | matches = [] # Track matching elements 13 | 14 | def debug_selector(selector: str) -> None: 15 | nonlocal matches 16 | try: 17 | matches = soup.select(selector) 18 | logger.debug(f"Selector '{selector}' matched {len(matches)} elements") 19 | except Exception as e: 20 | logger.error(f"Invalid selector '{selector}': {e}") 21 | 22 | # Check for inherently unique elements 23 | if ele.name in ["html", "head", "body"]: 24 | return ele.name 25 | 26 | # List of attributes to check for unique selectors 27 | priority_attrs = [ 28 | "id", 29 | "name", 30 | "data-testid", 31 | "data-cy", 32 | "data-qa", 33 | "aria-label", 34 | "aria-labelledby", 35 | "for", 36 | "href", 37 | "alt", 38 | "title", 39 | "role", 40 | "placeholder", 41 | ] 42 | 43 | # Try attrs 44 | for attr in priority_attrs: 45 | if attr_selector := check_unique_attribute(ele, soup, attr, ele.name): 46 | return attr_selector 47 | 48 | # Try class combinations 49 | if class_selector := find_unique_class_combination(ele, soup): 50 | return class_selector 51 | 52 | # If still not unique, use parent selector with nth-child 53 | parent_selector = find_selector_with_parent(ele, soup) 54 | 55 | return parent_selector 56 | 57 | 58 | def check_unique_attribute( 59 | ele: Tag, soup: BeautifulSoup, attr: str, tag_name: str 60 | ) -> str: 61 | attr_value = ele.get(attr) 62 | if attr_value: 63 | attr_value = css_escape(attr_value) 64 | attr = css_escape(attr) 65 | selector = f'{css_escape(tag_name)}[{attr}="{attr_value}"]' 66 | if check_if_selector_successful(selector, soup, True): 67 | return selector 68 | return "" 69 | 70 | 71 | def find_unique_class_combination(ele: Tag, soup: BeautifulSoup) -> str: 72 | classes = ele.get("class", []) 73 | 74 | if isinstance(classes, str): 75 | classes = [classes] 76 | 77 | if not classes: 78 | return "" 79 | 80 | tag_name = css_escape(ele.name) 81 | 82 | # Try single classes first 83 | for cls in classes: 84 | selector = f"{tag_name}.{css_escape(cls)}" 85 | if check_if_selector_successful(selector, soup, True): 86 | return selector 87 | 88 | # If single classes don't work, try the full combination 89 | full_selector = f"{tag_name}{'.'.join([''] + [css_escape(c) for c in classes])}" 90 | if check_if_selector_successful(full_selector, soup, True): 91 | return full_selector 92 | 93 | return "" 94 | 95 | 96 | def find_selector_with_parent(ele: Tag, soup: BeautifulSoup) -> str: 97 | parent = ele.find_parent() 98 | if parent is None or parent == soup: 99 | return f"{css_escape(ele.name)}" 100 | 101 | parent_selector = find_css_selector(parent, soup) 102 | siblings_of_same_type = parent.find_all(ele.name, recursive=False) 103 | 104 | if len(siblings_of_same_type) == 1: 105 | return f"{parent_selector} > {css_escape(ele.name)}" 106 | else: 107 | index = position_in_node_list(ele, parent) 108 | return f"{parent_selector} > {css_escape(ele.name)}:nth-child({index})" 109 | 110 | 111 | def position_in_node_list(element: Tag, parent: Tag): 112 | for index, child in enumerate(parent.find_all(recursive=False)): 113 | if child == element: 114 | return index + 1 115 | return -1 116 | 117 | 118 | # https://github.com/mathiasbynens/CSS.escape 119 | def css_escape(value): 120 | if len(str(value)) == 0: 121 | raise TypeError("`CSS.escape` requires an argument.") 122 | 123 | string = str(value) 124 | length = len(string) 125 | result = "" 126 | first_code_unit = ord(string[0]) if length > 0 else None 127 | 128 | if length == 1 and first_code_unit == 0x002D: 129 | return "\\" + string 130 | 131 | for index in range(length): 132 | code_unit = ord(string[index]) 133 | 134 | if code_unit == 0x0000: 135 | result += "\uFFFD" 136 | continue 137 | 138 | if ( 139 | (0x0001 <= code_unit <= 0x001F) 140 | or code_unit == 0x007F 141 | or (index == 0 and 0x0030 <= code_unit <= 0x0039) 142 | or ( 143 | index == 1 144 | and 0x0030 <= code_unit <= 0x0039 145 | and first_code_unit == 0x002D 146 | ) 147 | ): 148 | result += "\\" + format(code_unit, "x") + " " 149 | continue 150 | 151 | if ( 152 | code_unit >= 0x0080 153 | or code_unit == 0x002D 154 | or code_unit == 0x005F 155 | or 0x0030 <= code_unit <= 0x0039 156 | or 0x0041 <= code_unit <= 0x005A 157 | or 0x0061 <= code_unit <= 0x007A 158 | ): 159 | result += string[index] 160 | continue 161 | 162 | result += "\\" + string[index] 163 | 164 | return result 165 | 166 | 167 | def check_if_selector_successful( 168 | selector: str, 169 | bs4: BeautifulSoup, 170 | only_one: bool, 171 | ) -> Optional[str]: 172 | 173 | els = None 174 | try: 175 | els = bs4.select(selector) 176 | except Exception as e: 177 | logger.warning(f"Error selecting {selector}: {e}") 178 | 179 | if els: 180 | if only_one and len(els) == 1: 181 | return selector 182 | elif not only_one and len(els) >= 1: 183 | return selector 184 | 185 | return None 186 | -------------------------------------------------------------------------------- /dendrite/logic/dom/strip.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from typing import List, Union, overload 3 | 4 | from bs4 import BeautifulSoup, Comment, Doctype, Tag 5 | 6 | 7 | def mild_strip(soup: Tag, keep_d_id: bool = True) -> BeautifulSoup: 8 | new_soup = BeautifulSoup(str(soup), "html.parser") 9 | _mild_strip(new_soup, keep_d_id) 10 | return new_soup 11 | 12 | 13 | def mild_strip_in_place(soup: BeautifulSoup, keep_d_id: bool = True) -> None: 14 | _mild_strip(soup, keep_d_id) 15 | 16 | 17 | def _mild_strip(soup: BeautifulSoup, keep_d_id: bool = True) -> None: 18 | for element in soup(text=lambda text: isinstance(text, Comment)): 19 | element.extract() 20 | 21 | # for text in soup.find_all(text=lambda text: isinstance(text, NavigableString)): 22 | # if len(text) > 200: 23 | # text.replace_with(text[:200] + f"... [{len(text)-200} more chars]") 24 | 25 | for tag in soup( 26 | ["head", "script", "style", "path", "polygon", "defs", "svg", "br", "Doctype"] 27 | ): 28 | tag.extract() 29 | 30 | for element in soup.contents: 31 | if isinstance(element, Doctype): 32 | element.extract() 33 | 34 | # for tag in soup.find_all(True): 35 | # tag.attrs = { 36 | # attr: (value[:100] if isinstance(value, str) else value) 37 | # for attr, value in tag.attrs.items() 38 | # } 39 | # if keep_d_id == False: 40 | # del tag["d-id"] 41 | for tag in soup.find_all(True): 42 | if tag.attrs.get("is-interactable-d_id") == "true": 43 | continue 44 | 45 | tag.attrs = { 46 | attr: (value[:100] if isinstance(value, str) else value) 47 | for attr, value in tag.attrs.items() 48 | } 49 | if keep_d_id == False: 50 | del tag["d-id"] 51 | 52 | # if browser != None: 53 | # for elem in list(soup.descendants): 54 | # if isinstance(elem, Tag) and not browser.element_is_visible(elem): 55 | # elem.extract() 56 | 57 | 58 | @overload 59 | def shorten_attr_val(value: str, limit: int = 50) -> str: ... 60 | 61 | 62 | @overload 63 | def shorten_attr_val(value: List[str], limit: int = 50) -> List[str]: ... 64 | 65 | 66 | def shorten_attr_val( 67 | value: Union[str, List[str]], limit: int = 50 68 | ) -> Union[str, List[str]]: 69 | if isinstance(value, str): 70 | return value[:limit] 71 | 72 | char_count = sum(map(len, value)) 73 | if char_count <= limit: 74 | return value 75 | 76 | while len(value) > 1 and char_count > limit: 77 | char_count -= len(value.pop()) 78 | 79 | if len(value) == 1: 80 | return value[0][:limit] 81 | 82 | return value 83 | 84 | 85 | def clear_attrs(element: Tag): 86 | 87 | salient_attributes = [ 88 | "d-id", 89 | "class", 90 | "id", 91 | "type", 92 | "alt", 93 | "aria-describedby", 94 | "aria-label", 95 | "contenteditable", 96 | "aria-role", 97 | "input-checked", 98 | "label", 99 | "name", 100 | "option_selected", 101 | "placeholder", 102 | "readonly", 103 | "text-value", 104 | "title", 105 | "value", 106 | "href", 107 | "role", 108 | "action", 109 | "method", 110 | ] 111 | attrs = { 112 | attr: shorten_attr_val(value, limit=200) 113 | for attr, value in element.attrs.items() 114 | if attr in salient_attributes 115 | } 116 | element.attrs = attrs 117 | 118 | 119 | def strip_soup(soup: BeautifulSoup) -> BeautifulSoup: 120 | # Create a copy of the soup to avoid modifying the original 121 | stripped_soup = BeautifulSoup(str(soup), "html.parser") 122 | 123 | for tag in stripped_soup( 124 | [ 125 | "head", 126 | "script", 127 | "style", 128 | "path", 129 | "polygon", 130 | "defs", 131 | "br", 132 | "Doctype", 133 | ] # add noscript? 134 | ): 135 | tag.extract() 136 | 137 | # Remove comments 138 | comments = stripped_soup.find_all(text=lambda text: isinstance(text, Comment)) 139 | for comment in comments: 140 | comment.extract() 141 | 142 | # Clear non-salient attributes 143 | for element in stripped_soup.find_all(True): 144 | if isinstance(element, Doctype): 145 | element.extract() 146 | else: 147 | clear_attrs(element) 148 | 149 | return stripped_soup 150 | 151 | 152 | def remove_hidden_elements(soup: BeautifulSoup): 153 | # data-hidden is added by DendriteBrowser when an element is not visible 154 | new_soup = copy.copy(soup) 155 | elems = new_soup.find_all(attrs={"data-hidden": True}) 156 | for elem in elems: 157 | elem.extract() 158 | return new_soup 159 | -------------------------------------------------------------------------------- /dendrite/logic/dom/truncate.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def truncate_long_string( 5 | val: str, 6 | max_len_start: int = 150, 7 | max_len_end: int = 150, 8 | trucate_desc: str = "chars truncated for readability", 9 | ): 10 | return ( 11 | val 12 | if len(val) < max_len_start + max_len_end 13 | else val[:max_len_start] 14 | + f"... [{len(val)-max_len_start-max_len_end} {trucate_desc}] ..." 15 | + val[-max_len_end:] 16 | ) 17 | 18 | 19 | def truncate_long_string_w_words( 20 | val: str, 21 | max_len_start: int = 150, 22 | max_len_end: int = 150, 23 | trucate_desc: str = "words truncated for readability", 24 | show_more_words_for_longer_val: bool = True, 25 | ): 26 | if len(val) < max_len_start + max_len_end: 27 | return val 28 | else: 29 | if show_more_words_for_longer_val: 30 | max_len_end += int(len(val) / 100) 31 | max_len_end += int(len(val) / 100) 32 | 33 | truncate_start_pos = max_len_start 34 | steps_taken_start = 0 35 | while ( 36 | truncate_start_pos > 0 37 | and val[truncate_start_pos] not in [" ", "\n"] 38 | and steps_taken_start < 20 39 | ): 40 | truncate_start_pos -= 1 41 | steps_taken_start += 1 42 | 43 | truncate_end_pos = len(val) - max_len_end 44 | steps_taken_end = 0 45 | while ( 46 | truncate_end_pos < len(val) 47 | and val[truncate_end_pos] not in [" ", "\n"] 48 | and steps_taken_end < 20 49 | ): 50 | truncate_end_pos += 1 51 | steps_taken_end += 1 52 | 53 | if steps_taken_start >= 20 or steps_taken_end >= 20: 54 | # Return simple truncation if we've looped further than 20 chars 55 | return truncate_long_string(val, max_len_start, max_len_end, trucate_desc) 56 | else: 57 | return ( 58 | val[:truncate_start_pos] 59 | + f" [...{len(val[truncate_start_pos:truncate_end_pos].split())} {trucate_desc}...] " 60 | + val[truncate_end_pos:] 61 | ) 62 | 63 | 64 | def remove_excessive_whitespace(text: str, max_whitespaces=1): 65 | return re.sub(r"\s{2,}", " " * max_whitespaces, text) 66 | 67 | 68 | def truncate_and_remove_whitespace(text, max_len_start=100, max_len_end=100): 69 | return truncate_long_string_w_words( 70 | remove_excessive_whitespace(text), 71 | max_len_start=max_len_start, 72 | max_len_end=max_len_end, 73 | ) 74 | -------------------------------------------------------------------------------- /dendrite/logic/extract/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrite-systems/dendrite-python-sdk/16e591332855547ca31fb8a72793d05a96b2f4d3/dendrite/logic/extract/__init__.py -------------------------------------------------------------------------------- /dendrite/logic/extract/cache.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any, List, Optional, Tuple 3 | from urllib.parse import urlparse 4 | 5 | from loguru import logger 6 | 7 | from dendrite.logic.cache.file_cache import FileCache 8 | from dendrite.logic.code.code_session import execute 9 | from dendrite.logic.config import Config 10 | from dendrite.models.dto.cached_extract_dto import CachedExtractDTO 11 | from dendrite.models.scripts import Script 12 | 13 | 14 | def save_script(code: str, prompt: str, url: str, cache: FileCache[Script]): 15 | domain = urlparse(url).netloc 16 | script = Script( 17 | url=url, domain=domain, script=code, created_at=datetime.now().isoformat() 18 | ) 19 | cache.append({"prompt": prompt, "domain": domain}, script) 20 | 21 | 22 | def get_scripts( 23 | prompt: str, url: str, cache: FileCache[Script] 24 | ) -> Optional[List[Script]]: 25 | domain = urlparse(url).netloc 26 | return cache.get({"prompt": prompt, "domain": domain}) 27 | 28 | 29 | async def get_working_cached_script( 30 | prompt: str, raw_html: str, url: str, return_data_json_schema: Any, config: Config 31 | ) -> Optional[Tuple[Script, Any]]: 32 | 33 | if len(url) == 0: 34 | raise Exception("Domain must be specified") 35 | 36 | scripts = get_scripts(prompt, url, config.extract_cache) 37 | if scripts is None or len(scripts) == 0: 38 | return None 39 | logger.debug( 40 | f"Found {len(scripts)} scripts in cache | Prompt: {prompt} in domain: {url}" 41 | ) 42 | 43 | for script in scripts: 44 | try: 45 | res = execute(script.script, raw_html, return_data_json_schema) 46 | return script, res 47 | except Exception as e: 48 | logger.debug( 49 | f"Script failed with error: {str(e)} | Prompt: {prompt} in domain: {url}" 50 | ) 51 | continue 52 | 53 | raise Exception( 54 | f"No working script found in cache even though {len(scripts)} scripts were available | Prompt: '{prompt}' in domain: '{url}'" 55 | ) 56 | -------------------------------------------------------------------------------- /dendrite/logic/get_element/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrite-systems/dendrite-python-sdk/16e591332855547ca31fb8a72793d05a96b2f4d3/dendrite/logic/get_element/__init__.py -------------------------------------------------------------------------------- /dendrite/logic/get_element/agents/segment_agent.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from typing import Annotated, List, Literal, Union 4 | 5 | from annotated_types import Len 6 | from loguru import logger 7 | from pydantic import BaseModel, ValidationError 8 | 9 | from dendrite.logic.llm.agent import Agent 10 | from dendrite.logic.llm.config import LLMConfig 11 | 12 | from .prompts import SEGMENT_PROMPT 13 | 14 | 15 | class SegmentAgentSuccessResponse(BaseModel): 16 | reason: str 17 | status: Literal["success"] 18 | d_id: Annotated[List[str], Len(min_length=1)] 19 | index: int = 99999 # placeholder since the agent doesn't output this 20 | 21 | 22 | class SegmentAgentFailureResponse(BaseModel): 23 | reason: str 24 | status: Literal["failed", "loading", "impossible"] 25 | index: int = 99999 # placeholder since the agent doesn't output this 26 | 27 | 28 | SegmentAgentReponseType = Union[ 29 | SegmentAgentSuccessResponse, SegmentAgentFailureResponse 30 | ] 31 | 32 | 33 | def parse_segment_output(text: str, index: int) -> SegmentAgentReponseType: 34 | json_pattern = r"```json(.*?)```" 35 | res = None 36 | 37 | if text is None: 38 | return SegmentAgentFailureResponse( 39 | reason="No content", status="failed", index=index 40 | ) 41 | 42 | json_matches = re.findall(json_pattern, text, re.DOTALL) 43 | 44 | if not json_matches: 45 | return SegmentAgentFailureResponse( 46 | reason="No JSON matches", status="failed", index=index 47 | ) 48 | 49 | json_match = json_matches[0] 50 | try: 51 | json_data = json.loads(json_match) 52 | if "d_id" in json_data and "reason" in json_data: 53 | ids = json_data["d_id"] 54 | if len(ids) == 0: 55 | logger.warning( 56 | f"Success message was output, but no d_ids provided: {json_data}" 57 | ) 58 | return SegmentAgentFailureResponse( 59 | reason="No d_ids provided", status="failed", index=index 60 | ) 61 | 62 | res = SegmentAgentSuccessResponse( 63 | reason=json_data["reason"], 64 | status="success", 65 | d_id=json_data["d_id"], 66 | ) 67 | except json.JSONDecodeError as e: 68 | raise ValueError(f"Failed to decode JSON: {e}") 69 | 70 | if res is None: 71 | try: 72 | res = SegmentAgentFailureResponse.model_validate_json(json_matches[0]) 73 | except ValidationError as e: 74 | logger.bind(json=json_matches[0]).error( 75 | f"Failed to parse JSON: {e}", 76 | ) 77 | res = SegmentAgentFailureResponse( 78 | reason="Failed to parse JSON", status="failed", index=index 79 | ) 80 | 81 | res.index = index 82 | return res 83 | 84 | 85 | async def extract_relevant_d_ids( 86 | prompt: str, segments: List[str], index: int, llm_config: LLMConfig 87 | ) -> SegmentAgentReponseType: 88 | agent = Agent(llm_config.get("segment_agent"), system_prompt=SEGMENT_PROMPT) 89 | message = "" 90 | for segment in segments: 91 | message += ( 92 | f"""###### SEGMENT ######\n\n{segment}\n\n###### SEGMENT END ######\n\n""" 93 | ) 94 | 95 | message += f"Can you get the d_id of the elements that match the following description:\n\n{prompt} element\n\nIf you've selected an element you should NOT select another element that is a child of the element you've selected. It is important that you follow this." 96 | message += """\nOutput how you think. Think step by step. if there are multiple candidate elements return all of them. Don't make up d-id for elements if they are not present/don't match the description. Limit your reasoning to 2-3 sentences\nOnly include the json block – don't output an array, only ONE object.""" 97 | 98 | max_retries = 3 99 | for attempt in range(max_retries): 100 | res = await agent.add_message(message) 101 | if res is None: 102 | message = "I didn't receive a response. Please try again." 103 | continue 104 | 105 | try: 106 | parsed_res = parse_segment_output(res, index) 107 | # If we successfully parsed the result, return it 108 | return parsed_res 109 | except Exception as e: 110 | # If we encounter a ValueError, ask the agent to correct its output 111 | logger.warning(f"Error in segment agent: {e}") 112 | message = f"An exception occurred in your output: {e}\n\nPlease correct your output and try again. Ensure you're providing a valid JSON response." 113 | 114 | # If we've exhausted all retries, return a failure response 115 | return SegmentAgentFailureResponse( 116 | reason="Max retries reached without successful parsing", 117 | status="failed", 118 | index=index, 119 | ) 120 | -------------------------------------------------------------------------------- /dendrite/logic/get_element/agents/select_agent.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import List, Optional, Tuple 3 | 4 | from loguru import logger 5 | from openai.types.chat import ChatCompletion 6 | from pydantic import BaseModel 7 | 8 | from dendrite.browser._common.types import Status 9 | from dendrite.logic.llm.agent import Agent 10 | from dendrite.logic.llm.config import LLMConfig 11 | 12 | from ..hanifi_segment import SelectedTag 13 | from .prompts import SELECT_PROMPT 14 | 15 | 16 | class SelectAgentResponse(BaseModel): 17 | reason: str 18 | d_id: Optional[List[str]] = None 19 | status: Status 20 | 21 | 22 | async def select_best_tag( 23 | expanded_html_tree: str, 24 | tags: List[SelectedTag], 25 | prompt: str, 26 | time_since_frame_navigated: Optional[float], 27 | llm_config: LLMConfig, 28 | return_several: bool = False, 29 | ) -> Tuple[int, int, Optional[SelectAgentResponse]]: 30 | 31 | agent = Agent(llm_config.get("select_agent"), system_prompt=SELECT_PROMPT) 32 | 33 | message = f"\n{prompt}\n" 34 | 35 | tags_str = "\n".join([f"d-id: {tag.d_id} - reason: '{tag.reason}'" for tag in tags]) 36 | 37 | message += f"""\n\nA smaller and less intelligent AI agent has combed through the html document and found these elements that seems to match the element description:\n\n{tags_str}\n\nThis agent is very primitive however, so don't blindly trust it. Make sure you carefully look at this truncated version of the html document and do some proper reasoning in which you consider the different potential elements:\n\n```html\n{expanded_html_tree}\n```\n""" 38 | 39 | if return_several: 40 | message += f"""Please look at the HTML Tree and output a list of d-ids that matches the ELEMENT_DESCRIPTION.""" 41 | else: 42 | message += f"""Please look at the HTML Tree and output the best d-id that matches the ELEMENT_DESCRIPTION. Only return ONE d-id.""" 43 | 44 | if time_since_frame_navigated: 45 | message += f"""\n\nThis page was first loaded {round(time_since_frame_navigated, 2)} second(s) ago. If the page is blank or the data is not available on the current page it could be because the page is still loading.\n\nDon't return an element that isn't what the user asked for, in this case it is better to return `status: impossible` or `status: loading` if you think the page is still loading.""" 46 | 47 | res = await agent.add_message(message) 48 | 49 | logger.info(f"Select agent response: {res}") 50 | 51 | parsed = await parse_select_output(res) 52 | 53 | # token_usage = res.usage.input_tokens + res.usage.output_tokens 54 | return (0, 0, parsed) 55 | 56 | 57 | async def parse_select_output(text: str) -> Optional[SelectAgentResponse]: 58 | json_pattern = r"```json(.*?)```" 59 | 60 | json_matches = re.findall(json_pattern, text, re.DOTALL) 61 | 62 | if not json_matches: 63 | return None 64 | 65 | try: 66 | model = SelectAgentResponse.model_validate_json(json_matches[0]) 67 | except Exception as e: 68 | model = None 69 | 70 | return model 71 | 72 | 73 | async def parse_openai_select_response( 74 | result: ChatCompletion, 75 | ) -> Optional[SelectAgentResponse]: 76 | json_pattern = r"```json(.*?)```" 77 | 78 | # Ensure the result has a message and content field 79 | if len(result.choices) == 0 or result.choices[0].message.content is None: 80 | return None 81 | 82 | # Extract the text content 83 | text = result.choices[0].message.content 84 | 85 | # Find JSON formatted code block in the response text 86 | json_matches = re.findall(json_pattern, text, re.DOTALL) 87 | 88 | if not json_matches: 89 | return None 90 | 91 | try: 92 | # Attempt to validate and parse the JSON match 93 | model = SelectAgentResponse.model_validate_json(json_matches[0]) 94 | except Exception as e: 95 | # In case of any error during parsing 96 | model = None 97 | 98 | return model 99 | -------------------------------------------------------------------------------- /dendrite/logic/get_element/cache.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List, Optional 3 | from urllib.parse import urlparse 4 | 5 | from dendrite.logic.cache.file_cache import FileCache 6 | from dendrite.models.selector import Selector 7 | 8 | 9 | async def get_selector_from_cache( 10 | url: str, prompt: str, cache: FileCache[Selector] 11 | ) -> Optional[List[Selector]]: 12 | netloc = urlparse(url).netloc 13 | 14 | return cache.get({"netloc": netloc, "prompt": prompt}) 15 | 16 | 17 | async def add_selector_to_cache( 18 | prompt: str, bs4_selector: str, url: str, cache: FileCache[Selector] 19 | ) -> None: 20 | created_at = datetime.now().isoformat() 21 | netloc = urlparse(url).netloc 22 | selector: Selector = Selector( 23 | prompt=prompt, 24 | selector=bs4_selector, 25 | url=url, 26 | netloc=netloc, 27 | created_at=created_at, 28 | ) 29 | 30 | cache.append({"netloc": netloc, "prompt": prompt}, selector) 31 | -------------------------------------------------------------------------------- /dendrite/logic/get_element/get_element.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from bs4 import BeautifulSoup, Tag 4 | from loguru import logger 5 | 6 | from dendrite.logic.config import Config 7 | from dendrite.logic.dom.css import check_if_selector_successful, find_css_selector 8 | from dendrite.logic.dom.strip import remove_hidden_elements 9 | from dendrite.logic.get_element.cache import ( 10 | add_selector_to_cache, 11 | get_selector_from_cache, 12 | ) 13 | from dendrite.models.dto.cached_selector_dto import CachedSelectorDTO 14 | from dendrite.models.dto.get_elements_dto import GetElementsDTO 15 | from dendrite.models.response.get_element_response import GetElementResponse 16 | from dendrite.models.selector import Selector 17 | 18 | from .hanifi_search import hanifi_search 19 | 20 | 21 | async def get_element(dto: GetElementsDTO, config: Config) -> GetElementResponse: 22 | if isinstance(dto.prompt, str): 23 | return await process_prompt(dto.prompt, dto, config) 24 | raise NotImplementedError("Prompt is not a string") 25 | 26 | 27 | async def process_prompt( 28 | prompt: str, dto: GetElementsDTO, config: Config 29 | ) -> GetElementResponse: 30 | soup = BeautifulSoup(dto.page_information.raw_html, "lxml") 31 | return await get_new_element(soup, prompt, dto, config) 32 | 33 | 34 | async def get_new_element( 35 | soup: BeautifulSoup, prompt: str, dto: GetElementsDTO, config: Config 36 | ) -> GetElementResponse: 37 | soup_without_hidden_elements = remove_hidden_elements(soup) 38 | element = await hanifi_search( 39 | soup_without_hidden_elements, 40 | prompt, 41 | config, 42 | dto.page_information.time_since_frame_navigated, 43 | ) 44 | interactable = element[0] 45 | 46 | if interactable.status == "success": 47 | if interactable.dendrite_id is None: 48 | interactable.status = "failed" 49 | interactable.reason = "No d-id found returned from agent" 50 | print(interactable.dendrite_id) 51 | tag = soup_without_hidden_elements.find( 52 | attrs={"d-id": interactable.dendrite_id} 53 | ) 54 | if isinstance(tag, Tag): 55 | selector = find_css_selector(tag, soup) 56 | cache = config.element_cache 57 | await add_selector_to_cache( 58 | prompt, 59 | bs4_selector=selector, 60 | url=dto.page_information.url, 61 | cache=cache, 62 | ) 63 | return GetElementResponse( 64 | selectors=[selector], 65 | message=interactable.reason, 66 | d_id=interactable.dendrite_id, 67 | status="success", 68 | ) 69 | interactable.status = "failed" 70 | interactable.reason = "d-id does not exist in the soup" 71 | 72 | return GetElementResponse( 73 | message=interactable.reason, 74 | status=interactable.status, 75 | ) 76 | 77 | 78 | async def get_cached_selector(dto: CachedSelectorDTO, config: Config) -> List[Selector]: 79 | if not isinstance(dto.prompt, str): 80 | return [] 81 | db_selectors = await get_selector_from_cache( 82 | dto.url, dto.prompt, config.element_cache 83 | ) 84 | 85 | if db_selectors is None: 86 | return [] 87 | 88 | return db_selectors 89 | 90 | 91 | # async def check_cache( 92 | # soup: BeautifulSoup, url: str, prompt: str, only_one: bool, config: Config 93 | # ) -> Optional[GetElementResponse]: 94 | # cache = config.element_cache 95 | # db_selectors = await get_selector_from_cache(url, prompt, cache) 96 | 97 | # if db_selectors is None: 98 | # return None 99 | 100 | # if check_if_selector_successful(db_selectors.selector, soup, only_one): 101 | # return GetElementResponse( 102 | # selectors=[db_selectors.selector], 103 | # status="success", 104 | # ) 105 | 106 | 107 | # async def get_cached_selector(dto: GetCachedSelectorDTO) -> Optional[Selector]: 108 | # cache = config.element_cache 109 | # db_selectors = await get_selector_from_cache(dto.url, dto.prompt, cache) 110 | # return db_selectors 111 | -------------------------------------------------------------------------------- /dendrite/logic/get_element/hanifi_search.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Any, Coroutine, List, Optional, Tuple, Union 3 | 4 | from bs4 import BeautifulSoup, Tag 5 | 6 | from dendrite.logic.config import Config 7 | from dendrite.logic.dom.strip import strip_soup 8 | from dendrite.logic.llm.config import LLMConfig 9 | 10 | from .agents import segment_agent, select_agent 11 | from .agents.segment_agent import ( 12 | SegmentAgentFailureResponse, 13 | SegmentAgentReponseType, 14 | SegmentAgentSuccessResponse, 15 | extract_relevant_d_ids, 16 | ) 17 | from .hanifi_segment import SelectedTag, expand_tags, hanifi_segment 18 | from .models import Element 19 | 20 | 21 | async def get_expanded_dom( 22 | soup: BeautifulSoup, prompt: str, llm_config: LLMConfig 23 | ) -> Optional[Tuple[str, List[SegmentAgentReponseType], List[SelectedTag]]]: 24 | 25 | new_nodes = hanifi_segment(soup, 6000, 3) 26 | tags = await get_relevant_tags(prompt, new_nodes, llm_config) 27 | 28 | succesful_d_ids = [ 29 | (tag.d_id, tag.index, tag.reason) 30 | for tag in tags 31 | if isinstance(tag, SegmentAgentSuccessResponse) 32 | ] 33 | 34 | flat_list = [ 35 | SelectedTag( 36 | d_id, 37 | reason=segment_d_ids[2], 38 | index=segment_d_ids[1], 39 | ) 40 | for segment_d_ids in succesful_d_ids 41 | for d_id in segment_d_ids[0] 42 | ] 43 | dom = expand_tags(soup, flat_list) 44 | if dom is None: 45 | return None 46 | return dom, tags, flat_list 47 | 48 | 49 | async def hanifi_search( 50 | soup: BeautifulSoup, 51 | prompt: str, 52 | config: Config, 53 | time_since_frame_navigated: Optional[float] = None, 54 | return_several: bool = False, 55 | ) -> List[Element]: 56 | 57 | stripped_soup = strip_soup(soup) 58 | expand_res = await get_expanded_dom(stripped_soup, prompt, config.llm_config) 59 | 60 | if expand_res is None: 61 | return [Element(status="failed", reason="No element found when expanding HTML")] 62 | 63 | expanded, tags, flat_list = expand_res 64 | 65 | failed_messages = [] 66 | succesful_tags: List[SegmentAgentSuccessResponse] = [] 67 | for tag in tags: 68 | if isinstance(tag, SegmentAgentFailureResponse): 69 | failed_messages.append(tag) 70 | else: 71 | succesful_tags.append(tag) 72 | 73 | if len(succesful_tags) == 0: 74 | return [Element(status="failed", reason="No relevant tags found in DOM")] 75 | 76 | (input_token, output_token, res) = await select_agent.select_best_tag( 77 | expanded, 78 | flat_list, 79 | prompt, 80 | time_since_frame_navigated, 81 | config.llm_config, 82 | return_several, 83 | ) 84 | 85 | if not res: 86 | return [Element(status="failed", reason="Failed to get element")] 87 | 88 | if res.d_id: 89 | if return_several: 90 | return [ 91 | Element(status=res.status, dendrite_id=d_id, reason=res.reason) 92 | for d_id in res.d_id 93 | ] 94 | else: 95 | return [ 96 | Element(status=res.status, dendrite_id=res.d_id[0], reason=res.reason) 97 | ] 98 | 99 | return [Element(status=res.status, dendrite_id=None, reason=res.reason)] 100 | 101 | 102 | async def get_relevant_tags( 103 | prompt: str, 104 | segments: List[List[str]], 105 | llm_config: LLMConfig, 106 | ) -> List[SegmentAgentReponseType]: 107 | 108 | tasks: List[Coroutine[Any, Any, SegmentAgentReponseType]] = [] 109 | 110 | for index, segment in enumerate(segments): 111 | tasks.append(extract_relevant_d_ids(prompt, segment, index, llm_config)) 112 | 113 | results: List[SegmentAgentReponseType] = await asyncio.gather(*tasks) 114 | if results is None: 115 | return [] 116 | 117 | return results 118 | 119 | 120 | def get_if_one_tag( 121 | lst: List[SegmentAgentSuccessResponse], 122 | ) -> Optional[SegmentAgentSuccessResponse]: 123 | curr_item = None 124 | for item in lst: 125 | if isinstance(item, SegmentAgentSuccessResponse): 126 | d_id_count = len(item.d_id) 127 | if d_id_count > 1: # There are multiple d_ids 128 | return None 129 | 130 | if curr_item is None: 131 | curr_item = item # There should always be atleast one d_id 132 | else: # We have already found a d_id 133 | return None 134 | 135 | return curr_item 136 | 137 | 138 | def process_segments( 139 | nodes: List[Union[Tag, BeautifulSoup]], threshold: int = 5000 140 | ) -> List[List[Union[Tag, BeautifulSoup]]]: 141 | processed_segments: List[List[Union[Tag, BeautifulSoup]]] = [] 142 | grouped_segments: List[Union[Tag, BeautifulSoup]] = [] 143 | current_len = 0 144 | for index, node in enumerate(nodes): 145 | node_len = len(str(node)) 146 | 147 | if current_len + node_len > threshold: 148 | processed_segments.append(grouped_segments) 149 | grouped_segments = [] 150 | current_len = 0 151 | 152 | grouped_segments.append(node) 153 | current_len += node_len 154 | 155 | if grouped_segments: 156 | processed_segments.append(grouped_segments) 157 | 158 | return processed_segments 159 | 160 | 161 | def dump_processed_segments(processed_segments: List[List[Union[Tag, BeautifulSoup]]]): 162 | for index, processed_segement in enumerate(processed_segments): 163 | with open(f"processed_segments/segment_{index}.html", "w") as f: 164 | f.write("######\n\n".join(map(lambda x: x.prettify(), processed_segement))) 165 | 166 | 167 | def dump_nodes(nodes: List[Union[Tag, BeautifulSoup]]): 168 | for index, node in enumerate(nodes): 169 | with open(f"nodes/node_{index}.html", "w") as f: 170 | f.write(node.prettify()) 171 | -------------------------------------------------------------------------------- /dendrite/logic/get_element/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import NamedTuple, Optional 3 | 4 | from dendrite.browser._common.types import Status 5 | 6 | 7 | class ExpandedTag(NamedTuple): 8 | d_id: str 9 | html: str 10 | 11 | 12 | @dataclass 13 | class Element: 14 | status: Status 15 | reason: str 16 | dendrite_id: Optional[str] = None 17 | -------------------------------------------------------------------------------- /dendrite/logic/llm/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrite-systems/dendrite-python-sdk/16e591332855547ca31fb8a72793d05a96b2f4d3/dendrite/logic/llm/__init__.py -------------------------------------------------------------------------------- /dendrite/logic/llm/config.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Literal, Optional, overload 2 | 3 | from dendrite.logic.llm.agent import LLM 4 | 5 | AGENTS = Literal[ 6 | "extract_agent", 7 | "scroll_agent", 8 | "ask_page_agent", 9 | "segment_agent", 10 | "select_agent", 11 | "verify_action_agent", 12 | ] 13 | 14 | DEFAULT_LLM: Dict[str, LLM] = { 15 | "extract_agent": LLM( 16 | "claude-3-5-sonnet-20241022", temperature=0.3, max_tokens=1500 17 | ), 18 | "scroll_agent": LLM("claude-3-5-sonnet-20241022", temperature=0.3, max_tokens=1500), 19 | "ask_page_agent": LLM( 20 | "claude-3-5-sonnet-20241022", temperature=0.3, max_tokens=1500 21 | ), 22 | "segment_agent": LLM("claude-3-haiku-20240307", temperature=0, max_tokens=1500), 23 | "select_agent": LLM("claude-3-5-sonnet-20241022", temperature=0, max_tokens=1500), 24 | "verify_action_agent": LLM( 25 | "claude-3-5-sonnet-20241022", temperature=0.3, max_tokens=1500 26 | ), 27 | } 28 | 29 | 30 | class LLMConfig: 31 | """ 32 | Configuration class for Language Learning Models (LLMs) in Dendrite. 33 | 34 | This class manages the registration and retrieval of different LLM agents used 35 | throughout the system. It maintains a registry of LLM configurations for various 36 | agents and provides a default configuration when needed. 37 | 38 | Attributes: 39 | registered_llms (Dict[str, LLM]): Dictionary mapping agent names to their LLM configurations 40 | default_llm (LLM): Default LLM configuration used when no specific agent is found 41 | """ 42 | 43 | def __init__( 44 | self, 45 | default_agents: Optional[Dict[str, LLM]] = None, 46 | default_llm: Optional[LLM] = None, 47 | ): 48 | """ 49 | Initialize the LLMConfig with optional default configurations. 50 | 51 | Args: 52 | default_agents (Optional[Dict[str, LLM]]): Dictionary of agent names to LLM 53 | configurations to override the default agents. Defaults to None. 54 | default_llm (Optional[LLM]): Default LLM configuration to use when no 55 | specific agent is found. If None, uses Claude 3 Sonnet with default settings. 56 | """ 57 | self.registered_llms: Dict[str, LLM] = DEFAULT_LLM.copy() 58 | if default_agents: 59 | self.registered_llms.update(default_agents) 60 | 61 | self.default_llm = default_llm or LLM( 62 | "claude-3-5-sonnet-20241022", temperature=0.3, max_tokens=1500 63 | ) 64 | 65 | async def register_agent(self, agent: str, llm: LLM) -> None: 66 | """ 67 | Register a single LLM agent configuration. 68 | 69 | Args: 70 | agent (str): The name of the agent to register 71 | llm (LLM): The LLM configuration to associate with the agent 72 | """ 73 | self.registered_llms[agent] = llm 74 | 75 | async def register(self, agents: Dict[str, LLM]) -> None: 76 | """ 77 | Register multiple LLM agent configurations at once. 78 | 79 | This method will override any existing agent configurations with the same names. 80 | 81 | Args: 82 | agents (Dict[str, LLM]): Dictionary mapping agent names to their LLM configurations 83 | """ 84 | self.registered_llms.update(agents) 85 | 86 | @overload 87 | def get(self, agent: str) -> LLM: ... 88 | 89 | @overload 90 | def get(self, agent: str, default: LLM) -> LLM: ... 91 | 92 | @overload 93 | def get( 94 | self, 95 | agent: str, 96 | default: Optional[LLM] = ..., 97 | use_default: Literal[False] = False, 98 | ) -> Optional[LLM]: ... 99 | 100 | def get( 101 | self, 102 | agent: str, 103 | default: Optional[LLM] = None, 104 | use_default: bool = True, 105 | ) -> Optional[LLM]: 106 | """ 107 | Get an LLM configuration by agent name. 108 | 109 | This method attempts to retrieve an LLM configuration in the following order: 110 | 1. From the registered agents 111 | 2. From the provided default parameter 112 | 3. From the instance's default_llm (if use_default is True) 113 | 114 | Args: 115 | agent (str): The name of the agent whose configuration to retrieve 116 | default (Optional[LLM]): Specific default LLM to use if agent not found. 117 | Defaults to None. 118 | use_default (bool): Whether to use the instance's default_llm when agent 119 | is not found and no specific default is provided. Defaults to True. 120 | 121 | Returns: 122 | Optional[LLM]: The requested LLM configuration, or None if not found and 123 | no defaults are available or allowed. 124 | """ 125 | llm = self.registered_llms.get(agent) 126 | if llm is not None: 127 | return llm 128 | 129 | if default is not None: 130 | return default 131 | 132 | if use_default and self.default_llm is not None: 133 | return self.default_llm 134 | 135 | return None 136 | -------------------------------------------------------------------------------- /dendrite/logic/llm/token_count.py: -------------------------------------------------------------------------------- 1 | import tiktoken 2 | 3 | 4 | def token_count(string: str, encoding_name: str = "gpt-4o") -> int: 5 | encoding = tiktoken.encoding_for_model(encoding_name) 6 | num_tokens = len(encoding.encode(string)) 7 | return num_tokens 8 | -------------------------------------------------------------------------------- /dendrite/logic/sync_logic_engine.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import threading 3 | from concurrent.futures import ThreadPoolExecutor 4 | from typing import Any, Coroutine, List, TypeVar 5 | 6 | from dendrite.logic.ask import ask 7 | from dendrite.logic.config import Config 8 | from dendrite.logic.extract import extract 9 | from dendrite.logic.get_element import get_element 10 | from dendrite.logic.verify_interaction import verify_interaction 11 | from dendrite.models.dto.ask_page_dto import AskPageDTO 12 | from dendrite.models.dto.cached_extract_dto import CachedExtractDTO 13 | from dendrite.models.dto.cached_selector_dto import CachedSelectorDTO 14 | from dendrite.models.dto.extract_dto import ExtractDTO 15 | from dendrite.models.dto.get_elements_dto import GetElementsDTO 16 | from dendrite.models.dto.make_interaction_dto import VerifyActionDTO 17 | from dendrite.models.response.ask_page_response import AskPageResponse 18 | from dendrite.models.response.extract_response import ExtractResponse 19 | from dendrite.models.response.get_element_response import GetElementResponse 20 | from dendrite.models.response.interaction_response import InteractionResponse 21 | from dendrite.models.scripts import Script 22 | from dendrite.models.selector import Selector 23 | 24 | T = TypeVar("T") 25 | 26 | 27 | def run_coroutine_sync(coroutine: Coroutine[Any, Any, T], timeout: float = 30) -> T: 28 | def run_in_new_loop(): 29 | new_loop = asyncio.new_event_loop() 30 | asyncio.set_event_loop(new_loop) 31 | try: 32 | return new_loop.run_until_complete(coroutine) 33 | finally: 34 | new_loop.close() 35 | 36 | try: 37 | loop = asyncio.get_running_loop() 38 | except RuntimeError: 39 | return asyncio.run(coroutine) 40 | 41 | if threading.current_thread() is threading.main_thread(): 42 | if not loop.is_running(): 43 | return loop.run_until_complete(coroutine) 44 | else: 45 | with ThreadPoolExecutor() as pool: 46 | future = pool.submit(run_in_new_loop) 47 | return future.result(timeout=timeout) 48 | else: 49 | return asyncio.run_coroutine_threadsafe(coroutine, loop).result() 50 | 51 | 52 | class LogicEngine: 53 | 54 | def __init__(self, config: Config): 55 | self._config = config 56 | 57 | def get_element(self, dto: GetElementsDTO) -> GetElementResponse: 58 | return run_coroutine_sync(get_element.get_element(dto, self._config)) 59 | 60 | def get_cached_selectors(self, dto: CachedSelectorDTO) -> List[Selector]: 61 | return run_coroutine_sync(get_element.get_cached_selector(dto, self._config)) 62 | 63 | def get_cached_scripts(self, dto: CachedExtractDTO) -> List[Script]: 64 | return run_coroutine_sync(extract.get_cached_scripts(dto, self._config)) 65 | 66 | def extract(self, dto: ExtractDTO) -> ExtractResponse: 67 | return run_coroutine_sync(extract.extract(dto, self._config)) 68 | 69 | def verify_action(self, dto: VerifyActionDTO) -> InteractionResponse: 70 | return run_coroutine_sync(verify_interaction.verify_action(dto, self._config)) 71 | 72 | def ask_page(self, dto: AskPageDTO) -> AskPageResponse: 73 | return run_coroutine_sync(ask.ask_page_action(dto, self._config)) 74 | -------------------------------------------------------------------------------- /dendrite/logic/verify_interaction/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrite-systems/dendrite-python-sdk/16e591332855547ca31fb8a72793d05a96b2f4d3/dendrite/logic/verify_interaction/__init__.py -------------------------------------------------------------------------------- /dendrite/logic/verify_interaction/verify_interaction.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import List 3 | 4 | from bs4 import BeautifulSoup 5 | 6 | from dendrite.logic.config import Config 7 | from dendrite.logic.llm.agent import LLM, Agent, Message 8 | from dendrite.models.dto.make_interaction_dto import VerifyActionDTO 9 | from dendrite.models.response.interaction_response import InteractionResponse 10 | 11 | 12 | async def verify_action( 13 | make_interaction_dto: VerifyActionDTO, config: Config 14 | ) -> InteractionResponse: 15 | 16 | if ( 17 | make_interaction_dto.interaction_type == "fill" 18 | and make_interaction_dto.value == "" 19 | ): 20 | raise Exception(f"Error: You need to specify the keys you want to send.") 21 | 22 | interaction_verb = "" 23 | if make_interaction_dto.interaction_type == "click": 24 | interaction_verb = "clicked on" 25 | elif make_interaction_dto.interaction_type == "fill": 26 | interaction_verb = "sent keys to" 27 | 28 | locator_desc = "" 29 | if make_interaction_dto.dendrite_id != "": 30 | locator_desc = "the dendrite id '{element_dendrite_id}'" 31 | 32 | expected_outcome = ( 33 | "" 34 | if make_interaction_dto.expected_outcome == None 35 | else f"The expected outcome is: '{make_interaction_dto.expected_outcome}'" 36 | ) 37 | prompt = f"I {interaction_verb} a <{make_interaction_dto.tag_name}> element with {locator_desc}. {expected_outcome}" 38 | 39 | messages: List[Message] = [ 40 | { 41 | "role": "user", 42 | "content": [], 43 | }, 44 | { 45 | "role": "user", 46 | "content": [ 47 | { 48 | "type": "text", 49 | "text": prompt, 50 | }, 51 | { 52 | "type": "text", 53 | "text": "Here is the viewport before the interaction:", 54 | }, 55 | { 56 | "type": "image_url", 57 | "image_url": { 58 | "url": f"data:image/jpeg;base64,{make_interaction_dto.screenshot_before}" 59 | }, 60 | }, 61 | { 62 | "type": "text", 63 | "text": "Here is the viewport after the interaction:", 64 | }, 65 | { 66 | "type": "image_url", 67 | "image_url": { 68 | "url": f"data:image/jpeg;base64,{make_interaction_dto.screenshot_after}" 69 | }, 70 | }, 71 | { 72 | "type": "text", 73 | "text": """Based of the expected outcome, please output a json object that either confirms that the interaction was successful or that it failed. Output a json object like this with no description or backticks, just valid json. {"status": "success" | "failed", "message": "Give a short description of what happened and if the interaction completed successfully or failed to reach the expected outcome, write max 100 characters."}""", 74 | }, 75 | ], 76 | }, 77 | ] 78 | 79 | default = LLM(model="gpt-4o", max_tokens=150) 80 | llm = Agent(config.llm_config.get("verify_action", default)) 81 | 82 | res = await llm.call_llm(messages) 83 | try: 84 | dict_res = json.loads(res) 85 | return InteractionResponse( 86 | message=dict_res["message"], 87 | status=dict_res["status"], 88 | ) 89 | except: 90 | pass 91 | 92 | raise Exception("Failed to parse interaction page delta.") 93 | -------------------------------------------------------------------------------- /dendrite/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrite-systems/dendrite-python-sdk/16e591332855547ca31fb8a72793d05a96b2f4d3/dendrite/models/__init__.py -------------------------------------------------------------------------------- /dendrite/models/dto/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrite-systems/dendrite-python-sdk/16e591332855547ca31fb8a72793d05a96b2f4d3/dendrite/models/dto/__init__.py -------------------------------------------------------------------------------- /dendrite/models/dto/ask_page_dto.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | from dendrite.models.page_information import PageInformation 6 | 7 | 8 | class AskPageDTO(BaseModel): 9 | prompt: str 10 | return_schema: Optional[Any] 11 | page_information: PageInformation 12 | -------------------------------------------------------------------------------- /dendrite/models/dto/cached_extract_dto.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class CachedExtractDTO(BaseModel): 5 | url: str 6 | prompt: str 7 | -------------------------------------------------------------------------------- /dendrite/models/dto/cached_selector_dto.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class CachedSelectorDTO(BaseModel): 5 | url: str 6 | prompt: str 7 | -------------------------------------------------------------------------------- /dendrite/models/dto/extract_dto.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any, Optional 3 | 4 | from pydantic import BaseModel 5 | 6 | from dendrite.models.page_information import PageInformation 7 | 8 | 9 | class ExtractDTO(BaseModel): 10 | page_information: PageInformation 11 | prompt: str 12 | 13 | return_data_json_schema: Any 14 | use_screenshot: bool = False 15 | 16 | @property 17 | def combined_prompt(self) -> str: 18 | 19 | json_schema_prompt = ( 20 | "" 21 | if self.return_data_json_schema == None 22 | else f"\nJson schema: {json.dumps(self.return_data_json_schema)}" 23 | ) 24 | return f"Task: {self.prompt}{json_schema_prompt}" 25 | -------------------------------------------------------------------------------- /dendrite/models/dto/get_elements_dto.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Union 2 | 3 | from pydantic import BaseModel 4 | 5 | from dendrite.models.page_information import PageInformation 6 | 7 | 8 | class CheckSelectorCacheDTO(BaseModel): 9 | url: str 10 | prompt: Union[str, Dict[str, str]] 11 | 12 | 13 | class GetElementsDTO(BaseModel): 14 | prompt: Union[str, Dict[str, str]] 15 | page_information: PageInformation 16 | only_one: bool 17 | -------------------------------------------------------------------------------- /dendrite/models/dto/make_interaction_dto.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | from dendrite.models.page_information import PageDiffInformation 6 | 7 | InteractionType = Literal["click", "fill", "hover"] 8 | 9 | 10 | class VerifyActionDTO(BaseModel): 11 | url: str 12 | dendrite_id: str 13 | interaction_type: InteractionType 14 | tag_name: str 15 | value: Optional[str] = None 16 | expected_outcome: str 17 | screenshot_before: str 18 | screenshot_after: str 19 | -------------------------------------------------------------------------------- /dendrite/models/page_information.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class PageInformation(BaseModel): 5 | url: str 6 | raw_html: str 7 | screenshot_base64: str 8 | time_since_frame_navigated: float 9 | 10 | 11 | class PageDiffInformation(BaseModel): 12 | screenshot_before: str 13 | screenshot_after: str 14 | page_before: PageInformation 15 | page_after: PageInformation 16 | -------------------------------------------------------------------------------- /dendrite/models/response/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrite-systems/dendrite-python-sdk/16e591332855547ca31fb8a72793d05a96b2f4d3/dendrite/models/response/__init__.py -------------------------------------------------------------------------------- /dendrite/models/response/ask_page_response.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, Literal, TypeVar 2 | 3 | from pydantic import BaseModel 4 | 5 | T = TypeVar("T") 6 | 7 | 8 | class AskPageResponse(BaseModel, Generic[T]): 9 | status: Literal["success", "error"] 10 | return_data: T 11 | description: str 12 | -------------------------------------------------------------------------------- /dendrite/models/response/extract_response.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, Optional, TypeVar 2 | 3 | from pydantic import BaseModel 4 | 5 | from dendrite.browser._common.types import Status 6 | 7 | T = TypeVar("T") 8 | 9 | 10 | class ExtractResponse(BaseModel, Generic[T]): 11 | status: Status 12 | message: str 13 | return_data: Optional[T] = None 14 | created_script: Optional[str] = None 15 | -------------------------------------------------------------------------------- /dendrite/models/response/get_element_response.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Optional, Union 2 | 3 | from pydantic import BaseModel 4 | 5 | from dendrite.models.status import Status 6 | 7 | 8 | class GetElementResponse(BaseModel): 9 | status: Status 10 | d_id: Optional[str] = None 11 | selectors: Optional[List[str]] = None 12 | message: str = "" 13 | -------------------------------------------------------------------------------- /dendrite/models/response/interaction_response.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from dendrite.models.status import Status 4 | 5 | 6 | class InteractionResponse(BaseModel): 7 | message: str 8 | status: Status 9 | -------------------------------------------------------------------------------- /dendrite/models/scripts.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Script(BaseModel): 5 | url: str 6 | domain: str 7 | script: str 8 | created_at: str 9 | -------------------------------------------------------------------------------- /dendrite/models/selector.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Selector(BaseModel): 5 | selector: str 6 | prompt: str 7 | url: str 8 | netloc: str 9 | created_at: str 10 | -------------------------------------------------------------------------------- /dendrite/models/status.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | Status = Literal["success", "failed", "loading", "impossible"] 4 | -------------------------------------------------------------------------------- /dendrite/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendrite-systems/dendrite-python-sdk/16e591332855547ca31fb8a72793d05a96b2f4d3/dendrite/py.typed -------------------------------------------------------------------------------- /dendrite/remote/__init__.py: -------------------------------------------------------------------------------- 1 | from dendrite.browser.remote import BrowserbaseConfig, BrowserlessConfig, Providers 2 | 3 | __all__ = [ 4 | "BrowserbaseConfig", 5 | "BrowserlessConfig", 6 | ] 7 | -------------------------------------------------------------------------------- /publish.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | 4 | def run_command(command): 5 | result = subprocess.run(command, shell=True, capture_output=True, text=True) 6 | if result.returncode != 0: 7 | print(f"Error: {result.stderr}") 8 | else: 9 | print(result.stdout) 10 | 11 | 12 | # Step 1: Clean the dist directory 13 | print("Cleaning the dist directory...") 14 | run_command("rm -rf dist/*") 15 | 16 | # Step 2: Build the package 17 | print("Building the package...") 18 | run_command("poetry build") 19 | 20 | # Step 3: Publish the package to PyPI 21 | print("Publishing the package to PyPI...") 22 | run_command("poetry publish") 23 | 24 | print("Done!") 25 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "dendrite" 3 | version = "0.2.8" 4 | description = "Dendrite is a suite of tools that makes it easy to create web integrations for AI agents. With Dendrite your can: Authenticate on websites, Interact with elements, Extract structured data, Download and upload files, Fill out forms" 5 | 6 | authors = [ 7 | "Arian Hanifi ", 8 | "Charles Maddock ", 9 | "Sebastian Thunman 0 24 | # ), f"Expected non-empty file at {download_path}, but file size is zero." 25 | -------------------------------------------------------------------------------- /tests/tests_async/test_download.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from dendrite import AsyncDendrite 6 | 7 | pytest_plugins = ("pytest_asyncio",) 8 | 9 | 10 | # content of test_tmp_path.py 11 | @pytest.mark.asyncio(loop_scope="session") 12 | async def test_download(dendrite_browser: AsyncDendrite, tmp_path): 13 | page = await dendrite_browser.goto( 14 | "https://browser-tests-alpha.vercel.app/api/download-test" 15 | ) 16 | await page.playwright_page.get_by_text("Download File").click() 17 | dw = await page.get_download(timeout=5000) 18 | 19 | # Save the downloaded file to the suggested location 20 | download_path = tmp_path / dw.suggested_filename 21 | await dw.save_as(download_path) 22 | 23 | # Assertion: Verify that the file was downloaded successfully 24 | assert ( 25 | download_path.exists() 26 | ), f"Expected downloaded file at {download_path}, but it does not exist." 27 | assert ( 28 | os.path.getsize(download_path) > 0 29 | ), f"Expected non-empty file at {download_path}, but file size is zero." 30 | 31 | 32 | # @pytest.mark.asyncio 33 | # async def test_browserbase_download(dendrite_browser): 34 | -------------------------------------------------------------------------------- /tests/tests_async/tests.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dendrite import AsyncDendrite 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_get_element(): 8 | """Test the get_element method retrieves an existing element.""" 9 | browser = AsyncDendrite() 10 | try: 11 | page = await browser.goto("https://example.com") 12 | element = await page.get_element("The main heading") 13 | assert element is not None 14 | assert await element.locator.inner_text() == "Example Domain" 15 | finally: 16 | await browser.close() 17 | 18 | 19 | @pytest.mark.asyncio 20 | @pytest.mark.parametrize( 21 | "link_text, expected_url", 22 | [ 23 | ("the more information link", "https://www.iana.org/help/example-domains"), 24 | ], 25 | ) 26 | async def test_click(link_text, expected_url): 27 | """Test the click method navigates to the correct URL.""" 28 | browser = AsyncDendrite() 29 | try: 30 | page = await browser.goto("https://example.com") 31 | await page.click(link_text) 32 | assert expected_url in page.url 33 | finally: 34 | await browser.close() 35 | 36 | 37 | @pytest.mark.asyncio 38 | async def test_fill(): 39 | """Test the fill method correctly inputs text into a form field.""" 40 | browser = AsyncDendrite() 41 | try: 42 | page = await browser.goto("https://www.scrapethissite.com/pages/forms/") 43 | test_input = "Dendrite test" 44 | await page.fill("The search for teams input", test_input) 45 | search_input = await page.get_element("The search for teams input") 46 | assert search_input is not None 47 | assert await search_input.locator.input_value() == test_input 48 | finally: 49 | await browser.close() 50 | 51 | 52 | @pytest.mark.asyncio 53 | async def test_extract(): 54 | """Test the extract method retrieves the correct page title.""" 55 | browser = AsyncDendrite() 56 | try: 57 | page = await browser.goto("https://example.com") 58 | title = await page.extract("Get the page title", str) 59 | assert title == "Example Domain" 60 | finally: 61 | await browser.close() 62 | 63 | 64 | @pytest.mark.asyncio 65 | async def test_wait_for(): 66 | """Test the wait_for method waits for a specific condition.""" 67 | browser = AsyncDendrite() 68 | try: 69 | page = await browser.goto("https://example.com") 70 | await page.wait_for("The page to fully load and the main heading to be visible") 71 | main_heading = await page.get_element("The main heading") 72 | assert main_heading is not None 73 | assert await main_heading.locator.is_visible() 74 | finally: 75 | await browser.close() 76 | 77 | 78 | @pytest.mark.asyncio 79 | async def test_ask(): 80 | """Test the ask method returns a relevant response.""" 81 | browser = AsyncDendrite() 82 | try: 83 | page = await browser.goto("https://example.com") 84 | response = await page.ask("What is the main topic of this page?", str) 85 | assert "example" in response.lower() 86 | assert "domain" in response.lower() 87 | finally: 88 | await browser.close() 89 | -------------------------------------------------------------------------------- /tests/tests_sync/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dendrite import Dendrite 4 | 5 | 6 | @pytest.fixture(scope="session") 7 | def dendrite_browser(): 8 | """ 9 | Initializes a single instance of Dendrite to be shared across multiple test cases. 10 | 11 | The fixture has a session scope, so it will only be initialized once for the entire test session. 12 | """ 13 | browser = Dendrite( 14 | playwright_options={"headless": True}, 15 | ) # Launch the browser 16 | 17 | yield browser # Provide the browser to tests 18 | 19 | # Cleanup after all tests are done 20 | browser.close() 21 | -------------------------------------------------------------------------------- /tests/tests_sync/test_context.py: -------------------------------------------------------------------------------- 1 | # content of test_tmp_path.py 2 | from dendrite import Dendrite 3 | 4 | 5 | def test_context_manager(): 6 | with Dendrite( 7 | playwright_options={"headless": True}, 8 | ) as browser: 9 | browser.goto("https://dendrite.systems") 10 | -------------------------------------------------------------------------------- /tests/tests_sync/test_download.py: -------------------------------------------------------------------------------- 1 | # content of test_tmp_path.py 2 | import os 3 | 4 | from dendrite import Dendrite 5 | 6 | 7 | def test_download(dendrite_browser: Dendrite, tmp_path): 8 | page = dendrite_browser.goto( 9 | "https://browser-tests-alpha.vercel.app/api/download-test" 10 | ) 11 | page.playwright_page.get_by_text("Download File").click() 12 | dw = page.get_download(timeout=5000) 13 | 14 | # Save the downloaded file to the suggested location 15 | download_path = tmp_path / dw.suggested_filename 16 | dw.save_as(download_path) 17 | 18 | # Assertion: Verify that the file was downloaded successfully 19 | assert ( 20 | download_path.exists() 21 | ), f"Expected downloaded file at {download_path}, but it does not exist." 22 | assert ( 23 | os.path.getsize(download_path) > 0 24 | ), f"Expected non-empty file at {download_path}, but file size is zero." 25 | --------------------------------------------------------------------------------