unmagic-browser 0.1.0

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.
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Unmagic
6
+ class Browser
7
+ # How a string like `"Sign in"` turns into an element on the page.
8
+ #
9
+ # A locator is a *description* of what to look for, not a lookup — it's the
10
+ # spec handed to the JavaScript matcher that runs in the page. A plain
11
+ # string is resolved by label, then visible text, then ARIA role, falling
12
+ # back to CSS, which is what keeps the API usable by a caller (an LLM, say)
13
+ # that has never seen the page's markup. Naming a strategy explicitly —
14
+ # `css:`, `text:`, `label:`, `role:` — skips straight to it.
15
+ #
16
+ # @example
17
+ # Locator.build("Sign in", purpose: :button)
18
+ # Locator.build(css: "#user_email", purpose: :field)
19
+ class Locator
20
+ # The strategies a caller can name explicitly. `:auto` is the default and
21
+ # tries the others in turn.
22
+ KINDS = [:auto, :css, :text, :label, :role].freeze
23
+
24
+ # What kind of element a verb is after. Narrows the candidate set before
25
+ # any matching happens, so `click_button "Go"` can't land on a paragraph
26
+ # that happens to read "Go".
27
+ PURPOSES = [:any, :button, :link, :field, :checkbox, :radio, :select, :file_field].freeze
28
+
29
+ class << self
30
+ # Build a locator from a verb's arguments.
31
+ #
32
+ # @param query [String, nil] the positional query, resolved by every strategy in turn
33
+ # @param purpose [Symbol] one of {PURPOSES}
34
+ # @param exact [Boolean] require a whole-value match rather than a substring
35
+ # @param options [Hash] at most one of `css:`, `text:`, `label:`, `role:`
36
+ # @return [Locator]
37
+ # @raise [ArgumentError] when no query is given, or more than one strategy is
38
+ def build(query = nil, purpose: :any, exact: false, **options)
39
+ named = options.slice(*KINDS - [:auto])
40
+ raise ArgumentError, "give a query as a string or as one of #{named_list}" if query.nil? && named.empty?
41
+ raise ArgumentError, "give either a query or one of #{named_list}, not both" if query && named.any?
42
+ raise ArgumentError, "give only one of #{named_list}" if named.size > 1
43
+
44
+ if query
45
+ new(kind: :auto, query: query, purpose: purpose, exact: exact)
46
+ else
47
+ kind, value = named.first
48
+ new(kind: kind, query: value, purpose: purpose, exact: exact)
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def named_list
55
+ (KINDS - [:auto]).map { |kind| "#{kind}:" }.join(", ")
56
+ end
57
+ end
58
+
59
+ attr_reader :kind, :query, :purpose, :exact
60
+
61
+ # @param kind [Symbol] one of {KINDS}
62
+ # @param query [String] what to look for
63
+ # @param purpose [Symbol] one of {PURPOSES}
64
+ # @param exact [Boolean] require a whole-value match
65
+ def initialize(kind:, query:, purpose: :any, exact: false)
66
+ raise ArgumentError, "unknown locator kind #{kind.inspect}" unless KINDS.include?(kind)
67
+ raise ArgumentError, "unknown locator purpose #{purpose.inspect}" unless PURPOSES.include?(purpose)
68
+
69
+ @kind = kind
70
+ @query = query.to_s
71
+ @purpose = purpose
72
+ @exact = exact
73
+ freeze
74
+ end
75
+
76
+ # The spec handed to {SCRIPT} in the page.
77
+ #
78
+ # @param all [Boolean] collect every match rather than stopping at the first
79
+ # @return [Hash]
80
+ def to_spec(all: false)
81
+ { kind: @kind.to_s, query: @query, purpose: @purpose.to_s, exact: @exact, all: all }
82
+ end
83
+
84
+ # How this locator reads in an error message.
85
+ #
86
+ # @return [String]
87
+ def to_s
88
+ subject = @purpose == :any ? "element" : @purpose.to_s.tr("_", " ")
89
+ @kind == :auto ? "#{subject} matching #{@query.inspect}" : "#{subject} with #{@kind} #{@query.inspect}"
90
+ end
91
+
92
+ # The matcher, run in the page. Takes a spec and an optional scope element
93
+ # and returns the first match, an array of matches, or null.
94
+ #
95
+ # It works in strategies, tried in order until one produces something:
96
+ # exact name match, then substring, then role, then CSS. "Name" is
97
+ # deliberately broad — an ARIA label, an associated `<label>`, a
98
+ # placeholder, a title, the visible text, the `name` attribute, the `id` —
99
+ # because the caller usually knows what the field is *called*, not how
100
+ # it's marked up.
101
+ SCRIPT = <<~JAVASCRIPT
102
+ (spec, scope) => {
103
+ const root = scope || document;
104
+ const doc = root.ownerDocument || document;
105
+
106
+ const norm = (value) => (value || "").replace(/\\s+/g, " ").trim();
107
+
108
+ const visible = (el) => {
109
+ if (!el || el.nodeType !== 1) return false;
110
+ if (el.hidden) return false;
111
+ const style = getComputedStyle(el);
112
+ if (style.visibility === "hidden" || style.display === "none") return false;
113
+ return el.getClientRects().length > 0;
114
+ };
115
+
116
+ const PURPOSES = {
117
+ any: "*",
118
+ button: "button, input[type=submit], input[type=button], input[type=reset], " +
119
+ "input[type=image], [role=button], summary",
120
+ link: "a[href], [role=link]",
121
+ field: "input:not([type=submit]):not([type=button]):not([type=reset]):not([type=image])" +
122
+ ":not([type=checkbox]):not([type=radio]):not([type=file]):not([type=hidden]), " +
123
+ "textarea, [contenteditable=''], [contenteditable='true']",
124
+ checkbox: "input[type=checkbox], [role=checkbox], [role=switch]",
125
+ radio: "input[type=radio], [role=radio]",
126
+ select: "select, [role=combobox], [role=listbox]",
127
+ file_field: "input[type=file]",
128
+ };
129
+
130
+ const textOf = (el) => {
131
+ const tag = el.tagName;
132
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return "";
133
+ return norm(el.innerText === undefined ? el.textContent : el.innerText);
134
+ };
135
+
136
+ const labelsOf = (el) => {
137
+ const out = [];
138
+ const aria = el.getAttribute("aria-label");
139
+ if (aria) out.push(aria);
140
+ const by = el.getAttribute("aria-labelledby");
141
+ if (by) {
142
+ for (const id of by.split(/\\s+/)) {
143
+ const target = doc.getElementById(id);
144
+ if (target) out.push(target.textContent);
145
+ }
146
+ }
147
+ if (el.labels && el.labels.length) {
148
+ for (const label of el.labels) out.push(label.textContent);
149
+ } else if (el.closest) {
150
+ const ancestor = el.closest("label");
151
+ if (ancestor) out.push(ancestor.textContent);
152
+ }
153
+ for (const attribute of ["placeholder", "title", "alt"]) {
154
+ const value = el.getAttribute(attribute);
155
+ if (value) out.push(value);
156
+ }
157
+ return out;
158
+ };
159
+
160
+ const identifiersOf = (el) => {
161
+ const out = [];
162
+ const type = (el.getAttribute("type") || "").toLowerCase();
163
+ if (el.tagName === "INPUT" && ["submit", "button", "reset"].includes(type)) {
164
+ const value = el.getAttribute("value");
165
+ if (value) out.push(value);
166
+ }
167
+ const name = el.getAttribute("name");
168
+ if (name) out.push(name);
169
+ if (el.id) out.push(el.id);
170
+ return out;
171
+ };
172
+
173
+ const roleOf = (el) => {
174
+ const explicit = el.getAttribute("role");
175
+ if (explicit) return explicit.trim().toLowerCase();
176
+ const tag = el.tagName.toLowerCase();
177
+ const type = (el.getAttribute("type") || "text").toLowerCase();
178
+ if (tag === "button") return "button";
179
+ if (tag === "a") return el.hasAttribute("href") ? "link" : null;
180
+ if (tag === "select") return "combobox";
181
+ if (tag === "textarea") return "textbox";
182
+ if (tag === "img") return "img";
183
+ if (tag === "input") {
184
+ if (["submit", "button", "reset", "image"].includes(type)) return "button";
185
+ if (type === "checkbox") return "checkbox";
186
+ if (type === "radio") return "radio";
187
+ if (type === "search") return "searchbox";
188
+ return "textbox";
189
+ }
190
+ if (/^h[1-6]$/.test(tag)) return "heading";
191
+ return null;
192
+ };
193
+
194
+ const namesOf = (el, kind) => {
195
+ if (kind === "text") return [textOf(el)];
196
+ if (kind === "label") return labelsOf(el);
197
+ return labelsOf(el).concat([textOf(el)]).concat(identifiersOf(el));
198
+ };
199
+
200
+ const select = (selector) => {
201
+ try {
202
+ return Array.from(root.querySelectorAll(selector));
203
+ } catch (error) {
204
+ return [];
205
+ }
206
+ };
207
+
208
+ const needle = norm(spec.query).toLowerCase();
209
+ const purpose = PURPOSES[spec.purpose] || PURPOSES.any;
210
+ const results = [];
211
+
212
+ const collect = (elements, matches) => {
213
+ for (const el of elements) {
214
+ if (results.indexOf(el) !== -1) continue;
215
+ if (!visible(el)) continue;
216
+ if (matches(el)) results.push(el);
217
+ }
218
+ };
219
+
220
+ const byName = (exact) => (el) => namesOf(el, spec.kind).some((name) => {
221
+ const value = norm(name).toLowerCase();
222
+ if (!value || !needle) return false;
223
+ return exact ? value === needle : value.indexOf(needle) !== -1;
224
+ });
225
+
226
+ const byRole = (el) => roleOf(el) === needle;
227
+ const byCss = () => collect(select(spec.query), () => true);
228
+
229
+ const strategies = [];
230
+ if (spec.kind === "css") {
231
+ strategies.push(byCss);
232
+ } else if (spec.kind === "role") {
233
+ strategies.push(() => collect(select(purpose), byRole));
234
+ } else {
235
+ // A query that opens like a CSS selector is one: nothing on a page is
236
+ // labelled ".product", so trying names first would only waste a pass.
237
+ if (spec.kind === "auto" && /^[.#\\[]/.test(spec.query)) strategies.push(byCss);
238
+ strategies.push(() => collect(select(purpose), byName(true)));
239
+ if (!spec.exact) strategies.push(() => collect(select(purpose), byName(false)));
240
+ if (spec.kind === "auto") {
241
+ strategies.push(() => collect(select(purpose), byRole));
242
+ strategies.push(byCss);
243
+ }
244
+ }
245
+
246
+ for (const strategy of strategies) {
247
+ strategy();
248
+ if (results.length) break;
249
+ }
250
+
251
+ return spec.all ? results : (results[0] || null);
252
+ }
253
+ JAVASCRIPT
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+ require "reverse_markdown"
5
+
6
+ module Unmagic
7
+ class Browser
8
+ # Turns a rendered page into Markdown.
9
+ #
10
+ # This is the fallback for drivers with no Markdown endpoint of their own —
11
+ # Cloudflare renders its own server-side, and its output won't match this
12
+ # byte for byte. What both guarantee is the same shape: the page's content
13
+ # without the furniture.
14
+ module Markdown
15
+ # Elements that carry no content a reader wants, and would otherwise show
16
+ # up as noise or as raw JavaScript.
17
+ NOISE = "script, style, noscript, template, svg, iframe, link, meta, head, " \
18
+ "nav, header, footer, [aria-hidden=true], [hidden]"
19
+
20
+ # Where a page's own content usually lives, most specific first.
21
+ CONTENT_ROOTS = ["main", "article", "[role=main]", "body"].freeze
22
+
23
+ class << self
24
+ # Convert rendered HTML to Markdown.
25
+ #
26
+ # @param html [String] the page's HTML, after JavaScript has run
27
+ # @return [String]
28
+ def from_html(html)
29
+ document = Nokogiri::HTML(html.to_s)
30
+ document.css(NOISE).remove
31
+ convert(content_root(document))
32
+ end
33
+
34
+ private
35
+
36
+ def content_root(document)
37
+ CONTENT_ROOTS.each do |selector|
38
+ node = document.at_css(selector)
39
+ return node if node
40
+ end
41
+ document
42
+ end
43
+
44
+ def convert(node)
45
+ ReverseMarkdown
46
+ .convert(node.to_html, unknown_tags: :bypass, github_flavored: true)
47
+ .gsub(/[ \t]+$/, "")
48
+ .gsub(/\n{3,}/, "\n\n")
49
+ .strip
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end