headless_browser_tool 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.claude/settings.json +21 -0
- data/.rubocop.yml +56 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +298 -0
- data/LICENSE.md +7 -0
- data/README.md +522 -0
- data/Rakefile +12 -0
- data/config.ru +8 -0
- data/exe/hbt +7 -0
- data/lib/headless_browser_tool/browser.rb +374 -0
- data/lib/headless_browser_tool/browser_adapter.rb +320 -0
- data/lib/headless_browser_tool/cli.rb +34 -0
- data/lib/headless_browser_tool/directory_setup.rb +25 -0
- data/lib/headless_browser_tool/logger.rb +31 -0
- data/lib/headless_browser_tool/server.rb +150 -0
- data/lib/headless_browser_tool/session_manager.rb +199 -0
- data/lib/headless_browser_tool/session_middleware.rb +158 -0
- data/lib/headless_browser_tool/session_persistence.rb +146 -0
- data/lib/headless_browser_tool/stdio_server.rb +73 -0
- data/lib/headless_browser_tool/strict_session_middleware.rb +88 -0
- data/lib/headless_browser_tool/tools/attach_file_tool.rb +40 -0
- data/lib/headless_browser_tool/tools/auto_narrate_tool.rb +155 -0
- data/lib/headless_browser_tool/tools/base_tool.rb +39 -0
- data/lib/headless_browser_tool/tools/check_tool.rb +35 -0
- data/lib/headless_browser_tool/tools/choose_tool.rb +56 -0
- data/lib/headless_browser_tool/tools/click_button_tool.rb +49 -0
- data/lib/headless_browser_tool/tools/click_link_tool.rb +48 -0
- data/lib/headless_browser_tool/tools/click_tool.rb +45 -0
- data/lib/headless_browser_tool/tools/close_window_tool.rb +31 -0
- data/lib/headless_browser_tool/tools/double_click_tool.rb +37 -0
- data/lib/headless_browser_tool/tools/drag_tool.rb +46 -0
- data/lib/headless_browser_tool/tools/evaluate_script_tool.rb +20 -0
- data/lib/headless_browser_tool/tools/execute_script_tool.rb +29 -0
- data/lib/headless_browser_tool/tools/fill_in_tool.rb +66 -0
- data/lib/headless_browser_tool/tools/find_all_tool.rb +42 -0
- data/lib/headless_browser_tool/tools/find_element_tool.rb +21 -0
- data/lib/headless_browser_tool/tools/find_elements_containing_text_tool.rb +259 -0
- data/lib/headless_browser_tool/tools/get_attribute_tool.rb +21 -0
- data/lib/headless_browser_tool/tools/get_current_path_tool.rb +16 -0
- data/lib/headless_browser_tool/tools/get_current_url_tool.rb +16 -0
- data/lib/headless_browser_tool/tools/get_narration_history_tool.rb +35 -0
- data/lib/headless_browser_tool/tools/get_page_context_tool.rb +188 -0
- data/lib/headless_browser_tool/tools/get_page_source_tool.rb +16 -0
- data/lib/headless_browser_tool/tools/get_page_title_tool.rb +16 -0
- data/lib/headless_browser_tool/tools/get_session_info_tool.rb +37 -0
- data/lib/headless_browser_tool/tools/get_text_tool.rb +20 -0
- data/lib/headless_browser_tool/tools/get_value_tool.rb +20 -0
- data/lib/headless_browser_tool/tools/get_window_handles_tool.rb +29 -0
- data/lib/headless_browser_tool/tools/go_back_tool.rb +29 -0
- data/lib/headless_browser_tool/tools/go_forward_tool.rb +29 -0
- data/lib/headless_browser_tool/tools/has_element_tool.rb +21 -0
- data/lib/headless_browser_tool/tools/has_text_tool.rb +21 -0
- data/lib/headless_browser_tool/tools/hover_tool.rb +38 -0
- data/lib/headless_browser_tool/tools/is_visible_tool.rb +20 -0
- data/lib/headless_browser_tool/tools/maximize_window_tool.rb +34 -0
- data/lib/headless_browser_tool/tools/open_new_window_tool.rb +25 -0
- data/lib/headless_browser_tool/tools/refresh_tool.rb +32 -0
- data/lib/headless_browser_tool/tools/resize_window_tool.rb +43 -0
- data/lib/headless_browser_tool/tools/right_click_tool.rb +37 -0
- data/lib/headless_browser_tool/tools/save_page_tool.rb +32 -0
- data/lib/headless_browser_tool/tools/screenshot_tool.rb +199 -0
- data/lib/headless_browser_tool/tools/search_page_tool.rb +224 -0
- data/lib/headless_browser_tool/tools/search_source_tool.rb +148 -0
- data/lib/headless_browser_tool/tools/select_tool.rb +44 -0
- data/lib/headless_browser_tool/tools/switch_to_window_tool.rb +30 -0
- data/lib/headless_browser_tool/tools/uncheck_tool.rb +35 -0
- data/lib/headless_browser_tool/tools/visit_tool.rb +27 -0
- data/lib/headless_browser_tool/tools/visual_diff_tool.rb +177 -0
- data/lib/headless_browser_tool/tools.rb +104 -0
- data/lib/headless_browser_tool/version.rb +5 -0
- data/lib/headless_browser_tool.rb +8 -0
- metadata +256 -0
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_tool"
|
4
|
+
|
5
|
+
module HeadlessBrowserTool
|
6
|
+
module Tools
|
7
|
+
class ClickLinkTool < BaseTool
|
8
|
+
tool_name "click_link"
|
9
|
+
description "Click link by text or selector"
|
10
|
+
|
11
|
+
arguments do
|
12
|
+
required(:link_text_or_selector).filled(:string).description("Link text or CSS selector")
|
13
|
+
end
|
14
|
+
|
15
|
+
def execute(link_text_or_selector:)
|
16
|
+
url_before = browser.current_url
|
17
|
+
browser.title
|
18
|
+
|
19
|
+
# Find the link element first
|
20
|
+
link = begin
|
21
|
+
browser.find_link(link_text_or_selector)
|
22
|
+
rescue Capybara::ElementNotFound
|
23
|
+
browser.find(link_text_or_selector)
|
24
|
+
end
|
25
|
+
|
26
|
+
link_info = {
|
27
|
+
text: link.text.strip,
|
28
|
+
href: link[:href],
|
29
|
+
target: link[:target]
|
30
|
+
}.compact
|
31
|
+
|
32
|
+
browser.click_link(link_text_or_selector)
|
33
|
+
|
34
|
+
{
|
35
|
+
link: link_text_or_selector,
|
36
|
+
element: link_info,
|
37
|
+
navigation: {
|
38
|
+
navigated: browser.current_url != url_before,
|
39
|
+
from: url_before,
|
40
|
+
to: browser.current_url,
|
41
|
+
title: browser.title
|
42
|
+
},
|
43
|
+
status: "clicked"
|
44
|
+
}
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_tool"
|
4
|
+
|
5
|
+
module HeadlessBrowserTool
|
6
|
+
module Tools
|
7
|
+
class ClickTool < BaseTool
|
8
|
+
tool_name "click"
|
9
|
+
description "Click an element by CSS selector"
|
10
|
+
|
11
|
+
arguments do
|
12
|
+
required(:selector).filled(:string).description("CSS selector of the element to click")
|
13
|
+
end
|
14
|
+
|
15
|
+
def execute(selector:)
|
16
|
+
# Capture state before click
|
17
|
+
url_before = browser.current_url
|
18
|
+
|
19
|
+
# Get element info before clicking
|
20
|
+
element = browser.find_element(selector)
|
21
|
+
element_text = element[:text]
|
22
|
+
tag_name = element[:tag_name]
|
23
|
+
|
24
|
+
# Perform click
|
25
|
+
browser.click(selector)
|
26
|
+
|
27
|
+
# Brief wait to allow page changes
|
28
|
+
sleep 0.1
|
29
|
+
|
30
|
+
{
|
31
|
+
selector: selector,
|
32
|
+
element: {
|
33
|
+
tag_name: tag_name,
|
34
|
+
text: element_text
|
35
|
+
},
|
36
|
+
navigation: {
|
37
|
+
url_before: url_before,
|
38
|
+
url_after: browser.current_url,
|
39
|
+
navigated: url_before != browser.current_url
|
40
|
+
}
|
41
|
+
}
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_tool"
|
4
|
+
|
5
|
+
module HeadlessBrowserTool
|
6
|
+
module Tools
|
7
|
+
class CloseWindowTool < BaseTool
|
8
|
+
tool_name "close_window"
|
9
|
+
description "Close specific browser window/tab"
|
10
|
+
|
11
|
+
arguments do
|
12
|
+
required(:window_handle).filled(:string).description("Handle of the window to close")
|
13
|
+
end
|
14
|
+
|
15
|
+
def execute(window_handle:)
|
16
|
+
initial_windows = browser.windows
|
17
|
+
current_window = browser.current_window
|
18
|
+
browser.close_window(window_handle)
|
19
|
+
|
20
|
+
{
|
21
|
+
closed_window: window_handle,
|
22
|
+
was_current: window_handle == current_window,
|
23
|
+
previous_windows: initial_windows,
|
24
|
+
remaining_windows: browser.windows,
|
25
|
+
current_window: browser.current_window,
|
26
|
+
status: "closed"
|
27
|
+
}
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_tool"
|
4
|
+
|
5
|
+
module HeadlessBrowserTool
|
6
|
+
module Tools
|
7
|
+
class DoubleClickTool < BaseTool
|
8
|
+
tool_name "double_click"
|
9
|
+
description "Double-click an element by CSS selector"
|
10
|
+
|
11
|
+
arguments do
|
12
|
+
required(:selector).filled(:string).description("CSS selector of the element to double-click")
|
13
|
+
end
|
14
|
+
|
15
|
+
def execute(selector:)
|
16
|
+
element = browser.find(selector)
|
17
|
+
element_info = {
|
18
|
+
tag_name: element.tag_name,
|
19
|
+
text: element.text.strip,
|
20
|
+
visible: element.visible?,
|
21
|
+
attributes: {
|
22
|
+
id: element[:id],
|
23
|
+
class: element[:class]
|
24
|
+
}.compact
|
25
|
+
}
|
26
|
+
|
27
|
+
browser.double_click(selector)
|
28
|
+
|
29
|
+
{
|
30
|
+
selector: selector,
|
31
|
+
element: element_info,
|
32
|
+
status: "double_clicked"
|
33
|
+
}
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_tool"
|
4
|
+
|
5
|
+
module HeadlessBrowserTool
|
6
|
+
module Tools
|
7
|
+
class DragTool < BaseTool
|
8
|
+
tool_name "drag"
|
9
|
+
description "Drag an element to another element"
|
10
|
+
|
11
|
+
arguments do
|
12
|
+
required(:source_selector).filled(:string).description("CSS selector of the element to drag")
|
13
|
+
required(:target_selector).filled(:string).description("CSS selector of the target element")
|
14
|
+
end
|
15
|
+
|
16
|
+
def execute(source_selector:, target_selector:)
|
17
|
+
source = browser.find(source_selector)
|
18
|
+
target = browser.find(target_selector)
|
19
|
+
|
20
|
+
source_info = {
|
21
|
+
tag_name: source.tag_name,
|
22
|
+
text: source.text.strip,
|
23
|
+
id: source[:id],
|
24
|
+
class: source[:class]
|
25
|
+
}.compact
|
26
|
+
|
27
|
+
target_info = {
|
28
|
+
tag_name: target.tag_name,
|
29
|
+
text: target.text.strip,
|
30
|
+
id: target[:id],
|
31
|
+
class: target[:class]
|
32
|
+
}.compact
|
33
|
+
|
34
|
+
browser.drag(source_selector, target_selector)
|
35
|
+
|
36
|
+
{
|
37
|
+
source_selector: source_selector,
|
38
|
+
target_selector: target_selector,
|
39
|
+
source: source_info,
|
40
|
+
target: target_info,
|
41
|
+
status: "dragged"
|
42
|
+
}
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_tool"
|
4
|
+
|
5
|
+
module HeadlessBrowserTool
|
6
|
+
module Tools
|
7
|
+
class EvaluateScriptTool < BaseTool
|
8
|
+
tool_name "evaluate_script"
|
9
|
+
description "Run JavaScript and return the result"
|
10
|
+
|
11
|
+
arguments do
|
12
|
+
required(:javascript_code).filled(:string).description("JavaScript code to evaluate")
|
13
|
+
end
|
14
|
+
|
15
|
+
def execute(javascript_code:)
|
16
|
+
browser.evaluate_script(javascript_code)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_tool"
|
4
|
+
|
5
|
+
module HeadlessBrowserTool
|
6
|
+
module Tools
|
7
|
+
class ExecuteScriptTool < BaseTool
|
8
|
+
tool_name "execute_script"
|
9
|
+
description "Run JavaScript without return value"
|
10
|
+
|
11
|
+
arguments do
|
12
|
+
required(:javascript_code).filled(:string).description("JavaScript code to execute")
|
13
|
+
end
|
14
|
+
|
15
|
+
def execute(javascript_code:)
|
16
|
+
start_time = Time.now
|
17
|
+
browser.execute_script(javascript_code)
|
18
|
+
execution_time = Time.now - start_time
|
19
|
+
|
20
|
+
{
|
21
|
+
javascript_code: javascript_code,
|
22
|
+
execution_time: execution_time,
|
23
|
+
timestamp: Time.now.iso8601,
|
24
|
+
status: "executed"
|
25
|
+
}
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_tool"
|
4
|
+
|
5
|
+
module HeadlessBrowserTool
|
6
|
+
module Tools
|
7
|
+
class FillInTool < BaseTool
|
8
|
+
tool_name "fill_in"
|
9
|
+
description "Fill in an input field"
|
10
|
+
|
11
|
+
arguments do
|
12
|
+
required(:field).filled(:string).description("Field name, id, or label")
|
13
|
+
required(:value).filled(:string).description("Value to fill in")
|
14
|
+
end
|
15
|
+
|
16
|
+
def execute(field:, value:)
|
17
|
+
# Try to find the field to get more info
|
18
|
+
field_info = begin
|
19
|
+
# Try different selectors
|
20
|
+
element = begin
|
21
|
+
browser.find_element("input[name='#{field}']")
|
22
|
+
rescue StandardError
|
23
|
+
nil
|
24
|
+
end
|
25
|
+
element ||= begin
|
26
|
+
browser.find_element("input##{field}")
|
27
|
+
rescue StandardError
|
28
|
+
nil
|
29
|
+
end
|
30
|
+
element ||= begin
|
31
|
+
browser.find_element("textarea[name='#{field}']")
|
32
|
+
rescue StandardError
|
33
|
+
nil
|
34
|
+
end
|
35
|
+
element ||= begin
|
36
|
+
browser.find_element("textarea##{field}")
|
37
|
+
rescue StandardError
|
38
|
+
nil
|
39
|
+
end
|
40
|
+
|
41
|
+
if element
|
42
|
+
{
|
43
|
+
type: element[:attributes]["type"] || "text",
|
44
|
+
name: element[:attributes]["name"],
|
45
|
+
id: element[:attributes]["id"],
|
46
|
+
placeholder: element[:attributes]["placeholder"]
|
47
|
+
}.compact
|
48
|
+
else
|
49
|
+
{}
|
50
|
+
end
|
51
|
+
rescue StandardError
|
52
|
+
{}
|
53
|
+
end
|
54
|
+
|
55
|
+
browser.fill_in(field, value)
|
56
|
+
|
57
|
+
{
|
58
|
+
field: field,
|
59
|
+
value: value,
|
60
|
+
field_info: field_info,
|
61
|
+
status: "success"
|
62
|
+
}
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_tool"
|
4
|
+
|
5
|
+
module HeadlessBrowserTool
|
6
|
+
module Tools
|
7
|
+
class FindAllTool < BaseTool
|
8
|
+
tool_name "find_all"
|
9
|
+
description "Find all matching elements, returns array"
|
10
|
+
|
11
|
+
arguments do
|
12
|
+
required(:selector).filled(:string).description("CSS selector to find all matching elements")
|
13
|
+
end
|
14
|
+
|
15
|
+
def execute(selector:)
|
16
|
+
elements = browser.find_all(selector)
|
17
|
+
|
18
|
+
{
|
19
|
+
selector: selector,
|
20
|
+
count: elements.size,
|
21
|
+
elements: elements.map.with_index do |element, index|
|
22
|
+
result = {
|
23
|
+
index: index,
|
24
|
+
selector: "#{selector}:nth-of-type(#{index + 1})",
|
25
|
+
tag_name: element[:tag_name],
|
26
|
+
text: element[:text].strip,
|
27
|
+
visible: element[:visible]
|
28
|
+
}
|
29
|
+
|
30
|
+
# Include attributes if present
|
31
|
+
result[:attributes] = element[:attributes] unless element[:attributes].empty?
|
32
|
+
|
33
|
+
# Include value for form elements
|
34
|
+
result[:value] = element[:value] if element[:value] && !element[:value].empty?
|
35
|
+
|
36
|
+
result
|
37
|
+
end
|
38
|
+
}
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_tool"
|
4
|
+
|
5
|
+
module HeadlessBrowserTool
|
6
|
+
module Tools
|
7
|
+
class FindElementTool < BaseTool
|
8
|
+
tool_name "find_element"
|
9
|
+
description "Find single element, errors if not found"
|
10
|
+
|
11
|
+
arguments do
|
12
|
+
required(:selector).filled(:string).description("CSS selector of the element to find")
|
13
|
+
end
|
14
|
+
|
15
|
+
def execute(selector:)
|
16
|
+
browser.find_element(selector)
|
17
|
+
"Found element: #{selector}"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,259 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_tool"
|
4
|
+
|
5
|
+
module HeadlessBrowserTool
|
6
|
+
module Tools
|
7
|
+
class FindElementsContainingTextTool < BaseTool
|
8
|
+
tool_name "find_elements_containing_text"
|
9
|
+
description "Find all elements containing specified text and return their selectors"
|
10
|
+
|
11
|
+
arguments do
|
12
|
+
required(:text).filled(:string).description("Text to search for in elements")
|
13
|
+
optional(:exact_match).filled(:bool).description("Require exact text match (default: false)")
|
14
|
+
optional(:case_sensitive).filled(:bool).description("Case sensitive search (default: false)")
|
15
|
+
optional(:visible_only).filled(:bool).description("Only return visible elements (default: true)")
|
16
|
+
end
|
17
|
+
|
18
|
+
def execute(text:, exact_match: false, case_sensitive: false, visible_only: true)
|
19
|
+
script = build_search_script(text, exact_match, case_sensitive, visible_only)
|
20
|
+
elements_data = browser.execute_script(script)
|
21
|
+
|
22
|
+
# Ensure we have an array to work with
|
23
|
+
elements_data = [] if elements_data.nil?
|
24
|
+
elements_data = [elements_data] unless elements_data.is_a?(Array)
|
25
|
+
|
26
|
+
# Process and enrich the results
|
27
|
+
results = elements_data.map do |element|
|
28
|
+
# Skip if element is not a hash
|
29
|
+
next unless element.is_a?(Hash)
|
30
|
+
|
31
|
+
# Safely extract values with defaults
|
32
|
+
{
|
33
|
+
tag: element["tag"] || "unknown",
|
34
|
+
text: (element["text"] || "").to_s,
|
35
|
+
selector: element["selector"] || "",
|
36
|
+
xpath: element["xpath"] || "",
|
37
|
+
attributes: element["attributes"] || {},
|
38
|
+
parent: element["parent"] || nil,
|
39
|
+
clickable: element["clickable"] == true,
|
40
|
+
visible: element["visible"] == true,
|
41
|
+
position: element["position"] || {}
|
42
|
+
}
|
43
|
+
end.compact
|
44
|
+
|
45
|
+
{
|
46
|
+
query: text,
|
47
|
+
total_found: results.size,
|
48
|
+
elements: results
|
49
|
+
}
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def build_search_script(text, exact_match, case_sensitive, visible_only)
|
55
|
+
<<~JS
|
56
|
+
(function() {
|
57
|
+
const searchText = #{text.to_json};
|
58
|
+
const exactMatch = #{exact_match};
|
59
|
+
const caseSensitive = #{case_sensitive};
|
60
|
+
const visibleOnly = #{visible_only};
|
61
|
+
|
62
|
+
// Helper to check if element is visible
|
63
|
+
function isVisible(elem) {
|
64
|
+
if (!elem) return false;
|
65
|
+
const style = window.getComputedStyle(elem);
|
66
|
+
return style.display !== 'none' &&
|
67
|
+
style.visibility !== 'hidden' &&
|
68
|
+
style.opacity !== '0' &&
|
69
|
+
elem.offsetWidth > 0 &&
|
70
|
+
elem.offsetHeight > 0;
|
71
|
+
}
|
72
|
+
|
73
|
+
// Helper to generate unique selector
|
74
|
+
function getSelector(elem) {
|
75
|
+
if (elem.id) {
|
76
|
+
return '#' + CSS.escape(elem.id);
|
77
|
+
}
|
78
|
+
|
79
|
+
if (elem.className && typeof elem.className === 'string') {
|
80
|
+
const classes = elem.className.trim().split(/\\s+/)
|
81
|
+
.filter(c => c.length > 0)
|
82
|
+
.map(c => '.' + CSS.escape(c))
|
83
|
+
.join('');
|
84
|
+
if (classes && document.querySelectorAll(elem.tagName + classes).length === 1) {
|
85
|
+
return elem.tagName.toLowerCase() + classes;
|
86
|
+
}
|
87
|
+
}
|
88
|
+
|
89
|
+
// Build path from root
|
90
|
+
const path = [];
|
91
|
+
let current = elem;
|
92
|
+
while (current && current.nodeType === Node.ELEMENT_NODE) {
|
93
|
+
let selector = current.tagName.toLowerCase();
|
94
|
+
if (current.id) {
|
95
|
+
selector = '#' + CSS.escape(current.id);
|
96
|
+
path.unshift(selector);
|
97
|
+
break;
|
98
|
+
} else {
|
99
|
+
let sibling = current;
|
100
|
+
let nth = 1;
|
101
|
+
while (sibling.previousElementSibling) {
|
102
|
+
sibling = sibling.previousElementSibling;
|
103
|
+
if (sibling.tagName === current.tagName) nth++;
|
104
|
+
}
|
105
|
+
if (nth > 1) selector += ':nth-of-type(' + nth + ')';
|
106
|
+
}
|
107
|
+
path.unshift(selector);
|
108
|
+
current = current.parentElement;
|
109
|
+
}
|
110
|
+
return path.join(' > ');
|
111
|
+
}
|
112
|
+
|
113
|
+
// Helper to get XPath
|
114
|
+
function getXPath(elem) {
|
115
|
+
const path = [];
|
116
|
+
let current = elem;
|
117
|
+
while (current && current.nodeType === Node.ELEMENT_NODE) {
|
118
|
+
let index = 0;
|
119
|
+
let sibling = current;
|
120
|
+
while (sibling) {
|
121
|
+
if (sibling.nodeType === Node.ELEMENT_NODE && sibling.tagName === current.tagName) {
|
122
|
+
index++;
|
123
|
+
}
|
124
|
+
sibling = sibling.previousSibling;
|
125
|
+
if (sibling === current) break;
|
126
|
+
}
|
127
|
+
path.unshift(current.tagName.toLowerCase() + '[' + index + ']');
|
128
|
+
current = current.parentElement;
|
129
|
+
}
|
130
|
+
return '//' + path.join('/');
|
131
|
+
}
|
132
|
+
|
133
|
+
// Helper to check if text matches
|
134
|
+
function textMatches(elementText, searchText) {
|
135
|
+
if (!caseSensitive) {
|
136
|
+
elementText = elementText.toLowerCase();
|
137
|
+
searchText = searchText.toLowerCase();
|
138
|
+
}
|
139
|
+
|
140
|
+
if (exactMatch) {
|
141
|
+
return elementText.trim() === searchText.trim();
|
142
|
+
} else {
|
143
|
+
return elementText.includes(searchText);
|
144
|
+
}
|
145
|
+
}
|
146
|
+
|
147
|
+
// Helper to check if element is clickable
|
148
|
+
function isClickable(elem) {
|
149
|
+
const tag = elem.tagName.toLowerCase();
|
150
|
+
return tag === 'a' || tag === 'button' ||
|
151
|
+
elem.onclick !== null ||
|
152
|
+
elem.hasAttribute('onclick') ||
|
153
|
+
elem.style.cursor === 'pointer' ||
|
154
|
+
elem.role === 'button' ||
|
155
|
+
elem.role === 'link';
|
156
|
+
}
|
157
|
+
|
158
|
+
// Find all text nodes and their parent elements
|
159
|
+
const results = [];
|
160
|
+
const processed = new Set();
|
161
|
+
const walker = document.createTreeWalker(
|
162
|
+
document.body,
|
163
|
+
NodeFilter.SHOW_TEXT,
|
164
|
+
{
|
165
|
+
acceptNode: function(node) {
|
166
|
+
if (node.textContent.trim().length === 0) {
|
167
|
+
return NodeFilter.FILTER_REJECT;
|
168
|
+
}
|
169
|
+
return NodeFilter.FILTER_ACCEPT;
|
170
|
+
}
|
171
|
+
}
|
172
|
+
);
|
173
|
+
|
174
|
+
let node;
|
175
|
+
while (node = walker.nextNode()) {
|
176
|
+
const parent = node.parentElement;
|
177
|
+
if (!parent || processed.has(parent)) continue;
|
178
|
+
|
179
|
+
const text = parent.textContent;
|
180
|
+
if (textMatches(text, searchText)) {
|
181
|
+
if (visibleOnly && !isVisible(parent)) continue;
|
182
|
+
|
183
|
+
processed.add(parent);
|
184
|
+
|
185
|
+
// Get element attributes
|
186
|
+
const attributes = {};
|
187
|
+
if (parent.attributes && parent.attributes.length) {
|
188
|
+
for (let i = 0; i < parent.attributes.length; i++) {
|
189
|
+
const attr = parent.attributes[i];
|
190
|
+
attributes[attr.name] = attr.value;
|
191
|
+
}
|
192
|
+
}
|
193
|
+
|
194
|
+
// Get position
|
195
|
+
const rect = parent.getBoundingClientRect();
|
196
|
+
|
197
|
+
results.push({
|
198
|
+
tag: parent.tagName.toLowerCase(),
|
199
|
+
text: text.trim().substring(0, 200), // Limit text length
|
200
|
+
selector: getSelector(parent),
|
201
|
+
xpath: getXPath(parent),
|
202
|
+
attributes: attributes,
|
203
|
+
parent: parent.parentElement ? parent.parentElement.tagName.toLowerCase() : null,
|
204
|
+
clickable: isClickable(parent),
|
205
|
+
visible: isVisible(parent),
|
206
|
+
position: {
|
207
|
+
top: rect.top + window.scrollY,
|
208
|
+
left: rect.left + window.scrollX,
|
209
|
+
width: rect.width,
|
210
|
+
height: rect.height
|
211
|
+
}
|
212
|
+
});
|
213
|
+
}
|
214
|
+
}
|
215
|
+
|
216
|
+
// Also search in input values
|
217
|
+
document.querySelectorAll('input, textarea, select').forEach(elem => {
|
218
|
+
if (processed.has(elem)) return;
|
219
|
+
|
220
|
+
const value = elem.value || elem.textContent;
|
221
|
+
if (value && textMatches(value, searchText)) {
|
222
|
+
if (visibleOnly && !isVisible(elem)) return;
|
223
|
+
|
224
|
+
const attributes = {};
|
225
|
+
if (elem.attributes && elem.attributes.length) {
|
226
|
+
for (let i = 0; i < elem.attributes.length; i++) {
|
227
|
+
const attr = elem.attributes[i];
|
228
|
+
attributes[attr.name] = attr.value;
|
229
|
+
}
|
230
|
+
}
|
231
|
+
|
232
|
+
const rect = elem.getBoundingClientRect();
|
233
|
+
|
234
|
+
results.push({
|
235
|
+
tag: elem.tagName.toLowerCase(),
|
236
|
+
text: value.trim().substring(0, 200),
|
237
|
+
selector: getSelector(elem),
|
238
|
+
xpath: getXPath(elem),
|
239
|
+
attributes: attributes,
|
240
|
+
parent: elem.parentElement ? elem.parentElement.tagName.toLowerCase() : null,
|
241
|
+
clickable: true,
|
242
|
+
visible: isVisible(elem),
|
243
|
+
position: {
|
244
|
+
top: rect.top + window.scrollY,
|
245
|
+
left: rect.left + window.scrollX,
|
246
|
+
width: rect.width,
|
247
|
+
height: rect.height
|
248
|
+
}
|
249
|
+
});
|
250
|
+
}
|
251
|
+
});
|
252
|
+
|
253
|
+
return results;
|
254
|
+
})();
|
255
|
+
JS
|
256
|
+
end
|
257
|
+
end
|
258
|
+
end
|
259
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_tool"
|
4
|
+
|
5
|
+
module HeadlessBrowserTool
|
6
|
+
module Tools
|
7
|
+
class GetAttributeTool < BaseTool
|
8
|
+
tool_name "get_attribute"
|
9
|
+
description "Get an attribute value from an element"
|
10
|
+
|
11
|
+
arguments do
|
12
|
+
required(:selector).filled(:string).description("CSS selector of the element")
|
13
|
+
required(:attribute_name).filled(:string).description("Name of the attribute to get")
|
14
|
+
end
|
15
|
+
|
16
|
+
def execute(selector:, attribute_name:)
|
17
|
+
browser.get_attribute(selector, attribute_name)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_tool"
|
4
|
+
|
5
|
+
module HeadlessBrowserTool
|
6
|
+
module Tools
|
7
|
+
class GetCurrentPathTool < BaseTool
|
8
|
+
tool_name "get_current_path"
|
9
|
+
description "Get current path without domain"
|
10
|
+
|
11
|
+
def execute
|
12
|
+
browser.get_current_path
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|