Skip to content

Commit 9218f9b

Browse files
Den 418 improve cache retrieval and testing (#67)
* Added impossible as status and improved speed and reliability by checking caches for extract and get_element first. Also tracks if url changes with helps avoid sending wrong page information to the server due to race conditions. Fixed issue in screenshot manager and improved prompts for click and fill. Finally, refactored get_element and extract_mixins. * small refactor * Added missing DTO to async api * add docstring to extract * update mixin to not include util functions * fix use async instead of sync DTO * refactor browserless config to remote module --------- Co-authored-by: Arian Hanifi <[email protected]>
1 parent 855a683 commit 9218f9b

37 files changed

+850
-315
lines changed

dendrite_sdk/async_api/_api/browser_api_client.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
from typing import Optional
22

33
from loguru import logger
4+
from dendrite_sdk.async_api._api.response.cache_extract_response import (
5+
CacheExtractResponse,
6+
)
7+
from dendrite_sdk.async_api._api.response.selector_cache_response import (
8+
SelectorCacheResponse,
9+
)
410
from dendrite_sdk.async_api._core.models.authentication import AuthSession
511
from dendrite_sdk.async_api._api.response.get_element_response import GetElementResponse
612
from dendrite_sdk.async_api._api.dto.ask_page_dto import AskPageDTO
@@ -19,6 +25,7 @@
1925
from dendrite_sdk._common._exceptions.dendrite_exception import (
2026
InvalidAuthSessionError,
2127
)
28+
from dendrite_sdk.async_api._api.dto.get_elements_dto import CheckSelectorCacheDTO
2229

2330

2431
class BrowserAPIClient(HTTPClient):
@@ -38,6 +45,14 @@ async def upload_auth_session(self, dto: UploadAuthSessionDTO):
3845
"actions/upload-auth-session", data=dto.dict(), method="POST"
3946
)
4047

48+
async def check_selector_cache(
49+
self, dto: CheckSelectorCacheDTO
50+
) -> SelectorCacheResponse:
51+
res = await self.send_request(
52+
"actions/check-selector-cache", data=dto.dict(), method="POST"
53+
)
54+
return SelectorCacheResponse(**res.json())
55+
4156
async def get_interactions_selector(
4257
self, dto: GetElementsDTO
4358
) -> GetElementResponse:
@@ -55,6 +70,12 @@ async def make_interaction(self, dto: MakeInteractionDTO) -> InteractionResponse
5570
status=res_dict["status"], message=res_dict["message"]
5671
)
5772

