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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: efa999aac8d4321c2e8e6d463ace6c3f5ec5e95a62973ad06593c4714e1ffab1
4
- data.tar.gz: 9a9e59e8da81bb4238bf1a08b0ba84634553dfd3caac99763a7bb52a54d9ac6e
3
+ metadata.gz: b2dd2d3ee4ae5d53f7e1bf441d3b598be5195d3aafd58119213b7b739ad900db
4
+ data.tar.gz: 49d8aa803e8f4871e278d6fbe7ad71b6a6826c5afe62c8dc8f38abc2b8b4cb8e
5
5
  SHA512:
6
- metadata.gz: af1e312f91b9e5ea72679b7e877232cc3e4e9639b3fdf3e98291810377a450a5ce180bb4664ca651c8ccd2084220632890da92fe4eaf8c1ca472e6ba464d60ff
7
- data.tar.gz: 3951989986d476f6bc008712e6451ccb148c9fbe33537544061486d15c79443e0368dd668ef40b35287173ad41f569fc519130a9cb5368e78da16eafbcdf2c91
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
- CHALLENGE_RE = /just a moment|checking your browser|cf-chl/i
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.match?(CHALLENGE_RE)
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::TimeoutError, Ferrum::ProcessTimeoutError, Ferrum::DeadBrowserError => e
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 < SHELL_CONTENT_THRESHOLD
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
- # Markdown below this from a JS-app shell is "server sent a shell",
86
- # not "page is genuinely short" — a real page (even a short one)
87
- # is usually server-rendered above this. Tunable; the chain turns
88
- # the signal into "prefer Browser for this URL".
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module WebFetch
5
- VERSION = '0.7.1'
5
+ VERSION = '0.7.3'
6
6
  end
7
7
  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.1
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.3
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: []