ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
@@ -0,0 +1,824 @@
# coding: utf-8
# Copyright (c) 2025 inclusionAI.
import os
import traceback
import asyncio
import time
from typing import Tuple, Any
from examples.common.tools.tool_action import BrowserAction
from aworld.core.tool.action_factory import ActionFactory
from aworld.core.common import ActionModel, ActionResult, Observation
from examples.common.tools.browsers.util.dom import DOMElementNode
from aworld.logs.util import logger
from examples.common.tools.browsers.action.utils import DomUtil
from aworld.core.tool.action import ExecutableAction
from aworld.utils import import_packages
from aworld.models.llm import get_llm_model, call_llm_model
def get_page(**kwargs):
tool = kwargs.get("tool")
if tool is None:
page = kwargs.get('page')
else:
page = tool.page
return page
def get_browser(**kwargs):
tool = kwargs.get("tool")
if tool is None:
page = kwargs.get('browser')
else:
page = tool.context
return page
@ActionFactory.register(name=BrowserAction.GO_TO_URL.value.name,
desc=BrowserAction.GO_TO_URL.value.desc,
tool_name="browser")
class GotoUrl(ExecutableAction):
def act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.GO_TO_URL.value.name} action")
page = get_page(**kwargs)
if page is None:
logger.warning(f"{BrowserAction.GO_TO_URL.name} page is none")
return ActionResult(content="no page", keep=True), page
params = action.params
url = params.get("url")
if not url:
logger.warning("empty url, go to nothing.")
return ActionResult(content="empty url", keep=True), page
items = url.split('://')
if len(items) == 1:
if items[0][0] != '/':
url = "file://" + os.path.join(os.getcwd(), url)
page.goto(url)
page.wait_for_load_state()
msg = f'Navigated to {url}'
logger.info(msg)
return ActionResult(content=msg, keep=True), page
async def async_act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.GO_TO_URL.value.name} action")
page = get_page(**kwargs)
if page is None:
logger.warning(f"{BrowserAction.GO_TO_URL.name} page is none")
return ActionResult(content="no page", keep=True), page
url = action.params.get("url")
if not url:
logger.warning("empty url, go to nothing.")
return ActionResult(content="empty url", keep=True), page
items = url.split('://')
if len(items) == 1:
if items[0][0] != '/':
url = "file://" + os.path.join(os.getcwd(), url)
await page.goto(url)
await page.wait_for_load_state()
msg = f'Navigated to {url}'
logger.info(msg)
return ActionResult(content=msg, keep=True), page
@ActionFactory.register(name=BrowserAction.INPUT_TEXT.value.name,
desc=BrowserAction.INPUT_TEXT.value.desc,
tool_name="browser")
class InputText(ExecutableAction):
def act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.INPUT_TEXT.value.name} action")
page = get_page(**kwargs)
if page is None:
logger.warning(f"{BrowserAction.INPUT_TEXT.name} page is none")
return ActionResult(content="input text no page", keep=True), page
params = action.params
index = params.get("index", 0)
# compatible with int and str datatype
index = int(index)
input = params.get("text", "")
ob: Observation = kwargs.get("observation")
if not ob or index not in ob.dom_tree.element_map:
raise RuntimeError(f'Element index {index} does not exist')
if not input:
raise ValueError(f'No input to the page')
element_node = ob.dom_tree.element_map[index]
self.input_to_element(input, page, element_node)
msg = f'Input {input} into index {index}'
logger.info(f"action {msg}")
logger.debug(f'Element xpath: {element_node.xpath}')
return ActionResult(content=msg, keep=True), page
async def async_act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.INPUT_TEXT.value.name} action")
page = get_page(**kwargs)
if page is None:
logger.warning(f"{BrowserAction.INPUT_TEXT.name} page is none")
return ActionResult(content="input text no page", keep=True), page
params = action.params
index = params.get("index")
# compatible with int and str datatype
index = int(index)
input = params.get("text", "")
ob: Observation = kwargs.get("observation")
if not ob or index not in ob.dom_tree.element_map:
raise RuntimeError(f'Element index {index} does not exist')
if not input:
raise ValueError(f'No input to the page')
element_node = ob.dom_tree.element_map[index]
await self.async_input_to_element(input, page, element_node)
msg = f'Input {input} into index {index}'
logger.info(f"action {msg}")
logger.debug(f'Element xpath: {element_node.xpath}')
return ActionResult(content=msg, keep=True), page
def input_to_element(self, input: str, page, element_node: DOMElementNode):
try:
# Highlight before typing
# if element_node.highlight_index is not None:
# await self._update_state(focus_element=element_node.highlight_index)
element_handle = DomUtil.get_locate_element(page, element_node)
if element_handle is None:
raise RuntimeError(f'Element: {repr(element_node)} not found')
# Ensure element is ready for input
try:
element_handle.wait_for_element_state('stable', timeout=1000)
element_handle.scroll_into_view_if_needed(timeout=1000)
except Exception:
pass
# Get element properties to determine input method
is_contenteditable = element_handle.get_property('isContentEditable')
# Different handling for contenteditable vs input fields
if is_contenteditable.json_value():
element_handle.evaluate('el => el.textContent = ""')
element_handle.type(input, delay=5)
else:
element_handle.fill(input)
except Exception as e:
logger.warning(f'Failed to input text into element: {repr(element_node)}. Error: {str(e)}')
raise RuntimeError(f'Failed to input text into index {element_node.highlight_index}')
async def async_input_to_element(self, input: str, page, element_node: DOMElementNode):
try:
element_handle = await DomUtil.async_get_locate_element(page, element_node)
if element_handle is None:
raise RuntimeError(f'Element: {repr(element_node)} not found')
# Ensure element is ready for input
try:
await element_handle.wait_for_element_state('stable', timeout=1000)
await element_handle.scroll_into_view_if_needed(timeout=1000)
except Exception:
pass
# Get element properties to determine input method
is_contenteditable = await element_handle.get_property('isContentEditable')
# Different handling for contenteditable vs input fields
if await is_contenteditable.json_value():
await element_handle.evaluate('el => el.textContent = ""')
await element_handle.type(input, delay=5)
else:
await element_handle.fill(input)
except Exception as e:
logger.warning(f'Failed to input text into element: {repr(element_node)}. Error: {str(e)}')
raise RuntimeError(f'Failed to input text into index {element_node.highlight_index}')
@ActionFactory.register(name=BrowserAction.CLICK_ELEMENT.value.name,
desc=BrowserAction.CLICK_ELEMENT.value.desc,
tool_name="browser")
class ClickElement(ExecutableAction):
def __init__(self):
import_packages(['playwright', 'markdownify'])
def act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
from playwright.sync_api import BrowserContext
logger.info(f"exec {BrowserAction.CLICK_ELEMENT.value.name} action")
page = get_page(**kwargs)
if page is None:
logger.warning(f"{BrowserAction.CLICK_ELEMENT.name} page is none")
return ActionResult(content="input text no page", keep=True), page
browser: BrowserContext = get_browser(**kwargs)
if browser is None:
logger.warning(f"{BrowserAction.CLICK_ELEMENT.name} browser context is none")
return ActionResult(content="none browser context", keep=True), page
index = action.params.get("index")
# compatible with int and str datatype
index = int(index)
ob: Observation = kwargs.get("observation")
if not ob or index not in ob.dom_tree.element_map:
raise RuntimeError(f'Element index {index} does not exist')
if not input:
raise ValueError(f'No input to the page')
element_node = ob.dom_tree.element_map[index]
try:
pages = len(browser.pages)
msg = f'Clicked button with index {index}: {element_node.get_all_text_till_next_clickable_element(max_depth=2)}'
logger.info(msg)
DomUtil.click_element(page, element_node, browser=browser)
logger.debug(f'Element xpath: {element_node.xpath}')
if len(browser.pages) > pages:
new_tab_msg = 'Open the new tab'
msg += f' - {new_tab_msg}'
logger.info(new_tab_msg)
page = browser.pages[-1]
page.bring_to_front()
page.wait_for_load_state(timeout=60000)
return ActionResult(content=msg, keep=True), page
except Exception as e:
logger.warning(f'Element not clickable with index {index} - most likely the page changed')
return ActionResult(error=str(e)), page
async def async_act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.CLICK_ELEMENT.value.name} action")
page = get_page(**kwargs)
if page is None:
logger.warn(f"{BrowserAction.CLICK_ELEMENT.name} page is none")
return ActionResult(content="input text no page", keep=True), page
browser = get_browser(**kwargs)
if browser is None:
logger.warning(f"{BrowserAction.CLICK_ELEMENT.name} browser context is none")
return ActionResult(content="none browser context", keep=True), page
index = action.params.get("index")
# compatible with int and str datatype
index = int(index)
ob: Observation = kwargs.get("observation")
if not ob or index not in ob.dom_tree.element_map:
raise RuntimeError(f'Element index {index} does not exist')
if not input:
raise ValueError(f'No input to the page')
element_node = ob.dom_tree.element_map[index]
pages = len(browser.pages)
try:
await DomUtil.async_click_element(page, element_node, browser=browser)
msg = f'Clicked button with index {index}: {element_node.get_all_text_till_next_clickable_element(max_depth=2)}'
logger.info(msg)
logger.debug(f'Element xpath: {element_node.xpath}')
if len(browser.pages) > pages:
new_tab_msg = 'Open the new tab'
msg += f' - {new_tab_msg}'
logger.info(new_tab_msg)
page = browser.pages[-1]
await page.bring_to_front()
await page.wait_for_load_state(timeout=60000)
return ActionResult(content=msg, keep=True), page
except Exception as e:
logger.warning(f'Element not clickable with index {index} - most likely the page changed')
return ActionResult(error=str(e)), page
# SEARCH_ENGINE = {"": "https://www.google.com/search?udm=14&q=",
# "google": "https://www.google.com/search?udm=14&q="}
SEARCH_ENGINE = {"": "https://www.bing.com/search?q=",
"google": "https://www.bing.com/search?q="}
@ActionFactory.register(name=BrowserAction.SEARCH.value.name,
desc=BrowserAction.SEARCH.value.desc,
tool_name="browser")
class Search(ExecutableAction):
def act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.SEARCH.value.name} action")
page = get_page(**kwargs)
if page is None:
logger.warning(f"{BrowserAction.SEARCH.name} page is none")
return ActionResult(content="search no page", keep=True), page
params = action.params if action.params else {}
engine = params.get("engine", "")
url = SEARCH_ENGINE.get(engine)
query = params.get("query")
page.goto(f'{url}{query}')
page.wait_for_load_state()
msg = f'Searched for "{query}" in {url}'
logger.info(msg)
return ActionResult(content=msg, keep=True), page
async def async_act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.SEARCH.value.name} action")
page = get_page(**kwargs)
if page is None:
logger.warning(f"{BrowserAction.SEARCH.name} page is none")
return ActionResult(content="search no page", keep=True), page
params = action.params if action.params else {}
engine = params.get("engine", "")
url = SEARCH_ENGINE.get(engine)
query = params.get("query")
await page.goto(f'{url}{query}')
await page.wait_for_load_state()
msg = f'Searched for "{query}" in {url}'
logger.info(msg)
return ActionResult(content=msg, keep=True), page
@ActionFactory.register(name=BrowserAction.SEARCH_GOOGLE.value.name,
desc=BrowserAction.SEARCH_GOOGLE.value.desc,
tool_name="browser")
class SearchGoogle(ExecutableAction):
def act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.SEARCH_GOOGLE.value.name} action")
page = get_page(**kwargs)
if page is None:
logger.warning(f"{BrowserAction.SEARCH_GOOGLE.name} page is none")
return ActionResult(content="search no page", keep=True), page
query = action.params.get("query")
page.goto(f'{SEARCH_ENGINE.get("")}{query}')
page.wait_for_load_state()
msg = f'Searched for "{query}" in Google'
logger.info(msg)
return ActionResult(content=msg, keep=True), page
async def async_act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.SEARCH_GOOGLE.value.name} action")
page = get_page(**kwargs)
if page is None:
logger.warning(f"{BrowserAction.SEARCH_GOOGLE.name} page is none")
return ActionResult(content="search no page", keep=True), page
query = action.params.get("query")
await page.goto(f'{SEARCH_ENGINE.get("")}{query}')
await page.wait_for_load_state()
msg = f'Searched for "{query}" in Google'
logger.info(msg)
return ActionResult(content=msg, keep=True), page
@ActionFactory.register(name=BrowserAction.NEW_TAB.value.name,
desc=BrowserAction.NEW_TAB.value.desc,
tool_name="browser")
class NewTab(ExecutableAction):
def act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.NEW_TAB.value.name} action")
browser = get_browser(**kwargs)
url = action.params.get("url")
new_page = browser.new_page()
new_page.wait_for_load_state()
if url:
new_page.goto(url)
DomUtil.wait_for_stable_network(new_page)
msg = f'Opened new tab with {url}'
logger.debug(msg)
return ActionResult(content=msg, keep=True), new_page
async def async_act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.NEW_TAB.value.name} action")
browser = get_browser(**kwargs)
url = action.params.get("url")
new_page = await browser.new_page()
await new_page.wait_for_load_state()
if url:
await new_page.goto(url)
DomUtil.wait_for_stable_network(new_page)
msg = f'Opened new tab with {url}'
logger.debug(msg)
return ActionResult(content=msg, keep=True), get_page(**kwargs)
@ActionFactory.register(name=BrowserAction.GO_BACK.value.name,
desc=BrowserAction.GO_BACK.value.desc,
tool_name="browser")
class GoBack(ExecutableAction):
def act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.GO_BACK.value.name} action")
page = get_page(**kwargs)
if page is None:
logger.warning(f"{BrowserAction.GO_BACK.name} page is none")
return ActionResult(content="search no page", keep=True), page
page.go_back()
msg = 'Navigated back'
logger.info(msg)
return ActionResult(content=msg, keep=True), page
async def async_act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.GO_BACK.value.name} action")
page = get_page(**kwargs)
if page is None:
logger.warning(f"{BrowserAction.GO_BACK.name} page is none")
return ActionResult(content="search no page", keep=True), page
await page.go_back()
msg = 'Navigated back'
logger.info(msg)
return ActionResult(content=msg, keep=True), page
@ActionFactory.register(name=BrowserAction.EXTRACT_CONTENT.value.name,
desc=BrowserAction.EXTRACT_CONTENT.value.desc,
tool_name="browser")
class ExtractContent(ExecutableAction):
def act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
import markdownify
from langchain_core.prompts import PromptTemplate
logger.info(f"exec {BrowserAction.EXTRACT_CONTENT.value.name} action")
page = get_page(**kwargs)
if page is None:
logger.warning(f"{BrowserAction.EXTRACT_CONTENT.name} page is none")
return ActionResult(content="extract content no page", keep=True), page
goal = action.params.get("goal")
llm_config = kwargs.get("llm_config")
if llm_config and llm_config.llm_api_key:
llm = get_llm_model(llm_config)
max_extract_content_output_tokens = kwargs.get("max_extract_content_output_tokens")
max_extract_content_input_tokens = kwargs.get("max_extract_content_input_tokens")
content = markdownify.markdownify(page.content())
# Truncate content if it exceeds max input tokens
if max_extract_content_input_tokens and len(content) > max_extract_content_input_tokens:
logger.warning(
f"Content length ({len(content)}) exceeds max input tokens ({max_extract_content_input_tokens}). Truncating content.")
content = content[:max_extract_content_input_tokens]
prompt = 'Your task is to extract the content of the page. You will be given a page and a goal and you should extract all relevant information around this goal from the page. If the goal is vague, summarize the page. Respond in json format. Extraction goal: {goal}, Page: {page}'
prompt_with_outputlimit = 'Your task is to extract the content of the page. You will be given a page and a goal and you should extract all relevant information around this goal from the page. If the goal is vague, summarize the page. Respond in json format. Extraction goal: {goal}, Page: {page} \n\n#The length of the returned result must be less than {max_extract_content_output_tokens} characters.'
template = PromptTemplate(input_variables=['goal', 'page'], template=prompt)
messages = [{'role': 'user', 'content': template.format(goal=goal, page=content)}]
try:
output = call_llm_model(llm,
messages=messages,
model=llm_config.llm_model_name,
temperature=llm_config.llm_temperature)
result_content = output.content
# Check if output exceeds the token limit and retry with length-limited prompt if needed
if max_extract_content_output_tokens and len(result_content) > max_extract_content_output_tokens:
logger.warning(
f"Output exceeds maximum length ({len(result_content)} > {max_extract_content_output_tokens}). Retrying with limited prompt.")
template_with_limit = PromptTemplate(
input_variables=['goal', 'page', 'max_extract_content_output_tokens'],
template=prompt_with_outputlimit
)
messages = [{'role': 'user', 'content': template_with_limit.format(
goal=goal,
page=content,
max_extract_content_output_tokens=max_extract_content_output_tokens,
max_tokens=max_extract_content_output_tokens
)}]
# extract content with length limit
output = call_llm_model(llm,
messages=messages,
model=llm_config.llm_model_name,
temperature=llm_config.llm_temperature)
result_content = output.content
msg = f'Extracted from page\n: {result_content}\n'
logger.info(msg)
return ActionResult(content=msg, keep=True), page
except Exception as e:
logger.debug(f'Error extracting content: {e}')
msg = f'Extracted from page\n: {content}\n'
logger.info(msg)
return ActionResult(content=msg), page
async def async_act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
import markdownify
from langchain_core.prompts import PromptTemplate
logger.info(f"exec {BrowserAction.EXTRACT_CONTENT.value.name} action")
page = get_page(**kwargs)
if page is None:
logger.warning(f"{BrowserAction.EXTRACT_CONTENT.name} page is none")
return ActionResult(content="extract content no page", keep=True), page
goal = action.params.get("goal")
llm_config = kwargs.get("llm_config")
if llm_config and llm_config.llm_api_key:
llm = get_llm_model(llm_config)
content = markdownify.markdownify(await page.content())
max_extract_content_output_tokens = kwargs.get("max_extract_content_output_tokens")
max_extract_content_input_tokens = kwargs.get("max_extract_content_input_tokens")
# Truncate content if it exceeds max input tokens
if max_extract_content_input_tokens and len(content) > max_extract_content_input_tokens:
logger.warning(
f"Content length ({len(content)}) exceeds max input tokens ({max_extract_content_input_tokens}). Truncating content.")
content = content[:max_extract_content_input_tokens]
prompt = 'Your task is to extract the content of the page. You will be given a page and a goal and you should extract all relevant information around this goal from the page. If the goal is vague, summarize the page. Respond in json format. Extraction goal: {goal}, Page: {page}'
prompt_with_outputlimit = 'Your task is to extract the content of the page. You will be given a page and a goal and you should extract all relevant information around this goal from the page. If the goal is vague, summarize the page. Respond in json format. Extraction goal: {goal}, Page: {page} \n\n#The length of the returned result must be less than {max_extract_content_output_tokens} characters.'
template = PromptTemplate(input_variables=['goal', 'page'], template=prompt)
messages = [{'role': 'user', 'content': template.format(goal=goal, page=content)}]
try:
output = call_llm_model(llm,
messages=messages,
model=llm_config.llm_model_name,
temperature=llm_config.llm_temperature)
result_content = output.content
# Check if output exceeds the token limit and retry with length-limited prompt if needed
if max_extract_content_output_tokens and len(result_content) > max_extract_content_output_tokens:
logger.info(
f"Output exceeds maximum length ({len(result_content)} > {max_extract_content_output_tokens}). Retrying with limited prompt.")
template_with_limit = PromptTemplate(
input_variables=['goal', 'page', 'max_extract_content_output_tokens'],
template=prompt_with_outputlimit
)
messages = [{'role': 'user', 'content': template_with_limit.format(
goal=goal,
page=content,
max_extract_content_output_tokens=max_extract_content_output_tokens,
max_tokens=max_extract_content_output_tokens
)}]
# extract content with length limit
output = call_llm_model(llm,
messages=messages,
model=llm_config.llm_model_name,
temperature=llm_config.llm_temperature)
result_content = output.content
msg = f'Extracted from page\n: {result_content}\n'
logger.info(msg)
return ActionResult(content=msg, keep=True), page
except Exception as e:
logger.debug(f'Error extracting content: {e}')
msg = f'Extracted from page\n: {content}\n'
logger.info(msg)
return ActionResult(content=msg), page
@ActionFactory.register(name=BrowserAction.SCROLL_DOWN.value.name,
desc=BrowserAction.SCROLL_DOWN.value.desc,
tool_name="browser")
class ScrollDown(ExecutableAction):
def act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.SCROLL_DOWN.value.name} action")
page = get_page(**kwargs)
if page is None:
logger.warning(f"{BrowserAction.SCROLL_DOWN.name} page is none")
return ActionResult(content="scroll no page", keep=True), page
amount = action.params.get("amount")
if not amount:
page.evaluate('window.scrollBy(0, window.innerHeight);')
else:
amount = int(amount)
page.evaluate(f'window.scrollBy(0, {amount});')
amount = f'{amount} pixels' if amount else 'one page'
msg = f'Scrolled down the page by {amount}'
logger.info(msg)
return ActionResult(content=msg, keep=True), page
async def async_act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.SCROLL_DOWN.value.name} action")
page = get_page(**kwargs)
if page is None:
logger.warning(f"{BrowserAction.SCROLL_DOWN.name} page is none")
return ActionResult(content="scroll no page", keep=True), page
amount = action.params.get("amount")
if not amount:
await page.evaluate('window.scrollBy(0, window.innerHeight);')
else:
amount = int(amount)
await page.evaluate(f'window.scrollBy(0, {amount});')
amount = f'{amount} pixels' if amount else 'one page'
msg = f'Scrolled down the page by {amount}'
logger.info(msg)
return ActionResult(content=msg, keep=True), page
@ActionFactory.register(name=BrowserAction.SCROLL_UP.value.name,
desc=BrowserAction.SCROLL_UP.value.desc,
tool_name="browser")
class ScrollUp(ExecutableAction):
def act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.SCROLL_UP.value.name} action")
page = get_page(**kwargs)
if page is None:
logger.warning(f"{BrowserAction.SCROLL_UP.name} page is none")
return ActionResult(content="scroll no page", keep=True), page
amount = action.params.get("amount")
if not amount:
page.evaluate('window.scrollBy(0, -window.innerHeight);')
else:
amount = int(amount)
page.evaluate(f'window.scrollBy(0, -{amount});')
amount = f'{amount} pixels' if amount else 'one page'
msg = f'Scrolled down the page by {amount}'
logger.info(msg)
return ActionResult(content=msg, keep=True), page
async def async_act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.SCROLL_UP.value.name} action")
page = get_page(**kwargs)
if page is None:
logger.warning(f"{BrowserAction.SCROLL_UP.name} page is none")
return ActionResult(content="scroll no page", keep=True), page
amount = action.params.get("amount")
if not amount:
await page.evaluate('window.scrollBy(0, -window.innerHeight);')
else:
amount = int(amount)
await page.evaluate(f'window.scrollBy(0, -{amount});')
amount = f'{amount} pixels' if amount else 'one page'
msg = f'Scrolled down the page by {amount}'
logger.info(msg)
return ActionResult(content=msg, keep=True), page
@ActionFactory.register(name=BrowserAction.WAIT.value.name,
desc=BrowserAction.WAIT.value.desc,
tool_name="browser")
class Wait(ExecutableAction):
def act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
seconds = action.params.get("seconds")
if not seconds:
seconds = action.params.get("duration", 0)
seconds = int(seconds)
msg = f'Waiting for {seconds} seconds'
logger.info(msg)
time.sleep(seconds)
return ActionResult(content=msg, keep=True), kwargs.get('page')
async def async_act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
seconds = action.params.get("seconds")
if not seconds:
seconds = action.params.get("duration", 0)
seconds = int(seconds)
msg = f'Waiting for {seconds} seconds'
logger.info(msg)
await asyncio.sleep(seconds)
return ActionResult(content=msg, keep=True), kwargs.get('page')
@ActionFactory.register(name=BrowserAction.SWITCH_TAB.value.name,
desc=BrowserAction.SWITCH_TAB.value.desc,
tool_name="browser")
class SwitchTab(ExecutableAction):
def act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.SWITCH_TAB.value.name} action")
browser = get_browser(**kwargs)
if browser is None:
logger.warning(f"{BrowserAction.SWITCH_TAB.name} browser context is none")
return ActionResult(content="switch tab no browser context", keep=True), get_page(**kwargs)
page_id = action.params.get("page_id", 0)
page_id = int(page_id)
pages = browser.pages
if page_id >= len(pages):
raise RuntimeError(f'No tab found with page_id: {page_id}')
page = pages[page_id]
page.bring_to_front()
page.wait_for_load_state()
msg = f'Switched to tab {page_id}'
logger.info(msg)
return ActionResult(content=msg, keep=True), page
async def async_act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.SWITCH_TAB.value.name} action")
browser = get_browser(**kwargs)
if browser is None:
logger.warning(f"{BrowserAction.SWITCH_TAB.name} browser context is none")
return ActionResult(content="switch tab no browser context", keep=True), get_page(**kwargs)
page_id = action.params.get("page_id", 0)
page_id = int(page_id)
pages = browser.pages
if page_id >= len(pages):
raise RuntimeError(f'No tab found with page_id: {page_id}')
page = pages[page_id]
await page.bring_to_front()
await page.wait_for_load_state()
msg = f'Switched to tab {page_id}'
logger.info(msg)
return ActionResult(content=msg, keep=True), page
@ActionFactory.register(name=BrowserAction.SEND_KEYS.value.name,
desc=BrowserAction.SEND_KEYS.value.desc,
tool_name="browser")
class SendKeys(ExecutableAction):
def act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.SEND_KEYS.value.name} action")
page = get_page(**kwargs)
if page is None:
logger.warning(f"{BrowserAction.SEND_KEYS.name} page is none")
return ActionResult(content="scroll no page", keep=True), page
keys = action.params.get("keys")
if not keys:
return ActionResult(success=False, content="no keys", keep=True), page
try:
page.keyboard.press(keys)
except Exception as e:
logger.warning(f"{keys} press fail. \n{traceback.format_exc()}")
raise e
return ActionResult(content=f"Sent keys: {keys}", keep=True), page
async def async_act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.SEND_KEYS.value.name} action")
page = get_page(**kwargs)
if page is None:
logger.warning(f"{BrowserAction.SEND_KEYS.name} page is none")
return ActionResult(content="scroll no page", keep=True), page
keys = action.params.get("keys")
if not keys:
return ActionResult(success=False, content="no keys", keep=True), page
try:
await page.keyboard.press(keys)
except Exception as e:
logger.warning(f"{keys} press fail. \n{traceback.format_exc()}")
raise e
return ActionResult(content=f"Sent keys: {keys}", keep=True), page
@ActionFactory.register(name=BrowserAction.WRITE_TO_FILE.value.name,
desc=BrowserAction.WRITE_TO_FILE.value.desc,
tool_name="browser")
class WriteToFile(ExecutableAction):
def act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
# 设置默认文件路径
file_path = "tmp_result.md"
# 检查参数中是否有file_path
if "file_path" in action.params:
file_path = action.params.get("file_path", "tmp_result.md")
# 检查参数中是否有file_name
elif "file_name" in action.params:
file_path = action.params.get("file_name", "tmp_result.md")
elif "filename" in action.params:
file_path = action.params.get("filename", "tmp_result.md")
content = action.params.get("content", "")
mode = action.params.get("mode", "a") # Default to append mode
# 获取文件的绝对路径
abs_file_path = os.path.abspath(file_path)
try:
with open(file_path, mode, encoding='utf-8') as f:
f.write(content + '\n')
msg = f'Successfully wrote content to {abs_file_path}'
logger.info(msg)
return ActionResult(content=msg, keep=True), get_page(**kwargs)
except Exception as e:
error_msg = f'Failed to write to file {abs_file_path}: {str(e)}'
logger.error(error_msg)
return ActionResult(content=error_msg, keep=True, error=error_msg), get_page(**kwargs)
async def async_act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
# For file operations, we don't need to make this asynchronous
return self.act(action, **kwargs)
@ActionFactory.register(name=BrowserAction.DONE.value.name,
desc=BrowserAction.DONE.value.desc,
tool_name="browser")
class Done(ExecutableAction):
def act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.DONE.value.name} action")
return ActionResult(is_done=True, success=True, content="done", keep=True), get_page(**kwargs)
async def async_act(self, action: ActionModel, **kwargs) -> Tuple[ActionResult, Any]:
logger.info(f"exec {BrowserAction.DONE.value.name} action")
return ActionResult(is_done=True, success=True, content="done", keep=True), get_page(**kwargs)
@@ -0,0 +1,71 @@
# coding: utf-8
# Copyright (c) 2025 inclusionAI.
from typing import Tuple, List, Any
from aworld.core.tool.action_factory import ActionFactory
from aworld.core.common import ActionModel, ActionResult, Observation
from aworld.logs.util import logger
from aworld.core.tool.base import Tool, ToolActionExecutor
class BrowserToolActionExecutor(ToolActionExecutor):
def __init__(self, tool: Tool = None):
super(BrowserToolActionExecutor, self).__init__(tool)
def execute_action(self, actions: List[ActionModel], **kwargs) -> Tuple[
List[ActionResult], Any]:
"""Execute the specified browser action sequence by agent policy.
Args:
actions: Tool action sequence.
Returns:
Browser page and action result list.
"""
action_results = []
page = self.tool.page
for action in actions:
action_result, page = self._exec(action, **kwargs)
action_results.append(action_result)
return action_results, page
async def async_execute_action(self, actions: List[ActionModel], **kwargs) -> Tuple[
List[ActionResult], Any]:
"""Execute the specified browser action sequence by agent policy.
Args:
actions: Tool action sequence.
Returns:
Browser page and action result list.
"""
action_results = []
page = self.tool.page
for action in actions:
action_result, page = await self._async_exec(action, **kwargs)
action_results.append(action_result)
return action_results, page
def _exec(self, action_model: ActionModel, **kwargs):
action_name = action_model.action_name
if action_name not in ActionFactory:
raise ValueError(f'Action {action_name} not found')
action = ActionFactory(action_name)
action_result, page = action.act(action_model, page=self.tool.page, browser=self.tool.browser_context, **kwargs)
logger.info(f"{action_name} execute finished")
return action_result, page
async def _async_exec(self, action_model: ActionModel, **kwargs):
action_name = action_model.action_name
if action_name not in ActionFactory:
action_name = action_model.tool_name + action_model.action_name
if action_name not in ActionFactory:
raise ValueError(f'Action {action_name} not found')
action = ActionFactory(action_name)
action_result, page = await action.async_act(action_model, page=self.tool.page,
browser=self.tool.browser_context, **kwargs)
logger.info(f"{action_name} execute finished")
return action_result, page
@@ -0,0 +1,507 @@
# coding: utf-8
# Copyright (c) 2025 inclusionAI.
import re
import time
import traceback
from typing import Optional
from examples.common.tools.browsers.util.dom import DOMElementNode
from aworld.logs.util import logger
from aworld.utils import import_package
class DomUtil:
def __init__(self):
import_package("playwright")
@staticmethod
async def async_click_element(page, element_node: DOMElementNode, **kwargs) -> Optional[str]:
from playwright.async_api import ElementHandle as AElementHandle, BrowserContext as ABrowserContext
try:
element_handle: AElementHandle = await DomUtil.async_get_locate_element(page, element_node)
if element_handle is None:
raise Exception(f'Element: {repr(element_node)} not found')
bound = await element_handle.bounding_box()
try:
# todo: iframe.
center_x = bound['x'] + bound['width'] / 2
center_y = bound['y'] + bound['height'] / 2
try:
browser: ABrowserContext = kwargs.get('browser')
async with browser.expect_page() as new_page_info:
await page.mouse.click(center_x, center_y)
await page.mouse.click(center_x, center_y)
await page.wait_for_load_state()
except:
logger.warning(traceback.format_exc())
except:
logger.info(f"click {element_handle}!!")
if await element_handle.text_content():
browser: ABrowserContext = kwargs.get('browser')
if browser:
try:
async with browser.expect_page() as new_page_info:
await page.click(f"text={element_handle.text_content()}")
page = await new_page_info.value
await page.wait_for_load_state()
except:
logger.warning(traceback.format_exc())
else:
await element_handle.click()
await page.wait_for_load_state()
else:
await element_handle.click()
await page.wait_for_load_state()
except Exception as e:
logger.error(traceback.format_exc())
raise Exception(f'Failed to click element: {repr(element_node)}. Error: {str(e)}')
@staticmethod
def click_element(page, element_node: DOMElementNode, **kwargs) -> Optional[str]:
from playwright.sync_api import ElementHandle, BrowserContext
try:
element_handle: ElementHandle = DomUtil.get_locate_element(page, element_node)
if element_handle is None:
raise Exception(f'Element: {repr(element_node)} not found')
bound = element_handle.bounding_box()
try:
# todo: iframe.
center_x = bound['x'] + bound['width'] / 2
center_y = bound['y'] + bound['height'] / 2
try:
browser: BrowserContext = kwargs.get('browser')
with browser.expect_page() as new_page_info:
page.mouse.click(center_x, center_y)
page = new_page_info.value
page.wait_for_load_state()
except:
logger.warning(traceback.format_exc())
except:
logger.info(f"click {element_handle}!!")
if element_handle.text_content():
browser: BrowserContext = kwargs.get('browser')
if browser:
try:
with browser.expect_page() as new_page_info:
page.click(f"text={element_handle.text_content()}")
page = new_page_info.value
page.wait_for_load_state()
except:
logger.warning(traceback.format_exc())
else:
element_handle.click()
page.wait_for_load_state()
else:
element_handle.click()
page.wait_for_load_state()
except Exception as e:
logger.error(traceback.format_exc())
raise Exception(f'Failed to click element: {repr(element_node)}. Error: {str(e)}')
@staticmethod
async def async_get_locate_element(current_frame, element: DOMElementNode):
# Start with the target element and collect all parents, return Optional[AElementHandle]
from playwright.async_api import FrameLocator as AFrameLocator
parents: list[DOMElementNode] = []
current = element
while current.parent is not None:
parent = current.parent
parents.append(parent)
current = parent
# Reverse the parents list to process from top to bottom
parents.reverse()
# Process all iframe parents in sequence
iframes = [item for item in parents if item.tag_name == 'iframe']
for parent in iframes:
css_selector = DomUtil._enhanced_css_selector_for_element(
parent,
include_dynamic_attributes=True,
)
current_frame = current_frame.frame_locator(css_selector)
css_selector = DomUtil._enhanced_css_selector_for_element(
element, include_dynamic_attributes=True
)
try:
if isinstance(current_frame, AFrameLocator):
element_handle = await current_frame.locator(css_selector).element_handle()
return element_handle
else:
# Try to scroll into view if hidden
element_handle = await current_frame.query_selector(css_selector)
if element_handle:
await element_handle.scroll_into_view_if_needed()
return element_handle
return None
except Exception as e:
logger.error(f'Failed to locate element: {str(e)}')
return None
@staticmethod
def get_locate_element(current_frame, element: DOMElementNode):
# Start with the target element and collect all parents
from playwright.sync_api import FrameLocator
parents: list[DOMElementNode] = []
current = element
while current.parent is not None:
parent = current.parent
parents.append(parent)
current = parent
# Reverse the parents list to process from top to bottom
parents.reverse()
# Process all iframe parents in sequence
iframes = [item for item in parents if item.tag_name == 'iframe']
for parent in iframes:
css_selector = DomUtil._enhanced_css_selector_for_element(
parent,
include_dynamic_attributes=True,
)
current_frame = current_frame.frame_locator(css_selector)
css_selector = DomUtil._enhanced_css_selector_for_element(
element, include_dynamic_attributes=True
)
try:
if isinstance(current_frame, FrameLocator):
element_handle = current_frame.locator(css_selector).element_handle()
return element_handle
else:
# Try to scroll into view if hidden
element_handle = current_frame.query_selector(css_selector)
if element_handle:
element_handle.scroll_into_view_if_needed()
return element_handle
return None
except Exception as e:
logger.error(f'Failed to locate element: {str(e)}')
return None
@staticmethod
def wait_for_stable_network(page, **kwargs):
pending_requests = set()
last_activity = time.time()
# Define relevant resource types and content types
RELEVANT_RESOURCE_TYPES = {
'document',
'stylesheet',
'image',
'font',
'script',
'iframe',
}
RELEVANT_CONTENT_TYPES = {
'text/html',
'text/css',
'application/javascript',
'image/',
'font/',
'application/json',
}
# Additional patterns to filter out
IGNORED_URL_PATTERNS = {
# Analytics and tracking
'analytics',
'tracking',
'telemetry',
'beacon',
'metrics',
# Ad-related
'doubleclick',
'adsystem',
'adserver',
'advertising',
# Social media widgets
'facebook.com/plugins',
'platform.twitter',
'linkedin.com/embed',
# Live chat and support
'livechat',
'zendesk',
'intercom',
'crisp.chat',
'hotjar',
# Push notifications
'push-notifications',
'onesignal',
'pushwoosh',
# Background sync/heartbeat
'heartbeat',
'ping',
'alive',
# WebRTC and streaming
'webrtc',
'rtmp://',
'wss://',
# Common CDNs for dynamic content
'cloudfront.net',
'fastly.net',
}
def on_request(request):
# Filter by resource type
if request.resource_type not in RELEVANT_RESOURCE_TYPES:
return
# Filter out streaming, websocket, and other real-time requests
if request.resource_type in {
'websocket',
'media',
'eventsource',
'manifest',
'other',
}:
return
# Filter out by URL patterns
url = request.url.lower()
if any(pattern in url for pattern in IGNORED_URL_PATTERNS):
return
# Filter out data URLs and blob URLs
if url.startswith(('data:', 'blob:')):
return
# Filter out requests with certain headers
headers = request.headers
if headers.get('purpose') == 'prefetch' or headers.get('sec-fetch-dest') in [
'video',
'audio',
]:
return
nonlocal last_activity
pending_requests.add(request)
last_activity = time.time()
def on_response(response):
request = response.request
if request not in pending_requests:
return
# Filter by content type if available
content_type = response.headers.get('content-type', '').lower()
# Skip if content type indicates streaming or real-time data
if any(t in content_type
for t in [
'streaming',
'video',
'audio',
'webm',
'mp4',
'event-stream',
'websocket',
'protobuf']):
pending_requests.remove(request)
return
# Only process relevant content types
if not any(ct in content_type for ct in RELEVANT_CONTENT_TYPES):
pending_requests.remove(request)
return
# Skip if response is too large (likely not essential for page load)
content_length = response.headers.get('content-length')
if content_length and int(content_length) > 5 * 1024 * 1024: # 5MB
pending_requests.remove(request)
return
nonlocal last_activity
pending_requests.remove(request)
last_activity = time.time()
# Attach event listeners
page.on('request', on_request)
page.on('response', on_response)
try:
start_time = time.time()
while True:
time.sleep(0.1)
now = time.time()
if len(pending_requests) == 0 and (now - last_activity) >= kwargs.get('idle_wait_time', 0.5):
break
if now - start_time > kwargs.get('max_wait_time', 5):
logger.debug(
f'Network timeout after {kwargs.get("max_wait_time", 5)}s with {len(pending_requests)} '
f'pending requests: {[r.url for r in pending_requests]}'
)
break
finally:
# Clean up event listeners
page.remove_listener('request', on_request)
page.remove_listener('response', on_response)
logger.debug(f'Network stabilized for {kwargs.get("idle_wait_time", 0.5)} seconds')
@staticmethod
def _enhanced_css_selector_for_element(element: DOMElementNode, include_dynamic_attributes: bool = True) -> str:
"""Creates a CSS selector for a DOM element, handling various edge cases and special characters.
Args:
element: The DOM element to create a selector for
Returns:
A valid CSS selector string
"""
try:
# Get base selector from XPath
css_selector = DomUtil._convert_simple_xpath_to_css_selector(element.xpath)
# Handle class attributes
if 'class' in element.attributes and element.attributes['class'] and include_dynamic_attributes:
# Define a regex pattern for valid class names in CSS
valid_class_name_pattern = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_-]*$')
# Iterate through the class attribute values
classes = element.attributes['class'].split()
for class_name in classes:
# Skip empty class names
if not class_name.strip():
continue
# Check if the class name is valid
if valid_class_name_pattern.match(class_name):
# Append the valid class name to the CSS selector
css_selector += f'.{class_name}'
else:
# Skip invalid class names
continue
# Expanded set of safe attributes that are stable and useful for selection
SAFE_ATTRIBUTES = {
# Data attributes (if they're stable in your application)
'id',
# Standard HTML attributes
'name',
'type',
'placeholder',
# Accessibility attributes
'aria-label',
'aria-labelledby',
'aria-describedby',
'role',
# Common form attributes
'for',
'autocomplete',
'required',
'readonly',
# Media attributes
'alt',
'title',
'src',
# Custom stable attributes (add any application-specific ones)
'href',
'target',
}
if include_dynamic_attributes:
dynamic_attributes = {
'data-id',
'data-qa',
'data-cy',
'data-testid',
}
SAFE_ATTRIBUTES.update(dynamic_attributes)
# Handle other attributes
for attribute, value in element.attributes.items():
if attribute == 'class':
continue
# Skip invalid attribute names
if not attribute.strip():
continue
if attribute not in SAFE_ATTRIBUTES:
continue
# Escape special characters in attribute names
safe_attribute = attribute.replace(':', r'\:')
# Handle different value cases
if value == '':
css_selector += f'[{safe_attribute}]'
elif any(char in value for char in '"\'<>`\n\r\t'):
# Use contains for values with special characters
# Regex-substitute *any* whitespace with a single space, then strip.
collapsed_value = re.sub(r'\s+', ' ', value).strip()
# Escape embedded double-quotes.
safe_value = collapsed_value.replace('"', '\\"')
css_selector += f'[{safe_attribute}*="{safe_value}"]'
else:
css_selector += f'[{safe_attribute}="{value}"]'
return css_selector
except Exception:
# Fallback to a more basic selector if something goes wrong
tag_name = element.tag_name or '*'
return f"{tag_name}[highlight_index='{element.highlight_index}']"
@staticmethod
def _convert_simple_xpath_to_css_selector(xpath: str) -> str:
"""Converts simple XPath expressions to CSS selectors."""
if not xpath:
return ''
# Remove leading slash if present
xpath = xpath.lstrip('/')
# Split into parts
parts = xpath.split('/')
css_parts = []
for part in parts:
if not part:
continue
# Handle index notation [n]
if '[' in part:
base_part = part[: part.find('[')]
index_part = part[part.find('['):]
# Handle multiple indices
indices = [i.strip('[]') for i in index_part.split(']')[:-1]]
for idx in indices:
try:
# Handle numeric indices
if idx.isdigit():
index = int(idx) - 1
base_part += f':nth-of-type({index + 1})'
# Handle last() function
elif idx == 'last()':
base_part += ':last-of-type'
# Handle position() functions
elif 'position()' in idx:
if '>1' in idx:
base_part += ':nth-of-type(n+2)'
except ValueError:
continue
css_parts.append(base_part)
else:
css_parts.append(part)
base_selector = ' > '.join(css_parts)
return base_selector
@@ -0,0 +1,361 @@
# coding: utf-8
# Copyright (c) 2025 inclusionAI.
import asyncio
import base64
import json
import os
import subprocess
import traceback
from importlib import resources
from pathlib import Path
from typing import Any, Dict, Tuple, List
from examples.common.tools.common import package
from examples.common.tools.tool_action import BrowserAction
from aworld.core.common import Observation, ActionModel, ActionResult
from aworld.logs.util import logger
from aworld.core.tool.base import action_executor, ToolFactory, AsyncTool
from aworld.utils.import_package import is_package_installed
from examples.common.tools.browsers.action.executor import BrowserToolActionExecutor
from examples.common.tools.browsers.util.dom import DomTree
from examples.common.tools.conf import BrowserToolConfig
from examples.common.tools.browsers.util.dom_build import async_build_dom_tree
from aworld.utils import import_package
from aworld.tools.utils import build_observation
URL_MAX_LENGTH = 4096
UTF8 = "".join(chr(x) for x in range(0, 55290))
ASCII = "".join(chr(x) for x in range(32, 128))
@ToolFactory.register(name="browser",
desc="browser",
asyn=True,
supported_action=BrowserAction,
conf_file_name=f'browser_tool.yaml',
dir=f"{Path(__file__).parent.absolute()}")
class BrowserTool(AsyncTool):
def __init__(self, conf: BrowserToolConfig, **kwargs) -> None:
super(BrowserTool, self).__init__(conf)
self.initialized = False
self._finish = False
self.record_trace = self.conf.get("working_dir", False)
self.sleep_after_init = self.conf.get("sleep_after_init", False)
dom_js_path = self.conf.get('dom_js_path')
if dom_js_path and os.path.exists(dom_js_path):
with open(dom_js_path, 'r') as read:
self.js_code = read.read()
else:
self.js_code = resources.read_text(f'{package}.browsers.script',
'buildDomTree.js')
self.cur_observation = None
if not is_package_installed('playwright'):
import_package("playwright")
logger.info("playwright install...")
try:
subprocess.check_call('playwright install', shell=True, timeout=300)
except Exception as e:
logger.error(f"Fail to auto execute playwright install, you can install manually\n {e}")
async def init(self) -> None:
from playwright.async_api import async_playwright
if self.initialized:
return
self.context_manager = async_playwright()
self.playwright = await self.context_manager.start()
self.browser = await self._create_browser()
self.browser_context = await self._create_browser_context()
if self.record_trace:
await self.browser_context.tracing.start(screenshots=True, snapshots=True)
self.page = await self.browser_context.new_page()
if self.conf.get("custom_executor"):
self.action_executor = BrowserToolActionExecutor(self)
else:
self.action_executor = action_executor
self.initialized = True
async def _create_browser(self):
browse_name = self.conf.get("browse_name", "chromium")
browse = getattr(self.playwright, browse_name)
cdp_url = self.conf.get("cdp_url")
wss_url = self.conf.get("wss_url")
if cdp_url:
if browse_name != "chromium":
logger.warning(f"{browse_name} unsupported CDP, will use chromium browser")
browse = self.playwright.chromium
logger.info(f"Connecting to remote browser via CDP {cdp_url}")
browser = await browse.connect_over_cdp(cdp_url)
elif wss_url:
logger.info(f"Connecting to remote browser via wss {wss_url}")
browser = await browse.connect(wss_url)
else:
headless = self.conf.get("headless", False)
slow_mo = self.conf.get("slow_mo", 0)
disable_security_args = []
if self.conf.get('disable_security', False):
disable_security_args = ['--disable-web-security',
'--disable-site-isolation-trials',
'--disable-features=IsolateOrigins,site-per-process']
args = ['--no-sandbox',
'--disable-crash-reporte',
'--disable-blink-features=AutomationControlled',
'--disable-infobars',
'--disable-background-timer-throttling',
'--disable-popup-blocking',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--disable-window-activation',
'--disable-focus-on-load',
'--no-first-run',
'--no-default-browser-check',
'--no-startup-window',
'--window-position=0,0',
'--window-size=1280,720'] + disable_security_args
browser = await browse.launch(
headless=headless,
slow_mo=slow_mo,
args=args,
proxy=self.conf.get('proxy'),
)
return browser
async def _create_browser_context(self):
"""Creates a new browser context with anti-detection measures and loads cookies if available."""
from playwright.async_api import ViewportSize
browser = self.browser
if self.conf.get("cdp_url") and len(browser.contexts) > 0:
context = browser.contexts[0]
else:
viewport_size = ViewportSize(width=self.conf.get("width", 1280),
height=self.conf.get("height", 720))
disable_security = self.conf.get('disable_security', False)
context = await browser.new_context(viewport=viewport_size,
no_viewport=False,
user_agent=self.conf.get('user_agent'),
java_script_enabled=True,
bypass_csp=disable_security,
ignore_https_errors=disable_security,
record_video_dir=self.conf.get('working_dir'),
record_video_size=viewport_size,
locale=self.conf.get('locale'),
storage_state=self.conf.get("storage_state", None),
geolocation=self.conf.get("geolocation", None),
device_scale_factor=1)
if "chromium" == self.conf.get("browse_name", "chromium"):
await context.grant_permissions(['camera', 'microphone'])
if self.conf.get('trace_path'):
await context.tracing.start(screenshots=True, snapshots=True, sources=True)
cookie_file = self.conf.get('cookies_file')
if cookie_file and os.path.exists(cookie_file):
with open(cookie_file, 'r') as read:
cookies = json.loads(read.read())
await context.add_cookies(cookies)
logger.info(f'Cookies load from {cookie_file} finished')
if self.conf.get('private'):
js = resources.read_text(f"{package}.browsers.script", "stealth.min.js")
await context.add_init_script(js)
return context
async def get_cur_page(self):
return self.page
async def screenshot(self, full_page: bool = False) -> str:
"""Returns a base64 encoded screenshot of the current page.
Args:
full_page: When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport.
Returns:
Base64 of the page screenshot
"""
page = await self.get_cur_page()
try:
await page.bring_to_front()
await page.wait_for_load_state(timeout=2000)
except:
logger.warning("bring to front load timeout")
pass
screenshot = await page.screenshot(
full_page=full_page,
animations='disabled',
timeout=600000
)
logger.info("page screenshot finished")
screenshot_base64 = base64.b64encode(screenshot).decode('utf-8')
return screenshot_base64
async def _get_observation(self, info: Dict[str, Any] = {}) -> Observation:
fail_error = info.get('exception')
if fail_error:
return Observation(observer=self.name(), action_result=[ActionResult(error=fail_error)])
try:
dom_tree = await self._parse_dom_tree()
image = await self.screenshot()
pixels_above, pixels_below = await self._scroll_info()
info.update({"pixels_above": pixels_above,
"pixels_below": pixels_below,
"url": self.page.url})
return Observation(observer=self.name(), dom_tree=dom_tree, image=image, info=info)
except Exception as e:
try:
try:
await self.page.go_back()
except:
logger.warning("current page abnormal, new page to use.")
self.page = await self.browser_context.new_page()
dom_tree = await self._parse_dom_tree()
image = await self.screenshot()
pixels_above, pixels_below = await self._scroll_info()
info.update({"pixels_above": pixels_above,
"pixels_below": pixels_below,
"url": self.page.url})
return Observation(observer=self.name(), dom_tree=dom_tree, image=image, info=info)
except Exception as e:
logger.warning(f"build observation fail, {traceback.format_exc()}")
return Observation(observer=self.name(), action_result=[ActionResult(error=traceback.format_exc())])
async def _parse_dom_tree(self) -> DomTree:
args = {
'doHighlightElements': self.conf.get("do_highlight", True),
'focusHighlightIndex': self.conf.get("focus_highlight", -1),
'viewportExpansion': self.conf.get("viewport_expansion", 0),
'debugMode': logger.getEffectiveLevel() == 10,
}
element_tree, element_map = await async_build_dom_tree(self.page, self.js_code, args)
return DomTree(element_tree=element_tree, element_map=element_map)
async def _scroll_info(self) -> tuple[int, int]:
"""Get scroll position information for the current page."""
scroll_y = await self.page.evaluate('window.scrollY')
viewport_height = await self.page.evaluate('window.innerHeight')
total_height = await self.page.evaluate('document.documentElement.scrollHeight')
pixels_above = scroll_y
pixels_below = total_height - (scroll_y + viewport_height)
return pixels_above, pixels_below
async def reset(self, *, seed: int | None = None, options: Dict[str, str] | None = None) -> Tuple[
Observation, Dict[str, Any]]:
await super().reset(seed=seed, options=options)
if self.initialized:
observation = await self._get_observation()
observation.action_result = [ActionResult(content='start', keep=True)]
self.cur_observation = observation
return observation, {}
await self.close()
await self.init()
if self.sleep_after_init > 0:
await asyncio.sleep(self.sleep_after_init)
observation = await self._get_observation()
observation.action_result = [ActionResult(content='start', keep=True)]
observation.ability = ''
self.cur_observation = observation
return observation, {}
async def save_trace(self, trace_path: str | Path) -> None:
if self.record_trace:
await self.browser_context.tracing.stop(path=trace_path)
@property
async def finished(self) -> bool:
return self._finish
async def close(self) -> None:
if hasattr(self, 'context') and self.browser_context:
await self.browser_context.close()
if hasattr(self, 'browser') and self.browser:
await self.browser.close()
if hasattr(self, 'playwright') and self.playwright:
await self.playwright.stop()
if self.initialized:
await self.context_manager.__aexit__()
async def do_step(self, action: List[ActionModel], **kwargs) -> Tuple[
Observation, float, bool, bool, Dict[str, Any]]:
if not self.initialized:
raise RuntimeError("Call init first before calling step.")
if not action:
logger.warning(f"{self.name()} has no action")
return build_observation(observer=self.name(), ability='', content='no action'), 0., False, False, {}
reward = 0
fail_error = ""
action_result = None
invalid_acts: List[int] = []
for i, act in enumerate(action):
if act.tool_name != 'browser':
logger.warning(f"tool {act.tool_name} is not a browser!")
invalid_acts.append(i)
if invalid_acts:
for i in invalid_acts:
action[i] = None
try:
action_result, self.page = await self.action_executor.async_execute_action(action,
observation=self.cur_observation,
llm_config=self.conf.llm_config,
**kwargs)
reward = 1
except Exception as e:
fail_error = str(e)
info = {"exception": fail_error}
terminated = kwargs.get("terminated", False)
for res in action_result:
if res.is_done:
terminated = res.is_done
info['done'] = True
self._finish = True
if res.error:
fail_error += res.error
contains_write_to_file = any(act.action_name == BrowserAction.WRITE_TO_FILE.value.name for act in action if act)
if contains_write_to_file:
msg = ""
for action_result_elem in action_result:
msg = action_result_elem.content
# write_to_file observation
return (Observation(content=msg, action_result=action_result, info=info),
reward,
terminated,
kwargs.get("truncated", False),
info)
elif fail_error:
# failed error observation
return (Observation(action_result=action_result, observer=self.name()),
reward,
terminated,
kwargs.get("truncated", False),
info)
else:
# normal observation
observation = await self._get_observation(info)
observation.action_result = action_result
observation.ability = action[-1].action_name
self.cur_observation = observation
return (observation,
reward,
terminated,
kwargs.get("truncated", False),
info)
@@ -0,0 +1,368 @@
# coding: utf-8
# Copyright (c) 2025 inclusionAI.
import base64
import json
import os
import subprocess
import time
import traceback
from importlib import resources
from pathlib import Path
from typing import Any, Dict, Tuple, List, Union
from aworld.config import ConfigDict
from examples.common.tools.common import package
from examples.common.tools.tool_action import BrowserAction
from aworld.core.common import Observation, ActionModel, ActionResult
from aworld.logs.util import logger
from aworld.core.tool.base import action_executor, ToolFactory
from aworld.core.tool.base import Tool
from aworld.utils.import_package import is_package_installed
from examples.common.tools.browsers.action.executor import BrowserToolActionExecutor
from examples.common.tools.browsers.util.dom import DomTree
from examples.common.tools.conf import BrowserToolConfig
from examples.common.tools.browsers.util.dom_build import build_dom_tree
from aworld.utils import import_package
from aworld.tools.utils import build_observation
URL_MAX_LENGTH = 4096
UTF8 = "".join(chr(x) for x in range(0, 55290))
ASCII = "".join(chr(x) for x in range(32, 128))
BROWSER = "browser"
@ToolFactory.register(name=BROWSER,
desc="browser",
supported_action=BrowserAction,
conf_file_name=f'browser_tool.yaml',
dir=f"{Path(__file__).parent.absolute()}")
class BrowserTool(Tool):
def __init__(self, conf: Union[ConfigDict, BrowserToolConfig], **kwargs) -> None:
super(BrowserTool, self).__init__(conf, **kwargs)
self.initialized = False
self._finish = False
self.record_trace = self.conf.get("enable_recording", False)
self.sleep_after_init = self.conf.get("sleep_after_init", False)
dom_js_path = self.conf.get('dom_js_path')
if dom_js_path and os.path.exists(dom_js_path):
with open(dom_js_path, 'r') as read:
self.js_code = read.read()
else:
self.js_code = resources.read_text(f'{package}.browsers.script',
'buildDomTree.js')
self.cur_observation = None
if not is_package_installed('playwright'):
import_package("playwright")
logger.info("playwright install...")
try:
subprocess.check_call('playwright install', shell=True, timeout=300)
except Exception as e:
logger.error(f"Fail to auto execute playwright install, you can install manually\n {e}")
def init(self) -> None:
from playwright.sync_api import sync_playwright
if self.initialized:
return
self.context_manager = sync_playwright()
self.playwright = self.context_manager.start()
self.browser = self._create_browser()
self.browser_context = self._create_browser_context()
if self.record_trace:
self.browser_context.tracing.start(screenshots=True, snapshots=True)
self.page = self.browser_context.new_page()
if self.conf.get("custom_executor"):
self.action_executor = BrowserToolActionExecutor(self)
else:
self.action_executor = action_executor
self.initialized = True
def _create_browser(self):
browse_name = self.conf.get("browse_name", "chromium")
browse = getattr(self.playwright, browse_name)
cdp_url = self.conf.get("cdp_url")
wss_url = self.conf.get("wss_url")
if cdp_url:
if browse_name != "chromium":
logger.warning(f"{browse_name} unsupported CDP, will use chromium browser")
browse = self.playwright.chromium
logger.info(f"Connecting to remote browser via CDP {cdp_url}")
browser = browse.connect_over_cdp(cdp_url)
elif wss_url:
logger.info(f"Connecting to remote browser via wss {wss_url}")
browser = browse.connect(wss_url)
else:
headless = self.conf.get("headless", False)
slow_mo = self.conf.get("slow_mo", 0)
disable_security_args = []
if self.conf.get('disable_security', False):
disable_security_args = ['--disable-web-security',
'--disable-site-isolation-trials',
'--disable-features=IsolateOrigins,site-per-process']
args = ['--no-sandbox',
'--disable-crash-reporte',
'--disable-blink-features=AutomationControlled',
'--disable-infobars',
'--disable-background-timer-throttling',
'--disable-popup-blocking',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--disable-window-activation',
'--disable-focus-on-load',
'--no-first-run',
'--no-default-browser-check',
'--no-startup-window',
'--window-position=0,0',
'--window-size=1280,720'] + disable_security_args
browser = browse.launch(
headless=headless,
slow_mo=slow_mo,
args=args,
proxy=self.conf.get('proxy'),
)
return browser
def _create_browser_context(self):
"""Creates a new browser context with anti-detection measures and loads cookies if available."""
from playwright.sync_api import ViewportSize
browser = self.browser
if self.conf.get("cdp_url") and len(browser.contexts) > 0:
context = browser.contexts[0]
else:
viewport_size = ViewportSize(width=self.conf.get("width", 1280),
height=self.conf.get("height", 720))
disable_security = self.conf.get('disable_security', False)
context = browser.new_context(viewport=viewport_size,
no_viewport=False,
user_agent=self.conf.get('user_agent'),
java_script_enabled=True,
bypass_csp=disable_security,
ignore_https_errors=disable_security,
record_video_dir=self.conf.get('working_dir'),
record_video_size=viewport_size,
locale=self.conf.get('locale'),
storage_state=self.conf.get("storage_state", None),
geolocation=self.conf.get("geolocation", None),
device_scale_factor=1)
if "chromium" == self.conf.get("browse_name", "chromium"):
context.grant_permissions(['camera', 'microphone'])
if self.conf.get('working_dir'):
context.tracing.start(screenshots=True, snapshots=True, sources=True)
cookie_file = self.conf.get('cookies_file')
if cookie_file and os.path.exists(cookie_file):
with open(cookie_file, 'r') as read:
cookies = json.loads(read.read())
context.add_cookies(cookies)
logger.info(f'Cookies load from {cookie_file} finished')
if self.conf.get('private'):
js = resources.read_text(f"{package}.browsers.script", "stealth.min.js")
context.add_init_script(js)
return context
def get_cur_page(self):
return self.page
def screenshot(self, full_page: bool = False) -> str:
"""Returns a base64 encoded screenshot of the current page.
Args:
full_page: When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport.
Returns:
Base64 of the page screenshot
"""
page = self.get_cur_page()
try:
page.bring_to_front()
page.wait_for_load_state(timeout=2000)
except:
logger.warning("bring to front load timeout")
pass
screenshot = page.screenshot(
full_page=full_page,
animations='disabled',
timeout=600000
)
logger.info("page screenshot finished")
screenshot_base64 = base64.b64encode(screenshot).decode('utf-8')
return screenshot_base64
def _get_observation(self, info: Dict[str, Any] = {}) -> Observation:
fail_error = info.get('exception')
if fail_error:
return Observation(observer=self.name(), action_result=[ActionResult(error=fail_error)])
try:
dom_tree = self._parse_dom_tree()
image = self.screenshot()
pixels_above, pixels_below = self._scroll_info()
info.update({"pixels_above": pixels_above,
"pixels_below": pixels_below,
"url": self.page.url})
return Observation(observer=self.name(),
dom_tree=dom_tree,
image=image,
info=info)
except Exception as e:
try:
self.page.go_back()
except:
logger.warning("current page abnormal, new page to use.")
self.page = self.browser_context.new_page()
try:
dom_tree = self._parse_dom_tree()
image = self.screenshot()
pixels_above, pixels_below = self._scroll_info()
info.update({"pixels_above": pixels_above,
"pixels_below": pixels_below,
"url": self.page.url})
return Observation(observer=self.name(), dom_tree=dom_tree, image=image, info=info)
except Exception as e:
logger.warning(f"build observation fail, {traceback.format_exc()}")
return Observation(observer=self.name(), action_result=[ActionResult(error=traceback.format_exc())])
def _parse_dom_tree(self) -> DomTree:
args = {
'doHighlightElements': self.conf.get("do_highlight", True),
'focusHighlightIndex': self.conf.get("focus_highlight", -1),
'viewportExpansion': self.conf.get("viewport_expansion", 0),
'debugMode': logger.getEffectiveLevel() == 10,
}
element_tree, element_map = build_dom_tree(self.page, self.js_code, args)
return DomTree(element_tree=element_tree, element_map=element_map)
def _scroll_info(self) -> tuple[int, int]:
"""Get scroll position information for the current page."""
scroll_y = self.page.evaluate('window.scrollY')
viewport_height = self.page.evaluate('window.innerHeight')
total_height = self.page.evaluate('document.documentElement.scrollHeight')
pixels_above = scroll_y
pixels_below = total_height - (scroll_y + viewport_height)
return pixels_above, pixels_below
def reset(self, *, seed: int | None = None, options: Dict[str, str] | None = None) -> Tuple[
Observation, Dict[str, Any]]:
super().reset(seed=seed, options=options)
if self.initialized:
observation = self._get_observation()
observation.action_result = [ActionResult(content='start', keep=True)]
self.cur_observation = observation
return observation, {}
self.close()
self.init()
if self.sleep_after_init > 0:
time.sleep(self.sleep_after_init)
observation = self._get_observation()
observation.action_result = [ActionResult(content='start', keep=True)]
self.cur_observation = observation
return observation, {}
@property
def finished(self) -> bool:
return self._finish
def save_trace(self, trace_path: str | Path) -> None:
if self.record_trace:
self.browser_context.tracing.stop(path=trace_path)
def close(self) -> None:
if hasattr(self, 'context') and self.browser_context:
self.browser_context.close()
if hasattr(self, 'browser') and self.browser:
self.browser.close()
if hasattr(self, 'playwright') and self.playwright:
self.playwright.stop()
if self.initialized:
self.context_manager.__exit__()
def do_step(self, action: List[ActionModel], **kwargs) -> Tuple[
Observation, float, bool, bool, Dict[str, Any]]:
if not self.initialized:
raise RuntimeError("Call init first before calling step.")
if not action:
logger.warning(f"{self.name()} has no action")
return build_observation(observer=self.name(), ability='', content='no action'), 0., False, False, {}
reward = 0
fail_error = ""
action_result = None
invalid_acts: List[int] = []
for i, act in enumerate(action):
if act.tool_name != BROWSER:
logger.warning(f"tool {act.tool_name} is not a browser!")
invalid_acts.append(i)
if invalid_acts:
for i in invalid_acts:
action[i] = None
try:
action_result, self.page = self.action_executor.execute_action(action,
observation=self.cur_observation,
llm_config=self.conf.llm_config,
**kwargs)
reward = 1
except Exception as e:
fail_error = str(e)
info = {"exception": fail_error}
terminated = kwargs.get("terminated", False)
if action_result:
for res in action_result:
if res.is_done:
terminated = res.is_done
info['done'] = True
self._finish = True
if res.error:
fail_error += res.error
contains_write_to_file = any(act.action_name == BrowserAction.WRITE_TO_FILE.value.name for act in action if act)
if contains_write_to_file:
msg = ""
for action_result_elem in action_result:
msg = action_result_elem.content
# write_to_file observation
return (Observation(content=msg, action_result=action_result, info=info),
reward,
terminated,
kwargs.get("truncated", False),
info)
elif fail_error:
# failed error observation
return (Observation(action_result=action_result, observer=self.name()),
reward,
terminated,
kwargs.get("truncated", False),
info)
else:
# normal observation
observation = self._get_observation(info)
observation.ability = action[-1].action_name
observation.action_result = action_result
self.cur_observation = observation
return (observation,
reward,
terminated,
kwargs.get("truncated", False),
info)
@@ -0,0 +1,24 @@
browse_name: chromium
headless: False
width: 1280
height: 720
slow_mo: 0
disable_security: False
custom_executor: False
dom_js_path:
private:
locale:
geolocation:
storage_state:
do_highlight: True
focus_highlight: -1
viewport_expansion: 0
cdp_url:
wss_url:
proxy:
cookies_file:
working_dir:
enable_recording: False
sleep_after_init: 0
max_retry: 3
reuse: True
@@ -0,0 +1,2 @@
playwright
markdownify
File diff suppressed because one or more lines are too long
@@ -0,0 +1,210 @@
# coding: utf-8
from dataclasses import dataclass
from typing import Optional, Dict, List
from pydantic import BaseModel
class Coordinates(BaseModel):
x: int
y: int
class CoordinateSet(BaseModel):
top_left: Coordinates
top_right: Coordinates
bottom_left: Coordinates
bottom_right: Coordinates
center: Coordinates
width: int
height: int
class ViewportInfo(BaseModel):
width: int
height: int
@dataclass
class HashedDomElement:
"""
Hash of the dom element to be used as a unique identifier
"""
branch_path_hash: str
attributes_hash: str
xpath_hash: str
@dataclass(frozen=False)
class DOMBaseNode:
is_visible: bool
# Use None as default and set parent later to avoid circular reference issues
parent: Optional['DOMElementNode']
@dataclass(frozen=False)
class DOMTextNode(DOMBaseNode):
text: str
type: str = 'TEXT_NODE'
def has_parent_with_highlight_index(self) -> bool:
current = self.parent
while current is not None:
# stop if the element has a highlight index (will be handled separately)
if current.highlight_index is not None:
return True
current = current.parent
return False
def is_parent_in_viewport(self) -> bool:
if self.parent is None:
return False
return self.parent.is_in_viewport
def is_parent_top_element(self) -> bool:
if self.parent is None:
return False
return self.parent.is_top_element
@dataclass(frozen=False)
class DOMElementNode(DOMBaseNode):
"""
xpath: the xpath of the element from the last root node (shadow root or iframe OR document if no shadow root or iframe).
To properly reference the element we need to recursively switch the root node until we find the element (work you way up the tree with `.parent`)
"""
tag_name: str
xpath: str
attributes: Dict[str, str]
children: List[DOMBaseNode]
is_interactive: bool = False
is_top_element: bool = False
is_in_viewport: bool = False
shadow_root: bool = False
highlight_index: Optional[int] = None
viewport_coordinates: Optional[CoordinateSet] = None
page_coordinates: Optional[CoordinateSet] = None
viewport_info: Optional[ViewportInfo] = None
def __repr__(self) -> str:
tag_str = f'<{self.tag_name}'
# Add attributes
for key, value in self.attributes.items():
tag_str += f' {key}="{value}"'
tag_str += '>'
# Add extra info
extras = []
if self.is_interactive:
extras.append('interactive')
if self.is_top_element:
extras.append('top')
if self.shadow_root:
extras.append('shadow-root')
if self.highlight_index is not None:
extras.append(f'highlight:{self.highlight_index}')
if self.is_in_viewport:
extras.append('in-viewport')
if extras:
tag_str += f' [{", ".join(extras)}]'
return tag_str
def get_all_text_till_next_clickable_element(self, max_depth: int = -1) -> str:
text_parts = []
def collect_text(node: DOMBaseNode, current_depth: int) -> None:
if max_depth != -1 and current_depth > max_depth:
return
# Skip this branch if we hit a highlighted element (except for the current node)
if isinstance(node, DOMElementNode) and node != self and node.highlight_index is not None:
return
if isinstance(node, DOMTextNode):
text_parts.append(node.text)
elif isinstance(node, DOMElementNode):
for child in node.children:
collect_text(child, current_depth + 1)
collect_text(self, 0)
return '\n'.join(text_parts).strip()
def clickable_elements_to_string(self, include_attributes: list[str] | None = None) -> str:
"""Convert the processed DOM content to HTML."""
formatted_text = []
def process_node(node: DOMBaseNode, depth: int) -> None:
if isinstance(node, DOMElementNode):
# Add element with highlight_index
if node.highlight_index is not None:
attributes_str = ''
text = node.get_all_text_till_next_clickable_element()
if include_attributes:
attributes = list(
set(
[
str(value)
for key, value in node.attributes.items()
if key in include_attributes and value != node.tag_name
]
)
)
if text in attributes:
attributes.remove(text)
attributes_str = ';'.join(attributes)
line = f'[{node.highlight_index}]<{node.tag_name} '
if attributes_str:
line += f'{attributes_str}'
if text:
if attributes_str:
line += f'>{text}'
else:
line += f'{text}'
line += '/>'
formatted_text.append(line)
# Process children regardless
for child in node.children:
process_node(child, depth + 1)
elif isinstance(node, DOMTextNode):
# Add text only if it doesn't have a highlighted parent
if not node.has_parent_with_highlight_index() and node.is_visible: # and node.is_parent_top_element()
formatted_text.append(f'{node.text}')
process_node(self, 0)
return '\n'.join(formatted_text)
def get_file_upload_element(self, check_siblings: bool = True) -> Optional['DOMElementNode']:
# Check if current element is a file input
if self.tag_name == 'input' and self.attributes.get('type') == 'file':
return self
# Check children
for child in self.children:
if isinstance(child, DOMElementNode):
result = child.get_file_upload_element(check_siblings=False)
if result:
return result
# Check siblings only for the initial call
if check_siblings and self.parent:
for sibling in self.parent.children:
if sibling is not self and isinstance(sibling, DOMElementNode):
result = sibling.get_file_upload_element(check_siblings=False)
if result:
return result
return None
class DomTree(BaseModel):
element_tree: DOMElementNode
element_map: Dict[int, DOMElementNode]
@@ -0,0 +1,138 @@
# coding: utf-8
# Derived from browser_use DomService, we use it as a utility method, and supports sync and async.
import gc
import json
from typing import Dict, Any, Tuple, Optional
from aworld.utils.async_func import async_func
from examples.common.tools.browsers.util.dom import DOMElementNode, DOMBaseNode, DOMTextNode, ViewportInfo
from aworld.logs.util import logger
async def async_build_dom_tree(page, js_code: str, args: Dict[str, Any]) -> Tuple[DOMElementNode, Dict[int, DOMElementNode]]:
if await page.evaluate('1+1') != 2:
raise ValueError('The page cannot evaluate javascript code properly')
# NOTE: We execute JS code in the browser to extract important DOM information.
# The returned hash map contains information about the DOM tree and the
# relationship between the DOM elements.
try:
eval_page = await page.evaluate(js_code, args)
except Exception as e:
logger.error('Error evaluating JavaScript: %s', e)
raise
# Only log performance metrics in debug mode
if args.get("debugMode") and 'perfMetrics' in eval_page:
logger.debug('DOM Tree Building Performance Metrics:\n%s', json.dumps(eval_page['perfMetrics'], indent=2))
return await async_func(_construct_dom_tree)(eval_page)
def build_dom_tree(page, js_code: str, args: Dict[str, Any]) -> Tuple[DOMElementNode, Dict[int, DOMElementNode]]:
if page.evaluate('1+1') != 2:
raise ValueError('The page cannot evaluate javascript code properly')
# NOTE: We execute JS code in the browser to extract important DOM information.
# The returned hash map contains information about the DOM tree and the
# relationship between the DOM elements.
try:
eval_page = page.evaluate(js_code, args)
except Exception as e:
logger.error('Error evaluating JavaScript: %s', e)
raise
# Only log performance metrics in debug mode
if args.get("debugMode") and 'perfMetrics' in eval_page:
logger.debug('DOM Tree Building Performance Metrics:\n%s', json.dumps(eval_page['perfMetrics'], indent=2))
return _construct_dom_tree(eval_page)
def _construct_dom_tree(eval_page: dict, ) -> tuple[DOMElementNode, Dict[int, DOMElementNode]]:
js_node_map = eval_page['map']
js_root_id = eval_page['rootId']
selector_map = {}
node_map = {}
for id, node_data in js_node_map.items():
node, children_ids = _parse_node(node_data)
if node is None:
continue
node_map[id] = node
if isinstance(node, DOMElementNode) and node.highlight_index is not None:
selector_map[node.highlight_index] = node
# NOTE: We know that we are building the tree bottom up
# and all children are already processed.
if isinstance(node, DOMElementNode):
for child_id in children_ids:
if child_id not in node_map:
continue
child_node = node_map[child_id]
child_node.parent = node
node.children.append(child_node)
html_to_dict = node_map[str(js_root_id)]
del node_map
del js_node_map
del js_root_id
gc.collect()
if html_to_dict is None or not isinstance(html_to_dict, DOMElementNode):
raise ValueError('Failed to parse HTML to dictionary')
return html_to_dict, selector_map
def _parse_node(node_data: dict, ) -> Tuple[Optional[DOMBaseNode], list[int]]:
if not node_data:
return None, []
# Process text nodes immediately
if node_data.get('type') == 'TEXT_NODE':
text_node = DOMTextNode(
text=node_data['text'],
is_visible=node_data['isVisible'],
parent=None,
)
return text_node, []
# Process coordinates if they exist for element nodes
viewport_info = None
if 'viewport' in node_data:
viewport_info = ViewportInfo(
width=node_data['viewport']['width'],
height=node_data['viewport']['height'],
)
element_node = DOMElementNode(
tag_name=node_data['tagName'],
xpath=node_data['xpath'],
attributes=node_data.get('attributes', {}),
children=[],
is_visible=node_data.get('isVisible', False),
is_interactive=node_data.get('isInteractive', False),
is_top_element=node_data.get('isTopElement', False),
is_in_viewport=node_data.get('isInViewport', False),
highlight_index=node_data.get('highlightIndex'),
shadow_root=node_data.get('shadowRoot', False),
parent=None,
viewport_info=viewport_info,
)
children_ids = node_data.get('children', [])
return element_node, children_ids