73+
async def check_extract_cache(self, dto: ExtractDTO) -> CacheExtractResponse:
74+
res = await self.send_request(
75+
"actions/check-extract-cache", data=dto.dict(), method="POST"
76+
)
77+
return CacheExtractResponse(**res.json())
78+
5879
async def extract(self, dto: ExtractDTO) -> ExtractResponse:
5980
res = await self.send_request(
6081
"actions/extract-page", data=dto.dict(), method="POST"

dendrite_sdk/async_api/_api/dto/get_elements_dto.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,15 @@
55
from dendrite_sdk.async_api._core.models.page_information import PageInformation
66

77

8+
class CheckSelectorCacheDTO(BaseModel):
9+
url: str
10+
prompt: Union[str, Dict[str, str]]
11+
12+
813
class GetElementsDTO(BaseModel):
914
page_information: PageInformation
1015
prompt: Union[str, Dict[str, str]]
1116
api_config: APIConfig
1217
use_cache: bool = True
1318
only_one: bool
19+
force_use_cache: bool = False
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from pydantic import BaseModel
2+
3+
4+
class CacheExtractResponse(BaseModel):
5+
exists: bool

dendrite_sdk/async_api/_api/response/get_element_response.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ class GetElementResponse(BaseModel):
99
status: Status
1010
selectors: Optional[Union[List[str], Dict[str, List[str]]]] = None
1111
message: str = ""
12+
used_cache: bool = False
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from pydantic import BaseModel
2+
3+
4+
class SelectorCacheResponse(BaseModel):
5+
exists: bool
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
from typing import Literal
22

33

4-
Status = Literal["success", "failed", "loading"]
4+
Status = Literal["success", "failed", "loading", "impossible"]

dendrite_sdk/async_api/_core/_impl_mapping.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@
44

55
from dendrite_sdk.async_api._ext_impl.browserbase._impl import BrowserBaseImpl
66
from dendrite_sdk.async_api._ext_impl.browserless._impl import BrowserlessImpl
7-
from dendrite_sdk.async_api._ext_impl.browserless._settings import BrowserlessConfig
8-
from dendrite_sdk.remote import Providers
7+
from dendrite_sdk.remote.browserless_config import BrowserlessConfig
98
from dendrite_sdk.remote.browserbase_config import BrowserbaseConfig
10-
9+
from dendrite_sdk.remote import Providers
1110

1211
IMPL_MAPPING: Dict[Type[Providers], Type[ImplBrowser]] = {
1312
BrowserbaseConfig: BrowserBaseImpl,
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import asyncio
2+
import time
3+
4+
from typing import TYPE_CHECKING, Dict, Optional
5+
6+
if TYPE_CHECKING:
7+
from dendrite_sdk.async_api._core.dendrite_page import AsyncPage
8+
9+
10+
class NavigationTracker:
11+
def __init__(
12+
self,
13+
page: "AsyncPage",
14+
):
15+
self.playwright_page = page.playwright_page
16+
self._nav_start_timestamp: Optional[float] = None
17+
18+
# Track all navigation-related events
19+
self.playwright_page.on("framenavigated", self._on_frame_navigated)
20+
self.playwright_page.on("popup", self._on_popup)
21+
22+
# Store last event times
23+
self._last_events: Dict[str, Optional[float]] = {
24+
"framenavigated": None,
25+
"popup": None,
26+
}
27+
28+
def _on_frame_navigated(self, frame):
29+
self._last_events["framenavigated"] = time.time()
30+
if frame is self.playwright_page.main_frame:
31+
self._last_main_frame_url = frame.url
32+
self._last_frame_navigated_timestamp = time.time()
33+
34+
def _on_popup(self, page):
35+
self._last_events["popup"] = time.time()
36+
37+
def start_nav_tracking(self):
38+
"""Call this just before performing an action that might trigger navigation"""
39+
self._nav_start_timestamp = time.time()
40+
# Reset event timestamps
41+
for event in self._last_events:
42+
self._last_events[event] = None
43+
44+
def get_nav_events_since_start(self):
45+
"""
46+
Returns which events have fired since start_nav_tracking() was called
47+
and how long after the start they occurred
48+
"""
49+
if self._nav_start_timestamp is None:
50+
return "Navigation tracking not started. Call start_nav_tracking() first."
51+
52+
results = {}
53+
for event, timestamp in self._last_events.items():
54+
if timestamp is not None:
55+
delay = timestamp - self._nav_start_timestamp
56+
results[event] = f"{delay:.3f}s"
57+
else:
58+
results[event] = "not fired"
59+
60+
return results
61+
62+
async def has_navigated_since_start(self):
63+
"""Returns True if any navigation event has occurred since start_nav_tracking()"""
64+
if self._nav_start_timestamp is None:
65+
return False
66+
67+
start_time = time.time()
68+
max_wait = 1.0 # Maximum wait time in seconds
69+
70+
while time.time() - start_time < max_wait:
71+
if any(
72+
timestamp is not None and timestamp > self._nav_start_timestamp
73+
for timestamp in self._last_events.values()
74+
):
75+
return True
76+
await asyncio.sleep(0.1)
77+
78+
return False

dendrite_sdk/async_api/_core/_managers/screenshot_manager.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,14 @@ async def take_full_page_screenshot(self) -> str:
1616
# Check the page height
1717
scroll_height = await self.page.evaluate(
1818
"""
19-
Math.max(
20-
document.body.scrollHeight,
21-
document.documentElement.scrollHeight,
22-
document.body.offsetHeight,
23-
document.documentElement.offsetHeight,
24-
document.body.clientHeight,
25-
document.documentElement.clientHeight
26-
)
27-
"""
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+
"""
2827
)
2928

3029
if scroll_height > 30000:

dendrite_sdk/async_api/_core/dendrite_element.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
if TYPE_CHECKING:
1515
from dendrite_sdk.async_api._core.dendrite_browser import AsyncDendrite
16+
from dendrite_sdk.async_api._core._managers.navigation_tracker import NavigationTracker
1617
from dendrite_sdk.async_api._core.models.page_diff_information import (
1718
PageDiffInformation,
1819
)
@@ -46,10 +47,6 @@ async def wrapper(
4647
) -> InteractionResponse:
4748
expected_outcome: Optional[str] = kwargs.pop("expected_outcome", None)
4849

49-
logger.info(
50-
f'Performing action "{interaction_type}" | element: d_id:"{self.dendrite_id}" {self.locator}'
51-
)
52-
5350
if not expected_outcome:
5451
await func(self, *args, **kwargs)
5552
return InteractionResponse(status="success", message="")
@@ -146,7 +143,11 @@ async def screenshot(self) -> str:
146143

147144
@perform_action("click")
148145
async def click(
149-
self, expected_outcome: Optional[str] = None, *args, **kwargs
146+
self,
147+
expected_outcome: Optional[str] = None,
148+
wait_for_navigation: bool = True,
149+
*args,
150+
**kwargs,
150151
) -> InteractionResponse:
151152
"""
152153
Click the element.
@@ -163,6 +164,10 @@ async def click(
163164
timeout = kwargs.pop("timeout", 2000)
164165
force = kwargs.pop("force", False)
165166

167+
page = await self._dendrite_browser.get_active_page()
168+
navigation_tracker = NavigationTracker(page)
169+
navigation_tracker.start_nav_tracking()
170+
166171
try:
167172
await self.locator.click(timeout=timeout, force=force, *args, **kwargs)
168173
except Exception as e:
@@ -171,15 +176,26 @@ async def click(
171176
except Exception as e:
172177
await self.locator.dispatch_event("click", timeout=2000)
173178

179+
if wait_for_navigation:
180+
has_navigated = await navigation_tracker.has_navigated_since_start()
181+
if has_navigated:
182+
try:
183+
start_time = time.time()
184+
await page.playwright_page.wait_for_load_state("load", timeout=2000)
185+
wait_duration = time.time() - start_time
186+
print(f"Waited {wait_duration:.2f} seconds for load state")
187+
except Exception as e:
188+
print(f"Page navigated but failed to wait for load state: {e}")
189+
174190
return InteractionResponse(status="success", message="")
175191

176192
@perform_action("fill")
177193
async def fill(
178194
self, value: str, expected_outcome: Optional[str] = None, *args, **kwargs
179195
) -> InteractionResponse:
180196
"""
181-
Fill the element with a value. If an expected outcome is provided, the LLM will be used to verify the outcome and raise an exception if the outcome is not as expected.
182-
All additional arguments are passed to the Playwright fill method.
197+
Fill the element with a value. If the element itself is not fillable,
198+
it attempts to find and fill a fillable child element.
183199
184200
Args:
185201
value (str): The value to fill the element with.
@@ -192,7 +208,15 @@ async def fill(
192208
"""
193209

194210
timeout = kwargs.pop("timeout", 2000)
195-
await self.locator.fill(value, timeout=timeout, *args, **kwargs)
211+
try:
212+
# First, try to fill the element directly
213+
await self.locator.fill(value, timeout=timeout, *args, **kwargs)
214+
except Exception as e:
215+
# If direct fill fails, try to find a fillable child element
216+
fillable_child = self.locator.locator(
217+
'input, textarea, [contenteditable="true"]'
218+
).first
219+
await fillable_child.fill(value, timeout=timeout, *args, **kwargs)
196220

197221
return InteractionResponse(status="success", message="")
198222

0 commit comments

Comments
 (0)