ask-web-fetch 0.7.1 → 0.7.3
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/backend.rb +19 -2
- data/lib/ask/web_fetch/backends/browser.rb +17 -1
- data/lib/ask/web_fetch/backends/local.rb +94 -5
- data/lib/ask/web_fetch/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b2dd2d3ee4ae5d53f7e1bf441d3b598be5195d3aafd58119213b7b739ad900db
|
|
4
|
+
data.tar.gz: 49d8aa803e8f4871e278d6fbe7ad71b6a6826c5afe62c8dc8f38abc2b8b4cb8e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 95f40993df27de4c059f085ece2d71b5ea8c1e72c2d3d3dc42e96d25a1d5d87a400f5905cbaf6685675eb064ddebe95fa110609f16b93f586a5a31e02e17917c
|
|
7
|
+
data.tar.gz: a5b8513eef5cad48fbed247a2578e662c42a401b7b8e16028e5b6fbfa90d6b28da9e3db5786c20d43182ececf5a490ff98338a4fbf3ba286b0e4f9123210dea2
|
|
@@ -66,7 +66,14 @@ module Ask
|
|
|
66
66
|
# challenge/interstitial pages carry these markers, while legitimate
|
|
67
67
|
# pages can contain the word "captcha" in unrelated config/JS (e.g.
|
|
68
68
|
# Wikipedia embeds an hcaptcha edit-config flag on every page).
|
|
69
|
-
|
|
69
|
+
# Cf-chl: a Turnstile form widget uses `cf-chl-widget-*` + `cf-turnstile-response`
|
|
70
|
+
# (a per-form CAPTCHA, not the `cf-chl` managed challenge that gates
|
|
71
|
+
# the whole page), so matching bare `cf-chl` on the body misclassifies
|
|
72
|
+
# every Turnstile form (openai.com/form/codex-for-oss) as a challenge.
|
|
73
|
+
# Accept both the classic managed-challenge markers (`challenge-platform`,
|
|
74
|
+
# `_cf_chl_opt`) and the bare `cf-chl` id, but the plain widget id is
|
|
75
|
+
# explicitly NOT a challenge — see challenge_page? below.
|
|
76
|
+
CHALLENGE_RE = /just a moment|checking your browser|cf-chl|challenge-platform|_cf_chl_opt/i
|
|
70
77
|
|
|
71
78
|
# Registrar parking-page markers: the page is an ad for a parked
|
|
72
79
|
# (for-sale) domain, not the site's content. A content company must
|
|
@@ -159,7 +166,17 @@ module Ask
|
|
|
159
166
|
private
|
|
160
167
|
|
|
161
168
|
def challenge_page?(body)
|
|
162
|
-
body.to_s
|
|
169
|
+
text = body.to_s
|
|
170
|
+
return false unless text.match?(CHALLENGE_RE)
|
|
171
|
+
# Bare `cf-chl-widget-*` is a Turnstile form widget, not the
|
|
172
|
+
# Cloudflare managed challenge interstitial. The interstitial's
|
|
173
|
+
# `cf-chl` comes with `challenge-platform` / `_cf_chl_opt` /
|
|
174
|
+
# "just a moment" next to it; a page whose only hit is the widget
|
|
175
|
+
# id (openai.com form pages) is NOT a challenge.
|
|
176
|
+
return false if text.include?('cf-chl-widget') &&
|
|
177
|
+
!text.match?(/challenge-platform|_cf_chl_opt|just a moment|checking your browser/i)
|
|
178
|
+
|
|
179
|
+
true
|
|
163
180
|
end
|
|
164
181
|
|
|
165
182
|
def parked_domain?(body)
|
|
@@ -96,6 +96,12 @@ module Ask
|
|
|
96
96
|
browser_mutex.synchronize { @browser ||= build_browser }
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
+
# Drops the shared browser so the next use builds a fresh
|
|
100
|
+
# session — called when the current one died mid-fetch.
|
|
101
|
+
def reset_browser
|
|
102
|
+
browser_mutex.synchronize { @browser = nil }
|
|
103
|
+
end
|
|
104
|
+
|
|
99
105
|
def content_filter
|
|
100
106
|
@content_filter ||= ContentFilter.default
|
|
101
107
|
end
|
|
@@ -162,7 +168,17 @@ module Ask
|
|
|
162
168
|
|
|
163
169
|
page = self.class.browser.create_page
|
|
164
170
|
fetch_attempt(page, url)
|
|
165
|
-
rescue Ferrum::
|
|
171
|
+
rescue Ferrum::DeadBrowserError => e
|
|
172
|
+
# The shared browser died mid-fetch (the browserless server
|
|
173
|
+
# killed the session's browser). A dead browser is transient —
|
|
174
|
+
# the next session starts a fresh one — so reconnect and retry
|
|
175
|
+
# ONCE instead of failing the page (observed 2026-08-14: the
|
|
176
|
+
# server's session limits killed browsers and pages were
|
|
177
|
+
# silently dropped).
|
|
178
|
+
self.class.reset_browser
|
|
179
|
+
page = self.class.browser.create_page
|
|
180
|
+
fetch_attempt(page, url)
|
|
181
|
+
rescue Ferrum::TimeoutError, Ferrum::ProcessTimeoutError => e
|
|
166
182
|
raise TimeoutError, "#{e.class}: #{e.message}"
|
|
167
183
|
rescue Ferrum::StatusError => e
|
|
168
184
|
raise FetchError, "browser could not load #{url}: #{e.message}"
|
|
@@ -38,7 +38,33 @@ module Ask
|
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
# Agent-first content negotiation: many sites now serve clean
|
|
42
|
+
# markdown when asked via Accept: text/markdown, a .md URL twin,
|
|
43
|
+
# or an /llms.txt manifest. We probe these low-cost paths before
|
|
44
|
+
# falling back to full HTML scrape + DOM conversion.
|
|
45
|
+
#
|
|
46
|
+
# Order: (1) Accept: text/markdown on the original URL — the
|
|
47
|
+
# cheapest probe, one extra GET; (2) the .md twin — Mintlify-
|
|
48
|
+
# style sites redirect .md with content-type text/plain; (3) the
|
|
49
|
+
# full HTML scrape. llms.txt manifests are upstream of individual
|
|
50
|
+
# pages (they index the site) and are tried by the MCP tool
|
|
51
|
+
# layer, not per-URL — that avoids duplicate fetches when the
|
|
52
|
+
# same manifest covers multiple URLs.
|
|
41
53
|
def fetch(url)
|
|
54
|
+
# Probe 1: server content negotiation
|
|
55
|
+
md_body, md_ct, md_redirect = fetch_markdown(url)
|
|
56
|
+
if md_body && !md_body.empty?
|
|
57
|
+
return assemble_page(md_body, url, md_redirect, source: :accept_header)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Probe 2: .md URL twin (Mintlify, Docusaurus, some Hugo sites)
|
|
61
|
+
twin = "#{url.chomp('/')}.md"
|
|
62
|
+
md_body, md_ct, md_redirect = fetch_markdown(twin)
|
|
63
|
+
if md_body && !md_body.empty?
|
|
64
|
+
return assemble_page(md_body, url, md_redirect, source: :md_twin, twin_url: twin)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Probe 3: full HTML scrape (legacy path)
|
|
42
68
|
body, content_type, redirect = fetch_html(url)
|
|
43
69
|
raise FetchError, "expected HTML from #{url}, got #{content_type}" unless content_type.include?('html')
|
|
44
70
|
raise FetchError, "challenge page at #{url}" if challenge_page?(body)
|
|
@@ -63,7 +89,7 @@ module Ask
|
|
|
63
89
|
# and keeps a partial page from ever being stored as the real
|
|
64
90
|
# thing. Two detectors: known framework markers, or a large
|
|
65
91
|
# HTML page with almost no server-rendered text.
|
|
66
|
-
if js_app_shell?(body) && page[:content].length <
|
|
92
|
+
if js_app_shell?(body) && page[:content].length < SHELL_DEFER_THRESHOLD
|
|
67
93
|
raise EmptyContentError,
|
|
68
94
|
"JS-app shell at #{url} — server HTML renders only #{page[:content].length} chars; a rendering backend is required"
|
|
69
95
|
end
|
|
@@ -82,10 +108,19 @@ module Ask
|
|
|
82
108
|
# specific footprints of React/Vue/Next/Nuxt app shells.
|
|
83
109
|
JS_APP_SHELL_MARKERS = /id=["'](?:root|app|__next|site-content)["']|__NEXT_DATA__|window\.__NUXT__|ng-app|data-reactroot/
|
|
84
110
|
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
# the
|
|
111
|
+
# A framework marker (React/Vue/Next) alone is NOT emptiness: a
|
|
112
|
+
# marked page can server-render real content (careers.abb job
|
|
113
|
+
# pages: 2,162 chars of job description) and must be stored as-is
|
|
114
|
+
# when the rendering backends cannot do better — dropping it lost
|
|
115
|
+
# real pages (2026-08-14). The shell deferral fires only when the
|
|
116
|
+
# extraction is genuinely little: below this, the page is a true
|
|
117
|
+
# shell (airbnb: 613KB HTML -> 143 chars). Tunable; the chain
|
|
118
|
+
# turns the signal into "prefer Browser for this URL".
|
|
119
|
+
SHELL_DEFER_THRESHOLD = 500
|
|
120
|
+
|
|
121
|
+
# The ratio detector's own bar, kept for compatibility with the
|
|
122
|
+
# comment below (large HTML + near-empty text is a shell whatever
|
|
123
|
+
# the framework).
|
|
89
124
|
SHELL_CONTENT_THRESHOLD = 4_000
|
|
90
125
|
|
|
91
126
|
# A page whose server HTML is large but yields almost no text is a
|
|
@@ -174,6 +209,60 @@ module Ask
|
|
|
174
209
|
def redirect_info(status, uri)
|
|
175
210
|
status && { status: status, url: uri.to_s }
|
|
176
211
|
end
|
|
212
|
+
|
|
213
|
+
# Probes +url+ with Accept: text/markdown. Returns
|
|
214
|
+
# [body, content_type, redirect] on a text/markdown response, or
|
|
215
|
+
# nils when the server returned HTML (or anything else the caller
|
|
216
|
+
# shouldn't treat as agent-native). Follows one redirect hop —
|
|
217
|
+
# enough for Mintlify's 307 → .md twin.
|
|
218
|
+
def fetch_markdown(url)
|
|
219
|
+
uri = URI(url)
|
|
220
|
+
response = self.class.http.get(
|
|
221
|
+
uri.to_s,
|
|
222
|
+
headers: { 'accept' => 'text/markdown' }
|
|
223
|
+
)
|
|
224
|
+
return [nil, nil, nil] unless response
|
|
225
|
+
return [nil, nil, nil] if response.status >= 400
|
|
226
|
+
|
|
227
|
+
redirect_info = nil
|
|
228
|
+
|
|
229
|
+
# Follow a single redirect (Mintlify 307 → .md twin)
|
|
230
|
+
if (300..399).cover?(response.status) && !response.location.empty?
|
|
231
|
+
redirect_uri = URI.join(uri, response.location)
|
|
232
|
+
redirect_info = { status: response.status, url: redirect_uri.to_s }
|
|
233
|
+
response = self.class.http.get(
|
|
234
|
+
redirect_uri.to_s,
|
|
235
|
+
headers: { 'accept' => 'text/markdown' }
|
|
236
|
+
)
|
|
237
|
+
return [nil, nil, nil] unless response && response.status == 200
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
ct = response.content_type.to_s.downcase
|
|
241
|
+
return [nil, nil, nil] unless ct.include?('text/markdown') && response.status == 200
|
|
242
|
+
|
|
243
|
+
[response.body, ct, redirect_info]
|
|
244
|
+
rescue StandardError
|
|
245
|
+
[nil, nil, nil]
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Assembles a page hash from agent-native markdown (Accept or .md
|
|
249
|
+
# twin), skipping the HTML→markdown conversion pipeline. Runs the
|
|
250
|
+
# shared guards (parked domain, minimum content) so downstream
|
|
251
|
+
# behavior is identical regardless of source.
|
|
252
|
+
def assemble_page(markdown, url, redirect, source:, twin_url: nil)
|
|
253
|
+
source_url = twin_url || url
|
|
254
|
+
page = {
|
|
255
|
+
title: nil,
|
|
256
|
+
description: nil,
|
|
257
|
+
content: Markdown.clean(markdown),
|
|
258
|
+
redirected: redirect,
|
|
259
|
+
licenses: [],
|
|
260
|
+
outlinks: markdown_outlinks(markdown, source_url)
|
|
261
|
+
}
|
|
262
|
+
guard_page!(url, page[:content])
|
|
263
|
+
|
|
264
|
+
page
|
|
265
|
+
end
|
|
177
266
|
end
|
|
178
267
|
end
|
|
179
268
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-web-fetch
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.7.
|
|
4
|
+
version: 0.7.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -171,7 +171,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
171
171
|
- !ruby/object:Gem::Version
|
|
172
172
|
version: '0'
|
|
173
173
|
requirements: []
|
|
174
|
-
rubygems_version: 4.0.
|
|
174
|
+
rubygems_version: 4.0.18
|
|
175
175
|
specification_version: 4
|
|
176
176
|
summary: Web fetch library for the ask-rb ecosystem
|
|
177
177
|
test_files: []
|