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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +51 -0
- data/LICENSE +21 -0
- data/README.md +372 -0
- data/lib/unmagic/browser/configuration.rb +55 -0
- data/lib/unmagic/browser/console/banner.rb +82 -0
- data/lib/unmagic/browser/console/graphics.rb +145 -0
- data/lib/unmagic/browser/console/helpers.rb +325 -0
- data/lib/unmagic/browser/console/prompt.rb +152 -0
- data/lib/unmagic/browser/console/renderer.rb +205 -0
- data/lib/unmagic/browser/console/terminal.rb +108 -0
- data/lib/unmagic/browser/console/values.rb +78 -0
- data/lib/unmagic/browser/console.rb +161 -0
- data/lib/unmagic/browser/driver/base.rb +146 -0
- data/lib/unmagic/browser/driver/cloudflare/transport.rb +67 -0
- data/lib/unmagic/browser/driver/cloudflare.rb +214 -0
- data/lib/unmagic/browser/driver/connection.rb +75 -0
- data/lib/unmagic/browser/driver/local.rb +87 -0
- data/lib/unmagic/browser/driver/remote.rb +45 -0
- data/lib/unmagic/browser/driver.rb +75 -0
- data/lib/unmagic/browser/element.rb +257 -0
- data/lib/unmagic/browser/emulation.rb +248 -0
- data/lib/unmagic/browser/error.rb +44 -0
- data/lib/unmagic/browser/failures.rb +76 -0
- data/lib/unmagic/browser/locator.rb +256 -0
- data/lib/unmagic/browser/markdown.rb +54 -0
- data/lib/unmagic/browser/session.rb +588 -0
- data/lib/unmagic/browser/version.rb +8 -0
- data/lib/unmagic/browser.rb +214 -0
- metadata +105 -0
|
@@ -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
|