ask-web-fetch 0.5.4 → 0.5.6
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 +4 -4
- data/lib/ask/web_fetch/backends/browser.rb +62 -11
- data/lib/ask/web_fetch/backends/local.rb +47 -0
- data/lib/ask/web_fetch/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a4c0b6735b967e38e8b837e7128bb501875ea1ce239f2df1549518396740ba8e
|
|
4
|
+
data.tar.gz: 0c6b95aba29f4914d062b35b3e21a43d1616a95ab01111a8d81d73326bd7f985
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6f7f82bc5b93d368395989049757d86675e89d7645f7b351f3d24edff50a8303ee86cdc04dec9240a9e71ef2f6f7dedd0d6641da93ddc3b9d5494b116fa5ccd0
|
|
7
|
+
data.tar.gz: c160aac9363740829eaceeb53b18e91b009dcdc278b194d7774ba692b3b6d14d3404cb5b4f38e0d50fb73a62390fad7b2e0bbc1ca60a47d8dce8de8c1e745072
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'ferrum'
|
|
4
4
|
require 'json'
|
|
5
5
|
require 'net/http'
|
|
6
|
+
require 'set'
|
|
6
7
|
require 'uri'
|
|
7
8
|
require_relative '../backend'
|
|
8
9
|
require_relative '../content_filter'
|
|
@@ -80,6 +81,13 @@ module Ask
|
|
|
80
81
|
|
|
81
82
|
attr_writer :challenge_timeout, :poll_interval
|
|
82
83
|
|
|
84
|
+
# Domains already given a warm pass this process — a failed warm
|
|
85
|
+
# (DataDome-class wall) is not re-paid at CHALLENGE_TIMEOUT on
|
|
86
|
+
# every fetch of that domain.
|
|
87
|
+
def warmed_domains
|
|
88
|
+
@warmed_domains ||= Set.new
|
|
89
|
+
end
|
|
90
|
+
|
|
83
91
|
# A shared browser, built once and reused. Ferrum is loaded lazily
|
|
84
92
|
# so consumers who never hit this backend pay nothing for it.
|
|
85
93
|
def browser
|
|
@@ -153,6 +161,30 @@ module Ask
|
|
|
153
161
|
raise FetchError, 'ferrum gem unavailable' unless self.class.browser
|
|
154
162
|
|
|
155
163
|
page = self.class.browser.create_page
|
|
164
|
+
fetch_attempt(page, url)
|
|
165
|
+
rescue Ferrum::TimeoutError, Ferrum::ProcessTimeoutError, Ferrum::DeadBrowserError => e
|
|
166
|
+
raise TimeoutError, "#{e.class}: #{e.message}"
|
|
167
|
+
rescue Ferrum::StatusError => e
|
|
168
|
+
raise FetchError, "browser could not load #{url}: #{e.message}"
|
|
169
|
+
rescue Ferrum::Error => e
|
|
170
|
+
raise ServerError, "browser #{e.class}: #{e.message}"
|
|
171
|
+
rescue Errno::ECONNREFUSED, SocketError => e
|
|
172
|
+
raise TimeoutError, "browser connection #{e.class}: #{e.message}"
|
|
173
|
+
ensure
|
|
174
|
+
page&.close
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# One fetch of +url+ on the page, with the warm-and-retry pass: a
|
|
178
|
+
# challenge page means this domain hasn't issued the profile a
|
|
179
|
+
# clearance cookie yet. Visiting the DOMAIN ROOT first (where a
|
|
180
|
+
# managed challenge auto-solves for a trusted browser) earns the
|
|
181
|
+
# cookie — pinned to this browser + IP and persisted in the
|
|
182
|
+
# profile — then the URL is retried once. Subsequent fetches for
|
|
183
|
+
# the same domain find the cookie and never warm again. Bounded:
|
|
184
|
+
# one warm per domain per process (warmed_domains), one retry per
|
|
185
|
+
# fetch — a still-challenged URL fails as before, never wedging
|
|
186
|
+
# the queue on a DataDome-class wall.
|
|
187
|
+
def fetch_attempt(page, url, warmed: false)
|
|
156
188
|
page.go_to(url)
|
|
157
189
|
wait_for_challenge(page)
|
|
158
190
|
wait_for_idle(page)
|
|
@@ -161,23 +193,42 @@ module Ask
|
|
|
161
193
|
raise FetchError, "got #{status} at #{url}" if status && status >= 400
|
|
162
194
|
|
|
163
195
|
body = page.body
|
|
164
|
-
|
|
196
|
+
if challenge_page?(body)
|
|
197
|
+
raise FetchError, "challenge page at #{url}" if warmed
|
|
198
|
+
raise FetchError, "challenge page at #{url}" unless warm_domain(page, url)
|
|
199
|
+
|
|
200
|
+
return fetch_attempt(page, url, warmed: true)
|
|
201
|
+
end
|
|
165
202
|
|
|
166
203
|
result = Markdown.generate(body, base_url: url, filter: self.class.content_filter)
|
|
167
204
|
result[:outlinks] = outlink_urls(body, url)
|
|
168
205
|
raise EmptyContentError, "no readable content at #{url}" unless usable_content?(result[:content])
|
|
169
206
|
|
|
170
207
|
result
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Earns the domain's clearance cookie: navigates to the domain
|
|
211
|
+
# root (the challenge JS runs there, not on the deep URL), waits
|
|
212
|
+
# for the managed challenge to auto-solve, and lets the cookie
|
|
213
|
+
# land in the profile. Returns true when the domain is now warm.
|
|
214
|
+
# Domains already attempted this process are skipped — a failed
|
|
215
|
+
# warm is not re-paid at CHALLENGE_TIMEOUT per fetch.
|
|
216
|
+
def warm_domain(page, url)
|
|
217
|
+
domain = URI(url).host
|
|
218
|
+
return false if self.class.warmed_domains.include?(domain)
|
|
219
|
+
|
|
220
|
+
self.class.warmed_domains << domain
|
|
221
|
+
page.go_to(domain_root(url))
|
|
222
|
+
wait_for_challenge(page)
|
|
223
|
+
wait_for_idle(page)
|
|
224
|
+
true
|
|
225
|
+
rescue Ferrum::Error, URI::InvalidURIError
|
|
226
|
+
false
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def domain_root(url)
|
|
230
|
+
uri = URI(url)
|
|
231
|
+
"#{uri.scheme}://#{uri.host}"
|
|
181
232
|
end
|
|
182
233
|
|
|
183
234
|
private
|
|
@@ -46,6 +46,19 @@ module Ask
|
|
|
46
46
|
page = to_markdown(body, url)
|
|
47
47
|
page[:redirected] = redirect
|
|
48
48
|
raise EmptyContentError, "no readable content at #{url}" unless usable_content?(page[:content])
|
|
49
|
+
# The completeness signal: a JS-app shell whose server HTML
|
|
50
|
+
# renders little is a TRUNCATED page, not a complete one — the
|
|
51
|
+
# real content awaits client-side JS that Local cannot run.
|
|
52
|
+
# Storing the shell as success would silently under-deliver
|
|
53
|
+
# (airbnb: 613KB server HTML -> 143 chars of markdown); failing
|
|
54
|
+
# through lets the chain prefer a rendering backend (Browser),
|
|
55
|
+
# and keeps a partial page from ever being stored as the real
|
|
56
|
+
# thing. Two detectors: known framework markers, or a large
|
|
57
|
+
# HTML page with almost no server-rendered text.
|
|
58
|
+
if js_app_shell?(body) && page[:content].length < SHELL_CONTENT_THRESHOLD
|
|
59
|
+
raise EmptyContentError,
|
|
60
|
+
"JS-app shell at #{url} — server HTML renders only #{page[:content].length} chars; a rendering backend is required"
|
|
61
|
+
end
|
|
49
62
|
|
|
50
63
|
page
|
|
51
64
|
rescue Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError, URI::InvalidURIError => e
|
|
@@ -55,6 +68,40 @@ module Ask
|
|
|
55
68
|
raise TimeoutError, "#{e.class}: #{e.message}"
|
|
56
69
|
end
|
|
57
70
|
|
|
71
|
+
# Client-rendered framework markers: the server HTML is (at least
|
|
72
|
+
# partly) a shell awaiting JS. Deliberately narrow — generic terms
|
|
73
|
+
# like "script" or "root" appear on every page; these are the
|
|
74
|
+
# specific footprints of React/Vue/Next/Nuxt app shells.
|
|
75
|
+
JS_APP_SHELL_MARKERS = /id=["'](?:root|app|__next|site-content)["']|__NEXT_DATA__|window\.__NUXT__|ng-app|data-reactroot/
|
|
76
|
+
|
|
77
|
+
# Markdown below this from a JS-app shell is "server sent a shell",
|
|
78
|
+
# not "page is genuinely short" — a real page (even a short one)
|
|
79
|
+
# is usually server-rendered above this. Tunable; the chain turns
|
|
80
|
+
# the signal into "prefer Browser for this URL".
|
|
81
|
+
SHELL_CONTENT_THRESHOLD = 4_000
|
|
82
|
+
|
|
83
|
+
# A page whose server HTML is large but yields almost no text is a
|
|
84
|
+
# shell whatever framework it uses (airbnb: 613KB HTML -> 143 chars
|
|
85
|
+
# of markdown). Framework markers miss these; the size ratio
|
|
86
|
+
# catches them. nytimes (1.4MB -> 7.5k text) stays above the text
|
|
87
|
+
# floor and is correctly left to Local.
|
|
88
|
+
SHELL_HTML_BYTES = 20_000
|
|
89
|
+
|
|
90
|
+
def js_app_shell?(body)
|
|
91
|
+
body.to_s.match?(JS_APP_SHELL_MARKERS) ||
|
|
92
|
+
(body.to_s.bytesize > SHELL_HTML_BYTES && markdown_visible_chars(body) < SHELL_CONTENT_THRESHOLD)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A cheap text estimate from the raw HTML (tags stripped) — the
|
|
96
|
+
# "how much did the server actually render" number. Deliberately
|
|
97
|
+
# rough: it only feeds a shell-vs-page heuristic.
|
|
98
|
+
def markdown_visible_chars(body)
|
|
99
|
+
body.to_s.gsub(/<script[\s\S]*?<\/script>/i, "")
|
|
100
|
+
.gsub(/<style[\s\S]*?<\/style>/i, "")
|
|
101
|
+
.gsub(/<[^>]+>/, " ")
|
|
102
|
+
.gsub(/\s+/, " ").strip.length
|
|
103
|
+
end
|
|
104
|
+
|
|
58
105
|
# Parses +html+ and returns { title:, description:, content:,
|
|
59
106
|
# licenses:, outlinks: } where content is clean markdown (pruned by
|
|
60
107
|
# the ContentFilter), licenses are the page's declared license
|