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,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_tool"
|
4
|
+
|
5
|
+
module HeadlessBrowserTool
|
6
|
+
module Tools
|
7
|
+
class RightClickTool < BaseTool
|
8
|
+
tool_name "right_click"
|
9
|
+
description "Right-click an element by CSS selector"
|
10
|
+
|
11
|
+
arguments do
|
12
|
+
required(:selector).filled(:string).description("CSS selector of the element to right-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.right_click(selector)
|
28
|
+
|
29
|
+
{
|
30
|
+
selector: selector,
|
31
|
+
element: element_info,
|
32
|
+
status: "right_clicked"
|
33
|
+
}
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_tool"
|
4
|
+
|
5
|
+
module HeadlessBrowserTool
|
6
|
+
module Tools
|
7
|
+
class SavePageTool < BaseTool
|
8
|
+
tool_name "save_page"
|
9
|
+
description "Save the current page HTML"
|
10
|
+
|
11
|
+
arguments do
|
12
|
+
required(:file_path).filled(:string).description("Path where to save the HTML")
|
13
|
+
end
|
14
|
+
|
15
|
+
def execute(file_path:)
|
16
|
+
# Ensure directory exists
|
17
|
+
FileUtils.mkdir_p(File.dirname(file_path))
|
18
|
+
|
19
|
+
browser.save_page(file_path)
|
20
|
+
|
21
|
+
{
|
22
|
+
file_path: file_path,
|
23
|
+
file_size: File.size(file_path),
|
24
|
+
timestamp: Time.now.iso8601,
|
25
|
+
url: browser.current_url,
|
26
|
+
title: browser.title,
|
27
|
+
status: "saved"
|
28
|
+
}
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,199 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_tool"
|
4
|
+
require "time"
|
5
|
+
|
6
|
+
module HeadlessBrowserTool
|
7
|
+
module Tools
|
8
|
+
class ScreenshotTool < BaseTool
|
9
|
+
tool_name "screenshot"
|
10
|
+
description "Take a screenshot of the current page"
|
11
|
+
|
12
|
+
arguments do
|
13
|
+
optional(:filename).filled(:string).description("Filename for the screenshot (auto-generated if not provided)")
|
14
|
+
optional(:highlight_selectors).array(:string).description("CSS selectors to highlight in red boxes")
|
15
|
+
optional(:annotate).filled(:bool).description("Add element annotations with data-testid and common selectors")
|
16
|
+
optional(:full_page).filled(:bool).description("Capture entire page instead of just viewport (default: false)")
|
17
|
+
end
|
18
|
+
|
19
|
+
def execute(filename: nil, highlight_selectors: [], annotate: false, full_page: false)
|
20
|
+
screenshots_dir = "./.hbt/screenshots"
|
21
|
+
timestamp = Time.now.strftime("%Y%m%d_%H%M%S_%L")
|
22
|
+
|
23
|
+
# Generate filename with timestamp
|
24
|
+
if filename
|
25
|
+
# Remove .png extension if present to add timestamp before it
|
26
|
+
base_name = filename.sub(/\.png$/i, "")
|
27
|
+
filename = "#{base_name}_#{timestamp}.png"
|
28
|
+
else
|
29
|
+
filename = "screenshot_#{timestamp}.png"
|
30
|
+
end
|
31
|
+
|
32
|
+
file_path = File.join(screenshots_dir, filename)
|
33
|
+
|
34
|
+
# Inject JavaScript to highlight elements
|
35
|
+
inject_highlight_script(highlight_selectors, annotate) if highlight_selectors.any? || annotate
|
36
|
+
|
37
|
+
# Take screenshot
|
38
|
+
if full_page
|
39
|
+
take_full_page_screenshot(file_path)
|
40
|
+
else
|
41
|
+
browser.save_screenshot(file_path)
|
42
|
+
end
|
43
|
+
|
44
|
+
# Remove highlights
|
45
|
+
browser.execute_script("document.querySelectorAll('.ai-highlight').forEach(el => el.remove());")
|
46
|
+
|
47
|
+
# Get file info
|
48
|
+
file_size = File.size(file_path)
|
49
|
+
|
50
|
+
{
|
51
|
+
file_path: file_path,
|
52
|
+
filename: filename,
|
53
|
+
file_size: file_size,
|
54
|
+
file_size_human: "#{(file_size / 1024.0).round(2)} KB",
|
55
|
+
timestamp: timestamp,
|
56
|
+
full_page: full_page,
|
57
|
+
highlighted_elements: highlight_selectors.size,
|
58
|
+
annotated: annotate,
|
59
|
+
url: browser.current_url,
|
60
|
+
title: browser.title
|
61
|
+
}
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def take_full_page_screenshot(file_path)
|
67
|
+
# Get the full page dimensions
|
68
|
+
dimensions = browser.execute_script(<<~JS)
|
69
|
+
return {
|
70
|
+
width: Math.max(
|
71
|
+
document.body.scrollWidth,
|
72
|
+
document.body.offsetWidth,
|
73
|
+
document.documentElement.clientWidth,
|
74
|
+
document.documentElement.scrollWidth,
|
75
|
+
document.documentElement.offsetWidth
|
76
|
+
),
|
77
|
+
height: Math.max(
|
78
|
+
document.body.scrollHeight,
|
79
|
+
document.body.offsetHeight,
|
80
|
+
document.documentElement.clientHeight,
|
81
|
+
document.documentElement.scrollHeight,
|
82
|
+
document.documentElement.offsetHeight
|
83
|
+
),
|
84
|
+
devicePixelRatio: window.devicePixelRatio || 1
|
85
|
+
};
|
86
|
+
JS
|
87
|
+
|
88
|
+
# Handle nil dimensions
|
89
|
+
if dimensions.nil?
|
90
|
+
# Fallback to just taking a regular screenshot
|
91
|
+
browser.save_screenshot(file_path)
|
92
|
+
return
|
93
|
+
end
|
94
|
+
|
95
|
+
# Store original window size
|
96
|
+
original_size = browser.current_window.size
|
97
|
+
|
98
|
+
# Resize window to full page dimensions
|
99
|
+
# Add some padding to ensure everything is captured
|
100
|
+
browser.current_window.resize_to(
|
101
|
+
(dimensions["width"] || 1280) + 100,
|
102
|
+
(dimensions["height"] || 720) + 100
|
103
|
+
)
|
104
|
+
|
105
|
+
# Small delay to ensure resize is complete
|
106
|
+
sleep 0.5
|
107
|
+
|
108
|
+
# Take the screenshot
|
109
|
+
browser.save_screenshot(file_path)
|
110
|
+
|
111
|
+
# Restore original window size
|
112
|
+
browser.current_window.resize_to(original_size[0], original_size[1])
|
113
|
+
end
|
114
|
+
|
115
|
+
def inject_highlight_script(selectors, annotate)
|
116
|
+
script = <<~JS
|
117
|
+
// Remove any existing highlights
|
118
|
+
document.querySelectorAll('.ai-highlight').forEach(el => el.remove());
|
119
|
+
|
120
|
+
// Add highlight styles
|
121
|
+
const style = document.createElement('style');
|
122
|
+
style.textContent = `
|
123
|
+
.ai-highlight {
|
124
|
+
position: absolute;
|
125
|
+
border: 3px solid red;
|
126
|
+
background: rgba(255, 0, 0, 0.1);
|
127
|
+
pointer-events: none;
|
128
|
+
z-index: 10000;
|
129
|
+
}
|
130
|
+
.ai-annotation {
|
131
|
+
position: absolute;
|
132
|
+
background: red;
|
133
|
+
color: white;
|
134
|
+
padding: 2px 6px;
|
135
|
+
font-size: 12px;
|
136
|
+
font-family: monospace;
|
137
|
+
border-radius: 3px;
|
138
|
+
z-index: 10001;
|
139
|
+
}
|
140
|
+
`;
|
141
|
+
document.head.appendChild(style);
|
142
|
+
|
143
|
+
// Highlight specific selectors
|
144
|
+
#{selectors.map { |sel| highlight_selector(sel) }.join("\n")}
|
145
|
+
|
146
|
+
// Annotate interactive elements if requested
|
147
|
+
#{annotate ? annotate_elements : ""}
|
148
|
+
JS
|
149
|
+
|
150
|
+
browser.execute_script(script)
|
151
|
+
sleep 0.1 # Give time for rendering
|
152
|
+
end
|
153
|
+
|
154
|
+
def highlight_selector(selector)
|
155
|
+
<<~JS
|
156
|
+
try {
|
157
|
+
document.querySelectorAll('#{selector}').forEach((el, index) => {
|
158
|
+
const rect = el.getBoundingClientRect();
|
159
|
+
const highlight = document.createElement('div');
|
160
|
+
highlight.className = 'ai-highlight';
|
161
|
+
highlight.style.top = rect.top + window.scrollY + 'px';
|
162
|
+
highlight.style.left = rect.left + window.scrollX + 'px';
|
163
|
+
highlight.style.width = rect.width + 'px';
|
164
|
+
highlight.style.height = rect.height + 'px';
|
165
|
+
document.body.appendChild(highlight);
|
166
|
+
});
|
167
|
+
} catch(e) {}
|
168
|
+
JS
|
169
|
+
end
|
170
|
+
|
171
|
+
def annotate_elements
|
172
|
+
<<~JS
|
173
|
+
// Find and annotate interactive elements
|
174
|
+
const interactiveSelectors = ['button', 'a', 'input', 'select', 'textarea', '[onclick]', '[role="button"]'];
|
175
|
+
let annotationIndex = 1;
|
176
|
+
|
177
|
+
interactiveSelectors.forEach(selector => {
|
178
|
+
document.querySelectorAll(selector).forEach(el => {
|
179
|
+
if (el.offsetWidth > 0 && el.offsetHeight > 0) { // Only visible elements
|
180
|
+
const rect = el.getBoundingClientRect();
|
181
|
+
const annotation = document.createElement('div');
|
182
|
+
annotation.className = 'ai-annotation';
|
183
|
+
annotation.textContent = annotationIndex;
|
184
|
+
annotation.style.top = rect.top + window.scrollY - 20 + 'px';
|
185
|
+
annotation.style.left = rect.left + window.scrollX + 'px';
|
186
|
+
|
187
|
+
// Store selector info
|
188
|
+
el.setAttribute('data-ai-index', annotationIndex);
|
189
|
+
|
190
|
+
document.body.appendChild(annotation);
|
191
|
+
annotationIndex++;
|
192
|
+
}
|
193
|
+
});
|
194
|
+
});
|
195
|
+
JS
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
@@ -0,0 +1,224 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_tool"
|
4
|
+
|
5
|
+
module HeadlessBrowserTool
|
6
|
+
module Tools
|
7
|
+
class SearchPageTool < BaseTool
|
8
|
+
tool_name "search_page"
|
9
|
+
description "Search for text or patterns in the current page content"
|
10
|
+
|
11
|
+
arguments do
|
12
|
+
required(:query).filled(:string).description("Text or regex pattern to search for")
|
13
|
+
optional(:case_sensitive).filled(:bool)
|
14
|
+
.description("Whether the search should be case sensitive (default: false)")
|
15
|
+
optional(:regex).filled(:bool).description("Treat query as regex pattern (default: false)")
|
16
|
+
optional(:context_lines).filled(:integer).description("Number of lines to show around matches (default: 2)")
|
17
|
+
optional(:highlight).filled(:bool).description("Highlight matches in the browser (default: false)")
|
18
|
+
end
|
19
|
+
|
20
|
+
def execute(query:, case_sensitive: false, regex: false, context_lines: 2, highlight: false)
|
21
|
+
# Get page text content
|
22
|
+
page_text = browser.text
|
23
|
+
lines = page_text.split("\n")
|
24
|
+
|
25
|
+
# Build search pattern
|
26
|
+
pattern = if regex
|
27
|
+
Regexp.new(query, case_sensitive ? nil : Regexp::IGNORECASE)
|
28
|
+
else
|
29
|
+
escaped_query = Regexp.escape(query)
|
30
|
+
Regexp.new(escaped_query, case_sensitive ? nil : Regexp::IGNORECASE)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Find matches with line numbers
|
34
|
+
matches = []
|
35
|
+
lines.each_with_index do |line, index|
|
36
|
+
next unless line =~ pattern
|
37
|
+
|
38
|
+
match_info = {
|
39
|
+
line_number: index + 1,
|
40
|
+
line: line,
|
41
|
+
matches: line.scan(pattern)
|
42
|
+
}
|
43
|
+
|
44
|
+
# Add context
|
45
|
+
if context_lines.positive?
|
46
|
+
start_idx = [0, index - context_lines].max
|
47
|
+
end_idx = [lines.length - 1, index + context_lines].min
|
48
|
+
match_info[:context] = {
|
49
|
+
before: lines[start_idx...index],
|
50
|
+
after: lines[(index + 1)..end_idx]
|
51
|
+
}
|
52
|
+
end
|
53
|
+
|
54
|
+
matches << match_info
|
55
|
+
end
|
56
|
+
|
57
|
+
# Highlight in browser if requested
|
58
|
+
if highlight && matches.any?
|
59
|
+
highlight_script = build_highlight_script(query, regex, case_sensitive)
|
60
|
+
browser.execute_script(highlight_script)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Build result
|
64
|
+
result = {
|
65
|
+
query: query,
|
66
|
+
total_matches: matches.size,
|
67
|
+
matches: matches.map do |match|
|
68
|
+
output = {
|
69
|
+
line_number: match[:line_number],
|
70
|
+
line: match[:line].strip,
|
71
|
+
occurrences: match[:matches].size
|
72
|
+
}
|
73
|
+
|
74
|
+
if match[:context]
|
75
|
+
output[:context] = {
|
76
|
+
before: match[:context][:before].map(&:strip),
|
77
|
+
after: match[:context][:after].map(&:strip)
|
78
|
+
}
|
79
|
+
end
|
80
|
+
|
81
|
+
output
|
82
|
+
end
|
83
|
+
}
|
84
|
+
|
85
|
+
# Also search in HTML attributes and hidden text if no matches in visible text
|
86
|
+
if matches.empty?
|
87
|
+
html_matches = search_html(pattern)
|
88
|
+
result[:html_matches] = html_matches if html_matches.any?
|
89
|
+
end
|
90
|
+
|
91
|
+
result
|
92
|
+
end
|
93
|
+
|
94
|
+
private
|
95
|
+
|
96
|
+
def search_html(pattern)
|
97
|
+
# Search in page source for hidden content, attributes, etc.
|
98
|
+
page_source = browser.html
|
99
|
+
matches = []
|
100
|
+
|
101
|
+
# Search in common attributes
|
102
|
+
attribute_patterns = [
|
103
|
+
/<[^>]+\s+(?:title|alt|placeholder|aria-label|data-[^=]+)="([^"]*#{pattern}[^"]*)"/i,
|
104
|
+
/<[^>]+\s+(?:href|src|action)="([^"]*#{pattern}[^"]*)"/i,
|
105
|
+
/<meta[^>]+content="([^"]*#{pattern}[^"]*)"/i
|
106
|
+
]
|
107
|
+
|
108
|
+
attribute_patterns.each do |attr_pattern|
|
109
|
+
page_source.scan(attr_pattern) do |match|
|
110
|
+
matches << {
|
111
|
+
type: "attribute",
|
112
|
+
content: match[0],
|
113
|
+
context: ::Regexp.last_match(0)[0..200] # First 200 chars of the matching element
|
114
|
+
}
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
# Search in script tags
|
119
|
+
page_source.scan(%r{<script[^>]*>(.*?)</script>}mi) do |script_content|
|
120
|
+
if script_content[0] =~ pattern
|
121
|
+
matches << {
|
122
|
+
type: "script",
|
123
|
+
content: script_content[0][0..200],
|
124
|
+
match_count: script_content[0].scan(pattern).size
|
125
|
+
}
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
matches
|
130
|
+
end
|
131
|
+
|
132
|
+
def build_highlight_script(query, regex, case_sensitive)
|
133
|
+
<<~JS
|
134
|
+
(function() {
|
135
|
+
// Remove any existing highlights
|
136
|
+
document.querySelectorAll('.hbt-search-highlight').forEach(el => {
|
137
|
+
const parent = el.parentNode;
|
138
|
+
parent.replaceChild(document.createTextNode(el.textContent), el);
|
139
|
+
parent.normalize();
|
140
|
+
});
|
141
|
+
|
142
|
+
// Add highlight styles
|
143
|
+
if (!document.getElementById('hbt-search-styles')) {
|
144
|
+
const style = document.createElement('style');
|
145
|
+
style.id = 'hbt-search-styles';
|
146
|
+
style.textContent = `
|
147
|
+
.hbt-search-highlight {
|
148
|
+
background-color: yellow;
|
149
|
+
color: black;
|
150
|
+
font-weight: bold;
|
151
|
+
padding: 0 2px;
|
152
|
+
border-radius: 2px;
|
153
|
+
}
|
154
|
+
`;
|
155
|
+
document.head.appendChild(style);
|
156
|
+
}
|
157
|
+
|
158
|
+
// Create search pattern
|
159
|
+
const flags = #{case_sensitive ? "'g'" : "'gi'"};
|
160
|
+
const pattern = #{regex ? "new RegExp('#{query.gsub("'", "\\\\'")}')" : "new RegExp('#{Regexp.escape(query).gsub("'", "\\\\'")}')"} + ', ' + flags + ')';
|
161
|
+
|
162
|
+
// Function to highlight text nodes
|
163
|
+
function highlightTextNode(textNode) {
|
164
|
+
const text = textNode.textContent;
|
165
|
+
const matches = text.matchAll(pattern);
|
166
|
+
const matchArray = Array.from(matches);
|
167
|
+
|
168
|
+
if (matchArray.length > 0) {
|
169
|
+
const fragment = document.createDocumentFragment();
|
170
|
+
let lastIndex = 0;
|
171
|
+
|
172
|
+
matchArray.forEach(match => {
|
173
|
+
// Add text before match
|
174
|
+
if (match.index > lastIndex) {
|
175
|
+
fragment.appendChild(
|
176
|
+
document.createTextNode(text.substring(lastIndex, match.index))
|
177
|
+
);
|
178
|
+
}
|
179
|
+
|
180
|
+
// Add highlighted match
|
181
|
+
const span = document.createElement('span');
|
182
|
+
span.className = 'hbt-search-highlight';
|
183
|
+
span.textContent = match[0];
|
184
|
+
fragment.appendChild(span);
|
185
|
+
|
186
|
+
lastIndex = match.index + match[0].length;
|
187
|
+
});
|
188
|
+
|
189
|
+
// Add remaining text
|
190
|
+
if (lastIndex < text.length) {
|
191
|
+
fragment.appendChild(
|
192
|
+
document.createTextNode(text.substring(lastIndex))
|
193
|
+
);
|
194
|
+
}
|
195
|
+
|
196
|
+
textNode.parentNode.replaceChild(fragment, textNode);
|
197
|
+
}
|
198
|
+
}
|
199
|
+
|
200
|
+
// Walk through all text nodes
|
201
|
+
function walkTextNodes(node) {
|
202
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
203
|
+
highlightTextNode(node);
|
204
|
+
} else if (node.nodeType === Node.ELEMENT_NODE &&
|
205
|
+
!['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(node.tagName)) {
|
206
|
+
for (let child of Array.from(node.childNodes)) {
|
207
|
+
walkTextNodes(child);
|
208
|
+
}
|
209
|
+
}
|
210
|
+
}
|
211
|
+
|
212
|
+
walkTextNodes(document.body);
|
213
|
+
|
214
|
+
// Scroll to first match
|
215
|
+
const firstHighlight = document.querySelector('.hbt-search-highlight');
|
216
|
+
if (firstHighlight) {
|
217
|
+
firstHighlight.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
218
|
+
}
|
219
|
+
})();
|
220
|
+
JS
|
221
|
+
end
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
@@ -0,0 +1,148 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_tool"
|
4
|
+
|
5
|
+
module HeadlessBrowserTool
|
6
|
+
module Tools
|
7
|
+
class SearchSourceTool < BaseTool
|
8
|
+
tool_name "search_source"
|
9
|
+
description "Search the page's HTML source code"
|
10
|
+
|
11
|
+
arguments do
|
12
|
+
required(:query).filled(:string).description("Text or regex pattern to search for")
|
13
|
+
optional(:case_sensitive).filled(:bool)
|
14
|
+
.description("Whether the search should be case sensitive (default: false)")
|
15
|
+
optional(:regex).filled(:bool).description("Treat query as regex pattern (default: false)")
|
16
|
+
optional(:context_lines).filled(:integer).description("Number of lines to show around matches (default: 2)")
|
17
|
+
optional(:show_line_numbers).filled(:bool).description("Show line numbers in results (default: true)")
|
18
|
+
end
|
19
|
+
|
20
|
+
def execute(query:, case_sensitive: false, regex: false, context_lines: 2, show_line_numbers: true)
|
21
|
+
# Get page source
|
22
|
+
source = browser.html
|
23
|
+
lines = source.split("\n")
|
24
|
+
|
25
|
+
# Build search pattern
|
26
|
+
pattern = if regex
|
27
|
+
Regexp.new(query, case_sensitive ? nil : Regexp::IGNORECASE)
|
28
|
+
else
|
29
|
+
escaped_query = Regexp.escape(query)
|
30
|
+
Regexp.new(escaped_query, case_sensitive ? nil : Regexp::IGNORECASE)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Find all matches with line context
|
34
|
+
matches = []
|
35
|
+
lines.each_with_index do |line, index|
|
36
|
+
next unless line =~ pattern
|
37
|
+
|
38
|
+
# Get context lines
|
39
|
+
start_idx = [0, index - context_lines].max
|
40
|
+
end_idx = [lines.length - 1, index + context_lines].min
|
41
|
+
|
42
|
+
# Build match entry
|
43
|
+
match_entry = {
|
44
|
+
line_number: index + 1,
|
45
|
+
line: line,
|
46
|
+
match_count: line.scan(pattern).size
|
47
|
+
}
|
48
|
+
|
49
|
+
# Add context with line numbers
|
50
|
+
if context_lines.positive?
|
51
|
+
context_before = []
|
52
|
+
context_after = []
|
53
|
+
|
54
|
+
(start_idx...index).each do |i|
|
55
|
+
context_line = show_line_numbers ? "#{i + 1}: #{lines[i]}" : lines[i]
|
56
|
+
context_before << context_line
|
57
|
+
end
|
58
|
+
|
59
|
+
((index + 1)..end_idx).each do |i|
|
60
|
+
context_line = show_line_numbers ? "#{i + 1}: #{lines[i]}" : lines[i]
|
61
|
+
context_after << context_line
|
62
|
+
end
|
63
|
+
|
64
|
+
match_entry[:context] = {
|
65
|
+
before: context_before,
|
66
|
+
after: context_after
|
67
|
+
}
|
68
|
+
end
|
69
|
+
|
70
|
+
# Highlight matches in the line
|
71
|
+
highlighted_line = line.gsub(pattern) { |match| ">>#{match}<<" }
|
72
|
+
match_entry[:highlighted] = highlighted_line
|
73
|
+
|
74
|
+
matches << match_entry
|
75
|
+
end
|
76
|
+
|
77
|
+
# Analyze match types
|
78
|
+
match_analysis = analyze_matches(matches, lines, pattern)
|
79
|
+
|
80
|
+
# Build result
|
81
|
+
{
|
82
|
+
query: query,
|
83
|
+
total_matches: matches.sum { |m| m[:match_count] },
|
84
|
+
total_lines_with_matches: matches.size,
|
85
|
+
matches: matches.map do |match|
|
86
|
+
result = {
|
87
|
+
line_number: match[:line_number],
|
88
|
+
line: match[:line].strip,
|
89
|
+
highlighted: match[:highlighted].strip,
|
90
|
+
occurrences: match[:match_count]
|
91
|
+
}
|
92
|
+
|
93
|
+
result[:context] = match[:context] if match[:context]
|
94
|
+
|
95
|
+
result
|
96
|
+
end,
|
97
|
+
analysis: match_analysis
|
98
|
+
}
|
99
|
+
end
|
100
|
+
|
101
|
+
private
|
102
|
+
|
103
|
+
def analyze_matches(matches, _lines, pattern)
|
104
|
+
analysis = {
|
105
|
+
tags: {},
|
106
|
+
attributes: {},
|
107
|
+
scripts: 0,
|
108
|
+
styles: 0,
|
109
|
+
comments: 0,
|
110
|
+
text_content: 0
|
111
|
+
}
|
112
|
+
|
113
|
+
matches.each do |match|
|
114
|
+
line = match[:line]
|
115
|
+
|
116
|
+
# Detect what type of content contains the match
|
117
|
+
case line
|
118
|
+
when /<script/i
|
119
|
+
analysis[:scripts] += match[:match_count]
|
120
|
+
when /<style/i
|
121
|
+
analysis[:styles] += match[:match_count]
|
122
|
+
when /<!--/
|
123
|
+
analysis[:comments] += match[:match_count]
|
124
|
+
when /<(\w+)[^>]*>/
|
125
|
+
tag_name = ::Regexp.last_match(1).downcase
|
126
|
+
analysis[:tags][tag_name] ||= 0
|
127
|
+
analysis[:tags][tag_name] += match[:match_count]
|
128
|
+
|
129
|
+
# Check if match is in an attribute
|
130
|
+
# Use the pattern to check for attribute matches
|
131
|
+
line.scan(/(\w+)=["'][^"']*/) do |attr_match|
|
132
|
+
attr_name = attr_match[0].downcase
|
133
|
+
attr_value = ::Regexp.last_match(0)
|
134
|
+
if attr_value =~ pattern
|
135
|
+
analysis[:attributes][attr_name] ||= 0
|
136
|
+
analysis[:attributes][attr_name] += 1
|
137
|
+
end
|
138
|
+
end
|
139
|
+
else
|
140
|
+
analysis[:text_content] += match[:match_count] if line !~ /^\s*</
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
analysis
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_tool"
|
4
|
+
|
5
|
+
module HeadlessBrowserTool
|
6
|
+
module Tools
|
7
|
+
class SelectTool < BaseTool
|
8
|
+
tool_name "select"
|
9
|
+
description "Select an option from a dropdown"
|
10
|
+
|
11
|
+
arguments do
|
12
|
+
required(:value).filled(:string).description("Value or text to select")
|
13
|
+
required(:dropdown_selector).filled(:string).description("CSS selector of the dropdown")
|
14
|
+
end
|
15
|
+
|
16
|
+
def execute(value:, dropdown_selector:)
|
17
|
+
dropdown = browser.find(dropdown_selector)
|
18
|
+
|
19
|
+
# Get all options
|
20
|
+
options = dropdown.all("option").map.with_index do |opt, index|
|
21
|
+
{
|
22
|
+
selector: "#{dropdown_selector} option:nth-of-type(#{index + 1})",
|
23
|
+
value: opt.value,
|
24
|
+
text: opt.text,
|
25
|
+
selected: opt.selected?
|
26
|
+
}
|
27
|
+
end
|
28
|
+
|
29
|
+
browser.select(value, dropdown_selector)
|
30
|
+
|
31
|
+
# Find the newly selected option
|
32
|
+
selected_option = dropdown.all("option").find(&:selected?)
|
33
|
+
|
34
|
+
{
|
35
|
+
dropdown_selector: dropdown_selector,
|
36
|
+
selected_value: selected_option&.value,
|
37
|
+
selected_text: selected_option&.text,
|
38
|
+
options: options,
|
39
|
+
status: "selected"
|
40
|
+
}
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|