Files
tkmind_go/src/goose/toolkit/web_browser.py
T

471 lines
18 KiB
Python

import importlib.util
import os
import random
import shutil
import subprocess
import sys
import time
from typing import Callable
# Windows-specific import
# if sys.platform.startswith("win"):
# import winreg
# Check and install selenium if not installed
if importlib.util.find_spec("selenium") is None:
subprocess.check_call(["python", "-m", "pip", "install", "selenium"])
from bs4 import BeautifulSoup
from exchange import Message
from pyshadow.main import Shadow
from selenium import webdriver
from selenium.common.exceptions import InvalidSessionIdException, NoSuchElementException, TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.ui import WebDriverWait
from goose.toolkit.base import Toolkit, tool
class BrowserToolkit(Toolkit):
"""A toolkit for interacting with web browsers using Selenium."""
def __init__(self, *args: object, **kwargs: dict[str, object]) -> None:
super().__init__(*args, **kwargs)
self.driver = None
self.history = []
self.session_dir = ".goose/browsing_session"
os.makedirs(self.session_dir, exist_ok=True)
self.cached_url = ""
def _initialize_driver(self, force_restart: bool = False, mock_driver: object = None) -> None:
"""Initialize the web driver if not already initialized or if a restart is forced."""
if self.driver is None or force_restart:
if mock_driver:
self.driver = mock_driver
return
if self.driver is not None:
try:
self.driver.quit()
self.notifier.notify("Previous browser session closed.")
except Exception as e:
self.notifier.notify(f"Error closing previous session: {str(e)}")
self.driver = None
subprocess.run(["pkill", "-f", "webdriver"]) # Attempt to close all previous browser instances
self.notifier.notify("All previous browser instances terminated.")
if self.driver is not None:
try:
self.driver.quit()
except Exception as e:
self.notifier.notify(f"Error closing driver: {str(e)}")
browser_name = self._get_default_browser()
try:
if "chrome" in browser_name.lower():
options = webdriver.ChromeOptions()
self.driver = webdriver.Chrome(options=options)
elif "firefox" in browser_name.lower():
self.driver = webdriver.Firefox()
else:
self.driver = webdriver.Firefox()
try:
self.driver.set_window_size(835, 1024)
except Exception:
pass # Ignore window sizing errors if they occur
except Exception as e:
self.notifier.notify(f"Failed to initialize browser driver: {str(e)}")
self.notifier.notify("Falling back to Firefox.")
self.driver = webdriver.Firefox()
def _get_default_browser(self) -> str:
return get_default_browser()
def system(self) -> str:
return Message.load("prompts/browser.jinja").text
def safe_execute(self, func: Callable, *args: object, **kwargs: dict[str, object]) -> object:
"""Safely execute a browser action, restart the driver if needed."""
try:
return func(*args, **kwargs)
except (TimeoutException, NoSuchElementException, InvalidSessionIdException, Exception) as e:
self.notifier.notify(f"Error during browser action: {str(e)}")
self._initialize_driver(force_restart=True)
return func(*args, **kwargs)
@tool
def navigate_to(self, url: str) -> None:
"""Navigate or browse to a specified URL in the browser.
Args:
url (str): The URL to navigate to.
"""
self._initialize_driver()
self.notifier.notify(f"Navigating to {url}")
self.safe_execute(self.driver.get, url)
self.wait_for_page_load()
self.history.append(url)
@tool
def take_browser_screenshot(self, filename: str) -> str:
"""Take a screenshot of the current browser window to assist with navigation.
Args:
filename (str): The file path where the screenshot will be saved.
"""
try:
path = os.path.join(self.session_dir, filename)
self.driver.save_screenshot(path)
self.notifier.notify(f"Screenshot saved in browsing session: {path}")
return f"image:{path}"
except Exception as e:
self.notifier.notify(f"Error taking screenshot: {str(e)}")
@tool
def scroll_page(self, direction: str = "down") -> None:
"""Scroll the current page up or down.
Args:
direction (str): The direction to scroll the page. Either 'up' or 'down'.
"""
actions = ActionChains(self.driver)
if direction == "up":
actions.send_keys(Keys.PAGE_UP).perform()
elif direction == "down":
actions.send_keys(Keys.PAGE_DOWN).perform()
else:
self.notifier.notify(f"Invalid scroll direction: {direction}")
@tool
def open_new_tab(self, url: str) -> None:
"""Open a new tab and navigate to the specified URL.
Args:
url (str): The URL to navigate to in the new tab.
"""
if not self.driver:
self.notifier.notify("Driver not initialized, using navigate_to instead.")
self.navigate_to(url)
return
self.notifier.notify(f"Opening a new tab and navigating to {url}.")
self.driver.execute_script(f"window.open('{url}', '_blank');")
self.driver.switch_to.window(self.driver.window_handles[-1])
self.wait_for_page_load()
@tool
def check_current_page_url(self) -> str:
"""Get the URL of the current page."""
if not self.driver:
self.notifier.notify("Driver is not initialized.")
return ""
current_url = self.driver.current_url
self.notifier.notify(f"Current page URL: {current_url}")
return current_url
@tool
def switch_to_tab(self, index: int) -> None:
"""Switch to the browser tab at the specified index.
Args:
index (int): The index of the tab to switch to.
"""
try:
self.notifier.notify(f"Switching to tab at index {index}.")
self.driver.switch_to.window(self.driver.window_handles[index])
self.wait_for_page_load()
except IndexError:
self.notifier.notify(f"Invalid tab index: {index}.")
@tool
def close_current_tab(self) -> None:
"""Close the current browser tab."""
if not self.driver:
self.notifier.notify("Cannot close the tab as the driver is not initialized.")
return
self.notifier.notify("Closing the current tab.")
self.driver.close()
if len(self.driver.window_handles) > 0:
self.driver.switch_to.window(self.driver.window_handles[-1])
def refresh_page(self) -> None:
"""Refresh the current browser page."""
self.notifier.notify("Refreshing the current page.")
self.driver.refresh()
self.wait_for_page_load()
@tool
def get_html_content(self) -> str:
"""Extract the full HTML content of the current page and cache it to a file."""
self.notifier.notify("Extracting full HTML content of the page.")
current_url = self.driver.current_url.replace("https://", "").replace("http://", "").replace("/", "_")
if current_url != self.cached_url:
html_content = self.driver.page_source
filename = os.path.join(self.session_dir, f"{current_url}_page.html")
with open(filename, "w", encoding="utf-8") as f:
f.write(html_content)
self.cached_html_path = filename
self.cached_url = current_url
self.notifier.notify(f"HTML cached as {filename}.")
return self.cached_html_path
# @tool
# def run_js(self, script: str) -> str:
# """Execute custom JavaScript on the page.
#
# Args:
# script (str): JavaScript code to execute.
#
# Returns:
# str: The result of the JavaScript execution.
# """
# self.notifier.notify("Running JavaScript in the browser.")
# return self.driver.execute_script(script)
@tool
def type_into_input(self, selector: str, text: str) -> None:
"""Type text into an input element specified by a CSS selector for the currently open page.
Args:
selector (str): CSS selector string to locate the input element.
text (str): The text to type into the input element.
"""
retries = 3
for attempt in range(retries):
try:
self.notifier.notify(f"Typing '{text}' into input with selector: {selector}")
element = WebDriverWait(self.driver, 20).until(ec.element_to_be_clickable((By.CSS_SELECTOR, selector)))
element.clear()
for char in text:
element.send_keys(char)
time.sleep(random.uniform(0.1, 0.3))
break
except TimeoutException as e:
if attempt < retries - 1:
self.notifier.notify(f"Retry {attempt + 1}/{retries} due to timeout: {str(e)}")
time.sleep(2)
else:
raise
def wait_for_page_load(self, timeout: int = 45) -> None:
"""Wait for the page to fully load by checking the document readiness state.
Args:
timeout (int): Maximum time to wait for page load, in seconds.
"""
WebDriverWait(self.driver, timeout).until(
lambda driver: driver.execute_script("return document.readyState") == "complete"
)
self.notifier.notify("Page fully loaded.")
@tool
def click_element(self, selector: str) -> None:
"""Click a button or link specified by a CSS selector.
Args:
selector (str): CSS selector string to locate the element.
"""
retries = 3
for attempt in range(retries):
try:
self.notifier.notify(f"Clicking element with selector: {selector}")
element = WebDriverWait(self.driver, 20).until(ec.element_to_be_clickable((By.CSS_SELECTOR, selector)))
element.click()
self.wait_for_page_load()
break
except TimeoutException as e:
if attempt < retries - 1:
self.notifier.notify(f"Retry {attempt + 1}/{retries} due to timeout: {str(e)}")
time.sleep(2)
else:
raise
@tool
def click_element_by_link_text(self, link_text: str, exact_match: bool = True) -> None:
"""Click on a page element using the text visible on the page.
Useful when the page has multiple links or buttons, and you want to click on a specific one.
Args:
link_text (str): The visible text of the button or link.
exact_match (bool): Whether to match the exact link text or any partial match.
"""
self.notifier.notify(f"Clicking element with text: {link_text}")
match_type = By.LINK_TEXT if exact_match else By.PARTIAL_LINK_TEXT
element = self.driver.find_element(match_type, link_text)
element.click()
@tool
def find_element_by_text_soup(self, text: str, filename: str) -> str:
"""Find an element containing the specified text using BeautifulSoup on HTML content stored in a file.
If not found, fallback to Shadow DOM search using PyShadow.
Args:
text (str): The text content to find within an element.
filename (str): The name of the file containing the HTML content.
"""
# Search using BeautifulSoup as previously implemented
try:
with open(filename, "r", encoding="utf-8") as file:
soup = BeautifulSoup(file, "html.parser")
element = soup.find(
lambda tag: (tag.string and text in tag.string)
or (tag.get_text() and text in tag.get_text())
or (tag.has_attr("title") and text in tag["title"])
or (tag.has_attr("alt") and text in tag["alt"])
or (tag.has_attr("aria-label") and text in tag["aria-label"])
)
if element:
self.notifier.notify(f"Element found with text: {text}")
return str(element)
except FileNotFoundError:
self.notifier.notify(f"File not found: {filename}")
return None
# Fallback: search using PyShadow
try:
shadow = Shadow(self.driver)
shadow_element = shadow.find_element_by_xpath(f"//*[contains(text(), '{text}')]")
if shadow_element:
self.notifier.notify(f"Element found in shadow DOM with text: {text}")
return shadow_element.get_attribute("outerHTML")
except Exception as e:
self.notifier.notify(f"Error searching in shadow DOM: {str(e)}")
self.notifier.notify(f"Element not found with text: {text} in either DOMs")
return None
@tool
def find_elements_of_type(self, tag_type: str, filename: str) -> list[str]:
"""Find all elements of a specific tag type using BeautifulSoup on HTML content stored in a file.
Args:
tag_type (str): The HTML tag type to search for.
filename (str): The name of the file containing the HTML content.
"""
elements_as_strings = []
try:
with open(filename, "r", encoding="utf-8") as file:
soup = BeautifulSoup(file, "html.parser")
elements = soup.find_all(tag_type)
elements_as_strings = [str(element) for element in elements]
self.notifier.notify(f"Found {len(elements_as_strings)} elements of type: {tag_type}")
except FileNotFoundError:
self.notifier.notify(f"File not found: {filename}")
return elements_as_strings
def __del__(self) -> None:
# Remove the entire session directory
if os.path.exists(self.session_dir):
try:
shutil.rmtree(self.session_dir)
self.notifier.notify(f"Removed browsing session directory: {self.session_dir}")
except OSError as e:
self.notifier.notify(f"Error removing session directory: {str(e)}")
if self.driver:
self.driver.quit()
# def get_default_browser_windows() -> str:
# try:
# with winreg.OpenKey(
# winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice"
# ) as key:
# prog_id, _ = winreg.QueryValueEx(key, "ProgId")
#
# with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, f"{prog_id}\\shell\\open\\command") as cmd_key:
# command, _ = winreg.QueryValueEx(cmd_key, None)
#
# if command.startswith('"'):
# executable = command.split('"')[1]
# else:
# executable = command.split(" ")[0]
#
# return os.path.basename(executable)
#
# except Exception as e:
# print(f"Error retrieving default browser on Windows: {e}")
# return None
def get_default_browser_macos() -> str:
try:
import os
import plistlib
plist_path = os.path.expanduser(
"~/Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist"
)
if not os.path.exists(plist_path):
print(f"Launch services plist not found at: {plist_path}")
return None
with open(plist_path, "rb") as fp:
plist = plistlib.load(fp)
handlers = plist.get("LSHandlers", [])
for handler in handlers:
scheme = handler.get("LSHandlerURLScheme")
if scheme and scheme.lower() == "http":
return handler.get("LSHandlerRoleAll")
return None
except Exception as e:
print(f"Error retrieving default browser on macOS: {e}")
return None
# def get_default_browser_linux() -> str:
# try:
# result = subprocess.run(
# ["xdg-settings", "get", "default-web-browser"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
# )
#
# if result.returncode != 0:
# print(f"Error: {result.stderr.strip()}")
# return None
#
# desktop_file = result.stdout.strip()
# desktop_paths = [
# os.path.expanduser("~/.local/share/applications/"),
# "/usr/share/applications/",
# "/usr/local/share/applications/",
# ]
#
# for path in desktop_paths:
# desktop_file_path = os.path.join(path, desktop_file)
# if os.path.exists(desktop_file_path):
# with open(desktop_file_path, "r") as f:
# for line in f:
# if line.startswith("Name="):
# name = line.split("=", 1)[1].strip()
# return name
# return desktop_file.replace(".desktop", "")
#
# except Exception as e:
# print(f"Error retrieving default browser on Linux: {e}")
# return None
def get_default_browser() -> str:
if sys.platform.startswith("darwin"):
return get_default_browser_macos()
# other platforms are not enabled yet.
# elif sys.platform.startswith("win"):
# return get_default_browser_windows()
# elif sys.platform.startswith("linux"):
# return get_default_browser_linux()
else:
print(f"Unsupported platform {sys.platform}")
return None
return None