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.
Files changed (74) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/settings.json +21 -0
  3. data/.rubocop.yml +56 -0
  4. data/.ruby-version +1 -0
  5. data/CHANGELOG.md +5 -0
  6. data/CLAUDE.md +298 -0
  7. data/LICENSE.md +7 -0
  8. data/README.md +522 -0
  9. data/Rakefile +12 -0
  10. data/config.ru +8 -0
  11. data/exe/hbt +7 -0
  12. data/lib/headless_browser_tool/browser.rb +374 -0
  13. data/lib/headless_browser_tool/browser_adapter.rb +320 -0
  14. data/lib/headless_browser_tool/cli.rb +34 -0
  15. data/lib/headless_browser_tool/directory_setup.rb +25 -0
  16. data/lib/headless_browser_tool/logger.rb +31 -0
  17. data/lib/headless_browser_tool/server.rb +150 -0
  18. data/lib/headless_browser_tool/session_manager.rb +199 -0
  19. data/lib/headless_browser_tool/session_middleware.rb +158 -0
  20. data/lib/headless_browser_tool/session_persistence.rb +146 -0
  21. data/lib/headless_browser_tool/stdio_server.rb +73 -0
  22. data/lib/headless_browser_tool/strict_session_middleware.rb +88 -0
  23. data/lib/headless_browser_tool/tools/attach_file_tool.rb +40 -0
  24. data/lib/headless_browser_tool/tools/auto_narrate_tool.rb +155 -0
  25. data/lib/headless_browser_tool/tools/base_tool.rb +39 -0
  26. data/lib/headless_browser_tool/tools/check_tool.rb +35 -0
  27. data/lib/headless_browser_tool/tools/choose_tool.rb +56 -0
  28. data/lib/headless_browser_tool/tools/click_button_tool.rb +49 -0
  29. data/lib/headless_browser_tool/tools/click_link_tool.rb +48 -0
  30. data/lib/headless_browser_tool/tools/click_tool.rb +45 -0
  31. data/lib/headless_browser_tool/tools/close_window_tool.rb +31 -0
  32. data/lib/headless_browser_tool/tools/double_click_tool.rb +37 -0
  33. data/lib/headless_browser_tool/tools/drag_tool.rb +46 -0
  34. data/lib/headless_browser_tool/tools/evaluate_script_tool.rb +20 -0
  35. data/lib/headless_browser_tool/tools/execute_script_tool.rb +29 -0
  36. data/lib/headless_browser_tool/tools/fill_in_tool.rb +66 -0
  37. data/lib/headless_browser_tool/tools/find_all_tool.rb +42 -0
  38. data/lib/headless_browser_tool/tools/find_element_tool.rb +21 -0
  39. data/lib/headless_browser_tool/tools/find_elements_containing_text_tool.rb +259 -0
  40. data/lib/headless_browser_tool/tools/get_attribute_tool.rb +21 -0
  41. data/lib/headless_browser_tool/tools/get_current_path_tool.rb +16 -0
  42. data/lib/headless_browser_tool/tools/get_current_url_tool.rb +16 -0
  43. data/lib/headless_browser_tool/tools/get_narration_history_tool.rb +35 -0
  44. data/lib/headless_browser_tool/tools/get_page_context_tool.rb +188 -0
  45. data/lib/headless_browser_tool/tools/get_page_source_tool.rb +16 -0
  46. data/lib/headless_browser_tool/tools/get_page_title_tool.rb +16 -0
  47. data/lib/headless_browser_tool/tools/get_session_info_tool.rb +37 -0
  48. data/lib/headless_browser_tool/tools/get_text_tool.rb +20 -0
  49. data/lib/headless_browser_tool/tools/get_value_tool.rb +20 -0
  50. data/lib/headless_browser_tool/tools/get_window_handles_tool.rb +29 -0
  51. data/lib/headless_browser_tool/tools/go_back_tool.rb +29 -0
  52. data/lib/headless_browser_tool/tools/go_forward_tool.rb +29 -0
  53. data/lib/headless_browser_tool/tools/has_element_tool.rb +21 -0
  54. data/lib/headless_browser_tool/tools/has_text_tool.rb +21 -0
  55. data/lib/headless_browser_tool/tools/hover_tool.rb +38 -0
  56. data/lib/headless_browser_tool/tools/is_visible_tool.rb +20 -0
  57. data/lib/headless_browser_tool/tools/maximize_window_tool.rb +34 -0
  58. data/lib/headless_browser_tool/tools/open_new_window_tool.rb +25 -0
  59. data/lib/headless_browser_tool/tools/refresh_tool.rb +32 -0
  60. data/lib/headless_browser_tool/tools/resize_window_tool.rb +43 -0
  61. data/lib/headless_browser_tool/tools/right_click_tool.rb +37 -0
  62. data/lib/headless_browser_tool/tools/save_page_tool.rb +32 -0
  63. data/lib/headless_browser_tool/tools/screenshot_tool.rb +199 -0
  64. data/lib/headless_browser_tool/tools/search_page_tool.rb +224 -0
  65. data/lib/headless_browser_tool/tools/search_source_tool.rb +148 -0
  66. data/lib/headless_browser_tool/tools/select_tool.rb +44 -0
  67. data/lib/headless_browser_tool/tools/switch_to_window_tool.rb +30 -0
  68. data/lib/headless_browser_tool/tools/uncheck_tool.rb +35 -0
  69. data/lib/headless_browser_tool/tools/visit_tool.rb +27 -0
  70. data/lib/headless_browser_tool/tools/visual_diff_tool.rb +177 -0
  71. data/lib/headless_browser_tool/tools.rb +104 -0
  72. data/lib/headless_browser_tool/version.rb +5 -0
  73. data/lib/headless_browser_tool.rb +8 -0
  74. 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