├── .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 |
--------------------------------------------------------------------------------